fix: detect shrinkwrapped npm installs

Fixes status/update detection for npm-installed OpenClaw packages that ship npm-shrinkwrap while preserving pnpm and Bun install ownership.

Fixes #87732.
Supersedes #88283.

Proof: focused infra Vitest shard, autoreview clean, Crabbox install matrix, and PR CI all green.
This commit is contained in:
Peter Steinberger
2026-06-02 06:39:22 -04:00
committed by GitHub
parent bce3d5bf92
commit 5d6216a7f1
4 changed files with 204 additions and 9 deletions

View File

@@ -10,12 +10,24 @@ async function withPackageManagerRoot<T>(
): Promise<T> {
return await withTempDir({ prefix: "openclaw-detect-pm-" }, async (root) => {
for (const file of files) {
await fs.writeFile(path.join(root, file.path), file.content, "utf8");
const target = path.join(root, file.path);
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, file.content, "utf8");
}
return await run(root);
});
}
async function writePublishedOpenClawRoot(root: string): Promise<void> {
await fs.mkdir(root, { recursive: true });
await fs.writeFile(
path.join(root, "package.json"),
JSON.stringify({ name: "openclaw", packageManager: "pnpm@11.2.2" }),
"utf8",
);
await fs.writeFile(path.join(root, "npm-shrinkwrap.json"), "{}", "utf8");
}
describe("detectPackageManager", () => {
it("prefers packageManager from package.json when supported", async () => {
await withPackageManagerRoot(
@@ -54,6 +66,84 @@ describe("detectPackageManager", () => {
});
});
it("uses npm-shrinkwrap as npm evidence for published npm package roots", async () => {
await withPackageManagerRoot(
[
{ path: "package.json", content: JSON.stringify({ packageManager: "pnpm@11.2.2" }) },
{ path: "npm-shrinkwrap.json", content: "{}" },
],
async (root) => {
await expect(detectPackageManager(root)).resolves.toBe("npm");
},
);
});
it("keeps pnpm source roots when npm-shrinkwrap is present next to pnpm-lock", async () => {
await withPackageManagerRoot(
[
{ path: "package.json", content: JSON.stringify({ packageManager: "pnpm@11.2.2" }) },
{ path: "npm-shrinkwrap.json", content: "{}" },
{ path: "pnpm-lock.yaml", content: "lockfileVersion: '9.0'" },
],
async (root) => {
await expect(detectPackageManager(root)).resolves.toBe("pnpm");
},
);
});
it("keeps pnpm-owned direct package roots that ship npm-shrinkwrap", async () => {
await withTempDir({ prefix: "openclaw-detect-pm-pnpm-direct-" }, async (base) => {
const nodeModulesRoot = path.join(base, "pnpm-global", "node_modules");
const packageRoot = path.join(nodeModulesRoot, "openclaw");
await writePublishedOpenClawRoot(packageRoot);
await fs.writeFile(path.join(nodeModulesRoot, ".modules.yaml"), "layoutVersion: 5", "utf8");
await expect(detectPackageManager(packageRoot)).resolves.toBe("pnpm");
});
});
it("keeps pnpm-owned virtual-store package roots that ship npm-shrinkwrap", async () => {
await withTempDir({ prefix: "openclaw-detect-pm-pnpm-virtual-" }, async (base) => {
const nodeModulesRoot = path.join(base, "project", "node_modules");
const packageRoot = path.join(
nodeModulesRoot,
".pnpm",
"openclaw@2026.5.27",
"node_modules",
"openclaw",
);
await writePublishedOpenClawRoot(packageRoot);
await fs.writeFile(path.join(nodeModulesRoot, ".modules.yaml"), "layoutVersion: 5", "utf8");
await expect(detectPackageManager(packageRoot)).resolves.toBe("pnpm");
});
});
it("keeps bun-owned global package roots that ship npm-shrinkwrap", async () => {
await withTempDir({ prefix: "openclaw-detect-pm-bun-" }, async (base) => {
const oldBunInstall = process.env.BUN_INSTALL;
process.env.BUN_INSTALL = path.join(base, "bun-home");
try {
const packageRoot = path.join(
process.env.BUN_INSTALL,
"install",
"global",
"node_modules",
"openclaw",
);
await writePublishedOpenClawRoot(packageRoot);
await expect(detectPackageManager(packageRoot)).resolves.toBe("bun");
} finally {
if (oldBunInstall == null) {
delete process.env.BUN_INSTALL;
} else {
process.env.BUN_INSTALL = oldBunInstall;
}
}
});
});
it("returns null when no package manager markers exist", async () => {
await withPackageManagerRoot(
[{ path: "package.json", content: "{not-json}" }],

View File

@@ -1,22 +1,86 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { readPackageManagerSpec } from "./package-json.js";
type DetectedPackageManager = "pnpm" | "bun" | "npm";
async function exists(p: string): Promise<boolean> {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
function resolveBunGlobalNodeModules(): string {
return path.join(
process.env.BUN_INSTALL || path.join(os.homedir(), ".bun"),
"install",
"global",
"node_modules",
);
}
function resolvePnpmNodeModulesRoot(root: string): string | null {
const resolved = path.resolve(root);
const parts = resolved.split(path.sep);
const pnpmIndex = parts.lastIndexOf(".pnpm");
if (pnpmIndex > 0) {
const layoutRoot = parts.slice(0, pnpmIndex).join(path.sep) || path.sep;
return path.basename(layoutRoot) === "node_modules"
? layoutRoot
: path.join(layoutRoot, "node_modules");
}
const parent = path.dirname(resolved);
return path.basename(parent) === "node_modules" ? parent : null;
}
async function isBunOwnedPackageRoot(root: string): Promise<boolean> {
return path.resolve(path.dirname(root)) === path.resolve(resolveBunGlobalNodeModules());
}
async function isPnpmOwnedPackageRoot(root: string): Promise<boolean> {
const nodeModulesRoot = resolvePnpmNodeModulesRoot(root);
if (!nodeModulesRoot || !(await exists(path.join(nodeModulesRoot, ".modules.yaml")))) {
return false;
}
return true;
}
export async function detectPackageManager(root: string): Promise<DetectedPackageManager | null> {
const pm = (await readPackageManagerSpec(root))?.split("@")[0]?.trim();
const files = await fs.readdir(root).catch((): string[] => []);
const hasNpmShrinkwrap = files.includes("npm-shrinkwrap.json");
const hasPnpmLock = files.includes("pnpm-lock.yaml");
const hasBunLock = files.includes("bun.lock") || files.includes("bun.lockb");
if (hasNpmShrinkwrap) {
if (await isBunOwnedPackageRoot(root)) {
return "bun";
}
if (pm === "pnpm" && (hasPnpmLock || (await isPnpmOwnedPackageRoot(root)))) {
return "pnpm";
}
if (pm === "bun" && hasBunLock) {
return "bun";
}
return "npm";
}
if (pm === "pnpm" || pm === "bun" || pm === "npm") {
return pm;
}
const files = await fs.readdir(root).catch((): string[] => []);
if (files.includes("pnpm-lock.yaml")) {
if (hasPnpmLock) {
return "pnpm";
}
if (files.includes("bun.lock") || files.includes("bun.lockb")) {
if (hasBunLock) {
return "bun";
}
if (files.includes("package-lock.json")) {
if (files.includes("package-lock.json") || hasNpmShrinkwrap) {
return "npm";
}
return null;

View File

@@ -230,6 +230,20 @@ describe("checkDepsStatus", () => {
expect(okDeps.status).toBe("ok");
});
});
it("uses npm-shrinkwrap as the npm dependency lock marker when present", async () => {
await withTempDir({ prefix: "openclaw-update-check-shrinkwrap-" }, async (root) => {
const shrinkwrapPath = path.join(root, "npm-shrinkwrap.json");
await fs.writeFile(shrinkwrapPath, "{}", "utf8");
await fs.mkdir(path.join(root, "node_modules"), { recursive: true });
const deps = await checkDepsStatus({ root, manager: "npm" });
expect(deps.manager).toBe("npm");
expect(deps.status).toBe("ok");
expect(deps.lockfilePath).toBe(shrinkwrapPath);
});
});
});
describe("checkUpdateStatus", () => {
@@ -269,6 +283,30 @@ describe("checkUpdateStatus", () => {
});
});
it("detects npm package installs that ship pnpm package metadata with shrinkwrap", async () => {
await withTempDir({ prefix: "openclaw-update-check-npm-shrinkwrap-" }, async (root) => {
await fs.writeFile(
path.join(root, "package.json"),
JSON.stringify({ name: "openclaw", packageManager: "pnpm@11.2.2" }),
"utf8",
);
await fs.writeFile(path.join(root, "npm-shrinkwrap.json"), "{}", "utf8");
await fs.mkdir(path.join(root, "node_modules"), { recursive: true });
const status = await checkUpdateStatus({
root,
includeRegistry: false,
fetchGit: false,
timeoutMs: 1000,
});
expect(status.installKind).toBe("package");
expect(status.packageManager).toBe("npm");
expect(status.deps?.manager).toBe("npm");
expect(status.deps?.lockfilePath).toBe(path.join(root, "npm-shrinkwrap.json"));
});
});
it("treats symlinked git installs as git roots", async () => {
await withTempDir({ prefix: "openclaw-update-check-git-" }, async (base) => {
const repoRoot = path.join(base, "repo");

View File

@@ -200,10 +200,10 @@ async function statMtimeMs(p: string): Promise<number | null> {
}
}
function resolveDepsMarker(params: { root: string; manager: PackageManager }): {
async function resolveDepsMarker(params: { root: string; manager: PackageManager }): Promise<{
lockfilePath: string | null;
markerPath: string | null;
} {
}> {
const root = params.root;
if (params.manager === "pnpm") {
return {
@@ -218,8 +218,11 @@ function resolveDepsMarker(params: { root: string; manager: PackageManager }): {
};
}
if (params.manager === "npm") {
const shrinkwrapPath = path.join(root, "npm-shrinkwrap.json");
return {
lockfilePath: path.join(root, "package-lock.json"),
lockfilePath: (await exists(shrinkwrapPath))
? shrinkwrapPath
: path.join(root, "package-lock.json"),
markerPath: path.join(root, "node_modules"),
};
}
@@ -231,7 +234,7 @@ export async function checkDepsStatus(params: {
manager: PackageManager;
}): Promise<DepsStatus> {
const root = path.resolve(params.root);
const { lockfilePath, markerPath } = resolveDepsMarker({
const { lockfilePath, markerPath } = await resolveDepsMarker({
root,
manager: params.manager,
});