import * as fs from "node:fs/promises"; import { command, flag } from "cmd-ts"; import { select, spinner, log } from "@clack/prompts"; import * as path from "node:path"; import { loadConfig, loadState, saveState, findConfig } from "../lib/config"; import { loadCredentials, listAllCredentials, getCredentials, } from "../lib/credentials"; import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store"; import { createAgent, createDocument, updateDocument, uploadImage, resolveImagePath, createBlueskyPost, addBskyPostRefToDocument, listDocuments, deleteDocument, } from "../lib/atproto"; import { scanContentDirectory, getContentHash, updateFrontmatterWithAtUri, resolvePostPath, } from "../lib/markdown"; import type { BlogPost, BlobObject, StrongRef, PublisherConfig } from "../lib/types"; import { exitOnCancel } from "../lib/prompts"; import { stripMarkdownForText } from "../lib/markdown"; interface DocumentPayload { title: string; description?: string; path: string; textContent?: string; publishedAt: string; canonicalUrl: string; tags?: string[]; coverImage?: BlobObject; } function generateDocumentPayload( post: BlogPost, config: PublisherConfig, coverImage?: BlobObject, ): DocumentPayload { const postPath = resolvePostPath(post, config.pathPrefix, config.pathTemplate); const publishDate = new Date(post.frontmatter.publishDate); let textContent: string | null = null; if ( config.publishContent && config.textContentField && post.rawFrontmatter?.[config.textContentField] ) { textContent = String(post.rawFrontmatter[config.textContentField]); } else if (config.publishContent) { textContent = stripMarkdownForText(post.content); } return { title: post.frontmatter.title, description: post.frontmatter.description, path: postPath, textContent: textContent?.slice(0, 10000), publishedAt: publishDate.toISOString(), canonicalUrl: `${config.siteUrl}${postPath}`, tags: post.frontmatter.tags, coverImage, }; } interface FieldDiff { field: string; oldValue: string; newValue: string; } type DiffStatus = "created" | "updated" | "deleted"; interface PostDiff { status: DiffStatus; post?: BlogPost; pdsDoc?: Record; localPayload?: DocumentPayload; diffs: FieldDiff[]; localFileExists: boolean; } function computeDiff( localPayload: DocumentPayload, pdsDoc: Record, ): FieldDiff[] { const diffs: FieldDiff[] = []; const fields: Array = [ "title", "description", "path", "publishedAt", "canonicalUrl", ]; for (const field of fields) { const localValue = localPayload[field]; const pdsValue = pdsDoc[field]; const localStr = localValue !== undefined ? String(localValue) : ""; const pdsStr = pdsValue !== undefined ? String(pdsValue) : ""; if (localStr !== pdsStr) { if (field === "textContent") { diffs.push({ field, oldValue: pdsValue ? `${(pdsValue as string).length} chars` : "(none)", newValue: localValue ? `${(localValue as string).length} chars` : "(none)", }); } else { diffs.push({ field, oldValue: pdsStr || "(none)", newValue: localStr || "(none)", }); } } } return diffs; } export const publishCommand = command({ name: "publish", description: "Publish content to ATProto", args: { force: flag({ long: "force", short: "f", description: "Force publish all posts, ignoring change detection", }), dryRun: flag({ long: "dry-run", short: "n", description: "Preview what would be published without making changes", }), verbose: flag({ long: "verbose", short: "v", description: "Show more information", }), }, handler: async ({ force, dryRun, verbose }) => { // Load config const configPath = await findConfig(); if (!configPath) { log.error("No publisher.config.ts found. Run 'publisher init' first."); process.exit(1); } const config = await loadConfig(configPath); const configDir = path.dirname(configPath); log.info(`Site: ${config.siteUrl}`); log.info(`Content directory: ${config.contentDir}`); // Load credentials let credentials = await loadCredentials(config.identity); // If no credentials resolved, check if we need to prompt for identity selection if (!credentials) { const identities = await listAllCredentials(); if (identities.length === 0) { log.error( "No credentials found. Run 'sequoia login' or 'sequoia auth' first.", ); log.info( "Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables.", ); process.exit(1); } // Build labels with handles for OAuth sessions const options = await Promise.all( identities.map(async (cred) => { if (cred.type === "oauth") { const handle = await getOAuthHandle(cred.id); return { value: cred.id, label: `${handle || cred.id} (OAuth)`, }; } return { value: cred.id, label: `${cred.id} (App Password)`, }; }), ); // Multiple identities exist but none selected - prompt user log.info("Multiple identities found. Select one to use:"); const selected = exitOnCancel( await select({ message: "Identity:", options, }), ); // Load the selected credentials const selectedCred = identities.find((c) => c.id === selected); if (selectedCred?.type === "oauth") { const session = await getOAuthSession(selected); if (session) { const handle = await getOAuthHandle(selected); credentials = { type: "oauth", did: selected, handle: handle || selected, }; } } else { credentials = await getCredentials(selected); } if (!credentials) { log.error("Failed to load selected credentials."); process.exit(1); } const displayId = credentials.type === "oauth" ? credentials.handle || credentials.did : credentials.identifier; log.info( `Tip: Add "identity": "${displayId}" to sequoia.json to use this by default.`, ); } // Resolve content directory const contentDir = path.isAbsolute(config.contentDir) ? config.contentDir : path.join(configDir, config.contentDir); const imagesDir = config.imagesDir ? path.isAbsolute(config.imagesDir) ? config.imagesDir : path.join(configDir, config.imagesDir) : undefined; // Load state const state = await loadState(configDir); // Create spinner for operations const s = spinner(); // Create agent for fetching PDS docs const connectingTo = credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl; s.start(`Connecting as ${connectingTo}...`); let agent: Awaited> | undefined; try { agent = await createAgent(credentials); s.stop(`Logged in as ${agent.did}`); } catch (error) { s.stop("Failed to login"); log.error(`Failed to login: ${error}`); process.exit(1); } // Fetch current PDS documents s.start("Fetching documents from PDS..."); const pdsDocs = await listDocuments(agent, config.publicationUri); s.stop(`Found ${pdsDocs.length} documents on PDS`); // Build a map of path -> PDS doc const pdsDocsByPath = new Map(); for (const doc of pdsDocs) { pdsDocsByPath.set(doc.value.path, doc); } // Scan for posts s.start("Scanning for posts..."); const posts = await scanContentDirectory(contentDir, { frontmatterMapping: config.frontmatter, ignorePatterns: config.ignore, slugField: config.frontmatter?.slugField, removeIndexFromSlug: config.removeIndexFromSlug, stripDatePrefix: config.stripDatePrefix, }); s.stop(`Found ${posts.length} posts`); // Determine which posts need publishing with diff detection const postsToPublish: Array<{ post: BlogPost; action: "create" | "update"; reason: string; diffs: FieldDiff[]; }> = []; const deletions: Array<{ postStateKey: string; atUri: string; title: string; }> = []; const draftPosts: BlogPost[] = []; // Get local file paths const localPaths = new Set(); for (const post of posts) { const postPath = resolvePostPath( post, config.pathPrefix, config.pathTemplate, ); localPaths.add(postPath); } // Detect deletions (posts in state that are no longer local or have changed path) for (const [relativeFilePath, postState] of Object.entries(state.posts)) { const postStateKey = postState.pdsUri || postState.atUri; if (!postStateKey) continue; // Check if local file still exists const localPost = posts.find( (p) => path.relative(configDir, p.filePath) === relativeFilePath, ); if (!localPost) { // Local file was deleted const pdsDoc = pdsDocsByPath.get( Object.values(state.orphaned || {}).find( (o) => o.atUri === postState.pdsUri, )?.path || "", ); if (postState.pdsUri) { deletions.push({ postStateKey: relativeFilePath, atUri: postState.pdsUri, title: pdsDoc?.value.title || "(unknown)", }); } } } for (const post of posts) { // Skip draft posts if (post.frontmatter.draft) { draftPosts.push(post); continue; } const contentHash = await getContentHash(post.rawContent); const relativeFilePath = path.relative(configDir, post.filePath); const postState = state.posts[relativeFilePath]; const postPath = resolvePostPath(post, config.pathPrefix, config.pathTemplate); // Generate expected document payload const localPayload = generateDocumentPayload(post, config); // Check if there's a matching PDS doc const pdsDoc = pdsDocsByPath.get(postPath); if (force) { postsToPublish.push({ post, action: post.frontmatter.atUri ? "update" : "create", reason: "forced", diffs: [], }); } else if (!postState && !pdsDoc) { // New post postsToPublish.push({ post, action: "create", reason: "new post", diffs: [], }); } else if (pdsDoc && postState) { // Exists on PDS - check for changes const diffs = computeDiff( localPayload, pdsDoc.value as unknown as Record, ); if (diffs.length > 0) { postsToPublish.push({ post, action: "update", reason: "content changed", diffs, }); } else if (postState.contentHash !== contentHash) { // Hash changed but no field diff (might be formatting only) postsToPublish.push({ post, action: "update", reason: "content changed", diffs: [ { field: "content", oldValue: "(same fields, hash differs)", newValue: "(content reformatted)", }, ], }); } } else if (!pdsDoc && postState) { // In state but not on PDS - needs to be created postsToPublish.push({ post, action: "create", reason: "missing on PDS", diffs: [], }); } } if (draftPosts.length > 0) { log.info( `Skipping ${draftPosts.length} draft post${draftPosts.length === 1 ? "" : "s"}`, ); } if (postsToPublish.length === 0 && deletions.length === 0) { log.success("All posts are up to date. Nothing to publish."); return; } // Display changes log.message("\nChanges to publish:\n"); // Bluesky posting configuration const blueskyEnabled = config.bluesky?.enabled ?? false; const maxAgeDays = config.bluesky?.maxAgeDays ?? 7; const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays); // New posts const newPosts = postsToPublish.filter((p) => p.action === "create"); for (const { post, reason } of newPosts) { log.message(` + New: ${post.frontmatter.title} (${reason})`); } // Updated posts const updatedPosts = postsToPublish.filter((p) => p.action === "update"); for (const { post, reason, diffs } of updatedPosts) { log.message(` ~ Updated: ${post.frontmatter.title}`); for (const diff of diffs) { if (diff.field === "content") { log.message(` - ${diff.field}: ${diff.oldValue} → ${diff.newValue}`); } else if (diff.field === "textContent") { log.message(` - ${diff.field}: (${diff.oldValue} → ${diff.newValue})`); } else { log.message( ` - ${diff.field}: '${diff.oldValue}' → '${diff.newValue}'`, ); } } } // Deletions for (const { title } of deletions) { log.message(` - Deleted: ${title}`); log.message(` This post will be unpublished from PDS.`); } // Bluesky posts count let bskyPostCount = 0; if (blueskyEnabled) { for (const { post, action } of postsToPublish) { const relativeFilePath = path.relative(configDir, post.filePath); const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef; if (!existingBskyPostRef) { const publishDate = new Date(post.frontmatter.publishDate); if (publishDate >= cutoffDate) { bskyPostCount++; } } } if (bskyPostCount > 0) { log.message(`\nBluesky posts: ${bskyPostCount} will be created`); } } // Prompt for confirmation if there are deletions if (deletions.length > 0 && !dryRun) { log.message(""); const confirmed = exitOnCancel( await select({ message: "There are posts to delete. Continue?", options: [ { value: "yes", label: "Yes, publish including deletions" }, { value: "no", label: "No, cancel" }, ], }), ); if (confirmed === "no") { log.info("Publish cancelled."); process.exit(0); } } if (dryRun) { log.info("\nDry run complete. No changes made."); return; } // Publish posts let publishedCount = 0; let updatedCount = 0; let errorCount = 0; let actualBskyPostCount = 0; for (const { post, action } of postsToPublish) { s.start(`Publishing: ${post.frontmatter.title}`); try { // Handle cover image upload let coverImage: BlobObject | undefined; if (post.frontmatter.ogImage) { const imagePath = await resolveImagePath( post.frontmatter.ogImage, imagesDir, contentDir, ); if (imagePath) { log.info(` Uploading cover image: ${path.basename(imagePath)}`); coverImage = await uploadImage(agent, imagePath); if (coverImage) { log.info(` Uploaded image blob: ${coverImage.ref.$link}`); } } else { log.warn(` Cover image not found: ${post.frontmatter.ogImage}`); } } // Track atUri, content for state saving, and bskyPostRef let atUri: string; let contentForHash: string; let bskyPostRef: StrongRef | undefined; const relativeFilePath = path.relative(configDir, post.filePath); // Check if bskyPostRef already exists in state const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef; if (action === "create") { atUri = await createDocument(agent, post, config, coverImage); s.stop(`Created: ${atUri}`); // Update frontmatter with atUri const updatedContent = updateFrontmatterWithAtUri( post.rawContent, atUri, ); await fs.writeFile(post.filePath, updatedContent); log.info(` Updated frontmatter in ${path.basename(post.filePath)}`); // Use updated content (with atUri) for hash so next run sees matching hash contentForHash = updatedContent; publishedCount++; } else { atUri = post.frontmatter.atUri!; await updateDocument(agent, post, atUri, config, coverImage); s.stop(`Updated: ${atUri}`); // For updates, rawContent already has atUri contentForHash = post.rawContent; updatedCount++; } // Create Bluesky post if enabled and conditions are met if (blueskyEnabled) { if (existingBskyPostRef) { log.info(` Bluesky post already exists, skipping`); bskyPostRef = existingBskyPostRef; } else { const publishDate = new Date(post.frontmatter.publishDate); if (publishDate < cutoffDate) { log.info( ` Post is older than ${maxAgeDays} days, skipping Bluesky post`, ); } else { // Create Bluesky post try { const canonicalUrl = `${config.siteUrl}${resolvePostPath(post, config.pathPrefix, config.pathTemplate)}`; bskyPostRef = await createBlueskyPost(agent, { title: post.frontmatter.title, description: post.frontmatter.description, bskyPost: post.frontmatter.bskyPost, canonicalUrl, coverImage, publishedAt: post.frontmatter.publishDate, }); // Update document record with bskyPostRef await addBskyPostRefToDocument(agent, atUri, bskyPostRef); log.info(` Created Bluesky post: ${bskyPostRef.uri}`); actualBskyPostCount++; } catch (bskyError) { const errorMsg = bskyError instanceof Error ? bskyError.message : String(bskyError); log.warn(` Failed to create Bluesky post: ${errorMsg}`); } } } } // Update state (use relative path from config directory) const contentHash = await getContentHash(contentForHash); state.posts[relativeFilePath] = { contentHash, atUri, pdsUri: atUri, lastPublished: new Date().toISOString(), slug: post.slug, bskyPostRef, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); s.stop(`Error publishing "${path.basename(post.filePath)}"`); log.error(` ${errorMessage}`); errorCount++; } } // Handle deletions let deletedCount = 0; if (deletions.length > 0) { for (const { postStateKey, atUri } of deletions) { try { s.start(`Deleting: ${atUri}`); await deleteDocument(agent, atUri); s.stop(`Deleted: ${atUri}`); // Remove from state delete state.posts[postStateKey]; deletedCount++; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); s.stop(`Error deleting "${atUri}"`); log.error(` ${errorMessage}`); errorCount++; } } } // Save state await saveState(configDir, state); // Summary log.message("\n---"); log.info(`Published: ${publishedCount}`); log.info(`Updated: ${updatedCount}`); if (deletedCount > 0) { log.info(`Deleted: ${deletedCount}`); } if (actualBskyPostCount > 0) { log.info(`Bluesky posts: ${actualBskyPostCount}`); } if (errorCount > 0) { log.warn(`Errors: ${errorCount}`); } }, });