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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ import { builtInExtensions } from "./extensions/index.ts";
|
|||
import { runMigrations, showDeprecationWarnings } from "./migrations.ts";
|
||||
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.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 { cleanupWindowsSelfUpdateQuarantine } from "./utils/windows-self-update.ts";
|
||||
|
||||
|
|
@ -573,6 +573,7 @@ export async function main(args: string[], options?: MainOptions) {
|
|||
if (process.platform === "win32") {
|
||||
cleanupWindowsSelfUpdateQuarantine(getPackageDir());
|
||||
}
|
||||
cleanupManagedInstall();
|
||||
|
||||
const cwd = process.cwd();
|
||||
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 chalk from "chalk";
|
||||
import lockfile from "proper-lockfile";
|
||||
import { selectConfig } from "./cli/config-selector.ts";
|
||||
import { createProjectTrustContext } from "./cli/project-trust.ts";
|
||||
import {
|
||||
|
|
@ -23,7 +34,9 @@ import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts";
|
|||
import { DefaultResourceLoader } from "./core/resource-loader.ts";
|
||||
import { SettingsManager } from "./core/settings-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 {
|
||||
cleanupWindowsSelfUpdateQuarantine,
|
||||
|
|
@ -34,6 +47,179 @@ export type PackageCommand = "install" | "remove" | "update" | "list";
|
|||
|
||||
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 = {
|
||||
heading: (text) => chalk.bold(chalk.yellow(text)),
|
||||
link: (text) => chalk.cyan(text),
|
||||
|
|
@ -834,10 +1020,37 @@ export async function handlePackageCommand(
|
|||
}
|
||||
}
|
||||
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);
|
||||
if (!selfUpdatePlan.shouldRun) {
|
||||
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();
|
||||
if (process.platform === "win32" && installMethod !== "npm" && installMethod !== "pnpm") {
|
||||
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 { delimiter, join } from "node:path";
|
||||
import lockfile from "proper-lockfile";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ENV_AGENT_DIR, PACKAGE_NAME, VERSION } from "../src/config.ts";
|
||||
import { ModelRuntime } from "../src/core/model-runtime.ts";
|
||||
|
|
@ -29,6 +40,77 @@ describe("package commands", () => {
|
|||
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> {
|
||||
expect(await handlePackageCommand(args)).toBe(true);
|
||||
}
|
||||
|
|
@ -82,6 +164,7 @@ describe("package commands", () => {
|
|||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
process.chdir(originalCwd);
|
||||
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 projectPrefix = join(tempDir, "project-prefix");
|
||||
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 recordPath = join(tempDir, "self-update.json");
|
||||
mkdirSync(selfPackageDir, { recursive: true });
|
||||
|
|
|
|||
|
|
@ -57,8 +57,8 @@ function parseArgs(args) {
|
|||
if (!options.version || !STABLE_SEMVER_RE.test(options.version)) {
|
||||
throw new Error("--version must be a stable semver version");
|
||||
}
|
||||
if (Boolean(options.installerPackageJson) !== Boolean(options.installerPackageLock)) {
|
||||
throw new Error("--installer-package-json and --installer-package-lock must be provided together");
|
||||
if (!options.installerPackageJson || !options.installerPackageLock) {
|
||||
throw new Error("--installer-package-json and --installer-package-lock are required");
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
|
@ -302,32 +302,6 @@ async function main() {
|
|||
}
|
||||
|
||||
writeFileSync(latestPath, `${JSON.stringify(release, null, "\t")}\n`);
|
||||
const result = await advanceLatestRelease(
|
||||
options.version,
|
||||
() =>
|
||||
readLatestRelease(
|
||||
options.bucket,
|
||||
options.endpoint,
|
||||
`${RELEASES_PREFIX}/latest.json`,
|
||||
join(temporaryDirectory, "latest-current.json"),
|
||||
),
|
||||
(condition) =>
|
||||
putJson(
|
||||
options.bucket,
|
||||
options.endpoint,
|
||||
latestPath,
|
||||
`${RELEASES_PREFIX}/latest.json`,
|
||||
"no-store",
|
||||
condition,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
result.advanced
|
||||
? `Announced Pi ${options.version} through s3://${options.bucket}/${RELEASES_PREFIX}/latest.json`
|
||||
: `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(
|
||||
|
|
@ -378,7 +352,31 @@ async function main() {
|
|||
? `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(
|
||||
options.version,
|
||||
() =>
|
||||
readLatestRelease(
|
||||
options.bucket,
|
||||
options.endpoint,
|
||||
`${RELEASES_PREFIX}/latest.json`,
|
||||
join(temporaryDirectory, "latest-current.json"),
|
||||
),
|
||||
(condition) =>
|
||||
putJson(
|
||||
options.bucket,
|
||||
options.endpoint,
|
||||
latestPath,
|
||||
`${RELEASES_PREFIX}/latest.json`,
|
||||
"no-store",
|
||||
condition,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
result.advanced
|
||||
? `Announced Pi ${options.version} through s3://${options.bucket}/${RELEASES_PREFIX}/latest.json`
|
||||
: `Pi ${result.version} is already the latest announced release.`,
|
||||
);
|
||||
} finally {
|
||||
rmSync(temporaryDirectory, { force: true, recursive: true });
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue