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 updateFrontmatterWithAtUri,
17 resolvePostPath,
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 strict: flag({
36 long: "strict",
37 description:
38 "Filter out any PDS docs without matching local files on first sync",
39 }),
40 },
41 handler: async ({ updateFrontmatter, dryRun, strict }) => {
42 // Load config
43 const configPath = await findConfig();
44 if (!configPath) {
45 log.error("No sequoia.json found. Run 'sequoia init' first.");
46 process.exit(1);
47 }
48
49 const config = await loadConfig(configPath);
50 const configDir = path.dirname(configPath);
51
52 log.info(`Site: ${config.siteUrl}`);
53 log.info(`Publication: ${config.publicationUri}`);
54
55 // Load credentials
56 let credentials = await loadCredentials(config.identity);
57
58 if (!credentials) {
59 const identities = await listAllCredentials();
60 if (identities.length === 0) {
61 log.error(
62 "No credentials found. Run 'sequoia login' or 'sequoia auth' first.",
63 );
64 process.exit(1);
65 }
66
67 // Build labels with handles for OAuth sessions
68 const options = await Promise.all(
69 identities.map(async (cred) => {
70 if (cred.type === "oauth") {
71 const handle = await getOAuthHandle(cred.id);
72 return {
73 value: cred.id,
74 label: `${handle || cred.id} (OAuth)`,
75 };
76 }
77 return {
78 value: cred.id,
79 label: `${cred.id} (App Password)`,
80 };
81 }),
82 );
83
84 log.info("Multiple identities found. Select one to use:");
85 const selected = exitOnCancel(
86 await select({
87 message: "Identity:",
88 options,
89 }),
90 );
91
92 // Load the selected credentials
93 const selectedCred = identities.find((c) => c.id === selected);
94 if (selectedCred?.type === "oauth") {
95 const session = await getOAuthSession(selected);
96 if (session) {
97 const handle = await getOAuthHandle(selected);
98 credentials = {
99 type: "oauth",
100 did: selected,
101 handle: handle || selected,
102 };
103 }
104 } else {
105 credentials = await getCredentials(selected);
106 }
107
108 if (!credentials) {
109 log.error("Failed to load selected credentials.");
110 process.exit(1);
111 }
112 }
113
114 // Create agent
115 const s = spinner();
116 const connectingTo =
117 credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl;
118 s.start(`Connecting as ${connectingTo}...`);
119 let agent: Awaited<ReturnType<typeof createAgent>> | undefined;
120 try {
121 agent = await createAgent(credentials);
122 s.stop(`Logged in as ${agent.did}`);
123 } catch (error) {
124 s.stop("Failed to login");
125 log.error(`Failed to login: ${error}`);
126 process.exit(1);
127 }
128
129 // Fetch documents from PDS
130 s.start("Fetching documents from PDS...");
131 const documents = await listDocuments(agent, config.publicationUri);
132 s.stop(`Found ${documents.length} documents on PDS`);
133
134 if (documents.length === 0) {
135 log.info("No documents found for this publication.");
136 return;
137 }
138
139 // Resolve content directory
140 const contentDir = path.isAbsolute(config.contentDir)
141 ? config.contentDir
142 : path.join(configDir, config.contentDir);
143
144 // Scan local posts
145 s.start("Scanning local content...");
146 const localPosts = await scanContentDirectory(contentDir, {
147 frontmatterMapping: config.frontmatter,
148 ignorePatterns: config.ignore,
149 slugField: config.frontmatter?.slugField,
150 removeIndexFromSlug: config.removeIndexFromSlug,
151 stripDatePrefix: config.stripDatePrefix,
152 });
153 s.stop(`Found ${localPosts.length} local posts`);
154
155 // Build a map of path -> local post for matching
156 // Document path is like /posts/my-post-slug (or custom pathPrefix/pathTemplate)
157 const postsByPath = new Map<string, (typeof localPosts)[0]>();
158 for (const post of localPosts) {
159 const postPath = resolvePostPath(
160 post,
161 config.pathPrefix,
162 config.pathTemplate,
163 );
164 postsByPath.set(postPath, post);
165 }
166
167 // Load existing state
168 const state = await loadState(configDir);
169 const originalPostCount = Object.keys(state.posts).length;
170 const isFirstSync = originalPostCount === 0;
171
172 // Track changes
173 let matchedCount = 0;
174 let orphanedCount = 0;
175 const frontmatterUpdates: Array<{ filePath: string; atUri: string }> = [];
176
177 // Initialize orphaned if needed
178 if (!state.orphaned) {
179 state.orphaned = {};
180 }
181
182 // Clear orphaned on first sync (unless strict is used)
183 if (isFirstSync && !strict) {
184 state.orphaned = {};
185 }
186
187 log.message("\nMatching documents to local files:\n");
188
189 for (const doc of documents) {
190 const docPath = doc.value.path;
191 const localPost = postsByPath.get(docPath);
192
193 if (localPost) {
194 matchedCount++;
195 log.message(` ✓ ${doc.value.title}`);
196 log.message(` Path: ${docPath}`);
197 log.message(` URI: ${doc.uri}`);
198 log.message(` File: ${path.basename(localPost.filePath)}`);
199
200 // Update state (use relative path from config directory)
201 const contentHash = await getContentHash(localPost.rawContent);
202 const relativeFilePath = path.relative(configDir, localPost.filePath);
203 const existingState = state.posts[relativeFilePath];
204 state.posts[relativeFilePath] = {
205 contentHash: existingState?.contentHash ?? contentHash,
206 atUri: doc.uri,
207 pdsUri: doc.uri,
208 lastPublished: doc.value.publishedAt,
209 slug: existingState?.slug,
210 bskyPostRef: existingState?.bskyPostRef,
211 };
212
213 // Remove from orphaned if it was there
214 if (state.orphaned) {
215 for (const [orphanedPath, orphanedDoc] of Object.entries(
216 state.orphaned,
217 )) {
218 if (orphanedDoc.path === docPath) {
219 delete state.orphaned[orphanedPath];
220 break;
221 }
222 }
223 }
224
225 // Check if frontmatter needs updating
226 if (updateFrontmatter && localPost.frontmatter.atUri !== doc.uri) {
227 frontmatterUpdates.push({
228 filePath: localPost.filePath,
229 atUri: doc.uri,
230 });
231 log.message(` → Will update frontmatter`);
232 }
233 } else {
234 orphanedCount++;
235 log.message(` ✗ ${doc.value.title} (no matching local file)`);
236 log.message(` Path: ${docPath}`);
237 log.message(` URI: ${doc.uri}`);
238
239 // Track orphaned document
240 const orphanedKey = doc.uri;
241 state.orphaned![orphanedKey] = {
242 atUri: doc.uri,
243 title: doc.value.title,
244 path: docPath,
245 };
246 }
247 log.message("");
248 }
249
250 // Summary
251 log.message("---");
252 log.info(`Matched: ${matchedCount} documents`);
253 if (orphanedCount > 0) {
254 log.warn(`Orphaned: ${orphanedCount} documents (exist on PDS but not locally)`);
255 for (const [key, orphan] of Object.entries(state.orphaned ?? {})) {
256 log.message(` - ${orphan.path} (${orphan.atUri})`);
257 }
258 }
259
260 if (dryRun) {
261 log.info("\nDry run complete. No changes made.");
262 return;
263 }
264
265 // Save updated state
266 await saveState(configDir, state);
267 const newPostCount = Object.keys(state.posts).length;
268 log.success(
269 `\nSaved .sequoia-state.json (${originalPostCount} → ${newPostCount} entries)`,
270 );
271
272 // Update frontmatter if requested
273 if (frontmatterUpdates.length > 0) {
274 s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`);
275 for (const { filePath, atUri } of frontmatterUpdates) {
276 const content = await fs.readFile(filePath, "utf-8");
277 const updated = updateFrontmatterWithAtUri(content, atUri);
278 await fs.writeFile(filePath, updated);
279 log.message(` Updated: ${path.basename(filePath)}`);
280 }
281 s.stop("Frontmatter updated");
282 }
283
284 log.success("\nSync complete!");
285 },
286});