crabtalk_command/service/
mod.rs1use std::path::Path;
4use wcore::paths::{CONFIG_DIR, LOGS_DIR, service_log_path, service_port_file};
5
6#[cfg(target_os = "linux")]
7mod linux;
8#[cfg(target_os = "macos")]
9mod macos;
10#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
11mod unknown;
12#[cfg(target_os = "windows")]
13mod windows;
14
15#[cfg(target_os = "macos")]
18pub use macos::{TEMPLATE, install, is_installed, uninstall};
19
20#[cfg(target_os = "linux")]
21pub use linux::{TEMPLATE, install, is_installed, uninstall};
22
23#[cfg(target_os = "windows")]
24pub use windows::{TEMPLATE, install, is_installed, uninstall};
25
26#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
27pub use unknown::{install, is_installed, uninstall};
28
29pub trait Service {
35 fn name(&self) -> &str;
37 fn description(&self) -> &str;
39 fn label(&self) -> &str;
41
42 fn start(&self, force: bool) -> anyhow::Result<()> {
47 if !force && is_installed(self.label()) {
48 println!("{} is already running", self.name());
49 return Ok(());
50 }
51 let binary = std::env::current_exe()?;
52 let rendered = render_service_template(self, &binary);
53 install(&rendered, self.label())
54 }
55
56 fn stop(&self) -> anyhow::Result<()> {
58 uninstall(self.label())?;
59 let _ = std::fs::remove_file(service_port_file(self.name()));
60 Ok(())
61 }
62
63 fn logs(&self, tail_args: &[String]) -> anyhow::Result<()> {
65 view_logs(self.name(), tail_args)
66 }
67}
68
69pub fn verbose_flag(count: u8) -> String {
71 if count == 0 {
72 String::new()
73 } else {
74 format!("-{}", "v".repeat(count as usize))
75 }
76}
77
78#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
80pub fn render_service_template(svc: &(impl Service + ?Sized), binary: &Path) -> String {
81 let path_env = std::env::var("PATH").unwrap_or_default();
82 TEMPLATE
83 .replace("{label}", svc.label())
84 .replace("{description}", svc.description())
85 .replace("{log_name}", svc.name())
86 .replace("{binary}", &binary.display().to_string())
87 .replace("{logs_dir}", &LOGS_DIR.display().to_string())
88 .replace("{config_dir}", &CONFIG_DIR.display().to_string())
89 .replace("{path}", &path_env)
90}
91
92#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
93fn render_service_template(_svc: &(impl Service + ?Sized), _binary: &Path) -> String {
94 String::new()
95}
96
97#[cfg(unix)]
104pub fn view_logs(log_name: &str, tail_args: &[String]) -> anyhow::Result<()> {
105 let path = service_log_path(log_name);
106 if !path.exists() {
107 println!("no logs yet: {}", path.display());
108 return Ok(());
109 }
110
111 let args = if tail_args.is_empty() {
112 vec!["-n".to_owned(), "50".to_owned()]
113 } else {
114 tail_args.to_vec()
115 };
116
117 let status = std::process::Command::new("tail")
118 .args(&args)
119 .arg(&path)
120 .status()
121 .map_err(|e| anyhow::anyhow!("failed to run tail: {e}"))?;
122 if !status.success() {
123 anyhow::bail!("tail exited with {status}");
124 }
125 Ok(())
126}
127
128#[cfg(not(unix))]
129pub fn view_logs(log_name: &str, tail_args: &[String]) -> anyhow::Result<()> {
130 use std::io::{BufRead, BufReader};
131
132 let path = service_log_path(log_name);
133 if !path.exists() {
134 println!("no logs yet: {}", path.display());
135 return Ok(());
136 }
137
138 let n: usize = parse_tail_n(tail_args).unwrap_or(50);
139 let file = std::fs::File::open(&path)
140 .map_err(|e| anyhow::anyhow!("failed to open {}: {e}", path.display()))?;
141 let lines: Vec<String> = BufReader::new(file).lines().collect::<Result<_, _>>()?;
142 let start = lines.len().saturating_sub(n);
143 for line in &lines[start..] {
144 println!("{line}");
145 }
146 Ok(())
147}
148
149#[cfg(not(unix))]
151fn parse_tail_n(args: &[String]) -> Option<usize> {
152 let mut iter = args.iter();
153 while let Some(arg) = iter.next() {
154 if arg == "-n" {
155 return iter.next().and_then(|v| v.parse().ok());
156 }
157 if let Some(n) = arg.strip_prefix("-n") {
158 return n.parse().ok();
159 }
160 }
161 None
162}
163
164#[cfg(feature = "mcp")]
169pub async fn run_mcp(svc: &(impl McpService + Sync)) -> anyhow::Result<()> {
170 use wcore::paths::RUN_DIR;
171 let router = svc.router();
172 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
173 let addr = listener.local_addr()?;
174 std::fs::create_dir_all(&*RUN_DIR)?;
175 std::fs::write(service_port_file(svc.name()), addr.port().to_string())?;
176 eprintln!("MCP server listening on {addr}");
177 axum::serve(listener, router).await?;
178 Ok(())
179}
180
181#[cfg(feature = "mcp")]
185pub trait McpService: Service {
186 fn router(&self) -> axum::Router;
188}