1use std::env;
7use std::fs;
8use std::io::{self, IsTerminal, Write};
9use std::path::PathBuf;
10use std::time::{Duration, SystemTime, UNIX_EPOCH};
11
12use serde::{Deserialize, Serialize};
13
14const GITHUB_OWNER: &str = "meteora-pro";
15
16const GITHUB_REPO: &str = "devboy-tools";
17
18const CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
20
21const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
23
24const NO_UPDATE_CHECK_ENV: &str = "DEVBOY_NO_UPDATE_CHECK";
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29#[cfg_attr(test, derive(PartialEq))]
30pub(crate) struct VersionCache {
31 pub(crate) latest_version: String,
33 pub(crate) checked_at: u64,
35}
36
37#[derive(Debug, Clone, Serialize)]
39pub struct VersionStatus {
40 pub current_version: String,
42 pub latest_version: Option<String>,
44 pub update_available: bool,
46 pub install_method: String,
48 pub update_command: String,
50}
51
52#[derive(Debug, PartialEq)]
54pub enum InstallMethod {
55 Npm,
57 Pnpm,
59 Yarn,
61 Standalone,
63}
64
65impl InstallMethod {
66 pub fn update_command(&self) -> &'static str {
68 match self {
69 InstallMethod::Npm => "npm update -g @devboy-tools/cli",
70 InstallMethod::Pnpm => "pnpm update -g @devboy-tools/cli",
71 InstallMethod::Yarn => "yarn global upgrade @devboy-tools/cli",
72 InstallMethod::Standalone => "devboy upgrade",
73 }
74 }
75
76 #[cfg(not(windows))]
78 pub fn update_command_parts(&self) -> (&'static str, &'static [&'static str]) {
79 match self {
80 InstallMethod::Npm => ("npm", &["update", "-g", "@devboy-tools/cli"]),
81 InstallMethod::Pnpm => ("pnpm", &["update", "-g", "@devboy-tools/cli"]),
82 InstallMethod::Yarn => ("yarn", &["global", "upgrade", "@devboy-tools/cli"]),
83 InstallMethod::Standalone => ("devboy", &["upgrade"]),
84 }
85 }
86
87 pub fn is_managed(&self) -> bool {
89 !matches!(self, InstallMethod::Standalone)
90 }
91
92 pub fn name(&self) -> &'static str {
94 match self {
95 InstallMethod::Npm => "npm",
96 InstallMethod::Pnpm => "pnpm",
97 InstallMethod::Yarn => "yarn",
98 InstallMethod::Standalone => "standalone",
99 }
100 }
101}
102
103pub fn detect_install_method() -> InstallMethod {
106 let is_node_modules = env::current_exe()
108 .ok()
109 .and_then(|p| p.canonicalize().ok())
110 .map(|p| p.to_string_lossy().contains("node_modules"))
111 .unwrap_or(false);
112
113 if is_node_modules {
114 if let Ok(user_agent) = env::var("npm_config_user_agent")
117 && user_agent.starts_with("pnpm/")
118 {
119 return InstallMethod::Pnpm;
120 }
121 if let Ok(user_agent) = env::var("npm_config_user_agent")
122 && user_agent.starts_with("yarn/")
123 {
124 return InstallMethod::Yarn;
125 }
126
127 if let Ok(exe) = env::current_exe() {
129 let path_str = exe.to_string_lossy();
130 if path_str.contains("pnpm") {
131 return InstallMethod::Pnpm;
132 }
133 if path_str.contains("yarn") {
134 return InstallMethod::Yarn;
135 }
136 }
137
138 return InstallMethod::Npm;
139 }
140
141 InstallMethod::Standalone
142}
143
144fn should_skip_check() -> bool {
146 if env::var("CI").is_ok() {
148 return true;
149 }
150
151 if env::var(NO_UPDATE_CHECK_ENV)
153 .map(|v| v == "1" || v.to_lowercase() == "true")
154 .unwrap_or(false)
155 {
156 return true;
157 }
158
159 if !io::stderr().is_terminal() {
161 return true;
162 }
163
164 false
165}
166
167fn cache_path() -> Option<PathBuf> {
169 dirs::cache_dir().map(|d| d.join("devboy-tools").join("version-check.json"))
170}
171
172pub(crate) fn read_cache_from(path: &std::path::Path) -> Option<VersionCache> {
174 let content = fs::read_to_string(path).ok()?;
175 let cache: VersionCache = serde_json::from_str(&content).ok()?;
176
177 let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
179
180 if now.saturating_sub(cache.checked_at) < CACHE_TTL.as_secs() {
181 Some(cache)
182 } else {
183 None
184 }
185}
186
187fn read_cache() -> Option<VersionCache> {
189 let path = cache_path()?;
190 read_cache_from(&path)
191}
192
193pub(crate) fn write_cache_to(path: &std::path::Path, latest_version: &str) {
195 let now = SystemTime::now()
196 .duration_since(UNIX_EPOCH)
197 .map(|d| d.as_secs())
198 .unwrap_or(0);
199
200 let cache = VersionCache {
201 latest_version: latest_version.to_string(),
202 checked_at: now,
203 };
204
205 if let Ok(content) = serde_json::to_string(&cache) {
206 if let Some(parent) = path.parent() {
207 let _ = fs::create_dir_all(parent);
208 }
209 let _ = fs::write(path, content);
210 }
211}
212
213fn write_cache(latest_version: &str) {
215 let Some(path) = cache_path() else {
216 return;
217 };
218 write_cache_to(&path, latest_version);
219}
220
221fn github_api_request(client: &reqwest::Client, url: &str) -> reqwest::RequestBuilder {
226 let mut req = client.get(url);
227 if let Ok(token) = env::var("GITHUB_TOKEN").or_else(|_| env::var("GH_TOKEN"))
228 && !token.is_empty()
229 {
230 req = req.bearer_auth(token);
231 }
232 req
233}
234
235async fn fetch_latest_version() -> Option<String> {
237 let url = format!(
238 "https://api.github.com/repos/{}/{}/releases/latest",
239 GITHUB_OWNER, GITHUB_REPO
240 );
241
242 let client = reqwest::Client::builder()
243 .timeout(REQUEST_TIMEOUT)
244 .user_agent(format!("devboy/{}", env!("CARGO_PKG_VERSION")))
245 .build()
246 .ok()?;
247
248 let response = github_api_request(&client, &url).send().await.ok()?;
249
250 if !response.status().is_success() {
251 return None;
252 }
253
254 #[derive(Deserialize)]
255 struct Release {
256 tag_name: String,
257 }
258
259 let release: Release = response.json().await.ok()?;
260
261 let version = release
263 .tag_name
264 .strip_prefix('v')
265 .unwrap_or(&release.tag_name);
266 Some(version.to_string())
267}
268
269pub async fn resolve_version_status() -> VersionStatus {
271 let current_version = env!("CARGO_PKG_VERSION").to_string();
272 let install_method = detect_install_method();
273 let latest_version = if let Some(cache) =
274 read_cache().filter(|c| !is_newer_version(&c.latest_version, ¤t_version))
275 {
276 Some(cache.latest_version)
279 } else {
280 let fetched = fetch_latest_version().await;
281 if let Some(version) = &fetched {
282 write_cache(version);
283 }
284 fetched
285 };
286
287 let update_available = latest_version
288 .as_deref()
289 .is_some_and(|latest| is_newer_version(¤t_version, latest));
290
291 VersionStatus {
292 current_version,
293 latest_version,
294 update_available,
295 install_method: install_method.name().to_string(),
296 update_command: install_method.update_command().to_string(),
297 }
298}
299
300pub fn is_newer_version(current: &str, latest: &str) -> bool {
303 let parse = |v: &str| -> Option<(u64, u64, u64)> {
304 let v = v.split('-').next().unwrap_or(v);
306 let parts: Vec<&str> = v.split('.').collect();
307 if parts.len() != 3 {
308 return None;
309 }
310 Some((
311 parts[0].parse().ok()?,
312 parts[1].parse().ok()?,
313 parts[2].parse().ok()?,
314 ))
315 };
316
317 match (parse(current), parse(latest)) {
318 (Some(c), Some(l)) => l > c,
319 _ => false,
320 }
321}
322
323pub async fn check_and_notify() {
329 if should_skip_check() {
330 return;
331 }
332
333 let version_status = resolve_version_status().await;
334 let Some(latest_version) = version_status.latest_version.as_deref() else {
335 return;
336 };
337
338 if version_status.update_available {
339 let _ = writeln!(
340 io::stderr(),
341 "\n\x1b[33m⚠A new version of devboy is available: {} → {}\x1b[0m\n \
342 Update with: \x1b[1m{}\x1b[0m\n",
343 version_status.current_version,
344 latest_version,
345 version_status.update_command
346 );
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use std::time::{SystemTime, UNIX_EPOCH};
354
355 #[test]
356 fn test_is_newer_version() {
357 assert!(is_newer_version("0.9.0", "0.10.0"));
358 assert!(is_newer_version("0.9.0", "1.0.0"));
359 assert!(is_newer_version("0.9.0", "0.9.1"));
360 assert!(!is_newer_version("0.9.0", "0.9.0"));
361 assert!(!is_newer_version("0.10.0", "0.9.0"));
362 assert!(!is_newer_version("1.0.0", "0.9.0"));
363 }
364
365 #[test]
366 fn test_is_newer_version_with_prerelease() {
367 assert!(is_newer_version("0.9.0-alpha", "0.10.0"));
368 assert!(is_newer_version("0.9.0", "0.10.0-beta"));
369 }
370
371 #[test]
372 fn test_is_newer_version_invalid() {
373 assert!(!is_newer_version("invalid", "0.9.0"));
374 assert!(!is_newer_version("0.9.0", "invalid"));
375 assert!(!is_newer_version("0.9", "0.10.0"));
376 }
377
378 #[test]
379 fn test_is_newer_version_major_bump() {
380 assert!(is_newer_version("1.9.9", "2.0.0"));
381 assert!(!is_newer_version("2.0.0", "1.9.9"));
382 }
383
384 #[test]
385 fn test_is_newer_version_equal() {
386 assert!(!is_newer_version("1.0.0", "1.0.0"));
387 assert!(!is_newer_version("0.0.0", "0.0.0"));
388 }
389
390 #[test]
391 fn test_detect_install_method_standalone() {
392 assert_eq!(detect_install_method(), InstallMethod::Standalone);
394 }
395
396 #[test]
397 fn test_install_method_update_command() {
398 assert_eq!(
399 InstallMethod::Npm.update_command(),
400 "npm update -g @devboy-tools/cli"
401 );
402 assert_eq!(
403 InstallMethod::Pnpm.update_command(),
404 "pnpm update -g @devboy-tools/cli"
405 );
406 assert_eq!(
407 InstallMethod::Yarn.update_command(),
408 "yarn global upgrade @devboy-tools/cli"
409 );
410 assert_eq!(InstallMethod::Standalone.update_command(), "devboy upgrade");
411 }
412
413 #[test]
414 #[cfg(not(windows))]
415 fn test_install_method_update_command_parts() {
416 assert_eq!(
417 InstallMethod::Npm.update_command_parts(),
418 ("npm", &["update", "-g", "@devboy-tools/cli"][..])
419 );
420 assert_eq!(
421 InstallMethod::Pnpm.update_command_parts(),
422 ("pnpm", &["update", "-g", "@devboy-tools/cli"][..])
423 );
424 assert_eq!(
425 InstallMethod::Yarn.update_command_parts(),
426 ("yarn", &["global", "upgrade", "@devboy-tools/cli"][..])
427 );
428 assert_eq!(
429 InstallMethod::Standalone.update_command_parts(),
430 ("devboy", &["upgrade"][..])
431 );
432 }
433
434 #[test]
435 fn test_install_method_is_managed() {
436 assert!(InstallMethod::Npm.is_managed());
437 assert!(InstallMethod::Pnpm.is_managed());
438 assert!(InstallMethod::Yarn.is_managed());
439 assert!(!InstallMethod::Standalone.is_managed());
440 }
441
442 #[test]
443 fn test_install_method_name() {
444 assert_eq!(InstallMethod::Npm.name(), "npm");
445 assert_eq!(InstallMethod::Pnpm.name(), "pnpm");
446 assert_eq!(InstallMethod::Yarn.name(), "yarn");
447 assert_eq!(InstallMethod::Standalone.name(), "standalone");
448 }
449
450 #[test]
451 fn test_cache_write_and_read() {
452 let dir = tempfile::tempdir().unwrap();
453 let cache_file = dir.path().join("version-check.json");
454
455 write_cache_to(&cache_file, "1.2.3");
456
457 let cache = read_cache_from(&cache_file);
458 assert!(cache.is_some(), "Cache should be readable after write");
459 let cache = cache.unwrap();
460 assert_eq!(cache.latest_version, "1.2.3");
461 }
462
463 #[test]
464 fn test_cache_expired() {
465 let dir = tempfile::tempdir().unwrap();
466 let cache_file = dir.path().join("version-check.json");
467
468 let expired_time = SystemTime::now()
470 .duration_since(UNIX_EPOCH)
471 .unwrap()
472 .as_secs()
473 - (25 * 60 * 60);
474
475 let cache = VersionCache {
476 latest_version: "1.2.3".to_string(),
477 checked_at: expired_time,
478 };
479 let content = serde_json::to_string(&cache).unwrap();
480 fs::write(&cache_file, content).unwrap();
481
482 let result = read_cache_from(&cache_file);
483 assert!(result.is_none(), "Expired cache should return None");
484 }
485
486 #[test]
487 fn test_cache_fresh() {
488 let dir = tempfile::tempdir().unwrap();
489 let cache_file = dir.path().join("version-check.json");
490
491 let fresh_time = SystemTime::now()
493 .duration_since(UNIX_EPOCH)
494 .unwrap()
495 .as_secs()
496 - (60 * 60);
497
498 let cache = VersionCache {
499 latest_version: "2.0.0".to_string(),
500 checked_at: fresh_time,
501 };
502 let content = serde_json::to_string(&cache).unwrap();
503 fs::write(&cache_file, content).unwrap();
504
505 let result = read_cache_from(&cache_file);
506 assert!(result.is_some(), "Fresh cache should be returned");
507 assert_eq!(result.unwrap().latest_version, "2.0.0");
508 }
509
510 #[test]
511 fn test_cache_nonexistent_file() {
512 let dir = tempfile::tempdir().unwrap();
513 let cache_file = dir.path().join("nonexistent.json");
514
515 let result = read_cache_from(&cache_file);
516 assert!(
517 result.is_none(),
518 "Nonexistent cache file should return None"
519 );
520 }
521
522 #[test]
523 fn test_cache_invalid_json() {
524 let dir = tempfile::tempdir().unwrap();
525 let cache_file = dir.path().join("version-check.json");
526
527 fs::write(&cache_file, "not valid json").unwrap();
528
529 let result = read_cache_from(&cache_file);
530 assert!(result.is_none(), "Invalid JSON should return None");
531 }
532
533 #[test]
534 fn test_cache_creates_parent_dirs() {
535 let dir = tempfile::tempdir().unwrap();
536 let cache_file = dir
537 .path()
538 .join("nested")
539 .join("deep")
540 .join("version-check.json");
541
542 write_cache_to(&cache_file, "3.0.0");
543
544 assert!(
545 cache_file.exists(),
546 "Cache file should be created with parent dirs"
547 );
548 let cache = read_cache_from(&cache_file);
549 assert!(cache.is_some());
550 assert_eq!(cache.unwrap().latest_version, "3.0.0");
551 }
552
553 #[test]
554 fn test_version_cache_serialization_roundtrip() {
555 let cache = VersionCache {
556 latest_version: "1.2.3".to_string(),
557 checked_at: 1700000000,
558 };
559
560 let json = serde_json::to_string(&cache).unwrap();
561 let deserialized: VersionCache = serde_json::from_str(&json).unwrap();
562
563 assert_eq!(cache, deserialized);
564 }
565
566 #[test]
567 fn test_cache_path_is_some() {
568 let path = cache_path();
570 assert!(
571 path.is_some(),
572 "cache_path() should return Some on this platform"
573 );
574 let path = path.unwrap();
575 assert!(
576 path.to_string_lossy().contains("devboy-tools"),
577 "Cache path should contain 'devboy-tools': {:?}",
578 path
579 );
580 }
581}