Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
60 changes: 56 additions & 4 deletions npm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<target>`) 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)
Expand All @@ -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).
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- `scripts/generate-platform-packages.mjs` — at release time, generates the
per-platform package directories from the built `csp-<target>` assets and the
release version, ready to `npm publish --provenance` each one.
Expand Down
132 changes: 64 additions & 68 deletions npm/csp/bin/csp.js
Original file line number Diff line number Diff line change
@@ -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-<platform> 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()
Expand All @@ -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. '
Expand All @@ -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',
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

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()
94 changes: 94 additions & 0 deletions npm/csp/install.js
Original file line number Diff line number Diff line change
@@ -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 {}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

main()
Loading
Loading