From 1382777ed8000e8a84f81053d66f6bb713dccd92 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 5 Sep 2026 12:45:04 +0200 Subject: [PATCH] fix(coding-agent): keep remote harness dependencies development-only Separate stable and development entrypoints, exclude remote harness code from distributions, and verify isolated consumer installs before release. Fixes #9132. --- .github/workflows/build-binaries.yml | 3 + package-lock.json | 5 +- package.json | 4 +- packages/coding-agent/CHANGELOG.md | 5 + packages/coding-agent/README.md | 2 - packages/coding-agent/docs/development.md | 19 +++ .../docs/environment-variables.md | 4 +- .../install-lock/package-lock.json | 26 ---- packages/coding-agent/npm-shrinkwrap.json | 26 ---- packages/coding-agent/package.json | 15 +- packages/coding-agent/src/bun/cli.ts | 16 +- .../coding-agent/src/bun/sandbox-env-setup.ts | 4 + packages/coding-agent/src/cli.ts | 28 +--- packages/coding-agent/src/cli/args.ts | 2 - packages/coding-agent/src/cli/setup.ts | 13 ++ packages/coding-agent/src/experimental/cli.ts | 12 ++ .../coding-agent/src/experimental/commands.ts | 106 ++++++++++++++ packages/coding-agent/src/main.ts | 118 --------------- .../test/experimental-cli-entry.test.ts | 65 +++++++++ packages/coding-agent/tsconfig.build.json | 9 +- pi-test.sh | 2 +- scripts/build-coding-agent-bundle.mjs | 5 - scripts/check-runtime-deps.mjs | 93 ++++++++++++ scripts/check-runtime-deps.test.mjs | 110 ++++++++++++++ scripts/coding-agent-consumer.mjs | 138 ++++++++++++++++++ scripts/coding-agent-consumer.test.mjs | 95 ++++++++++++ scripts/local-release.mjs | 46 +----- scripts/release.mjs | 4 + 28 files changed, 697 insertions(+), 278 deletions(-) create mode 100644 packages/coding-agent/src/bun/sandbox-env-setup.ts create mode 100644 packages/coding-agent/src/cli/setup.ts create mode 100644 packages/coding-agent/src/experimental/cli.ts create mode 100644 packages/coding-agent/src/experimental/commands.ts create mode 100644 packages/coding-agent/test/experimental-cli-entry.test.ts create mode 100644 scripts/check-runtime-deps.mjs create mode 100644 scripts/check-runtime-deps.test.mjs create mode 100644 scripts/coding-agent-consumer.mjs create mode 100644 scripts/coding-agent-consumer.test.mjs diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 37facbe2f..03f06609f 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -335,6 +335,9 @@ jobs: - name: Test run: npm test + - name: Smoke-test packed npm consumer + run: npm run check:package-install + - name: Upgrade npm for trusted publishing run: | npm install -g npm@11.16.0 --ignore-scripts diff --git a/package-lock.json b/package-lock.json index 876e2c2bc..900c05a79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5424,8 +5424,6 @@ "@earendil-works/chord": "^0.85.0", "@earendil-works/pi-agent-core": "^0.85.0", "@earendil-works/pi-ai": "^0.85.0", - "@earendil-works/pi-client": "^0.85.0", - "@earendil-works/pi-protocol": "^0.85.0", "@earendil-works/pi-tui": "^0.85.0", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", @@ -5447,6 +5445,9 @@ "pi": "dist/bundle/cli.js" }, "devDependencies": { + "@earendil-works/pi-client": "^0.85.0", + "@earendil-works/pi-protocol": "^0.85.0", + "@earendil-works/pi-server": "^0.85.0", "@types/cross-spawn": "6.0.6", "@types/hosted-git-info": "3.0.5", "@types/node": "22.19.19", diff --git a/package.json b/package.json index 62e5ef813..99000cf7a 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,11 @@ "clean": "npm run clean --workspaces", "build": "cd packages/chord && npm run build && cd ../tui && npm run build && cd ../telemetry && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../session-backends/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build", "build:offline": "cd packages/chord && npm run build && cd ../tui && npm run build && cd ../telemetry && npm run build && cd ../ai && npm run build:offline && cd ../agent && npm run build && cd ../session-backends/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build", - "check": "biome check --write --error-on-warnings . && npm run check:pinned-deps && npm run check:ts-imports && npm run check:entry-graphs && npm run check:shrinkwrap && npm run check:install-lock:coding-agent && tsgo --noEmit && npm run check:browser-smoke", + "check": "biome check --write --error-on-warnings . && npm run check:pinned-deps && npm run check:runtime-deps && npm run check:ts-imports && npm run check:entry-graphs && npm run check:shrinkwrap && npm run check:install-lock:coding-agent && tsgo --noEmit && npm run check:browser-smoke", "check:browser-smoke": "node scripts/check-browser-smoke.mjs", "check:pinned-deps": "node scripts/check-pinned-deps.mjs", + "check:runtime-deps": "node scripts/check-runtime-deps.mjs", + "check:package-install": "node scripts/coding-agent-consumer.mjs", "check:shrinkwrap": "node scripts/generate-coding-agent-shrinkwrap.mjs --check", "check:install-lock:coding-agent": "node scripts/generate-coding-agent-install-lock.mjs --check", "check:ts-imports": "node scripts/check-ts-relative-imports.mjs", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index cd3495b24..d907cf915 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,9 +2,14 @@ ## [Unreleased] +### Breaking Changes + +- Restricted the experimental `client` and `experimental/plugin` package subpaths and server/client commands to source development through `pi-test.sh`. They are no longer included in npm packages or standalone binaries; the local SDK and stdio RPC API are unchanged. + ### Fixed - Fixed configurable save keybindings in the model and thinking selectors ([#8797](https://github.com/earendil-works/pi/issues/8797)). +- Fixed SDK imports failing on missing remote-server dependencies by excluding the development-only runtime from distributed builds ([#9132](https://github.com/earendil-works/pi/issues/9132)). ## [0.85.0] - 2026-09-04 diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 27ca4c9e2..50861f2a5 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -677,8 +677,6 @@ pi --thinking high "Solve this complex problem" | `PI_CODING_AGENT_DIR` | Override config directory (default: `~/.pi/agent`) | | `PI_CODING_AGENT_SESSION_DIR` | Override session storage directory (overridden by `--session-dir`) | | `PI_PACKAGE_DIR` | Override package directory (useful for Nix/Guix where store paths tokenize poorly) | -| `PI_SERVER_DIR` | Override experimental server profile and socket directory (default: `~/.pi/server`) | -| `PI_SERVER_ID` | Select the logical experimental server ID when `--server-id` is omitted | | `PI_OFFLINE` | Disable startup network operations, including update checks, package update checks, and install/update telemetry | | `PI_SKIP_VERSION_CHECK` | Skip the Pi version update check at startup. This prevents the `pi.dev` latest-version request | | `PI_TELEMETRY` | Override install/update telemetry and provider attribution headers. Use `1`/`true`/`yes` to enable or `0`/`false`/`no` to disable. This does not disable update checks | diff --git a/packages/coding-agent/docs/development.md b/packages/coding-agent/docs/development.md index fc57befa4..c50222ef2 100644 --- a/packages/coding-agent/docs/development.md +++ b/packages/coding-agent/docs/development.md @@ -19,6 +19,19 @@ Run from source: The script can be run from any directory. Pi keeps the caller's current working directory. +### Experimental remote harness + +The remote harness server/client integration is development-only. Run it from the repository with: + +```bash +PI_EXPERIMENTAL=1 ./pi-test.sh server +PI_EXPERIMENTAL=1 ./pi-test.sh client +``` + +`PI_SERVER_DIR` overrides the server profile and socket directory (default: `~/.pi/server`). `PI_SERVER_ID` selects the logical server ID when `--server-id` is omitted. + +The `client` and `experimental/plugin` package subpaths resolve only under the `source` condition in a checkout. Their implementations and the server/client commands are excluded from npm packages and standalone binaries. `pi-client`, `pi-protocol`, and `pi-server` are development dependencies of coding-agent, not runtime dependencies. The local SDK and stdio RPC API are unchanged. + ## Forking / Rebranding Configure via `package.json`: @@ -60,6 +73,12 @@ npm test # Run all tests npm test -- test/specific.test.ts # Run specific test ``` +### Published package smoke test + +After building, run `npm run check:package-install`. It packs the public packages and installs only coding-agent as a direct dependency in a temporary directory outside the repository. Local tarball overrides select declared transitive dependencies without installing development-only packages. The check verifies SDK imports and CLI startup without credentials or model requests. + +`npm run check` also checks runtime dependency declarations and rejects excluded development sources pulled into a package's build through imports. + ## Project Structure ``` diff --git a/packages/coding-agent/docs/environment-variables.md b/packages/coding-agent/docs/environment-variables.md index 9abe74b33..2315cf302 100644 --- a/packages/coding-agent/docs/environment-variables.md +++ b/packages/coding-agent/docs/environment-variables.md @@ -81,8 +81,6 @@ These variables are read by Pi itself: | `PI_CODING_AGENT_DIR` | Override the config directory; default is `~/.pi/agent` | | `PI_CODING_AGENT_SESSION_DIR` | Override session storage; overridden by `--session-dir` | | `PI_PACKAGE_DIR` | Override the package directory, useful for Nix/Guix store paths | -| `PI_SERVER_DIR` | Override the experimental server profile and socket directory; default is `~/.pi/server` | -| `PI_SERVER_ID` | Select the logical experimental server ID when `--server-id` is omitted | | `PI_OFFLINE` | Disable startup network operations, including update checks, package updates, and install/update telemetry | | `PI_SKIP_VERSION_CHECK` | Disable the `pi.dev` latest-version request | | `PI_TELEMETRY` | Override install/update telemetry and provider attribution headers: `1`/`true`/`yes` or `0`/`false`/`no` | @@ -97,3 +95,5 @@ These variables are read by Pi itself: | `HTTP_PROXY`, `HTTPS_PROXY` | Proxy outbound HTTP requests | Provider credentials such as `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and cloud-provider configuration are listed in [Providers](providers.md#environment-variables-or-auth-file). + +`PI_SERVER_DIR` and `PI_SERVER_ID` apply only to the source-only [experimental remote harness](development.md#experimental-remote-harness), not distributed builds. diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index a8252bac8..e0d754320 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -501,18 +501,6 @@ "node": ">=22.19.0" } }, - "node_modules/@earendil-works/pi-client": { - "version": "0.85.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.85.0.tgz", - "license": "MIT", - "dependencies": { - "@earendil-works/chord": "^0.85.0", - "@earendil-works/pi-protocol": "^0.85.0" - }, - "engines": { - "node": ">=22.19.0" - } - }, "node_modules/@earendil-works/pi-coding-agent": { "version": "0.85.0", "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.85.0.tgz", @@ -521,8 +509,6 @@ "@earendil-works/chord": "^0.85.0", "@earendil-works/pi-agent-core": "^0.85.0", "@earendil-works/pi-ai": "^0.85.0", - "@earendil-works/pi-client": "^0.85.0", - "@earendil-works/pi-protocol": "^0.85.0", "@earendil-works/pi-tui": "^0.85.0", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", @@ -550,18 +536,6 @@ "node": ">=22.19.0" } }, - "node_modules/@earendil-works/pi-protocol": { - "version": "0.85.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.85.0.tgz", - "license": "MIT", - "dependencies": { - "@earendil-works/chord": "^0.85.0", - "typebox": "1.3.7" - }, - "engines": { - "node": ">=22.19.0" - } - }, "node_modules/@earendil-works/pi-telemetry": { "version": "0.85.0", "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.85.0.tgz", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 8a270c079..a7ef10042 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -12,8 +12,6 @@ "@earendil-works/chord": "^0.85.0", "@earendil-works/pi-agent-core": "^0.85.0", "@earendil-works/pi-ai": "^0.85.0", - "@earendil-works/pi-client": "^0.85.0", - "@earendil-works/pi-protocol": "^0.85.0", "@earendil-works/pi-tui": "^0.85.0", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", @@ -528,30 +526,6 @@ "node": ">=22.19.0" } }, - "node_modules/@earendil-works/pi-client": { - "version": "0.85.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.85.0.tgz", - "license": "MIT", - "dependencies": { - "@earendil-works/chord": "^0.85.0", - "@earendil-works/pi-protocol": "^0.85.0" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@earendil-works/pi-protocol": { - "version": "0.85.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.85.0.tgz", - "license": "MIT", - "dependencies": { - "@earendil-works/chord": "^0.85.0", - "typebox": "1.3.7" - }, - "engines": { - "node": ">=22.19.0" - } - }, "node_modules/@earendil-works/pi-telemetry": { "version": "0.85.0", "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.85.0.tgz", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 8cda9e037..1424c72da 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -20,17 +20,17 @@ "import": "./dist/bundle/rpc-entry.js" }, "./client": { - "types": "./dist/client/index.d.ts", - "import": "./dist/client/index.js" + "source": "./src/client/index.ts" }, "./experimental/plugin": { - "source": "./src/experimental/plugin.ts", - "types": "./dist/experimental/plugin.d.ts", - "import": "./dist/experimental/plugin.js" + "source": "./src/experimental/plugin.ts" } }, "files": [ "dist", + "!dist/client", + "!dist/experimental", + "!dist/cli/experimental", "docs", "examples", "containerization.md", @@ -52,8 +52,6 @@ "@earendil-works/chord": "^0.85.0", "@earendil-works/pi-agent-core": "^0.85.0", "@earendil-works/pi-ai": "^0.85.0", - "@earendil-works/pi-client": "^0.85.0", - "@earendil-works/pi-protocol": "^0.85.0", "@earendil-works/pi-tui": "^0.85.0", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", @@ -82,6 +80,9 @@ "@mariozechner/clipboard": "0.3.9" }, "devDependencies": { + "@earendil-works/pi-client": "^0.85.0", + "@earendil-works/pi-protocol": "^0.85.0", + "@earendil-works/pi-server": "^0.85.0", "@types/cross-spawn": "6.0.6", "@types/hosted-git-info": "3.0.5", "@types/node": "22.19.19", diff --git a/packages/coding-agent/src/bun/cli.ts b/packages/coding-agent/src/bun/cli.ts index ae72d6e52..a02378bf1 100644 --- a/packages/coding-agent/src/bun/cli.ts +++ b/packages/coding-agent/src/bun/cli.ts @@ -1,14 +1,4 @@ #!/usr/bin/env node -import { runCoordinatorProcess } from "../experimental/coordinator.ts"; -import { consumeInternalProcessRole, getInternalProcessRole } from "../experimental/process.ts"; -import { restoreSandboxEnv } from "./restore-sandbox-env.ts"; - -restoreSandboxEnv(); - -if (getInternalProcessRole() === "coordinator") { - consumeInternalProcessRole(); - await runCoordinatorProcess(process.argv.slice(2)); -} else { - await import("./runtime-setup.ts"); - await import("../cli.ts"); -} +import "./sandbox-env-setup.ts"; +import "./runtime-setup.ts"; +import "../cli.ts"; diff --git a/packages/coding-agent/src/bun/sandbox-env-setup.ts b/packages/coding-agent/src/bun/sandbox-env-setup.ts new file mode 100644 index 000000000..54545c1b1 --- /dev/null +++ b/packages/coding-agent/src/bun/sandbox-env-setup.ts @@ -0,0 +1,4 @@ +import { restoreSandboxEnv } from "./restore-sandbox-env.ts"; + +// Restore the environment before evaluating modules that read it at startup. +restoreSandboxEnv(); diff --git a/packages/coding-agent/src/cli.ts b/packages/coding-agent/src/cli.ts index 3108a13b1..bdb184edf 100644 --- a/packages/coding-agent/src/cli.ts +++ b/packages/coding-agent/src/cli.ts @@ -1,28 +1,6 @@ #!/usr/bin/env node -import { APP_NAME } from "./config.ts"; -import { configureHttpDispatcher } from "./core/http-dispatcher.ts"; -import { consumeInternalProcessRole } from "./experimental/process.ts"; -import { runServerProcess } from "./experimental/server.ts"; -import { runSessionWorkerProcess } from "./experimental/session-worker.ts"; +import { setupCli } from "./cli/setup.ts"; import { main } from "./main.ts"; -const internalProcessRole = consumeInternalProcessRole(); -if (internalProcessRole === "server") { - void runServerProcess(process.argv.slice(2)).catch(() => process.exit(1)); -} else if (internalProcessRole === "session-worker") { - void runSessionWorkerProcess(process.argv.slice(2)).catch(() => process.exit(1)); -} else { - if (internalProcessRole !== undefined) { - throw new Error(`Internal ${internalProcessRole} process must use its lightweight entrypoint`); - } - process.title = APP_NAME; - process.env.PI_CODING_AGENT = "true"; - process.env.AI_AGENT = "pi"; - process.emitWarning = (() => {}) as typeof process.emitWarning; - - // Configure undici's global dispatcher before provider SDKs issue requests. - // Runtime settings are applied once SettingsManager has loaded global/project settings. - configureHttpDispatcher(); - - main(process.argv.slice(2)); -} +setupCli(); +main(process.argv.slice(2)); diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 2ced290f9..8ad5da63e 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -430,8 +430,6 @@ ${chalk.bold("Environment Variables:")} ${ENV_AGENT_DIR.padEnd(32)} - Config directory (default: ~/${CONFIG_DIR_NAME}/agent) ${ENV_SESSION_DIR.padEnd(32)} - Session storage directory (overridden by --session-dir) PI_PACKAGE_DIR - Override package directory (for Nix/Guix store paths) - PI_SERVER_DIR - Experimental server profile and socket directory (default: ~/.pi/server) - PI_SERVER_ID - Logical experimental server ID (overridden by --server-id) PI_OFFLINE - Disable startup network operations when set to 1/true/yes PI_TELEMETRY - Override install telemetry when set to 1/true/yes or 0/false/no PI_SHARE_VIEWER_URL - Base URL for /share command (default: https://pi.dev/session/) diff --git a/packages/coding-agent/src/cli/setup.ts b/packages/coding-agent/src/cli/setup.ts new file mode 100644 index 000000000..8ddf34a14 --- /dev/null +++ b/packages/coding-agent/src/cli/setup.ts @@ -0,0 +1,13 @@ +import { APP_NAME } from "../config.ts"; +import { configureHttpDispatcher } from "../core/http-dispatcher.ts"; + +export function setupCli(): void { + process.title = APP_NAME; + process.env.PI_CODING_AGENT = "true"; + process.env.AI_AGENT = "pi"; + process.emitWarning = (() => {}) as typeof process.emitWarning; + + // Configure undici before provider SDKs issue requests. Settings are applied + // once SettingsManager has loaded global/project configuration. + configureHttpDispatcher(); +} diff --git a/packages/coding-agent/src/experimental/cli.ts b/packages/coding-agent/src/experimental/cli.ts new file mode 100644 index 000000000..8ff1750da --- /dev/null +++ b/packages/coding-agent/src/experimental/cli.ts @@ -0,0 +1,12 @@ +#!/usr/bin/env node +import { setupCli } from "../cli/setup.ts"; +import { main } from "../main.ts"; +import { runExperimentalCommand } from "./commands.ts"; + +setupCli(); +const args = process.argv.slice(2); +if (await runExperimentalCommand(args)) { + if (args[0] === "client") process.exit(process.exitCode ?? 0); +} else { + await main(args); +} diff --git a/packages/coding-agent/src/experimental/commands.ts b/packages/coding-agent/src/experimental/commands.ts new file mode 100644 index 000000000..abd63fce0 --- /dev/null +++ b/packages/coding-agent/src/experimental/commands.ts @@ -0,0 +1,106 @@ +import chalk from "chalk"; +import { cli } from "../cli/experimental/cli.ts"; +import type { ClientCommand } from "../cli/experimental/commands/client.ts"; +import type { ServerCommand } from "../cli/experimental/commands/server.ts"; +import { areExperimentalFeaturesEnabled } from "../core/experimental.ts"; +import { runClient } from "./client.ts"; +import { runClientTui } from "./client-tui.ts"; +import type { RadiusRelayHostStatus } from "./radius-relay.ts"; +import { startForegroundServer } from "./server.ts"; + +async function runServerCommand(command: ServerCommand): Promise { + let previousRelayStatus = ""; + let relayOutputReady = false; + let pendingRelayStatus: RadiusRelayHostStatus | undefined; + const reportRelayStatus = (status: RadiusRelayHostStatus): void => { + const description = + status.status === "connected" + ? "connected" + : status.status === "not_authenticated" + ? "not connected; local only" + : status.status === "retrying" + ? `reconnecting: ${status.error}` + : "connecting"; + if (description === previousRelayStatus || status.status === "connecting") return; + previousRelayStatus = description; + console.log(`Radius: ${description}`); + }; + const runtime = await startForegroundServer({ + serverId: command.serverId, + sessionDir: command.sessionDir, + provider: command.provider, + model: command.model, + pluginPackages: command.pluginPackages ?? [], + relayAuth: command.auth, + onRelayStatus(status) { + if (relayOutputReady) reportRelayStatus(status); + else pendingRelayStatus = status; + }, + }); + console.log(`Server: ${runtime.serverId}`); + console.log(`Socket: ${runtime.socketPath}`); + relayOutputReady = true; + if (pendingRelayStatus !== undefined) reportRelayStatus(pendingRelayStatus); + try { + await new Promise((resolve, reject) => { + const cleanup = (): void => { + process.off("SIGINT", finish); + process.off("SIGTERM", finish); + }; + const finish = (): void => { + cleanup(); + resolve(); + }; + const fail = (error: unknown): void => { + cleanup(); + reject(error); + }; + process.once("SIGINT", finish); + process.once("SIGTERM", finish); + void runtime.closed.then(finish, fail); + }); + } finally { + await runtime.close(); + } +} + +async function runClientCommand(command: ClientCommand): Promise { + if (command.prompt === undefined && process.stdin.isTTY === true && process.stdout.isTTY === true) { + await runClientTui(command); + return; + } + let streamedText = false; + const result = await runClient(command, { + onEvent(event) { + if (event.type !== "message_update" || event.frame?.type !== "text_delta") return; + streamedText = true; + process.stdout.write(event.frame.delta); + }, + }); + if (result.kind === "attached") { + console.log(`${result.serverId}\t${result.sessionId}\tattached`); + return; + } + if (result.kind === "prompted") { + if (streamedText) process.stdout.write("\n"); + else console.log(result.text); + return; + } + for (const session of result.sessions) console.log(`${session.serverId}\t${session.sessionId}`); +} + +/** Development-only command dispatch. Published entrypoints must not import this module. */ +export async function runExperimentalCommand(args: string[]): Promise { + if (!areExperimentalFeaturesEnabled() || (args[0] !== "server" && args[0] !== "client")) return false; + try { + const result = await cli.execute(args, { runServer: runServerCommand, runClient: runClientCommand }); + if (!result.ok) { + for (const error of result.errors) console.error(chalk.red(`Error: ${error}`)); + process.exitCode = 1; + } + } catch (error) { + console.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`)); + process.exitCode = 1; + } + return true; +} diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index c6734a0b4..55351dfe6 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -27,9 +27,6 @@ import { validateAuthCommandArgs, } from "./cli/auth-command.ts"; import { resolveCredentialForPrint } from "./cli/credential-print.ts"; -import { cli as experimentalCli } from "./cli/experimental/cli.ts"; -import type { ClientCommand } from "./cli/experimental/commands/client.ts"; -import type { ServerCommand } from "./cli/experimental/commands/server.ts"; import { processFileArguments } from "./cli/file-processor.ts"; import { buildInitialMessage } from "./cli/initial-message.ts"; import { listModels } from "./cli/list-models.ts"; @@ -45,7 +42,6 @@ import { } from "./core/agent-session-services.ts"; import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts"; import { AuthStorage, ReadOnlyAuthStorage } from "./core/auth-storage.ts"; -import { areExperimentalFeaturesEnabled } from "./core/experimental.ts"; import { exportFromFile } from "./core/export-html/index.ts"; import type { InlineExtension } from "./core/extensions/types.ts"; import { applyHttpProxySettings, configureHttpDispatcher } from "./core/http-dispatcher.ts"; @@ -65,10 +61,6 @@ import { collectSettingsDiagnostics, deduplicateDiagnostics } from "./core/setti import { SettingsManager } from "./core/settings-manager.ts"; import { printTimings, resetTimings, time } from "./core/timings.ts"; import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts"; -import { runClient } from "./experimental/client.ts"; -import { runClientTui } from "./experimental/client-tui.ts"; -import type { RadiusRelayHostStatus } from "./experimental/radius-relay.ts"; -import { startForegroundServer } from "./experimental/server.ts"; import { builtInExtensions } from "./extensions/index.ts"; import { runMigrations, showDeprecationWarnings } from "./migrations.ts"; import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts"; @@ -563,111 +555,6 @@ async function promptForMissingSessionCwd( ]); } -async function waitForTermination(serverClosed: Promise): Promise { - await new Promise((resolve, reject) => { - const cleanup = (): void => { - process.off("SIGINT", finish); - process.off("SIGTERM", finish); - }; - const finish = (): void => { - cleanup(); - resolve(); - }; - const fail = (error: unknown): void => { - cleanup(); - reject(error); - }; - process.once("SIGINT", finish); - process.once("SIGTERM", finish); - void serverClosed.then(finish, fail); - }); -} - -async function runExperimentalServerCommand(command: ServerCommand): Promise { - let previousRelayStatus = ""; - let relayOutputReady = false; - let pendingRelayStatus: RadiusRelayHostStatus | undefined; - const reportRelayStatus = (status: RadiusRelayHostStatus): void => { - const description = - status.status === "connected" - ? "connected" - : status.status === "not_authenticated" - ? "not connected; local only" - : status.status === "retrying" - ? `reconnecting: ${status.error}` - : "connecting"; - if (description === previousRelayStatus || status.status === "connecting") return; - previousRelayStatus = description; - console.log(`Radius: ${description}`); - }; - const runtime = await startForegroundServer({ - serverId: command.serverId, - sessionDir: command.sessionDir, - provider: command.provider, - model: command.model, - pluginPackages: command.pluginPackages ?? [], - relayAuth: command.auth, - onRelayStatus(status) { - if (relayOutputReady) reportRelayStatus(status); - else pendingRelayStatus = status; - }, - }); - console.log(`Server: ${runtime.serverId}`); - console.log(`Socket: ${runtime.socketPath}`); - relayOutputReady = true; - if (pendingRelayStatus !== undefined) reportRelayStatus(pendingRelayStatus); - try { - await waitForTermination(runtime.closed); - } finally { - await runtime.close(); - } -} - -async function runClientCommand(command: ClientCommand): Promise { - if (command.prompt === undefined && process.stdin.isTTY === true && process.stdout.isTTY === true) { - await runClientTui(command); - return; - } - let streamedText = false; - const result = await runClient(command, { - onEvent(event) { - if (event.type !== "message_update" || event.frame?.type !== "text_delta") return; - streamedText = true; - process.stdout.write(event.frame.delta); - }, - }); - if (result.kind === "attached") { - console.log(`${result.serverId}\t${result.sessionId}\tattached`); - return; - } - if (result.kind === "prompted") { - if (streamedText) process.stdout.write("\n"); - else console.log(result.text); - return; - } - for (const session of result.sessions) console.log(`${session.serverId}\t${session.sessionId}`); -} - -async function runExperimentalCommand(args: string[]): Promise { - if (!areExperimentalFeaturesEnabled() || (args[0] !== "server" && args[0] !== "client")) return false; - try { - const result = await experimentalCli.execute(args, { - runServer: runExperimentalServerCommand, - runClient: runClientCommand, - }); - if (!result.ok) { - for (const error of result.errors) console.error(chalk.red(`Error: ${error}`)); - process.exitCode = 1; - return true; - } - return true; - } catch (error) { - console.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`)); - process.exitCode = 1; - return true; - } -} - export interface MainOptions { extensionFactories?: InlineExtension[]; } @@ -685,11 +572,6 @@ export async function main(args: string[], options?: MainOptions) { return; } - if (await runExperimentalCommand(args)) { - if (args[0] === "client") process.exit(process.exitCode ?? 0); - return; - } - if (process.platform === "win32") { cleanupWindowsSelfUpdateQuarantine(getPackageDir()); } diff --git a/packages/coding-agent/test/experimental-cli-entry.test.ts b/packages/coding-agent/test/experimental-cli-entry.test.ts new file mode 100644 index 000000000..c2277e45d --- /dev/null +++ b/packages/coding-agent/test/experimental-cli-entry.test.ts @@ -0,0 +1,65 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { VERSION } from "../src/config.ts"; + +const sourceResolverPath = resolve(__dirname, "../src/experimental/source-resolver.ts"); +const tempDirs: string[] = []; + +afterEach(() => { + for (const directory of tempDirs.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +function runEntry(entry: string, experimental: boolean) { + const directory = mkdtempSync(join(tmpdir(), "pi-cli-boundary-")); + tempDirs.push(directory); + return spawnSync( + process.execPath, + [ + "--import", + sourceResolverPath, + resolve(__dirname, "../src", entry), + "server", + "--server-id", + "invalid", + "--version", + ], + { + cwd: directory, + encoding: "utf8", + timeout: 15_000, + env: { + ...process.env, + HOME: directory, + USERPROFILE: directory, + PI_CODING_AGENT_DIR: join(directory, "agent"), + PI_OFFLINE: "1", + PI_EXPERIMENTAL: experimental ? "1" : "0", + }, + }, + ); +} + +describe("stable and development CLI entrypoints", () => { + // #9132: enabling experiments must not pull remote-server dependencies into the published CLI. + it("does not dispatch experimental commands from the stable entrypoint", () => { + const result = runEntry("cli.ts", true); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout.trim()).toBe(VERSION); + }); + + it("keeps experimental dispatch in the development entrypoint", () => { + const result = runEntry("experimental/cli.ts", true); + expect(result.status, result.stderr).toBe(1); + expect(result.stderr).toContain("Invalid --server-id"); + expect(result.stdout).not.toContain(VERSION); + }); + + it("falls back to the stable CLI when experiments are disabled", () => { + const result = runEntry("experimental/cli.ts", false); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout.trim()).toBe(VERSION); + }); +}); diff --git a/packages/coding-agent/tsconfig.build.json b/packages/coding-agent/tsconfig.build.json index 87bc51b10..b747af7b8 100644 --- a/packages/coding-agent/tsconfig.build.json +++ b/packages/coding-agent/tsconfig.build.json @@ -10,18 +10,11 @@ "@earendil-works/pi-agent-core/*": ["../agent/dist/*.d.ts"], "@earendil-works/pi-ai": ["../ai/dist/index.d.ts"], "@earendil-works/pi-ai/*": ["../ai/dist/*.d.ts", "../ai/dist/providers/*.d.ts"], - "@earendil-works/pi-client": ["../client/dist/index.d.ts"], - "@earendil-works/pi-client/unix": ["../client/dist/unix.d.ts"], - "@earendil-works/pi-client/*": ["../client/dist/*.d.ts"], - "@earendil-works/pi-server": ["../server/dist/index.d.ts"], - "@earendil-works/pi-server/unix": ["../server/dist/transports/unix/index.d.ts"], - "@earendil-works/pi-server/*": ["../server/dist/*.d.ts", "../server/dist/transports/*/index.d.ts"], - "@earendil-works/pi-protocol": ["../protocol/dist/index.d.ts"], "@earendil-works/pi-tui": ["../tui/dist/index.d.ts"], "@earendil-works/pi-tui/*": ["../tui/dist/*.d.ts", "../tui/dist/components/*.d.ts"] }, "rootDir": "./src" }, "include": ["src/**/*.ts", "src/**/*.d.ts"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "src/client", "src/experimental", "src/cli/experimental"] } diff --git a/pi-test.sh b/pi-test.sh index b726dd412..3f40112bd 100755 --- a/pi-test.sh +++ b/pi-test.sh @@ -54,4 +54,4 @@ if [[ "$NO_ENV" == "true" ]]; then echo "Running without API keys..." fi -"$SCRIPT_DIR/node_modules/.bin/tsx" --tsconfig "$SCRIPT_DIR/tsconfig.json" "$SCRIPT_DIR/packages/coding-agent/src/cli.ts" ${ARGS[@]+"${ARGS[@]}"} +"$SCRIPT_DIR/node_modules/.bin/tsx" --tsconfig "$SCRIPT_DIR/tsconfig.json" "$SCRIPT_DIR/packages/coding-agent/src/experimental/cli.ts" ${ARGS[@]+"${ARGS[@]}"} diff --git a/scripts/build-coding-agent-bundle.mjs b/scripts/build-coding-agent-bundle.mjs index 7ca7e8bf9..26ba40280 100644 --- a/scripts/build-coding-agent-bundle.mjs +++ b/scripts/build-coding-agent-bundle.mjs @@ -142,10 +142,8 @@ function outputBytes(metafiles) { for (const entry of [ join(codingAgentDistDir, "cli.js"), - join(codingAgentDistDir, "experimental", "coordinator-entry.js"), join(codingAgentDistDir, "index.js"), join(codingAgentDistDir, "rpc-entry.js"), - join(codingAgentDistDir, "client", "index.js"), join(codingAgentDistDir, "utils", "image-resize-worker.js"), join(aiDistDir, "api", "bedrock-converse-stream.js"), join(aiDistDir, "auth", "oauth", "anthropic.js"), @@ -163,8 +161,6 @@ const mainResult = await build({ entryNames: "[name]", entryPoints: { cli: join(codingAgentDistDir, "cli.js"), - client: join(codingAgentDistDir, "client", "index.js"), - coordinator: join(codingAgentDistDir, "experimental", "coordinator-entry.js"), index: join(codingAgentDistDir, "index.js"), "rpc-entry": join(codingAgentDistDir, "rpc-entry.js"), }, @@ -208,7 +204,6 @@ if (dirname(imageResizeOutput) !== dirname(imageResizeWorkerOutput)) { validateExternalImports([mainResult.metafile, lazyResult.metafile]); chmodSync(join(bundleDir, "cli.js"), 0o755); -chmodSync(join(bundleDir, "coordinator.js"), 0o755); chmodSync(join(bundleDir, "rpc-entry.js"), 0o755); const files = new Set([...Object.keys(mainResult.metafile.outputs), ...Object.keys(lazyResult.metafile.outputs)]).size; diff --git a/scripts/check-runtime-deps.mjs b/scripts/check-runtime-deps.mjs new file mode 100644 index 000000000..7d3850772 --- /dev/null +++ b/scripts/check-runtime-deps.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node + +import { existsSync, readFileSync } from "node:fs"; +import { isBuiltin } from "node:module"; +import { isAbsolute, join, relative, resolve } from "node:path"; +import ts from "typescript"; +import { getPublicWorkspacePackages } from "./release-packages.mjs"; + +const failures = []; + +function checkSource(source, manifest) { + const file = source.fileName; + const declared = new Set([ + manifest.name, + ...Object.keys(manifest.dependencies ?? {}), + ...Object.keys(manifest.optionalDependencies ?? {}), + ...Object.keys(manifest.peerDependencies ?? {}), + ]); + + function checkSpecifier(node) { + if (!node || !ts.isStringLiteralLike(node)) return; + const specifier = node.text; + if (specifier.startsWith(".") || specifier.startsWith("/") || isBuiltin(specifier)) return; + const name = specifier.split("/").slice(0, specifier.startsWith("@") ? 2 : 1).join("/"); + if (declared.has(name)) return; + const { line } = source.getLineAndCharacterOfPosition(node.getStart(source)); + failures.push(`${file}:${line + 1}: ${specifier} is not declared in ${manifest.name}'s runtime dependencies`); + } + + function visit(node) { + if (ts.isImportDeclaration(node)) { + const clause = node.importClause; + const bindings = clause?.namedBindings; + if ( + !clause || + (!clause.isTypeOnly && + (clause.name || !bindings || !ts.isNamedImports(bindings) || + bindings.elements.length === 0 || bindings.elements.some((element) => !element.isTypeOnly))) + ) { + checkSpecifier(node.moduleSpecifier); + } + } else if (ts.isExportDeclaration(node) && !node.isTypeOnly) { + const clause = node.exportClause; + if (!clause || !ts.isNamedExports(clause) || clause.elements.length === 0 || clause.elements.some((element) => !element.isTypeOnly)) { + checkSpecifier(node.moduleSpecifier); + } + } else if ( + ts.isCallExpression(node) && + (node.expression.kind === ts.SyntaxKind.ImportKeyword || + (ts.isIdentifier(node.expression) && node.expression.text === "require") || + (ts.isPropertyAccessExpression(node.expression) && node.expression.getText(source) === "require.resolve")) + ) { + checkSpecifier(node.arguments[0]); + } + ts.forEachChild(node, visit); + } + visit(source); +} + +for (const { directory } of getPublicWorkspacePackages()) { + const sourceDirectory = resolve(directory, "src"); + if (!existsSync(sourceDirectory)) continue; + const manifest = JSON.parse(readFileSync(join(directory, "package.json"), "utf8")); + const configPath = join(directory, "tsconfig.build.json"); + const config = existsSync(configPath) + ? ts.readConfigFile(configPath, ts.sys.readFile) + : { config: { include: ["src/**/*"] } }; + if (config.error) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, "\n")); + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, resolve(directory)); + if (parsed.errors.length > 0) { + throw new Error(parsed.errors.map((error) => ts.flattenDiagnosticMessageText(error.messageText, "\n")).join("\n")); + } + const roots = new Set(parsed.fileNames.map((file) => resolve(file))); + const program = ts.createProgram(parsed.fileNames, parsed.options); + for (const source of program.getSourceFiles()) { + if (source.isDeclarationFile || source.fileName.endsWith(".json")) continue; + const path = relative(sourceDirectory, resolve(source.fileName)); + if (path.startsWith("..") || isAbsolute(path)) continue; + // TypeScript's exclude only filters roots: imports can pull excluded files + // back into the build. Reject that too, including type-only imports. + if (!roots.has(resolve(source.fileName))) { + failures.push(`${source.fileName} is excluded from ${manifest.name}'s build but imported by it`); + } + checkSource(source, manifest); + } +} + +if (failures.length > 0) { + console.error("Undeclared runtime imports in public packages:"); + for (const failure of failures) console.error(` ${failure}`); + process.exit(1); +} +console.log("Public package runtime imports have declared dependencies."); diff --git a/scripts/check-runtime-deps.test.mjs b/scripts/check-runtime-deps.test.mjs new file mode 100644 index 000000000..2ccede6b5 --- /dev/null +++ b/scripts/check-runtime-deps.test.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const script = fileURLToPath(new URL("./check-runtime-deps.mjs", import.meta.url)); + +async function check(t, manifest, source, extraFiles = {}) { + const root = await mkdtemp(join(tmpdir(), "pi-runtime-deps-")); + t.after(() => rm(root, { recursive: true, force: true })); + const files = { + "packages/example/package.json": JSON.stringify({ name: "example", version: "1.0.0", ...manifest }), + "packages/example/src/index.ts": source, + ...extraFiles, + }; + for (const [path, contents] of Object.entries(files)) { + const fullPath = join(root, path); + await mkdir(join(fullPath, ".."), { recursive: true }); + await writeFile(fullPath, contents); + } + return spawnSync(process.execPath, [script], { cwd: root, encoding: "utf8" }); +} + +// #9132: workspace resolution and installing every release package masked a missing runtime dependency. +test("rejects undeclared imports even when the workspace package exists", async (t) => { + const result = await check(t, {}, 'export { createUnixServer } from "@earendil-works/pi-server/unix";', { + "packages/server/package.json": JSON.stringify({ name: "@earendil-works/pi-server", version: "1.0.0" }), + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /src[\\/]index\.ts:1: @earendil-works\/pi-server\/unix is not declared/); +}); + +test("accepts runtime declarations, builtins, self imports, relative imports, and erased types", async (t) => { + const result = await check(t, { + dependencies: { "@scope/runtime": "1.0.0" }, + optionalDependencies: { optional: "1.0.0" }, + peerDependencies: { peer: "1.0.0" }, + }, ` +import "node:fs"; +import "fs/promises"; +import "./local.ts"; +import "example/subpath"; +import { value, type T } from "@scope/runtime/subpath"; +import optional from "optional"; +export * from "peer"; +import type { Type } from "type-only"; +import { type OtherType } from "inline-type-only"; +export type { Type } from "export-type-only"; +export { type OtherType } from "export-inline-type-only"; +`); + assert.equal(result.status, 0, result.stderr); +}); + +test("rejects dev-only dependencies, side-effect imports, mixed exports, and literal runtime loads", async (t) => { + const result = await check(t, { devDependencies: { dev: "1.0.0" } }, ` +import "dev"; +import {} from "empty-import"; +export {} from "empty-export"; +export { type T, value } from "mixed-export"; +const lazy = () => import("lazy/subpath"); +const required = require("required"); +const resolved = require.resolve("resolved/subpath"); +`); + assert.equal(result.status, 1); + for (const name of ["dev", "empty-import", "empty-export", "mixed-export", "lazy/subpath", "required", "resolved/subpath"]) { + assert.ok(result.stderr.includes(`${name} is not declared`), result.stderr); + } +}); + +test("allows imported JSON assets outside the TypeScript include pattern", async (t) => { + const result = await check(t, {}, 'import data from "./data.json";', { + "packages/example/tsconfig.build.json": JSON.stringify({ include: ["src/**/*.ts"], compilerOptions: { resolveJsonModule: true } }), + "packages/example/src/data.json": "{}", + }); + assert.equal(result.status, 0, result.stderr); +}); + +test("permits dev-only dependencies in sources excluded from the published build", async (t) => { + const result = await check(t, { devDependencies: { server: "1.0.0" } }, "", { + "packages/example/tsconfig.build.json": JSON.stringify({ include: ["src/**/*.ts"], exclude: ["src/experimental"] }), + "packages/example/src/experimental/server.ts": 'import "server";', + }); + assert.equal(result.status, 0, result.stderr); +}); + +// #9132: an experimental flag cannot prevent module resolution through a public entrypoint. +for (const statement of ['export * from "./experimental/server";', 'import type { Options } from "./experimental/server";']) { + test(`rejects excluded code reachable through ${statement}`, async (t) => { + const result = await check(t, { devDependencies: { server: "1.0.0" } }, statement, { + "packages/example/tsconfig.build.json": JSON.stringify({ include: ["src/**/*.ts"], exclude: ["src/experimental"] }), + "packages/example/src/experimental/server.ts": 'import "server"; export interface Options {}', + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /is excluded from example's build but imported by it/); + assert.match(result.stderr, /server is not declared/); + }); +} + +test("ignores tests, declarations, and private packages", async (t) => { + const result = await check(t, {}, "", { + "packages/example/test/test.ts": 'import "test-only";', + "packages/example/src/index.d.ts": 'import "declaration-only";', + "packages/private/package.json": JSON.stringify({ name: "private", private: true }), + "packages/private/src/index.ts": 'import "private-only";', + }); + assert.equal(result.status, 0, result.stderr); +}); diff --git a/scripts/coding-agent-consumer.mjs b/scripts/coding-agent-consumer.mjs new file mode 100644 index 000000000..82c37960c --- /dev/null +++ b/scripts/coding-agent-consumer.mjs @@ -0,0 +1,138 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { getPublicWorkspacePackages } from "./release-packages.mjs"; + +const codingAgentName = "@earendil-works/pi-coding-agent"; +const developmentPackages = new Set(["pi-client", "pi-protocol", "pi-server"].map((name) => `@earendil-works/${name}`)); + +function run(command, args, options = {}) { + console.log(`$ ${[command, ...args].join(" ")}`); + const result = spawnSync(command, args, { + encoding: "utf8", + shell: process.platform === "win32", + timeout: 300_000, + ...options, + }); + if (result.status !== 0) { + throw new Error(`Command failed: ${command} ${args.join(" ")}\n${result.stdout ?? ""}${result.stderr ?? ""}${result.error?.message ?? ""}`); + } + return result.stdout; +} + +export function packReleasePackages(packages, tarballDirectory) { + mkdirSync(tarballDirectory, { recursive: true }); + const tarballs = new Map(); + for (const pkg of packages) { + const manifest = JSON.parse(readFileSync(join(pkg.directory, "package.json"), "utf8")); + if (manifest.name !== pkg.name) throw new Error(`Unexpected package name in ${pkg.directory}`); + const output = run("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", tarballDirectory], { cwd: pkg.directory }); + // npm <11.6 returns an array; newer npm can return an object keyed by package name. + const parsed = JSON.parse(output); + const packed = Array.isArray(parsed) ? parsed[0] : Object.values(parsed)[0]; + tarballs.set(pkg.name, join(tarballDirectory, packed.filename)); + } + return tarballs; +} + +export function installCodingAgentConsumer(directory, tarballs, packageManager = "npm") { + mkdirSync(directory, { recursive: true }); + const overrides = Object.fromEntries([...tarballs].map(([name, path]) => [ + name, `file:./${relative(directory, path).replaceAll("\\", "/")}`, + ])); + if (!overrides[codingAgentName]) throw new Error("Missing coding-agent tarball"); + // Only coding-agent is a direct dependency. Overrides select local artifacts + // for declared transitive dependencies without installing undeclared packages. + const manifest = { + private: true, + dependencies: { [codingAgentName]: overrides[codingAgentName] }, + overrides, + }; + writeFileSync(join(directory, "package.json"), `${JSON.stringify(manifest, null, "\t")}\n`); + const installArgs = packageManager === "bun" ? ["--production"] : ["--omit=dev", "--no-audit", "--no-fund"]; + run(packageManager, ["install", "--ignore-scripts", ...installArgs], { cwd: directory }); +} + +function checkInstalledPackages(nodeModules, seen = new Set()) { + if (!existsSync(nodeModules)) return; + const directories = readdirSync(nodeModules) + .filter((name) => !name.startsWith(".")) + .flatMap((name) => name.startsWith("@") + ? readdirSync(join(nodeModules, name)).map((child) => join(nodeModules, name, child)) + : [join(nodeModules, name)]); + for (const directory of directories) { + if (!existsSync(join(directory, "package.json"))) continue; + const path = realpathSync(directory); + if (seen.has(path)) continue; + seen.add(path); + const manifest = JSON.parse(readFileSync(join(path, "package.json"), "utf8")); + if (developmentPackages.has(manifest.name)) throw new Error(`${manifest.name} must not be installed: ${path}`); + checkInstalledPackages(join(path, "node_modules"), seen); + } +} + +export function smokeTestCodingAgentConsumer(directory, runtime = process.execPath) { + checkInstalledPackages(join(directory, "node_modules")); + const packageDir = join(directory, "node_modules", codingAgentName); + const manifest = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8")); + for (const path of ["dist/client", "dist/experimental", "dist/cli/experimental", "dist/bundle/client.js", "dist/bundle/coordinator.js"]) { + if (existsSync(join(packageDir, path))) throw new Error(`Published package contains development-only code: ${path}`); + } + const home = mkdtempSync(join(directory, "smoke-home-")); + const entry = join(directory, "smoke-sdk.mjs"); + const env = { + PATH: process.env.PATH, + HOME: home, + USERPROFILE: home, + APPDATA: home, + LOCALAPPDATA: home, + XDG_CONFIG_HOME: home, + XDG_CACHE_HOME: home, + PI_CODING_AGENT_DIR: join(home, ".pi", "agent"), + PI_OFFLINE: "1", + PI_TELEMETRY: "0", + }; + for (const name of ["SystemRoot", "SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT"]) { + if (process.env[name]) env[name] = process.env[name]; + } + try { + writeFileSync(entry, `import assert from "node:assert/strict"; +import { createAgentSession, SessionManager, ModelRuntime } from "${codingAgentName}"; +assert.equal(typeof createAgentSession, "function"); +assert.equal(typeof SessionManager.inMemory, "function"); +assert.equal(typeof ModelRuntime.create, "function"); +for (const name of ["pi-client", "pi-protocol", "pi-server"]) { + assert.throws(() => import.meta.resolve("@earendil-works/" + name), /Cannot find|cannot find/, name + " must not be installed"); +} +for (const subpath of ["/client", "/experimental/plugin"]) { + assert.throws(() => import.meta.resolve("${codingAgentName}" + subpath), /not exported|not defined|Cannot find|cannot find/); +} +`); + run(runtime, [entry], { cwd: directory, env, timeout: 30_000 }); + for (const cli of new Set([manifest.bin.pi, "dist/cli.js"])) { + const output = run(runtime, [join(packageDir, cli), "--version"], { cwd: directory, env, timeout: 30_000 }); + if (output.trim() !== manifest.version) throw new Error(`Unexpected version from ${cli}: ${output}`); + } + } finally { + rmSync(entry, { force: true }); + rmSync(home, { recursive: true, force: true }); + } + console.log(`Coding-agent SDK and CLI consumer smoke tests passed (${runtime}).`); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + if (process.argv.length !== 2) throw new Error("Usage: node scripts/coding-agent-consumer.mjs"); + const root = mkdtempSync(join(tmpdir(), "pi-package-consumer-")); + try { + const tarballs = packReleasePackages(getPublicWorkspacePackages(), join(root, "tarballs")); + const directory = join(root, "consumer"); + installCodingAgentConsumer(directory, tarballs); + smokeTestCodingAgentConsumer(directory); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} diff --git a/scripts/coding-agent-consumer.test.mjs b/scripts/coding-agent-consumer.test.mjs new file mode 100644 index 000000000..3d125cce8 --- /dev/null +++ b/scripts/coding-agent-consumer.test.mjs @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { installCodingAgentConsumer, packReleasePackages, smokeTestCodingAgentConsumer } from "./coding-agent-consumer.mjs"; + +const codingAgentName = "@earendil-works/pi-coding-agent"; +const devPackages = ["pi-client", "pi-protocol", "pi-server"].map((name) => `@earendil-works/${name}`); + +function createFixture(t, { importServer = false, declareServer = false } = {}) { + const root = mkdtempSync(join(tmpdir(), "pi-consumer-test-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const packages = [codingAgentName, "@earendil-works/chord", ...devPackages].map((name) => ({ + name, + directory: join(root, "packages", name.split("/")[1]), + })); + for (const pkg of packages) { + const isAgent = pkg.name === codingAgentName; + const manifest = { + name: pkg.name, + version: "1.0.0", + type: "module", + exports: isAgent ? { + ".": "./dist/index.js", + "./client": { source: "./src/client/index.ts" }, + "./experimental/plugin": { source: "./src/experimental/plugin.ts" }, + } : "./dist/index.js", + ...(isAgent ? { + bin: { pi: "dist/bundle/cli.js" }, + dependencies: { + "@earendil-works/chord": "1.0.0", + ...(declareServer ? { "@earendil-works/pi-server": "1.0.0" } : {}), + }, + devDependencies: Object.fromEntries(devPackages.map((name) => [name, "1.0.0"])), + } : {}), + }; + const files = { + "package.json": JSON.stringify(manifest), + "dist/index.js": isAgent ? ` +${importServer ? 'import "@earendil-works/pi-server";' : ""} +import { marker } from "@earendil-works/chord"; +if (marker !== "local tarball") throw new Error("Wrong Chord artifact"); +export function createAgentSession() {} +export class SessionManager { static inMemory() {} } +export class ModelRuntime { static create() {} } +` : 'export const marker = "local tarball";', + ...(isAgent ? { + "dist/cli.js": 'console.log("1.0.0");', + "dist/bundle/cli.js": 'console.log("1.0.0");', + } : {}), + }; + for (const [path, content] of Object.entries(files)) { + mkdirSync(dirname(join(pkg.directory, path)), { recursive: true }); + writeFileSync(join(pkg.directory, path), content); + } + } + const tarballs = packReleasePackages(packages, join(root, "tarballs")); + const directory = join(root, "consumer"); + installCodingAgentConsumer(directory, tarballs); + return directory; +} + +// #9132: installing every tarball directly hid undeclared runtime imports. +test("installs only coding-agent directly and uses overrides only for declared runtime dependencies", (t) => { + const directory = createFixture(t); + const manifest = JSON.parse(readFileSync(join(directory, "package.json"), "utf8")); + assert.deepEqual(Object.keys(manifest.dependencies), [codingAgentName]); + for (const name of devPackages) { + assert.ok(manifest.overrides[name]); + assert.equal(existsSync(join(directory, "node_modules", name)), false); + } + smokeTestCodingAgentConsumer(directory); + + const nested = join(directory, "node_modules", codingAgentName, "node_modules/@earendil-works/pi-server"); + mkdirSync(nested, { recursive: true }); + writeFileSync(join(nested, "package.json"), JSON.stringify({ name: "@earendil-works/pi-server", version: "1.0.0" })); + assert.throws(() => smokeTestCodingAgentConsumer(directory), /pi-server must not be installed/); + rmSync(nested, { recursive: true }); + + const experimental = join(directory, "node_modules", codingAgentName, "dist/experimental"); + mkdirSync(experimental); + assert.throws(() => smokeTestCodingAgentConsumer(directory), /contains development-only code/); +}); + +// #9132: smoke-test the public SDK, not just a bundled CLI that hides missing imports. +test("fails when the SDK imports an undeclared server despite a working CLI", (t) => { + const directory = createFixture(t, { importServer: true }); + assert.throws(() => smokeTestCodingAgentConsumer(directory), /Cannot find package '@earendil-works\/pi-server'/); +}); + +test("fails if a development-only dependency is added back to the published dependency tree", (t) => { + const directory = createFixture(t, { declareServer: true }); + assert.throws(() => smokeTestCodingAgentConsumer(directory), /pi-server must not be installed/); +}); diff --git a/scripts/local-release.mjs b/scripts/local-release.mjs index f1382de3e..c387f0b0c 100644 --- a/scripts/local-release.mjs +++ b/scripts/local-release.mjs @@ -4,6 +4,7 @@ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symli import { tmpdir } from "node:os"; import { isAbsolute, join, relative, resolve } from "node:path"; import { spawnSync } from "node:child_process"; +import { installCodingAgentConsumer, packReleasePackages, smokeTestCodingAgentConsumer } from "./coding-agent-consumer.mjs"; const packages = [ { directory: "packages/chord", name: "@earendil-works/chord" }, @@ -137,11 +138,6 @@ function prepareOutputDirectory(options, repoRoot) { return outDir; } -function fileSpecifier(fromDirectory, file) { - const relativePath = relative(fromDirectory, file).replaceAll("\\", "/"); - return `file:${relativePath.startsWith(".") ? relativePath : `./${relativePath}`}`; -} - function currentBinaryPlatform() { if (process.platform === "win32") return process.arch === "arm64" ? "windows-arm64" : "windows-x64"; if (process.platform === "darwin") return process.arch === "arm64" ? "darwin-arm64" : "darwin-x64"; @@ -186,22 +182,6 @@ function createPiShim(installDirectory) { symlinkSync(join("node_modules", ".bin", "pi"), join(installDirectory, "pi")); } -function packPackage(pkg, tarballDirectory) { - const packageJson = readPackageJson(pkg.directory); - if (packageJson.name !== pkg.name) { - throw new Error(`${pkg.directory}/package.json has name ${packageJson.name}, expected ${pkg.name}`); - } - - const output = run("npm", ["pack", "--json", "--pack-destination", tarballDirectory], { - capture: true, - cwd: pkg.directory, - }); - // npm <11.6 returns an array; newer npm returns an object keyed by package name. - const parsed = JSON.parse(output); - const packed = Array.isArray(parsed) ? parsed[0] : Object.values(parsed)[0]; - return join(tarballDirectory, packed.filename); -} - const options = parseArgs(); const repoRoot = process.cwd(); const rootPackageJson = readPackageJson(repoRoot); @@ -234,36 +214,22 @@ if (!options.skipTest) { run("./test.sh", [], { cwd: repoRoot }); } -const tarballs = new Map(); -for (const pkg of packages) { - const tarball = packPackage(pkg, tarballDirectory); - tarballs.set(pkg.name, tarball); -} +const tarballs = packReleasePackages(packages, tarballDirectory); let binaryPlatform; if (!options.skipInstall) { binaryPlatform = buildBunBinaryRelease(binaryDirectory, outDir); - mkdirSync(nodeInstallDirectory, { recursive: true }); - const dependencies = Object.fromEntries( - packages.map((pkg) => [pkg.name, fileSpecifier(nodeInstallDirectory, tarballs.get(pkg.name))]), - ); - const installPackageJson = `${JSON.stringify({ private: true, dependencies, overrides: dependencies }, undefined, "\t")}\n`; - writeFileSync(join(nodeInstallDirectory, "package.json"), installPackageJson); - - run("npm", ["install", "--omit=dev", "--ignore-scripts"], { cwd: nodeInstallDirectory }); + installCodingAgentConsumer(nodeInstallDirectory, tarballs); + smokeTestCodingAgentConsumer(nodeInstallDirectory); createPiShim(nodeInstallDirectory); if (!options.skipBunInstall) { if (!commandExists("bun")) { throw new Error("Bun is required for the isolated Bun install. Use --skip-bun-install to skip it."); } - mkdirSync(bunInstallDirectory, { recursive: true }); - const bunDependencies = Object.fromEntries( - packages.map((pkg) => [pkg.name, fileSpecifier(bunInstallDirectory, tarballs.get(pkg.name))]), - ); - writeFileSync(join(bunInstallDirectory, "package.json"), `${JSON.stringify({ private: true, dependencies: bunDependencies, overrides: bunDependencies }, undefined, "\t")}\n`); - run("bun", ["install", "--production", "--ignore-scripts"], { cwd: bunInstallDirectory }); + installCodingAgentConsumer(bunInstallDirectory, tarballs, "bun"); + smokeTestCodingAgentConsumer(bunInstallDirectory, "bun"); createPiShim(bunInstallDirectory); } } diff --git a/scripts/release.mjs b/scripts/release.mjs index 02e1fd0fc..093101553 100755 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -254,6 +254,10 @@ console.log("Running tests..."); run("./test.sh"); console.log(); +console.log("Checking the packed coding-agent consumer install..."); +run("npm run check:package-install"); +console.log(); + // 7. Commit and tag console.log("Committing and tagging..."); stageChangedFiles();