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