1use 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 #[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
68pub async fn check_startup(cwd: &Path) -> UpdateReport {
73 let _ = cwd;
74 check_startup_with_package_resources(None).await
75}
76
77pub 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
291pub 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
337pub 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}