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; #[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
111pub 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
143pub 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
163fn tag_version(tag: &str) -> &str {
165 tag.strip_prefix('v').unwrap_or(tag)
166}
167
168pub 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 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 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 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 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 let current_link = dir.join("current");
236 let _ = fs::remove_file(¤t_link);
237
238 #[cfg(unix)]
239 std::os::unix::fs::symlink(&version_dir, ¤t_link).context("update current symlink")?;
240
241 write_metadata(tag, platform)?;
243
244 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
282fn effective_platform(meta: &InstallMetadata) -> &str {
284 if meta.platform.is_empty() {
285 compile_time_platform()
286 } else {
287 &meta.platform
288 }
289}
290
291pub 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
316pub 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(_) => {} }
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}