diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index 5e9225a..2148450 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -25,6 +25,7 @@ import { resolvePostPath, } from "../lib/markdown"; import type { BlogPost, BlobObject, StrongRef } from "../lib/types"; +import { syncStateFromPDS } from "../lib/sync"; import { exitOnCancel } from "../lib/prompts"; export const publishCommand = command({ @@ -145,10 +146,57 @@ export const publishCommand = command({ : undefined; // Load state - const state = await loadState(configDir); + let state = await loadState(configDir); - // Scan for posts + // Auto-sync from PDS if state is empty (prevents duplicates on fresh clones) const s = spinner(); + let agent: Awaited> | undefined; + + if ( + config.autoSync !== false && + 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, { frontmatterMapping: config.frontmatter, @@ -261,18 +309,21 @@ export const publishCommand = command({ return; } - // Create agent - 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); + // 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); + } } // Publish posts diff --git a/packages/cli/src/commands/sync.ts b/packages/cli/src/commands/sync.ts index 55489bd..afcb811 100644 --- a/packages/cli/src/commands/sync.ts +++ b/packages/cli/src/commands/sync.ts @@ -1,21 +1,15 @@ -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 { loadConfig, findConfig } from "../lib/config"; import { loadCredentials, listAllCredentials, getCredentials, } from "../lib/credentials"; import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store"; -import { createAgent, listDocuments } from "../lib/atproto"; -import { - scanContentDirectory, - getContentHash, - updateFrontmatterWithAtUri, - resolvePostPath, -} from "../lib/markdown"; +import { createAgent } from "../lib/atproto"; +import { syncStateFromPDS } from "../lib/sync"; import { exitOnCancel } from "../lib/prompts"; export const syncCommand = command({ @@ -121,123 +115,26 @@ export const syncCommand = command({ process.exit(1); } - // Fetch documents from PDS + // Sync state from PDS s.start("Fetching documents from PDS..."); - const documents = await listDocuments(agent, config.publicationUri); - s.stop(`Found ${documents.length} documents on PDS`); - - if (documents.length === 0) { - log.info("No documents found for this publication."); - return; - } - - // Resolve content directory - const contentDir = path.isAbsolute(config.contentDir) - ? config.contentDir - : path.join(configDir, config.contentDir); - - // Scan local posts - s.start("Scanning local content..."); - const localPosts = await scanContentDirectory(contentDir, { - frontmatterMapping: config.frontmatter, - ignorePatterns: config.ignore, - slugField: config.frontmatter?.slugField, - removeIndexFromSlug: config.removeIndexFromSlug, - stripDatePrefix: config.stripDatePrefix, + const result = await syncStateFromPDS(agent, config, configDir, { + updateFrontmatter, + dryRun, + quiet: false, }); - s.stop(`Found ${localPosts.length} local posts`); - - // Build a map of path -> local post for matching - // Document path is like /posts/my-post-slug (or custom pathPrefix/pathTemplate) - const postsByPath = new Map(); - for (const post of localPosts) { - const postPath = resolvePostPath( - post, - config.pathPrefix, - config.pathTemplate, - ); - postsByPath.set(postPath, post); - } - - // Load existing state - const state = await loadState(configDir); - const originalPostCount = Object.keys(state.posts).length; - - // Track changes - let matchedCount = 0; - let unmatchedCount = 0; - const frontmatterUpdates: Array<{ filePath: string; atUri: string }> = []; - - log.message("\nMatching documents to local files:\n"); - - for (const doc of documents) { - const docPath = doc.value.path; - const localPost = postsByPath.get(docPath); + s.stop(`Found documents on PDS`); - if (localPost) { - matchedCount++; - log.message(` ✓ ${doc.value.title}`); - log.message(` Path: ${docPath}`); - log.message(` URI: ${doc.uri}`); - log.message(` File: ${path.basename(localPost.filePath)}`); - - // Update state (use relative path from config directory) - const contentHash = await getContentHash(localPost.rawContent); - const relativeFilePath = path.relative(configDir, localPost.filePath); - state.posts[relativeFilePath] = { - contentHash, - atUri: doc.uri, - lastPublished: doc.value.publishedAt, - }; - - // Check if frontmatter needs updating - if (updateFrontmatter && localPost.frontmatter.atUri !== doc.uri) { - frontmatterUpdates.push({ - filePath: localPost.filePath, - atUri: doc.uri, - }); - log.message(` → Will update frontmatter`); - } - } else { - unmatchedCount++; - log.message(` ✗ ${doc.value.title} (no matching local file)`); - log.message(` Path: ${docPath}`); - log.message(` URI: ${doc.uri}`); - } - log.message(""); - } - - // Summary - log.message("---"); - log.info(`Matched: ${matchedCount} documents`); - if (unmatchedCount > 0) { - log.warn( - `Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`, + if (!dryRun) { + const stateCount = Object.keys(result.state.posts).length; + log.success( + `\nSaved .sequoia-state.json (${stateCount} entries)`, ); - } - - if (dryRun) { - log.info("\nDry run complete. No changes made."); - return; - } - - // Save updated state - await saveState(configDir, state); - const newPostCount = Object.keys(state.posts).length; - log.success( - `\nSaved .sequoia-state.json (${originalPostCount} → ${newPostCount} entries)`, - ); - // Update frontmatter if requested - if (frontmatterUpdates.length > 0) { - s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`); - for (const { filePath, atUri } of frontmatterUpdates) { - const content = await fs.readFile(filePath, "utf-8"); - const updated = updateFrontmatterWithAtUri(content, atUri); - await fs.writeFile(filePath, updated); - log.message(` Updated: ${path.basename(filePath)}`); + if (result.frontmatterUpdatesApplied > 0) { + log.success( + `Updated frontmatter in ${result.frontmatterUpdatesApplied} files`, + ); } - s.stop("Frontmatter updated"); } log.success("\nSync complete!"); diff --git a/packages/cli/src/lib/sync.ts b/packages/cli/src/lib/sync.ts new file mode 100644 index 0000000..960425d --- /dev/null +++ b/packages/cli/src/lib/sync.ts @@ -0,0 +1,193 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { log } from "@clack/prompts"; +import { listDocuments, type createAgent } from "./atproto"; +import { loadState, saveState } from "./config"; +import { + scanContentDirectory, + getContentHash, + updateFrontmatterWithAtUri, + resolvePostPath, +} from "./markdown"; +import type { PublisherConfig, PublisherState } from "./types"; + +export interface SyncOptions { + updateFrontmatter?: boolean; + dryRun?: boolean; + quiet?: boolean; +} + +export interface SyncResult { + state: PublisherState; + matchedCount: number; + unmatchedCount: number; + frontmatterUpdatesApplied: number; +} + +/** + * Core sync logic: fetches documents from PDS and matches them to local files, + * updating state and optionally frontmatter. + * + * Used by both the `sync` command and auto-sync before `publish`. + */ +export async function syncStateFromPDS( + agent: Awaited>, + config: PublisherConfig, + configDir: string, + options: SyncOptions = {}, +): Promise { + const { updateFrontmatter = false, dryRun = false, quiet = false } = options; + + // Fetch documents from PDS (filtered by publicationUri for multi-publication safety) + const documents = await listDocuments(agent, config.publicationUri); + + if (documents.length === 0) { + if (!quiet) { + log.info("No documents found for this publication."); + } + return { + state: await loadState(configDir), + matchedCount: 0, + unmatchedCount: 0, + frontmatterUpdatesApplied: 0, + }; + } + + // Resolve content directory + const contentDir = path.isAbsolute(config.contentDir) + ? config.contentDir + : path.join(configDir, config.contentDir); + + // Scan local posts + const localPosts = await scanContentDirectory(contentDir, { + frontmatterMapping: config.frontmatter, + ignorePatterns: config.ignore, + slugField: config.frontmatter?.slugField, + removeIndexFromSlug: config.removeIndexFromSlug, + stripDatePrefix: config.stripDatePrefix, + }); + + // Build a map of path -> local post for matching + const postsByPath = new Map(); + for (const post of localPosts) { + const postPath = resolvePostPath( + post, + config.pathPrefix, + config.pathTemplate, + ); + postsByPath.set(postPath, post); + } + + // Load existing state + const state = await loadState(configDir); + + // Track changes + let matchedCount = 0; + let unmatchedCount = 0; + let frontmatterUpdatesApplied = 0; + const frontmatterUpdates: Array<{ filePath: string; atUri: string; relativeFilePath: string }> = []; + + if (!quiet) { + log.message("\nMatching documents to local files:\n"); + } + + for (const doc of documents) { + const docPath = doc.value.path; + const localPost = postsByPath.get(docPath); + + if (localPost) { + matchedCount++; + const relativeFilePath = path.relative(configDir, localPost.filePath); + + if (!quiet) { + log.message(` ✓ ${doc.value.title}`); + log.message(` Path: ${docPath}`); + log.message(` URI: ${doc.uri}`); + log.message(` File: ${path.basename(localPost.filePath)}`); + } + + // Check if frontmatter needs updating + const needsFrontmatterUpdate = + updateFrontmatter && localPost.frontmatter.atUri !== doc.uri; + + if (needsFrontmatterUpdate) { + frontmatterUpdates.push({ + filePath: localPost.filePath, + atUri: doc.uri, + relativeFilePath, + }); + if (!quiet) { + log.message(` → Will update frontmatter`); + } + } + + // Compute content hash — if we're updating frontmatter, hash the updated content + // so the state matches what will be on disk after the update + let contentHash: string; + if (needsFrontmatterUpdate) { + const updatedContent = updateFrontmatterWithAtUri( + localPost.rawContent, + doc.uri, + ); + contentHash = await getContentHash(updatedContent); + } else { + contentHash = await getContentHash(localPost.rawContent); + } + + // Update state + state.posts[relativeFilePath] = { + contentHash, + atUri: doc.uri, + lastPublished: doc.value.publishedAt, + }; + } else { + unmatchedCount++; + if (!quiet) { + log.message( + ` ✗ ${doc.value.title} (no matching local file)`, + ); + log.message(` Path: ${docPath}`); + log.message(` URI: ${doc.uri}`); + } + } + if (!quiet) { + log.message(""); + } + } + + // Summary (always show, even in quiet mode) + if (!quiet) { + log.message("---"); + log.info(`Matched: ${matchedCount} documents`); + if (unmatchedCount > 0) { + log.warn( + `Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`, + ); + } + } + + if (dryRun) { + if (!quiet) { + log.info("\nDry run complete. No changes made."); + } + return { state, matchedCount, unmatchedCount, frontmatterUpdatesApplied: 0 }; + } + + // Save updated state + await saveState(configDir, state); + + // Update frontmatter files + if (frontmatterUpdates.length > 0) { + for (const { filePath, atUri } of frontmatterUpdates) { + const content = await fs.readFile(filePath, "utf-8"); + const updated = updateFrontmatterWithAtUri(content, atUri); + await fs.writeFile(filePath, updated); + if (!quiet) { + log.message(` Updated: ${path.basename(filePath)}`); + } + } + frontmatterUpdatesApplied = frontmatterUpdates.length; + } + + return { state, matchedCount, unmatchedCount, frontmatterUpdatesApplied }; +} diff --git a/packages/cli/src/lib/types.ts b/packages/cli/src/lib/types.ts index be29e29..2cda25f 100644 --- a/packages/cli/src/lib/types.ts +++ b/packages/cli/src/lib/types.ts @@ -45,6 +45,7 @@ export interface PublisherConfig { publishContent?: boolean; // Whether or not to publish the documents content on the standard.site document (default: true) bluesky?: BlueskyConfig; // Optional Bluesky posting configuration ui?: UIConfig; // Optional UI components configuration + autoSync?: boolean; // Automatically sync state from PDS before publishing (default: true) } // Legacy credentials format (for backward compatibility during migration) diff --git a/sequoia.schema.json b/sequoia.schema.json index 60eaa17..1ec09bc 100644 --- a/sequoia.schema.json +++ b/sequoia.schema.json @@ -145,6 +145,11 @@ } } }, + "autoSync": { + "type": "boolean", + "description": "Automatically sync state from PDS before publishing to prevent duplicate posts on fresh clones", + "default": true + }, "ui": { "type": "object", "additionalProperties": false,