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