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