offline-cli 0.3.3

The Offline Protocol developer CLI
//! `offline doctor`: environment checks for Offline Protocol development.

use anyhow::Result;
use clap::Args;
use serde::Serialize;
use serde_json::json;

use crate::api::DeveloperClient;
use crate::config::CliConfig;
use crate::output;

#[derive(Args)]
pub struct DoctorArgs {
    /// Skip network checks
    #[arg(long)]
    pub offline: bool,
    /// Output JSON
    #[arg(long)]
    pub json: bool,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Check {
    name: String,
    status: Status,
    detail: String,
}

#[derive(Serialize, PartialEq, Clone, Copy)]
#[serde(rename_all = "lowercase")]
enum Status {
    Ok,
    Warn,
    Fail,
}

struct VersionSpec {
    bin: &'static str,
    args: &'static [&'static str],
    min_major: Option<u32>,
    missing_status: Status,
    hint: &'static str,
}

const VERSION_SPECS: &[VersionSpec] = &[
    VersionSpec {
        bin: "node",
        args: &["--version"],
        min_major: Some(18),
        missing_status: Status::Fail,
        hint: "Node.js >= 18 is required for React Native and Next.js projects",
    },
    VersionSpec {
        bin: "npm",
        args: &["--version"],
        min_major: None,
        missing_status: Status::Fail,
        hint: "npm is required to install SDK packages",
    },
    VersionSpec {
        bin: "git",
        args: &["--version"],
        min_major: None,
        missing_status: Status::Warn,
        hint: "git is recommended for project history",
    },
    VersionSpec {
        bin: "watchman",
        args: &["--version"],
        min_major: None,
        missing_status: Status::Warn,
        hint: "watchman speeds up React Native development (optional)",
    },
    VersionSpec {
        bin: "pod",
        args: &["--version"],
        min_major: None,
        missing_status: Status::Warn,
        hint: "CocoaPods is needed for iOS builds (macOS only)",
    },
    VersionSpec {
        bin: "java",
        args: &["-version"],
        min_major: None,
        missing_status: Status::Warn,
        hint: "a JDK is needed for Android builds",
    },
];

const NOT_LOGGED_IN: &str = "not logged in; run `offline login` (or `offline login --email` \
for `setup`, `orgs`, `apps` and `keys`)";

pub async fn run(args: DoctorArgs) -> Result<()> {
    let mut checks = parallel_version_checks();

    checks.push(
        match std::env::var("ANDROID_HOME").or_else(|_| std::env::var("ANDROID_SDK_ROOT")) {
            Ok(path) if !path.is_empty() => Check {
                name: "android-sdk".into(),
                status: Status::Ok,
                detail: path,
            },
            _ => Check {
                name: "android-sdk".into(),
                status: Status::Warn,
                detail: "ANDROID_HOME not set — needed for Android builds".into(),
            },
        },
    );

    checks.push(match offline_registry::Registry::load_default() {
        Ok(r) => Check {
            name: "registry".into(),
            status: Status::Ok,
            detail: format!(
                "{} packages, {} workflows, {} skills, {} templates",
                r.packages().count(),
                r.workflows().count(),
                r.skills().count(),
                r.templates().count()
            ),
        },
        Err(e) => Check {
            name: "registry".into(),
            status: Status::Fail,
            detail: format!("registry failed to load: {e}"),
        },
    });

    let config = CliConfig::load().unwrap_or_default();
    if let Some(key) = config.effective_api_key() {
        if args.offline {
            checks.push(Check {
                name: "auth".into(),
                status: Status::Ok,
                detail: format!(
                    "API key present ({})",
                    config.email.as_deref().unwrap_or("identity unverified")
                ),
            });
        } else {
            checks.push(match DeveloperClient::new(config.effective_api_base()) {
                Ok(client) => match client.cli_me(&key).await {
                    Ok(me) => {
                        let app = me.app_id.as_deref().unwrap_or("(unknown app)");
                        let telemetry = me
                            .product_settings
                            .as_ref()
                            .map(|s| s.mesh_sdk.mesh_telemetry);
                        let detail = match telemetry {
                            Some(true) => {
                                format!("valid key for app {app}; mesh telemetry enabled")
                            }
                            Some(false) => {
                                format!("valid key for app {app}; mesh telemetry disabled")
                            }
                            None => format!("valid key for app {app}"),
                        };
                        Check {
                            name: "auth".into(),
                            status: Status::Ok,
                            detail,
                        }
                    }
                    Err(e) => Check {
                        name: "auth".into(),
                        status: Status::Warn,
                        detail: format!("API key present but invalid: {e:#}"),
                    },
                },
                Err(e) => Check {
                    name: "auth".into(),
                    status: Status::Warn,
                    detail: format!("could not verify API key: {e:#}"),
                },
            });
        }
    } else if config.effective_access_token().is_some() {
        checks.push(Check {
            name: "auth".into(),
            status: Status::Ok,
            detail: format!(
                "developer JWT present ({}) — run `offline setup` for an API key",
                config.email.as_deref().unwrap_or("email unknown")
            ),
        });
    } else {
        checks.push(Check {
            name: "auth".into(),
            status: Status::Warn,
            detail: NOT_LOGGED_IN.into(),
        });
    }

    if !args.offline {
        let base = config.effective_api_base();
        checks.push(match DeveloperClient::new(&base) {
            Ok(client) => match client.health().await {
                Ok(()) => Check {
                    name: "api-gateway".into(),
                    status: Status::Ok,
                    detail: base,
                },
                Err(e) => Check {
                    name: "api-gateway".into(),
                    status: Status::Warn,
                    detail: format!("{base}: {e} (offline development still works)"),
                },
            },
            Err(e) => Check {
                name: "api-gateway".into(),
                status: Status::Warn,
                detail: format!("{base}: {e} (offline development still works)"),
            },
        });
    }

    let failed = checks.iter().any(|c| c.status == Status::Fail);

    if args.json {
        output::json(&json!({
            "ok": !failed,
            "checks": checks,
        }))?;
    } else {
        output::heading("offline doctor");
        for c in &checks {
            let badge = match c.status {
                Status::Ok => console::style("ok ").green(),
                Status::Warn => console::style("warn").yellow(),
                Status::Fail => console::style("FAIL").red().bold(),
            };
            println!("  [{badge}] {:<12} {}", c.name, c.detail);
        }
        println!();
        if failed {
            output::warn("some required checks failed — fix the FAIL items above");
        } else {
            output::success("environment looks good");
        }
    }

    if failed {
        std::process::exit(1);
    }
    Ok(())
}

fn parallel_version_checks() -> Vec<Check> {
    std::thread::scope(|scope| {
        VERSION_SPECS
            .iter()
            .map(|spec| {
                scope.spawn(|| {
                    version_check(
                        spec.bin,
                        spec.args,
                        spec.min_major,
                        spec.missing_status,
                        spec.hint,
                    )
                })
            })
            .map(|handle| handle.join().expect("doctor version check thread"))
            .collect()
    })
}

fn version_check(
    bin: &str,
    args: &[&str],
    min_major: Option<u32>,
    missing_status: Status,
    hint: &str,
) -> Check {
    let result = std::process::Command::new(bin).args(args).output();
    match result {
        Ok(out) if out.status.success() => {
            let stdout = String::from_utf8_lossy(&out.stdout);
            let stderr = String::from_utf8_lossy(&out.stderr);
            let raw = if stdout.trim().is_empty() {
                stderr
            } else {
                stdout
            };
            let version = raw.lines().next().unwrap_or("").trim().to_string();
            if let Some(min) = min_major {
                let major = version
                    .trim_start_matches(|c: char| !c.is_ascii_digit())
                    .split('.')
                    .next()
                    .and_then(|m| m.parse::<u32>().ok());
                if let Some(major) = major {
                    if major < min {
                        return Check {
                            name: bin.into(),
                            status: Status::Fail,
                            detail: format!("{version} — need >= {min}"),
                        };
                    }
                }
            }
            Check {
                name: bin.into(),
                status: Status::Ok,
                detail: version,
            }
        }
        _ => Check {
            name: bin.into(),
            status: missing_status,
            detail: format!("not found — {hint}"),
        },
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn not_logged_in_hint_leads_with_browser_login() {
        assert!(NOT_LOGGED_IN.starts_with("not logged in; run `offline login` ("));
        assert!(NOT_LOGGED_IN.contains("`offline login --email` for `setup`"));
    }
}