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/CHANGELOG.md +71 -0
- package/README.md +32 -26
- package/bin/ghfind.js +5 -5
- package/completions/ghfind.bash +5 -1
- package/completions/ghfind.fish +2 -1
- package/completions/ghfind.zsh +3 -0
- package/dist/cli.js +617 -258
- package/package.json +3 -2
- package/release-notes/9.7.0.md +53 -0
- package/release-notes/9.7.1.md +62 -0
- package/scripts/build.ts +1 -1
- package/scripts/postinstall.js +8 -8
- package/src/agent-skill.ts +7 -6
- package/src/cli.ts +139 -16
- package/src/compare.ts +81 -6
- package/src/config.ts +3 -3
- package/src/deepdive.ts +195 -24
- package/src/doctor.ts +144 -8
- package/src/error-report.ts +14 -8
- package/src/export.ts +1 -1
- package/src/history.ts +5 -5
- package/src/init.ts +2 -2
- package/src/login.ts +4 -4
- package/src/markdown-render.ts +13 -13
- package/src/mcp-server.ts +30 -58
- package/src/open-url.ts +16 -5
- package/src/output.ts +1 -1
- package/src/package-browser.ts +1 -1
- package/src/releases.ts +1 -14
- package/src/search-state.ts +1 -4
- package/src/search.ts +22 -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 +38 -42
- package/src/update-check.ts +96 -34
- package/src/version.ts +3 -3
- package/src/watch.ts +1 -1
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
|
|
|
@@ -10,6 +9,119 @@ function buildHeaders(token?: string): Record<string, string> {
|
|
|
10
9
|
return headers;
|
|
11
10
|
}
|
|
12
11
|
|
|
12
|
+
// ─── Repo ref parsing (shared by the CLI deep-dive and the MCP server) ─
|
|
13
|
+
|
|
14
|
+
const REPO_REF_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse a repo reference ("owner/name") into its parts. Tolerates a trailing
|
|
18
|
+
* `.git` and a leading `@`. Throws with a user-facing message for anything else.
|
|
19
|
+
*/
|
|
20
|
+
export function parseRepoRef(input: string): { owner: string; name: string } {
|
|
21
|
+
const fullName = input
|
|
22
|
+
.trim()
|
|
23
|
+
.replace(/\.git$/, "")
|
|
24
|
+
.replace(/^@/, "");
|
|
25
|
+
if (!REPO_REF_RE.test(fullName)) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`"${input}" is not a repo name — use owner/name, e.g. facebook/react.`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
const [owner, name] = fullName.split("/");
|
|
31
|
+
return { owner, name };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Minimal Repo for deep-dive (the fetchers only read owner/name). */
|
|
35
|
+
export function repoFromRef(fullName: string): Repo {
|
|
36
|
+
const { owner, name } = parseRepoRef(fullName);
|
|
37
|
+
return {
|
|
38
|
+
id: 0,
|
|
39
|
+
fullName: `${owner}/${name}`,
|
|
40
|
+
name,
|
|
41
|
+
owner,
|
|
42
|
+
description: null,
|
|
43
|
+
url: `https://github.com/${owner}/${name}`,
|
|
44
|
+
stars: 0,
|
|
45
|
+
forks: 0,
|
|
46
|
+
watchers: 0,
|
|
47
|
+
language: null,
|
|
48
|
+
topics: [],
|
|
49
|
+
archived: false,
|
|
50
|
+
isFork: false,
|
|
51
|
+
private: false,
|
|
52
|
+
createdAt: "",
|
|
53
|
+
updatedAt: "",
|
|
54
|
+
pushedAt: "",
|
|
55
|
+
score: 0,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve a repo ref to a real Repo by fetching canonical metadata from the
|
|
61
|
+
* GitHub API. Returns a stub via repoFromRef when the metadata fetch fails
|
|
62
|
+
* (offline, rate limit) — the ref is still validated first. Deep-dive
|
|
63
|
+
* sub-fetches tolerate missing data either way.
|
|
64
|
+
*/
|
|
65
|
+
export async function resolveRepoFromRef(
|
|
66
|
+
fullName: string,
|
|
67
|
+
token?: string,
|
|
68
|
+
): Promise<Repo> {
|
|
69
|
+
const ref = parseRepoRef(fullName); // throws for malformed refs
|
|
70
|
+
const canonical = `${ref.owner}/${ref.name}`;
|
|
71
|
+
try {
|
|
72
|
+
const res = await fetch(`https://api.github.com/repos/${canonical}`, {
|
|
73
|
+
headers: buildHeaders(token),
|
|
74
|
+
});
|
|
75
|
+
if (res.status === 404) {
|
|
76
|
+
throw new Error(`Repo not found: ${canonical}`);
|
|
77
|
+
}
|
|
78
|
+
if (res.ok) {
|
|
79
|
+
const api = (await res.json()) as {
|
|
80
|
+
description: string | null;
|
|
81
|
+
html_url: string;
|
|
82
|
+
stargazers_count: number;
|
|
83
|
+
forks_count: number;
|
|
84
|
+
subscribers_count?: number;
|
|
85
|
+
language: string | null;
|
|
86
|
+
topics?: string[];
|
|
87
|
+
archived: boolean;
|
|
88
|
+
fork: boolean;
|
|
89
|
+
created_at: string;
|
|
90
|
+
updated_at: string;
|
|
91
|
+
pushed_at: string;
|
|
92
|
+
};
|
|
93
|
+
return {
|
|
94
|
+
id: 0,
|
|
95
|
+
fullName: canonical,
|
|
96
|
+
name: ref.name,
|
|
97
|
+
owner: ref.owner,
|
|
98
|
+
description: api.description,
|
|
99
|
+
url: api.html_url,
|
|
100
|
+
stars: api.stargazers_count ?? 0,
|
|
101
|
+
forks: api.forks_count ?? 0,
|
|
102
|
+
watchers: api.subscribers_count ?? 0,
|
|
103
|
+
language: api.language,
|
|
104
|
+
topics: api.topics ?? [],
|
|
105
|
+
archived: api.archived ?? false,
|
|
106
|
+
isFork: api.fork ?? false,
|
|
107
|
+
private: false,
|
|
108
|
+
createdAt: api.created_at ?? "",
|
|
109
|
+
updatedAt: api.updated_at ?? "",
|
|
110
|
+
pushedAt: api.pushed_at ?? "",
|
|
111
|
+
score: 0,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
// Let the not-found error propagate; only network/parse failures degrade
|
|
116
|
+
// to the stub below.
|
|
117
|
+
if (err instanceof Error && err.message.startsWith("Repo not found")) {
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
// Fall through to the stub.
|
|
121
|
+
}
|
|
122
|
+
return repoFromRef(canonical);
|
|
123
|
+
}
|
|
124
|
+
|
|
13
125
|
/** Language breakdown: { [lang]: bytes } */
|
|
14
126
|
type LanguageMap = Record<string, number>;
|
|
15
127
|
|
|
@@ -27,11 +139,31 @@ export interface DeepDiveData {
|
|
|
27
139
|
readme: string;
|
|
28
140
|
}
|
|
29
141
|
|
|
30
|
-
/**
|
|
31
|
-
export
|
|
142
|
+
/** Structured deep-dive payload — JSON-ready (for `ghfind deep-dive --json`). */
|
|
143
|
+
export interface DeepDiveJson {
|
|
144
|
+
repo: {
|
|
145
|
+
fullName: string;
|
|
146
|
+
url: string;
|
|
147
|
+
description: string | null;
|
|
148
|
+
stars: number;
|
|
149
|
+
forks: number;
|
|
150
|
+
language: string | null;
|
|
151
|
+
topics: string[];
|
|
152
|
+
};
|
|
153
|
+
/** Language breakdown sorted by bytes, descending. */
|
|
154
|
+
languages: { name: string; bytes: number; percent: number }[];
|
|
155
|
+
contributors: { login: string; contributions: number }[];
|
|
156
|
+
/** Raw README markdown (capped at README_MAX_CHARS). */
|
|
157
|
+
readme: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const README_MAX_CHARS = 5_000;
|
|
161
|
+
|
|
162
|
+
/** Fetch deep-dive data as structured JSON: language percents, contributors, raw README. */
|
|
163
|
+
export async function fetchDeepDiveRaw(
|
|
32
164
|
repo: Repo,
|
|
33
165
|
token?: string,
|
|
34
|
-
): Promise<
|
|
166
|
+
): Promise<DeepDiveJson> {
|
|
35
167
|
const headers = buildHeaders(token);
|
|
36
168
|
|
|
37
169
|
const [languagesRes, contributorsRes] = await Promise.all([
|
|
@@ -60,22 +192,64 @@ export async function fetchDeepDive(
|
|
|
60
192
|
}
|
|
61
193
|
}
|
|
62
194
|
|
|
195
|
+
const langEntries: [string, number][] = languagesRes.ok
|
|
196
|
+
? Object.entries(
|
|
197
|
+
(await languagesRes.json().catch(() => ({}))) as LanguageMap,
|
|
198
|
+
)
|
|
199
|
+
: [];
|
|
200
|
+
const totalBytes = langEntries.reduce((s, [, v]) => s + v, 0);
|
|
201
|
+
const contributors: Contributor[] = contributorsRes.ok
|
|
202
|
+
? ((await contributorsRes.json().catch(() => [])) as Contributor[])
|
|
203
|
+
: [];
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
repo: {
|
|
207
|
+
fullName: repo.fullName,
|
|
208
|
+
url: repo.url,
|
|
209
|
+
description: repo.description,
|
|
210
|
+
stars: repo.stars,
|
|
211
|
+
forks: repo.forks,
|
|
212
|
+
language: repo.language,
|
|
213
|
+
topics: repo.topics,
|
|
214
|
+
},
|
|
215
|
+
languages: langEntries
|
|
216
|
+
.sort(([, a], [, b]) => b - a)
|
|
217
|
+
.map(([name, bytes]) => ({
|
|
218
|
+
name,
|
|
219
|
+
bytes,
|
|
220
|
+
percent: totalBytes > 0 ? (bytes / totalBytes) * 100 : 0,
|
|
221
|
+
})),
|
|
222
|
+
contributors: contributors.map((c) => ({
|
|
223
|
+
login: c.login,
|
|
224
|
+
contributions: c.contributions,
|
|
225
|
+
})),
|
|
226
|
+
readme: readmeText.slice(0, README_MAX_CHARS),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** JSON string for `ghfind deep-dive --json`. */
|
|
231
|
+
export function buildDeepDiveJson(data: DeepDiveJson): string {
|
|
232
|
+
return JSON.stringify(data, null, 2);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Format structured deep-dive data into display sections. */
|
|
236
|
+
export function buildDeepDiveData(repo: Repo, raw: DeepDiveJson): DeepDiveData {
|
|
63
237
|
return {
|
|
64
238
|
summary: formatSummary(repo),
|
|
65
|
-
languages: formatLanguages(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
: {},
|
|
69
|
-
),
|
|
70
|
-
contributors: formatContributors(
|
|
71
|
-
contributorsRes.ok
|
|
72
|
-
? ((await contributorsRes.json().catch(() => [])) as Contributor[])
|
|
73
|
-
: [],
|
|
74
|
-
),
|
|
75
|
-
readme: formatReadme(readmeText),
|
|
239
|
+
languages: formatLanguages(raw.languages),
|
|
240
|
+
contributors: formatContributors(raw.contributors),
|
|
241
|
+
readme: formatReadme(raw.readme),
|
|
76
242
|
};
|
|
77
243
|
}
|
|
78
244
|
|
|
245
|
+
/** Fetch all deep-dive data for a repo. Returns formatted sections. */
|
|
246
|
+
export async function fetchDeepDive(
|
|
247
|
+
repo: Repo,
|
|
248
|
+
token?: string,
|
|
249
|
+
): Promise<DeepDiveData> {
|
|
250
|
+
return buildDeepDiveData(repo, await fetchDeepDiveRaw(repo, token));
|
|
251
|
+
}
|
|
252
|
+
|
|
79
253
|
function formatSummary(repo: Repo): string {
|
|
80
254
|
const lines = [
|
|
81
255
|
` ${repo.fullName}`,
|
|
@@ -87,17 +261,14 @@ function formatSummary(repo: Repo): string {
|
|
|
87
261
|
return lines.filter(Boolean).join("\n");
|
|
88
262
|
}
|
|
89
263
|
|
|
90
|
-
function formatLanguages(langs:
|
|
91
|
-
|
|
92
|
-
if (entries.length === 0) return " (no language data)";
|
|
93
|
-
const total = entries.reduce((s, [, v]) => s + v, 0);
|
|
264
|
+
function formatLanguages(langs: DeepDiveJson["languages"]): string {
|
|
265
|
+
if (langs.length === 0) return " (no language data)";
|
|
94
266
|
const barW = 20;
|
|
95
|
-
return
|
|
96
|
-
.sort(([, a], [, b]) => b - a)
|
|
267
|
+
return langs
|
|
97
268
|
.slice(0, 8)
|
|
98
|
-
.map((
|
|
99
|
-
const pct =
|
|
100
|
-
const filled = Math.round((
|
|
269
|
+
.map(({ name, percent }) => {
|
|
270
|
+
const pct = percent.toFixed(1);
|
|
271
|
+
const filled = Math.round((percent / 100) * barW);
|
|
101
272
|
const bar = "█".repeat(filled) + "░".repeat(barW - filled);
|
|
102
273
|
return ` ${name.padEnd(16)} ${bar} ${pct}%`;
|
|
103
274
|
})
|
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/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
|
|
package/src/init.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* ponytail: simple stdin prompts, no curses/readline UI.
|
|
5
5
|
*/
|
|
6
|
-
import { createInterface } from "readline";
|
|
6
|
+
import { createInterface } from "node:readline";
|
|
7
7
|
import { saveConfig } from "./config";
|
|
8
8
|
import { isLikelyGithubToken } from "./login";
|
|
9
9
|
import { listThemes } from "./themes";
|
|
@@ -44,7 +44,7 @@ export async function runInitWizard(): Promise<void> {
|
|
|
44
44
|
defaultSort: (["best-match", "stars", "updated", "forks"].includes(sortStr)
|
|
45
45
|
? sortStr
|
|
46
46
|
: "best-match") as SortStrategy,
|
|
47
|
-
defaultLimit: Math.min(100, Math.max(10, parseInt(limitStr) || 50)),
|
|
47
|
+
defaultLimit: Math.min(100, Math.max(10, parseInt(limitStr, 10) || 50)),
|
|
48
48
|
theme: themes.includes(themeStr) ? themeStr : themes[0],
|
|
49
49
|
cacheTtlSeconds: 300,
|
|
50
50
|
defaultTab: "search",
|
package/src/login.ts
CHANGED
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
* used by `ghfind init` (~/.config/ghfind/config.json). `GITHUB_TOKEN` in the
|
|
7
7
|
* environment always takes precedence at runtime (see src/config.ts).
|
|
8
8
|
*/
|
|
9
|
-
import { createInterface } from "readline";
|
|
10
|
-
import { homedir } from "os";
|
|
11
|
-
import { join } from "path";
|
|
12
|
-
import { existsSync, readFileSync } from "fs";
|
|
9
|
+
import { createInterface } from "node:readline";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
13
|
import { loadConfig, saveConfig, configPath } from "./config";
|
|
14
14
|
import type { Config } from "./types";
|
|
15
15
|
|
package/src/markdown-render.ts
CHANGED
|
@@ -26,12 +26,12 @@ export function renderMarkdown(md: string, opts: RenderOptions = {}): string {
|
|
|
26
26
|
if (codeLines.length === 0) return;
|
|
27
27
|
const maxLen = Math.max(...codeLines.map((l) => l.length), codeLang.length);
|
|
28
28
|
const barW = Math.min(maxLen + 4, width - 2);
|
|
29
|
-
out.push("
|
|
30
|
-
if (codeLang) out.push(
|
|
29
|
+
out.push(`┌${"─".repeat(barW)}┐`);
|
|
30
|
+
if (codeLang) out.push(`│ ${codeLang.padEnd(barW - 1)}│`);
|
|
31
31
|
for (const cl of codeLines) {
|
|
32
|
-
out.push(
|
|
32
|
+
out.push(`│ ${cl.padEnd(barW - 1)}│`);
|
|
33
33
|
}
|
|
34
|
-
out.push("
|
|
34
|
+
out.push(`└${"─".repeat(barW)}┘`);
|
|
35
35
|
out.push("");
|
|
36
36
|
codeLines = [];
|
|
37
37
|
codeLang = "";
|
|
@@ -54,16 +54,16 @@ export function renderMarkdown(md: string, opts: RenderOptions = {}): string {
|
|
|
54
54
|
const totalW = colW.reduce((a, b) => a + b, 0) + colCount * 3 - 1;
|
|
55
55
|
if (totalW > width) {
|
|
56
56
|
for (const row of dataRows) {
|
|
57
|
-
out.push(
|
|
57
|
+
out.push(` ${row.map((c) => c.trim()).join(" │ ")}`);
|
|
58
58
|
}
|
|
59
59
|
} else {
|
|
60
|
-
const sep =
|
|
61
|
-
const mid =
|
|
62
|
-
const end =
|
|
60
|
+
const sep = `┌${colW.map((w) => "─".repeat(w + 2)).join("┬")}┐`;
|
|
61
|
+
const mid = `├${colW.map((w) => "─".repeat(w + 2)).join("┼")}┤`;
|
|
62
|
+
const end = `└${colW.map((w) => "─".repeat(w + 2)).join("┴")}┘`;
|
|
63
63
|
out.push(sep);
|
|
64
64
|
dataRows.forEach((row, i) => {
|
|
65
65
|
const cells = colW.map((w, ci) => (row[ci] ?? "").trim().padEnd(w));
|
|
66
|
-
out.push(
|
|
66
|
+
out.push(`│ ${cells.map((c) => ` ${c} `).join("│")}│`);
|
|
67
67
|
if (i === 0) out.push(mid);
|
|
68
68
|
});
|
|
69
69
|
out.push(end);
|
|
@@ -102,7 +102,7 @@ export function renderMarkdown(md: string, opts: RenderOptions = {}): string {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
for (let i = 0; i < lines.length; i++) {
|
|
105
|
-
|
|
105
|
+
const line = lines[i];
|
|
106
106
|
|
|
107
107
|
// Code block boundaries
|
|
108
108
|
if (/^```/.test(line)) {
|
|
@@ -160,7 +160,7 @@ export function renderMarkdown(md: string, opts: RenderOptions = {}): string {
|
|
|
160
160
|
if (bqMatch) {
|
|
161
161
|
const inner = renderInline(bqMatch[1]);
|
|
162
162
|
for (const wl of wrapLine(inner, width - 4)) {
|
|
163
|
-
out.push(
|
|
163
|
+
out.push(`│ ${wl}`);
|
|
164
164
|
}
|
|
165
165
|
continue;
|
|
166
166
|
}
|
|
@@ -170,7 +170,7 @@ export function renderMarkdown(md: string, opts: RenderOptions = {}): string {
|
|
|
170
170
|
if (ulMatch) {
|
|
171
171
|
const indent = ulMatch[1].length;
|
|
172
172
|
const depth = Math.floor(indent / 2);
|
|
173
|
-
const prefix = " ".repeat(depth)
|
|
173
|
+
const prefix = `${" ".repeat(depth)}• `;
|
|
174
174
|
const inner = renderInline(ulMatch[2]);
|
|
175
175
|
for (const wl of wrapLine(inner, width - prefix.length)) {
|
|
176
176
|
out.push(prefix + wl);
|
|
@@ -183,7 +183,7 @@ export function renderMarkdown(md: string, opts: RenderOptions = {}): string {
|
|
|
183
183
|
if (olMatch) {
|
|
184
184
|
const indent = olMatch[1].length;
|
|
185
185
|
const depth = Math.floor(indent / 2);
|
|
186
|
-
const prefix = " ".repeat(depth)
|
|
186
|
+
const prefix = `${" ".repeat(depth)}◦ `;
|
|
187
187
|
const inner = renderInline(olMatch[2]);
|
|
188
188
|
for (const wl of wrapLine(inner, width - prefix.length)) {
|
|
189
189
|
out.push(prefix + wl);
|