diff --git a/cli/src/__tests__/install-command.test.ts b/cli/src/__tests__/install-command.test.ts index c8292de7d6d..46bc6d73a4f 100644 --- a/cli/src/__tests__/install-command.test.ts +++ b/cli/src/__tests__/install-command.test.ts @@ -4,7 +4,16 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { installCommand, resolveNpmInstallRequest } from "../commands/install.js"; import { uninstallCommand } from "../commands/uninstall.js"; -import { readInstallManifest, resolveInstallStorePaths } from "../install-store.js"; +import { + INSTALL_MANIFEST_VERSION, + flipCurrentAtomic, + initializeInstallStore, + payloadPathFor, + readInstallManifest, + resolveInstallStorePaths, + withInstallStoreLock, + writeInstallManifestAtomic, +} from "../install-store.js"; import { resolveCliVersion } from "../version.js"; const ORIGINAL_ENV = { ...process.env }; @@ -108,4 +117,29 @@ describe("managed install commands", () => { await expect(uninstallCommand()).rejects.toThrow("unverified install store"); expect(fs.readFileSync(unrelatedFile, "utf8")).toBe("keep"); }); + + it("refuses to uninstall while another store mutation holds the lock", async () => { + const paths = resolveInstallStorePaths(); + const payloadPath = payloadPathFor(paths, "npm", "2026.720.0"); + initializeInstallStore(paths); + fs.mkdirSync(payloadPath, { recursive: true }); + flipCurrentAtomic(payloadPath, paths); + writeInstallManifestAtomic({ + schemaVersion: INSTALL_MANIFEST_VERSION, + source: "npm", + version: "2026.720.0", + channel: "latest", + payloadPath, + installedAt: "2026-07-22T18:00:00.000Z", + previous: [], + }, paths); + + await withInstallStoreLock( + async () => { + await expect(uninstallCommand()).rejects.toThrow("already running"); + }, + paths, + ); + expect(fs.existsSync(paths.lockPath)).toBe(false); + }); }); diff --git a/cli/src/commands/uninstall.ts b/cli/src/commands/uninstall.ts index c1dc4eba027..ab8f9c76b06 100644 --- a/cli/src/commands/uninstall.ts +++ b/cli/src/commands/uninstall.ts @@ -6,18 +6,24 @@ import { removeManagedPathBlock, removeManagedShim, resolveInstallStorePaths, + withInstallStoreLock, } from "../install-store.js"; export async function uninstallCommand(): Promise { const paths = resolveInstallStorePaths(); - if (fs.existsSync(paths.cliRoot)) assertManagedInstallStore(paths); - const shimRemoved = removeManagedShim(paths); - fs.rmSync(paths.cliRoot, { recursive: true, force: true }); + const hadStore = fs.existsSync(paths.cliRoot); + if (hadStore) assertManagedInstallStore(paths); + const shimRemoved = await withInstallStoreLock(async () => { + if (hadStore) assertManagedInstallStore(paths); + const removed = removeManagedShim(paths); - const home = process.env.HOME; - for (const rcFile of home ? [path.join(home, ".bashrc"), path.join(home, ".zshrc")] : []) { - removeManagedPathBlock(rcFile); - } + const home = process.env.HOME; + for (const rcFile of home ? [path.join(home, ".bashrc"), path.join(home, ".zshrc")] : []) { + removeManagedPathBlock(rcFile); + } + fs.rmSync(paths.cliRoot, { recursive: true, force: true }); + return removed; + }, paths, { initialize: !hadStore }); if (!shimRemoved) { console.log(pc.yellow(`Left ${paths.shimPath} unchanged because it is not a Paperclip-managed shim.`)); diff --git a/cli/src/install-store.ts b/cli/src/install-store.ts index 164376b5341..390a414dbc4 100644 --- a/cli/src/install-store.ts +++ b/cli/src/install-store.ts @@ -156,8 +156,9 @@ function processIsAlive(pid: number): boolean { export async function withInstallStoreLock( callback: () => Promise, paths = resolveInstallStorePaths(), + options: { initialize?: boolean } = {}, ): Promise { - initializeInstallStore(paths); + if (options.initialize !== false) initializeInstallStore(paths); const token = `${process.pid}:${Date.now()}:${Math.random().toString(16).slice(2)}`; const acquire = (): void => { const temporaryPath = `${paths.lockPath}.${token}.tmp`;