harnery 0.7.1 → 0.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/README.md +1 -1
- package/dist/commander.d.ts +9 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commands/callers.d.ts.map +1 -1
- package/dist/commands/callers.js +69 -17
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +46 -2
- package/dist/commands/grep.d.ts +44 -0
- package/dist/commands/grep.d.ts.map +1 -1
- package/dist/commands/grep.js +246 -45
- package/dist/core/config.d.ts +9 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +19 -0
- package/dist/lib/tools/ripgrep.d.ts +61 -0
- package/dist/lib/tools/ripgrep.d.ts.map +1 -0
- package/dist/lib/tools/ripgrep.js +217 -0
- package/package.json +1 -1
- package/src/commander.ts +9 -0
- package/src/commands/callers.ts +90 -26
- package/src/commands/doctor.ts +50 -2
- package/src/commands/grep.ts +277 -46
- package/src/core/config.ts +24 -0
- package/src/lib/tools/ripgrep.ts +244 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
4
|
+
import { readFile, stat } from "node:fs/promises";
|
|
5
|
+
import { homedir, tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { resolveBinName, ripgrepAutoInstall } from "../../core/config.js";
|
|
8
|
+
import { coordEnv } from "../env.js";
|
|
9
|
+
/**
|
|
10
|
+
* Managed ripgrep provisioning for `grep --files` / content search.
|
|
11
|
+
*
|
|
12
|
+
* harnery can install a pinned, checksum-verified ripgrep into its own tools
|
|
13
|
+
* directory (`$XDG_DATA_HOME/harnery/tools`, default `~/.local/share/harnery/
|
|
14
|
+
* tools`) so `harn grep` gets the fast engine even on machines where nobody
|
|
15
|
+
* ever installed rg. The download is version-pinned and every artifact's
|
|
16
|
+
* sha256 is baked in below (cross-checked against the official release
|
|
17
|
+
* checksums), so the install is reproducible, never "whatever is latest."
|
|
18
|
+
*
|
|
19
|
+
* Consent model: the installer only runs automatically when the host project
|
|
20
|
+
* opts in via `.harnery/config.jsonc` `{ "tools": { "ripgrep": { "autoInstall":
|
|
21
|
+
* true } } }` — a repo commits that once and every clone self-heals. Without
|
|
22
|
+
* the opt-in, a missing rg produces a rate-limited stderr hint naming
|
|
23
|
+
* `harn doctor --fix` (explicit install). Environments that forbid downloads
|
|
24
|
+
* lose nothing: every failure path falls back to GNU grep.
|
|
25
|
+
*/
|
|
26
|
+
export const RG_VERSION = "14.1.1";
|
|
27
|
+
/** sha256 pins verified against the official ripgrep release .sha256 assets. */
|
|
28
|
+
const ARTIFACTS = {
|
|
29
|
+
"linux-x64": {
|
|
30
|
+
name: `ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl.tar.gz`,
|
|
31
|
+
sha256: "4cf9f2741e6c465ffdb7c26f38056a59e2a2544b51f7cc128ef28337eeae4d8e",
|
|
32
|
+
},
|
|
33
|
+
"linux-arm64": {
|
|
34
|
+
name: `ripgrep-${RG_VERSION}-aarch64-unknown-linux-gnu.tar.gz`,
|
|
35
|
+
sha256: "c827481c4ff4ea10c9dc7a4022c8de5db34a5737cb74484d62eb94a95841ab2f",
|
|
36
|
+
},
|
|
37
|
+
"darwin-x64": {
|
|
38
|
+
name: `ripgrep-${RG_VERSION}-x86_64-apple-darwin.tar.gz`,
|
|
39
|
+
sha256: "fc87e78f7cb3fea12d69072e7ef3b21509754717b746368fd40d88963630e2b3",
|
|
40
|
+
},
|
|
41
|
+
"darwin-arm64": {
|
|
42
|
+
name: `ripgrep-${RG_VERSION}-aarch64-apple-darwin.tar.gz`,
|
|
43
|
+
sha256: "24ad76777745fbff131c8fbc466742b011f925bfa4fffa2ded6def23b5b937be",
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
function platformKey() {
|
|
47
|
+
return `${process.platform}-${process.arch}`;
|
|
48
|
+
}
|
|
49
|
+
/** True when a pinned artifact exists for this OS/arch (Windows: not yet). */
|
|
50
|
+
export function rgInstallSupported() {
|
|
51
|
+
return platformKey() in ARTIFACTS;
|
|
52
|
+
}
|
|
53
|
+
/** Machine-level harnery tools dir (NOT the per-project .harnery/ state dir). */
|
|
54
|
+
export function toolsDir() {
|
|
55
|
+
const xdg = process.env.XDG_DATA_HOME;
|
|
56
|
+
const base = xdg && xdg.trim() !== "" ? xdg : join(homedir(), ".local", "share");
|
|
57
|
+
return join(base, "harnery", "tools");
|
|
58
|
+
}
|
|
59
|
+
/** Path the managed rg binary lives at once installed. */
|
|
60
|
+
export function managedRgPath() {
|
|
61
|
+
return join(toolsDir(), "rg");
|
|
62
|
+
}
|
|
63
|
+
function isExecutable(p) {
|
|
64
|
+
try {
|
|
65
|
+
const r = spawnSync(p, ["--version"], { stdio: "ignore" });
|
|
66
|
+
return r.status === 0;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Locate a usable ripgrep. Precedence:
|
|
74
|
+
* 1. HARNERY_RG_PATH (explicit override)
|
|
75
|
+
* 2. the managed tools-dir install (works even when PATH doesn't carry it)
|
|
76
|
+
* 3. `rg` on PATH
|
|
77
|
+
* Returns the spawnable path/name, or null when none respond to --version.
|
|
78
|
+
*/
|
|
79
|
+
export function findRg() {
|
|
80
|
+
const override = coordEnv("RG_PATH");
|
|
81
|
+
if (override && override.trim() !== "") {
|
|
82
|
+
return isExecutable(override) ? override : null;
|
|
83
|
+
}
|
|
84
|
+
const managed = managedRgPath();
|
|
85
|
+
if (existsSync(managed) && isExecutable(managed))
|
|
86
|
+
return managed;
|
|
87
|
+
if (isExecutable("rg"))
|
|
88
|
+
return "rg";
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Download + verify + install the pinned ripgrep into the tools dir.
|
|
93
|
+
* Atomic: extracts to a temp dir and renames the binary into place.
|
|
94
|
+
* Throws on unsupported platform, network failure, or checksum mismatch —
|
|
95
|
+
* callers are expected to fall back to grep and say why.
|
|
96
|
+
*/
|
|
97
|
+
export async function installRg(log) {
|
|
98
|
+
const key = platformKey();
|
|
99
|
+
const artifact = ARTIFACTS[key];
|
|
100
|
+
if (!artifact) {
|
|
101
|
+
throw new Error(`no pinned ripgrep artifact for ${key} — install ripgrep manually (https://github.com/BurntSushi/ripgrep#installation)`);
|
|
102
|
+
}
|
|
103
|
+
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${artifact.name}`;
|
|
104
|
+
log?.(`downloading ${artifact.name} (pinned ${RG_VERSION})...`);
|
|
105
|
+
const res = await fetch(url);
|
|
106
|
+
if (!res.ok)
|
|
107
|
+
throw new Error(`download failed: HTTP ${res.status} for ${url}`);
|
|
108
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
109
|
+
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
110
|
+
if (digest !== artifact.sha256) {
|
|
111
|
+
throw new Error(`sha256 mismatch for ${artifact.name}: expected ${artifact.sha256}, got ${digest} — refusing to install`);
|
|
112
|
+
}
|
|
113
|
+
const work = join(tmpdir(), `harnery-rg-${process.pid}`);
|
|
114
|
+
rmSync(work, { recursive: true, force: true });
|
|
115
|
+
mkdirSync(work, { recursive: true });
|
|
116
|
+
try {
|
|
117
|
+
const tarball = join(work, artifact.name);
|
|
118
|
+
writeFileSync(tarball, bytes);
|
|
119
|
+
await extractTar(tarball, work);
|
|
120
|
+
const extractedRg = join(work, artifact.name.replace(/\.tar\.gz$/, ""), "rg");
|
|
121
|
+
const rgStat = await stat(extractedRg);
|
|
122
|
+
if (!rgStat.isFile())
|
|
123
|
+
throw new Error(`extracted archive missing rg binary at ${extractedRg}`);
|
|
124
|
+
mkdirSync(toolsDir(), { recursive: true });
|
|
125
|
+
const dest = managedRgPath();
|
|
126
|
+
// rename() can't cross filesystems (tmp is often tmpfs); copy + rename
|
|
127
|
+
// within the destination dir keeps the final placement atomic.
|
|
128
|
+
const staged = `${dest}.tmp-${process.pid}`;
|
|
129
|
+
writeFileSync(staged, await readFile(extractedRg));
|
|
130
|
+
chmodSync(staged, 0o755);
|
|
131
|
+
renameSync(staged, dest);
|
|
132
|
+
if (!isExecutable(dest)) {
|
|
133
|
+
rmSync(dest, { force: true });
|
|
134
|
+
throw new Error(`installed rg at ${dest} but it failed to execute`);
|
|
135
|
+
}
|
|
136
|
+
log?.(`installed ripgrep ${RG_VERSION} -> ${dest}`);
|
|
137
|
+
return dest;
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
rmSync(work, { recursive: true, force: true });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function extractTar(tarball, cwd) {
|
|
144
|
+
return new Promise((resolveP, reject) => {
|
|
145
|
+
const proc = spawn("tar", ["xzf", tarball], { cwd, stdio: ["ignore", "ignore", "pipe"] });
|
|
146
|
+
let stderr = "";
|
|
147
|
+
proc.stderr.setEncoding("utf8");
|
|
148
|
+
proc.stderr.on("data", (c) => {
|
|
149
|
+
stderr += c;
|
|
150
|
+
});
|
|
151
|
+
proc.on("error", reject);
|
|
152
|
+
proc.on("close", (code) => {
|
|
153
|
+
if (code === 0)
|
|
154
|
+
resolveP();
|
|
155
|
+
else
|
|
156
|
+
reject(new Error(`tar exited ${code}: ${stderr.trim()}`));
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Shared engine resolver for the ripgrep-backed search commands (`grep`,
|
|
162
|
+
* `callers`). Returns the engine plus the spawnable rg path.
|
|
163
|
+
*
|
|
164
|
+
* `HARNERY_GREP_ENGINE=rg|grep` forces one; otherwise findRg() probes
|
|
165
|
+
* HARNERY_RG_PATH → the managed tools-dir install → PATH. On a miss, the
|
|
166
|
+
* host's `.harnery/config.jsonc` `tools.ripgrep.autoInstall` consent triggers
|
|
167
|
+
* a pinned, checksum-verified install into the harnery tools dir (any failure
|
|
168
|
+
* falls back to grep with a stderr note); without consent, a rate-limited
|
|
169
|
+
* stderr hint (keyed per `hintKey`) names the explicit install command.
|
|
170
|
+
*/
|
|
171
|
+
export async function resolveSearchEngine(hintKey = "grep") {
|
|
172
|
+
const forced = coordEnv("GREP_ENGINE");
|
|
173
|
+
if (forced === "grep")
|
|
174
|
+
return { engine: "grep", rgBin: "rg" };
|
|
175
|
+
const found = findRg();
|
|
176
|
+
if (found)
|
|
177
|
+
return { engine: "rg", rgBin: found };
|
|
178
|
+
if (forced === "rg") {
|
|
179
|
+
// Forced rg with none findable: let the spawn error surface loudly.
|
|
180
|
+
return { engine: "rg", rgBin: "rg" };
|
|
181
|
+
}
|
|
182
|
+
if (rgInstallSupported()) {
|
|
183
|
+
if (ripgrepAutoInstall()) {
|
|
184
|
+
try {
|
|
185
|
+
const installed = await installRg((line) => process.stderr.write(`harnery: ${line}\n`));
|
|
186
|
+
return { engine: "rg", rgBin: installed };
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
process.stderr.write(`harnery: ripgrep auto-install failed (${err.message}); using grep fallback\n`);
|
|
190
|
+
return { engine: "grep", rgBin: "rg" };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const bin = resolveBinName();
|
|
194
|
+
hintOncePerDay(`${bin} ${hintKey}: ripgrep not found; using the slower GNU grep fallback. ` +
|
|
195
|
+
`Run \`${bin} doctor --fix\` to install it (pinned + checksum-verified), ` +
|
|
196
|
+
`or set { "tools": { "ripgrep": { "autoInstall": true } } } in .harnery/config.jsonc.`);
|
|
197
|
+
}
|
|
198
|
+
return { engine: "grep", rgBin: "rg" };
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Emit `line` to stderr at most once per 24h (stamp file in the tools dir).
|
|
202
|
+
* Keeps the "rg missing" nudge from nagging on every single grep invocation.
|
|
203
|
+
*/
|
|
204
|
+
export function hintOncePerDay(line) {
|
|
205
|
+
try {
|
|
206
|
+
const stamp = join(toolsDir(), ".rg-hint-stamp");
|
|
207
|
+
if (existsSync(stamp) && Date.now() - statSync(stamp).mtimeMs < 24 * 60 * 60 * 1000) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
mkdirSync(toolsDir(), { recursive: true });
|
|
211
|
+
writeFileSync(stamp, String(Date.now()));
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// stamping failed; still emit the hint
|
|
215
|
+
}
|
|
216
|
+
process.stderr.write(`${line}\n`);
|
|
217
|
+
}
|
package/package.json
CHANGED
package/src/commander.ts
CHANGED
|
@@ -120,6 +120,15 @@ export interface HarneryProgramContext {
|
|
|
120
120
|
* carrying provider-specific checks. harn standalone ships none.
|
|
121
121
|
*/
|
|
122
122
|
envSections?: Record<string, EnvSection>;
|
|
123
|
+
/**
|
|
124
|
+
* Extra directory names for `grep` to exclude by default — host-generated
|
|
125
|
+
* mirrors, vendored trees, and similar directories that only ever produce
|
|
126
|
+
* duplicate or noisy matches. Names match at any depth (same semantics as
|
|
127
|
+
* grep's `--exclude-dir`). Applied alongside the built-in skip list and
|
|
128
|
+
* disabled together with it by `--no-default-excludes`. harn standalone
|
|
129
|
+
* ships none.
|
|
130
|
+
*/
|
|
131
|
+
grepExcludeDirs?: readonly string[];
|
|
123
132
|
/**
|
|
124
133
|
* Optional callback that returns extra HTTP headers to attach to outbound
|
|
125
134
|
* `fetch` calls based on the target URL. Useful for consumers that need
|
package/src/commands/callers.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import type { Command } from "commander";
|
|
3
3
|
import type { EmitContext, HarneryProgramContext } from "../commander.ts";
|
|
4
|
+
import { resolveSearchEngine, type SearchEngine } from "../lib/tools/ripgrep.ts";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* `harn callers <symbol>`: find references to a symbol across the monorepo with
|
|
7
|
-
* kind classification (call / import / type / decl / ref).
|
|
8
|
-
* grep
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* kind classification (call / import / type / decl / ref). Whole-word search
|
|
9
|
+
* (ripgrep when available, GNU grep fallback — same engine + provisioning path
|
|
10
|
+
* as `harn grep`, honoring HARNERY_GREP_ENGINE) with post-filtering:
|
|
11
|
+
* declarations + single-line comments are filtered out by default, and lines
|
|
12
|
+
* starting with " * " (multi-line comment continuation) are dropped. Inherent
|
|
13
|
+
* text-based-search limitations: multi-line string literals and block-comment
|
|
14
|
+
* interiors aren't excluded. Repos are searched in parallel.
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
17
|
interface CallersOpts {
|
|
@@ -116,21 +119,31 @@ async function runCallers(
|
|
|
116
119
|
}
|
|
117
120
|
|
|
118
121
|
const started = Date.now();
|
|
122
|
+
const { engine, rgBin } = await resolveSearchEngine("callers");
|
|
119
123
|
const repos = resolveRepos(opts, context);
|
|
120
124
|
const limit = opts.limit ? Number.parseInt(opts.limit, 10) : 500;
|
|
121
125
|
|
|
126
|
+
// Search every repo concurrently, each capped at the global limit, then
|
|
127
|
+
// apply the global budget in repo order so truncation stays deterministic.
|
|
128
|
+
// In --all-repos mode the parent scan prunes submodule dirs (each submodule
|
|
129
|
+
// is scanned on its own), so a match is attributed to exactly one repo.
|
|
130
|
+
const perRepo = await Promise.all(
|
|
131
|
+
repos.map((repo) => {
|
|
132
|
+
const pruneDirs = opts.allRepos && repo.name === "parent" ? (context?.submodules ?? []) : [];
|
|
133
|
+
return searchInRepo(symbol, opts, repo, limit, engine, rgBin, pruneDirs);
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
|
|
122
137
|
const allCallers: Caller[] = [];
|
|
123
138
|
let truncated = false;
|
|
124
|
-
|
|
125
|
-
for (
|
|
126
|
-
|
|
127
|
-
truncated = true;
|
|
128
|
-
break;
|
|
129
|
-
}
|
|
130
|
-
const remaining = limit - allCallers.length;
|
|
131
|
-
const found = await grepInRepo(symbol, opts, repo, remaining);
|
|
139
|
+
let budget = limit;
|
|
140
|
+
for (let i = 0; i < repos.length; i++) {
|
|
141
|
+
const found = perRepo[i] ?? { callers: [], truncated: false };
|
|
132
142
|
if (found.truncated) truncated = true;
|
|
133
|
-
|
|
143
|
+
const take = Math.min(found.callers.length, Math.max(0, budget));
|
|
144
|
+
if (found.callers.length > take) truncated = true;
|
|
145
|
+
allCallers.push(...found.callers.slice(0, take));
|
|
146
|
+
budget -= take;
|
|
134
147
|
}
|
|
135
148
|
|
|
136
149
|
const by_kind: Record<string, number> = {};
|
|
@@ -184,24 +197,72 @@ function resolveRepos(
|
|
|
184
197
|
return [{ name: "cwd", cwd: process.cwd() }];
|
|
185
198
|
}
|
|
186
199
|
|
|
187
|
-
function
|
|
188
|
-
symbol: string,
|
|
189
|
-
opts: CallersOpts,
|
|
190
|
-
repo: { name: string; cwd: string },
|
|
191
|
-
limit: number,
|
|
192
|
-
): Promise<{ callers: Caller[]; truncated: boolean }> {
|
|
193
|
-
const args: string[] = ["-rnw", "--color=never", "-I", "-E"];
|
|
194
|
-
for (const d of DEFAULT_EXCLUDE_DIRS) args.push(`--exclude-dir=${d}`);
|
|
195
|
-
for (const f of DEFAULT_EXCLUDE_FILES) args.push(`--exclude=${f}`);
|
|
200
|
+
function resolveLangGlobs(opts: CallersOpts): string[] | undefined {
|
|
196
201
|
const langGlobs = opts.lang ? LANG_GLOBS[opts.lang] : undefined;
|
|
197
202
|
if (opts.lang && !langGlobs) {
|
|
198
203
|
throw new Error(`unknown --lang "${opts.lang}". Valid: ${Object.keys(LANG_GLOBS).join(", ")}`);
|
|
199
204
|
}
|
|
205
|
+
return langGlobs;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function buildCallersGrepArgs(
|
|
209
|
+
symbol: string,
|
|
210
|
+
opts: CallersOpts,
|
|
211
|
+
extraExcludeDirs: readonly string[],
|
|
212
|
+
): string[] {
|
|
213
|
+
const args: string[] = ["-rnw", "-H", "--color=never", "-I", "-E"];
|
|
214
|
+
for (const d of DEFAULT_EXCLUDE_DIRS) args.push(`--exclude-dir=${d}`);
|
|
215
|
+
for (const f of DEFAULT_EXCLUDE_FILES) args.push(`--exclude=${f}`);
|
|
216
|
+
for (const d of extraExcludeDirs) args.push(`--exclude-dir=${d}`);
|
|
217
|
+
const langGlobs = resolveLangGlobs(opts);
|
|
200
218
|
if (langGlobs) for (const g of langGlobs) args.push(`--include=${g}`);
|
|
201
219
|
args.push("--", symbol, ".");
|
|
220
|
+
return args;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function buildCallersRgArgs(
|
|
224
|
+
symbol: string,
|
|
225
|
+
opts: CallersOpts,
|
|
226
|
+
extraExcludeDirs: readonly string[],
|
|
227
|
+
): string[] {
|
|
228
|
+
// --hidden --no-ignore --no-config: match grep semantics (dotdirs searched,
|
|
229
|
+
// .gitignore ignored, user rg config can't skew results). -w = whole word.
|
|
230
|
+
// Lang globs (positives) go before dir excludes (negatives) since rg globs
|
|
231
|
+
// are last-match-wins.
|
|
232
|
+
const args: string[] = [
|
|
233
|
+
"-nw",
|
|
234
|
+
"--no-heading",
|
|
235
|
+
"--with-filename",
|
|
236
|
+
"--color=never",
|
|
237
|
+
"--no-config",
|
|
238
|
+
"--hidden",
|
|
239
|
+
"--no-ignore",
|
|
240
|
+
];
|
|
241
|
+
const langGlobs = resolveLangGlobs(opts);
|
|
242
|
+
if (langGlobs) for (const g of langGlobs) args.push(`--glob=${g}`);
|
|
243
|
+
for (const d of DEFAULT_EXCLUDE_DIRS) args.push(`--glob=!${d}`);
|
|
244
|
+
for (const f of DEFAULT_EXCLUDE_FILES) args.push(`--glob=!${f}`);
|
|
245
|
+
for (const d of extraExcludeDirs) args.push(`--glob=!${d}`);
|
|
246
|
+
args.push("--", symbol, ".");
|
|
247
|
+
return args;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function searchInRepo(
|
|
251
|
+
symbol: string,
|
|
252
|
+
opts: CallersOpts,
|
|
253
|
+
repo: { name: string; cwd: string },
|
|
254
|
+
limit: number,
|
|
255
|
+
engine: SearchEngine,
|
|
256
|
+
rgBin: string,
|
|
257
|
+
extraExcludeDirs: readonly string[],
|
|
258
|
+
): Promise<{ callers: Caller[]; truncated: boolean }> {
|
|
259
|
+
const [bin, args] =
|
|
260
|
+
engine === "rg"
|
|
261
|
+
? ([rgBin, buildCallersRgArgs(symbol, opts, extraExcludeDirs)] as const)
|
|
262
|
+
: (["grep", buildCallersGrepArgs(symbol, opts, extraExcludeDirs)] as const);
|
|
202
263
|
|
|
203
264
|
return new Promise((resolveP, reject) => {
|
|
204
|
-
const proc = spawn(
|
|
265
|
+
const proc = spawn(bin, args, { cwd: repo.cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
205
266
|
const callers: Caller[] = [];
|
|
206
267
|
let buffer = "";
|
|
207
268
|
let stderr = "";
|
|
@@ -237,8 +298,11 @@ function grepInRepo(
|
|
|
237
298
|
const parsed = parseCallerLine(buffer, symbol, opts, repo.name);
|
|
238
299
|
if (parsed) callers.push(parsed);
|
|
239
300
|
}
|
|
240
|
-
|
|
241
|
-
|
|
301
|
+
// Exit 1 = no matches (both engines): normal. Exit 2 with nothing
|
|
302
|
+
// collected = a real failure; with matches, a partial walk error we
|
|
303
|
+
// tolerate (surface what was found).
|
|
304
|
+
if (code !== null && code !== 0 && code !== 1 && !truncated && callers.length === 0) {
|
|
305
|
+
reject(new Error(`${bin} exited ${code}: ${stderr.trim() || "(no stderr)"}`));
|
|
242
306
|
return;
|
|
243
307
|
}
|
|
244
308
|
resolveP({ callers, truncated });
|
package/src/commands/doctor.ts
CHANGED
|
@@ -17,8 +17,9 @@ import os from "node:os";
|
|
|
17
17
|
import path from "node:path";
|
|
18
18
|
import type { Command } from "commander";
|
|
19
19
|
import type { EmitContext } from "../commander.ts";
|
|
20
|
-
import { resolveBinName } from "../core/config.ts";
|
|
20
|
+
import { resolveBinName, ripgrepAutoInstall } from "../core/config.ts";
|
|
21
21
|
import { loadHarnessWiring } from "../core/hooks/harness/wiring.ts";
|
|
22
|
+
import { findRg, installRg, managedRgPath, rgInstallSupported } from "../lib/tools/ripgrep.ts";
|
|
22
23
|
|
|
23
24
|
type Severity = "ok" | "warn" | "fail";
|
|
24
25
|
|
|
@@ -31,6 +32,7 @@ interface Check {
|
|
|
31
32
|
|
|
32
33
|
interface CheckOpts {
|
|
33
34
|
json?: boolean;
|
|
35
|
+
fix?: boolean;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
export function registerDoctorCommand(program: Command, emit: EmitContext): void {
|
|
@@ -41,7 +43,18 @@ export function registerDoctorCommand(program: Command, emit: EmitContext): void
|
|
|
41
43
|
"unless a required dep (Node, git) is missing.",
|
|
42
44
|
)
|
|
43
45
|
.option("--json", "Machine-readable JSON output")
|
|
44
|
-
.
|
|
46
|
+
.option(
|
|
47
|
+
"--fix",
|
|
48
|
+
"Install missing managed tools (currently: ripgrep, pinned + checksum-verified)",
|
|
49
|
+
)
|
|
50
|
+
.action(async (opts: CheckOpts) => {
|
|
51
|
+
if (opts.fix && !findRg() && rgInstallSupported()) {
|
|
52
|
+
try {
|
|
53
|
+
await installRg((line) => emit.text(`${line}\n`));
|
|
54
|
+
} catch (err) {
|
|
55
|
+
emit.text(`ripgrep install failed: ${(err as Error).message}\n`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
45
58
|
const checks = runChecks();
|
|
46
59
|
const requiredFailed = checks.some((c) => c.severity === "fail");
|
|
47
60
|
|
|
@@ -80,6 +93,7 @@ export function runChecks(): Check[] {
|
|
|
80
93
|
checkNode(),
|
|
81
94
|
checkGit(),
|
|
82
95
|
checkBun(),
|
|
96
|
+
checkRipgrep(),
|
|
83
97
|
checkHarneryDir(),
|
|
84
98
|
checkHarnessHooks(),
|
|
85
99
|
checkRestic(),
|
|
@@ -89,6 +103,40 @@ export function runChecks(): Check[] {
|
|
|
89
103
|
];
|
|
90
104
|
}
|
|
91
105
|
|
|
106
|
+
function checkRipgrep(): Check {
|
|
107
|
+
const found = findRg();
|
|
108
|
+
if (found) {
|
|
109
|
+
const r = whichVersion(found);
|
|
110
|
+
const managed = found === managedRgPath() ? ", managed" : "";
|
|
111
|
+
return {
|
|
112
|
+
name: "ripgrep",
|
|
113
|
+
severity: "ok",
|
|
114
|
+
detail: `${r.out.replace(/^ripgrep\s*/, "")} (${found === "rg" ? "PATH" : found}${managed})`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (!rgInstallSupported()) {
|
|
118
|
+
return {
|
|
119
|
+
name: "ripgrep",
|
|
120
|
+
severity: "warn",
|
|
121
|
+
detail: "missing (grep fallback; no pinned artifact for this OS/arch)",
|
|
122
|
+
hint: "https://github.com/BurntSushi/ripgrep#installation",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (ripgrepAutoInstall()) {
|
|
126
|
+
return {
|
|
127
|
+
name: "ripgrep",
|
|
128
|
+
severity: "warn",
|
|
129
|
+
detail: "missing (autoInstall on: will self-provision on first grep)",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
name: "ripgrep",
|
|
134
|
+
severity: "warn",
|
|
135
|
+
detail: "missing (grep fallback works, just slower)",
|
|
136
|
+
hint: `${resolveBinName()} doctor --fix (pinned + checksum-verified, installs to the harnery tools dir)`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
92
140
|
function whichVersion(bin: string, args: string[] = ["--version"]): { ok: boolean; out: string } {
|
|
93
141
|
const r = spawnSync(bin, args, { encoding: "utf-8" });
|
|
94
142
|
if (r.status !== 0) return { ok: false, out: "" };
|