diff --git a/src/infra/npm-managed-root.test.ts b/src/infra/npm-managed-root.test.ts index 90928fa06bf..9c15fc5821e 100644 --- a/src/infra/npm-managed-root.test.ts +++ b/src/infra/npm-managed-root.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { repairManagedNpmRootOpenClawPeer, removeManagedNpmRootDependency, @@ -12,6 +12,15 @@ import { const tempDirs: string[] = []; +const successfulSpawn = { + code: 0, + stdout: "", + stderr: "", + signal: null, + killed: false, + termination: "exit" as const, +}; + async function makeTempRoot(): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-npm-managed-root-")); tempDirs.push(dir); @@ -219,8 +228,40 @@ describe("managed npm root", () => { path.join(npmRoot, "node_modules", "openclaw", "package.json"), `${JSON.stringify({ name: "openclaw", version: "2026.5.4" })}\n`, ); + await fs.writeFile( + path.join(npmRoot, "node_modules", ".package-lock.json"), + `${JSON.stringify( + { + lockfileVersion: 3, + packages: { + "node_modules/openclaw": { + version: "2026.5.4", + }, + }, + }, + null, + 2, + )}\n`, + ); - await expect(repairManagedNpmRootOpenClawPeer({ npmRoot })).resolves.toBe(true); + const runCommand = vi.fn().mockResolvedValue(successfulSpawn); + await expect(repairManagedNpmRootOpenClawPeer({ npmRoot, runCommand })).resolves.toBe(true); + expect(runCommand).toHaveBeenCalledWith( + [ + "npm", + "uninstall", + "--loglevel=error", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--prefix", + ".", + "openclaw", + ], + expect.objectContaining({ + cwd: npmRoot, + }), + ); const manifest = JSON.parse(await fs.readFile(path.join(npmRoot, "package.json"), "utf8")) as { dependencies?: Record; @@ -243,5 +284,10 @@ describe("managed npm root", () => { await expect(fs.lstat(path.join(npmRoot, "node_modules", "openclaw"))).rejects.toMatchObject({ code: "ENOENT", }); + await expect( + fs.lstat(path.join(npmRoot, "node_modules", ".package-lock.json")), + ).rejects.toMatchObject({ + code: "ENOENT", + }); }); }); diff --git a/src/infra/npm-managed-root.ts b/src/infra/npm-managed-root.ts index db0f88d11f7..2a69f89e6ba 100644 --- a/src/infra/npm-managed-root.ts +++ b/src/infra/npm-managed-root.ts @@ -1,7 +1,9 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { runCommandWithTimeout } from "../process/exec.js"; import type { NpmSpecResolution } from "./install-source-utils.js"; import type { ParsedRegistryNpmSpec } from "./npm-registry-spec.js"; +import { createSafeNpmInstallEnv } from "./safe-package-install.js"; type ManagedNpmRootManifest = { private?: boolean; @@ -21,6 +23,12 @@ type ManagedNpmRootLockfile = { [key: string]: unknown; }; +type ManagedNpmRootLogger = { + warn?: (message: string) => void; +}; + +type ManagedNpmRootRunCommand = typeof runCommandWithTimeout; + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -83,10 +91,105 @@ export async function upsertManagedNpmRootDependency(params: { export async function repairManagedNpmRootOpenClawPeer(params: { npmRoot: string; + timeoutMs?: number; + logger?: ManagedNpmRootLogger; + runCommand?: ManagedNpmRootRunCommand; }): Promise { - let changed = false; - await fs.mkdir(params.npmRoot, { recursive: true }); + + const manifestPath = path.join(params.npmRoot, "package.json"); + const manifest = await readManagedNpmRootManifest(manifestPath); + const dependencies = readDependencyRecord(manifest.dependencies); + const hasManifestDependency = "openclaw" in dependencies; + const hasLockDependency = await managedNpmRootLockfileHasOpenClawPeer(params.npmRoot); + const hasPackageDir = await pathExists(path.join(params.npmRoot, "node_modules", "openclaw")); + if (!hasManifestDependency && !hasLockDependency && !hasPackageDir) { + return false; + } + + const command = params.runCommand ?? runCommandWithTimeout; + const npmArgs = hasManifestDependency + ? [ + "npm", + "uninstall", + "--loglevel=error", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--prefix", + ".", + "openclaw", + ] + : [ + "npm", + "prune", + "--loglevel=error", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--prefix", + ".", + ]; + try { + const result = await command(npmArgs, { + cwd: params.npmRoot, + timeoutMs: Math.max(params.timeoutMs ?? 300_000, 300_000), + env: createSafeNpmInstallEnv(process.env, { packageLock: true, quiet: true }), + }); + if (result.code !== 0) { + params.logger?.warn?.( + `npm ${hasManifestDependency ? "uninstall openclaw" : "prune"} failed while repairing managed npm root; falling back to direct cleanup: ${result.stderr.trim() || result.stdout.trim()}`, + ); + } + } catch (error) { + params.logger?.warn?.( + `npm ${hasManifestDependency ? "uninstall openclaw" : "prune"} failed while repairing managed npm root; falling back to direct cleanup: ${String(error)}`, + ); + } + + await scrubManagedNpmRootOpenClawPeer({ npmRoot: params.npmRoot }); + return true; +} + +async function managedNpmRootLockfileHasOpenClawPeer(npmRoot: string): Promise { + const lockPath = path.join(npmRoot, "package-lock.json"); + try { + const parsed = JSON.parse(await fs.readFile(lockPath, "utf8")) as ManagedNpmRootLockfile; + if (isRecord(parsed.packages)) { + const rootPackage = parsed.packages[""]; + if ( + isRecord(rootPackage) && + isRecord(rootPackage.dependencies) && + "openclaw" in rootPackage.dependencies + ) { + return true; + } + if ("node_modules/openclaw" in parsed.packages) { + return true; + } + } + return isRecord(parsed.dependencies) && "openclaw" in parsed.dependencies; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return false; + } + throw err; + } +} + +async function pathExists(filePath: string): Promise { + return await fs + .lstat(filePath) + .then(() => true) + .catch((err: NodeJS.ErrnoException) => { + if (err.code === "ENOENT") { + return false; + } + throw err; + }); +} + +async function scrubManagedNpmRootOpenClawPeer(params: { npmRoot: string }): Promise { const manifestPath = path.join(params.npmRoot, "package.json"); const manifest = await readManagedNpmRootManifest(manifestPath); const dependencies = readDependencyRecord(manifest.dependencies); @@ -97,7 +200,6 @@ export async function repairManagedNpmRootOpenClawPeer(params: { `${JSON.stringify({ ...manifest, private: true, dependencies: nextDependencies }, null, 2)}\n`, "utf8", ); - changed = true; } const lockPath = path.join(params.npmRoot, "package-lock.json"); @@ -127,7 +229,6 @@ export async function repairManagedNpmRootOpenClawPeer(params: { } if (lockChanged) { await fs.writeFile(lockPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8"); - changed = true; } } catch (err) { if ((err as NodeJS.ErrnoException).code !== "ENOENT") { @@ -136,21 +237,12 @@ export async function repairManagedNpmRootOpenClawPeer(params: { } const openclawPackageDir = path.join(params.npmRoot, "node_modules", "openclaw"); - const openclawPackageDirExists = await fs - .lstat(openclawPackageDir) - .then(() => true) - .catch((err: NodeJS.ErrnoException) => { - if (err.code === "ENOENT") { - return false; - } - throw err; - }); - if (openclawPackageDirExists) { + if (await pathExists(openclawPackageDir)) { await fs.rm(openclawPackageDir, { recursive: true, force: true }); - changed = true; } - - return changed; + await fs.rm(path.join(params.npmRoot, "node_modules", ".package-lock.json"), { + force: true, + }); } export async function readManagedNpmRootInstalledDependency(params: { diff --git a/src/plugins/install.npm-spec.test.ts b/src/plugins/install.npm-spec.test.ts index b9a2aaba530..ddac63f3f7a 100644 --- a/src/plugins/install.npm-spec.test.ts +++ b/src/plugins/install.npm-spec.test.ts @@ -257,6 +257,19 @@ function mockNpmViewAndInstallMany(packages: MockNpmPackage[]) { } if (argv[0] === "npm" && argv[1] === "uninstall") { const packageName = argv.at(-1); + if (packageName === "openclaw") { + const prefixIndex = argv.indexOf("--prefix"); + const prefixValue = prefixIndex >= 0 ? argv[prefixIndex + 1] : undefined; + const npmRoot = prefixValue === "." ? options?.cwd : prefixValue; + if (!npmRoot) { + throw new Error(`unexpected npm uninstall command: ${argv.join(" ")}`); + } + fs.rmSync(path.join(npmRoot, "node_modules", "openclaw"), { + recursive: true, + force: true, + }); + return successfulSpawn(); + } const pkg = packageName ? packagesByName.get(packageName) : undefined; if (!pkg) { throw new Error(`unexpected npm uninstall package: ${packageName ?? ""}`); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 253fd8bed6a..3099ffb407a 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -1337,7 +1337,11 @@ export async function installPluginFromNpmSpec( logger.info?.(`Installing ${spec} into ${npmRoot}…`); if (parsedSpec.name !== "openclaw") { - const repairedOpenClawPeer = await repairManagedNpmRootOpenClawPeer({ npmRoot }); + const repairedOpenClawPeer = await repairManagedNpmRootOpenClawPeer({ + npmRoot, + timeoutMs, + logger, + }); if (repairedOpenClawPeer) { logger.info?.(`Repaired stale openclaw peer dependency in ${npmRoot}`); }