Skip to main content

cc_token_usage/
update.rs

1use anyhow::{bail, Context, Result};
2use ureq::ResponseExt;
3
4const GITHUB_REPO: &str = "LokiQ0713/cc-token-usage";
5const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
6
7pub struct UpdateStatus {
8    pub current_version: String,
9    pub latest_version: String,
10    pub update_available: bool,
11    pub download_url: String,
12}
13
14/// Check for updates by following GitHub's release redirect (no API quota needed).
15pub fn check_for_update() -> Result<UpdateStatus> {
16    let target = target_triple();
17    let asset_name = format!("cc-token-usage-{target}.tar.gz");
18
19    // Follow redirect: github.com/REPO/releases/latest → github.com/REPO/releases/tag/vX.Y.Z
20    // This uses GitHub CDN, not the REST API, so it's never rate-limited.
21    let redirect_url = format!("https://github.com/{GITHUB_REPO}/releases/latest");
22    let response = ureq::get(&redirect_url)
23        .header("User-Agent", concat!("cc-token-usage/", env!("CARGO_PKG_VERSION")))
24        .call()
25        .context("failed to check latest release — check your internet connection")?;
26
27    let final_url = response.get_uri().to_string();
28    let tag_segment = final_url
29        .rsplit('/')
30        .next()
31        .context("could not extract version from GitHub redirect")?;
32    let latest = tag_segment.strip_prefix('v').unwrap_or(tag_segment);
33
34    // Construct download URL directly (no API call needed)
35    let download_url = format!(
36        "https://github.com/{GITHUB_REPO}/releases/download/v{latest}/{asset_name}"
37    );
38
39    Ok(UpdateStatus {
40        current_version: CURRENT_VERSION.to_string(),
41        latest_version: latest.to_string(),
42        update_available: version_gt(latest, CURRENT_VERSION),
43        download_url,
44    })
45}
46
47/// Download the latest release and replace the current binary.
48pub fn perform_update() -> Result<()> {
49    // Refuse to update if managed by npm
50    if is_npm_managed() {
51        bail!(
52            "This binary is managed by npm.\n\
53             Run `npm update -g cc-token-usage` to upgrade,\n\
54             or use `npx cc-token-usage@latest` to always run the latest version."
55        );
56    }
57
58    // Rustup-style permission probe: try creating a tempdir next to the binary.
59    // If it fails, a package manager likely owns this location.
60    check_write_permission()?;
61
62    let status = check_for_update()?;
63
64    if !status.update_available {
65        eprintln!("Already up to date (v{})", status.current_version);
66        return Ok(());
67    }
68
69    eprintln!(
70        "Updating v{} → v{}",
71        status.current_version, status.latest_version
72    );
73    eprintln!("Downloading...");
74
75    // Download tar.gz
76    let data = ureq::get(&status.download_url)
77        .header("User-Agent", concat!("cc-token-usage/", env!("CARGO_PKG_VERSION")))
78        .call()
79        .context("failed to download release")?
80        .into_body()
81        .read_to_vec()
82        .context("failed to read response body")?;
83
84    // Extract binary from tar.gz
85    let decoder = flate2::read::GzDecoder::new(&data[..]);
86    let mut archive = tar::Archive::new(decoder);
87
88    let current_exe = std::env::current_exe().context("cannot determine current executable path")?;
89    let parent = current_exe
90        .parent()
91        .context("current executable has no parent directory")?;
92    let tmp_path = parent.join(".cc-token-usage.new");
93
94    let mut found = false;
95    for entry in archive.entries().context("failed to read tar archive")? {
96        let mut entry = entry.context("corrupt tar entry")?;
97        let path = entry.path().context("invalid path in archive")?;
98        if path.file_name().and_then(|n| n.to_str()) == Some("cc-token-usage") {
99            entry
100                .unpack(&tmp_path)
101                .context("failed to extract binary")?;
102            found = true;
103            break;
104        }
105    }
106
107    if !found {
108        // Clean up temp file
109        let _ = std::fs::remove_file(&tmp_path);
110        bail!("archive does not contain cc-token-usage binary");
111    }
112
113    // Set executable permission
114    #[cfg(unix)]
115    {
116        use std::os::unix::fs::PermissionsExt;
117        std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755))
118            .context("failed to set executable permission")?;
119    }
120
121    // Atomic replacement: current → .old, .new → current
122    let backup_path = parent.join(".cc-token-usage.old");
123    let _ = std::fs::remove_file(&backup_path); // clean up any leftover
124
125    std::fs::rename(&current_exe, &backup_path).context(
126        "failed to replace binary — permission denied?\n\
127         Try: sudo cc-token-usage update",
128    )?;
129
130    if let Err(e) = std::fs::rename(&tmp_path, &current_exe) {
131        // Rollback: restore the old binary
132        let _ = std::fs::rename(&backup_path, &current_exe);
133        let _ = std::fs::remove_file(&tmp_path);
134        return Err(e).context("failed to install new binary (rolled back)");
135    }
136
137    // Best-effort cleanup
138    let _ = std::fs::remove_file(&backup_path);
139
140    eprintln!("Updated to v{}", status.latest_version);
141    Ok(())
142}
143
144/// Compile-time platform detection → target triple matching GitHub Release assets.
145fn target_triple() -> &'static str {
146    cfg_if_triple()
147}
148
149#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
150fn cfg_if_triple() -> &'static str {
151    "aarch64-apple-darwin"
152}
153#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
154fn cfg_if_triple() -> &'static str {
155    "x86_64-apple-darwin"
156}
157#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
158fn cfg_if_triple() -> &'static str {
159    "x86_64-unknown-linux-musl"
160}
161#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
162fn cfg_if_triple() -> &'static str {
163    "aarch64-unknown-linux-musl"
164}
165#[cfg(not(any(
166    all(target_os = "macos", target_arch = "aarch64"),
167    all(target_os = "macos", target_arch = "x86_64"),
168    all(target_os = "linux", target_arch = "x86_64"),
169    all(target_os = "linux", target_arch = "aarch64"),
170)))]
171fn cfg_if_triple() -> &'static str {
172    "unsupported"
173}
174
175/// Detect if the binary lives inside node_modules (npm-managed).
176fn is_npm_managed() -> bool {
177    std::env::current_exe()
178        .ok()
179        .and_then(|p| p.to_str().map(|s| s.contains("node_modules")))
180        .unwrap_or(false)
181}
182
183/// Rustup-style permission probe: try creating a temp file next to the binary.
184/// Catches cases where a package manager installed to /usr/local/bin, /opt/homebrew/bin, etc.
185fn check_write_permission() -> Result<()> {
186    let exe = std::env::current_exe().context("cannot determine executable path")?;
187    let dir = exe.parent().context("executable has no parent directory")?;
188
189    match tempfile::tempfile_in(dir) {
190        Ok(_) => Ok(()),
191        Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
192            bail!(
193                "Cannot update: no write permission to {}.\n\
194                 This binary may have been installed by a package manager.\n\
195                 Try: sudo cc-token-usage update\n\
196                 Or reinstall with: curl -fsSL https://raw.githubusercontent.com/LokiQ0713/cc-token-usage/master/install.sh | sh",
197                dir.display()
198            );
199        }
200        Err(e) => Err(e).context("permission check failed"),
201    }
202}
203
204/// Simple semver comparison: returns true if `a` > `b`.
205fn version_gt(a: &str, b: &str) -> bool {
206    let parse = |s: &str| -> (u32, u32, u32) {
207        let mut parts = s.split('.').filter_map(|p| p.parse().ok());
208        (
209            parts.next().unwrap_or(0),
210            parts.next().unwrap_or(0),
211            parts.next().unwrap_or(0),
212        )
213    };
214    parse(a) > parse(b)
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn test_version_gt() {
223        assert!(version_gt("1.5.0", "1.4.0"));
224        assert!(version_gt("2.0.0", "1.99.99"));
225        assert!(version_gt("1.4.1", "1.4.0"));
226        assert!(!version_gt("1.4.0", "1.4.0"));
227        assert!(!version_gt("1.3.0", "1.4.0"));
228    }
229
230    #[test]
231    fn test_target_triple_is_known() {
232        assert_ne!(target_triple(), "unsupported");
233    }
234
235    #[test]
236    fn test_npm_detection() {
237        // Current test binary is not in node_modules
238        assert!(!is_npm_managed());
239    }
240}