Skip to content

Commit 336303e

Browse files
committed
fix(plugins): make uninstall teardown idempotent
1 parent edfef73 commit 336303e

4 files changed

Lines changed: 121 additions & 23 deletions

File tree

src/cli/plugins-cli.uninstall.test.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,53 @@ describe("plugins cli uninstall", () => {
271271
});
272272
});
273273

274+
it("cleans stale policy refs even when plugin is absent from the current registry", async () => {
275+
const baseConfig = {
276+
plugins: {
277+
allow: ["alpha", "beta"],
278+
deny: ["alpha"],
279+
},
280+
} as OpenClawConfig;
281+
const nextConfig = {
282+
plugins: {
283+
allow: ["beta"],
284+
},
285+
} as OpenClawConfig;
286+
287+
loadConfig.mockReturnValue(baseConfig);
288+
buildPluginSnapshotReport.mockReturnValue({
289+
plugins: [],
290+
diagnostics: [],
291+
});
292+
planPluginUninstall.mockReturnValue({
293+
ok: true,
294+
config: nextConfig,
295+
actions: {
296+
entry: false,
297+
install: false,
298+
allowlist: true,
299+
denylist: true,
300+
loadPath: false,
301+
memorySlot: false,
302+
contextEngineSlot: false,
303+
channelConfig: false,
304+
directory: false,
305+
},
306+
directoryRemoval: null,
307+
});
308+
309+
await runPluginsCommand(["plugins", "uninstall", "alpha", "--force"]);
310+
311+
expect(planPluginUninstall).toHaveBeenCalledWith(
312+
expect.objectContaining({
313+
pluginId: "alpha",
314+
deleteFiles: true,
315+
}),
316+
);
317+
expect(writeConfigFile).toHaveBeenCalledWith(nextConfig);
318+
expect(runtimeLogs.at(-2)).toContain('Uninstalled plugin "alpha"');
319+
});
320+
274321
it("exits when uninstall target is not managed by plugin install records", async () => {
275322
loadConfig.mockReturnValue({
276323
plugins: {
@@ -282,12 +329,16 @@ describe("plugins cli uninstall", () => {
282329
plugins: [{ id: "alpha", name: "alpha" }],
283330
diagnostics: [],
284331
});
332+
planPluginUninstall.mockReturnValue({
333+
ok: false,
334+
error: "Plugin not found: alpha",
335+
});
285336

286337
await expect(runPluginsCommand(["plugins", "uninstall", "alpha", "--force"])).rejects.toThrow(
287338
"__exit__:1",
288339
);
289340

290341
expect(runtimeErrors.at(-1)).toContain("is not managed by plugins config/install records");
291-
expect(planPluginUninstall).not.toHaveBeenCalled();
342+
expect(planPluginUninstall).toHaveBeenCalled();
292343
});
293344
});

src/cli/plugins-uninstall-command.ts

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -74,21 +74,6 @@ export async function runPluginUninstallCommand(
7474
config: cfg,
7575
plugins: report.plugins,
7676
});
77-
const hasEntry = pluginId in (cfg.plugins?.entries ?? {});
78-
const hasInstall = pluginId in (cfg.plugins?.installs ?? {});
79-
80-
if (!hasEntry && !hasInstall) {
81-
if (plugin) {
82-
runtime.error(
83-
`Plugin "${pluginId}" is not managed by plugins config/install records and cannot be uninstalled.`,
84-
);
85-
} else {
86-
runtime.error(`Plugin not found: ${id}`);
87-
}
88-
runtime.exit(1);
89-
return;
90-
}
91-
9277
const channelIds = plugin?.status === "loaded" ? plugin.channelIds : undefined;
9378
const plan = planPluginUninstall({
9479
config: cfg,
@@ -98,10 +83,17 @@ export async function runPluginUninstallCommand(
9883
extensionsDir,
9984
});
10085
if (!plan.ok) {
101-
runtime.error(plan.error);
86+
if (plugin) {
87+
runtime.error(
88+
`Plugin "${pluginId}" is not managed by plugins config/install records and cannot be uninstalled.`,
89+
);
90+
} else {
91+
runtime.error(plan.error);
92+
}
10293
runtime.exit(1);
10394
return;
10495
}
96+
const hasInstall = pluginId in (cfg.plugins?.installs ?? {});
10597

10698
const preview: string[] = [];
10799
if (plan.actions.entry) {

src/plugins/uninstall.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,37 @@ describe("uninstallPlugin", () => {
704704
}
705705
});
706706

707+
it("cleans stale policy references even when plugin code and install records are gone", async () => {
708+
const result = await uninstallPlugin({
709+
config: createPluginConfig({
710+
allow: ["missing-plugin", "other-plugin"],
711+
deny: ["missing-plugin"],
712+
slots: {
713+
memory: "missing-plugin",
714+
},
715+
}),
716+
pluginId: "missing-plugin",
717+
deleteFiles: true,
718+
});
719+
720+
const successfulResult = expectSuccessfulUninstall(result);
721+
expect(successfulResult.actions).toEqual({
722+
entry: false,
723+
install: false,
724+
allowlist: true,
725+
denylist: true,
726+
loadPath: false,
727+
memorySlot: true,
728+
contextEngineSlot: false,
729+
channelConfig: false,
730+
directory: false,
731+
});
732+
expect(successfulResult.config.plugins?.allow).toEqual(["other-plugin"]);
733+
expect(successfulResult.config.plugins?.deny).toBeUndefined();
734+
expect(successfulResult.config.plugins?.slots?.memory).toBe("memory-core");
735+
expect(runCommandWithTimeoutMock).not.toHaveBeenCalled();
736+
});
737+
707738
it("removes config entries", async () => {
708739
const config = createPluginConfig({
709740
entries: createSinglePluginEntries(),
@@ -829,6 +860,24 @@ describe("uninstallPlugin", () => {
829860
await expect(fs.access(pluginDir)).rejects.toThrow();
830861
});
831862

863+
it("skips npm cleanup when the managed package directory is already absent", async () => {
864+
const stateDir = path.join(tempDir, "state");
865+
const npmRoot = path.join(stateDir, "npm");
866+
const pluginDir = path.join(npmRoot, "node_modules", "missing-plugin");
867+
868+
const applied = await applyPluginUninstallDirectoryRemoval({
869+
target: pluginDir,
870+
cleanup: {
871+
kind: "npm",
872+
npmRoot,
873+
packageName: "missing-plugin",
874+
},
875+
});
876+
877+
expect(applied).toEqual({ directoryRemoved: false, warnings: [] });
878+
expect(runCommandWithTimeoutMock).not.toHaveBeenCalled();
879+
});
880+
832881
it("warns and still removes npm package dirs when npm prune cleanup fails", async () => {
833882
runCommandWithTimeoutMock.mockResolvedValueOnce({
834883
code: 1,

src/plugins/uninstall.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ export function formatUninstallActionLabels(actions: UninstallActions): string[]
7777
);
7878
}
7979

80+
function hasUninstallAction(actions: Omit<UninstallActions, "directory">): boolean {
81+
return Object.values(actions).some(Boolean);
82+
}
83+
8084
export function formatUninstallSlotResetPreview(slotKey: "memory" | "contextEngine"): string {
8185
const actionKey = slotKey === "memory" ? "memorySlot" : "contextEngineSlot";
8286
return `${UNINSTALL_ACTION_LABELS[actionKey]} (will reset to "${defaultSlotIdForKey(slotKey)}")`;
@@ -489,14 +493,8 @@ export type UninstallPluginParams = {
489493
export function planPluginUninstall(params: UninstallPluginParams): PluginUninstallPlanResult {
490494
const { config, pluginId, channelIds, deleteFiles = true, extensionsDir } = params;
491495

492-
// Validate plugin exists
493496
const hasEntry = pluginId in (config.plugins?.entries ?? {});
494497
const hasInstall = pluginId in (config.plugins?.installs ?? {});
495-
496-
if (!hasEntry && !hasInstall) {
497-
return { ok: false, error: `Plugin not found: ${pluginId}` };
498-
}
499-
500498
const installRecord = config.plugins?.installs?.[pluginId];
501499
const isLinked = isLinkedPathInstallRecord(installRecord);
502500

@@ -505,6 +503,10 @@ export function planPluginUninstall(params: UninstallPluginParams): PluginUninst
505503
channelIds,
506504
});
507505

506+
if (!hasEntry && !hasInstall && !hasUninstallAction(configActions)) {
507+
return { ok: false, error: `Plugin not found: ${pluginId}` };
508+
}
509+
508510
const actions: UninstallActions = {
509511
...configActions,
510512
directory: false,
@@ -563,6 +565,10 @@ export async function applyPluginUninstallDirectoryRemoval(
563565
.then(() => true)
564566
.catch(() => false)) ?? false;
565567
const warnings: string[] = [];
568+
if (!existed) {
569+
return { directoryRemoved: false, warnings };
570+
}
571+
566572
if (removal.cleanup?.kind === "npm") {
567573
const uninstall = await runCommandWithTimeout(
568574
[

0 commit comments

Comments
 (0)