diff --git a/docs/install/updating.md b/docs/install/updating.md index 9db6a63ad774..1d9ab5e73242 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -15,7 +15,7 @@ state needs manual repair. ## Recommended: `openclaw update` -Detects your install type (npm or git), fetches the latest version, runs `openclaw doctor`, and restarts the gateway. +Detects your install type (npm, pnpm, Bun, or git), fetches the latest version, runs `openclaw doctor`, and restarts the gateway. ```bash openclaw update @@ -179,6 +179,20 @@ explicit OpenClaw update means "install the selected release now." pnpm add -g openclaw@latest ``` +If pnpm 11 installed OpenClaw 2026.7.1, run that manual command once. That +release predates pnpm 11's isolated global-package layout, so its updater can +mistake another npm installation for the running CLI. Later releases retain +pnpm ownership and follow the replacement package root during updates. They +also use the owning manager's reported global bin directory and stop before +mutation when the available pnpm command reports another global root or major, +or when the invoking package is orphaned or not the only active OpenClaw +install there. + +If OpenClaw shares a pnpm 11 global install group with another package, the +automatic updater stops before changing the group. Update the original +comma-separated group manually so its sibling packages and build policy stay +intact. + ```bash bun add -g openclaw@latest ``` diff --git a/scripts/e2e/multi-node-update-docker.sh b/scripts/e2e/multi-node-update-docker.sh index 2ed0134fa722..badcb0d97cfe 100755 --- a/scripts/e2e/multi-node-update-docker.sh +++ b/scripts/e2e/multi-node-update-docker.sh @@ -1,14 +1,12 @@ #!/usr/bin/env bash -# Reproduces the multi-node-install update bug. +# Guards the multi-node-install update fix. # # Sets up two independent Node installations inside a Docker container, installs # OpenClaw under node-A, registers the gateway service pointing at node-A, then # switches PATH so node-B comes first and runs `openclaw update`. Verifies that: # -# 1. The update targets the wrong install root (node-B npm prefix) or produces -# a gateway service definition pointing at node-B while the package lives -# under node-A. -# 2. The gateway fails to start or runs a stale/missing entrypoint. +# 1. The update stays on node-A's package root and service runtime. +# 2. The gateway restarts from the preserved entrypoint and becomes healthy. # # Usage: # ./scripts/e2e/multi-node-update-docker.sh @@ -206,7 +204,7 @@ start_gateway() { echo "systemctl shim: unit not found: \$unit" >&2 return 1 fi - exec_start="\$(sed -n 's/^ExecStart=//p' "\$unit" | tail -n 1)" + exec_start="\$(sed -n "s/^ExecStart=//p" "\$unit" | tail -n 1)" if [ -z "\$exec_start" ]; then echo "systemctl shim: no ExecStart in \$unit" >&2 return 1 @@ -216,7 +214,7 @@ start_gateway() { export OPENCLAW_NO_RESPAWN=1 echo "systemctl shim: starting: \$exec_start" nohup bash -lc "exec \$exec_start" >>"$GATEWAY_DAEMON_LOG" 2>&1 & - printf '%s\n' "\$!" >"$GATEWAY_PID_FILE" + printf "%s\n" "\$!" >"$GATEWAY_PID_FILE" ) } @@ -247,9 +245,9 @@ case "\$command" in ;; show) if is_running; then - printf 'ActiveState=active\nSubState=running\nMainPID=%s\nExecMainStatus=0\nExecMainCode=0\n' "\$(cat "$GATEWAY_PID_FILE")" + printf "ActiveState=active\nSubState=running\nMainPID=%s\nExecMainStatus=0\nExecMainCode=0\n" "\$(cat "$GATEWAY_PID_FILE")" else - printf 'ActiveState=inactive\nSubState=dead\nMainPID=0\nExecMainStatus=0\nExecMainCode=0\n' + printf "ActiveState=inactive\nSubState=dead\nMainPID=0\nExecMainStatus=0\nExecMainCode=0\n" fi ;; *) @@ -271,6 +269,27 @@ if ! openclaw gateway install --json >"$ARTIFACTS/gateway-install.json" 2>"$ARTI exit 1 fi +if ! openclaw gateway status --json \ + >"$ARTIFACTS/gateway-status-before-update.json" \ + 2>"$ARTIFACTS/gateway-status-before-update.err"; then + echo "FAIL: gateway status failed before update" + cat "$ARTIFACTS/gateway-status-before-update.err" 2>/dev/null || true + exit 1 +fi +if ! GATEWAY_STATUS_FILE="$ARTIFACTS/gateway-status-before-update.json" node --input-type=module <<"NODE" +import fs from "node:fs"; +const status = JSON.parse(fs.readFileSync(process.env.GATEWAY_STATUS_FILE, "utf8")); +if (status.service?.runtime?.status !== "running") { + console.error(`expected running gateway service before update, got \${status.service?.runtime?.status ?? "missing"}`); + process.exit(1); +} +NODE +then + echo "FAIL: gateway service was not running before update" + cat "$ARTIFACTS/gateway-status-before-update.json" 2>/dev/null || true + exit 1 +fi + echo "" echo "── Step 4: Inspect what node path was baked into the service ──" diff --git a/src/cli/update-cli/shared.command-runner.test.ts b/src/cli/update-cli/shared.command-runner.test.ts index 0a332d3e8e78..63f4b577c2b9 100644 --- a/src/cli/update-cli/shared.command-runner.test.ts +++ b/src/cli/update-cli/shared.command-runner.test.ts @@ -1,7 +1,10 @@ // Shared command runner tests cover update helper command execution and error capture. +import fs from "node:fs/promises"; +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { defaultRuntime } from "../../runtime.js"; -import { createGlobalCommandRunner, parseTimeoutMsOrExit } from "./shared.js"; +import { withTempDir } from "../../test-helpers/temp-dir.js"; +import { createGlobalCommandRunner, parseTimeoutMsOrExit, resolveUpdateRoot } from "./shared.js"; const runCommandWithTimeout = vi.hoisted(() => vi.fn()); @@ -90,4 +93,30 @@ describe("createGlobalCommandRunner", () => { exit.mockRestore(); } }); + + it.runIf(process.platform !== "win32")( + "resolves update ownership from the lexical invocation path", + async () => { + await withTempDir({ prefix: "openclaw-update-root-" }, async (base) => { + const storeRoot = path.join(base, "store", "openclaw"); + const packageRoot = path.join(base, "global", "v11", "install", "node_modules", "openclaw"); + await fs.mkdir(path.dirname(packageRoot), { recursive: true }); + await fs.mkdir(storeRoot, { recursive: true }); + await fs.writeFile( + path.join(storeRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "1.0.0" }), + "utf8", + ); + await fs.symlink(storeRoot, packageRoot, "dir"); + + const previousArgv = [...process.argv]; + process.argv[1] = path.join(packageRoot, "openclaw.mjs"); + try { + await expect(resolveUpdateRoot()).resolves.toBe(packageRoot); + } finally { + process.argv.splice(0, process.argv.length, ...previousArgv); + } + }); + }, + ); }); diff --git a/src/cli/update-cli/shared.ts b/src/cli/update-cli/shared.ts index 5afdcf379b68..cc0ef35b5b42 100644 --- a/src/cli/update-cli/shared.ts +++ b/src/cli/update-cli/shared.ts @@ -173,12 +173,15 @@ export function resolveNodeRunner(): string { /** Locate the installed OpenClaw package root that should receive update operations. */ export async function resolveUpdateRoot(): Promise { + // Preserve the lexical package path from the invoking shim. pnpm 11 package + // modules realpath into a shared store, which is not the install owner. + const invocationRoot = process.argv[1] + ? await resolveOpenClawPackageRoot({ cwd: path.dirname(path.resolve(process.argv[1])) }) + : null; return ( - (await resolveOpenClawPackageRoot({ - moduleUrl: import.meta.url, - argv1: process.argv[1], - cwd: process.cwd(), - })) ?? process.cwd() + invocationRoot ?? + (await resolveOpenClawPackageRoot({ moduleUrl: import.meta.url, cwd: process.cwd() })) ?? + process.cwd() ); } diff --git a/src/infra/package-update-steps.pnpm11-guard.test.ts b/src/infra/package-update-steps.pnpm11-guard.test.ts new file mode 100644 index 000000000000..903e367719bd --- /dev/null +++ b/src/infra/package-update-steps.pnpm11-guard.test.ts @@ -0,0 +1,805 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { writePackageDistInventory } from "../../scripts/lib/package-dist-inventory.ts"; +import { withTempDir } from "../test-helpers/temp-dir.js"; +import { runGlobalPackageUpdateSteps } from "./package-update-steps.js"; +import type { CommandRunner, ResolvedGlobalInstallTarget } from "./update-global.js"; + +type PackageUpdateStepResult = Awaited< + ReturnType +>["steps"][number]; + +async function writePackageRoot(packageRoot: string, version: string): Promise { + await fs.mkdir(path.join(packageRoot, "dist"), { recursive: true }); + await Promise.all([ + fs.writeFile( + path.join(packageRoot, "package.json"), + JSON.stringify({ name: "openclaw", version }), + "utf8", + ), + fs.writeFile(path.join(packageRoot, "dist", "index.js"), "export {};\n", "utf8"), + ]); + await writePackageDistInventory(packageRoot); +} + +async function writePnpmIsolatedPackage(params: { + globalRoot: string; + installName: string; + version: string; + dependencies?: Record; +}): Promise<{ activeLink: string; packageRoot: string }> { + const installRoot = path.join(params.globalRoot, params.installName); + const packageRoot = path.join(installRoot, "node_modules", "openclaw"); + await writePackageRoot(packageRoot, params.version); + await fs.writeFile( + path.join(installRoot, "package.json"), + JSON.stringify({ + private: true, + dependencies: { openclaw: params.version, ...params.dependencies }, + }), + "utf8", + ); + const activeLink = path.join(params.globalRoot, `hash-${params.installName}`); + await fs.symlink(installRoot, activeLink, "dir"); + return { activeLink, packageRoot }; +} + +function createPnpmTarget(globalRoot: string): ResolvedGlobalInstallTarget { + return { + manager: "pnpm", + command: "pnpm", + globalRoot, + packageRoot: path.join(globalRoot, "openclaw"), + }; +} + +async function expectPathMissing(filePath: string): Promise { + try { + await fs.access(filePath); + } catch (error) { + expect((error as NodeJS.ErrnoException).code).toBe("ENOENT"); + return; + } + throw new Error(`Expected missing path: ${filePath}`); +} + +describe("pnpm 11 isolated install preflight", () => { + it("rejects grouped installs before dropping sibling packages", async () => { + await withTempDir({ prefix: "openclaw-package-update-pnpm-group-" }, async (base) => { + const globalRoot = path.join(base, "pnpm-home", "global", "v11"); + await writePnpmIsolatedPackage({ + globalRoot, + installName: "grouped", + version: "1.0.0", + dependencies: { cowsay: "1.6.0" }, + }); + const { packageRoot } = await writePnpmIsolatedPackage({ + globalRoot, + installName: "invoking", + version: "1.0.0", + }); + const runCommand = vi.fn(); + const runStep = vi.fn(); + + const result = await runGlobalPackageUpdateSteps({ + installTarget: { + manager: "pnpm", + command: "pnpm", + pnpmIsolated: { layoutVersion: 11 }, + globalRoot, + packageRoot, + }, + installSpec: "openclaw@2.0.0", + packageName: "openclaw", + packageRoot, + runCommand, + runStep, + timeoutMs: 1000, + }); + + expect(result.failedStep?.name).toBe("pnpm isolated install preflight"); + expect(result.failedStep?.stderrTail).toContain("with cowsay"); + expect(result.failedStep?.stderrTail).toContain("stopped before mutation"); + expect(runCommand).not.toHaveBeenCalled(); + expect(runStep).not.toHaveBeenCalled(); + }); + }); + + it("rejects multiple standalone installs before an alias-wide update", async () => { + await withTempDir({ prefix: "openclaw-package-update-pnpm-multiple-" }, async (base) => { + const globalRoot = path.join(base, "pnpm-home", "global", "v11"); + await writePnpmIsolatedPackage({ + globalRoot, + installName: "other", + version: "9.0.0", + }); + const { packageRoot } = await writePnpmIsolatedPackage({ + globalRoot, + installName: "invoking", + version: "1.0.0", + }); + const runCommand = vi.fn(); + const runStep = vi.fn(); + + const result = await runGlobalPackageUpdateSteps({ + installTarget: { + manager: "pnpm", + command: "pnpm", + pnpmIsolated: { layoutVersion: 11 }, + globalRoot, + packageRoot, + }, + installSpec: "openclaw@2.0.0", + packageName: "openclaw", + packageRoot, + runCommand, + runStep, + timeoutMs: 1000, + }); + + expect(result.failedStep?.name).toBe("pnpm isolated install preflight"); + expect(result.failedStep?.stderrTail).toContain("found 2"); + expect(result.failedStep?.stderrTail).toContain("stopped before mutation"); + expect(runCommand).not.toHaveBeenCalled(); + expect(runStep).not.toHaveBeenCalled(); + }); + }); + + it("rejects an orphaned invoking install before manager probes", async () => { + await withTempDir({ prefix: "openclaw-package-update-pnpm-invoking-orphan-" }, async (base) => { + const globalRoot = path.join(base, "pnpm-home", "global", "v11"); + const packageRoot = path.join(globalRoot, "orphan", "node_modules", "openclaw"); + await writePackageRoot(packageRoot, "1.0.0"); + const runCommand = vi.fn(); + const runStep = vi.fn(); + + const result = await runGlobalPackageUpdateSteps({ + installTarget: { + manager: "pnpm", + command: "pnpm", + pnpmIsolated: { layoutVersion: 11 }, + globalRoot, + packageRoot, + }, + installSpec: "openclaw@2.0.0", + packageName: "openclaw", + packageRoot, + runCommand, + runStep, + timeoutMs: 1000, + }); + + expect(result.failedStep?.name).toBe("pnpm isolated install preflight"); + expect(result.failedStep?.stderrTail).toContain("found 0"); + expect(runCommand).not.toHaveBeenCalled(); + expect(runStep).not.toHaveBeenCalled(); + }); + }); + + it("rejects an orphan whose package symlink shares the active store target", async () => { + await withTempDir({ prefix: "openclaw-package-update-pnpm-shared-store-" }, async (base) => { + const globalRoot = path.join(base, "pnpm-home", "global", "v11"); + const activeInstallRoot = path.join(globalRoot, "active"); + const orphanInstallRoot = path.join(globalRoot, "orphan"); + const activePackageRoot = path.join(activeInstallRoot, "node_modules", "openclaw"); + const orphanPackageRoot = path.join(orphanInstallRoot, "node_modules", "openclaw"); + const sharedPackageRoot = path.join(base, "store", "openclaw"); + await Promise.all([ + fs.mkdir(path.dirname(activePackageRoot), { recursive: true }), + fs.mkdir(path.dirname(orphanPackageRoot), { recursive: true }), + writePackageRoot(sharedPackageRoot, "1.0.0"), + ]); + await Promise.all([ + fs.writeFile( + path.join(activeInstallRoot, "package.json"), + JSON.stringify({ private: true, dependencies: { openclaw: "1.0.0" } }), + "utf8", + ), + fs.writeFile( + path.join(orphanInstallRoot, "package.json"), + JSON.stringify({ private: true, dependencies: { openclaw: "1.0.0" } }), + "utf8", + ), + fs.symlink(sharedPackageRoot, activePackageRoot, "dir"), + fs.symlink(sharedPackageRoot, orphanPackageRoot, "dir"), + fs.symlink(activeInstallRoot, path.join(globalRoot, "hash-active"), "dir"), + ]); + const runCommand = vi.fn(); + const runStep = vi.fn(); + + const result = await runGlobalPackageUpdateSteps({ + installTarget: { + manager: "pnpm", + command: "pnpm", + pnpmIsolated: { layoutVersion: 11 }, + globalRoot, + packageRoot: orphanPackageRoot, + }, + installSpec: "openclaw@2.0.0", + packageName: "openclaw", + packageRoot: orphanPackageRoot, + runCommand, + runStep, + timeoutMs: 1000, + }); + + expect(result.failedStep?.name).toBe("pnpm isolated install preflight"); + expect(result.failedStep?.stderrTail).toContain( + "found 1 active installs and 0 owner matches", + ); + expect(runCommand).not.toHaveBeenCalled(); + expect(runStep).not.toHaveBeenCalled(); + }); + }); + + it("uses the owner-reported custom bin without changing pnpm command resolution", async () => { + await withTempDir({ prefix: "openclaw-package-update-pnpm-isolated-" }, async (base) => { + const globalDir = path.join(base, "pnpm-home", "global"); + const globalRoot = path.join(globalDir, "v11"); + const ownerBinDir = path.join(base, "custom-global-bin"); + const pathBinDir = path.join(base, "path-pnpm-home", "bin"); + const callerProjectDir = path.join(base, "caller-project"); + const oldPackageRoot = path.join(globalRoot, "old", "node_modules", "openclaw"); + const newPackageRoot = path.join(globalRoot, "new", "node_modules", "openclaw"); + await fs.mkdir(ownerBinDir, { recursive: true }); + await writePackageRoot(oldPackageRoot, "1.0.0"); + await fs.writeFile( + path.join(globalRoot, "old", "package.json"), + JSON.stringify({ private: true, dependencies: { openclaw: "1.0.0" } }), + "utf8", + ); + await fs.symlink(path.join(globalRoot, "old"), path.join(globalRoot, "hash-openclaw"), "dir"); + + const pnpmWarning = "[WARN] Using --global skips the package manager check for this project"; + const runCommand: CommandRunner = async (argv, options) => { + const command = argv.join(" "); + expect(options.cwd).toBe(globalRoot); + if (command === "pnpm root -g") { + return { stdout: `${pnpmWarning}\n${globalRoot}\n`, stderr: "", code: 0 }; + } + if (command === "pnpm bin -g") { + expect(options.env?.PATH?.split(path.delimiter)[0]).toBe(pathBinDir); + return { stdout: `${pnpmWarning}\n${ownerBinDir}\n`, stderr: "", code: 0 }; + } + if (command === "pnpm --version") { + expect(options.env?.PATH?.split(path.delimiter)[0]).toBe(pathBinDir); + return { stdout: `${pnpmWarning}\n11.4.0\n`, stderr: "", code: 0 }; + } + throw new Error(`unexpected command: ${command}`); + }; + const runStep = vi.fn(async ({ name, argv, cwd, env }): Promise => { + if (name === "global update") { + expect(cwd).toBe(globalRoot); + expect(env?.PATH?.split(path.delimiter)[0]).toBe(pathBinDir); + expect(argv).toEqual([ + "pnpm", + "add", + "-g", + "--global-dir", + globalDir, + "--global-bin-dir", + ownerBinDir, + "--allow-build=openclaw", + "openclaw@2.0.0", + ]); + await fs.rm(path.join(globalRoot, "hash-openclaw"), { force: true }); + await fs.rm(path.join(globalRoot, "old"), { recursive: true, force: true }); + await writePackageRoot(newPackageRoot, "2.0.0"); + await fs.mkdir(path.join(newPackageRoot, "scripts"), { recursive: true }); + await Promise.all([ + fs.writeFile( + path.join(newPackageRoot, "dist", "openclaw-install-guard"), + "pending\n", + "utf8", + ), + fs.writeFile( + path.join(newPackageRoot, "scripts", "preinstall-package-manager-warning.mjs"), + "export {};\n", + "utf8", + ), + fs.writeFile( + path.join(newPackageRoot, "scripts", "postinstall-bundled-plugins.mjs"), + "export {};\n", + "utf8", + ), + fs.writeFile( + path.join(globalRoot, "new", "package.json"), + JSON.stringify({ private: true, dependencies: { openclaw: "2.0.0" } }), + "utf8", + ), + ]); + await fs.symlink( + path.join(globalRoot, "new"), + path.join(globalRoot, "hash-openclaw"), + "dir", + ); + } else if (name === "pnpm package preinstall") { + expect(argv).toEqual([ + process.execPath, + path.join(newPackageRoot, "scripts", "preinstall-package-manager-warning.mjs"), + ]); + await expect( + fs.readFile(path.join(newPackageRoot, ".openclaw-lifecycle-pending"), "utf8"), + ).resolves.toBe("pending\n"); + await fs.rm(path.join(newPackageRoot, "dist", "openclaw-install-guard")); + } else if (name === "pnpm package postinstall") { + expect(argv).toEqual([ + process.execPath, + path.join(newPackageRoot, "scripts", "postinstall-bundled-plugins.mjs"), + ]); + await expect( + fs.readFile(path.join(newPackageRoot, ".openclaw-lifecycle-pending"), "utf8"), + ).resolves.toBe("pending\n"); + } else { + throw new Error(`unexpected step: ${name}`); + } + return { + name, + command: argv.join(" "), + cwd: cwd ?? process.cwd(), + durationMs: 1, + exitCode: 0, + }; + }); + const postVerifyStep = vi.fn(async (packageRoot: string) => { + expect(packageRoot).toBe(newPackageRoot); + return null; + }); + + const result = await runGlobalPackageUpdateSteps({ + installTarget: { + manager: "pnpm", + command: "pnpm", + pnpmIsolated: { + layoutVersion: 11, + }, + globalRoot, + packageRoot: oldPackageRoot, + }, + installSpec: "openclaw@2.0.0", + packageName: "openclaw", + packageRoot: oldPackageRoot, + runCommand, + runStep, + timeoutMs: 1000, + env: { PATH: `${pathBinDir}${path.delimiter}${ownerBinDir}` }, + installCwd: callerProjectDir, + postVerifyStep, + }); + + expect(result.failedStep).toBeNull(); + expect(result.afterVersion).toBe("2.0.0"); + expect(result.verifiedPackageRoot).toBe(newPackageRoot); + expect(result.steps.map((step) => step.name)).toEqual([ + "global update", + "pnpm package preinstall", + "pnpm package postinstall", + ]); + await expectPathMissing(path.join(newPackageRoot, ".openclaw-lifecycle-pending")); + expect(postVerifyStep).toHaveBeenCalledOnce(); + }); + }); + + it("accepts a replacement pnpm project that reuses the same shared-store package", async () => { + await withTempDir( + { prefix: "openclaw-package-update-pnpm-shared-replacement-" }, + async (base) => { + const globalDir = path.join(base, "pnpm-home", "global"); + const globalRoot = path.join(globalDir, "v11"); + const globalBinDir = path.join(base, "pnpm-home", "bin"); + const oldInstallRoot = path.join(globalRoot, "old"); + const newInstallRoot = path.join(globalRoot, "new"); + const oldPackageRoot = path.join(oldInstallRoot, "node_modules", "openclaw"); + const newPackageRoot = path.join(newInstallRoot, "node_modules", "openclaw"); + const sharedPackageRoot = path.join(base, "store", "openclaw"); + const activeLink = path.join(globalRoot, "hash-openclaw"); + await Promise.all([ + fs.mkdir(path.dirname(oldPackageRoot), { recursive: true }), + writePackageRoot(sharedPackageRoot, "1.0.0"), + ]); + await Promise.all([ + fs.writeFile( + path.join(oldInstallRoot, "package.json"), + JSON.stringify({ private: true, dependencies: { openclaw: "1.0.0" } }), + "utf8", + ), + fs.symlink(sharedPackageRoot, oldPackageRoot, "dir"), + fs.symlink(oldInstallRoot, activeLink, "dir"), + ]); + const runCommand: CommandRunner = async (argv, options) => { + expect(options.cwd).toBe(globalRoot); + const command = argv.join(" "); + if (command === "pnpm root -g") { + return { stdout: `${globalRoot}\n`, stderr: "", code: 0 }; + } + if (command === "pnpm bin -g") { + return { stdout: `${globalBinDir}\n`, stderr: "", code: 0 }; + } + if (command === "pnpm --version") { + return { stdout: "11.4.0\n", stderr: "", code: 0 }; + } + throw new Error(`unexpected command: ${command}`); + }; + const runStep = vi.fn(async ({ name, argv, cwd }): Promise => { + expect(name).toBe("global update"); + expect(cwd).toBe(globalRoot); + await fs.rm(activeLink); + await fs.mkdir(path.dirname(newPackageRoot), { recursive: true }); + await Promise.all([ + fs.writeFile( + path.join(newInstallRoot, "package.json"), + JSON.stringify({ private: true, dependencies: { openclaw: "1.0.0" } }), + "utf8", + ), + fs.symlink(sharedPackageRoot, newPackageRoot, "dir"), + fs.symlink(newInstallRoot, activeLink, "dir"), + ]); + return { + name, + command: argv.join(" "), + cwd: cwd ?? process.cwd(), + durationMs: 1, + exitCode: 0, + }; + }); + + const result = await runGlobalPackageUpdateSteps({ + installTarget: { + manager: "pnpm", + command: "pnpm", + pnpmIsolated: { layoutVersion: 11 }, + globalRoot, + packageRoot: oldPackageRoot, + }, + installSpec: "openclaw@1.0.0", + packageName: "openclaw", + packageRoot: oldPackageRoot, + runCommand, + runStep, + timeoutMs: 1000, + }); + + expect(result.failedStep).toBeNull(); + expect(result.afterVersion).toBe("1.0.0"); + expect(result.verifiedPackageRoot).toBe(newPackageRoot); + expect(runStep).toHaveBeenCalledOnce(); + }, + ); + }); + + it("preserves pnpm local specs before mutating from the owner root", async () => { + await withTempDir({ prefix: "openclaw-package-update-pnpm-relative-spec-" }, async (base) => { + const globalDir = path.join(base, "pnpm-home", "global"); + const globalRoot = path.join(globalDir, "v11"); + const globalBinDir = path.join(base, "pnpm-home", "bin"); + const callerProjectDir = path.join(base, "caller-project"); + const candidateTarball = path.join(callerProjectDir, "candidate.tgz"); + const candidateTar = path.join(callerProjectDir, "candidate.tar"); + const cases: Array<{ + installSpec: string; + expectedInstallSpec: string; + installCwd?: string; + }> = [ + { + installSpec: "file:./candidate.tgz", + expectedInstallSpec: `file:${candidateTarball}`, + }, + { + installSpec: "openclaw@link:./candidate", + expectedInstallSpec: `openclaw@link:${path.join(callerProjectDir, "candidate")}`, + }, + { + installSpec: "git+file:./candidate#main", + expectedInstallSpec: "git+file:///C:/caller/candidate#main", + installCwd: "C:\\caller", + }, + { installSpec: "./candidate.tar", expectedInstallSpec: candidateTar }, + { + installSpec: "openclaw@file:./candidate.tar", + expectedInstallSpec: `openclaw@file:${candidateTar}`, + }, + { installSpec: "candidate.tar", expectedInstallSpec: "candidate.tar" }, + { installSpec: "openclaw@candidate.tar", expectedInstallSpec: "openclaw@candidate.tar" }, + { installSpec: "file:~/candidate.tgz", expectedInstallSpec: "file:~/candidate.tgz" }, + { installSpec: "~/candidate.tgz", expectedInstallSpec: "~/candidate.tgz" }, + ]; + const { packageRoot } = await writePnpmIsolatedPackage({ + globalRoot, + installName: "install", + version: "1.0.0", + }); + await fs.mkdir(callerProjectDir, { recursive: true }); + await fs.writeFile(candidateTarball, "fixture", "utf8"); + await fs.writeFile(candidateTar, "fixture", "utf8"); + const runCommand: CommandRunner = async (argv, options) => { + expect(options.cwd).toBe(globalRoot); + const command = argv.join(" "); + if (command === "pnpm root -g") { + return { stdout: `${globalRoot}\n`, stderr: "", code: 0 }; + } + if (command === "pnpm bin -g") { + return { stdout: `${globalBinDir}\n`, stderr: "", code: 0 }; + } + if (command === "pnpm --version") { + return { stdout: "11.4.0\n", stderr: "", code: 0 }; + } + throw new Error(`unexpected command: ${command}`); + }; + let expectedInstallSpec = ""; + const runStep = vi.fn(async ({ name, argv, cwd }): Promise => { + expect(name).toBe("global update"); + expect(cwd).toBe(globalRoot); + expect(argv).toEqual([ + "pnpm", + "add", + "-g", + "--global-dir", + globalDir, + "--global-bin-dir", + globalBinDir, + "--allow-build=openclaw", + expectedInstallSpec, + ]); + return { + name, + command: argv.join(" "), + cwd: cwd ?? process.cwd(), + durationMs: 1, + exitCode: 1, + stderrTail: "fixture stop", + }; + }); + + for (const testCase of cases) { + expectedInstallSpec = testCase.expectedInstallSpec; + const result = await runGlobalPackageUpdateSteps({ + installTarget: { + manager: "pnpm", + command: "pnpm", + pnpmIsolated: { layoutVersion: 11 }, + globalRoot, + packageRoot, + }, + installSpec: testCase.installSpec, + packageName: "openclaw", + packageRoot, + runCommand, + runStep, + timeoutMs: 1000, + installCwd: testCase.installCwd ?? callerProjectDir, + }); + expect(result.failedStep?.name).toBe("global update"); + } + expect(runStep).toHaveBeenCalledTimes(cases.length); + }); + }); + + it("probes pnpm from its owner root before rejecting a mismatched major", async () => { + await withTempDir({ prefix: "openclaw-package-update-pnpm-major-" }, async (base) => { + const globalRoot = path.join(base, "pnpm-home", "global", "v11"); + const globalBinDir = path.join(base, "pnpm-home", "bin"); + const { packageRoot } = await writePnpmIsolatedPackage({ + globalRoot, + installName: "install", + version: "1.0.0", + }); + const runStep = vi.fn(); + const runCommand: CommandRunner = async (argv, options) => { + const command = argv.join(" "); + expect(options.cwd).toBe(globalRoot); + expect(options.env?.PATH?.split(path.delimiter)[0]).toBe(globalBinDir); + if (command === "pnpm root -g") { + return { stdout: `${globalRoot}\n`, stderr: "", code: 0 }; + } + if (command === "pnpm bin -g") { + return { stdout: `${globalBinDir}\n`, stderr: "", code: 0 }; + } + if (command === "pnpm --version") { + return { stdout: "10.32.1\n", stderr: "", code: 0 }; + } + throw new Error(`unexpected command: ${command}`); + }; + + const result = await runGlobalPackageUpdateSteps({ + installTarget: { + manager: "pnpm", + command: "pnpm", + pnpmIsolated: { + layoutVersion: 11, + }, + globalRoot, + packageRoot, + }, + installSpec: "openclaw@2.0.0", + packageName: "openclaw", + packageRoot, + runCommand, + runStep, + timeoutMs: 1000, + env: { PATH: `${globalBinDir}${path.delimiter}${path.join(base, "pnpm-10", "bin")}` }, + }); + + expect(result.failedStep?.name).toBe("pnpm isolated install preflight"); + expect(result.failedStep?.stderrTail).toContain("reports pnpm 10.32.1"); + expect(runStep).not.toHaveBeenCalled(); + await expect(fs.readFile(path.join(packageRoot, "package.json"), "utf8")).resolves.toContain( + '"version":"1.0.0"', + ); + }); + }); + + it("rejects a pnpm command that owns another global root", async () => { + await withTempDir({ prefix: "openclaw-package-update-pnpm-root-" }, async (base) => { + const globalRoot = path.join(base, "owner", "global", "v11"); + const otherGlobalRoot = path.join(base, "other", "global", "v11"); + const { packageRoot } = await writePnpmIsolatedPackage({ + globalRoot, + installName: "install", + version: "1.0.0", + }); + const runCommand = vi.fn(async (argv) => { + expect(argv).toEqual(["pnpm", "root", "-g"]); + return { stdout: `${otherGlobalRoot}\n`, stderr: "", code: 0 }; + }); + const runStep = vi.fn(); + + const result = await runGlobalPackageUpdateSteps({ + installTarget: { + manager: "pnpm", + command: "pnpm", + pnpmIsolated: { layoutVersion: 11 }, + globalRoot, + packageRoot, + }, + installSpec: "openclaw@2.0.0", + packageName: "openclaw", + packageRoot, + runCommand, + runStep, + timeoutMs: 1000, + }); + + expect(result.failedStep?.name).toBe("pnpm isolated install preflight"); + expect(result.failedStep?.stderrTail).toContain("owns"); + expect(result.failedStep?.stderrTail).toContain("not the invoking OpenClaw install"); + expect(runCommand).toHaveBeenCalledOnce(); + expect(runStep).not.toHaveBeenCalled(); + }); + }); + + it("rejects a pnpm update that leaves only an orphaned old package root", async () => { + await withTempDir({ prefix: "openclaw-package-update-pnpm-orphan-" }, async (base) => { + const globalRoot = path.join(base, "pnpm-home", "global", "v11"); + const globalBinDir = path.join(base, "pnpm-home", "bin"); + const { activeLink, packageRoot } = await writePnpmIsolatedPackage({ + globalRoot, + installName: "old", + version: "1.0.0", + }); + const runCommand: CommandRunner = async (argv) => { + const command = argv.join(" "); + if (command === "pnpm root -g") { + return { stdout: `${globalRoot}\n`, stderr: "", code: 0 }; + } + if (command === "pnpm bin -g") { + return { stdout: `${globalBinDir}\n`, stderr: "", code: 0 }; + } + if (command === "pnpm --version") { + return { stdout: "11.4.0\n", stderr: "", code: 0 }; + } + throw new Error(`unexpected command: ${command}`); + }; + const runStep = vi.fn(async ({ name, argv, cwd }): Promise => { + expect(name).toBe("global update"); + await fs.rm(activeLink); + return { + name, + command: argv.join(" "), + cwd: cwd ?? process.cwd(), + durationMs: 1, + exitCode: 0, + }; + }); + + const result = await runGlobalPackageUpdateSteps({ + installTarget: { + manager: "pnpm", + command: "pnpm", + pnpmIsolated: { + layoutVersion: 11, + }, + globalRoot, + packageRoot, + }, + installSpec: "openclaw@2.0.0", + packageName: "openclaw", + packageRoot, + runCommand, + runStep, + timeoutMs: 1000, + }); + + expect(result.failedStep?.name).toBe("global install verify"); + expect(result.failedStep?.stderrTail).toContain("unique active pnpm replacement"); + expect(runStep).toHaveBeenCalledOnce(); + await expect(fs.readFile(path.join(packageRoot, "package.json"), "utf8")).resolves.toContain( + '"version":"1.0.0"', + ); + }); + }); + + it("retries interrupted pnpm package lifecycle repair", async () => { + await withTempDir({ prefix: "openclaw-package-update-pnpm-lifecycle-" }, async (base) => { + const globalRoot = path.join(base, "global"); + const packageRoot = path.join(globalRoot, "openclaw"); + await writePackageRoot(packageRoot, "1.0.0"); + let firstAttempt = true; + + const runStep = vi.fn(async ({ name, argv, cwd }): Promise => { + if (name === "global update" && firstAttempt) { + await writePackageRoot(packageRoot, "2.0.0"); + await fs.mkdir(path.join(packageRoot, "scripts"), { recursive: true }); + await Promise.all([ + fs.writeFile( + path.join(packageRoot, "dist", "openclaw-install-guard"), + "pending\n", + "utf8", + ), + fs.writeFile( + path.join(packageRoot, "scripts", "preinstall-package-manager-warning.mjs"), + "export {};\n", + "utf8", + ), + fs.writeFile( + path.join(packageRoot, "scripts", "postinstall-bundled-plugins.mjs"), + "export {};\n", + "utf8", + ), + ]); + } else if (name === "pnpm package preinstall") { + await fs.rm(path.join(packageRoot, "dist", "openclaw-install-guard")); + } + const exitCode = name === "pnpm package postinstall" && firstAttempt ? 1 : 0; + return { + name, + command: argv.join(" "), + cwd: cwd ?? process.cwd(), + durationMs: 1, + exitCode, + }; + }); + const updateParams = { + installTarget: createPnpmTarget(globalRoot), + installSpec: "openclaw@2.0.0", + packageName: "openclaw", + packageRoot, + runCommand: async (argv: string[]) => { + if (argv.join(" ") === "pnpm root -g") { + return { stdout: `${globalRoot}\n`, stderr: "", code: 0 }; + } + throw new Error(`unexpected command: ${argv.join(" ")}`); + }, + runStep, + timeoutMs: 1000, + }; + + const failed = await runGlobalPackageUpdateSteps(updateParams); + expect(failed.failedStep?.name).toBe("pnpm package postinstall"); + await expect( + fs.readFile(path.join(packageRoot, ".openclaw-lifecycle-pending"), "utf8"), + ).resolves.toBe("pending\n"); + + firstAttempt = false; + runStep.mockClear(); + const recovered = await runGlobalPackageUpdateSteps(updateParams); + expect(recovered.failedStep).toBeNull(); + expect(recovered.afterVersion).toBe("2.0.0"); + expect(runStep.mock.calls.map(([call]) => call.name)).toEqual([ + "global update", + "pnpm package postinstall", + ]); + await expectPathMissing(path.join(packageRoot, ".openclaw-lifecycle-pending")); + }); + }); +}); diff --git a/src/infra/package-update-steps.ts b/src/infra/package-update-steps.ts index ef6235f58585..f75f57c56662 100644 --- a/src/infra/package-update-steps.ts +++ b/src/infra/package-update-steps.ts @@ -2,11 +2,13 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { formatErrorMessage } from "./errors.js"; import { pathExists } from "./fs-safe.js"; import { readPackageVersion } from "./package-json.js"; import { movePathWithCopyFallback } from "./replace-file.js"; import { trimLogTail } from "./restart-sentinel.js"; +import { parseSemver } from "./runtime-guard.js"; import { PACKAGE_POST_INSTALL_DOCTOR_ADVISORY, UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE, @@ -18,8 +20,11 @@ import { collectInstalledGlobalPackageErrors, globalInstallArgs, globalInstallFallbackArgs, + listActivePnpmIsolatedGlobalPackages, + readPackageManagerProbeValue, resolveNpmGlobalPrefixLayoutFromGlobalRoot, resolveNpmGlobalPrefixLayoutFromPrefix, + resolvePnpmIsolatedInstallOwner, resolvePnpmGlobalDirFromGlobalRoot, resolveExpectedInstalledVersionFromSpec, resolveGlobalInstallTarget, @@ -73,6 +78,211 @@ type NpmBinShimBackup = { }; const NPM_PACK_QUIET_FLAGS = ["--json", "--loglevel=error"] as const; +const PACKAGE_INSTALL_GUARD_PATH = path.join("dist", "openclaw-install-guard"); +const PACKAGE_LIFECYCLE_PENDING_PATH = ".openclaw-lifecycle-pending"; +const PACKAGE_PREINSTALL_SCRIPT_PATH = path.join( + "scripts", + "preinstall-package-manager-warning.mjs", +); + +async function resolveCanonicalPath(filePath: string): Promise { + return path.resolve(await fs.realpath(filePath).catch(() => filePath)); +} + +async function runPnpmPreflightProbe(params: { + installTarget: ResolvedGlobalInstallTarget; + args: string[]; + runCommand: CommandRunner; + timeoutMs: number; + env?: NodeJS.ProcessEnv; +}): Promise<{ + result: Awaited> | null; + failedStep: PackageUpdateStepResult | null; +}> { + const startedAt = Date.now(); + const argv = [params.installTarget.command, ...params.args]; + const probeCwd = params.installTarget.globalRoot ?? undefined; + try { + // pnpm reads project packageManager/config for every command. Keep all + // ownership probes in one manager-owned context before mutation. + const result = await params.runCommand(argv, { + timeoutMs: params.timeoutMs, + env: params.env, + ...(probeCwd ? { cwd: probeCwd } : {}), + }); + if (result.code === 0) { + return { result, failedStep: null }; + } + return { + result: null, + failedStep: { + name: "pnpm isolated install preflight", + command: argv.join(" "), + cwd: probeCwd ?? process.cwd(), + durationMs: Date.now() - startedAt, + exitCode: result.code ?? 1, + stdoutTail: result.stdout || null, + stderrTail: result.stderr || `Unable to run ${argv.join(" ")}.`, + }, + }; + } catch (error) { + return { + result: null, + failedStep: { + name: "pnpm isolated install preflight", + command: argv.join(" "), + cwd: probeCwd ?? process.cwd(), + durationMs: Date.now() - startedAt, + exitCode: 1, + stdoutTail: null, + stderrTail: formatErrorMessage(error), + }, + }; + } +} + +async function validatePnpmIsolatedUpdate(params: { + installTarget: ResolvedGlobalInstallTarget; + packageName: string; + runCommand: CommandRunner; + timeoutMs: number; + env?: NodeJS.ProcessEnv; +}): Promise<{ + globalBinDir: string | null; + failedStep: PackageUpdateStepResult | null; +}> { + const owner = params.installTarget.pnpmIsolated; + if (!owner) { + return { globalBinDir: null, failedStep: null }; + } + const activePackages = await listActivePnpmIsolatedGlobalPackages({ + globalRoot: params.installTarget.globalRoot, + packageName: params.packageName, + }); + const activePackageRoots = activePackages.map((entry) => entry.packageRoot); + const siblingPackages = [ + ...new Set( + activePackages.flatMap((entry) => + entry.packageNames.filter((name) => name !== params.packageName), + ), + ), + ].toSorted((a, b) => a.localeCompare(b)); + if (siblingPackages.length > 0) { + return { + globalBinDir: null, + failedStep: { + name: "pnpm isolated install preflight", + command: `inspect ${params.installTarget.globalRoot ?? "pnpm install"}`, + cwd: params.installTarget.globalRoot ?? process.cwd(), + durationMs: 0, + exitCode: 1, + stdoutTail: null, + stderrTail: `OpenClaw shares a pnpm ${owner.layoutVersion} global install group with ${siblingPackages.join(", ")}. Automatic update stopped before mutation; update the group manually to preserve its sibling packages.`, + }, + }; + } + + const invokingPackageRoot = params.installTarget.packageRoot; + const invokingInstallOwner = await resolvePnpmIsolatedInstallOwner(invokingPackageRoot); + const activeInstallOwners = await Promise.all( + activePackageRoots.map((packageRoot) => resolvePnpmIsolatedInstallOwner(packageRoot)), + ); + const ownerMatchCount = invokingInstallOwner + ? activeInstallOwners.filter((installOwner) => installOwner === invokingInstallOwner).length + : 0; + if (!invokingPackageRoot || activePackageRoots.length !== 1 || ownerMatchCount !== 1) { + return { + globalBinDir: null, + failedStep: { + name: "pnpm isolated install preflight", + command: `inspect ${params.installTarget.globalRoot ?? "pnpm install"}`, + cwd: params.installTarget.globalRoot ?? process.cwd(), + durationMs: 0, + exitCode: 1, + stdoutTail: null, + stderrTail: `Expected exactly one active pnpm ${owner.layoutVersion} OpenClaw install owned by the invoking project; found ${activePackageRoots.length} active installs and ${ownerMatchCount} owner matches. Automatic update stopped before mutation.`, + }, + }; + } + + const rootProbe = await runPnpmPreflightProbe({ ...params, args: ["root", "-g"] }); + if (rootProbe.failedStep || !rootProbe.result) { + return { + globalBinDir: null, + failedStep: rootProbe.failedStep, + }; + } + const reportedGlobalRoot = readPackageManagerProbeValue(rootProbe.result.stdout); + const expectedGlobalRoot = params.installTarget.globalRoot; + if ( + !reportedGlobalRoot || + !expectedGlobalRoot || + (await resolveCanonicalPath(reportedGlobalRoot)) !== + (await resolveCanonicalPath(expectedGlobalRoot)) + ) { + return { + globalBinDir: null, + failedStep: { + name: "pnpm isolated install preflight", + command: `${params.installTarget.command} root -g`, + cwd: expectedGlobalRoot ?? process.cwd(), + durationMs: 0, + exitCode: 1, + stdoutTail: rootProbe.result.stdout || null, + stderrTail: `The active pnpm command owns ${reportedGlobalRoot || "an unknown global root"}, not the invoking OpenClaw install at ${expectedGlobalRoot ?? "an unknown root"}. Automatic update stopped before mutation.`, + }, + }; + } + + const binProbe = await runPnpmPreflightProbe({ ...params, args: ["bin", "-g"] }); + const globalBinDir = binProbe.result + ? readPackageManagerProbeValue(binProbe.result.stdout) || null + : null; + if (binProbe.failedStep || !globalBinDir) { + return { + globalBinDir: null, + failedStep: binProbe.failedStep ?? { + name: "pnpm isolated install preflight", + command: `${params.installTarget.command} bin -g`, + cwd: expectedGlobalRoot, + durationMs: 0, + exitCode: 1, + stdoutTail: null, + stderrTail: "The owning pnpm command did not report its global bin directory.", + }, + }; + } + + const versionProbe = await runPnpmPreflightProbe({ ...params, args: ["--version"] }); + if (versionProbe.failedStep || !versionProbe.result) { + return { + globalBinDir: null, + failedStep: versionProbe.failedStep, + }; + } + const reportedVersion = readPackageManagerProbeValue(versionProbe.result.stdout); + const version = parseSemver(reportedVersion); + if (version?.major !== owner.layoutVersion) { + return { + globalBinDir: null, + failedStep: { + name: "pnpm isolated install preflight", + command: `${params.installTarget.command} --version`, + cwd: expectedGlobalRoot, + durationMs: 0, + exitCode: 1, + stdoutTail: versionProbe.result.stdout || null, + stderrTail: `OpenClaw belongs to pnpm isolated layout v${owner.layoutVersion}, but the update command reports pnpm ${reportedVersion || "unknown"}. Use pnpm ${owner.layoutVersion} for this install or update it manually.`, + }, + }; + } + + return { + globalBinDir, + failedStep: null, + }; +} +const PACKAGE_POSTINSTALL_SCRIPT_PATH = path.join("scripts", "postinstall-bundled-plugins.mjs"); function isBlockingPackageUpdateStep(step: PackageUpdateStepResult): boolean { return step.exitCode !== 0 && step.advisory === undefined; @@ -234,6 +444,49 @@ function isNpmGitSourceInstallSpec(spec: string, packageName: string): boolean { ); } +function resolvePnpmInstallSpecFromCwd( + spec: string, + packageName: string, + sourceCwd: string, +): string { + const trimmed = spec.trim(); + const aliasPrefix = `${packageName.trim()}@`; + const hasAlias = trimmed.toLowerCase().startsWith(aliasPrefix.toLowerCase()); + const targetSpec = hasAlias ? trimmed.slice(aliasPrefix.length).trim() : trimmed; + const restoreAlias = (target: string) => (hasAlias ? `${aliasPrefix}${target}` : target); + if (/^~[\\/]/u.test(targetSpec)) { + return spec; + } + const localProtocol = /^(file:|git\+file:|link:)(.*)$/iu.exec(targetSpec); + if (localProtocol) { + const protocol = localProtocol[1] ?? ""; + const target = localProtocol[2]?.trim() ?? ""; + const fragmentIndex = protocol.toLowerCase() === "git+file:" ? target.indexOf("#") : -1; + const targetPath = fragmentIndex >= 0 ? target.slice(0, fragmentIndex) : target; + const fragment = fragmentIndex >= 0 ? target.slice(fragmentIndex) : ""; + if ( + targetPath && + !/^~[\\/]/u.test(targetPath) && + !path.isAbsolute(targetPath) && + !path.win32.isAbsolute(targetPath) + ) { + const windowsPath = /^[a-z]:[\\/]/iu.test(sourceCwd) || sourceCwd.startsWith("\\\\"); + const resolvedTarget = (windowsPath ? path.win32 : path).resolve(sourceCwd, targetPath); + if (protocol.toLowerCase() === "git+file:") { + return restoreAlias( + `git+${pathToFileURL(resolvedTarget, { windows: windowsPath }).href}${fragment}`, + ); + } + return restoreAlias(`${protocol}${resolvedTarget}`); + } + return spec; + } + // pnpm treats scheme-less tar-like values as registry names or tags. Only + // explicit dot paths are caller-relative and must move with the command cwd. + const isRelativePath = /^\.{1,2}(?:[\\/]|$)/u.test(targetSpec); + return isRelativePath ? restoreAlias(path.resolve(sourceCwd, targetSpec)) : spec; +} + async function createStagedNpmInstall( installTarget: ResolvedGlobalInstallTarget, packageName: string, @@ -585,12 +838,44 @@ export async function runGlobalPackageUpdateSteps(params: { afterVersion: string | null; failedStep: PackageUpdateStepResult | null; }> { - const installEnv = params.env === undefined ? {} : { env: params.env }; let stagedInstall: StagedNpmInstall | null | undefined; let packedInstallDir: string | null = null; try { - const preparedInstall = await prepareStagedNpmInstall(params.installTarget, params.packageName); + const pnpmPreflight = await validatePnpmIsolatedUpdate({ + installTarget: params.installTarget, + packageName: params.packageName, + runCommand: params.runCommand, + timeoutMs: params.timeoutMs, + env: params.env, + }); + if (pnpmPreflight.failedStep) { + return { + steps: [pnpmPreflight.failedStep], + verifiedPackageRoot: params.packageRoot ?? params.installTarget.packageRoot, + afterVersion: null, + failedStep: pnpmPreflight.failedStep, + }; + } + // Keep the preflight and mutation on the same pnpm executable. `pnpm bin -g` + // already verifies its reported bin is on PATH, so no PATH rewrite is needed. + const effectiveInstallEnv = params.env; + const installEnv = effectiveInstallEnv === undefined ? {} : { env: effectiveInstallEnv }; + const resolvedInstallTarget = + params.installTarget.pnpmIsolated && pnpmPreflight.globalBinDir + ? { + ...params.installTarget, + pnpmIsolated: { + ...params.installTarget.pnpmIsolated, + globalBinDir: pnpmPreflight.globalBinDir, + }, + } + : params.installTarget; + + const preparedInstall = await prepareStagedNpmInstall( + resolvedInstallTarget, + params.packageName, + ); stagedInstall = preparedInstall.stagedInstall; if (preparedInstall.failedStep) { return { @@ -602,7 +887,7 @@ export async function runGlobalPackageUpdateSteps(params: { } const steps: PackageUpdateStepResult[] = []; - const installCommandTarget = stagedInstall?.installTarget ?? params.installTarget; + const installCommandTarget = stagedInstall?.installTarget ?? resolvedInstallTarget; const preparedSpec = await prepareNpmGitSourceInstallSpec({ installTarget: installCommandTarget, installSpec: params.installSpec, @@ -628,16 +913,28 @@ export async function runGlobalPackageUpdateSteps(params: { (installCommandTarget.manager === "pnpm" ? resolvePnpmGlobalDirFromGlobalRoot(installCommandTarget.globalRoot) : null); + // pnpm selects its version from cwd. Keep every pnpm mutation beside its + // detected global root, after preserving caller-relative package specs. + const pnpmMutationCwd = + installCommandTarget.manager === "pnpm" ? installCommandTarget.globalRoot : null; + const updateCwd = pnpmMutationCwd ?? preparedSpec.installCwd; + const updateInstallSpec = pnpmMutationCwd + ? resolvePnpmInstallSpecFromCwd( + preparedSpec.installSpec, + params.packageName, + preparedSpec.installCwd ?? process.cwd(), + ) + : preparedSpec.installSpec; const updateStep = await params.runStep({ name: "global update", argv: globalInstallArgs( installCommandTarget, - preparedSpec.installSpec, + updateInstallSpec, undefined, installLocation, preparedSpec.installCwd, ), - ...(preparedSpec.installCwd ? { cwd: preparedSpec.installCwd } : {}), + ...(updateCwd ? { cwd: updateCwd } : {}), ...installEnv, timeoutMs: params.timeoutMs, }); @@ -685,7 +982,60 @@ export async function runGlobalPackageUpdateSteps(params: { } } + // pnpm 11 replaces an isolated global project with a new install directory. + // Resolve it again before verification so doctor and version checks inspect + // the package behind the refreshed global shim, not the removed old root. + const refreshedPnpmPackageRoot = + finalInstallStep.exitCode === 0 && !stagedInstall && params.installTarget.pnpmIsolated + ? await (async () => { + const activeRoots = ( + await listActivePnpmIsolatedGlobalPackages({ + globalRoot: params.installTarget.globalRoot, + packageName: params.packageName, + }) + ).map((entry) => entry.packageRoot); + if (activeRoots.length !== 1 || !params.installTarget.packageRoot) { + return null; + } + const replacementRoot = activeRoots[0]; + if (!replacementRoot) { + return null; + } + const [replacementOwner, previousOwner] = await Promise.all([ + resolvePnpmIsolatedInstallOwner(replacementRoot), + resolvePnpmIsolatedInstallOwner(params.installTarget.packageRoot), + ]); + return replacementOwner && previousOwner && replacementOwner !== previousOwner + ? replacementRoot + : null; + })() + : null; + const pnpmReplacementMissing = + finalInstallStep.exitCode === 0 && + !stagedInstall && + params.installTarget.manager === "pnpm" && + params.installTarget.pnpmIsolated !== undefined && + params.installTarget.packageRoot !== null && + refreshedPnpmPackageRoot === null; + if (pnpmReplacementMissing) { + const replacementStep: PackageUpdateStepResult = { + name: "global install verify", + command: `resolve pnpm replacement in ${params.installTarget.globalRoot ?? "unknown root"}`, + cwd: params.installTarget.globalRoot ?? process.cwd(), + durationMs: 0, + exitCode: 1, + stderrTail: "could not identify a unique active pnpm replacement package", + }; + steps.push(replacementStep); + return { + steps, + verifiedPackageRoot: params.packageRoot ?? null, + afterVersion: null, + failedStep: replacementStep, + }; + } const livePackageRoot = + refreshedPnpmPackageRoot ?? params.installTarget.packageRoot ?? params.packageRoot ?? ( @@ -693,12 +1043,99 @@ export async function runGlobalPackageUpdateSteps(params: { manager: params.installTarget, runCommand: params.runCommand, timeoutMs: params.timeoutMs, + packageName: params.packageName, }) ).packageRoot ?? null; const verificationPackageRoot = stagedInstall?.packageRoot ?? livePackageRoot; let verifiedPackageRoot = livePackageRoot ?? verificationPackageRoot; + // Some pnpm releases accept --allow-build for global local-tar installs + // but still skip lifecycle scripts. Keep a marker outside dist because + // postinstall prunes the packed guard before the remaining work finishes. + if ( + finalInstallStep.exitCode === 0 && + !stagedInstall && + params.installTarget.manager === "pnpm" && + verificationPackageRoot + ) { + const installGuardPath = path.join(verificationPackageRoot, PACKAGE_INSTALL_GUARD_PATH); + const lifecyclePendingPath = path.join( + verificationPackageRoot, + PACKAGE_LIFECYCLE_PENDING_PATH, + ); + const hasInstallGuard = await pathExists(installGuardPath); + const hasPendingLifecycle = await pathExists(lifecyclePendingPath); + if (hasInstallGuard || hasPendingLifecycle) { + if (!hasPendingLifecycle) { + try { + await fs.writeFile(lifecyclePendingPath, "pending\n", "utf8"); + } catch (error) { + const markerStep: PackageUpdateStepResult = { + name: "pnpm package lifecycle marker", + command: `write ${lifecyclePendingPath}`, + cwd: verificationPackageRoot, + durationMs: 0, + exitCode: 1, + stderrTail: formatErrorMessage(error), + }; + steps.push(markerStep); + return { + steps, + verifiedPackageRoot, + afterVersion: null, + failedStep: markerStep, + }; + } + } + + const lifecycleScripts = [ + ...(hasInstallGuard + ? [["pnpm package preinstall", PACKAGE_PREINSTALL_SCRIPT_PATH] as const] + : []), + ["pnpm package postinstall", PACKAGE_POSTINSTALL_SCRIPT_PATH] as const, + ]; + for (const [name, relativeScript] of lifecycleScripts) { + const lifecycleStep = await params.runStep({ + name, + argv: [process.execPath, path.join(verificationPackageRoot, relativeScript)], + cwd: verificationPackageRoot, + env: effectiveInstallEnv, + timeoutMs: params.timeoutMs, + }); + steps.push(lifecycleStep); + if (lifecycleStep.exitCode !== 0) { + return { + steps, + verifiedPackageRoot, + afterVersion: null, + failedStep: lifecycleStep, + }; + } + } + + try { + await fs.rm(lifecyclePendingPath); + } catch (error) { + const finalizeStep: PackageUpdateStepResult = { + name: "pnpm package lifecycle finalize", + command: `remove ${lifecyclePendingPath}`, + cwd: verificationPackageRoot, + durationMs: 0, + exitCode: 1, + stderrTail: formatErrorMessage(error), + }; + steps.push(finalizeStep); + return { + steps, + verifiedPackageRoot, + afterVersion: null, + failedStep: finalizeStep, + }; + } + } + } + let afterVersion: string | null = null; if (finalInstallStep.exitCode === 0 && verificationPackageRoot) { const candidateVersion = await readPackageVersion(verificationPackageRoot); diff --git a/src/infra/update-global.pnpm11-discovery.test.ts b/src/infra/update-global.pnpm11-discovery.test.ts new file mode 100644 index 000000000000..3391e1f10a3b --- /dev/null +++ b/src/infra/update-global.pnpm11-discovery.test.ts @@ -0,0 +1,355 @@ +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 { + detectGlobalInstallManagerForRoot, + listActivePnpmIsolatedGlobalPackages, + resolveGlobalInstallTarget, + resolvePnpmGlobalDirFromGlobalRoot, + type CommandRunner, +} from "./update-global.js"; + +async function writeGlobalPackageJson(packageRoot: string, version: string): Promise { + await fs.writeFile( + path.join(packageRoot, "package.json"), + JSON.stringify({ name: "openclaw", version }), + "utf8", + ); +} + +async function writePnpmIsolatedPackage(params: { + globalRoot: string; + installName: string; + version: string; + dependencies?: Record; +}): Promise { + const installDir = path.join(params.globalRoot, params.installName); + const packageRoot = path.join(installDir, "node_modules", "openclaw"); + await fs.mkdir(packageRoot, { recursive: true }); + await Promise.all([ + writeGlobalPackageJson(packageRoot, params.version), + fs.writeFile( + path.join(installDir, "package.json"), + JSON.stringify({ + private: true, + dependencies: { openclaw: params.version, ...params.dependencies }, + }), + "utf8", + ), + ]); + await fs.symlink(installDir, path.join(params.globalRoot, `hash-${params.installName}`), "dir"); + return packageRoot; +} + +describe("pnpm 11 global install discovery", () => { + it("detects isolated global installs from the active project link", async () => { + await withTempDir({ prefix: "openclaw-update-pnpm-isolated-root-" }, async (base) => { + const npmRoot = path.join(base, "npm", "lib", "node_modules"); + const pnpmGlobalDir = path.join(base, "pnpm-home", "global"); + const pnpmGlobalRoot = path.join(pnpmGlobalDir, "v11"); + const pkgRoot = await writePnpmIsolatedPackage({ + globalRoot: pnpmGlobalRoot, + installName: "a1b2", + version: "2026.7.1", + }); + const hashLinkedPkgRoot = path.join(pnpmGlobalRoot, "hash-a1b2", "node_modules", "openclaw"); + const pnpmHomeAlias = path.join(base, "pnpm-home-alias"); + await fs.symlink(path.join(base, "pnpm-home"), pnpmHomeAlias, "dir"); + const aliasedPkgRoot = path.join( + pnpmHomeAlias, + "global", + "v11", + "a1b2", + "node_modules", + "openclaw", + ); + await fs.mkdir(path.join(npmRoot, "openclaw"), { recursive: true }); + + const runCommand: CommandRunner = async (argv) => { + const command = argv.join(" "); + if (command === "npm root -g") { + return { stdout: `${npmRoot}\n`, stderr: "", code: 0 }; + } + if (command === "pnpm root -g") { + return { + stdout: `[WARN] Using --global skips the package manager check for this project\n${pnpmGlobalRoot}\n`, + stderr: "", + code: 0, + }; + } + throw new Error(`unexpected command: ${command}`); + }; + + await expect(detectGlobalInstallManagerForRoot(runCommand, pkgRoot, 1000)).resolves.toBe( + "pnpm", + ); + await expect( + detectGlobalInstallManagerForRoot(runCommand, hashLinkedPkgRoot, 1000), + ).resolves.toBe("pnpm"); + await expect( + detectGlobalInstallManagerForRoot(runCommand, aliasedPkgRoot, 1000), + ).resolves.toBe("pnpm"); + await expect( + resolveGlobalInstallTarget({ + manager: "pnpm", + runCommand, + timeoutMs: 1000, + pkgRoot, + honorPackageRoot: true, + }), + ).resolves.toEqual({ + manager: "pnpm", + command: "pnpm", + pnpmIsolated: { layoutVersion: 11 }, + globalRoot: pnpmGlobalRoot, + packageRoot: pkgRoot, + }); + expect(resolvePnpmGlobalDirFromGlobalRoot(pnpmGlobalRoot)).toBe(pnpmGlobalDir); + }); + }); + + it("prefers the invoking project when multiple installs are active", async () => { + await withTempDir({ prefix: "openclaw-update-pnpm-isolated-owner-" }, async (base) => { + const pnpmGlobalRoot = path.join(base, "pnpm-home", "global", "v11"); + const otherPackageRoot = await writePnpmIsolatedPackage({ + globalRoot: pnpmGlobalRoot, + installName: "a-other", + version: "2026.7.1", + }); + const invokingPackageRoot = await writePnpmIsolatedPackage({ + globalRoot: pnpmGlobalRoot, + installName: "z-invoking", + version: "2026.7.2", + dependencies: { cowsay: "1.6.0" }, + }); + const runCommand: CommandRunner = async (argv) => { + if (argv.join(" ") === "pnpm root -g") { + return { stdout: `${pnpmGlobalRoot}\n`, stderr: "", code: 0 }; + } + throw new Error(`unexpected command: ${argv.join(" ")}`); + }; + + await expect( + listActivePnpmIsolatedGlobalPackages({ + globalRoot: pnpmGlobalRoot, + packageName: "openclaw", + }), + ).resolves.toEqual([ + { packageRoot: otherPackageRoot, packageNames: ["openclaw"] }, + { packageRoot: invokingPackageRoot, packageNames: ["cowsay", "openclaw"] }, + ]); + await expect( + resolveGlobalInstallTarget({ + manager: "pnpm", + runCommand, + timeoutMs: 1000, + pkgRoot: invokingPackageRoot, + packageName: "openclaw", + }), + ).resolves.toEqual({ + manager: "pnpm", + command: "pnpm", + pnpmIsolated: { layoutVersion: 11 }, + globalRoot: pnpmGlobalRoot, + packageRoot: invokingPackageRoot, + }); + }); + }); + + it("does not adopt another pnpm project through a shared-store package symlink", async () => { + await withTempDir({ prefix: "openclaw-update-pnpm-shared-store-owner-" }, async (base) => { + const globalRoot = path.join(base, "pnpm-home", "global", "v11"); + const activeInstallRoot = path.join(globalRoot, "active"); + const orphanInstallRoot = path.join(globalRoot, "orphan"); + const activePackageRoot = path.join(activeInstallRoot, "node_modules", "openclaw"); + const orphanPackageRoot = path.join(orphanInstallRoot, "node_modules", "openclaw"); + const sharedPackageRoot = path.join(base, "store", "openclaw"); + await Promise.all([ + fs.mkdir(path.dirname(activePackageRoot), { recursive: true }), + fs.mkdir(path.dirname(orphanPackageRoot), { recursive: true }), + fs.mkdir(sharedPackageRoot, { recursive: true }), + ]); + await Promise.all([ + writeGlobalPackageJson(sharedPackageRoot, "2026.7.1"), + fs.writeFile( + path.join(activeInstallRoot, "package.json"), + JSON.stringify({ private: true, dependencies: { openclaw: "2026.7.1" } }), + "utf8", + ), + fs.writeFile( + path.join(orphanInstallRoot, "package.json"), + JSON.stringify({ private: true, dependencies: { openclaw: "2026.7.1" } }), + "utf8", + ), + fs.writeFile(path.join(orphanInstallRoot, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"), + ]); + await Promise.all([ + fs.symlink(sharedPackageRoot, activePackageRoot, "dir"), + fs.symlink(sharedPackageRoot, orphanPackageRoot, "dir"), + fs.symlink(activeInstallRoot, path.join(globalRoot, "hash-active"), "dir"), + ]); + const runCommand: CommandRunner = async (argv) => { + if (argv.join(" ") === "pnpm root -g") { + return { stdout: `${globalRoot}\n`, stderr: "", code: 0 }; + } + throw new Error(`unexpected command: ${argv.join(" ")}`); + }; + + await expect( + resolveGlobalInstallTarget({ + manager: "pnpm", + runCommand, + timeoutMs: 1000, + pkgRoot: orphanPackageRoot, + packageName: "openclaw", + }), + ).resolves.toEqual({ + manager: "pnpm", + command: "pnpm", + pnpmIsolated: { layoutVersion: 11 }, + globalRoot, + packageRoot: orphanPackageRoot, + }); + }); + }); + + it("preserves pnpm 11 ownership when the invoking project is orphaned", async () => { + await withTempDir({ prefix: "openclaw-update-pnpm-isolated-orphan-" }, async (base) => { + const pnpmGlobalRoot = path.join(base, "pnpm-home", "global", "v11"); + const orphanPackageRoot = path.join(pnpmGlobalRoot, "orphan", "node_modules", "openclaw"); + await fs.mkdir(orphanPackageRoot, { recursive: true }); + const orphanInstallRoot = path.join(pnpmGlobalRoot, "orphan"); + await Promise.all([ + writeGlobalPackageJson(orphanPackageRoot, "2026.7.1"), + fs.writeFile( + path.join(orphanInstallRoot, "package.json"), + JSON.stringify({ private: true, dependencies: { openclaw: "2026.7.1" } }), + "utf8", + ), + fs.writeFile(path.join(orphanInstallRoot, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"), + ]); + const detectRunner = vi.fn().mockResolvedValue({ + stdout: "", + stderr: "not active", + code: 1, + }); + + await expect( + detectGlobalInstallManagerForRoot(detectRunner, orphanPackageRoot, 1000), + ).resolves.toBe("pnpm"); + expect(detectRunner).toHaveBeenCalledTimes(2); + + const runCommand: CommandRunner = async (argv) => { + if (argv.join(" ") === "pnpm root -g") { + return { + stdout: `${path.join(base, "other-pnpm-home", "global", "v11")}\n`, + stderr: "", + code: 0, + }; + } + throw new Error(`unexpected command: ${argv.join(" ")}`); + }; + await expect( + resolveGlobalInstallTarget({ + manager: "npm", + runCommand, + timeoutMs: 1000, + pkgRoot: orphanPackageRoot, + packageName: "openclaw", + }), + ).resolves.toEqual({ + manager: "pnpm", + command: "pnpm", + pnpmIsolated: { layoutVersion: 11 }, + globalRoot: pnpmGlobalRoot, + packageRoot: orphanPackageRoot, + }); + }); + }); + + it("keeps npm ownership when its prefix is named like a pnpm layout", async () => { + await withTempDir({ prefix: "openclaw-update-npm-v11-prefix-" }, async (base) => { + const npmPrefix = path.join(base, "v11"); + const npmGlobalRoot = path.join(npmPrefix, "lib", "node_modules"); + const packageRoot = path.join(npmGlobalRoot, "openclaw"); + await fs.mkdir(packageRoot, { recursive: true }); + await writeGlobalPackageJson(packageRoot, "2026.7.1"); + const runCommand: CommandRunner = async (argv) => { + const command = argv.join(" "); + if (command === "npm root -g") { + return { stdout: `${npmGlobalRoot}\n`, stderr: "", code: 0 }; + } + if (command === "pnpm root -g") { + return { + stdout: `${path.join(base, "pnpm-home", "global", "v11")}\n`, + stderr: "", + code: 0, + }; + } + throw new Error(`unexpected command: ${command}`); + }; + + await expect(detectGlobalInstallManagerForRoot(runCommand, packageRoot, 1000)).resolves.toBe( + "npm", + ); + await expect( + resolveGlobalInstallTarget({ + manager: "npm", + runCommand, + timeoutMs: 1000, + pkgRoot: packageRoot, + packageName: "openclaw", + honorPackageRoot: true, + }), + ).resolves.toEqual({ + manager: "npm", + command: "npm", + globalRoot: npmGlobalRoot, + packageRoot, + }); + }); + }); + + 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"), + }); + }); + }); +}); diff --git a/src/infra/update-global.test.ts b/src/infra/update-global.test.ts index dc438673ca5f..dac319661c32 100644 --- a/src/infra/update-global.test.ts +++ b/src/infra/update-global.test.ts @@ -636,48 +636,6 @@ describe("update global helpers", () => { }); }); - 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 npm staged install argv with an explicit prefix", () => { expect(globalInstallArgs("npm", "openclaw@latest", null, "/tmp/stage")).toEqual([ "npm", @@ -768,6 +726,27 @@ describe("update global helpers", () => { "--trust", "openclaw@latest", ]); + expect(globalInstallArgs("bun", "/tmp/openclaw-current.tgz")).toEqual([ + "bun", + "add", + "-g", + "--trust", + "openclaw@file:/tmp/openclaw-current.tgz", + ]); + expect(globalInstallArgs("bun", "https://example.test/openclaw.tgz")).toEqual([ + "bun", + "add", + "-g", + "--trust", + "openclaw@https://example.test/openclaw.tgz", + ]); + expect(globalInstallArgs("bun", "github:openclaw/openclaw#main")).toEqual([ + "bun", + "add", + "-g", + "--trust", + "openclaw@github:openclaw/openclaw#main", + ]); expect(globalInstallFallbackArgs("npm", "openclaw@latest")).toEqual([ "npm", "i", diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts index 93122494182a..a1b48c849954 100644 --- a/src/infra/update-global.ts +++ b/src/infra/update-global.ts @@ -39,6 +39,10 @@ export type CommandRunner = ( type ResolvedGlobalInstallCommand = { manager: GlobalInstallManager; command: string; + pnpmIsolated?: { + layoutVersion: number; + globalBinDir?: string; + }; }; /** @@ -77,6 +81,18 @@ function normalizePackageTarget(value: string): string { return value.trim(); } +/** Reads the command value after package-manager warnings printed on stdout. */ +export function readPackageManagerProbeValue(stdout: string): string { + const lines = stdout.split(/\r?\n/u); + for (let index = lines.length - 1; index >= 0; index -= 1) { + const value = lines[index]?.trim(); + if (value) { + return value; + } + } + return ""; +} + function normalizePackageVersionForComparison(value: string | null | undefined): string | null { const trimmed = value?.trim(); if (!trimmed) { @@ -679,6 +695,10 @@ function inferBunGlobalRootFromPackageRoot(pkgRoot?: string | null): string | nu } function inferPnpmGlobalRootFromPackageRoot(pkgRoot?: string | null): string | null { + const isolatedGlobalRoot = inferPnpmIsolatedGlobalRootFromPackageRoot(pkgRoot); + if (isolatedGlobalRoot) { + return isolatedGlobalRoot; + } const directGlobalRoot = inferGlobalRootFromPackageRoot(pkgRoot); if (resolvePnpmGlobalDirFromGlobalRoot(directGlobalRoot)) { return directGlobalRoot; @@ -703,9 +723,165 @@ function inferPnpmGlobalRootFromPackageRoot(pkgRoot?: string | null): string | n return resolvePnpmGlobalDirFromGlobalRoot(globalRoot) ? globalRoot : null; } +type PnpmIsolatedGlobalPackage = { + globalRoot: string; + packageRoot: string; + layoutVersion: number; + packageNames: string[]; +}; + +function resolvePnpmIsolatedLayoutVersion(globalRoot?: string | null): number | null { + const trimmed = globalRoot?.trim(); + const match = trimmed ? /^v(\d+)$/u.exec(path.basename(path.resolve(trimmed))) : null; + return match ? Number.parseInt(match[1] ?? "", 10) : null; +} + +function inferPnpmIsolatedGlobalRootFromPackageRoot(pkgRoot?: string | null): string | null { + const nodeModulesRoot = inferGlobalRootFromPackageRoot(pkgRoot); + if (!nodeModulesRoot) { + return null; + } + const globalRoot = path.dirname(path.dirname(nodeModulesRoot)); + return resolvePnpmIsolatedLayoutVersion(globalRoot) === null ? null : globalRoot; +} + +async function hasPnpmIsolatedProjectMetadata( + pkgRoot?: string | null, + packageName = PRIMARY_PACKAGE_NAME, +): Promise { + if (!inferPnpmIsolatedGlobalRootFromPackageRoot(pkgRoot)) { + return false; + } + const nodeModulesRoot = inferGlobalRootFromPackageRoot(pkgRoot); + if (!nodeModulesRoot) { + return false; + } + const installDir = path.dirname(nodeModulesRoot); + const manifest = await fs + .readFile(path.join(installDir, "package.json"), "utf8") + .then((raw) => JSON.parse(raw) as { dependencies?: Record }) + .catch(() => null); + return Boolean( + manifest?.dependencies && + packageName in manifest.dependencies && + (await pathExists(path.join(installDir, "pnpm-lock.yaml"))), + ); +} + +/** Resolves the pnpm project owner without following its shared-store package symlink. */ +export async function resolvePnpmIsolatedInstallOwner( + pkgRoot?: string | null, +): Promise { + const nodeModulesRoot = inferGlobalRootFromPackageRoot(pkgRoot); + if (!nodeModulesRoot) { + return null; + } + return path.resolve(await tryRealpath(path.dirname(nodeModulesRoot))); +} + +async function listPnpmIsolatedGlobalPackages(params: { + globalRoot?: string | null; + packageName?: string; +}): Promise { + const globalRoot = params.globalRoot?.trim(); + const layoutVersion = resolvePnpmIsolatedLayoutVersion(globalRoot); + if (!globalRoot || layoutVersion === null) { + return []; + } + const packageName = params.packageName?.trim() || PRIMARY_PACKAGE_NAME; + const entries = await fs.readdir(globalRoot, { withFileTypes: true }).catch(() => []); + const packages: PnpmIsolatedGlobalPackage[] = []; + + // pnpm 11 marks active isolated projects with hash symlinks. Scan those + // links, not install directories, so orphaned replacement roots stay ignored. + for (const entry of entries.toSorted((a, b) => a.name.localeCompare(b.name))) { + if (!entry.isSymbolicLink()) { + continue; + } + const installDir = await fs.realpath(path.join(globalRoot, entry.name)).catch(() => null); + if (!installDir) { + continue; + } + const manifest = await fs + .readFile(path.join(installDir, "package.json"), "utf8") + .then((raw) => JSON.parse(raw) as { dependencies?: Record }) + .catch(() => null); + if (!manifest?.dependencies || !(packageName in manifest.dependencies)) { + continue; + } + const packageRoot = resolvePackageRootFromGlobalRoot({ + globalRoot: path.join(installDir, "node_modules"), + packageName, + }); + if (await pathExists(packageRoot)) { + packages.push({ + globalRoot: path.resolve(globalRoot), + packageRoot, + layoutVersion, + packageNames: Object.keys(manifest.dependencies).toSorted((a, b) => a.localeCompare(b)), + }); + } + } + return packages; +} + +export async function listActivePnpmIsolatedGlobalPackages(params: { + globalRoot?: string | null; + packageName?: string; +}): Promise> { + return (await listPnpmIsolatedGlobalPackages(params)).map((entry) => ({ + packageRoot: entry.packageRoot, + packageNames: entry.packageNames, + })); +} + +async function resolvePnpmIsolatedGlobalPackage(params: { + globalRoot?: string | null; + packageName?: string; + pkgRoot?: string | null; +}): Promise { + const packages = await listPnpmIsolatedGlobalPackages(params); + const requestedPackageRoot = params.pkgRoot ? path.resolve(params.pkgRoot) : null; + const requestedOwnerRoot = inferPnpmIsolatedGlobalRootFromPackageRoot(params.pkgRoot); + const globalRoot = params.globalRoot?.trim(); + const canonicalRequestedOwnerRoot = requestedOwnerRoot + ? path.resolve(await tryRealpath(requestedOwnerRoot)) + : null; + const canonicalGlobalRoot = globalRoot ? path.resolve(await tryRealpath(globalRoot)) : null; + const canonicalOwnerMatches = + canonicalRequestedOwnerRoot !== null && canonicalRequestedOwnerRoot === canonicalGlobalRoot; + const requestedInstallOwner = + requestedPackageRoot && canonicalOwnerMatches + ? await resolvePnpmIsolatedInstallOwner(requestedPackageRoot) + : null; + + for (const entry of packages) { + const packageRoot = entry.packageRoot; + if (requestedPackageRoot) { + // Compare the isolated project owners, not the package symlinks: separate + // pnpm 11 projects can point at the same shared-store package directory. + const installOwner = await resolvePnpmIsolatedInstallOwner(packageRoot); + if (requestedInstallOwner === null || installOwner !== requestedInstallOwner) { + continue; + } + } + return entry; + } + return null; +} + +async function isPnpmIsolatedGlobalPackageRoot(pkgRoot?: string | null): Promise { + const globalRoot = inferPnpmIsolatedGlobalRootFromPackageRoot(pkgRoot); + if (!globalRoot) { + return false; + } + return Boolean(await resolvePnpmIsolatedGlobalPackage({ globalRoot, pkgRoot })); +} + /** - * Resolves pnpm's global-dir from its active `node_modules` root. - * Versioned pnpm layouts put packages under `//node_modules`. + * Resolves pnpm's global-dir from its active global package root. + * pnpm 10 used `//node_modules`; pnpm 11 uses + * `/v` with isolated package projects below it. */ export function resolvePnpmGlobalDirFromGlobalRoot(globalRoot?: string | null): string | null { const trimmed = globalRoot?.trim(); @@ -713,6 +889,9 @@ export function resolvePnpmGlobalDirFromGlobalRoot(globalRoot?: string | null): return null; } const normalized = path.resolve(trimmed); + if (/^v\d+$/u.test(path.basename(normalized))) { + return path.dirname(normalized); + } if (path.basename(normalized) !== "node_modules") { return null; } @@ -721,6 +900,12 @@ export function resolvePnpmGlobalDirFromGlobalRoot(globalRoot?: string | null): } async function isPnpmGlobalPackageRoot(pkgRoot?: string | null): Promise { + if (await isPnpmIsolatedGlobalPackageRoot(pkgRoot)) { + return true; + } + if (await hasPnpmIsolatedProjectMetadata(pkgRoot)) { + return true; + } const globalRoot = inferPnpmGlobalRootFromPackageRoot(pkgRoot); if (!globalRoot) { return false; @@ -768,6 +953,19 @@ function normalizeGlobalInstallCommand( : managerOrCommand; } +function resolveBunGlobalInstallSpec(spec: string): string { + const trimmed = normalizePackageTarget(spec); + if (normalizeLowercaseStringOrEmpty(trimmed).startsWith(`${PRIMARY_PACKAGE_NAME}@`)) { + return trimmed; + } + const isWindowsAbsolutePath = /^[a-z]:[\\/]/iu.test(trimmed); + const hasScheme = /^[a-z][a-z0-9+.-]*:/iu.test(trimmed) && !isWindowsAbsolutePath; + const target = /\.(?:tgz|tar\.gz)$/iu.test(trimmed) && !hasScheme ? `file:${trimmed}` : trimmed; + // Bun needs an alias to replace the existing global dependency. A bare + // tarball is added beside it and can form an openclaw dependency loop. + return `${PRIMARY_PACKAGE_NAME}@${target}`; +} + function resolveInstallCommandForManager( managerOrCommand: GlobalInstallManager | ResolvedGlobalInstallCommand, manager: GlobalInstallManager, @@ -798,7 +996,7 @@ async function resolveGlobalRoot( if (!res || res.code !== 0) { return null; } - const root = res.stdout.trim(); + const root = readPackageManagerProbeValue(res.stdout); return root || null; } @@ -814,30 +1012,61 @@ export async function resolveGlobalInstallTarget(params: { honorPackageRoot?: boolean; packageName?: string; }): Promise { + const requestedCommand = normalizeGlobalInstallCommand(params.manager, params.pkgRoot); + const requestedPnpmGlobalRoot = + requestedCommand.manager === "pnpm" + ? await resolveGlobalRoot( + requestedCommand, + params.runCommand, + params.timeoutMs, + params.pkgRoot, + ) + : null; + const inferredPnpmIsolatedGlobalRoot = inferPnpmIsolatedGlobalRootFromPackageRoot(params.pkgRoot); + const pnpmIsolatedPackage = inferredPnpmIsolatedGlobalRoot + ? await resolvePnpmIsolatedGlobalPackage({ + globalRoot: inferredPnpmIsolatedGlobalRoot, + packageName: params.packageName, + pkgRoot: params.pkgRoot, + }) + : await resolvePnpmIsolatedGlobalPackage({ + globalRoot: requestedPnpmGlobalRoot, + packageName: params.packageName, + pkgRoot: params.pkgRoot, + }); + const hasPnpmIsolatedMetadata = pnpmIsolatedPackage + ? true + : await hasPnpmIsolatedProjectMetadata(params.pkgRoot, params.packageName); + const verifiedPnpmIsolatedGlobalRoot = + pnpmIsolatedPackage?.globalRoot ?? + (hasPnpmIsolatedMetadata ? inferredPnpmIsolatedGlobalRoot : null); const honoredPackageRootGlobalRoot = params.honorPackageRoot ? inferGlobalRootFromPackageRoot(params.pkgRoot) : null; - const pnpmPackageRootGlobalRoot = (await isPnpmGlobalPackageRoot(params.pkgRoot)) - ? inferPnpmGlobalRootFromPackageRoot(params.pkgRoot) - : null; + const pnpmPackageRootGlobalRoot = + verifiedPnpmIsolatedGlobalRoot || (await isPnpmGlobalPackageRoot(params.pkgRoot)) + ? inferPnpmGlobalRootFromPackageRoot(params.pkgRoot) + : null; const bunPackageRootGlobalRoot = inferBunGlobalRootFromPackageRoot(params.pkgRoot); const honoredDirectNpmRoot = + verifiedPnpmIsolatedGlobalRoot === null && + pnpmIsolatedPackage === null && pnpmPackageRootGlobalRoot === null && bunPackageRootGlobalRoot === null && isDirectNpmNodeModulesRoot(honoredPackageRootGlobalRoot); const command = bunPackageRootGlobalRoot ? resolveInstallCommandForManager(params.manager, "bun", params.pkgRoot) - : pnpmPackageRootGlobalRoot + : verifiedPnpmIsolatedGlobalRoot || pnpmPackageRootGlobalRoot ? resolveInstallCommandForManager(params.manager, "pnpm", params.pkgRoot) : honoredDirectNpmRoot ? resolveInstallCommandForManager(params.manager, "npm", params.pkgRoot) : normalizeGlobalInstallCommand(params.manager, params.pkgRoot); - const globalRoot = await resolveGlobalRoot( - command, - params.runCommand, - params.timeoutMs, - params.pkgRoot, - ); + const globalRoot = + requestedCommand.manager === "pnpm" && + command.manager === requestedCommand.manager && + command.command === requestedCommand.command + ? requestedPnpmGlobalRoot + : await resolveGlobalRoot(command, params.runCommand, params.timeoutMs, params.pkgRoot); const pkgRootGlobalRoot = command.manager === "pnpm" ? pnpmPackageRootGlobalRoot : null; // The detected npm owner applies to the running package, so its prefix is // authoritative. PATH's npm may belong to another Node installation and @@ -848,19 +1077,38 @@ export async function resolveGlobalInstallTarget(params: { : null; const targetGlobalRoot = (command.manager === "bun" ? bunPackageRootGlobalRoot : null) ?? + (command.manager === "pnpm" ? verifiedPnpmIsolatedGlobalRoot : null) ?? pkgRootGlobalRoot ?? (command.manager === "npm" ? honoredPackageRootGlobalRoot : null) ?? npmPackageRootGlobalRoot ?? globalRoot; + const pnpmIsolatedLayoutVersion = + pnpmIsolatedPackage?.layoutVersion ?? + resolvePnpmIsolatedLayoutVersion(verifiedPnpmIsolatedGlobalRoot); + const fallbackPackageRoot = targetGlobalRoot + ? resolvePackageRootFromGlobalRoot({ + globalRoot: targetGlobalRoot, + packageName: params.packageName, + }) + : null; + const packageRoot = + command.manager === "pnpm" + ? (pnpmIsolatedPackage?.packageRoot ?? + (verifiedPnpmIsolatedGlobalRoot && params.pkgRoot ? params.pkgRoot : fallbackPackageRoot)) + : fallbackPackageRoot; + // Preserve metadata-backed pnpm ownership when the invoking project link is gone. + // The update preflight must reject that orphan instead of falling through to npm. return { ...command, + ...(command.manager === "pnpm" && pnpmIsolatedLayoutVersion !== null + ? { + pnpmIsolated: { + layoutVersion: pnpmIsolatedLayoutVersion, + }, + } + : {}), globalRoot: targetGlobalRoot, - packageRoot: targetGlobalRoot - ? resolvePackageRootFromGlobalRoot({ - globalRoot: targetGlobalRoot, - packageName: params.packageName, - }) - : null, + packageRoot, ...(honoredPackageRootGlobalRoot && targetGlobalRoot === honoredPackageRootGlobalRoot && honoredDirectNpmRoot @@ -893,11 +1141,18 @@ export async function detectGlobalInstallManagerForRoot( if (!res || res.code !== 0) { continue; } - const globalRoot = res.stdout.trim(); + const globalRoot = readPackageManagerProbeValue(res.stdout); if (!globalRoot) { continue; } const globalReal = await tryRealpath(globalRoot); + if (manager === "pnpm") { + for (const name of ALL_PACKAGE_NAMES) { + if (await resolvePnpmIsolatedGlobalPackage({ globalRoot, packageName: name, pkgRoot })) { + return "pnpm"; + } + } + } for (const name of ALL_PACKAGE_NAMES) { const expected = path.join(globalReal, name); const expectedReal = await tryRealpath(expected); @@ -975,12 +1230,21 @@ export function globalInstallArgs( "add", "-g", ...(installPrefix ? ["--global-dir", installPrefix] : []), + ...(resolved.pnpmIsolated?.globalBinDir + ? ["--global-bin-dir", resolved.pnpmIsolated.globalBinDir] + : []), PNPM_OPENCLAW_BUILD_ALLOWLIST_FLAG, spec, ]; } if (resolved.manager === "bun") { - return [resolved.command, "add", "-g", BUN_OPENCLAW_TRUST_FLAG, spec]; + return [ + resolved.command, + "add", + "-g", + BUN_OPENCLAW_TRUST_FLAG, + resolveBunGlobalInstallSpec(spec), + ]; } return [ resolved.command, diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index 56f574ea3ad6..56a777c6a699 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -240,6 +240,62 @@ describe("runGatewayUpdate", () => { }, ); + it.runIf(process.platform !== "win32")( + "prefers the invoking pnpm 11 project over its shared-store cwd", + async () => { + const globalRoot = path.join(tempDir, "pnpm-home", "global", "v11"); + const installDir = path.join(globalRoot, "install-a"); + const packageRoot = path.join(installDir, "node_modules", "openclaw"); + const storeRoot = path.join(tempDir, "pnpm-home", "store", "v11", "links", "openclaw"); + await fs.mkdir(path.dirname(packageRoot), { recursive: true }); + await fs.mkdir(storeRoot, { recursive: true }); + await Promise.all([ + fs.writeFile( + path.join(installDir, "package.json"), + JSON.stringify({ private: true, dependencies: { openclaw: "1.0.0" } }), + "utf8", + ), + fs.writeFile( + path.join(storeRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "1.0.0" }), + "utf8", + ), + ]); + await Promise.all([ + fs.symlink(storeRoot, packageRoot, "dir"), + fs.symlink(installDir, path.join(globalRoot, "hash-openclaw"), "dir"), + ]); + + const runCommand = async (argv: string[]) => { + const command = argv.join(" "); + if (command.startsWith("git -C ")) { + return toCommandResult({ code: 1 }); + } + if (command === "npm root -g") { + return toCommandResult({ stdout: `${path.join(tempDir, "npm", "node_modules")}\n` }); + } + if (command === "pnpm root -g") { + return toCommandResult({ stdout: `${globalRoot}\n` }); + } + throw new Error(`unexpected command: ${command}`); + }; + + await expect( + resolveUpdateInstallSurface({ + cwd: storeRoot, + argv1: path.join(packageRoot, "openclaw.mjs"), + timeoutMs: 1000, + runCommand, + }), + ).resolves.toMatchObject({ + kind: "global", + mode: "pnpm", + root: packageRoot, + packageRoot, + }); + }, + ); + async function setupUiIndex() { const uiIndexPath = path.join(tempDir, "dist", "control-ui", "index.html"); await fs.mkdir(path.dirname(uiIndexPath), { recursive: true }); diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index a1a3478723ff..ee7e93f5c23e 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -281,18 +281,20 @@ function resolveNodeModulesBinPackageRoot(argv1: string): string | null { function buildStartDirs(opts: UpdateRunnerOptions): string[] { const dirs: string[] = []; - const cwd = normalizeDir(opts.cwd); - if (cwd) { - dirs.push(cwd); - } const argv1 = normalizeDir(opts.argv1); if (argv1) { + // Keep the lexical shim path ahead of a module-derived cwd. pnpm 11 module + // realpaths can point into a shared store that does not identify the owner. dirs.push(path.dirname(argv1)); const packageRoot = resolveNodeModulesBinPackageRoot(argv1); if (packageRoot) { dirs.push(packageRoot); } } + const cwd = normalizeDir(opts.cwd); + if (cwd) { + dirs.push(cwd); + } let proc: string | null; try { proc = normalizeDir(process.cwd()); diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index 18a0a371e5a7..8b4a9b52737b 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -4023,7 +4023,10 @@ heartbeat_elapsed="\${BASH_REMATCH[1]}" expect(runner).toContain('if [ "$UPDATE_FAILED" -ne 0 ]; then'); expect(runner).toContain('if [ "$GATEWAY_START_FAILED" -ne 0 ]; then'); expect(runner).toContain('if [ "$GATEWAY_HEALTH_FAILED" -ne 0 ]; then'); - expect(runner).toContain("ActiveState=active"); + expect(runner).toContain('printf "%s\\n" "\\$!" >"$GATEWAY_PID_FILE"'); + expect(runner).toContain('printf "ActiveState=active\\nSubState=running'); + expect(runner).toContain('status.service?.runtime?.status !== "running"'); + expect(runner).toContain("FAIL: gateway service was not running before update"); expect(runner).toContain("OPENCLAW_NO_RESPAWN=1"); expect(runner).toContain("is-enabled)"); expect(runner).toContain("/healthz");