import { mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ENV_AGENT_DIR, PACKAGE_NAME, VERSION } from "../src/config.js"; import { main } from "../src/main.js"; 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 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}`; } beforeEach(() => { 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; originalExitCode = process.exitCode; originalExecPath = process.execPath; process.exitCode = undefined; process.env[ENV_AGENT_DIR] = agentDir; process.chdir(projectDir); }); afterEach(() => { vi.unstubAllGlobals(); 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; } 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("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("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]".'); 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("uses global npmCommand and current package name for forced self updates without checking the api", 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 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(); vi.stubGlobal("fetch", fetchMock); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { await expect(main(["update", "--self", "--force"])).resolves.toBeUndefined(); expect(process.exitCode).toBeUndefined(); expect(errorSpy).not.toHaveBeenCalled(); expect(fetchMock).not.toHaveBeenCalled(); const recordedArgs = JSON.parse(readFileSync(recordPath, "utf-8")) as string[]; expect(recordedArgs).toContain(globalPrefix); expect(recordedArgs).toContain(PACKAGE_NAME); expect(recordedArgs).not.toContain(projectPrefix); } 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 fetchMock = vi.fn(async () => Response.json({ version: getNewerPatchVersion() })); vi.stubGlobal("fetch", fetchMock); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { await expect(main(["update", "--self"])).resolves.toBeUndefined(); expect(process.exitCode).toBeUndefined(); expect(errorSpy).not.toHaveBeenCalled(); expect(fetchMock).toHaveBeenCalledOnce(); const recordedArgs = JSON.parse(readFileSync(recordPath, "utf-8")) as string[]; expect(recordedArgs).toContain(PACKAGE_NAME); } 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(main(["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]), ]); } 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(main(["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]), ]); } 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(); } }); });