diff --git a/README.md b/README.md index 54bb0700e..35e97ac07 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Diffex is a lightweight, self-evolving coding agent that lives in your CLI. Ask ## Quick start -Requires Node.js 22.19 or newer and npm. +Requires Node.js 22.19 or newer and npm or pnpm. ```bash npm install -g --ignore-scripts @diffexai/diffex @@ -38,6 +38,10 @@ cd your-project diffex ``` +Alternatively, install with `pnpm add -g --ignore-scripts @diffexai/diffex`. +Use `diffex update --self` to update with the package manager that owns your installation. +See [installation and troubleshooting](packages/coding-agent/docs/installation.md) for PATH, permissions, and interrupted-download recovery. + Use `/login` to connect a supported subscription provider, or set a provider API key such as `ANTHROPIC_API_KEY` before launching. Then describe what you want to build or change. Diffex runs with your user account's permissions. Use a container or sandbox when working with untrusted code. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 24ed4b412..4854570e5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +### Changed + +- Added a compact terminal activity display for npm and pnpm self-updates, while preserving package-manager warnings, failure diagnostics, and redirected logs. + +### Fixed + +- Added actionable installation recovery guidance for permissions, unsupported Node versions, interrupted updates, registry failures, and integrity errors without deleting user data or blindly pruning caches. +- Check the CLI's Node.js requirement before initializing application dependencies. + ## [0.4.0] - 2026-10-27 ### New Features diff --git a/packages/coding-agent/docs/installation.md b/packages/coding-agent/docs/installation.md index cb9b44a90..fd9dd6d4b 100644 --- a/packages/coding-agent/docs/installation.md +++ b/packages/coding-agent/docs/installation.md @@ -1,7 +1,7 @@ # Install, update, and remove Diffex Diffex is distributed through npmjs.org as `@diffexai/diffex`. -Use Node.js 22.19.0 or newer and npm on macOS or Linux, on arm64 or x64. +Use Node.js 22.19.0 or newer and npm or pnpm on macOS or Linux, on arm64 or x64. Windows, Android/Termux, standalone binaries, and additional installation channels are not accepted release targets. The hosted runtime image is for the cloud service. @@ -37,6 +37,27 @@ Changing Node installations can change the npm prefix; check `command -v diffex` Start `diffex` in your project directory, use `/login` to configure your provider, and send a short prompt. See [Quickstart](quickstart.md) and [Providers](providers.md) for authentication and execution. +## Install with pnpm + +pnpm installs the same package from npmjs.org; no separate registry or release is required: + +```sh +node --version +pnpm --version +pnpm add -g --ignore-scripts --registry=https://registry.npmjs.org @diffexai/diffex +diffex --version +diffex --help +``` + +If pnpm reports `ERR_PNPM_NO_GLOBAL_BIN_DIR`, run `pnpm setup` and open a new terminal before retrying. +Use `pnpm bin -g` to locate the executable directory and ensure it is on PATH. +Use pnpm for subsequent upgrades, repairs, and removal of this installation; do not mix npm and pnpm in the same installation directory. +For a reproducible installation, specify `@diffexai/diffex@` instead of the unversioned package. + +npm and pnpm own the initial download display. Diffex does not execute install scripts or print a second installer banner. +`diffex update --self` shows a compact activity indicator in terminals, retains warnings and failure diagnostics, and streams package-manager output when redirected to a log. +The indicator shows activity, not an estimated download percentage. + ## Upgrade ```sh @@ -61,6 +82,34 @@ Use your original npm prefix if it differs from the active default. Legacy clients that still trust Pi cannot migrate just by contacting the Diffex endpoint. Install the canonical package directly with the command above, then verify `command -v diffex` and `diffex --version`. +## Diagnose installation failures + +Check the active tools and installation before retrying: + +```sh +node --version +npm --version +npm prefix -g +command -v diffex +``` + +For pnpm, also check `pnpm --version` and `pnpm bin -g`. +Changing Node installations can change both the active executable and the global prefix. + +| Error or symptom | Action | +| --- | --- | +| `EBADENGINE`, unsupported engine, or the Node requirement message | Upgrade to Node.js 22.19.0 or newer using your Node version manager, then retry with the same manager and prefix. | +| `EACCES` or `EPERM` | Use a user-writable prefix or the Node version manager that owns the installation. Do not recursively change permissions on the global directory or other packages. | +| `diffex: command not found` after a successful install | Check the npm prefix or `pnpm bin -g`, add its executable directory to PATH, and open a new terminal. | +| `ETIMEDOUT`, `ECONNRESET`, or registry fetch failure | Check your network, proxy, and configured registry. Retry the exact pinned installation command. | +| `ETARGET` or `ERR_PNPM_NO_MATCHING_VERSION` | Check whether your registry mirror has the reviewed version; wait for it to refresh or use the canonical npmjs.org registry. Do not substitute an unverified version. | +| `EINTEGRITY` or tarball integrity failure | Retry from a trusted registry or refreshed mirror. Do not disable integrity checks. | +| `ENOSPC` | Free space on the installation and cache disks, then retry. | +| `ENOTEMPTY` during npm replacement | Follow the recovery procedure below; do not delete settings or sessions. | + +Do not clear the entire npm cache or pnpm store as a first response to an unrelated error. +If reporting a failure, include the command, OS/architecture, Node and package-manager versions, and the first error code. Redact tokens, proxy credentials, and private registry URLs. + ## Recover an interrupted installation Npm owns replacement of an npm installation. @@ -101,6 +150,24 @@ Removal retains settings, credentials, sessions, installed extensions, and harne Project `.diffex` directories and any separately configured session directory are also retained. Keep this data for reinstallation; delete it separately only if you intend to erase it. +## Repair and remove pnpm installations + +Retry a failed pnpm installation with the same manager and reviewed version: + +```sh +pnpm add -g --ignore-scripts --registry=https://registry.npmjs.org '@diffexai/diffex@' +diffex --version +diffex --help +``` + +Do not apply npm's `.diffex-...` backup recovery procedure to pnpm's shared store. +To remove the pnpm-managed executable while retaining settings and sessions: + +```sh +pnpm remove -g @diffexai/diffex +command -v diffex +``` + ## Bun package installations The same npm package can also be installed by Bun while running on Node: diff --git a/packages/coding-agent/src/cli.ts b/packages/coding-agent/src/cli.ts index 8555ee321..56152f6de 100644 --- a/packages/coding-agent/src/cli.ts +++ b/packages/coding-agent/src/cli.ts @@ -5,6 +5,7 @@ * * Test with: npx tsx src/cli-new.ts [args...] */ +import "./runtime-check.ts"; import { APP_NAME } from "./config.ts"; import { configureHttpDispatcher } from "./core/http-dispatcher.ts"; import { main } from "./main.ts"; diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index 80e3eb011..6545fa17f 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -179,7 +179,23 @@ function getSelfUpdateCommandForMethod( target.packageName === installedPackageName ? undefined : makeSelfUpdateCommandStep(command, [...prefixArgs, "uninstall", "-g", installedPackageName]); - return makeSelfUpdateCommand(installStep, uninstallStep); + const updateCommand = makeSelfUpdateCommand(installStep, uninstallStep); + // Configured wrappers may implement install without supporting npm's cache API. + if (npmCommand?.length) return updateCommand; + // Validate the target archive before npm can retire the installed package. + const cacheStep = makeSelfUpdateCommandStep(command, [ + ...prefixArgs, + "cache", + "add", + "--ignore-scripts", + "--min-release-age=0", + target.installSpec, + ]); + return { + ...updateCommand, + display: `${cacheStep.display} && ${updateCommand.display}`, + steps: [cacheStep, ...(updateCommand.steps ?? [installStep])], + }; } case "unknown": return undefined; diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index 9672ae80d..704fce024 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -13,7 +13,6 @@ import { getSelfUpdateCommand, getSelfUpdateUnavailableInstruction, PACKAGE_NAME, - type SelfUpdateCommand, type SelfUpdatePackageTarget, VERSION, } from "./config.ts"; @@ -24,8 +23,8 @@ import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts"; import { DefaultResourceLoader } from "./core/resource-loader.ts"; import { SettingsManager } from "./core/settings-manager.ts"; import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts"; -import { spawnProcess } from "./utils/child-process.ts"; import { isStableVersion, RELEASE_PACKAGE_NAME } from "./utils/release-contract.ts"; +import { getSelfUpdateFailureHints, runSelfUpdate } from "./utils/self-update.ts"; import { formatVersionCheckError, getLatestDiffexRelease, @@ -445,15 +444,6 @@ function printSelfUpdateUnavailable( } } -function printSelfUpdateFallback(command: SelfUpdateCommand): void { - console.error(chalk.dim(`If this keeps failing, run this command yourself: ${command.display}`)); -} - -function printPnpmSelfUpdateMetadataHint(): void { - console.error(chalk.yellow("If pnpm reports missing package versions, its cached registry metadata may be stale.")); - console.error(chalk.yellow(`Run \`pnpm store prune\` and retry \`${APP_NAME} update --self\`.`)); -} - function printSelfUpdateNote(note: string): void { const trimmedNote = note.trim(); if (!trimmedNote) { @@ -515,29 +505,6 @@ async function getSelfUpdatePlan(force: boolean): Promise { return { packageName, installSpec, version: latestRelease.version, shouldRun: false }; } -async function runSelfUpdate(command: SelfUpdateCommand): Promise { - console.log(chalk.dim(`Updating ${APP_NAME} with ${command.display}...`)); - for (const step of command.steps ?? [command]) { - await new Promise((resolve, reject) => { - const child = spawnProcess(step.command, step.args, { - stdio: "inherit", - }); - child.on("error", (error) => { - reject(error); - }); - child.on("close", (code, signal) => { - if (code === 0) { - resolve(); - } else if (signal) { - reject(new Error(`${step.display} terminated by signal ${signal}`)); - } else { - reject(new Error(`${step.display} exited with code ${code ?? "unknown"}`)); - } - }); - }); - } -} - function prepareWindowsNpmSelfUpdate(): void { if (process.platform !== "win32") { return; @@ -919,6 +886,7 @@ export async function handlePackageCommand( if (selfUpdatePlan.note) { printSelfUpdateNote(selfUpdatePlan.note); } + console.log(chalk.bold(`Diffex update ${VERSION} -> ${selfUpdatePlan.version}`)); try { if (installMethod === "npm") { prepareWindowsNpmSelfUpdate(); @@ -927,10 +895,9 @@ export async function handlePackageCommand( } catch (error: unknown) { const message = error instanceof Error ? error.message : "Unknown package command error"; console.error(chalk.red(`Error: ${message}`)); - if (installMethod === "pnpm") { - printPnpmSelfUpdateMetadataHint(); - } - printSelfUpdateFallback(selfUpdateCommand); + for (const hint of getSelfUpdateFailureHints(error)) console.error(chalk.yellow(hint)); + console.error(chalk.dim(`Retry or repair with: ${selfUpdateCommand.display}`)); + console.error(chalk.dim("Your settings, credentials, and sessions do not need to be removed.")); process.exitCode = 1; return true; } diff --git a/packages/coding-agent/src/runtime-check.ts b/packages/coding-agent/src/runtime-check.ts new file mode 100644 index 000000000..1b537ff64 --- /dev/null +++ b/packages/coding-agent/src/runtime-check.ts @@ -0,0 +1,10 @@ +// Evaluate before the CLI's application imports so older Node installations get +// an actionable message rather than an exception from a provider dependency. +const [major = 0, minor = 0] = process.versions.node.split(".").map(Number); +if (major < 22 || (major === 22 && minor < 19)) { + console.error(`Diffex requires Node.js 22.19.0 or newer (found ${process.versions.node}).`); + console.error( + "Upgrade Node with your version manager, then reinstall @diffexai/diffex using the same package manager and prefix.", + ); + process.exit(1); +} diff --git a/packages/coding-agent/src/utils/self-update.ts b/packages/coding-agent/src/utils/self-update.ts new file mode 100644 index 000000000..fd20d3033 --- /dev/null +++ b/packages/coding-agent/src/utils/self-update.ts @@ -0,0 +1,166 @@ +import { basename } from "node:path"; +import { stripVTControlCharacters } from "node:util"; +import chalk from "chalk"; +import type { SelfUpdateCommand } from "../config.ts"; +import { spawnProcess } from "./child-process.ts"; + +const MAX_OUTPUT_LENGTH = 32 * 1024; + +interface UpdateOutput { + write(text: string): unknown; + isTTY?: boolean; + columns?: number; +} + +export class SelfUpdateError extends Error { + readonly output: string; + + constructor(message: string, output: string, cause?: unknown) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "SelfUpdateError"; + this.output = output; + } +} + +/** Keep package-manager diagnostics actionable without deleting caches or user data. */ +export function getSelfUpdateFailureHints(error: unknown): string[] { + const message = error instanceof Error ? error.message : String(error); + const output = error instanceof SelfUpdateError ? error.output : ""; + const details = `${message}\n${output}`; + if (/\bENOTEMPTY\b/.test(details)) { + return [ + "An interrupted npm update may have left a partial installation or backup. Retry the exact command below first.", + "If ENOTEMPTY persists, move only the Diffex package directory or the exact .diffex-... backup reported by npm to a separate backup directory, then retry. Do not remove your npm prefix or ~/.diffex.", + ]; + } + if (/\bEACCES\b|\bEPERM\b/.test(details)) { + return [ + "The installation directory is not writable. Use the Node version manager or user-writable prefix that owns this installation; do not change permissions on unrelated packages.", + ]; + } + if (/\bEBADENGINE\b|ERR_PNPM_UNSUPPORTED_ENGINE/.test(details)) { + return [ + "Diffex requires Node.js 22.19.0 or newer. Check node --version and the Node installation used by your package manager, then retry.", + ]; + } + if (/\bENOSPC\b/.test(details)) { + return ["The download cache or installation disk is full. Free disk space and retry the exact command below."]; + } + if (/ERR_PNPM_NO_GLOBAL_BIN_DIR/.test(details)) { + return [ + "Run pnpm setup, open a new terminal so PNPM_HOME is on PATH, and retry using the same pnpm installation.", + ]; + } + if (/ERR_PNPM_NO_MATCHING_VERSION|\bETARGET\b/.test(details)) { + return [ + "The verified release is not available from your configured registry or mirror. Check its registry metadata and retry after the mirror refreshes; do not install an unverified version.", + ]; + } + if (/\bEINTEGRITY\b|ERR_PNPM_TARBALL_INTEGRITY/.test(details)) { + return [ + "Package integrity verification failed. Retry using a trusted registry or refreshed mirror; do not disable integrity checks.", + ]; + } + if ( + /\b(?:ETIMEDOUT|ECONNRESET|ECONNREFUSED|EAI_AGAIN|ENOTFOUND)\b|ERR_PNPM_(?:META_FETCH_FAIL|FETCH_)/.test(details) + ) { + return [ + "Check your network, proxy, and package registry, then retry the exact command below. An interrupted replacement may need the same command to repair it.", + ]; + } + return []; +} + +/** Native manager output in logs; one honest indeterminate activity line in terminals. */ +export async function runSelfUpdate( + command: SelfUpdateCommand, + options: { stdout?: UpdateOutput; stderr?: UpdateOutput } = {}, +): Promise { + const stdout = options.stdout ?? process.stdout; + const stderr = options.stderr ?? process.stderr; + for (const step of command.steps ?? [command]) { + const manager = basename(step.command).replace(/\.(?:cmd|exe)$/i, ""); + // Configured wrappers and other managers retain inherited terminal semantics. + const captureOutput = manager === "npm" || manager === "pnpm"; + const interactive = stdout.isTTY === true && process.env.TERM !== "dumb" && captureOutput; + let output = ""; + let errorOutput = ""; + let standardOutput = ""; + let frame = 0; + const started = Date.now(); + const render = () => { + const frames = ["-", "\\", "|", "/"]; + const text = ` ${frames[frame++ % frames.length]} Downloading and installing with ${manager} (${Math.floor((Date.now() - started) / 1000)}s)`; + stdout.write(`\r\u001b[2K${chalk.dim(text.slice(0, Math.max(1, (stdout.columns ?? 80) - 1)))}`); + }; + if (interactive) render(); + else stdout.write(captureOutput ? `Downloading and installing with ${manager}...\n` : `Running ${manager}...\n`); + const timer = interactive ? setInterval(render, 100) : undefined; + let failure: unknown; + try { + await new Promise((resolve, reject) => { + const child = spawnProcess(step.command, step.args, { + stdio: captureOutput ? ["inherit", "pipe", "pipe"] : "inherit", + env: { ...process.env, npm_config_update_notifier: "false", npm_config_fund: "false" }, + }); + let interrupted: NodeJS.Signals | undefined; + const forwardSignal = (signal: NodeJS.Signals) => { + interrupted = signal; + child.kill(signal); + }; + const interrupt = () => forwardSignal("SIGINT"); + const terminate = () => forwardSignal("SIGTERM"); + process.on("SIGINT", interrupt); + process.on("SIGTERM", terminate); + const cleanup = () => { + process.removeListener("SIGINT", interrupt); + process.removeListener("SIGTERM", terminate); + }; + const receive = (chunk: string, destination: UpdateOutput) => { + const text = stripVTControlCharacters(chunk); + output = (output + text).slice(-MAX_OUTPUT_LENGTH); + if (destination === stderr) errorOutput = (errorOutput + text).slice(-MAX_OUTPUT_LENGTH); + else standardOutput = (standardOutput + text).slice(-MAX_OUTPUT_LENGTH); + if (!interactive) destination.write(chunk); + }; + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => receive(chunk, stdout)); + child.stderr?.on("data", (chunk: string) => receive(chunk, stderr)); + child.once("error", (error) => { + cleanup(); + reject(new SelfUpdateError(`Could not start ${manager}: ${error.message}`, output, error)); + }); + child.once("close", (code, signal) => { + cleanup(); + if (code === 0 && !interrupted) resolve(); + else + reject( + new SelfUpdateError( + signal || interrupted + ? `${manager} terminated by signal ${signal ?? interrupted}` + : `${manager} exited with code ${code ?? "unknown"}`, + output, + ), + ); + }); + }); + } catch (error) { + failure = error; + } finally { + if (timer) clearInterval(timer); + if (interactive) stdout.write("\r\u001b[2K"); + } + if (interactive) { + if (failure) stderr.write(`${output.trimEnd()}\n`); + else { + if (errorOutput) stderr.write(`${errorOutput.trimEnd()}\n`); + const warnings = standardOutput + .split(/\r?\n/) + .filter((line) => /\bwarn(?:ing)?\b|deprecated|vulnerabilit|security/i.test(line)); + if (warnings.length) stderr.write(`${warnings.join("\n")}\n`); + } + } + if (failure) throw failure; + } +} diff --git a/packages/coding-agent/test/config.test.ts b/packages/coding-agent/test/config.test.ts index f890de75f..70e279d71 100644 --- a/packages/coding-agent/test/config.test.ts +++ b/packages/coding-agent/test/config.test.ts @@ -218,11 +218,21 @@ describe("detectInstallMethod", () => { const command = getSelfUpdateCommand("@diffexai/diffex"); - expect(detectInstallMethod()).toBe("npm"); - expect(command).toEqual({ + const install = { command: "npm", args: ["--prefix", prefix, "install", "-g", "--ignore-scripts", "--min-release-age=0", "@diffexai/diffex"], display: `npm --prefix ${prefix} install -g --ignore-scripts --min-release-age=0 @diffexai/diffex`, + }; + const cache = { + command: "npm", + args: ["--prefix", prefix, "cache", "add", "--ignore-scripts", "--min-release-age=0", "@diffexai/diffex"], + display: `npm --prefix ${prefix} cache add --ignore-scripts --min-release-age=0 @diffexai/diffex`, + }; + expect(detectInstallMethod()).toBe("npm"); + expect(command).toEqual({ + ...install, + display: `${cache.display} && ${install.display}`, + steps: [cache, install], }); }); @@ -234,7 +244,7 @@ describe("detectInstallMethod", () => { installSpec: "@diffexai/diffex@1.2.3", }); - expect(command).toEqual({ + const install = { command: "npm", args: [ "--prefix", @@ -246,6 +256,24 @@ describe("detectInstallMethod", () => { "@diffexai/diffex@1.2.3", ], display: `npm --prefix ${prefix} install -g --ignore-scripts --min-release-age=0 @diffexai/diffex@1.2.3`, + }; + const cache = { + command: "npm", + args: [ + "--prefix", + prefix, + "cache", + "add", + "--ignore-scripts", + "--min-release-age=0", + "@diffexai/diffex@1.2.3", + ], + display: `npm --prefix ${prefix} cache add --ignore-scripts --min-release-age=0 @diffexai/diffex@1.2.3`, + }; + expect(command).toEqual({ + ...install, + display: `${cache.display} && ${install.display}`, + steps: [cache, install], }); }); @@ -257,8 +285,13 @@ describe("detectInstallMethod", () => { expect(command).toEqual({ command: "npm", args: ["--prefix", prefix, "install", "-g", "--ignore-scripts", "--min-release-age=0", "@new-scope/pi"], - display: `npm --prefix ${prefix} uninstall -g @mariozechner/pi-coding-agent && npm --prefix ${prefix} install -g --ignore-scripts --min-release-age=0 @new-scope/pi`, + display: `npm --prefix ${prefix} cache add --ignore-scripts --min-release-age=0 @new-scope/pi && npm --prefix ${prefix} uninstall -g @mariozechner/pi-coding-agent && npm --prefix ${prefix} install -g --ignore-scripts --min-release-age=0 @new-scope/pi`, steps: [ + { + command: "npm", + args: ["--prefix", prefix, "cache", "add", "--ignore-scripts", "--min-release-age=0", "@new-scope/pi"], + display: `npm --prefix ${prefix} cache add --ignore-scripts --min-release-age=0 @new-scope/pi`, + }, { command: "npm", args: ["--prefix", prefix, "uninstall", "-g", "@mariozechner/pi-coding-agent"], @@ -290,14 +323,10 @@ describe("detectInstallMethod", () => { const command = getSelfUpdateCommand("@diffexai/diffex", []); - expect(command?.args).toEqual([ - "--prefix", - prefix, - "install", - "-g", - "--ignore-scripts", - "--min-release-age=0", - "@diffexai/diffex", + expect(command).toEqual(getSelfUpdateCommand("@diffexai/diffex")); + expect(command?.steps?.map((step) => step.args)).toEqual([ + ["--prefix", prefix, "cache", "add", "--ignore-scripts", "--min-release-age=0", "@diffexai/diffex"], + ["--prefix", prefix, "install", "-g", "--ignore-scripts", "--min-release-age=0", "@diffexai/diffex"], ]); }); @@ -307,7 +336,7 @@ describe("detectInstallMethod", () => { const command = getSelfUpdateCommand("@diffexai/diffex"); expect(command?.display).toBe( - `npm --prefix "${prefix}" install -g --ignore-scripts --min-release-age=0 @diffexai/diffex`, + `npm --prefix "${prefix}" cache add --ignore-scripts --min-release-age=0 @diffexai/diffex && npm --prefix "${prefix}" install -g --ignore-scripts --min-release-age=0 @diffexai/diffex`, ); }); @@ -318,7 +347,7 @@ describe("detectInstallMethod", () => { expect(detectInstallMethod()).toBe("npm"); expect(getUpdateInstruction("@diffexai/diffex")).toBe( - "Run: npm install -g --ignore-scripts --min-release-age=0 @diffexai/diffex", + "Run: npm cache add --ignore-scripts --min-release-age=0 @diffexai/diffex && npm install -g --ignore-scripts --min-release-age=0 @diffexai/diffex", ); }); diff --git a/packages/coding-agent/test/fixtures/version-update-guard.mjs b/packages/coding-agent/test/fixtures/version-update-guard.mjs index 1704d4620..fab79c9ab 100644 --- a/packages/coding-agent/test/fixtures/version-update-guard.mjs +++ b/packages/coding-agent/test/fixtures/version-update-guard.mjs @@ -107,10 +107,27 @@ childProcess.spawn = (command, args, options) => { return originalSpawn(command, args, options); } record({ type: "spawn", command, args }); - if (command !== "npm" || args?.[0] !== "--prefix" || !["uninstall", "install"].includes(args?.[2])) { + const cacheArgs = [ + "--prefix", + join(process.env.DIFFEX_PACKAGE_DIR ?? "", "../../../.."), + "cache", + "add", + "--ignore-scripts", + "--min-release-age=0", + `@diffexai/diffex@${process.env.DIFFEX_REPRO_VERSION}`, + ]; + const cacheAdd = + Boolean(process.env.DIFFEX_PACKAGE_DIR) && + process.env.DIFFEX_REPRO_PACKAGE === "@diffexai/diffex" && + args?.length === cacheArgs.length && + cacheArgs.every((arg, index) => args[index] === arg); + if ( + command !== "npm" || + (!cacheAdd && (args?.[0] !== "--prefix" || !["uninstall", "install"].includes(args?.[2]))) + ) { throw new Error("Unexpected subprocess blocked by the version-update reproduction"); } - // Simulate completion so both uninstall and install attempts can be observed. + // Simulate completion so cache prefetch, uninstall and install attempts can be observed. const child = new EventEmitter(); queueMicrotask(() => child.emit("close", 0, null)); return child; diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index 063333c42..ab40c2166 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -747,7 +747,7 @@ else { }); }); - it("prints a pnpm metadata hint when self-update fails", async () => { + it("prints a targeted pnpm registry hint for missing release metadata", async () => { const globalRoot = join(tempDir, "pnpm", "global", "v11"); const selfPackageDir = join(globalRoot, "node_modules", "@diffexai", "diffex"); const fakeBinDir = join(tempDir, "bin"); @@ -757,8 +757,8 @@ else { writeFileSync(join(selfPackageDir, "package.json"), JSON.stringify({ name: PACKAGE_NAME, version: VERSION })); const fakePnpmScript = process.platform === "win32" - ? `@echo off\r\nif "%1"=="root" if "%2"=="-g" (echo ${globalRoot} & exit /b 0)\r\nexit /b 23\r\n` - : `#!/bin/sh\nif [ "$1" = "root" ] && [ "$2" = "-g" ]; then\n\tprintf '%s\\n' '${globalRoot.replaceAll("'", "'\\''")}'\n\texit 0\nfi\nexit 23\n`; + ? `@echo off\r\nif "%1"=="root" if "%2"=="-g" (echo ${globalRoot} & exit /b 0)\r\necho ERR_PNPM_NO_MATCHING_VERSION fixture\r\nexit /b 23\r\n` + : `#!/bin/sh\nif [ "$1" = "root" ] && [ "$2" = "-g" ]; then\n\tprintf '%s\\n' '${globalRoot.replaceAll("'", "'\\''")}'\n\texit 0\nfi\nprintf '%s\\n' 'ERR_PNPM_NO_MATCHING_VERSION fixture'\nexit 23\n`; writeFileSync(fakePnpmPath, fakePnpmScript); chmodSync(fakePnpmPath, 0o755); process.env.PATH = `${fakeBinDir}${process.env.PATH ? `${delimiter}${process.env.PATH}` : ""}`; @@ -783,8 +783,9 @@ else { const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); expect(stdout).not.toContain("Updated diffex"); expect(stderr).toContain("exited with code 23"); - expect(stderr).toContain("If pnpm reports missing package versions"); - expect(stderr).toContain("Run `pnpm store prune` and retry `diffex update --self`."); + expect(stderr).toContain("The verified release is not available from your configured registry or mirror."); + expect(stderr).toContain("Retry or repair with:"); + expect(stderr).not.toContain("pnpm store prune"); } finally { logSpy.mockRestore(); errorSpy.mockRestore(); diff --git a/packages/coding-agent/test/runtime-check.test.ts b/packages/coding-agent/test/runtime-check.test.ts new file mode 100644 index 000000000..1de9d89c0 --- /dev/null +++ b/packages/coding-agent/test/runtime-check.test.ts @@ -0,0 +1,46 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +describe("CLI Node requirement diagnostic", () => { + let directory: string; + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), "diffex-node-check-")); + }); + afterEach(() => rmSync(directory, { recursive: true, force: true })); + + it.each([ + ["20.19.0", false], + ["22.18.0", false], + ["22.19.0", true], + ["22.20.0", true], + ["24.0.0", true], + ])("checks runtime metadata %s before application dependencies", (version, supported) => { + const preload = join(directory, "runtime-version.mjs"); + writeFileSync(preload, `Object.defineProperty(process.versions, "node", { value: ${JSON.stringify(version)} });`); + const result = spawnSync( + process.execPath, + ["--import", preload, fileURLToPath(new URL("../src/runtime-check.ts", import.meta.url))], + { encoding: "utf8" }, + ); + expect(result.status).toBe(supported ? 0 : 1); + if (supported) expect(result.stderr).toBe(""); + else { + expect(result.stderr).toContain(`Diffex requires Node.js 22.19.0 or newer (found ${version})`); + expect(result.stderr).toContain("same package manager and prefix"); + expect(result.stderr).not.toContain("at "); + } + }); + + it("keeps the CLI guard aligned with the published Node engine", () => { + const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { + engines: { node: string }; + }; + expect(manifest.engines.node).toBe(">=22.19.0"); + const cli = readFileSync(new URL("../src/cli.ts", import.meta.url), "utf8"); + expect(cli.indexOf('import "./runtime-check.ts"')).toBeLessThan(cli.indexOf('from "./main.ts"')); + }); +}); diff --git a/packages/coding-agent/test/self-update.test.ts b/packages/coding-agent/test/self-update.test.ts new file mode 100644 index 000000000..6b93ae6db --- /dev/null +++ b/packages/coding-agent/test/self-update.test.ts @@ -0,0 +1,227 @@ +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getSelfUpdateCommand } from "../src/config.ts"; +import { getSelfUpdateFailureHints, runSelfUpdate, SelfUpdateError } from "../src/utils/self-update.ts"; + +describe("self-update output and diagnostics", () => { + let directory: string; + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), "diffex-self-update-")); + }); + afterEach(() => { + vi.unstubAllEnvs(); + rmSync(directory, { recursive: true, force: true }); + }); + + function manager(name: string, script: string) { + const file = join(directory, `${name}.cjs`); + const command = join(directory, process.platform === "win32" ? `${name}.cmd` : name); + writeFileSync(file, script); + writeFileSync( + command, + process.platform === "win32" + ? `@echo off\r\n"${process.execPath}" "${file}" %*\r\n` + : `#!/bin/sh\nexec '${process.execPath.replaceAll("'", "'\\''")}' '${file.replaceAll("'", "'\\''")}' "$@"\n`, + ); + chmodSync(command, 0o755); + return { + command, + args: ["install", "-g", "@diffexai/diffex@1.2.3"], + display: `${command} install -g @diffexai/diffex@1.2.3`, + }; + } + + function output(interactive: boolean) { + const stdout: string[] = []; + const stderr: string[] = []; + return { + stdout, + stderr, + streams: { + stdout: { write: (text: string) => stdout.push(text), isTTY: interactive, columns: 80 }, + stderr: { write: (text: string) => stderr.push(text) }, + }, + }; + } + + it.each(["npm", "pnpm"])("shows one activity line for interactive %s, preserving warnings", async (name) => { + const command = manager( + name, + 'console.log("Progress: resolved 200, downloaded 200"); console.error("WARN deprecated fixture");', + ); + const capture = output(true); + await runSelfUpdate(command, capture.streams); + expect(capture.stdout.join("")).toContain(`Downloading and installing with ${name}`); + expect(capture.stdout.join("")).not.toContain("resolved 200"); + expect(capture.stdout.join("")).not.toContain("100%"); + expect(capture.stdout.at(-1)).toBe("\r\u001b[2K"); + expect(capture.stderr.join("")).toContain("WARN deprecated fixture"); + }); + + it("streams native output without terminal escapes when redirected", async () => { + const command = manager("npm", 'console.log("added 3 packages"); console.error("npm warn fixture");'); + const capture = output(false); + await runSelfUpdate(command, capture.streams); + expect(capture.stdout.join("")).toContain("added 3 packages"); + expect(capture.stderr.join("")).toContain("npm warn fixture"); + expect(capture.stdout.join("")).not.toContain("\u001b"); + }); + + it("preserves configured wrapper output even in a terminal", async () => { + const command = manager("custom-wrapper", 'console.log("wrapper diagnostic");'); + const capture = output(true); + await runSelfUpdate(command, capture.streams); + // Wrapper output goes directly to its inherited streams, not through our renderer. + expect(capture.stdout.join("")).toBe("Running custom-wrapper...\n"); + expect(capture.stdout.join("")).not.toContain("\u001b"); + }); + + it("replays a bounded diagnostic tail after a failed interactive installation", async () => { + const command = manager( + "npm", + 'process.stdout.write("x".repeat(40000)); console.error("npm error ENOTEMPTY fixture"); process.exitCode = 23;', + ); + const capture = output(true); + const before = [process.listenerCount("SIGINT"), process.listenerCount("SIGTERM")]; + let failure: unknown; + try { + await runSelfUpdate(command, capture.streams); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(SelfUpdateError); + expect((failure as SelfUpdateError).message).toContain("exited with code 23"); + expect((failure as SelfUpdateError).output.length).toBeLessThanOrEqual(32 * 1024); + expect(capture.stderr.join("")).toContain("npm error ENOTEMPTY fixture"); + expect(capture.stderr.join("").length).toBeLessThanOrEqual(32 * 1024 + 1); + expect([process.listenerCount("SIGINT"), process.listenerCount("SIGTERM")]).toEqual(before); + }); + + it("clears progress and reports spawn failures", async () => { + const capture = output(true); + await expect( + runSelfUpdate({ command: join(directory, "missing", "npm"), args: [], display: "npm" }, capture.streams), + ).rejects.toThrow("Could not start npm"); + expect(capture.stdout.at(-1)).toBe("\r\u001b[2K"); + }); + + it("does not run later steps after a failed installation", async () => { + const fail = manager("npm", "process.exitCode = 23;"); + const next = manager("pnpm", 'throw new Error("must not run");'); + const capture = output(false); + await expect(runSelfUpdate({ ...fail, steps: [fail, next] }, capture.streams)).rejects.toThrow( + "exited with code 23", + ); + expect(capture.stdout.join("")).not.toContain("with pnpm"); + }); + + it.each([ + { failure: "EINTEGRITY", renamed: false }, + { failure: "ECONNRESET", renamed: false }, + { failure: "EINTEGRITY", renamed: true }, + { failure: "ECONNRESET", renamed: true }, + { failure: undefined, renamed: false }, + { failure: undefined, renamed: true }, + ])( + "validates the native npm target before replacement ($failure, renamed=$renamed)", + async ({ failure, renamed }) => { + const prefix = join(directory, "prefix with spaces"); + const installedName = renamed ? "@mariozechner/pi-coding-agent" : "@diffexai/diffex"; + const packageDir = join(prefix, "lib", "node_modules", ...installedName.split("/")); + const sentinel = join(packageDir, "sentinel"); + const records = join(directory, "commands.jsonl"); + mkdirSync(packageDir, { recursive: true }); + writeFileSync(sentinel, "existing installation"); + manager( + "npm", + `const fs = require("node:fs"); +const args = process.argv.slice(2); +if (args.includes("root")) { + console.log(${JSON.stringify(join(prefix, "lib", "node_modules"))}); + process.exit(0); +} +fs.appendFileSync(${JSON.stringify(records)}, JSON.stringify(args) + "\\n"); +if (args.includes("cache") && ${JSON.stringify(failure ?? "")}) { + console.error("npm error " + ${JSON.stringify(failure ?? "")}); + process.exit(23); +} +if (args.includes("install") || args.includes("uninstall")) fs.writeFileSync(${JSON.stringify(sentinel)}, "replacement started"); +`, + ); + vi.stubEnv("PATH", `${directory}${delimiter}${process.env.PATH ?? ""}`); + vi.stubEnv("DIFFEX_PACKAGE_DIR", packageDir); + const descriptor = Object.getOwnPropertyDescriptor(process, "execPath"); + Object.defineProperty(process, "execPath", { value: join(packageDir, "dist", "cli.js"), configurable: true }); + try { + const command = getSelfUpdateCommand(installedName, undefined, { + packageName: "@diffexai/diffex", + installSpec: "@diffexai/diffex@1.2.3", + }); + expect(command).toBeDefined(); + if (!command) throw new Error("Expected a native npm update command"); + const capture = output(false); + if (failure) { + await expect(runSelfUpdate(command, capture.streams)).rejects.toThrow("exited with code 23"); + expect(capture.stderr.join("")).toContain(failure); + expect(readFileSync(sentinel, "utf8")).toBe("existing installation"); + } else { + await runSelfUpdate(command, capture.streams); + expect(readFileSync(sentinel, "utf8")).toBe("replacement started"); + } + const cache = [ + "--prefix", + prefix, + "cache", + "add", + "--ignore-scripts", + "--min-release-age=0", + "@diffexai/diffex@1.2.3", + ]; + const install = [ + "--prefix", + prefix, + "install", + "-g", + "--ignore-scripts", + "--min-release-age=0", + "@diffexai/diffex@1.2.3", + ]; + const uninstall = ["--prefix", prefix, "uninstall", "-g", installedName]; + expect( + readFileSync(records, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)), + ).toEqual(failure ? [cache] : renamed ? [cache, uninstall, install] : [cache, install]); + } finally { + if (descriptor) Object.defineProperty(process, "execPath", descriptor); + } + }, + ); + + it.each([ + ["ENOTEMPTY", "move only the Diffex package directory"], + ["EACCES", "not writable"], + ["EPERM", "not writable"], + ["EBADENGINE", "Node.js 22.19.0"], + ["ERR_PNPM_UNSUPPORTED_ENGINE", "Node.js 22.19.0"], + ["ENOSPC", "disk is full"], + ["ERR_PNPM_NO_GLOBAL_BIN_DIR", "pnpm setup"], + ["ERR_PNPM_NO_MATCHING_VERSION", "mirror refreshes"], + ["ETARGET", "mirror refreshes"], + ["EINTEGRITY", "do not disable integrity checks"], + ["ERR_PNPM_TARBALL_INTEGRITY", "do not disable integrity checks"], + ["ECONNRESET", "network, proxy"], + ["ERR_PNPM_META_FETCH_FAIL", "network, proxy"], + ])("provides a targeted recovery hint for %s", (code, expected) => { + expect(getSelfUpdateFailureHints(new SelfUpdateError("install failed", code)).join("\n")).toContain(expected); + }); + + it("does not suggest pruning caches for unrelated pnpm failures", () => { + expect(getSelfUpdateFailureHints(new SelfUpdateError("pnpm exited with code 23", "unrelated failure"))).toEqual( + [], + ); + }); +}); diff --git a/packages/coding-agent/test/version-update-e2e.test.ts b/packages/coding-agent/test/version-update-e2e.test.ts index 780b5875a..5db1efd0c 100644 --- a/packages/coding-agent/test/version-update-e2e.test.ts +++ b/packages/coding-agent/test/version-update-e2e.test.ts @@ -131,11 +131,24 @@ describe.skipIf(process.platform === "win32")("version discovery through the sou }, ); - it("installs the exact canonical newer version after automatic opt-out without uninstalling", async () => { + it("prefetches then installs the exact canonical newer version after automatic opt-out without uninstalling", async () => { const result = await reproduce({ sharedTempDir, skip: true, packageName: installed.name }); expect(result.code, result.output).toBe(0); expectCanonicalRequest(result.events); expect(result.events.filter((event) => event.type === "spawn")).toEqual([ + { + type: "spawn", + command: "npm", + args: [ + "--prefix", + result.prefix, + "cache", + "add", + "--ignore-scripts", + "--min-release-age=0", + `${installed.name}@9999.0.0`, + ], + }, { type: "spawn", command: "npm",