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 } 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 = JSON.stringify(configUpdated, null, 2);
151
152 await fs.writeFile(configPath, configContent);
153 log.success("Configuration saved!");
154 } else {
155 log.info("Changes discarded.");
156 }
157}
158
159async function editSiteSettings(
160 config: PublisherConfig,
161): Promise<PublisherConfig> {
162 const siteUrl = exitOnCancel(
163 await text({
164 message: "Site URL:",
165 initialValue: config.siteUrl,
166 validate: (value) => {
167 if (!value) return "Site URL is required";
168 try {
169 new URL(value);
170 } catch {
171 return "Please enter a valid URL";
172 }
173 },
174 }),
175 );
176
177 const pathPrefix = exitOnCancel(
178 await text({
179 message: "URL path prefix for posts:",
180 initialValue: config.pathPrefix ?? "/posts",
181 }),
182 );
183
184 return {
185 ...config,
186 siteUrl,
187 pathPrefix,
188 };
189}
190
191async function editDirectories(
192 config: PublisherConfig,
193): Promise<PublisherConfig> {
194 const contentDir = exitOnCancel(
195 await text({
196 message: "Content directory:",
197 initialValue: config.contentDir,
198 validate: (value) => {
199 if (!value) return "Content directory is required";
200 },
201 }),
202 );
203
204 const imagesDir = exitOnCancel(
205 await text({
206 message: "Cover images directory (leave empty to skip):",
207 initialValue: config.imagesDir || "",
208 }),
209 );
210
211 const publicDir = exitOnCancel(
212 await text({
213 message: "Public/static directory:",
214 initialValue: config.publicDir || "./public",
215 }),
216 );
217
218 const outputDir = exitOnCancel(
219 await text({
220 message: "Build output directory:",
221 initialValue: config.outputDir || "./dist",
222 }),
223 );
224
225 return {
226 ...config,
227 contentDir,
228 imagesDir: imagesDir || undefined,
229 publicDir: publicDir || undefined,
230 outputDir: outputDir || undefined,
231 };
232}
233
234async function editFrontmatter(
235 config: PublisherConfig,
236): Promise<PublisherConfig> {
237 const currentFrontmatter = config.frontmatter || {};
238
239 log.info("Press Enter to keep current value, or type a new field name.");
240
241 const titleField = exitOnCancel(
242 await text({
243 message: "Field name for title:",
244 initialValue: currentFrontmatter.title || "title",
245 }),
246 );
247
248 const descField = exitOnCancel(
249 await text({
250 message: "Field name for description:",
251 initialValue: currentFrontmatter.description || "description",
252 }),
253 );
254
255 const dateField = exitOnCancel(
256 await text({
257 message: "Field name for publish date:",
258 initialValue: currentFrontmatter.publishDate || "publishDate",
259 }),
260 );
261
262 const coverField = exitOnCancel(
263 await text({
264 message: "Field name for cover image:",
265 initialValue: currentFrontmatter.coverImage || "ogImage",
266 }),
267 );
268
269 const tagsField = exitOnCancel(
270 await text({
271 message: "Field name for tags:",
272 initialValue: currentFrontmatter.tags || "tags",
273 }),
274 );
275
276 const draftField = exitOnCancel(
277 await text({
278 message: "Field name for draft status:",
279 initialValue: currentFrontmatter.draft || "draft",
280 }),
281 );
282
283 const slugField = exitOnCancel(
284 await text({
285 message: "Field name for slug (leave empty to use filepath):",
286 initialValue: currentFrontmatter.slugField || "",
287 }),
288 );
289
290 // Build frontmatter mapping, only including non-default values
291 const fieldMappings: Array<[keyof FrontmatterMapping, string, string]> = [
292 ["title", titleField, "title"],
293 ["description", descField, "description"],
294 ["publishDate", dateField, "publishDate"],
295 ["coverImage", coverField, "ogImage"],
296 ["tags", tagsField, "tags"],
297 ["draft", draftField, "draft"],
298 ];
299
300 const builtMapping = fieldMappings.reduce<FrontmatterMapping>(
301 (acc, [key, value, defaultValue]) => {
302 if (value !== defaultValue) {
303 acc[key] = value;
304 }
305 return acc;
306 },
307 {},
308 );
309
310 // Handle slugField separately since it has no default
311 if (slugField) {
312 builtMapping.slugField = slugField;
313 }
314
315 const frontmatter =
316 Object.keys(builtMapping).length > 0 ? builtMapping : undefined;
317
318 return {
319 ...config,
320 frontmatter,
321 };
322}
323
324async function editAdvanced(config: PublisherConfig): Promise<PublisherConfig> {
325 const pdsUrl = exitOnCancel(
326 await text({
327 message: "PDS URL (leave empty for default bsky.social):",
328 initialValue: config.pdsUrl || "",
329 }),
330 );
331
332 const identity = exitOnCancel(
333 await text({
334 message: "Identity/profile to use (leave empty for auto-detect):",
335 initialValue: config.identity || "",
336 }),
337 );
338
339 const ignoreInput = exitOnCancel(
340 await text({
341 message: "Ignore patterns (comma-separated, e.g., _index.md,drafts/**):",
342 initialValue: config.ignore?.join(", ") || "",
343 }),
344 );
345
346 const removeIndexFromSlug = exitOnCancel(
347 await confirm({
348 message: "Remove /index or /_index suffix from paths?",
349 initialValue: config.removeIndexFromSlug || false,
350 }),
351 );
352
353 const stripDatePrefix = exitOnCancel(
354 await confirm({
355 message: "Strip YYYY-MM-DD- prefix from filenames (Jekyll-style)?",
356 initialValue: config.stripDatePrefix || false,
357 }),
358 );
359
360 const publishContent = exitOnCancel(
361 await confirm({
362 message: "Publish the post content on the standard.site document?",
363 initialValue: config.publishContent ?? true,
364 }),
365 );
366
367 const textContentField = exitOnCancel(
368 await text({
369 message:
370 "Frontmatter field for textContent (leave empty to use markdown body):",
371 initialValue: config.textContentField || "",
372 }),
373 );
374
375 // Parse ignore patterns
376 const ignore = ignoreInput
377 ? ignoreInput
378 .split(",")
379 .map((p) => p.trim())
380 .filter(Boolean)
381 : undefined;
382
383 return {
384 ...config,
385 pdsUrl: pdsUrl || undefined,
386 identity: identity || undefined,
387 ignore: ignore && ignore.length > 0 ? ignore : undefined,
388 removeIndexFromSlug: removeIndexFromSlug || undefined,
389 stripDatePrefix: stripDatePrefix || undefined,
390 textContentField: textContentField || undefined,
391 publishContent: publishContent ?? true,
392 };
393}
394
395async function editBluesky(config: PublisherConfig): Promise<PublisherConfig> {
396 const enabled = exitOnCancel(
397 await confirm({
398 message: "Enable automatic Bluesky posting when publishing?",
399 initialValue: config.bluesky?.enabled || false,
400 }),
401 );
402
403 if (!enabled) {
404 return {
405 ...config,
406 bluesky: undefined,
407 };
408 }
409
410 const maxAgeDaysInput = exitOnCancel(
411 await text({
412 message: "Maximum age (in days) for posts to be shared on Bluesky:",
413 initialValue: String(config.bluesky?.maxAgeDays || 7),
414 validate: (value) => {
415 if (!value) return "Please enter a number";
416 const num = Number.parseInt(value, 10);
417 if (Number.isNaN(num) || num < 1) {
418 return "Please enter a positive number";
419 }
420 },
421 }),
422 );
423
424 const maxAgeDays = parseInt(maxAgeDaysInput, 10);
425
426 const bluesky: BlueskyConfig = {
427 enabled: true,
428 ...(maxAgeDays !== 7 && { maxAgeDays }),
429 };
430
431 return {
432 ...config,
433 bluesky,
434 };
435}
436
437async function updatePublicationFlow(config: PublisherConfig): Promise<void> {
438 // Load credentials
439 let credentials = await loadCredentials(config.identity);
440
441 if (!credentials) {
442 const identities = await listAllCredentials();
443 if (identities.length === 0) {
444 log.error(
445 "No credentials found. Run 'sequoia login' or 'sequoia auth' first.",
446 );
447 process.exit(1);
448 }
449
450 // Build labels with handles for OAuth sessions
451 const options = await Promise.all(
452 identities.map(async (cred) => {
453 if (cred.type === "oauth") {
454 const handle = await getOAuthHandle(cred.id);
455 return {
456 value: cred.id,
457 label: `${handle || cred.id} (OAuth)`,
458 };
459 }
460 return {
461 value: cred.id,
462 label: `${cred.id} (App Password)`,
463 };
464 }),
465 );
466
467 log.info("Multiple identities found. Select one to use:");
468 const selected = exitOnCancel(
469 await select({
470 message: "Identity:",
471 options,
472 }),
473 );
474
475 // Load the selected credentials
476 const selectedCred = identities.find((c) => c.id === selected);
477 if (selectedCred?.type === "oauth") {
478 const session = await getOAuthSession(selected);
479 if (session) {
480 const handle = await getOAuthHandle(selected);
481 credentials = {
482 type: "oauth",
483 did: selected,
484 handle: handle || selected,
485 };
486 }
487 } else {
488 credentials = await getCredentials(selected);
489 }
490
491 if (!credentials) {
492 log.error("Failed to load selected credentials.");
493 process.exit(1);
494 }
495 }
496
497 const s = spinner();
498 s.start("Connecting to ATProto...");
499
500 let agent: Awaited<ReturnType<typeof createAgent>>;
501 try {
502 agent = await createAgent(credentials);
503 s.stop("Connected!");
504 } catch (error) {
505 s.stop("Failed to connect");
506 log.error(`Failed to connect: ${error}`);
507 process.exit(1);
508 }
509
510 // Fetch existing publication
511 s.start("Fetching publication...");
512 const publication = await getPublication(agent, config.publicationUri);
513
514 if (!publication) {
515 s.stop("Publication not found");
516 log.error(`Could not find publication: ${config.publicationUri}`);
517 process.exit(1);
518 }
519 s.stop("Publication loaded!");
520
521 // Show current publication info
522 const pubRecord = publication.value;
523 const pubSummary = [
524 `Name: ${pubRecord.name}`,
525 `URL: ${pubRecord.url}`,
526 pubRecord.description ? `Description: ${pubRecord.description}` : null,
527 pubRecord.icon ? `Icon: (uploaded)` : null,
528 `Show in Discover: ${pubRecord.preferences?.showInDiscover ?? true}`,
529 `Created: ${pubRecord.createdAt}`,
530 ]
531 .filter(Boolean)
532 .join("\n");
533
534 note(pubSummary, "Current Publication");
535
536 // Collect updates with pre-populated values
537 const name = exitOnCancel(
538 await text({
539 message: "Publication name:",
540 initialValue: pubRecord.name,
541 validate: (value) => {
542 if (!value) return "Publication name is required";
543 },
544 }),
545 );
546
547 const description = exitOnCancel(
548 await text({
549 message: "Publication description (leave empty to clear):",
550 initialValue: pubRecord.description || "",
551 }),
552 );
553
554 const url = exitOnCancel(
555 await text({
556 message: "Publication URL:",
557 initialValue: pubRecord.url,
558 validate: (value) => {
559 if (!value) return "URL is required";
560 try {
561 new URL(value);
562 } catch {
563 return "Please enter a valid URL";
564 }
565 },
566 }),
567 );
568
569 const iconPath = exitOnCancel(
570 await text({
571 message: "New icon path (leave empty to keep existing):",
572 initialValue: "",
573 }),
574 );
575
576 const showInDiscover = exitOnCancel(
577 await confirm({
578 message: "Show in Discover feed?",
579 initialValue: pubRecord.preferences?.showInDiscover ?? true,
580 }),
581 );
582
583 // Confirm before updating
584 const shouldUpdate = exitOnCancel(
585 await confirm({
586 message: "Update publication on ATProto?",
587 initialValue: true,
588 }),
589 );
590
591 if (!shouldUpdate) {
592 log.info("Update cancelled.");
593 return;
594 }
595
596 // Perform update
597 s.start("Updating publication...");
598 try {
599 await updatePublication(
600 agent,
601 config.publicationUri,
602 {
603 name,
604 description,
605 url,
606 iconPath: iconPath || undefined,
607 showInDiscover,
608 },
609 pubRecord,
610 );
611 s.stop("Publication updated!");
612 } catch (error) {
613 s.stop("Failed to update publication");
614 log.error(`Failed to update: ${error}`);
615 process.exit(1);
616 }
617}