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"; import { DefaultPackageManager, type ResolvedPaths } from "../src/core/package-manager.ts"; import { InMemorySettingsStorage, SettingsManager } from "../src/core/settings-manager.ts"; import { ProjectTrustStore } from "../src/core/trust-manager.ts"; import { main } from "../src/main.ts"; import { ConfigSelectorComponent } from "../src/modes/interactive/components/config-selector.ts"; import { initTheme } from "../src/modes/interactive/theme/theme.ts"; import { handlePackageCommand } from "../src/package-manager-cli.ts"; import { allowNetwork } from "./test-network-env.ts"; describe("package commands", () => { let tempDir: string; let agentDir: string; let projectDir: string; let packageDir: string; let originalCwd: string; let originalAgentDir: string | undefined; let originalPiPackageDir: string | undefined; let originalPath: string | undefined; let originalExitCode: typeof process.exitCode; let originalExecPath: string; function getNewerPatchVersion(): string { const [major = "0", minor = "0", patch = "0"] = VERSION.split("."); 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 { expect(await handlePackageCommand(args)).toBe(true); } function extensionPaths( packageRoot: string, source: string, scope: "user" | "project", names: string[], ): ResolvedPaths { return { extensions: names.map((name) => ({ path: join(packageRoot, "extensions", name), enabled: true, metadata: { source, scope, origin: "package", baseDir: packageRoot }, })), skills: [], prompts: [], themes: [], }; } beforeEach(() => { allowNetwork(); tempDir = join(tmpdir(), `pi-package-commands-${Date.now()}-${Math.random().toString(36).slice(2)}`); agentDir = join(tempDir, "agent"); projectDir = join(tempDir, "project"); packageDir = join(tempDir, "local-package"); mkdirSync(agentDir, { recursive: true }); mkdirSync(projectDir, { recursive: true }); mkdirSync(packageDir, { recursive: true }); originalCwd = process.cwd(); originalAgentDir = process.env[ENV_AGENT_DIR]; originalPiPackageDir = process.env.PI_PACKAGE_DIR; originalPath = process.env.PATH; originalExitCode = process.exitCode; originalExecPath = process.execPath; process.exitCode = undefined; vi.spyOn(process, "exit").mockImplementation(((code?: string | number | null) => { if (code === undefined || code === null || Number(code) === 0) { process.exitCode = undefined; } else { process.exitCode = code; } return undefined as never; }) as typeof process.exit); process.env[ENV_AGENT_DIR] = agentDir; process.chdir(projectDir); }); afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); vi.restoreAllMocks(); process.chdir(originalCwd); process.exitCode = originalExitCode; if (originalAgentDir === undefined) { delete process.env[ENV_AGENT_DIR]; } else { process.env[ENV_AGENT_DIR] = originalAgentDir; } if (originalPiPackageDir === undefined) { delete process.env.PI_PACKAGE_DIR; } else { process.env.PI_PACKAGE_DIR = originalPiPackageDir; } if (originalPath === undefined) { delete process.env.PATH; } else { process.env.PATH = originalPath; } Object.defineProperty(process, "execPath", { value: originalExecPath, configurable: true }); rmSync(tempDir, { recursive: true, force: true }); }); it("should persist global relative local package paths relative to settings.json", async () => { const relativePkgDir = join(projectDir, "packages", "local-package"); mkdirSync(relativePkgDir, { recursive: true }); await main(["install", "./packages/local-package"]); const settingsPath = join(agentDir, "settings.json"); const settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as { packages?: string[] }; expect(settings.packages?.length).toBe(1); const stored = settings.packages?.[0] ?? ""; const resolvedFromSettings = realpathSync(join(agentDir, stored)); expect(resolvedFromSettings).toBe(realpathSync(relativePkgDir)); }); it("should remove local packages using a path with a trailing slash", async () => { await main(["install", `${packageDir}/`]); const settingsPath = join(agentDir, "settings.json"); const installedSettings = JSON.parse(readFileSync(settingsPath, "utf-8")) as { packages?: string[] }; expect(installedSettings.packages?.length).toBe(1); await main(["remove", `${packageDir}/`]); const removedSettings = JSON.parse(readFileSync(settingsPath, "utf-8")) as { packages?: string[] }; expect(removedSettings.packages ?? []).toHaveLength(0); }); it("skips untrusted project package settings", async () => { mkdirSync(join(projectDir, ".pi"), { recursive: true }); writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { await expect(main(["list"])).resolves.toBeUndefined(); const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); expect(stdout).toContain("No packages installed."); expect(stdout).not.toContain("Project packages:"); } finally { logSpy.mockRestore(); } }); it("uses remembered project trust for list", async () => { mkdirSync(join(projectDir, ".pi"), { recursive: true }); writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); new ProjectTrustStore(agentDir).set(projectDir, true); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { await expect(main(["list"])).resolves.toBeUndefined(); const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); expect(stdout).toContain("Project packages:"); expect(stdout).toContain("npm:@project/pkg"); expect(stdout).not.toContain("No packages installed."); expect(process.exitCode).toBeUndefined(); } finally { logSpy.mockRestore(); } }); it("overrides remembered trust for list with --no-approve", async () => { mkdirSync(join(projectDir, ".pi"), { recursive: true }); writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); new ProjectTrustStore(agentDir).set(projectDir, true); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { await expect(main(["list", "--no-approve"])).resolves.toBeUndefined(); const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); expect(stdout).toContain("No packages installed."); expect(stdout).not.toContain("Project packages:"); expect(process.exitCode).toBeUndefined(); } finally { logSpy.mockRestore(); } }); it("approves project trust for list with --approve", async () => { mkdirSync(join(projectDir, ".pi"), { recursive: true }); writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { await expect(main(["list", "--approve"])).resolves.toBeUndefined(); const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); expect(stdout).toContain("Project packages:"); expect(stdout).toContain("npm:@project/pkg"); expect(stdout).not.toContain("No packages installed."); expect(process.exitCode).toBeUndefined(); } finally { logSpy.mockRestore(); } }); it("uses default project trust for list", async () => { mkdirSync(join(projectDir, ".pi"), { recursive: true }); writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" })); writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { await expect(main(["list"])).resolves.toBeUndefined(); const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); expect(stdout).toContain("Project packages:"); expect(stdout).toContain("npm:@project/pkg"); expect(stdout).not.toContain("No packages installed."); expect(process.exitCode).toBeUndefined(); } finally { logSpy.mockRestore(); } }); it("uses project_trust extensions for package commands", async () => { mkdirSync(join(projectDir, ".pi"), { recursive: true }); writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { await expect( main(["list"], { extensionFactories: [ (pi) => { pi.on("project_trust", () => ({ trusted: "yes" })); }, ], }), ).resolves.toBeUndefined(); const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); expect(stdout).toContain("Project packages:"); expect(stdout).toContain("npm:@project/pkg"); expect(stdout).not.toContain("No packages installed."); expect(process.exitCode).toBeUndefined(); } finally { logSpy.mockRestore(); } }); it("does not prompt or ask extensions for project trust during update", async () => { mkdirSync(join(projectDir, ".pi"), { recursive: true }); writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" })); const fakeNpmPath = join(tempDir, "fake-project-npm.cjs"); const recordPath = join(tempDir, "project-update.json"); writeFileSync( fakeNpmPath, `const fs=require("node:fs");fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(process.argv.slice(2)));`, ); writeFileSync( join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:fake-package"], npmCommand: [originalExecPath, fakeNpmPath] }), ); let projectTrustCalled = false; const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { await expect( main(["update", "--extensions"], { extensionFactories: [ (pi) => { pi.on("project_trust", () => { projectTrustCalled = true; return { trusted: "yes" }; }); }, ], }), ).resolves.toBeUndefined(); expect(projectTrustCalled).toBe(false); expect(existsSync(recordPath)).toBe(false); expect(process.exitCode).toBeUndefined(); } finally { logSpy.mockRestore(); } }); it("uses saved project trust during update", async () => { mkdirSync(join(projectDir, ".pi"), { recursive: true }); const fakeNpmPath = join(tempDir, "fake-trusted-project-npm.cjs"); const recordPath = join(tempDir, "trusted-project-update.json"); writeFileSync( fakeNpmPath, `const fs=require("node:fs");fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(process.argv.slice(2)));`, ); writeFileSync( join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:fake-package"], npmCommand: [originalExecPath, fakeNpmPath] }), ); new ProjectTrustStore(agentDir).set(projectDir, true); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { await expect(main(["update", "--extensions"])).resolves.toBeUndefined(); expect(existsSync(recordPath)).toBe(true); expect(process.exitCode).toBeUndefined(); } finally { logSpy.mockRestore(); } }); it("lets trust.json override default project trust", async () => { mkdirSync(join(projectDir, ".pi"), { recursive: true }); writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" })); writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); new ProjectTrustStore(agentDir).set(projectDir, false); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { await expect(main(["list"])).resolves.toBeUndefined(); const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); expect(stdout).toContain("No packages installed."); expect(stdout).not.toContain("Project packages:"); expect(process.exitCode).toBeUndefined(); } finally { logSpy.mockRestore(); } }); it("blocks local package changes when project is untrusted", async () => { mkdirSync(join(projectDir, ".pi"), { recursive: true }); writeFileSync(join(projectDir, ".pi", "settings.json"), "{}"); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { await expect(main(["install", "-l", "./local-package"])).resolves.toBeUndefined(); const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); expect(stderr).toContain("Project is not trusted. Use --approve to modify local package config."); expect(process.exitCode).toBe(1); } finally { errorSpy.mockRestore(); } }); it("allows local package install to initialize fresh project settings", async () => { await main(["install", "-l", packageDir]); const settingsPath = join(projectDir, ".pi", "settings.json"); const settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as { packages?: string[] }; expect(settings.packages?.length).toBe(1); const stored = settings.packages?.[0] ?? ""; expect(realpathSync(join(projectDir, ".pi", stored))).toBe(realpathSync(packageDir)); expect(process.exitCode).toBeUndefined(); }); it("shows install subcommand help", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { await expect(main(["install", "--help"])).resolves.toBeUndefined(); const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); expect(stdout).toContain("Usage:"); expect(stdout).toContain("pi install [-l]"); expect(errorSpy).not.toHaveBeenCalled(); expect(process.exitCode).toBeUndefined(); } finally { logSpy.mockRestore(); errorSpy.mockRestore(); } }); it("refreshes only model catalogs with update --models", async () => { const refresh = vi.fn(async () => ({ aborted: false, errors: new Map() })); const create = vi.spyOn(ModelRuntime, "create").mockResolvedValue({ refresh } as unknown as ModelRuntime); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); await expect(runPackageCommandDirectly(["update", "--models"])).resolves.toBeUndefined(); expect(create).toHaveBeenCalledWith({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json"), allowModelNetwork: false, signal: expect.any(AbortSignal), }); expect(refresh).toHaveBeenCalledWith({ allowNetwork: true, force: true, signal: expect.any(AbortSignal), }); expect(logSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain("Model catalogs refreshed"); expect(errorSpy).not.toHaveBeenCalled(); expect(process.exitCode).toBeUndefined(); }); it("rejects update --models combined with another update target", async () => { const create = vi.spyOn(ModelRuntime, "create"); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); await expect(runPackageCommandDirectly(["update", "--models", "--self"])).resolves.toBeUndefined(); expect(create).not.toHaveBeenCalled(); expect(errorSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain( "--models cannot be combined with --self", ); expect(process.exitCode).toBe(1); }); it("cycles project package overrides in config local mode", async () => { const storage = new InMemorySettingsStorage(); storage.withLock("global", () => JSON.stringify({ packages: ["npm:pi-tools"] })); const settingsManager = SettingsManager.fromStorage(storage, { projectTrusted: true }); const resolvedPaths = extensionPaths(join(tempDir, "pkg"), "npm:pi-tools", "user", ["bar.ts"]); const selector = new ConfigSelectorComponent( { global: resolvedPaths, project: resolvedPaths }, settingsManager, projectDir, agentDir, () => {}, () => {}, () => {}, 24, "project", ); selector.getResourceList().handleInput(" "); expect(settingsManager.getProjectSettings().packages).toEqual([ { source: "npm:pi-tools", autoload: false, extensions: ["-extensions/bar.ts"] }, ]); selector.getResourceList().handleInput(" "); expect(settingsManager.getProjectSettings().packages).toEqual([ { source: "npm:pi-tools", autoload: false, extensions: ["+extensions/bar.ts"] }, ]); selector.getResourceList().handleInput(" "); expect(settingsManager.getProjectSettings().packages).toEqual([]); }); it("toggles built-in extensions in config global mode", async () => { const settingsManager = SettingsManager.fromStorage(new InMemorySettingsStorage(), { projectTrusted: true }); const resolvedPaths = await new DefaultPackageManager({ cwd: projectDir, agentDir, settingsManager, builtinExtensions: ["llama.cpp", "mcp"], }).resolve(); const selector = new ConfigSelectorComponent( { global: resolvedPaths, project: resolvedPaths }, settingsManager, projectDir, agentDir, () => {}, () => {}, () => {}, 24, "global", ); const list = selector.getResourceList(); initTheme("dark"); expect(list.render(80).join("\n")).toContain("Built-in"); expect(list.render(80).join("\n")).toContain("llama.cpp"); list.handleInput(" "); expect(settingsManager.getGlobalSettings().extensions).toEqual(["-builtin:llama.cpp"]); list.handleInput(" "); expect(settingsManager.getGlobalSettings().extensions).toEqual(["+builtin:llama.cpp"]); }); it("cycles project built-in extension overrides in config local mode", async () => { const storage = new InMemorySettingsStorage(); storage.withLock("global", () => JSON.stringify({ extensions: ["-builtin:mcp"] })); const settingsManager = SettingsManager.fromStorage(storage, { projectTrusted: true }); const resolvedPaths = await new DefaultPackageManager({ cwd: projectDir, agentDir, settingsManager, builtinExtensions: ["mcp"], }).resolve(); const selector = new ConfigSelectorComponent( { global: resolvedPaths, project: resolvedPaths }, settingsManager, projectDir, agentDir, () => {}, () => {}, () => {}, 24, "project", ); const list = selector.getResourceList(); list.handleInput(" "); expect(settingsManager.getProjectSettings().extensions).toEqual(["+builtin:mcp"]); list.handleInput(" "); expect(settingsManager.getProjectSettings().extensions).toEqual(["-builtin:mcp"]); list.handleInput(" "); expect(settingsManager.getProjectSettings().extensions).toEqual([]); }); it("shows a friendly error for unknown install options", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { await expect(main(["install", "--unknown"])).resolves.toBeUndefined(); const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); expect(stderr).toContain('Unknown option --unknown for "install".'); expect(stderr).toContain('Use "pi --help" or "pi install [-l] [--approve|--no-approve]".'); expect(process.exitCode).toBe(1); } finally { errorSpy.mockRestore(); } }); it("shows a friendly error for missing install source", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { await expect(main(["install"])).resolves.toBeUndefined(); const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); expect(stderr).toContain("Missing install source."); expect(stderr).toContain("Usage: pi install [-l]"); expect(stderr).not.toContain("at "); expect(process.exitCode).toBe(1); } finally { errorSpy.mockRestore(); } }); it("allows explicit self-update checks when automatic version checks are disabled", async () => { const previousSkipVersionCheck = process.env.PI_SKIP_VERSION_CHECK; process.env.PI_SKIP_VERSION_CHECK = "1"; const fetchMock = vi.fn(async () => Response.json({ version: VERSION })); vi.stubGlobal("fetch", fetchMock); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); expect(fetchMock).toHaveBeenCalledOnce(); expect(logSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain( `pi is already up to date (v${VERSION})`, ); expect(errorSpy).not.toHaveBeenCalled(); expect(process.exitCode).toBeUndefined(); } finally { if (previousSkipVersionCheck === undefined) { delete process.env.PI_SKIP_VERSION_CHECK; } else { process.env.PI_SKIP_VERSION_CHECK = previousSkipVersionCheck; } } }); it("retries a transient self-update version check", async () => { const previousSkipVersionCheck = process.env.PI_SKIP_VERSION_CHECK; delete process.env.PI_SKIP_VERSION_CHECK; const fetchMock = vi .fn() .mockRejectedValueOnce(new Error("fetch failed")) .mockRejectedValueOnce(new Error("fetch failed")) .mockResolvedValueOnce(Response.json({ version: VERSION })); vi.stubGlobal("fetch", fetchMock); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); expect(fetchMock).toHaveBeenCalledTimes(3); expect(errorSpy).not.toHaveBeenCalled(); } finally { if (previousSkipVersionCheck === undefined) delete process.env.PI_SKIP_VERSION_CHECK; else process.env.PI_SKIP_VERSION_CHECK = previousSkipVersionCheck; logSpy.mockRestore(); errorSpy.mockRestore(); } }); 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("removes older managed releases after an update", async () => { // https://github.com/earendil-works/pi/issues/10392 const targetVersion = getNewerPatchVersion(); const { managedRoot } = prepareManagedInstall(targetVersion); const releasesRoot = join(managedRoot, "releases"); mkdirSync(join(releasesRoot, "0.0.1", "node_modules"), { recursive: true }); mkdirSync(join(releasesRoot, "0.0.2-beta.1"), { recursive: true }); mkdirSync(join(releasesRoot, "not-a-release"), { recursive: true }); mockManagedUpdate(targetVersion); vi.spyOn(console, "log").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); expect(readdirSync(releasesRoot).sort()).toEqual([VERSION, "not-a-release", targetVersion].sort()); 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); mkdirSync(join(managedRoot, "releases", "0.0.1"), { recursive: true }); 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(existsSync(join(managedRoot, "releases", "0.0.1"))).toBe(true); 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 }); mkdirSync(join(projectDir, ".pi"), { recursive: true }); writeFileSync( fakeNpmPath, `const fs=require("node:fs"),path=require("node:path"),args=process.argv.slice(2),prefix=args[args.indexOf("--prefix")+1]; if(args.includes("root")) console.log(path.join(prefix,"lib","node_modules")); else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args)); `, ); writeFileSync( join(agentDir, "settings.json"), JSON.stringify({ npmCommand: [originalExecPath, fakeNpmPath, "--prefix", globalPrefix] }, null, 2), ); writeFileSync( join(projectDir, ".pi", "settings.json"), JSON.stringify({ npmCommand: [originalExecPath, fakeNpmPath, "--prefix", projectPrefix] }, null, 2), ); process.env.PI_PACKAGE_DIR = selfPackageDir; Object.defineProperty(process, "execPath", { value: join(selfPackageDir, "dist", "cli.js"), configurable: true, }); const fetchMock = vi.fn(async () => Response.json({ version: VERSION })); vi.stubGlobal("fetch", fetchMock); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { await expect(runPackageCommandDirectly(["update", "--self", "--force"])).resolves.toBeUndefined(); expect(process.exitCode).toBeUndefined(); expect(errorSpy).not.toHaveBeenCalled(); expect(fetchMock).toHaveBeenCalledOnce(); const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); const recordedArgs = JSON.parse(readFileSync(recordPath, "utf-8")) as string[]; expect(recordedArgs).toContain(globalPrefix); expect(recordedArgs).toContain(`${PACKAGE_NAME}@${VERSION}`); expect(recordedArgs).not.toContain(PACKAGE_NAME); expect(recordedArgs).not.toContain(projectPrefix); expect(stdout).toContain(`Updated pi from ${VERSION} to ${VERSION}`); } finally { logSpy.mockRestore(); errorSpy.mockRestore(); } }); it("uses the current package name when the update check omits packageName", async () => { const globalPrefix = join(tempDir, "global-prefix"); const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@mariozechner", "pi-coding-agent"); const fakeNpmPath = join(tempDir, "fake-npm.cjs"); const recordPath = join(tempDir, "self-update.json"); mkdirSync(selfPackageDir, { recursive: true }); writeFileSync( fakeNpmPath, `const fs=require("node:fs"),path=require("node:path"),args=process.argv.slice(2),prefix=args[args.indexOf("--prefix")+1]; if(args.includes("root")) console.log(path.join(prefix,"lib","node_modules")); else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args)); `, ); writeFileSync( join(agentDir, "settings.json"), JSON.stringify({ npmCommand: [originalExecPath, fakeNpmPath, "--prefix", globalPrefix] }, null, 2), ); process.env.PI_PACKAGE_DIR = selfPackageDir; Object.defineProperty(process, "execPath", { value: join(selfPackageDir, "dist", "cli.js"), configurable: true, }); const targetVersion = getNewerPatchVersion(); const fetchMock = vi.fn(async () => Response.json({ version: targetVersion })); vi.stubGlobal("fetch", fetchMock); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); expect(process.exitCode).toBeUndefined(); expect(errorSpy).not.toHaveBeenCalled(); expect(fetchMock).toHaveBeenCalledOnce(); const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); const recordedArgs = JSON.parse(readFileSync(recordPath, "utf-8")) as string[]; expect(recordedArgs).toContain(`${PACKAGE_NAME}@${targetVersion}`); expect(recordedArgs).not.toContain(PACKAGE_NAME); expect(stdout).toContain(`Updated pi from ${VERSION} to ${targetVersion}`); } finally { logSpy.mockRestore(); errorSpy.mockRestore(); } }); it("installs the active package name from the update check during self-update", async () => { const globalPrefix = join(tempDir, "global-prefix"); const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@mariozechner", "pi-coding-agent"); const fakeNpmPath = join(tempDir, "fake-npm.cjs"); const recordPath = join(tempDir, "self-update.json"); mkdirSync(selfPackageDir, { recursive: true }); writeFileSync( fakeNpmPath, `const fs=require("node:fs"),path=require("node:path"),args=process.argv.slice(2),prefix=args[args.indexOf("--prefix")+1]; if(args.includes("root")) console.log(path.join(prefix,"lib","node_modules")); else { const records=fs.existsSync(${JSON.stringify(recordPath)})?JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)},"utf-8")):[]; records.push(args); fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(records)); } `, ); writeFileSync( join(agentDir, "settings.json"), JSON.stringify({ npmCommand: [originalExecPath, fakeNpmPath, "--prefix", globalPrefix] }, null, 2), ); process.env.PI_PACKAGE_DIR = selfPackageDir; Object.defineProperty(process, "execPath", { value: join(selfPackageDir, "dist", "cli.js"), configurable: true, }); const activePackageName = PACKAGE_NAME === "@new-scope/pi" ? "@newer-scope/pi" : "@new-scope/pi"; vi.stubGlobal( "fetch", vi.fn(async () => Response.json({ packageName: activePackageName, version: "0.73.0" })), ); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); expect(process.exitCode).toBeUndefined(); expect(errorSpy).not.toHaveBeenCalled(); const recordedCalls = JSON.parse(readFileSync(recordPath, "utf-8")) as string[][]; expect(recordedCalls).toEqual([ expect.arrayContaining(["uninstall", "-g", PACKAGE_NAME]), expect.arrayContaining(["install", "-g", `${activePackageName}@0.73.0`]), ]); } finally { logSpy.mockRestore(); errorSpy.mockRestore(); } }); it("prints a pnpm metadata hint when self-update fails", async () => { const globalRoot = join(tempDir, "pnpm", "global", "v11"); const selfPackageDir = join(globalRoot, "node_modules", "@earendil-works", "pi-coding-agent"); const fakeBinDir = join(tempDir, "bin"); const fakePnpmPath = join(fakeBinDir, process.platform === "win32" ? "pnpm.cmd" : "pnpm"); mkdirSync(selfPackageDir, { recursive: true }); mkdirSync(fakeBinDir, { recursive: true }); writeFileSync(join(selfPackageDir, "package.json"), JSON.stringify({ name: PACKAGE_NAME, version: VERSION })); const fakePnpmScript = process.platform === "win32" ? `@echo off\r\nif "%1"=="root" if "%2"=="-g" (echo ${globalRoot} & exit /b 0)\r\nexit /b 23\r\n` : `#!/bin/sh\nif [ "$1" = "root" ] && [ "$2" = "-g" ]; then\n\tprintf '%s\\n' '${globalRoot.replaceAll("'", "'\\''")}'\n\texit 0\nfi\nexit 23\n`; writeFileSync(fakePnpmPath, fakePnpmScript); chmodSync(fakePnpmPath, 0o755); process.env.PATH = `${fakeBinDir}${process.env.PATH ? `${delimiter}${process.env.PATH}` : ""}`; process.env.PI_PACKAGE_DIR = selfPackageDir; Object.defineProperty(process, "execPath", { value: join(tempDir, "pnpm", "bin", "node"), configurable: true, }); vi.stubGlobal( "fetch", vi.fn(async () => Response.json({ version: getNewerPatchVersion() })), ); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); expect(process.exitCode).toBe(1); const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); expect(stdout).not.toContain("Updated pi"); expect(stderr).toContain("exited with code 23"); expect(stderr).toContain("If pnpm reports missing package versions"); expect(stderr).toContain("Run `pnpm store prune` and retry `pi update --self`."); } finally { logSpy.mockRestore(); errorSpy.mockRestore(); } }); it("fails self-update when renamed npm package installation fails", async () => { const globalPrefix = join(tempDir, "global-prefix"); const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@mariozechner", "pi-coding-agent"); const fakeNpmPath = join(tempDir, "fake-npm-fail.cjs"); const recordPath = join(tempDir, "self-update-fail.json"); mkdirSync(selfPackageDir, { recursive: true }); writeFileSync( fakeNpmPath, `const fs=require("node:fs"),path=require("node:path"),args=process.argv.slice(2),prefix=args[args.indexOf("--prefix")+1]; if(args.includes("root")) { console.log(path.join(prefix,"lib","node_modules")); process.exit(0); } const records=fs.existsSync(${JSON.stringify(recordPath)})?JSON.parse(fs.readFileSync(${JSON.stringify(recordPath)},"utf-8")):[]; records.push(args); fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(records)); if(args.includes("install")) process.exit(23); `, ); writeFileSync( join(agentDir, "settings.json"), JSON.stringify({ npmCommand: [originalExecPath, fakeNpmPath, "--prefix", globalPrefix] }, null, 2), ); process.env.PI_PACKAGE_DIR = selfPackageDir; Object.defineProperty(process, "execPath", { value: join(selfPackageDir, "dist", "cli.js"), configurable: true, }); const activePackageName = PACKAGE_NAME === "@new-scope/pi" ? "@newer-scope/pi" : "@new-scope/pi"; vi.stubGlobal( "fetch", vi.fn(async () => Response.json({ packageName: activePackageName, version: "0.73.0" })), ); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); expect(process.exitCode).toBe(1); const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); expect(stdout).not.toContain(`Updated pi`); expect(stderr).toContain("exited with code 23"); const recordedCalls = JSON.parse(readFileSync(recordPath, "utf-8")) as string[][]; expect(recordedCalls).toEqual([ expect.arrayContaining(["uninstall", "-g", PACKAGE_NAME]), expect.arrayContaining(["install", "-g", `${activePackageName}@0.73.0`]), ]); } finally { logSpy.mockRestore(); errorSpy.mockRestore(); } }); it("suggests the configured source when update input omits the npm prefix", async () => { const settingsPath = join(agentDir, "settings.json"); writeFileSync(settingsPath, JSON.stringify({ packages: ["npm:pi-formatter"] }, null, 2)); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { await expect(main(["update", "pi-formatter"])).resolves.toBeUndefined(); const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); expect(stderr).toContain("Did you mean npm:pi-formatter?"); expect(stdout).not.toContain("Updated pi-formatter"); expect(process.exitCode).toBe(1); const settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as { packages?: string[] }; expect(settings.packages).toContain("npm:pi-formatter"); } finally { errorSpy.mockRestore(); logSpy.mockRestore(); } }); });