From 6f5d2955ed263263b58756b06cef13decd0bc566 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:38:04 +0000 Subject: [PATCH 1/5] Install Pi with bb's own runtime when npm is missing Pi ships only on npm, so Setup could not install it on a computer without Node.js. When the host has no npm, the Pi bridge now offers an Install that runs bb's bundled npm CLI on the runtime the bridge itself runs on (process.execPath, in Node mode under Electron) and installs into /pi-npm-global. A pi on PATH still wins; otherwise the bb-owned copy is detected by status, the install gate, and launch, and runs on the same runtime with ELECTRON_RUN_AS_NODE removed before Pi starts so it does not leak into agent tool shells. Without a bundled npm (or a runtime older than Node 22.19) the existing missingPrerequisite "nodejs" answer stays. Co-Authored-By: Claude Opus 5.5 --- plugins/provider-pi/PLUGIN_OVERVIEW.md | 2 +- plugins/provider-pi/README.md | 4 + plugins/provider-pi/src/bridge/bridge.ts | 8 + .../provider-pi/src/bridge/bundled-runtime.ts | 160 ++++++++++++++++++ .../bridge/provider-maintenance.bun.test.ts | 2 + .../src/bridge/provider-maintenance.test.ts | 107 ++++++++++++ .../src/bridge/provider-maintenance.ts | 129 +++++++++----- plugins/provider-pi/src/bridge/rpc-child.ts | 46 ++++- 8 files changed, 413 insertions(+), 45 deletions(-) create mode 100644 plugins/provider-pi/src/bridge/bundled-runtime.ts diff --git a/plugins/provider-pi/PLUGIN_OVERVIEW.md b/plugins/provider-pi/PLUGIN_OVERVIEW.md index 6bb47ee7c79..6d872efbbb8 100644 --- a/plugins/provider-pi/PLUGIN_OVERVIEW.md +++ b/plugins/provider-pi/PLUGIN_OVERVIEW.md @@ -13,6 +13,6 @@ The plugin starts `pi` in RPC mode on the host and loads a small bb extension in ## Requirements -- Install the Pi coding agent on the host machine: `npm install -g @earendil-works/pi-coding-agent`. Version 0.84.0 or newer is required. +- Install the Pi coding agent on the host machine: `npm install -g @earendil-works/pi-coding-agent`. Version 0.84.0 or newer is required. On a machine without npm, Setup's Install puts Pi in bb's own data directory using the Node.js runtime bundled with bb; no admin rights are needed. - Sign in with `pi` on that machine. - Optional: set `BB_PI_BRIDGE_COMMAND` and `BB_PI_BRIDGE_ARGS` on the host to use a Pi executable that is not on `PATH`. diff --git a/plugins/provider-pi/README.md b/plugins/provider-pi/README.md index 0c78d90da8d..8e13ffb9561 100644 --- a/plugins/provider-pi/README.md +++ b/plugins/provider-pi/README.md @@ -15,6 +15,10 @@ What lives here: - `src/delta-translation.ts` — pi's session events become bb's thread deltas. - `src/bridge/provider-maintenance.ts` — the install gate (`pi --version` ≥ 0.84.0) and the npm install/update actions. +- `src/bridge/bundled-runtime.ts` — when the host has no `npm`, Install runs + bb's bundled npm on the runtime the bridge runs on (`process.execPath`, in + Node mode under Electron) into `/pi-npm-global`. A `pi` on + `PATH` still wins; otherwise that copy launches on the same runtime. - `src/bridge/extension-ui.ts` — pi extension dialogs reach the user: `ctx.ui.select/confirm/input/editor` inside a pi extension arrive as pi RPC `extension_ui_request` lines, the bridge forwards each dialog as a diff --git a/plugins/provider-pi/src/bridge/bridge.ts b/plugins/provider-pi/src/bridge/bridge.ts index d11d2baef4e..61e124db908 100644 --- a/plugins/provider-pi/src/bridge/bridge.ts +++ b/plugins/provider-pi/src/bridge/bridge.ts @@ -47,6 +47,10 @@ import { type PiSessionParams, } from "../session-params.js"; import { piSessionNeedsRelocation } from "./session-cwd.js"; +import { + configurePiBundledRuntime, + resolveBundledNpmCliFrom, +} from "./bundled-runtime.js"; import { BB_PI_EXTENSION_SOURCE } from "./bb-pi-extension.js"; import { createExtensionUiCoordinator, @@ -1239,6 +1243,10 @@ export async function experimental_closeAllForTests(): Promise { export const experimental_providerBridge = experimental_defineProviderBridge({ handleLine, start: (context) => { + configurePiBundledRuntime({ + dataDir: context.dataDir, + npmCli: resolveBundledNpmCliFrom(process.argv[1]), + }); scratchDir = join(context.tempDir, "pi"); scratchDirIsPrivate = false; mkdirSync(scratchDir, { recursive: true }); diff --git a/plugins/provider-pi/src/bridge/bundled-runtime.ts b/plugins/provider-pi/src/bridge/bundled-runtime.ts new file mode 100644 index 00000000000..4487c209401 --- /dev/null +++ b/plugins/provider-pi/src/bridge/bundled-runtime.ts @@ -0,0 +1,160 @@ +import { execFile } from "node:child_process"; +import { readFileSync, statSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { promisify } from "node:util"; +import { + type ProviderInstallationCommand, + experimental_compareVersions as compareVersions, + experimental_formatCommand as formatCommand, + experimental_versionFrom as versionFrom, +} from "@get-bb/plugin-sdk/provider-bridge"; +import { z } from "zod"; + +const execFileAsync = promisify(execFile); +const PI_PACKAGE_SEGMENTS = ["@earendil-works", "pi-coding-agent"] as const; +const PI_PACKAGE_NAME = PI_PACKAGE_SEGMENTS.join("/"); +const PI_MINIMUM_NODE_VERSION = "22.19.0"; +const BUNDLED_PREFIX_DIR_NAME = "pi-npm-global"; +const LATEST_VERSION_TIMEOUT_MS = 15_000; +const PI_BOOTSTRAP_SOURCE = + "delete process.env.ELECTRON_RUN_AS_NODE;import(require('node:url').pathToFileURL(process.argv[1]).href)"; + +const piPackageSchema = z.object({ + bin: z.union([z.string().min(1), z.object({ pi: z.string().min(1) })]), +}); + +interface PiBundledRuntime { + prefix: string; + npmCli: string | null; +} + +let bundledRuntime: PiBundledRuntime | null = null; + +function isFile(file: string): boolean { + try { + return statSync(file).isFile(); + } catch { + return false; + } +} + +export function resolveBundledNpmCliFrom( + entryPath: string | undefined, +): string | null { + if (entryPath === undefined || !path.isAbsolute(entryPath)) return null; + try { + const npmPackageJson = createRequire(entryPath).resolve("npm/package.json"); + const cli = path.join(path.dirname(npmPackageJson), "bin", "npm-cli.js"); + return isFile(cli) ? cli : null; + } catch { + return null; + } +} + +export function configurePiBundledRuntime(args: { + dataDir: string; + npmCli: string | null; +}): void { + bundledRuntime = { + prefix: path.join(args.dataDir, BUNDLED_PREFIX_DIR_NAME), + npmCli: args.npmCli, + }; +} + +export function resetPiBundledRuntimeForTests(): void { + bundledRuntime = null; +} + +export function bbRuntimeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return process.versions.electron === undefined + ? env + : { ...env, ELECTRON_RUN_AS_NODE: "1" }; +} + +function bundledPackageDir(prefix: string): string { + return process.platform === "win32" + ? path.join(prefix, "node_modules", ...PI_PACKAGE_SEGMENTS) + : path.join(prefix, "lib", "node_modules", ...PI_PACKAGE_SEGMENTS); +} + +export function resolveBundledPiCli(): string | null { + if (bundledRuntime === null) return null; + const packageDir = bundledPackageDir(bundledRuntime.prefix); + try { + const pkg = piPackageSchema.parse( + JSON.parse(readFileSync(path.join(packageDir, "package.json"), "utf8")), + ); + const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin.pi; + const cli = path.resolve(packageDir, bin); + const relative = path.relative(packageDir, cli); + if (relative.startsWith("..") || path.isAbsolute(relative)) return null; + return isFile(cli) ? cli : null; + } catch { + return null; + } +} + +export function bundledPiLaunch(cli: string): { + command: string; + args: string[]; +} { + return { + command: process.execPath, + args: ["-e", PI_BOOTSTRAP_SOURCE, cli], + }; +} + +export function isBundledPiLaunch(launch: { + command: string; + args: string[]; +}): boolean { + return ( + launch.command === process.execPath && + launch.args[0] === "-e" && + launch.args[1] === PI_BOOTSTRAP_SOURCE + ); +} + +function usableBundledNpm(): { prefix: string; npmCli: string } | null { + if (bundledRuntime === null || bundledRuntime.npmCli === null) return null; + if (compareVersions(process.versions.node, PI_MINIMUM_NODE_VERSION) < 0) { + return null; + } + return { prefix: bundledRuntime.prefix, npmCli: bundledRuntime.npmCli }; +} + +export function bundledPiInstallCommand(): ProviderInstallationCommand | null { + const runtime = usableBundledNpm(); + if (runtime === null) return null; + const args = [ + runtime.npmCli, + "install", + "--global", + "--prefix", + runtime.prefix, + "--no-audit", + "--no-fund", + `${PI_PACKAGE_NAME}@latest`, + ]; + return { + command: process.execPath, + args, + displayCommand: formatCommand(process.execPath, args), + }; +} + +export async function bundledPiLatestVersion(): Promise { + const runtime = usableBundledNpm(); + if (runtime === null) return null; + try { + const { stdout } = await execFileAsync( + process.execPath, + [runtime.npmCli, "view", PI_PACKAGE_NAME, "version"], + { env: bbRuntimeEnv(process.env), timeout: LATEST_VERSION_TIMEOUT_MS }, + ); + return versionFrom(stdout); + } catch { + return null; + } +} diff --git a/plugins/provider-pi/src/bridge/provider-maintenance.bun.test.ts b/plugins/provider-pi/src/bridge/provider-maintenance.bun.test.ts index f3965a31553..02221f5a389 100644 --- a/plugins/provider-pi/src/bridge/provider-maintenance.bun.test.ts +++ b/plugins/provider-pi/src/bridge/provider-maintenance.bun.test.ts @@ -30,6 +30,8 @@ vi.mock("@get-bb/plugin-sdk/provider-bridge", async (importOriginal) => { }); vi.mock("./rpc-child.js", () => ({ + piProcessEnv: (_launch: unknown, env: NodeJS.ProcessEnv) => env, + resolveBundledPiInUse: () => null, resolvePiLaunch: () => ({ command: probeState.executablePath, args: [] }), resolvePiProcessLaunch: () => ({ command: probeState.executablePath, diff --git a/plugins/provider-pi/src/bridge/provider-maintenance.test.ts b/plugins/provider-pi/src/bridge/provider-maintenance.test.ts index 976d4317772..c4c94e36ffe 100644 --- a/plugins/provider-pi/src/bridge/provider-maintenance.test.ts +++ b/plugins/provider-pi/src/bridge/provider-maintenance.test.ts @@ -2,10 +2,16 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + configurePiBundledRuntime, + resetPiBundledRuntimeForTests, +} from "./bundled-runtime.js"; import { describePiVersionProbeFailure, + getPiProviderInstallationRun, getPiProviderInstallationStatus, } from "./provider-maintenance.js"; +import { resolvePiProcessLaunch } from "./rpc-child.js"; describe("describePiVersionProbeFailure", () => { it("names execFile's own timeout with the 15 s budget", () => { @@ -83,3 +89,104 @@ describe("Pi install prerequisites", () => { expect(status.missingPrerequisite).toBeNull(); }); }); + +describe("Pi installs on bb's own runtime", () => { + let root: string; + let binDir: string; + let dataDir: string; + let npmCli: string; + + beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), "bb-pi-bundled-")); + binDir = path.join(root, "bin"); + dataDir = path.join(root, "bridge-data"); + npmCli = path.join(root, "npm", "bin", "npm-cli.js"); + await fs.mkdir(binDir, { recursive: true }); + await fs.mkdir(path.dirname(npmCli), { recursive: true }); + await fs.writeFile(npmCli, 'console.log("0.90.0");\n'); + vi.stubEnv("PATH", binDir); + vi.stubEnv("BB_PI_BRIDGE_COMMAND", ""); + configurePiBundledRuntime({ dataDir, npmCli }); + }); + + afterEach(async () => { + resetPiBundledRuntimeForTests(); + vi.unstubAllEnvs(); + await fs.rm(root, { force: true, recursive: true }); + }); + + it("installs with bb's runtime and bundled npm into a bb-owned prefix when npm is missing", async () => { + const status = await getPiProviderInstallationStatus(); + + expect(status.installed).toBe(false); + expect(status.missingPrerequisite).toBeNull(); + expect(status.installAction?.kind).toBe("install"); + + const run = await getPiProviderInstallationRun("install"); + if (!run.available) throw new Error(run.message); + expect(run.command.command).toBe(process.execPath); + expect(run.command.command).not.toMatch(/^npm(\.cmd)?$/u); + expect(run.command.args[0]).toBe(npmCli); + expect(run.command.args).toEqual( + expect.arrayContaining([ + "install", + "--global", + "--prefix", + path.join(dataDir, "pi-npm-global"), + "@earendil-works/pi-coding-agent@latest", + ]), + ); + expect(run.verification).toEqual({ kind: "installed" }); + }); + + it("keeps the system npm install when npm is available", async () => { + const npm = process.platform === "win32" ? "npm.cmd" : "npm"; + await fs.writeFile( + path.join(binDir, npm), + process.platform === "win32" + ? "@echo off\r\necho 10.9.0\r\n" + : '#!/bin/sh\necho "10.9.0"\n', + { mode: 0o755 }, + ); + + const run = await getPiProviderInstallationRun("install"); + + if (!run.available) throw new Error(run.message); + expect(run.command.command).toBe(npm); + }); + + it("detects and launches a bb-owned install with bb's runtime", async () => { + const packageDir = + process.platform === "win32" + ? path.join(dataDir, "pi-npm-global", "node_modules") + : path.join(dataDir, "pi-npm-global", "lib", "node_modules"); + const piDir = path.join(packageDir, "@earendil-works", "pi-coding-agent"); + await fs.mkdir(path.join(piDir, "dist"), { recursive: true }); + await fs.writeFile( + path.join(piDir, "package.json"), + JSON.stringify({ + name: "@earendil-works/pi-coding-agent", + version: "0.90.0", + type: "module", + bin: { pi: "dist/cli.js" }, + }), + ); + const cli = path.join(piDir, "dist", "cli.js"); + await fs.writeFile( + cli, + 'if (process.argv.includes("--version")) console.log("0.90.0");\n', + ); + + const launch = resolvePiProcessLaunch(process.env); + expect(launch.command).toBe(process.execPath); + expect(launch.args.at(-1)).toBe(cli); + + const status = await getPiProviderInstallationStatus(); + expect(status.installed).toBe(true); + expect(status.executablePath).toBe(cli); + expect(status.currentVersion).toBe("0.90.0"); + expect(status.latestVersion).toBe("0.90.0"); + expect(status.installAction).toBeNull(); + expect(status.missingPrerequisite).toBeNull(); + }); +}); diff --git a/plugins/provider-pi/src/bridge/provider-maintenance.ts b/plugins/provider-pi/src/bridge/provider-maintenance.ts index e422203072a..0c2a95095a2 100644 --- a/plugins/provider-pi/src/bridge/provider-maintenance.ts +++ b/plugins/provider-pi/src/bridge/provider-maintenance.ts @@ -20,7 +20,16 @@ import { experimental_resolveExecutablePath as resolveExecutablePath, experimental_versionFrom as versionFrom, } from "@get-bb/plugin-sdk/provider-bridge"; -import { resolvePiLaunch, resolvePiProcessLaunch } from "./rpc-child.js"; +import { + bundledPiInstallCommand, + bundledPiLatestVersion, +} from "./bundled-runtime.js"; +import { + piProcessEnv, + resolveBundledPiInUse, + resolvePiLaunch, + resolvePiProcessLaunch, +} from "./rpc-child.js"; const execFileAsync = promisify(execFile); export const PI_MINIMUM_SUPPORTED_VERSION = "0.84.0"; @@ -139,6 +148,7 @@ export async function probePiVersion(): Promise { launch.command, [...launch.args, "--version"], { + env: piProcessEnv(launch, process.env), timeout: VERSION_PROBE_TIMEOUT_MS, }, )); @@ -171,30 +181,46 @@ export function describePiVersionProbeFailure(error: unknown): string { return error instanceof Error ? error.message : String(error); } -async function missingNpmPrerequisite(): Promise<"nodejs" | null> { - return (await commandOutput(npmCommand(), ["--version"])) === null - ? "nodejs" - : null; +async function systemNpmAvailable(): Promise { + return (await commandOutput(npmCommand(), ["--version"])) !== null; } -export async function getPiProviderInstallationStatus( - checkUpdates = true, -): Promise { +async function piInstallCommand(args: { + executablePath: string | null; + bundledCli: string | null; +}): Promise { + if (args.bundledCli !== null) { + return bundledPiInstallCommand() ?? npmGlobalInstallCommand(PI_NPM_PACKAGE); + } + if (args.executablePath !== null) { + return piGlobalInstallCommand(args.executablePath); + } + return (await systemNpmAvailable()) + ? npmGlobalInstallCommand(PI_NPM_PACKAGE) + : bundledPiInstallCommand(); +} + +async function resolvePiInstallationState(checkUpdates: boolean): Promise<{ + status: ProviderInstallationStatus; + command: ProviderInstallationCommand | null; +}> { const launch = resolvePiLaunch(process.env); + const bundledCli = resolveBundledPiInUse(process.env); const [resolvedExecutable, probe, latestVersion, npmGlobal] = await Promise.all([ - resolveExecutablePath(launch.command), + bundledCli ?? resolveExecutablePath(launch.command), probePiVersion(), - checkUpdates ? npmLatestVersion(PI_NPM_PACKAGE) : null, checkUpdates + ? bundledCli === null + ? npmLatestVersion(PI_NPM_PACKAGE) + : bundledPiLatestVersion() + : null, + checkUpdates && bundledCli === null ? probeNpmGlobalPackage(PI_NPM_PACKAGE) : { npmBin: null, npmGlobalPackageVersion: null }, ]); const currentVersion = probe.version; const installed = resolvedExecutable !== null || currentVersion !== null; - const missingPrerequisite = installed - ? null - : await missingNpmPrerequisite(); const needsUpdate = installed && currentVersion !== null && @@ -204,49 +230,63 @@ export async function getPiProviderInstallationStatus( installed && currentVersion !== null && compareVersions(currentVersion, PI_MINIMUM_SUPPORTED_VERSION) < 0; - const actionKind = !installed - ? missingPrerequisite === null - ? "install" - : null + const wantedAction = !installed + ? "install" : needsUpdate || versionUnsupported ? "update" : null; + const command = + wantedAction === null || (installed && !checkUpdates) + ? null + : await piInstallCommand({ + executablePath: resolvedExecutable, + bundledCli, + }); + const missingPrerequisite = !installed && command === null ? "nodejs" : null; const installAction: ProviderInstallationStatus["installAction"] = - actionKind === null || !checkUpdates + wantedAction === null || command === null || !checkUpdates ? null : { - kind: actionKind, - label: actionKind === "install" ? "Install" : "Update", - command: (await piGlobalInstallCommand(resolvedExecutable)) - .displayCommand, + kind: wantedAction, + label: wantedAction === "install" ? "Install" : "Update", + command: command.displayCommand, }; return { - executableName: "pi", - executablePath: resolvedExecutable, - installed, - installSource: npmGlobalInstallSource({ - installed, + command, + status: { + executableName: "pi", executablePath: resolvedExecutable, - npmBin: npmGlobal.npmBin, - }), - currentVersion, - latestVersion, - minimumSupportedVersion: PI_MINIMUM_SUPPORTED_VERSION, - npmPackageName: PI_NPM_PACKAGE, - npmGlobalPackageVersion: npmGlobal.npmGlobalPackageVersion, - installAction, - missingPrerequisite, - needsUpdate, - versionUnsupported, + installed, + installSource: npmGlobalInstallSource({ + installed, + executablePath: resolvedExecutable, + npmBin: npmGlobal.npmBin, + }), + currentVersion, + latestVersion, + minimumSupportedVersion: PI_MINIMUM_SUPPORTED_VERSION, + npmPackageName: PI_NPM_PACKAGE, + npmGlobalPackageVersion: npmGlobal.npmGlobalPackageVersion, + installAction, + missingPrerequisite, + needsUpdate, + versionUnsupported, + }, }; } +export async function getPiProviderInstallationStatus( + checkUpdates = true, +): Promise { + return (await resolvePiInstallationState(checkUpdates)).status; +} + export async function getPiProviderInstallationRun( action: "install" | "update", ): Promise { - const status = await getPiProviderInstallationStatus(); - if (status.installAction?.kind !== action) { + const { status, command } = await resolvePiInstallationState(true); + if (status.installAction?.kind !== action || command === null) { return { available: false, message: `Pi ${action} is no longer available on this host.`, @@ -254,7 +294,7 @@ export async function getPiProviderInstallationRun( } return { available: true, - command: await piGlobalInstallCommand(status.executablePath), + command, verification: installationVerification(status, action), }; } @@ -300,7 +340,10 @@ const INSTALL_GUIDANCE = `Install ${PI_NPM_PACKAGE} ${PI_MINIMUM_SUPPORTED_VERSI async function probePiInstallGate(): Promise { const launch = resolvePiLaunch(process.env); - if ((await resolveExecutablePath(launch.command)) === null) { + if ( + resolveBundledPiInUse(process.env) === null && + (await resolveExecutablePath(launch.command)) === null + ) { return { ok: false, status: "not_installed", @@ -340,7 +383,7 @@ const installGateMemo = new Map< >(); export function getPiInstallGate(): Promise { - const launch = resolvePiLaunch(process.env); + const launch = resolvePiProcessLaunch(process.env); const key = JSON.stringify([launch.command, launch.args]); const now = Date.now(); const cached = installGateMemo.get(key); diff --git a/plugins/provider-pi/src/bridge/rpc-child.ts b/plugins/provider-pi/src/bridge/rpc-child.ts index c203a211823..c6be62f4f48 100644 --- a/plugins/provider-pi/src/bridge/rpc-child.ts +++ b/plugins/provider-pi/src/bridge/rpc-child.ts @@ -10,6 +10,13 @@ import { withoutBridgeRuntimeEnv, } from "@get-bb/plugin-sdk/provider-bridge"; +import { whichCommandSync } from "which-command"; +import { + bbRuntimeEnv, + bundledPiLaunch, + isBundledPiLaunch, + resolveBundledPiCli, +} from "./bundled-runtime.js"; import { resolveWindowsPiProcessLaunch } from "./windows-pi-launch.js"; export const PI_BRIDGE_COMMAND_ENV = "BB_PI_BRIDGE_COMMAND"; @@ -88,15 +95,52 @@ export function resolvePiLaunch(env: NodeJS.ProcessEnv): { return { command, args: parsed }; } +function envValue(env: NodeJS.ProcessEnv, name: string): string | undefined { + const key = Object.keys(env) + .sort() + .find((candidate) => candidate.toUpperCase() === name); + return key === undefined ? undefined : env[key]; +} + +function piIsOnPath(env: NodeJS.ProcessEnv, cwd: string): boolean { + return ( + whichCommandSync("pi", { + cwd, + path: envValue(env, "PATH") ?? "", + pathExt: envValue(env, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD", + }) !== undefined + ); +} + +export function resolveBundledPiInUse( + configEnv: NodeJS.ProcessEnv, + childEnv: NodeJS.ProcessEnv = configEnv, + cwd = process.cwd(), +): string | null { + if (configEnv[PI_BRIDGE_COMMAND_ENV]) return null; + const cli = resolveBundledPiCli(); + if (cli === null || piIsOnPath(childEnv, cwd)) return null; + return cli; +} + export function resolvePiProcessLaunch( configEnv: NodeJS.ProcessEnv, childEnv: NodeJS.ProcessEnv = configEnv, cwd = process.cwd(), ): { command: string; args: string[] } { + const bundledCli = resolveBundledPiInUse(configEnv, childEnv, cwd); + if (bundledCli !== null) return bundledPiLaunch(bundledCli); const launch = resolvePiLaunch(configEnv); return resolveWindowsPiProcessLaunch(launch, childEnv, cwd); } +export function piProcessEnv( + launch: { command: string; args: string[] }, + env: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv { + return isBundledPiLaunch(launch) ? bbRuntimeEnv(env) : env; +} + export function buildPiChildEnv( overrides: Record, ): NodeJS.ProcessEnv { @@ -134,7 +178,7 @@ export class PiRpcChild { command: launch.command, args: [...launch.args, ...args.args], cwd: args.cwd, - env: args.env, + env: piProcessEnv(launch, args.env), stdio: ["pipe", "pipe", "pipe", "pipe", "pipe"], }); experimental_recordProviderChildIo(this.child, { From cf632dead5b4174bba1cb261943c057f606aa004 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:27:20 +0000 Subject: [PATCH 2/5] Add a failing test for Pi dependency install scripts that need node When bb installs Pi with its bundled npm on its own runtime, npm runs dependency install scripts with `node` from PATH, which a computer without Node.js doesn't have. Co-Authored-By: Claude Opus 5.5 --- .../src/bridge/bundled-runtime.test.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 plugins/provider-pi/src/bridge/bundled-runtime.test.ts diff --git a/plugins/provider-pi/src/bridge/bundled-runtime.test.ts b/plugins/provider-pi/src/bridge/bundled-runtime.test.ts new file mode 100644 index 00000000000..30e33a8991b --- /dev/null +++ b/plugins/provider-pi/src/bridge/bundled-runtime.test.ts @@ -0,0 +1,49 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + bundledPiInstallCommand, + configurePiBundledRuntime, + resetPiBundledRuntimeForTests, +} from "./bundled-runtime.js"; + +describe("Pi install on bb's own runtime", () => { + let root: string; + + beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), "bb-pi-node-shim-")); + }); + + afterEach(async () => { + resetPiBundledRuntimeForTests(); + await fs.rm(root, { force: true, recursive: true }); + }); + + it("lets dependency install scripts run `node` when no Node.js is on PATH", async () => { + const fakeNpm = path.join(root, "fake-npm-cli.js"); + await fs.writeFile( + fakeNpm, + [ + 'const { spawnSync } = require("node:child_process");', + 'const result = spawnSync("node", ["-e", "process.stdout.write(\'from-node\')"], { shell: process.platform === "win32", encoding: "utf8" });', + 'process.stdout.write(`status=${result.status} out=${result.stdout ?? ""} argv=${process.argv.slice(2).join(",")}`);', + ].join("\n"), + ); + const emptyPath = path.join(root, "empty-path"); + await fs.mkdir(emptyPath); + configurePiBundledRuntime({ dataDir: root, npmCli: fakeNpm }); + + const install = bundledPiInstallCommand(); + expect(install).not.toBeNull(); + if (install === null) return; + const run = spawnSync(install.command, install.args, { + encoding: "utf8", + env: { ...process.env, PATH: emptyPath, Path: emptyPath }, + }); + + expect(run.stdout).toContain("status=0 out=from-node"); + expect(run.stdout).toContain("argv=install,--global"); + }); +}); From f3714f54b4455a85075382498cd225d3a68e9c9b Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:28:08 +0000 Subject: [PATCH 3/5] Give Pi's dependency install scripts a node that runs on bb's runtime bb installs Pi with its bundled npm on its own runtime, which on desktop is the Electron app rather than a node on PATH. Dependency install scripts call node and failed. The install now starts through a small bootstrap that writes a node shim (node or node.cmd running bb's runtime with ELECTRON_RUN_AS_NODE=1) into bb's Pi folder, puts it first on PATH, and then runs npm. Co-Authored-By: Claude Opus 5.5 --- .../provider-pi/src/bridge/bundled-runtime.ts | 16 ++++++++++++++++ .../src/bridge/provider-maintenance.test.ts | 3 ++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/plugins/provider-pi/src/bridge/bundled-runtime.ts b/plugins/provider-pi/src/bridge/bundled-runtime.ts index 4487c209401..7099062b912 100644 --- a/plugins/provider-pi/src/bridge/bundled-runtime.ts +++ b/plugins/provider-pi/src/bridge/bundled-runtime.ts @@ -19,6 +19,19 @@ const BUNDLED_PREFIX_DIR_NAME = "pi-npm-global"; const LATEST_VERSION_TIMEOUT_MS = 15_000; const PI_BOOTSTRAP_SOURCE = "delete process.env.ELECTRON_RUN_AS_NODE;import(require('node:url').pathToFileURL(process.argv[1]).href)"; +const NPM_BOOTSTRAP_SOURCE = [ + "const fs=require('node:fs'),path=require('node:path');", + "const [prefix,npmCli,...rest]=process.argv.slice(1);", + "const shim=path.join(prefix,'.bb-node-shim');", + "fs.mkdirSync(shim,{recursive:true});", + "const exe=process.execPath;", + "if(process.platform==='win32'){fs.writeFileSync(path.join(shim,'node.cmd'),'@echo off\\r\\nset ELECTRON_RUN_AS_NODE=1\\r\\n\"'+exe+'\" %*\\r\\n');}", + "else{const f=path.join(shim,'node');fs.writeFileSync(f,'#!/bin/sh\\nELECTRON_RUN_AS_NODE=1 exec \\''+exe.replace(/'/g,\"'\\\\''\")+'\\' \"$@\"\\n');fs.chmodSync(f,0o755);}", + "const key=Object.keys(process.env).find(k=>k.toUpperCase()==='PATH')||'PATH';", + "process.env[key]=shim+path.delimiter+(process.env[key]||'');", + "process.argv=[exe,npmCli,...rest];", + "require(npmCli);", +].join(""); const piPackageSchema = z.object({ bin: z.union([z.string().min(1), z.object({ pi: z.string().min(1) })]), @@ -128,6 +141,9 @@ export function bundledPiInstallCommand(): ProviderInstallationCommand | null { const runtime = usableBundledNpm(); if (runtime === null) return null; const args = [ + "-e", + NPM_BOOTSTRAP_SOURCE, + runtime.prefix, runtime.npmCli, "install", "--global", diff --git a/plugins/provider-pi/src/bridge/provider-maintenance.test.ts b/plugins/provider-pi/src/bridge/provider-maintenance.test.ts index c4c94e36ffe..8b6a74fdf94 100644 --- a/plugins/provider-pi/src/bridge/provider-maintenance.test.ts +++ b/plugins/provider-pi/src/bridge/provider-maintenance.test.ts @@ -126,7 +126,8 @@ describe("Pi installs on bb's own runtime", () => { if (!run.available) throw new Error(run.message); expect(run.command.command).toBe(process.execPath); expect(run.command.command).not.toMatch(/^npm(\.cmd)?$/u); - expect(run.command.args[0]).toBe(npmCli); + expect(run.command.args[0]).toBe("-e"); + expect(run.command.args).toContain(npmCli); expect(run.command.args).toEqual( expect.arrayContaining([ "install", From 6aebdb9896c190589329764b256c2e64bc810282 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Wed, 21 Oct 2026 02:32:06 +0000 Subject: [PATCH 4/5] Add a failing test for the Pi node shim under a non-ASCII path with % MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd.exe reads batch files in the console's OEM code page and expands % even inside quotes, so a node.cmd shim that embeds bb's runtime path breaks for a Windows profile like C:\Users\José or one containing %. Co-Authored-By: Claude Opus 5.5 --- .../src/bridge/bundled-runtime.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/plugins/provider-pi/src/bridge/bundled-runtime.test.ts b/plugins/provider-pi/src/bridge/bundled-runtime.test.ts index 30e33a8991b..4b55430b408 100644 --- a/plugins/provider-pi/src/bridge/bundled-runtime.test.ts +++ b/plugins/provider-pi/src/bridge/bundled-runtime.test.ts @@ -46,4 +46,34 @@ describe("Pi install on bb's own runtime", () => { expect(run.stdout).toContain("status=0 out=from-node"); expect(run.stdout).toContain("argv=install,--global"); }); + + it("keeps `node` working when bb's runtime lives under a non-ASCII path with %", async () => { + const runtimeDir = path.join(root, "Jösé 100%"); + await fs.mkdir(runtimeDir); + const runtime = path.join(runtimeDir, path.basename(process.execPath)); + await fs.copyFile(process.execPath, runtime); + await fs.chmod(runtime, 0o755); + const fakeNpm = path.join(root, "fake-npm-cli.js"); + await fs.writeFile( + fakeNpm, + [ + 'const { spawnSync } = require("node:child_process");', + 'const result = spawnSync("node", ["-e", "process.stdout.write(\'from-node\')"], { shell: process.platform === "win32", encoding: "utf8" });', + 'process.stdout.write(`status=${result.status} out=${result.stdout ?? ""}`);', + ].join("\n"), + ); + const emptyPath = path.join(root, "empty-path"); + await fs.mkdir(emptyPath); + configurePiBundledRuntime({ dataDir: root, npmCli: fakeNpm }); + + const install = bundledPiInstallCommand(); + expect(install).not.toBeNull(); + if (install === null) return; + const run = spawnSync(runtime, install.args, { + encoding: "utf8", + env: { ...process.env, PATH: emptyPath, Path: emptyPath }, + }); + + expect(run.stdout).toContain("status=0 out=from-node"); + }); }); From 2d208d96515ee6dd3989f1420361a2fa7d1dec60 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Wed, 21 Oct 2026 02:32:39 +0000 Subject: [PATCH 5/5] Pass bb's runtime path to the Pi node shim through the environment Batch files are read in the console's OEM code page and expand % inside quotes, so embedding bb's runtime path in node.cmd broke for non-ASCII or %-containing Windows paths. The bootstrap now sets PI_BB_NODE_RUNTIME and both shims (node.cmd and the sh script) read it, so no path is quoted into a script at all. Co-Authored-By: Claude Opus 5.5 --- plugins/provider-pi/src/bridge/bundled-runtime.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/provider-pi/src/bridge/bundled-runtime.ts b/plugins/provider-pi/src/bridge/bundled-runtime.ts index 7099062b912..31fd5a3ae11 100644 --- a/plugins/provider-pi/src/bridge/bundled-runtime.ts +++ b/plugins/provider-pi/src/bridge/bundled-runtime.ts @@ -24,12 +24,12 @@ const NPM_BOOTSTRAP_SOURCE = [ "const [prefix,npmCli,...rest]=process.argv.slice(1);", "const shim=path.join(prefix,'.bb-node-shim');", "fs.mkdirSync(shim,{recursive:true});", - "const exe=process.execPath;", - "if(process.platform==='win32'){fs.writeFileSync(path.join(shim,'node.cmd'),'@echo off\\r\\nset ELECTRON_RUN_AS_NODE=1\\r\\n\"'+exe+'\" %*\\r\\n');}", - "else{const f=path.join(shim,'node');fs.writeFileSync(f,'#!/bin/sh\\nELECTRON_RUN_AS_NODE=1 exec \\''+exe.replace(/'/g,\"'\\\\''\")+'\\' \"$@\"\\n');fs.chmodSync(f,0o755);}", + "process.env.PI_BB_NODE_RUNTIME=process.execPath;", + "if(process.platform==='win32'){fs.writeFileSync(path.join(shim,'node.cmd'),'@echo off\\r\\nsetlocal\\r\\nset \"ELECTRON_RUN_AS_NODE=1\"\\r\\n\"%PI_BB_NODE_RUNTIME%\" %*\\r\\n');}", + "else{const f=path.join(shim,'node');fs.writeFileSync(f,'#!/bin/sh\\nELECTRON_RUN_AS_NODE=1 exec \"$PI_BB_NODE_RUNTIME\" \"$@\"\\n');fs.chmodSync(f,0o755);}", "const key=Object.keys(process.env).find(k=>k.toUpperCase()==='PATH')||'PATH';", "process.env[key]=shim+path.delimiter+(process.env[key]||'');", - "process.argv=[exe,npmCli,...rest];", + "process.argv=[process.execPath,npmCli,...rest];", "require(npmCli);", ].join("");