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