A CLI for publishing standard.site documents to ATProto
at main 634 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 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}