diff --git a/.gitattributes b/.gitattributes index b9c119d33..b37633265 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,6 +8,11 @@ *.sh eol=lf *.bash eol=lf Makefile eol=lf +# The POSIX launcher is a shell script exec'd by the OS; CRLF would cause a +# "bad interpreter" failure on Linux. Explicitly enforce LF even though +# * text=auto eol=lf already covers it, as a defense against editors/CI that +# might override the default. +packages/cli/bin/llxprt eol=lf # Explicitly declare binary file types to prevent Git from attempting to # normalize their line endings. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af96de047..7bff7acd1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -528,13 +528,13 @@ jobs: bun scripts/bun-native-modules-smoke.mjs # - # Node consumer smoke (issue 2243, S6) + # Node consumer smoke (issue 2243, S6 / issue 2603) # - # Guards npm install/invocation compatibility while keeping the application - # runtime on Bun/TypeScript source: install with npm, then run the checked-in - # Node bin launcher, which re-execs Bun against packages/cli/index.ts. Wired - # into the `lint` aggregator so the expensive test matrix waits for this fast - # install+smoke check first. + # Guards npm install/invocation compatibility: install with npm, then run the + # installed bin directly. The bin is a POSIX sh launcher (issue #2603) that + # execs the package-local Bun against packages/cli/index.ts — no Node process + # is started on the installed command path. Wired into the `lint` aggregator + # so the expensive test matrix waits for this fast install+smoke check first. node_consumer_smoke: name: 'Node Consumer Smoke' runs-on: 'ubuntu-latest' @@ -545,8 +545,8 @@ jobs: # This job runs npm ci (executing third-party install scripts) and only # needs to read the repo, so scope permissions to contents:read and drop # credential persistence (matches bun_native_modules_smoke hardening). It - # validates the published npm entry path by running the checked-in Node - # launcher after install; the launcher starts Bun on TypeScript source. + # validates the published npm entry path by running the installed launcher + # directly after install; the launcher execs Bun on TypeScript source. permissions: contents: 'read' steps: @@ -581,8 +581,8 @@ jobs: npm init -y npm install "$RUNNER_TEMP/llxprt-pack"/*.tgz - - name: 'Smoke test installed CLI entry (Node launcher -> Bun -> TypeScript)' - run: 'node "$RUNNER_TEMP/llxprt-consumer/node_modules/.bin/llxprt" --version' + - name: 'Smoke test installed CLI entry (launcher -> Bun, no Node in chain)' + run: '"$RUNNER_TEMP/llxprt-consumer/node_modules/.bin/llxprt" --version' # # Bun-backed test orchestration smoke (issue #2463) @@ -787,8 +787,8 @@ jobs: CI: true run: npm run test:scripts - - name: 'Smoke test CLI entry (Node launcher -> Bun -> TypeScript)' - run: 'node ./packages/cli/bin/llxprt.cjs --version' + - name: 'Smoke test CLI entry (launcher -> Bun -> TypeScript, no Node)' + run: './packages/cli/bin/llxprt --version' - name: 'Wait for file system sync' run: 'sleep 2' diff --git a/.github/workflows/interactive-ui.yml b/.github/workflows/interactive-ui.yml index 7fca1d0e2..766a62362 100644 --- a/.github/workflows/interactive-ui.yml +++ b/.github/workflows/interactive-ui.yml @@ -104,8 +104,8 @@ jobs: set -euo pipefail node --version tmux -V - test -x packages/cli/bin/llxprt.cjs - node packages/cli/bin/llxprt.cjs --help >/tmp/llxprt-cli-help.txt + test -x packages/cli/bin/llxprt + ./packages/cli/bin/llxprt --help >/tmp/llxprt-cli-help.txt head -n 5 /tmp/llxprt-cli-help.txt - name: 'Run interactive UI tests' diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 622b9428f..d8779fcd4 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -117,8 +117,8 @@ jobs: CI: true run: npm run test:scripts - - name: 'Smoke test CLI entry (Node -> launcher -> Bun)' - run: 'node ./packages/cli/bin/llxprt.cjs --version' + - name: 'Smoke test CLI entry (launcher -> Bun, no Node in chain)' + run: './packages/cli/bin/llxprt --version' - name: 'Wait for file system sync' run: 'sleep 2' diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 3afa0c648..e05eba8bf 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -55,9 +55,9 @@ jobs: - name: 'Install Dependencies' run: 'npm ci' # No build step: the checked-in launcher resolves packages/cli/index.ts - # and re-execs it with Bun; no dist/ output is consumed. - - name: 'Smoke test CLI entry (Node -> launcher -> Bun)' - run: 'node ./packages/cli/bin/llxprt.cjs --version' + # and execs it with the bundled Bun; no dist/ output is consumed. + - name: 'Smoke test CLI entry (launcher -> Bun, no Node in chain)' + run: './packages/cli/bin/llxprt --version' - name: 'Create Issue on Failure' if: '${{ failure() && github.event.inputs.dry-run == false }}' env: diff --git a/.github/workflows/windows-installed-command.yml b/.github/workflows/windows-installed-command.yml new file mode 100644 index 000000000..55187a9c4 --- /dev/null +++ b/.github/workflows/windows-installed-command.yml @@ -0,0 +1,99 @@ +name: 'Windows Installed Command (issue #2603)' + +on: + pull_request: + paths: + - 'packages/cli/bin/**' + - 'packages/cli/package.json' + - 'packages/cli/scripts/install-native-launchers.cjs' + - 'package.json' + - 'package-lock.json' + - 'scripts/postinstall.cjs' + - 'scripts/tests/issue-2603-release-pack.cjs' + - 'scripts/tests/issue-2603-windows-probe.ts' + - 'scripts/tests/issue-2603-startup-benchmark.cjs' + - 'scripts/windows-installed-command-smoke.cjs' + - 'scripts/windows-installed-command-smoke/**' + - '.github/workflows/windows-installed-command.yml' + push: + branches: + - 'main' + paths: + - 'packages/cli/bin/**' + - 'packages/cli/package.json' + - 'packages/cli/scripts/install-native-launchers.cjs' + - 'package.json' + - 'package-lock.json' + - 'scripts/postinstall.cjs' + - 'scripts/tests/issue-2603-release-pack.cjs' + - 'scripts/tests/issue-2603-windows-probe.ts' + - 'scripts/tests/issue-2603-startup-benchmark.cjs' + - 'scripts/windows-installed-command-smoke.cjs' + - 'scripts/windows-installed-command-smoke/**' + - '.github/workflows/windows-installed-command.yml' + workflow_dispatch: + +# Cancel superseded runs of this workflow for the same ref so that repeated +# pushes to a PR do not queue redundant Windows install smokes. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + windows-installed-command: + runs-on: 'windows-latest' + # 60 min (root cause D): the smoke does real global/local/npm-exec + # installs plus a benchmark. 30 min was too tight when multiple installs + # ran back-to-back. All install timeouts are env-configurable and stay + # well under this job budget. + timeout-minutes: 60 + permissions: + contents: 'read' + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + fetch-depth: 1 + + - name: 'Set up Node.js' + uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # ratchet:actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: 'Setup Bun' + uses: 'oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76' # ratchet:oven-sh/setup-bun@v2 + with: + bun-version-file: '.bun-version' + + # Publish the absolute path to PowerShell 7 (pwsh.exe) so the smoke + # resolves it robustly under a constrained PATH. windows-latest ships + # pwsh but NOT legacy `powershell` on PATH, which previously caused + # every PowerShell-gated step to fail with ENOENT (run 29850614559). + - name: 'Export PowerShell 7 path' + shell: 'pwsh' + run: | + "PWSH_PATH=$((Get-Command pwsh).Source)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: 'Install dependencies (npm ci)' + run: 'npm ci' + + # The smoke runs the behavioral checks AND the startup benchmark (as a + # child process using the already-installed launcher + platform bun), so + # no separate benchmark step is needed. The benchmark is invoked with + # LLXPRT_BENCH_LAUNCHER/LLXPRT_BENCH_BUN so it never repacks or + # reinstalls — it reuses the install the smoke already proved. + - name: 'Run Windows installed-command behavioral smoke + benchmark' + run: 'node scripts/windows-installed-command-smoke.cjs' + + # Upload the small diagnostic JSON written on failure (root cause I). + # Hosted runner temp vanishes post-job, so this small artifact (no + # node_modules) is how failures are debugged offline. Kept tiny. + - name: 'Upload diagnostic artifact on failure' + if: ${{ failure() }} + uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 + with: + name: 'llxprt-win-smoke-diagnostic' + path: '${{ runner.temp }}/llxprt-win-smoke-diagnostic-*.json' + if-no-files-found: 'ignore' + retention-days: 7 diff --git a/.prettierignore b/.prettierignore index bdec03637..cfac7b3d9 100644 --- a/.prettierignore +++ b/.prettierignore @@ -31,7 +31,6 @@ project-plans/ Thumbs.db # Generated files -packages/cli/bin/llxprt.cjs package-lock.json bun.lock **/bun.lock @@ -56,3 +55,6 @@ junit.xml # Intentionally-malformed test fixture (must stay unparseable to exercise the # unparseable-profile dir-scan rejection path in profiles T19a). packages/agents/src/api/__tests__/fixtures/profile-invalid-malformed.json + +# Extensionless shell script (not JS/TS, prettier breaks it) +packages/cli/bin/llxprt diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a102acbc..b2da84bbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- **Installed command launches Bun directly (issue #2603):** The `llxprt` bin entry is now `packages/cli/bin/llxprt`, a POSIX sh launcher with a valid `#!/bin/sh` shebang (directly execve-compatible). On Windows, the CLI workspace `postinstall` (`packages/cli/scripts/install-native-launchers.cjs`) replaces npm's cmd-shim with native `.cmd` and `.ps1` launchers. No Node process is started on the installed command path. The old Node launcher (`packages/cli/bin/llxprt.cjs`) has been removed. The POSIX launcher validates the Bun executable's native binary magic (ELF/Mach-O) before exec, producing an actionable exit 43 for a corrupt or unusable binary without double-starting Bun. The Windows cmd launcher preserves the child exit code exactly (no errorlevel remapping); the PowerShell launcher wraps the invocation in try/catch to surface launch failures as exit 43 while propagating normal nonzero exits via `$LASTEXITCODE`. + ### Removed (0.10.0 breaking cleanup) - Removed provider-neutral Gemini legacy aliases and inherited internal naming as a 0.10.0 breaking cleanup. The `geminiLegacyAliases.ts` singleton alias module (the single legacy re-export location introduced in #2354) is deleted without replacement. External consumers must use the canonical names directly: @@ -37,7 +41,7 @@ ### Migration - Direct consumers constructing `AuthPrecedenceResolver` and expecting it to resolve named auth keys must pass `providerKeyStorage` in the constructor options or use core's `createAuthPrecedenceResolver()` factory. The CLI profile flow already resolves named keys to concrete provider API keys before provider construction. -- LLxprt Code has moved to the [Bun](https://bun.sh) runtime. Node-compatible install/run UX is preserved — the npm (`npm install -g @vybestack/llxprt-code`), npx, and Homebrew flows are unchanged from the user's perspective. Bun is now required under the covers to power execution. When Bun is not found on `PATH` (and the bundled `node_modules/.bin/bun` dependency is unavailable), the launcher prints an error instructing the user to reinstall dependencies (`npm install`) or install Bun directly from https://bun.sh. The published npm package ships TypeScript source (`.ts`) and a checked-in Node launcher (`packages/cli/bin/llxprt.cjs`) as its `bin` entry — no compilation or pre-compiled `dist/` artifact is shipped or required. The launcher resolves Bun and executes the `.ts` entry point directly at run time. `tsc --noEmit` is used solely for type-checking during development. The retired `bundle/llxprt.js` esbuild bundle artifact is no longer produced. vitest is retained as the test runner. On Windows, the `node-pty` module has a known terminal resize race condition; the CLI silences this specific error at the process level and uses `@lydell/node-pty` (not the Bun adapter, which is POSIX-only). Users encountering terminal sizing issues should use a compatible terminal emulator; the resize race is in `node-pty` itself, not the Bun runtime. +- LLxprt Code has moved to the [Bun](https://bun.sh) runtime. Node-compatible install/run UX is preserved — the npm (`npm install -g @vybestack/llxprt-code`), npx, and Homebrew flows are unchanged from the user's perspective. Bun is now required under the covers to power execution; the published package bundles Bun as a dependency, so most users never need to install Bun separately. The published npm package ships TypeScript source (`.ts`) and a platform-native launcher (`packages/cli/bin/llxprt`, a POSIX sh script) as its `bin` entry — no compilation or pre-compiled `dist/` artifact is shipped or required. The launcher resolves the package-local Bun and executes the `.ts` entry point directly at run time. `tsc --noEmit` is used solely for type-checking during development. The retired `bundle/llxprt.js` esbuild bundle artifact is no longer produced. vitest is retained as the test runner. On Windows, the `node-pty` module has a known terminal resize race condition; the CLI silences this specific error at the process level and uses `@lydell/node-pty` (not the Bun adapter, which is POSIX-only). Users encountering terminal sizing issues should use a compatible terminal emulator; the resize race is in `node-pty` itself, not the Bun runtime. ### Removed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 631e3338e..76567060f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -129,7 +129,7 @@ To build the entire project (all packages): bun run build ``` -TypeScript source (`.ts`) is shipped directly. The CLI's run path starts from the checked-in Node launcher (`packages/cli/bin/llxprt.cjs`), which resolves Bun and re-execs `packages/cli/index.ts` directly — no pre-compiled CLI `dist/` artifact or `bundle/llxprt.js` artifact is required for the CLI to run. Type checking uses `tsc --noEmit`, and package builds still produce `dist/` artifacts for workspace/library packaging. Refer to `scripts/build.ts`, `scripts/build_package.ts`, and `package.json` scripts for more details on what happens during the build. +TypeScript source (`.ts`) is shipped directly. The CLI's installed command uses platform-native launchers (`packages/cli/bin/llxprt`) that resolve the package-bundled Bun and exec `packages/cli/index.ts` directly — no Node process is started on the installed path, and no pre-compiled CLI `dist/` artifact or `bundle/llxprt.js` artifact is required for the CLI to run. Type checking uses `tsc --noEmit`, and package builds still produce `dist/` artifacts for workspace/library packaging. Refer to `scripts/build.ts`, `scripts/build_package.ts`, and `package.json` scripts for more details on what happens during the build. ### Enabling Sandboxing @@ -151,13 +151,13 @@ To start LLxprt Code from the source code, run the following command from the ro bun run start ``` -Alternatively, the dev launcher (`scripts/start.ts`) starts the checked-in launcher under Node; that launcher then resolves Bun and re-execs `packages/cli/index.ts`. In debug mode (`DEBUG=1`), Node starts with the inspector before handing off to the launcher: +Alternatively, the dev launcher (`scripts/start.ts`) spawns Bun directly on the TypeScript source entry (`packages/cli/index.ts`); in debug mode (`DEBUG=1`) Bun starts with the inspector: ```bash bun scripts/start.ts ``` -The production launcher (`packages/cli/bin/llxprt.cjs`) resolves Bun by climbing ancestor directories for `node_modules/.bin/bun`, falling back to `node_modules/bun/bin/bun.exe`, then `PATH` (`which`/`where`). If Bun is absent, the launcher prints guidance to install Bun and exits. See the [Bun Runtime and Install Fallback](./README.md#bun-runtime-and-install-fallback) section in the README. +The production launcher (`packages/cli/bin/llxprt`) is a POSIX sh script with a valid `#!/bin/sh` shebang. It resolves the bundled Bun by checking, in order: the package-local `node_modules/bun/bin/bun.exe`, the enclosing `node_modules/bun/bin/bun.exe` (for hoisted installed packages, stopping at the enclosing `node_modules` boundary), and — for the source workspace only — a verified repository root's `node_modules/bun/bin/bun.exe` (the root manifest must reference this package). It never scans `.bin` symlinks or falls back to a global `bun` on `PATH`. When the package declares an exact Bun pin, a candidate whose `package.json`/version is missing or mismatched is rejected. It validates the executable's native binary magic (ELF/Mach-O) before exec to reject corrupt or unusable binaries with an actionable exit 43. On Windows, the CLI workspace `postinstall` (`packages/cli/scripts/install-native-launchers.cjs`) replaces npm's cmd-shim with native `.cmd` / `.ps1` launchers that invoke the same package-local Bun. The cmd launcher preserves the child exit code exactly (no errorlevel remapping); the PowerShell launcher wraps the invocation in try/catch to surface launch failures as exit 43. (The root `scripts/postinstall.cjs` delegates to this script on Windows after linking internal workspace packages.) If Bun is absent, the launcher prints actionable guidance and exits 43. See the [Bun Runtime and Install Fallback](./README.md#bun-runtime-and-install-fallback) section in the README. If you'd like to run the source build outside the llxprt-code folder you can utilize `npm link path/to/llxprt-code/packages/cli` (see: [docs](https://docs.npmjs.com/cli/v9/commands/npm-link)) or `alias llxprt="bun path/to/llxprt-code/packages/cli"` to run with `llxprt` @@ -308,7 +308,7 @@ For more detailed architecture, see `docs/architecture.md`. bun run debug ``` - This launches the checked-in Node launcher (via `scripts/start.ts`) with the inspector, pausing execution until a debugger attaches before the launcher resolves Bun and executes `packages/cli/index.ts`. You can then open `chrome://inspect` in your Chrome browser to connect to the debugger. + This launches Bun directly on the TypeScript entry (via `scripts/start.ts`) with the inspector, pausing execution until a debugger attaches before Bun runs `packages/cli/index.ts`. You can then open `chrome://inspect` in your Chrome browser to connect to the debugger. 2. In VS Code, use the "Attach" launch configuration (found in `.vscode/launch.json`). @@ -322,7 +322,7 @@ DEBUG=1 llxprt **Note:** If you have `DEBUG=true` in a project's `.env` file, it won't affect llxprt-code due to automatic exclusion. Use `.llxprt/.env` files for llxprt-code specific debug settings. -**Note:** Debugging via the dev launcher (`scripts/start.ts`) still uses the same checked-in launcher as the published `llxprt` binary (`packages/cli/bin/llxprt.cjs`). That launcher resolves Bun and executes the `.ts` source entrypoint (`packages/cli/index.ts`) directly. +**Note:** Debugging via the dev launcher (`scripts/start.ts`) spawns Bun directly on the TypeScript source entrypoint (`packages/cli/index.ts`), equivalent to what the installed `llxprt` binary does via the native launcher (`packages/cli/bin/llxprt`). ### React DevTools diff --git a/README.md b/README.md index 67c9bbd9d..2207f5593 100644 --- a/README.md +++ b/README.md @@ -104,23 +104,25 @@ LLxprt Code is a command-line AI assistant designed for developers who want powe ### Bun Runtime and Install Fallback -LLxprt Code is powered by the [Bun](https://bun.sh) runtime. When you run `llxprt`, the checked-in Node launcher (`packages/cli/bin/llxprt.cjs`) resolves Bun and re-execs the TypeScript entrypoint (`packages/cli/index.ts`) under it. The CLI's run path does not require a pre-compiled CLI `dist/` artifact or the retired `bundle/llxprt.js` artifact. +LLxprt Code is powered by the [Bun](https://bun.sh) runtime. When you run `llxprt`, the platform-native launcher (`packages/cli/bin/llxprt`) resolves the package-bundled Bun and execs the TypeScript entrypoint (`packages/cli/index.ts`) directly — no Node process is started on the installed command path. The CLI's run path does not require a pre-compiled CLI `dist/` artifact or the retired `bundle/llxprt.js` artifact. -**Bun resolution order (production launcher):** +**Bun resolution (production launcher):** -1. `node_modules/.bin/bun` (the bundled Bun, climbing ancestor directories) -2. `node_modules/bun/bin/bun.exe` (direct dependency fallback) -3. `bun` found on `PATH` (via `which`/`where`) +1. Package-local: `/node_modules/bun/bin/bun.exe` (the package's pinned Bun dependency) +2. Hoisted (installed packages only): the enclosing `node_modules/bun/bin/bun.exe` (npm/Bun hoisting), stopping at the enclosing `node_modules` boundary — never climbing into consumer ancestors +3. Workspace root (source workspace only): when the package is not under a `node_modules` and the repository root is a verified llxprt-code workspace (its manifest references this package), that verified root's `node_modules/bun/bin/bun.exe` -If no Bun runtime is found, the launcher prints an error with instructions: +The launcher never scans `.bin` symlinks and never falls back to a global `bun` on `PATH`. When the package's `package.json` declares an exact Bun pin (e.g. `1.3.14`), a candidate whose `package.json`/version is missing or mismatched is rejected. -> Bun runtime was not found. Install it with "npm install" (it is bundled as the "bun" dependency) or install Bun directly from https://bun.sh and ensure it is on your PATH. +If no package-local Bun runtime is found, the launcher prints an actionable error (exit code 43): + +> LLxprt Code: bundled Bun runtime was not found. Reinstall the package with "npm install @vybestack/llxprt-code" to restore the bundled Bun dependency, or visit https://bun.sh To resolve this: - **npm users:** Re-run `npm install @vybestack/llxprt-code` (or `npm install -g @vybestack/llxprt-code`) to restore the bundled Bun dependency. - **Homebrew users:** Run `brew upgrade llxprt-code` to get the latest formula, or `brew reinstall llxprt-code` to restore a broken installation. -- **All users:** Install Bun directly from [https://bun.sh](https://bun.sh) and ensure it is on your `PATH`. +- **All users:** If the bundled Bun dependency cannot be restored, reinstalling the package is the supported path. A separately installed global Bun is not used by the launcher. **Windows pty caveat:** On Windows, the `node-pty` module has a known terminal resize race condition (`Cannot resize a pty that has already exited`). The CLI silences this specific error at the process level. On POSIX systems under Bun, a dedicated `bun-pty` adapter (`packages/core/src/utils/bunPtyAdapter.ts`) is used instead of `node-pty` to work around a Bun hang. Windows uses `@lydell/node-pty` (with `node-pty` as fallback), not the Bun adapter. If you encounter terminal sizing issues on Windows, use a compatible terminal emulator; the resize race is in `node-pty` itself, not the Bun runtime. diff --git a/bun.lock b/bun.lock index eb223b079..f82561b4d 100644 --- a/bun.lock +++ b/bun.lock @@ -245,7 +245,7 @@ "name": "@vybestack/llxprt-code", "version": "0.10.0", "bin": { - "llxprt": "bin/llxprt.cjs", + "llxprt": "bin/llxprt", }, "dependencies": { "@agentclientprotocol/sdk": "^0.14.1", diff --git a/dev-docs/npm.md b/dev-docs/npm.md index ffde2ab9f..d51131864 100644 --- a/dev-docs/npm.md +++ b/dev-docs/npm.md @@ -6,7 +6,7 @@ This monorepo contains two main packages: `@vybestack/llxprt-code` and `@vybesta This is the main package for the LLxprt Code. It is responsible for the user interface, command parsing, and all other user-facing functionality. -LLxprt Code runs on the [Bun](https://bun.sh) runtime. The published package ships a checked-in Node launcher (`packages/cli/bin/llxprt.cjs`) as its `bin` entry: Node starts the launcher, the launcher resolves Bun (from the package's own `bun` dependency or `PATH`), and Bun executes the TypeScript (`.ts`) entry point directly. No compilation to JavaScript happens at install or run time, and no pre-compiled `dist/` artifact is shipped or required. Type checking uses `tsc --noEmit`. The esbuild bundle artifact has been retired. +LLxprt Code runs on the [Bun](https://bun.sh) runtime. The published package ships platform-native launchers (`packages/cli/bin/llxprt`) as its `bin` entry. On POSIX, a valid `#!/bin/sh` shebang makes it directly execve-compatible; the launcher resolves the package-local Bun (from the package's own `bun` dependency, never a global PATH Bun) and execs the TypeScript (`.ts`) entry point directly. On Windows, the CLI workspace `postinstall` (`packages/cli/scripts/install-native-launchers.cjs`) replaces npm's generated cmd-shim with a native `.cmd` / `.ps1` launcher that invokes the same package-local Bun. (The root `scripts/postinstall.cjs` delegates to this script on Windows after linking internal workspace packages.) No Node process is started on the installed command path. No compilation to JavaScript happens at install or run time, and no pre-compiled `dist/` artifact is shipped or required. Type checking uses `tsc --noEmit`. The esbuild bundle artifact has been retired. ## `@vybestack/llxprt-code-core` @@ -166,7 +166,7 @@ By performing a dry run, you can be confident that your changes to the packaging ## Release Deep Dive The main goal of the release process is to take the source code from the packages/ directory, validate it, and publish -the CLI package with its checked-in Node launcher (`bin/llxprt.cjs`) plus the TypeScript sources executed by Bun. No +the CLI package with its platform-native launchers (`bin/llxprt`) plus the TypeScript sources executed by Bun. No compilation step is required for the published package; the retired `bundle/llxprt.js` artifact is no longer produced. Here are the key stages: @@ -182,7 +182,7 @@ Stage 1: Pre-Release Sanity Checks and Versioning Stage 2: Validating the Source Code - What happens: The TypeScript source code (`.ts`) is type-checked with `tsc --noEmit`. No JavaScript is emitted for the - published package — the checked-in Node launcher (`packages/cli/bin/llxprt.cjs`) resolves Bun at run time and Bun + published package — the native launcher (`packages/cli/bin/llxprt`) resolves the package-local Bun at run time and Bun executes the TypeScript sources directly. Development builds may still produce local `dist/` output for tooling, but the published package does not depend on it. - Why: The published package ships TypeScript source, so correctness is enforced by type-checking and tests rather than a @@ -194,14 +194,15 @@ This is the stage where the CLI package is prepared for publishing. 1. The workspace package metadata is validated and transformed as needed: - What happens: release scripts ensure the package metadata, dependency ranges, bin, main, and files fields point at - the checked-in launcher (`bin/llxprt.cjs`) and the shipped TypeScript sources executed by Bun. + the native launcher (`bin/llxprt`) and the shipped TypeScript sources executed by Bun. - Why: The final package must not expose development-only dependencies while still containing the runtime assets needed by npm/npx/Homebrew users. 2. Runtime files are included from the package `files` allowlist: - What happens: `packages/cli/package.json` ships `bin`, `src`, and `index.ts` while excluding tests and snapshots. - - Why: `bin/llxprt.cjs` is the Node-compatible launcher entry; it re-execs Bun, which runs the TypeScript source - directly. + - Why: `bin/llxprt` is the POSIX-native launcher entry; it resolves the package-local Bun and execs the TypeScript + source directly. On Windows, `packages/cli/scripts/install-native-launchers.cjs` (run via the CLI workspace + postinstall) generates the `.cmd` / `.ps1` launchers that invoke the same package-local Bun. Stage 4: Publishing to NPM @@ -229,7 +230,7 @@ graph TD end subgraph "Artifacts" - H["packages/cli/bin/llxprt.cjs"] + H["packages/cli/bin/llxprt"] I["TypeScript source packages"] J["npm tarball"] end @@ -272,3 +273,39 @@ This tells NPM that any folder inside the `packages` directory is a separate pac - **Simplified Dependency Management**: Running `bun install` from the root of the project will install all dependencies for all packages in the workspace and link them together. This means you don't need to run `bun install` in each package's directory. - **Automatic Linking**: Packages within the workspace can depend on each other. When you run `bun install`, Bun will automatically create symlinks between the packages. This means that when you make changes to one package, the changes are immediately available to other packages that depend on it. - **Simplified Script Execution**: You can run scripts in any package from the root of the project using Bun's `--filter` flag. For example, to run the `build` script in the `cli` package, you can run `bun run --filter '@vybestack/llxprt-code' build`. + +## Package-Manager Layout Support (issue #2603) + +The installed `llxprt` command uses platform-native launchers that resolve the +package-local Bun runtime directly. Supported package-manager layouts: + +### Fully Supported (POSIX + Windows) + +- **npm global** (`npm install -g @vybestack/llxprt-code`): POSIX symlink to the + shebang launcher; Windows `.cmd`/`.ps1` generated by `packages/cli/scripts/install-native-launchers.cjs`. +- **npm local** (`npm install @vybestack/llxprt-code`): Same as global, bin link + at `node_modules/.bin/llxprt`. +- **npm exec / npx** (`npx @vybestack/llxprt-code`): Temporary install invokes + the same launcher. + +### POSIX-Only (No Windows Wrapper Generation) + +- **Bun install** (`bun install -g`): Bun's lifecycle script handling differs + from npm's. On POSIX, Bun creates a symlink to the shebang launcher which works + correctly. On Windows, Bun does not reliably run npm-compatible lifecycle + scripts, so the CLI workspace `postinstall` may not execute. + Users on Windows should prefer `npm install -g` for the full native launcher + experience. + +### Limitations + +The `packages/cli/scripts/install-native-launchers.cjs` postinstall only generates Windows wrappers +(it is a no-op on POSIX where the shebang symlink suffices). The wrappers are +only written to bin-link directories that can be discovered at install time +(`npm_config_prefix` for global installs with `npm_config_global=true`, +`node_modules/.bin` for local installs via `INIT_CWD`). If a +package-manager uses a non-standard bin-link location, the POSIX symlink still +works, but the Windows wrappers may not be generated. In that case, the +npm-generated cmd-shim would be used, which invokes the POSIX launcher's +shebang — on Windows this falls back to `sh.exe` if available, or fails with an +error directing the user to reinstall. diff --git a/docs/deployment.md b/docs/deployment.md index a5222e4b4..39601df93 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -121,11 +121,13 @@ These packages are used when performing the standard installation and when runni **Build and packaging processes** -LLxprt Code runs on the [Bun](https://bun.sh) runtime. The CLI's run path starts with the checked-in Node launcher (`packages/cli/bin/llxprt.cjs`), which resolves Bun and executes the TypeScript (`.ts`) entry point directly. No pre-compiled CLI `dist/` artifact or retired `bundle/llxprt.js` artifact is required for the CLI to run. This applies to both distribution channels: +LLxprt Code runs on the [Bun](https://bun.sh) runtime. The CLI's installed command (`llxprt`) uses platform-native launchers that resolve the package-bundled Bun and execute the TypeScript (`.ts`) entry point directly, without starting Node. No pre-compiled CLI `dist/` artifact or retired `bundle/llxprt.js` artifact is required for the CLI to run. This applies to both distribution channels: -- **NPM publication:** The published NPM package exposes `packages/cli/bin/llxprt.cjs` as the `llxprt` binary. That launcher resolves Bun and executes the `.ts` source entrypoint directly. `tsc --noEmit` is used for type-checking during development. +- **NPM publication:** The published NPM package exposes `packages/cli/bin/llxprt` as the `llxprt` binary. On POSIX, a valid `#!/bin/sh` shebang makes it directly execve-compatible; the launcher resolves the package-local Bun and execs the `.ts` source entrypoint. On Windows, the CLI workspace `postinstall` (`packages/cli/scripts/install-native-launchers.cjs`) replaces npm's cmd-shim with a native `.cmd` / `.ps1` launcher that invokes the same package-local Bun. `tsc --noEmit` is used for type-checking during development. -- **GitHub `npx` execution:** When running the latest version of LLxprt Code directly from GitHub, the postinstall bootstrap ensures dependencies are available, and the checked-in launcher executes the `.ts` source through Bun. +The CLI's own runtime entry (`index.ts`) is never compiled to `dist/` — Bun executes the TypeScript source directly. The release pipeline still runs `npm run build:packages` to compile the internal workspace dependency packages (tools, storage, auth, etc.) whose `main`/`types` point at `dist/`, but the CLI package itself does not consume or ship a `dist/` artifact for its own runtime. + +- **GitHub `npx` execution:** When running the latest version of LLxprt Code directly from GitHub, the postinstall bootstrap ensures dependencies are available, and the native launcher executes the `.ts` source through Bun. Testing uses [vitest](https://vitest.dev), which is retained as the test runner. diff --git a/docs/getting-started.md b/docs/getting-started.md index b5d7e855e..66a945ff1 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -23,23 +23,23 @@ npx @vybestack/llxprt-code ### Bun Runtime and Install Fallback -LLxprt Code is powered by the [Bun](https://bun.sh) runtime. When you run `llxprt`, an internal launcher resolves Bun and re-execs the CLI under it. The launcher executes the TypeScript (`.ts`) entry point directly — the CLI's run path does not require a pre-compiled `dist/` artifact. +LLxprt Code is powered by the [Bun](https://bun.sh) runtime. When you run `llxprt`, a platform-native launcher resolves the package-local Bun and execs the CLI under it. The launcher executes the TypeScript (`.ts`) entry point directly — the CLI's run path does not require a pre-compiled `dist/` artifact. No Node process is started on the installed command path, and Bun does not need to be on your `PATH`. **Bun resolution order (production launcher):** -1. `node_modules/.bin/bun` (the bundled Bun, climbing ancestor directories) -2. `node_modules/bun/bin/bun.exe` (direct dependency fallback) -3. `bun` found on `PATH` (via `which`/`where`) +1. Package-local: `/node_modules/bun/bin/bun.exe` (the package's pinned `bun` dependency) +2. Hoisted (installed packages only): the enclosing `node_modules/bun/bin/bun.exe` (npm/Bun hoisting), stopping at the enclosing `node_modules` boundary — never climbing into consumer ancestors +3. Workspace root (source workspace only): when the package is not under a `node_modules` and the repository root is a verified llxprt-code workspace, that verified root's `node_modules/bun/bin/bun.exe` -If no Bun runtime is found, the launcher prints an error with instructions: +The launcher never scans `.bin` symlinks and never falls back to a global `bun` on `PATH`, so a separately installed Bun is not required. When an exact Bun pin is declared, a candidate whose version is missing or mismatched is rejected. If no package-local Bun is found, the launcher prints an error with instructions: -> Bun runtime was not found. Install it with "npm install" (it is bundled as the "bun" dependency) or install Bun directly from https://bun.sh and ensure it is on your PATH. +> LLxprt Code: bundled Bun runtime was not found. Reinstall the package with "npm install @vybestack/llxprt-code" to restore the bundled Bun dependency, or visit https://bun.sh To resolve this: - **npm users:** Re-run `npm install @vybestack/llxprt-code` (or `npm install -g @vybestack/llxprt-code`) to restore the bundled Bun dependency. - **Homebrew users:** Run `brew upgrade llxprt-code` to get the latest formula, or `brew reinstall llxprt-code` to restore a broken installation. -- **All users:** Install Bun directly from [https://bun.sh](https://bun.sh) and ensure it is on your `PATH`. +- **All users:** If the bundled Bun dependency cannot be restored, reinstalling the package is the supported path. A separately installed global Bun is not used by the launcher. ## Choose Your Path diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index bab41946e..b11be3e55 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -222,13 +222,14 @@ The `is-in-ci` package detects `CI`, `CONTINUOUS_INTEGRATION`, or any `CI_*` env ## Exit Codes -| Exit Code | Error Type | Description | -| --------- | -------------------------- | ---------------------------------------- | -| 41 | `FatalAuthenticationError` | Authentication failed | -| 42 | `FatalInputError` | Invalid input (non-interactive mode) | -| 44 | `FatalSandboxError` | Sandbox setup failed | -| 52 | `FatalConfigError` | Invalid settings.json | -| 53 | `FatalTurnLimitedError` | Max turns reached (non-interactive mode) | +| Exit Code | Error Type | Description | +| --------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| 41 | `FatalAuthenticationError` | Authentication failed | +| 42 | `FatalInputError` | Invalid input (non-interactive mode) | +| 43 | Launcher runtime failure | Bundled Bun runtime missing, corrupt, wrong platform, or an unrecognized native format — reinstall `@vybestack/llxprt-code` or visit https://bun.sh | +| 44 | `FatalSandboxError` | Sandbox setup failed | +| 52 | `FatalConfigError` | Invalid settings.json | +| 53 | `FatalTurnLimitedError` | Max turns reached (non-interactive mode) | ## Sandbox Issues @@ -277,7 +278,7 @@ Cache metrics only appear when the provider supports and reports them. OAuth use ## Building from Source -The CLI's run path starts with the checked-in Node launcher (`packages/cli/bin/llxprt.cjs`), which resolves [Bun](https://bun.sh) and executes the TypeScript entrypoint (`packages/cli/index.ts`) directly. No pre-compiled CLI `dist/` artifact or retired `bundle/llxprt.js` artifact is required for the CLI to run. +The CLI's installed command uses platform-native launchers (`packages/cli/bin/llxprt`) that resolve the package-bundled [Bun](https://bun.sh) and execute the TypeScript entrypoint (`packages/cli/index.ts`) directly — no Node process is started on the installed path. No pre-compiled CLI `dist/` artifact or retired `bundle/llxprt.js` artifact is required for the CLI to run. To build from source: diff --git a/eslint.config.js b/eslint.config.js index 0584e1829..2216ebc11 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -924,7 +924,11 @@ export default tseslint.config( }, // Settings for CommonJS scripts { - files: ['./scripts/**/*.cjs', './packages/cli/bin/**/*.cjs'], + files: [ + './scripts/**/*.cjs', + './packages/cli/bin/**/*.cjs', + './packages/cli/scripts/**/*.cjs', + ], languageOptions: { globals: { ...globals.node, diff --git a/package-lock.json b/package-lock.json index 52ca0b0dd..266d4cb77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -140,7 +140,7 @@ "zod-to-json-schema": "^3.25.1" }, "bin": { - "llxprt": "packages/cli/bin/llxprt.cjs" + "llxprt": "packages/cli/bin/llxprt" }, "devDependencies": { "@fast-check/vitest": "^0.2.2", @@ -23905,7 +23905,7 @@ "zod": "^3.23.8" }, "bin": { - "llxprt": "bin/llxprt.cjs" + "llxprt": "bin/llxprt" }, "devDependencies": { "@babel/runtime": "^7.27.6", @@ -23934,7 +23934,8 @@ }, "engines": { "node": ">=24" - } + }, + "hasInstallScript": true }, "packages/cli/node_modules/@types/node": { "version": "20.19.33", diff --git a/package.json b/package.json index 06a1f37e0..ffdc6878f 100644 --- a/package.json +++ b/package.json @@ -62,8 +62,6 @@ "auth:docker": "echo 'For ghcr.io, use: echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin'", "auth": "npm run auth:npm && npm run auth:docker", "generate": "bun scripts/generate-git-commit-info.ts && bun scripts/generate_prompt_manifest.ts", - "generate:cli-launcher": "bun scripts/generate-cli-launcher.ts", - "check:cli-launcher": "bun scripts/generate-cli-launcher.ts --check", "predocs:settings": "npm run build --workspace @vybestack/llxprt-code-settings && npm run build --workspace @vybestack/llxprt-code-core", "schema:settings": "bun ./scripts/generate-settings-schema.ts", "docs:settings": "bun ./scripts/generate-settings-doc.ts", @@ -114,6 +112,9 @@ "release:version": "bun scripts/version.ts", "telemetry": "bun scripts/telemetry.ts", "check:lockfile": "bun scripts/check-lockfile.ts", + "test:issue-2603-smoke": "node scripts/tests/issue-2603-release-install-smoke.cjs", + "test:issue-2603-benchmark": "node scripts/tests/issue-2603-startup-benchmark.cjs", + "test:windows-installed-smoke": "node scripts/windows-installed-command-smoke.cjs", "clean": "bun scripts/clean.ts" }, "overrides": { @@ -149,7 +150,7 @@ "typescript": "5.8.3" }, "bin": { - "llxprt": "packages/cli/bin/llxprt.cjs" + "llxprt": "packages/cli/bin/llxprt" }, "files": [ "packages/cli/bin/", diff --git a/packages/cli/bin/llxprt b/packages/cli/bin/llxprt new file mode 100755 index 000000000..a6f5bb001 --- /dev/null +++ b/packages/cli/bin/llxprt @@ -0,0 +1,391 @@ +#!/bin/sh +# shellcheck disable=SC2250 +set -u + +# Resolve the real location of this script, following symlinks using only +# portable POSIX constructs (readlink without -f, case-based join). This works +# on stock macOS BSD and Linux without GNU coreutils. +# +# A bounded iteration count guards against pathological symlink cycles. In +# practice npm .bin links are single-hop, but a crafted or corrupt chain is +# rejected after MAX_SYMLINK_HOPS rather than looping indefinitely. +_llxprt_self=$0 +_llxprt_hops=0 +MAX_SYMLINK_HOPS=40 +while [ -L "$_llxprt_self" ]; do + _llxprt_hops=$((_llxprt_hops + 1)) + if [ "$_llxprt_hops" -gt "$MAX_SYMLINK_HOPS" ]; then + printf '%s\n' 'LLxprt Code: symlink resolution exceeded maximum hops (possible cycle).' >&2 + printf '%s\n' "The symlink chain at $_llxprt_self may be cyclic or corrupt." >&2 + printf '%s\n' 'Reinstall the package with "npm install @vybestack/llxprt-code"' >&2 + exit 43 + fi + _llxprt_dir=$(dirname -- "$_llxprt_self") + # On readlink failure (permission denied, dangling link, I/O error), emit an + # actionable error and exit 43 immediately rather than preserving the same + # symlink (which would spin MAX_SYMLINK_HOPS iterations). The hop bound + # above still guards against cycles where readlink succeeds. + if ! _llxprt_target=$(readlink -- "$_llxprt_self" 2>/dev/null); then + printf '%s\n' 'LLxprt Code: could not resolve symlink (readlink failed).' >&2 + printf '%s\n' "The symlink at $_llxprt_self is broken, cyclic, or unreadable." >&2 + printf '%s\n' 'Reinstall the package with "npm install @vybestack/llxprt-code"' >&2 + exit 43 + fi + case "$_llxprt_target" in + /*) _llxprt_self=$_llxprt_target ;; + *) _llxprt_self=$_llxprt_dir/$_llxprt_target ;; + esac +done +_llxprt_script_dir=$(cd -- "$(dirname -- "$_llxprt_self")" 2>/dev/null && pwd) || \ + _llxprt_script_dir=$(dirname -- "$_llxprt_self") + +# Read the pinned Bun dependency version and package name from this package's +# own package.json. When an exact pin is present, discovered Bun candidates are +# validated against this pin so an unrelated Bun (a different version) is +# rejected even if it lives inside an allowed boundary. A missing or unreadable +# candidate package.json/version is also rejected when a pin exists, so a +# partial install cannot silently fall through to an unrelated Bun. +_llxprt_pkg_json=$_llxprt_script_dir/../package.json +_llxprt_bun_pin="" +if [ -f "$_llxprt_pkg_json" ]; then + _llxprt_bun_pin=$(sed -n 's/^[[:space:]]*"bun"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' -- "$_llxprt_pkg_json" 2>/dev/null | head -n1) +fi + +# Derive the candidate bun package.json path from a bun binary path. The binary +# lives at .../bun/bin/bun.exe and its package.json is two dirs up +# (.../bun/package.json). Sets _llxprt_bun_pkg to the derived path and returns +# 0; returns 1 for layouts where the package.json cannot be derived. +_llxprt_derive_bun_pkg() { + _llxprt_candidate=$1 + case "$_llxprt_candidate" in + */bin/bun|*/bin/bun.exe) + # .../bun/bin/bun.exe -> .../bun/package.json (two dirs up from binary) + _llxprt_bun_pkg=$(dirname -- "$(dirname -- "$_llxprt_candidate")")/package.json + return 0 + ;; + *) + # Unknown layout: cannot derive package.json. + return 1 + ;; + esac +} + +# Check whether $_llxprt_bun_pin is an exact X.Y.Z version (with optional +# prerelease suffix). Returns 0 for an exact pin, 1 for a range/non-exact spec. +# This is stricter than a bare digit-leading glob: "1.x" and "1.3.14 - 2.0.0" +# start with a digit but are NOT exact versions and must not be treated as pins. +# A prerelease pin like "1.3.14-beta.1" IS an exact version and must be +# accepted so the strict version equality check is applied during Bun beta/rc +# testing cycles. The prerelease suffix (dash followed by dot-separated +# alphanumeric identifiers) is validated so ranges like "1.3.14-" or +# "1.3.14-alpha.." are still rejected. +_llxprt_is_exact_pin() { + _llxprt_pin=$1 + _llxprt_prerelease="" + _llxprt_has_prerelease=0 + # Split on the first dash to separate the core version from any prerelease + # suffix (e.g. "1.3.14-beta.1" -> core="1.3.14", prerelease="beta.1"). + # A range like "1.x" or "^1.3.14" contains no dash and is handled below by + # the core-only numeric check. + case "$_llxprt_pin" in + *-*) + _llxprt_core=${_llxprt_pin%%-*} + _llxprt_prerelease=${_llxprt_pin#*-} + _llxprt_has_prerelease=1 + ;; + *) + _llxprt_core=$_llxprt_pin + ;; + esac + # The core (before any dash) must be purely numeric with dots. + case "$_llxprt_core" in + *[!0-9.]*) return 1 ;; + *) ;; + esac + # Must have exactly two dots (three numeric groups) in the core. + _llxprt_rest=$_llxprt_core + _llxprt_group=0 + while [ -n "$_llxprt_rest" ]; do + _llxprt_part=${_llxprt_rest%%.*} + case "$_llxprt_part" in + ''|*[!0-9]*) return 1 ;; + *) ;; + esac + _llxprt_group=$((_llxprt_group + 1)) + case "$_llxprt_rest" in + *.*) _llxprt_rest=${_llxprt_rest#*.} ;; + *) break ;; + esac + done + [ "$_llxprt_group" -eq 3 ] || return 1 + # If a prerelease separator is present, validate its non-empty, + # dot-separated identifiers (which may contain hyphens per semver). + if [ "$_llxprt_has_prerelease" -eq 1 ]; then + [ -n "$_llxprt_prerelease" ] || return 1 + case "$_llxprt_prerelease" in + .*|*.|*..*) return 1 ;; + *) ;; + esac + _llxprt_pre_rest=$_llxprt_prerelease + while [ -n "$_llxprt_pre_rest" ]; do + _llxprt_pre_part=${_llxprt_pre_rest%%.*} + case "$_llxprt_pre_part" in + ''|*[!A-Za-z0-9-]*) return 1 ;; + *) ;; + esac + case "$_llxprt_pre_rest" in + *.*) _llxprt_pre_rest=${_llxprt_pre_rest#*.} ;; + *) break ;; + esac + done + fi + return 0 +} + +# Check whether a discovered Bun binary's version matches the dependency pin. +# +# When no exact pin is known (empty _llxprt_bun_pin or a range like "^1.3.14"), +# the boundary check is the primary defense and the candidate is accepted +# (return 0). +# +# When an exact pin IS known, the candidate's package.json version MUST be +# present and MUST match. A missing/unreadable package.json or missing version +# field is rejected (return 1) rather than accepted, so a partial or tampered +# install cannot bypass the pin. This is stricter than a mere advisory check. +_llxprt_bun_validates() { + _llxprt_candidate=$1 + # No exact pin: accept (boundary check is the primary defense). A range + # pin (e.g. "^1.3.14" or "1.x") is not exact, so we cannot compare equality. + if [ -z "$_llxprt_bun_pin" ] || ! _llxprt_is_exact_pin "$_llxprt_bun_pin"; then + return 0 + fi + if ! _llxprt_derive_bun_pkg "$_llxprt_candidate"; then + # Cannot derive package.json for this layout; reject under an exact pin. + return 1 + fi + if [ ! -f "$_llxprt_bun_pkg" ]; then + # Candidate package.json missing: reject under an exact pin rather than + # accept, so a partial install cannot bypass version verification. + return 1 + fi + _llxprt_found_ver=$(sed -n 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' -- "$_llxprt_bun_pkg" 2>/dev/null | head -n1) + # sed may succeed with exit 0 but print nothing (malformed JSON, binary + # garbage, or a non-version manifest). Treat an empty extraction as a + # rejection under exact-pin semantics — do NOT accept. + if [ -z "$_llxprt_found_ver" ]; then + # Version field missing/unreadable: reject under an exact pin. + return 1 + fi + [ "$_llxprt_found_ver" = "$_llxprt_bun_pin" ] +} + +# Determine the package root (parent of the bin/ directory) and whether the +# package is installed under an enclosing node_modules directory. When installed +# (e.g. consumer/node_modules/@vybestack/llxprt-code/bin/llxprt), the enclosing +# node_modules boundary is honored and consumer ancestors are never climbed. +# When NOT under a node_modules (source workspace: packages/cli/bin/llxprt), a +# verified workspace-root fallback is used instead. +_llxprt_pkg_root=$(cd -- "$_llxprt_script_dir/.." 2>/dev/null && pwd) || \ + _llxprt_pkg_root=$_llxprt_script_dir/.. + +# Returns 0 if $_llxprt_pkg_root is nested under a directory literally named +# "node_modules" (an installed package), 1 otherwise. Sets +# _llxprt_enclosing_nm to the nearest enclosing node_modules path when found. +_llxprt_find_enclosing_nm() { + _llxprt_search=$_llxprt_pkg_root + _llxprt_enclosing_nm="" + while [ "$_llxprt_search" != "/" ] && [ -n "$_llxprt_search" ]; do + _llxprt_parent=$(dirname -- "$_llxprt_search") + if [ "$(basename -- "$_llxprt_parent")" = "node_modules" ]; then + _llxprt_enclosing_nm=$_llxprt_parent + return 0 + fi + _llxprt_search=$_llxprt_parent + done + return 1 +} + +# Verify a candidate repository root is the genuine llxprt-code workspace root +# for this package using canonical filesystem structure validation — NOT regex +# or grep scanning of arbitrary root JSON. Since the candidate root is derived +# deterministically as exactly three directories above this launcher +# (packages/cli/bin/llxprt → packages/cli/bin → packages/cli → root), the +# canonical layout is verified structurally: +# 1. The candidate's package.json exists. +# 2. The canonical package path /packages/cli equals our pkg_root. +# No JSON content scanning is performed, so a crafted manifest cannot produce a +# false positive and no unescaped variable regex is needed. Sets +# _llxprt_ws_root on success. +_llxprt_verify_workspace_root() { + _llxprt_candidate_root=$1 + _llxprt_root_manifest=$_llxprt_candidate_root/package.json + if [ ! -f "$_llxprt_root_manifest" ]; then + return 1 + fi + # Canonical structural check: the package root must be exactly + # /packages/cli. This confirms the launcher sits at the canonical + # source-workspace path without scanning any manifest content. Both sides of + # the comparison are canonicalized via cd && pwd so symlinked components, + # redundant separators, or ./.. segments cannot cause a false mismatch. + _llxprt_canonical_pkg=$(cd -- "$_llxprt_candidate_root/packages/cli" 2>/dev/null && pwd) || \ + _llxprt_canonical_pkg="$_llxprt_candidate_root/packages/cli" + _llxprt_pkg_root_abs=$(cd -- "$_llxprt_pkg_root" 2>/dev/null && pwd) || \ + _llxprt_pkg_root_abs="$_llxprt_pkg_root" + if [ "$_llxprt_pkg_root_abs" != "$_llxprt_canonical_pkg" ]; then + return 1 + fi + _llxprt_ws_root=$_llxprt_candidate_root + return 0 +} + +# Resolve the bundled Bun runtime. Resolution is strictly bounded so an +# unrelated consumer Bun can never be accepted: +# +# 1. Package-local: /node_modules/bun/bin/bun.exe +# 2. Hoisted (installed only): /bun/bin/bun.exe +# 3. Workspace root (source workspace only): if the package is NOT under a +# node_modules and the repository root two levels up is a verified +# llxprt-code workspace (its manifest references this package), permit +# only that verified root's node_modules/bun/bin/bun.exe. +# +# We deliberately do NOT scan .bin symlinks: resolving them portably without +# GNU readlink -f is unreliable, and the direct bun/bin/bun.exe paths are +# authoritative under both npm and Bun installers. We never generic-climb +# arbitrary ancestors. +_llxprt_bun="" +# 1. Package-local Bun. +if [ -x "$_llxprt_pkg_root/node_modules/bun/bin/bun.exe" ] && \ + _llxprt_bun_validates "$_llxprt_pkg_root/node_modules/bun/bin/bun.exe"; then + _llxprt_bun=$_llxprt_pkg_root/node_modules/bun/bin/bun.exe +fi + +# 2. Hoisted Bun within the enclosing node_modules (installed packages only). +if [ -z "$_llxprt_bun" ] && _llxprt_find_enclosing_nm; then + if [ -x "$_llxprt_enclosing_nm/bun/bin/bun.exe" ] && \ + _llxprt_bun_validates "$_llxprt_enclosing_nm/bun/bin/bun.exe"; then + _llxprt_bun=$_llxprt_enclosing_nm/bun/bin/bun.exe + fi +fi + +# 3. Workspace-root Bun (source workspace only). The package is NOT under a +# node_modules; verify the repository root two levels up +# (packages/cli -> packages -> repo-root) is a genuine workspace whose +# manifest references this package, then accept only its node_modules/bun. +if [ -z "$_llxprt_bun" ] && ! _llxprt_find_enclosing_nm; then + _llxprt_ws_candidate=$(cd -- "$_llxprt_pkg_root/../.." 2>/dev/null && pwd) || \ + _llxprt_ws_candidate="" + if [ -n "$_llxprt_ws_candidate" ] && \ + _llxprt_verify_workspace_root "$_llxprt_ws_candidate" && \ + [ -x "$_llxprt_ws_root/node_modules/bun/bin/bun.exe" ] && \ + _llxprt_bun_validates "$_llxprt_ws_root/node_modules/bun/bin/bun.exe"; then + _llxprt_bun=$_llxprt_ws_root/node_modules/bun/bin/bun.exe + fi +fi + +if [ -z "$_llxprt_bun" ]; then + printf '%s\n' 'LLxprt Code: bundled Bun runtime was not found.' >&2 + printf '%s\n' 'Reinstall the package with "npm install @vybestack/llxprt-code"' >&2 + printf '%s\n' 'to restore the bundled Bun dependency, or visit https://bun.sh' >&2 + exit 43 +fi + +# Validate the Bun executable's native binary magic before exec. A bare exec +# on a corrupt/text file triggers the shell's ENOEXEC fallback, which silently +# interprets the file as a shell script — masking the corruption. Reading the +# first 4 bytes via `od` (portable -An -tx1 -N4 form) lets us reject non-native +# binaries with an actionable exit 43 without spawning Bun. +# +# Platform-gated format acceptance: +# Darwin: Mach-O only (feedface/feedfacf/cefaedfe/cffaedfe, fat cafebabe/bebafeca) +# Linux: ELF only (7f454c46) +# MINGW/MSYS/CYGWIN: PE/COFF only (4d5a, "MZ") — Windows runs PE natively. +# +# Note: wrong-architecture vs wrong-format are distinct failure modes. This +# check validates the FORMAT (the OS can parse the container); it does NOT +# validate the architecture (e.g. arm64 vs x86_64). A wrong-architecture binary +# will be exec'd and fail with an OS error, which is a different, less common +# failure than a corrupt/text file. Architecture validation would require +# parsing the binary header (e.g. ELF e_machine or Mach-O cputype) and +# comparing against `uname -m`, which adds significant complexity for marginal +# gain; the format check covers the primary corruption vector. + +# `od` is a POSIX-standard utility; verify it is available before relying on +# it. A missing or failing `od` is treated as a launcher runtime failure (exit +# 43) with a clear diagnostic rather than silently skipping the format check. +if ! command -v od >/dev/null 2>&1; then + printf '%s\n' 'LLxprt Code: required tool "od" was not found on PATH.' >&2 + printf '%s\n' 'The launcher needs od to validate the bundled Bun binary format.' >&2 + printf '%s\n' 'Reinstall the package with "npm install @vybestack/llxprt-code"' >&2 + printf '%s\n' 'to restore the bundled Bun dependency, or visit https://bun.sh' >&2 + exit 43 +fi + +# Capture od's combined stdout+stderr into a single read so we can detect a +# read failure (non-existent/unreadable file) separately from a successful +# read of an unrecognized format. +_llxprt_od_out=$(od -An -tx1 -N4 -- "$_llxprt_bun" 2>&1) || { + # od itself failed (read error, I/O error). Treat as a launcher runtime + # failure with a clear diagnostic. + printf '%s\n' 'LLxprt Code: could not read bundled Bun binary to validate its format.' >&2 + printf '%s\n' "od reported: $_llxprt_od_out" >&2 + printf '%s\n' 'The bundled bun.exe may be corrupt, the wrong platform, or an unrecognized native format.' >&2 + printf '%s\n' 'Reinstall the package with "npm install @vybestack/llxprt-code"' >&2 + printf '%s\n' 'to restore the bundled Bun dependency, or visit https://bun.sh' >&2 + exit 43 +} +_llxprt_magic=$(printf '%s' "$_llxprt_od_out" | tr -d ' \n') + +_llxprt_kernel=$(uname -s 2>/dev/null || printf '%s' '') +case "$_llxprt_kernel" in + MINGW*|MSYS*|CYGWIN*) + # Windows POSIX layer: only PE/COFF is accepted (the OS runs PE natively; + # ELF and Mach-O would indicate a corrupt or wrong-platform install). + case "$_llxprt_magic" in + 4d5a*) ;; + *) + printf '%s\n' 'LLxprt Code: bundled Bun runtime is not a usable native binary.' >&2 + printf '%s\n' 'The bundled bun.exe may be corrupt, the wrong platform, or an unrecognized native format.' >&2 + printf '%s\n' 'Reinstall the package with "npm install @vybestack/llxprt-code"' >&2 + printf '%s\n' 'to restore the bundled Bun dependency, or visit https://bun.sh' >&2 + exit 43 + ;; + esac + ;; + Darwin) + # macOS: only Mach-O is accepted. + case "$_llxprt_magic" in + feedface|feedfacf|cefaedfe|cffaedfe|cafebabe|bebafeca) ;; + *) + printf '%s\n' 'LLxprt Code: bundled Bun runtime is not a usable native binary.' >&2 + printf '%s\n' 'The bundled bun.exe may be corrupt, the wrong platform, or an unrecognized native format.' >&2 + printf '%s\n' 'Reinstall the package with "npm install @vybestack/llxprt-code"' >&2 + printf '%s\n' 'to restore the bundled Bun dependency, or visit https://bun.sh' >&2 + exit 43 + ;; + esac + ;; + *) + # Linux and other ELF systems: only ELF is accepted. + case "$_llxprt_magic" in + 7f454c46) ;; + *) + printf '%s\n' 'LLxprt Code: bundled Bun runtime is not a usable native binary.' >&2 + printf '%s\n' 'The bundled bun.exe may be corrupt, the wrong platform, or an unrecognized native format.' >&2 + printf '%s\n' 'Reinstall the package with "npm install @vybestack/llxprt-code"' >&2 + printf '%s\n' 'to restore the bundled Bun dependency, or visit https://bun.sh' >&2 + exit 43 + ;; + esac + ;; +esac + +_llxprt_entry="$_llxprt_pkg_root/index.ts" + +if [ ! -f "$_llxprt_entry" ]; then + printf '%s\n' 'LLxprt Code: TypeScript entry point (index.ts) was not found.' >&2 + printf '%s\n' "Expected: $_llxprt_entry" >&2 + printf '%s\n' 'Your installation may be corrupt; reinstall @vybestack/llxprt-code.' >&2 + exit 43 +fi + +exec "$_llxprt_bun" "$_llxprt_entry" "$@" diff --git a/packages/cli/bin/llxprt.cjs b/packages/cli/bin/llxprt.cjs deleted file mode 100755 index 1129bdc8d..000000000 --- a/packages/cli/bin/llxprt.cjs +++ /dev/null @@ -1,518 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -// cli-bin.cts -var import_node_child_process = require("node:child_process"); -var import_node_fs = require("node:fs"); -var import_node_path2 = require("node:path"); - -// bun-candidate-policy.ts -var import_node_path = require("node:path"); -var WINDOWS_BUN_CANDIDATE_PRIORITY = { - "bin-native": 0, - "direct-native": 1, - "path-native": 2, - wrapper: 3, -}; -function orderWindowsBunCandidates(candidates) { - return [...candidates].sort( - (left, right) => - WINDOWS_BUN_CANDIDATE_PRIORITY[left.kind] - - WINDOWS_BUN_CANDIDATE_PRIORITY[right.kind], - ); -} -function isWindowsBunWrapper(candidate) { - return candidate.kind === "wrapper"; -} -function classifyWindowsPathCandidate(path) { - switch (import_node_path.win32.basename(path).toLowerCase()) { - case "bun.exe": - return { path, kind: "path-native" }; - case "bun.cmd": - return { path, kind: "wrapper" }; - default: - return null; - } -} -function classifyWindowsPathCandidates(paths) { - return paths.flatMap((path) => { - const candidate = classifyWindowsPathCandidate(path); - return candidate === null ? [] : [candidate]; - }); -} - -// cli-bin.cts -function runtimeModuleFilename(currentModule) { - return currentModule.filename; -} -var launcherDir = import_node_path2.dirname(runtimeModuleFilename(module)); -var BUN_RELAUNCH_ENV = "LLXPRT_BUN_RELAUNCHED"; -var FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP", "SIGBREAK"]; -var SIGHUP_SELF_EXIT_DELAY_MS = 5000; -var ORPHAN_CHECK_INTERVAL_MS = 1e4; -var SIGHUP_EXIT_CODE = 129; -var SIGNAL_EXIT_CODES = { - SIGHUP: SIGHUP_EXIT_CODE, - SIGINT: 130, - SIGQUIT: 131, - SIGILL: 132, - SIGTRAP: 133, - SIGABRT: 134, - SIGBUS: 135, - SIGFPE: 136, - SIGKILL: 137, - SIGUSR1: 138, - SIGSEGV: 139, - SIGUSR2: 140, - SIGPIPE: 141, - SIGALRM: 142, - SIGTERM: 143, - SIGBREAK: 149, -}; -function ancestors(startDir) { - const dirs = []; - let dir = startDir; - while (dir !== import_node_path2.dirname(dir)) { - dirs.push(dir); - dir = import_node_path2.dirname(dir); - } - dirs.push(dir); - return dirs; -} -function isFile(path) { - try { - return import_node_fs.statSync(path).isFile(); - } catch { - return false; - } -} -function isExecutable(path) { - try { - import_node_fs.accessSync(path, import_node_fs.constants.X_OK); - return isSpawnableUnixCandidate(path); - } catch { - return false; - } -} -function isSpawnableUnixCandidate(path) { - if (process.platform === "win32") { - return true; - } - try { - const firstBytes = import_node_fs.readFileSync(path).subarray(0, 4); - const magic = firstBytes.toString("hex"); - return ( - firstBytes.toString("utf8").startsWith("#!") || - magic === "7f454c46" || - magic === "cffaedfe" || - magic === "feedfacf" - ); - } catch { - return false; - } -} -function resolveEntry() { - const packageRootEntry = import_node_path2.join( - import_node_path2.dirname(launcherDir), - "index.ts", - ); - if (isFile(packageRootEntry)) { - return packageRootEntry; - } - for (const dir of ancestors(launcherDir)) { - const packageEntry = import_node_path2.join(dir, "index.ts"); - if (isFile(packageEntry) && import_node_path2.basename(dir) === "cli") { - return packageEntry; - } - const repositoryEntry = import_node_path2.join( - dir, - "packages", - "cli", - "index.ts", - ); - if (isFile(repositoryEntry)) { - return repositoryEntry; - } - } - return null; -} -function bunNames() { - return ["bun"]; -} -function directBunNames() { - return ["bun.exe", "bun"]; -} -function resolveBunFromNodeModules() { - for (const dir of ancestors(launcherDir)) { - for (const name of bunNames()) { - const candidate = import_node_path2.join( - dir, - "node_modules", - ".bin", - name, - ); - if (isExecutable(candidate)) { - return candidate; - } - } - for (const name of directBunNames()) { - const candidate = import_node_path2.join( - dir, - "node_modules", - "bun", - "bin", - name, - ); - if (isExecutable(candidate)) { - return candidate; - } - } - } - return null; -} -function pathLookupTool() { - if (process.platform !== "win32") { - return "which"; - } - const systemRoot = process.env["SystemRoot"]; - return systemRoot !== undefined && - import_node_path2.win32.isAbsolute(systemRoot) - ? import_node_path2.win32.join(systemRoot, "System32", "where.exe") - : "where.exe"; -} -function pathCandidates() { - const result = import_node_child_process.spawnSync( - pathLookupTool(), - ["bun"], - { - encoding: "utf8", - windowsHide: true, - }, - ); - if (result.status !== 0 || typeof result.stdout !== "string") { - return []; - } - return result.stdout - .split(/\r?\n/) - .map((line) => line.trim().replace(/^(["'])(.+?)\1$/, "$2")) - .filter((candidate) => candidate.length > 0); -} -function resolveBunFromPath() { - for (const candidate of pathCandidates()) { - if (isExecutable(candidate)) { - return candidate; - } - } - return null; -} -function windowsNodeModuleCandidates() { - return ancestors(launcherDir).flatMap((dir) => [ - { - path: import_node_path2.join(dir, "node_modules", ".bin", "bun.exe"), - kind: "bin-native", - }, - { - path: import_node_path2.join(dir, "node_modules", ".bin", "bun.cmd"), - kind: "wrapper", - }, - { - path: import_node_path2.join( - dir, - "node_modules", - "bun", - "bin", - "bun.exe", - ), - kind: "direct-native", - }, - { - path: import_node_path2.join( - dir, - "node_modules", - "bun", - "bin", - "bun.cmd", - ), - kind: "wrapper", - }, - ]); -} -function windowsPathCandidates() { - return classifyWindowsPathCandidates(pathCandidates()); -} -function firstUsableCandidate(candidates) { - for (const candidate of candidates) { - if (isExecutable(candidate.path)) { - return candidate.path; - } - } - return null; -} -function resolveWindowsBun() { - const localCandidates = orderWindowsBunCandidates( - windowsNodeModuleCandidates(), - ); - const localNative = firstUsableCandidate( - localCandidates.filter((candidate) => !isWindowsBunWrapper(candidate)), - ); - if (localNative !== null) { - return localNative; - } - return firstUsableCandidate( - orderWindowsBunCandidates([ - ...localCandidates.filter(isWindowsBunWrapper), - ...windowsPathCandidates(), - ]), - ); -} -function resolveBun() { - return process.platform === "win32" - ? resolveWindowsBun() - : (resolveBunFromNodeModules() ?? resolveBunFromPath()); -} -function hasWindowsCmdMetaCharacter(arg) { - return /[&|<>^()%!"\r\n]/.test(arg); -} -function isWindowsCmdShim(path) { - return ( - process.platform === "win32" && - import_node_path2.basename(path).toLowerCase() === "bun.cmd" - ); -} -function describeError(error) { - return error instanceof Error ? error.message : String(error); -} -function bunLaunchErrorMessage(bunPath, error) { - return `Failed to launch Bun at "${bunPath}" (${describeError(error)}). Reinstall dependencies with "npm install" to restore the bundled Bun, or ensure a working Bun is executable and on your PATH (see https://bun.sh).`; -} -function fatalExit(exit, message) { - process.stderr.write(`${message} -`); - exit(43); -} -function resolveBunOrFail(exit, resolveBunFn) { - const bunPath = resolveBunFn === undefined ? resolveBun() : resolveBunFn(); - if (bunPath === null) { - fatalExit( - exit, - 'Bun runtime was not found. Install it with "npm install" (it is bundled as the "bun" dependency) or install Bun directly from https://bun.sh and ensure it is on your PATH.', - ); - return null; - } - return bunPath; -} -function resolveEntryOrFail(exit, resolveEntryFn) { - const entry = - resolveEntryFn === undefined ? resolveEntry() : resolveEntryFn(); - if (entry === null) { - fatalExit( - exit, - "Could not locate the LLxprt Code TypeScript entry point (packages/cli/index.ts). Your installation may be corrupt; reinstall @vybestack/llxprt-code.", - ); - return null; - } - return entry; -} -function windowsCommandProcessor() { - const systemRoot = process.env["SystemRoot"]; - return systemRoot !== undefined && - import_node_path2.win32.isAbsolute(systemRoot) - ? import_node_path2.win32.join(systemRoot, "System32", "cmd.exe") - : "cmd.exe"; -} -function quoteWindowsCommandArgument(arg) { - const escapedTrailingBackslashes = arg.replace(/\\+$/, (backslashes) => - backslashes.repeat(2), - ); - return `"${escapedTrailingBackslashes}"`; -} -function buildSpawnInvocation(bunPath, entry) { - const args = [entry, ...process.argv.slice(2)]; - if (!isWindowsCmdShim(bunPath)) { - return { command: bunPath, args }; - } - if (hasWindowsCmdMetaCharacter(bunPath)) { - return { - error: - "Cannot safely launch the bundled bun.cmd shim from a path containing Windows command-shell metacharacters. Install Bun directly so bun.exe is on PATH, or move the installation to a path without shell metacharacters.", - }; - } - if (args.some(hasWindowsCmdMetaCharacter)) { - return { - error: - "Cannot safely forward arguments containing Windows command-shell metacharacters through the bundled bun.cmd shim. Install Bun directly so bun.exe is on PATH, or remove shell metacharacters from the CLI arguments.", - }; - } - const commandLine = [bunPath, ...args] - .map(quoteWindowsCommandArgument) - .join(" "); - return { - command: windowsCommandProcessor(), - args: ["/d", "/s", "/c", `"${commandLine}"`], - windowsVerbatimArguments: true, - }; -} -function createChildEnv() { - return { ...process.env, [BUN_RELAUNCH_ENV]: "true" }; -} -async function runCliBin(options = {}) { - const exit = options.exit ?? process.exit; - const spawnFn = options.spawn ?? import_node_child_process.spawn; - const bunPath = resolveBunOrFail(exit, options.resolveBun); - if (bunPath === null) { - return; - } - const entry = resolveEntryOrFail(exit, options.resolveEntry); - if (entry === null) { - return; - } - const built = buildSpawnInvocation(bunPath, entry); - if ("error" in built) { - fatalExit(exit, built.error); - return; - } - let child; - try { - child = spawnFn(built.command, built.args, { - stdio: "inherit", - env: createChildEnv(), - windowsVerbatimArguments: built.windowsVerbatimArguments, - }); - } catch (error) { - fatalExit(exit, bunLaunchErrorMessage(bunPath, error)); - return; - } - attachChildHandlers(child, bunPath, exit, { - getPpid: options.getPpid, - selfExitDelayMs: options.selfExitDelayMs, - orphanCheckIntervalMs: options.orphanCheckIntervalMs, - }); -} -function attachChildHandlers(child, bunPath, exit, options = {}) { - let settled = false; - let childExitInfo = null; - let hangupExitTimer = null; - let orphanCheckTimer = null; - const getPpid = options.getPpid ?? (() => process.ppid); - const selfExitDelayMs = options.selfExitDelayMs ?? SIGHUP_SELF_EXIT_DELAY_MS; - const orphanCheckIntervalMs = - options.orphanCheckIntervalMs ?? ORPHAN_CHECK_INTERVAL_MS; - const cleanupListeners = () => { - child.off("close", onClose); - child.off("error", onError); - child.off("exit", onChildExit); - for (const signal of FORWARDED_SIGNALS) { - process.off(signal, forwardSignal); - } - process.off("beforeExit", onBeforeExit); - if (hangupExitTimer !== null) { - clearTimeout(hangupExitTimer); - hangupExitTimer = null; - } - if (orphanCheckTimer !== null) { - clearInterval(orphanCheckTimer); - orphanCheckTimer = null; - } - }; - const prepareSettle = () => { - if (settled) { - return false; - } - settled = true; - cleanupListeners(); - child.on("error", () => {}); - return true; - }; - const settle = (exitCode) => { - if (!prepareSettle()) { - return; - } - exit(exitCode); - }; - const exitCodeFromChild = (code, signal) => { - if (code !== null) { - return code; - } - if (signal !== null) { - return SIGNAL_EXIT_CODES[signal] ?? 1; - } - return 1; - }; - const forwardSignal = (signal) => { - if (settled) { - return; - } - try { - child.kill(signal); - } catch { - if (signal !== "SIGHUP") { - return; - } - } - if (signal === "SIGHUP" && hangupExitTimer === null) { - hangupExitTimer = setTimeout(() => { - settle(SIGHUP_EXIT_CODE); - }, selfExitDelayMs); - hangupExitTimer.unref(); - } - }; - const onClose = (code, signal) => { - settle(exitCodeFromChild(code, signal)); - }; - const onError = (error) => { - if (!prepareSettle()) { - return; - } - try { - child.kill("SIGTERM"); - } catch (killError) { - process.stderr - .write(`Failed to stop Bun after its spawn error (${describeError(killError)}). -`); - } - process.stderr.write(`${bunLaunchErrorMessage(bunPath, error)} -`); - exit(43); - }; - const onChildExit = (code, signal) => { - childExitInfo = { code, signal }; - }; - const onBeforeExit = () => { - if (settled || childExitInfo === null) { - return; - } - settle(exitCodeFromChild(childExitInfo.code, childExitInfo.signal)); - }; - const checkOrphaned = () => { - if (settled || childExitInfo === null) { - return; - } - let orphaned; - try { - orphaned = getPpid() === 1; - } catch { - return; - } - if (orphaned) { - settle(exitCodeFromChild(childExitInfo.code, childExitInfo.signal)); - } - }; - for (const signal of FORWARDED_SIGNALS) { - process.on(signal, forwardSignal); - } - child.on("error", onError); - child.on("close", onClose); - child.on("exit", onChildExit); - process.on("beforeExit", onBeforeExit); - orphanCheckTimer = setInterval(checkOrphaned, orphanCheckIntervalMs); - orphanCheckTimer.unref(); -} -module.exports = { runCliBin }; -if (Object.is(module, require.main)) { - runCliBin().catch((error) => { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`${message} -`); - process.exit(1); - }); -} diff --git a/packages/cli/package.json b/packages/cli/package.json index 4d3baa3c7..858b2bace 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -11,12 +11,13 @@ "main": "index.ts", "types": "index.ts", "bin": { - "llxprt": "bin/llxprt.cjs" + "llxprt": "bin/llxprt" }, "scripts": { "build": "bun ../../scripts/build_package.ts && bun ../../scripts/chmod_executable.ts dist/index.js", "start": "bun index.ts", "debug": "bun --inspect-brk index.ts", + "postinstall": "node scripts/install-native-launchers.cjs", "lint": "eslint . --ext .ts,.tsx", "format": "prettier --write .", "test": "vitest run", @@ -32,6 +33,7 @@ "bin", "index.ts", "src", + "scripts/install-native-launchers.cjs", "README.md", "LICENSE", "!**/*.test.ts", diff --git a/packages/cli/scripts/install-native-launchers.cjs b/packages/cli/scripts/install-native-launchers.cjs new file mode 100644 index 000000000..73eeacd97 --- /dev/null +++ b/packages/cli/scripts/install-native-launchers.cjs @@ -0,0 +1,588 @@ +#!/usr/bin/env node + +/* eslint-env node */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { createRequire } = require('module'); + +const PACKAGE_NAME = '@vybestack/llxprt-code'; +const BIN_NAME = 'llxprt'; +const OWNERSHIP_SENTINEL = + 'LLXPRT_NATIVE_LAUNCHER owned by @vybestack/llxprt-code'; +const LAUNCHER_ERROR_EXIT_CODE = 43; + +// Reads a file, returning '' for ENOENT (missing) only. Other errors +// (EACCES, EISDIR, EIO) propagate so a protected or unreadable foreign shim +// is NOT silently treated as an empty/zero-byte file. This prevents +// canOverwriteLauncher from clobbering a protected shim whose ACLs prevent +// reading. +function readFileSafe(filePath) { + try { + return fs.readFileSync(filePath, 'utf8'); + } catch (e) { + if (e && e.code === 'ENOENT') { + return ''; + } + throw e; + } +} + +// Returns true when filePath is a regular FILE (not a directory, device, or +// other special type). Use this instead of bare existsSync so a directory +// named bun.exe or index.ts (from a corrupt install) is not treated as the +// expected file. +function isRegularFile(filePath) { + try { + return fs.statSync(filePath).isFile(); + } catch { + return false; + } +} + +function hasOwnershipSentinel(filePath) { + if (!isRegularFile(filePath)) { + return false; + } + return readFileSafe(filePath).includes(OWNERSHIP_SENTINEL); +} + +function isWithinPackageRoot(resolvedPath, packageRoot) { + const rel = path.relative(packageRoot, resolvedPath); + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); +} + +// npm cmd-shim wraps a shebanged target with an interpreter. For a +// #!/bin/sh target, the .cmd file references both "/bin/sh.exe" (the +// interpreter) and the real package target ("%dp0%\..\path\to\bin\llxprt"). +// Only the package target must authorize overwriting; the interpreter +// reference must be ignored. Return every "%dp0%\" candidate so the +// caller can test each against the package boundary. +// cmd-shim uses backslashes on Windows, but accept forward slashes too for +// robustness against alternate shim generators. +function extractCmdShimTargets(content) { + const targets = []; + const seen = new Set(); + const re = /"%dp0%[/\\]([^"]+)"/g; + let m; + while ((m = re.exec(content)) !== null) { + if (!seen.has(m[1])) { + seen.add(m[1]); + targets.push(m[1]); + } + } + return targets; +} + +// npm cmd-shim ps1 files reference the interpreter ("$basedir//bin/sh$exe") +// and the real package target ("$basedir/../path/to/bin/llxprt"). npm +// cmd-shim emits forward slashes on all platforms, but some environments or +// future versions may emit backslashes on Windows. Accept both separators +// so the ownership check is robust. Return every "$basedir/" candidate +// so the caller can test each against the package boundary. +function extractPs1ShimTargets(content) { + const targets = []; + const seen = new Set(); + const re = /"\$basedir[/\\]([^"]+)"/g; + let m; + while ((m = re.exec(content)) !== null) { + if (!seen.has(m[1])) { + seen.add(m[1]); + targets.push(m[1]); + } + } + return targets; +} + +// Resolve a shim-relative reference using Windows path semantics, even when the +// postinstall unit test runs on POSIX. A reference like "..\prefix\lib\...\llxprt" +// must not be split on "/" (POSIX) when it represents a Windows wrapper path. +function resolveShimCandidate(binLinkDir, relativeTarget) { + const normalized = relativeTarget.replace(/\//g, '\\'); + return path.win32.resolve(binLinkDir, normalized); +} + +// Boundary check using Windows path semantics so backslash-separated resolved +// paths match packageRoot consistently. +function isWithinPackageRootWin(resolvedPath, packageRoot) { + const rel = path.win32.relative(packageRoot, resolvedPath); + return rel === '' || (!rel.startsWith('..') && !path.win32.isAbsolute(rel)); +} + +// Shared core: extract shim target candidates from content and return true if +// ANY candidate resolves within the exact packageRoot boundary. The interpreter +// reference (e.g. /bin/sh) resolves outside the package and is correctly +// ignored; the package target resolves inside and authorizes. Uses path.win32 +// for both resolution and boundary check so Windows wrapper semantics +// (backslash separators) are applied consistently even when the postinstall +// unit test runs on POSIX. +function shimTargetWithinPackage(content, binLinkDir, packageRoot, shimType) { + const candidates = + shimType === 'ps1' + ? extractPs1ShimTargets(content) + : extractCmdShimTargets(content); + if (candidates.length === 0) { + return false; + } + for (const candidate of candidates) { + const resolved = resolveShimCandidate(binLinkDir, candidate); + if (isWithinPackageRootWin(resolved, packageRoot)) { + return true; + } + } + return false; +} + +function pointsToOurPackage(filePath, binLinkDir, packageRoot, shimType) { + const content = readFileSafe(filePath); + // An empty file does NOT point to our package (it has no content). This is + // intentionally different from canOverwriteLauncher, which returns true for + // empty files to repair truncated launchers. The two functions answer + // different questions: pointsToOurPackage asks "does this file reference + // our package?" while canOverwriteLauncher asks "is it safe to replace?". + if (!content) { + return false; + } + return shimTargetWithinPackage(content, binLinkDir, packageRoot, shimType); +} + +function canOverwriteLauncher(filePath, binLinkDir, packageRoot, shimType) { + if (!isRegularFile(filePath)) { + // If the path does not exist, overwrite is safe. If it is a directory + // or special file, refuse to overwrite (a directory named llxprt.cmd + // from a corrupt install should not be silently clobbered). + return !fs.existsSync(filePath); + } + // Read the file once and reuse the content for both the ownership sentinel + // check and the shim-target boundary check, avoiding a duplicate read. + // A foreign shim that exists but is unreadable (EACCES, EISDIR, EIO) must + // be treated as non-overwritable rather than crashing postinstall. The + // presence of an unreadable file at the launcher path signals a protected + // or unexpected state that should not be silently clobbered. + let content; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch (e) { + if (e && e.code === 'ENOENT') { + // Removed between isRegularFile and readFileSync: treat as missing. + return true; + } + // EACCES, EISDIR, EIO, etc: do NOT overwrite an unreadable foreign shim. + return false; + } + if (content.includes(OWNERSHIP_SENTINEL)) { + return true; + } + // A zero-byte file cannot be a valid foreign shim (it has no interpreter + // reference, no sentinel, and no target). Allowing overwrite here repairs + // a truncated launcher left by a failed install without risking a foreign + // shim. + if (content.length === 0) { + return true; + } + return shimTargetWithinPackage(content, binLinkDir, packageRoot, shimType); +} + +function relativePath(fromDir, toPath) { + return path.relative(fromDir, toPath).split(path.sep).join('\\'); +} + +function relativePathPosix(fromDir, toPath) { + return path.relative(fromDir, toPath).split(path.sep).join('/'); +} + +function escapeForCmdQuote(value) { + // Double internal double quotes (cmd's quoting rule) and double percent + // signs so a literal % survives cmd.exe's batch parser. Inside a batch file, + // %VAR% expands environment variables and %% is a literal %. The generated + // launcher forwards args via %*, not %1..%9, so positional expansion does not + // apply; doubling % ensures a literal percent is preserved verbatim. + return value.replace(/"/g, '""').replace(/%/g, '%%'); +} + +// cmd cannot distinguish a CreateProcess launch failure (corrupt binary, +// access denied, not found) from a legitimate nonzero application exit after +// the process has returned: both surface as %ERRORLEVEL%. Remapping specific +// errorlevels (5, 193, 9009) would corrupt legitimate CLI exit codes that +// happen to collide with those values. Therefore the cmd launcher only +// preflights file existence (giving exit 43 for a missing bun/entry) and then +// preserves the child's exit code EXACTLY. The PowerShell launcher (ps1) +// uses try/catch to detect native launch exceptions for the diagnostic path. +function generateCmdLauncher(bunRelative, entryRelative) { + const bunQuoted = escapeForCmdQuote(bunRelative); + const entryQuoted = escapeForCmdQuote(entryRelative); + return [ + '@echo off', + `REM ${OWNERSHIP_SENTINEL}`, + 'setlocal enableextensions', + `if not exist "%~dp0${bunQuoted}" goto :LLXPRT_NO_BUN`, + `if not exist "%~dp0${entryQuoted}" goto :LLXPRT_NO_ENTRY`, + `"%~dp0${bunQuoted}" "%~dp0${entryQuoted}" %*`, + 'exit /b %ERRORLEVEL%', + '', + ':LLXPRT_NO_BUN', + 'echo LLxprt Code: bundled Bun runtime was not found. 1>&2', + 'echo Reinstall the package with "npm install @vybestack/llxprt-code" 1>&2', + 'echo to restore the bundled Bun dependency, or visit https://bun.sh 1>&2', + 'exit /b ' + LAUNCHER_ERROR_EXIT_CODE, + '', + ':LLXPRT_NO_ENTRY', + 'echo LLxprt Code: TypeScript entry point ^(index.ts^) was not found. 1>&2', + 'echo Your installation may be corrupt; reinstall @vybestack/llxprt-code. 1>&2', + 'exit /b ' + LAUNCHER_ERROR_EXIT_CODE, + '', + ].join('\r\n'); +} + +function generatePs1Launcher(bunRelativePosix, entryRelativePosix) { + const bunPs = bunRelativePosix.replace(/'/g, "''"); + const entryPs = entryRelativePosix.replace(/'/g, "''"); + return [ + '#!/usr/bin/env pwsh', + `# ${OWNERSHIP_SENTINEL}`, + '$basedir = Split-Path $MyInvocation.MyCommand.Definition -Parent', + `$bunExe = Join-Path $basedir '${bunPs}'`, + `$entry = Join-Path $basedir '${entryPs}'`, + 'if (-not (Test-Path $bunExe)) {', + " [Console]::Error.WriteLine('LLxprt Code: bundled Bun runtime was not found.')", + ' [Console]::Error.WriteLine(\'Reinstall the package with "npm install @vybestack/llxprt-code"\')', + " [Console]::Error.WriteLine('to restore the bundled Bun dependency, or visit https://bun.sh')", + ' exit ' + LAUNCHER_ERROR_EXIT_CODE, + '}', + 'if (-not (Test-Path $entry)) {', + " [Console]::Error.WriteLine('LLxprt Code: TypeScript entry point (index.ts) was not found.')", + " [Console]::Error.WriteLine('Your installation may be corrupt; reinstall @vybestack/llxprt-code.')", + ' exit ' + LAUNCHER_ERROR_EXIT_CODE, + '}', + '$allArgs = @($entry) + $args', + // Wrap the native invocation so a launch failure (corrupt/missing binary, + // access denied) is distinguished from a legitimate CLI nonzero exit. + // PowerShell throws when the OS cannot start the process; we catch that + // and exit 43 with the diagnostic. A normal nonzero exit from the CLI is + // propagated unchanged via $LASTEXITCODE. + 'try {', + ' if ($MyInvocation.ExpectingInput) {', + ' $input | & $bunExe @allArgs', + ' } else {', + ' & $bunExe @allArgs', + ' }', + '} catch {', + " [Console]::Error.WriteLine('LLxprt Code: bundled Bun runtime could not be launched.')", + " [Console]::Error.WriteLine('The bundled bun.exe may be missing, corrupt, or inaccessible.')", + ' [Console]::Error.WriteLine(\'Reinstall the package with "npm install @vybestack/llxprt-code"\')', + " [Console]::Error.WriteLine('or install Bun directly from https://bun.sh')", + ' exit ' + LAUNCHER_ERROR_EXIT_CODE, + '}', + 'exit $LASTEXITCODE', + '', + ].join('\r\n'); +} + +function writeOwnedLauncher( + filePath, + content, + binLinkDir, + packageRoot, + shimType, + log, +) { + if (!canOverwriteLauncher(filePath, binLinkDir, packageRoot, shimType)) { + return false; + } + // Re-validate the ownership sentinel immediately before the write to close + // the TOCTOU window between canOverwriteLauncher and writeFileSync. If a + // concurrent process replaced the file with a foreign shim after the initial + // check, the sentinel re-check catches it and refuses the overwrite. + if (fs.existsSync(filePath) && isRegularFile(filePath)) { + let currentContent; + try { + currentContent = fs.readFileSync(filePath, 'utf8'); + } catch { + // Unreadable file (EACCES/EIO): refuse to overwrite. + return false; + } + if ( + currentContent.length > 0 && + !currentContent.includes(OWNERSHIP_SENTINEL) + ) { + // The file changed between the first check and now and is not our + // sentinel or empty. It may now be a foreign shim; refuse to clobber. + if ( + !shimTargetWithinPackage( + currentContent, + binLinkDir, + packageRoot, + shimType, + ) + ) { + return false; + } + } + } + // Wrap the write so a read-only directory, full disk, or other I/O error is + // reported as skipped/nonfatal rather than crashing postinstall and leaving + // the package in a partially installed state. + try { + fs.writeFileSync(filePath, content, 'utf8'); + } catch (e) { + if (log) { + log( + `[postinstall] Could not write launcher ${filePath} (non-fatal): ` + + `${e.message}`, + ); + } + return false; + } + try { + fs.chmodSync(filePath, 0o755); + } catch (e) { + // chmod is best-effort, especially on Windows where the launcher's + // executability is governed by the file extension, not the mode bit. + if (log) { + log( + `[postinstall] Could not chmod launcher ${filePath} ` + + `(best-effort on Windows): ${e.message}`, + ); + } + } + return true; +} + +function resolveBunExe(packageRoot) { + // Prefer the package-local Bun first. Since bun is a direct dependency in + // packages/cli/package.json, the local node_modules/bun is the authoritative + // install and should not be bypassed in favor of a hoisted copy that could + // disappear when the hoisted dependency is removed. + const localBunExe = path.join( + packageRoot, + 'node_modules', + 'bun', + 'bin', + 'bun.exe', + ); + if (isRegularFile(localBunExe)) { + return localBunExe; + } + // Fall back to Node module resolution, which may find a hoisted Bun under a + // package manager that deduplicates to the enclosing node_modules. + const pkgRequire = createRequire(path.join(packageRoot, 'package.json')); + let bunPkgJsonPath; + try { + bunPkgJsonPath = pkgRequire.resolve('bun/package.json'); + } catch { + // Last resort: walk up parent directories. This covers edge-case layouts + // where neither the local install nor Node resolution find the binary. + let dir = packageRoot; + while (dir !== path.dirname(dir)) { + const candidate = path.join(dir, 'node_modules', 'bun', 'bin', 'bun.exe'); + if (isRegularFile(candidate)) { + return candidate; + } + dir = path.dirname(dir); + } + return null; + } + const bunDir = path.dirname(bunPkgJsonPath); + const bunExe = path.join(bunDir, 'bin', 'bun.exe'); + if (isRegularFile(bunExe)) { + return bunExe; + } + return null; +} + +function resolveEntry(packageRoot) { + const entry = path.join(packageRoot, 'index.ts'); + if (isRegularFile(entry)) { + return entry; + } + return null; +} + +// Walk up from packageRoot to find the nearest enclosing "node_modules" +// directory and return its ".bin" sibling. For .../node_modules/pkg, the bin +// dir is .../node_modules/.bin. For .../node_modules/@scope/pkg, the bin dir +// is still .../node_modules/.bin (the scope is one level under node_modules). +// Returns null if no enclosing node_modules is found. +function nearestNodeModulesBin(packageRoot) { + let dir = packageRoot; + let parent = path.dirname(dir); + while (dir !== parent) { + if (path.basename(parent) === 'node_modules') { + const candidate = path.join(parent, '.bin'); + if (fs.existsSync(candidate)) { + return candidate; + } + // Found the node_modules dir but .bin does not exist yet; return the + // canonical path anyway so the caller can decide (npm creates .bin + // before running lifecycle scripts, but pnpm/Yarn may differ). + return candidate; + } + dir = parent; + parent = path.dirname(dir); + } + return null; +} + +function findBinLinkDirs(packageRoot, env) { + const dirs = []; + const seen = new Set(); + function add(candidate) { + if (candidate && fs.existsSync(candidate) && !seen.has(candidate)) { + seen.add(candidate); + dirs.push(candidate); + } + } + + // npm_config_global can be 'true', 'false', or undefined. Only 'true' + // indicates a global install where shims go in the prefix root. + const isGlobal = env.npm_config_global === 'true'; + + if (isGlobal) { + // Yarn and pnpm support overriding the global bin directory via + // npm_config_bin_root (Yarn) / --global-bin-dir (pnpm). npm itself does + // NOT define bin_root — for npm the global bin IS the prefix root. We + // honor the env var anyway so non-npm package managers that set it are + // handled, then fall back to npm_config_prefix for standard npm installs. + const binRoot = env.npm_config_bin_root; + if (binRoot) { + add(binRoot); + } + const prefix = env.npm_config_prefix; + if (prefix) { + add(prefix); + } + return dirs; + } + + // INIT_CWD is the consumer project root where `npm install` was invoked. + // For npx cache installs, INIT_CWD may be unrelated to packageRoot, so we + // also derive the bin dir from packageRoot's nearest node_modules ancestor. + const initCwd = env.INIT_CWD; + if (initCwd) { + add(path.join(initCwd, 'node_modules', '.bin')); + } + + // Derive from packageRoot: walk up to the nearest node_modules and use .bin. + add(nearestNodeModulesBin(packageRoot)); + + return dirs; +} + +function installNativeLaunchers(options) { + const platform = options?.platform ?? process.platform; + const log = options?.log ?? (() => {}); + const env = options?.env ?? process.env; + + if (platform !== 'win32') { + return { written: [], skipped: [], error: null }; + } + + const packageRoot = options?.packageRoot ?? path.join(__dirname, '..'); + const bunExeAbs = resolveBunExe(packageRoot); + const entryAbs = resolveEntry(packageRoot); + + if (!bunExeAbs) { + log( + `[postinstall] Could not resolve bundled Bun for ${PACKAGE_NAME}; ` + + 'skipping native launcher generation.', + ); + return { written: [], skipped: [], error: 'bun-not-found' }; + } + if (!entryAbs) { + log( + `[postinstall] Could not resolve entry point for ${PACKAGE_NAME}; ` + + 'skipping native launcher generation.', + ); + return { written: [], skipped: [], error: 'entry-not-found' }; + } + + const binLinkDirs = findBinLinkDirs(packageRoot, env); + const written = []; + const skipped = []; + + for (const dir of binLinkDirs) { + const cmdPath = path.join(dir, `${BIN_NAME}.cmd`); + const ps1Path = path.join(dir, `${BIN_NAME}.ps1`); + + const bunRel = relativePath(dir, bunExeAbs); + const entryRel = relativePath(dir, entryAbs); + const bunRelPosix = relativePathPosix(dir, bunExeAbs); + const entryRelPosix = relativePathPosix(dir, entryAbs); + + const cmdContent = generateCmdLauncher(bunRel, entryRel); + const ps1Content = generatePs1Launcher(bunRelPosix, entryRelPosix); + + if (writeOwnedLauncher(cmdPath, cmdContent, dir, packageRoot, 'cmd', log)) { + written.push(cmdPath); + } else { + log( + `[postinstall] Skipped foreign/non-owned launcher at ${cmdPath} ` + + '(ownership guard refused overwrite).', + ); + skipped.push(cmdPath); + } + + if (writeOwnedLauncher(ps1Path, ps1Content, dir, packageRoot, 'ps1', log)) { + written.push(ps1Path); + } else { + log( + `[postinstall] Skipped foreign/non-owned launcher at ${ps1Path} ` + + '(ownership guard refused overwrite).', + ); + skipped.push(ps1Path); + } + } + + if (written.length > 0) { + log( + `[postinstall] Wrote ${written.length} native launcher file` + + (written.length === 1 ? '' : 's') + + ` for ${PACKAGE_NAME}.`, + ); + } + + return { written, skipped, error: null }; +} + +// This module is a postinstall entry point invoked by scripts/postinstall.cjs. +// It has no package export surface — it is never imported by the CLI runtime. +// The public API for the postinstall caller is installNativeLaunchers. +// +// Implementation-detail helpers (hasOwnershipSentinel, pointsToOurPackage, +// extractXxxShimTargets, etc.) are grouped under a private `_testing` namespace +// to keep the public surface narrow while remaining accessible to the +// behavioral unit tests in scripts/tests/. +module.exports = { + // Public API (postinstall entry point). + installNativeLaunchers, + OWNERSHIP_SENTINEL, + LAUNCHER_ERROR_EXIT_CODE, + // Private test-only internals. These are NOT part of any package's public + // API and may change without notice; they are exposed solely so the + // behavioral tests can assert contracts directly. + _testing: { + generateCmdLauncher, + generatePs1Launcher, + hasOwnershipSentinel, + pointsToOurPackage, + canOverwriteLauncher, + shimTargetWithinPackage, + findBinLinkDirs, + nearestNodeModulesBin, + resolveBunExe, + resolveEntry, + isWithinPackageRoot, + isWithinPackageRootWin, + extractCmdShimTargets, + extractPs1ShimTargets, + resolveShimCandidate, + relativePath, + relativePathPosix, + writeOwnedLauncher, + }, +}; + +if (require.main === module) { + installNativeLaunchers(); +} diff --git a/packages/cli/src/launcher/cli-bin.cts b/packages/cli/src/launcher/cli-bin.cts deleted file mode 100644 index e21d6c9a1..000000000 --- a/packages/cli/src/launcher/cli-bin.cts +++ /dev/null @@ -1,577 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; -import { accessSync, constants, readFileSync, statSync } from 'node:fs'; -import { basename, dirname, join, win32 } from 'node:path'; -import { - classifyWindowsPathCandidates, - isWindowsBunWrapper, - orderWindowsBunCandidates, - type WindowsBunCandidate, -} from './bun-candidate-policy.js'; - -function runtimeModuleFilename(currentModule: NodeJS.Module): string { - return currentModule.filename; -} - -const launcherDir = dirname(runtimeModuleFilename(module)); -const BUN_RELAUNCH_ENV = 'LLXPRT_BUN_RELAUNCHED'; -const FORWARDED_SIGNALS: readonly NodeJS.Signals[] = [ - 'SIGINT', - 'SIGTERM', - 'SIGHUP', - 'SIGBREAK', -]; -const SIGHUP_SELF_EXIT_DELAY_MS = 5_000; -const ORPHAN_CHECK_INTERVAL_MS = 10_000; -const SIGHUP_EXIT_CODE = 129; -const SIGNAL_EXIT_CODES: Readonly>> = { - SIGHUP: SIGHUP_EXIT_CODE, - SIGINT: 130, - SIGQUIT: 131, - SIGILL: 132, - SIGTRAP: 133, - SIGABRT: 134, - SIGBUS: 135, - SIGFPE: 136, - SIGKILL: 137, - SIGUSR1: 138, - SIGSEGV: 139, - SIGUSR2: 140, - SIGPIPE: 141, - SIGALRM: 142, - SIGTERM: 143, - SIGBREAK: 149, -}; - -type ExitFunction = (code: number) => void; -type PathResolver = () => string | null; - -interface RunCliBinOptions { - readonly exit?: ExitFunction; - readonly spawn?: typeof spawn; - readonly resolveBun?: PathResolver; - readonly resolveEntry?: PathResolver; - readonly getPpid?: () => number; - readonly selfExitDelayMs?: number; - readonly orphanCheckIntervalMs?: number; -} - -interface ChildHandlerOptions { - readonly getPpid?: () => number; - readonly selfExitDelayMs?: number; - readonly orphanCheckIntervalMs?: number; -} - -interface ChildExitInfo { - readonly code: number | null; - readonly signal: NodeJS.Signals | null; -} - -interface SpawnInvocation { - readonly command: string; - readonly args: readonly string[]; - readonly windowsVerbatimArguments?: true; -} - -type SpawnInvocationResult = SpawnInvocation | { readonly error: string }; - -function ancestors(startDir: string): readonly string[] { - const dirs = []; - let dir = startDir; - while (dir !== dirname(dir)) { - dirs.push(dir); - dir = dirname(dir); - } - dirs.push(dir); - return dirs; -} - -function isFile(path: string): boolean { - try { - return statSync(path).isFile(); - } catch { - return false; - } -} - -function isExecutable(path: string): boolean { - try { - accessSync(path, constants.X_OK); - return isSpawnableUnixCandidate(path); - } catch { - return false; - } -} - -function isSpawnableUnixCandidate(path: string): boolean { - if (process.platform === 'win32') { - return true; - } - try { - const firstBytes = readFileSync(path).subarray(0, 4); - const magic = firstBytes.toString('hex'); - return ( - firstBytes.toString('utf8').startsWith('#!') || - magic === '7f454c46' || - magic === 'cffaedfe' || - magic === 'feedfacf' - ); - } catch { - return false; - } -} - -function resolveEntry(): string | null { - // The launcher always lives at /bin/llxprt.cjs, so the - // package's own entry point is a sibling of this file's directory. This - // covers the published standalone package layout, where the install - // directory is named after the package (not "cli"). - const packageRootEntry = join(dirname(launcherDir), 'index.ts'); - if (isFile(packageRootEntry)) { - return packageRootEntry; - } - - for (const dir of ancestors(launcherDir)) { - const packageEntry = join(dir, 'index.ts'); - if (isFile(packageEntry) && basename(dir) === 'cli') { - return packageEntry; - } - - const repositoryEntry = join(dir, 'packages', 'cli', 'index.ts'); - if (isFile(repositoryEntry)) { - return repositoryEntry; - } - } - return null; -} - -function bunNames(): readonly string[] { - return ['bun']; -} - -function directBunNames(): readonly string[] { - // The npm package installs bun.exe, while the bare name supports layouts - // that retain the platform package's original POSIX executable name. - return ['bun.exe', 'bun']; -} - -function resolveBunFromNodeModules(): string | null { - for (const dir of ancestors(launcherDir)) { - for (const name of bunNames()) { - const candidate = join(dir, 'node_modules', '.bin', name); - if (isExecutable(candidate)) { - return candidate; - } - } - for (const name of directBunNames()) { - const candidate = join(dir, 'node_modules', 'bun', 'bin', name); - if (isExecutable(candidate)) { - return candidate; - } - } - } - return null; -} - -function pathLookupTool(): string { - if (process.platform !== 'win32') { - return 'which'; - } - const systemRoot = process.env['SystemRoot']; - return systemRoot !== undefined && win32.isAbsolute(systemRoot) - ? win32.join(systemRoot, 'System32', 'where.exe') - : 'where.exe'; -} - -function pathCandidates(): readonly string[] { - // Execute where.exe/which directly so a shell cannot reinterpret arguments. - const result = spawnSync(pathLookupTool(), ['bun'], { - encoding: 'utf8', - windowsHide: true, - }); - if (result.status !== 0 || typeof result.stdout !== 'string') { - return []; - } - return result.stdout - .split(/\r?\n/) - .map((line) => line.trim().replace(/^(["'])(.+?)\1$/, '$2')) - .filter((candidate) => candidate.length > 0); -} - -function resolveBunFromPath(): string | null { - for (const candidate of pathCandidates()) { - if (isExecutable(candidate)) { - return candidate; - } - } - return null; -} - -function windowsNodeModuleCandidates(): readonly WindowsBunCandidate[] { - return ancestors(launcherDir).flatMap((dir) => [ - { - path: join(dir, 'node_modules', '.bin', 'bun.exe'), - kind: 'bin-native', - }, - { - path: join(dir, 'node_modules', '.bin', 'bun.cmd'), - kind: 'wrapper', - }, - { - path: join(dir, 'node_modules', 'bun', 'bin', 'bun.exe'), - kind: 'direct-native', - }, - { - path: join(dir, 'node_modules', 'bun', 'bin', 'bun.cmd'), - kind: 'wrapper', - }, - ]); -} - -function windowsPathCandidates(): readonly WindowsBunCandidate[] { - return classifyWindowsPathCandidates(pathCandidates()); -} - -function firstUsableCandidate( - candidates: readonly WindowsBunCandidate[], -): string | null { - for (const candidate of candidates) { - if (isExecutable(candidate.path)) { - return candidate.path; - } - } - return null; -} - -function resolveWindowsBun(): string | null { - const localCandidates = orderWindowsBunCandidates( - windowsNodeModuleCandidates(), - ); - const localNative = firstUsableCandidate( - localCandidates.filter((candidate) => !isWindowsBunWrapper(candidate)), - ); - if (localNative !== null) { - return localNative; - } - return firstUsableCandidate( - orderWindowsBunCandidates([ - ...localCandidates.filter(isWindowsBunWrapper), - ...windowsPathCandidates(), - ]), - ); -} - -function resolveBun(): string | null { - return process.platform === 'win32' - ? resolveWindowsBun() - : (resolveBunFromNodeModules() ?? resolveBunFromPath()); -} - -function hasWindowsCmdMetaCharacter(arg: string): boolean { - return /[&|<>^()%!"\r\n]/.test(arg); -} - -function isWindowsCmdShim(path: string): boolean { - return ( - process.platform === 'win32' && basename(path).toLowerCase() === 'bun.cmd' - ); -} - -function describeError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function bunLaunchErrorMessage(bunPath: string, error: unknown): string { - return `Failed to launch Bun at "${bunPath}" (${describeError(error)}). Reinstall dependencies with "npm install" to restore the bundled Bun, or ensure a working Bun is executable and on your PATH (see https://bun.sh).`; -} - -function fatalExit(exit: ExitFunction, message: string): void { - process.stderr.write(`${message}\n`); - exit(43); -} - -function resolveBunOrFail( - exit: ExitFunction, - resolveBunFn: PathResolver | undefined, -): string | null { - const bunPath = resolveBunFn === undefined ? resolveBun() : resolveBunFn(); - if (bunPath === null) { - fatalExit( - exit, - 'Bun runtime was not found. Install it with "npm install" (it is bundled as the "bun" dependency) or install Bun directly from https://bun.sh and ensure it is on your PATH.', - ); - return null; - } - return bunPath; -} - -function resolveEntryOrFail( - exit: ExitFunction, - resolveEntryFn: PathResolver | undefined, -): string | null { - const entry = - resolveEntryFn === undefined ? resolveEntry() : resolveEntryFn(); - if (entry === null) { - fatalExit( - exit, - 'Could not locate the LLxprt Code TypeScript entry point (packages/cli/index.ts). Your installation may be corrupt; reinstall @vybestack/llxprt-code.', - ); - return null; - } - return entry; -} - -function windowsCommandProcessor(): string { - const systemRoot = process.env['SystemRoot']; - return systemRoot !== undefined && win32.isAbsolute(systemRoot) - ? win32.join(systemRoot, 'System32', 'cmd.exe') - : 'cmd.exe'; -} - -function quoteWindowsCommandArgument(arg: string): string { - const escapedTrailingBackslashes = arg.replace(/\\+$/, (backslashes) => - backslashes.repeat(2), - ); - return `"${escapedTrailingBackslashes}"`; -} - -function buildSpawnInvocation( - bunPath: string, - entry: string, -): SpawnInvocationResult { - const args = [entry, ...process.argv.slice(2)]; - if (!isWindowsCmdShim(bunPath)) { - return { command: bunPath, args }; - } - if (hasWindowsCmdMetaCharacter(bunPath)) { - return { - error: - 'Cannot safely launch the bundled bun.cmd shim from a path containing Windows command-shell metacharacters. Install Bun directly so bun.exe is on PATH, or move the installation to a path without shell metacharacters.', - }; - } - if (args.some(hasWindowsCmdMetaCharacter)) { - return { - error: - 'Cannot safely forward arguments containing Windows command-shell metacharacters through the bundled bun.cmd shim. Install Bun directly so bun.exe is on PATH, or remove shell metacharacters from the CLI arguments.', - }; - } - const commandLine = [bunPath, ...args] - .map(quoteWindowsCommandArgument) - .join(' '); - return { - command: windowsCommandProcessor(), - args: ['/d', '/s', '/c', `"${commandLine}"`], - windowsVerbatimArguments: true, - }; -} - -function createChildEnv(): NodeJS.ProcessEnv { - return { ...process.env, [BUN_RELAUNCH_ENV]: 'true' }; -} - -async function runCliBin(options: RunCliBinOptions = {}): Promise { - const exit = options.exit ?? process.exit; - const spawnFn = options.spawn ?? spawn; - - const bunPath = resolveBunOrFail(exit, options.resolveBun); - if (bunPath === null) { - return; - } - - const entry = resolveEntryOrFail(exit, options.resolveEntry); - if (entry === null) { - return; - } - - const built = buildSpawnInvocation(bunPath, entry); - if ('error' in built) { - fatalExit(exit, built.error); - return; - } - - let child; - try { - child = spawnFn(built.command, built.args, { - stdio: 'inherit', - env: createChildEnv(), - windowsVerbatimArguments: built.windowsVerbatimArguments, - }); - } catch (error) { - fatalExit(exit, bunLaunchErrorMessage(bunPath, error)); - return; - } - - attachChildHandlers(child, bunPath, exit, { - getPpid: options.getPpid, - selfExitDelayMs: options.selfExitDelayMs, - orphanCheckIntervalMs: options.orphanCheckIntervalMs, - }); -} - -function attachChildHandlers( - child: ChildProcess, - bunPath: string, - exit: ExitFunction, - options: ChildHandlerOptions = {}, -): void { - let settled = false; - let childExitInfo: ChildExitInfo | null = null; - let hangupExitTimer: NodeJS.Timeout | null = null; - let orphanCheckTimer: NodeJS.Timeout | null = null; - - const getPpid = options.getPpid ?? (() => process.ppid); - const selfExitDelayMs = options.selfExitDelayMs ?? SIGHUP_SELF_EXIT_DELAY_MS; - const orphanCheckIntervalMs = - options.orphanCheckIntervalMs ?? ORPHAN_CHECK_INTERVAL_MS; - - const cleanupListeners = (): void => { - child.off('close', onClose); - child.off('error', onError); - child.off('exit', onChildExit); - for (const signal of FORWARDED_SIGNALS) { - process.off(signal, forwardSignal); - } - process.off('beforeExit', onBeforeExit); - if (hangupExitTimer !== null) { - clearTimeout(hangupExitTimer); - hangupExitTimer = null; - } - if (orphanCheckTimer !== null) { - clearInterval(orphanCheckTimer); - orphanCheckTimer = null; - } - }; - - const prepareSettle = (): boolean => { - if (settled) { - return false; - } - settled = true; - cleanupListeners(); - child.on('error', () => {}); - return true; - }; - - const settle = (exitCode: number): void => { - if (!prepareSettle()) { - return; - } - exit(exitCode); - }; - - const exitCodeFromChild = ( - code: number | null, - signal: NodeJS.Signals | null, - ): number => { - if (code !== null) { - return code; - } - if (signal !== null) { - return SIGNAL_EXIT_CODES[signal] ?? 1; - } - return 1; - }; - - const forwardSignal = (signal: NodeJS.Signals): void => { - if (settled) { - return; - } - try { - child.kill(signal); - } catch { - if (signal !== 'SIGHUP') { - return; - } - } - // SIGHUP indicates the controlling terminal is gone. After forwarding, - // schedule a fallback self-exit so the shim cannot become an immortal - // husk if the child's close event never fires (e.g. child already - // reaped externally, or event loop stalled). - if (signal === 'SIGHUP' && hangupExitTimer === null) { - hangupExitTimer = setTimeout(() => { - settle(SIGHUP_EXIT_CODE); - }, selfExitDelayMs); - hangupExitTimer.unref(); - } - }; - - const onClose = ( - code: number | null, - signal: NodeJS.Signals | null, - ): void => { - settle(exitCodeFromChild(code, signal)); - }; - - const onError = (error: Error): void => { - if (!prepareSettle()) { - return; - } - try { - child.kill('SIGTERM'); - } catch (killError) { - process.stderr.write( - `Failed to stop Bun after its spawn error (${describeError(killError)}).\n`, - ); - } - process.stderr.write(`${bunLaunchErrorMessage(bunPath, error)} -`); - exit(43); - }; - - const onChildExit = ( - code: number | null, - signal: NodeJS.Signals | null, - ): void => { - childExitInfo = { code, signal }; - }; - - const onBeforeExit = (): void => { - if (settled || childExitInfo === null) { - return; - } - // Last-resort guard: if the event loop is draining and the child has - // already exited (but 'close' never fired, e.g. inherited stdio with - // a dead terminal), exit now rather than hanging forever. - settle(exitCodeFromChild(childExitInfo.code, childExitInfo.signal)); - }; - - const checkOrphaned = (): void => { - if (settled || childExitInfo === null) { - return; - } - // If the shim has been reparented to init (ppid === 1) and the child - // has already exited, the terminal is gone and no signal will arrive. - // Force exit to avoid becoming an immortal husk. - let orphaned: boolean; - try { - orphaned = getPpid() === 1; - } catch { - return; - } - if (orphaned) { - settle(exitCodeFromChild(childExitInfo.code, childExitInfo.signal)); - } - }; - - for (const signal of FORWARDED_SIGNALS) { - process.on(signal, forwardSignal); - } - child.on('error', onError); - child.on('close', onClose); - child.on('exit', onChildExit); - process.on('beforeExit', onBeforeExit); - orphanCheckTimer = setInterval(checkOrphaned, orphanCheckIntervalMs); - orphanCheckTimer.unref(); -} - -module.exports = { runCliBin }; - -if (Object.is(module, require.main)) { - runCliBin().catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`${message}\n`); - process.exit(1); - }); -} diff --git a/packages/cli/src/launcher/cli-bin.e2e.test.ts b/packages/cli/src/launcher/cli-bin.e2e.test.ts deleted file mode 100644 index 6a8d10e5e..000000000 --- a/packages/cli/src/launcher/cli-bin.e2e.test.ts +++ /dev/null @@ -1,692 +0,0 @@ -/** - * @license - * Copyright 2025 Vybestack LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, expect, it } from 'vitest'; -import { - mkdir, - mkdtemp, - writeFile, - readFile, - rm, - access, -} from 'node:fs/promises'; -import { accessSync, constants, existsSync } from 'node:fs'; -import { join, resolve } from 'node:path'; -import { tmpdir } from 'node:os'; -import { spawn, spawnSync } from 'node:child_process'; -import { z } from 'zod'; - -const childReportSchema = z.object({ - execPath: z.string(), - argv: z.array(z.string()), -}); -type ChildReport = z.infer; - -const SUBPROCESS_TIMEOUT_MS = 15_000; -const UNSAFE_CMD_ARGUMENT_EXIT_CODE = 43; - -async function pathExists(targetPath: string): Promise { - try { - await access(targetPath); - return true; - } catch { - return false; - } -} - -function resolveExecutableFromPath(command: string): string | null { - const lookup = process.platform === 'win32' ? 'where' : 'which'; - const result = spawnSync(lookup, [command], { - encoding: 'utf8', - timeout: SUBPROCESS_TIMEOUT_MS, - }); - if (result.status !== 0) { - return null; - } - return ( - result.stdout.split(/\r?\n/).find((line) => line.trim() !== '') ?? null - ); -} - -async function resolveTestBunPath(workspaceBunPath: string): Promise { - const envBunPath = process.env['BUN_PATH']; - if (envBunPath !== undefined) { - return envBunPath; - } - if (await pathExists(workspaceBunPath)) { - return workspaceBunPath; - } - return resolveExecutableFromPath('bun') ?? workspaceBunPath; -} - -const cliPackageRoot = resolve(__dirname, '..', '..'); -const repositoryRoot = resolve(cliPackageRoot, '..', '..'); -const localBunCmd = resolve(repositoryRoot, 'node_modules', '.bin', 'bun.cmd'); -const localBunExe = resolve(repositoryRoot, 'node_modules', '.bin', 'bun.exe'); -const directBunExe = resolve( - repositoryRoot, - 'node_modules', - 'bun', - 'bin', - 'bun.exe', -); -const credentialSocketEnv = 'LLXPRT_CREDENTIAL_SOCKET'; - -interface SubprocessResult { - readonly code: number | null; - readonly signal: NodeJS.Signals | null; - readonly stdout: string; - readonly stderr: string; -} - -interface DefaultResolverResult extends SubprocessResult { - readonly selectedExecutable: string | null; -} - -// Grace window to wait for a killed subprocess to emit 'close' before forcing -// the timeout rejection (in case SIGKILL never produces a close event). -const POST_KILL_GRACE_MS = 2_000; - -function runSubprocess( - command: string, - args: readonly string[], - env: NodeJS.ProcessEnv, - timeoutMs = SUBPROCESS_TIMEOUT_MS, -): Promise { - return new Promise((resolveSubprocess, reject) => { - // Spawn in its own process group (detached) so the timeout handler can kill - // the whole tree — the Node wrapper AND the Bun grandchild it spawns — - // since SIGKILL is not propagated to descendants by default. - const child = spawn(command, [...args], { - cwd: cliPackageRoot, - env, - stdio: ['ignore', 'pipe', 'pipe'], - detached: process.platform !== 'win32', - }); - let settled = false; - let stdout = ''; - let stderr = ''; - child.stdout.setEncoding('utf8'); - child.stderr.setEncoding('utf8'); - child.stdout.on('data', (chunk: string) => { - stdout += chunk; - }); - child.stderr.on('data', (chunk: string) => { - stderr += chunk; - }); - // A stdio stream with no 'error' listener throws an uncaught exception when - // it errors (e.g. EPIPE/ECONNRESET during a kill), which would crash the - // whole test process. Swallow stream errors here and rely on the child's - // 'error'/'close' events for the promise's resolution. - child.stdout.on('error', () => {}); - child.stderr.on('error', () => {}); - const killTree = () => { - if (typeof child.pid === 'number') { - if (process.platform === 'win32') { - // SIGKILL only reaps the Node wrapper, not the Bun grandchild, so use - // taskkill /T to terminate the whole process tree on Windows. - try { - spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { - stdio: 'ignore', - }); - return; - } catch { - // Fall through to killing just the direct child. - } - } else { - try { - process.kill(-child.pid, 'SIGKILL'); - return; - } catch { - // Fall through to killing just the direct child. - } - } - } - child.kill('SIGKILL'); - }; - // Guard against a hung child (e.g. one that never exits): kill the whole - // process tree, then reject after a fixed grace window. The grace timer is - // the single rejection source — relying on a newly-attached 'close' - // listener would be fragile because the pre-registered close handler - // consumes the event first, and this also covers the case where SIGKILL - // never produces a close event (defunct/zombie child). - const timer = setTimeout(() => { - if (settled) { - return; - } - settled = true; - killTree(); - setTimeout(() => { - reject( - new Error( - [ - `Subprocess timed out after ${timeoutMs}ms`, - `stdout: ${stdout}`, - `stderr: ${stderr}`, - ].join(String.fromCharCode(10)), - ), - ); - }, POST_KILL_GRACE_MS).unref(); - }, timeoutMs); - timer.unref(); - child.once('error', (error) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timer); - reject(error); - }); - child.once('close', (code, signal) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timer); - resolveSubprocess({ code, signal, stdout, stderr }); - }); - }); -} - -/** - * Shared harness for the e2e credential-routing tests: it spins up a real - * Node→Bun launcher child (no mocks on the credential factory) and returns the - * routing outcome the child observed. - * - * @param socketEnvValue - The value to set on the credential-socket env var - * before spawning the child. `undefined` removes the var entirely; an empty - * string leaves it set but falsy; any other string is passed through verbatim. - * @returns The parsed `{ socket, storageType }` the child wrote to stdout. - */ -async function runLauncherChild( - socketEnvValue: string | undefined, -): Promise<{ socket: string | null; storageType: string }> { - const binPath = resolve(cliPackageRoot, 'bin', 'llxprt.cjs'); - // This e2e test drives the real CJS launcher, so it needs a concrete Bun - // binary path. Prefer the bundled monorepo Bun when present, but allow - // developer installs that expose Bun on PATH without creating a local - // node_modules/.bin/bun shim. - const workspaceBunPath = resolve( - cliPackageRoot, - '..', - '..', - 'node_modules', - '.bin', - process.platform === 'win32' ? 'bun.cmd' : 'bun', - ); - const bunPath = await resolveTestBunPath(workspaceBunPath); - // Fail fast with an actionable message if the monorepo layout has - // shifted, rather than surfacing an opaque ENOENT from inside the spawned - // subprocess. Do this BEFORE creating the temp dir so a stale path can - // never leave an orphaned directory behind. - try { - accessSync(bunPath, constants.X_OK); - } catch (error) { - throw new Error( - `Bun binary not found at ${bunPath}. The monorepo layout may have ` + - `changed; update the bunPath resolution in this test.`, - { cause: error }, - ); - } - - // Create the temp dir inside the cli package so Bun's node_modules - // resolution walks up into the monorepo workspace to find provider - // packages; a dir under the OS tmpdir cannot reach them. The `temp-` - // prefix is covered by the root .gitignore `temp-*` rule, so an orphaned - // directory (e.g. if the process is SIGKILLed before the finally block - // runs) never pollutes the working tree. - const tempDir = await mkdtemp(join(cliPackageRoot, 'temp-e2e-launcher-')); - const childEntry = join(tempDir, 'child.ts'); - const launcherWrapper = join(tempDir, 'launcher-wrapper.cjs'); - - try { - await writeFile( - childEntry, - [ - "import { createProviderKeyStorage } from '@vybestack/llxprt-code-providers/auth.js';", - 'const storage = createProviderKeyStorage();', - '// In non-sandbox mode the stub starts no proxy, so the child reads', - '// the keychain directly (ProviderKeyStorage). Only assert the store', - '// type + socket — never call getKey, which could hit the real keychain', - '// and prompt.', - 'process.stdout.write(', - ' JSON.stringify({', - ' socket: process.env.LLXPRT_CREDENTIAL_SOCKET ?? null,', - ' storageType: storage.constructor.name,', - ' }) + "\\n",', - ' () => process.exit(0),', - ');', - ].join('\n'), - ); - // The wrapper source is fully static: the launcher bin path, the Bun - // binary path, and the child entry are passed in through the - // environment and read at runtime with process.env, rather than - // interpolated into the generated source. This keeps test-controlled - // filesystem paths out of the code-construction sink entirely (no - // JSON.stringify-into-source, which is not a safe JS escaper for values - // like U+2028/U+2029). - await writeFile( - launcherWrapper, - [ - `'use strict';`, - `const binPath = process.env.LLXPRT_E2E_BIN_PATH;`, - `const bunPath = process.env.LLXPRT_E2E_BUN_PATH;`, - `const childEntry = process.env.LLXPRT_E2E_CHILD_ENTRY;`, - `const { runCliBin } = require(binPath);`, - `runCliBin({`, - ` resolveBun: () => bunPath,`, - ` resolveEntry: () => childEntry,`, - ` exit: (code) => process.exit(code ?? 0),`, - `}).catch((error) => {`, - ` process.stderr.write(String(error instanceof Error ? error.stack : error));`, - ` process.exit(1);`, - `});`, - ].join('\n'), - ); - - const childEnv = { ...process.env }; - // Hand the launcher wrapper its filesystem paths through the - // environment so the generated source above stays static. - childEnv['LLXPRT_E2E_BIN_PATH'] = binPath; - childEnv['LLXPRT_E2E_BUN_PATH'] = bunPath; - childEnv['LLXPRT_E2E_CHILD_ENTRY'] = childEntry; - // Ensure a clean relaunch state so the launcher exercises its full - // routing path regardless of env leakage from other tests in the - // same worker. - delete childEnv['LLXPRT_BUN_RELAUNCHED']; - if (socketEnvValue === undefined) { - delete childEnv[credentialSocketEnv]; - } else { - childEnv[credentialSocketEnv] = socketEnvValue; - } - const result = await runSubprocess( - process.execPath, - [launcherWrapper, '--key', 'sk-test'], - childEnv, - ); - - expect(result.code).toBe(0); - expect(result.signal).toBeNull(); - // Parse stdout before the stderr assertion so a garbled/non-JSON - // payload surfaces a clear diagnostic (including stderr) rather than - // being masked by that assertion below. - let parsed: unknown; - try { - parsed = JSON.parse(result.stdout.trim()); - } catch (error) { - throw new Error( - `Failed to parse child stdout as JSON: ${String(error)}\n` + - `stdout: ${result.stdout}\nstderr: ${result.stderr}`, - { cause: error }, - ); - } - // The happy path must not surface a real uncaught exception / error - // header from the runtime. Anchor to the start of a line and require the - // specific runtime error formats (with trailing content) so benign - // warnings (e.g. ExperimentalWarning), deprecation notices, or stack - // frames that merely mention these words do not cause spurious failures. - // Exit code is the primary success signal (asserted above); this is a - // secondary guard against silent runtime errors. - expect(result.stderr).not.toMatch(/^(Uncaught .+|Error: .+)/m); - return parsed as { socket: string | null; storageType: string }; - } finally { - await cleanupTempDirectory(tempDir); - } -} - -function describeError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -interface CleanupOperations { - readonly remove: (directory: string) => Promise; - readonly reportFailure: (message: string) => void; -} - -const defaultCleanupOperations: CleanupOperations = { - remove: async (directory) => rm(directory, { force: true, recursive: true }), - reportFailure: (message) => process.stderr.write(message), -}; - -async function cleanupTempDirectory( - directory: string, - operations: CleanupOperations = defaultCleanupOperations, -): Promise { - try { - await operations.remove(directory); - } catch (error) { - try { - operations.reportFailure( - `Failed to remove test directory ${directory} (${describeError(error)}).\n`, - ); - } catch (reportingError) { - void reportingError; - } - } -} - -interface BunCmdFixture { - readonly bunPath: string; - readonly tempDir: string; -} - -async function createBunCmdFixture( - shimDirectoryName = 'bun shim', -): Promise { - const tempDir = await mkdtemp(join(tmpdir(), 'llxprt e2e cmd ')); - const shimDirectory = join(tempDir, shimDirectoryName); - const bunPath = join(shimDirectory, 'bun.cmd'); - await mkdir(shimDirectory, { recursive: true }); - await writeFile(bunPath, `@ECHO off\r\n@"${directBunExe}" %*\r\n`); - return { bunPath, tempDir }; -} - -async function runDefaultResolverLauncher( - args: readonly string[], - bunPath?: string, -): Promise { - const binPath = resolve(cliPackageRoot, 'bin', 'llxprt.cjs'); - const tempDir = await mkdtemp(join(tmpdir(), 'llxprt-e2e-argv-')); - const childEntry = join(tempDir, 'child.ts'); - const launcherWrapper = join(tempDir, 'launcher-wrapper.cjs'); - const selectedExecutableReport = join(tempDir, 'selected-executable.txt'); - - try { - await writeFile( - childEntry, - [ - 'process.stdout.write(', - ' JSON.stringify({ execPath: process.execPath, argv: process.argv.slice(2) }) + "\\n",', - ' () => process.exit(0),', - ');', - ].join('\n'), - ); - await writeFile( - launcherWrapper, - [ - `'use strict';`, - `const { spawn } = require('node:child_process');`, - `const { writeFileSync } = require('node:fs');`, - `const { runCliBin } = require(process.env.LLXPRT_E2E_BIN_PATH);`, - `const options = {`, - ` resolveEntry: () => process.env.LLXPRT_E2E_CHILD_ENTRY,`, - ` spawn: (command, args, spawnOptions) => {`, - ` writeFileSync(process.env.LLXPRT_E2E_SELECTED_EXECUTABLE, command);`, - ` return spawn(command, args, spawnOptions);`, - ` },`, - ` exit: (code) => process.exit(code ?? 0),`, - `};`, - `if (process.env.LLXPRT_E2E_BUN_PATH) {`, - ` options.resolveBun = () => process.env.LLXPRT_E2E_BUN_PATH;`, - `}`, - `runCliBin(options).catch((error) => {`, - ` process.stderr.write(String(error instanceof Error ? error.stack : error));`, - ` process.exit(1);`, - `});`, - ].join('\n'), - ); - - const env = { - ...process.env, - LLXPRT_E2E_BIN_PATH: binPath, - LLXPRT_E2E_CHILD_ENTRY: childEntry, - LLXPRT_E2E_SELECTED_EXECUTABLE: selectedExecutableReport, - }; - delete env['LLXPRT_BUN_RELAUNCHED']; - if (bunPath !== undefined) { - env['LLXPRT_E2E_BUN_PATH'] = bunPath; - } else { - delete env['LLXPRT_E2E_BUN_PATH']; - } - - const result = await runSubprocess( - process.execPath, - [launcherWrapper, ...args], - env, - ); - const selectedExecutable = (await pathExists(selectedExecutableReport)) - ? await readFile(selectedExecutableReport, 'utf8') - : null; - return { ...result, selectedExecutable }; - } finally { - await cleanupTempDirectory(tempDir); - } -} - -function parseChildReport(result: SubprocessResult): ChildReport { - let parsed: unknown; - try { - parsed = JSON.parse(result.stdout.trim()); - } catch (error) { - throw new Error( - `Failed to parse child stdout as JSON: ${String(error)}\n` + - `stdout: ${result.stdout}\nstderr: ${result.stderr}`, - { cause: error }, - ); - } - try { - return childReportSchema.parse(parsed); - } catch (error) { - throw new Error( - `Failed to validate child report: ${String(error)}\n` + - `stdout: ${result.stdout}\nstderr: ${result.stderr}`, - { cause: error }, - ); - } -} - -describe('cleanupTempDirectory', () => { - it('reports removal failures without rejecting', async () => { - const removalError = new Error('directory busy'); - const reported: string[] = []; - - await expect( - cleanupTempDirectory('temporary-directory', { - remove: async () => { - throw removalError; - }, - reportFailure: (message) => reported.push(message), - }), - ).resolves.toBeUndefined(); - expect(reported).toStrictEqual([ - 'Failed to remove test directory temporary-directory (directory busy).\n', - ]); - }); -}); - -describe('parseChildReport', () => { - it('includes child output when stdout is not valid JSON', () => { - const result: SubprocessResult = { - code: 1, - signal: null, - stdout: 'not-json', - stderr: 'child failed', - }; - - expect(() => parseChildReport(result)).toThrowError( - /Failed to parse child stdout as JSON[\s\S]*stdout: not-json[\s\S]*stderr: child failed/, - ); - }); - - it('includes child output when valid JSON does not match the report schema', () => { - const result: SubprocessResult = { - code: 1, - signal: null, - stdout: '{"execPath":42,"argv":[]}', - stderr: 'invalid child report', - }; - - expect(() => parseChildReport(result)).toThrowError( - /Failed to validate child report[\s\S]*stdout: {"execPath":42,"argv":\[\]}[\s\S]*stderr: invalid child report/, - ); - }); -}); - -describe('cli bin Windows native Bun resolution', () => { - it.runIf(process.platform === 'win32')( - 'selects the direct native executable when .bin contains only a command shim', - async () => { - expect(() => accessSync(localBunCmd, constants.X_OK)).not.toThrow(); - expect(existsSync(localBunExe)).toBe(false); - expect(() => accessSync(directBunExe, constants.X_OK)).not.toThrow(); - - const result = await runDefaultResolverLauncher([]); - - expect(result.code).toBe(0); - const child = parseChildReport(result); - expect(result.selectedExecutable?.toLowerCase()).toBe( - directBunExe.toLowerCase(), - ); - expect(child.execPath.toLowerCase()).toBe(directBunExe.toLowerCase()); - expect(result.stderr).not.toContain('DEP0190'); - }, - ); - - it.runIf(process.platform === 'win32')( - 'preserves a multiword prompt as one child argument', - async () => { - const result = await runDefaultResolverLauncher([ - '--prompt', - 'hello world', - ]); - - expect(result.code).toBe(0); - const child = parseChildReport(result); - expect(child.argv).toStrictEqual(['--prompt', 'hello world']); - }, - ); - - it.runIf(process.platform === 'win32')( - 'preserves a multiword prompt through a bun.cmd-only fallback', - async () => { - const result = await runDefaultResolverLauncher( - ['--prompt', 'hello cmd world'], - localBunCmd, - ); - - expect(result.code).toBe(0); - const child = parseChildReport(result); - expect(child.argv).toStrictEqual(['--prompt', 'hello cmd world']); - }, - ); - - it.runIf(process.platform === 'win32')( - 'preserves a trailing backslash before another argument through a bun.cmd path containing spaces', - async () => { - expect(() => accessSync(directBunExe, constants.X_OK)).not.toThrow(); - const fixture = await createBunCmdFixture(); - - try { - const trailingBackslash = 'C:\\allowed path\\'; - const result = await runDefaultResolverLauncher( - ['--prompt', trailingBackslash, 'following argument'], - fixture.bunPath, - ); - - expect(result.code).toBe(0); - const child = parseChildReport(result); - expect(child.argv).toStrictEqual([ - '--prompt', - trailingBackslash, - 'following argument', - ]); - expect(result.stderr).not.toContain('DEP0190'); - } finally { - await cleanupTempDirectory(fixture.tempDir); - } - }, - ); - - it.runIf(process.platform === 'win32')( - 'rejects a bun.cmd path containing command-shell metacharacters', - async () => { - expect(() => accessSync(directBunExe, constants.X_OK)).not.toThrow(); - const fixture = await createBunCmdFixture('bun & injected'); - - try { - const result = await runDefaultResolverLauncher([], fixture.bunPath); - - expect(result.code).toBe(UNSAFE_CMD_ARGUMENT_EXIT_CODE); - expect(result.stdout).toBe(''); - expect(result.stderr).toContain( - 'Cannot safely launch the bundled bun.cmd shim from a path containing Windows command-shell metacharacters', - ); - } finally { - await cleanupTempDirectory(fixture.tempDir); - } - }, - ); - - it.runIf(process.platform === 'win32')( - 'preserves Windows shell metacharacters without a shell', - async () => { - const result = await runDefaultResolverLauncher([ - '--prompt', - 'hello & whoami', - ]); - - expect(result.code).toBe(0); - const child = parseChildReport(result); - expect(child.argv).toStrictEqual(['--prompt', 'hello & whoami']); - expect(result.stderr).not.toContain( - 'Cannot safely forward arguments containing Windows command-shell metacharacters', - ); - }, - ); - - it.runIf(process.platform === 'win32')( - 'rejects unsafe metacharacters for a bun.cmd-only fallback', - async () => { - const result = await runDefaultResolverLauncher( - ['--prompt', 'hello & whoami'], - localBunCmd, - ); - - expect(result.code).toBe(UNSAFE_CMD_ARGUMENT_EXIT_CODE); - expect(result.stdout).toBe(''); - expect(result.stderr).toContain( - 'Cannot safely forward arguments containing Windows command-shell metacharacters', - ); - expect(result.stderr).toContain('bun.exe is on PATH'); - }, - ); -}); - -describe('cli bin end-to-end credential routing', () => { - it.each([ - // The launcher must treat an empty-string socket env the same as a missing - // one — both should route the child to the DIRECT store (keychain-direct, - // no proxy) per issue #2419. - ['empty string', ''] as const, - ['absent', undefined] as const, - ])( - 'routes no-compile launcher children to the direct credential store (socket env: %s)', - async (_label, socketEnvValue) => { - const routed = await runLauncherChild(socketEnvValue); - // The minimal stub starts NO proxy, so the child must see no usable - // socket and select the DIRECT store (ProviderKeyStorage). An empty - // string socket is treated as missing by the factory (falsy). This is - // the core assertion of issue #2419: keychain-direct in non-sandbox. - expect(routed.socket === null || routed.socket === '').toBe(true); - expect(routed.storageType).toBe('ProviderKeyStorage'); - // It must NOT be the proxy-backed store. - expect(routed.storageType).not.toBe('ProxyProviderKeyStorage'); - }, - 20_000, - ); - - it('passes through a sandbox-provided credential socket so the child selects the proxy store', async () => { - // A dummy socket path string. The factory only checks PRESENCE of the env - // var to pick ProxyProviderKeyStorage; it constructs the client lazily and - // never connects during construction, so a dummy value is safe. - const dummySocket = join(tmpdir(), 'llxprt-dummy-sandbox.sock'); - const routed = await runLauncherChild(dummySocket); - // The stub passes the parent's credential socket through unchanged (it - // spreads process.env), so the child must select the proxy-backed store. - expect(routed.socket).toBe(dummySocket); - expect(routed.storageType).toBe('ProxyProviderKeyStorage'); - }, 20_000); -}); diff --git a/packages/cli/src/launcher/cli-bin.test.ts b/packages/cli/src/launcher/cli-bin.test.ts deleted file mode 100644 index 1c8ea0639..000000000 --- a/packages/cli/src/launcher/cli-bin.test.ts +++ /dev/null @@ -1,353 +0,0 @@ -/** - * @license - * Copyright 2025 Vybestack LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; -import { createRequire } from 'node:module'; -import { EventEmitter } from 'node:events'; -import type { spawn as spawnType } from 'node:child_process'; - -const loadCommonJsModule = createRequire(import.meta.url); -const credentialSocketEnv = 'LLXPRT_CREDENTIAL_SOCKET'; - -interface RunCliBinOptions { - exit?: ExitFn; - spawn?: typeof spawnType; - resolveBun?: () => string | null; - resolveEntry?: () => string | null; - getPpid?: () => number; - selfExitDelayMs?: number; - orphanCheckIntervalMs?: number; -} - -type ExitFn = (code?: number) => never; -type RunCliBin = (options?: RunCliBinOptions) => Promise; - -interface CliBinModule { - runCliBin: RunCliBin; -} - -interface TestChildProcess extends EventEmitter { - stdout: EventEmitter; - stderr: EventEmitter; - stdin: EventEmitter; - kill: ReturnType; - killed: boolean; - exitCode: number | null; - signalCode: NodeJS.Signals | null; -} - -function recordingExit(sink: number[]): ExitFn { - const exit: ExitFn = (code?: number) => { - sink.push(code ?? 0); - return undefined as never; - }; - return exit; -} - -function isCliBinModule(module: unknown): module is CliBinModule { - if (typeof module !== 'object' || module === null) { - return false; - } - const { runCliBin } = module as { runCliBin?: unknown }; - return typeof runCliBin === 'function'; -} - -function loadCliBin(): CliBinModule { - const module = loadCommonJsModule('../../bin/llxprt.cjs'); - if (!isCliBinModule(module)) { - throw new Error('cli bin module did not expose expected test seams'); - } - return module; -} - -function createChildProcess({ - autoClose = true, -}: { autoClose?: boolean } = {}): TestChildProcess { - const child = new EventEmitter() as TestChildProcess; - child.stdout = new EventEmitter(); - child.stderr = new EventEmitter(); - child.stdin = new EventEmitter(); - child.kill = vi.fn((signal?: NodeJS.Signals | 'SIGKILL') => { - child.killed = true; - if (autoClose) { - process.nextTick(() => child.emit('close', null, signal ?? null)); - } - return true; - }); - child.killed = false; - child.exitCode = null; - child.signalCode = null; - return child; -} - -describe('cli bin (packages/cli/bin/llxprt.cjs)', () => { - let originalEnv: NodeJS.ProcessEnv; - let originalArgv: string[]; - let runCliBin: RunCliBin; - - beforeEach(() => { - originalEnv = process.env; - originalArgv = process.argv; - process.env = { ...process.env }; - process.argv = ['/node', '/llxprt.cjs']; - const bin = loadCliBin(); - runCliBin = bin.runCliBin; - }); - - afterEach(() => { - process.env = originalEnv; - process.argv = originalArgv; - vi.restoreAllMocks(); - }); - - async function runLauncher( - overrides: RunCliBinOptions & { autoClose?: boolean } = {}, - ) { - const { autoClose = true, ...cliBinOptions } = overrides; - const exitCalls: number[] = []; - const child = createChildProcess({ autoClose }); - const spawnFn = vi.fn(() => child); - - await runCliBin({ - exit: recordingExit(exitCalls), - spawn: spawnFn as unknown as typeof spawnType, - resolveBun: () => '/path/to/bun', - resolveEntry: () => '/entry.ts', - ...cliBinOptions, - }); - - return { child, exitCalls, spawnFn }; - } - - it('spawns Bun with the entry + forwarded args and relaunch guard env', async () => { - delete process.env[credentialSocketEnv]; - process.argv = ['/node', '/llxprt.cjs', '--profile-load', 'dev']; - const { child, exitCalls, spawnFn } = await runLauncher(); - - expect(spawnFn).toHaveBeenCalledWith( - '/path/to/bun', - ['/entry.ts', '--profile-load', 'dev'], - expect.objectContaining({ - stdio: 'inherit', - env: expect.objectContaining({ - LLXPRT_BUN_RELAUNCHED: 'true', - }), - }), - ); - child.emit('close', 0, null); - await vi.waitFor(() => expect(exitCalls).toStrictEqual([0])); - }); - - it('does not start a proxy or set a credential socket when none is present', async () => { - delete process.env[credentialSocketEnv]; - const { spawnFn } = await runLauncher(); - - const spawnEnv = spawnFn.mock.calls[0][2]?.env as - | NodeJS.ProcessEnv - | undefined; - expect(spawnEnv).toBeDefined(); - expect(spawnEnv![credentialSocketEnv]).toBeUndefined(); - }); - - it('passes through an existing credential socket unchanged (sandbox passthrough)', async () => { - process.env[credentialSocketEnv] = '/tmp/existing-sandbox.sock'; - const { spawnFn } = await runLauncher(); - - const spawnEnv = spawnFn.mock.calls[0][2]?.env as - | NodeJS.ProcessEnv - | undefined; - expect(spawnEnv).toBeDefined(); - expect(spawnEnv![credentialSocketEnv]).toBe('/tmp/existing-sandbox.sock'); - }); - - it('forwards --profile-load style args unchanged', async () => { - process.argv = ['/node', '/llxprt.cjs', '--profile-load', 'ollamakimi']; - const { spawnFn } = await runLauncher(); - - expect(spawnFn.mock.calls[0][1]).toStrictEqual([ - '/entry.ts', - '--profile-load', - 'ollamakimi', - ]); - }); - - it.each([ - ['SIGINT', 130], - ['SIGTERM', 143], - ['SIGHUP', 129], - ['SIGBREAK', 149], - ] satisfies Array<[NodeJS.Signals, number]>)( - 'forwards %s to the Bun child until close', - async (signal, exitCode) => { - delete process.env[credentialSocketEnv]; - const { child, exitCalls } = await runLauncher(); - - process.emit(signal, signal); - child.emit('close', null, signal); - await vi.waitFor(() => expect(exitCalls).toStrictEqual([exitCode])); - - expect(child.kill).toHaveBeenCalledWith(signal); - }, - ); - - it.each([ - [0, 0], - [7, 7], - ] satisfies Array<[number, number]>)( - 'propagates child close code %i as exit %i', - async (closeCode, expectedExit) => { - const { child, exitCalls } = await runLauncher(); - - child.emit('close', closeCode, null); - await vi.waitFor(() => expect(exitCalls).toStrictEqual([expectedExit])); - }, - ); - - it('exits with code 1 when the child closes without code or signal', async () => { - const { child, exitCalls } = await runLauncher(); - - child.emit('close', null, null); - await vi.waitFor(() => expect(exitCalls).toStrictEqual([1])); - }); - - it('exits with 43 and does not spawn when Bun cannot be resolved', async () => { - const { exitCalls, spawnFn } = await runLauncher({ - resolveBun: () => null, - }); - - expect(spawnFn).not.toHaveBeenCalled(); - expect(exitCalls).toStrictEqual([43]); - }); - - it('exits with 43 and does not spawn when the entry cannot be resolved', async () => { - const { exitCalls, spawnFn } = await runLauncher({ - resolveEntry: () => null, - }); - - expect(spawnFn).not.toHaveBeenCalled(); - expect(exitCalls).toStrictEqual([43]); - }); - - it('exits with 43 when spawn throws synchronously', async () => { - const { exitCalls } = await runLauncher({ - spawn: vi.fn(() => { - throw new Error('sync spawn failed'); - }) as unknown as typeof spawnType, - }); - - expect(exitCalls).toStrictEqual([43]); - }); - - it('exits with 43 and best-effort kills the child on an async spawn error', async () => { - const { child, exitCalls } = await runLauncher(); - - child.emit('error', new Error('spawn failed')); - - await vi.waitFor(() => { - expect(child.kill).toHaveBeenCalledWith('SIGTERM'); - expect(exitCalls).toStrictEqual([43]); - }); - }); - - it('exposes runCliBin as a function without executing the launcher on import', () => { - const bin = loadCliBin(); - expect(typeof bin.runCliBin).toBe('function'); - }); - - it('schedules a self-exit on SIGHUP when the child does not close', async () => { - const { child, exitCalls } = await runLauncher({ - autoClose: false, - selfExitDelayMs: 50, - }); - - process.emit('SIGHUP', 'SIGHUP'); - expect(child.kill).toHaveBeenCalledWith('SIGHUP'); - - await vi.waitFor(() => expect(exitCalls).toStrictEqual([129])); - }); - - it('cancels the SIGHUP self-exit when the child closes before the grace period', async () => { - const { child, exitCalls } = await runLauncher({ - autoClose: false, - selfExitDelayMs: 50, - }); - - process.emit('SIGHUP', 'SIGHUP'); - child.emit('close', 0, null); - - await vi.waitFor(() => expect(exitCalls).toStrictEqual([0])); - - await new Promise((resolve) => setTimeout(resolve, 120)); - expect(exitCalls).toStrictEqual([0]); - }); - - it('exits when orphaned (ppid=1) after child exit', async () => { - const { child, exitCalls } = await runLauncher({ - autoClose: false, - getPpid: () => 1, - orphanCheckIntervalMs: 50, - }); - - child.emit('exit', null, 'SIGTERM'); - - await vi.waitFor(() => expect(exitCalls).toStrictEqual([143])); - }); - - it('does not exit from orphan check when the child is still alive', async () => { - const { child, exitCalls } = await runLauncher({ - autoClose: false, - getPpid: () => 1, - orphanCheckIntervalMs: 50, - }); - - await new Promise((resolve) => setTimeout(resolve, 150)); - expect(exitCalls).toStrictEqual([]); - - child.emit('close', 0, null); - await vi.waitFor(() => expect(exitCalls).toStrictEqual([0])); - }); - - it('does not exit from orphan check when not orphaned (ppid!=1)', async () => { - const { child, exitCalls } = await runLauncher({ - autoClose: false, - getPpid: () => 12345, - orphanCheckIntervalMs: 50, - }); - - child.emit('exit', 0, null); - - await new Promise((resolve) => setTimeout(resolve, 150)); - expect(exitCalls).toStrictEqual([]); - - child.emit('close', 0, null); - await vi.waitFor(() => expect(exitCalls).toStrictEqual([0])); - }); - - it('exits via beforeExit guard when the child has exited but close never fires', async () => { - const { child, exitCalls } = await runLauncher({ - autoClose: false, - getPpid: () => 12345, - }); - - child.emit('exit', 0, null); - expect(exitCalls).toStrictEqual([]); - - process.emit('beforeExit', 0); - expect(exitCalls).toStrictEqual([0]); - }); - - it('does not exit via beforeExit when the child is still alive', async () => { - const { child, exitCalls } = await runLauncher({ - autoClose: false, - }); - - process.emit('beforeExit', 0); - expect(exitCalls).toStrictEqual([]); - - child.emit('close', 0, null); - await vi.waitFor(() => expect(exitCalls).toStrictEqual([0])); - }); -}); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index e82a3221f..e0afe6a95 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -38,7 +38,6 @@ }, "include": [ "index.ts", - "src/**/*.cts", "src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", diff --git a/packages/test-utils/src/cli-args.test.ts b/packages/test-utils/src/cli-args.test.ts index 842820275..27fd8b0d9 100644 --- a/packages/test-utils/src/cli-args.test.ts +++ b/packages/test-utils/src/cli-args.test.ts @@ -92,11 +92,34 @@ describe('cli-args helpers', () => { setEnv('LLXPRT_TEST_PROFILE', ' profile-name '); expect( - getCommandAndArgs('/packages/cli/bin/llxprt.cjs', ['--flag']), + getCommandAndArgs('/packages/cli/index.ts', ['--flag']), ).toStrictEqual({ command: 'llxprt', initialArgs: ['--flag'], }); expect(getProfileName()).toBe('profile-name'); }); + + it('uses bun and the TypeScript entry path when installed CLI is not requested', () => { + setEnv('INTEGRATION_TEST_USE_INSTALLED_LLXPRT', 'false'); + + expect( + getCommandAndArgs('/packages/cli/index.ts', ['--flag']), + ).toStrictEqual({ + command: 'bun', + initialArgs: ['/packages/cli/index.ts', '--flag'], + }); + }); + + it('uses bun and the TypeScript entry path when installed CLI env is unset', () => { + // Unset the environment variable entirely (not just set to false). + delete process.env.INTEGRATION_TEST_USE_INSTALLED_LLXPRT; + + expect( + getCommandAndArgs('/packages/cli/index.ts', ['--flag']), + ).toStrictEqual({ + command: 'bun', + initialArgs: ['/packages/cli/index.ts', '--flag'], + }); + }); }); diff --git a/packages/test-utils/src/cli-args.ts b/packages/test-utils/src/cli-args.ts index ed488329d..0fced34ca 100644 --- a/packages/test-utils/src/cli-args.ts +++ b/packages/test-utils/src/cli-args.ts @@ -79,8 +79,8 @@ export function buildExtraArgs( /** * Compute the command and its base args, choosing the installed `llxprt` - * binary for npm release tests and `node ` (the checked-in launcher, - * which relaunches into Bun on TypeScript source) otherwise. + * binary for npm release tests and `bun ` (running the TypeScript + * source directly) otherwise. */ export function getCommandAndArgs( entryPath: string, @@ -88,7 +88,7 @@ export function getCommandAndArgs( ): { command: string; initialArgs: string[] } { const isNpmReleaseTest = env['INTEGRATION_TEST_USE_INSTALLED_LLXPRT'] === 'true'; - const command = isNpmReleaseTest ? 'llxprt' : 'node'; + const command = isNpmReleaseTest ? 'llxprt' : 'bun'; const initialArgs = isNpmReleaseTest ? extraInitialArgs : [entryPath, ...extraInitialArgs]; diff --git a/packages/test-utils/src/test-rig.ts b/packages/test-utils/src/test-rig.ts index 4f495f05c..e06be7a3a 100644 --- a/packages/test-utils/src/test-rig.ts +++ b/packages/test-utils/src/test-rig.ts @@ -66,14 +66,16 @@ export { } from './util.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); -// Entry is the checked-in Node launcher: it re-execs Bun against TypeScript -// source, so integration tests exercise the no-compile runtime path. +// Entry is the TypeScript source run directly under Bun. The checked-in +// launcher (packages/cli/bin/llxprt) is the installed command, but for +// integration tests we spawn Bun directly on the source entry point to +// exercise the same no-compile runtime path without a Node relay. const CLI_ENTRY_PATH = join( __dirname, '..', '..', '..', - 'packages/cli/bin/llxprt.cjs', + 'packages/cli/index.ts', ); interface RunMethodOptions { @@ -158,8 +160,8 @@ export class TestRig { /** * The command and args to use to invoke LLxprt CLI. Allows switching between - * the checked-in Node launcher (which re-execs Bun on the TypeScript source) - * and the installed 'llxprt' binary. + * spawning Bun directly on the TypeScript source (the development path) and + * the installed 'llxprt' binary. */ private _getCommandAndArgs(extraInitialArgs: string[] = []): { command: string; @@ -450,7 +452,14 @@ export class TestRig { env: childEnv, }; - const executable = command === 'node' ? process.execPath : command; + // When command is 'bun', use process.execPath (the exact Bun executable + // when running under Bun) rather than the literal 'bun', which may not be + // on PATH or may resolve to a different version. This mirrors how the + // 'node' case uses the exact Node executable. + const executable = + command === 'bun' && typeof process.versions.bun === 'string' + ? process.execPath + : command; const ptyProcess = pty.spawn(executable, commandArgs, ptyOptions); const run = new InteractiveRun(ptyProcess, this._diagnostics); diff --git a/scripts/build.ts b/scripts/build.ts index 6c7409742..a8d0616be 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -31,8 +31,6 @@ if (!existsSync(join(root, 'node_modules'))) { execSync('npm install', { stdio: 'inherit', cwd: root }); } -execSync('npm run check:cli-launcher', { stdio: 'inherit', cwd: root }); - // build all workspaces/packages execSync('npm run generate', { stdio: 'inherit', cwd: root }); execSync('npm run build --workspaces', { stdio: 'inherit', cwd: root }); diff --git a/scripts/bun-build.config.mjs b/scripts/bun-build.config.mjs index eb29740af..4ef01a346 100644 --- a/scripts/bun-build.config.mjs +++ b/scripts/bun-build.config.mjs @@ -9,8 +9,9 @@ * Replaces the retired `esbuild.config.js`. * * The CLI run path no longer requires a bundle or compiled JavaScript entry — - * the published bin is a checked-in Node launcher that re-execs Bun against the - * TypeScript source. This script produces only the self-contained + * the published bin is a checked-in POSIX sh launcher (`packages/cli/bin/llxprt`) + * that resolves the package-local Bun and execs the TypeScript source directly. + * This script produces only the self-contained * `packages/a2a-server/dist/a2a-server.mjs` artifact. */ diff --git a/scripts/generate-cli-launcher.ts b/scripts/generate-cli-launcher.ts deleted file mode 100644 index 765ed5d17..000000000 --- a/scripts/generate-cli-launcher.ts +++ /dev/null @@ -1,261 +0,0 @@ -/** - * @license - * Copyright 2025 Vybestack LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'; -import { basename, dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { format } from 'prettier'; - -const scriptDir = dirname(fileURLToPath(import.meta.url)); -const repoRoot = resolve(scriptDir, '..'); -const defaultSource = join( - repoRoot, - 'packages', - 'cli', - 'src', - 'launcher', - 'cli-bin.cts', -); -const defaultOutput = join(repoRoot, 'packages', 'cli', 'bin', 'llxprt.cjs'); - -interface GeneratorOptions { - readonly check: boolean; - readonly source: string; - readonly output: string; -} - -export interface WorkingDirectoryOperations { - readonly cwd: () => string; - readonly chdir: (directory: string) => void; - readonly reportRestorationFailure: (error: unknown) => void; -} - -function describeError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -const defaultWorkingDirectoryOperations: WorkingDirectoryOperations = { - cwd: () => process.cwd(), - chdir: (directory) => process.chdir(directory), - reportRestorationFailure: (error) => { - process.stderr.write( - `Additionally failed to restore the working directory (${describeError(error)}).\n`, - ); - }, -}; - -export async function runInWorkingDirectory( - directory: string, - operation: () => Promise, - operations: WorkingDirectoryOperations = defaultWorkingDirectoryOperations, -): Promise { - const originalCwd = operations.cwd(); - operations.chdir(directory); - let outcome: - | { readonly kind: 'success'; readonly value: Result } - | { readonly kind: 'failure'; readonly error: unknown }; - try { - outcome = { kind: 'success', value: await operation() }; - } catch (error) { - outcome = { kind: 'failure', error }; - } - - let restorationError: unknown; - try { - operations.chdir(originalCwd); - } catch (error) { - restorationError = error; - } - - if (outcome.kind === 'failure') { - if (restorationError !== undefined) { - try { - operations.reportRestorationFailure(restorationError); - } catch { - process.stderr.write( - `Additionally failed to restore the working directory (${describeError(restorationError)}).\n`, - ); - } - } - throw outcome.error; - } - if (restorationError !== undefined) { - throw restorationError; - } - return outcome.value; -} - -function optionValue( - args: readonly string[], - name: string, -): string | undefined { - const equalsPrefix = `${name}=`; - const equalsArgument = args.findLast((arg) => arg.startsWith(equalsPrefix)); - if (equalsArgument !== undefined) { - const value = equalsArgument.slice(equalsPrefix.length); - if (value.length === 0) { - throw new Error(`${name} requires a path`); - } - return resolve(repoRoot, value); - } - - const index = args.indexOf(name); - if (index === -1) { - return undefined; - } - const value = args[index + 1]; - if (value === undefined || value.startsWith('--')) { - throw new Error(`${name} requires a path`); - } - return resolve(repoRoot, value); -} - -function parseOptions(args: readonly string[]): GeneratorOptions { - return { - check: args.includes('--check'), - source: optionValue(args, '--source') ?? defaultSource, - output: optionValue(args, '--output') ?? defaultOutput, - }; -} - -type LauncherFormatter = ( - source: string, - options: { readonly parser: 'babel' }, -) => Promise; - -export async function formatLauncher( - bundled: string, - sourcePath: string, - outputPath: string, - formatter: LauncherFormatter = format, -): Promise { - try { - return await formatter(`#!/usr/bin/env node\n${bundled}`, { - parser: 'babel', - }); - } catch (error) { - throw new Error( - `Failed to format CLI launcher (source: ${sourcePath}, output: ${outputPath}): ${describeError(error)}`, - { cause: error }, - ); - } -} - -async function buildLauncher( - sourcePath: string, - outputPath: string, -): Promise { - const sourceDir = dirname(sourcePath); - let result: Awaited>; - try { - result = await runInWorkingDirectory(sourceDir, () => - Bun.build({ - entrypoints: [`./${basename(sourcePath)}`], - root: sourceDir, - target: 'node', - format: 'cjs', - minify: false, - sourcemap: 'none', - }), - ); - } catch (error) { - throw new Error( - `Failed to build CLI launcher (source: ${sourcePath}, output: ${outputPath}): ${describeError(error)}`, - { cause: error }, - ); - } - if (!result.success || result.outputs.length !== 1) { - const diagnostics = result.logs.map((log) => log.message).join('\n'); - throw new Error( - `Failed to build CLI launcher (source: ${sourcePath}, output: ${outputPath}):\n${diagnostics}`, - ); - } - const bundled = (await result.outputs[0].text()).replace( - /^#![^\n]*(?:\n|$)/, - '', - ); - return formatLauncher(bundled, sourcePath, outputPath); -} - -function hasErrorCode(error: unknown, code: string): boolean { - return ( - typeof error === 'object' && - error !== null && - 'code' in error && - error.code === code - ); -} - -export async function outputMatches( - outputPath: string, - generated: string, -): Promise { - try { - return (await readFile(outputPath, 'utf8')) === generated; - } catch (error) { - if (hasErrorCode(error, 'ENOENT')) { - return false; - } - throw error; - } -} - -async function writeLauncher( - outputPath: string, - generated: string, -): Promise { - try { - await mkdir(dirname(outputPath), { recursive: true }); - await writeFile(outputPath, generated, 'utf8'); - if (process.platform !== 'win32') { - await chmod(outputPath, 0o755); - } - } catch (error) { - throw new Error( - `Failed to write CLI launcher to ${outputPath}: ${describeError(error)}`, - { cause: error }, - ); - } -} - -async function main(): Promise { - if (typeof Bun === 'undefined') { - throw new Error( - 'CLI launcher generation requires the Bun runtime. Run `npm run generate:cli-launcher` instead of invoking this script with Node.', - ); - } - - const options = parseOptions(process.argv.slice(2)); - const generated = await buildLauncher(options.source, options.output); - - if (options.check) { - if (!(await outputMatches(options.output, generated))) { - process.stderr.write( - `Generated CLI launcher is stale: ${options.output}\n` + - 'Run `npm run generate:cli-launcher` and commit the result.\n', - ); - process.exitCode = 1; - } - return; - } - - await writeLauncher(options.output, generated); -} - -function normalizeEntryPath(path: string): string { - const normalized = resolve(path); - return process.platform === 'win32' ? normalized.toLowerCase() : normalized; -} - -const isDirectEntry = - import.meta.main ?? - (process.argv[1] !== undefined && - normalizeEntryPath(fileURLToPath(import.meta.url)) === - normalizeEntryPath(process.argv[1])); - -if (isDirectEntry) { - await main(); -} diff --git a/scripts/lib/non-npm-release-packages.cjs b/scripts/lib/non-npm-release-packages.cjs new file mode 100644 index 000000000..758b825e3 --- /dev/null +++ b/scripts/lib/non-npm-release-packages.cjs @@ -0,0 +1,23 @@ +'use strict'; + +/** + * Shared list of workspace packages that are NOT published to NPM by the + * release pipeline. + * + * Canonical source: scripts/utils/release-packages.ts (TypeScript). Because + * .cjs scripts cannot import .ts modules without a build step, this .cjs + * mirror exists so both release-pack.cjs and release-install-smoke.cjs import + * a single shared definition rather than each duplicating the list. If the + * canonical TypeScript set changes, update this mirror to match. + * + * The release-pack.cjs test also validates that this list matches the + * TypeScript source at runtime by reading the compiled module. + */ + +const NON_NPM_RELEASE_PACKAGES = new Set([ + '@vybestack/llxprt-code-test-utils', + '@vybestack/llxprt-code-a2a-server', + 'llxprt-code-vscode-ide-companion', +]); + +module.exports = { NON_NPM_RELEASE_PACKAGES }; diff --git a/scripts/lib/npm-command.cjs b/scripts/lib/npm-command.cjs new file mode 100644 index 000000000..ee1bc48b8 --- /dev/null +++ b/scripts/lib/npm-command.cjs @@ -0,0 +1,228 @@ +'use strict'; + +/** + * Cross-platform npm/npx invocation resolver for Node spawn/spawnSync. + * + * On Windows, npm and npx are batch scripts (npm.cmd / npx.cmd). Node's + * spawn/spawnSync without `shell: true` cannot reliably execute .cmd files — + * it may fail with EINVAL or ENOENT depending on the Node version and Windows + * build. Using `shell: true` would introduce shell-injection risk through + * argv. Instead, on Windows we resolve npm's JavaScript CLI entry + * (npm-cli.js) and spawn `process.execPath` (node.exe) with the CLI script as + * the first argument, preserving argv boundaries without any shell. + * + * npm-cli.js is resolved from: + * 1. process.env.npm_execpath — set by npm when running under it (e.g. + * during postinstall lifecycle scripts). Only used when it points to a + * real .js file that exists. + * 2. /node_modules/npm/bin/npm-cli.js — the standard location in + * setup-node GitHub Actions runners and official Node installers. Only + * used when the file actually exists. + * + * If neither resolves to an existing .js file, resolveNpmCliJs throws an + * actionable NpmCliNotFoundError instead of letting Node emit an opaque + * MODULE_NOT_FOUND at spawn time. + * + * On POSIX, `npm` is a shebanged script that spawns directly, so we use it + * as-is. + * + * All functions accept an optional options object (with platform/execPath/env, + * and an injected existsSync for testing) so tests can validate command + * selection without mutating global state or touching the filesystem. + */ + +const path = require('node:path'); +const fs = require('node:fs'); + +/** + * Error thrown when npm-cli.js cannot be resolved on Windows. Includes the + * attempted paths so callers get an actionable message rather than an opaque + * Node MODULE_NOT_FOUND from a later spawn. + */ +class NpmCliNotFoundError extends Error { + constructor(message, details) { + super(message); + this.name = 'NpmCliNotFoundError'; + this.code = 'LLXPRT_NPM_CLI_NOT_FOUND'; + this.details = details; + } +} + +/** + * @typedef {{ + * platform?: string; + * execPath?: string; + * env?: Record; + * existsSync?: (p: string) => boolean; + * }} InvocationOptions + */ + +/** + * @typedef {{ command: string; args: string[] }} Invocation + */ + +function existsSyncOptional(options, p) { + const fn = (options && options.existsSync) || fs.existsSync; + return Boolean(fn(p)); +} + +/** + * Resolves the path to npm's JavaScript CLI entry (npm-cli.js) on Windows. + * + * Resolution order: + * 1. npm_execpath — only when it is a real .js path that exists. + * 2. /node_modules/npm/bin/npm-cli.js — only when it exists. + * + * Throws NpmCliNotFoundError when neither candidate exists, so the failure is + * actionable instead of surfacing as an opaque Node MODULE_NOT_FOUND at spawn. + * + * @param {InvocationOptions} [options] + * @returns {string} + * @throws {NpmCliNotFoundError} when no existing npm-cli.js candidate is found. + */ +function resolveNpmCliJs(options) { + const env = (options && options.env) || process.env; + const probed = []; + // npm_execpath is set when running under npm. It points to the CLI JS entry + // (e.g. /path/to/node_modules/npm/bin/npm-cli.js). Only trust it when it is a + // real .js file whose basename is npm-cli.js that exists — pnpm and Yarn + // also set npm_execpath to their own CLI during lifecycle scripts, so a + // generic .js+exists check could resolve the wrong package manager. + // + // We check basename on BOTH separators (/ and \) so Windows-style paths + // (backslashes) are handled correctly even when this code runs on POSIX + // (e.g. during cross-platform unit tests). + if (env.npm_execpath && env.npm_execpath.endsWith('.js')) { + const normalizedBase = env.npm_execpath + .replace(/\\/g, '/') + .split('/') + .pop(); + // Require an absolute path so a relative npm_execpath (which would be + // CWD-dependent) is never trusted. Check both POSIX (/ prefix) and + // Windows (drive-letter:\) forms so cross-platform unit tests that + // simulate Windows paths on a POSIX host work correctly. + const isAbs = + path.isAbsolute(env.npm_execpath) || + /^[A-Za-z]:[\\/]/.test(env.npm_execpath); + if ( + normalizedBase === 'npm-cli.js' && + isAbs && + existsSyncOptional(options, env.npm_execpath) + ) { + return env.npm_execpath; + } + probed.push(env.npm_execpath); + } + // Fallback: npm ships alongside Node in setup-node and official installers: + // /node_modules/npm/bin/npm-cli.js + const nodeExe = (options && options.execPath) || process.execPath; + const nodeDir = path.dirname(nodeExe); + const fallback = path.join( + nodeDir, + 'node_modules', + 'npm', + 'bin', + 'npm-cli.js', + ); + if (existsSyncOptional(options, fallback)) { + return fallback; + } + probed.push(fallback); + + // Fallback 2: npm prefix locations for nvm-windows, Volta, and global npm + // installs where npm is NOT alongside node.exe. These are derived from + // well-known environment variables (NPM_CONFIG_PREFIX, APPDATA) without + // spawning a shell or npm.cmd. + const prefixCandidates = []; + // Validate NPM_CONFIG_PREFIX as an absolute path so a relative value does + // not silently resolve against process.cwd(). Check both POSIX (/) and + // Windows (drive-letter:\) forms so cross-platform unit tests that simulate + // Windows paths on a POSIX host work correctly. + if ( + env.NPM_CONFIG_PREFIX && + (path.isAbsolute(env.NPM_CONFIG_PREFIX) || + /^[A-Za-z]:[\\/]/.test(env.NPM_CONFIG_PREFIX)) + ) { + prefixCandidates.push( + path.join( + env.NPM_CONFIG_PREFIX, + 'node_modules', + 'npm', + 'bin', + 'npm-cli.js', + ), + ); + } + if (env.APPDATA) { + // nvm-windows / global npm roaming install location. + prefixCandidates.push( + path.join(env.APPDATA, 'npm', 'node_modules', 'npm', 'bin', 'npm-cli.js'), + ); + } + for (const candidate of prefixCandidates) { + if (existsSyncOptional(options, candidate)) { + return candidate; + } + probed.push(candidate); + } + + throw new NpmCliNotFoundError( + `npm-cli.js could not be resolved on Windows (probed: ${probed.join(', ')}). ` + + 'Ensure npm is installed and accessible. This code checks the node.exe ' + + 'directory (setup-node / official installers), NPM_CONFIG_PREFIX, and ' + + 'APPDATA locations (nvm-windows, Volta, global installs). If none apply, ' + + 'install Node via setup-node or an official installer that ships npm ' + + 'alongside node.exe, or verify that NPM_CONFIG_PREFIX / APPDATA point to ' + + 'a valid npm installation.', + { probed }, + ); +} + +/** + * Returns the npm invocation (command + args) for spawn/spawnSync. + * + * On Windows, spawns `node.exe [args...]` so no .cmd file or + * shell is involved. On POSIX, spawns `npm [args...]` directly. + * + * @param {readonly string[]} [args] - npm arguments (e.g. ['pack', '-w']). + * @param {InvocationOptions} [options] + * @returns {Invocation} + * @throws {NpmCliNotFoundError} on Windows when npm-cli.js cannot be resolved + * (propagated from resolveNpmCliJs). + */ +function npmInvocation(args, options) { + const platform = (options && options.platform) || process.platform; + const cliArgs = args ? Array.from(args) : []; + if (platform === 'win32') { + const nodeExe = (options && options.execPath) || process.execPath; + const npmCliPath = resolveNpmCliJs(options); + return { command: nodeExe, args: [npmCliPath, ...cliArgs] }; + } + return { command: 'npm', args: cliArgs }; +} + +/** + * Returns the npx-equivalent invocation via `npm exec`. + * + * npx is a batch script on Windows (npx.cmd) with the same spawn limitation + * as npm.cmd. Instead of spawning npx.cmd, we route through `npm exec`, which + * the npm CLI supports on all platforms. The caller is responsible for adding + * a `--` separator before the command if needed. + * + * @param {readonly string[]} [args] - arguments after `npm exec`. + * @param {InvocationOptions} [options] + * @returns {Invocation} + * @throws {NpmCliNotFoundError} on Windows when npm-cli.js cannot be resolved + * (propagated from resolveNpmCliJs via npmInvocation). + */ +function npxInvocation(args, options) { + const cliArgs = args ? Array.from(args) : []; + return npmInvocation(['exec', ...cliArgs], options); +} + +module.exports = { + npmInvocation, + npxInvocation, + resolveNpmCliJs, + NpmCliNotFoundError, +}; diff --git a/scripts/lib/tar-command.cjs b/scripts/lib/tar-command.cjs new file mode 100644 index 000000000..c0d616c84 --- /dev/null +++ b/scripts/lib/tar-command.cjs @@ -0,0 +1,255 @@ +'use strict'; + +/** + * Shared tar spawn helpers for issue #2603 test scripts. + * + * Multiple test files (issue-2603-install.test.ts, issue-2603-release-pack.cjs, + * issue-2603-release-install-smoke.cjs) duplicate near-identical tar spawn + * logic with spawn-error diagnostics. This module centralizes that logic so a + * fix or diagnostic improvement applies everywhere. + * + * GitHub Windows runners ship bsdtar, but a spawn failure (ENOENT) must + * produce a clear diagnostic rather than an opaque null status. + */ + +const { spawnSync } = require('node:child_process'); +const { existsSync, statSync } = require('node:fs'); +const { join } = require('node:path'); + +/** + * Default timeout for tar operations (listing, extracting). + */ +const TAR_TIMEOUT_MS = 30_000; + +/** + * Shared maxBuffer for tar spawn captures. Large tar listings or verbose + * output can exceed Node's default 200KB stdio buffer, causing + * ERR_CHILD_PROCESS_STDIO_MAXBUFFER or silent truncation of error + * diagnostics. 64MB aligns with the project-wide convention used by the + * issue-2603 smoke harness (install-helpers.cjs SPAWN_MAX_BUFFER). + */ +const TAR_MAX_BUFFER = 64 * 1024 * 1024; + +/** + * Builds the spawn options object shared by all tar spawn helpers. + * + * @param {number} timeoutMs - spawn timeout (falls back to TAR_TIMEOUT_MS). + * @param {string} [cwd] - optional working directory for the tar process. + * @returns {object} the spawnSync options. + */ +function tarSpawnOptions(timeoutMs, cwd) { + const opts = { + encoding: 'utf8', + // Use nullish coalescing so an explicit timeoutMs of 0 (meaning + // "no timeout" in Node's spawnSync) is honored instead of falling + // back to the default. + timeout: timeoutMs ?? TAR_TIMEOUT_MS, + maxBuffer: TAR_MAX_BUFFER, + // Suppress transient console windows on Windows CI runners, consistent + // with the project-wide convention used in + // windows-installed-command-smoke/*.cjs. + windowsHide: true, + }; + if (cwd != null) { + opts.cwd = cwd; + } + return opts; +} + +/** + * Throws a clear diagnostic when a required path argument is missing or empty. + * Without this, Node's spawnSync coerces undefined to the string 'undefined', + * producing confusing tar errors like "tar: undefined: Cannot open". + * + * @param {string} name - the parameter name for the error message. + * @param {unknown} value - the value to validate. + * @returns {string} the validated non-empty string. + * @throws {Error} when value is not a non-empty string. + */ +function requireNonEmptyPath(name, value) { + if (typeof value !== 'string' || value.length === 0) { + throw new Error( + `tar spawn helper requires a non-empty ${name}, got: ${JSON.stringify(value)}`, + ); + } + return value; +} + +/** + * Spawns tar to list the contents of a tarball (-tzf). Throws on spawn error, + * signal termination, or non-zero exit with stderr context. + * + * @param {string} tarball - path to the .tgz file. + * @param {number} [timeoutMs] - optional spawn timeout (default 30s). + * @param {string} [cwd] - optional working directory for the tar process. + * @returns {{ stdout: string, stderr: string }} + * @throws {Error} on spawn failure, signal, or non-zero exit. + */ +function spawnTarList(tarball, timeoutMs, cwd) { + requireNonEmptyPath('tarball', tarball); + const result = spawnSync( + 'tar', + ['-tzf', tarball], + tarSpawnOptions(timeoutMs, cwd), + ); + if (result.error) { + throw new Error( + `Failed to spawn tar (is tar on PATH?): ${result.error.message}`, + ); + } + if (result.status !== 0) { + throw new Error( + `tar list failed (exit ${result.status}, signal=${result.signal ?? 'none'}): ${result.stderr || result.stdout}`, + ); + } + return { stdout: result.stdout, stderr: result.stderr }; +} + +/** + * Spawns tar to list verbose info for a specific member (-tzvf). Throws on + * spawn error, signal termination, or non-zero exit with stderr context. + * + * @param {string} tarball - path to the .tgz file. + * @param {string} member - the tar member path to inspect. + * @param {number} [timeoutMs] - optional spawn timeout (default 30s). + * @param {string} [cwd] - optional working directory for the tar process. + * @returns {{ stdout: string, stderr: string }} + * @throws {Error} on spawn failure, signal, or non-zero exit. + */ +function spawnTarListVerbose(tarball, member, timeoutMs, cwd) { + requireNonEmptyPath('tarball', tarball); + requireNonEmptyPath('member', member); + const result = spawnSync( + 'tar', + ['-tzvf', tarball, member], + tarSpawnOptions(timeoutMs, cwd), + ); + if (result.error) { + throw new Error( + `Failed to spawn tar (is tar on PATH?): ${result.error.message}`, + ); + } + if (result.status !== 0) { + throw new Error( + `tar list-verbose failed (exit ${result.status}, signal=${result.signal ?? 'none'}): ${result.stderr || result.stdout}`, + ); + } + return { stdout: result.stdout, stderr: result.stderr }; +} + +/** + * Spawns tar to extract a tarball (-xzf). Throws on spawn error, signal + * termination, or non-zero exit with stderr context. + * + * @param {string} tarball - path to the .tgz file. + * @param {string} extractDir - directory to extract into. + * @param {number} [timeoutMs] - optional spawn timeout (default 30s). + * @param {string} [cwd] - optional working directory for the tar process. + * @returns {{ stdout: string, stderr: string }} + * @throws {Error} on spawn failure, signal, or non-zero exit. + */ +function spawnTarExtract(tarball, extractDir, timeoutMs, cwd) { + requireNonEmptyPath('tarball', tarball); + requireNonEmptyPath('extractDir', extractDir); + // Pre-validate extractDir is a directory so a missing or non-directory path + // produces a clear diagnostic instead of a generic tar error. Use a single + // statSync wrapped in try/catch to avoid the TOCTOU gap between existsSync + // and statSync, and to catch permission-denied or removed-between-calls + // errors with the module's diagnostic style rather than a raw stack trace. + let stat; + try { + stat = statSync(extractDir); + } catch (e) { + if (e && e.code === 'ENOENT') { + throw new Error(`tar extract destination does not exist: ${extractDir}`); + } + throw new Error( + `tar extract destination is not accessible: ${extractDir} (${e.message})`, + ); + } + if (!stat.isDirectory()) { + throw new Error( + `tar extract destination is not a directory: ${extractDir}`, + ); + } + const result = spawnSync( + 'tar', + ['-xzf', tarball, '-C', extractDir], + tarSpawnOptions(timeoutMs, cwd), + ); + if (result.error) { + throw new Error( + `Failed to spawn tar (is tar on PATH?): ${result.error.message}`, + ); + } + if (result.status !== 0) { + throw new Error( + `tar extract failed (exit ${result.status}, signal=${result.signal ?? 'none'}): ${result.stderr || result.stdout}`, + ); + } + return { stdout: result.stdout, stderr: result.stderr }; +} + +/** + * Locate the .tgz filename in npm pack output. npm pack prints the tarball + * filename (ending in .tgz) as the final non-empty line. Earlier warnings or + * progress lines ending in .tgz are ignored by scanning from the end. + * + * @param {string} packOutput - raw npm pack stdout. + * @param {string} [cacheDir] - optional dir to validate the tarball exists in. + * @returns {string} the tarball filename. + * @throws {Error} when no .tgz line is found, or when cacheDir is provided and + * the file does not exist. + */ +function findTarballName(packOutput, cacheDir) { + const lines = packOutput.split(/\r?\n/); + let tarName = ''; + // A valid npm pack tarball name has the shape -.tgz where + // name is a non-empty npm package name and version is a non-empty semver-ish + // string. Validate without a regex to avoid any backtracking risk: split on + // the last dash before .tgz and check both halves are non-empty. + function looksLikeTarballName(s) { + if (!s.endsWith('.tgz')) { + return false; + } + const base = s.slice(0, -4); + const dashIdx = base.lastIndexOf('-'); + if (dashIdx <= 0) { + return false; + } + return dashIdx < base.length - 1; + } + for (let i = lines.length - 1; i >= 0; i--) { + const trimmed = lines[i].trim(); + if (trimmed.endsWith('.tgz') && looksLikeTarballName(trimmed)) { + tarName = trimmed; + break; + } + } + if (!tarName) { + // Truncate to the last 10 lines so large npm pack outputs with many + // warnings do not produce unwieldy error messages. + const tail = lines.slice(-10).join('\n'); + throw new Error( + `npm pack output did not contain a .tgz line (showing last 10 lines):\n${tail}`, + ); + } + if (cacheDir != null) { + const tarPath = join(cacheDir, tarName); + if (!existsSync(tarPath)) { + throw new Error( + `npm pack reported tarball ${tarName} but it does not exist at ${tarPath}`, + ); + } + } + return tarName; +} + +module.exports = { + spawnTarList, + spawnTarListVerbose, + spawnTarExtract, + findTarballName, + requireNonEmptyPath, + TAR_TIMEOUT_MS, +}; diff --git a/scripts/postinstall.cjs b/scripts/postinstall.cjs index 71be108cb..85c543578 100644 --- a/scripts/postinstall.cjs +++ b/scripts/postinstall.cjs @@ -253,6 +253,47 @@ function stripPeerFlagsFromLockfile() { } } +/** + * Installs the Windows native launchers (cmd/ps1) for the CLI workspace. Both + * the Bun and npm postinstall branches run this at the same lifecycle point + * (after their own manager-specific steps) so the launchers are generated + * exactly once per install with the correct package layout. Errors during + * launcher generation are surfaced but never fatal: the package still installs. + */ +function installWindowsNativeLaunchers() { + if (process.platform !== 'win32') { + return; + } + const cliInstaller = path.join( + repoRoot, + 'packages', + 'cli', + 'scripts', + 'install-native-launchers.cjs', + ); + if (!fs.existsSync(cliInstaller)) { + return; + } + try { + const { installNativeLaunchers } = require(cliInstaller); + installNativeLaunchers({ + packageRoot: path.join(repoRoot, 'packages', 'cli'), + log: console.log, + }); + } catch (error) { + // Normalize non-Error rejections (string, number, null) so the message + // extraction never throws and postinstall stays non-fatal as documented. + const msg = + error && typeof error.message === 'string' + ? error.message + : String(error); + console.warn( + 'Warning: Windows native launcher generation failed (non-fatal):', + msg, + ); + } +} + // Under Bun, only the npm-specific actions below are skipped: Bun does not // consume package-lock.json (so peer-flag sanitization is irrelevant and must // not mutate it), and it resolves workspace packages through Bun's own install. @@ -261,6 +302,7 @@ function stripPeerFlagsFromLockfile() { // symlinks so Bun resolves the live TypeScript source tree consistently. if (detectInstaller() === 'bun') { symlinkBunWorkspaceCopies(); + installWindowsNativeLaunchers(); process.exit(0); } @@ -280,3 +322,5 @@ if (hasWorkspaceSources) { process.exit(1); } } + +installWindowsNativeLaunchers(); diff --git a/scripts/start.ts b/scripts/start.ts index 5bfdfd29e..260112226 100644 --- a/scripts/start.ts +++ b/scripts/start.ts @@ -190,9 +190,14 @@ if (experimentalUi) { process.exit(code ?? 1); }); } else { - // Standard CLI path: checked-in Node launcher re-execs Bun on TypeScript source. - nodeArgs.push('./packages/cli/bin/llxprt.cjs'); - nodeArgs.push(...args); + // Standard CLI dev path: launch Bun directly on the TypeScript entry point. + // The dev script (`npm run start`) is a development convenience; the + // installed command (issue #2603) uses packages/cli/bin/llxprt, a POSIX sh + // launcher that resolves the package-bundled Bun and execs the entry. Here + // we do the equivalent for dev mode — spawn Bun directly on the source. + // nodeArgs may carry --inspect-brk flags (Bun-compatible) that must precede + // the entry path. + const bunArgs = [...nodeArgs, './packages/cli/index.ts', ...args]; const env: NodeJS.ProcessEnv = { ...process.env, @@ -205,10 +210,10 @@ if (experimentalUi) { env.LLXPRT_DEBUG_SESSION_ID = `${process.pid}`; } - const child = spawn('node', nodeArgs, { stdio: 'inherit', env }); + const child = spawn('bun', bunArgs, { stdio: 'inherit', env }); child.on('error', (error) => { - console.error(`Failed to spawn node: ${messageOf(error)}`); + console.error(`Failed to spawn bun: ${messageOf(error)}`); process.exit(1); }); child.on('close', (code) => { diff --git a/scripts/test-acp-integration.mjs b/scripts/test-acp-integration.mjs index 3af0274fe..eebbd2e84 100755 --- a/scripts/test-acp-integration.mjs +++ b/scripts/test-acp-integration.mjs @@ -9,7 +9,7 @@ import { spawn } from 'child_process'; const args = process.argv.slice(2); const profileName = args[0] || process.env.LLXPRT_PROFILE; -const llxprtArgs = ['packages/cli/bin/llxprt.cjs', '--experimental-acp']; +const llxprtArgs = ['packages/cli/index.ts', '--experimental-acp']; if (profileName && !process.env.LLXPRT_PROFILE) { llxprtArgs.splice(1, 0, '--profile-load', profileName); } @@ -22,7 +22,7 @@ if (profileName) { const env = { ...process.env, DEBUG: 'llxprt:*' }; console.log('Spawning with args:', llxprtArgs); console.log('DEBUG env set to:', env.DEBUG); -const llxprt = spawn('node', llxprtArgs, { +const llxprt = spawn('bun', llxprtArgs, { stdio: ['pipe', 'pipe', 'pipe'], cwd: process.cwd(), env, diff --git a/scripts/tests/bun-script-migration.test.ts b/scripts/tests/bun-script-migration.test.ts index 5f48d702d..64d7b31cf 100644 --- a/scripts/tests/bun-script-migration.test.ts +++ b/scripts/tests/bun-script-migration.test.ts @@ -739,16 +739,25 @@ runtimeDescribe( }; } - it('spawns node with the CLI launcher and forwards user args', () => { - const { binDir, nodeLogPath } = setupFakeBinDir(); + it('spawns bun with the CLI entry and forwards user args', () => { + const { binDir, bunLogPath } = setupFakeBinDir(); try { const { status } = runStartWithFakeBin(binDir, ['--version']); expect(status).toBe(0); - const nodeArgs = readFileSync(nodeLogPath, 'utf-8') - .trim() - .split(NEWLINE); - expect(nodeArgs[0]).toBe('./packages/cli/bin/llxprt.cjs'); - expect(nodeArgs).toContain('--version'); + const bunArgs = readFileSync(bunLogPath, 'utf-8').trim().split(NEWLINE); + // start.ts spawns bun for the CLI entry (packages/cli/index.ts) and + // forwards user args. Assert that SOME bun invocation contains both + // the entry and --version, without depending on last-match ordering + // (start.ts may emit multiple bun invocations for discovery). + const cliWithVersion = bunArgs.filter( + (line) => + line.includes('packages/cli/index.ts') && + line.includes('--version'), + ); + expect( + cliWithVersion.length, + `expected a bun invocation containing both the CLI entry and --version; got: ${bunArgs.join(NEWLINE)}`, + ).toBeGreaterThan(0); } finally { rmSync(binDir, { recursive: true, force: true }); } diff --git a/scripts/tests/generate-cli-launcher.test.ts b/scripts/tests/generate-cli-launcher.test.ts deleted file mode 100644 index 250ec5717..000000000 --- a/scripts/tests/generate-cli-launcher.test.ts +++ /dev/null @@ -1,654 +0,0 @@ -/** - * @license - * Copyright 2025 Vybestack LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { spawnSync, type SpawnSyncReturns } from 'node:child_process'; -import { - accessSync, - chmodSync, - copyFileSync, - constants, - mkdirSync, - mkdtempSync, - readFileSync, - realpathSync, - rmSync, - statSync, - writeFileSync, -} from 'node:fs'; -import { createRequire } from 'node:module'; -import { tmpdir } from 'node:os'; -import { basename, dirname, join, resolve } from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { - formatLauncher, - outputMatches, - runInWorkingDirectory, -} from '../generate-cli-launcher.js'; - -const repoRoot = resolve(import.meta.dirname, '../..'); -const generatorPath = join(repoRoot, 'scripts', 'generate-cli-launcher.ts'); -const canonicalSource = join( - repoRoot, - 'packages', - 'cli', - 'src', - 'launcher', - 'cli-bin.cts', -); -const canonicalPolicy = join( - dirname(canonicalSource), - 'bun-candidate-policy.ts', -); -const loadCommonJsModule = createRequire(import.meta.url); -const SPAWN_TIMEOUT_MS = 30_000; -const SPAWN_TEST_TIMEOUT_MS = SPAWN_TIMEOUT_MS + 10_000; -const DOUBLE_SPAWN_TEST_TIMEOUT_MS = SPAWN_TIMEOUT_MS * 2 + 10_000; - -type TextSpawnResult = SpawnSyncReturns<'utf8'>; - -interface CommonJsLauncher { - readonly runCliBin: () => unknown; -} - -function spawnDiagnostics(result: TextSpawnResult): string { - return [ - `status: ${String(result.status)}`, - `signal: ${String(result.signal)}`, - `error: ${result.error?.message ?? ''}`, - `stdout: ${result.stdout}`, - `stderr: ${result.stderr}`, - ].join('\n'); -} - -function loadLauncher(output: string): CommonJsLauncher { - const loaded: unknown = loadCommonJsModule(output); - if ( - typeof loaded !== 'object' || - loaded === null || - !('runCliBin' in loaded) || - typeof loaded.runCliBin !== 'function' - ) { - throw new Error( - `Generated launcher at ${output} does not export runCliBin`, - ); - } - return { runCliBin: loaded.runCliBin }; -} - -function executableExists(path: string): boolean { - try { - accessSync(path, constants.X_OK); - return true; - } catch { - return false; - } -} - -function resolveRequiredBun(): string { - const directCandidates = [ - process.env['BUN_PATH'], - join(repoRoot, 'node_modules', 'bun', 'bin', 'bun.exe'), - join(repoRoot, 'node_modules', 'bun', 'bin', 'bun'), - ] - .filter((candidate): candidate is string => candidate !== undefined) - .map((candidate) => candidate.replace(/^(["'])(.+?)\1$/, '$2')); - const direct = directCandidates.find(executableExists); - if (direct !== undefined) { - return direct; - } - - const lookupTool = process.platform === 'win32' ? 'where.exe' : 'which'; - const lookup = spawnSync(lookupTool, ['bun'], { - encoding: 'utf8', - timeout: SPAWN_TIMEOUT_MS, - }); - if (lookup.status === 0) { - const fromPath = lookup.stdout - .split(/\r?\n/) - .map((candidate) => candidate.trim().replace(/^(["'])(.+?)\1$/, '$2')) - .find((candidate) => candidate.length > 0 && executableExists(candidate)); - if (fromPath !== undefined) { - return fromPath; - } - } - - throw new Error( - `Bun is required for CLI launcher generator tests. Set BUN_PATH, install the repository bun dependency, or put bun on PATH. Lookup diagnostics: ${spawnDiagnostics(lookup)}`, - ); -} - -let repositoryBun: string | undefined; - -function getRepositoryBun(): string { - repositoryBun ??= resolveRequiredBun(); - return repositoryBun; -} - -function expectSpawnSuccess(result: TextSpawnResult): void { - const diagnostics = spawnDiagnostics(result); - expect(result.error, diagnostics).toBeUndefined(); - expect(result.status, diagnostics).toBe(0); -} - -function runGenerator(args: readonly string[]): TextSpawnResult { - return spawnSync(getRepositoryBun(), [generatorPath, ...args], { - cwd: repoRoot, - encoding: 'utf8', - timeout: SPAWN_TIMEOUT_MS, - }); -} - -function copyEquivalentLauncherSource(targetDir: string): string { - mkdirSync(targetDir, { recursive: true }); - const source = join(targetDir, 'cli-bin.cts'); - copyFileSync(canonicalSource, source); - copyFileSync(canonicalPolicy, join(targetDir, 'bun-candidate-policy.ts')); - return source; -} - -function expectGeneratedBytesExcludePath(bytes: string, path: string): void { - const lowerBytes = bytes.toLowerCase(); - const backslashPath = path.replace(/\//g, '\\'); - const variants = new Set( - [ - path, - path.replace(/\\/g, '/'), - backslashPath, - backslashPath.replace(/\\/g, '\\\\'), - ].map((variant) => variant.toLowerCase()), - ); - for (const variant of variants) { - expect( - lowerBytes, - `generated bytes should not contain path variant: ${variant}`, - ).not.toContain(variant); - } -} - -describe('CLI launcher generation', () => { - it('rejects direct Node invocation before parsing generator options', () => { - const result = spawnSync(process.execPath, [generatorPath, '--source='], { - cwd: repoRoot, - encoding: 'utf8', - timeout: SPAWN_TIMEOUT_MS, - }); - const diagnostics = spawnDiagnostics(result); - - expect(result.error, diagnostics).toBeUndefined(); - expect(result.status, diagnostics).not.toBe(0); - expect(result.stderr, diagnostics).toContain( - 'CLI launcher generation requires the Bun runtime', - ); - expect(result.stderr, diagnostics).toContain( - '`npm run generate:cli-launcher`', - ); - expect(result.stderr, diagnostics).not.toContain( - '--source requires a path', - ); - expect(result.stderr, diagnostics).not.toContain( - 'Failed to build CLI launcher', - ); - }); - - it('rejects direct Node invocation when import.meta.main is unavailable', () => { - const tempDir = mkdtempSync( - join(dirname(generatorPath), '.cli-launcher-no-import-meta-main-'), - ); - const copiedGenerator = join(tempDir, basename(generatorPath)); - - try { - const source = readFileSync(generatorPath, 'utf8'); - const sourceWithoutImportMetaMain = source.replace( - /import\.meta\.main/g, - 'undefined', - ); - expect(sourceWithoutImportMetaMain).not.toBe(source); - writeFileSync(copiedGenerator, sourceWithoutImportMetaMain); - - const result = spawnSync( - process.execPath, - [copiedGenerator, '--source='], - { - cwd: repoRoot, - encoding: 'utf8', - timeout: SPAWN_TIMEOUT_MS, - }, - ); - const diagnostics = spawnDiagnostics(result); - - expect(result.error, diagnostics).toBeUndefined(); - expect(result.status, diagnostics).not.toBe(0); - expect(result.stderr, diagnostics).toContain( - 'CLI launcher generation requires the Bun runtime', - ); - expect(result.stderr, diagnostics).not.toContain( - '--source requires a path', - ); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } - }); - - it( - 'uses a quoted BUN_PATH candidate without falling back to the repository Bun', - () => { - const tempDir = mkdtempSync(join(tmpdir(), 'quoted-bun-path-')); - const copiedBun = join(tempDir, basename(getRepositoryBun())); - const originalBunPath = process.env['BUN_PATH']; - - try { - copyFileSync(getRepositoryBun(), copiedBun); - chmodSync(copiedBun, statSync(getRepositoryBun()).mode); - process.env['BUN_PATH'] = `"${copiedBun}"`; - - expect(resolveRequiredBun()).toBe(copiedBun); - } finally { - if (originalBunPath === undefined) { - delete process.env['BUN_PATH']; - } else { - process.env['BUN_PATH'] = originalBunPath; - } - rmSync(tempDir, { recursive: true, force: true }); - } - }, - SPAWN_TEST_TIMEOUT_MS, - ); - - it('preserves a build failure while reporting a concurrent cwd restoration failure', async () => { - const buildFailure = new Error('build failed'); - const restorationFailure = new Error('cwd restore failed'); - const reported: unknown[] = []; - - const result = runInWorkingDirectory( - 'source-directory', - async () => { - throw buildFailure; - }, - { - cwd: () => 'original-directory', - chdir: (path) => { - if (path === 'original-directory') { - throw restorationFailure; - } - }, - reportRestorationFailure: (error) => reported.push(error), - }, - ); - - await expect(result).rejects.toBe(buildFailure); - expect(reported).toStrictEqual([restorationFailure]); - }); - - it('surfaces cwd restoration failure when the build succeeds', async () => { - const restorationFailure = new Error('cwd restore failed'); - - const result = runInWorkingDirectory( - 'source-directory', - async () => 'generated', - { - cwd: () => 'original-directory', - chdir: (path) => { - if (path === 'original-directory') { - throw restorationFailure; - } - }, - reportRestorationFailure: () => { - throw new Error('restoration failure should not be reported twice'); - }, - }, - ); - - await expect(result).rejects.toBe(restorationFailure); - }); - - it('identifies source and output paths in formatting failure diagnostics', async () => { - const source = join(repoRoot, 'source', 'cli-bin.cts'); - const output = join(repoRoot, 'output', 'llxprt.cjs'); - const formattingFailure = new Error('formatter failed'); - - await expect( - formatLauncher('module.exports = {};', source, output, async () => { - throw formattingFailure; - }), - ).rejects.toThrowError( - `Failed to format CLI launcher (source: ${source}, output: ${output}): formatter failed`, - ); - }); - - it( - 'creates a missing output parent directory', - () => { - const tempDir = mkdtempSync(join(tmpdir(), 'cli-launcher-parent-')); - const output = join(tempDir, 'missing', 'nested', 'llxprt.cjs'); - - try { - const result = runGenerator(['--output', output]); - - expectSpawnSuccess(result); - expect(readFileSync(output, 'utf8')).toMatch(/^#!\/usr\/bin\/env node/); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } - }, - SPAWN_TEST_TIMEOUT_MS, - ); - - it( - 'uses the final repeated equals-separated source and output paths', - () => { - const tempDir = mkdtempSync( - join(tmpdir(), 'cli-launcher-equals-source-'), - ); - const source = copyEquivalentLauncherSource(join(tempDir, 'source')); - const output = join(tempDir, 'output', 'llxprt.cjs'); - const sourceMarker = 'LLXPRT_EQUALS_SOURCE_OPTION'; - - try { - writeFileSync( - source, - readFileSync(source, 'utf8').replace( - 'LLXPRT_BUN_RELAUNCHED', - sourceMarker, - ), - ); - const result = runGenerator([ - '--source=', - `--source=${source}`, - '--output=', - `--output=${output}`, - ]); - - expectSpawnSuccess(result); - expect(readFileSync(output, 'utf8')).toContain(sourceMarker); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } - }, - SPAWN_TEST_TIMEOUT_MS, - ); - - it.each([ - { - earlierOption: '--source=ignored', - finalOption: '--source=', - diagnostic: '--source requires a path', - }, - { - earlierOption: '--output=ignored', - finalOption: '--output=', - diagnostic: '--output requires a path', - }, - ])( - 'rejects the final empty equals-separated option $finalOption', - ({ earlierOption, finalOption, diagnostic }) => { - const result = runGenerator(['--check', earlierOption, finalOption]); - const diagnostics = spawnDiagnostics(result); - - expect(result.status, diagnostics).not.toBe(0); - expect(result.stderr, diagnostics).toContain(diagnostic); - }, - ); - - it( - 'identifies the target path when the generated launcher cannot be written', - () => { - const tempDir = mkdtempSync(join(tmpdir(), 'cli-launcher-write-error-')); - - try { - const result = runGenerator(['--output', tempDir]); - const diagnostics = spawnDiagnostics(result); - - expect(result.status, diagnostics).not.toBe(0); - expect(result.stderr, diagnostics).toContain( - `Failed to write CLI launcher to ${tempDir}`, - ); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } - }, - SPAWN_TEST_TIMEOUT_MS, - ); - - it('treats only a missing output as stale during check', async () => { - const tempDir = mkdtempSync(join(tmpdir(), 'cli-launcher-read-error-')); - - try { - await expect( - outputMatches(join(tempDir, 'missing.cjs'), 'generated'), - ).resolves.toBe(false); - await expect(outputMatches(tempDir, 'generated')).rejects.toMatchObject({ - code: expect.stringMatching(/^(EISDIR|EPERM)$/), - }); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } - }); - - it( - 'identifies source and output paths in build failure diagnostics', - () => { - const tempDir = mkdtempSync(join(tmpdir(), 'cli-launcher-diagnostic-')); - const source = join(tempDir, 'invalid-launcher.cts'); - const output = join(tempDir, 'output', 'llxprt.cjs'); - - try { - writeFileSync(source, 'const invalid: =;\n'); - const result = runGenerator(['--source', source, '--output', output]); - const diagnostics = spawnDiagnostics(result); - - expect(result.status, diagnostics).not.toBe(0); - expect(result.stderr, diagnostics).toContain(source); - expect(result.stderr, diagnostics).toContain(output); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } - }, - SPAWN_TEST_TIMEOUT_MS, - ); - - it( - 'generates a Node-loadable CommonJS launcher with a shebang', - () => { - const tempDir = mkdtempSync(join(tmpdir(), 'cli-launcher-generate-')); - const output = join(tempDir, 'llxprt.cjs'); - - try { - const result = runGenerator(['--output', output]); - expectSpawnSuccess(result); - const generated = readFileSync(output, 'utf8'); - expect(generated).toMatch(/^#!\/usr\/bin\/env node\r?\n/); - expect(generated).toContain('Object.is(module, require.main)'); - expect(typeof loadLauncher(output).runCliBin).toBe('function'); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } - }, - SPAWN_TEST_TIMEOUT_MS, - ); - - it( - 'does not embed the repository absolute path in generated bytes', - () => { - const tempDir = mkdtempSync(join(tmpdir(), 'cli-launcher-portable-')); - const output = join(tempDir, 'llxprt.cjs'); - - try { - const result = runGenerator(['--output', output]); - expectSpawnSuccess(result); - const generated = readFileSync(output, 'utf8'); - expectGeneratedBytesExcludePath(generated, repoRoot); - expect(generated).toContain('runtimeModuleFilename(module)'); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } - }, - SPAWN_TEST_TIMEOUT_MS, - ); - - it( - 'produces byte-identical output from equivalent sources in different absolute directories', - () => { - const tempDir = mkdtempSync(join(tmpdir(), 'cli-launcher-determinism-')); - const firstSource = copyEquivalentLauncherSource( - join(tempDir, 'first-source'), - ); - const secondSource = copyEquivalentLauncherSource( - join(tempDir, 'second-source'), - ); - const first = join(tempDir, 'first.cjs'); - const second = join(tempDir, 'second.cjs'); - - try { - const firstResult = runGenerator([ - '--source', - firstSource, - '--output', - first, - ]); - const secondResult = runGenerator([ - '--source', - secondSource, - '--output', - second, - ]); - expectSpawnSuccess(firstResult); - expectSpawnSuccess(secondResult); - expect(readFileSync(first)).toStrictEqual(readFileSync(second)); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } - }, - DOUBLE_SPAWN_TEST_TIMEOUT_MS, - ); - - it( - 'checks a synchronized generated launcher without rewriting it', - () => { - const tempDir = mkdtempSync(join(tmpdir(), 'cli-launcher-check-')); - const output = join(tempDir, 'llxprt.cjs'); - - try { - const generation = runGenerator(['--output', output]); - expectSpawnSuccess(generation); - const before = readFileSync(output); - const result = runGenerator(['--check', '--output', output]); - - expectSpawnSuccess(result); - expect(readFileSync(output)).toStrictEqual(before); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } - }, - DOUBLE_SPAWN_TEST_TIMEOUT_MS, - ); - - it( - 'rejects stale generated output with the regeneration command', - () => { - const tempDir = mkdtempSync(join(tmpdir(), 'cli-launcher-stale-')); - const output = join(tempDir, 'llxprt.cjs'); - - try { - writeFileSync(output, '#!/usr/bin/env node\nstale\n'); - const result = runGenerator(['--check', '--output', output]); - const diagnostics = spawnDiagnostics(result); - - expect(result.error, diagnostics).toBeUndefined(); - expect(result.status, diagnostics).not.toBe(0); - expect(result.stderr, diagnostics).toContain( - 'npm run generate:cli-launcher', - ); - expect(readFileSync(output, 'utf8')).toContain('stale'); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } - }, - SPAWN_TEST_TIMEOUT_MS, - ); - - it( - 'runs from a relocated package with its sibling entry and local native Bun', - () => { - const tempDir = mkdtempSync(join(tmpdir(), 'cli-launcher-relocated-')); - const packageRoot = join(tempDir, 'installed-package'); - const launcher = join(packageRoot, 'bin', 'llxprt.cjs'); - const localBun = join( - packageRoot, - 'node_modules', - 'bun', - 'bin', - basename(getRepositoryBun()), - ); - const emptyPath = join(tempDir, 'empty-path'); - - try { - mkdirSync(dirname(launcher), { recursive: true }); - mkdirSync(dirname(localBun), { recursive: true }); - mkdirSync(emptyPath, { recursive: true }); - copyFileSync(getRepositoryBun(), localBun); - chmodSync(localBun, statSync(getRepositoryBun()).mode); - writeFileSync( - join(packageRoot, 'index.ts'), - [ - 'process.stdout.write([', - ' process.execPath,', - " 'relocated-sibling-entry',", - ' ...process.argv.slice(2),', - '].join("\\n"));', - ].join('\n'), - ); - - const generation = runGenerator(['--output', launcher]); - expectSpawnSuccess(generation); - - const result = spawnSync( - process.execPath, - [launcher, '--prompt', 'package local'], - { - cwd: tempDir, - env: { ...process.env, PATH: emptyPath, Path: emptyPath }, - encoding: 'utf8', - timeout: SPAWN_TIMEOUT_MS, - }, - ); - expectSpawnSuccess(result); - const [observedBun, ...observedOutput] = result.stdout - .trim() - .split(/\r?\n/); - if (observedBun === undefined) { - throw new Error( - 'relocated launcher did not report its Bun executable', - ); - } - expect(realpathSync(observedBun)).toBe(realpathSync(localBun)); - expect(observedOutput).toStrictEqual([ - 'relocated-sibling-entry', - '--prompt', - 'package local', - ]); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } - }, - DOUBLE_SPAWN_TEST_TIMEOUT_MS, - ); - - it( - 'keeps the checked-in launcher runnable before a project build', - () => { - const launcher = join(repoRoot, 'packages', 'cli', 'bin', 'llxprt.cjs'); - const result = spawnSync(process.execPath, [launcher, '--version'], { - cwd: repoRoot, - encoding: 'utf8', - timeout: SPAWN_TIMEOUT_MS, - }); - - expectSpawnSuccess(result); - expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/); - }, - SPAWN_TEST_TIMEOUT_MS, - ); -}); diff --git a/scripts/tests/issue-2603-install-layouts.test.ts b/scripts/tests/issue-2603-install-layouts.test.ts new file mode 100644 index 000000000..b21505fd9 --- /dev/null +++ b/scripts/tests/issue-2603-install-layouts.test.ts @@ -0,0 +1,502 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, + mkdtempSync, +} from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { tmpdir } from 'node:os'; +import { createRequire } from 'node:module'; + +const thisFile = fileURLToPath(import.meta.url); +const repoRoot = resolve(thisFile, '..', '..', '..'); +const nodeRequire = createRequire(import.meta.url); +const cliModulePath = join( + repoRoot, + 'packages', + 'cli', + 'scripts', + 'install-native-launchers.cjs', +); + +/** + * Reads the CLI package manifest to derive the pnpm virtual-store directory + * name, which encodes `+@`. Both name and version are + * derived dynamically so this test tracks the actual published manifest. + */ +interface CliManifest { + name: string; + version: string; +} + +function readCliManifest(): CliManifest { + const cliPkg = JSON.parse( + readFileSync(join(repoRoot, 'packages', 'cli', 'package.json'), 'utf8'), + ) as { name?: string; version?: string }; + if ( + typeof cliPkg.name !== 'string' || + cliPkg.name.length === 0 || + typeof cliPkg.version !== 'string' || + cliPkg.version.length === 0 + ) { + throw new Error('packages/cli/package.json is missing name or version'); + } + return { name: cliPkg.name, version: cliPkg.version }; +} + +const CLI_MANIFEST = readCliManifest(); +// pnpm encodes the scope separator as + in the virtual-store directory name. +const PNPM_PACKAGE_DIR = `${CLI_MANIFEST.name.replace(/^@/, '').replace(/\//g, '+')}@${CLI_MANIFEST.version}`; +// Derive the package scope and unscoped name from the manifest so these paths +// stay correct if the package name or scope ever changes. +const [CLI_SCOPE, ...CLI_NAME_PARTS] = CLI_MANIFEST.name.split('/'); +const CLI_NAME = CLI_NAME_PARTS.join('/') || CLI_MANIFEST.name; +// The bin name is derived from the CLI manifest's bin field so assertions +// adapt automatically if the bin name ever changes from 'llxprt'. +const CLI_BIN_NAME = (() => { + try { + const binPkg = JSON.parse( + readFileSync(join(repoRoot, 'packages', 'cli', 'package.json'), 'utf8'), + ) as { bin?: Record }; + const binEntries = Object.values(binPkg.bin ?? {}); + return binEntries.length > 0 + ? binEntries[0].replace(/^bin\//, '') + : 'llxprt'; + } catch { + return 'llxprt'; + } +})(); + +function loadCliInstaller(): ReturnType { + const mod = nodeRequire(cliModulePath); + // Implementation-detail helpers are exposed under a private `_testing` + // namespace; merge them onto the top-level return for legacy `mod.X` access. + return { ...mod, ...mod._testing }; +} + +describe('pnpm virtual-store layout (consumer-visible .bin)', () => { + function setupPnpmVirtualStore(tempDir: string): { + packageRoot: string; + consumerRoot: string; + consumerDotBin: string; + virtualStoreNodeModules: string; + } { + const consumerRoot = join(tempDir, 'consumer'); + const packageRoot = join( + consumerRoot, + 'node_modules', + '.pnpm', + PNPM_PACKAGE_DIR, + 'node_modules', + CLI_SCOPE, + CLI_NAME, + ); + const consumerDotBin = join(consumerRoot, 'node_modules', '.bin'); + const virtualStoreNodeModules = join( + consumerRoot, + 'node_modules', + '.pnpm', + PNPM_PACKAGE_DIR, + 'node_modules', + ); + mkdirSync(join(packageRoot, 'node_modules', 'bun', 'bin'), { + recursive: true, + }); + writeFileSync( + join(packageRoot, 'node_modules', 'bun', 'bin', 'bun.exe'), + 'fake', + ); + writeFileSync(join(packageRoot, 'index.ts'), '// entry'); + mkdirSync(consumerDotBin, { recursive: true }); + return { + packageRoot, + consumerRoot, + consumerDotBin, + virtualStoreNodeModules, + }; + } + + it('writes launchers to consumer-visible node_modules/.bin, not virtual-store .bin', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-pnpm-vs-')); + try { + const { packageRoot, consumerRoot, consumerDotBin } = + setupPnpmVirtualStore(tempDir); + const result = mod.installNativeLaunchers({ + platform: 'win32', + packageRoot, + env: { INIT_CWD: consumerRoot }, + log: () => {}, + }); + expect(result.written.length).toBeGreaterThanOrEqual(2); + expect(existsSync(join(consumerDotBin, `${CLI_BIN_NAME}.cmd`))).toBe( + true, + ); + expect(existsSync(join(consumerDotBin, `${CLI_BIN_NAME}.ps1`))).toBe( + true, + ); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('does not write launchers into the virtual-store node_modules/.bin', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-pnpm-novs-')); + try { + const { packageRoot, consumerRoot, virtualStoreNodeModules } = + setupPnpmVirtualStore(tempDir); + mod.installNativeLaunchers({ + platform: 'win32', + packageRoot, + env: { INIT_CWD: consumerRoot }, + log: () => {}, + }); + const virtualDotBin = join(virtualStoreNodeModules, '.bin'); + expect(existsSync(join(virtualDotBin, `${CLI_BIN_NAME}.cmd`))).toBe( + false, + ); + expect(existsSync(join(virtualDotBin, `${CLI_BIN_NAME}.ps1`))).toBe( + false, + ); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('uses INIT_CWD to find consumer .bin when nearestNodeModulesBin resolves into virtual store', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-pnpm-initcwd-')); + try { + const { packageRoot, consumerRoot, consumerDotBin } = + setupPnpmVirtualStore(tempDir); + const nearest = mod.nearestNodeModulesBin(packageRoot); + expect(nearest).toBe( + join( + tempDir, + 'consumer', + 'node_modules', + '.pnpm', + PNPM_PACKAGE_DIR, + 'node_modules', + '.bin', + ), + ); + const result = mod.installNativeLaunchers({ + platform: 'win32', + packageRoot, + env: { INIT_CWD: consumerRoot }, + log: () => {}, + }); + const writtenToConsumer = result.written.filter((p: string) => + p.startsWith(consumerDotBin), + ); + expect(writtenToConsumer.length).toBe(2); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('ownership validation rejects a foreign-package cmd shim in consumer .bin', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-pnpm-foreign-')); + try { + const { packageRoot, consumerRoot, consumerDotBin } = + setupPnpmVirtualStore(tempDir); + const foreignCmd = join(consumerDotBin, `${CLI_BIN_NAME}.cmd`); + writeFileSync(foreignCmd, '@echo off\necho someone else'); + const result = mod.installNativeLaunchers({ + platform: 'win32', + packageRoot, + env: { INIT_CWD: consumerRoot }, + log: () => {}, + }); + expect(result.skipped).toContain(foreignCmd); + expect(readFileSync(foreignCmd, 'utf8')).toContain('someone else'); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('ownership validation accepts our own sentinel in consumer .bin', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-pnpm-sentinel-')); + try { + const { packageRoot, consumerRoot, consumerDotBin } = + setupPnpmVirtualStore(tempDir); + const ourCmd = join(consumerDotBin, `${CLI_BIN_NAME}.cmd`); + writeFileSync(ourCmd, `REM ${mod.OWNERSHIP_SENTINEL}\n@echo off`); + const result = mod.installNativeLaunchers({ + platform: 'win32', + packageRoot, + env: { INIT_CWD: consumerRoot }, + log: () => {}, + }); + expect(result.written).toContain(ourCmd); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('ownership validation rejects a foreign-package ps1 shim in consumer .bin', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-pnpm-ps1-foreign-')); + try { + const { packageRoot, consumerRoot, consumerDotBin } = + setupPnpmVirtualStore(tempDir); + const foreignPs1 = join(consumerDotBin, `${CLI_BIN_NAME}.ps1`); + writeFileSync( + foreignPs1, + '#!/usr/bin/env pwsh\nWrite-Host "someone else"\n', + ); + const result = mod.installNativeLaunchers({ + platform: 'win32', + packageRoot, + env: { INIT_CWD: consumerRoot }, + log: () => {}, + }); + expect(result.skipped).toContain(foreignPs1); + expect(readFileSync(foreignPs1, 'utf8')).toContain('someone else'); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('ownership validation accepts our own sentinel ps1 in consumer .bin', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-pnpm-ps1-sentinel-')); + try { + const { packageRoot, consumerRoot, consumerDotBin } = + setupPnpmVirtualStore(tempDir); + const ourPs1 = join(consumerDotBin, `${CLI_BIN_NAME}.ps1`); + writeFileSync( + ourPs1, + `# ${mod.OWNERSHIP_SENTINEL}\n#!/usr/bin/env pwsh\n`, + ); + const result = mod.installNativeLaunchers({ + platform: 'win32', + packageRoot, + env: { INIT_CWD: consumerRoot }, + log: () => {}, + }); + expect(result.written).toContain(ourPs1); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); + +describe('installNativeLaunchers graceful error contract', () => { + function setupPackageRoot(tempDir: string): string { + const packageRoot = join(tempDir, 'pkg'); + mkdirSync(join(packageRoot, 'node_modules', 'bun', 'bin'), { + recursive: true, + }); + writeFileSync( + join(packageRoot, 'node_modules', 'bun', 'bin', 'bun.exe'), + 'fake', + ); + writeFileSync(join(packageRoot, 'index.ts'), '// entry'); + mkdirSync(join(tempDir, 'node_modules', '.bin'), { recursive: true }); + return packageRoot; + } + + it('returns the exact result contract on POSIX no-op', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-contract-posix-')); + try { + const packageRoot = setupPackageRoot(tempDir); + const result = mod.installNativeLaunchers({ + platform: 'darwin', + packageRoot, + log: () => {}, + }); + expect(result).toStrictEqual({ written: [], skipped: [], error: null }); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('returns the exact result contract on a successful win32 install', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-contract-ok-')); + try { + const packageRoot = setupPackageRoot(tempDir); + const result = mod.installNativeLaunchers({ + platform: 'win32', + packageRoot, + env: { INIT_CWD: tempDir }, + log: () => {}, + }); + expect(Object.keys(result).sort()).toStrictEqual( + ['error', 'skipped', 'written'].sort(), + ); + expect(result.error).toBeNull(); + expect(result.written.length).toBeGreaterThanOrEqual(2); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('returns bun-not-found error with empty written/skipped when Bun is absent', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-contract-nobun-')); + try { + const packageRoot = join(tempDir, 'pkg'); + // No node_modules/bun/bin/bun.exe created. + mkdirSync(packageRoot, { recursive: true }); + writeFileSync(join(packageRoot, 'index.ts'), '// entry'); + const result = mod.installNativeLaunchers({ + platform: 'win32', + packageRoot, + env: { INIT_CWD: tempDir }, + log: () => {}, + }); + expect(result).toStrictEqual({ + written: [], + skipped: [], + error: 'bun-not-found', + }); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('returns entry-not-found error with empty written/skipped when entry is absent', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-contract-noentry-')); + try { + const packageRoot = join(tempDir, 'pkg'); + mkdirSync(join(packageRoot, 'node_modules', 'bun', 'bin'), { + recursive: true, + }); + writeFileSync( + join(packageRoot, 'node_modules', 'bun', 'bin', 'bun.exe'), + 'fake', + ); + // No index.ts created. + const result = mod.installNativeLaunchers({ + platform: 'win32', + packageRoot, + env: { INIT_CWD: tempDir }, + log: () => {}, + }); + expect(result).toStrictEqual({ + written: [], + skipped: [], + error: 'entry-not-found', + }); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); + +describe('Bun install layout support boundary', () => { + it('CLI package.json declares a postinstall that Bun will run', () => { + const cliPkg = JSON.parse( + readFileSync(join(repoRoot, 'packages', 'cli', 'package.json'), 'utf8'), + ) as { scripts: Record }; + expect(cliPkg.scripts.postinstall).toMatch(/install-native-launchers/); + }); + + it('root trustedDependencies includes bun so Bun runs its lifecycle', () => { + const rootPkg = JSON.parse( + readFileSync(join(repoRoot, 'package.json'), 'utf8'), + ) as { trustedDependencies?: string[] }; + expect(rootPkg.trustedDependencies).toContain('bun'); + }); + + it('Bun hoisted-layout bin resolution: bun.exe is resolvable from package root', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-bun-layout-')); + try { + const packageRoot = join(tempDir, 'node_modules', CLI_SCOPE, CLI_NAME); + mkdirSync(join(packageRoot, 'node_modules', 'bun', 'bin'), { + recursive: true, + }); + writeFileSync( + join(packageRoot, 'node_modules', 'bun', 'bin', 'bun.exe'), + 'fake', + ); + writeFileSync(join(packageRoot, 'index.ts'), '// entry'); + mkdirSync(join(tempDir, 'node_modules', '.bin'), { recursive: true }); + const bunExe = mod.resolveBunExe(packageRoot); + expect(bunExe).toBeTruthy(); + expect(bunExe).toContain('bun.exe'); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('installer writes to consumer .bin for Bun hoisted layout via INIT_CWD', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-bun-initcwd-')); + try { + const packageRoot = join(tempDir, 'node_modules', CLI_SCOPE, CLI_NAME); + mkdirSync(join(packageRoot, 'node_modules', 'bun', 'bin'), { + recursive: true, + }); + writeFileSync( + join(packageRoot, 'node_modules', 'bun', 'bin', 'bun.exe'), + 'fake', + ); + writeFileSync(join(packageRoot, 'index.ts'), '// entry'); + const dotBin = join(tempDir, 'node_modules', '.bin'); + mkdirSync(dotBin, { recursive: true }); + mod.installNativeLaunchers({ + platform: 'win32', + packageRoot, + env: { INIT_CWD: tempDir }, + log: () => {}, + }); + expect(existsSync(join(dotBin, `${CLI_BIN_NAME}.cmd`))).toBe(true); + expect(existsSync(join(dotBin, `${CLI_BIN_NAME}.ps1`))).toBe(true); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('resolveBunExe prefers package-local Bun over a hoisted ancestor', () => { + // Since bun is a direct dependency, the package-local + // node_modules/bun/bin/bun.exe must be preferred over a hoisted copy in a + // parent node_modules. This prevents the launcher from referencing an + // external hoisted path that could disappear. + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-bun-local-pref-')); + try { + const packageRoot = join(tempDir, 'node_modules', CLI_SCOPE, CLI_NAME); + // Package-local Bun. + mkdirSync(join(packageRoot, 'node_modules', 'bun', 'bin'), { + recursive: true, + }); + writeFileSync( + join(packageRoot, 'node_modules', 'bun', 'bin', 'bun.exe'), + 'local-bun', + ); + // Hoisted ancestor Bun that would shadow if walk-up ran first. + mkdirSync(join(tempDir, 'node_modules', 'bun', 'bin'), { + recursive: true, + }); + writeFileSync( + join(tempDir, 'node_modules', 'bun', 'bin', 'bun.exe'), + 'hoisted-bun', + ); + const resolved = mod.resolveBunExe(packageRoot); + expect(resolved).toBeTruthy(); + expect(readFileSync(resolved, 'utf8')).toBe('local-bun'); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/tests/issue-2603-install-native-launchers-contract.test.ts b/scripts/tests/issue-2603-install-native-launchers-contract.test.ts new file mode 100644 index 000000000..3d24bd93f --- /dev/null +++ b/scripts/tests/issue-2603-install-native-launchers-contract.test.ts @@ -0,0 +1,302 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, + readFileSync, + chmodSync, +} from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { tmpdir } from 'node:os'; +import { createRequire } from 'node:module'; + +const thisFile = fileURLToPath(import.meta.url); +const repoRoot = resolve(thisFile, '..', '..', '..'); +const nodeRequire = createRequire(import.meta.url); +const cliModulePath = join( + repoRoot, + 'packages', + 'cli', + 'scripts', + 'install-native-launchers.cjs', +); + +function loadCliInstaller(): ReturnType { + const mod = nodeRequire(cliModulePath); + // Implementation-detail helpers are exposed under a private `_testing` + // namespace; merge them onto the top-level return for legacy `mod.X` access. + return { ...mod, ...mod._testing }; +} + +describe('installNativeLaunchers return shape consistency', () => { + it('returns error:null on POSIX no-op', () => { + const mod = loadCliInstaller(); + const result = mod.installNativeLaunchers({ + platform: 'darwin', + packageRoot: repoRoot, + log: () => {}, + }); + expect(result.error).toBeNull(); + expect(result.written).toEqual([]); + expect(result.skipped).toEqual([]); + }); + + it('returns bun-not-found when bundled Bun is absent', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-shape-nobun-')); + try { + const packageRoot = join(tempDir, 'pkg'); + mkdirSync(packageRoot, { recursive: true }); + writeFileSync(join(packageRoot, 'index.ts'), '// entry'); + const result = mod.installNativeLaunchers({ + platform: 'win32', + packageRoot, + env: {}, + log: () => {}, + }); + expect(result.error).toBe('bun-not-found'); + expect(result.written).toEqual([]); + expect(result.skipped).toEqual([]); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('returns entry-not-found when entry is absent', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-shape-noentry-')); + try { + const packageRoot = join(tempDir, 'pkg'); + mkdirSync(join(packageRoot, 'node_modules', 'bun', 'bin'), { + recursive: true, + }); + writeFileSync( + join(packageRoot, 'node_modules', 'bun', 'bin', 'bun.exe'), + 'fake', + ); + const result = mod.installNativeLaunchers({ + platform: 'win32', + packageRoot, + env: {}, + log: () => {}, + }); + expect(result.error).toBe('entry-not-found'); + expect(result.written).toEqual([]); + expect(result.skipped).toEqual([]); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('returns error:null on a successful win32 install', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-shape-ok-')); + try { + const packageRoot = join( + tempDir, + 'node_modules', + '@vybestack', + 'llxprt-code', + ); + mkdirSync(join(packageRoot, 'node_modules', 'bun', 'bin'), { + recursive: true, + }); + writeFileSync( + join(packageRoot, 'node_modules', 'bun', 'bin', 'bun.exe'), + 'fake', + ); + writeFileSync(join(packageRoot, 'index.ts'), '// entry'); + mkdirSync(join(tempDir, 'node_modules', '.bin'), { recursive: true }); + const result = mod.installNativeLaunchers({ + platform: 'win32', + packageRoot, + env: {}, + log: () => {}, + }); + expect(result.error).toBeNull(); + expect(result.written.length).toBeGreaterThanOrEqual(2); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); + +describe('installNativeLaunchers logging', () => { + it('logs skipped foreign launcher paths via the log callback', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-log-skip-')); + try { + const packageRoot = join( + tempDir, + 'node_modules', + '@vybestack', + 'llxprt-code', + ); + mkdirSync(join(packageRoot, 'node_modules', 'bun', 'bin'), { + recursive: true, + }); + writeFileSync( + join(packageRoot, 'node_modules', 'bun', 'bin', 'bun.exe'), + 'fake', + ); + writeFileSync(join(packageRoot, 'index.ts'), '// entry'); + const dotBin = join(tempDir, 'node_modules', '.bin'); + mkdirSync(dotBin, { recursive: true }); + const foreignCmd = join(dotBin, 'llxprt.cmd'); + writeFileSync(foreignCmd, '@echo off\necho someone else'); + const messages: string[] = []; + mod.installNativeLaunchers({ + platform: 'win32', + packageRoot, + env: {}, + log: (msg: string) => messages.push(msg), + }); + const skipMsg = messages.find((m) => m.includes(foreignCmd)); + expect(skipMsg, messages.join('\n')).toBeDefined(); + expect(skipMsg).toMatch(/Skipped foreign/i); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('repairs a zero-byte existing launcher (truncated install recovery)', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-zero-byte-')); + try { + const packageRoot = join( + tempDir, + 'node_modules', + '@vybestack', + 'llxprt-code', + ); + mkdirSync(join(packageRoot, 'node_modules', 'bun', 'bin'), { + recursive: true, + }); + writeFileSync( + join(packageRoot, 'node_modules', 'bun', 'bin', 'bun.exe'), + 'fake', + ); + writeFileSync(join(packageRoot, 'index.ts'), '// entry'); + const dotBin = join(tempDir, 'node_modules', '.bin'); + mkdirSync(dotBin, { recursive: true }); + // A zero-byte file cannot be a valid foreign shim (no sentinel, no + // target reference). The installer must repair it. + const zeroByteCmd = join(dotBin, 'llxprt.cmd'); + writeFileSync(zeroByteCmd, ''); + const result = mod.installNativeLaunchers({ + platform: 'win32', + packageRoot, + env: {}, + log: () => {}, + }); + expect(result.written).toContain(zeroByteCmd); + const content = readFileSync(zeroByteCmd, 'utf8'); + expect(content).toContain(mod.OWNERSHIP_SENTINEL); + expect(content.length).toBeGreaterThan(0); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('propagates the log callback to launcher writes (chmod warning is best-effort)', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-log-chmod-')); + try { + const packageRoot = join( + tempDir, + 'node_modules', + '@vybestack', + 'llxprt-code', + ); + mkdirSync(join(packageRoot, 'node_modules', 'bun', 'bin'), { + recursive: true, + }); + writeFileSync( + join(packageRoot, 'node_modules', 'bun', 'bin', 'bun.exe'), + 'fake', + ); + writeFileSync(join(packageRoot, 'index.ts'), '// entry'); + const dotBin = join(tempDir, 'node_modules', '.bin'); + mkdirSync(dotBin, { recursive: true }); + const messages: string[] = []; + // A successful write still surfaces via the written summary log. + const result = mod.installNativeLaunchers({ + platform: 'win32', + packageRoot, + env: {}, + log: (msg: string) => messages.push(msg), + }); + expect(result.error).toBeNull(); + expect(result.written.length).toBeGreaterThan(0); + // The summary "Wrote N native launcher" message is emitted via log. + const wroteMsg = messages.find((m) => + /Wrote \d+ native launcher/.test(m), + ); + expect(wroteMsg, messages.join('\n')).toBeDefined(); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); + +describe('installNativeLaunchers EACCES graceful handling', () => { + it('does not crash when a foreign shim is unreadable (EACCES)', () => { + // A foreign shim that exists but is unreadable (EACCES) must be treated + // as non-overwritable rather than crashing postinstall. The installer + // must skip it gracefully and return it in the skipped list. + if (process.platform === 'win32') { + // chmod 0 does not reliably prevent reads on Windows; skip on win32. + return; + } + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-eacces-')); + try { + const packageRoot = join( + tempDir, + 'node_modules', + '@vybestack', + 'llxprt-code', + ); + mkdirSync(join(packageRoot, 'node_modules', 'bun', 'bin'), { + recursive: true, + }); + writeFileSync( + join(packageRoot, 'node_modules', 'bun', 'bin', 'bun.exe'), + 'fake', + ); + writeFileSync(join(packageRoot, 'index.ts'), '// entry'); + const dotBin = join(tempDir, 'node_modules', '.bin'); + mkdirSync(dotBin, { recursive: true }); + const unreadableCmd = join(dotBin, 'llxprt.cmd'); + // Write a foreign shim (no sentinel, no package target reference). + writeFileSync(unreadableCmd, '@echo off\necho foreign shim'); + // Remove read permission so reading throws EACCES. + chmodSync(unreadableCmd, 0o000); + // The install must not throw; the unreadable file must be skipped. + const result = mod.installNativeLaunchers({ + platform: 'win32', + packageRoot, + env: {}, + log: () => {}, + }); + expect(result.error).toBeNull(); + expect(result.skipped).toContain(unreadableCmd); + } finally { + // Restore permission before cleanup so rmSync can remove the file. + try { + chmodSync(join(tempDir, 'node_modules', '.bin', 'llxprt.cmd'), 0o644); + } catch { + // ignore + } + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/tests/issue-2603-install.test.ts b/scripts/tests/issue-2603-install.test.ts new file mode 100644 index 000000000..41ff13ec3 --- /dev/null +++ b/scripts/tests/issue-2603-install.test.ts @@ -0,0 +1,868 @@ +import { describe, it, expect, afterAll } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, + mkdtempSync, + statSync, +} from 'node:fs'; +import { join, resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { tmpdir } from 'node:os'; +import { createRequire } from 'node:module'; + +const thisFile = fileURLToPath(import.meta.url); +const repoRoot = resolve(thisFile, '..', '..', '..'); +const nodeRequire = createRequire(import.meta.url); +const npmInvocation = nodeRequire('../lib/npm-command.cjs').npmInvocation as ( + args?: readonly string[], + options?: { + platform?: string; + execPath?: string; + env?: Record; + }, +) => { command: string; args: string[] }; +const tarCommand = nodeRequire('../lib/tar-command.cjs') as { + spawnTarList: (tarball: string, timeoutMs?: number) => { stdout: string }; + spawnTarListVerbose: ( + tarball: string, + member: string, + timeoutMs?: number, + ) => { stdout: string }; + findTarballName: (packOutput: string, cacheDir?: string) => string; +}; +const cliModulePath = join( + repoRoot, + 'packages', + 'cli', + 'scripts', + 'install-native-launchers.cjs', +); + +// Derive the CLI workspace package name from the manifest so this test +// adapts if the package is ever renamed or moved to a different scope. +const CLI_PKG_NAME = JSON.parse( + readFileSync(join(repoRoot, 'packages', 'cli', 'package.json'), 'utf8'), +).name as string; + +// Derive the evil-sibling package name from the real package name so these +// tests stay in sync automatically if the package is renamed or re-scoped. +const EVIL_PKG_NAME = `${CLI_PKG_NAME}-evil`; + +function loadCliInstaller(): ReturnType { + // Always require a fresh module instance so a previous test's cached state + // (e.g. resolved paths) cannot leak across runs. The module is stateless + // aside from its exported functions, but deleting the cache entry prevents + // any future module-level mutation from causing stale behavior. + delete nodeRequire.cache[cliModulePath]; + const mod = nodeRequire(cliModulePath); + // The module exposes implementation-detail helpers under a private + // `_testing` namespace; merge them onto the top-level return so existing + // `mod.X` references continue to work. + return { ...mod, ...mod._testing }; +} + +/** + * Per-process cache dir keyed by the CLI manifest fingerprint so concurrent + * test runs do not corrupt a shared cache. The fingerprint is derived from the + * package name + version + mtime, matching the release-pack helper's strategy. + */ +function cliManifestFingerprint(): string { + const cliPkgPath = join(repoRoot, 'packages', 'cli', 'package.json'); + let mtime = 0; + try { + mtime = statSync(cliPkgPath).mtimeMs; + } catch { + // stat failure is non-fatal. + } + return String(mtime).replace(/[^0-9]/g, '0'); +} + +const sharedCacheDir = join( + tmpdir(), + `llxprt-2603-cache-${process.pid}-${cliManifestFingerprint()}`, +); +let cachedTarball: string | null = null; + +// Clean up the per-process cache dir after all tests complete to prevent +// accumulation of stale tarball caches across repeated test runs. +afterAll(() => { + try { + rmSync(sharedCacheDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup; a failure here does not affect test results. + } +}); + +function findTarballName(packOutput: string): string { + return tarCommand.findTarballName(packOutput); +} + +function packCliWorkspace(): string { + if (cachedTarball && existsSync(cachedTarball)) { + return cachedTarball; + } + mkdirSync(sharedCacheDir, { recursive: true }); + const { command, args } = npmInvocation([ + 'pack', + '-w', + CLI_PKG_NAME, + '--pack-destination', + sharedCacheDir, + ]); + const result = spawnSync(command, args, { + cwd: repoRoot, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + timeout: 120_000, + }); + if (result.error) { + throw new Error(`npm pack -w spawn failed: ${result.error.message}`); + } + if (result.status !== 0) { + throw new Error( + `npm pack -w failed (exit ${result.status}, signal=${result.signal ?? 'none'}): ${result.stderr}`, + ); + } + const tarballName = findTarballName(result.stdout); + cachedTarball = join(sharedCacheDir, tarballName); + return cachedTarball; +} + +/** + * Delegates to the shared tar-command helper for tar listing. + */ +function spawnTarList(tarball: string): { stdout: string } { + return tarCommand.spawnTarList(tarball); +} + +function spawnTarListVerbose( + tarball: string, + member: string, +): { stdout: string } { + return tarCommand.spawnTarListVerbose(tarball, member); +} + +describe('CLI workspace tarball contents (actual release artifact)', () => { + it('includes the POSIX launcher at bin/llxprt', () => { + const tarball = packCliWorkspace(); + expect(existsSync(tarball)).toBe(true); + const { stdout } = spawnTarList(tarball); + // Use /\r?\n/ (not '\n') so Windows tar (bsdtar) CRLF output parses the + // same as POSIX tar output. + const files = stdout.split(/\r?\n/); + expect(files).toContain('package/bin/llxprt'); + expect(files).toContain('package/index.ts'); + expect(files).toContain('package/package.json'); + }, 120_000); + + it('includes the installer script', () => { + const tarball = packCliWorkspace(); + const { stdout } = spawnTarList(tarball); + const files = stdout.split(/\r?\n/); + expect(files).toContain('package/scripts/install-native-launchers.cjs'); + }, 120_000); + + it('does NOT include the old Node launcher (llxprt.cjs)', () => { + const tarball = packCliWorkspace(); + const { stdout } = spawnTarList(tarball); + const files = stdout.split(/\r?\n/); + expect(files.some((f) => f.endsWith('.cjs') && f.includes('bin/'))).toBe( + false, + ); + }, 120_000); + + it('declares a postinstall script in the CLI workspace package.json', () => { + const cliPkg = JSON.parse( + readFileSync(join(repoRoot, 'packages', 'cli', 'package.json'), 'utf8'), + ) as { scripts: Record; bin: Record }; + expect(cliPkg.scripts.postinstall).toContain('install-native-launchers'); + expect(cliPkg.bin.llxprt).toBe('bin/llxprt'); + }); + + it('ships bin/llxprt with executable mode in the tarball', () => { + const tarball = packCliWorkspace(); + const { stdout } = spawnTarListVerbose(tarball, 'package/bin/llxprt'); + // Match the POSIX permission string (e.g. -rwxr-xr-x). Both GNU tar and + // bsdtar emit this format in verbose mode. Check for 'x' in the owner + // position (character index 3 or 4 depending on type prefix). + expect(stdout).toMatch(/^.{0,1}[-bcCdDlMnpPs?]rwx/); + }, 120_000); +}); + +describe('install-native-launchers module (CLI workspace)', () => { + describe('cmd launcher generation', () => { + it('generates a cmd with no delayed expansion', () => { + const mod = loadCliInstaller(); + const cmd = mod.generateCmdLauncher('bun.exe', 'index.ts'); + expect(cmd).not.toMatch(/enableDelayedExpansion/i); + }); + + it('embeds the ownership sentinel', () => { + const mod = loadCliInstaller(); + const cmd = mod.generateCmdLauncher('bun.exe', 'index.ts'); + expect(cmd).toContain(mod.OWNERSHIP_SENTINEL); + }); + + it('directly invokes bun.exe with %* using %~dp0 prefix', () => { + const mod = loadCliInstaller(); + const cmd = mod.generateCmdLauncher('bun.exe', 'index.ts'); + expect(cmd).toMatch(/"%~dp0bun\.exe" "%~dp0index\.ts" %\*/); + }); + + it('exits with code 43 on missing Bun', () => { + const mod = loadCliInstaller(); + const cmd = mod.generateCmdLauncher('bun.exe', 'index.ts'); + expect(cmd).toContain('exit /b ' + mod.LAUNCHER_ERROR_EXIT_CODE); + expect(cmd).toMatch(/npm install|bun\.sh/i); + }); + + it('does not set LLXPRT_BUN_RELAUNCHED', () => { + const mod = loadCliInstaller(); + const cmd = mod.generateCmdLauncher('bun.exe', 'index.ts'); + expect(cmd).not.toMatch(/LLXPRT_BUN_RELAUNCHED/i); + }); + }); + + describe('PowerShell launcher generation', () => { + it('uses an argument array and propagates LASTEXITCODE', () => { + const mod = loadCliInstaller(); + const ps1 = mod.generatePs1Launcher('bun.exe', 'index.ts'); + expect(ps1).toContain('$allArgs = @($entry) + $args'); + expect(ps1).toContain('exit $LASTEXITCODE'); + }); + + it('supports pipeline input', () => { + const mod = loadCliInstaller(); + const ps1 = mod.generatePs1Launcher('bun.exe', 'index.ts'); + expect(ps1).toContain('ExpectingInput'); + expect(ps1).toContain('$input | & $bunExe'); + }); + + it('embeds the ownership sentinel', () => { + const mod = loadCliInstaller(); + const ps1 = mod.generatePs1Launcher('bun.exe', 'index.ts'); + expect(ps1).toContain(mod.OWNERSHIP_SENTINEL); + }); + + it('exits with code 43 on missing Bun', () => { + const mod = loadCliInstaller(); + const ps1 = mod.generatePs1Launcher('bun.exe', 'index.ts'); + expect(ps1).toContain('exit ' + mod.LAUNCHER_ERROR_EXIT_CODE); + }); + + it('does not set LLXPRT_BUN_RELAUNCHED', () => { + const mod = loadCliInstaller(); + const ps1 = mod.generatePs1Launcher('bun.exe', 'index.ts'); + expect(ps1).not.toMatch(/LLXPRT_BUN_RELAUNCHED/i); + }); + + it('uses forward-slash relative paths for Join-Path', () => { + const mod = loadCliInstaller(); + const ps1 = mod.generatePs1Launcher('sub/bun.exe', 'index.ts'); + expect(ps1).toContain("Join-Path $basedir 'sub/bun.exe'"); + }); + }); + + describe('ownership guard with real npm cmd-shim grammar', () => { + function resolveCmdShimPath(): string { + const candidates = [ + '/opt/homebrew/lib/node_modules/npm/node_modules/cmd-shim', + join( + process.env.HOME ?? '/root', + '.nvm/versions/node/npm/node_modules/cmd-shim', + ), + join(repoRoot, 'node_modules', 'cmd-shim'), + ]; + for (const candidate of candidates) { + try { + nodeRequire.resolve(candidate); + return candidate; + } catch { + /* try next */ + } + } + const { command: npmCmd, args: npmArgs } = npmInvocation(['root', '-g']); + const npmRoot = spawnSync(npmCmd, npmArgs, { + encoding: 'utf8', + timeout: 10_000, + }); + if (npmRoot.error) { + // spawn error; fall through to alternative discovery. + } else if (npmRoot.status === 0) { + const globalRoot = npmRoot.stdout.trim(); + const candidate = join(globalRoot, 'npm', 'node_modules', 'cmd-shim'); + try { + nodeRequire.resolve(candidate); + return candidate; + } catch { + // try npm's own location + } + } + // Cross-platform npm discovery: `which` does not exist on Windows. + // Use `where` on win32, or prefer process.execPath / npm_execpath when + // available to avoid spawning an extra process. + const npmExecPath = process.env.npm_execpath; + let npmDir: string | null = null; + if (npmExecPath) { + // npm_execpath is the CLI script path; npm lives in its parent dir. + npmDir = dirname(dirname(npmExecPath)); + } else { + const tool = process.platform === 'win32' ? 'where' : 'which'; + const npmCli = spawnSync(tool, ['npm'], { encoding: 'utf8' }); + if (npmCli.error) { + // spawn error (e.g. tool not installed); fall through to throw. + } else if (npmCli.status === 0) { + const lines = npmCli.stdout.trim().split('\n'); + if (lines.length > 0 && lines[0]) { + const npmBin = dirname(lines[0]); + npmDir = dirname(npmBin); + } + } + } + if (npmDir) { + const candidate = join(npmDir, 'node_modules', 'cmd-shim'); + try { + nodeRequire.resolve(candidate); + return candidate; + } catch { + // not there + } + } + throw new Error('cmd-shim not found'); + } + + function generateRealCmdShim(binLinkDir: string, target: string): string { + const cmdShimPath = resolveCmdShimPath(); + // Use the ACTUAL POSIX launcher (with its #!/bin/sh shebang) as the + // target. This is what npm cmd-shim sees in a real install: a shebanged + // script, which causes cmd-shim to emit an interpreter-first wrapper + // (referencing /bin/sh.exe AND the package target). Using a placeholder + // target without a shebang would only exercise the non-interpreter path + // and hide the parser bug where the first %dp0% reference is the + // interpreter, not the package target. + const launcherSource = join(repoRoot, 'packages', 'cli', 'bin', 'llxprt'); + const result = spawnSync( + 'node', + [ + '-e', + [ + 'const cmdShim = require(' + JSON.stringify(cmdShimPath) + ');', + 'const fs = require("fs");', + 'const path = require("path");', + 'const target = ' + JSON.stringify(target) + ';', + 'const link = ' + JSON.stringify(join(binLinkDir, 'llxprt')) + ';', + 'try { fs.mkdirSync(' + + JSON.stringify(binLinkDir) + + ', { recursive: true }); } catch {}', + 'try { fs.mkdirSync(path.dirname(target), { recursive: true }); fs.copyFileSync(' + + JSON.stringify(launcherSource) + + ', target); fs.chmodSync(target, 0o755); } catch {}', + 'cmdShim(target, link, function(err) {', + ' if (err) { console.error(err.message); process.exit(1); }', + ' process.exit(0);', + '});', + ].join(''), + ], + { encoding: 'utf8', timeout: 15_000 }, + ); + if (result.error) { + throw new Error( + `cmd-shim generation spawn failed: ${result.error.message}`, + ); + } + if (result.status !== 0) { + throw new Error( + `cmd-shim generation failed (exit ${result.status}, signal=${result.signal ?? 'none'}): ${result.stderr}`, + ); + } + return join(binLinkDir, 'llxprt.cmd'); + } + + it('recognizes a real npm cmd-shim (dp0 pattern) pointing to our package', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-cmd-shim-')); + try { + const packageRoot = join( + tempDir, + 'prefix', + 'lib', + 'node_modules', + '@vybestack', + CLI_PKG_NAME, + ); + const binTarget = join(packageRoot, 'bin', 'llxprt'); + const binLinkDir = join(tempDir, 'bin-link'); + const shimPath = generateRealCmdShim(binLinkDir, binTarget); + expect( + mod.pointsToOurPackage(shimPath, binLinkDir, packageRoot, 'cmd'), + ).toBe(true); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('rejects an npm cmd-shim pointing to a sibling package (evil)', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-evil-')); + try { + const evilRoot = join( + tempDir, + 'prefix', + 'lib', + 'node_modules', + '@vybestack', + EVIL_PKG_NAME, + ); + const binTarget = join(evilRoot, 'bin', 'llxprt'); + const binLinkDir = join(tempDir, 'bin-link'); + const ourPackageRoot = join( + tempDir, + 'prefix', + 'lib', + 'node_modules', + '@vybestack', + CLI_PKG_NAME, + ); + mkdirSync(ourPackageRoot, { recursive: true }); + const shimPath = generateRealCmdShim(binLinkDir, binTarget); + expect( + mod.pointsToOurPackage(shimPath, binLinkDir, ourPackageRoot, 'cmd'), + ).toBe(false); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + // cmd-shim ALWAYS emits .cmd AND .ps1 on ALL platforms (no process.platform + // gate in cmd-shim source — it unconditionally writes to + '.ps1'). These + // ps1 tests are valid on POSIX CI, not just Windows. Verified by reading + // the cmd-shim source (line ~234: writeFile(to + '.ps1', pwsh, 'utf8')). + it('recognizes a real npm ps1 shim ($basedir pattern) pointing to our package', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-ps1-shim-')); + try { + const packageRoot = join( + tempDir, + 'prefix', + 'lib', + 'node_modules', + '@vybestack', + CLI_PKG_NAME, + ); + const binTarget = join(packageRoot, 'bin', 'llxprt'); + const binLinkDir = join(tempDir, 'bin-link'); + generateRealCmdShim(binLinkDir, binTarget); + const ps1Path = join(binLinkDir, 'llxprt.ps1'); + expect( + mod.pointsToOurPackage(ps1Path, binLinkDir, packageRoot, 'ps1'), + ).toBe(true); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('rejects a ps1 shim pointing to a different package', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-ps1-evil-')); + try { + const evilRoot = join( + tempDir, + 'prefix', + 'lib', + 'node_modules', + '@vybestack', + EVIL_PKG_NAME, + ); + const binTarget = join(evilRoot, 'bin', 'llxprt'); + const binLinkDir = join(tempDir, 'bin-link'); + const ourPackageRoot = join( + tempDir, + 'prefix', + 'lib', + 'node_modules', + '@vybestack', + CLI_PKG_NAME, + ); + mkdirSync(ourPackageRoot, { recursive: true }); + generateRealCmdShim(binLinkDir, binTarget); + const ps1Path = join(binLinkDir, 'llxprt.ps1'); + expect( + mod.pointsToOurPackage(ps1Path, binLinkDir, ourPackageRoot, 'ps1'), + ).toBe(false); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('authorizes overwriting a file with our sentinel', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-sentinel-')); + try { + const filePath = join(tempDir, 'llxprt.cmd'); + writeFileSync( + filePath, + `REM ${mod.OWNERSHIP_SENTINEL}\n@echo off\necho old`, + ); + expect(mod.hasOwnershipSentinel(filePath)).toBe(true); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('does not authorize overwriting a foreign file', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-foreign-')); + try { + const filePath = join(tempDir, 'llxprt.cmd'); + writeFileSync(filePath, '@echo off\necho someone else'); + expect(mod.hasOwnershipSentinel(filePath)).toBe(false); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + }); + + describe('installNativeLaunchers platform gating', () => { + /** + * Creates a mock package layout with a fake bundled bun.exe and entry + * point. Used by multiple platform-gating tests to avoid duplicating the + * setup boilerplate (creating node_modules/bun/bin, writing bun.exe, + * writing index.ts). + */ + function ensureMockBunPackage(packageRoot: string): void { + mkdirSync(join(packageRoot, 'node_modules', 'bun', 'bin'), { + recursive: true, + }); + writeFileSync( + join(packageRoot, 'node_modules', 'bun', 'bin', 'bun.exe'), + 'fake', + ); + writeFileSync(join(packageRoot, 'index.ts'), '// entry'); + } + + it('is a no-op on POSIX', () => { + const mod = loadCliInstaller(); + const result = mod.installNativeLaunchers({ + platform: 'darwin', + packageRoot: repoRoot, + log: () => {}, + }); + expect(result.written).toEqual([]); + expect(result.skipped).toEqual([]); + }); + + it('generates launchers for global install (npm_config_global=true)', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-global-')); + try { + const packageRoot = join( + tempDir, + 'lib', + 'node_modules', + '@vybestack', + 'llxprt-code', + ); + const prefix = join(tempDir); + ensureMockBunPackage(packageRoot); + + const result = mod.installNativeLaunchers({ + platform: 'win32', + packageRoot, + env: { + npm_config_global: 'true', + npm_config_prefix: prefix, + }, + log: () => {}, + }); + expect(result.written.length).toBe(2); + expect(existsSync(join(prefix, 'llxprt.cmd'))).toBe(true); + expect(existsSync(join(prefix, 'llxprt.ps1'))).toBe(true); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('generates launchers for local install (INIT_CWD set)', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-local-')); + try { + const packageRoot = join( + tempDir, + 'node_modules', + '@vybestack', + 'llxprt-code', + ); + const initCwd = join(tempDir, 'consumer'); + ensureMockBunPackage(packageRoot); + const dotBin = join(initCwd, 'node_modules', '.bin'); + mkdirSync(dotBin, { recursive: true }); + + const result = mod.installNativeLaunchers({ + platform: 'win32', + packageRoot, + env: { + npm_config_global: '', + INIT_CWD: initCwd, + }, + log: () => {}, + }); + expect(result.written.length).toBe(2); + expect(existsSync(join(dotBin, 'llxprt.cmd'))).toBe(true); + expect(existsSync(join(dotBin, 'llxprt.ps1'))).toBe(true); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('does not treat npm_config_prefix as bin dir for local install', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-noprefix-')); + try { + const packageRoot = join( + tempDir, + 'node_modules', + '@vybestack', + 'llxprt-code', + ); + const initCwd = join(tempDir, 'consumer'); + ensureMockBunPackage(packageRoot); + mkdirSync(join(initCwd, 'node_modules', '.bin'), { recursive: true }); + + mod.installNativeLaunchers({ + platform: 'win32', + packageRoot, + env: { + npm_config_global: '', + npm_config_prefix: initCwd, + INIT_CWD: initCwd, + }, + log: () => {}, + }); + expect(existsSync(join(initCwd, 'llxprt.cmd'))).toBe(false); + expect( + existsSync(join(initCwd, 'node_modules', '.bin', 'llxprt.cmd')), + ).toBe(true); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('derives scoped package bin from nearest node_modules ancestor', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-scoped-')); + try { + const packageRoot = join( + tempDir, + 'consumer', + 'node_modules', + '@vybestack', + 'llxprt-code', + ); + ensureMockBunPackage(packageRoot); + const dotBin = join(tempDir, 'consumer', 'node_modules', '.bin'); + mkdirSync(dotBin, { recursive: true }); + + const result = mod.installNativeLaunchers({ + platform: 'win32', + packageRoot, + env: {}, + log: () => {}, + }); + expect(result.written.length).toBe(2); + expect(existsSync(join(dotBin, 'llxprt.cmd'))).toBe(true); + expect(existsSync(join(dotBin, 'llxprt.ps1'))).toBe(true); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('handles npm_config_global undefined (not just empty)', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-undef-global-')); + try { + const packageRoot = join( + tempDir, + 'node_modules', + '@vybestack', + 'llxprt-code', + ); + ensureMockBunPackage(packageRoot); + const dotBin = join(tempDir, 'node_modules', '.bin'); + mkdirSync(dotBin, { recursive: true }); + + const result = mod.installNativeLaunchers({ + platform: 'win32', + packageRoot, + env: { npm_config_global: undefined }, + log: () => {}, + }); + expect(result.written.length).toBe(2); + expect(existsSync(join(dotBin, 'llxprt.cmd'))).toBe(true); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('derives bin from packageRoot even when INIT_CWD is unrelated (npx cache shape)', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-npx-')); + try { + const packageRoot = join( + tempDir, + 'npx-cache', + 'node_modules', + '@vybestack', + 'llxprt-code', + ); + ensureMockBunPackage(packageRoot); + const packageDotBin = join( + tempDir, + 'npx-cache', + 'node_modules', + '.bin', + ); + mkdirSync(packageDotBin, { recursive: true }); + + const unrelatedInitCwd = join(tempDir, 'somewhere-else'); + mod.installNativeLaunchers({ + platform: 'win32', + packageRoot, + env: { INIT_CWD: unrelatedInitCwd }, + log: () => {}, + }); + expect(existsSync(join(packageDotBin, 'llxprt.cmd'))).toBe(true); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('does not write consumer-root wrappers for local install', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-no-consumer-')); + try { + const packageRoot = join( + tempDir, + 'consumer', + 'node_modules', + '@vybestack', + 'llxprt-code', + ); + const initCwd = join(tempDir, 'consumer'); + ensureMockBunPackage(packageRoot); + mkdirSync(join(initCwd, 'node_modules', '.bin'), { recursive: true }); + + mod.installNativeLaunchers({ + platform: 'win32', + packageRoot, + env: { INIT_CWD: initCwd }, + log: () => {}, + }); + expect(existsSync(join(initCwd, 'llxprt.cmd'))).toBe(false); + expect(existsSync(join(initCwd, 'llxprt.ps1'))).toBe(false); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + }); + + describe('nearestNodeModulesBin derivation', () => { + it('finds node_modules/.bin for scoped package', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-nm-scoped-')); + try { + const packageRoot = join( + tempDir, + 'project', + 'node_modules', + '@vybestack', + 'llxprt-code', + ); + mkdirSync(packageRoot, { recursive: true }); + const dotBin = join(tempDir, 'project', 'node_modules', '.bin'); + mkdirSync(dotBin, { recursive: true }); + expect(mod.nearestNodeModulesBin(packageRoot)).toBe(dotBin); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('finds node_modules/.bin for unscoped package', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-nm-unscoped-')); + try { + const packageRoot = join( + tempDir, + 'project', + 'node_modules', + 'llxprt-code', + ); + mkdirSync(packageRoot, { recursive: true }); + const dotBin = join(tempDir, 'project', 'node_modules', '.bin'); + mkdirSync(dotBin, { recursive: true }); + expect(mod.nearestNodeModulesBin(packageRoot)).toBe(dotBin); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('returns null when no enclosing node_modules exists', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-nm-none-')); + try { + expect(mod.nearestNodeModulesBin(tempDir)).toBeNull(); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + }); + + describe('cmd launcher exit-code preservation', () => { + it('preserves the child exit code exactly (no remapping)', () => { + const mod = loadCliInstaller(); + const cmd = mod.generateCmdLauncher('bun.exe', 'index.ts'); + // cmd cannot reliably distinguish a launch failure from a legitimate + // nonzero exit, so it must NOT remap any errorlevel. It preserves + // %ERRORLEVEL% directly. + expect(cmd).toContain('exit /b %ERRORLEVEL%'); + expect(cmd).not.toMatch(/LLXPRT_EXITCODE/); + expect(cmd).not.toMatch(/LLXPRT_LAUNCH_FAIL/); + }); + + it('does not remap errorlevel 5, 193, or 9009', () => { + const mod = loadCliInstaller(); + const cmd = mod.generateCmdLauncher('bun.exe', 'index.ts'); + // These errorlevels may be legitimate CLI exit codes; remapping them + // would corrupt the child's real exit status. + expect(cmd).not.toMatch(/equ 9009/); + expect(cmd).not.toMatch(/equ 193/); + expect(cmd).not.toMatch(/equ 5\b/); + }); + + it('still exits 43 for missing bun (existence preflight)', () => { + const mod = loadCliInstaller(); + const cmd = mod.generateCmdLauncher('bun.exe', 'index.ts'); + expect(cmd).toContain('goto :LLXPRT_NO_BUN'); + expect(cmd).toContain('exit /b ' + mod.LAUNCHER_ERROR_EXIT_CODE); + }); + }); + + describe('ps1 launcher launch-failure diagnostics', () => { + it('wraps invocation in try/catch for native launch exceptions', () => { + const mod = loadCliInstaller(); + const ps1 = mod.generatePs1Launcher('bun.exe', 'index.ts'); + expect(ps1).toContain('try {'); + expect(ps1).toContain('} catch {'); + expect(ps1).toContain('exit ' + mod.LAUNCHER_ERROR_EXIT_CODE); + }); + + it('still propagates LASTEXITCODE for normal nonzero exits', () => { + const mod = loadCliInstaller(); + const ps1 = mod.generatePs1Launcher('bun.exe', 'index.ts'); + expect(ps1).toContain('exit $LASTEXITCODE'); + }); + }); +}); diff --git a/scripts/tests/issue-2603-launcher-hardening.test.ts b/scripts/tests/issue-2603-launcher-hardening.test.ts new file mode 100644 index 000000000..781a11c3a --- /dev/null +++ b/scripts/tests/issue-2603-launcher-hardening.test.ts @@ -0,0 +1,351 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + * + * Focused tests for the POSIX launcher hardening pass (issue #2603 correction): + * - readlink failure: immediate exit 43 (not loop spin) + * - sed extraction anchored at the start of the JSON key line + * - exact-pin detection: strict X.Y.Z only, not digit-leading ranges + * - entry point: exactly packageRoot/index.ts (no parent walk) + * + * Extracted from issue-2603-launcher.test.ts to keep files under the max-lines + * lint limit. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { + existsSync, + mkdtempSync, + mkdirSync, + writeFileSync, + rmSync, + copyFileSync, + chmodSync, + readFileSync, +} from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { tmpdir } from 'node:os'; + +const thisFile = fileURLToPath(import.meta.url); +const repoRoot = resolve(thisFile, '..', '..', '..'); +const launcherPath = join(repoRoot, 'packages', 'cli', 'bin', 'llxprt'); +const repoBun = join(repoRoot, 'node_modules', 'bun', 'bin', 'bun.exe'); + +const LAUNCHER_FAILURE_EXIT = 43; +const SHORT_LAUNCH_TIMEOUT_MS = 15_000; +const STANDARD_LAUNCH_TIMEOUT_MS = 30_000; + +function assertNoSpawnError( + result: { error?: NodeJS.ErrnoException | undefined; status: number | null }, + label: string, +): void { + if (result.error) { + throw new Error( + `${label}: spawn failed: ${result.error.message} (code=${result.error.code ?? 'none'})`, + ); + } +} + +function ensureBun(): string { + if (existsSync(repoBun)) { + return repoBun; + } + // Use POSIX-standard 'command -v' instead of non-standard 'which'. + const whichResult = spawnSync('sh', ['-c', 'command -v bun'], { + encoding: 'utf8', + }); + if (whichResult.error) { + throw new Error(`Bun discovery spawn failed: ${whichResult.error.message}`); + } + if (whichResult.status === 0 && whichResult.stdout.trim()) { + return whichResult.stdout.trim(); + } + throw new Error('Bun not found for test setup'); +} + +function realBunVersion(): string { + const bunPkgPath = join(repoRoot, 'node_modules', 'bun', 'package.json'); + const bunPkg = JSON.parse(readFileSync(bunPkgPath, 'utf8')); + if (typeof bunPkg.version === 'string' && bunPkg.version.length > 0) { + return bunPkg.version; + } + throw new Error( + `Bun package.json at ${bunPkgPath} has no valid version field; ` + + 'the repo installation appears broken.', + ); +} + +function makeEntry(pkgRoot: string, code: string): void { + writeFileSync(join(pkgRoot, 'index.ts'), `#!/usr/bin/env -S bun\n${code}\n`); +} + +describe('POSIX launcher readlink-failure and hop-bound hardening', () => { + it('exits 43 on readlink failure rather than preserving the same symlink', () => { + // On readlink failure (e.g. permission denied, dangling symlink target), + // the launcher must immediately emit an actionable symlink-resolution + // error and exit 43. It must NOT fall back to preserving the same + // $_llxprt_self value (which would cause the while loop to spin + // MAX_SYMLINK_HOPS iterations before bailing). We verify at the source + // level that a readlink failure is handled by exiting 43 directly, not by + // falling through to a self-preservation fallback. + const source = readFileSync(launcherPath, 'utf8'); + expect(source).not.toMatch( + /readlink -- "\$_llxprt_self" 2>\/dev\/null \|\| printf '%s\\n' "\$_llxprt_self"/, + ); + }); + + it('terminates a circular symlink chain rather than looping forever', () => { + // A bounded loop (MAX_SYMLINK_HOPS) guards against pathological symlink + // chains. This is a hop bound, NOT visited-path cycle detection. + const source = readFileSync(launcherPath, 'utf8'); + expect(source).toMatch(/MAX_SYMLINK_HOPS\s*=\s*\d+/); + expect(source).toMatch(/symlink resolution exceeded maximum hops/i); + expect(source).toMatch(/_llxprt_hops.*gt.*MAX_SYMLINK_HOPS/); + }); +}); + +describe('POSIX launcher sed extraction anchoring', () => { + it('anchors the bun dependency sed extraction at the start of the JSON key line', () => { + // npm pretty-prints package.json, so each dependency key appears at the + // start of its own line (with leading whitespace). The sed extraction must + // anchor at the start of the line (after optional whitespace) so it does + // not match "bun" appearing inside another key name on the same line. + const source = readFileSync(launcherPath, 'utf8'); + expect(source).toMatch(/s\/\^.*"bun"/); + expect(source).not.toMatch(/s\/\.\*"bun"/); + expect(source).toMatch(/s\/\^.*"version"/); + expect(source).not.toMatch(/s\/\.\*"version"/); + }); +}); + +describe('POSIX launcher exact-pin detection', () => { + it('treats only an exact X.Y.Z as a pin, not 1.x', () => { + // The exact-pin detection must recognize ONLY a plain exact semver pin + // (e.g. "1.3.14"), not any digit-leading string. A range like "1.x" starts + // with a digit but is NOT an exact version. + const source = readFileSync(launcherPath, 'utf8'); + expect(source).toMatch(/_llxprt_is_exact_pin/); + expect(source).not.toMatch(/\[0-9\]\*\s*\)\s*;;.*exact/); + }); +}); + +describe('POSIX launcher entry-point safety', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'llxprt-entry-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('exits 43 when index.ts is missing but an ancestor index.ts exists', () => { + // The entry point is known to be exactly packageRoot/index.ts. The launcher + // must NOT walk parent directories looking for index.ts — an unrelated + // ancestor index.ts must not be executed. + const pkgRoot = join(tempDir, 'pkg'); + const binDir = join(pkgRoot, 'bin'); + mkdirSync(binDir, { recursive: true }); + const launcherTarget = join(binDir, 'llxprt'); + copyFileSync(launcherPath, launcherTarget); + chmodSync(launcherTarget, 0o755); + // NO index.ts in pkgRoot. Place an unrelated index.ts in an ancestor. + makeEntry(tempDir, 'process.exit(0);'); + const bunPath = ensureBun(); + const bunDir = join(pkgRoot, 'node_modules', 'bun', 'bin'); + mkdirSync(bunDir, { recursive: true }); + copyFileSync(bunPath, join(bunDir, 'bun.exe')); + const result = spawnSync(launcherTarget, [], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: 15_000, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expect(result.status).toBe(LAUNCHER_FAILURE_EXIT); + expect(result.stderr).toMatch(/entry point|index\.ts|corrupt/i); + }, 15_000); +}); + +describe('POSIX launcher range-pin acceptance', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'llxprt-range-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('accepts a hoisted Bun when the pin is a range like 1.x (not treated as exact)', () => { + // A digit-leading range like "1.x" is NOT an exact version pin. The + // launcher must NOT treat it as exact (which would reject every candidate + // because no candidate version equals the literal string "1.x"). + const bunVersion = realBunVersion(); + const consumerDir = join(tempDir, 'consumer-range-pin'); + const pkgRoot = join( + consumerDir, + 'node_modules', + '@vybestack', + 'llxprt-code', + ); + const binDir = join(pkgRoot, 'bin'); + mkdirSync(binDir, { recursive: true }); + const launcherTarget = join(binDir, 'llxprt'); + copyFileSync(launcherPath, launcherTarget); + chmodSync(launcherTarget, 0o755); + makeEntry(pkgRoot, 'process.exit(0);'); + + const hoistedBunDir = join(consumerDir, 'node_modules', 'bun', 'bin'); + mkdirSync(hoistedBunDir, { recursive: true }); + copyFileSync(ensureBun(), join(hoistedBunDir, 'bun.exe')); + writeFileSync( + join(consumerDir, 'node_modules', 'bun', 'package.json'), + JSON.stringify({ name: 'bun', version: bunVersion }, null, 2), + ); + writeFileSync( + join(pkgRoot, 'package.json'), + JSON.stringify( + { + name: '@vybestack/llxprt-code', + dependencies: { bun: '1.x' }, + }, + null, + 2, + ), + ); + + const result = spawnSync(launcherTarget, [], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: 30_000, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + assertNoSpawnError(result, 'accepts hoisted Bun with range pin 1.x'); + expect(result.status, result.stderr).toBe(0); + }, 30_000); +}); +describe('POSIX launcher prerelease semver pin', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'llxprt-prerel-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it( + 'accepts a hoisted Bun whose prerelease version matches the exact pin', + () => { + // A prerelease pin like "1.3.14-beta.1" IS an exact version and must be + // treated as such so the strict equality check is applied. The hoisted + // Bun's package.json reports the matching prerelease version. + const bunVersion = realBunVersion(); + // Use the real Bun version with a synthetic prerelease suffix so the + // binary is valid but the pin is a prerelease string. + const prereleaseVersion = `${bunVersion}-beta.1`; + const consumerDir = join(tempDir, 'consumer-prerelease'); + const pkgRoot = join( + consumerDir, + 'node_modules', + '@vybestack', + 'llxprt-code', + ); + const binDir = join(pkgRoot, 'bin'); + mkdirSync(binDir, { recursive: true }); + const launcherTarget = join(binDir, 'llxprt'); + copyFileSync(launcherPath, launcherTarget); + chmodSync(launcherTarget, 0o755); + makeEntry(pkgRoot, 'process.exit(0);'); + + const hoistedBunDir = join(consumerDir, 'node_modules', 'bun', 'bin'); + mkdirSync(hoistedBunDir, { recursive: true }); + copyFileSync(ensureBun(), join(hoistedBunDir, 'bun.exe')); + writeFileSync( + join(consumerDir, 'node_modules', 'bun', 'package.json'), + JSON.stringify({ name: 'bun', version: prereleaseVersion }, null, 2), + ); + writeFileSync( + join(pkgRoot, 'package.json'), + JSON.stringify( + { + name: '@vybestack/llxprt-code', + dependencies: { bun: prereleaseVersion }, + }, + null, + 2, + ), + ); + + const result = spawnSync(launcherTarget, [], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: STANDARD_LAUNCH_TIMEOUT_MS, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + assertNoSpawnError(result, 'prerelease pin matches'); + expect(result.status, result.stderr).toBe(0); + }, + STANDARD_LAUNCH_TIMEOUT_MS, + ); + + it( + 'rejects a hoisted Bun whose version does not match the prerelease pin', + () => { + // The package pins bun "1.3.14-beta.1" but the hoisted Bun reports a + // different version. The launcher must reject this mismatch because the + // prerelease pin is recognized as an exact pin. + const bunVersion = realBunVersion(); + const consumerDir = join(tempDir, 'consumer-prerelease-mismatch'); + const pkgRoot = join( + consumerDir, + 'node_modules', + '@vybestack', + 'llxprt-code', + ); + const binDir = join(pkgRoot, 'bin'); + mkdirSync(binDir, { recursive: true }); + const launcherTarget = join(binDir, 'llxprt'); + copyFileSync(launcherPath, launcherTarget); + chmodSync(launcherTarget, 0o755); + makeEntry(pkgRoot, 'process.exit(0);'); + + const hoistedBunDir = join(consumerDir, 'node_modules', 'bun', 'bin'); + mkdirSync(hoistedBunDir, { recursive: true }); + copyFileSync(ensureBun(), join(hoistedBunDir, 'bun.exe')); + writeFileSync( + join(consumerDir, 'node_modules', 'bun', 'package.json'), + JSON.stringify({ name: 'bun', version: bunVersion }, null, 2), + ); + writeFileSync( + join(pkgRoot, 'package.json'), + JSON.stringify( + { + name: '@vybestack/llxprt-code', + // Pin a prerelease version that does NOT match the installed Bun. + dependencies: { bun: '1.3.14-beta.1' }, + }, + null, + 2, + ), + ); + + const result = spawnSync(launcherTarget, [], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: SHORT_LAUNCH_TIMEOUT_MS, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + assertNoSpawnError(result, 'prerelease pin mismatch'); + expect(result.status).toBe(LAUNCHER_FAILURE_EXIT); + expect(result.stderr).toMatch(/bundled Bun runtime was not found/i); + }, + SHORT_LAUNCH_TIMEOUT_MS, + ); +}); diff --git a/scripts/tests/issue-2603-launcher-signals.test.ts b/scripts/tests/issue-2603-launcher-signals.test.ts new file mode 100644 index 000000000..d907c876b --- /dev/null +++ b/scripts/tests/issue-2603-launcher-signals.test.ts @@ -0,0 +1,177 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawnSync, spawn } from 'node:child_process'; +import { + existsSync, + mkdtempSync, + mkdirSync, + writeFileSync, + rmSync, + copyFileSync, + chmodSync, + readFileSync, +} from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { tmpdir } from 'node:os'; + +const thisFile = fileURLToPath(import.meta.url); +const repoRoot = resolve(thisFile, '..', '..', '..'); +const launcherPath = join(repoRoot, 'packages', 'cli', 'bin', 'llxprt'); +const repoBun = join(repoRoot, 'node_modules', 'bun', 'bin', 'bun.exe'); + +// Constrained PATH used to prove the launcher needs no global Bun/Node — only +// /bin/sh (POSIX shell) and core utilities are expected. This intentionally +// excludes /usr/local/bin so the test cannot accidentally resolve a globally +// installed bun. This is POSIX-only; Windows has no /usr/bin:/bin. +const CONSTRAINED_POSIX_PATH = '/usr/bin:/bin'; + +// Bun is a declared root dependency and test prerequisite: the launcher exec's +// it directly. A missing Bun means the repo install is broken — skipping would +// hide that, so we throw rather than mark tests as skipped. +function ensureBun(): string { + if (existsSync(repoBun)) { + return repoBun; + } + // Use POSIX-standard 'command -v' instead of non-standard 'which' for + // portability on minimal container images and BusyBox environments. + const cmdVResult = spawnSync('sh', ['-c', 'command -v bun'], { + encoding: 'utf8', + }); + if (cmdVResult.status === 0 && cmdVResult.stdout.trim()) { + return cmdVResult.stdout.trim(); + } + throw new Error('Bun not found for test setup'); +} + +function makeEntry(pkgRoot: string, code: string): void { + writeFileSync(join(pkgRoot, 'index.ts'), `#!/usr/bin/env -S bun\n${code}\n`); +} + +function makeLayout( + tempDir: string, + opts: { withBun?: boolean; withIndex?: boolean; entryCode?: string } = {}, +): { pkgRoot: string; launcherTarget: string } { + const pkgRoot = join(tempDir, 'pkg'); + const binDir = join(pkgRoot, 'bin'); + mkdirSync(binDir, { recursive: true }); + + const launcherTarget = join(binDir, 'llxprt'); + copyFileSync(launcherPath, launcherTarget); + chmodSync(launcherTarget, 0o755); + + if (opts.withIndex !== false) { + makeEntry(pkgRoot, opts.entryCode ?? 'process.exit(0);'); + } + + if (opts.withBun !== false) { + const bunPath = ensureBun(); + const bunDir = join(pkgRoot, 'node_modules', 'bun', 'bin'); + mkdirSync(bunDir, { recursive: true }); + copyFileSync(bunPath, join(bunDir, 'bun.exe')); + } + + return { pkgRoot, launcherTarget }; +} + +// POSIX-only: signal semantics (SIGINT/SIGTERM process replacement via exec) +// require the POSIX launcher. Windows has no POSIX signals and the launcher +// path is a .cmd/.ps1, not this POSIX shell script. Skip explicitly on Windows. +describe.skipIf(process.platform === 'win32')( + 'POSIX launcher signal behavior', + () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'llxprt-sig-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + function makeLongRunning(dir: string): { + pkgRoot: string; + launcherTarget: string; + pidFile: string; + } { + const pidFile = join(dir, 'child-pid.txt'); + const { pkgRoot, launcherTarget } = makeLayout(dir, { + entryCode: [ + 'const fs = require("fs");', + `fs.writeFileSync(${JSON.stringify(pidFile)}, String(process.pid));`, + 'process.stdin.resume();', + // Pause stdin on exit so a clean shutdown does not leave it flowing. + 'process.on("exit", () => { try { process.stdin.pause(); } catch {} });', + ].join('\n'), + }); + return { pkgRoot, launcherTarget, pidFile }; + } + + // Parameterized signal test: both SIGINT and SIGTERM must reach the child + // process directly via the launcher's exec (process replacement). The POSIX + // launcher uses `exec "$bun" "$entry" "$@"`, so the child replaces the shell + // and signals are delivered to the actual Bun process, not a parent shell. + function testSignalDelivery(signal: NodeJS.Signals): void { + it(`${signal} reaches the child directly via exec (process replacement)`, () => { + const { pkgRoot, launcherTarget, pidFile } = makeLongRunning(tempDir); + const child = spawn(launcherTarget, [], { + cwd: pkgRoot, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, PATH: CONSTRAINED_POSIX_PATH }, + }); + + let exited = false; + let exitSignal: NodeJS.Signals | null = null; + child.on('exit', (_code, sig) => { + exited = true; + exitSignal = sig; + }); + + let waited = 0; + const wait = setInterval(() => { + if (existsSync(pidFile) || waited > 50) { + clearInterval(wait); + if (existsSync(pidFile)) { + const childPid = parseInt( + readFileSync(pidFile, 'utf8').trim(), + 10, + ); + try { + process.kill(childPid, signal); + } catch { + child.kill(signal); + } + } else { + child.kill(signal); + } + } + waited++; + }, 100); + + setTimeout(() => { + clearInterval(wait); + if (!exited) { + child.kill('SIGKILL'); + } + }, 15_000).unref(); + + return new Promise((resolve) => { + child.on('exit', () => { + expect(exited).toBe(true); + expect(exitSignal).toBe(signal); + resolve(); + }); + }); + }, 20_000); + } + + testSignalDelivery('SIGINT'); + testSignalDelivery('SIGTERM'); + }, +); diff --git a/scripts/tests/issue-2603-launcher-source-workspace.test.ts b/scripts/tests/issue-2603-launcher-source-workspace.test.ts new file mode 100644 index 000000000..10fec68d6 --- /dev/null +++ b/scripts/tests/issue-2603-launcher-source-workspace.test.ts @@ -0,0 +1,299 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + * + * Behavioral tests for the POSIX launcher's source-workspace resolution path + * (issue #2610 regression: the launcher must find the hoisted root Bun when + * run from the source workspace, without weakening the installed-package + * boundary). Extracted from issue-2603-launcher.test.ts to keep files under + * the max-lines lint limit. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { + existsSync, + mkdtempSync, + mkdirSync, + writeFileSync, + rmSync, + copyFileSync, + chmodSync, + readFileSync, + statSync, +} from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { tmpdir } from 'node:os'; + +const thisFile = fileURLToPath(import.meta.url); +const repoRoot = resolve(thisFile, '..', '..', '..'); +const launcherPath = join(repoRoot, 'packages', 'cli', 'bin', 'llxprt'); +const repoBun = join(repoRoot, 'node_modules', 'bun', 'bin', 'bun.exe'); + +// Exit code used by packages/cli/bin/llxprt when the bundled Bun runtime is +// missing, corrupt, or an unrecognized native format. Mirrors the +// LAUNCHER_ERROR_EXIT_CODE constant in install-native-launchers.cjs. +const LAUNCHER_FAILURE_EXIT = 43; + +function ensureBun(): string { + if (existsSync(repoBun) && statSync(repoBun).isFile()) { + return repoBun; + } + const whichResult = spawnSync('which', ['bun'], { encoding: 'utf8' }); + if (whichResult.status === 0) { + const bunPath = whichResult.stdout.trim(); + // Validate the discovered path is an existing regular file so + // copyFileSync gets a clear diagnostic instead of a low-level OS error. + if (bunPath && existsSync(bunPath) && statSync(bunPath).isFile()) { + return bunPath; + } + } + throw new Error('Bun not found for test setup'); +} + +function makeEntry(pkgRoot: string, code: string): void { + writeFileSync(join(pkgRoot, 'index.ts'), `#!/usr/bin/env -S bun\n${code}\n`); +} + +function isPackageJson(v: unknown): v is { version: string } { + return ( + v !== null && + typeof v === 'object' && + !Array.isArray(v) && + typeof (v as Record).version === 'string' + ); +} + +function realBunVersion(): string { + const bunPkgPath = join(repoRoot, 'node_modules', 'bun', 'package.json'); + // Bun is a declared dependency and test prerequisite (see root + // trustedDependencies). A missing/unreadable version here indicates a + // broken installation; throw rather than fall back to a hardcoded version + // that would become stale on the next Bun upgrade. + const bunPkg: unknown = JSON.parse(readFileSync(bunPkgPath, 'utf8')); + if (isPackageJson(bunPkg) && bunPkg.version.length > 0) { + return bunPkg.version; + } + throw new Error( + `Bun package.json at ${bunPkgPath} has no valid version field; ` + + 'the repo installation appears broken.', + ); +} + +describe('POSIX launcher source-workspace resolution', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'llxprt-source-ws-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + /** + * Builds a synthetic source-workspace layout that mirrors the real repo: + * /packages/cli/bin/llxprt (launcher copy) + * /packages/cli/index.ts (entry) + * /packages/cli/package.json (CLI manifest with bun pin) + * /package.json (root manifest with workspaces) + * /node_modules/bun/bin/bun.exe (hoisted root Bun) + * The launcher sits OUTSIDE any node_modules, so resolution must use the + * verified-workspace-root path (option 3). + */ + function makeSourceWorkspace( + baseDir: string, + opts: { + bunVersion?: string; + cliPin?: string; + rootWorkspaces?: string[]; + rootPkgName?: string; + cliPkgName?: string; + withBunPackageJson?: boolean; + entryCode?: string; + } = {}, + ): { wsRoot: string; pkgRoot: string; launcherTarget: string } { + const wsRoot = join(baseDir, 'repo'); + const pkgRoot = join(wsRoot, 'packages', 'cli'); + const binDir = join(pkgRoot, 'bin'); + mkdirSync(binDir, { recursive: true }); + + const launcherTarget = join(binDir, 'llxprt'); + copyFileSync(launcherPath, launcherTarget); + chmodSync(launcherTarget, 0o755); + + const bunVersion = opts.bunVersion ?? realBunVersion(); + const cliPin = opts.cliPin ?? bunVersion; + const cliName = opts.cliPkgName ?? '@vybestack/llxprt-code'; + writeFileSync( + join(pkgRoot, 'package.json'), + JSON.stringify( + { name: cliName, version: '0.10.0', dependencies: { bun: cliPin } }, + null, + 2, + ), + ); + + makeEntry(pkgRoot, opts.entryCode ?? 'process.exit(0);'); + + const rootName = opts.rootPkgName ?? cliName; + const workspaces = opts.rootWorkspaces ?? [ + 'packages/tools', + 'packages/cli', + 'packages/core', + ]; + writeFileSync( + join(wsRoot, 'package.json'), + JSON.stringify( + { + name: rootName, + version: '0.10.0', + private: true, + workspaces, + }, + null, + 2, + ), + ); + + const rootBunDir = join(wsRoot, 'node_modules', 'bun', 'bin'); + mkdirSync(rootBunDir, { recursive: true }); + copyFileSync(ensureBun(), join(rootBunDir, 'bun.exe')); + if (opts.withBunPackageJson !== false) { + writeFileSync( + join(wsRoot, 'node_modules', 'bun', 'package.json'), + JSON.stringify({ name: 'bun', version: bunVersion }, null, 2), + ); + } + + return { wsRoot, pkgRoot, launcherTarget }; + } + + it('launches from a source workspace using the verified root Bun', () => { + // The launcher is at packages/cli/bin/llxprt with NO enclosing + // node_modules around the package. Resolution must verify the workspace + // root (two levels up) and accept its node_modules/bun. + const { pkgRoot, launcherTarget } = makeSourceWorkspace(tempDir); + const result = spawnSync(launcherTarget, ['--version'], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: 30_000, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expect(result.status, result.stderr).toBe(0); + }, 30_000); + + it('launches the REAL source-workspace launcher (repo root)', () => { + // Direct behavioral proof against the actual repo layout: the launcher at + // packages/cli/bin/llxprt must resolve the real root node_modules/bun. + const result = spawnSync(launcherPath, ['--version'], { + cwd: repoRoot, + encoding: 'utf8', + timeout: 30_000, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expect(result.status, result.stderr).toBe(0); + }, 30_000); + + it('accepts a source-workspace root via canonical structure regardless of manifest name', () => { + // The workspace-root check now uses canonical filesystem structure + // (/packages/cli === pkg_root), NOT manifest content scanning. + // A root whose manifest name differs from the package name is still + // accepted because the structural layout is the trust boundary — the + // candidate root is deterministically three parents up from the launcher. + // This test documents that architectural decision: the structural check + // replaces the former grep-based manifest verification. + const { pkgRoot, launcherTarget } = makeSourceWorkspace(tempDir, { + rootWorkspaces: ['packages/tools', 'packages/core'], + rootPkgName: 'some-other-project', + }); + const result = spawnSync(launcherTarget, ['--version'], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: 15_000, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + // The structural check passes (packages/cli is at the canonical path), so + // the launcher finds the hoisted root Bun and exits 0. + expect(result.status, result.stderr).toBe(0); + }, 15_000); + + it('rejects a source-workspace root Bun whose package.json is missing (under exact pin)', () => { + // The root Bun binary exists but its package.json is absent. Under an + // exact pin the launcher must reject (not accept) the unversionable + // candidate, so a partial/tampered install cannot bypass the pin. + const { pkgRoot, launcherTarget } = makeSourceWorkspace(tempDir, { + withBunPackageJson: false, + }); + const result = spawnSync(launcherTarget, [], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: 15_000, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expect(result.status).toBe(LAUNCHER_FAILURE_EXIT); + expect(result.stderr).toMatch(/bundled Bun runtime was not found/i); + }, 15_000); + + it('rejects a source-workspace root Bun whose version does not match the pin', () => { + // The CLI pins bun "9.9.9" but the root Bun package.json reports a + // different version. The launcher must reject the mismatch. + const { pkgRoot, launcherTarget } = makeSourceWorkspace(tempDir, { + bunVersion: '1.0.0', + cliPin: '9.9.9', + }); + const result = spawnSync(launcherTarget, [], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: 15_000, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expect(result.status).toBe(LAUNCHER_FAILURE_EXIT); + expect(result.stderr).toMatch(/bundled Bun runtime was not found/i); + }, 15_000); + + it('does not climb an arbitrary ancestor that is not a verified workspace root', () => { + // The package is at /a/b/packages/cli/bin/llxprt (NOT under a + // node_modules). Two levels up is /a/b which has no package.json. + // A Bun exists much higher at /node_modules/bun — the launcher + // must NOT generic-climb to find it. + const nested = join(tempDir, 'a', 'b'); + const { pkgRoot, launcherTarget, wsRoot } = makeSourceWorkspace(nested); + // Remove the workspace root's Bun so the only Bun is the arbitrary + // ancestor's. + rmSync(join(wsRoot, 'node_modules', 'bun'), { + recursive: true, + force: true, + }); + + const ancestorBunDir = join(tempDir, 'node_modules', 'bun', 'bin'); + mkdirSync(ancestorBunDir, { recursive: true }); + copyFileSync(ensureBun(), join(ancestorBunDir, 'bun.exe')); + + const result = spawnSync(launcherTarget, [], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: 15_000, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expect(result.status).toBe(LAUNCHER_FAILURE_EXIT); + expect(result.stderr).toMatch(/bundled Bun runtime was not found/i); + }, 15_000); + + it('accepts a source-workspace root Bun whose version matches the pin', () => { + const bunVersion = realBunVersion(); + const { pkgRoot, launcherTarget } = makeSourceWorkspace(tempDir, { + bunVersion, + cliPin: bunVersion, + }); + const result = spawnSync(launcherTarget, ['--version'], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: 30_000, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expect(result.status, result.stderr).toBe(0); + }, 30_000); +}); diff --git a/scripts/tests/issue-2603-launcher.test.ts b/scripts/tests/issue-2603-launcher.test.ts new file mode 100644 index 000000000..ebf293e28 --- /dev/null +++ b/scripts/tests/issue-2603-launcher.test.ts @@ -0,0 +1,851 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { + existsSync, + statSync, + mkdtempSync, + mkdirSync, + writeFileSync, + rmSync, + copyFileSync, + chmodSync, + symlinkSync, + readFileSync, +} from 'node:fs'; +import { join, resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { tmpdir } from 'node:os'; + +const thisFile = fileURLToPath(import.meta.url); +const repoRoot = resolve(thisFile, '..', '..', '..'); +const launcherPath = join(repoRoot, 'packages', 'cli', 'bin', 'llxprt'); +const repoBun = join(repoRoot, 'node_modules', 'bun', 'bin', 'bun.exe'); + +/** + * The exit code the launcher uses for all launch-failure modes (missing Bun, + * corrupt Bun, wrong platform/unrecognized format, missing entry point). + * Centralized so a change to the launcher's failure code only requires updating + * one place. Mirrors the LAUNCHER_ERROR_EXIT_CODE constant in + * packages/cli/scripts/install-native-launchers.cjs. + */ +const LAUNCHER_FAILURE_EXIT = 43; + +/** + * The bundled Bun binary filename. The launcher resolves and exec's + * node_modules/bun/bin/ on all platforms; on Windows the + * launcher runs through the .cmd/.ps1 wrapper but the binary itself is still + * named bun.exe. This constant makes the platform-independent binary name + * explicit so a rename here stays in sync with the launcher. + */ +const BUN_BINARY_NAME = 'bun.exe'; + +const SHELL_PROBE_TIMEOUT_MS = 10_000; +const SHORT_LAUNCH_TIMEOUT_MS = 15_000; +const STANDARD_LAUNCH_TIMEOUT_MS = 30_000; + +/** + * Extracts the inner `case "$_llxprt_magic"` block that follows a kernel + * marker (e.g. 'Darwin)') in the launcher source, so platform-gated magic + * acceptance can be asserted per-branch. + */ +function launcherMagicBlockAfter(marker: string): string { + const source = readFileSync(launcherPath, 'utf8'); + const start = source.indexOf(marker); + const magicStart = source.indexOf('case "$_llxprt_magic"', start); + const magicEsac = source.indexOf('esac', magicStart); + return source.slice(magicStart, magicEsac); +} + +// Bun is a declared root dependency (see trustedDependencies in the root +// package.json) and a test prerequisite: the launcher exec's it directly. A +// missing Bun means the repo install is broken — skipping would hide that, so +// we throw rather than mark tests as skipped. +function ensureBun(): string { + if (existsSync(repoBun)) { + return repoBun; + } + // Use POSIX-standard 'command -v' instead of non-standard 'which' for + // better portability on minimal container images. + const commandVResult = spawnSync('sh', ['-c', 'command -v bun'], { + encoding: 'utf8', + }); + if (commandVResult.status === 0 && commandVResult.stdout.trim()) { + return commandVResult.stdout.trim(); + } + throw new Error('Bun not found for test setup'); +} + +/** Guard: surfaces spawn failures (ENOENT, EACCES) before null status checks. */ +function expectNoSpawnError(result: { error?: Error }): void { + if (result.error) { + throw new Error(`spawn failed: ${result.error.message}`); + } +} + +/** + * Returns the real Bun version from the repo's bun package.json so tests can + * write matching pins. Bun is a declared dependency and test prerequisite; a + * missing/unreadable version indicates a broken installation, so we throw + * rather than fall back to a hardcoded version that would become stale on the + * next Bun upgrade. + */ +function realBunVersion(): string { + const bunPkgPath = join(repoRoot, 'node_modules', 'bun', 'package.json'); + const bunPkg = JSON.parse(readFileSync(bunPkgPath, 'utf8')); + if (typeof bunPkg.version === 'string' && bunPkg.version.length > 0) { + return bunPkg.version; + } + throw new Error( + `Bun package.json at ${bunPkgPath} has no valid version field; ` + + 'the repo installation appears broken.', + ); +} + +function makeEntry(pkgRoot: string, code: string): void { + writeFileSync(join(pkgRoot, 'index.ts'), `#!/usr/bin/env -S bun\n${code}\n`); +} + +function makeLayout( + tempDir: string, + opts: { withBun?: boolean; withIndex?: boolean; entryCode?: string } = {}, +): { pkgRoot: string; launcherTarget: string } { + const pkgRoot = join(tempDir, 'pkg'); + const binDir = join(pkgRoot, 'bin'); + mkdirSync(binDir, { recursive: true }); + + const launcherTarget = join(binDir, 'llxprt'); + copyFileSync(launcherPath, launcherTarget); + chmodSync(launcherTarget, 0o755); + + if (opts.withIndex !== false) { + makeEntry(pkgRoot, opts.entryCode ?? 'process.exit(0);'); + } + + if (opts.withBun !== false) { + const bunPath = ensureBun(); + const bunDir = join(pkgRoot, 'node_modules', 'bun', 'bin'); + mkdirSync(bunDir, { recursive: true }); + copyFileSync(bunPath, join(bunDir, BUN_BINARY_NAME)); + } + + return { pkgRoot, launcherTarget }; +} + +describe('POSIX launcher portability', () => { + it('passes shellcheck with no warnings', () => { + // Use POSIX-standard 'command -v' instead of non-standard 'which'. + const which = spawnSync('sh', ['-c', 'command -v shellcheck'], { + encoding: 'utf8', + }); + if (which.status !== 0) { + console.warn('shellcheck not installed; skipping static analysis proof'); + return; + } + const result = spawnSync('shellcheck', [launcherPath], { + encoding: 'utf8', + timeout: SHORT_LAUNCH_TIMEOUT_MS, + }); + expect( + result.status, + `shellcheck reported issues:\n${result.stdout}\n${result.stderr}`, + ).toBe(0); + }); + + it('uses -- end-of-options for readlink/dirname/cd (portable on stock macOS BSD)', () => { + const source = readFileSync(launcherPath, 'utf8'); + expect(source).toMatch(/readlink -- "\$_llxprt_self"/); + expect(source).toMatch(/dirname -- "\$_llxprt_self"/); + expect(source).toMatch(/cd -- "\$\(dirname/); + }); + + it('od magic check is portable (single-file -N4 form, no GNU-only flags)', () => { + const source = readFileSync(launcherPath, 'utf8'); + expect(source).toMatch(/od -An -tx1 -N4 -- "\$_llxprt_bun"/); + }); + + it('readlink -- resolves symlinks on stock macOS (behavioral proof)', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-readlink-')); + try { + const target = join(tempDir, 'real-target'); + writeFileSync(target, '#!/bin/sh\necho ok\n'); + chmodSync(target, 0o755); + const link = join(tempDir, 'mylink'); + symlinkSync(target, link); + const r = spawnSync('sh', ['-c', `readlink -- "${link}"`], { + encoding: 'utf8', + timeout: SHELL_PROBE_TIMEOUT_MS, + }); + expect(r.status, r.stderr).toBe(0); + expect(r.stdout.trim()).toBe(target); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('dirname -- handles dash-prefixed names on stock macOS (behavioral proof)', () => { + const r = spawnSync('sh', ['-c', `dirname -- "-weird-name"`], { + encoding: 'utf8', + timeout: SHELL_PROBE_TIMEOUT_MS, + }); + expect(r.status, r.stderr).toBe(0); + expect(r.stdout.trim()).toBe('.'); + }); + + it('od -An -tx1 -N4 reads first 4 bytes on stock macOS (behavioral proof)', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-od-')); + try { + const elfFile = join(tempDir, 'fake-elf'); + writeFileSync(elfFile, Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x01])); + const r = spawnSync( + 'sh', + ['-c', `od -An -tx1 -N4 -- "${elfFile}" | tr -d ' \\n'`], + { encoding: 'utf8', timeout: SHELL_PROBE_TIMEOUT_MS }, + ); + expect(r.status, r.stderr).toBe(0); + expect(r.stdout.trim()).toBe('7f454c46'); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); + +describe('POSIX launcher file', () => { + it('ships as an executable file with a valid sh shebang', () => { + expect(existsSync(launcherPath)).toBe(true); + const stats = statSync(launcherPath); + expect(stats.isFile()).toBe(true); + expect(stats.mode & 0o111).toBeTruthy(); + }); + + it('uses a sh shebang, not a Node shebang', () => { + const source = readFileSync(launcherPath, 'utf8'); + expect(source.startsWith('#!/bin/sh')).toBe(true); + expect(source).not.toMatch(/^#!.*node/m); + }); +}); + +describe('POSIX launcher execution behavior', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'llxprt-posix-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + it('is directly execve-compatible (no shell fallback)', () => { + const { pkgRoot, launcherTarget } = makeLayout(tempDir); + const result = spawnSync(launcherTarget, ['--test'], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: STANDARD_LAUNCH_TIMEOUT_MS, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + }); + it('launches Bun (process.versions.bun is set)', () => { + const { pkgRoot, launcherTarget } = makeLayout(tempDir, { + entryCode: `console.log(typeof process.versions.bun === 'string' && process.versions.bun.length > 0);`, + }); + const result = spawnSync(launcherTarget, [], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: STANDARD_LAUNCH_TIMEOUT_MS, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expectNoSpawnError(result); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe('true'); + }); + it('uses package-local Bun even with constrained PATH', () => { + const { pkgRoot, launcherTarget } = makeLayout(tempDir); + const result = spawnSync(launcherTarget, [], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: STANDARD_LAUNCH_TIMEOUT_MS, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expectNoSpawnError(result); + expect(result.status).toBe(0); + }); + it('invokes Bun exactly once (no pre-probe)', () => { + const counterDir = join(tempDir, 'counter'); + mkdirSync(counterDir, { recursive: true }); + const counterFile = join(counterDir, 'invocations.txt'); + const { pkgRoot, launcherTarget } = makeLayout(tempDir, { + entryCode: `const fs = require('fs'); + const counter = ${JSON.stringify(counterFile)}; + let count = 0; + try { count = parseInt(fs.readFileSync(counter, 'utf8').trim(), 10) || 0; } catch {} + fs.writeFileSync(counter, String(count + 1)); + console.log(count + 1);`, + }); + + const result = spawnSync(launcherTarget, [], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: STANDARD_LAUNCH_TIMEOUT_MS, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout.trim()).toBe('1'); + expect(existsSync(counterFile)).toBe(true); + expect(readFileSync(counterFile, 'utf8').trim()).toBe('1'); + }); + it('forwards arguments including spaces, Unicode, and shell metacharacters', () => { + const { pkgRoot, launcherTarget } = makeLayout(tempDir, { + entryCode: `console.log(JSON.stringify(process.argv.slice(2)));`, + }); + const trickyArgs = [ + 'hello world', + 'Unicode: ✓ 日本語 ñ', + 'shell: $HOME `whoami` $(date)', + 'quotes: "double" \'single\'', + 'semicolon; pipe| amp&', + ]; + const result = spawnSync(launcherTarget, trickyArgs, { + cwd: pkgRoot, + encoding: 'utf8', + timeout: STANDARD_LAUNCH_TIMEOUT_MS, + }); + expectNoSpawnError(result); + expect(result.status).toBe(0); + const parsed = JSON.parse(result.stdout.trim()) as string[]; + expect(parsed).toStrictEqual(trickyArgs); + }); + it('propagates a non-zero exit code from the child', () => { + const { pkgRoot, launcherTarget } = makeLayout(tempDir, { + entryCode: 'process.exit(7);', + }); + const result = spawnSync(launcherTarget, [], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: STANDARD_LAUNCH_TIMEOUT_MS, + }); + expectNoSpawnError(result); + expect(result.status).toBe(7); + }); + it('propagates stdin/stdout/stderr', () => { + const { pkgRoot, launcherTarget } = makeLayout(tempDir, { + entryCode: [ + 'process.stdin.on("data", (chunk) => {', + ' process.stdout.write("OUT:" + chunk.toString());', + ' process.stderr.write("ERR:" + chunk.toString());', + '});', + ].join('\n'), + }); + const result = spawnSync(launcherTarget, [], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: STANDARD_LAUNCH_TIMEOUT_MS, + input: 'hello', + }); + expectNoSpawnError(result); + expect(result.status).toBe(0); + expect(result.stdout).toContain('OUT:hello'); + expect(result.stderr).toContain('ERR:hello'); + }); + it('exits 43 when Bun is not found', () => { + const { pkgRoot, launcherTarget } = makeLayout(tempDir, { + withBun: false, + }); + const result = spawnSync(launcherTarget, [], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: SHORT_LAUNCH_TIMEOUT_MS, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expectNoSpawnError(result); + expect(result.status).toBe(LAUNCHER_FAILURE_EXIT); + expect(result.stderr).toMatch(/npm install|bun\.sh/i); + }); + it('does not accept an unrelated Bun from a consumer ancestor directory', () => { + // Simulates: consumer-project/node_modules/@vybestack/llxprt-code/bin/llxprt + // The launcher must NOT climb past the enclosing node_modules to find + // consumer-project/node_modules/bun (a consumer's own Bun dependency). + // Here the package has NO package-local or hoisted Bun, but a "consumer" + // ancestor directory has its own node_modules/bun — which must be rejected. + const consumerDir = join(tempDir, 'consumer-project'); + const pkgRoot = join( + consumerDir, + 'node_modules', + '@vybestack', + 'llxprt-code', + ); + const binDir = join(pkgRoot, 'bin'); + mkdirSync(binDir, { recursive: true }); + const launcherTarget = join(binDir, 'llxprt'); + copyFileSync(launcherPath, launcherTarget); + chmodSync(launcherTarget, 0o755); + makeEntry(pkgRoot, 'process.exit(0);'); + + // Consumer's OWN bun dependency (unrelated ancestor Bun). + const consumerBunDir = join(consumerDir, 'node_modules', 'bun', 'bin'); + mkdirSync(consumerBunDir, { recursive: true }); + copyFileSync(ensureBun(), join(consumerBunDir, 'bun.exe')); + + // The package has a package.json with a bun pin so the launcher WILL try + // to validate versions. The consumer's bun has a DIFFERENT version. + writeFileSync( + join(pkgRoot, 'package.json'), + JSON.stringify( + { name: '@vybestack/llxprt-code', dependencies: { bun: '1.3.14' } }, + null, + 2, + ), + ); + // The consumer's bun package.json has a different version. + writeFileSync( + join(consumerDir, 'node_modules', 'bun', 'package.json'), + JSON.stringify({ name: 'bun', version: '9.9.9' }, null, 2), + ); + + const result = spawnSync(launcherTarget, [], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: SHORT_LAUNCH_TIMEOUT_MS, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expectNoSpawnError(result); + expect(result.status).toBe(LAUNCHER_FAILURE_EXIT); + expect(result.stderr).toMatch(/bundled Bun runtime was not found/i); + }); + it('accepts a hoisted Bun within the enclosing node_modules', () => { + // Simulates npm hoisting: the package is at + // consumer/node_modules/@vybestack/llxprt-code/bin/llxprt and the Bun + // dependency is hoisted to consumer/node_modules/bun/bin/bun.exe. + // The enclosing node_modules is consumer/node_modules; the search must + // find the hoisted Bun and NOT climb to consumer/ or above. + const consumerDir = join(tempDir, 'consumer-hoisted'); + const pkgRoot = join( + consumerDir, + 'node_modules', + '@vybestack', + 'llxprt-code', + ); + const binDir = join(pkgRoot, 'bin'); + mkdirSync(binDir, { recursive: true }); + const launcherTarget = join(binDir, 'llxprt'); + copyFileSync(launcherPath, launcherTarget); + chmodSync(launcherTarget, 0o755); + makeEntry(pkgRoot, 'process.exit(0);'); + + // Hoisted Bun at the enclosing node_modules level. + const hoistedBunDir = join(consumerDir, 'node_modules', 'bun', 'bin'); + mkdirSync(hoistedBunDir, { recursive: true }); + const bunPath = ensureBun(); + copyFileSync(bunPath, join(hoistedBunDir, 'bun.exe')); + + const bunVersion = realBunVersion(); + writeFileSync( + join(consumerDir, 'node_modules', 'bun', 'package.json'), + JSON.stringify({ name: 'bun', version: bunVersion }, null, 2), + ); + writeFileSync( + join(pkgRoot, 'package.json'), + JSON.stringify( + { + name: '@vybestack/llxprt-code', + dependencies: { bun: bunVersion }, + }, + null, + 2, + ), + ); + + const result = spawnSync(launcherTarget, [], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: STANDARD_LAUNCH_TIMEOUT_MS, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expect(result.status, result.stderr).toBe(0); + }); + it('exits 43 when Bun is a corrupt text file (not a native binary)', () => { + const { pkgRoot, launcherTarget } = makeLayout(tempDir, { + withBun: false, + }); + const bunDir = join(pkgRoot, 'node_modules', 'bun', 'bin'); + mkdirSync(bunDir, { recursive: true }); + const corruptBun = join(bunDir, 'bun.exe'); + writeFileSync(corruptBun, '#!/bin/sh\necho this is not a real binary\n'); + chmodSync(corruptBun, 0o755); + const result = spawnSync(launcherTarget, [], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: SHORT_LAUNCH_TIMEOUT_MS, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expectNoSpawnError(result); + expect(result.status).toBe(LAUNCHER_FAILURE_EXIT); + expect(result.stderr).toMatch( + /npm install|bun\.sh|unusable|not a valid|corrupt/i, + ); + }); + it('exits 43 when Bun has wrong magic bytes (not ELF/Mach-O/PE)', () => { + const { pkgRoot, launcherTarget } = makeLayout(tempDir, { + withBun: false, + }); + const bunDir = join(pkgRoot, 'node_modules', 'bun', 'bin'); + mkdirSync(bunDir, { recursive: true }); + const wrongMagicBun = join(bunDir, 'bun.exe'); + // Random bytes that are neither ELF (7f454c46), Mach-O (feedface/etc.), + // nor PE/COFF (4d5a, "MZ"). + writeFileSync( + wrongMagicBun, + Buffer.from([0xde, 0xad, 0xbe, 0xef, 0x00, 0x01]), + ); + chmodSync(wrongMagicBun, 0o755); + const result = spawnSync(launcherTarget, [], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: SHORT_LAUNCH_TIMEOUT_MS, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expectNoSpawnError(result); + expect(result.status).toBe(LAUNCHER_FAILURE_EXIT); + expect(result.stderr).toMatch( + /npm install|bun\.sh|unusable|not a valid|corrupt/i, + ); + }); + + it('accepts a PE/COFF (MZ, 4d5a) Bun magic only on Windows POSIX (MSYS/Git Bash)', () => { + // POSIX shells that can execute Windows PE (Git Bash/MSYS) need the magic + // check to accept MZ so a real bun.exe is not rejected. We cannot exec a + // PE on this POSIX host, so we assert at the unit level: the launcher's + // magic case-statement must accept the 4d5a prefix ONLY in the + // MINGW/MSYS/CYGWIN branch, and must NOT accept it in Darwin or Linux. + expect(launcherMagicBlockAfter('MINGW*|MSYS*|CYGWIN*')).toMatch(/4d5a\*/); + expect(launcherMagicBlockAfter('Darwin)')).not.toMatch(/4d5a/); + expect(launcherMagicBlockAfter('Linux and other ELF')).not.toMatch(/4d5a/); + }); + + it.skipIf(process.platform !== 'darwin')( + 'rejects a PE/COFF Bun binary on Darwin (platform-gated magic)', + () => { + // On macOS (Darwin), a PE/COFF binary cannot execute and must be rejected + // with exit 43. This test only runs on Darwin; on Linux it would test ELF + // rejection of PE (also correct), but the assertion is Darwin-specific. + const { pkgRoot, launcherTarget } = makeLayout(tempDir, { + withBun: false, + }); + const bunDir = join(pkgRoot, 'node_modules', 'bun', 'bin'); + mkdirSync(bunDir, { recursive: true }); + const peBun = join(bunDir, 'bun.exe'); + // PE/COFF magic: MZ (4d5a) followed by arbitrary bytes. + writeFileSync(peBun, Buffer.from([0x4d, 0x5a, 0x90, 0x00, 0x01, 0x02])); + chmodSync(peBun, 0o755); + const result = spawnSync(launcherTarget, [], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: SHORT_LAUNCH_TIMEOUT_MS, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expectNoSpawnError(result); + expect(result.status).toBe(LAUNCHER_FAILURE_EXIT); + expect(result.stderr).toMatch( + /npm install|bun\.sh|unusable|not a valid|corrupt/i, + ); + }, + 15_000, + ); + + it('magic case-statement accepts the correct native format per platform', () => { + // Unit-level contract: accepted magics must appear in the correct + // platform-gated branches. + // MINGW/MSYS/CYGWIN branch: PE/COFF only (Windows runs PE natively; + // ELF and Mach-O indicate a corrupt or wrong-platform install). + const mingwBlock = launcherMagicBlockAfter('MINGW*|MSYS*|CYGWIN*'); + expect(mingwBlock).toContain('4d5a'); // PE/COFF + expect(mingwBlock).not.toContain('7f454c46'); // no ELF on Windows + expect(mingwBlock).not.toContain('feedface'); // no Mach-O on Windows + + // Darwin branch: Mach-O only. + const darwinBlock = launcherMagicBlockAfter('Darwin)'); + expect(darwinBlock).toContain('feedface'); + expect(darwinBlock).toContain('feedfacf'); + expect(darwinBlock).toContain('cefaedfe'); + expect(darwinBlock).toContain('cffaedfe'); + expect(darwinBlock).toContain('cafebabe'); + expect(darwinBlock).toContain('bebafeca'); + expect(darwinBlock).not.toContain('7f454c46'); // no ELF on Darwin + expect(darwinBlock).not.toContain('4d5a'); // no PE on Darwin + + // Default (Linux) branch: ELF only. + const defaultBlock = launcherMagicBlockAfter('Linux and other ELF'); + expect(defaultBlock).toContain('7f454c46'); // ELF + expect(defaultBlock).not.toContain('feedface'); // no Mach-O on Linux + expect(defaultBlock).not.toContain('4d5a'); // no PE on Linux + }); + + it('rejects a PE/COFF-looking file whose payload is not executable (od proof)', () => { + // Behavioral proof that od reads the first 4 bytes and tr matchers accept + // the MZ prefix: this confirms the 4d5a* glob would match real PE files. + const tempDir2 = mkdtempSync(join(tmpdir(), 'llxprt-pe-od-')); + try { + const peFile = join(tempDir2, 'fake-pe'); + writeFileSync(peFile, Buffer.from([0x4d, 0x5a, 0x90, 0x00, 0x00, 0x01])); + const r = spawnSync( + 'sh', + ['-c', `od -An -tx1 -N4 -- "${peFile}" | tr -d ' \\n'`], + { encoding: 'utf8', timeout: SHELL_PROBE_TIMEOUT_MS }, + ); + expect(r.status, r.stderr).toBe(0); + expect(r.stdout.trim().startsWith('4d5a')).toBe(true); + } finally { + rmSync(tempDir2, { recursive: true, force: true }); + } + }); + it('exits 43 when Bun exists but is not executable', () => { + const { pkgRoot, launcherTarget } = makeLayout(tempDir, { + withBun: false, + }); + const bunDir = join(pkgRoot, 'node_modules', 'bun', 'bin'); + mkdirSync(bunDir, { recursive: true }); + const bunPath = ensureBun(); + const nonExecBun = join(bunDir, 'bun.exe'); + copyFileSync(bunPath, nonExecBun); + chmodSync(nonExecBun, 0o644); // readable but not executable + const result = spawnSync(launcherTarget, [], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: SHORT_LAUNCH_TIMEOUT_MS, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expectNoSpawnError(result); + expect(result.status).toBe(LAUNCHER_FAILURE_EXIT); + }); + it('launches a valid Mach-O Bun exactly once (no double-start)', () => { + // The real Bun binary IS a valid Mach-O/ELF. This confirms the magic + // check ACCEPTS a real native binary and execs it (the counter proves + // exactly one invocation, not a pre-probe + exec). + const counterFile = join(tempDir, 'invocations.txt'); + const { pkgRoot, launcherTarget } = makeLayout(tempDir, { + entryCode: `const fs = require('fs'); + let count = 0; + try { count = parseInt(fs.readFileSync(${JSON.stringify(counterFile)}, 'utf8').trim(), 10) || 0; } catch {} + fs.writeFileSync(${JSON.stringify(counterFile)}, String(count + 1));`, + }); + + const result = spawnSync(launcherTarget, [], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: STANDARD_LAUNCH_TIMEOUT_MS, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expect(result.status, result.stderr).toBe(0); + expect(readFileSync(counterFile, 'utf8').trim()).toBe('1'); + }); + it('preserves a legitimate non-zero exit code from the entry', () => { + const { pkgRoot, launcherTarget } = makeLayout(tempDir, { + entryCode: 'process.exit(42);', + }); + const result = spawnSync(launcherTarget, [], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: STANDARD_LAUNCH_TIMEOUT_MS, + }); + expectNoSpawnError(result); + expect(result.status).toBe(42); + }); + it('exits 43 when index.ts is not found', () => { + const { pkgRoot, launcherTarget } = makeLayout(tempDir, { + withIndex: false, + }); + const result = spawnSync(launcherTarget, [], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: SHORT_LAUNCH_TIMEOUT_MS, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expectNoSpawnError(result); + expect(result.status).toBe(LAUNCHER_FAILURE_EXIT); + expect(result.stderr).toMatch(/entry point|index\.ts|corrupt/i); + }); + it('resolves symlinks so $0 works through npm .bin links', () => { + const { pkgRoot, launcherTarget } = makeLayout(tempDir); + const binLink = join(pkgRoot, 'node_modules', '.bin', 'llxprt'); + mkdirSync(dirname(binLink), { recursive: true }); + symlinkSync(launcherTarget, binLink); + + const result = spawnSync(binLink, ['--version'], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: STANDARD_LAUNCH_TIMEOUT_MS, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expectNoSpawnError(result); + expect(result.status).toBe(0); + }); + it('does not mutate the environment with LLXPRT_BUN_RELAUNCHED', () => { + const { pkgRoot, launcherTarget } = makeLayout(tempDir, { + entryCode: `console.log(process.env.LLXPRT_BUN_RELAUNCHED ?? 'unset');`, + }); + const result = spawnSync(launcherTarget, [], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: STANDARD_LAUNCH_TIMEOUT_MS, + env: { PATH: '/usr/bin:/bin' }, + }); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout.trim()).toBe('unset'); + }); +}); + +describe('POSIX launcher version-pin and platform validation', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'llxprt-pin-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + it('rejects a hoisted Bun whose version does not match the package pin', () => { + // The package declares bun pin "9.9.9" but the hoisted Bun has a different + // version. The launcher must reject this version mismatch. + const consumerDir = join(tempDir, 'consumer-pin-mismatch'); + const pkgRoot = join( + consumerDir, + 'node_modules', + '@vybestack', + 'llxprt-code', + ); + const binDir = join(pkgRoot, 'bin'); + mkdirSync(binDir, { recursive: true }); + const launcherTarget = join(binDir, 'llxprt'); + copyFileSync(launcherPath, launcherTarget); + chmodSync(launcherTarget, 0o755); + makeEntry(pkgRoot, 'process.exit(0);'); + + // Hoisted Bun with a DIFFERENT version than the pin. + const hoistedBunDir = join(consumerDir, 'node_modules', 'bun', 'bin'); + mkdirSync(hoistedBunDir, { recursive: true }); + copyFileSync(ensureBun(), join(hoistedBunDir, 'bun.exe')); + writeFileSync( + join(consumerDir, 'node_modules', 'bun', 'package.json'), + JSON.stringify({ name: 'bun', version: '1.0.0' }, null, 2), + ); + writeFileSync( + join(pkgRoot, 'package.json'), + JSON.stringify( + { name: '@vybestack/llxprt-code', dependencies: { bun: '9.9.9' } }, + null, + 2, + ), + ); + + const result = spawnSync(launcherTarget, [], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: SHORT_LAUNCH_TIMEOUT_MS, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expectNoSpawnError(result); + expect(result.status).toBe(LAUNCHER_FAILURE_EXIT); + expect(result.stderr).toMatch(/bundled Bun runtime was not found/i); + }); + it('accepts a hoisted Bun whose version matches the package pin', () => { + const bunVersion = realBunVersion(); + const consumerDir = join(tempDir, 'consumer-pin-match'); + const pkgRoot = join( + consumerDir, + 'node_modules', + '@vybestack', + 'llxprt-code', + ); + const binDir = join(pkgRoot, 'bin'); + mkdirSync(binDir, { recursive: true }); + const launcherTarget = join(binDir, 'llxprt'); + copyFileSync(launcherPath, launcherTarget); + chmodSync(launcherTarget, 0o755); + makeEntry(pkgRoot, 'process.exit(0);'); + + // Hoisted Bun with a MATCHING version. + const hoistedBunDir = join(consumerDir, 'node_modules', 'bun', 'bin'); + mkdirSync(hoistedBunDir, { recursive: true }); + copyFileSync(ensureBun(), join(hoistedBunDir, 'bun.exe')); + writeFileSync( + join(consumerDir, 'node_modules', 'bun', 'package.json'), + JSON.stringify({ name: 'bun', version: bunVersion }, null, 2), + ); + writeFileSync( + join(pkgRoot, 'package.json'), + JSON.stringify( + { + name: '@vybestack/llxprt-code', + dependencies: { bun: bunVersion }, + }, + null, + 2, + ), + ); + + const result = spawnSync(launcherTarget, [], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: STANDARD_LAUNCH_TIMEOUT_MS, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expect(result.status, result.stderr).toBe(0); + }); + it('does not scan beyond the enclosing node_modules for Bun', () => { + // The package is nested two levels deep inside node_modules. The enclosing + // node_modules has NO bun. An ancestor project dir (OUTSIDE the enclosing + // node_modules) has bun — but the launcher must NOT climb past the + // enclosing node_modules to find it. + const grandparentDir = join(tempDir, 'grandparent'); + const consumerNm = join(grandparentDir, 'node_modules'); + const pkgRoot = join(consumerNm, '@vybestack', 'llxprt-code'); + const binDir = join(pkgRoot, 'bin'); + mkdirSync(binDir, { recursive: true }); + const launcherTarget = join(binDir, 'llxprt'); + copyFileSync(launcherPath, launcherTarget); + chmodSync(launcherTarget, 0o755); + makeEntry(pkgRoot, 'process.exit(0);'); + + // Ancestor bun OUTSIDE the enclosing node_modules — must be rejected. + const ancestorBunDir = join(tempDir, 'node_modules', 'bun', 'bin'); + mkdirSync(ancestorBunDir, { recursive: true }); + copyFileSync(ensureBun(), join(ancestorBunDir, 'bun.exe')); + + const result = spawnSync(launcherTarget, [], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: SHORT_LAUNCH_TIMEOUT_MS, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expectNoSpawnError(result); + expect(result.status).toBe(LAUNCHER_FAILURE_EXIT); + expect(result.stderr).toMatch(/bundled Bun runtime was not found/i); + }); + + it.skipIf(process.platform !== 'darwin')( + 'rejects an ELF Bun on Darwin (platform-gated format)', + () => { + const { pkgRoot, launcherTarget } = makeLayout(tempDir, { + withBun: false, + }); + const bunDir = join(pkgRoot, 'node_modules', 'bun', 'bin'); + mkdirSync(bunDir, { recursive: true }); + const elfBun = join(bunDir, 'bun.exe'); + // ELF magic: 7f454c46 + writeFileSync(elfBun, Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x01])); + chmodSync(elfBun, 0o755); + const result = spawnSync(launcherTarget, [], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: SHORT_LAUNCH_TIMEOUT_MS, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expect(result.status).toBe(LAUNCHER_FAILURE_EXIT); + expect(result.stderr).toMatch( + /npm install|bun\.sh|unusable|not a valid|corrupt/i, + ); + }, + 15_000, + ); +}); diff --git a/scripts/tests/issue-2603-release-install-smoke.cjs b/scripts/tests/issue-2603-release-install-smoke.cjs new file mode 100644 index 000000000..56c81d4fb --- /dev/null +++ b/scripts/tests/issue-2603-release-install-smoke.cjs @@ -0,0 +1,452 @@ +'use strict'; + +/** + * Standalone release-install smoke for issue #2603. + * + * This script is intentionally OUTSIDE the Vitest worker because the release + * pack + npm install sequence is long-running and blocks the event loop, which + * starves Vitest's worker RPC heartbeat (onTaskUpdate) and causes an unhandled + * timeout error. By running as a detached child process, the Vitest test stays + * async and the event loop is free to answer RPC pings. + * + * The script: + * 1. Packs a release-like CLI tarball (exact-version manifest) and a separate + * offline-installable replica (local tarball refs) via the shared helper. + * 2. Installs the replica globally into an isolated prefix and locally into a + * consumer project, then runs `--version` in each. + * 3. Extracts the release artifact and asserts its manifest has exact-version + * internal deps (no file:/workspace:/link:). + * + * Exit code 0 = all assertions passed. Non-zero = failure (with diagnostics on + * stderr). Output is also printed to stdout for the test to capture. + * + * Usage: node scripts/tests/issue-2603-release-install-smoke.cjs [repoRoot] + */ + +const { spawnSync } = require('node:child_process'); +const { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, + mkdtempSync, +} = require('node:fs'); +const { join, resolve } = require('node:path'); +const { tmpdir } = require('node:os'); +const { npmInvocation } = require('../lib/npm-command.cjs'); +const { spawnTarExtract } = require('../lib/tar-command.cjs'); + +/** + * Platform-aware PATH that proves the launcher needs NO global Bun or Node. + * On POSIX, only /usr/bin and /bin are present (intentionally excludes + * /usr/local/bin so a globally installed bun cannot be accidentally resolved). + * On Windows, SystemRoot-derived paths are used (via process.env.SystemRoot, + * NOT a hardcoded C:\Windows, so non-English/non-default Windows installations + * work correctly) so cmd.exe remains reachable for the .cmd wrapper; no global + * Bun/Node paths are included. + */ +function constrainedPath() { + if (process.platform === 'win32') { + const root = process.env.SystemRoot || 'C:\\Windows'; + return [join(root, 'System32'), root, join(root, 'System32', 'Wbem')].join( + ';', + ); + } + return '/usr/bin:/bin'; +} + +/** + * Resolves the installed bin path for a global npm install. + * On Windows, the wrapper is at /llxprt.cmd (npm writes the .cmd at + * the prefix root for global installs). On POSIX, it is at /bin/llxprt. + */ +function resolveGlobalBin(prefix) { + if (process.platform === 'win32') { + return join(prefix, 'llxprt.cmd'); + } + return join(prefix, 'bin', 'llxprt'); +} + +/** + * Resolves the installed bin path for a local (non-global) npm install. + * On Windows, the wrapper is at /node_modules/.bin/llxprt.cmd. + * On POSIX, it is at /node_modules/.bin/llxprt. + */ +function resolveLocalBin(consumerDir) { + const base = join(consumerDir, 'node_modules', '.bin', 'llxprt'); + return process.platform === 'win32' ? base + '.cmd' : base; +} + +/** + * Returns {command, baseArgs} for invoking a resolved bin path. The caller + * appends additional args (e.g. --version) to baseArgs. On Windows, .cmd + * files cannot be spawned directly (EINVAL/ENOENT), so cmd.exe /c is used + * with the wrapper path as the first argument — argv boundaries are preserved + * (no shell string). On POSIX, the shebanged launcher is exec'd directly. + */ +function resolveBinInvocation(binPath) { + if (process.platform === 'win32') { + return { command: 'cmd', baseArgs: ['/c', binPath] }; + } + return { command: binPath, baseArgs: [] }; +} + +const repoRoot = resolve(process.argv[2] || process.cwd()); +// Validate repoRoot early so an invalid path produces a clear error instead of +// a confusing failure deep inside packReleaseLikeCli. +if (!existsSync(join(repoRoot, 'package.json'))) { + console.error(`Invalid repo root (no package.json found): ${repoRoot}`); + process.exit(1); +} +if (!existsSync(join(repoRoot, 'packages', 'cli', 'package.json'))) { + console.error( + `Invalid repo root (no packages/cli/package.json found): ${repoRoot}`, + ); + process.exit(1); +} +const releasePackHelperPath = join( + repoRoot, + 'scripts', + 'tests', + 'issue-2603-release-pack.cjs', +); + +let failed = false; +const failures = []; + +function fail(msg) { + failed = true; + failures.push(msg); + console.error('FAIL: ' + msg); +} + +function assert(condition, msg) { + if (!condition) { + fail(msg); + } + return condition; +} + +// Shared list of non-NPM release packages, imported from a single .cjs source +// so the test stays in sync with release-pack.cjs without manual duplication. +const { + NON_NPM_RELEASE_PACKAGES, +} = require('../lib/non-npm-release-packages.cjs'); + +function assertExactVersions(deps) { + if (!deps) return; + for (const [name, spec] of Object.entries(deps)) { + if ( + typeof spec === 'string' && + (spec.startsWith('file:') || + spec.startsWith('workspace:') || + spec.startsWith('link:')) + ) { + // Non-NPM internal packages (test-utils, a2a-server, vscode-companion) + // are intentionally left as file: refs by bind-release-deps because + // they are never published to the registry. They are resolved by the + // real release pipeline at publish time, so a file: spec here is not a + // release-integrity violation. + if (NON_NPM_RELEASE_PACKAGES.has(name)) continue; + throw new Error(`release manifest has non-exact dep ${name}="${spec}"`); + } + } +} + +/** + * Assert the release manifest has no non-exact deps in ANY dependency field: + * dependencies, devDependencies, optionalDependencies, and peerDependencies. A + * release artifact with a file:/workspace:/link: spec in any of these fields + * would fail in an isolated install, so all four are validated. + */ +function assertReleaseManifestAllFields(pkgJson) { + assertExactVersions(pkgJson.dependencies); + assertExactVersions(pkgJson.devDependencies); + assertExactVersions(pkgJson.optionalDependencies); + assertExactVersions(pkgJson.peerDependencies); +} + +function runStep(label, fn) { + process.stdout.write(`[${label}] starting...\n`); + try { + fn(); + process.stdout.write(`[${label}] OK\n`); + } catch (err) { + fail(`${label}: ${err.message}`); + } +} + +/** + * Robustly removes a temp dir, swallowing cleanup errors so they never mask a + * test failure summary. Cleanup failures are logged to stderr as warnings. + */ +function safeCleanup(tempDir) { + if (!tempDir) return; + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch (e) { + console.error( + `Warning: cleanup of ${tempDir} failed (test result is still valid): ${e.message}`, + ); + } +} + +/** + * Spawns tar extract with spawn-error diagnostics via the shared tar-command + * helper. + */ +function spawnTarExtractLocal(tarball, extractDir) { + return spawnTarExtract(tarball, extractDir); +} + +// Track tempDir at module scope so signal handlers can clean up even if main +// has not yet reached the finally block. +let _cleanupTempDir = null; + +function _signalCleanup() { + if (_cleanupTempDir) { + safeCleanup(_cleanupTempDir); + } + process.exit(130); +} + +process.on('SIGINT', _signalCleanup); +process.on('SIGTERM', _signalCleanup); + +function main() { + let tempDir; + _cleanupTempDir = null; + try { + const { packReleaseLikeCli } = require(releasePackHelperPath); + const { releaseTarball, replicaTarball } = packReleaseLikeCli(repoRoot); + + assert( + existsSync(releaseTarball), + `release tarball not found: ${releaseTarball}`, + ); + assert( + existsSync(replicaTarball), + `replica tarball not found: ${replicaTarball}`, + ); + process.stdout.write( + `release=${releaseTarball}\nreplica=${replicaTarball}\n`, + ); + + tempDir = mkdtempSync(join(tmpdir(), 'llxprt-2603-smoke-')); + _cleanupTempDir = tempDir; + + // 1. Release artifact manifest integrity: exact versions, no file:/link:. + runStep('release-manifest-integrity', () => { + const extractDir = mkdtempSync(join(tmpdir(), 'llxprt-tarball-check-')); + try { + spawnTarExtractLocal(releaseTarball, extractDir); + const pkgJson = JSON.parse( + readFileSync(join(extractDir, 'package', 'package.json'), 'utf8'), + ); + assertReleaseManifestAllFields(pkgJson); + } finally { + safeCleanup(extractDir); + } + }); + + // 2. Global install of replica runs --version and exits 0. + runStep('global-install-version', () => { + const prefix = join(tempDir, 'global-prefix'); + mkdirSync(prefix, { recursive: true }); + const { command, args } = npmInvocation([ + 'install', + '--global', + '--prefix', + prefix, + '--cache', + join(tempDir, 'npm-cache'), + '--loglevel', + 'error', + replicaTarball, + ]); + const installResult = spawnSync(command, args, { + encoding: 'utf8', + timeout: 180_000, + maxBuffer: 64 * 1024 * 1024, + }); + if (installResult.error) { + throw new Error( + `global npm install spawn failed: ${installResult.error.message}`, + ); + } + if (installResult.status !== 0) { + throw new Error( + `global npm install failed (exit ${installResult.status}, signal=${installResult.signal ?? 'none'}): ${installResult.stderr || installResult.stdout}`, + ); + } + const binLink = resolveGlobalBin(prefix); + assert(existsSync(binLink), `global bin link not found: ${binLink}`); + const binInv = resolveBinInvocation(binLink); + const result = spawnSync( + binInv.command, + [...binInv.baseArgs, '--version'], + { + encoding: 'utf8', + timeout: 30_000, + // The launcher resolves its own package-local Bun, so it must NOT + // need a global Bun or Node on PATH. The constrained PATH proves + // this invariant: if the launcher accidentally relied on a global + // Bun/Node, it would fail here. On Windows, cmd.exe (in System32) + // must remain reachable for the .cmd wrapper. + env: { ...process.env, PATH: constrainedPath() }, + }, + ); + if (result.error) { + throw new Error( + `global --version spawn failed: ${result.error.message}`, + ); + } + if (result.status !== 0) { + throw new Error( + `global --version exited ${result.status}: ${result.stderr}`, + ); + } + assert( + /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test( + result.stdout.trim(), + ), + `global --version output unexpected: ${result.stdout}`, + ); + }); + + // 3. Local install of replica runs --version and exits 0. + runStep('local-install-version', () => { + const consumerDir = join(tempDir, 'consumer'); + mkdirSync(consumerDir, { recursive: true }); + writeFileSync( + join(consumerDir, 'package.json'), + JSON.stringify({ name: 'consumer', version: '0.0.0' }, null, 2), + ); + const { command, args } = npmInvocation([ + 'install', + '--cache', + join(tempDir, 'npm-cache-local'), + '--loglevel', + 'error', + replicaTarball, + ]); + const installResult = spawnSync(command, args, { + cwd: consumerDir, + encoding: 'utf8', + timeout: 180_000, + maxBuffer: 64 * 1024 * 1024, + }); + if (installResult.error) { + throw new Error( + `local npm install spawn failed: ${installResult.error.message}`, + ); + } + if (installResult.status !== 0) { + throw new Error( + `local npm install failed (exit ${installResult.status}, signal=${installResult.signal ?? 'none'}): ${installResult.stderr || installResult.stdout}`, + ); + } + const binLink = resolveLocalBin(consumerDir); + assert(existsSync(binLink), `local bin link not found: ${binLink}`); + const binInv = resolveBinInvocation(binLink); + const result = spawnSync( + binInv.command, + [...binInv.baseArgs, '--version'], + { + encoding: 'utf8', + timeout: 30_000, + cwd: consumerDir, + // Constrained PATH proves the launcher needs no global Bun/Node. + env: { ...process.env, PATH: constrainedPath() }, + }, + ); + if (result.error) { + throw new Error( + `local --version spawn failed: ${result.error.message}`, + ); + } + if (result.status !== 0) { + throw new Error( + `local --version exited ${result.status}: ${result.stderr}`, + ); + } + assert( + /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test( + result.stdout.trim(), + ), + `local --version output unexpected: ${result.stdout}`, + ); + }); + + // 4. ACTUAL ephemeral npm exec install: a CLEAN directory with NO local + // dependency, using `npm exec --package -- llxprt + // --version`. npm installs the package into the npx cache (running the + // postinstall lifecycle, which replaces the Windows wrappers on win32) + // and runs the bin, then leaves the clean dir with no node_modules. This + // is the real ephemeral install path — NOT `npx llxprt` against an + // already-local-installed bin, which would only exercise the local .bin + // link. We use a separate clean cache so the cache install is genuine. + runStep('npm-exec-ephemeral', () => { + const cleanDir = join(tempDir, 'npm-exec-clean'); + mkdirSync(cleanDir, { recursive: true }); + writeFileSync( + join(cleanDir, 'package.json'), + JSON.stringify({ name: 'clean-consumer', version: '0.0.0' }, null, 2), + ); + const npmCache = join(tempDir, 'npm-exec-cache'); + const { command, args } = npmInvocation([ + 'exec', + '--package', + replicaTarball, + '--', + 'llxprt', + '--version', + ]); + const result = spawnSync(command, args, { + cwd: cleanDir, + encoding: 'utf8', + timeout: 300_000, + maxBuffer: 64 * 1024 * 1024, + env: { ...process.env, npm_config_cache: npmCache }, + }); + if (result.error) { + throw new Error(`npm exec spawn failed: ${result.error.message}`); + } + if (result.status !== 0) { + throw new Error( + `npm exec --version failed (exit ${result.status}, signal=${result.signal ?? 'none'}): ${result.stderr || result.stdout}`, + ); + } + assert( + /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test( + result.stdout.trim(), + ), + `npm exec --version unexpected output: ${result.stdout}`, + ); + // The clean dir must NOT have been polluted with a local install — + // the install must have gone to the npx cache only. + assert( + !existsSync(join(cleanDir, 'node_modules')), + `npm exec polluted the clean dir with node_modules — must be ephemeral`, + ); + }); + } catch (err) { + fail(`unexpected error: ${err.stack || err.message}`); + } finally { + // Cleanup errors must not mask the test failure summary. safeCleanup + // logs warnings but never throws. + safeCleanup(tempDir); + } + + if (failed) { + console.error(`\n${failures.length} failure(s):\n`); + for (const f of failures) { + console.error(' - ' + f); + } + process.exit(1); + } + console.log('\nAll release-install smoke assertions passed.'); +} + +main(); diff --git a/scripts/tests/issue-2603-release-install.test.ts b/scripts/tests/issue-2603-release-install.test.ts new file mode 100644 index 000000000..5dbc774f9 --- /dev/null +++ b/scripts/tests/issue-2603-release-install.test.ts @@ -0,0 +1,387 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; +import { + existsSync, + mkdtempSync, + writeFileSync, + readFileSync, + rmSync, +} from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { tmpdir } from 'node:os'; +import { createRequire } from 'node:module'; + +const thisFile = fileURLToPath(import.meta.url); +const repoRoot = resolve(thisFile, '..', '..', '..'); +const smokeScript = join( + repoRoot, + 'scripts', + 'tests', + 'issue-2603-release-install-smoke.cjs', +); +const releasePackHelper = join( + repoRoot, + 'scripts', + 'tests', + 'issue-2603-release-pack.cjs', +); + +/** + * Spawns the standalone smoke script as an async child process with a hard + * timeout that SIGKILLs the child to prevent hangs/leaks. Using `spawn` (not + * `spawnSync`) keeps the event loop responsive to Vitest's worker RPC. + * + * Cleanup design (no process-global listener leaks): + * - The child IS spawned detached (detached: true) so the entire process + * group can be killed via kill(-pid) on POSIX or taskkill /T on Windows, + * reaping grandchildren (npm, tar, bun) safely. Despite detachment, the + * dispose() function explicitly kills the group and destroys stdio + * streams so no event-loop handles or orphan processes remain. + * - The only timers/listeners are attached to the `child` object itself + * (close/error events + a timeout timer), and are all removed in + * `dispose()` so no event-loop handles remain after the test settles. + * - NO `process.on('SIGINT'/'SIGTERM'/'exit'/'beforeExit')` listeners are + * registered: those keep the Vitest worker alive and caused the aggregate + * suite hang. Scoped cleanup is the caller's responsibility via the + * returned `dispose()` (invoked from try/finally + onTestFinished). + */ +/** + * Env-overridable timeout constants with a comfortable margin. The smoke + * timeout must be strictly less than the test timeout so the kill+dispose + * completes before Vitest's test timeout fires. + * + * Override via env for per-CI tuning: + * LLXPRT_SMOKE_TIMEOUT_MS — the hard kill timer for the smoke child. + * LLXPRT_SMOKE_TEST_TIMEOUT_MS — the Vitest test timeout (must exceed the + * smoke timeout by a safe margin). + */ +const SMOKE_TIMEOUT_MS = Number(process.env.LLXPRT_SMOKE_TIMEOUT_MS) || 540_000; +const SMOKE_TEST_TIMEOUT_MS = + Number(process.env.LLXPRT_SMOKE_TEST_TIMEOUT_MS) || 600_000; + +interface SmokeHandle { + promise: Promise<{ + status: number | null; + stdout: string; + stderr: string; + }>; + /** Kill the child if still alive and clear all timers/listeners. Idempotent. */ + dispose: () => void; +} + +function runSmokeAsync(): SmokeHandle { + let child: ChildProcess | null = spawn('node', [smokeScript, repoRoot], { + cwd: repoRoot, + env: { ...process.env }, + stdio: ['ignore', 'pipe', 'pipe'], + // Spawn detached on POSIX so we can kill the entire process group + // (grandchildren: npm, tar, bun, etc. inside the smoke script). On + // Windows, detached creates a new process group that taskkill /T can reap. + detached: true, + }); + let stdout = ''; + let stderr = ''; + let settled = false; + let timer: NodeJS.Timeout | null = null; + let disposed = false; + let streamTeardown: (() => void) | null = null; + + const promise = new Promise<{ + status: number | null; + stdout: string; + stderr: string; + }>((resolvePromise, reject) => { + function finish( + outcome: + | { ok: true; status: number | null } + | { ok: false; error: unknown }, + ): void { + if (settled) return; + settled = true; + if (timer) { + clearTimeout(timer); + timer = null; + } + if (outcome.ok) { + resolvePromise({ status: outcome.status, stdout, stderr }); + } else { + reject(outcome.error); + } + } + + timer = setTimeout(() => { + dispose(); + finish({ + ok: false, + error: new Error( + `smoke script exceeded ${SMOKE_TIMEOUT_MS}ms and was killed to prevent a hang/leak`, + ), + }); + }, SMOKE_TIMEOUT_MS); + + function onStdout(chunk: Buffer): void { + stdout += chunk.toString(); + } + function onStderr(chunk: Buffer): void { + stderr += chunk.toString(); + } + function onError(err: Error): void { + finish({ ok: false, error: err }); + } + function onClose(status: number | null): void { + finish({ ok: true, status }); + } + + const c = child!; + const { stdout: out, stderr: err } = c; + if (!out || !err) { + // With stdio 'pipe' both streams exist; guard for type narrowing only. + finish({ ok: false, error: new Error('child streams unavailable') }); + return; + } + out.on('data', onStdout); + err.on('data', onStderr); + c.on('error', onError); + c.on('close', onClose); + + streamTeardown = () => { + out.removeListener('data', onStdout); + err.removeListener('data', onStderr); + c.removeListener('error', onError); + c.removeListener('close', onClose); + }; + }); + + // POSIX process-group kill: attempt kill(-pid) and ignore ESRCH (group + // already exited). Falls back to child.kill for other errors. Extracted to + // keep dispose() under the nested-control-flow limit. + function killPosixGroup(proc: ChildProcess): void { + if (!proc.pid) return; + try { + process.kill(-proc.pid, 'SIGKILL'); + } catch (e) { + const code = (e as NodeJS.ErrnoException)?.code; + // ESRCH = no such process/group; expected when everything already exited. + if (code !== 'ESRCH') { + try { + proc.kill('SIGKILL'); + } catch { + // best effort; child may have exited concurrently + } + } + } + } + + function dispose(): void { + if (disposed) return; + disposed = true; + if (timer) { + clearTimeout(timer); + timer = null; + } + // Kill the entire process tree so grandchildren (npm, tar, bun spawned + // inside the smoke script) are reaped and do not leak as orphans. + // We attempt the group kill REGARDLESS of whether the direct child is + // still alive: if the direct child exited but descendants survived, the + // process group is still addressable via kill(-pid). + if (child && child.pid) { + try { + if (process.platform === 'win32') { + spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { + stdio: 'ignore', + timeout: 10_000, + }); + } else { + killPosixGroup(child); + } + } catch { + try { + child.kill('SIGKILL'); + } catch { + // best effort; child may have exited concurrently + } + } + } + // Explicitly destroy the child's stdio streams so their file descriptors + // do not keep the Vitest worker event loop alive. child.unref() alone does + // not close the underlying pipe FDs; destroying the streams ensures + // prompt worker exit after the test settles. + if (child) { + try { + child.stdout?.destroy(); + } catch { + // best effort; stream may already be destroyed + } + try { + child.stderr?.destroy(); + } catch { + // best effort; stream may already be destroyed + } + try { + child.unref(); + } catch { + // best effort + } + } + streamTeardown?.(); + streamTeardown = null; + child = null; + } + + return { promise, dispose }; +} + +describe('release-like CLI pack/install smoke (issue #2603)', () => { + it('the standalone smoke script exists and is invocable via npm script', () => { + expect(existsSync(smokeScript)).toBe(true); + }); + + it('the release-pack helper exports packReleaseLikeCli', async () => { + const mod = await import(releasePackHelper); + expect(typeof mod.packReleaseLikeCli).toBe('function'); + }, 15_000); + + it( + 'release-like global + local install runs --version and exits 0, release manifest has exact versions', + async (ctx) => { + const smoke = runSmokeAsync(); + // Guarantee the child is killed and listeners cleared even if the test is + // cancelled (timeout) or fails before reaching the finally below. + ctx.onTestFinished(() => smoke.dispose()); + let result: { status: number | null; stdout: string; stderr: string }; + try { + result = await smoke.promise; + } finally { + smoke.dispose(); + } + const { status, stdout, stderr } = result; + // Truncate output to the last 80 lines so verbose npm/tar output does not + // create huge error objects in CI logs. + const tail = (s: string, maxLines = 80): string => { + const lines = s.split('\n'); + if (lines.length <= maxLines) return s; + return `... (${lines.length - maxLines} earlier lines truncated) ...\n${lines.slice(-maxLines).join('\n')}`; + }; + expect( + status, + `smoke exited ${status}\n--- stdout (tail) ---\n${tail(stdout)}\n--- stderr (tail) ---\n${tail(stderr)}`, + ).toBe(0); + // stderr may contain benign safeCleanup warnings; only FAIL: lines indicate + // a real test failure. The status===0 and success marker prove success. + expect( + stderr, + `stderr contained FAIL: lines\n--- stdout (tail) ---\n${tail(stdout)}\n--- stderr (tail) ---\n${tail(stderr)}`, + ).not.toContain('FAIL:'); + expect(stdout).toContain('global-install-version'); + expect(stdout).toContain('local-install-version'); + expect(stdout).toContain('npm-exec-ephemeral'); + expect(stdout).toContain('release-manifest-integrity'); + expect(stdout).toContain('All release-install smoke assertions passed.'); + }, + SMOKE_TEST_TIMEOUT_MS, + ); +}); + +describe('rewriteOnePkgDeps does not rewrite peerDependencies', () => { + const nodeRequire = createRequire(import.meta.url); + + // Runtime guard: verify the dynamically required helper export exists before + // calling it, so a refactor that removes or renames rewriteOnePkgDeps + // produces a clear assertion failure instead of a cryptic TypeError. + function loadReleasePackHelper(): { + rewriteOnePkgDeps: (p: string, m: Map) => void; + } { + const mod = nodeRequire(releasePackHelper) as Record; + expect( + typeof mod.rewriteOnePkgDeps, + 'release-pack helper must export rewriteOnePkgDeps as a function', + ).toBe('function'); + return mod as { + rewriteOnePkgDeps: (p: string, m: Map) => void; + }; + } + + it('rewrites dependencies, devDependencies, and optionalDependencies to file: tarballs', () => { + const dir = mkdtempSync(join(tmpdir(), 'rewrite-deps-')); + try { + const pkgPath = join(dir, 'package.json'); + writeFileSync( + pkgPath, + JSON.stringify( + { + name: 'test-pkg', + dependencies: { '@vybestack/llxprt-code-core': '^1.0.0' }, + devDependencies: { '@vybestack/llxprt-code-agents': '^1.0.0' }, + optionalDependencies: { '@vybestack/llxprt-code-policy': '^1.0.0' }, + }, + null, + 2, + ), + ); + const tarballMap = new Map([ + ['@vybestack/llxprt-code-core', '/cache/core-1.0.0.tgz'], + ['@vybestack/llxprt-code-agents', '/cache/agents-1.0.0.tgz'], + ['@vybestack/llxprt-code-policy', '/cache/policy-1.0.0.tgz'], + ]); + const mod = loadReleasePackHelper(); + mod.rewriteOnePkgDeps(pkgPath, tarballMap); + const result = JSON.parse(readFileSync(pkgPath, 'utf8')); + expect(result.dependencies['@vybestack/llxprt-code-core']).toBe( + 'file:/cache/core-1.0.0.tgz', + ); + expect(result.devDependencies['@vybestack/llxprt-code-agents']).toBe( + 'file:/cache/agents-1.0.0.tgz', + ); + expect(result.optionalDependencies['@vybestack/llxprt-code-policy']).toBe( + 'file:/cache/policy-1.0.0.tgz', + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('does NOT rewrite peerDependencies (peers are consumer-provided)', () => { + const dir = mkdtempSync(join(tmpdir(), 'rewrite-peers-')); + try { + const pkgPath = join(dir, 'package.json'); + const originalPeerSpec = '^1.0.0'; + writeFileSync( + pkgPath, + JSON.stringify( + { + name: 'test-pkg', + dependencies: { '@vybestack/llxprt-code-core': '^1.0.0' }, + peerDependencies: { + '@vybestack/llxprt-code-core': originalPeerSpec, + }, + }, + null, + 2, + ), + ); + const tarballMap = new Map([ + ['@vybestack/llxprt-code-core', '/cache/core-1.0.0.tgz'], + ]); + const mod = loadReleasePackHelper(); + mod.rewriteOnePkgDeps(pkgPath, tarballMap); + const result = JSON.parse(readFileSync(pkgPath, 'utf8')); + // dependencies ARE rewritten. + expect(result.dependencies['@vybestack/llxprt-code-core']).toBe( + 'file:/cache/core-1.0.0.tgz', + ); + // peerDependencies are NOT rewritten — the original spec is preserved. + expect(result.peerDependencies['@vybestack/llxprt-code-core']).toBe( + originalPeerSpec, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/tests/issue-2603-release-pack.cjs b/scripts/tests/issue-2603-release-pack.cjs new file mode 100644 index 000000000..5d2531c1f --- /dev/null +++ b/scripts/tests/issue-2603-release-pack.cjs @@ -0,0 +1,500 @@ +'use strict'; + +/** + * Release-like CLI pack helper for issue #2603 offline install tests. + * + * The raw workspace tarball has `file:` internal deps that fail in an isolated + * install. This helper produces TWO distinct artifacts from a temporary repo + * copy (the real repo is never mutated): + * + * 1. RELEASE ARTIFACT (integrity): the CLI tarball after `bind-release-deps` + * rewrites every `file:`/`workspace:` internal dep to an exact version + * range. Its manifest is asserted to have NO `file:`/`workspace:`/`link:` + * deps and to contain the required assets (launcher, installer, entry, + * README, LICENSE). This is the release-faithful manifest shape. + * + * 2. LOCAL-INSTALL REPLICA: a separate, offline-installable variant where the + * CLI's exact-version internal deps are repointed at packed local tarballs + * so `npm install` succeeds without network/registry access. This is a + * test transport, NOT the release artifact — the release artifact above is + * the integrity reference. + * + * Both are cached in a temp dir and reused across test runs. + */ + +const { spawnSync } = require('node:child_process'); +const { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, + mkdtempSync, + cpSync, + copyFileSync, +} = require('node:fs'); +const { join } = require('node:path'); +const { tmpdir } = require('node:os'); +const { npmInvocation } = require('../lib/npm-command.cjs'); +const { + spawnTarList, + findTarballName: sharedFindTarballName, +} = require('../lib/tar-command.cjs'); + +/** + * Derive the cache dir, filename, and version from the CLI manifest so the + * cache key tracks the actual published version. A stale hardcoded name would + * serve a wrong-version artifact after a version bump. + */ +function readCliManifest(repoRoot) { + const cliPkgPath = join(repoRoot, 'packages', 'cli', 'package.json'); + const cliPkg = JSON.parse(readFileSync(cliPkgPath, 'utf8')); + const version = cliPkg.version || '0.0.0'; + const name = cliPkg.name || '@vybestack/llxprt-code'; + const tarballName = `${name.replace(/^@/, '').replace(/\//g, '-')}-${version}.tgz`; + return { name, version, tarballName }; +} + +/** + * Process-specific cache directory (PID + source fingerprint) avoids concurrent + * test processes corrupting a shared cache. The fingerprint is derived from the + * CLI manifest content (SHA-256) so a source change invalidates the cache + * deterministically regardless of checkout mtime (which varies across clones, + * CI reruns, and git operations like checkout/reset). The dir is cleaned when + * practical (same-process reuse is fine if both artifacts exist and the + * fingerprint is stable). + */ +function sourceFingerprint(repoRoot) { + const crypto = require('node:crypto'); + const fs = require('node:fs'); + const { join } = require('node:path'); + const hasher = crypto.createHash('sha256'); + // Hash the CLI manifest content (the primary source of truth for what gets + // packed) AND the packing scripts so a change to the packing logic + // invalidates the cache, preventing stale artifacts from being served. + const fingerprintFiles = [ + join(repoRoot, 'packages', 'cli', 'package.json'), + join(repoRoot, 'package.json'), + join(repoRoot, 'scripts', 'bind-release-deps.ts'), + join(repoRoot, 'scripts', 'prepare-package.ts'), + join(repoRoot, 'scripts', 'lib', 'npm-command.cjs'), + join(repoRoot, 'scripts', 'tests', 'issue-2603-release-pack.cjs'), + ]; + for (const f of fingerprintFiles) { + try { + const data = fs.readFileSync(f); + hasher.update(f).update(data); + } catch { + // A missing/unreadable fingerprint file is non-fatal; it simply does + // not contribute to the hash. The manifest is always present for a + // valid repo, so a missing file indicates a broken checkout that will + // fail later with a clearer error. + } + } + return hasher.digest('hex').slice(0, 16); +} + +function processCacheDir(repoRoot) { + const { version } = readCliManifest(repoRoot); + const fp = sourceFingerprint(repoRoot); + return join( + tmpdir(), + `llxprt-2603-release-cache-v${version}-${process.pid}-${fp}`, + ); +} + +// Shared list of non-NPM release packages, imported from a single .cjs source +// so release-pack.cjs and release-install-smoke.cjs stay in sync without +// manual duplication. +const { + NON_NPM_RELEASE_PACKAGES, +} = require('../lib/non-npm-release-packages.cjs'); + +let cachedReleaseTarball = null; +let cachedReplicaTarball = null; +let cachedRepoRoot = null; +let cachedFingerprint = null; + +/** + * Locate the .tgz filename in npm pack output. Delegates to the shared + * tar-command helper so spawn/validation logic is centralized. + */ +function findTarballName(packOutput, cacheDir) { + return sharedFindTarballName(packOutput, cacheDir); +} + +/** + * Returns true when both cached artifacts still exist and the cache key + * (repoRoot + source fingerprint) matches the current request. Kept as a + * separate function to avoid exceeding the conditional-operator complexity + * limit inside packReleaseLikeCli. + */ +function cacheIsValid(repoRoot, fp) { + if (!cachedReleaseTarball || !existsSync(cachedReleaseTarball)) return false; + if (!cachedReplicaTarball || !existsSync(cachedReplicaTarball)) return false; + if (cachedRepoRoot !== repoRoot) return false; + return cachedFingerprint === fp; +} + +function packReleaseLikeCli(repoRoot) { + // Cache hit only when BOTH artifacts exist AND the repoRoot + source + // fingerprint match the previous call. A stale cache from a different + // repoRoot or changed source files must not be served. + const fp = sourceFingerprint(repoRoot); + if (cacheIsValid(repoRoot, fp)) { + return { + releaseTarball: cachedReleaseTarball, + replicaTarball: cachedReplicaTarball, + }; + } + // Re-derive the process-specific cache dir from the real repoRoot so the + // fingerprint tracks the actual source being packed (not the CWD at module + // load). + const cacheDir = processCacheDir(repoRoot); + mkdirSync(cacheDir, { recursive: true }); + + const workCopy = mkdtempSync(join(tmpdir(), 'llxprt-release-copy-')); + try { + copyRepoExcludingGenerated(repoRoot, workCopy); + runPreparePackage(workCopy); + runBindReleaseDeps(workCopy); + const internalPkgs = collectInternalPackages(workCopy); + + // Assert the release-bound manifest BEFORE creating any local-install + // replica: the release artifact must have exact versions and no + // file:/workspace:/link: internal deps. + assertReleaseBoundManifest(workCopy, internalPkgs); + + // Pack the RELEASE artifact (exact-version manifest), then copy it to a + // distinct path so the subsequent replica pack (same name/version) does + // not overwrite it. + const releasePacked = packCli(workCopy, cacheDir); + const { tarballName: releaseTarballName } = readCliManifest(repoRoot); + // Stage the candidate paths locally; only publish to the module-level + // cache variables after all generation AND validation succeeds. + const stagedReleaseTarball = join( + cacheDir, + `release-${releaseTarballName}`, + ); + copyFileSync(releasePacked, stagedReleaseTarball); + + // Verify the release tarball contains required assets. + assertReleaseTarballAssets(stagedReleaseTarball); + + // Now build the SEPARATE local-install replica: repoint ALL internal + // package deps (including transitive, e.g. agents → policy) at packed + // local tarballs for offline install. + const tarballMap = packAllInternal(internalPkgs, workCopy, cacheDir); + rewriteAllDepsToTarballs(workCopy, internalPkgs, tarballMap); + // Repack internal packages so their tarballs reflect the rewritten deps. + // The return value (tarballMap) is intentionally not captured: the + // rewritten tarballs overwrite the same name-version.tgz paths from the + // first pack, so the original tarballMap's paths remain valid. If npm + // pack naming ever changes to be non-deterministic, this would surface + // as a broken install downstream. + packAllInternal(internalPkgs, workCopy, cacheDir); + const stagedReplicaTarball = packCli(workCopy, cacheDir); + + // Validate the replica tarball assets as well, so a missing file + // (launcher, installer, entry, README, LICENSE) fails here with a clear + // message instead of producing obscure downstream install errors. + assertReleaseTarballAssets(stagedReplicaTarball); + + // Publish to module-level cache only after both artifacts exist and + // validation passed. Key by repoRoot + fingerprint so a different source + // tree or changed files invalidate the cache. + cachedReleaseTarball = stagedReleaseTarball; + cachedReplicaTarball = stagedReplicaTarball; + cachedRepoRoot = repoRoot; + cachedFingerprint = fp; + + return { + releaseTarball: cachedReleaseTarball, + replicaTarball: cachedReplicaTarball, + }; + } finally { + rmSync(workCopy, { recursive: true, force: true }); + } +} + +/** + * Normalizes a repo-relative path for the copy filter so Windows backslash + * separators match consistently. The filter receives POSIX-style substrings + * from cpSync, so we match against forward-slash delimited segments. + */ +function shouldCopyRepoEntry(src, repoRoot) { + const rel = src.slice(repoRoot.length).replace(/\\/g, '/'); + if (rel === '') return true; + const skipSubstrings = [ + '/node_modules/', + '/.git/', + '/dist/', + 'node_modules/', + '.git/', + ]; + for (const s of skipSubstrings) { + if (rel.includes(s)) return false; + } + const skipPrefixes = ['/node_modules', '/.git', 'node_modules', '.git']; + for (const p of skipPrefixes) { + if (rel === p || rel.startsWith(p + '/')) return false; + } + return !rel.endsWith('.tgz'); +} + +function copyRepoExcludingGenerated(repoRoot, workCopy) { + cpSync(repoRoot, workCopy, { + recursive: true, + filter: (src) => shouldCopyRepoEntry(src, repoRoot), + }); +} + +function runPreparePackage(workCopy) { + // prepare:package copies README.md and LICENSE into packages/cli (and + // others) so the packed tarball includes them. Without this, the release + // tarball is missing required assets. + const result = spawnSync('bun', ['scripts/prepare-package.ts'], { + cwd: workCopy, + encoding: 'utf8', + timeout: 120_000, + maxBuffer: 64 * 1024 * 1024, + }); + if (result.error) { + throw new Error(`prepare:package spawn failed: ${result.error.message}`); + } + if (result.status !== 0) { + throw new Error( + `prepare:package failed (exit ${result.status}, signal=${result.signal ?? 'none'}): ${result.stderr || result.stdout}`, + ); + } +} + +function runBindReleaseDeps(workCopy) { + const bindResult = spawnSync('bun', ['scripts/bind-release-deps.ts'], { + cwd: workCopy, + encoding: 'utf8', + timeout: 300_000, + maxBuffer: 64 * 1024 * 1024, + }); + if (bindResult.error) { + throw new Error( + `bind-release-deps spawn failed: ${bindResult.error.message}`, + ); + } + if (bindResult.status !== 0) { + throw new Error( + `bind-release-deps failed (exit ${bindResult.status}, signal=${bindResult.signal ?? 'none'}): ${bindResult.stderr || bindResult.stdout}`, + ); + } +} + +function collectInternalPackages(workCopy) { + const rootPkg = JSON.parse( + readFileSync(join(workCopy, 'package.json'), 'utf8'), + ); + const internal = []; + for (const ws of rootPkg.workspaces) { + const pkgPath = join(workCopy, ws, 'package.json'); + if (!existsSync(pkgPath)) continue; + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); + if ( + pkg.private !== true && + pkg.name && + pkg.name.startsWith('@vybestack/') && + pkg.name !== '@vybestack/llxprt-code' + ) { + internal.push({ name: pkg.name, path: pkgPath }); + } + } + return internal; +} + +/** + * Assert the release-bound CLI manifest has exact version specs for every + * PUBLISHED internal dependency (no file:/workspace:/link:). Non-NPM workspace + * packages (policy, test-utils, a2a-server) are intentionally left as file: + * refs by bind-release-deps because they are never published to the registry; + * the real release pipeline resolves them at publish time. This is the + * release-integrity contract that distinguishes the real release artifact + * from the offline test replica. + */ +function assertReleaseBoundManifest(workCopy, internalPkgs) { + const publishedInternalNames = new Set( + internalPkgs + .filter((p) => !NON_NPM_RELEASE_PACKAGES.has(p.name)) + .map((p) => p.name), + ); + const cliPkgPath = join(workCopy, 'packages/cli/package.json'); + const cliPkg = JSON.parse(readFileSync(cliPkgPath, 'utf8')); + const violations = []; + for (const depField of [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', + ]) { + const deps = cliPkg[depField]; + if (!deps) continue; + for (const [depName, spec] of Object.entries(deps)) { + if (typeof spec !== 'string') continue; + // After bind-release-deps, PUBLISHED internal deps must be exact versions + // (no file:/workspace:/link:). Non-NPM internal deps and external deps + // are not subject to this constraint. + if ( + publishedInternalNames.has(depName) && + (spec.startsWith('file:') || + spec.startsWith('workspace:') || + spec.startsWith('link:')) + ) { + violations.push( + `packages/cli ${depField}.${depName} = "${spec}" (expected exact version after bind-release-deps)`, + ); + } + } + } + if (violations.length > 0) { + throw new Error( + 'Release-bound manifest integrity violations:\n - ' + + violations.join('\n - '), + ); + } +} + +/** + * Assert the release tarball contains the required assets: POSIX launcher, + * installer script, TypeScript entry, README, and LICENSE. + */ +function assertReleaseTarballAssets(releaseTarball) { + const { stdout } = spawnTarList(releaseTarball); + const files = new Set( + stdout + .split(/\r?\n/) + .map((entry) => entry.replace(/^\.\//, '').replace(/\\/g, '/')) + .filter(Boolean), + ); + const required = [ + 'package/bin/llxprt', + 'package/scripts/install-native-launchers.cjs', + 'package/index.ts', + 'package/package.json', + 'package/README.md', + 'package/LICENSE', + ]; + const missing = required.filter((p) => !files.has(p)); + if (missing.length > 0) { + throw new Error( + `Release tarball missing required assets: ${missing.join(', ')}; listed entries: ${JSON.stringify([...files])}`, + ); + } +} + +function packAllInternal(internalPkgs, workCopy, cacheDir) { + const tarballMap = new Map(); + for (const { name } of internalPkgs) { + const { command, args } = npmInvocation([ + 'pack', + '-w', + name, + '--pack-destination', + cacheDir, + ]); + const packResult = spawnSync(command, args, { + cwd: workCopy, + encoding: 'utf8', + timeout: 120_000, + maxBuffer: 64 * 1024 * 1024, + }); + if (packResult.error) { + throw new Error( + `npm pack -w ${name} spawn failed: ${packResult.error.message}`, + ); + } + if (packResult.status !== 0) { + throw new Error( + `npm pack -w ${name} failed (exit ${packResult.status}, signal=${packResult.signal ?? 'none'}): ${packResult.stderr || packResult.stdout}`, + ); + } + const tarName = findTarballName(packResult.stdout, cacheDir); + tarballMap.set(name, join(cacheDir, tarName)); + } + return tarballMap; +} + +/** + * Repoint internal package deps at packed local tarballs so the replica + * installs offline. This rewrites BOTH the CLI's deps AND every internal + * package's deps (e.g. agents → policy), because npm resolves the full + * transitive tree from each packed tarball's manifest. + */ +function rewriteAllDepsToTarballs(workCopy, internalPkgs, tarballMap) { + const cliPkgPath = join(workCopy, 'packages/cli/package.json'); + rewriteOnePkgDeps(cliPkgPath, tarballMap); + for (const { path: pkgPath } of internalPkgs) { + rewriteOnePkgDeps(pkgPath, tarballMap); + } +} + +function rewriteOnePkgDeps(pkgPath, tarballMap) { + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); + let changed = false; + // peerDependencies are intentionally NOT rewritten: peers are + // consumer-provided and must not be repointed at absolute file tarballs. + // Rewriting peers would force consumers to install an absolute local + // tarball path as a peer, breaking the peer contract. Only regular deps, + // devDeps, and optionalDeps are repointed for the offline install replica. + for (const depField of [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', + ]) { + const deps = pkg[depField]; + if (!deps) continue; + for (const [depName, spec] of Object.entries(deps)) { + if (tarballMap.has(depName) && typeof spec === 'string') { + deps[depName] = `file:${tarballMap.get(depName)}`; + changed = true; + } + } + } + if (changed) { + writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); + } +} + +function packCli(workCopy, cacheDir) { + // Derive the package name from the CLI manifest instead of hardcoding it, + // so this stays correct if the package name/scope ever changes. + const { name: cliName } = readCliManifest(workCopy); + const { command, args } = npmInvocation([ + 'pack', + '-w', + cliName, + '--pack-destination', + cacheDir, + ]); + const cliPackResult = spawnSync(command, args, { + cwd: workCopy, + encoding: 'utf8', + timeout: 120_000, + maxBuffer: 64 * 1024 * 1024, + }); + if (cliPackResult.error) { + throw new Error( + `npm pack CLI spawn failed: ${cliPackResult.error.message}`, + ); + } + if (cliPackResult.status !== 0) { + throw new Error( + `npm pack CLI failed (exit ${cliPackResult.status}, signal=${cliPackResult.signal ?? 'none'}): ${cliPackResult.stderr || cliPackResult.stdout}`, + ); + } + const cliTarName = findTarballName(cliPackResult.stdout, cacheDir); + return join(cacheDir, cliTarName); +} + +module.exports = { + packReleaseLikeCli, + readCliManifest, + findTarballName, + shouldCopyRepoEntry, + rewriteOnePkgDeps, +}; diff --git a/scripts/tests/issue-2603-shim-separator-tests.test.ts b/scripts/tests/issue-2603-shim-separator-tests.test.ts new file mode 100644 index 000000000..167144cbf --- /dev/null +++ b/scripts/tests/issue-2603-shim-separator-tests.test.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'node:module'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const thisFile = fileURLToPath(import.meta.url); +const repoRoot = resolve(thisFile, '..', '..', '..'); +const nodeRequire = createRequire(import.meta.url); +const cliModulePath = join( + repoRoot, + 'packages', + 'cli', + 'scripts', + 'install-native-launchers.cjs', +); + +/** + * Known shape of the installer module's test-only internals. + * createRequire returns an untyped CommonJS require, so the return is cast to + * this known shape rather than relying on the implicit `any`. + */ +interface CliInstallerInternals { + extractPs1ShimTargets: (content: string) => string[]; + extractCmdShimTargets: (content: string) => string[]; +} +function loadCliInstaller(): CliInstallerInternals { + delete nodeRequire.cache[cliModulePath]; + const mod = nodeRequire(cliModulePath) as CliInstallerInternals & { + _testing?: Partial; + }; + // Implementation-detail helpers are exposed under a private `_testing` + // namespace; merge them onto the top-level return for legacy `mod.X` access. + return { ...mod, ...(mod._testing || {}) } as CliInstallerInternals; +} + +describe('shim target extraction accepts both path separators', () => { + // Parameterized helper to reduce boilerplate: load module, call extractor, + // assert the expected target is present. + function assertExtract( + extractor: 'extractPs1ShimTargets' | 'extractCmdShimTargets', + content: string, + expectedTarget: string, + ): void { + const mod = loadCliInstaller(); + const targets = mod[extractor](content); + expect(targets).toContain(expectedTarget); + } + + it('extractPs1ShimTargets accepts forward-slash $basedir paths', () => { + const mod = loadCliInstaller(); + const content = [ + '$basedir = Split-Path $MyInvocation.MyCommand.Definition -Parent', + '$exe = "$basedir//bin/sh$exe"', + '$target = "$basedir/../lib/node_modules/@vybestack/llxprt-code/bin/llxprt"', + ].join('\n'); + const targets = mod.extractPs1ShimTargets(content); + // Both the interpreter and package target should be extracted. + expect(targets.length).toBeGreaterThanOrEqual(2); + expect(targets).toContain('/bin/sh$exe'); + expect(targets).toContain( + '../lib/node_modules/@vybestack/llxprt-code/bin/llxprt', + ); + }); + + it('extractPs1ShimTargets accepts backslash $basedir paths (Windows)', () => { + assertExtract( + 'extractPs1ShimTargets', + [ + '$target = "$basedir\\..\\lib\\node_modules\\@vybestack\\llxprt-code\\bin\\llxprt"', + ].join('\n'), + '..\\lib\\node_modules\\@vybestack\\llxprt-code\\bin\\llxprt', + ); + }); + + it('extractCmdShimTargets accepts backslash %dp0% paths', () => { + assertExtract( + 'extractCmdShimTargets', + [ + '@echo off', + '"/bin/sh.exe" "%dp0%\\..\\lib\\node_modules\\@vybestack\\llxprt-code\\bin\\llxprt" %*', + ].join('\n'), + '..\\lib\\node_modules\\@vybestack\\llxprt-code\\bin\\llxprt', + ); + }); + + it('extractCmdShimTargets accepts forward-slash %dp0% paths (robustness)', () => { + assertExtract( + 'extractCmdShimTargets', + ['"%dp0%/../lib/node_modules/@vybestack/llxprt-code/bin/llxprt" %*'].join( + '\n', + ), + '../lib/node_modules/@vybestack/llxprt-code/bin/llxprt', + ); + }); +}); diff --git a/scripts/tests/issue-2603-smoke-fail-fast.test.ts b/scripts/tests/issue-2603-smoke-fail-fast.test.ts new file mode 100644 index 000000000..2fd701845 --- /dev/null +++ b/scripts/tests/issue-2603-smoke-fail-fast.test.ts @@ -0,0 +1,730 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + * + * Focused unit tests for the Windows installed-command smoke harness + * infrastructure changes made for CI remediation (PR 2610, run 29850614559): + * + * - runRequiredStep fail-fast (root cause G): a required setup step that + * throws or calls fail() must RETHROW so the orchestrator aborts dependent + * checks instead of cascading. + * - runStep "OK" snapshot (root cause F): a step that calls the non-throwing + * assert()/fail() must NOT print OK. + * - pwsh resolver (root cause C): PWSH_PATH -> pwsh.exe -> powershell.exe. + * - install helpers cache args (root cause A): no isolated empty --cache. + * - benchmark env handoff (root cause E): LLXPRT_BENCH_LAUNCHER/BUN reuse. + * - bundled bun.exe PE/version validation (root cause B, J). + * + * These do NOT spawn real processes (the hosted Windows smoke is the source of + * truth); they assert the pure-function/state contracts. + */ + +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'node:module'; +import { resolve, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + writeFileSync, + readFileSync, + mkdtempSync, + rmSync, + chmodSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; + +const thisFile = fileURLToPath(import.meta.url); +const repoRoot = resolve(thisFile, '..', '..', '..'); +const nodeRequire = createRequire(import.meta.url); + +const smokeDir = join(repoRoot, 'scripts', 'windows-installed-command-smoke'); + +function loadFresh(modulePath: string): Record { + // Each require resolves the module fresh from disk (CJS caches per path; we + // delete the cache entry so resetState state is isolated per test group). + const abs = require.resolve(modulePath); + delete require.cache[abs]; + return nodeRequire(modulePath); +} + +const assertModule = () => + loadFresh(join(smokeDir, 'assert.cjs')) as { + fail: (msg: string) => void; + assert: (cond: boolean, msg: string) => boolean; + runStep: (label: string, fn: () => unknown) => unknown; + runRequiredStep: (label: string, fn: () => unknown) => unknown; + resetState: () => void; + getState: () => { failed: boolean; failures: string[] }; + }; + +describe('runRequiredStep fail-fast (root cause G)', () => { + it('rethrows when the step function throws, so dependent checks abort', () => { + const m = assertModule(); + m.resetState(); + expect(() => + m.runRequiredStep('required-install', () => { + throw new Error('npm global install spawn failed: ETIMEDOUT'); + }), + ).toThrow(/npm global install spawn failed: ETIMEDOUT/); + // The failure is recorded exactly once (no cascade). + const state = m.getState(); + expect(state.failed).toBe(true); + expect(state.failures).toHaveLength(1); + expect(state.failures[0]).toMatch( + /required-install: npm global install spawn failed/, + ); + }); + + it('treats a non-throwing fail() during a required step as fatal (rethrows)', () => { + const m = assertModule(); + m.resetState(); + // A step that calls the non-throwing fail() must still abort. + expect(() => + m.runRequiredStep('required-check', () => { + m.fail('recorded-but-no-throw'); + }), + ).toThrow(/required step "required-check" recorded failure/); + const state = m.getState(); + expect(state.failed).toBe(true); + expect( + state.failures.some((f) => f.includes('recorded-but-no-throw')), + ).toBe(true); + }); + + it('does NOT throw when the required step succeeds', () => { + const m = assertModule(); + m.resetState(); + expect(() => + m.runRequiredStep('required-ok', () => { + /* success */ + }), + ).not.toThrow(); + expect(m.getState().failed).toBe(false); + }); +}); + +describe('runStep OK snapshot (root cause F)', () => { + // We cannot easily capture stdout in this harness, but we CAN assert the + // STATE contract: a step that calls fail() leaves failed=true with the + // accumulated failures, and a subsequent getState() reflects it. The OK + // printing is gated on failures.length not increasing, which we verify + // indirectly via the failure accumulation. + + it('accumulates failures from non-throwing assert() within a step', () => { + const m = assertModule(); + m.resetState(); + m.runStep('exit-codes', () => { + for (const code of [0, 1, 5, 7]) { + m.assert(code === 0, `cmd did not preserve exit ${code}`); + } + }); + const state = m.getState(); + expect(state.failed).toBe(true); + // 3 failures recorded (for codes 1, 5, 7). + expect(state.failures).toHaveLength(3); + }); + + it('a passing step leaves the failure count unchanged', () => { + const m = assertModule(); + m.resetState(); + m.runStep('passing', () => { + m.assert(true, 'should not fail'); + }); + expect(m.getState().failed).toBe(false); + }); +}); + +describe('pwsh resolver (root cause C)', () => { + const pwshModule = () => + nodeRequire(join(smokeDir, 'pwsh-resolver.cjs')) as { + resolvePwsh: (options?: { + platform?: string; + env?: NodeJS.ProcessEnv; + spawnSync?: unknown; + existsSync?: (path: string) => boolean; + statSync?: (path: string) => { isFile: () => boolean }; + }) => string; + whereResolve: ( + command: string, + options?: { + platform?: string; + spawnSync?: unknown; + }, + ) => string | null; + }; + + it('returns PWSH_PATH when set (highest priority)', () => { + const m = pwshModule(); + const result = m.resolvePwsh({ + platform: 'win32', + env: { PWSH_PATH: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' }, + existsSync: () => true, + statSync: () => ({ isFile: () => true }), + spawnSync: () => ({ error: new Error('should not be called') }), + }); + expect(result).toBe('C:\\Program Files\\PowerShell\\7\\pwsh.exe'); + }); + + it('prefers PWSH_PATH even where.exe would resolve pwsh.exe', () => { + const m = pwshModule(); + const result = m.resolvePwsh({ + platform: 'win32', + env: { PWSH_PATH: 'C:\\explicit\\pwsh.exe' }, + existsSync: () => true, + statSync: () => ({ isFile: () => true }), + spawnSync: () => ({ + status: 0, + stdout: 'C:\\other\\pwsh.exe\r\n', + }), + }); + expect(result).toBe('C:\\explicit\\pwsh.exe'); + }); + + it('ignores a malformed PWSH_PATH and falls back to where.exe', () => { + const m = pwshModule(); + const result = m.resolvePwsh({ + platform: 'win32', + env: { PWSH_PATH: 'pwsh.exe.Source' }, + existsSync: () => false, + spawnSync: (cmd: string, args: string[]) => { + if (cmd === 'where.exe' && args[0] === 'pwsh.exe') { + return { + status: 0, + stdout: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe\r\n', + }; + } + return { status: 1, stdout: '' }; + }, + }); + expect(result).toBe('C:\\Program Files\\PowerShell\\7\\pwsh.exe'); + }); + + it('resolves pwsh.exe via where.exe when PWSH_PATH is unset', () => { + const m = pwshModule(); + const result = m.resolvePwsh({ + platform: 'win32', + env: {}, + spawnSync: (cmd: string, args: string[]) => { + if (cmd === 'where.exe' && args[0] === 'pwsh.exe') { + return { + status: 0, + stdout: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe\r\n', + }; + } + return { status: 1, stdout: '' }; + }, + }); + expect(result).toBe('C:\\Program Files\\PowerShell\\7\\pwsh.exe'); + }); + + it('falls back to powershell.exe when pwsh.exe is not found', () => { + const m = pwshModule(); + let whereCount = 0; + const result = m.resolvePwsh({ + platform: 'win32', + env: {}, + spawnSync: (cmd: string, args: string[]) => { + if (cmd !== 'where.exe') return { status: 1, stdout: '' }; + whereCount++; + if (args[0] === 'pwsh.exe') { + return { + status: 1, + stdout: '', + stderr: 'INFO: Could not find files', + }; + } + // powershell.exe exists + return { + status: 0, + stdout: + 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe\r\n', + }; + }, + }); + expect(whereCount).toBe(2); + expect(result).toMatch(/powershell\.exe$/); + }); + + it('returns bare pwsh.exe when neither is found (last-resort PATH lookup)', () => { + const m = pwshModule(); + const result = m.resolvePwsh({ + platform: 'win32', + env: {}, + spawnSync: () => ({ status: 1, stdout: '', stderr: 'not found' }), + }); + expect(result).toBe('pwsh.exe'); + }); + + it('returns pwsh on non-Windows (never invoked at runtime)', () => { + const m = pwshModule(); + const result = m.resolvePwsh({ + platform: 'darwin', + env: {}, + }); + expect(result).toBe('pwsh'); + }); + + it('whereResolve returns null on non-Windows', () => { + const m = pwshModule(); + expect(m.whereResolve('pwsh.exe', { platform: 'darwin' })).toBeNull(); + }); + + it('whereResolve returns null when where.exe fails', () => { + const m = pwshModule(); + const result = m.whereResolve('pwsh.exe', { + platform: 'win32', + spawnSync: () => ({ status: 1, stdout: '', stderr: 'not found' }), + }); + expect(result).toBeNull(); + }); +}); + +describe('install helpers cache args (root cause A)', () => { + const installModule = () => + nodeRequire(join(smokeDir, 'install-helpers.cjs')) as { + buildInstallArgs: (extraArgs: string[]) => string[]; + checkLocalCmdVersion: (consumerDir: string) => void; + }; + + it('does NOT include an isolated --cache flag (uses warmed default)', () => { + const m = installModule(); + const args = m.buildInstallArgs([ + '--global', + '--prefix', + 'C:\\prefix', + 'pkg.tgz', + ]); + expect(args).not.toContain('--cache'); + // No cache path value either. + expect(args.some((a) => /npm-cache/i.test(a))).toBe(false); + }); + + it('produces exact install args with cache-first, no-weakening flags', () => { + // Root cause K (PR 2610): three consecutive global installs hit the exact + // configured timeout ceiling despite a warmed cache, proving the install + // is blocked on avoidable registry/audit activity (not compute). The args + // must include cache-first flags and must NOT weaken lifecycle execution. + const m = installModule(); + const args = m.buildInstallArgs([ + '--global', + '--prefix', + 'C:\\prefix', + 'replica.tgz', + ]); + expect(args).toStrictEqual([ + 'install', + '--global', + '--prefix', + 'C:\\prefix', + 'replica.tgz', + '--no-audit', + '--no-fund', + '--prefer-offline', + '--loglevel', + 'error', + ]); + }); + + it('suppresses avoidable registry/audit activity via cache-first flags', () => { + const m = installModule(); + const args = m.buildInstallArgs(['pkg.tgz']); + // --no-audit: skip the blocking vulnerability audit HTTP round-trip. + expect(args).toContain('--no-audit'); + // --no-fund: skip the blocking funding metadata HTTP round-trip. + expect(args).toContain('--no-fund'); + // --prefer-offline: serve from the warmed cache, fall back to registry. + expect(args).toContain('--prefer-offline'); + // Strict --offline must NOT be used: it hard-fails on any cache miss. + expect(args).not.toContain('--offline'); + }); + + it('does NOT weaken lifecycle or install-integrity guarantees', () => { + const m = installModule(); + const args = m.buildInstallArgs(['pkg.tgz']); + // --ignore-scripts would skip the postinstall that installs native + // launchers — the entire point of the smoke. + expect(args).not.toContain('--ignore-scripts'); + // --force would clobber install-integrity protections. + expect(args).not.toContain('--force'); + }); + + it('includes the user args and loglevel', () => { + const m = installModule(); + const args = m.buildInstallArgs(['pkg.tgz']); + expect(args[0]).toBe('install'); + expect(args).toContain('pkg.tgz'); + expect(args).toContain('--loglevel'); + expect(args[args.indexOf('--loglevel') + 1]).toBe('error'); + }); + + it('checkLocalCmdVersion uses the shared invokeCmd (not raw spawnSync cmd /c)', () => { + // The install-helpers module must require invokeCmd from + // launcher-invocation.cjs so checkLocalCmdVersion uses the proven + // /d /s /c + windowsVerbatimArguments construction. We verify the module + // source imports invokeCmd rather than using a raw spawnSync('cmd', ...) + // with quote-only arguments. + const src = readFileSync(join(smokeDir, 'install-helpers.cjs'), 'utf8'); + expect(src).toMatch(/require\(['"].*launcher-invocation\.cjs['"]\)/); + expect(src).toMatch(/invokeCmd/); + // The raw spawnSync('cmd', ['/c', ...]) pattern must NOT be present for + // the version check (it lacks /d /s /c and windowsVerbatimArguments). + expect(src).not.toMatch(/spawnSync\(['"]cmd['"],\s*\['\/c'/); + }); +}); + +describe('bundled bun.exe PE validation (root cause B, J)', () => { + const bunModule = () => + nodeRequire(join(smokeDir, 'bun-validation.cjs')) as { + isWindowsPe: ( + filePath: string, + options?: { + readHeader?: (p: string, n?: number) => Buffer; + }, + ) => boolean; + assertBundledBunHealthy: ( + bunExePath: string, + expectedVersion: string, + options?: { + readHeader?: (p: string, n?: number) => Buffer; + spawnSync?: unknown; + env?: NodeJS.ProcessEnv; + timeoutMs?: number; + }, + ) => void; + MZ_MAGIC: Buffer; + PE_SIGNATURE: Buffer; + ELFANEW_OFFSET: number; + }; + + /** + * Builds a minimal valid PE header in a Buffer: MZ, e_lfanew pointing at + * offset 0x80, and "PE\0\0" at that offset. + */ + function makePeHeader(size = 256): Buffer { + const buf = Buffer.alloc(size, 0); + buf[0] = 0x4d; // M + buf[1] = 0x5a; // Z + const peOffset = 0x80; + buf.writeUInt32LE(peOffset, 0x3c); + buf.write('PE', peOffset, 'latin1'); // writes 'PE'; trailing \0\0 are implicit from Buffer.alloc(size, 0) + return buf; + } + + it('recognizes a valid MZ+PE header', () => { + const m = bunModule(); + const header = makePeHeader(); + let calledPath = ''; + const result = m.isWindowsPe('C:\\fake\\bun.exe', { + readHeader: (p) => { + calledPath = p; + return header; + }, + }); + expect(result).toBe(true); + expect(calledPath).toBe('C:\\fake\\bun.exe'); + }); + + it('rejects a non-PE file (POSIX shell script bytes)', () => { + const m = bunModule(); + const notPe = Buffer.from('#!/bin/sh\necho not a binary\n'); + const result = m.isWindowsPe('C:\\fake\\bun.exe', { + readHeader: () => notPe, + }); + expect(result).toBe(false); + }); + + it('rejects a truncated file (shorter than e_lfanew offset)', () => { + const m = bunModule(); + const truncated = Buffer.from([0x4d, 0x5a]); + const result = m.isWindowsPe('C:\\fake\\bun.exe', { + readHeader: () => truncated, + }); + expect(result).toBe(false); + }); + + it('rejects MZ magic with a bad PE offset', () => { + const m = bunModule(); + const buf = Buffer.alloc(256, 0); + buf[0] = 0x4d; + buf[1] = 0x5a; + buf.writeUInt32LE(0xff, 0x3c); // out of range + const result = m.isWindowsPe('C:\\fake\\bun.exe', { + readHeader: () => buf, + }); + expect(result).toBe(false); + }); + + it('throws when the PE check fails AND version is wrong', () => { + const m = bunModule(); + expect(() => + m.assertBundledBunHealthy('C:\\fake\\bun.exe', '1.3.14', { + readHeader: () => Buffer.from('not-a-pe'), + spawnSync: () => ({ + status: 216, + stdout: '', + stderr: 'not compatible with Windows', + }), + }), + ).toThrow(/failed health check/); + }); + + it('does NOT throw for a valid PE + correct version', () => { + const m = bunModule(); + expect(() => + m.assertBundledBunHealthy('C:\\fake\\bun.exe', '1.3.14', { + readHeader: () => makePeHeader(), + spawnSync: () => ({ + status: 0, + stdout: '1.3.14\n', + stderr: '', + }), + }), + ).not.toThrow(); + }); + + it('throws on version mismatch (right PE, wrong version)', () => { + const m = bunModule(); + expect(() => + m.assertBundledBunHealthy('C:\\fake\\bun.exe', '1.3.14', { + readHeader: () => makePeHeader(), + spawnSync: () => ({ + status: 0, + stdout: '1.2.3\n', + stderr: '', + }), + }), + ).toThrow(/version.*1\.2\.3.*1\.3\.14/); + }); + + it('rejects a real partial-download file written to disk', () => { + const m = bunModule(); + const dir = mkdtempSync(join(tmpdir(), 'pe-test-')); + try { + const partialPath = join(dir, 'partial.exe'); + // Simulate a partial download (not MZ). + writeFileSync(partialPath, Buffer.from([0x00, 0x01, 0x02, 0x03])); + expect(m.isWindowsPe(partialPath)).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('constants: expected bun version + configurable timeouts', () => { + const constantsModule = () => + nodeRequire(join(smokeDir, 'constants.cjs')) as { + EXPECTED_BUN_VERSION: string; + INSTALL_TIMEOUT_MS: number; + NPM_EXEC_TIMEOUT_MS: number; + PROBE_TIMEOUT_MS: number; + VERSION_TIMEOUT_MS: number; + resolveExpectedBunVersion: (options?: { + cliPkgPath?: string; + readFileSync?: (p: string) => string; + }) => string | undefined; + }; + + it('EXPECTED_BUN_VERSION is derived from the CLI manifest bun dependency', () => { + const m = constantsModule(); + // Read the source of truth (the CLI manifest) directly so this test + // fails if the constant drifts from the declared dependency. + const cliPkgPath = join(repoRoot, 'packages', 'cli', 'package.json'); + const cliPkg = JSON.parse(readFileSync(cliPkgPath, 'utf8')); + const declaredBun = String(cliPkg.dependencies.bun); + // The manifest must declare an EXACT version (no range prefix). The + // resolver returns the exact spec verbatim; it does NOT strip range + // prefixes and pretend a range is exact. + expect(declaredBun).toMatch(/^\d+\.\d+\.\d+/); + expect(m.EXPECTED_BUN_VERSION).toBe(declaredBun); + }); + + it('EXPECTED_BUN_VERSION is an exact semver (no range prefix stripped)', () => { + // The CLI manifest intentionally pins exact Bun. resolveExpectedBunVersion + // must return the exact manifest spec only — it must NOT strip range + // prefixes (^, ~, >=) and pretend a range is exact. If the manifest ever + // declares a range, EXPECTED_BUN_VERSION must be undefined (fail loudly) + // rather than returning a stripped base version. + const m = constantsModule(); + if (m.EXPECTED_BUN_VERSION !== undefined) { + // Must be a complete exact semver X.Y.Z (no range prefix). + expect(m.EXPECTED_BUN_VERSION).toMatch(/^\d+\.\d+\.\d+$/); + } + }); + + it('resolveExpectedBunVersion returns the exact spec for an exact pin', () => { + const m = constantsModule(); + const fakeRead = (): string => + JSON.stringify({ dependencies: { bun: '1.3.14' } }); + expect( + m.resolveExpectedBunVersion({ + cliPkgPath: 'fake', + readFileSync: fakeRead, + }), + ).toBe('1.3.14'); + }); + + it('resolveExpectedBunVersion returns undefined for a caret range (^1.3.14)', () => { + const m = constantsModule(); + const fakeRead = (): string => + JSON.stringify({ dependencies: { bun: '^1.3.14' } }); + expect( + m.resolveExpectedBunVersion({ + cliPkgPath: 'fake', + readFileSync: fakeRead, + }), + ).toBeUndefined(); + }); + + it('resolveExpectedBunVersion returns undefined for a tilde range (~1.3.14)', () => { + const m = constantsModule(); + const fakeRead = (): string => + JSON.stringify({ dependencies: { bun: '~1.3.14' } }); + expect( + m.resolveExpectedBunVersion({ + cliPkgPath: 'fake', + readFileSync: fakeRead, + }), + ).toBeUndefined(); + }); + + it('resolveExpectedBunVersion returns undefined for a digit-leading range (1.x)', () => { + const m = constantsModule(); + const fakeRead = (): string => + JSON.stringify({ dependencies: { bun: '1.x' } }); + expect( + m.resolveExpectedBunVersion({ + cliPkgPath: 'fake', + readFileSync: fakeRead, + }), + ).toBeUndefined(); + }); + + it('resolveExpectedBunVersion returns undefined when manifest is unreadable', () => { + const m = constantsModule(); + const fakeRead = (): string => { + throw new Error('ENOENT'); + }; + expect( + m.resolveExpectedBunVersion({ + cliPkgPath: 'fake', + readFileSync: fakeRead, + }), + ).toBeUndefined(); + }); + + it('resolveExpectedBunVersion returns undefined when bun field is missing', () => { + const m = constantsModule(); + const fakeRead = (): string => JSON.stringify({ dependencies: {} }); + expect( + m.resolveExpectedBunVersion({ + cliPkgPath: 'fake', + readFileSync: fakeRead, + }), + ).toBeUndefined(); + }); + + it('all timeouts are positive and env-configurable', () => { + const m = constantsModule(); + for (const t of [ + m.INSTALL_TIMEOUT_MS, + m.NPM_EXEC_TIMEOUT_MS, + m.PROBE_TIMEOUT_MS, + m.VERSION_TIMEOUT_MS, + ]) { + expect(t).toBeGreaterThan(0); + } + }); + + it('install timeout default has adequate headroom for Windows npm installs', () => { + // Evidence-based ceiling (PR 2610): the prior successful global install + // completed in 342_875 ms; the smoke then failed twice at exactly 480_000 + // ms (the old default), confirming the ceiling was too tight for runner + // variance. The default must give meaningful headroom over the observed + // success while staying within the 60-minute aggregate job budget. Two + // installs use INSTALL_TIMEOUT_MS, one npm exec uses NPM_EXEC_TIMEOUT_MS, + // and the benchmark uses 300_000 ms — their ceilings must sum to well + // under 3_600_000 ms (60 min). + const m = constantsModule(); + const observedSuccessMs = 342_875; + // At least 1.5x the observed success (~514s) so normal runner variance + // does not hit the wall. + expect(m.INSTALL_TIMEOUT_MS).toBeGreaterThanOrEqual( + Math.ceil(observedSuccessMs * 1.5), + ); + // Aggregate ceiling budget stays safely under the 60-minute job timeout. + const aggregateCeilingMs = + m.INSTALL_TIMEOUT_MS * 2 + m.NPM_EXEC_TIMEOUT_MS + 300_000; + expect(aggregateCeilingMs).toBeLessThan(3_600_000); + // Fail-fast preserved: each install still has a finite ceiling so a + // genuine hang aborts in minutes, not the full job budget. + expect(m.INSTALL_TIMEOUT_MS).toBeLessThan(3_600_000); + }); +}); + +describe('benchmark env handoff (root cause E)', () => { + const benchmarkModule = () => + nodeRequire( + join(repoRoot, 'scripts', 'tests', 'issue-2603-startup-benchmark.cjs'), + ) as { + resolveBun: () => string; + resolveDirectLauncherInvocation: () => { + command: string; + baseArgs: string[]; + }; + parseIterations: (raw: unknown) => number; + }; + + /** + * Creates a real executable file on disk so validateExecutable (POSIX X_OK) + * passes, then sets LLXPRT_BENCH_BUN and asserts resolveBun() honors it. + */ + it('resolveBun honors LLXPRT_BENCH_BUN when the file exists and is executable', () => { + const dir = mkdtempSync(join(tmpdir(), 'bench-bun-')); + try { + const bunPath = join(dir, 'fake-bun'); + writeFileSync(bunPath, '#!/bin/sh\\necho 1.3.14\\n'); + chmodSync(bunPath, 0o755); + const origEnv = process.env.LLXPRT_BENCH_BUN; + process.env.LLXPRT_BENCH_BUN = bunPath; + try { + const m = benchmarkModule(); + expect(m.resolveBun()).toBe(bunPath); + } finally { + if (origEnv === undefined) { + delete process.env.LLXPRT_BENCH_BUN; + } else { + process.env.LLXPRT_BENCH_BUN = origEnv; + } + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('resolveDirectLauncherInvocation on POSIX returns the source launcher', () => { + // On POSIX the env handoff does not apply (the launcher is the POSIX + // shell script). We assert the POSIX path is returned, proving the + // function does not crash when LLXPRT_BENCH_LAUNCHER is unset. + const origEnv = process.env.LLXPRT_BENCH_LAUNCHER; + delete process.env.LLXPRT_BENCH_LAUNCHER; + try { + const m = benchmarkModule(); + const inv = m.resolveDirectLauncherInvocation(); + expect(inv.command).toMatch(/llxprt$/); + expect(inv.baseArgs).toEqual([]); + } finally { + if (origEnv !== undefined) { + process.env.LLXPRT_BENCH_LAUNCHER = origEnv; + } + } + }); + + it('parseIterations rejects non-numeric / non-positive values', () => { + const m = benchmarkModule(); + expect(() => m.parseIterations('abc')).toThrow(/positive integer/); + expect(() => m.parseIterations(0)).toThrow(/positive integer/); + expect(() => m.parseIterations(-5)).toThrow(/positive integer/); + expect(() => m.parseIterations(1.5)).toThrow(/positive integer/); + expect(m.parseIterations('15')).toBe(15); + }); +}); diff --git a/scripts/tests/issue-2603-smoke-helpers.test.ts b/scripts/tests/issue-2603-smoke-helpers.test.ts new file mode 100644 index 000000000..d549dd174 --- /dev/null +++ b/scripts/tests/issue-2603-smoke-helpers.test.ts @@ -0,0 +1,351 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + * + * Focused unit tests for the pure validation/quoting helpers used by the + * Windows installed-command smoke harness. These do NOT spawn real processes + * (the hosted Windows smoke is the source of truth for end-to-end behavior); + * they assert the pure-function contracts of validateSpawnResult, cmdQuote, + * pwshQuote, and assertValidPid. + */ + +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'node:module'; +import { resolve, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const thisFile = fileURLToPath(import.meta.url); +const repoRoot = resolve(thisFile, '..', '..', '..'); +const nodeRequire = createRequire(import.meta.url); + +/** + * Require a smoke-harness module by relative path, reducing repetition of the + * verbose join(repoRoot, 'scripts', ...) pattern. + */ +function requireSmoke(moduleRelpath: string): Record { + return nodeRequire(join(repoRoot, ...moduleRelpath.split('/'))); +} + +const launcherInvocation = requireSmoke( + 'scripts/windows-installed-command-smoke/launcher-invocation.cjs', +) as { + probeArg: (request: Record) => string; + validateSpawnResult: (label: string, r: T) => T; + cmdQuote: (s: string) => string; + pwshQuote: (s: string) => string; +}; + +const processHelpers = requireSmoke( + 'scripts/windows-installed-command-smoke/process-helpers.cjs', +) as { + assertValidPid: (pid: unknown) => void; + MAX_LEVELS: number; +}; + +describe('validateSpawnResult', () => { + it('returns the result unchanged when there is no error and no signal', () => { + const r = { status: 0, signal: null, error: undefined, stdout: '' }; + expect(launcherInvocation.validateSpawnResult('lbl', r)).toBe(r); + }); + + it('returns the result when status is nonzero (child exit, not spawn failure)', () => { + const r = { status: 42, signal: null, error: undefined, stdout: '' }; + expect(launcherInvocation.validateSpawnResult('lbl', r)).toBe(r); + }); + + it('throws when r.error is set (spawn failure)', () => { + const r = { + status: null, + signal: null, + error: new Error('ENOENT'), + stdout: '', + }; + expect(() => + launcherInvocation.validateSpawnResult('invokeCmd', r), + ).toThrow(/invokeCmd: spawn failed: ENOENT/); + }); + + it('throws when r.signal is set (terminated by signal)', () => { + const r = { + status: null, + signal: 'SIGTERM', + error: undefined, + stdout: '', + }; + expect(() => + launcherInvocation.validateSpawnResult('invokePwsh', r), + ).toThrow(/invokePwsh: terminated by signal SIGTERM/); + }); + + it('does NOT throw for a legitimate nonzero status', () => { + const r = { status: 1, signal: null, error: undefined, stdout: '' }; + expect(() => + launcherInvocation.validateSpawnResult('lbl', r), + ).not.toThrow(); + }); +}); + +describe('cmdQuote', () => { + it('wraps a plain argument in double quotes', () => { + expect(launcherInvocation.cmdQuote('hello')).toBe('"hello"'); + }); + + it('doubles internal double quotes', () => { + expect(launcherInvocation.cmdQuote('a"b')).toBe('"a""b"'); + }); + + it('doubles percent signs so a literal % survives the batch parser', () => { + expect(launcherInvocation.cmdQuote('100%done')).toBe('"100%%done"'); + }); + + it('doubles every percent in a sequence', () => { + expect(launcherInvocation.cmdQuote('%%')).toBe('"%%%%"'); + }); + + it('preserves spaces and other metacharacters within quotes', () => { + expect(launcherInvocation.cmdQuote('a b&c|d')).toBe('"a b&c|d"'); + }); + + it('preserves a single caret (^) without doubling it', () => { + // The caret (^) is a cmd.exe escape metacharacter, but inside the quoted + // /c argument doubling it (^^) can turn one literal caret into two. The + // hosted Windows hostile-argv test passed at commit b6bdf4e1a with caret + // preserved, so cmdQuote must NOT double carets. + expect(launcherInvocation.cmdQuote('a^b')).toBe('"a^b"'); + }); + + it('handles an empty string', () => { + expect(launcherInvocation.cmdQuote('')).toBe('""'); + }); +}); + +describe('pwshQuote', () => { + it('returns simple tokens unquoted', () => { + expect(launcherInvocation.pwshQuote('abc123')).toBe('abc123'); + }); + + it('single-quotes and doubles internal single quotes', () => { + expect(launcherInvocation.pwshQuote("a'b")).toBe("'a''b'"); + }); + + it('wraps strings with spaces in single quotes', () => { + expect(launcherInvocation.pwshQuote('hello world')).toBe("'hello world'"); + }); + + it('wraps strings with PowerShell metacharacters in single quotes', () => { + expect(launcherInvocation.pwshQuote('a;b')).toBe("'a;b'"); + expect(launcherInvocation.pwshQuote('a|b')).toBe("'a|b'"); + expect(launcherInvocation.pwshQuote('a&b')).toBe("'a&b'"); + expect(launcherInvocation.pwshQuote('$var')).toBe("'$var'"); + }); + + it('handles combined spaces, metacharacters, and single quotes', () => { + expect(launcherInvocation.pwshQuote("it's a $test; done")).toBe( + "'it''s a $test; done'", + ); + }); +}); + +describe('assertValidPid', () => { + it('accepts a positive integer', () => { + expect(() => processHelpers.assertValidPid(1234)).not.toThrow(); + }); + + it('throws on a non-number', () => { + expect(() => processHelpers.assertValidPid('1234')).toThrow(/Invalid PID/); + }); + + it('throws on a non-integer number', () => { + expect(() => processHelpers.assertValidPid(1.5)).toThrow(/Invalid PID/); + }); + + it('throws on zero', () => { + expect(() => processHelpers.assertValidPid(0)).toThrow(/Invalid PID/); + }); + + it('throws on a negative number', () => { + expect(() => processHelpers.assertValidPid(-1)).toThrow(/Invalid PID/); + }); + + it('throws on null/undefined', () => { + expect(() => processHelpers.assertValidPid(null)).toThrow(/Invalid PID/); + expect(() => processHelpers.assertValidPid(undefined)).toThrow( + /Invalid PID/, + ); + }); + + it('throws on NaN', () => { + expect(() => processHelpers.assertValidPid(NaN)).toThrow(/Invalid PID/); + }); +}); + +describe('MAX_LEVELS', () => { + it('is the expected safety bound for BFS traversal depth', () => { + expect(typeof processHelpers.MAX_LEVELS).toBe('number'); + expect(processHelpers.MAX_LEVELS).toBe(200); + }); +}); + +/** + * Probe request/payload contract tests for the nativeExit mode. The probe + * request is base64url-encoded JSON; these verify the construction keeps the + * exit status exact so the hosted Windows behavioral check can rely on it. + * Source pattern alone is not sufficient — the round-trip must preserve the + * full uint32 value. + */ +describe('probeArg nativeExit payload contract', () => { + function decodeProbeArg(arg: string): Record { + expect(arg.startsWith('LLXPRT_PROBE_B64=')).toBe(true); + const json = Buffer.from( + arg.slice('LLXPRT_PROBE_B64='.length), + 'base64url', + ).toString('utf8'); + return JSON.parse(json) as Record; + } + + it('keeps nativeExit 9009 exact through the base64url round-trip', () => { + const arg = launcherInvocation.probeArg({ nativeExit: 9009 }); + const decoded = decodeProbeArg(arg); + expect(decoded.nativeExit).toBe(9009); + // Sanity: 9009 must NOT be truncated modulo 256 (which would be 49). + expect(decoded.nativeExit).not.toBe(9009 % 256); + }); + + it('keeps nativeExit 0 exact (valid uint32)', () => { + const arg = launcherInvocation.probeArg({ nativeExit: 0 }); + const decoded = decodeProbeArg(arg); + expect(decoded.nativeExit).toBe(0); + }); + + it('keeps the max uint32 value exact', () => { + const arg = launcherInvocation.probeArg({ nativeExit: 0xffffffff }); + const decoded = decodeProbeArg(arg); + expect(decoded.nativeExit).toBe(0xffffffff); + }); + + it('does not add nativeExit when only exit is requested', () => { + const arg = launcherInvocation.probeArg({ exit: 42 }); + const decoded = decodeProbeArg(arg); + expect(decoded.exit).toBe(42); + expect(decoded.nativeExit).toBeUndefined(); + }); + + it('keeps exit (ordinary process.exit path) exact for in-range codes', () => { + const arg = launcherInvocation.probeArg({ exit: 193 }); + const decoded = decodeProbeArg(arg); + expect(decoded.exit).toBe(193); + }); + + describe('parseProbeOutput sentinel extraction', () => { + const launcherInvocationWithParse = nodeRequire( + join( + repoRoot, + 'scripts', + 'windows-installed-command-smoke', + 'launcher-invocation.cjs', + ), + ) as { + parseProbeOutput: (stdout: string) => Record; + PROBE_SENTINEL: string; + }; + + it('extracts JSON from a dedicated sentinel line', () => { + const payload = { argv: ['test'], exit: 0 }; + const stdout = `some log line\n${launcherInvocationWithParse.PROBE_SENTINEL}${JSON.stringify(payload)}\nmore output\n`; + const result = launcherInvocationWithParse.parseProbeOutput(stdout); + expect(result).toEqual(payload); + }); + + it('falls back to brace-matching when no sentinel is present', () => { + const payload = { argv: ['test'], exit: 0 }; + const stdout = `log line\n${JSON.stringify(payload)}\n`; + const result = launcherInvocationWithParse.parseProbeOutput(stdout); + expect(result).toEqual(payload); + }); + + it('throws when stdout has no JSON object', () => { + expect(() => + launcherInvocationWithParse.parseProbeOutput('no json here'), + ).toThrow(/no JSON object/); + }); + + it('throws with context when sentinel line has invalid JSON', () => { + const stdout = `${launcherInvocationWithParse.PROBE_SENTINEL}{invalid}\n`; + expect(() => + launcherInvocationWithParse.parseProbeOutput(stdout), + ).toThrow(/sentinel line/); + }); + }); + + describe('buildInstallArgs input guard', () => { + const installHelpersModule = () => + requireSmoke( + 'scripts/windows-installed-command-smoke/install-helpers.cjs', + ) as { + buildInstallArgs: (extraArgs: string[]) => string[]; + }; + + it('throws TypeError when extraArgs is undefined', () => { + const m = installHelpersModule(); + expect(() => + m.buildInstallArgs(undefined as unknown as string[]), + ).toThrow(/must be an array/); + }); + + it('throws TypeError when extraArgs is a string', () => { + const m = installHelpersModule(); + expect(() => + m.buildInstallArgs('not-an-array' as unknown as string[]), + ).toThrow(/must be an array/); + }); + + it('accepts an empty array', () => { + const m = installHelpersModule(); + const args = m.buildInstallArgs([]); + expect(args).toStrictEqual([ + 'install', + '--no-audit', + '--no-fund', + '--prefer-offline', + '--loglevel', + 'error', + ]); + }); + + it('emits cache-first flags that suppress avoidable registry activity', () => { + // Root cause K (PR 2610, three exact-ceiling global-install timeouts): + // npm's defaults (audit=true, fund=true, prefer-offline=false) cause + // every smoke install to make blocking registry/audit HTTP round-trips + // even when the warmed cache holds a copy. The prior successful head + // completed in 342_875 ms; three consecutive runs then hit the exact + // configured ceiling, proving the install is blocked on avoidable + // network activity, not compute. These flags make installs + // deterministic and cache-first while preserving registry fallback. + const m = installHelpersModule(); + const args = m.buildInstallArgs(['pkg.tgz']); + expect(args).toContain('--no-audit'); + expect(args).toContain('--no-fund'); + expect(args).toContain('--prefer-offline'); + }); + + it('does NOT emit strict --offline (registry fallback preserved)', () => { + // --offline would hard-fail on any cache miss (e.g. metadata stall or a + // cold entry), making installs brittle. --prefer-offline prefers cached + // copies but transparently falls back to the registry when needed. + const m = installHelpersModule(); + const args = m.buildInstallArgs(['pkg.tgz']); + expect(args).not.toContain('--offline'); + }); + + it('does NOT weaken lifecycle/script execution', () => { + // --ignore-scripts would skip the postinstall that installs native + // launchers, defeating the entire smoke. --force would clobber + // install-integrity guarantees. Neither must ever appear. + const m = installHelpersModule(); + const args = m.buildInstallArgs(['pkg.tgz']); + expect(args).not.toContain('--ignore-scripts'); + expect(args).not.toContain('--force'); + }); + }); +}); diff --git a/scripts/tests/issue-2603-startup-benchmark.cjs b/scripts/tests/issue-2603-startup-benchmark.cjs new file mode 100644 index 000000000..54e05cb37 --- /dev/null +++ b/scripts/tests/issue-2603-startup-benchmark.cjs @@ -0,0 +1,432 @@ +'use strict'; + +/** + * Startup benchmark for issue #2603. + * + * Compares the direct native launcher against a simulated "old Node relay" + * baseline (node spawning Bun on the same entry). Outputs median, iterations, + * and ratio. There is NO pass/fail threshold — this is a measurement tool, + * not a gate, to avoid flaky timing assertions. + * + * Platform behavior: + * POSIX: the source launcher (packages/cli/bin/llxprt) is a POSIX shell + * script that exec's bun.exe directly. It is benchmarked as-is. + * Windows: the source launcher is a POSIX shell script that cannot be spawned + * directly by Node. Instead, the benchmark resolves the REAL installed + * .cmd launcher (produced by install-native-launchers / npm cmd-shim) via + * the release-pack smoke replica, and invokes it through `cmd /c`. This + * measures the actual Windows production path. The Node-relay baseline + * spawns node -> bun.exe on both platforms so the comparison is fair. + * + * Usage: node scripts/tests/issue-2603-startup-benchmark.cjs [repoRoot] [iterations] + * + * Default iterations: 15 (enough for a stable median without excessive CI time) + */ + +const { spawnSync } = require('node:child_process'); +const { existsSync, appendFileSync, mkdirSync } = require('node:fs'); +const { join, resolve, dirname } = require('node:path'); + +const repoRoot = resolve(process.argv[2] || process.cwd()); +const isWindows = process.platform === 'win32'; + +/** + * Validates that the iterations argument is a finite positive integer. + * A non-numeric, non-integer, or non-positive value would produce NaN/0 sample + * counts and a meaningless median. Fail with an actionable message instead. + */ +function parseIterations(raw) { + const n = Number(raw); + if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) { + throw new Error( + `iterations must be a finite positive integer (got ${JSON.stringify(raw)}). ` + + 'Usage: node scripts/tests/issue-2603-startup-benchmark.cjs [repoRoot] [iterations]', + ); + } + return n; +} + +const iterations = parseIterations(process.argv[3] || '15'); +const posixLauncher = join(repoRoot, 'packages', 'cli', 'bin', 'llxprt'); +const repoBun = join(repoRoot, 'node_modules', 'bun', 'bin', 'bun.exe'); +// POSIX alternate: some installers place bun at node_modules/.bun/bin/bun. +const repoBunPosix = join(repoRoot, 'node_modules', '.bun', 'bin', 'bun'); +const entry = process.env.LLXPRT_BENCH_ENTRY + ? resolve(process.env.LLXPRT_BENCH_ENTRY) + : join(repoRoot, 'packages', 'cli', 'index.ts'); + +/** + * Cross-platform Bun discovery. The bun npm package installs bun at + * node_modules/bun/bin/bun.exe on all platforms (Windows, macOS, Linux) — + * the .exe suffix is part of the filename regardless of OS. On POSIX, + * node_modules/.bun/bin/bun is also checked as a fallback for alternate + * installers before falling back to a global lookup. + */ +function resolveBun() { + // SMOKE HANDOFF: reuse the exact bun.exe the smoke validated (PE + version). + const envBun = process.env.LLXPRT_BENCH_BUN; + if (envBun && existsSync(envBun)) { + validateExecutable(envBun); + return envBun; + } + if (existsSync(repoBun)) { + validateExecutable(repoBun); + return repoBun; + } + if (!isWindows && existsSync(repoBunPosix)) { + validateExecutable(repoBunPosix); + return repoBunPosix; + } + const tool = isWindows ? 'where' : 'which'; + const r = spawnSync(tool, ['bun'], { encoding: 'utf8' }); + if (r.error) { + throw new Error( + `Could not discover bun via '${tool}': ${r.error.message}. ` + + 'Ensure Bun is installed and on PATH.', + ); + } + if (r.status !== 0) { + throw new Error( + `'${tool} bun' exited ${r.status}: ${r.stderr || r.stdout}. ` + + 'Ensure Bun is installed and on PATH.', + ); + } + const found = r.stdout.trim().split(/\r?\n/)[0]; + if (!found) { + throw new Error(`'${tool} bun' produced no output.`); + } + validateExecutable(found); + return found; +} + +/** + * Validates that a resolved Bun path is actually executable (exists and has + * execute permission on POSIX). A non-executable file would produce a confusing + * spawn failure. + */ +function validateExecutable(bunPath) { + if (!existsSync(bunPath)) { + throw new Error(`Resolved bun path does not exist: ${bunPath}`); + } + if (!isWindows) { + try { + const { accessSync, constants } = require('node:fs'); + accessSync(bunPath, constants.X_OK); + } catch { + throw new Error(`Resolved bun path is not executable: ${bunPath}`); + } + } +} + +/** + * On Windows, resolves the real installed .cmd launcher. Two modes: + * - SMOKE HANDOFF (preferred): when LLXPRT_BENCH_LAUNCHER is set (by the + * smoke orchestrator), use it directly. This avoids repacking/reinstalling + * — the smoke already proved the install, so the benchmark reuses it. + * - STANDALONE: when run directly by the workflow, pack+install a replica + * from the release-pack helper. This is the fallback for when the + * benchmark runs as a separate workflow step. + * Returns {command, args} for spawnSync. On POSIX, returns the source launcher + * path for direct exec. + */ +function resolveDirectLauncherInvocation() { + if (!isWindows) { + return { command: posixLauncher, baseArgs: [] }; + } + // SMOKE HANDOFF: reuse the installed launcher the smoke already built. + const envLauncher = process.env.LLXPRT_BENCH_LAUNCHER; + if (envLauncher && existsSync(envLauncher)) { + return { command: 'cmd', baseArgs: ['/c', envLauncher] }; + } + // STANDALONE: use the real installed .cmd launcher from the smoke replica. + // The release-pack helper produces a release-like tarball whose postinstall + // generates llxprt.cmd. We resolve it from the smoke replica's global prefix. + const releasePackHelper = join( + repoRoot, + 'scripts', + 'tests', + 'issue-2603-release-pack.cjs', + ); + const { packReleaseLikeCli } = require(releasePackHelper); + const { replicaTarball } = packReleaseLikeCli(repoRoot); + + // Install into a temp prefix to get the generated .cmd launcher. + const { mkdtempSync, rmSync } = require('node:fs'); + const { tmpdir } = require('node:os'); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-bench-')); + const prefix = join(tempDir, 'prefix'); + mkdirSync(prefix, { recursive: true }); + + const { npmInvocation } = require('../lib/npm-command.cjs'); + const { command: npmCmd, args: npmArgs } = npmInvocation([ + 'install', + '--global', + '--prefix', + prefix, + '--cache', + join(tempDir, 'cache'), + '--loglevel', + 'error', + replicaTarball, + ]); + const installResult = spawnSync(npmCmd, npmArgs, { + encoding: 'utf8', + timeout: 180_000, + maxBuffer: 64 * 1024 * 1024, + }); + if (installResult.error) { + throw new Error( + `benchmark replica install spawn failed: ${installResult.error.message}`, + ); + } + if (installResult.status !== 0) { + throw new Error( + `benchmark replica install failed (exit ${installResult.status}): ${installResult.stderr}`, + ); + } + + const cmdLauncher = join(prefix, 'llxprt.cmd'); + if (!existsSync(cmdLauncher)) { + throw new Error(`Installed .cmd launcher not found at ${cmdLauncher}`); + } + // The .cmd launcher lives inside tempDir, so cleanup must wait until the + // process exits. Register an exit handler to remove the temp install dir + // so a standalone benchmark run does not leak it. + const tempDirRef = tempDir; + const cleanup = () => { + try { + rmSync(tempDirRef, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + }; + process.on('exit', cleanup); + return { command: 'cmd', baseArgs: ['/c', cmdLauncher] }; +} + +function timeDirectLauncher(launcherInvocation) { + // The production launcher: resolves package-local Bun and execs the entry. + // Use stdio 'inherit' to match the relay baseline so the comparison + // measures startup overhead, not I/O plumbing differences. + const r = spawnSync( + launcherInvocation.command, + [...launcherInvocation.baseArgs, '--version'], + { + cwd: repoRoot, + encoding: 'utf8', + timeout: 30_000, + stdio: 'inherit', + env: { ...process.env }, + }, + ); + if (r.error) { + throw new Error(`direct launcher spawn failed: ${r.error.message}`); + } + // Never coerce a null/signal/timeout status to 0. A null status means the + // child was killed by a signal or timed out; surface it as a failure. + if (r.status === null) { + throw new Error( + `direct launcher did not exit normally (signal=${r.signal ?? 'none'}): ${r.stderr}`, + ); + } + if (r.status !== 0) { + throw new Error(`direct launcher exited ${r.status}: ${r.stderr}`); + } + return r.status; +} + +function timeNodeRelayBaseline(bunExe) { + // Simulates the OLD relay path: node starts, locates Bun, then spawns Bun + // on the entry. We pass bun/entry as argv to the relay script rather than + // interpolating them into the generated source, so there is no string + // injection surface (the values are never reparsed as code). + // stdio 'inherit' matches the direct launcher for a fair comparison. + const relayScript = ` + const { spawnSync } = require('child_process'); + // When Node runs 'node -e