Skip to main content

devboy_cli/
update_check.rs

1//! Version update check with caching.
2//!
3//! Checks GitHub Releases for newer versions and notifies the user via stderr.
4//! Results are cached for 24 hours to avoid excessive API calls.
5
6use 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
18/// Cache TTL: 24 hours.
19const CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
20
21/// HTTP request timeout for version check.
22const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
23
24/// Environment variable to disable update checks.
25const NO_UPDATE_CHECK_ENV: &str = "DEVBOY_NO_UPDATE_CHECK";
26
27/// Cached version check result.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[cfg_attr(test, derive(PartialEq))]
30pub(crate) struct VersionCache {
31    /// Latest version from GitHub (without 'v' prefix).
32    pub(crate) latest_version: String,
33    /// Unix timestamp when the check was performed.
34    pub(crate) checked_at: u64,
35}
36
37/// Resolved version information for the current installation.
38#[derive(Debug, Clone, Serialize)]
39pub struct VersionStatus {
40    /// Current CLI version.
41    pub current_version: String,
42    /// Latest version from cache or GitHub, when available.
43    pub latest_version: Option<String>,
44    /// Whether an update is available.
45    pub update_available: bool,
46    /// Human-readable installation method.
47    pub install_method: String,
48    /// Recommended update command for this installation.
49    pub update_command: String,
50}
51
52/// Detected installation method.
53#[derive(Debug, PartialEq)]
54pub enum InstallMethod {
55    /// Installed via npm (`npm install -g`).
56    Npm,
57    /// Installed via pnpm (`pnpm add -g`).
58    Pnpm,
59    /// Installed via yarn (`yarn global add`).
60    Yarn,
61    /// Standalone binary (GitHub release, cargo install, etc.).
62    Standalone,
63}
64
65impl InstallMethod {
66    /// Returns the appropriate update command as a display string.
67    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    /// Returns the update command as structured `(program, args)` for execution.
77    #[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    /// Returns true if this is a package-manager-managed installation.
88    pub fn is_managed(&self) -> bool {
89        !matches!(self, InstallMethod::Standalone)
90    }
91
92    /// Returns a human-readable name.
93    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
103/// Detect how devboy was installed by examining the binary path
104/// and environment variables.
105pub fn detect_install_method() -> InstallMethod {
106    // Check if binary is inside node_modules (npm/pnpm/yarn)
107    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        // Try to detect specific package manager from npm_config_user_agent
115        // Format: "npm/10.x.x node/22.x.x ..." or "pnpm/9.x.x ..." or "yarn/4.x.x ..."
116        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        // Check pnpm global store path pattern as fallback
128        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
144/// Check if update check should be skipped.
145fn should_skip_check() -> bool {
146    // Skip in CI environments
147    if env::var("CI").is_ok() {
148        return true;
149    }
150
151    // Skip if user explicitly disabled
152    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    // Skip if stderr is not a TTY
160    if !io::stderr().is_terminal() {
161        return true;
162    }
163
164    false
165}
166
167/// Get the cache file path.
168fn cache_path() -> Option<PathBuf> {
169    dirs::cache_dir().map(|d| d.join("devboy-tools").join("version-check.json"))
170}
171
172/// Read cached version check result from the given path.
173pub(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    // Check if cache is still fresh
178    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
187/// Read cached version check result from the default cache path.
188fn read_cache() -> Option<VersionCache> {
189    let path = cache_path()?;
190    read_cache_from(&path)
191}
192
193/// Write version check result to the given path.
194pub(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
213/// Write version check result to the default cache path.
214fn write_cache(latest_version: &str) {
215    let Some(path) = cache_path() else {
216        return;
217    };
218    write_cache_to(&path, latest_version);
219}
220
221/// Build a GitHub API request with optional authentication.
222///
223/// Uses `GITHUB_TOKEN` or `GH_TOKEN` env var for authentication if available,
224/// which increases the rate limit from 60 to 5000 requests/hour.
225fn 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
235/// Fetch the latest version from GitHub Releases API.
236async 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    // Strip 'v' prefix: "v0.9.0" -> "0.9.0"
262    let version = release
263        .tag_name
264        .strip_prefix('v')
265        .unwrap_or(&release.tag_name);
266    Some(version.to_string())
267}
268
269/// Resolve version status using the cache first, then GitHub as a fallback.
270pub 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, &current_version))
275    {
276        // Use cache only if current version is not newer than cached latest.
277        // If user upgraded past the cached version, force a re-fetch.
278        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(&current_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
300/// Compare two semver-like version strings.
301/// Returns true if `latest` is newer than `current`.
302pub fn is_newer_version(current: &str, latest: &str) -> bool {
303    let parse = |v: &str| -> Option<(u64, u64, u64)> {
304        // Strip any pre-release suffix for comparison
305        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
323/// Run the update check and print a notice if a newer version is available.
324///
325/// This should be called early in `main()` for non-upgrade commands.
326/// Uses cached results when available. When the cache is stale, performs
327/// an async HTTP request which can take up to `REQUEST_TIMEOUT` (5s).
328pub 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        // In test environment, binary is not in node_modules
393        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        // Write a cache entry with a timestamp from 25 hours ago
469        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        // Write a cache entry from 1 hour ago (within 24h TTL)
492        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        // On any platform with a home directory, cache_path should return Some
569        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}