mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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",
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user