devist 0.26.0

Project bootstrap CLI for AI-assisted development. Spin up new projects from templates, manage backends, and keep your codebase comprehensible.
//! macOS launchd integration for `devist worker`.
//!
//! Two responsibilities:
//!
//! 1. **Boot persistence** — `enable()` writes a LaunchAgent plist to
//!    `~/Library/LaunchAgents/dev.webchemist.devist.worker.plist` with
//!    `RunAtLoad=true` + `KeepAlive=true`. `launchctl bootstrap`
//!    activates it for the current GUI session and re-activates on
//!    every login.
//!
//! 2. **Self-restart on update** — when running under launchd, if the
//!    daemon exits cleanly the agent restarts the worker
//!    automatically. Combined with the in-process binary mtime check
//!    (see `check_self_update`), this means `cargo install --force`
//!    or `brew upgrade devist` picks up the new binary within ~30s
//!    without any manual `worker stop && start`.

use anyhow::{anyhow, Context, Result};
use std::fs;
use std::process::Command;
use std::time::{Duration, Instant, SystemTime};

use crate::paths;

pub const LABEL: &str = "dev.webchemist.devist.worker";

/// Full path to the installed devist binary, resolved at call time.
pub fn binary_path() -> Result<String> {
    let exe = std::env::current_exe().context("locate current devist binary")?;
    Ok(exe.to_string_lossy().to_string())
}

/// True if the LaunchAgent plist is present on disk (i.e. user opted
/// in to persistent daemon).
pub fn is_enabled() -> Result<bool> {
    Ok(paths::worker_plist_file()?.exists())
}

/// Render the LaunchAgent plist for the current binary path.
fn render_plist() -> Result<String> {
    let exe = binary_path()?;
    let stdout_log = paths::worker_launchd_stdout_log()?;
    let stderr_log = paths::worker_launchd_stderr_log()?;
    let path_env = std::env::var("PATH").unwrap_or_else(|_| {
        "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin".into()
    });

    Ok(format!(
        r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>{label}</string>
    <key>ProgramArguments</key>
    <array>
        <string>{exe}</string>
        <string>worker</string>
        <string>__run</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>ThrottleInterval</key>
    <integer>10</integer>
    <key>StandardOutPath</key>
    <string>{stdout}</string>
    <key>StandardErrorPath</key>
    <string>{stderr}</string>
    <key>EnvironmentVariables</key>
    <dict>
        <key>PATH</key>
        <string>{path_env}</string>
    </dict>
</dict>
</plist>
"#,
        label = LABEL,
        exe = exe,
        stdout = stdout_log.display(),
        stderr = stderr_log.display(),
        path_env = path_env,
    ))
}

/// Install + activate the LaunchAgent. Idempotent.
pub fn enable() -> Result<()> {
    let plist_path = paths::worker_plist_file()?;
    let agents_dir = paths::launch_agents_dir()?;
    let worker_dir = paths::worker_dir()?;
    fs::create_dir_all(&agents_dir).context("create LaunchAgents directory")?;
    fs::create_dir_all(&worker_dir).context("create worker directory for logs")?;

    let plist_body = render_plist()?;
    fs::write(&plist_path, plist_body)
        .with_context(|| format!("write {}", plist_path.display()))?;

    // Best-effort bootout in case an old agent is still loaded
    // pointing at a different binary path.
    let uid = unsafe { libc::getuid() };
    let domain = format!("gui/{}", uid);
    let _ = Command::new("launchctl")
        .args(["bootout", &domain, &plist_path.to_string_lossy()])
        .output();

    let out = Command::new("launchctl")
        .args(["bootstrap", &domain, &plist_path.to_string_lossy()])
        .output()
        .context("run launchctl bootstrap")?;
    if !out.status.success() {
        let stderr = String::from_utf8_lossy(&out.stderr);
        return Err(anyhow!(
            "launchctl bootstrap failed: {}\nstderr: {}",
            out.status,
            stderr
        ));
    }
    Ok(())
}

/// Deactivate + remove the LaunchAgent. Idempotent.
pub fn disable() -> Result<()> {
    let plist_path = paths::worker_plist_file()?;
    let uid = unsafe { libc::getuid() };
    let domain = format!("gui/{}", uid);

    if plist_path.exists() {
        let _ = Command::new("launchctl")
            .args(["bootout", &domain, &plist_path.to_string_lossy()])
            .output();
        fs::remove_file(&plist_path).with_context(|| format!("remove {}", plist_path.display()))?;
    }
    Ok(())
}

/// Trigger a clean exit so launchd's KeepAlive restarts us with the
/// freshly installed binary. Logs the reason then exits with code 0
/// (launchd's ThrottleInterval ensures we don't busy-loop).
pub fn restart_via_launchd(reason: &str) -> ! {
    eprintln!("[self-restart] {reason} — exiting; launchd will respawn");
    std::process::exit(0);
}

/// Snapshot of the binary's mtime at process start. Used by the daemon
/// loop to detect "the binary was replaced under us" (cargo install
/// --force, brew upgrade) and trigger a self-restart.
pub struct BinaryWatch {
    started: Instant,
    initial_mtime: Option<SystemTime>,
    grace: Duration,
}

impl BinaryWatch {
    pub fn capture() -> Self {
        let initial_mtime = std::env::current_exe()
            .ok()
            .and_then(|p| fs::metadata(p).ok())
            .and_then(|m| m.modified().ok());
        Self {
            started: Instant::now(),
            initial_mtime,
            // Skip checks for the first 60s — avoids racing the
            // installer that may still be writing the file.
            grace: Duration::from_secs(60),
        }
    }

    /// Returns true if the binary's mtime is newer than what we
    /// captured at startup AND the grace period has elapsed.
    pub fn was_updated(&self) -> bool {
        if self.started.elapsed() < self.grace {
            return false;
        }
        let Some(initial) = self.initial_mtime else {
            return false;
        };
        let current = std::env::current_exe()
            .ok()
            .and_then(|p| fs::metadata(p).ok())
            .and_then(|m| m.modified().ok());
        let Some(current) = current else {
            return false;
        };
        current > initial
    }
}