this repo has no description
1import * as fs from "node:fs/promises";
2import { command, flag } from "cmd-ts";
3import { select, spinner, log } from "@clack/prompts";
4import * as path from "node:path";
5import { loadConfig, loadState, saveState, findConfig } from "../lib/config";
6import {
7 loadCredentials,
8 listAllCredentials,
9 getCredentials,
10} from "../lib/credentials";
11import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store";
12import type { Agent } from "@atproto/api";
13import { createAgent, listDocuments } from "../lib/atproto";
14import type { ListDocumentsResult } from "../lib/atproto";
15import type { BlogPost } from "../lib/types";
16import {
17 scanContentDirectory,
18 getContentHash,
19 getTextContent,
20 updateFrontmatterWithAtUri,
21} from "../lib/markdown";
22import { exitOnCancel } from "../lib/prompts";
23
24async function matchesPDS(
25 localPost: BlogPost,
26 doc: ListDocumentsResult,
27 agent: Agent,
28 textContentField?: string,
29): Promise<boolean> {
30 // Compare body text content
31 const localTextContent = getTextContent(localPost, textContentField);
32 if (localTextContent.slice(0, 10000) !== doc.value.textContent) {
33 return false;
34 }
35
36 // Compare document fields: title, description, tags
37 const trimmedContent = localPost.content.trim();
38 const titleMatch = trimmedContent.match(/^# (.+)$/m);
39 const localTitle = titleMatch ? titleMatch[1] : localPost.frontmatter.title;
40 if (localTitle !== doc.value.title) return false;
41
42 const localDescription = localPost.frontmatter.description || undefined;
43 if (localDescription !== doc.value.description) return false;
44
45 const localTags =
46 localPost.frontmatter.tags && localPost.frontmatter.tags.length > 0
47 ? localPost.frontmatter.tags
48 : undefined;
49 if (JSON.stringify(localTags) !== JSON.stringify(doc.value.tags)) {
50 return false;
51 }
52
53 // Compare note-specific fields: theme, fontSize, fontFamily.
54 // Fetch the space.remanso.note record to check these fields.
55 const noteUriMatch = doc.uri.match(/^at:\/\/([^/]+)\/[^/]+\/(.+)$/);
56 if (noteUriMatch) {
57 const repo = noteUriMatch[1]!;
58 const rkey = noteUriMatch[2]!;
59 try {
60 const noteResponse = await agent.com.atproto.repo.getRecord({
61 repo,
62 collection: "space.remanso.note",
63 rkey,
64 });
65 const noteValue = noteResponse.data.value as Record<string, unknown>;
66 if (
67 (localPost.frontmatter.theme || undefined) !==
68 (noteValue.theme as string | undefined) ||
69 (localPost.frontmatter.fontSize || undefined) !==
70 (noteValue.fontSize as number | undefined) ||
71 (localPost.frontmatter.fontFamily || undefined) !==
72 (noteValue.fontFamily as string | undefined)
73 ) {
74 return false;
75 }
76 } catch {
77 // Note record doesn't exist — treat as matching to avoid
78 // forcing a re-publish of posts never published as notes.
79 }
80 }
81
82 return true;
83}
84
85export const syncCommand = command({
86 name: "sync",
87 description: "Sync state from ATProto to restore .sequoia-state.json",
88 args: {
89 updateFrontmatter: flag({
90 long: "update-frontmatter",
91 short: "u",
92 description: "Update frontmatter atUri fields in local markdown files",
93 }),
94 dryRun: flag({
95 long: "dry-run",
96 short: "n",
97 description: "Preview what would be synced without making changes",
98 }),
99 },
100 handler: async ({ updateFrontmatter, dryRun }) => {
101 // Load config
102 const configPath = await findConfig();
103 if (!configPath) {
104 log.error("No sequoia.json found. Run 'sequoia init' first.");
105 process.exit(1);
106 }
107
108 const config = await loadConfig(configPath);
109 const configDir = path.dirname(configPath);
110
111 log.info(`Site: ${config.siteUrl}`);
112 log.info(`Publication: ${config.publicationUri}`);
113
114 // Load credentials
115 let credentials = await loadCredentials(config.identity);
116
117 if (!credentials) {
118 const identities = await listAllCredentials();
119 if (identities.length === 0) {
120 log.error(
121 "No credentials found. Run 'sequoia login' or 'sequoia auth' first.",
122 );
123 process.exit(1);
124 }
125
126 // Build labels with handles for OAuth sessions
127 const options = await Promise.all(
128 identities.map(async (cred) => {
129 if (cred.type === "oauth") {
130 const handle = await getOAuthHandle(cred.id);
131 return {
132 value: cred.id,
133 label: `${handle || cred.id} (OAuth)`,
134 };
135 }
136 return {
137 value: cred.id,
138 label: `${cred.id} (App Password)`,
139 };
140 }),
141 );
142
143 log.info("Multiple identities found. Select one to use:");
144 const selected = exitOnCancel(
145 await select({
146 message: "Identity:",
147 options,
148 }),
149 );
150
151 // Load the selected credentials
152 const selectedCred = identities.find((c) => c.id === selected);
153 if (selectedCred?.type === "oauth") {
154 const session = await getOAuthSession(selected);
155 if (session) {
156 const handle = await getOAuthHandle(selected);
157 credentials = {
158 type: "oauth",
159 did: selected,
160 handle: handle || selected,
161 };
162 }
163 } else {
164 credentials = await getCredentials(selected);
165 }
166
167 if (!credentials) {
168 log.error("Failed to load selected credentials.");
169 process.exit(1);
170 }
171 }
172
173 // Create agent
174 const s = spinner();
175 const connectingTo =
176 credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl;
177 s.start(`Connecting as ${connectingTo}...`);
178 let agent: Awaited<ReturnType<typeof createAgent>> | undefined;
179 try {
180 agent = await createAgent(credentials);
181 s.stop(`Logged in as ${agent.did}`);
182 } catch (error) {
183 s.stop("Failed to login");
184 log.error(`Failed to login: ${error}`);
185 process.exit(1);
186 }
187
188 // Fetch documents from PDS
189 s.start("Fetching documents from PDS...");
190 const documents = await listDocuments(agent, config.publicationUri);
191 s.stop(`Found ${documents.length} documents on PDS`);
192
193 if (documents.length === 0) {
194 log.info("No documents found for this publication.");
195 return;
196 }
197
198 // Resolve content directory
199 const contentDir = path.isAbsolute(config.contentDir)
200 ? config.contentDir
201 : path.join(configDir, config.contentDir);
202
203 // Scan local posts
204 s.start("Scanning local content...");
205 const localPosts = await scanContentDirectory(contentDir, {
206 frontmatterMapping: config.frontmatter,
207 ignorePatterns: config.ignore,
208 slugField: config.frontmatter?.slugField,
209 removeIndexFromSlug: config.removeIndexFromSlug,
210 stripDatePrefix: config.stripDatePrefix,
211 });
212 s.stop(`Found ${localPosts.length} local posts`);
213
214 // Build a map of atUri -> local post for matching
215 const postsByAtUri = new Map<string, (typeof localPosts)[0]>();
216 for (const post of localPosts) {
217 if (post.frontmatter.atUri) {
218 postsByAtUri.set(post.frontmatter.atUri, post);
219 }
220 }
221
222 // Load existing state
223 const state = await loadState(configDir);
224 const originalPostCount = Object.keys(state.posts).length;
225
226 // Track changes
227 let matchedCount = 0;
228 let unmatchedCount = 0;
229 const frontmatterUpdates: Array<{ filePath: string; atUri: string }> = [];
230
231 log.message("\nMatching documents to local files:\n");
232
233 for (const doc of documents) {
234 const localPost = postsByAtUri.get(doc.uri);
235
236 if (localPost) {
237 matchedCount++;
238 log.message(` ✓ ${doc.value.title}`);
239 log.message(` Path: ${doc.value.path}`);
240 log.message(` URI: ${doc.uri}`);
241 log.message(` File: ${path.basename(localPost.filePath)}`);
242
243 // If local content matches PDS, store the local hash (up to date).
244 // If it differs, store empty hash so publish detects the change.
245 const contentMatchesPDS = await matchesPDS(
246 localPost,
247 doc,
248 agent,
249 config.textContentField,
250 );
251 const contentHash = contentMatchesPDS
252 ? await getContentHash(localPost.rawContent)
253 : "";
254 const relativeFilePath = path.relative(configDir, localPost.filePath);
255 state.posts[relativeFilePath] = {
256 contentHash,
257 atUri: doc.uri,
258 lastPublished: doc.value.publishedAt,
259 };
260
261 // Check if frontmatter needs updating
262 if (updateFrontmatter && localPost.frontmatter.atUri !== doc.uri) {
263 frontmatterUpdates.push({
264 filePath: localPost.filePath,
265 atUri: doc.uri,
266 });
267 log.message(` → Will update frontmatter`);
268 }
269 } else {
270 unmatchedCount++;
271 log.message(` ✗ ${doc.value.title} (no matching local file)`);
272 log.message(` Path: ${doc.value.path}`);
273 log.message(` URI: ${doc.uri}`);
274 }
275 log.message("");
276 }
277
278 // Summary
279 log.message("---");
280 log.info(`Matched: ${matchedCount} documents`);
281 if (unmatchedCount > 0) {
282 log.warn(
283 `Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`,
284 );
285 log.info(
286 `Run 'sequoia publish' to delete unmatched records from your PDS.`,
287 );
288 }
289
290 if (dryRun) {
291 log.info("\nDry run complete. No changes made.");
292 return;
293 }
294
295 // Save updated state
296 await saveState(configDir, state);
297 const newPostCount = Object.keys(state.posts).length;
298 log.success(
299 `\nSaved .sequoia-state.json (${originalPostCount} → ${newPostCount} entries)`,
300 );
301
302 // Update frontmatter if requested
303 if (frontmatterUpdates.length > 0) {
304 s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`);
305 for (const { filePath, atUri } of frontmatterUpdates) {
306 const content = await fs.readFile(filePath, "utf-8");
307 const updated = updateFrontmatterWithAtUri(content, atUri);
308 await fs.writeFile(filePath, updated);
309 log.message(` Updated: ${path.basename(filePath)}`);
310 }
311 s.stop("Frontmatter updated");
312 }
313
314 log.success("\nSync complete!");
315 },
316});