use std::env;
use std::fs;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{bail, Context, Result};
use crate::prompt::confirm;
const LAUNCHD_LABEL: &str = "dev.navi.navi";
const WINDOWS_TASK_NAME: &str = "Navi";
const WINDOWS_LOG_DISPLAY: &str = r"%LOCALAPPDATA%\navi\navi.log";
pub fn install(config_path: &Path, yes: bool) -> Result<()> {
let exe = current_exe_string()?;
match env::consts::OS {
"linux" => install_systemd(&exe, config_path, yes),
"macos" => install_launchd(&exe, config_path, yes),
"windows" => install_task(&exe, config_path, yes),
other => bail!(
"no background-service integration for {other}; run `navi run` under your own supervisor"
),
}
}
pub fn uninstall(yes: bool) -> Result<()> {
match env::consts::OS {
"linux" => uninstall_systemd(yes),
"macos" => uninstall_launchd(yes),
"windows" => uninstall_task(yes),
other => bail!("no background-service integration for {other}"),
}
}
pub fn status() -> Result<()> {
match env::consts::OS {
"linux" => status_systemd(),
"macos" => status_launchd(),
"windows" => status_task(),
other => bail!("no background-service integration for {other}"),
}
}
pub fn offer_after_init(config_path: &Path) -> Result<()> {
if !std::io::stdin().is_terminal() {
return Ok(());
}
let kind = match env::consts::OS {
"linux" => "a systemd user service",
"macos" => "a launchd agent",
"windows" => "a Task Scheduler logon task",
_ => return Ok(()),
};
if confirm(&format!(
"Run navi in the background on login? Installs {kind}. [y/N] "
))? {
install(config_path, true)?;
} else {
println!("Skipped; enable it later with `navi service install`.");
}
Ok(())
}
pub fn restart_after_upgrade(do_restart: bool) -> Result<()> {
match env::consts::OS {
"linux" => restart_systemd(do_restart),
"macos" => restart_launchd(do_restart),
"windows" => restart_task(do_restart),
_ => Ok(()),
}
}
fn restart_systemd(do_restart: bool) -> Result<()> {
let Ok(home) = home_dir() else {
return Ok(());
};
let unit = home.join(".config/systemd/user/navi.service");
if !unit.exists() {
return Ok(());
}
if let Some(exec) = systemd_exec_path(&unit) {
if !exec_is_current(&exec) {
warn_exec_mismatch(&exec);
return Ok(());
}
}
if !do_restart {
println!("service left as-is; restart it with: systemctl --user restart navi.service");
return Ok(());
}
run(Command::new("systemctl").args(["--user", "restart", "navi.service"]))?;
println!("restarted navi.service on the new version");
Ok(())
}
fn restart_launchd(do_restart: bool) -> Result<()> {
let Ok(home) = home_dir() else {
return Ok(());
};
let plist = home
.join("Library/LaunchAgents")
.join(format!("{LAUNCHD_LABEL}.plist"));
if !plist.exists() {
return Ok(());
}
if !do_restart {
println!(
"agent left as-is; reload it: launchctl unload -w \"{p}\" && launchctl load -w \"{p}\"",
p = plist.display()
);
return Ok(());
}
let _ = Command::new("launchctl")
.args(["unload", "-w"])
.arg(&plist)
.status();
run(Command::new("launchctl").args(["load", "-w"]).arg(&plist))?;
println!("restarted the launchd agent on the new version");
Ok(())
}
fn restart_task(do_restart: bool) -> Result<()> {
let exists = Command::new("schtasks")
.args(["/Query", "/TN", WINDOWS_TASK_NAME])
.status()
.map(|s| s.success())
.unwrap_or(false);
if !exists {
return Ok(());
}
if !do_restart {
println!("task left as-is; restart it: schtasks /End /TN {WINDOWS_TASK_NAME} && schtasks /Run /TN {WINDOWS_TASK_NAME}");
return Ok(());
}
let _ = Command::new("schtasks")
.args(["/End", "/TN", WINDOWS_TASK_NAME])
.status();
run(Command::new("schtasks").args(["/Run", "/TN", WINDOWS_TASK_NAME]))?;
println!("restarted the '{WINDOWS_TASK_NAME}' task on the new version");
Ok(())
}
fn systemd_exec_path(unit: &Path) -> Option<PathBuf> {
let text = fs::read_to_string(unit).ok()?;
let value = text
.lines()
.find_map(|l| l.trim().strip_prefix("ExecStart="))?;
Some(PathBuf::from(exec_program(value)))
}
fn exec_program(value: &str) -> String {
let value = value.trim();
if let Some(rest) = value.strip_prefix('"') {
rest.split('"').next().unwrap_or(rest).to_string()
} else {
value.split_whitespace().next().unwrap_or(value).to_string()
}
}
fn current_binary() -> Option<PathBuf> {
let path = env::current_exe().ok()?;
let text = path.to_string_lossy();
let cleaned = text.strip_suffix(" (deleted)").unwrap_or(&text);
Some(PathBuf::from(cleaned))
}
fn exec_is_current(exec: &Path) -> bool {
let Some(current) = current_binary() else {
return true;
};
match (fs::canonicalize(exec), fs::canonicalize(¤t)) {
(Ok(a), Ok(b)) => a == b,
_ => exec == current,
}
}
fn warn_exec_mismatch(exec: &Path) {
let current = current_binary()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "the newly installed binary".into());
println!(
"warning: the service runs {} but the upgrade installed {current}",
exec.display()
);
println!("it will keep running the old binary; regenerate the unit with: navi service install");
}
fn install_systemd(exe: &str, config: &Path, yes: bool) -> Result<()> {
let home = home_dir()?;
let unit_dir = home.join(".config/systemd/user");
let unit_path = unit_dir.join("navi.service");
let env_file = env_file_path(config);
if !approve(
yes,
&format!(
"install systemd user service at {} and enable it? [y/N] ",
unit_path.display()
),
)? {
return Ok(());
}
fs::create_dir_all(&unit_dir)
.with_context(|| format!("failed to create {}", unit_dir.display()))?;
fs::write(&unit_path, systemd_unit(exe, config, &env_file))
.with_context(|| format!("failed to write {}", unit_path.display()))?;
ensure_env_file(&env_file)?;
run(Command::new("systemctl").args(["--user", "daemon-reload"]))?;
run(Command::new("systemctl").args(["--user", "enable", "--now", "navi.service"]))?;
println!("installed and started navi.service");
print_env_hint(&env_file, "systemctl --user restart navi.service");
println!("logs: journalctl --user -u navi -f");
println!("to keep it running while logged out: loginctl enable-linger \"$USER\"");
Ok(())
}
fn uninstall_systemd(yes: bool) -> Result<()> {
let unit_path = home_dir()?.join(".config/systemd/user/navi.service");
if !unit_path.exists() {
println!("no navi.service found at {}", unit_path.display());
return Ok(());
}
if !approve(yes, "stop and remove navi.service? [y/N] ")? {
return Ok(());
}
let _ = Command::new("systemctl")
.args(["--user", "disable", "--now", "navi.service"])
.status();
fs::remove_file(&unit_path)
.with_context(|| format!("failed to remove {}", unit_path.display()))?;
let _ = Command::new("systemctl")
.args(["--user", "daemon-reload"])
.status();
println!("removed {}", unit_path.display());
println!("(the navi.env token file, if any, is left in place)");
Ok(())
}
fn status_systemd() -> Result<()> {
let enabled = capture(Command::new("systemctl").args(["--user", "is-enabled", "navi.service"]));
let active = capture(Command::new("systemctl").args(["--user", "is-active", "navi.service"]));
println!(
"navi.service: {} ({})",
active.as_deref().unwrap_or("unknown"),
enabled.as_deref().unwrap_or("unknown")
);
Ok(())
}
fn systemd_unit(exe: &str, config: &Path, env_file: &Path) -> String {
format!(
"[Unit]\n\
Description=navi: focused PR-review alerts\n\
After=network-online.target\n\
Wants=network-online.target\n\
\n\
[Service]\n\
Type=simple\n\
ExecStart=\"{exe}\" run --config \"{config}\"\n\
EnvironmentFile=-{env_file}\n\
Restart=on-failure\n\
RestartSec=10\n\
\n\
[Install]\n\
WantedBy=default.target\n",
exe = exe,
config = config.display(),
env_file = env_file.display(),
)
}
fn install_launchd(exe: &str, config: &Path, yes: bool) -> Result<()> {
let home = home_dir()?;
let plist_path = home
.join("Library/LaunchAgents")
.join(format!("{LAUNCHD_LABEL}.plist"));
let log = home.join("Library/Logs/navi.log");
let env_file = env_file_path(config);
if !approve(
yes,
&format!(
"install launchd agent at {} and load it? [y/N] ",
plist_path.display()
),
)? {
return Ok(());
}
if let Some(parent) = plist_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
fs::write(&plist_path, launchd_plist(exe, config, &env_file, &log))
.with_context(|| format!("failed to write {}", plist_path.display()))?;
ensure_env_file(&env_file)?;
let _ = Command::new("launchctl")
.args(["unload", "-w"])
.arg(&plist_path)
.status();
run(Command::new("launchctl")
.args(["load", "-w"])
.arg(&plist_path))?;
println!("installed and loaded {LAUNCHD_LABEL}");
print_env_hint(
&env_file,
&format!(
"launchctl unload -w \"{p}\" && launchctl load -w \"{p}\"",
p = plist_path.display()
),
);
println!("logs: tail -f {}", log.display());
Ok(())
}
fn uninstall_launchd(yes: bool) -> Result<()> {
let plist_path = home_dir()?
.join("Library/LaunchAgents")
.join(format!("{LAUNCHD_LABEL}.plist"));
if !plist_path.exists() {
println!("no launchd agent found at {}", plist_path.display());
return Ok(());
}
if !approve(yes, "unload and remove the navi launchd agent? [y/N] ")? {
return Ok(());
}
let _ = Command::new("launchctl")
.args(["unload", "-w"])
.arg(&plist_path)
.status();
fs::remove_file(&plist_path)
.with_context(|| format!("failed to remove {}", plist_path.display()))?;
println!("removed {}", plist_path.display());
println!("(the navi.env token file, if any, is left in place)");
Ok(())
}
fn status_launchd() -> Result<()> {
let loaded = Command::new("launchctl")
.args(["list", LAUNCHD_LABEL])
.status()
.map(|s| s.success())
.unwrap_or(false);
println!(
"{LAUNCHD_LABEL}: {}",
if loaded { "loaded" } else { "not loaded" }
);
Ok(())
}
fn launchd_plist(exe: &str, config: &Path, env_file: &Path, log: &Path) -> String {
let run = format!(
"set -a; [ -f \"{env}\" ] && . \"{env}\"; exec \"{exe}\" run --config \"{config}\"",
env = env_file.display(),
exe = exe,
config = config.display(),
);
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
<plist version=\"1.0\">\n\
<dict>\n\
\x20 <key>Label</key><string>{label}</string>\n\
\x20 <key>ProgramArguments</key>\n\
\x20 <array>\n\
\x20 <string>/bin/sh</string>\n\
\x20 <string>-c</string>\n\
\x20 <string>{run}</string>\n\
\x20 </array>\n\
\x20 <key>RunAtLoad</key><true/>\n\
\x20 <key>KeepAlive</key><true/>\n\
\x20 <key>StandardOutPath</key><string>{log}</string>\n\
\x20 <key>StandardErrorPath</key><string>{log}</string>\n\
</dict>\n\
</plist>\n",
label = LAUNCHD_LABEL,
run = xml_escape(&run),
log = xml_escape(&log.display().to_string()),
)
}
fn install_task(exe: &str, config: &Path, yes: bool) -> Result<()> {
if !approve(
yes,
&format!("register the '{WINDOWS_TASK_NAME}' logon task to run navi at sign-in? [y/N] "),
)? {
return Ok(());
}
run(Command::new("schtasks").args(schtasks_create_args(exe, config)))?;
println!("registered the '{WINDOWS_TASK_NAME}' task; navi will start at your next sign-in");
println!("navi runs hidden (no console window); output goes to {WINDOWS_LOG_DISPLAY}");
println!("start it now with:");
println!(" schtasks /Run /TN {WINDOWS_TASK_NAME}");
println!("the task inherits your user environment, so set tokens persistently:");
println!(" setx NAVI_GITHUB_TOKEN ghp_...");
println!(" setx NAVI_SLACK_TOKEN xoxb-...");
println!("(then sign out and back in so the task picks them up)");
Ok(())
}
fn uninstall_task(yes: bool) -> Result<()> {
if !approve(
yes,
&format!("delete the '{WINDOWS_TASK_NAME}' task? [y/N] "),
)? {
return Ok(());
}
run(Command::new("schtasks").args(["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"]))?;
println!("deleted the '{WINDOWS_TASK_NAME}' task");
Ok(())
}
fn status_task() -> Result<()> {
let found = Command::new("schtasks")
.args(["/Query", "/TN", WINDOWS_TASK_NAME])
.status()
.map(|s| s.success())
.unwrap_or(false);
println!(
"{WINDOWS_TASK_NAME} task: {}",
if found {
"registered"
} else {
"not registered"
}
);
Ok(())
}
fn schtasks_create_args(exe: &str, config: &Path) -> Vec<String> {
let action = format!(
"powershell -WindowStyle Hidden -NoProfile -Command \"$log = Join-Path $env:LOCALAPPDATA 'navi\\navi.log'; New-Item -ItemType Directory -Force -Path (Split-Path $log) | Out-Null; & '{exe}' run --config '{config}' *>> $log\"",
exe = exe,
config = config.display(),
);
vec![
"/Create".into(),
"/TN".into(),
WINDOWS_TASK_NAME.into(),
"/SC".into(),
"ONLOGON".into(),
"/RL".into(),
"LIMITED".into(),
"/F".into(),
"/TR".into(),
action,
]
}
fn current_exe_string() -> Result<String> {
let path = env::current_exe().context("cannot determine the navi binary path")?;
Ok(path.to_string_lossy().into_owned())
}
fn home_dir() -> Result<PathBuf> {
env::var_os("HOME")
.map(PathBuf::from)
.context("cannot locate your home directory; set HOME")
}
fn env_file_path(config: &Path) -> PathBuf {
config
.parent()
.unwrap_or_else(|| Path::new("."))
.join("navi.env")
}
fn ensure_env_file(path: &Path) -> Result<()> {
if path.exists() {
return Ok(());
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
fs::write(path, ENV_TEMPLATE).with_context(|| format!("failed to write {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
.with_context(|| format!("failed to chmod 600 {}", path.display()))?;
}
Ok(())
}
const ENV_TEMPLATE: &str = "# navi service environment (chmod 600). One KEY=value per line.\n\
# Fill in tokens for the sources and destinations you enabled in config.toml.\n\
NAVI_GITHUB_TOKEN=\n\
NAVI_SLACK_TOKEN=\n\
# NAVI_GITLAB_TOKEN=\n\
# NAVI_GITEA_TOKEN=\n\
# NAVI_DISCORD_TOKEN=\n\
# NAVI_EMAIL_PASSWORD=\n";
fn print_env_hint(env_file: &Path, restart_cmd: &str) {
println!(
"put your tokens in {} (already chmod 600):",
env_file.display()
);
println!(" NAVI_GITHUB_TOKEN=ghp_...");
println!(" NAVI_SLACK_TOKEN=xoxb-...");
println!("then restart the service: {restart_cmd}");
}
fn approve(yes: bool, message: &str) -> Result<bool> {
if yes {
return Ok(true);
}
if !std::io::stdin().is_terminal() {
println!("non-interactive; re-run with --yes to install without a prompt");
return Ok(false);
}
let proceed = confirm(message)?;
if !proceed {
println!("cancelled");
}
Ok(proceed)
}
fn run(cmd: &mut Command) -> Result<()> {
let program = cmd.get_program().to_string_lossy().into_owned();
let status = cmd
.status()
.with_context(|| format!("failed to run {program}"))?;
if !status.success() {
bail!("{program} exited with {status}");
}
Ok(())
}
fn capture(cmd: &mut Command) -> Option<String> {
let output = cmd.output().ok()?;
let text = String::from_utf8_lossy(&output.stdout).trim().to_owned();
(!text.is_empty()).then_some(text)
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn systemd_unit_wires_exe_config_and_env_file() {
let unit = systemd_unit(
"/home/me/.cargo/bin/navi",
Path::new("/home/me/.config/navi/config.toml"),
Path::new("/home/me/.config/navi/navi.env"),
);
assert!(unit
.contains("ExecStart=\"/home/me/.cargo/bin/navi\" run --config \"/home/me/.config/navi/config.toml\""));
assert!(unit.contains("EnvironmentFile=-/home/me/.config/navi/navi.env"));
assert!(unit.contains("WantedBy=default.target"));
}
#[test]
fn launchd_plist_sources_env_then_execs_navi() {
let plist = launchd_plist(
"/usr/local/bin/navi",
Path::new("/Users/me/.config/navi/config.toml"),
Path::new("/Users/me/.config/navi/navi.env"),
Path::new("/Users/me/Library/Logs/navi.log"),
);
assert!(plist.contains("<string>dev.navi.navi</string>"));
assert!(plist.contains(". \"/Users/me/.config/navi/navi.env\""));
assert!(plist.contains("exec \"/usr/local/bin/navi\" run"));
assert!(plist.contains("/Users/me/Library/Logs/navi.log"));
}
#[test]
fn schtasks_args_register_a_hidden_logon_task() {
let args = schtasks_create_args(
"C:\\navi.exe",
Path::new("C:\\Users\\me\\navi\\config.toml"),
);
assert_eq!(args[0], "/Create");
assert!(args.iter().any(|a| a == "ONLOGON"));
assert!(args.iter().any(|a| a == WINDOWS_TASK_NAME));
let action = args.last().unwrap();
assert!(action.contains("WindowStyle Hidden"));
assert!(action.contains("C:\\navi.exe"));
assert!(action.contains("*>>"), "output must be redirected to a log");
assert!(action.contains("navi.log"));
assert!(
action.contains("$env:LOCALAPPDATA"),
"log must live under LOCALAPPDATA"
);
}
#[test]
fn env_template_is_chmod_600_and_not_overwritten() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("navi.env");
ensure_env_file(&path).unwrap();
assert!(path.exists());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
fs::write(&path, "NAVI_GITHUB_TOKEN=secret\n").unwrap();
ensure_env_file(&path).unwrap();
assert_eq!(
fs::read_to_string(&path).unwrap(),
"NAVI_GITHUB_TOKEN=secret\n"
);
}
#[test]
fn xml_escape_handles_ampersand_and_angles() {
assert_eq!(xml_escape("a & b < c > d"), "a & b < c > d");
}
#[test]
fn exec_program_extracts_the_binary_path() {
assert_eq!(
exec_program("\"/home/me/.local/bin/navi\" run --config \"/home/me/c.toml\""),
"/home/me/.local/bin/navi"
);
assert_eq!(
exec_program("/home/me/.cargo/bin/navi run"),
"/home/me/.cargo/bin/navi"
);
}
}