A CLI for publishing standard.site documents to ATProto
at main 256 lines 7.7 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 getTextContent, 17 updateFrontmatterWithAtUri, 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) 152 const pathPrefix = config.pathPrefix || "/posts"; 153 const postsByPath = new Map<string, (typeof localPosts)[0]>(); 154 for (const post of localPosts) { 155 const postPath = `${pathPrefix}/${post.slug}`; 156 postsByPath.set(postPath, post); 157 } 158 159 // Load existing state 160 const state = await loadState(configDir); 161 const originalPostCount = Object.keys(state.posts).length; 162 163 // Track changes 164 let matchedCount = 0; 165 let unmatchedCount = 0; 166 const frontmatterUpdates: Array<{ filePath: string; atUri: string }> = []; 167 168 log.message("\nMatching documents to local files:\n"); 169 170 for (const doc of documents) { 171 const docPath = doc.value.path; 172 const localPost = postsByPath.get(docPath); 173 174 if (localPost) { 175 matchedCount++; 176 log.message(`${doc.value.title}`); 177 log.message(` Path: ${docPath}`); 178 log.message(` URI: ${doc.uri}`); 179 log.message(` File: ${path.basename(localPost.filePath)}`); 180 181 // Compare local text content with PDS text content to detect changes. 182 // We must avoid storing the local rawContent hash blindly, because 183 // that would make publish think nothing changed even when content 184 // was modified since the last publish. 185 const localTextContent = getTextContent( 186 localPost, 187 config.textContentField, 188 ); 189 const contentMatchesPDS = 190 localTextContent.slice(0, 10000) === doc.value.textContent; 191 192 // If local content matches PDS, store the local hash (up to date). 193 // If it differs, store empty hash so publish detects the change. 194 const contentHash = contentMatchesPDS 195 ? await getContentHash(localPost.rawContent) 196 : ""; 197 const relativeFilePath = path.relative(configDir, localPost.filePath); 198 state.posts[relativeFilePath] = { 199 contentHash, 200 atUri: doc.uri, 201 lastPublished: doc.value.publishedAt, 202 }; 203 204 // Check if frontmatter needs updating 205 if (updateFrontmatter && localPost.frontmatter.atUri !== doc.uri) { 206 frontmatterUpdates.push({ 207 filePath: localPost.filePath, 208 atUri: doc.uri, 209 }); 210 log.message(` → Will update frontmatter`); 211 } 212 } else { 213 unmatchedCount++; 214 log.message(`${doc.value.title} (no matching local file)`); 215 log.message(` Path: ${docPath}`); 216 log.message(` URI: ${doc.uri}`); 217 } 218 log.message(""); 219 } 220 221 // Summary 222 log.message("---"); 223 log.info(`Matched: ${matchedCount} documents`); 224 if (unmatchedCount > 0) { 225 log.warn( 226 `Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`, 227 ); 228 } 229 230 if (dryRun) { 231 log.info("\nDry run complete. No changes made."); 232 return; 233 } 234 235 // Save updated state 236 await saveState(configDir, state); 237 const newPostCount = Object.keys(state.posts).length; 238 log.success( 239 `\nSaved .sequoia-state.json (${originalPostCount}${newPostCount} entries)`, 240 ); 241 242 // Update frontmatter if requested 243 if (frontmatterUpdates.length > 0) { 244 s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`); 245 for (const { filePath, atUri } of frontmatterUpdates) { 246 const content = await fs.readFile(filePath, "utf-8"); 247 const updated = updateFrontmatterWithAtUri(content, atUri); 248 await fs.writeFile(filePath, updated); 249 log.message(` Updated: ${path.basename(filePath)}`); 250 } 251 s.stop("Frontmatter updated"); 252 } 253 254 log.success("\nSync complete!"); 255 }, 256});