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