packages/cli/src/commands/init.ts 10.8 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
			},
102
			{ onCancel },
103
		);
104
105
		log.info(
106
			"Configure your frontmatter field mappings (press Enter to use defaults):",
107
		);
108
109
		// Frontmatter mapping group
110
		const frontmatterConfig = await group(
111
			{
112
				titleField: () =>
113
					text({
114
						message: "Field name for title:",
115
						defaultValue: "title",
116
						placeholder: "title",
117
					}),
118
				descField: () =>
119
					text({
120
						message: "Field name for description:",
121
						defaultValue: "description",
122
						placeholder: "description",
123
					}),
124
				dateField: () =>
125
					text({
126
						message: "Field name for publish date:",
127
						defaultValue: "publishDate",
128
						placeholder: "publishDate, pubDate, date, etc.",
129
					}),
130
				coverField: () =>
131
					text({
132
						message: "Field name for cover image:",
133
						defaultValue: "ogImage",
134
						placeholder: "ogImage, coverImage, image, hero, etc.",
135
					}),
136
				tagsField: () =>
137
					text({
138
						message: "Field name for tags:",
139
						defaultValue: "tags",
140
						placeholder: "tags, categories, keywords, etc.",
141
					}),
142
				draftField: () =>
143
					text({
144
						message: "Field name for draft status:",
145
						defaultValue: "draft",
146
						placeholder: "draft, private, hidden, etc.",
147
					}),
148
			},
149
			{ onCancel },
150
		);
151
152
		// Build frontmatter mapping object
153
		const fieldMappings: Array<[keyof FrontmatterMapping, string, string]> = [
154
			["title", frontmatterConfig.titleField, "title"],
155
			["description", frontmatterConfig.descField, "description"],
156
			["publishDate", frontmatterConfig.dateField, "publishDate"],
157
			["coverImage", frontmatterConfig.coverField, "ogImage"],
158
			["tags", frontmatterConfig.tagsField, "tags"],
159
			["draft", frontmatterConfig.draftField, "draft"],
160
		];
161
162
		const builtMapping = fieldMappings.reduce<FrontmatterMapping>(
163
			(acc, [key, value, defaultValue]) => {
164
				if (value !== defaultValue) {
165
					acc[key] = value;
166
				}
167
				return acc;
168
			},
169
			{},
170
		);
171
172
		// Only keep frontmatterMapping if it has any custom fields
173
		const frontmatterMapping =
174
			Object.keys(builtMapping).length > 0 ? builtMapping : undefined;
175
176
		// Publication setup
177
		const publicationChoice = await select({
178
			message: "Publication setup:",
179
			options: [
180
				{ label: "Create a new publication", value: "create" },
181
				{ label: "Use an existing publication AT URI", value: "existing" },
182
			],
183
		});
184
185
		if (publicationChoice === Symbol.for("cancel")) {
186
			onCancel();
187
		}
188
189
		let publicationUri: string;
190
		let credentials = await loadCredentials();
191
192
		if (publicationChoice === "create") {
193
			// Need credentials to create a publication
194
			if (!credentials) {
195
				// Check if there are multiple identities - if so, prompt to select
196
				const allCredentials = await listAllCredentials();
197
				if (allCredentials.length > 1) {
198
					credentials = await selectCredential(allCredentials);
199
				} else if (allCredentials.length === 1) {
200
					// Single credential exists but couldn't be loaded - try to load it explicitly
201
					credentials = await selectCredential(allCredentials);
202
				} else {
203
					log.error(
204
						"You must authenticate first. Run 'sequoia login' (recommended) or 'sequoia auth' before creating a publication.",
205
					);
206
					process.exit(1);
207
				}
208
			}
209
210
			if (!credentials) {
211
				log.error(
212
					"Could not load credentials. Try running 'sequoia login' again to re-authenticate.",
213
				);
214
				process.exit(1);
215
			}
216
217
			const s = spinner();
218
			s.start("Connecting to ATProto...");
219
			let agent: Awaited<ReturnType<typeof createAgent>> | undefined;
220
			try {
221
				agent = await createAgent(credentials);
222
				s.stop("Connected!");
223
			} catch (_error) {
224
				s.stop("Failed to connect");
225
				log.error(
226
					"Failed to connect. Try re-authenticating with 'sequoia login' or 'sequoia auth'.",
227
				);
228
				process.exit(1);
229
			}
230
231
			const publicationConfig = await group(
232
				{
233
					name: () =>
234
						text({
235
							message: "Publication name:",
236
							placeholder: "My Blog",
237
							validate: (value) => {
238
								if (!value) return "Publication name is required";
239
							},
240
						}),
241
					description: () =>
242
						text({
243
							message: "Publication description (optional):",
244
							placeholder: "A blog about...",
245
						}),
246
					iconPath: () =>
247
						text({
248
							message: "Icon image path (leave empty to skip):",
249
							placeholder: "./public/favicon.png",
250
						}),
251
					showInDiscover: () =>
252
						confirm({
253
							message: "Show in Discover feed?",
254
							initialValue: true,
255
						}),
256
				},
257
				{ onCancel },
258
			);
259
260
			s.start("Creating publication...");
261
			try {
262
				publicationUri = await createPublication(agent, {
263
					url: siteConfig.siteUrl,
264
					name: publicationConfig.name,
265
					description: publicationConfig.description || undefined,
266
					iconPath: publicationConfig.iconPath || undefined,
267
					showInDiscover: publicationConfig.showInDiscover,
268
				});
269
				s.stop(`Publication created: ${publicationUri}`);
270
			} catch (error) {
271
				s.stop("Failed to create publication");
272
				log.error(`Failed to create publication: ${error}`);
273
				process.exit(1);
274
			}
275
		} else {
276
			const uri = await text({
277
				message: "Publication AT URI:",
278
				placeholder: "at://did:plc:.../site.standard.publication/...",
279
				validate: (value) => {
280
					if (!value) return "Publication URI is required";
281
				},
282
			});
283
284
			if (uri === Symbol.for("cancel")) {
285
				onCancel();
286
			}
287
			publicationUri = uri as string;
288
		}
289
290
		// Bluesky posting configuration
291
		const enableBluesky = await confirm({
292
			message: "Enable automatic Bluesky posting when publishing?",
293
			initialValue: false,
294
		});
295
296
		if (enableBluesky === Symbol.for("cancel")) {
297
			onCancel();
298
		}
299
300
		let blueskyConfig: BlueskyConfig | undefined;
301
		if (enableBluesky) {
302
			const maxAgeDaysInput = await text({
303
				message: "Maximum age (in days) for posts to be shared on Bluesky:",
304
				defaultValue: "7",
305
				placeholder: "7",
306
				validate: (value) => {
307
					if (!value) {
308
						return "Please enter a number";
309
					}
310
					const num = Number.parseInt(value, 10);
311
					if (Number.isNaN(num) || num < 1) {
312
						return "Please enter a positive number";
313
					}
314
				},
315
			});
316
317
			if (maxAgeDaysInput === Symbol.for("cancel")) {
318
				onCancel();
319
			}
320
321
			const maxAgeDays = parseInt(maxAgeDaysInput as string, 10);
322
			blueskyConfig = {
323
				enabled: true,
324
				...(maxAgeDays !== 7 && { maxAgeDays }),
325
			};
326
		}
327
328
		// Get PDS URL from credentials (only available for app-password auth)
329
		const pdsUrl =
330
			credentials?.type === "app-password" ? credentials.pdsUrl : undefined;
331
332
		// Generate config file
333
		const configContent = generateConfigTemplate({
334
			siteUrl: siteConfig.siteUrl,
335
			contentDir: siteConfig.contentDir || "./content",
336
			imagesDir: siteConfig.imagesDir || undefined,
337
			publicDir: siteConfig.publicDir || "./public",
338
			outputDir: siteConfig.outputDir || "./dist",
339
			pathPrefix: siteConfig.pathPrefix || "/posts",
340
			publicationUri,
341
			pdsUrl,
342
			frontmatter: frontmatterMapping,
343
			bluesky: blueskyConfig,
344
		});
345
346
		const configPath = path.join(process.cwd(), "sequoia.json");
347
		await fs.writeFile(configPath, configContent);
348
349
		log.success(`Configuration saved to ${configPath}`);
350
351
		// Create .well-known/site.standard.publication file
352
		const publicDir = siteConfig.publicDir || "./public";
353
		const resolvedPublicDir = path.isAbsolute(publicDir)
354
			? publicDir
355
			: path.join(process.cwd(), publicDir);
356
		const wellKnownDir = path.join(resolvedPublicDir, ".well-known");
357
		const wellKnownPath = path.join(wellKnownDir, "site.standard.publication");
358
359
		// Ensure .well-known directory exists
360
		await fs.mkdir(wellKnownDir, { recursive: true });
361
		await fs.writeFile(path.join(wellKnownDir, ".gitkeep"), "");
362
		await fs.writeFile(wellKnownPath, publicationUri);
363
364
		log.success(`Created ${wellKnownPath}`);
365
366
		// Update .gitignore
367
		const gitignorePath = path.join(process.cwd(), ".gitignore");
368
		const stateFilename = ".sequoia-state.json";
369
370
		if (await fileExists(gitignorePath)) {
371
			const gitignoreContent = await fs.readFile(gitignorePath, "utf-8");
372
			if (!gitignoreContent.includes(stateFilename)) {
373
				await fs.writeFile(
374
					gitignorePath,
375
					`${gitignoreContent}\n${stateFilename}\n`,
376
				);
377
				log.info(`Added ${stateFilename} to .gitignore`);
378
			}
379
		} else {
380
			await fs.writeFile(gitignorePath, `${stateFilename}\n`);
381
			log.info(`Created .gitignore with ${stateFilename}`);
382
		}
383
384
		note(
385
			"Next steps:\n" +
386
				"1. Run 'sequoia publish --dry-run' to preview\n" +
387
				"2. Run 'sequoia publish' to publish your content",
388
			"Setup complete!",
389
		);
390
391
		outro("Happy publishing!");
392
	},
393
});