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, |_| {
57 println!("already on latest ({})", current)
58 });
59 return Ok(());
60 }
61
62 let cmd = install_upgrade_command();
64 eprintln!("upgrading {current} → {latest} via: {}", cmd.join(" "));
65 let status = Command::new(&cmd[0])
66 .args(&cmd[1..])
67 .status()
68 .map_err(|e| AppError::Other(format!("failed to launch upgrader: {e}")))?;
69
70 if !status.success() {
71 return Err(AppError::Other(format!(
72 "upgrade command exited with status {}",
73 status.code().unwrap_or(-1)
74 )));
75 }
76
77 let payload = serde_json::json!({
78 "current": current,
79 "latest": latest,
80 "updated": true,
81 "method": cmd.join(" "),
82 });
83 print_success(ctx, &payload, |_| {
84 println!("upgraded to {latest}. verify with: invoice --version")
85 });
86 Ok(())
87}
88
89fn fetch_latest_version() -> Result<String> {
90 let out = Command::new("curl")
91 .args([
92 "-sSL",
93 "-H",
94 "User-Agent: invoice-cli",
95 "-H",
96 "Accept: application/json",
97 CRATES_IO_URL,
98 ])
99 .output()
100 .map_err(|e| AppError::Other(format!("curl not available: {e}")))?;
101 if !out.status.success() {
102 return Err(AppError::Other(format!(
103 "crates.io query failed (exit {})",
104 out.status.code().unwrap_or(-1)
105 )));
106 }
107 let body: serde_json::Value = serde_json::from_slice(&out.stdout)
108 .map_err(|e| AppError::Other(format!("bad crates.io response: {e}")))?;
109 if let Some(errors) = body.get("errors").and_then(|e| e.as_array()) {
111 let detail = errors
112 .first()
113 .and_then(|e| e.get("detail"))
114 .and_then(|d| d.as_str())
115 .unwrap_or("unknown");
116 return Err(AppError::Other(format!("crates.io: {detail}")));
117 }
118 body.get("crate")
119 .and_then(|c| c.get("max_stable_version"))
120 .and_then(|v| v.as_str())
121 .map(|s| s.to_string())
122 .ok_or_else(|| AppError::Other("crates.io response missing max_stable_version".into()))
123}
124
125fn version_newer_than(a: &str, b: &str) -> bool {
128 let pa = parse_version(a);
129 let pb = parse_version(b);
130 match (pa, pb) {
131 (Some(a), Some(b)) => a > b,
132 _ => false,
133 }
134}
135
136fn parse_version(v: &str) -> Option<(u32, u32, u32)> {
137 let core = v.split(['-', '+']).next()?;
138 let mut parts = core.split('.');
139 Some((
140 parts.next()?.parse().ok()?,
141 parts.next()?.parse().ok()?,
142 parts.next().unwrap_or("0").parse().unwrap_or(0),
143 ))
144}
145
146fn install_upgrade_command() -> Vec<String> {
149 if cfg!(target_os = "macos") && running_under_brew() {
150 return vec![
151 "brew".into(),
152 "upgrade".into(),
153 "199-biotechnologies/tap/invoice".into(),
154 ];
155 }
156 vec![
157 "cargo".into(),
158 "install".into(),
159 "--force".into(),
160 "invoice-cli".into(),
161 ]
162}
163
164fn running_under_brew() -> bool {
165 let exe = match std::env::current_exe() {
166 Ok(p) => p,
167 Err(_) => return false,
168 };
169 let s = exe.to_string_lossy();
170 s.contains("/homebrew/") || s.contains("/Cellar/") || s.contains("/opt/homebrew/")
171}