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 { createAgent, listDocuments } from "../lib/atproto";
13import {
14 scanContentDirectory,
15 getContentHash,
16 getTextContent,
17 updateFrontmatterWithAtUri,
18} from "../lib/markdown";
19import { exitOnCancel } from "../lib/prompts";
20
21export const syncCommand = command({
22 name: "sync",
23 description: "Sync state from ATProto to restore .sequoia-state.json",
24 args: {
25 updateFrontmatter: flag({
26 long: "update-frontmatter",
27 short: "u",
28 description: "Update frontmatter atUri fields in local markdown files",
29 }),
30 dryRun: flag({
31 long: "dry-run",
32 short: "n",
33 description: "Preview what would be synced without making changes",
34 }),
35 },
36 handler: async ({ updateFrontmatter, dryRun }) => {
37 // Load config
38 const configPath = await findConfig();
39 if (!configPath) {
40 log.error("No sequoia.json found. Run 'sequoia init' first.");
41 process.exit(1);
42 }
43
44 const config = await loadConfig(configPath);
45 const configDir = path.dirname(configPath);
46
47 log.info(`Site: ${config.siteUrl}`);
48 log.info(`Publication: ${config.publicationUri}`);
49
50 // Load credentials
51 let credentials = await loadCredentials(config.identity);
52
53 if (!credentials) {
54 const identities = await listAllCredentials();
55 if (identities.length === 0) {
56 log.error(
57 "No credentials found. Run 'sequoia login' or 'sequoia auth' first.",
58 );
59 process.exit(1);
60 }
61
62 // Build labels with handles for OAuth sessions
63 const options = await Promise.all(
64 identities.map(async (cred) => {
65 if (cred.type === "oauth") {
66 const handle = await getOAuthHandle(cred.id);
67 return {
68 value: cred.id,
69 label: `${handle || cred.id} (OAuth)`,
70 };
71 }
72 return {
73 value: cred.id,
74 label: `${cred.id} (App Password)`,
75 };
76 }),
77 );
78
79 log.info("Multiple identities found. Select one to use:");
80 const selected = exitOnCancel(
81 await select({
82 message: "Identity:",
83 options,
84 }),
85 );
86
87 // Load the selected credentials
88 const selectedCred = identities.find((c) => c.id === selected);
89 if (selectedCred?.type === "oauth") {
90 const session = await getOAuthSession(selected);
91 if (session) {
92 const handle = await getOAuthHandle(selected);
93 credentials = {
94 type: "oauth",
95 did: selected,
96 handle: handle || selected,
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 const connectingTo =
112 credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl;
113 s.start(`Connecting as ${connectingTo}...`);
114 let agent: Awaited<ReturnType<typeof createAgent>> | undefined;
115 try {
116 agent = await createAgent(credentials);
117 s.stop(`Logged in as ${agent.did}`);
118 } catch (error) {
119 s.stop("Failed to login");
120 log.error(`Failed to login: ${error}`);
121 process.exit(1);
122 }
123
124 // Fetch documents from PDS
125 s.start("Fetching documents from PDS...");
126 const documents = await listDocuments(agent, config.publicationUri);
127 s.stop(`Found ${documents.length} documents on PDS`);
128
129 if (documents.length === 0) {
130 log.info("No documents found for this publication.");
131 return;
132 }
133
134 // Resolve content directory
135 const contentDir = path.isAbsolute(config.contentDir)
136 ? config.contentDir
137 : path.join(configDir, config.contentDir);
138
139 // Scan local posts
140 s.start("Scanning local content...");
141 const localPosts = await scanContentDirectory(contentDir, {
142 frontmatterMapping: config.frontmatter,
143 ignorePatterns: config.ignore,
144 slugField: config.frontmatter?.slugField,
145 removeIndexFromSlug: config.removeIndexFromSlug,
146 stripDatePrefix: config.stripDatePrefix,
147 });
148 s.stop(`Found ${localPosts.length} local posts`);
149
150 // Build a map of path -> local post for matching
151 // Document path is like /posts/my-post-slug (or custom pathPrefix)
152 const pathPrefix = config.pathPrefix || "/posts";
153 const postsByPath = new Map<string, (typeof localPosts)[0]>();
154 for (const post of localPosts) {
155 const postPath = `${pathPrefix}/${post.slug}`;
156 postsByPath.set(postPath, post);
157 }
158
159 // Load existing state
160 const state = await loadState(configDir);
161 const originalPostCount = Object.keys(state.posts).length;
162
163 // Track changes
164 let matchedCount = 0;
165 let unmatchedCount = 0;
166 const frontmatterUpdates: Array<{ filePath: string; atUri: string }> = [];
167
168 log.message("\nMatching documents to local files:\n");
169
170 for (const doc of documents) {
171 const docPath = doc.value.path;
172 const localPost = postsByPath.get(docPath);
173
174 if (localPost) {
175 matchedCount++;
176 log.message(` ✓ ${doc.value.title}`);
177 log.message(` Path: ${docPath}`);
178 log.message(` URI: ${doc.uri}`);
179 log.message(` File: ${path.basename(localPost.filePath)}`);
180
181 // Compare local text content with PDS text content to detect changes.
182 // We must avoid storing the local rawContent hash blindly, because
183 // that would make publish think nothing changed even when content
184 // was modified since the last publish.
185 const localTextContent = getTextContent(
186 localPost,
187 config.textContentField,
188 );
189 const contentMatchesPDS =
190 localTextContent.slice(0, 10000) === doc.value.textContent;
191
192 // If local content matches PDS, store the local hash (up to date).
193 // If it differs, store empty hash so publish detects the change.
194 const contentHash = contentMatchesPDS
195 ? await getContentHash(localPost.rawContent)
196 : "";
197 const relativeFilePath = path.relative(configDir, localPost.filePath);
198 state.posts[relativeFilePath] = {
199 contentHash,
200 atUri: doc.uri,
201 lastPublished: doc.value.publishedAt,
202 };
203
204 // Check if frontmatter needs updating
205 if (updateFrontmatter && localPost.frontmatter.atUri !== doc.uri) {
206 frontmatterUpdates.push({
207 filePath: localPost.filePath,
208 atUri: doc.uri,
209 });
210 log.message(` → Will update frontmatter`);
211 }
212 } else {
213 unmatchedCount++;
214 log.message(` ✗ ${doc.value.title} (no matching local file)`);
215 log.message(` Path: ${docPath}`);
216 log.message(` URI: ${doc.uri}`);
217 }
218 log.message("");
219 }
220
221 // Summary
222 log.message("---");
223 log.info(`Matched: ${matchedCount} documents`);
224 if (unmatchedCount > 0) {
225 log.warn(
226 `Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`,
227 );
228 }
229
230 if (dryRun) {
231 log.info("\nDry run complete. No changes made.");
232 return;
233 }
234
235 // Save updated state
236 await saveState(configDir, state);
237 const newPostCount = Object.keys(state.posts).length;
238 log.success(
239 `\nSaved .sequoia-state.json (${originalPostCount} → ${newPostCount} entries)`,
240 );
241
242 // Update frontmatter if requested
243 if (frontmatterUpdates.length > 0) {
244 s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`);
245 for (const { filePath, atUri } of frontmatterUpdates) {
246 const content = await fs.readFile(filePath, "utf-8");
247 const updated = updateFrontmatterWithAtUri(content, atUri);
248 await fs.writeFile(filePath, updated);
249 log.message(` Updated: ${path.basename(filePath)}`);
250 }
251 s.stop("Frontmatter updated");
252 }
253
254 log.success("\nSync complete!");
255 },
256});