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