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