fix(update): stage npm-prefix package updates cleanly

Co-authored-by: Josh Lehman <josh@martian.engineering>
This commit is contained in:
Vincent Koc
2026-05-04 21:23:53 +01:00
committed by Peter Steinberger
parent be8b4dc845
commit b63336186a
3 changed files with 182 additions and 6 deletions

View File

@@ -29,6 +29,15 @@ function createNpmTarget(globalRoot: string): ResolvedGlobalInstallTarget {
};
}
function createPnpmTarget(globalRoot: string): ResolvedGlobalInstallTarget {
return {
manager: "pnpm",
command: "pnpm",
globalRoot,
packageRoot: path.join(globalRoot, "openclaw"),
};
}
function createRootRunner(globalRoot: string): CommandRunner {
return async (argv) => {
if (argv.join(" ") === "npm root -g") {
@@ -115,6 +124,99 @@ describe("runGlobalPackageUpdateSteps", () => {
});
});
it("stages pnpm-detected updates through npm when the global root has npm prefix layout", async () => {
await withTempDir({ prefix: "openclaw-package-update-pnpm-staged-" }, async (base) => {
const prefix = path.join(base, "prefix");
const globalRoot = path.join(prefix, "lib", "node_modules");
const packageRoot = path.join(globalRoot, "openclaw");
const staleChunk = path.join(packageRoot, "dist", "install-C_GuuNz6.js");
await writePackageRoot(packageRoot, "1.0.0");
await fs.writeFile(staleChunk, 'import "./install.runtime-Xom5hOHq.js";\n', "utf8");
const runStep = vi.fn(async ({ name, argv, cwd }): Promise<PackageUpdateStepResult> => {
if (name !== "global update") {
throw new Error(`unexpected step ${name}`);
}
expect(argv[0]).toBe("npm");
expect(argv).toEqual(expect.arrayContaining(["i", "-g", "--prefix", "openclaw@2.0.0"]));
expect(argv).not.toContain("pnpm");
const prefixIndex = argv.indexOf("--prefix");
const stagePrefix = argv[prefixIndex + 1];
if (!stagePrefix) {
throw new Error("missing staged prefix");
}
await writePackageRoot(path.join(stagePrefix, "lib", "node_modules", "openclaw"), "2.0.0");
return {
name,
command: argv.join(" "),
cwd: cwd ?? process.cwd(),
durationMs: 1,
exitCode: 0,
};
});
const result = await runGlobalPackageUpdateSteps({
installTarget: createPnpmTarget(globalRoot),
installSpec: "openclaw@2.0.0",
packageName: "openclaw",
packageRoot,
runCommand: createRootRunner(globalRoot),
runStep,
timeoutMs: 1000,
});
expect(result.failedStep).toBeNull();
expect(result.afterVersion).toBe("2.0.0");
expect(result.steps.map((step) => step.name)).toEqual([
"global update",
"global install swap",
]);
await expect(fs.access(staleChunk)).rejects.toMatchObject({ code: "ENOENT" });
});
});
it("keeps Windows pnpm global roots on the pnpm update path", async () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
try {
await withTempDir({ prefix: "openclaw-package-update-win32-pnpm-" }, async (base) => {
const globalRoot = path.join(base, "pnpm", "global", "5", "node_modules");
const packageRoot = path.join(globalRoot, "openclaw");
await writePackageRoot(packageRoot, "1.0.0");
const runStep = vi.fn(async ({ name, argv, cwd }): Promise<PackageUpdateStepResult> => {
if (name !== "global update") {
throw new Error(`unexpected step ${name}`);
}
expect(argv).toEqual(["pnpm", "add", "-g", "openclaw@2.0.0"]);
await writePackageRoot(packageRoot, "2.0.0");
return {
name,
command: argv.join(" "),
cwd: cwd ?? process.cwd(),
durationMs: 1,
exitCode: 0,
};
});
const result = await runGlobalPackageUpdateSteps({
installTarget: createPnpmTarget(globalRoot),
installSpec: "openclaw@2.0.0",
packageName: "openclaw",
packageRoot,
runCommand: createRootRunner(globalRoot),
runStep,
timeoutMs: 1000,
});
expect(result.failedStep).toBeNull();
expect(result.afterVersion).toBe("2.0.0");
expect(result.steps.map((step) => step.name)).toEqual(["global update"]);
});
} finally {
platformSpy.mockRestore();
}
});
it("keeps a successful staged swap when old package cleanup hits a transient Windows native module error", async () => {
await withTempDir({ prefix: "openclaw-package-update-staged-cleanup-" }, async (base) => {
const prefix = path.join(base, "prefix");

View File

@@ -36,6 +36,7 @@ type StagedNpmInstall = {
prefix: string;
layout: NpmGlobalPrefixLayout;
packageRoot: string;
installTarget: ResolvedGlobalInstallTarget;
};
type NpmBinShimBackup = {
@@ -82,24 +83,60 @@ async function readPackageVersionIfPresent(packageRoot: string | null): Promise<
}
}
function isUnambiguousNpmPrefixGlobalRoot(globalRoot: string | null): boolean {
const trimmed = globalRoot?.trim();
if (!trimmed) {
return false;
}
const normalized = path.resolve(trimmed);
if (path.basename(normalized) !== "node_modules") {
return false;
}
const parentDir = path.dirname(normalized);
if (path.basename(parentDir) === "lib") {
return true;
}
return process.platform === "win32" && path.basename(parentDir).toLowerCase() === "npm";
}
function resolveStagedNpmTargetLayout(
installTarget: ResolvedGlobalInstallTarget,
): NpmGlobalPrefixLayout | null {
const targetLayout = resolveNpmGlobalPrefixLayoutFromGlobalRoot(installTarget.globalRoot);
if (!targetLayout) {
return null;
}
if (
installTarget.manager === "npm" ||
isUnambiguousNpmPrefixGlobalRoot(installTarget.globalRoot)
) {
return targetLayout;
}
return null;
}
async function createStagedNpmInstall(
installTarget: ResolvedGlobalInstallTarget,
packageName: string,
): Promise<StagedNpmInstall | null> {
if (installTarget.manager !== "npm") {
return null;
}
const targetLayout = resolveNpmGlobalPrefixLayoutFromGlobalRoot(installTarget.globalRoot);
const targetLayout = resolveStagedNpmTargetLayout(installTarget);
if (!targetLayout) {
return null;
}
await fs.mkdir(targetLayout.globalRoot, { recursive: true });
const prefix = await fs.mkdtemp(path.join(targetLayout.globalRoot, ".openclaw-update-stage-"));
const layout = resolveNpmGlobalPrefixLayoutFromPrefix(prefix);
const command = installTarget.manager === "npm" ? installTarget.command : "npm";
return {
prefix,
layout,
packageRoot: path.join(layout.globalRoot, packageName),
installTarget: {
manager: "npm",
command,
globalRoot: layout.globalRoot,
packageRoot: path.join(layout.globalRoot, packageName),
},
};
}
@@ -329,10 +366,11 @@ export async function runGlobalPackageUpdateSteps(params: {
};
}
const installCommandTarget = stagedInstall?.installTarget ?? params.installTarget;
const updateStep = await params.runStep({
name: "global update",
argv: globalInstallArgs(
params.installTarget,
installCommandTarget,
params.installSpec,
undefined,
stagedInstall?.prefix,
@@ -363,7 +401,7 @@ export async function runGlobalPackageUpdateSteps(params: {
}
const fallbackArgv = globalInstallFallbackArgs(
params.installTarget,
stagedInstall?.installTarget ?? params.installTarget,
params.installSpec,
undefined,
stagedInstall?.prefix,

View File

@@ -1365,6 +1365,7 @@ describe("runGatewayUpdate", () => {
const createGlobalInstallHarness = (params: {
pkgRoot: string;
npmRootOutput?: string;
pnpmRootOutput?: string;
installCommand: string;
gitRootMode?: "not-git" | "missing";
onInstall?: (options?: {
@@ -1390,6 +1391,9 @@ describe("runGatewayUpdate", () => {
return { stdout: "", stderr: "", code: 1 };
}
if (key === "pnpm root -g") {
if (params.pnpmRootOutput) {
return { stdout: params.pnpmRootOutput, stderr: "", code: 0 };
}
return { stdout: "", stderr: "", code: 1 };
}
if (key === params.installCommand) {
@@ -1747,6 +1751,38 @@ describe("runGatewayUpdate", () => {
);
});
it("uses clean staged npm swaps for pnpm installs that resolve to an npm global root", async () => {
const prefix = path.join(tempDir, "npm-prefix");
const nodeModules = path.join(prefix, "lib", "node_modules");
const pkgRoot = path.join(nodeModules, "openclaw");
const staleInstallChunk = path.join(pkgRoot, "dist", "install-C_GuuNz6.js");
await seedGlobalPackageRoot(pkgRoot);
await fs.writeFile(
staleInstallChunk,
'const pluginRuntime = () => import("./install.runtime-Xom5hOHq.js");\n',
"utf-8",
);
const { calls, runCommand } = createGlobalInstallHarness({
pkgRoot,
pnpmRootOutput: nodeModules,
installCommand: "npm i -g openclaw@latest --no-fund --no-audit --loglevel=error",
onInstall: async (options) => {
await writeGlobalPackageVersion(options?.packageRoot ?? pkgRoot);
},
});
const result = await runWithCommand(runCommand, { cwd: pkgRoot });
expect(result.status).toBe("ok");
expect(result.mode).toBe("pnpm");
expect(result.after?.version).toBe("2.0.0");
expect(calls.some((call) => call.startsWith("npm i -g --prefix "))).toBe(true);
expect(calls.some((call) => call.startsWith("pnpm add -g"))).toBe(false);
expect(result.steps.map((step) => step.name)).toEqual(["global update", "global install swap"]);
await expect(fs.access(staleInstallChunk)).rejects.toMatchObject({ code: "ENOENT" });
});
it("uses OPENCLAW_UPDATE_PACKAGE_SPEC for global package updates", async () => {
const { nodeModules, pkgRoot } = await createGlobalPackageFixture(tempDir);
const expectedInstallCommand =