From 8bc94b19ff1c8ba238019559480a079d7b5bedff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Fri, 30 Oct 2026 22:30:39 +0100 Subject: [PATCH] feat(coding-agent): add PI_SELF_UPDATE_COMMAND and selfUpdateCommand for pi update (#11629) Refs #7756, #5607. Problem: when a wrapper or another package manager provides Pi (mise, a `PNPM_HOME` wrapper such as nub, Nix), `pi update` cannot update it and prints "pi cannot self-update this installation." Users with several machines have to remember which tool updates Pi on which machine. Example: ```sh export PI_SELF_UPDATE_COMMAND="mise use -g npm:{packageName}@{version}" pi update # Updating pi with mise use -g npm:@earendil-works/pi-coding-agent@1.4.0... # Updated pi from 1.3.0 to 1.4.0 ``` or in `~/.pi/agent/settings.json`: ```json { "selfUpdateCommand": ["nix", "profile", "upgrade", "pi"] } ``` Behavior: - `pi update` still checks pi.dev first. The command runs only when a newer release exists, or with `--force`, and the release note prints first. - Placeholders come from that release: `{packageName}`, `{version}`, `{installSpec}` (`@`). A package rename announced by pi.dev flows into them. - `PI_SELF_UPDATE_COMMAND` takes precedence and is split on whitespace (no quoting). The setting is argv, for arguments with spaces, and is read only from global settings, so a project's `.pi/settings.json` cannot inject a command. - The configured command replaces Pi's own update logic for every install method, including installer-managed installs: it is explicit user intent. - Exit code 0 does not prove an update. Pi reports "Updated pi from X to Y" when its own `package.json` now shows Y (in-place updates) or when `pi --version` from PATH reports Y (wrappers such as mise or Nix that install each version into a new directory). Otherwise it warns: ``Ran , but `pi --version` reports X, not Y.`` - "cannot self-update this installation" now mentions the env var and setting. Docs: `docs/cli.md` (new "Custom update command" section), `docs/settings.md`, `docs/environment-variables.md`, `--help` env list, regenerated `schemas/settings.schema.json`. Tests: unit tests for argv resolution and placeholders in `test/config.test.ts`; CLI tests in `test/package-command-paths.test.ts` for env precedence over the setting and the managed install, global-only setting gated by the version check, and command failure. `npm run check` passes. Manual end-to-end check with real mise 2026.10.23 in a sandboxed HOME, running this branch as the mise-installed 1.3.0 (`PI_PACKAGE_DIR` pointing at mise's install): | Scenario | Result | |---|---| | `mise use -g npm:{packageName}@{version}` | mise installs 1.4.0; `Updated pi from 1.3.0 to 1.4.0` | | Already 1.4.0 | `pi is already up to date (v1.4.0)`, mise not run | | In-place `npm install -g --prefix {installSpec}` via the setting | `Updated pi from 1.3.0 to 1.4.0` | | Command `true` (exit 0, installs nothing) | ``Ran true, but `pi --version` reports 1.3.0, not 1.4.0.`` | This PR is AI-generated. --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/cli.md | 10 ++ .../docs/environment-variables.md | 1 + packages/coding-agent/docs/settings.md | 1 + .../coding-agent/schemas/settings.schema.json | 8 ++ packages/coding-agent/src/cli/args.ts | 1 + packages/coding-agent/src/config.ts | 26 ++++ .../coding-agent/src/core/settings-schema.ts | 7 + .../coding-agent/src/package-manager-cli.ts | 58 +++++++- packages/coding-agent/test/config.test.ts | 53 +++++++ .../test/package-command-paths.test.ts | 134 ++++++++++++++++++ 11 files changed, 299 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 651551286..f20c29b45 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -7,6 +7,7 @@ - Extension packages can now show their changelog after an update. Pi reads `pi.changelogPath` from the package's `package.json`, or `CHANGELOG.md` in the package root, and on the next fresh launch shows entries newer than the last seen version under "Extension Updates". The `collapseChangelog` setting condenses this to one line per package ([#5958](https://github.com/earendil-works/pi/issues/5958), [#8790](https://github.com/earendil-works/pi/pull/8790)) - `pi.exec()` accepts a `maxBuffer` option that limits stdout and stderr combined, and its result has a `truncated` field that is true when output exceeded the limit ([#11360](https://github.com/earendil-works/pi/issues/11360)) - Device-code login (for example GitHub Copilot) now opens the verification page and copies the code when you press Enter. Providers can turn either action off with `openBrowserOnConfirm: false` or `copyCodeOnConfirm: false` ([#9282](https://github.com/earendil-works/pi/issues/9282), [#9301](https://github.com/earendil-works/pi/pull/9301)) +- `pi update` can update Pi through a wrapper or another package manager. Set `PI_SELF_UPDATE_COMMAND` (for example `mise use -g npm:{packageName}@{version}`) or the global `selfUpdateCommand` setting; `pi update` runs it when pi.dev reports a newer release and fills `{packageName}`, `{version}`, and `{installSpec}` from that release. See [Custom update command](docs/cli.md#custom-update-command) ([#7756](https://github.com/earendil-works/pi/issues/7756), [#5607](https://github.com/earendil-works/pi/issues/5607)) ### Changed diff --git a/packages/coding-agent/docs/cli.md b/packages/coding-agent/docs/cli.md index cc50719fc..8861d22ad 100644 --- a/packages/coding-agent/docs/cli.md +++ b/packages/coding-agent/docs/cli.md @@ -282,6 +282,16 @@ Add `--force` to reinstall Pi when the selected update includes Pi. `pi update` cannot update Pi when another package manager provides it, such as Nix. Update Pi with that package manager, for example `nix profile upgrade pi`. Package and model catalog updates still work. +#### Custom update command + +If a wrapper or another package manager provides Pi, set `PI_SELF_UPDATE_COMMAND` so `pi update` runs its update command: + +```sh +export PI_SELF_UPDATE_COMMAND="mise use -g npm:{packageName}@{version}" +``` + +`pi update` runs it only when pi.dev has a newer release, replacing `{packageName}`, `{version}`, and `{installSpec}` (`{packageName}@{version}`) with that release. The value is split on spaces. For arguments with spaces, use the `selfUpdateCommand` setting in `~/.pi/agent/settings.json` instead, an argv array such as `["nix", "profile", "upgrade", "pi"]`. + ### Aliases and command options - `pi uninstall ` is an alias for `pi remove `. diff --git a/packages/coding-agent/docs/environment-variables.md b/packages/coding-agent/docs/environment-variables.md index b485d6051..14dd3e142 100644 --- a/packages/coding-agent/docs/environment-variables.md +++ b/packages/coding-agent/docs/environment-variables.md @@ -83,6 +83,7 @@ These variables are read by Pi itself: | `PI_PACKAGE_DIR` | Override the package directory, useful for Nix/Guix store paths | | `PI_OFFLINE` | Disable automatic network activity, including model catalog refreshes | | `PI_SKIP_VERSION_CHECK` | Disable the `pi.dev` latest-version request | +| `PI_SELF_UPDATE_COMMAND` | Command `pi update` runs to update Pi itself, split on whitespace, with `{packageName}`, `{version}`, and `{installSpec}` placeholders. Takes precedence over the `selfUpdateCommand` setting. See [Custom update command](cli.md#custom-update-command) | | `PI_TELEMETRY` | Override install/update telemetry and provider attribution headers: `1`/`true`/`yes` or `0`/`false`/`no` | | `PI_CODEMODE_MAX_WORKERS` | Maximum codemode workers alive at once across all sessions in the process; default `2`. Each worker's script may use up to 256 MB. Raise it when one process runs several sessions, such as in-process subagents. Further scripts wait for a free worker. See [Codemode](codemode.md) | | `PI_CACHE_RETENTION` | Set to `long` for extended provider prompt caching where supported | diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 538a61392..3de528c44 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -142,6 +142,7 @@ Keep `retry.provider.maxRetries` at `0` unless provider-level retries are requir | `shellPath` | string | Platform default | Custom shell executable path. Supports a leading `~`. | | `shellCommandPrefix` | string | None | Prefix prepended to every shell command. | | `npmCommand` | `string[]` | `npm` | Command and arguments used for npm package lookup and installation. | +| `selfUpdateCommand` | `string[]` | None | Command and arguments `pi update` runs to update Pi itself, with `{packageName}`, `{version}`, and `{installSpec}` placeholders. `PI_SELF_UPDATE_COMMAND` takes precedence. **Can only be set in agent-directory settings.** See [Custom update command](cli.md#custom-update-command). | See [Shell aliases](shell-aliases.md) for shell setup and [Pi Packages](packages.md) for package-manager behavior. diff --git a/packages/coding-agent/schemas/settings.schema.json b/packages/coding-agent/schemas/settings.schema.json index 90eaaa3c0..c331ae27a 100644 --- a/packages/coding-agent/schemas/settings.schema.json +++ b/packages/coding-agent/schemas/settings.schema.json @@ -267,6 +267,14 @@ }, "description": "Command used for npm package lookup and installation, in argv form such as [\"mise\", \"exec\", \"node@20\", \"--\", \"npm\"]." }, + "selfUpdateCommand": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "description": "Command `pi update` runs to update Pi itself, in argv form such as [\"mise\", \"use\", \"-g\", \"npm:{packageName}@{version}\"]. Placeholders: {packageName}, {version}, {installSpec}. PI_SELF_UPDATE_COMMAND takes precedence. Global setting only." + }, "collapseChangelog": { "type": "boolean", "description": "Show the condensed changelog after update; use /changelog for the full changelog.", diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 18e0f8c5e..4bcd28dc2 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -463,6 +463,7 @@ ${chalk.bold("Environment Variables:")} ${ENV_SESSION_DIR.padEnd(32)} - Session storage directory (overridden by --session-dir) PI_PACKAGE_DIR - Override package directory (for Nix/Guix store paths) PI_OFFLINE - Disable startup network operations when set to 1/true/yes + PI_SELF_UPDATE_COMMAND - Command pi update runs to update pi itself ({packageName}, {version}, {installSpec}) PI_TELEMETRY - Override install telemetry when set to 1/true/yes or 0/false/no PI_SHARE_VIEWER_URL - Base URL for /share command (default: https://pi.dev/session/) diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index 381747620..5a9552d5d 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -334,6 +334,32 @@ export function getSelfUpdateCommand( return command; } +/** + * The self-update command configured by the user: `PI_SELF_UPDATE_COMMAND` (whitespace-separated + * argv, no quoting), else the global `selfUpdateCommand` setting. Overrides install-method detection. + */ +export function getConfiguredSelfUpdateArgv(setting: string[] | undefined): string[] | undefined { + const fromEnv = process.env.PI_SELF_UPDATE_COMMAND?.trim().split(/\s+/).filter(Boolean); + if (fromEnv?.length) return fromEnv; + return setting?.length ? setting : undefined; +} + +/** Fill the `{packageName}`, `{version}`, and `{installSpec}` placeholders of a configured self-update command. */ +export function makeConfiguredSelfUpdateCommand( + argv: string[], + release: { packageName: string; version: string }, +): SelfUpdateCommand { + const values: Record = { + packageName: release.packageName, + version: release.version, + installSpec: `${release.packageName}@${release.version}`, + }; + const [command = "", ...args] = argv.map((arg) => + arg.replace(/\{(packageName|version|installSpec)\}/g, (_, name: string) => values[name]), + ); + return makeSelfUpdateCommandStep(command, args); +} + export function getSelfUpdateUnavailableInstruction( packageName: string, npmCommand?: string[], diff --git a/packages/coding-agent/src/core/settings-schema.ts b/packages/coding-agent/src/core/settings-schema.ts index 87a3b71a5..c3ada2872 100644 --- a/packages/coding-agent/src/core/settings-schema.ts +++ b/packages/coding-agent/src/core/settings-schema.ts @@ -283,6 +283,13 @@ export const SettingsSchema = Type.Object( 'Command used for npm package lookup and installation, in argv form such as ["mise", "exec", "node@20", "--", "npm"].', }), ), + selfUpdateCommand: Type.Optional( + Type.Array(Type.String(), { + minItems: 1, + description: + 'Command `pi update` runs to update Pi itself, in argv form such as ["mise", "use", "-g", "npm:{packageName}@{version}"]. Placeholders: {packageName}, {version}, {installSpec}. PI_SELF_UPDATE_COMMAND takes precedence. Global setting only.', + }), + ), collapseChangelog: Type.Optional( Type.Boolean({ description: "Show the condensed changelog after update; use /changelog for the full changelog.", diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index 0bfb88917..eecf962ed 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -17,11 +17,14 @@ import { createProjectTrustContext } from "./cli/project-trust.ts"; import { APP_NAME, CONFIG_DIR_NAME, + detectInstallChange, detectInstallMethod, getAgentDir, + getConfiguredSelfUpdateArgv, getPackageDir, getSelfUpdateCommand, getSelfUpdateUnavailableInstruction, + makeConfiguredSelfUpdateCommand, PACKAGE_NAME, type SelfUpdateCommand, type SelfUpdatePackageTarget, @@ -638,6 +641,9 @@ function printSelfUpdateUnavailable( ): void { console.error(`error: ${APP_NAME} cannot self-update this installation.`); console.error(getSelfUpdateUnavailableInstruction(PACKAGE_NAME, npmCommand, updatePackageTarget)); + console.error( + `To make \`${APP_NAME} update\` run your own update command, set PI_SELF_UPDATE_COMMAND or the selfUpdateCommand setting.`, + ); const entrypoint = process.argv[1]; if (entrypoint) { @@ -735,6 +741,48 @@ async function runSelfUpdate(command: SelfUpdateCommand): Promise { } } +/** Run the user-configured self-update command, then report the version installed on disk. */ +async function runConfiguredSelfUpdate(argv: string[], plan: SelfUpdatePlan): Promise { + const command = makeConfiguredSelfUpdateCommand(argv, plan); + if (plan.note) { + printSelfUpdateNote(plan.note); + } + try { + await runSelfUpdate(command); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown self-update command error"; + console.error(chalk.red(`Error: ${message}`)); + process.exitCode = 1; + return; + } + // Exit code 0 does not prove an update. Wrappers such as mise or Nix install each version into a + // new directory and leave this installation untouched, so fall back to the pi on PATH. + const change = detectInstallChange(); + const installedVersion = + change?.kind === "updated" && change.version === plan.version ? change.version : readVersionOnPath(); + if (installedVersion === plan.version) { + console.log(chalk.green(`Updated ${APP_NAME} from ${VERSION} to ${plan.version}`)); + return; + } + console.log( + chalk.yellow( + `Ran ${command.display}, but \`${APP_NAME} --version\` reports ${installedVersion ?? "no version"}, not ${plan.version}.`, + ), + ); +} + +/** Version printed by `pi --version` as resolved from PATH, the pi the user runs next. */ +function readVersionOnPath(): string | undefined { + // PI_PACKAGE_DIR points at this installation and would make the new pi report the old version. + const { PI_PACKAGE_DIR: _, ...env } = process.env; + const result = spawnProcessSync(APP_NAME, ["--version"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + env, + }); + return result.status === 0 ? result.stdout.trim() || undefined : undefined; +} + function prepareWindowsNpmSelfUpdate(): void { if (process.platform !== "win32") { return; @@ -1058,7 +1106,11 @@ export async function handlePackageCommand( } } if (updateTargetIncludesSelf(target)) { - const managedInstallRoot = getActiveManagedInstallRoot(); + const configuredSelfUpdateArgv = getConfiguredSelfUpdateArgv( + settingsManager.getGlobalSettings().selfUpdateCommand, + ); + // A configured command is explicit user intent, so it replaces the managed install path too. + const managedInstallRoot = configuredSelfUpdateArgv ? undefined : getActiveManagedInstallRoot(); if (managedInstallRoot && options.force) { console.error( chalk.red( @@ -1072,6 +1124,10 @@ export async function handlePackageCommand( if (!selfUpdatePlan.shouldRun) { return true; } + if (configuredSelfUpdateArgv) { + await runConfiguredSelfUpdate(configuredSelfUpdateArgv, selfUpdatePlan); + return true; + } if (managedInstallRoot) { if (selfUpdatePlan.note) { printSelfUpdateNote(selfUpdatePlan.note); diff --git a/packages/coding-agent/test/config.test.ts b/packages/coding-agent/test/config.test.ts index 236e26986..ff2c8a733 100644 --- a/packages/coding-agent/test/config.test.ts +++ b/packages/coding-agent/test/config.test.ts @@ -6,9 +6,11 @@ import { detectInstallChange, detectInstallMethod, findNodePackageDir, + getConfiguredSelfUpdateArgv, getSelfUpdateCommand, getSelfUpdateUnavailableInstruction, getUpdateInstruction, + makeConfiguredSelfUpdateCommand, } from "../src/config.ts"; const execPathDescriptor = Object.getOwnPropertyDescriptor(process, "execPath"); @@ -489,3 +491,54 @@ describe("detectInstallChange", () => { expect(detectInstallChange(join(installDir, "package.json"))).toEqual({ kind: "removed" }); }); }); + +describe("configured self-update command", () => { + const originalSelfUpdateCommand = process.env.PI_SELF_UPDATE_COMMAND; + + afterEach(() => { + if (originalSelfUpdateCommand === undefined) { + delete process.env.PI_SELF_UPDATE_COMMAND; + } else { + process.env.PI_SELF_UPDATE_COMMAND = originalSelfUpdateCommand; + } + }); + + test("prefers PI_SELF_UPDATE_COMMAND over the setting and splits it on whitespace", () => { + process.env.PI_SELF_UPDATE_COMMAND = " nub upgrade {installSpec} "; + + expect(getConfiguredSelfUpdateArgv(["mise", "upgrade"])).toEqual(["nub", "upgrade", "{installSpec}"]); + }); + + test("uses the setting when PI_SELF_UPDATE_COMMAND is unset or blank", () => { + process.env.PI_SELF_UPDATE_COMMAND = " "; + + expect(getConfiguredSelfUpdateArgv(["mise", "upgrade", "npm:{packageName}"])).toEqual([ + "mise", + "upgrade", + "npm:{packageName}", + ]); + expect(getConfiguredSelfUpdateArgv([])).toBeUndefined(); + expect(getConfiguredSelfUpdateArgv(undefined)).toBeUndefined(); + }); + + test("fills placeholders in every argument", () => { + const command = makeConfiguredSelfUpdateCommand( + ["wrapper", "install", "{installSpec}", "--name={packageName}", "--version", "{version}", "{unknown}"], + { packageName: "@earendil-works/pi-coding-agent", version: "1.2.3" }, + ); + + expect(command).toEqual({ + command: "wrapper", + args: [ + "install", + "@earendil-works/pi-coding-agent@1.2.3", + "--name=@earendil-works/pi-coding-agent", + "--version", + "1.2.3", + "{unknown}", + ], + display: + "wrapper install @earendil-works/pi-coding-agent@1.2.3 --name=@earendil-works/pi-coding-agent --version 1.2.3 {unknown}", + }); + }); +}); diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index 68949f9a3..a12acd856 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -774,6 +774,140 @@ if (process.platform !== "win32") fs.chmodSync(piPath, 0o755); expect(process.exitCode).toBe(1); }); + function writeNodeShim(binDir: string, name: string, scriptPath: string): void { + const shimPath = join(binDir, process.platform === "win32" ? `${name}.cmd` : name); + writeFileSync( + shimPath, + process.platform === "win32" + ? `@echo off\r\n"${originalExecPath}" "${scriptPath}" %*\r\n` + : `#!/bin/sh\nexec "${originalExecPath}" "${scriptPath}" "$@"\n`, + ); + chmodSync(shimPath, 0o755); + } + + /** Put a `pi-wrapper` update command and a `pi` that prints `piVersion` first on PATH. */ + function prepareSelfUpdateCommand(piVersion: string, exitCode = 0): { scriptPath: string; recordPath: string } { + const binDir = join(tempDir, "self-update-bin"); + const scriptPath = join(tempDir, "self-update.cjs"); + const piScriptPath = join(tempDir, "fake-pi.cjs"); + const recordPath = join(tempDir, "self-update-record.json"); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + scriptPath, + `require("node:fs").writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify(process.argv.slice(2))); +process.exit(${exitCode}); +`, + ); + writeFileSync(piScriptPath, `console.log(${JSON.stringify(piVersion)});\n`); + writeNodeShim(binDir, "pi-wrapper", scriptPath); + writeNodeShim(binDir, "pi", piScriptPath); + process.env.PATH = `${binDir}${delimiter}${originalPath ?? ""}`; + return { scriptPath, recordPath }; + } + + it("runs PI_SELF_UPDATE_COMMAND instead of the managed update and the selfUpdateCommand setting", async () => { + const targetVersion = getNewerPatchVersion(); + const { npmRecordPath } = prepareManagedInstall(targetVersion); + const { scriptPath, recordPath } = prepareSelfUpdateCommand(targetVersion); + const settingRecordPath = join(tempDir, "setting-record.json"); + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ selfUpdateCommand: [originalExecPath, scriptPath, settingRecordPath] }), + ); + vi.stubEnv("PI_SELF_UPDATE_COMMAND", "pi-wrapper upgrade {installSpec} {version}"); + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ packageName: "@new-scope/pi", version: targetVersion, note: "Read me" })), + ); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); + + expect(JSON.parse(readFileSync(recordPath, "utf8")) as string[]).toEqual([ + "upgrade", + `@new-scope/pi@${targetVersion}`, + targetVersion, + ]); + expect(existsSync(npmRecordPath)).toBe(false); + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stdout).toContain("Read me"); + // Like mise, the command leaves this installation untouched; the pi on PATH reports the new version. + expect(stdout).toContain(`Updated pi from ${VERSION} to ${targetVersion}`); + expect(errorSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + }); + + it("runs the global selfUpdateCommand setting only when a newer release exists", async () => { + const targetVersion = getNewerPatchVersion(); + const { scriptPath, recordPath } = prepareSelfUpdateCommand(targetVersion); + const projectRecordPath = join(tempDir, "project-record.json"); + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ selfUpdateCommand: [originalExecPath, scriptPath, "{packageName}"] }), + ); + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync( + join(projectDir, ".pi", "settings.json"), + JSON.stringify({ selfUpdateCommand: [originalExecPath, scriptPath, projectRecordPath] }), + ); + vi.stubEnv("PI_SELF_UPDATE_COMMAND", ""); + const fetchMock = vi.fn(async () => Response.json({ version: VERSION })); + vi.stubGlobal("fetch", fetchMock); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); + expect(existsSync(recordPath)).toBe(false); + expect(logSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain( + `pi is already up to date (v${VERSION})`, + ); + + fetchMock.mockImplementation(async () => Response.json({ version: targetVersion })); + await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); + expect(JSON.parse(readFileSync(recordPath, "utf8")) as string[]).toEqual([PACKAGE_NAME]); + expect(process.exitCode).toBeUndefined(); + }); + + it("warns when the pi on PATH does not report the new version", async () => { + const targetVersion = getNewerPatchVersion(); + prepareSelfUpdateCommand(VERSION); + vi.stubEnv("PI_SELF_UPDATE_COMMAND", "pi-wrapper upgrade"); + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ version: targetVersion })), + ); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); + + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stdout).toContain( + `Ran pi-wrapper upgrade, but \`pi --version\` reports ${VERSION}, not ${targetVersion}.`, + ); + expect(stdout).not.toContain("Updated pi from"); + expect(process.exitCode).toBeUndefined(); + }); + + it("fails when the configured self-update command fails", async () => { + prepareSelfUpdateCommand(VERSION, 23); + vi.stubEnv("PI_SELF_UPDATE_COMMAND", "pi-wrapper upgrade"); + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ version: getNewerPatchVersion() })), + ); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); + + expect(errorSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain( + "pi-wrapper upgrade exited with code 23", + ); + expect(logSpy.mock.calls.map(([message]) => String(message)).join("\n")).not.toContain("Ran pi-wrapper"); + expect(process.exitCode).toBe(1); + }); + it("keeps the managed release active when its update fails", async () => { const targetVersion = getNewerPatchVersion(); const { managedRoot } = prepareManagedInstall(targetVersion, 23);