mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
committed by
GitHub
parent
bce3d5bf92
commit
5d6216a7f1
@@ -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}" }],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user