mirror of
https://github.com/earendil-works/pi.git
synced 2026-10-02 00:35:27 +08:00
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:
@@ -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
|
||||
|
||||
Generated
+3
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
@@ -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));
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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[@]}"}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.");
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user