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