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