github-search-cli 9.8.1 → 9.8.2
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/LICENSE +21 -21
- package/README.md +11 -8
- package/completions/ghfind.bash +55 -55
- package/completions/ghfind.fish +28 -28
- package/completions/ghfind.zsh +52 -52
- package/demo.svg +71 -71
- package/dist/cli.js +681 -557
- package/package.json +1 -1
- package/release-notes/9.8.2.md +65 -0
- package/scripts/brew.rb +53 -53
- package/scripts/postinstall.js +38 -0
- package/src/cli.ts +40 -22
- package/src/config.ts +12 -2
- package/src/deepdive.ts +70 -66
- package/src/doctor.ts +3 -2
- package/src/error-report.ts +81 -11
- package/src/errors.ts +0 -1
- package/src/history.ts +2 -5
- package/src/init.ts +1 -2
- package/src/mcp-server.ts +5 -10
- package/src/notifications.ts +9 -3
- package/src/org.ts +15 -14
- package/src/output.ts +1 -13
- package/src/readme.ts +19 -3
- package/src/releases.ts +29 -27
- package/src/search.ts +4 -0
- package/src/skill-finder.ts +33 -2
- package/src/storage.ts +22 -47
- package/src/tui.ts +9 -11
- package/src/user.ts +15 -14
- package/dist/chunk-GYIL4S5X.js +0 -59
- package/dist/config-OCIPF2IF.js +0 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "github-search-cli",
|
|
3
|
-
"version": "9.8.
|
|
3
|
+
"version": "9.8.2",
|
|
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",
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# v9.8.2
|
|
2
|
+
|
|
3
|
+
A maintenance release: twenty-three fixes from a full static audit of the
|
|
4
|
+
codebase, most of them invisible until something went wrong. Nothing user-facing
|
|
5
|
+
was added or removed — every flag, key and command behaves as it did in 9.8.1.
|
|
6
|
+
|
|
7
|
+
## The one that mattered
|
|
8
|
+
|
|
9
|
+
A GitHub token passed as `--token` could end up in a public URL. When ghfind
|
|
10
|
+
crashed, it built a GitHub issue link straight from `process.argv` — argv
|
|
11
|
+
included your token — and opened it in a browser. The docblock claimed the
|
|
12
|
+
command line was redacted; it was not. Every field that reaches the issue now
|
|
13
|
+
goes through redaction first, and when ghfind sees a credential it prints the
|
|
14
|
+
URL for you to copy instead of opening a browser tab automatically.
|
|
15
|
+
|
|
16
|
+
## Security, in full
|
|
17
|
+
|
|
18
|
+
- Tokens are no longer sent to `raw.githubusercontent.com` when fetching a
|
|
19
|
+
README — only to `api.github.com`, and only for a private repository.
|
|
20
|
+
- Skill sources and ids from the public skills.sh registry are validated before
|
|
21
|
+
they can reach a shell command or a URL, so a hostile registry entry cannot
|
|
22
|
+
smuggle `;`, `$(`, backticks or a path traversal into `npx skills add`.
|
|
23
|
+
- The config file that stores your token is now written with `0600`
|
|
24
|
+
permissions. On Windows the file still inherits the directory ACL, so this
|
|
25
|
+
closes the hole on POSIX only.
|
|
26
|
+
- `--doctor` masks the token rather than printing a raw prefix.
|
|
27
|
+
|
|
28
|
+
## Reliability
|
|
29
|
+
|
|
30
|
+
The most user-visible correctness fix: recoverable errors were treated as
|
|
31
|
+
crashes. The top-level catch tested `err.name === "SearchCliError"`, a string
|
|
32
|
+
that is never true, so a rate limit, a network blip or a layout change on
|
|
33
|
+
GitHub's trending page all opened a browser tab asking you to file a bug. That
|
|
34
|
+
branch now works, and those errors print a short explanation and exit.
|
|
35
|
+
|
|
36
|
+
`--compare` and the `ghfind_compare_repos` MCP tool both resolved repository
|
|
37
|
+
names through a free-text search with no `repo:` qualifier and never checked
|
|
38
|
+
the `fullName` that came back, so `ghfind --compare oven/bun oven-sh/bun` could
|
|
39
|
+
quietly compare the wrong two repositories. Both now resolve exactly.
|
|
40
|
+
|
|
41
|
+
State files — bookmarks, history, sessions, saved searches, notifications —
|
|
42
|
+
were written directly to their live path. A crash, a full disk, or a reader
|
|
43
|
+
caught mid-write left a truncated JSON file, which the loader then silently
|
|
44
|
+
replaced with defaults: your data was gone with no error. Writes are now atomic
|
|
45
|
+
(temp file plus rename).
|
|
46
|
+
|
|
47
|
+
## Smaller fixes
|
|
48
|
+
|
|
49
|
+
Notification IDs restarted after "dismiss all"; `history --delete` removed
|
|
50
|
+
every duplicate entry instead of one; an invalid `--sort` value degraded
|
|
51
|
+
silently instead of erroring; a failed "load more" in the TUI wiped the results
|
|
52
|
+
already on screen; `--markdown` did not escape `|` in cells while `--csv`
|
|
53
|
+
did; the in-memory cache was not a real LRU; local skill scanning re-walked the
|
|
54
|
+
filesystem on every keystroke; and skill ids containing `_` — common on GitHub
|
|
55
|
+
— were wrongly rejected.
|
|
56
|
+
|
|
57
|
+
## Verification
|
|
58
|
+
|
|
59
|
+
482 tests, plus `typecheck`, `lint`, `format:check` and `build` all green.
|
|
60
|
+
The security fixes were additionally verified with runnable probes that assert
|
|
61
|
+
no token substring survives URL encoding in the generated crash-report URL, that
|
|
62
|
+
none of twelve `raw.githubusercontent.com` requests carries an `Authorization`
|
|
63
|
+
header, and that seven of eight hostile skill payloads are rejected.
|
|
64
|
+
|
|
65
|
+
`README.md` test count updated to match the real suite.
|
package/scripts/brew.rb
CHANGED
|
@@ -1,53 +1,53 @@
|
|
|
1
|
-
# typed: true
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
# Homebrew formula for ghfind.
|
|
5
|
-
#
|
|
6
|
-
# NOTE: this file is a template, kept in-tree for a future homebrew tap.
|
|
7
|
-
# Before use:
|
|
8
|
-
# 1. Replace VERSION with the released version tag.
|
|
9
|
-
# 2. Fill SHA256 placeholders from the SHA256SUMS.txt asset attached to
|
|
10
|
-
# the GitHub Release (the release workflow generates it).
|
|
11
|
-
# 3. Host it in a tap repo (e.g. codersdfs/tap) at Formula/ghfind.rb,
|
|
12
|
-
# then: brew install codersdfs/tap/ghfind
|
|
13
|
-
# See marketing/README.md (Track B) for the rollout order.
|
|
14
|
-
|
|
15
|
-
class Ghfind < Formula
|
|
16
|
-
desc "Search GitHub repos from your terminal — trending, bookmarks, deep-dive"
|
|
17
|
-
homepage "https://github.com/codersdfs/search-cli"
|
|
18
|
-
version "9.5.1"
|
|
19
|
-
license "MIT"
|
|
20
|
-
|
|
21
|
-
livecheck do
|
|
22
|
-
url :stable
|
|
23
|
-
regex(/^v?(\d+(?:\.\d+)+)$/i)
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
on_macos do
|
|
27
|
-
if Hardware::CPU.arm?
|
|
28
|
-
url "https://github.com/codersdfs/search-cli/releases/download/v#{version}/ghfind-darwin-arm64"
|
|
29
|
-
sha256 "SHA256_PLACEHOLDER"
|
|
30
|
-
else
|
|
31
|
-
url "https://github.com/codersdfs/search-cli/releases/download/v#{version}/ghfind-darwin-x64"
|
|
32
|
-
sha256 "SHA256_PLACEHOLDER"
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
on_linux do
|
|
37
|
-
if Hardware::CPU.arm? && Hardware::CPU.is_64_bit?
|
|
38
|
-
url "https://github.com/codersdfs/search-cli/releases/download/v#{version}/ghfind-linux-arm64"
|
|
39
|
-
sha256 "SHA256_PLACEHOLDER"
|
|
40
|
-
else
|
|
41
|
-
url "https://github.com/codersdfs/search-cli/releases/download/v#{version}/ghfind-linux-x64"
|
|
42
|
-
sha256 "SHA256_PLACEHOLDER"
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def install
|
|
47
|
-
bin.install "ghfind" => "ghfind"
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
test do
|
|
51
|
-
assert_match version.to_s, shell_output("#{bin}/ghfind --version")
|
|
52
|
-
end
|
|
53
|
-
end
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Homebrew formula for ghfind.
|
|
5
|
+
#
|
|
6
|
+
# NOTE: this file is a template, kept in-tree for a future homebrew tap.
|
|
7
|
+
# Before use:
|
|
8
|
+
# 1. Replace VERSION with the released version tag.
|
|
9
|
+
# 2. Fill SHA256 placeholders from the SHA256SUMS.txt asset attached to
|
|
10
|
+
# the GitHub Release (the release workflow generates it).
|
|
11
|
+
# 3. Host it in a tap repo (e.g. codersdfs/tap) at Formula/ghfind.rb,
|
|
12
|
+
# then: brew install codersdfs/tap/ghfind
|
|
13
|
+
# See marketing/README.md (Track B) for the rollout order.
|
|
14
|
+
|
|
15
|
+
class Ghfind < Formula
|
|
16
|
+
desc "Search GitHub repos from your terminal — trending, bookmarks, deep-dive"
|
|
17
|
+
homepage "https://github.com/codersdfs/search-cli"
|
|
18
|
+
version "9.5.1"
|
|
19
|
+
license "MIT"
|
|
20
|
+
|
|
21
|
+
livecheck do
|
|
22
|
+
url :stable
|
|
23
|
+
regex(/^v?(\d+(?:\.\d+)+)$/i)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
on_macos do
|
|
27
|
+
if Hardware::CPU.arm?
|
|
28
|
+
url "https://github.com/codersdfs/search-cli/releases/download/v#{version}/ghfind-darwin-arm64"
|
|
29
|
+
sha256 "SHA256_PLACEHOLDER"
|
|
30
|
+
else
|
|
31
|
+
url "https://github.com/codersdfs/search-cli/releases/download/v#{version}/ghfind-darwin-x64"
|
|
32
|
+
sha256 "SHA256_PLACEHOLDER"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
on_linux do
|
|
37
|
+
if Hardware::CPU.arm? && Hardware::CPU.is_64_bit?
|
|
38
|
+
url "https://github.com/codersdfs/search-cli/releases/download/v#{version}/ghfind-linux-arm64"
|
|
39
|
+
sha256 "SHA256_PLACEHOLDER"
|
|
40
|
+
else
|
|
41
|
+
url "https://github.com/codersdfs/search-cli/releases/download/v#{version}/ghfind-linux-x64"
|
|
42
|
+
sha256 "SHA256_PLACEHOLDER"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def install
|
|
47
|
+
bin.install "ghfind" => "ghfind"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
test do
|
|
51
|
+
assert_match version.to_s, shell_output("#{bin}/ghfind --version")
|
|
52
|
+
end
|
|
53
|
+
end
|
package/scripts/postinstall.js
CHANGED
|
@@ -18,6 +18,7 @@ import { join, dirname } from "node:path";
|
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
import { platform, arch } from "node:os";
|
|
20
20
|
import { spawnSync } from "node:child_process";
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
21
22
|
import { inflateRawSync } from "node:zlib";
|
|
22
23
|
|
|
23
24
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -96,6 +97,35 @@ const key = `${platform()}-${arch}`;
|
|
|
96
97
|
const assetName = map[key];
|
|
97
98
|
const version = "1.3.14";
|
|
98
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Pinned SHA256 of every Bun release asset this script can install, taken
|
|
102
|
+
* from that release's own SHASUMS256.txt. The download is verified before
|
|
103
|
+
* anything is written, so a tampered or truncated archive can never become
|
|
104
|
+
* an executable in vendor/.
|
|
105
|
+
*
|
|
106
|
+
* ponytail: hand-pinned per Bun release — regenerate this table (and bump
|
|
107
|
+
* `version`) whenever the release is bumped; the check is what makes the
|
|
108
|
+
* hardcoded `version` above trustworthy.
|
|
109
|
+
*/
|
|
110
|
+
const SHA256 = {
|
|
111
|
+
"bun-windows-x64.zip":
|
|
112
|
+
"0a0620930b6675d7ba440e81f4e0e00d3cfbe096c4b140d3fff02205e9e18922",
|
|
113
|
+
"bun-windows-aarch64.zip":
|
|
114
|
+
"89841f5a57f2348b67ec0839b718f4bf4ea7d07c371c9ba4b77b6c790f918953",
|
|
115
|
+
"bun-darwin-x64.zip":
|
|
116
|
+
"4183df3374623e5bab315c547cfa0974533cd457d86b73b639f7a87974cd6633",
|
|
117
|
+
"bun-darwin-aarch64.zip":
|
|
118
|
+
"d8b96221828ad6f97ac7ac0ab7e95872341af763001e8803e8267652c2652620",
|
|
119
|
+
"bun-linux-x64.zip":
|
|
120
|
+
"951ee2aee855f08595aeec6225226a298d3fea83a3dcd6465c09cbccdf7e848f",
|
|
121
|
+
"bun-linux-x64-musl.zip":
|
|
122
|
+
"14bd9aedeebf1dba67e8def9531c89bc989ecfdf1de42e5bfcaf1b8cd9294719",
|
|
123
|
+
"bun-linux-aarch64.zip":
|
|
124
|
+
"a27ffb63a8310375836e0d6f668ae17fa8d8d18b88c37c821c65331973a19a3b",
|
|
125
|
+
"bun-linux-aarch64-musl.zip":
|
|
126
|
+
"b98e0ad3625c5c00d1d5b5ff55605c7adddbfae151861e68ade57b2d3b8703bb",
|
|
127
|
+
};
|
|
128
|
+
|
|
99
129
|
if (!assetName) {
|
|
100
130
|
console.error(
|
|
101
131
|
`[ghfind] Unsupported platform: ${key}. Install Bun manually: https://bun.sh`,
|
|
@@ -116,6 +146,14 @@ try {
|
|
|
116
146
|
|
|
117
147
|
const buffer = Buffer.from(await res.arrayBuffer());
|
|
118
148
|
|
|
149
|
+
const expected = SHA256[assetName];
|
|
150
|
+
const actual = createHash("sha256").update(buffer).digest("hex");
|
|
151
|
+
if (expected !== actual) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`checksum mismatch for ${assetName} (expected ${expected}, got ${actual})`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
119
157
|
// Find the central directory file header signature (PK\x01\x02)
|
|
120
158
|
const cdSig = "PK\x01\x02";
|
|
121
159
|
let cdOffset = buffer.indexOf(cdSig, 0);
|
package/src/cli.ts
CHANGED
|
@@ -140,6 +140,20 @@ interface CLIFlags {
|
|
|
140
140
|
limitExplicit: boolean;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Valid `--sort` values: the repo strategies plus the npm-package strategies
|
|
145
|
+
* (the `pkg` subcommand validates its own narrower set).
|
|
146
|
+
*/
|
|
147
|
+
const SORT_VALUES = [
|
|
148
|
+
"best-match",
|
|
149
|
+
"stars",
|
|
150
|
+
"updated",
|
|
151
|
+
"forks",
|
|
152
|
+
"score",
|
|
153
|
+
"downloads",
|
|
154
|
+
"name",
|
|
155
|
+
];
|
|
156
|
+
|
|
143
157
|
function parseArgs(args: string[]): CLIFlags {
|
|
144
158
|
const flags: CLIFlags = {
|
|
145
159
|
query: "",
|
|
@@ -246,9 +260,17 @@ function parseArgs(args: string[]): CLIFlags {
|
|
|
246
260
|
}
|
|
247
261
|
break;
|
|
248
262
|
}
|
|
249
|
-
case "--sort":
|
|
250
|
-
|
|
263
|
+
case "--sort": {
|
|
264
|
+
const sort = args[++i];
|
|
265
|
+
if (!sort || !SORT_VALUES.includes(sort)) {
|
|
266
|
+
console.error(
|
|
267
|
+
`Invalid sort: ${sort ?? "(missing)"} (use best-match|stars|updated|forks).`,
|
|
268
|
+
);
|
|
269
|
+
process.exit(1);
|
|
270
|
+
}
|
|
271
|
+
flags.sort = sort as SearchOptions["sort"];
|
|
251
272
|
break;
|
|
273
|
+
}
|
|
252
274
|
case "--token":
|
|
253
275
|
flags.token = args[++i];
|
|
254
276
|
break;
|
|
@@ -883,10 +905,11 @@ function rowsToCsv(header: string[], rows: string[][]): string {
|
|
|
883
905
|
|
|
884
906
|
/** Markdown table for the same small local-state tables. */
|
|
885
907
|
function rowsToMarkdown(header: string[], rows: string[][]): string {
|
|
908
|
+
const cell = (value: string) => value.replace(/\|/g, "\\|");
|
|
886
909
|
return [
|
|
887
|
-
`| ${header.join(" | ")} |`,
|
|
910
|
+
`| ${header.map(cell).join(" | ")} |`,
|
|
888
911
|
`|${header.map(() => "---").join("|")}|`,
|
|
889
|
-
...rows.map((row) => `| ${row.join(" | ")} |`),
|
|
912
|
+
...rows.map((row) => `| ${row.map(cell).join(" | ")} |`),
|
|
890
913
|
].join("\n");
|
|
891
914
|
}
|
|
892
915
|
|
|
@@ -945,7 +968,12 @@ async function runReadme(flags: CLIFlags): Promise<void> {
|
|
|
945
968
|
}
|
|
946
969
|
try {
|
|
947
970
|
const { owner, name } = repoFromRef(ref); // validates the ref
|
|
948
|
-
const readme = await fetchReadme(owner, name, {
|
|
971
|
+
const readme = await fetchReadme(owner, name, {
|
|
972
|
+
// A bare ref carries no visibility info, so the token is never
|
|
973
|
+
// forwarded to raw.githubusercontent.com.
|
|
974
|
+
token: flags.token,
|
|
975
|
+
private: false,
|
|
976
|
+
});
|
|
949
977
|
if (!readme) {
|
|
950
978
|
console.error(`No README found for ${owner}/${name}.`);
|
|
951
979
|
process.exit(1);
|
|
@@ -1224,22 +1252,14 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
|
|
|
1224
1252
|
}
|
|
1225
1253
|
},
|
|
1226
1254
|
compare: async (_ctx) => {
|
|
1227
|
-
const search = createGitHubSearch(undefined, [flags.token ?? ""]);
|
|
1228
1255
|
const repos: Repo[] = [];
|
|
1229
1256
|
const missing: string[] = [];
|
|
1230
1257
|
for (const fullName of flags.compare) {
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
json: false,
|
|
1237
|
-
verbose: false,
|
|
1238
|
-
token: flags.token,
|
|
1239
|
-
},
|
|
1240
|
-
);
|
|
1241
|
-
if (res.repos.length > 0) repos.push(...res.repos);
|
|
1242
|
-
else missing.push(fullName);
|
|
1258
|
+
try {
|
|
1259
|
+
repos.push(await resolveRepoFromRef(fullName, flags.token));
|
|
1260
|
+
} catch {
|
|
1261
|
+
missing.push(fullName);
|
|
1262
|
+
}
|
|
1243
1263
|
}
|
|
1244
1264
|
if (repos.length < 2) {
|
|
1245
1265
|
console.error(
|
|
@@ -1281,11 +1301,9 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
|
|
|
1281
1301
|
}
|
|
1282
1302
|
|
|
1283
1303
|
main().catch((err) => {
|
|
1284
|
-
if (err instanceof
|
|
1304
|
+
if (err instanceof SearchCliError) {
|
|
1285
1305
|
// ponytail: known user-facing errors already carry a friendly message; skip the report prompt
|
|
1286
|
-
console.error(
|
|
1287
|
-
err instanceof SearchCliError ? err.userMessage : err.message,
|
|
1288
|
-
);
|
|
1306
|
+
console.error(err.userMessage);
|
|
1289
1307
|
} else {
|
|
1290
1308
|
console.error(err instanceof Error ? err.message : String(err));
|
|
1291
1309
|
reportError(err);
|
package/src/config.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import type { Config } from "./types";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
13
|
|
|
14
14
|
/** Directory for persistent state (history, bookmarks, session). */
|
|
15
15
|
export function stateDir(): string {
|
|
@@ -61,7 +61,17 @@ export function saveConfig(config: Config): void {
|
|
|
61
61
|
Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")),
|
|
62
62
|
);
|
|
63
63
|
mkdirSync(dir, { recursive: true });
|
|
64
|
-
|
|
64
|
+
// The file holds a plaintext GitHub token — owner-only, and tighten any
|
|
65
|
+
// pre-existing world-readable file too.
|
|
66
|
+
writeFileSync(path, JSON.stringify(config, null, 2), {
|
|
67
|
+
encoding: "utf-8",
|
|
68
|
+
mode: 0o600,
|
|
69
|
+
});
|
|
70
|
+
try {
|
|
71
|
+
chmodSync(path, 0o600);
|
|
72
|
+
} catch {
|
|
73
|
+
// best effort (read-only file / unsupported filesystem)
|
|
74
|
+
}
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
function mergeWithEnv(config: Config): Config {
|
package/src/deepdive.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Repo deep-dive — fetch and format rich repo details. */
|
|
2
2
|
import type { Repo } from "./types";
|
|
3
|
+
import { fetchReadme } from "./readme";
|
|
3
4
|
|
|
4
5
|
const USER_AGENT = "ghfind/1.0";
|
|
5
6
|
|
|
@@ -56,11 +57,29 @@ export function repoFromRef(fullName: string): Repo {
|
|
|
56
57
|
};
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
/** Repo payload as returned by GET /repos/{owner}/{repo}. */
|
|
61
|
+
interface GitHubRepoApi {
|
|
62
|
+
description: string | null;
|
|
63
|
+
html_url: string;
|
|
64
|
+
stargazers_count: number;
|
|
65
|
+
forks_count: number;
|
|
66
|
+
subscribers_count?: number;
|
|
67
|
+
language: string | null;
|
|
68
|
+
topics?: string[];
|
|
69
|
+
archived: boolean;
|
|
70
|
+
fork: boolean;
|
|
71
|
+
private?: boolean;
|
|
72
|
+
created_at: string;
|
|
73
|
+
updated_at: string;
|
|
74
|
+
pushed_at: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
59
77
|
/**
|
|
60
78
|
* Resolve a repo ref to a real Repo by fetching canonical metadata from the
|
|
61
|
-
* GitHub API.
|
|
62
|
-
* (offline,
|
|
63
|
-
*
|
|
79
|
+
* GitHub API. The ref is validated first. Only transport-level failures
|
|
80
|
+
* (offline, unparseable body) degrade to a stub via repoFromRef — an auth
|
|
81
|
+
* error or a rate limit is surfaced, because a stub with `stars: 0` is
|
|
82
|
+
* indistinguishable from a real repo with no data.
|
|
64
83
|
*/
|
|
65
84
|
export async function resolveRepoFromRef(
|
|
66
85
|
fullName: string,
|
|
@@ -68,58 +87,50 @@ export async function resolveRepoFromRef(
|
|
|
68
87
|
): Promise<Repo> {
|
|
69
88
|
const ref = parseRepoRef(fullName); // throws for malformed refs
|
|
70
89
|
const canonical = `${ref.owner}/${ref.name}`;
|
|
90
|
+
|
|
91
|
+
let res: Response;
|
|
71
92
|
try {
|
|
72
|
-
|
|
93
|
+
res = await fetch(`https://api.github.com/repos/${canonical}`, {
|
|
73
94
|
headers: buildHeaders(token),
|
|
74
95
|
});
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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.
|
|
96
|
+
} catch {
|
|
97
|
+
return repoFromRef(canonical);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (res.status === 404) {
|
|
101
|
+
throw new Error(`Repo not found: ${canonical}`);
|
|
102
|
+
}
|
|
103
|
+
if (!res.ok) {
|
|
104
|
+
throw new Error(`GitHub API error ${res.status} for ${canonical}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let api: GitHubRepoApi;
|
|
108
|
+
try {
|
|
109
|
+
api = (await res.json()) as GitHubRepoApi;
|
|
110
|
+
} catch {
|
|
111
|
+
return repoFromRef(canonical);
|
|
121
112
|
}
|
|
122
|
-
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
id: 0,
|
|
116
|
+
fullName: canonical,
|
|
117
|
+
name: ref.name,
|
|
118
|
+
owner: ref.owner,
|
|
119
|
+
description: api.description,
|
|
120
|
+
url: api.html_url,
|
|
121
|
+
stars: api.stargazers_count ?? 0,
|
|
122
|
+
forks: api.forks_count ?? 0,
|
|
123
|
+
watchers: api.subscribers_count ?? 0,
|
|
124
|
+
language: api.language,
|
|
125
|
+
topics: api.topics ?? [],
|
|
126
|
+
archived: api.archived ?? false,
|
|
127
|
+
isFork: api.fork ?? false,
|
|
128
|
+
private: api.private ?? false,
|
|
129
|
+
createdAt: api.created_at ?? "",
|
|
130
|
+
updatedAt: api.updated_at ?? "",
|
|
131
|
+
pushedAt: api.pushed_at ?? "",
|
|
132
|
+
score: 0,
|
|
133
|
+
};
|
|
123
134
|
}
|
|
124
135
|
|
|
125
136
|
/** Language breakdown: { [lang]: bytes } */
|
|
@@ -176,21 +187,14 @@ export async function fetchDeepDiveRaw(
|
|
|
176
187
|
),
|
|
177
188
|
]);
|
|
178
189
|
|
|
179
|
-
// README
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
headers,
|
|
188
|
-
});
|
|
189
|
-
if (r.ok) {
|
|
190
|
-
readmeText = await r.text();
|
|
191
|
-
break;
|
|
192
|
-
}
|
|
193
|
-
}
|
|
190
|
+
// README: main → master → README.rst, probed in parallel by fetchReadme.
|
|
191
|
+
// Public READMEs are served unauthenticated, so the token is only forwarded
|
|
192
|
+
// to raw.githubusercontent.com when the repo actually needs it.
|
|
193
|
+
const readme = await fetchReadme(repo.owner, repo.name, {
|
|
194
|
+
token: repo.private ? token : undefined,
|
|
195
|
+
private: repo.private,
|
|
196
|
+
});
|
|
197
|
+
const readmeText = readme?.text ?? "";
|
|
194
198
|
|
|
195
199
|
const langEntries: [string, number][] = languagesRes.ok
|
|
196
200
|
? Object.entries(
|
package/src/doctor.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { join, dirname } from "node:path";
|
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
12
|
import { homedir, platform, arch } from "node:os";
|
|
13
13
|
import { loadConfig, configPath, stateDir, cacheDir } from "./config";
|
|
14
|
+
import { maskToken } from "./login";
|
|
14
15
|
|
|
15
16
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
17
|
const PKG_DIR = join(__dirname, "..");
|
|
@@ -212,8 +213,8 @@ function checkGithubToken(): void {
|
|
|
212
213
|
const config = loadConfig();
|
|
213
214
|
const token = config.githubToken || process.env.GITHUB_TOKEN;
|
|
214
215
|
if (token) {
|
|
215
|
-
const preview = token
|
|
216
|
-
add("PASS", "GitHub token", `Configured (${preview}
|
|
216
|
+
const preview = maskToken(token);
|
|
217
|
+
add("PASS", "GitHub token", `Configured (${preview}).`);
|
|
217
218
|
} else {
|
|
218
219
|
add(
|
|
219
220
|
"WARN",
|