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