diff --git a/docs/NPM_DISTRIBUTION.md b/docs/NPM_DISTRIBUTION.md index 0f46d3d..0cdc162 100644 --- a/docs/NPM_DISTRIBUTION.md +++ b/docs/NPM_DISTRIBUTION.md @@ -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 `//`. 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 diff --git a/scripts/idx.js b/scripts/idx.js index 50232d8..ea9dde0 100755 --- a/scripts/idx.js +++ b/scripts/idx.js @@ -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); + }); +} diff --git a/scripts/install.js b/scripts/install.js index a92e465..b19c604 100755 --- a/scripts/install.js +++ b/scripts/install.js @@ -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) => { - console.error(`${packageName}: ${error.message}`); - process.exit(1); -}); +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); + }); +} diff --git a/scripts/npm-smoke.sh b/scripts/npm-smoke.sh index d09ea0a..81c82db 100755 --- a/scripts/npm-smoke.sh +++ b/scripts/npm-smoke.sh @@ -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)"