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