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