packages/cli/src/lib/sync.ts 6.0 K raw
1
import * as fs from "node:fs/promises";
2
import * as path from "node:path";
3
import { log } from "@clack/prompts";
4
import { getPublication, 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, StrongRef } 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
	// Update the publication information for enhanced links.
85
	if (!state.publication) {
86
		state.publication = await syncPublication(
87
			agent,
88
			config.publicationUri,
89
			quiet,
90
		);
91
	}
92
93
	// Track changes
94
	let matchedCount = 0;
95
	let unmatchedCount = 0;
96
	let frontmatterUpdatesApplied = 0;
97
	const frontmatterUpdates: Array<{
98
		filePath: string;
99
		atUri: string;
100
		relativeFilePath: string;
101
	}> = [];
102
103
	if (!quiet) {
104
		log.message("\nMatching documents to local files:\n");
105
	}
106
107
	for (const doc of documents) {
108
		const docPath = doc.value.path;
109
		const localPost = postsByPath.get(docPath);
110
111
		if (localPost) {
112
			matchedCount++;
113
			const relativeFilePath = path.relative(configDir, localPost.filePath);
114
115
			if (!quiet) {
116
				log.message(`  ✓ ${doc.value.title}`);
117
				log.message(`    Path: ${docPath}`);
118
				log.message(`    URI: ${doc.uri}`);
119
				log.message(`    File: ${path.basename(localPost.filePath)}`);
120
			}
121
122
			// Check if frontmatter needs updating
123
			const needsFrontmatterUpdate =
124
				updateFrontmatter && localPost.frontmatter.atUri !== doc.uri;
125
126
			if (needsFrontmatterUpdate) {
127
				frontmatterUpdates.push({
128
					filePath: localPost.filePath,
129
					atUri: doc.uri,
130
					relativeFilePath,
131
				});
132
				if (!quiet) {
133
					log.message(`    → Will update frontmatter`);
134
				}
135
			}
136
137
			// Compute content hash — if we're updating frontmatter, hash the updated content
138
			// so the state matches what will be on disk after the update
139
			let contentHash: string;
140
			if (needsFrontmatterUpdate) {
141
				const updatedContent = updateFrontmatterWithAtUri(
142
					localPost.rawContent,
143
					doc.uri,
144
				);
145
				contentHash = await getContentHash(updatedContent);
146
			} else {
147
				contentHash = await getContentHash(localPost.rawContent);
148
			}
149
150
			// Update state (preserve bskyPostRef from prior publishes)
151
			const existing = state.posts[relativeFilePath];
152
			state.posts[relativeFilePath] = {
153
				contentHash,
154
				atUri: doc.uri,
155
				lastPublished: doc.value.publishedAt,
156
				slug: localPost.slug,
157
				...(existing?.bskyPostRef ? { bskyPostRef: existing.bskyPostRef } : {}),
158
			};
159
		} else {
160
			unmatchedCount++;
161
			if (!quiet) {
162
				log.message(`  ✗ ${doc.value.title} (no matching local file)`);
163
				log.message(`    Path: ${docPath}`);
164
				log.message(`    URI: ${doc.uri}`);
165
			}
166
		}
167
		if (!quiet) {
168
			log.message("");
169
		}
170
	}
171
172
	// Summary (always show, even in quiet mode)
173
	if (!quiet) {
174
		log.message("---");
175
		log.info(`Matched: ${matchedCount} documents`);
176
		if (unmatchedCount > 0) {
177
			log.warn(
178
				`Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`,
179
			);
180
		}
181
	}
182
183
	if (dryRun) {
184
		if (!quiet) {
185
			log.info("\nDry run complete. No changes made.");
186
		}
187
		return {
188
			state,
189
			matchedCount,
190
			unmatchedCount,
191
			frontmatterUpdatesApplied: 0,
192
		};
193
	}
194
195
	// Save updated state
196
	await saveState(configDir, state);
197
198
	// Update frontmatter files
199
	if (frontmatterUpdates.length > 0) {
200
		for (const { filePath, atUri } of frontmatterUpdates) {
201
			const content = await fs.readFile(filePath, "utf-8");
202
			const updated = updateFrontmatterWithAtUri(content, atUri);
203
			await fs.writeFile(filePath, updated);
204
			if (!quiet) {
205
				log.message(`  Updated: ${path.basename(filePath)}`);
206
			}
207
		}
208
		frontmatterUpdatesApplied = frontmatterUpdates.length;
209
	}
210
211
	return { state, matchedCount, unmatchedCount, frontmatterUpdatesApplied };
212
}
213
214
export async function syncPublication(
215
	agent: Awaited<ReturnType<typeof createAgent>>,
216
	publicationUri: string,
217
	quiet: boolean,
218
): Promise<StrongRef> {
219
	const publicationRef = await getPublication(agent, publicationUri);
220
	if (!publicationRef) {
221
		if (!quiet) {
222
			log.error(
223
				`Publication ${publicationUri} not found. Update your publication record and try again.`,
224
			);
225
		}
226
		process.exit(1);
227
	}
228
229
	return {
230
		cid: publicationRef.cid,
231
		uri: publicationRef.uri,
232
	};
233
}