fix(cli): serialize managed uninstall

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-22 18:58:27 +00:00
1 parent 9d758ad730
commit 71c6d2eed2
3 files changed
+50 -9

No files matched your search

+35 -1
View File
@@ -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);
});
});
+13 -7
View File
@@ -6,18 +6,24 @@ import {
removeManagedPathBlock,
removeManagedShim,
resolveInstallStorePaths,
withInstallStoreLock,
} from "../install-store.js";
export async function uninstallCommand(): Promise<void> {
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.`));
+2 -1
View File
@@ -156,8 +156,9 @@ function processIsAlive(pid: number): boolean {
export async function withInstallStoreLock<T>(
callback: () => Promise<T>,
paths = resolveInstallStorePaths(),
options: { initialize?: boolean } = {},
): Promise<T> {
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`;