chore: checkpoint 00ae004c
Steve · 2026-01-29 23:19 1 file(s) · +166 −168
packages/cli/src/commands/init.ts +166 −168
8 8
	select,
9 9
	spinner,
10 10
	log,
11 +
	group,
11 12
} from "@clack/prompts";
12 13
import * as path from "path";
13 14
import { findConfig, generateConfigTemplate } from "../lib/config";
14 15
import { loadCredentials } from "../lib/credentials";
15 16
import { createAgent, createPublication } from "../lib/atproto";
16 17
import type { FrontmatterMapping } from "../lib/types";
17 -
import { exitOnCancel } from "../lib/prompts";
18 +
19 +
const onCancel = () => {
20 +
	outro("Setup cancelled");
21 +
	process.exit(0);
22 +
};
18 23
19 24
export const initCommand = command({
20 25
	name: "init",
26 31
		// Check if config already exists
27 32
		const existingConfig = await findConfig();
28 33
		if (existingConfig) {
29 -
			const overwrite = exitOnCancel(
30 -
				await confirm({
31 -
					message: `Config already exists at ${existingConfig}. Overwrite?`,
32 -
					initialValue: false,
33 -
				}),
34 -
			);
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 +
			}
35 41
			if (!overwrite) {
36 42
				log.info("Keeping existing configuration");
37 43
				return;
40 46
41 47
		note("Follow the prompts to build your config for publishing", "Setup");
42 48
43 -
		const siteUrl = exitOnCancel(
44 -
			await text({
45 -
				message: "Site URL (canonical URL of your site):",
46 -
				placeholder: "https://example.com",
47 -
			}),
48 -
		);
49 -
50 -
		if (!siteUrl) {
51 -
			log.error("Site URL is required");
52 -
			process.exit(1);
53 -
		}
54 -
55 -
		const contentDir = exitOnCancel(
56 -
			await text({
57 -
				message: "Content directory:",
58 -
				placeholder: "./src/content/blog",
59 -
			}),
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 },
60 92
		);
61 93
62 -
		const imagesDir = exitOnCancel(
63 -
			await text({
64 -
				message: "Cover images directory (leave empty to skip):",
65 -
				placeholder: "./src/assets",
66 -
			}),
67 -
		);
68 -
69 -
		// Public/static directory for .well-known files
70 -
		const publicDir = exitOnCancel(
71 -
			await text({
72 -
				message: "Public/static directory (for .well-known files):",
73 -
				placeholder: "./public",
74 -
			}),
75 -
		);
76 -
77 -
		// Output directory for inject command
78 -
		const outputDir = exitOnCancel(
79 -
			await text({
80 -
				message: "Build output directory (for link tag injection):",
81 -
				placeholder: "./dist",
82 -
			}),
83 -
		);
84 -
85 -
		// Path prefix for posts
86 -
		const pathPrefix = exitOnCancel(
87 -
			await text({
88 -
				message: "URL path prefix for posts:",
89 -
				placeholder: "/posts, /blog, /articles, etc.",
90 -
			}),
91 -
		);
92 -
93 -
		// Frontmatter mapping configuration
94 94
		log.info(
95 95
			"Configure your frontmatter field mappings (press Enter to use defaults):",
96 96
		);
97 97
98 -
		const titleField = exitOnCancel(
99 -
			await text({
100 -
				message: "Field name for title:",
101 -
				defaultValue: "title",
102 -
				placeholder: "title",
103 -
			}),
104 -
		);
105 -
106 -
		const descField = exitOnCancel(
107 -
			await text({
108 -
				message: "Field name for description:",
109 -
				defaultValue: "description",
110 -
				placeholder: "description",
111 -
			}),
112 -
		);
113 -
114 -
		const dateField = exitOnCancel(
115 -
			await text({
116 -
				message: "Field name for publish date:",
117 -
				defaultValue: "publishDate",
118 -
				placeholder: "publishDate, pubDate, date, etc.",
119 -
			}),
120 -
		);
121 -
122 -
		const coverField = exitOnCancel(
123 -
			await text({
124 -
				message: "Field name for cover image:",
125 -
				defaultValue: "ogImage",
126 -
				placeholder: "ogImage, coverImage, image, hero, etc.",
127 -
			}),
128 -
		);
129 -
130 -
		const tagsField = exitOnCancel(
131 -
			await text({
132 -
				message: "Field name for tags:",
133 -
				defaultValue: "tags",
134 -
				placeholder: "tags, categories, keywords, etc.",
135 -
			}),
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 },
136 133
		);
137 134
135 +
		// Build frontmatter mapping object
138 136
		let frontmatterMapping: FrontmatterMapping | undefined = {};
139 137
140 -
		if (titleField && titleField !== "title") {
141 -
			frontmatterMapping.title = titleField;
138 +
		if (frontmatterConfig.titleField !== "title") {
139 +
			frontmatterMapping.title = frontmatterConfig.titleField;
142 140
		}
143 -
		if (descField && descField !== "description") {
144 -
			frontmatterMapping.description = descField;
141 +
		if (frontmatterConfig.descField !== "description") {
142 +
			frontmatterMapping.description = frontmatterConfig.descField;
145 143
		}
146 -
		if (dateField && dateField !== "publishDate") {
147 -
			frontmatterMapping.publishDate = dateField;
144 +
		if (frontmatterConfig.dateField !== "publishDate") {
145 +
			frontmatterMapping.publishDate = frontmatterConfig.dateField;
148 146
		}
149 -
		if (coverField && coverField !== "ogImage") {
150 -
			frontmatterMapping.coverImage = coverField;
147 +
		if (frontmatterConfig.coverField !== "ogImage") {
148 +
			frontmatterMapping.coverImage = frontmatterConfig.coverField;
151 149
		}
152 -
		if (tagsField && tagsField !== "tags") {
153 -
			frontmatterMapping.tags = tagsField;
150 +
		if (frontmatterConfig.tagsField !== "tags") {
151 +
			frontmatterMapping.tags = frontmatterConfig.tagsField;
154 152
		}
155 153
156 154
		// Only keep frontmatterMapping if it has any custom fields
159 157
		}
160 158
161 159
		// Publication setup
162 -
		const publicationChoice = exitOnCancel(
163 -
			await select({
164 -
				message: "Publication setup:",
165 -
				options: [
166 -
					{ label: "Create a new publication", value: "create" },
167 -
					{ label: "Use an existing publication AT URI", value: "existing" },
168 -
				],
169 -
			}),
170 -
		);
160 +
		const publicationChoice = await select({
161 +
			message: "Publication setup:",
162 +
			options: [
163 +
				{ label: "Create a new publication", value: "create" },
164 +
				{ label: "Use an existing publication AT URI", value: "existing" },
165 +
			],
166 +
		});
167 +
168 +
		if (publicationChoice === Symbol.for("cancel")) {
169 +
			onCancel();
170 +
		}
171 171
172 172
		let publicationUri: string;
173 -
		let credentials = await loadCredentials();
173 +
		const credentials = await loadCredentials();
174 174
175 175
		if (publicationChoice === "create") {
176 176
			// Need credentials to create a publication
195 195
				process.exit(1);
196 196
			}
197 197
198 -
			const pubName = exitOnCancel(
199 -
				await text({
200 -
					message: "Publication name:",
201 -
					placeholder: "My Blog",
202 -
				}),
203 -
			);
204 -
205 -
			if (!pubName) {
206 -
				log.error("Publication name is required");
207 -
				process.exit(1);
208 -
			}
209 -
210 -
			const pubDescription = exitOnCancel(
211 -
				await text({
212 -
					message: "Publication description (optional):",
213 -
					placeholder: "A blog about...",
214 -
				}),
215 -
			);
216 -
217 -
			const iconPath = exitOnCancel(
218 -
				await pathPrompt({
219 -
					message: "Icon image path (leave empty to skip):",
220 -
				}),
221 -
			);
222 -
223 -
			const showInDiscover = exitOnCancel(
224 -
				await confirm({
225 -
					message: "Show in Discover feed?",
226 -
					initialValue: true,
227 -
				}),
198 +
			const publicationConfig = await group(
199 +
				{
200 +
					name: () =>
201 +
						text({
202 +
							message: "Publication name:",
203 +
							placeholder: "My Blog",
204 +
							validate: (value) => {
205 +
								if (!value) return "Publication name is required";
206 +
							},
207 +
						}),
208 +
					description: () =>
209 +
						text({
210 +
							message: "Publication description (optional):",
211 +
							placeholder: "A blog about...",
212 +
						}),
213 +
					iconPath: () =>
214 +
						text({
215 +
							message: "Icon image path (leave empty to skip):",
216 +
							placeholder: "./public/favicon.png",
217 +
						}),
218 +
					showInDiscover: () =>
219 +
						confirm({
220 +
							message: "Show in Discover feed?",
221 +
							initialValue: true,
222 +
						}),
223 +
				},
224 +
				{ onCancel },
228 225
			);
229 226
230 227
			s.start("Creating publication...");
231 228
			try {
232 229
				publicationUri = await createPublication(agent, {
233 -
					url: siteUrl,
234 -
					name: pubName,
235 -
					description: pubDescription || undefined,
236 -
					iconPath: iconPath || undefined,
237 -
					showInDiscover,
230 +
					url: siteConfig.siteUrl,
231 +
					name: publicationConfig.name,
232 +
					description: publicationConfig.description || undefined,
233 +
					iconPath: publicationConfig.iconPath || undefined,
234 +
					showInDiscover: publicationConfig.showInDiscover,
238 235
				});
239 236
				s.stop(`Publication created: ${publicationUri}`);
240 237
			} catch (error) {
243 240
				process.exit(1);
244 241
			}
245 242
		} else {
246 -
			const uri = exitOnCancel(
247 -
				await text({
248 -
					message: "Publication AT URI:",
249 -
					placeholder: "at://did:plc:.../site.standard.publication/...",
250 -
				}),
251 -
			);
243 +
			const uri = await text({
244 +
				message: "Publication AT URI:",
245 +
				placeholder: "at://did:plc:.../site.standard.publication/...",
246 +
				validate: (value) => {
247 +
					if (!value) return "Publication URI is required";
248 +
				},
249 +
			});
252 250
253 -
			if (!uri) {
254 -
				log.error("Publication URI is required");
255 -
				process.exit(1);
251 +
			if (uri === Symbol.for("cancel")) {
252 +
				onCancel();
256 253
			}
257 -
			publicationUri = uri;
254 +
			publicationUri = uri as string;
258 255
		}
259 256
260 257
		// Get PDS URL from credentials (already loaded earlier)
262 259
263 260
		// Generate config file
264 261
		const configContent = generateConfigTemplate({
265 -
			siteUrl: siteUrl,
266 -
			contentDir: contentDir || "./content",
267 -
			imagesDir: imagesDir || undefined,
268 -
			publicDir: publicDir || "./public",
269 -
			outputDir: outputDir || "./dist",
270 -
			pathPrefix: pathPrefix || "/posts",
262 +
			siteUrl: siteConfig.siteUrl,
263 +
			contentDir: siteConfig.contentDir || "./content",
264 +
			imagesDir: siteConfig.imagesDir || undefined,
265 +
			publicDir: siteConfig.publicDir || "./public",
266 +
			outputDir: siteConfig.outputDir || "./dist",
267 +
			pathPrefix: siteConfig.pathPrefix || "/posts",
271 268
			publicationUri,
272 269
			pdsUrl,
273 270
			frontmatter: frontmatterMapping,
279 276
		log.success(`Configuration saved to ${configPath}`);
280 277
281 278
		// Create .well-known/site.standard.publication file
282 -
		const resolvedPublicDir = path.isAbsolute(publicDir || "./public")
283 -
			? publicDir || "./public"
284 -
			: path.join(process.cwd(), publicDir || "./public");
279 +
		const publicDir = siteConfig.publicDir || "./public";
280 +
		const resolvedPublicDir = path.isAbsolute(publicDir)
281 +
			? publicDir
282 +
			: path.join(process.cwd(), publicDir);
285 283
		const wellKnownDir = path.join(resolvedPublicDir, ".well-known");
286 284
		const wellKnownPath = path.join(wellKnownDir, "site.standard.publication");
287 285