fix(macos): offer CLI channels for unreleased builds (#104178)

* fix(macos): choose CLI channel for dev builds

* chore: leave release notes to automation

* chore(macos): sync native string inventory

* style(macos): wrap CLI channel guidance

* fix(macos): return CLI version policy
This commit is contained in:
Peter Steinberger
2026-07-10 22:27:57 -07:00
committed by GitHub
parent 79bfa3771c
commit 4b3d4020d3
8 changed files with 299 additions and 36 deletions
+44 -4
View File
@@ -15195,7 +15195,47 @@
},
{
"kind": "conditional-branch",
"line": 34,
"line": 107,
"path": "apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift",
"source": "CLI install failed",
"surface": "apple",
"id": "native.apple.b9da2c873ad24b0f"
},
{
"kind": "conditional-branch",
"line": 107,
"path": "apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift",
"source": "CLI install finished",
"surface": "apple",
"id": "native.apple.506cd570a8f8a80d"
},
{
"kind": "conditional-branch",
"line": 46,
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
"source": "Stable",
"surface": "apple",
"id": "native.apple.a952bff9aa7c7e9b"
},
{
"kind": "conditional-branch",
"line": 47,
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
"source": "Beta",
"surface": "apple",
"id": "native.apple.a80cbef551294895"
},
{
"kind": "conditional-branch",
"line": 48,
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
"source": "Dev (Git main)",
"surface": "apple",
"id": "native.apple.dac0bc4221a6e965"
},
{
"kind": "conditional-branch",
"line": 101,
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
"source": "OpenClaw Gateway \\(version) is ready.",
"surface": "apple",
@@ -15203,7 +15243,7 @@
},
{
"kind": "conditional-branch",
"line": 36,
"line": 103,
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
"source": "OpenClaw Gateway is not installed yet.",
"surface": "apple",
@@ -15211,7 +15251,7 @@
},
{
"kind": "conditional-branch",
"line": 38,
"line": 105,
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
"source": "The OpenClaw Gateway could not be verified. Setup will repair it.",
"surface": "apple",
@@ -15219,7 +15259,7 @@
},
{
"kind": "conditional-branch",
"line": 40,
"line": 107,
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
"source": "Gateway \\(found) does not match app \\(required). Setup will update it.",
"surface": "apple",
@@ -29,29 +29,64 @@ final class CLIInstallPrompter {
guard lastPrompt != version else { return }
UserDefaults.standard.set(version, forKey: cliInstallPromptedVersionKey)
let alert = NSAlert()
alert.messageText = "Install OpenClaw CLI?"
alert.informativeText = "Local mode needs the CLI so launchd can run the gateway."
alert.addButton(withTitle: "Install CLI")
alert.addButton(withTitle: "Not now")
alert.addButton(withTitle: "Open Settings")
let response = alert.runModal()
switch response {
case .alertFirstButtonReturn:
Task { await self.installCLI() }
case .alertThirdButtonReturn:
self.openSettings(tab: .connection)
default:
break
if let target = self.installTargetForCurrentBuild(confirmStable: true) {
Task { await self.installCLI(target: target) }
}
self.logger.debug("cli install prompt handled reason=\(reason, privacy: .public)")
}
private func installCLI() async {
func installTargetForCurrentBuild(confirmStable: Bool = false) -> CLIInstaller.InstallTarget? {
let appVersion = Self.appVersion()
if let target = CLIInstaller.automaticInstallTarget(
appVersion: appVersion,
isDebug: CLIInstallBuild.isDebug)
{
guard confirmStable else { return target }
let alert = NSAlert()
alert.messageText = "Install OpenClaw CLI?"
alert.informativeText = "Local mode needs the CLI so launchd can run the Gateway."
alert.addButton(withTitle: "Install CLI")
alert.addButton(withTitle: "Not Now")
alert.addButton(withTitle: "Open Settings")
switch alert.runModal() {
case .alertFirstButtonReturn:
return target
case .alertThirdButtonReturn:
self.openSettings(tab: .connection)
return nil
default:
return nil
}
}
return self.chooseChannel(
suggested: CLIInstaller.suggestedChannel(
appVersion: appVersion,
isDebug: CLIInstallBuild.isDebug))
.map(CLIInstaller.InstallTarget.channel)
}
private func chooseChannel(suggested: CLIInstaller.Channel) -> CLIInstaller.Channel? {
let channels = [suggested] + CLIInstaller.Channel.allCases.filter { $0 != suggested }
let alert = NSAlert()
alert.messageText = "Choose OpenClaw CLI channel"
alert.informativeText =
"This is an unreleased OpenClaw build. " +
"Local mode can use Stable, Beta, or Dev from Git main."
for channel in channels {
alert.addButton(withTitle: channel.label)
}
alert.addButton(withTitle: "Not Now")
let response = alert.runModal()
let index = response.rawValue - NSApplication.ModalResponse.alertFirstButtonReturn.rawValue
guard channels.indices.contains(index) else { return nil }
return channels[index]
}
private func installCLI(target: CLIInstaller.InstallTarget) async {
let status = StatusBox()
let installed = await CLIInstaller.install { message in
let installed = await CLIInstaller.install(target: target) { message in
await status.set(message)
}
if installed {
@@ -69,7 +104,7 @@ final class CLIInstallPrompter {
}
if let message = await status.get() {
let alert = NSAlert()
alert.messageText = "CLI install finished"
alert.messageText = installed ? "CLI install finished" : "CLI install failed"
alert.informativeText = message
alert.runModal()
}
+126 -10
View File
@@ -1,7 +1,74 @@
import Foundation
enum CLIInstallBuild {
static var isDebug: Bool {
#if DEBUG
true
#else
false
#endif
}
static func isStable(appVersion: String?, isDebug: Bool) -> Bool {
guard let appVersion, !isDebug else { return false }
guard let separator = appVersion.firstIndex(of: "-") else { return true }
let suffix = appVersion[appVersion.index(after: separator)...]
return suffix.split(separator: ".").allSatisfy { Int($0) != nil }
}
}
enum CLIInstallPolicy {
static func requiredGatewayVersionString(
appVersion: String?,
isDebug: Bool,
defaults: UserDefaults = .standard) -> String?
{
guard !CLIInstallBuild.isStable(appVersion: appVersion, isDebug: isDebug) else {
return appVersion
}
return switch defaults.string(forKey: cliInstallPolicyKey) {
case "stable", "beta", "dev": nil
case "exact", nil: appVersion
default: appVersion
}
}
}
@MainActor
enum CLIInstaller {
enum Channel: String, CaseIterable, Equatable {
case stable
case beta
case dev
var label: String {
switch self {
case .stable: "Stable"
case .beta: "Beta"
case .dev: "Dev (Git main)"
}
}
}
enum InstallTarget: Equatable {
case exact(String)
case channel(Channel)
var selector: String {
switch self {
case let .exact(version): version
case .channel(.stable): "latest"
case .channel(.beta): "beta"
case .channel(.dev): "main"
}
}
var requiresExactVersion: Bool {
if case .exact = self { return true }
return false
}
}
enum LocalGatewayActivation: Equatable {
case ready
case deferred
@@ -109,12 +176,16 @@ enum CLIInstaller {
}
static func managedStatus() async -> Status {
await self.managedStatus(expectedVersion: GatewayEnvironment.expectedGatewayVersionString())
}
private static func managedStatus(expectedVersion: String?) async -> Status {
let location = self.managedExecutableLocation()
guard FileManager.default.isExecutableFile(atPath: location) else {
return .missing(location: location)
}
let status = await self.status(location: location)
let status = await self.status(location: location, expectedVersion: expectedVersion)
if status.isReady {
self.rememberValidated(status)
}
@@ -122,6 +193,12 @@ enum CLIInstaller {
}
static func status(location: String) async -> Status {
await self.status(
location: location,
expectedVersion: GatewayEnvironment.expectedGatewayVersionString())
}
private static func status(location: String, expectedVersion: String?) async -> Status {
let environment = self.probeEnvironment(location: location)
let response = await ShellExecutor.runDetailed(
command: [location, "--version"],
@@ -134,7 +211,7 @@ enum CLIInstaller {
let versionStatus = self.classifyVersion(
location: location,
output: response.stdout,
expectedVersion: GatewayEnvironment.expectedGatewayVersionString())
expectedVersion: expectedVersion)
guard versionStatus.isReady else { return versionStatus }
guard await self.runtimeIsCompatible(environment: environment) else {
return .unusable(location: location)
@@ -202,22 +279,25 @@ enum CLIInstaller {
}
@discardableResult
static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async -> Bool {
let expected = GatewayEnvironment.expectedGatewayVersionString() ?? "latest"
static func install(
target: InstallTarget,
statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async -> Bool
{
let prefix = Self.installPrefix()
await statusHandler("Installing openclaw CLI…")
await statusHandler("Installing OpenClaw CLI (\(target.selector))")
guard let installerURL = Bundle.main.url(forResource: "install-cli", withExtension: "sh") else {
await statusHandler("Install failed: installer resource is missing. Reinstall OpenClaw.")
return false
}
let cmd = self.installScriptCommand(
version: expected,
target: target,
prefix: prefix,
scriptPath: installerURL.path)
let response = await ShellExecutor.runDetailed(command: cmd, cwd: nil, env: nil, timeout: 900)
if response.success {
let managedStatus = await self.managedStatus()
let expectedVersion = target.requiresExactVersion ? GatewayEnvironment.appVersionString() : nil
let managedStatus = await self.managedStatus(expectedVersion: expectedVersion)
guard managedStatus.isReady else {
await statusHandler("Install failed: \(managedStatus.message)")
return false
@@ -225,6 +305,7 @@ enum CLIInstaller {
let parsed = self.parseInstallEvents(response.stdout)
let installedVersion = parsed.last { $0.event == "done" }?.version
let summary = installedVersion.map { "Installed openclaw \($0)." } ?? "Installed openclaw."
self.rememberInstallPolicy(target)
await statusHandler(summary)
return true
}
@@ -247,8 +328,8 @@ enum CLIInstaller {
.path
}
static func installScriptCommand(version: String, prefix: String, scriptPath: String) -> [String] {
[
static func installScriptCommand(target: InstallTarget, prefix: String, scriptPath: String) -> [String] {
var command = [
"/bin/bash",
scriptPath,
"--json",
@@ -256,8 +337,43 @@ enum CLIInstaller {
"--prefix",
prefix,
"--version",
version,
target.selector,
]
if target == .channel(.dev) {
command.append(contentsOf: [
"--install-method",
"git",
"--git-dir",
self.devCheckoutLocation(prefix: prefix),
])
}
return command
}
static func automaticInstallTarget(appVersion: String?, isDebug: Bool) -> InstallTarget? {
guard let appVersion else { return .channel(.stable) }
guard CLIInstallBuild.isStable(appVersion: appVersion, isDebug: isDebug) else { return nil }
return .exact(appVersion)
}
static func suggestedChannel(appVersion: String?, isDebug: Bool) -> Channel {
if isDebug { return .dev }
if appVersion?.localizedCaseInsensitiveContains("beta") == true { return .beta }
return .dev
}
private static func rememberInstallPolicy(_ target: InstallTarget) {
let policy = switch target {
case .exact: "exact"
case let .channel(channel): channel.rawValue
}
UserDefaults.standard.set(policy, forKey: cliInstallPolicyKey)
}
private static func devCheckoutLocation(prefix: String) -> String {
URL(fileURLWithPath: prefix)
.appendingPathComponent("dev/openclaw")
.path
}
static func activateLocalGateway(
@@ -43,6 +43,7 @@ let locationPreciseKey = "openclaw.locationPreciseEnabled"
let peekabooBridgeEnabledKey = "openclaw.peekabooBridgeEnabled"
let deepLinkKeyKey = "openclaw.deepLinkKey"
let cliInstallPromptedVersionKey = "openclaw.cliInstallPromptedVersion"
let cliInstallPolicyKey = "openclaw.cliInstallPolicy"
let cliValidatedExecutableKey = "openclaw.cliValidatedExecutable"
let cliValidatedVersionKey = "openclaw.cliValidatedVersion"
let macNodeIdentityProfileKey = "openclaw.macNodeIdentityProfile"
@@ -89,12 +89,18 @@ enum GatewayEnvironment {
Semver.parse(self.expectedGatewayVersionString())
}
static func expectedGatewayVersionString() -> String? {
static func appVersionString() -> String? {
let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
let trimmed = bundleVersion?.trimmingCharacters(in: .whitespacesAndNewlines)
return (trimmed?.isEmpty == false) ? trimmed : nil
}
static func expectedGatewayVersionString() -> String? {
CLIInstallPolicy.requiredGatewayVersionString(
appVersion: self.appVersionString(),
isDebug: CLIInstallBuild.isDebug)
}
/// Exposed for tests so we can inject fake version checks without rewriting bundle metadata.
static func expectedGatewayVersion(from versionString: String?) -> Semver? {
Semver.parse(versionString)
@@ -648,7 +648,7 @@ private final class CodexAppServerThreadListSession: @unchecked Sendable {
"clientInfo": [
"name": "openclaw_macos",
"title": "OpenClaw macOS Node",
"version": GatewayEnvironment.expectedGatewayVersionString() ?? "unknown",
"version": GatewayEnvironment.appVersionString() ?? "unknown",
],
],
])
@@ -157,7 +157,11 @@ extension OnboardingView {
OnboardingController.shared.setWindowCloseEnabled(true)
OnboardingController.shared.busyReason = nil
}
let installed = await CLIInstaller.install { message in
guard let target = CLIInstallPrompter.shared.installTargetForCurrentBuild() else {
cliStatus = "CLI installation cancelled."
return
}
let installed = await CLIInstaller.install(target: target) { message in
self.cliStatus = message
}
guard installed else { return }
@@ -34,7 +34,7 @@ struct CLIInstallerTests {
@Test func `installer command runs the signed bundled script without a shell pipeline`() {
let command = CLIInstaller.installScriptCommand(
version: "2026.7.3-beta.1",
target: .exact("2026.7.3-beta.1"),
prefix: "/Users/Test User/.openclaw",
scriptPath: "/Applications/OpenClaw.app/Contents/Resources/install-cli.sh")
@@ -51,6 +51,67 @@ struct CLIInstallerTests {
#expect(!command.contains("curl"))
}
@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")
#expect(command.suffix(6) == [
"--version",
"main",
"--install-method",
"git",
"--git-dir",
"/Users/Test User/.openclaw/dev/openclaw",
])
}
@Test func `release builds install exact while unreleased builds choose a channel`() {
#expect(CLIInstaller.automaticInstallTarget(
appVersion: "2026.7.2",
isDebug: false) == .exact("2026.7.2"))
#expect(CLIInstaller.automaticInstallTarget(
appVersion: "2026.7.2-1",
isDebug: false) == .exact("2026.7.2-1"))
#expect(CLIInstaller.automaticInstallTarget(
appVersion: "2026.7.2-beta.1",
isDebug: false) == nil)
#expect(CLIInstaller.automaticInstallTarget(
appVersion: "2026.7.2",
isDebug: true) == nil)
#expect(CLIInstaller.suggestedChannel(
appVersion: "2026.7.2-beta.1",
isDebug: false) == .beta)
#expect(CLIInstaller.suggestedChannel(
appVersion: "2026.7.2",
isDebug: true) == .dev)
}
@Test func `channel policy accepts the selected channel version`() throws {
let suite = "CLIInstallerTests.channel-policy.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
#expect(CLIInstallPolicy.requiredGatewayVersionString(
appVersion: "2026.7.2",
isDebug: true,
defaults: defaults) == "2026.7.2")
defaults.set("beta", forKey: cliInstallPolicyKey)
#expect(CLIInstallPolicy.requiredGatewayVersionString(
appVersion: "2026.7.2",
isDebug: true,
defaults: defaults) == nil)
#expect(CLIInstallPolicy.requiredGatewayVersionString(
appVersion: "2026.7.2-beta.1",
isDebug: false,
defaults: defaults) == nil)
#expect(CLIInstallPolicy.requiredGatewayVersionString(
appVersion: "2026.7.2",
isDebug: false,
defaults: defaults) == "2026.7.2")
}
@Test func `managed setup requires a parseable compatible version`() {
let location = "/Users/test/.openclaw/bin/openclaw"