diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f4ff903f9b..54b4a8a26d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai - Gateway/device tokens: stop echoing rotated bearer tokens from shared/admin `device.token.rotate` responses while preserving the same-device token handoff needed by token-only clients before reconnect. (#66773) Thanks @MoerAI. - Agents/subagents: enforce `subagents.allowAgents` for explicit same-agent `sessions_spawn(agentId=...)` calls instead of auto-allowing requester self-targets. Fixes #72827. Thanks @oiGaDio. - ACP/sessions_spawn: let explicit `sessions_spawn(runtime="acp")` bootstrap turns run while `acp.dispatch.enabled=false` still blocks automatic ACP thread dispatch. Fixes #63591. Thanks @moeedahmed. +- CLI/update: install npm global updates into a verified temporary prefix before swapping the package tree into place, preventing mixed old/new installs and stale packaged files from breaking `openclaw update` verification. Thanks @shakkernerd. - Gateway: skip CLI startup self-respawn for foreground gateway runs so low-memory Linux/Node 24 hosts start through the same path as direct `dist/index.js` without hanging before logs. Fixes #72720. Thanks @sign-2025. - Google Meet: grant Meet media permissions through browser control and pin local Chrome audio defaults to `BlackHole 2ch`, so joined agents no longer show `Permission needed` or use macOS default audio devices. Thanks @DougButdorf. - Google Meet: route local Chrome joins through OpenClaw browser control instead of raw default Chrome, so agents use the configured OpenClaw browser profile when opening Meet. Thanks @oromeis. diff --git a/docs/cli/update.md b/docs/cli/update.md index 4eeef6822a1..2ea75e736cf 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -85,7 +85,11 @@ install method aligned: The Gateway core auto-updater (when enabled via config) reuses this same update path. For package-manager installs, `openclaw update` resolves the target package -version before invoking the package manager. Even when the installed version +version before invoking the package manager. npm global installs use a staged +install: OpenClaw installs the new package into a temporary npm prefix, verifies +the packaged `dist` inventory there, then swaps that clean package tree into the +real global prefix. If verification fails, post-update doctor, plugin sync, and +restart work do not run from the suspect tree. Even when the installed version already matches the target, the command refreshes the global package install, then runs plugin sync, a core-command completion refresh, and restart work. This keeps packaged sidecars and channel-owned plugin records aligned with the diff --git a/docs/install/updating.md b/docs/install/updating.md index 92c036d7e3e..e967c5e6224 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -87,11 +87,13 @@ curl -fsSL https://openclaw.ai/install.sh | bash -s -- --install-method npm --ve npm i -g openclaw@latest ``` -When `openclaw update` manages a global npm install, it first runs the normal -global install command. If that command fails, OpenClaw retries once with -`--omit=optional`. That retry helps hosts where native optional dependencies -cannot compile, while keeping the original failure visible if the fallback also -fails. +When `openclaw update` manages a global npm install, it installs the target into +a temporary npm prefix first, verifies the packaged `dist` inventory, then swaps +the clean package tree into the real global prefix. That avoids npm overlaying a +new package onto stale files from the old package. If the install command fails, +OpenClaw retries once with `--omit=optional`. That retry helps hosts where native +optional dependencies cannot compile, while keeping the original failure visible +if the fallback also fails. ```bash pnpm add -g openclaw@latest diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index b72a0e0c501..dc1fa147cab 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -1227,6 +1227,88 @@ describe("update-cli", () => { expect(logs.join("\n")).toContain("expected installed version 2026.3.23-2, found 2026.3.23"); }); + it("stops package post-update work when staged npm install verification fails", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-staged-fail-")); + const prefix = path.join(tempDir, "prefix"); + const nodeModules = path.join(prefix, "lib", "node_modules"); + const pkgRoot = path.join(nodeModules, "openclaw"); + mockPackageInstallStatus(pkgRoot); + readPackageVersion.mockResolvedValue("2026.4.20"); + vi.mocked(resolveNpmChannelTag).mockResolvedValue({ + tag: "latest", + version: "2026.4.25", + }); + await fs.mkdir(path.join(pkgRoot, "dist"), { recursive: true }); + await fs.writeFile( + path.join(pkgRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.4.20" }), + "utf-8", + ); + await fs.writeFile(path.join(pkgRoot, "dist", "index.js"), "export {};\n", "utf-8"); + await writePackageDistInventory(pkgRoot); + + vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => { + if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") { + return { + stdout: `${nodeModules}\n`, + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }; + } + if ( + Array.isArray(argv) && + argv[0] === "npm" && + argv[1] === "i" && + argv.includes("--prefix") + ) { + const stagePrefix = argv[argv.indexOf("--prefix") + 1]; + if (typeof stagePrefix !== "string") { + throw new Error("missing stage prefix"); + } + const stageRoot = path.join(stagePrefix, "lib", "node_modules", "openclaw"); + await fs.mkdir(path.join(stageRoot, "dist"), { recursive: true }); + await fs.writeFile( + path.join(stageRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.4.25" }), + "utf-8", + ); + await fs.writeFile(path.join(stageRoot, "dist", "index.js"), "export {};\n", "utf-8"); + await writePackageDistInventory(stageRoot); + await fs.writeFile( + path.join(stageRoot, "dist", "stale-runtime.js"), + "export {};\n", + "utf-8", + ); + } + return { + stdout: "", + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }; + }); + + await updateCommand({ yes: true, restart: false }); + + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + expect(runCommandWithTimeout).not.toHaveBeenCalledWith( + [expect.stringMatching(/node/), expect.any(String), "doctor", "--non-interactive", "--fix"], + expect.any(Object), + ); + expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); + await expect(fs.readFile(path.join(pkgRoot, "package.json"), "utf-8")).resolves.toContain( + '"version":"2026.4.20"', + ); + const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0])); + expect(logs.join("\n")).toContain("global install verify"); + expect(logs.join("\n")).toContain("unexpected packaged dist file dist/stale-runtime.js"); + }); + it("marks package post-update doctor as update-in-progress", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-package-")); const nodeModules = path.join(tempDir, "node_modules"); @@ -1492,7 +1574,7 @@ describe("update-cli", () => { isOwningNpmCommand(argv[0], brewPrefix) && argv[1] === "i" && argv[2] === "-g" && - argv[3] === "openclaw@latest", + argv.includes("openclaw@latest"), ); expect(installCall).toBeDefined(); diff --git a/src/infra/package-update-steps.test.ts b/src/infra/package-update-steps.test.ts new file mode 100644 index 00000000000..fc7eb538fd4 --- /dev/null +++ b/src/infra/package-update-steps.test.ts @@ -0,0 +1,167 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { withTempDir } from "../test-helpers/temp-dir.js"; +import { writePackageDistInventory } from "./package-dist-inventory.js"; +import { + runGlobalPackageUpdateSteps, + type PackageUpdateStepResult, +} from "./package-update-steps.js"; +import type { CommandRunner, ResolvedGlobalInstallTarget } from "./update-global.js"; + +async function writePackageRoot(packageRoot: string, version: string): Promise { + await fs.mkdir(path.join(packageRoot, "dist"), { recursive: true }); + await fs.writeFile( + path.join(packageRoot, "package.json"), + JSON.stringify({ name: "openclaw", version }), + "utf8", + ); + await fs.writeFile(path.join(packageRoot, "dist", "index.js"), "export {};\n", "utf8"); + await writePackageDistInventory(packageRoot); +} + +function createNpmTarget(globalRoot: string): ResolvedGlobalInstallTarget { + return { + manager: "npm", + command: "npm", + globalRoot, + packageRoot: path.join(globalRoot, "openclaw"), + }; +} + +function createRootRunner(globalRoot: string): CommandRunner { + return async (argv) => { + if (argv.join(" ") === "npm root -g") { + return { stdout: `${globalRoot}\n`, stderr: "", code: 0 }; + } + throw new Error(`unexpected command: ${argv.join(" ")}`); + }; +} + +describe("runGlobalPackageUpdateSteps", () => { + it("installs npm updates into a clean staged prefix before swapping the global package", async () => { + await withTempDir({ prefix: "openclaw-package-update-staged-" }, async (base) => { + const prefix = path.join(base, "prefix"); + const globalRoot = path.join(prefix, "lib", "node_modules"); + const packageRoot = path.join(globalRoot, "openclaw"); + await writePackageRoot(packageRoot, "1.0.0"); + await fs.mkdir(path.join(packageRoot, "dist", "extensions", "qa-channel"), { + recursive: true, + }); + await fs.writeFile( + path.join(packageRoot, "dist", "extensions", "qa-channel", "runtime-api.js"), + "export {};\n", + "utf8", + ); + + const runStep = vi.fn( + async ({ name, argv, cwd, timeoutMs }): Promise => { + expect(timeoutMs).toBe(1000); + if (name !== "global update") { + throw new Error(`unexpected step ${name}`); + } + const prefixIndex = argv.indexOf("--prefix"); + expect(prefixIndex).toBeGreaterThan(0); + 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", + ); + await fs.mkdir(path.join(stagePrefix, "bin"), { recursive: true }); + await fs.symlink( + "../lib/node_modules/openclaw/dist/index.js", + path.join(stagePrefix, "bin", "openclaw"), + ); + return { + name, + command: argv.join(" "), + cwd: cwd ?? process.cwd(), + durationMs: 1, + exitCode: 0, + }; + }, + ); + + const result = await runGlobalPackageUpdateSteps({ + installTarget: createNpmTarget(globalRoot), + installSpec: "openclaw@2.0.0", + packageName: "openclaw", + packageRoot, + runCommand: createRootRunner(globalRoot), + runStep, + timeoutMs: 1000, + }); + + expect(result.failedStep).toBeNull(); + expect(result.verifiedPackageRoot).toBe(packageRoot); + expect(result.afterVersion).toBe("2.0.0"); + expect(result.steps.map((step) => step.name)).toEqual([ + "global update", + "global install swap", + ]); + await expect(fs.readFile(path.join(packageRoot, "package.json"), "utf8")).resolves.toContain( + '"version":"2.0.0"', + ); + await expect( + fs.access(path.join(packageRoot, "dist", "extensions", "qa-channel", "runtime-api.js")), + ).rejects.toMatchObject({ code: "ENOENT" }); + await expect(fs.readlink(path.join(prefix, "bin", "openclaw"))).resolves.toBe( + "../lib/node_modules/openclaw/dist/index.js", + ); + }); + }); + + it("does not run post-verify work when staged npm verification fails", async () => { + await withTempDir({ prefix: "openclaw-package-update-verify-" }, async (base) => { + const prefix = path.join(base, "prefix"); + const globalRoot = path.join(prefix, "lib", "node_modules"); + const packageRoot = path.join(globalRoot, "openclaw"); + await writePackageRoot(packageRoot, "1.0.0"); + const postVerifyStep = vi.fn(); + + const result = await runGlobalPackageUpdateSteps({ + installTarget: createNpmTarget(globalRoot), + installSpec: "openclaw@2.0.0", + packageName: "openclaw", + packageRoot, + runCommand: createRootRunner(globalRoot), + runStep: async ({ name, argv, cwd }) => { + 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"), + "1.5.0", + ); + return { + name, + command: argv.join(" "), + cwd: cwd ?? process.cwd(), + durationMs: 1, + exitCode: 0, + }; + }, + timeoutMs: 1000, + postVerifyStep, + }); + + expect(result.failedStep?.name).toBe("global install verify"); + expect(result.steps.map((step) => step.name)).toEqual([ + "global update", + "global install verify", + ]); + expect(result.steps.at(-1)?.stderrTail).toContain( + "expected installed version 2.0.0, found 1.5.0", + ); + expect(postVerifyStep).not.toHaveBeenCalled(); + await expect(fs.readFile(path.join(packageRoot, "package.json"), "utf8")).resolves.toContain( + '"version":"1.0.0"', + ); + }); + }); +}); diff --git a/src/infra/package-update-steps.ts b/src/infra/package-update-steps.ts index 4d332c90430..54a7039b231 100644 --- a/src/infra/package-update-steps.ts +++ b/src/infra/package-update-steps.ts @@ -1,11 +1,16 @@ +import fs from "node:fs/promises"; +import path from "node:path"; import { readPackageVersion } from "./package-json.js"; import { collectInstalledGlobalPackageErrors, globalInstallArgs, globalInstallFallbackArgs, + resolveNpmGlobalPrefixLayoutFromGlobalRoot, + resolveNpmGlobalPrefixLayoutFromPrefix, resolveExpectedInstalledVersionFromSpec, resolveGlobalInstallTarget, type CommandRunner, + type NpmGlobalPrefixLayout, type ResolvedGlobalInstallTarget, } from "./update-global.js"; @@ -27,6 +32,170 @@ export type PackageUpdateStepRunner = (params: { env?: NodeJS.ProcessEnv; }) => Promise; +type StagedNpmInstall = { + prefix: string; + layout: NpmGlobalPrefixLayout; + packageRoot: string; +}; + +function formatError(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +async function pathExists(targetPath: string): Promise { + try { + await fs.access(targetPath); + return true; + } catch { + return false; + } +} + +async function createStagedNpmInstall( + installTarget: ResolvedGlobalInstallTarget, + packageName: string, +): Promise { + if (installTarget.manager !== "npm") { + return null; + } + const targetLayout = resolveNpmGlobalPrefixLayoutFromGlobalRoot(installTarget.globalRoot); + if (!targetLayout) { + return null; + } + const prefix = await fs.mkdtemp(path.join(targetLayout.prefix, ".openclaw-update-stage-")); + const layout = resolveNpmGlobalPrefixLayoutFromPrefix(prefix); + return { + prefix, + layout, + packageRoot: path.join(layout.globalRoot, packageName), + }; +} + +async function cleanupStagedNpmInstall(stage: StagedNpmInstall | null): Promise { + if (!stage) { + return; + } + await fs.rm(stage.prefix, { recursive: true, force: true }).catch(() => undefined); +} + +async function copyPathEntry(source: string, destination: string): Promise { + const stat = await fs.lstat(source); + await fs.rm(destination, { recursive: true, force: true }).catch(() => undefined); + if (stat.isSymbolicLink()) { + await fs.symlink(await fs.readlink(source), destination); + return; + } + if (stat.isDirectory()) { + await fs.cp(source, destination, { + recursive: true, + force: true, + preserveTimestamps: false, + }); + return; + } + await fs.copyFile(source, destination); + await fs.chmod(destination, stat.mode).catch(() => undefined); +} + +async function replaceNpmBinShims(params: { + stageLayout: NpmGlobalPrefixLayout; + targetLayout: NpmGlobalPrefixLayout; + packageName: string; +}): Promise { + let entries: string[] = []; + try { + entries = await fs.readdir(params.stageLayout.binDir); + } catch { + return; + } + + const names = new Set([params.packageName, "openclaw"]); + const shimEntries = entries.filter((entry) => { + const parsed = path.parse(entry); + return names.has(entry) || names.has(parsed.name); + }); + if (shimEntries.length === 0) { + return; + } + + await fs.mkdir(params.targetLayout.binDir, { recursive: true }); + for (const entry of shimEntries) { + await copyPathEntry( + path.join(params.stageLayout.binDir, entry), + path.join(params.targetLayout.binDir, entry), + ); + } +} + +async function swapStagedNpmInstall(params: { + stage: StagedNpmInstall; + installTarget: ResolvedGlobalInstallTarget; + packageName: string; +}): Promise { + const startedAt = Date.now(); + const targetLayout = resolveNpmGlobalPrefixLayoutFromGlobalRoot(params.installTarget.globalRoot); + const targetPackageRoot = params.installTarget.packageRoot; + if (!targetLayout || !targetPackageRoot) { + return { + name: "global install swap", + command: "swap staged npm install", + cwd: params.stage.prefix, + durationMs: Date.now() - startedAt, + exitCode: 1, + stdoutTail: null, + stderrTail: "cannot resolve npm global prefix layout", + }; + } + + const backupRoot = path.join(targetLayout.globalRoot, `.openclaw-${process.pid}-${Date.now()}`); + let movedExisting = false; + let movedStaged = false; + try { + await fs.mkdir(targetLayout.globalRoot, { recursive: true }); + if (await pathExists(targetPackageRoot)) { + await fs.rename(targetPackageRoot, backupRoot); + movedExisting = true; + } + await fs.rename(params.stage.packageRoot, targetPackageRoot); + movedStaged = true; + await replaceNpmBinShims({ + stageLayout: params.stage.layout, + targetLayout, + packageName: params.packageName, + }); + if (movedExisting) { + await fs.rm(backupRoot, { recursive: true, force: true }); + } + return { + name: "global install swap", + command: `swap ${params.stage.packageRoot} -> ${targetPackageRoot}`, + cwd: targetLayout.globalRoot, + durationMs: Date.now() - startedAt, + exitCode: 0, + stdoutTail: movedExisting + ? `replaced ${params.packageName}` + : `installed ${params.packageName}`, + stderrTail: null, + }; + } catch (err) { + if (movedStaged) { + await fs.rm(targetPackageRoot, { recursive: true, force: true }).catch(() => undefined); + } + if (movedExisting) { + await fs.rename(backupRoot, targetPackageRoot).catch(() => undefined); + } + return { + name: "global install swap", + command: `swap ${params.stage.packageRoot} -> ${targetPackageRoot}`, + cwd: targetLayout.globalRoot, + durationMs: Date.now() - startedAt, + exitCode: 1, + stdoutTail: null, + stderrTail: formatError(err), + }; + } +} + export async function runGlobalPackageUpdateSteps(params: { installTarget: ResolvedGlobalInstallTarget; installSpec: string; @@ -46,9 +215,15 @@ export async function runGlobalPackageUpdateSteps(params: { }> { const installCwd = params.installCwd === undefined ? {} : { cwd: params.installCwd }; const installEnv = params.env === undefined ? {} : { env: params.env }; + let stagedInstall = await createStagedNpmInstall(params.installTarget, params.packageName); const updateStep = await params.runStep({ name: "global update", - argv: globalInstallArgs(params.installTarget, params.installSpec), + argv: globalInstallArgs( + params.installTarget, + params.installSpec, + undefined, + stagedInstall?.prefix, + ), ...installCwd, ...installEnv, timeoutMs: params.timeoutMs, @@ -57,7 +232,14 @@ export async function runGlobalPackageUpdateSteps(params: { const steps = [updateStep]; let finalInstallStep = updateStep; if (updateStep.exitCode !== 0) { - const fallbackArgv = globalInstallFallbackArgs(params.installTarget, params.installSpec); + await cleanupStagedNpmInstall(stagedInstall); + stagedInstall = await createStagedNpmInstall(params.installTarget, params.packageName); + const fallbackArgv = globalInstallFallbackArgs( + params.installTarget, + params.installSpec, + undefined, + stagedInstall?.prefix, + ); if (fallbackArgv) { const fallbackStep = await params.runStep({ name: "global update (omit optional)", @@ -68,10 +250,14 @@ export async function runGlobalPackageUpdateSteps(params: { }); steps.push(fallbackStep); finalInstallStep = fallbackStep; + } else { + await cleanupStagedNpmInstall(stagedInstall); + stagedInstall = null; } } - const verifiedPackageRoot = + let verifiedPackageRoot = + stagedInstall?.packageRoot ?? ( await resolveGlobalInstallTarget({ manager: params.installTarget, @@ -83,7 +269,7 @@ export async function runGlobalPackageUpdateSteps(params: { null; let afterVersion: string | null = null; - if (verifiedPackageRoot) { + if (finalInstallStep.exitCode === 0 && verifiedPackageRoot) { afterVersion = await readPackageVersion(verifiedPackageRoot); const expectedVersion = resolveExpectedInstalledVersionFromSpec( params.packageName, @@ -104,12 +290,34 @@ export async function runGlobalPackageUpdateSteps(params: { stdoutTail: null, }); } - const postVerifyStep = await params.postVerifyStep?.(verifiedPackageRoot); + + if (stagedInstall && verificationErrors.length === 0) { + const swapStep = await swapStagedNpmInstall({ + stage: stagedInstall, + installTarget: params.installTarget, + packageName: params.packageName, + }); + steps.push(swapStep); + if (swapStep.exitCode === 0) { + verifiedPackageRoot = params.installTarget.packageRoot ?? verifiedPackageRoot; + } + } + + const failedVerifyOrSwap = steps.find( + (step) => + (step.name === "global install verify" || step.name === "global install swap") && + step.exitCode !== 0, + ); + const postVerifyStep = failedVerifyOrSwap + ? null + : await params.postVerifyStep?.(verifiedPackageRoot); if (postVerifyStep) { steps.push(postVerifyStep); } } + await cleanupStagedNpmInstall(stagedInstall); + const failedStep = finalInstallStep.exitCode !== 0 ? finalInstallStep diff --git a/src/infra/update-global.test.ts b/src/infra/update-global.test.ts index f406afbb8a5..7666343b70b 100644 --- a/src/infra/update-global.test.ts +++ b/src/infra/update-global.test.ts @@ -26,6 +26,8 @@ import { resolveGlobalInstallTarget, resolveGlobalInstallSpec, resolveGlobalRoot, + resolveNpmGlobalPrefixLayoutFromGlobalRoot, + resolveNpmGlobalPrefixLayoutFromPrefix, type CommandRunner, } from "./update-global.js"; @@ -367,6 +369,46 @@ describe("update global helpers", () => { ).toEqual(["/opt/homebrew/bin/pnpm", "add", "-g", "openclaw@latest"]); }); + it("builds npm staged install argv with an explicit prefix", () => { + expect(globalInstallArgs("npm", "openclaw@latest", null, "/tmp/stage")).toEqual([ + "npm", + "i", + "-g", + "--prefix", + "/tmp/stage", + "openclaw@latest", + "--no-fund", + "--no-audit", + "--loglevel=error", + ]); + expect(globalInstallFallbackArgs("npm", "openclaw@latest", null, "/tmp/stage")).toEqual([ + "npm", + "i", + "-g", + "--prefix", + "/tmp/stage", + "openclaw@latest", + "--omit=optional", + "--no-fund", + "--no-audit", + "--loglevel=error", + ]); + }); + + it("resolves npm prefix layouts for normal global roots", () => { + expect(resolveNpmGlobalPrefixLayoutFromGlobalRoot("/opt/openclaw/lib/node_modules")).toEqual({ + prefix: "/opt/openclaw", + globalRoot: "/opt/openclaw/lib/node_modules", + binDir: "/opt/openclaw/bin", + }); + expect(resolveNpmGlobalPrefixLayoutFromPrefix("/tmp/stage")).toEqual({ + prefix: "/tmp/stage", + globalRoot: "/tmp/stage/lib/node_modules", + binDir: "/tmp/stage/bin", + }); + expect(resolveNpmGlobalPrefixLayoutFromGlobalRoot("/tmp/node_modules")).toBeNull(); + }); + it("cleans only renamed package directories", async () => { await withTempDir({ prefix: "openclaw-update-cleanup-" }, async (root) => { await fs.mkdir(path.join(root, ".openclaw-123"), { recursive: true }); diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts index c2359125008..efdaaa41207 100644 --- a/src/infra/update-global.ts +++ b/src/infra/update-global.ts @@ -48,6 +48,12 @@ const OMITTED_PRIVATE_QA_BUNDLED_PLUGIN_ROOTS = new Set([ "dist/extensions/qa-matrix", ]); +export type NpmGlobalPrefixLayout = { + prefix: string; + globalRoot: string; + binDir: string; +}; + function normalizePackageTarget(value: string): string { return value.trim(); } @@ -379,6 +385,52 @@ function inferNpmPrefixFromPackageRoot(pkgRoot?: string | null): string | null { return null; } +export function resolveNpmGlobalPrefixLayoutFromGlobalRoot( + globalRoot?: string | null, +): NpmGlobalPrefixLayout | null { + const trimmed = globalRoot?.trim(); + if (!trimmed) { + return null; + } + const normalized = path.resolve(trimmed); + if (path.basename(normalized) !== "node_modules") { + return null; + } + const parentDir = path.dirname(normalized); + if (path.basename(parentDir) === "lib") { + const prefix = path.dirname(parentDir); + return { + prefix, + globalRoot: normalized, + binDir: path.join(prefix, "bin"), + }; + } + if (process.platform === "win32") { + return { + prefix: parentDir, + globalRoot: normalized, + binDir: parentDir, + }; + } + return null; +} + +export function resolveNpmGlobalPrefixLayoutFromPrefix(prefix: string): NpmGlobalPrefixLayout { + const resolvedPrefix = path.resolve(prefix); + if (process.platform === "win32") { + return { + prefix: resolvedPrefix, + globalRoot: path.join(resolvedPrefix, "node_modules"), + binDir: resolvedPrefix, + }; + } + return { + prefix: resolvedPrefix, + globalRoot: path.join(resolvedPrefix, "lib", "node_modules"), + binDir: path.join(resolvedPrefix, "bin"), + }; +} + function resolvePreferredNpmCommand(pkgRoot?: string | null): string | null { const prefix = inferNpmPrefixFromPackageRoot(pkgRoot); if (!prefix) { @@ -550,6 +602,7 @@ export function globalInstallArgs( managerOrCommand: GlobalInstallManager | ResolvedGlobalInstallCommand, spec: string, pkgRoot?: string | null, + installPrefix?: string | null, ): string[] { const resolved = normalizeGlobalInstallCommand(managerOrCommand, pkgRoot); if (resolved.manager === "pnpm") { @@ -558,19 +611,34 @@ export function globalInstallArgs( if (resolved.manager === "bun") { return [resolved.command, "add", "-g", spec]; } - return [resolved.command, "i", "-g", spec, ...NPM_GLOBAL_INSTALL_QUIET_FLAGS]; + return [ + resolved.command, + "i", + "-g", + ...(installPrefix ? ["--prefix", installPrefix] : []), + spec, + ...NPM_GLOBAL_INSTALL_QUIET_FLAGS, + ]; } export function globalInstallFallbackArgs( managerOrCommand: GlobalInstallManager | ResolvedGlobalInstallCommand, spec: string, pkgRoot?: string | null, + installPrefix?: string | null, ): string[] | null { const resolved = normalizeGlobalInstallCommand(managerOrCommand, pkgRoot); if (resolved.manager !== "npm") { return null; } - return [resolved.command, "i", "-g", spec, ...NPM_GLOBAL_INSTALL_OMIT_OPTIONAL_FLAGS]; + return [ + resolved.command, + "i", + "-g", + ...(installPrefix ? ["--prefix", installPrefix] : []), + spec, + ...NPM_GLOBAL_INSTALL_OMIT_OPTIONAL_FLAGS, + ]; } export async function cleanupGlobalRenameDirs(params: {