···1818 createBlueskyPost,
1919 addBskyPostRefToDocument,
2020 deleteRecord,
2121+ listDocuments,
2122} from "../lib/atproto";
2223import {
2324 scanContentDirectory,
···173174 posts.map((p) => path.relative(configDir, p.filePath)),
174175 );
175176 const deletedEntries: Array<{ filePath: string; atUri: string }> = [];
177177+176178 for (const [filePath, postState] of Object.entries(state.posts)) {
177179 if (!scannedPaths.has(filePath) && postState.atUri) {
178180 // Check if the file truly doesn't exist (not just excluded by ignore patterns)
179181 const absolutePath = path.resolve(configDir, filePath);
180180-181181- // If file exists but wasn't scanned (e.g. draft or ignored) — skip
182182 if (!(await fileExists(absolutePath))) {
183183 deletedEntries.push({ filePath, atUri: postState.atUri });
184184 }
185185 }
186186 }
187187+188188+ // Detect unmatched PDS records: exist on PDS but have no matching local file
189189+ const unmatchedEntries: Array<{ atUri: string; title: string }> = [];
187190188191 // Shared agent — created lazily, reused across deletion and publishing
189192 let agent: Awaited<ReturnType<typeof createAgent>> | undefined;
···257260 );
258261 }
259262260260- if (postsToPublish.length === 0) {
261261- log.success("All posts are up to date. Nothing to publish.");
262262- return;
263263+ // Fetch PDS records and detect unmatched documents
264264+ async function fetchUnmatchedRecords() {
265265+ const ag = await getAgent();
266266+ s.start("Fetching documents from PDS...");
267267+ const pdsDocuments = await listDocuments(ag, config.publicationUri);
268268+ s.stop(`Found ${pdsDocuments.length} documents on PDS`);
269269+270270+ const pathPrefix = config.pathPrefix || "/posts";
271271+ const postsByPath = new Map<string, BlogPost>();
272272+ for (const post of posts) {
273273+ postsByPath.set(`${pathPrefix}/${post.slug}`, post);
274274+ }
275275+ const deletedAtUris = new Set(deletedEntries.map((e) => e.atUri));
276276+ for (const doc of pdsDocuments) {
277277+ if (!postsByPath.has(doc.value.path) && !deletedAtUris.has(doc.uri)) {
278278+ unmatchedEntries.push({
279279+ atUri: doc.uri,
280280+ title: doc.value.title || doc.value.path,
281281+ });
282282+ }
283283+ }
263284 }
264285265265- log.info(`\n${postsToPublish.length} posts to publish:\n`);
286286+ if (postsToPublish.length === 0 && deletedEntries.length === 0) {
287287+ await fetchUnmatchedRecords();
288288+289289+ if (unmatchedEntries.length === 0) {
290290+ log.success("All posts are up to date. Nothing to publish.");
291291+ return;
292292+ }
293293+ }
266294267295 // Bluesky posting configuration
268296 const blueskyEnabled = config.bluesky?.enabled ?? false;
···270298 const cutoffDate = new Date();
271299 cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays);
272300273273- for (const { post, action, reason } of postsToPublish) {
274274- const icon = action === "create" ? "+" : "~";
275275- const relativeFilePath = path.relative(configDir, post.filePath);
276276- const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef;
301301+ if (postsToPublish.length > 0) {
302302+ log.info(`\n${postsToPublish.length} posts to publish:\n`);
303303+304304+ for (const { post, action, reason } of postsToPublish) {
305305+ const icon = action === "create" ? "+" : "~";
306306+ const relativeFilePath = path.relative(configDir, post.filePath);
307307+ const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef;
277308278278- let bskyNote = "";
279279- if (blueskyEnabled) {
280280- if (existingBskyPostRef) {
281281- bskyNote = " [bsky: exists]";
282282- } else {
283283- const publishDate = new Date(post.frontmatter.publishDate);
284284- if (publishDate < cutoffDate) {
285285- bskyNote = ` [bsky: skipped, older than ${maxAgeDays} days]`;
309309+ let bskyNote = "";
310310+ if (blueskyEnabled) {
311311+ if (existingBskyPostRef) {
312312+ bskyNote = " [bsky: exists]";
286313 } else {
287287- bskyNote = " [bsky: will post]";
314314+ const publishDate = new Date(post.frontmatter.publishDate);
315315+ if (publishDate < cutoffDate) {
316316+ bskyNote = ` [bsky: skipped, older than ${maxAgeDays} days]`;
317317+ } else {
318318+ bskyNote = " [bsky: will post]";
319319+ }
288320 }
289321 }
322322+323323+ let postUrl = "";
324324+ if (verbose) {
325325+ const postPath = resolvePostPath(
326326+ post,
327327+ config.pathPrefix,
328328+ config.pathTemplate,
329329+ );
330330+ postUrl = `\n ${config.siteUrl}${postPath}`;
331331+ }
332332+ log.message(
333333+ ` ${icon} ${post.filePath} (${reason})${bskyNote}${postUrl}`,
334334+ );
290335 }
336336+ }
291337292292- let postUrl = "";
293293- if (verbose) {
294294- const postPath = resolvePostPath(
295295- post,
296296- config.pathPrefix,
297297- config.pathTemplate,
298298- );
299299- postUrl = `\n ${config.siteUrl}${postPath}`;
338338+ if (deletedEntries.length > 0) {
339339+ log.info(
340340+ `\n${deletedEntries.length} deleted local files to remove from PDS:\n`,
341341+ );
342342+ for (const { filePath } of deletedEntries) {
343343+ log.message(` - ${filePath}`);
300344 }
301301- log.message(
302302- ` ${icon} ${post.filePath} (${reason})${bskyNote}${postUrl}`,
345345+ }
346346+347347+ if (unmatchedEntries.length > 0) {
348348+ log.info(
349349+ `\n${unmatchedEntries.length} unmatched PDS records to delete:\n`,
303350 );
351351+ for (const { title } of unmatchedEntries) {
352352+ log.message(` - ${title}`);
353353+ }
304354 }
305355306356 if (dryRun) {
···316366317367 if (!agent) {
318368 throw new Error("agent is not connected");
369369+ }
370370+371371+ // Fetch PDS records to detect unmatched documents (if not already done)
372372+ if (unmatchedEntries.length === 0) {
373373+ await fetchUnmatchedRecords();
319374 }
320375321376 // Publish posts
···512567 }
513568 }
514569570570+ // Delete records for removed files
571571+ let deletedCount = 0;
572572+ for (const { filePath, atUri } of deletedEntries) {
573573+ try {
574574+ const ag = await getAgent();
575575+ s.start(`Deleting: ${filePath}`);
576576+ await deleteRecord(ag, atUri);
577577+578578+ // Try to delete the corresponding Remanso note
579579+ try {
580580+ const noteAtUri = atUri.replace(
581581+ "site.standard.document",
582582+ "space.remanso.note",
583583+ );
584584+ await deleteNote(ag, noteAtUri);
585585+ } catch {
586586+ // Note may not exist, ignore
587587+ }
588588+589589+ delete state.posts[filePath];
590590+ s.stop(`Deleted: ${filePath}`);
591591+ deletedCount++;
592592+ } catch (error) {
593593+ s.stop(`Failed to delete: ${filePath}`);
594594+ log.warn(` ${error instanceof Error ? error.message : String(error)}`);
595595+ }
596596+ }
597597+598598+ // Delete unmatched PDS records (exist on PDS but no matching local file)
599599+ let unmatchedDeletedCount = 0;
600600+ for (const { atUri, title } of unmatchedEntries) {
601601+ try {
602602+ const ag = await getAgent();
603603+ s.start(`Deleting unmatched: ${title}`);
604604+ await deleteRecord(ag, atUri);
605605+606606+ // Try to delete the corresponding Remanso note
607607+ try {
608608+ const noteAtUri = atUri.replace(
609609+ "site.standard.document",
610610+ "space.remanso.note",
611611+ );
612612+ await deleteNote(ag, noteAtUri);
613613+ } catch {
614614+ // Note may not exist, ignore
615615+ }
616616+617617+ s.stop(`Deleted unmatched: ${title}`);
618618+ unmatchedDeletedCount++;
619619+ } catch (error) {
620620+ s.stop(`Failed to delete: ${title}`);
621621+ log.warn(` ${error instanceof Error ? error.message : String(error)}`);
622622+ }
623623+ }
624624+515625 // Save state
516626 await saveState(configDir, state);
517627518628 // Summary
519629 log.message("\n---");
520520- if (deletedEntries.length > 0) {
521521- log.info(`Deleted: ${deletedEntries.length}`);
630630+ const totalDeleted = deletedCount + unmatchedDeletedCount;
631631+ if (totalDeleted > 0) {
632632+ log.info(`Deleted: ${totalDeleted}`);
522633 }
523634 log.info(`Published: ${publishedCount}`);
524635 log.info(`Updated: ${updatedCount}`);
+3
packages/cli/src/commands/sync.ts
···229229 log.warn(
230230 `Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`,
231231 );
232232+ log.info(
233233+ `Run 'sequoia publish' to delete unmatched records from your PDS.`,
234234+ );
232235 }
233236234237 if (dryRun) {