Mac: launch gateway and add relay installer
This commit is contained in:
@@ -13,9 +13,8 @@ actor AgentRPC {
|
||||
private var configured = false
|
||||
|
||||
private var gatewayURL: URL {
|
||||
let port = UserDefaults.standard.integer(forKey: "gatewayPort")
|
||||
let effectivePort = port > 0 ? port : 18789
|
||||
return URL(string: "ws://127.0.0.1:\(effectivePort)")!
|
||||
let port = RelayEnvironment.gatewayPort()
|
||||
return URL(string: "ws://127.0.0.1:\(port)")!
|
||||
}
|
||||
|
||||
private var gatewayToken: String? {
|
||||
|
||||
@@ -57,9 +57,8 @@ final class ControlChannel: ObservableObject {
|
||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")
|
||||
private let gateway = GatewayChannel()
|
||||
private var gatewayURL: URL {
|
||||
let port = UserDefaults.standard.integer(forKey: "gatewayPort")
|
||||
let effectivePort = port > 0 ? port : 18789
|
||||
return URL(string: "ws://127.0.0.1:\(effectivePort)")!
|
||||
let port = RelayEnvironment.gatewayPort()
|
||||
return URL(string: "ws://127.0.0.1:\(port)")!
|
||||
}
|
||||
|
||||
private var gatewayToken: String? {
|
||||
|
||||
@@ -8,6 +8,9 @@ struct GeneralSettings: View {
|
||||
@State private var cliStatus: String?
|
||||
@State private var cliInstalled = false
|
||||
@State private var cliInstallLocation: String?
|
||||
@State private var relayStatus: RelayEnvironmentStatus = .checking
|
||||
@State private var relayInstallMessage: String?
|
||||
@State private var relayInstalling = false
|
||||
@State private var remoteStatus: RemoteStatus = .idle
|
||||
@State private var showRemoteAdvanced = false
|
||||
|
||||
@@ -61,7 +64,10 @@ struct GeneralSettings: View {
|
||||
.padding(.horizontal, 22)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.onAppear { self.refreshCLIStatus() }
|
||||
.onAppear {
|
||||
self.refreshCLIStatus()
|
||||
self.refreshRelayStatus()
|
||||
}
|
||||
}
|
||||
|
||||
private var activeBinding: Binding<Bool> {
|
||||
@@ -84,6 +90,7 @@ struct GeneralSettings: View {
|
||||
.frame(width: 380, alignment: .leading)
|
||||
|
||||
if self.state.connectionMode == .local {
|
||||
self.relayInstallerCard
|
||||
self.healthRow
|
||||
}
|
||||
|
||||
@@ -239,6 +246,64 @@ struct GeneralSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var relayInstallerCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(self.relayStatusColor)
|
||||
.frame(width: 10, height: 10)
|
||||
Text(self.relayStatus.message)
|
||||
.font(.callout)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
if let relayVersion = self.relayStatus.relayVersion,
|
||||
let required = self.relayStatus.requiredRelay,
|
||||
relayVersion != required
|
||||
{
|
||||
Text("Installed: \(relayVersion) · Required: \(required)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if let relayVersion = self.relayStatus.relayVersion {
|
||||
Text("Relay \(relayVersion) detected")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let node = self.relayStatus.nodeVersion {
|
||||
Text("Node \(node)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
Task { await self.installRelay() }
|
||||
} label: {
|
||||
if self.relayInstalling {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Install/Update relay")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.relayInstalling)
|
||||
|
||||
Button("Recheck") { self.refreshRelayStatus() }
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.relayInstalling)
|
||||
}
|
||||
|
||||
Text(self.relayInstallMessage ?? "Installs the global \"clawdis\" package and expects the gateway on port 18789.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.gray.opacity(0.08))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
|
||||
private func installCLI() async {
|
||||
guard !self.isInstallingCLI else { return }
|
||||
self.isInstallingCLI = true
|
||||
@@ -257,6 +322,30 @@ struct GeneralSettings: View {
|
||||
self.cliInstalled = installLocation != nil
|
||||
}
|
||||
|
||||
private func refreshRelayStatus() {
|
||||
self.relayStatus = RelayEnvironment.check()
|
||||
}
|
||||
|
||||
private func installRelay() async {
|
||||
guard !self.relayInstalling else { return }
|
||||
self.relayInstalling = true
|
||||
defer { self.relayInstalling = false }
|
||||
self.relayInstallMessage = nil
|
||||
let expected = RelayEnvironment.expectedRelayVersion()
|
||||
await RelayEnvironment.installGlobal(version: expected) { message in
|
||||
Task { @MainActor in self.relayInstallMessage = message }
|
||||
}
|
||||
self.refreshRelayStatus()
|
||||
}
|
||||
|
||||
private var relayStatusColor: Color {
|
||||
switch self.relayStatus.kind {
|
||||
case .ok: .green
|
||||
case .checking: .secondary
|
||||
case .missingNode, .missingRelay, .incompatible, .error: .orange
|
||||
}
|
||||
}
|
||||
|
||||
private var healthCard: some View {
|
||||
let snapshot = self.healthStore.snapshot
|
||||
return VStack(alignment: .leading, spacing: 6) {
|
||||
|
||||
@@ -151,15 +151,15 @@ final class HealthStore: ObservableObject {
|
||||
|
||||
/// Short, human-friendly detail for the last failure, used in the UI.
|
||||
var detailLine: String? {
|
||||
if let error = self.lastError, !error.isEmpty {
|
||||
let lower = error.lowercased()
|
||||
if lower.contains("connection refused") {
|
||||
return "The relay control port (127.0.0.1:18789) isn’t listening — restart Clawdis to bring it back."
|
||||
}
|
||||
if lower.contains("timeout") {
|
||||
return "Timed out waiting for the control server; the relay may be crashed or still starting."
|
||||
}
|
||||
return error
|
||||
if let error = self.lastError, !error.isEmpty {
|
||||
let lower = error.lowercased()
|
||||
if lower.contains("connection refused") {
|
||||
return "The gateway control port (127.0.0.1:18789) isn’t listening — restart Clawdis to bring it back."
|
||||
}
|
||||
if lower.contains("timeout") {
|
||||
return "Timed out waiting for the control server; the relay may be crashed or still starting."
|
||||
}
|
||||
return error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -46,13 +46,16 @@ struct OnboardingView: View {
|
||||
@State private var monitoringPermissions = false
|
||||
@State private var cliInstalled = false
|
||||
@State private var cliInstallLocation: String?
|
||||
@State private var relayStatus: RelayEnvironmentStatus = .checking
|
||||
@State private var relayInstalling = false
|
||||
@State private var relayInstallMessage: String?
|
||||
@ObservedObject private var state = AppStateStore.shared
|
||||
@ObservedObject private var permissionMonitor = PermissionMonitor.shared
|
||||
|
||||
private let pageWidth: CGFloat = 680
|
||||
private let contentHeight: CGFloat = 520
|
||||
private let permissionsPageIndex = 2
|
||||
private var pageCount: Int { 6 }
|
||||
private let permissionsPageIndex = 3
|
||||
private var pageCount: Int { 7 }
|
||||
private var buttonTitle: String { self.currentPage == self.pageCount - 1 ? "Finish" : "Next" }
|
||||
private let devLinkCommand = "ln -sf $(pwd)/apps/macos/.build/debug/ClawdisCLI /usr/local/bin/clawdis-mac"
|
||||
|
||||
@@ -67,6 +70,7 @@ struct OnboardingView: View {
|
||||
HStack(spacing: 0) {
|
||||
self.welcomePage().frame(width: self.pageWidth)
|
||||
self.connectionPage().frame(width: self.pageWidth)
|
||||
self.relayPage().frame(width: self.pageWidth)
|
||||
self.permissionsPage().frame(width: self.pageWidth)
|
||||
self.cliPage().frame(width: self.pageWidth)
|
||||
self.whatsappPage().frame(width: self.pageWidth)
|
||||
@@ -96,6 +100,7 @@ struct OnboardingView: View {
|
||||
.task {
|
||||
await self.refreshPerms()
|
||||
self.refreshCLIStatus()
|
||||
self.refreshRelayStatus()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +177,81 @@ struct OnboardingView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func relayPage() -> some View {
|
||||
self.onboardingPage {
|
||||
Text("Install the relay")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text("Clawdis now runs the WebSocket gateway from the global \"clawdis\" package. Install/update it here and we’ll check Node for you.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 520)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
self.onboardingCard(spacing: 10, padding: 14) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(self.relayStatusColor)
|
||||
.frame(width: 10, height: 10)
|
||||
Text(self.relayStatus.message)
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
if let relayVersion = self.relayStatus.relayVersion,
|
||||
let required = self.relayStatus.requiredRelay,
|
||||
relayVersion != required
|
||||
{
|
||||
Text("Installed: \(relayVersion) · Required: \(required)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if let relayVersion = self.relayStatus.relayVersion {
|
||||
Text("Relay \(relayVersion) detected")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let node = self.relayStatus.nodeVersion {
|
||||
Text("Node \(node)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
Task { await self.installRelay() }
|
||||
} label: {
|
||||
if self.relayInstalling {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("Install / Update relay")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.relayInstalling)
|
||||
|
||||
Button("Recheck") { self.refreshRelayStatus() }
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.relayInstalling)
|
||||
}
|
||||
|
||||
if let relayInstallMessage {
|
||||
Text(relayInstallMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
} else {
|
||||
Text("Uses \"pnpm add -g clawdis@<version>\" on your PATH. We keep the gateway on port 18789.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func permissionsPage() -> some View {
|
||||
self.onboardingPage {
|
||||
Text("Grant permissions")
|
||||
@@ -481,6 +561,30 @@ struct OnboardingView: View {
|
||||
self.cliInstalled = installLocation != nil
|
||||
}
|
||||
|
||||
private func refreshRelayStatus() {
|
||||
self.relayStatus = RelayEnvironment.check()
|
||||
}
|
||||
|
||||
private func installRelay() async {
|
||||
guard !self.relayInstalling else { return }
|
||||
self.relayInstalling = true
|
||||
defer { self.relayInstalling = false }
|
||||
self.relayInstallMessage = nil
|
||||
let expected = RelayEnvironment.expectedRelayVersion()
|
||||
await RelayEnvironment.installGlobal(version: expected) { message in
|
||||
Task { @MainActor in self.relayInstallMessage = message }
|
||||
}
|
||||
self.refreshRelayStatus()
|
||||
}
|
||||
|
||||
private var relayStatusColor: Color {
|
||||
switch self.relayStatus.kind {
|
||||
case .ok: .green
|
||||
case .checking: .secondary
|
||||
case .missingNode, .missingRelay, .incompatible, .error: .orange
|
||||
}
|
||||
}
|
||||
|
||||
private func copyToPasteboard(_ text: String) {
|
||||
let pb = NSPasteboard.general
|
||||
pb.clearContents()
|
||||
|
||||
162
apps/macos/Sources/Clawdis/RelayEnvironment.swift
Normal file
162
apps/macos/Sources/Clawdis/RelayEnvironment.swift
Normal file
@@ -0,0 +1,162 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ final class RelayProcessManager: ObservableObject {
|
||||
@Published private(set) var status: Status = .stopped
|
||||
@Published private(set) var log: String = ""
|
||||
@Published private(set) var restartCount: Int = 0
|
||||
@Published private(set) var environmentStatus: RelayEnvironmentStatus = .checking
|
||||
|
||||
private var execution: Execution?
|
||||
private var desiredActive = false
|
||||
@@ -48,6 +49,7 @@ final class RelayProcessManager: ObservableObject {
|
||||
|
||||
func setActive(_ active: Bool) {
|
||||
self.desiredActive = active
|
||||
self.refreshEnvironmentStatus()
|
||||
if active {
|
||||
self.startIfNeeded()
|
||||
} else {
|
||||
@@ -82,10 +84,22 @@ final class RelayProcessManager: ObservableObject {
|
||||
self.execution = nil
|
||||
}
|
||||
|
||||
func refreshEnvironmentStatus() {
|
||||
self.environmentStatus = RelayEnvironment.check()
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private func spawnRelay() async {
|
||||
let command = self.resolveCommand()
|
||||
let resolution = RelayEnvironment.resolveGatewayCommand()
|
||||
await MainActor.run { self.environmentStatus = resolution.status }
|
||||
guard let command = resolution.command else {
|
||||
await MainActor.run {
|
||||
self.status = .failed(resolution.status.message)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let cwd = self.defaultProjectRoot().path
|
||||
self.appendLog("[relay] starting: \(command.joined(separator: " ")) (cwd: \(cwd))\n")
|
||||
|
||||
@@ -192,29 +206,6 @@ final class RelayProcessManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveCommand() -> [String] {
|
||||
// Force the app-managed relay to use system Node (no bundled runtime, no bun).
|
||||
let runtimeResult = CommandResolver.runtimeResolution()
|
||||
guard case let .success(runtime) = runtimeResult else {
|
||||
if case let .failure(err) = runtimeResult {
|
||||
return CommandResolver.runtimeErrorCommand(err)
|
||||
}
|
||||
return ["/bin/sh", "-c", "echo 'runtime resolution failed' >&2; exit 1"]
|
||||
}
|
||||
|
||||
let relayRoot = CommandResolver.projectRoot()
|
||||
if let entry = CommandResolver.relayEntrypoint(in: relayRoot) {
|
||||
return CommandResolver.makeRuntimeCommand(
|
||||
runtime: runtime,
|
||||
entrypoint: entry,
|
||||
subcommand: "relay",
|
||||
extraArgs: [])
|
||||
}
|
||||
|
||||
return CommandResolver.errorCommand(
|
||||
with: "clawdis entrypoint missing (looked for dist/index.js or bin/clawdis.js); run pnpm build.")
|
||||
}
|
||||
|
||||
private func makeEnvironment() -> Environment {
|
||||
let merged = CommandResolver.preferredPaths().joined(separator: ":")
|
||||
return .inherit.updating([
|
||||
|
||||
@@ -201,12 +201,6 @@ enum CommandResolver {
|
||||
private static let projectRootDefaultsKey = "clawdis.relayProjectRootPath"
|
||||
private static let helperName = "clawdis"
|
||||
|
||||
private static func bundledRelayRoot() -> URL? {
|
||||
guard let resource = Bundle.main.resourceURL else { return nil }
|
||||
let relay = resource.appendingPathComponent("Relay")
|
||||
return FileManager.default.fileExists(atPath: relay.path) ? relay : nil
|
||||
}
|
||||
|
||||
static func relayEntrypoint(in root: URL) -> String? {
|
||||
let distEntry = root.appendingPathComponent("dist/index.js").path
|
||||
if FileManager.default.isReadableFile(atPath: distEntry) { return distEntry }
|
||||
@@ -244,11 +238,9 @@ enum CommandResolver {
|
||||
}
|
||||
|
||||
static func projectRoot() -> URL {
|
||||
if let bundled = self.bundledRelayRoot() {
|
||||
return bundled
|
||||
}
|
||||
if let stored = UserDefaults.standard.string(forKey: self.projectRootDefaultsKey),
|
||||
let url = self.expandPath(stored)
|
||||
let url = self.expandPath(stored),
|
||||
FileManager.default.fileExists(atPath: url.path)
|
||||
{
|
||||
return url
|
||||
}
|
||||
@@ -272,16 +264,13 @@ enum CommandResolver {
|
||||
let current = ProcessInfo.processInfo.environment["PATH"]?
|
||||
.split(separator: ":").map(String.init) ?? []
|
||||
var extras = [
|
||||
self.projectRoot().appendingPathComponent("node_modules/.bin").path,
|
||||
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/pnpm").path,
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
"/usr/bin",
|
||||
"/bin",
|
||||
]
|
||||
if let relay = self.bundledRelayRoot() {
|
||||
extras.insert(relay.appendingPathComponent("node_modules/.bin").path, at: 0)
|
||||
}
|
||||
extras.insert(self.projectRoot().appendingPathComponent("node_modules/.bin").path, at: 0)
|
||||
var seen = Set<String>()
|
||||
// Preserve order while stripping duplicates so PATH lookups remain deterministic.
|
||||
return (extras + current).filter { seen.insert($0).inserted }
|
||||
@@ -327,14 +316,8 @@ enum CommandResolver {
|
||||
|
||||
switch runtimeResult {
|
||||
case let .success(runtime):
|
||||
if let relay = self.bundledRelayRoot(),
|
||||
let entry = self.relayEntrypoint(in: relay)
|
||||
{
|
||||
return self.makeRuntimeCommand(
|
||||
runtime: runtime,
|
||||
entrypoint: entry,
|
||||
subcommand: subcommand,
|
||||
extraArgs: extraArgs)
|
||||
if let clawdisPath = self.clawdisExecutable() {
|
||||
return [clawdisPath, subcommand] + extraArgs
|
||||
}
|
||||
|
||||
if let entry = self.relayEntrypoint(in: self.projectRoot()) {
|
||||
@@ -344,10 +327,6 @@ enum CommandResolver {
|
||||
subcommand: subcommand,
|
||||
extraArgs: extraArgs)
|
||||
}
|
||||
|
||||
if let clawdisPath = self.clawdisExecutable() {
|
||||
return [clawdisPath, subcommand] + extraArgs
|
||||
}
|
||||
if let pnpm = self.findExecutable(named: "pnpm") {
|
||||
// Use --silent to avoid pnpm lifecycle banners that would corrupt JSON outputs.
|
||||
return [pnpm, "--silent", "clawdis", subcommand] + extraArgs
|
||||
|
||||
@@ -397,18 +397,39 @@ Examples:
|
||||
.command("status")
|
||||
.description("Show web session health and recent session recipients")
|
||||
.option("--json", "Output JSON instead of text", false)
|
||||
.option("--deep", "Probe providers (WA connect + Telegram API)", false)
|
||||
.option("--timeout <ms>", "Probe timeout in milliseconds", "10000")
|
||||
.option("--verbose", "Verbose logging", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
Examples:
|
||||
clawdis status # show linked account + session store summary
|
||||
clawdis status --json # machine-readable output`,
|
||||
clawdis status --json # machine-readable output
|
||||
clawdis status --deep # run provider probes (WA + Telegram)
|
||||
clawdis status --deep --timeout 5000 # tighten probe timeout`,
|
||||
)
|
||||
.action(async (opts) => {
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
const timeout = opts.timeout
|
||||
? Number.parseInt(String(opts.timeout), 10)
|
||||
: undefined;
|
||||
if (timeout !== undefined && (Number.isNaN(timeout) || timeout <= 0)) {
|
||||
defaultRuntime.error(
|
||||
"--timeout must be a positive integer (milliseconds)",
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await statusCommand(opts, defaultRuntime);
|
||||
await statusCommand(
|
||||
{
|
||||
json: Boolean(opts.json),
|
||||
deep: Boolean(opts.deep),
|
||||
timeoutMs: timeout,
|
||||
},
|
||||
defaultRuntime,
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
|
||||
@@ -21,6 +21,9 @@ vi.mock("../config/sessions.js", () => ({
|
||||
|
||||
const waitForWaConnection = vi.fn();
|
||||
const webAuthExists = vi.fn();
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
vi.mock("../web/session.js", () => ({
|
||||
createWaSocket: vi.fn(async () => ({
|
||||
@@ -41,11 +44,25 @@ vi.mock("../web/reconnect.js", () => ({
|
||||
describe("healthCommand", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
it("outputs JSON when linked and connect succeeds", async () => {
|
||||
webAuthExists.mockResolvedValue(true);
|
||||
waitForWaConnection.mockResolvedValue(undefined);
|
||||
process.env.TELEGRAM_BOT_TOKEN = "123:abc";
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ok: true, result: { id: 1, username: "bot" } }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ok: true, result: { url: "https://hook" } }),
|
||||
});
|
||||
|
||||
await healthCommand({ json: true, timeoutMs: 5000 }, runtime as never);
|
||||
|
||||
@@ -54,6 +71,8 @@ describe("healthCommand", () => {
|
||||
const parsed = JSON.parse(logged);
|
||||
expect(parsed.web.linked).toBe(true);
|
||||
expect(parsed.web.connect.ok).toBe(true);
|
||||
expect(parsed.telegram.configured).toBe(true);
|
||||
expect(parsed.telegram.probe.ok).toBe(true);
|
||||
expect(parsed.sessions.count).toBe(1);
|
||||
});
|
||||
|
||||
@@ -75,4 +94,24 @@ describe("healthCommand", () => {
|
||||
expect(parsed.web.connect.ok).toBe(false);
|
||||
expect(parsed.web.connect.status).toBe(440);
|
||||
});
|
||||
|
||||
it("exits non-zero when telegram probe fails", async () => {
|
||||
webAuthExists.mockResolvedValue(true);
|
||||
waitForWaConnection.mockResolvedValue(undefined);
|
||||
process.env.TELEGRAM_BOT_TOKEN = "123:abc";
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ ok: false, description: "unauthorized" }),
|
||||
});
|
||||
|
||||
await healthCommand({ json: true }, runtime as never);
|
||||
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
const logged = runtime.log.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(logged);
|
||||
expect(parsed.telegram.configured).toBe(true);
|
||||
expect(parsed.telegram.probe.ok).toBe(false);
|
||||
expect(parsed.telegram.probe.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
|
||||
import { info } from "../globals.js";
|
||||
import { makeProxyFetch } from "../telegram/proxy.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
|
||||
import {
|
||||
@@ -22,6 +23,15 @@ type HealthConnect = {
|
||||
elapsedMs: number;
|
||||
};
|
||||
|
||||
type TelegramProbe = {
|
||||
ok: boolean;
|
||||
status?: number | null;
|
||||
error?: string | null;
|
||||
elapsedMs: number;
|
||||
bot?: { id?: number | null; username?: string | null };
|
||||
webhook?: { url?: string | null; hasCustomCert?: boolean | null };
|
||||
};
|
||||
|
||||
export type HealthSummary = {
|
||||
ts: number;
|
||||
durationMs: number;
|
||||
@@ -30,6 +40,10 @@ export type HealthSummary = {
|
||||
authAgeMs: number | null;
|
||||
connect?: HealthConnect;
|
||||
};
|
||||
telegram: {
|
||||
configured: boolean;
|
||||
probe?: TelegramProbe;
|
||||
};
|
||||
heartbeatSeconds: number;
|
||||
sessions: {
|
||||
path: string;
|
||||
@@ -44,6 +58,7 @@ export type HealthSummary = {
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
const TELEGRAM_API_BASE = "https://api.telegram.org";
|
||||
|
||||
async function probeWebConnect(timeoutMs: number): Promise<HealthConnect> {
|
||||
const started = Date.now();
|
||||
@@ -77,6 +92,93 @@ async function probeWebConnect(timeoutMs: number): Promise<HealthConnect> {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
timeoutMs: number,
|
||||
fetcher: typeof fetch,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetcher(url, { signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function probeTelegram(
|
||||
token: string,
|
||||
timeoutMs: number,
|
||||
proxyUrl?: string,
|
||||
): Promise<TelegramProbe> {
|
||||
const started = Date.now();
|
||||
const fetcher = proxyUrl ? makeProxyFetch(proxyUrl) : fetch;
|
||||
const base = `${TELEGRAM_API_BASE}/bot${token}`;
|
||||
|
||||
const result: TelegramProbe = {
|
||||
ok: false,
|
||||
status: null,
|
||||
error: null,
|
||||
elapsedMs: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
const meRes = await fetchWithTimeout(`${base}/getMe`, timeoutMs, fetcher);
|
||||
const meJson = (await meRes.json()) as {
|
||||
ok?: boolean;
|
||||
description?: string;
|
||||
result?: { id?: number; username?: string };
|
||||
};
|
||||
if (!meRes.ok || !meJson?.ok) {
|
||||
result.status = meRes.status;
|
||||
result.error = meJson?.description ?? `getMe failed (${meRes.status})`;
|
||||
return { ...result, elapsedMs: Date.now() - started };
|
||||
}
|
||||
|
||||
result.bot = {
|
||||
id: meJson.result?.id ?? null,
|
||||
username: meJson.result?.username ?? null,
|
||||
};
|
||||
|
||||
// Try to fetch webhook info, but don't fail health if it errors
|
||||
try {
|
||||
const webhookRes = await fetchWithTimeout(
|
||||
`${base}/getWebhookInfo`,
|
||||
timeoutMs,
|
||||
fetcher,
|
||||
);
|
||||
const webhookJson = (await webhookRes.json()) as {
|
||||
ok?: boolean;
|
||||
result?: {
|
||||
url?: string;
|
||||
has_custom_certificate?: boolean;
|
||||
};
|
||||
};
|
||||
if (webhookRes.ok && webhookJson?.ok) {
|
||||
result.webhook = {
|
||||
url: webhookJson.result?.url ?? null,
|
||||
hasCustomCert: webhookJson.result?.has_custom_certificate ?? null,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore webhook errors for health
|
||||
}
|
||||
|
||||
result.ok = true;
|
||||
result.status = null;
|
||||
result.error = null;
|
||||
result.elapsedMs = Date.now() - started;
|
||||
return result;
|
||||
} catch (err) {
|
||||
return {
|
||||
...result,
|
||||
status: err instanceof Response ? err.status : result.status,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHealthSnapshot(
|
||||
timeoutMs?: number,
|
||||
): Promise<HealthSummary> {
|
||||
@@ -103,10 +205,19 @@ export async function getHealthSnapshot(
|
||||
const cappedTimeout = Math.max(1000, timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
||||
const connect = linked ? await probeWebConnect(cappedTimeout) : undefined;
|
||||
|
||||
const telegramToken =
|
||||
process.env.TELEGRAM_BOT_TOKEN ?? cfg.telegram?.botToken ?? "";
|
||||
const telegramConfigured = telegramToken.trim().length > 0;
|
||||
const telegramProxy = cfg.telegram?.proxy;
|
||||
const telegramProbe = telegramConfigured
|
||||
? await probeTelegram(telegramToken.trim(), cappedTimeout, telegramProxy)
|
||||
: undefined;
|
||||
|
||||
const summary: HealthSummary = {
|
||||
ts: Date.now(),
|
||||
durationMs: Date.now() - start,
|
||||
web: { linked, authAgeMs, connect },
|
||||
telegram: { configured: telegramConfigured, probe: telegramProbe },
|
||||
heartbeatSeconds,
|
||||
sessions: {
|
||||
path: storePath,
|
||||
@@ -125,7 +236,11 @@ export async function healthCommand(
|
||||
) {
|
||||
const summary = await getHealthSnapshot(opts.timeoutMs);
|
||||
const fatal =
|
||||
!summary.web.linked || (summary.web.connect && !summary.web.connect.ok);
|
||||
!summary.web.linked ||
|
||||
(summary.web.connect && !summary.web.connect.ok) ||
|
||||
(summary.telegram.configured &&
|
||||
summary.telegram.probe &&
|
||||
!summary.telegram.probe.ok);
|
||||
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify(summary, null, 2));
|
||||
@@ -147,6 +262,19 @@ export async function healthCommand(
|
||||
(summary.web.connect.error ? ` - ${summary.web.connect.error}` : ""),
|
||||
);
|
||||
}
|
||||
|
||||
const tgLabel = summary.telegram.configured
|
||||
? summary.telegram.probe?.ok
|
||||
? info(
|
||||
`Telegram: ok${summary.telegram.probe.bot?.username ? ` (@${summary.telegram.probe.bot.username})` : ""} (${summary.telegram.probe.elapsedMs}ms)` +
|
||||
(summary.telegram.probe.webhook?.url
|
||||
? ` - webhook ${summary.telegram.probe.webhook.url}`
|
||||
: ""),
|
||||
)
|
||||
: `Telegram: failed (${summary.telegram.probe?.status ?? "unknown"})${summary.telegram.probe?.error ? ` - ${summary.telegram.probe.error}` : ""}`
|
||||
: "Telegram: not configured";
|
||||
runtime.log(tgLabel);
|
||||
|
||||
runtime.log(info(`Heartbeat interval: ${summary.heartbeatSeconds}s`));
|
||||
runtime.log(
|
||||
info(
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type SessionEntry,
|
||||
} from "../config/sessions.js";
|
||||
import { info } from "../globals.js";
|
||||
import { getHealthSnapshot, type HealthSummary } from "./health.js";
|
||||
import { buildProviderSummary } from "../infra/provider-summary.js";
|
||||
import { peekSystemEvents } from "../infra/system-events.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
@@ -187,13 +188,22 @@ const buildFlags = (entry: SessionEntry): string[] => {
|
||||
};
|
||||
|
||||
export async function statusCommand(
|
||||
opts: { json?: boolean },
|
||||
opts: { json?: boolean; deep?: boolean; timeoutMs?: number },
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
const summary = await getStatusSummary();
|
||||
const health: HealthSummary | undefined = opts.deep
|
||||
? await getHealthSnapshot(opts.timeoutMs)
|
||||
: undefined;
|
||||
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify(summary, null, 2));
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
health ? { ...summary, health } : summary,
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -204,6 +214,28 @@ export async function statusCommand(
|
||||
logWebSelfId(runtime, true);
|
||||
}
|
||||
runtime.log(info(`System: ${summary.providerSummary}`));
|
||||
if (health) {
|
||||
const waLine = health.web.connect
|
||||
? health.web.connect.ok
|
||||
? info(`WA connect: ok (${health.web.connect.elapsedMs}ms)`)
|
||||
: `WA connect: failed (${health.web.connect.status ?? "unknown"})${health.web.connect.error ? ` - ${health.web.connect.error}` : ""}`
|
||||
: info("WA connect: skipped (not linked)");
|
||||
runtime.log(waLine);
|
||||
|
||||
const tgLine = health.telegram.configured
|
||||
? health.telegram.probe?.ok
|
||||
? info(
|
||||
`Telegram: ok${health.telegram.probe.bot?.username ? ` (@${health.telegram.probe.bot.username})` : ""} (${health.telegram.probe.elapsedMs}ms)` +
|
||||
(health.telegram.probe.webhook?.url
|
||||
? ` - webhook ${health.telegram.probe.webhook.url}`
|
||||
: ""),
|
||||
)
|
||||
: `Telegram: failed (${health.telegram.probe?.status ?? "unknown"})${health.telegram.probe?.error ? ` - ${health.telegram.probe.error}` : ""}`
|
||||
: info("Telegram: not configured");
|
||||
runtime.log(tgLine);
|
||||
} else {
|
||||
runtime.log(info("Provider probes: skipped (use --deep)"));
|
||||
}
|
||||
if (summary.queuedSystemEvents.length > 0) {
|
||||
const preview = summary.queuedSystemEvents.slice(0, 3).join(" | ");
|
||||
runtime.log(
|
||||
|
||||
Reference in New Issue
Block a user