this repo has no description
at main 614 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 `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}