sylphx-cli 0.2.10

Sylphx Platform CLI — dogfoods the Rust Management SDK
//! Install-method detection and channel-native upgrade (ADR-4437).
//!
//! Industry standard (Codex CLI): detect how the binary was installed, then
//! upgrade via that channel's native command. Never invent a second updater
//! story for package-manager installs.

use std::path::{Path, PathBuf};
use std::process::Command;

/// How this `sylphx` binary was installed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallChannel {
    /// curl install-sylphx.sh / standalone layout under ~/.local or /opt.
    Standalone,
    /// `cargo install sylphx-cli`.
    Cargo,
    /// npm global/local adapter (`@sylphx/cli`).
    Npm,
    /// bun global adapter.
    Bun,
    /// pnpm global adapter.
    Pnpm,
    /// Homebrew formula/cask.
    Brew,
    /// Could not classify.
    Unknown,
}

impl InstallChannel {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Standalone => "standalone",
            Self::Cargo => "cargo",
            Self::Npm => "npm",
            Self::Bun => "bun",
            Self::Pnpm => "pnpm",
            Self::Brew => "brew",
            Self::Unknown => "unknown",
        }
    }

    /// Human label for docs (not internal jargon).
    pub fn display_name(self) -> &'static str {
        match self {
            Self::Standalone => "standalone (curl installer)",
            Self::Cargo => "Cargo",
            Self::Npm => "npm",
            Self::Bun => "bun",
            Self::Pnpm => "pnpm",
            Self::Brew => "Homebrew",
            Self::Unknown => "unknown",
        }
    }

    /// Whether default `sylphx update` may replace this executable in place.
    pub fn allows_standalone_self_update(self) -> bool {
        matches!(self, Self::Standalone)
    }

    /// Native upgrade command string (for display and shell).
    pub fn upgrade_command(self) -> &'static str {
        match self {
            Self::Cargo => "cargo install sylphx-cli --locked --force",
            Self::Npm => "npm install -g @sylphx/cli@latest",
            Self::Bun => "bun add -g @sylphx/cli@latest",
            Self::Pnpm => "pnpm add -g @sylphx/cli@latest",
            Self::Brew => "brew upgrade sylphx",
            Self::Standalone => "sylphx update",
            Self::Unknown => "see https://sylphx.com/docs/cli/update",
        }
    }

    /// Command + args for spawning a package-manager upgrade (Codex-style).
    pub fn upgrade_argv(self) -> Option<(&'static str, &'static [&'static str])> {
        match self {
            Self::Cargo => Some(("cargo", &["install", "sylphx-cli", "--locked", "--force"])),
            Self::Npm => Some(("npm", &["install", "-g", "@sylphx/cli@latest"])),
            Self::Bun => Some(("bun", &["add", "-g", "@sylphx/cli@latest"])),
            Self::Pnpm => Some(("pnpm", &["add", "-g", "@sylphx/cli@latest"])),
            Self::Brew => Some(("brew", &["upgrade", "sylphx"])),
            Self::Standalone | Self::Unknown => None,
        }
    }

}

/// Marker written by `install-sylphx.sh` so path heuristics are not required.
pub fn install_method_marker_path() -> Option<PathBuf> {
    dirs::data_local_dir().map(|d| d.join("sylphx").join("install-method"))
}

pub fn read_install_method_marker() -> Option<InstallChannel> {
    let path = install_method_marker_path()?;
    let raw = std::fs::read_to_string(path).ok()?;
    match raw.trim() {
        "standalone" | "release" => Some(InstallChannel::Standalone),
        "cargo" => Some(InstallChannel::Cargo),
        "npm" => Some(InstallChannel::Npm),
        "bun" => Some(InstallChannel::Bun),
        "pnpm" => Some(InstallChannel::Pnpm),
        "brew" => Some(InstallChannel::Brew),
        _ => None,
    }
}

pub fn write_install_method_marker(channel: InstallChannel) -> anyhow::Result<()> {
    let path = install_method_marker_path()
        .ok_or_else(|| anyhow::anyhow!("no data local dir for install-method marker"))?;
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::write(&path, format!("{}\n", channel.as_str()))?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
    }
    Ok(())
}

/// Detect install channel from env overrides, marker file, then executable path.
pub fn detect_install_channel() -> InstallChannel {
    // Explicit operator/agent overrides (Codex-style CODEX_MANAGED_BY_*).
    for (key, ch) in [
        ("SYLPHX_MANAGED_BY_PNPM", InstallChannel::Pnpm),
        ("SYLPHX_MANAGED_BY_NPM", InstallChannel::Npm),
        ("SYLPHX_MANAGED_BY_BUN", InstallChannel::Bun),
        ("SYLPHX_MANAGED_BY_BREW", InstallChannel::Brew),
        ("SYLPHX_MANAGED_BY_CARGO", InstallChannel::Cargo),
        ("SYLPHX_MANAGED_BY_STANDALONE", InstallChannel::Standalone),
    ] {
        if std::env::var_os(key).is_some() {
            return ch;
        }
    }

    if let Some(ch) = read_install_method_marker() {
        return ch;
    }

    let Ok(exe) = std::env::current_exe() else {
        return InstallChannel::Unknown;
    };
    detect_from_path(&exe)
}

pub fn detect_from_path(exe: &Path) -> InstallChannel {
    let path = normalize_path_string(exe);
    let lower = path.to_ascii_lowercase();

    // Homebrew first (symlinks into Cellar).
    if lower.contains("/cellar/sylphx")
        || lower.contains("/homebrew/")
        || lower.contains("/linuxbrew/")
        || lower.contains("homebrew/bin/sylphx")
        || lower.contains("/caskroom/")
    {
        return InstallChannel::Brew;
    }

    // Cargo
    if lower.contains("/.cargo/bin/sylphx") || lower.ends_with("/cargo/bin/sylphx") {
        return InstallChannel::Cargo;
    }

    // pnpm / bun / npm layout (order: more specific first).
    if lower.contains("/.local/share/pnpm/")
        || lower.contains("/pnpm/global/")
        || lower.contains("pnpm") && lower.contains("node_modules") && lower.contains("@sylphx")
    {
        return InstallChannel::Pnpm;
    }
    if lower.contains("/.bun/")
        || lower.contains("bun") && lower.contains("node_modules") && lower.contains("@sylphx")
    {
        // Prefer Bun only when path clearly bun-owned; npm also uses node_modules.
        if lower.contains("/.bun/") || lower.contains("bun-node") {
            return InstallChannel::Bun;
        }
    }
    if lower.contains("node_modules")
        || lower.contains("/.npm/")
        || lower.contains("@sylphx/cli")
        || lower.contains("/npm/_npx/")
    {
        return InstallChannel::Npm;
    }

    // Standalone: common install-script destinations **only when marker would
    // have applied**; path alone is a weak signal (local cargo builds may be
    // copied here). Prefer Standalone for classic installer paths.
    if lower.contains("/.local/bin/sylphx")
        || lower.contains("/usr/local/bin/sylphx")
        || lower.contains("/opt/sylphx/")
    {
        return InstallChannel::Standalone;
    }

    InstallChannel::Unknown
}

fn normalize_path_string(p: &Path) -> String {
    let path: PathBuf = std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
    path.to_string_lossy().into_owned()
}

/// Spawn a package-manager upgrade (npm/brew/cargo/…).
pub fn run_package_manager_upgrade(channel: InstallChannel) -> anyhow::Result<()> {
    let Some((cmd, args)) = channel.upgrade_argv() else {
        anyhow::bail!("no package-manager upgrade for channel {}", channel.as_str());
    };
    let status = Command::new(cmd).args(args).status().map_err(|e| {
        anyhow::anyhow!(
            "failed to run `{cmd}`: {e}\n\
             Install the package manager or upgrade manually:\n  {}",
            channel.upgrade_command()
        )
    })?;
    if !status.success() {
        anyhow::bail!("`{}` failed with {status}", channel.upgrade_command());
    }
    Ok(())
}

/// Compare semver-ish versions (MAJOR.MINOR.PATCH only; pre-release ignored).
pub fn is_outdated(current: &str, latest: &str) -> bool {
    let c = parse_triple(current);
    let l = parse_triple(latest);
    match (c, l) {
        (Some(c), Some(l)) => c < l,
        _ => false,
    }
}

fn parse_triple(v: &str) -> Option<(u64, u64, u64)> {
    let v = v.trim().trim_start_matches('v');
    let mut num = String::new();
    for ch in v.chars() {
        if ch.is_ascii_digit() || ch == '.' {
            num.push(ch);
        } else if !num.is_empty() {
            break;
        }
    }
    let mut parts = num.split('.');
    let major = parts.next()?.parse().ok()?;
    let minor = parts.next()?.parse().ok()?;
    let patch = parts.next()?.parse().ok()?;
    Some((major, minor, patch))
}

/// Fetch latest product version from crates.io (public).
pub async fn fetch_latest_release_version() -> anyhow::Result<String> {
    let client = reqwest::Client::builder()
        .user_agent(format!(
            "sylphx-cli/{} (+https://sylphx.com)",
            env!("CARGO_PKG_VERSION")
        ))
        .redirect(reqwest::redirect::Policy::limited(10))
        .build()?;

    let crates_url = "https://crates.io/api/v1/crates/sylphx-cli";
    let resp = client.get(crates_url).send().await?;
    let status = resp.status();
    if status.is_success() {
        let body: serde_json::Value = resp.json().await?;
        if let Some(v) = body
            .pointer("/crate/max_version")
            .and_then(|x| x.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty())
        {
            return Ok(v.to_string());
        }
        anyhow::bail!("crates.io response missing crate.max_version");
    }

    anyhow::bail!("version lookup failed (crates.io HTTP {status}). Try again later.")
}

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

    #[test]
    fn detects_cargo_npm_brew_standalone() {
        assert_eq!(
            detect_from_path(Path::new("/home/u/.cargo/bin/sylphx")),
            InstallChannel::Cargo
        );
        assert_eq!(
            detect_from_path(Path::new(
                "/home/u/.npm-global/lib/node_modules/@sylphx/cli/binaries/linux-x64/sylphx"
            )),
            InstallChannel::Npm
        );
        assert_eq!(
            detect_from_path(Path::new(
                "/opt/homebrew/Cellar/sylphx/0.2.3/bin/sylphx"
            )),
            InstallChannel::Brew
        );
        assert_eq!(
            detect_from_path(Path::new("/home/u/.local/bin/sylphx")),
            InstallChannel::Standalone
        );
    }

    #[test]
    fn upgrade_argv_for_package_managers() {
        assert!(InstallChannel::Npm.upgrade_argv().is_some());
        assert!(InstallChannel::Cargo.upgrade_argv().is_some());
        assert!(InstallChannel::Brew.upgrade_argv().is_some());
        assert!(InstallChannel::Standalone.upgrade_argv().is_none());
    }

    #[test]
    fn outdated_semver() {
        assert!(is_outdated("0.2.2", "0.2.3"));
        assert!(!is_outdated("0.2.3", "0.2.3"));
        assert!(!is_outdated("0.2.4", "0.2.3"));
        assert!(is_outdated("sylphx 0.1.0", "0.2.0"));
    }

    #[test]
    fn upgrade_commands() {
        assert!(InstallChannel::Cargo
            .upgrade_command()
            .contains("cargo install"));
        assert!(InstallChannel::Brew.upgrade_command().contains("brew upgrade"));
        assert_eq!(InstallChannel::Standalone.upgrade_command(), "sylphx update");
        assert!(InstallChannel::Unknown
            .upgrade_command()
            .contains("sylphx.com/docs/cli"));
    }
}