diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2f8621aa..79ca11e6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Changed `pi update --self` to honor the active package name returned by the Pi version check endpoint, defaulting to the current package when omitted and uninstalling the old global package before installing a renamed package. + ### Fixed - Fixed `pi -p` treating prompts that start with YAML frontmatter as extension flags instead of user messages ([#4163](https://github.com/badlogic/pi-mono/issues/4163)). diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index 0d961655..869f4024 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -28,12 +28,36 @@ export const isBunRuntime = !!process.versions.bun; export type InstallMethod = "bun-binary" | "npm" | "pnpm" | "yarn" | "bun" | "unknown"; -export interface SelfUpdateCommand { +interface SelfUpdateCommandStep { command: string; args: string[]; display: string; } +export interface SelfUpdateCommand extends SelfUpdateCommandStep { + steps?: SelfUpdateCommandStep[]; +} + +function makeSelfUpdateCommand( + installStep: SelfUpdateCommandStep, + uninstallStep?: SelfUpdateCommandStep, +): SelfUpdateCommand { + if (!uninstallStep) return installStep; + return { + ...installStep, + display: `${uninstallStep.display} && ${installStep.display}`, + steps: [uninstallStep, installStep], + }; +} + +function makeSelfUpdateCommandStep(command: string, args: string[]): SelfUpdateCommandStep { + return { + command, + args, + display: [command, ...args].map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg)).join(" "), + }; +} + export function detectInstallMethod(): InstallMethod { if (isBunBinary) { return "bun-binary"; @@ -83,24 +107,44 @@ function getInferredNpmInstall(packageName: string): { root: string; prefix: str function getSelfUpdateCommandForMethod( method: InstallMethod, - packageName: string, + installedPackageName: string, + updatePackageName = installedPackageName, npmCommand?: string[], ): SelfUpdateCommand | undefined { switch (method) { case "bun-binary": return undefined; case "pnpm": - return { command: "pnpm", args: ["install", "-g", packageName], display: `pnpm install -g ${packageName}` }; + return makeSelfUpdateCommand( + makeSelfUpdateCommandStep("pnpm", ["install", "-g", updatePackageName]), + updatePackageName === installedPackageName + ? undefined + : makeSelfUpdateCommandStep("pnpm", ["remove", "-g", installedPackageName]), + ); case "yarn": - return { command: "yarn", args: ["global", "add", packageName], display: `yarn global add ${packageName}` }; + return makeSelfUpdateCommand( + makeSelfUpdateCommandStep("yarn", ["global", "add", updatePackageName]), + updatePackageName === installedPackageName + ? undefined + : makeSelfUpdateCommandStep("yarn", ["global", "remove", installedPackageName]), + ); case "bun": - return { command: "bun", args: ["install", "-g", packageName], display: `bun install -g ${packageName}` }; + return makeSelfUpdateCommand( + makeSelfUpdateCommandStep("bun", ["install", "-g", updatePackageName]), + updatePackageName === installedPackageName + ? undefined + : makeSelfUpdateCommandStep("bun", ["uninstall", "-g", installedPackageName]), + ); case "npm": { const [command = "npm", ...npmArgs] = npmCommand ?? []; - const inferred = npmCommand?.length ? undefined : getInferredNpmInstall(packageName); - const args = [...npmArgs, ...(inferred ? ["--prefix", inferred.prefix] : []), "install", "-g", packageName]; - const display = [command, ...args].map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg)).join(" "); - return { command, args, display }; + const inferred = npmCommand?.length ? undefined : getInferredNpmInstall(installedPackageName); + const prefixArgs = [...npmArgs, ...(inferred ? ["--prefix", inferred.prefix] : [])]; + const installStep = makeSelfUpdateCommandStep(command, [...prefixArgs, "install", "-g", updatePackageName]); + const uninstallStep = + updatePackageName === installedPackageName + ? undefined + : makeSelfUpdateCommandStep(command, [...prefixArgs, "uninstall", "-g", installedPackageName]); + return makeSelfUpdateCommand(installStep, uninstallStep); } case "unknown": return undefined; @@ -210,28 +254,36 @@ function isManagedByGlobalPackageManager(method: InstallMethod, packageName: str ); } -export function getSelfUpdateCommand(packageName: string, npmCommand?: string[]): SelfUpdateCommand | undefined { +export function getSelfUpdateCommand( + packageName: string, + npmCommand?: string[], + updatePackageName = packageName, +): SelfUpdateCommand | undefined { const method = detectInstallMethod(); - const command = getSelfUpdateCommandForMethod(method, packageName, npmCommand); + const command = getSelfUpdateCommandForMethod(method, packageName, updatePackageName, npmCommand); if (!command || !isManagedByGlobalPackageManager(method, packageName, npmCommand) || !isSelfUpdatePathWritable()) { return undefined; } return command; } -export function getSelfUpdateUnavailableInstruction(packageName: string, npmCommand?: string[]): string { +export function getSelfUpdateUnavailableInstruction( + packageName: string, + npmCommand?: string[], + updatePackageName = packageName, +): string { const method = detectInstallMethod(); if (method === "bun-binary") { return `Download from: https://github.com/badlogic/pi-mono/releases/latest`; } - const command = getSelfUpdateCommandForMethod(method, packageName, npmCommand); + const command = getSelfUpdateCommandForMethod(method, packageName, updatePackageName, 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 ${packageName} using the package manager, wrapper, or source checkout that provides this installation.`; + return `Update ${updatePackageName} 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 d9153ac7..a41fb7a6 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -13,7 +13,7 @@ import { import { DefaultPackageManager } from "./core/package-manager.js"; import { SettingsManager } from "./core/settings-manager.js"; import { shouldUseWindowsShell } from "./utils/child-process.js"; -import { getLatestPiVersion, isNewerPackageVersion } from "./utils/version-check.js"; +import { getLatestPiRelease, isNewerPackageVersion } from "./utils/version-check.js"; export type PackageCommand = "install" | "remove" | "update" | "list"; @@ -273,9 +273,9 @@ function updateTargetIncludesExtensions(target: UpdateTarget): boolean { return target.type === "all" || target.type === "extensions"; } -function printSelfUpdateUnavailable(npmCommand?: string[]): void { +function printSelfUpdateUnavailable(npmCommand?: string[], updatePackageName = PACKAGE_NAME): void { console.error(`error: ${APP_NAME} cannot self-update this installation.`); - console.error(getSelfUpdateUnavailableInstruction(PACKAGE_NAME, npmCommand)); + console.error(getSelfUpdateUnavailableInstruction(PACKAGE_NAME, npmCommand, updatePackageName)); const entrypoint = process.argv[1]; if (entrypoint) { @@ -288,47 +288,53 @@ function printSelfUpdateFallback(command: SelfUpdateCommand): void { console.error(chalk.dim(`If this keeps failing, run this command yourself: ${command.display}`)); } -async function shouldRunSelfUpdate(force: boolean): Promise { +interface SelfUpdatePlan { + packageName: string; + shouldRun: boolean; +} + +async function getSelfUpdatePlan(force: boolean): Promise { if (force) { - return true; + return { packageName: PACKAGE_NAME, shouldRun: true }; } - let latestVersion: string | undefined; try { - latestVersion = await getLatestPiVersion(VERSION); + const latestRelease = await getLatestPiRelease(VERSION); + const packageName = latestRelease?.packageName ?? PACKAGE_NAME; + if (!latestRelease || packageName !== PACKAGE_NAME || isNewerPackageVersion(latestRelease.version, VERSION)) { + return { packageName, shouldRun: true }; + } } catch { - return true; - } - - if (!latestVersion || isNewerPackageVersion(latestVersion, VERSION)) { - return true; + return { packageName: PACKAGE_NAME, shouldRun: true }; } console.log(chalk.green(`${APP_NAME} is already up to date (v${VERSION})`)); - return false; + return { packageName: PACKAGE_NAME, shouldRun: false }; } async function runSelfUpdate(command: SelfUpdateCommand): Promise { console.log(chalk.dim(`Updating ${APP_NAME} with ${command.display}...`)); - await new Promise((resolve, reject) => { - // Windows package managers are commonly .cmd shims. Use the shell so Node can execute them. - const child = spawn(command.command, command.args, { - stdio: "inherit", - shell: shouldUseWindowsShell(command.command), + for (const step of command.steps ?? [command]) { + await new Promise((resolve, reject) => { + // Windows package managers are commonly .cmd shims. Use the shell so Node can execute them. + const child = spawn(step.command, step.args, { + stdio: "inherit", + shell: shouldUseWindowsShell(step.command), + }); + 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"}`)); + } + }); }); - child.on("error", (error) => { - reject(error); - }); - child.on("close", (code, signal) => { - if (code === 0) { - resolve(); - } else if (signal) { - reject(new Error(`${command.display} terminated by signal ${signal}`)); - } else { - reject(new Error(`${command.display} exited with code ${code ?? "unknown"}`)); - } - }); - }); + } } export async function handleConfigCommand(args: string[]): Promise { @@ -480,13 +486,18 @@ export async function handlePackageCommand(args: string[]): Promise { } } if (updateTargetIncludesSelf(target)) { - const selfUpdateCommand = getSelfUpdateCommand(PACKAGE_NAME, selfUpdateNpmCommand); - if (!selfUpdateCommand) { - printSelfUpdateUnavailable(selfUpdateNpmCommand); - process.exitCode = 1; + const selfUpdatePlan = await getSelfUpdatePlan(options.force); + if (!selfUpdatePlan.shouldRun) { return true; } - if (!(await shouldRunSelfUpdate(options.force))) { + const selfUpdateCommand = getSelfUpdateCommand( + PACKAGE_NAME, + selfUpdateNpmCommand, + selfUpdatePlan.packageName, + ); + if (!selfUpdateCommand) { + printSelfUpdateUnavailable(selfUpdateNpmCommand, selfUpdatePlan.packageName); + process.exitCode = 1; return true; } try { diff --git a/packages/coding-agent/src/utils/version-check.ts b/packages/coding-agent/src/utils/version-check.ts index e246dcd6..ec969469 100644 --- a/packages/coding-agent/src/utils/version-check.ts +++ b/packages/coding-agent/src/utils/version-check.ts @@ -3,6 +3,11 @@ import { getPiUserAgent } from "./pi-user-agent.js"; const LATEST_VERSION_URL = "https://pi.dev/api/latest-version"; const DEFAULT_VERSION_CHECK_TIMEOUT_MS = 10000; +export interface LatestPiRelease { + version: string; + packageName?: string; +} + interface ParsedVersion { major: number; minor: number; @@ -47,10 +52,10 @@ export function isNewerPackageVersion(candidateVersion: string, currentVersion: return candidateVersion.trim() !== currentVersion.trim(); } -export async function getLatestPiVersion( +export async function getLatestPiRelease( currentVersion: string, options: { timeoutMs?: number } = {}, -): Promise { +): Promise { if (process.env.PI_SKIP_VERSION_CHECK || process.env.PI_OFFLINE) return undefined; const response = await fetch(LATEST_VERSION_URL, { @@ -62,8 +67,20 @@ export async function getLatestPiVersion( }); if (!response.ok) return undefined; - const data = (await response.json()) as { version?: unknown }; - return typeof data.version === "string" && data.version.trim() ? data.version.trim() : undefined; + const data = (await response.json()) as { packageName?: unknown; version?: unknown }; + if (typeof data.version !== "string" || !data.version.trim()) { + return undefined; + } + const packageName = + typeof data.packageName === "string" && data.packageName.trim() ? data.packageName.trim() : undefined; + return { version: data.version.trim(), packageName }; +} + +export async function getLatestPiVersion( + currentVersion: string, + options: { timeoutMs?: number } = {}, +): Promise { + return (await getLatestPiRelease(currentVersion, options))?.version; } export async function checkForNewPiVersion(currentVersion: string): Promise { diff --git a/packages/coding-agent/test/config.test.ts b/packages/coding-agent/test/config.test.ts index e0a556c5..8e29d9e4 100644 --- a/packages/coding-agent/test/config.test.ts +++ b/packages/coding-agent/test/config.test.ts @@ -54,6 +54,49 @@ function createNpmPrefixInstall(template = "pi-prefix-"): { prefix: string; pack return { prefix, packageDir }; } +function createPnpmGlobalInstall(): { root: string; packageDir: string } { + const temp = mkdtempSync(join(tmpdir(), "pi-pnpm-")); + const binDir = join(temp, "bin"); + const root = join(temp, "pnpm", "global", "5", "node_modules"); + const packageDir = join(root, "@mariozechner", "pi-coding-agent"); + mkdirSync(packageDir, { recursive: true }); + mkdirSync(binDir, { recursive: true }); + writeFileSync(join(binDir, process.platform === "win32" ? "pnpm.cmd" : "pnpm"), createFakePnpmScript(root)); + chmodSync(join(binDir, process.platform === "win32" ? "pnpm.cmd" : "pnpm"), 0o755); + tempDir = temp; + process.env.PATH = `${binDir}${delimiter}${originalPath ?? ""}`; + process.env.PI_PACKAGE_DIR = packageDir; + setExecPath( + join( + root, + ".pnpm", + "@mariozechner+pi-coding-agent@0.0.0", + "node_modules", + "@mariozechner", + "pi-coding-agent", + "dist", + "cli.js", + ), + ); + return { root, packageDir }; +} + +function createYarnGlobalInstall(): { globalDir: string; packageDir: string } { + const temp = mkdtempSync(join(tmpdir(), "pi-yarn-")); + const binDir = join(temp, "bin"); + const globalDir = join(temp, "yarn", "global"); + const packageDir = join(globalDir, "node_modules", "@mariozechner", "pi-coding-agent"); + mkdirSync(packageDir, { recursive: true }); + mkdirSync(binDir, { recursive: true }); + writeFileSync(join(binDir, process.platform === "win32" ? "yarn.cmd" : "yarn"), createFakeYarnScript(globalDir)); + chmodSync(join(binDir, process.platform === "win32" ? "yarn.cmd" : "yarn"), 0o755); + tempDir = temp; + process.env.PATH = `${binDir}${delimiter}${originalPath ?? ""}`; + process.env.PI_PACKAGE_DIR = packageDir; + setExecPath(join(globalDir, ".yarn", "@mariozechner", "pi-coding-agent", "dist", "cli.js")); + return { globalDir, packageDir }; +} + function createBunGlobalInstall(): { packageDir: string } { const temp = mkdtempSync(join(tmpdir(), "pi-bun-")); const prefix = join(temp, ".bun"); @@ -72,6 +115,22 @@ function createBunGlobalInstall(): { packageDir: string } { return { packageDir }; } +function createFakePnpmScript(root: string): string { + if (process.platform === "win32") { + return `@echo off\r\nif "%1"=="root" if "%2"=="-g" echo ${root}\r\n`; + } + const escapedRoot = root.replaceAll("'", "'\\''"); + return `#!/bin/sh\nif [ "$1" = "root" ] && [ "$2" = "-g" ]; then\n\tprintf '%s\\n' '${escapedRoot}'\n\texit 0\nfi\nexit 1\n`; +} + +function createFakeYarnScript(globalDir: string): string { + if (process.platform === "win32") { + return `@echo off\r\nif "%1"=="global" if "%2"=="dir" echo ${globalDir}\r\n`; + } + const escapedGlobalDir = globalDir.replaceAll("'", "'\\''"); + return `#!/bin/sh\nif [ "$1" = "global" ] && [ "$2" = "dir" ]; then\n\tprintf '%s\\n' '${escapedGlobalDir}'\n\texit 0\nfi\nexit 1\n`; +} + function createFakeBunScript(bunBin: string): string { if (process.platform === "win32") { return `@echo off\r\nif "%1"=="pm" if "%2"=="bin" if "%3"=="-g" echo ${bunBin}\r\n`; @@ -115,6 +174,30 @@ describe("detectInstallMethod", () => { }); }); + test("self-updates renamed packages from the current install prefix", () => { + const { prefix } = createNpmPrefixInstall(); + + const command = getSelfUpdateCommand("@mariozechner/pi-coding-agent", undefined, "@new-scope/pi"); + + expect(command).toEqual({ + command: "npm", + args: ["--prefix", prefix, "install", "-g", "@new-scope/pi"], + display: `npm --prefix ${prefix} uninstall -g @mariozechner/pi-coding-agent && npm --prefix ${prefix} install -g @new-scope/pi`, + steps: [ + { + command: "npm", + args: ["--prefix", prefix, "uninstall", "-g", "@mariozechner/pi-coding-agent"], + display: `npm --prefix ${prefix} uninstall -g @mariozechner/pi-coding-agent`, + }, + { + command: "npm", + args: ["--prefix", prefix, "install", "-g", "@new-scope/pi"], + display: `npm --prefix ${prefix} install -g @new-scope/pi`, + }, + ], + }); + }); + test("self-update respects configured npmCommand", () => { const { prefix } = createNpmPrefixInstall(); @@ -167,6 +250,81 @@ describe("detectInstallMethod", () => { }); }); + test("self-updates renamed pnpm global installs by removing the old package first", () => { + createPnpmGlobalInstall(); + + const command = getSelfUpdateCommand("@mariozechner/pi-coding-agent", undefined, "@new-scope/pi"); + + expect(detectInstallMethod()).toBe("pnpm"); + expect(command).toEqual({ + command: "pnpm", + args: ["install", "-g", "@new-scope/pi"], + display: "pnpm remove -g @mariozechner/pi-coding-agent && pnpm install -g @new-scope/pi", + steps: [ + { + command: "pnpm", + args: ["remove", "-g", "@mariozechner/pi-coding-agent"], + display: "pnpm remove -g @mariozechner/pi-coding-agent", + }, + { + command: "pnpm", + args: ["install", "-g", "@new-scope/pi"], + display: "pnpm install -g @new-scope/pi", + }, + ], + }); + }); + + test("self-updates renamed yarn global installs by removing the old package first", () => { + createYarnGlobalInstall(); + + const command = getSelfUpdateCommand("@mariozechner/pi-coding-agent", undefined, "@new-scope/pi"); + + expect(detectInstallMethod()).toBe("yarn"); + expect(command).toEqual({ + command: "yarn", + args: ["global", "add", "@new-scope/pi"], + display: "yarn global remove @mariozechner/pi-coding-agent && yarn global add @new-scope/pi", + steps: [ + { + command: "yarn", + args: ["global", "remove", "@mariozechner/pi-coding-agent"], + display: "yarn global remove @mariozechner/pi-coding-agent", + }, + { + command: "yarn", + args: ["global", "add", "@new-scope/pi"], + display: "yarn global add @new-scope/pi", + }, + ], + }); + }); + + test("self-updates renamed bun global installs by removing the old package first", () => { + createBunGlobalInstall(); + + const command = getSelfUpdateCommand("@mariozechner/pi-coding-agent", undefined, "@new-scope/pi"); + + expect(detectInstallMethod()).toBe("bun"); + expect(command).toEqual({ + command: "bun", + args: ["install", "-g", "@new-scope/pi"], + display: "bun uninstall -g @mariozechner/pi-coding-agent && bun install -g @new-scope/pi", + steps: [ + { + command: "bun", + args: ["uninstall", "-g", "@mariozechner/pi-coding-agent"], + display: "bun uninstall -g @mariozechner/pi-coding-agent", + }, + { + command: "bun", + args: ["install", "-g", "@new-scope/pi"], + display: "bun install -g @new-scope/pi", + }, + ], + }); + }); + test("does not self-update when npm install path is not writable", () => { const { packageDir } = createNpmPrefixInstall(); chmodSync(packageDir, 0o500); diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index 1c1ceab4..74cb94f9 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "no import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { ENV_AGENT_DIR } from "../src/config.js"; +import { ENV_AGENT_DIR, PACKAGE_NAME } from "../src/config.js"; import { main } from "../src/main.js"; describe("package commands", () => { @@ -36,6 +36,7 @@ describe("package commands", () => { }); afterEach(() => { + vi.unstubAllGlobals(); process.chdir(originalCwd); process.exitCode = originalExitCode; if (originalAgentDir === undefined) { @@ -128,7 +129,7 @@ describe("package commands", () => { } }); - it("uses global npmCommand for self updates", async () => { + it("uses global npmCommand and current package name for forced self updates without checking the api", async () => { const globalPrefix = join(tempDir, "global-prefix"); const projectPrefix = join(tempDir, "project-prefix"); const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@mariozechner", "pi-coding-agent"); @@ -156,6 +157,8 @@ else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args)); value: join(selfPackageDir, "dist", "cli.js"), configurable: true, }); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); @@ -165,8 +168,10 @@ else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args)); expect(process.exitCode).toBeUndefined(); expect(errorSpy).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); const recordedArgs = JSON.parse(readFileSync(recordPath, "utf-8")) as string[]; expect(recordedArgs).toContain(globalPrefix); + expect(recordedArgs).toContain(PACKAGE_NAME); expect(recordedArgs).not.toContain(projectPrefix); } finally { logSpy.mockRestore(); @@ -174,6 +179,155 @@ else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args)); } }); + it("uses the current package name when the update check omits packageName", async () => { + const globalPrefix = join(tempDir, "global-prefix"); + const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@mariozechner", "pi-coding-agent"); + const fakeNpmPath = join(tempDir, "fake-npm.cjs"); + const recordPath = join(tempDir, "self-update.json"); + mkdirSync(selfPackageDir, { recursive: true }); + writeFileSync( + fakeNpmPath, + `const fs=require("node:fs"),path=require("node:path"),args=process.argv.slice(2),prefix=args[args.indexOf("--prefix")+1]; +if(args.includes("root")) console.log(path.join(prefix,"lib","node_modules")); +else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args)); +`, + ); + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ npmCommand: [originalExecPath, fakeNpmPath, "--prefix", globalPrefix] }, null, 2), + ); + process.env.PI_PACKAGE_DIR = selfPackageDir; + Object.defineProperty(process, "execPath", { + value: join(selfPackageDir, "dist", "cli.js"), + configurable: true, + }); + const fetchMock = vi.fn(async () => Response.json({ version: "0.73.1" })); + vi.stubGlobal("fetch", fetchMock); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect(main(["update", "--self"])).resolves.toBeUndefined(); + + expect(process.exitCode).toBeUndefined(); + expect(errorSpy).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledOnce(); + const recordedArgs = JSON.parse(readFileSync(recordPath, "utf-8")) as string[]; + expect(recordedArgs).toContain(PACKAGE_NAME); + } finally { + logSpy.mockRestore(); + errorSpy.mockRestore(); + } + }); + + it("installs the active package name from the update check during self-update", async () => { + const globalPrefix = join(tempDir, "global-prefix"); + const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@mariozechner", "pi-coding-agent"); + const fakeNpmPath = join(tempDir, "fake-npm.cjs"); + const recordPath = join(tempDir, "self-update.json"); + mkdirSync(selfPackageDir, { recursive: true }); + writeFileSync( + fakeNpmPath, + `const fs=require("node:fs"),path=require("node:path"),args=process.argv.slice(2),prefix=args[args.indexOf("--prefix")+1]; +if(args.includes("root")) console.log(path.join(prefix,"lib","node_modules")); +else { + const records=fs.existsSync(${JSON.stringify(recordPath)})?JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)},"utf-8")):[]; + records.push(args); + fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(records)); +} +`, + ); + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ npmCommand: [originalExecPath, fakeNpmPath, "--prefix", globalPrefix] }, null, 2), + ); + process.env.PI_PACKAGE_DIR = selfPackageDir; + Object.defineProperty(process, "execPath", { + value: join(selfPackageDir, "dist", "cli.js"), + configurable: true, + }); + const activePackageName = PACKAGE_NAME === "@new-scope/pi" ? "@newer-scope/pi" : "@new-scope/pi"; + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ packageName: activePackageName, version: "0.73.0" })), + ); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect(main(["update", "--self"])).resolves.toBeUndefined(); + + expect(process.exitCode).toBeUndefined(); + expect(errorSpy).not.toHaveBeenCalled(); + const recordedCalls = JSON.parse(readFileSync(recordPath, "utf-8")) as string[][]; + expect(recordedCalls).toEqual([ + expect.arrayContaining(["uninstall", "-g", PACKAGE_NAME]), + expect.arrayContaining(["install", "-g", activePackageName]), + ]); + } finally { + logSpy.mockRestore(); + errorSpy.mockRestore(); + } + }); + + it("fails self-update when renamed npm package installation fails", async () => { + const globalPrefix = join(tempDir, "global-prefix"); + const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@mariozechner", "pi-coding-agent"); + const fakeNpmPath = join(tempDir, "fake-npm-fail.cjs"); + const recordPath = join(tempDir, "self-update-fail.json"); + mkdirSync(selfPackageDir, { recursive: true }); + writeFileSync( + fakeNpmPath, + `const fs=require("node:fs"),path=require("node:path"),args=process.argv.slice(2),prefix=args[args.indexOf("--prefix")+1]; +if(args.includes("root")) { + console.log(path.join(prefix,"lib","node_modules")); + process.exit(0); +} +const records=fs.existsSync(${JSON.stringify(recordPath)})?JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)},"utf-8")):[]; +records.push(args); +fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(records)); +if(args.includes("install")) process.exit(23); +`, + ); + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ npmCommand: [originalExecPath, fakeNpmPath, "--prefix", globalPrefix] }, null, 2), + ); + process.env.PI_PACKAGE_DIR = selfPackageDir; + Object.defineProperty(process, "execPath", { + value: join(selfPackageDir, "dist", "cli.js"), + configurable: true, + }); + const activePackageName = PACKAGE_NAME === "@new-scope/pi" ? "@newer-scope/pi" : "@new-scope/pi"; + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ packageName: activePackageName, version: "0.73.0" })), + ); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect(main(["update", "--self"])).resolves.toBeUndefined(); + + expect(process.exitCode).toBe(1); + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); + const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stdout).not.toContain(`Updated pi`); + expect(stderr).toContain("exited with code 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]), + ]); + } finally { + logSpy.mockRestore(); + errorSpy.mockRestore(); + } + }); + it("suggests the configured source when update input omits the npm prefix", async () => { const settingsPath = join(agentDir, "settings.json"); writeFileSync(settingsPath, JSON.stringify({ packages: ["npm:pi-formatter"] }, null, 2)); diff --git a/packages/coding-agent/test/version-check.test.ts b/packages/coding-agent/test/version-check.test.ts index 3e5bc8f2..c871718f 100644 --- a/packages/coding-agent/test/version-check.test.ts +++ b/packages/coding-agent/test/version-check.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { checkForNewPiVersion, comparePackageVersions, + getLatestPiRelease, getLatestPiVersion, isNewerPackageVersion, } from "../src/utils/version-check.js"; @@ -56,6 +57,13 @@ describe("version checks", () => { ); }); + it("returns the active package name from the version check api", async () => { + const fetchMock = vi.fn(async () => Response.json({ packageName: "@new-scope/pi", version: "1.2.4" })); + vi.stubGlobal("fetch", fetchMock); + + await expect(getLatestPiRelease("1.2.3")).resolves.toEqual({ packageName: "@new-scope/pi", version: "1.2.4" }); + }); + it("skips api calls when version checks are disabled", async () => { process.env.PI_SKIP_VERSION_CHECK = "1"; const fetchMock = vi.fn();