feat: system service management (gyandoot service install|remove|status)
- Create systemd + launchd service templates in resources/ - Add gyandoot-core/src/service.rs module with full lifecycle - systemd: user service, daemon-reload, enable, start, stop, disable - launchd: macOS plist, load/unload, start/stop (launchctl) - Add 'gyandoot service install|remove|status' CLI subcommand - Update setup wizard: prompts 'Install system service? [y/N]' - Update install.sh: prompts user for service install after binary - Browser auto-starts at runtime (browser_manager), no separate service needed - Camoufox installed via npm, auto-started by GyanDoot agent loop
This commit is contained in:
@@ -143,6 +143,9 @@ pub enum AgentError {
|
||||
#[error("Agent loop terminated: {0}")]
|
||||
Terminated(String),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
pub mod types;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod service;
|
||||
|
||||
pub use types::*;
|
||||
pub use config::*;
|
||||
|
||||
390
crates/gyandoot-core/src/service.rs
Normal file
390
crates/gyandoot-core/src/service.rs
Normal file
@@ -0,0 +1,390 @@
|
||||
//! Service management for GyanDoot — install, remove, and check system services.
|
||||
//!
|
||||
//! Supports:
|
||||
//! - **Linux**: systemd user services (`systemctl --user`)
|
||||
//! - **macOS**: launchd user agents (`launchctl`)
|
||||
//!
|
||||
//! Templates live in the resources/ directory of the repo and are rendered
|
||||
//! at install time with the actual binary path and home directory.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{AgentError, AgentResult};
|
||||
|
||||
/// Supported init systems.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum InitSystem {
|
||||
Systemd,
|
||||
Launchd,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl InitSystem {
|
||||
/// Detect the current init system from the OS.
|
||||
pub fn detect() -> Self {
|
||||
if cfg!(target_os = "linux") {
|
||||
// Check if systemd is available
|
||||
if std::process::Command::new("systemctl")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.is_ok()
|
||||
{
|
||||
Self::Systemd
|
||||
} else {
|
||||
Self::Unknown
|
||||
}
|
||||
} else if cfg!(target_os = "macos") {
|
||||
Self::Launchd
|
||||
} else {
|
||||
Self::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable name.
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
Self::Systemd => "systemd",
|
||||
Self::Launchd => "launchd",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a service status check.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServiceStatus {
|
||||
pub name: String,
|
||||
pub init_system: String,
|
||||
pub installed: bool,
|
||||
pub running: bool,
|
||||
pub enabled: bool,
|
||||
pub details: String,
|
||||
}
|
||||
|
||||
/// Install GyanDoot as a system service.
|
||||
///
|
||||
/// Detects the init system, renders the appropriate template, installs it,
|
||||
/// and starts the service.
|
||||
pub fn install_service() -> AgentResult<ServiceStatus> {
|
||||
let init = InitSystem::detect();
|
||||
info!("Detected init system: {}", init.name());
|
||||
|
||||
match init {
|
||||
InitSystem::Systemd => install_systemd(),
|
||||
InitSystem::Launchd => install_launchd(),
|
||||
InitSystem::Unknown => Err(AgentError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::Unsupported,
|
||||
format!(
|
||||
"No supported init system detected. Supported: systemd (Linux), launchd (macOS).\n\
|
||||
You can still run GyanDoot manually: `gyandoot run` or `gyandoot serve`"
|
||||
),
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the GyanDoot system service (stop + disable + delete).
|
||||
pub fn remove_service() -> AgentResult<()> {
|
||||
let init = InitSystem::detect();
|
||||
info!("Detected init system: {}", init.name());
|
||||
|
||||
match init {
|
||||
InitSystem::Systemd => remove_systemd(),
|
||||
InitSystem::Launchd => remove_launchd(),
|
||||
InitSystem::Unknown => Err(AgentError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::Unsupported,
|
||||
"No supported init system detected.",
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check the status of the GyanDoot service.
|
||||
pub fn check_service() -> AgentResult<ServiceStatus> {
|
||||
let init = InitSystem::detect();
|
||||
info!("Detected init system: {}", init.name());
|
||||
|
||||
match init {
|
||||
InitSystem::Systemd => check_systemd(),
|
||||
InitSystem::Launchd => check_launchd(),
|
||||
InitSystem::Unknown => Ok(ServiceStatus {
|
||||
name: "gyandoot".to_string(),
|
||||
init_system: "unknown".to_string(),
|
||||
installed: false,
|
||||
running: false,
|
||||
enabled: false,
|
||||
details: "Unknown init system. Supported: systemd (Linux), launchd (macOS).".to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// ── systemd ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Get the systemd user service directory.
|
||||
fn systemd_user_dir() -> PathBuf {
|
||||
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
|
||||
PathBuf::from(xdg).join("systemd").join("user")
|
||||
} else if let Some(home) = dirs::home_dir() {
|
||||
home.join(".config").join("systemd").join("user")
|
||||
} else {
|
||||
PathBuf::from("/tmp/gyandoot-systemd-user")
|
||||
}
|
||||
}
|
||||
|
||||
fn install_systemd() -> AgentResult<ServiceStatus> {
|
||||
let home = dirs::home_dir().ok_or_else(|| {
|
||||
AgentError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "Cannot determine home directory"))
|
||||
})?;
|
||||
let binary = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("gyandoot"));
|
||||
let user = std::env::var("USER").unwrap_or_else(|_| "root".to_string());
|
||||
let group = std::env::var("USER").unwrap_or_else(|_| "root".to_string());
|
||||
|
||||
let service_dir = systemd_user_dir();
|
||||
std::fs::create_dir_all(&service_dir).map_err(|e| {
|
||||
AgentError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("Cannot create systemd user dir {}: {}", service_dir.display(), e),
|
||||
))
|
||||
})?;
|
||||
|
||||
let service_path = service_dir.join("gyandoot.service");
|
||||
|
||||
// Render template
|
||||
let template = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../resources/systemd/gyandoot.service"
|
||||
));
|
||||
|
||||
let rendered = template
|
||||
.replace("%BINARY%", &binary.to_string_lossy())
|
||||
.replace("%USER%", &user)
|
||||
.replace("%GROUP%", &group)
|
||||
.replace("%HOME%", &home.to_string_lossy());
|
||||
|
||||
std::fs::write(&service_path, &rendered).map_err(|e| {
|
||||
AgentError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("Cannot write service file {}: {}", service_path.display(), e),
|
||||
))
|
||||
})?;
|
||||
|
||||
info!("Wrote systemd service to {}", service_path.display());
|
||||
|
||||
// Reload systemd user daemon
|
||||
run_cmd("systemctl", &["--user", "daemon-reload"])?;
|
||||
|
||||
// Enable and start
|
||||
run_cmd("systemctl", &["--user", "enable", "gyandoot.service"])?;
|
||||
run_cmd("systemctl", &["--user", "start", "gyandoot.service"])?;
|
||||
|
||||
info!("GyanDoot systemd service installed and started");
|
||||
|
||||
check_systemd()
|
||||
}
|
||||
|
||||
fn remove_systemd() -> AgentResult<()> {
|
||||
// Stop first
|
||||
let _ = run_cmd("systemctl", &["--user", "stop", "gyandoot.service"]);
|
||||
|
||||
// Disable
|
||||
let _ = run_cmd("systemctl", &["--user", "disable", "gyandoot.service"]);
|
||||
|
||||
// Remove file
|
||||
let service_path = systemd_user_dir().join("gyandoot.service");
|
||||
if service_path.exists() {
|
||||
std::fs::remove_file(&service_path).map_err(|e| {
|
||||
AgentError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("Cannot remove {}: {}", service_path.display(), e),
|
||||
))
|
||||
})?;
|
||||
info!("Removed systemd service file: {}", service_path.display());
|
||||
}
|
||||
|
||||
// Reload
|
||||
let _ = run_cmd("systemctl", &["--user", "daemon-reload"]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_systemd() -> AgentResult<ServiceStatus> {
|
||||
let mut details = String::new();
|
||||
|
||||
// Check if unit file exists
|
||||
let service_path = systemd_user_dir().join("gyandoot.service");
|
||||
let installed = service_path.exists();
|
||||
|
||||
let mut running = false;
|
||||
let mut enabled = false;
|
||||
|
||||
if installed {
|
||||
// Check if running
|
||||
if let Ok(out) = run_cmd_for_output("systemctl", &["--user", "is-active", "gyandoot.service"]) {
|
||||
running = out.trim() == "active";
|
||||
details.push_str(&format!("Active: {}", out.trim()));
|
||||
}
|
||||
|
||||
if let Ok(out) = run_cmd_for_output("systemctl", &["--user", "is-enabled", "gyandoot.service"]) {
|
||||
enabled = out.trim() == "enabled";
|
||||
details.push_str(&format!("\nEnabled: {}", out.trim()));
|
||||
}
|
||||
} else {
|
||||
details = "Not installed".to_string();
|
||||
}
|
||||
|
||||
Ok(ServiceStatus {
|
||||
name: "gyandoot".to_string(),
|
||||
init_system: "systemd".to_string(),
|
||||
installed,
|
||||
running,
|
||||
enabled,
|
||||
details,
|
||||
})
|
||||
}
|
||||
|
||||
// ── launchd (macOS) ────────────────────────────────────────────────────
|
||||
|
||||
fn launchd_plist_path() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join("Library").join("LaunchAgents").join("com.gyandoot.agent.plist")
|
||||
} else {
|
||||
PathBuf::from("/tmp/com.gyandoot.agent.plist")
|
||||
}
|
||||
}
|
||||
|
||||
fn install_launchd() -> AgentResult<ServiceStatus> {
|
||||
let home = dirs::home_dir().ok_or_else(|| {
|
||||
AgentError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "Cannot determine home directory"))
|
||||
})?;
|
||||
let binary = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("gyandoot"));
|
||||
|
||||
let launch_agents_dir = home.join("Library").join("LaunchAgents");
|
||||
std::fs::create_dir_all(&launch_agents_dir).map_err(|e| {
|
||||
AgentError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("Cannot create LaunchAgents dir {}: {}", launch_agents_dir.display(), e),
|
||||
))
|
||||
})?;
|
||||
|
||||
let plist_path = launchd_plist_path();
|
||||
|
||||
// Render template
|
||||
let template = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../resources/launchd/com.gyandoot.agent.plist"
|
||||
));
|
||||
|
||||
let rendered = template
|
||||
.replace("%BINARY%", &binary.to_string_lossy())
|
||||
.replace("%HOME%", &home.to_string_lossy());
|
||||
|
||||
std::fs::write(&plist_path, &rendered).map_err(|e| {
|
||||
AgentError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("Cannot write plist {}: {}", plist_path.display(), e),
|
||||
))
|
||||
})?;
|
||||
|
||||
info!("Wrote launchd plist to {}", plist_path.display());
|
||||
|
||||
// Unload first (in case it was already loaded)
|
||||
let _ = run_cmd("launchctl", &["unload", &plist_path.to_string_lossy()]);
|
||||
|
||||
// Load the new plist
|
||||
run_cmd("launchctl", &["load", &plist_path.to_string_lossy()])?;
|
||||
|
||||
// Start
|
||||
run_cmd("launchctl", &["start", "com.gyandoot.agent"])?;
|
||||
|
||||
info!("GyanDoot launchd agent installed and started");
|
||||
|
||||
check_launchd()
|
||||
}
|
||||
|
||||
fn remove_launchd() -> AgentResult<()> {
|
||||
let plist_path = launchd_plist_path();
|
||||
|
||||
// Stop
|
||||
let _ = run_cmd("launchctl", &["stop", "com.gyandoot.agent"]);
|
||||
|
||||
// Unload
|
||||
if plist_path.exists() {
|
||||
let _ = run_cmd("launchctl", &["unload", &plist_path.to_string_lossy()]);
|
||||
std::fs::remove_file(&plist_path).map_err(|e| {
|
||||
AgentError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("Cannot remove plist {}: {}", plist_path.display(), e),
|
||||
))
|
||||
})?;
|
||||
info!("Removed launchd plist: {}", plist_path.display());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_launchd() -> AgentResult<ServiceStatus> {
|
||||
let plist_path = launchd_plist_path();
|
||||
let installed = plist_path.exists();
|
||||
|
||||
let mut running = false;
|
||||
let mut details = String::new();
|
||||
|
||||
if installed {
|
||||
// Check if loaded
|
||||
if let Ok(out) = run_cmd_for_output("launchctl", &["list", "com.gyandoot.agent"]) {
|
||||
running = !out.contains("PID = 0,") && !out.is_empty();
|
||||
details = out;
|
||||
}
|
||||
} else {
|
||||
details = "Not installed".to_string();
|
||||
}
|
||||
|
||||
Ok(ServiceStatus {
|
||||
name: "com.gyandoot.agent".to_string(),
|
||||
init_system: "launchd".to_string(),
|
||||
installed,
|
||||
running,
|
||||
enabled: installed, // launchd agents are "enabled" if loaded
|
||||
details,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Run a command and return stdout as a String.
|
||||
fn run_cmd_for_output(cmd: &str, args: &[&str]) -> Result<String, String> {
|
||||
let output = std::process::Command::new(cmd)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute {}: {}", cmd, e))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
|
||||
if output.status.success() {
|
||||
Ok(stdout)
|
||||
} else {
|
||||
Err(format!("{} failed: {}", cmd, stderr.trim()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a command and check for success.
|
||||
fn run_cmd(cmd: &str, args: &[&str]) -> AgentResult<()> {
|
||||
let status = std::process::Command::new(cmd)
|
||||
.args(args)
|
||||
.status()
|
||||
.map_err(|e| {
|
||||
AgentError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("Failed to execute {}: {}", cmd, e),
|
||||
))
|
||||
})?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AgentError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("{} exited with status {:?}", cmd, status.code()),
|
||||
)))
|
||||
}
|
||||
}
|
||||
@@ -333,6 +333,40 @@ pub fn run_setup_wizard() -> anyhow::Result<Option<WizardResult>> {
|
||||
&format!("Configuration saved to {}", config_path.display()),
|
||||
);
|
||||
|
||||
// Step 7: Optional service install
|
||||
println!();
|
||||
terminal::section_header("System Service");
|
||||
println!(" GyanDoot can run as a system service and auto-start on boot.");
|
||||
println!(" This keeps the gateway server running in the background.");
|
||||
let install_service = terminal::confirm_prompt(
|
||||
"Install GyanDoot system service (auto-start on boot)?",
|
||||
Some(false),
|
||||
);
|
||||
|
||||
if install_service {
|
||||
terminal::show_status(
|
||||
terminal::StatusKind::Info,
|
||||
"Installing system service...",
|
||||
);
|
||||
match gyandoot_core::service::install_service() {
|
||||
Ok(status) => {
|
||||
terminal::show_status(
|
||||
terminal::StatusKind::Success,
|
||||
&format!("Service installed! (init: {})", status.init_system),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
terminal::show_status(
|
||||
terminal::StatusKind::Warning,
|
||||
&format!("Could not install service: {}", e),
|
||||
);
|
||||
println!(" You can install later with: gyandoot service install");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!(" You can install later with: gyandoot service install");
|
||||
}
|
||||
|
||||
Ok(Some(WizardResult {
|
||||
provider: choice,
|
||||
config,
|
||||
|
||||
Reference in New Issue
Block a user