Files
openclaw/apps/macos/Sources/OpenClaw/CLIInstaller.swift
Peter Steinberger 87bb2e5af0 fix(mac): profile onboarding updates the operator's host-global managed CLI (#121651)
* fix(mac): scope managed CLI install and detection to the active app profile

Under an active OPENCLAW_PROFILE the onboarding ready page detected the
host-global managed CLI (~/.openclaw/bin/openclaw) and its update/install
flow rewrote the operator's real managed copy. The managed install prefix
now follows the profile state directory (~/.openclaw-<name>), preferred
paths exclude other profiles' managed trees (including stale validated
executables and inherited shell PATH entries), and external CLIs stay
detectable read-only. Default-profile behavior is unchanged.

* chore(mac): refresh native i18n inventory for shifted source lines
2026-08-10 16:50:05 -07:00

678 lines
25 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
extension Notification.Name {
static let openclawCLIInstalled = Notification.Name("openclaw.cli.installed")
}
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 storedPolicy(defaults: UserDefaults = AppDefaults.standard) -> String? {
defaults.string(forKey: cliInstallPolicyKey)
}
static func requiredGatewayVersionString(
appVersion: String?,
isDebug: Bool,
defaults: UserDefaults = AppDefaults.standard) -> String?
{
guard !CLIInstallBuild.isStable(appVersion: appVersion, isDebug: isDebug) else {
return appVersion
}
return switch self.storedPolicy(defaults: defaults) {
case "stable", "beta", "dev": nil
case "exact", nil: appVersion
default: appVersion
}
}
}
struct ManagedCLIUpdateSummary: Decodable, Equatable {
struct Version: Decodable, Equatable {
let version: String?
}
struct Step: Decodable, Equatable {
let name: String
let exitCode: Int?
let stderrTail: String?
}
let status: String
let reason: String?
let before: Version?
let after: Version?
let steps: [Step]?
}
enum ManagedCLIUpdateOutcome: Equatable {
case success(fromVersion: String?, toVersion: String)
case failure(message: String, details: String?)
}
@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
case failed
}
enum Status: Equatable {
case ready(location: String, version: String)
case missing(location: String)
case unusable(location: String)
case incompatible(location: String, found: String, required: String)
var isReady: Bool {
if case .ready = self { return true }
return false
}
var location: String {
switch self {
case let .ready(location, _),
let .missing(location),
let .unusable(location),
let .incompatible(location, _, _):
location
}
}
var message: String {
switch self {
case let .ready(_, version):
"OpenClaw Gateway \(version) is ready."
case .missing:
"OpenClaw Gateway is not installed yet."
case .unusable:
"The OpenClaw Gateway could not be verified. Setup will repair it."
case let .incompatible(_, found, required):
"Gateway \(found) does not match app \(required). Setup will update it."
}
}
}
static func installedLocation() -> String? {
self.installedLocations(
searchPaths: CommandResolver.preferredPaths(),
fileManager: .default).first
}
static func installedLocation(
searchPaths: [String],
fileManager: FileManager) -> String?
{
self.installedLocations(searchPaths: searchPaths, fileManager: fileManager).first
}
static func installedLocations(
searchPaths: [String],
fileManager: FileManager) -> [String]
{
var locations: [String] = []
for basePath in searchPaths {
let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("openclaw").path
var isDirectory: ObjCBool = false
guard fileManager.fileExists(atPath: candidate, isDirectory: &isDirectory),
!isDirectory.boolValue
else {
continue
}
guard fileManager.isExecutableFile(atPath: candidate) else { continue }
locations.append(candidate)
}
return locations
}
static func managedExecutableLocation(
homeDirectory: URL = FileManager().homeDirectoryForCurrentUser,
profile: AppProfile = .current) -> String
{
URL(fileURLWithPath: self.installPrefix(homeDirectory: homeDirectory, profile: profile))
.appendingPathComponent("bin/openclaw")
.path
}
static func status() async -> Status {
let preferredPaths = await CommandResolver.preferredPathsAsync()
let locations = self.installedLocations(
searchPaths: preferredPaths,
fileManager: .default)
guard !locations.isEmpty else {
return .missing(location: self.managedExecutableLocation())
}
var fallbackStatus: Status?
for location in locations {
let status = await self.status(
location: location,
expectedVersion: GatewayEnvironment.expectedGatewayVersionString(),
preferredPaths: preferredPaths)
if status.isReady {
self.rememberValidated(status)
return status
}
fallbackStatus = fallbackStatus ?? status
}
return fallbackStatus ?? .missing(location: self.managedExecutableLocation())
}
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 preferredPaths = await CommandResolver.preferredPathsAsync()
let status = await self.status(
location: location,
expectedVersion: expectedVersion,
preferredPaths: preferredPaths)
if status.isReady {
self.rememberValidated(status)
}
return status
}
static func status(location: String) async -> Status {
let preferredPaths = await CommandResolver.preferredPathsAsync()
return await self.status(
location: location,
expectedVersion: GatewayEnvironment.expectedGatewayVersionString(),
preferredPaths: preferredPaths)
}
private static func status(
location: String,
expectedVersion: String?,
preferredPaths: [String]) async -> Status
{
let environment = self.probeEnvironment(
location: location,
preferredPaths: preferredPaths)
let response = await ShellExecutor.runDetailed(
command: [location, "--version"],
cwd: nil,
env: environment,
timeout: 5)
guard response.success else {
return .unusable(location: location)
}
let versionStatus = self.classifyVersion(
location: location,
output: response.stdout,
expectedVersion: expectedVersion)
guard versionStatus.isReady else { return versionStatus }
guard await self.runtimeIsCompatible(environment: environment) else {
return .unusable(location: location)
}
return versionStatus
}
private static func runtimeIsCompatible(environment: [String: String]) async -> Bool {
let paths = environment["PATH"]?.split(separator: ":").map(String.init) ?? []
if case .success = await RuntimeLocator.resolve(searchPaths: paths) {
return true
}
return false
}
static func classifyVersion(
location: String,
output: String?,
expectedVersion: String?) -> Status
{
let normalized = GatewayEnvironment.normalizeGatewayVersionOutput(output)
guard let normalized, Semver.parse(normalized) != nil else {
return .unusable(location: location)
}
guard Semver.parse(expectedVersion) != nil else {
return .ready(location: location, version: normalized)
}
guard Semver.satisfiesExpectedGatewayVersion(installed: normalized, expected: expectedVersion) else {
return .incompatible(
location: location,
found: normalized,
required: expectedVersion ?? "unknown")
}
return .ready(location: location, version: normalized)
}
static func probeEnvironment(
location: String,
processEnvironment: [String: String] = ProcessInfo.processInfo.environment,
preferredPaths: [String] = CommandResolver.preferredPaths(),
managedExecutable: String? = nil,
managedRuntimeDirectory: String? = nil) -> [String: String]
{
var environment = processEnvironment
let executableDirectory = URL(fileURLWithPath: location).deletingLastPathComponent().path
let effectiveManagedExecutable = managedExecutable ?? self.managedExecutableLocation()
let effectiveManagedRuntimeDirectory = managedRuntimeDirectory ?? URL(fileURLWithPath: self.installPrefix())
.appendingPathComponent("tools/node/bin")
.path
let initialPaths = location == effectiveManagedExecutable
? [executableDirectory, effectiveManagedRuntimeDirectory]
: [executableDirectory]
var seen = Set<String>()
let paths = (initialPaths + preferredPaths).filter { seen.insert($0).inserted }
environment["PATH"] = paths.joined(separator: ":")
return environment
}
private static func rememberValidated(_ status: Status) {
guard case let .ready(location, version) = status else { return }
AppDefaults.standard.set(location, forKey: cliValidatedExecutableKey)
AppDefaults.standard.set(version, forKey: cliValidatedVersionKey)
}
@discardableResult
static func install(
target: InstallTarget,
statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async -> Bool
{
let prefix = Self.installPrefix()
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 appVersion = GatewayEnvironment.appVersionString()
let cmd = self.installScriptCommand(
target: target,
prefix: prefix,
scriptPath: installerURL.path,
compatibleWith: target.requiresExactVersion ? nil : appVersion)
let response = await ShellExecutor.runStreamingDetailed(
command: cmd,
cwd: nil,
env: nil,
timeout: self.installWatchdogTimeout(for: target))
{ line in
guard let status = self.installStatus(forEventLine: line) else { return }
await statusHandler(status)
}
if response.success {
let expectedVersion = target.requiresExactVersion ? GatewayEnvironment.appVersionString() : nil
let managedStatus = await self.managedStatus(expectedVersion: expectedVersion)
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."
self.rememberInstallPolicy(target)
await statusHandler(summary)
NotificationCenter.default.post(name: .openclawCLIInstalled, object: nil)
return true
}
if let error = self.installErrorMessage(from: response.stdout) {
await statusHandler("Install failed: \(error)")
return false
}
let detail = response.stderr.trimmingCharacters(in: .whitespacesAndNewlines)
let fallback = response.errorMessage ?? "install failed"
await statusHandler("Install failed: \(detail.isEmpty ? fallback : detail)")
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.
target == .channel(.dev) ? 7200 : 900
}
static func installPrefix(
homeDirectory: URL = FileManager().homeDirectoryForCurrentUser,
profile: AppProfile = .current) -> String
{
// Managed install identity follows only the profile; a state override must not split
// the install used by its LaunchAgent.
profile.stateDirectoryURL(homeDirectory: homeDirectory).path
}
static func installScriptCommand(
target: InstallTarget,
prefix: String,
scriptPath: String,
compatibleWith appVersion: String? = nil) -> [String]
{
var command = [
"/bin/bash",
scriptPath,
"--json",
"--no-onboard",
"--prefix",
prefix,
"--version",
target.selector,
]
if let appVersion, !target.requiresExactVersion {
command.append(contentsOf: ["--compatible-with", appVersion])
}
if target == .channel(.dev) {
command.append(contentsOf: [
"--install-method",
"git",
"--git-dir",
self.devCheckoutLocation(prefix: prefix),
])
}
return command
}
static func managedUpdateCommand(
executable: String,
targetVersion: String,
restartGateway: Bool = true,
repair: Bool = false,
profile: AppProfile = .current) -> [String]
{
let arguments = repair
? ["update", "repair", "--json", "--timeout", "900", "--yes"]
: ["update", "--tag", targetVersion, "--json", "--timeout", "900"]
var command = profile.localCLICommand(prefix: [executable], arguments: arguments)
if !restartGateway {
command.append("--no-restart")
}
return command
}
static func updateManaged(
targetVersion: String,
restartGateway: Bool = true,
repair: Bool = false,
statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async
-> ManagedCLIUpdateOutcome
{
let executable = self.managedExecutableLocation()
await statusHandler(repair
? String(localized: "Repairing the OpenClaw Gateway update…")
: String(localized: "Updating the OpenClaw Gateway to \(targetVersion)…"))
let command = self.managedUpdateCommand(
executable: executable,
targetVersion: targetVersion,
restartGateway: restartGateway,
repair: repair)
let environment = self.probeEnvironment(location: executable)
let response = await ShellExecutor.runDetailed(
command: command,
cwd: nil,
env: environment,
// The CLI timeout is per step. Keep the aggregate watchdog above
// the full package, plugin, doctor, and restart sequence.
timeout: 7200)
let summary = self.parseManagedUpdateSummary(response.stdout)
let reportedStatus = summary?.status
guard response.success, reportedStatus != "error", reportedStatus != "warning" else {
let reason = summary?.reason?.trimmingCharacters(in: .whitespacesAndNewlines)
let failedStep = summary?.steps?.last(where: { ($0.exitCode ?? 0) != 0 })
let message = if reportedStatus == "warning" {
String(localized: "Gateway update needs attention.")
} else {
String(localized: "Gateway update failed.")
}
let details = self.firstNonEmpty([
reason,
failedStep.map { "\($0.name): \($0.stderrTail ?? "exit \($0.exitCode ?? -1)")" },
response.stderr,
response.errorMessage,
response.stdout,
])
await statusHandler(message)
return .failure(message: message, details: details.map(self.limitDiagnostic))
}
let managedStatus = await self.managedStatus(expectedVersion: targetVersion)
guard case let .ready(_, installedVersion) = managedStatus else {
let message = String(localized: "Gateway update finished, but verification failed.")
await statusHandler(message)
return .failure(message: message, details: managedStatus.message)
}
self.rememberInstallPolicy(.exact(targetVersion))
NotificationCenter.default.post(name: .openclawCLIInstalled, object: nil)
await statusHandler(String(localized: "OpenClaw Gateway \(installedVersion) is installed."))
return .success(
fromVersion: summary?.before?.version,
toVersion: installedVersion)
}
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
}
AppDefaults.standard.set(policy, forKey: cliInstallPolicyKey)
}
private static func devCheckoutLocation(prefix: String) -> String {
URL(fileURLWithPath: prefix)
.appendingPathComponent("dev/openclaw")
.path
}
static func activateLocalGateway(
mode: AppState.ConnectionMode = AppStateStore.shared.connectionMode,
paused: Bool = AppStateStore.shared.isPaused,
start: @MainActor () -> Void = { GatewayProcessManager.shared.setActive(true) },
waitUntilReady: @MainActor () async -> Bool = {
await GatewayProcessManager.shared.waitForGatewayReady(
timeout: GatewayLaunchAgentManager.startupMigrationTolerance)
}) async -> LocalGatewayActivation
{
guard mode == .local, !paused else { return .deferred }
start()
return await waitUntilReady() ? .ready : .failed
}
private static func parseInstallEvents(_ output: String) -> [InstallEvent] {
let decoder = JSONDecoder()
let lines = output
.split(whereSeparator: \.isNewline)
.map { String($0) }
var events: [InstallEvent] = []
for line in lines {
guard let data = line.data(using: .utf8) else { continue }
if let event = try? decoder.decode(InstallEvent.self, from: data) {
events.append(event)
}
}
return events
}
nonisolated static func installStatus(forEventLine line: String) -> String? {
guard let data = line.data(using: .utf8),
let event = try? JSONDecoder().decode(InstallEvent.self, from: data),
event.event == "step",
let name = event.name,
let status = event.status
else {
return nil
}
return switch (name, status) {
case ("disk-space", "start"): "Checking available disk space…"
case ("node", "start"): "Installing Node.js runtime…"
case ("git-tools", "start"): "Preparing Git and pnpm…"
case ("git-clone", "start"): "Downloading OpenClaw source…"
case ("git-update", "start"): "Updating OpenClaw source…"
case ("dependencies", "start"): "Installing dependencies…"
case ("control-ui", "start"): "Building interface…"
case ("cli-build", "start"): "Building OpenClaw CLI…"
case ("openclaw", "retry"): "Retrying OpenClaw CLI install…"
case ("disk-space", "warn"): "Couldnt verify free disk space; continuing…"
case ("git-update", "warn"): "Using the existing modified OpenClaw source…"
case ("control-ui", "warn"): "Interface build did not finish; continuing…"
default: nil
}
}
static func installErrorMessage(from output: String) -> String? {
self.parseInstallEvents(output).last(where: { $0.event == "error" })?.message
}
static func parseManagedUpdateSummary(_ output: String) -> ManagedCLIUpdateSummary? {
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let decoder = JSONDecoder()
if let data = trimmed.data(using: .utf8),
let result = try? decoder.decode(ManagedCLIUpdateSummary.self, from: data)
{
return result
}
for line in trimmed.split(whereSeparator: \.isNewline).reversed() {
guard let data = String(line).data(using: .utf8),
let result = try? decoder.decode(ManagedCLIUpdateSummary.self, from: data)
else { continue }
return result
}
return nil
}
private static func firstNonEmpty(_ values: [String?]) -> String? {
values.compactMap { value in
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed?.isEmpty == false ? trimmed : nil
}.first
}
private static func limitDiagnostic(_ value: String) -> String {
let maximumCharacters = 4000
guard value.count > maximumCharacters else { return value }
return String(value.suffix(maximumCharacters))
}
}
private struct InstallEvent: Decodable {
let event: String
let name: String?
let status: String?
let version: String?
let message: String?
}