fix(npm): install to a user cache when the package is read-only; signal-safe first run

- When the package's bin/ is not writable (a root-owned `sudo npm i -g`
  whose postinstall npm 12 skipped), install the binary to a per-user
  cache ($XDG_CACHE_HOME, ~/.cache, ~/Library/Caches on macOS, under
  <package>/<version>/); the launcher looks in both. Previously the first
  run failed with EACCES and advice to rerun npm install.
- Run the first-run installer asynchronously with SIGTERM/SIGHUP
  forwarded, so a supervisor stopping the launcher stops the download;
  the installer removes its partial file and the launcher exits by the
  same signal.
- Verify an IDX_BINARY_URL local file's checksum on the copy, not the
  source, so the installed bytes are the checked ones.
- npm smoke covers the read-only fallback.

The last three come from a Codex review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh
This commit is contained in:
hermes 2026-10-16 08:07:41 +00:00
commit 907223a838
4 changed files with 156 additions and 51 deletions

View file

@ -52,6 +52,13 @@ itself on first start, logging to stderr so the `idx-mcp` stdout channel stays
clean; it fails only when that download fails too. Hermes Agent runs `npx` with clean; it fails only when that download fails too. Hermes Agent runs `npx` with
its own npm 12, which is how 0.3.1 broke there (issue #41). its own npm 12, which is how 0.3.1 broke there (issue #41).
The binary goes in the package's `bin/`, or, when that is not writable (a
root-owned `sudo npm install -g` whose postinstall npm 12 skipped), in a
per-user cache: `$XDG_CACHE_HOME`, else `~/.cache` (`~/Library/Caches` on
macOS), under `<package>/<version>/`. The launcher looks in both. A
termination signal during that first-run download stops the installer too,
and it removes its partial file.
To install the binary up front instead, allow the script explicitly: To install the binary up front instead, allow the script explicitly:
```bash ```bash
@ -70,7 +77,8 @@ npm install --global --allow-scripts=idx-cli idx-cli
The smoke script builds or reuses `target/release/idx`, packs the npm wrapper, The smoke script builds or reuses `target/release/idx`, packs the npm wrapper,
installs the tarball into a new temporary directory, and runs the installed installs the tarball into a new temporary directory, and runs the installed
`idx` command, then installs `idx-mcp` again with `--ignore-scripts` to check `idx` command, then installs `idx-mcp` again with `--ignore-scripts` to check
the launcher's first-run install. It uses `IDX_BINARY_URL` with a local `file:` URL, so the test the launcher's first-run install, also with a read-only package directory
(the per-user cache fallback). It uses `IDX_BINARY_URL` with a local `file:` URL, so the test
does not need a registry publish or a network download. does not need a registry publish or a network download.
```bash ```bash

View file

@ -3,51 +3,83 @@
const fs = require("node:fs"); const fs = require("node:fs");
const path = require("node:path"); const path = require("node:path");
const { spawn, spawnSync } = require("node:child_process"); const { spawn } = require("node:child_process");
const packageManifest = JSON.parse( const packageManifest = JSON.parse(
fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8"), fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8"),
); );
const nativeBinary = packageManifest.nativeBinary || "idx";
const packageName = packageManifest.name; const packageName = packageManifest.name;
const binaryName = process.platform === "win32" ? `${nativeBinary}.exe` : nativeBinary; const installer = path.join(__dirname, "install.js");
const binaryPath = path.join(__dirname, "..", "bin", binaryName); // Requiring the installer only reads its paths; it installs when run directly.
const { binaryLocations } = require(installer);
// postinstall never ran (ignore-scripts) or its download failed: install now. const findBinary = () => binaryLocations.find((candidate) => fs.existsSync(candidate));
// Its log goes to stderr, since stdout is the MCP channel for idx-mcp.
if (!fs.existsSync(binaryPath)) { // The running child: the first-run installer, then the binary.
console.error(`${packageName}: binary not installed; running the installer`); let child = null;
spawnSync(process.execPath, [path.join(__dirname, "install.js")], { // A termination signal received while installing; the installer cleans up
stdio: ["ignore", 2, 2], // and exits, and the launcher then stops the same way.
let stopping = null;
// Pass termination signals from a supervisor (kill, docker stop) to the child.
for (const signal of ["SIGTERM", "SIGHUP"]) {
process.on(signal, () => {
stopping = signal;
if (child) {
child.kill(signal);
} else {
process.exit(1);
}
}); });
} }
// Ctrl+C already reaches the child through the terminal's process group.
if (!fs.existsSync(binaryPath)) {
console.error(`${packageName}: installed binary not found at ${binaryPath}`);
console.error(`${packageName}: rerun npm install or set IDX_BINARY_URL for a local binary`);
process.exit(1);
}
const child = spawn(binaryPath, process.argv.slice(2), { stdio: "inherit" });
// Pass termination signals from a supervisor (kill, docker stop) to the binary.
for (const signal of ["SIGTERM", "SIGHUP"]) {
process.on(signal, () => child.kill(signal));
}
// Ctrl+C already reaches the binary through the terminal's process group.
process.on("SIGINT", () => {}); process.on("SIGINT", () => {});
child.once("error", (error) => { function exitLike(code, signal) {
console.error(`${packageName}: failed to start ${binaryPath}: ${error.message}`);
process.exit(1);
});
child.once("exit", (code, signal) => {
if (signal) { if (signal) {
process.removeAllListeners(signal); process.removeAllListeners(signal);
process.kill(process.pid, signal); process.kill(process.pid, signal);
return; return;
} }
process.exit(code === null ? 1 : code); process.exit(code === null ? 1 : code);
}); }
function run(binaryPath) {
child = spawn(binaryPath, process.argv.slice(2), { stdio: "inherit" });
child.once("error", (error) => {
console.error(`${packageName}: failed to start ${binaryPath}: ${error.message}`);
process.exit(1);
});
child.once("exit", exitLike);
}
const installed = findBinary();
if (installed) {
run(installed);
} else {
// postinstall never ran (npm 12 `allow-scripts`, pnpm, bun, --ignore-scripts)
// or its download failed: install now. Its log goes to stderr, since stdout
// is the MCP channel for idx-mcp.
console.error(`${packageName}: binary not installed; running the installer`);
child = spawn(process.execPath, [installer], { stdio: ["ignore", 2, 2] });
child.once("error", (error) => {
console.error(`${packageName}: failed to run the installer: ${error.message}`);
process.exit(1);
});
child.once("exit", (code, signal) => {
child = null;
if (signal || stopping) {
exitLike(code, signal || stopping);
return;
}
const binaryPath = findBinary();
if (!binaryPath) {
console.error(`${packageName}: no installed binary at ${binaryLocations.join(" or ")}`);
console.error(
`${packageName}: check access to github.com and rerun, or set IDX_BINARY_URL to a local binary`,
);
process.exit(1);
}
run(binaryPath);
});
}

View file

@ -5,6 +5,7 @@ const crypto = require("node:crypto");
const fs = require("node:fs"); const fs = require("node:fs");
const http = require("node:http"); const http = require("node:http");
const https = require("node:https"); const https = require("node:https");
const os = require("node:os");
const path = require("node:path"); const path = require("node:path");
const { pipeline } = require("node:stream"); const { pipeline } = require("node:stream");
const { URL, fileURLToPath } = require("node:url"); const { URL, fileURLToPath } = require("node:url");
@ -18,8 +19,21 @@ const packageManifest = JSON.parse(
const nativeBinary = packageManifest.nativeBinary || "idx"; const nativeBinary = packageManifest.nativeBinary || "idx";
const packageName = packageManifest.name; const packageName = packageManifest.name;
const binaryName = process.platform === "win32" ? `${nativeBinary}.exe` : nativeBinary; const binaryName = process.platform === "win32" ? `${nativeBinary}.exe` : nativeBinary;
const binaryDirectory = path.join(packageRoot, "bin"); // The binary goes in the package's own bin/, or, when that is not writable
const binaryPath = path.join(binaryDirectory, binaryName); // (a root-owned global install whose postinstall npm 12 skipped), in a
// per-user cache. scripts/idx.js looks in both, in this order.
const packageBinaryPath = path.join(packageRoot, "bin", binaryName);
const cacheBinaryPath = path.join(
process.env.XDG_CACHE_HOME ||
(process.platform === "darwin"
? path.join(os.homedir(), "Library", "Caches")
: path.join(os.homedir(), ".cache")),
packageName,
packageManifest.version,
binaryName,
);
const binaryLocations = [packageBinaryPath, cacheBinaryPath];
let binaryPath = packageBinaryPath;
const githubRepository = "0xrsydn/idx-cli"; const githubRepository = "0xrsydn/idx-cli";
const maxRedirects = 5; const maxRedirects = 5;
const requestTimeoutMs = 60_000; const requestTimeoutMs = 60_000;
@ -65,15 +79,43 @@ function releaseAssetUrl(assetName) {
return `https://github.com/${githubRepository}/releases/download/v${packageManifest.version}/${assetName}`; return `https://github.com/${githubRepository}/releases/download/v${packageManifest.version}/${assetName}`;
} }
// Picks the first writable location and points binaryPath at it.
function ensureBinaryDirectory() { function ensureBinaryDirectory() {
fs.mkdirSync(binaryDirectory, { recursive: true }); for (const candidate of binaryLocations) {
const directory = path.dirname(candidate);
try {
fs.mkdirSync(directory, { recursive: true });
fs.accessSync(directory, fs.constants.W_OK);
binaryPath = candidate;
return;
} catch (error) {
if (!["EACCES", "EPERM", "EROFS"].includes(error.code)) {
throw error;
}
}
}
throw new Error(`no writable location for the binary: ${binaryLocations.join(", ")}`);
} }
let activeTemporaryPath = null;
function removeActiveTemporary() {
if (activeTemporaryPath && fs.existsSync(activeTemporaryPath)) {
fs.unlinkSync(activeTemporaryPath);
}
activeTemporaryPath = null;
}
// Each install writes its own temporary file and renames it into place, so
// concurrent first runs never expose a partial binary.
function temporaryBinaryPath() { function temporaryBinaryPath() {
return `${binaryPath}.${process.pid}.tmp`; activeTemporaryPath = `${binaryPath}.${process.pid}.tmp`;
return activeTemporaryPath;
} }
function replaceWithLocalBinary(sourcePath) { // Verifies the copy rather than the source, so the installed bytes are the
// checked ones even if the source changes meanwhile.
function replaceWithLocalBinary(sourcePath, expectedSha256) {
const resolvedSource = path.resolve(sourcePath); const resolvedSource = path.resolve(sourcePath);
const sourceStat = fs.statSync(resolvedSource, { throwIfNoEntry: false }); const sourceStat = fs.statSync(resolvedSource, { throwIfNoEntry: false });
if (!sourceStat || !sourceStat.isFile()) { if (!sourceStat || !sourceStat.isFile()) {
@ -83,12 +125,13 @@ function replaceWithLocalBinary(sourcePath) {
const temporaryPath = temporaryBinaryPath(); const temporaryPath = temporaryBinaryPath();
try { try {
fs.copyFileSync(resolvedSource, temporaryPath); fs.copyFileSync(resolvedSource, temporaryPath);
if (expectedSha256) {
verifyChecksum(temporaryPath, expectedSha256, resolvedSource);
}
fs.chmodSync(temporaryPath, 0o755); fs.chmodSync(temporaryPath, 0o755);
fs.renameSync(temporaryPath, binaryPath); fs.renameSync(temporaryPath, binaryPath);
} finally { } finally {
if (fs.existsSync(temporaryPath)) { removeActiveTemporary();
fs.unlinkSync(temporaryPath);
}
} }
} }
@ -199,9 +242,7 @@ async function replaceWithDownloadedBinary(url, expectedSha256) {
fs.chmodSync(temporaryPath, 0o755); fs.chmodSync(temporaryPath, 0o755);
fs.renameSync(temporaryPath, binaryPath); fs.renameSync(temporaryPath, binaryPath);
} finally { } finally {
if (fs.existsSync(temporaryPath)) { removeActiveTemporary();
fs.unlinkSync(temporaryPath);
}
} }
} }
@ -241,10 +282,7 @@ async function main() {
if (override) { if (override) {
const localPath = localPathFromOverride(override); const localPath = localPathFromOverride(override);
if (localPath) { if (localPath) {
if (overrideSha256) { replaceWithLocalBinary(localPath, overrideSha256);
verifyChecksum(localPath, overrideSha256, localPath);
}
replaceWithLocalBinary(localPath);
console.log(`${packageName}: installed local binary from ${path.resolve(localPath)}`); console.log(`${packageName}: installed local binary from ${path.resolve(localPath)}`);
return; return;
} }
@ -260,7 +298,19 @@ async function main() {
console.log(`${packageName}: installed binary at ${binaryPath}`); console.log(`${packageName}: installed binary at ${binaryPath}`);
} }
main().catch((error) => { module.exports = { binaryLocations };
if (require.main === module) {
// A supervisor stopping the launcher mid-download stops this too; leave no
// temporary file behind.
for (const [signal, number] of [["SIGTERM", 15], ["SIGHUP", 1], ["SIGINT", 2]]) {
process.on(signal, () => {
removeActiveTemporary();
process.exit(128 + number);
});
}
main().catch((error) => {
console.error(`${packageName}: ${error.message}`); console.error(`${packageName}: ${error.message}`);
process.exit(1); process.exit(1);
}); });
}

View file

@ -95,4 +95,19 @@ if ! head -n 1 "${smoke_dir}/no-scripts-help.txt" | grep -q "MCP server"; then
cat "${smoke_dir}/no-scripts-help.txt" >&2 cat "${smoke_dir}/no-scripts-help.txt" >&2
exit 1 exit 1
fi fi
echo "npm smoke: PASS (idx-cli and idx-mcp: pack, install, --help; idx-mcp without postinstall)" # A read-only package (root-owned global install) falls back to the user cache.
echo "npm smoke: idx-mcp without postinstall, read-only package directory"
read_only_dir="${smoke_dir}/idx-mcp-read-only"
npm_config_cache="$npm_cache" npm install --prefix "$read_only_dir" "${smoke_dir}"/idx-mcp/idx-mcp-*.tgz \
--no-save --no-package-lock --no-audit --no-fund --ignore-scripts
chmod -R a-w "${read_only_dir}/node_modules/idx-mcp"
XDG_CACHE_HOME="${smoke_dir}/user-cache" IDX_BINARY_URL="${repo_root}/target/release/idx-mcp" \
"${read_only_dir}/node_modules/.bin/idx-mcp" --help > "${smoke_dir}/read-only-help.txt"
chmod -R u+w "${read_only_dir}/node_modules/idx-mcp"
cached=("${smoke_dir}"/user-cache/idx-mcp/*/idx-mcp)
if ! head -n 1 "${smoke_dir}/read-only-help.txt" | grep -q "MCP server" || [[ ! -x "${cached[0]}" ]]; then
echo "npm smoke: idx-mcp with a read-only package did not install to the user cache:" >&2
cat "${smoke_dir}/read-only-help.txt" >&2
exit 1
fi
echo "npm smoke: PASS (idx-cli and idx-mcp: pack, install, --help; idx-mcp without postinstall, also read-only)"