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