A CLI for publishing standard.site documents to ATProto
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 322 lines 9.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 type { Agent } from "@atproto/api"; 13import { createAgent, listDocuments } from "../lib/atproto"; 14import type { ListDocumentsResult } from "../lib/atproto"; 15import type { BlogPost } from "../lib/types"; 16import { 17 scanContentDirectory, 18 getContentHash, 19 getTextContent, 20 updateFrontmatterWithAtUri, 21 resolvePostPath, 22} from "../lib/markdown"; 23import { exitOnCancel } from "../lib/prompts"; 24 25async function matchesPDS( 26 localPost: BlogPost, 27 doc: ListDocumentsResult, 28 agent: Agent, 29 textContentField?: string, 30): Promise<boolean> { 31 // Compare body text content 32 const localTextContent = getTextContent(localPost, textContentField); 33 if (localTextContent.slice(0, 10000) !== doc.value.textContent) { 34 return false; 35 } 36 37 // Compare document fields: title, description, tags 38 const trimmedContent = localPost.content.trim(); 39 const titleMatch = trimmedContent.match(/^# (.+)$/m); 40 const localTitle = titleMatch ? titleMatch[1] : localPost.frontmatter.title; 41 if (localTitle !== doc.value.title) return false; 42 43 const localDescription = localPost.frontmatter.description || undefined; 44 if (localDescription !== doc.value.description) return false; 45 46 const localTags = 47 localPost.frontmatter.tags && localPost.frontmatter.tags.length > 0 48 ? localPost.frontmatter.tags 49 : undefined; 50 if (JSON.stringify(localTags) !== JSON.stringify(doc.value.tags)) { 51 return false; 52 } 53 54 // Compare note-specific fields: theme, fontSize, fontFamily. 55 // Fetch the space.remanso.note record to check these fields. 56 const noteUriMatch = doc.uri.match(/^at:\/\/([^/]+)\/[^/]+\/(.+)$/); 57 if (noteUriMatch) { 58 const repo = noteUriMatch[1]!; 59 const rkey = noteUriMatch[2]!; 60 try { 61 const noteResponse = await agent.com.atproto.repo.getRecord({ 62 repo, 63 collection: "space.remanso.note", 64 rkey, 65 }); 66 const noteValue = noteResponse.data.value as Record<string, unknown>; 67 if ( 68 (localPost.frontmatter.theme || undefined) !== 69 (noteValue.theme as string | undefined) || 70 (localPost.frontmatter.fontSize || undefined) !== 71 (noteValue.fontSize as number | undefined) || 72 (localPost.frontmatter.fontFamily || undefined) !== 73 (noteValue.fontFamily as string | undefined) 74 ) { 75 return false; 76 } 77 } catch { 78 // Note record doesn't exist — treat as matching to avoid 79 // forcing a re-publish of posts never published as notes. 80 } 81 } 82 83 return true; 84} 85 86export const syncCommand = command({ 87 name: "sync", 88 description: "Sync state from ATProto to restore .sequoia-state.json", 89 args: { 90 updateFrontmatter: flag({ 91 long: "update-frontmatter", 92 short: "u", 93 description: "Update frontmatter atUri fields in local markdown files", 94 }), 95 dryRun: flag({ 96 long: "dry-run", 97 short: "n", 98 description: "Preview what would be synced without making changes", 99 }), 100 }, 101 handler: async ({ updateFrontmatter, dryRun }) => { 102 // Load config 103 const configPath = await findConfig(); 104 if (!configPath) { 105 log.error("No sequoia.json found. Run 'sequoia init' first."); 106 process.exit(1); 107 } 108 109 const config = await loadConfig(configPath); 110 const configDir = path.dirname(configPath); 111 112 log.info(`Site: ${config.siteUrl}`); 113 log.info(`Publication: ${config.publicationUri}`); 114 115 // Load credentials 116 let credentials = await loadCredentials(config.identity); 117 118 if (!credentials) { 119 const identities = await listAllCredentials(); 120 if (identities.length === 0) { 121 log.error( 122 "No credentials found. Run 'sequoia login' or 'sequoia auth' first.", 123 ); 124 process.exit(1); 125 } 126 127 // Build labels with handles for OAuth sessions 128 const options = await Promise.all( 129 identities.map(async (cred) => { 130 if (cred.type === "oauth") { 131 const handle = await getOAuthHandle(cred.id); 132 return { 133 value: cred.id, 134 label: `${handle || cred.id} (OAuth)`, 135 }; 136 } 137 return { 138 value: cred.id, 139 label: `${cred.id} (App Password)`, 140 }; 141 }), 142 ); 143 144 log.info("Multiple identities found. Select one to use:"); 145 const selected = exitOnCancel( 146 await select({ 147 message: "Identity:", 148 options, 149 }), 150 ); 151 152 // Load the selected credentials 153 const selectedCred = identities.find((c) => c.id === selected); 154 if (selectedCred?.type === "oauth") { 155 const session = await getOAuthSession(selected); 156 if (session) { 157 const handle = await getOAuthHandle(selected); 158 credentials = { 159 type: "oauth", 160 did: selected, 161 handle: handle || selected, 162 }; 163 } 164 } else { 165 credentials = await getCredentials(selected); 166 } 167 168 if (!credentials) { 169 log.error("Failed to load selected credentials."); 170 process.exit(1); 171 } 172 } 173 174 // Create agent 175 const s = spinner(); 176 const connectingTo = 177 credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl; 178 s.start(`Connecting as ${connectingTo}...`); 179 let agent: Awaited<ReturnType<typeof createAgent>> | undefined; 180 try { 181 agent = await createAgent(credentials); 182 s.stop(`Logged in as ${agent.did}`); 183 } catch (error) { 184 s.stop("Failed to login"); 185 log.error(`Failed to login: ${error}`); 186 process.exit(1); 187 } 188 189 // Fetch documents from PDS 190 s.start("Fetching documents from PDS..."); 191 const documents = await listDocuments(agent, config.publicationUri); 192 s.stop(`Found ${documents.length} documents on PDS`); 193 194 if (documents.length === 0) { 195 log.info("No documents found for this publication."); 196 return; 197 } 198 199 // Resolve content directory 200 const contentDir = path.isAbsolute(config.contentDir) 201 ? config.contentDir 202 : path.join(configDir, config.contentDir); 203 204 // Scan local posts 205 s.start("Scanning local content..."); 206 const localPosts = await scanContentDirectory(contentDir, { 207 frontmatterMapping: config.frontmatter, 208 ignorePatterns: config.ignore, 209 slugField: config.frontmatter?.slugField, 210 removeIndexFromSlug: config.removeIndexFromSlug, 211 stripDatePrefix: config.stripDatePrefix, 212 }); 213 s.stop(`Found ${localPosts.length} local posts`); 214 215 // Build a map of path -> local post for matching 216 // Document path is like /posts/my-post-slug (or custom pathPrefix/pathTemplate) 217 const postsByPath = new Map<string, (typeof localPosts)[0]>(); 218 for (const post of localPosts) { 219 const postPath = resolvePostPath( 220 post, 221 config.pathPrefix, 222 config.pathTemplate, 223 ); 224 postsByPath.set(postPath, post); 225 } 226 227 // Load existing state 228 const state = await loadState(configDir); 229 const originalPostCount = Object.keys(state.posts).length; 230 231 // Track changes 232 let matchedCount = 0; 233 let unmatchedCount = 0; 234 const frontmatterUpdates: Array<{ filePath: string; atUri: string }> = []; 235 236 log.message("\nMatching documents to local files:\n"); 237 238 for (const doc of documents) { 239 const docPath = doc.value.path; 240 const localPost = postsByPath.get(docPath); 241 242 if (localPost) { 243 matchedCount++; 244 log.message(`${doc.value.title}`); 245 log.message(` Path: ${docPath}`); 246 log.message(` URI: ${doc.uri}`); 247 log.message(` File: ${path.basename(localPost.filePath)}`); 248 249 // If local content matches PDS, store the local hash (up to date). 250 // If it differs, store empty hash so publish detects the change. 251 const contentMatchesPDS = await matchesPDS( 252 localPost, 253 doc, 254 agent, 255 config.textContentField, 256 ); 257 const contentHash = contentMatchesPDS 258 ? await getContentHash(localPost.rawContent) 259 : ""; 260 const relativeFilePath = path.relative(configDir, localPost.filePath); 261 state.posts[relativeFilePath] = { 262 contentHash, 263 atUri: doc.uri, 264 lastPublished: doc.value.publishedAt, 265 }; 266 267 // Check if frontmatter needs updating 268 if (updateFrontmatter && localPost.frontmatter.atUri !== doc.uri) { 269 frontmatterUpdates.push({ 270 filePath: localPost.filePath, 271 atUri: doc.uri, 272 }); 273 log.message(` → Will update frontmatter`); 274 } 275 } else { 276 unmatchedCount++; 277 log.message(`${doc.value.title} (no matching local file)`); 278 log.message(` Path: ${docPath}`); 279 log.message(` URI: ${doc.uri}`); 280 } 281 log.message(""); 282 } 283 284 // Summary 285 log.message("---"); 286 log.info(`Matched: ${matchedCount} documents`); 287 if (unmatchedCount > 0) { 288 log.warn( 289 `Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`, 290 ); 291 log.info( 292 `Run 'sequoia publish' to delete unmatched records from your PDS.`, 293 ); 294 } 295 296 if (dryRun) { 297 log.info("\nDry run complete. No changes made."); 298 return; 299 } 300 301 // Save updated state 302 await saveState(configDir, state); 303 const newPostCount = Object.keys(state.posts).length; 304 log.success( 305 `\nSaved .sequoia-state.json (${originalPostCount}${newPostCount} entries)`, 306 ); 307 308 // Update frontmatter if requested 309 if (frontmatterUpdates.length > 0) { 310 s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`); 311 for (const { filePath, atUri } of frontmatterUpdates) { 312 const content = await fs.readFile(filePath, "utf-8"); 313 const updated = updateFrontmatterWithAtUri(content, atUri); 314 await fs.writeFile(filePath, updated); 315 log.message(` Updated: ${path.basename(filePath)}`); 316 } 317 s.stop("Frontmatter updated"); 318 } 319 320 log.success("\nSync complete!"); 321 }, 322});