use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallChannel {
Standalone,
Cargo,
Npm,
Bun,
Pnpm,
Brew,
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",
}
}
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",
}
}
pub fn allows_standalone_self_update(self) -> bool {
matches!(self, Self::Standalone)
}
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",
}
}
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,
}
}
}
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(())
}
pub fn detect_install_channel() -> InstallChannel {
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();
if lower.contains("/cellar/sylphx")
|| lower.contains("/homebrew/")
|| lower.contains("/linuxbrew/")
|| lower.contains("homebrew/bin/sylphx")
|| lower.contains("/caskroom/")
{
return InstallChannel::Brew;
}
if lower.contains("/.cargo/bin/sylphx") || lower.ends_with("/cargo/bin/sylphx") {
return InstallChannel::Cargo;
}
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")
{
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;
}
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()
}
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(())
}
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))
}
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"));
}
}