From 833cfef437de65b8c3b0496bf80a7f1a88d219f2 Mon Sep 17 00:00:00 2001 From: Gustavo Hariel Date: Mon, 1 Jun 2026 00:23:19 -0300 Subject: [PATCH] refactor(cli): port runtime to Node-compatible APIs Replace the Bun-only APIs so funfa runs on Node as well as Bun: Bun.spawn -> node:child_process execFile (argv-only, preserves the ENOENT->127 contract), Bun.argv -> process.argv, @effect/platform-bun BunContext/BunRuntime -> @effect/platform-node NodeContext/NodeRuntime, BunPath -> NodePath in the CLI test, and StderrBun -> StderrNode (it was already runtime-neutral). Drop the now-unused @effect/platform-bun dependency; add @effect/platform-node@0.106.0 (matches @effect/platform 0.96 / effect 3.21 / platform-node-shared 0.59 already in the tree). pi itself uses no Bun globals, and skills inline at build time via the .md text loader. No distribution change: still builds via 'bun build --compile'. This unblocks shipping a small node bundle later. Verified: tsc + Ultracite clean, full suite 105 pass / 1 skip / 0 fail (incl. real makeNodeApplyRunner echo + ENOENT->127 tests), and a node-target bundle runs under node v25 (--help + auth). --- apps/cli/package.json | 2 +- apps/cli/src/__tests__/cli.test.ts | 4 +- apps/cli/src/__tests__/propose.test.ts | 4 +- apps/cli/src/__tests__/tools.test.ts | 8 +-- apps/cli/src/agent/tools/apply-command.ts | 64 ++++++++++++++++------- apps/cli/src/index.ts | 23 ++++---- apps/cli/src/services/stderr.ts | 2 +- bun.lock | 8 ++- 8 files changed, 74 insertions(+), 41 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index a034baf..77761db 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -18,7 +18,7 @@ "@earendil-works/pi-coding-agent": "0.78.0", "@effect/cli": "^0.75.1", "@effect/platform": "^0.96.1", - "@effect/platform-bun": "^0.89.0", + "@effect/platform-node": "^0.106.0", "@effect/printer": "^0.49.0", "@effect/printer-ansi": "^0.49.0", "@inquirer/prompts": "^8.4.3", diff --git a/apps/cli/src/__tests__/cli.test.ts b/apps/cli/src/__tests__/cli.test.ts index 9239fc8..215a01d 100644 --- a/apps/cli/src/__tests__/cli.test.ts +++ b/apps/cli/src/__tests__/cli.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { FileSystem, Terminal } from "@effect/platform"; -import { BunPath } from "@effect/platform-bun"; +import { NodePath } from "@effect/platform-node"; import { Console, Effect, Exit, Layer } from "effect"; import type { AuthOutcome } from "../agent/auth"; import type { ProposeOutput } from "../agent/propose"; @@ -182,7 +182,7 @@ const run = async ( makeTestTerminal(stdoutChunks), makeTestStderr(stderrChunks), makeTestConsole(stdoutChunks, stderrChunks), - BunPath.layer, + NodePath.layer, Layer.succeed(FileSystem.FileSystem, {} as FileSystem.FileSystem) ); const exit = await Effect.runPromiseExit( diff --git a/apps/cli/src/__tests__/propose.test.ts b/apps/cli/src/__tests__/propose.test.ts index a9d8978..adbacfb 100644 --- a/apps/cli/src/__tests__/propose.test.ts +++ b/apps/cli/src/__tests__/propose.test.ts @@ -3,7 +3,7 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent"; import { Effect } from "effect"; import { buildUserPrompt, propose } from "../agent/propose"; import { buildPiAuth, hasUsableAuth } from "../agent/provider"; -import { makeBunApplyRunner } from "../agent/tools/apply-command"; +import { makeNodeApplyRunner } from "../agent/tools/apply-command"; import type { SubmitPlanDeps } from "../agent/tools/submit-plan"; import { skillsMetadata } from "../skills/registry"; @@ -50,7 +50,7 @@ describe("propose — pi session smoke", () => { gameName: "Hades", gate: { bypassConfirms: true, confirm: () => Promise.resolve(false) }, modelRegistry: auth.modelRegistry, - runApply: makeBunApplyRunner(), + runApply: makeNodeApplyRunner(), skills: skillsMetadata.filter((s) => s.name !== "funfa"), submitPlanDeps, webSearch: () => Promise.resolve({ results: [] }), diff --git a/apps/cli/src/__tests__/tools.test.ts b/apps/cli/src/__tests__/tools.test.ts index a5d721d..e04e4c6 100644 --- a/apps/cli/src/__tests__/tools.test.ts +++ b/apps/cli/src/__tests__/tools.test.ts @@ -3,7 +3,7 @@ import type { ToolDefinition } from "@earendil-works/pi-coding-agent"; import { type ExecResult, makeApplyCommandTool, - makeBunApplyRunner, + makeNodeApplyRunner, } from "../agent/tools/apply-command"; import { type LoadSkillResult, @@ -142,15 +142,15 @@ describe("apply_command tool", () => { }); }); -describe("makeBunApplyRunner", () => { +describe("makeNodeApplyRunner", () => { test("runs a real command", async () => { - const result = await makeBunApplyRunner()(["echo", "hello"]); + const result = await makeNodeApplyRunner()(["echo", "hello"]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("hello"); }); test("maps a missing binary to exit code 127", async () => { - const result = await makeBunApplyRunner()([ + const result = await makeNodeApplyRunner()([ "funfa-definitely-not-a-real-binary-xyz", ]); expect(result.exitCode).toBe(127); diff --git a/apps/cli/src/agent/tools/apply-command.ts b/apps/cli/src/agent/tools/apply-command.ts index 5cc6d2a..3a230ca 100644 --- a/apps/cli/src/agent/tools/apply-command.ts +++ b/apps/cli/src/agent/tools/apply-command.ts @@ -1,3 +1,4 @@ +import { execFile } from "node:child_process"; import { defineTool, type ToolDefinition, @@ -30,27 +31,52 @@ const isEnoent = (err: unknown): boolean => err !== null && (err as { code?: string }).code === "ENOENT"; -/** Bun-backed argv runner. Preserves the ENOENT→127 "command absent" contract. */ -export const makeBunApplyRunner = (): ApplyRunner => async (argv) => { - try { - const proc = Bun.spawn([...argv], { stderr: "pipe", stdout: "pipe" }); - const [stdout, stderr] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - ]); - const exitCode = (await proc.exited) ?? 0; - return { exitCode, stderr, stdout }; - } catch (err) { - if (isEnoent(err)) { - return { - exitCode: 127, - stderr: `command not found: ${argv.join(" ")}`, +/** Max captured stdout/stderr per apply step (downloads write to files via -o). */ +const APPLY_MAX_BUFFER = 64 * 1024 * 1024; + +/** + * Node-backed argv runner (uses `node:child_process`, so it runs on Node and + * Bun alike). `execFile` takes an argv array with no shell — matching the + * tool's no-shell contract — and preserves the ENOENT→127 "command absent" + * signal plus the child's real exit code. + */ +export const makeNodeApplyRunner = (): ApplyRunner => (argv) => + new Promise((resolve) => { + const [command, ...args] = argv; + if (command === undefined) { + resolve({ + exitCode: 1, + stderr: "apply_command: argv must not be empty", stdout: "", - }; + }); + return; } - return { exitCode: 1, stderr: String(err), stdout: "" }; - } -}; + execFile( + command, + args, + { encoding: "utf8", maxBuffer: APPLY_MAX_BUFFER }, + (err, stdout, stderr) => { + if (err === null) { + resolve({ exitCode: 0, stderr, stdout }); + return; + } + if (isEnoent(err)) { + resolve({ + exitCode: 127, + stderr: `command not found: ${argv.join(" ")}`, + stdout: "", + }); + return; + } + const code = (err as { code?: unknown }).code; + resolve({ + exitCode: typeof code === "number" ? code : 1, + stderr: stderr.length > 0 ? stderr : String(err), + stdout, + }); + } + ); + }); /** * Build the `apply_command` tool. The permission gate (see `agent/permission.ts`) diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 789647e..3778ba5 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -2,7 +2,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; -import { BunContext, BunRuntime } from "@effect/platform-bun"; +import { NodeContext, NodeRuntime } from "@effect/platform-node"; import { confirm, input, select } from "@inquirer/prompts"; import { Effect, Layer } from "effect"; import { type AuthIO, listAuthProviders, openUrl, runAuth } from "./agent/auth"; @@ -14,7 +14,7 @@ import { MissingProviderKeysError, resolveModelOverride, } from "./agent/provider"; -import { makeBunApplyRunner } from "./agent/tools/apply-command"; +import { makeNodeApplyRunner } from "./agent/tools/apply-command"; import { makeBunFetchPage } from "./agent/tools/fetch-page"; import type { SubmitPlanDeps } from "./agent/tools/submit-plan"; import { makeParallelWebSearch } from "./agent/tools/web-search"; @@ -23,7 +23,7 @@ import { runCli } from "./core"; import { type Deps, layerFromDeps } from "./core/deps"; import { listExistingPlans, writePlan as writePlanToDisk } from "./core/plan"; import { isUserAbort, reviewPlanInteractive } from "./core/review-plan"; -import { StderrBun } from "./services/stderr"; +import { StderrNode } from "./services/stderr"; import { skillsMetadata } from "./skills/registry"; const agentDir = getAgentDir(); @@ -41,7 +41,7 @@ const bunWebSearch = makeSubscriptionWebSearch({ authStorage: piAuth.authStorage, fallback: makeParallelWebSearch({ apiKey: process.env.PARALLEL_API_KEY }), }); -const bunApplyRunner = makeBunApplyRunner(); +const applyRunner = makeNodeApplyRunner(); const siblingSkills = skillsMetadata.filter((s) => s.name !== "funfa"); /** Interactive consent for a PHASE 4 apply step. Default deny (Enter declines). */ @@ -160,7 +160,7 @@ const realDeps: Deps = { gameName, gate: { bypassConfirms, confirm: applyConfirm }, modelRegistry, - runApply: bunApplyRunner, + runApply: applyRunner, skills: siblingSkills, submitPlanDeps: buildSubmitPlanDeps(bypassConfirms), webSearch: bunWebSearch, @@ -203,10 +203,13 @@ const realDeps: Deps = { const AppLayer = Layer.mergeAll( layerFromDeps(realDeps), - StderrBun, - BunContext.layer + StderrNode, + NodeContext.layer ); -BunRuntime.runMain(runCli(Bun.argv.slice(2)).pipe(Effect.provide(AppLayer)), { - disableErrorReporting: true, -}); +NodeRuntime.runMain( + runCli(process.argv.slice(2)).pipe(Effect.provide(AppLayer)), + { + disableErrorReporting: true, + } +); diff --git a/apps/cli/src/services/stderr.ts b/apps/cli/src/services/stderr.ts index 7d3ea90..9c922b1 100644 --- a/apps/cli/src/services/stderr.ts +++ b/apps/cli/src/services/stderr.ts @@ -6,7 +6,7 @@ export interface Stderr { export class StderrTag extends Context.Tag("cli/Stderr")() {} -export const StderrBun: Layer.Layer = Layer.succeed(StderrTag, { +export const StderrNode: Layer.Layer = Layer.succeed(StderrTag, { write: (s) => Effect.sync(() => { process.stderr.write(s); diff --git a/bun.lock b/bun.lock index 8960657..331f34f 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ "@earendil-works/pi-coding-agent": "0.78.0", "@effect/cli": "^0.75.1", "@effect/platform": "^0.96.1", - "@effect/platform-bun": "^0.89.0", + "@effect/platform-node": "^0.106.0", "@effect/printer": "^0.49.0", "@effect/printer-ansi": "^0.49.0", "@inquirer/prompts": "^8.4.3", @@ -143,7 +143,7 @@ "@effect/platform": ["@effect/platform@0.96.1", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.10", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.21.2" } }, "sha512-cjB1QZZYEP8JXCFNGvBLVi0T6YUBQTmOVEUA3SDbiQ6RUO+p6CE3eyD2vMWmrz5nE8yY5QSAuOV9v0boEcUv+A=="], - "@effect/platform-bun": ["@effect/platform-bun@0.89.0", "", { "dependencies": { "@effect/platform-node-shared": "^0.59.0", "multipasta": "^0.2.7" }, "peerDependencies": { "@effect/cluster": "^0.58.0", "@effect/platform": "^0.96.0", "@effect/rpc": "^0.75.0", "@effect/sql": "^0.51.0", "effect": "^3.21.0" } }, "sha512-ReT5f2vujJfffMOBexrgwJd2RLxgfr2G0c1FyCsoflcjdQJ7RZE3cwHDp1M3hAzmG67wWAssMHqLsX6H/n27sQ=="], + "@effect/platform-node": ["@effect/platform-node@0.106.0", "", { "dependencies": { "@effect/platform-node-shared": "^0.59.0", "mime": "^3.0.0", "undici": "^7.10.0", "ws": "^8.18.2" }, "peerDependencies": { "@effect/cluster": "^0.58.0", "@effect/platform": "^0.96.0", "@effect/rpc": "^0.75.0", "@effect/sql": "^0.51.0", "effect": "^3.21.0" } }, "sha512-mpsJK2jNLVd0jQAjHKBo8j3wdKWznSGvfnKBcAuG/9Rr4mb8bMRZFLXHHT9wUP7EvnZ0tDZJgEDxkC+j+ByRag=="], "@effect/platform-node-shared": ["@effect/platform-node-shared@0.59.0", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "multipasta": "^0.2.7", "ws": "^8.18.2" }, "peerDependencies": { "@effect/cluster": "^0.58.0", "@effect/platform": "^0.96.0", "@effect/rpc": "^0.75.0", "@effect/sql": "^0.51.0", "effect": "^3.21.0" } }, "sha512-3bq2YKKfLY7UFauZSxqZUneCXoA3SMSls82V+0RKunvRlfPuPQW0hVn6t1RkvEdh0PDoygWG2mZXYQa6Iqgp9A=="], @@ -521,6 +521,8 @@ "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], @@ -647,6 +649,8 @@ "@aws-sdk/nested-clients/@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.5", "", { "dependencies": { "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-3dA9TQ+ybRSZ/m0wnbZhiBy4Dezjgq1Ib/ZZrYTpJDBgpoLLU/SDzZc/g0x0MNAdOJe1wPcM+x2PBRmoOur+Sw=="], + "@effect/platform-node/undici": ["undici@7.26.0", "", {}, "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg=="], + "@inquirer/core/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],