birdclaw 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +113 -7
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +30 -28
  5. package/playwright.config.ts +1 -0
  6. package/public/birdclaw-mark.png +0 -0
  7. package/public/favicon.ico +0 -0
  8. package/public/logo192.png +0 -0
  9. package/public/logo512.png +0 -0
  10. package/public/manifest.json +2 -2
  11. package/scripts/browser-perf.mjs +399 -0
  12. package/scripts/build-docs-site.mjs +940 -0
  13. package/scripts/docs-site-assets.mjs +311 -0
  14. package/scripts/run-vitest.mjs +21 -0
  15. package/scripts/sanitize-node-options.mjs +23 -0
  16. package/scripts/start-test-server.mjs +29 -0
  17. package/src/cli.ts +496 -19
  18. package/src/components/AppNav.tsx +66 -29
  19. package/src/components/AvatarChip.tsx +10 -5
  20. package/src/components/BrandMark.tsx +67 -0
  21. package/src/components/ConversationThread.tsx +126 -0
  22. package/src/components/DmWorkspace.tsx +118 -105
  23. package/src/components/EmbeddedTweetCard.tsx +20 -14
  24. package/src/components/FeedState.tsx +147 -0
  25. package/src/components/InboxCard.tsx +104 -90
  26. package/src/components/LinkPreviewCard.tsx +270 -0
  27. package/src/components/ProfilePreview.tsx +8 -3
  28. package/src/components/SavedTimelineView.tsx +89 -71
  29. package/src/components/SyncNowButton.tsx +105 -0
  30. package/src/components/ThemeSlider.tsx +10 -59
  31. package/src/components/TimelineCard.tsx +326 -86
  32. package/src/components/TimelineRouteFrame.tsx +156 -0
  33. package/src/components/TweetMediaGrid.tsx +120 -23
  34. package/src/components/TweetRichText.tsx +19 -4
  35. package/src/components/useTimelineRouteData.ts +137 -0
  36. package/src/lib/api-client.ts +229 -0
  37. package/src/lib/archive-finder.ts +24 -20
  38. package/src/lib/archive-import.ts +1582 -67
  39. package/src/lib/authored-live.ts +1074 -0
  40. package/src/lib/backup.ts +316 -14
  41. package/src/lib/bird-actions.ts +1 -10
  42. package/src/lib/bird-command.ts +57 -0
  43. package/src/lib/bird.ts +89 -5
  44. package/src/lib/config.ts +1 -1
  45. package/src/lib/conversation-surface.ts +174 -0
  46. package/src/lib/db.ts +193 -4
  47. package/src/lib/follow-graph.ts +1053 -0
  48. package/src/lib/link-index.ts +11 -98
  49. package/src/lib/link-insights.ts +834 -0
  50. package/src/lib/link-preview-metadata.ts +334 -0
  51. package/src/lib/media-fetch.ts +787 -0
  52. package/src/lib/media-includes.ts +165 -0
  53. package/src/lib/mention-threads-live.ts +535 -43
  54. package/src/lib/mentions-live.ts +623 -19
  55. package/src/lib/moderation-target.ts +6 -0
  56. package/src/lib/profile-hydration.ts +1 -1
  57. package/src/lib/profile-resolver.ts +115 -1
  58. package/src/lib/queries.ts +326 -35
  59. package/src/lib/seed.ts +127 -8
  60. package/src/lib/timeline-collections-live.ts +145 -22
  61. package/src/lib/timeline-live.ts +10 -15
  62. package/src/lib/tweet-account-edges.ts +9 -2
  63. package/src/lib/tweet-render.ts +97 -0
  64. package/src/lib/types.ts +185 -2
  65. package/src/lib/ui.ts +383 -147
  66. package/src/lib/url-expansion-store.ts +110 -0
  67. package/src/lib/url-expansion.ts +20 -3
  68. package/src/lib/web-sync.ts +443 -0
  69. package/src/lib/x-profile.ts +7 -2
  70. package/src/lib/xurl.ts +296 -12
  71. package/src/routeTree.gen.ts +126 -0
  72. package/src/routes/__root.tsx +7 -3
  73. package/src/routes/api/conversation.tsx +34 -0
  74. package/src/routes/api/link-insights.tsx +79 -0
  75. package/src/routes/api/link-preview.tsx +43 -0
  76. package/src/routes/api/profile-hydrate.tsx +51 -0
  77. package/src/routes/api/sync.tsx +59 -0
  78. package/src/routes/blocks.tsx +111 -86
  79. package/src/routes/dms.tsx +172 -87
  80. package/src/routes/inbox.tsx +98 -86
  81. package/src/routes/index.tsx +22 -115
  82. package/src/routes/links.tsx +928 -0
  83. package/src/routes/mentions.tsx +22 -115
  84. package/src/styles.css +169 -43
  85. package/vite.config.ts +8 -0
package/src/cli.ts CHANGED
@@ -7,7 +7,16 @@ import { fileURLToPath, pathToFileURL } from "node:url";
7
7
  import { Command } from "commander";
8
8
  import { registerModerationCommands } from "#/cli-moderation";
9
9
  import { findArchives } from "#/lib/archive-finder";
10
- import { importArchive } from "#/lib/archive-import";
10
+ import {
11
+ ARCHIVE_IMPORT_SLICES,
12
+ type ArchiveImportSlice,
13
+ importArchive,
14
+ } from "#/lib/archive-import";
15
+ import {
16
+ AuthoredSyncError,
17
+ syncAuthoredTweets,
18
+ type AuthoredSyncMode,
19
+ } from "#/lib/authored-live";
11
20
  import {
12
21
  exportBackup,
13
22
  importBackup,
@@ -30,12 +39,23 @@ import {
30
39
  import { syncDirectMessagesViaCachedBird } from "#/lib/dms-live";
31
40
  import { listInboxItems, scoreInbox } from "#/lib/inbox";
32
41
  import { backfillLinkIndex, searchLinks } from "#/lib/link-index";
42
+ import { fetchTweetMedia, formatMediaFetchResult } from "#/lib/media-fetch";
33
43
  import { syncMentionThreads } from "#/lib/mention-threads-live";
34
44
  import { exportMentionItems } from "#/lib/mentions-export";
35
45
  import {
36
46
  exportMentionsViaCachedBird,
37
47
  exportMentionsViaCachedXurl,
48
+ syncMentions,
38
49
  } from "#/lib/mentions-live";
50
+ import {
51
+ getFollowGraphSummary,
52
+ listFollowEvents,
53
+ listMutuals,
54
+ listNonMutualFollowing,
55
+ listTopFollowers,
56
+ listUnfollowedSince,
57
+ syncFollowGraph,
58
+ } from "#/lib/follow-graph";
39
59
  import { hydrateProfilesFromX } from "#/lib/profile-hydration";
40
60
  import { resolveProfilesForIds } from "#/lib/profile-resolver";
41
61
  import { inspectProfileReplies } from "#/lib/profile-replies";
@@ -74,6 +94,10 @@ function printError(error: string) {
74
94
  console.error(JSON.stringify({ error }));
75
95
  }
76
96
 
97
+ function errorMessage(error: unknown) {
98
+ return error instanceof Error ? error.message : String(error);
99
+ }
100
+
77
101
  function formatLinkSearchItems(items: ReturnType<typeof searchLinks>) {
78
102
  return items
79
103
  .map((item) => {
@@ -117,6 +141,68 @@ function parseNonNegativeIntegerOption(
117
141
  return parsed;
118
142
  }
119
143
 
144
+ function parsePositiveIntegerOption(value: string | undefined, option: string) {
145
+ const parsed = parseNonNegativeIntegerOption(value, option);
146
+ if (parsed === undefined) {
147
+ return undefined;
148
+ }
149
+ if (parsed < 1) {
150
+ printError(`${option} must be at least 1`);
151
+ process.exitCode = 1;
152
+ return undefined;
153
+ }
154
+ return parsed;
155
+ }
156
+
157
+ function parseArchiveImportSelect(value: string | undefined) {
158
+ if (value === undefined) {
159
+ return undefined;
160
+ }
161
+
162
+ const aliases: Record<string, ArchiveImportSlice> = Object.assign(
163
+ Object.create(null) as Record<string, ArchiveImportSlice>,
164
+ {
165
+ tweets: "tweets",
166
+ likes: "likes",
167
+ bookmarks: "bookmarks",
168
+ directmessages: "directMessages",
169
+ "direct-messages": "directMessages",
170
+ dms: "directMessages",
171
+ profiles: "profiles",
172
+ followers: "followers",
173
+ following: "following",
174
+ },
175
+ );
176
+ const selected: ArchiveImportSlice[] = [];
177
+ const seen = new Set<ArchiveImportSlice>();
178
+ for (const rawItem of value.split(",")) {
179
+ const item = rawItem.trim();
180
+ if (!item) continue;
181
+ const slice = aliases[item] ?? aliases[item.toLowerCase()];
182
+ if (!slice) {
183
+ printError(
184
+ `--select must be a comma-separated subset of ${ARCHIVE_IMPORT_SLICES.join(", ")}`,
185
+ );
186
+ process.exitCode = 1;
187
+ return undefined;
188
+ }
189
+ if (!seen.has(slice)) {
190
+ seen.add(slice);
191
+ selected.push(slice);
192
+ }
193
+ }
194
+
195
+ if (selected.length === 0) {
196
+ printError(
197
+ `--select must include at least one of ${ARCHIVE_IMPORT_SLICES.join(", ")}`,
198
+ );
199
+ process.exitCode = 1;
200
+ return undefined;
201
+ }
202
+
203
+ return selected;
204
+ }
205
+
120
206
  function resolveActionOptions(options: { transport?: string }) {
121
207
  return {
122
208
  transport: options.transport as ActionsTransport | undefined,
@@ -235,7 +321,15 @@ const importCommand = program
235
321
  importCommand
236
322
  .command("archive [archivePath]")
237
323
  .description("Import a Twitter archive into the local SQLite store")
238
- .action(async (archivePath) => {
324
+ .option(
325
+ "--select <kinds>",
326
+ `Import only selected archive slices: ${ARCHIVE_IMPORT_SLICES.join(", ")}`,
327
+ )
328
+ .action(async (archivePath, options: { select?: string }) => {
329
+ const select = parseArchiveImportSelect(options.select);
330
+ if (options.select !== undefined && !select) {
331
+ return;
332
+ }
239
333
  let resolvedArchivePath = archivePath;
240
334
  if (!resolvedArchivePath) {
241
335
  const [latestArchive] = await findArchives();
@@ -248,7 +342,7 @@ importCommand
248
342
  );
249
343
  }
250
344
 
251
- const result = await importArchive(resolvedArchivePath);
345
+ const result = await importArchive(resolvedArchivePath, { select });
252
346
  await autoSyncAfterWrite();
253
347
  print(result, program.opts().json ?? false);
254
348
  });
@@ -268,7 +362,7 @@ const searchCommand = program
268
362
 
269
363
  searchCommand
270
364
  .command("tweets [query]")
271
- .option("--resource <resource>", "home or mentions", "home")
365
+ .option("--resource <resource>", "home, mentions, or authored", "home")
272
366
  .option("--replied", "Only replied items")
273
367
  .option("--unreplied", "Only unreplied items")
274
368
  .option("--since <date>", "Include tweets created at or after this date")
@@ -299,7 +393,12 @@ searchCommand
299
393
  ? "unreplied"
300
394
  : "all";
301
395
  const items = listTimelineItems({
302
- resource: options.resource === "mentions" ? "mentions" : "home",
396
+ resource:
397
+ options.resource === "mentions"
398
+ ? "mentions"
399
+ : options.resource === "authored"
400
+ ? "authored"
401
+ : "home",
303
402
  search: query,
304
403
  replyFilter,
305
404
  since: options.since,
@@ -477,6 +576,73 @@ linksCommand
477
576
  print(result, program.opts().json ?? false);
478
577
  });
479
578
 
579
+ const mediaCommand = program
580
+ .command("media")
581
+ .description("Manage the local media cache");
582
+
583
+ mediaCommand
584
+ .command("fetch")
585
+ .description(
586
+ "Fetch missing pbs.twimg.com image media already stored in tweets",
587
+ )
588
+ .option("--account <accountId>", "Account id")
589
+ .option("--limit <n>", "Stop after N tweets processed")
590
+ .option(
591
+ "--kind <kind>",
592
+ "Tweet or collection kind, e.g. home, like, bookmark",
593
+ )
594
+ .option("--since <isoDate>", "Only tweets created at or after this date")
595
+ .option("--parallel <n>", "Concurrent fetch workers, capped at 5", "1")
596
+ .option("--pacing-ms <n>", "Delay between request starts", "250")
597
+ .option("--video-pacing-ms <n>", "Delay between video request starts")
598
+ .option("--retry-max <n>", "Retries per file after rate limiting", "3")
599
+ .option("--include-video", "Include video and animated GIF media", true)
600
+ .option("--no-include-video", "Skip video and animated GIF media")
601
+ .option("--max-bytes <n>", "Maximum media file size in bytes", "104857600")
602
+ .option("--dry-run", "List what would be fetched without downloading")
603
+ .option("--json", "Emit JSON output")
604
+ .action(async (options) => {
605
+ const limit = parseNonNegativeIntegerOption(options.limit, "--limit");
606
+ if (options.limit !== undefined && limit === undefined) {
607
+ return;
608
+ }
609
+ const parallel =
610
+ parsePositiveIntegerOption(options.parallel, "--parallel") ?? 1;
611
+ const pacingMs =
612
+ parseNonNegativeIntegerOption(options.pacingMs, "--pacing-ms") ?? 250;
613
+ const retryMax =
614
+ parseNonNegativeIntegerOption(options.retryMax, "--retry-max") ?? 3;
615
+ const videoPacingMs =
616
+ options.videoPacingMs === undefined
617
+ ? undefined
618
+ : parseNonNegativeIntegerOption(
619
+ options.videoPacingMs,
620
+ "--video-pacing-ms",
621
+ );
622
+ const maxBytes =
623
+ parseNonNegativeIntegerOption(options.maxBytes, "--max-bytes") ??
624
+ 100 * 1024 * 1024;
625
+ if (process.exitCode) {
626
+ return;
627
+ }
628
+
629
+ const result = await fetchTweetMedia({
630
+ account: options.account,
631
+ limit,
632
+ kind: options.kind,
633
+ since: options.since,
634
+ parallel,
635
+ pacingMs,
636
+ videoPacingMs,
637
+ retryMax,
638
+ includeVideo: Boolean(options.includeVideo),
639
+ maxBytes,
640
+ dryRun: Boolean(options.dryRun),
641
+ });
642
+ const asJson = Boolean(program.opts().json || options.json);
643
+ print(asJson ? result : formatMediaFetchResult(result), asJson);
644
+ });
645
+
480
646
  program
481
647
  .command("whois <query>")
482
648
  .description("Identify likely people or orgs from local DMs and tweets")
@@ -663,28 +829,130 @@ syncCommand
663
829
  print(result, true);
664
830
  });
665
831
 
832
+ syncCommand
833
+ .command("mentions")
834
+ .description("Refresh live mentions through xurl or bird")
835
+ .option("--account <accountId>", "Account id")
836
+ .option("--mode <mode>", "bird or xurl", "xurl")
837
+ .option("--limit <n>", "Result limit per page", "20")
838
+ .option("--max-pages <n>", "Stop after N pages")
839
+ .option("--since-id <id>", "Fetch mentions newer than this tweet id")
840
+ .option("--start-time <iso>", "Fetch mentions created at or after this time")
841
+ .option("--refresh", "Bypass live-cache freshness window")
842
+ .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
843
+ .action(async (options) => {
844
+ try {
845
+ const result = await syncMentions({
846
+ account: options.account,
847
+ mode: options.mode,
848
+ limit: Number(options.limit),
849
+ maxPages: options.maxPages ? Number(options.maxPages) : undefined,
850
+ sinceId: options.sinceId,
851
+ startTime: options.startTime,
852
+ refresh: Boolean(options.refresh),
853
+ cacheTtlMs: Number(options.cacheTtl) * 1000,
854
+ });
855
+ await autoSyncAfterWrite();
856
+ print(result, true);
857
+ if (result.partial) {
858
+ process.exitCode = 5;
859
+ }
860
+ } catch (error) {
861
+ print(
862
+ {
863
+ ok: false,
864
+ kind: "mentions",
865
+ mode: options.mode ?? "xurl",
866
+ error: errorMessage(error),
867
+ },
868
+ true,
869
+ );
870
+ process.exitCode = 1;
871
+ }
872
+ });
873
+
874
+ syncCommand
875
+ .command("authored")
876
+ .description("Refresh authenticated authored tweets through xurl")
877
+ .option("--account <accountId>", "Account id")
878
+ .option("--mode <mode>", "xurl", "xurl")
879
+ .option("--limit <n>", "X API page size", "100")
880
+ .option("--max-pages <n>", "Stop after N pages and resume later")
881
+ .option("--since-id <tweetId>", "Override the stored since_id cursor")
882
+ .option(
883
+ "--until-id <tweetId>",
884
+ "Fetch tweets older than this id without moving the cursor",
885
+ )
886
+ .action(async (options) => {
887
+ try {
888
+ const result = await syncAuthoredTweets({
889
+ account: options.account,
890
+ mode: options.mode as AuthoredSyncMode,
891
+ limit: Number(options.limit),
892
+ maxPages: options.maxPages ? Number(options.maxPages) : undefined,
893
+ sinceId: options.sinceId,
894
+ untilId: options.untilId,
895
+ });
896
+ await autoSyncAfterWrite();
897
+ print(result, true);
898
+ if (result.partial) {
899
+ process.exitCode = 5;
900
+ }
901
+ } catch (error) {
902
+ print(
903
+ {
904
+ ok: false,
905
+ kind: "authored",
906
+ source: "xurl",
907
+ error: errorMessage(error),
908
+ },
909
+ true,
910
+ );
911
+ process.exitCode =
912
+ error instanceof AuthoredSyncError ? error.exitCode : 1;
913
+ }
914
+ });
915
+
666
916
  syncCommand
667
917
  .command("mention-threads")
668
918
  .description(
669
- "Fetch tweet conversation context for recent mentions through bird",
919
+ "Fetch tweet conversation context for recent mentions through bird or xurl",
670
920
  )
671
921
  .option("--account <accountId>", "Account id")
922
+ .option("--mode <mode>", "bird or xurl", "bird")
672
923
  .option("--limit <n>", "Recent mentions to inspect", "30")
673
924
  .option("--delay-ms <n>", "Delay between thread fetches", "1500")
674
925
  .option("--timeout-ms <n>", "Per-thread timeout", "15000")
675
926
  .option("--all", "Fetch all retrievable thread pages")
676
927
  .option("--max-pages <n>", "Stop after N pages")
677
928
  .action(async (options) => {
678
- const result = await syncMentionThreads({
679
- account: options.account,
680
- limit: Number(options.limit),
681
- delayMs: Number(options.delayMs),
682
- timeoutMs: Number(options.timeoutMs),
683
- all: Boolean(options.all),
684
- maxPages: options.maxPages ? Number(options.maxPages) : undefined,
685
- });
686
- await autoSyncAfterWrite();
687
- print(result, true);
929
+ try {
930
+ const result = await syncMentionThreads({
931
+ account: options.account,
932
+ mode: options.mode,
933
+ limit: Number(options.limit),
934
+ delayMs: Number(options.delayMs),
935
+ timeoutMs: Number(options.timeoutMs),
936
+ all: Boolean(options.all),
937
+ maxPages: options.maxPages ? Number(options.maxPages) : undefined,
938
+ });
939
+ await autoSyncAfterWrite();
940
+ print(result, true);
941
+ if (result.partial) {
942
+ process.exitCode = 5;
943
+ }
944
+ } catch (error) {
945
+ print(
946
+ {
947
+ ok: false,
948
+ kind: "mention-threads",
949
+ mode: options.mode ?? "bird",
950
+ error: errorMessage(error),
951
+ },
952
+ true,
953
+ );
954
+ process.exitCode = 1;
955
+ }
688
956
  });
689
957
 
690
958
  for (const kind of ["likes", "bookmarks"] as const) {
@@ -695,7 +963,11 @@ for (const kind of ["likes", "bookmarks"] as const) {
695
963
  .option("--mode <mode>", "auto, xurl, or bird", "auto")
696
964
  .option("--limit <n>", "Per-page/result limit", "20")
697
965
  .option("--all", "Fetch every retrievable page")
698
- .option("--max-pages <n>", "Stop after N pages when using --all")
966
+ .option(
967
+ "--max-pages <n>",
968
+ "Stop after N pages when using --all or --early-stop",
969
+ )
970
+ .option("--early-stop", "Stop when a fetched page is already fully local")
699
971
  .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
700
972
  .option("--refresh", "Bypass live-cache freshness window")
701
973
  .action(async (options) => {
@@ -708,12 +980,55 @@ for (const kind of ["likes", "bookmarks"] as const) {
708
980
  maxPages: options.maxPages ? Number(options.maxPages) : undefined,
709
981
  refresh: Boolean(options.refresh),
710
982
  cacheTtlMs: Number(options.cacheTtl) * 1000,
983
+ earlyStop: Boolean(options.earlyStop),
711
984
  });
712
985
  await autoSyncAfterWrite();
713
986
  print(result, true);
714
987
  });
715
988
  }
716
989
 
990
+ for (const direction of ["followers", "following"] as const) {
991
+ syncCommand
992
+ .command(direction)
993
+ .description(
994
+ `Dry-run or refresh live ${direction} into the local follow graph`,
995
+ )
996
+ .option("--account <accountId>", "Account id")
997
+ .option("--mode <mode>", "auto, bird, or xurl", "auto")
998
+ .option("--limit <n>", "X API users per page", "1000")
999
+ .option("--max-pages <n>", "Stop after N pages")
1000
+ .option("--max-resources <n>", "Stop after N unique users")
1001
+ .option("--cache-ttl <seconds>", "Live-cache freshness window", "86400")
1002
+ .option("--refresh", "Bypass the live-cache freshness window")
1003
+ .option("--allow-partial", "Acknowledge capped/incomplete snapshot")
1004
+ .option("--yes", "Confirm live sync or fresh-cache merge")
1005
+ .action(async (options) => {
1006
+ try {
1007
+ const result = await syncFollowGraph({
1008
+ direction,
1009
+ account: options.account,
1010
+ mode: options.mode,
1011
+ limit: Number(options.limit),
1012
+ maxPages: options.maxPages ? Number(options.maxPages) : undefined,
1013
+ maxResources: options.maxResources
1014
+ ? Number(options.maxResources)
1015
+ : undefined,
1016
+ cacheTtlMs: Number(options.cacheTtl) * 1000,
1017
+ refresh: Boolean(options.refresh),
1018
+ allowPartial: Boolean(options.allowPartial),
1019
+ yes: Boolean(options.yes),
1020
+ });
1021
+ if (!result.dryRun) {
1022
+ await autoSyncAfterWrite();
1023
+ }
1024
+ print(result, true);
1025
+ } catch (error) {
1026
+ print({ ok: false, direction, error: errorMessage(error) }, true);
1027
+ process.exitCode = 1;
1028
+ }
1029
+ });
1030
+ }
1031
+
717
1032
  const jobsCommand = program
718
1033
  .command("jobs")
719
1034
  .description("Run and install background Birdclaw jobs");
@@ -946,6 +1261,120 @@ program
946
1261
  );
947
1262
  });
948
1263
 
1264
+ const graphCommand = program
1265
+ .command("graph")
1266
+ .description("Query the local cache-only follow graph");
1267
+
1268
+ graphCommand
1269
+ .command("summary")
1270
+ .description("Summarize cached followers, following, mutuals, and snapshots")
1271
+ .option("--account <accountId>", "Account id")
1272
+ .action(async (options) => {
1273
+ await autoUpdateBeforeRead();
1274
+ print(getFollowGraphSummary({ account: options.account }), true);
1275
+ });
1276
+
1277
+ graphCommand
1278
+ .command("top-followers")
1279
+ .description("List current followers sorted by their follower count")
1280
+ .option("--account <accountId>", "Account id")
1281
+ .option("--limit <n>", "Limit results", "20")
1282
+ .action(async (options) => {
1283
+ await autoUpdateBeforeRead();
1284
+ print(
1285
+ listTopFollowers({
1286
+ account: options.account,
1287
+ limit: Number(options.limit),
1288
+ }),
1289
+ true,
1290
+ );
1291
+ });
1292
+
1293
+ graphCommand
1294
+ .command("unfollowed")
1295
+ .description("List cached ended follow edges since a date")
1296
+ .requiredOption("--date <date>", "YYYY-MM-DD or ISO timestamp")
1297
+ .option("--account <accountId>", "Account id")
1298
+ .option("--direction <direction>", "followers or following", "followers")
1299
+ .option("--limit <n>", "Limit results", "100")
1300
+ .action(async (options) => {
1301
+ await autoUpdateBeforeRead();
1302
+ print(
1303
+ listUnfollowedSince({
1304
+ account: options.account,
1305
+ date: options.date,
1306
+ direction:
1307
+ options.direction === "following" ? "following" : "followers",
1308
+ limit: Number(options.limit),
1309
+ }),
1310
+ true,
1311
+ );
1312
+ });
1313
+
1314
+ graphCommand
1315
+ .command("events")
1316
+ .description("List cached append-only follow graph events")
1317
+ .option("--account <accountId>", "Account id")
1318
+ .option("--direction <direction>", "followers or following")
1319
+ .option("--kind <kind>", "started or ended")
1320
+ .option("--since <date>", "YYYY-MM-DD or ISO timestamp")
1321
+ .option("--until <date>", "YYYY-MM-DD or ISO timestamp")
1322
+ .option("--limit <n>", "Limit results", "100")
1323
+ .action(async (options) => {
1324
+ await autoUpdateBeforeRead();
1325
+ print(
1326
+ listFollowEvents({
1327
+ account: options.account,
1328
+ direction:
1329
+ options.direction === "followers" || options.direction === "following"
1330
+ ? options.direction
1331
+ : undefined,
1332
+ kind:
1333
+ options.kind === "started" || options.kind === "ended"
1334
+ ? options.kind
1335
+ : undefined,
1336
+ since: options.since,
1337
+ until: options.until,
1338
+ limit: Number(options.limit),
1339
+ }),
1340
+ true,
1341
+ );
1342
+ });
1343
+
1344
+ graphCommand
1345
+ .command("non-mutual-following")
1346
+ .description("List current following who are not current followers")
1347
+ .option("--account <accountId>", "Account id")
1348
+ .option("--sort <mode>", "followers or handle", "followers")
1349
+ .option("--limit <n>", "Limit results", "100")
1350
+ .action(async (options) => {
1351
+ await autoUpdateBeforeRead();
1352
+ print(
1353
+ listNonMutualFollowing({
1354
+ account: options.account,
1355
+ sort: options.sort === "handle" ? "handle" : "followers",
1356
+ limit: Number(options.limit),
1357
+ }),
1358
+ true,
1359
+ );
1360
+ });
1361
+
1362
+ graphCommand
1363
+ .command("mutuals")
1364
+ .description("List profiles that are both followers and following")
1365
+ .option("--account <accountId>", "Account id")
1366
+ .option("--limit <n>", "Limit results", "100")
1367
+ .action(async (options) => {
1368
+ await autoUpdateBeforeRead();
1369
+ print(
1370
+ listMutuals({
1371
+ account: options.account,
1372
+ limit: Number(options.limit),
1373
+ }),
1374
+ true,
1375
+ );
1376
+ });
1377
+
949
1378
  program
950
1379
  .command("db stats")
951
1380
  .description("Show local storage and dataset stats")
@@ -1045,9 +1474,54 @@ program
1045
1474
  {
1046
1475
  cwd: packageRoot,
1047
1476
  stdio: "inherit",
1477
+ detached: process.platform !== "win32",
1048
1478
  },
1049
1479
  );
1050
- child.on("exit", (code) => {
1480
+ const forwardedSignals = [
1481
+ "SIGINT",
1482
+ "SIGTERM",
1483
+ "SIGHUP",
1484
+ "SIGQUIT",
1485
+ ] as const;
1486
+ const forwardSignal = (signal: NodeJS.Signals) => {
1487
+ if (child.exitCode === null && child.signalCode === null) {
1488
+ signalChild(signal);
1489
+ }
1490
+ };
1491
+ const signalChild = (signal: NodeJS.Signals) => {
1492
+ if (child.pid === undefined) {
1493
+ return;
1494
+ }
1495
+ const targetPid = process.platform === "win32" ? child.pid : -child.pid;
1496
+ try {
1497
+ process.kill(targetPid, signal);
1498
+ } catch (error) {
1499
+ if (
1500
+ !(
1501
+ typeof error === "object" &&
1502
+ error !== null &&
1503
+ "code" in error &&
1504
+ error.code === "ESRCH"
1505
+ )
1506
+ ) {
1507
+ throw error;
1508
+ }
1509
+ }
1510
+ };
1511
+ const removeSignalHandlers = () => {
1512
+ for (const signal of forwardedSignals) {
1513
+ process.removeListener(signal, forwardSignal);
1514
+ }
1515
+ };
1516
+ for (const signal of forwardedSignals) {
1517
+ process.on(signal, forwardSignal);
1518
+ }
1519
+ child.on("exit", (code, signal) => {
1520
+ removeSignalHandlers();
1521
+ if (signal) {
1522
+ process.kill(process.pid, signal);
1523
+ return;
1524
+ }
1051
1525
  process.exit(code ?? 0);
1052
1526
  });
1053
1527
  });
@@ -1060,6 +1534,9 @@ export async function runCli(argv = process.argv) {
1060
1534
  if (process.argv[1]) {
1061
1535
  const entryUrl = pathToFileURL(process.argv[1]).href;
1062
1536
  if (import.meta.url === entryUrl) {
1063
- void runCli();
1537
+ void runCli().catch((error) => {
1538
+ console.error(error instanceof Error ? error.message : String(error));
1539
+ process.exitCode = 1;
1540
+ });
1064
1541
  }
1065
1542
  }