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.
This commit is contained in:
Armin Ronacher
2026-09-05 12:45:04 +02:00
parent 9841914c71
commit 1382777ed8
28 changed files with 697 additions and 278 deletions
+3
View File
@@ -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
+3 -2
View File
@@ -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",
+3 -1
View File
@@ -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",
+5
View File
@@ -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
-2
View File
@@ -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 |
+19
View File
@@ -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
```
@@ -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.
-26
View File
@@ -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",
-26
View File
@@ -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",
+8 -7
View File
@@ -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",
+3 -13
View File
@@ -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";
@@ -0,0 +1,4 @@
import { restoreSandboxEnv } from "./restore-sandbox-env.ts";
// Restore the environment before evaluating modules that read it at startup.
restoreSandboxEnv();
+3 -25
View File
@@ -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));
-2
View File
@@ -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/)
+13
View File
@@ -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();
}
@@ -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);
}
@@ -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<void> {
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<void>((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<void> {
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<boolean> {
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;
}
-118
View File
@@ -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<void>): Promise<void> {
await new Promise<void>((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<void> {
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<void> {
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<boolean> {
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());
}
@@ -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);
});
});
+1 -8
View File
@@ -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"]
}
+1 -1
View File
@@ -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[@]}"}
-5
View File
@@ -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;
+93
View File
@@ -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.");
+110
View File
@@ -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);
});
+138
View File
@@ -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 });
}
}
+95
View File
@@ -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/);
});
+6 -40
View File
@@ -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);
}
}
+4
View File
@@ -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();