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() 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"): "Couldn’t 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? }