Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions src/infra/update-global.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,140 @@ describe("update global helpers", () => {
});
});

it("keeps npm self-updates on the running package root when the PATH probe diverges", async () => {
await withMockedPlatform("darwin", async () => {
await withTempDir({ prefix: "openclaw-update-ephemeral-probe-" }, async (base) => {
// The running install lives in an nvm tree while `npm root -g` on
// PATH answers with a Homebrew Cellar root — the skew produced when a
// per-Node npm shim is executed by a foreign node (e.g. a launchd
// service PATH pairing nvm's npm with Homebrew's node). Installing
// into the Cellar root would create a brand-new tree the running
// install never loads from.
const nvmPrefix = path.join(base, "home", ".nvm", "versions", "node", "v24.5.0");
const nvmRoot = path.join(nvmPrefix, "lib", "node_modules");
const pkgRoot = path.join(nvmRoot, "openclaw");
const cellarRoot = path.join(
base,
"opt",
"homebrew",
"Cellar",
"node",
"26.3.1",
"lib",
"node_modules",
);
await fs.mkdir(pkgRoot, { recursive: true });

const runCommand = createNpmRootRunner({ defaultNpmRoot: cellarRoot });

await expect(
resolveGlobalInstallTarget({
manager: "npm",
runCommand,
timeoutMs: 1000,
pkgRoot,
}),
).resolves.toEqual({
manager: "npm",
command: "npm",
globalRoot: nvmRoot,
packageRoot: pkgRoot,
});
});
});
});

it("keeps scoped npm self-updates on the running package root", async () => {
await withMockedPlatform("darwin", async () => {
await withTempDir({ prefix: "openclaw-update-scoped-probe-" }, async (base) => {
const nvmPrefix = path.join(base, "home", ".nvm", "versions", "node", "v24.5.0");
const nvmRoot = path.join(nvmPrefix, "lib", "node_modules");
const pkgRoot = path.join(nvmRoot, "@scope", "cli");
const cellarRoot = path.join(
base,
"opt",
"homebrew",
"Cellar",
"node",
"26.3.1",
"lib",
"node_modules",
);
await fs.mkdir(pkgRoot, { recursive: true });

const runCommand = createNpmRootRunner({ defaultNpmRoot: cellarRoot });

await expect(
resolveGlobalInstallTarget({
manager: "npm",
runCommand,
timeoutMs: 1000,
pkgRoot,
packageName: "@scope/cli",
}),
).resolves.toEqual({
manager: "npm",
command: "npm",
globalRoot: nvmRoot,
packageRoot: pkgRoot,
});
});
});
});

it("keeps the npm probe when the package root is not globally installed", async () => {
await withMockedPlatform("darwin", async () => {
await withTempDir({ prefix: "openclaw-update-probe-only-" }, async (base) => {
const nvmPrefix = path.join(base, "home", ".nvm", "versions", "node", "v24.5.0");
const nvmRoot = path.join(nvmPrefix, "lib", "node_modules");
const pkgRoot = path.join(base, "checkout", "node_modules", "openclaw");
await fs.mkdir(pkgRoot, { recursive: true });

const runCommand = createNpmRootRunner({ defaultNpmRoot: nvmRoot });

await expect(
resolveGlobalInstallTarget({
manager: "npm",
runCommand,
timeoutMs: 1000,
pkgRoot,
}),
).resolves.toEqual({
manager: "npm",
command: "npm",
globalRoot: nvmRoot,
packageRoot: path.join(nvmRoot, "openclaw"),
});
});
});
});

it("falls back to the running package root when the npm root probe fails", async () => {
await withMockedPlatform("darwin", async () => {
await withTempDir({ prefix: "openclaw-update-probe-failure-" }, async (base) => {
const globalRoot = path.join(base, "usr", "local", "lib", "node_modules");
const pkgRoot = path.join(globalRoot, "openclaw");
await fs.mkdir(pkgRoot, { recursive: true });

const runCommand: CommandRunner = async () => ({ stdout: "", stderr: "", code: 1 });

await expect(
resolveGlobalInstallTarget({
manager: "npm",
runCommand,
timeoutMs: 1000,
pkgRoot,
}),
).resolves.toEqual({
manager: "npm",
command: "npm",
globalRoot,
packageRoot: pkgRoot,
});
});
});
});

it("does not infer npm ownership from path shape alone when the owning npm binary is absent", async () => {
await withTempDir({ prefix: "openclaw-update-npm-missing-bin-" }, async (base) => {
const brewRoot = path.join(base, "opt", "homebrew", "lib", "node_modules");
Expand Down
22 changes: 14 additions & 8 deletions src/infra/update-global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,13 +442,8 @@ function resolveBunGlobalRoot(): string {
}

function inferNpmPrefixFromPackageRoot(pkgRoot?: string | null): string | null {
const trimmed = pkgRoot?.trim();
if (!trimmed) {
return null;
}
const normalized = path.resolve(trimmed);
const nodeModulesDir = path.dirname(normalized);
if (path.basename(nodeModulesDir) !== "node_modules") {
const nodeModulesDir = inferGlobalRootFromPackageRoot(pkgRoot);
if (!nodeModulesDir) {
return null;
}
const parentDir = path.dirname(nodeModulesDir);
Expand Down Expand Up @@ -595,7 +590,10 @@ function inferGlobalRootFromPackageRoot(pkgRoot?: string | null): string | null
return null;
}
const normalized = path.resolve(trimmed);
const globalRoot = path.dirname(normalized);
let globalRoot = path.dirname(normalized);
if (path.basename(globalRoot).startsWith("@")) {
globalRoot = path.dirname(globalRoot);
}
return path.basename(globalRoot) === "node_modules" ? globalRoot : null;
}

Expand Down Expand Up @@ -796,10 +794,18 @@ export async function resolveGlobalInstallTarget(params: {
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
// report a different root, which would leave the running tree stale.
const npmPackageRootGlobalRoot =
command.manager === "npm" && inferNpmPrefixFromPackageRoot(params.pkgRoot)
? inferGlobalRootFromPackageRoot(params.pkgRoot)
: null;
const targetGlobalRoot =
(command.manager === "bun" ? bunPackageRootGlobalRoot : null) ??
pkgRootGlobalRoot ??
(command.manager === "npm" ? honoredPackageRootGlobalRoot : null) ??
npmPackageRootGlobalRoot ??
globalRoot;
return {
...command,
Expand Down
Loading