diff --git a/bin/openpi.js b/bin/openpi.js index 62d9d72a..432dc854 100755 --- a/bin/openpi.js +++ b/bin/openpi.js @@ -67,7 +67,13 @@ const stop = () => { }; try { - const jiti = createJiti(import.meta.url); + const piCodingAgentEntry = process.env.OPENPI_PI_CODING_AGENT_ENTRY; + const jiti = createJiti( + import.meta.url, + piCodingAgentEntry + ? { alias: { "@earendil-works/pi-coding-agent": piCodingAgentEntry } } + : {}, + ); const [browserModule, hostModule, runtimeModule, statusModule, traceModule] = await Promise.all([ jiti.import("../web/host/browser-launcher.ts"), diff --git a/extensions/web/index.ts b/extensions/web/index.ts index 58085d29..e1413eda 100644 --- a/extensions/web/index.ts +++ b/extensions/web/index.ts @@ -1,5 +1,6 @@ import { spawn as nodeSpawn } from "node:child_process"; -import { dirname } from "node:path"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI, @@ -7,6 +8,9 @@ import type { } from "@earendil-works/pi-coding-agent"; const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000; +const PI_CODING_AGENT_PACKAGE = "@earendil-works/pi-coding-agent"; +const PI_CODING_AGENT_ENTRY_ENV = "OPENPI_PI_CODING_AGENT_ENTRY"; +const PACKAGE_ROOT_SEARCH_DEPTH = 10; export interface WebProcess { readonly exitCode: number | null; @@ -26,12 +30,56 @@ interface SpawnWebOptions { stdio: "inherit"; } -function webProcessEnvironment(cwd: string) { +function findPackageRoot(realPath: string, packageName: string) { + let dir = dirname(realPath); + for (let depth = 0; depth < PACKAGE_ROOT_SEARCH_DEPTH; depth++) { + const manifestPath = join(dir, "package.json"); + if (existsSync(manifestPath)) { + const manifest: { name?: unknown } = JSON.parse( + readFileSync(manifestPath, "utf8"), + ); + if (manifest.name === packageName) return dir; + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + return undefined; +} + +// Pi loads this extension through jiti aliases, so neither `import.meta.resolve` +// nor `createRequire` can locate the peer package here; the launcher path is the +// only handle that reaches the running Pi installation. +function resolvePiCodingAgentEntryFromLauncher() { + const launcher = process.argv[1]; + if (!launcher) return undefined; + try { + const packageRoot = findPackageRoot( + realpathSync(launcher), + PI_CODING_AGENT_PACKAGE, + ); + if (!packageRoot) return undefined; + const entry = join(packageRoot, "dist", "index.js"); + return existsSync(entry) ? entry : undefined; + } catch { + return undefined; + } +} + +function webProcessEnvironment( + cwd: string, + piCodingAgentEntry: string | undefined, +) { const environment: NodeJS.ProcessEnv = { ...process.env, PWD: cwd }; delete environment.OLDPWD; delete environment.INIT_CWD; delete environment.PI_SESSION_ID; delete environment.PI_SESSION_FILE; + if (piCodingAgentEntry) { + environment[PI_CODING_AGENT_ENTRY_ENV] = piCodingAgentEntry; + } else { + delete environment[PI_CODING_AGENT_ENTRY_ENV]; + } return environment; } @@ -40,6 +88,7 @@ export interface WebCommandDependencies { spawn(command: string, args: string[], options: SpawnWebOptions): WebProcess; clearTerminal(): void; holdParentSigint(): () => void; + resolvePiCodingAgentEntry(): string | undefined; shutdownTimeoutMs: number; } @@ -65,6 +114,7 @@ const defaultDependencies: WebCommandDependencies = { process.on("SIGINT", keepPiAlive); return () => process.removeListener("SIGINT", keepPiAlive); }, + resolvePiCodingAgentEntry: resolvePiCodingAgentEntryFromLauncher, shutdownTimeoutMs: DEFAULT_SHUTDOWN_TIMEOUT_MS, }; @@ -128,7 +178,10 @@ function runWebInForeground( [dependencies.entrypoint, "web", "--no-workspace"], { cwd: childCwd, - env: webProcessEnvironment(childCwd), + env: webProcessEnvironment( + childCwd, + dependencies.resolvePiCodingAgentEntry(), + ), shell: false, stdio: "inherit", }, diff --git a/tests/extensions/web/index.test.ts b/tests/extensions/web/index.test.ts index bedfaa4d..7163cf79 100644 --- a/tests/extensions/web/index.test.ts +++ b/tests/extensions/web/index.test.ts @@ -43,7 +43,12 @@ class FakeWebProcess extends EventEmitter implements WebProcess { } function harness( - options: { mode?: "tui" | "print"; idle?: boolean; stopError?: Error } = {}, + options: { + mode?: "tui" | "print"; + idle?: boolean; + stopError?: Error; + piCodingAgentEntry?: string; + } = {}, ) { const hooks = new Map unknown>>(); let command: CommandHandler | undefined; @@ -56,6 +61,7 @@ function harness( let clearCalls = 0; const notifications: Array<{ message: string; level?: string }> = []; const children: FakeWebProcess[] = []; + const spawnEnvs: NodeJS.ProcessEnv[] = []; const cwd = "/workspace/current"; const pi = { registerCommand(name: string, definition: { handler: CommandHandler }) { @@ -71,6 +77,7 @@ function harness( entrypoint: "/package/bin/openpi.js", spawn(commandName, args, spawnOptions) { spawnCalls++; + spawnEnvs.push(spawnOptions.env); assert.equal(commandName, process.execPath); assert.deepEqual(args, [ "/package/bin/openpi.js", @@ -84,6 +91,10 @@ function harness( assert.equal(spawnOptions.env.PI_SESSION_ID, undefined); assert.equal(spawnOptions.env.PI_SESSION_FILE, undefined); assert.equal(spawnOptions.env.PATH, process.env.PATH); + assert.equal( + spawnOptions.env.OPENPI_PI_CODING_AGENT_ENTRY, + options.piCodingAgentEntry, + ); assert.equal(spawnOptions.shell, false); assert.equal(spawnOptions.stdio, "inherit"); const child = new FakeWebProcess(); @@ -99,6 +110,7 @@ function harness( activeSigint--; }; }, + resolvePiCodingAgentEntry: () => options.piCodingAgentEntry, shutdownTimeoutMs: 20, }; @@ -152,6 +164,7 @@ function harness( emit, children, notifications, + spawnEnv: () => spawnEnvs.at(-1), customCalls: () => customCalls, stopped: () => stopped, started: () => started, @@ -196,6 +209,38 @@ test("/web hands the terminal to the exact packaged Web CLI and restores Pi", as } }); +test("/web hands the child the resolved Pi entry and drops a stale one", async () => { + const previousEntry = process.env.OPENPI_PI_CODING_AGENT_ENTRY; + process.env.OPENPI_PI_CODING_AGENT_ENTRY = + "/stale/pi-coding-agent/dist/index.js"; + const resolvedEntry = + "/pi/node_modules/@earendil-works/pi-coding-agent/dist/index.js"; + try { + const resolved = harness({ piCodingAgentEntry: resolvedEntry }); + const running = resolved.run(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal( + resolved.spawnEnv()?.OPENPI_PI_CODING_AGENT_ENTRY, + resolvedEntry, + ); + resolved.children[0]!.close(0); + await running; + + const unresolved = harness(); + const failed = unresolved.run(); + await new Promise((resolve) => setImmediate(resolve)); + const childEnv = unresolved.spawnEnv(); + assert.ok(childEnv); + assert.equal("OPENPI_PI_CODING_AGENT_ENTRY" in childEnv, false); + unresolved.children[0]!.close(1); + await failed; + } finally { + if (previousEntry === undefined) + delete process.env.OPENPI_PI_CODING_AGENT_ENTRY; + else process.env.OPENPI_PI_CODING_AGENT_ENTRY = previousEntry; + } +}); + test("/web rejects unsupported modes, arguments, busy sessions, and duplicates", async () => { const print = harness({ mode: "print" }); await print.run(); diff --git a/tests/web/cli.test.ts b/tests/web/cli.test.ts index b0f55b96..a5fc1e59 100644 --- a/tests/web/cli.test.ts +++ b/tests/web/cli.test.ts @@ -228,3 +228,79 @@ export class PiWebRuntime { await rm(temporaryRoot, { recursive: true, force: true }); } }); + +test("installed CLI aliases the Pi peer package to the handed-over entry", async () => { + const temporaryRoot = await mkdtemp(join(process.cwd(), ".openpi-cli-test-")); + const packageRoot = join(temporaryRoot, "node_modules", "@tt-a1i", "openpi"); + try { + await mkdir(join(packageRoot, "bin"), { recursive: true }); + await mkdir(join(packageRoot, "web", "host"), { recursive: true }); + await mkdir(join(packageRoot, "web", "runtime"), { recursive: true }); + await cp(entrypointPath, join(packageRoot, "bin", "openpi.js")); + await writeFile( + join(packageRoot, "package.json"), + JSON.stringify({ type: "module" }), + ); + const stubEntry = join(packageRoot, "pi-entry-stub.js"); + await writeFile(stubEntry, 'export const PI_ENTRY_STUB = "handed-over";\n'); + await writeFile( + join(packageRoot, "web", "host", "browser-launcher.ts"), + "export async function openBrowser(): Promise { return false; }\n", + ); + await writeFile( + join(packageRoot, "web", "host", "terminal-status.ts"), + "export function formatWebReadyScreen(options: { origin: string; url: string }): string { return `ready ${options.origin} ${options.url}`; }\n", + ); + await writeFile( + join(packageRoot, "web", "host", "web-host.ts"), + `export class WebHost { + origin = "http://127.0.0.1:12346"; + url = "http://127.0.0.1:12346/"; + async start(): Promise {} + async stop(): Promise {} +}\n`, + ); + await writeFile( + join(packageRoot, "web", "trace.ts"), + "export function traceWeb(): void {}\n", + ); + await writeFile( + join(packageRoot, "web", "runtime", "pi-runtime.ts"), + `import { writeFile } from "node:fs/promises"; +import { PI_ENTRY_STUB } from "@earendil-works/pi-coding-agent"; + +export class PiWebRuntime { + static async createWithoutWorkspace(): Promise<{ cwd: string; dispose(): Promise }> { + const marker = process.env.OPENPI_CLI_PI_ENTRY_MARKER; + if (marker) await writeFile(marker, PI_ENTRY_STUB); + return { + cwd: "/web-owned-bootstrap", + async dispose(): Promise {}, + }; + } +}\n`, + ); + + const entryMarker = join(temporaryRoot, "pi-entry"); + const { stdout } = await execFileAsync( + process.execPath, + [ + join(packageRoot, "bin", "openpi.js"), + "web", + "--no-workspace", + "--no-open", + ], + { + env: { + ...process.env, + OPENPI_PI_CODING_AGENT_ENTRY: stubEntry, + OPENPI_CLI_PI_ENTRY_MARKER: entryMarker, + }, + }, + ); + assert.match(stdout, /ready http:\/\/127\.0\.0\.1:12346/u); + assert.equal(await readFile(entryMarker, "utf8"), "handed-over"); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } +});