mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-24 04:51:10 +00:00
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:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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 ──"
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
805
src/infra/package-update-steps.pnpm11-guard.test.ts
Normal file
805
src/infra/package-update-steps.pnpm11-guard.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
355
src/infra/update-global.pnpm11-discovery.test.ts
Normal file
355
src/infra/update-global.pnpm11-discovery.test.ts
Normal 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"),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user