A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing
at v0.1.1 183 lines 6.2 kB view raw
1import * as fs from "fs/promises"; 2import { command, flag } from "cmd-ts"; 3import { select, spinner, log } from "@clack/prompts"; 4import * as path from "path"; 5import { loadConfig, loadState, saveState, findConfig } from "../lib/config"; 6import { loadCredentials, listCredentials, getCredentials } from "../lib/credentials"; 7import { createAgent, listDocuments } from "../lib/atproto"; 8import { scanContentDirectory, getContentHash, updateFrontmatterWithAtUri } from "../lib/markdown"; 9import { exitOnCancel } from "../lib/prompts"; 10 11export const syncCommand = command({ 12 name: "sync", 13 description: "Sync state from ATProto to restore .sequoia-state.json", 14 args: { 15 updateFrontmatter: flag({ 16 long: "update-frontmatter", 17 short: "u", 18 description: "Update frontmatter atUri fields in local markdown files", 19 }), 20 dryRun: flag({ 21 long: "dry-run", 22 short: "n", 23 description: "Preview what would be synced without making changes", 24 }), 25 }, 26 handler: async ({ updateFrontmatter, dryRun }) => { 27 // Load config 28 const configPath = await findConfig(); 29 if (!configPath) { 30 log.error("No sequoia.json found. Run 'sequoia init' first."); 31 process.exit(1); 32 } 33 34 const config = await loadConfig(configPath); 35 const configDir = path.dirname(configPath); 36 37 log.info(`Site: ${config.siteUrl}`); 38 log.info(`Publication: ${config.publicationUri}`); 39 40 // Load credentials 41 let credentials = await loadCredentials(config.identity); 42 43 if (!credentials) { 44 const identities = await listCredentials(); 45 if (identities.length === 0) { 46 log.error("No credentials found. Run 'sequoia auth' first."); 47 process.exit(1); 48 } 49 50 log.info("Multiple identities found. Select one to use:"); 51 const selected = exitOnCancel(await select({ 52 message: "Identity:", 53 options: identities.map(id => ({ value: id, label: id })), 54 })); 55 56 credentials = await getCredentials(selected); 57 if (!credentials) { 58 log.error("Failed to load selected credentials."); 59 process.exit(1); 60 } 61 } 62 63 // Create agent 64 const s = spinner(); 65 s.start(`Connecting to ${credentials.pdsUrl}...`); 66 let agent; 67 try { 68 agent = await createAgent(credentials); 69 s.stop(`Logged in as ${agent.session?.handle}`); 70 } catch (error) { 71 s.stop("Failed to login"); 72 log.error(`Failed to login: ${error}`); 73 process.exit(1); 74 } 75 76 // Fetch documents from PDS 77 s.start("Fetching documents from PDS..."); 78 const documents = await listDocuments(agent, config.publicationUri); 79 s.stop(`Found ${documents.length} documents on PDS`); 80 81 if (documents.length === 0) { 82 log.info("No documents found for this publication."); 83 return; 84 } 85 86 // Resolve content directory 87 const contentDir = path.isAbsolute(config.contentDir) 88 ? config.contentDir 89 : path.join(configDir, config.contentDir); 90 91 // Scan local posts 92 s.start("Scanning local content..."); 93 const localPosts = await scanContentDirectory(contentDir, config.frontmatter); 94 s.stop(`Found ${localPosts.length} local posts`); 95 96 // Build a map of path -> local post for matching 97 // Document path is like /posts/my-post-slug 98 const postsByPath = new Map<string, typeof localPosts[0]>(); 99 for (const post of localPosts) { 100 const postPath = `/posts/${post.slug}`; 101 postsByPath.set(postPath, post); 102 } 103 104 // Load existing state 105 const state = await loadState(configDir); 106 const originalPostCount = Object.keys(state.posts).length; 107 108 // Track changes 109 let matchedCount = 0; 110 let unmatchedCount = 0; 111 let frontmatterUpdates: Array<{ filePath: string; atUri: string }> = []; 112 113 log.message("\nMatching documents to local files:\n"); 114 115 for (const doc of documents) { 116 const docPath = doc.value.path; 117 const localPost = postsByPath.get(docPath); 118 119 if (localPost) { 120 matchedCount++; 121 log.message(`${doc.value.title}`); 122 log.message(` Path: ${docPath}`); 123 log.message(` URI: ${doc.uri}`); 124 log.message(` File: ${path.basename(localPost.filePath)}`); 125 126 // Update state (use relative path from config directory) 127 const contentHash = await getContentHash(localPost.rawContent); 128 const relativeFilePath = path.relative(configDir, localPost.filePath); 129 state.posts[relativeFilePath] = { 130 contentHash, 131 atUri: doc.uri, 132 lastPublished: doc.value.publishedAt, 133 }; 134 135 // Check if frontmatter needs updating 136 if (updateFrontmatter && localPost.frontmatter.atUri !== doc.uri) { 137 frontmatterUpdates.push({ 138 filePath: localPost.filePath, 139 atUri: doc.uri, 140 }); 141 log.message(` → Will update frontmatter`); 142 } 143 } else { 144 unmatchedCount++; 145 log.message(`${doc.value.title} (no matching local file)`); 146 log.message(` Path: ${docPath}`); 147 log.message(` URI: ${doc.uri}`); 148 } 149 log.message(""); 150 } 151 152 // Summary 153 log.message("---"); 154 log.info(`Matched: ${matchedCount} documents`); 155 if (unmatchedCount > 0) { 156 log.warn(`Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`); 157 } 158 159 if (dryRun) { 160 log.info("\nDry run complete. No changes made."); 161 return; 162 } 163 164 // Save updated state 165 await saveState(configDir, state); 166 const newPostCount = Object.keys(state.posts).length; 167 log.success(`\nSaved .sequoia-state.json (${originalPostCount}${newPostCount} entries)`); 168 169 // Update frontmatter if requested 170 if (frontmatterUpdates.length > 0) { 171 s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`); 172 for (const { filePath, atUri } of frontmatterUpdates) { 173 const content = await fs.readFile(filePath, "utf-8"); 174 const updated = updateFrontmatterWithAtUri(content, atUri); 175 await fs.writeFile(filePath, updated); 176 log.message(` Updated: ${path.basename(filePath)}`); 177 } 178 s.stop("Frontmatter updated"); 179 } 180 181 log.success("\nSync complete!"); 182 }, 183});