A CLI for publishing standard.site documents to ATProto
at sync-update 286 lines 8.4 kB view raw
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});