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