A CLI for publishing standard.site documents to ATProto
at test 625 lines 15 kB view raw
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}