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.session?.handle}`);
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
		});
109
		s.stop(`Found ${localPosts.length} local posts`);
110
111
		// Build a map of path -> local post for matching
112
		// Document path is like /posts/my-post-slug (or custom pathPrefix)
113
		const pathPrefix = config.pathPrefix || "/posts";
114
		const postsByPath = new Map<string, (typeof localPosts)[0]>();
115
		for (const post of localPosts) {
116
			const postPath = `${pathPrefix}/${post.slug}`;
117
			postsByPath.set(postPath, post);
118
		}
119
120
		// Load existing state
121
		const state = await loadState(configDir);
122
		const originalPostCount = Object.keys(state.posts).length;
123
124
		// Track changes
125
		let matchedCount = 0;
126
		let unmatchedCount = 0;
127
		const frontmatterUpdates: Array<{ filePath: string; atUri: string }> = [];
128
129
		log.message("\nMatching documents to local files:\n");
130
131
		for (const doc of documents) {
132
			const docPath = doc.value.path;
133
			const localPost = postsByPath.get(docPath);
134
135
			if (localPost) {
136
				matchedCount++;
137
				log.message(`  ✓ ${doc.value.title}`);
138
				log.message(`    Path: ${docPath}`);
139
				log.message(`    URI: ${doc.uri}`);
140
				log.message(`    File: ${path.basename(localPost.filePath)}`);
141
142
				// Update state (use relative path from config directory)
143
				const contentHash = await getContentHash(localPost.rawContent);
144
				const relativeFilePath = path.relative(configDir, localPost.filePath);
145
				state.posts[relativeFilePath] = {
146
					contentHash,
147
					atUri: doc.uri,
148
					lastPublished: doc.value.publishedAt,
149
				};
150
151
				// Check if frontmatter needs updating
152
				if (updateFrontmatter && localPost.frontmatter.atUri !== doc.uri) {
153
					frontmatterUpdates.push({
154
						filePath: localPost.filePath,
155
						atUri: doc.uri,
156
					});
157
					log.message(`    → Will update frontmatter`);
158
				}
159
			} else {
160
				unmatchedCount++;
161
				log.message(`  ✗ ${doc.value.title} (no matching local file)`);
162
				log.message(`    Path: ${docPath}`);
163
				log.message(`    URI: ${doc.uri}`);
164
			}
165
			log.message("");
166
		}
167
168
		// Summary
169
		log.message("---");
170
		log.info(`Matched: ${matchedCount} documents`);
171
		if (unmatchedCount > 0) {
172
			log.warn(
173
				`Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`,
174
			);
175
		}
176
177
		if (dryRun) {
178
			log.info("\nDry run complete. No changes made.");
179
			return;
180
		}
181
182
		// Save updated state
183
		await saveState(configDir, state);
184
		const newPostCount = Object.keys(state.posts).length;
185
		log.success(
186
			`\nSaved .sequoia-state.json (${originalPostCount} → ${newPostCount} entries)`,
187
		);
188
189
		// Update frontmatter if requested
190
		if (frontmatterUpdates.length > 0) {
191
			s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`);
192
			for (const { filePath, atUri } of frontmatterUpdates) {
193
				const content = await fs.readFile(filePath, "utf-8");
194
				const updated = updateFrontmatterWithAtUri(content, atUri);
195
				await fs.writeFile(filePath, updated);
196
				log.message(`  Updated: ${path.basename(filePath)}`);
197
			}
198
			s.stop("Frontmatter updated");
199
		}
200
201
		log.success("\nSync complete!");
202
	},
203
});