A CLI for publishing standard.site documents to ATProto
0
fork

Configure Feed

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

at main 643 lines 18 kB view raw
1import * as fs from "node:fs/promises"; 2import { command, flag } from "cmd-ts"; 3import { select, spinner, log } from "@clack/prompts"; 4import * as path from "node:path"; 5import { loadConfig, loadState, saveState, findConfig } from "../lib/config"; 6import { 7 loadCredentials, 8 listAllCredentials, 9 getCredentials, 10} from "../lib/credentials"; 11import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store"; 12import { 13 createAgent, 14 createDocument, 15 updateDocument, 16 uploadImage, 17 resolveImagePath, 18 createBlueskyPost, 19 addBskyPostRefToDocument, 20 deleteRecord, 21 listDocuments, 22} from "../lib/atproto"; 23import { 24 scanContentDirectory, 25 getContentHash, 26 updateFrontmatterWithAtUri, 27 resolvePostPath, 28} from "../lib/markdown"; 29import type { BlogPost, BlobObject, StrongRef } from "../lib/types"; 30import { exitOnCancel } from "../lib/prompts"; 31import { 32 createNote, 33 updateNote, 34 deleteNote, 35 findPostsWithStaleLinks, 36 type NoteOptions, 37} from "../extensions/remanso"; 38import { fileExists } from "../lib/utils"; 39 40export const publishCommand = command({ 41 name: "publish", 42 description: "Publish content to ATProto", 43 args: { 44 force: flag({ 45 long: "force", 46 short: "f", 47 description: "Force publish all posts, ignoring change detection", 48 }), 49 dryRun: flag({ 50 long: "dry-run", 51 short: "n", 52 description: "Preview what would be published without making changes", 53 }), 54 verbose: flag({ 55 long: "verbose", 56 short: "v", 57 description: "Show more information", 58 }), 59 }, 60 handler: async ({ force, dryRun, verbose }) => { 61 // Load config 62 const configPath = await findConfig(); 63 if (!configPath) { 64 log.error("No publisher.config.ts found. Run 'publisher init' first."); 65 process.exit(1); 66 } 67 68 const config = await loadConfig(configPath); 69 const configDir = path.dirname(configPath); 70 71 log.info(`Site: ${config.siteUrl}`); 72 log.info(`Content directory: ${config.contentDir}`); 73 74 // Load credentials 75 let credentials = await loadCredentials(config.identity); 76 77 // If no credentials resolved, check if we need to prompt for identity selection 78 if (!credentials) { 79 const identities = await listAllCredentials(); 80 if (identities.length === 0) { 81 log.error( 82 "No credentials found. Run 'sequoia login' or 'sequoia auth' first.", 83 ); 84 log.info( 85 "Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables.", 86 ); 87 process.exit(1); 88 } 89 90 // Build labels with handles for OAuth sessions 91 const options = await Promise.all( 92 identities.map(async (cred) => { 93 if (cred.type === "oauth") { 94 const handle = await getOAuthHandle(cred.id); 95 return { 96 value: cred.id, 97 label: `${handle || cred.id} (OAuth)`, 98 }; 99 } 100 return { 101 value: cred.id, 102 label: `${cred.id} (App Password)`, 103 }; 104 }), 105 ); 106 107 // Multiple identities exist but none selected - prompt user 108 log.info("Multiple identities found. Select one to use:"); 109 const selected = exitOnCancel( 110 await select({ 111 message: "Identity:", 112 options, 113 }), 114 ); 115 116 // Load the selected credentials 117 const selectedCred = identities.find((c) => c.id === selected); 118 if (selectedCred?.type === "oauth") { 119 const session = await getOAuthSession(selected); 120 if (session) { 121 const handle = await getOAuthHandle(selected); 122 credentials = { 123 type: "oauth", 124 did: selected, 125 handle: handle || selected, 126 }; 127 } 128 } else { 129 credentials = await getCredentials(selected); 130 } 131 132 if (!credentials) { 133 log.error("Failed to load selected credentials."); 134 process.exit(1); 135 } 136 137 const displayId = 138 credentials.type === "oauth" 139 ? credentials.handle || credentials.did 140 : credentials.identifier; 141 log.info( 142 `Tip: Add "identity": "${displayId}" to sequoia.json to use this by default.`, 143 ); 144 } 145 146 // Resolve content directory 147 const contentDir = path.isAbsolute(config.contentDir) 148 ? config.contentDir 149 : path.join(configDir, config.contentDir); 150 151 const imagesDir = config.imagesDir 152 ? path.isAbsolute(config.imagesDir) 153 ? config.imagesDir 154 : path.join(configDir, config.imagesDir) 155 : undefined; 156 157 // Load state 158 const state = await loadState(configDir); 159 160 // Scan for posts 161 const s = spinner(); 162 s.start("Scanning for posts..."); 163 const posts = await scanContentDirectory(contentDir, { 164 frontmatterMapping: config.frontmatter, 165 ignorePatterns: config.ignore, 166 slugField: config.frontmatter?.slugField, 167 removeIndexFromSlug: config.removeIndexFromSlug, 168 stripDatePrefix: config.stripDatePrefix, 169 }); 170 s.stop(`Found ${posts.length} posts`); 171 172 // Detect deleted files: state entries whose local files no longer exist 173 const scannedPaths = new Set( 174 posts.map((p) => path.relative(configDir, p.filePath)), 175 ); 176 const deletedEntries: Array<{ filePath: string; atUri: string }> = []; 177 178 for (const [filePath, postState] of Object.entries(state.posts)) { 179 if (!scannedPaths.has(filePath) && postState.atUri) { 180 // Check if the file truly doesn't exist (not just excluded by ignore patterns) 181 const absolutePath = path.resolve(configDir, filePath); 182 if (!(await fileExists(absolutePath))) { 183 deletedEntries.push({ filePath, atUri: postState.atUri }); 184 } 185 } 186 } 187 188 // Detect unmatched PDS records: exist on PDS but have no matching local file 189 const unmatchedEntries: Array<{ atUri: string; title: string }> = []; 190 191 // Shared agent — created lazily, reused across deletion and publishing 192 let agent: Awaited<ReturnType<typeof createAgent>> | undefined; 193 async function getAgent(): Promise< 194 Awaited<ReturnType<typeof createAgent>> 195 > { 196 if (agent) return agent; 197 198 if (!credentials) { 199 throw new Error("credentials not found"); 200 } 201 202 const connectingTo = 203 credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl; 204 s.start(`Connecting as ${connectingTo}...`); 205 try { 206 agent = await createAgent(credentials); 207 s.stop(`Logged in as ${agent.did}`); 208 return agent; 209 } catch (error) { 210 s.stop("Failed to login"); 211 log.error(`Failed to login: ${error}`); 212 process.exit(1); 213 } 214 } 215 216 // Determine which posts need publishing 217 const postsToPublish: Array<{ 218 post: BlogPost; 219 action: "create" | "update"; 220 reason: "content changed" | "forced" | "new post" | "missing state"; 221 }> = []; 222 const draftPosts: BlogPost[] = []; 223 224 for (const post of posts) { 225 // Skip draft posts 226 if (post.frontmatter.draft) { 227 draftPosts.push(post); 228 continue; 229 } 230 231 const contentHash = await getContentHash(post.rawContent); 232 const relativeFilePath = path.relative(configDir, post.filePath); 233 const postState = state.posts[relativeFilePath]; 234 235 if (force) { 236 postsToPublish.push({ 237 post, 238 action: post.frontmatter.atUri ? "update" : "create", 239 reason: "forced", 240 }); 241 } else if (!postState) { 242 postsToPublish.push({ 243 post, 244 action: post.frontmatter.atUri ? "update" : "create", 245 reason: post.frontmatter.atUri ? "missing state" : "new post", 246 }); 247 } else if (postState.contentHash !== contentHash) { 248 // Changed post 249 postsToPublish.push({ 250 post, 251 action: post.frontmatter.atUri ? "update" : "create", 252 reason: "content changed", 253 }); 254 } 255 } 256 257 if (draftPosts.length > 0) { 258 log.info( 259 `Skipping ${draftPosts.length} draft post${draftPosts.length === 1 ? "" : "s"}`, 260 ); 261 } 262 263 // Fetch PDS records and detect unmatched documents 264 async function fetchUnmatchedRecords() { 265 const ag = await getAgent(); 266 s.start("Fetching documents from PDS..."); 267 const pdsDocuments = await listDocuments(ag, config.publicationUri); 268 s.stop(`Found ${pdsDocuments.length} documents on PDS`); 269 270 const pathPrefix = config.pathPrefix || "/posts"; 271 const postsByPath = new Map<string, BlogPost>(); 272 for (const post of posts) { 273 postsByPath.set(`${pathPrefix}/${post.slug}`, post); 274 } 275 const deletedAtUris = new Set(deletedEntries.map((e) => e.atUri)); 276 for (const doc of pdsDocuments) { 277 if (!postsByPath.has(doc.value.path) && !deletedAtUris.has(doc.uri)) { 278 unmatchedEntries.push({ 279 atUri: doc.uri, 280 title: doc.value.title || doc.value.path, 281 }); 282 } 283 } 284 } 285 286 if (postsToPublish.length === 0 && deletedEntries.length === 0) { 287 await fetchUnmatchedRecords(); 288 289 if (unmatchedEntries.length === 0) { 290 log.success("All posts are up to date. Nothing to publish."); 291 return; 292 } 293 } 294 295 // Bluesky posting configuration 296 const blueskyEnabled = config.bluesky?.enabled ?? false; 297 const maxAgeDays = config.bluesky?.maxAgeDays ?? 7; 298 const cutoffDate = new Date(); 299 cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays); 300 301 if (postsToPublish.length > 0) { 302 log.info(`\n${postsToPublish.length} posts to publish:\n`); 303 304 for (const { post, action, reason } of postsToPublish) { 305 const icon = action === "create" ? "+" : "~"; 306 const relativeFilePath = path.relative(configDir, post.filePath); 307 const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef; 308 309 let bskyNote = ""; 310 if (blueskyEnabled) { 311 if (existingBskyPostRef) { 312 bskyNote = " [bsky: exists]"; 313 } else { 314 const publishDate = new Date(post.frontmatter.publishDate); 315 if (publishDate < cutoffDate) { 316 bskyNote = ` [bsky: skipped, older than ${maxAgeDays} days]`; 317 } else { 318 bskyNote = " [bsky: will post]"; 319 } 320 } 321 } 322 323 let postUrl = ""; 324 if (verbose) { 325 const postPath = resolvePostPath( 326 post, 327 config.pathPrefix, 328 config.pathTemplate, 329 ); 330 postUrl = `\n ${config.siteUrl}${postPath}`; 331 } 332 log.message( 333 ` ${icon} ${post.filePath} (${reason})${bskyNote}${postUrl}`, 334 ); 335 } 336 } 337 338 if (deletedEntries.length > 0) { 339 log.info( 340 `\n${deletedEntries.length} deleted local files to remove from PDS:\n`, 341 ); 342 for (const { filePath } of deletedEntries) { 343 log.message(` - ${filePath}`); 344 } 345 } 346 347 if (unmatchedEntries.length > 0) { 348 log.info( 349 `\n${unmatchedEntries.length} unmatched PDS records to delete:\n`, 350 ); 351 for (const { title } of unmatchedEntries) { 352 log.message(` - ${title}`); 353 } 354 } 355 356 if (dryRun) { 357 if (blueskyEnabled) { 358 log.info(`\nBluesky posting: enabled (max age: ${maxAgeDays} days)`); 359 } 360 log.info("\nDry run complete. No changes made."); 361 return; 362 } 363 364 // Ensure agent is connected 365 await getAgent(); 366 367 if (!agent) { 368 throw new Error("agent is not connected"); 369 } 370 371 // Fetch PDS records to detect unmatched documents (if not already done) 372 if (unmatchedEntries.length === 0) { 373 await fetchUnmatchedRecords(); 374 } 375 376 // Publish posts 377 let publishedCount = 0; 378 let updatedCount = 0; 379 let errorCount = 0; 380 let bskyPostCount = 0; 381 382 const context: NoteOptions = { 383 contentDir, 384 imagesDir, 385 allPosts: posts, 386 }; 387 388 // Pass 1: Create/update document records and collect note queue 389 const noteQueue: Array<{ 390 post: BlogPost; 391 action: "create" | "update"; 392 atUri: string; 393 }> = []; 394 395 for (const { post, action } of postsToPublish) { 396 const trimmedContent = post.content.trim(); 397 const titleMatch = trimmedContent.match(/^# (.+)$/m); 398 const title = titleMatch ? titleMatch[1] : post.frontmatter.title; 399 s.start(`Publishing: ${title}`); 400 401 // Init publish date 402 if (!post.frontmatter.publishDate) { 403 const [publishDate] = new Date().toISOString().split("T"); 404 post.frontmatter.publishDate = publishDate!; 405 } 406 407 try { 408 // Handle cover image upload 409 let coverImage: BlobObject | undefined; 410 if (post.frontmatter.ogImage) { 411 const imagePath = await resolveImagePath( 412 post.frontmatter.ogImage, 413 imagesDir, 414 contentDir, 415 ); 416 417 if (imagePath) { 418 log.info(` Uploading cover image: ${path.basename(imagePath)}`); 419 coverImage = await uploadImage(agent, imagePath); 420 if (coverImage) { 421 log.info(` Uploaded image blob: ${coverImage.ref.$link}`); 422 } 423 } else { 424 log.warn(` Cover image not found: ${post.frontmatter.ogImage}`); 425 } 426 } 427 428 // Track atUri, content for state saving, and bskyPostRef 429 let atUri: string; 430 let contentForHash: string; 431 let bskyPostRef: StrongRef | undefined; 432 const relativeFilePath = path.relative(configDir, post.filePath); 433 434 // Check if bskyPostRef already exists in state 435 const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef; 436 437 if (action === "create") { 438 atUri = await createDocument(agent, post, config, coverImage); 439 post.frontmatter.atUri = atUri; 440 s.stop(`Created: ${atUri}`); 441 442 // Update frontmatter with atUri 443 const updatedContent = updateFrontmatterWithAtUri( 444 post.rawContent, 445 atUri, 446 ); 447 await fs.writeFile(post.filePath, updatedContent); 448 log.info(` Updated frontmatter in ${path.basename(post.filePath)}`); 449 450 // Use updated content (with atUri) for hash so next run sees matching hash 451 contentForHash = updatedContent; 452 publishedCount++; 453 } else { 454 atUri = post.frontmatter.atUri!; 455 await updateDocument(agent, post, atUri, config, coverImage); 456 s.stop(`Updated: ${atUri}`); 457 458 // For updates, rawContent already has atUri 459 contentForHash = post.rawContent; 460 updatedCount++; 461 } 462 463 // Create Bluesky post if enabled and conditions are met 464 if (blueskyEnabled) { 465 if (existingBskyPostRef) { 466 log.info(` Bluesky post already exists, skipping`); 467 bskyPostRef = existingBskyPostRef; 468 } else { 469 const publishDate = new Date(post.frontmatter.publishDate); 470 471 if (publishDate < cutoffDate) { 472 log.info( 473 ` Post is older than ${maxAgeDays} days, skipping Bluesky post`, 474 ); 475 } else { 476 // Create Bluesky post 477 try { 478 const canonicalUrl = `${config.siteUrl}${resolvePostPath(post, config.pathPrefix, config.pathTemplate)}`; 479 480 bskyPostRef = await createBlueskyPost(agent, { 481 title: post.frontmatter.title, 482 description: post.frontmatter.description, 483 canonicalUrl, 484 coverImage, 485 publishedAt: post.frontmatter.publishDate, 486 }); 487 488 // Update document record with bskyPostRef 489 await addBskyPostRefToDocument(agent, atUri, bskyPostRef); 490 log.info(` Created Bluesky post: ${bskyPostRef.uri}`); 491 bskyPostCount++; 492 } catch (bskyError) { 493 const errorMsg = 494 bskyError instanceof Error 495 ? bskyError.message 496 : String(bskyError); 497 log.warn(` Failed to create Bluesky post: ${errorMsg}`); 498 } 499 } 500 } 501 } 502 503 // Update state (use relative path from config directory) 504 const contentHash = await getContentHash(contentForHash); 505 state.posts[relativeFilePath] = { 506 contentHash, 507 atUri, 508 lastPublished: new Date().toISOString(), 509 slug: post.slug, 510 bskyPostRef, 511 }; 512 513 noteQueue.push({ post, action, atUri }); 514 } catch (error) { 515 const errorMessage = 516 error instanceof Error ? error.message : String(error); 517 s.stop(`Error publishing "${path.basename(post.filePath)}"`); 518 log.error(` ${errorMessage}`); 519 errorCount++; 520 } 521 } 522 523 // Pass 2: Create/update Remanso notes (atUris are now available for link resolution) 524 for (const { post, action, atUri } of noteQueue) { 525 try { 526 if (action === "create") { 527 await createNote(agent, post, atUri, context); 528 } else { 529 await updateNote(agent, post, atUri, context); 530 } 531 } catch (error) { 532 log.warn( 533 `Failed to create note for "${post.frontmatter.title}": ${error instanceof Error ? error.message : String(error)}`, 534 ); 535 } 536 } 537 538 // Re-process already-published posts with stale links to newly created posts 539 const newlyCreatedSlugs = noteQueue 540 .filter((r) => r.action === "create") 541 .map((r) => r.post.slug); 542 543 if (newlyCreatedSlugs.length > 0) { 544 const batchFilePaths = new Set(noteQueue.map((r) => r.post.filePath)); 545 const stalePosts = findPostsWithStaleLinks( 546 posts, 547 newlyCreatedSlugs, 548 batchFilePaths, 549 ); 550 551 for (const stalePost of stalePosts) { 552 try { 553 s.start(`Updating links in: ${stalePost.frontmatter.title}`); 554 await updateNote( 555 agent, 556 stalePost, 557 stalePost.frontmatter.atUri!, 558 context, 559 ); 560 s.stop(`Updated links: ${stalePost.frontmatter.title}`); 561 } catch (error) { 562 s.stop(`Failed to update links: ${stalePost.frontmatter.title}`); 563 log.warn( 564 ` ${error instanceof Error ? error.message : String(error)}`, 565 ); 566 } 567 } 568 } 569 570 // Delete records for removed files 571 let deletedCount = 0; 572 for (const { filePath, atUri } of deletedEntries) { 573 try { 574 const ag = await getAgent(); 575 s.start(`Deleting: ${filePath}`); 576 await deleteRecord(ag, atUri); 577 578 // Try to delete the corresponding Remanso note 579 try { 580 const noteAtUri = atUri.replace( 581 "site.standard.document", 582 "space.remanso.note", 583 ); 584 await deleteNote(ag, noteAtUri); 585 } catch { 586 // Note may not exist, ignore 587 } 588 589 delete state.posts[filePath]; 590 s.stop(`Deleted: ${filePath}`); 591 deletedCount++; 592 } catch (error) { 593 s.stop(`Failed to delete: ${filePath}`); 594 log.warn(` ${error instanceof Error ? error.message : String(error)}`); 595 } 596 } 597 598 // Delete unmatched PDS records (exist on PDS but no matching local file) 599 let unmatchedDeletedCount = 0; 600 for (const { atUri, title } of unmatchedEntries) { 601 try { 602 const ag = await getAgent(); 603 s.start(`Deleting unmatched: ${title}`); 604 await deleteRecord(ag, atUri); 605 606 // Try to delete the corresponding Remanso note 607 try { 608 const noteAtUri = atUri.replace( 609 "site.standard.document", 610 "space.remanso.note", 611 ); 612 await deleteNote(ag, noteAtUri); 613 } catch { 614 // Note may not exist, ignore 615 } 616 617 s.stop(`Deleted unmatched: ${title}`); 618 unmatchedDeletedCount++; 619 } catch (error) { 620 s.stop(`Failed to delete: ${title}`); 621 log.warn(` ${error instanceof Error ? error.message : String(error)}`); 622 } 623 } 624 625 // Save state 626 await saveState(configDir, state); 627 628 // Summary 629 log.message("\n---"); 630 const totalDeleted = deletedCount + unmatchedDeletedCount; 631 if (totalDeleted > 0) { 632 log.info(`Deleted: ${totalDeleted}`); 633 } 634 log.info(`Published: ${publishedCount}`); 635 log.info(`Updated: ${updatedCount}`); 636 if (bskyPostCount > 0) { 637 log.info(`Bluesky posts: ${bskyPostCount}`); 638 } 639 if (errorCount > 0) { 640 log.warn(`Errors: ${errorCount}`); 641 } 642 }, 643});