crabtalk_command/service/
mod.rs1use std::path::Path;
4use wcore::paths::{CONFIG_DIR, LOGS_DIR, RUN_DIR};
5
6#[cfg(target_os = "linux")]
7mod linux;
8#[cfg(target_os = "macos")]
9mod macos;
10
11#[cfg(target_os = "macos")]
14const LAUNCHD_TEMPLATE: &str = include_str!("launchd.plist");
15#[cfg(target_os = "linux")]
16const SYSTEMD_TEMPLATE: &str = include_str!("systemd.service");
17
18pub trait Service {
24 fn name(&self) -> &str;
26 fn description(&self) -> &str;
28 fn label(&self) -> &str;
30
31 fn start(&self) -> anyhow::Result<()> {
33 let binary = std::env::current_exe()?;
34 let rendered = render_service_template(self, &binary, self.name());
35 install(&rendered, self.label())
36 }
37
38 fn stop(&self) -> anyhow::Result<()> {
40 uninstall(self.label())?;
41 let port_file = RUN_DIR.join(format!("{}.port", self.name()));
42 let _ = std::fs::remove_file(&port_file);
43 Ok(())
44 }
45
46 fn logs(&self, tail_args: &[String]) -> anyhow::Result<()> {
48 view_logs(self.name(), tail_args)
49 }
50}
51
52#[cfg(any(target_os = "macos", target_os = "linux"))]
54pub fn render_service_template(
55 svc: &(impl Service + ?Sized),
56 binary: &Path,
57 subcommand: &str,
58) -> String {
59 let path_env = std::env::var("PATH").unwrap_or_default();
60 #[cfg(target_os = "macos")]
61 let template = LAUNCHD_TEMPLATE;
62 #[cfg(target_os = "linux")]
63 let template = SYSTEMD_TEMPLATE;
64 template
65 .replace("{label}", svc.label())
66 .replace("{description}", svc.description())
67 .replace("{subcommand}", subcommand)
68 .replace("{log_name}", svc.name())
69 .replace("{binary}", &binary.display().to_string())
70 .replace("{logs_dir}", &LOGS_DIR.display().to_string())
71 .replace("{config_dir}", &CONFIG_DIR.display().to_string())
72 .replace("{path}", &path_env)
73}
74
75#[cfg(not(any(target_os = "macos", target_os = "linux")))]
76pub fn render_service_template(
77 _svc: &(impl Service + ?Sized),
78 _binary: &Path,
79 _subcommand: &str,
80) -> String {
81 String::new()
82}
83
84#[cfg(target_os = "macos")]
87pub use macos::{install, uninstall};
88
89#[cfg(target_os = "linux")]
90pub use linux::{install, uninstall};
91
92#[cfg(not(any(target_os = "macos", target_os = "linux")))]
93pub fn install(_rendered: &str, _label: &str) -> anyhow::Result<()> {
94 anyhow::bail!("service install is only supported on macOS and Linux")
95}
96
97#[cfg(not(any(target_os = "macos", target_os = "linux")))]
98pub fn uninstall(_label: &str) -> anyhow::Result<()> {
99 anyhow::bail!("service uninstall is only supported on macOS and Linux")
100}
101
102pub fn view_logs(log_name: &str, tail_args: &[String]) -> anyhow::Result<()> {
108 let path = LOGS_DIR.join(format!("{log_name}.log"));
109 if !path.exists() {
110 anyhow::bail!("log file not found: {}", path.display());
111 }
112
113 let args = if tail_args.is_empty() {
114 vec!["-n".to_owned(), "50".to_owned()]
115 } else {
116 tail_args.to_vec()
117 };
118
119 let status = std::process::Command::new("tail")
120 .args(&args)
121 .arg(&path)
122 .status()
123 .map_err(|e| anyhow::anyhow!("failed to run tail: {e}"))?;
124 if !status.success() {
125 anyhow::bail!("tail exited with {status}");
126 }
127 Ok(())
128}
129
130#[cfg(feature = "mcp")]
135pub async fn run_mcp(svc: &(impl McpService + Sync)) -> anyhow::Result<()> {
136 let router = svc.router();
137 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
138 let addr = listener.local_addr()?;
139 std::fs::create_dir_all(&*RUN_DIR)?;
140 std::fs::write(
141 RUN_DIR.join(format!("{}.port", svc.name())),
142 addr.port().to_string(),
143 )?;
144 eprintln!("MCP server listening on {addr}");
145 axum::serve(listener, router).await?;
146 Ok(())
147}
148
149#[cfg(feature = "mcp")]
153pub trait McpService: Service {
154 fn router(&self) -> axum::Router;
156}