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