Skip to main content

agentis_pay/
updater.rs

1use std::fs;
2use std::path::PathBuf;
3
4use anyhow::{Context, Result, anyhow, bail};
5use serde::{Deserialize, Serialize};
6
7const RELEASE_REPO: &str = "bipa-app/agentispay";
8const BINARY_NAME: &str = "agentis-pay";
9const CHECK_INTERVAL_SECS: i64 = 4 * 60 * 60; // 4 hours
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct InstallMetadata {
13    pub source: String,
14    #[serde(default)]
15    pub repository: String,
16    #[serde(default)]
17    pub version: String,
18    #[serde(default)]
19    pub tag: String,
20    #[serde(default)]
21    pub platform: String,
22    #[serde(default)]
23    pub binary: String,
24    #[serde(default)]
25    pub launcher_path: String,
26    #[serde(default)]
27    pub install_dir: String,
28    #[serde(default)]
29    pub installed_at: String,
30}
31
32fn install_dir() -> PathBuf {
33    std::env::var("AGENTIS_PAY_INSTALL_DIR")
34        .ok()
35        .filter(|v| !v.trim().is_empty())
36        .map(PathBuf::from)
37        .unwrap_or_else(|| {
38            dirs::home_dir()
39                .unwrap_or_else(|| PathBuf::from("."))
40                .join(".agentis-pay")
41        })
42}
43
44fn metadata_path() -> PathBuf {
45    install_dir().join("install.json")
46}
47
48fn last_check_path() -> PathBuf {
49    install_dir().join(".last-update-check")
50}
51
52pub fn load_metadata() -> Result<Option<InstallMetadata>> {
53    let path = metadata_path();
54    if !path.exists() {
55        return Ok(None);
56    }
57    let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
58    let meta: InstallMetadata = serde_json::from_str(&raw).context("parse install.json")?;
59    if meta.source != "managed" {
60        return Ok(None);
61    }
62    Ok(Some(meta))
63}
64
65pub fn is_managed_install() -> bool {
66    load_metadata().ok().flatten().is_some()
67}
68
69fn needs_update_check() -> bool {
70    let path = last_check_path();
71    let raw = match fs::read_to_string(&path) {
72        Ok(raw) => raw,
73        Err(_) => return true,
74    };
75    let last_check: i64 = match raw.trim().parse() {
76        Ok(ts) => ts,
77        Err(_) => return true,
78    };
79    let now = agentis_pay_shared::unix_timestamp_seconds();
80    now.saturating_sub(last_check) >= CHECK_INTERVAL_SECS
81}
82
83fn record_update_check() {
84    let now = agentis_pay_shared::unix_timestamp_seconds();
85    let _ = fs::write(last_check_path(), now.to_string());
86}
87
88pub fn current_version() -> &'static str {
89    env!("CARGO_PKG_VERSION")
90}
91
92fn compile_time_platform() -> &'static str {
93    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
94    {
95        "darwin-arm64"
96    }
97    #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
98    {
99        "darwin-x64"
100    }
101    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
102    {
103        "linux-x64"
104    }
105    #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
106    {
107        "linux-arm64"
108    }
109}
110
111/// Resolve the latest release tag from the GitHub releases redirect.
112pub async fn resolve_latest_tag() -> Result<String> {
113    let url = format!("https://github.com/{RELEASE_REPO}/releases/latest");
114    let client = reqwest::Client::builder()
115        .redirect(reqwest::redirect::Policy::none())
116        .build()
117        .context("build http client")?;
118
119    let response = client
120        .head(&url)
121        .send()
122        .await
123        .context("check latest release")?;
124
125    let location = response
126        .headers()
127        .get("location")
128        .and_then(|v| v.to_str().ok())
129        .ok_or_else(|| anyhow!("no redirect from GitHub releases/latest"))?;
130
131    let tag = location
132        .rsplit('/')
133        .next()
134        .ok_or_else(|| anyhow!("unexpected redirect URL: {location}"))?;
135
136    if !tag.starts_with('v') {
137        bail!("unexpected tag format: {tag}");
138    }
139
140    Ok(tag.to_string())
141}
142
143/// Compare two semver strings, return true if `latest` is newer than `current`.
144pub fn is_newer(latest: &str, current: &str) -> bool {
145    let parse = |s: &str| -> Option<(u32, u32, u32)> {
146        let parts: Vec<&str> = s.split('.').collect();
147        if parts.len() != 3 {
148            return None;
149        }
150        Some((
151            parts[0].parse().ok()?,
152            parts[1].parse().ok()?,
153            parts[2].parse().ok()?,
154        ))
155    };
156
157    match (parse(latest), parse(current)) {
158        (Some(l), Some(c)) => l > c,
159        _ => false,
160    }
161}
162
163/// Tag version string without the `v` prefix.
164fn tag_version(tag: &str) -> &str {
165    tag.strip_prefix('v').unwrap_or(tag)
166}
167
168/// Download and install a specific release tag.
169pub async fn download_and_install(tag: &str, platform: &str) -> Result<()> {
170    let dir = install_dir();
171    let archive_name = format!("{BINARY_NAME}-{tag}-{platform}.tar.gz");
172    let url = format!("https://github.com/{RELEASE_REPO}/releases/download/{tag}/{archive_name}");
173    let version_dir = dir.join("versions").join(tag);
174
175    // Download archive
176    let response = reqwest::get(&url).await.context("download release")?;
177    if !response.status().is_success() {
178        bail!("download failed: HTTP {}", response.status());
179    }
180    let bytes = response.bytes().await.context("read release archive")?;
181
182    // Write to temp file inside install dir (same filesystem for rename safety)
183    let tmp_dir = dir.join(".tmp-update");
184    if tmp_dir.exists() {
185        fs::remove_dir_all(&tmp_dir).ok();
186    }
187    fs::create_dir_all(&tmp_dir).context("create temp dir")?;
188
189    let archive_path = tmp_dir.join(&archive_name);
190    fs::write(&archive_path, &bytes).context("write archive")?;
191
192    // Extract using tar
193    let status = tokio::process::Command::new("tar")
194        .args([
195            "-xzf",
196            archive_path
197                .to_str()
198                .ok_or_else(|| anyhow!("non-utf8 archive path"))?,
199        ])
200        .current_dir(&tmp_dir)
201        .stdout(std::process::Stdio::null())
202        .stderr(std::process::Stdio::null())
203        .status()
204        .await
205        .context("run tar")?;
206
207    if !status.success() {
208        let _ = fs::remove_dir_all(&tmp_dir);
209        bail!("tar extraction failed");
210    }
211
212    let extracted = tmp_dir.join(BINARY_NAME);
213    if !extracted.exists() {
214        let _ = fs::remove_dir_all(&tmp_dir);
215        bail!("archive did not contain {BINARY_NAME}");
216    }
217
218    // Install binary into version directory
219    if version_dir.exists() {
220        fs::remove_dir_all(&version_dir).ok();
221    }
222    fs::create_dir_all(&version_dir).context("create version dir")?;
223
224    let dest = version_dir.join(BINARY_NAME);
225    fs::copy(&extracted, &dest).context("install binary")?;
226
227    #[cfg(unix)]
228    {
229        use std::os::unix::fs::PermissionsExt;
230        fs::set_permissions(&dest, fs::Permissions::from_mode(0o755))
231            .context("set binary permissions")?;
232    }
233
234    // Update current symlink
235    let current_link = dir.join("current");
236    let _ = fs::remove_file(&current_link);
237
238    #[cfg(unix)]
239    std::os::unix::fs::symlink(&version_dir, &current_link).context("update current symlink")?;
240
241    // Update install.json
242    write_metadata(tag, platform)?;
243
244    // Cleanup
245    let _ = fs::remove_dir_all(&tmp_dir);
246
247    Ok(())
248}
249
250fn write_metadata(tag: &str, platform: &str) -> Result<()> {
251    let dir = install_dir();
252    let bin_dir = std::env::var("AGENTIS_PAY_BIN_DIR")
253        .ok()
254        .filter(|v| !v.trim().is_empty())
255        .unwrap_or_else(|| {
256            dirs::home_dir()
257                .unwrap_or_else(|| PathBuf::from("."))
258                .join(".local/bin")
259                .to_string_lossy()
260                .into_owned()
261        });
262
263    let now = agentis_pay_shared::unix_timestamp_seconds();
264
265    let meta = InstallMetadata {
266        source: "managed".to_string(),
267        repository: RELEASE_REPO.to_string(),
268        version: tag_version(tag).to_string(),
269        tag: tag.to_string(),
270        platform: platform.to_string(),
271        binary: BINARY_NAME.to_string(),
272        launcher_path: format!("{bin_dir}/{BINARY_NAME}"),
273        install_dir: dir.to_string_lossy().into_owned(),
274        installed_at: now.to_string(),
275    };
276
277    let raw = serde_json::to_string_pretty(&meta).context("serialize install.json")?;
278    fs::write(dir.join("install.json"), raw).context("write install.json")?;
279    Ok(())
280}
281
282/// Resolve the platform for updates: prefer install.json, fall back to compile-time.
283fn effective_platform(meta: &InstallMetadata) -> &str {
284    if meta.platform.is_empty() {
285        compile_time_platform()
286    } else {
287        &meta.platform
288    }
289}
290
291/// Perform an update check and install if a newer version is available.
292///
293/// Returns `Some(tag)` if an update was installed, `None` otherwise.
294pub async fn check_and_update(force: bool) -> Result<Option<String>> {
295    let meta = load_metadata()?.ok_or_else(|| anyhow!("not a managed install"))?;
296
297    if !force && !needs_update_check() {
298        return Ok(None);
299    }
300
301    let tag = resolve_latest_tag().await?;
302    let latest_version = tag_version(&tag);
303
304    record_update_check();
305
306    if !is_newer(latest_version, current_version()) {
307        return Ok(None);
308    }
309
310    let platform = effective_platform(&meta).to_string();
311    download_and_install(&tag, &platform).await?;
312
313    Ok(Some(tag))
314}
315
316/// Run auto-update silently. Errors are swallowed.
317pub async fn auto_update_if_needed() {
318    if std::env::var_os("AGENTIS_PAY_NO_AUTO_UPDATE").is_some() {
319        return;
320    }
321
322    if !is_managed_install() {
323        return;
324    }
325
326    match check_and_update(false).await {
327        Ok(Some(tag)) => {
328            eprintln!("agentis-pay: updated to {tag} (active on next invocation)",);
329        }
330        Ok(None) => {}
331        Err(_) => {} // silent failure
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn is_newer_compares_semver_correctly() {
341        assert!(is_newer("0.1.10", "0.1.9"));
342        assert!(is_newer("0.2.0", "0.1.99"));
343        assert!(is_newer("1.0.0", "0.99.99"));
344        assert!(!is_newer("0.1.9", "0.1.9"));
345        assert!(!is_newer("0.1.8", "0.1.9"));
346        assert!(!is_newer("0.0.1", "0.1.0"));
347    }
348
349    #[test]
350    fn tag_version_strips_prefix() {
351        assert_eq!(tag_version("v0.1.10"), "0.1.10");
352        assert_eq!(tag_version("0.1.10"), "0.1.10");
353    }
354
355    #[test]
356    fn compile_time_platform_is_not_empty() {
357        assert!(!compile_time_platform().is_empty());
358    }
359}