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