diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9f8305f2..18ceea02 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,8 +2,13 @@ ## [Unreleased] +### Changed + +- Changed Pi version checks to identify Pi with a `pi/` user agent. + ### Fixed +- Fixed `pi update` to skip self-update reinstalls when the installed version is already current ([#3853](https://github.com/badlogic/pi-mono/issues/3853)). - Fixed Cloudflare Workers AI attribution headers to honor the install telemetry setting. - Fixed `pi update --self` detection and execution for Windows package-manager shim installs, including symlinked global package roots, and print the manual fallback command when self-update fails. ([#3857](https://github.com/badlogic/pi-mono/issues/3857)) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 10cdc2c0..eec277c3 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -385,6 +385,7 @@ pi list pi update # update pi and packages (skips pinned packages) pi update --extensions # update packages only pi update --self # update pi only +pi update --self --force # reinstall pi even if current pi update npm:@foo/pi-tools # update one package pi config # enable/disable extensions, skills, prompts, themes ``` @@ -483,6 +484,7 @@ pi uninstall [-l] # Alias for remove pi update [source|self|pi] # Update pi and packages (skips pinned packages) pi update --extensions # Update packages only pi update --self # Update pi only +pi update --self --force # Reinstall pi even if current pi update --extension # Update one package pi list # List installed packages pi config # Enable/disable package resources diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index 6370dab1..7face1e3 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -31,6 +31,7 @@ pi list # show installed packages from settings pi update # update pi and all non-pinned packages pi update --extensions # update all non-pinned packages only pi update --self # update pi only +pi update --self --force # reinstall pi even if current pi update npm:@foo/bar # update one package pi update --extension npm:@foo/bar ``` diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index f27d89a8..082a1443 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -83,8 +83,10 @@ import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/cha import { copyToClipboard } from "../../utils/clipboard.js"; import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.js"; import { parseGitUrl } from "../../utils/git.js"; +import { getPiUserAgent } from "../../utils/pi-user-agent.js"; import { killTrackedDetachedChildren } from "../../utils/shell.js"; import { ensureTool } from "../../utils/tools-manager.js"; +import { checkForNewPiVersion } from "../../utils/version-check.js"; import { ArminComponent } from "./components/armin.js"; import { AssistantMessageComponent } from "./components/assistant-message.js"; import { BashExecutionComponent } from "./components/bash-execution.js"; @@ -708,7 +710,7 @@ export class InteractiveMode { await this.init(); // Start version check asynchronously - this.checkForNewVersion().then((newVersion) => { + checkForNewPiVersion(this.version).then((newVersion) => { if (newVersion) { this.showNewVersionNotification(newVersion); } @@ -779,31 +781,6 @@ export class InteractiveMode { } } - /** - * Check npm registry for a newer version. - */ - private async checkForNewVersion(): Promise { - if (process.env.PI_SKIP_VERSION_CHECK || process.env.PI_OFFLINE) return undefined; - - try { - const response = await fetch("https://registry.npmjs.org/@mariozechner/pi-coding-agent/latest", { - signal: AbortSignal.timeout(10000), - }); - if (!response.ok) return undefined; - - const data = (await response.json()) as { version?: string }; - const latestVersion = data.version; - - if (latestVersion && latestVersion !== this.version) { - return latestVersion; - } - - return undefined; - } catch { - return undefined; - } - } - private async checkForPackageUpdates(): Promise { if (process.env.PI_OFFLINE) { return []; @@ -909,7 +886,10 @@ export class InteractiveMode { return; } - void fetch(`https://pi.dev/install?version=${encodeURIComponent(version)}`, { + void fetch(`https://pi.dev/api/report-install?version=${encodeURIComponent(version)}`, { + headers: { + "User-Agent": getPiUserAgent(version), + }, signal: AbortSignal.timeout(5000), }) .then(() => undefined) diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index 8ef940f4..3473d6cc 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -7,9 +7,11 @@ import { getSelfUpdateCommand, getSelfUpdateUnavailableInstruction, PACKAGE_NAME, + VERSION, } from "./config.js"; import { DefaultPackageManager } from "./core/package-manager.js"; import { SettingsManager } from "./core/settings-manager.js"; +import { getLatestPiVersion, isNewerPackageVersion } from "./utils/version-check.js"; export type PackageCommand = "install" | "remove" | "update" | "list"; @@ -20,6 +22,7 @@ interface PackageCommandOptions { source?: string; updateTarget?: UpdateTarget; local: boolean; + force: boolean; help: boolean; invalidOption?: string; invalidArgument?: string; @@ -44,7 +47,7 @@ function getPackageCommandUsage(command: PackageCommand): string { case "remove": return `${APP_NAME} remove [-l]`; case "update": - return `${APP_NAME} update [source|self|pi] [--self] [--extensions] [--extension ]`; + return `${APP_NAME} update [source|self|pi] [--self] [--extensions] [--extension ] [--force]`; case "list": return `${APP_NAME} list`; } @@ -97,6 +100,7 @@ Options: --self Update pi only --extensions Update installed packages only --extension Update one package only + --force Reinstall pi even if the current version is latest Short forms: ${APP_NAME} update Update pi and all extensions @@ -128,6 +132,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined } let local = false; + let force = false; let help = false; let invalidOption: string | undefined; let invalidArgument: string | undefined; @@ -172,6 +177,15 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined continue; } + if (arg === "--force") { + if (command === "update") { + force = true; + } else { + invalidOption = invalidOption ?? arg; + } + continue; + } + if (arg === "--extension") { if (command !== "update") { invalidOption = invalidOption ?? arg; @@ -240,6 +254,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined source, updateTarget, local, + force, help, invalidOption, invalidArgument, @@ -277,6 +292,26 @@ function printSelfUpdateFallback(): void { console.error(chalk.dim(`If this keeps failing, run this command yourself: ${command.display}`)); } +async function shouldRunSelfUpdate(force: boolean): Promise { + if (force) { + return true; + } + + let latestVersion: string | undefined; + try { + latestVersion = await getLatestPiVersion(VERSION); + } catch { + return true; + } + + if (!latestVersion || isNewerPackageVersion(latestVersion, VERSION)) { + return true; + } + + console.log(chalk.green(`${APP_NAME} is already up to date (v${VERSION})`)); + return false; +} + async function runSelfUpdate(): Promise { const command = getSelfUpdateCommand(PACKAGE_NAME); if (!command) { @@ -459,6 +494,9 @@ export async function handlePackageCommand(args: string[]): Promise { } if (updateTargetIncludesSelf(target)) { if (canSelfUpdate()) { + if (!(await shouldRunSelfUpdate(options.force))) { + return true; + } try { await runSelfUpdate(); } catch (error: unknown) { diff --git a/packages/coding-agent/src/utils/pi-user-agent.ts b/packages/coding-agent/src/utils/pi-user-agent.ts new file mode 100644 index 00000000..b1f0a726 --- /dev/null +++ b/packages/coding-agent/src/utils/pi-user-agent.ts @@ -0,0 +1,4 @@ +export function getPiUserAgent(version: string): string { + const runtime = process.versions.bun ? `bun/${process.versions.bun}` : `node/${process.version}`; + return `pi/${version} (${process.platform}; ${runtime}; ${process.arch})`; +} diff --git a/packages/coding-agent/src/utils/version-check.ts b/packages/coding-agent/src/utils/version-check.ts new file mode 100644 index 00000000..e246dcd6 --- /dev/null +++ b/packages/coding-agent/src/utils/version-check.ts @@ -0,0 +1,79 @@ +import { getPiUserAgent } from "./pi-user-agent.js"; + +const LATEST_VERSION_URL = "https://pi.dev/api/latest-version"; +const DEFAULT_VERSION_CHECK_TIMEOUT_MS = 10000; + +interface ParsedVersion { + major: number; + minor: number; + patch: number; + prerelease?: string; +} + +function parsePackageVersion(version: string): ParsedVersion | undefined { + const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/); + if (!match) { + return undefined; + } + return { + major: Number.parseInt(match[1], 10), + minor: Number.parseInt(match[2], 10), + patch: Number.parseInt(match[3], 10), + prerelease: match[4], + }; +} + +export function comparePackageVersions(leftVersion: string, rightVersion: string): number | undefined { + const left = parsePackageVersion(leftVersion); + const right = parsePackageVersion(rightVersion); + if (!left || !right) { + return undefined; + } + + if (left.major !== right.major) return left.major - right.major; + if (left.minor !== right.minor) return left.minor - right.minor; + if (left.patch !== right.patch) return left.patch - right.patch; + if (left.prerelease === right.prerelease) return 0; + if (!left.prerelease) return 1; + if (!right.prerelease) return -1; + return left.prerelease.localeCompare(right.prerelease); +} + +export function isNewerPackageVersion(candidateVersion: string, currentVersion: string): boolean { + const comparison = comparePackageVersions(candidateVersion, currentVersion); + if (comparison !== undefined) { + return comparison > 0; + } + return candidateVersion.trim() !== currentVersion.trim(); +} + +export async function getLatestPiVersion( + currentVersion: string, + options: { timeoutMs?: number } = {}, +): Promise { + if (process.env.PI_SKIP_VERSION_CHECK || process.env.PI_OFFLINE) return undefined; + + const response = await fetch(LATEST_VERSION_URL, { + headers: { + "User-Agent": getPiUserAgent(currentVersion), + accept: "application/json", + }, + signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_VERSION_CHECK_TIMEOUT_MS), + }); + 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; +} + +export async function checkForNewPiVersion(currentVersion: string): Promise { + try { + const latestVersion = await getLatestPiVersion(currentVersion); + if (latestVersion && isNewerPackageVersion(latestVersion, currentVersion)) { + return latestVersion; + } + return undefined; + } catch { + return undefined; + } +} diff --git a/packages/coding-agent/test/pi-user-agent.test.ts b/packages/coding-agent/test/pi-user-agent.test.ts new file mode 100644 index 00000000..64439b21 --- /dev/null +++ b/packages/coding-agent/test/pi-user-agent.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { getPiUserAgent } from "../src/utils/pi-user-agent.js"; + +describe("getPiUserAgent", () => { + it("formats the user agent expected by pi.dev", () => { + const runtime = process.versions.bun ? `bun/${process.versions.bun}` : `node/${process.version}`; + const userAgent = getPiUserAgent("1.2.3"); + + expect(userAgent).toBe(`pi/1.2.3 (${process.platform}; ${runtime}; ${process.arch})`); + expect(userAgent).toMatch(/^pi\/[^\s()]+ \([^;()]+;\s*[^;()]+;\s*[^()]+\)$/); + }); +}); diff --git a/packages/coding-agent/test/version-check.test.ts b/packages/coding-agent/test/version-check.test.ts new file mode 100644 index 00000000..3e5bc8f2 --- /dev/null +++ b/packages/coding-agent/test/version-check.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + checkForNewPiVersion, + comparePackageVersions, + getLatestPiVersion, + isNewerPackageVersion, +} from "../src/utils/version-check.js"; + +const originalSkipVersionCheck = process.env.PI_SKIP_VERSION_CHECK; +const originalOffline = process.env.PI_OFFLINE; + +afterEach(() => { + vi.unstubAllGlobals(); + if (originalSkipVersionCheck === undefined) { + delete process.env.PI_SKIP_VERSION_CHECK; + } else { + process.env.PI_SKIP_VERSION_CHECK = originalSkipVersionCheck; + } + if (originalOffline === undefined) { + delete process.env.PI_OFFLINE; + } else { + process.env.PI_OFFLINE = originalOffline; + } +}); + +describe("version checks", () => { + it("compares package versions", () => { + expect(comparePackageVersions("0.70.6", "0.70.5")).toBeGreaterThan(0); + expect(comparePackageVersions("0.70.5", "0.70.5")).toBe(0); + expect(comparePackageVersions("0.70.4", "0.70.5")).toBeLessThan(0); + expect(isNewerPackageVersion("0.70.5", "0.70.5")).toBe(false); + expect(isNewerPackageVersion("0.70.6", "0.70.5")).toBe(true); + }); + + it("returns only newer versions", async () => { + const fetchMock = vi.fn(async () => Response.json({ version: "1.2.3" })); + vi.stubGlobal("fetch", fetchMock); + + await expect(checkForNewPiVersion("1.2.3")).resolves.toBeUndefined(); + await expect(checkForNewPiVersion("1.2.2")).resolves.toBe("1.2.3"); + }); + + it("uses the pi.dev version check api with a pi user agent", async () => { + const fetchMock = vi.fn(async () => Response.json({ version: "1.2.4" })); + vi.stubGlobal("fetch", fetchMock); + + await expect(getLatestPiVersion("1.2.3")).resolves.toBe("1.2.4"); + expect(fetchMock).toHaveBeenCalledWith( + "https://pi.dev/api/latest-version", + expect.objectContaining({ + headers: expect.objectContaining({ + "User-Agent": expect.stringMatching(/^pi\/1\.2\.3 /), + accept: "application/json", + }), + }), + ); + }); + + it("skips api calls when version checks are disabled", async () => { + process.env.PI_SKIP_VERSION_CHECK = "1"; + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect(getLatestPiVersion("1.2.3")).resolves.toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +});