packages/cli/src/lib/sync.ts 5.4 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 { 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<{
89
		filePath: string;
90
		atUri: string;
91
		relativeFilePath: string;
92
	}> = [];
93
94
	if (!quiet) {
95
		log.message("\nMatching documents to local files:\n");
96
	}
97
98
	for (const doc of documents) {
99
		const docPath = doc.value.path;
100
		const localPost = postsByPath.get(docPath);
101
102
		if (localPost) {
103
			matchedCount++;
104
			const relativeFilePath = path.relative(configDir, localPost.filePath);
105
106
			if (!quiet) {
107
				log.message(`  ✓ ${doc.value.title}`);
108
				log.message(`    Path: ${docPath}`);
109
				log.message(`    URI: ${doc.uri}`);
110
				log.message(`    File: ${path.basename(localPost.filePath)}`);
111
			}
112
113
			// Check if frontmatter needs updating
114
			const needsFrontmatterUpdate =
115
				updateFrontmatter && localPost.frontmatter.atUri !== doc.uri;
116
117
			if (needsFrontmatterUpdate) {
118
				frontmatterUpdates.push({
119
					filePath: localPost.filePath,
120
					atUri: doc.uri,
121
					relativeFilePath,
122
				});
123
				if (!quiet) {
124
					log.message(`    → Will update frontmatter`);
125
				}
126
			}
127
128
			// Compute content hash — if we're updating frontmatter, hash the updated content
129
			// so the state matches what will be on disk after the update
130
			let contentHash: string;
131
			if (needsFrontmatterUpdate) {
132
				const updatedContent = updateFrontmatterWithAtUri(
133
					localPost.rawContent,
134
					doc.uri,
135
				);
136
				contentHash = await getContentHash(updatedContent);
137
			} else {
138
				contentHash = await getContentHash(localPost.rawContent);
139
			}
140
141
			// Update state (preserve bskyPostRef from prior publishes)
142
			const existing = state.posts[relativeFilePath];
143
			state.posts[relativeFilePath] = {
144
				contentHash,
145
				atUri: doc.uri,
146
				lastPublished: doc.value.publishedAt,
147
				slug: localPost.slug,
148
				...(existing?.bskyPostRef ? { bskyPostRef: existing.bskyPostRef } : {}),
149
			};
150
		} else {
151
			unmatchedCount++;
152
			if (!quiet) {
153
				log.message(`  ✗ ${doc.value.title} (no matching local file)`);
154
				log.message(`    Path: ${docPath}`);
155
				log.message(`    URI: ${doc.uri}`);
156
			}
157
		}
158
		if (!quiet) {
159
			log.message("");
160
		}
161
	}
162
163
	// Summary (always show, even in quiet mode)
164
	if (!quiet) {
165
		log.message("---");
166
		log.info(`Matched: ${matchedCount} documents`);
167
		if (unmatchedCount > 0) {
168
			log.warn(
169
				`Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`,
170
			);
171
		}
172
	}
173
174
	if (dryRun) {
175
		if (!quiet) {
176
			log.info("\nDry run complete. No changes made.");
177
		}
178
		return {
179
			state,
180
			matchedCount,
181
			unmatchedCount,
182
			frontmatterUpdatesApplied: 0,
183
		};
184
	}
185
186
	// Save updated state
187
	await saveState(configDir, state);
188
189
	// Update frontmatter files
190
	if (frontmatterUpdates.length > 0) {
191
		for (const { filePath, atUri } of frontmatterUpdates) {
192
			const content = await fs.readFile(filePath, "utf-8");
193
			const updated = updateFrontmatterWithAtUri(content, atUri);
194
			await fs.writeFile(filePath, updated);
195
			if (!quiet) {
196
				log.message(`  Updated: ${path.basename(filePath)}`);
197
			}
198
		}
199
		frontmatterUpdatesApplied = frontmatterUpdates.length;
200
	}
201
202
	return { state, matchedCount, unmatchedCount, frontmatterUpdatesApplied };
203
}