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