Something went wrong. Try again.
A CLI for publishing standard.site documents to ATProto
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518import * 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 { CONFIG_FILENAME, 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, COVER_IMAGE_MAX_SIZE,} from "../lib/atproto";import { scanContentDirectory, getContentHash, updateFrontmatterWithAtUri, resolvePostPath,} from "../lib/markdown";import type { BlogPost, BlobObject, StrongRef } from "../lib/types";import { syncPublication, syncStateFromPDS } from "../lib/sync";import { exitOnCancel } from "../lib/prompts";
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 ${CONFIG_FILENAME} found. Run 'sequoia 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 let state = await loadState(configDir);
// Auto-sync from PDS if state is empty (prevents duplicates on fresh clones) const s = spinner(); let agent: Awaited<ReturnType<typeof createAgent>> | undefined;
if ( config.autoSync !== false && (!state.publication || Object.keys(state.posts).length === 0) && !dryRun ) { // Create agent early for sync (will be reused for publishing) const connectingTo = credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl; s.start(`Connecting as ${connectingTo}...`); 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); }
try { s.start("Auto-syncing state from PDS..."); const syncResult = await syncStateFromPDS(agent, config, configDir, { updateFrontmatter: true, quiet: true, }); s.stop(`Auto-synced ${syncResult.matchedCount} posts from PDS`); state = syncResult.state; } catch (error) { s.stop("Auto-sync failed"); log.warn( `Auto-sync failed: ${error instanceof Error ? error.message : String(error)}`, ); log.warn( "Continuing with empty state. Run 'sequoia sync' manually to fix.", ); } }
// Scan for posts s.start("Scanning for posts..."); const posts = await scanContentDirectory(contentDir, imagesDir, { 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 const postsToPublish: Array<{ post: BlogPost; action: "create" | "update"; reason: string; }> = []; const draftPosts: BlogPost[] = [];
for (const post of posts) { // Skip draft posts if (post.frontmatter.draft) { draftPosts.push(post); continue; }
// Resolve the cover image to include it in the hash. if (post.frontmatter.ogImage) { post.coverImagePath = await resolveImagePath( post.frontmatter.ogImage, imagesDir, contentDir, ); }
const contentHash = await getContentHash(post); const relativeFilePath = path.relative(configDir, post.filePath); const postState = state.posts[relativeFilePath];
if (force) { postsToPublish.push({ post, action: post.frontmatter.atUri ? "update" : "create", reason: "forced", }); } else if (!postState) { // New post postsToPublish.push({ post, action: "create", reason: "new post", }); } else if (postState.contentHash !== contentHash) { // Changed post postsToPublish.push({ post, action: post.frontmatter.atUri ? "update" : "create", reason: "content changed", }); } }
if (draftPosts.length > 0) { log.info( `Skipping ${draftPosts.length} draft post${draftPosts.length === 1 ? "" : "s"}`, ); }
if (postsToPublish.length === 0) { log.success("All posts are up to date. Nothing to publish."); return; }
log.info(`\n${postsToPublish.length} posts 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);
let isValid = true; for (const { post, action, reason } of postsToPublish) { const icon = action === "create" ? "+" : "~"; const relativeFilePath = path.relative(configDir, post.filePath); const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef;
let bskyNote = ""; if (blueskyEnabled) { if (existingBskyPostRef) { bskyNote = " [bsky: exists]"; } else { const publishDate = new Date(post.frontmatter.publishDate); if (publishDate < cutoffDate) { bskyNote = ` [bsky: skipped, older than ${maxAgeDays} days]`; } else { bskyNote = " [bsky: will post]"; } } }
log.message(` ${icon} ${post.frontmatter.title} (${reason})${bskyNote}`); if (verbose) { const postPath = resolvePostPath( post, config.pathPrefix, config.pathTemplate, ); log.message(` Post URL: ${config.siteUrl}${postPath}`); if (post.frontmatter.ogImage) { if (!post.coverImagePath) { log.error( ` Failed to resolve cover image for ${post.frontmatter.title}: ${post.frontmatter.ogImage}`, ); } else { log.message( ` Resolved cover image for ${post.frontmatter.title}: ${post.coverImagePath}`, ); } } log.message( ` Frontmatter:\n${Object.entries(post.frontmatter) .map(([key, value]) => ` - ${key}: ${value}`) .join("\n")}`, ); } const postValid = await validatePost(post); isValid &&= postValid; }
if (!isValid) { return; }
if (dryRun) { if (blueskyEnabled) { log.info(`\nBluesky posting: enabled (max age: ${maxAgeDays} days)`); } log.info("\nDry run complete. No changes made."); return; }
// Create agent (skip if already created during auto-sync) if (!agent) { const connectingTo = credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl; s.start(`Connecting as ${connectingTo}...`); 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); } }
// Make sure publication state is available for enhanced links. if (!state.publication) { state.publication = await syncPublication( agent, config.publicationUri, false, ); }
// Publish posts let publishedCount = 0; let updatedCount = 0; let errorCount = 0; let bskyPostCount = 0;
for (const { post, action } of postsToPublish) { s.start(`Publishing: ${post.frontmatter.title}`);
try { // Handle cover image upload let coverImage: BlobObject | undefined; if (post.coverImagePath) { log.info( ` Uploading cover image: ${path.basename(post.coverImagePath)}`, ); coverImage = await uploadImage(agent, post.coverImagePath); 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 documentRef: StrongRef; 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") { documentRef = await createDocument(agent, post, config, coverImage); s.stop(`Created: ${documentRef.uri}`);
// Update frontmatter with atUri post.rawContent = updateFrontmatterWithAtUri( post.rawContent, documentRef.uri, ); await fs.writeFile(post.filePath, post.rawContent); log.info(` Updated frontmatter in ${path.basename(post.filePath)}`);
publishedCount++; } else { // Validate post. const atUri = post.frontmatter.atUri!; documentRef = await updateDocument( agent, post, atUri, config, coverImage, ); s.stop(`Updated: ${documentRef.uri}`);
// For updates, rawContent already has atUri 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, documentRef, publicationRef: state.publication, coverImage, publishedAt: post.frontmatter.publishDate, });
// Update document record with bskyPostRef await addBskyPostRefToDocument( agent, documentRef.uri, bskyPostRef, ); log.info(` Created Bluesky post: ${bskyPostRef.uri}`); bskyPostCount++; } 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(post); state.posts[relativeFilePath] = { contentHash, atUri: documentRef.uri, 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++; } }
// Save state await saveState(configDir, state);
// Summary log.message("\n---"); log.info(`Published: ${publishedCount}`); log.info(`Updated: ${updatedCount}`); if (bskyPostCount > 0) { log.info(`Bluesky posts: ${bskyPostCount}`); } if (errorCount > 0) { log.warn(`Errors: ${errorCount}`); } },});
async function validatePost(post: BlogPost): Promise<boolean> { if (post.coverImagePath) { const stat = await fs.stat(post.coverImagePath); if (stat.size >= COVER_IMAGE_MAX_SIZE) { log.error(` Cover image "${post.coverImagePath}" must be less than 1MB`); return false; } }
return true;}