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
|
||||
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:
|
||||
|
||||
```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,
|
||||
installs the tarball into a new temporary directory, and runs the installed
|
||||
`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.
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -3,51 +3,83 @@
|
|||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { spawn, spawnSync } = require("node:child_process");
|
||||
const { spawn } = require("node:child_process");
|
||||
|
||||
const packageManifest = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8"),
|
||||
);
|
||||
const nativeBinary = packageManifest.nativeBinary || "idx";
|
||||
const packageName = packageManifest.name;
|
||||
const binaryName = process.platform === "win32" ? `${nativeBinary}.exe` : nativeBinary;
|
||||
const binaryPath = path.join(__dirname, "..", "bin", binaryName);
|
||||
const installer = path.join(__dirname, "install.js");
|
||||
// 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.
|
||||
// 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],
|
||||
const findBinary = () => binaryLocations.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
// The running child: the first-run installer, then the binary.
|
||||
let child = null;
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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.
|
||||
// Ctrl+C already reaches the child through the terminal's process group.
|
||||
process.on("SIGINT", () => {});
|
||||
|
||||
child.once("error", (error) => {
|
||||
console.error(`${packageName}: failed to start ${binaryPath}: ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
child.once("exit", (code, signal) => {
|
||||
function exitLike(code, signal) {
|
||||
if (signal) {
|
||||
process.removeAllListeners(signal);
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
|
||||
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 http = require("node:http");
|
||||
const https = require("node:https");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { pipeline } = require("node:stream");
|
||||
const { URL, fileURLToPath } = require("node:url");
|
||||
|
|
@ -18,8 +19,21 @@ const packageManifest = JSON.parse(
|
|||
const nativeBinary = packageManifest.nativeBinary || "idx";
|
||||
const packageName = packageManifest.name;
|
||||
const binaryName = process.platform === "win32" ? `${nativeBinary}.exe` : nativeBinary;
|
||||
const binaryDirectory = path.join(packageRoot, "bin");
|
||||
const binaryPath = path.join(binaryDirectory, binaryName);
|
||||
// The binary goes in the package's own bin/, or, when that is not writable
|
||||
// (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 maxRedirects = 5;
|
||||
const requestTimeoutMs = 60_000;
|
||||
|
|
@ -65,15 +79,43 @@ function releaseAssetUrl(assetName) {
|
|||
return `https://github.com/${githubRepository}/releases/download/v${packageManifest.version}/${assetName}`;
|
||||
}
|
||||
|
||||
// Picks the first writable location and points binaryPath at it.
|
||||
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() {
|
||||
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 sourceStat = fs.statSync(resolvedSource, { throwIfNoEntry: false });
|
||||
if (!sourceStat || !sourceStat.isFile()) {
|
||||
|
|
@ -83,12 +125,13 @@ function replaceWithLocalBinary(sourcePath) {
|
|||
const temporaryPath = temporaryBinaryPath();
|
||||
try {
|
||||
fs.copyFileSync(resolvedSource, temporaryPath);
|
||||
if (expectedSha256) {
|
||||
verifyChecksum(temporaryPath, expectedSha256, resolvedSource);
|
||||
}
|
||||
fs.chmodSync(temporaryPath, 0o755);
|
||||
fs.renameSync(temporaryPath, binaryPath);
|
||||
} finally {
|
||||
if (fs.existsSync(temporaryPath)) {
|
||||
fs.unlinkSync(temporaryPath);
|
||||
}
|
||||
removeActiveTemporary();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -199,9 +242,7 @@ async function replaceWithDownloadedBinary(url, expectedSha256) {
|
|||
fs.chmodSync(temporaryPath, 0o755);
|
||||
fs.renameSync(temporaryPath, binaryPath);
|
||||
} finally {
|
||||
if (fs.existsSync(temporaryPath)) {
|
||||
fs.unlinkSync(temporaryPath);
|
||||
}
|
||||
removeActiveTemporary();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -241,10 +282,7 @@ async function main() {
|
|||
if (override) {
|
||||
const localPath = localPathFromOverride(override);
|
||||
if (localPath) {
|
||||
if (overrideSha256) {
|
||||
verifyChecksum(localPath, overrideSha256, localPath);
|
||||
}
|
||||
replaceWithLocalBinary(localPath);
|
||||
replaceWithLocalBinary(localPath, overrideSha256);
|
||||
console.log(`${packageName}: installed local binary from ${path.resolve(localPath)}`);
|
||||
return;
|
||||
}
|
||||
|
|
@ -260,7 +298,19 @@ async function main() {
|
|||
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}`);
|
||||
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
|
||||
exit 1
|
||||
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