feat: Update check against pi.dev (#3877)
This commit is contained in:
@@ -2,8 +2,13 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed Pi version checks to identify Pi with a `pi/<version>` user agent.
|
||||||
|
|
||||||
### Fixed
|
### 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 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))
|
- 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))
|
||||||
|
|
||||||
|
|||||||
@@ -385,6 +385,7 @@ pi list
|
|||||||
pi update # update pi and packages (skips pinned packages)
|
pi update # update pi and packages (skips pinned packages)
|
||||||
pi update --extensions # update packages only
|
pi update --extensions # update packages only
|
||||||
pi update --self # update pi 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 update npm:@foo/pi-tools # update one package
|
||||||
pi config # enable/disable extensions, skills, prompts, themes
|
pi config # enable/disable extensions, skills, prompts, themes
|
||||||
```
|
```
|
||||||
@@ -483,6 +484,7 @@ pi uninstall <source> [-l] # Alias for remove
|
|||||||
pi update [source|self|pi] # Update pi and packages (skips pinned packages)
|
pi update [source|self|pi] # Update pi and packages (skips pinned packages)
|
||||||
pi update --extensions # Update packages only
|
pi update --extensions # Update packages only
|
||||||
pi update --self # Update pi only
|
pi update --self # Update pi only
|
||||||
|
pi update --self --force # Reinstall pi even if current
|
||||||
pi update --extension <src> # Update one package
|
pi update --extension <src> # Update one package
|
||||||
pi list # List installed packages
|
pi list # List installed packages
|
||||||
pi config # Enable/disable package resources
|
pi config # Enable/disable package resources
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ pi list # show installed packages from settings
|
|||||||
pi update # update pi and all non-pinned packages
|
pi update # update pi and all non-pinned packages
|
||||||
pi update --extensions # update all non-pinned packages only
|
pi update --extensions # update all non-pinned packages only
|
||||||
pi update --self # update pi 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 npm:@foo/bar # update one package
|
||||||
pi update --extension npm:@foo/bar
|
pi update --extension npm:@foo/bar
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -83,8 +83,10 @@ import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/cha
|
|||||||
import { copyToClipboard } from "../../utils/clipboard.js";
|
import { copyToClipboard } from "../../utils/clipboard.js";
|
||||||
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.js";
|
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.js";
|
||||||
import { parseGitUrl } from "../../utils/git.js";
|
import { parseGitUrl } from "../../utils/git.js";
|
||||||
|
import { getPiUserAgent } from "../../utils/pi-user-agent.js";
|
||||||
import { killTrackedDetachedChildren } from "../../utils/shell.js";
|
import { killTrackedDetachedChildren } from "../../utils/shell.js";
|
||||||
import { ensureTool } from "../../utils/tools-manager.js";
|
import { ensureTool } from "../../utils/tools-manager.js";
|
||||||
|
import { checkForNewPiVersion } from "../../utils/version-check.js";
|
||||||
import { ArminComponent } from "./components/armin.js";
|
import { ArminComponent } from "./components/armin.js";
|
||||||
import { AssistantMessageComponent } from "./components/assistant-message.js";
|
import { AssistantMessageComponent } from "./components/assistant-message.js";
|
||||||
import { BashExecutionComponent } from "./components/bash-execution.js";
|
import { BashExecutionComponent } from "./components/bash-execution.js";
|
||||||
@@ -708,7 +710,7 @@ export class InteractiveMode {
|
|||||||
await this.init();
|
await this.init();
|
||||||
|
|
||||||
// Start version check asynchronously
|
// Start version check asynchronously
|
||||||
this.checkForNewVersion().then((newVersion) => {
|
checkForNewPiVersion(this.version).then((newVersion) => {
|
||||||
if (newVersion) {
|
if (newVersion) {
|
||||||
this.showNewVersionNotification(newVersion);
|
this.showNewVersionNotification(newVersion);
|
||||||
}
|
}
|
||||||
@@ -779,31 +781,6 @@ export class InteractiveMode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check npm registry for a newer version.
|
|
||||||
*/
|
|
||||||
private async checkForNewVersion(): Promise<string | undefined> {
|
|
||||||
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<string[]> {
|
private async checkForPackageUpdates(): Promise<string[]> {
|
||||||
if (process.env.PI_OFFLINE) {
|
if (process.env.PI_OFFLINE) {
|
||||||
return [];
|
return [];
|
||||||
@@ -909,7 +886,10 @@ export class InteractiveMode {
|
|||||||
return;
|
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),
|
signal: AbortSignal.timeout(5000),
|
||||||
})
|
})
|
||||||
.then(() => undefined)
|
.then(() => undefined)
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import {
|
|||||||
getSelfUpdateCommand,
|
getSelfUpdateCommand,
|
||||||
getSelfUpdateUnavailableInstruction,
|
getSelfUpdateUnavailableInstruction,
|
||||||
PACKAGE_NAME,
|
PACKAGE_NAME,
|
||||||
|
VERSION,
|
||||||
} from "./config.js";
|
} from "./config.js";
|
||||||
import { DefaultPackageManager } from "./core/package-manager.js";
|
import { DefaultPackageManager } from "./core/package-manager.js";
|
||||||
import { SettingsManager } from "./core/settings-manager.js";
|
import { SettingsManager } from "./core/settings-manager.js";
|
||||||
|
import { getLatestPiVersion, isNewerPackageVersion } from "./utils/version-check.js";
|
||||||
|
|
||||||
export type PackageCommand = "install" | "remove" | "update" | "list";
|
export type PackageCommand = "install" | "remove" | "update" | "list";
|
||||||
|
|
||||||
@@ -20,6 +22,7 @@ interface PackageCommandOptions {
|
|||||||
source?: string;
|
source?: string;
|
||||||
updateTarget?: UpdateTarget;
|
updateTarget?: UpdateTarget;
|
||||||
local: boolean;
|
local: boolean;
|
||||||
|
force: boolean;
|
||||||
help: boolean;
|
help: boolean;
|
||||||
invalidOption?: string;
|
invalidOption?: string;
|
||||||
invalidArgument?: string;
|
invalidArgument?: string;
|
||||||
@@ -44,7 +47,7 @@ function getPackageCommandUsage(command: PackageCommand): string {
|
|||||||
case "remove":
|
case "remove":
|
||||||
return `${APP_NAME} remove <source> [-l]`;
|
return `${APP_NAME} remove <source> [-l]`;
|
||||||
case "update":
|
case "update":
|
||||||
return `${APP_NAME} update [source|self|pi] [--self] [--extensions] [--extension <source>]`;
|
return `${APP_NAME} update [source|self|pi] [--self] [--extensions] [--extension <source>] [--force]`;
|
||||||
case "list":
|
case "list":
|
||||||
return `${APP_NAME} list`;
|
return `${APP_NAME} list`;
|
||||||
}
|
}
|
||||||
@@ -97,6 +100,7 @@ Options:
|
|||||||
--self Update pi only
|
--self Update pi only
|
||||||
--extensions Update installed packages only
|
--extensions Update installed packages only
|
||||||
--extension <source> Update one package only
|
--extension <source> Update one package only
|
||||||
|
--force Reinstall pi even if the current version is latest
|
||||||
|
|
||||||
Short forms:
|
Short forms:
|
||||||
${APP_NAME} update Update pi and all extensions
|
${APP_NAME} update Update pi and all extensions
|
||||||
@@ -128,6 +132,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
|
|||||||
}
|
}
|
||||||
|
|
||||||
let local = false;
|
let local = false;
|
||||||
|
let force = false;
|
||||||
let help = false;
|
let help = false;
|
||||||
let invalidOption: string | undefined;
|
let invalidOption: string | undefined;
|
||||||
let invalidArgument: string | undefined;
|
let invalidArgument: string | undefined;
|
||||||
@@ -172,6 +177,15 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (arg === "--force") {
|
||||||
|
if (command === "update") {
|
||||||
|
force = true;
|
||||||
|
} else {
|
||||||
|
invalidOption = invalidOption ?? arg;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (arg === "--extension") {
|
if (arg === "--extension") {
|
||||||
if (command !== "update") {
|
if (command !== "update") {
|
||||||
invalidOption = invalidOption ?? arg;
|
invalidOption = invalidOption ?? arg;
|
||||||
@@ -240,6 +254,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
|
|||||||
source,
|
source,
|
||||||
updateTarget,
|
updateTarget,
|
||||||
local,
|
local,
|
||||||
|
force,
|
||||||
help,
|
help,
|
||||||
invalidOption,
|
invalidOption,
|
||||||
invalidArgument,
|
invalidArgument,
|
||||||
@@ -277,6 +292,26 @@ function printSelfUpdateFallback(): void {
|
|||||||
console.error(chalk.dim(`If this keeps failing, run this command yourself: ${command.display}`));
|
console.error(chalk.dim(`If this keeps failing, run this command yourself: ${command.display}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function shouldRunSelfUpdate(force: boolean): Promise<boolean> {
|
||||||
|
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<void> {
|
async function runSelfUpdate(): Promise<void> {
|
||||||
const command = getSelfUpdateCommand(PACKAGE_NAME);
|
const command = getSelfUpdateCommand(PACKAGE_NAME);
|
||||||
if (!command) {
|
if (!command) {
|
||||||
@@ -459,6 +494,9 @@ export async function handlePackageCommand(args: string[]): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
if (updateTargetIncludesSelf(target)) {
|
if (updateTargetIncludesSelf(target)) {
|
||||||
if (canSelfUpdate()) {
|
if (canSelfUpdate()) {
|
||||||
|
if (!(await shouldRunSelfUpdate(options.force))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await runSelfUpdate();
|
await runSelfUpdate();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
4
packages/coding-agent/src/utils/pi-user-agent.ts
Normal file
4
packages/coding-agent/src/utils/pi-user-agent.ts
Normal file
@@ -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})`;
|
||||||
|
}
|
||||||
79
packages/coding-agent/src/utils/version-check.ts
Normal file
79
packages/coding-agent/src/utils/version-check.ts
Normal file
@@ -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<string | undefined> {
|
||||||
|
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<string | undefined> {
|
||||||
|
try {
|
||||||
|
const latestVersion = await getLatestPiVersion(currentVersion);
|
||||||
|
if (latestVersion && isNewerPackageVersion(latestVersion, currentVersion)) {
|
||||||
|
return latestVersion;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
packages/coding-agent/test/pi-user-agent.test.ts
Normal file
12
packages/coding-agent/test/pi-user-agent.test.ts
Normal file
@@ -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*[^()]+\)$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
67
packages/coding-agent/test/version-check.test.ts
Normal file
67
packages/coding-agent/test/version-check.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user