fix: make npm global updates atomic

This commit is contained in:
Shakker
2026-04-27 14:11:27 +01:00
parent 9b4c1f0fa3
commit 6985c6751c
8 changed files with 588 additions and 14 deletions

View File

@@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai
- Gateway/device tokens: stop echoing rotated bearer tokens from shared/admin `device.token.rotate` responses while preserving the same-device token handoff needed by token-only clients before reconnect. (#66773) Thanks @MoerAI.
- Agents/subagents: enforce `subagents.allowAgents` for explicit same-agent `sessions_spawn(agentId=...)` calls instead of auto-allowing requester self-targets. Fixes #72827. Thanks @oiGaDio.
- ACP/sessions_spawn: let explicit `sessions_spawn(runtime="acp")` bootstrap turns run while `acp.dispatch.enabled=false` still blocks automatic ACP thread dispatch. Fixes #63591. Thanks @moeedahmed.
- CLI/update: install npm global updates into a verified temporary prefix before swapping the package tree into place, preventing mixed old/new installs and stale packaged files from breaking `openclaw update` verification. Thanks @shakkernerd.
- Gateway: skip CLI startup self-respawn for foreground gateway runs so low-memory Linux/Node 24 hosts start through the same path as direct `dist/index.js` without hanging before logs. Fixes #72720. Thanks @sign-2025.
- Google Meet: grant Meet media permissions through browser control and pin local Chrome audio defaults to `BlackHole 2ch`, so joined agents no longer show `Permission needed` or use macOS default audio devices. Thanks @DougButdorf.
- Google Meet: route local Chrome joins through OpenClaw browser control instead of raw default Chrome, so agents use the configured OpenClaw browser profile when opening Meet. Thanks @oromeis.

View File

@@ -85,7 +85,11 @@ install method aligned:
The Gateway core auto-updater (when enabled via config) reuses this same update path.
For package-manager installs, `openclaw update` resolves the target package
version before invoking the package manager. Even when the installed version
version before invoking the package manager. npm global installs use a staged
install: OpenClaw installs the new package into a temporary npm prefix, verifies
the packaged `dist` inventory there, then swaps that clean package tree into the
real global prefix. If verification fails, post-update doctor, plugin sync, and
restart work do not run from the suspect tree. Even when the installed version
already matches the target, the command refreshes the global package install,
then runs plugin sync, a core-command completion refresh, and restart work. This
keeps packaged sidecars and channel-owned plugin records aligned with the

View File

@@ -87,11 +87,13 @@ curl -fsSL https://openclaw.ai/install.sh | bash -s -- --install-method npm --ve
npm i -g openclaw@latest
```
When `openclaw update` manages a global npm install, it first runs the normal
global install command. If that command fails, OpenClaw retries once with
`--omit=optional`. That retry helps hosts where native optional dependencies
cannot compile, while keeping the original failure visible if the fallback also
fails.
When `openclaw update` manages a global npm install, it installs the target into
a temporary npm prefix first, verifies the packaged `dist` inventory, then swaps
the clean package tree into the real global prefix. That avoids npm overlaying a
new package onto stale files from the old package. If the install command fails,
OpenClaw retries once with `--omit=optional`. That retry helps hosts where native
optional dependencies cannot compile, while keeping the original failure visible
if the fallback also fails.
```bash
pnpm add -g openclaw@latest

View File

@@ -1227,6 +1227,88 @@ describe("update-cli", () => {
expect(logs.join("\n")).toContain("expected installed version 2026.3.23-2, found 2026.3.23");
});
it("stops package post-update work when staged npm install verification fails", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-staged-fail-"));
const prefix = path.join(tempDir, "prefix");
const nodeModules = path.join(prefix, "lib", "node_modules");
const pkgRoot = path.join(nodeModules, "openclaw");
mockPackageInstallStatus(pkgRoot);
readPackageVersion.mockResolvedValue("2026.4.20");
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
tag: "latest",
version: "2026.4.25",
});
await fs.mkdir(path.join(pkgRoot, "dist"), { recursive: true });
await fs.writeFile(
path.join(pkgRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.4.20" }),
"utf-8",
);
await fs.writeFile(path.join(pkgRoot, "dist", "index.js"), "export {};\n", "utf-8");
await writePackageDistInventory(pkgRoot);
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
return {
stdout: `${nodeModules}\n`,
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
};
}
if (
Array.isArray(argv) &&
argv[0] === "npm" &&
argv[1] === "i" &&
argv.includes("--prefix")
) {
const stagePrefix = argv[argv.indexOf("--prefix") + 1];
if (typeof stagePrefix !== "string") {
throw new Error("missing stage prefix");
}
const stageRoot = path.join(stagePrefix, "lib", "node_modules", "openclaw");
await fs.mkdir(path.join(stageRoot, "dist"), { recursive: true });
await fs.writeFile(
path.join(stageRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.4.25" }),
"utf-8",
);
await fs.writeFile(path.join(stageRoot, "dist", "index.js"), "export {};\n", "utf-8");
await writePackageDistInventory(stageRoot);
await fs.writeFile(
path.join(stageRoot, "dist", "stale-runtime.js"),
"export {};\n",
"utf-8",
);
}
return {
stdout: "",
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
};
});
await updateCommand({ yes: true, restart: false });
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
expect(runCommandWithTimeout).not.toHaveBeenCalledWith(
[expect.stringMatching(/node/), expect.any(String), "doctor", "--non-interactive", "--fix"],
expect.any(Object),
);
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
await expect(fs.readFile(path.join(pkgRoot, "package.json"), "utf-8")).resolves.toContain(
'"version":"2026.4.20"',
);
const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0]));
expect(logs.join("\n")).toContain("global install verify");
expect(logs.join("\n")).toContain("unexpected packaged dist file dist/stale-runtime.js");
});
it("marks package post-update doctor as update-in-progress", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-package-"));
const nodeModules = path.join(tempDir, "node_modules");
@@ -1492,7 +1574,7 @@ describe("update-cli", () => {
isOwningNpmCommand(argv[0], brewPrefix) &&
argv[1] === "i" &&
argv[2] === "-g" &&
argv[3] === "openclaw@latest",
argv.includes("openclaw@latest"),
);
expect(installCall).toBeDefined();

View File

@@ -0,0 +1,167 @@
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 { writePackageDistInventory } from "./package-dist-inventory.js";
import {
runGlobalPackageUpdateSteps,
type PackageUpdateStepResult,
} from "./package-update-steps.js";
import type { CommandRunner, ResolvedGlobalInstallTarget } from "./update-global.js";
async function writePackageRoot(packageRoot: string, version: string): Promise<void> {
await fs.mkdir(path.join(packageRoot, "dist"), { recursive: true });
await fs.writeFile(
path.join(packageRoot, "package.json"),
JSON.stringify({ name: "openclaw", version }),
"utf8",
);
await fs.writeFile(path.join(packageRoot, "dist", "index.js"), "export {};\n", "utf8");
await writePackageDistInventory(packageRoot);
}
function createNpmTarget(globalRoot: string): ResolvedGlobalInstallTarget {
return {
manager: "npm",
command: "npm",
globalRoot,
packageRoot: path.join(globalRoot, "openclaw"),
};
}
function createRootRunner(globalRoot: string): CommandRunner {
return async (argv) => {
if (argv.join(" ") === "npm root -g") {
return { stdout: `${globalRoot}\n`, stderr: "", code: 0 };
}
throw new Error(`unexpected command: ${argv.join(" ")}`);
};
}
describe("runGlobalPackageUpdateSteps", () => {
it("installs npm updates into a clean staged prefix before swapping the global package", async () => {
await withTempDir({ prefix: "openclaw-package-update-staged-" }, async (base) => {
const prefix = path.join(base, "prefix");
const globalRoot = path.join(prefix, "lib", "node_modules");
const packageRoot = path.join(globalRoot, "openclaw");
await writePackageRoot(packageRoot, "1.0.0");
await fs.mkdir(path.join(packageRoot, "dist", "extensions", "qa-channel"), {
recursive: true,
});
await fs.writeFile(
path.join(packageRoot, "dist", "extensions", "qa-channel", "runtime-api.js"),
"export {};\n",
"utf8",
);
const runStep = vi.fn(
async ({ name, argv, cwd, timeoutMs }): Promise<PackageUpdateStepResult> => {
expect(timeoutMs).toBe(1000);
if (name !== "global update") {
throw new Error(`unexpected step ${name}`);
}
const prefixIndex = argv.indexOf("--prefix");
expect(prefixIndex).toBeGreaterThan(0);
const stagePrefix = argv[prefixIndex + 1];
if (!stagePrefix) {
throw new Error("missing staged prefix");
}
await writePackageRoot(
path.join(stagePrefix, "lib", "node_modules", "openclaw"),
"2.0.0",
);
await fs.mkdir(path.join(stagePrefix, "bin"), { recursive: true });
await fs.symlink(
"../lib/node_modules/openclaw/dist/index.js",
path.join(stagePrefix, "bin", "openclaw"),
);
return {
name,
command: argv.join(" "),
cwd: cwd ?? process.cwd(),
durationMs: 1,
exitCode: 0,
};
},
);
const result = await runGlobalPackageUpdateSteps({
installTarget: createNpmTarget(globalRoot),
installSpec: "openclaw@2.0.0",
packageName: "openclaw",
packageRoot,
runCommand: createRootRunner(globalRoot),
runStep,
timeoutMs: 1000,
});
expect(result.failedStep).toBeNull();
expect(result.verifiedPackageRoot).toBe(packageRoot);
expect(result.afterVersion).toBe("2.0.0");
expect(result.steps.map((step) => step.name)).toEqual([
"global update",
"global install swap",
]);
await expect(fs.readFile(path.join(packageRoot, "package.json"), "utf8")).resolves.toContain(
'"version":"2.0.0"',
);
await expect(
fs.access(path.join(packageRoot, "dist", "extensions", "qa-channel", "runtime-api.js")),
).rejects.toMatchObject({ code: "ENOENT" });
await expect(fs.readlink(path.join(prefix, "bin", "openclaw"))).resolves.toBe(
"../lib/node_modules/openclaw/dist/index.js",
);
});
});
it("does not run post-verify work when staged npm verification fails", async () => {
await withTempDir({ prefix: "openclaw-package-update-verify-" }, async (base) => {
const prefix = path.join(base, "prefix");
const globalRoot = path.join(prefix, "lib", "node_modules");
const packageRoot = path.join(globalRoot, "openclaw");
await writePackageRoot(packageRoot, "1.0.0");
const postVerifyStep = vi.fn();
const result = await runGlobalPackageUpdateSteps({
installTarget: createNpmTarget(globalRoot),
installSpec: "openclaw@2.0.0",
packageName: "openclaw",
packageRoot,
runCommand: createRootRunner(globalRoot),
runStep: async ({ name, argv, cwd }) => {
const prefixIndex = argv.indexOf("--prefix");
const stagePrefix = argv[prefixIndex + 1];
if (!stagePrefix) {
throw new Error("missing staged prefix");
}
await writePackageRoot(
path.join(stagePrefix, "lib", "node_modules", "openclaw"),
"1.5.0",
);
return {
name,
command: argv.join(" "),
cwd: cwd ?? process.cwd(),
durationMs: 1,
exitCode: 0,
};
},
timeoutMs: 1000,
postVerifyStep,
});
expect(result.failedStep?.name).toBe("global install verify");
expect(result.steps.map((step) => step.name)).toEqual([
"global update",
"global install verify",
]);
expect(result.steps.at(-1)?.stderrTail).toContain(
"expected installed version 2.0.0, found 1.5.0",
);
expect(postVerifyStep).not.toHaveBeenCalled();
await expect(fs.readFile(path.join(packageRoot, "package.json"), "utf8")).resolves.toContain(
'"version":"1.0.0"',
);
});
});
});

View File

@@ -1,11 +1,16 @@
import fs from "node:fs/promises";
import path from "node:path";
import { readPackageVersion } from "./package-json.js";
import {
collectInstalledGlobalPackageErrors,
globalInstallArgs,
globalInstallFallbackArgs,
resolveNpmGlobalPrefixLayoutFromGlobalRoot,
resolveNpmGlobalPrefixLayoutFromPrefix,
resolveExpectedInstalledVersionFromSpec,
resolveGlobalInstallTarget,
type CommandRunner,
type NpmGlobalPrefixLayout,
type ResolvedGlobalInstallTarget,
} from "./update-global.js";
@@ -27,6 +32,170 @@ export type PackageUpdateStepRunner = (params: {
env?: NodeJS.ProcessEnv;
}) => Promise<PackageUpdateStepResult>;
type StagedNpmInstall = {
prefix: string;
layout: NpmGlobalPrefixLayout;
packageRoot: string;
};
function formatError(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
async function pathExists(targetPath: string): Promise<boolean> {
try {
await fs.access(targetPath);
return true;
} catch {
return false;
}
}
async function createStagedNpmInstall(
installTarget: ResolvedGlobalInstallTarget,
packageName: string,
): Promise<StagedNpmInstall | null> {
if (installTarget.manager !== "npm") {
return null;
}
const targetLayout = resolveNpmGlobalPrefixLayoutFromGlobalRoot(installTarget.globalRoot);
if (!targetLayout) {
return null;
}
const prefix = await fs.mkdtemp(path.join(targetLayout.prefix, ".openclaw-update-stage-"));
const layout = resolveNpmGlobalPrefixLayoutFromPrefix(prefix);
return {
prefix,
layout,
packageRoot: path.join(layout.globalRoot, packageName),
};
}
async function cleanupStagedNpmInstall(stage: StagedNpmInstall | null): Promise<void> {
if (!stage) {
return;
}
await fs.rm(stage.prefix, { recursive: true, force: true }).catch(() => undefined);
}
async function copyPathEntry(source: string, destination: string): Promise<void> {
const stat = await fs.lstat(source);
await fs.rm(destination, { recursive: true, force: true }).catch(() => undefined);
if (stat.isSymbolicLink()) {
await fs.symlink(await fs.readlink(source), destination);
return;
}
if (stat.isDirectory()) {
await fs.cp(source, destination, {
recursive: true,
force: true,
preserveTimestamps: false,
});
return;
}
await fs.copyFile(source, destination);
await fs.chmod(destination, stat.mode).catch(() => undefined);
}
async function replaceNpmBinShims(params: {
stageLayout: NpmGlobalPrefixLayout;
targetLayout: NpmGlobalPrefixLayout;
packageName: string;
}): Promise<void> {
let entries: string[] = [];
try {
entries = await fs.readdir(params.stageLayout.binDir);
} catch {
return;
}
const names = new Set([params.packageName, "openclaw"]);
const shimEntries = entries.filter((entry) => {
const parsed = path.parse(entry);
return names.has(entry) || names.has(parsed.name);
});
if (shimEntries.length === 0) {
return;
}
await fs.mkdir(params.targetLayout.binDir, { recursive: true });
for (const entry of shimEntries) {
await copyPathEntry(
path.join(params.stageLayout.binDir, entry),
path.join(params.targetLayout.binDir, entry),
);
}
}
async function swapStagedNpmInstall(params: {
stage: StagedNpmInstall;
installTarget: ResolvedGlobalInstallTarget;
packageName: string;
}): Promise<PackageUpdateStepResult> {
const startedAt = Date.now();
const targetLayout = resolveNpmGlobalPrefixLayoutFromGlobalRoot(params.installTarget.globalRoot);
const targetPackageRoot = params.installTarget.packageRoot;
if (!targetLayout || !targetPackageRoot) {
return {
name: "global install swap",
command: "swap staged npm install",
cwd: params.stage.prefix,
durationMs: Date.now() - startedAt,
exitCode: 1,
stdoutTail: null,
stderrTail: "cannot resolve npm global prefix layout",
};
}
const backupRoot = path.join(targetLayout.globalRoot, `.openclaw-${process.pid}-${Date.now()}`);
let movedExisting = false;
let movedStaged = false;
try {
await fs.mkdir(targetLayout.globalRoot, { recursive: true });
if (await pathExists(targetPackageRoot)) {
await fs.rename(targetPackageRoot, backupRoot);
movedExisting = true;
}
await fs.rename(params.stage.packageRoot, targetPackageRoot);
movedStaged = true;
await replaceNpmBinShims({
stageLayout: params.stage.layout,
targetLayout,
packageName: params.packageName,
});
if (movedExisting) {
await fs.rm(backupRoot, { recursive: true, force: true });
}
return {
name: "global install swap",
command: `swap ${params.stage.packageRoot} -> ${targetPackageRoot}`,
cwd: targetLayout.globalRoot,
durationMs: Date.now() - startedAt,
exitCode: 0,
stdoutTail: movedExisting
? `replaced ${params.packageName}`
: `installed ${params.packageName}`,
stderrTail: null,
};
} catch (err) {
if (movedStaged) {
await fs.rm(targetPackageRoot, { recursive: true, force: true }).catch(() => undefined);
}
if (movedExisting) {
await fs.rename(backupRoot, targetPackageRoot).catch(() => undefined);
}
return {
name: "global install swap",
command: `swap ${params.stage.packageRoot} -> ${targetPackageRoot}`,
cwd: targetLayout.globalRoot,
durationMs: Date.now() - startedAt,
exitCode: 1,
stdoutTail: null,
stderrTail: formatError(err),
};
}
}
export async function runGlobalPackageUpdateSteps(params: {
installTarget: ResolvedGlobalInstallTarget;
installSpec: string;
@@ -46,9 +215,15 @@ export async function runGlobalPackageUpdateSteps(params: {
}> {
const installCwd = params.installCwd === undefined ? {} : { cwd: params.installCwd };
const installEnv = params.env === undefined ? {} : { env: params.env };
let stagedInstall = await createStagedNpmInstall(params.installTarget, params.packageName);
const updateStep = await params.runStep({
name: "global update",
argv: globalInstallArgs(params.installTarget, params.installSpec),
argv: globalInstallArgs(
params.installTarget,
params.installSpec,
undefined,
stagedInstall?.prefix,
),
...installCwd,
...installEnv,
timeoutMs: params.timeoutMs,
@@ -57,7 +232,14 @@ export async function runGlobalPackageUpdateSteps(params: {
const steps = [updateStep];
let finalInstallStep = updateStep;
if (updateStep.exitCode !== 0) {
const fallbackArgv = globalInstallFallbackArgs(params.installTarget, params.installSpec);
await cleanupStagedNpmInstall(stagedInstall);
stagedInstall = await createStagedNpmInstall(params.installTarget, params.packageName);
const fallbackArgv = globalInstallFallbackArgs(
params.installTarget,
params.installSpec,
undefined,
stagedInstall?.prefix,
);
if (fallbackArgv) {
const fallbackStep = await params.runStep({
name: "global update (omit optional)",
@@ -68,10 +250,14 @@ export async function runGlobalPackageUpdateSteps(params: {
});
steps.push(fallbackStep);
finalInstallStep = fallbackStep;
} else {
await cleanupStagedNpmInstall(stagedInstall);
stagedInstall = null;
}
}
const verifiedPackageRoot =
let verifiedPackageRoot =
stagedInstall?.packageRoot ??
(
await resolveGlobalInstallTarget({
manager: params.installTarget,
@@ -83,7 +269,7 @@ export async function runGlobalPackageUpdateSteps(params: {
null;
let afterVersion: string | null = null;
if (verifiedPackageRoot) {
if (finalInstallStep.exitCode === 0 && verifiedPackageRoot) {
afterVersion = await readPackageVersion(verifiedPackageRoot);
const expectedVersion = resolveExpectedInstalledVersionFromSpec(
params.packageName,
@@ -104,12 +290,34 @@ export async function runGlobalPackageUpdateSteps(params: {
stdoutTail: null,
});
}
const postVerifyStep = await params.postVerifyStep?.(verifiedPackageRoot);
if (stagedInstall && verificationErrors.length === 0) {
const swapStep = await swapStagedNpmInstall({
stage: stagedInstall,
installTarget: params.installTarget,
packageName: params.packageName,
});
steps.push(swapStep);
if (swapStep.exitCode === 0) {
verifiedPackageRoot = params.installTarget.packageRoot ?? verifiedPackageRoot;
}
}
const failedVerifyOrSwap = steps.find(
(step) =>
(step.name === "global install verify" || step.name === "global install swap") &&
step.exitCode !== 0,
);
const postVerifyStep = failedVerifyOrSwap
? null
: await params.postVerifyStep?.(verifiedPackageRoot);
if (postVerifyStep) {
steps.push(postVerifyStep);
}
}
await cleanupStagedNpmInstall(stagedInstall);
const failedStep =
finalInstallStep.exitCode !== 0
? finalInstallStep

View File

@@ -26,6 +26,8 @@ import {
resolveGlobalInstallTarget,
resolveGlobalInstallSpec,
resolveGlobalRoot,
resolveNpmGlobalPrefixLayoutFromGlobalRoot,
resolveNpmGlobalPrefixLayoutFromPrefix,
type CommandRunner,
} from "./update-global.js";
@@ -367,6 +369,46 @@ describe("update global helpers", () => {
).toEqual(["/opt/homebrew/bin/pnpm", "add", "-g", "openclaw@latest"]);
});
it("builds npm staged install argv with an explicit prefix", () => {
expect(globalInstallArgs("npm", "openclaw@latest", null, "/tmp/stage")).toEqual([
"npm",
"i",
"-g",
"--prefix",
"/tmp/stage",
"openclaw@latest",
"--no-fund",
"--no-audit",
"--loglevel=error",
]);
expect(globalInstallFallbackArgs("npm", "openclaw@latest", null, "/tmp/stage")).toEqual([
"npm",
"i",
"-g",
"--prefix",
"/tmp/stage",
"openclaw@latest",
"--omit=optional",
"--no-fund",
"--no-audit",
"--loglevel=error",
]);
});
it("resolves npm prefix layouts for normal global roots", () => {
expect(resolveNpmGlobalPrefixLayoutFromGlobalRoot("/opt/openclaw/lib/node_modules")).toEqual({
prefix: "/opt/openclaw",
globalRoot: "/opt/openclaw/lib/node_modules",
binDir: "/opt/openclaw/bin",
});
expect(resolveNpmGlobalPrefixLayoutFromPrefix("/tmp/stage")).toEqual({
prefix: "/tmp/stage",
globalRoot: "/tmp/stage/lib/node_modules",
binDir: "/tmp/stage/bin",
});
expect(resolveNpmGlobalPrefixLayoutFromGlobalRoot("/tmp/node_modules")).toBeNull();
});
it("cleans only renamed package directories", async () => {
await withTempDir({ prefix: "openclaw-update-cleanup-" }, async (root) => {
await fs.mkdir(path.join(root, ".openclaw-123"), { recursive: true });

View File

@@ -48,6 +48,12 @@ const OMITTED_PRIVATE_QA_BUNDLED_PLUGIN_ROOTS = new Set([
"dist/extensions/qa-matrix",
]);
export type NpmGlobalPrefixLayout = {
prefix: string;
globalRoot: string;
binDir: string;
};
function normalizePackageTarget(value: string): string {
return value.trim();
}
@@ -379,6 +385,52 @@ function inferNpmPrefixFromPackageRoot(pkgRoot?: string | null): string | null {
return null;
}
export function resolveNpmGlobalPrefixLayoutFromGlobalRoot(
globalRoot?: string | null,
): NpmGlobalPrefixLayout | null {
const trimmed = globalRoot?.trim();
if (!trimmed) {
return null;
}
const normalized = path.resolve(trimmed);
if (path.basename(normalized) !== "node_modules") {
return null;
}
const parentDir = path.dirname(normalized);
if (path.basename(parentDir) === "lib") {
const prefix = path.dirname(parentDir);
return {
prefix,
globalRoot: normalized,
binDir: path.join(prefix, "bin"),
};
}
if (process.platform === "win32") {
return {
prefix: parentDir,
globalRoot: normalized,
binDir: parentDir,
};
}
return null;
}
export function resolveNpmGlobalPrefixLayoutFromPrefix(prefix: string): NpmGlobalPrefixLayout {
const resolvedPrefix = path.resolve(prefix);
if (process.platform === "win32") {
return {
prefix: resolvedPrefix,
globalRoot: path.join(resolvedPrefix, "node_modules"),
binDir: resolvedPrefix,
};
}
return {
prefix: resolvedPrefix,
globalRoot: path.join(resolvedPrefix, "lib", "node_modules"),
binDir: path.join(resolvedPrefix, "bin"),
};
}
function resolvePreferredNpmCommand(pkgRoot?: string | null): string | null {
const prefix = inferNpmPrefixFromPackageRoot(pkgRoot);
if (!prefix) {
@@ -550,6 +602,7 @@ export function globalInstallArgs(
managerOrCommand: GlobalInstallManager | ResolvedGlobalInstallCommand,
spec: string,
pkgRoot?: string | null,
installPrefix?: string | null,
): string[] {
const resolved = normalizeGlobalInstallCommand(managerOrCommand, pkgRoot);
if (resolved.manager === "pnpm") {
@@ -558,19 +611,34 @@ export function globalInstallArgs(
if (resolved.manager === "bun") {
return [resolved.command, "add", "-g", spec];
}
return [resolved.command, "i", "-g", spec, ...NPM_GLOBAL_INSTALL_QUIET_FLAGS];
return [
resolved.command,
"i",
"-g",
...(installPrefix ? ["--prefix", installPrefix] : []),
spec,
...NPM_GLOBAL_INSTALL_QUIET_FLAGS,
];
}
export function globalInstallFallbackArgs(
managerOrCommand: GlobalInstallManager | ResolvedGlobalInstallCommand,
spec: string,
pkgRoot?: string | null,
installPrefix?: string | null,
): string[] | null {
const resolved = normalizeGlobalInstallCommand(managerOrCommand, pkgRoot);
if (resolved.manager !== "npm") {
return null;
}
return [resolved.command, "i", "-g", spec, ...NPM_GLOBAL_INSTALL_OMIT_OPTIONAL_FLAGS];
return [
resolved.command,
"i",
"-g",
...(installPrefix ? ["--prefix", installPrefix] : []),
spec,
...NPM_GLOBAL_INSTALL_OMIT_OPTIONAL_FLAGS,
];
}
export async function cleanupGlobalRenameDirs(params: {