Skip to content

Commit 964fd53

Browse files
buddyhsteipete
andauthored
fix(update): keep self-updates on the running install's global root (#101228)
* fix(update): keep self-updates on the running install's global root Package self-updates resolved their install target from `npm root -g` even when the running install's package root was already known. In an environment where PATH's npm does not belong to the running install's Node tree (a launchd/systemd service PATH, or mixed Homebrew + nvm setups), the probe points at a different global root — or derives a brand-new one — so the update installs there, reports success, and leaves the running install stale. Observed in the wild: a gateway-managed `openclaw update --yes` on 2026.5.28 ran nvm's npm under Homebrew's node, npm derived its default prefix from the realpathed execPath, and the update installed a full copy into /opt/homebrew/Cellar/node/26.3.1/lib/node_modules/openclaw ("before": null — a tree that had never existed) while the running nvm install stayed on the old version until a manual shell update. resolveGlobalInstallTarget now prefers the package root's own global root for npm targets; the `npm root -g` probe only decides the target when no package root is known, and an ephemeral per-Node probe result (Homebrew Cellar, nvm/n/asdf/volta version dirs — the same set #93650 filters npm commands against) is never adopted. * fix(update): infer global roots for scoped package roots The running-root fallback missed scoped installs because inferGlobalRootFromPackageRoot only accepted an immediate node_modules parent. Hop over an @scope segment so node_modules/@scope/name resolves to the same global root. Regression test: scoped package root with an ephemeral probe now falls back to the running root. * fix(update): scope running-root selection * docs(changelog): note npm self-update root fix * chore(changelog): defer release note --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
1 parent 9b2bb9c commit 964fd53

2 files changed

Lines changed: 148 additions & 8 deletions

File tree

src/infra/update-global.test.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,140 @@ describe("update global helpers", () => {
482482
});
483483
});
484484

485+
it("keeps npm self-updates on the running package root when the PATH probe diverges", async () => {
486+
await withMockedPlatform("darwin", async () => {
487+
await withTempDir({ prefix: "openclaw-update-ephemeral-probe-" }, async (base) => {
488+
// The running install lives in an nvm tree while `npm root -g` on
489+
// PATH answers with a Homebrew Cellar root — the skew produced when a
490+
// per-Node npm shim is executed by a foreign node (e.g. a launchd
491+
// service PATH pairing nvm's npm with Homebrew's node). Installing
492+
// into the Cellar root would create a brand-new tree the running
493+
// install never loads from.
494+
const nvmPrefix = path.join(base, "home", ".nvm", "versions", "node", "v24.5.0");
495+
const nvmRoot = path.join(nvmPrefix, "lib", "node_modules");
496+
const pkgRoot = path.join(nvmRoot, "openclaw");
497+
const cellarRoot = path.join(
498+
base,
499+
"opt",
500+
"homebrew",
501+
"Cellar",
502+
"node",
503+
"26.3.1",
504+
"lib",
505+
"node_modules",
506+
);
507+
await fs.mkdir(pkgRoot, { recursive: true });
508+
509+
const runCommand = createNpmRootRunner({ defaultNpmRoot: cellarRoot });
510+
511+
await expect(
512+
resolveGlobalInstallTarget({
513+
manager: "npm",
514+
runCommand,
515+
timeoutMs: 1000,
516+
pkgRoot,
517+
}),
518+
).resolves.toEqual({
519+
manager: "npm",
520+
command: "npm",
521+
globalRoot: nvmRoot,
522+
packageRoot: pkgRoot,
523+
});
524+
});
525+
});
526+
});
527+
528+
it("keeps scoped npm self-updates on the running package root", async () => {
529+
await withMockedPlatform("darwin", async () => {
530+
await withTempDir({ prefix: "openclaw-update-scoped-probe-" }, async (base) => {
531+
const nvmPrefix = path.join(base, "home", ".nvm", "versions", "node", "v24.5.0");
532+
const nvmRoot = path.join(nvmPrefix, "lib", "node_modules");
533+
const pkgRoot = path.join(nvmRoot, "@scope", "cli");
534+
const cellarRoot = path.join(
535+
base,
536+
"opt",
537+
"homebrew",
538+
"Cellar",
539+
"node",
540+
"26.3.1",
541+
"lib",
542+
"node_modules",
543+
);
544+
await fs.mkdir(pkgRoot, { recursive: true });
545+
546+
const runCommand = createNpmRootRunner({ defaultNpmRoot: cellarRoot });
547+
548+
await expect(
549+
resolveGlobalInstallTarget({
550+
manager: "npm",
551+
runCommand,
552+
timeoutMs: 1000,
553+
pkgRoot,
554+
packageName: "@scope/cli",
555+
}),
556+
).resolves.toEqual({
557+
manager: "npm",
558+
command: "npm",
559+
globalRoot: nvmRoot,
560+
packageRoot: pkgRoot,
561+
});
562+
});
563+
});
564+
});
565+
566+
it("keeps the npm probe when the package root is not globally installed", async () => {
567+
await withMockedPlatform("darwin", async () => {
568+
await withTempDir({ prefix: "openclaw-update-probe-only-" }, async (base) => {
569+
const nvmPrefix = path.join(base, "home", ".nvm", "versions", "node", "v24.5.0");
570+
const nvmRoot = path.join(nvmPrefix, "lib", "node_modules");
571+
const pkgRoot = path.join(base, "checkout", "node_modules", "openclaw");
572+
await fs.mkdir(pkgRoot, { recursive: true });
573+
574+
const runCommand = createNpmRootRunner({ defaultNpmRoot: nvmRoot });
575+
576+
await expect(
577+
resolveGlobalInstallTarget({
578+
manager: "npm",
579+
runCommand,
580+
timeoutMs: 1000,
581+
pkgRoot,
582+
}),
583+
).resolves.toEqual({
584+
manager: "npm",
585+
command: "npm",
586+
globalRoot: nvmRoot,
587+
packageRoot: path.join(nvmRoot, "openclaw"),
588+
});
589+
});
590+
});
591+
});
592+
593+
it("falls back to the running package root when the npm root probe fails", async () => {
594+
await withMockedPlatform("darwin", async () => {
595+
await withTempDir({ prefix: "openclaw-update-probe-failure-" }, async (base) => {
596+
const globalRoot = path.join(base, "usr", "local", "lib", "node_modules");
597+
const pkgRoot = path.join(globalRoot, "openclaw");
598+
await fs.mkdir(pkgRoot, { recursive: true });
599+
600+
const runCommand: CommandRunner = async () => ({ stdout: "", stderr: "", code: 1 });
601+
602+
await expect(
603+
resolveGlobalInstallTarget({
604+
manager: "npm",
605+
runCommand,
606+
timeoutMs: 1000,
607+
pkgRoot,
608+
}),
609+
).resolves.toEqual({
610+
manager: "npm",
611+
command: "npm",
612+
globalRoot,
613+
packageRoot: pkgRoot,
614+
});
615+
});
616+
});
617+
});
618+
485619
it("does not infer npm ownership from path shape alone when the owning npm binary is absent", async () => {
486620
await withTempDir({ prefix: "openclaw-update-npm-missing-bin-" }, async (base) => {
487621
const brewRoot = path.join(base, "opt", "homebrew", "lib", "node_modules");

src/infra/update-global.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -442,13 +442,8 @@ function resolveBunGlobalRoot(): string {
442442
}
443443

444444
function inferNpmPrefixFromPackageRoot(pkgRoot?: string | null): string | null {
445-
const trimmed = pkgRoot?.trim();
446-
if (!trimmed) {
447-
return null;
448-
}
449-
const normalized = path.resolve(trimmed);
450-
const nodeModulesDir = path.dirname(normalized);
451-
if (path.basename(nodeModulesDir) !== "node_modules") {
445+
const nodeModulesDir = inferGlobalRootFromPackageRoot(pkgRoot);
446+
if (!nodeModulesDir) {
452447
return null;
453448
}
454449
const parentDir = path.dirname(nodeModulesDir);
@@ -595,7 +590,10 @@ function inferGlobalRootFromPackageRoot(pkgRoot?: string | null): string | null
595590
return null;
596591
}
597592
const normalized = path.resolve(trimmed);
598-
const globalRoot = path.dirname(normalized);
593+
let globalRoot = path.dirname(normalized);
594+
if (path.basename(globalRoot).startsWith("@")) {
595+
globalRoot = path.dirname(globalRoot);
596+
}
599597
return path.basename(globalRoot) === "node_modules" ? globalRoot : null;
600598
}
601599

@@ -796,10 +794,18 @@ export async function resolveGlobalInstallTarget(params: {
796794
params.pkgRoot,
797795
);
798796
const pkgRootGlobalRoot = command.manager === "pnpm" ? pnpmPackageRootGlobalRoot : null;
797+
// The detected npm owner applies to the running package, so its prefix is
798+
// authoritative. PATH's npm may belong to another Node installation and
799+
// report a different root, which would leave the running tree stale.
800+
const npmPackageRootGlobalRoot =
801+
command.manager === "npm" && inferNpmPrefixFromPackageRoot(params.pkgRoot)
802+
? inferGlobalRootFromPackageRoot(params.pkgRoot)
803+
: null;
799804
const targetGlobalRoot =
800805
(command.manager === "bun" ? bunPackageRootGlobalRoot : null) ??
801806
pkgRootGlobalRoot ??
802807
(command.manager === "npm" ? honoredPackageRootGlobalRoot : null) ??
808+
npmPackageRootGlobalRoot ??
803809
globalRoot;
804810
return {
805811
...command,

0 commit comments

Comments
 (0)