Skip to main content

omni_dev/cli/
daemon.rs

1//! `omni-dev daemon` — supervise the long-lived daemon and its services.
2
3pub(crate) mod bridge;
4pub(crate) mod control;
5pub(crate) mod logs;
6pub(crate) mod restart;
7pub(crate) mod run;
8pub(crate) mod service;
9pub(crate) mod start;
10pub(crate) mod status;
11pub(crate) mod stop;
12
13use anyhow::Result;
14use clap::{Parser, Subcommand};
15
16/// Daemon: host long-lived services (e.g. the browser bridge) under one
17/// supervised, menu-bar-controllable process.
18#[derive(Parser)]
19pub struct DaemonCommand {
20    /// The daemon subcommand to execute.
21    #[command(subcommand)]
22    pub command: DaemonSubcommands,
23}
24
25/// Daemon subcommands.
26#[derive(Subcommand)]
27pub enum DaemonSubcommands {
28    /// Runs the daemon in the foreground (the process launchd execs).
29    Run(run::RunCommand),
30    /// Starts the daemon in the background.
31    Start(start::StartCommand),
32    /// Stops the running daemon.
33    Stop(stop::StopCommand),
34    /// Restarts the daemon.
35    Restart(restart::RestartCommand),
36    /// Reports daemon and per-service status.
37    Status(status::StatusCommand),
38    /// Reads (and optionally follows) the daemon's log file.
39    Logs(logs::LogsCommand),
40    /// Controls the daemon-hosted browser bridge (restart, disconnect a tab, …).
41    Bridge(bridge::BridgeCommand),
42    /// Sends an arbitrary operation to any daemon service (low-level escape hatch).
43    Service(service::ServiceCommand),
44}
45
46/// Prints a non-fatal warning when the resident daemon's version differs from
47/// this CLI binary's — the client is driving a stale daemon after a binary
48/// upgrade (#1113). A `None` daemon version (a pre-#1113 daemon that advertises
49/// none) is treated as unknown and never warns.
50pub(crate) fn warn_version_mismatch(daemon_version: Option<&str>) {
51    if let Some(daemon) = daemon_version {
52        if is_version_stale(daemon_version, crate::VERSION) {
53            eprintln!(
54                "warning: omni-dev CLI v{} is talking to daemon v{daemon}; \
55                 run `omni-dev daemon restart` to upgrade the resident daemon",
56                crate::VERSION
57            );
58        }
59    }
60}
61
62/// Whether a daemon advertising `daemon_version` is stale relative to a CLI of
63/// `cli_version`. A daemon that advertises no version (`None`) is never stale.
64fn is_version_stale(daemon_version: Option<&str>, cli_version: &str) -> bool {
65    matches!(daemon_version, Some(v) if v != cli_version)
66}
67
68/// Sends one operation to a named daemon service over the control socket and
69/// returns its payload, turning an `ok: false` reply into an error. Stamps the
70/// caller's request-log `invocation_id` so any HTTP the daemon issues while
71/// serving the op correlates back to this invocation (#1198). Shared by the
72/// typed `daemon bridge` command and the generic `daemon service` passthrough.
73pub(crate) async fn call_service(
74    socket: &std::path::Path,
75    service: &str,
76    op: &str,
77    payload: serde_json::Value,
78) -> Result<serde_json::Value> {
79    use crate::daemon::client::DaemonClient;
80    use crate::daemon::protocol::DaemonEnvelope;
81
82    let origin = crate::request_log::current_context().invocation_id;
83    let reply = DaemonClient::new(socket)
84        .request(DaemonEnvelope::service(service, op, payload).with_origin(origin))
85        .await?;
86    if reply.ok {
87        Ok(reply.payload)
88    } else {
89        anyhow::bail!(
90            "daemon returned an error: {}",
91            reply.error.as_deref().unwrap_or("unknown error")
92        )
93    }
94}
95
96impl DaemonCommand {
97    /// Executes the daemon command.
98    pub async fn execute(self) -> Result<()> {
99        match self.command {
100            DaemonSubcommands::Run(cmd) => cmd.execute().await,
101            DaemonSubcommands::Start(cmd) => cmd.execute().await,
102            DaemonSubcommands::Stop(cmd) => cmd.execute().await,
103            DaemonSubcommands::Restart(cmd) => cmd.execute().await,
104            DaemonSubcommands::Status(cmd) => cmd.execute().await,
105            DaemonSubcommands::Logs(cmd) => cmd.execute().await,
106            DaemonSubcommands::Bridge(cmd) => cmd.execute().await,
107            DaemonSubcommands::Service(cmd) => cmd.execute().await,
108        }
109    }
110}
111
112#[cfg(test)]
113#[allow(clippy::unwrap_used, clippy::expect_used)]
114mod tests {
115    use super::*;
116    use std::path::Path;
117
118    /// Mirrors the `omni-dev daemon` argv surface for parse tests.
119    #[derive(Parser)]
120    struct Wrapper {
121        #[command(subcommand)]
122        cmd: DaemonSubcommands,
123    }
124
125    fn parse(args: &[&str]) -> DaemonSubcommands {
126        let mut full = vec!["omni-dev"];
127        full.extend_from_slice(args);
128        Wrapper::try_parse_from(full).unwrap().cmd
129    }
130
131    #[test]
132    fn parses_all_subcommands() {
133        assert!(matches!(parse(&["run"]), DaemonSubcommands::Run(_)));
134        assert!(matches!(parse(&["start"]), DaemonSubcommands::Start(_)));
135        assert!(matches!(parse(&["stop"]), DaemonSubcommands::Stop(_)));
136        assert!(matches!(parse(&["restart"]), DaemonSubcommands::Restart(_)));
137        assert!(matches!(parse(&["status"]), DaemonSubcommands::Status(_)));
138        assert!(matches!(parse(&["logs"]), DaemonSubcommands::Logs(_)));
139        assert!(matches!(
140            parse(&["bridge", "status"]),
141            DaemonSubcommands::Bridge(_)
142        ));
143        assert!(matches!(
144            parse(&["service", "browser-bridge", "status"]),
145            DaemonSubcommands::Service(_)
146        ));
147    }
148
149    #[test]
150    fn logs_flags_and_defaults_parse() {
151        let DaemonSubcommands::Logs(cmd) = parse(&["logs"]) else {
152            panic!("expected logs");
153        };
154        assert_eq!(cmd.lines, 200);
155        assert!(!cmd.follow);
156        assert!(cmd.socket.is_none());
157
158        let DaemonSubcommands::Logs(cmd) =
159            parse(&["logs", "-f", "-n", "50", "--socket", "/tmp/d.sock"])
160        else {
161            panic!("expected logs");
162        };
163        assert!(cmd.follow);
164        assert_eq!(cmd.lines, 50);
165        assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
166    }
167
168    #[test]
169    fn is_version_stale_only_warns_on_a_known_mismatch() {
170        // Same version → not stale.
171        assert!(!is_version_stale(Some("1.2.3"), "1.2.3"));
172        // A different advertised version → stale (either direction).
173        assert!(is_version_stale(Some("1.2.2"), "1.2.3"));
174        assert!(is_version_stale(Some("1.3.0"), "1.2.3"));
175        // A daemon that advertises no version is never treated as stale.
176        assert!(!is_version_stale(None, "1.2.3"));
177    }
178
179    #[test]
180    fn warn_version_mismatch_covers_every_branch_without_panicking() {
181        // Exercises the print branch (a known mismatch) and both no-op branches
182        // (a matching version, and a daemon advertising none). It writes to
183        // stderr, so there is nothing to assert beyond it not panicking.
184        warn_version_mismatch(Some("0.0.0-mismatch"));
185        warn_version_mismatch(Some(crate::VERSION));
186        warn_version_mismatch(None);
187    }
188
189    #[tokio::test]
190    async fn call_service_returns_the_payload_of_an_ok_reply() {
191        let (_dir, sock, server) = crate::daemon::testutil::fake_daemon_reply(
192            serde_json::json!({ "ok": true, "payload": { "restarted": true } }),
193        );
194        let payload = call_service(&sock, "browser-bridge", "restart", serde_json::Value::Null)
195            .await
196            .unwrap();
197        assert_eq!(payload, serde_json::json!({ "restarted": true }));
198        server.await.unwrap();
199    }
200
201    #[tokio::test]
202    async fn daemon_execute_routes_logs_without_a_daemon() {
203        // The `Logs` dispatch arm reads the log file directly (no socket), so a
204        // missing `daemon.log` beside an absent socket is a clean no-op.
205        let dir = tempfile::tempdir().unwrap();
206        let cmd = DaemonCommand {
207            command: DaemonSubcommands::Logs(logs::LogsCommand {
208                socket: Some(dir.path().join("daemon.sock")),
209                lines: 10,
210                follow: false,
211            }),
212        };
213        cmd.execute().await.unwrap();
214    }
215
216    #[tokio::test]
217    async fn daemon_execute_routes_bridge_and_service_over_the_socket() {
218        // The `Bridge` and `Service` dispatch arms both reach a service over the
219        // control socket; a fake daemon acknowledges each.
220        let (_bdir, bsock, bserver) = crate::daemon::testutil::fake_daemon_reply(
221            serde_json::json!({ "ok": true, "payload": { "restarted": true } }),
222        );
223        DaemonCommand {
224            command: DaemonSubcommands::Bridge(bridge::BridgeCommand {
225                command: bridge::BridgeSubcommands::Restart(bridge::SocketArg {
226                    socket: Some(bsock),
227                }),
228            }),
229        }
230        .execute()
231        .await
232        .unwrap();
233        bserver.await.unwrap();
234
235        let (_sdir, ssock, sserver) = crate::daemon::testutil::fake_daemon_reply(
236            serde_json::json!({ "ok": true, "payload": { "connected": false } }),
237        );
238        DaemonCommand {
239            command: DaemonSubcommands::Service(service::ServiceCommand {
240                service: "browser-bridge".to_string(),
241                op: "status".to_string(),
242                payload: None,
243                socket: Some(ssock),
244            }),
245        }
246        .execute()
247        .await
248        .unwrap();
249        sserver.await.unwrap();
250    }
251
252    #[tokio::test]
253    async fn call_service_maps_an_error_reply_to_an_err() {
254        let (_dir, sock, server) = crate::daemon::testutil::fake_daemon_reply(
255            serde_json::json!({ "ok": false, "error": "no connected tab with id 9" }),
256        );
257        let err = call_service(
258            &sock,
259            "browser-bridge",
260            "disconnect-tab",
261            serde_json::Value::Null,
262        )
263        .await
264        .unwrap_err();
265        assert!(
266            err.to_string().contains("no connected tab with id 9"),
267            "{err}"
268        );
269        server.await.unwrap();
270    }
271
272    #[test]
273    fn socket_override_parses() {
274        let DaemonSubcommands::Run(cmd) = parse(&["run", "--socket", "/tmp/x.sock"]) else {
275            panic!("expected run");
276        };
277        assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/x.sock")));
278    }
279
280    #[test]
281    fn status_json_flag_parses() {
282        let DaemonSubcommands::Status(cmd) = parse(&["status", "--json"]) else {
283            panic!("expected status");
284        };
285        assert!(cmd.json);
286    }
287
288    #[test]
289    fn socket_defaults_to_none() {
290        let DaemonSubcommands::Status(cmd) = parse(&["status"]) else {
291            panic!("expected status");
292        };
293        assert!(cmd.socket.is_none());
294        assert!(!cmd.json);
295    }
296
297    /// `daemon status` against a socket with no daemon dispatches through to the
298    /// "not running" path (table and `--json`) without erroring or side effects.
299    #[tokio::test]
300    async fn status_dispatch_reports_not_running() {
301        let dir = tempfile::tempdir().unwrap();
302        let socket = dir.path().join("absent.sock");
303        for json in [false, true] {
304            let cmd = DaemonCommand {
305                command: DaemonSubcommands::Status(status::StatusCommand {
306                    socket: Some(socket.clone()),
307                    output: crate::cli::format::TableOrJson::Table,
308                    json,
309                }),
310            };
311            cmd.execute().await.unwrap();
312        }
313    }
314}