openlatch-provider 0.2.1

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! Top-level `openlatch-provider update [--check] [--apply] [--yes] [--force-cargo]`.
//!
//! Routes through the daemon's `POST /admin/update` RPC if a running
//! daemon is reachable on `/v1/health`; otherwise falls back to the
//! in-process apply path in [`crate::update::apply_local`]. Daemon-
//! present-but-RPC-failure is fatal (exit 6 / OL-4253) — silently falling
//! through to in-process would race the swap.
//!
//! The daemon RPC implementation itself lands in P3.T2b
//! (`src/runtime/admin.rs`); this command is the user-facing surface for
//! both T2a (in-process apply) and T2b (RPC fallthrough).

use clap::Args;

use crate::cli::GlobalArgs;
use crate::error::{
    OlError, OL_4250_UPDATE_FETCH_FAILED, OL_4253_UPDATE_APPLY_FAILED,
    OL_4258_UPDATE_REFUSED_CARGO_INSTALL, OL_4259_UPDATE_BLOCKED_MIN_SUPPORTED,
};
use crate::update::{
    apply_local, check, ApplyMode, ApplyOptions, ApplyResult, ApplyStage, CheckResult,
};

#[derive(Args, Debug, Clone)]
pub struct UpdateArgs {
    /// Only check for updates; don't apply.
    #[arg(long)]
    pub check: bool,

    /// Apply an available update without prompting.
    #[arg(long)]
    pub apply: bool,

    /// Override the npm registry origin (default: `https://registry.npmjs.org`).
    /// Used by the E2E suite to point at a fake registry served on localhost.
    /// Also picked up from `OPENLATCH_PROVIDER_NPM_REGISTRY`.
    #[arg(long, value_name = "URL")]
    pub registry: Option<String>,

    /// Bypass the cargo-install gate. Dangerous — see
    /// `.claude/rules/auto-update.md` for the rationale on when this is
    /// the right call.
    #[arg(long)]
    pub force_cargo: bool,
}

pub async fn run(g: &GlobalArgs, args: UpdateArgs) -> Result<(), OlError> {
    let registry = args
        .registry
        .clone()
        .or_else(|| std::env::var("OPENLATCH_PROVIDER_NPM_REGISTRY").ok())
        .unwrap_or_else(|| "https://registry.npmjs.org".to_string());
    let current = env!("CARGO_PKG_VERSION").to_string();

    if args.check && args.apply {
        return Err(OlError::new(
            OL_4250_UPDATE_FETCH_FAILED,
            "--check and --apply are mutually exclusive",
        ));
    }

    if !args.check && !args.apply {
        // Default behaviour without a flag: same as `--check`. Keeps the
        // command useful as a one-liner status probe.
        return run_check(g, &current, &registry).await;
    }

    if args.check {
        return run_check(g, &current, &registry).await;
    }

    // --apply
    if !g.yes && !args.force_cargo {
        // We don't ship a TUI prompt here; the contract says `--yes` is
        // required for non-interactive apply (matches openlatch-client).
        return Err(OlError::new(
            OL_4250_UPDATE_FETCH_FAILED,
            "refusing to apply without --yes (non-interactive safety)",
        )
        .with_suggestion("Re-run with: openlatch-provider update --apply --yes"));
    }

    let opts = ApplyOptions {
        current_version: current.clone(),
        registry_origin: registry.clone(),
        download_timeout: std::time::Duration::from_secs(60),
        force_cargo_install: args.force_cargo,
        mode: ApplyMode::InProcess,
    };
    match apply_local(opts).await {
        ApplyResult::Applied { from, to, .. } => {
            if !g.quiet {
                eprintln!("openlatch-provider update applied: {from} -> {to}");
            }
            Ok(())
        }
        ApplyResult::UpToDate { current } => {
            if !g.quiet {
                eprintln!("openlatch-provider already on latest version: {current}");
            }
            Ok(())
        }
        ApplyResult::RefusedCargoInstall { suggestion } => Err(OlError::new(
            OL_4258_UPDATE_REFUSED_CARGO_INSTALL,
            "auto-update refused: cargo-installed binary detected",
        )
        .with_suggestion(suggestion)),
        ApplyResult::Failed { stage, reason } => {
            let code = match stage {
                ApplyStage::Check => OL_4250_UPDATE_FETCH_FAILED,
                _ => OL_4253_UPDATE_APPLY_FAILED,
            };
            // Special-case the min_supported gate so the user gets the
            // right OL-4259 code + recovery hint.
            if reason.contains("min_supported_provider") {
                return Err(OlError::new(OL_4259_UPDATE_BLOCKED_MIN_SUPPORTED, reason));
            }
            Err(OlError::new(
                code,
                format!("apply failed at {}: {reason}", stage.as_str()),
            ))
        }
    }
}

async fn run_check(g: &GlobalArgs, current: &str, registry: &str) -> Result<(), OlError> {
    let result = check(current, registry).await;
    let json = match &result {
        CheckResult::UpToDate { current } => serde_json::json!({
            "current": current,
            "latest": serde_json::Value::Null,
            "severity": serde_json::Value::Null,
            "min_supported_provider": serde_json::Value::Null,
        }),
        CheckResult::Available {
            current,
            latest,
            severity,
            min_supported,
            ..
        } => serde_json::json!({
            "current": current,
            "latest": latest,
            "severity": severity.as_str(),
            "min_supported_provider": min_supported,
        }),
        CheckResult::Failed { reason } => serde_json::json!({
            "current": current,
            "latest": serde_json::Value::Null,
            "severity": serde_json::Value::Null,
            "min_supported_provider": serde_json::Value::Null,
            "error": reason,
        }),
    };
    if !g.quiet {
        println!(
            "{}",
            serde_json::to_string_pretty(&json).unwrap_or_default()
        );
    }
    Ok(())
}