packages/cli/src/commands/init.ts 8.7 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 } 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
			},
142
			{ onCancel },
143
		);
144
145
		// Build frontmatter mapping object
146
		const fieldMappings: Array<[keyof FrontmatterMapping, string, string]> = [
147
			["title", frontmatterConfig.titleField, "title"],
148
			["description", frontmatterConfig.descField, "description"],
149
			["publishDate", frontmatterConfig.dateField, "publishDate"],
150
			["coverImage", frontmatterConfig.coverField, "ogImage"],
151
			["tags", frontmatterConfig.tagsField, "tags"],
152
		];
153
154
		const builtMapping = fieldMappings.reduce<FrontmatterMapping>(
155
			(acc, [key, value, defaultValue]) => {
156
				if (value !== defaultValue) {
157
					acc[key] = value;
158
				}
159
				return acc;
160
			},
161
			{},
162
		);
163
164
		// Only keep frontmatterMapping if it has any custom fields
165
		const frontmatterMapping =
166
			Object.keys(builtMapping).length > 0 ? builtMapping : undefined;
167
168
		// Publication setup
169
		const publicationChoice = await select({
170
			message: "Publication setup:",
171
			options: [
172
				{ label: "Create a new publication", value: "create" },
173
				{ label: "Use an existing publication AT URI", value: "existing" },
174
			],
175
		});
176
177
		if (publicationChoice === Symbol.for("cancel")) {
178
			onCancel();
179
		}
180
181
		let publicationUri: string;
182
		const credentials = await loadCredentials();
183
184
		if (publicationChoice === "create") {
185
			// Need credentials to create a publication
186
			if (!credentials) {
187
				log.error(
188
					"You must authenticate first. Run 'sequoia auth' before creating a publication.",
189
				);
190
				process.exit(1);
191
			}
192
193
			const s = spinner();
194
			s.start("Connecting to ATProto...");
195
			let agent;
196
			try {
197
				agent = await createAgent(credentials);
198
				s.stop("Connected!");
199
			} catch (error) {
200
				s.stop("Failed to connect");
201
				log.error(
202
					"Failed to connect. Check your credentials with 'sequoia auth'.",
203
				);
204
				process.exit(1);
205
			}
206
207
			const publicationConfig = await group(
208
				{
209
					name: () =>
210
						text({
211
							message: "Publication name:",
212
							placeholder: "My Blog",
213
							validate: (value) => {
214
								if (!value) return "Publication name is required";
215
							},
216
						}),
217
					description: () =>
218
						text({
219
							message: "Publication description (optional):",
220
							placeholder: "A blog about...",
221
						}),
222
					iconPath: () =>
223
						text({
224
							message: "Icon image path (leave empty to skip):",
225
							placeholder: "./public/favicon.png",
226
						}),
227
					showInDiscover: () =>
228
						confirm({
229
							message: "Show in Discover feed?",
230
							initialValue: true,
231
						}),
232
				},
233
				{ onCancel },
234
			);
235
236
			s.start("Creating publication...");
237
			try {
238
				publicationUri = await createPublication(agent, {
239
					url: siteConfig.siteUrl,
240
					name: publicationConfig.name,
241
					description: publicationConfig.description || undefined,
242
					iconPath: publicationConfig.iconPath || undefined,
243
					showInDiscover: publicationConfig.showInDiscover,
244
				});
245
				s.stop(`Publication created: ${publicationUri}`);
246
			} catch (error) {
247
				s.stop("Failed to create publication");
248
				log.error(`Failed to create publication: ${error}`);
249
				process.exit(1);
250
			}
251
		} else {
252
			const uri = await text({
253
				message: "Publication AT URI:",
254
				placeholder: "at://did:plc:.../site.standard.publication/...",
255
				validate: (value) => {
256
					if (!value) return "Publication URI is required";
257
				},
258
			});
259
260
			if (uri === Symbol.for("cancel")) {
261
				onCancel();
262
			}
263
			publicationUri = uri as string;
264
		}
265
266
		// Get PDS URL from credentials (already loaded earlier)
267
		const pdsUrl = credentials?.pdsUrl;
268
269
		// Generate config file
270
		const configContent = generateConfigTemplate({
271
			siteUrl: siteConfig.siteUrl,
272
			contentDir: siteConfig.contentDir || "./content",
273
			imagesDir: siteConfig.imagesDir || undefined,
274
			publicDir: siteConfig.publicDir || "./public",
275
			outputDir: siteConfig.outputDir || "./dist",
276
			pathPrefix: siteConfig.pathPrefix || "/posts",
277
			publicationUri,
278
			pdsUrl,
279
			frontmatter: frontmatterMapping,
280
		});
281
282
		const configPath = path.join(process.cwd(), "sequoia.json");
283
		await fs.writeFile(configPath, configContent);
284
285
		log.success(`Configuration saved to ${configPath}`);
286
287
		// Create .well-known/site.standard.publication file
288
		const publicDir = siteConfig.publicDir || "./public";
289
		const resolvedPublicDir = path.isAbsolute(publicDir)
290
			? publicDir
291
			: path.join(process.cwd(), publicDir);
292
		const wellKnownDir = path.join(resolvedPublicDir, ".well-known");
293
		const wellKnownPath = path.join(wellKnownDir, "site.standard.publication");
294
295
		// Ensure .well-known directory exists
296
		await fs.mkdir(wellKnownDir, { recursive: true });
297
		await fs.writeFile(path.join(wellKnownDir, ".gitkeep"), "");
298
		await fs.writeFile(wellKnownPath, publicationUri);
299
300
		log.success(`Created ${wellKnownPath}`);
301
302
		// Update .gitignore
303
		const gitignorePath = path.join(process.cwd(), ".gitignore");
304
		const stateFilename = ".sequoia-state.json";
305
306
		if (await fileExists(gitignorePath)) {
307
			const gitignoreContent = await fs.readFile(gitignorePath, "utf-8");
308
			if (!gitignoreContent.includes(stateFilename)) {
309
				await fs.writeFile(
310
					gitignorePath,
311
					gitignoreContent + `\n${stateFilename}\n`,
312
				);
313
				log.info(`Added ${stateFilename} to .gitignore`);
314
			}
315
		} else {
316
			await fs.writeFile(gitignorePath, `${stateFilename}\n`);
317
			log.info(`Created .gitignore with ${stateFilename}`);
318
		}
319
320
		note(
321
			"Next steps:\n" +
322
				"1. Run 'sequoia publish --dry-run' to preview\n" +
323
				"2. Run 'sequoia publish' to publish your content",
324
			"Setup complete!",
325
		);
326
327
		outro("Happy publishing!");
328
	},
329
});