A CLI for publishing standard.site documents to ATProto
at v0.1.0 183 lines 6.2 kB view raw
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});