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