mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
fix(update): support Homebrew installs and preserve stable LaunchAgent paths (#141011)
Homebrew-managed installations now return the existing typed skipped-update outcome with guidance to run brew upgrade openclaw-cli and then openclaw gateway restart. Existing update history, status JSON, and reports retain that guidance without changing their data model. A shared formula-path resolver validates the Homebrew prefix and maps versioned Cellar entrypoints to the stable opt path used by Gateway and node services. Homebrew keeps ownership of package replacement, and ordinary npm, pnpm, Bun, Windows, and source paths retain their existing behavior. Thanks to @maxsxu for the Homebrew repair. Validation: 507 focused tests, changed-file checks, full build, scoped-clean Codex review, and green exact-head CI run 35337287646. The built-CLI fixture verified persisted guidance and entrypoint survival after simulated keg retirement; it did not run a real Homebrew upgrade or native-service restart. Co-authored-by: Peter Steinberger <steipete@gmail.com> Co-authored-by: Max Xu <xuhuan@live.cn>
This commit is contained in:
parent
543e8ce382
commit
8db38db911
8 changed files with 278 additions and 1 deletions
|
|
@ -164,6 +164,26 @@ Pin the recovery to a specific version or dist-tag with `--version`:
|
|||
curl -fsSL https://openclaw.ai/install.sh | bash -s -- --install-method npm --version <version-or-dist-tag>
|
||||
```
|
||||
|
||||
## Homebrew formula installs
|
||||
|
||||
For `brew install openclaw-cli`, update through Homebrew:
|
||||
|
||||
```bash
|
||||
brew upgrade openclaw-cli
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
`openclaw update` leaves the formula unchanged and prints these commands. Existing
|
||||
profiles retain that skipped outcome and guidance in `openclaw update status --json`
|
||||
and the update report. Stop a running Gateway before a manual upgrade to avoid
|
||||
loading files from a removed keg; back up first and run `openclaw doctor --fix`
|
||||
before restarting.
|
||||
|
||||
New or refreshed service definitions use Homebrew's stable `opt/openclaw-cli`
|
||||
path. To repair a service still pointing at a versioned `Cellar` path, run
|
||||
`openclaw gateway install --force` from the upgraded CLI. Global npm packages
|
||||
under the Homebrew prefix continue to use npm.
|
||||
|
||||
## Alternative: manual npm, pnpm, or bun
|
||||
|
||||
The npm command below is for npm 12 or npm 11.16+. On npm 11.15 and earlier,
|
||||
|
|
|
|||
|
|
@ -155,6 +155,51 @@ describe("update CLI shared helpers", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"guides Homebrew-managed installations to use brew upgrade",
|
||||
async () => {
|
||||
await expect(
|
||||
resolveGlobalManager({
|
||||
root: "/opt/homebrew/Cellar/openclaw-cli/2026.9.2/libexec/lib/node_modules/openclaw",
|
||||
installKind: "package",
|
||||
timeoutMs: 1_000,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: "UpdatePreMutationError",
|
||||
reason: "unmanaged-package-install",
|
||||
message:
|
||||
"This OpenClaw installation is managed by Homebrew. To update OpenClaw, run:\n\n brew upgrade openclaw-cli\n\nThen restart the gateway:\n\n openclaw gateway restart",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("does not treat global npm packages under HOMEBREW_PREFIX as Homebrew formula installs", async () => {
|
||||
const originalPrefix = process.env.HOMEBREW_PREFIX;
|
||||
process.env.HOMEBREW_PREFIX = "/opt/homebrew-custom";
|
||||
runCommandWithTimeout.mockResolvedValue({
|
||||
...successfulCommandResult,
|
||||
code: 1,
|
||||
stderr: "not owned",
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
resolveGlobalManager({
|
||||
root: "/opt/homebrew-custom/lib/node_modules/openclaw",
|
||||
installKind: "package",
|
||||
timeoutMs: 1_000,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: "UpdatePreMutationError",
|
||||
message: expect.stringMatching(
|
||||
/No package changes or Gateway restart were attempted\.[\s\S]*Inspected:[\s\S]*\/opt\/homebrew-custom\/lib\/node_modules\/openclaw/,
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
process.env.HOMEBREW_PREFIX = originalPrefix;
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes a successful fresh clone only after the clone completes", async () => {
|
||||
await withTestDir({ prefix: "openclaw-update-clone-success-" }, async (base) => {
|
||||
const checkoutDir = path.join(base, "nested", "openclaw");
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import os from "node:os";
|
|||
import path from "node:path";
|
||||
import { parseStrictPositiveInteger } from "@openclaw/normalization-core/number-coercion";
|
||||
import { theme } from "../../../packages/terminal-core/src/theme.js";
|
||||
import { resolveBrewOpenClawPath } from "../../infra/brew.js";
|
||||
import { hasErrnoCode } from "../../infra/errors.js";
|
||||
import { resolveRequiredHomeDir } from "../../infra/home-dir.js";
|
||||
import { resolveOpenClawPackageRoot } from "../../infra/openclaw-root.js";
|
||||
|
|
@ -451,6 +452,13 @@ export async function resolveGlobalManager(params: {
|
|||
params.pkgOwnership ?? createFreeBsdPkgOwnershipInspection(params.timeoutMs)
|
||||
).assertUnowned(params.root);
|
||||
if (params.installKind === "package") {
|
||||
if (await resolveBrewOpenClawPath(params.root)) {
|
||||
const reason = resolveUnmanagedUpdateInstallReason();
|
||||
throw new UpdatePreMutationError(
|
||||
reason,
|
||||
"This OpenClaw installation is managed by Homebrew. To update OpenClaw, run:\n\n brew upgrade openclaw-cli\n\nThen restart the gateway:\n\n openclaw gateway restart",
|
||||
);
|
||||
}
|
||||
const diagnostics: string[] = [];
|
||||
const detected = await detectGlobalInstallManagerForRoot(
|
||||
runCommandWithTimeout,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
|||
import path from "node:path";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
|
||||
import * as nodeRuntime from "../../commands/node-runtime-diagnostics.js";
|
||||
import * as container from "../../infra/container-environment.js";
|
||||
import * as updateCheck from "../../infra/update-check.js";
|
||||
import { prepareUpdateFailureReport } from "../../infra/update-failure-report-prepare.js";
|
||||
|
|
@ -20,6 +21,7 @@ import {
|
|||
} from "../../state/openclaw-state-db.js";
|
||||
import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js";
|
||||
import * as shared from "./shared.js";
|
||||
import { updateStatusCommand } from "./status.js";
|
||||
import * as finalization from "./update-command-finalize.js";
|
||||
import { updateCommand } from "./update-command.js";
|
||||
import { updateRepairCommand } from "./update-repair-command.js";
|
||||
|
|
@ -182,3 +184,76 @@ it("keeps the Git runner's untouched container result out of failure reports", a
|
|||
prepareUpdateFailureReport({ attemptId: "untouched-container", result }),
|
||||
).rejects.toThrow("Only a final failed update");
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32").each([false, true])(
|
||||
"reports Homebrew guidance across output and existing history (database: %s)",
|
||||
async (existingDatabase) => {
|
||||
if (existingDatabase) {
|
||||
openOpenClawStateDatabase();
|
||||
}
|
||||
const prefix = dirs.make("brew-cellar-");
|
||||
vi.stubEnv("HOMEBREW_PREFIX", prefix);
|
||||
const brewRoot = path.join(
|
||||
prefix,
|
||||
"Cellar",
|
||||
"openclaw-cli",
|
||||
"2026.9.4",
|
||||
"libexec",
|
||||
"lib",
|
||||
"node_modules",
|
||||
"openclaw",
|
||||
);
|
||||
await fs.mkdir(path.join(brewRoot, "dist"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(brewRoot, "package.json"),
|
||||
'{"name":"openclaw","version":"2026.9.4"}',
|
||||
);
|
||||
vi.spyOn(shared, "resolveUpdateRoot").mockResolvedValue(brewRoot);
|
||||
vi.spyOn(process, "cwd").mockReturnValue(brewRoot);
|
||||
|
||||
await expect(updateCommand({ json: true, yes: true })).rejects.toMatchObject({ code: 0 });
|
||||
expect(output).toHaveLength(1);
|
||||
expect(output[0]).toMatchObject({
|
||||
status: "skipped",
|
||||
reason: "unmanaged-package-install",
|
||||
before: { version: "2026.9.4" },
|
||||
steps: [],
|
||||
});
|
||||
expect(lines.join("\n")).toContain("brew upgrade openclaw-cli");
|
||||
if (existingDatabase) {
|
||||
const run = listUpdateRuns({ limit: 1 })[0]!;
|
||||
expect(run).toMatchObject({
|
||||
status: "skipped",
|
||||
origin: { nextAction: expect.stringContaining("brew upgrade openclaw-cli") },
|
||||
});
|
||||
expect(output[0]).toMatchObject({ runId: run.runId, run: { origin: run.origin } });
|
||||
const report = renderUpdateRunReport(run).markdown;
|
||||
expect(report).toContain("brew upgrade openclaw-cli");
|
||||
expect(report).toContain("openclaw gateway restart");
|
||||
expect(report.length).toBeLessThanOrEqual(1500);
|
||||
expect(isReportableUpdateRun(run)).toBe(false);
|
||||
vi.spyOn(nodeRuntime, "collectNodeRuntimeFindings").mockResolvedValue([]);
|
||||
vi.spyOn(updateCheck, "checkUpdateStatus").mockResolvedValue({
|
||||
root: brewRoot,
|
||||
installKind: "package",
|
||||
packageManager: "unknown",
|
||||
});
|
||||
await updateStatusCommand({ json: true });
|
||||
expect(output[1]).toMatchObject({
|
||||
lastRun: { runId: run.runId, status: "skipped", origin: run.origin },
|
||||
});
|
||||
} else {
|
||||
expect(output[0]).not.toHaveProperty("runId");
|
||||
await expect(fs.stat(resolveOpenClawStateSqlitePath(process.env))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
}
|
||||
|
||||
lines = [];
|
||||
await expect(updateCommand({ yes: true })).rejects.toMatchObject({ code: 0 });
|
||||
expect(lines.join("\n")).toContain("OpenClaw update skipped: unmanaged-package-install");
|
||||
expect(lines.join("\n")).toContain("brew upgrade openclaw-cli");
|
||||
expect(lines.join("\n")).toContain("openclaw gateway restart");
|
||||
expect(triage).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -608,3 +608,43 @@ describe("resolveNodeProgramArguments", () => {
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
...["/opt/homebrew", "/usr/local", "/home/linuxbrew/.linuxbrew"].map((prefix) => ({
|
||||
entry: `${prefix}/Cellar/openclaw-cli/2026.9.2/libexec/lib/node_modules/openclaw/dist/index.js`,
|
||||
stable: `${prefix}/opt/openclaw-cli/libexec/lib/node_modules/openclaw/dist/index.js`,
|
||||
})),
|
||||
...[
|
||||
"/opt/homebrew/opt/openclaw-cli/libexec/lib/node_modules/openclaw/dist/index.js",
|
||||
"/tmp/unrelated/Cellar/openclaw-cli/2026.9.2/libexec/lib/node_modules/openclaw/dist/index.js",
|
||||
"/opt/homebrew/project/Cellar/openclaw-cli/2026.9.2/libexec/lib/node_modules/openclaw/dist/index.js",
|
||||
"/opt/homebrew/Cellar/openclaw-cli/libexec/lib/node_modules/openclaw/dist/index.js",
|
||||
"/usr/local/lib/node_modules/openclaw/dist/index.js",
|
||||
"/opt/homebrew/lib/node_modules/openclaw/dist/index.js",
|
||||
"/home/user/.local/share/pnpm/global/5/node_modules/openclaw/dist/index.js",
|
||||
"/home/user/.bun/install/global/node_modules/openclaw/dist/index.js",
|
||||
"/home/user/openclaw/dist/index.js",
|
||||
"C:/Users/test/AppData/Roaming/npm/node_modules/openclaw/dist/index.js",
|
||||
].map((entry) => ({ entry, stable: entry })),
|
||||
])(
|
||||
"keeps service entrypoints stable without rewriting other installs: $entry",
|
||||
async ({ entry, stable }) => {
|
||||
const entryPath = path.resolve(entry);
|
||||
const expected = process.platform === "win32" ? entryPath : path.resolve(stable);
|
||||
process.argv = ["node", entryPath];
|
||||
fsMocks.realpath.mockResolvedValue(entryPath);
|
||||
fsMocks.access.mockResolvedValue(undefined);
|
||||
for (const runtime of ["node", "bun"] as const) {
|
||||
const runtimePath = runtime === "node" ? validatedNodePath : validatedBunPath;
|
||||
const gateway = await resolveGatewayProgramArguments({ port: 18789, runtime, runtimePath });
|
||||
const node = await resolveNodeProgramArguments({
|
||||
host: "gateway.example",
|
||||
port: 18789,
|
||||
runtime,
|
||||
runtimePath,
|
||||
});
|
||||
expect(gateway.programArguments[runtime === "node" ? 2 : 1]).toBe(expected);
|
||||
expect(node.programArguments[1]).toBe(expected);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import fs from "node:fs/promises";
|
|||
import path from "node:path";
|
||||
import { SUPPORTED_NODE_VERSIONS } from "../../node-version.mjs";
|
||||
import type { GatewayDaemonRuntime } from "../commands/daemon-runtime.js";
|
||||
import { resolveBrewOpenClawPath } from "../infra/brew.js";
|
||||
import {
|
||||
buildGatewayDistEntrypointCandidates,
|
||||
findFirstAccessibleGatewayEntrypoint,
|
||||
|
|
@ -216,7 +217,11 @@ async function resolveCliProgramArguments(params: {
|
|||
|
||||
const cliEntrypointPath = await resolveCliEntrypointPathForService();
|
||||
return {
|
||||
programArguments: [runtimePath, cliEntrypointPath, ...params.args],
|
||||
programArguments: [
|
||||
runtimePath,
|
||||
(await resolveBrewOpenClawPath(cliEntrypointPath)) ?? cliEntrypointPath,
|
||||
...params.args,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -80,3 +80,35 @@ export function resolveBrewExecutable(opts?: BrewResolutionOptions): string | un
|
|||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Recognize formula-owned OpenClaw files and keep service paths independent of the keg version. */
|
||||
export async function resolveBrewOpenClawPath(inputPath: string): Promise<string | null> {
|
||||
const match =
|
||||
/^(.*)\/(?:Cellar\/openclaw-cli\/[^/]+|opt\/openclaw-cli)(\/libexec(?:\/.*)?)$/u.exec(
|
||||
inputPath,
|
||||
);
|
||||
if (!match || process.platform === "win32") {
|
||||
return null;
|
||||
}
|
||||
const [, prefix, suffix] = match;
|
||||
const prefixes = [
|
||||
process.env.HOMEBREW_PREFIX,
|
||||
...resolveBrewPathDirs().map((dir) => path.dirname(dir)),
|
||||
];
|
||||
if (
|
||||
!prefixes.some((value) => value && path.isAbsolute(value) && path.resolve(value) === prefix)
|
||||
) {
|
||||
const brew = resolveBrewExecutable();
|
||||
if (!brew) {
|
||||
return null;
|
||||
}
|
||||
const { runCommandWithTimeout } = await import("../process/exec.js");
|
||||
const result = await runCommandWithTimeout([brew, "--prefix"], { timeoutMs: 5_000 }).catch(
|
||||
() => null,
|
||||
);
|
||||
if (result?.code !== 0 || result.stdout.trim() !== prefix) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return path.join(prefix!, "opt", "openclaw-cli", suffix!);
|
||||
}
|
||||
|
|
|
|||
52
src/infra/update-homebrew.test.ts
Normal file
52
src/infra/update-homebrew.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import { resolveBrewOpenClawPath } from "./brew.js";
|
||||
|
||||
const dirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
describe.skipIf(process.platform === "win32")("Homebrew formula paths", () => {
|
||||
it.each(["/opt/homebrew", "/usr/local", "/home/linuxbrew/.linuxbrew"])(
|
||||
"recognizes only formula layouts immediately beneath %s",
|
||||
async (prefix) => {
|
||||
const suffix = "/libexec/lib/node_modules/openclaw";
|
||||
const stable = `${prefix}/opt/openclaw-cli${suffix}`;
|
||||
await expect(
|
||||
resolveBrewOpenClawPath(`${prefix}/Cellar/openclaw-cli/2026.9.4_1${suffix}`),
|
||||
).resolves.toBe(stable);
|
||||
await expect(resolveBrewOpenClawPath(stable)).resolves.toBe(stable);
|
||||
for (const root of [
|
||||
`${prefix}/lib/node_modules/openclaw`,
|
||||
`${prefix}/Cellar/openclaw-cli${suffix}`,
|
||||
`${prefix}/project/opt/openclaw-cli${suffix}`,
|
||||
`${prefix}-other/Cellar/openclaw-cli/2026.9.4${suffix}`,
|
||||
]) {
|
||||
await expect(resolveBrewOpenClawPath(root)).resolves.toBeNull();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["environment", "brew", "unavailable"])(
|
||||
"resolves custom prefixes via %s",
|
||||
async (source) => {
|
||||
const prefix = dirs.make("homebrew-prefix-");
|
||||
const bin = path.join(prefix, "bin");
|
||||
await fs.mkdir(bin);
|
||||
await fs.writeFile(
|
||||
path.join(bin, "brew"),
|
||||
'#!/bin/sh\n[ "$1" = "--prefix" ] || exit 9\n' +
|
||||
(source === "brew" ? 'printf "%s\\n" "${0%/bin/brew}"\n' : "exit 1\n"),
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
vi.stubEnv("PATH", bin);
|
||||
vi.stubEnv("HOMEBREW_PREFIX", source === "environment" ? prefix : undefined);
|
||||
const suffix = "/libexec/lib/node_modules/openclaw";
|
||||
const root = `${prefix}/Cellar/openclaw-cli/2026.9.4${suffix}`;
|
||||
await expect(resolveBrewOpenClawPath(root)).resolves.toBe(
|
||||
source === "unavailable" ? null : `${prefix}/opt/openclaw-cli${suffix}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue