github-search-cli 9.1.1 → 9.2.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.
package/README.md CHANGED
@@ -22,14 +22,15 @@
22
22
  npm install -g github-search-cli
23
23
  ```
24
24
 
25
- > **No separate runtime needed!** The package bundles a Bun binary, so the TUI
26
- > works out of the box on Windows, macOS, and Linux — no Node FFI, no `--experimental-ffi`.
25
+ > **No separate runtime needed!** Only requires Node.js 20+. The TUI needs Bun,
26
+ > which is downloaded automatically at install time. Non-interactive modes work
27
+ > with just Node.js.
27
28
  >
28
- > | Runtime | TUI | CLI modes |
29
- > | ----------- | --- | --------- |
30
- > | Bundled Bun | ✅ | ✅ |
31
- > | System Bun | ✅ | ✅ |
32
- > | Node 20+ | ✅ | ✅ |
29
+ > | Platform | TUI | CLI modes |
30
+ > | --------------------- | ---------------- | --------- |
31
+ > | Node 20+ | ⚠ Bun required | ✅ |
32
+ > | Bun | ✅ | ✅ |
33
+ > | Downloaded Bun | ✅ auto-installed| ✅ |
33
34
 
34
35
  ## Use
35
36
 
@@ -191,17 +192,31 @@ cd ghfind
191
192
  # With Bun (recommended for TUI)
192
193
  bun install
193
194
  bun start # TUI
194
- bun test # 102 tests
195
+ bun test # 131 tests
195
196
  bun run build # build dist/
196
197
 
197
198
  # Or with npm
198
199
  npm install
199
- npm start # TUI (requires Node 20-22 for native FFI)
200
- npm test # vitest (TUI render test needs Bun)
200
+ npm start # TUI (requires Node 20+)
201
+ npm test # 131 tests
201
202
  npm run build # build dist/
202
203
  ```
203
204
 
204
205
  ---
206
+ ### Unreleased
207
+ - Fixed trending tabs (Today / This Week / This Month) returning the same repos — cache key now includes the `since` parameter so each period fetches independently
208
+ - Added cyan underline indicator beneath the active trending tab for clearer visual selection
209
+ - Added `ghfind --compare <repo1> <repo2> [repo3]` CLI command to compare repos side-by-side
210
+ - Added `c` keybinding in TUI to toggle compare mode on selected repo
211
+ ---
212
+ ### v0.9.2
213
+
214
+ - Remove bundled Bun binary from npm package (now downloaded at install time for all platforms)
215
+ - Package size reduced from 39.5 MB to 100 KB
216
+ - Postinstall no longer fails npm install on Bun download failure (graceful fallback)
217
+ - Removed `bun` from engines field (users no longer need Bun pre-installed)
218
+ - Non-interactive modes (`--json`, `--csv`, etc.) work with just Node.js 20+
219
+
205
220
  ### v0.9.1
206
221
 
207
222
  - bug fix TUI layout issues on small terminals
package/bin/ghfind.js CHANGED
@@ -3,9 +3,9 @@
3
3
  * ghfind — npm bin entry point.
4
4
  * Requires Node.js >= 20 or Bun.
5
5
  *
6
- * Uses the bundled Bun binary (vendor/bun.exe) to run the TypeScript source
7
- * directly, so users don't need to install Bun separately. Falls back to
8
- * the bundled dist/cli.js if present, or system Bun/Node.
6
+ * Strategy 1: Use a Bun binary from vendor/ (downloaded by postinstall) to
7
+ * run TypeScript source directly. Strategy 2: Fall back to the bundled
8
+ * dist/cli.js with Node.js (works for non-interactive modes).
9
9
  */
10
10
  import { existsSync } from "fs";
11
11
  import { spawnSync } from "child_process";
package/dist/cli.js CHANGED
@@ -490,7 +490,8 @@ var SearchModule = class _SearchModule {
490
490
  query.raw,
491
491
  options.sort,
492
492
  String(options.limit),
493
- String(options.page ?? 1)
493
+ String(options.page ?? 1),
494
+ options.trendingSince ?? "none"
494
495
  );
495
496
  const cached = _SearchModule.cache.get(cacheKey);
496
497
  if (cached) {
@@ -1133,6 +1134,34 @@ async function runInitWizard() {
1133
1134
  console.log("");
1134
1135
  }
1135
1136
 
1137
+ // src/compare.ts
1138
+ function buildComparisonTable(repos) {
1139
+ if (repos.length < 2) return "Select at least 2 repos to compare (press c to add).";
1140
+ const rows = [];
1141
+ const names = repos.map((r) => r.fullName);
1142
+ rows.push({ label: "Stars", values: repos.map((r) => `\u2605 ${r.stars.toLocaleString()}`) });
1143
+ rows.push({ label: "Forks", values: repos.map((r) => r.forks.toLocaleString()) });
1144
+ rows.push({ label: "Language", values: repos.map((r) => r.language ?? "\u2014") });
1145
+ rows.push({ label: "Created", values: repos.map((r) => r.createdAt?.slice(0, 7) ?? "\u2014") });
1146
+ rows.push({ label: "Updated", values: repos.map((r) => r.updatedAt?.slice(0, 10) ?? "\u2014") });
1147
+ rows.push({ label: "Topics", values: repos.map((r) => (r.topics?.slice(0, 3).join(", ") ?? "\u2014") || "\u2014") });
1148
+ const colW = Math.max(15, ...names.map((n) => n.length));
1149
+ const valW = Math.max(8, ...rows.flatMap((r) => r.values.map((v) => v.length)));
1150
+ const header = ["".padEnd(14), ...names.map((n) => n.padEnd(colW))];
1151
+ const sep = ["".padEnd(14, "\u2500"), ...names.map(() => "".padEnd(colW, "\u2500"))];
1152
+ const lines = [
1153
+ "\u250C" + sep.map((s) => "\u2500" + s + "\u2500").join("\u252C") + "\u2510",
1154
+ "\u2502" + header.map((h) => ` ${h} `).join("\u2502") + "\u2502",
1155
+ "\u251C" + sep.map((s) => "\u2500" + s + "\u2500").join("\u253C") + "\u2524"
1156
+ ];
1157
+ for (const row of rows) {
1158
+ const cells = [row.label.padEnd(14), ...row.values.map((v) => v.padEnd(colW))];
1159
+ lines.push("\u2502" + cells.map((c) => ` ${c} `).join("\u2502") + "\u2502");
1160
+ }
1161
+ lines.push("\u2514" + sep.map((s) => "\u2500" + s + "\u2500").join("\u2534") + "\u2518");
1162
+ return lines.join("\n");
1163
+ }
1164
+
1136
1165
  // src/cli.ts
1137
1166
  import { readFileSync as readFileSync3 } from "fs";
1138
1167
  import { join as join3, dirname as dirname2 } from "path";
@@ -1546,34 +1575,6 @@ function buildDeepDiveText(data) {
1546
1575
  ].join("\n");
1547
1576
  }
1548
1577
 
1549
- // src/compare.ts
1550
- function buildComparisonTable(repos) {
1551
- if (repos.length < 2) return "Select at least 2 repos to compare (press c to add).";
1552
- const rows = [];
1553
- const names = repos.map((r) => r.fullName);
1554
- rows.push({ label: "Stars", values: repos.map((r) => `\u2605 ${r.stars.toLocaleString()}`) });
1555
- rows.push({ label: "Forks", values: repos.map((r) => r.forks.toLocaleString()) });
1556
- rows.push({ label: "Language", values: repos.map((r) => r.language ?? "\u2014") });
1557
- rows.push({ label: "Created", values: repos.map((r) => r.createdAt?.slice(0, 7) ?? "\u2014") });
1558
- rows.push({ label: "Updated", values: repos.map((r) => r.updatedAt?.slice(0, 10) ?? "\u2014") });
1559
- rows.push({ label: "Topics", values: repos.map((r) => (r.topics?.slice(0, 3).join(", ") ?? "\u2014") || "\u2014") });
1560
- const colW = Math.max(15, ...names.map((n) => n.length));
1561
- const valW = Math.max(8, ...rows.flatMap((r) => r.values.map((v) => v.length)));
1562
- const header = ["".padEnd(14), ...names.map((n) => n.padEnd(colW))];
1563
- const sep = ["".padEnd(14, "\u2500"), ...names.map(() => "".padEnd(colW, "\u2500"))];
1564
- const lines = [
1565
- "\u250C" + sep.map((s) => "\u2500" + s + "\u2500").join("\u252C") + "\u2510",
1566
- "\u2502" + header.map((h) => ` ${h} `).join("\u2502") + "\u2502",
1567
- "\u251C" + sep.map((s) => "\u2500" + s + "\u2500").join("\u253C") + "\u2524"
1568
- ];
1569
- for (const row of rows) {
1570
- const cells = [row.label.padEnd(14), ...row.values.map((v) => v.padEnd(colW))];
1571
- lines.push("\u2502" + cells.map((c) => ` ${c} `).join("\u2502") + "\u2502");
1572
- }
1573
- lines.push("\u2514" + sep.map((s) => "\u2500" + s + "\u2500").join("\u2534") + "\u2518");
1574
- return lines.join("\n");
1575
- }
1576
-
1577
1578
  // src/explore.ts
1578
1579
  async function fetchTopics() {
1579
1580
  const res = await fetch("https://api.github.com/search/topics?q=popular&per_page=30", {
@@ -2134,27 +2135,27 @@ async function launchBrowser(theme_override) {
2134
2135
  for (let i = 0; i < landingOptions.length; i++) {
2135
2136
  const opt = landingOptions[i];
2136
2137
  const card = new BoxRenderable3(renderer, {
2137
- flexDirection: "row",
2138
- height: 1,
2138
+ flexDirection: "column",
2139
+ height: 5,
2139
2140
  width: "60%",
2140
2141
  marginTop: 1,
2141
- backgroundColor: void 0,
2142
+ border: true,
2142
2143
  borderColor: i === 0 ? colors.accent : colors.border,
2143
- borderWidth: 1,
2144
2144
  paddingX: 1,
2145
- alignItems: "center",
2146
2145
  focusable: false
2147
2146
  });
2148
2147
  landingCards.push(card);
2149
2148
  landingBox.add(card);
2149
+ const spacerTop = new TextRenderable3(renderer, { content: "", height: 1 });
2150
2150
  const titleLine = new TextRenderable3(renderer, {
2151
2151
  content: opt.title,
2152
2152
  color: i === 0 ? colors.accent : colors.text,
2153
- backgroundColor: void 0,
2154
- height: 1,
2155
- flexGrow: 1
2153
+ height: 1
2156
2154
  });
2155
+ const spacerBot = new TextRenderable3(renderer, { content: "", height: 1 });
2156
+ card.add(spacerTop);
2157
2157
  card.add(titleLine);
2158
+ card.add(spacerBot);
2158
2159
  }
2159
2160
  const landingHint = new TextRenderable3(renderer, {
2160
2161
  content: " \u2191\u2193 Navigate \u21E9 Select [?]help [q]uit",
@@ -2176,38 +2177,12 @@ async function launchBrowser(theme_override) {
2176
2177
  }
2177
2178
  function updateLandingCards() {
2178
2179
  for (let i = 0; i < landingCards.length; i++) {
2179
- const opt = landingOptions[i];
2180
2180
  const isSel = i === landingSelected;
2181
- landingCards[i].backgroundColor = isSel ? colors.selectionBg : colors.surface;
2182
2181
  landingCards[i].borderColor = isSel ? colors.accent : colors.border;
2183
- landingCards[i].borderWidth = isSel ? 1 : 0;
2184
2182
  const children = landingCards[i].children ?? [];
2185
- if (children[0]) {
2186
- const iconCol = children[0];
2187
- const iconChildren = iconCol.children ?? [];
2188
- const iconTextChild = iconChildren[0];
2189
- if (iconTextChild) {
2190
- iconTextChild.color = isSel ? colors.accent : colors.text;
2191
- iconTextChild.backgroundColor = isSel ? colors.selectionBg : colors.surface;
2192
- }
2193
- const iconSpacerChild = iconChildren[1];
2194
- if (iconSpacerChild) {
2195
- iconSpacerChild.backgroundColor = isSel ? colors.selectionBg : colors.surface;
2196
- }
2197
- }
2198
- if (children[1]) {
2199
- const contentCol = children[1];
2200
- const contentChildren = contentCol.children ?? [];
2201
- const titleChild = contentChildren[0];
2202
- if (titleChild) {
2203
- titleChild.color = isSel ? colors.accent : colors.text;
2204
- titleChild.backgroundColor = isSel ? colors.selectionBg : colors.surface;
2205
- }
2206
- const descChild = contentChildren[1];
2207
- if (descChild) {
2208
- descChild.backgroundColor = isSel ? colors.selectionBg : colors.surface;
2209
- }
2210
- const hintChild = contentChildren[2];
2183
+ const titleChild = children[1];
2184
+ if (titleChild) {
2185
+ titleChild.color = isSel ? colors.accent : colors.text;
2211
2186
  }
2212
2187
  }
2213
2188
  }
@@ -3525,6 +3500,7 @@ async function launchBrowser(theme_override) {
3525
3500
  searchBox.visible = false;
3526
3501
  toolbarText.visible = false;
3527
3502
  trendingTabBox.visible = true;
3503
+ body.visible = true;
3528
3504
  renderTrendingTabs();
3529
3505
  resultsSelect.options = [
3530
3506
  { name: " Loading trending...", description: "", value: null }
@@ -4087,6 +4063,16 @@ ${pack.description ?? ""}`;
4087
4063
  return;
4088
4064
  }
4089
4065
  }
4066
+ if (key.name === "c" && !searchInput.focused) {
4067
+ if (currentOverlay === "compare") {
4068
+ showOverlay("none");
4069
+ } else {
4070
+ toggleCompareOnSelected();
4071
+ refreshCompare();
4072
+ showOverlay("compare");
4073
+ }
4074
+ return;
4075
+ }
4090
4076
  if (key.name === "?" || key.name === "h" && key.ctrl) {
4091
4077
  showOverlay("help");
4092
4078
  return;
@@ -4236,7 +4222,8 @@ function parseArgs(args) {
4236
4222
  version: false,
4237
4223
  help: false,
4238
4224
  pkg: false,
4239
- registry: "npm"
4225
+ registry: "npm",
4226
+ compare: []
4240
4227
  };
4241
4228
  let queryParts = [];
4242
4229
  for (let i = 0; i < args.length; i++) {
@@ -4305,6 +4292,14 @@ function parseArgs(args) {
4305
4292
  case "--completion":
4306
4293
  flags.completion = args[++i];
4307
4294
  break;
4295
+ case "--compare": {
4296
+ const repos = [];
4297
+ while (i + 1 < args.length && !args[i + 1].startsWith("-")) {
4298
+ repos.push(args[++i]);
4299
+ }
4300
+ flags.compare = repos;
4301
+ break;
4302
+ }
4308
4303
  default:
4309
4304
  if (!arg.startsWith("-")) queryParts.push(arg);
4310
4305
  }
@@ -4336,6 +4331,7 @@ Usage:
4336
4331
  ghfind <query> --format <fmt> Format lines (urls|names|ssh-urls|clone-commands|ids)
4337
4332
  ghfind <query> --pipe <target> Pipe to clone/open
4338
4333
  ghfind --trending --json Trending repos as JSON
4334
+ ghfind --compare <repo1> <repo2> Compare two+ repos side-by-side
4339
4335
  ghfind pkg <query> --json Search npm packages, output JSON
4340
4336
  ghfind pkg <query> Search npm packages, text list
4341
4337
  ghfind --watch <query> Watch mode (poll every Ns)
@@ -4516,9 +4512,21 @@ async function runNonInteractive(flags, outputFormat) {
4516
4512
  if (outputFormat) {
4517
4513
  console.log(format(repos, outputFormat));
4518
4514
  }
4515
+ },
4516
+ compare: async (_ctx) => {
4517
+ const search = createGitHubSearch(void 0, [flags.token ?? ""]);
4518
+ const repos = [];
4519
+ for (const fullName of flags.compare) {
4520
+ const res = await search.search(
4521
+ { keywords: [fullName], qualifiers: [], raw: fullName },
4522
+ { limit: 1, sort: "stars", json: false, verbose: false, token: flags.token }
4523
+ );
4524
+ repos.push(...res.repos);
4525
+ }
4526
+ console.log(buildComparisonTable(repos));
4519
4527
  }
4520
4528
  };
4521
- const key = flags.trending && !flags.watch ? "trending" : flags.watch ? "watch" : flags.pipe ? "pipe" : flags.format ? "format" : "search";
4529
+ const key = flags.compare.length > 0 ? "compare" : flags.trending && !flags.watch ? "trending" : flags.watch ? "watch" : flags.pipe ? "pipe" : flags.format ? "format" : "search";
4522
4530
  const ctx = buildSearchContext(flags);
4523
4531
  await handlers[key](ctx);
4524
4532
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-search-cli",
3
- "version": "9.1.1",
3
+ "version": "9.2.1",
4
4
  "description": "Terminal-native GitHub search — search, trending, bookmarks, history, deep-dive, compare, export, and more. All keyboard-driven, no browser needed.",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1",
@@ -16,7 +16,6 @@
16
16
  "files": [
17
17
  "bin/",
18
18
  "dist/",
19
- "vendor/",
20
19
  "src/",
21
20
  "scripts/",
22
21
  "completions/",
@@ -45,8 +44,7 @@
45
44
  "typescript": "^5.3.0"
46
45
  },
47
46
  "engines": {
48
- "node": ">=20.0.0",
49
- "bun": ">=1.0.0"
47
+ "node": ">=20.0.0"
50
48
  },
51
49
  "repository": {
52
50
  "type": "git",
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Postinstall script — ensures a Bun binary is available for ghfind.
3
+ * Postinstall script — downloads a Bun binary for ghfind's TUI.
4
4
  *
5
- * If vendor/bun or vendor/bun.exe already exists (bundled with the package),
6
- * this is a no-op. Otherwise, it attempts to download the correct binary
7
- * for the user's platform.
5
+ * If vendor/bun or vendor/bun.exe already exists, this is a no-op.
6
+ * Otherwise, it downloads the correct Bun binary for the user's platform.
7
+ * On failure, prints a warning and exits 0 so npm install still succeeds.
8
8
  */
9
9
  import { existsSync, mkdirSync, chmodSync, writeFileSync } from "fs";
10
10
  import { join, dirname } from "path";
@@ -100,7 +100,10 @@ try {
100
100
 
101
101
  throw new Error("Bun binary not found in archive");
102
102
  } catch (err) {
103
- console.error(`[ghfind] Failed to download Bun: ${err instanceof Error ? err.message : String(err)}`);
104
- console.error("[ghfind] Install Bun manually: https://bun.sh/docs/install");
105
- process.exit(1);
103
+ console.warn(`[ghfind] ⚠ Could not download Bun: ${err instanceof Error ? err.message : String(err)}`);
104
+ console.warn("[ghfind] The interactive TUI requires Bun. Install it manually:");
105
+ console.warn("[ghfind] curl -fsSL https://bun.sh/install | bash");
106
+ console.warn("[ghfind] # or: npm install -g bun");
107
+ console.warn("[ghfind] Non-interactive modes (--json, --csv, etc.) will still work with Node.js 20+.");
108
+ process.exit(0);
106
109
  }
package/src/cli.ts CHANGED
@@ -15,6 +15,8 @@ import { launchPackageBrowser } from "./package-browser";
15
15
  import { format as formatOutput, exportToFile, pipeExec, type Format, type ExportFormat, type FormatLine } from "./output";
16
16
  import { runWatch } from "./watch";
17
17
  import { runInitWizard } from "./init";
18
+ import { buildComparisonTable } from "./compare";
19
+ import type { Repo } from "./types";
18
20
  import { readFileSync } from "fs";
19
21
  import { join, dirname } from "path";
20
22
  import { fileURLToPath } from "url";
@@ -46,6 +48,7 @@ interface CLIFlags {
46
48
  pkg: boolean;
47
49
  theme?: string;
48
50
  registry: string;
51
+ compare: string[]; // repo fullNames to compare
49
52
  }
50
53
 
51
54
  function parseArgs(args: string[]): CLIFlags {
@@ -67,6 +70,7 @@ function parseArgs(args: string[]): CLIFlags {
67
70
  help: false,
68
71
  pkg: false,
69
72
  registry: "npm",
73
+ compare: [],
70
74
  };
71
75
 
72
76
  let queryParts: string[] = [];
@@ -100,6 +104,15 @@ function parseArgs(args: string[]): CLIFlags {
100
104
  case "--registry": flags.registry = args[++i] ?? "npm"; break;
101
105
  case "--theme": flags.theme = args[++i] ?? "tokyo-night"; break;
102
106
  case "--completion": flags.completion = args[++i]; break;
107
+ case "--compare": {
108
+ // Collect repo names until next flag
109
+ const repos: string[] = [];
110
+ while (i + 1 < args.length && !args[i + 1].startsWith("-")) {
111
+ repos.push(args[++i]);
112
+ }
113
+ flags.compare = repos;
114
+ break;
115
+ }
103
116
  default:
104
117
  if (!arg.startsWith("-")) queryParts.push(arg);
105
118
  }
@@ -136,6 +149,7 @@ Usage:
136
149
  ghfind <query> --format <fmt> Format lines (urls|names|ssh-urls|clone-commands|ids)
137
150
  ghfind <query> --pipe <target> Pipe to clone/open
138
151
  ghfind --trending --json Trending repos as JSON
152
+ ghfind --compare <repo1> <repo2> Compare two+ repos side-by-side
139
153
  ghfind pkg <query> --json Search npm packages, output JSON
140
154
  ghfind pkg <query> Search npm packages, text list
141
155
  ghfind --watch <query> Watch mode (poll every Ns)
@@ -328,10 +342,23 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
328
342
  console.log(formatOutput(repos, outputFormat));
329
343
  }
330
344
  },
345
+ compare: async (_ctx) => {
346
+ const search = createGitHubSearch(undefined, [flags.token ?? ""]);
347
+ const repos: Repo[] = [];
348
+ for (const fullName of flags.compare) {
349
+ const res = await search.search(
350
+ { keywords: [fullName], qualifiers: [], raw: fullName },
351
+ { limit: 1, sort: "stars", json: false, verbose: false, token: flags.token },
352
+ );
353
+ repos.push(...res.repos);
354
+ }
355
+ console.log(buildComparisonTable(repos));
356
+ },
331
357
  };
332
358
 
333
359
  // Determine which handler to run
334
- const key = flags.trending && !flags.watch ? "trending"
360
+ const key = flags.compare.length > 0 ? "compare"
361
+ : flags.trending && !flags.watch ? "trending"
335
362
  : flags.watch ? "watch"
336
363
  : flags.pipe ? "pipe"
337
364
  : flags.format ? "format"