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
16use crate::daemon::protocol::StatusReport;
17
18#[derive(Parser)]
21pub struct DaemonCommand {
22 #[command(subcommand)]
24 pub command: DaemonSubcommands,
25}
26
27#[derive(Subcommand)]
29pub enum DaemonSubcommands {
30 Run(run::RunCommand),
32 Start(start::StartCommand),
34 Stop(stop::StopCommand),
36 Restart(restart::RestartCommand),
38 Status(status::StatusCommand),
40 Logs(logs::LogsCommand),
42 Bridge(bridge::BridgeCommand),
44 Service(service::ServiceCommand),
46}
47
48pub(crate) fn warn_version_mismatch(report: &StatusReport) {
56 if is_daemon_stale(
57 report.version.as_deref(),
58 report.provenance.commit_long.as_deref(),
59 crate::VERSION,
60 crate::build_info::GIT_SHA,
61 ) {
62 let cli = describe_build(crate::VERSION, crate::build_info::GIT_SHA_SHORT);
63 let daemon = describe_build(
64 report.version.as_deref().unwrap_or("unknown"),
65 report.provenance.commit.as_deref(),
66 );
67 eprintln!(
68 "warning: omni-dev CLI {cli} is talking to daemon {daemon}; \
69 run `omni-dev daemon restart` to upgrade the resident daemon"
70 );
71 }
72}
73
74fn is_daemon_stale(
79 daemon_version: Option<&str>,
80 daemon_commit: Option<&str>,
81 cli_version: &str,
82 cli_commit: Option<&str>,
83) -> bool {
84 match (cli_commit, daemon_commit) {
85 (Some(cli), Some(daemon)) => cli != daemon,
88 _ => matches!(daemon_version, Some(v) if v != cli_version),
91 }
92}
93
94fn describe_build(version: &str, short_commit: Option<&str>) -> String {
97 match short_commit {
98 Some(commit) => format!("v{version} ({commit})"),
99 None => format!("v{version}"),
100 }
101}
102
103pub(crate) async fn call_service(
109 socket: &std::path::Path,
110 service: &str,
111 op: &str,
112 payload: serde_json::Value,
113) -> Result<serde_json::Value> {
114 use crate::daemon::client::DaemonClient;
115 use crate::daemon::protocol::DaemonEnvelope;
116
117 let origin = crate::request_log::current_context().invocation_id;
118 let reply = DaemonClient::new(socket)
119 .request(DaemonEnvelope::service(service, op, payload).with_origin(origin))
120 .await?;
121 if reply.ok {
122 Ok(reply.payload)
123 } else {
124 anyhow::bail!(
125 "daemon returned an error: {}",
126 reply.error.as_deref().unwrap_or("unknown error")
127 )
128 }
129}
130
131impl DaemonCommand {
132 pub async fn execute(self) -> Result<()> {
134 match self.command {
135 DaemonSubcommands::Run(cmd) => cmd.execute().await,
136 DaemonSubcommands::Start(cmd) => cmd.execute().await,
137 DaemonSubcommands::Stop(cmd) => cmd.execute().await,
138 DaemonSubcommands::Restart(cmd) => cmd.execute().await,
139 DaemonSubcommands::Status(cmd) => cmd.execute().await,
140 DaemonSubcommands::Logs(cmd) => cmd.execute().await,
141 DaemonSubcommands::Bridge(cmd) => cmd.execute().await,
142 DaemonSubcommands::Service(cmd) => cmd.execute().await,
143 }
144 }
145}
146
147#[cfg(test)]
148#[allow(clippy::unwrap_used, clippy::expect_used)]
149mod tests {
150 use super::*;
151 use std::path::Path;
152
153 #[derive(Parser)]
155 struct Wrapper {
156 #[command(subcommand)]
157 cmd: DaemonSubcommands,
158 }
159
160 fn parse(args: &[&str]) -> DaemonSubcommands {
161 let mut full = vec!["omni-dev"];
162 full.extend_from_slice(args);
163 Wrapper::try_parse_from(full).unwrap().cmd
164 }
165
166 #[test]
167 fn parses_all_subcommands() {
168 assert!(matches!(parse(&["run"]), DaemonSubcommands::Run(_)));
169 assert!(matches!(parse(&["start"]), DaemonSubcommands::Start(_)));
170 assert!(matches!(parse(&["stop"]), DaemonSubcommands::Stop(_)));
171 assert!(matches!(parse(&["restart"]), DaemonSubcommands::Restart(_)));
172 assert!(matches!(parse(&["status"]), DaemonSubcommands::Status(_)));
173 assert!(matches!(parse(&["logs"]), DaemonSubcommands::Logs(_)));
174 assert!(matches!(
175 parse(&["bridge", "status"]),
176 DaemonSubcommands::Bridge(_)
177 ));
178 assert!(matches!(
179 parse(&["service", "browser-bridge", "status"]),
180 DaemonSubcommands::Service(_)
181 ));
182 }
183
184 #[test]
185 fn logs_flags_and_defaults_parse() {
186 let DaemonSubcommands::Logs(cmd) = parse(&["logs"]) else {
187 panic!("expected logs");
188 };
189 assert_eq!(cmd.lines, 200);
190 assert!(!cmd.follow);
191 assert!(cmd.socket.is_none());
192
193 let DaemonSubcommands::Logs(cmd) =
194 parse(&["logs", "-f", "-n", "50", "--socket", "/tmp/d.sock"])
195 else {
196 panic!("expected logs");
197 };
198 assert!(cmd.follow);
199 assert_eq!(cmd.lines, 50);
200 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
201 }
202
203 #[test]
204 fn is_daemon_stale_falls_back_to_version_when_a_commit_is_unknown() {
205 assert!(!is_daemon_stale(Some("1.2.3"), None, "1.2.3", None));
207 assert!(is_daemon_stale(Some("1.2.2"), None, "1.2.3", None));
208 assert!(is_daemon_stale(Some("1.3.0"), None, "1.2.3", None));
209 assert!(!is_daemon_stale(None, None, "1.2.3", None));
211 assert!(is_daemon_stale(Some("1.2.2"), None, "1.2.3", Some("aaaa")));
213 assert!(is_daemon_stale(Some("1.2.2"), Some("bbbb"), "1.2.3", None));
214 }
215
216 #[test]
217 fn is_daemon_stale_compares_on_commit_when_both_are_known() {
218 assert!(is_daemon_stale(
220 Some("1.2.3"),
221 Some("bbbbbbbb"),
222 "1.2.3",
223 Some("aaaaaaaa")
224 ));
225 assert!(!is_daemon_stale(
227 Some("1.2.3"),
228 Some("aaaaaaaa"),
229 "1.2.3",
230 Some("aaaaaaaa")
231 ));
232 }
233
234 #[test]
235 fn services_flag_parses_a_subset_on_every_launcher() {
236 use crate::daemon::DaemonServiceKind;
237
238 let DaemonSubcommands::Run(cmd) = parse(&["run", "--services", "worktrees,sessions"])
239 else {
240 panic!("expected run");
241 };
242 assert_eq!(
243 cmd.services,
244 vec![DaemonServiceKind::Worktrees, DaemonServiceKind::Sessions]
245 );
246
247 let DaemonSubcommands::Start(cmd) = parse(&["start", "--services", "browser-bridge"])
248 else {
249 panic!("expected start");
250 };
251 assert_eq!(cmd.services, vec![DaemonServiceKind::Bridge]);
252
253 let DaemonSubcommands::Restart(cmd) = parse(&["restart", "--services", "snowflake"]) else {
254 panic!("expected restart");
255 };
256 assert_eq!(cmd.services, vec![DaemonServiceKind::Snowflake]);
257 }
258
259 #[test]
260 fn services_flag_defaults_empty_and_rejects_unknown_values() {
261 let DaemonSubcommands::Run(cmd) = parse(&["run"]) else {
262 panic!("expected run");
263 };
264 assert!(cmd.services.is_empty());
265
266 assert!(Wrapper::try_parse_from(["omni-dev", "run", "--services", "bogus"]).is_err());
269 }
270
271 #[test]
272 fn warn_version_mismatch_covers_every_branch_without_panicking() {
273 let mismatch = StatusReport {
277 version: Some("0.0.0-mismatch".to_string()),
278 provenance: crate::build_info::Provenance {
279 commit: Some("deadbeef".to_string()),
280 commit_long: Some("deadbeefdeadbeef".to_string()),
281 ..crate::build_info::Provenance::default()
282 },
283 ..StatusReport::default()
284 };
285 warn_version_mismatch(&mismatch);
286 warn_version_mismatch(&StatusReport::current(vec![]));
287 warn_version_mismatch(&StatusReport::default());
288 }
289
290 #[tokio::test]
291 async fn call_service_returns_the_payload_of_an_ok_reply() {
292 let (_dir, sock, server) = crate::daemon::testutil::fake_daemon_reply(
293 serde_json::json!({ "ok": true, "payload": { "restarted": true } }),
294 );
295 let payload = call_service(&sock, "browser-bridge", "restart", serde_json::Value::Null)
296 .await
297 .unwrap();
298 assert_eq!(payload, serde_json::json!({ "restarted": true }));
299 server.await.unwrap();
300 }
301
302 #[tokio::test]
303 async fn daemon_execute_routes_logs_without_a_daemon() {
304 let dir = tempfile::tempdir().unwrap();
307 let cmd = DaemonCommand {
308 command: DaemonSubcommands::Logs(logs::LogsCommand {
309 socket: Some(dir.path().join("daemon.sock")),
310 lines: 10,
311 follow: false,
312 }),
313 };
314 cmd.execute().await.unwrap();
315 }
316
317 #[tokio::test]
318 async fn daemon_execute_routes_bridge_and_service_over_the_socket() {
319 let (_bdir, bsock, bserver) = crate::daemon::testutil::fake_daemon_reply(
322 serde_json::json!({ "ok": true, "payload": { "restarted": true } }),
323 );
324 DaemonCommand {
325 command: DaemonSubcommands::Bridge(bridge::BridgeCommand {
326 command: bridge::BridgeSubcommands::Restart(bridge::SocketArg {
327 socket: Some(bsock),
328 }),
329 }),
330 }
331 .execute()
332 .await
333 .unwrap();
334 bserver.await.unwrap();
335
336 let (_sdir, ssock, sserver) = crate::daemon::testutil::fake_daemon_reply(
337 serde_json::json!({ "ok": true, "payload": { "connected": false } }),
338 );
339 DaemonCommand {
340 command: DaemonSubcommands::Service(service::ServiceCommand {
341 service: "browser-bridge".to_string(),
342 op: "status".to_string(),
343 payload: None,
344 socket: Some(ssock),
345 }),
346 }
347 .execute()
348 .await
349 .unwrap();
350 sserver.await.unwrap();
351 }
352
353 #[tokio::test]
354 async fn call_service_maps_an_error_reply_to_an_err() {
355 let (_dir, sock, server) = crate::daemon::testutil::fake_daemon_reply(
356 serde_json::json!({ "ok": false, "error": "no connected tab with id 9" }),
357 );
358 let err = call_service(
359 &sock,
360 "browser-bridge",
361 "disconnect-tab",
362 serde_json::Value::Null,
363 )
364 .await
365 .unwrap_err();
366 assert!(
367 err.to_string().contains("no connected tab with id 9"),
368 "{err}"
369 );
370 server.await.unwrap();
371 }
372
373 #[test]
374 fn socket_override_parses() {
375 let DaemonSubcommands::Run(cmd) = parse(&["run", "--socket", "/tmp/x.sock"]) else {
376 panic!("expected run");
377 };
378 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/x.sock")));
379 }
380
381 #[test]
382 fn status_json_flag_parses() {
383 let DaemonSubcommands::Status(cmd) = parse(&["status", "--json"]) else {
384 panic!("expected status");
385 };
386 assert!(cmd.json);
387 }
388
389 #[test]
390 fn socket_defaults_to_none() {
391 let DaemonSubcommands::Status(cmd) = parse(&["status"]) else {
392 panic!("expected status");
393 };
394 assert!(cmd.socket.is_none());
395 assert!(!cmd.json);
396 }
397
398 #[tokio::test]
401 async fn status_dispatch_reports_not_running() {
402 let dir = tempfile::tempdir().unwrap();
403 let socket = dir.path().join("absent.sock");
404 for json in [false, true] {
405 let cmd = DaemonCommand {
406 command: DaemonSubcommands::Status(status::StatusCommand {
407 socket: Some(socket.clone()),
408 output: crate::cli::format::TableOrJson::Table,
409 json,
410 }),
411 };
412 cmd.execute().await.unwrap();
413 }
414 }
415}