Mass Block [bsky] Reposts [and more]
1import { mkdir, readFile, writeFile } from "node:fs/promises";
2import { homedir } from "node:os";
3import { dirname, join } from "node:path";
4import { fileURLToPath } from "node:url";
5import { parseArgs as nodeParseArgs } from "node:util";
6import { OAuthClient, MemoryStore } from "@atcute/oauth-node-client";
7import { Prompt } from "@clack/core";
8import * as p from "@clack/prompts";
9import color from "picocolors";
10
11// ── runtime detection ───────────────────────────────────────────────
12const IS_BUN = typeof Bun !== "undefined";
13
14// ── config ──────────────────────────────────────────────────────────
15const AUTHOR_CONTACT = "did:plc:7exy3k53z33dvghn6edyayxt; winter@madoka.systems";
16let USER_AGENT = "mbr/1.0";
17const CONSTELLATION_BASE = process.env.CONSTELLATION_BASE ?? "https://constellation.microcosm.blue";
18const SLINGSHOT_BASE = process.env.SLINGSHOT_BASE ?? "https://slingshot.microcosm.blue";
19const PORT = 22891;
20const REDIRECT_URI = `http://127.0.0.1:${PORT}/callback`;
21const BLOCK_DELAY_MS = 150;
22const BATCH_SIZE = 200;
23const PAGE_SIZE = 100;
24const PROFILE_BATCH_SIZE = 25;
25
26const POST_CATEGORIES = /** @type {const} */ (["reposts", "replies", "likes", "quotes", "followers"]);
27const PROFILE_CATEGORIES = /** @type {const} */ (["followers", "following"]);
28const DUPLICATE_PATTERNS = ["duplicate", "already exists"];
29
30// ── CLI parsing ─────────────────────────────────────────────────────
31function parseArgs() {
32 const argv = IS_BUN
33 ? (() => { const i = Bun.argv.findIndex((a) => a.endsWith(".js") || a.endsWith(".ts")); return i >= 0 ? Bun.argv.slice(i + 1) : Bun.argv.slice(2); })()
34 : process.argv.slice(2);
35
36 const { values } = nodeParseArgs({
37 args: argv,
38 options: {
39 delay: { type: "string" },
40 batch: { type: "string" },
41 "dry-run": { type: "boolean" },
42 "no-block-target": { type: "boolean" },
43 output: { type: "string" },
44 unblock: { type: "boolean" },
45 help: { type: "boolean", short: "h" },
46 },
47 strict: false,
48 });
49
50 return {
51 flags: {
52 delay: values.delay != null ? parseInt(values.delay, 10) : undefined,
53 batch: values.batch != null ? parseInt(values.batch, 10) : undefined,
54 dryRun: values["dry-run"],
55 noBlockTarget: values["no-block-target"],
56 output: values.output,
57 unblock: values.unblock,
58 help: values.help,
59 },
60 };
61}
62
63function printUsage() {
64 console.log(`mbr: interactively block engagement on an atproto post
65
66usage:
67 bun index.js [options]
68 node index.js [options]
69
70options:
71 --delay <ms> delay between batches in ms (default: ${BLOCK_DELAY_MS})
72 --batch <n> writes per applyWrites call (default: ${BATCH_SIZE})
73 --dry-run show what would be blocked without actually blocking
74 --no-block-target don't block the target account itself
75 --output <file> write blocked DIDs to file (one per line)
76 --unblock remove blocks instead of adding them
77 -h, --help show this help`);
78}
79
80// ── shared helpers ──────────────────────────────────────────────────
81const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
82
83function exitIfCancelled(value) {
84 if (p.isCancel(value)) { p.cancel("cancelled."); process.exit(0); }
85 return value;
86}
87
88async function writeOutput(path, dids) {
89 const { writeFile } = await import("node:fs/promises");
90 await writeFile(path, dids.join("\n") + "\n");
91}
92
93async function resolveMiniDoc(identifier) {
94 const res = await fetch(
95 `${SLINGSHOT_BASE}/xrpc/blue.microcosm.identity.resolveMiniDoc?identifier=${encodeURIComponent(identifier)}`,
96 { headers: { "user-agent": USER_AGENT } }
97 );
98 if (!res.ok) throw new Error(`slingshot identity error for ${identifier}: ${res.status}`);
99 const doc = await res.json();
100 if (typeof doc?.did !== "string") throw new Error(`unexpected slingshot response for ${identifier}`);
101 return doc;
102}
103
104async function resolveHandle(handle) {
105 return (await resolveMiniDoc(handle)).did;
106}
107
108function makeBlockRecord(targetDid, createdAt) {
109 return { $type: "app.bsky.graph.block", subject: targetDid, createdAt };
110}
111
112function isDuplicateError(msg) {
113 return DUPLICATE_PATTERNS.some((pat) => msg.includes(pat));
114}
115
116async function parseApiError(res) {
117 const body = await res.json().catch(() => ({}));
118 return body?.message ?? body?.error ?? `status ${res.status}`;
119}
120
121// ── TID generation ──────────────────────────────────────────────────
122const TID_CHARS = "234567abcdefghijklmnopqrstuvwxyz";
123const tidClockId = BigInt(Math.floor(Math.random() * 1024));
124let tidLast = 0n;
125
126function generateTid() {
127 let now = BigInt(Date.now()) * 1000n;
128 if (now <= tidLast) now = tidLast + 1n;
129 tidLast = now;
130 let v = (now << 10n) | (tidClockId & 0x3ffn);
131 let out = "";
132 for (let i = 0; i < 13; i++) {
133 out = TID_CHARS[Number(v & 31n)] + out;
134 v >>= 5n;
135 }
136 return out;
137}
138
139// ── CAR / CBOR (inline, no deps) ────────────────────────────────────
140const TEXT_DECODER = new TextDecoder();
141
142function readVarint(bytes, pos) {
143 let val = 0, mul = 1;
144 for (;;) {
145 const b = bytes[pos++];
146 val += (b & 0x7f) * mul;
147 if (!(b & 0x80)) break;
148 mul *= 128;
149 }
150 return [val, pos];
151}
152
153// Minimal dag-cbor decoder — handles the subset used in ATProto records.
154function decodeCbor(bytes, pos) {
155 const initial = bytes[pos++];
156 const major = initial >> 5;
157 const info = initial & 0x1f;
158
159 let arg = info;
160 if (info === 24) { arg = bytes[pos++]; }
161 else if (info === 25) { arg = (bytes[pos] << 8) | bytes[pos + 1]; pos += 2; }
162 else if (info === 26) { arg = ((bytes[pos] << 24) | (bytes[pos+1] << 16) | (bytes[pos+2] << 8) | bytes[pos+3]) >>> 0; pos += 4; }
163 else if (info === 27) { pos += 8; arg = 0; } // 8-byte ints won't appear in block records
164
165 switch (major) {
166 case 0: return [arg, pos];
167 case 1: return [-(arg + 1), pos];
168 case 2: { const end = pos + arg; return [bytes.slice(pos, end), end]; }
169 case 3: { const end = pos + arg; return [TEXT_DECODER.decode(bytes.slice(pos, end)), end]; }
170 case 4: {
171 const arr = [];
172 for (let i = 0; i < arg; i++) { let v; [v, pos] = decodeCbor(bytes, pos); arr.push(v); }
173 return [arr, pos];
174 }
175 case 5: {
176 const obj = Object.create(null);
177 for (let i = 0; i < arg; i++) {
178 let k, v;
179 [k, pos] = decodeCbor(bytes, pos);
180 [v, pos] = decodeCbor(bytes, pos);
181 obj[k] = v;
182 }
183 return [obj, pos];
184 }
185 case 6: return decodeCbor(bytes, pos); // tag — unwrap (tag 42 = CID link, we don't need the value)
186 case 7:
187 if (info === 20) return [false, pos];
188 if (info === 21) return [true, pos];
189 if (info === 22) return [null, pos];
190 return [undefined, pos]; // floats: bytes already consumed above
191 default: return [undefined, pos];
192 }
193}
194
195// ── URL type detection ──────────────────────────────────────────────
196function isProfileUrl(input) {
197 if (input.startsWith("at://")) return false;
198 return input.includes("/profile/") && !input.includes("/post/");
199}
200
201async function resolveToDid(handleOrDid) {
202 if (handleOrDid.startsWith("did:")) return handleOrDid;
203 return resolveHandle(handleOrDid);
204}
205
206async function resolveProfileTarget(input) {
207 const match = input.match(/\/profile\/([^/?#]+)/);
208 if (!match) throw new Error(`can't parse profile URL: ${input}`);
209 return resolveToDid(match[1]);
210}
211
212// ── URL/URI parsing ─────────────────────────────────────────────────
213async function resolvePostUri(input) {
214 if (input.startsWith("at://")) return input;
215
216 const urlMatch = input.match(/\/profile\/([^/]+)\/post\/([^/?#]+)/);
217 if (!urlMatch) {
218 throw new Error(
219 `can't parse post URL: ${input}\nexpected format: https://<domain>/profile/<handle>/post/<rkey>`
220 );
221 }
222
223 const [, handleOrDid, rkey] = urlMatch;
224 const did = await resolveToDid(handleOrDid);
225 return `at://${did}/app.bsky.feed.post/${rkey}`;
226}
227
228// ── constellation ───────────────────────────────────────────────────
229async function fetchConstellationDids(target, collection, path, onProgress) {
230 const allDids = [];
231 let cursor;
232
233 while (true) {
234 const url = new URL(`${CONSTELLATION_BASE}/links/distinct-dids`);
235 url.searchParams.set("target", target);
236 url.searchParams.set("collection", collection);
237 url.searchParams.set("path", path);
238 url.searchParams.set("limit", String(PAGE_SIZE));
239 if (cursor) url.searchParams.set("cursor", cursor);
240
241 const res = await fetch(url, { headers: { "user-agent": USER_AGENT } });
242 if (!res.ok) throw new Error(`constellation error: ${res.status}`);
243 const data = await res.json();
244
245 allDids.push(...data.linking_dids);
246 if (onProgress) onProgress(data.linking_dids.length);
247 if (!data.cursor || data.linking_dids.length === 0) break;
248 cursor = data.cursor;
249 }
250
251 return allDids;
252}
253
254const fetchReposters = (atUri, onProgress) =>
255 fetchConstellationDids(atUri, "app.bsky.feed.repost", ".subject.uri", onProgress);
256
257const fetchLikers = (atUri, onProgress) =>
258 fetchConstellationDids(atUri, "app.bsky.feed.like", ".subject.uri", onProgress);
259
260const fetchRepliers = (atUri, onProgress) =>
261 fetchConstellationDids(atUri, "app.bsky.feed.post", ".reply.parent.uri", onProgress);
262
263const fetchQuotePosters = (atUri, onProgress) =>
264 fetchConstellationDids(atUri, "app.bsky.feed.post", ".embed.record.uri", onProgress);
265
266function extractAuthorDid(atUri) {
267 return atUri.replace("at://", "").split("/")[0];
268}
269
270// ── social graph ────────────────────────────────────────────────────
271async function resolvePds(did) {
272 const doc = await resolveMiniDoc(did);
273 if (typeof doc?.pds !== "string") throw new Error(`no pds in slingshot response for ${did}`);
274 return doc.pds;
275}
276
277async function fetchFollowing(did, pdsUrl, onProgress) {
278 const allDids = [];
279 let cursor;
280
281 while (true) {
282 const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.listRecords`);
283 url.searchParams.set("repo", did);
284 url.searchParams.set("collection", "app.bsky.graph.follow");
285 url.searchParams.set("limit", "100");
286 if (cursor) url.searchParams.set("cursor", cursor);
287
288 const res = await fetch(url);
289 if (!res.ok) throw new Error(`listRecords error: ${res.status}`);
290 const data = await res.json();
291
292 for (const rec of data.records) allDids.push(rec.value.subject);
293 if (onProgress) onProgress(data.records.length);
294 if (!data.cursor || data.records.length === 0) break;
295 cursor = data.cursor;
296 }
297
298 return new Set(allDids);
299}
300
301async function fetchFollowers(did, onProgress) {
302 const dids = await fetchConstellationDids(did, "app.bsky.graph.follow", ".subject", onProgress);
303 return new Set(dids);
304}
305
306// Fast-path extractor for app.bsky.graph.block records from raw dag-cbor bytes.
307// dag-cbor uses deterministic key ordering (length-first, then lex), so a block
308// record's keys always appear in order: "$type"(5) < "subject"(7) < "createdAt"(9).
309// That gives a fixed byte prefix we can check cheaply before touching the decoder.
310//
311// Layout from pos:
312// a3 map(3)
313// 65 tstr(5)
314// 24 74 79 70 65 "$type"
315// 74 tstr(20)
316// [20 bytes] "app.bsky.graph.block"
317// 67 tstr(7)
318// [7 bytes] "subject"
319// [tstr hdr] subject DID string
320const BLOCK_TYPE_BYTES = new TextEncoder().encode("app.bsky.graph.block");
321const SUBJECT_KEY_BYTES = new TextEncoder().encode("subject");
322
323function extractBlockSubject(bytes, pos) {
324 // map(3), tstr(5), "$type"
325 if (bytes[pos] !== 0xa3 || bytes[pos+1] !== 0x65) return null;
326 if (bytes[pos+2] !== 0x24 || bytes[pos+3] !== 0x74 ||
327 bytes[pos+4] !== 0x79 || bytes[pos+5] !== 0x70 || bytes[pos+6] !== 0x65) return null;
328 // tstr(20) + "app.bsky.graph.block"
329 if (bytes[pos+7] !== 0x74) return null;
330 for (let i = 0; i < 20; i++) {
331 if (bytes[pos + 8 + i] !== BLOCK_TYPE_BYTES[i]) return null;
332 }
333 // tstr(7) + "subject"
334 let p = pos + 28;
335 if (bytes[p] !== 0x67) return null;
336 p++;
337 for (let i = 0; i < 7; i++) {
338 if (bytes[p + i] !== SUBJECT_KEY_BYTES[i]) return null;
339 }
340 p += 7;
341 // read the subject DID string
342 const hdr = bytes[p++];
343 if ((hdr >> 5) !== 3) return null; // not a tstr
344 const info = hdr & 0x1f;
345 let len;
346 if (info <= 23) { len = info; }
347 else if (info === 24) { len = bytes[p++]; }
348 else if (info === 25) { len = (bytes[p] << 8) | bytes[p + 1]; p += 2; }
349 else return null;
350 return TEXT_DECODER.decode(bytes.slice(p, p + len));
351}
352
353async function fetchExistingBlocks(did, pdsUrl) {
354 const res = await fetch(
355 `${pdsUrl}/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(did)}`,
356 { headers: { "user-agent": USER_AGENT } }
357 );
358 if (!res.ok) throw new Error(`getRepo error: ${res.status}`);
359 const bytes = new Uint8Array(await res.arrayBuffer());
360
361 // Parse CARv1: skip header, then scan every dag-cbor block for block records
362 let pos = 0;
363 let headerLen; [headerLen, pos] = readVarint(bytes, pos);
364 pos += headerLen;
365
366 const blocked = new Set();
367 while (pos < bytes.length) {
368 let sectionLen; [sectionLen, pos] = readVarint(bytes, pos);
369 const sectionEnd = pos + sectionLen;
370
371 // CIDv1: version + codec + multihash (fn + digestLen + digest)
372 let codec, digestLen;
373 [, pos] = readVarint(bytes, pos); // version
374 [codec, pos] = readVarint(bytes, pos);
375 [, pos] = readVarint(bytes, pos); // hash fn
376 [digestLen, pos] = readVarint(bytes, pos);
377 pos += digestLen;
378
379 if (codec === 0x71) { // dag-cbor
380 const subject = extractBlockSubject(bytes, pos);
381 if (subject !== null) blocked.add(subject);
382 }
383
384 pos = sectionEnd;
385 }
386
387 return blocked;
388}
389
390async function fetchBlockMap(did, pdsUrl, onProgress) {
391 const map = new Map();
392 let cursor;
393
394 while (true) {
395 const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.listRecords`);
396 url.searchParams.set("repo", did);
397 url.searchParams.set("collection", "app.bsky.graph.block");
398 url.searchParams.set("limit", "100");
399 if (cursor) url.searchParams.set("cursor", cursor);
400
401 const res = await fetch(url);
402 if (!res.ok) throw new Error(`listRecords error: ${res.status}`);
403 const data = await res.json();
404
405 for (const rec of data.records) {
406 const rkey = rec.uri.split("/").pop();
407 map.set(rec.value.subject, rkey);
408 }
409 if (onProgress) onProgress(data.records.length);
410 if (!data.cursor || data.records.length === 0) break;
411 cursor = data.cursor;
412 }
413
414 return map;
415}
416
417async function fetchSocialGraph(did, onProgress) {
418 const pdsUrl = await resolvePds(did);
419 let total = 0;
420 const tick = onProgress ? (delta) => { total += delta; onProgress(total); } : undefined;
421 const [follows, followers, existingBlocks] = await Promise.all([
422 fetchFollowing(did, pdsUrl, tick),
423 fetchFollowers(did, tick),
424 fetchExistingBlocks(did, pdsUrl),
425 ]);
426 return { follows, followers, existingBlocks };
427}
428
429// ── count prefetch ──────────────────────────────────────────────────
430async function fetchProfile(actor) {
431 const res = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(actor)}`);
432 if (!res.ok) return null;
433 return res.json();
434}
435
436async function fetchPostCounts(atUri) {
437 const [postRes, profile] = await Promise.all([
438 fetch(`https://public.api.bsky.app/xrpc/app.bsky.feed.getPosts?uris=${encodeURIComponent(atUri)}`),
439 fetchProfile(extractAuthorDid(atUri)),
440 ]);
441 const counts = {};
442 if (postRes.ok) {
443 const data = await postRes.json();
444 const post = data.posts?.[0];
445 if (post) {
446 counts.reposts = post.repostCount ?? 0;
447 counts.likes = post.likeCount ?? 0;
448 counts.replies = post.replyCount ?? 0;
449 }
450 }
451 if (profile) counts.followers = profile.followersCount ?? 0;
452 return counts;
453}
454
455async function fetchProfileCounts(did) {
456 const profile = await fetchProfile(did);
457 if (!profile) return {};
458 return {
459 followers: profile.followersCount ?? 0,
460 following: profile.followsCount ?? 0,
461 };
462}
463
464// ── profile resolution ──────────────────────────────────────────────
465async function resolveProfiles(dids) {
466 const batches = [];
467 for (let i = 0; i < dids.length; i += PROFILE_BATCH_SIZE) {
468 const params = dids.slice(i, i + PROFILE_BATCH_SIZE).map((d) => `actors=${encodeURIComponent(d)}`).join("&");
469 batches.push(
470 fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfiles?${params}`)
471 .then((r) => r.ok ? r.json() : null)
472 .then((d) => d?.profiles ?? [])
473 .catch(() => [])
474 );
475 }
476 return (await Promise.all(batches)).flat();
477}
478
479// ── oauth scope ─────────────────────────────────────────────────────
480function getOAuthScope(unblock) {
481 const action = unblock ? "delete" : "create";
482 return `atproto repo:app.bsky.graph.block?action=${action}`;
483}
484
485function scopeCovers(granted, required) {
486 if (!granted) return false;
487 const g = new Set(granted.trim().split(/\s+/));
488 return required.trim().split(/\s+/).every((tok) => g.has(tok));
489}
490
491// ── persistent session store ────────────────────────────────────────
492function getSessionStorePath() {
493 const base = process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
494 return join(base, "mbr", "sessions.json");
495}
496
497class FileStore {
498 #path;
499 #cache;
500
501 constructor(path) {
502 this.#path = path;
503 }
504
505 async #load() {
506 if (this.#cache) return this.#cache;
507 try {
508 const data = await readFile(this.#path, "utf8");
509 this.#cache = new Map(Object.entries(JSON.parse(data)));
510 } catch (err) {
511 if (err.code !== "ENOENT") throw err;
512 this.#cache = new Map();
513 }
514 return this.#cache;
515 }
516
517 async #flush() {
518 await mkdir(dirname(this.#path), { recursive: true });
519 await writeFile(this.#path, JSON.stringify(Object.fromEntries(this.#cache)), { mode: 0o600 });
520 }
521
522 async get(key) {
523 return (await this.#load()).get(key);
524 }
525
526 async set(key, value) {
527 (await this.#load()).set(key, value);
528 await this.#flush();
529 }
530
531 async delete(key) {
532 (await this.#load()).delete(key);
533 await this.#flush();
534 }
535
536 async clear() {
537 this.#cache = new Map();
538 await this.#flush();
539 }
540}
541
542const sessionStore = new FileStore(getSessionStorePath());
543
544// ── callback server ─────────────────────────────────────────────────
545async function startCallbackServer() {
546 let resolveSession, rejectSession;
547 const sessionPromise = new Promise((resolve, reject) => {
548 resolveSession = resolve;
549 rejectSession = reject;
550 });
551
552 const ctx = { oauthClient: null };
553
554 const handler = async (req) => {
555 const url = new URL(req.url);
556 if (url.pathname !== "/callback") {
557 return new Response("not found", { status: 404 });
558 }
559 try {
560 const { session } = await ctx.oauthClient.callback(url.searchParams);
561 resolveSession(session);
562 return new Response("<h2>authenticated! you can close this tab.</h2>", {
563 headers: { "content-type": "text/html" },
564 });
565 } catch (err) {
566 rejectSession(err);
567 return new Response(`oauth error: ${err.message}`, { status: 500 });
568 }
569 };
570
571 let server;
572
573 if (IS_BUN) {
574 server = Bun.serve({ port: PORT, hostname: "127.0.0.1", fetch: handler });
575 } else {
576 const { createServer } = await import("node:http");
577 server = createServer(async (req, res) => {
578 const fakeReq = new Request(`http://127.0.0.1:${PORT}${req.url}`);
579 const response = await handler(fakeReq);
580 res.writeHead(response.status, {
581 "content-type": response.headers.get("content-type") || "text/plain",
582 });
583 res.end(await response.text());
584 });
585 await new Promise((r) => server.listen(PORT, "127.0.0.1", r));
586 }
587
588 const close = () => {
589 if (IS_BUN) server.stop();
590 else server.close();
591 };
592
593 return { sessionPromise, close, ctx };
594}
595
596// ── oauth ───────────────────────────────────────────────────────────
597async function authenticate({ handle, did, scope }) {
598 const oauthClient = new OAuthClient({
599 metadata: {
600 redirect_uris: [REDIRECT_URI],
601 scope,
602 },
603 actorResolver: {
604 resolve: async (actor) => {
605 const doc = await resolveMiniDoc(actor);
606 return { did: doc.did, handle: doc.handle, pds: doc.pds };
607 },
608 },
609 stores: {
610 sessions: sessionStore,
611 states: new MemoryStore({ ttl: 600_000 }),
612 },
613 });
614
615 if (did) {
616 const stored = await sessionStore.get(did);
617 if (stored && scopeCovers(stored.tokenSet?.scope, scope)) {
618 try {
619 const session = await oauthClient.restore(did);
620 p.log.info(`restored cached session for ${session.did}`);
621 return session;
622 } catch {
623 // cached session unusable (expired/revoked) — fall through to full flow
624 }
625 }
626 }
627
628 const { sessionPromise, close, ctx } = await startCallbackServer();
629 ctx.oauthClient = oauthClient;
630
631 let authUrl;
632 try {
633 const { url } = await oauthClient.authorize({
634 target: { type: "account", identifier: handle },
635 scope,
636 });
637 authUrl = url.toString();
638
639 // Fire-and-forget: errors suppressed (fails silently in SSH/WSL)
640 const { execFile } = await import("node:child_process");
641 const cmd =
642 process.platform === "darwin" ? "open" :
643 process.platform === "win32" ? "start" :
644 "xdg-open";
645 execFile(cmd, [authUrl], () => {});
646 } catch (err) {
647 close();
648 throw new Error(`failed to start oauth flow: ${err.message}`);
649 }
650
651 p.log.info(`open this URL in your browser:\n ${color.cyan(authUrl)}`);
652 p.log.info(`SSH/WSL: if the redirect fails, copy the full ${color.dim("http://127.0.0.1:" + PORT + "/callback?code=...")} URL from your browser and paste it below`);
653
654 // Race: server callback vs manual paste
655 let serverWon = false;
656 let serverError = null;
657
658 // When server callback arrives, dismiss the paste prompt via a synthetic keypress.
659 // On server-side OAuth failure, surface the error and cancel the prompt.
660 sessionPromise.then(() => {
661 serverWon = true;
662 process.stdin.emit("keypress", "\r", { name: "return", ctrl: false, meta: false, shift: false });
663 }).catch((err) => {
664 serverError = err;
665 p.log.error(`browser callback failed: ${err.message}`);
666 process.stdin.emit("keypress", "\x03", { name: "c", ctrl: true, meta: false, shift: false });
667 });
668
669 const pastePrompt = new Prompt({
670 validate(value) {
671 if (serverWon) return; // server resolved -- accept empty input to dismiss
672 if (!value) return "waiting for browser redirect... or paste the full callback URL";
673 try {
674 const u = new URL(value);
675 if (!u.searchParams.has("code")) return "paste the full redirect URL from your browser (must include ?code=...)";
676 } catch {
677 return "not a valid URL";
678 }
679 },
680 render() {
681 const prefix = color.gray("│");
682 switch (this.state) {
683 case "submit":
684 return `${color.gray("◇")} ${serverWon ? "authenticated via browser" : "callback URL received"}`;
685 case "error":
686 return `${color.yellow("▲")} waiting for OAuth\n${prefix} ${this.value || ""}\n${prefix} ${color.yellow(this.error)}`;
687 default:
688 return `${color.cyan("◆")} waiting for OAuth (paste callback URL if redirect failed)\n${prefix} ${this.value || color.dim("http://127.0.0.1:" + PORT + "/callback?code=...")}`;
689 }
690 },
691 });
692
693 const pastedInput = await pastePrompt.prompt();
694
695 if (p.isCancel(pastedInput)) {
696 close();
697 if (serverError) throw serverError; // propagate server-side OAuth failure
698 p.cancel("cancelled.");
699 process.exit(0);
700 }
701
702 if (serverWon || !pastedInput) {
703 // Browser callback completed
704 const session = await sessionPromise;
705 close();
706 return session;
707 }
708
709 // User pasted the callback URL manually
710 close();
711 const params = new URL(pastedInput).searchParams;
712 const { session } = await oauthClient.callback(params);
713 return session;
714}
715
716// ── custom inline multi-select ──────────────────────────────────────
717function inlineMultiSelect({ message, options }) {
718 let cursor = 0;
719 const selected = new Set();
720
721 const prompt = new Prompt({
722 validate(value) {
723 if (!value || value.size === 0) return "select at least one option";
724 },
725 render() {
726 const prefix = color.gray("│");
727
728 const items = options
729 .map((opt, i) => {
730 const check = selected.has(opt.value)
731 ? color.green("◼")
732 : color.dim("◻");
733 const label =
734 i === cursor
735 ? color.cyan(`${check} ${color.underline(opt.label)}`)
736 : `${check} ${opt.label}`;
737 return label;
738 })
739 .join(" ");
740
741 switch (this.state) {
742 case "submit":
743 return `${color.gray("◇")} ${message}\n${prefix} ${color.dim([...selected].join(", "))}`;
744 case "cancel":
745 return `${color.gray("◇")} ${message}\n${prefix} ${color.strikethrough(color.dim("cancelled"))}`;
746 case "error":
747 return `${color.yellow("▲")} ${message}\n${prefix} ${items}\n${prefix} ${color.yellow(this.error)}`;
748 default:
749 return `${color.cyan("◆")} ${message}\n${prefix} ${items}\n${prefix} ${color.dim("← → move · space toggle · enter confirm")}`;
750 }
751 },
752 });
753
754 prompt.on("cursor", (key) => {
755 if (key === "right") {
756 cursor = (cursor + 1) % options.length;
757 } else if (key === "left") {
758 cursor = (cursor - 1 + options.length) % options.length;
759 } else if (key === "space") {
760 const val = options[cursor].value;
761 if (selected.has(val)) selected.delete(val);
762 else selected.add(val);
763 }
764 });
765
766 prompt.value = selected;
767
768 return prompt.prompt();
769}
770
771// ── interactive flow ────────────────────────────────────────────────
772const PROFILE_LABELS = { followers: "their followers", following: "their following" };
773
774async function runInteractiveFlow() {
775 p.intro(color.inverse(" bluesky blocker "));
776
777 const url = exitIfCancelled(await p.text({
778 message: "paste a post URL, profile URL, or at:// URI",
779 placeholder: "https://bsky.app/profile/someone.bsky.social/post/abc123",
780 validate: (v) => {
781 if (!v) return "url is required";
782 if (v.startsWith("at://")) return;
783 if (v.includes("/post/")) return;
784 if (v.includes("/profile/")) return;
785 return "paste a post URL (…/post/…), a profile URL (…/profile/…), or an at:// URI";
786 },
787 }));
788
789 const mode = isProfileUrl(url) ? "profile" : "post";
790
791 // Resolve target and prefetch counts before showing category selector
792 const s = p.spinner();
793 s.start(mode === "post" ? "resolving post..." : "resolving profile...");
794 let target, counts;
795 try {
796 target = mode === "post" ? await resolvePostUri(url) : await resolveProfileTarget(url);
797 counts = mode === "post" ? await fetchPostCounts(target) : await fetchProfileCounts(target);
798 s.stop(`target: ${target}`);
799 } catch (err) {
800 s.stop("failed to resolve");
801 throw err;
802 }
803
804 const categoryList = mode === "post" ? POST_CATEGORIES : PROFILE_CATEGORIES;
805 const categories = exitIfCancelled(await inlineMultiSelect({
806 message: "what do you want to block?",
807 options: categoryList.map((c) => {
808 const base = mode === "profile" ? (PROFILE_LABELS[c] ?? c) : c;
809 const n = counts?.[c];
810 return { value: c, label: n !== undefined ? `${base} (${n.toLocaleString()})` : base };
811 }),
812 }));
813
814 const blockFollowing = exitIfCancelled(await p.confirm({
815 message: "include people you're following in blocks?",
816 initialValue: false,
817 }));
818
819 const handle = exitIfCancelled(await p.text({
820 message: "enter your bluesky handle",
821 placeholder: "you.bsky.social",
822 validate: (v) => { if (!v) return "handle is required"; },
823 }));
824
825 return { url, mode, target, categories, blockFollowing, handle };
826}
827
828// ── data fetching ───────────────────────────────────────────────────
829async function fetchEngagementData(target, categories, mode, onProgress) {
830 const results = { reposts: [], likes: [], replies: [], quotes: [], followers: [], following: [], quotePosters: [] };
831 const fetchers = [];
832 let total = 0;
833 const tick = onProgress ? (delta) => { total += delta; onProgress(total); } : undefined;
834
835 if (mode === "profile") {
836 // target is a DID
837 if (categories.has("followers")) {
838 fetchers.push(fetchFollowers(target, tick).then((d) => { results.followers = [...d]; }));
839 }
840 if (categories.has("following")) {
841 fetchers.push(
842 resolvePds(target).then((pdsUrl) =>
843 fetchFollowing(target, pdsUrl, tick).then((d) => { results.following = [...d]; })
844 )
845 );
846 }
847 } else {
848 // post mode: target is an at:// URI
849 if (categories.has("reposts")) {
850 fetchers.push(fetchReposters(target, tick).then((d) => { results.reposts = d; }));
851 }
852 if (categories.has("likes")) {
853 fetchers.push(fetchLikers(target, tick).then((d) => { results.likes = d; }));
854 }
855 if (categories.has("replies")) {
856 fetchers.push(fetchRepliers(target, tick).then((d) => { results.replies = d; }));
857 }
858 if (categories.has("followers")) {
859 const authorDid = extractAuthorDid(target);
860 fetchers.push(fetchFollowers(authorDid, tick).then((d) => { results.followers = [...d]; }));
861 }
862 fetchers.push(fetchQuotePosters(target, tick).then((d) => {
863 results.quotePosters = d;
864 if (categories.has("quotes")) results.quotes = d;
865 }));
866 }
867
868 await Promise.all(fetchers);
869 return results;
870}
871
872// ── filtering ───────────────────────────────────────────────────────
873function filterCandidates({ results, did, targetDid, follows, followers, existingBlocks, blockFollowing, blockTarget = true, blockQuotes = false }) {
874 const allCandidates = new Set();
875 for (const src of [results.reposts, results.likes, results.replies, results.quotes, results.followers, results.following]) {
876 for (const d of src) allCandidates.add(d);
877 }
878
879 const quotePosters = blockQuotes ? new Set() : new Set(results.quotePosters);
880 const followedInBlockList = [];
881 const toBlock = [];
882 let skippedSelf = 0, skippedQuote = 0, skippedFollow = 0, skippedFollower = 0, skippedAlreadyBlocked = 0;
883
884 // Block the target themselves first, unless skipped or already blocked
885 if (blockTarget && targetDid && targetDid !== did && !existingBlocks.has(targetDid)) {
886 toBlock.push(targetDid);
887 }
888
889 for (const d of allCandidates) {
890 if (d === did) { skippedSelf++; continue; }
891 if (d === targetDid) continue; // already added above
892 if (existingBlocks.has(d)) { skippedAlreadyBlocked++; continue; }
893 if (quotePosters.has(d)) { skippedQuote++; continue; }
894 if (follows.has(d)) {
895 if (!blockFollowing) {
896 skippedFollow++;
897 continue;
898 } else {
899 followedInBlockList.push(d);
900 }
901 }
902 if (followers.has(d)) { skippedFollower++; continue; }
903 toBlock.push(d);
904 }
905
906 return { toBlock, followedInBlockList, skippedSelf, skippedQuote, skippedFollow, skippedFollower, skippedAlreadyBlocked, total: allCandidates.size };
907}
908
909// ── summary display ─────────────────────────────────────────────────
910async function showSummary(results, filterResult, categories, mode) {
911 const lines = [];
912
913 if (mode === "post") {
914 if (categories.has("reposts")) lines.push(` reposters: ${results.reposts.length}`);
915 if (categories.has("likes")) lines.push(` likers: ${results.likes.length}`);
916 if (categories.has("replies")) lines.push(` repliers: ${results.replies.length}`);
917 if (categories.has("followers")) lines.push(` author followers: ${results.followers.length}`);
918 if (categories.has("quotes")) lines.push(` quote posters: ${results.quotes.length}`);
919 else lines.push(` quote posters (excluded): ${results.quotePosters.length}`);
920 } else {
921 if (categories.has("followers")) lines.push(` followers: ${results.followers.length}`);
922 if (categories.has("following")) lines.push(` following: ${results.following.length}`);
923 }
924
925 lines.push("");
926 lines.push(` unique candidates: ${filterResult.total}`);
927 if (filterResult.skippedSelf) lines.push(` - ${filterResult.skippedSelf} (self)`);
928 if (filterResult.skippedQuote) lines.push(` - ${filterResult.skippedQuote} (quote posters)`);
929 if (filterResult.skippedFollow) lines.push(` - ${filterResult.skippedFollow} (people you follow)`);
930 if (filterResult.skippedFollower) lines.push(` - ${filterResult.skippedFollower} (your followers)`);
931 if (filterResult.skippedAlreadyBlocked) lines.push(` - ${filterResult.skippedAlreadyBlocked} (already blocked)`);
932 lines.push(` = ${color.bold(String(filterResult.toBlock.length))} to block`);
933
934 p.note(lines.join("\n"), "summary");
935
936 if (filterResult.followedInBlockList.length > 0) {
937 const s = p.spinner();
938 s.start("resolving profiles of followed users...");
939 const profiles = await resolveProfiles(filterResult.followedInBlockList);
940 s.stop("profiles resolved");
941
942 if (profiles.length === 0) {
943 p.log.warn(`${color.yellow("you follow")} ${filterResult.followedInBlockList.length} ${color.yellow("accounts that will be blocked")} ${color.dim("(profile resolution unavailable)")}`);
944 } else {
945 const warningLines = profiles.map((pr) =>
946 ` ${color.cyan(`@${pr.handle}`)} ${color.dim(`(${pr.displayName || "no display name"})`)}`
947 );
948 p.log.warn(
949 `${color.yellow("you follow these accounts and they will be blocked:")}\n${warningLines.join("\n")}`
950 );
951 }
952 }
953}
954
955// ── rate limit handling ──────────────────────────────────────────────
956function getBackoffDelay(res, defaultDelay) {
957 if (!res?.headers) return defaultDelay;
958
959 const remaining = parseInt(res.headers.get("ratelimit-remaining"), 10);
960 const reset = parseInt(res.headers.get("ratelimit-reset"), 10);
961
962 if (isNaN(remaining) || isNaN(reset)) return defaultDelay;
963
964 if (remaining <= 0) {
965 const waitMs = Math.max(0, (reset * 1000) - Date.now()) + 1000;
966 return waitMs;
967 }
968
969 const limit = parseInt(res.headers.get("ratelimit-limit"), 10);
970 if (!isNaN(limit) && limit > 0) {
971 const ratio = remaining / limit;
972 if (ratio < 0.2) {
973 const scale = 1 + (5 * (1 - ratio / 0.2));
974 return Math.ceil(defaultDelay * scale);
975 }
976 }
977
978 return defaultDelay;
979}
980
981async function sleepForRateLimit(res, spinner) {
982 if (res?.status === 429) {
983 const wait = getBackoffDelay(res, 60_000);
984 spinner.message(`rate limited, waiting ${Math.ceil(wait / 1000)}s...`);
985 await sleep(wait);
986 return true;
987 }
988 return false;
989}
990
991// ── blocking ────────────────────────────────────────────────────────
992async function createSingleBlock(session, did, targetDid) {
993 return session.handle("/xrpc/com.atproto.repo.createRecord", {
994 method: "POST",
995 headers: { "content-type": "application/json" },
996 body: JSON.stringify({
997 repo: did,
998 collection: "app.bsky.graph.block",
999 record: makeBlockRecord(targetDid, new Date().toISOString()),
1000 }),
1001 });
1002}
1003
1004async function confirmAndBlock({ toBlock, handle, did, delayMs, batchSize }) {
1005 const proceed = exitIfCancelled(await p.confirm({
1006 message: `block ${toBlock.length} accounts?`,
1007 initialValue: false,
1008 }));
1009 if (!proceed) {
1010 p.cancel("cancelled.");
1011 process.exit(0);
1012 }
1013
1014 const authSpinner = p.spinner();
1015 authSpinner.start("authenticating via oauth...");
1016 const session = await authenticate({ handle, did, scope: getOAuthScope(false) });
1017 authSpinner.stop(`authenticated as ${session.did}`);
1018
1019 const confirmAuth = exitIfCancelled(await p.confirm({
1020 message: `proceed as ${session.did}?`,
1021 initialValue: true,
1022 }));
1023 if (!confirmAuth) {
1024 p.cancel("cancelled.");
1025 process.exit(0);
1026 }
1027
1028 const blockSpinner = p.spinner();
1029 blockSpinner.start(`blocking 0/${toBlock.length} (batch size ${batchSize})...`);
1030
1031 let blocked = 0, alreadyBlocked = 0, errors = 0;
1032 let errorCount = 0;
1033
1034 function logError(msg) {
1035 errorCount++;
1036 if (errorCount <= 5) p.log.error(msg);
1037 }
1038
1039 for (let i = 0; i < toBlock.length; i += batchSize) {
1040 const batch = toBlock.slice(i, i + batchSize);
1041 const now = new Date().toISOString();
1042
1043 const writes = batch.map((targetDid) => ({
1044 $type: "com.atproto.repo.applyWrites#create",
1045 collection: "app.bsky.graph.block",
1046 rkey: generateTid(),
1047 value: makeBlockRecord(targetDid, now),
1048 }));
1049
1050 let lastRes = null;
1051
1052 try {
1053 const res = await session.handle("/xrpc/com.atproto.repo.applyWrites", {
1054 method: "POST",
1055 headers: { "content-type": "application/json" },
1056 body: JSON.stringify({ repo: did, writes }),
1057 });
1058 lastRes = res;
1059
1060 if (await sleepForRateLimit(res, blockSpinner)) {
1061 i -= batchSize;
1062 continue;
1063 }
1064
1065 if (!res.ok) {
1066 const msg = await parseApiError(res);
1067 if (isDuplicateError(msg)) {
1068 // batch rejected for duplicates — fall back to individual creates
1069 const fallbackResults = await Promise.allSettled(
1070 batch.map((targetDid) => createSingleBlock(session, did, targetDid))
1071 );
1072 for (let j = 0; j < fallbackResults.length; j++) {
1073 const result = fallbackResults[j];
1074 if (result.status === "rejected") {
1075 errors++;
1076 logError(`error blocking ${batch[j]}: ${result.reason?.message ?? result.reason}`);
1077 continue;
1078 }
1079 const r = result.value;
1080 lastRes = r;
1081 if (r.status === 429) {
1082 // 429 on individual — retry this one
1083 await sleepForRateLimit(r, blockSpinner);
1084 try {
1085 const retry = await createSingleBlock(session, did, batch[j]);
1086 if (retry.ok) blocked++;
1087 else {
1088 const m = await parseApiError(retry);
1089 if (isDuplicateError(m)) alreadyBlocked++;
1090 else { errors++; logError(`error blocking ${batch[j]}: ${m}`); }
1091 }
1092 } catch (err) {
1093 errors++;
1094 logError(`error blocking ${batch[j]}: ${err?.message ?? err}`);
1095 }
1096 } else if (!r.ok) {
1097 const m = await parseApiError(r);
1098 if (isDuplicateError(m)) alreadyBlocked++;
1099 else { errors++; logError(`error blocking ${batch[j]}: ${m}`); }
1100 } else {
1101 blocked++;
1102 }
1103 }
1104 } else {
1105 errors += batch.length;
1106 logError(`batch error: ${msg}`);
1107 }
1108 } else {
1109 blocked += batch.length;
1110 }
1111 } catch (err) {
1112 errors += batch.length;
1113 logError(`batch error: ${err?.message ?? err}`);
1114 }
1115
1116 const total = blocked + alreadyBlocked + errors;
1117 blockSpinner.message(`blocking ${total}/${toBlock.length}...`);
1118 if (i + batchSize < toBlock.length) await sleep(getBackoffDelay(lastRes, delayMs));
1119 }
1120
1121 blockSpinner.stop("blocking complete");
1122
1123 p.note(
1124 ` blocked: ${blocked}\n already blocked: ${alreadyBlocked}\n errors: ${errors}`,
1125 "results"
1126 );
1127 p.outro("done!");
1128}
1129
1130async function confirmAndUnblock({ toUnblock, blockMap, handle, did, delayMs, batchSize }) {
1131 const proceed = exitIfCancelled(await p.confirm({
1132 message: `unblock ${toUnblock.length} accounts?`,
1133 initialValue: false,
1134 }));
1135 if (!proceed) {
1136 p.cancel("cancelled.");
1137 process.exit(0);
1138 }
1139
1140 const authSpinner = p.spinner();
1141 authSpinner.start("authenticating via oauth...");
1142 const session = await authenticate({ handle, did, scope: getOAuthScope(true) });
1143 authSpinner.stop(`authenticated as ${session.did}`);
1144
1145 const confirmAuth = exitIfCancelled(await p.confirm({
1146 message: `proceed as ${session.did}?`,
1147 initialValue: true,
1148 }));
1149 if (!confirmAuth) {
1150 p.cancel("cancelled.");
1151 process.exit(0);
1152 }
1153
1154 const unblockSpinner = p.spinner();
1155 unblockSpinner.start(`unblocking 0/${toUnblock.length}...`);
1156
1157 let unblocked = 0, notBlocked = 0, errors = 0;
1158 let errorCount = 0;
1159 function logError(msg) { errorCount++; if (errorCount <= 5) p.log.error(msg); }
1160
1161 for (let i = 0; i < toUnblock.length; i += batchSize) {
1162 const batch = toUnblock.slice(i, i + batchSize);
1163
1164 const writes = batch.flatMap((targetDid) => {
1165 const rkey = blockMap.get(targetDid);
1166 if (!rkey) { notBlocked++; return []; }
1167 return [{ $type: "com.atproto.repo.applyWrites#delete", collection: "app.bsky.graph.block", rkey }];
1168 });
1169
1170 if (writes.length === 0) continue;
1171
1172 let lastRes = null;
1173 try {
1174 const res = await session.handle("/xrpc/com.atproto.repo.applyWrites", {
1175 method: "POST",
1176 headers: { "content-type": "application/json" },
1177 body: JSON.stringify({ repo: did, writes }),
1178 });
1179 lastRes = res;
1180
1181 if (await sleepForRateLimit(res, unblockSpinner)) { i -= batchSize; continue; }
1182
1183 if (!res.ok) {
1184 const msg = await parseApiError(res);
1185 errors += writes.length;
1186 logError(`batch error: ${msg}`);
1187 } else {
1188 unblocked += writes.length;
1189 }
1190 } catch (err) {
1191 errors += writes.length;
1192 logError(`batch error: ${err?.message ?? err}`);
1193 }
1194
1195 unblockSpinner.message(`unblocking ${unblocked + notBlocked + errors}/${toUnblock.length}...`);
1196 if (i + batchSize < toUnblock.length) await sleep(getBackoffDelay(lastRes, delayMs));
1197 }
1198
1199 unblockSpinner.stop("unblocking complete");
1200 p.note(` unblocked: ${unblocked}\n not blocked: ${notBlocked}\n errors: ${errors}`, "results");
1201 p.outro("done!");
1202}
1203
1204// ── main ────────────────────────────────────────────────────────────
1205async function main() {
1206 const { flags } = parseArgs();
1207 if (flags.help) { printUsage(); process.exit(0); }
1208 const delayMs = flags.delay ?? BLOCK_DELAY_MS;
1209 const batchSize = flags.batch ?? BATCH_SIZE;
1210
1211 const config = await runInteractiveFlow();
1212 USER_AGENT = `mbr/1.0 (@${config.handle}; ${AUTHOR_CONTACT})`;
1213
1214 const { target } = config;
1215
1216 const s2 = p.spinner();
1217 s2.start("fetching data & resolving identity...");
1218 const [results, did] = await Promise.all([
1219 fetchEngagementData(target, config.categories, config.mode, (n) => {
1220 s2.message(`fetching data & resolving identity... (${n.toLocaleString()} records)`);
1221 }),
1222 resolveHandle(config.handle),
1223 ]);
1224 s2.stop("data fetched");
1225
1226 let filterResult, blockMap;
1227
1228 if (flags.unblock) {
1229 const pdsUrl = await resolvePds(did);
1230 const s3 = p.spinner();
1231 s3.start("fetching your existing blocks...");
1232 let blockCount = 0;
1233 blockMap = await fetchBlockMap(did, pdsUrl, (n) => {
1234 blockCount += n;
1235 s3.message(`fetching your existing blocks... (${blockCount.toLocaleString()} records)`);
1236 });
1237 s3.stop(`existing blocks: ${blockMap.size}`);
1238
1239 const targetDid = config.mode === "post" ? extractAuthorDid(target) : target;
1240 const allEngaged = new Set([
1241 ...results.reposts, ...results.likes, ...results.replies,
1242 ...results.quotes, ...results.followers, ...results.following,
1243 ]);
1244 if (!flags.noBlockTarget && targetDid !== did) allEngaged.add(targetDid);
1245 const toUnblock = [...allEngaged].filter((d) => blockMap.has(d));
1246 filterResult = { toBlock: toUnblock, followedInBlockList: [], skippedSelf: 0, skippedQuote: 0, skippedFollow: 0, skippedFollower: 0, skippedAlreadyBlocked: 0, total: allEngaged.size };
1247 } else {
1248 const s3 = p.spinner();
1249 s3.start("fetching your social graph & existing blocks...");
1250 const { follows, followers, existingBlocks } = await fetchSocialGraph(did, (n) => {
1251 s3.message(`fetching your social graph & existing blocks... (${n.toLocaleString()} records)`);
1252 });
1253 s3.stop(`following: ${follows.size}, followers: ${followers.size}, existing blocks: ${existingBlocks.size}`);
1254
1255 const targetDid = config.mode === "post" ? extractAuthorDid(target) : target;
1256 filterResult = filterCandidates({
1257 results, did, targetDid, follows, followers, existingBlocks,
1258 blockFollowing: config.blockFollowing,
1259 blockTarget: !flags.noBlockTarget,
1260 blockQuotes: config.categories.has("quotes"),
1261 });
1262 }
1263
1264 if (flags.unblock) {
1265 p.note(` to unblock: ${color.bold(String(filterResult.toBlock.length))}`, "summary");
1266 } else {
1267 await showSummary(results, filterResult, config.categories, config.mode);
1268 }
1269
1270 if (flags.output) {
1271 await writeOutput(flags.output, filterResult.toBlock);
1272 p.log.info(`wrote ${filterResult.toBlock.length} DIDs to ${flags.output}`);
1273 }
1274
1275 if (flags.dryRun) {
1276 p.outro(flags.unblock ? "dry run — nothing unblocked." : "dry run — nothing blocked.");
1277 process.exit(0);
1278 }
1279
1280 if (filterResult.toBlock.length === 0) {
1281 p.outro(flags.unblock ? "nothing to unblock." : "nothing to block after filtering.");
1282 process.exit(0);
1283 }
1284
1285 if (flags.unblock) {
1286 await confirmAndUnblock({ toUnblock: filterResult.toBlock, blockMap, handle: config.handle, did, delayMs, batchSize });
1287 } else {
1288 await confirmAndBlock({ toBlock: filterResult.toBlock, handle: config.handle, did, delayMs, batchSize });
1289 }
1290 process.exit(0);
1291}
1292
1293// Only run when executed directly (not imported by tests)
1294const __isMain = IS_BUN
1295 ? import.meta.main
1296 : fileURLToPath(import.meta.url) === process.argv[1];
1297
1298if (__isMain) {
1299 main().catch((err) => {
1300 p.cancel(`fatal: ${err.message}`);
1301 process.exit(1);
1302 });
1303}
1304
1305export { isProfileUrl, resolveMiniDoc, filterCandidates, writeOutput };