use anyhow::{Context, Result};
use clap::Args;
use serde_json::json;
use crate::output;
pub const NPM_PACKAGE: &str = "@offline-protocol/cli";
const DEFAULT_REGISTRY: &str = "https://registry.npmjs.org";
const REGISTRY_ENV: &str = "OFFLINE_NPM_REGISTRY";
#[derive(Args)]
pub struct UpdateArgs {
#[arg(long)]
pub json: bool,
}
pub fn run(args: UpdateArgs) -> Result<()> {
let current = env!("CARGO_PKG_VERSION");
let registry = registry_base();
let latest = fetch_latest_version(®istry);
let (latest, update_available, fetch_error) = match &latest {
Ok(v) => (Some(v.clone()), is_newer(v, current), None),
Err(e) => (None, false, Some(format!("{e:#}"))),
};
if args.json {
return output::json(&json!({
"current": current,
"latest": latest,
"updateAvailable": update_available,
"package": NPM_PACKAGE,
"installCommand": format!("npm install -g {NPM_PACKAGE}@latest"),
"npxCommand": format!("npx {NPM_PACKAGE}@latest"),
"error": fetch_error,
}));
}
println!("current version: {current}");
match (latest, fetch_error) {
(Some(v), _) if update_available => {
output::warn(&format!("update available: {v}"));
print_upgrade_commands();
}
(Some(v), _) => output::success(&format!("you are on the latest release ({v})")),
(None, err) => {
let detail = err.unwrap_or_else(|| "unknown error".to_string());
output::warn(&format!("could not check for updates: {detail}"));
output::note("Check your network connection, or upgrade directly:");
print_upgrade_commands();
}
}
Ok(())
}
fn print_upgrade_commands() {
output::heading("Update with");
println!(" npm install -g {NPM_PACKAGE}@latest");
println!(" # or run the latest release without installing:");
println!(" npx {NPM_PACKAGE}@latest --version");
}
fn registry_base() -> String {
std::env::var(REGISTRY_ENV)
.ok()
.map(|v| v.trim().trim_end_matches('/').to_string())
.filter(|v| !v.is_empty())
.unwrap_or_else(|| DEFAULT_REGISTRY.to_string())
}
fn dist_tags_url(registry: &str) -> String {
let encoded = NPM_PACKAGE.replace('/', "%2f");
format!("{registry}/-/package/{encoded}/dist-tags")
}
fn fetch_latest_version(registry: &str) -> Result<String> {
let url = dist_tags_url(registry);
let agent = ureq::Agent::new_with_config(
ureq::Agent::config_builder()
.timeout_global(Some(std::time::Duration::from_secs(8)))
.user_agent(concat!("offline-cli/", env!("CARGO_PKG_VERSION")))
.build(),
);
let mut resp = agent
.get(&url)
.header("Accept", "application/json")
.call()
.map_err(|e| match e {
ureq::Error::StatusCode(code) => {
anyhow::anyhow!("npm registry returned HTTP {code} for {NPM_PACKAGE}")
}
other => anyhow::anyhow!("npm registry request failed: {other}"),
})?;
let value: serde_json::Value = resp
.body_mut()
.read_json()
.context("decoding npm dist-tags JSON")?;
parse_latest(&value)
}
fn parse_latest(value: &serde_json::Value) -> Result<String> {
value
.get("latest")
.and_then(|t| t.as_str())
.map(|t| t.trim().trim_start_matches('v').to_string())
.filter(|t| !t.is_empty())
.ok_or_else(|| anyhow::anyhow!("npm registry response has no `latest` dist-tag"))
}
fn is_newer(candidate: &str, current: &str) -> bool {
fn split(v: &str) -> (Vec<u64>, bool) {
let v = v.trim().trim_start_matches('v');
let v = v.split('+').next().unwrap_or(v);
let (core, pre) = match v.split_once('-') {
Some((core, _)) => (core, true),
None => (v, false),
};
let nums = core
.split('.')
.map(|p| p.parse::<u64>().unwrap_or(0))
.collect();
(nums, pre)
}
let (cand, cand_pre) = split(candidate);
let (cur, cur_pre) = split(current);
match cand.cmp(&cur) {
std::cmp::Ordering::Greater => true,
std::cmp::Ordering::Less => false,
std::cmp::Ordering::Equal => cur_pre && !cand_pre,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_comparison() {
assert!(is_newer("0.2.0", "0.1.0"));
assert!(is_newer("1.0.0", "0.9.9"));
assert!(is_newer("0.2.10", "0.2.9"));
assert!(!is_newer("0.1.0", "0.1.0"));
assert!(!is_newer("0.0.9", "0.1.0"));
assert!(is_newer("0.3.0", "0.3.0-beta.1"));
assert!(!is_newer("0.3.0-beta.1", "0.3.0"));
assert!(!is_newer("0.2.3", "0.3.0-beta.1"));
}
#[test]
fn dist_tags_url_encodes_scope() {
assert_eq!(
dist_tags_url("https://registry.npmjs.org"),
"https://registry.npmjs.org/-/package/@offline-protocol%2fcli/dist-tags"
);
}
#[test]
fn parses_latest_tag() {
let v = json!({"latest": "0.2.3", "beta": "0.3.0-beta.1"});
assert_eq!(parse_latest(&v).unwrap(), "0.2.3");
assert!(parse_latest(&json!({"beta": "1.0.0-beta.1"})).is_err());
}
#[test]
fn fetches_from_registry() {
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
let port = server.server_addr().to_ip().unwrap().port();
let handle = std::thread::spawn(move || {
let req = server.recv().unwrap();
let url = req.url().to_string();
let resp = tiny_http::Response::from_string(r#"{"latest":"9.9.9"}"#).with_header(
"Content-Type: application/json"
.parse::<tiny_http::Header>()
.unwrap(),
);
req.respond(resp).unwrap();
url
});
let latest = fetch_latest_version(&format!("http://127.0.0.1:{port}")).unwrap();
assert_eq!(latest, "9.9.9");
assert_eq!(
handle.join().unwrap(),
"/-/package/@offline-protocol%2fcli/dist-tags"
);
}
#[test]
fn registry_error_is_reported_without_repo_hint() {
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
let port = server.server_addr().to_ip().unwrap().port();
std::thread::spawn(move || {
let req = server.recv().unwrap();
req.respond(tiny_http::Response::from_string("{}").with_status_code(503))
.unwrap();
});
let err = fetch_latest_version(&format!("http://127.0.0.1:{port}"))
.unwrap_err()
.to_string();
assert!(err.contains("HTTP 503"), "{err}");
assert!(!err.to_lowercase().contains("github"), "{err}");
}
}