packages/cli/src/commands/sync.ts 7.0 K raw
1
import * as fs from "node:fs/promises";
2
import { command, flag } from "cmd-ts";
3
import { select, spinner, log } from "@clack/prompts";
4
import * as path from "node:path";
5
import { loadConfig, loadState, saveState, findConfig } from "../lib/config";
6
import {
7
	loadCredentials,
8
	listAllCredentials,
9
	getCredentials,
10
} from "../lib/credentials";
11
import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store";
12
import { createAgent, listDocuments } from "../lib/atproto";
13
import {
14
	scanContentDirectory,
15
	getContentHash,
16
	updateFrontmatterWithAtUri,
17
} from "../lib/markdown";
18
import { exitOnCancel } from "../lib/prompts";
19
20
export const syncCommand = command({
21
	name: "sync",
22
	description: "Sync state from ATProto to restore .sequoia-state.json",
23
	args: {
24
		updateFrontmatter: flag({
25
			long: "update-frontmatter",
26
			short: "u",
27
			description: "Update frontmatter atUri fields in local markdown files",
28
		}),
29
		dryRun: flag({
30
			long: "dry-run",
31
			short: "n",
32
			description: "Preview what would be synced without making changes",
33
		}),
34
	},
35
	handler: async ({ updateFrontmatter, dryRun }) => {
36
		// Load config
37
		const configPath = await findConfig();
38
		if (!configPath) {
39
			log.error("No sequoia.json found. Run 'sequoia init' first.");
40
			process.exit(1);
41
		}
42
43
		const config = await loadConfig(configPath);
44
		const configDir = path.dirname(configPath);
45
46
		log.info(`Site: ${config.siteUrl}`);
47
		log.info(`Publication: ${config.publicationUri}`);
48
49
		// Load credentials
50
		let credentials = await loadCredentials(config.identity);
51
52
		if (!credentials) {
53
			const identities = await listAllCredentials();
54
			if (identities.length === 0) {
55
				log.error(
56
					"No credentials found. Run 'sequoia login' or 'sequoia auth' first.",
57
				);
58
				process.exit(1);
59
			}
60
61
			// Build labels with handles for OAuth sessions
62
			const options = await Promise.all(
63
				identities.map(async (cred) => {
64
					if (cred.type === "oauth") {
65
						const handle = await getOAuthHandle(cred.id);
66
						return {
67
							value: cred.id,
68
							label: `${handle || cred.id} (OAuth)`,
69
						};
70
					}
71
					return {
72
						value: cred.id,
73
						label: `${cred.id} (App Password)`,
74
					};
75
				}),
76
			);
77
78
			log.info("Multiple identities found. Select one to use:");
79
			const selected = exitOnCancel(
80
				await select({
81
					message: "Identity:",
82
					options,
83
				}),
84
			);
85
86
			// Load the selected credentials
87
			const selectedCred = identities.find((c) => c.id === selected);
88
			if (selectedCred?.type === "oauth") {
89
				const session = await getOAuthSession(selected);
90
				if (session) {
91
					const handle = await getOAuthHandle(selected);
92
					credentials = {
93
						type: "oauth",
94
						did: selected,
95
						handle: handle || selected,
96
					};
97
				}
98
			} else {
99
				credentials = await getCredentials(selected);
100
			}
101
102
			if (!credentials) {
103
				log.error("Failed to load selected credentials.");
104
				process.exit(1);
105
			}
106
		}
107
108
		// Create agent
109
		const s = spinner();
110
		const connectingTo =
111
			credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl;
112
		s.start(`Connecting as ${connectingTo}...`);
113
		let agent: Awaited<ReturnType<typeof createAgent>> | undefined;
114
		try {
115
			agent = await createAgent(credentials);
116
			s.stop(`Logged in as ${agent.did}`);
117
		} catch (error) {
118
			s.stop("Failed to login");
119
			log.error(`Failed to login: ${error}`);
120
			process.exit(1);
121
		}
122
123
		// Fetch documents from PDS
124
		s.start("Fetching documents from PDS...");
125
		const documents = await listDocuments(agent, config.publicationUri);
126
		s.stop(`Found ${documents.length} documents on PDS`);
127
128
		if (documents.length === 0) {
129
			log.info("No documents found for this publication.");
130
			return;
131
		}
132
133
		// Resolve content directory
134
		const contentDir = path.isAbsolute(config.contentDir)
135
			? config.contentDir
136
			: path.join(configDir, config.contentDir);
137
138
		// Scan local posts
139
		s.start("Scanning local content...");
140
		const localPosts = await scanContentDirectory(contentDir, {
141
			frontmatterMapping: config.frontmatter,
142
			ignorePatterns: config.ignore,
143
			slugField: config.frontmatter?.slugField,
144
			removeIndexFromSlug: config.removeIndexFromSlug,
145
			stripDatePrefix: config.stripDatePrefix,
146
		});
147
		s.stop(`Found ${localPosts.length} local posts`);
148
149
		// Build a map of path -> local post for matching
150
		// Document path is like /posts/my-post-slug (or custom pathPrefix)
151
		const pathPrefix = config.pathPrefix || "/posts";
152
		const postsByPath = new Map<string, (typeof localPosts)[0]>();
153
		for (const post of localPosts) {
154
			const postPath = `${pathPrefix}/${post.slug}`;
155
			postsByPath.set(postPath, post);
156
		}
157
158
		// Load existing state
159
		const state = await loadState(configDir);
160
		const originalPostCount = Object.keys(state.posts).length;
161
162
		// Track changes
163
		let matchedCount = 0;
164
		let unmatchedCount = 0;
165
		const frontmatterUpdates: Array<{ filePath: string; atUri: string }> = [];
166
167
		log.message("\nMatching documents to local files:\n");
168
169
		for (const doc of documents) {
170
			const docPath = doc.value.path;
171
			const localPost = postsByPath.get(docPath);
172
173
			if (localPost) {
174
				matchedCount++;
175
				log.message(`  ✓ ${doc.value.title}`);
176
				log.message(`    Path: ${docPath}`);
177
				log.message(`    URI: ${doc.uri}`);
178
				log.message(`    File: ${path.basename(localPost.filePath)}`);
179
180
				// Update state (use relative path from config directory)
181
				const contentHash = await getContentHash(localPost.rawContent);
182
				const relativeFilePath = path.relative(configDir, localPost.filePath);
183
				state.posts[relativeFilePath] = {
184
					contentHash,
185
					atUri: doc.uri,
186
					lastPublished: doc.value.publishedAt,
187
				};
188
189
				// Check if frontmatter needs updating
190
				if (updateFrontmatter && localPost.frontmatter.atUri !== doc.uri) {
191
					frontmatterUpdates.push({
192
						filePath: localPost.filePath,
193
						atUri: doc.uri,
194
					});
195
					log.message(`    → Will update frontmatter`);
196
				}
197
			} else {
198
				unmatchedCount++;
199
				log.message(`  ✗ ${doc.value.title} (no matching local file)`);
200
				log.message(`    Path: ${docPath}`);
201
				log.message(`    URI: ${doc.uri}`);
202
			}
203
			log.message("");
204
		}
205
206
		// Summary
207
		log.message("---");
208
		log.info(`Matched: ${matchedCount} documents`);
209
		if (unmatchedCount > 0) {
210
			log.warn(
211
				`Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`,
212
			);
213
		}
214
215
		if (dryRun) {
216
			log.info("\nDry run complete. No changes made.");
217
			return;
218
		}
219
220
		// Save updated state
221
		await saveState(configDir, state);
222
		const newPostCount = Object.keys(state.posts).length;
223
		log.success(
224
			`\nSaved .sequoia-state.json (${originalPostCount} → ${newPostCount} entries)`,
225
		);
226
227
		// Update frontmatter if requested
228
		if (frontmatterUpdates.length > 0) {
229
			s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`);
230
			for (const { filePath, atUri } of frontmatterUpdates) {
231
				const content = await fs.readFile(filePath, "utf-8");
232
				const updated = updateFrontmatterWithAtUri(content, atUri);
233
				await fs.writeFile(filePath, updated);
234
				log.message(`  Updated: ${path.basename(filePath)}`);
235
			}
236
			s.stop("Frontmatter updated");
237
		}
238
239
		log.success("\nSync complete!");
240
	},
241
});