diff --git a/src/infra/package-update-steps.test.ts b/src/infra/package-update-steps.test.ts index f74a0952e4e..91583dbc4ec 100644 --- a/src/infra/package-update-steps.test.ts +++ b/src/infra/package-update-steps.test.ts @@ -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 => { + 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 => { + 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"); diff --git a/src/infra/package-update-steps.ts b/src/infra/package-update-steps.ts index 18bcb372568..3a3f15e7cd9 100644 --- a/src/infra/package-update-steps.ts +++ b/src/infra/package-update-steps.ts @@ -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 { - 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, diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index edaf8b6147e..c54094981fb 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -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 =