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
	resolvePostPath,
18
} from "../lib/markdown";
19
import { exitOnCancel } from "../lib/prompts";
20
21
export const syncCommand = command({
22
	name: "sync",
23
	description: "Sync state from ATProto to restore .sequoia-state.json",
24
	args: {
25
		updateFrontmatter: flag({
26
			long: "update-frontmatter",
27
			short: "u",
28
			description: "Update frontmatter atUri fields in local markdown files",
29
		}),
30
		dryRun: flag({
31
			long: "dry-run",
32
			short: "n",
33
			description: "Preview what would be synced without making changes",
34
		}),
35
	},
36
	handler: async ({ updateFrontmatter, dryRun }) => {
37
		// Load config
38
		const configPath = await findConfig();
39
		if (!configPath) {
40
			log.error("No sequoia.json found. Run 'sequoia init' first.");
41
			process.exit(1);
42
		}
43
44
		const config = await loadConfig(configPath);
45
		const configDir = path.dirname(configPath);
46
47
		log.info(`Site: ${config.siteUrl}`);
48
		log.info(`Publication: ${config.publicationUri}`);
49
50
		// Load credentials
51
		let credentials = await loadCredentials(config.identity);
52
53
		if (!credentials) {
54
			const identities = await listAllCredentials();
55
			if (identities.length === 0) {
56
				log.error(
57
					"No credentials found. Run 'sequoia login' or 'sequoia auth' first.",
58
				);
59
				process.exit(1);
60
			}
61
62
			// Build labels with handles for OAuth sessions
63
			const options = await Promise.all(
64
				identities.map(async (cred) => {
65
					if (cred.type === "oauth") {
66
						const handle = await getOAuthHandle(cred.id);
67
						return {
68
							value: cred.id,
69
							label: `${handle || cred.id} (OAuth)`,
70
						};
71
					}
72
					return {
73
						value: cred.id,
74
						label: `${cred.id} (App Password)`,
75
					};
76
				}),
77
			);
78
79
			log.info("Multiple identities found. Select one to use:");
80
			const selected = exitOnCancel(
81
				await select({
82
					message: "Identity:",
83
					options,
84
				}),
85
			);
86
87
			// Load the selected credentials
88
			const selectedCred = identities.find((c) => c.id === selected);
89
			if (selectedCred?.type === "oauth") {
90
				const session = await getOAuthSession(selected);
91
				if (session) {
92
					const handle = await getOAuthHandle(selected);
93
					credentials = {
94
						type: "oauth",
95
						did: selected,
96
						handle: handle || selected,
97
					};
98
				}
99
			} else {
100
				credentials = await getCredentials(selected);
101
			}
102
103
			if (!credentials) {
104
				log.error("Failed to load selected credentials.");
105
				process.exit(1);
106
			}
107
		}
108
109
		// Create agent
110
		const s = spinner();
111
		const connectingTo =
112
			credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl;
113
		s.start(`Connecting as ${connectingTo}...`);
114
		let agent: Awaited<ReturnType<typeof createAgent>> | undefined;
115
		try {
116
			agent = await createAgent(credentials);
117
			s.stop(`Logged in as ${agent.did}`);
118
		} catch (error) {
119
			s.stop("Failed to login");
120
			log.error(`Failed to login: ${error}`);
121
			process.exit(1);
122
		}
123
124
		// Fetch documents from PDS
125
		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,
147
		});
148
		s.stop(`Found ${localPosts.length} local posts`);
149
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,
158
			);
159
			postsByPath.set(postPath, post);
160
		}
161
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}`);
206
			}
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
		}
242
243
		log.success("\nSync complete!");
244
	},
245
});