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