Skip to main content

rpi_cli/
updates.rs

1//! Lightweight update discovery and update commands.
2//!
3//! Startup checks are deliberately best-effort: they run only for an
4//! interactive terminal, use a short timeout, and cache the result so a
5//! temporary registry outage never blocks the agent.
6
7use std::collections::BTreeMap;
8use std::path::Path;
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use futures::future::join_all;
12use serde::{Deserialize, Serialize};
13
14const CACHE_FILE: &str = "update-check.json";
15const CACHE_TTL_MS: i64 = 6 * 60 * 60 * 1000;
16const REQUEST_TIMEOUT_MS: u64 = 1800;
17const CRATES_IO_API: &str = "https://crates.io/api/v1/crates/rpi-cli";
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct UpdateNotice {
21    pub name: String,
22    pub current: String,
23    pub latest: String,
24    pub command: String,
25}
26
27#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub struct UpdateReport {
29    pub notices: Vec<UpdateNotice>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, Default)]
33#[serde(rename_all = "camelCase")]
34struct UpdateCache {
35    checked_at: i64,
36    rpi_latest: Option<String>,
37    packages: BTreeMap<String, String>,
38    /// Timestamp for the last opt-in Pi package check. This is separate from
39    /// `checked_at` because the default startup path deliberately skips npm
40    /// package discovery and must not make stale package data look fresh.
41    #[serde(default)]
42    packages_checked_at: i64,
43    #[serde(default)]
44    native_packages: BTreeMap<String, String>,
45}
46
47#[derive(Debug, Deserialize)]
48struct CratesResponse {
49    #[serde(rename = "crate")]
50    crate_info: CrateInfo,
51}
52
53#[derive(Debug, Deserialize)]
54struct CrateInfo {
55    max_version: String,
56}
57
58#[derive(Debug, Deserialize)]
59struct NpmResponse {
60    dist_tags: Option<DistTags>,
61}
62
63#[derive(Debug, Deserialize)]
64struct DistTags {
65    latest: Option<String>,
66}
67
68/// Perform a cached, best-effort check with Pi package discovery disabled.
69///
70/// Keep this compatibility entry point conservative: callers that have not
71/// explicitly opted into Pi packages must never parse package settings.
72pub async fn check_startup(cwd: &Path) -> UpdateReport {
73    let _ = cwd;
74    check_startup_with_package_resources(None).await
75}
76
77/// Perform a cached, best-effort startup check.
78///
79/// `package_resources` must be the same trust-gated resource set that the
80/// session will load. `None` disables Pi package checks entirely.
81pub async fn check_startup_with_package_resources(
82    package_resources: Option<&crate::packages::PackageResources>,
83) -> UpdateReport {
84    let enable_pi_packages = package_resources.is_some();
85    if std::env::var_os("RPI_DISABLE_UPDATE_CHECK").is_some() {
86        return UpdateReport::default();
87    }
88    let cache_path = match crate::config::agent_dir() {
89        Ok(dir) => dir.join(CACHE_FILE),
90        Err(_) => return UpdateReport::default(),
91    };
92    let previous_cache = read_cache(&cache_path);
93    if let Some(cache) = previous_cache.as_ref() {
94        if cache_is_fresh(cache, enable_pi_packages) {
95            return report_from_cache(cache, package_resources);
96        }
97    }
98
99    let client = match reqwest::Client::builder()
100        .timeout(std::time::Duration::from_millis(REQUEST_TIMEOUT_MS))
101        .user_agent(format!("rpi/{}", crate::VERSION))
102        .build()
103    {
104        Ok(client) => client,
105        Err(_) => return UpdateReport::default(),
106    };
107    let rpi_latest = fetch_rpi_latest(&client).await;
108    let package_names = if let Some(resources) = package_resources {
109        resources
110            .packages
111            .iter()
112            .filter(|package| package.version.is_some() && is_registry_package_path(&package.root))
113            .map(|package| {
114                (
115                    package.name.clone(),
116                    package.version.clone().unwrap_or_default(),
117                )
118            })
119            .collect::<Vec<_>>()
120    } else {
121        Vec::new()
122    };
123    let package_checks = package_names.into_iter().map(|(name, _)| {
124        let client = client.clone();
125        async move { (name.clone(), fetch_npm_latest(&client, &name).await) }
126    });
127    let mut packages = BTreeMap::new();
128    for (name, latest) in join_all(package_checks).await {
129        if let Some(latest) = latest {
130            packages.insert(name, latest);
131        }
132    }
133    let native_checks = crate::install::installed_native_packages()
134        .into_iter()
135        .filter(|package| package.source.is_none())
136        .map(|package| {
137            let client = client.clone();
138            let name = package.name;
139            async move {
140                let latest = fetch_crates_latest(&client, &name).await;
141                (name, latest)
142            }
143        });
144    let mut native_packages = BTreeMap::new();
145    for (name, latest) in join_all(native_checks).await {
146        if let Some(latest) = latest {
147            native_packages.insert(name, latest);
148        }
149    }
150    let checked_at = now_ms();
151    let (packages, packages_checked_at) = if enable_pi_packages {
152        (packages, checked_at)
153    } else {
154        (
155            previous_cache
156                .as_ref()
157                .map(|cache| cache.packages.clone())
158                .unwrap_or_default(),
159            previous_cache
160                .as_ref()
161                .map(|cache| cache.packages_checked_at)
162                .unwrap_or_default(),
163        )
164    };
165    let cache = UpdateCache {
166        checked_at,
167        rpi_latest,
168        packages,
169        packages_checked_at,
170        native_packages,
171    };
172    if cache.rpi_latest.is_some() || !cache.packages.is_empty() || !cache.native_packages.is_empty()
173    {
174        let _ = write_cache(&cache_path, &cache);
175    }
176    report_from_cache(&cache, package_resources)
177}
178
179fn cache_is_fresh(cache: &UpdateCache, enable_pi_packages: bool) -> bool {
180    let now = now_ms();
181    now.saturating_sub(cache.checked_at) < CACHE_TTL_MS
182        && (!enable_pi_packages || now.saturating_sub(cache.packages_checked_at) < CACHE_TTL_MS)
183}
184
185fn report_from_cache(
186    cache: &UpdateCache,
187    package_resources: Option<&crate::packages::PackageResources>,
188) -> UpdateReport {
189    let mut notices = Vec::new();
190    if let Some(latest) = cache.rpi_latest.as_deref() {
191        if is_newer(crate::VERSION, latest) {
192            notices.push(UpdateNotice {
193                name: "rpi".into(),
194                current: crate::VERSION.into(),
195                latest: latest.into(),
196                command: "rpi update".into(),
197            });
198        }
199    }
200    if let Some(resources) = package_resources {
201        for package in &resources.packages {
202            let Some(current) = package.version.as_deref() else {
203                continue;
204            };
205            let Some(latest) = cache.packages.get(&package.name) else {
206                continue;
207            };
208            if is_newer(current, latest) {
209                notices.push(UpdateNotice {
210                    name: package.name.clone(),
211                    current: current.into(),
212                    latest: latest.into(),
213                    command: "rpi package update".into(),
214                });
215            }
216        }
217    }
218    for package in crate::install::installed_native_packages() {
219        if package.source.is_some() {
220            continue;
221        }
222        let Some(latest) = cache.native_packages.get(&package.name) else {
223            continue;
224        };
225        if is_newer(&package.version, latest) {
226            notices.push(UpdateNotice {
227                name: package.name,
228                current: package.version,
229                latest: latest.clone(),
230                command: "rpi package update".into(),
231            });
232        }
233    }
234    UpdateReport { notices }
235}
236
237async fn fetch_rpi_latest(client: &reqwest::Client) -> Option<String> {
238    client
239        .get(CRATES_IO_API)
240        .send()
241        .await
242        .ok()?
243        .error_for_status()
244        .ok()?
245        .json::<CratesResponse>()
246        .await
247        .ok()
248        .map(|response| response.crate_info.max_version)
249}
250
251async fn fetch_npm_latest(client: &reqwest::Client, name: &str) -> Option<String> {
252    let encoded = name.replace('/', "%2F");
253    client
254        .get(format!("https://registry.npmjs.org/{encoded}"))
255        .send()
256        .await
257        .ok()?
258        .error_for_status()
259        .ok()?
260        .json::<NpmResponse>()
261        .await
262        .ok()?
263        .dist_tags?
264        .latest
265}
266
267async fn fetch_crates_latest(client: &reqwest::Client, name: &str) -> Option<String> {
268    client
269        .get(format!("https://crates.io/api/v1/crates/{name}"))
270        .send()
271        .await
272        .ok()?
273        .error_for_status()
274        .ok()?
275        .json::<CratesResponse>()
276        .await
277        .ok()
278        .map(|response| response.crate_info.max_version)
279}
280
281fn is_registry_package_path(path: &Path) -> bool {
282    let text = path
283        .to_string_lossy()
284        .replace('\\', "/")
285        .to_ascii_lowercase();
286    text.contains("/.rpi/packages/")
287        || text.contains("/.pi/packages/")
288        || text.contains("/agent/packages/")
289}
290
291/// Compare the numeric semver components. Pre-release/build metadata are
292/// intentionally ignored because update notifications should be conservative.
293pub fn is_newer(current: &str, latest: &str) -> bool {
294    let parse = |value: &str| -> Option<[u64; 3]> {
295        let value = value.trim().trim_start_matches('v');
296        let mut parts = value.split(['.', '-', '+']);
297        Some([
298            parts.next()?.parse().ok()?,
299            parts.next().unwrap_or("0").parse().ok()?,
300            parts.next().unwrap_or("0").parse().ok()?,
301        ])
302    };
303    match (parse(current), parse(latest)) {
304        (Some(current), Some(latest)) => latest > current,
305        _ => false,
306    }
307}
308
309fn read_cache(path: &Path) -> Option<UpdateCache> {
310    serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
311}
312
313fn write_cache(path: &Path, cache: &UpdateCache) -> Result<(), String> {
314    if let Some(parent) = path.parent() {
315        std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
316    }
317    let data = serde_json::to_vec_pretty(cache).map_err(|error| error.to_string())?;
318    std::fs::write(path, data).map_err(|error| error.to_string())
319}
320
321fn now_ms() -> i64 {
322    SystemTime::now()
323        .duration_since(UNIX_EPOCH)
324        .map(|duration| duration.as_millis() as i64)
325        .unwrap_or(0)
326}
327
328pub fn print_startup_notices(report: &UpdateReport) {
329    for notice in &report.notices {
330        eprintln!(
331            "Update available: {} {} -> {}. Run `{}`.",
332            notice.name, notice.current, notice.latest, notice.command
333        );
334    }
335}
336
337/// Update the installed rpi CLI through its documented crates.io install
338/// path. Cargo remains responsible for selecting the target and replacing the
339/// executable atomically.
340pub fn run_self_update(args: &[String]) -> i32 {
341    if args
342        .iter()
343        .any(|arg| matches!(arg.as_str(), "--help" | "-h"))
344    {
345        println!("Usage: rpi update\n\nUpdate the rpi CLI from crates.io.");
346        return 0;
347    }
348    if !args.is_empty() {
349        eprintln!("error: `rpi update` does not accept arguments");
350        return 2;
351    }
352    let status = std::process::Command::new("cargo")
353        .args(["install", "rpi-cli", "--locked", "--force"])
354        .status();
355    match status {
356        Ok(status) if status.success() => {
357            println!("rpi updated successfully");
358            0
359        }
360        Ok(status) => {
361            eprintln!("error: cargo install exited with {status}");
362            1
363        }
364        Err(error) => {
365            eprintln!("error: could not run cargo (install Rust/Cargo first): {error}");
366            1
367        }
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use std::collections::BTreeMap;
374
375    use super::{cache_is_fresh, is_newer, report_from_cache, UpdateCache};
376
377    #[test]
378    fn compares_release_versions_conservatively() {
379        assert!(is_newer("0.1.9", "0.1.10"));
380        assert!(is_newer("v1.2.3", "1.3.0"));
381        assert!(!is_newer("1.2.3", "1.2.3"));
382        assert!(!is_newer("nightly", "1.0.0"));
383    }
384
385    #[test]
386    fn disabled_package_gate_skips_cached_package_notices() {
387        let mut packages = BTreeMap::new();
388        packages.insert("@scope/example".to_string(), "9.9.9".to_string());
389        let cache = UpdateCache {
390            checked_at: 0,
391            rpi_latest: None,
392            packages,
393            packages_checked_at: 0,
394            native_packages: BTreeMap::new(),
395        };
396
397        let report = report_from_cache(&cache, None);
398        assert!(report.notices.is_empty());
399    }
400
401    #[test]
402    fn package_notices_use_only_the_supplied_resource_set() {
403        let temp = tempfile::tempdir().unwrap();
404        let package_dir = temp.path().join(".rpi/packages/example");
405        std::fs::create_dir_all(&package_dir).unwrap();
406        std::fs::write(
407            package_dir.join("package.json"),
408            r#"{"name":"example-package","version":"1.0.0"}"#,
409        )
410        .unwrap();
411        let resources =
412            crate::packages::discover(temp.path(), &[package_dir.to_string_lossy().into_owned()]);
413        assert_eq!(resources.packages.len(), 1);
414
415        let mut packages = BTreeMap::new();
416        packages.insert("example-package".to_string(), "2.0.0".to_string());
417        let cache = UpdateCache {
418            checked_at: 0,
419            rpi_latest: None,
420            packages,
421            packages_checked_at: 0,
422            native_packages: BTreeMap::new(),
423        };
424
425        assert!(report_from_cache(&cache, None).notices.is_empty());
426        let report = report_from_cache(&cache, Some(&resources));
427        assert_eq!(report.notices.len(), 1);
428        assert_eq!(report.notices[0].name, "example-package");
429    }
430
431    #[test]
432    fn opt_in_cache_requires_a_package_check_timestamp() {
433        let cache = UpdateCache {
434            checked_at: super::now_ms(),
435            rpi_latest: None,
436            packages: BTreeMap::new(),
437            packages_checked_at: 0,
438            native_packages: BTreeMap::new(),
439        };
440
441        assert!(cache_is_fresh(&cache, false));
442        assert!(!cache_is_fresh(&cache, true));
443    }
444}