A CLI for publishing standard.site documents to ATProto
sequoia.pub
standard
site
lexicon
cli
publishing
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} from "@clack/prompts";
13import { findConfig, loadConfig, generateConfigTemplate } from "../lib/config";
14import {
15 loadCredentials,
16 listAllCredentials,
17 getCredentials,
18} from "../lib/credentials";
19import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store";
20import { createAgent, getPublication, updatePublication } from "../lib/atproto";
21import { exitOnCancel } from "../lib/prompts";
22import type {
23 PublisherConfig,
24 FrontmatterMapping,
25 BlueskyConfig,
26} from "../lib/types";
27
28export const updateCommand = command({
29 name: "update",
30 description: "Update local config or ATProto publication record",
31 args: {},
32 handler: async () => {
33 intro("Sequoia Update");
34
35 // Check if config exists
36 const configPath = await findConfig();
37 if (!configPath) {
38 log.error("No configuration found. Run 'sequoia init' first.");
39 process.exit(1);
40 }
41
42 const config = await loadConfig(configPath);
43
44 // Ask what to update
45 const updateChoice = exitOnCancel(
46 await select({
47 message: "What would you like to update?",
48 options: [
49 { label: "Local configuration (sequoia.json)", value: "config" },
50 { label: "ATProto publication record", value: "publication" },
51 ],
52 }),
53 );
54
55 if (updateChoice === "config") {
56 await updateConfigFlow(config, configPath);
57 } else {
58 await updatePublicationFlow(config);
59 }
60
61 outro("Update complete!");
62 },
63});
64
65async function updateConfigFlow(
66 config: PublisherConfig,
67 configPath: string,
68): Promise<void> {
69 // Show current config summary
70 const configSummary = [
71 `Site URL: ${config.siteUrl}`,
72 `Content Dir: ${config.contentDir}`,
73 `Path Prefix: ${config.pathPrefix || "/posts"}`,
74 `Publication URI: ${config.publicationUri}`,
75 config.imagesDir ? `Images Dir: ${config.imagesDir}` : null,
76 config.outputDir ? `Output Dir: ${config.outputDir}` : null,
77 config.bluesky?.enabled ? `Bluesky: enabled` : null,
78 ]
79 .filter(Boolean)
80 .join("\n");
81
82 note(configSummary, "Current Configuration");
83
84 let configUpdated = { ...config };
85 let editing = true;
86
87 while (editing) {
88 const section = exitOnCancel(
89 await select({
90 message: "Select a section to edit:",
91 options: [
92 { label: "Site settings (siteUrl, pathPrefix)", value: "site" },
93 {
94 label:
95 "Directory paths (contentDir, imagesDir, publicDir, outputDir)",
96 value: "directories",
97 },
98 {
99 label:
100 "Frontmatter mappings (title, description, publishDate, etc.)",
101 value: "frontmatter",
102 },
103 {
104 label:
105 "Advanced options (pdsUrl, identity, ignore, removeIndexFromSlug, etc.)",
106 value: "advanced",
107 },
108 {
109 label: "Bluesky settings (enabled, maxAgeDays)",
110 value: "bluesky",
111 },
112 { label: "Done editing", value: "done" },
113 ],
114 }),
115 );
116
117 if (section === "done") {
118 editing = false;
119 continue;
120 }
121
122 switch (section) {
123 case "site":
124 configUpdated = await editSiteSettings(configUpdated);
125 break;
126 case "directories":
127 configUpdated = await editDirectories(configUpdated);
128 break;
129 case "frontmatter":
130 configUpdated = await editFrontmatter(configUpdated);
131 break;
132 case "advanced":
133 configUpdated = await editAdvanced(configUpdated);
134 break;
135 case "bluesky":
136 configUpdated = await editBluesky(configUpdated);
137 break;
138 }
139 }
140
141 // Confirm before saving
142 const shouldSave = exitOnCancel(
143 await confirm({
144 message: "Save changes to sequoia.json?",
145 initialValue: true,
146 }),
147 );
148
149 if (shouldSave) {
150 const configContent = generateConfigTemplate({
151 siteUrl: configUpdated.siteUrl,
152 contentDir: configUpdated.contentDir,
153 imagesDir: configUpdated.imagesDir,
154 publicDir: configUpdated.publicDir,
155 outputDir: configUpdated.outputDir,
156 pathPrefix: configUpdated.pathPrefix,
157 publicationUri: configUpdated.publicationUri,
158 pdsUrl: configUpdated.pdsUrl,
159 frontmatter: configUpdated.frontmatter,
160 ignore: configUpdated.ignore,
161 removeIndexFromSlug: configUpdated.removeIndexFromSlug,
162 stripDatePrefix: configUpdated.stripDatePrefix,
163 textContentField: configUpdated.textContentField,
164 bluesky: configUpdated.bluesky,
165 });
166
167 await fs.writeFile(configPath, configContent);
168 log.success("Configuration saved!");
169 } else {
170 log.info("Changes discarded.");
171 }
172}
173
174async function editSiteSettings(
175 config: PublisherConfig,
176): Promise<PublisherConfig> {
177 const siteUrl = exitOnCancel(
178 await text({
179 message: "Site URL:",
180 initialValue: config.siteUrl,
181 validate: (value) => {
182 if (!value) return "Site URL is required";
183 try {
184 new URL(value);
185 } catch {
186 return "Please enter a valid URL";
187 }
188 },
189 }),
190 );
191
192 const pathPrefix = exitOnCancel(
193 await text({
194 message: "URL path prefix for posts:",
195 initialValue: config.pathPrefix || "/posts",
196 }),
197 );
198
199 return {
200 ...config,
201 siteUrl,
202 pathPrefix: pathPrefix || undefined,
203 };
204}
205
206async function editDirectories(
207 config: PublisherConfig,
208): Promise<PublisherConfig> {
209 const contentDir = exitOnCancel(
210 await text({
211 message: "Content directory:",
212 initialValue: config.contentDir,
213 validate: (value) => {
214 if (!value) return "Content directory is required";
215 },
216 }),
217 );
218
219 const imagesDir = exitOnCancel(
220 await text({
221 message: "Cover images directory (leave empty to skip):",
222 initialValue: config.imagesDir || "",
223 }),
224 );
225
226 const publicDir = exitOnCancel(
227 await text({
228 message: "Public/static directory:",
229 initialValue: config.publicDir || "./public",
230 }),
231 );
232
233 const outputDir = exitOnCancel(
234 await text({
235 message: "Build output directory:",
236 initialValue: config.outputDir || "./dist",
237 }),
238 );
239
240 return {
241 ...config,
242 contentDir,
243 imagesDir: imagesDir || undefined,
244 publicDir: publicDir || undefined,
245 outputDir: outputDir || undefined,
246 };
247}
248
249async function editFrontmatter(
250 config: PublisherConfig,
251): Promise<PublisherConfig> {
252 const currentFrontmatter = config.frontmatter || {};
253
254 log.info("Press Enter to keep current value, or type a new field name.");
255
256 const titleField = exitOnCancel(
257 await text({
258 message: "Field name for title:",
259 initialValue: currentFrontmatter.title || "title",
260 }),
261 );
262
263 const descField = exitOnCancel(
264 await text({
265 message: "Field name for description:",
266 initialValue: currentFrontmatter.description || "description",
267 }),
268 );
269
270 const dateField = exitOnCancel(
271 await text({
272 message: "Field name for publish date:",
273 initialValue: currentFrontmatter.publishDate || "publishDate",
274 }),
275 );
276
277 const coverField = exitOnCancel(
278 await text({
279 message: "Field name for cover image:",
280 initialValue: currentFrontmatter.coverImage || "ogImage",
281 }),
282 );
283
284 const tagsField = exitOnCancel(
285 await text({
286 message: "Field name for tags:",
287 initialValue: currentFrontmatter.tags || "tags",
288 }),
289 );
290
291 const draftField = exitOnCancel(
292 await text({
293 message: "Field name for draft status:",
294 initialValue: currentFrontmatter.draft || "draft",
295 }),
296 );
297
298 const slugField = exitOnCancel(
299 await text({
300 message: "Field name for slug (leave empty to use filepath):",
301 initialValue: currentFrontmatter.slugField || "",
302 }),
303 );
304
305 // Build frontmatter mapping, only including non-default values
306 const fieldMappings: Array<[keyof FrontmatterMapping, string, string]> = [
307 ["title", titleField, "title"],
308 ["description", descField, "description"],
309 ["publishDate", dateField, "publishDate"],
310 ["coverImage", coverField, "ogImage"],
311 ["tags", tagsField, "tags"],
312 ["draft", draftField, "draft"],
313 ];
314
315 const builtMapping = fieldMappings.reduce<FrontmatterMapping>(
316 (acc, [key, value, defaultValue]) => {
317 if (value !== defaultValue) {
318 acc[key] = value;
319 }
320 return acc;
321 },
322 {},
323 );
324
325 // Handle slugField separately since it has no default
326 if (slugField) {
327 builtMapping.slugField = slugField;
328 }
329
330 const frontmatter =
331 Object.keys(builtMapping).length > 0 ? builtMapping : undefined;
332
333 return {
334 ...config,
335 frontmatter,
336 };
337}
338
339async function editAdvanced(config: PublisherConfig): Promise<PublisherConfig> {
340 const pdsUrl = exitOnCancel(
341 await text({
342 message: "PDS URL (leave empty for default bsky.social):",
343 initialValue: config.pdsUrl || "",
344 }),
345 );
346
347 const identity = exitOnCancel(
348 await text({
349 message: "Identity/profile to use (leave empty for auto-detect):",
350 initialValue: config.identity || "",
351 }),
352 );
353
354 const ignoreInput = exitOnCancel(
355 await text({
356 message: "Ignore patterns (comma-separated, e.g., _index.md,drafts/**):",
357 initialValue: config.ignore?.join(", ") || "",
358 }),
359 );
360
361 const removeIndexFromSlug = exitOnCancel(
362 await confirm({
363 message: "Remove /index or /_index suffix from paths?",
364 initialValue: config.removeIndexFromSlug || false,
365 }),
366 );
367
368 const stripDatePrefix = exitOnCancel(
369 await confirm({
370 message: "Strip YYYY-MM-DD- prefix from filenames (Jekyll-style)?",
371 initialValue: config.stripDatePrefix || false,
372 }),
373 );
374
375 const textContentField = exitOnCancel(
376 await text({
377 message:
378 "Frontmatter field for textContent (leave empty to use markdown body):",
379 initialValue: config.textContentField || "",
380 }),
381 );
382
383 // Parse ignore patterns
384 const ignore = ignoreInput
385 ? ignoreInput
386 .split(",")
387 .map((p) => p.trim())
388 .filter(Boolean)
389 : undefined;
390
391 return {
392 ...config,
393 pdsUrl: pdsUrl || undefined,
394 identity: identity || undefined,
395 ignore: ignore && ignore.length > 0 ? ignore : undefined,
396 removeIndexFromSlug: removeIndexFromSlug || undefined,
397 stripDatePrefix: stripDatePrefix || undefined,
398 textContentField: textContentField || undefined,
399 };
400}
401
402async function editBluesky(config: PublisherConfig): Promise<PublisherConfig> {
403 const enabled = exitOnCancel(
404 await confirm({
405 message: "Enable automatic Bluesky posting when publishing?",
406 initialValue: config.bluesky?.enabled || false,
407 }),
408 );
409
410 if (!enabled) {
411 return {
412 ...config,
413 bluesky: undefined,
414 };
415 }
416
417 const maxAgeDaysInput = exitOnCancel(
418 await text({
419 message: "Maximum age (in days) for posts to be shared on Bluesky:",
420 initialValue: String(config.bluesky?.maxAgeDays || 7),
421 validate: (value) => {
422 if (!value) return "Please enter a number";
423 const num = Number.parseInt(value, 10);
424 if (Number.isNaN(num) || num < 1) {
425 return "Please enter a positive number";
426 }
427 },
428 }),
429 );
430
431 const maxAgeDays = parseInt(maxAgeDaysInput, 10);
432
433 const bluesky: BlueskyConfig = {
434 enabled: true,
435 ...(maxAgeDays !== 7 && { maxAgeDays }),
436 };
437
438 return {
439 ...config,
440 bluesky,
441 };
442}
443
444async function updatePublicationFlow(config: PublisherConfig): Promise<void> {
445 // Load credentials
446 let credentials = await loadCredentials(config.identity);
447
448 if (!credentials) {
449 const identities = await listAllCredentials();
450 if (identities.length === 0) {
451 log.error(
452 "No credentials found. Run 'sequoia login' or 'sequoia auth' first.",
453 );
454 process.exit(1);
455 }
456
457 // Build labels with handles for OAuth sessions
458 const options = await Promise.all(
459 identities.map(async (cred) => {
460 if (cred.type === "oauth") {
461 const handle = await getOAuthHandle(cred.id);
462 return {
463 value: cred.id,
464 label: `${handle || cred.id} (OAuth)`,
465 };
466 }
467 return {
468 value: cred.id,
469 label: `${cred.id} (App Password)`,
470 };
471 }),
472 );
473
474 log.info("Multiple identities found. Select one to use:");
475 const selected = exitOnCancel(
476 await select({
477 message: "Identity:",
478 options,
479 }),
480 );
481
482 // Load the selected credentials
483 const selectedCred = identities.find((c) => c.id === selected);
484 if (selectedCred?.type === "oauth") {
485 const session = await getOAuthSession(selected);
486 if (session) {
487 const handle = await getOAuthHandle(selected);
488 credentials = {
489 type: "oauth",
490 did: selected,
491 handle: handle || selected,
492 };
493 }
494 } else {
495 credentials = await getCredentials(selected);
496 }
497
498 if (!credentials) {
499 log.error("Failed to load selected credentials.");
500 process.exit(1);
501 }
502 }
503
504 const s = spinner();
505 s.start("Connecting to ATProto...");
506
507 let agent: Awaited<ReturnType<typeof createAgent>>;
508 try {
509 agent = await createAgent(credentials);
510 s.stop("Connected!");
511 } catch (error) {
512 s.stop("Failed to connect");
513 log.error(`Failed to connect: ${error}`);
514 process.exit(1);
515 }
516
517 // Fetch existing publication
518 s.start("Fetching publication...");
519 const publication = await getPublication(agent, config.publicationUri);
520
521 if (!publication) {
522 s.stop("Publication not found");
523 log.error(`Could not find publication: ${config.publicationUri}`);
524 process.exit(1);
525 }
526 s.stop("Publication loaded!");
527
528 // Show current publication info
529 const pubRecord = publication.value;
530 const pubSummary = [
531 `Name: ${pubRecord.name}`,
532 `URL: ${pubRecord.url}`,
533 pubRecord.description ? `Description: ${pubRecord.description}` : null,
534 pubRecord.icon ? `Icon: (uploaded)` : null,
535 `Show in Discover: ${pubRecord.preferences?.showInDiscover ?? true}`,
536 `Created: ${pubRecord.createdAt}`,
537 ]
538 .filter(Boolean)
539 .join("\n");
540
541 note(pubSummary, "Current Publication");
542
543 // Collect updates with pre-populated values
544 const name = exitOnCancel(
545 await text({
546 message: "Publication name:",
547 initialValue: pubRecord.name,
548 validate: (value) => {
549 if (!value) return "Publication name is required";
550 },
551 }),
552 );
553
554 const description = exitOnCancel(
555 await text({
556 message: "Publication description (leave empty to clear):",
557 initialValue: pubRecord.description || "",
558 }),
559 );
560
561 const url = exitOnCancel(
562 await text({
563 message: "Publication URL:",
564 initialValue: pubRecord.url,
565 validate: (value) => {
566 if (!value) return "URL is required";
567 try {
568 new URL(value);
569 } catch {
570 return "Please enter a valid URL";
571 }
572 },
573 }),
574 );
575
576 const iconPath = exitOnCancel(
577 await text({
578 message: "New icon path (leave empty to keep existing):",
579 initialValue: "",
580 }),
581 );
582
583 const showInDiscover = exitOnCancel(
584 await confirm({
585 message: "Show in Discover feed?",
586 initialValue: pubRecord.preferences?.showInDiscover ?? true,
587 }),
588 );
589
590 // Confirm before updating
591 const shouldUpdate = exitOnCancel(
592 await confirm({
593 message: "Update publication on ATProto?",
594 initialValue: true,
595 }),
596 );
597
598 if (!shouldUpdate) {
599 log.info("Update cancelled.");
600 return;
601 }
602
603 // Perform update
604 s.start("Updating publication...");
605 try {
606 await updatePublication(
607 agent,
608 config.publicationUri,
609 {
610 name,
611 description,
612 url,
613 iconPath: iconPath || undefined,
614 showInDiscover,
615 },
616 pubRecord,
617 );
618 s.stop("Publication updated!");
619 } catch (error) {
620 s.stop("Failed to update publication");
621 log.error(`Failed to update: ${error}`);
622 process.exit(1);
623 }
624}