A CLI for publishing standard.site documents to ATProto

feat: add deletion

+145 -31
+142 -31
packages/cli/src/commands/publish.ts
··· 18 18 createBlueskyPost, 19 19 addBskyPostRefToDocument, 20 20 deleteRecord, 21 + listDocuments, 21 22 } from "../lib/atproto"; 22 23 import { 23 24 scanContentDirectory, ··· 173 174 posts.map((p) => path.relative(configDir, p.filePath)), 174 175 ); 175 176 const deletedEntries: Array<{ filePath: string; atUri: string }> = []; 177 + 176 178 for (const [filePath, postState] of Object.entries(state.posts)) { 177 179 if (!scannedPaths.has(filePath) && postState.atUri) { 178 180 // Check if the file truly doesn't exist (not just excluded by ignore patterns) 179 181 const absolutePath = path.resolve(configDir, filePath); 180 - 181 - // If file exists but wasn't scanned (e.g. draft or ignored) — skip 182 182 if (!(await fileExists(absolutePath))) { 183 183 deletedEntries.push({ filePath, atUri: postState.atUri }); 184 184 } 185 185 } 186 186 } 187 + 188 + // Detect unmatched PDS records: exist on PDS but have no matching local file 189 + const unmatchedEntries: Array<{ atUri: string; title: string }> = []; 187 190 188 191 // Shared agent — created lazily, reused across deletion and publishing 189 192 let agent: Awaited<ReturnType<typeof createAgent>> | undefined; ··· 257 260 ); 258 261 } 259 262 260 - if (postsToPublish.length === 0) { 261 - log.success("All posts are up to date. Nothing to publish."); 262 - return; 263 + // Fetch PDS records and detect unmatched documents 264 + async function fetchUnmatchedRecords() { 265 + const ag = await getAgent(); 266 + s.start("Fetching documents from PDS..."); 267 + const pdsDocuments = await listDocuments(ag, config.publicationUri); 268 + s.stop(`Found ${pdsDocuments.length} documents on PDS`); 269 + 270 + const pathPrefix = config.pathPrefix || "/posts"; 271 + const postsByPath = new Map<string, BlogPost>(); 272 + for (const post of posts) { 273 + postsByPath.set(`${pathPrefix}/${post.slug}`, post); 274 + } 275 + const deletedAtUris = new Set(deletedEntries.map((e) => e.atUri)); 276 + for (const doc of pdsDocuments) { 277 + if (!postsByPath.has(doc.value.path) && !deletedAtUris.has(doc.uri)) { 278 + unmatchedEntries.push({ 279 + atUri: doc.uri, 280 + title: doc.value.title || doc.value.path, 281 + }); 282 + } 283 + } 263 284 } 264 285 265 - log.info(`\n${postsToPublish.length} posts to publish:\n`); 286 + if (postsToPublish.length === 0 && deletedEntries.length === 0) { 287 + await fetchUnmatchedRecords(); 288 + 289 + if (unmatchedEntries.length === 0) { 290 + log.success("All posts are up to date. Nothing to publish."); 291 + return; 292 + } 293 + } 266 294 267 295 // Bluesky posting configuration 268 296 const blueskyEnabled = config.bluesky?.enabled ?? false; ··· 270 298 const cutoffDate = new Date(); 271 299 cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays); 272 300 273 - for (const { post, action, reason } of postsToPublish) { 274 - const icon = action === "create" ? "+" : "~"; 275 - const relativeFilePath = path.relative(configDir, post.filePath); 276 - const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef; 301 + if (postsToPublish.length > 0) { 302 + log.info(`\n${postsToPublish.length} posts to publish:\n`); 303 + 304 + for (const { post, action, reason } of postsToPublish) { 305 + const icon = action === "create" ? "+" : "~"; 306 + const relativeFilePath = path.relative(configDir, post.filePath); 307 + const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef; 277 308 278 - let bskyNote = ""; 279 - if (blueskyEnabled) { 280 - if (existingBskyPostRef) { 281 - bskyNote = " [bsky: exists]"; 282 - } else { 283 - const publishDate = new Date(post.frontmatter.publishDate); 284 - if (publishDate < cutoffDate) { 285 - bskyNote = ` [bsky: skipped, older than ${maxAgeDays} days]`; 309 + let bskyNote = ""; 310 + if (blueskyEnabled) { 311 + if (existingBskyPostRef) { 312 + bskyNote = " [bsky: exists]"; 286 313 } else { 287 - bskyNote = " [bsky: will post]"; 314 + const publishDate = new Date(post.frontmatter.publishDate); 315 + if (publishDate < cutoffDate) { 316 + bskyNote = ` [bsky: skipped, older than ${maxAgeDays} days]`; 317 + } else { 318 + bskyNote = " [bsky: will post]"; 319 + } 288 320 } 289 321 } 322 + 323 + let postUrl = ""; 324 + if (verbose) { 325 + const postPath = resolvePostPath( 326 + post, 327 + config.pathPrefix, 328 + config.pathTemplate, 329 + ); 330 + postUrl = `\n ${config.siteUrl}${postPath}`; 331 + } 332 + log.message( 333 + ` ${icon} ${post.filePath} (${reason})${bskyNote}${postUrl}`, 334 + ); 290 335 } 336 + } 291 337 292 - let postUrl = ""; 293 - if (verbose) { 294 - const postPath = resolvePostPath( 295 - post, 296 - config.pathPrefix, 297 - config.pathTemplate, 298 - ); 299 - postUrl = `\n ${config.siteUrl}${postPath}`; 338 + if (deletedEntries.length > 0) { 339 + log.info( 340 + `\n${deletedEntries.length} deleted local files to remove from PDS:\n`, 341 + ); 342 + for (const { filePath } of deletedEntries) { 343 + log.message(` - ${filePath}`); 300 344 } 301 - log.message( 302 - ` ${icon} ${post.filePath} (${reason})${bskyNote}${postUrl}`, 345 + } 346 + 347 + if (unmatchedEntries.length > 0) { 348 + log.info( 349 + `\n${unmatchedEntries.length} unmatched PDS records to delete:\n`, 303 350 ); 351 + for (const { title } of unmatchedEntries) { 352 + log.message(` - ${title}`); 353 + } 304 354 } 305 355 306 356 if (dryRun) { ··· 316 366 317 367 if (!agent) { 318 368 throw new Error("agent is not connected"); 369 + } 370 + 371 + // Fetch PDS records to detect unmatched documents (if not already done) 372 + if (unmatchedEntries.length === 0) { 373 + await fetchUnmatchedRecords(); 319 374 } 320 375 321 376 // Publish posts ··· 512 567 } 513 568 } 514 569 570 + // Delete records for removed files 571 + let deletedCount = 0; 572 + for (const { filePath, atUri } of deletedEntries) { 573 + try { 574 + const ag = await getAgent(); 575 + s.start(`Deleting: ${filePath}`); 576 + await deleteRecord(ag, atUri); 577 + 578 + // Try to delete the corresponding Remanso note 579 + try { 580 + const noteAtUri = atUri.replace( 581 + "site.standard.document", 582 + "space.remanso.note", 583 + ); 584 + await deleteNote(ag, noteAtUri); 585 + } catch { 586 + // Note may not exist, ignore 587 + } 588 + 589 + delete state.posts[filePath]; 590 + s.stop(`Deleted: ${filePath}`); 591 + deletedCount++; 592 + } catch (error) { 593 + s.stop(`Failed to delete: ${filePath}`); 594 + log.warn(` ${error instanceof Error ? error.message : String(error)}`); 595 + } 596 + } 597 + 598 + // Delete unmatched PDS records (exist on PDS but no matching local file) 599 + let unmatchedDeletedCount = 0; 600 + for (const { atUri, title } of unmatchedEntries) { 601 + try { 602 + const ag = await getAgent(); 603 + s.start(`Deleting unmatched: ${title}`); 604 + await deleteRecord(ag, atUri); 605 + 606 + // Try to delete the corresponding Remanso note 607 + try { 608 + const noteAtUri = atUri.replace( 609 + "site.standard.document", 610 + "space.remanso.note", 611 + ); 612 + await deleteNote(ag, noteAtUri); 613 + } catch { 614 + // Note may not exist, ignore 615 + } 616 + 617 + s.stop(`Deleted unmatched: ${title}`); 618 + unmatchedDeletedCount++; 619 + } catch (error) { 620 + s.stop(`Failed to delete: ${title}`); 621 + log.warn(` ${error instanceof Error ? error.message : String(error)}`); 622 + } 623 + } 624 + 515 625 // Save state 516 626 await saveState(configDir, state); 517 627 518 628 // Summary 519 629 log.message("\n---"); 520 - if (deletedEntries.length > 0) { 521 - log.info(`Deleted: ${deletedEntries.length}`); 630 + const totalDeleted = deletedCount + unmatchedDeletedCount; 631 + if (totalDeleted > 0) { 632 + log.info(`Deleted: ${totalDeleted}`); 522 633 } 523 634 log.info(`Published: ${publishedCount}`); 524 635 log.info(`Updated: ${updatedCount}`);
+3
packages/cli/src/commands/sync.ts
··· 229 229 log.warn( 230 230 `Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`, 231 231 ); 232 + log.info( 233 + `Run 'sequoia publish' to delete unmatched records from your PDS.`, 234 + ); 232 235 } 233 236 234 237 if (dryRun) {