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