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