this repo has no description
1import * as fs from "node:fs/promises";
2import { command } from "cmd-ts";
3import {
4 intro,
5 outro,
6 note,
7 text,
8 confirm,
9 select,
10 spinner,
11 log,
12 group,
13} from "@clack/prompts";
14import * as path from "node:path";
15import { findConfig, generateConfigTemplate } from "../lib/config";
16import { loadCredentials, listAllCredentials } from "../lib/credentials";
17import { createAgent, createPublication } from "../lib/atproto";
18import { selectCredential } from "../lib/credential-select";
19import type { FrontmatterMapping, BlueskyConfig } from "../lib/types";
20import { fileExists } from "../lib/utils";
21
22const onCancel = () => {
23 outro("Setup cancelled");
24 process.exit(0);
25};
26
27export const initCommand = command({
28 name: "init",
29 description: "Initialize a new publisher configuration",
30 args: {},
31 handler: async () => {
32 intro("Sequoia Configuration Setup");
33
34 // Check if config already exists
35 const existingConfig = await findConfig();
36 if (existingConfig) {
37 const overwrite = await confirm({
38 message: `Config already exists at ${existingConfig}. Overwrite?`,
39 initialValue: false,
40 });
41 if (overwrite === Symbol.for("cancel")) {
42 onCancel();
43 }
44 if (!overwrite) {
45 log.info("Keeping existing configuration");
46 return;
47 }
48 }
49
50 note("Follow the prompts to build your config for publishing", "Setup");
51
52 // Site configuration group
53 const siteConfig = await group(
54 {
55 siteUrl: () =>
56 text({
57 message: "Site URL (canonical URL of your site):",
58 placeholder: "https://example.com",
59 validate: (value) => {
60 if (!value) return "Site URL is required";
61 try {
62 new URL(value);
63 } catch {
64 return "Please enter a valid URL";
65 }
66 },
67 }),
68 contentDir: () =>
69 text({
70 message: "Content directory:",
71 placeholder: "./src/content/blog",
72 }),
73 imagesDir: () =>
74 text({
75 message: "Cover images directory (leave empty to skip):",
76 placeholder: "./src/assets",
77 }),
78 publicDir: () =>
79 text({
80 message: "Public/static directory (for .well-known files):",
81 placeholder: "./public",
82 }),
83 outputDir: () =>
84 text({
85 message: "Build output directory (for link tag injection):",
86 placeholder: "./dist",
87 }),
88 },
89 { onCancel },
90 );
91
92 log.info(
93 "Configure your frontmatter field mappings (press Enter to use defaults):",
94 );
95
96 // Frontmatter mapping group
97 const frontmatterConfig = await group(
98 {
99 titleField: () =>
100 text({
101 message: "Field name for title:",
102 defaultValue: "title",
103 placeholder: "title",
104 }),
105 descField: () =>
106 text({
107 message: "Field name for description:",
108 defaultValue: "description",
109 placeholder: "description",
110 }),
111 dateField: () =>
112 text({
113 message: "Field name for publish date:",
114 defaultValue: "publishDate",
115 placeholder: "publishDate, pubDate, date, etc.",
116 }),
117 coverField: () =>
118 text({
119 message: "Field name for cover image:",
120 defaultValue: "ogImage",
121 placeholder: "ogImage, coverImage, image, hero, etc.",
122 }),
123 tagsField: () =>
124 text({
125 message: "Field name for tags:",
126 defaultValue: "tags",
127 placeholder: "tags, categories, keywords, etc.",
128 }),
129 draftField: () =>
130 text({
131 message: "Field name for draft status:",
132 defaultValue: "draft",
133 placeholder: "draft, private, hidden, etc.",
134 }),
135 },
136 { onCancel },
137 );
138
139 // Build frontmatter mapping object
140 const fieldMappings: Array<[keyof FrontmatterMapping, string, string]> = [
141 ["title", frontmatterConfig.titleField, "title"],
142 ["description", frontmatterConfig.descField, "description"],
143 ["publishDate", frontmatterConfig.dateField, "publishDate"],
144 ["coverImage", frontmatterConfig.coverField, "ogImage"],
145 ["tags", frontmatterConfig.tagsField, "tags"],
146 ["draft", frontmatterConfig.draftField, "draft"],
147 ];
148
149 const builtMapping = fieldMappings.reduce<FrontmatterMapping>(
150 (acc, [key, value, defaultValue]) => {
151 if (value !== defaultValue) {
152 acc[key] = value;
153 }
154 return acc;
155 },
156 {},
157 );
158
159 // Only keep frontmatterMapping if it has any custom fields
160 const frontmatterMapping =
161 Object.keys(builtMapping).length > 0 ? builtMapping : undefined;
162
163 // Publication setup
164 const publicationChoice = await select({
165 message: "Publication setup:",
166 options: [
167 { label: "Create a new publication", value: "create" },
168 { label: "Use an existing publication AT URI", value: "existing" },
169 ],
170 });
171
172 if (publicationChoice === Symbol.for("cancel")) {
173 onCancel();
174 }
175
176 let publicationUri: string;
177 let credentials = await loadCredentials();
178
179 if (publicationChoice === "create") {
180 // Need credentials to create a publication
181 if (!credentials) {
182 // Check if there are multiple identities - if so, prompt to select
183 const allCredentials = await listAllCredentials();
184 if (allCredentials.length > 1) {
185 credentials = await selectCredential(allCredentials);
186 } else if (allCredentials.length === 1) {
187 // Single credential exists but couldn't be loaded - try to load it explicitly
188 credentials = await selectCredential(allCredentials);
189 } else {
190 log.error(
191 "You must authenticate first. Run 'sequoia login' (recommended) or 'sequoia auth' before creating a publication.",
192 );
193 process.exit(1);
194 }
195 }
196
197 if (!credentials) {
198 log.error(
199 "Could not load credentials. Try running 'sequoia login' again to re-authenticate.",
200 );
201 process.exit(1);
202 }
203
204 const s = spinner();
205 s.start("Connecting to ATProto...");
206 let agent: Awaited<ReturnType<typeof createAgent>> | undefined;
207 try {
208 agent = await createAgent(credentials);
209 s.stop("Connected!");
210 } catch (_error) {
211 s.stop("Failed to connect");
212 log.error(
213 "Failed to connect. Try re-authenticating with 'sequoia login' or 'sequoia auth'.",
214 );
215 process.exit(1);
216 }
217
218 const publicationConfig = await group(
219 {
220 name: () =>
221 text({
222 message: "Publication name:",
223 placeholder: "My Blog",
224 validate: (value) => {
225 if (!value) return "Publication name is required";
226 },
227 }),
228 description: () =>
229 text({
230 message: "Publication description (optional):",
231 placeholder: "A blog about...",
232 }),
233 iconPath: () =>
234 text({
235 message: "Icon image path (leave empty to skip):",
236 placeholder: "./public/favicon.png",
237 }),
238 showInDiscover: () =>
239 confirm({
240 message: "Show in Discover feed?",
241 initialValue: true,
242 }),
243 },
244 { onCancel },
245 );
246
247 s.start("Creating publication...");
248 try {
249 publicationUri = await createPublication(agent, {
250 url: siteConfig.siteUrl,
251 name: publicationConfig.name,
252 description: publicationConfig.description || undefined,
253 iconPath: publicationConfig.iconPath || undefined,
254 showInDiscover: publicationConfig.showInDiscover,
255 });
256 s.stop(`Publication created: ${publicationUri}`);
257 } catch (error) {
258 s.stop("Failed to create publication");
259 log.error(`Failed to create publication: ${error}`);
260 process.exit(1);
261 }
262 } else {
263 const uri = await text({
264 message: "Publication AT URI:",
265 placeholder: "at://did:plc:.../site.standard.publication/...",
266 validate: (value) => {
267 if (!value) return "Publication URI is required";
268 },
269 });
270
271 if (uri === Symbol.for("cancel")) {
272 onCancel();
273 }
274 publicationUri = uri as string;
275 }
276
277 // Bluesky posting configuration
278 const enableBluesky = await confirm({
279 message: "Enable automatic Bluesky posting when publishing?",
280 initialValue: false,
281 });
282
283 if (enableBluesky === Symbol.for("cancel")) {
284 onCancel();
285 }
286
287 let blueskyConfig: BlueskyConfig | undefined;
288 if (enableBluesky) {
289 const maxAgeDaysInput = await text({
290 message: "Maximum age (in days) for posts to be shared on Bluesky:",
291 defaultValue: "7",
292 placeholder: "7",
293 validate: (value) => {
294 if (!value) {
295 return "Please enter a number";
296 }
297 const num = Number.parseInt(value, 10);
298 if (Number.isNaN(num) || num < 1) {
299 return "Please enter a positive number";
300 }
301 },
302 });
303
304 if (maxAgeDaysInput === Symbol.for("cancel")) {
305 onCancel();
306 }
307
308 const maxAgeDays = parseInt(maxAgeDaysInput as string, 10);
309 blueskyConfig = {
310 enabled: true,
311 ...(maxAgeDays !== 7 && { maxAgeDays }),
312 };
313 }
314
315 // Get PDS URL from credentials (only available for app-password auth)
316 const pdsUrl =
317 credentials?.type === "app-password" ? credentials.pdsUrl : undefined;
318
319 // Generate config file
320 const configContent = generateConfigTemplate({
321 siteUrl: siteConfig.siteUrl,
322 contentDir: siteConfig.contentDir || "./content",
323 imagesDir: siteConfig.imagesDir || undefined,
324 publicDir: siteConfig.publicDir || "./public",
325 outputDir: siteConfig.outputDir || "./dist",
326 publicationUri,
327 pdsUrl,
328 frontmatter: frontmatterMapping,
329 bluesky: blueskyConfig,
330 });
331
332 const configPath = path.join(process.cwd(), "sequoia.json");
333 await fs.writeFile(configPath, configContent);
334
335 log.success(`Configuration saved to ${configPath}`);
336
337 // Create .well-known/site.standard.publication file
338 const publicDir = siteConfig.publicDir || "./public";
339 const resolvedPublicDir = path.isAbsolute(publicDir)
340 ? publicDir
341 : path.join(process.cwd(), publicDir);
342 const wellKnownDir = path.join(resolvedPublicDir, ".well-known");
343 const wellKnownPath = path.join(wellKnownDir, "site.standard.publication");
344
345 // Ensure .well-known directory exists
346 await fs.mkdir(wellKnownDir, { recursive: true });
347 await fs.writeFile(path.join(wellKnownDir, ".gitkeep"), "");
348 await fs.writeFile(wellKnownPath, publicationUri);
349
350 log.success(`Created ${wellKnownPath}`);
351
352 // Update .gitignore
353 const gitignorePath = path.join(process.cwd(), ".gitignore");
354 const stateFilename = ".sequoia-state.json";
355
356 if (await fileExists(gitignorePath)) {
357 const gitignoreContent = await fs.readFile(gitignorePath, "utf-8");
358 if (!gitignoreContent.includes(stateFilename)) {
359 await fs.writeFile(
360 gitignorePath,
361 `${gitignoreContent}\n${stateFilename}\n`,
362 );
363 log.info(`Added ${stateFilename} to .gitignore`);
364 }
365 } else {
366 await fs.writeFile(gitignorePath, `${stateFilename}\n`);
367 log.info(`Created .gitignore with ${stateFilename}`);
368 }
369
370 note(
371 "Next steps:\n" +
372 "1. Run 'sequoia publish --dry-run' to preview\n" +
373 "2. Run 'sequoia publish' to publish your content",
374 "Setup complete!",
375 );
376
377 outro("Happy publishing!");
378 },
379});