From a2fcf0c4825239653c531f6cd08c005178abfe6a Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 2 Jul 2026 00:07:20 +0900 Subject: [PATCH 1/3] perf(npm): copy-over-shim for near-native CLI startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the spawn-shim launcher with the esbuild/ast-grep copy-over model so `.bin/csp` resolves directly to the native Rust binary — no Node.js process on the hot path. Measured `csp --version` via the installed bin drops from ~59 ms (Node boot + spawnSync) to ~5 ms (~11x faster); the win applies to npm/pnpm installs and bun installs that trust the package. - add `install.js` postinstall: copies (hard-links, falls back to byte-copy) the resolved platform binary over `bin/csp.js` via temp + atomic rename. Best-effort (always exits 0) and idempotent via an inode short-circuit that avoids the POSIX same-inode rename() no-op leaving a stray temp file. - extract shared platform resolution into `lib/resolve.js` (never overwritten), required by both the launcher and the postinstall so `npm rebuild` never require()s a native binary as CommonJS. - harden `bin/csp.js` as the fallback launcher (used when postinstall is skipped, e.g. bun-untrusted / `bunx` / --ignore-scripts): forward argv, stdio, exit code, and SIGINT/SIGTERM/SIGHUP so killing the launcher cleanly stops a long-running `csp mcp` child instead of orphaning it; print a TTY-only fast-path hint; resolve a dev `target/` binary from a source checkout. The dev resolver is NOT used by the postinstall, so it can never clobber the source shim. - generator ships install.js + lib/resolve.js in the wrapper; package.json files/postinstall updated. - document the model, startup numbers, and the bun `trustedDependencies` caveat in npm/README.md and CLAUDE.md. --- CLAUDE.md | 2 +- npm/README.md | 57 ++++++++- npm/csp/bin/csp.js | 131 ++++++++++----------- npm/csp/install.js | 80 +++++++++++++ npm/csp/lib/resolve.js | 115 ++++++++++++++++++ npm/csp/package.json | 7 +- npm/scripts/generate-platform-packages.mjs | 5 + 7 files changed, 323 insertions(+), 74 deletions(-) create mode 100644 npm/csp/install.js create mode 100644 npm/csp/lib/resolve.js diff --git a/CLAUDE.md b/CLAUDE.md index 582312e..1cb6c03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co The deprecated TypeScript implementation that formerly lived under `src/` has been **removed** — the Rust port is the only implementation. The root `package.json` / `tsconfig.json` / `eslint.config.ts` remain only as repo JS tooling (lint/typecheck of `npm/`) and the release-please version anchor. The **napi-rs native-binding SDK** binds the `crates/` Rust directly (it does not reintroduce a TS port). -**SDK packaging decision: keep the two distribution channels separate.** `npm/` stays the Biome-style CLI/MCP launcher — a thin Node shim that execs the **standalone Rust binary** (this preserves the no-runtime Homebrew story; do NOT convert it to napi). The napi-rs SDK is a distinct concern: `crates/csp-node` holds `#[napi]` bindings over `crates/csp` and is shipped as its **own npm package** (`@pleaseai/csp-sdk`), an in-process native addon — not merged into `npm/`. Both build outputs share the one `crates/csp` core. The SDK is in place: `#[napi]` bindings (`fromPath`/`fromGit`/`loadFromDisk` are async on the libuv worker pool; `search`/`findRelated`/`save`/`stats` sync, with `inner` held behind `Arc` to enable a future async move), the `napi build` toolchain (`.node` + `index.js`; `index.d.ts` is the committed type surface), and the cross-compile + Trusted-Publishing release in `release-sdk.yml`. The remaining step is publish-only — a maintainer must configure the npm trusted publisher for `@pleaseai/csp-sdk` + its platform packages (see `crates/csp-node/README.md`). +**SDK packaging decision: keep the two distribution channels separate.** `npm/` stays the Biome-style optional-dependency launcher for the **standalone Rust binary** (this preserves the no-runtime Homebrew story; do NOT convert it to napi). It uses the esbuild **copy-over-shim**: a `postinstall` (`npm/csp/install.js`) copies the resolved platform binary over the `bin/csp.js` Node launcher so `.bin/csp` execs native code directly (no Node process on the hot path, ~10× faster startup); the Node launcher remains only as a fallback when postinstall is skipped (e.g. bun blocks it unless `@pleaseai/csp` is in `trustedDependencies`). Shared platform resolution lives in `npm/csp/lib/resolve.js` (never overwritten, so re-running postinstall is idempotent). The napi-rs SDK is a distinct concern: `crates/csp-node` holds `#[napi]` bindings over `crates/csp` and is shipped as its **own npm package** (`@pleaseai/csp-sdk`), an in-process native addon — not merged into `npm/`. Both build outputs share the one `crates/csp` core. The SDK is in place: `#[napi]` bindings (`fromPath`/`fromGit`/`loadFromDisk` are async on the libuv worker pool; `search`/`findRelated`/`save`/`stats` sync, with `inner` held behind `Arc` to enable a future async move), the `napi build` toolchain (`.node` + `index.js`; `index.d.ts` is the committed type surface), and the cross-compile + Trusted-Publishing release in `release-sdk.yml`. The remaining step is publish-only — a maintainer must configure the npm trusted publisher for `@pleaseai/csp-sdk` + its platform packages (see `crates/csp-node/README.md`). ### Rust port (ADR-0003) diff --git a/npm/README.md b/npm/README.md index baab28d..dd88db3 100644 --- a/npm/README.md +++ b/npm/README.md @@ -10,16 +10,62 @@ ## Goal Preserve the existing entrypoint — `bunx @pleaseai/csp` / `npx @pleaseai/csp` — -while shipping the Rust-compiled `csp` binary instead of a bundled JS CLI. This -follows the [Biome](https://github.com/biomejs/biome) distribution model: +while shipping the Rust-compiled `csp` binary instead of a bundled JS CLI. The +package layout follows the [Biome](https://github.com/biomejs/biome) +optional-dependency model, and the launch path uses the +[esbuild](https://github.com/evanw/esbuild) **copy-over-shim** optimization: - `@pleaseai/csp` (this `csp/` dir) is a thin **wrapper** package. Its `bin` - is a tiny Node launcher that resolves and `exec`s the correct platform binary. + points at a Node launcher (`bin/csp.js`) that resolves and `exec`s the correct + platform binary — used as a **fallback** when the copy-over did not run. The + fallback forwards argv, stdio, exit code, and termination signals + (SIGINT/SIGTERM/SIGHUP) to the child, so killing the launcher cleanly stops a + long-running `csp mcp` server instead of orphaning it; on an interactive TTY + it prints a one-line hint that the native fast path is not active (silence with + `CSP_NO_FALLBACK_WARNING=1`). Modeled on + [ast-grep](https://github.com/ast-grep/ast-grep/tree/main/npm)'s launcher. +- A `postinstall` step (`install.js`) copies the resolved platform binary + **over** `bin/csp.js`, so npm's `.bin/csp` symlink resolves directly to native + code. After install there is **no Node.js process on the hot path** — this is + ~10× faster to start than spawning the binary from a Node launcher (see + "Startup cost" below). +- The shared platform resolver lives in `lib/resolve.js` (required by both the + launcher and `install.js`); it is never the file overwritten by the + copy-over, so re-running the postinstall (`npm rebuild`, `npm ci`) is + idempotent rather than trying to `require()` a native executable. - Per-platform packages (`@pleaseai/csp-`) each carry one prebuilt binary and declare `os` + `cpu` so npm/bun install only the matching one. - The wrapper lists every platform package under `optionalDependencies`, so a failed-to-match platform is skipped rather than failing the whole install. +### Startup cost + +Measured on macOS (`csp --version`, via the installed `.bin/csp`): + +| Launch path | Median startup | +| --- | --- | +| Spawn shim (Node launcher → `spawnSync` binary) | ~60 ms (Node boot + spawn) | +| Copy-over (`.bin/csp` → native binary directly) | ~5–12 ms | + +The delta is the Node.js interpreter boot plus the `spawnSync` of the child — +paid on *every* invocation with the old spawn shim, and eliminated by the +copy-over. + +### Package-manager note (bun) + +The copy-over runs as a `postinstall` script. **npm** and **pnpm** run it by +default. **bun blocks lifecycle scripts for untrusted dependencies by default** +(`Blocked 1 postinstall`), so under `bun install` the copy-over does not run and +`bin/csp.js` stays the JS launcher — still fully functional, just without the +startup win. bun users who want the fast path add `@pleaseai/csp` to +`trustedDependencies` in their project's `package.json`: + +```jsonc +{ "trustedDependencies": ["@pleaseai/csp"] } +``` + +`bunx @pleaseai/csp` continues to work regardless via the launcher fallback. + ``` @pleaseai/csp (wrapper — bin/csp.js launcher) ├── @pleaseai/csp-darwin-arm64 (optionalDependency, os=darwin cpu=arm64) @@ -32,7 +78,10 @@ follows the [Biome](https://github.com/biomejs/biome) distribution model: ## Layout -- `csp/` — the wrapper package (`package.json` + `bin/csp.js`). +- `csp/` — the wrapper package: + - `bin/csp.js` — the runtime launcher (overwritten by the copy-over at install). + - `install.js` — the `postinstall` copy-over step. + - `lib/resolve.js` — the shared platform resolver (never overwritten). - `scripts/generate-platform-packages.mjs` — at release time, generates the per-platform package directories from the built `csp-` assets and the release version, ready to `npm publish --provenance` each one. diff --git a/npm/csp/bin/csp.js b/npm/csp/bin/csp.js index b01666f..3241d95 100644 --- a/npm/csp/bin/csp.js +++ b/npm/csp/bin/csp.js @@ -1,64 +1,18 @@ #!/usr/bin/env node -// Launcher for the platform-specific `csp` Rust binary. Resolves the binary -// shipped by the matching @pleaseai/csp- optional dependency and -// execs it, forwarding argv, stdio, and the exit code. Modeled on Biome's -// distribution launcher (ADR-0003 / T023). +// Fallback launcher for the platform-specific `csp` Rust binary. +// +// Normally the postinstall step (`install.js`) copies the native binary OVER +// this file so npm's `.bin/csp` symlink resolves straight to native code — no +// Node.js process on the hot path (esbuild/ast-grep-style copy-over). This JS +// shim is the fallback for when postinstall did not run — e.g. `--ignore-scripts`, +// or bun blocking lifecycle scripts for untrusted deps (`bunx @pleaseai/csp`, +// or `bun install` without `@pleaseai/csp` in `trustedDependencies`). It resolves +// the binary at runtime and execs it, forwarding argv, stdio, signals, and the +// exit code. -const { spawnSync } = require('node:child_process') +const { spawn } = require('node:child_process') const process = require('node:process') - -/** - * Map the current platform/arch (plus libc on Linux) to the optional-dependency - * package name and the binary filename it ships. - */ -function resolvePlatformPackage() { - const { platform, arch } = process - - if (platform === 'win32') { - if (arch === 'x64') { - return { pkg: '@pleaseai/csp-win32-x64', binary: 'csp.exe' } - } - } - else if (platform === 'darwin') { - if (arch === 'arm64') { - return { pkg: '@pleaseai/csp-darwin-arm64', binary: 'csp' } - } - if (arch === 'x64') { - return { pkg: '@pleaseai/csp-darwin-x64', binary: 'csp' } - } - } - else if (platform === 'linux') { - const musl = isMusl() - if (arch === 'x64') { - return musl - ? { pkg: '@pleaseai/csp-linux-x64-musl', binary: 'csp' } - : { pkg: '@pleaseai/csp-linux-x64', binary: 'csp' } - } - if (arch === 'arm64') { - // arm64 ships glibc only for now; musl arm64 falls back to it. - return { pkg: '@pleaseai/csp-linux-arm64', binary: 'csp' } - } - } - - return null -} - -/** Best-effort libc detection: report.glibcVersionRuntime is absent on musl. */ -function isMusl() { - try { - const report = typeof process.report?.getReport === 'function' - ? process.report.getReport() - : null - if (report && report.header && report.header.glibcVersionRuntime) { - return false - } - // No glibc runtime reported → assume musl (e.g. Alpine). - return report !== null - } - catch { - return false - } -} +const { resolveBinaryPath, resolveDevBinaryPath, resolvePlatformPackage } = require('../lib/resolve.js') function main() { const target = resolvePlatformPackage() @@ -70,11 +24,8 @@ function main() { process.exit(1) } - let binaryPath - try { - binaryPath = require.resolve(`${target.pkg}/${target.binary}`) - } - catch { + const binaryPath = resolveBinaryPath() ?? resolveDevBinaryPath() + if (binaryPath === null) { process.stderr.write( `csp: the platform package "${target.pkg}" is not installed.\n` + 'It should have been pulled in automatically as an optional dependency. ' @@ -84,14 +35,58 @@ function main() { process.exit(1) } - const result = spawnSync(binaryPath, process.argv.slice(2), { + // This shim only runs when the postinstall copy-over did not (on Windows the + // shim is the intended launcher, so no hint there). Nudge interactive users + // toward the fast path; stay silent on pipes/MCP-stdio/CI to avoid noise. + if (process.platform !== 'win32' && process.stderr.isTTY && !process.env.CSP_NO_FALLBACK_WARNING) { + process.stderr.write( + 'csp: running via the Node launcher (postinstall copy-over did not run), ' + + 'which adds per-invocation startup overhead.\n' + + 'Under bun, add "@pleaseai/csp" to "trustedDependencies" for the native fast path. ' + + 'Set CSP_NO_FALLBACK_WARNING=1 to silence this.\n', + ) + } + + const child = spawn(binaryPath, process.argv.slice(2), { stdio: 'inherit', windowsHide: true, }) - if (result.error) { - throw result.error - } - process.exit(result.status ?? 1) + + child.on('error', (error) => { + process.stderr.write(`csp: failed to execute native binary: ${error.message}\n`) + process.exit(1) + }) + + // Forward termination signals to the child so a supervisor killing this + // launcher (e.g. stopping a long-running `csp mcp` server) cleanly stops the + // binary too, rather than orphaning it. + const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'] + const forward = signals.map((signal) => { + const handler = () => { + if (!child.killed) { + try { + child.kill(signal) + } + catch {} + } + } + process.on(signal, handler) + return [signal, handler] + }) + + child.on('exit', (code, signal) => { + for (const [s, h] of forward) { + process.removeListener(s, h) + } + if (signal) { + // Re-raise the signal on ourselves so the parent observes the same cause + // of death (correct exit status for shells and supervisors). + process.kill(process.pid, signal) + } + else { + process.exit(code ?? 1) + } + }) } main() diff --git a/npm/csp/install.js b/npm/csp/install.js new file mode 100644 index 0000000..fd30b63 --- /dev/null +++ b/npm/csp/install.js @@ -0,0 +1,80 @@ +#!/usr/bin/env node +// Postinstall copy-over optimization (esbuild-style). +// +// Replace the JS launcher shim at `bin/csp.js` with the actual platform binary +// so npm's `.bin/csp` symlink resolves directly to native code — no Node.js +// process spawned on every invocation. If anything fails (unsupported platform, +// missing optional dependency, read-only filesystem, or a package manager that +// skips lifecycle scripts entirely), the JS shim stays in place and still works +// as a runtime fallback. This step is therefore best-effort and MUST exit 0. +// +// It is also idempotent: it only requires `lib/resolve.js` (never the shim it +// overwrites), so a repeated `npm rebuild` re-copies the binary cleanly instead +// of trying to `require()` a native executable as CommonJS. + +const { chmodSync, copyFileSync, linkSync, renameSync, statSync, unlinkSync } = require('node:fs') +const { join } = require('node:path') +const process = require('node:process') +const { resolveBinaryPath } = require('./lib/resolve.js') + +function main() { + // On Windows the npm-generated bin shims (csp.cmd / csp.ps1) invoke + // `node bin/csp.js`, so the shim must remain JavaScript. Skip the copy-over + // and rely on the runtime launcher there. + if (process.platform === 'win32') { + return + } + + const binaryPath = resolveBinaryPath() + if (binaryPath === null) { + // Unsupported platform or the optional dependency was not installed; the JS + // shim already prints a helpful message at runtime. + return + } + + const shimPath = join(__dirname, 'bin', 'csp.js') + + // Idempotency short-circuit: if the shim was already replaced by a hard link + // to the binary, there is nothing to do. This matters because POSIX rename() + // between two hard links to the same inode is a no-op that leaves the temp + // file behind — so on a re-run (`npm rebuild`, `npm ci`) we must not enter the + // link+rename path at all. + try { + if (statSync(shimPath).ino === statSync(binaryPath).ino) { + return + } + } + catch {} + + const tempPath = `${shimPath}.tmp-${process.pid}` + + try { + // Prefer a hard link (instant, no byte copy, shares the binary's inode); + // fall back to a real copy across filesystems. The platform package already + // ships the binary mode 0755, so only chmod on the copy path (a hard link + // shares the source inode — mutating its mode could touch a shared store). + // Write to a temp path then atomically rename over the shim so a concurrent + // exec never observes a half-written file. + try { + linkSync(binaryPath, tempPath) + } + catch { + copyFileSync(binaryPath, tempPath) + chmodSync(tempPath, 0o755) + } + renameSync(tempPath, shimPath) + } + catch { + // Leave the JS shim in place as the fallback. + } + finally { + // Best-effort cleanup: rename() normally consumes the temp file (ENOENT + // here, ignored); this removes any residue if it did not. + try { + unlinkSync(tempPath) + } + catch {} + } +} + +main() diff --git a/npm/csp/lib/resolve.js b/npm/csp/lib/resolve.js new file mode 100644 index 0000000..28e7b8f --- /dev/null +++ b/npm/csp/lib/resolve.js @@ -0,0 +1,115 @@ +// Shared platform-binary resolution for the `csp` npm wrapper. +// +// This module is required by BOTH the runtime launcher (`bin/csp.js`) and the +// postinstall copy-over step (`install.js`). It must NEVER be the file that the +// copy-over overwrites — `bin/csp.js` is overwritten with the native binary at +// install time, so the resolution logic lives here where it stays JavaScript +// and can be required idempotently (`npm rebuild` runs postinstall again). + +const { existsSync } = require('node:fs') +const { join } = require('node:path') +const process = require('node:process') + +/** + * Map the current platform/arch (plus libc on Linux) to the optional-dependency + * package name and the binary filename it ships. + * + * @returns {{ pkg: string, binary: string } | null} the package/binary pair, or + * null when the current platform/arch is unsupported. + */ +function resolvePlatformPackage() { + const { platform, arch } = process + + if (platform === 'win32') { + if (arch === 'x64') { + return { pkg: '@pleaseai/csp-win32-x64', binary: 'csp.exe' } + } + } + else if (platform === 'darwin') { + if (arch === 'arm64') { + return { pkg: '@pleaseai/csp-darwin-arm64', binary: 'csp' } + } + if (arch === 'x64') { + return { pkg: '@pleaseai/csp-darwin-x64', binary: 'csp' } + } + } + else if (platform === 'linux') { + const musl = isMusl() + if (arch === 'x64') { + return musl + ? { pkg: '@pleaseai/csp-linux-x64-musl', binary: 'csp' } + : { pkg: '@pleaseai/csp-linux-x64', binary: 'csp' } + } + if (arch === 'arm64') { + // arm64 ships glibc only for now; musl arm64 falls back to it. + return { pkg: '@pleaseai/csp-linux-arm64', binary: 'csp' } + } + } + + return null +} + +/** Best-effort libc detection: report.glibcVersionRuntime is absent on musl. */ +function isMusl() { + try { + const report = typeof process.report?.getReport === 'function' + ? process.report.getReport() + : null + if (report && report.header && report.header.glibcVersionRuntime) { + return false + } + // No glibc runtime reported → assume musl (e.g. Alpine). + return report !== null + } + catch { + return false + } +} + +/** + * Resolve the absolute path to the platform binary shipped by the matching + * optional-dependency package, or `null` if the platform is unsupported or the + * package is not installed. + * + * @returns {string | null} the absolute binary path, or null when unsupported + * or the platform package is not installed. + */ +function resolveBinaryPath() { + const target = resolvePlatformPackage() + if (target === null) { + return null + } + try { + return require.resolve(`${target.pkg}/${target.binary}`) + } + catch { + return null + } +} + +/** + * Locate a binary built into the repo's `target/` dir, for running the shim + * straight from a source checkout (no published platform package installed). + * + * This is intentionally NOT consulted by the postinstall copy-over — it must + * never copy a dev binary over the source `bin/csp.js`. Only the runtime + * fallback launcher uses it, as a last resort after {@link resolveBinaryPath}. + * + * @returns {string | null} the absolute path to a locally built binary, or null. + */ +function resolveDevBinaryPath() { + const target = resolvePlatformPackage() + if (target === null) { + return null + } + // lib/resolve.js → npm/csp/lib → npm/csp → npm → + for (const profile of ['release', 'debug']) { + const dev = join(__dirname, '..', '..', '..', 'target', profile, target.binary) + if (existsSync(dev)) { + return dev + } + } + return null +} + +module.exports = { resolvePlatformPackage, resolveBinaryPath, resolveDevBinaryPath, isMusl } diff --git a/npm/csp/package.json b/npm/csp/package.json index 644a892..d2c0f1c 100644 --- a/npm/csp/package.json +++ b/npm/csp/package.json @@ -26,11 +26,16 @@ "csp": "bin/csp.js" }, "files": [ - "bin/csp.js" + "bin/csp.js", + "install.js", + "lib/resolve.js" ], "engines": { "node": ">=22.0.0" }, + "scripts": { + "postinstall": "node install.js" + }, "optionalDependencies": { "@pleaseai/csp-darwin-arm64": "0.0.0", "@pleaseai/csp-darwin-x64": "0.0.0", diff --git a/npm/scripts/generate-platform-packages.mjs b/npm/scripts/generate-platform-packages.mjs index 5cdab9b..62ab2ef 100644 --- a/npm/scripts/generate-platform-packages.mjs +++ b/npm/scripts/generate-platform-packages.mjs @@ -96,8 +96,13 @@ const wrapper = { } const wrapperDir = join(distRoot, 'csp') mkdirSync(join(wrapperDir, 'bin'), { recursive: true }) +mkdirSync(join(wrapperDir, 'lib'), { recursive: true }) writeFileSync(join(wrapperDir, 'package.json'), `${JSON.stringify(wrapper, null, 2)}\n`) +// The runtime launcher shim (overwritten by the postinstall copy-over), the +// postinstall step itself, and the shared resolver both of them require. copyFileSync(join(npmRoot, 'csp', 'bin', 'csp.js'), join(wrapperDir, 'bin', 'csp.js')) +copyFileSync(join(npmRoot, 'csp', 'install.js'), join(wrapperDir, 'install.js')) +copyFileSync(join(npmRoot, 'csp', 'lib', 'resolve.js'), join(wrapperDir, 'lib', 'resolve.js')) // Ship the user-facing README + LICENSE in the published wrapper so the npm // package page renders docs (without these, npm shows "No README data found"). From c43be1830e8ab07222de07458d4e4df46d2eaaa7 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 2 Jul 2026 00:21:06 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(npm):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20guard=20copy-over,=20harden=20libc=20probe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback from CodeRabbit / Greptile / Gemini on the copy-over-shim: - install.js: only run the copy-over when installed under node_modules. From a source checkout `bin/csp.js` is tracked and the generator reads it into every wrapper, so an in-place rewrite could ship a native binary as the launcher. Verified the guard blocks the clobber even when the platform binary resolves. - lib/resolve.js: set `process.report.excludeNetwork = true` around getReport() (restored after) so libc detection can't trigger a blocking reverse-DNS lookup; stop exporting the internal `isMusl` helper. - bin/csp.js: broaden the fallback hint to mention `--ignore-scripts`, not just bun trustedDependencies. - README: make the bin/csp.js overwrite note conditional (Windows / unsupported / skipped scripts / copy failure leave it as the fallback launcher). --- npm/README.md | 5 ++++- npm/csp/bin/csp.js | 3 ++- npm/csp/install.js | 12 +++++++++++- npm/csp/lib/resolve.js | 28 +++++++++++++++++++++------- 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/npm/README.md b/npm/README.md index dd88db3..e1c8089 100644 --- a/npm/README.md +++ b/npm/README.md @@ -79,7 +79,10 @@ startup win. bun users who want the fast path add `@pleaseai/csp` to ## Layout - `csp/` — the wrapper package: - - `bin/csp.js` — the runtime launcher (overwritten by the copy-over at install). + - `bin/csp.js` — the runtime launcher. On a successful `postinstall` the + copy-over replaces it with the native binary; it is left in place (and used + as the fallback) on Windows, unsupported platforms, when lifecycle scripts + are skipped, or if the copy fails. - `install.js` — the `postinstall` copy-over step. - `lib/resolve.js` — the shared platform resolver (never overwritten). - `scripts/generate-platform-packages.mjs` — at release time, generates the diff --git a/npm/csp/bin/csp.js b/npm/csp/bin/csp.js index 3241d95..1625bfc 100644 --- a/npm/csp/bin/csp.js +++ b/npm/csp/bin/csp.js @@ -42,7 +42,8 @@ function main() { process.stderr.write( 'csp: running via the Node launcher (postinstall copy-over did not run), ' + 'which adds per-invocation startup overhead.\n' - + 'Under bun, add "@pleaseai/csp" to "trustedDependencies" for the native fast path. ' + + 'Reinstall without --ignore-scripts, or under bun add "@pleaseai/csp" to ' + + '"trustedDependencies", for the native fast path. ' + 'Set CSP_NO_FALLBACK_WARNING=1 to silence this.\n', ) } diff --git a/npm/csp/install.js b/npm/csp/install.js index fd30b63..aa1e170 100644 --- a/npm/csp/install.js +++ b/npm/csp/install.js @@ -13,11 +13,21 @@ // of trying to `require()` a native executable as CommonJS. const { chmodSync, copyFileSync, linkSync, renameSync, statSync, unlinkSync } = require('node:fs') -const { join } = require('node:path') +const { join, sep } = require('node:path') const process = require('node:process') const { resolveBinaryPath } = require('./lib/resolve.js') function main() { + // Only rewrite the launcher when running as an installed dependency. From a + // source checkout `bin/csp.js` is a git-tracked file, and an in-place + // copy-over there would mutate the repo — the generator reads that same file + // into every published wrapper, so one stray local install could ship a + // native binary as the "launcher". An installed package always lives under a + // `node_modules/` path segment; a checkout does not. + if (!__dirname.split(sep).includes('node_modules')) { + return + } + // On Windows the npm-generated bin shims (csp.cmd / csp.ps1) invoke // `node bin/csp.js`, so the shim must remain JavaScript. Skip the copy-over // and rely on the runtime launcher there. diff --git a/npm/csp/lib/resolve.js b/npm/csp/lib/resolve.js index 28e7b8f..e228b2e 100644 --- a/npm/csp/lib/resolve.js +++ b/npm/csp/lib/resolve.js @@ -52,14 +52,26 @@ function resolvePlatformPackage() { /** Best-effort libc detection: report.glibcVersionRuntime is absent on musl. */ function isMusl() { try { - const report = typeof process.report?.getReport === 'function' - ? process.report.getReport() - : null - if (report && report.header && report.header.glibcVersionRuntime) { + if (typeof process.report?.getReport !== 'function') { return false } - // No glibc runtime reported → assume musl (e.g. Alpine). - return report !== null + // getReport() defaults to including network info, which can trigger a + // blocking reverse-DNS (PTR) lookup — the opposite of this shim's goal. + // We only need report.header.glibcVersionRuntime, so exclude network data + // for the call and restore the caller's setting afterward. + const previousExcludeNetwork = process.report.excludeNetwork + process.report.excludeNetwork = true + try { + const report = process.report.getReport() + if (report && report.header && report.header.glibcVersionRuntime) { + return false + } + // No glibc runtime reported → assume musl (e.g. Alpine). + return report != null + } + finally { + process.report.excludeNetwork = previousExcludeNetwork + } } catch { return false @@ -112,4 +124,6 @@ function resolveDevBinaryPath() { return null } -module.exports = { resolvePlatformPackage, resolveBinaryPath, resolveDevBinaryPath, isMusl } +// isMusl stays private — it is an internal helper of resolvePlatformPackage; +// exporting it would invite callers to drift from the canonical resolution. +module.exports = { resolvePlatformPackage, resolveBinaryPath, resolveDevBinaryPath } From 71ed3af218d39033baef9700b868b6750587f095 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 2 Jul 2026 00:34:31 +0900 Subject: [PATCH 3/3] fix(npm): musl-arm64 resolution + cross-device idempotency check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second round of PR review (cubic): - resolve.js: on musl arm64 (Alpine/aarch64) return null instead of the glibc arm64 package, so the launcher reports an unsupported platform rather than exec'ing an incompatible glibc binary. Add a matching `libc: 'glibc'` constraint to the linux-arm64 target so npm/bun skip it on musl arm64. - install.js: compare device + inode (not inode alone) in the idempotency short-circuit — inode numbers are only unique within a filesystem. --- npm/csp/install.js | 6 +++++- npm/csp/lib/resolve.js | 6 ++++-- npm/scripts/generate-platform-packages.mjs | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/npm/csp/install.js b/npm/csp/install.js index aa1e170..f87b882 100644 --- a/npm/csp/install.js +++ b/npm/csp/install.js @@ -50,7 +50,11 @@ function main() { // file behind — so on a re-run (`npm rebuild`, `npm ci`) we must not enter the // link+rename path at all. try { - if (statSync(shimPath).ino === statSync(binaryPath).ino) { + const shimStat = statSync(shimPath) + const binStat = statSync(binaryPath) + // Match on device + inode: inode numbers are only unique within a + // filesystem, so comparing ino alone could collide across devices. + if (shimStat.dev === binStat.dev && shimStat.ino === binStat.ino) { return } } diff --git a/npm/csp/lib/resolve.js b/npm/csp/lib/resolve.js index e228b2e..aa14a03 100644 --- a/npm/csp/lib/resolve.js +++ b/npm/csp/lib/resolve.js @@ -41,8 +41,10 @@ function resolvePlatformPackage() { : { pkg: '@pleaseai/csp-linux-x64', binary: 'csp' } } if (arch === 'arm64') { - // arm64 ships glibc only for now; musl arm64 falls back to it. - return { pkg: '@pleaseai/csp-linux-arm64', binary: 'csp' } + // Only a glibc arm64 build exists. On musl arm64 (Alpine) return null so + // the launcher reports an unsupported platform rather than exec'ing an + // incompatible glibc binary. + return musl ? null : { pkg: '@pleaseai/csp-linux-arm64', binary: 'csp' } } } diff --git a/npm/scripts/generate-platform-packages.mjs b/npm/scripts/generate-platform-packages.mjs index 62ab2ef..e622d96 100644 --- a/npm/scripts/generate-platform-packages.mjs +++ b/npm/scripts/generate-platform-packages.mjs @@ -24,7 +24,7 @@ const TARGETS = [ { pkg: '@pleaseai/csp-darwin-arm64', asset: 'csp-darwin-arm64', binary: 'csp', os: 'darwin', cpu: 'arm64' }, { pkg: '@pleaseai/csp-darwin-x64', asset: 'csp-darwin-x64', binary: 'csp', os: 'darwin', cpu: 'x64' }, { pkg: '@pleaseai/csp-linux-x64', asset: 'csp-linux-x64', binary: 'csp', os: 'linux', cpu: 'x64', libc: 'glibc' }, - { pkg: '@pleaseai/csp-linux-arm64', asset: 'csp-linux-arm64', binary: 'csp', os: 'linux', cpu: 'arm64' }, + { pkg: '@pleaseai/csp-linux-arm64', asset: 'csp-linux-arm64', binary: 'csp', os: 'linux', cpu: 'arm64', libc: 'glibc' }, { pkg: '@pleaseai/csp-linux-x64-musl', asset: 'csp-linux-x64-musl', binary: 'csp', os: 'linux', cpu: 'x64', libc: 'musl' }, { pkg: '@pleaseai/csp-win32-x64', asset: 'csp-windows-x64.exe', binary: 'csp.exe', os: 'win32', cpu: 'x64' }, ]