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