diff --git a/scripts/postinstall-bundled-plugins.mjs b/scripts/postinstall-bundled-plugins.mjs index 841affc1b2a..b950947bb04 100644 --- a/scripts/postinstall-bundled-plugins.mjs +++ b/scripts/postinstall-bundled-plugins.mjs @@ -164,12 +164,6 @@ function assertSafeInstalledDistPath(relativePath, params) { return candidatePath; } -function isStagedRuntimeDependencyPath(relativePath) { - return /^dist\/extensions\/[^/]+\/(?:node_modules|\.openclaw-install-stage(?:-[^/]+)?)(?:\/|$)/u.test( - normalizeRelativePath(relativePath), - ); -} - function listInstalledDistFiles(params = {}) { const readDir = params.readdirSync ?? readdirSync; const distRoot = resolveInstalledDistRoot(params); @@ -184,10 +178,6 @@ function listInstalledDistFiles(params = {}) { if (!currentDir) { continue; } - const relativeCurrentDir = normalizeRelativePath(relative(packageRoot, currentDir)); - if (isStagedRuntimeDependencyPath(relativeCurrentDir)) { - continue; - } for (const entry of readDir(currentDir, { withFileTypes: true })) { const entryPath = join(currentDir, entry.name); if (entry.isSymbolicLink()) { @@ -223,10 +213,6 @@ function pruneEmptyDistDirectories(params = {}) { const pathLstat = params.lstatSync ?? lstatSync; function prune(currentDir) { - const relativeCurrentDir = normalizeRelativePath(relative(packageRoot, currentDir)); - if (isStagedRuntimeDependencyPath(relativeCurrentDir)) { - return; - } for (const entry of readDir(currentDir, { withFileTypes: true })) { if (entry.isSymbolicLink()) { throw new Error( @@ -261,6 +247,57 @@ function pruneEmptyDistDirectories(params = {}) { prune(distRoot.distDir); } +function isLegacyInstalledPluginDependencyDirName(name) { + return name === "node_modules" || /^\.openclaw-install-stage(?:-[^/]+)?$/iu.test(name); +} + +function pruneLegacyInstalledPluginDependencyDirs(params) { + const readDir = params.readdirSync ?? readdirSync; + const removePath = params.rmSync ?? rmSync; + const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT; + const extensionsDir = join(packageRoot, "dist", "extensions"); + const removed = []; + let pluginEntries; + try { + pluginEntries = readDir(extensionsDir, { withFileTypes: true }); + } catch { + return removed; + } + + for (const pluginEntry of pluginEntries) { + if (!pluginEntry.isDirectory() || pluginEntry.isSymbolicLink()) { + continue; + } + const pluginDir = join(extensionsDir, pluginEntry.name); + let pluginChildren; + try { + pluginChildren = readDir(pluginDir, { withFileTypes: true }); + } catch { + continue; + } + for (const childEntry of pluginChildren) { + if (!isLegacyInstalledPluginDependencyDirName(childEntry.name)) { + continue; + } + const safePluginDir = assertSafeInstalledDistPath( + normalizeRelativePath(relative(packageRoot, pluginDir)), + { + packageRoot, + distDirReal: params.distDirReal, + realpathSync: params.realpathSync, + }, + ); + const relativePath = normalizeRelativePath( + relative(packageRoot, join(pluginDir, childEntry.name)), + ); + removePath(join(safePluginDir, childEntry.name), { recursive: true, force: true }); + removed.push(relativePath); + } + } + + return removed; +} + const JS_DIST_FILE_RE = /^dist\/.*\.(?:cjs|js|mjs)$/u; function stripSpecifierSuffix(value) { @@ -400,6 +437,13 @@ export function pruneInstalledPackageDist(params = {}) { if (distRoot === null) { return []; } + const removedLegacyDependencyDirs = pruneLegacyInstalledPluginDependencyDirs({ + packageRoot, + distDirReal: distRoot.distDirReal, + realpathSync: params.realpathSync, + readdirSync: params.readdirSync, + rmSync: params.rmSync, + }); let expectedFiles = params.expectedFiles ?? null; if (expectedFiles === null) { try { @@ -444,6 +488,11 @@ export function pruneInstalledPackageDist(params = {}) { if (removed.length > 0) { log.log(`[postinstall] pruned stale dist files: ${removed.join(", ")}`); } + if (removedLegacyDependencyDirs.length > 0) { + log.log( + `[postinstall] pruned legacy plugin dependency dirs: ${removedLegacyDependencyDirs.join(", ")}`, + ); + } return removed; } diff --git a/src/channels/plugins/bundled.shape-guard.test.ts b/src/channels/plugins/bundled.shape-guard.test.ts index 8959aa39343..b5fe7d80916 100644 --- a/src/channels/plugins/bundled.shape-guard.test.ts +++ b/src/channels/plugins/bundled.shape-guard.test.ts @@ -739,25 +739,6 @@ describe("bundled channel entry shape guards", () => { expect(offenders).toEqual([]); }); - it("keeps setup-only plugin barrels off legacy staged runtime-dependency metadata", () => { - const offenders: string[] = []; - - for (const extensionDir of bundledPluginRoots) { - const packageJsonPath = path.join(extensionDir, "package.json"); - if (!fs.existsSync(packageJsonPath)) { - continue; - } - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { - openclaw?: { bundle?: Record }; - }; - if (packageJson.openclaw?.bundle?.stageRuntimeDependencies === true) { - offenders.push(path.relative(process.cwd(), packageJsonPath)); - } - } - - expect(offenders).toEqual([]); - }); - it("keeps bundled channel entrypoints free of static src imports", () => { const offenders = collectBundledChannelEntrypointOffenders(bundledPluginRoots, (source) => /^(?:import|export)\s.+["']\.\/src\//mu.test(source), diff --git a/test/scripts/postinstall-bundled-plugins.test.ts b/test/scripts/postinstall-bundled-plugins.test.ts index bfe4af5a2a9..97d90b482b8 100644 --- a/test/scripts/postinstall-bundled-plugins.test.ts +++ b/test/scripts/postinstall-bundled-plugins.test.ts @@ -61,27 +61,6 @@ describe("bundled plugin postinstall", () => { ).toBe(true); }); - it("does not install bundled plugin package deps outside of source checkouts by default", async () => { - const extensionsDir = await createExtensionsDir(); - const packageRoot = path.dirname(path.dirname(extensionsDir)); - await writePluginPackage(extensionsDir, "acpx", { - dependencies: { - acpx: "0.4.1", - }, - }); - const spawnSync = vi.fn(); - - runBundledPluginPostinstall({ - env: { HOME: "/tmp/home" }, - extensionsDir, - packageRoot, - spawnSync, - log: { log: vi.fn(), warn: vi.fn() }, - }); - - expect(spawnSync).not.toHaveBeenCalled(); - }); - it("prunes Node versioned compile cache dirs during package postinstall", () => { const configuredBase = path.join("/tmp", "openclaw-cache"); const defaultBase = path.join(tmpdir(), "node-compile-cache"); @@ -173,19 +152,15 @@ describe("bundled plugin postinstall", () => { path.join(extensionsDir, "acpx", "node_modules", "acpx", "package.json"), JSON.stringify({ name: "acpx", version: "0.4.1" }), ); - const spawnSync = vi.fn(); - runBundledPluginPostinstall({ env: { HOME: "/tmp/home" }, packageRoot, - spawnSync, log: { log: vi.fn(), warn: vi.fn() }, }); await expect(fs.stat(path.join(extensionsDir, "acpx", "node_modules"))).rejects.toMatchObject({ code: "ENOENT", }); - expect(spawnSync).not.toHaveBeenCalled(); }); it("keeps source-checkout prune non-fatal", async () => { @@ -419,7 +394,6 @@ describe("bundled plugin postinstall", () => { runBundledPluginPostinstall({ packageRoot, - spawnSync: vi.fn(), log: { log: vi.fn(), warn: vi.fn() }, }); @@ -529,11 +503,20 @@ describe("bundled plugin postinstall", () => { ).toThrow("unsafe dist entry: dist/escape"); }); - it("ignores staged bundled plugin node_modules when pruning packaged dist", async () => { + it("prunes stale bundled plugin dependency debris from packaged dist", async () => { const packageRoot = await createTempDirAsync("openclaw-packaged-install-dist-prune-"); const staleFile = path.join(packageRoot, "dist", "stale-runtime.js"); const packageJson = path.join(packageRoot, "dist", "extensions", "slack", "package.json"); const binDir = path.join(packageRoot, "dist", "extensions", "slack", "node_modules", ".bin"); + const dependencyFile = path.join( + packageRoot, + "dist", + "extensions", + "slack", + "node_modules", + "typebox", + "package.json", + ); const installStageFile = path.join( packageRoot, "dist", @@ -561,10 +544,12 @@ describe("bundled plugin postinstall", () => { await fs.mkdir(path.dirname(staleFile), { recursive: true }); await fs.mkdir(path.dirname(packageJson), { recursive: true }); await fs.mkdir(binDir, { recursive: true }); + await fs.mkdir(path.dirname(dependencyFile), { recursive: true }); await fs.mkdir(path.dirname(installStageFile), { recursive: true }); await fs.mkdir(path.dirname(retryInstallStageFile), { recursive: true }); await fs.writeFile(staleFile, "export {};\n"); await fs.writeFile(packageJson, "{}\n"); + await fs.writeFile(dependencyFile, "{}\n"); await fs.writeFile(installStageFile, "export {};\n"); await fs.writeFile(retryInstallStageFile, "export {};\n"); await fs.symlink("../fxparser/bin.js", path.join(binDir, "fxparser")); @@ -576,8 +561,15 @@ describe("bundled plugin postinstall", () => { log: { log: vi.fn(), warn: vi.fn() }, }), ).toEqual(["dist/stale-runtime.js"]); - await expect(fs.stat(installStageFile)).resolves.toBeDefined(); - await expect(fs.stat(retryInstallStageFile)).resolves.toBeDefined(); + await expect( + fs.stat(path.join(packageRoot, "dist", "extensions", "slack", "node_modules")), + ).rejects.toMatchObject({ code: "ENOENT" }); + await expect(fs.stat(path.dirname(installStageFile))).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect(fs.stat(path.dirname(retryInstallStageFile))).rejects.toMatchObject({ + code: "ENOENT", + }); }); it("unlinks stale files instead of recursive pruning them", () => { @@ -614,32 +606,6 @@ describe("bundled plugin postinstall", () => { expect(unlinkSync).toHaveBeenCalledWith("/pkg/dist/stale.js"); }); - it("skips reinstall when the bundled sentinel package already exists", async () => { - const extensionsDir = await createExtensionsDir(); - const packageRoot = path.dirname(path.dirname(extensionsDir)); - await writePluginPackage(extensionsDir, "acpx", { - dependencies: { - acpx: "0.4.1", - }, - }); - await fs.mkdir(path.join(packageRoot, "node_modules", "acpx"), { recursive: true }); - await fs.writeFile( - path.join(packageRoot, "node_modules", "acpx", "package.json"), - "{}\n", - "utf8", - ); - const spawnSync = vi.fn(); - - runBundledPluginPostinstall({ - env: { npm_config_global: "true" }, - extensionsDir, - packageRoot, - spawnSync, - }); - - expect(spawnSync).not.toHaveBeenCalled(); - }); - it("prunes only bundled plugin package node_modules in source checkouts", async () => { const packageRoot = await createTempDirAsync("openclaw-source-prune-"); const extensionsDir = path.join(packageRoot, "extensions");