invoice_cli/commands/
update.rs1use std::process::Command;
2
3use crate::error::{AppError, Result};
4use crate::output::{print_success, Ctx};
5
6const CRATES_IO_URL: &str = "https://crates.io/api/v1/crates/invoice-cli";
7
8pub fn run(ctx: Ctx, check: bool) -> Result<()> {
9 let current = env!("CARGO_PKG_VERSION");
10 let latest = match fetch_latest_version() {
11 Ok(v) => v,
12 Err(e) => {
13 let payload = serde_json::json!({
16 "current": current,
17 "latest": null,
18 "update_available": false,
19 "note": format!("could not query crates.io: {e}. You may not be on a published release yet."),
20 });
21 print_success(ctx, &payload, |p| {
22 println!("current: {}", p["current"].as_str().unwrap_or("?"));
23 println!("latest: unknown ({})", p["note"].as_str().unwrap_or(""));
24 });
25 return Ok(());
26 }
27 };
28
29 let is_newer = version_newer_than(&latest, current);
30
31 if check {
32 let payload = serde_json::json!({
33 "current": current,
34 "latest": latest,
35 "update_available": is_newer,
36 });
37 print_success(ctx, &payload, |p| {
38 println!("current: {}", p["current"].as_str().unwrap_or("?"));
39 println!("latest: {}", p["latest"].as_str().unwrap_or("?"));
40 if is_newer {
41 println!("update available — run `invoice update` to install");
42 } else {
43 println!("up to date");
44 }
45 });
46 return Ok(());
47 }
48
49 if !is_newer {
50 let payload = serde_json::json!({
51 "current": current,
52 "latest": latest,
53 "updated": false,
54 "note": "already on latest",
55 });
56 print_success(ctx, &payload, |_| println!("already on latest ({})", current));
57 return Ok(());
58 }
59
60 let cmd = install_upgrade_command();
62 eprintln!("upgrading {current} → {latest} via: {}", cmd.join(" "));
63 let status = Command::new(&cmd[0])
64 .args(&cmd[1..])
65 .status()
66 .map_err(|e| AppError::Other(format!("failed to launch upgrader: {e}")))?;
67
68 if !status.success() {
69 return Err(AppError::Other(format!(
70 "upgrade command exited with status {}",
71 status.code().unwrap_or(-1)
72 )));
73 }
74
75 let payload = serde_json::json!({
76 "current": current,
77 "latest": latest,
78 "updated": true,
79 "method": cmd.join(" "),
80 });
81 print_success(ctx, &payload, |_| {
82 println!("upgraded to {latest}. verify with: invoice --version")
83 });
84 Ok(())
85}
86
87fn fetch_latest_version() -> Result<String> {
88 let out = Command::new("curl")
89 .args([
90 "-sSL",
91 "-H",
92 "User-Agent: invoice-cli",
93 "-H",
94 "Accept: application/json",
95 CRATES_IO_URL,
96 ])
97 .output()
98 .map_err(|e| AppError::Other(format!("curl not available: {e}")))?;
99 if !out.status.success() {
100 return Err(AppError::Other(format!(
101 "crates.io query failed (exit {})",
102 out.status.code().unwrap_or(-1)
103 )));
104 }
105 let body: serde_json::Value = serde_json::from_slice(&out.stdout)
106 .map_err(|e| AppError::Other(format!("bad crates.io response: {e}")))?;
107 if let Some(errors) = body.get("errors").and_then(|e| e.as_array()) {
109 let detail = errors
110 .first()
111 .and_then(|e| e.get("detail"))
112 .and_then(|d| d.as_str())
113 .unwrap_or("unknown");
114 return Err(AppError::Other(format!("crates.io: {detail}")));
115 }
116 body.get("crate")
117 .and_then(|c| c.get("max_stable_version"))
118 .and_then(|v| v.as_str())
119 .map(|s| s.to_string())
120 .ok_or_else(|| AppError::Other("crates.io response missing max_stable_version".into()))
121}
122
123fn version_newer_than(a: &str, b: &str) -> bool {
126 let pa = parse_version(a);
127 let pb = parse_version(b);
128 match (pa, pb) {
129 (Some(a), Some(b)) => a > b,
130 _ => false,
131 }
132}
133
134fn parse_version(v: &str) -> Option<(u32, u32, u32)> {
135 let core = v.split(['-', '+']).next()?;
136 let mut parts = core.split('.');
137 Some((
138 parts.next()?.parse().ok()?,
139 parts.next()?.parse().ok()?,
140 parts.next().unwrap_or("0").parse().unwrap_or(0),
141 ))
142}
143
144fn install_upgrade_command() -> Vec<String> {
147 if cfg!(target_os = "macos") && running_under_brew() {
148 return vec![
149 "brew".into(),
150 "upgrade".into(),
151 "199-biotechnologies/tap/invoice".into(),
152 ];
153 }
154 vec![
155 "cargo".into(),
156 "install".into(),
157 "--force".into(),
158 "invoice-cli".into(),
159 ]
160}
161
162fn running_under_brew() -> bool {
163 let exe = match std::env::current_exe() {
164 Ok(p) => p,
165 Err(_) => return false,
166 };
167 let s = exe.to_string_lossy();
168 s.contains("/homebrew/") || s.contains("/Cellar/") || s.contains("/opt/homebrew/")
169}