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

Add update command #10

merged opened by stevedylan.dev targeting main from feat/update-command

This PR completes issue #6 by adding a new interactive update command, allowing users to easily update their sequoia.json config or their site.standard.publication record on their PDS.

Labels

None yet.

assignee

None yet.

Participants 1
Referenced by
AT URI
at://did:plc:ia2zdnhjaokf5lazhxrmj6eu/sh.tangled.repo.pull/3mdyoos4mby22
+688 -1
Diff #0
+15
docs/docs/pages/cli-reference.mdx
··· 79 --dry-run, -n - Preview what would be synced without making changes [optional] 80 --help, -h - show help [optional] 81 ```
··· 79 --dry-run, -n - Preview what would be synced without making changes [optional] 80 --help, -h - show help [optional] 81 ``` 82 + 83 + ## `update` 84 + 85 + ```bash [Terminal] 86 + sequoia update 87 + > Update local config or ATProto publication record 88 + 89 + FLAGS: 90 + --help, -h - show help [optional] 91 + ``` 92 + 93 + Interactive command to modify your existing configuration. Choose between: 94 + 95 + - **Local configuration**: Edit `sequoia.json` settings including site URL, directory paths, frontmatter mappings, advanced options, and Bluesky settings 96 + - **ATProto publication**: Update your publication record's name, description, URL, icon, and discover visibility
+569
packages/cli/src/commands/update.ts
···
··· 1 + import * as fs from "node:fs/promises"; 2 + import { command } from "cmd-ts"; 3 + import { 4 + intro, 5 + outro, 6 + note, 7 + text, 8 + confirm, 9 + select, 10 + spinner, 11 + log, 12 + } from "@clack/prompts"; 13 + import { findConfig, loadConfig, generateConfigTemplate } from "../lib/config"; 14 + import { loadCredentials } from "../lib/credentials"; 15 + import { createAgent, getPublication, updatePublication } from "../lib/atproto"; 16 + import { exitOnCancel } from "../lib/prompts"; 17 + import type { 18 + PublisherConfig, 19 + FrontmatterMapping, 20 + BlueskyConfig, 21 + } from "../lib/types"; 22 + 23 + export 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 + 60 + async 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 + 169 + async 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 + 201 + async 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 + 244 + async 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 + 334 + async 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 + 397 + async 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 + 439 + async 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 + }
+2
packages/cli/src/index.ts
··· 7 import { loginCommand } from "./commands/login"; 8 import { publishCommand } from "./commands/publish"; 9 import { syncCommand } from "./commands/sync"; 10 11 const app = subcommands({ 12 name: "sequoia", ··· 42 login: loginCommand, 43 publish: publishCommand, 44 sync: syncCommand, 45 }, 46 }); 47
··· 7 import { loginCommand } from "./commands/login"; 8 import { publishCommand } from "./commands/publish"; 9 import { syncCommand } from "./commands/sync"; 10 + import { updateCommand } from "./commands/update"; 11 12 const app = subcommands({ 13 name: "sequoia", ··· 43 login: loginCommand, 44 publish: publishCommand, 45 sync: syncCommand, 46 + update: updateCommand, 47 }, 48 }); 49
+97
packages/cli/src/lib/atproto.ts
··· 8 BlobObject, 9 BlogPost, 10 Credentials, 11 PublisherConfig, 12 StrongRef, 13 } from "./types"; ··· 442 return response.data.uri; 443 } 444 445 // --- Bluesky Post Creation --- 446 447 export interface CreateBlueskyPostOptions {
··· 8 BlobObject, 9 BlogPost, 10 Credentials, 11 + PublicationRecord, 12 PublisherConfig, 13 StrongRef, 14 } from "./types"; ··· 443 return response.data.uri; 444 } 445 446 + export interface GetPublicationResult { 447 + uri: string; 448 + cid: string; 449 + value: PublicationRecord; 450 + } 451 + 452 + export async function getPublication( 453 + agent: Agent, 454 + publicationUri: string, 455 + ): Promise<GetPublicationResult | null> { 456 + const parsed = parseAtUri(publicationUri); 457 + if (!parsed) { 458 + return null; 459 + } 460 + 461 + try { 462 + const response = await agent.com.atproto.repo.getRecord({ 463 + repo: parsed.did, 464 + collection: parsed.collection, 465 + rkey: parsed.rkey, 466 + }); 467 + 468 + return { 469 + uri: publicationUri, 470 + cid: response.data.cid!, 471 + value: response.data.value as unknown as PublicationRecord, 472 + }; 473 + } catch { 474 + return null; 475 + } 476 + } 477 + 478 + export interface UpdatePublicationOptions { 479 + url?: string; 480 + name?: string; 481 + description?: string; 482 + iconPath?: string; 483 + showInDiscover?: boolean; 484 + } 485 + 486 + export async function updatePublication( 487 + agent: Agent, 488 + publicationUri: string, 489 + options: UpdatePublicationOptions, 490 + existingRecord: PublicationRecord, 491 + ): Promise<void> { 492 + const parsed = parseAtUri(publicationUri); 493 + if (!parsed) { 494 + throw new Error(`Invalid publication URI: ${publicationUri}`); 495 + } 496 + 497 + // Build updated record, preserving createdAt and $type 498 + const record: Record<string, unknown> = { 499 + $type: existingRecord.$type, 500 + url: options.url ?? existingRecord.url, 501 + name: options.name ?? existingRecord.name, 502 + createdAt: existingRecord.createdAt, 503 + }; 504 + 505 + // Handle description - can be cleared with empty string 506 + if (options.description !== undefined) { 507 + if (options.description) { 508 + record.description = options.description; 509 + } 510 + // If empty string, don't include description (clears it) 511 + } else if (existingRecord.description) { 512 + record.description = existingRecord.description; 513 + } 514 + 515 + // Handle icon - upload new if provided, otherwise keep existing 516 + if (options.iconPath) { 517 + const icon = await uploadImage(agent, options.iconPath); 518 + if (icon) { 519 + record.icon = icon; 520 + } 521 + } else if (existingRecord.icon) { 522 + record.icon = existingRecord.icon; 523 + } 524 + 525 + // Handle preferences 526 + if (options.showInDiscover !== undefined) { 527 + record.preferences = { 528 + showInDiscover: options.showInDiscover, 529 + }; 530 + } else if (existingRecord.preferences) { 531 + record.preferences = existingRecord.preferences; 532 + } 533 + 534 + await agent.com.atproto.repo.putRecord({ 535 + repo: parsed.did, 536 + collection: parsed.collection, 537 + rkey: parsed.rkey, 538 + record, 539 + }); 540 + } 541 + 542 // --- Bluesky Post Creation --- 543 544 export interface CreateBlueskyPostOptions {
+5 -1
packages/cli/src/lib/markdown.ts
··· 186 rawFrontmatter: Record<string, unknown>, 187 options: SlugOptions = {}, 188 ): string { 189 - const { slugField, removeIndexFromSlug = false, stripDatePrefix = false } = options; 190 191 let slug: string; 192
··· 186 rawFrontmatter: Record<string, unknown>, 187 options: SlugOptions = {}, 188 ): string { 189 + const { 190 + slugField, 191 + removeIndexFromSlug = false, 192 + stripDatePrefix = false, 193 + } = options; 194 195 let slug: string; 196

History

1 round 0 comments
sign up or login to add to the discussion
stevedylan.dev submitted #0
1 commit
expand
feat: add update command
1/1 success
expand
expand 0 comments
pull request successfully merged