A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing

chore: add auto sync to publishing

authored by stevedylan.dev and committed by tangled.org 8af71e22 caaefffb

+281 -134
+65 -14
packages/cli/src/commands/publish.ts
··· 25 25 resolvePostPath, 26 26 } from "../lib/markdown"; 27 27 import type { BlogPost, BlobObject, StrongRef } from "../lib/types"; 28 + import { syncStateFromPDS } from "../lib/sync"; 28 29 import { exitOnCancel } from "../lib/prompts"; 29 30 30 31 export const publishCommand = command({ ··· 145 146 : undefined; 146 147 147 148 // Load state 148 - const state = await loadState(configDir); 149 + let state = await loadState(configDir); 150 + 151 + // Auto-sync from PDS if state is empty (prevents duplicates on fresh clones) 152 + const s = spinner(); 153 + let agent: Awaited<ReturnType<typeof createAgent>> | undefined; 154 + 155 + if ( 156 + config.autoSync !== false && 157 + Object.keys(state.posts).length === 0 && 158 + !dryRun 159 + ) { 160 + // Create agent early for sync (will be reused for publishing) 161 + const connectingTo = 162 + credentials.type === "oauth" 163 + ? credentials.handle 164 + : credentials.pdsUrl; 165 + s.start(`Connecting as ${connectingTo}...`); 166 + try { 167 + agent = await createAgent(credentials); 168 + s.stop(`Logged in as ${agent.did}`); 169 + } catch (error) { 170 + s.stop("Failed to login"); 171 + log.error(`Failed to login: ${error}`); 172 + process.exit(1); 173 + } 174 + 175 + try { 176 + s.start("Auto-syncing state from PDS..."); 177 + const syncResult = await syncStateFromPDS( 178 + agent, 179 + config, 180 + configDir, 181 + { 182 + updateFrontmatter: true, 183 + quiet: true, 184 + }, 185 + ); 186 + s.stop( 187 + `Auto-synced ${syncResult.matchedCount} posts from PDS`, 188 + ); 189 + state = syncResult.state; 190 + } catch (error) { 191 + s.stop("Auto-sync failed"); 192 + log.warn( 193 + `Auto-sync failed: ${error instanceof Error ? error.message : String(error)}`, 194 + ); 195 + log.warn("Continuing with empty state. Run 'sequoia sync' manually to fix."); 196 + } 197 + } 149 198 150 199 // Scan for posts 151 - const s = spinner(); 152 200 s.start("Scanning for posts..."); 153 201 const posts = await scanContentDirectory(contentDir, { 154 202 frontmatterMapping: config.frontmatter, ··· 261 309 return; 262 310 } 263 311 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); 312 + // Create agent (skip if already created during auto-sync) 313 + if (!agent) { 314 + const connectingTo = 315 + credentials.type === "oauth" 316 + ? credentials.handle 317 + : credentials.pdsUrl; 318 + s.start(`Connecting as ${connectingTo}...`); 319 + try { 320 + agent = await createAgent(credentials); 321 + s.stop(`Logged in as ${agent.did}`); 322 + } catch (error) { 323 + s.stop("Failed to login"); 324 + log.error(`Failed to login: ${error}`); 325 + process.exit(1); 326 + } 276 327 } 277 328 278 329 // Publish posts
+17 -120
packages/cli/src/commands/sync.ts
··· 1 - import * as fs from "node:fs/promises"; 2 1 import { command, flag } from "cmd-ts"; 3 2 import { select, spinner, log } from "@clack/prompts"; 4 3 import * as path from "node:path"; 5 - import { loadConfig, loadState, saveState, findConfig } from "../lib/config"; 4 + import { loadConfig, findConfig } from "../lib/config"; 6 5 import { 7 6 loadCredentials, 8 7 listAllCredentials, 9 8 getCredentials, 10 9 } from "../lib/credentials"; 11 10 import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store"; 12 - import { createAgent, listDocuments } from "../lib/atproto"; 13 - import { 14 - scanContentDirectory, 15 - getContentHash, 16 - updateFrontmatterWithAtUri, 17 - resolvePostPath, 18 - } from "../lib/markdown"; 11 + import { createAgent } from "../lib/atproto"; 12 + import { syncStateFromPDS } from "../lib/sync"; 19 13 import { exitOnCancel } from "../lib/prompts"; 20 14 21 15 export const syncCommand = command({ ··· 121 115 process.exit(1); 122 116 } 123 117 124 - // Fetch documents from PDS 118 + // Sync state from PDS 125 119 s.start("Fetching documents from PDS..."); 126 - const documents = await listDocuments(agent, config.publicationUri); 127 - s.stop(`Found ${documents.length} documents on PDS`); 128 - 129 - if (documents.length === 0) { 130 - log.info("No documents found for this publication."); 131 - return; 132 - } 133 - 134 - // Resolve content directory 135 - const contentDir = path.isAbsolute(config.contentDir) 136 - ? config.contentDir 137 - : path.join(configDir, config.contentDir); 138 - 139 - // Scan local posts 140 - s.start("Scanning local content..."); 141 - const localPosts = await scanContentDirectory(contentDir, { 142 - frontmatterMapping: config.frontmatter, 143 - ignorePatterns: config.ignore, 144 - slugField: config.frontmatter?.slugField, 145 - removeIndexFromSlug: config.removeIndexFromSlug, 146 - stripDatePrefix: config.stripDatePrefix, 120 + const result = await syncStateFromPDS(agent, config, configDir, { 121 + updateFrontmatter, 122 + dryRun, 123 + quiet: false, 147 124 }); 148 - s.stop(`Found ${localPosts.length} local posts`); 125 + s.stop(`Found documents on PDS`); 149 126 150 - // Build a map of path -> local post for matching 151 - // Document path is like /posts/my-post-slug (or custom pathPrefix/pathTemplate) 152 - const postsByPath = new Map<string, (typeof localPosts)[0]>(); 153 - for (const post of localPosts) { 154 - const postPath = resolvePostPath( 155 - post, 156 - config.pathPrefix, 157 - config.pathTemplate, 127 + if (!dryRun) { 128 + const stateCount = Object.keys(result.state.posts).length; 129 + log.success( 130 + `\nSaved .sequoia-state.json (${stateCount} entries)`, 158 131 ); 159 - postsByPath.set(postPath, post); 160 - } 161 132 162 - // Load existing state 163 - const state = await loadState(configDir); 164 - const originalPostCount = Object.keys(state.posts).length; 165 - 166 - // Track changes 167 - let matchedCount = 0; 168 - let unmatchedCount = 0; 169 - const frontmatterUpdates: Array<{ filePath: string; atUri: string }> = []; 170 - 171 - log.message("\nMatching documents to local files:\n"); 172 - 173 - for (const doc of documents) { 174 - const docPath = doc.value.path; 175 - const localPost = postsByPath.get(docPath); 176 - 177 - if (localPost) { 178 - matchedCount++; 179 - log.message(` ✓ ${doc.value.title}`); 180 - log.message(` Path: ${docPath}`); 181 - log.message(` URI: ${doc.uri}`); 182 - log.message(` File: ${path.basename(localPost.filePath)}`); 183 - 184 - // Update state (use relative path from config directory) 185 - const contentHash = await getContentHash(localPost.rawContent); 186 - const relativeFilePath = path.relative(configDir, localPost.filePath); 187 - state.posts[relativeFilePath] = { 188 - contentHash, 189 - atUri: doc.uri, 190 - lastPublished: doc.value.publishedAt, 191 - }; 192 - 193 - // Check if frontmatter needs updating 194 - if (updateFrontmatter && localPost.frontmatter.atUri !== doc.uri) { 195 - frontmatterUpdates.push({ 196 - filePath: localPost.filePath, 197 - atUri: doc.uri, 198 - }); 199 - log.message(` → Will update frontmatter`); 200 - } 201 - } else { 202 - unmatchedCount++; 203 - log.message(` ✗ ${doc.value.title} (no matching local file)`); 204 - log.message(` Path: ${docPath}`); 205 - log.message(` URI: ${doc.uri}`); 133 + if (result.frontmatterUpdatesApplied > 0) { 134 + log.success( 135 + `Updated frontmatter in ${result.frontmatterUpdatesApplied} files`, 136 + ); 206 137 } 207 - log.message(""); 208 - } 209 - 210 - // Summary 211 - log.message("---"); 212 - log.info(`Matched: ${matchedCount} documents`); 213 - if (unmatchedCount > 0) { 214 - log.warn( 215 - `Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`, 216 - ); 217 - } 218 - 219 - if (dryRun) { 220 - log.info("\nDry run complete. No changes made."); 221 - return; 222 - } 223 - 224 - // Save updated state 225 - await saveState(configDir, state); 226 - const newPostCount = Object.keys(state.posts).length; 227 - log.success( 228 - `\nSaved .sequoia-state.json (${originalPostCount} → ${newPostCount} entries)`, 229 - ); 230 - 231 - // Update frontmatter if requested 232 - if (frontmatterUpdates.length > 0) { 233 - s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`); 234 - for (const { filePath, atUri } of frontmatterUpdates) { 235 - const content = await fs.readFile(filePath, "utf-8"); 236 - const updated = updateFrontmatterWithAtUri(content, atUri); 237 - await fs.writeFile(filePath, updated); 238 - log.message(` Updated: ${path.basename(filePath)}`); 239 - } 240 - s.stop("Frontmatter updated"); 241 138 } 242 139 243 140 log.success("\nSync complete!");
+193
packages/cli/src/lib/sync.ts
··· 1 + import * as fs from "node:fs/promises"; 2 + import * as path from "node:path"; 3 + import { log } from "@clack/prompts"; 4 + import { listDocuments, type createAgent } from "./atproto"; 5 + import { loadState, saveState } from "./config"; 6 + import { 7 + scanContentDirectory, 8 + getContentHash, 9 + updateFrontmatterWithAtUri, 10 + resolvePostPath, 11 + } from "./markdown"; 12 + import type { PublisherConfig, PublisherState } from "./types"; 13 + 14 + export interface SyncOptions { 15 + updateFrontmatter?: boolean; 16 + dryRun?: boolean; 17 + quiet?: boolean; 18 + } 19 + 20 + export interface SyncResult { 21 + state: PublisherState; 22 + matchedCount: number; 23 + unmatchedCount: number; 24 + frontmatterUpdatesApplied: number; 25 + } 26 + 27 + /** 28 + * Core sync logic: fetches documents from PDS and matches them to local files, 29 + * updating state and optionally frontmatter. 30 + * 31 + * Used by both the `sync` command and auto-sync before `publish`. 32 + */ 33 + export async function syncStateFromPDS( 34 + agent: Awaited<ReturnType<typeof createAgent>>, 35 + config: PublisherConfig, 36 + configDir: string, 37 + options: SyncOptions = {}, 38 + ): Promise<SyncResult> { 39 + const { updateFrontmatter = false, dryRun = false, quiet = false } = options; 40 + 41 + // Fetch documents from PDS (filtered by publicationUri for multi-publication safety) 42 + const documents = await listDocuments(agent, config.publicationUri); 43 + 44 + if (documents.length === 0) { 45 + if (!quiet) { 46 + log.info("No documents found for this publication."); 47 + } 48 + return { 49 + state: await loadState(configDir), 50 + matchedCount: 0, 51 + unmatchedCount: 0, 52 + frontmatterUpdatesApplied: 0, 53 + }; 54 + } 55 + 56 + // Resolve content directory 57 + const contentDir = path.isAbsolute(config.contentDir) 58 + ? config.contentDir 59 + : path.join(configDir, config.contentDir); 60 + 61 + // Scan local posts 62 + const localPosts = await scanContentDirectory(contentDir, { 63 + frontmatterMapping: config.frontmatter, 64 + ignorePatterns: config.ignore, 65 + slugField: config.frontmatter?.slugField, 66 + removeIndexFromSlug: config.removeIndexFromSlug, 67 + stripDatePrefix: config.stripDatePrefix, 68 + }); 69 + 70 + // Build a map of path -> local post for matching 71 + const postsByPath = new Map<string, (typeof localPosts)[0]>(); 72 + for (const post of localPosts) { 73 + const postPath = resolvePostPath( 74 + post, 75 + config.pathPrefix, 76 + config.pathTemplate, 77 + ); 78 + postsByPath.set(postPath, post); 79 + } 80 + 81 + // Load existing state 82 + const state = await loadState(configDir); 83 + 84 + // Track changes 85 + let matchedCount = 0; 86 + let unmatchedCount = 0; 87 + let frontmatterUpdatesApplied = 0; 88 + const frontmatterUpdates: Array<{ filePath: string; atUri: string; relativeFilePath: string }> = []; 89 + 90 + if (!quiet) { 91 + log.message("\nMatching documents to local files:\n"); 92 + } 93 + 94 + for (const doc of documents) { 95 + const docPath = doc.value.path; 96 + const localPost = postsByPath.get(docPath); 97 + 98 + if (localPost) { 99 + matchedCount++; 100 + const relativeFilePath = path.relative(configDir, localPost.filePath); 101 + 102 + if (!quiet) { 103 + log.message(` ✓ ${doc.value.title}`); 104 + log.message(` Path: ${docPath}`); 105 + log.message(` URI: ${doc.uri}`); 106 + log.message(` File: ${path.basename(localPost.filePath)}`); 107 + } 108 + 109 + // Check if frontmatter needs updating 110 + const needsFrontmatterUpdate = 111 + updateFrontmatter && localPost.frontmatter.atUri !== doc.uri; 112 + 113 + if (needsFrontmatterUpdate) { 114 + frontmatterUpdates.push({ 115 + filePath: localPost.filePath, 116 + atUri: doc.uri, 117 + relativeFilePath, 118 + }); 119 + if (!quiet) { 120 + log.message(` → Will update frontmatter`); 121 + } 122 + } 123 + 124 + // Compute content hash — if we're updating frontmatter, hash the updated content 125 + // so the state matches what will be on disk after the update 126 + let contentHash: string; 127 + if (needsFrontmatterUpdate) { 128 + const updatedContent = updateFrontmatterWithAtUri( 129 + localPost.rawContent, 130 + doc.uri, 131 + ); 132 + contentHash = await getContentHash(updatedContent); 133 + } else { 134 + contentHash = await getContentHash(localPost.rawContent); 135 + } 136 + 137 + // Update state 138 + state.posts[relativeFilePath] = { 139 + contentHash, 140 + atUri: doc.uri, 141 + lastPublished: doc.value.publishedAt, 142 + }; 143 + } else { 144 + unmatchedCount++; 145 + if (!quiet) { 146 + log.message( 147 + ` ✗ ${doc.value.title} (no matching local file)`, 148 + ); 149 + log.message(` Path: ${docPath}`); 150 + log.message(` URI: ${doc.uri}`); 151 + } 152 + } 153 + if (!quiet) { 154 + log.message(""); 155 + } 156 + } 157 + 158 + // Summary (always show, even in quiet mode) 159 + if (!quiet) { 160 + log.message("---"); 161 + log.info(`Matched: ${matchedCount} documents`); 162 + if (unmatchedCount > 0) { 163 + log.warn( 164 + `Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`, 165 + ); 166 + } 167 + } 168 + 169 + if (dryRun) { 170 + if (!quiet) { 171 + log.info("\nDry run complete. No changes made."); 172 + } 173 + return { state, matchedCount, unmatchedCount, frontmatterUpdatesApplied: 0 }; 174 + } 175 + 176 + // Save updated state 177 + await saveState(configDir, state); 178 + 179 + // Update frontmatter files 180 + if (frontmatterUpdates.length > 0) { 181 + for (const { filePath, atUri } of frontmatterUpdates) { 182 + const content = await fs.readFile(filePath, "utf-8"); 183 + const updated = updateFrontmatterWithAtUri(content, atUri); 184 + await fs.writeFile(filePath, updated); 185 + if (!quiet) { 186 + log.message(` Updated: ${path.basename(filePath)}`); 187 + } 188 + } 189 + frontmatterUpdatesApplied = frontmatterUpdates.length; 190 + } 191 + 192 + return { state, matchedCount, unmatchedCount, frontmatterUpdatesApplied }; 193 + }
+1
packages/cli/src/lib/types.ts
··· 45 45 publishContent?: boolean; // Whether or not to publish the documents content on the standard.site document (default: true) 46 46 bluesky?: BlueskyConfig; // Optional Bluesky posting configuration 47 47 ui?: UIConfig; // Optional UI components configuration 48 + autoSync?: boolean; // Automatically sync state from PDS before publishing (default: true) 48 49 } 49 50 50 51 // Legacy credentials format (for backward compatibility during migration)
+5
sequoia.schema.json
··· 145 145 } 146 146 } 147 147 }, 148 + "autoSync": { 149 + "type": "boolean", 150 + "description": "Automatically sync state from PDS before publishing to prevent duplicate posts on fresh clones", 151 + "default": true 152 + }, 148 153 "ui": { 149 154 "type": "object", 150 155 "additionalProperties": false,