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