diff --git a/CHANGELOG.md b/CHANGELOG.md index dff875b75..34876ceed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4. - Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204. - Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639. +- CLI: add `--json` output for `clawdbot daemon` lifecycle/install commands. - Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors. - Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`. - Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors. diff --git a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift index ac3c3a3b7..c4166c0d9 100644 --- a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift +++ b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift @@ -2,77 +2,16 @@ import Foundation enum GatewayLaunchAgentManager { private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd") - private static let supportedBindModes: Set = ["loopback", "tailnet", "lan", "auto"] - private static let legacyGatewayLaunchdLabel = "com.steipete.clawdbot.gateway" private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent" - private enum GatewayProgramArgumentsError: LocalizedError { - case message(String) - - var errorDescription: String? { - switch self { - case let .message(message): - message - } - } - } - private static var plistURL: URL { FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist") } - private static var legacyPlistURL: URL { - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/LaunchAgents/\(legacyGatewayLaunchdLabel).plist") - } - - private static func gatewayProgramArguments( - port: Int, - bind: String) -> Result<[String], GatewayProgramArgumentsError> - { - let projectRoot = CommandResolver.projectRoot() - #if DEBUG - if let localBin = CommandResolver.projectClawdbotExecutable(projectRoot: projectRoot) { - return .success([localBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]) - } - if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot) { - switch CommandResolver.runtimeResolution() { - case let .success(runtime): - let cmd = CommandResolver.makeRuntimeCommand( - runtime: runtime, - entrypoint: entry, - subcommand: "gateway-daemon", - extraArgs: ["--port", "\(port)", "--bind", bind]) - return .success(cmd) - case .failure: - break - } - } - #endif - let searchPaths = CommandResolver.preferredPaths() - if let gatewayBin = CommandResolver.clawdbotExecutable(searchPaths: searchPaths) { - return .success([gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]) - } - - if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot), - case let .success(runtime) = CommandResolver.runtimeResolution(searchPaths: searchPaths) - { - let cmd = CommandResolver.makeRuntimeCommand( - runtime: runtime, - entrypoint: entry, - subcommand: "gateway-daemon", - extraArgs: ["--port", "\(port)", "--bind", bind]) - return .success(cmd) - } - - return .failure(.message("clawdbot CLI not found in PATH; install the CLI.")) - } - static func isLoaded() async -> Bool { - guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false } - let result = await Launchctl.run(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) - return result.status == 0 + guard let loaded = await self.readDaemonLoaded() else { return false } + return loaded } static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? { @@ -81,255 +20,44 @@ enum GatewayLaunchAgentManager { self.logger.info("launchd enable skipped (disable marker set)") return nil } + if enabled { - _ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"]) - try? FileManager.default.removeItem(at: self.legacyPlistURL) - - let desiredBind = self.preferredGatewayBind() ?? "loopback" - let desiredToken = self.preferredGatewayToken() - let desiredPassword = self.preferredGatewayPassword() - let desiredConfig = DesiredConfig( - port: port, - bind: desiredBind, - token: desiredToken, - password: desiredPassword) - let programArgumentsResult = self.gatewayProgramArguments(port: port, bind: desiredBind) - guard case let .success(programArguments) = programArgumentsResult else { - if case let .failure(error) = programArgumentsResult { - let message = error.localizedDescription - self.logger.error("launchd enable failed: \(message)") - return message - } - return "Failed to resolve gateway command." - } - - // If launchd already loaded the job (common on login), avoid `bootout` unless we must - // change the config. `bootout` can kill a just-started gateway and cause attach loops. - let loaded = await self.isLoaded() - if loaded { - if let existing = self.readPlistConfig(), existing.matches(desiredConfig) { - self.logger.info("launchd job already loaded with desired config; skipping bootout") - await self.ensureEnabled() - _ = await Launchctl.run(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) - return nil - } - } - - self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)") - self.writePlist(programArguments: programArguments) - - await self.ensureEnabled() - if loaded { - _ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) - } - let bootstrap = await Launchctl.run(["bootstrap", "gui/\(getuid())", self.plistURL.path]) - if bootstrap.status != 0 { - let msg = bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines) - self.logger.error("launchd bootstrap failed: \(msg)") - return bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ? "Failed to bootstrap gateway launchd job" - : bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines) - } - await self.ensureEnabled() - return nil + self.logger.info("launchd enable requested via CLI port=\(port)") + return await self.runDaemonCommand([ + "install", + "--force", + "--port", + "\(port)", + "--runtime", + "node", + ]) } - self.logger.info("launchd disable requested") - _ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) - await self.ensureDisabled() - try? FileManager.default.removeItem(at: self.plistURL) - return nil + self.logger.info("launchd disable requested via CLI") + return await self.runDaemonCommand(["uninstall"]) } static func kickstart() async { - _ = await Launchctl.run(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) - } - - private static func writePlist(programArguments: [String]) { - let preferredPath = CommandResolver.preferredPaths().joined(separator: ":") - let token = self.preferredGatewayToken() - let password = self.preferredGatewayPassword() - var envEntries = """ - PATH - \(preferredPath) - """ - if let token { - let escapedToken = self.escapePlistValue(token) - envEntries += """ - CLAWDBOT_GATEWAY_TOKEN - \(escapedToken) - """ - } - if let password { - let escapedPassword = self.escapePlistValue(password) - envEntries += """ - CLAWDBOT_GATEWAY_PASSWORD - \(escapedPassword) - """ - } - let argsXml = programArguments - .map { "\(self.escapePlistValue($0))" } - .joined(separator: "\n ") - let plist = """ - - - - - Label - \(gatewayLaunchdLabel) - ProgramArguments - - \(argsXml) - - WorkingDirectory - \(FileManager.default.homeDirectoryForCurrentUser.path) - RunAtLoad - - KeepAlive - - EnvironmentVariables - - \(envEntries) - - StandardOutPath - \(LogLocator.launchdGatewayLogPath) - StandardErrorPath - \(LogLocator.launchdGatewayLogPath) - - - """ - do { - try plist.write(to: self.plistURL, atomically: true, encoding: .utf8) - } catch { - self.logger.error("launchd plist write failed: \(error.localizedDescription)") - } - } - - private static func preferredGatewayBind() -> String? { - if CommandResolver.connectionModeIsRemote() { - return nil - } - if let env = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_BIND"] { - let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if self.supportedBindModes.contains(trimmed) { - return trimmed - } - } - - let root = ClawdbotConfigFile.loadDict() - if let gateway = root["gateway"] as? [String: Any], - let bind = gateway["bind"] as? String - { - let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if self.supportedBindModes.contains(trimmed) { - return trimmed - } - } - - return nil - } - - private static func preferredGatewayToken() -> String? { - let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"] ?? "" - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { - return trimmed - } - let root = ClawdbotConfigFile.loadDict() - if let gateway = root["gateway"] as? [String: Any], - let auth = gateway["auth"] as? [String: Any], - let token = auth["token"] as? String - { - let value = token.trimmingCharacters(in: .whitespacesAndNewlines) - if !value.isEmpty { - return value - } - } - return nil - } - - private static func preferredGatewayPassword() -> String? { - // First check environment variable - let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PASSWORD"] ?? "" - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { - return trimmed - } - // Then check config file (gateway.auth.password) - let root = ClawdbotConfigFile.loadDict() - if let gateway = root["gateway"] as? [String: Any], - let auth = gateway["auth"] as? [String: Any], - let password = auth["password"] as? String - { - return password.trimmingCharacters(in: .whitespacesAndNewlines) - } - return nil - } - - private static func escapePlistValue(_ raw: String) -> String { - raw - .replacingOccurrences(of: "&", with: "&") - .replacingOccurrences(of: "<", with: "<") - .replacingOccurrences(of: ">", with: ">") - .replacingOccurrences(of: "\"", with: """) - .replacingOccurrences(of: "'", with: "'") - } - - private struct DesiredConfig: Equatable { - let port: Int - let bind: String - let token: String? - let password: String? - } - - private struct InstalledConfig: Equatable { - let port: Int? - let bind: String? - let token: String? - let password: String? - - func matches(_ desired: DesiredConfig) -> Bool { - guard self.port == desired.port else { return false } - guard (self.bind ?? "loopback") == desired.bind else { return false } - guard self.token == desired.token else { return false } - guard self.password == desired.password else { return false } - return true - } - } - - private static func readPlistConfig() -> InstalledConfig? { - guard let snapshot = LaunchAgentPlist.snapshot(url: self.plistURL) else { return nil } - return InstalledConfig( - port: snapshot.port, - bind: snapshot.bind, - token: snapshot.token, - password: snapshot.password) + _ = await self.runDaemonCommand(["restart"], timeout: 20) } static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? { LaunchAgentPlist.snapshot(url: self.plistURL) } - private static func ensureEnabled() async { - let result = await Launchctl.run(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) - guard result.status != 0 else { return } - let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines) - if msg.isEmpty { - self.logger.warning("launchd enable failed") - } else { - self.logger.warning("launchd enable failed: \(msg)") + static func launchdGatewayLogPath() -> String { + let snapshot = self.launchdConfigSnapshot() + if let stdout = snapshot?.stdoutPath?.trimmingCharacters(in: .whitespacesAndNewlines), + !stdout.isEmpty + { + return stdout } - } - - private static func ensureDisabled() async { - let result = await Launchctl.run(["disable", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) - guard result.status != 0 else { return } - let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines) - if msg.isEmpty { - self.logger.warning("launchd disable failed") - } else { - self.logger.warning("launchd disable failed: \(msg)") + if let stderr = snapshot?.stderrPath?.trimmingCharacters(in: .whitespacesAndNewlines), + !stderr.isEmpty + { + return stderr } + return LogLocator.launchdGatewayLogPath } } @@ -339,20 +67,99 @@ extension GatewayLaunchAgentManager { .appendingPathComponent(self.disableLaunchAgentMarker) return FileManager.default.fileExists(atPath: marker.path) } -} -#if DEBUG -extension GatewayLaunchAgentManager { - static func _testPreferredGatewayBind() -> String? { - self.preferredGatewayBind() + private static func readDaemonLoaded() async -> Bool? { + let result = await self.runDaemonCommand(["status", "--json", "--no-probe"], timeout: 15, quiet: true) + guard result.success, let payload = result.payload else { return nil } + guard + let json = try? JSONSerialization.jsonObject(with: payload) as? [String: Any], + let service = json["service"] as? [String: Any], + let loaded = service["loaded"] as? Bool + else { + return nil + } + return loaded } - static func _testPreferredGatewayToken() -> String? { - self.preferredGatewayToken() + private struct CommandResult { + let success: Bool + let payload: Data? + let message: String? } - static func _testEscapePlistValue(_ raw: String) -> String { - self.escapePlistValue(raw) + private struct ParsedDaemonJson { + let text: String + let object: [String: Any] + } + + private static func runDaemonCommand( + _ args: [String], + timeout: Double = 15, + quiet: Bool = false) async -> String? + { + let result = await self.runDaemonCommandResult(args, timeout: timeout, quiet: quiet) + if result.success { return nil } + return result.message ?? "Gateway daemon command failed" + } + + private static func runDaemonCommandResult( + _ args: [String], + timeout: Double, + quiet: Bool) async -> CommandResult + { + let command = CommandResolver.clawdbotCommand( + subcommand: "daemon", + extraArgs: self.withJsonFlag(args)) + var env = ProcessInfo.processInfo.environment + env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":") + let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout) + let parsed = self.parseDaemonJson(from: response.stdout) ?? self.parseDaemonJson(from: response.stderr) + let ok = parsed?.object["ok"] as? Bool + let message = (parsed?.object["error"] as? String) ?? (parsed?.object["message"] as? String) + let payload = parsed?.text.data(using: .utf8) + ?? (response.stdout.isEmpty ? response.stderr : response.stdout).data(using: .utf8) + let success = ok ?? response.success + if success { + return CommandResult(success: true, payload: payload, message: nil) + } + + if quiet { + return CommandResult(success: false, payload: payload, message: message) + } + + let detail = message ?? self.summarize(response.stderr) ?? self.summarize(response.stdout) + let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed") + let fullMessage = detail.map { "Gateway daemon command failed (\(exit)): \($0)" } + ?? "Gateway daemon command failed (\(exit))" + self.logger.error("\(fullMessage, privacy: .public)") + return CommandResult(success: false, payload: payload, message: detail) + } + + private static func withJsonFlag(_ args: [String]) -> [String] { + if args.contains("--json") { return args } + return args + ["--json"] + } + + private static func parseDaemonJson(from raw: String) -> ParsedDaemonJson? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard let start = trimmed.firstIndex(of: "{"), + let end = trimmed.lastIndex(of: "}") + else { + return nil + } + let jsonText = String(trimmed[start...end]) + guard let data = jsonText.data(using: .utf8) else { return nil } + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + return ParsedDaemonJson(text: jsonText, object: object) + } + + private static func summarize(_ text: String) -> String? { + let lines = text + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard let last = lines.last else { return nil } + let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized } } -#endif diff --git a/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift b/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift index 9dc71d690..234418128 100644 --- a/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift +++ b/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift @@ -140,7 +140,7 @@ final class GatewayProcessManager { func refreshLog() { guard self.logRefreshTask == nil else { return } - let path = LogLocator.launchdGatewayLogPath + let path = GatewayLaunchAgentManager.launchdGatewayLogPath() let limit = self.logLimit self.logRefreshTask = Task { [weak self] in let log = await Task.detached(priority: .utility) { @@ -354,7 +354,7 @@ final class GatewayProcessManager { func clearLog() { self.log = "" - try? FileManager.default.removeItem(atPath: LogLocator.launchdGatewayLogPath) + try? FileManager.default.removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath()) self.logger.debug("gateway log cleared") } diff --git a/apps/macos/Sources/Clawdbot/Launchctl.swift b/apps/macos/Sources/Clawdbot/Launchctl.swift index ba52bb96b..74690f7b9 100644 --- a/apps/macos/Sources/Clawdbot/Launchctl.swift +++ b/apps/macos/Sources/Clawdbot/Launchctl.swift @@ -31,6 +31,8 @@ enum Launchctl { struct LaunchAgentPlistSnapshot: Equatable, Sendable { let programArguments: [String] let environment: [String: String] + let stdoutPath: String? + let stderrPath: String? let port: Int? let bind: String? @@ -53,6 +55,10 @@ enum LaunchAgentPlist { guard let root = rootAny as? [String: Any] else { return nil } let programArguments = root["ProgramArguments"] as? [String] ?? [] let env = root["EnvironmentVariables"] as? [String: String] ?? [:] + let stdoutPath = (root["StandardOutPath"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty + let stderrPath = (root["StandardErrorPath"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty let port = Self.extractFlagInt(programArguments, flag: "--port") let bind = Self.extractFlagString(programArguments, flag: "--bind")?.lowercased() let token = env["CLAWDBOT_GATEWAY_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty @@ -60,6 +66,8 @@ enum LaunchAgentPlist { return LaunchAgentPlistSnapshot( programArguments: programArguments, environment: env, + stdoutPath: stdoutPath, + stderrPath: stderrPath, port: port, bind: bind, token: token, diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayEndpointStoreTests.swift b/apps/macos/Tests/ClawdbotIPCTests/GatewayEndpointStoreTests.swift index 2250c413e..dd795f80c 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/GatewayEndpointStoreTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/GatewayEndpointStoreTests.swift @@ -7,6 +7,8 @@ import Testing let snapshot = LaunchAgentPlistSnapshot( programArguments: [], environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"], + stdoutPath: nil, + stderrPath: nil, port: nil, bind: nil, token: "launchd-token", @@ -31,6 +33,8 @@ import Testing let snapshot = LaunchAgentPlistSnapshot( programArguments: [], environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"], + stdoutPath: nil, + stderrPath: nil, port: nil, bind: nil, token: "launchd-token", @@ -48,6 +52,8 @@ import Testing let snapshot = LaunchAgentPlistSnapshot( programArguments: [], environment: ["CLAWDBOT_GATEWAY_PASSWORD": "launchd-pass"], + stdoutPath: nil, + stderrPath: nil, port: nil, bind: nil, token: nil, diff --git a/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift b/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift index d120105f1..b68f98cb9 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift @@ -112,20 +112,6 @@ struct LowCoverageHelperTests { _ = PresenceReporter._testPrimaryIPv4Address() } - @Test func gatewayLaunchAgentHelpers() async throws { - await TestIsolation.withEnvValues( - [ - "CLAWDBOT_GATEWAY_BIND": "Lan", - "CLAWDBOT_GATEWAY_TOKEN": " secret ", - ]) { - #expect(GatewayLaunchAgentManager._testPreferredGatewayBind() == "lan") - #expect(GatewayLaunchAgentManager._testPreferredGatewayToken() == "secret") - #expect( - GatewayLaunchAgentManager._testEscapePlistValue("a&b\"'") == - "a&b<c>"'") - } - } - @Test func portGuardianParsesListenersAndBuildsReports() { let output = """ p123 diff --git a/docs/cli/daemon.md b/docs/cli/daemon.md index da6cbf8ca..9de629040 100644 --- a/docs/cli/daemon.md +++ b/docs/cli/daemon.md @@ -15,3 +15,6 @@ Related: Tip: run `clawdbot daemon --help` for platform-specific flags. +Notes: +- `daemon status` supports `--json` for scripting. +- `daemon install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly). diff --git a/docs/cli/index.md b/docs/cli/index.md index 7837d4fe9..4c9bf67b6 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -594,8 +594,9 @@ Notes: - `daemon status` supports `--no-probe`, `--deep`, and `--json` for scripting. - `daemon status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named Clawdbot services are treated as first-class and aren't flagged as "extra". - `daemon status` prints which config path the CLI uses vs which config the daemon likely uses (service env), plus the resolved probe target URL. +- `daemon install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly). - `daemon install` defaults to Node runtime; bun is **not recommended** (WhatsApp/Telegram bugs). -- `daemon install` options: `--port`, `--runtime`, `--token`, `--force`. +- `daemon install` options: `--port`, `--runtime`, `--token`, `--force`, `--json`. ### `logs` Tail Gateway file logs via RPC. diff --git a/src/cli/daemon-cli.coverage.test.ts b/src/cli/daemon-cli.coverage.test.ts index e2eaff48e..891e0561f 100644 --- a/src/cli/daemon-cli.coverage.test.ts +++ b/src/cli/daemon-cli.coverage.test.ts @@ -209,6 +209,28 @@ describe("daemon-cli coverage", () => { expect(serviceInstall).toHaveBeenCalledTimes(1); }); + it("installs the daemon with json output", async () => { + runtimeLogs.length = 0; + runtimeErrors.length = 0; + serviceIsLoaded.mockResolvedValueOnce(false); + serviceInstall.mockClear(); + + const { registerDaemonCli } = await import("./daemon-cli.js"); + const program = new Command(); + program.exitOverride(); + registerDaemonCli(program); + + await program.parseAsync(["daemon", "install", "--port", "18789", "--json"], { + from: "user", + }); + + const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{")); + const parsed = JSON.parse(jsonLine ?? "{}") as { ok?: boolean; action?: string; result?: string }; + expect(parsed.ok).toBe(true); + expect(parsed.action).toBe("install"); + expect(parsed.result).toBe("installed"); + }); + it("starts and stops the daemon via service helpers", async () => { serviceRestart.mockClear(); serviceStop.mockClear(); @@ -225,4 +247,25 @@ describe("daemon-cli coverage", () => { expect(serviceRestart).toHaveBeenCalledTimes(1); expect(serviceStop).toHaveBeenCalledTimes(1); }); + + it("emits json for daemon start/stop", async () => { + runtimeLogs.length = 0; + runtimeErrors.length = 0; + serviceRestart.mockClear(); + serviceStop.mockClear(); + serviceIsLoaded.mockResolvedValue(true); + + const { registerDaemonCli } = await import("./daemon-cli.js"); + const program = new Command(); + program.exitOverride(); + registerDaemonCli(program); + + await program.parseAsync(["daemon", "start", "--json"], { from: "user" }); + await program.parseAsync(["daemon", "stop", "--json"], { from: "user" }); + + const jsonLines = runtimeLogs.filter((line) => line.trim().startsWith("{")); + const parsed = jsonLines.map((line) => JSON.parse(line) as { action?: string; ok?: boolean }); + expect(parsed.some((entry) => entry.action === "start" && entry.ok === true)).toBe(true); + expect(parsed.some((entry) => entry.action === "stop" && entry.ok === true)).toBe(true); + }); }); diff --git a/src/cli/daemon-cli/install.ts b/src/cli/daemon-cli/install.ts index 04e497fab..a489af526 100644 --- a/src/cli/daemon-cli/install.ts +++ b/src/cli/daemon-cli/install.ts @@ -15,33 +15,59 @@ import { import { resolveGatewayService } from "../../daemon/service.js"; import { buildServiceEnvironment } from "../../daemon/service-env.js"; import { defaultRuntime } from "../../runtime.js"; +import { buildDaemonServiceSnapshot, createNullWriter, emitDaemonActionJson } from "./response.js"; import { parsePort } from "./shared.js"; import type { DaemonInstallOptions } from "./types.js"; export async function runDaemonInstall(opts: DaemonInstallOptions) { - if (resolveIsNixMode(process.env)) { - defaultRuntime.error("Nix mode detected; daemon install is disabled."); + const json = Boolean(opts.json); + const warnings: string[] = []; + const stdout = json ? createNullWriter() : process.stdout; + const emit = (payload: { + ok: boolean; + result?: string; + message?: string; + error?: string; + service?: { + label: string; + loaded: boolean; + loadedText: string; + notLoadedText: string; + }; + hints?: string[]; + warnings?: string[]; + }) => { + if (!json) return; + emitDaemonActionJson({ action: "install", ...payload }); + }; + const fail = (message: string) => { + if (json) { + emit({ ok: false, error: message, warnings: warnings.length ? warnings : undefined }); + } else { + defaultRuntime.error(message); + } defaultRuntime.exit(1); + }; + + if (resolveIsNixMode(process.env)) { + fail("Nix mode detected; daemon install is disabled."); return; } const cfg = loadConfig(); const portOverride = parsePort(opts.port); if (opts.port !== undefined && portOverride === null) { - defaultRuntime.error("Invalid port"); - defaultRuntime.exit(1); + fail("Invalid port"); return; } const port = portOverride ?? resolveGatewayPort(cfg); if (!Number.isFinite(port) || port <= 0) { - defaultRuntime.error("Invalid port"); - defaultRuntime.exit(1); + fail("Invalid port"); return; } const runtimeRaw = opts.runtime ? String(opts.runtime) : DEFAULT_GATEWAY_DAEMON_RUNTIME; if (!isGatewayDaemonRuntime(runtimeRaw)) { - defaultRuntime.error('Invalid --runtime (use "node" or "bun")'); - defaultRuntime.exit(1); + fail('Invalid --runtime (use "node" or "bun")'); return; } @@ -50,14 +76,22 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { try { loaded = await service.isLoaded({ env: process.env }); } catch (err) { - defaultRuntime.error(`Gateway service check failed: ${String(err)}`); - defaultRuntime.exit(1); + fail(`Gateway service check failed: ${String(err)}`); return; } if (loaded) { if (!opts.force) { - defaultRuntime.log(`Gateway service already ${service.loadedText}.`); - defaultRuntime.log("Reinstall with: clawdbot daemon install --force"); + emit({ + ok: true, + result: "already-installed", + message: `Gateway service already ${service.loadedText}.`, + service: buildDaemonServiceSnapshot(service, loaded), + warnings: warnings.length ? warnings : undefined, + }); + if (!json) { + defaultRuntime.log(`Gateway service already ${service.loadedText}.`); + defaultRuntime.log("Reinstall with: clawdbot daemon install --force"); + } return; } } @@ -77,7 +111,10 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { if (runtimeRaw === "node") { const systemNode = await resolveSystemNodeInfo({ env: process.env }); const warning = renderSystemNodeWarning(systemNode, programArguments[0]); - if (warning) defaultRuntime.log(warning); + if (warning) { + if (json) warnings.push(warning); + else defaultRuntime.log(warning); + } } const environment = buildServiceEnvironment({ env: process.env, @@ -92,13 +129,26 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { try { await service.install({ env: process.env, - stdout: process.stdout, + stdout, programArguments, workingDirectory, environment, }); } catch (err) { - defaultRuntime.error(`Gateway install failed: ${String(err)}`); - defaultRuntime.exit(1); + fail(`Gateway install failed: ${String(err)}`); + return; } + + let installed = true; + try { + installed = await service.isLoaded({ env: process.env }); + } catch { + installed = true; + } + emit({ + ok: true, + result: "installed", + service: buildDaemonServiceSnapshot(service, installed), + warnings: warnings.length ? warnings : undefined, + }); } diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index 39ae3267e..f249661d6 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -1,72 +1,193 @@ import { resolveIsNixMode } from "../../config/paths.js"; import { resolveGatewayService } from "../../daemon/service.js"; import { defaultRuntime } from "../../runtime.js"; +import { buildDaemonServiceSnapshot, createNullWriter, emitDaemonActionJson } from "./response.js"; import { renderGatewayServiceStartHints } from "./shared.js"; +import type { DaemonLifecycleOptions } from "./types.js"; + +export async function runDaemonUninstall(opts: DaemonLifecycleOptions = {}) { + const json = Boolean(opts.json); + const stdout = json ? createNullWriter() : process.stdout; + const emit = (payload: { + ok: boolean; + result?: string; + message?: string; + error?: string; + service?: { + label: string; + loaded: boolean; + loadedText: string; + notLoadedText: string; + }; + }) => { + if (!json) return; + emitDaemonActionJson({ action: "uninstall", ...payload }); + }; + const fail = (message: string) => { + if (json) emit({ ok: false, error: message }); + else defaultRuntime.error(message); + defaultRuntime.exit(1); + }; -export async function runDaemonUninstall() { if (resolveIsNixMode(process.env)) { - defaultRuntime.error("Nix mode detected; daemon uninstall is disabled."); - defaultRuntime.exit(1); + fail("Nix mode detected; daemon uninstall is disabled."); return; } const service = resolveGatewayService(); try { - await service.uninstall({ env: process.env, stdout: process.stdout }); + await service.uninstall({ env: process.env, stdout }); } catch (err) { - defaultRuntime.error(`Gateway uninstall failed: ${String(err)}`); - defaultRuntime.exit(1); + fail(`Gateway uninstall failed: ${String(err)}`); + return; } + + let loaded = false; + try { + loaded = await service.isLoaded({ env: process.env }); + } catch { + loaded = false; + } + emit({ + ok: true, + result: "uninstalled", + service: buildDaemonServiceSnapshot(service, loaded), + }); } -export async function runDaemonStart() { +export async function runDaemonStart(opts: DaemonLifecycleOptions = {}) { + const json = Boolean(opts.json); + const stdout = json ? createNullWriter() : process.stdout; + const emit = (payload: { + ok: boolean; + result?: string; + message?: string; + error?: string; + hints?: string[]; + service?: { + label: string; + loaded: boolean; + loadedText: string; + notLoadedText: string; + }; + }) => { + if (!json) return; + emitDaemonActionJson({ action: "start", ...payload }); + }; + const fail = (message: string, hints?: string[]) => { + if (json) emit({ ok: false, error: message, hints }); + else defaultRuntime.error(message); + defaultRuntime.exit(1); + }; + const service = resolveGatewayService(); let loaded = false; try { loaded = await service.isLoaded({ env: process.env }); } catch (err) { - defaultRuntime.error(`Gateway service check failed: ${String(err)}`); - defaultRuntime.exit(1); + fail(`Gateway service check failed: ${String(err)}`); return; } if (!loaded) { - defaultRuntime.log(`Gateway service ${service.notLoadedText}.`); - for (const hint of renderGatewayServiceStartHints()) { - defaultRuntime.log(`Start with: ${hint}`); + const hints = renderGatewayServiceStartHints(); + emit({ + ok: true, + result: "not-loaded", + message: `Gateway service ${service.notLoadedText}.`, + hints, + service: buildDaemonServiceSnapshot(service, loaded), + }); + if (!json) { + defaultRuntime.log(`Gateway service ${service.notLoadedText}.`); + for (const hint of hints) { + defaultRuntime.log(`Start with: ${hint}`); + } } return; } try { - await service.restart({ env: process.env, stdout: process.stdout }); + await service.restart({ env: process.env, stdout }); } catch (err) { - defaultRuntime.error(`Gateway start failed: ${String(err)}`); - for (const hint of renderGatewayServiceStartHints()) { - defaultRuntime.error(`Start with: ${hint}`); - } - defaultRuntime.exit(1); + const hints = renderGatewayServiceStartHints(); + fail(`Gateway start failed: ${String(err)}`, hints); + return; } + + let started = true; + try { + started = await service.isLoaded({ env: process.env }); + } catch { + started = true; + } + emit({ + ok: true, + result: "started", + service: buildDaemonServiceSnapshot(service, started), + }); } -export async function runDaemonStop() { +export async function runDaemonStop(opts: DaemonLifecycleOptions = {}) { + const json = Boolean(opts.json); + const stdout = json ? createNullWriter() : process.stdout; + const emit = (payload: { + ok: boolean; + result?: string; + message?: string; + error?: string; + service?: { + label: string; + loaded: boolean; + loadedText: string; + notLoadedText: string; + }; + }) => { + if (!json) return; + emitDaemonActionJson({ action: "stop", ...payload }); + }; + const fail = (message: string) => { + if (json) emit({ ok: false, error: message }); + else defaultRuntime.error(message); + defaultRuntime.exit(1); + }; + const service = resolveGatewayService(); let loaded = false; try { loaded = await service.isLoaded({ env: process.env }); } catch (err) { - defaultRuntime.error(`Gateway service check failed: ${String(err)}`); - defaultRuntime.exit(1); + fail(`Gateway service check failed: ${String(err)}`); return; } if (!loaded) { - defaultRuntime.log(`Gateway service ${service.notLoadedText}.`); + emit({ + ok: true, + result: "not-loaded", + message: `Gateway service ${service.notLoadedText}.`, + service: buildDaemonServiceSnapshot(service, loaded), + }); + if (!json) { + defaultRuntime.log(`Gateway service ${service.notLoadedText}.`); + } return; } try { - await service.stop({ env: process.env, stdout: process.stdout }); + await service.stop({ env: process.env, stdout }); } catch (err) { - defaultRuntime.error(`Gateway stop failed: ${String(err)}`); - defaultRuntime.exit(1); + fail(`Gateway stop failed: ${String(err)}`); + return; } + + let stopped = false; + try { + stopped = await service.isLoaded({ env: process.env }); + } catch { + stopped = false; + } + emit({ + ok: true, + result: "stopped", + service: buildDaemonServiceSnapshot(service, stopped), + }); } /** @@ -74,29 +195,73 @@ export async function runDaemonStop() { * @returns `true` if restart succeeded, `false` if the service was not loaded. * Throws/exits on check or restart failures. */ -export async function runDaemonRestart(): Promise { +export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promise { + const json = Boolean(opts.json); + const stdout = json ? createNullWriter() : process.stdout; + const emit = (payload: { + ok: boolean; + result?: string; + message?: string; + error?: string; + hints?: string[]; + service?: { + label: string; + loaded: boolean; + loadedText: string; + notLoadedText: string; + }; + }) => { + if (!json) return; + emitDaemonActionJson({ action: "restart", ...payload }); + }; + const fail = (message: string, hints?: string[]) => { + if (json) emit({ ok: false, error: message, hints }); + else defaultRuntime.error(message); + defaultRuntime.exit(1); + }; + const service = resolveGatewayService(); let loaded = false; try { loaded = await service.isLoaded({ env: process.env }); } catch (err) { - defaultRuntime.error(`Gateway service check failed: ${String(err)}`); - defaultRuntime.exit(1); + fail(`Gateway service check failed: ${String(err)}`); return false; } if (!loaded) { - defaultRuntime.log(`Gateway service ${service.notLoadedText}.`); - for (const hint of renderGatewayServiceStartHints()) { - defaultRuntime.log(`Start with: ${hint}`); + const hints = renderGatewayServiceStartHints(); + emit({ + ok: true, + result: "not-loaded", + message: `Gateway service ${service.notLoadedText}.`, + hints, + service: buildDaemonServiceSnapshot(service, loaded), + }); + if (!json) { + defaultRuntime.log(`Gateway service ${service.notLoadedText}.`); + for (const hint of hints) { + defaultRuntime.log(`Start with: ${hint}`); + } } return false; } try { - await service.restart({ env: process.env, stdout: process.stdout }); + await service.restart({ env: process.env, stdout }); + let restarted = true; + try { + restarted = await service.isLoaded({ env: process.env }); + } catch { + restarted = true; + } + emit({ + ok: true, + result: "restarted", + service: buildDaemonServiceSnapshot(service, restarted), + }); return true; } catch (err) { - defaultRuntime.error(`Gateway restart failed: ${String(err)}`); - defaultRuntime.exit(1); + const hints = renderGatewayServiceStartHints(); + fail(`Gateway restart failed: ${String(err)}`, hints); return false; } } diff --git a/src/cli/daemon-cli/register.ts b/src/cli/daemon-cli/register.ts index 5a03e7478..ed37dc841 100644 --- a/src/cli/daemon-cli/register.ts +++ b/src/cli/daemon-cli/register.ts @@ -47,6 +47,7 @@ export function registerDaemonCli(program: Command) { .option("--runtime ", "Daemon runtime (node|bun). Default: node") .option("--token ", "Gateway token (token auth)") .option("--force", "Reinstall/overwrite if already installed", false) + .option("--json", "Output JSON", false) .action(async (opts) => { await runDaemonInstall(opts); }); @@ -54,29 +55,33 @@ export function registerDaemonCli(program: Command) { daemon .command("uninstall") .description("Uninstall the Gateway service (launchd/systemd/schtasks)") - .action(async () => { - await runDaemonUninstall(); + .option("--json", "Output JSON", false) + .action(async (opts) => { + await runDaemonUninstall(opts); }); daemon .command("start") .description("Start the Gateway service (launchd/systemd/schtasks)") - .action(async () => { - await runDaemonStart(); + .option("--json", "Output JSON", false) + .action(async (opts) => { + await runDaemonStart(opts); }); daemon .command("stop") .description("Stop the Gateway service (launchd/systemd/schtasks)") - .action(async () => { - await runDaemonStop(); + .option("--json", "Output JSON", false) + .action(async (opts) => { + await runDaemonStop(opts); }); daemon .command("restart") .description("Restart the Gateway service (launchd/systemd/schtasks)") - .action(async () => { - await runDaemonRestart(); + .option("--json", "Output JSON", false) + .action(async (opts) => { + await runDaemonRestart(opts); }); // Build default deps (parity with other commands). diff --git a/src/cli/daemon-cli/response.ts b/src/cli/daemon-cli/response.ts new file mode 100644 index 000000000..98de30c09 --- /dev/null +++ b/src/cli/daemon-cli/response.ts @@ -0,0 +1,43 @@ +import { Writable } from "node:stream"; + +import type { GatewayService } from "../../daemon/service.js"; +import { defaultRuntime } from "../../runtime.js"; + +export type DaemonAction = "install" | "uninstall" | "start" | "stop" | "restart"; + +export type DaemonActionResponse = { + ok: boolean; + action: DaemonAction; + result?: string; + message?: string; + error?: string; + hints?: string[]; + warnings?: string[]; + service?: { + label: string; + loaded: boolean; + loadedText: string; + notLoadedText: string; + }; +}; + +export function emitDaemonActionJson(payload: DaemonActionResponse) { + defaultRuntime.log(JSON.stringify(payload, null, 2)); +} + +export function buildDaemonServiceSnapshot(service: GatewayService, loaded: boolean) { + return { + label: service.label, + loaded, + loadedText: service.loadedText, + notLoadedText: service.notLoadedText, + }; +} + +export function createNullWriter(): Writable { + return new Writable({ + write(_chunk, _encoding, callback) { + callback(); + }, + }); +} diff --git a/src/cli/daemon-cli/types.ts b/src/cli/daemon-cli/types.ts index d0100efba..602d47e9f 100644 --- a/src/cli/daemon-cli/types.ts +++ b/src/cli/daemon-cli/types.ts @@ -19,4 +19,9 @@ export type DaemonInstallOptions = { runtime?: string; token?: string; force?: boolean; + json?: boolean; +}; + +export type DaemonLifecycleOptions = { + json?: boolean; };