From 2186080963f4c17276e59eb03035356240e0afe9 Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 27 Apr 2026 14:26:24 +0100 Subject: [PATCH] fix: stage npm updates under global root --- src/cli/update-cli/progress.test.ts | 10 ++++++++++ src/cli/update-cli/progress.ts | 4 +++- src/infra/package-update-steps.test.ts | 1 + src/infra/package-update-steps.ts | 3 ++- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/cli/update-cli/progress.test.ts b/src/cli/update-cli/progress.test.ts index 1c4c7998c40..a2003e6f03d 100644 --- a/src/cli/update-cli/progress.test.ts +++ b/src/cli/update-cli/progress.test.ts @@ -66,6 +66,16 @@ describe("inferUpdateFailureHints", () => { expect(hints.join("\n")).toContain("npm config set prefix ~/.local"); }); + it("returns EACCES hint for staged package permission failures", () => { + const result = makeResult( + "global install stage", + "EACCES: permission denied, mkdtemp '/usr/local/lib/node_modules/.openclaw-update-stage-'", + ); + const hints = inferUpdateFailureHints(result); + expect(hints.join("\n")).toContain("EACCES"); + expect(hints.join("\n")).toContain("npm config set prefix ~/.local"); + }); + it("returns native optional dependency hint for node-gyp failures", () => { const result = makeResult("global update", "node-pre-gyp ERR!\nnode-gyp rebuild failed"); const hints = inferUpdateFailureHints(result); diff --git a/src/cli/update-cli/progress.ts b/src/cli/update-cli/progress.ts index 09cb0cf454a..61797ffd3c0 100644 --- a/src/cli/update-cli/progress.ts +++ b/src/cli/update-cli/progress.ts @@ -78,8 +78,10 @@ export function inferUpdateFailureHints(result: UpdateRunResult): string[] { const stderr = normalizeLowercaseStringOrEmpty(failedStep.stderrTail); const hints: string[] = []; + const isGlobalPackageInstallStep = + failedStep.name.startsWith("global update") || failedStep.name.startsWith("global install"); - if (failedStep.name.startsWith("global update") && stderr.includes("eacces")) { + if (isGlobalPackageInstallStep && stderr.includes("eacces")) { hints.push( "Detected permission failure (EACCES). Re-run with a writable global prefix or sudo (for system-managed Node installs).", ); diff --git a/src/infra/package-update-steps.test.ts b/src/infra/package-update-steps.test.ts index 0e937eada59..593d1e46f24 100644 --- a/src/infra/package-update-steps.test.ts +++ b/src/infra/package-update-steps.test.ts @@ -66,6 +66,7 @@ describe("runGlobalPackageUpdateSteps", () => { if (!stagePrefix) { throw new Error("missing staged prefix"); } + expect(path.dirname(stagePrefix)).toBe(globalRoot); await writePackageRoot( path.join(stagePrefix, "lib", "node_modules", "openclaw"), "2.0.0", diff --git a/src/infra/package-update-steps.ts b/src/infra/package-update-steps.ts index 920f5f55a1e..a2befe2683f 100644 --- a/src/infra/package-update-steps.ts +++ b/src/infra/package-update-steps.ts @@ -62,7 +62,8 @@ async function createStagedNpmInstall( if (!targetLayout) { return null; } - const prefix = await fs.mkdtemp(path.join(targetLayout.prefix, ".openclaw-update-stage-")); + await fs.mkdir(targetLayout.globalRoot, { recursive: true }); + const prefix = await fs.mkdtemp(path.join(targetLayout.globalRoot, ".openclaw-update-stage-")); const layout = resolveNpmGlobalPrefixLayoutFromPrefix(prefix); return { prefix,