openlatch-client 0.0.0

The open-source security layer for AI agents — client forwarder
Documentation
/// `openlatch init` command handler.
///
/// Runs the full initialization flow:
/// 1. Detect AI agent (D-01)
/// 2. Regenerate auth token (D-02)
/// 3. Write hooks to settings.json (D-03, D-04)
/// 4. Start the daemon (D-05)
///
/// In JSON mode, emits a single JSON object at the end instead of step-by-step output.
use crate::cli::commands::lifecycle;
use crate::cli::output::{OutputConfig, OutputFormat};
use crate::cli::InitArgs;
use crate::config;
use crate::error::{OlError, ERR_INVALID_CONFIG};
use crate::hooks;
use crate::hooks::DetectedAgent;

/// Run the `openlatch init` command.
///
/// Detects the AI agent, regenerates the auth token, writes hooks, and starts the daemon.
/// Prints step-by-step checkmark output in human mode (D-01 through D-05).
/// In JSON mode, emits a single JSON object.
///
/// # Errors
///
/// Returns an error at the first failing step. No rollback is performed (D-03).
pub fn run_init(args: &InitArgs, output: &OutputConfig) -> Result<(), OlError> {
    let version = env!("CARGO_PKG_VERSION");

    // Step 0: Print version header in human mode
    if output.format == OutputFormat::Human && !output.quiet {
        eprintln!("openlatch v{version}");
    }

    // Ensure the openlatch directory exists
    let ol_dir = config::openlatch_dir();
    std::fs::create_dir_all(&ol_dir).map_err(|e| {
        OlError::new(
            ERR_INVALID_CONFIG,
            format!(
                "Cannot create openlatch directory '{}': {e}",
                ol_dir.display()
            ),
        )
        .with_suggestion("Check that you have write permission to your home directory.")
    })?;
    std::fs::create_dir_all(ol_dir.join("logs")).map_err(|e| {
        OlError::new(
            ERR_INVALID_CONFIG,
            format!("Cannot create logs directory: {e}"),
        )
    })?;

    // Step 1: Detect agent (D-01)
    let agent = match hooks::detect_agent() {
        Ok(a) => {
            let label = agent_label(&a);
            output.print_step(&format!("Detected agent: {label}"));
            a
        }
        Err(e) => {
            output.print_error(&e);
            return Err(e);
        }
    };

    // Step 2: Regenerate token (D-02 — always regenerate)
    // Check if token file already existed to display the right message
    let token_path = ol_dir.join("daemon.token");
    let token_existed = token_path.exists();

    // Always regenerate: write a fresh token
    let new_token = config::generate_token();
    std::fs::write(&token_path, &new_token).map_err(|e| {
        OlError::new(
            ERR_INVALID_CONFIG,
            format!("Cannot write token file '{}': {e}", token_path.display()),
        )
        .with_suggestion("Check that you have write permission to the openlatch directory.")
    })?;

    // SECURITY: Restrict token file to owner only (mode 0600) on Unix.
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let perms = std::fs::Permissions::from_mode(0o600);
        std::fs::set_permissions(&token_path, perms).map_err(|e| {
            OlError::new(
                ERR_INVALID_CONFIG,
                format!("Cannot set permissions on token file: {e}"),
            )
        })?;
    }

    let token_action = if token_existed {
        "(regenerated existing)"
    } else {
        "(new)"
    };
    output.print_step(&format!("Generated auth token {token_action}"));

    // Step 3: Install hooks (D-03, D-04)
    let settings_path = match &agent {
        DetectedAgent::ClaudeCode { settings_path, .. } => settings_path.clone(),
    };

    // Port probing: on first init (no config.toml) or --reconfig, probe 7443-7543
    // for a free port. On normal re-init, use the pinned port from existing config.
    let config_path = config::openlatch_dir().join("config.toml");
    let needs_port_probe = !config_path.exists() || args.reconfig;

    let port = if needs_port_probe {
        if args.reconfig {
            // Stop running daemon before rebinding (if any)
            let _ = lifecycle::run_stop(output);
            // Remove stale port file
            let _ = std::fs::remove_file(config::openlatch_dir().join("daemon.port"));
            // Remove old config so ensure_config writes fresh
            let _ = std::fs::remove_file(&config_path);
        }
        let probed = config::probe_free_port(config::PORT_RANGE_START, config::PORT_RANGE_END)?;
        output.print_substep(&format!("Selected port {probed} (first available)"));
        // Write config.toml with the probed port
        config::ensure_config(probed)?;
        // Write daemon.port file for hook binary discovery
        config::write_port_file(probed)?;
        probed
    } else {
        config::Config::load(None, None, false)?.port
    };

    let cfg = config::Config::load(Some(port), None, false)?;

    let hook_result = match hooks::install_hooks(&agent, cfg.port, &new_token) {
        Ok(r) => r,
        Err(e) => {
            output.print_error(&e);
            return Err(e);
        }
    };

    output.print_step(&format!("Hooks written to {}", settings_path.display()));
    for entry in &hook_result.entries {
        let action_label = match entry.action {
            hooks::HookAction::Added => "added",
            hooks::HookAction::Replaced => "replaced",
        };
        output.print_substep(&format!("{} ({})", entry.event_type, action_label));
    }

    // Step 4: Start daemon (D-05) — skip if --no-start
    let (port, pid) = if args.no_start {
        output.print_step("Skipped daemon start (--no-start)");
        (cfg.port, 0u32)
    } else if args.foreground {
        // Foreground mode: start inline (blocking). We print the step first, then call.
        output.print_step(&format!(
            "Starting daemon on port {} (foreground)",
            cfg.port
        ));
        run_daemon_foreground(cfg.port, &new_token)?;
        (cfg.port, std::process::id())
    } else {
        match lifecycle::spawn_daemon_background(cfg.port, &new_token) {
            Ok(pid) => {
                // Wait up to 5 seconds for /health to return 200
                let _ = wait_for_health(cfg.port, 5);
                output.print_step(&format!("Daemon started on port {} (PID {pid})", cfg.port));
                (cfg.port, pid)
            }
            Err(e) => {
                output.print_error(&e);
                return Err(e);
            }
        }
    };

    // Final line: show where events will appear (D-05, PLAT-02)
    let today = chrono::Local::now().format("%Y-%m-%d");
    let log_path = config::openlatch_dir()
        .join("logs")
        .join(format!("events-{today}.jsonl"));
    if output.format == OutputFormat::Human && !output.quiet {
        eprintln!();
        if args.no_start {
            eprintln!("Setup complete. Run `openlatch start` to launch the daemon.");
        } else {
            eprintln!("Ready. Events will appear in: {}", log_path.display());
        }
    }

    // JSON output mode: emit single JSON object
    if output.format == OutputFormat::Json {
        let hooks_list: Vec<&str> = hook_result
            .entries
            .iter()
            .map(|e| e.event_type.as_str())
            .collect();

        let agent_str = match &agent {
            DetectedAgent::ClaudeCode { .. } => "claude-code",
        };

        let json = serde_json::json!({
            "status": "ok",
            "agent": agent_str,
            "port": port,
            "pid": pid,
            "hooks": hooks_list,
            "log_path": log_path.to_string_lossy(),
            "token_action": token_action,
            "daemon_started": !args.no_start,
        });
        output.print_json(&json);
    }

    Ok(())
}

/// Get a human-readable agent label with path for display.
fn agent_label(agent: &DetectedAgent) -> String {
    match agent {
        DetectedAgent::ClaudeCode { claude_dir, .. } => {
            format!("Claude Code ({})", claude_dir.display())
        }
    }
}

/// Start the daemon in foreground mode (blocking).
///
/// This creates a tokio runtime and starts the daemon server directly.
fn run_daemon_foreground(port: u16, token: &str) -> Result<(), OlError> {
    let mut cfg = config::Config::load(Some(port), None, true)?;
    cfg.foreground = true;

    let rt = tokio::runtime::Runtime::new().map_err(|e| {
        OlError::new(
            ERR_INVALID_CONFIG,
            format!("Failed to create async runtime: {e}"),
        )
    })?;

    let token_owned = token.to_string();
    rt.block_on(async move {
        use crate::envelope;
        use crate::logging;
        use crate::privacy;

        let _guard = logging::daemon_log::init_daemon_logging(&cfg.log_dir);

        if let Ok(deleted) = logging::cleanup_old_logs(&cfg.log_dir, cfg.retention_days) {
            if deleted > 0 {
                tracing::info!(deleted = deleted, "cleaned up old log files");
            }
        }

        privacy::init_filter(&cfg.extra_patterns);

        let pid = std::process::id();

        // Write PID file so status/stop can find us
        let pid_path = config::openlatch_dir().join("daemon.pid");
        if let Err(e) = std::fs::write(&pid_path, pid.to_string()) {
            tracing::warn!(error = %e, "failed to write PID file");
        }

        logging::daemon_log::log_startup(
            env!("CARGO_PKG_VERSION"),
            cfg.port,
            pid,
            envelope::os_string(),
            envelope::arch_string(),
        );

        match crate::daemon::start_server(cfg.clone(), token_owned).await {
            Ok((uptime_secs, events)) => {
                eprintln!(
                    "openlatch daemon stopped \u{2022} uptime {} \u{2022} {} events processed",
                    crate::daemon::format_uptime(uptime_secs),
                    events
                );
            }
            Err(e) => {
                tracing::error!(error = %e, "daemon exited with error");
                eprintln!("Error: daemon exited unexpectedly: {e}");
            }
        }

        // Clean up PID file on exit
        let _ = std::fs::remove_file(&pid_path);
    });

    Ok(())
}

/// Wait for the daemon's /health endpoint to return 200, up to `timeout_secs`.
///
/// Returns `true` if health check passed within the timeout, `false` otherwise.
fn wait_for_health(port: u16, timeout_secs: u64) -> bool {
    let url = format!("http://127.0.0.1:{port}/health");
    let start = std::time::Instant::now();
    let timeout = std::time::Duration::from_secs(timeout_secs);

    while start.elapsed() < timeout {
        if let Ok(resp) = reqwest::blocking::get(&url) {
            if resp.status().is_success() {
                return true;
            }
        }
        std::thread::sleep(std::time::Duration::from_millis(200));
    }
    false
}