import Foundation import ClawdisIPC // Lightweight SemVer helper (major.minor.patch only) for relay compatibility checks. struct Semver: Comparable, CustomStringConvertible, Sendable { let major: Int let minor: Int let patch: Int var description: String { "\(self.major).\(self.minor).\(self.patch)" } static func < (lhs: Semver, rhs: Semver) -> Bool { if lhs.major != rhs.major { return lhs.major < rhs.major } if lhs.minor != rhs.minor { return lhs.minor < rhs.minor } return lhs.patch < rhs.patch } static func parse(_ raw: String?) -> Semver? { guard let raw, !raw.isEmpty else { return nil } let cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines) .replacingOccurrences(of: "^v", with: "", options: .regularExpression) let parts = cleaned.split(separator: ".") guard parts.count >= 3, let major = Int(parts[0]), let minor = Int(parts[1]) else { return nil } let patch = Int(parts[2]) ?? 0 return Semver(major: major, minor: minor, patch: patch) } func compatible(with required: Semver) -> Bool { // Same major and not older than required. self.major == required.major && self >= required } } enum RelayEnvironmentKind: Equatable { case checking case ok case missingNode case missingRelay case incompatible(found: String, required: String) case error(String) } struct RelayEnvironmentStatus: Equatable { let kind: RelayEnvironmentKind let nodeVersion: String? let relayVersion: String? let requiredRelay: String? let message: String static var checking: Self { .init(kind: .checking, nodeVersion: nil, relayVersion: nil, requiredRelay: nil, message: "Checking…") } } struct RelayCommandResolution { let status: RelayEnvironmentStatus let command: [String]? } enum RelayEnvironment { static func gatewayPort() -> Int { let stored = UserDefaults.standard.integer(forKey: "gatewayPort") return stored > 0 ? stored : 18789 } static func expectedRelayVersion() -> Semver? { let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String return Semver.parse(bundleVersion) } static func check() -> RelayEnvironmentStatus { let expected = self.expectedRelayVersion() switch RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths()) { case let .failure(err): return RelayEnvironmentStatus( kind: .missingNode, nodeVersion: nil, relayVersion: nil, requiredRelay: expected?.description, message: RuntimeLocator.describeFailure(err)) case let .success(runtime): guard let relayBin = CommandResolver.clawdisExecutable() else { return RelayEnvironmentStatus( kind: .missingRelay, nodeVersion: runtime.version.description, relayVersion: nil, requiredRelay: expected?.description, message: "clawdis CLI not found in PATH; install the global package.") } let installedRelay = self.readRelayVersion(binary: relayBin) if let expected, let installed = installedRelay, !installed.compatible(with: expected) { return RelayEnvironmentStatus( kind: .incompatible(found: installed.description, required: expected.description), nodeVersion: runtime.version.description, relayVersion: installed.description, requiredRelay: expected.description, message: "Relay version \(installed.description) is incompatible with app \(expected.description); install/update the global package.") } return RelayEnvironmentStatus( kind: .ok, nodeVersion: runtime.version.description, relayVersion: installedRelay?.description, requiredRelay: expected?.description, message: "Node \(runtime.version.description); relay \(installedRelay?.description ?? "unknown")") } } static func resolveGatewayCommand() -> RelayCommandResolution { let status = self.check() guard case .ok = status.kind, let relayBin = CommandResolver.clawdisExecutable() else { return RelayCommandResolution(status: status, command: nil) } let port = self.gatewayPort() let cmd = [relayBin, "gateway", "--port", "\(port)"] return RelayCommandResolution(status: status, command: cmd) } static func installGlobal(version: Semver?, statusHandler: @escaping @Sendable (String) -> Void) async { let preferred = CommandResolver.preferredPaths().joined(separator: ":") let target = version?.description ?? "latest" let pnpm = CommandResolver.findExecutable(named: "pnpm") ?? "pnpm" let cmd = [pnpm, "add", "-g", "clawdis@\(target)"] statusHandler("Installing clawdis@\(target) via pnpm…") let response = await ShellRunner.run(command: cmd, cwd: nil, env: ["PATH": preferred], timeout: 300) if response.ok { statusHandler("Installed clawdis@\(target)") } else { let detail = response.message ?? "install failed" statusHandler("Install failed: \(detail)") } } // MARK: - Internals private static func readRelayVersion(binary: String) -> Semver? { let process = Process() process.executableURL = URL(fileURLWithPath: binary) process.arguments = ["--version"] process.environment = ["PATH": CommandResolver.preferredPaths().joined(separator: ":")] let pipe = Pipe() process.standardOutput = pipe process.standardError = pipe do { try process.run() process.waitUntilExit() let data = pipe.fileHandleForReading.readDataToEndOfFile() let raw = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) return Semver.parse(raw) } catch { return nil } } }