A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing
58
fork

Configure Feed

Select the types of activity you want to include in your feed.

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