mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-18 00:23:25 -06:00
87bb2e5af0
* 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
678 lines
25 KiB
Swift
678 lines
25 KiB
Swift
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"): "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?
|
||
}
|