A CLI for publishing standard.site documents to ATProto

feat: auto-delete orphaned posts during publish

When a file is removed from the content directory, its ATProto records
(document, remanso note, and bsky post) are now automatically deleted
from the PDS during publish. Supports --dry-run to preview deletions.

+125 -13
+96 -13
packages/cli/src/commands/publish.ts
··· 17 17 resolveImagePath, 18 18 createBlueskyPost, 19 19 addBskyPostRefToDocument, 20 + deleteRecord, 20 21 } from "../lib/atproto"; 21 22 import { 22 23 scanContentDirectory, ··· 25 26 } from "../lib/markdown"; 26 27 import type { BlogPost, BlobObject, StrongRef } from "../lib/types"; 27 28 import { exitOnCancel } from "../lib/prompts"; 28 - import { createNote, updateNote, findPostsWithStaleLinks, type NoteOptions } from "../extensions/remanso" 29 + import { createNote, updateNote, deleteNote, findPostsWithStaleLinks, type NoteOptions } from "../extensions/remanso" 29 30 30 31 export const publishCommand = command({ 31 32 name: "publish", ··· 147 148 // Load state 148 149 const state = await loadState(configDir); 149 150 151 + let agent: Awaited<ReturnType<typeof createAgent>> | undefined; 152 + 150 153 // Scan for posts 151 154 const s = spinner(); 152 155 s.start("Scanning for posts..."); ··· 159 162 }); 160 163 s.stop(`Found ${posts.length} posts`); 161 164 165 + // Detect orphaned posts (in state but file no longer exists) 166 + const currentFilePaths = new Set( 167 + posts.map((p) => path.relative(configDir, p.filePath)), 168 + ); 169 + const orphanedKeys = Object.keys(state.posts).filter( 170 + (key) => !currentFilePaths.has(key), 171 + ); 172 + 173 + if (orphanedKeys.length > 0) { 174 + log.info( 175 + `\n${orphanedKeys.length} orphaned post${orphanedKeys.length === 1 ? "" : "s"} to delete:\n`, 176 + ); 177 + for (const key of orphanedKeys) { 178 + log.message(` - ${key}`); 179 + } 180 + 181 + if (!dryRun) { 182 + const connectingTo = 183 + credentials.type === "oauth" 184 + ? credentials.handle 185 + : credentials.pdsUrl; 186 + s.start(`Connecting as ${connectingTo}...`); 187 + try { 188 + if (!agent) { 189 + agent = await createAgent(credentials); 190 + } 191 + s.stop(`Logged in as ${agent.did}`); 192 + } catch (error) { 193 + s.stop("Failed to login"); 194 + log.error(`Failed to login: ${error}`); 195 + process.exit(1); 196 + } 197 + 198 + let deletedCount = 0; 199 + for (const key of orphanedKeys) { 200 + const postState = state.posts[key]!; 201 + try { 202 + if (postState.atUri) { 203 + // Delete site.standard.document 204 + s.start(`Deleting: ${key}`); 205 + await deleteRecord(agent, postState.atUri); 206 + 207 + // Delete associated remanso note 208 + try { 209 + await deleteNote(agent, postState.atUri); 210 + } catch { 211 + // Note may not exist, ignore 212 + } 213 + 214 + // Delete app.bsky.feed.post if it exists 215 + if (postState.bskyPostRef) { 216 + try { 217 + await deleteRecord(agent, postState.bskyPostRef.uri); 218 + } catch { 219 + // Post may already be deleted, ignore 220 + } 221 + } 222 + 223 + s.stop(`Deleted: ${key}`); 224 + } 225 + delete state.posts[key]; 226 + deletedCount++; 227 + } catch (error) { 228 + s.stop(`Failed to delete: ${key}`); 229 + log.warn( 230 + ` ${error instanceof Error ? error.message : String(error)}`, 231 + ); 232 + } 233 + } 234 + 235 + await saveState(configDir, state); 236 + log.info(`Deleted ${deletedCount} orphaned post${deletedCount === 1 ? "" : "s"}`); 237 + } else { 238 + log.info("\nDry run: no deletions performed."); 239 + } 240 + } 241 + 162 242 // Determine which posts need publishing 163 243 const postsToPublish: Array<{ 164 244 post: BlogPost; ··· 256 336 return; 257 337 } 258 338 259 - // Create agent 260 - const connectingTo = 261 - credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl; 262 - s.start(`Connecting as ${connectingTo}...`); 263 - let agent: Awaited<ReturnType<typeof createAgent>> | undefined; 264 - try { 265 - agent = await createAgent(credentials); 266 - s.stop(`Logged in as ${agent.did}`); 267 - } catch (error) { 268 - s.stop("Failed to login"); 269 - log.error(`Failed to login: ${error}`); 270 - process.exit(1); 339 + // Create agent (if not already created during orphan cleanup) 340 + if (!agent) { 341 + const connectingTo = 342 + credentials.type === "oauth" 343 + ? credentials.handle 344 + : credentials.pdsUrl; 345 + s.start(`Connecting as ${connectingTo}...`); 346 + try { 347 + agent = await createAgent(credentials); 348 + s.stop(`Logged in as ${agent.did}`); 349 + } catch (error) { 350 + s.stop("Failed to login"); 351 + log.error(`Failed to login: ${error}`); 352 + process.exit(1); 353 + } 271 354 } 272 355 273 356 // Publish posts
+14
packages/cli/src/extensions/remanso.ts
··· 181 181 return uriMatch[3]! 182 182 } 183 183 184 + export async function deleteNote( 185 + agent: Agent, 186 + documentAtUri: string, 187 + ): Promise<void> { 188 + const uriMatch = documentAtUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/) 189 + if (!uriMatch) return 190 + const [, did, , rkey] = uriMatch 191 + await agent.com.atproto.repo.deleteRecord({ 192 + repo: did!, 193 + collection: LEXICON, 194 + rkey: rkey!, 195 + }) 196 + } 197 + 184 198 export async function createNote( 185 199 agent: Agent, 186 200 post: BlogPost,
+15
packages/cli/src/lib/atproto.ts
··· 345 345 }; 346 346 } 347 347 348 + export async function deleteRecord( 349 + agent: Agent, 350 + atUri: string, 351 + ): Promise<void> { 352 + const parsed = parseAtUri(atUri); 353 + if (!parsed) { 354 + throw new Error(`Invalid AT URI: ${atUri}`); 355 + } 356 + await agent.com.atproto.repo.deleteRecord({ 357 + repo: parsed.did, 358 + collection: parsed.collection, 359 + rkey: parsed.rkey, 360 + }); 361 + } 362 + 348 363 export interface DocumentRecord { 349 364 $type: "site.standard.document"; 350 365 title: string;