packages/cli/src/commands/sync.ts 6.9 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
						pdsUrl: "https://bsky.social",
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
		s.start(`Connecting to ${credentials.pdsUrl}...`);
112
		let agent: Awaited<ReturnType<typeof createAgent>> | undefined;
113
		try {
114
			agent = await createAgent(credentials);
115
			s.stop(`Logged in as ${agent.did}`);
116
		} catch (error) {
117
			s.stop("Failed to login");
118
			log.error(`Failed to login: ${error}`);
119
			process.exit(1);
120
		}
121
122
		// Fetch documents from PDS
123
		s.start("Fetching documents from PDS...");
124
		const documents = await listDocuments(agent, config.publicationUri);
125
		s.stop(`Found ${documents.length} documents on PDS`);
126
127
		if (documents.length === 0) {
128
			log.info("No documents found for this publication.");
129
			return;
130
		}
131
132
		// Resolve content directory
133
		const contentDir = path.isAbsolute(config.contentDir)
134
			? config.contentDir
135
			: path.join(configDir, config.contentDir);
136
137
		// Scan local posts
138
		s.start("Scanning local content...");
139
		const localPosts = await scanContentDirectory(contentDir, {
140
			frontmatterMapping: config.frontmatter,
141
			ignorePatterns: config.ignore,
142
			slugField: config.frontmatter?.slugField,
143
			removeIndexFromSlug: config.removeIndexFromSlug,
144
			stripDatePrefix: config.stripDatePrefix,
145
		});
146
		s.stop(`Found ${localPosts.length} local posts`);
147
148
		// Build a map of path -> local post for matching
149
		// Document path is like /posts/my-post-slug (or custom pathPrefix)
150
		const pathPrefix = config.pathPrefix || "/posts";
151
		const postsByPath = new Map<string, (typeof localPosts)[0]>();
152
		for (const post of localPosts) {
153
			const postPath = `${pathPrefix}/${post.slug}`;
154
			postsByPath.set(postPath, post);
155
		}
156
157
		// Load existing state
158
		const state = await loadState(configDir);
159
		const originalPostCount = Object.keys(state.posts).length;
160
161
		// Track changes
162
		let matchedCount = 0;
163
		let unmatchedCount = 0;
164
		const frontmatterUpdates: Array<{ filePath: string; atUri: string }> = [];
165
166
		log.message("\nMatching documents to local files:\n");
167
168
		for (const doc of documents) {
169
			const docPath = doc.value.path;
170
			const localPost = postsByPath.get(docPath);
171
172
			if (localPost) {
173
				matchedCount++;
174
				log.message(`  ✓ ${doc.value.title}`);
175
				log.message(`    Path: ${docPath}`);
176
				log.message(`    URI: ${doc.uri}`);
177
				log.message(`    File: ${path.basename(localPost.filePath)}`);
178
179
				// Update state (use relative path from config directory)
180
				const contentHash = await getContentHash(localPost.rawContent);
181
				const relativeFilePath = path.relative(configDir, localPost.filePath);
182
				state.posts[relativeFilePath] = {
183
					contentHash,
184
					atUri: doc.uri,
185
					lastPublished: doc.value.publishedAt,
186
				};
187
188
				// Check if frontmatter needs updating
189
				if (updateFrontmatter && localPost.frontmatter.atUri !== doc.uri) {
190
					frontmatterUpdates.push({
191
						filePath: localPost.filePath,
192
						atUri: doc.uri,
193
					});
194
					log.message(`    → Will update frontmatter`);
195
				}
196
			} else {
197
				unmatchedCount++;
198
				log.message(`  ✗ ${doc.value.title} (no matching local file)`);
199
				log.message(`    Path: ${docPath}`);
200
				log.message(`    URI: ${doc.uri}`);
201
			}
202
			log.message("");
203
		}
204
205
		// Summary
206
		log.message("---");
207
		log.info(`Matched: ${matchedCount} documents`);
208
		if (unmatchedCount > 0) {
209
			log.warn(
210
				`Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`,
211
			);
212
		}
213
214
		if (dryRun) {
215
			log.info("\nDry run complete. No changes made.");
216
			return;
217
		}
218
219
		// Save updated state
220
		await saveState(configDir, state);
221
		const newPostCount = Object.keys(state.posts).length;
222
		log.success(
223
			`\nSaved .sequoia-state.json (${originalPostCount} → ${newPostCount} entries)`,
224
		);
225
226
		// Update frontmatter if requested
227
		if (frontmatterUpdates.length > 0) {
228
			s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`);
229
			for (const { filePath, atUri } of frontmatterUpdates) {
230
				const content = await fs.readFile(filePath, "utf-8");
231
				const updated = updateFrontmatterWithAtUri(content, atUri);
232
				await fs.writeFile(filePath, updated);
233
				log.message(`  Updated: ${path.basename(filePath)}`);
234
			}
235
			s.stop("Frontmatter updated");
236
		}
237
238
		log.success("\nSync complete!");
239
	},
240
});