github-search-cli 9.7.0 → 9.8.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-search-cli",
3
- "version": "9.7.0",
3
+ "version": "9.8.0",
4
4
  "description": "Search GitHub from your terminal — repo search, trending repos, npm package search, bookmarks, deep-dive, compare, export. Keyboard-driven TUI + pipeable CLI.",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1",
@@ -37,13 +37,14 @@
37
37
  "format:check": "prettier --check .",
38
38
  "format": "prettier --write .",
39
39
  "typecheck": "tsc --noEmit",
40
- "lint": "bunx biome@2.5.13 check .",
40
+ "lint": "biome check .",
41
41
  "check": "prettier --check . && bun test"
42
42
  },
43
43
  "dependencies": {
44
44
  "@opentui/core": "^0.4.3"
45
45
  },
46
46
  "devDependencies": {
47
+ "@biomejs/biome": "^2.5.14",
47
48
  "@types/bun": "latest",
48
49
  "@types/node": "^20.0.0",
49
50
  "prettier": "^3.2.0",
@@ -83,5 +84,10 @@
83
84
  "license": "MIT",
84
85
  "publishConfig": {
85
86
  "access": "public"
86
- }
87
+ },
88
+ "main": "index.js",
89
+ "directories": {
90
+ "test": "tests"
91
+ },
92
+ "author": ""
87
93
  }
@@ -0,0 +1,62 @@
1
+ # What's New in ghfind 9.7.1
2
+
3
+ The in-app update finally works — plus a Windows URL fix and a new doctor
4
+ check that names every stale install shadowing your updates.
5
+
6
+ ### Fixed
7
+
8
+ **In-app update installed to the wrong location**
9
+
10
+ - When the TUI runs under Bun, `performUpdate()` ran `bun install -g
11
+ github-search-cli`, which targets Bun's own global dir (`~/.bun`) — not
12
+ the npm prefix where the running copy and its `ghfind` shim live. The
13
+ command exited 0, "update succeeded", and the running install never
14
+ changed.
15
+ - The updater now detects which package manager owns the running tree
16
+ (`detectInstallLocation`: npm global prefix vs `~/.bun` global) and
17
+ installs with `npm install -g --prefix <prefix>` for npm-owned installs,
18
+ so the update lands exactly where the running install is.
19
+ - Compiled binaries and dev checkouts (no `node_modules` ancestor) refuse
20
+ self-update honestly instead of npm-installing a copy you aren't running.
21
+
22
+ **Node fallback couldn't launch npm on Windows**
23
+
24
+ - `child_process.spawn` without a shell cannot execute `npm.cmd` — recent
25
+ Node rejects it outright with `EINVAL`. The update spawn now goes through
26
+ `node:child_process` on every runtime with `shell: true` on win32, so the
27
+ Node fallback path works on Windows too.
28
+
29
+ **Windows: URLs truncated at the first `&`**
30
+
31
+ - Opening URLs via `cmd /c start "" <url>` split the URL at the first
32
+ unquoted `&` — a query-string separator — and tried to execute the rest
33
+ as a command. Every pre-filled crash-report/issue link lost its body and
34
+ labels.
35
+ - Both URL openers now use `explorer.exe` on Windows, which receives the
36
+ URL as a single argument, so `&` is never interpreted.
37
+
38
+ **New `ghfind --doctor` check: stale/PATH-shadowing installs**
39
+
40
+ - Multiple `ghfind` copies on PATH resolving to different versions are the
41
+ classic "updater said OK but nothing changed" trap: the update lands in
42
+ one prefix while the shell keeps launching another. `ghfind --doctor` now
43
+ lists every install with its version and prints the exact
44
+ `npm uninstall -g github-search-cli --prefix <dir>` command to remove the
45
+ stale ones.
46
+
47
+ ### Changed
48
+
49
+ - **CI enforces lint + typecheck** — the repo's first green Biome baseline
50
+ (198 diagnostics triaged, real `@biomejs/biome` 2.5.14 dependency replacing
51
+ the phantom `biome@2.5.13` pin) and a `.gitattributes` forcing LF so
52
+ `prettier --check` passes on Windows runners (main had been red on
53
+ windows-latest for weeks). Previously both gates were excluded as
54
+ known-broken; the typecheck-opentui-drift ticket is closed.
55
+
56
+ ### Internal
57
+
58
+ - `open-url.ts` is runtime-agnostic with a detached Node fallback (it
59
+ previously crashed under Node via unconditional `Bun.spawn`).
60
+ - Housekeeping: `mcp-server.ts` assignment-in-expressions, `trending-parser.ts`
61
+ stateful regex loop → `matchAll`, typed casts replacing `as any`, and a
62
+ repaired unsafe-autofix regression in `TrendingAdapter`.
@@ -0,0 +1,55 @@
1
+ # What's New in ghfind 9.8.0
2
+
3
+ The README viewer learned to render markdown properly — and to show the
4
+ pictures inside it. Same menu item, same keys, but the overlay now shows a
5
+ repository the way its authors wrote it, images included.
6
+
7
+ ### Added
8
+
9
+ **Full markdown rendering in the README viewer**
10
+
11
+ - The viewer used to approximate markdown with box-drawing text: heading
12
+ markers became `█` prefixes, tables were hand-drawn, and everything else
13
+ was inline-decorated plain text. It now drives OpenTUI's markdown
14
+ renderable with a syntax style derived from your theme, so headings,
15
+ emphasis, links, lists, blockquotes, boxed tables, and fenced code blocks
16
+ render as markdown.
17
+ - Headings pick up the accent colour (H1 underlined), inline code the theme's
18
+ green, links the accent with dimmed URLs — all of it follows `t` theme
19
+ switching, and everything re-reads the palette when a README is opened.
20
+ - The document keeps its own block layout: paragraphs, lists, and code stay
21
+ scrollable in the same overlay (`↑`/`↓`, `j`/`k`, `PgUp`/`PgDn`).
22
+
23
+ **Images now render in the terminal**
24
+
25
+ - README images — markdown `![]()` and standalone `<img>` tags — are fetched
26
+ and drawn inline as truecolor half blocks. No image protocol support
27
+ required: it works on any truecolor terminal, inside the normal cell
28
+ buffer, so it scrolls and clips like every other line.
29
+ - Paths resolve the way GitHub resolves them: `docs/shot.png` and
30
+ `/assets/banner.webp` both resolve against the branch the README came
31
+ from, and `github.com/owner/repo/blob/<ref>/<path>` links are rewritten to
32
+ raw content first.
33
+ - Images are cached per URL (so toggling or re-opening a README doesn't
34
+ re-download), capped at 6 MB and 20 megapixels, and skipped entirely when
35
+ they can't be shown usefully: SVG is vector text, badges are tiny
36
+ machine-generated images, and neither survives rasterising. Skipped and
37
+ failed images leave an `🖼 alt text` caption, so a README never silently
38
+ loses a section.
39
+ - Press `i` in the viewer to toggle images off and on; the footer shows the
40
+ current state. On a runtime without `Bun.Image` (plain Node), images
41
+ degrade to those captions instead of erroring.
42
+
43
+ ### Changed
44
+
45
+ - **The update panel's release notes render as markdown too.** The in-app
46
+ update modal used the same text approximation as the README viewer; it now
47
+ shares the same markdown view, so release notes show real headings, lists,
48
+ inline code, and tables. Inline images stay off there — the panel is
49
+ narrow and notes are prose.
50
+ - With the viewer and the update panel both migrated, the old plain-text
51
+ markdown renderer is gone; the markdown view is the only renderer left.
52
+ - Test coverage grew with the feature: a PNG decoder (colour types, bit
53
+ depths, `tRNS`, every scanline filter), the image-to-cells rasteriser,
54
+ README image URL resolution and fetch behaviour, and the markdown token
55
+ helpers — 414 tests total, no new dependencies.
package/scripts/build.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Build script — compile ghfind for distribution.
3
3
  * Usage: npm run build
4
4
  */
5
- import { execFileSync } from "child_process";
5
+ import { execFileSync } from "node:child_process";
6
6
 
7
7
  async function main() {
8
8
  console.log("Building ghfind...");
@@ -13,12 +13,12 @@ import {
13
13
  chmodSync,
14
14
  writeFileSync,
15
15
  readFileSync,
16
- } from "fs";
17
- import { join, dirname } from "path";
18
- import { fileURLToPath } from "url";
19
- import { platform, arch } from "os";
20
- import { spawnSync } from "child_process";
21
- import { inflateRawSync } from "zlib";
16
+ } from "node:fs";
17
+ import { join, dirname } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { platform, arch } from "node:os";
20
+ import { spawnSync } from "node:child_process";
21
+ import { inflateRawSync } from "node:zlib";
22
22
 
23
23
  const __dirname = dirname(fileURLToPath(import.meta.url));
24
24
  const ROOT = join(__dirname, "..");
@@ -121,8 +121,8 @@ try {
121
121
  let cdOffset = buffer.indexOf(cdSig, 0);
122
122
  while (cdOffset !== -1) {
123
123
  const nameLen = buffer.readUInt16LE(cdOffset + 28);
124
- const extraLen = buffer.readUInt16LE(cdOffset + 30);
125
- const commentLen = buffer.readUInt16LE(cdOffset + 32);
124
+ const _extraLen = buffer.readUInt16LE(cdOffset + 30);
125
+ const _commentLen = buffer.readUInt16LE(cdOffset + 32);
126
126
  const localHeaderOffset = buffer.readUInt32LE(cdOffset + 42);
127
127
  const compSize = buffer.readUInt32LE(cdOffset + 20);
128
128
  const compMethod = buffer.readUInt16LE(cdOffset + 10);
package/src/cli.ts CHANGED
@@ -22,11 +22,9 @@ import { runDoctor } from "./doctor";
22
22
  import { reportError } from "./error-report";
23
23
  import {
24
24
  format as formatOutput,
25
- exportToFile,
26
25
  pipeExec,
27
26
  type Format,
28
27
  type ExportFormat,
29
- type FormatLine,
30
28
  } from "./output";
31
29
  import { runWatch } from "./watch";
32
30
  import {
@@ -61,9 +59,9 @@ import {
61
59
  } from "./deepdive";
62
60
  import { resolveTrendingLanguage } from "./search";
63
61
  import type { Repo } from "./types";
64
- import { readFileSync } from "fs";
65
- import { join, dirname } from "path";
66
- import { fileURLToPath } from "url";
62
+ import { readFileSync } from "node:fs";
63
+ import { join, dirname } from "node:path";
64
+ import { fileURLToPath } from "node:url";
67
65
  import { launchBrowser } from "./tui";
68
66
  import { getVersion } from "./version";
69
67
  import { runMcpServer } from "./mcp-server";
@@ -139,7 +137,7 @@ function parseArgs(args: string[]): CLIFlags {
139
137
  mcp: false,
140
138
  };
141
139
 
142
- let queryParts: string[] = [];
140
+ const queryParts: string[] = [];
143
141
  for (let i = 0; i < args.length; i++) {
144
142
  const arg = args[i];
145
143
  switch (arg) {
@@ -200,8 +198,8 @@ function parseArgs(args: string[]): CLIFlags {
200
198
  flags.login = true;
201
199
  break;
202
200
  case "--limit": {
203
- const parsed = parseInt(args[++i]);
204
- if (isNaN(parsed) || parsed < 1 || parsed > 100) {
201
+ const parsed = parseInt(args[++i], 10);
202
+ if (Number.isNaN(parsed) || parsed < 1 || parsed > 100) {
205
203
  console.error(`Invalid limit: must be 1-100. Using default 50.`);
206
204
  flags.limit = 50;
207
205
  } else {
@@ -513,7 +511,12 @@ async function runUserProfile(
513
511
  outputFormat?: ExportFormat,
514
512
  ): Promise<void> {
515
513
  try {
516
- const profile = await fetchUserProfile(flags.user!, {
514
+ const user = flags.user ?? "";
515
+ if (!user) {
516
+ console.error("Usage: ghfind user <name> [--json] [--token <t>]");
517
+ process.exit(1);
518
+ }
519
+ const profile = await fetchUserProfile(user, {
517
520
  token: flags.token,
518
521
  limit: flags.limit,
519
522
  });
@@ -540,7 +543,12 @@ async function runOrgProfile(
540
543
  outputFormat?: ExportFormat,
541
544
  ): Promise<void> {
542
545
  try {
543
- const profile = await fetchOrgProfile(flags.org!, {
546
+ const org = flags.org ?? "";
547
+ if (!org) {
548
+ console.error("Usage: ghfind org <name> [--json] [--token <t>]");
549
+ process.exit(1);
550
+ }
551
+ const profile = await fetchOrgProfile(org, {
544
552
  token: flags.token,
545
553
  limit: flags.limit,
546
554
  });
@@ -566,7 +574,7 @@ async function runDeepDive(
566
574
  flags: CLIFlags,
567
575
  outputFormat?: ExportFormat,
568
576
  ): Promise<void> {
569
- const ref = flags.deepDive!;
577
+ const ref = flags.deepDive;
570
578
  if (!ref) {
571
579
  console.error(
572
580
  "Usage: ghfind deep-dive <owner/repo> [--json] [--token <t>]",
@@ -684,7 +692,7 @@ async function runReleases(
684
692
  async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
685
693
  // Dispatch table: each handler receives shared context + flags, returns void.
686
694
  const handlers: Record<string, (ctx: SearchContext) => Promise<void>> = {
687
- trending: async (ctx) => {
695
+ trending: async (_ctx) => {
688
696
  const since =
689
697
  flags.since === "weekly"
690
698
  ? "weekly"
@@ -773,7 +781,7 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
773
781
  token: flags.token,
774
782
  });
775
783
  const repos = rankRepos(response.repos, flags.sort);
776
- await pipeExec(repos, flags.pipe!);
784
+ if (flags.pipe) await pipeExec(repos, flags.pipe);
777
785
  },
778
786
  format: async (ctx) => {
779
787
  const response = await ctx.provider.search(ctx.parsed, {
package/src/compare.ts CHANGED
@@ -52,9 +52,9 @@ export function buildComparisonTable(repos: Repo[]): string {
52
52
  const sep = ["".padEnd(14, "─"), ...names.map(() => "".padEnd(colW, "─"))];
53
53
 
54
54
  const lines: string[] = [
55
- "┌" + sep.map((s) => "─" + s + "─").join("┬") + "┐",
56
- "│" + header.map((h) => ` ${h} `).join("│") + "│",
57
- "├" + sep.map((s) => "─" + s + "─").join("┼") + "┤",
55
+ `┌${sep.map((s) => `─${s}─`).join("┬")}┐`,
56
+ `│${header.map((h) => ` ${h} `).join("│")}│`,
57
+ `├${sep.map((s) => `─${s}─`).join("┼")}┤`,
58
58
  ];
59
59
 
60
60
  for (const row of rows) {
@@ -62,10 +62,10 @@ export function buildComparisonTable(repos: Repo[]): string {
62
62
  row.label.padEnd(14),
63
63
  ...row.values.map((v) => v.padEnd(colW)),
64
64
  ];
65
- lines.push("│" + cells.map((c) => ` ${c} `).join("│") + "│");
65
+ lines.push(`│${cells.map((c) => ` ${c} `).join("│")}│`);
66
66
  }
67
67
 
68
- lines.push("└" + sep.map((s) => "─" + s + "─").join("┴") + "┘");
68
+ lines.push(`└${sep.map((s) => `─${s}─`).join("┴")}┘`);
69
69
  return lines.join("\n");
70
70
  }
71
71
 
package/src/config.ts CHANGED
@@ -7,9 +7,9 @@
7
7
  * 3. ~/.config/ghfind/config.json
8
8
  */
9
9
  import type { Config } from "./types";
10
- import { homedir } from "os";
11
- import { join } from "path";
12
- import { mkdirSync, readFileSync, writeFileSync } from "fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
13
 
14
14
  /** Directory for persistent state (history, bookmarks, session). */
15
15
  export function stateDir(): string {
package/src/deepdive.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  /** Repo deep-dive — fetch and format rich repo details. */
2
2
  import type { Repo } from "./types";
3
- import { NetworkError } from "./errors";
4
3
 
5
4
  const USER_AGENT = "ghfind/1.0";
6
5
 
package/src/doctor.ts CHANGED
@@ -5,11 +5,11 @@
5
5
  * Runs a battery of checks and prints a PASS / WARN / FAIL report, then exits
6
6
  * non-zero if any FAIL check was detected.
7
7
  */
8
- import { existsSync, readFileSync, mkdirSync } from "fs";
9
- import { spawnSync } from "child_process";
10
- import { join, dirname } from "path";
11
- import { fileURLToPath } from "url";
12
- import { homedir, platform, arch } from "os";
8
+ import { existsSync, readFileSync, mkdirSync } from "node:fs";
9
+ import { spawnSync } from "node:child_process";
10
+ import { join, dirname } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { homedir, platform, arch } from "node:os";
13
13
  import { loadConfig, configPath, stateDir, cacheDir } from "./config";
14
14
 
15
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -31,9 +31,14 @@ function add(status: CheckStatus, label: string, detail: string): void {
31
31
  function run(
32
32
  cmd: string,
33
33
  args: string[],
34
+ opts: { shell?: boolean; timeout?: number } = {},
34
35
  ): { status: number | null; stdout: string; stderr: string } {
35
36
  try {
36
- const res = spawnSync(cmd, args, { encoding: "utf8", timeout: 15000 });
37
+ const res = spawnSync(cmd, args, {
38
+ encoding: "utf8",
39
+ timeout: 15000,
40
+ ...opts,
41
+ });
37
42
  return {
38
43
  status: res.status,
39
44
  stdout: res.stdout ?? "",
@@ -123,13 +128,13 @@ function checkSystemBun(bundledOk: boolean): void {
123
128
  add(
124
129
  "WARN",
125
130
  "System Bun",
126
- "No \`bun\` on PATH. Bundled Bun is working, so this is only a fallback.",
131
+ "No `bun` on PATH. Bundled Bun is working, so this is only a fallback.",
127
132
  );
128
133
  } else {
129
134
  add(
130
135
  "FAIL",
131
136
  "System Bun",
132
- "No \`bun\` on PATH. Install it: curl -fsSL https://bun.sh/install | bash",
137
+ "No `bun` on PATH. Install it: curl -fsSL https://bun.sh/install | bash",
133
138
  );
134
139
  }
135
140
  }
@@ -236,6 +241,136 @@ function checkDirs(): void {
236
241
  }
237
242
  }
238
243
 
244
+ const PKG_NAME = "github-search-cli";
245
+ const BIN_NAME = "ghfind";
246
+
247
+ function runningVersion(): string {
248
+ try {
249
+ const pkg = JSON.parse(
250
+ readFileSync(join(PKG_DIR, "package.json"), "utf8"),
251
+ ) as { version?: string };
252
+ return pkg.version ?? "";
253
+ } catch {
254
+ return "";
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Best-effort version of the install that owns a `ghfind` shim on PATH.
260
+ * Prefers reading the sibling package.json (cheap, exact); falls back to
261
+ * executing the shim with `--version` (handles bun/unknown layouts).
262
+ */
263
+ export function installVersionAt(
264
+ shimPath: string,
265
+ ): { version: string; dir: string } | null {
266
+ const dir = dirname(shimPath);
267
+ // npm layouts: <prefix>/node_modules/<pkg> (win) or <prefix>/lib/node_modules/<pkg> (unix)
268
+ for (const ancestor of [dir, dirname(dir)]) {
269
+ for (const sub of ["node_modules", join("lib", "node_modules")]) {
270
+ const pkgJson = join(ancestor, sub, PKG_NAME, "package.json");
271
+ if (existsSync(pkgJson)) {
272
+ try {
273
+ const pkg = JSON.parse(readFileSync(pkgJson, "utf8")) as {
274
+ version?: string;
275
+ };
276
+ return { version: pkg.version ?? "", dir: dirname(pkgJson) };
277
+ } catch {
278
+ // fall through to shim execution
279
+ }
280
+ }
281
+ }
282
+ }
283
+ // .cmd/.bat shims require shell:true under Node (EINVAL otherwise); the
284
+ // package.json read above already covered the common npm layouts.
285
+ const res = run(shimPath, ["--version"], {
286
+ shell: platform() === "win32",
287
+ timeout: 10000,
288
+ });
289
+ const m = /\d+\.\d+\.\d+/.exec(res.stdout);
290
+ return m ? { version: m[0], dir } : null;
291
+ }
292
+
293
+ /**
294
+ * Detect stale/shadowing global installs: multiple `ghfind` binaries on PATH
295
+ * resolving to different versions means the shell may keep launching an old
296
+ * copy even after a successful update — the exact "updater says OK but
297
+ * nothing changed" trap. Warns and names the offending prefixes.
298
+ */
299
+ function checkInstallShadowing(): void {
300
+ const isWin = platform() === "win32";
301
+ const res = isWin ? run("where", [BIN_NAME]) : run("which", ["-a", BIN_NAME]);
302
+ const shims = [
303
+ ...new Set(
304
+ res.stdout
305
+ .split(/\r?\n/)
306
+ .map((l) => l.trim())
307
+ .filter((l) => l.length > 0),
308
+ ),
309
+ ];
310
+ if (shims.length === 0) {
311
+ add("WARN", "Global installs", `${BIN_NAME} not found on PATH.`);
312
+ return;
313
+ }
314
+ const running = runningVersion();
315
+ const norm = (p: string) => (isWin ? p.replace(/\\/g, "/").toLowerCase() : p);
316
+ const seenDirs = new Set<string>();
317
+ const stale: string[] = [];
318
+ let knownCount = 0;
319
+ let staleVersion = "";
320
+ let staleDir = "";
321
+
322
+ for (const shim of shims) {
323
+ const info = installVersionAt(shim);
324
+ // Group shims that live in the same install (ghfind + ghfind.cmd + ghfind.ps1)
325
+ const dirKey = norm(dirname(shim));
326
+ if (seenDirs.has(dirKey)) continue;
327
+ seenDirs.add(dirKey);
328
+ knownCount++;
329
+ const isRunningInstall = info !== null && norm(info.dir) === norm(PKG_DIR);
330
+ if (
331
+ !isRunningInstall &&
332
+ info !== null &&
333
+ running &&
334
+ info.version !== running
335
+ ) {
336
+ stale.push(`${dirname(shim)} (v${info.version} ≠ running v${running})`);
337
+ staleVersion = info.version;
338
+ staleDir = dirname(shim);
339
+ }
340
+ }
341
+
342
+ if (stale.length > 0 && knownCount > 1) {
343
+ // Shadowing needs ≥2 installs: the shell can keep launching an old copy
344
+ // while updates land in another prefix.
345
+ add(
346
+ "WARN",
347
+ "Global installs",
348
+ `${knownCount} install(s) on PATH; stale copies shadowing updates → ${stale.join("; ")}. Remove with: npm uninstall -g ${PKG_NAME} --prefix <dir>`,
349
+ );
350
+ } else if (stale.length > 0) {
351
+ // Single install whose version differs from the running copy — normal
352
+ // when developing from source (running vNEWER than the published one).
353
+ // Uninstalling it would be harmful advice.
354
+ add(
355
+ "PASS",
356
+ "Global installs",
357
+ `v${staleVersion} at ${staleDir} (running v${running || "?"} from source/build) — single install, nothing shadowed.`,
358
+ );
359
+ } else if (knownCount > 1) {
360
+ add(
361
+ "PASS",
362
+ "Global installs",
363
+ `${knownCount} install(s) on PATH, all v${running || "?"}.`,
364
+ );
365
+ } else {
366
+ add(
367
+ "PASS",
368
+ "Global installs",
369
+ `v${running || "?"} at ${dirname(shims[0])}.`,
370
+ );
371
+ }
372
+ }
373
+
239
374
  function printReport(): void {
240
375
  // Respect NO_COLOR (https://no-color.org/): plain text when set (any value,
241
376
  // including the empty string) or when stdout is not a TTY.
@@ -284,6 +419,7 @@ export async function runDoctor(): Promise<void> {
284
419
  checkConfig();
285
420
  checkGithubToken();
286
421
  checkDirs();
422
+ checkInstallShadowing();
287
423
  printReport();
288
424
 
289
425
  const hasFail = results.some((r) => r.status === "FAIL");
@@ -10,8 +10,8 @@ import {
10
10
  arch as osArch,
11
11
  version as nodeVersion,
12
12
  versions as nodeVersions,
13
- } from "process";
14
- import { spawn } from "child_process";
13
+ } from "node:process";
14
+ import { spawn } from "node:child_process";
15
15
  import { getVersion } from "./version";
16
16
 
17
17
  // ponytail: in bun, process.platform is a string property; in node, same.
@@ -43,6 +43,7 @@ export function formatErrorBody(
43
43
  context: { command: string; argv: string[] },
44
44
  ): string {
45
45
  const e = err instanceof Error ? err : new Error(String(err));
46
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: strips ANSI color escapes from stack traces
46
47
  const stack = (e.stack ?? "(no stack)").replace(/\u001b\[[0-9;]*m/g, "");
47
48
  const message = (e.message || "(no message)").slice(0, 500);
48
49
  const cmd = `${context.command} ${context.argv.join(" ")}`.trim();
@@ -105,14 +106,19 @@ function tryOpenUrl(url: string): boolean {
105
106
  try {
106
107
  const p = osPlatform;
107
108
  if (typeof Bun !== "undefined") {
108
- if (p === "win32") Bun.spawn(["cmd", "/c", "start", "", url]);
109
- else if (p === "darwin") Bun.spawn(["open", url]);
109
+ if (p === "win32") {
110
+ // explorer.exe, not `cmd /c start` — cmd splits the URL at the first
111
+ // unquoted `&` (query separator) and runs the rest as a command,
112
+ // truncating every pre-filled issue URL. See src/open-url.ts.
113
+ Bun.spawn(["explorer", url]);
114
+ } else if (p === "darwin") Bun.spawn(["open", url]);
110
115
  else Bun.spawn(["xdg-open", url]);
111
116
  return true;
112
117
  }
113
118
  // Node fallback — use child_process.spawn detached so it survives exit
114
- const cmd = p === "win32" ? "cmd" : p === "darwin" ? "open" : "xdg-open";
115
- const args = p === "win32" ? ["/c", "start", "", url] : [url];
119
+ const cmd =
120
+ p === "win32" ? "explorer" : p === "darwin" ? "open" : "xdg-open";
121
+ const args = [url];
116
122
  const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
117
123
  child.unref();
118
124
  return true;
@@ -142,11 +148,11 @@ export function reportError(
142
148
  console.error("");
143
149
  const opened = tryOpenUrl(url);
144
150
  if (opened) {
145
- console.error(" → Opened: " + url);
151
+ console.error(` → Opened: ${url}`);
146
152
  } else {
147
153
  console.error(" Copy this URL to file a bug report:");
148
154
  console.error("");
149
- console.error(" " + url);
155
+ console.error(` ${url}`);
150
156
  }
151
157
  console.error("");
152
158
  return url;
package/src/export.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Export — format repos as JSON, CSV, Markdown, or plain text.
3
3
  */
4
- import { writeFileSync } from "fs";
4
+ import { writeFileSync } from "node:fs";
5
5
  import type { Repo } from "./types";
6
6
 
7
7
  export type ExportFormat = "json" | "csv" | "markdown" | "text";
package/src/help.ts CHANGED
@@ -74,7 +74,7 @@ export function buildHelpSections(): HelpSection[] {
74
74
  { keys: "Limit", action: "Cycle limit: 10 → 25 → 50 → 100" },
75
75
  { keys: "Refresh", action: "Re-run current search" },
76
76
  { keys: "Graph", action: "Toggle commit activity chart" },
77
- { keys: "Readme", action: "Full README viewer" },
77
+ { keys: "Readme", action: "Full markdown README viewer" },
78
78
  { keys: "Deep-dive", action: "Languages + contributors + excerpt" },
79
79
  { keys: "Bookmark", action: "Save / unsave selected repo" },
80
80
  { keys: "Compare", action: "Add to / remove from comparison" },
@@ -114,6 +114,7 @@ export function buildHelpSections(): HelpSection[] {
114
114
  { keys: "d", action: "Delete entry (history, saved)" },
115
115
  { keys: "↑ ↓ / j k", action: "Navigate overlay list" },
116
116
  { keys: "← → / h l", action: "Navigate tabs / panels" },
117
+ { keys: "i", action: "Toggle inline images (README viewer)" },
117
118
  {
118
119
  keys: "PgUp/PgDn",
119
120
  action: "Scroll update notes; Home/End jump to ends",
package/src/history.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Persistent search history — append-only JSONL with rotation.
3
3
  */
4
4
  import type { HistoryEntry } from "./types";
5
- import { readJSONL, appendJSONL, writeJSON, writeRaw } from "./storage";
5
+ import { readJSONL, appendJSONL, writeRaw } from "./storage";
6
6
 
7
7
  const HISTORY_FILE = "history.jsonl";
8
8
  const MAX_ENTRIES = 500;
@@ -37,10 +37,10 @@ export function deleteHistoryEntry(index: number): void {
37
37
  );
38
38
  writeRaw(
39
39
  HISTORY_FILE,
40
- remaining
40
+ `${remaining
41
41
  .reverse()
42
42
  .map((e) => JSON.stringify(e))
43
- .join("\n") + "\n",
43
+ .join("\n")}\n`,
44
44
  );
45
45
  }
46
46
 
@@ -56,10 +56,10 @@ export function rotateHistory(): void {
56
56
  const trimmed = entries.slice(0, MAX_ENTRIES);
57
57
  writeRaw(
58
58
  HISTORY_FILE,
59
- trimmed
59
+ `${trimmed
60
60
  .reverse()
61
61
  .map((e) => JSON.stringify(e))
62
- .join("\n") + "\n",
62
+ .join("\n")}\n`,
63
63
  );
64
64
  }
65
65