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.test.ts b/plugins/provider-pi/src/bridge/bundled-runtime.test.ts new file mode 100644 index 00000000000..4b55430b408 --- /dev/null +++ b/plugins/provider-pi/src/bridge/bundled-runtime.test.ts @@ -0,0 +1,79 @@ +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"); + }); + + 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"); + }); +}); 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..31fd5a3ae11 --- /dev/null +++ b/plugins/provider-pi/src/bridge/bundled-runtime.ts @@ -0,0 +1,176 @@ +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 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});", + "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=[process.execPath,npmCli,...rest];", + "require(npmCli);", +].join(""); + +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 = [ + "-e", + NPM_BOOTSTRAP_SOURCE, + runtime.prefix, + 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..8b6a74fdf94 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,105 @@ 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("-e"); + expect(run.command.args).toContain(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, {