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