fix(macos): unblock first-launch gateway setup (#119831)

* fix(macos): stop writing retired config metadata

* fix(macos): guard packaged CLI bootstrap versions

* chore(macos): refresh native i18n inventory

* fix(macos): repair retired metadata before gateway start
This commit is contained in:
Patrick Erichsen
2026-08-05 22:25:17 -07:00
committed by GitHub
parent b8f6086411
commit 355c107c09
8 changed files with 422 additions and 20 deletions
+8 -8
View File
@@ -30355,7 +30355,7 @@
},
{
"kind": "ui-localized-call",
"line": 429,
"line": 490,
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
"source": "Repairing the OpenClaw Gateway update…",
"surface": "apple",
@@ -30363,7 +30363,7 @@
},
{
"kind": "ui-localized-call",
"line": 430,
"line": 491,
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
"source": "Updating the OpenClaw Gateway to \\(targetVersion)…",
"surface": "apple",
@@ -30371,7 +30371,7 @@
},
{
"kind": "ui-localized-call",
"line": 451,
"line": 512,
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
"source": "Gateway update needs attention.",
"surface": "apple",
@@ -30379,7 +30379,7 @@
},
{
"kind": "ui-localized-call",
"line": 453,
"line": 514,
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
"source": "Gateway update failed.",
"surface": "apple",
@@ -30387,7 +30387,7 @@
},
{
"kind": "ui-localized-call",
"line": 468,
"line": 529,
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
"source": "Gateway update finished, but verification failed.",
"surface": "apple",
@@ -30395,7 +30395,7 @@
},
{
"kind": "ui-localized-call",
"line": 475,
"line": 536,
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
"source": "OpenClaw Gateway \\(installedVersion) is installed.",
"surface": "apple",
@@ -33123,7 +33123,7 @@
},
{
"kind": "conditional-branch",
"line": 569,
"line": 578,
"path": "apps/macos/Sources/OpenClaw/GatewayProcessManager.swift",
"source": "not linked",
"surface": "apple",
@@ -33131,7 +33131,7 @@
},
{
"kind": "conditional-branch",
"line": 582,
"line": 591,
"path": "apps/macos/Sources/OpenClaw/GatewayProcessManager.swift",
"source": "unknown error",
"surface": "apple",
+64 -3
View File
@@ -330,10 +330,12 @@ enum CLIInstaller {
await statusHandler("Install failed: installer resource is missing. Reinstall OpenClaw.")
return false
}
let appVersion = GatewayEnvironment.appVersionString()
let cmd = self.installScriptCommand(
target: target,
prefix: prefix,
scriptPath: installerURL.path)
scriptPath: installerURL.path,
compatibleWith: target.requiresExactVersion ? nil : appVersion)
let response = await ShellExecutor.runDetailed(
command: cmd,
cwd: nil,
@@ -343,10 +345,22 @@ enum CLIInstaller {
if response.success {
let expectedVersion = target.requiresExactVersion ? GatewayEnvironment.appVersionString() : nil
let managedStatus = await self.managedStatus(expectedVersion: expectedVersion)
guard managedStatus.isReady else {
guard case let .ready(_, verifiedVersion) = managedStatus else {
await statusHandler("Install failed: \(managedStatus.message)")
return false
}
if case let .channel(channel) = target,
let appVersion,
!self.channelInstallIsCompatible(
installedVersion: verifiedVersion,
appVersion: appVersion)
{
await statusHandler(
"Install failed: \(channel.label) resolved to Gateway \(verifiedVersion), " +
"which is older than this app (\(appVersion)). Choose a newer CLI channel " +
"or retry after the channel is updated.")
return false
}
let parsed = self.parseInstallEvents(response.stdout)
let installedVersion = parsed.last { $0.event == "done" }?.version
let summary = installedVersion.map { "Installed openclaw \($0)." } ?? "Installed openclaw."
@@ -368,6 +382,45 @@ enum CLIInstaller {
return false
}
static func channelInstallIsCompatible(
installedVersion: String,
appVersion: String) -> Bool
{
guard let installed = Semver.parse(installedVersion),
let app = Semver.parse(appVersion)
else {
return false
}
if installed != app { return installed > app }
// The CLI's future-config guard permits all same-base stable/correction families.
// For prerelease app builds, only an older prerelease would block the service write.
guard let appPrerelease = self.prereleaseTail(appVersion),
!self.isCorrectionPrerelease(appPrerelease)
else {
return true
}
guard let installedPrerelease = self.prereleaseTail(installedVersion),
!self.isCorrectionPrerelease(installedPrerelease)
else {
return true
}
return installedPrerelease.compare(appPrerelease, options: .numeric) != .orderedAscending
}
private static func prereleaseTail(_ version: String) -> String? {
let withoutBuild = version
.trimmingCharacters(in: .whitespacesAndNewlines)
.split(separator: "+", maxSplits: 1, omittingEmptySubsequences: false)[0]
guard let separator = withoutBuild.firstIndex(of: "-") else { return nil }
let tail = String(withoutBuild[withoutBuild.index(after: separator)...])
return tail.isEmpty ? nil : tail
}
private static func isCorrectionPrerelease(_ prerelease: String) -> Bool {
!prerelease.isEmpty && prerelease.allSatisfy(\.isNumber)
}
static func installWatchdogTimeout(for target: InstallTarget) -> TimeInterval {
// Dev installs clone/fetch source, install dependencies, and build the UI
// plus CLI. Keep that workflow bounded without killing healthy cold builds.
@@ -380,7 +433,12 @@ enum CLIInstaller {
.path
}
static func installScriptCommand(target: InstallTarget, prefix: String, scriptPath: String) -> [String] {
static func installScriptCommand(
target: InstallTarget,
prefix: String,
scriptPath: String,
compatibleWith appVersion: String? = nil) -> [String]
{
var command = [
"/bin/bash",
scriptPath,
@@ -391,6 +449,9 @@ enum CLIInstaller {
"--version",
target.selector,
]
if let appVersion, !target.requiresExactVersion {
command.append(contentsOf: ["--compatible-with", appVersion])
}
if target == .channel(.dev) {
command.append(contentsOf: [
"--install-method",
@@ -346,6 +346,15 @@ final class GatewayProcessManager {
self.status = .stopped
return
}
guard OpenClawConfigFile.migrateRetiredAppMetadataForGatewayStart() else {
let message =
"Could not repair retired macOS config metadata. Run `openclaw doctor --fix`, then retry."
self.status = .failed(message)
self.lastFailureReason = message
self.appendLog("[gateway] \(message)\n")
self.logger.error("gateway config metadata migration failed")
return
}
// Many surfaces can call `setActive(true)` in quick succession (startup, Canvas, health checks).
// Avoid spawning multiple concurrent "start" tasks that can thrash launchd and flap the port.
switch self.status {
@@ -223,6 +223,21 @@ enum OpenClawConfigFile {
let browser = root["browser"] as? [String: Any]
return browser?["enabled"] as? Bool ?? defaultValue
}
/// Beta macOS builds wrote this retired key after core moved it to SQLite.
/// Repair only that app-owned shape before local Gateway validation can reject it.
static func migrateRetiredAppMetadataForGatewayStart() -> Bool {
self.withFileLock {
let root = self.loadDict()
guard let meta = root["meta"] as? [String: Any],
meta.keys.contains("lastTouchedAt")
else {
return true
}
self.logger.notice("removing retired app-written config metadata before Gateway start")
return self.saveDict(root)
}
}
}
extension OpenClawConfigFile {
@@ -455,7 +470,9 @@ extension OpenClawConfigFile {
var meta = root["meta"] as? [String: Any] ?? [:]
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "macos-app"
meta["lastTouchedVersion"] = version
meta["lastTouchedAt"] = ISO8601DateFormatter().string(from: Date())
// Machine-state timestamps moved to SQLite. Keeping this retired config key makes the
// matching CLI reject the app's config before the Gateway can start.
meta.removeValue(forKey: "lastTouchedAt")
root["meta"] = meta
}
@@ -36,7 +36,8 @@ struct CLIInstallerTests {
let command = CLIInstaller.installScriptCommand(
target: .exact("2026.7.3-beta.1"),
prefix: "/Users/Test User/.openclaw",
scriptPath: "/Applications/OpenClaw.app/Contents/Resources/install-cli.sh")
scriptPath: "/Applications/OpenClaw.app/Contents/Resources/install-cli.sh",
compatibleWith: "2026.7.4")
#expect(command == [
"/bin/bash",
@@ -49,17 +50,32 @@ struct CLIInstallerTests {
"2026.7.3-beta.1",
])
#expect(!command.contains("curl"))
#expect(!command.contains("--compatible-with"))
}
@Test func `channel installer checks compatibility before replacing the managed CLI`() {
let command = CLIInstaller.installScriptCommand(
target: .channel(.stable),
prefix: "/Users/Test User/.openclaw",
scriptPath: "/Applications/OpenClaw.app/Contents/Resources/install-cli.sh",
compatibleWith: "2026.7.3-beta.8")
#expect(command.suffix(3) == [
"latest",
"--compatible-with",
"2026.7.3-beta.8",
])
}
@Test func `dev installer uses a managed git main checkout`() {
let command = CLIInstaller.installScriptCommand(
target: .channel(.dev),
prefix: "/Users/Test User/.openclaw",
scriptPath: "/Applications/OpenClaw.app/Contents/Resources/install-cli.sh")
scriptPath: "/Applications/OpenClaw.app/Contents/Resources/install-cli.sh",
compatibleWith: "2026.7.3")
#expect(command.suffix(6) == [
"--version",
"main",
#expect(command.suffix(5) == [
"2026.7.3",
"--install-method",
"git",
"--git-dir",
@@ -230,6 +246,27 @@ struct CLIInstallerTests {
required: "2026.7.3"))
}
@Test func `channel install cannot bootstrap with an older config writer`() {
#expect(!CLIInstaller.channelInstallIsCompatible(
installedVersion: "2026.7.1-2",
appVersion: "2026.7.2"))
#expect(!CLIInstaller.channelInstallIsCompatible(
installedVersion: "2026.7.2-beta.6",
appVersion: "2026.7.2-beta.7"))
#expect(CLIInstaller.channelInstallIsCompatible(
installedVersion: "2026.7.2",
appVersion: "2026.7.2-beta.7"))
#expect(CLIInstaller.channelInstallIsCompatible(
installedVersion: "2026.7.2-beta.7",
appVersion: "2026.7.2"))
#expect(CLIInstaller.channelInstallIsCompatible(
installedVersion: "2026.7.2-1",
appVersion: "2026.7.2-2"))
#expect(CLIInstaller.channelInstallIsCompatible(
installedVersion: "2026.7.3-beta.1",
appVersion: "2026.7.2"))
}
@Test func `compatible external CLI satisfies setup`() async throws {
let root = FileManager().temporaryDirectory.appendingPathComponent(
"openclaw-compatible-cli-\(UUID().uuidString)")
@@ -114,6 +114,53 @@ struct OpenClawConfigFileTests {
}
}
@MainActor
@Test
func `save dict removes retired config metadata`() async throws {
let configPath = self.makeConfigOverridePath()
defer { try? FileManager().removeItem(at: URL(fileURLWithPath: configPath).deletingLastPathComponent()) }
try await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) {
#expect(OpenClawConfigFile.saveDict([
"gateway": ["mode": "local"],
"meta": ["lastTouchedAt": "2026-08-05T22:45:14Z"],
]))
let data = try Data(contentsOf: URL(fileURLWithPath: configPath))
let root = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
let meta = try #require(root["meta"] as? [String: Any])
#expect(meta["lastTouchedVersion"] as? String != nil)
#expect(meta["lastTouchedAt"] == nil)
}
}
@MainActor
@Test
func `gateway start migration repairs existing retired config metadata`() async throws {
let configPath = self.makeConfigOverridePath()
let configURL = URL(fileURLWithPath: configPath)
defer { try? FileManager().removeItem(at: configURL.deletingLastPathComponent()) }
try FileManager().createDirectory(
at: configURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
try Data(
#"{"gateway":{"mode":"local"},"meta":{"lastTouchedAt":"2026-08-05T22:45:14Z"}}"#.utf8)
.write(to: configURL)
try await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) {
#expect(OpenClawConfigFile.migrateRetiredAppMetadataForGatewayStart())
let data = try Data(contentsOf: configURL)
let root = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
let gateway = try #require(root["gateway"] as? [String: Any])
let meta = try #require(root["meta"] as? [String: Any])
#expect(gateway["mode"] as? String == "local")
#expect(meta["lastTouchedVersion"] as? String != nil)
#expect(meta["lastTouchedAt"] == nil)
}
}
@MainActor
@Test
func `save dict preserves gateway auth unless explicitly allowed`() async throws {
+153 -3
View File
@@ -64,6 +64,7 @@ resolve_openclaw_effective_home() {
OPENCLAW_EFFECTIVE_HOME="$(resolve_openclaw_effective_home)"
PREFIX="${OPENCLAW_PREFIX:-${HOME}/.openclaw}"
OPENCLAW_VERSION="${OPENCLAW_VERSION:-latest}"
REQUIRED_COMPATIBLE_VERSION=""
DEFAULT_NODE_VERSION="24.15.0"
ARMV7_DEFAULT_NODE_VERSION="22.22.3"
NODE_VERSION="${OPENCLAW_NODE_VERSION:-${DEFAULT_NODE_VERSION}}"
@@ -95,6 +96,7 @@ Usage: install-cli.sh [options]
--git, --github Shortcut for --install-method git
--git-dir, --dir <path> Checkout directory (default: ~/openclaw, or \$OPENCLAW_HOME/openclaw)
--version <ver> OpenClaw version (default: latest)
--compatible-with <ver> Refuse a CLI that cannot modify config written by <ver>
--node-version <ver> Node version (default: 24.15.0; 22.22.3 on Linux ARMv7)
--onboard Run "openclaw onboard" after install
--no-onboard Skip onboarding (default)
@@ -289,6 +291,13 @@ parse_args() {
OPENCLAW_VERSION="$2"
shift 2
;;
--compatible-with)
if [[ $# -lt 2 || "${2:-}" == --* ]]; then
fail "Missing value for $1"
fi
REQUIRED_COMPATIBLE_VERSION="$2"
shift 2
;;
--node-version)
if [[ $# -lt 2 || "${2:-}" == --* ]]; then
fail "Missing value for $1"
@@ -725,6 +734,122 @@ is_openclaw_source_package_install_spec() {
return 1
}
openclaw_version_is_compatible_with() {
local candidate="$1"
local config_writer="$2"
"$(node_bin)" - "$candidate" "$config_writer" <<'NODE'
const candidateRaw = process.argv[2];
const writerRaw = process.argv[3];
function parse(raw) {
let value = String(raw ?? "").trim();
const legacyBeta = /^([vV]?\d+\.\d+\.\d+)\.beta(?:\.([0-9A-Za-z.-]+))?$/.exec(value);
if (legacyBeta) {
value = `${legacyBeta[1]}-beta${legacyBeta[2] ? `.${legacyBeta[2]}` : ""}`;
}
const match = /^[vV]?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/.exec(value);
if (!match) return null;
const parseIdentifiers = (rawIdentifiers) => {
if (!rawIdentifiers) return [];
const identifiers = rawIdentifiers.split(".");
if (identifiers.some((identifier) => identifier.length === 0)) return null;
return identifiers.map((identifier) => {
if (!/^\d+$/.test(identifier)) return identifier;
if (identifier.length > 1 && identifier.startsWith("0")) return null;
return Number(identifier);
});
};
const prerelease = parseIdentifiers(match[4]);
const build = parseIdentifiers(match[5]);
if (!prerelease || !build || prerelease.includes(null) || build.includes(null)) return null;
return {
core: [Number(match[1]), Number(match[2]), Number(match[3])],
prerelease,
build,
};
}
function compareIdentifiers(left, right) {
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
const a = left[index];
const b = right[index];
if (a === undefined) return -1;
if (b === undefined) return 1;
if (a === b) continue;
if (typeof a === "number" && typeof b === "number") return a < b ? -1 : 1;
if (typeof a === "number") return -1;
if (typeof b === "number") return 1;
return a < b ? -1 : 1;
}
return 0;
}
function isCorrection(version) {
return version.prerelease.length === 1 && typeof version.prerelease[0] === "number";
}
function comparable(version) {
if (!isCorrection(version)) return version;
return { ...version, prerelease: [], build: [version.prerelease[0]] };
}
function compare(leftRaw, rightRaw) {
const left = comparable(leftRaw);
const right = comparable(rightRaw);
for (let index = 0; index < left.core.length; index += 1) {
if (left.core[index] !== right.core[index]) {
return left.core[index] < right.core[index] ? -1 : 1;
}
}
if (left.prerelease.length === 0 && right.prerelease.length > 0) return 1;
if (right.prerelease.length === 0 && left.prerelease.length > 0) return -1;
const prereleaseOrder = compareIdentifiers(left.prerelease, right.prerelease);
return prereleaseOrder === 0 ? compareIdentifiers(left.build, right.build) : prereleaseOrder;
}
const candidate = parse(candidateRaw);
const writer = parse(writerRaw);
if (!candidate || !writer) process.exit(2);
const sameCore = candidate.core.every((part, index) => part === writer.core[index]);
if (sameCore && (writer.prerelease.length === 0 || isCorrection(writer))) process.exit(0);
process.exit(compare(candidate, writer) < 0 ? 1 : 0);
NODE
}
require_openclaw_version_compatible() {
local candidate="$1"
local config_writer="${REQUIRED_COMPATIBLE_VERSION:-}"
if [[ -z "$config_writer" ]]; then
return 0
fi
if openclaw_version_is_compatible_with "$candidate" "$config_writer"; then
return 0
fi
local status="$?"
if [[ "$status" -eq 2 ]]; then
fail "Cannot compare resolved OpenClaw version '${candidate}' with config writer '${config_writer}'."
fi
fail "OpenClaw ${candidate} is older than config writer ${config_writer}. Choose a newer CLI channel or retry after the channel is updated."
}
resolve_npm_openclaw_version() {
local requested="$1"
"$(npm_bin)" view "openclaw@${requested}" version 2>/dev/null | awk 'NF { value = $0 } END { print value }'
}
resolve_git_checkout_openclaw_version() {
local repo_dir="$1"
"$(node_bin)" -e '
const fs = require("node:fs");
const path = require("node:path");
const value = JSON.parse(fs.readFileSync(path.join(process.argv[1], "package.json"), "utf8")).version;
if (typeof value !== "string" || value.trim() === "") process.exit(1);
process.stdout.write(value.trim());
' "$repo_dir"
}
resolve_git_openclaw_ref() {
local requested="${OPENCLAW_VERSION:-latest}"
local resolved_version=""
@@ -1102,6 +1227,14 @@ install_openclaw() {
--no-audit
"$freshness_flag"
)
local resolved_requested="$requested"
if [[ -n "${REQUIRED_COMPATIBLE_VERSION:-}" ]]; then
resolved_requested="$(resolve_npm_openclaw_version "$requested")"
if [[ -z "$resolved_requested" ]]; then
fail "Could not resolve OpenClaw ${requested} before compatibility checking."
fi
require_openclaw_version_compatible "$resolved_requested"
fi
emit_json "{\"event\":\"step\",\"name\":\"openclaw\",\"status\":\"start\",\"version\":\"${requested}\"}"
log "Installing OpenClaw (${requested})..."
if [[ "$SET_NPM_PREFIX" -eq 1 ]]; then
@@ -1109,14 +1242,22 @@ install_openclaw() {
fi
if [[ "${requested}" == "latest" ]]; then
if ! env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$(npm_bin)" install -g --prefix "$(node_dir)" "${npm_args[@]}" "openclaw@latest"; then
if ! env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$(npm_bin)" install -g --prefix "$(node_dir)" "${npm_args[@]}" "openclaw@${resolved_requested}"; then
log "npm install openclaw@latest failed; retrying openclaw@next"
emit_json "{\"event\":\"step\",\"name\":\"openclaw\",\"status\":\"retry\",\"version\":\"next\"}"
env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$(npm_bin)" install -g --prefix "$(node_dir)" "${npm_args[@]}" "openclaw@next"
resolved_requested="next"
if [[ -n "${REQUIRED_COMPATIBLE_VERSION:-}" ]]; then
resolved_requested="$(resolve_npm_openclaw_version next)"
if [[ -z "$resolved_requested" ]]; then
fail "Could not resolve OpenClaw next before compatibility checking."
fi
require_openclaw_version_compatible "$resolved_requested"
fi
env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$(npm_bin)" install -g --prefix "$(node_dir)" "${npm_args[@]}" "openclaw@${resolved_requested}"
requested="next"
fi
else
env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$(npm_bin)" install -g --prefix "$(node_dir)" "${npm_args[@]}" "openclaw@${requested}"
env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$(npm_bin)" install -g --prefix "$(node_dir)" "${npm_args[@]}" "openclaw@${resolved_requested}"
fi
mkdir -p "${PREFIX}/bin"
@@ -1212,6 +1353,15 @@ install_openclaw_from_git() {
log "Repo is dirty; skipping git checkout/update"
fi
if [[ -n "${REQUIRED_COMPATIBLE_VERSION:-}" ]]; then
local resolved_version
resolved_version="$(resolve_git_checkout_openclaw_version "$repo_dir" 2>/dev/null || true)"
if [[ -z "$resolved_version" ]]; then
fail "Could not resolve the Git checkout version before compatibility checking."
fi
require_openclaw_version_compatible "$resolved_version"
fi
cleanup_legacy_submodules "$repo_dir"
ensure_pnpm_git_prepare_allowlist "$repo_dir"
activate_repo_pnpm_version "$repo_dir"
+81
View File
@@ -217,6 +217,87 @@ describe("install-cli.sh", () => {
expect(result.stdout + result.stderr).not.toContain("unbound variable");
});
it("matches the Gateway future-config compatibility rule", () => {
const result = runInstallCliShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
node_bin() { command -v node; }
set +e
for pair in \
2026.7.1-2:2026.7.2 \
2026.7.2-beta.6:2026.7.2-beta.7 \
2026.7.2:2026.7.2-beta.7 \
2026.7.2-beta.7:2026.7.2 \
2026.7.2-1:2026.7.2-2 \
2026.7.3-beta.1:2026.7.2; do
candidate="\${pair%%:*}"
writer="\${pair#*:}"
openclaw_version_is_compatible_with "$candidate" "$writer"
printf '%s=%s\\n' "$pair" "$?"
done
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("2026.7.1-2:2026.7.2=1");
expect(result.stdout).toContain("2026.7.2-beta.6:2026.7.2-beta.7=1");
expect(result.stdout).toContain("2026.7.2:2026.7.2-beta.7=0");
expect(result.stdout).toContain("2026.7.2-beta.7:2026.7.2=0");
expect(result.stdout).toContain("2026.7.2-1:2026.7.2-2=0");
expect(result.stdout).toContain("2026.7.3-beta.1:2026.7.2=0");
});
it("rejects an incompatible channel before replacing an existing managed CLI", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-compatible-"));
const prefix = join(tmp, "prefix");
const bin = join(prefix, "bin");
const openclaw = join(bin, "openclaw");
mkdirSync(bin, { recursive: true });
writeFileSync(openclaw, "existing-managed-cli\n");
try {
const result = runInstallCliShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
PREFIX=${JSON.stringify(prefix)}
OPENCLAW_VERSION=latest
REQUIRED_COMPATIBLE_VERSION=2026.7.2
node_bin() { command -v node; }
npm_bin() { printf 'npm\\n'; }
npm_config_has_raw_key() { return 1; }
npm() {
if [[ "$1" == "view" ]]; then printf '2026.7.1-2\\n'; return 0; fi
if [[ "$1" == "config" ]]; then printf 'null\\n'; return 0; fi
printf 'unexpected mutation: %s\\n' "$*" >&2
return 99
}
install_openclaw
`);
expect(result.status).toBe(1);
expect(result.stdout).toContain("OpenClaw 2026.7.1-2 is older than config writer 2026.7.2");
expect(result.stderr).not.toContain("unexpected mutation");
expect(readFileSync(openclaw, "utf8")).toBe("existing-managed-cli\n");
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("checks a git checkout version before dependency install or wrapper replacement", () => {
const checkoutIndex = script.indexOf('checkout_git_openclaw_ref "$repo_dir" "$git_ref"');
const compatibilityIndex = script.indexOf(
'require_openclaw_version_compatible "$resolved_version"',
);
const dependencyInstallIndex = script.indexOf(
'CI="${CI:-true}" run_pnpm -C "$repo_dir" install "$install_lockfile_flag"',
);
const wrapperIndex = script.indexOf('cat > "${PREFIX}/bin/openclaw"', compatibilityIndex);
expect(checkoutIndex).toBeGreaterThan(-1);
expect(compatibilityIndex).toBeGreaterThan(checkoutIndex);
expect(dependencyInstallIndex).toBeGreaterThan(compatibilityIndex);
expect(wrapperIndex).toBeGreaterThan(compatibilityIndex);
});
it("does not restart a gateway again after force-install activates it", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-gateway-refresh-"));
const prefix = join(tmp, "prefix");