chore: add auto sync to publishing 8af71e22
Steve · 2026-03-22 14:19 5 file(s) · +281 −134
packages/cli/src/commands/publish.ts +65 −14
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
packages/cli/src/commands/sync.ts +17 −120
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!");
packages/cli/src/lib/sync.ts (added) +193 −0
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 +
}
packages/cli/src/lib/types.ts +1 −0
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)
sequoia.schema.json +5 −0
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,