Skip to main content

contract_cli/commands/
update.rs

1// Distribution-aware update, per agent-cli-framework docs/update-standard.md:
2// the binary is upgraded by whichever channel owns it. Homebrew installs use
3// `brew upgrade`, everything else defers to cargo. `--check` never mutates.
4
5use std::process::Command;
6
7use crate::error::{AppError, Result};
8use crate::output::{print_success, Ctx};
9
10const CRATES_IO_URL: &str = "https://crates.io/api/v1/crates/contract-cli";
11const BREW_FORMULA: &str = "paperfoot/tap/contract";
12const RELEASE_URL: &str = "https://github.com/paperfoot/contract-cli/releases";
13
14#[derive(serde::Serialize)]
15struct UpdateReport {
16    current_version: String,
17    latest_version: Option<String>,
18    status: &'static str,
19    install_source: &'static str,
20    update_mode: &'static str,
21    upgrade_command: String,
22    release_url: &'static str,
23    requires_skill_reinstall: bool,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    note: Option<String>,
26}
27
28pub fn run(ctx: Ctx, check: bool) -> Result<()> {
29    let current = env!("CARGO_PKG_VERSION").to_string();
30    let (install_source, update_mode, upgrade_command) = if running_under_brew() {
31        (
32            "homebrew",
33            "package_manager",
34            format!("brew upgrade {BREW_FORMULA}"),
35        )
36    } else {
37        (
38            "cargo",
39            "package_manager",
40            "cargo install --locked --force contract-cli".to_string(),
41        )
42    };
43
44    if let Ok(cfg) = crate::config::load() {
45        if !cfg.self_update {
46            let report = UpdateReport {
47                current_version: current,
48                latest_version: None,
49                status: "disabled",
50                install_source,
51                update_mode: "disabled",
52                upgrade_command,
53                release_url: RELEASE_URL,
54                requires_skill_reinstall: false,
55                note: Some("self_update = false in config; upgrade via the package manager".into()),
56            };
57            print_success(ctx, &report, |r| {
58                println!("updates disabled by config. Upgrade manually: {}", r.upgrade_command)
59            });
60            return Ok(());
61        }
62    }
63
64    let latest = match fetch_latest_version() {
65        Ok(v) => v,
66        Err(e) => {
67            // --check must stay exit 0 on lookup trouble per the standard's
68            // "safe check" contract; report unknown and move on.
69            let report = UpdateReport {
70                current_version: current,
71                latest_version: None,
72                status: "up_to_date",
73                install_source,
74                update_mode,
75                upgrade_command,
76                release_url: RELEASE_URL,
77                requires_skill_reinstall: false,
78                note: Some(format!("could not query crates.io: {e}")),
79            };
80            print_success(ctx, &report, |r| {
81                println!(
82                    "current: {} — latest unknown ({})",
83                    r.current_version,
84                    r.note.as_deref().unwrap_or("")
85                )
86            });
87            return Ok(());
88        }
89    };
90
91    let is_newer = version_newer_than(&latest, &current);
92    let status = if is_newer { "update_available" } else { "up_to_date" };
93
94    if check || !is_newer {
95        let report = UpdateReport {
96            current_version: current,
97            latest_version: Some(latest),
98            status,
99            install_source,
100            update_mode,
101            upgrade_command,
102            release_url: RELEASE_URL,
103            requires_skill_reinstall: is_newer,
104            note: None,
105        };
106        print_success(ctx, &report, |r| {
107            println!("current: {}", r.current_version);
108            println!("latest:  {}", r.latest_version.as_deref().unwrap_or("?"));
109            if r.status == "update_available" {
110                println!("update available — run: {}", r.upgrade_command);
111            } else {
112                println!("up to date");
113            }
114        });
115        return Ok(());
116    }
117
118    // Apply through the owning channel.
119    eprintln!("upgrading {current} → {latest} via: {upgrade_command}");
120    let ok = if install_source == "homebrew" {
121        run_cmd("brew", &["upgrade", BREW_FORMULA])?
122    } else {
123        run_cmd(
124            "cargo",
125            &["install", "--locked", "--force", "contract-cli"],
126        )?
127    };
128    if !ok {
129        return Err(AppError::Transient(
130            "upgrade command failed — run it manually to see the full output".into(),
131        ));
132    }
133    let report = UpdateReport {
134        current_version: current,
135        latest_version: Some(latest.clone()),
136        status: "updated",
137        install_source,
138        update_mode,
139        upgrade_command,
140        release_url: RELEASE_URL,
141        requires_skill_reinstall: true,
142        note: Some("run `contract skill install` to refresh the installed skill".into()),
143    };
144    print_success(ctx, &report, |_| {
145        println!("upgraded to {latest}. Refresh the skill: contract skill install");
146    });
147    Ok(())
148}
149
150fn run_cmd(program: &str, args: &[&str]) -> Result<bool> {
151    let status = Command::new(program)
152        .args(args)
153        .env("HOMEBREW_NO_AUTO_UPDATE", "1")
154        .status()
155        .map_err(|e| AppError::Transient(format!("failed to launch {program}: {e}")))?;
156    Ok(status.success())
157}
158
159fn fetch_latest_version() -> Result<String> {
160    let out = Command::new("curl")
161        .args([
162            "-sSL",
163            "--max-time",
164            "10",
165            "-H",
166            "User-Agent: contract-cli",
167            "-H",
168            "Accept: application/json",
169            CRATES_IO_URL,
170        ])
171        .output()
172        .map_err(|e| AppError::Transient(format!("curl not available: {e}")))?;
173    if !out.status.success() {
174        return Err(AppError::Transient(format!(
175            "crates.io query failed (exit {})",
176            out.status.code().unwrap_or(-1)
177        )));
178    }
179    let body: serde_json::Value = serde_json::from_slice(&out.stdout)
180        .map_err(|e| AppError::Transient(format!("bad crates.io response: {e}")))?;
181    if let Some(errors) = body.get("errors").and_then(|e| e.as_array()) {
182        let detail = errors
183            .first()
184            .and_then(|e| e.get("detail"))
185            .and_then(|d| d.as_str())
186            .unwrap_or("unknown");
187        return Err(AppError::Transient(format!("crates.io: {detail}")));
188    }
189    body.get("crate")
190        .and_then(|c| c.get("max_stable_version"))
191        .and_then(|v| v.as_str())
192        .map(|s| s.to_string())
193        .ok_or_else(|| AppError::Transient("crates.io response missing max_stable_version".into()))
194}
195
196fn version_newer_than(a: &str, b: &str) -> bool {
197    match (parse_version(a), parse_version(b)) {
198        (Some(a), Some(b)) => a > b,
199        _ => false,
200    }
201}
202
203fn parse_version(v: &str) -> Option<(u32, u32, u32)> {
204    let core = v.split(['-', '+']).next()?;
205    let mut parts = core.split('.');
206    Some((
207        parts.next()?.parse().ok()?,
208        parts.next()?.parse().ok()?,
209        parts.next().unwrap_or("0").parse().unwrap_or(0),
210    ))
211}
212
213fn running_under_brew() -> bool {
214    std::env::current_exe()
215        .map(|p| {
216            let s = p.to_string_lossy().to_string();
217            s.contains("/homebrew/") || s.contains("/Cellar/") || s.contains("/opt/homebrew/")
218        })
219        .unwrap_or(false)
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn compares_semver_versions() {
228        assert!(version_newer_than("0.2.0", "0.1.0"));
229        assert!(!version_newer_than("0.1.0", "0.1.0"));
230        assert!(!version_newer_than("0.1.0", "0.2.0"));
231    }
232}