harnery 0.7.0 → 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.
@@ -0,0 +1,244 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import {
4
+ chmodSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ renameSync,
8
+ rmSync,
9
+ statSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { readFile, stat } from "node:fs/promises";
13
+ import { homedir, tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { resolveBinName, ripgrepAutoInstall } from "../../core/config.ts";
16
+ import { coordEnv } from "../env.ts";
17
+
18
+ /**
19
+ * Managed ripgrep provisioning for `grep --files` / content search.
20
+ *
21
+ * harnery can install a pinned, checksum-verified ripgrep into its own tools
22
+ * directory (`$XDG_DATA_HOME/harnery/tools`, default `~/.local/share/harnery/
23
+ * tools`) so `harn grep` gets the fast engine even on machines where nobody
24
+ * ever installed rg. The download is version-pinned and every artifact's
25
+ * sha256 is baked in below (cross-checked against the official release
26
+ * checksums), so the install is reproducible, never "whatever is latest."
27
+ *
28
+ * Consent model: the installer only runs automatically when the host project
29
+ * opts in via `.harnery/config.jsonc` `{ "tools": { "ripgrep": { "autoInstall":
30
+ * true } } }` — a repo commits that once and every clone self-heals. Without
31
+ * the opt-in, a missing rg produces a rate-limited stderr hint naming
32
+ * `harn doctor --fix` (explicit install). Environments that forbid downloads
33
+ * lose nothing: every failure path falls back to GNU grep.
34
+ */
35
+
36
+ export const RG_VERSION = "14.1.1";
37
+
38
+ /** sha256 pins verified against the official ripgrep release .sha256 assets. */
39
+ const ARTIFACTS: Record<string, { name: string; sha256: string }> = {
40
+ "linux-x64": {
41
+ name: `ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl.tar.gz`,
42
+ sha256: "4cf9f2741e6c465ffdb7c26f38056a59e2a2544b51f7cc128ef28337eeae4d8e",
43
+ },
44
+ "linux-arm64": {
45
+ name: `ripgrep-${RG_VERSION}-aarch64-unknown-linux-gnu.tar.gz`,
46
+ sha256: "c827481c4ff4ea10c9dc7a4022c8de5db34a5737cb74484d62eb94a95841ab2f",
47
+ },
48
+ "darwin-x64": {
49
+ name: `ripgrep-${RG_VERSION}-x86_64-apple-darwin.tar.gz`,
50
+ sha256: "fc87e78f7cb3fea12d69072e7ef3b21509754717b746368fd40d88963630e2b3",
51
+ },
52
+ "darwin-arm64": {
53
+ name: `ripgrep-${RG_VERSION}-aarch64-apple-darwin.tar.gz`,
54
+ sha256: "24ad76777745fbff131c8fbc466742b011f925bfa4fffa2ded6def23b5b937be",
55
+ },
56
+ };
57
+
58
+ function platformKey(): string {
59
+ return `${process.platform}-${process.arch}`;
60
+ }
61
+
62
+ /** True when a pinned artifact exists for this OS/arch (Windows: not yet). */
63
+ export function rgInstallSupported(): boolean {
64
+ return platformKey() in ARTIFACTS;
65
+ }
66
+
67
+ /** Machine-level harnery tools dir (NOT the per-project .harnery/ state dir). */
68
+ export function toolsDir(): string {
69
+ const xdg = process.env.XDG_DATA_HOME;
70
+ const base = xdg && xdg.trim() !== "" ? xdg : join(homedir(), ".local", "share");
71
+ return join(base, "harnery", "tools");
72
+ }
73
+
74
+ /** Path the managed rg binary lives at once installed. */
75
+ export function managedRgPath(): string {
76
+ return join(toolsDir(), "rg");
77
+ }
78
+
79
+ function isExecutable(p: string): boolean {
80
+ try {
81
+ const r = spawnSync(p, ["--version"], { stdio: "ignore" });
82
+ return r.status === 0;
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Locate a usable ripgrep. Precedence:
90
+ * 1. HARNERY_RG_PATH (explicit override)
91
+ * 2. the managed tools-dir install (works even when PATH doesn't carry it)
92
+ * 3. `rg` on PATH
93
+ * Returns the spawnable path/name, or null when none respond to --version.
94
+ */
95
+ export function findRg(): string | null {
96
+ const override = coordEnv("RG_PATH");
97
+ if (override && override.trim() !== "") {
98
+ return isExecutable(override) ? override : null;
99
+ }
100
+ const managed = managedRgPath();
101
+ if (existsSync(managed) && isExecutable(managed)) return managed;
102
+ if (isExecutable("rg")) return "rg";
103
+ return null;
104
+ }
105
+
106
+ /**
107
+ * Download + verify + install the pinned ripgrep into the tools dir.
108
+ * Atomic: extracts to a temp dir and renames the binary into place.
109
+ * Throws on unsupported platform, network failure, or checksum mismatch —
110
+ * callers are expected to fall back to grep and say why.
111
+ */
112
+ export async function installRg(log?: (line: string) => void): Promise<string> {
113
+ const key = platformKey();
114
+ const artifact = ARTIFACTS[key];
115
+ if (!artifact) {
116
+ throw new Error(
117
+ `no pinned ripgrep artifact for ${key} — install ripgrep manually (https://github.com/BurntSushi/ripgrep#installation)`,
118
+ );
119
+ }
120
+
121
+ const url = `https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${artifact.name}`;
122
+ log?.(`downloading ${artifact.name} (pinned ${RG_VERSION})...`);
123
+ const res = await fetch(url);
124
+ if (!res.ok) throw new Error(`download failed: HTTP ${res.status} for ${url}`);
125
+ const bytes = Buffer.from(await res.arrayBuffer());
126
+
127
+ const digest = createHash("sha256").update(bytes).digest("hex");
128
+ if (digest !== artifact.sha256) {
129
+ throw new Error(
130
+ `sha256 mismatch for ${artifact.name}: expected ${artifact.sha256}, got ${digest} — refusing to install`,
131
+ );
132
+ }
133
+
134
+ const work = join(tmpdir(), `harnery-rg-${process.pid}`);
135
+ rmSync(work, { recursive: true, force: true });
136
+ mkdirSync(work, { recursive: true });
137
+ try {
138
+ const tarball = join(work, artifact.name);
139
+ writeFileSync(tarball, bytes);
140
+ await extractTar(tarball, work);
141
+
142
+ const extractedRg = join(work, artifact.name.replace(/\.tar\.gz$/, ""), "rg");
143
+ const rgStat = await stat(extractedRg);
144
+ if (!rgStat.isFile()) throw new Error(`extracted archive missing rg binary at ${extractedRg}`);
145
+
146
+ mkdirSync(toolsDir(), { recursive: true });
147
+ const dest = managedRgPath();
148
+ // rename() can't cross filesystems (tmp is often tmpfs); copy + rename
149
+ // within the destination dir keeps the final placement atomic.
150
+ const staged = `${dest}.tmp-${process.pid}`;
151
+ writeFileSync(staged, await readFile(extractedRg));
152
+ chmodSync(staged, 0o755);
153
+ renameSync(staged, dest);
154
+
155
+ if (!isExecutable(dest)) {
156
+ rmSync(dest, { force: true });
157
+ throw new Error(`installed rg at ${dest} but it failed to execute`);
158
+ }
159
+ log?.(`installed ripgrep ${RG_VERSION} -> ${dest}`);
160
+ return dest;
161
+ } finally {
162
+ rmSync(work, { recursive: true, force: true });
163
+ }
164
+ }
165
+
166
+ function extractTar(tarball: string, cwd: string): Promise<void> {
167
+ return new Promise((resolveP, reject) => {
168
+ const proc = spawn("tar", ["xzf", tarball], { cwd, stdio: ["ignore", "ignore", "pipe"] });
169
+ let stderr = "";
170
+ proc.stderr.setEncoding("utf8");
171
+ proc.stderr.on("data", (c: string) => {
172
+ stderr += c;
173
+ });
174
+ proc.on("error", reject);
175
+ proc.on("close", (code) => {
176
+ if (code === 0) resolveP();
177
+ else reject(new Error(`tar exited ${code}: ${stderr.trim()}`));
178
+ });
179
+ });
180
+ }
181
+
182
+ export type SearchEngine = "rg" | "grep";
183
+
184
+ /**
185
+ * Shared engine resolver for the ripgrep-backed search commands (`grep`,
186
+ * `callers`). Returns the engine plus the spawnable rg path.
187
+ *
188
+ * `HARNERY_GREP_ENGINE=rg|grep` forces one; otherwise findRg() probes
189
+ * HARNERY_RG_PATH → the managed tools-dir install → PATH. On a miss, the
190
+ * host's `.harnery/config.jsonc` `tools.ripgrep.autoInstall` consent triggers
191
+ * a pinned, checksum-verified install into the harnery tools dir (any failure
192
+ * falls back to grep with a stderr note); without consent, a rate-limited
193
+ * stderr hint (keyed per `hintKey`) names the explicit install command.
194
+ */
195
+ export async function resolveSearchEngine(
196
+ hintKey = "grep",
197
+ ): Promise<{ engine: SearchEngine; rgBin: string }> {
198
+ const forced = coordEnv("GREP_ENGINE");
199
+ if (forced === "grep") return { engine: "grep", rgBin: "rg" };
200
+ const found = findRg();
201
+ if (found) return { engine: "rg", rgBin: found };
202
+ if (forced === "rg") {
203
+ // Forced rg with none findable: let the spawn error surface loudly.
204
+ return { engine: "rg", rgBin: "rg" };
205
+ }
206
+ if (rgInstallSupported()) {
207
+ if (ripgrepAutoInstall()) {
208
+ try {
209
+ const installed = await installRg((line) => process.stderr.write(`harnery: ${line}\n`));
210
+ return { engine: "rg", rgBin: installed };
211
+ } catch (err) {
212
+ process.stderr.write(
213
+ `harnery: ripgrep auto-install failed (${(err as Error).message}); using grep fallback\n`,
214
+ );
215
+ return { engine: "grep", rgBin: "rg" };
216
+ }
217
+ }
218
+ const bin = resolveBinName();
219
+ hintOncePerDay(
220
+ `${bin} ${hintKey}: ripgrep not found; using the slower GNU grep fallback. ` +
221
+ `Run \`${bin} doctor --fix\` to install it (pinned + checksum-verified), ` +
222
+ `or set { "tools": { "ripgrep": { "autoInstall": true } } } in .harnery/config.jsonc.`,
223
+ );
224
+ }
225
+ return { engine: "grep", rgBin: "rg" };
226
+ }
227
+
228
+ /**
229
+ * Emit `line` to stderr at most once per 24h (stamp file in the tools dir).
230
+ * Keeps the "rg missing" nudge from nagging on every single grep invocation.
231
+ */
232
+ export function hintOncePerDay(line: string): void {
233
+ try {
234
+ const stamp = join(toolsDir(), ".rg-hint-stamp");
235
+ if (existsSync(stamp) && Date.now() - statSync(stamp).mtimeMs < 24 * 60 * 60 * 1000) {
236
+ return;
237
+ }
238
+ mkdirSync(toolsDir(), { recursive: true });
239
+ writeFileSync(stamp, String(Date.now()));
240
+ } catch {
241
+ // stamping failed; still emit the hint
242
+ }
243
+ process.stderr.write(`${line}\n`);
244
+ }