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