A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing
at main 398 lines 11 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"; 28 29export const publishCommand = command({ 30 name: "publish", 31 description: "Publish content to ATProto", 32 args: { 33 force: flag({ 34 long: "force", 35 short: "f", 36 description: "Force publish all posts, ignoring change detection", 37 }), 38 dryRun: flag({ 39 long: "dry-run", 40 short: "n", 41 description: "Preview what would be published without making changes", 42 }), 43 }, 44 handler: async ({ force, dryRun }) => { 45 // Load config 46 const configPath = await findConfig(); 47 if (!configPath) { 48 log.error("No publisher.config.ts found. Run 'publisher init' first."); 49 process.exit(1); 50 } 51 52 const config = await loadConfig(configPath); 53 const configDir = path.dirname(configPath); 54 55 log.info(`Site: ${config.siteUrl}`); 56 log.info(`Content directory: ${config.contentDir}`); 57 58 // Load credentials 59 let credentials = await loadCredentials(config.identity); 60 61 // If no credentials resolved, check if we need to prompt for identity selection 62 if (!credentials) { 63 const identities = await listAllCredentials(); 64 if (identities.length === 0) { 65 log.error( 66 "No credentials found. Run 'sequoia login' or 'sequoia auth' first.", 67 ); 68 log.info( 69 "Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables.", 70 ); 71 process.exit(1); 72 } 73 74 // Build labels with handles for OAuth sessions 75 const options = await Promise.all( 76 identities.map(async (cred) => { 77 if (cred.type === "oauth") { 78 const handle = await getOAuthHandle(cred.id); 79 return { 80 value: cred.id, 81 label: `${handle || cred.id} (OAuth)`, 82 }; 83 } 84 return { 85 value: cred.id, 86 label: `${cred.id} (App Password)`, 87 }; 88 }), 89 ); 90 91 // Multiple identities exist but none selected - prompt user 92 log.info("Multiple identities found. Select one to use:"); 93 const selected = exitOnCancel( 94 await select({ 95 message: "Identity:", 96 options, 97 }), 98 ); 99 100 // Load the selected credentials 101 const selectedCred = identities.find((c) => c.id === selected); 102 if (selectedCred?.type === "oauth") { 103 const session = await getOAuthSession(selected); 104 if (session) { 105 const handle = await getOAuthHandle(selected); 106 credentials = { 107 type: "oauth", 108 did: selected, 109 handle: handle || selected, 110 }; 111 } 112 } else { 113 credentials = await getCredentials(selected); 114 } 115 116 if (!credentials) { 117 log.error("Failed to load selected credentials."); 118 process.exit(1); 119 } 120 121 const displayId = 122 credentials.type === "oauth" 123 ? credentials.handle || credentials.did 124 : credentials.identifier; 125 log.info( 126 `Tip: Add "identity": "${displayId}" to sequoia.json to use this by default.`, 127 ); 128 } 129 130 // Resolve content directory 131 const contentDir = path.isAbsolute(config.contentDir) 132 ? config.contentDir 133 : path.join(configDir, config.contentDir); 134 135 const imagesDir = config.imagesDir 136 ? path.isAbsolute(config.imagesDir) 137 ? config.imagesDir 138 : path.join(configDir, config.imagesDir) 139 : undefined; 140 141 // Load state 142 const state = await loadState(configDir); 143 144 // Scan for posts 145 const s = spinner(); 146 s.start("Scanning for posts..."); 147 const posts = await scanContentDirectory(contentDir, { 148 frontmatterMapping: config.frontmatter, 149 ignorePatterns: config.ignore, 150 slugField: config.frontmatter?.slugField, 151 removeIndexFromSlug: config.removeIndexFromSlug, 152 stripDatePrefix: config.stripDatePrefix, 153 }); 154 s.stop(`Found ${posts.length} posts`); 155 156 // Determine which posts need publishing 157 const postsToPublish: Array<{ 158 post: BlogPost; 159 action: "create" | "update"; 160 reason: string; 161 }> = []; 162 const draftPosts: BlogPost[] = []; 163 164 for (const post of posts) { 165 // Skip draft posts 166 if (post.frontmatter.draft) { 167 draftPosts.push(post); 168 continue; 169 } 170 171 const contentHash = await getContentHash(post.rawContent); 172 const relativeFilePath = path.relative(configDir, post.filePath); 173 const postState = state.posts[relativeFilePath]; 174 175 if (force) { 176 postsToPublish.push({ 177 post, 178 action: post.frontmatter.atUri ? "update" : "create", 179 reason: "forced", 180 }); 181 } else if (!postState) { 182 // New post 183 postsToPublish.push({ 184 post, 185 action: "create", 186 reason: "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.frontmatter.title} (${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 for (const { post, action } of postsToPublish) { 268 s.start(`Publishing: ${post.frontmatter.title}`); 269 270 try { 271 // Handle cover image upload 272 let coverImage: BlobObject | undefined; 273 if (post.frontmatter.ogImage) { 274 const imagePath = await resolveImagePath( 275 post.frontmatter.ogImage, 276 imagesDir, 277 contentDir, 278 ); 279 280 if (imagePath) { 281 log.info(` Uploading cover image: ${path.basename(imagePath)}`); 282 coverImage = await uploadImage(agent, imagePath); 283 if (coverImage) { 284 log.info(` Uploaded image blob: ${coverImage.ref.$link}`); 285 } 286 } else { 287 log.warn(` Cover image not found: ${post.frontmatter.ogImage}`); 288 } 289 } 290 291 // Track atUri, content for state saving, and bskyPostRef 292 let atUri: string; 293 let contentForHash: string; 294 let bskyPostRef: StrongRef | undefined; 295 const relativeFilePath = path.relative(configDir, post.filePath); 296 297 // Check if bskyPostRef already exists in state 298 const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef; 299 300 if (action === "create") { 301 atUri = await createDocument(agent, post, config, coverImage); 302 s.stop(`Created: ${atUri}`); 303 304 // Update frontmatter with atUri 305 const updatedContent = updateFrontmatterWithAtUri( 306 post.rawContent, 307 atUri, 308 ); 309 await fs.writeFile(post.filePath, updatedContent); 310 log.info(` Updated frontmatter in ${path.basename(post.filePath)}`); 311 312 // Use updated content (with atUri) for hash so next run sees matching hash 313 contentForHash = updatedContent; 314 publishedCount++; 315 } else { 316 atUri = post.frontmatter.atUri!; 317 await updateDocument(agent, post, atUri, config, coverImage); 318 s.stop(`Updated: ${atUri}`); 319 320 // For updates, rawContent already has atUri 321 contentForHash = post.rawContent; 322 updatedCount++; 323 } 324 325 // Create Bluesky post if enabled and conditions are met 326 if (blueskyEnabled) { 327 if (existingBskyPostRef) { 328 log.info(` Bluesky post already exists, skipping`); 329 bskyPostRef = existingBskyPostRef; 330 } else { 331 const publishDate = new Date(post.frontmatter.publishDate); 332 333 if (publishDate < cutoffDate) { 334 log.info( 335 ` Post is older than ${maxAgeDays} days, skipping Bluesky post`, 336 ); 337 } else { 338 // Create Bluesky post 339 try { 340 const pathPrefix = config.pathPrefix || "/posts"; 341 const canonicalUrl = `${config.siteUrl}${pathPrefix}/${post.slug}`; 342 343 bskyPostRef = await createBlueskyPost(agent, { 344 title: post.frontmatter.title, 345 description: post.frontmatter.description, 346 canonicalUrl, 347 coverImage, 348 publishedAt: post.frontmatter.publishDate, 349 }); 350 351 // Update document record with bskyPostRef 352 await addBskyPostRefToDocument(agent, atUri, bskyPostRef); 353 log.info(` Created Bluesky post: ${bskyPostRef.uri}`); 354 bskyPostCount++; 355 } catch (bskyError) { 356 const errorMsg = 357 bskyError instanceof Error 358 ? bskyError.message 359 : String(bskyError); 360 log.warn(` Failed to create Bluesky post: ${errorMsg}`); 361 } 362 } 363 } 364 } 365 366 // Update state (use relative path from config directory) 367 const contentHash = await getContentHash(contentForHash); 368 state.posts[relativeFilePath] = { 369 contentHash, 370 atUri, 371 lastPublished: new Date().toISOString(), 372 slug: post.slug, 373 bskyPostRef, 374 }; 375 } catch (error) { 376 const errorMessage = 377 error instanceof Error ? error.message : String(error); 378 s.stop(`Error publishing "${path.basename(post.filePath)}"`); 379 log.error(` ${errorMessage}`); 380 errorCount++; 381 } 382 } 383 384 // Save state 385 await saveState(configDir, state); 386 387 // Summary 388 log.message("\n---"); 389 log.info(`Published: ${publishedCount}`); 390 log.info(`Updated: ${updatedCount}`); 391 if (bskyPostCount > 0) { 392 log.info(`Bluesky posts: ${bskyPostCount}`); 393 } 394 if (errorCount > 0) { 395 log.warn(`Errors: ${errorCount}`); 396 } 397 }, 398});