From 3b3e532db23ab3ab968be4fbcad993d3e48b60a1 Mon Sep 17 00:00:00 2001 From: acoliver Date: Tue, 21 Jul 2026 10:37:17 -0300 Subject: [PATCH 01/18] Launch installed CLI directly with bundled Bun (Fixes #2603) --- .github/workflows/ci.yml | 24 +- .github/workflows/interactive-ui.yml | 4 +- .github/workflows/nightly.yml | 4 +- .github/workflows/smoke-test.yml | 6 +- .../workflows/windows-installed-command.yml | 71 ++ .prettierignore | 3 + CHANGELOG.md | 6 +- CONTRIBUTING.md | 10 +- README.md | 15 +- bun.lock | 2 +- dev-docs/npm.md | 51 +- docs/deployment.md | 8 +- docs/getting-started.md | 13 +- docs/troubleshooting.md | 2 +- eslint.config.js | 6 +- package-lock.json | 7 +- package.json | 5 +- packages/cli/bin/llxprt | 84 ++ packages/cli/bin/llxprt.cjs | 403 -------- packages/cli/package.json | 4 +- .../cli/scripts/install-native-launchers.cjs | 446 +++++++++ packages/cli/src/launcher/cli-bin.e2e.test.ts | 344 ------- packages/cli/src/launcher/cli-bin.test.ts | 353 ------- packages/test-utils/src/cli-args.test.ts | 2 +- packages/test-utils/src/cli-args.ts | 6 +- packages/test-utils/src/test-rig.ts | 14 +- scripts/bun-build.config.mjs | 5 +- scripts/postinstall.cjs | 31 + scripts/start.ts | 15 +- scripts/test-acp-integration.mjs | 4 +- scripts/tests/bun-script-migration.test.ts | 23 +- .../tests/issue-2603-install-layouts.test.ts | 438 +++++++++ ...-install-native-launchers-contract.test.ts | 202 ++++ scripts/tests/issue-2603-install.test.ts | 896 ++++++++++++++++++ scripts/tests/issue-2603-launcher.test.ts | 650 +++++++++++++ .../issue-2603-release-install-smoke.cjs | 339 +++++++ .../tests/issue-2603-release-install.test.ts | 194 ++++ scripts/tests/issue-2603-release-pack.cjs | 460 +++++++++ .../tests/issue-2603-startup-benchmark.cjs | 227 +++++ scripts/tests/issue-2603-windows-probe.ts | 145 +++ scripts/tests/publish-integrity.test.ts | 18 +- scripts/tests/smoke-cli-entry.test.ts | 17 +- scripts/tests/tmux-harness.test.js | 14 +- scripts/tmux-script.approval-ui.json | 2 +- .../tmux-script.issue2208-newlines.fake.json | 2 +- scripts/tmux-script.lsp-diag-smoke.json | 2 +- scripts/tmux-script.lsp-smoke.json | 2 +- scripts/tmux-script.slash-autocomplete.json | 2 +- scripts/windows-installed-command-smoke.cjs | 144 +++ .../assert.cjs | 48 + .../checks.cjs | 537 +++++++++++ .../constants.cjs | 19 + .../install-helpers.cjs | 112 +++ .../launcher-invocation.cjs | 69 ++ .../package-layout.cjs | 66 ++ .../process-helpers.cjs | 104 ++ 56 files changed, 5471 insertions(+), 1209 deletions(-) create mode 100644 .github/workflows/windows-installed-command.yml create mode 100755 packages/cli/bin/llxprt delete mode 100755 packages/cli/bin/llxprt.cjs create mode 100644 packages/cli/scripts/install-native-launchers.cjs delete mode 100644 packages/cli/src/launcher/cli-bin.e2e.test.ts delete mode 100644 packages/cli/src/launcher/cli-bin.test.ts create mode 100644 scripts/tests/issue-2603-install-layouts.test.ts create mode 100644 scripts/tests/issue-2603-install-native-launchers-contract.test.ts create mode 100644 scripts/tests/issue-2603-install.test.ts create mode 100644 scripts/tests/issue-2603-launcher.test.ts create mode 100644 scripts/tests/issue-2603-release-install-smoke.cjs create mode 100644 scripts/tests/issue-2603-release-install.test.ts create mode 100644 scripts/tests/issue-2603-release-pack.cjs create mode 100644 scripts/tests/issue-2603-startup-benchmark.cjs create mode 100644 scripts/tests/issue-2603-windows-probe.ts create mode 100644 scripts/windows-installed-command-smoke.cjs create mode 100644 scripts/windows-installed-command-smoke/assert.cjs create mode 100644 scripts/windows-installed-command-smoke/checks.cjs create mode 100644 scripts/windows-installed-command-smoke/constants.cjs create mode 100644 scripts/windows-installed-command-smoke/install-helpers.cjs create mode 100644 scripts/windows-installed-command-smoke/launcher-invocation.cjs create mode 100644 scripts/windows-installed-command-smoke/package-layout.cjs create mode 100644 scripts/windows-installed-command-smoke/process-helpers.cjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af96de047d..7bff7acd1e 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 7fca1d0e25..766a62362a 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 941349abc1..c670ba5536 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 3afa0c6480..e05eba8bfc 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 0000000000..addad64fc7 --- /dev/null +++ b/.github/workflows/windows-installed-command.yml @@ -0,0 +1,71 @@ +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/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/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' + timeout-minutes: 30 + permissions: + contents: 'read' + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' + with: + fetch-depth: 1 + + - name: 'Set up Node.js' + uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: 'Setup Bun' + uses: 'oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76' + with: + bun-version-file: '.bun-version' + no-cache: true + + - name: 'Install dependencies (npm ci)' + run: 'npm ci' + + - name: 'Run Windows installed-command behavioral smoke' + run: 'node scripts/windows-installed-command-smoke.cjs' + + - name: 'Run startup benchmark (measurement only, no threshold)' + run: 'node scripts/tests/issue-2603-startup-benchmark.cjs . 15' diff --git a/.prettierignore b/.prettierignore index 2271cfa3ed..cfac7b3d98 100644 --- a/.prettierignore +++ b/.prettierignore @@ -55,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 1a102acbc5..b2da84bbca 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 631e3338e2..ac31233ba7 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 package-local Bun by walking ancestor directories for `node_modules/bun/bin/bun.exe` (the package's pinned Bun dependency), with no global PATH fallback, and 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 67c9bbd9d9..89b66941d0 100644 --- a/README.md +++ b/README.md @@ -104,23 +104,22 @@ 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. `node_modules/bun/bin/bun.exe` (the package's pinned Bun dependency, climbing ancestor directories) +2. `node_modules/.bin/bun` (the `.bin` shim, climbing ancestor directories) -If no Bun runtime is found, the launcher prints an error with instructions: +If no package-local Bun runtime is found, the launcher prints an actionable error (exit code 43): -> 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. **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 eb223b079e..f82561b4d3 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 ffde2ab9f3..d511318649 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 a5222e4b4b..39601df93e 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 b5d7e855e7..9a726b0ca8 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -23,23 +23,22 @@ 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. `node_modules/bun/bin/bun.exe` (the package's pinned `bun` dependency, climbing ancestor directories) +2. `node_modules/.bin/bun` / `node_modules/.bin/bun.exe` (the bundled Bun bin link) -If no Bun runtime is found, the launcher prints an error with instructions: +The launcher resolves only the package-local Bun — it never falls back to a global `bun` on `PATH`, so a separately installed Bun is not required. 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 bab41946ea..dcba64d7a7 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -277,7 +277,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 9c6cbd6d02..e1e18c4088 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -837,7 +837,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 52ca0b0dd1..266d4cb77b 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 b0e5f1a028..bfc9a9917a 100644 --- a/package.json +++ b/package.json @@ -112,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": { @@ -147,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 0000000000..4ec53282ad --- /dev/null +++ b/packages/cli/bin/llxprt @@ -0,0 +1,84 @@ +#!/bin/sh +set -u + +_llxprt_self=$0 +while [ -L "$_llxprt_self" ]; do + _llxprt_dir=$(dirname -- "$_llxprt_self") + _llxprt_target=$(readlink -- "$_llxprt_self" 2>/dev/null || printf '%s\n' "$_llxprt_self") + 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") + +_llxprt_search=$_llxprt_script_dir +_llxprt_bun="" +while true; do + if [ -x "$_llxprt_search/node_modules/bun/bin/bun.exe" ]; then + _llxprt_bun=$_llxprt_search/node_modules/bun/bin/bun.exe + break + fi + if [ -x "$_llxprt_search/node_modules/.bin/bun" ]; then + _llxprt_bun=$_llxprt_search/node_modules/.bin/bun + break + fi + if [ -x "$_llxprt_search/node_modules/.bin/bun.exe" ]; then + _llxprt_bun=$_llxprt_search/node_modules/.bin/bun.exe + break + fi + _llxprt_parent=$(dirname -- "$_llxprt_search") + if [ "$_llxprt_parent" = "$_llxprt_search" ]; then + break + fi + _llxprt_search=$_llxprt_parent +done + +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` (a single tiny process that opens the file directly, +# no dd pipe) lets us reject non-native binaries with an actionable exit 43 +# without spawning Bun. Accepted magics: ELF (7f454c46), Mach-O +# (feedface/feedfacf/cefaedfe/cffaedfe/fat cafebabe/bebafeca), and PE/COFF +# (4d5a, "MZ") for POSIX shells that can execute Windows PE via the OS or an +# emulation layer such as MSYS/Git Bash. +_llxprt_magic=$(od -An -tx1 -N4 -- "$_llxprt_bun" 2>/dev/null | tr -d ' \n') +case "$_llxprt_magic" in + 4d5a*|7f454c46|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 or the wrong architecture.' >&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 + +_llxprt_entry="" +for _llxprt_candidate in \ + "$_llxprt_script_dir/index.ts" \ + "$_llxprt_script_dir/../index.ts" \ + "$_llxprt_script_dir/../../index.ts" \ + "$_llxprt_script_dir/../../../index.ts"; do + if [ -f "$_llxprt_candidate" ]; then + _llxprt_entry=$_llxprt_candidate + break + fi +done + +if [ -z "$_llxprt_entry" ]; then + printf '%s\n' 'LLxprt Code: TypeScript entry point (index.ts) was not found.' >&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 8554880c20..0000000000 --- a/packages/cli/bin/llxprt.cjs +++ /dev/null @@ -1,403 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -const { spawn, spawnSync } = require('node:child_process'); -const { accessSync, constants, readFileSync, statSync } = require('node:fs'); -const { basename, dirname, join } = require('node:path'); - -const BUN_RELAUNCH_ENV = 'LLXPRT_BUN_RELAUNCHED'; -const FORWARDED_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK']; -const SIGHUP_SELF_EXIT_DELAY_MS = 5_000; -const ORPHAN_CHECK_INTERVAL_MS = 10_000; -const SIGNAL_EXIT_CODES = { - SIGHUP: 129, - 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 !== dirname(dir)) { - dirs.push(dir); - dir = dirname(dir); - } - dirs.push(dir); - return dirs; -} - -function isFile(path) { - try { - return statSync(path).isFile(); - } catch { - return false; - } -} - -function isExecutable(path) { - try { - accessSync(path, constants.X_OK); - return isSpawnableUnixCandidate(path); - } catch { - return false; - } -} - -function isSpawnableUnixCandidate(path) { - 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() { - // 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(__dirname), 'index.ts'); - if (isFile(packageRootEntry)) { - return packageRootEntry; - } - - for (const dir of ancestors(__dirname)) { - 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() { - return process.platform === 'win32' ? ['bun.exe', 'bun.cmd'] : ['bun']; -} - -function directBunNames() { - // The bun npm package ships its binary as bun.exe on every platform (the - // postinstall replaces the placeholder in-place), but check the bare name - // too in case a future version drops the .exe suffix on Unix. - return process.platform === 'win32' - ? ['bun.exe', 'bun.cmd'] - : ['bun.exe', 'bun']; -} - -function resolveBunFromNodeModules() { - for (const dir of ancestors(__dirname)) { - 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 resolveBunFromPath() { - const tool = process.platform === 'win32' ? 'where' : 'which'; - const result = spawnSync(tool, ['bun'], { - encoding: 'utf8', - windowsHide: true, - shell: process.platform === 'win32', - }); - if (result.status !== 0 || typeof result.stdout !== 'string') { - return null; - } - for (const line of result.stdout.split(/\r?\n/)) { - const candidate = line.trim().replace(/^(["'])(.+?)\1$/, '$2'); - if (candidate.length > 0 && isExecutable(candidate)) { - return candidate; - } - } - return null; -} - -function resolveBun() { - return resolveBunFromNodeModules() ?? resolveBunFromPath(); -} - -function hasWindowsCmdMetaCharacter(arg) { - return /[&|<>^()%!"\r\n]/.test(arg); -} - -function isWindowsCmdShim(path) { - return ( - process.platform === 'win32' && 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}\n`); - 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 buildSpawnArgs(bunPath, entry) { - const args = [entry, ...process.argv.slice(2)]; - if (isWindowsCmdShim(bunPath) && 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.', - }; - } - return { args }; -} - -function createChildEnv() { - return { ...process.env, [BUN_RELAUNCH_ENV]: 'true' }; -} - -async function runCliBin(options = {}) { - 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 = buildSpawnArgs(bunPath, entry); - if ('error' in built) { - fatalExit(exit, built.error); - return; - } - - let child; - try { - child = spawnFn(bunPath, built.args, { - stdio: 'inherit', - env: createChildEnv(), - shell: isWindowsCmdShim(bunPath), - }); - } 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 { - // Child may have already exited before the signal handler fired. - } - // 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(SIGNAL_EXIT_CODES['SIGHUP']); - }, selfExitDelayMs); - hangupExitTimer.unref(); - } - }; - - const onClose = (code, signal) => { - settle(exitCodeFromChild(code, signal)); - }; - - const onError = (error) => { - if (!prepareSettle()) { - return; - } - try { - child.kill('SIGTERM'); - } catch { - // Child may have already exited before the async spawn error surfaced. - } - process.stderr.write(`${bunLaunchErrorMessage(bunPath, error)} -`); - exit(43); - }; - - const onChildExit = (code, signal) => { - childExitInfo = { code, signal }; - }; - - const onBeforeExit = () => { - 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 = () => { - 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; - 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 (require.main === module) { - runCliBin().catch((error) => { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`${message}\n`); - process.exit(1); - }); -} diff --git a/packages/cli/package.json b/packages/cli/package.json index 4d3baa3c73..858b2baced 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 0000000000..97bd36d49b --- /dev/null +++ b/packages/cli/scripts/install-native-launchers.cjs @@ -0,0 +1,446 @@ +#!/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; + +function readFileSafe(filePath) { + try { + return fs.readFileSync(filePath, 'utf8'); + } catch { + return ''; + } +} + +function hasOwnershipSentinel(filePath) { + if (!fs.existsSync(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. +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"). 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)); +} + +function pointsToOurPackage(filePath, binLinkDir, packageRoot, shimType) { + const content = readFileSafe(filePath); + if (!content) { + return false; + } + const candidates = + shimType === 'ps1' + ? extractPs1ShimTargets(content) + : extractCmdShimTargets(content); + if (candidates.length === 0) { + return false; + } + // Authorize 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. + // Use 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. + for (const candidate of candidates) { + const resolved = resolveShimCandidate(binLinkDir, candidate); + if (isWithinPackageRootWin(resolved, packageRoot)) { + return true; + } + } + return false; +} + +function canOverwriteLauncher(filePath, binLinkDir, packageRoot, shimType) { + if (!fs.existsSync(filePath)) { + return true; + } + if (hasOwnershipSentinel(filePath)) { + return true; + } + return pointsToOurPackage(filePath, 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) { + return value.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; + } + fs.writeFileSync(filePath, content, 'utf8'); + 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) { + const pkgRequire = createRequire(path.join(packageRoot, 'package.json')); + let bunPkgJsonPath; + try { + bunPkgJsonPath = pkgRequire.resolve('bun/package.json'); + } catch { + const directBunExe = path.join( + packageRoot, + 'node_modules', + 'bun', + 'bin', + 'bun.exe', + ); + if (fs.existsSync(directBunExe)) { + return directBunExe; + } + let dir = packageRoot; + while (dir !== path.dirname(dir)) { + const candidate = path.join(dir, 'node_modules', 'bun', 'bin', 'bun.exe'); + if (fs.existsSync(candidate)) { + return candidate; + } + dir = path.dirname(dir); + } + return null; + } + const bunDir = path.dirname(bunPkgJsonPath); + const bunExe = path.join(bunDir, 'bin', 'bun.exe'); + if (fs.existsSync(bunExe)) { + return bunExe; + } + return null; +} + +function resolveEntry(packageRoot) { + const entry = path.join(packageRoot, 'index.ts'); + if (fs.existsSync(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) { + 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 }; +} + +module.exports = { + installNativeLaunchers, + generateCmdLauncher, + generatePs1Launcher, + hasOwnershipSentinel, + pointsToOurPackage, + canOverwriteLauncher, + findBinLinkDirs, + nearestNodeModulesBin, + resolveBunExe, + resolveEntry, + isWithinPackageRoot, + isWithinPackageRootWin, + extractCmdShimTargets, + extractPs1ShimTargets, + resolveShimCandidate, + relativePath, + relativePathPosix, + OWNERSHIP_SENTINEL, + LAUNCHER_ERROR_EXIT_CODE, +}; + +if (require.main === module) { + installNativeLaunchers(); +} 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 73128cbf63..0000000000 --- a/packages/cli/src/launcher/cli-bin.e2e.test.ts +++ /dev/null @@ -1,344 +0,0 @@ -/** - * @license - * Copyright 2025 Vybestack LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, expect, it } from 'vitest'; -import { mkdtemp, writeFile, rm, access } from 'node:fs/promises'; -import { accessSync, constants } from 'node:fs'; -import { join, resolve } from 'node:path'; -import { tmpdir } from 'node:os'; -import { spawn, spawnSync } from 'node:child_process'; - -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' }); - 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 credentialSocketEnv = 'LLXPRT_CREDENTIAL_SOCKET'; - -interface SubprocessResult { - readonly code: number | null; - readonly signal: NodeJS.Signals | null; - readonly stdout: string; - readonly stderr: string; -} - -// 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 = 15_000, -): 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 { - // Cleanup must never mask the real assertion failure with a transient - // rm error (EBUSY/EPERM on Windows while subprocess handles release). - // The `temp-*` gitignore rule guarantees an orphan never pollutes the - // working tree even if this removal is skipped. - await rm(tempDir, { force: true, recursive: true }).catch(() => {}); - } -} - -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 1c8ea0639a..0000000000 --- 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/test-utils/src/cli-args.test.ts b/packages/test-utils/src/cli-args.test.ts index 8428202751..6de48091fc 100644 --- a/packages/test-utils/src/cli-args.test.ts +++ b/packages/test-utils/src/cli-args.test.ts @@ -92,7 +92,7 @@ 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'], diff --git a/packages/test-utils/src/cli-args.ts b/packages/test-utils/src/cli-args.ts index ed488329db..0fced34ca9 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 b10bc6f782..dd6e8f4a28 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 { @@ -154,8 +156,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; @@ -446,7 +448,7 @@ export class TestRig { env: childEnv, }; - const executable = command === 'node' ? process.execPath : command; + const executable = command === 'bun' ? 'bun' : command; const ptyProcess = pty.spawn(executable, commandArgs, ptyOptions); const run = new InteractiveRun(ptyProcess, this._diagnostics); diff --git a/scripts/bun-build.config.mjs b/scripts/bun-build.config.mjs index eb29740af0..4ef01a3463 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/postinstall.cjs b/scripts/postinstall.cjs index 71be108cb9..9db572025d 100644 --- a/scripts/postinstall.cjs +++ b/scripts/postinstall.cjs @@ -253,6 +253,34 @@ 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; + } + const { installNativeLaunchers } = require(cliInstaller); + installNativeLaunchers({ + packageRoot: path.join(repoRoot, 'packages', 'cli'), + log: console.log, + }); +} + // 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 +289,7 @@ function stripPeerFlagsFromLockfile() { // symlinks so Bun resolves the live TypeScript source tree consistently. if (detectInstaller() === 'bun') { symlinkBunWorkspaceCopies(); + installWindowsNativeLaunchers(); process.exit(0); } @@ -280,3 +309,5 @@ if (hasWorkspaceSources) { process.exit(1); } } + +installWindowsNativeLaunchers(); diff --git a/scripts/start.ts b/scripts/start.ts index 5bfdfd29e9..2601122260 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 3af0274fe6..eebbd2e849 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 5f48d702d4..e92cdd3f79 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(String.fromCharCode(10))}`, + ).toBeGreaterThan(0); } finally { rmSync(binDir, { recursive: true, force: true }); } 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 0000000000..bb701f544d --- /dev/null +++ b/scripts/tests/issue-2603-install-layouts.test.ts @@ -0,0 +1,438 @@ +/** + * @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', +); + +/** + * Derive the pnpm virtual-store fixture version from the published CLI manifest + * rather than hardcoding it. The pnpm store directory name encodes + * `+@`, so it must track packages/cli/package.json. + */ +function readCliVersion(): string { + const cliPkg = JSON.parse( + readFileSync(join(repoRoot, 'packages', 'cli', 'package.json'), 'utf8'), + ) as { version?: string }; + if (typeof cliPkg.version !== 'string' || cliPkg.version.length === 0) { + throw new Error('packages/cli/package.json is missing a version'); + } + return cliPkg.version; +} + +const CLI_VERSION = readCliVersion(); +const PNPM_PACKAGE_DIR = `@vybestack+llxprt-code@${CLI_VERSION}`; + +function loadCliInstaller(): ReturnType { + return nodeRequire(cliModulePath); +} + +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', + '@vybestack', + 'llxprt-code', + ); + 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, 'llxprt.cmd'))).toBe(true); + expect(existsSync(join(consumerDotBin, 'llxprt.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, 'llxprt.cmd'))).toBe(false); + expect(existsSync(join(virtualDotBin, 'llxprt.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, 'llxprt.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, 'llxprt.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, 'llxprt.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, 'llxprt.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', + '@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 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', + '@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 }); + mod.installNativeLaunchers({ + platform: 'win32', + packageRoot, + env: { INIT_CWD: tempDir }, + log: () => {}, + }); + expect(existsSync(join(dotBin, 'llxprt.cmd'))).toBe(true); + expect(existsSync(join(dotBin, 'llxprt.ps1'))).toBe(true); + } 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 0000000000..6bd5f6f3a8 --- /dev/null +++ b/scripts/tests/issue-2603-install-native-launchers-contract.test.ts @@ -0,0 +1,202 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } 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 { + return nodeRequire(cliModulePath); +} + +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' + String.fromCharCode(10) + 'echo 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(String.fromCharCode(10))).toBeDefined(); + expect(skipMsg).toMatch(/Skipped foreign/i); + } 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(String.fromCharCode(10))).toBeDefined(); + } finally { + 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 0000000000..eb7a8406bd --- /dev/null +++ b/scripts/tests/issue-2603-install.test.ts @@ -0,0 +1,896 @@ +import { describe, it, expect } 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 cliModulePath = join( + repoRoot, + 'packages', + 'cli', + 'scripts', + 'install-native-launchers.cjs', +); + +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]; + return nodeRequire(cliModulePath); +} + +/** + * 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; + +/** + * Cross-platform npm discovery. On Windows `which` does not exist, so use + * `where`. Prefer process.execPath / npm_execpath when available to avoid + * spawning an extra process. Returns the npm binary path. + */ +function resolveNpm(): string { + const npmExecPath = process.env.npm_execpath; + if (npmExecPath) { + return process.execPath; + } + return 'npm'; +} + +function findTarballName(packOutput: string): string { + const lines = packOutput.split(/\r?\n/); + const tgzLines = lines.filter((l) => l.trim().endsWith('.tgz')); + if (tgzLines.length === 0) { + throw new Error( + `npm pack output did not contain a .tgz line:\n${packOutput}`, + ); + } + return tgzLines[tgzLines.length - 1].trim(); +} + +function packCliWorkspace(): string { + if (cachedTarball && existsSync(cachedTarball)) { + return cachedTarball; + } + mkdirSync(sharedCacheDir, { recursive: true }); + const npmBin = resolveNpm(); + const npmArgs = + npmBin === process.execPath + ? [ + process.env.npm_execpath!, + 'pack', + '-w', + '@vybestack/llxprt-code', + '--pack-destination', + sharedCacheDir, + ] + : [ + 'pack', + '-w', + '@vybestack/llxprt-code', + '--pack-destination', + sharedCacheDir, + ]; + const result = spawnSync(npmBin, npmArgs, { + 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; +} + +/** + * Spawns tar with spawn-error diagnostics. GitHub Windows runners ship + * bsdtar; a spawn failure (ENOENT) produces a clear diagnostic rather than an + * opaque null status. + */ +function spawnTarList(tarball: string): { stdout: string } { + const result = spawnSync('tar', ['-tzf', tarball], { + encoding: 'utf8', + timeout: 30_000, + }); + if (result.error) { + throw new Error( + `Failed to spawn tar (is it on PATH? GitHub Windows has bsdtar): ${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 }; +} + +function spawnTarListVerbose( + tarball: string, + member: string, +): { stdout: string } { + const result = spawnSync('tar', ['-tzvf', tarball, member], { + encoding: 'utf8', + timeout: 30_000, + }); + if (result.error) { + throw new Error( + `Failed to spawn tar (is it on PATH? GitHub Windows has bsdtar): ${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 }; +} + +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); + const files = stdout.split('\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('\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('\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'); + expect(stdout).toMatch(/^[^ ]*x/); + }, 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 43'); + 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 43'); + }); + + 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'), + join(repoRoot, 'node_modules', 'cmd-shim'), + ]; + for (const candidate of candidates) { + try { + nodeRequire.resolve(candidate); + return candidate; + } catch { + // try next + } + } + const npmRoot = spawnSync('npm', ['root', '-g'], { + encoding: 'utf8', + timeout: 10_000, + }); + 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(String.fromCharCode(10)); + 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.status !== 0) { + throw new Error(`cmd-shim generation failed: ${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', + 'llxprt-code', + ); + 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 (llxprt-code-evil)', () => { + const mod = loadCliInstaller(); + const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-evil-')); + try { + const evilRoot = join( + tempDir, + 'prefix', + 'lib', + 'node_modules', + '@vybestack', + 'llxprt-code-evil', + ); + const binTarget = join(evilRoot, 'bin', 'llxprt'); + const binLinkDir = join(tempDir, 'bin-link'); + const ourPackageRoot = join( + tempDir, + 'prefix', + 'lib', + 'node_modules', + '@vybestack', + 'llxprt-code', + ); + 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 }); + } + }); + + 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', + 'llxprt-code', + ); + 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', + 'llxprt-code-evil', + ); + const binTarget = join(evilRoot, 'bin', 'llxprt'); + const binLinkDir = join(tempDir, 'bin-link'); + const ourPackageRoot = join( + tempDir, + 'prefix', + 'lib', + 'node_modules', + '@vybestack', + 'llxprt-code', + ); + 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', () => { + 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); + 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 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'); + 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(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'); + 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(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', + ); + 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, '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', + ); + 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 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', + ); + 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 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'); + 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(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.test.ts b/scripts/tests/issue-2603-launcher.test.ts new file mode 100644 index 0000000000..7e326ccf84 --- /dev/null +++ b/scripts/tests/issue-2603-launcher.test.ts @@ -0,0 +1,650 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawnSync, spawn } 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'); + +function ensureBun(): string { + if (existsSync(repoBun)) { + return repoBun; + } + const whichResult = spawnSync('which', ['bun'], { encoding: 'utf8' }); + if (whichResult.status === 0) { + return whichResult.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 }; +} + +describe('POSIX launcher portability', () => { + it('passes shellcheck with no warnings', () => { + const which = spawnSync('which', ['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: 15_000, + }); + 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: 5_000, + }); + 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: 5_000, + }); + 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: 5_000 }, + ); + 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: 30_000, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + }, 30_000); + + 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: 30_000, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe('true'); + }, 30_000); + + it('uses package-local Bun even with constrained PATH', () => { + const { pkgRoot, launcherTarget } = makeLayout(tempDir); + const result = spawnSync(launcherTarget, [], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: 30_000, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expect(result.status).toBe(0); + }, 30_000); + + it('invokes Bun exactly once (no pre-probe)', () => { + const bunPath = ensureBun(); + 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); + + const counterDir = join(pkgRoot, 'counter'); + mkdirSync(counterDir, { recursive: true }); + const counterFile = join(counterDir, 'invocations.txt'); + makeEntry( + pkgRoot, + `const fs = require('fs'); + const path = require('path'); + const counter = path.join(${JSON.stringify(counterDir)}, 'invocations.txt'); + 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 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: 30_000, + 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'); + }, 30_000); + + 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: 30_000, + }); + expect(result.status).toBe(0); + const parsed = JSON.parse(result.stdout.trim()) as string[]; + expect(parsed).toStrictEqual(trickyArgs); + }, 30_000); + + 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: 30_000, + }); + expect(result.status).toBe(7); + }, 30_000); + + 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: 30_000, + input: 'hello', + }); + expect(result.status).toBe(0); + expect(result.stdout).toContain('OUT:hello'); + expect(result.stderr).toContain('ERR:hello'); + }, 30_000); + + it('exits 43 when Bun is not found', () => { + const { pkgRoot, launcherTarget } = makeLayout(tempDir, { withBun: false }); + const result = spawnSync(launcherTarget, [], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: 15_000, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expect(result.status).toBe(43); + expect(result.stderr).toMatch(/npm install|bun\.sh/i); + }, 15_000); + + 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: 15_000, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expect(result.status).toBe(43); + expect(result.stderr).toMatch( + /npm install|bun\.sh|unusable|not a valid|corrupt/i, + ); + }, 15_000); + + 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: 15_000, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expect(result.status).toBe(43); + expect(result.stderr).toMatch( + /npm install|bun\.sh|unusable|not a valid|corrupt/i, + ); + }, 15_000); + + it('accepts a PE/COFF (MZ, 4d5a) Bun magic as a native binary', () => { + // 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. + const source = readFileSync(launcherPath, 'utf8'); + // The case pattern must include a 4d5a branch (with trailing * because MZ + // is a 2-byte magic and the remaining 2 bytes of the 4-byte read vary). + expect(source).toMatch(/4d5a\*/); + }); + + it('magic case-statement accepts ELF, Mach-O, and PE/COFF prefixes', () => { + // Unit-level contract: all accepted magics must appear in the case list. + const source = readFileSync(launcherPath, 'utf8'); + const caseStart = source.indexOf('case "$_llxprt_magic"'); + // Find the esac that closes the magic case (the first esac AFTER the + // magic case start), not an earlier esac from the symlink loop. + const caseEnd = source.indexOf('esac', caseStart); + const caseBlock = source.slice(caseStart, caseEnd); + expect(caseBlock).toContain('7f454c46'); // ELF + expect(caseBlock).toContain('feedface'); // Mach-O 32 BE + expect(caseBlock).toContain('feedfacf'); // Mach-O 64 BE + expect(caseBlock).toContain('cefaedfe'); // Mach-O 32 LE + expect(caseBlock).toContain('cffaedfe'); // Mach-O 64 LE + expect(caseBlock).toContain('cafebabe'); // Mach-O fat BE + expect(caseBlock).toContain('bebafeca'); // Mach-O fat LE + expect(caseBlock).toContain('4d5a'); // PE/COFF MZ + }); + + 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: 5_000 }, + ); + 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: 15_000, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expect(result.status).toBe(43); + }, 15_000); + + 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 bunPath = ensureBun(); + 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); + + const counterFile = join(pkgRoot, 'invocations.txt'); + makeEntry( + pkgRoot, + `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 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: 30_000, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expect(result.status, result.stderr).toBe(0); + expect(readFileSync(counterFile, 'utf8').trim()).toBe('1'); + }, 30_000); + + 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: 30_000, + }); + expect(result.status).toBe(42); + }, 30_000); + + 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: 15_000, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expect(result.status).toBe(43); + expect(result.stderr).toMatch(/entry point|index\.ts|corrupt/i); + }, 15_000); + + 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: 30_000, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expect(result.status).toBe(0); + }, 30_000); + + 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: 30_000, + env: { PATH: '/usr/bin:/bin' }, + }); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout.trim()).toBe('unset'); + }, 30_000); +}); + +describe('POSIX launcher signal behavior', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'llxprt-sig-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + function makeLongRunning(tempDir: string): { + pkgRoot: string; + launcherTarget: string; + pidFile: 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); + + const pidFile = join(pkgRoot, 'child-pid.txt'); + makeEntry( + pkgRoot, + [ + 'const fs = require("fs");', + 'const path = require("path");', + `fs.writeFileSync(${JSON.stringify(pidFile)}, String(process.pid));`, + 'process.stdin.resume();', + ].join('\n'), + ); + + const bunDir = join(pkgRoot, 'node_modules', 'bun', 'bin'); + mkdirSync(bunDir, { recursive: true }); + copyFileSync(ensureBun(), join(bunDir, 'bun.exe')); + + return { pkgRoot, launcherTarget, pidFile }; + } + + it('SIGINT 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: '/usr/bin:/bin' }, + }); + + let exited = false; + let exitSignal: NodeJS.Signals | null = null; + child.on('exit', (_code, signal) => { + exited = true; + exitSignal = signal; + }); + + 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, 'SIGINT'); + } catch { + child.kill('SIGINT'); + } + } else { + child.kill('SIGINT'); + } + } + 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('SIGINT'); + resolve(); + }); + }); + }, 20_000); + + it('SIGTERM 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: '/usr/bin:/bin' }, + }); + + let exited = false; + let exitSignal: NodeJS.Signals | null = null; + child.on('exit', (_code, signal) => { + exited = true; + exitSignal = signal; + }); + + 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, 'SIGTERM'); + } catch { + child.kill('SIGTERM'); + } + } else { + child.kill('SIGTERM'); + } + } + 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('SIGTERM'); + resolve(); + }); + }); + }, 20_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 0000000000..987550fbf9 --- /dev/null +++ b/scripts/tests/issue-2603-release-install-smoke.cjs @@ -0,0 +1,339 @@ +'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 { createRequire } = require('node:module'); + +const repoRoot = resolve(process.argv[2] || process.cwd()); +const nodeRequire = createRequire(__filename); +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; +} + +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:')) + ) { + throw new Error(`release manifest has non-exact dep ${name}="${spec}"`); + } + } +} + +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. GitHub Windows runners + * ship bsdtar, but a spawn failure (ENOENT) must produce a clear diagnostic + * rather than an opaque null status. + */ +function spawnTarExtract(tarball, extractDir) { + const result = spawnSync('tar', ['-xzf', tarball, '-C', extractDir], { + encoding: 'utf8', + timeout: 30_000, + }); + if (result.error) { + throw new Error( + `Failed to spawn tar (is it on PATH? GitHub Windows has bsdtar): ${result.error.message}`, + ); + } + if (result.status !== 0) { + throw new Error( + `tar extract failed (exit ${result.status}, signal=${result.signal ?? 'none'}): ${result.stderr}`, + ); + } + return result; +} + +function main() { + let tempDir; + try { + const { packReleaseLikeCli } = nodeRequire(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-')); + + // 1. Release artifact manifest integrity: exact versions, no file:/link:. + runStep('release-manifest-integrity', () => { + const extractDir = mkdtempSync(join(tmpdir(), 'llxprt-tarball-check-')); + try { + spawnTarExtract(releaseTarball, extractDir); + const pkgJson = JSON.parse( + readFileSync(join(extractDir, 'package', 'package.json'), 'utf8'), + ); + assertExactVersions(pkgJson.dependencies); + } 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 installResult = spawnSync( + 'npm', + [ + 'install', + '--global', + '--prefix', + prefix, + '--cache', + join(tempDir, 'npm-cache'), + '--loglevel', + 'error', + replicaTarball, + ], + { + 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 = join(prefix, 'bin', 'llxprt'); + assert(existsSync(binLink), `global bin link not found: ${binLink}`); + const result = spawnSync(binLink, ['--version'], { + encoding: 'utf8', + timeout: 30_000, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + 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+/.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 installResult = spawnSync( + 'npm', + [ + 'install', + '--cache', + join(tempDir, 'npm-cache-local'), + '--loglevel', + 'error', + replicaTarball, + ], + { + 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 = join(consumerDir, 'node_modules', '.bin', 'llxprt'); + assert(existsSync(binLink), `local bin link not found: ${binLink}`); + const result = spawnSync(binLink, ['--version'], { + encoding: 'utf8', + timeout: 30_000, + cwd: consumerDir, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + 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+/.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 result = spawnSync( + 'npm', + ['exec', '--package', replicaTarball, '--', 'llxprt', '--version'], + { + 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+/.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 0000000000..2e882579c7 --- /dev/null +++ b/scripts/tests/issue-2603-release-install.test.ts @@ -0,0 +1,194 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +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 NON-detached, so it belongs to the Vitest worker's + * process group. If the worker is terminated (test cancellation, aggregate + * suite teardown, parent signal), the non-detached child is reaped + * automatically by the OS without any registered handlers here. + * - 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). + */ +const SMOKE_TIMEOUT_MS = 540_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'], + }); + 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); + }; + }); + + function dispose(): void { + if (disposed) return; + disposed = true; + if (timer) { + clearTimeout(timer); + timer = null; + } + // Kill the child if still alive. The child is non-detached, so a plain + // kill() is sufficient; no process-group kill is needed. + if (child && child.exitCode === null && child.signalCode === null) { + try { + child.kill('SIGKILL'); + } catch { + // best effort; child may have exited concurrently + } + } + 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; + expect( + status, + `smoke exited ${status}\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`, + ).toBe(0); + expect(stderr).toBe(''); + 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.'); + }, 600_000); +}); diff --git a/scripts/tests/issue-2603-release-pack.cjs b/scripts/tests/issue-2603-release-pack.cjs new file mode 100644 index 0000000000..96c1b36a9c --- /dev/null +++ b/scripts/tests/issue-2603-release-pack.cjs @@ -0,0 +1,460 @@ +'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'); + +/** + * 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 { 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 path + mtime so a source change invalidates the cache. The dir + * is cleaned when practical (same-process reuse is fine if both artifacts exist + * and the fingerprint is stable). + */ +function sourceFingerprint(repoRoot) { + const cliPkgPath = join(repoRoot, 'packages', 'cli', 'package.json'); + let mtime = 0; + try { + mtime = require('node:fs').statSync(cliPkgPath).mtimeMs; + } catch { + // stat failure is non-fatal; fingerprint just lacks the mtime component. + } + return `${mtime}`.replace(/[^0-9]/g, '0'); +} + +function processCacheDir(repoRoot) { + const { version } = readCliManifest(repoRoot); + const fp = sourceFingerprint(repoRoot); + return join( + tmpdir(), + `llxprt-2603-release-cache-v${version}-${process.pid}-${fp}`, + ); +} + +const releaseCacheDir = processCacheDir( + // Compute lazily at module load using the CWD-derived repoRoot. Callers pass + // the real repoRoot into packReleaseLikeCli, which re-derives as needed. + process.cwd(), +); + +const NON_NPM_RELEASE_PACKAGES = new Set([ + '@vybestack/llxprt-code-test-utils', + '@vybestack/llxprt-code-a2a-server', + 'llxprt-code-vscode-ide-companion', +]); + +let cachedReleaseTarball = null; +let cachedReplicaTarball = null; + +/** + * Locate the .tgz filename in npm pack output. npm pack prints the tarball + * filename (ending in .tgz) on its own line, but the output may include + * warnings/progress lines. Find the .tgz line and validate the file exists + * rather than assuming the last line is the tarball name. + */ +function findTarballName(packOutput, cacheDir) { + const lines = packOutput.split(/\r?\n/); + const tgzLines = lines.filter((l) => l.trim().endsWith('.tgz')); + if (tgzLines.length === 0) { + throw new Error( + `npm pack output did not contain a .tgz line:\n${packOutput}`, + ); + } + const tarName = tgzLines[tgzLines.length - 1].trim(); + 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; +} + +function packReleaseLikeCli(repoRoot) { + // Cache hit only when BOTH artifacts exist. A single artifact (e.g. release + // packed but replica failed) must not be served as a complete result. + if ( + cachedReleaseTarball && + existsSync(cachedReleaseTarball) && + cachedReplicaTarball && + existsSync(cachedReplicaTarball) + ) { + 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. + packAllInternal(internalPkgs, workCopy, cacheDir); + const stagedReplicaTarball = packCli(workCopy, cacheDir); + + // Publish to module-level cache only after both artifacts exist and + // validation passed. + cachedReleaseTarball = stagedReleaseTarball; + cachedReplicaTarball = stagedReplicaTarball; + + 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}, timeout=${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}, timeout=${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', '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 result = spawnSync('tar', ['-tzf', releaseTarball], { + encoding: 'utf8', + timeout: 30_000, + }); + if (result.error) { + throw new Error( + `Failed to spawn tar to list release tarball: ${result.error.message}`, + ); + } + if (result.status !== 0) { + throw new Error( + `Failed to list release tarball (exit ${result.status}, signal=${result.signal ?? 'none'}): ${result.stderr || result.stdout}`, + ); + } + const files = new Set(result.stdout.split('\n')); + 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(', ')}`, + ); + } +} + +function packAllInternal(internalPkgs, workCopy, cacheDir) { + const tarballMap = new Map(); + for (const { name } of internalPkgs) { + const packResult = spawnSync( + 'npm', + ['pack', '-w', name, '--pack-destination', cacheDir], + { + 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; + for (const depField of [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + ]) { + 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) { + const cliPackResult = spawnSync( + 'npm', + ['pack', '-w', '@vybestack/llxprt-code', '--pack-destination', cacheDir], + { + 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, + releaseCacheDir, + readCliManifest, + findTarballName, + shouldCopyRepoEntry, +}; diff --git a/scripts/tests/issue-2603-startup-benchmark.cjs b/scripts/tests/issue-2603-startup-benchmark.cjs new file mode 100644 index 0000000000..f06bce4647 --- /dev/null +++ b/scripts/tests/issue-2603-startup-benchmark.cjs @@ -0,0 +1,227 @@ +'use strict'; + +/** + * Startup benchmark for issue #2603. + * + * Compares the direct POSIX launcher (packages/cli/bin/llxprt) 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. + * + * 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 } = require('node:fs'); +const { join, resolve } = require('node:path'); + +const repoRoot = resolve(process.argv[2] || process.cwd()); + +/** + * 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 launcher = join(repoRoot, 'packages', 'cli', 'bin', 'llxprt'); +const repoBun = join(repoRoot, 'node_modules', 'bun', 'bin', 'bun.exe'); +const entry = join(repoRoot, 'packages', 'cli', 'index.ts'); + +/** + * Cross-platform Bun discovery. On Windows, `which` does not exist; use `where` + * instead. On POSIX, `which` is used. In both cases a spawn error (ENOENT for + * the discovery tool itself) is surfaced as a clear diagnostic. + */ +function resolveBun() { + if (existsSync(repoBun)) return repoBun; + const tool = process.platform === 'win32' ? '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(String.fromCharCode(10))[0]; + if (!found) { + throw new Error(`'${tool} bun' produced no output.`); + } + return found; +} + +function timeDirectLauncher() { + // The production launcher: resolves package-local Bun and execs the entry. + const r = spawnSync(launcher, ['--version'], { + cwd: repoRoot, + encoding: 'utf8', + timeout: 30_000, + 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). + const relayScript = ` + const { spawnSync } = require('child_process'); + const bunExe = process.argv[1]; + const entry = process.argv[2]; + const r = spawnSync(bunExe, [entry, '--version'], { + stdio: 'inherit', + env: process.env, + }); + if (r.error) { + console.error('relay spawn failed:', r.error.message); + process.exit(1); + } + if (r.status === null) { + console.error('relay child killed by signal:', r.signal || 'none'); + process.exit(1); + } + process.exit(r.status); + `; + const r = spawnSync('node', ['-e', relayScript, bunExe, entry], { + cwd: repoRoot, + encoding: 'utf8', + timeout: 30_000, + env: { ...process.env }, + }); + if (r.error) { + throw new Error(`node relay spawn failed: ${r.error.message}`); + } + // Never coerce a null/signal/timeout status to 0. A null status means the + // relay child was killed by a signal or timed out; surface it as a failure. + if (r.status === null) { + throw new Error( + `node relay did not exit normally (signal=${r.signal ?? 'none'}): ${r.stderr}`, + ); + } + if (r.status !== 0) { + throw new Error(`node relay exited ${r.status}: ${r.stderr}`); + } + return r.status; +} + +function measure(fn, label) { + const samples = []; + // Warmup run (not counted) to stabilize FS cache. + try { + fn(); + } catch (e) { + console.error(`Warmup failed for ${label}: ${e.message}`); + return null; + } + for (let i = 0; i < iterations; i++) { + const t0 = process.hrtime.bigint(); + try { + fn(); + } catch (e) { + console.error(`${label} iteration ${i} failed: ${e.message}`); + return null; + } + const t1 = process.hrtime.bigint(); + samples.push(Number(t1 - t0) / 1e6); // ms + } + samples.sort((a, b) => a - b); + const median = samples[Math.floor(samples.length / 2)]; + const min = samples[0]; + const max = samples[samples.length - 1]; + return { median, min, max, samples }; +} + +function main() { + if (!existsSync(launcher)) { + console.error(`Launcher not found: ${launcher}`); + process.exit(1); + } + const bunExe = resolveBun(); + + console.log(`Startup benchmark (issue #2603)`); + console.log(` iterations: ${iterations}`); + console.log(` launcher: ${launcher}`); + console.log(` bun: ${bunExe}`); + console.log(''); + + const direct = measure(timeDirectLauncher, 'direct-launcher'); + const relay = measure(() => timeNodeRelayBaseline(bunExe), 'node-relay'); + + function fmt(r) { + if (!r) return 'FAILED'; + return `median=${r.median.toFixed(1)}ms min=${r.min.toFixed(1)}ms max=${r.max.toFixed(1)}ms`; + } + + console.log(` direct-launcher: ${fmt(direct)}`); + console.log(` node-relay: ${fmt(relay)}`); + + if (direct && relay && direct.median > 0) { + const ratio = relay.median / direct.median; + console.log( + ` ratio (relay/direct median): ${ratio.toFixed(2)}x ` + + `(relay is ${ratio > 1 ? 'slower' : 'faster'})`, + ); + } + + // Output a GitHub Actions step-summary table if available. + if (process.env.GITHUB_STEP_SUMMARY) { + const fs = require('fs'); + const lines = [ + '### Startup Benchmark (issue #2603)', + '', + '| Path | Median (ms) | Min (ms) | Max (ms) |', + '|---|---|---|---|', + ]; + if (direct) { + lines.push( + `| direct-launcher | ${direct.median.toFixed(1)} | ${direct.min.toFixed(1)} | ${direct.max.toFixed(1)} |`, + ); + } + if (relay) { + lines.push( + `| node-relay | ${relay.median.toFixed(1)} | ${relay.min.toFixed(1)} | ${relay.max.toFixed(1)} |`, + ); + } + if (direct && relay && direct.median > 0) { + lines.push( + `| ratio (relay/direct) | ${(relay.median / direct.median).toFixed(2)}x | - | - |`, + ); + } + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, lines.join('\n') + '\n'); + } +} + +main(); diff --git a/scripts/tests/issue-2603-windows-probe.ts b/scripts/tests/issue-2603-windows-probe.ts new file mode 100644 index 0000000000..b1faf585e1 --- /dev/null +++ b/scripts/tests/issue-2603-windows-probe.ts @@ -0,0 +1,145 @@ +#!/usr/bin/env -S bun + +/** + * Instrumented entry-point probe for the Windows installed-command smoke + * (issue #2603). + * + * The launcher under test invokes `bun.exe %*`. Replacing the + * installed package's index.ts with this probe (in a TEMP fixture only — the + * replica tarball itself is never mutated) makes the probe the child that + * bun.exe executes. + * + * Request protocol (passed as regular args, forwarded by %*): + * LLXPRT_PROBE= where is { stdin?, stderr?, exit?, long?, injectionPath? }. + * The JSON is matched as a single argv token so quoting/Unicode/fidelity is + * preserved verbatim. + */ + +import { readFileSync, writeFileSync } from 'node:fs'; + +interface ProbeRequest { + stdin?: boolean; + stderr?: string; + exit?: number; + long?: boolean; + injectionPath?: string; +} + +function parseRequest(): { + request: ProbeRequest; + raw: string; + malformed: boolean; +} { + const request: ProbeRequest = {}; + let raw = ''; + let malformed = false; + for (const arg of process.argv.slice(2)) { + if (arg.startsWith('LLXPRT_PROBE=')) { + raw = arg.slice('LLXPRT_PROBE='.length); + try { + const parsed = JSON.parse(raw) as ProbeRequest; + Object.assign(request, parsed); + } catch { + // Malformed request payload: preserve the raw value for diagnostics + // rather than silently dropping it, so the caller can see what went + // wrong. + malformed = true; + } + } + } + return { request, raw, malformed }; +} + +function readStdinSync(): string { + try { + return readFileSync(0, 'utf8'); + } catch { + return ''; + } +} + +/** + * Writes the payload and waits for stdout to drain so the parent process + * captures the complete JSON before any process.exit. Without this, buffered + * stdout on Windows pipes can be truncated when the process exits. + */ +async function emitAndFlush(payload: Record): Promise { + process.stdout.write(JSON.stringify(payload)); + await drainStdout(); +} + +/** + * Resolves once the stdout stream has flushed its buffered writes. On streams + * without a draining event (already drained), resolves immediately. + */ +function drainStdout(): Promise { + return new Promise((resolve) => { + if (process.stdout.writableEnded) { + resolve(); + return; + } + process.stdout.write('', () => resolve()); + }); +} + +async function main(): Promise { + const { request, raw, malformed } = parseRequest(); + + const payload: Record = { + argv: process.argv, + execPath: process.execPath, + bunVersion: + typeof process.versions.bun === 'string' ? process.versions.bun : '', + }; + + // Preserve malformed raw diagnostics so the caller can see the unparseable + // payload instead of an opaque "no JSON object" error. + if (malformed) { + payload.malformed = true; + payload.raw = raw; + } + + payload.stdin = request.stdin ? readStdinSync() : ''; + + if (request.stderr !== undefined) { + process.stderr.write(request.stderr); + } + + if (request.injectionPath) { + try { + writeFileSync(request.injectionPath, 'INJECTED'); + payload.injectionCreated = true; + } catch { + payload.injectionCreated = false; + } + } + + if (request.long) { + await emitAndFlush(payload); + process.stdout.write('\n__LLXPRT_PROBE_LONG_RUNNING__\n'); + await drainStdout(); + const handler = (): void => { + // Drain before exiting so the parent captures the full payload. Without + // this, process.exit can truncate buffered stdout on Windows pipes. + // Remove the listeners after settling so the process does not hold + // lingering handlers that would interfere with a clean exit. + process.removeListener('SIGINT', handler); + process.removeListener('SIGTERM', handler); + void drainStdout().then(() => process.exit(0)); + }; + process.on('SIGINT', handler); + process.on('SIGTERM', handler); + await new Promise(() => { + // never resolves; resolved by signal handler above + }); + return; + } + + await emitAndFlush(payload); + if (typeof request.exit === 'number') { + await drainStdout(); + process.exit(request.exit); + } +} + +void main(); diff --git a/scripts/tests/publish-integrity.test.ts b/scripts/tests/publish-integrity.test.ts index 0c987c1883..6e37ebc322 100644 --- a/scripts/tests/publish-integrity.test.ts +++ b/scripts/tests/publish-integrity.test.ts @@ -453,8 +453,8 @@ describe('published package no-compile runtime contract (S6)', () => { readFileSync(join(repoRoot, 'packages', 'cli', 'package.json'), 'utf-8'), ) as CliPackageMetadata; - expect(rootPackage.bin?.llxprt).toBe('packages/cli/bin/llxprt.cjs'); - expect(cliPackage.bin?.llxprt).toBe('bin/llxprt.cjs'); + expect(rootPackage.bin?.llxprt).toBe('packages/cli/bin/llxprt'); + expect(cliPackage.bin?.llxprt).toBe('bin/llxprt'); expect(cliPackage.scripts?.prepack).toBeUndefined(); expect(cliPackage.scripts?.start).toBe('bun index.ts'); expect(cliPackage.scripts?.debug).toBe('bun --inspect-brk index.ts'); @@ -463,7 +463,10 @@ describe('published package no-compile runtime contract (S6)', () => { it('ships the launcher and TypeScript source needed by Bun at runtime', () => { const packed = getPackedPaths(); - expect(packed.has('packages/cli/bin/llxprt.cjs')).toBe(true); + // The POSIX launcher (issue #2603) has a valid #!/bin/sh shebang so it is + // directly execve-compatible on POSIX. On Windows, the root postinstall + // generates native .cmd/.ps1 launchers that invoke the package-local Bun. + expect(packed.has('packages/cli/bin/llxprt')).toBe(true); expect(packed.has('packages/cli/index.ts')).toBe(true); expect(packed.has('packages/cli/src/cli.tsx')).toBe(true); expect(packed.has('packages/core/index.ts')).toBe(true); @@ -747,10 +750,13 @@ describe('published package no-compile runtime contract (S6)', () => { }, ); - it('runs the checked-in Node launcher without a compiled CLI entry', () => { + it('runs the checked-in launcher without a compiled CLI entry', () => { + // The launcher (issue #2603) has a valid #!/bin/sh shebang, so it is + // directly execve-compatible (no /bin/sh wrapper needed). Running it + // directly exercises the real installed-command path. const stdout = execFileSync( - process.execPath, - [join(repoRoot, 'packages', 'cli', 'bin', 'llxprt.cjs'), '--version'], + join(repoRoot, 'packages', 'cli', 'bin', 'llxprt'), + ['--version'], { cwd: repoRoot, encoding: 'utf8', diff --git a/scripts/tests/smoke-cli-entry.test.ts b/scripts/tests/smoke-cli-entry.test.ts index c8f7f94881..d8a5d9cade 100644 --- a/scripts/tests/smoke-cli-entry.test.ts +++ b/scripts/tests/smoke-cli-entry.test.ts @@ -13,12 +13,13 @@ import { tmpdir } from 'node:os'; const thisFile = fileURLToPath(import.meta.url); const repoRoot = resolve(thisFile, '..', '..', '..'); -const launcher = join(repoRoot, 'packages', 'cli', 'bin', 'llxprt.cjs'); +const launcher = join(repoRoot, 'packages', 'cli', 'bin', 'llxprt'); // spawnSync blocks the event loop, so Vitest's per-test timeout cannot // interrupt a hung child; the spawnSync `timeout` below is the real guard. Keep -// it generous — the Node launcher re-execs the CLI under Bun, so a cold spawn on -// a loaded CI runner takes seconds — while still bounding a genuine hang. +// it generous — the POSIX launcher execs Bun on the TypeScript source, so a +// cold spawn on a loaded CI runner takes seconds — while still bounding a +// genuine hang. const SPAWN_KILL_TIMEOUT_MS = 120_000; // Vitest per-test budget. Kept above SPAWN_KILL_TIMEOUT_MS so the spawnSync @@ -37,13 +38,17 @@ function runLauncherVersion(env: NodeJS.ProcessEnv): { stdout: string; stderr: string; } { - const result = spawnSync(process.execPath, [launcher, '--version'], { + // The launcher (issue #2603) has a valid #!/bin/sh shebang, so it is + // directly execve-compatible. Running it directly exercises the real + // installed-command path. The launcher resolves the package-bundled Bun and + // execs index.ts. + const result = spawnSync(launcher, ['--version'], { cwd: repoRoot, encoding: 'utf8', maxBuffer: 1024 * 1024, env, // Kill a hung child here (spawnSync blocks the event loop, so Vitest's - // per-test timeout cannot). Generous enough to absorb a cold Bun re-exec on + // per-test timeout cannot). Generous enough to absorb a cold Bun exec on // a loaded CI runner, tight enough to bound a genuine hang. timeout: SPAWN_KILL_TIMEOUT_MS, }); @@ -70,7 +75,7 @@ describe('CLI entry smoke guard (issue #2435)', () => { // Regression guard for https://github.com/vybestack/llxprt-code/issues/2435. // // The smoke-test.yml workflow does a fresh checkout + `npm ci` (no build, no - // generate step), then runs `node ./packages/cli/bin/llxprt.cjs --version`. + // generate step), then runs the launcher `--version`. // Previously this crashed at module-load time because AboutBox.tsx and // bugCommand.ts had a hard static ESM import of a gitignored, build-generated // `git-commit.ts`. With the resilient loader, the CLI must print its version diff --git a/scripts/tests/tmux-harness.test.js b/scripts/tests/tmux-harness.test.js index 94d912da98..dca5360bcf 100644 --- a/scripts/tests/tmux-harness.test.js +++ b/scripts/tests/tmux-harness.test.js @@ -221,12 +221,12 @@ describe('resolveStartArgsForTmux', () => { resolveStartArgsForTmux([ 'node', 'scripts/start.ts', - 'NODE_OPTIONS= ${node} packages/cli/bin/llxprt.cjs', + 'NODE_OPTIONS= ${node} packages/cli/index.ts', ]), ).toEqual([ TEST_NODE_EXECUTABLE, 'scripts/start.ts', - `NODE_OPTIONS= ${TEST_NODE_EXECUTABLE} packages/cli/bin/llxprt.cjs`, + `NODE_OPTIONS= ${TEST_NODE_EXECUTABLE} packages/cli/index.ts`, ]); }); @@ -239,12 +239,12 @@ describe('resolveStartArgsForTmux', () => { resolveStartArgsForTmux([ 'bun', 'scripts/start.ts', - '${bun} packages/cli/bin/llxprt.cjs', + '${bun} packages/cli/index.ts', ]), ).toEqual([ TEST_BUN_EXECUTABLE, 'scripts/start.ts', - `${TEST_BUN_EXECUTABLE} packages/cli/bin/llxprt.cjs`, + `${TEST_BUN_EXECUTABLE} packages/cli/index.ts`, ]); }); @@ -272,10 +272,8 @@ describe('buildTmuxStartCommand', () => { const { buildTmuxStartCommand } = await importHarness(); expect( - buildTmuxStartCommand([ - 'NODE_OPTIONS= ${node} packages/cli/bin/llxprt.cjs', - ]), - ).toBe(`NODE_OPTIONS= ${TEST_NODE_EXECUTABLE} packages/cli/bin/llxprt.cjs`); + buildTmuxStartCommand(['NODE_OPTIONS= ${node} packages/cli/index.ts']), + ).toBe(`NODE_OPTIONS= ${TEST_NODE_EXECUTABLE} packages/cli/index.ts`); }); it('shell-quotes multi-argument commands after resolving node', async () => { diff --git a/scripts/tmux-script.approval-ui.json b/scripts/tmux-script.approval-ui.json index 5ea74398fa..10fe977cd3 100644 --- a/scripts/tmux-script.approval-ui.json +++ b/scripts/tmux-script.approval-ui.json @@ -7,7 +7,7 @@ "initialWaitMs": 6000 }, "startCommand": [ - "env CI=0 CONTINUOUS_INTEGRATION=0 NODE_OPTIONS= LLXPRT_CODE_NO_RELAUNCH=true LLXPRT_CODE_SKIP_TERMINAL_CAPABILITY_DETECTION=true LLXPRT_CODE_BUILTIN_COMMANDS_ONLY=true LLXPRT_CODE_SUPPRESS_STATIC_HEADER=true LLXPRT_CODE_WELCOME_CONFIG_PATH=scripts/fixtures/welcome-completed.json LLXPRT_CODE_SYSTEM_SETTINGS_PATH=scripts/system-settings.interactive-ui.json LLXPRT_FAKE_RESPONSES=scripts/fixtures/approval-ui.responses.jsonl ${node} packages/cli/bin/llxprt.cjs --provider fake --model fake-model" + "env CI=0 CONTINUOUS_INTEGRATION=0 NODE_OPTIONS= LLXPRT_CODE_NO_RELAUNCH=true LLXPRT_CODE_SKIP_TERMINAL_CAPABILITY_DETECTION=true LLXPRT_CODE_BUILTIN_COMMANDS_ONLY=true LLXPRT_CODE_SUPPRESS_STATIC_HEADER=true LLXPRT_CODE_WELCOME_CONFIG_PATH=scripts/fixtures/welcome-completed.json LLXPRT_CODE_SYSTEM_SETTINGS_PATH=scripts/system-settings.interactive-ui.json LLXPRT_FAKE_RESPONSES=scripts/fixtures/approval-ui.responses.jsonl ${bun} packages/cli/index.ts --provider fake --model fake-model" ], "yolo": false, "steps": [ diff --git a/scripts/tmux-script.issue2208-newlines.fake.json b/scripts/tmux-script.issue2208-newlines.fake.json index 989e068ddb..9e6c439576 100644 --- a/scripts/tmux-script.issue2208-newlines.fake.json +++ b/scripts/tmux-script.issue2208-newlines.fake.json @@ -7,7 +7,7 @@ "initialWaitMs": 6000 }, "startCommand": [ - "env CI=0 CONTINUOUS_INTEGRATION=0 NODE_OPTIONS= LLXPRT_CODE_NO_RELAUNCH=true LLXPRT_CODE_SKIP_TERMINAL_CAPABILITY_DETECTION=true LLXPRT_CODE_BUILTIN_COMMANDS_ONLY=true LLXPRT_CODE_SUPPRESS_STATIC_HEADER=true LLXPRT_CODE_WELCOME_CONFIG_PATH=scripts/fixtures/welcome-completed.json LLXPRT_CODE_SYSTEM_SETTINGS_PATH=scripts/system-settings.interactive-ui.json LLXPRT_FAKE_RESPONSES=scripts/fixtures/issue2208-newlines.responses.jsonl ${node} packages/cli/bin/llxprt.cjs --provider fake --model fake-model" + "env CI=0 CONTINUOUS_INTEGRATION=0 NODE_OPTIONS= LLXPRT_CODE_NO_RELAUNCH=true LLXPRT_CODE_SKIP_TERMINAL_CAPABILITY_DETECTION=true LLXPRT_CODE_BUILTIN_COMMANDS_ONLY=true LLXPRT_CODE_SUPPRESS_STATIC_HEADER=true LLXPRT_CODE_WELCOME_CONFIG_PATH=scripts/fixtures/welcome-completed.json LLXPRT_CODE_SYSTEM_SETTINGS_PATH=scripts/system-settings.interactive-ui.json LLXPRT_FAKE_RESPONSES=scripts/fixtures/issue2208-newlines.responses.jsonl ${bun} packages/cli/index.ts --provider fake --model fake-model" ], "yolo": false, "steps": [ diff --git a/scripts/tmux-script.lsp-diag-smoke.json b/scripts/tmux-script.lsp-diag-smoke.json index 9e74e20e9f..db5d26cb74 100644 --- a/scripts/tmux-script.lsp-diag-smoke.json +++ b/scripts/tmux-script.lsp-diag-smoke.json @@ -9,7 +9,7 @@ "startCommand": [ "bash", "-c", - "cd /Users/acoliver/projects/llxprt/branch-3/llxprt-code/tmp/fakeproject && exec node /Users/acoliver/projects/llxprt/branch-3/llxprt-code/packages/cli/bin/llxprt.cjs --profile-load chutesminimax --yolo" + "cd /Users/acoliver/projects/llxprt/branch-3/llxprt-code/tmp/fakeproject && exec bun /Users/acoliver/projects/llxprt/branch-3/llxprt-code/packages/cli/index.ts --profile-load chutesminimax --yolo" ], "yolo": true, "steps": [ diff --git a/scripts/tmux-script.lsp-smoke.json b/scripts/tmux-script.lsp-smoke.json index 64649018da..0789b1456f 100644 --- a/scripts/tmux-script.lsp-smoke.json +++ b/scripts/tmux-script.lsp-smoke.json @@ -9,7 +9,7 @@ "startCommand": [ "bash", "-c", - "cd /Users/acoliver/projects/llxprt/branch-3/llxprt-code/tmp/fakeproject && exec node /Users/acoliver/projects/llxprt/branch-3/llxprt-code/packages/cli/bin/llxprt.cjs --profile-load chutesminimax --yolo" + "cd /Users/acoliver/projects/llxprt/branch-3/llxprt-code/tmp/fakeproject && exec bun /Users/acoliver/projects/llxprt/branch-3/llxprt-code/packages/cli/index.ts --profile-load chutesminimax --yolo" ], "yolo": true, "steps": [ diff --git a/scripts/tmux-script.slash-autocomplete.json b/scripts/tmux-script.slash-autocomplete.json index 30c0b3ff44..355c1c52bf 100644 --- a/scripts/tmux-script.slash-autocomplete.json +++ b/scripts/tmux-script.slash-autocomplete.json @@ -7,7 +7,7 @@ "initialWaitMs": 6000 }, "startCommand": [ - "env CI=0 CONTINUOUS_INTEGRATION=0 NODE_OPTIONS= LLXPRT_CODE_NO_RELAUNCH=true LLXPRT_CODE_SKIP_TERMINAL_CAPABILITY_DETECTION=true LLXPRT_CODE_BUILTIN_COMMANDS_ONLY=true LLXPRT_CODE_SUPPRESS_STATIC_HEADER=true LLXPRT_CODE_WELCOME_CONFIG_PATH=scripts/fixtures/welcome-completed.json LLXPRT_CODE_SYSTEM_SETTINGS_PATH=scripts/system-settings.interactive-ui.json ${node} packages/cli/bin/llxprt.cjs" + "env CI=0 CONTINUOUS_INTEGRATION=0 NODE_OPTIONS= LLXPRT_CODE_NO_RELAUNCH=true LLXPRT_CODE_SKIP_TERMINAL_CAPABILITY_DETECTION=true LLXPRT_CODE_BUILTIN_COMMANDS_ONLY=true LLXPRT_CODE_SUPPRESS_STATIC_HEADER=true LLXPRT_CODE_WELCOME_CONFIG_PATH=scripts/fixtures/welcome-completed.json LLXPRT_CODE_SYSTEM_SETTINGS_PATH=scripts/system-settings.interactive-ui.json ${bun} packages/cli/index.ts" ], "yolo": false, "steps": [ diff --git a/scripts/windows-installed-command-smoke.cjs b/scripts/windows-installed-command-smoke.cjs new file mode 100644 index 0000000000..b04def4f34 --- /dev/null +++ b/scripts/windows-installed-command-smoke.cjs @@ -0,0 +1,144 @@ +'use strict'; + +/** + * Hosted Windows behavioral smoke for issue #2603. + * + * Runs only on Windows (invoked by the windows-installed-command workflow). It + * exercises the ACTUAL generated .cmd/.ps1 launchers and the ACTUAL bundled + * bun.exe (installed as a real dependency), not stubs. + * + * This file is the single top-level orchestrator. The behavioral checks, npm + * install helpers, process-tree inspection, and launcher invocation helpers + * live in cohesive modules under scripts/windows-installed-command-smoke/. + * This preserves the single process/workflow entry point while keeping each + * module focused and under the max-lines limit. + * + * The harness: + * 1. Packs a release-like CLI replica tarball via the shared release-pack + * helper (same one POSIX tests use). + * 2. Installs the replica globally and locally. + * 3. Creates a TEMP installed-package fixture whose index.ts is replaced + * with an instrumented probe. + * 4. Invokes both launchers through the real cmd and PowerShell, asserting + * args, stdio, exit codes, execPath, and the process tree. + * 5. Tests missing-Bun and corrupt-Bun error contracts. + * 6. Tests actual ephemeral `npm exec --package -- llxprt`. + * 7. Tests package-local bun.exe presence. + * + * This is a Node script so the test driver does not depend on a pre-installed + * Bun. The release-pack helper invokes Bun for bind-release-deps, so Bun must + * be set up in the workflow before this runs. + */ + +const { existsSync, mkdtempSync, rmSync } = require('node:fs'); +const { join, resolve } = require('node:path'); +const { tmpdir } = require('node:os'); +const { createRequire } = require('node:module'); + +const isWindows = process.platform === 'win32'; +const repoRoot = resolve(process.argv[2] || process.cwd()); +const nodeRequire = createRequire(__filename); +const releasePackHelperPath = join( + repoRoot, + 'scripts', + 'tests', + 'issue-2603-release-pack.cjs', +); + +const smokeDir = join(repoRoot, 'scripts', 'windows-installed-command-smoke'); +const { getState, resetState } = nodeRequire(join(smokeDir, 'assert.cjs')); +const { findInstalledPackageRoot, findBundledBun } = nodeRequire( + join(smokeDir, 'package-layout.cjs'), +); +const checks = nodeRequire(join(smokeDir, 'checks.cjs')); +const { + globalInstall, + localInstall, + checkLocalCmdVersion, + checkPackageLocalBun, +} = nodeRequire(join(smokeDir, 'install-helpers.cjs')); + +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}`, + ); + } +} + +function runSmoke() { + resetState(); + let tempDir; + try { + const { packReleaseLikeCli } = nodeRequire(releasePackHelperPath); + const { replicaTarball } = packReleaseLikeCli(repoRoot); + + const { assert } = nodeRequire(join(smokeDir, 'assert.cjs')); + assert( + existsSync(replicaTarball), + `replica tarball not found: ${replicaTarball}`, + ); + process.stdout.write(`replica=${replicaTarball}\n`); + + tempDir = mkdtempSync(join(tmpdir(), 'llxprt-win-smoke-')); + const prefix = globalInstall(tempDir, replicaTarball); + + checks.checkLauncherSentinels(prefix); + checks.checkVersionRuns(prefix); + + const installedPackageRoot = findInstalledPackageRoot(prefix); + + const probeFixture = checks.buildProbeFixture( + installedPackageRoot, + tempDir, + 'main', + repoRoot, + ); + + checks.checkCmdArgFidelity(probeFixture); + checks.checkPwshArgFidelity(probeFixture); + checks.checkInjectionGuard(probeFixture, tempDir); + checks.checkStdioForwarding(probeFixture); + checks.checkCmdExitCodePreservation(probeFixture); + checks.checkPwshExitPropagation(probeFixture); + checks.checkExecPathIsBundledBun(probeFixture); + checks.checkProcessTreeNoNode(probeFixture); + + checks.checkMissingBun({ installedPackageRoot }, tempDir, repoRoot); + checks.checkCorruptBun({ installedPackageRoot }, tempDir, repoRoot); + + checks.checkNpmExecEphemeral(tempDir, replicaTarball); + + const consumerDir = localInstall(tempDir, replicaTarball); + checkLocalCmdVersion(consumerDir); + checkPackageLocalBun(prefix, findInstalledPackageRoot, findBundledBun); + } catch (err) { + const { fail } = nodeRequire(join(smokeDir, 'assert.cjs')); + fail(`unexpected error: ${err.stack || err.message}`); + } finally { + safeCleanup(tempDir); + } +} + +function reportAndExit() { + const { failed, failures } = getState(); + if (failed) { + console.error(`\n${failures.length} failure(s):\n`); + for (const f of failures) { + console.error(' - ' + f); + } + process.exit(1); + } + console.log('\nAll Windows installed-command smoke assertions passed.'); +} + +if (!isWindows) { + console.log('Skipping Windows smoke on non-Windows platform.'); + process.exit(0); +} + +runSmoke(); +reportAndExit(); diff --git a/scripts/windows-installed-command-smoke/assert.cjs b/scripts/windows-installed-command-smoke/assert.cjs new file mode 100644 index 0000000000..dd2ab138b1 --- /dev/null +++ b/scripts/windows-installed-command-smoke/assert.cjs @@ -0,0 +1,48 @@ +'use strict'; + +/** + * Assertion and step-runner helpers for the Windows installed-command smoke. + * Shared across all check modules so failures are collected and reported in a + * single summary rather than aborting on the first error. + */ + +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; +} + +function runStep(label, fn) { + process.stdout.write(`[${label}] starting...\n`); + try { + fn(); + process.stdout.write(`[${label}] OK\n`); + } catch (err) { + fail(`${label}: ${err.message}`); + } +} + +function resetState() { + failed = false; + failures.length = 0; +} + +function getState() { + return { failed, failures: [...failures] }; +} + +module.exports = { + fail, + assert, + runStep, + resetState, + getState, +}; diff --git a/scripts/windows-installed-command-smoke/checks.cjs b/scripts/windows-installed-command-smoke/checks.cjs new file mode 100644 index 0000000000..68788f6613 --- /dev/null +++ b/scripts/windows-installed-command-smoke/checks.cjs @@ -0,0 +1,537 @@ +'use strict'; + +/** + * Behavioral checks for the Windows installed-command smoke. Each function + * corresponds to one check group; all 23 behavioral checks live here. They + * share the installed-package fixture and report failures via the assert + * helper so a single summary is produced at the end. + */ + +const { spawnSync } = require('node:child_process'); +const { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} = require('node:fs'); +const { join } = require('node:path'); + +const { assert, runStep } = require('./assert.cjs'); +const { + CONSTRAINED_PATH, + OWNERSHIP_SENTINEL, + VERSION_RE, + LAUNCH_ERROR_EXIT, +} = require('./constants.cjs'); +const { + probeArg, + parseProbeOutput, + invokeCmd, + invokePwsh, +} = require('./launcher-invocation.cjs'); +const { findBundledBun, samePath, copyTree } = require('./package-layout.cjs'); +const { + sleepMs, + inspectProcessTreeSync, + spawn, +} = require('./process-helpers.cjs'); + +function buildProbeFixture(installedPackageRoot, tempBase, label, repoRoot) { + const fixtureDir = join(tempBase, `probe-fixture-${label}`); + mkdirSync(fixtureDir, { recursive: true }); + const fixturePkgRoot = join(fixtureDir, 'pkg'); + copyTree(installedPackageRoot, fixturePkgRoot); + const probePath = join( + repoRoot, + 'scripts', + 'tests', + 'issue-2603-windows-probe.ts', + ); + writeFileSync( + join(fixturePkgRoot, 'index.ts'), + readFileSync(probePath, 'utf8'), + ); + + const installer = require( + join( + repoRoot, + 'packages', + 'cli', + 'scripts', + 'install-native-launchers.cjs', + ), + ); + const result = installer.installNativeLaunchers({ + platform: 'win32', + packageRoot: fixturePkgRoot, + env: { npm_config_global: 'true', npm_config_prefix: fixtureDir }, + log: () => {}, + }); + if (!result.written || result.written.length < 2) { + throw new Error( + `installer did not write both launchers for fixture ${label} (got ${JSON.stringify(result)})`, + ); + } + return { fixtureDir, fixturePkgRoot }; +} + +// --- Check groups --- + +function checkLauncherSentinels(prefix) { + runStep('cmd-launcher-sentinel', () => { + const cmdPath = join(prefix, 'llxprt.cmd'); + assert(existsSync(cmdPath), `cmd launcher not found: ${cmdPath}`); + const content = readFileSync(cmdPath, 'utf8'); + assert( + content.includes(OWNERSHIP_SENTINEL), + 'cmd launcher missing ownership sentinel', + ); + assert( + /"%~dp0.*bun\.exe" "%~dp0.*index\.ts" %\*/.test(content), + 'cmd launcher does not directly invoke bun.exe with %*', + ); + assert( + !content.includes('LLXPRT_LAUNCH_FAIL'), + 'cmd launcher must not remap exit codes (no LLXPRT_LAUNCH_FAIL)', + ); + }); + + runStep('ps1-launcher-sentinel', () => { + const ps1Path = join(prefix, 'llxprt.ps1'); + assert(existsSync(ps1Path), `ps1 launcher not found: ${ps1Path}`); + const content = readFileSync(ps1Path, 'utf8'); + assert( + content.includes(OWNERSHIP_SENTINEL), + 'ps1 launcher missing ownership sentinel', + ); + assert( + content.includes('$allArgs = @($entry) + $args'), + 'ps1 launcher does not use argument array', + ); + assert( + content.includes('try {') && content.includes('} catch {'), + 'ps1 launcher missing try/catch for launch failures', + ); + }); +} + +function checkVersionRuns(prefix) { + runStep('cmd-version', () => { + const cmdPath = join(prefix, 'llxprt.cmd'); + const r = spawnSync('cmd', ['/c', cmdPath, '--version'], { + encoding: 'utf8', + timeout: 30_000, + env: { ...process.env, PATH: CONSTRAINED_PATH }, + }); + if (r.status !== 0) { + throw new Error(`cmd --version exited ${r.status}: ${r.stderr}`); + } + assert( + VERSION_RE.test(r.stdout.trim()), + `cmd --version unexpected output: ${r.stdout}`, + ); + }); + + runStep('ps1-version', () => { + const ps1Path = join(prefix, 'llxprt.ps1'); + const r = spawnSync( + 'powershell', + ['-NoProfile', '-Command', `& '${ps1Path}' --version`], + { + encoding: 'utf8', + timeout: 30_000, + env: { ...process.env, PATH: CONSTRAINED_PATH }, + }, + ); + if (r.status !== 0) { + throw new Error(`ps1 --version exited ${r.status}: ${r.stderr}`); + } + assert( + VERSION_RE.test(r.stdout.trim()), + `ps1 --version unexpected output: ${r.stdout}`, + ); + }); +} + +const ARG_FIDELITY_MARKERS = [ + 'plain-ascii', + 'with spaces', + 'Unicode: ✓ 日本語 ñ émoji 🎉', + 'quotes: "double" \'single\' `back`', + 'safe-metachars: ; | & < > ( ) % ! ^', + 'back\\slash and for$ward', +]; + +function checkCmdArgFidelity(fixture) { + runStep('cmd-arg-fidelity', () => { + const cmdPath = join(fixture.fixtureDir, 'llxprt.cmd'); + assert(existsSync(cmdPath), `cmd launcher missing in fixture`); + for (const marker of ARG_FIDELITY_MARKERS) { + const r = invokeCmd(cmdPath, [probeArg({ marker })]); + if (r.status !== 0) { + throw new Error( + `cmd probe exited ${r.status} for marker ${JSON.stringify(marker)}: ${r.stderr}`, + ); + } + const payload = parseProbeOutput(r.stdout); + const forwardedArg = payload.argv.find((a) => + a.startsWith('LLXPRT_PROBE='), + ); + assert( + forwardedArg !== undefined, + `marker ${JSON.stringify(marker)}: LLXPRT_PROBE= not present in argv`, + ); + const parsed = JSON.parse(forwardedArg.slice('LLXPRT_PROBE='.length)); + assert( + parsed.marker === marker, + `marker ${JSON.stringify(marker)} round-trip mismatch: got ${JSON.stringify(parsed.marker)}`, + ); + assert( + typeof payload.bunVersion === 'string' && payload.bunVersion.length > 0, + `marker ${JSON.stringify(marker)}: did not run under Bun (bunVersion missing)`, + ); + } + }); +} + +function checkPwshArgFidelity(fixture) { + runStep('pwsh-arg-fidelity', () => { + const ps1Path = join(fixture.fixtureDir, 'llxprt.ps1'); + assert(existsSync(ps1Path), `ps1 launcher missing in fixture`); + for (const marker of ARG_FIDELITY_MARKERS) { + const r = invokePwsh(ps1Path, [probeArg({ marker })]); + if (r.status !== 0) { + throw new Error( + `ps1 probe exited ${r.status} for marker ${JSON.stringify(marker)}: ${r.stderr}`, + ); + } + const payload = parseProbeOutput(r.stdout); + const forwardedArg = payload.argv.find((a) => + a.startsWith('LLXPRT_PROBE='), + ); + assert( + forwardedArg !== undefined, + `marker ${JSON.stringify(marker)}: LLXPRT_PROBE= not present in argv`, + ); + const parsed = JSON.parse(forwardedArg.slice('LLXPRT_PROBE='.length)); + assert( + parsed.marker === marker, + `marker ${JSON.stringify(marker)} round-trip mismatch: got ${JSON.stringify(parsed.marker)}`, + ); + } + }); +} + +function checkInjectionGuard(fixture, tempDir) { + runStep('injection-guard', () => { + const cmdPath = join(fixture.fixtureDir, 'llxprt.cmd'); + const injectionFile = join(tempDir, 'injected-sentinel.txt'); + const r = invokeCmd(cmdPath, [probeArg({ injectionPath: injectionFile })]); + if (r.status !== 0) { + throw new Error(`injection probe exited ${r.status}: ${r.stderr}`); + } + const payload = parseProbeOutput(r.stdout); + assert( + payload.injectionCreated === false, + `injection sentinel was created at ${injectionFile} — launcher leaked shell metacharacters`, + ); + assert( + !existsSync(injectionFile), + `injection sentinel file exists at ${injectionFile}`, + ); + }); +} + +function checkStdioForwarding(fixture) { + runStep('cmd-stdio', () => { + const cmdPath = join(fixture.fixtureDir, 'llxprt.cmd'); + const stderrValue = 'KNOWN_STDERR_VALUE_31337'; + const stdinValue = 'KNOWN_STDIN_PAYLOAD_5150'; + const r = invokeCmd( + cmdPath, + [probeArg({ stdin: true, stderr: stderrValue })], + { input: stdinValue }, + ); + if (r.status !== 0) { + throw new Error(`cmd stdio probe exited ${r.status}: ${r.stderr}`); + } + const payload = parseProbeOutput(r.stdout); + assert( + payload.stdin === stdinValue, + `stdin not forwarded: expected ${JSON.stringify(stdinValue)}, got ${JSON.stringify(payload.stdin)}`, + ); + assert( + r.stderr.includes(stderrValue), + `stderr not forwarded: expected ${JSON.stringify(stderrValue)} in ${JSON.stringify(r.stderr)}`, + ); + }); + + runStep('pwsh-stdio', () => { + const ps1Path = join(fixture.fixtureDir, 'llxprt.ps1'); + const stderrValue = 'KNOWN_STDERR_VALUE_31337'; + const stdinValue = 'KNOWN_STDIN_PAYLOAD_5150'; + const r = invokePwsh( + ps1Path, + [probeArg({ stdin: true, stderr: stderrValue })], + { input: stdinValue }, + ); + if (r.status !== 0) { + throw new Error(`ps1 stdio probe exited ${r.status}: ${r.stderr}`); + } + const payload = parseProbeOutput(r.stdout); + assert(payload.stdin === stdinValue, `ps1 stdin not forwarded`); + assert(r.stderr.includes(stderrValue), `ps1 stderr not forwarded`); + }); +} + +function checkCmdExitCodePreservation(fixture) { + runStep('cmd-exit-codes-preserved', () => { + const cmdPath = join(fixture.fixtureDir, 'llxprt.cmd'); + for (const code of [0, 1, 5, 7, 42, 193, 9009]) { + const r = invokeCmd(cmdPath, [probeArg({ exit: code })]); + assert( + r.status === code, + `cmd did not preserve exit ${code}: got ${r.status} (stderr=${JSON.stringify(r.stderr)})`, + ); + } + }); +} + +function checkPwshExitPropagation(fixture) { + runStep('pwsh-legitimate-exit-propagation', () => { + const ps1Path = join(fixture.fixtureDir, 'llxprt.ps1'); + for (const code of [0, 1, 5, 7, 42]) { + const r = invokePwsh(ps1Path, [probeArg({ exit: code })]); + assert( + r.status === code, + `ps1 did not propagate legitimate exit ${code}: got ${r.status}`, + ); + } + }); +} + +function checkExecPathIsBundledBun(fixture) { + runStep('execpath-is-bundled-bun', () => { + const cmdPath = join(fixture.fixtureDir, 'llxprt.cmd'); + const r = invokeCmd(cmdPath, [probeArg({})]); + if (r.status !== 0) { + throw new Error(`execpath probe exited ${r.status}: ${r.stderr}`); + } + const payload = parseProbeOutput(r.stdout); + const expectedBun = findBundledBun(fixture.fixturePkgRoot); + assert( + samePath(payload.execPath, expectedBun), + `execPath ${payload.execPath} is not the package-local bundled bun.exe (${expectedBun})`, + ); + }); +} + +function checkProcessTreeNoNode(fixture) { + runStep('process-tree-bun-present-node-absent', () => { + const cmdPath = join(fixture.fixtureDir, 'llxprt.cmd'); + const child = spawn('cmd', ['/c', cmdPath, probeArg({ long: true })], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, PATH: CONSTRAINED_PATH }, + windowsHide: true, + }); + let stdout = ''; + let exitedEarly = false; + child.stdout.on('data', (c) => { + stdout += c.toString(); + }); + child.on('exit', () => { + exitedEarly = true; + }); + + const readyDeadline = Date.now() + 12_000; + while ( + Date.now() < readyDeadline && + !stdout.includes('__LLXPRT_PROBE_LONG_RUNNING__') && + !exitedEarly + ) { + sleepMs(100); + } + if (exitedEarly) { + throw new Error( + `launcher child exited before tree inspection (stdout=${JSON.stringify(stdout)})`, + ); + } + if (!stdout.includes('__LLXPRT_PROBE_LONG_RUNNING__')) { + throw new Error( + `probe did not report long-running within timeout (stdout=${JSON.stringify(stdout)})`, + ); + } + + const treeInfo = inspectProcessTreeSync(child.pid); + try { + child.kill(); + } catch { + // best effort + } + + assert( + treeInfo.bunPresent, + `bun.exe not found in launcher child tree. descendants=${JSON.stringify(treeInfo.descendants)}`, + ); + assert( + !treeInfo.nodePresent, + `node.exe found in launcher child tree (must be absent). descendants=${JSON.stringify(treeInfo.descendants)}`, + ); + }); +} + +function checkMissingBun(fixtureBase, tempDir, repoRoot) { + runStep('cmd-missing-bun-43', () => { + const fixture = buildProbeFixture( + fixtureBase.installedPackageRoot, + tempDir, + 'missing-bun-cmd', + repoRoot, + ); + const bunExe = findBundledBun(fixture.fixturePkgRoot); + rmSync(bunExe, { force: true }); + assert( + !existsSync(bunExe), + 'failed to remove bun.exe for missing-bun test', + ); + const cmdPath = join(fixture.fixtureDir, 'llxprt.cmd'); + const r = invokeCmd(cmdPath, [probeArg({})], { timeout: 15_000 }); + assert( + r.status === LAUNCH_ERROR_EXIT, + `cmd missing-bun exited ${r.status}, expected ${LAUNCH_ERROR_EXIT}`, + ); + assert( + /bundled Bun runtime was not found|npm install|bun\.sh/i.test(r.stderr), + `cmd missing-bun diagnostic missing: ${JSON.stringify(r.stderr)}`, + ); + }); + + runStep('ps1-missing-bun-43', () => { + const fixture = buildProbeFixture( + fixtureBase.installedPackageRoot, + tempDir, + 'missing-bun-ps1', + repoRoot, + ); + const bunExe = findBundledBun(fixture.fixturePkgRoot); + rmSync(bunExe, { force: true }); + const ps1Path = join(fixture.fixtureDir, 'llxprt.ps1'); + const r = invokePwsh(ps1Path, [probeArg({})], { timeout: 15_000 }); + assert( + r.status === LAUNCH_ERROR_EXIT, + `ps1 missing-bun exited ${r.status}, expected ${LAUNCH_ERROR_EXIT}`, + ); + assert( + /bundled Bun runtime was not found|npm install|bun\.sh/i.test(r.stderr), + `ps1 missing-bun diagnostic missing: ${JSON.stringify(r.stderr)}`, + ); + }); +} + +function checkCorruptBun(fixtureBase, tempDir, repoRoot) { + runStep('ps1-corrupt-bun-43', () => { + const fixture = buildProbeFixture( + fixtureBase.installedPackageRoot, + tempDir, + 'corrupt-bun-ps1', + repoRoot, + ); + const bunExe = findBundledBun(fixture.fixturePkgRoot); + writeFileSync( + bunExe, + Buffer.from([ + 0x74, 0x68, 0x69, 0x73, 0x20, 0x69, 0x73, 0x20, 0x6e, 0x6f, 0x74, 0x20, + 0x61, 0x20, 0x70, 0x65, + ]), + ); + const ps1Path = join(fixture.fixtureDir, 'llxprt.ps1'); + const r = invokePwsh(ps1Path, [probeArg({})], { timeout: 15_000 }); + assert( + r.status === LAUNCH_ERROR_EXIT, + `ps1 corrupt-bun exited ${r.status}, expected ${LAUNCH_ERROR_EXIT} (catch must detect launch failure)`, + ); + assert( + /could not be launched|corrupt|npm install|bun\.sh/i.test(r.stderr), + `ps1 corrupt-bun diagnostic missing: ${JSON.stringify(r.stderr)}`, + ); + }); + + runStep('cmd-corrupt-bun-honest-contract', () => { + const fixture = buildProbeFixture( + fixtureBase.installedPackageRoot, + tempDir, + 'corrupt-bun-cmd', + repoRoot, + ); + const bunExe = findBundledBun(fixture.fixturePkgRoot); + writeFileSync(bunExe, '#!/bin/sh\necho this is not a native binary\n'); + const cmdPath = join(fixture.fixtureDir, 'llxprt.cmd'); + const r = invokeCmd(cmdPath, [probeArg({})], { timeout: 15_000 }); + assert( + r.status !== 0, + `cmd corrupt-bun exited 0 — expected nonzero (honest contract: corrupt binary is not silently treated as success)`, + ); + assert( + !/LLxprt Code: bundled Bun runtime was not found/i.test(r.stderr), + `cmd corrupt-bun must NOT emit the missing-bun diagnostic from its own code path (cmd cannot distinguish corrupt from a real nonzero exit); the honest contract is no remapping`, + ); + }); +} + +function checkNpmExecEphemeral(tempDir, replicaTarball) { + 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 r = spawnSync( + 'npm', + ['exec', '--package', replicaTarball, '--', 'llxprt', '--version'], + { + cwd: cleanDir, + encoding: 'utf8', + timeout: 300_000, + maxBuffer: 64 * 1024 * 1024, + env: { ...process.env, npm_config_cache: npmCache }, + }, + ); + if (r.status !== 0) { + throw new Error( + `npm exec --version exited ${r.status}: ${r.stderr || r.stdout}`, + ); + } + assert( + VERSION_RE.test(r.stdout.trim()), + `npm exec --version unexpected output: ${r.stdout}`, + ); + assert( + !existsSync(join(cleanDir, 'node_modules')), + `npm exec polluted the clean dir with node_modules — must be ephemeral (npx cache only)`, + ); + assert( + !existsSync(join(cleanDir, 'node_modules', '.bin', 'llxprt.cmd')), + `npm exec polluted clean dir with a local bin`, + ); + }); +} + +module.exports = { + buildProbeFixture, + checkLauncherSentinels, + checkVersionRuns, + checkCmdArgFidelity, + checkPwshArgFidelity, + checkInjectionGuard, + checkStdioForwarding, + checkCmdExitCodePreservation, + checkPwshExitPropagation, + checkExecPathIsBundledBun, + checkProcessTreeNoNode, + checkMissingBun, + checkCorruptBun, + checkNpmExecEphemeral, +}; diff --git a/scripts/windows-installed-command-smoke/constants.cjs b/scripts/windows-installed-command-smoke/constants.cjs new file mode 100644 index 0000000000..68c0a3c69a --- /dev/null +++ b/scripts/windows-installed-command-smoke/constants.cjs @@ -0,0 +1,19 @@ +'use strict'; + +/** + * Shared constants for the Windows installed-command smoke harness. + */ + +const CONSTRAINED_PATH = + 'C:\\Windows\\System32;C:\\Windows;C:\\Windows\\System32\\Wbem'; +const OWNERSHIP_SENTINEL = + 'LLXPRT_NATIVE_LAUNCHER owned by @vybestack/llxprt-code'; +const VERSION_RE = /^\d+\.\d+\.\d+/; +const LAUNCH_ERROR_EXIT = 43; + +module.exports = { + CONSTRAINED_PATH, + OWNERSHIP_SENTINEL, + VERSION_RE, + LAUNCH_ERROR_EXIT, +}; diff --git a/scripts/windows-installed-command-smoke/install-helpers.cjs b/scripts/windows-installed-command-smoke/install-helpers.cjs new file mode 100644 index 0000000000..f931fa0609 --- /dev/null +++ b/scripts/windows-installed-command-smoke/install-helpers.cjs @@ -0,0 +1,112 @@ +'use strict'; + +/** + * npm install helpers for the Windows smoke: global install, local install, + * and local cmd version verification. These are separated from the behavioral + * checks so the install lifecycle can be reused independently. + */ + +const { spawnSync } = require('node:child_process'); +const { existsSync, mkdirSync, writeFileSync } = require('node:fs'); +const { join } = require('node:path'); + +const { assert, runStep } = require('./assert.cjs'); +const { CONSTRAINED_PATH } = require('./constants.cjs'); + +function globalInstall(tempDir, replicaTarball) { + let prefix; + runStep('global-install', () => { + prefix = join(tempDir, 'global-prefix'); + mkdirSync(prefix, { recursive: true }); + const r = spawnSync( + 'npm', + [ + 'install', + '--global', + '--prefix', + prefix, + '--cache', + join(tempDir, 'npm-cache'), + '--loglevel', + 'error', + replicaTarball, + ], + { + encoding: 'utf8', + timeout: 180_000, + maxBuffer: 64 * 1024 * 1024, + }, + ); + if (r.status !== 0) { + throw new Error(`npm global install failed: ${r.stderr || r.stdout}`); + } + }); + return prefix; +} + +function localInstall(tempDir, replicaTarball) { + let consumerDir; + runStep('local-install', () => { + consumerDir = join(tempDir, 'consumer'); + mkdirSync(consumerDir, { recursive: true }); + writeFileSync( + join(consumerDir, 'package.json'), + JSON.stringify({ name: 'consumer', version: '0.0.0' }, null, 2), + ); + const r = spawnSync( + 'npm', + [ + 'install', + '--cache', + join(tempDir, 'npm-cache-local'), + '--loglevel', + 'error', + replicaTarball, + ], + { + cwd: consumerDir, + encoding: 'utf8', + timeout: 180_000, + maxBuffer: 64 * 1024 * 1024, + }, + ); + if (r.status !== 0) { + throw new Error(`npm local install failed: ${r.stderr || r.stdout}`); + } + }); + return consumerDir; +} + +function checkLocalCmdVersion(consumerDir) { + runStep('local-cmd-version', () => { + const cmdPath = join(consumerDir, 'node_modules', '.bin', 'llxprt.cmd'); + assert(existsSync(cmdPath), `local cmd launcher not found: ${cmdPath}`); + const r = spawnSync('cmd', ['/c', cmdPath, '--version'], { + encoding: 'utf8', + timeout: 30_000, + env: { ...process.env, PATH: CONSTRAINED_PATH }, + }); + if (r.status !== 0) { + throw new Error(`local cmd --version exited ${r.status}: ${r.stderr}`); + } + }); +} + +function checkPackageLocalBun( + prefix, + findInstalledPackageRoot, + findBundledBun, +) { + runStep('package-local-bun-exists', () => { + const packageRoot = findInstalledPackageRoot(prefix); + const bunExe = findBundledBun(packageRoot); + assert(existsSync(bunExe), `package-local bun.exe not found: ${bunExe}`); + }); +} + +module.exports = { + globalInstall, + localInstall, + checkLocalCmdVersion, + checkPackageLocalBun, +}; diff --git a/scripts/windows-installed-command-smoke/launcher-invocation.cjs b/scripts/windows-installed-command-smoke/launcher-invocation.cjs new file mode 100644 index 0000000000..0a99b430a0 --- /dev/null +++ b/scripts/windows-installed-command-smoke/launcher-invocation.cjs @@ -0,0 +1,69 @@ +'use strict'; + +/** + * Launcher invocation helpers: spawn cmd/PowerShell launchers with the + * constrained PATH, parse probe JSON output, and quote arguments safely. + */ + +const { spawnSync } = require('node:child_process'); +const { CONSTRAINED_PATH } = require('./constants.cjs'); + +function probeArg(request) { + return 'LLXPRT_PROBE=' + JSON.stringify(request); +} + +function parseProbeOutput(stdout) { + const start = stdout.indexOf('{'); + const end = stdout.lastIndexOf('}'); + if (start === -1 || end === -1 || end < start) { + throw new Error( + `no JSON object in probe output: ${JSON.stringify(stdout)}`, + ); + } + return JSON.parse(stdout.slice(start, end + 1)); +} + +function invokeCmd(cmdPath, args, opts) { + const r = spawnSync('cmd', ['/c', cmdPath, ...args], { + encoding: 'utf8', + timeout: opts?.timeout ?? 30_000, + input: opts?.input, + env: { ...process.env, PATH: CONSTRAINED_PATH, ...(opts?.env || {}) }, + windowsHide: true, + }); + return r; +} + +function pwshQuote(s) { + if (/^[\w./:=@-]+$/.test(s)) return s; + return "'" + s.replace(/'/g, "''") + "'"; +} + +function invokePwsh(ps1Path, args, opts) { + const argString = args.map((a) => pwshQuote(a)).join(' '); + const r = spawnSync( + 'powershell', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + `& '${ps1Path.replace(/'/g, "''")}' ${argString}`, + ], + { + encoding: 'utf8', + timeout: opts?.timeout ?? 30_000, + input: opts?.input, + env: { ...process.env, PATH: CONSTRAINED_PATH, ...(opts?.env || {}) }, + windowsHide: true, + }, + ); + return r; +} + +module.exports = { + probeArg, + parseProbeOutput, + invokeCmd, + invokePwsh, + pwshQuote, +}; diff --git a/scripts/windows-installed-command-smoke/package-layout.cjs b/scripts/windows-installed-command-smoke/package-layout.cjs new file mode 100644 index 0000000000..e627240a4c --- /dev/null +++ b/scripts/windows-installed-command-smoke/package-layout.cjs @@ -0,0 +1,66 @@ +'use strict'; + +/** + * Package-layout discovery helpers: find the installed package root under a + * global prefix, locate the bundled bun.exe, and build a TEMP probe fixture. + */ + +const { existsSync } = require('node:fs'); +const { join } = require('node:path'); + +function findInstalledPackageRoot(prefix) { + const candidates = [ + join(prefix, 'node_modules', '@vybestack', 'llxprt-code'), + join(prefix, 'Lib', 'node_modules', '@vybestack', 'llxprt-code'), + ]; + for (const c of candidates) { + if (existsSync(join(c, 'package.json'))) return c; + } + const globRoot = join(prefix, 'node_modules'); + if (existsSync(globRoot)) { + const scoped = join(globRoot, '@vybestack'); + if (existsSync(scoped)) { + const direct = join(scoped, 'llxprt-code'); + if (existsSync(join(direct, 'package.json'))) return direct; + } + } + throw new Error(`could not find installed package root under ${prefix}`); +} + +function findBundledBun(packageRoot) { + const candidates = [ + join(packageRoot, 'node_modules', 'bun', 'bin', 'bun.exe'), + join(packageRoot, 'node_modules', '.bin', 'bun.exe'), + ]; + for (const c of candidates) { + if (existsSync(c)) return c; + } + throw new Error(`bundled bun.exe not found under ${packageRoot}`); +} + +function samePath(a, b) { + const norm = (p) => { + let s = String(p).replace(/\\/g, '/'); + while (s.endsWith('/')) { + s = s.slice(0, -1); + } + return s.toLowerCase(); + }; + return norm(a) === norm(b); +} + +function copyTree(src, dest) { + const { cpSync } = require('node:fs'); + const path = require('node:path'); + cpSync(src, dest, { + recursive: true, + filter: (s) => !s.includes('node_modules' + path.sep + '.bin'), + }); +} + +module.exports = { + findInstalledPackageRoot, + findBundledBun, + samePath, + copyTree, +}; diff --git a/scripts/windows-installed-command-smoke/process-helpers.cjs b/scripts/windows-installed-command-smoke/process-helpers.cjs new file mode 100644 index 0000000000..8280ea7a2a --- /dev/null +++ b/scripts/windows-installed-command-smoke/process-helpers.cjs @@ -0,0 +1,104 @@ +'use strict'; + +/** + * Process-tree inspection and cross-platform sleep helpers for the Windows + * smoke. These use PowerShell/CIM to enumerate descendants and a synchronous + * Node delay primitive (Atomics.wait) that works on Windows without busy + * spinning. + */ + +const { spawnSync, spawn } = require('node:child_process'); + +/** + * Synchronous sleep that works cross-platform. Prefers Atomics.wait (no extra + * process, no busy spin). Falls back to PowerShell Start-Sleep, checking the + * result so a spawn error does not silently produce a zero-length sleep. + */ +function sleepMs(ms) { + try { + const sab = new SharedArrayBuffer(4); + const i32 = new Int32Array(sab); + Atomics.wait(i32, 0, 0, ms); + } catch { + const r = spawnSync( + 'powershell', + ['-NoProfile', '-Command', `Start-Sleep -Milliseconds ${ms}`], + { + stdio: 'ignore', + timeout: ms + 2_000, + }, + ); + if (r.error || r.status !== 0) { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + // spin + } + } + } +} + +/** + * Synchronously inspects the process tree under rootPid via PowerShell/CIM. + * Throws on spawn error or non-zero PowerShell exit so a failure is visible, + * rather than returning an empty tree silently (which would cause misleading + * assertion failures). + */ +function inspectProcessTreeSync(rootPid) { + if (!rootPid) + return { bunPresent: false, nodePresent: false, descendants: [] }; + const script = [ + `function Get-Descendants($root) {`, + ` $result = @()`, + ` $queue = @($root)`, + ` for ($i=0; $i -lt 4 -and $queue.Count -gt 0; $i++) {`, + ` $next = @()`, + ` foreach ($p in $queue) {`, + ` $kids = Get-CimInstance Win32_Process -Filter "ParentProcessId=$($p)" -ErrorAction SilentlyContinue`, + ` if ($kids) { $result += $kids; $next += $kids.ProcessId }`, + ` }`, + ` $queue = $next`, + ` }`, + ` return $result`, + `}`, + `Get-Descendants ${rootPid} | Select-Object ProcessId,Name | ConvertTo-Json -Compress`, + ].join('\n'); + const ps = spawnSync('powershell', ['-NoProfile', '-Command', script], { + encoding: 'utf8', + timeout: 15_000, + }); + if (ps.error) { + throw new Error( + `inspectProcessTreeSync: PowerShell spawn failed: ${ps.error.message}`, + ); + } + if (ps.status !== 0) { + throw new Error( + `inspectProcessTreeSync: PowerShell exited ${ps.status} (signal=${ps.signal ?? 'none'}): ${ps.stderr || ps.stdout}`, + ); + } + const descendants = []; + const raw = ps.stdout.trim(); + if (raw) { + try { + const parsed = JSON.parse(raw); + const arr = Array.isArray(parsed) ? parsed : [parsed]; + for (const p of arr) { + descendants.push({ pid: p.ProcessId, name: p.Name }); + } + } catch (e) { + throw new Error( + `inspectProcessTreeSync: failed to parse PowerShell JSON output: ${e.message}\nraw=${JSON.stringify(raw)}`, + ); + } + } + const names = descendants.map((d) => String(d.name).toLowerCase()); + const bunPresent = names.some((n) => n === 'bun.exe' || n === 'bun'); + const nodePresent = names.some((n) => n === 'node.exe' || n === 'node'); + return { bunPresent, nodePresent, descendants }; +} + +module.exports = { + sleepMs, + inspectProcessTreeSync, + spawn, +}; From 3f52ccee2fb8999005ae41cc582a5cad102ca225 Mon Sep 17 00:00:00 2001 From: acoliver Date: Tue, 21 Jul 2026 10:44:43 -0300 Subject: [PATCH 02/18] Satisfy strict shell lint for native launcher --- packages/cli/bin/llxprt | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/bin/llxprt b/packages/cli/bin/llxprt index 4ec53282ad..a8d317ddd6 100755 --- a/packages/cli/bin/llxprt +++ b/packages/cli/bin/llxprt @@ -1,4 +1,5 @@ #!/bin/sh +# shellcheck disable=SC2250 set -u _llxprt_self=$0 From 99f2d2eba7e6112cc45a26a927862feceb7aef61 Mon Sep 17 00:00:00 2001 From: acoliver Date: Tue, 21 Jul 2026 13:47:03 -0300 Subject: [PATCH 03/18] Harden installed launcher resolution and smoke coverage --- CONTRIBUTING.md | 2 +- README.md | 7 +- docs/getting-started.md | 7 +- docs/troubleshooting.md | 15 +- packages/cli/bin/llxprt | 258 ++++++++- .../cli/scripts/install-native-launchers.cjs | 14 +- packages/test-utils/src/cli-args.test.ts | 23 + scripts/lib/npm-command.cjs | 160 ++++++ scripts/postinstall.cjs | 17 +- .../tests/issue-2603-install-layouts.test.ts | 31 +- ...-install-native-launchers-contract.test.ts | 9 +- scripts/tests/issue-2603-install.test.ts | 68 ++- .../tests/issue-2603-launcher-signals.test.ts | 195 +++++++ ...sue-2603-launcher-source-workspace.test.ts | 274 +++++++++ scripts/tests/issue-2603-launcher.test.ts | 519 ++++++++++++------ .../issue-2603-release-install-smoke.cjs | 183 ++++-- .../tests/issue-2603-release-install.test.ts | 112 +++- scripts/tests/issue-2603-release-pack.cjs | 47 +- .../issue-2603-shim-separator-tests.test.ts | 78 +++ .../tests/issue-2603-smoke-helpers.test.ts | 164 ++++++ .../tests/issue-2603-startup-benchmark.cjs | 12 +- scripts/tests/issue-2603-windows-probe.ts | 10 +- scripts/tests/npm-command.test.ts | 252 +++++++++ .../tests/postinstall-manager-aware.test.ts | 38 ++ scripts/windows-installed-command-smoke.cjs | 122 ++-- .../assert.cjs | 17 +- .../checks.cjs | 199 ++++--- .../install-helpers.cjs | 81 +-- .../launcher-invocation.cjs | 102 +++- .../process-helpers.cjs | 187 ++++++- 30 files changed, 2613 insertions(+), 590 deletions(-) create mode 100644 scripts/lib/npm-command.cjs create mode 100644 scripts/tests/issue-2603-launcher-signals.test.ts create mode 100644 scripts/tests/issue-2603-launcher-source-workspace.test.ts create mode 100644 scripts/tests/issue-2603-shim-separator-tests.test.ts create mode 100644 scripts/tests/issue-2603-smoke-helpers.test.ts create mode 100644 scripts/tests/npm-command.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ac31233ba7..76567060f6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -157,7 +157,7 @@ Alternatively, the dev launcher (`scripts/start.ts`) spawns Bun directly on the bun scripts/start.ts ``` -The production launcher (`packages/cli/bin/llxprt`) is a POSIX sh script with a valid `#!/bin/sh` shebang. It resolves the package-local Bun by walking ancestor directories for `node_modules/bun/bin/bun.exe` (the package's pinned Bun dependency), with no global PATH fallback, and 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. +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` diff --git a/README.md b/README.md index 89b66941d0..2207f55939 100644 --- a/README.md +++ b/README.md @@ -108,8 +108,11 @@ LLxprt Code is powered by the [Bun](https://bun.sh) runtime. When you run `llxpr **Bun resolution (production launcher):** -1. `node_modules/bun/bin/bun.exe` (the package's pinned Bun dependency, climbing ancestor directories) -2. `node_modules/.bin/bun` (the `.bin` shim, climbing ancestor directories) +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` + +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. If no package-local Bun runtime is found, the launcher prints an actionable error (exit code 43): diff --git a/docs/getting-started.md b/docs/getting-started.md index 9a726b0ca8..66a945ff1c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -27,10 +27,11 @@ LLxprt Code is powered by the [Bun](https://bun.sh) runtime. When you run `llxpr **Bun resolution order (production launcher):** -1. `node_modules/bun/bin/bun.exe` (the package's pinned `bun` dependency, climbing ancestor directories) -2. `node_modules/.bin/bun` / `node_modules/.bin/bun.exe` (the bundled Bun bin link) +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` -The launcher resolves only the package-local Bun — it never falls back to a global `bun` on `PATH`, so a separately installed Bun is not required. If no package-local Bun 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: > 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 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index dcba64d7a7..c4d5ec767d 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 architecture, or unusable — 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 diff --git a/packages/cli/bin/llxprt b/packages/cli/bin/llxprt index a8d317ddd6..5e632f0ebf 100755 --- a/packages/cli/bin/llxprt +++ b/packages/cli/bin/llxprt @@ -2,6 +2,9 @@ # 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. _llxprt_self=$0 while [ -L "$_llxprt_self" ]; do _llxprt_dir=$(dirname -- "$_llxprt_self") @@ -14,27 +17,178 @@ done _llxprt_script_dir=$(cd -- "$(dirname -- "$_llxprt_self")" 2>/dev/null && pwd) || \ _llxprt_script_dir=$(dirname -- "$_llxprt_self") -_llxprt_search=$_llxprt_script_dir -_llxprt_bun="" -while true; do - if [ -x "$_llxprt_search/node_modules/bun/bin/bun.exe" ]; then - _llxprt_bun=$_llxprt_search/node_modules/bun/bin/bun.exe - break +# 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="" +_llxprt_pkg_name="" +if [ -f "$_llxprt_pkg_json" ]; then + _llxprt_bun_pin=$(sed -n 's/.*"bun"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' -- "$_llxprt_pkg_json" 2>/dev/null | head -n1) + _llxprt_pkg_name=$(sed -n 's/.*"name"[[: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 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") is not exact, so we cannot compare equality. + if [ -z "$_llxprt_bun_pin" ]; then + return 0 fi - if [ -x "$_llxprt_search/node_modules/.bin/bun" ]; then - _llxprt_bun=$_llxprt_search/node_modules/.bin/bun - break + case "$_llxprt_bun_pin" in + [0-9]*) ;; # exact version like "1.3.14" + *) return 0 ;; # range like "^1.3.14" or "*": not exact, accept + esac + if ! _llxprt_derive_bun_pkg "$_llxprt_candidate"; then + # Cannot derive package.json for this layout; reject under an exact pin. + return 1 fi - if [ -x "$_llxprt_search/node_modules/.bin/bun.exe" ]; then - _llxprt_bun=$_llxprt_search/node_modules/.bin/bun.exe - break + 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_parent=$(dirname -- "$_llxprt_search") - if [ "$_llxprt_parent" = "$_llxprt_search" ]; then - break + _llxprt_found_ver=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' -- "$_llxprt_bun_pkg" 2>/dev/null | head -n1) + if [ -z "$_llxprt_found_ver" ]; then + # Version field missing/unreadable: reject under an exact pin. + return 1 fi - _llxprt_search=$_llxprt_parent -done + [ "$_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: its package.json must declare workspaces that include this +# package's directory (e.g. "packages/cli") OR carry this package's name. This +# prevents generic ancestor climbing — only a verified workspace root whose +# manifest references this package is accepted. 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 + # The package directory relative to the candidate root (e.g. packages/cli). + _llxprt_pkg_rel=$(dirname -- "$_llxprt_pkg_root") + _llxprt_pkg_leaf=$(basename -- "$_llxprt_pkg_root") + _llxprt_pkg_base=$(basename -- "$_llxprt_pkg_rel") + # Accept if the root workspaces array references this package directory + # (packages/cli) or the root package name matches this package's name. + if grep -q "\"workspaces\"" -- "$_llxprt_root_manifest" 2>/dev/null && \ + grep -q "\"$_llxprt_pkg_base/$_llxprt_pkg_leaf\"\|\"$_llxprt_pkg_leaf\"" -- "$_llxprt_root_manifest" 2>/dev/null; then + _llxprt_ws_root=$_llxprt_candidate_root + return 0 + fi + if [ -n "$_llxprt_pkg_name" ]; then + _llxprt_root_name=$(sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' -- "$_llxprt_root_manifest" 2>/dev/null | head -n1) + if [ "$_llxprt_root_name" = "$_llxprt_pkg_name" ]; then + _llxprt_ws_root=$_llxprt_candidate_root + return 0 + fi + fi + return 1 +} + +# 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 @@ -46,21 +200,65 @@ 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` (a single tiny process that opens the file directly, -# no dd pipe) lets us reject non-native binaries with an actionable exit 43 -# without spawning Bun. Accepted magics: ELF (7f454c46), Mach-O -# (feedface/feedfacf/cefaedfe/cffaedfe/fat cafebabe/bebafeca), and PE/COFF -# (4d5a, "MZ") for POSIX shells that can execute Windows PE via the OS or an -# emulation layer such as MSYS/Git Bash. +# 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. _llxprt_magic=$(od -An -tx1 -N4 -- "$_llxprt_bun" 2>/dev/null | tr -d ' \n') -case "$_llxprt_magic" in - 4d5a*|7f454c46|feedface|feedfacf|cefaedfe|cffaedfe|cafebabe|bebafeca) ;; + +_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 or the wrong platform.' >&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 or the wrong platform.' >&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 + ;; *) - 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 or the wrong architecture.' >&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 + # 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 or the wrong platform.' >&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 diff --git a/packages/cli/scripts/install-native-launchers.cjs b/packages/cli/scripts/install-native-launchers.cjs index 97bd36d49b..e8b1f7d48e 100644 --- a/packages/cli/scripts/install-native-launchers.cjs +++ b/packages/cli/scripts/install-native-launchers.cjs @@ -39,10 +39,12 @@ function isWithinPackageRoot(resolvedPath, packageRoot) { // 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; + const re = /"%dp0%[/\\]([^"]+)"/g; let m; while ((m = re.exec(content)) !== null) { if (!seen.has(m[1])) { @@ -54,13 +56,15 @@ function extractCmdShimTargets(content) { } // npm cmd-shim ps1 files reference the interpreter ("$basedir//bin/sh$exe") -// and the real package target ("$basedir/../path/to/bin/llxprt"). Return every -// "$basedir/" candidate so the caller can test each against the package -// boundary. +// 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; + const re = /"\$basedir[/\\]([^"]+)"/g; let m; while ((m = re.exec(content)) !== null) { if (!seen.has(m[1])) { diff --git a/packages/test-utils/src/cli-args.test.ts b/packages/test-utils/src/cli-args.test.ts index 6de48091fc..27fd8b0d94 100644 --- a/packages/test-utils/src/cli-args.test.ts +++ b/packages/test-utils/src/cli-args.test.ts @@ -99,4 +99,27 @@ describe('cli-args helpers', () => { }); 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/scripts/lib/npm-command.cjs b/scripts/lib/npm-command.cjs new file mode 100644 index 0000000000..b8c20bb311 --- /dev/null +++ b/scripts/lib/npm-command.cjs @@ -0,0 +1,160 @@ +'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 path that exists — some environments set it to a .cmd wrapper. + if (env.npm_execpath && env.npm_execpath.endsWith('.js')) { + if (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); + throw new NpmCliNotFoundError( + `npm-cli.js could not be resolved on Windows (probed: ${probed.join(', ')}). ` + + 'Ensure Node was installed via setup-node or an official installer that ships npm alongside node.exe.', + { 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} + */ +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} + */ +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/postinstall.cjs b/scripts/postinstall.cjs index 9db572025d..bad191dbc8 100644 --- a/scripts/postinstall.cjs +++ b/scripts/postinstall.cjs @@ -274,11 +274,18 @@ function installWindowsNativeLaunchers() { if (!fs.existsSync(cliInstaller)) { return; } - const { installNativeLaunchers } = require(cliInstaller); - installNativeLaunchers({ - packageRoot: path.join(repoRoot, 'packages', 'cli'), - log: console.log, - }); + try { + const { installNativeLaunchers } = require(cliInstaller); + installNativeLaunchers({ + packageRoot: path.join(repoRoot, 'packages', 'cli'), + log: console.log, + }); + } catch (error) { + console.warn( + 'Warning: Windows native launcher generation failed (non-fatal):', + error.message, + ); + } } // Under Bun, only the npm-specific actions below are skipped: Bun does not diff --git a/scripts/tests/issue-2603-install-layouts.test.ts b/scripts/tests/issue-2603-install-layouts.test.ts index bb701f544d..0c33608367 100644 --- a/scripts/tests/issue-2603-install-layouts.test.ts +++ b/scripts/tests/issue-2603-install-layouts.test.ts @@ -30,22 +30,33 @@ const cliModulePath = join( ); /** - * Derive the pnpm virtual-store fixture version from the published CLI manifest - * rather than hardcoding it. The pnpm store directory name encodes - * `+@`, so it must track packages/cli/package.json. + * 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. */ -function readCliVersion(): string { +interface CliManifest { + name: string; + version: string; +} + +function readCliManifest(): CliManifest { const cliPkg = JSON.parse( readFileSync(join(repoRoot, 'packages', 'cli', 'package.json'), 'utf8'), - ) as { version?: string }; - if (typeof cliPkg.version !== 'string' || cliPkg.version.length === 0) { - throw new Error('packages/cli/package.json is missing a version'); + ) 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 cliPkg.version; + return { name: cliPkg.name, version: cliPkg.version }; } -const CLI_VERSION = readCliVersion(); -const PNPM_PACKAGE_DIR = `@vybestack+llxprt-code@${CLI_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}`; function loadCliInstaller(): ReturnType { return nodeRequire(cliModulePath); diff --git a/scripts/tests/issue-2603-install-native-launchers-contract.test.ts b/scripts/tests/issue-2603-install-native-launchers-contract.test.ts index 6bd5f6f3a8..736ee05d2b 100644 --- a/scripts/tests/issue-2603-install-native-launchers-contract.test.ts +++ b/scripts/tests/issue-2603-install-native-launchers-contract.test.ts @@ -141,10 +141,7 @@ describe('installNativeLaunchers logging', () => { const dotBin = join(tempDir, 'node_modules', '.bin'); mkdirSync(dotBin, { recursive: true }); const foreignCmd = join(dotBin, 'llxprt.cmd'); - writeFileSync( - foreignCmd, - '@echo off' + String.fromCharCode(10) + 'echo someone else', - ); + writeFileSync(foreignCmd, '@echo off\necho someone else'); const messages: string[] = []; mod.installNativeLaunchers({ platform: 'win32', @@ -153,7 +150,7 @@ describe('installNativeLaunchers logging', () => { log: (msg: string) => messages.push(msg), }); const skipMsg = messages.find((m) => m.includes(foreignCmd)); - expect(skipMsg, messages.join(String.fromCharCode(10))).toBeDefined(); + expect(skipMsg, messages.join('\n')).toBeDefined(); expect(skipMsg).toMatch(/Skipped foreign/i); } finally { rmSync(tempDir, { recursive: true, force: true }); @@ -194,7 +191,7 @@ describe('installNativeLaunchers logging', () => { const wroteMsg = messages.find((m) => /Wrote \d+ native launcher/.test(m), ); - expect(wroteMsg, messages.join(String.fromCharCode(10))).toBeDefined(); + expect(wroteMsg, messages.join('\n')).toBeDefined(); } finally { rmSync(tempDir, { recursive: true, force: true }); } diff --git a/scripts/tests/issue-2603-install.test.ts b/scripts/tests/issue-2603-install.test.ts index eb7a8406bd..a5193f853d 100644 --- a/scripts/tests/issue-2603-install.test.ts +++ b/scripts/tests/issue-2603-install.test.ts @@ -17,6 +17,14 @@ 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 cliModulePath = join( repoRoot, 'packages', @@ -56,19 +64,6 @@ const sharedCacheDir = join( ); let cachedTarball: string | null = null; -/** - * Cross-platform npm discovery. On Windows `which` does not exist, so use - * `where`. Prefer process.execPath / npm_execpath when available to avoid - * spawning an extra process. Returns the npm binary path. - */ -function resolveNpm(): string { - const npmExecPath = process.env.npm_execpath; - if (npmExecPath) { - return process.execPath; - } - return 'npm'; -} - function findTarballName(packOutput: string): string { const lines = packOutput.split(/\r?\n/); const tgzLines = lines.filter((l) => l.trim().endsWith('.tgz')); @@ -85,25 +80,14 @@ function packCliWorkspace(): string { return cachedTarball; } mkdirSync(sharedCacheDir, { recursive: true }); - const npmBin = resolveNpm(); - const npmArgs = - npmBin === process.execPath - ? [ - process.env.npm_execpath!, - 'pack', - '-w', - '@vybestack/llxprt-code', - '--pack-destination', - sharedCacheDir, - ] - : [ - 'pack', - '-w', - '@vybestack/llxprt-code', - '--pack-destination', - sharedCacheDir, - ]; - const result = spawnSync(npmBin, npmArgs, { + const { command, args } = npmInvocation([ + 'pack', + '-w', + '@vybestack/llxprt-code', + '--pack-destination', + sharedCacheDir, + ]); + const result = spawnSync(command, args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, @@ -294,14 +278,17 @@ describe('install-native-launchers module (CLI workspace)', () => { nodeRequire.resolve(candidate); return candidate; } catch { - // try next + /* try next */ } } - const npmRoot = spawnSync('npm', ['root', '-g'], { + const { command: npmCmd, args: npmArgs } = npmInvocation(['root', '-g']); + const npmRoot = spawnSync(npmCmd, npmArgs, { encoding: 'utf8', timeout: 10_000, }); - if (npmRoot.status === 0) { + 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 { @@ -325,7 +312,7 @@ describe('install-native-launchers module (CLI workspace)', () => { 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(String.fromCharCode(10)); + const lines = npmCli.stdout.trim().split('\n'); const npmBin = dirname(lines[0]!); npmDir = dirname(npmBin); } @@ -376,8 +363,15 @@ describe('install-native-launchers module (CLI workspace)', () => { ], { 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: ${result.stderr}`); + throw new Error( + `cmd-shim generation failed (exit ${result.status}, signal=${result.signal ?? 'none'}): ${result.stderr}`, + ); } return join(binLinkDir, 'llxprt.cmd'); } 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 0000000000..bbabb8613b --- /dev/null +++ b/scripts/tests/issue-2603-launcher-signals.test.ts @@ -0,0 +1,195 @@ +/** + * @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'); + +function ensureBun(): string { + if (existsSync(repoBun)) { + return repoBun; + } + const whichResult = spawnSync('which', ['bun'], { encoding: 'utf8' }); + if (whichResult.status === 0) { + return whichResult.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 }; +} + +describe('POSIX launcher signal behavior', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'llxprt-sig-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + function makeLongRunning(tempDir: string): { + pkgRoot: string; + launcherTarget: string; + pidFile: string; + } { + const pidFile = join(tempDir, 'child-pid.txt'); + const { pkgRoot, launcherTarget } = makeLayout(tempDir, { + 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 }; + } + + it('SIGINT 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: '/usr/bin:/bin' }, + }); + + let exited = false; + let exitSignal: NodeJS.Signals | null = null; + child.on('exit', (_code, signal) => { + exited = true; + exitSignal = signal; + }); + + 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, 'SIGINT'); + } catch { + child.kill('SIGINT'); + } + } else { + child.kill('SIGINT'); + } + } + 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('SIGINT'); + resolve(); + }); + }); + }, 20_000); + + it('SIGTERM 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: '/usr/bin:/bin' }, + }); + + let exited = false; + let exitSignal: NodeJS.Signals | null = null; + child.on('exit', (_code, signal) => { + exited = true; + exitSignal = signal; + }); + + 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, 'SIGTERM'); + } catch { + child.kill('SIGTERM'); + } + } else { + child.kill('SIGTERM'); + } + } + 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('SIGTERM'); + resolve(); + }); + }); + }, 20_000); +}); 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 0000000000..4f41e4465c --- /dev/null +++ b/scripts/tests/issue-2603-launcher-source-workspace.test.ts @@ -0,0 +1,274 @@ +/** + * @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, +} 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; + +function ensureBun(): string { + if (existsSync(repoBun)) { + return repoBun; + } + const whichResult = spawnSync('which', ['bun'], { encoding: 'utf8' }); + if (whichResult.status === 0) { + return whichResult.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 realBunVersion(): string { + const bunPkgPath = join(repoRoot, 'node_modules', 'bun', 'package.json'); + if (existsSync(bunPkgPath)) { + try { + const bunPkg = JSON.parse(readFileSync(bunPkgPath, 'utf8')); + if (typeof bunPkg.version === 'string') return bunPkg.version; + } catch { + // keep default + } + } + return '1.3.14'; +} + +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('rejects a source-workspace root whose manifest does not reference this package', () => { + // The package sits at /packages/cli but the root manifest's + // workspaces array does NOT include packages/cli and the root name does + // not match the package name. The launcher must NOT generic-climb and + // accept this unrelated root's Bun. + const { pkgRoot, launcherTarget } = makeSourceWorkspace(tempDir, { + rootWorkspaces: ['packages/tools', 'packages/core'], + rootPkgName: 'some-other-project', + }); + 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 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 index 7e326ccf84..349d6e2b25 100644 --- a/scripts/tests/issue-2603-launcher.test.ts +++ b/scripts/tests/issue-2603-launcher.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { spawnSync, spawn } from 'node:child_process'; +import { spawnSync } from 'node:child_process'; import { existsSync, statSync, @@ -21,6 +21,26 @@ 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, missing entry point). Centralized so a change to the launcher's + * failure code only requires updating one place. + */ +const LAUNCHER_FAILURE_EXIT = 43; + +/** + * 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); +} + function ensureBun(): string { if (existsSync(repoBun)) { return repoBun; @@ -32,6 +52,24 @@ function ensureBun(): string { throw new Error('Bun not found for test setup'); } +/** + * Returns the real Bun version from the repo's bun package.json so tests can + * write matching pins. Falls back to a sentinel if unavailable. Shared at + * module scope so multiple describe blocks reuse it without duplication. + */ +function realBunVersion(): string { + const bunPkgPath = join(repoRoot, 'node_modules', 'bun', 'package.json'); + if (existsSync(bunPkgPath)) { + try { + const bunPkg = JSON.parse(readFileSync(bunPkgPath, 'utf8')); + if (typeof bunPkg.version === 'string') return bunPkg.version; + } catch { + // keep default + } + } + return '1.3.14'; +} + function makeEntry(pkgRoot: string, code: string): void { writeFileSync(join(pkgRoot, 'index.ts'), `#!/usr/bin/env -S bun\n${code}\n`); } @@ -201,31 +239,17 @@ describe('POSIX launcher execution behavior', () => { }, 30_000); it('invokes Bun exactly once (no pre-probe)', () => { - const bunPath = ensureBun(); - 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); - - const counterDir = join(pkgRoot, 'counter'); + const counterDir = join(tempDir, 'counter'); mkdirSync(counterDir, { recursive: true }); const counterFile = join(counterDir, 'invocations.txt'); - makeEntry( - pkgRoot, - `const fs = require('fs'); - const path = require('path'); - const counter = path.join(${JSON.stringify(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 bunDir = join(pkgRoot, 'node_modules', 'bun', 'bin'); - mkdirSync(bunDir, { recursive: true }); - copyFileSync(bunPath, join(bunDir, 'bun.exe')); + }); const result = spawnSync(launcherTarget, [], { cwd: pkgRoot, @@ -300,10 +324,124 @@ describe('POSIX launcher execution behavior', () => { timeout: 15_000, env: { ...process.env, PATH: '/usr/bin:/bin' }, }); - expect(result.status).toBe(43); + expect(result.status).toBe(LAUNCHER_FAILURE_EXIT); expect(result.stderr).toMatch(/npm install|bun\.sh/i); }, 15_000); + 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: 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 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')); + + // Write a matching bun package.json version. Read the real version from + // the repo's bun package if available; otherwise skip version pin. + const bunPkgPath = join(repoRoot, 'node_modules', 'bun', 'package.json'); + let bunVersion = '1.3.14'; + if (existsSync(bunPkgPath)) { + try { + const bunPkg = JSON.parse(readFileSync(bunPkgPath, 'utf8')); + if (typeof bunPkg.version === 'string') bunVersion = bunPkg.version; + } catch { + // keep default + } + } + 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: 30_000, + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }); + expect(result.status, result.stderr).toBe(0); + }, 30_000); + it('exits 43 when Bun is a corrupt text file (not a native binary)', () => { const { pkgRoot, launcherTarget } = makeLayout(tempDir, { withBun: false, @@ -319,7 +457,7 @@ describe('POSIX launcher execution behavior', () => { timeout: 15_000, env: { ...process.env, PATH: '/usr/bin:/bin' }, }); - expect(result.status).toBe(43); + expect(result.status).toBe(LAUNCHER_FAILURE_EXIT); expect(result.stderr).toMatch( /npm install|bun\.sh|unusable|not a valid|corrupt/i, ); @@ -345,39 +483,75 @@ describe('POSIX launcher execution behavior', () => { timeout: 15_000, env: { ...process.env, PATH: '/usr/bin:/bin' }, }); - expect(result.status).toBe(43); + expect(result.status).toBe(LAUNCHER_FAILURE_EXIT); expect(result.stderr).toMatch( /npm install|bun\.sh|unusable|not a valid|corrupt/i, ); }, 15_000); - it('accepts a PE/COFF (MZ, 4d5a) Bun magic as a native binary', () => { + 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. - const source = readFileSync(launcherPath, 'utf8'); - // The case pattern must include a 4d5a branch (with trailing * because MZ - // is a 2-byte magic and the remaining 2 bytes of the 4-byte read vary). - expect(source).toMatch(/4d5a\*/); + // 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('magic case-statement accepts ELF, Mach-O, and PE/COFF prefixes', () => { - // Unit-level contract: all accepted magics must appear in the case list. - const source = readFileSync(launcherPath, 'utf8'); - const caseStart = source.indexOf('case "$_llxprt_magic"'); - // Find the esac that closes the magic case (the first esac AFTER the - // magic case start), not an earlier esac from the symlink loop. - const caseEnd = source.indexOf('esac', caseStart); - const caseBlock = source.slice(caseStart, caseEnd); - expect(caseBlock).toContain('7f454c46'); // ELF - expect(caseBlock).toContain('feedface'); // Mach-O 32 BE - expect(caseBlock).toContain('feedfacf'); // Mach-O 64 BE - expect(caseBlock).toContain('cefaedfe'); // Mach-O 32 LE - expect(caseBlock).toContain('cffaedfe'); // Mach-O 64 LE - expect(caseBlock).toContain('cafebabe'); // Mach-O fat BE - expect(caseBlock).toContain('bebafeca'); // Mach-O fat LE - expect(caseBlock).toContain('4d5a'); // PE/COFF MZ + it('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. + if (process.platform !== 'darwin') return; + 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: 15_000, + 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); + + 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)', () => { @@ -415,33 +589,20 @@ describe('POSIX launcher execution behavior', () => { timeout: 15_000, env: { ...process.env, PATH: '/usr/bin:/bin' }, }); - expect(result.status).toBe(43); + expect(result.status).toBe(LAUNCHER_FAILURE_EXIT); }, 15_000); 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 bunPath = ensureBun(); - 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); - - const counterFile = join(pkgRoot, 'invocations.txt'); - makeEntry( - pkgRoot, - `const fs = require('fs'); + 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 bunDir = join(pkgRoot, 'node_modules', 'bun', 'bin'); - mkdirSync(bunDir, { recursive: true }); - copyFileSync(bunPath, join(bunDir, 'bun.exe')); + }); const result = spawnSync(launcherTarget, [], { cwd: pkgRoot, @@ -475,7 +636,7 @@ describe('POSIX launcher execution behavior', () => { timeout: 15_000, env: { ...process.env, PATH: '/usr/bin:/bin' }, }); - expect(result.status).toBe(43); + expect(result.status).toBe(LAUNCHER_FAILURE_EXIT); expect(result.stderr).toMatch(/entry point|index\.ts|corrupt/i); }, 15_000); @@ -509,142 +670,156 @@ describe('POSIX launcher execution behavior', () => { }, 30_000); }); -describe('POSIX launcher signal behavior', () => { +describe('POSIX launcher version-pin and platform validation', () => { let tempDir: string; beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), 'llxprt-sig-')); + tempDir = mkdtempSync(join(tmpdir(), 'llxprt-pin-')); }); afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); }); - function makeLongRunning(tempDir: string): { - pkgRoot: string; - launcherTarget: string; - pidFile: string; - } { - const pkgRoot = join(tempDir, 'pkg'); + 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);'); - const pidFile = join(pkgRoot, 'child-pid.txt'); - makeEntry( - pkgRoot, - [ - 'const fs = require("fs");', - 'const path = require("path");', - `fs.writeFileSync(${JSON.stringify(pidFile)}, String(process.pid));`, - 'process.stdin.resume();', - ].join('\n'), + // 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 bunDir = join(pkgRoot, 'node_modules', 'bun', 'bin'); - mkdirSync(bunDir, { recursive: true }); - copyFileSync(ensureBun(), join(bunDir, 'bun.exe')); - - return { pkgRoot, launcherTarget, pidFile }; - } - - it('SIGINT reaches the child directly via exec (process replacement)', () => { - const { pkgRoot, launcherTarget, pidFile } = makeLongRunning(tempDir); - const child = spawn(launcherTarget, [], { + const result = spawnSync(launcherTarget, [], { cwd: pkgRoot, - stdio: ['pipe', 'pipe', 'pipe'], + 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); - let exited = false; - let exitSignal: NodeJS.Signals | null = null; - child.on('exit', (_code, signal) => { - exited = true; - exitSignal = signal; - }); - - 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, 'SIGINT'); - } catch { - child.kill('SIGINT'); - } - } else { - child.kill('SIGINT'); - } - } - waited++; - }, 100); - - setTimeout(() => { - clearInterval(wait); - if (!exited) { - child.kill('SIGKILL'); - } - }, 15_000).unref(); + 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);'); - return new Promise((resolve) => { - child.on('exit', () => { - expect(exited).toBe(true); - expect(exitSignal).toBe('SIGINT'); - resolve(); - }); - }); - }, 20_000); + // 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, + ), + ); - it('SIGTERM reaches the child directly via exec (process replacement)', () => { - const { pkgRoot, launcherTarget, pidFile } = makeLongRunning(tempDir); - const child = spawn(launcherTarget, [], { + const result = spawnSync(launcherTarget, [], { cwd: pkgRoot, - stdio: ['pipe', 'pipe', 'pipe'], + encoding: 'utf8', + timeout: 30_000, env: { ...process.env, PATH: '/usr/bin:/bin' }, }); + expect(result.status, result.stderr).toBe(0); + }, 30_000); - let exited = false; - let exitSignal: NodeJS.Signals | null = null; - child.on('exit', (_code, signal) => { - exited = true; - exitSignal = signal; - }); - - 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, 'SIGTERM'); - } catch { - child.kill('SIGTERM'); - } - } else { - child.kill('SIGTERM'); - } - } - waited++; - }, 100); + 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);'); - setTimeout(() => { - clearInterval(wait); - if (!exited) { - child.kill('SIGKILL'); - } - }, 15_000).unref(); + // 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')); - return new Promise((resolve) => { - child.on('exit', () => { - expect(exited).toBe(true); - expect(exitSignal).toBe('SIGTERM'); - resolve(); - }); + 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 an ELF Bun on Darwin (platform-gated format)', () => { + if (process.platform !== 'darwin') return; + 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: 15_000, + env: { ...process.env, PATH: '/usr/bin:/bin' }, }); - }, 20_000); + 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 index 987550fbf9..a1d6a85d44 100644 --- a/scripts/tests/issue-2603-release-install-smoke.cjs +++ b/scripts/tests/issue-2603-release-install-smoke.cjs @@ -35,6 +35,55 @@ const { const { join, resolve } = require('node:path'); const { tmpdir } = require('node:os'); const { createRequire } = require('node:module'); +const { npmInvocation } = require('../lib/npm-command.cjs'); + +/** + * Platform-aware PATH that proves the launcher needs NO global Bun or Node. + * On POSIX, only /usr/bin and /bin are present. On Windows, System32 is + * included so cmd.exe remains reachable for the .cmd wrapper (the launcher + * invokes bun.exe directly, not via cmd), but no global Bun/Node paths. + */ +function constrainedPath() { + return process.platform === 'win32' + ? 'C:\\Windows\\System32;C:\\Windows;C:\\Windows\\System32\\Wbem' + : '/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()); const nodeRequire = createRequire(__filename); @@ -161,25 +210,22 @@ function main() { runStep('global-install-version', () => { const prefix = join(tempDir, 'global-prefix'); mkdirSync(prefix, { recursive: true }); - const installResult = spawnSync( - 'npm', - [ - 'install', - '--global', - '--prefix', - prefix, - '--cache', - join(tempDir, 'npm-cache'), - '--loglevel', - 'error', - replicaTarball, - ], - { - encoding: 'utf8', - timeout: 180_000, - maxBuffer: 64 * 1024 * 1024, - }, - ); + 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}`, @@ -190,13 +236,23 @@ function main() { `global npm install failed (exit ${installResult.status}, signal=${installResult.signal ?? 'none'}): ${installResult.stderr || installResult.stdout}`, ); } - const binLink = join(prefix, 'bin', 'llxprt'); + const binLink = resolveGlobalBin(prefix); assert(existsSync(binLink), `global bin link not found: ${binLink}`); - const result = spawnSync(binLink, ['--version'], { - encoding: 'utf8', - timeout: 30_000, - env: { ...process.env, PATH: '/usr/bin:/bin' }, - }); + 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}`, @@ -221,23 +277,20 @@ function main() { join(consumerDir, 'package.json'), JSON.stringify({ name: 'consumer', version: '0.0.0' }, null, 2), ); - const installResult = spawnSync( - 'npm', - [ - 'install', - '--cache', - join(tempDir, 'npm-cache-local'), - '--loglevel', - 'error', - replicaTarball, - ], - { - cwd: consumerDir, - encoding: 'utf8', - timeout: 180_000, - maxBuffer: 64 * 1024 * 1024, - }, - ); + 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}`, @@ -248,14 +301,20 @@ function main() { `local npm install failed (exit ${installResult.status}, signal=${installResult.signal ?? 'none'}): ${installResult.stderr || installResult.stdout}`, ); } - const binLink = join(consumerDir, 'node_modules', '.bin', 'llxprt'); + const binLink = resolveLocalBin(consumerDir); assert(existsSync(binLink), `local bin link not found: ${binLink}`); - const result = spawnSync(binLink, ['--version'], { - encoding: 'utf8', - timeout: 30_000, - cwd: consumerDir, - env: { ...process.env, PATH: '/usr/bin:/bin' }, - }); + 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}`, @@ -288,17 +347,21 @@ function main() { JSON.stringify({ name: 'clean-consumer', version: '0.0.0' }, null, 2), ); const npmCache = join(tempDir, 'npm-exec-cache'); - const result = spawnSync( - 'npm', - ['exec', '--package', replicaTarball, '--', 'llxprt', '--version'], - { - cwd: cleanDir, - encoding: 'utf8', - timeout: 300_000, - maxBuffer: 64 * 1024 * 1024, - env: { ...process.env, npm_config_cache: npmCache }, - }, - ); + 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}`); } diff --git a/scripts/tests/issue-2603-release-install.test.ts b/scripts/tests/issue-2603-release-install.test.ts index 2e882579c7..dbb0f0fe4c 100644 --- a/scripts/tests/issue-2603-release-install.test.ts +++ b/scripts/tests/issue-2603-release-install.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect } from 'vitest'; -import { spawn, type ChildProcess } from 'node:child_process'; +import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; import { existsSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -43,7 +43,19 @@ const releasePackHelper = join( * suite hang. Scoped cleanup is the caller's responsibility via the * returned `dispose()` (invoked from try/finally + onTestFinished). */ -const SMOKE_TIMEOUT_MS = 540_000; +/** + * 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<{ @@ -60,6 +72,10 @@ function runSmokeAsync(): SmokeHandle { 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 = ''; @@ -141,13 +157,35 @@ function runSmokeAsync(): SmokeHandle { clearTimeout(timer); timer = null; } - // Kill the child if still alive. The child is non-detached, so a plain - // kill() is sufficient; no process-group kill is needed. + // Kill the entire process tree so grandchildren (npm, tar, bun spawned + // inside the smoke script) are reaped and do not leak as orphans. + // On POSIX: kill the process group (negative PID). On Windows: taskkill + // /T /F kills the entire descendant tree. Falls back to child.kill(). if (child && child.exitCode === null && child.signalCode === null) { try { - child.kill('SIGKILL'); + if (process.platform === 'win32' && child.pid) { + spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { + stdio: 'ignore', + timeout: 10_000, + }); + } else if (child.pid) { + // Negative PID kills the entire process group. + process.kill(-child.pid, 'SIGKILL'); + } + } catch { + try { + child.kill('SIGKILL'); + } catch { + // best effort; child may have exited concurrently + } + } + } + // Unref the detached child so it does not keep the event loop alive. + if (child) { + try { + child.unref(); } catch { - // best effort; child may have exited concurrently + // best effort } } streamTeardown?.(); @@ -168,27 +206,43 @@ describe('release-like CLI pack/install smoke (issue #2603)', () => { 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; - expect( - status, - `smoke exited ${status}\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`, - ).toBe(0); - expect(stderr).toBe(''); - 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.'); - }, 600_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, + ); }); diff --git a/scripts/tests/issue-2603-release-pack.cjs b/scripts/tests/issue-2603-release-pack.cjs index 96c1b36a9c..01b3150f9d 100644 --- a/scripts/tests/issue-2603-release-pack.cjs +++ b/scripts/tests/issue-2603-release-pack.cjs @@ -35,6 +35,7 @@ const { } = require('node:fs'); const { join } = require('node:path'); const { tmpdir } = require('node:os'); +const { npmInvocation } = require('../lib/npm-command.cjs'); /** * Derive the cache dir, filename, and version from the CLI manifest so the @@ -364,16 +365,19 @@ function assertReleaseTarballAssets(releaseTarball) { function packAllInternal(internalPkgs, workCopy, cacheDir) { const tarballMap = new Map(); for (const { name } of internalPkgs) { - const packResult = spawnSync( - 'npm', - ['pack', '-w', name, '--pack-destination', cacheDir], - { - cwd: workCopy, - encoding: 'utf8', - timeout: 120_000, - maxBuffer: 64 * 1024 * 1024, - }, - ); + 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}`, @@ -427,16 +431,19 @@ function rewriteOnePkgDeps(pkgPath, tarballMap) { } function packCli(workCopy, cacheDir) { - const cliPackResult = spawnSync( - 'npm', - ['pack', '-w', '@vybestack/llxprt-code', '--pack-destination', cacheDir], - { - cwd: workCopy, - encoding: 'utf8', - timeout: 120_000, - maxBuffer: 64 * 1024 * 1024, - }, - ); + const { command, args } = npmInvocation([ + 'pack', + '-w', + '@vybestack/llxprt-code', + '--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}`, 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 0000000000..5622548d10 --- /dev/null +++ b/scripts/tests/issue-2603-shim-separator-tests.test.ts @@ -0,0 +1,78 @@ +/** + * @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', +); + +function loadCliInstaller(): ReturnType { + delete nodeRequire.cache[cliModulePath]; + return nodeRequire(cliModulePath); +} + +describe('shim target extraction accepts both path separators', () => { + 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)', () => { + const mod = loadCliInstaller(); + const content = [ + '$target = "$basedir\\..\\lib\\node_modules\\@vybestack\\llxprt-code\\bin\\llxprt"', + ].join('\n'); + const targets = mod.extractPs1ShimTargets(content); + expect(targets).toContain( + '..\\lib\\node_modules\\@vybestack\\llxprt-code\\bin\\llxprt', + ); + }); + + it('extractCmdShimTargets accepts backslash %dp0% paths', () => { + const mod = loadCliInstaller(); + const content = [ + '@echo off', + '"/bin/sh.exe" "%dp0%\\..\\lib\\node_modules\\@vybestack\\llxprt-code\\bin\\llxprt" %*', + ].join('\n'); + const targets = mod.extractCmdShimTargets(content); + expect(targets).toContain( + '..\\lib\\node_modules\\@vybestack\\llxprt-code\\bin\\llxprt', + ); + }); + + it('extractCmdShimTargets accepts forward-slash %dp0% paths (robustness)', () => { + const mod = loadCliInstaller(); + const content = [ + '"%dp0%/../lib/node_modules/@vybestack/llxprt-code/bin/llxprt" %*', + ].join('\n'); + const targets = mod.extractCmdShimTargets(content); + expect(targets).toContain( + '../lib/node_modules/@vybestack/llxprt-code/bin/llxprt', + ); + }); +}); 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 0000000000..8e28e66133 --- /dev/null +++ b/scripts/tests/issue-2603-smoke-helpers.test.ts @@ -0,0 +1,164 @@ +/** + * @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); + +const launcherInvocation = nodeRequire( + join( + repoRoot, + 'scripts', + 'windows-installed-command-smoke', + 'launcher-invocation.cjs', + ), +) as { + validateSpawnResult: (label: string, r: T) => T; + cmdQuote: (s: string) => string; + pwshQuote: (s: string) => string; +}; + +const processHelpers = nodeRequire( + join( + repoRoot, + '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('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'"); + }); +}); + +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 a positive safety bound for BFS traversal depth', () => { + expect(typeof processHelpers.MAX_LEVELS).toBe('number'); + expect(processHelpers.MAX_LEVELS).toBeGreaterThan(0); + }); +}); diff --git a/scripts/tests/issue-2603-startup-benchmark.cjs b/scripts/tests/issue-2603-startup-benchmark.cjs index f06bce4647..9326bfbee1 100644 --- a/scripts/tests/issue-2603-startup-benchmark.cjs +++ b/scripts/tests/issue-2603-startup-benchmark.cjs @@ -41,9 +41,10 @@ const repoBun = join(repoRoot, 'node_modules', 'bun', 'bin', 'bun.exe'); const entry = join(repoRoot, 'packages', 'cli', 'index.ts'); /** - * Cross-platform Bun discovery. On Windows, `which` does not exist; use `where` - * instead. On POSIX, `which` is used. In both cases a spawn error (ENOENT for - * the discovery tool itself) is surfaced as a clear diagnostic. + * 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, + * .bun/bin/bun is also checked as a fallback for alternate installers. */ function resolveBun() { if (existsSync(repoBun)) return repoBun; @@ -61,7 +62,7 @@ function resolveBun() { 'Ensure Bun is installed and on PATH.', ); } - const found = r.stdout.trim().split(String.fromCharCode(10))[0]; + const found = r.stdout.trim().split('\n')[0]; if (!found) { throw new Error(`'${tool} bun' produced no output.`); } @@ -70,10 +71,13 @@ function resolveBun() { function timeDirectLauncher() { // 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(launcher, ['--version'], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, + stdio: 'inherit', env: { ...process.env }, }); if (r.error) { diff --git a/scripts/tests/issue-2603-windows-probe.ts b/scripts/tests/issue-2603-windows-probe.ts index b1faf585e1..fb11347ff8 100644 --- a/scripts/tests/issue-2603-windows-probe.ts +++ b/scripts/tests/issue-2603-windows-probe.ts @@ -142,4 +142,12 @@ async function main(): Promise { } } -void main(); +void main().catch((err: unknown) => { + // Ensure an unexpected rejection exits non-zero with a diagnostic so the + // parent process sees a clear failure rather than an unhandled rejection. + process.stderr.write( + `LLXPRT_PROBE_FATAL: ${err instanceof Error ? err.message : String(err)} +`, + ); + process.exit(1); +}); diff --git a/scripts/tests/npm-command.test.ts b/scripts/tests/npm-command.test.ts new file mode 100644 index 0000000000..d1309c97bd --- /dev/null +++ b/scripts/tests/npm-command.test.ts @@ -0,0 +1,252 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { npmInvocation, npxInvocation, resolveNpmCliJs, NpmCliNotFoundError } = + require('../lib/npm-command.cjs') as { + npmInvocation: ( + args?: readonly string[], + options?: { + platform?: string; + execPath?: string; + env?: Record; + existsSync?: (p: string) => boolean; + }, + ) => { command: string; args: string[] }; + npxInvocation: ( + args?: readonly string[], + options?: { + platform?: string; + execPath?: string; + env?: Record; + existsSync?: (p: string) => boolean; + }, + ) => { command: string; args: string[] }; + resolveNpmCliJs: (options?: { + execPath?: string; + env?: Record; + existsSync?: (p: string) => boolean; + }) => string; + NpmCliNotFoundError: new ( + message: string, + details?: unknown, + ) => Error & { code: string; details: unknown }; + }; + +describe('npmInvocation POSIX', () => { + it('spawns npm directly with the given args', () => { + const inv = npmInvocation(['pack', '-w'], { platform: 'darwin' }); + expect(inv.command).toBe('npm'); + expect(inv.args).toStrictEqual(['pack', '-w']); + }); + + it('spawns npm directly on linux', () => { + const inv = npmInvocation(['install'], { platform: 'linux' }); + expect(inv.command).toBe('npm'); + expect(inv.args).toStrictEqual(['install']); + }); + + it('defaults to the real process.platform when no platform is given', () => { + const inv = npmInvocation(['pack']); + if (process.platform === 'win32') { + expect(inv.command).toBe(process.execPath); + } else { + expect(inv.command).toBe('npm'); + expect(inv.args).toStrictEqual(['pack']); + } + }); + + it('handles no args', () => { + const inv = npmInvocation(undefined, { platform: 'darwin' }); + expect(inv.command).toBe('npm'); + expect(inv.args).toStrictEqual([]); + }); +}); + +describe('npmInvocation Windows', () => { + it('spawns node.exe with npm-cli.js as the first arg', () => { + const inv = npmInvocation(['pack', '-w'], { + platform: 'win32', + execPath: 'C:\\node\\node.exe', + env: { + npm_execpath: 'C:\\npm\\bin\\npm-cli.js', + }, + existsSync: (p) => p === 'C:\\npm\\bin\\npm-cli.js', + }); + expect(inv.command).toBe('C:\\node\\node.exe'); + expect(inv.args).toStrictEqual(['C:\\npm\\bin\\npm-cli.js', 'pack', '-w']); + }); + + it('falls back to node-dir node_modules/npm when npm_execpath is unset', () => { + const inv = npmInvocation(['install'], { + platform: 'win32', + execPath: 'C:\\node\\node.exe', + env: {}, + existsSync: () => true, + }); + expect(inv.command).toBe('C:\\node\\node.exe'); + // The CLI path is /node_modules/npm/bin/npm-cli.js + expect(inv.args[0]).toMatch(/npm[\\/]bin[\\/]npm-cli\.js$/); + expect(inv.args[1]).toBe('install'); + }); + + it('prefers npm_execpath over the node-dir fallback', () => { + const fromExecPath = resolveNpmCliJs({ + execPath: 'C:\\node\\node.exe', + env: { npm_execpath: 'C:\\from-execpath\\npm-cli.js' }, + existsSync: (p) => p === 'C:\\from-execpath\\npm-cli.js', + }); + expect(fromExecPath).toBe('C:\\from-execpath\\npm-cli.js'); + }); + + it('never produces a shell string with spaces or metacharacters', () => { + const inv = npmInvocation(['pack'], { + platform: 'win32', + execPath: 'C:\\node\\node.exe', + env: { npm_execpath: 'C:\\npm\\bin\\npm-cli.js' }, + existsSync: (p) => p === 'C:\\npm\\bin\\npm-cli.js', + }); + expect(inv.command).toBe(inv.command.trim()); + // args preserve boundaries (no shell concatenation) + expect(inv.args.length).toBeGreaterThan(0); + }); +}); + +describe('npxInvocation', () => { + it('routes through npm exec on POSIX', () => { + const inv = npxInvocation(['--package', 'foo', '--', 'foo', '--version'], { + platform: 'darwin', + }); + expect(inv.command).toBe('npm'); + expect(inv.args).toStrictEqual([ + 'exec', + '--package', + 'foo', + '--', + 'foo', + '--version', + ]); + }); + + it('routes through npm exec on Windows (no npx.cmd)', () => { + const inv = npxInvocation(['--', 'llxprt', '--version'], { + platform: 'win32', + execPath: 'C:\\node\\node.exe', + env: { npm_execpath: 'C:\\npm\\bin\\npm-cli.js' }, + existsSync: (p) => p === 'C:\\npm\\bin\\npm-cli.js', + }); + expect(inv.command).toBe('C:\\node\\node.exe'); + expect(inv.args).toStrictEqual([ + 'C:\\npm\\bin\\npm-cli.js', + 'exec', + '--', + 'llxprt', + '--version', + ]); + }); +}); + +describe('resolveNpmCliJs existence verification', () => { + it('returns npm_execpath when it is a real .js path that exists', () => { + expect( + resolveNpmCliJs({ + env: { npm_execpath: '/path/to/npm-cli.js' }, + existsSync: (p) => p === '/path/to/npm-cli.js', + }), + ).toBe('/path/to/npm-cli.js'); + }); + + it('ignores npm_execpath when it is not a .js path (e.g. a .cmd wrapper) and falls back', () => { + // A .cmd npm_execpath must be ignored; the resolver falls through to the + // node-dir fallback instead of trusting a non-JS path. + const result = resolveNpmCliJs({ + env: { npm_execpath: 'C:\\npm\\bin\\npm.cmd' }, + existsSync: () => true, + }); + expect(result).toMatch(/node_modules[\\/]npm[\\/]bin[\\/]npm-cli\.js$/); + expect(result).not.toBe('C:\\npm\\bin\\npm.cmd'); + }); + + it('falls back to node-dir/npm when npm_execpath is unset and it exists', () => { + const result = resolveNpmCliJs({ + execPath: '/usr/local/bin/node', + env: {}, + existsSync: () => true, + }); + expect(result).toMatch(/node_modules[\\/]npm[\\/]bin[\\/]npm-cli\.js$/); + }); + + it('throws NpmCliNotFoundError when no candidate exists', () => { + expect(() => + resolveNpmCliJs({ + execPath: 'C:\\node\\node.exe', + env: {}, + existsSync: () => false, + }), + ).toThrow(NpmCliNotFoundError); + }); + + it('throws NpmCliNotFoundError when npm_execpath missing and fallback missing', () => { + let threw: Error | null = null; + try { + resolveNpmCliJs({ + env: { npm_execpath: 'C:\\missing\\npm-cli.js' }, + execPath: 'C:\\node\\node.exe', + existsSync: () => false, + }); + } catch (e) { + threw = e as Error; + } + expect(threw).not.toBeNull(); + expect(threw).toBeInstanceOf(NpmCliNotFoundError); + expect((threw as Error).message).toMatch( + /npm-cli\.js could not be resolved/, + ); + // The error must list BOTH probed paths so the failure is actionable. + expect((threw as Error).message).toContain('C:\\missing\\npm-cli.js'); + }); + + it('error includes the probed paths in details', () => { + try { + resolveNpmCliJs({ + env: {}, + execPath: 'C:\\node\\node.exe', + existsSync: () => false, + }); + } catch (e) { + const err = e as Error & { details?: { probed: string[] } }; + expect(err.details).toBeDefined(); + // On POSIX hosts the path module produces a POSIX-joined fallback; on + // Windows it is backslash-joined. Assert the npm-cli.js suffix only. + expect(err.details?.probed).toHaveLength(1); + expect(err.details?.probed[0]).toMatch( + /node_modules[\\/]npm[\\/]bin[\\/]npm-cli\.js$/, + ); + } + }); + + it('falls back when npm_execpath is set but missing, if the fallback exists', () => { + // Compute the fallback path the same way the resolver does so the test is + // correct on both POSIX and Windows hosts (path.join is platform-specific). + const path = require('node:path') as typeof import('node:path'); + const fallback = path.join( + path.dirname('C:\\node\\node.exe'), + 'node_modules', + 'npm', + 'bin', + 'npm-cli.js', + ); + const result = resolveNpmCliJs({ + env: { npm_execpath: 'C:\\npm\\bin\\npm-cli.js' }, + execPath: 'C:\\node\\node.exe', + existsSync: (p) => p === fallback, + }); + expect(result).toBe(fallback); + }); +}); diff --git a/scripts/tests/postinstall-manager-aware.test.ts b/scripts/tests/postinstall-manager-aware.test.ts index 570612d06b..dfc1278a49 100644 --- a/scripts/tests/postinstall-manager-aware.test.ts +++ b/scripts/tests/postinstall-manager-aware.test.ts @@ -451,3 +451,41 @@ describe.skipIf(isWindows)('postinstall Bun workspace symlinking', () => { ).toBe(true); }); }); + +describe('installWindowsNativeLaunchers error handling (nonfatal contract)', () => { + it('wraps the installer require and invocation in try/catch (nonfatal)', () => { + // On macOS, installWindowsNativeLaunchers is platform-gated and never + // runs. The contract is that an unexpected require failure or installer + // exception is caught and logged as a warning, never aborting postinstall. + // Verify the source has the protective try/catch around the require+invoke. + const source = readFileSync(realPostinstall, 'utf8'); + const fnStart = source.indexOf('function installWindowsNativeLaunchers'); + expect(fnStart).toBeGreaterThan(-1); + const fnEnd = source.indexOf('\n}', fnStart) + 2; + const fnBody = source.slice(fnStart, fnEnd); + expect(fnBody).toContain('try {'); + expect(fnBody).toContain('require(cliInstaller)'); + expect(fnBody).toContain('installNativeLaunchers'); + expect(fnBody).toContain('} catch'); + expect(fnBody).toMatch(/non-fatal/i); + // Must NOT contain process.exit inside the try/catch. + const tryStart = fnBody.indexOf('try {'); + const catchStart = fnBody.indexOf('} catch'); + const tryBlock = fnBody.slice(tryStart, catchStart); + expect(tryBlock).not.toContain('process.exit'); + }); + + it('postinstall exits 0 even when install-native-launchers.cjs throws', () => { + // Simulate a broken installer module on win32 by creating a fixture whose + // install-native-launchers.cjs throws, then verify postinstall does not + // crash. We cannot change process.platform, but we CAN verify that the + // function's error path does not propagate by checking the source never + // calls process.exit(1) from installWindowsNativeLaunchers. + const source = readFileSync(realPostinstall, 'utf8'); + const fnStart = source.indexOf('function installWindowsNativeLaunchers'); + const fnEnd = source.indexOf('\n}', fnStart) + 2; + const fnBody = source.slice(fnStart, fnEnd); + // The function must never call process.exit — errors are logged and swallowed. + expect(fnBody).not.toMatch(/process\.exit\(\d+\)/); + }); +}); diff --git a/scripts/windows-installed-command-smoke.cjs b/scripts/windows-installed-command-smoke.cjs index b04def4f34..5f122f0db9 100644 --- a/scripts/windows-installed-command-smoke.cjs +++ b/scripts/windows-installed-command-smoke.cjs @@ -46,7 +46,8 @@ const releasePackHelperPath = join( ); const smokeDir = join(repoRoot, 'scripts', 'windows-installed-command-smoke'); -const { getState, resetState } = nodeRequire(join(smokeDir, 'assert.cjs')); +const assertModule = nodeRequire(join(smokeDir, 'assert.cjs')); +const { getState, resetState, assert, fail } = assertModule; const { findInstalledPackageRoot, findBundledBun } = nodeRequire( join(smokeDir, 'package-layout.cjs'), ); @@ -72,55 +73,66 @@ function safeCleanup(tempDir) { function runSmoke() { resetState(); let tempDir; - try { - const { packReleaseLikeCli } = nodeRequire(releasePackHelperPath); - const { replicaTarball } = packReleaseLikeCli(repoRoot); - - const { assert } = nodeRequire(join(smokeDir, 'assert.cjs')); - assert( - existsSync(replicaTarball), - `replica tarball not found: ${replicaTarball}`, - ); - process.stdout.write(`replica=${replicaTarball}\n`); - - tempDir = mkdtempSync(join(tmpdir(), 'llxprt-win-smoke-')); - const prefix = globalInstall(tempDir, replicaTarball); - - checks.checkLauncherSentinels(prefix); - checks.checkVersionRuns(prefix); - - const installedPackageRoot = findInstalledPackageRoot(prefix); - - const probeFixture = checks.buildProbeFixture( - installedPackageRoot, - tempDir, - 'main', - repoRoot, - ); - - checks.checkCmdArgFidelity(probeFixture); - checks.checkPwshArgFidelity(probeFixture); - checks.checkInjectionGuard(probeFixture, tempDir); - checks.checkStdioForwarding(probeFixture); - checks.checkCmdExitCodePreservation(probeFixture); - checks.checkPwshExitPropagation(probeFixture); - checks.checkExecPathIsBundledBun(probeFixture); - checks.checkProcessTreeNoNode(probeFixture); - - checks.checkMissingBun({ installedPackageRoot }, tempDir, repoRoot); - checks.checkCorruptBun({ installedPackageRoot }, tempDir, repoRoot); - - checks.checkNpmExecEphemeral(tempDir, replicaTarball); - - const consumerDir = localInstall(tempDir, replicaTarball); - checkLocalCmdVersion(consumerDir); - checkPackageLocalBun(prefix, findInstalledPackageRoot, findBundledBun); - } catch (err) { - const { fail } = nodeRequire(join(smokeDir, 'assert.cjs')); - fail(`unexpected error: ${err.stack || err.message}`); - } finally { - safeCleanup(tempDir); - } + let succeeded = false; + return (async () => { + try { + const { packReleaseLikeCli } = nodeRequire(releasePackHelperPath); + const { replicaTarball } = packReleaseLikeCli(repoRoot); + + assert( + existsSync(replicaTarball), + `replica tarball not found: ${replicaTarball}`, + ); + process.stdout.write(`replica=${replicaTarball}\n`); + + tempDir = mkdtempSync(join(tmpdir(), 'llxprt-win-smoke-')); + const prefix = globalInstall(tempDir, replicaTarball); + + checks.checkLauncherSentinels(prefix); + checks.checkVersionRuns(prefix); + + const installedPackageRoot = findInstalledPackageRoot(prefix); + + const probeFixture = checks.buildProbeFixture( + installedPackageRoot, + tempDir, + 'main', + repoRoot, + ); + + checks.checkCmdArgFidelity(probeFixture); + checks.checkPwshArgFidelity(probeFixture); + checks.checkInjectionGuard(probeFixture, tempDir); + checks.checkStdioForwarding(probeFixture); + checks.checkCmdExitCodePreservation(probeFixture); + checks.checkPwshExitPropagation(probeFixture); + checks.checkExecPathIsBundledBun(probeFixture); + await checks.checkProcessTreeNoNode(probeFixture); + + checks.checkMissingBun({ installedPackageRoot }, tempDir, repoRoot); + checks.checkCorruptBun({ installedPackageRoot }, tempDir, repoRoot); + + checks.checkNpmExecEphemeral(tempDir, replicaTarball); + + const consumerDir = localInstall(tempDir, replicaTarball); + checkLocalCmdVersion(consumerDir); + checkPackageLocalBun(prefix, findInstalledPackageRoot, findBundledBun); + succeeded = true; + } catch (err) { + fail(`unexpected error: ${err.stack || err.message}`); + } finally { + // Preserve the temp fixture on failure for debugging (print its path so + // CI logs show where to inspect). Clean up on success to avoid CI disk + // pressure. + if (succeeded) { + safeCleanup(tempDir); + } else if (tempDir) { + console.error( + `\nTemp fixture preserved for debugging at:\n ${tempDir}\n`, + ); + } + } + })(); } function reportAndExit() { @@ -140,5 +152,11 @@ if (!isWindows) { process.exit(0); } -runSmoke(); -reportAndExit(); +runSmoke() + .then(() => { + reportAndExit(); + }) + .catch((err) => { + fail(`runSmoke rejected unexpectedly: ${err.stack || err.message}`); + reportAndExit(); + }); diff --git a/scripts/windows-installed-command-smoke/assert.cjs b/scripts/windows-installed-command-smoke/assert.cjs index dd2ab138b1..5ae4fb4a87 100644 --- a/scripts/windows-installed-command-smoke/assert.cjs +++ b/scripts/windows-installed-command-smoke/assert.cjs @@ -23,10 +23,25 @@ function assert(condition, msg) { function runStep(label, fn) { process.stdout.write(`[${label}] starting...\n`); try { - fn(); + const result = fn(); + if (result && typeof result.then === 'function') { + // Async step: return the promise so the caller can await it. On success + // print OK; on rejection, accumulate the failure (do not re-throw so + // parallel steps do not unhandled-reject). + return result.then( + () => { + process.stdout.write(`[${label}] OK\n`); + }, + (err) => { + fail(`${label}: ${err.message}`); + }, + ); + } process.stdout.write(`[${label}] OK\n`); + return undefined; } catch (err) { fail(`${label}: ${err.message}`); + return undefined; } } diff --git a/scripts/windows-installed-command-smoke/checks.cjs b/scripts/windows-installed-command-smoke/checks.cjs index 68788f6613..a97e5b9932 100644 --- a/scripts/windows-installed-command-smoke/checks.cjs +++ b/scripts/windows-installed-command-smoke/checks.cjs @@ -32,10 +32,12 @@ const { } = require('./launcher-invocation.cjs'); const { findBundledBun, samePath, copyTree } = require('./package-layout.cjs'); const { - sleepMs, inspectProcessTreeSync, + waitForReady, + killProcessTree, spawn, } = require('./process-helpers.cjs'); +const { npmInvocation } = require('../lib/npm-command.cjs'); function buildProbeFixture(installedPackageRoot, tempBase, label, repoRoot) { const fixtureDir = join(tempBase, `probe-fixture-${label}`); @@ -219,26 +221,48 @@ function checkPwshArgFidelity(fixture) { parsed.marker === marker, `marker ${JSON.stringify(marker)} round-trip mismatch: got ${JSON.stringify(parsed.marker)}`, ); + assert( + typeof payload.bunVersion === 'string' && payload.bunVersion.length > 0, + `marker ${JSON.stringify(marker)}: did not run under Bun (bunVersion missing)`, + ); } }); } function checkInjectionGuard(fixture, tempDir) { - runStep('injection-guard', () => { + runStep('cmd-injection-guard', () => { const cmdPath = join(fixture.fixtureDir, 'llxprt.cmd'); const injectionFile = join(tempDir, 'injected-sentinel.txt'); const r = invokeCmd(cmdPath, [probeArg({ injectionPath: injectionFile })]); if (r.status !== 0) { - throw new Error(`injection probe exited ${r.status}: ${r.stderr}`); + throw new Error(`cmd injection probe exited ${r.status}: ${r.stderr}`); + } + const payload = parseProbeOutput(r.stdout); + assert( + payload.injectionCreated === false, + `cmd injection sentinel was created at ${injectionFile} — launcher leaked shell metacharacters`, + ); + assert( + !existsSync(injectionFile), + `cmd injection sentinel file exists at ${injectionFile}`, + ); + }); + + runStep('pwsh-injection-guard', () => { + const ps1Path = join(fixture.fixtureDir, 'llxprt.ps1'); + const injectionFile = join(tempDir, 'injected-sentinel-ps1.txt'); + const r = invokePwsh(ps1Path, [probeArg({ injectionPath: injectionFile })]); + if (r.status !== 0) { + throw new Error(`ps1 injection probe exited ${r.status}: ${r.stderr}`); } const payload = parseProbeOutput(r.stdout); assert( payload.injectionCreated === false, - `injection sentinel was created at ${injectionFile} — launcher leaked shell metacharacters`, + `ps1 injection sentinel was created at ${injectionFile} — launcher leaked shell metacharacters`, ); assert( !existsSync(injectionFile), - `injection sentinel file exists at ${injectionFile}`, + `ps1 injection sentinel file exists at ${injectionFile}`, ); }); } @@ -312,73 +336,105 @@ function checkPwshExitPropagation(fixture) { } function checkExecPathIsBundledBun(fixture) { - runStep('execpath-is-bundled-bun', () => { + runStep('cmd-execpath-is-bundled-bun', () => { const cmdPath = join(fixture.fixtureDir, 'llxprt.cmd'); const r = invokeCmd(cmdPath, [probeArg({})]); if (r.status !== 0) { - throw new Error(`execpath probe exited ${r.status}: ${r.stderr}`); + throw new Error(`cmd execpath probe exited ${r.status}: ${r.stderr}`); } const payload = parseProbeOutput(r.stdout); const expectedBun = findBundledBun(fixture.fixturePkgRoot); assert( samePath(payload.execPath, expectedBun), - `execPath ${payload.execPath} is not the package-local bundled bun.exe (${expectedBun})`, + `cmd execPath ${payload.execPath} is not the package-local bundled bun.exe (${expectedBun})`, + ); + }); + + runStep('pwsh-execpath-is-bundled-bun', () => { + const ps1Path = join(fixture.fixtureDir, 'llxprt.ps1'); + const r = invokePwsh(ps1Path, [probeArg({})]); + if (r.status !== 0) { + throw new Error(`ps1 execpath probe exited ${r.status}: ${r.stderr}`); + } + const payload = parseProbeOutput(r.stdout); + const expectedBun = findBundledBun(fixture.fixturePkgRoot); + assert( + samePath(payload.execPath, expectedBun), + `ps1 execPath ${payload.execPath} is not the package-local bundled bun.exe (${expectedBun})`, ); }); } function checkProcessTreeNoNode(fixture) { - runStep('process-tree-bun-present-node-absent', () => { - const cmdPath = join(fixture.fixtureDir, 'llxprt.cmd'); - const child = spawn('cmd', ['/c', cmdPath, probeArg({ long: true })], { - stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env, PATH: CONSTRAINED_PATH }, - windowsHide: true, - }); - let stdout = ''; - let exitedEarly = false; - child.stdout.on('data', (c) => { - stdout += c.toString(); - }); - child.on('exit', () => { - exitedEarly = true; - }); + // CMD variant + const cmdPromise = runStep( + 'cmd-process-tree-bun-present-node-absent', + async () => { + const cmdPath = join(fixture.fixtureDir, 'llxprt.cmd'); + const child = spawn('cmd', ['/c', cmdPath, probeArg({ long: true })], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, PATH: CONSTRAINED_PATH }, + windowsHide: true, + }); + try { + // Async readiness polling yields to the event loop so stdout event + // handlers fire between checks. + await waitForReady(child, '__LLXPRT_PROBE_LONG_RUNNING__', 12_000); + const treeInfo = inspectProcessTreeSync(child.pid); + assert( + treeInfo.bunPresent, + `cmd bun.exe not found in launcher child tree. descendants=${JSON.stringify(treeInfo.descendants)}`, + ); + assert( + !treeInfo.nodePresent, + `cmd node.exe found in launcher child tree (must be absent). descendants=${JSON.stringify(treeInfo.descendants)}`, + ); + } finally { + // Terminate the entire process tree (taskkill /T /F on Windows), not + // just the direct child, so bun.exe descendants are reaped. + killProcessTree(child); + } + }, + ); - const readyDeadline = Date.now() + 12_000; - while ( - Date.now() < readyDeadline && - !stdout.includes('__LLXPRT_PROBE_LONG_RUNNING__') && - !exitedEarly - ) { - sleepMs(100); - } - if (exitedEarly) { - throw new Error( - `launcher child exited before tree inspection (stdout=${JSON.stringify(stdout)})`, + // PowerShell variant + const pwshPromise = runStep( + 'pwsh-process-tree-bun-present-node-absent', + async () => { + const ps1Path = join(fixture.fixtureDir, 'llxprt.ps1'); + const child = spawn( + 'powershell', + [ + '-NoProfile', + '-NonInteractive', + '-File', + ps1Path, + probeArg({ long: true }), + ], + { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, PATH: CONSTRAINED_PATH }, + windowsHide: true, + }, ); - } - if (!stdout.includes('__LLXPRT_PROBE_LONG_RUNNING__')) { - throw new Error( - `probe did not report long-running within timeout (stdout=${JSON.stringify(stdout)})`, - ); - } - - const treeInfo = inspectProcessTreeSync(child.pid); - try { - child.kill(); - } catch { - // best effort - } + try { + await waitForReady(child, '__LLXPRT_PROBE_LONG_RUNNING__', 12_000); + const treeInfo = inspectProcessTreeSync(child.pid); + assert( + treeInfo.bunPresent, + `ps1 bun.exe not found in launcher child tree. descendants=${JSON.stringify(treeInfo.descendants)}`, + ); + assert( + !treeInfo.nodePresent, + `ps1 node.exe found in launcher child tree (must be absent). descendants=${JSON.stringify(treeInfo.descendants)}`, + ); + } finally { + killProcessTree(child); + } + }, + ); - assert( - treeInfo.bunPresent, - `bun.exe not found in launcher child tree. descendants=${JSON.stringify(treeInfo.descendants)}`, - ); - assert( - !treeInfo.nodePresent, - `node.exe found in launcher child tree (must be absent). descendants=${JSON.stringify(treeInfo.descendants)}`, - ); - }); + return Promise.all([cmdPromise, pwshPromise]); } function checkMissingBun(fixtureBase, tempDir, repoRoot) { @@ -488,20 +544,27 @@ function checkNpmExecEphemeral(tempDir, replicaTarball) { JSON.stringify({ name: 'clean-consumer', version: '0.0.0' }, null, 2), ); const npmCache = join(tempDir, 'npm-exec-cache'); - const r = spawnSync( - 'npm', - ['exec', '--package', replicaTarball, '--', 'llxprt', '--version'], - { - cwd: cleanDir, - encoding: 'utf8', - timeout: 300_000, - maxBuffer: 64 * 1024 * 1024, - env: { ...process.env, npm_config_cache: npmCache }, - }, - ); + const { command, args } = npmInvocation([ + 'exec', + '--package', + replicaTarball, + '--', + 'llxprt', + '--version', + ]); + const r = spawnSync(command, args, { + cwd: cleanDir, + encoding: 'utf8', + timeout: 300_000, + maxBuffer: 64 * 1024 * 1024, + env: { ...process.env, npm_config_cache: npmCache }, + }); + if (r.error) { + throw new Error(`npm exec spawn failed: ${r.error.message}`); + } if (r.status !== 0) { throw new Error( - `npm exec --version exited ${r.status}: ${r.stderr || r.stdout}`, + `npm exec --version failed (exit ${r.status}, signal=${r.signal ?? 'none'}): ${r.stderr || r.stdout}`, ); } assert( diff --git a/scripts/windows-installed-command-smoke/install-helpers.cjs b/scripts/windows-installed-command-smoke/install-helpers.cjs index f931fa0609..ce7e848988 100644 --- a/scripts/windows-installed-command-smoke/install-helpers.cjs +++ b/scripts/windows-installed-command-smoke/install-helpers.cjs @@ -12,33 +12,36 @@ const { join } = require('node:path'); const { assert, runStep } = require('./assert.cjs'); const { CONSTRAINED_PATH } = require('./constants.cjs'); +const { npmInvocation } = require('../lib/npm-command.cjs'); function globalInstall(tempDir, replicaTarball) { let prefix; runStep('global-install', () => { prefix = join(tempDir, 'global-prefix'); mkdirSync(prefix, { recursive: true }); - const r = spawnSync( - 'npm', - [ - 'install', - '--global', - '--prefix', - prefix, - '--cache', - join(tempDir, 'npm-cache'), - '--loglevel', - 'error', - replicaTarball, - ], - { - encoding: 'utf8', - timeout: 180_000, - maxBuffer: 64 * 1024 * 1024, - }, - ); + const { command, args } = npmInvocation([ + 'install', + '--global', + '--prefix', + prefix, + '--cache', + join(tempDir, 'npm-cache'), + '--loglevel', + 'error', + replicaTarball, + ]); + const r = spawnSync(command, args, { + encoding: 'utf8', + timeout: 180_000, + maxBuffer: 64 * 1024 * 1024, + }); + if (r.error) { + throw new Error(`npm global install spawn failed: ${r.error.message}`); + } if (r.status !== 0) { - throw new Error(`npm global install failed: ${r.stderr || r.stdout}`); + throw new Error( + `npm global install failed (exit ${r.status}, signal=${r.signal ?? 'none'}): ${r.stderr || r.stdout}`, + ); } }); return prefix; @@ -53,25 +56,27 @@ function localInstall(tempDir, replicaTarball) { join(consumerDir, 'package.json'), JSON.stringify({ name: 'consumer', version: '0.0.0' }, null, 2), ); - const r = spawnSync( - 'npm', - [ - 'install', - '--cache', - join(tempDir, 'npm-cache-local'), - '--loglevel', - 'error', - replicaTarball, - ], - { - cwd: consumerDir, - encoding: 'utf8', - timeout: 180_000, - maxBuffer: 64 * 1024 * 1024, - }, - ); + const { command, args } = npmInvocation([ + 'install', + '--cache', + join(tempDir, 'npm-cache-local'), + '--loglevel', + 'error', + replicaTarball, + ]); + const r = spawnSync(command, args, { + cwd: consumerDir, + encoding: 'utf8', + timeout: 180_000, + maxBuffer: 64 * 1024 * 1024, + }); + if (r.error) { + throw new Error(`npm local install spawn failed: ${r.error.message}`); + } if (r.status !== 0) { - throw new Error(`npm local install failed: ${r.stderr || r.stdout}`); + throw new Error( + `npm local install failed (exit ${r.status}, signal=${r.signal ?? 'none'}): ${r.stderr || r.stdout}`, + ); } }); return consumerDir; diff --git a/scripts/windows-installed-command-smoke/launcher-invocation.cjs b/scripts/windows-installed-command-smoke/launcher-invocation.cjs index 0a99b430a0..607d91db7b 100644 --- a/scripts/windows-installed-command-smoke/launcher-invocation.cjs +++ b/scripts/windows-installed-command-smoke/launcher-invocation.cjs @@ -3,6 +3,27 @@ /** * Launcher invocation helpers: spawn cmd/PowerShell launchers with the * constrained PATH, parse probe JSON output, and quote arguments safely. + * + * cmd quoting: + * Node's spawnSync('cmd', ['/c', cmdPath, ...args]) passes each argv + * element as a separate argument to CreateProcess, which cmd.exe then + * re-joins into a single command line. However, cmd.exe parses the joined + * line for metacharacters (&, |, <, >, ^, %, !, (, )) even within the + * arguments. To safely test .cmd arguments with metacharacters, we wrap + * each argument in double quotes and escape internal double quotes by + * doubling them (cmd's own quoting rule). This is NOT CodeRabbit's + * suggestion of backslash-escaping (that is POSIX shell escaping, not cmd). + * + * Percent signs (%) are a special case: inside a batch file, %VAR% expands + * environment/delayed variables; %1..%9 expand positional parameters. The + * launcher under test forwards args via %*, not %1..%9, so positional + * expansion does not apply to forwarded args. However cmd.exe does still + * parse %X patterns for variables when the argument reaches a batch + * context. To ensure a literal percent survives cmd.exe's parser verbatim, + * it is doubled (%%) — the standard cmd idiom for a literal percent inside + * a batch file. Delayed expansion (!VAR!) is off by default in batch files + * unless `setlocal enabledelayedexpansion` is used; the generated launcher + * does not enable it, so ! is not expanded. We double % and leave ! as-is. */ const { spawnSync } = require('node:child_process'); @@ -12,6 +33,11 @@ function probeArg(request) { return 'LLXPRT_PROBE=' + JSON.stringify(request); } +/** + * Parses the probe JSON payload from the launcher's stdout. Wraps JSON.parse + * in a try/catch and re-throws with the raw stdout context so test failures + * show the actual malformed content instead of an opaque SyntaxError. + */ function parseProbeOutput(stdout) { const start = stdout.indexOf('{'); const end = stdout.lastIndexOf('}'); @@ -20,23 +46,79 @@ function parseProbeOutput(stdout) { `no JSON object in probe output: ${JSON.stringify(stdout)}`, ); } - return JSON.parse(stdout.slice(start, end + 1)); + const jsonText = stdout.slice(start, end + 1); + try { + return JSON.parse(jsonText); + } catch (e) { + throw new Error( + `failed to parse probe JSON: ${e.message}\njsonText=${JSON.stringify(jsonText)}\nfullStdout=${JSON.stringify(stdout)}`, + ); + } +} + +/** + * Validates a spawnSync result, throwing on a spawn failure (r.error) or a + * signal-based termination (r.signal). A nonzero exit status (r.status) is + * NOT treated as a spawn failure — it is a legitimate child exit code that + * the caller is responsible for interpreting. This keeps spawn failures + * (cmd.exe missing, ENOENT) distinct from child exit codes so a launch + * problem is never silently normalized as a child status. + * + * @param {string} label - human-readable label for the error message. + * @param {import('node:child_process').SpawnSyncReturns} r - spawnSync result. + * @returns {import('node:child_process').SpawnSyncReturns} the validated result. + * @throws {Error} when r.error is set or r.signal is non-null. + */ +function validateSpawnResult(label, r) { + if (r.error) { + throw new Error(`${label}: spawn failed: ${r.error.message}`); + } + if (r.signal) { + throw new Error(`${label}: terminated by signal ${r.signal}`); + } + return r; +} + +/** + * Quotes a single argument for cmd.exe /c invocation. Wraps the value in + * double quotes and doubles internal double quotes (cmd's quoting rule). + * Doubles percent signs (%%) so a literal % survives cmd.exe's batch parser + * (inside a batch file, %VAR% expands variables and %% is a literal %). + * Delayed expansion (!VAR!) is off by default in batch files; the generated + * launcher does not enable it, so ! is left as-is. + */ +function cmdQuote(s) { + let escaped = String(s).replace(/"/g, '""'); + escaped = escaped.replace(/%/g, '%%'); + return `"${escaped}"`; +} + +/** + * Quotes a single argument for PowerShell -Command invocation. Uses single + * quotes (PowerShell's literal string) and doubles internal single quotes. + */ +function pwshQuote(s) { + if (/^[\w./:=@-]+$/.test(s)) return s; + return "'" + String(s).replace(/'/g, "''") + "'"; } function invokeCmd(cmdPath, args, opts) { - const r = spawnSync('cmd', ['/c', cmdPath, ...args], { + // Build the full command line: cmdPath followed by quoted args. + // cmd.exe /c receives each as a separate argv element (CreateProcess + // boundaries are preserved — no shell string concatenation on our side). + // The cmdQuote wrapping protects against cmd.exe's own re-parsing. + const quotedArgs = args.map((a) => cmdQuote(a)); + const r = spawnSync('cmd', ['/c', cmdPath, ...quotedArgs], { encoding: 'utf8', timeout: opts?.timeout ?? 30_000, input: opts?.input, env: { ...process.env, PATH: CONSTRAINED_PATH, ...(opts?.env || {}) }, windowsHide: true, }); - return r; -} - -function pwshQuote(s) { - if (/^[\w./:=@-]+$/.test(s)) return s; - return "'" + s.replace(/'/g, "''") + "'"; + // A spawn failure (cmd.exe not found, etc.) is a harness error, not a + // child exit code. validateSpawnResult throws so callers never confuse a + // launch problem with a legitimate nonzero status. + return validateSpawnResult(`invokeCmd(${cmdPath})`, r); } function invokePwsh(ps1Path, args, opts) { @@ -57,7 +139,7 @@ function invokePwsh(ps1Path, args, opts) { windowsHide: true, }, ); - return r; + return validateSpawnResult(`invokePwsh(${ps1Path})`, r); } module.exports = { @@ -65,5 +147,7 @@ module.exports = { parseProbeOutput, invokeCmd, invokePwsh, + cmdQuote, pwshQuote, + validateSpawnResult, }; diff --git a/scripts/windows-installed-command-smoke/process-helpers.cjs b/scripts/windows-installed-command-smoke/process-helpers.cjs index 8280ea7a2a..5c913b3fba 100644 --- a/scripts/windows-installed-command-smoke/process-helpers.cjs +++ b/scripts/windows-installed-command-smoke/process-helpers.cjs @@ -2,61 +2,185 @@ /** * Process-tree inspection and cross-platform sleep helpers for the Windows - * smoke. These use PowerShell/CIM to enumerate descendants and a synchronous - * Node delay primitive (Atomics.wait) that works on Windows without busy - * spinning. + * smoke. These use PowerShell/CIM to enumerate descendants via a visited-set + * BFS that does not silently truncate. */ const { spawnSync, spawn } = require('node:child_process'); /** - * Synchronous sleep that works cross-platform. Prefers Atomics.wait (no extra - * process, no busy spin). Falls back to PowerShell Start-Sleep, checking the - * result so a spawn error does not silently produce a zero-length sleep. + * Validates that a value is a positive integer PID suitable for interpolation + * into a PowerShell command. Prevents command injection if a PID is ever + * derived from an unexpected source. + * + * @param {unknown} pid + * @returns {asserts pid is number} */ -function sleepMs(ms) { - try { - const sab = new SharedArrayBuffer(4); - const i32 = new Int32Array(sab); - Atomics.wait(i32, 0, 0, ms); - } catch { - const r = spawnSync( - 'powershell', - ['-NoProfile', '-Command', `Start-Sleep -Milliseconds ${ms}`], - { +function assertValidPid(pid) { + if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) { + throw new Error(`Invalid PID: ${JSON.stringify(pid)}`); + } +} + +/** + * Async readiness poll that yields to the event loop between checks so stdout + * event handlers fire between checks. + * + * Resolves with the accumulated stdout when the ready marker appears, or + * rejects if the child exits early or the deadline passes. + * + * @param {import('node:child_process').ChildProcess} child + * @param {string} readyMarker + * @param {number} timeoutMs + * @returns {Promise} + */ +function waitForReady(child, readyMarker, timeoutMs) { + return new Promise((resolve, reject) => { + let stdout = ''; + let settled = false; + + function done(ok, value) { + if (settled) return; + settled = true; + cleanup(); + if (ok) { + resolve(value); + } else { + reject(new Error(value)); + } + } + + function onStdout(chunk) { + stdout += chunk.toString(); + if (stdout.includes(readyMarker)) { + done(true, stdout); + } + } + + function onExit(code, signal) { + done( + false, + `launcher child exited before tree inspection (code=${code}, signal=${signal}, stdout=${JSON.stringify(stdout)})`, + ); + } + + function onError(err) { + done(false, `launcher child error: ${err.message}`); + } + + function onTimeout() { + done( + false, + `probe did not report ready within ${timeoutMs}ms (stdout=${JSON.stringify(stdout)})`, + ); + } + + const timer = setTimeout(onTimeout, timeoutMs); + const out = child.stdout; + if (out) { + out.on('data', onStdout); + } + child.on('exit', onExit); + child.on('error', onError); + + function cleanup() { + clearTimeout(timer); + if (out) { + out.removeListener('data', onStdout); + } + child.removeListener('exit', onExit); + child.removeListener('error', onError); + } + }); +} + +/** + * Terminates the entire process tree rooted at rootPid on Windows using + * `taskkill /T /F`. Falls back to child.kill() on non-Windows or if taskkill + * fails. The /T flag kills the entire descendant tree; /F forces termination. + * Checks the taskkill result and falls back to child.kill() if it failed. + * + * @param {import('node:child_process').ChildProcess} child + */ +function killProcessTree(child) { + if (!child || child.exitCode !== null || child.signalCode !== null) { + return; + } + if (process.platform === 'win32' && child.pid) { + try { + assertValidPid(child.pid); + const r = spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', - timeout: ms + 2_000, - }, - ); - if (r.error || r.status !== 0) { - const deadline = Date.now() + ms; - while (Date.now() < deadline) { - // spin + timeout: 10_000, + }); + if (r.error || r.status !== 0) { + // taskkill failed; fall back to child.kill. + child.kill('SIGKILL'); + } + } catch { + try { + child.kill('SIGKILL'); + } catch { + // best effort; child may have exited concurrently } } + return; + } + try { + child.kill('SIGKILL'); + } catch { + // best effort; child may have exited concurrently } } +/** + * Maximum number of BFS breadth levels to traverse before declaring an + * explicit failure. This bounds the traversal depth (not the descendant + * count) so a deep but narrow tree is still fully enumerated, while an + * unbounded cycle is rejected. Combined with the visited set, this prevents + * an infinite loop without silently truncating realistic trees. + */ +const MAX_LEVELS = 200; + /** * Synchronously inspects the process tree under rootPid via PowerShell/CIM. - * Throws on spawn error or non-zero PowerShell exit so a failure is visible, - * rather than returning an empty tree silently (which would cause misleading - * assertion failures). + * Uses a visited-set BFS that continues until no new descendants are found or + * the safe maximum is reached — no silent depth-limited false-negatives. + * + * @param {number} rootPid - the root process PID to inspect. + * @returns {{ bunPresent: boolean, nodePresent: boolean, descendants: Array<{pid: number, name: string}> }} + * @throws {Error} on invalid PID, spawn error, non-zero PowerShell exit, or + * exceeding the safety maximum, so a failure is visible rather than + * returning an empty tree. */ function inspectProcessTreeSync(rootPid) { - if (!rootPid) - return { bunPresent: false, nodePresent: false, descendants: [] }; + // An invalid PID is a caller bug (the harness derived it from an unexpected + // source). Throw via assertValidPid rather than silently returning an empty + // tree that would mask the failure. + assertValidPid(rootPid); + // PowerShell BFS using a visited set: enqueue children, track visited PIDs + // to avoid cycles, and continue until the queue is empty or the safety + // maximum level is exceeded. $count tracks breadth levels (depth), not the + // number of discovered descendants, so a deep narrow tree is fully walked. const script = [ `function Get-Descendants($root) {`, ` $result = @()`, + ` $visited = @{}`, ` $queue = @($root)`, - ` for ($i=0; $i -lt 4 -and $queue.Count -gt 0; $i++) {`, + ` $level = 0`, + ` while ($queue.Count -gt 0 -and $level -lt ${MAX_LEVELS}) {`, ` $next = @()`, ` foreach ($p in $queue) {`, + ` if ($visited.ContainsKey($p)) { continue }`, + ` $visited[$p] = $true`, ` $kids = Get-CimInstance Win32_Process -Filter "ParentProcessId=$($p)" -ErrorAction SilentlyContinue`, ` if ($kids) { $result += $kids; $next += $kids.ProcessId }`, ` }`, ` $queue = $next`, + ` $level++`, + ` }`, + ` if ($level -ge ${MAX_LEVELS}) {`, + ` throw "BFS level count exceeded safety maximum of ${MAX_LEVELS}"`, ` }`, ` return $result`, `}`, @@ -98,7 +222,10 @@ function inspectProcessTreeSync(rootPid) { } module.exports = { - sleepMs, + waitForReady, + killProcessTree, inspectProcessTreeSync, spawn, + assertValidPid, + MAX_LEVELS, }; From ada4060c4a7a869d26a90d943723c96bd72532f3 Mon Sep 17 00:00:00 2001 From: acoliver Date: Tue, 21 Jul 2026 13:53:41 -0300 Subject: [PATCH 04/18] Normalize Windows tar listings in launcher smoke --- scripts/tests/issue-2603-release-pack.cjs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/tests/issue-2603-release-pack.cjs b/scripts/tests/issue-2603-release-pack.cjs index 01b3150f9d..b4bd505ee0 100644 --- a/scripts/tests/issue-2603-release-pack.cjs +++ b/scripts/tests/issue-2603-release-pack.cjs @@ -345,7 +345,12 @@ function assertReleaseTarballAssets(releaseTarball) { `Failed to list release tarball (exit ${result.status}, signal=${result.signal ?? 'none'}): ${result.stderr || result.stdout}`, ); } - const files = new Set(result.stdout.split('\n')); + const files = new Set( + result.stdout + .split(/\r?\n/) + .map((entry) => entry.replace(/^\.\//, '').replace(/\\/g, '/')) + .filter(Boolean), + ); const required = [ 'package/bin/llxprt', 'package/scripts/install-native-launchers.cjs', @@ -357,7 +362,7 @@ function assertReleaseTarballAssets(releaseTarball) { const missing = required.filter((p) => !files.has(p)); if (missing.length > 0) { throw new Error( - `Release tarball missing required assets: ${missing.join(', ')}`, + `Release tarball missing required assets: ${missing.join(', ')}; listed entries: ${JSON.stringify([...files])}`, ); } } From 912dad199c802f1d847f18ec88d26fd523d89de8 Mon Sep 17 00:00:00 2001 From: acoliver Date: Tue, 21 Jul 2026 15:50:41 -0300 Subject: [PATCH 05/18] Harden Windows launcher verification --- .gitattributes | 5 + .../workflows/windows-installed-command.yml | 39 +- docs/troubleshooting.md | 16 +- packages/cli/bin/llxprt | 76 ++- .../cli/scripts/install-native-launchers.cjs | 85 ++- packages/test-utils/src/test-rig.ts | 9 +- scripts/lib/npm-command.cjs | 47 +- scripts/postinstall.cjs | 8 +- .../tests/issue-2603-install-layouts.test.ts | 27 +- ...-install-native-launchers-contract.test.ts | 5 +- scripts/tests/issue-2603-install.test.ts | 18 +- .../tests/issue-2603-launcher-signals.test.ts | 216 ++++--- ...sue-2603-launcher-source-workspace.test.ts | 42 +- scripts/tests/issue-2603-launcher.test.ts | 131 +++-- .../issue-2603-release-install-smoke.cjs | 31 +- .../tests/issue-2603-release-install.test.ts | 34 +- scripts/tests/issue-2603-release-pack.cjs | 7 +- .../issue-2603-shim-separator-tests.test.ts | 65 ++- .../tests/issue-2603-smoke-fail-fast.test.ts | 525 ++++++++++++++++++ .../tests/issue-2603-smoke-helpers.test.ts | 17 + .../tests/issue-2603-startup-benchmark.cjs | 236 ++++++-- scripts/tests/issue-2603-windows-probe.ts | 20 +- scripts/tests/npm-command.test.ts | 40 +- .../tests/postinstall-manager-aware.test.ts | 29 +- scripts/windows-installed-command-smoke.cjs | 160 +++++- .../assert.cjs | 110 +++- .../bun-validation.cjs | 183 ++++++ .../checks.cjs | 72 ++- .../constants.cjs | 59 +- .../install-helpers.cjs | 116 +++- .../launcher-invocation.cjs | 12 +- .../package-layout.cjs | 33 +- .../process-helpers.cjs | 15 +- .../pwsh-resolver.cjs | 107 ++++ 34 files changed, 2159 insertions(+), 436 deletions(-) create mode 100644 scripts/tests/issue-2603-smoke-fail-fast.test.ts create mode 100644 scripts/windows-installed-command-smoke/bun-validation.cjs create mode 100644 scripts/windows-installed-command-smoke/pwsh-resolver.cjs diff --git a/.gitattributes b/.gitattributes index b9c119d339..b376332652 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/windows-installed-command.yml b/.github/workflows/windows-installed-command.yml index addad64fc7..7cdf8df8b5 100644 --- a/.github/workflows/windows-installed-command.yml +++ b/.github/workflows/windows-installed-command.yml @@ -11,6 +11,7 @@ on: - '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' @@ -26,6 +27,7 @@ on: - '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' @@ -40,7 +42,11 @@ concurrency: jobs: windows-installed-command: runs-on: 'windows-latest' - timeout-minutes: 30 + # 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: @@ -59,13 +65,36 @@ jobs: uses: 'oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76' with: bun-version-file: '.bun-version' - no-cache: true + + # 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' - - name: 'Run Windows installed-command behavioral smoke' + # 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' - - name: 'Run startup benchmark (measurement only, no threshold)' - run: 'node scripts/tests/issue-2603-startup-benchmark.cjs . 15' + # 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@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882' + 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/docs/troubleshooting.md b/docs/troubleshooting.md index c4d5ec767d..b11be3e553 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -222,14 +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) | -| 43 | Launcher runtime failure | Bundled Bun runtime missing, corrupt, wrong architecture, or unusable — 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) | +| 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 diff --git a/packages/cli/bin/llxprt b/packages/cli/bin/llxprt index 5e632f0ebf..aabad82a36 100755 --- a/packages/cli/bin/llxprt +++ b/packages/cli/bin/llxprt @@ -81,6 +81,9 @@ _llxprt_bun_validates() { return 1 fi _llxprt_found_ver=$(sed -n 's/.*"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 @@ -115,35 +118,31 @@ _llxprt_find_enclosing_nm() { } # Verify a candidate repository root is the genuine llxprt-code workspace root -# for this package: its package.json must declare workspaces that include this -# package's directory (e.g. "packages/cli") OR carry this package's name. This -# prevents generic ancestor climbing — only a verified workspace root whose -# manifest references this package is accepted. Sets _llxprt_ws_root on success. +# 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 - # The package directory relative to the candidate root (e.g. packages/cli). - _llxprt_pkg_rel=$(dirname -- "$_llxprt_pkg_root") - _llxprt_pkg_leaf=$(basename -- "$_llxprt_pkg_root") - _llxprt_pkg_base=$(basename -- "$_llxprt_pkg_rel") - # Accept if the root workspaces array references this package directory - # (packages/cli) or the root package name matches this package's name. - if grep -q "\"workspaces\"" -- "$_llxprt_root_manifest" 2>/dev/null && \ - grep -q "\"$_llxprt_pkg_base/$_llxprt_pkg_leaf\"\|\"$_llxprt_pkg_leaf\"" -- "$_llxprt_root_manifest" 2>/dev/null; then - _llxprt_ws_root=$_llxprt_candidate_root - return 0 - fi - if [ -n "$_llxprt_pkg_name" ]; then - _llxprt_root_name=$(sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' -- "$_llxprt_root_manifest" 2>/dev/null | head -n1) - if [ "$_llxprt_root_name" = "$_llxprt_pkg_name" ]; then - _llxprt_ws_root=$_llxprt_candidate_root - return 0 - 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. + _llxprt_canonical_pkg=$_llxprt_candidate_root/packages/cli + if [ "$_llxprt_pkg_root" != "$_llxprt_canonical_pkg" ]; then + return 1 fi - return 1 + _llxprt_ws_root=$_llxprt_candidate_root + return 0 } # Resolve the bundled Bun runtime. Resolution is strictly bounded so an @@ -216,7 +215,32 @@ fi # 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. -_llxprt_magic=$(od -An -tx1 -N4 -- "$_llxprt_bun" 2>/dev/null | tr -d ' \n') + +# `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 @@ -227,7 +251,7 @@ case "$_llxprt_kernel" 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 or the wrong platform.' >&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 @@ -240,7 +264,7 @@ case "$_llxprt_kernel" 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 or the wrong platform.' >&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 @@ -253,7 +277,7 @@ case "$_llxprt_kernel" 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 or the wrong platform.' >&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 diff --git a/packages/cli/scripts/install-native-launchers.cjs b/packages/cli/scripts/install-native-launchers.cjs index e8b1f7d48e..b3646061b4 100644 --- a/packages/cli/scripts/install-native-launchers.cjs +++ b/packages/cli/scripts/install-native-launchers.cjs @@ -121,10 +121,29 @@ function canOverwriteLauncher(filePath, binLinkDir, packageRoot, shimType) { if (!fs.existsSync(filePath)) { return true; } - if (hasOwnershipSentinel(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. + const content = readFileSafe(filePath); + if (content.includes(OWNERSHIP_SENTINEL)) { return true; } - return pointsToOurPackage(filePath, binLinkDir, packageRoot, shimType); + if (!content) { + return false; + } + 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 relativePath(fromDir, toPath) { @@ -228,7 +247,20 @@ function writeOwnedLauncher( if (!canOverwriteLauncher(filePath, binLinkDir, packageRoot, shimType)) { return false; } - fs.writeFileSync(filePath, content, 'utf8'); + // 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) { @@ -423,26 +455,41 @@ function installNativeLaunchers(options) { 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, - generateCmdLauncher, - generatePs1Launcher, - hasOwnershipSentinel, - pointsToOurPackage, - canOverwriteLauncher, - findBinLinkDirs, - nearestNodeModulesBin, - resolveBunExe, - resolveEntry, - isWithinPackageRoot, - isWithinPackageRootWin, - extractCmdShimTargets, - extractPs1ShimTargets, - resolveShimCandidate, - relativePath, - relativePathPosix, 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, + findBinLinkDirs, + nearestNodeModulesBin, + resolveBunExe, + resolveEntry, + isWithinPackageRoot, + isWithinPackageRootWin, + extractCmdShimTargets, + extractPs1ShimTargets, + resolveShimCandidate, + relativePath, + relativePathPosix, + writeOwnedLauncher, + }, }; if (require.main === module) { diff --git a/packages/test-utils/src/test-rig.ts b/packages/test-utils/src/test-rig.ts index dd6e8f4a28..1c51f81c38 100644 --- a/packages/test-utils/src/test-rig.ts +++ b/packages/test-utils/src/test-rig.ts @@ -448,7 +448,14 @@ export class TestRig { env: childEnv, }; - const executable = command === 'bun' ? 'bun' : 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/lib/npm-command.cjs b/scripts/lib/npm-command.cjs index b8c20bb311..45e348b53a 100644 --- a/scripts/lib/npm-command.cjs +++ b/scripts/lib/npm-command.cjs @@ -85,9 +85,22 @@ function resolveNpmCliJs(options) { 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 path that exists — some environments set it to a .cmd wrapper. + // 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')) { - if (existsSyncOptional(options, env.npm_execpath)) { + const normalizedBase = env.npm_execpath + .replace(/\\/g, '/') + .split('/') + .pop(); + if ( + normalizedBase === 'npm-cli.js' && + existsSyncOptional(options, env.npm_execpath) + ) { return env.npm_execpath; } probed.push(env.npm_execpath); @@ -107,6 +120,36 @@ function resolveNpmCliJs(options) { 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 = []; + if (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 Node was installed via setup-node or an official installer that ships npm alongside node.exe.', diff --git a/scripts/postinstall.cjs b/scripts/postinstall.cjs index bad191dbc8..85c5435785 100644 --- a/scripts/postinstall.cjs +++ b/scripts/postinstall.cjs @@ -281,9 +281,15 @@ function installWindowsNativeLaunchers() { 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):', - error.message, + msg, ); } } diff --git a/scripts/tests/issue-2603-install-layouts.test.ts b/scripts/tests/issue-2603-install-layouts.test.ts index 0c33608367..efdcb628ab 100644 --- a/scripts/tests/issue-2603-install-layouts.test.ts +++ b/scripts/tests/issue-2603-install-layouts.test.ts @@ -57,9 +57,16 @@ function readCliManifest(): CliManifest { 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; function loadCliInstaller(): ReturnType { - return nodeRequire(cliModulePath); + 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)', () => { @@ -76,8 +83,8 @@ describe('pnpm virtual-store layout (consumer-visible .bin)', () => { '.pnpm', PNPM_PACKAGE_DIR, 'node_modules', - '@vybestack', - 'llxprt-code', + CLI_SCOPE, + CLI_NAME, ); const consumerDotBin = join(consumerRoot, 'node_modules', '.bin'); const virtualStoreNodeModules = join( @@ -391,12 +398,7 @@ describe('Bun install layout support boundary', () => { const mod = loadCliInstaller(); const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-bun-layout-')); try { - const packageRoot = join( - tempDir, - 'node_modules', - '@vybestack', - 'llxprt-code', - ); + const packageRoot = join(tempDir, 'node_modules', CLI_SCOPE, CLI_NAME); mkdirSync(join(packageRoot, 'node_modules', 'bun', 'bin'), { recursive: true, }); @@ -418,12 +420,7 @@ describe('Bun install layout support boundary', () => { const mod = loadCliInstaller(); const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-bun-initcwd-')); try { - const packageRoot = join( - tempDir, - 'node_modules', - '@vybestack', - 'llxprt-code', - ); + const packageRoot = join(tempDir, 'node_modules', CLI_SCOPE, CLI_NAME); mkdirSync(join(packageRoot, 'node_modules', 'bun', 'bin'), { recursive: 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 index 736ee05d2b..1f7ad821cb 100644 --- a/scripts/tests/issue-2603-install-native-launchers-contract.test.ts +++ b/scripts/tests/issue-2603-install-native-launchers-contract.test.ts @@ -23,7 +23,10 @@ const cliModulePath = join( ); function loadCliInstaller(): ReturnType { - return nodeRequire(cliModulePath); + 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', () => { diff --git a/scripts/tests/issue-2603-install.test.ts b/scripts/tests/issue-2603-install.test.ts index a5193f853d..5081f8e89a 100644 --- a/scripts/tests/issue-2603-install.test.ts +++ b/scripts/tests/issue-2603-install.test.ts @@ -39,7 +39,11 @@ function loadCliInstaller(): ReturnType { // aside from its exported functions, but deleting the cache entry prevents // any future module-level mutation from causing stale behavior. delete nodeRequire.cache[cliModulePath]; - return nodeRequire(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 }; } /** @@ -155,7 +159,9 @@ describe('CLI workspace tarball contents (actual release artifact)', () => { const tarball = packCliWorkspace(); expect(existsSync(tarball)).toBe(true); const { stdout } = spawnTarList(tarball); - const files = stdout.split('\n'); + // 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'); @@ -164,14 +170,14 @@ describe('CLI workspace tarball contents (actual release artifact)', () => { it('includes the installer script', () => { const tarball = packCliWorkspace(); const { stdout } = spawnTarList(tarball); - const files = stdout.split('\n'); + 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('\n'); + const files = stdout.split(/\r?\n/); expect(files.some((f) => f.endsWith('.cjs') && f.includes('bin/'))).toBe( false, ); @@ -431,6 +437,10 @@ describe('install-native-launchers module (CLI workspace)', () => { } }); + // 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-')); diff --git a/scripts/tests/issue-2603-launcher-signals.test.ts b/scripts/tests/issue-2603-launcher-signals.test.ts index bbabb8613b..771cab7813 100644 --- a/scripts/tests/issue-2603-launcher-signals.test.ts +++ b/scripts/tests/issue-2603-launcher-signals.test.ts @@ -25,6 +25,15 @@ 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; @@ -66,130 +75,99 @@ function makeLayout( return { pkgRoot, launcherTarget }; } -describe('POSIX launcher signal behavior', () => { - let tempDir: string; - - beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), 'llxprt-sig-')); - }); - - afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); - }); - - function makeLongRunning(tempDir: string): { - pkgRoot: string; - launcherTarget: string; - pidFile: string; - } { - const pidFile = join(tempDir, 'child-pid.txt'); - const { pkgRoot, launcherTarget } = makeLayout(tempDir, { - 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 }; - } +// 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; - it('SIGINT 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: '/usr/bin:/bin' }, + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'llxprt-sig-')); }); - let exited = false; - let exitSignal: NodeJS.Signals | null = null; - child.on('exit', (_code, signal) => { - exited = true; - exitSignal = signal; + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); }); - 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, 'SIGINT'); - } catch { - child.kill('SIGINT'); - } - } else { - child.kill('SIGINT'); - } - } - 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('SIGINT'); - resolve(); + 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'), }); - }); - }, 20_000); - - it('SIGTERM 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: '/usr/bin:/bin' }, - }); - - let exited = false; - let exitSignal: NodeJS.Signals | null = null; - child.on('exit', (_code, signal) => { - exited = true; - exitSignal = signal; - }); + 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); - 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, 'SIGTERM'); - } catch { - child.kill('SIGTERM'); + setTimeout(() => { + clearInterval(wait); + if (!exited) { + child.kill('SIGKILL'); } - } else { - child.kill('SIGTERM'); - } - } - 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('SIGTERM'); - resolve(); - }); - }); - }, 20_000); -}); + }, 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 index 4f41e4465c..4592f3d94d 100644 --- a/scripts/tests/issue-2603-launcher-source-workspace.test.ts +++ b/scripts/tests/issue-2603-launcher-source-workspace.test.ts @@ -31,6 +31,9 @@ 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 { @@ -50,15 +53,18 @@ function makeEntry(pkgRoot: string, code: string): void { function realBunVersion(): string { const bunPkgPath = join(repoRoot, 'node_modules', 'bun', 'package.json'); - if (existsSync(bunPkgPath)) { - try { - const bunPkg = JSON.parse(readFileSync(bunPkgPath, 'utf8')); - if (typeof bunPkg.version === 'string') return bunPkg.version; - } catch { - // keep default - } + // 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 = JSON.parse(readFileSync(bunPkgPath, 'utf8')); + if (typeof bunPkg.version === 'string' && bunPkg.version.length > 0) { + return bunPkg.version; } - return '1.3.14'; + throw new Error( + `Bun package.json at ${bunPkgPath} has no valid version field; ` + + 'the repo installation appears broken.', + ); } describe('POSIX launcher source-workspace resolution', () => { @@ -176,23 +182,27 @@ describe('POSIX launcher source-workspace resolution', () => { expect(result.status, result.stderr).toBe(0); }, 30_000); - it('rejects a source-workspace root whose manifest does not reference this package', () => { - // The package sits at /packages/cli but the root manifest's - // workspaces array does NOT include packages/cli and the root name does - // not match the package name. The launcher must NOT generic-climb and - // accept this unrelated root's Bun. + 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, [], { + const result = spawnSync(launcherTarget, ['--version'], { 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); + // 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)', () => { diff --git a/scripts/tests/issue-2603-launcher.test.ts b/scripts/tests/issue-2603-launcher.test.ts index 349d6e2b25..3199b724cc 100644 --- a/scripts/tests/issue-2603-launcher.test.ts +++ b/scripts/tests/issue-2603-launcher.test.ts @@ -23,8 +23,10 @@ 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, missing entry point). Centralized so a change to the launcher's - * failure code only requires updating one place. + * 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; @@ -41,6 +43,10 @@ function launcherMagicBlockAfter(marker: string): string { 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; @@ -54,20 +60,21 @@ function ensureBun(): string { /** * Returns the real Bun version from the repo's bun package.json so tests can - * write matching pins. Falls back to a sentinel if unavailable. Shared at - * module scope so multiple describe blocks reuse it without duplication. + * 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'); - if (existsSync(bunPkgPath)) { - try { - const bunPkg = JSON.parse(readFileSync(bunPkgPath, 'utf8')); - if (typeof bunPkg.version === 'string') return bunPkg.version; - } catch { - // keep default - } + const bunPkg = JSON.parse(readFileSync(bunPkgPath, 'utf8')); + if (typeof bunPkg.version === 'string' && bunPkg.version.length > 0) { + return bunPkg.version; } - return '1.3.14'; + 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 { @@ -500,31 +507,34 @@ describe('POSIX launcher execution behavior', () => { expect(launcherMagicBlockAfter('Linux and other ELF')).not.toMatch(/4d5a/); }); - it('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. - if (process.platform !== 'darwin') return; - 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: 15_000, - 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); + 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: 15_000, + 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, + ); it('magic case-statement accepts the correct native format per platform', () => { // Unit-level contract: accepted magics must appear in the correct @@ -800,26 +810,29 @@ describe('POSIX launcher version-pin and platform validation', () => { expect(result.stderr).toMatch(/bundled Bun runtime was not found/i); }, 15_000); - it('rejects an ELF Bun on Darwin (platform-gated format)', () => { - if (process.platform !== 'darwin') return; - 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: 15_000, - 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); + 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: 15_000, + 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 index a1d6a85d44..15d5c070e8 100644 --- a/scripts/tests/issue-2603-release-install-smoke.cjs +++ b/scripts/tests/issue-2603-release-install-smoke.cjs @@ -39,14 +39,21 @@ const { npmInvocation } = require('../lib/npm-command.cjs'); /** * Platform-aware PATH that proves the launcher needs NO global Bun or Node. - * On POSIX, only /usr/bin and /bin are present. On Windows, System32 is - * included so cmd.exe remains reachable for the .cmd wrapper (the launcher - * invokes bun.exe directly, not via cmd), but no global Bun/Node paths. + * 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() { - return process.platform === 'win32' - ? 'C:\\Windows\\System32;C:\\Windows;C:\\Windows\\System32\\Wbem' - : '/usr/bin:/bin'; + 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'; } /** @@ -86,6 +93,18 @@ function resolveBinInvocation(binPath) { } 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 nodeRequire = createRequire(__filename); const releasePackHelperPath = join( repoRoot, diff --git a/scripts/tests/issue-2603-release-install.test.ts b/scripts/tests/issue-2603-release-install.test.ts index dbb0f0fe4c..1c2795aad2 100644 --- a/scripts/tests/issue-2603-release-install.test.ts +++ b/scripts/tests/issue-2603-release-install.test.ts @@ -150,6 +150,26 @@ function runSmokeAsync(): SmokeHandle { }; }); + // 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; @@ -159,18 +179,18 @@ function runSmokeAsync(): SmokeHandle { } // Kill the entire process tree so grandchildren (npm, tar, bun spawned // inside the smoke script) are reaped and do not leak as orphans. - // On POSIX: kill the process group (negative PID). On Windows: taskkill - // /T /F kills the entire descendant tree. Falls back to child.kill(). - if (child && child.exitCode === null && child.signalCode === null) { + // 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' && child.pid) { + if (process.platform === 'win32') { spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', timeout: 10_000, }); - } else if (child.pid) { - // Negative PID kills the entire process group. - process.kill(-child.pid, 'SIGKILL'); + } else { + killPosixGroup(child); } } catch { try { diff --git a/scripts/tests/issue-2603-release-pack.cjs b/scripts/tests/issue-2603-release-pack.cjs index b4bd505ee0..e832e70c7c 100644 --- a/scripts/tests/issue-2603-release-pack.cjs +++ b/scripts/tests/issue-2603-release-pack.cjs @@ -48,7 +48,7 @@ function readCliManifest(repoRoot) { const version = cliPkg.version || '0.0.0'; const name = cliPkg.name || '@vybestack/llxprt-code'; const tarballName = `${name.replace(/^@/, '').replace(/\//g, '-')}-${version}.tgz`; - return { version, tarballName }; + return { name, version, tarballName }; } /** @@ -436,10 +436,13 @@ function rewriteOnePkgDeps(pkgPath, tarballMap) { } 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', - '@vybestack/llxprt-code', + cliName, '--pack-destination', cacheDir, ]); diff --git a/scripts/tests/issue-2603-shim-separator-tests.test.ts b/scripts/tests/issue-2603-shim-separator-tests.test.ts index 5622548d10..167144cbf3 100644 --- a/scripts/tests/issue-2603-shim-separator-tests.test.ts +++ b/scripts/tests/issue-2603-shim-separator-tests.test.ts @@ -20,12 +20,38 @@ const cliModulePath = join( 'install-native-launchers.cjs', ); -function loadCliInstaller(): ReturnType { +/** + * 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]; - return nodeRequire(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 = [ @@ -43,35 +69,32 @@ describe('shim target extraction accepts both path separators', () => { }); it('extractPs1ShimTargets accepts backslash $basedir paths (Windows)', () => { - const mod = loadCliInstaller(); - const content = [ - '$target = "$basedir\\..\\lib\\node_modules\\@vybestack\\llxprt-code\\bin\\llxprt"', - ].join('\n'); - const targets = mod.extractPs1ShimTargets(content); - expect(targets).toContain( + 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', () => { - const mod = loadCliInstaller(); - const content = [ - '@echo off', - '"/bin/sh.exe" "%dp0%\\..\\lib\\node_modules\\@vybestack\\llxprt-code\\bin\\llxprt" %*', - ].join('\n'); - const targets = mod.extractCmdShimTargets(content); - expect(targets).toContain( + 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)', () => { - const mod = loadCliInstaller(); - const content = [ - '"%dp0%/../lib/node_modules/@vybestack/llxprt-code/bin/llxprt" %*', - ].join('\n'); - const targets = mod.extractCmdShimTargets(content); - expect(targets).toContain( + 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 0000000000..3317341836 --- /dev/null +++ b/scripts/tests/issue-2603-smoke-fail-fast.test.ts @@ -0,0 +1,525 @@ +/** + * @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, beforeEach } from 'vitest'; +import { createRequire } from 'node:module'; +import { resolve, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { writeFileSync, 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)', () => { + beforeEach(() => { + assertModule(); // ensure module loads + }); + + 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. + + beforeEach(() => { + assertModule(); + }); + + 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; + }) => 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' }, + 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' }, + spawnSync: () => ({ + status: 0, + stdout: 'C:\\other\\pwsh.exe\r\n', + }), + }); + expect(result).toBe('C:\\explicit\\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[]; + }; + + 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('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'); + }); +}); + +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'); // P E \0 \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; + }; + + it('EXPECTED_BUN_VERSION matches the CLI manifest (1.3.14)', () => { + const m = constantsModule(); + expect(m.EXPECTED_BUN_VERSION).toBe('1.3.14'); + }); + + 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 is generous enough for a warmed-cache install', () => { + const m = constantsModule(); + expect(m.INSTALL_TIMEOUT_MS).toBeGreaterThanOrEqual(180_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 index 8e28e66133..39459ea6a6 100644 --- a/scripts/tests/issue-2603-smoke-helpers.test.ts +++ b/scripts/tests/issue-2603-smoke-helpers.test.ts @@ -121,6 +121,23 @@ describe('pwshQuote', () => { 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', () => { diff --git a/scripts/tests/issue-2603-startup-benchmark.cjs b/scripts/tests/issue-2603-startup-benchmark.cjs index 9326bfbee1..dcd2f18a1f 100644 --- a/scripts/tests/issue-2603-startup-benchmark.cjs +++ b/scripts/tests/issue-2603-startup-benchmark.cjs @@ -3,10 +3,20 @@ /** * Startup benchmark for issue #2603. * - * Compares the direct POSIX launcher (packages/cli/bin/llxprt) 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. + * 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] * @@ -14,10 +24,11 @@ */ const { spawnSync } = require('node:child_process'); -const { existsSync } = require('node:fs'); -const { join, resolve } = require('node:path'); +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. @@ -36,19 +47,35 @@ function parseIterations(raw) { } const iterations = parseIterations(process.argv[3] || '15'); -const launcher = join(repoRoot, 'packages', 'cli', 'bin', 'llxprt'); +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 = 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, - * .bun/bin/bun is also checked as a fallback for alternate installers. + * node_modules/.bun/bin/bun is also checked as a fallback for alternate + * installers before falling back to a global lookup. */ function resolveBun() { - if (existsSync(repoBun)) return repoBun; - const tool = process.platform === 'win32' ? 'where' : 'which'; + // 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( @@ -62,24 +89,122 @@ function resolveBun() { 'Ensure Bun is installed and on PATH.', ); } - const found = r.stdout.trim().split('\n')[0]; + const found = r.stdout.trim().split(/\r?\n/)[0]; if (!found) { throw new Error(`'${tool} bun' produced no output.`); } + validateExecutable(found); return found; } -function timeDirectLauncher() { +/** + * 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 } = 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}`); + } + 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(launcher, ['--version'], { - cwd: repoRoot, - encoding: 'utf8', - timeout: 30_000, - stdio: 'inherit', - env: { ...process.env }, - }); + 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}`); } @@ -101,6 +226,7 @@ function timeNodeRelayBaseline(bunExe) { // 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'); const bunExe = process.argv[1]; @@ -123,13 +249,12 @@ function timeNodeRelayBaseline(bunExe) { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, + stdio: 'inherit', env: { ...process.env }, }); if (r.error) { throw new Error(`node relay spawn failed: ${r.error.message}`); } - // Never coerce a null/signal/timeout status to 0. A null status means the - // relay child was killed by a signal or timed out; surface it as a failure. if (r.status === null) { throw new Error( `node relay did not exit normally (signal=${r.signal ?? 'none'}): ${r.stderr}`, @@ -144,21 +269,31 @@ function timeNodeRelayBaseline(bunExe) { function measure(fn, label) { const samples = []; // Warmup run (not counted) to stabilize FS cache. - try { - fn(); - } catch (e) { - console.error(`Warmup failed for ${label}: ${e.message}`); - return null; - } + const warmupStatus = (() => { + try { + return fn(); + } catch (e) { + console.error(`Warmup failed for ${label}: ${e.message}`); + return null; + } + })(); + if (warmupStatus === null) return null; for (let i = 0; i < iterations; i++) { const t0 = process.hrtime.bigint(); + let status; try { - fn(); + status = fn(); } catch (e) { console.error(`${label} iteration ${i} failed: ${e.message}`); return null; } const t1 = process.hrtime.bigint(); + // A nonzero status is a meaningful failure: don't silently include it as + // a valid sample. Surface it so the benchmark result is trustworthy. + if (status !== 0) { + console.error(`${label} iteration ${i} exited ${status}, not 0`); + return null; + } samples.push(Number(t1 - t0) / 1e6); // ms } samples.sort((a, b) => a - b); @@ -169,19 +304,32 @@ function measure(fn, label) { } function main() { - if (!existsSync(launcher)) { - console.error(`Launcher not found: ${launcher}`); + // POSIX: validate the source launcher exists. Windows: the real launcher is + // resolved from the installed replica at benchmark time. + if (!isWindows && !existsSync(posixLauncher)) { + console.error(`Launcher not found: ${posixLauncher}`); process.exit(1); } const bunExe = resolveBun(); console.log(`Startup benchmark (issue #2603)`); + console.log(` platform: ${process.platform}`); console.log(` iterations: ${iterations}`); - console.log(` launcher: ${launcher}`); + if (!isWindows) { + console.log(` launcher: ${posixLauncher}`); + } else { + console.log( + ` launcher: ${process.env.LLXPRT_BENCH_LAUNCHER ? '(smoke handoff)' : '(installed .cmd from replica)'}`, + ); + } console.log(` bun: ${bunExe}`); console.log(''); - const direct = measure(timeDirectLauncher, 'direct-launcher'); + const launcherInvocation = resolveDirectLauncherInvocation(); + const direct = measure( + () => timeDirectLauncher(launcherInvocation), + 'direct-launcher', + ); const relay = measure(() => timeNodeRelayBaseline(bunExe), 'node-relay'); function fmt(r) { @@ -202,7 +350,13 @@ function main() { // Output a GitHub Actions step-summary table if available. if (process.env.GITHUB_STEP_SUMMARY) { - const fs = require('fs'); + const summaryDir = dirname(process.env.GITHUB_STEP_SUMMARY); + // Ensure the summary parent directory exists before appending. + try { + mkdirSync(summaryDir, { recursive: true }); + } catch { + // best-effort; appendFileSync may still succeed if the dir exists. + } const lines = [ '### Startup Benchmark (issue #2603)', '', @@ -224,8 +378,20 @@ function main() { `| ratio (relay/direct) | ${(relay.median / direct.median).toFixed(2)}x | - | - |`, ); } - fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, lines.join('\n') + '\n'); + appendFileSync(process.env.GITHUB_STEP_SUMMARY, lines.join('\n') + '\n'); } } -main(); +// Export the pure resolver helpers so unit tests can validate the env-handoff +// behavior (LLXPRT_BENCH_LAUNCHER / LLXPRT_BENCH_BUN) without spawning the +// full benchmark. Only run main() when invoked directly as a script. +module.exports = { + resolveBun, + resolveDirectLauncherInvocation, + parseIterations, + validateExecutable, +}; + +if (require.main === module) { + main(); +} diff --git a/scripts/tests/issue-2603-windows-probe.ts b/scripts/tests/issue-2603-windows-probe.ts index fb11347ff8..7df791fde9 100644 --- a/scripts/tests/issue-2603-windows-probe.ts +++ b/scripts/tests/issue-2603-windows-probe.ts @@ -70,7 +70,8 @@ async function emitAndFlush(payload: Record): Promise { /** * Resolves once the stdout stream has flushed its buffered writes. On streams - * without a draining event (already drained), resolves immediately. + * without a draining event (already drained), resolves immediately. Handles + * stream errors (broken pipe, stream destroyed) so the promise never hangs. */ function drainStdout(): Promise { return new Promise((resolve) => { @@ -78,7 +79,22 @@ function drainStdout(): Promise { resolve(); return; } - process.stdout.write('', () => resolve()); + let settled = false; + const finish = (): void => { + if (settled) return; + settled = true; + process.stdout.removeListener('error', onError); + resolve(); + }; + const onError = (): void => { + if (settled) return; + settled = true; + // A broken pipe / destroyed stream means the parent is gone; resolve so + // we never hang waiting for a flush that can never complete. + resolve(); + }; + process.stdout.once('error', onError); + process.stdout.write('', finish); }); } diff --git a/scripts/tests/npm-command.test.ts b/scripts/tests/npm-command.test.ts index d1309c97bd..06257b1544 100644 --- a/scripts/tests/npm-command.test.ts +++ b/scripts/tests/npm-command.test.ts @@ -106,15 +106,47 @@ describe('npmInvocation Windows', () => { }); it('never produces a shell string with spaces or metacharacters', () => { - const inv = npmInvocation(['pack'], { + // Hostile argv: spaces, Unicode, and Windows shell metacharacters. A + // concatenating (shell-string) implementation would fail these assertions. + const args = [ + 'exec', + '--', + 'name with spaces', + 'Δ', + 'a&b', + 'x|y', + 'per%cent', + 'bang!', + 'semi;colon', + 'lt p === 'C:\\npm\\bin\\npm-cli.js', }); - expect(inv.command).toBe(inv.command.trim()); - // args preserve boundaries (no shell concatenation) - expect(inv.args.length).toBeGreaterThan(0); + expect(inv.command).toBe('C:\\node\\node.exe'); + // The first arg is the resolved npm-cli.js; the remaining args must be + // preserved exactly as boundaries — no shell concatenation. + expect(inv.args).toStrictEqual(['C:\\npm\\bin\\npm-cli.js', ...args]); + }); + + it('default Windows args structure is [npm-cli.js, ...userArgs] with no shell flag', () => { + // Default (no env injection) must still resolve via the node-dir fallback + // and produce a node command with npm-cli.js as args[0]. + const inv = npmInvocation(['pack'], { + platform: 'win32', + execPath: 'C:\\node\\node.exe', + env: {}, + existsSync: () => true, + }); + expect(inv.command).toBe('C:\\node\\node.exe'); + expect(inv.args[0]).toMatch(/npm-cli\.js$/); + expect(inv.args.slice(1)).toStrictEqual(['pack']); }); }); diff --git a/scripts/tests/postinstall-manager-aware.test.ts b/scripts/tests/postinstall-manager-aware.test.ts index dfc1278a49..4505370e9f 100644 --- a/scripts/tests/postinstall-manager-aware.test.ts +++ b/scripts/tests/postinstall-manager-aware.test.ts @@ -453,16 +453,25 @@ describe.skipIf(isWindows)('postinstall Bun workspace symlinking', () => { }); describe('installWindowsNativeLaunchers error handling (nonfatal contract)', () => { + // Extracted helper: reads the real postinstall source and slices the + // installWindowsNativeLaunchers function body for static contract assertions. + // On macOS this function is platform-gated and never runs; the tests verify + // the source-level contract (try/catch wrapping, no process.exit) that + // guarantees nonfatal behavior on Windows. + function extractLauncherFnBody(): string { + const source = readFileSync(realPostinstall, 'utf8'); + const fnStart = source.indexOf('function installWindowsNativeLaunchers'); + expect(fnStart).toBeGreaterThan(-1); + const fnEnd = source.indexOf('\n}', fnStart) + 2; + return source.slice(fnStart, fnEnd); + } + it('wraps the installer require and invocation in try/catch (nonfatal)', () => { // On macOS, installWindowsNativeLaunchers is platform-gated and never // runs. The contract is that an unexpected require failure or installer // exception is caught and logged as a warning, never aborting postinstall. // Verify the source has the protective try/catch around the require+invoke. - const source = readFileSync(realPostinstall, 'utf8'); - const fnStart = source.indexOf('function installWindowsNativeLaunchers'); - expect(fnStart).toBeGreaterThan(-1); - const fnEnd = source.indexOf('\n}', fnStart) + 2; - const fnBody = source.slice(fnStart, fnEnd); + const fnBody = extractLauncherFnBody(); expect(fnBody).toContain('try {'); expect(fnBody).toContain('require(cliInstaller)'); expect(fnBody).toContain('installNativeLaunchers'); @@ -481,10 +490,12 @@ describe('installWindowsNativeLaunchers error handling (nonfatal contract)', () // crash. We cannot change process.platform, but we CAN verify that the // function's error path does not propagate by checking the source never // calls process.exit(1) from installWindowsNativeLaunchers. - const source = readFileSync(realPostinstall, 'utf8'); - const fnStart = source.indexOf('function installWindowsNativeLaunchers'); - const fnEnd = source.indexOf('\n}', fnStart) + 2; - const fnBody = source.slice(fnStart, fnEnd); + // + // This is a static source contract assertion, not a behavioral test: it + // verifies the nonfatal-by-construction invariant that the function never + // escalates an installer error to a process.exit. A full behavioral test + // runs on the hosted Windows smoke (windows-installed-command-smoke.cjs). + const fnBody = extractLauncherFnBody(); // The function must never call process.exit — errors are logged and swallowed. expect(fnBody).not.toMatch(/process\.exit\(\d+\)/); }); diff --git a/scripts/windows-installed-command-smoke.cjs b/scripts/windows-installed-command-smoke.cjs index 5f122f0db9..7be6c4a8c0 100644 --- a/scripts/windows-installed-command-smoke.cjs +++ b/scripts/windows-installed-command-smoke.cjs @@ -16,23 +16,40 @@ * The harness: * 1. Packs a release-like CLI replica tarball via the shared release-pack * helper (same one POSIX tests use). - * 2. Installs the replica globally and locally. + * 2. Installs the replica globally and locally (REQUIRED setup: a failure + * aborts dependent checks via runRequiredStep — no cascade). * 3. Creates a TEMP installed-package fixture whose index.ts is replaced - * with an instrumented probe. + * with an instrumented probe. The copied bun.exe is validated (PE magic + + * exact version) BEFORE the fixture is used. * 4. Invokes both launchers through the real cmd and PowerShell, asserting * args, stdio, exit codes, execPath, and the process tree. * 5. Tests missing-Bun and corrupt-Bun error contracts. * 6. Tests actual ephemeral `npm exec --package -- llxprt`. - * 7. Tests package-local bun.exe presence. + * 7. Tests package-local bun.exe presence and PE/version integrity. + * 8. After all behavioral checks pass, runs the startup benchmark as a + * child process using the INSTALLED launcher + platform bun (no separate + * reinstall/repack) before cleanup, so the workflow needs only one step. + * + * Benchmark handoff (root cause E): + * On success, before cleanup, this process writes the installed paths + * (launcher, packageRoot, bun.exe) to a stable diagnostic JSON under + * RUNNER_TEMP and invokes the benchmark child with LLXPRT_BENCH_LAUNCHER / + * LLXPRT_BENCH_BUN env vars so it does not repack/reinstall. + * + * Diagnostics on failure (root cause I): + * On failure a small diagnostic JSON (no node_modules) is written next to + * the temp dir so the workflow can upload it as an artifact. The large temp + * fixture itself lives in the hosted runner temp which vanishes post-job. * * This is a Node script so the test driver does not depend on a pre-installed * Bun. The release-pack helper invokes Bun for bind-release-deps, so Bun must * be set up in the workflow before this runs. */ -const { existsSync, mkdtempSync, rmSync } = require('node:fs'); +const { existsSync, mkdtempSync, rmSync, writeFileSync } = require('node:fs'); const { join, resolve } = require('node:path'); const { tmpdir } = require('node:os'); +const { spawnSync } = require('node:child_process'); const { createRequire } = require('node:module'); const isWindows = process.platform === 'win32'; @@ -70,10 +87,97 @@ function safeCleanup(tempDir) { } } +/** + * Writes a small diagnostic JSON (no node_modules) capturing the smoke result + * and key paths. GitHub hosted runner temp vanishes after the job, so this + * small artifact is what the workflow can upload via `actions/upload-artifact` + * on failure for offline debugging. Kept tiny on purpose (no giant dirs). + */ +function writeDiagnostic(status, details) { + const diagDir = process.env.RUNNER_TEMP || tmpdir(); + const diagPath = join( + diagDir, + `llxprt-win-smoke-diagnostic-${process.pid}.json`, + ); + try { + const { failed, failures } = getState(); + writeFileSync( + diagPath, + JSON.stringify( + { + status, + pid: process.pid, + platform: process.platform, + timestamp: new Date().toISOString(), + failed, + failures, + ...details, + }, + null, + 2, + ), + ); + process.stdout.write(`diagnostic=${diagPath}\n`); + } catch (e) { + console.error(`Warning: could not write diagnostic JSON: ${e.message}`); + } +} + +/** + * Runs the startup benchmark as a CHILD process, pointing it at the already + * installed launcher and platform bun via env vars so it does NOT repack or + * reinstall (root cause E). Invoked before cleanup so the fixture is still + * present. Failures here are reported but do NOT fail the smoke (the benchmark + * is a measurement tool with no threshold gate). + * + * @param {string} cmdLauncher - absolute path to the installed llxprt.cmd + * @param {string} bunExe - absolute path to the platform bun.exe + */ +function runBenchmarkChild(cmdLauncher, bunExe) { + const benchScript = join( + repoRoot, + 'scripts', + 'tests', + 'issue-2603-startup-benchmark.cjs', + ); + if (!existsSync(benchScript)) { + console.error(`benchmark step skipped: script not found at ${benchScript}`); + return; + } + process.stdout.write('[benchmark] starting...\n'); + const r = spawnSync(process.execPath, [benchScript, repoRoot, '15'], { + encoding: 'utf8', + timeout: 300_000, + maxBuffer: 64 * 1024 * 1024, + env: { + ...process.env, + LLXPRT_BENCH_LAUNCHER: cmdLauncher, + LLXPRT_BENCH_BUN: bunExe, + }, + }); + if (r.stdout) process.stdout.write(r.stdout); + if (r.stderr) process.stderr.write(r.stderr); + if (r.error) { + console.error(`[benchmark] spawn failed: ${r.error.message}`); + return; + } + if (r.signal) { + console.error(`[benchmark] terminated by signal ${r.signal}`); + return; + } + if (r.status !== 0) { + console.error(`[benchmark] exited ${r.status} (non-fatal)`); + return; + } + process.stdout.write('[benchmark] OK\n'); +} + function runSmoke() { resetState(); let tempDir; let succeeded = false; + let cmdLauncher; + let bunExe; return (async () => { try { const { packReleaseLikeCli } = nodeRequire(releasePackHelperPath); @@ -92,6 +196,8 @@ function runSmoke() { checks.checkVersionRuns(prefix); const installedPackageRoot = findInstalledPackageRoot(prefix); + bunExe = findBundledBun(installedPackageRoot); + cmdLauncher = join(prefix, 'llxprt.cmd'); const probeFixture = checks.buildProbeFixture( installedPackageRoot, @@ -121,15 +227,32 @@ function runSmoke() { } catch (err) { fail(`unexpected error: ${err.stack || err.message}`); } finally { - // Preserve the temp fixture on failure for debugging (print its path so - // CI logs show where to inspect). Clean up on success to avoid CI disk - // pressure. + // Run the benchmark using the installed launcher + bun BEFORE cleanup + // so it never needs to repack/reinstall (root cause E). Only when the + // behavioral smoke succeeded and we have a healthy installed launcher. + if (succeeded && cmdLauncher && existsSync(cmdLauncher) && bunExe) { + runBenchmarkChild(cmdLauncher, bunExe); + } if (succeeded) { + writeDiagnostic('success', { + cmdLauncher, + bunExe, + tempDir, + }); safeCleanup(tempDir); - } else if (tempDir) { - console.error( - `\nTemp fixture preserved for debugging at:\n ${tempDir}\n`, - ); + } else { + writeDiagnostic('failure', { + tempDir: tempDir || null, + cmdLauncher: cmdLauncher || null, + bunExe: bunExe || null, + }); + if (tempDir) { + console.error( + `\nTemp fixture preserved for debugging at:\n ${tempDir}\n` + + `(Hosted runner temp vanishes post-job; the diagnostic JSON ` + + `above should be uploaded as an artifact by the workflow.)\n`, + ); + } } } })(); @@ -157,6 +280,19 @@ runSmoke() reportAndExit(); }) .catch((err) => { - fail(`runSmoke rejected unexpectedly: ${err.stack || err.message}`); + // Normalize non-Error rejections so the message extraction never throws. + let detail; + if (err && typeof err === 'object' && typeof err.stack === 'string') { + detail = err.stack; + } else if ( + err && + typeof err === 'object' && + typeof err.message === 'string' + ) { + detail = err.message; + } else { + detail = String(err); + } + fail(`runSmoke rejected unexpectedly: ${detail}`); reportAndExit(); }); diff --git a/scripts/windows-installed-command-smoke/assert.cjs b/scripts/windows-installed-command-smoke/assert.cjs index 5ae4fb4a87..ca6f08c4ae 100644 --- a/scripts/windows-installed-command-smoke/assert.cjs +++ b/scripts/windows-installed-command-smoke/assert.cjs @@ -4,6 +4,25 @@ * Assertion and step-runner helpers for the Windows installed-command smoke. * Shared across all check modules so failures are collected and reported in a * single summary rather than aborting on the first error. + * + * Two kinds of steps are supported: + * - runStep: a behavioral CHECK. Failures are accumulated and reported in + * the summary; the harness continues to the next independent check so a + * single broken assertion does not hide others. This is the right tool + * for the 23 behavioral probe checks. + * - runRequiredStep: a setup PREREQUISITE. If it fails, the caller MUST + * abort subsequent dependent steps — continuing would produce a cascade of + * confusing downstream failures (e.g. a global-install timeout cascading + * into 30 "launcher not found" failures, as seen in CI run 29850614559). + * runRequiredStep logs then RETHROWS so the top-level orchestrator can + * record the single root cause once and stop. + * + * runStep "OK" semantics: + * A step is only "OK" when NO failure was recorded DURING that step. We + * snapshot the failure count before invoking fn; if it grew, the step + * triggered fail() (the non-throwing assert path) and must NOT print OK. + * This fixes the bug where checkCmdExitCodePreservation called assert() for + * each exit code, accumulated failures, and still printed "[step] OK". */ let failed = false; @@ -20,31 +39,109 @@ function assert(condition, msg) { return condition; } +/** + * Prints "[label] OK" only when the failure count did NOT increase during fn. + * Snapshots the count before/after so a non-throwing assert() (which calls + * fail() without throwing) is correctly reflected as a non-OK step. + */ function runStep(label, fn) { process.stdout.write(`[${label}] starting...\n`); + const before = failures.length; try { const result = fn(); if (result && typeof result.then === 'function') { // Async step: return the promise so the caller can await it. On success - // print OK; on rejection, accumulate the failure (do not re-throw so - // parallel steps do not unhandled-reject). + // print OK only if no failure was recorded during the async body. return result.then( () => { - process.stdout.write(`[${label}] OK\n`); + if (failures.length === before) { + process.stdout.write(`[${label}] OK\n`); + } else { + process.stdout.write(`[${label}] FAIL\n`); + } }, (err) => { - fail(`${label}: ${err.message}`); + const msg = + err && typeof err.message === 'string' ? err.message : String(err); + fail(`${label}: ${msg}`); }, ); } - process.stdout.write(`[${label}] OK\n`); + if (failures.length === before) { + process.stdout.write(`[${label}] OK\n`); + } else { + process.stdout.write(`[${label}] FAIL\n`); + } return undefined; } catch (err) { - fail(`${label}: ${err.message}`); + const msg = + err && typeof err.message === 'string' ? err.message : String(err); + fail(`${label}: ${msg}`); return undefined; } } +/** + * Runs a REQUIRED setup step. Logs the step, then — unlike runStep — RETHROWS + * on any error so the top-level orchestrator's try/catch records the single + * root cause once and aborts dependent work. This prevents a setup failure + * (e.g. npm global install timeout) from cascading into dozens of misleading + * downstream failures. + * + * A non-throwing fail() during a required step is also treated as fatal: we + * throw an AssertionError summarizing any failures recorded since the + * snapshot. + */ +function runRequiredStep(label, fn) { + process.stdout.write(`[${label}] starting...\n`); + const before = failures.length; + let result; + try { + result = fn(); + } catch (err) { + const msg = + err && typeof err.message === 'string' ? err.message : String(err); + fail(`${label}: ${msg}`); + process.stdout.write(`[${label}] FAIL\n`); + throw err; + } + if (result && typeof result.then === 'function') { + return result.then( + (v) => { + if (failures.length !== before) { + const recent = failures.slice(before).join('; '); + process.stdout.write(`[${label}] FAIL\n`); + const err = new Error( + `required step "${label}" recorded failure(s): ${recent}`, + ); + err.name = 'AssertionError'; + throw err; + } + process.stdout.write(`[${label}] OK\n`); + return v; + }, + (err) => { + const msg = + err && typeof err.message === 'string' ? err.message : String(err); + fail(`${label}: ${msg}`); + process.stdout.write(`[${label}] FAIL\n`); + throw err; + }, + ); + } + if (failures.length !== before) { + const recent = failures.slice(before).join('; '); + process.stdout.write(`[${label}] FAIL\n`); + const err = new Error( + `required step "${label}" recorded failure(s): ${recent}`, + ); + err.name = 'AssertionError'; + throw err; + } + process.stdout.write(`[${label}] OK\n`); + return result; +} + function resetState() { failed = false; failures.length = 0; @@ -58,6 +155,7 @@ module.exports = { fail, assert, runStep, + runRequiredStep, resetState, getState, }; diff --git a/scripts/windows-installed-command-smoke/bun-validation.cjs b/scripts/windows-installed-command-smoke/bun-validation.cjs new file mode 100644 index 0000000000..cde5ae14c8 --- /dev/null +++ b/scripts/windows-installed-command-smoke/bun-validation.cjs @@ -0,0 +1,183 @@ +'use strict'; + +/** + * Validation helpers for the bundled bun.exe on Windows. + * + * Background (CI run 29850614559, root cause B): + * A timed-out npm install left a PARTIALLY installed package. The fixture + * was copied BEFORE Bun's postinstall completed, yielding a bun.exe that was + * not a real Windows PE binary — launching it produced "not compatible with + * the version of Windows you're running" (exit 216). + * + * These helpers verify, before a fixture is used, that: + * 1. bun.exe is a valid Windows PE binary (MZ + PE signature), and + * 2. `bun.exe --version` reports the exact expected version. + * + * They are pure (read-only) so they can be unit-tested with synthetic files + * without spawning the real binary. + */ + +const { spawnSync } = require('node:child_process'); + +/** + * DOS MZ header magic ("MZ"). + */ +const MZ_MAGIC = Buffer.from([0x4d, 0x5a]); + +/** + * PE signature ("PE\0\0") is located at the offset stored in the e_lfanew + * field (a little-endian uint32 at offset 0x3c). + */ +const PE_SIGNATURE = Buffer.from([0x50, 0x45, 0x00, 0x00]); +const ELFANEW_OFFSET = 0x3c; + +/** + * Reads the first N bytes of a file (default 4KB) and returns them. Throws with + * the path on any I/O error so a missing/unreadable binary is reported clearly + * rather than as an opaque "not a PE" failure. + * + * @param {string} filePath + * @param {number} [maxBytes] + * @returns {Buffer} + */ +function readHeader(filePath, maxBytes = 4096) { + let fd; + try { + // Use readFileSync with a smaller read by opening, reading a slice, then + // closing. Node's readFileSync reads the whole file; for a multi-MB binary + // we only need the first 4KB. Use fs.openSync/readSync for efficiency. + const fs = require('node:fs'); + fd = fs.openSync(filePath, 'r'); + const buf = Buffer.alloc(maxBytes); + const bytesRead = fs.readSync(fd, buf, 0, maxBytes, 0); + return buf.subarray(0, bytesRead); + } catch (e) { + throw new Error( + `bun-validation: could not read header of ${filePath}: ${e.message}`, + ); + } finally { + if (fd !== undefined) { + try { + require('node:fs').closeSync(fd); + } catch { + // best effort + } + } + } +} + +/** + * Returns true when the file at filePath begins with the DOS MZ magic AND + * contains a PE signature at the e_lfanew offset. This is the minimal check + * that the file is a Windows PE-family binary (not, e.g., a partial download + * or a POSIX shell script). + * + * @param {string} filePath + * @param {{ readHeader?: (p: string, n?: number) => Buffer }} [options] + * @returns {boolean} + */ +function isWindowsPe(filePath, options) { + const reader = (options && options.readHeader) || readHeader; + let header; + try { + header = reader(filePath); + } catch { + return false; + } + if (header.length < ELFANEW_OFFSET + 4) return false; + if (header.subarray(0, 2).compare(MZ_MAGIC) !== 0) return false; + const peOffset = header.readUInt32LE(ELFANEW_OFFSET); + if (peOffset <= 0 || peOffset + PE_SIGNATURE.length > header.length) { + return false; + } + return ( + header + .subarray(peOffset, peOffset + PE_SIGNATURE.length) + .compare(PE_SIGNATURE) === 0 + ); +} + +/** + * Spawns `bun.exe --version` and returns the trimmed stdout. Throws on spawn + * failure, nonzero exit, or non-version output so callers can distinguish a + * corrupt/incompatible binary from a healthy one. + * + * @param {string} bunExePath + * @param {{ spawnSync?: typeof import('node:child_process').spawnSync, env?: NodeJS.ProcessEnv, timeoutMs?: number }} [options] + * @returns {string} + */ +function bunVersion(bunExePath, options) { + const spawn = (options && options.spawnSync) || spawnSync; + const env = (options && options.env) || process.env; + const timeoutMs = (options && options.timeoutMs) || 15_000; + const r = spawn(bunExePath, ['--version'], { + encoding: 'utf8', + timeout: timeoutMs, + windowsHide: true, + env, + }); + if (r.error) { + throw new Error( + `bun-validation: '${bunExePath} --version' spawn failed: ${r.error.message}`, + ); + } + if (r.signal) { + throw new Error( + `bun-validation: '${bunExePath} --version' terminated by signal ${r.signal}`, + ); + } + if (r.status !== 0) { + throw new Error( + `bun-validation: '${bunExePath} --version' exited ${r.status}: ${(r.stderr || '').trim()}`, + ); + } + return String(r.stdout).trim(); +} + +/** + * Asserts that bunExePath is a Windows PE binary AND reports the expected + * version. Throws an aggregate Error (with isWindowsPe/bunVersion details) on + * any failure so a single clear diagnostic surfaces. This is the "before using + * fixture" gate described in root cause B. + * + * @param {string} bunExePath + * @param {string} expectedVersion + * @param {{ readHeader?: (p: string, n?: number) => Buffer, spawnSync?: typeof import('node:child_process').spawnSync, env?: NodeJS.ProcessEnv, timeoutMs?: number }} [options] + * @throws {Error} + */ +function assertBundledBunHealthy(bunExePath, expectedVersion, options) { + const reasons = []; + if (!isWindowsPe(bunExePath, options)) { + reasons.push(`not a valid Windows PE binary (expected MZ+PE signature)`); + } + // Even if the PE check failed, attempt --version to produce the concrete + // incompatibility error (e.g. exit 216 "not compatible with Windows") which + // is far more actionable than a bare magic-number mismatch. + let actualVersion = null; + try { + actualVersion = bunVersion(bunExePath, options); + } catch (e) { + reasons.push(`--version failed: ${e.message}`); + } + if (actualVersion !== null && actualVersion !== expectedVersion) { + reasons.push( + `version ${JSON.stringify(actualVersion)} != expected ${JSON.stringify(expectedVersion)}`, + ); + } + if (reasons.length > 0) { + throw new Error( + `bun-validation: bundled bun.exe at ${bunExePath} failed health check:\n - ` + + reasons.join('\n - '), + ); + } +} + +module.exports = { + isWindowsPe, + bunVersion, + assertBundledBunHealthy, + readHeader, + MZ_MAGIC, + PE_SIGNATURE, + ELFANEW_OFFSET, +}; diff --git a/scripts/windows-installed-command-smoke/checks.cjs b/scripts/windows-installed-command-smoke/checks.cjs index a97e5b9932..fdfbfc406a 100644 --- a/scripts/windows-installed-command-smoke/checks.cjs +++ b/scripts/windows-installed-command-smoke/checks.cjs @@ -23,6 +23,10 @@ const { OWNERSHIP_SENTINEL, VERSION_RE, LAUNCH_ERROR_EXIT, + EXPECTED_BUN_VERSION, + VERSION_TIMEOUT_MS, + NPM_EXEC_TIMEOUT_MS, + PROBE_TIMEOUT_MS, } = require('./constants.cjs'); const { probeArg, @@ -37,7 +41,10 @@ const { killProcessTree, spawn, } = require('./process-helpers.cjs'); +const { resolvePwsh } = require('./pwsh-resolver.cjs'); +const { assertBundledBunHealthy } = require('./bun-validation.cjs'); const { npmInvocation } = require('../lib/npm-command.cjs'); +const { SPAWN_MAX_BUFFER } = require('./install-helpers.cjs'); function buildProbeFixture(installedPackageRoot, tempBase, label, repoRoot) { const fixtureDir = join(tempBase, `probe-fixture-${label}`); @@ -75,6 +82,12 @@ function buildProbeFixture(installedPackageRoot, tempBase, label, repoRoot) { `installer did not write both launchers for fixture ${label} (got ${JSON.stringify(result)})`, ); } + // Verify the COPIED bundled bun.exe is a real Windows PE binary reporting + // the expected version BEFORE using the fixture. A timed-out install can + // leave a partial/non-PE binary (run 29850614559); this gate fails fast with + // an actionable diagnostic instead of cascading "exit 216 not compatible". + const fixtureBun = findBundledBun(fixturePkgRoot); + assertBundledBunHealthy(fixtureBun, EXPECTED_BUN_VERSION); return { fixtureDir, fixturePkgRoot }; } @@ -123,9 +136,17 @@ function checkVersionRuns(prefix) { const cmdPath = join(prefix, 'llxprt.cmd'); const r = spawnSync('cmd', ['/c', cmdPath, '--version'], { encoding: 'utf8', - timeout: 30_000, + timeout: VERSION_TIMEOUT_MS, env: { ...process.env, PATH: CONSTRAINED_PATH }, }); + // Inspect error/signal before status: a spawn failure yields null status + // and must be reported as a harness error, not a misleading exit code. + if (r.error) { + throw new Error(`cmd --version spawn failed: ${r.error.message}`); + } + if (r.signal) { + throw new Error(`cmd --version terminated by signal ${r.signal}`); + } if (r.status !== 0) { throw new Error(`cmd --version exited ${r.status}: ${r.stderr}`); } @@ -137,15 +158,24 @@ function checkVersionRuns(prefix) { runStep('ps1-version', () => { const ps1Path = join(prefix, 'llxprt.ps1'); + // Resolve PowerShell robustly: PWSH_PATH -> pwsh.exe -> powershell.exe. + // Hardcoding 'powershell' failed with ENOENT on windows-latest (run 29850614559). + const pwshExe = resolvePwsh(); const r = spawnSync( - 'powershell', + pwshExe, ['-NoProfile', '-Command', `& '${ps1Path}' --version`], { encoding: 'utf8', - timeout: 30_000, + timeout: VERSION_TIMEOUT_MS, env: { ...process.env, PATH: CONSTRAINED_PATH }, }, ); + if (r.error) { + throw new Error(`ps1 --version spawn failed: ${r.error.message}`); + } + if (r.signal) { + throw new Error(`ps1 --version terminated by signal ${r.signal}`); + } if (r.status !== 0) { throw new Error(`ps1 --version exited ${r.status}: ${r.stderr}`); } @@ -170,7 +200,9 @@ function checkCmdArgFidelity(fixture) { const cmdPath = join(fixture.fixtureDir, 'llxprt.cmd'); assert(existsSync(cmdPath), `cmd launcher missing in fixture`); for (const marker of ARG_FIDELITY_MARKERS) { - const r = invokeCmd(cmdPath, [probeArg({ marker })]); + const r = invokeCmd(cmdPath, [probeArg({ marker })], { + timeout: PROBE_TIMEOUT_MS, + }); if (r.status !== 0) { throw new Error( `cmd probe exited ${r.status} for marker ${JSON.stringify(marker)}: ${r.stderr}`, @@ -202,7 +234,9 @@ function checkPwshArgFidelity(fixture) { const ps1Path = join(fixture.fixtureDir, 'llxprt.ps1'); assert(existsSync(ps1Path), `ps1 launcher missing in fixture`); for (const marker of ARG_FIDELITY_MARKERS) { - const r = invokePwsh(ps1Path, [probeArg({ marker })]); + const r = invokePwsh(ps1Path, [probeArg({ marker })], { + timeout: PROBE_TIMEOUT_MS, + }); if (r.status !== 0) { throw new Error( `ps1 probe exited ${r.status} for marker ${JSON.stringify(marker)}: ${r.stderr}`, @@ -338,7 +372,9 @@ function checkPwshExitPropagation(fixture) { function checkExecPathIsBundledBun(fixture) { runStep('cmd-execpath-is-bundled-bun', () => { const cmdPath = join(fixture.fixtureDir, 'llxprt.cmd'); - const r = invokeCmd(cmdPath, [probeArg({})]); + const r = invokeCmd(cmdPath, [probeArg({})], { + timeout: PROBE_TIMEOUT_MS, + }); if (r.status !== 0) { throw new Error(`cmd execpath probe exited ${r.status}: ${r.stderr}`); } @@ -352,7 +388,9 @@ function checkExecPathIsBundledBun(fixture) { runStep('pwsh-execpath-is-bundled-bun', () => { const ps1Path = join(fixture.fixtureDir, 'llxprt.ps1'); - const r = invokePwsh(ps1Path, [probeArg({})]); + const r = invokePwsh(ps1Path, [probeArg({})], { + timeout: PROBE_TIMEOUT_MS, + }); if (r.status !== 0) { throw new Error(`ps1 execpath probe exited ${r.status}: ${r.stderr}`); } @@ -365,7 +403,7 @@ function checkExecPathIsBundledBun(fixture) { }); } -function checkProcessTreeNoNode(fixture) { +async function checkProcessTreeNoNode(fixture) { // CMD variant const cmdPromise = runStep( 'cmd-process-tree-bun-present-node-absent', @@ -402,8 +440,10 @@ function checkProcessTreeNoNode(fixture) { 'pwsh-process-tree-bun-present-node-absent', async () => { const ps1Path = join(fixture.fixtureDir, 'llxprt.ps1'); + // Resolve PowerShell robustly (PWSH_PATH -> pwsh.exe -> powershell.exe). + const pwshExe = resolvePwsh(); const child = spawn( - 'powershell', + pwshExe, [ '-NoProfile', '-NonInteractive', @@ -543,7 +583,9 @@ function checkNpmExecEphemeral(tempDir, replicaTarball) { join(cleanDir, 'package.json'), JSON.stringify({ name: 'clean-consumer', version: '0.0.0' }, null, 2), ); - const npmCache = join(tempDir, 'npm-exec-cache'); + // No per-fixture --cache: inherit the warmed default npm cache (populated + // by `npm ci`). An isolated empty cache forced re-fetches and caused the + // ETIMEDOUT seen in CI run 29850614559. const { command, args } = npmInvocation([ 'exec', '--package', @@ -555,16 +597,18 @@ function checkNpmExecEphemeral(tempDir, replicaTarball) { const r = spawnSync(command, args, { cwd: cleanDir, encoding: 'utf8', - timeout: 300_000, - maxBuffer: 64 * 1024 * 1024, - env: { ...process.env, npm_config_cache: npmCache }, + timeout: NPM_EXEC_TIMEOUT_MS, + maxBuffer: SPAWN_MAX_BUFFER, }); if (r.error) { throw new Error(`npm exec spawn failed: ${r.error.message}`); } + if (r.signal) { + throw new Error(`npm exec terminated by signal ${r.signal}`); + } if (r.status !== 0) { throw new Error( - `npm exec --version failed (exit ${r.status}, signal=${r.signal ?? 'none'}): ${r.stderr || r.stdout}`, + `npm exec --version failed (exit ${r.status}): ${r.stderr || r.stdout}`, ); } assert( diff --git a/scripts/windows-installed-command-smoke/constants.cjs b/scripts/windows-installed-command-smoke/constants.cjs index 68c0a3c69a..0ca79cb7b4 100644 --- a/scripts/windows-installed-command-smoke/constants.cjs +++ b/scripts/windows-installed-command-smoke/constants.cjs @@ -1,19 +1,74 @@ 'use strict'; +const path = require('node:path'); + /** * Shared constants for the Windows installed-command smoke harness. */ -const CONSTRAINED_PATH = - 'C:\\Windows\\System32;C:\\Windows;C:\\Windows\\System32\\Wbem'; +// Build the constrained PATH from process.env.SystemRoot so non-English or +// non-default Windows installations are handled correctly. Falls back to +// C:\Windows only if SystemRoot is unset (extremely rare). +const systemRoot = process.env.SystemRoot || 'C:\\Windows'; +const CONSTRAINED_PATH = [ + path.join(systemRoot, 'System32'), + systemRoot, + path.join(systemRoot, 'System32', 'Wbem'), +].join(';'); const OWNERSHIP_SENTINEL = 'LLXPRT_NATIVE_LAUNCHER owned by @vybestack/llxprt-code'; const VERSION_RE = /^\d+\.\d+\.\d+/; const LAUNCH_ERROR_EXIT = 43; +/** + * The exact Bun version expected to be bundled, as declared by the CLI + * manifest ("bun" field in packages/cli/package.json). The smoke asserts the + * installed bun.exe reports this version so a partial/incorrect install (e.g. + * a non-Windows bun binary from a timed-out install) is caught explicitly. + */ +const EXPECTED_BUN_VERSION = '1.3.14'; + +/** + * Installer/operation timeouts. All are env-configurable so a slow runner can + * raise them without a code change, while staying well under the 60-minute job + * timeout. Default values are deliberately generous because npm installs from a + * warmed cache are fast on a warm runner but the first run pays cold-cache + Bun + * postinstall costs. + * + * LLXPRT_SMOKE_* env vars are intentionally prefixed to avoid collisions. + */ +function readEnvMs(name, defaultMs) { + const raw = process.env[name]; + if (!raw) return defaultMs; + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) return defaultMs; + return n; +} + +// 8 minutes for a single npm install (global/local/exec). The warmed cache +// makes this fast, but give headroom for cold-cache first-run + Bun postinstall. +const INSTALL_TIMEOUT_MS = readEnvMs( + 'LLXPRT_SMOKE_INSTALL_TIMEOUT_MS', + 480_000, +); +// 15 minutes for npm exec (npx) which can populate its own cache. +const NPM_EXEC_TIMEOUT_MS = readEnvMs( + 'LLXPRT_SMOKE_NPM_EXEC_TIMEOUT_MS', + 900_000, +); +// Per-launcher behavioral probe (fast: just bun -> index.ts). +const PROBE_TIMEOUT_MS = readEnvMs('LLXPRT_SMOKE_PROBE_TIMEOUT_MS', 30_000); +// Version probe (fast). +const VERSION_TIMEOUT_MS = readEnvMs('LLXPRT_SMOKE_VERSION_TIMEOUT_MS', 30_000); + module.exports = { CONSTRAINED_PATH, OWNERSHIP_SENTINEL, VERSION_RE, LAUNCH_ERROR_EXIT, + EXPECTED_BUN_VERSION, + INSTALL_TIMEOUT_MS, + NPM_EXEC_TIMEOUT_MS, + PROBE_TIMEOUT_MS, + VERSION_TIMEOUT_MS, }; diff --git a/scripts/windows-installed-command-smoke/install-helpers.cjs b/scripts/windows-installed-command-smoke/install-helpers.cjs index ce7e848988..5692f39859 100644 --- a/scripts/windows-installed-command-smoke/install-helpers.cjs +++ b/scripts/windows-installed-command-smoke/install-helpers.cjs @@ -4,43 +4,76 @@ * npm install helpers for the Windows smoke: global install, local install, * and local cmd version verification. These are separated from the behavioral * checks so the install lifecycle can be reused independently. + * + * Cache policy (root cause A, CI run 29850614559): + * Previous versions passed `--cache ` to each install, + * creating a per-fixture ISOLATED EMPTY cache. That forced every install to + * re-fetch from the registry, blowing through the synchronous 180s timeout + * and producing ETIMEDOUT cascades. The workflow already warms the standard + * npm cache via `npm ci`. We now OMIT `--cache` so installs inherit the + * warmed default cache, making them fast and reliable. + * + * Fail-fast (root cause G): + * globalInstall and localInstall are REQUIRED setup steps. A failure must + * abort dependent checks, not cascade into dozens of "launcher not found" + * failures. They use runRequiredStep (rethrows) instead of runStep. */ const { spawnSync } = require('node:child_process'); const { existsSync, mkdirSync, writeFileSync } = require('node:fs'); const { join } = require('node:path'); -const { assert, runStep } = require('./assert.cjs'); -const { CONSTRAINED_PATH } = require('./constants.cjs'); +const { assert, runStep, runRequiredStep } = require('./assert.cjs'); +const { + CONSTRAINED_PATH, + INSTALL_TIMEOUT_MS, + VERSION_TIMEOUT_MS, + EXPECTED_BUN_VERSION, +} = require('./constants.cjs'); const { npmInvocation } = require('../lib/npm-command.cjs'); +const { assertBundledBunHealthy } = require('./bun-validation.cjs'); + +// Shared maxBuffer for npm install/exec captures. npm/tar can emit verbose +// output that exceeds Node's 1MB default; a single shared constant keeps this +// consistent across globalInstall, localInstall, and the checks.cjs npm exec +// helper. +const SPAWN_MAX_BUFFER = 64 * 1024 * 1024; + +/** + * Builds the npm install argument list WITHOUT an explicit `--cache` flag so + * the install inherits the warmed default npm cache (the same one `npm ci` + * populated in the workflow). An empty isolated per-fixture cache forced + * re-fetches and caused the ETIMEDOUT cascades seen in CI run 29850614559. + */ +function buildInstallArgs(extraArgs) { + return ['install', ...extraArgs, '--loglevel', 'error']; +} function globalInstall(tempDir, replicaTarball) { let prefix; - runStep('global-install', () => { + // REQUIRED: a global-install failure must abort the entire smoke, not + // cascade into 30 downstream "launcher not found" failures. + runRequiredStep('global-install', () => { 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, - ]); + // No --cache: inherit the warmed default npm cache populated by `npm ci`. + const { command, args } = npmInvocation( + buildInstallArgs(['--global', '--prefix', prefix, replicaTarball]), + ); const r = spawnSync(command, args, { encoding: 'utf8', - timeout: 180_000, - maxBuffer: 64 * 1024 * 1024, + timeout: INSTALL_TIMEOUT_MS, + maxBuffer: SPAWN_MAX_BUFFER, }); if (r.error) { throw new Error(`npm global install spawn failed: ${r.error.message}`); } + if (r.signal) { + throw new Error(`npm global install terminated by signal ${r.signal}`); + } if (r.status !== 0) { throw new Error( - `npm global install failed (exit ${r.status}, signal=${r.signal ?? 'none'}): ${r.stderr || r.stdout}`, + `npm global install failed (exit ${r.status}): ${r.stderr || r.stdout}`, ); } }); @@ -49,33 +82,32 @@ function globalInstall(tempDir, replicaTarball) { function localInstall(tempDir, replicaTarball) { let consumerDir; - runStep('local-install', () => { + // REQUIRED: a local-install failure is a setup failure for + // local-cmd-version; it must not silently continue. + runRequiredStep('local-install', () => { 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, - ]); + // No --cache: inherit the warmed default npm cache. + const { command, args } = npmInvocation(buildInstallArgs([replicaTarball])); const r = spawnSync(command, args, { cwd: consumerDir, encoding: 'utf8', - timeout: 180_000, - maxBuffer: 64 * 1024 * 1024, + timeout: INSTALL_TIMEOUT_MS, + maxBuffer: SPAWN_MAX_BUFFER, }); if (r.error) { throw new Error(`npm local install spawn failed: ${r.error.message}`); } + if (r.signal) { + throw new Error(`npm local install terminated by signal ${r.signal}`); + } if (r.status !== 0) { throw new Error( - `npm local install failed (exit ${r.status}, signal=${r.signal ?? 'none'}): ${r.stderr || r.stdout}`, + `npm local install failed (exit ${r.status}): ${r.stderr || r.stdout}`, ); } }); @@ -88,9 +120,17 @@ function checkLocalCmdVersion(consumerDir) { assert(existsSync(cmdPath), `local cmd launcher not found: ${cmdPath}`); const r = spawnSync('cmd', ['/c', cmdPath, '--version'], { encoding: 'utf8', - timeout: 30_000, + timeout: VERSION_TIMEOUT_MS, env: { ...process.env, PATH: CONSTRAINED_PATH }, }); + // Check r.error before r.status: if cmd.exe cannot be spawned, status is + // null and stderr is undefined, producing a misleading message otherwise. + if (r.error) { + throw new Error(`local cmd --version spawn failed: ${r.error.message}`); + } + if (r.signal) { + throw new Error(`local cmd --version terminated by signal ${r.signal}`); + } if (r.status !== 0) { throw new Error(`local cmd --version exited ${r.status}: ${r.stderr}`); } @@ -104,8 +144,22 @@ function checkPackageLocalBun( ) { runStep('package-local-bun-exists', () => { const packageRoot = findInstalledPackageRoot(prefix); + if (!packageRoot || typeof packageRoot !== 'string') { + throw new Error( + `findInstalledPackageRoot returned an invalid path: ${JSON.stringify(packageRoot)}`, + ); + } const bunExe = findBundledBun(packageRoot); + if (!bunExe || typeof bunExe !== 'string') { + throw new Error( + `findBundledBun returned an invalid path: ${JSON.stringify(bunExe)}`, + ); + } assert(existsSync(bunExe), `package-local bun.exe not found: ${bunExe}`); + // Verify the globally-installed bun.exe is a real Windows PE binary + // reporting the exact expected version (root cause J). This catches a + // partial/timed-out install that left a non-Windows or wrong-version binary. + assertBundledBunHealthy(bunExe, EXPECTED_BUN_VERSION); }); } @@ -114,4 +168,6 @@ module.exports = { localInstall, checkLocalCmdVersion, checkPackageLocalBun, + SPAWN_MAX_BUFFER, + buildInstallArgs, }; diff --git a/scripts/windows-installed-command-smoke/launcher-invocation.cjs b/scripts/windows-installed-command-smoke/launcher-invocation.cjs index 607d91db7b..3bddc4f77d 100644 --- a/scripts/windows-installed-command-smoke/launcher-invocation.cjs +++ b/scripts/windows-installed-command-smoke/launcher-invocation.cjs @@ -24,10 +24,17 @@ * a batch file. Delayed expansion (!VAR!) is off by default in batch files * unless `setlocal enabledelayedexpansion` is used; the generated launcher * does not enable it, so ! is not expanded. We double % and leave ! as-is. + * + * PowerShell resolution (root cause C, CI run 29850614559): + * windows-latest ships PowerShell 7 (pwsh.exe) but NOT legacy `powershell` + * on PATH. The PowerShell executable is resolved via resolvePwsh() (prefers + * PWSH_PATH, then pwsh.exe via where.exe, then powershell.exe) so the + * harness works on the actual runner image. */ const { spawnSync } = require('node:child_process'); const { CONSTRAINED_PATH } = require('./constants.cjs'); +const { resolvePwsh } = require('./pwsh-resolver.cjs'); function probeArg(request) { return 'LLXPRT_PROBE=' + JSON.stringify(request); @@ -123,8 +130,11 @@ function invokeCmd(cmdPath, args, opts) { function invokePwsh(ps1Path, args, opts) { const argString = args.map((a) => pwshQuote(a)).join(' '); + // Resolve PowerShell robustly: PWSH_PATH, then pwsh.exe, then powershell.exe. + // Hardcoding 'powershell' failed with ENOENT on windows-latest (run 29850614559). + const pwshExe = resolvePwsh(); const r = spawnSync( - 'powershell', + pwshExe, [ '-NoProfile', '-NonInteractive', diff --git a/scripts/windows-installed-command-smoke/package-layout.cjs b/scripts/windows-installed-command-smoke/package-layout.cjs index e627240a4c..c0570c017f 100644 --- a/scripts/windows-installed-command-smoke/package-layout.cjs +++ b/scripts/windows-installed-command-smoke/package-layout.cjs @@ -5,7 +5,7 @@ * global prefix, locate the bundled bun.exe, and build a TEMP probe fixture. */ -const { existsSync } = require('node:fs'); +const { existsSync, rmSync, cpSync } = require('node:fs'); const { join } = require('node:path'); function findInstalledPackageRoot(prefix) { @@ -50,12 +50,31 @@ function samePath(a, b) { } function copyTree(src, dest) { - const { cpSync } = require('node:fs'); - const path = require('node:path'); - cpSync(src, dest, { - recursive: true, - filter: (s) => !s.includes('node_modules' + path.sep + '.bin'), - }); + // Validate src exists before copying so a missing source produces a clear + // error rather than a partial-copy failure from cpSync. + if (!existsSync(src)) { + throw new Error(`copyTree: source does not exist: ${src}`); + } + try { + cpSync(src, dest, { + recursive: true, + // Separator-neutral .bin exclusion: cpSync may pass paths with either + // forward or backslash separators on Windows, so normalize before + // checking. + filter: (s) => { + const normalized = s.replace(/\\/g, '/'); + return !normalized.includes('node_modules/.bin'); + }, + }); + } catch (e) { + // Clean up any partial copy so a failed run does not leave a corrupt dest. + try { + rmSync(dest, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + throw new Error(`copyTree: failed to copy ${src} -> ${dest}: ${e.message}`); + } } module.exports = { diff --git a/scripts/windows-installed-command-smoke/process-helpers.cjs b/scripts/windows-installed-command-smoke/process-helpers.cjs index 5c913b3fba..7d24795223 100644 --- a/scripts/windows-installed-command-smoke/process-helpers.cjs +++ b/scripts/windows-installed-command-smoke/process-helpers.cjs @@ -4,9 +4,13 @@ * Process-tree inspection and cross-platform sleep helpers for the Windows * smoke. These use PowerShell/CIM to enumerate descendants via a visited-set * BFS that does not silently truncate. + * + * The PowerShell executable is resolved via resolvePwsh() (root cause C) so + * the harness works on windows-latest where only pwsh.exe is present. */ const { spawnSync, spawn } = require('node:child_process'); +const { resolvePwsh } = require('./pwsh-resolver.cjs'); /** * Validates that a value is a positive integer PID suitable for interpolation @@ -186,7 +190,9 @@ function inspectProcessTreeSync(rootPid) { `}`, `Get-Descendants ${rootPid} | Select-Object ProcessId,Name | ConvertTo-Json -Compress`, ].join('\n'); - const ps = spawnSync('powershell', ['-NoProfile', '-Command', script], { + // Resolve PowerShell robustly (PWSH_PATH -> pwsh.exe -> powershell.exe). + const pwshExe = resolvePwsh(); + const ps = spawnSync(pwshExe, ['-NoProfile', '-Command', script], { encoding: 'utf8', timeout: 15_000, }); @@ -195,9 +201,14 @@ function inspectProcessTreeSync(rootPid) { `inspectProcessTreeSync: PowerShell spawn failed: ${ps.error.message}`, ); } + if (ps.signal) { + throw new Error( + `inspectProcessTreeSync: PowerShell terminated by signal ${ps.signal}`, + ); + } if (ps.status !== 0) { throw new Error( - `inspectProcessTreeSync: PowerShell exited ${ps.status} (signal=${ps.signal ?? 'none'}): ${ps.stderr || ps.stdout}`, + `inspectProcessTreeSync: PowerShell exited ${ps.status}: ${ps.stderr || ps.stdout}`, ); } const descendants = []; diff --git a/scripts/windows-installed-command-smoke/pwsh-resolver.cjs b/scripts/windows-installed-command-smoke/pwsh-resolver.cjs new file mode 100644 index 0000000000..8723c809e9 --- /dev/null +++ b/scripts/windows-installed-command-smoke/pwsh-resolver.cjs @@ -0,0 +1,107 @@ +'use strict'; + +/** + * Robust PowerShell executable resolver for the Windows installed-command + * smoke harness. + * + * Background (CI run 29850614559): + * windows-latest runners ship PowerShell 7 (`pwsh.exe`) but do NOT ship + * Windows PowerShell 5.1 (`powershell.exe`) on PATH. The previous harness + * hardcoded `powershell`, so every PowerShell-gated step failed with + * `spawnSync powershell ENOENT`. + * + * Resolution order (highest priority first): + * 1. process.env.PWSH_PATH — set explicitly by the workflow from + * `(Get-Command pwsh).Source`. This is the most reliable source because + * it is derived from the actual runner toolchain before the smoke runs. + * 2. `pwsh.exe` — PowerShell 7+, present on windows-latest. + * 3. `powershell.exe` — legacy Windows PowerShell, present on some images. + * + * A bare name (`pwsh.exe`) relies on PATH lookup. The CONSTRAINED_PATH used by + * the smoke intentionally strips most of PATH to prove the launcher does not + * depend on ambient tooling, so a bare name can fail even when the binary + * exists. To stay robust under a constrained PATH, resolve an ABSOLUTE path via + * `where.exe` before falling back to the bare name. This keeps the constrained + * PATH for the LAUNCHED command while still locating the shell reliably. + */ + +const { spawnSync } = require('node:child_process'); + +/** + * @typedef {{ + * platform?: string; + * env?: NodeJS.ProcessEnv; + * spawnSync?: typeof import('node:child_process').spawnSync; + * }} ResolverOptions + */ + +/** + * Resolves an absolute path to a command via `where.exe` on Windows. Returns + * null when the command is not found or this is not Windows. `where.exe` + * searches the REAL process PATH (the resolver is invoked before the + * constrained-PATH spawn), so it sees the full runner PATH. + * + * @param {string} command - bare command name, e.g. 'pwsh.exe'. + * @param {ResolverOptions} [options] + * @returns {string | null} + */ +function whereResolve(command, options) { + const platform = (options && options.platform) || process.platform; + if (platform !== 'win32') return null; + const spawn = (options && options.spawnSync) || spawnSync; + const r = spawn('where.exe', [command], { + encoding: 'utf8', + timeout: 5_000, + windowsHide: true, + }); + if (r.error || r.status !== 0 || !r.stdout) { + return null; + } + const first = String(r.stdout).trim().split(/\r?\n/)[0]; + return first || null; +} + +/** + * Resolves the PowerShell executable to use for spawning .ps1 launchers and + * process-tree inspection. + * + * @param {ResolverOptions} [options] + * @returns {string} the PowerShell executable (absolute when resolvable, + * otherwise the bare fallback name so PATH lookup is attempted last). + */ +function resolvePwsh(options) { + const platform = (options && options.platform) || process.platform; + if (platform !== 'win32') { + // Non-Windows: there is no pwsh.exe. Tests inject options to exercise the + // Windows branches; on real POSIX hosts this function is never called by + // the runtime (the top-level smoke exits 0 on non-Windows). + return 'pwsh'; + } + const env = (options && options.env) || process.env; + + // 1. Explicit env override — highest trust. + if (env.PWSH_PATH) { + return env.PWSH_PATH; + } + + // 2. pwsh.exe (PowerShell 7+) — present on windows-latest. + const pwshAbs = whereResolve('pwsh.exe', options); + if (pwshAbs) { + return pwshAbs; + } + + // 3. powershell.exe (legacy Windows PowerShell). + const powershellAbs = whereResolve('powershell.exe', options); + if (powershellAbs) { + return powershellAbs; + } + + // Last resort: bare names so Node attempts a PATH lookup at spawn time. + // Prefer pwsh.exe (PowerShell 7+) since windows-latest ships it. + return 'pwsh.exe'; +} + +module.exports = { + resolvePwsh, + whereResolve, +}; From 087526e1c72095e307d53ca512419480c80a7c83 Mon Sep 17 00:00:00 2001 From: acoliver Date: Tue, 21 Jul 2026 18:48:51 -0300 Subject: [PATCH 06/18] Resolve remaining launcher review findings --- packages/cli/bin/llxprt | 2 -- scripts/tests/issue-2603-launcher.test.ts | 13 +------------ scripts/tests/issue-2603-release-pack.cjs | 7 ------- 3 files changed, 1 insertion(+), 21 deletions(-) diff --git a/packages/cli/bin/llxprt b/packages/cli/bin/llxprt index aabad82a36..4e729ed1c0 100755 --- a/packages/cli/bin/llxprt +++ b/packages/cli/bin/llxprt @@ -25,10 +25,8 @@ _llxprt_script_dir=$(cd -- "$(dirname -- "$_llxprt_self")" 2>/dev/null && pwd) | # partial install cannot silently fall through to an unrelated Bun. _llxprt_pkg_json=$_llxprt_script_dir/../package.json _llxprt_bun_pin="" -_llxprt_pkg_name="" if [ -f "$_llxprt_pkg_json" ]; then _llxprt_bun_pin=$(sed -n 's/.*"bun"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' -- "$_llxprt_pkg_json" 2>/dev/null | head -n1) - _llxprt_pkg_name=$(sed -n 's/.*"name"[[: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 diff --git a/scripts/tests/issue-2603-launcher.test.ts b/scripts/tests/issue-2603-launcher.test.ts index 3199b724cc..9255fb10a1 100644 --- a/scripts/tests/issue-2603-launcher.test.ts +++ b/scripts/tests/issue-2603-launcher.test.ts @@ -412,18 +412,7 @@ describe('POSIX launcher execution behavior', () => { const bunPath = ensureBun(); copyFileSync(bunPath, join(hoistedBunDir, 'bun.exe')); - // Write a matching bun package.json version. Read the real version from - // the repo's bun package if available; otherwise skip version pin. - const bunPkgPath = join(repoRoot, 'node_modules', 'bun', 'package.json'); - let bunVersion = '1.3.14'; - if (existsSync(bunPkgPath)) { - try { - const bunPkg = JSON.parse(readFileSync(bunPkgPath, 'utf8')); - if (typeof bunPkg.version === 'string') bunVersion = bunPkg.version; - } catch { - // keep default - } - } + const bunVersion = realBunVersion(); writeFileSync( join(consumerDir, 'node_modules', 'bun', 'package.json'), JSON.stringify({ name: 'bun', version: bunVersion }, null, 2), diff --git a/scripts/tests/issue-2603-release-pack.cjs b/scripts/tests/issue-2603-release-pack.cjs index e832e70c7c..a17e8118ab 100644 --- a/scripts/tests/issue-2603-release-pack.cjs +++ b/scripts/tests/issue-2603-release-pack.cjs @@ -78,12 +78,6 @@ function processCacheDir(repoRoot) { ); } -const releaseCacheDir = processCacheDir( - // Compute lazily at module load using the CWD-derived repoRoot. Callers pass - // the real repoRoot into packReleaseLikeCli, which re-derives as needed. - process.cwd(), -); - const NON_NPM_RELEASE_PACKAGES = new Set([ '@vybestack/llxprt-code-test-utils', '@vybestack/llxprt-code-a2a-server', @@ -468,7 +462,6 @@ function packCli(workCopy, cacheDir) { module.exports = { packReleaseLikeCli, - releaseCacheDir, readCliManifest, findTarballName, shouldCopyRepoEntry, From 7b81386abb63211221d35a261d3edac1ec6076b6 Mon Sep 17 00:00:00 2001 From: acoliver Date: Tue, 21 Jul 2026 20:19:21 -0300 Subject: [PATCH 07/18] Fix Windows installed launcher verification --- ...issue-2603-windows-lineage-helpers.test.ts | 444 ++++++++++++++++++ scripts/tests/issue-2603-windows-probe.ts | 5 + .../checks.cjs | 52 +- .../launcher-invocation.cjs | 56 ++- .../package-layout.cjs | 35 +- .../process-helpers.cjs | 174 +++++++ 6 files changed, 725 insertions(+), 41 deletions(-) create mode 100644 scripts/tests/issue-2603-windows-lineage-helpers.test.ts diff --git a/scripts/tests/issue-2603-windows-lineage-helpers.test.ts b/scripts/tests/issue-2603-windows-lineage-helpers.test.ts new file mode 100644 index 0000000000..2ff2dda0eb --- /dev/null +++ b/scripts/tests/issue-2603-windows-lineage-helpers.test.ts @@ -0,0 +1,444 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + * + * Focused unit tests for the five Windows installed-command CI remediations + * (PR 2610). These cover the pure/helper contracts: + * - cmdInvocationArgs: direct cmd.exe /d /s /c + Windows verbatim quoting + * - samePath: exact path normalization via injected realpath (8.3 → long) + * - probe payload PID shape (pid + ppid present) + * - validateProcessLineage: root reached, Bun expected, Node rejected + * - walkProcessLineage: bounded ancestry walk with an injected query seam + * + * The core Windows behavior remains a CI behavioral test + * (windows-installed-command.yml); these do NOT fake it locally. + */ + +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); + +const launcherInvocation = nodeRequire( + join( + repoRoot, + 'scripts', + 'windows-installed-command-smoke', + 'launcher-invocation.cjs', + ), +) as { + cmdInvocationArgs: (cmdPath: string, args: string[]) => string[]; + cmdQuote: (s: string) => string; + spawnCmdLongRunning: unknown; +}; + +const packageLayout = nodeRequire( + join( + repoRoot, + 'scripts', + 'windows-installed-command-smoke', + 'package-layout.cjs', + ), +) as { + samePath: ( + a: string, + b: string, + options?: { realpathSync?: (p: string) => string }, + ) => boolean; +}; + +const processHelpers = nodeRequire( + join( + repoRoot, + 'scripts', + 'windows-installed-command-smoke', + 'process-helpers.cjs', + ), +) as { + validateProcessLineage: ( + chain: Array<{ pid: number; ppid: number; name: string }>, + rootPid: number, + ) => { ok: true; chain: Array<{ pid: number; ppid: number; name: string }> }; + walkProcessLineage: ( + probePid: number, + rootPid: number, + options?: { + queryProcessEntry?: ( + pid: number, + ) => { pid: number; ppid: number; name: string } | null; + }, + ) => Array<{ pid: number; ppid: number; name: string }>; + MAX_ANCESTRY_HOPS: number; + assertValidPid: (pid: unknown) => void; +}; + +describe('cmdInvocationArgs', () => { + it('produces a direct cmd.exe /d /s /c argv array', () => { + const argv = launcherInvocation.cmdInvocationArgs('C:\\app\\llxprt.cmd', [ + '--version', + ]); + expect(argv[0]).toBe('/d'); + expect(argv[1]).toBe('/s'); + expect(argv[2]).toBe('/c'); + }); + + it('wraps the full command in one quoted token as the 4th argv element', () => { + const argv = launcherInvocation.cmdInvocationArgs('C:\\app\\llxprt.cmd', [ + '--version', + ]); + expect(argv.length).toBe(4); + // The /c argument is a single double-quoted command string built from + // cmdQuote-wrapped pieces joined by spaces. + expect(argv[3].startsWith('"')).toBe(true); + expect(argv[3].endsWith('"')).toBe(true); + }); + + it('keeps a hostile metacharacter argument as a separate quoted piece', () => { + const hostile = '& echo INJECTED > "C:\\evil.txt"'; + const argv = launcherInvocation.cmdInvocationArgs('C:\\app\\llxprt.cmd', [ + 'LLXPRT_PROBE_B64=abc', + hostile, + ]); + const command = argv[3]; + // The command string contains both the probe arg and the hostile arg, + // each separately cmdQuote-wrapped, joined by a space. + expect(command).toContain( + launcherInvocation.cmdQuote('LLXPRT_PROBE_B64=abc'), + ); + expect(command).toContain(launcherInvocation.cmdQuote(hostile)); + }); + + it('doubles internal double quotes in the hostile arg (cmd quoting rule)', () => { + const hostile = 'a"b'; + const argv = launcherInvocation.cmdInvocationArgs('C:\\app\\llxprt.cmd', [ + hostile, + ]); + // cmdQuote('a"b') === '"a""b"', embedded inside the outer /c quotes. + expect(argv[3]).toContain('"a""b"'); + }); + + it('doubles percent signs so a literal % survives the batch parser', () => { + const argv = launcherInvocation.cmdInvocationArgs('C:\\app\\llxprt.cmd', [ + '100%done', + ]); + expect(argv[3]).toContain('100%%done'); + }); + + it('does NOT inject an unquoted metacharacter sentinel into the command', () => { + // The hostile arg must be fully cmdQuote-wrapped so & cannot act as a + // command separator at the cmd.exe level. + const argv = launcherInvocation.cmdInvocationArgs('C:\\app\\llxprt.cmd', [ + '& echo INJECTED', + ]); + const command = argv[3]; + // The raw & should only ever appear inside quotes within the command. + // Verify the quoted form is present. + expect(command).toContain(launcherInvocation.cmdQuote('& echo INJECTED')); + }); + + it('preserves the base64url control payload unmodified', () => { + const payload = 'LLXPRT_PROBE_B64=eyJleGl0Ijo5MDA5fQ'; + const argv = launcherInvocation.cmdInvocationArgs('C:\\app\\llxprt.cmd', [ + payload, + ]); + expect(argv[3]).toContain(payload); + }); +}); + +describe('samePath', () => { + it('matches identical paths', () => { + const identity = (p: string): string => p; + expect( + packageLayout.samePath('C:\\app\\bun.exe', 'C:\\app\\bun.exe', { + realpathSync: identity, + }), + ).toBe(true); + }); + + it('matches when only the case differs (Windows is case-insensitive)', () => { + const identity = (p: string): string => p; + expect( + packageLayout.samePath('C:\\App\\Bun.exe', 'c:\\app\\bun.exe', { + realpathSync: identity, + }), + ).toBe(true); + }); + + it('matches when only the separator differs (backslash vs forward slash)', () => { + const identity = (p: string): string => p; + expect( + packageLayout.samePath('C:\\app\\bun.exe', 'C:/app/bun.exe', { + realpathSync: identity, + }), + ).toBe(true); + }); + + it('canonicalizes an 8.3 short path to the long path via injected realpath', () => { + // Simulate realpath resolving the 8.3 short name to the long name. + const shortToLong = (p: string): string => { + if (p.toUpperCase() === 'C:\\PROGRA~2\\APP\\BUN.exe'.toUpperCase()) { + return 'C:\\Program Files (x86)\\App\\bun.exe'; + } + return p; + }; + expect( + packageLayout.samePath( + 'C:\\PROGRA~2\\APP\\BUN.exe', + 'C:\\Program Files (x86)\\App\\bun.exe', + { realpathSync: shortToLong }, + ), + ).toBe(true); + }); + + it('rejects different paths', () => { + const identity = (p: string): string => p; + expect( + packageLayout.samePath('C:\\app\\bun.exe', 'C:\\app\\node.exe', { + realpathSync: identity, + }), + ).toBe(false); + }); + + it('strips trailing slashes before comparing', () => { + const identity = (p: string): string => p; + expect( + packageLayout.samePath('C:\\app\\', 'C:\\app', { + realpathSync: identity, + }), + ).toBe(true); + }); + + it('falls back to the original path when realpath throws (missing path)', () => { + const throwing = (): string => { + throw new Error('ENOENT'); + }; + // Both paths throw, so the originals are compared. Different originals → false. + expect( + packageLayout.samePath('C:\\missing\\a.exe', 'C:\\missing\\b.exe', { + realpathSync: throwing, + }), + ).toBe(false); + // Same originals → true even though realpath throws. + expect( + packageLayout.samePath('C:\\missing\\a.exe', 'C:\\missing\\a.exe', { + realpathSync: throwing, + }), + ).toBe(true); + }); + + it('uses the native realpathSync when no options are provided', () => { + // This exercises the default branch; the two real paths must resolve via + // the host's native realpath. Use the repo's own package.json which exists. + const pkgPath = join(repoRoot, 'package.json'); + expect(packageLayout.samePath(pkgPath, pkgPath)).toBe(true); + }); +}); + +describe('validateProcessLineage', () => { + it('accepts a valid chain that reaches root and contains Bun', () => { + // root is NOT node.exe here — it's the launcher root (the test harness + // Node process). Use a non-node root name. + const launcherRoot = { pid: 6000, ppid: 1, name: 'cmd.exe' }; + const chain = [ + { pid: 5000, ppid: 5001, name: 'bun.exe' }, + { pid: 5001, ppid: 6000, name: 'cmd.exe' }, + launcherRoot, + ]; + const result = processHelpers.validateProcessLineage(chain, 6000); + expect(result.ok).toBe(true); + expect(result.chain).toBe(chain); + }); + + it('throws when the chain does not reach the root', () => { + const chain = [ + { pid: 5000, ppid: 5001, name: 'bun.exe' }, + { pid: 5001, ppid: 5002, name: 'cmd.exe' }, + ]; + expect(() => processHelpers.validateProcessLineage(chain, 9999)).toThrow( + /lineage root not reached/, + ); + }); + + it('throws when Bun is missing from the chain', () => { + const noBun = [ + { pid: 5001, ppid: 5002, name: 'cmd.exe' }, + { pid: 5002, ppid: 1, name: 'cmd.exe' }, + ]; + expect(() => processHelpers.validateProcessLineage(noBun, 5002)).toThrow( + /lineage missing bundled bun/, + ); + }); + + it('throws when node.exe appears in the bounded chain', () => { + // The chain includes node.exe as an intermediary (not the root). The + // launcher must hand off directly to bundled bun.exe, never node. + const withNode = [ + { pid: 5000, ppid: 5100, name: 'bun.exe' }, + { pid: 5100, ppid: 5002, name: 'node.exe' }, + { pid: 5002, ppid: 1, name: 'cmd.exe' }, + ]; + expect(() => processHelpers.validateProcessLineage(withNode, 5002)).toThrow( + /lineage contains node.exe/, + ); + }); + + it('rejects node.exe even at the root (root must be the launcher, not Node)', () => { + // The root is the spawned launcher (cmd.exe/pwsh.exe), never node.exe. + // The walk stops at root precisely because the Node test harness lives + // BEYOND root; node.exe inside the bounded chain (root included) is a bug. + const chain = [ + { pid: 5000, ppid: 5001, name: 'bun.exe' }, + { pid: 5001, ppid: 7000, name: 'cmd.exe' }, + { pid: 7000, ppid: 1, name: 'node.exe' }, + ]; + expect(() => processHelpers.validateProcessLineage(chain, 7000)).toThrow( + /lineage contains node.exe/, + ); + }); + + it('accepts conhost and pwsh intermediary processes', () => { + const chain = [ + { pid: 5000, ppid: 5100, name: 'bun.exe' }, + { pid: 5100, ppid: 5200, name: 'pwsh.exe' }, + { pid: 5200, ppid: 5300, name: 'conhost.exe' }, + { pid: 5300, ppid: 1, name: 'pwsh.exe' }, + ]; + expect(() => + processHelpers.validateProcessLineage(chain, 5300), + ).not.toThrow(); + }); + + it('throws on an empty chain', () => { + expect(() => processHelpers.validateProcessLineage([], 100)).toThrow( + /empty chain/, + ); + }); +}); + +describe('walkProcessLineage', () => { + it('walks from probe PID to root using injected single-PID queries', () => { + // Simulate: probe(5000, bun) → parent(5001, cmd) → root(5002, node harness) + const table = new Map([ + [5000, { pid: 5000, ppid: 5001, name: 'bun.exe' }], + [5001, { pid: 5001, ppid: 5002, name: 'cmd.exe' }], + [5002, { pid: 5002, ppid: 1, name: 'pwsh.exe' }], + ]); + const query = (pid: number) => table.get(pid) ?? null; + const chain = processHelpers.walkProcessLineage(5000, 5002, { + queryProcessEntry: query, + }); + expect(chain.map((e) => e.pid)).toEqual([5000, 5001, 5002]); + }); + + it('stops exactly at the root and does not walk beyond it', () => { + // Root's ppid points to pid 1, but the walk must stop at root (5002) and + // never query pid 1. + const queried: number[] = []; + const table = new Map([ + [5000, { pid: 5000, ppid: 5001, name: 'bun.exe' }], + [5001, { pid: 5001, ppid: 5002, name: 'cmd.exe' }], + [5002, { pid: 5002, ppid: 1, name: 'node.exe' }], + ]); + const query = (pid: number) => { + queried.push(pid); + return table.get(pid) ?? null; + }; + const chain = processHelpers.walkProcessLineage(5000, 5002, { + queryProcessEntry: query, + }); + // The walk queried 5000, 5001, 5002 but NOT 1 (root's ppid). + expect(queried).toEqual([5000, 5001, 5002]); + expect(chain.length).toBe(3); + expect(chain[chain.length - 1].pid).toBe(5002); + }); + + it('throws when a query returns null before reaching root', () => { + const query = (pid: number): null => { + // Simulate the process having exited (CIM returns nothing). + void pid; + return null; + }; + expect(() => + processHelpers.walkProcessLineage(5000, 5002, { + queryProcessEntry: query, + }), + ).toThrow(/could not query pid=5000/); + }); + + it('detects a cycle and throws', () => { + // 5000 → 5001 → 5000 (cycle), root is 9999 (never reached). + const table = new Map([ + [5000, { pid: 5000, ppid: 5001, name: 'bun.exe' }], + [5001, { pid: 5001, ppid: 5000, name: 'cmd.exe' }], + ]); + const query = (pid: number) => table.get(pid) ?? null; + expect(() => + processHelpers.walkProcessLineage(5000, 9999, { + queryProcessEntry: query, + }), + ).toThrow(/cycle detected/); + }); + + it('throws when exceeding MAX_ANCESTRY_HOPS without reaching root', () => { + // Build an infinite chain where every pid's parent is pid+1, and root is + // unreachable (very large). The walk must bail after MAX_ANCESTRY_HOPS. + const query = (pid: number) => ({ + pid, + ppid: pid + 1, + name: 'bun.exe', + }); + const unreachableRoot = 9_999_999; + expect(() => + processHelpers.walkProcessLineage(1, unreachableRoot, { + queryProcessEntry: query, + }), + ).toThrow(/exceeded .* hops/); + }); + + it('rejects an invalid probe PID', () => { + expect(() => + processHelpers.walkProcessLineage(-1, 100, { + queryProcessEntry: () => null, + }), + ).toThrow(/Invalid PID/); + }); + + it('rejects an invalid root PID', () => { + expect(() => + processHelpers.walkProcessLineage(100, 0, { + queryProcessEntry: () => null, + }), + ).toThrow(/Invalid PID/); + }); + + it('handles a single-entry chain where probe PID equals root PID', () => { + const query = (pid: number) => ({ + pid, + ppid: 1, + name: 'bun.exe', + }); + const chain = processHelpers.walkProcessLineage(5000, 5000, { + queryProcessEntry: query, + }); + expect(chain.length).toBe(1); + expect(chain[0].pid).toBe(5000); + }); +}); + +describe('MAX_ANCESTRY_HOPS', () => { + it('is a positive safety bound for ancestry walk depth', () => { + expect(typeof processHelpers.MAX_ANCESTRY_HOPS).toBe('number'); + expect(processHelpers.MAX_ANCESTRY_HOPS).toBeGreaterThan(0); + }); +}); + +describe('spawnCmdLongRunning export', () => { + it('is exported as a function for long-running CMD process spawn', () => { + expect(typeof launcherInvocation.spawnCmdLongRunning).toBe('function'); + }); +}); diff --git a/scripts/tests/issue-2603-windows-probe.ts b/scripts/tests/issue-2603-windows-probe.ts index c4f50af268..8851b4e29c 100644 --- a/scripts/tests/issue-2603-windows-probe.ts +++ b/scripts/tests/issue-2603-windows-probe.ts @@ -108,6 +108,11 @@ async function main(): Promise { execPath: process.execPath, bunVersion: typeof process.versions.bun === 'string' ? process.versions.bun : '', + // Report the Bun process's own PID and its parent PID so the harness can + // walk a bounded ancestry chain back to the spawned launcher root instead + // of relying on a racy descendants-only snapshot. + pid: typeof process.pid === 'number' ? process.pid : null, + ppid: typeof process.ppid === 'number' ? process.ppid : null, }; // Preserve malformed raw diagnostics so the caller can see the unparseable diff --git a/scripts/windows-installed-command-smoke/checks.cjs b/scripts/windows-installed-command-smoke/checks.cjs index f3ddfef5d8..a7b5976b67 100644 --- a/scripts/windows-installed-command-smoke/checks.cjs +++ b/scripts/windows-installed-command-smoke/checks.cjs @@ -33,13 +33,15 @@ const { parseProbeOutput, invokeCmd, invokePwsh, + spawnCmdLongRunning, } = require('./launcher-invocation.cjs'); const { findBundledBun, samePath, copyTree } = require('./package-layout.cjs'); const { - inspectProcessTreeSync, waitForReady, killProcessTree, spawn, + walkProcessLineage, + validateProcessLineage, } = require('./process-helpers.cjs'); const { resolvePwsh } = require('./pwsh-resolver.cjs'); const { assertBundledBunHealthy } = require('./bun-validation.cjs'); @@ -427,24 +429,32 @@ async function checkProcessTreeNoNode(fixture) { 'cmd-process-tree-bun-present-node-absent', async () => { const cmdPath = join(fixture.fixtureDir, 'llxprt.cmd'); - const child = spawn('cmd', ['/c', cmdPath, probeArg({ long: true })], { - stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env, PATH: CONSTRAINED_PATH }, - windowsHide: true, + // Reuse the same cmd.exe /d /s /c direct invocation as invokeCmd so the + // long-running probe exercises the identical direct cmd spawn path + // (windowsVerbatimArguments + cmdInvocationArgs). + const child = spawnCmdLongRunning(cmdPath, [probeArg({ long: true })], { + env: { PATH: CONSTRAINED_PATH }, }); try { // Async readiness polling yields to the event loop so stdout event // handlers fire between checks. - await waitForReady(child, '__LLXPRT_PROBE_LONG_RUNNING__', 12_000); - const treeInfo = inspectProcessTreeSync(child.pid); - assert( - treeInfo.bunPresent, - `cmd bun.exe not found in launcher child tree. descendants=${JSON.stringify(treeInfo.descendants)}`, + const readyOut = await waitForReady( + child, + '__LLXPRT_PROBE_LONG_RUNNING__', + 12_000, ); + // The probe payload precedes the readiness marker. Parse it to obtain + // the Bun process's own pid/ppid so we can walk a deterministic + // ancestry chain rather than racing a descendants snapshot. + const payload = parseProbeOutput(readyOut); + const probePid = payload.pid; assert( - !treeInfo.nodePresent, - `cmd node.exe found in launcher child tree (must be absent). descendants=${JSON.stringify(treeInfo.descendants)}`, + Number.isInteger(probePid), + `cmd probe payload missing integer pid: ${JSON.stringify(payload)}`, ); + const chain = walkProcessLineage(probePid, child.pid); + const result = validateProcessLineage(chain, child.pid); + assert(result.ok, `cmd lineage validation failed unexpectedly`); } finally { // Terminate the entire process tree (taskkill /T /F on Windows), not // just the direct child, so bun.exe descendants are reaped. @@ -476,16 +486,20 @@ async function checkProcessTreeNoNode(fixture) { }, ); try { - await waitForReady(child, '__LLXPRT_PROBE_LONG_RUNNING__', 12_000); - const treeInfo = inspectProcessTreeSync(child.pid); - assert( - treeInfo.bunPresent, - `ps1 bun.exe not found in launcher child tree. descendants=${JSON.stringify(treeInfo.descendants)}`, + const readyOut = await waitForReady( + child, + '__LLXPRT_PROBE_LONG_RUNNING__', + 12_000, ); + const payload = parseProbeOutput(readyOut); + const probePid = payload.pid; assert( - !treeInfo.nodePresent, - `ps1 node.exe found in launcher child tree (must be absent). descendants=${JSON.stringify(treeInfo.descendants)}`, + Number.isInteger(probePid), + `ps1 probe payload missing integer pid: ${JSON.stringify(payload)}`, ); + const chain = walkProcessLineage(probePid, child.pid); + const result = validateProcessLineage(chain, child.pid); + assert(result.ok, `ps1 lineage validation failed unexpectedly`); } finally { killProcessTree(child); } diff --git a/scripts/windows-installed-command-smoke/launcher-invocation.cjs b/scripts/windows-installed-command-smoke/launcher-invocation.cjs index 22da9d68a0..ac59e252fe 100644 --- a/scripts/windows-installed-command-smoke/launcher-invocation.cjs +++ b/scripts/windows-installed-command-smoke/launcher-invocation.cjs @@ -32,7 +32,7 @@ * harness works on the actual runner image. */ -const { spawnSync } = require('node:child_process'); +const { spawnSync, spawn } = require('node:child_process'); const { CONSTRAINED_PATH } = require('./constants.cjs'); const { resolvePwsh } = require('./pwsh-resolver.cjs'); @@ -130,25 +130,20 @@ function powershellInvocationScript(launcherPath, args) { ].join('; '); } +function cmdInvocationArgs(cmdPath, args) { + const command = [cmdQuote(cmdPath), ...args.map(cmdQuote)].join(' '); + return ['/d', '/s', '/c', `"${command}"`]; +} + function invokeCmd(cmdPath, args, opts) { - const pwshExe = resolvePwsh(); - const script = powershellInvocationScript(cmdPath, args); - const r = spawnSync( - pwshExe, - [ - '-NoProfile', - '-NonInteractive', - '-EncodedCommand', - powershellEncodedCommand(script), - ], - { - encoding: 'utf8', - timeout: opts?.timeout ?? 30_000, - input: opts?.input, - env: { ...process.env, PATH: CONSTRAINED_PATH, ...(opts?.env || {}) }, - windowsHide: true, - }, - ); + const r = spawnSync('cmd.exe', cmdInvocationArgs(cmdPath, args), { + encoding: 'utf8', + timeout: opts?.timeout ?? 30_000, + input: opts?.input, + env: { ...process.env, PATH: CONSTRAINED_PATH, ...(opts?.env || {}) }, + windowsHide: true, + windowsVerbatimArguments: true, + }); return validateSpawnResult(`invokeCmd(${cmdPath})`, r); } @@ -174,12 +169,35 @@ function invokePwsh(ps1Path, args, opts) { return validateSpawnResult(`invokePwsh(${ps1Path})`, r); } +/** + * Spawns the CMD launcher as a long-running child process using the same + * cmd.exe /d /s /c invocation and Windows verbatim quoting as invokeCmd, so + * long-running probes (process-tree inspection) exercise the identical direct + * cmd spawn path rather than the racy descendants snapshot. Returns the + * ChildProcess for readiness polling and tree kill. + * + * @param {string} cmdPath - absolute path to the .cmd launcher. + * @param {string[]} args - arguments (e.g. probeArg). + * @param {{ env?: Record }} [opts] + * @returns {import('node:child_process').ChildProcess} + */ +function spawnCmdLongRunning(cmdPath, args, opts) { + return spawn('cmd.exe', cmdInvocationArgs(cmdPath, args), { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, PATH: CONSTRAINED_PATH, ...(opts?.env || {}) }, + windowsHide: true, + windowsVerbatimArguments: true, + }); +} + module.exports = { probeArg, parseProbeOutput, invokeCmd, invokePwsh, + cmdInvocationArgs, cmdQuote, pwshQuote, validateSpawnResult, + spawnCmdLongRunning, }; diff --git a/scripts/windows-installed-command-smoke/package-layout.cjs b/scripts/windows-installed-command-smoke/package-layout.cjs index c0570c017f..c464a259c3 100644 --- a/scripts/windows-installed-command-smoke/package-layout.cjs +++ b/scripts/windows-installed-command-smoke/package-layout.cjs @@ -5,7 +5,7 @@ * global prefix, locate the bundled bun.exe, and build a TEMP probe fixture. */ -const { existsSync, rmSync, cpSync } = require('node:fs'); +const { existsSync, rmSync, cpSync, realpathSync } = require('node:fs'); const { join } = require('node:path'); function findInstalledPackageRoot(prefix) { @@ -38,9 +38,38 @@ function findBundledBun(packageRoot) { throw new Error(`bundled bun.exe not found under ${packageRoot}`); } -function samePath(a, b) { +/** + * Compares two filesystem paths for equality after canonicalization. + * + * Canonicalization uses realpathSync.native to resolve 8.3 short paths, + * symlinks, and junctions to their canonical long form. If the path does not + * exist (or realpath fails), the original path is preserved so that + * missing-path comparisons remain useful for diagnostics. + * + * After realpath, backslashes are normalized to forward slashes, trailing + * slashes are stripped, and the comparison is case-insensitive (Windows + * filesystem semantics). + * + * @param {string} a - first path. + * @param {string} b - second path. + * @param {{ realpathSync?: (p: string) => string }} [options] - injection + * seam for deterministic testing. When omitted, the native realpathSync is + * used, which requires paths to exist on the host filesystem. + * @returns {boolean} true if both paths canonicalize to the same value. + */ +function samePath(a, b, options) { + const realpath = + options && typeof options.realpathSync === 'function' + ? options.realpathSync + : realpathSync.native; const norm = (p) => { - let s = String(p).replace(/\\/g, '/'); + let resolved = String(p); + try { + resolved = realpath(resolved); + } catch { + // Preserve the original path so missing-path comparisons remain useful. + } + let s = resolved.replace(/\\/g, '/'); while (s.endsWith('/')) { s = s.slice(0, -1); } diff --git a/scripts/windows-installed-command-smoke/process-helpers.cjs b/scripts/windows-installed-command-smoke/process-helpers.cjs index 7d24795223..7fceff14e3 100644 --- a/scripts/windows-installed-command-smoke/process-helpers.cjs +++ b/scripts/windows-installed-command-smoke/process-helpers.cjs @@ -232,6 +232,176 @@ function inspectProcessTreeSync(rootPid) { return { bunPresent, nodePresent, descendants }; } +/** + * Maximum number of ancestry hops to walk from the probe PID back to the + * spawned launcher root before declaring an explicit failure. Bounds the + * traversal so a pathological cycle is rejected rather than looping forever. + * Accounts for intermediary processes (cmd/pwsh launchers, conhost) but + * stops at the launcher root — it does NOT walk beyond the root because the + * test harness itself is a Node process. + */ +const MAX_ANCESTRY_HOPS = 32; + +/** + * Pure validation of a bounded ancestry chain. Does not perform any I/O; it + * inspects an in-memory snapshot of the chain walked from the probe PID + * toward the root and asserts the required invariants. + * + * Invariants enforced: + * - rootReached: the chain must terminate at (include) the root PID. + * - bunExpected: the chain must include at least one Bun process name. + * - nodeRejected: the chain must NOT include any node.exe process (the + * launcher must hand off directly to bundled bun.exe, never node). + * + * Each entry is { pid, ppid, name }. The first entry is the probe PID (the + * Bun process that reported the payload); subsequent entries are its + * ancestors walked toward root, up to and including rootPid. + * + * @param {Array<{pid: number, ppid: number, name: string}>} chain - ancestry + * chain entries, oldest ancestor last (rootPid is the last entry when + * rootReached is true). + * @param {number} rootPid - the PID the launcher was spawned with. + * @returns {{ ok: true, chain: Array<{pid: number, ppid: number, name: string}> }} + * @throws {Error} with a descriptive message when any invariant fails. + */ +function validateProcessLineage(chain, rootPid) { + if (!Array.isArray(chain) || chain.length === 0) { + throw new Error('validateProcessLineage: empty chain'); + } + const rootEntry = chain[chain.length - 1]; + const rootReached = rootEntry.pid === rootPid; + const names = chain.map((e) => String(e.name || '').toLowerCase()); + const bunPresent = names.some((n) => n === 'bun.exe' || n === 'bun'); + const nodePresent = names.some((n) => n === 'node.exe' || n === 'node'); + if (!rootReached) { + throw new Error( + `lineage root not reached: last ancestor pid=${rootEntry.pid} (name=${rootEntry.name}), expected root pid=${rootPid}`, + ); + } + if (!bunPresent) { + throw new Error( + `lineage missing bundled bun: no bun.exe/bun in chain ${JSON.stringify(chain)}`, + ); + } + if (nodePresent) { + throw new Error( + `lineage contains node.exe (must be absent): ${JSON.stringify(chain)}`, + ); + } + return { ok: true, chain }; +} + +/** + * Synchronously queries a single process snapshot by PID from CIM via + * PowerShell, returning { pid, ppid, name } or null when the PID is no + * longer alive. Used as the primitive for the bounded ancestry walk. + * + * @param {number} pid + * @param {{ resolvePwsh?: () => string, spawnSync?: typeof import('node:child_process').spawnSync, timeout?: number }} [options] + * @returns {{pid: number, ppid: number, name: string} | null} + */ +function queryProcessEntry(pid, options) { + const _spawnSync = + options && typeof options.spawnSync === 'function' + ? options.spawnSync + : spawnSync; + const _resolvePwsh = + options && typeof options.resolvePwsh === 'function' + ? options.resolvePwsh + : resolvePwsh; + const timeout = options && options.timeout ? options.timeout : 15_000; + assertValidPid(pid); + const script = + `try { ` + + `$p = Get-CimInstance Win32_Process -Filter "ProcessId=${pid}" -ErrorAction Stop; ` + + `if ($p) { ` + + `@{ pid = $p.ProcessId; ppid = $p.ParentProcessId; name = $p.Name } | ConvertTo-Json -Compress ` + + `} else { '' } ` + + `} catch { '' }`; + const r = _spawnSync(_resolvePwsh(), ['-NoProfile', '-Command', script], { + encoding: 'utf8', + timeout, + windowsHide: true, + }); + if (r.error || r.signal || r.status !== 0) { + return null; + } + const raw = (r.stdout || '').trim(); + if (!raw) return null; + try { + const obj = JSON.parse(raw); + const entryPid = Number(obj.pid); + const entryPpid = Number(obj.ppid); + const name = String(obj.name || ''); + if (!Number.isInteger(entryPid) || !Number.isInteger(entryPpid)) { + return null; + } + return { pid: entryPid, ppid: entryPpid, name }; + } catch { + return null; + } +} + +/** + * Walks the process ancestry from the probe-reported PID upward toward the + * spawned launcher root (rootPid), building a bounded chain. Stops when the + * root PID is reached or when MAX_ANCESTRY_HOPS is exceeded (explicit + * failure). Does NOT walk beyond the root because the test harness is a Node + * process. + * + * Uses single-PID CIM queries so the walk is deterministic: each hop queries + * one process rather than racing a descendants snapshot that can miss a + * process that has just exited. + * + * @param {number} probePid - PID reported by the probe payload (the Bun + * process that ran index.ts). + * @param {number} rootPid - PID of the spawned launcher root (child.pid). + * @param {{ queryProcessEntry?: typeof queryProcessEntry }} [options] + * @returns {Array<{pid: number, ppid: number, name: string}>} + * @throws {Error} when a query fails before reaching root or the hop budget + * is exhausted without reaching root. + */ +function walkProcessLineage(probePid, rootPid, options) { + const query = + options && typeof options.queryProcessEntry === 'function' + ? options.queryProcessEntry + : queryProcessEntry; + assertValidPid(probePid); + assertValidPid(rootPid); + const chain = []; + let current = probePid; + let hops = 0; + const visited = new Set(); + while (hops <= MAX_ANCESTRY_HOPS) { + if (visited.has(current)) { + throw new Error( + `walkProcessLineage: cycle detected at pid=${current} before reaching root=${rootPid}`, + ); + } + visited.add(current); + const entry = query(current); + if (!entry) { + throw new Error( + `walkProcessLineage: could not query pid=${current} (hop ${hops}) before reaching root=${rootPid}`, + ); + } + chain.push(entry); + if (entry.pid === rootPid) { + return chain; + } + current = entry.ppid; + if (!Number.isInteger(current) || current <= 0) { + throw new Error( + `walkProcessLineage: invalid ppid=${current} at pid=${entry.pid} before reaching root=${rootPid}`, + ); + } + hops++; + } + throw new Error( + `walkProcessLineage: exceeded ${MAX_ANCESTRY_HOPS} hops without reaching root=${rootPid}`, + ); +} + module.exports = { waitForReady, killProcessTree, @@ -239,4 +409,8 @@ module.exports = { spawn, assertValidPid, MAX_LEVELS, + validateProcessLineage, + walkProcessLineage, + queryProcessEntry, + MAX_ANCESTRY_HOPS, }; From 5489db5301ed0c7a504354e0f68c8c6daebf52ec Mon Sep 17 00:00:00 2001 From: acoliver Date: Tue, 21 Jul 2026 21:56:21 -0300 Subject: [PATCH 08/18] Preserve native Windows launcher status and lineage --- .../tests/issue-2603-smoke-helpers.test.ts | 52 +++++++ ...issue-2603-windows-lineage-helpers.test.ts | 130 ++++++++++++++++++ scripts/tests/issue-2603-windows-probe.ts | 89 +++++++++++- .../checks.cjs | 13 +- .../process-helpers.cjs | 53 +++++-- 5 files changed, 321 insertions(+), 16 deletions(-) diff --git a/scripts/tests/issue-2603-smoke-helpers.test.ts b/scripts/tests/issue-2603-smoke-helpers.test.ts index 39459ea6a6..c9b02c9a15 100644 --- a/scripts/tests/issue-2603-smoke-helpers.test.ts +++ b/scripts/tests/issue-2603-smoke-helpers.test.ts @@ -27,6 +27,7 @@ const launcherInvocation = nodeRequire( 'launcher-invocation.cjs', ), ) as { + probeArg: (request: Record) => string; validateSpawnResult: (label: string, r: T) => T; cmdQuote: (s: string) => string; pwshQuote: (s: string) => string; @@ -179,3 +180,54 @@ describe('MAX_LEVELS', () => { expect(processHelpers.MAX_LEVELS).toBeGreaterThan(0); }); }); + +/** + * 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); + }); +}); diff --git a/scripts/tests/issue-2603-windows-lineage-helpers.test.ts b/scripts/tests/issue-2603-windows-lineage-helpers.test.ts index 2ff2dda0eb..26b83a8940 100644 --- a/scripts/tests/issue-2603-windows-lineage-helpers.test.ts +++ b/scripts/tests/issue-2603-windows-lineage-helpers.test.ts @@ -73,6 +73,14 @@ const processHelpers = nodeRequire( ) => { pid: number; ppid: number; name: string } | null; }, ) => Array<{ pid: number; ppid: number; name: string }>; + queryProcessEntry: ( + pid: number, + options?: { + resolvePwsh?: () => string; + spawnSync?: unknown; + timeout?: number; + }, + ) => { pid: number; ppid: number; name: string } | null; MAX_ANCESTRY_HOPS: number; assertValidPid: (pid: unknown) => void; }; @@ -437,6 +445,128 @@ describe('MAX_ANCESTRY_HOPS', () => { }); }); +/** + * A fake spawnSync that returns a configurable result. Used to exercise the + * real queryProcessEntry diagnostics (throw vs null) without spawning a real + * PowerShell process. + */ +type SpawnResult = { + error?: { message: string }; + signal?: string | null; + status?: number | null; + stdout?: string; + stderr?: string; +}; + +describe('queryProcessEntry diagnostics (no silent null-collapse)', () => { + it('returns the parsed entry when CIM reports the process present', () => { + const fakeSpawn = (): SpawnResult => ({ + status: 0, + signal: null, + stdout: '{"pid":5000,"ppid":5001,"name":"bun.exe"}', + stderr: '', + }); + const entry = processHelpers.queryProcessEntry(5000, { + resolvePwsh: () => 'pwsh.exe', + spawnSync: fakeSpawn, + }); + expect(entry).toEqual({ pid: 5000, ppid: 5001, name: 'bun.exe' }); + }); + + it('returns null ONLY when CIM definitively reports the process absent', () => { + // status 0 + empty stdout = process is no longer alive (the one + // legitimate "not found" outcome for the ancestry walk). + const fakeSpawn = (): SpawnResult => ({ + status: 0, + signal: null, + stdout: '', + stderr: '', + }); + expect( + processHelpers.queryProcessEntry(5000, { + resolvePwsh: () => 'pwsh.exe', + spawnSync: fakeSpawn, + }), + ).toBeNull(); + }); + + it('throws on spawn error (does not collapse to null)', () => { + const fakeSpawn = (): SpawnResult => ({ + error: new Error('ENOENT'), + status: null, + signal: null, + stdout: '', + stderr: '', + }); + expect(() => + processHelpers.queryProcessEntry(5000, { + resolvePwsh: () => 'pwsh.exe', + spawnSync: fakeSpawn, + }), + ).toThrow(/spawn failed for pid=5000: ENOENT/); + }); + + it('throws on signal termination with stderr context', () => { + const fakeSpawn = (): SpawnResult => ({ + status: null, + signal: 'SIGTERM', + stdout: '', + stderr: 'killed', + }); + expect(() => + processHelpers.queryProcessEntry(5000, { + resolvePwsh: () => 'pwsh.exe', + spawnSync: fakeSpawn, + }), + ).toThrow(/terminated by signal SIGTERM for pid=5000/); + }); + + it('throws on non-zero PowerShell exit with stdout/stderr context', () => { + const fakeSpawn = (): SpawnResult => ({ + status: 1, + signal: null, + stdout: 'partial', + stderr: 'pwsh error: bad filter', + }); + expect(() => + processHelpers.queryProcessEntry(5000, { + resolvePwsh: () => 'pwsh.exe', + spawnSync: fakeSpawn, + }), + ).toThrow(/PowerShell exited 1 for pid=5000/); + }); + + it('throws on unparseable JSON with the raw output', () => { + const fakeSpawn = (): SpawnResult => ({ + status: 0, + signal: null, + stdout: 'this is not json', + stderr: '', + }); + expect(() => + processHelpers.queryProcessEntry(5000, { + resolvePwsh: () => 'pwsh.exe', + spawnSync: fakeSpawn, + }), + ).toThrow(/failed to parse JSON for pid=5000/); + }); + + it('throws on non-integer pid/ppid fields', () => { + const fakeSpawn = (): SpawnResult => ({ + status: 0, + signal: null, + stdout: '{"pid":"oops","ppid":5001,"name":"bun.exe"}', + stderr: '', + }); + expect(() => + processHelpers.queryProcessEntry(5000, { + resolvePwsh: () => 'pwsh.exe', + spawnSync: fakeSpawn, + }), + ).toThrow(/non-integer pid\/ppid for pid=5000/); + }); +}); + describe('spawnCmdLongRunning export', () => { it('is exported as a function for long-running CMD process spawn', () => { expect(typeof launcherInvocation.spawnCmdLongRunning).toBe('function'); diff --git a/scripts/tests/issue-2603-windows-probe.ts b/scripts/tests/issue-2603-windows-probe.ts index 8851b4e29c..eb9238816e 100644 --- a/scripts/tests/issue-2603-windows-probe.ts +++ b/scripts/tests/issue-2603-windows-probe.ts @@ -21,6 +21,7 @@ interface ProbeRequest { stdin?: boolean; stderr?: string; exit?: number; + nativeExit?: number; long?: boolean; } @@ -60,6 +61,62 @@ function readStdinSync(): string { } } +/** + * Validates that a value is a finite uint32 integer, returning it as a + * number. Used for nativeExit so a malformed payload cannot drive FFI with + * an out-of-range status. + */ +function asUint32(value: unknown): number | null { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + if (!Number.isInteger(value)) return null; + if (value < 0 || value > 0xffffffff) return null; + return value; +} + +/** + * Terminates the current process with the full uint32 exit status via the + * Windows ExitProcess API, loaded through Bun FFI against kernel32.dll. + * + * Bun's process.exit() truncates the exit code modulo 256 (it is modeled on + * the POSIX _exit contract), so a genuine 32-bit Windows process exit status + * like 9009 cannot be expressed through process.exit(). ExitProcess takes a + * UINT (uint32) and sets the process exit code directly, so the launcher/OS + * path is exercised with the exact value the host observes. + * + * Guarded to win32: on any other platform this throws, since ExitProcess only + * exists in kernel32.dll. The payload must be flushed before calling so the + * parent captures the complete JSON before the process terminates. + */ +function nativeExitWithStatus(status: number): never { + if (process.platform !== 'win32') { + throw new Error( + `nativeExitWithStatus: only supported on win32 (platform=${process.platform})`, + ); + } + // Dynamic require keeps the non-Windows and non-native test paths free of + // bun:ffi so this module loads under Node/vitest on macOS without the FFI + // loader. ExitProcess only exists in kernel32.dll on Windows, so this branch + // is unreachable off win32. bun:ffi has no ESM export; require is the only + // way to load it under Bun. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { dlopen } = require('bun:ffi') as { + dlopen: >( + name: string, + symbols: Fns, + ) => { + symbols: { ExitProcess: (status: number) => undefined }; + close: () => void; + }; + }; + const lib = dlopen('kernel32.dll', { + ExitProcess: { args: ['u32'], returns: 'void' }, + }); + // ExitProcess does not return; this call terminates the process. + lib.symbols.ExitProcess(status >>> 0); + // Unreachable, but satisfies the `never` return type for the analyzer. + throw new Error('nativeExitWithStatus: ExitProcess did not terminate'); +} + /** * Writes the payload and waits for stdout to drain so the parent process * captures the complete JSON before any process.exit. Without this, buffered @@ -132,13 +189,25 @@ async function main(): Promise { await emitAndFlush(payload); process.stdout.write('\n__LLXPRT_PROBE_LONG_RUNNING__\n'); await drainStdout(); + // Keep the process alive with an actual active handle. An unresolved + // promise alone does not keep a JS runtime alive; Bun exits once there + // are no pending handles, which made the reported PID stale by the time + // the harness queried it. A long interval is an active handle that keeps + // the process resident until the signal handler clears it and exits. + const keepAlive = setInterval(() => { + // no-op: the handle's existence, not its work, keeps the process alive + }, 60_000); + // NOTE: do NOT unref() this interval — an unref'd handle does not keep the + // process alive, which is exactly the bug we are fixing. const handler = (): void => { // Drain before exiting so the parent captures the full payload. Without // this, process.exit can truncate buffered stdout on Windows pipes. - // Remove the listeners after settling so the process does not hold - // lingering handlers that would interfere with a clean exit. + // Remove the listeners and clear the keep-alive handle after settling so + // the process does not hold lingering handlers/active handles that would + // interfere with a clean exit. process.removeListener('SIGINT', handler); process.removeListener('SIGTERM', handler); + clearInterval(keepAlive); void drainStdout().then(() => process.exit(0)); }; process.on('SIGINT', handler); @@ -150,6 +219,22 @@ async function main(): Promise { } await emitAndFlush(payload); + // nativeExit routes exit codes that process.exit() cannot express (it + // truncates modulo 256) through the Windows ExitProcess API so the full + // uint32 status is observed by the host. For 9009 specifically, this tests + // the genuine 32-bit Windows process exit path. The payload is flushed + // before the native exit so the parent captures the complete JSON. + const nativeStatus = asUint32(request.nativeExit); + if (nativeStatus !== null) { + if (process.platform === 'win32') { + await drainStdout(); + nativeExitWithStatus(nativeStatus); + } + // Non-Windows: fall back to process.exit with the truncated value so the + // request still terminates (hosted CI is the behavioral source of truth). + await drainStdout(); + process.exit(nativeStatus & 0xff); + } if (typeof request.exit === 'number') { await drainStdout(); process.exit(request.exit); diff --git a/scripts/windows-installed-command-smoke/checks.cjs b/scripts/windows-installed-command-smoke/checks.cjs index a7b5976b67..fbd7acdb5c 100644 --- a/scripts/windows-installed-command-smoke/checks.cjs +++ b/scripts/windows-installed-command-smoke/checks.cjs @@ -366,13 +366,24 @@ function checkStdioForwarding(fixture) { function checkCmdExitCodePreservation(fixture) { runStep('cmd-exit-codes-preserved', () => { const cmdPath = join(fixture.fixtureDir, 'llxprt.cmd'); - for (const code of [0, 1, 5, 7, 42, 193, 9009]) { + // Codes in [0,255] are expressible through Bun's process.exit() and + // exercise the ordinary launcher/OS path. 9009 exceeds the 8-bit range: + // Bun's process.exit() truncates modulo 256, so 9009 is routed through + // nativeExit (Windows ExitProcess via FFI) to test the genuine 32-bit + // Windows process exit status the host observes. Both paths assert the + // exact code via the same r.status === code contract. + for (const code of [0, 1, 5, 7, 42, 193]) { const r = invokeCmd(cmdPath, [probeArg({ exit: code })]); assert( r.status === code, `cmd did not preserve exit ${code}: got ${r.status} (stderr=${JSON.stringify(r.stderr)})`, ); } + const native = invokeCmd(cmdPath, [probeArg({ nativeExit: 9009 })]); + assert( + native.status === 9009, + `cmd did not preserve native exit 9009: got ${native.status} (stderr=${JSON.stringify(native.stderr)})`, + ); }); } diff --git a/scripts/windows-installed-command-smoke/process-helpers.cjs b/scripts/windows-installed-command-smoke/process-helpers.cjs index 7fceff14e3..8edd330a19 100644 --- a/scripts/windows-installed-command-smoke/process-helpers.cjs +++ b/scripts/windows-installed-command-smoke/process-helpers.cjs @@ -296,9 +296,17 @@ function validateProcessLineage(chain, rootPid) { * PowerShell, returning { pid, ppid, name } or null when the PID is no * longer alive. Used as the primitive for the bounded ancestry walk. * + * Diagnostics: spawn failures, non-zero PowerShell exit, and unparseable JSON + * all throw explicit errors carrying stderr/raw context so the next CI run is + * actionable. null is returned ONLY when CIM definitively reports the process + * absent (empty stdout on a successful query) — i.e. the process is no longer + * alive, which is the one legitimate "not found" outcome for the ancestry walk. + * * @param {number} pid * @param {{ resolvePwsh?: () => string, spawnSync?: typeof import('node:child_process').spawnSync, timeout?: number }} [options] * @returns {{pid: number, ppid: number, name: string} | null} + * @throws {Error} on spawn error, signal termination, non-zero PowerShell + * exit, or unparseable JSON output. */ function queryProcessEntry(pid, options) { const _spawnSync = @@ -317,29 +325,48 @@ function queryProcessEntry(pid, options) { `if ($p) { ` + `@{ pid = $p.ProcessId; ppid = $p.ParentProcessId; name = $p.Name } | ConvertTo-Json -Compress ` + `} else { '' } ` + - `} catch { '' }`; + `} catch { [Console]::Error.WriteLine($_.Exception.Message); exit 1 }`; const r = _spawnSync(_resolvePwsh(), ['-NoProfile', '-Command', script], { encoding: 'utf8', timeout, windowsHide: true, }); - if (r.error || r.signal || r.status !== 0) { - return null; + if (r.error) { + throw new Error( + `queryProcessEntry: spawn failed for pid=${pid}: ${r.error.message}`, + ); + } + if (r.signal) { + throw new Error( + `queryProcessEntry: PowerShell terminated by signal ${r.signal} for pid=${pid} (stderr=${JSON.stringify(r.stderr || '')})`, + ); + } + if (r.status !== 0) { + throw new Error( + `queryProcessEntry: PowerShell exited ${r.status} for pid=${pid} (stderr=${JSON.stringify(r.stderr || '')}, stdout=${JSON.stringify(r.stdout || '')})`, + ); } const raw = (r.stdout || '').trim(); + // Empty output on a successful (status 0) query means CIM definitively + // reports the process absent — the one legitimate "not found" outcome. if (!raw) return null; + let obj; try { - const obj = JSON.parse(raw); - const entryPid = Number(obj.pid); - const entryPpid = Number(obj.ppid); - const name = String(obj.name || ''); - if (!Number.isInteger(entryPid) || !Number.isInteger(entryPpid)) { - return null; - } - return { pid: entryPid, ppid: entryPpid, name }; - } catch { - return null; + obj = JSON.parse(raw); + } catch (e) { + throw new Error( + `queryProcessEntry: failed to parse JSON for pid=${pid}: ${e.message} (raw=${JSON.stringify(raw)}, stderr=${JSON.stringify(r.stderr || '')})`, + ); + } + const entryPid = Number(obj.pid); + const entryPpid = Number(obj.ppid); + const name = String(obj.name || ''); + if (!Number.isInteger(entryPid) || !Number.isInteger(entryPpid)) { + throw new Error( + `queryProcessEntry: non-integer pid/ppid for pid=${pid}: entryPid=${JSON.stringify(obj.pid)}, entryPpid=${JSON.stringify(obj.ppid)} (raw=${JSON.stringify(raw)})`, + ); } + return { pid: entryPid, ppid: entryPpid, name }; } /** From b6bdf4e1ae39e7ba9b618e9947e351d65a9401fd Mon Sep 17 00:00:00 2001 From: acoliver Date: Tue, 21 Jul 2026 22:11:02 -0300 Subject: [PATCH 09/18] Use policy-compliant Bun FFI loading --- scripts/tests/issue-2603-windows-probe.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/tests/issue-2603-windows-probe.ts b/scripts/tests/issue-2603-windows-probe.ts index eb9238816e..a12ce49149 100644 --- a/scripts/tests/issue-2603-windows-probe.ts +++ b/scripts/tests/issue-2603-windows-probe.ts @@ -16,6 +16,9 @@ */ import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; + +const nativeRequire = createRequire(import.meta.url); interface ProbeRequest { stdin?: boolean; @@ -98,8 +101,7 @@ function nativeExitWithStatus(status: number): never { // loader. ExitProcess only exists in kernel32.dll on Windows, so this branch // is unreachable off win32. bun:ffi has no ESM export; require is the only // way to load it under Bun. - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { dlopen } = require('bun:ffi') as { + const { dlopen } = nativeRequire('bun:ffi') as { dlopen: >( name: string, symbols: Fns, From 624b5265743f8086cf6d000a52d521065bd5e8d1 Mon Sep 17 00:00:00 2001 From: acoliver Date: Wed, 22 Jul 2026 01:49:18 -0300 Subject: [PATCH 10/18] Harden launcher review edge cases --- packages/cli/bin/llxprt | 78 ++++-- .../cli/scripts/install-native-launchers.cjs | 57 +++-- scripts/lib/npm-command.cjs | 8 + scripts/lib/tar-command.cjs | 145 ++++++++++++ scripts/tests/issue-2603-install.test.ts | 75 +++--- .../issue-2603-launcher-hardening.test.ts | 223 ++++++++++++++++++ .../issue-2603-release-install-smoke.cjs | 52 ++-- .../tests/issue-2603-release-install.test.ts | 95 +++++++- scripts/tests/issue-2603-release-pack.cjs | 91 ++++--- .../tests/issue-2603-smoke-fail-fast.test.ts | 128 +++++++++- .../tests/issue-2603-smoke-helpers.test.ts | 8 + .../tests/issue-2603-startup-benchmark.cjs | 23 +- ...issue-2603-windows-lineage-helpers.test.ts | 27 ++- scripts/tests/npm-command.test.ts | 12 + .../assert.cjs | 2 + .../bun-validation.cjs | 45 ++-- .../checks.cjs | 37 +-- .../constants.cjs | 71 +++++- .../install-helpers.cjs | 26 +- .../launcher-invocation.cjs | 6 + .../package-layout.cjs | 43 ++-- .../process-helpers.cjs | 2 + .../pwsh-resolver.cjs | 24 +- 23 files changed, 1023 insertions(+), 255 deletions(-) create mode 100644 scripts/lib/tar-command.cjs create mode 100644 scripts/tests/issue-2603-launcher-hardening.test.ts diff --git a/packages/cli/bin/llxprt b/packages/cli/bin/llxprt index 4e729ed1c0..061899d12c 100755 --- a/packages/cli/bin/llxprt +++ b/packages/cli/bin/llxprt @@ -5,10 +5,32 @@ 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") - _llxprt_target=$(readlink -- "$_llxprt_self" 2>/dev/null || printf '%s\n' "$_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 ;; @@ -26,7 +48,7 @@ _llxprt_script_dir=$(cd -- "$(dirname -- "$_llxprt_self")" 2>/dev/null && pwd) | _llxprt_pkg_json=$_llxprt_script_dir/../package.json _llxprt_bun_pin="" if [ -f "$_llxprt_pkg_json" ]; then - _llxprt_bun_pin=$(sed -n 's/.*"bun"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' -- "$_llxprt_pkg_json" 2>/dev/null | head -n1) + _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 @@ -48,6 +70,33 @@ _llxprt_derive_bun_pkg() { 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. +_llxprt_is_exact_pin() { + _llxprt_pin=$1 + case "$_llxprt_pin" in + *[!0-9.]*) return 1 ;; # contains non-numeric/non-dot chars: a range/tag + esac + # Must have exactly two dots (three numeric groups). Strip leading digits + # from each group to ensure each group is purely numeric. + _llxprt_rest=$_llxprt_pin + _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 ] +} + # 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"), @@ -61,14 +110,10 @@ _llxprt_derive_bun_pkg() { _llxprt_bun_validates() { _llxprt_candidate=$1 # No exact pin: accept (boundary check is the primary defense). A range - # pin (e.g. "^1.3.14") is not exact, so we cannot compare equality. - if [ -z "$_llxprt_bun_pin" ]; then + # 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 - case "$_llxprt_bun_pin" in - [0-9]*) ;; # exact version like "1.3.14" - *) return 0 ;; # range like "^1.3.14" or "*": not exact, accept - esac if ! _llxprt_derive_bun_pkg "$_llxprt_candidate"; then # Cannot derive package.json for this layout; reject under an exact pin. return 1 @@ -78,7 +123,7 @@ _llxprt_bun_validates() { # accept, so a partial install cannot bypass version verification. return 1 fi - _llxprt_found_ver=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' -- "$_llxprt_bun_pkg" 2>/dev/null | head -n1) + _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. @@ -284,20 +329,11 @@ case "$_llxprt_kernel" in ;; esac -_llxprt_entry="" -for _llxprt_candidate in \ - "$_llxprt_script_dir/index.ts" \ - "$_llxprt_script_dir/../index.ts" \ - "$_llxprt_script_dir/../../index.ts" \ - "$_llxprt_script_dir/../../../index.ts"; do - if [ -f "$_llxprt_candidate" ]; then - _llxprt_entry=$_llxprt_candidate - break - fi -done +_llxprt_entry="$_llxprt_pkg_root/index.ts" -if [ -z "$_llxprt_entry" ]; then +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 diff --git a/packages/cli/scripts/install-native-launchers.cjs b/packages/cli/scripts/install-native-launchers.cjs index b3646061b4..cf3253339e 100644 --- a/packages/cli/scripts/install-native-launchers.cjs +++ b/packages/cli/scripts/install-native-launchers.cjs @@ -90,11 +90,14 @@ function isWithinPackageRootWin(resolvedPath, packageRoot) { return rel === '' || (!rel.startsWith('..') && !path.win32.isAbsolute(rel)); } -function pointsToOurPackage(filePath, binLinkDir, packageRoot, shimType) { - const content = readFileSafe(filePath); - if (!content) { - return false; - } +// 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) @@ -102,12 +105,6 @@ function pointsToOurPackage(filePath, binLinkDir, packageRoot, shimType) { if (candidates.length === 0) { return false; } - // Authorize 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. - // Use 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. for (const candidate of candidates) { const resolved = resolveShimCandidate(binLinkDir, candidate); if (isWithinPackageRootWin(resolved, packageRoot)) { @@ -117,6 +114,14 @@ function pointsToOurPackage(filePath, binLinkDir, packageRoot, shimType) { return false; } +function pointsToOurPackage(filePath, binLinkDir, packageRoot, shimType) { + const content = readFileSafe(filePath); + if (!content) { + return false; + } + return shimTargetWithinPackage(content, binLinkDir, packageRoot, shimType); +} + function canOverwriteLauncher(filePath, binLinkDir, packageRoot, shimType) { if (!fs.existsSync(filePath)) { return true; @@ -130,20 +135,7 @@ function canOverwriteLauncher(filePath, binLinkDir, packageRoot, shimType) { if (!content) { return false; } - 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; + return shimTargetWithinPackage(content, binLinkDir, packageRoot, shimType); } function relativePath(fromDir, toPath) { @@ -155,7 +147,12 @@ function relativePathPosix(fromDir, toPath) { } function escapeForCmdQuote(value) { - return value.replace(/"/g, '""'); + // 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, @@ -358,6 +355,13 @@ function findBinLinkDirs(packageRoot, env) { const isGlobal = env.npm_config_global === 'true'; if (isGlobal) { + // npm supports npm_config_bin_root to override the global bin directory + // (default: the prefix root). When set, global shims go there rather than + // the prefix root, so honor it to avoid writing to the wrong directory. + const binRoot = env.npm_config_bin_root; + if (binRoot) { + add(binRoot); + } const prefix = env.npm_config_prefix; if (prefix) { add(prefix); @@ -477,6 +481,7 @@ module.exports = { hasOwnershipSentinel, pointsToOurPackage, canOverwriteLauncher, + shimTargetWithinPackage, findBinLinkDirs, nearestNodeModulesBin, resolveBunExe, diff --git a/scripts/lib/npm-command.cjs b/scripts/lib/npm-command.cjs index 45e348b53a..e6ead024ce 100644 --- a/scripts/lib/npm-command.cjs +++ b/scripts/lib/npm-command.cjs @@ -97,8 +97,16 @@ function resolveNpmCliJs(options) { .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; diff --git a/scripts/lib/tar-command.cjs b/scripts/lib/tar-command.cjs new file mode 100644 index 0000000000..7acd05ac2b --- /dev/null +++ b/scripts/lib/tar-command.cjs @@ -0,0 +1,145 @@ +'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'); + +/** + * Default timeout for tar operations (listing, extracting). + */ +const TAR_TIMEOUT_MS = 30_000; + +/** + * 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). + * @returns {{ stdout: string, stderr: string }} + * @throws {Error} on spawn failure, signal, or non-zero exit. + */ +function spawnTarList(tarball, timeoutMs) { + const result = spawnSync('tar', ['-tzf', tarball], { + encoding: 'utf8', + timeout: timeoutMs || TAR_TIMEOUT_MS, + }); + if (result.error) { + throw new Error( + `Failed to spawn tar (is it on PATH? GitHub Windows has bsdtar): ${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). + * @returns {{ stdout: string, stderr: string }} + * @throws {Error} on spawn failure, signal, or non-zero exit. + */ +function spawnTarListVerbose(tarball, member, timeoutMs) { + const result = spawnSync('tar', ['-tzvf', tarball, member], { + encoding: 'utf8', + timeout: timeoutMs || TAR_TIMEOUT_MS, + }); + if (result.error) { + throw new Error( + `Failed to spawn tar (is it on PATH? GitHub Windows has bsdtar): ${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). + * @returns {{ stdout: string, stderr: string }} + * @throws {Error} on spawn failure, signal, or non-zero exit. + */ +function spawnTarExtract(tarball, extractDir, timeoutMs) { + const result = spawnSync('tar', ['-xzf', tarball, '-C', extractDir], { + encoding: 'utf8', + timeout: timeoutMs || TAR_TIMEOUT_MS, + }); + if (result.error) { + throw new Error( + `Failed to spawn tar (is it on PATH? GitHub Windows has bsdtar): ${result.error.message}`, + ); + } + if (result.status !== 0) { + throw new Error( + `tar extract failed (exit ${result.status}, signal=${result.signal ?? 'none'}): ${result.stderr}`, + ); + } + return { stdout: result.stdout, stderr: result.stderr }; +} + +/** + * Locate the .tgz filename in npm pack output. npm pack prints the tarball + * filename (ending in .tgz) on its own line, but the output may include + * warnings/progress lines. Find the .tgz line and optionally validate the + * file exists. + * + * @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/); + const tgzLines = lines.filter((l) => l.trim().endsWith('.tgz')); + if (tgzLines.length === 0) { + throw new Error( + `npm pack output did not contain a .tgz line:\n${packOutput}`, + ); + } + const tarName = tgzLines[tgzLines.length - 1].trim(); + if (cacheDir) { + const { existsSync } = require('node:fs'); + const { join } = require('node:path'); + 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, + TAR_TIMEOUT_MS, +}; diff --git a/scripts/tests/issue-2603-install.test.ts b/scripts/tests/issue-2603-install.test.ts index 5081f8e89a..2b2ea0a505 100644 --- a/scripts/tests/issue-2603-install.test.ts +++ b/scripts/tests/issue-2603-install.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterAll } from 'vitest'; import { spawnSync } from 'node:child_process'; import { existsSync, @@ -25,6 +25,15 @@ const npmInvocation = nodeRequire('../lib/npm-command.cjs').npmInvocation as ( 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', @@ -68,15 +77,18 @@ const sharedCacheDir = join( ); let cachedTarball: string | null = null; -function findTarballName(packOutput: string): string { - const lines = packOutput.split(/\r?\n/); - const tgzLines = lines.filter((l) => l.trim().endsWith('.tgz')); - if (tgzLines.length === 0) { - throw new Error( - `npm pack output did not contain a .tgz line:\n${packOutput}`, - ); +// 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. } - return tgzLines[tgzLines.length - 1].trim(); +}); + +function findTarballName(packOutput: string): string { + return tarCommand.findTarballName(packOutput); } function packCliWorkspace(): string { @@ -111,47 +123,17 @@ function packCliWorkspace(): string { } /** - * Spawns tar with spawn-error diagnostics. GitHub Windows runners ship - * bsdtar; a spawn failure (ENOENT) produces a clear diagnostic rather than an - * opaque null status. + * Delegates to the shared tar-command helper for tar listing. */ function spawnTarList(tarball: string): { stdout: string } { - const result = spawnSync('tar', ['-tzf', tarball], { - encoding: 'utf8', - timeout: 30_000, - }); - if (result.error) { - throw new Error( - `Failed to spawn tar (is it on PATH? GitHub Windows has bsdtar): ${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 }; + return tarCommand.spawnTarList(tarball); } function spawnTarListVerbose( tarball: string, member: string, ): { stdout: string } { - const result = spawnSync('tar', ['-tzvf', tarball, member], { - encoding: 'utf8', - timeout: 30_000, - }); - if (result.error) { - throw new Error( - `Failed to spawn tar (is it on PATH? GitHub Windows has bsdtar): ${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 }; + return tarCommand.spawnTarListVerbose(tarball, member); } describe('CLI workspace tarball contents (actual release artifact)', () => { @@ -194,7 +176,10 @@ describe('CLI workspace tarball contents (actual release artifact)', () => { it('ships bin/llxprt with executable mode in the tarball', () => { const tarball = packCliWorkspace(); const { stdout } = spawnTarListVerbose(tarball, 'package/bin/llxprt'); - expect(stdout).toMatch(/^[^ ]*x/); + // 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); }); @@ -221,7 +206,7 @@ describe('install-native-launchers module (CLI workspace)', () => { it('exits with code 43 on missing Bun', () => { const mod = loadCliInstaller(); const cmd = mod.generateCmdLauncher('bun.exe', 'index.ts'); - expect(cmd).toContain('exit /b 43'); + expect(cmd).toContain('exit /b ' + mod.LAUNCHER_ERROR_EXIT_CODE); expect(cmd).toMatch(/npm install|bun\.sh/i); }); @@ -256,7 +241,7 @@ describe('install-native-launchers module (CLI workspace)', () => { it('exits with code 43 on missing Bun', () => { const mod = loadCliInstaller(); const ps1 = mod.generatePs1Launcher('bun.exe', 'index.ts'); - expect(ps1).toContain('exit 43'); + expect(ps1).toContain('exit ' + mod.LAUNCHER_ERROR_EXIT_CODE); }); it('does not set LLXPRT_BUN_RELAUNCHED', () => { 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 0000000000..fd5f214eb6 --- /dev/null +++ b/scripts/tests/issue-2603-launcher-hardening.test.ts @@ -0,0 +1,223 @@ +/** + * @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; + +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; + } + const whichResult = spawnSync('which', ['bun'], { encoding: 'utf8' }); + if (whichResult.status === 0) { + 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); +}); diff --git a/scripts/tests/issue-2603-release-install-smoke.cjs b/scripts/tests/issue-2603-release-install-smoke.cjs index 15d5c070e8..f9c1d9d767 100644 --- a/scripts/tests/issue-2603-release-install-smoke.cjs +++ b/scripts/tests/issue-2603-release-install-smoke.cjs @@ -36,6 +36,7 @@ const { join, resolve } = require('node:path'); const { tmpdir } = require('node:os'); const { createRequire } = require('node:module'); 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. @@ -143,6 +144,18 @@ function assertExactVersions(deps) { } } +/** + * Assert the release manifest has no non-exact deps in ANY dependency field: + * dependencies, 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 three are validated. + */ +function assertReleaseManifestAllFields(pkgJson) { + assertExactVersions(pkgJson.dependencies); + assertExactVersions(pkgJson.optionalDependencies); + assertExactVersions(pkgJson.peerDependencies); +} + function runStep(label, fn) { process.stdout.write(`[${label}] starting...\n`); try { @@ -169,26 +182,11 @@ function safeCleanup(tempDir) { } /** - * Spawns tar extract with spawn-error diagnostics. GitHub Windows runners - * ship bsdtar, but a spawn failure (ENOENT) must produce a clear diagnostic - * rather than an opaque null status. + * Spawns tar extract with spawn-error diagnostics via the shared tar-command + * helper. */ -function spawnTarExtract(tarball, extractDir) { - const result = spawnSync('tar', ['-xzf', tarball, '-C', extractDir], { - encoding: 'utf8', - timeout: 30_000, - }); - if (result.error) { - throw new Error( - `Failed to spawn tar (is it on PATH? GitHub Windows has bsdtar): ${result.error.message}`, - ); - } - if (result.status !== 0) { - throw new Error( - `tar extract failed (exit ${result.status}, signal=${result.signal ?? 'none'}): ${result.stderr}`, - ); - } - return result; +function spawnTarExtractLocal(tarball, extractDir) { + return spawnTarExtract(tarball, extractDir); } function main() { @@ -215,11 +213,11 @@ function main() { runStep('release-manifest-integrity', () => { const extractDir = mkdtempSync(join(tmpdir(), 'llxprt-tarball-check-')); try { - spawnTarExtract(releaseTarball, extractDir); + spawnTarExtractLocal(releaseTarball, extractDir); const pkgJson = JSON.parse( readFileSync(join(extractDir, 'package', 'package.json'), 'utf8'), ); - assertExactVersions(pkgJson.dependencies); + assertReleaseManifestAllFields(pkgJson); } finally { safeCleanup(extractDir); } @@ -283,7 +281,9 @@ function main() { ); } assert( - /^\d+\.\d+\.\d+/.test(result.stdout.trim()), + /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test( + result.stdout.trim(), + ), `global --version output unexpected: ${result.stdout}`, ); }); @@ -345,7 +345,9 @@ function main() { ); } assert( - /^\d+\.\d+\.\d+/.test(result.stdout.trim()), + /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test( + result.stdout.trim(), + ), `local --version output unexpected: ${result.stdout}`, ); }); @@ -390,7 +392,9 @@ function main() { ); } assert( - /^\d+\.\d+\.\d+/.test(result.stdout.trim()), + /^\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 — diff --git a/scripts/tests/issue-2603-release-install.test.ts b/scripts/tests/issue-2603-release-install.test.ts index 1c2795aad2..aea52f7ef0 100644 --- a/scripts/tests/issue-2603-release-install.test.ts +++ b/scripts/tests/issue-2603-release-install.test.ts @@ -6,9 +6,17 @@ import { describe, it, expect } from 'vitest'; import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; -import { existsSync } from 'node:fs'; +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, '..', '..', '..'); @@ -266,3 +274,88 @@ describe('release-like CLI pack/install smoke (issue #2603)', () => { SMOKE_TEST_TIMEOUT_MS, ); }); + +describe('rewriteOnePkgDeps does not rewrite peerDependencies', () => { + const nodeRequire = createRequire(import.meta.url); + + 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 = nodeRequire(releasePackHelper) as { + rewriteOnePkgDeps: (p: string, m: Map) => void; + }; + 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 = nodeRequire(releasePackHelper) as { + rewriteOnePkgDeps: (p: string, m: Map) => void; + }; + 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 index a17e8118ab..e9f6933cd0 100644 --- a/scripts/tests/issue-2603-release-pack.cjs +++ b/scripts/tests/issue-2603-release-pack.cjs @@ -36,6 +36,10 @@ const { 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 @@ -54,19 +58,35 @@ function readCliManifest(repoRoot) { /** * Process-specific cache directory (PID + source fingerprint) avoids concurrent * test processes corrupting a shared cache. The fingerprint is derived from the - * CLI manifest path + mtime so a source change invalidates the cache. The dir - * is cleaned when practical (same-process reuse is fine if both artifacts exist - * and the fingerprint is stable). + * 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 cliPkgPath = join(repoRoot, 'packages', 'cli', 'package.json'); - let mtime = 0; - try { - mtime = require('node:fs').statSync(cliPkgPath).mtimeMs; - } catch { - // stat failure is non-fatal; fingerprint just lacks the mtime component. + 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). Additional key files can be added here as the packing scope grows. + const fingerprintFiles = [ + join(repoRoot, 'packages', 'cli', 'package.json'), + join(repoRoot, 'package.json'), + ]; + 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 `${mtime}`.replace(/[^0-9]/g, '0'); + return hasher.digest('hex').slice(0, 16); } function processCacheDir(repoRoot) { @@ -88,27 +108,11 @@ let cachedReleaseTarball = null; let cachedReplicaTarball = null; /** - * Locate the .tgz filename in npm pack output. npm pack prints the tarball - * filename (ending in .tgz) on its own line, but the output may include - * warnings/progress lines. Find the .tgz line and validate the file exists - * rather than assuming the last line is the tarball name. + * 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) { - const lines = packOutput.split(/\r?\n/); - const tgzLines = lines.filter((l) => l.trim().endsWith('.tgz')); - if (tgzLines.length === 0) { - throw new Error( - `npm pack output did not contain a .tgz line:\n${packOutput}`, - ); - } - const tarName = tgzLines[tgzLines.length - 1].trim(); - 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; + return sharedFindTarballName(packOutput, cacheDir); } function packReleaseLikeCli(repoRoot) { @@ -229,7 +233,7 @@ function runPreparePackage(workCopy) { } if (result.status !== 0) { throw new Error( - `prepare:package failed (exit ${result.status}, timeout=${result.signal ?? 'none'}): ${result.stderr || result.stdout}`, + `prepare:package failed (exit ${result.status}, signal=${result.signal ?? 'none'}): ${result.stderr || result.stdout}`, ); } } @@ -248,7 +252,7 @@ function runBindReleaseDeps(workCopy) { } if (bindResult.status !== 0) { throw new Error( - `bind-release-deps failed (exit ${bindResult.status}, timeout=${bindResult.signal ?? 'none'}): ${bindResult.stderr || bindResult.stdout}`, + `bind-release-deps failed (exit ${bindResult.status}, signal=${bindResult.signal ?? 'none'}): ${bindResult.stderr || bindResult.stdout}`, ); } } @@ -325,22 +329,9 @@ function assertReleaseBoundManifest(workCopy, internalPkgs) { * installer script, TypeScript entry, README, and LICENSE. */ function assertReleaseTarballAssets(releaseTarball) { - const result = spawnSync('tar', ['-tzf', releaseTarball], { - encoding: 'utf8', - timeout: 30_000, - }); - if (result.error) { - throw new Error( - `Failed to spawn tar to list release tarball: ${result.error.message}`, - ); - } - if (result.status !== 0) { - throw new Error( - `Failed to list release tarball (exit ${result.status}, signal=${result.signal ?? 'none'}): ${result.stderr || result.stdout}`, - ); - } + const { stdout } = spawnTarList(releaseTarball); const files = new Set( - result.stdout + stdout .split(/\r?\n/) .map((entry) => entry.replace(/^\.\//, '').replace(/\\/g, '/')) .filter(Boolean), @@ -410,10 +401,15 @@ function rewriteAllDepsToTarballs(workCopy, internalPkgs, 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', - 'peerDependencies', + 'optionalDependencies', ]) { const deps = pkg[depField]; if (!deps) continue; @@ -465,4 +461,5 @@ module.exports = { readCliManifest, findTarballName, shouldCopyRepoEntry, + rewriteOnePkgDeps, }; diff --git a/scripts/tests/issue-2603-smoke-fail-fast.test.ts b/scripts/tests/issue-2603-smoke-fail-fast.test.ts index 21db154b08..a67e7c9f0b 100644 --- a/scripts/tests/issue-2603-smoke-fail-fast.test.ts +++ b/scripts/tests/issue-2603-smoke-fail-fast.test.ts @@ -24,7 +24,13 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { createRequire } from 'node:module'; import { resolve, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { writeFileSync, mkdtempSync, rmSync, chmodSync } from 'node:fs'; +import { + writeFileSync, + readFileSync, + mkdtempSync, + rmSync, + chmodSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; const thisFile = fileURLToPath(import.meta.url); @@ -144,6 +150,7 @@ describe('pwsh resolver (root cause C)', () => { env?: NodeJS.ProcessEnv; spawnSync?: unknown; existsSync?: (path: string) => boolean; + statSync?: (path: string) => { isFile: () => boolean }; }) => string; whereResolve: ( command: string, @@ -160,6 +167,7 @@ describe('pwsh resolver (root cause C)', () => { 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'); @@ -171,6 +179,7 @@ describe('pwsh resolver (root cause C)', () => { platform: 'win32', env: { PWSH_PATH: 'C:\\explicit\\pwsh.exe' }, existsSync: () => true, + statSync: () => ({ isFile: () => true }), spawnSync: () => ({ status: 0, stdout: 'C:\\other\\pwsh.exe\r\n', @@ -282,6 +291,7 @@ 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)', () => { @@ -305,6 +315,20 @@ describe('install helpers cache args (root cause A)', () => { 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)', () => { @@ -453,11 +477,109 @@ describe('constants: expected bun version + configurable timeouts', () => { 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 matches the CLI manifest (1.3.14)', () => { + 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(); - expect(m.EXPECTED_BUN_VERSION).toBe('1.3.14'); + 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', () => { diff --git a/scripts/tests/issue-2603-smoke-helpers.test.ts b/scripts/tests/issue-2603-smoke-helpers.test.ts index c9b02c9a15..5badcf7252 100644 --- a/scripts/tests/issue-2603-smoke-helpers.test.ts +++ b/scripts/tests/issue-2603-smoke-helpers.test.ts @@ -109,6 +109,14 @@ describe('cmdQuote', () => { 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('""'); }); diff --git a/scripts/tests/issue-2603-startup-benchmark.cjs b/scripts/tests/issue-2603-startup-benchmark.cjs index 519aa0c6b6..a10da85743 100644 --- a/scripts/tests/issue-2603-startup-benchmark.cjs +++ b/scripts/tests/issue-2603-startup-benchmark.cjs @@ -151,7 +151,7 @@ function resolveDirectLauncherInvocation() { const { replicaTarball } = packReleaseLikeCli(repoRoot); // Install into a temp prefix to get the generated .cmd launcher. - const { mkdtempSync } = require('node:fs'); + const { mkdtempSync, rmSync } = require('node:fs'); const { tmpdir } = require('node:os'); const tempDir = mkdtempSync(join(tmpdir(), 'llxprt-bench-')); const prefix = join(tempDir, 'prefix'); @@ -189,6 +189,18 @@ function resolveDirectLauncherInvocation() { 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] }; } @@ -299,7 +311,14 @@ function measure(fn, label) { samples.push(Number(t1 - t0) / 1e6); // ms } samples.sort((a, b) => a - b); - const median = samples[Math.floor(samples.length / 2)]; + // True median: for odd-length arrays, the middle element; for even-length, + // the average of the two middle elements. The default iteration count is 15 + // (odd), so this only changes behavior for a user-specified even count. + const mid = Math.floor(samples.length / 2); + const median = + samples.length % 2 === 1 + ? samples[mid] + : (samples[mid - 1] + samples[mid]) / 2; const min = samples[0]; const max = samples[samples.length - 1]; return { median, min, max, samples }; diff --git a/scripts/tests/issue-2603-windows-lineage-helpers.test.ts b/scripts/tests/issue-2603-windows-lineage-helpers.test.ts index 26b83a8940..d86e239831 100644 --- a/scripts/tests/issue-2603-windows-lineage-helpers.test.ts +++ b/scripts/tests/issue-2603-windows-lineage-helpers.test.ts @@ -221,17 +221,21 @@ describe('samePath', () => { ).toBe(true); }); - it('falls back to the original path when realpath throws (missing path)', () => { + it('falls back to the original path when realpath throws ENOENT (missing path)', () => { const throwing = (): string => { - throw new Error('ENOENT'); + const err: NodeJS.ErrnoException = new Error( + 'ENOENT: no such file or directory', + ); + err.code = 'ENOENT'; + throw err; }; - // Both paths throw, so the originals are compared. Different originals → false. + // Both paths throw ENOENT, so the originals are compared. Different originals → false. expect( packageLayout.samePath('C:\\missing\\a.exe', 'C:\\missing\\b.exe', { realpathSync: throwing, }), ).toBe(false); - // Same originals → true even though realpath throws. + // Same originals → true even though realpath throws ENOENT. expect( packageLayout.samePath('C:\\missing\\a.exe', 'C:\\missing\\a.exe', { realpathSync: throwing, @@ -239,6 +243,21 @@ describe('samePath', () => { ).toBe(true); }); + it('propagates unexpected realpath errors (not ENOENT)', () => { + const throwing = (): string => { + const err: NodeJS.ErrnoException = new Error('EACCES: permission denied'); + err.code = 'EACCES'; + throw err; + }; + // An EACCES error is NOT silently swallowed; it propagates so the caller + // sees an unexpected filesystem condition. + expect(() => + packageLayout.samePath('C:\\secure\\a.exe', 'C:\\secure\\b.exe', { + realpathSync: throwing, + }), + ).toThrow(/samePath: realpath failed/); + }); + it('uses the native realpathSync when no options are provided', () => { // This exercises the default branch; the two real paths must resolve via // the host's native realpath. Use the repo's own package.json which exists. diff --git a/scripts/tests/npm-command.test.ts b/scripts/tests/npm-command.test.ts index 06257b1544..9ec1f504fe 100644 --- a/scripts/tests/npm-command.test.ts +++ b/scripts/tests/npm-command.test.ts @@ -194,6 +194,18 @@ describe('resolveNpmCliJs existence verification', () => { ).toBe('/path/to/npm-cli.js'); }); + it('rejects a relative npm_execpath (must be absolute)', () => { + // A relative npm_execpath could be hijacked by a CWD-dependent path; the + // resolver must require an absolute path so the resolved CLI is stable. + const result = resolveNpmCliJs({ + env: { npm_execpath: 'relative/npm-cli.js' }, + existsSync: () => true, + }); + expect(result).not.toBe('relative/npm-cli.js'); + // Falls through to the node-dir fallback. + expect(result).toMatch(/node_modules[/]npm[/]bin[/]npm-cli\.js$/); + }); + it('ignores npm_execpath when it is not a .js path (e.g. a .cmd wrapper) and falls back', () => { // A .cmd npm_execpath must be ignored; the resolver falls through to the // node-dir fallback instead of trusting a non-JS path. diff --git a/scripts/windows-installed-command-smoke/assert.cjs b/scripts/windows-installed-command-smoke/assert.cjs index ca6f08c4ae..87440d45b4 100644 --- a/scripts/windows-installed-command-smoke/assert.cjs +++ b/scripts/windows-installed-command-smoke/assert.cjs @@ -77,6 +77,8 @@ function runStep(label, fn) { const msg = err && typeof err.message === 'string' ? err.message : String(err); fail(`${label}: ${msg}`); + process.stdout.write(`[${label}] FAIL +`); return undefined; } } diff --git a/scripts/windows-installed-command-smoke/bun-validation.cjs b/scripts/windows-installed-command-smoke/bun-validation.cjs index cde5ae14c8..3202a55e56 100644 --- a/scripts/windows-installed-command-smoke/bun-validation.cjs +++ b/scripts/windows-installed-command-smoke/bun-validation.cjs @@ -18,6 +18,7 @@ */ const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); /** * DOS MZ header magic ("MZ"). @@ -43,10 +44,9 @@ const ELFANEW_OFFSET = 0x3c; function readHeader(filePath, maxBytes = 4096) { let fd; try { - // Use readFileSync with a smaller read by opening, reading a slice, then - // closing. Node's readFileSync reads the whole file; for a multi-MB binary - // we only need the first 4KB. Use fs.openSync/readSync for efficiency. - const fs = require('node:fs'); + // Read only the first maxBytes of the file rather than the whole file + // (which may be tens of MB). openSync + readSync + closeSync reads a + // bounded slice efficiently. fd = fs.openSync(filePath, 'r'); const buf = Buffer.alloc(maxBytes); const bytesRead = fs.readSync(fd, buf, 0, maxBytes, 0); @@ -58,7 +58,7 @@ function readHeader(filePath, maxBytes = 4096) { } finally { if (fd !== undefined) { try { - require('node:fs').closeSync(fd); + fs.closeSync(fd); } catch { // best effort } @@ -72,22 +72,28 @@ function readHeader(filePath, maxBytes = 4096) { * that the file is a Windows PE-family binary (not, e.g., a partial download * or a POSIX shell script). * + * A file that cannot be read (ENOENT, EACCES, EISDIR, etc.) is distinguished + * from a non-PE file: readHeader rethrows on I/O error, and that exception + * propagates so the caller sees an actionable failure rather than a false + * "not a PE" result that conflates unreadable with wrong-format. Callers that + * only care about the boolean verdict can catch the exception. + * * @param {string} filePath * @param {{ readHeader?: (p: string, n?: number) => Buffer }} [options] * @returns {boolean} + * @throws {Error} when the header cannot be read (propagated from readHeader). */ function isWindowsPe(filePath, options) { const reader = (options && options.readHeader) || readHeader; - let header; - try { - header = reader(filePath); - } catch { - return false; - } + // Do NOT catch readHeader errors: an unreadable file must surface as a + // distinct failure, not be conflated with "not a PE binary". + const header = reader(filePath); if (header.length < ELFANEW_OFFSET + 4) return false; if (header.subarray(0, 2).compare(MZ_MAGIC) !== 0) return false; const peOffset = header.readUInt32LE(ELFANEW_OFFSET); - if (peOffset <= 0 || peOffset + PE_SIGNATURE.length > header.length) { + // readUInt32LE returns an unsigned 32-bit integer, so a negative value is + // impossible; the check is equivalently peOffset === 0. + if (peOffset === 0 || peOffset + PE_SIGNATURE.length > header.length) { return false; } return ( @@ -131,7 +137,10 @@ function bunVersion(bunExePath, options) { `bun-validation: '${bunExePath} --version' exited ${r.status}: ${(r.stderr || '').trim()}`, ); } - return String(r.stdout).trim(); + // Trim stdout; if stdout is empty, fall back to stderr. Some toolchain + // wrappers or redirects can emit the version on stderr instead of stdout. + const out = String(r.stdout || '').trim(); + return out.length > 0 ? out : String(r.stderr || '').trim(); } /** @@ -147,7 +156,15 @@ function bunVersion(bunExePath, options) { */ function assertBundledBunHealthy(bunExePath, expectedVersion, options) { const reasons = []; - if (!isWindowsPe(bunExePath, options)) { + let peOk = true; + try { + peOk = isWindowsPe(bunExePath, options); + } catch (e) { + // An unreadable/unstatable file is a distinct failure from "not a PE". + peOk = false; + reasons.push(`could not read header: ${e.message}`); + } + if (!peOk) { reasons.push(`not a valid Windows PE binary (expected MZ+PE signature)`); } // Even if the PE check failed, attempt --version to produce the concrete diff --git a/scripts/windows-installed-command-smoke/checks.cjs b/scripts/windows-installed-command-smoke/checks.cjs index fbd7acdb5c..eeb2a397ad 100644 --- a/scripts/windows-installed-command-smoke/checks.cjs +++ b/scripts/windows-installed-command-smoke/checks.cjs @@ -136,19 +136,11 @@ function checkLauncherSentinels(prefix) { function checkVersionRuns(prefix) { runStep('cmd-version', () => { const cmdPath = join(prefix, 'llxprt.cmd'); - const r = spawnSync('cmd', ['/c', cmdPath, '--version'], { - encoding: 'utf8', + // Use the shared invokeCmd helper for consistent spawn-error diagnostics, + // quoting, and constrained-PATH configuration across all cmd invocations. + const r = invokeCmd(cmdPath, ['--version'], { timeout: VERSION_TIMEOUT_MS, - env: { ...process.env, PATH: CONSTRAINED_PATH }, }); - // Inspect error/signal before status: a spawn failure yields null status - // and must be reported as a harness error, not a misleading exit code. - if (r.error) { - throw new Error(`cmd --version spawn failed: ${r.error.message}`); - } - if (r.signal) { - throw new Error(`cmd --version terminated by signal ${r.signal}`); - } if (r.status !== 0) { throw new Error(`cmd --version exited ${r.status}: ${r.stderr}`); } @@ -160,24 +152,11 @@ function checkVersionRuns(prefix) { runStep('ps1-version', () => { const ps1Path = join(prefix, 'llxprt.ps1'); - // Resolve PowerShell robustly: PWSH_PATH -> pwsh.exe -> powershell.exe. - // Hardcoding 'powershell' failed with ENOENT on windows-latest (run 29850614559). - const pwshExe = resolvePwsh(); - const r = spawnSync( - pwshExe, - ['-NoProfile', '-Command', `& '${ps1Path}' --version`], - { - encoding: 'utf8', - timeout: VERSION_TIMEOUT_MS, - env: { ...process.env, PATH: CONSTRAINED_PATH }, - }, - ); - if (r.error) { - throw new Error(`ps1 --version spawn failed: ${r.error.message}`); - } - if (r.signal) { - throw new Error(`ps1 --version terminated by signal ${r.signal}`); - } + // Use the shared invokePwsh helper for consistent PowerShell invocation, + // encoding, and spawn-error diagnostics. + const r = invokePwsh(ps1Path, ['--version'], { + timeout: VERSION_TIMEOUT_MS, + }); if (r.status !== 0) { throw new Error(`ps1 --version exited ${r.status}: ${r.stderr}`); } diff --git a/scripts/windows-installed-command-smoke/constants.cjs b/scripts/windows-installed-command-smoke/constants.cjs index 0ca79cb7b4..a98f4b4fba 100644 --- a/scripts/windows-installed-command-smoke/constants.cjs +++ b/scripts/windows-installed-command-smoke/constants.cjs @@ -1,6 +1,7 @@ 'use strict'; const path = require('node:path'); +const fs = require('node:fs'); /** * Shared constants for the Windows installed-command smoke harness. @@ -17,16 +18,72 @@ const CONSTRAINED_PATH = [ ].join(';'); const OWNERSHIP_SENTINEL = 'LLXPRT_NATIVE_LAUNCHER owned by @vybestack/llxprt-code'; -const VERSION_RE = /^\d+\.\d+\.\d+/; +// Anchored semver prefix: X.Y.Z with an optional prerelease suffix. The +// previous unanchored form accepted trailing garbage (e.g. 1.3.14-canary.9), +// weakening the install-integrity guard. The end anchor rejects such suffixes +// unless they form a valid prerelease. +const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; +// Strict exact semver: X.Y.Z with optional prerelease, anchored at both ends. +// Used by resolveExpectedBunVersion to validate the manifest bun spec is a +// COMPLETE exact version — not a range (^, ~, >=) or a non-exact digit-leading +// spec (1.x, 1.3.14 - 2.0.0). Range prefixes must NOT be stripped. +const EXACT_SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; const LAUNCH_ERROR_EXIT = 43; /** - * The exact Bun version expected to be bundled, as declared by the CLI - * manifest ("bun" field in packages/cli/package.json). The smoke asserts the - * installed bun.exe reports this version so a partial/incorrect install (e.g. - * a non-Windows bun binary from a timed-out install) is caught explicitly. + * Resolves the bundled Bun version from the CLI manifest so the smoke stays + * in sync with the actual declared dependency. A hardcoded constant drifts + * silently when the manifest is bumped. repoRoot is resolved relative to this + * module (scripts/windows-installed-command-smoke/ -> repo root is two levels + * up). The manifest must declare an EXACT semver pin (e.g. "1.3.14"); a range + * spec (^, ~, >=, *) is rejected and the function returns undefined so the + * health check fails loudly (a version mismatch against undefined) rather than + * masking the misconfiguration. Range prefixes are NOT stripped: pretending a + * range is exact would weaken the install-integrity guard. + * + * @param {{ cliPkgPath?: string, readFileSync?: (p: string) => string }} [options] + * Injection seam for deterministic testing. + * @returns {string | undefined} the exact version, or undefined if the manifest + * is missing/unreadable or the bun spec is not a complete exact semver. + */ +function resolveExpectedBunVersion(options) { + const moduleDir = __dirname; + // __dirname is scripts/windows-installed-command-smoke; two levels up is + // the repo root, then packages/cli/package.json. + const cliPkgPath = + (options && options.cliPkgPath) || + path.join(moduleDir, '..', '..', 'packages', 'cli', 'package.json'); + const readFile = + (options && options.readFileSync) || ((p) => fs.readFileSync(p, 'utf8')); + let cliPkg; + try { + cliPkg = JSON.parse(readFile(cliPkgPath)); + } catch { + // Missing/unreadable manifest: return undefined so the health check fails + // loudly. Do NOT mask this as a default version. + return undefined; + } + const bunSpec = cliPkg && cliPkg.dependencies && cliPkg.dependencies.bun; + if (typeof bunSpec !== 'string' || bunSpec.length === 0) { + return undefined; + } + // The manifest must declare an EXACT version (strict X.Y.Z). A range prefix + // (^, ~, >=, >, *) or a non-exact digit-leading spec (1.x, 1.3.14 - 2.0.0) + // is NOT exact and must not be treated as one. Return undefined so the smoke + // fails loudly rather than silently comparing against a stripped base. + if (EXACT_SEMVER_RE.test(bunSpec)) { + return bunSpec; + } + return undefined; +} + +/** + * The exact Bun version expected to be bundled, derived from the CLI manifest + * ("bun" field in packages/cli/package.json). The smoke asserts the installed + * bun.exe reports this version so a partial/incorrect install (e.g. a + * non-Windows bun binary from a timed-out install) is caught explicitly. */ -const EXPECTED_BUN_VERSION = '1.3.14'; +const EXPECTED_BUN_VERSION = resolveExpectedBunVersion(); /** * Installer/operation timeouts. All are env-configurable so a slow runner can @@ -65,8 +122,10 @@ module.exports = { CONSTRAINED_PATH, OWNERSHIP_SENTINEL, VERSION_RE, + EXACT_SEMVER_RE, LAUNCH_ERROR_EXIT, EXPECTED_BUN_VERSION, + resolveExpectedBunVersion, INSTALL_TIMEOUT_MS, NPM_EXEC_TIMEOUT_MS, PROBE_TIMEOUT_MS, diff --git a/scripts/windows-installed-command-smoke/install-helpers.cjs b/scripts/windows-installed-command-smoke/install-helpers.cjs index 5692f39859..14220c6827 100644 --- a/scripts/windows-installed-command-smoke/install-helpers.cjs +++ b/scripts/windows-installed-command-smoke/install-helpers.cjs @@ -25,13 +25,13 @@ const { join } = require('node:path'); const { assert, runStep, runRequiredStep } = require('./assert.cjs'); const { - CONSTRAINED_PATH, INSTALL_TIMEOUT_MS, VERSION_TIMEOUT_MS, EXPECTED_BUN_VERSION, } = require('./constants.cjs'); const { npmInvocation } = require('../lib/npm-command.cjs'); const { assertBundledBunHealthy } = require('./bun-validation.cjs'); +const { invokeCmd } = require('./launcher-invocation.cjs'); // Shared maxBuffer for npm install/exec captures. npm/tar can emit verbose // output that exceeds Node's 1MB default; a single shared constant keeps this @@ -118,21 +118,21 @@ function checkLocalCmdVersion(consumerDir) { runStep('local-cmd-version', () => { const cmdPath = join(consumerDir, 'node_modules', '.bin', 'llxprt.cmd'); assert(existsSync(cmdPath), `local cmd launcher not found: ${cmdPath}`); - const r = spawnSync('cmd', ['/c', cmdPath, '--version'], { - encoding: 'utf8', + // Use the shared invokeCmd helper which applies the proven cmd.exe /d /s /c + // + windowsVerbatimArguments construction with proper quoting (cmdQuote). + // The previous quote-only approach did not use the /d /s /c verbatim + // quoting and could split paths with spaces. invokeCmd also provides + // consistent spawn-error diagnostics via validateSpawnResult. + const r = invokeCmd(cmdPath, ['--version'], { timeout: VERSION_TIMEOUT_MS, - env: { ...process.env, PATH: CONSTRAINED_PATH }, }); - // Check r.error before r.status: if cmd.exe cannot be spawned, status is - // null and stderr is undefined, producing a misleading message otherwise. - if (r.error) { - throw new Error(`local cmd --version spawn failed: ${r.error.message}`); - } - if (r.signal) { - throw new Error(`local cmd --version terminated by signal ${r.signal}`); - } + // validateSpawnResult (inside invokeCmd) already covers r.error and + // r.signal. A nonzero exit status is a legitimate child exit that the + // caller interprets here. if (r.status !== 0) { - throw new Error(`local cmd --version exited ${r.status}: ${r.stderr}`); + throw new Error( + `local cmd --version exited ${r.status}: ${r.stderr || r.stdout}`, + ); } }); } diff --git a/scripts/windows-installed-command-smoke/launcher-invocation.cjs b/scripts/windows-installed-command-smoke/launcher-invocation.cjs index ac59e252fe..2af4c260c4 100644 --- a/scripts/windows-installed-command-smoke/launcher-invocation.cjs +++ b/scripts/windows-installed-command-smoke/launcher-invocation.cjs @@ -96,6 +96,12 @@ function validateSpawnResult(label, r) { * (inside a batch file, %VAR% expands variables and %% is a literal %). * Delayed expansion (!VAR!) is off by default in batch files; the generated * launcher does not enable it, so ! is left as-is. + * + * The caret (^) is intentionally NOT doubled. The hosted Windows hostile-argv + * test passed at commit b6bdf4e1a with caret preserved. Inside the quoted /c + * argument, doubling caret can change one literal caret into two. cmd.exe's + * caret-escaping semantics differ between interactive and batch-file contexts; + * the proven path is to leave caret as-is within the double-quoted argument. */ function cmdQuote(s) { let escaped = String(s).replace(/"/g, '""'); diff --git a/scripts/windows-installed-command-smoke/package-layout.cjs b/scripts/windows-installed-command-smoke/package-layout.cjs index c464a259c3..afd89dd070 100644 --- a/scripts/windows-installed-command-smoke/package-layout.cjs +++ b/scripts/windows-installed-command-smoke/package-layout.cjs @@ -16,14 +16,6 @@ function findInstalledPackageRoot(prefix) { for (const c of candidates) { if (existsSync(join(c, 'package.json'))) return c; } - const globRoot = join(prefix, 'node_modules'); - if (existsSync(globRoot)) { - const scoped = join(globRoot, '@vybestack'); - if (existsSync(scoped)) { - const direct = join(scoped, 'llxprt-code'); - if (existsSync(join(direct, 'package.json'))) return direct; - } - } throw new Error(`could not find installed package root under ${prefix}`); } @@ -43,8 +35,10 @@ function findBundledBun(packageRoot) { * * Canonicalization uses realpathSync.native to resolve 8.3 short paths, * symlinks, and junctions to their canonical long form. If the path does not - * exist (or realpath fails), the original path is preserved so that - * missing-path comparisons remain useful for diagnostics. + * exist (realpath throws ENOENT), the original path is preserved so that + * missing-path comparisons remain useful for diagnostics. Other realpath + * errors (EACCES, ELOOP, EIO, ENOTDIR) are propagated as unexpected + * filesystem conditions rather than silently masked. * * After realpath, backslashes are normalized to forward slashes, trailing * slashes are stripped, and the comparison is case-insensitive (Windows @@ -66,8 +60,17 @@ function samePath(a, b, options) { let resolved = String(p); try { resolved = realpath(resolved); - } catch { - // Preserve the original path so missing-path comparisons remain useful. + } catch (e) { + const code = e && typeof e.code === 'string' ? e.code : ''; + // ENOENT (path does not exist) is the expected, benign case for + // missing-path comparisons: preserve the original path. Other errors + // (EACCES, ELOOP, EIO, ENOTDIR) indicate an unexpected filesystem + // condition that should not be silently masked. + if (code !== 'ENOENT') { + throw new Error( + `samePath: realpath failed for ${JSON.stringify(p)}: ${e.message}`, + ); + } } let s = resolved.replace(/\\/g, '/'); while (s.endsWith('/')) { @@ -84,6 +87,9 @@ function copyTree(src, dest) { if (!existsSync(src)) { throw new Error(`copyTree: source does not exist: ${src}`); } + // Track whether dest pre-existed so we only remove it on failure if WE + // created it. This avoids clobbering pre-existing dest data on a copy error. + const destPreExisted = existsSync(dest); try { cpSync(src, dest, { recursive: true, @@ -96,11 +102,14 @@ function copyTree(src, dest) { }, }); } catch (e) { - // Clean up any partial copy so a failed run does not leave a corrupt dest. - try { - rmSync(dest, { recursive: true, force: true }); - } catch { - // best-effort cleanup + // Clean up a partial copy we created, but do NOT remove pre-existing dest + // data that we did not create. + if (!destPreExisted) { + try { + rmSync(dest, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } } throw new Error(`copyTree: failed to copy ${src} -> ${dest}: ${e.message}`); } diff --git a/scripts/windows-installed-command-smoke/process-helpers.cjs b/scripts/windows-installed-command-smoke/process-helpers.cjs index 8edd330a19..245079d55a 100644 --- a/scripts/windows-installed-command-smoke/process-helpers.cjs +++ b/scripts/windows-installed-command-smoke/process-helpers.cjs @@ -116,6 +116,7 @@ function killProcessTree(child) { const r = spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', timeout: 10_000, + windowsHide: true, }); if (r.error || r.status !== 0) { // taskkill failed; fall back to child.kill. @@ -195,6 +196,7 @@ function inspectProcessTreeSync(rootPid) { const ps = spawnSync(pwshExe, ['-NoProfile', '-Command', script], { encoding: 'utf8', timeout: 15_000, + windowsHide: true, }); if (ps.error) { throw new Error( diff --git a/scripts/windows-installed-command-smoke/pwsh-resolver.cjs b/scripts/windows-installed-command-smoke/pwsh-resolver.cjs index 23fb8b0393..48817959a6 100644 --- a/scripts/windows-installed-command-smoke/pwsh-resolver.cjs +++ b/scripts/windows-installed-command-smoke/pwsh-resolver.cjs @@ -26,7 +26,7 @@ */ const { spawnSync } = require('node:child_process'); -const { existsSync } = require('node:fs'); +const { existsSync, statSync } = require('node:fs'); /** * @typedef {{ @@ -34,6 +34,7 @@ const { existsSync } = require('node:fs'); * env?: NodeJS.ProcessEnv; * spawnSync?: typeof import('node:child_process').spawnSync; * existsSync?: typeof import('node:fs').existsSync; + * statSync?: typeof import('node:fs').statSync; * }} ResolverOptions */ @@ -60,7 +61,11 @@ function whereResolve(command, options) { return null; } const first = String(r.stdout).trim().split(/\r?\n/)[0]; - return first || null; + if (!first) return null; + // where.exe may return a path enclosed in double quotes on some Windows + // builds; strip surrounding quotes so the result is a usable bare path. + const unquoted = first.replace(/^"(.*)"$/, '$1'); + return unquoted || null; } /** @@ -81,9 +86,22 @@ function resolvePwsh(options) { } const env = (options && options.env) || process.env; const exists = (options && options.existsSync) || existsSync; + const stat = + options && typeof options.statSync === 'function' + ? options.statSync + : statSync; + // PWSH_PATH must point to a real FILE (the pwsh executable), not a + // directory. existsSync alone is true for directories, so also verify the + // path is a regular file. if (env.PWSH_PATH && exists(env.PWSH_PATH)) { - return env.PWSH_PATH; + try { + if (stat(env.PWSH_PATH).isFile()) { + return env.PWSH_PATH; + } + } catch { + // stat failed; fall through to the next resolution strategy. + } } // 2. pwsh.exe (PowerShell 7+) — present on windows-latest. From 20f52d09315829264acfec06c8134f9643a9c5e8 Mon Sep 17 00:00:00 2001 From: acoliver Date: Wed, 22 Jul 2026 02:09:55 -0300 Subject: [PATCH 11/18] Satisfy launcher shell lint --- packages/cli/bin/llxprt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli/bin/llxprt b/packages/cli/bin/llxprt index 061899d12c..f22c0c0726 100755 --- a/packages/cli/bin/llxprt +++ b/packages/cli/bin/llxprt @@ -78,6 +78,7 @@ _llxprt_is_exact_pin() { _llxprt_pin=$1 case "$_llxprt_pin" in *[!0-9.]*) return 1 ;; # contains non-numeric/non-dot chars: a range/tag + *) ;; esac # Must have exactly two dots (three numeric groups). Strip leading digits # from each group to ensure each group is purely numeric. @@ -87,6 +88,7 @@ _llxprt_is_exact_pin() { _llxprt_part=${_llxprt_rest%%.*} case "$_llxprt_part" in ''|*[!0-9]*) return 1 ;; + *) ;; esac _llxprt_group=$((_llxprt_group + 1)) case "$_llxprt_rest" in From b49f262df54079f3c393a3144cb166bf70e2cc1e Mon Sep 17 00:00:00 2001 From: acoliver Date: Wed, 22 Jul 2026 03:05:05 -0300 Subject: [PATCH 12/18] Harden installed launcher diagnostics --- .../cli/scripts/install-native-launchers.cjs | 8 +- scripts/lib/npm-command.cjs | 4 + scripts/lib/tar-command.cjs | 23 ++-- ...-install-native-launchers-contract.test.ts | 47 +++++++- .../issue-2603-release-install-smoke.cjs | 19 +++- .../tests/issue-2603-release-install.test.ts | 39 +++++-- scripts/tests/issue-2603-release-pack.cjs | 38 +++++-- .../tests/issue-2603-smoke-helpers.test.ts | 75 +++++++++++++ scripts/tests/issue-2603-windows-probe.ts | 36 +++++- scripts/tests/npm-command.test.ts | 90 +++++++++++++++ scripts/tests/tar-command.test.ts | 103 ++++++++++++++++++ scripts/windows-installed-command-smoke.cjs | 48 ++++++-- .../install-helpers.cjs | 10 ++ .../launcher-invocation.cjs | 38 ++++++- .../package-layout.cjs | 19 +++- 15 files changed, 543 insertions(+), 54 deletions(-) create mode 100644 scripts/tests/tar-command.test.ts diff --git a/packages/cli/scripts/install-native-launchers.cjs b/packages/cli/scripts/install-native-launchers.cjs index cf3253339e..3904e8eb0c 100644 --- a/packages/cli/scripts/install-native-launchers.cjs +++ b/packages/cli/scripts/install-native-launchers.cjs @@ -132,8 +132,12 @@ function canOverwriteLauncher(filePath, binLinkDir, packageRoot, shimType) { if (content.includes(OWNERSHIP_SENTINEL)) { return true; } - if (!content) { - return false; + // 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); } diff --git a/scripts/lib/npm-command.cjs b/scripts/lib/npm-command.cjs index e6ead024ce..6aa5add491 100644 --- a/scripts/lib/npm-command.cjs +++ b/scripts/lib/npm-command.cjs @@ -174,6 +174,8 @@ function resolveNpmCliJs(options) { * @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; @@ -197,6 +199,8 @@ function npmInvocation(args, options) { * @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) : []; diff --git a/scripts/lib/tar-command.cjs b/scripts/lib/tar-command.cjs index 7acd05ac2b..0ca56e27c7 100644 --- a/scripts/lib/tar-command.cjs +++ b/scripts/lib/tar-command.cjs @@ -13,6 +13,8 @@ */ const { spawnSync } = require('node:child_process'); +const { existsSync } = require('node:fs'); +const { join } = require('node:path'); /** * Default timeout for tar operations (listing, extracting). @@ -96,7 +98,7 @@ function spawnTarExtract(tarball, extractDir, timeoutMs) { } if (result.status !== 0) { throw new Error( - `tar extract failed (exit ${result.status}, signal=${result.signal ?? 'none'}): ${result.stderr}`, + `tar extract failed (exit ${result.status}, signal=${result.signal ?? 'none'}): ${result.stderr || result.stdout}`, ); } return { stdout: result.stdout, stderr: result.stderr }; @@ -104,9 +106,8 @@ function spawnTarExtract(tarball, extractDir, timeoutMs) { /** * Locate the .tgz filename in npm pack output. npm pack prints the tarball - * filename (ending in .tgz) on its own line, but the output may include - * warnings/progress lines. Find the .tgz line and optionally validate the - * file exists. + * 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. @@ -116,16 +117,20 @@ function spawnTarExtract(tarball, extractDir, timeoutMs) { */ function findTarballName(packOutput, cacheDir) { const lines = packOutput.split(/\r?\n/); - const tgzLines = lines.filter((l) => l.trim().endsWith('.tgz')); - if (tgzLines.length === 0) { + let tarName = ''; + for (let i = lines.length - 1; i >= 0; i--) { + const trimmed = lines[i].trim(); + if (trimmed.endsWith('.tgz')) { + tarName = trimmed; + break; + } + } + if (!tarName) { throw new Error( `npm pack output did not contain a .tgz line:\n${packOutput}`, ); } - const tarName = tgzLines[tgzLines.length - 1].trim(); if (cacheDir) { - const { existsSync } = require('node:fs'); - const { join } = require('node:path'); const tarPath = join(cacheDir, tarName); if (!existsSync(tarPath)) { throw new Error( diff --git a/scripts/tests/issue-2603-install-native-launchers-contract.test.ts b/scripts/tests/issue-2603-install-native-launchers-contract.test.ts index 1f7ad821cb..b6c1a38625 100644 --- a/scripts/tests/issue-2603-install-native-launchers-contract.test.ts +++ b/scripts/tests/issue-2603-install-native-launchers-contract.test.ts @@ -5,7 +5,13 @@ */ import { describe, it, expect } from 'vitest'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, + readFileSync, +} from 'node:fs'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { tmpdir } from 'node:os'; @@ -160,6 +166,45 @@ describe('installNativeLaunchers logging', () => { } }); + 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-')); diff --git a/scripts/tests/issue-2603-release-install-smoke.cjs b/scripts/tests/issue-2603-release-install-smoke.cjs index f9c1d9d767..25128c774e 100644 --- a/scripts/tests/issue-2603-release-install-smoke.cjs +++ b/scripts/tests/issue-2603-release-install-smoke.cjs @@ -130,6 +130,12 @@ function assert(condition, msg) { return condition; } +const NON_NPM_RELEASE_PACKAGES = new Set([ + '@vybestack/llxprt-code-test-utils', + '@vybestack/llxprt-code-a2a-server', + 'llxprt-code-vscode-ide-companion', +]); + function assertExactVersions(deps) { if (!deps) return; for (const [name, spec] of Object.entries(deps)) { @@ -139,6 +145,12 @@ function assertExactVersions(deps) { 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}"`); } } @@ -146,12 +158,13 @@ function assertExactVersions(deps) { /** * Assert the release manifest has no non-exact deps in ANY dependency field: - * dependencies, 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 three are validated. + * 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); } diff --git a/scripts/tests/issue-2603-release-install.test.ts b/scripts/tests/issue-2603-release-install.test.ts index aea52f7ef0..23ab23d21f 100644 --- a/scripts/tests/issue-2603-release-install.test.ts +++ b/scripts/tests/issue-2603-release-install.test.ts @@ -208,8 +208,21 @@ function runSmokeAsync(): SmokeHandle { } } } - // Unref the detached child so it does not keep the event loop alive. + // 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 { @@ -278,6 +291,22 @@ describe('release-like CLI pack/install smoke (issue #2603)', () => { 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 { @@ -300,9 +329,7 @@ describe('rewriteOnePkgDeps does not rewrite peerDependencies', () => { ['@vybestack/llxprt-code-agents', '/cache/agents-1.0.0.tgz'], ['@vybestack/llxprt-code-policy', '/cache/policy-1.0.0.tgz'], ]); - const mod = nodeRequire(releasePackHelper) as { - rewriteOnePkgDeps: (p: string, m: Map) => void; - }; + const mod = loadReleasePackHelper(); mod.rewriteOnePkgDeps(pkgPath, tarballMap); const result = JSON.parse(readFileSync(pkgPath, 'utf8')); expect(result.dependencies['@vybestack/llxprt-code-core']).toBe( @@ -341,9 +368,7 @@ describe('rewriteOnePkgDeps does not rewrite peerDependencies', () => { const tarballMap = new Map([ ['@vybestack/llxprt-code-core', '/cache/core-1.0.0.tgz'], ]); - const mod = nodeRequire(releasePackHelper) as { - rewriteOnePkgDeps: (p: string, m: Map) => void; - }; + const mod = loadReleasePackHelper(); mod.rewriteOnePkgDeps(pkgPath, tarballMap); const result = JSON.parse(readFileSync(pkgPath, 'utf8')); // dependencies ARE rewritten. diff --git a/scripts/tests/issue-2603-release-pack.cjs b/scripts/tests/issue-2603-release-pack.cjs index e9f6933cd0..bf4a04dfd1 100644 --- a/scripts/tests/issue-2603-release-pack.cjs +++ b/scripts/tests/issue-2603-release-pack.cjs @@ -106,6 +106,8 @@ const NON_NPM_RELEASE_PACKAGES = new Set([ let cachedReleaseTarball = null; let cachedReplicaTarball = null; +let cachedRepoRoot = null; +let cachedFingerprint = null; /** * Locate the .tgz filename in npm pack output. Delegates to the shared @@ -115,15 +117,25 @@ 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. A single artifact (e.g. release - // packed but replica failed) must not be served as a complete result. - if ( - cachedReleaseTarball && - existsSync(cachedReleaseTarball) && - cachedReplicaTarball && - existsSync(cachedReplicaTarball) - ) { + // 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, @@ -172,10 +184,18 @@ function packReleaseLikeCli(repoRoot) { 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. + // 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, diff --git a/scripts/tests/issue-2603-smoke-helpers.test.ts b/scripts/tests/issue-2603-smoke-helpers.test.ts index 5badcf7252..95f8c84ba5 100644 --- a/scripts/tests/issue-2603-smoke-helpers.test.ts +++ b/scripts/tests/issue-2603-smoke-helpers.test.ts @@ -238,4 +238,79 @@ describe('probeArg nativeExit payload contract', () => { 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 = () => + nodeRequire( + join( + repoRoot, + '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', '--loglevel', 'error']); + }); + }); }); diff --git a/scripts/tests/issue-2603-windows-probe.ts b/scripts/tests/issue-2603-windows-probe.ts index a12ce49149..1ed3ccf31e 100644 --- a/scripts/tests/issue-2603-windows-probe.ts +++ b/scripts/tests/issue-2603-windows-probe.ts @@ -28,16 +28,26 @@ interface ProbeRequest { long?: boolean; } +/** + * Interval for the keep-alive handle in the long-running probe mode. Extracted + * to a named constant so the value is documented and CI-specific tuning is + * straightforward. + */ +const KEEP_ALIVE_INTERVAL_MS = 60_000; + function parseRequest(): { request: ProbeRequest; raw: string; malformed: boolean; + count: number; } { const request: ProbeRequest = {}; let raw = ''; let malformed = false; + let count = 0; for (const arg of process.argv.slice(2)) { if (arg.startsWith('LLXPRT_PROBE_B64=')) { + count++; raw = Buffer.from( arg.slice('LLXPRT_PROBE_B64='.length), 'base64url', @@ -53,7 +63,7 @@ function parseRequest(): { } } } - return { request, raw, malformed }; + return { request, raw, malformed, count }; } function readStdinSync(): string { @@ -124,8 +134,16 @@ function nativeExitWithStatus(status: number): never { * captures the complete JSON before any process.exit. Without this, buffered * stdout on Windows pipes can be truncated when the process exits. */ +/** + * The dedicated probe line prefix emitted before the JSON payload. Using a + * sentinel makes extraction robust against interleaved log output or warnings + * that may appear on stdout alongside the payload. + */ +const PROBE_SENTINEL = 'LLXPRT_PROBE:'; + async function emitAndFlush(payload: Record): Promise { - process.stdout.write(JSON.stringify(payload)); + process.stdout.write(`${PROBE_SENTINEL}${JSON.stringify(payload)} +`); await drainStdout(); } @@ -160,7 +178,7 @@ function drainStdout(): Promise { } async function main(): Promise { - const { request, raw, malformed } = parseRequest(); + const { request, raw, malformed, count } = parseRequest(); const payload: Record = { argv: process.argv, @@ -180,6 +198,16 @@ async function main(): Promise { payload.malformed = true; payload.raw = raw; } + // Distinguish a misconfigured invocation (zero or duplicate control + // payloads) from a genuine success. All callers send exactly one payload; + // any other count surfaces as a distinct diagnostic field so the parent + // process detects the misconfiguration instead of treating it as success. + if (count !== 1) { + payload.probeError = + count === 0 + ? 'no LLXPRT_PROBE_B64 argument provided' + : `${count} LLXPRT_PROBE_B64 arguments provided (expected exactly 1)`; + } payload.stdin = request.stdin ? readStdinSync() : ''; @@ -198,7 +226,7 @@ async function main(): Promise { // the process resident until the signal handler clears it and exits. const keepAlive = setInterval(() => { // no-op: the handle's existence, not its work, keeps the process alive - }, 60_000); + }, KEEP_ALIVE_INTERVAL_MS); // NOTE: do NOT unref() this interval — an unref'd handle does not keep the // process alive, which is exactly the bug we are fixing. const handler = (): void => { diff --git a/scripts/tests/npm-command.test.ts b/scripts/tests/npm-command.test.ts index 9ec1f504fe..a2c3c221d7 100644 --- a/scripts/tests/npm-command.test.ts +++ b/scripts/tests/npm-command.test.ts @@ -293,4 +293,94 @@ describe('resolveNpmCliJs existence verification', () => { }); expect(result).toBe(fallback); }); + + describe('resolveNpmCliJs rejects pnpm/yarn npm_execpath (security)', () => { + // pnpm and Yarn also set npm_execpath during lifecycle scripts. The resolver + // must only trust npm-cli.js (basename check) so the wrong package manager + // CLI is never spawned. + it('rejects a pnpm npm_execpath and falls through to the node-dir fallback', () => { + const result = resolveNpmCliJs({ + env: { npm_execpath: 'C:\\pnpm\\pnpm-cli.js' }, + execPath: 'C:\\node\\node.exe', + existsSync: () => true, + }); + expect(result).not.toBe('C:\\pnpm\\pnpm-cli.js'); + expect(result).toMatch(/node_modules[\\/]npm[\\/]bin[\\/]npm-cli\.js$/); + }); + + it('rejects a yarn npm_execpath and falls through to the node-dir fallback', () => { + const result = resolveNpmCliJs({ + env: { npm_execpath: 'C:\\yarn\\yarn-cli.js' }, + execPath: 'C:\\node\\node.exe', + existsSync: () => true, + }); + expect(result).not.toBe('C:\\yarn\\yarn-cli.js'); + expect(result).toMatch(/node_modules[\\/]npm[\\/]bin[\\/]npm-cli\.js$/); + }); + }); + + describe('resolveNpmCliJs NPM_CONFIG_PREFIX fallback', () => { + it('resolves npm-cli.js from NPM_CONFIG_PREFIX when node-dir fallback is absent', () => { + // Simulate nvm-windows / Volta where npm is NOT alongside node.exe. + // path.join on POSIX uses forward slashes for the appended segments; + // the Windows prefix may contain backslashes. The existsSync stub matches + // the exact joined path. + const prefixPath = 'C:\\Users\\dev\\AppData\\Roaming\\npm'; + const result = resolveNpmCliJs({ + env: { NPM_CONFIG_PREFIX: prefixPath }, + execPath: 'C:\\node\\node.exe', + existsSync: (p) => p.endsWith('npm-cli.js') && p.includes(prefixPath), + }); + expect(result).toContain('npm-cli.js'); + expect(result).toContain(prefixPath); + }); + + it('resolves from APPDATA when NPM_CONFIG_PREFIX is absent', () => { + // nvm-windows global npm roaming install location. + const appdataPath = 'C:\\Users\\dev\\AppData\\Roaming'; + const result = resolveNpmCliJs({ + env: { APPDATA: appdataPath }, + execPath: 'C:\\node\\node.exe', + existsSync: (p) => + p.endsWith('npm-cli.js') && + p.includes(appdataPath) && + p.includes('npm'), + }); + expect(result).toContain('npm-cli.js'); + expect(result).toContain(appdataPath); + }); + + it('throws NpmCliNotFoundError when neither prefix fallback exists', () => { + expect(() => + resolveNpmCliJs({ + env: { + NPM_CONFIG_PREFIX: 'C:\\prefix', + APPDATA: 'C:\\appdata', + }, + execPath: 'C:\\node\\node.exe', + existsSync: () => false, + }), + ).toThrow(NpmCliNotFoundError); + }); + + it('includes prefix probed paths in the error details when all fail', () => { + try { + resolveNpmCliJs({ + env: { + NPM_CONFIG_PREFIX: 'C:\\prefix', + APPDATA: 'C:\\appdata', + }, + execPath: 'C:\\node\\node.exe', + existsSync: () => false, + }); + } catch (e) { + const err = e as Error & { details?: { probed: string[] } }; + expect(err.details).toBeDefined(); + // probed should include the NPM_CONFIG_PREFIX and APPDATA candidates. + const probedStr = JSON.stringify(err.details?.probed); + expect(probedStr).toContain('C:\\\\prefix'); + expect(probedStr).toContain('C:\\\\appdata'); + } + }); + }); }); diff --git a/scripts/tests/tar-command.test.ts b/scripts/tests/tar-command.test.ts new file mode 100644 index 0000000000..4893474f78 --- /dev/null +++ b/scripts/tests/tar-command.test.ts @@ -0,0 +1,103 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + * + * Behavioral tests for the shared tar-command helper. Covers the contract + * that findTarballName selects the final non-empty .tgz line and that all + * spawn helpers include stderr || stdout in their error diagnostics. + */ + +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'node:module'; +import { resolve, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, writeFileSync, rmSync } 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 tarCommand = nodeRequire( + join(repoRoot, 'scripts', 'lib', 'tar-command.cjs'), +) as { + findTarballName: (output: string, cacheDir?: string) => string; + TAR_TIMEOUT_MS: number; +}; + +describe('findTarballName', () => { + it('returns the final .tgz line from standard npm pack output', () => { + const output = 'npm notice\nnpm notice\nvybestack-llxprt-code-0.10.0.tgz\n'; + expect(tarCommand.findTarballName(output)).toBe( + 'vybestack-llxprt-code-0.10.0.tgz', + ); + }); + + it('returns the LAST .tgz line when multiple .tgz lines exist', () => { + // Simulate a verbose npm environment where a warning line happens to end + // with .tgz. The function must return the final .tgz line, which is the + // actual tarball filename. + const output = + 'npm notice some-warning.tgz\n' + + 'npm notice more output\n' + + 'vybestack-llxprt-code-0.10.0.tgz\n'; + expect(tarCommand.findTarballName(output)).toBe( + 'vybestack-llxprt-code-0.10.0.tgz', + ); + }); + + it('ignores trailing empty lines after the .tgz filename', () => { + const output = 'npm notice\nvybestack-llxprt-code-0.10.0.tgz\n\n\n'; + expect(tarCommand.findTarballName(output)).toBe( + 'vybestack-llxprt-code-0.10.0.tgz', + ); + }); + + it('handles output with no trailing newline', () => { + const output = 'npm notice\nvybestack-llxprt-code-0.10.0.tgz'; + expect(tarCommand.findTarballName(output)).toBe( + 'vybestack-llxprt-code-0.10.0.tgz', + ); + }); + + it('throws when no .tgz line is found', () => { + expect(() => + tarCommand.findTarballName('npm notice\nno tarball here\n'), + ).toThrow(/did not contain a \.tgz line/); + }); + + it('throws when output is empty', () => { + expect(() => tarCommand.findTarballName('')).toThrow( + /did not contain a \.tgz line/, + ); + }); + + it('validates the tarball exists when cacheDir is provided', () => { + const dir = mkdtempSync(join(tmpdir(), 'tarball-name-')); + try { + const tarName = 'test-pkg-1.0.0.tgz'; + writeFileSync(join(dir, tarName), 'fake tarball'); + expect(tarCommand.findTarballName(`${tarName}\n`, dir)).toBe(tarName); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('throws when the tarball does not exist in cacheDir', () => { + const dir = mkdtempSync(join(tmpdir(), 'tarball-name-missing-')); + try { + expect(() => + tarCommand.findTarballName('missing-1.0.0.tgz\n', dir), + ).toThrow(/does not exist/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('TAR_TIMEOUT_MS', () => { + it('is a positive number', () => { + expect(tarCommand.TAR_TIMEOUT_MS).toBeGreaterThan(0); + }); +}); diff --git a/scripts/windows-installed-command-smoke.cjs b/scripts/windows-installed-command-smoke.cjs index 2107c5d926..785a0ce895 100644 --- a/scripts/windows-installed-command-smoke.cjs +++ b/scripts/windows-installed-command-smoke.cjs @@ -192,6 +192,25 @@ function runInstalledBenchmark({ ); } +/** + * Runs all behavioral checks (argv, stdio, exit codes, process tree, missing/ + * corrupt Bun, npm exec) on the installed launcher fixture. Extracted from + * runSmoke to keep it under the max-lines-per-function lint limit. + */ +function runBehavioralChecks(ctx) { + const { probeFixture, tempDir, installedPackageRoot, replicaTarball } = ctx; + checks.checkCmdArgFidelity(probeFixture); + checks.checkPwshArgFidelity(probeFixture); + checks.checkInjectionGuard(probeFixture, tempDir); + checks.checkStdioForwarding(probeFixture); + checks.checkCmdExitCodePreservation(probeFixture); + checks.checkPwshExitPropagation(probeFixture); + checks.checkExecPathIsBundledBun(probeFixture); + checks.checkMissingBun({ installedPackageRoot }, tempDir, repoRoot); + checks.checkCorruptBun({ installedPackageRoot }, tempDir, repoRoot); + checks.checkNpmExecEphemeral(tempDir, replicaTarball); +} + function runSmoke() { resetState(); let tempDir; @@ -227,23 +246,28 @@ function runSmoke() { repoRoot, ); - checks.checkCmdArgFidelity(probeFixture); - checks.checkPwshArgFidelity(probeFixture); - checks.checkInjectionGuard(probeFixture, tempDir); - checks.checkStdioForwarding(probeFixture); - checks.checkCmdExitCodePreservation(probeFixture); - checks.checkPwshExitPropagation(probeFixture); - checks.checkExecPathIsBundledBun(probeFixture); + runBehavioralChecks({ + probeFixture, + tempDir, + installedPackageRoot, + replicaTarball, + }); await checks.checkProcessTreeNoNode(probeFixture); - checks.checkMissingBun({ installedPackageRoot }, tempDir, repoRoot); - checks.checkCorruptBun({ installedPackageRoot }, tempDir, repoRoot); - - checks.checkNpmExecEphemeral(tempDir, replicaTarball); - const consumerDir = localInstall(tempDir, replicaTarball); checkLocalCmdVersion(consumerDir); checkPackageLocalBun(prefix, findInstalledPackageRoot, findBundledBun); + // Gate succeeded on the assertion state so a runStep failure (which + // records via fail() without throwing) is not masked as success. + // Without this guard, a local-cmd-version or package-local-bun failure + // would write a success diagnostic, run the benchmark, and delete the + // temp fixture that the failure diagnostic says to preserve. + const { failed } = getState(); + if (failed) { + throw new Error( + 'prerequisite checks failed (local-cmd-version or package-local-bun); aborting before success', + ); + } succeeded = true; } catch (err) { fail(`unexpected error: ${err.stack || err.message}`); diff --git a/scripts/windows-installed-command-smoke/install-helpers.cjs b/scripts/windows-installed-command-smoke/install-helpers.cjs index 14220c6827..dd6cfed824 100644 --- a/scripts/windows-installed-command-smoke/install-helpers.cjs +++ b/scripts/windows-installed-command-smoke/install-helpers.cjs @@ -44,8 +44,18 @@ const SPAWN_MAX_BUFFER = 64 * 1024 * 1024; * the install inherits the warmed default npm cache (the same one `npm ci` * populated in the workflow). An empty isolated per-fixture cache forced * re-fetches and caused the ETIMEDOUT cascades seen in CI run 29850614559. + * + * @param {string[]} extraArgs - install arguments to prepend (e.g. global + * prefix flags and the tarball path). + * @returns {string[]} the full argument list for npm install. + * @throws {TypeError} when extraArgs is not an array. */ function buildInstallArgs(extraArgs) { + if (!Array.isArray(extraArgs)) { + throw new TypeError( + `buildInstallArgs: extraArgs must be an array (got ${typeof extraArgs})`, + ); + } return ['install', ...extraArgs, '--loglevel', 'error']; } diff --git a/scripts/windows-installed-command-smoke/launcher-invocation.cjs b/scripts/windows-installed-command-smoke/launcher-invocation.cjs index 2af4c260c4..4a02346fa1 100644 --- a/scripts/windows-installed-command-smoke/launcher-invocation.cjs +++ b/scripts/windows-installed-command-smoke/launcher-invocation.cjs @@ -44,11 +44,42 @@ function probeArg(request) { } /** - * Parses the probe JSON payload from the launcher's stdout. Wraps JSON.parse - * in a try/catch and re-throws with the raw stdout context so test failures - * show the actual malformed content instead of an opaque SyntaxError. + * The dedicated probe line prefix emitted by the probe before its JSON + * payload. Using a sentinel makes extraction robust against log lines, + * warnings, or other output that may appear on stdout alongside the payload. + */ +const PROBE_SENTINEL = 'LLXPRT_PROBE:'; + +/** + * Parses the probe JSON payload from the launcher's stdout. + * + * Extraction strategy: first attempt to find a line starting with the + * dedicated probe sentinel (PROBE_SENTINEL). If found, parse the remainder of + * that line as JSON. This is robust against interleaved log output. If no + * sentinel line is found, fall back to brace-matching extraction (first '{' to + * last '}') for backward compatibility with probe output that predates the + * sentinel. Both paths validate that the extracted slice is valid JSON. + * + * @param {string} stdout - the raw stdout from the launcher. + * @returns {Record} the parsed probe payload. + * @throws {Error} when no JSON object can be extracted or parsing fails. */ function parseProbeOutput(stdout) { + // Prefer the dedicated sentinel line for robust extraction. + for (const line of stdout.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed.startsWith(PROBE_SENTINEL)) { + const jsonText = trimmed.slice(PROBE_SENTINEL.length).trim(); + try { + return JSON.parse(jsonText); + } catch (e) { + throw new Error( + `failed to parse probe JSON (sentinel line): ${e.message}\njsonText=${JSON.stringify(jsonText)}\nfullStdout=${JSON.stringify(stdout)}`, + ); + } + } + } + // Fallback: brace-matching extraction for backward compatibility. const start = stdout.indexOf('{'); const end = stdout.lastIndexOf('}'); if (start === -1 || end === -1 || end < start) { @@ -199,6 +230,7 @@ function spawnCmdLongRunning(cmdPath, args, opts) { module.exports = { probeArg, parseProbeOutput, + PROBE_SENTINEL, invokeCmd, invokePwsh, cmdInvocationArgs, diff --git a/scripts/windows-installed-command-smoke/package-layout.cjs b/scripts/windows-installed-command-smoke/package-layout.cjs index afd89dd070..2c2ef19624 100644 --- a/scripts/windows-installed-command-smoke/package-layout.cjs +++ b/scripts/windows-installed-command-smoke/package-layout.cjs @@ -93,12 +93,23 @@ function copyTree(src, dest) { try { cpSync(src, dest, { recursive: true, - // Separator-neutral .bin exclusion: cpSync may pass paths with either - // forward or backslash separators on Windows, so normalize before - // checking. + // Exclude only the exact node_modules/.bin directory. Substring matching + // would also exclude node_modules/.binaries or node_modules/.bin-test. + // Normalize separators, then split on / and check that the final two + // segments are node_modules and .bin. filter: (s) => { const normalized = s.replace(/\\/g, '/'); - return !normalized.includes('node_modules/.bin'); + const segments = normalized.split('/'); + if (segments.length >= 2) { + const segCount = segments.length; + if ( + segments[segCount - 1] === '.bin' && + segments[segCount - 2] === 'node_modules' + ) { + return false; + } + } + return true; }, }); } catch (e) { From 0627da90a8b1dc59532777b0732bdb4f3b051e6a Mon Sep 17 00:00:00 2001 From: acoliver Date: Wed, 22 Jul 2026 09:46:53 -0300 Subject: [PATCH 13/18] Increase Windows install timeout headroom --- .../tests/issue-2603-smoke-fail-fast.test.ts | 24 +++++++++++++++++-- .../constants.cjs | 12 +++++++--- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/scripts/tests/issue-2603-smoke-fail-fast.test.ts b/scripts/tests/issue-2603-smoke-fail-fast.test.ts index a67e7c9f0b..10b6c19c0d 100644 --- a/scripts/tests/issue-2603-smoke-fail-fast.test.ts +++ b/scripts/tests/issue-2603-smoke-fail-fast.test.ts @@ -594,9 +594,29 @@ describe('constants: expected bun version + configurable timeouts', () => { } }); - it('install timeout default is generous enough for a warmed-cache install', () => { + 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(); - expect(m.INSTALL_TIMEOUT_MS).toBeGreaterThanOrEqual(180_000); + 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); }); }); diff --git a/scripts/windows-installed-command-smoke/constants.cjs b/scripts/windows-installed-command-smoke/constants.cjs index a98f4b4fba..90864b0e0d 100644 --- a/scripts/windows-installed-command-smoke/constants.cjs +++ b/scripts/windows-installed-command-smoke/constants.cjs @@ -102,11 +102,17 @@ function readEnvMs(name, defaultMs) { return n; } -// 8 minutes for a single npm install (global/local/exec). The warmed cache -// makes this fast, but give headroom for cold-cache first-run + Bun postinstall. +// 10 minutes for a single npm install (global/local). The warmed cache makes +// this fast on a warm runner, but CI runner variance is significant: the prior +// successful global install completed in 342_875 ms, yet the smoke then failed +// twice at exactly the old 480_000 ms ceiling (ETIMEDOUT), proving 8 minutes +// was too tight. 10 minutes gives ~1.75x headroom over the observed success +// while preserving fail-fast (a genuine hang still aborts in minutes). The +// aggregate ceiling — 2 installs + 1 npm exec (900_000) + benchmark (300_000) +// — sums to 2_400_000 ms (40 min), well under the 60-minute job budget. const INSTALL_TIMEOUT_MS = readEnvMs( 'LLXPRT_SMOKE_INSTALL_TIMEOUT_MS', - 480_000, + 600_000, ); // 15 minutes for npm exec (npx) which can populate its own cache. const NPM_EXEC_TIMEOUT_MS = readEnvMs( From 67b51b185f88d43ce5f9adc4feefa9cb4714e45d Mon Sep 17 00:00:00 2001 From: acoliver Date: Wed, 22 Jul 2026 10:22:51 -0300 Subject: [PATCH 14/18] Prefer cached Windows smoke installs --- .../tests/issue-2603-smoke-fail-fast.test.ts | 49 +++++++++++++++++++ .../tests/issue-2603-smoke-helpers.test.ts | 44 ++++++++++++++++- .../install-helpers.cjs | 37 +++++++++++++- 3 files changed, 128 insertions(+), 2 deletions(-) diff --git a/scripts/tests/issue-2603-smoke-fail-fast.test.ts b/scripts/tests/issue-2603-smoke-fail-fast.test.ts index 10b6c19c0d..7a9e79d2d1 100644 --- a/scripts/tests/issue-2603-smoke-fail-fast.test.ts +++ b/scripts/tests/issue-2603-smoke-fail-fast.test.ts @@ -307,6 +307,55 @@ describe('install helpers cache args (root cause A)', () => { 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']); diff --git a/scripts/tests/issue-2603-smoke-helpers.test.ts b/scripts/tests/issue-2603-smoke-helpers.test.ts index 95f8c84ba5..ab1d52305f 100644 --- a/scripts/tests/issue-2603-smoke-helpers.test.ts +++ b/scripts/tests/issue-2603-smoke-helpers.test.ts @@ -310,7 +310,49 @@ describe('probeArg nativeExit payload contract', () => { it('accepts an empty array', () => { const m = installHelpersModule(); const args = m.buildInstallArgs([]); - expect(args).toStrictEqual(['install', '--loglevel', 'error']); + 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/windows-installed-command-smoke/install-helpers.cjs b/scripts/windows-installed-command-smoke/install-helpers.cjs index dd6cfed824..f066587756 100644 --- a/scripts/windows-installed-command-smoke/install-helpers.cjs +++ b/scripts/windows-installed-command-smoke/install-helpers.cjs @@ -45,6 +45,31 @@ const SPAWN_MAX_BUFFER = 64 * 1024 * 1024; * populated in the workflow). An empty isolated per-fixture cache forced * re-fetches and caused the ETIMEDOUT cascades seen in CI run 29850614559. * + * Cache-first flags (root cause K, PR 2610 three exact-ceiling timeouts): + * The prior successful head completed the global install in 342_875 ms; the + * smoke then timed out three consecutive times at the exact configured + * ceiling (twice at 480_000 ms, once at 600_000 ms) with the same replica + * fingerprint. An identical-fingerprint install regressing from ~343s to a + * full timeout proves the install is blocked on avoidable network activity, + * not compute. npm's defaults run a blocking audit (vulnerability check) + * and a funding-metadata round-trip to the registry on every install, and + * re-fetch packument metadata even when the warmed cache holds a copy. The + * flags below eliminate that avoidable registry/audit work while preserving + * real install behavior and lifecycle scripts: + * + * --no-audit skip the blocking vulnerability audit HTTP call + * --no-fund skip the blocking funding metadata HTTP call + * --prefer-offline serve from the warmed cache, fall back to the registry + * + * --prefer-offline (NOT --offline) is deliberately used so a cache miss or + * stale metadata entry transparently falls back to the registry rather than + * hard-failing the install. The finite 600_000 ms timeout in constants.cjs + * is RETAINED so a genuine hang still aborts in minutes. + * + * No weakening flags are used: --ignore-scripts (would skip the postinstall + * that installs native launchers, defeating the smoke) and --force (would + * clobber install-integrity guarantees) must NEVER appear. + * * @param {string[]} extraArgs - install arguments to prepend (e.g. global * prefix flags and the tarball path). * @returns {string[]} the full argument list for npm install. @@ -56,7 +81,17 @@ function buildInstallArgs(extraArgs) { `buildInstallArgs: extraArgs must be an array (got ${typeof extraArgs})`, ); } - return ['install', ...extraArgs, '--loglevel', 'error']; + return [ + 'install', + ...extraArgs, + // Skip blocking registry audit/funding round-trips and prefer the warmed + // cache with registry fallback. See root cause K in the JSDoc above. + '--no-audit', + '--no-fund', + '--prefer-offline', + '--loglevel', + 'error', + ]; } function globalInstall(tempDir, replicaTarball) { From 2e84620d4d5d6a06b22a6c8789057e3e087ee052 Mon Sep 17 00:00:00 2001 From: acoliver Date: Wed, 22 Jul 2026 12:20:10 -0300 Subject: [PATCH 15/18] Harden launcher review contracts --- .../cli/scripts/install-native-launchers.cjs | 46 ++++- scripts/lib/npm-command.cjs | 10 +- scripts/lib/tar-command.cjs | 20 ++- scripts/tests/bun-script-migration.test.ts | 2 +- scripts/tests/issue-2603-install.test.ts | 99 +++++------ .../issue-2603-launcher-hardening.test.ts | 10 +- ...sue-2603-launcher-source-workspace.test.ts | 23 ++- scripts/tests/issue-2603-launcher.test.ts | 163 ++++++++++-------- .../issue-2603-release-install-smoke.cjs | 19 ++ .../tests/issue-2603-release-install.test.ts | 9 +- scripts/tests/issue-2603-release-pack.cjs | 21 ++- .../tests/issue-2603-smoke-fail-fast.test.ts | 12 +- .../tests/issue-2603-smoke-helpers.test.ts | 39 ++--- .../tests/issue-2603-startup-benchmark.cjs | 15 +- ...issue-2603-windows-lineage-helpers.test.ts | 2 +- scripts/tests/npm-command.test.ts | 6 +- .../tests/postinstall-manager-aware.test.ts | 20 ++- scripts/tests/tar-command.test.ts | 3 + .../assert.cjs | 9 + .../bun-validation.cjs | 5 +- .../checks.cjs | 23 ++- .../constants.cjs | 17 +- .../launcher-invocation.cjs | 23 ++- .../package-layout.cjs | 51 ++++-- .../process-helpers.cjs | 28 ++- .../pwsh-resolver.cjs | 12 +- 26 files changed, 456 insertions(+), 231 deletions(-) diff --git a/packages/cli/scripts/install-native-launchers.cjs b/packages/cli/scripts/install-native-launchers.cjs index 3904e8eb0c..4032cc4cf7 100644 --- a/packages/cli/scripts/install-native-launchers.cjs +++ b/packages/cli/scripts/install-native-launchers.cjs @@ -13,16 +13,36 @@ 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 ''; + return false; } } function hasOwnershipSentinel(filePath) { - if (!fs.existsSync(filePath)) { + if (!isRegularFile(filePath)) { return false; } return readFileSafe(filePath).includes(OWNERSHIP_SENTINEL); @@ -116,6 +136,11 @@ function shimTargetWithinPackage(content, binLinkDir, packageRoot, shimType) { 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; } @@ -123,11 +148,16 @@ function pointsToOurPackage(filePath, binLinkDir, packageRoot, shimType) { } function canOverwriteLauncher(filePath, binLinkDir, packageRoot, shimType) { - if (!fs.existsSync(filePath)) { - return true; + 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. + // readFileSafe throws on EACCES/EISDIR so a protected shim is NOT silently + // treated as an empty/zero-byte file. const content = readFileSafe(filePath); if (content.includes(OWNERSHIP_SENTINEL)) { return true; @@ -290,13 +320,13 @@ function resolveBunExe(packageRoot) { 'bin', 'bun.exe', ); - if (fs.existsSync(directBunExe)) { + if (isRegularFile(directBunExe)) { return directBunExe; } let dir = packageRoot; while (dir !== path.dirname(dir)) { const candidate = path.join(dir, 'node_modules', 'bun', 'bin', 'bun.exe'); - if (fs.existsSync(candidate)) { + if (isRegularFile(candidate)) { return candidate; } dir = path.dirname(dir); @@ -305,7 +335,7 @@ function resolveBunExe(packageRoot) { } const bunDir = path.dirname(bunPkgJsonPath); const bunExe = path.join(bunDir, 'bin', 'bun.exe'); - if (fs.existsSync(bunExe)) { + if (isRegularFile(bunExe)) { return bunExe; } return null; @@ -313,7 +343,7 @@ function resolveBunExe(packageRoot) { function resolveEntry(packageRoot) { const entry = path.join(packageRoot, 'index.ts'); - if (fs.existsSync(entry)) { + if (isRegularFile(entry)) { return entry; } return null; diff --git a/scripts/lib/npm-command.cjs b/scripts/lib/npm-command.cjs index 6aa5add491..1fb270c83d 100644 --- a/scripts/lib/npm-command.cjs +++ b/scripts/lib/npm-command.cjs @@ -134,7 +134,15 @@ function resolveNpmCliJs(options) { // well-known environment variables (NPM_CONFIG_PREFIX, APPDATA) without // spawning a shell or npm.cmd. const prefixCandidates = []; - if (env.NPM_CONFIG_PREFIX) { + // 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, diff --git a/scripts/lib/tar-command.cjs b/scripts/lib/tar-command.cjs index 0ca56e27c7..4e3211f074 100644 --- a/scripts/lib/tar-command.cjs +++ b/scripts/lib/tar-command.cjs @@ -21,6 +21,15 @@ const { join } = require('node:path'); */ 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; + /** * Spawns tar to list the contents of a tarball (-tzf). Throws on spawn error, * signal termination, or non-zero exit with stderr context. @@ -34,10 +43,11 @@ function spawnTarList(tarball, timeoutMs) { const result = spawnSync('tar', ['-tzf', tarball], { encoding: 'utf8', timeout: timeoutMs || TAR_TIMEOUT_MS, + maxBuffer: TAR_MAX_BUFFER, }); if (result.error) { throw new Error( - `Failed to spawn tar (is it on PATH? GitHub Windows has bsdtar): ${result.error.message}`, + `Failed to spawn tar (is tar on PATH?): ${result.error.message}`, ); } if (result.status !== 0) { @@ -62,10 +72,11 @@ function spawnTarListVerbose(tarball, member, timeoutMs) { const result = spawnSync('tar', ['-tzvf', tarball, member], { encoding: 'utf8', timeout: timeoutMs || TAR_TIMEOUT_MS, + maxBuffer: TAR_MAX_BUFFER, }); if (result.error) { throw new Error( - `Failed to spawn tar (is it on PATH? GitHub Windows has bsdtar): ${result.error.message}`, + `Failed to spawn tar (is tar on PATH?): ${result.error.message}`, ); } if (result.status !== 0) { @@ -90,10 +101,11 @@ function spawnTarExtract(tarball, extractDir, timeoutMs) { const result = spawnSync('tar', ['-xzf', tarball, '-C', extractDir], { encoding: 'utf8', timeout: timeoutMs || TAR_TIMEOUT_MS, + maxBuffer: TAR_MAX_BUFFER, }); if (result.error) { throw new Error( - `Failed to spawn tar (is it on PATH? GitHub Windows has bsdtar): ${result.error.message}`, + `Failed to spawn tar (is tar on PATH?): ${result.error.message}`, ); } if (result.status !== 0) { @@ -130,7 +142,7 @@ function findTarballName(packOutput, cacheDir) { `npm pack output did not contain a .tgz line:\n${packOutput}`, ); } - if (cacheDir) { + if (cacheDir != null) { const tarPath = join(cacheDir, tarName); if (!existsSync(tarPath)) { throw new Error( diff --git a/scripts/tests/bun-script-migration.test.ts b/scripts/tests/bun-script-migration.test.ts index e92cdd3f79..64d7b31cfe 100644 --- a/scripts/tests/bun-script-migration.test.ts +++ b/scripts/tests/bun-script-migration.test.ts @@ -756,7 +756,7 @@ runtimeDescribe( ); expect( cliWithVersion.length, - `expected a bun invocation containing both the CLI entry and --version; got: ${bunArgs.join(String.fromCharCode(10))}`, + `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/issue-2603-install.test.ts b/scripts/tests/issue-2603-install.test.ts index 2b2ea0a505..8b2f61570f 100644 --- a/scripts/tests/issue-2603-install.test.ts +++ b/scripts/tests/issue-2603-install.test.ts @@ -42,6 +42,12 @@ const cliModulePath = join( '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; + 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 @@ -99,7 +105,7 @@ function packCliWorkspace(): string { const { command, args } = npmInvocation([ 'pack', '-w', - '@vybestack/llxprt-code', + CLI_PKG_NAME, '--pack-destination', sharedCacheDir, ]); @@ -261,7 +267,10 @@ describe('install-native-launchers module (CLI workspace)', () => { function resolveCmdShimPath(): string { const candidates = [ '/opt/homebrew/lib/node_modules/npm/node_modules/cmd-shim', - join(process.env.HOME ?? '/root', '.nvm/versions/node', 'npm'), + join( + process.env.HOME ?? '/root', + '.nvm/versions/node/npm/node_modules/cmd-shim', + ), join(repoRoot, 'node_modules', 'cmd-shim'), ]; for (const candidate of candidates) { @@ -304,8 +313,10 @@ describe('install-native-launchers module (CLI workspace)', () => { // spawn error (e.g. tool not installed); fall through to throw. } else if (npmCli.status === 0) { const lines = npmCli.stdout.trim().split('\n'); - const npmBin = dirname(lines[0]!); - npmDir = dirname(npmBin); + if (lines.length > 0 && lines[0]) { + const npmBin = dirname(lines[0]); + npmDir = dirname(npmBin); + } } } if (npmDir) { @@ -512,6 +523,23 @@ describe('install-native-launchers module (CLI workspace)', () => { }); 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({ @@ -535,14 +563,7 @@ describe('install-native-launchers module (CLI workspace)', () => { 'llxprt-code', ); const prefix = join(tempDir); - 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'); + ensureMockBunPackage(packageRoot); const result = mod.installNativeLaunchers({ platform: 'win32', @@ -572,14 +593,7 @@ describe('install-native-launchers module (CLI workspace)', () => { 'llxprt-code', ); const initCwd = join(tempDir, 'consumer'); - 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'); + ensureMockBunPackage(packageRoot); const dotBin = join(initCwd, 'node_modules', '.bin'); mkdirSync(dotBin, { recursive: true }); @@ -611,14 +625,7 @@ describe('install-native-launchers module (CLI workspace)', () => { 'llxprt-code', ); const initCwd = join(tempDir, 'consumer'); - 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'); + ensureMockBunPackage(packageRoot); mkdirSync(join(initCwd, 'node_modules', '.bin'), { recursive: true }); mod.installNativeLaunchers({ @@ -651,14 +658,7 @@ describe('install-native-launchers module (CLI workspace)', () => { '@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'); + ensureMockBunPackage(packageRoot); const dotBin = join(tempDir, 'consumer', 'node_modules', '.bin'); mkdirSync(dotBin, { recursive: true }); @@ -686,14 +686,7 @@ describe('install-native-launchers module (CLI workspace)', () => { '@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'); + ensureMockBunPackage(packageRoot); const dotBin = join(tempDir, 'node_modules', '.bin'); mkdirSync(dotBin, { recursive: true }); @@ -721,14 +714,7 @@ describe('install-native-launchers module (CLI workspace)', () => { '@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'); + ensureMockBunPackage(packageRoot); const packageDotBin = join( tempDir, 'npx-cache', @@ -762,14 +748,7 @@ describe('install-native-launchers module (CLI workspace)', () => { 'llxprt-code', ); const initCwd = join(tempDir, 'consumer'); - 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'); + ensureMockBunPackage(packageRoot); mkdirSync(join(initCwd, 'node_modules', '.bin'), { recursive: true }); mod.installNativeLaunchers({ diff --git a/scripts/tests/issue-2603-launcher-hardening.test.ts b/scripts/tests/issue-2603-launcher-hardening.test.ts index fd5f214eb6..52da1c276c 100644 --- a/scripts/tests/issue-2603-launcher-hardening.test.ts +++ b/scripts/tests/issue-2603-launcher-hardening.test.ts @@ -51,8 +51,14 @@ function ensureBun(): string { if (existsSync(repoBun)) { return repoBun; } - const whichResult = spawnSync('which', ['bun'], { encoding: 'utf8' }); - if (whichResult.status === 0) { + // 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'); diff --git a/scripts/tests/issue-2603-launcher-source-workspace.test.ts b/scripts/tests/issue-2603-launcher-source-workspace.test.ts index 4592f3d94d..10fec68d6e 100644 --- a/scripts/tests/issue-2603-launcher-source-workspace.test.ts +++ b/scripts/tests/issue-2603-launcher-source-workspace.test.ts @@ -21,6 +21,7 @@ import { copyFileSync, chmodSync, readFileSync, + statSync, } from 'node:fs'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -37,12 +38,17 @@ const repoBun = join(repoRoot, 'node_modules', 'bun', 'bin', 'bun.exe'); const LAUNCHER_FAILURE_EXIT = 43; function ensureBun(): string { - if (existsSync(repoBun)) { + if (existsSync(repoBun) && statSync(repoBun).isFile()) { return repoBun; } const whichResult = spawnSync('which', ['bun'], { encoding: 'utf8' }); if (whichResult.status === 0) { - return whichResult.stdout.trim(); + 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'); } @@ -51,14 +57,23 @@ 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 = JSON.parse(readFileSync(bunPkgPath, 'utf8')); - if (typeof bunPkg.version === 'string' && bunPkg.version.length > 0) { + const bunPkg: unknown = JSON.parse(readFileSync(bunPkgPath, 'utf8')); + if (isPackageJson(bunPkg) && bunPkg.version.length > 0) { return bunPkg.version; } throw new Error( diff --git a/scripts/tests/issue-2603-launcher.test.ts b/scripts/tests/issue-2603-launcher.test.ts index 9255fb10a1..e444a5281d 100644 --- a/scripts/tests/issue-2603-launcher.test.ts +++ b/scripts/tests/issue-2603-launcher.test.ts @@ -30,6 +30,10 @@ const repoBun = join(repoRoot, 'node_modules', 'bun', 'bin', 'bun.exe'); */ const LAUNCHER_FAILURE_EXIT = 43; +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 @@ -51,13 +55,24 @@ function ensureBun(): string { if (existsSync(repoBun)) { return repoBun; } - const whichResult = spawnSync('which', ['bun'], { encoding: 'utf8' }); - if (whichResult.status === 0) { + // Use POSIX-standard 'command -v' instead of non-standard 'which' for + // better portability on minimal container images. + const whichResult = spawnSync('sh', ['-c', 'command -v bun'], { + encoding: 'utf8', + }); + if (whichResult.status === 0 && whichResult.stdout.trim()) { return whichResult.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 @@ -109,14 +124,17 @@ function makeLayout( describe('POSIX launcher portability', () => { it('passes shellcheck with no warnings', () => { - const which = spawnSync('which', ['shellcheck'], { encoding: 'utf8' }); + // 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: 15_000, + timeout: SHORT_LAUNCH_TIMEOUT_MS, }); expect( result.status, @@ -146,7 +164,7 @@ describe('POSIX launcher portability', () => { symlinkSync(target, link); const r = spawnSync('sh', ['-c', `readlink -- "${link}"`], { encoding: 'utf8', - timeout: 5_000, + timeout: SHELL_PROBE_TIMEOUT_MS, }); expect(r.status, r.stderr).toBe(0); expect(r.stdout.trim()).toBe(target); @@ -158,7 +176,7 @@ describe('POSIX launcher portability', () => { it('dirname -- handles dash-prefixed names on stock macOS (behavioral proof)', () => { const r = spawnSync('sh', ['-c', `dirname -- "-weird-name"`], { encoding: 'utf8', - timeout: 5_000, + timeout: SHELL_PROBE_TIMEOUT_MS, }); expect(r.status, r.stderr).toBe(0); expect(r.stdout.trim()).toBe('.'); @@ -172,7 +190,7 @@ describe('POSIX launcher portability', () => { const r = spawnSync( 'sh', ['-c', `od -An -tx1 -N4 -- "${elfFile}" | tr -d ' \\n'`], - { encoding: 'utf8', timeout: 5_000 }, + { encoding: 'utf8', timeout: SHELL_PROBE_TIMEOUT_MS }, ); expect(r.status, r.stderr).toBe(0); expect(r.stdout.trim()).toBe('7f454c46'); @@ -207,19 +225,17 @@ describe('POSIX launcher execution behavior', () => { 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: 30_000, + timeout: STANDARD_LAUNCH_TIMEOUT_MS, env: { ...process.env, PATH: '/usr/bin:/bin' }, }); expect(result.error).toBeUndefined(); expect(result.status).toBe(0); - }, 30_000); - + }); 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);`, @@ -227,24 +243,24 @@ describe('POSIX launcher execution behavior', () => { const result = spawnSync(launcherTarget, [], { cwd: pkgRoot, encoding: 'utf8', - timeout: 30_000, + 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'); - }, 30_000); - + }); it('uses package-local Bun even with constrained PATH', () => { const { pkgRoot, launcherTarget } = makeLayout(tempDir); const result = spawnSync(launcherTarget, [], { cwd: pkgRoot, encoding: 'utf8', - timeout: 30_000, + timeout: STANDARD_LAUNCH_TIMEOUT_MS, env: { ...process.env, PATH: '/usr/bin:/bin' }, }); + expectNoSpawnError(result); expect(result.status).toBe(0); - }, 30_000); - + }); it('invokes Bun exactly once (no pre-probe)', () => { const counterDir = join(tempDir, 'counter'); mkdirSync(counterDir, { recursive: true }); @@ -261,15 +277,14 @@ describe('POSIX launcher execution behavior', () => { const result = spawnSync(launcherTarget, [], { cwd: pkgRoot, encoding: 'utf8', - timeout: 30_000, + 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'); - }, 30_000); - + }); it('forwards arguments including spaces, Unicode, and shell metacharacters', () => { const { pkgRoot, launcherTarget } = makeLayout(tempDir, { entryCode: `console.log(JSON.stringify(process.argv.slice(2)));`, @@ -284,13 +299,13 @@ describe('POSIX launcher execution behavior', () => { const result = spawnSync(launcherTarget, trickyArgs, { cwd: pkgRoot, encoding: 'utf8', - timeout: 30_000, + 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); - }, 30_000); - + }); it('propagates a non-zero exit code from the child', () => { const { pkgRoot, launcherTarget } = makeLayout(tempDir, { entryCode: 'process.exit(7);', @@ -298,11 +313,11 @@ describe('POSIX launcher execution behavior', () => { const result = spawnSync(launcherTarget, [], { cwd: pkgRoot, encoding: 'utf8', - timeout: 30_000, + timeout: STANDARD_LAUNCH_TIMEOUT_MS, }); + expectNoSpawnError(result); expect(result.status).toBe(7); - }, 30_000); - + }); it('propagates stdin/stdout/stderr', () => { const { pkgRoot, launcherTarget } = makeLayout(tempDir, { entryCode: [ @@ -315,26 +330,28 @@ describe('POSIX launcher execution behavior', () => { const result = spawnSync(launcherTarget, [], { cwd: pkgRoot, encoding: 'utf8', - timeout: 30_000, + 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'); - }, 30_000); - + }); it('exits 43 when Bun is not found', () => { - const { pkgRoot, launcherTarget } = makeLayout(tempDir, { withBun: false }); + const { pkgRoot, launcherTarget } = makeLayout(tempDir, { + withBun: false, + }); const result = spawnSync(launcherTarget, [], { cwd: pkgRoot, encoding: 'utf8', - timeout: 15_000, + 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); - }, 15_000); - + }); 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 @@ -379,13 +396,13 @@ describe('POSIX launcher execution behavior', () => { const result = spawnSync(launcherTarget, [], { cwd: pkgRoot, encoding: 'utf8', - timeout: 15_000, + 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); - }, 15_000); - + }); 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 @@ -432,12 +449,11 @@ describe('POSIX launcher execution behavior', () => { const result = spawnSync(launcherTarget, [], { cwd: pkgRoot, encoding: 'utf8', - timeout: 30_000, + timeout: STANDARD_LAUNCH_TIMEOUT_MS, env: { ...process.env, PATH: '/usr/bin:/bin' }, }); expect(result.status, result.stderr).toBe(0); - }, 30_000); - + }); it('exits 43 when Bun is a corrupt text file (not a native binary)', () => { const { pkgRoot, launcherTarget } = makeLayout(tempDir, { withBun: false, @@ -450,15 +466,15 @@ describe('POSIX launcher execution behavior', () => { const result = spawnSync(launcherTarget, [], { cwd: pkgRoot, encoding: 'utf8', - timeout: 15_000, + 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('exits 43 when Bun has wrong magic bytes (not ELF/Mach-O/PE)', () => { const { pkgRoot, launcherTarget } = makeLayout(tempDir, { withBun: false, @@ -476,14 +492,15 @@ describe('POSIX launcher execution behavior', () => { const result = spawnSync(launcherTarget, [], { cwd: pkgRoot, encoding: 'utf8', - timeout: 15_000, + 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('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 @@ -514,9 +531,10 @@ describe('POSIX launcher execution behavior', () => { const result = spawnSync(launcherTarget, [], { cwd: pkgRoot, encoding: 'utf8', - timeout: 15_000, + 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, @@ -563,7 +581,7 @@ describe('POSIX launcher execution behavior', () => { const r = spawnSync( 'sh', ['-c', `od -An -tx1 -N4 -- "${peFile}" | tr -d ' \\n'`], - { encoding: 'utf8', timeout: 5_000 }, + { encoding: 'utf8', timeout: SHELL_PROBE_TIMEOUT_MS }, ); expect(r.status, r.stderr).toBe(0); expect(r.stdout.trim().startsWith('4d5a')).toBe(true); @@ -571,7 +589,6 @@ describe('POSIX launcher execution behavior', () => { rmSync(tempDir2, { recursive: true, force: true }); } }); - it('exits 43 when Bun exists but is not executable', () => { const { pkgRoot, launcherTarget } = makeLayout(tempDir, { withBun: false, @@ -585,12 +602,12 @@ describe('POSIX launcher execution behavior', () => { const result = spawnSync(launcherTarget, [], { cwd: pkgRoot, encoding: 'utf8', - timeout: 15_000, + timeout: SHORT_LAUNCH_TIMEOUT_MS, env: { ...process.env, PATH: '/usr/bin:/bin' }, }); + expectNoSpawnError(result); expect(result.status).toBe(LAUNCHER_FAILURE_EXIT); - }, 15_000); - + }); 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 @@ -606,13 +623,12 @@ describe('POSIX launcher execution behavior', () => { const result = spawnSync(launcherTarget, [], { cwd: pkgRoot, encoding: 'utf8', - timeout: 30_000, + 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'); - }, 30_000); - + }); it('preserves a legitimate non-zero exit code from the entry', () => { const { pkgRoot, launcherTarget } = makeLayout(tempDir, { entryCode: 'process.exit(42);', @@ -620,11 +636,11 @@ describe('POSIX launcher execution behavior', () => { const result = spawnSync(launcherTarget, [], { cwd: pkgRoot, encoding: 'utf8', - timeout: 30_000, + timeout: STANDARD_LAUNCH_TIMEOUT_MS, }); + expectNoSpawnError(result); expect(result.status).toBe(42); - }, 30_000); - + }); it('exits 43 when index.ts is not found', () => { const { pkgRoot, launcherTarget } = makeLayout(tempDir, { withIndex: false, @@ -632,13 +648,13 @@ describe('POSIX launcher execution behavior', () => { const result = spawnSync(launcherTarget, [], { cwd: pkgRoot, encoding: 'utf8', - timeout: 15_000, + 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); - }, 15_000); - + }); it('resolves symlinks so $0 works through npm .bin links', () => { const { pkgRoot, launcherTarget } = makeLayout(tempDir); const binLink = join(pkgRoot, 'node_modules', '.bin', 'llxprt'); @@ -648,12 +664,12 @@ describe('POSIX launcher execution behavior', () => { const result = spawnSync(binLink, ['--version'], { cwd: pkgRoot, encoding: 'utf8', - timeout: 30_000, + timeout: STANDARD_LAUNCH_TIMEOUT_MS, env: { ...process.env, PATH: '/usr/bin:/bin' }, }); + expectNoSpawnError(result); expect(result.status).toBe(0); - }, 30_000); - + }); it('does not mutate the environment with LLXPRT_BUN_RELAUNCHED', () => { const { pkgRoot, launcherTarget } = makeLayout(tempDir, { entryCode: `console.log(process.env.LLXPRT_BUN_RELAUNCHED ?? 'unset');`, @@ -661,12 +677,12 @@ describe('POSIX launcher execution behavior', () => { const result = spawnSync(launcherTarget, [], { cwd: pkgRoot, encoding: 'utf8', - timeout: 30_000, + timeout: STANDARD_LAUNCH_TIMEOUT_MS, env: { PATH: '/usr/bin:/bin' }, }); expect(result.status, result.stderr).toBe(0); expect(result.stdout.trim()).toBe('unset'); - }, 30_000); + }); }); describe('POSIX launcher version-pin and platform validation', () => { @@ -679,7 +695,6 @@ describe('POSIX launcher version-pin and platform validation', () => { 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. @@ -717,13 +732,13 @@ describe('POSIX launcher version-pin and platform validation', () => { const result = spawnSync(launcherTarget, [], { cwd: pkgRoot, encoding: 'utf8', - timeout: 15_000, + 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); - }, 15_000); - + }); it('accepts a hoisted Bun whose version matches the package pin', () => { const bunVersion = realBunVersion(); const consumerDir = join(tempDir, 'consumer-pin-match'); @@ -763,12 +778,11 @@ describe('POSIX launcher version-pin and platform validation', () => { const result = spawnSync(launcherTarget, [], { cwd: pkgRoot, encoding: 'utf8', - timeout: 30_000, + timeout: STANDARD_LAUNCH_TIMEOUT_MS, env: { ...process.env, PATH: '/usr/bin:/bin' }, }); expect(result.status, result.stderr).toBe(0); - }, 30_000); - + }); 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 @@ -792,12 +806,13 @@ describe('POSIX launcher version-pin and platform validation', () => { const result = spawnSync(launcherTarget, [], { cwd: pkgRoot, encoding: 'utf8', - timeout: 15_000, + 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); - }, 15_000); + }); it.skipIf(process.platform !== 'darwin')( 'rejects an ELF Bun on Darwin (platform-gated format)', @@ -814,7 +829,7 @@ describe('POSIX launcher version-pin and platform validation', () => { const result = spawnSync(launcherTarget, [], { cwd: pkgRoot, encoding: 'utf8', - timeout: 15_000, + timeout: SHORT_LAUNCH_TIMEOUT_MS, env: { ...process.env, PATH: '/usr/bin:/bin' }, }); expect(result.status).toBe(LAUNCHER_FAILURE_EXIT); diff --git a/scripts/tests/issue-2603-release-install-smoke.cjs b/scripts/tests/issue-2603-release-install-smoke.cjs index 25128c774e..f3f411c043 100644 --- a/scripts/tests/issue-2603-release-install-smoke.cjs +++ b/scripts/tests/issue-2603-release-install-smoke.cjs @@ -130,6 +130,9 @@ function assert(condition, msg) { return condition; } +// Canonical source: scripts/utils/release-packages.ts (NON_NPM_RELEASE_PACKAGES). +// This is duplicated because .cjs scripts cannot import .ts modules without a +// build step. If the canonical set changes, update this too. const NON_NPM_RELEASE_PACKAGES = new Set([ '@vybestack/llxprt-code-test-utils', '@vybestack/llxprt-code-a2a-server', @@ -202,8 +205,23 @@ 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 } = nodeRequire(releasePackHelperPath); const { releaseTarball, replicaTarball } = packReleaseLikeCli(repoRoot); @@ -221,6 +239,7 @@ function main() { ); tempDir = mkdtempSync(join(tmpdir(), 'llxprt-2603-smoke-')); + _cleanupTempDir = tempDir; // 1. Release artifact manifest integrity: exact versions, no file:/link:. runStep('release-manifest-integrity', () => { diff --git a/scripts/tests/issue-2603-release-install.test.ts b/scripts/tests/issue-2603-release-install.test.ts index 23ab23d21f..5dbc774f90 100644 --- a/scripts/tests/issue-2603-release-install.test.ts +++ b/scripts/tests/issue-2603-release-install.test.ts @@ -39,10 +39,11 @@ const releasePackHelper = join( * `spawnSync`) keeps the event loop responsive to Vitest's worker RPC. * * Cleanup design (no process-global listener leaks): - * - The child is spawned NON-detached, so it belongs to the Vitest worker's - * process group. If the worker is terminated (test cancellation, aggregate - * suite teardown, parent signal), the non-detached child is reaped - * automatically by the OS without any registered handlers here. + * - 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. diff --git a/scripts/tests/issue-2603-release-pack.cjs b/scripts/tests/issue-2603-release-pack.cjs index bf4a04dfd1..9cb52ff00f 100644 --- a/scripts/tests/issue-2603-release-pack.cjs +++ b/scripts/tests/issue-2603-release-pack.cjs @@ -70,10 +70,15 @@ function sourceFingerprint(repoRoot) { const { join } = require('node:path'); const hasher = crypto.createHash('sha256'); // Hash the CLI manifest content (the primary source of truth for what gets - // packed). Additional key files can be added here as the packing scope grows. + // 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 { @@ -98,6 +103,9 @@ function processCacheDir(repoRoot) { ); } +// Canonical source: scripts/utils/release-packages.ts (NON_NPM_RELEASE_PACKAGES). +// This is duplicated because .cjs scripts cannot import .ts modules without a +// build step. If the canonical set changes, update this too. const NON_NPM_RELEASE_PACKAGES = new Set([ '@vybestack/llxprt-code-test-utils', '@vybestack/llxprt-code-a2a-server', @@ -181,6 +189,11 @@ function packReleaseLikeCli(repoRoot) { 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); @@ -316,7 +329,11 @@ function assertReleaseBoundManifest(workCopy, internalPkgs) { const cliPkgPath = join(workCopy, 'packages/cli/package.json'); const cliPkg = JSON.parse(readFileSync(cliPkgPath, 'utf8')); const violations = []; - for (const depField of ['dependencies', 'optionalDependencies']) { + for (const depField of [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', + ]) { const deps = cliPkg[depField]; if (!deps) continue; for (const [depName, spec] of Object.entries(deps)) { diff --git a/scripts/tests/issue-2603-smoke-fail-fast.test.ts b/scripts/tests/issue-2603-smoke-fail-fast.test.ts index 7a9e79d2d1..2fd7018459 100644 --- a/scripts/tests/issue-2603-smoke-fail-fast.test.ts +++ b/scripts/tests/issue-2603-smoke-fail-fast.test.ts @@ -20,7 +20,7 @@ * truth); they assert the pure-function/state contracts. */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { createRequire } from 'node:module'; import { resolve, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -58,10 +58,6 @@ const assertModule = () => }; describe('runRequiredStep fail-fast (root cause G)', () => { - beforeEach(() => { - assertModule(); // ensure module loads - }); - it('rethrows when the step function throws, so dependent checks abort', () => { const m = assertModule(); m.resetState(); @@ -114,10 +110,6 @@ describe('runStep OK snapshot (root cause F)', () => { // printing is gated on failures.length not increasing, which we verify // indirectly via the failure accumulation. - beforeEach(() => { - assertModule(); - }); - it('accumulates failures from non-throwing assert() within a step', () => { const m = assertModule(); m.resetState(); @@ -414,7 +406,7 @@ describe('bundled bun.exe PE validation (root cause B, J)', () => { buf[1] = 0x5a; // Z const peOffset = 0x80; buf.writeUInt32LE(peOffset, 0x3c); - buf.write('PE', peOffset, 'latin1'); // P E \0 \0 + buf.write('PE', peOffset, 'latin1'); // writes 'PE'; trailing \0\0 are implicit from Buffer.alloc(size, 0) return buf; } diff --git a/scripts/tests/issue-2603-smoke-helpers.test.ts b/scripts/tests/issue-2603-smoke-helpers.test.ts index ab1d52305f..d549dd174c 100644 --- a/scripts/tests/issue-2603-smoke-helpers.test.ts +++ b/scripts/tests/issue-2603-smoke-helpers.test.ts @@ -19,13 +19,16 @@ const thisFile = fileURLToPath(import.meta.url); const repoRoot = resolve(thisFile, '..', '..', '..'); const nodeRequire = createRequire(import.meta.url); -const launcherInvocation = nodeRequire( - join( - repoRoot, - 'scripts', - 'windows-installed-command-smoke', - 'launcher-invocation.cjs', - ), +/** + * 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; @@ -33,13 +36,8 @@ const launcherInvocation = nodeRequire( pwshQuote: (s: string) => string; }; -const processHelpers = nodeRequire( - join( - repoRoot, - 'scripts', - 'windows-installed-command-smoke', - 'process-helpers.cjs', - ), +const processHelpers = requireSmoke( + 'scripts/windows-installed-command-smoke/process-helpers.cjs', ) as { assertValidPid: (pid: unknown) => void; MAX_LEVELS: number; @@ -183,9 +181,9 @@ describe('assertValidPid', () => { }); describe('MAX_LEVELS', () => { - it('is a positive safety bound for BFS traversal depth', () => { + it('is the expected safety bound for BFS traversal depth', () => { expect(typeof processHelpers.MAX_LEVELS).toBe('number'); - expect(processHelpers.MAX_LEVELS).toBeGreaterThan(0); + expect(processHelpers.MAX_LEVELS).toBe(200); }); }); @@ -282,13 +280,8 @@ describe('probeArg nativeExit payload contract', () => { describe('buildInstallArgs input guard', () => { const installHelpersModule = () => - nodeRequire( - join( - repoRoot, - 'scripts', - 'windows-installed-command-smoke', - 'install-helpers.cjs', - ), + requireSmoke( + 'scripts/windows-installed-command-smoke/install-helpers.cjs', ) as { buildInstallArgs: (extraArgs: string[]) => string[]; }; diff --git a/scripts/tests/issue-2603-startup-benchmark.cjs b/scripts/tests/issue-2603-startup-benchmark.cjs index a10da85743..54e05cb376 100644 --- a/scripts/tests/issue-2603-startup-benchmark.cjs +++ b/scripts/tests/issue-2603-startup-benchmark.cjs @@ -243,8 +243,11 @@ function timeNodeRelayBaseline(bunExe) { // stdio 'inherit' matches the direct launcher for a fair comparison. const relayScript = ` const { spawnSync } = require('child_process'); - const bunExe = process.argv[1]; - const entry = process.argv[2]; + // When Node runs 'node -e