diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ba9b1ae7..92bdd6ba 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `pi update` to install the exact version returned by the Pi update check, make `--force` reinstall that checked version, fail instead of falling back to an unversioned reinstall when no version is available, and report both the old and updated versions. + ## [0.79.9] - 2026-06-20 ### New Features diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index 75b2efaf..38049f05 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -38,6 +38,18 @@ export interface SelfUpdateCommand extends SelfUpdateCommandStep { steps?: SelfUpdateCommandStep[]; } +export type SelfUpdatePackageTarget = string | { packageName: string; installSpec?: string }; + +function normalizeSelfUpdatePackageTarget(target: SelfUpdatePackageTarget): { + packageName: string; + installSpec: string; +} { + if (typeof target === "string") { + return { packageName: target, installSpec: target }; + } + return { packageName: target.packageName, installSpec: target.installSpec ?? target.packageName }; +} + function makeSelfUpdateCommand( installStep: SelfUpdateCommandStep, uninstallStep?: SelfUpdateCommandStep, @@ -103,9 +115,10 @@ function getInferredNpmInstall(): { root: string; prefix: string } | undefined { function getSelfUpdateCommandForMethod( method: InstallMethod, installedPackageName: string, - updatePackageName = installedPackageName, + updatePackageTarget: SelfUpdatePackageTarget = installedPackageName, npmCommand?: string[], ): SelfUpdateCommand | undefined { + const target = normalizeSelfUpdatePackageTarget(updatePackageTarget); switch (method) { case "bun-binary": return undefined; @@ -123,17 +136,17 @@ function getSelfUpdateCommandForMethod( "--ignore-scripts", "--config.minimumReleaseAge=0", ...binDirArgs, - updatePackageName, + target.installSpec, ]), - updatePackageName === installedPackageName + target.packageName === installedPackageName ? undefined : makeSelfUpdateCommandStep("pnpm", ["remove", "-g", ...binDirArgs, installedPackageName]), ); } case "yarn": return makeSelfUpdateCommand( - makeSelfUpdateCommandStep("yarn", ["global", "add", "--ignore-scripts", updatePackageName]), - updatePackageName === installedPackageName + makeSelfUpdateCommandStep("yarn", ["global", "add", "--ignore-scripts", target.installSpec]), + target.packageName === installedPackageName ? undefined : makeSelfUpdateCommandStep("yarn", ["global", "remove", installedPackageName]), ); @@ -144,9 +157,9 @@ function getSelfUpdateCommandForMethod( "-g", "--ignore-scripts", "--minimum-release-age=0", - updatePackageName, + target.installSpec, ]), - updatePackageName === installedPackageName + target.packageName === installedPackageName ? undefined : makeSelfUpdateCommandStep("bun", ["uninstall", "-g", installedPackageName]), ); @@ -160,10 +173,10 @@ function getSelfUpdateCommandForMethod( "-g", "--ignore-scripts", "--min-release-age=0", - updatePackageName, + target.installSpec, ]); const uninstallStep = - updatePackageName === installedPackageName + target.packageName === installedPackageName ? undefined : makeSelfUpdateCommandStep(command, [...prefixArgs, "uninstall", "-g", installedPackageName]); return makeSelfUpdateCommand(installStep, uninstallStep); @@ -302,10 +315,10 @@ function isManagedByGlobalPackageManager(method: InstallMethod, packageName: str export function getSelfUpdateCommand( packageName: string, npmCommand?: string[], - updatePackageName = packageName, + updatePackageTarget: SelfUpdatePackageTarget = packageName, ): SelfUpdateCommand | undefined { const method = detectInstallMethod(); - const command = getSelfUpdateCommandForMethod(method, packageName, updatePackageName, npmCommand); + const command = getSelfUpdateCommandForMethod(method, packageName, updatePackageTarget, npmCommand); if (!command || !isManagedByGlobalPackageManager(method, packageName, npmCommand) || !isSelfUpdatePathWritable()) { return undefined; } @@ -315,20 +328,21 @@ export function getSelfUpdateCommand( export function getSelfUpdateUnavailableInstruction( packageName: string, npmCommand?: string[], - updatePackageName = packageName, + updatePackageTarget: SelfUpdatePackageTarget = packageName, ): string { const method = detectInstallMethod(); + const target = normalizeSelfUpdatePackageTarget(updatePackageTarget); if (method === "bun-binary") { return `Download from: https://github.com/earendil-works/pi-mono/releases/latest`; } - const command = getSelfUpdateCommandForMethod(method, packageName, updatePackageName, npmCommand); + const command = getSelfUpdateCommandForMethod(method, packageName, target, npmCommand); if (command) { if (isManagedByGlobalPackageManager(method, packageName, npmCommand) && !isSelfUpdatePathWritable()) { return `This installation is managed by a global ${method} install, but the install path is not writable. Update it yourself with: ${command.display}`; } return `This installation is not managed by a global ${method} install. Update it with the package manager, wrapper, or source checkout that provides it.`; } - return `Update ${updatePackageName} using the package manager, wrapper, or source checkout that provides this installation.`; + return `Update ${target.installSpec} using the package manager, wrapper, or source checkout that provides this installation.`; } export function getUpdateInstruction(packageName: string): string { diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index d47df930..32980851 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -12,6 +12,7 @@ import { getSelfUpdateUnavailableInstruction, PACKAGE_NAME, type SelfUpdateCommand, + type SelfUpdatePackageTarget, VERSION, } from "./config.ts"; import type { ExtensionFactory } from "./core/extensions/types.ts"; @@ -353,9 +354,12 @@ function updateTargetIncludesExtensions(target: UpdateTarget): boolean { return target.type === "all" || target.type === "extensions"; } -function printSelfUpdateUnavailable(npmCommand?: string[], updatePackageName = PACKAGE_NAME): void { +function printSelfUpdateUnavailable( + npmCommand?: string[], + updatePackageTarget: SelfUpdatePackageTarget = PACKAGE_NAME, +): void { console.error(`error: ${APP_NAME} cannot self-update this installation.`); - console.error(getSelfUpdateUnavailableInstruction(PACKAGE_NAME, npmCommand, updatePackageName)); + console.error(getSelfUpdateUnavailableInstruction(PACKAGE_NAME, npmCommand, updatePackageTarget)); const entrypoint = process.argv[1]; if (entrypoint) { @@ -390,27 +394,38 @@ function printSelfUpdateNote(note: string): void { interface SelfUpdatePlan { packageName: string; + installSpec: string; + version: string; shouldRun: boolean; note?: string; } async function getSelfUpdatePlan(force: boolean): Promise { - if (force) { - return { packageName: PACKAGE_NAME, shouldRun: true }; + let latestRelease: Awaited>; + try { + latestRelease = await getLatestPiRelease(VERSION); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Could not determine latest ${APP_NAME} version: ${message}`); + } + if (!latestRelease) { + throw new Error(`Could not determine latest ${APP_NAME} version.`); } - try { - const latestRelease = await getLatestPiRelease(VERSION); - const packageName = latestRelease?.packageName ?? PACKAGE_NAME; - if (!latestRelease || packageName !== PACKAGE_NAME || isNewerPackageVersion(latestRelease.version, VERSION)) { - return { packageName, shouldRun: true, ...(latestRelease?.note ? { note: latestRelease.note } : {}) }; - } - } catch { - return { packageName: PACKAGE_NAME, shouldRun: true }; + const packageName = latestRelease.packageName ?? PACKAGE_NAME; + const installSpec = `${packageName}@${latestRelease.version}`; + if (force || packageName !== PACKAGE_NAME || isNewerPackageVersion(latestRelease.version, VERSION)) { + return { + packageName, + installSpec, + version: latestRelease.version, + ...(latestRelease.note ? { note: latestRelease.note } : {}), + shouldRun: true, + }; } console.log(chalk.green(`${APP_NAME} is already up to date (v${VERSION})`)); - return { packageName: PACKAGE_NAME, shouldRun: false }; + return { packageName, installSpec, version: latestRelease.version, shouldRun: false }; } async function runSelfUpdate(command: SelfUpdateCommand): Promise { @@ -717,13 +732,13 @@ export async function handlePackageCommand( process.exitCode = 1; return true; } - const selfUpdateCommand = getSelfUpdateCommand( - PACKAGE_NAME, - selfUpdateNpmCommand, - selfUpdatePlan.packageName, - ); + const selfUpdateTarget = { + packageName: selfUpdatePlan.packageName, + installSpec: selfUpdatePlan.installSpec, + }; + const selfUpdateCommand = getSelfUpdateCommand(PACKAGE_NAME, selfUpdateNpmCommand, selfUpdateTarget); if (!selfUpdateCommand) { - printSelfUpdateUnavailable(selfUpdateNpmCommand, selfUpdatePlan.packageName); + printSelfUpdateUnavailable(selfUpdateNpmCommand, selfUpdateTarget); process.exitCode = 1; return true; } @@ -742,7 +757,7 @@ export async function handlePackageCommand( process.exitCode = 1; return true; } - console.log(chalk.green(`Updated ${APP_NAME}`)); + console.log(chalk.green(`Updated ${APP_NAME} from ${VERSION} to ${selfUpdatePlan.version}`)); } return true; } diff --git a/packages/coding-agent/test/config.test.ts b/packages/coding-agent/test/config.test.ts index cbfa044e..49448cf1 100644 --- a/packages/coding-agent/test/config.test.ts +++ b/packages/coding-agent/test/config.test.ts @@ -188,6 +188,29 @@ describe("detectInstallMethod", () => { }); }); + test("self-updates exact npm versions without uninstalling the current package", () => { + const { prefix } = createNpmPrefixInstall(); + + const command = getSelfUpdateCommand("@earendil-works/pi-coding-agent", undefined, { + packageName: "@earendil-works/pi-coding-agent", + installSpec: "@earendil-works/pi-coding-agent@1.2.3", + }); + + expect(command).toEqual({ + command: "npm", + args: [ + "--prefix", + prefix, + "install", + "-g", + "--ignore-scripts", + "--min-release-age=0", + "@earendil-works/pi-coding-agent@1.2.3", + ], + display: `npm --prefix ${prefix} install -g --ignore-scripts --min-release-age=0 @earendil-works/pi-coding-agent@1.2.3`, + }); + }); + test("self-updates renamed packages from the current install prefix", () => { const { prefix } = createNpmPrefixInstall(); diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index df87cf34..6df65ded 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -374,7 +374,7 @@ describe("package commands", () => { } }); - it("uses global npmCommand and current package name for forced self updates without checking the api", async () => { + it("uses the update check version for forced self updates even when current", async () => { const globalPrefix = join(tempDir, "global-prefix"); const projectPrefix = join(tempDir, "project-prefix"); const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@earendil-works", "pi-coding-agent"); @@ -402,7 +402,7 @@ else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args)); value: join(selfPackageDir, "dist", "cli.js"), configurable: true, }); - const fetchMock = vi.fn(); + const fetchMock = vi.fn(async () => Response.json({ version: VERSION })); vi.stubGlobal("fetch", fetchMock); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); @@ -413,11 +413,14 @@ else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args)); expect(process.exitCode).toBeUndefined(); expect(errorSpy).not.toHaveBeenCalled(); - expect(fetchMock).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledOnce(); + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); const recordedArgs = JSON.parse(readFileSync(recordPath, "utf-8")) as string[]; expect(recordedArgs).toContain(globalPrefix); - expect(recordedArgs).toContain(PACKAGE_NAME); + expect(recordedArgs).toContain(`${PACKAGE_NAME}@${VERSION}`); + expect(recordedArgs).not.toContain(PACKAGE_NAME); expect(recordedArgs).not.toContain(projectPrefix); + expect(stdout).toContain(`Updated pi from ${VERSION} to ${VERSION}`); } finally { logSpy.mockRestore(); errorSpy.mockRestore(); @@ -446,7 +449,8 @@ else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args)); value: join(selfPackageDir, "dist", "cli.js"), configurable: true, }); - const fetchMock = vi.fn(async () => Response.json({ version: getNewerPatchVersion() })); + const targetVersion = getNewerPatchVersion(); + const fetchMock = vi.fn(async () => Response.json({ version: targetVersion })); vi.stubGlobal("fetch", fetchMock); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); @@ -458,8 +462,11 @@ else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args)); expect(process.exitCode).toBeUndefined(); expect(errorSpy).not.toHaveBeenCalled(); expect(fetchMock).toHaveBeenCalledOnce(); + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); const recordedArgs = JSON.parse(readFileSync(recordPath, "utf-8")) as string[]; - expect(recordedArgs).toContain(PACKAGE_NAME); + expect(recordedArgs).toContain(`${PACKAGE_NAME}@${targetVersion}`); + expect(recordedArgs).not.toContain(PACKAGE_NAME); + expect(stdout).toContain(`Updated pi from ${VERSION} to ${targetVersion}`); } finally { logSpy.mockRestore(); errorSpy.mockRestore(); @@ -509,7 +516,7 @@ else { const recordedCalls = JSON.parse(readFileSync(recordPath, "utf-8")) as string[][]; expect(recordedCalls).toEqual([ expect.arrayContaining(["uninstall", "-g", PACKAGE_NAME]), - expect.arrayContaining(["install", "-g", activePackageName]), + expect.arrayContaining(["install", "-g", `${activePackageName}@0.73.0`]), ]); } finally { logSpy.mockRestore(); @@ -565,7 +572,7 @@ if(args.includes("install")) process.exit(23); const recordedCalls = JSON.parse(readFileSync(recordPath, "utf-8")) as string[][]; expect(recordedCalls).toEqual([ expect.arrayContaining(["uninstall", "-g", PACKAGE_NAME]), - expect.arrayContaining(["install", "-g", activePackageName]), + expect.arrayContaining(["install", "-g", `${activePackageName}@0.73.0`]), ]); } finally { logSpy.mockRestore();