donsetch 4.7.3 → 4.7.4

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 CHANGED
@@ -59,6 +59,7 @@ Downloads the prebuilt binary for your platform from [GitHub Releases](https://g
59
59
 
60
60
  - **pnpm or bun:** the postinstall needs approval. `pnpm approve-builds` (or the bun equivalent), then reinstall. If scripts were blocked, `npx donsetch` runs the self-healing shim.
61
61
  - **`--ignore-scripts`:** postinstall is intentionally skipped. Run `node node_modules/donsetch/install.js`, or use `npx donsetch` to download the binary when network access is available.
62
+ - **System-wide installs:** if the npm prefix is root-owned (for example `/usr/local/lib/node_modules`) and you run donsetch as a normal user, the package copy cannot be written there. donsetch then uses an existing binary from `PATH` or `~/.cargo/bin` automatically; `DONSETCH_BINARY=/path/to/donsetch` points at one explicitly. To keep the package copy instead: `npm config set prefix ~/.npm-global && npm install -g donsetch`.
62
63
  - **Proxy:** set `HTTPS_PROXY` (or `https_proxy`, `HTTP_PROXY`, `http_proxy`) to an HTTP CONNECT proxy.
63
64
  - **Release mirror:** set `DONSETCH_RELEASES_BASE` to a mirror serving `<tag>/<asset>` paths, for example `https://mirror.example/donsetch/releases`.
64
65
  - **Windows:** the installer needs `tar`, included since Windows 10 1803.
package/bin/donsetch.js CHANGED
@@ -1,48 +1,44 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
- // donsetch CLI wrapper: spawn the native binary with forwarded stdio.
4
+ // donsetch CLI wrapper: resolve the binary and spawn it with
5
+ // forwarded stdio.
5
6
  //
6
7
  // For MCP server usage (`donsetch mcp`), the MCP client spawns this
7
8
  // wrapper as a subprocess and communicates via stdin/stdout (JSON-RPC).
8
9
  // stdio: 'inherit' pipes the native binary's stdio directly to the
9
10
  // parent process, so the MCP protocol passes through unmodified.
10
11
  //
12
+ // Resolution goes through resolve-binary.js: package-local binary
13
+ // first, install.js when it can run, then an existing donsetch on
14
+ // PATH / $CARGO_HOME / ~/.cargo/bin (#361: a system-wide package
15
+ // under a root-owned prefix must not kill a working install). All
16
+ // installer chatter and notices go to stderr: stdout belongs to the
17
+ // protocol.
18
+ //
11
19
  // Signal forwarding: when the MCP client sends SIGTERM/SIGINT to this
12
20
  // wrapper, we forward it to the native binary so it can clean up
13
21
  // (close connections, save ghost state, etc.) before exiting.
14
22
 
15
- const { spawn, execFileSync } = require('child_process');
16
- const { existsSync } = require('fs');
23
+ const { spawn } = require('child_process');
17
24
  const { join } = require('path');
25
+ const { resolveBinary } = require('../resolve-binary.js');
18
26
 
19
27
  // ── resolve binary path ─────────────────────────────────────────
20
- const binaryName = process.platform === 'win32' ? 'donsetch.exe' : 'donsetch';
21
- const binDir = join(__dirname, '..', 'binaries');
22
- const binaryPath = join(binDir, binaryName);
23
-
24
- if (!existsSync(binaryPath)) {
25
- const installScript = join(__dirname, '..', 'install.js');
26
- try {
27
- execFileSync(process.execPath, [installScript], {
28
- stdio: 'inherit',
29
- cwd: join(__dirname, '..'),
30
- });
31
- } catch (_) {
32
- process.stderr.write(
33
- `donsetch: native binary missing and download failed. Retry with network access, ` +
34
- `or run node ${installScript}; pnpm users: pnpm approve-builds then reinstall.\n`
35
- );
36
- process.exit(1);
37
- }
38
- if (!existsSync(binaryPath)) {
39
- process.stderr.write(
40
- `donsetch: native binary missing and download failed. Retry with network access, ` +
41
- `or run node ${installScript}; pnpm users: pnpm approve-builds then reinstall.\n`
42
- );
43
- process.exit(1);
44
- }
28
+ let resolved;
29
+ try {
30
+ resolved = resolveBinary({
31
+ pkgDir: join(__dirname, '..'),
32
+ self: __filename,
33
+ onOutput: (chunk) => process.stderr.write(chunk),
34
+ });
35
+ } catch (err) {
36
+ process.stderr.write(`donsetch: ${err.message || err}\n`);
37
+ process.exit(1);
45
38
  }
39
+ if (resolved.notice) process.stderr.write(resolved.notice + '\n');
40
+
41
+ const binaryPath = resolved.path;
46
42
 
47
43
  // ── spawn native binary ─────────────────────────────────────────
48
44
  const child = spawn(binaryPath, process.argv.slice(2), {
package/install.js CHANGED
@@ -11,6 +11,7 @@ const crypto = require('crypto');
11
11
  const fs = require('fs');
12
12
  const path = require('path');
13
13
  const { execFileSync } = require('child_process');
14
+ const { findExistingBinary, targetWritable } = require('./resolve-binary.js');
14
15
  const { pipeline } = require('stream/promises');
15
16
 
16
17
  const REPO = 'dondai44423/donsetch';
@@ -269,12 +270,26 @@ async function download(url, dest) {
269
270
  }
270
271
 
271
272
  async function main() {
272
- const tarball = path.join(binDir, plat.asset);
273
- const checksumFile = path.join(binDir, 'checksum.sha256');
274
-
275
- // Inside main() so an unwritable install dir gets the curated
276
- // error below, not a raw Node stack trace from a top-level throw.
277
- fs.mkdirSync(binDir, { recursive: true });
273
+ // Per-pid temp names: two first runs racing (two MCP clients
274
+ // starting together) must never share a write target. The renames
275
+ // that land the files are atomic; the temps are invisible until then.
276
+ const tarball = path.join(binDir, `${plat.asset}.tmp-${process.pid}`);
277
+ const checksumFile = path.join(binDir, `checksum.sha256.tmp-${process.pid}`);
278
+ const extractDir = path.join(binDir, `extract.tmp-${process.pid}`);
279
+
280
+ // The unwritable install dir (#361: a system-wide npm package under
281
+ // a root-owned prefix, run by an unprivileged user) is decided
282
+ // BEFORE any download, so the failure names the directory and the
283
+ // ways out instead of dying after 19MB of network.
284
+ try {
285
+ fs.mkdirSync(binDir, { recursive: true });
286
+ } catch (err) {
287
+ settleUnwritable(err);
288
+ }
289
+ if (!targetWritable(__dirname)) {
290
+ settleUnwritable(new Error(`${binDir} is not writable`));
291
+ }
292
+ cleanupStaleTemps();
278
293
 
279
294
  if (process.platform === 'win32') {
280
295
  try {
@@ -300,11 +315,22 @@ async function main() {
300
315
  }
301
316
 
302
317
  console.log('donsetch: extracting...');
303
- execFileSync('tar', ['xzf', tarball, '-C', binDir], { stdio: 'inherit' });
318
+ fs.rmSync(extractDir, { recursive: true, force: true });
319
+ fs.mkdirSync(extractDir, { recursive: true });
320
+ execFileSync('tar', ['xzf', tarball, '-C', extractDir], { stdio: 'inherit' });
304
321
 
305
- if (!fs.existsSync(binaryPath)) {
322
+ const entries = fs.readdirSync(extractDir);
323
+ if (!entries.includes(plat.binary)) {
306
324
  throw new Error(`expected ${plat.binary} not found after extraction in ${binDir}`);
307
325
  }
326
+ // Land each entry with a rename inside binDir: a rename is atomic,
327
+ // so a concurrent first run can only ever replace a complete file,
328
+ // never expose a half-written one. The stamp goes last: a crash
329
+ // before it leaves no stamp, and the next run re-downloads.
330
+ for (const name of entries) {
331
+ fs.renameSync(path.join(extractDir, name), path.join(binDir, name));
332
+ }
333
+ fs.rmSync(extractDir, { recursive: true, force: true });
308
334
  if (process.platform !== 'win32') fs.chmodSync(binaryPath, 0o755);
309
335
  fs.writeFileSync(stampPath, `${VERSION}\n`);
310
336
  console.log(`donsetch: installed ${plat.binary} to ${binaryPath}`);
@@ -314,13 +340,80 @@ async function main() {
314
340
  // and checksum sitting in ./binaries/ forever.
315
341
  try { fs.unlinkSync(tarball); } catch (_) {}
316
342
  try { fs.unlinkSync(checksumFile); } catch (_) {}
343
+ try { fs.rmSync(extractDir, { recursive: true, force: true }); } catch (_) {}
344
+ }
345
+ }
346
+
347
+ // One existing-binary lookup for the failure paths: an install that
348
+ // cannot succeed must not take a working donsetch down with it (#361).
349
+ // Returns false when there is nothing to fall back on.
350
+ function ackExisting(detail) {
351
+ let existing = null;
352
+ try {
353
+ existing = findExistingBinary({ pkgDir: __dirname });
354
+ } catch (_) {
355
+ return false;
356
+ }
357
+ if (!existing) return false;
358
+ console.log(`donsetch: install failed: ${detail}`);
359
+ console.log(`donsetch: an existing binary was found and will be used: ${existing.path}`);
360
+ process.exit(0);
361
+ }
362
+
363
+ // The unwritable install dir: acknowledge an existing binary when one
364
+ // is findable (npm install / postinstall stays green), otherwise fail
365
+ // with every way out named.
366
+ function settleUnwritable(cause) {
367
+ const detail = cause && cause.message ? cause.message : 'directory is not writable';
368
+ if (ackExisting(detail)) return;
369
+ console.error(
370
+ `donsetch: cannot install into ${binDir} (${detail}) and no existing donsetch binary was found.`
371
+ );
372
+ console.error('');
373
+ console.error('Options:');
374
+ console.error(' - install under a user-writable prefix:');
375
+ console.error(' npm config set prefix ~/.npm-global && npm install -g donsetch');
376
+ console.error(' - point at a donsetch binary you already have:');
377
+ console.error(' DONSETCH_BINARY=/path/to/donsetch');
378
+ console.error(' - build from source:');
379
+ console.error(` git clone https://github.com/${REPO} && cd donsetch && cargo build --release`);
380
+ process.exit(1);
381
+ }
382
+
383
+ // Temp leftovers from crashed runs (this installer is the only writer
384
+ // of `.tmp-<pid>` / `extract.tmp-<pid>` names in binDir). A pid that
385
+ // is still alive keeps its workspace.
386
+ function cleanupStaleTemps() {
387
+ let names;
388
+ try {
389
+ names = fs.readdirSync(binDir);
390
+ } catch (_) {
391
+ return;
392
+ }
393
+ for (const name of names) {
394
+ const m = /\.tmp-(\d+)$/.exec(name);
395
+ if (!m || Number(m[1]) === process.pid) continue;
396
+ let alive = false;
397
+ try {
398
+ process.kill(Number(m[1]), 0);
399
+ alive = true;
400
+ } catch (_) {/* not running (or not ours): its temps are stale */}
401
+ if (!alive) {
402
+ try { fs.rmSync(path.join(binDir, name), { recursive: true, force: true }); } catch (_) {}
403
+ }
317
404
  }
318
405
  }
319
406
 
320
407
  main().catch((error) => {
408
+ if (ackExisting(error.message)) return;
321
409
  console.error(`donsetch: install failed: ${error.message}`);
322
410
  console.error('');
323
- console.error('You can build from source:');
411
+ console.error('Options:');
412
+ console.error(' - install under a user-writable prefix:');
413
+ console.error(' npm config set prefix ~/.npm-global && npm install -g donsetch');
414
+ console.error(' - point at a donsetch binary you already have:');
415
+ console.error(' DONSETCH_BINARY=/path/to/donsetch');
416
+ console.error(' - build from source:');
324
417
  console.error(` git clone https://github.com/${REPO} && cd donsetch && cargo build --release`);
325
418
  process.exit(1);
326
419
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "donsetch",
3
- "version": "4.7.3",
3
+ "version": "4.7.4",
4
4
  "description": "MCP server that gives AI agents the web: fetch, search, crawl and screenshot from one local Rust binary. Zero API keys, zero accounts, Chrome-true TLS.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Bishesh Bhandari",
@@ -49,10 +49,12 @@
49
49
  "donsetch": "bin/donsetch.js"
50
50
  },
51
51
  "scripts": {
52
- "postinstall": "node install.js"
52
+ "postinstall": "node install.js",
53
+ "test": "node --test --test-timeout=90000 tests/*.test.js"
53
54
  },
54
55
  "files": [
55
56
  "install.js",
57
+ "resolve-binary.js",
56
58
  "bin/donsetch.js",
57
59
  "pi-extension.ts",
58
60
  "README.md",
package/pi-extension.ts CHANGED
@@ -11,8 +11,11 @@
11
11
  * them up automatically : no code changes needed here.
12
12
  *
13
13
  * Auto-download: if the binary is missing (e.g. postinstall was
14
- * blocked by npm 10+), the extension runs install.js at session_start
15
- * to fetch it from GitHub Releases.
14
+ * blocked by npm 10+), the extension resolves through
15
+ * resolve-binary.js at session_start: the package binary gets
16
+ * installed when possible, otherwise an existing donsetch
17
+ * (DONSETCH_BINARY / PATH / cargo bin) is used (#361: system-wide
18
+ * installs under a root-owned prefix).
16
19
  *
17
20
  * Custom TUI: each tool has clean renderCall/renderResult showing
18
21
  * a compact summary card : not the full raw output. The LLM still
@@ -22,7 +25,8 @@
22
25
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
23
26
  import { Type } from "typebox";
24
27
  import { spawn, execFileSync, type ChildProcess } from "node:child_process";
25
- import { existsSync } from "node:fs";
28
+ import { existsSync, readFileSync, rmSync } from "node:fs";
29
+ import { tmpdir } from "node:os";
26
30
  import { join } from "node:path";
27
31
  import { Text } from "@earendil-works/pi-tui";
28
32
 
@@ -108,32 +112,35 @@ function ensureBinary(): string {
108
112
  const binaryPath = getBinaryPath();
109
113
  if (existsSync(binaryPath)) return binaryPath;
110
114
 
111
- const installScript = join(__dirname, "install.js");
112
- if (!existsSync(installScript)) {
115
+ const resolverScript = join(__dirname, "resolve-binary.js");
116
+ if (!existsSync(resolverScript)) {
113
117
  throw new Error(
114
- `donsetch binary not found at ${binaryPath} and install.js is missing. ` +
118
+ `donsetch binary not found at ${binaryPath} and resolve-binary.js is missing. ` +
115
119
  `Run \`npm rebuild donsetch\` or \`npm install -g --allow-scripts=donsetch donsetch@latest\`.`
116
120
  );
117
121
  }
118
122
 
123
+ // The shared resolver installs the package binary when it can and
124
+ // falls back to an existing donsetch when it cannot. stdio is
125
+ // inherited so first-run download progress stays visible; the path
126
+ // arrives via a temp file because stdout belongs to the terminal.
127
+ const outFile = join(tmpdir(), `donsetch-resolve-${process.pid}.txt`);
119
128
  try {
120
- execFileSync(process.execPath, [installScript], {
121
- stdio: "inherit",
122
- cwd: __dirname,
123
- timeout: 300_000,
124
- });
129
+ execFileSync(
130
+ process.execPath,
131
+ [resolverScript, "--print-file", outFile, "--pkg-dir", __dirname],
132
+ { stdio: "inherit", cwd: __dirname, timeout: 300_000 }
133
+ );
134
+ const resolved = readFileSync(outFile, "utf8").trim();
135
+ if (!resolved) throw new Error("resolver returned no binary path");
136
+ return resolved;
125
137
  } catch (err: any) {
126
- throw new Error(`Failed to download donsetch binary: ${err.message}`);
127
- }
128
-
129
- if (!existsSync(binaryPath)) {
130
138
  throw new Error(
131
- `donsetch binary still missing after install.js ran. ` +
132
- `Run \`npm install -g --allow-scripts=donsetch donsetch@latest\` manually.`
139
+ `Failed to resolve the donsetch binary. The resolver output above names the cause and the ways out (${err.message}).`
133
140
  );
141
+ } finally {
142
+ try { rmSync(outFile, { force: true }); } catch { /* best effort */ }
134
143
  }
135
-
136
- return binaryPath;
137
144
  }
138
145
 
139
146
  // ── MCP JSON-RPC 2.0 over stdio ──
@@ -0,0 +1,247 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Binary resolution for the npm package, shared by every consumer:
5
+ // bin/donsetch.js (the CLI / MCP wrapper, in-process), install.js
6
+ // (install-time acknowledgement), and pi-extension.ts (spawned as
7
+ // `node resolve-binary.js --print-file <path>`).
8
+ //
9
+ // Order:
10
+ // 1. DONSETCH_BINARY explicit override, wins over everything
11
+ // 2. <pkg>/binaries/<name> the pinned copy this package installs
12
+ // 3. install.js fetch the pinned copy when it can run
13
+ // 4. an existing binary PATH, $CARGO_HOME/bin, ~/.cargo/bin
14
+ // 5. hard failure curated, names every way out
15
+ //
16
+ // The fallback exists because the pinned copy is not always obtainable
17
+ // (issue #361): a system-wide npm package under a root-owned prefix
18
+ // cannot be written by the user running it, and an install that cannot
19
+ // succeed must not take a working donsetch down with it. The wrapper
20
+ // itself is never a candidate: on a global npm install the PATH entry
21
+ // named `donsetch` is a symlink back into this package, so candidates
22
+ // are compared by their real path and anything resolving into the
23
+ // package is skipped.
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+ const { execFileSync } = require('child_process');
28
+
29
+ const BINARY_NAME = process.platform === 'win32' ? 'donsetch.exe' : 'donsetch';
30
+
31
+ function isExecutableFile(p) {
32
+ try {
33
+ if (!fs.statSync(p).isFile()) return false;
34
+ if (process.platform !== 'win32') fs.accessSync(p, fs.constants.X_OK);
35
+ return true;
36
+ } catch (_) {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ function real(p) {
42
+ try {
43
+ return fs.realpathSync(p);
44
+ } catch (_) {
45
+ return p;
46
+ }
47
+ }
48
+
49
+ function packageBinary(pkgDir) {
50
+ return path.join(pkgDir, 'binaries', BINARY_NAME);
51
+ }
52
+
53
+ // The package-local copy, with a chmod rescue: an executable bit lost
54
+ // in transit (copied tree, artifact download) is repaired in place
55
+ // rather than triggering a re-download.
56
+ function usableLocal(p) {
57
+ if (!fs.existsSync(p)) return false;
58
+ if (isExecutableFile(p)) return true;
59
+ if (process.platform !== 'win32') {
60
+ try {
61
+ fs.chmodSync(p, 0o755);
62
+ } catch (_) {/* fall through to the recheck */}
63
+ }
64
+ return isExecutableFile(p);
65
+ }
66
+
67
+ // True when install.js could write into <pkgDir>/binaries. A missing
68
+ // directory is judged by its parent. Advisory only: the install itself
69
+ // still handles a late failure, but a known-unwritable target skips a
70
+ // doomed 19MB download first (#361).
71
+ function targetWritable(pkgDir) {
72
+ const binDir = path.join(pkgDir, 'binaries');
73
+ try {
74
+ if (fs.existsSync(binDir)) {
75
+ fs.accessSync(binDir, fs.constants.W_OK);
76
+ return true;
77
+ }
78
+ fs.accessSync(pkgDir, fs.constants.W_OK);
79
+ return true;
80
+ } catch (_) {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ // The one explicit override. Invalid values fail loudly: a DONSETCH_BINARY
86
+ // that names nothing is a config bug, not a fall-through case.
87
+ function overrideBinary() {
88
+ const over = process.env.DONSETCH_BINARY;
89
+ if (!over) return null;
90
+ const p = path.resolve(over);
91
+ if (!isExecutableFile(p)) {
92
+ throw new Error(
93
+ `DONSETCH_BINARY is set to ${p}, which is not an executable file. ` +
94
+ `Unset it or point it at a donsetch binary.`
95
+ );
96
+ }
97
+ return { path: p, source: 'env' };
98
+ }
99
+
100
+ // An already-installed donsetch outside this package: PATH entries
101
+ // (skipping empty ones, which mean the current directory), then the
102
+ // cargo bin directories even when they are not on PATH.
103
+ function findExistingBinary(opts) {
104
+ const pkgDir = opts.pkgDir;
105
+
106
+ const over = overrideBinary();
107
+ if (over) return over;
108
+
109
+ const banned = new Set([real(path.join(pkgDir, 'bin', 'donsetch.js'))]);
110
+ if (opts.self) banned.add(real(opts.self));
111
+ const pkgRoot = real(pkgDir);
112
+ const pkgPrefix = pkgRoot.endsWith(path.sep) ? pkgRoot : pkgRoot + path.sep;
113
+
114
+ const candidates = [];
115
+ const seen = new Set();
116
+ const add = (p) => {
117
+ const r = real(p);
118
+ if (seen.has(r)) return;
119
+ seen.add(r);
120
+ candidates.push({ p, r });
121
+ };
122
+
123
+ for (const dir of (process.env.PATH || '').split(path.delimiter)) {
124
+ if (!dir) continue;
125
+ add(path.join(dir, BINARY_NAME));
126
+ }
127
+ const home = process.env.HOME || process.env.USERPROFILE;
128
+ if (process.env.CARGO_HOME) add(path.join(process.env.CARGO_HOME, 'bin', BINARY_NAME));
129
+ if (home) add(path.join(home, '.cargo', 'bin', BINARY_NAME));
130
+
131
+ for (const c of candidates) {
132
+ if (banned.has(c.r)) continue;
133
+ if (c.r === pkgRoot || c.r.startsWith(pkgPrefix)) continue;
134
+ if (!isExecutableFile(c.p)) continue;
135
+ return { path: c.p, source: 'path' };
136
+ }
137
+ return null;
138
+ }
139
+
140
+ function noBinaryMessage(why) {
141
+ return [
142
+ `could not install the donsetch binary and none was found (${why}).`,
143
+ 'Options:',
144
+ ' - install under a user-writable prefix:',
145
+ ' npm config set prefix ~/.npm-global && npm install -g donsetch',
146
+ ' - point at a donsetch binary you already have:',
147
+ ' DONSETCH_BINARY=/path/to/donsetch',
148
+ ' - build from source:',
149
+ ' git clone https://github.com/dondai44423/donsetch && cd donsetch && cargo build --release',
150
+ ].join('\n');
151
+ }
152
+
153
+ function runInstaller(pkgDir, onOutput) {
154
+ const installScript = path.join(pkgDir, 'install.js');
155
+ if (!fs.existsSync(installScript)) {
156
+ return { ok: false, error: `installer missing: ${installScript}` };
157
+ }
158
+ try {
159
+ const out = execFileSync(process.execPath, [installScript], {
160
+ cwd: pkgDir,
161
+ encoding: 'utf8',
162
+ timeout: 300_000,
163
+ stdio: ['ignore', 'pipe', 'pipe'],
164
+ });
165
+ if (onOutput && out) onOutput(out);
166
+ return { ok: true };
167
+ } catch (err) {
168
+ if (onOutput) {
169
+ if (err.stdout) onOutput(String(err.stdout));
170
+ if (err.stderr) onOutput(String(err.stderr));
171
+ }
172
+ const lines = String(err.stderr || err.message || err).trim().split('\n');
173
+ return { ok: false, error: (lines[0] || 'installer failed').trim() };
174
+ }
175
+ }
176
+
177
+ // Resolve the binary for a run. `onOutput` receives installer output
178
+ // chunks; callers point it at stderr (never stdout: an MCP stdio
179
+ // session owns that channel).
180
+ function resolveBinary(opts) {
181
+ const pkgDir = opts.pkgDir;
182
+ const onOutput = opts.onOutput;
183
+
184
+ const over = overrideBinary();
185
+ if (over) return over;
186
+
187
+ const local = packageBinary(pkgDir);
188
+ if (usableLocal(local)) return { path: local, source: 'package' };
189
+
190
+ let why;
191
+ if (process.env.DONSETCH_SKIP_DOWNLOAD === '1') {
192
+ why = 'download skipped (DONSETCH_SKIP_DOWNLOAD=1)';
193
+ } else if (!targetWritable(pkgDir)) {
194
+ why = `${path.join(pkgDir, 'binaries')} is not writable`;
195
+ } else {
196
+ const result = runInstaller(pkgDir, onOutput);
197
+ if (usableLocal(local)) return { path: local, source: 'package' };
198
+ // The installer can also exit 0 without a local binary (its own
199
+ // existing-binary acknowledgement): name that, never "undefined".
200
+ why = result.ok ? 'the installer did not produce the binary' : result.error;
201
+ }
202
+
203
+ const existing = findExistingBinary({ pkgDir, self: opts.self });
204
+ if (existing) {
205
+ return {
206
+ path: existing.path,
207
+ source: existing.source,
208
+ notice: `donsetch: package binary unavailable (${why}); using existing binary: ${existing.path}`,
209
+ };
210
+ }
211
+ throw new Error(noBinaryMessage(why));
212
+ }
213
+
214
+ module.exports = { resolveBinary, findExistingBinary, targetWritable, packageBinary };
215
+
216
+ // ── CLI mode ─────────────────────────────────────────────────────
217
+ // Used by pi-extension.ts. `--print` writes the resolved path to
218
+ // stdout; `--print-file <path>` writes it to a file and keeps stdout
219
+ // free for an inheriting caller. Progress and failures go to stderr.
220
+ if (require.main === module) {
221
+ const args = process.argv.slice(2);
222
+ const pkgIdx = args.indexOf('--pkg-dir');
223
+ const fileIdx = args.indexOf('--print-file');
224
+ if (pkgIdx < 0) {
225
+ process.stderr.write('usage: resolve-binary.js --pkg-dir <dir> [--print-file <path>]\n');
226
+ process.exit(2);
227
+ }
228
+ const pkgDir = path.resolve(args[pkgIdx + 1] || '');
229
+ const outFile = fileIdx >= 0 ? args[fileIdx + 1] : null;
230
+ try {
231
+ const resolved = resolveBinary({
232
+ pkgDir,
233
+ self: process.argv[1],
234
+ onOutput: (chunk) => process.stderr.write(chunk),
235
+ });
236
+ if (resolved.notice) process.stderr.write(resolved.notice + '\n');
237
+ if (outFile) {
238
+ fs.writeFileSync(outFile, resolved.path + '\n');
239
+ } else {
240
+ process.stdout.write(resolved.path + '\n');
241
+ }
242
+ process.exit(0);
243
+ } catch (err) {
244
+ process.stderr.write(String(err.message || err) + '\n');
245
+ process.exit(1);
246
+ }
247
+ }