Skip to main content

crabtalk_command/service/
mod.rs

1//! Shared system service management (launchd/systemd).
2
3use 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// ── Embedded templates ──────────────────────────────────────────────
12
13#[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
18// ── Service trait ───────────────────────────────────────────────────
19
20/// Trait for external command binaries that run as system services.
21///
22/// Implementors provide metadata; `start`/`stop`/`logs` come free.
23pub trait Service {
24    /// Service name, e.g. "search". Used for log/port files.
25    fn name(&self) -> &str;
26    /// Human description.
27    fn description(&self) -> &str;
28    /// Reverse-DNS label, e.g. "ai.crabtalk.search".
29    fn label(&self) -> &str;
30
31    /// Install and start the service.
32    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    /// Stop and uninstall the service.
39    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    /// View service logs.
47    fn logs(&self, tail_args: &[String]) -> anyhow::Result<()> {
48        view_logs(self.name(), tail_args)
49    }
50}
51
52/// Render the platform-specific service template for a [`Service`] implementor.
53#[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// ── Low-level install/uninstall ─────────────────────────────────────
85
86#[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
102/// View service logs by delegating to `tail`.
103///
104/// `log_name` corresponds to the `{log_name}.log` file under `~/.crabtalk/logs/`.
105/// Extra args (e.g. `-f`, `-n 100`) are passed through to `tail`.
106/// Defaults to `-n 50` if no extra args are given.
107pub 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// ── MCP run helper ──────────────────────────────────────────────────
131
132/// Run an MCP (port-bound) service: bind a TCP listener, write the port file,
133/// and serve the router.
134#[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// ── McpService ──────────────────────────────────────────────────────
150
151/// MCP (port-bound) service. Implementors provide an axum Router.
152#[cfg(feature = "mcp")]
153pub trait McpService: Service {
154    /// Return the axum Router for the MCP server.
155    fn router(&self) -> axum::Router;
156}