mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
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:
parent
6f44c3ce50
commit
907223a838
4 changed files with 156 additions and 51 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
|
||||||
console.error(`${packageName}: binary not installed; running the installer`);
|
|
||||||
spawnSync(process.execPath, [path.join(__dirname, "install.js")], {
|
|
||||||
stdio: ["ignore", 2, 2],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fs.existsSync(binaryPath)) {
|
// The running child: the first-run installer, then the binary.
|
||||||
console.error(`${packageName}: installed binary not found at ${binaryPath}`);
|
let child = null;
|
||||||
console.error(`${packageName}: rerun npm install or set IDX_BINARY_URL for a local binary`);
|
// A termination signal received while installing; the installer cleans up
|
||||||
|
// 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);
|
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.
|
// Ctrl+C already reaches the child 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) => {
|
main().catch((error) => {
|
||||||
console.error(`${packageName}: ${error.message}`);
|
console.error(`${packageName}: ${error.message}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue