fix(update): preserve pnpm and Bun global installs (#107802)

* fix(update): preserve pnpm and bun global installs

* fix(update): anchor pnpm updates to invoking install

* fix(update): recover skipped pnpm lifecycle

* fix(update): fail closed on ambiguous pnpm ownership

* fix(update): tolerate pnpm probe warnings

* fix(update): bind pnpm installs to project owners

* fix(update): isolate pnpm mutations from caller pins

* docs(changelog): credit pnpm 11 report

Co-authored-by: jincheng-xydt <xu.jincheng@xydigit.com>

* chore(changelog): defer release note ownership

* fix(update): make package-root fallback explicit

* test(update): split pnpm scenario coverage

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
jincheng-xydt
2026-07-15 23:03:35 +08:00
committed by GitHub
parent c41f46260c
commit b2e42e3645
12 changed files with 2055 additions and 89 deletions

View File

@@ -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
```

View File

@@ -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 ──"

View File

@@ -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);
}
});
},
);
});

View File

@@ -173,12 +173,15 @@ export function resolveNodeRunner(): string {
/** Locate the installed OpenClaw package root that should receive update operations. */
export async function resolveUpdateRoot(): Promise<string> {
// 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()
);
}

View File

@@ -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<typeof runGlobalPackageUpdateSteps>
>["steps"][number];
async function writePackageRoot(packageRoot: string, version: string): Promise<void> {
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<string, string>;
}): 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<void> {
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<CommandRunner>();
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<CommandRunner>();
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<CommandRunner>();
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<CommandRunner>();
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<PackageUpdateStepResult> => {
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<PackageUpdateStepResult> => {
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<PackageUpdateStepResult> => {
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<CommandRunner>(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<PackageUpdateStepResult> => {
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<PackageUpdateStepResult> => {
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"));
});
});
});

View File

@@ -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<string> {
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<ReturnType<CommandRunner>> | 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);

View File

@@ -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<void> {
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<string, string>;
}): Promise<string> {
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<CommandRunner>().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"),
});
});
});
});

View File

@@ -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",

View File

@@ -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<boolean> {
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<string, unknown> })
.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<string | null> {
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<PnpmIsolatedGlobalPackage[]> {
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<string, unknown> })
.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<Array<{ packageRoot: string; packageNames: string[] }>> {
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<PnpmIsolatedGlobalPackage | null> {
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<boolean> {
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 `<globalDir>/<version>/node_modules`.
* Resolves pnpm's global-dir from its active global package root.
* pnpm 10 used `<globalDir>/<version>/node_modules`; pnpm 11 uses
* `<globalDir>/v<version>` 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<boolean> {
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<ResolvedGlobalInstallTarget> {
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,

View File

@@ -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 });

View File

@@ -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());

View File

@@ -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");