diff --git a/CHANGELOG.md b/CHANGELOG.md index e64b0c22921..d9873c6b240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,7 @@ Docs: https://docs.openclaw.ai - Web fetch: bound guarded dispatcher cleanup after request timeouts so timed-out fetches return tool errors instead of leaving Gateway tool lanes active. (#78439) Thanks @obviyus. - Gate Slack startup user allowlist resolution [AI]. (#77898) Thanks @pgondhi987. - OpenAI/Codex: suppress stale `openai-codex` GPT-5.1/5.2/5.3 model refs that ChatGPT/Codex OAuth accounts now reject, keeping model lists, config validation, and forward-compat resolution on current 5.4/5.5 routes. Fixes #67158. Thanks @drpau. +- CLI/update: keep pnpm package updates on the running custom global install root and pass pnpm's `--global-dir` so `openclaw update` does not create a second default-prefix install when `OPENCLAW_HOME` or the shell points at a custom OpenClaw directory. Fixes #78377. Thanks @amknight. - Google Meet/Voice Call: wait longer before playing PIN-derived Twilio DTMF for Meet dial-in prompts and retire stale delegated phone sessions instead of reusing completed calls. - PDF/Codex: include extraction-fallback instructions for `openai-codex/*` PDF tool requests so Codex Responses receives its required system prompt. Fixes #77872. Thanks @anyech. - Onboard/channels: recover externalized channel plugins from stale `channels.` config by falling back to `ensureChannelSetupPluginInstalled` via the trusted catalog when the plugin is missing on disk, so leftover `appId`/token entries no longer dead-end onboard with " plugin not available." (#78328) Thanks @sliverp. diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 9f899218beb..d3965ac0eb4 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -52,6 +52,7 @@ import { globalInstallArgs, resolveGlobalInstallTarget, resolveGlobalInstallSpec, + resolvePnpmGlobalDirFromGlobalRoot, } from "../../infra/update-global.js"; import { runGatewayUpdate, type UpdateRunResult } from "../../infra/update-runner.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "../../plugins/config-state.js"; @@ -1070,9 +1071,13 @@ async function runGitUpdate(params: { timeoutMs: effectiveTimeout, pkgRoot: params.root, }); + const installLocation = + installTarget.manager === "pnpm" + ? resolvePnpmGlobalDirFromGlobalRoot(installTarget.globalRoot) + : null; const installStep = await runUpdateStep({ name: "global install", - argv: globalInstallArgs(installTarget, updateRoot), + argv: globalInstallArgs(installTarget, updateRoot, undefined, installLocation), cwd: updateRoot, env: installEnv, timeoutMs: effectiveTimeout, diff --git a/src/infra/package-update-steps.test.ts b/src/infra/package-update-steps.test.ts index 91583dbc4ec..aee1ee693d1 100644 --- a/src/infra/package-update-steps.test.ts +++ b/src/infra/package-update-steps.test.ts @@ -179,7 +179,8 @@ describe("runGlobalPackageUpdateSteps", () => { 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 globalDir = path.join(base, "pnpm", "global"); + const globalRoot = path.join(globalDir, "5", "node_modules"); const packageRoot = path.join(globalRoot, "openclaw"); await writePackageRoot(packageRoot, "1.0.0"); @@ -187,7 +188,7 @@ describe("runGlobalPackageUpdateSteps", () => { if (name !== "global update") { throw new Error(`unexpected step ${name}`); } - expect(argv).toEqual(["pnpm", "add", "-g", "openclaw@2.0.0"]); + expect(argv).toEqual(["pnpm", "add", "-g", "--global-dir", globalDir, "openclaw@2.0.0"]); await writePackageRoot(packageRoot, "2.0.0"); return { name, diff --git a/src/infra/package-update-steps.ts b/src/infra/package-update-steps.ts index 0ce5d02fd74..ead65b46e2d 100644 --- a/src/infra/package-update-steps.ts +++ b/src/infra/package-update-steps.ts @@ -8,6 +8,7 @@ import { globalInstallFallbackArgs, resolveNpmGlobalPrefixLayoutFromGlobalRoot, resolveNpmGlobalPrefixLayoutFromPrefix, + resolvePnpmGlobalDirFromGlobalRoot, resolveExpectedInstalledVersionFromSpec, resolveGlobalInstallTarget, type CommandRunner, @@ -359,14 +360,14 @@ export async function runGlobalPackageUpdateSteps(params: { } const installCommandTarget = stagedInstall?.installTarget ?? params.installTarget; + const installLocation = + stagedInstall?.prefix ?? + (installCommandTarget.manager === "pnpm" + ? resolvePnpmGlobalDirFromGlobalRoot(installCommandTarget.globalRoot) + : null); const updateStep = await params.runStep({ name: "global update", - argv: globalInstallArgs( - installCommandTarget, - params.installSpec, - undefined, - stagedInstall?.prefix, - ), + argv: globalInstallArgs(installCommandTarget, params.installSpec, undefined, installLocation), ...installCwd, ...installEnv, timeoutMs: params.timeoutMs, diff --git a/src/infra/update-global.test.ts b/src/infra/update-global.test.ts index e399aba43ac..e022a9401f1 100644 --- a/src/infra/update-global.test.ts +++ b/src/infra/update-global.test.ts @@ -29,6 +29,7 @@ import { resolveGlobalRoot, resolveNpmGlobalPrefixLayoutFromGlobalRoot, resolveNpmGlobalPrefixLayoutFromPrefix, + resolvePnpmGlobalDirFromGlobalRoot, type CommandRunner, } from "./update-global.js"; @@ -416,6 +417,151 @@ describe("update global helpers", () => { } }); + it("detects custom pnpm global layouts from the running package root", async () => { + await withTempDir({ prefix: "openclaw-update-pnpm-custom-root-" }, async (base) => { + const customGlobalDir = path.join(base, "custom-pnpm"); + const customGlobalRoot = path.join(customGlobalDir, "5", "node_modules"); + const pkgRoot = path.join(customGlobalRoot, "openclaw"); + const defaultPnpmRoot = path.join(base, "default-pnpm", "5", "node_modules"); + await fs.mkdir(pkgRoot, { recursive: true }); + await fs.writeFile( + path.join(customGlobalDir, "5", "pnpm-lock.yaml"), + "lockfileVersion: '9.0'\n", + "utf8", + ); + await fs.writeFile( + path.join(customGlobalRoot, ".modules.yaml"), + "layoutVersion: 5\n", + "utf8", + ); + + const runCommand: CommandRunner = async (argv) => { + if (argv[0] === "npm") { + return { stdout: "", stderr: "", code: 1 }; + } + if (argv[0] === "pnpm") { + return { stdout: `${defaultPnpmRoot}\n`, stderr: "", code: 0 }; + } + throw new Error(`unexpected command: ${argv.join(" ")}`); + }; + + await expect(detectGlobalInstallManagerForRoot(runCommand, pkgRoot, 1000)).resolves.toBe( + "pnpm", + ); + await expect( + resolveGlobalInstallTarget({ + manager: "pnpm", + runCommand, + timeoutMs: 1000, + pkgRoot, + }), + ).resolves.toEqual({ + manager: "pnpm", + command: "pnpm", + globalRoot: customGlobalRoot, + packageRoot: pkgRoot, + }); + expect(resolvePnpmGlobalDirFromGlobalRoot(customGlobalRoot)).toBe(customGlobalDir); + }); + }); + + it("detects custom pnpm global layouts from virtual-store package roots", async () => { + await withTempDir({ prefix: "openclaw-update-pnpm-virtual-root-" }, async (base) => { + const customGlobalDir = path.join(base, "custom-pnpm"); + const customGlobalRoot = path.join(customGlobalDir, "5", "node_modules"); + const pkgRoot = path.join( + customGlobalDir, + "5", + ".pnpm", + "openclaw@file+..+pack+openclaw-2026.5.6.tgz", + "node_modules", + "openclaw", + ); + const defaultPnpmRoot = path.join(base, "default-pnpm", "5", "node_modules"); + await fs.mkdir(customGlobalRoot, { recursive: true }); + await fs.mkdir(pkgRoot, { recursive: true }); + await fs.writeFile( + path.join(customGlobalDir, "5", "pnpm-lock.yaml"), + "lockfileVersion: '9.0'\n", + "utf8", + ); + await fs.writeFile( + path.join(customGlobalRoot, ".modules.yaml"), + "layoutVersion: 5\n", + "utf8", + ); + + const runCommand: CommandRunner = async (argv) => { + if (argv[0] === "npm") { + return { stdout: "", stderr: "", code: 1 }; + } + if (argv[0] === "pnpm") { + return { stdout: `${defaultPnpmRoot}\n`, stderr: "", code: 0 }; + } + throw new Error(`unexpected command: ${argv.join(" ")}`); + }; + + await expect(detectGlobalInstallManagerForRoot(runCommand, pkgRoot, 1000)).resolves.toBe( + "pnpm", + ); + await expect( + resolveGlobalInstallTarget({ + manager: "pnpm", + runCommand, + timeoutMs: 1000, + pkgRoot, + }), + ).resolves.toEqual({ + manager: "pnpm", + command: "pnpm", + globalRoot: customGlobalRoot, + packageRoot: path.join(customGlobalRoot, "openclaw"), + }); + }); + }); + + it("does not infer pnpm ownership without pnpm node_modules metadata", async () => { + await withTempDir({ prefix: "openclaw-update-pnpm-shape-only-" }, async (base) => { + const customGlobalDir = path.join(base, "custom-pnpm"); + const customGlobalRoot = path.join(customGlobalDir, "5", "node_modules"); + const pkgRoot = path.join(customGlobalRoot, "openclaw"); + const defaultPnpmRoot = path.join(base, "default-pnpm", "5", "node_modules"); + await fs.mkdir(pkgRoot, { recursive: true }); + await fs.writeFile( + path.join(customGlobalDir, "5", "pnpm-lock.yaml"), + "lockfileVersion: '9.0'\n", + "utf8", + ); + + const runCommand: CommandRunner = async (argv) => { + if (argv[0] === "npm") { + return { stdout: "", stderr: "", code: 1 }; + } + if (argv[0] === "pnpm") { + return { stdout: `${defaultPnpmRoot}\n`, stderr: "", code: 0 }; + } + throw new Error(`unexpected command: ${argv.join(" ")}`); + }; + + await expect( + detectGlobalInstallManagerForRoot(runCommand, pkgRoot, 1000), + ).resolves.toBeNull(); + await expect( + resolveGlobalInstallTarget({ + manager: "pnpm", + runCommand, + timeoutMs: 1000, + pkgRoot, + }), + ).resolves.toEqual({ + manager: "pnpm", + command: "pnpm", + globalRoot: defaultPnpmRoot, + packageRoot: path.join(defaultPnpmRoot, "openclaw"), + }); + }); + }); + it("builds install argv and npm fallback argv", () => { expect(resolveGlobalInstallCommand("npm")).toEqual({ manager: "npm", @@ -457,6 +603,14 @@ describe("update global helpers", () => { expect( globalInstallArgs({ manager: "pnpm", command: "/opt/homebrew/bin/pnpm" }, "openclaw@latest"), ).toEqual(["/opt/homebrew/bin/pnpm", "add", "-g", "openclaw@latest"]); + expect(globalInstallArgs("pnpm", "openclaw@latest", null, "/opt/pnpm-global")).toEqual([ + "pnpm", + "add", + "-g", + "--global-dir", + "/opt/pnpm-global", + "openclaw@latest", + ]); }); it("builds npm staged install argv with an explicit prefix", () => { diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts index e1628e64380..45d24c4c3fd 100644 --- a/src/infra/update-global.ts +++ b/src/infra/update-global.ts @@ -484,6 +484,69 @@ function resolvePreferredNpmCommand(pkgRoot?: string | null): string | null { return fsSync.existsSync(candidate) ? candidate : null; } +function inferGlobalRootFromPackageRoot(pkgRoot?: string | null): string | null { + const trimmed = pkgRoot?.trim(); + if (!trimmed) { + return null; + } + const normalized = path.resolve(trimmed); + const globalRoot = path.dirname(normalized); + return path.basename(globalRoot) === "node_modules" ? globalRoot : null; +} + +function inferPnpmGlobalRootFromPackageRoot(pkgRoot?: string | null): string | null { + const directGlobalRoot = inferGlobalRootFromPackageRoot(pkgRoot); + if (resolvePnpmGlobalDirFromGlobalRoot(directGlobalRoot)) { + return directGlobalRoot; + } + + const trimmed = pkgRoot?.trim(); + if (!trimmed) { + return null; + } + const normalized = path.resolve(trimmed); + const parts = normalized.split(path.sep); + const pnpmIndex = parts.lastIndexOf(".pnpm"); + if (pnpmIndex <= 0) { + return null; + } + if (parts[pnpmIndex + 2] !== "node_modules") { + return null; + } + const layoutDir = parts.slice(0, pnpmIndex).join(path.sep) || path.sep; + const globalRoot = + path.basename(layoutDir) === "node_modules" ? layoutDir : path.join(layoutDir, "node_modules"); + return resolvePnpmGlobalDirFromGlobalRoot(globalRoot) ? globalRoot : null; +} + +export function resolvePnpmGlobalDirFromGlobalRoot(globalRoot?: string | null): string | null { + const trimmed = globalRoot?.trim(); + if (!trimmed) { + return null; + } + const normalized = path.resolve(trimmed); + if (path.basename(normalized) !== "node_modules") { + return null; + } + const layoutDir = path.dirname(normalized); + return /^\d+$/u.test(path.basename(layoutDir)) ? path.dirname(layoutDir) : null; +} + +async function isPnpmGlobalPackageRoot(pkgRoot?: string | null): Promise { + const globalRoot = inferPnpmGlobalRootFromPackageRoot(pkgRoot); + if (!globalRoot) { + return false; + } + const layoutDir = path.dirname(globalRoot); + if (!(await pathExists(path.join(globalRoot, ".modules.yaml")))) { + return false; + } + return ( + (await pathExists(path.join(layoutDir, "pnpm-lock.yaml"))) || + (await pathExists(path.join(layoutDir, "package.json"))) + ); +} + function resolvePreferredGlobalManagerCommand( manager: GlobalInstallManager, pkgRoot?: string | null, @@ -558,10 +621,15 @@ export async function resolveGlobalInstallTarget(params: { params.timeoutMs, params.pkgRoot, ); + const pkgRootGlobalRoot = + command.manager === "pnpm" && (await isPnpmGlobalPackageRoot(params.pkgRoot)) + ? inferPnpmGlobalRootFromPackageRoot(params.pkgRoot) + : null; + const targetGlobalRoot = pkgRootGlobalRoot ?? globalRoot; return { ...command, - globalRoot, - packageRoot: globalRoot ? path.join(globalRoot, PRIMARY_PACKAGE_NAME) : null, + globalRoot: targetGlobalRoot, + packageRoot: targetGlobalRoot ? path.join(targetGlobalRoot, PRIMARY_PACKAGE_NAME) : null, }; } @@ -599,6 +667,10 @@ export async function detectGlobalInstallManagerForRoot( } } + if (await isPnpmGlobalPackageRoot(pkgRoot)) { + return "pnpm"; + } + const bunGlobalRoot = resolveBunGlobalRoot(); const bunGlobalReal = await tryRealpath(bunGlobalRoot); for (const name of ALL_PACKAGE_NAMES) { @@ -649,7 +721,13 @@ export function globalInstallArgs( ): string[] { const resolved = normalizeGlobalInstallCommand(managerOrCommand, pkgRoot); if (resolved.manager === "pnpm") { - return [resolved.command, "add", "-g", spec]; + return [ + resolved.command, + "add", + "-g", + ...(installPrefix ? ["--global-dir", installPrefix] : []), + spec, + ]; } if (resolved.manager === "bun") { return [resolved.command, "add", "-g", spec];