1pub(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#[derive(Parser)]
19pub struct DaemonCommand {
20 #[command(subcommand)]
22 pub command: DaemonSubcommands,
23}
24
25#[derive(Subcommand)]
27pub enum DaemonSubcommands {
28 Run(run::RunCommand),
30 Start(start::StartCommand),
32 Stop(stop::StopCommand),
34 Restart(restart::RestartCommand),
36 Status(status::StatusCommand),
38 Logs(logs::LogsCommand),
40 Bridge(bridge::BridgeCommand),
42 Service(service::ServiceCommand),
44}
45
46pub(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
62fn is_version_stale(daemon_version: Option<&str>, cli_version: &str) -> bool {
65 matches!(daemon_version, Some(v) if v != cli_version)
66}
67
68pub(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 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 #[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 assert!(!is_version_stale(Some("1.2.3"), "1.2.3"));
172 assert!(is_version_stale(Some("1.2.2"), "1.2.3"));
174 assert!(is_version_stale(Some("1.3.0"), "1.2.3"));
175 assert!(!is_version_stale(None, "1.2.3"));
177 }
178
179 #[test]
180 fn warn_version_mismatch_covers_every_branch_without_panicking() {
181 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 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 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 #[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}