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