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