Skip to content

Commit ff10c47

Browse files
fix(plugins): clear hidden npm lock during peer repair
1 parent 97c6830 commit ff10c47

4 files changed

Lines changed: 175 additions & 20 deletions

File tree

src/infra/npm-managed-root.test.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import fs from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
4-
import { afterEach, describe, expect, it } from "vitest";
4+
import { afterEach, describe, expect, it, vi } from "vitest";
55
import {
66
repairManagedNpmRootOpenClawPeer,
77
removeManagedNpmRootDependency,
@@ -12,6 +12,15 @@ import {
1212

1313
const tempDirs: string[] = [];
1414

15+
const successfulSpawn = {
16+
code: 0,
17+
stdout: "",
18+
stderr: "",
19+
signal: null,
20+
killed: false,
21+
termination: "exit" as const,
22+
};
23+
1524
async function makeTempRoot(): Promise<string> {
1625
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-npm-managed-root-"));
1726
tempDirs.push(dir);
@@ -219,8 +228,40 @@ describe("managed npm root", () => {
219228
path.join(npmRoot, "node_modules", "openclaw", "package.json"),
220229
`${JSON.stringify({ name: "openclaw", version: "2026.5.4" })}\n`,
221230
);
231+
await fs.writeFile(
232+
path.join(npmRoot, "node_modules", ".package-lock.json"),
233+
`${JSON.stringify(
234+
{
235+
lockfileVersion: 3,
236+
packages: {
237+
"node_modules/openclaw": {
238+
version: "2026.5.4",
239+
},
240+
},
241+
},
242+
null,
243+
2,
244+
)}\n`,
245+
);
222246

223-
await expect(repairManagedNpmRootOpenClawPeer({ npmRoot })).resolves.toBe(true);
247+
const runCommand = vi.fn().mockResolvedValue(successfulSpawn);
248+
await expect(repairManagedNpmRootOpenClawPeer({ npmRoot, runCommand })).resolves.toBe(true);
249+
expect(runCommand).toHaveBeenCalledWith(
250+
[
251+
"npm",
252+
"uninstall",
253+
"--loglevel=error",
254+
"--ignore-scripts",
255+
"--no-audit",
256+
"--no-fund",
257+
"--prefix",
258+
".",
259+
"openclaw",
260+
],
261+
expect.objectContaining({
262+
cwd: npmRoot,
263+
}),
264+
);
224265

225266
const manifest = JSON.parse(await fs.readFile(path.join(npmRoot, "package.json"), "utf8")) as {
226267
dependencies?: Record<string, string>;
@@ -243,5 +284,10 @@ describe("managed npm root", () => {
243284
await expect(fs.lstat(path.join(npmRoot, "node_modules", "openclaw"))).rejects.toMatchObject({
244285
code: "ENOENT",
245286
});
287+
await expect(
288+
fs.lstat(path.join(npmRoot, "node_modules", ".package-lock.json")),
289+
).rejects.toMatchObject({
290+
code: "ENOENT",
291+
});
246292
});
247293
});

src/infra/npm-managed-root.ts

Lines changed: 109 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import fs from "node:fs/promises";
22
import path from "node:path";
3+
import { runCommandWithTimeout } from "../process/exec.js";
34
import type { NpmSpecResolution } from "./install-source-utils.js";
45
import type { ParsedRegistryNpmSpec } from "./npm-registry-spec.js";
6+
import { createSafeNpmInstallEnv } from "./safe-package-install.js";
57

68
type ManagedNpmRootManifest = {
79
private?: boolean;
@@ -21,6 +23,12 @@ type ManagedNpmRootLockfile = {
2123
[key: string]: unknown;
2224
};
2325

26+
type ManagedNpmRootLogger = {
27+
warn?: (message: string) => void;
28+
};
29+
30+
type ManagedNpmRootRunCommand = typeof runCommandWithTimeout;
31+
2432
function isRecord(value: unknown): value is Record<string, unknown> {
2533
return typeof value === "object" && value !== null && !Array.isArray(value);
2634
}
@@ -83,10 +91,105 @@ export async function upsertManagedNpmRootDependency(params: {
8391

8492
export async function repairManagedNpmRootOpenClawPeer(params: {
8593
npmRoot: string;
94+
timeoutMs?: number;
95+
logger?: ManagedNpmRootLogger;
96+
runCommand?: ManagedNpmRootRunCommand;
8697
}): Promise<boolean> {
87-
let changed = false;
88-
8998
await fs.mkdir(params.npmRoot, { recursive: true });
99+
100+
const manifestPath = path.join(params.npmRoot, "package.json");
101+
const manifest = await readManagedNpmRootManifest(manifestPath);
102+
const dependencies = readDependencyRecord(manifest.dependencies);
103+
const hasManifestDependency = "openclaw" in dependencies;
104+
const hasLockDependency = await managedNpmRootLockfileHasOpenClawPeer(params.npmRoot);
105+
const hasPackageDir = await pathExists(path.join(params.npmRoot, "node_modules", "openclaw"));
106+
if (!hasManifestDependency && !hasLockDependency && !hasPackageDir) {
107+
return false;
108+
}
109+
110+
const command = params.runCommand ?? runCommandWithTimeout;
111+
const npmArgs = hasManifestDependency
112+
? [
113+
"npm",
114+
"uninstall",
115+
"--loglevel=error",
116+
"--ignore-scripts",
117+
"--no-audit",
118+
"--no-fund",
119+
"--prefix",
120+
".",
121+
"openclaw",
122+
]
123+
: [
124+
"npm",
125+
"prune",
126+
"--loglevel=error",
127+
"--ignore-scripts",
128+
"--no-audit",
129+
"--no-fund",
130+
"--prefix",
131+
".",
132+
];
133+
try {
134+
const result = await command(npmArgs, {
135+
cwd: params.npmRoot,
136+
timeoutMs: Math.max(params.timeoutMs ?? 300_000, 300_000),
137+
env: createSafeNpmInstallEnv(process.env, { packageLock: true, quiet: true }),
138+
});
139+
if (result.code !== 0) {
140+
params.logger?.warn?.(
141+
`npm ${hasManifestDependency ? "uninstall openclaw" : "prune"} failed while repairing managed npm root; falling back to direct cleanup: ${result.stderr.trim() || result.stdout.trim()}`,
142+
);
143+
}
144+
} catch (error) {
145+
params.logger?.warn?.(
146+
`npm ${hasManifestDependency ? "uninstall openclaw" : "prune"} failed while repairing managed npm root; falling back to direct cleanup: ${String(error)}`,
147+
);
148+
}
149+
150+
await scrubManagedNpmRootOpenClawPeer({ npmRoot: params.npmRoot });
151+
return true;
152+
}
153+
154+
async function managedNpmRootLockfileHasOpenClawPeer(npmRoot: string): Promise<boolean> {
155+
const lockPath = path.join(npmRoot, "package-lock.json");
156+
try {
157+
const parsed = JSON.parse(await fs.readFile(lockPath, "utf8")) as ManagedNpmRootLockfile;
158+
if (isRecord(parsed.packages)) {
159+
const rootPackage = parsed.packages[""];
160+
if (
161+
isRecord(rootPackage) &&
162+
isRecord(rootPackage.dependencies) &&
163+
"openclaw" in rootPackage.dependencies
164+
) {
165+
return true;
166+
}
167+
if ("node_modules/openclaw" in parsed.packages) {
168+
return true;
169+
}
170+
}
171+
return isRecord(parsed.dependencies) && "openclaw" in parsed.dependencies;
172+
} catch (err) {
173+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
174+
return false;
175+
}
176+
throw err;
177+
}
178+
}
179+
180+
async function pathExists(filePath: string): Promise<boolean> {
181+
return await fs
182+
.lstat(filePath)
183+
.then(() => true)
184+
.catch((err: NodeJS.ErrnoException) => {
185+
if (err.code === "ENOENT") {
186+
return false;
187+
}
188+
throw err;
189+
});
190+
}
191+
192+
async function scrubManagedNpmRootOpenClawPeer(params: { npmRoot: string }): Promise<void> {
90193
const manifestPath = path.join(params.npmRoot, "package.json");
91194
const manifest = await readManagedNpmRootManifest(manifestPath);
92195
const dependencies = readDependencyRecord(manifest.dependencies);
@@ -97,7 +200,6 @@ export async function repairManagedNpmRootOpenClawPeer(params: {
97200
`${JSON.stringify({ ...manifest, private: true, dependencies: nextDependencies }, null, 2)}\n`,
98201
"utf8",
99202
);
100-
changed = true;
101203
}
102204

103205
const lockPath = path.join(params.npmRoot, "package-lock.json");
@@ -127,7 +229,6 @@ export async function repairManagedNpmRootOpenClawPeer(params: {
127229
}
128230
if (lockChanged) {
129231
await fs.writeFile(lockPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
130-
changed = true;
131232
}
132233
} catch (err) {
133234
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
@@ -136,21 +237,12 @@ export async function repairManagedNpmRootOpenClawPeer(params: {
136237
}
137238

138239
const openclawPackageDir = path.join(params.npmRoot, "node_modules", "openclaw");
139-
const openclawPackageDirExists = await fs
140-
.lstat(openclawPackageDir)
141-
.then(() => true)
142-
.catch((err: NodeJS.ErrnoException) => {
143-
if (err.code === "ENOENT") {
144-
return false;
145-
}
146-
throw err;
147-
});
148-
if (openclawPackageDirExists) {
240+
if (await pathExists(openclawPackageDir)) {
149241
await fs.rm(openclawPackageDir, { recursive: true, force: true });
150-
changed = true;
151242
}
152-
153-
return changed;
243+
await fs.rm(path.join(params.npmRoot, "node_modules", ".package-lock.json"), {
244+
force: true,
245+
});
154246
}
155247

156248
export async function readManagedNpmRootInstalledDependency(params: {

src/plugins/install.npm-spec.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,19 @@ function mockNpmViewAndInstallMany(packages: MockNpmPackage[]) {
257257
}
258258
if (argv[0] === "npm" && argv[1] === "uninstall") {
259259
const packageName = argv.at(-1);
260+
if (packageName === "openclaw") {
261+
const prefixIndex = argv.indexOf("--prefix");
262+
const prefixValue = prefixIndex >= 0 ? argv[prefixIndex + 1] : undefined;
263+
const npmRoot = prefixValue === "." ? options?.cwd : prefixValue;
264+
if (!npmRoot) {
265+
throw new Error(`unexpected npm uninstall command: ${argv.join(" ")}`);
266+
}
267+
fs.rmSync(path.join(npmRoot, "node_modules", "openclaw"), {
268+
recursive: true,
269+
force: true,
270+
});
271+
return successfulSpawn();
272+
}
260273
const pkg = packageName ? packagesByName.get(packageName) : undefined;
261274
if (!pkg) {
262275
throw new Error(`unexpected npm uninstall package: ${packageName ?? ""}`);

src/plugins/install.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1337,7 +1337,11 @@ export async function installPluginFromNpmSpec(
13371337

13381338
logger.info?.(`Installing ${spec} into ${npmRoot}…`);
13391339
if (parsedSpec.name !== "openclaw") {
1340-
const repairedOpenClawPeer = await repairManagedNpmRootOpenClawPeer({ npmRoot });
1340+
const repairedOpenClawPeer = await repairManagedNpmRootOpenClawPeer({
1341+
npmRoot,
1342+
timeoutMs,
1343+
logger,
1344+
});
13411345
if (repairedOpenClawPeer) {
13421346
logger.info?.(`Repaired stale openclaw peer dependency in ${npmRoot}`);
13431347
}

0 commit comments

Comments
 (0)