fix(infra): recognize global Bun installs without breaking pnpm (#118143)

This commit is contained in:
Peter Steinberger
2026-08-02 13:08:20 -07:00
committed by GitHub
parent bc826d6866
commit d9c9ad8d8a
3 changed files with 115 additions and 62 deletions
+73 -33
View File
@@ -20,14 +20,19 @@ async function withPackageManagerRoot<T>(
});
}
async function writePublishedOpenClawRoot(root: string): Promise<void> {
async function writePublishedOpenClawRoot(
root: string,
options: { shrinkwrap: boolean },
): 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");
if (options.shrinkwrap) {
await fs.writeFile(path.join(root, "npm-shrinkwrap.json"), "{}", "utf8");
}
}
describe("detectPackageManager", () => {
@@ -93,43 +98,78 @@ describe("detectPackageManager", () => {
);
});
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");
it.each(
[
{ manager: "pnpm", layout: "direct" },
{ manager: "pnpm", layout: "virtual" },
{ manager: "bun", layout: "global" },
].flatMap((scenario) => [
{ ...scenario, packageFormat: "lockless", shrinkwrap: false },
{ ...scenario, packageFormat: "legacy shrinkwrapped", shrinkwrap: true },
]),
)(
"detects $manager ownership for $packageFormat packages in $layout layouts",
async ({ manager, layout, shrinkwrap }) => {
await withTempDir({ prefix: `openclaw-detect-pm-${manager}-` }, async (base) => {
const bunInstall = path.join(base, "custom-bun-home");
const nodeModulesRoot =
manager === "bun"
? path.join(bunInstall, "install", "global", "node_modules")
: path.join(base, `${manager}-global`, "node_modules");
const packageRoot =
layout === "virtual"
? path.join(nodeModulesRoot, ".pnpm", "openclaw@2026.5.27", "node_modules", "openclaw")
: path.join(nodeModulesRoot, "openclaw");
await writePublishedOpenClawRoot(packageRoot, { shrinkwrap });
if (manager === "pnpm") {
await fs.writeFile(
path.join(nodeModulesRoot, ".modules.yaml"),
"layoutVersion: 5",
"utf8",
);
}
await expect(detectPackageManager(packageRoot)).resolves.toBe("pnpm");
});
});
await withEnvAsync({ BUN_INSTALL: bunInstall }, async () => {
await expect(detectPackageManager(packageRoot)).resolves.toBe(manager);
});
});
},
);
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",
it("keeps pnpm 11 ownership through markerless global virtual-store symlinks", async () => {
await withTempDir({ prefix: "openclaw-detect-pm-pnpm-global-store-" }, async (base) => {
const globalRoot = path.join(base, "pnpm-home", "global", "v11");
const installRoot = path.join(globalRoot, "install-openclaw");
const linkedPackageRoot = path.join(installRoot, "node_modules", "openclaw");
const storePackageRoot = path.join(
base,
"pnpm-home",
"store",
"v11",
"links",
"@",
"openclaw",
"2026.7.2",
"graph-hash",
"node_modules",
"openclaw",
);
await writePublishedOpenClawRoot(packageRoot);
await fs.writeFile(path.join(nodeModulesRoot, ".modules.yaml"), "layoutVersion: 5", "utf8");
await writePublishedOpenClawRoot(storePackageRoot, { shrinkwrap: false });
await fs.mkdir(path.dirname(linkedPackageRoot), { recursive: true });
await fs.writeFile(
path.join(installRoot, "package.json"),
JSON.stringify({ private: true, dependencies: { openclaw: "2026.7.2" } }),
"utf8",
);
await fs.writeFile(path.join(installRoot, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n");
const symlinkType = process.platform === "win32" ? "junction" : "dir";
await fs.symlink(storePackageRoot, linkedPackageRoot, symlinkType);
await fs.symlink(installRoot, path.join(globalRoot, "hash-openclaw"), symlinkType);
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 bunInstall = path.join(base, "bun-home");
await withEnvAsync({ BUN_INSTALL: bunInstall }, async () => {
const packageRoot = path.join(bunInstall, "install", "global", "node_modules", "openclaw");
await writePublishedOpenClawRoot(packageRoot);
await expect(detectPackageManager(packageRoot)).resolves.toBe("bun");
});
await expect(detectPackageManager(linkedPackageRoot)).resolves.toBe("pnpm");
await expect(detectPackageManager(await fs.realpath(linkedPackageRoot))).resolves.toBe(
"pnpm",
);
});
});
+5 -5
View File
@@ -59,12 +59,12 @@ export async function detectPackageManager(root: string): Promise<DetectedPackag
const hasPnpmLock = files.includes("pnpm-lock.yaml");
const hasBunLock = files.includes("bun.lock") || files.includes("bun.lockb");
// Published packages retain source pnpm metadata, and modern releases omit
// shrinkwrap; detect Bun by its install root before checking older npm locks.
if (await isBunOwnedPackageRoot(root)) {
return "bun";
}
if (hasNpmShrinkwrap) {
// Published npm packages carry npm-shrinkwrap even when their source uses pnpm;
// installed pnpm/bun-owned roots need layout proof before overriding npm.
if (await isBunOwnedPackageRoot(root)) {
return "bun";
}
if (pm === "pnpm" && (hasPnpmLock || (await isPnpmOwnedPackageRoot(root)))) {
return "pnpm";
}
+37 -24
View File
@@ -7,6 +7,7 @@ import { pathToFileURL } from "node:url";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { runCommandWithTimeout } from "../process/exec.js";
import { withTempDir } from "../test-helpers/temp-dir.js";
import { withEnvAsync } from "../test-utils/env.js";
import { useMockHttp } from "../test-utils/mock-http.js";
import { fetchNpmPackageTargetStatus } from "./update-check-package-target.js";
import {
@@ -816,33 +817,45 @@ describe("checkUpdateStatus", () => {
});
});
it("detects lockless OpenClaw npm installs despite packed pnpm metadata", async () => {
await withTempDir({ prefix: "openclaw-update-check-lockless-npm-" }, async (base) => {
const root = path.join(base, "prefix", "node_modules", "openclaw");
await fs.mkdir(root, { recursive: true });
await fs.writeFile(
path.join(root, "package.json"),
JSON.stringify({ name: "openclaw", packageManager: "pnpm@11.2.2" }),
"utf8",
);
it.each([
{ manager: "npm", expectedLockfile: "package-lock.json" },
{ manager: "bun", expectedLockfile: "bun.lockb" },
])(
"detects lockless OpenClaw $manager installs despite packed pnpm metadata",
async ({ manager, expectedLockfile }) => {
await withTempDir({ prefix: `openclaw-update-check-lockless-${manager}-` }, async (base) => {
const bunInstall = path.join(base, "custom-bun-home");
const root =
manager === "bun"
? path.join(bunInstall, "install", "global", "node_modules", "openclaw")
: path.join(base, "prefix", "node_modules", "openclaw");
await fs.mkdir(root, { recursive: true });
await fs.writeFile(
path.join(root, "package.json"),
JSON.stringify({ name: "openclaw", packageManager: "pnpm@11.2.2" }),
"utf8",
);
const status = await checkUpdateStatus({
root,
includeRegistry: false,
fetchGit: false,
timeoutMs: 1000,
});
await withEnvAsync({ BUN_INSTALL: bunInstall }, async () => {
const status = await checkUpdateStatus({
root,
includeRegistry: false,
fetchGit: false,
timeoutMs: 1000,
});
expect(status.installKind).toBe("package");
expect(status.packageManager).toBe("npm");
expect(status.deps).toMatchObject({
manager: "npm",
lockfilePath: path.join(root, "package-lock.json"),
status: "unknown",
reason: "lockfile missing",
expect(status.installKind).toBe("package");
expect(status.packageManager).toBe(manager);
expect(status.deps).toMatchObject({
manager,
lockfilePath: path.join(root, expectedLockfile),
status: "unknown",
reason: "lockfile missing",
});
});
});
});
});
},
);
it("reports missing and stale dependency markers for package installs", async () => {
await withTempDir({ prefix: "openclaw-update-check-deps-" }, async (root) => {