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..e1c8089 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,13 @@ 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. 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 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..1625bfc 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,59 @@ 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' + + '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', + ) + } + + 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..f87b882 --- /dev/null +++ b/npm/csp/install.js @@ -0,0 +1,94 @@ +#!/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, 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. + 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 { + 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 + } + } + 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..aa14a03 --- /dev/null +++ b/npm/csp/lib/resolve.js @@ -0,0 +1,131 @@ +// 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') { + // 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' } + } + } + + return null +} + +/** Best-effort libc detection: report.glibcVersionRuntime is absent on musl. */ +function isMusl() { + try { + if (typeof process.report?.getReport !== 'function') { + return false + } + // 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 + } +} + +/** + * 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 +} + +// 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 } 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..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' }, ] @@ -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").