mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(coding-agent): update managed installations in place
This commit is contained in:
parent
a470b121bf
commit
4af9d21d3b
5 changed files with 449 additions and 61 deletions
|
|
@ -38,7 +38,7 @@ pi update npm:@foo/bar # update one package
|
||||||
pi update --extension npm:@foo/bar
|
pi update --extension npm:@foo/bar
|
||||||
```
|
```
|
||||||
|
|
||||||
These commands manage pi packages and `pi update` can update the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall).
|
These commands manage pi packages and `pi update` can update the pi CLI installation. For experimental installer-managed installations, `pi update` installs the exact checked version into a staged, lockfile-backed release and activates it only after verification, leaving the current release intact if the update fails. Managed installations do not support `--force`; rerun the installer to repair one. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall).
|
||||||
|
|
||||||
By default, `install` and `remove` write to user settings (`~/.pi/agent/settings.json`). Use `-l` to write to project settings (`.pi/settings.json`) instead. Project settings can be shared with your team, and pi installs any missing packages automatically on startup after the project is trusted.
|
By default, `install` and `remove` write to user settings (`~/.pi/agent/settings.json`). Use `-l` to write to project settings (`.pi/settings.json`) instead. Project settings can be shared with your team, and pi installs any missing packages automatically on startup after the project is trusted.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ import { builtInExtensions } from "./extensions/index.ts";
|
||||||
import { runMigrations, showDeprecationWarnings } from "./migrations.ts";
|
import { runMigrations, showDeprecationWarnings } from "./migrations.ts";
|
||||||
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts";
|
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts";
|
||||||
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts";
|
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts";
|
||||||
import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts";
|
import { cleanupManagedInstall, handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts";
|
||||||
import { isLocalPath, normalizePath, resolvePath } from "./utils/paths.ts";
|
import { isLocalPath, normalizePath, resolvePath } from "./utils/paths.ts";
|
||||||
import { cleanupWindowsSelfUpdateQuarantine } from "./utils/windows-self-update.ts";
|
import { cleanupWindowsSelfUpdateQuarantine } from "./utils/windows-self-update.ts";
|
||||||
|
|
||||||
|
|
@ -573,6 +573,7 @@ export async function main(args: string[], options?: MainOptions) {
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
cleanupWindowsSelfUpdateQuarantine(getPackageDir());
|
cleanupWindowsSelfUpdateQuarantine(getPackageDir());
|
||||||
}
|
}
|
||||||
|
cleanupManagedInstall();
|
||||||
|
|
||||||
const cwd = process.cwd();
|
const cwd = process.cwd();
|
||||||
const agentDir = getAgentDir();
|
const agentDir = getAgentDir();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
import { join } from "node:path";
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readdirSync,
|
||||||
|
readFileSync,
|
||||||
|
renameSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
import { Markdown, type MarkdownTheme } from "@earendil-works/pi-tui";
|
import { Markdown, type MarkdownTheme } from "@earendil-works/pi-tui";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
|
import lockfile from "proper-lockfile";
|
||||||
import { selectConfig } from "./cli/config-selector.ts";
|
import { selectConfig } from "./cli/config-selector.ts";
|
||||||
import { createProjectTrustContext } from "./cli/project-trust.ts";
|
import { createProjectTrustContext } from "./cli/project-trust.ts";
|
||||||
import {
|
import {
|
||||||
|
|
@ -23,7 +34,9 @@ import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts";
|
||||||
import { DefaultResourceLoader } from "./core/resource-loader.ts";
|
import { DefaultResourceLoader } from "./core/resource-loader.ts";
|
||||||
import { SettingsManager } from "./core/settings-manager.ts";
|
import { SettingsManager } from "./core/settings-manager.ts";
|
||||||
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts";
|
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts";
|
||||||
import { spawnProcess } from "./utils/child-process.ts";
|
import { spawnProcess, spawnProcessSync, waitForChildProcess } from "./utils/child-process.ts";
|
||||||
|
import { canonicalizePath, getCwdRelativePath } from "./utils/paths.ts";
|
||||||
|
import { getPiUserAgent } from "./utils/pi-user-agent.ts";
|
||||||
import { formatVersionCheckError, getLatestPiRelease, isNewerPackageVersion } from "./utils/version-check.ts";
|
import { formatVersionCheckError, getLatestPiRelease, isNewerPackageVersion } from "./utils/version-check.ts";
|
||||||
import {
|
import {
|
||||||
cleanupWindowsSelfUpdateQuarantine,
|
cleanupWindowsSelfUpdateQuarantine,
|
||||||
|
|
@ -34,6 +47,179 @@ export type PackageCommand = "install" | "remove" | "update" | "list";
|
||||||
|
|
||||||
type UpdateTarget = { type: "all" } | { type: "self" } | { type: "extensions"; source?: string } | { type: "models" };
|
type UpdateTarget = { type: "all" } | { type: "self" } | { type: "extensions"; source?: string } | { type: "models" };
|
||||||
|
|
||||||
|
const DEFAULT_INSTALLER_API_BASE = "https://pi.dev/api/installer/releases";
|
||||||
|
const MANAGED_INSTALL_MARKER = "managed-install.json";
|
||||||
|
const MANAGED_RELEASE_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
||||||
|
|
||||||
|
function getActiveManagedInstallRoot(): string | undefined {
|
||||||
|
const configuredRoot = process.env.PI_MANAGED_INSTALL_ROOT?.trim();
|
||||||
|
if (!configuredRoot) return undefined;
|
||||||
|
|
||||||
|
const managedRoot = resolve(configuredRoot);
|
||||||
|
const releasesDir = canonicalizePath(join(managedRoot, "releases"));
|
||||||
|
// The launcher environment is inherited by child processes. Do not classify a
|
||||||
|
// source checkout or another Pi installation launched from managed Pi as managed.
|
||||||
|
if (getCwdRelativePath(canonicalizePath(getPackageDir()), releasesDir) === undefined) return undefined;
|
||||||
|
|
||||||
|
const markerPath = join(managedRoot, MANAGED_INSTALL_MARKER);
|
||||||
|
try {
|
||||||
|
const marker = JSON.parse(readFileSync(markerPath, "utf8")) as {
|
||||||
|
kind?: unknown;
|
||||||
|
layout?: unknown;
|
||||||
|
schemaVersion?: unknown;
|
||||||
|
};
|
||||||
|
if (marker.kind !== "pi-managed-install" || marker.schemaVersion !== 1 || marker.layout !== "releases-v1") {
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Managed install marker is missing or invalid: ${markerPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return managedRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchInstallerArtifact(url: string, label: string): Promise<string> {
|
||||||
|
const response = await fetch(url, { headers: { "User-Agent": getPiUserAgent(VERSION) } });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Could not download managed installer ${label} from ${url}: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
return await response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runManagedNpmCi(stageDir: string): Promise<void> {
|
||||||
|
const args = [
|
||||||
|
"ci",
|
||||||
|
"--ignore-scripts",
|
||||||
|
"--min-release-age=0",
|
||||||
|
"--omit=dev",
|
||||||
|
"--include=optional",
|
||||||
|
"--no-fund",
|
||||||
|
"--no-audit",
|
||||||
|
"--loglevel=error",
|
||||||
|
"--progress=false",
|
||||||
|
];
|
||||||
|
const code = await waitForChildProcess(spawnProcess("npm", args, { cwd: stageDir, stdio: "inherit" }));
|
||||||
|
if (code !== 0) throw new Error(`npm ${args.join(" ")} exited with code ${code ?? "unknown"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyManagedRelease(releaseDir: string, expectedVersion: string): void {
|
||||||
|
const binPath = join(
|
||||||
|
releaseDir,
|
||||||
|
"node_modules",
|
||||||
|
".bin",
|
||||||
|
process.platform === "win32" ? `${APP_NAME}.cmd` : APP_NAME,
|
||||||
|
);
|
||||||
|
const result = spawnProcessSync(binPath, ["--version"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
if (result.error || result.status !== 0) {
|
||||||
|
const reason = result.error?.message || result.stderr.trim() || `exit code ${result.status ?? "unknown"}`;
|
||||||
|
throw new Error(`Could not verify managed Pi ${expectedVersion}: ${reason}`);
|
||||||
|
}
|
||||||
|
const installedVersion = result.stdout.trim();
|
||||||
|
if (installedVersion !== expectedVersion) {
|
||||||
|
throw new Error(`Managed Pi smoke test returned version ${installedVersion}; expected ${expectedVersion}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateManagedRelease(managedRoot: string, version: string): void {
|
||||||
|
const currentPath = join(managedRoot, "current-version");
|
||||||
|
const temporaryPath = join(managedRoot, `current-version.tmp.${process.pid}-${Date.now()}`);
|
||||||
|
try {
|
||||||
|
writeFileSync(temporaryPath, `${version}\n`);
|
||||||
|
renameSync(temporaryPath, currentPath);
|
||||||
|
} finally {
|
||||||
|
rmSync(temporaryPath, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupManagedStaging(managedRoot: string): void {
|
||||||
|
const stagingRoot = join(managedRoot, "staging");
|
||||||
|
try {
|
||||||
|
for (const entry of readdirSync(stagingRoot)) {
|
||||||
|
if (entry.startsWith("update-")) {
|
||||||
|
rmSync(join(stagingRoot, entry), { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// The staging directory does not exist yet or is not writable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanupManagedInstall(): void {
|
||||||
|
let managedRoot: string | undefined;
|
||||||
|
try {
|
||||||
|
managedRoot = getActiveManagedInstallRoot();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!managedRoot) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const releaseLock = lockfile.lockSync(join(managedRoot, "update"), { realpath: false });
|
||||||
|
try {
|
||||||
|
cleanupManagedStaging(managedRoot);
|
||||||
|
} finally {
|
||||||
|
releaseLock();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// A live update owns the staging directory, or cleanup is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runManagedSelfUpdate(managedRoot: string, version: string): Promise<void> {
|
||||||
|
if (!MANAGED_RELEASE_VERSION_RE.test(version)) {
|
||||||
|
throw new Error(`Invalid managed release version: ${version}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let releaseLock: () => Promise<void>;
|
||||||
|
try {
|
||||||
|
releaseLock = await lockfile.lock(join(managedRoot, "update"), { realpath: false });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof Error && "code" in error && error.code === "ELOCKED") {
|
||||||
|
throw new Error("Another managed Pi update is already running.");
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stageDir: string | undefined;
|
||||||
|
try {
|
||||||
|
cleanupManagedStaging(managedRoot);
|
||||||
|
const installerApiBase = (process.env.PI_INSTALLER_API_BASE?.trim() || DEFAULT_INSTALLER_API_BASE).replace(
|
||||||
|
/\/+$/,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
const releaseUrl = `${installerApiBase}/${encodeURIComponent(version)}`;
|
||||||
|
const stagingRoot = join(managedRoot, "staging");
|
||||||
|
const releasesRoot = join(managedRoot, "releases");
|
||||||
|
mkdirSync(releasesRoot, { recursive: true });
|
||||||
|
const releaseDir = join(releasesRoot, version);
|
||||||
|
if (existsSync(releaseDir)) {
|
||||||
|
verifyManagedRelease(releaseDir, version);
|
||||||
|
activateManagedRelease(managedRoot, version);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(stagingRoot, { recursive: true });
|
||||||
|
stageDir = mkdtempSync(join(stagingRoot, "update-"));
|
||||||
|
const [packageJsonContent, packageLockContent] = await Promise.all([
|
||||||
|
fetchInstallerArtifact(`${releaseUrl}/package.json`, "package.json"),
|
||||||
|
fetchInstallerArtifact(`${releaseUrl}/package-lock.json`, "package-lock.json"),
|
||||||
|
]);
|
||||||
|
writeFileSync(join(stageDir, "package.json"), packageJsonContent);
|
||||||
|
writeFileSync(join(stageDir, "package-lock.json"), packageLockContent);
|
||||||
|
|
||||||
|
await runManagedNpmCi(stageDir);
|
||||||
|
verifyManagedRelease(stageDir, version);
|
||||||
|
renameSync(stageDir, releaseDir);
|
||||||
|
activateManagedRelease(managedRoot, version);
|
||||||
|
} finally {
|
||||||
|
if (stageDir) rmSync(stageDir, { force: true, recursive: true });
|
||||||
|
await releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const SELF_UPDATE_NOTE_MARKDOWN_THEME: MarkdownTheme = {
|
const SELF_UPDATE_NOTE_MARKDOWN_THEME: MarkdownTheme = {
|
||||||
heading: (text) => chalk.bold(chalk.yellow(text)),
|
heading: (text) => chalk.bold(chalk.yellow(text)),
|
||||||
link: (text) => chalk.cyan(text),
|
link: (text) => chalk.cyan(text),
|
||||||
|
|
@ -834,10 +1020,37 @@ export async function handlePackageCommand(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (updateTargetIncludesSelf(target)) {
|
if (updateTargetIncludesSelf(target)) {
|
||||||
|
const managedInstallRoot = getActiveManagedInstallRoot();
|
||||||
|
if (managedInstallRoot && options.force) {
|
||||||
|
console.error(
|
||||||
|
chalk.red(
|
||||||
|
`Managed ${APP_NAME} installations do not support --force; rerun the installer to repair this installation.`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const selfUpdatePlan = await getSelfUpdatePlan(options.force);
|
const selfUpdatePlan = await getSelfUpdatePlan(options.force);
|
||||||
if (!selfUpdatePlan.shouldRun) {
|
if (!selfUpdatePlan.shouldRun) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (managedInstallRoot) {
|
||||||
|
if (selfUpdatePlan.note) {
|
||||||
|
printSelfUpdateNote(selfUpdatePlan.note);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
console.log(chalk.dim(`Updating managed ${APP_NAME} installation...`));
|
||||||
|
await runManagedSelfUpdate(managedInstallRoot, selfUpdatePlan.version);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown managed update error";
|
||||||
|
console.error(chalk.red(`Error: ${message}`));
|
||||||
|
process.exitCode = 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
console.log(chalk.green(`Updated ${APP_NAME} from ${VERSION} to ${selfUpdatePlan.version}`));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const installMethod = detectInstallMethod();
|
const installMethod = detectInstallMethod();
|
||||||
if (process.platform === "win32" && installMethod !== "npm" && installMethod !== "pnpm") {
|
if (process.platform === "win32" && installMethod !== "npm" && installMethod !== "pnpm") {
|
||||||
console.error(
|
console.error(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
import {
|
||||||
|
chmodSync,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
readdirSync,
|
||||||
|
readFileSync,
|
||||||
|
realpathSync,
|
||||||
|
rmSync,
|
||||||
|
utimesSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { delimiter, join } from "node:path";
|
import { delimiter, join } from "node:path";
|
||||||
|
import lockfile from "proper-lockfile";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { ENV_AGENT_DIR, PACKAGE_NAME, VERSION } from "../src/config.ts";
|
import { ENV_AGENT_DIR, PACKAGE_NAME, VERSION } from "../src/config.ts";
|
||||||
import { ModelRuntime } from "../src/core/model-runtime.ts";
|
import { ModelRuntime } from "../src/core/model-runtime.ts";
|
||||||
|
|
@ -29,6 +40,77 @@ describe("package commands", () => {
|
||||||
return `${major}.${minor}.${Number.parseInt(patch, 10) + 1}`;
|
return `${major}.${minor}.${Number.parseInt(patch, 10) + 1}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function prepareManagedInstall(
|
||||||
|
targetVersion: string,
|
||||||
|
npmExitCode = 0,
|
||||||
|
): { managedRoot: string; npmRecordPath: string } {
|
||||||
|
const managedRoot = join(agentDir, "install");
|
||||||
|
const activeRelease = join(managedRoot, "releases", VERSION);
|
||||||
|
const selfPackageDir = join(activeRelease, "node_modules", ...PACKAGE_NAME.split("/"));
|
||||||
|
mkdirSync(selfPackageDir, { recursive: true });
|
||||||
|
writeFileSync(join(activeRelease, "active.txt"), "active");
|
||||||
|
writeFileSync(join(managedRoot, "current-version"), `${VERSION}\n`);
|
||||||
|
writeFileSync(
|
||||||
|
join(managedRoot, "managed-install.json"),
|
||||||
|
`${JSON.stringify({ kind: "pi-managed-install", schemaVersion: 1, layout: "releases-v1" })}\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const binDir = join(tempDir, "managed-bin");
|
||||||
|
const fakeNpmPath = join(tempDir, "managed-npm.cjs");
|
||||||
|
const npmRecordPath = join(tempDir, "managed-npm-record.json");
|
||||||
|
mkdirSync(binDir, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
fakeNpmPath,
|
||||||
|
`const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
fs.writeFileSync(${JSON.stringify(npmRecordPath)}, JSON.stringify(args));
|
||||||
|
if (${npmExitCode} !== 0) process.exit(${npmExitCode});
|
||||||
|
const binDir = path.join(process.cwd(), "node_modules", ".bin");
|
||||||
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
|
const piPath = path.join(binDir, process.platform === "win32" ? "pi.cmd" : "pi");
|
||||||
|
fs.writeFileSync(
|
||||||
|
piPath,
|
||||||
|
process.platform === "win32"
|
||||||
|
? "@echo off\\r\\necho ${targetVersion}\\r\\n"
|
||||||
|
: "#!/bin/sh\\nprintf '%s\\n' ${targetVersion}\\n",
|
||||||
|
);
|
||||||
|
if (process.platform !== "win32") fs.chmodSync(piPath, 0o755);
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
const npmPath = join(binDir, process.platform === "win32" ? "npm.cmd" : "npm");
|
||||||
|
writeFileSync(
|
||||||
|
npmPath,
|
||||||
|
process.platform === "win32"
|
||||||
|
? `@echo off\r\n"${originalExecPath}" "${fakeNpmPath}" %*\r\n`
|
||||||
|
: `#!/bin/sh\nexec "${originalExecPath}" "${fakeNpmPath}" "$@"\n`,
|
||||||
|
);
|
||||||
|
chmodSync(npmPath, 0o755);
|
||||||
|
|
||||||
|
vi.stubEnv("PI_INSTALLER_API_BASE", "https://example.test/api/installer/releases");
|
||||||
|
vi.stubEnv("PI_MANAGED_INSTALL_ROOT", managedRoot);
|
||||||
|
process.env.PI_PACKAGE_DIR = selfPackageDir;
|
||||||
|
process.env.PATH = `${binDir}${delimiter}${originalPath ?? ""}`;
|
||||||
|
return { managedRoot, npmRecordPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockManagedUpdate(targetVersion: string): void {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: string | URL | Request) => {
|
||||||
|
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
if (url === "https://pi.dev/api/latest-version") {
|
||||||
|
return Response.json({ packageName: PACKAGE_NAME, version: targetVersion });
|
||||||
|
}
|
||||||
|
const releaseUrl = `https://example.test/api/installer/releases/${targetVersion}`;
|
||||||
|
if (url === `${releaseUrl}/package.json` || url === `${releaseUrl}/package-lock.json`) {
|
||||||
|
return Response.json({});
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected fetch: ${url}`);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function runPackageCommandDirectly(args: string[]): Promise<void> {
|
async function runPackageCommandDirectly(args: string[]): Promise<void> {
|
||||||
expect(await handlePackageCommand(args)).toBe(true);
|
expect(await handlePackageCommand(args)).toBe(true);
|
||||||
}
|
}
|
||||||
|
|
@ -82,6 +164,7 @@ describe("package commands", () => {
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
process.chdir(originalCwd);
|
process.chdir(originalCwd);
|
||||||
process.exitCode = originalExitCode;
|
process.exitCode = originalExitCode;
|
||||||
|
|
@ -523,10 +606,103 @@ describe("package commands", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the update check version for forced self updates even when current", async () => {
|
it("updates installer-managed Pi through a staged immutable release", async () => {
|
||||||
|
const targetVersion = getNewerPatchVersion();
|
||||||
|
const { managedRoot, npmRecordPath } = prepareManagedInstall(targetVersion);
|
||||||
|
const abandonedStage = join(managedRoot, "staging", "update-abandoned");
|
||||||
|
mkdirSync(abandonedStage, { recursive: true });
|
||||||
|
writeFileSync(join(abandonedStage, "partial"), "partial");
|
||||||
|
const abandonedLock = join(managedRoot, "update.lock");
|
||||||
|
mkdirSync(abandonedLock);
|
||||||
|
utimesSync(abandonedLock, new Date(0), new Date(0));
|
||||||
|
mockManagedUpdate(targetVersion);
|
||||||
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(readFileSync(join(managedRoot, "current-version"), "utf8")).toBe(`${targetVersion}\n`);
|
||||||
|
expect(existsSync(join(managedRoot, "releases", targetVersion))).toBe(true);
|
||||||
|
expect(existsSync(join(managedRoot, "releases", VERSION, "active.txt"))).toBe(true);
|
||||||
|
expect(readdirSync(join(managedRoot, "staging"))).toEqual([]);
|
||||||
|
expect(JSON.parse(readFileSync(npmRecordPath, "utf8")) as string[]).toEqual(
|
||||||
|
expect.arrayContaining(["ci", "--ignore-scripts"]),
|
||||||
|
);
|
||||||
|
expect(logSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain(
|
||||||
|
`Updated pi from ${VERSION} to ${targetVersion}`,
|
||||||
|
);
|
||||||
|
expect(errorSpy).not.toHaveBeenCalled();
|
||||||
|
expect(process.exitCode).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a concurrent managed update", async () => {
|
||||||
|
const targetVersion = getNewerPatchVersion();
|
||||||
|
const { managedRoot, npmRecordPath } = prepareManagedInstall(targetVersion);
|
||||||
|
const releaseLock = await lockfile.lock(join(managedRoot, "update"), { realpath: false });
|
||||||
|
mockManagedUpdate(targetVersion);
|
||||||
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await releaseLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(readFileSync(join(managedRoot, "current-version"), "utf8")).toBe(`${VERSION}\n`);
|
||||||
|
expect(existsSync(npmRecordPath)).toBe(false);
|
||||||
|
expect(logSpy.mock.calls.map(([message]) => String(message)).join("\n")).not.toContain("Updated pi from");
|
||||||
|
expect(errorSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain(
|
||||||
|
"Another managed Pi update is already running.",
|
||||||
|
);
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects forced managed reinstalls", async () => {
|
||||||
|
const targetVersion = getNewerPatchVersion();
|
||||||
|
const { npmRecordPath } = prepareManagedInstall(targetVersion);
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await expect(runPackageCommandDirectly(["update", "--self", "--force"])).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(existsSync(npmRecordPath)).toBe(false);
|
||||||
|
expect(errorSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain(
|
||||||
|
"Managed pi installations do not support --force",
|
||||||
|
);
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the managed release active when its update fails", async () => {
|
||||||
|
const targetVersion = getNewerPatchVersion();
|
||||||
|
const { managedRoot } = prepareManagedInstall(targetVersion, 23);
|
||||||
|
mockManagedUpdate(targetVersion);
|
||||||
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(readFileSync(join(managedRoot, "current-version"), "utf8")).toBe(`${VERSION}\n`);
|
||||||
|
expect(existsSync(join(managedRoot, "releases", targetVersion))).toBe(false);
|
||||||
|
expect(readdirSync(join(managedRoot, "staging"))).toEqual([]);
|
||||||
|
expect(logSpy.mock.calls.map(([message]) => String(message)).join("\n")).not.toContain("Updated pi from");
|
||||||
|
expect(errorSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain("exited with code 23");
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps npm self-updates non-managed when the managed environment is inherited", async () => {
|
||||||
const globalPrefix = join(tempDir, "global-prefix");
|
const globalPrefix = join(tempDir, "global-prefix");
|
||||||
const projectPrefix = join(tempDir, "project-prefix");
|
const projectPrefix = join(tempDir, "project-prefix");
|
||||||
const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@earendil-works", "pi-coding-agent");
|
const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@earendil-works", "pi-coding-agent");
|
||||||
|
const inheritedManagedRoot = join(tempDir, "inherited-managed-install");
|
||||||
|
mkdirSync(join(inheritedManagedRoot, "releases"), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(inheritedManagedRoot, "managed-install.json"),
|
||||||
|
JSON.stringify({ kind: "pi-managed-install", schemaVersion: 1, layout: "releases-v1" }),
|
||||||
|
);
|
||||||
|
vi.stubEnv("PI_MANAGED_INSTALL_ROOT", inheritedManagedRoot);
|
||||||
const fakeNpmPath = join(tempDir, "fake-npm.cjs");
|
const fakeNpmPath = join(tempDir, "fake-npm.cjs");
|
||||||
const recordPath = join(tempDir, "self-update.json");
|
const recordPath = join(tempDir, "self-update.json");
|
||||||
mkdirSync(selfPackageDir, { recursive: true });
|
mkdirSync(selfPackageDir, { recursive: true });
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,8 @@ function parseArgs(args) {
|
||||||
if (!options.version || !STABLE_SEMVER_RE.test(options.version)) {
|
if (!options.version || !STABLE_SEMVER_RE.test(options.version)) {
|
||||||
throw new Error("--version must be a stable semver version");
|
throw new Error("--version must be a stable semver version");
|
||||||
}
|
}
|
||||||
if (Boolean(options.installerPackageJson) !== Boolean(options.installerPackageLock)) {
|
if (!options.installerPackageJson || !options.installerPackageLock) {
|
||||||
throw new Error("--installer-package-json and --installer-package-lock must be provided together");
|
throw new Error("--installer-package-json and --installer-package-lock are required");
|
||||||
}
|
}
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
@ -302,6 +302,57 @@ async function main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
writeFileSync(latestPath, `${JSON.stringify(release, null, "\t")}\n`);
|
writeFileSync(latestPath, `${JSON.stringify(release, null, "\t")}\n`);
|
||||||
|
validateInstallerArtifacts(options.installerPackageJson, options.installerPackageLock, options.version);
|
||||||
|
const installerReleasePrefix = `${INSTALLER_PREFIX}/releases/${options.version}`;
|
||||||
|
putObject(
|
||||||
|
options.bucket,
|
||||||
|
options.endpoint,
|
||||||
|
options.installerPackageJson,
|
||||||
|
`${installerReleasePrefix}/package.json`,
|
||||||
|
"public, max-age=31536000, immutable",
|
||||||
|
{ missing: true },
|
||||||
|
);
|
||||||
|
putObject(
|
||||||
|
options.bucket,
|
||||||
|
options.endpoint,
|
||||||
|
options.installerPackageLock,
|
||||||
|
`${installerReleasePrefix}/package-lock.json`,
|
||||||
|
"public, max-age=31536000, immutable",
|
||||||
|
{ missing: true },
|
||||||
|
);
|
||||||
|
putJson(
|
||||||
|
options.bucket,
|
||||||
|
options.endpoint,
|
||||||
|
releasePath,
|
||||||
|
`${installerReleasePrefix}/metadata.json`,
|
||||||
|
"public, max-age=31536000, immutable",
|
||||||
|
{ missing: true },
|
||||||
|
);
|
||||||
|
const installerLatest = await advanceLatestRelease(
|
||||||
|
options.version,
|
||||||
|
() =>
|
||||||
|
readLatestRelease(
|
||||||
|
options.bucket,
|
||||||
|
options.endpoint,
|
||||||
|
`${INSTALLER_PREFIX}/latest.json`,
|
||||||
|
join(temporaryDirectory, "installer-latest-current.json"),
|
||||||
|
),
|
||||||
|
(condition) =>
|
||||||
|
putJson(
|
||||||
|
options.bucket,
|
||||||
|
options.endpoint,
|
||||||
|
latestPath,
|
||||||
|
`${INSTALLER_PREFIX}/latest.json`,
|
||||||
|
"no-store",
|
||||||
|
condition,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
installerLatest.advanced
|
||||||
|
? `Published installer artifacts for Pi ${options.version} through s3://${options.bucket}/${INSTALLER_PREFIX}/latest.json`
|
||||||
|
: `Pi ${installerLatest.version} is already the latest installer release.`,
|
||||||
|
);
|
||||||
|
|
||||||
const result = await advanceLatestRelease(
|
const result = await advanceLatestRelease(
|
||||||
options.version,
|
options.version,
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -326,59 +377,6 @@ async function main() {
|
||||||
? `Announced Pi ${options.version} through s3://${options.bucket}/${RELEASES_PREFIX}/latest.json`
|
? `Announced Pi ${options.version} through s3://${options.bucket}/${RELEASES_PREFIX}/latest.json`
|
||||||
: `Pi ${result.version} is already the latest announced release.`,
|
: `Pi ${result.version} is already the latest announced release.`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (options.installerPackageJson) {
|
|
||||||
validateInstallerArtifacts(options.installerPackageJson, options.installerPackageLock, options.version);
|
|
||||||
const installerReleasePrefix = `${INSTALLER_PREFIX}/releases/${options.version}`;
|
|
||||||
putObject(
|
|
||||||
options.bucket,
|
|
||||||
options.endpoint,
|
|
||||||
options.installerPackageJson,
|
|
||||||
`${installerReleasePrefix}/package.json`,
|
|
||||||
"public, max-age=31536000, immutable",
|
|
||||||
{ missing: true },
|
|
||||||
);
|
|
||||||
putObject(
|
|
||||||
options.bucket,
|
|
||||||
options.endpoint,
|
|
||||||
options.installerPackageLock,
|
|
||||||
`${installerReleasePrefix}/package-lock.json`,
|
|
||||||
"public, max-age=31536000, immutable",
|
|
||||||
{ missing: true },
|
|
||||||
);
|
|
||||||
putJson(
|
|
||||||
options.bucket,
|
|
||||||
options.endpoint,
|
|
||||||
releasePath,
|
|
||||||
`${installerReleasePrefix}/metadata.json`,
|
|
||||||
"public, max-age=31536000, immutable",
|
|
||||||
{ missing: true },
|
|
||||||
);
|
|
||||||
const installerLatest = await advanceLatestRelease(
|
|
||||||
options.version,
|
|
||||||
() =>
|
|
||||||
readLatestRelease(
|
|
||||||
options.bucket,
|
|
||||||
options.endpoint,
|
|
||||||
`${INSTALLER_PREFIX}/latest.json`,
|
|
||||||
join(temporaryDirectory, "installer-latest-current.json"),
|
|
||||||
),
|
|
||||||
(condition) =>
|
|
||||||
putJson(
|
|
||||||
options.bucket,
|
|
||||||
options.endpoint,
|
|
||||||
latestPath,
|
|
||||||
`${INSTALLER_PREFIX}/latest.json`,
|
|
||||||
"no-store",
|
|
||||||
condition,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
installerLatest.advanced
|
|
||||||
? `Published installer artifacts for Pi ${options.version} through s3://${options.bucket}/${INSTALLER_PREFIX}/latest.json`
|
|
||||||
: `Pi ${installerLatest.version} is already the latest installer release.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(temporaryDirectory, { force: true, recursive: true });
|
rmSync(temporaryDirectory, { force: true, recursive: true });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue