Skip to content

Commit 6f239c3

Browse files
committed
fix: preserve npm plugin generations during updates
1 parent 12c34fc commit 6f239c3

13 files changed

Lines changed: 1546 additions & 95 deletions

src/cli/plugins-install-persist.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
// Plugin install persist tests cover saving installed plugin records after install.
2+
import fs from "node:fs";
3+
import os from "node:os";
4+
import path from "node:path";
25
import { beforeEach, describe, expect, it } from "vitest";
36
import type { OpenClawConfig } from "../config/config.js";
7+
import { hasRetainedManagedNpmInstallMarker } from "../plugins/managed-npm-retention.js";
48
import {
59
applyExclusiveSlotSelection,
610
buildPluginDiagnosticsReport,
@@ -291,6 +295,108 @@ describe("persistPluginInstall", () => {
291295
expect(applyPluginUninstallDirectoryRemoval).not.toHaveBeenCalled();
292296
});
293297

298+
it("preserves replaced npm install directories across generation updates", async () => {
299+
const { persistPluginInstall } = await import("./plugins-install-persist.js");
300+
const baseConfig = {
301+
plugins: {
302+
entries: {},
303+
},
304+
} as OpenClawConfig;
305+
const enabledConfig = {
306+
plugins: {
307+
entries: {
308+
codex: { enabled: true },
309+
},
310+
},
311+
} as OpenClawConfig;
312+
enablePluginInConfig.mockReturnValue({ config: enabledConfig });
313+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-persist-"));
314+
const previousProjectRoot = path.join(tempRoot, "npm", "projects", "codex-v1");
315+
const previousInstallPath = path.join(
316+
previousProjectRoot,
317+
"node_modules",
318+
"@openclaw",
319+
"codex",
320+
);
321+
const nextInstallPath = path.join(
322+
tempRoot,
323+
"npm",
324+
"projects",
325+
"codex-v2",
326+
"node_modules",
327+
"@openclaw",
328+
"codex",
329+
);
330+
fs.mkdirSync(previousInstallPath, { recursive: true });
331+
setInstalledPluginIndexInstallRecords({
332+
codex: {
333+
source: "npm",
334+
spec: "@openclaw/[email protected]",
335+
installPath: previousInstallPath,
336+
},
337+
});
338+
planPluginUninstall.mockReturnValueOnce({
339+
ok: true,
340+
config: {} as OpenClawConfig,
341+
pluginId: "codex",
342+
actions: {
343+
entry: false,
344+
install: true,
345+
allowlist: false,
346+
denylist: false,
347+
loadPath: false,
348+
memorySlot: false,
349+
contextEngineSlot: false,
350+
channelConfig: false,
351+
directory: false,
352+
},
353+
directoryRemoval: {
354+
target: previousInstallPath,
355+
cleanup: {
356+
kind: "npm",
357+
npmRoot: previousProjectRoot,
358+
packageName: "@openclaw/codex",
359+
},
360+
},
361+
});
362+
363+
try {
364+
await persistPluginInstall({
365+
snapshot: {
366+
config: baseConfig,
367+
baseHash: "config-1",
368+
writeOptions: installWriteOptions,
369+
},
370+
pluginId: "codex",
371+
install: {
372+
source: "npm",
373+
spec: "@openclaw/[email protected]",
374+
installPath: nextInstallPath,
375+
},
376+
});
377+
378+
expect(planPluginUninstall).toHaveBeenCalledWith({
379+
config: {
380+
plugins: {
381+
installs: {
382+
codex: {
383+
source: "npm",
384+
spec: "@openclaw/[email protected]",
385+
installPath: previousInstallPath,
386+
},
387+
},
388+
},
389+
},
390+
pluginId: "codex",
391+
deleteFiles: true,
392+
});
393+
expect(applyPluginUninstallDirectoryRemoval).not.toHaveBeenCalled();
394+
expect(hasRetainedManagedNpmInstallMarker(previousInstallPath)).toBe(true);
395+
} finally {
396+
fs.rmSync(tempRoot, { recursive: true, force: true });
397+
}
398+
});
399+
294400
it("warns when an installed npm plugin remains shadowed by a config-selected source", async () => {
295401
const { persistPluginInstall } = await import("./plugins-install-persist.js");
296402
const baseConfig = {

src/cli/plugins-install-persist.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,11 @@ function resolveReplacedManagedInstallRemoval(params: {
388388
if (!previousInstallPath || !nextInstallPath) {
389389
return null;
390390
}
391+
if (params.previousInstall.source === "npm" && params.nextInstall.source === "npm") {
392+
// npm plugin updates can leave a running gateway holding imports into the
393+
// previous dist tree until restart; keep replaced generations available.
394+
return null;
395+
}
391396
if (
392397
shouldPreserveReplacedInstallPath({
393398
removalTarget: previousInstallPath,
@@ -450,9 +455,10 @@ export async function persistPluginInstall(params: {
450455
() => loadInstalledPluginIndexInstallRecords(),
451456
{ command: "install" },
452457
);
458+
const previousInstall = installRecords[params.pluginId];
453459
const replacedInstallRemoval = resolveReplacedManagedInstallRemoval({
454460
pluginId: params.pluginId,
455-
previousInstall: installRecords[params.pluginId],
461+
previousInstall,
456462
nextInstall: params.install,
457463
});
458464
const nextInstallRecords = recordPluginInstallInRecords(installRecords, {

0 commit comments

Comments
 (0)