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/CHANGELOG.md +63 -0
- package/README.md +17 -16
- package/bin/ghfind.js +5 -5
- package/dist/cli.js +1068 -342
- package/package.json +9 -3
- package/release-notes/9.7.1.md +62 -0
- package/release-notes/9.8.0.md +55 -0
- package/scripts/build.ts +1 -1
- package/scripts/postinstall.js +8 -8
- package/src/cli.ts +21 -13
- package/src/compare.ts +5 -5
- package/src/config.ts +3 -3
- package/src/deepdive.ts +0 -1
- package/src/doctor.ts +144 -8
- package/src/error-report.ts +14 -8
- package/src/export.ts +1 -1
- package/src/help.ts +2 -1
- package/src/history.ts +5 -5
- package/src/image-loader.ts +248 -0
- package/src/image-render.ts +209 -0
- package/src/init.ts +2 -2
- package/src/login.ts +4 -4
- package/src/markdown-images.ts +123 -0
- package/src/markdown-view.ts +300 -0
- package/src/mcp-server.ts +4 -3
- package/src/open-url.ts +16 -5
- package/src/output.ts +1 -1
- package/src/package-browser.ts +1 -1
- package/src/png.ts +260 -0
- package/src/releases.ts +1 -14
- package/src/search-state.ts +1 -4
- package/src/search.ts +3 -4
- package/src/share.ts +2 -2
- package/src/storage.ts +4 -4
- package/src/themes.ts +1 -0
- package/src/trending-parser.ts +3 -4
- package/src/trending.ts +7 -7
- package/src/tui.ts +115 -83
- package/src/update-check.ts +96 -34
- package/src/version.ts +3 -3
- package/src/watch.ts +1 -1
- package/src/markdown-render.ts +0 -211
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "github-search-cli",
|
|
3
|
-
"version": "9.
|
|
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": "
|
|
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
package/scripts/postinstall.js
CHANGED
|
@@ -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
|
|
125
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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(
|
|
65
|
+
lines.push(`│${cells.map((c) => ` ${c} `).join("│")}│`);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
lines.push(
|
|
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
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, {
|
|
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
|
|
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
|
|
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");
|
package/src/error-report.ts
CHANGED
|
@@ -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")
|
|
109
|
-
|
|
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 =
|
|
115
|
-
|
|
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(
|
|
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(
|
|
155
|
+
console.error(` ${url}`);
|
|
150
156
|
}
|
|
151
157
|
console.error("");
|
|
152
158
|
return url;
|
package/src/export.ts
CHANGED
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,
|
|
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")
|
|
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")
|
|
62
|
+
.join("\n")}\n`,
|
|
63
63
|
);
|
|
64
64
|
}
|
|
65
65
|
|