mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:50:42 +00:00
fix: make npm global updates atomic
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
167
src/infra/package-update-steps.test.ts
Normal file
167
src/infra/package-update-steps.test.ts
Normal 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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user