fix(mac): add tailnet discovery fallback and debug CLI
This commit is contained in:
@@ -10,7 +10,9 @@ let package = Package(
|
||||
],
|
||||
products: [
|
||||
.library(name: "ClawdbotIPC", targets: ["ClawdbotIPC"]),
|
||||
.library(name: "ClawdbotDiscovery", targets: ["ClawdbotDiscovery"]),
|
||||
.executable(name: "Clawdbot", targets: ["Clawdbot"]),
|
||||
.executable(name: "clawdbot-mac-discovery", targets: ["ClawdbotDiscoveryCLI"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
|
||||
@@ -36,10 +38,20 @@ let package = Package(
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.target(
|
||||
name: "ClawdbotDiscovery",
|
||||
dependencies: [
|
||||
.product(name: "ClawdbotKit", package: "ClawdbotKit"),
|
||||
],
|
||||
path: "Sources/ClawdbotDiscovery",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.executableTarget(
|
||||
name: "Clawdbot",
|
||||
dependencies: [
|
||||
"ClawdbotIPC",
|
||||
"ClawdbotDiscovery",
|
||||
"ClawdbotProtocol",
|
||||
.product(name: "ClawdbotKit", package: "ClawdbotKit"),
|
||||
.product(name: "ClawdbotChatUI", package: "ClawdbotKit"),
|
||||
@@ -61,11 +73,21 @@ let package = Package(
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.executableTarget(
|
||||
name: "ClawdbotDiscoveryCLI",
|
||||
dependencies: [
|
||||
"ClawdbotDiscovery",
|
||||
],
|
||||
path: "Sources/ClawdbotDiscoveryCLI",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.testTarget(
|
||||
name: "ClawdbotIPCTests",
|
||||
dependencies: [
|
||||
"ClawdbotIPC",
|
||||
"Clawdbot",
|
||||
"ClawdbotDiscovery",
|
||||
"ClawdbotProtocol",
|
||||
.product(name: "SwabbleKit", package: "swabble"),
|
||||
],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClawdbotDiscovery
|
||||
import SwiftUI
|
||||
|
||||
struct GatewayDiscoveryInlineList: View {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppKit
|
||||
import ClawdbotDiscovery
|
||||
import ClawdbotIPC
|
||||
import ClawdbotKit
|
||||
import CoreLocation
|
||||
@@ -12,7 +13,8 @@ struct GeneralSettings: View {
|
||||
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
|
||||
private let healthStore = HealthStore.shared
|
||||
private let gatewayManager = GatewayProcessManager.shared
|
||||
@State private var gatewayDiscovery = GatewayDiscoveryModel()
|
||||
@State private var gatewayDiscovery = GatewayDiscoveryModel(
|
||||
localDisplayName: InstanceIdentity.displayName)
|
||||
@State private var isInstallingCLI = false
|
||||
@State private var cliStatus: String?
|
||||
@State private var cliInstalled = false
|
||||
@@ -187,7 +189,8 @@ struct GeneralSettings: View {
|
||||
}
|
||||
SettingsToggleRow(
|
||||
title: "Attach only",
|
||||
subtitle: "Use this when the gateway runs externally; the mac app will only attach to an already-running gateway and won't start one locally.",
|
||||
subtitle: "Use this when the gateway runs externally; the mac app will only attach " +
|
||||
"to an already-running gateway and won't start one locally.",
|
||||
binding: self.$state.attachExistingGatewayOnly)
|
||||
TailscaleIntegrationSection(
|
||||
connectionMode: self.state.connectionMode,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClawdbotDiscovery
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import Network
|
||||
@@ -315,7 +316,9 @@ final class MacNodeModeCoordinator {
|
||||
let port = NWEndpoint.Port(rawValue: localPort)
|
||||
{
|
||||
self.logger.info(
|
||||
"mac node bridge tunnel ready localPort=\(localPort, privacy: .public) remotePort=\(remotePort, privacy: .public)")
|
||||
"mac node bridge tunnel ready " +
|
||||
"localPort=\(localPort, privacy: .public) " +
|
||||
"remotePort=\(remotePort, privacy: .public)")
|
||||
return .hostPort(host: "127.0.0.1", port: port)
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppKit
|
||||
import ClawdbotDiscovery
|
||||
import ClawdbotIPC
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
@@ -533,7 +534,7 @@ final class NodePairingApprovalPrompter {
|
||||
return SSHTarget(host: host, port: port)
|
||||
}
|
||||
|
||||
let model = GatewayDiscoveryModel()
|
||||
let model = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
|
||||
model.start()
|
||||
defer { model.stop() }
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AppKit
|
||||
import ClawdbotChatUI
|
||||
import ClawdbotDiscovery
|
||||
import ClawdbotIPC
|
||||
import Combine
|
||||
import Observation
|
||||
@@ -156,7 +157,8 @@ struct OnboardingView: View {
|
||||
init(
|
||||
state: AppState = AppStateStore.shared,
|
||||
permissionMonitor: PermissionMonitor = .shared,
|
||||
discoveryModel: GatewayDiscoveryModel = GatewayDiscoveryModel())
|
||||
discoveryModel: GatewayDiscoveryModel = GatewayDiscoveryModel(
|
||||
localDisplayName: InstanceIdentity.displayName))
|
||||
{
|
||||
self.state = state
|
||||
self.permissionMonitor = permissionMonitor
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppKit
|
||||
import ClawdbotDiscovery
|
||||
import ClawdbotIPC
|
||||
import SwiftUI
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AppKit
|
||||
import ClawdbotChatUI
|
||||
import ClawdbotDiscovery
|
||||
import ClawdbotIPC
|
||||
import SwiftUI
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClawdbotDiscovery
|
||||
import SwiftUI
|
||||
|
||||
#if DEBUG
|
||||
@@ -5,7 +6,7 @@ import SwiftUI
|
||||
extension OnboardingView {
|
||||
static func exerciseForTesting() {
|
||||
let state = AppState(preview: true)
|
||||
let discovery = GatewayDiscoveryModel()
|
||||
let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
|
||||
discovery.statusText = "Searching..."
|
||||
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "Test Bridge",
|
||||
|
||||
@@ -2,8 +2,8 @@ import ClawdbotKit
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
enum BridgeEndpointID {
|
||||
static func stableID(_ endpoint: NWEndpoint) -> String {
|
||||
public enum BridgeEndpointID {
|
||||
public static func stableID(_ endpoint: NWEndpoint) -> String {
|
||||
switch endpoint {
|
||||
case let .service(name, type, domain, _):
|
||||
// Keep stable across encoded/decoded differences (e.g. \032 for spaces).
|
||||
@@ -14,7 +14,7 @@ enum BridgeEndpointID {
|
||||
}
|
||||
}
|
||||
|
||||
static func prettyDescription(_ endpoint: NWEndpoint) -> String {
|
||||
public static func prettyDescription(_ endpoint: NWEndpoint) -> String {
|
||||
BonjourEscapes.decode(String(describing: endpoint))
|
||||
}
|
||||
|
||||
@@ -6,43 +6,79 @@ import OSLog
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class GatewayDiscoveryModel {
|
||||
struct LocalIdentity: Equatable {
|
||||
var hostTokens: Set<String>
|
||||
var displayTokens: Set<String>
|
||||
public final class GatewayDiscoveryModel {
|
||||
public struct LocalIdentity: Equatable, Sendable {
|
||||
public var hostTokens: Set<String>
|
||||
public var displayTokens: Set<String>
|
||||
|
||||
public init(hostTokens: Set<String>, displayTokens: Set<String>) {
|
||||
self.hostTokens = hostTokens
|
||||
self.displayTokens = displayTokens
|
||||
}
|
||||
}
|
||||
|
||||
struct DiscoveredGateway: Identifiable, Equatable {
|
||||
var id: String { self.stableID }
|
||||
var displayName: String
|
||||
var lanHost: String?
|
||||
var tailnetDns: String?
|
||||
var sshPort: Int
|
||||
var gatewayPort: Int?
|
||||
var cliPath: String?
|
||||
var stableID: String
|
||||
var debugID: String
|
||||
var isLocal: Bool
|
||||
public struct DiscoveredGateway: Identifiable, Equatable, Sendable {
|
||||
public var id: String { self.stableID }
|
||||
public var displayName: String
|
||||
public var lanHost: String?
|
||||
public var tailnetDns: String?
|
||||
public var sshPort: Int
|
||||
public var gatewayPort: Int?
|
||||
public var cliPath: String?
|
||||
public var stableID: String
|
||||
public var debugID: String
|
||||
public var isLocal: Bool
|
||||
|
||||
public init(
|
||||
displayName: String,
|
||||
lanHost: String? = nil,
|
||||
tailnetDns: String? = nil,
|
||||
sshPort: Int,
|
||||
gatewayPort: Int? = nil,
|
||||
cliPath: String? = nil,
|
||||
stableID: String,
|
||||
debugID: String,
|
||||
isLocal: Bool)
|
||||
{
|
||||
self.displayName = displayName
|
||||
self.lanHost = lanHost
|
||||
self.tailnetDns = tailnetDns
|
||||
self.sshPort = sshPort
|
||||
self.gatewayPort = gatewayPort
|
||||
self.cliPath = cliPath
|
||||
self.stableID = stableID
|
||||
self.debugID = debugID
|
||||
self.isLocal = isLocal
|
||||
}
|
||||
}
|
||||
|
||||
var gateways: [DiscoveredGateway] = []
|
||||
var statusText: String = "Idle"
|
||||
public var gateways: [DiscoveredGateway] = []
|
||||
public var statusText: String = "Idle"
|
||||
|
||||
private var browsers: [String: NWBrowser] = [:]
|
||||
private var resultsByDomain: [String: Set<NWBrowser.Result>] = [:]
|
||||
private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:]
|
||||
private var statesByDomain: [String: NWBrowser.State] = [:]
|
||||
private var localIdentity: LocalIdentity
|
||||
private let localDisplayName: String?
|
||||
private let filterLocalGateways: Bool
|
||||
private var resolvedTXTByID: [String: [String: String]] = [:]
|
||||
private var pendingTXTResolvers: [String: GatewayTXTResolver] = [:]
|
||||
private var wideAreaFallbackTask: Task<Void, Never>?
|
||||
private var wideAreaFallbackGateways: [DiscoveredGateway] = []
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-discovery")
|
||||
|
||||
init() {
|
||||
self.localIdentity = Self.buildLocalIdentityFast()
|
||||
public init(
|
||||
localDisplayName: String? = nil,
|
||||
filterLocalGateways: Bool = true)
|
||||
{
|
||||
self.localDisplayName = localDisplayName
|
||||
self.filterLocalGateways = filterLocalGateways
|
||||
self.localIdentity = Self.buildLocalIdentityFast(displayName: localDisplayName)
|
||||
self.refreshLocalIdentity()
|
||||
}
|
||||
|
||||
func start() {
|
||||
public func start() {
|
||||
if !self.browsers.isEmpty { return }
|
||||
|
||||
for domain in ClawdbotBonjour.bridgeServiceDomains {
|
||||
@@ -72,9 +108,11 @@ final class GatewayDiscoveryModel {
|
||||
self.browsers[domain] = browser
|
||||
browser.start(queue: DispatchQueue(label: "com.clawdbot.macos.gateway-discovery.\(domain)"))
|
||||
}
|
||||
|
||||
self.scheduleWideAreaFallback()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
public func stop() {
|
||||
for browser in self.browsers.values {
|
||||
browser.cancel()
|
||||
}
|
||||
@@ -85,15 +123,34 @@ final class GatewayDiscoveryModel {
|
||||
self.resolvedTXTByID = [:]
|
||||
self.pendingTXTResolvers.values.forEach { $0.cancel() }
|
||||
self.pendingTXTResolvers = [:]
|
||||
self.wideAreaFallbackTask?.cancel()
|
||||
self.wideAreaFallbackTask = nil
|
||||
self.wideAreaFallbackGateways = []
|
||||
self.gateways = []
|
||||
self.statusText = "Stopped"
|
||||
}
|
||||
|
||||
private func recomputeGateways() {
|
||||
self.gateways = self.gatewaysByDomain.values
|
||||
var next = self.gatewaysByDomain.values
|
||||
.flatMap(\.self)
|
||||
.filter { !$0.isLocal }
|
||||
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
|
||||
if self.gatewaysByDomain[ClawdbotBonjour.wideAreaBridgeServiceDomain]?.isEmpty ?? true,
|
||||
!self.wideAreaFallbackGateways.isEmpty
|
||||
{
|
||||
next.append(contentsOf: self.wideAreaFallbackGateways)
|
||||
}
|
||||
var seen = Set<String>()
|
||||
let deduped = next.filter { gateway in
|
||||
if seen.contains(gateway.stableID) { return false }
|
||||
seen.insert(gateway.stableID)
|
||||
return true
|
||||
}
|
||||
let sorted = deduped.sorted {
|
||||
$0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending
|
||||
}
|
||||
self.gateways = self.filterLocalGateways
|
||||
? sorted.filter { !$0.isLocal }
|
||||
: sorted
|
||||
}
|
||||
|
||||
private func updateGateways(for domain: String) {
|
||||
@@ -146,6 +203,52 @@ final class GatewayDiscoveryModel {
|
||||
isLocal: isLocal)
|
||||
}
|
||||
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
|
||||
|
||||
if domain == ClawdbotBonjour.wideAreaBridgeServiceDomain,
|
||||
!(self.gatewaysByDomain[domain]?.isEmpty ?? true)
|
||||
{
|
||||
self.wideAreaFallbackGateways = []
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleWideAreaFallback() {
|
||||
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
|
||||
guard self.wideAreaFallbackTask == nil else { return }
|
||||
self.wideAreaFallbackTask = Task.detached(priority: .utility) { [weak self] in
|
||||
guard let self else { return }
|
||||
if Task.isCancelled { return }
|
||||
let hasResults = await MainActor.run {
|
||||
!(self.gatewaysByDomain[domain]?.isEmpty ?? true)
|
||||
}
|
||||
if hasResults { return }
|
||||
|
||||
let beacons = WideAreaGatewayDiscovery.discover(timeoutSeconds: 3.0)
|
||||
if beacons.isEmpty { return }
|
||||
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.wideAreaFallbackGateways = beacons.map { beacon in
|
||||
let stableID = "wide-area|\(domain)|\(beacon.instanceName)"
|
||||
let isLocal = Self.isLocalGateway(
|
||||
lanHost: beacon.lanHost,
|
||||
tailnetDns: beacon.tailnetDns,
|
||||
displayName: beacon.displayName,
|
||||
serviceName: beacon.instanceName,
|
||||
local: self.localIdentity)
|
||||
return DiscoveredGateway(
|
||||
displayName: beacon.displayName,
|
||||
lanHost: beacon.lanHost,
|
||||
tailnetDns: beacon.tailnetDns,
|
||||
sshPort: beacon.sshPort ?? 22,
|
||||
gatewayPort: beacon.gatewayPort,
|
||||
cliPath: beacon.cliPath,
|
||||
stableID: stableID,
|
||||
debugID: "\(beacon.instanceName)@\(beacon.host):\(beacon.port)",
|
||||
isLocal: isLocal)
|
||||
}
|
||||
self.recomputeGateways()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateGatewaysForAllDomains() {
|
||||
@@ -208,15 +311,15 @@ final class GatewayDiscoveryModel {
|
||||
return merged
|
||||
}
|
||||
|
||||
struct GatewayTXT: Equatable {
|
||||
var lanHost: String?
|
||||
var tailnetDns: String?
|
||||
var sshPort: Int
|
||||
var gatewayPort: Int?
|
||||
var cliPath: String?
|
||||
public struct GatewayTXT: Equatable {
|
||||
public var lanHost: String?
|
||||
public var tailnetDns: String?
|
||||
public var sshPort: Int
|
||||
public var gatewayPort: Int?
|
||||
public var cliPath: String?
|
||||
}
|
||||
|
||||
static func parseGatewayTXT(_ txt: [String: String]) -> GatewayTXT {
|
||||
public static func parseGatewayTXT(_ txt: [String: String]) -> GatewayTXT {
|
||||
var lanHost: String?
|
||||
var tailnetDns: String?
|
||||
var sshPort = 22
|
||||
@@ -256,7 +359,7 @@ final class GatewayDiscoveryModel {
|
||||
cliPath: cliPath)
|
||||
}
|
||||
|
||||
static func buildSSHTarget(user: String, host: String, port: Int) -> String {
|
||||
public static func buildSSHTarget(user: String, host: String, port: Int) -> String {
|
||||
var target = "\(user)@\(host)"
|
||||
if port != 22 {
|
||||
target += ":\(port)"
|
||||
@@ -324,7 +427,7 @@ final class GatewayDiscoveryModel {
|
||||
return titled.isEmpty ? normalized : titled
|
||||
}
|
||||
|
||||
nonisolated static func isLocalGateway(
|
||||
public nonisolated static func isLocalGateway(
|
||||
lanHost: String?,
|
||||
tailnetDns: String?,
|
||||
displayName: String?,
|
||||
@@ -356,8 +459,9 @@ final class GatewayDiscoveryModel {
|
||||
|
||||
private func refreshLocalIdentity() {
|
||||
let fastIdentity = self.localIdentity
|
||||
let displayName = self.localDisplayName
|
||||
Task.detached(priority: .utility) {
|
||||
let slowIdentity = Self.buildLocalIdentitySlow()
|
||||
let slowIdentity = Self.buildLocalIdentitySlow(displayName: displayName)
|
||||
let merged = Self.mergeLocalIdentity(fast: fastIdentity, slow: slowIdentity)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -377,7 +481,7 @@ final class GatewayDiscoveryModel {
|
||||
displayTokens: fast.displayTokens.union(slow.displayTokens))
|
||||
}
|
||||
|
||||
private nonisolated static func buildLocalIdentityFast() -> LocalIdentity {
|
||||
private nonisolated static func buildLocalIdentityFast(displayName: String?) -> LocalIdentity {
|
||||
var hostTokens: Set<String> = []
|
||||
var displayTokens: Set<String> = []
|
||||
|
||||
@@ -386,14 +490,14 @@ final class GatewayDiscoveryModel {
|
||||
hostTokens.insert(token)
|
||||
}
|
||||
|
||||
if let token = normalizeDisplayToken(InstanceIdentity.displayName) {
|
||||
if let token = normalizeDisplayToken(displayName) {
|
||||
displayTokens.insert(token)
|
||||
}
|
||||
|
||||
return LocalIdentity(hostTokens: hostTokens, displayTokens: displayTokens)
|
||||
}
|
||||
|
||||
private nonisolated static func buildLocalIdentitySlow() -> LocalIdentity {
|
||||
private nonisolated static func buildLocalIdentitySlow(displayName: String?) -> LocalIdentity {
|
||||
var hostTokens: Set<String> = []
|
||||
var displayTokens: Set<String> = []
|
||||
|
||||
@@ -403,6 +507,10 @@ final class GatewayDiscoveryModel {
|
||||
hostTokens.insert(token)
|
||||
}
|
||||
|
||||
if let token = normalizeDisplayToken(displayName) {
|
||||
displayTokens.insert(token)
|
||||
}
|
||||
|
||||
if let token = normalizeDisplayToken(Host.current().localizedName) {
|
||||
displayTokens.insert(token)
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
|
||||
struct WideAreaGatewayBeacon: Sendable, Equatable {
|
||||
var instanceName: String
|
||||
var displayName: String
|
||||
var host: String
|
||||
var port: Int
|
||||
var lanHost: String?
|
||||
var tailnetDns: String?
|
||||
var gatewayPort: Int?
|
||||
var bridgePort: Int?
|
||||
var sshPort: Int?
|
||||
var cliPath: String?
|
||||
}
|
||||
|
||||
enum WideAreaGatewayDiscovery {
|
||||
private static let maxCandidates = 40
|
||||
private static let digPath = "/usr/bin/dig"
|
||||
private static let defaultTimeoutSeconds: TimeInterval = 0.2
|
||||
|
||||
struct DiscoveryContext: Sendable {
|
||||
var tailscaleStatus: @Sendable () -> String?
|
||||
var dig: @Sendable (_ args: [String], _ timeout: TimeInterval) -> String?
|
||||
|
||||
static let live = DiscoveryContext(
|
||||
tailscaleStatus: { readTailscaleStatus() },
|
||||
dig: { args, timeout in
|
||||
runDig(args: args, timeout: timeout)
|
||||
})
|
||||
}
|
||||
|
||||
static func discover(
|
||||
timeoutSeconds: TimeInterval = 2.0,
|
||||
context: DiscoveryContext = .live) -> [WideAreaGatewayBeacon]
|
||||
{
|
||||
let startedAt = Date()
|
||||
let remaining = {
|
||||
timeoutSeconds - Date().timeIntervalSince(startedAt)
|
||||
}
|
||||
|
||||
guard let ips = collectTailnetIPv4s(
|
||||
statusJson: context.tailscaleStatus()).nonEmpty else { return [] }
|
||||
var candidates = Array(ips.prefix(self.maxCandidates))
|
||||
guard let nameserver = findNameserver(
|
||||
candidates: &candidates,
|
||||
remaining: remaining,
|
||||
dig: context.dig)
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
||||
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
|
||||
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||
let probeName = "_clawdbot-bridge._tcp.\(domainTrimmed)"
|
||||
guard let ptrLines = context.dig(
|
||||
["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"],
|
||||
min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline),
|
||||
!ptrLines.isEmpty
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
||||
var beacons: [WideAreaGatewayBeacon] = []
|
||||
for raw in ptrLines {
|
||||
let ptr = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if ptr.isEmpty { continue }
|
||||
let ptrName = ptr.hasSuffix(".") ? String(ptr.dropLast()) : ptr
|
||||
let suffix = "._clawdbot-bridge._tcp.\(domainTrimmed)"
|
||||
let rawInstanceName = ptrName.hasSuffix(suffix)
|
||||
? String(ptrName.dropLast(suffix.count))
|
||||
: ptrName
|
||||
let instanceName = self.decodeDnsSdEscapes(rawInstanceName)
|
||||
|
||||
guard let srv = context.dig(
|
||||
["+short", "+time=1", "+tries=1", "@\(nameserver)", ptrName, "SRV"],
|
||||
min(defaultTimeoutSeconds, remaining()))
|
||||
else { continue }
|
||||
guard let (host, port) = parseSrv(srv) else { continue }
|
||||
|
||||
let txtRaw = context.dig(
|
||||
["+short", "+time=1", "+tries=1", "@\(nameserver)", ptrName, "TXT"],
|
||||
min(self.defaultTimeoutSeconds, remaining()))
|
||||
let txtTokens = txtRaw.map(self.parseTxtTokens) ?? []
|
||||
let txt = self.mapTxt(tokens: txtTokens)
|
||||
|
||||
let displayName = txt["displayName"] ?? instanceName
|
||||
let beacon = WideAreaGatewayBeacon(
|
||||
instanceName: instanceName,
|
||||
displayName: displayName,
|
||||
host: host,
|
||||
port: port,
|
||||
lanHost: txt["lanHost"],
|
||||
tailnetDns: txt["tailnetDns"],
|
||||
gatewayPort: parseInt(txt["gatewayPort"]),
|
||||
bridgePort: parseInt(txt["bridgePort"]),
|
||||
sshPort: parseInt(txt["sshPort"]),
|
||||
cliPath: txt["cliPath"])
|
||||
beacons.append(beacon)
|
||||
}
|
||||
|
||||
return beacons
|
||||
}
|
||||
|
||||
private static func collectTailnetIPv4s(statusJson: String?) -> [String] {
|
||||
guard let statusJson else { return [] }
|
||||
let decoder = JSONDecoder()
|
||||
guard let data = statusJson.data(using: .utf8),
|
||||
let status = try? decoder.decode(TailscaleStatus.self, from: data)
|
||||
else { return [] }
|
||||
|
||||
var ips: [String] = []
|
||||
ips.append(contentsOf: status.selfNode?.resolvedIPs ?? [])
|
||||
if let peers = status.peer {
|
||||
for peer in peers.values {
|
||||
ips.append(contentsOf: peer.resolvedIPs)
|
||||
}
|
||||
}
|
||||
|
||||
var seen = Set<String>()
|
||||
let ordered = ips.filter { value in
|
||||
guard self.isTailnetIPv4(value) else { return false }
|
||||
if seen.contains(value) { return false }
|
||||
seen.insert(value)
|
||||
return true
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
private static func readTailscaleStatus() -> String? {
|
||||
let candidates = [
|
||||
"/usr/local/bin/tailscale",
|
||||
"/opt/homebrew/bin/tailscale",
|
||||
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
|
||||
"tailscale",
|
||||
]
|
||||
|
||||
var output: String?
|
||||
for candidate in candidates {
|
||||
if let result = run(
|
||||
path: candidate,
|
||||
args: ["status", "--json"],
|
||||
timeout: 0.7)
|
||||
{
|
||||
output = result
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
private static func findNameserver(
|
||||
candidates: inout [String],
|
||||
remaining: () -> TimeInterval,
|
||||
dig: @Sendable (_ args: [String], _ timeout: TimeInterval) -> String?) -> String?
|
||||
{
|
||||
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
|
||||
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||
let probeName = "_clawdbot-bridge._tcp.\(domainTrimmed)"
|
||||
|
||||
while !candidates.isEmpty {
|
||||
if remaining() <= 0 { break }
|
||||
let ip = candidates.removeFirst()
|
||||
if let stdout = dig(
|
||||
["+short", "+time=1", "+tries=1", "@\(ip)", probeName, "PTR"],
|
||||
min(defaultTimeoutSeconds, remaining())),
|
||||
stdout.split(whereSeparator: \.isNewline).isEmpty == false
|
||||
{
|
||||
return ip
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func runDig(args: [String], timeout: TimeInterval) -> String? {
|
||||
self.run(path: self.digPath, args: args, timeout: timeout)
|
||||
}
|
||||
|
||||
private static func run(path: String, args: [String], timeout: TimeInterval) -> String? {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: path)
|
||||
process.arguments = args
|
||||
let outPipe = Pipe()
|
||||
let errPipe = Pipe()
|
||||
process.standardOutput = outPipe
|
||||
process.standardError = errPipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while process.isRunning, Date() < deadline {
|
||||
Thread.sleep(forTimeInterval: 0.02)
|
||||
}
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
}
|
||||
process.waitUntilExit()
|
||||
|
||||
let data = outPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return output?.isEmpty == false ? output : nil
|
||||
}
|
||||
|
||||
private static func parseSrv(_ stdout: String) -> (String, Int)? {
|
||||
let line = stdout
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.first(where: { !$0.isEmpty })
|
||||
guard let line else { return nil }
|
||||
let parts = line.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
|
||||
guard parts.count >= 4 else { return nil }
|
||||
guard let port = Int(parts[2]), port > 0 else { return nil }
|
||||
let host = parts[3].hasSuffix(".") ? String(parts[3].dropLast()) : parts[3]
|
||||
return (host, port)
|
||||
}
|
||||
|
||||
private static func parseTxtTokens(_ stdout: String) -> [String] {
|
||||
let lines = stdout.split(whereSeparator: \.isNewline)
|
||||
var tokens: [String] = []
|
||||
for raw in lines {
|
||||
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if line.isEmpty { continue }
|
||||
let matches = line.matches(of: /"([^"]*)"/)
|
||||
for match in matches {
|
||||
tokens.append(self.unescapeTxt(String(match.1)))
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
private static func unescapeTxt(_ value: String) -> String {
|
||||
value
|
||||
.replacingOccurrences(of: "\\\\", with: "\\")
|
||||
.replacingOccurrences(of: "\\\"", with: "\"")
|
||||
.replacingOccurrences(of: "\\n", with: "\n")
|
||||
}
|
||||
|
||||
private static func mapTxt(tokens: [String]) -> [String: String] {
|
||||
var out: [String: String] = [:]
|
||||
for token in tokens {
|
||||
guard let idx = token.firstIndex(of: "=") else { continue }
|
||||
let key = String(token[..<idx]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let rawValue = String(token[token.index(after: idx)...])
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let value = self.decodeDnsSdEscapes(rawValue)
|
||||
if !key.isEmpty { out[key] = value }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private static func parseInt(_ value: String?) -> Int? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return Int(trimmed)
|
||||
}
|
||||
|
||||
private static func isTailnetIPv4(_ value: String) -> Bool {
|
||||
let parts = value.split(separator: ".")
|
||||
if parts.count != 4 { return false }
|
||||
let octets = parts.compactMap { Int($0) }
|
||||
if octets.count != 4 { return false }
|
||||
let a = octets[0]
|
||||
let b = octets[1]
|
||||
return a == 100 && b >= 64 && b <= 127
|
||||
}
|
||||
|
||||
private static func decodeDnsSdEscapes(_ value: String) -> String {
|
||||
var bytes: [UInt8] = []
|
||||
var pending = ""
|
||||
|
||||
func flushPending() {
|
||||
guard !pending.isEmpty else { return }
|
||||
bytes.append(contentsOf: pending.utf8)
|
||||
pending = ""
|
||||
}
|
||||
|
||||
let chars = Array(value)
|
||||
var i = 0
|
||||
while i < chars.count {
|
||||
let ch = chars[i]
|
||||
if ch == "\\", i + 3 < chars.count {
|
||||
let digits = String(chars[(i + 1)...(i + 3)])
|
||||
if digits.allSatisfy(\.isNumber),
|
||||
let byte = UInt8(digits)
|
||||
{
|
||||
flushPending()
|
||||
bytes.append(byte)
|
||||
i += 4
|
||||
continue
|
||||
}
|
||||
}
|
||||
pending.append(ch)
|
||||
i += 1
|
||||
}
|
||||
flushPending()
|
||||
|
||||
if bytes.isEmpty { return value }
|
||||
if let decoded = String(bytes: bytes, encoding: .utf8) {
|
||||
return decoded
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
private struct TailscaleStatus: Decodable {
|
||||
struct Node: Decodable {
|
||||
let tailscaleIPs: [String]?
|
||||
|
||||
var resolvedIPs: [String] {
|
||||
self.tailscaleIPs ?? []
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case tailscaleIPs = "TailscaleIPs"
|
||||
}
|
||||
}
|
||||
|
||||
let selfNode: Node?
|
||||
let peer: [String: Node]?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case selfNode = "Self"
|
||||
case peer = "Peer"
|
||||
}
|
||||
}
|
||||
|
||||
extension Collection {
|
||||
fileprivate var nonEmpty: Self? { isEmpty ? nil : self }
|
||||
}
|
||||
150
apps/macos/Sources/ClawdbotDiscoveryCLI/main.swift
Normal file
150
apps/macos/Sources/ClawdbotDiscoveryCLI/main.swift
Normal file
@@ -0,0 +1,150 @@
|
||||
import ClawdbotDiscovery
|
||||
import Foundation
|
||||
|
||||
struct DiscoveryOptions {
|
||||
var timeoutMs: Int = 2000
|
||||
var json: Bool = false
|
||||
var includeLocal: Bool = false
|
||||
var help: Bool = false
|
||||
|
||||
static func parse(_ args: [String]) -> DiscoveryOptions {
|
||||
var opts = DiscoveryOptions()
|
||||
var i = 0
|
||||
while i < args.count {
|
||||
let arg = args[i]
|
||||
switch arg {
|
||||
case "-h", "--help":
|
||||
opts.help = true
|
||||
case "--json":
|
||||
opts.json = true
|
||||
case "--include-local":
|
||||
opts.includeLocal = true
|
||||
case "--timeout":
|
||||
let next = (i + 1 < args.count) ? args[i + 1] : nil
|
||||
if let next, let parsed = Int(next.trimmingCharacters(in: .whitespacesAndNewlines)) {
|
||||
opts.timeoutMs = max(100, parsed)
|
||||
i += 1
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return opts
|
||||
}
|
||||
}
|
||||
|
||||
struct DiscoveryOutput: Encodable {
|
||||
struct Gateway: Encodable {
|
||||
var displayName: String
|
||||
var lanHost: String?
|
||||
var tailnetDns: String?
|
||||
var sshPort: Int
|
||||
var gatewayPort: Int?
|
||||
var cliPath: String?
|
||||
var stableID: String
|
||||
var debugID: String
|
||||
var isLocal: Bool
|
||||
}
|
||||
|
||||
var status: String
|
||||
var timeoutMs: Int
|
||||
var includeLocal: Bool
|
||||
var count: Int
|
||||
var gateways: [Gateway]
|
||||
}
|
||||
|
||||
@main
|
||||
struct ClawdbotDiscoveryCLI {
|
||||
static func main() async {
|
||||
let opts = DiscoveryOptions.parse(Array(CommandLine.arguments.dropFirst()))
|
||||
if opts.help {
|
||||
print("""
|
||||
clawdbot-mac-discovery
|
||||
|
||||
Usage:
|
||||
clawdbot-mac-discovery [--timeout <ms>] [--json] [--include-local]
|
||||
|
||||
Options:
|
||||
--timeout <ms> Discovery window in milliseconds (default: 2000)
|
||||
--json Emit JSON
|
||||
--include-local Include gateways considered local
|
||||
-h, --help Show help
|
||||
""")
|
||||
return
|
||||
}
|
||||
|
||||
let displayName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
|
||||
let model = GatewayDiscoveryModel(
|
||||
localDisplayName: displayName,
|
||||
filterLocalGateways: !opts.includeLocal)
|
||||
|
||||
await MainActor.run {
|
||||
model.start()
|
||||
}
|
||||
|
||||
let nanos = UInt64(max(100, opts.timeoutMs)) * 1_000_000
|
||||
try? await Task.sleep(nanoseconds: nanos)
|
||||
|
||||
let gateways = await MainActor.run { model.gateways }
|
||||
let status = await MainActor.run { model.statusText }
|
||||
|
||||
await MainActor.run {
|
||||
model.stop()
|
||||
}
|
||||
|
||||
if opts.json {
|
||||
let payload = DiscoveryOutput(
|
||||
status: status,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
includeLocal: opts.includeLocal,
|
||||
count: gateways.count,
|
||||
gateways: gateways.map {
|
||||
DiscoveryOutput.Gateway(
|
||||
displayName: $0.displayName,
|
||||
lanHost: $0.lanHost,
|
||||
tailnetDns: $0.tailnetDns,
|
||||
sshPort: $0.sshPort,
|
||||
gatewayPort: $0.gatewayPort,
|
||||
cliPath: $0.cliPath,
|
||||
stableID: $0.stableID,
|
||||
debugID: $0.debugID,
|
||||
isLocal: $0.isLocal)
|
||||
})
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
if let data = try? encoder.encode(payload),
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
{
|
||||
print(json)
|
||||
} else {
|
||||
print("{\"error\":\"failed to encode JSON\"}")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
print("Gateway Discovery (macOS NWBrowser)")
|
||||
print("Status: \(status)")
|
||||
print("Found \(gateways.count) gateway(s)\(opts.includeLocal ? "" : " (local filtered)")")
|
||||
if gateways.isEmpty { return }
|
||||
|
||||
for gateway in gateways {
|
||||
let hosts = [gateway.tailnetDns, gateway.lanHost]
|
||||
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: ", ")
|
||||
print("- \(gateway.displayName)")
|
||||
print(" hosts: \(hosts.isEmpty ? "(none)" : hosts)")
|
||||
print(" ssh: \(gateway.sshPort)")
|
||||
if let port = gateway.gatewayPort {
|
||||
print(" gatewayPort: \(port)")
|
||||
}
|
||||
if let cliPath = gateway.cliPath {
|
||||
print(" cliPath: \(cliPath)")
|
||||
}
|
||||
print(" isLocal: \(gateway.isLocal)")
|
||||
print(" stableID: \(gateway.stableID)")
|
||||
print(" debugID: \(gateway.debugID)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import ClawdbotDiscovery
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite
|
||||
@MainActor
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClawdbotDiscovery
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
@@ -6,7 +7,7 @@ import Testing
|
||||
@MainActor
|
||||
struct MasterDiscoveryMenuSmokeTests {
|
||||
@Test func inlineListBuildsBodyWhenEmpty() {
|
||||
let discovery = GatewayDiscoveryModel()
|
||||
let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
|
||||
discovery.statusText = "Searching…"
|
||||
discovery.gateways = []
|
||||
|
||||
@@ -15,7 +16,7 @@ struct MasterDiscoveryMenuSmokeTests {
|
||||
}
|
||||
|
||||
@Test func inlineListBuildsBodyWithMasterAndSelection() {
|
||||
let discovery = GatewayDiscoveryModel()
|
||||
let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
|
||||
discovery.statusText = "Found 1"
|
||||
discovery.gateways = [
|
||||
GatewayDiscoveryModel.DiscoveredGateway(
|
||||
@@ -23,6 +24,8 @@ struct MasterDiscoveryMenuSmokeTests {
|
||||
lanHost: "office.local",
|
||||
tailnetDns: "office.tailnet-123.ts.net",
|
||||
sshPort: 2222,
|
||||
gatewayPort: nil,
|
||||
cliPath: nil,
|
||||
stableID: "office",
|
||||
debugID: "office",
|
||||
isLocal: false),
|
||||
@@ -34,7 +37,7 @@ struct MasterDiscoveryMenuSmokeTests {
|
||||
}
|
||||
|
||||
@Test func menuBuildsBodyWithMasters() {
|
||||
let discovery = GatewayDiscoveryModel()
|
||||
let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
|
||||
discovery.statusText = "Found 2"
|
||||
discovery.gateways = [
|
||||
GatewayDiscoveryModel.DiscoveredGateway(
|
||||
@@ -42,6 +45,8 @@ struct MasterDiscoveryMenuSmokeTests {
|
||||
lanHost: "a.local",
|
||||
tailnetDns: nil,
|
||||
sshPort: 22,
|
||||
gatewayPort: nil,
|
||||
cliPath: nil,
|
||||
stableID: "a",
|
||||
debugID: "a",
|
||||
isLocal: false),
|
||||
@@ -50,6 +55,8 @@ struct MasterDiscoveryMenuSmokeTests {
|
||||
lanHost: nil,
|
||||
tailnetDns: "b.ts.net",
|
||||
sshPort: 22,
|
||||
gatewayPort: nil,
|
||||
cliPath: nil,
|
||||
stableID: "b",
|
||||
debugID: "b",
|
||||
isLocal: false),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClawdbotDiscovery
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
@@ -10,7 +11,7 @@ struct OnboardingViewSmokeTests {
|
||||
let view = OnboardingView(
|
||||
state: state,
|
||||
permissionMonitor: PermissionMonitor.shared,
|
||||
discoveryModel: GatewayDiscoveryModel())
|
||||
discoveryModel: GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName))
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import Testing
|
||||
@testable import ClawdbotDiscovery
|
||||
|
||||
@Suite
|
||||
struct WideAreaGatewayDiscoveryTests {
|
||||
@Test func discoversBeaconFromTailnetDnsSdFallback() {
|
||||
let statusJson = """
|
||||
{
|
||||
"Self": { "TailscaleIPs": ["100.69.232.64"] },
|
||||
"Peer": {
|
||||
"peer-1": { "TailscaleIPs": ["100.123.224.76"] }
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
let context = WideAreaGatewayDiscovery.DiscoveryContext(
|
||||
tailscaleStatus: { statusJson },
|
||||
dig: { args, _ in
|
||||
let recordType = args.last ?? ""
|
||||
let nameserver = args.first(where: { $0.hasPrefix("@") }) ?? ""
|
||||
if recordType == "PTR" {
|
||||
if nameserver == "@100.123.224.76" {
|
||||
return "steipetacstudio-bridge._clawdbot-bridge._tcp.clawdbot.internal.\n"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if recordType == "SRV" {
|
||||
return "0 0 18790 steipetacstudio.clawdbot.internal."
|
||||
}
|
||||
if recordType == "TXT" {
|
||||
return "\"displayName=Peter\\226\\128\\153s Mac Studio (Clawdbot)\" \"transport=bridge\" \"bridgePort=18790\" \"gatewayPort=18789\" \"tailnetDns=peters-mac-studio-1.sheep-coho.ts.net\" \"cliPath=/Users/steipete/clawdbot/src/entry.ts\""
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
let beacons = WideAreaGatewayDiscovery.discover(
|
||||
timeoutSeconds: 2.0,
|
||||
context: context)
|
||||
|
||||
#expect(beacons.count == 1)
|
||||
let beacon = beacons[0]
|
||||
let expectedDisplay = "Peter\u{2019}s Mac Studio (Clawdbot)"
|
||||
#expect(beacon.displayName == expectedDisplay)
|
||||
#expect(beacon.bridgePort == 18790)
|
||||
#expect(beacon.gatewayPort == 18789)
|
||||
#expect(beacon.tailnetDns == "peters-mac-studio-1.sheep-coho.ts.net")
|
||||
#expect(beacon.cliPath == "/Users/steipete/clawdbot/src/entry.ts")
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,25 @@ Safety:
|
||||
- Package app + CLI: `scripts/package-mac-app.sh`
|
||||
- Switch bundled gateway runtime with `BUNDLED_RUNTIME=node|bun` (default: node).
|
||||
|
||||
## Debug gateway discovery (macOS CLI)
|
||||
|
||||
Use the debug CLI to exercise the same Bonjour + wide‑area discovery code that the
|
||||
macOS app uses, without launching the app.
|
||||
|
||||
```bash
|
||||
cd apps/macos
|
||||
swift run clawdbot-mac-discovery --timeout 3000 --json
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--include-local`: include gateways that would be filtered as “local”
|
||||
- `--timeout <ms>`: overall discovery window (default `2000`)
|
||||
- `--json`: structured output for diffing
|
||||
|
||||
Tip: compare against `pnpm clawdbot gateway discover --json` to see whether the
|
||||
macOS app’s discovery pipeline (NWBrowser + tailnet DNS‑SD fallback) differs from
|
||||
the Node CLI’s `dns-sd` based discovery.
|
||||
|
||||
## Related docs
|
||||
|
||||
- [Gateway runbook](/gateway)
|
||||
|
||||
@@ -7,6 +7,11 @@ vi.mock("../infra/update-runner.js", () => ({
|
||||
runGatewayUpdate: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the doctor command to avoid loading heavy dependencies
|
||||
vi.mock("../commands/doctor.js", () => ({
|
||||
doctorCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the daemon-cli module
|
||||
vi.mock("./daemon-cli.js", () => ({
|
||||
runDaemonRestart: vi.fn(),
|
||||
|
||||
Reference in New Issue
Block a user