github-search-cli 9.6.0 → 9.7.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/src/tui.ts CHANGED
@@ -38,6 +38,8 @@ import type {
38
38
  SortStrategy,
39
39
  Package,
40
40
  SessionState,
41
+ HistoryEntry,
42
+ SavedSearch,
41
43
  } from "./types";
42
44
  import { fetchOrgProfile, type OrgProfile } from "./org";
43
45
  import {
@@ -54,13 +56,7 @@ import {
54
56
  import { tabSince, TAB_NAMES, fmtStars } from "./trending";
55
57
  import { loadConfig, saveConfig } from "./config";
56
58
  import { buildHelpSections, HELP_KEYS_COLUMN } from "./help";
57
- import {
58
- SearchCliError,
59
- NetworkError,
60
- RateLimitError,
61
- BadQueryError,
62
- NoResultsError,
63
- } from "./errors";
59
+ import { SearchCliError, NoResultsError } from "./errors";
64
60
  import {
65
61
  appendHistory,
66
62
  readHistory,
@@ -68,12 +64,7 @@ import {
68
64
  clearHistory,
69
65
  rotateHistory,
70
66
  } from "./history";
71
- import {
72
- getBookmarks,
73
- isBookmarked,
74
- toggleBookmark,
75
- removeBookmark,
76
- } from "./bookmarks";
67
+ import { getBookmarks, toggleBookmark, removeBookmark } from "./bookmarks";
77
68
  import {
78
69
  getSavedSearches,
79
70
  saveSearch,
@@ -84,7 +75,7 @@ import { saveSession, restoreSession } from "./session";
84
75
  import { fetchDeepDive, buildDeepDiveText } from "./deepdive";
85
76
  import { buildComparisonTable } from "./compare";
86
77
  import { renderMarkdown } from "./markdown-render";
87
- import { fetchTopics, type TopicItem } from "./explore";
78
+ import { fetchTopics } from "./explore";
88
79
  import { exportToFile, type ExportFormat } from "./output";
89
80
  import {
90
81
  listThemes,
@@ -269,8 +260,8 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
269
260
  let currentPage = 1;
270
261
  let totalCount = 0;
271
262
  let deepDiveActive = false;
272
- let compareList: Repo[] = [];
273
- let quitArmed = false;
263
+ const compareList: Repo[] = [];
264
+ const _quitArmed = false;
274
265
 
275
266
  const header = new TextRenderable(renderer, {
276
267
  content:
@@ -1259,7 +1250,8 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1259
1250
  function popMenuLevel(): boolean {
1260
1251
  if (menuStack.length <= 1) return false;
1261
1252
  const prev = menuStack[menuStack.length - 2];
1262
- const current = menuStack.pop()!;
1253
+ const current = menuStack.pop();
1254
+ if (!current) return false;
1263
1255
  leaderSelect.options = prev.entries.map((e) => ({
1264
1256
  name:
1265
1257
  e.type === "category" ? ` ${e.icon} ${e.name} \u2192` : ` ${e.name}`,
@@ -1283,7 +1275,10 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1283
1275
  function buildMenuHierarchy(): MenuCategory[] {
1284
1276
  const hasRepo = (() => {
1285
1277
  const opt = resultsSelect.getSelectedOption();
1286
- return opt?.value && typeof (opt.value as any)?.fullName === "string";
1278
+ return (
1279
+ opt?.value &&
1280
+ typeof (opt.value as { fullName?: unknown })?.fullName === "string"
1281
+ );
1287
1282
  })();
1288
1283
 
1289
1284
  const groups: MenuCategory[] = [];
@@ -1694,7 +1689,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1694
1689
  if (currentQueryInput) {
1695
1690
  const name =
1696
1691
  currentQueryInput.length > 40
1697
- ? currentQueryInput.slice(0, 37) + "..."
1692
+ ? `${currentQueryInput.slice(0, 37)}...`
1698
1693
  : currentQueryInput;
1699
1694
  saveSearch(
1700
1695
  name,
@@ -1925,16 +1920,11 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1925
1920
  statusBar.content = ` ${text}`;
1926
1921
  renderer.requestRender();
1927
1922
  });
1928
-
1929
- // ── Startup tip ──
1930
- if (config.theme !== undefined || true) {
1931
- // Show a tip after 2s idle
1932
- setTimeout(() => {
1933
- if (currentOverlay === "none") {
1934
- setStatus(nextTip());
1935
- }
1936
- }, 2000);
1937
- }
1923
+ setTimeout(() => {
1924
+ if (currentOverlay === "none") {
1925
+ setStatus(nextTip());
1926
+ }
1927
+ }, 2000);
1938
1928
 
1939
1929
  // ── Helper functions ────────────────────────────────────────────────
1940
1930
 
@@ -2097,7 +2087,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
2097
2087
  val >= 1000
2098
2088
  ? `${(val / 1000).toFixed(1).replace(/\.0$/, "")}k`
2099
2089
  : String(val);
2100
- label = label.padStart(5) + " ";
2090
+ label = `${label.padStart(5)} `;
2101
2091
  }
2102
2092
  lines.push(`${label}┆${chars.join("")}`);
2103
2093
  }
@@ -2624,7 +2614,8 @@ ${pack.description ?? ""}`;
2624
2614
  if (key.name === "d") {
2625
2615
  const sel = historySelect.getSelectedOption();
2626
2616
  if (sel?.value) {
2627
- deleteHistoryEntry((sel.value as any).index);
2617
+ const idx = (sel.value as { index?: number }).index;
2618
+ if (idx !== undefined) deleteHistoryEntry(idx);
2628
2619
  refreshHistory();
2629
2620
  setStatus("Deleted history entry");
2630
2621
  }
@@ -2641,8 +2632,9 @@ ${pack.description ?? ""}`;
2641
2632
  if (key.name === "enter" || key.name === "return") {
2642
2633
  const sel = historySelect.getSelectedOption();
2643
2634
  if (sel?.value) {
2644
- const entry = (sel.value as any).entry;
2635
+ const entry = (sel.value as { entry?: HistoryEntry }).entry;
2645
2636
  showOverlay("none");
2637
+ if (!entry) return;
2646
2638
  if (entry.mode === "trending") {
2647
2639
  if (entry.tab)
2648
2640
  trendingTab = entry.tab as (typeof TAB_NAMES)[number];
@@ -2663,7 +2655,10 @@ ${pack.description ?? ""}`;
2663
2655
  if (key.name === "d") {
2664
2656
  const sel = bookmarksSelect.getSelectedOption();
2665
2657
  if (sel?.value) {
2666
- removeBookmark((sel.value as any).repo.fullName);
2658
+ removeBookmark(
2659
+ (sel.value as { repo?: { fullName: string } }).repo?.fullName ??
2660
+ "",
2661
+ );
2667
2662
  refreshBookmarks();
2668
2663
  setStatus("Bookmark removed");
2669
2664
  }
@@ -2672,7 +2667,8 @@ ${pack.description ?? ""}`;
2672
2667
  }
2673
2668
  if (key.name === "enter" || key.name === "return") {
2674
2669
  const sel = bookmarksSelect.getSelectedOption();
2675
- if (sel?.value) openUrl((sel.value as any).repo.url);
2670
+ if (sel?.value)
2671
+ openUrl((sel.value as { repo?: { url?: string } }).repo?.url ?? "");
2676
2672
  return;
2677
2673
  }
2678
2674
  return;
@@ -2683,7 +2679,7 @@ ${pack.description ?? ""}`;
2683
2679
  if (key.name === "d") {
2684
2680
  const sel = savedSelect.getSelectedOption();
2685
2681
  if (sel?.value) {
2686
- deleteSavedSearch((sel.value as any).name);
2682
+ deleteSavedSearch((sel.value as { name?: string }).name ?? "");
2687
2683
  refreshSavedSearches();
2688
2684
  setStatus("Saved search deleted");
2689
2685
  }
@@ -2693,7 +2689,7 @@ ${pack.description ?? ""}`;
2693
2689
  if (key.name === "enter" || key.name === "return") {
2694
2690
  const sel = savedSelect.getSelectedOption();
2695
2691
  if (sel?.value) {
2696
- const s = sel.value as any;
2692
+ const s = sel.value as SavedSearch;
2697
2693
  showOverlay("none");
2698
2694
  touchSavedSearch(s.name);
2699
2695
  if (s.mode === "trending") {
@@ -2718,9 +2714,9 @@ ${pack.description ?? ""}`;
2718
2714
  if (key.name === "enter" || key.name === "return") {
2719
2715
  const sel = topicsSelect.getSelectedOption();
2720
2716
  if (sel?.value) {
2721
- const topic = sel.value as any;
2717
+ const topic = sel.value as { name?: string };
2722
2718
  showOverlay("none");
2723
- searchInput.value = `topic:${topic.name}`;
2719
+ searchInput.value = `topic:${topic.name ?? ""}`;
2724
2720
  showSearchMode();
2725
2721
  doSearch(`topic:${topic.name}`);
2726
2722
  }
@@ -2973,7 +2969,7 @@ ${pack.description ?? ""}`;
2973
2969
  // Number keys 1-5 switch trending tabs (only in trending mode)
2974
2970
  if (/^[1-5]$/.test(key.name)) {
2975
2971
  if (currentMode === "trending") {
2976
- const idx = parseInt(key.name) - 1;
2972
+ const idx = parseInt(key.name, 10) - 1;
2977
2973
  if (idx >= 0 && idx < TAB_NAMES.length) {
2978
2974
  trendingTab = TAB_NAMES[idx];
2979
2975
  loadTrending();
@@ -3025,16 +3021,16 @@ ${pack.description ?? ""}`;
3025
3021
  });
3026
3022
 
3027
3023
  // ── Update check (before start) ──────────────────────────────────────
3028
- const currentVersion = getVersion();
3024
+ const _currentVersion = getVersion();
3029
3025
  // ── Start ──────────────────────────────────────────────────────────
3030
3026
  // Check for updates (non-blocking — doesn't delay TUI startup)
3031
- if (!session || !session.mode) {
3027
+ if (!session?.mode) {
3032
3028
  showLanding(true);
3033
3029
  }
3034
3030
  checkForUpdateAndShow();
3035
3031
  checkPostUpgradePanel();
3036
3032
  renderer.start();
3037
- if (session && session.mode) {
3033
+ if (session?.mode) {
3038
3034
  if (currentMode === "trending") {
3039
3035
  loadTrending();
3040
3036
  } else {
@@ -5,12 +5,13 @@
5
5
  * Persists check state in the XDG state dir via storage.ts.
6
6
  */
7
7
  import { readJSON, writeJSON, debugLog } from "./storage";
8
- import { stateDir } from "./config";
9
- import { readFileSync } from "fs";
10
- import { join, dirname } from "path";
11
- import { fileURLToPath } from "url";
8
+ import { readFileSync } from "node:fs";
9
+ import { join, dirname, posix } from "node:path";
10
+ import { fileURLToPath } from "node:url";
12
11
 
13
12
  const REGISTRY_URL = "https://registry.npmjs.org/github-search-cli/latest";
13
+ // The npm package name is `github-search-cli`; `ghfind` is the bin name.
14
+ const PKG_NAME = "github-search-cli";
14
15
  const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
15
16
  const REQUEST_TIMEOUT_MS = 5000;
16
17
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -147,39 +148,100 @@ export function suppressUpdateNotices(): void {
147
148
  }
148
149
 
149
150
  /**
150
- * Attempt to update ghfind in-place.
151
- * Uses `process.versions.bun` for reliable runtime detection (works on all platforms).
151
+ * Where the running install lives and which package manager owns it.
152
+ *
153
+ * Detection walks up from the package dir to its `node_modules` ancestor:
154
+ * - npm global install: `<prefix>/node_modules/<pkg>` (Windows) or
155
+ * `<prefix>/lib/node_modules/<pkg>` (Unix)
156
+ * - Bun global install: `<bun-home>/install/global/node_modules/<pkg>`
157
+ * - Anything else (compiled binary, `bunx`/`npx` cache edges, dev checkout
158
+ * without a `node_modules` ancestor): `null` — self-update is refused
159
+ * rather than npm-installing a copy the user isn't running.
160
+ */
161
+ export interface InstallLocation {
162
+ channel: "npm" | "bun";
163
+ /** npm prefix that owns the running install (npm channel only). */
164
+ prefix?: string;
165
+ }
166
+
167
+ /**
168
+ * Detect the install channel and owning npm prefix for a package directory.
169
+ * Separators are normalized so layouts match regardless of the host OS.
152
170
  */
153
- export async function performUpdate(): Promise<boolean> {
154
- // The npm package name is `github-search-cli`; `ghfind` is the bin name.
155
- const pkg = "github-search-cli";
156
- const isBun =
157
- typeof process.versions !== "undefined" && "bun" in process.versions;
158
- const cmd = isBun
159
- ? ["bun", "install", "-g", pkg]
160
- : ["npm", "install", "-g", pkg];
171
+ export function detectInstallLocation(pkgDir: string): InstallLocation | null {
172
+ const norm = pkgDir.split("\\").join("/");
173
+ const nodeModules = posix.dirname(norm);
174
+ if (posix.basename(nodeModules) !== "node_modules") return null;
175
+ const owner = posix.dirname(nodeModules);
176
+ // Bun's global root is ~/.bun by default (BUN_INSTALL override may rename
177
+ // it, but the default layout is what `bun install -g` produces for the
178
+ // overwhelming majority of installs).
179
+ if (/(^|\/)\.bun(\/|$)/.test(owner)) return { channel: "bun" };
180
+ // Unix npm global layout nests one level deeper: <prefix>/lib/node_modules
181
+ const prefix = posix.basename(owner) === "lib" ? posix.dirname(owner) : owner;
182
+ return { channel: "npm", prefix };
183
+ }
184
+
185
+ /**
186
+ * Build the install command for an update, targeting the npm prefix that
187
+ * owns the running install. Under Bun this must NOT be `bun install -g`:
188
+ * that writes to Bun's own global dir, leaving the running npm copy stale
189
+ * while still exiting 0.
190
+ */
191
+ export function buildUpdateCommand(loc: InstallLocation): string[] {
192
+ if (loc.channel === "bun") return ["bun", "install", "-g", PKG_NAME];
193
+ const args = ["npm", "install", "-g"];
194
+ if (loc.prefix) args.push("--prefix", loc.prefix);
195
+ args.push(PKG_NAME);
196
+ return args;
197
+ }
198
+
199
+ /** Minimal quoting for win32 spawns, which go through a shell. */
200
+ function quoteWin32(arg: string): string {
201
+ return /\s/.test(arg) ? `"${arg}"` : arg;
202
+ }
161
203
 
204
+ /**
205
+ * Attempt to update ghfind in-place.
206
+ *
207
+ * Runs the install command through `node:child_process` on every runtime
208
+ * (works under Bun and Node alike; on Windows npm is `npm.cmd`, which needs
209
+ * `shell: true` to resolve). Always returns a definitive boolean — the TUI
210
+ * surfaces false as "Update failed".
211
+ */
212
+ export async function performUpdate(
213
+ location: InstallLocation | null = detectInstallLocation(PKG_DIR),
214
+ ): Promise<boolean> {
215
+ if (!location) {
216
+ debugLog(
217
+ "Update skipped: the running install is not under a node_modules tree " +
218
+ "(compiled binary or dev checkout). Update it manually.",
219
+ );
220
+ return false;
221
+ }
222
+ const argv = buildUpdateCommand(location);
162
223
  try {
163
- if (isBun) {
164
- const proc = Bun.spawn(cmd, {
165
- stdout: "pipe",
166
- stderr: "pipe",
167
- });
168
- await proc.exited;
169
- if (proc.exitCode !== 0) {
170
- const err = await proc.stderr.text();
171
- debugLog(`Install failed (code ${proc.exitCode}): ${err}`);
172
- }
173
- return proc.exitCode === 0;
174
- }
175
- // Node fallback
176
- const { spawn } = await import("child_process");
177
- return await new Promise<boolean>((resolve) => {
178
- const proc = spawn(cmd[0], cmd.slice(1), { stdio: "ignore" });
179
- proc.on("close", (code) => resolve(code === 0));
180
- proc.on("error", () => resolve(false));
224
+ const { spawn } = await import("node:child_process");
225
+ const useShell = process.platform === "win32";
226
+ const proc = spawn(
227
+ argv[0],
228
+ useShell ? argv.slice(1).map(quoteWin32) : argv.slice(1),
229
+ { stdio: ["ignore", "ignore", "pipe"], shell: useShell },
230
+ );
231
+ let stderr = "";
232
+ proc.stderr?.on("data", (chunk: Buffer) => {
233
+ stderr += String(chunk);
181
234
  });
182
- } catch {
235
+ const code = await new Promise<number | null>((resolve) => {
236
+ proc.on("close", (c) => resolve(c));
237
+ proc.on("error", () => resolve(-1));
238
+ });
239
+ if (code !== 0) {
240
+ debugLog(`Install failed (code ${code}): ${stderr.trim()}`);
241
+ }
242
+ return code === 0;
243
+ } catch (e) {
244
+ debugLog(`Update failed: ${e instanceof Error ? e.message : String(e)}`);
183
245
  return false;
184
246
  }
185
247
  }
@@ -215,7 +277,7 @@ export function fetchReleaseNotes(version: string): string | null {
215
277
  const changelogPath = join(PKG_DIR, "CHANGELOG.md");
216
278
  const changelog = readFileSync(changelogPath, "utf-8");
217
279
  // ponytail: find "## [version]" header, slice until next "## "
218
- const header = "## [" + version + "]";
280
+ const header = `## [${version}]`;
219
281
  const start = changelog.indexOf(header);
220
282
  if (start === -1) return null;
221
283
  const bodyStart = start + header.length;
package/src/version.ts CHANGED
@@ -6,9 +6,9 @@
6
6
  * standalone single-file binaries where package.json is not present.
7
7
  * Fallback: read package.json relative to this module (source / dist trees).
8
8
  */
9
- import { readFileSync } from "fs";
10
- import { join, dirname } from "path";
11
- import { fileURLToPath } from "url";
9
+ import { readFileSync } from "node:fs";
10
+ import { join, dirname } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
12
 
13
13
  declare const __GHFIND_VERSION__: string | undefined;
14
14
 
package/src/watch.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Watch mode — periodically re-run a search and show changes.
3
3
  *
4
4
  */
5
- import type { ParsedQuery, SearchOptions, Repo } from "./types";
5
+ import type { SearchOptions, Repo } from "./types";
6
6
  import {
7
7
  parseQuery,
8
8
  applyFlagFilters,