1use std::collections::HashMap;
2use std::io::{self, Write};
3#[cfg(unix)]
4use std::os::unix::fs::PermissionsExt;
5#[cfg(windows)]
6use std::os::windows::process::CommandExt;
7use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11
12use roboticus_core::home_dir;
13use roboticus_llm::oauth::check_and_repair_oauth_storage;
14
15use super::{colors, heading, icons};
16use crate::cli::{CRT_DRAW_MS, theme};
17
18pub(crate) const DEFAULT_REGISTRY_URL: &str = "https://roboticus.ai/registry/manifest.json";
19const CRATES_IO_API: &str = "https://crates.io/api/v1/crates/roboticus";
20const CRATE_NAME: &str = "roboticus";
21const RELEASE_BASE_URL: &str = "https://github.com/robot-accomplice/roboticus/releases/download";
22const GITHUB_RELEASES_API: &str =
23 "https://api.github.com/repos/robot-accomplice/roboticus/releases?per_page=100";
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct RegistryManifest {
29 pub version: String,
30 pub packs: Packs,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct Packs {
35 pub providers: ProviderPack,
36 pub skills: SkillPack,
37 #[serde(default)]
38 pub plugins: Option<roboticus_plugin_sdk::catalog::PluginCatalog>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct ProviderPack {
43 pub sha256: String,
44 pub path: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct SkillPack {
49 pub sha256: Option<String>,
50 pub path: String,
51 pub files: HashMap<String, String>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, Default)]
57pub struct UpdateState {
58 pub binary_version: String,
59 pub last_check: String,
60 pub registry_url: String,
61 pub installed_content: InstalledContent,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, Default)]
65pub struct InstalledContent {
66 pub providers: Option<ContentRecord>,
67 pub skills: Option<SkillsRecord>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct ContentRecord {
72 pub version: String,
73 pub sha256: String,
74 pub installed_at: String,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct SkillsRecord {
79 pub version: String,
80 pub files: HashMap<String, String>,
81 pub installed_at: String,
82}
83
84impl UpdateState {
85 pub fn load() -> Self {
86 let path = state_path();
87 if path.exists() {
88 match std::fs::read_to_string(&path) {
89 Ok(content) => serde_json::from_str(&content)
90 .inspect_err(|e| tracing::warn!(error = %e, "corrupted update state file, resetting to default"))
91 .unwrap_or_default(),
92 Err(e) => {
93 tracing::warn!(error = %e, "failed to read update state file, resetting to default");
94 Self::default()
95 }
96 }
97 } else {
98 Self::default()
99 }
100 }
101
102 pub fn save(&self) -> io::Result<()> {
103 let path = state_path();
104 if let Some(parent) = path.parent() {
105 std::fs::create_dir_all(parent)?;
106 }
107 let json = serde_json::to_string_pretty(self).map_err(io::Error::other)?;
108 std::fs::write(&path, json)
109 }
110}
111
112fn state_path() -> PathBuf {
113 home_dir().join(".roboticus").join("update_state.json")
114}
115
116fn roboticus_home() -> PathBuf {
117 home_dir().join(".roboticus")
118}
119
120fn now_iso() -> String {
121 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
122}
123
124fn is_safe_skill_path(base_dir: &Path, filename: &str) -> bool {
133 if filename.contains("..") || Path::new(filename).is_absolute() {
135 return false;
136 }
137 let effective_base = base_dir.canonicalize().unwrap_or_else(|_| {
142 base_dir.components().fold(PathBuf::new(), |mut acc, c| {
143 acc.push(c);
144 acc
145 })
146 });
147 let joined = effective_base.join(filename);
150 let normalized: PathBuf = joined.components().fold(PathBuf::new(), |mut acc, c| {
151 match c {
152 std::path::Component::ParentDir => {
153 acc.pop();
154 }
155 other => acc.push(other),
156 }
157 acc
158 });
159 normalized.starts_with(&effective_base)
160}
161
162pub fn file_sha256(path: &Path) -> io::Result<String> {
163 let bytes = std::fs::read(path)?;
164 let hash = Sha256::digest(&bytes);
165 Ok(hex::encode(hash))
166}
167
168pub fn bytes_sha256(data: &[u8]) -> String {
169 let hash = Sha256::digest(data);
170 hex::encode(hash)
171}
172
173pub(crate) fn resolve_registry_url(cli_override: Option<&str>, config_path: &str) -> String {
174 if let Some(url) = cli_override {
175 return url.to_string();
176 }
177 if let Ok(val) = std::env::var("ROBOTICUS_REGISTRY_URL")
178 && !val.is_empty()
179 {
180 return val;
181 }
182 if let Ok(content) = std::fs::read_to_string(config_path)
183 && let Ok(config) = content.parse::<toml::Value>()
184 && let Some(url) = config
185 .get("update")
186 .and_then(|u| u.get("registry_url"))
187 .and_then(|v| v.as_str())
188 && !url.is_empty()
189 {
190 return url.to_string();
191 }
192 DEFAULT_REGISTRY_URL.to_string()
193}
194
195pub(crate) fn registry_base_url(manifest_url: &str) -> String {
196 if let Some(pos) = manifest_url.rfind('/') {
197 manifest_url[..pos].to_string()
198 } else {
199 manifest_url.to_string()
200 }
201}
202
203fn confirm_action(prompt: &str, default_yes: bool) -> bool {
204 let hint = if default_yes { "[Y/n]" } else { "[y/N]" };
205 print!(" {prompt} {hint} ");
206 io::stdout().flush().ok();
207 let mut input = String::new();
208 if io::stdin().read_line(&mut input).is_err() {
209 return default_yes;
210 }
211 let answer = input.trim().to_lowercase();
212 if answer.is_empty() {
213 return default_yes;
214 }
215 matches!(answer.as_str(), "y" | "yes")
216}
217
218fn confirm_overwrite(filename: &str) -> OverwriteChoice {
219 let (_, _, _, _, YELLOW, _, _, RESET, _) = colors();
220 print!(" Overwrite {YELLOW}{filename}{RESET}? [y/N/backup] ");
221 io::stdout().flush().ok();
222 let mut input = String::new();
223 if io::stdin().read_line(&mut input).is_err() {
224 return OverwriteChoice::Skip;
225 }
226 match input.trim().to_lowercase().as_str() {
227 "y" | "yes" => OverwriteChoice::Overwrite,
228 "b" | "backup" => OverwriteChoice::Backup,
229 _ => OverwriteChoice::Skip,
230 }
231}
232
233#[derive(Debug, PartialEq)]
234enum OverwriteChoice {
235 Overwrite,
236 Backup,
237 Skip,
238}
239
240fn http_client() -> Result<reqwest::Client, Box<dyn std::error::Error>> {
241 Ok(reqwest::Client::builder()
242 .timeout(std::time::Duration::from_secs(15))
243 .user_agent(format!("roboticus/{}", env!("CARGO_PKG_VERSION")))
244 .build()?)
245}
246
247fn run_oauth_storage_maintenance() {
248 let (OK, _, WARN, DETAIL, _) = icons();
249 let oauth_health = check_and_repair_oauth_storage(true);
250 if oauth_health.needs_attention() {
251 if oauth_health.repaired {
252 println!(" {OK} OAuth token storage repaired/migrated");
253 } else if !oauth_health.keystore_available {
254 println!(" {WARN} OAuth migration check skipped (keystore unavailable)");
255 println!(
256 " {DETAIL} Run `roboticus mechanic --repair` after fixing keystore access."
257 );
258 } else {
259 println!(" {WARN} OAuth token storage requires manual attention");
260 println!(" {DETAIL} Run `roboticus mechanic --repair` to attempt recovery.");
261 }
262 } else {
263 println!(" {OK} OAuth token storage is healthy");
264 }
265}
266
267pub type HygieneFn = Box<
271 dyn Fn(&str) -> Result<Option<(u64, u64, u64, u64)>, Box<dyn std::error::Error>> + Send + Sync,
272>;
273
274pub type DaemonOps = Box<dyn Fn() -> Result<(), Box<dyn std::error::Error>> + Send + Sync>;
276
277pub struct DaemonCallbacks {
279 pub is_installed: Box<dyn Fn() -> bool + Send + Sync>,
280 pub restart: DaemonOps,
281}
282
283fn run_mechanic_checks_maintenance(config_path: &str, hygiene_fn: Option<&HygieneFn>) {
284 let (OK, _, WARN, DETAIL, _) = icons();
285 let Some(hygiene) = hygiene_fn else {
286 return;
287 };
288 match hygiene(config_path) {
289 Ok(Some((changed_rows, subagent, cron_payload, cron_disabled))) if changed_rows > 0 => {
290 println!(
291 " {OK} Mechanic checks repaired {changed_rows} row(s) (subagents={subagent}, cron_payloads={cron_payload}, invalid_cron_disabled={cron_disabled})"
292 );
293 }
294 Ok(_) => println!(" {OK} Mechanic checks found no repairs needed"),
295 Err(e) => {
296 println!(" {WARN} Mechanic checks failed: {e}");
297 println!(" {DETAIL} Run `roboticus mechanic --repair` for detailed diagnostics.");
298 }
299 }
300}
301
302fn apply_removed_legacy_config_migration(
303 config_path: &str,
304) -> Result<(), Box<dyn std::error::Error>> {
305 let path = Path::new(config_path);
306 let (_, _, WARN, DETAIL, _) = icons();
307 if let Some(report) = roboticus_core::config_utils::migrate_removed_legacy_config_file(path)? {
308 println!(" {WARN} Removed legacy config compatibility settings during update");
309 if report.renamed_server_host_to_bind {
310 println!(" {DETAIL} Renamed [server].host to [server].bind");
311 }
312 if report.routing_mode_heuristic_rewritten {
313 println!(" {DETAIL} Rewrote models.routing.mode from heuristic to metascore");
314 }
315 if report.deny_on_empty_allowlist_hardened {
316 println!(" {DETAIL} Hardened security.deny_on_empty_allowlist to true");
317 }
318 if report.removed_credit_cooldown_seconds {
319 println!(" {DETAIL} Removed deprecated circuit_breaker.credit_cooldown_seconds");
320 }
321 }
322 Ok(())
323}
324
325fn parse_semver(v: &str) -> (u32, u32, u32) {
328 let v = v.trim_start_matches('v');
329 let v = v.split_once('+').map(|(core, _)| core).unwrap_or(v);
330 let v = v.split_once('-').map(|(core, _)| core).unwrap_or(v);
331 let parts: Vec<&str> = v.split('.').collect();
332 let major = parts
333 .first()
334 .and_then(|s| s.parse().ok())
335 .unwrap_or_else(|| {
336 tracing::warn!(version = v, "failed to parse major version component");
337 0
338 });
339 let minor = parts
340 .get(1)
341 .and_then(|s| s.parse().ok())
342 .unwrap_or_else(|| {
343 tracing::warn!(version = v, "failed to parse minor version component");
344 0
345 });
346 let patch = parts
347 .get(2)
348 .and_then(|s| s.parse().ok())
349 .unwrap_or_else(|| {
350 tracing::warn!(version = v, "failed to parse patch version component");
351 0
352 });
353 (major, minor, patch)
354}
355
356pub(crate) fn is_newer(remote: &str, local: &str) -> bool {
357 parse_semver(remote) > parse_semver(local)
358}
359
360fn platform_archive_name(version: &str) -> Option<String> {
361 let (arch, os, ext) = platform_archive_parts()?;
362 Some(format!("roboticus-{version}-{arch}-{os}.{ext}"))
363}
364
365fn platform_archive_parts() -> Option<(&'static str, &'static str, &'static str)> {
366 let arch = match std::env::consts::ARCH {
367 "x86_64" => "x86_64",
368 "aarch64" => "aarch64",
369 _ => return None,
370 };
371 let os = match std::env::consts::OS {
372 "linux" => "linux",
373 "macos" => "darwin",
374 "windows" => "windows",
375 _ => return None,
376 };
377 let ext = if os == "windows" { "zip" } else { "tar.gz" };
378 Some((arch, os, ext))
379}
380
381fn parse_sha256sums_for_artifact(sha256sums: &str, artifact: &str) -> Option<String> {
382 for raw in sha256sums.lines() {
383 let line = raw.trim();
384 if line.is_empty() || line.starts_with('#') {
385 continue;
386 }
387 let mut parts = line.split_whitespace();
388 let hash = parts.next()?;
389 let file = parts.next()?;
390 if file == artifact {
391 return Some(hash.to_ascii_lowercase());
392 }
393 }
394 None
395}
396
397#[derive(Debug, Clone, Deserialize)]
398struct GitHubRelease {
399 tag_name: String,
400 draft: bool,
401 prerelease: bool,
402 published_at: Option<String>,
403 assets: Vec<GitHubAsset>,
404}
405
406#[derive(Debug, Clone, Deserialize)]
407struct GitHubAsset {
408 name: String,
409}
410
411fn core_version(s: &str) -> &str {
412 let s = s.trim_start_matches('v');
413 let s = s.split_once('+').map(|(core, _)| core).unwrap_or(s);
414 s.split_once('-').map(|(core, _)| core).unwrap_or(s)
415}
416
417fn select_archive_asset_name(release: &GitHubRelease, version: &str) -> Option<String> {
418 let exact = platform_archive_name(version)?;
419 if release.assets.iter().any(|a| a.name == exact) {
420 return Some(exact);
421 }
422 let (arch, os, ext) = platform_archive_parts()?;
423 let suffix = format!("-{arch}-{os}.{ext}");
424 let core_prefix = format!("roboticus-{}", core_version(version));
425 release
426 .assets
427 .iter()
428 .find(|a| a.name.ends_with(&suffix) && a.name.starts_with(&core_prefix))
429 .map(|a| a.name.clone())
430}
431
432fn select_release_for_download(
433 releases: &[GitHubRelease],
434 version: &str,
435) -> Option<(String, String)> {
436 let canonical = format!("v{version}");
437
438 if let Some(exact) = releases
439 .iter()
440 .find(|r| !r.draft && !r.prerelease && r.tag_name == canonical)
441 {
442 let has_sums = exact.assets.iter().any(|a| a.name == "SHA256SUMS.txt");
443 if has_sums && let Some(archive) = select_archive_asset_name(exact, version) {
444 return Some((exact.tag_name.clone(), archive));
445 }
446 }
447
448 releases
449 .iter()
450 .filter(|r| !r.draft && !r.prerelease)
451 .filter(|r| core_version(&r.tag_name) == core_version(version))
452 .filter(|r| r.assets.iter().any(|a| a.name == "SHA256SUMS.txt"))
453 .filter_map(|r| select_archive_asset_name(r, version).map(|archive| (r, archive)))
454 .max_by_key(|(r, _)| r.published_at.as_deref().unwrap_or(""))
455 .map(|(r, archive)| (r.tag_name.clone(), archive))
456}
457
458async fn resolve_download_release(
459 client: &reqwest::Client,
460 version: &str,
461) -> Result<(String, String), Box<dyn std::error::Error>> {
462 let resp = client.get(GITHUB_RELEASES_API).send().await?;
463 if !resp.status().is_success() {
464 return Err(format!("Failed to query GitHub releases: HTTP {}", resp.status()).into());
465 }
466 let releases: Vec<GitHubRelease> = resp.json().await?;
467 select_release_for_download(&releases, version).ok_or_else(|| {
468 format!(
469 "No downloadable release found for v{version} with required platform archive and SHA256SUMS.txt"
470 )
471 .into()
472 })
473}
474
475fn find_file_recursive(root: &Path, filename: &str) -> io::Result<Option<PathBuf>> {
476 find_file_recursive_depth(root, filename, 10)
477}
478
479fn find_file_recursive_depth(
480 root: &Path,
481 filename: &str,
482 remaining_depth: usize,
483) -> io::Result<Option<PathBuf>> {
484 if remaining_depth == 0 {
485 return Ok(None);
486 }
487 for entry in std::fs::read_dir(root)? {
488 let entry = entry?;
489 let path = entry.path();
490 if path.is_dir() {
491 if let Some(found) = find_file_recursive_depth(&path, filename, remaining_depth - 1)? {
492 return Ok(Some(found));
493 }
494 } else if path
495 .file_name()
496 .and_then(|n| n.to_str())
497 .map(|n| n == filename)
498 .unwrap_or(false)
499 {
500 return Ok(Some(path));
501 }
502 }
503 Ok(None)
504}
505
506fn install_binary_bytes(bytes: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
507 #[cfg(windows)]
508 {
509 let exe = std::env::current_exe()?;
510 let staging_dir = std::env::temp_dir().join(format!(
511 "roboticus-update-{}-{}",
512 std::process::id(),
513 chrono::Utc::now().timestamp_millis()
514 ));
515 std::fs::create_dir_all(&staging_dir)?;
516 let staged_exe = staging_dir.join("roboticus-staged.exe");
517 std::fs::write(&staged_exe, bytes)?;
518 let log_file = staging_dir.join("apply-update.log");
519 let script_path = staging_dir.join("apply-update.cmd");
520 let script = format!(
523 "@echo off\r\n\
524 setlocal\r\n\
525 set SRC={src}\r\n\
526 set DST={dst}\r\n\
527 set LOG={log}\r\n\
528 echo [%DATE% %TIME%] Starting binary replacement >> \"%LOG%\"\r\n\
529 for /L %%i in (1,1,60) do (\r\n\
530 copy /Y \"%SRC%\" \"%DST%\" >nul 2>nul && goto :ok\r\n\
531 timeout /t 1 /nobreak >nul\r\n\
532 )\r\n\
533 echo [%DATE% %TIME%] FAILED: could not replace binary after 60 attempts >> \"%LOG%\"\r\n\
534 exit /b 1\r\n\
535 :ok\r\n\
536 echo [%DATE% %TIME%] SUCCESS: binary replaced >> \"%LOG%\"\r\n\
537 del /Q \"%SRC%\" >nul 2>nul\r\n\
538 del /Q \"%~f0\" >nul 2>nul\r\n\
539 exit /b 0\r\n",
540 src = staged_exe.display(),
541 dst = exe.display(),
542 log = log_file.display(),
543 );
544 std::fs::write(&script_path, &script)?;
545 let _child = std::process::Command::new("cmd")
546 .arg("/C")
547 .arg(script_path.to_string_lossy().as_ref())
548 .creation_flags(0x00000008) .spawn()?;
550 #[allow(clippy::needless_return)]
551 return Ok(());
552 }
553
554 #[cfg(not(windows))]
555 {
556 let exe = std::env::current_exe()?;
557 let tmp = exe.with_extension("new");
558 std::fs::write(&tmp, bytes)?;
559 #[cfg(unix)]
560 {
561 let mode = std::fs::metadata(&exe)
562 .map(|m| m.permissions().mode())
563 .unwrap_or(0o755);
564 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode))?;
565 }
566 std::fs::rename(&tmp, &exe)?;
567 Ok(())
568 }
569}
570
571async fn apply_binary_download_update(
572 client: &reqwest::Client,
573 latest: &str,
574) -> Result<(), Box<dyn std::error::Error>> {
575 let _archive_probe = platform_archive_name(latest).ok_or_else(|| {
576 format!(
577 "No release archive mapping for platform {}/{}",
578 std::env::consts::OS,
579 std::env::consts::ARCH
580 )
581 })?;
582 let (tag, archive) = resolve_download_release(client, latest).await?;
583 let sha_url = format!("{RELEASE_BASE_URL}/{tag}/SHA256SUMS.txt");
584 let archive_url = format!("{RELEASE_BASE_URL}/{tag}/{archive}");
585
586 let sha_resp = client.get(&sha_url).send().await?;
587 if !sha_resp.status().is_success() {
588 return Err(format!("Failed to fetch SHA256SUMS.txt: HTTP {}", sha_resp.status()).into());
589 }
590 let sha_body = sha_resp.text().await?;
591 let expected = parse_sha256sums_for_artifact(&sha_body, &archive)
592 .ok_or_else(|| format!("No checksum found for artifact {archive}"))?;
593
594 let archive_resp = client.get(&archive_url).send().await?;
595 if !archive_resp.status().is_success() {
596 return Err(format!(
597 "Failed to download release archive: HTTP {}",
598 archive_resp.status()
599 )
600 .into());
601 }
602 let archive_bytes = archive_resp.bytes().await?.to_vec();
603 let actual = bytes_sha256(&archive_bytes);
604 if actual != expected {
605 return Err(
606 format!("SHA256 mismatch for {archive}: expected {expected}, got {actual}").into(),
607 );
608 }
609
610 let temp_root = std::env::temp_dir().join(format!(
611 "roboticus-update-{}-{}",
612 std::process::id(),
613 chrono::Utc::now().timestamp_millis()
614 ));
615 std::fs::create_dir_all(&temp_root)?;
616 let archive_path = if archive.ends_with(".zip") {
617 temp_root.join("roboticus.zip")
618 } else {
619 temp_root.join("roboticus.tar.gz")
620 };
621 std::fs::write(&archive_path, &archive_bytes)?;
622
623 if archive.ends_with(".zip") {
624 let status = std::process::Command::new("powershell")
625 .args([
626 "-NoProfile",
627 "-ExecutionPolicy",
628 "Bypass",
629 "-Command",
630 &format!(
631 "Expand-Archive -Path \"{}\" -DestinationPath \"{}\" -Force",
632 archive_path.display(),
633 temp_root.display()
634 ),
635 ])
636 .status()?;
637 if !status.success() {
638 let _ = std::fs::remove_dir_all(&temp_root);
640 return Err(
641 format!("Failed to extract {archive} with PowerShell Expand-Archive").into(),
642 );
643 }
644 } else {
645 let status = std::process::Command::new("tar")
646 .arg("-xzf")
647 .arg(&archive_path)
648 .arg("-C")
649 .arg(&temp_root)
650 .status()?;
651 if !status.success() {
652 let _ = std::fs::remove_dir_all(&temp_root);
654 return Err(format!("Failed to extract {archive} with tar").into());
655 }
656 }
657
658 let bin_name = if std::env::consts::OS == "windows" {
659 "roboticus.exe"
660 } else {
661 "roboticus"
662 };
663 let extracted = find_file_recursive(&temp_root, bin_name)?
664 .ok_or_else(|| format!("Could not locate extracted {bin_name} binary"))?;
665 let bytes = std::fs::read(&extracted)?;
666 install_binary_bytes(&bytes)?;
667 let _ = std::fs::remove_dir_all(&temp_root);
669 Ok(())
670}
671
672fn c_compiler_available() -> bool {
673 #[cfg(windows)]
674 {
675 if std::process::Command::new("cmd")
676 .args(["/C", "where", "cl"])
677 .status()
678 .map(|s| s.success())
679 .unwrap_or(false)
680 {
681 return true;
682 }
683 if std::process::Command::new("gcc")
684 .arg("--version")
685 .status()
686 .map(|s| s.success())
687 .unwrap_or(false)
688 {
689 return true;
690 }
691 #[allow(clippy::needless_return)]
692 return std::process::Command::new("clang")
693 .arg("--version")
694 .status()
695 .map(|s| s.success())
696 .unwrap_or(false);
697 }
698
699 #[cfg(not(windows))]
700 {
701 if std::process::Command::new("cc")
702 .arg("--version")
703 .status()
704 .map(|s| s.success())
705 .unwrap_or(false)
706 {
707 return true;
708 }
709 if std::process::Command::new("clang")
710 .arg("--version")
711 .status()
712 .map(|s| s.success())
713 .unwrap_or(false)
714 {
715 return true;
716 }
717 std::process::Command::new("gcc")
718 .arg("--version")
719 .status()
720 .map(|s| s.success())
721 .unwrap_or(false)
722 }
723}
724
725fn apply_binary_cargo_update(latest: &str) -> bool {
726 let (DIM, _, _, _, _, _, _, RESET, _) = colors();
727 let (OK, _, WARN, DETAIL, ERR) = icons();
728 if !c_compiler_available() {
729 println!(" {WARN} Local build toolchain check failed: no C compiler found in PATH");
730 println!(
731 " {DETAIL} `--method build` requires a C compiler (and related native build tools)."
732 );
733 println!(
734 " {DETAIL} Recommended: use `roboticus update binary --method download --yes`."
735 );
736 #[cfg(windows)]
737 {
738 println!(
739 " {DETAIL} Windows: install Visual Studio Build Tools (MSVC) or clang/gcc."
740 );
741 }
742 #[cfg(target_os = "macos")]
743 {
744 println!(" {DETAIL} macOS: run `xcode-select --install`.");
745 }
746 #[cfg(target_os = "linux")]
747 {
748 println!(
749 " {DETAIL} Linux: install build tools (for example `build-essential` on Debian/Ubuntu)."
750 );
751 }
752 return false;
753 }
754 println!(" Installing v{latest} via cargo install...");
755 println!(" {DIM}This may take a few minutes.{RESET}");
756
757 let status = std::process::Command::new("cargo")
758 .args(["install", CRATE_NAME])
759 .status();
760
761 match status {
762 Ok(s) if s.success() => {
763 println!(" {OK} Binary updated to v{latest}");
764 true
765 }
766 Ok(s) => {
767 println!(
768 " {ERR} cargo install exited with code {}",
769 s.code().unwrap_or(-1)
770 );
771 false
772 }
773 Err(e) => {
774 println!(" {ERR} Failed to run cargo install: {e}");
775 println!(" {DIM}Ensure cargo is in your PATH{RESET}");
776 false
777 }
778 }
779}
780
781pub fn diff_lines(old: &str, new: &str) -> Vec<DiffLine> {
784 let old_lines: Vec<&str> = old.lines().collect();
785 let new_lines: Vec<&str> = new.lines().collect();
786 let mut result = Vec::new();
787
788 let max = old_lines.len().max(new_lines.len());
789 for i in 0..max {
790 match (old_lines.get(i), new_lines.get(i)) {
791 (Some(o), Some(n)) if o == n => {
792 result.push(DiffLine::Same((*o).to_string()));
793 }
794 (Some(o), Some(n)) => {
795 result.push(DiffLine::Removed((*o).to_string()));
796 result.push(DiffLine::Added((*n).to_string()));
797 }
798 (Some(o), None) => {
799 result.push(DiffLine::Removed((*o).to_string()));
800 }
801 (None, Some(n)) => {
802 result.push(DiffLine::Added((*n).to_string()));
803 }
804 (None, None) => {}
805 }
806 }
807 result
808}
809
810#[derive(Debug, PartialEq)]
811pub enum DiffLine {
812 Same(String),
813 Added(String),
814 Removed(String),
815}
816
817fn print_diff(old: &str, new: &str) {
818 let (DIM, _, _, GREEN, _, RED, _, RESET, _) = colors();
819 let lines = diff_lines(old, new);
820 let changes: Vec<&DiffLine> = lines
821 .iter()
822 .filter(|l| !matches!(l, DiffLine::Same(_)))
823 .collect();
824
825 if changes.is_empty() {
826 println!(" {DIM}(no changes){RESET}");
827 return;
828 }
829
830 for line in &changes {
831 match line {
832 DiffLine::Removed(s) => println!(" {RED}- {s}{RESET}"),
833 DiffLine::Added(s) => println!(" {GREEN}+ {s}{RESET}"),
834 DiffLine::Same(_) => {}
835 }
836 }
837}
838
839pub(crate) async fn check_binary_version(
842 client: &reqwest::Client,
843) -> Result<Option<String>, Box<dyn std::error::Error>> {
844 let resp = client.get(CRATES_IO_API).send().await?;
845 if !resp.status().is_success() {
846 return Ok(None);
847 }
848 let body: serde_json::Value = resp.json().await?;
849 let latest = body
850 .pointer("/crate/max_version")
851 .and_then(|v| v.as_str())
852 .map(String::from);
853 Ok(latest)
854}
855
856async fn apply_binary_update(yes: bool, method: &str) -> Result<bool, Box<dyn std::error::Error>> {
857 let (DIM, BOLD, _, GREEN, _, _, _, RESET, MONO) = colors();
858 let (OK, _, WARN, DETAIL, ERR) = icons();
859 let current = env!("CARGO_PKG_VERSION");
860 let client = http_client()?;
861 let method = method.to_ascii_lowercase();
862
863 println!("\n {BOLD}Binary Update{RESET}\n");
864 println!(" Current version: {MONO}v{current}{RESET}");
865
866 let latest = match check_binary_version(&client).await? {
867 Some(v) => v,
868 None => {
869 println!(" {WARN} Could not reach crates.io");
870 return Ok(false);
871 }
872 };
873
874 println!(" Latest version: {MONO}v{latest}{RESET}");
875
876 if !is_newer(&latest, current) {
877 println!(" {OK} Already on latest version");
878 return Ok(false);
879 }
880
881 println!(" {GREEN}New version available: v{latest}{RESET}");
882 println!();
883
884 if std::env::consts::OS == "windows" && method == "build" {
885 println!(" {WARN} Build method is not supported in-process on Windows");
886 println!(
887 " {DETAIL} Running executables are file-locked. Use `--method download` (recommended),"
888 );
889 println!(
890 " {DETAIL} or run `cargo install {CRATE_NAME} --force` from a separate PowerShell session."
891 );
892 return Ok(false);
893 }
894
895 if !yes && !confirm_action("Proceed with binary update?", true) {
896 println!(" Skipped.");
897 return Ok(false);
898 }
899
900 let mut updated = false;
901 if method == "download" {
902 println!(" Attempting platform binary download + fingerprint verification...");
903 match apply_binary_download_update(&client, &latest).await {
904 Ok(()) => {
905 println!(" {OK} Binary downloaded and verified (SHA256)");
906 if std::env::consts::OS == "windows" {
907 println!(
908 " {DETAIL} Update staged. The replacement finalizes after this process exits."
909 );
910 println!(
911 " {DETAIL} Re-run `roboticus version` in a few seconds to confirm."
912 );
913 }
914 updated = true;
915 }
916 Err(e) => {
917 println!(" {WARN} Download update failed: {e}");
918 if std::env::consts::OS == "windows" {
919 println!(
920 " {DETAIL} On Windows, fallback build-in-place is blocked by executable locks."
921 );
922 println!(
923 " {DETAIL} Retry `roboticus update binary --method download` or run build update from a separate shell."
924 );
925 } else if confirm_action(
926 "Download failed. Fall back to cargo build update? (slower, compiles from source)",
927 true,
928 ) {
929 updated = apply_binary_cargo_update(&latest);
933 } else {
934 println!(" Skipped fallback build.");
935 }
936 }
937 }
938 } else {
939 updated = apply_binary_cargo_update(&latest);
940 }
941
942 if updated {
943 println!(" {OK} Binary updated to v{latest}");
944 let mut state = UpdateState::load();
945 state.binary_version = latest;
946 state.last_check = now_iso();
947 state
948 .save()
949 .inspect_err(
950 |e| tracing::warn!(error = %e, "failed to save update state after version check"),
951 )
952 .ok();
953 Ok(true)
954 } else {
955 if method == "download" {
956 println!(" {ERR} Binary update did not complete");
957 }
958 Ok(false)
959 }
960}
961
962pub(crate) async fn fetch_manifest(
965 client: &reqwest::Client,
966 registry_url: &str,
967) -> Result<RegistryManifest, Box<dyn std::error::Error>> {
968 let resp = client.get(registry_url).send().await?;
969 if !resp.status().is_success() {
970 return Err(format!("Registry returned HTTP {}", resp.status()).into());
971 }
972 let manifest: RegistryManifest = resp.json().await?;
973 Ok(manifest)
974}
975
976async fn fetch_file(
977 client: &reqwest::Client,
978 base_url: &str,
979 relative_path: &str,
980) -> Result<String, Box<dyn std::error::Error>> {
981 let url = format!("{base_url}/{relative_path}");
982 let resp = client.get(&url).send().await?;
983 if !resp.status().is_success() {
984 return Err(format!("Failed to fetch {relative_path}: HTTP {}", resp.status()).into());
985 }
986 Ok(resp.text().await?)
987}
988
989async fn apply_providers_update(
990 yes: bool,
991 registry_url: &str,
992 config_path: &str,
993) -> Result<bool, Box<dyn std::error::Error>> {
994 let (DIM, BOLD, _, GREEN, YELLOW, _, _, RESET, MONO) = colors();
995 let (OK, _, WARN, DETAIL, _) = icons();
996 let client = http_client()?;
997
998 println!("\n {BOLD}Provider Configs{RESET}\n");
999
1000 let manifest = match fetch_manifest(&client, registry_url).await {
1001 Ok(m) => m,
1002 Err(e) => {
1003 println!(" {WARN} Could not fetch registry manifest: {e}");
1004 return Ok(false);
1005 }
1006 };
1007
1008 let base_url = registry_base_url(registry_url);
1009 let remote_content = match fetch_file(&client, &base_url, &manifest.packs.providers.path).await
1010 {
1011 Ok(c) => c,
1012 Err(e) => {
1013 println!(" {WARN} Could not fetch providers.toml: {e}");
1014 return Ok(false);
1015 }
1016 };
1017
1018 let remote_hash = bytes_sha256(remote_content.as_bytes());
1019 let state = UpdateState::load();
1020
1021 let local_path = providers_local_path(config_path);
1022 let local_exists = local_path.exists();
1023 let local_content = if local_exists {
1024 std::fs::read_to_string(&local_path).unwrap_or_default()
1025 } else {
1026 String::new()
1027 };
1028
1029 if local_exists {
1030 let local_hash = bytes_sha256(local_content.as_bytes());
1031 if local_hash == remote_hash {
1032 println!(" {OK} Provider configs are up to date");
1033 return Ok(false);
1034 }
1035 }
1036
1037 let user_modified = if let Some(ref record) = state.installed_content.providers {
1038 if local_exists {
1039 let current_hash = file_sha256(&local_path).unwrap_or_default();
1040 current_hash != record.sha256
1041 } else {
1042 false
1043 }
1044 } else {
1045 local_exists
1046 };
1047
1048 if !local_exists {
1049 println!(" {GREEN}+ New provider configuration available{RESET}");
1050 print_diff("", &remote_content);
1051 } else if user_modified {
1052 println!(" {YELLOW}Provider config has been modified locally{RESET}");
1053 println!(" Changes from registry:");
1054 print_diff(&local_content, &remote_content);
1055 } else {
1056 println!(" Updated provider configuration available");
1057 print_diff(&local_content, &remote_content);
1058 }
1059
1060 println!();
1061
1062 if user_modified {
1063 match confirm_overwrite("providers config") {
1064 OverwriteChoice::Overwrite => {}
1065 OverwriteChoice::Backup => {
1066 let backup = local_path.with_extension("toml.bak");
1067 std::fs::copy(&local_path, &backup)?;
1068 println!(" {DETAIL} Backed up to {}", backup.display());
1069 }
1070 OverwriteChoice::Skip => {
1071 println!(" Skipped.");
1072 return Ok(false);
1073 }
1074 }
1075 } else if !yes && !confirm_action("Apply provider updates?", true) {
1076 println!(" Skipped.");
1077 return Ok(false);
1078 }
1079
1080 if let Some(parent) = local_path.parent() {
1081 std::fs::create_dir_all(parent)?;
1082 }
1083 std::fs::write(&local_path, &remote_content)?;
1084
1085 let mut state = UpdateState::load();
1086 state.installed_content.providers = Some(ContentRecord {
1087 version: manifest.version.clone(),
1088 sha256: remote_hash,
1089 installed_at: now_iso(),
1090 });
1091 state.last_check = now_iso();
1092 state
1093 .save()
1094 .inspect_err(
1095 |e| tracing::warn!(error = %e, "failed to save update state after provider install"),
1096 )
1097 .ok();
1098
1099 println!(" {OK} Provider configs updated to v{}", manifest.version);
1100 Ok(true)
1101}
1102
1103fn providers_local_path(config_path: &str) -> PathBuf {
1104 if let Ok(content) = std::fs::read_to_string(config_path)
1105 && let Ok(config) = content.parse::<toml::Value>()
1106 && let Some(path) = config.get("providers_file").and_then(|v| v.as_str())
1107 {
1108 return PathBuf::from(path);
1109 }
1110 roboticus_home().join("providers.toml")
1111}
1112
1113async fn apply_skills_update(
1114 yes: bool,
1115 registry_url: &str,
1116 config_path: &str,
1117) -> Result<bool, Box<dyn std::error::Error>> {
1118 let (DIM, BOLD, _, GREEN, YELLOW, _, _, RESET, MONO) = colors();
1119 let (OK, _, WARN, DETAIL, _) = icons();
1120 let client = http_client()?;
1121
1122 println!("\n {BOLD}Skills{RESET}\n");
1123
1124 let manifest = match fetch_manifest(&client, registry_url).await {
1125 Ok(m) => m,
1126 Err(e) => {
1127 println!(" {WARN} Could not fetch registry manifest: {e}");
1128 return Ok(false);
1129 }
1130 };
1131
1132 let base_url = registry_base_url(registry_url);
1133 let state = UpdateState::load();
1134 let skills_dir = skills_local_dir(config_path);
1135
1136 if !skills_dir.exists() {
1137 std::fs::create_dir_all(&skills_dir)?;
1138 }
1139
1140 let mut new_files = Vec::new();
1141 let mut updated_unmodified = Vec::new();
1142 let mut updated_modified = Vec::new();
1143 let mut up_to_date = Vec::new();
1144
1145 for (filename, remote_hash) in &manifest.packs.skills.files {
1146 if !is_safe_skill_path(&skills_dir, filename) {
1148 tracing::warn!(filename, "skipping manifest entry with suspicious path");
1149 continue;
1150 }
1151
1152 let local_file = skills_dir.join(filename);
1153 let installed_hash = state
1154 .installed_content
1155 .skills
1156 .as_ref()
1157 .and_then(|s| s.files.get(filename))
1158 .cloned();
1159
1160 if !local_file.exists() {
1161 new_files.push(filename.clone());
1162 continue;
1163 }
1164
1165 let current_hash = file_sha256(&local_file).unwrap_or_default();
1166 if ¤t_hash == remote_hash {
1167 up_to_date.push(filename.clone());
1168 continue;
1169 }
1170
1171 let user_modified = match &installed_hash {
1172 Some(ih) => current_hash != *ih,
1173 None => true,
1174 };
1175
1176 if user_modified {
1177 updated_modified.push(filename.clone());
1178 } else {
1179 updated_unmodified.push(filename.clone());
1180 }
1181 }
1182
1183 if new_files.is_empty() && updated_unmodified.is_empty() && updated_modified.is_empty() {
1184 println!(
1185 " {OK} All skills are up to date ({} files)",
1186 up_to_date.len()
1187 );
1188 return Ok(false);
1189 }
1190
1191 let total_changes = new_files.len() + updated_unmodified.len() + updated_modified.len();
1192 println!(
1193 " {total_changes} change(s): {} new, {} updated, {} with local modifications",
1194 new_files.len(),
1195 updated_unmodified.len(),
1196 updated_modified.len()
1197 );
1198 println!();
1199
1200 for f in &new_files {
1201 println!(" {GREEN}+ {f}{RESET} (new)");
1202 }
1203 for f in &updated_unmodified {
1204 println!(" {DIM} {f}{RESET} (unmodified -- will auto-update)");
1205 }
1206 for f in &updated_modified {
1207 println!(" {YELLOW} {f}{RESET} (YOU MODIFIED THIS FILE)");
1208 }
1209
1210 println!();
1211 if !yes && !confirm_action("Apply skill updates?", true) {
1212 println!(" Skipped.");
1213 return Ok(false);
1214 }
1215
1216 let mut applied = 0u32;
1217 let mut file_hashes: HashMap<String, String> = state
1218 .installed_content
1219 .skills
1220 .as_ref()
1221 .map(|s| s.files.clone())
1222 .unwrap_or_default();
1223
1224 for filename in new_files.iter().chain(updated_unmodified.iter()) {
1225 let remote_content = fetch_file(
1226 &client,
1227 &base_url,
1228 &format!("{}{}", manifest.packs.skills.path, filename),
1229 )
1230 .await?;
1231 let download_hash = bytes_sha256(remote_content.as_bytes());
1233 if let Some(expected) = manifest.packs.skills.files.get(filename)
1234 && download_hash != *expected
1235 {
1236 tracing::warn!(
1237 filename,
1238 expected,
1239 actual = %download_hash,
1240 "skill download hash mismatch — skipping"
1241 );
1242 continue;
1243 }
1244 std::fs::write(skills_dir.join(filename), &remote_content)?;
1245 file_hashes.insert(filename.clone(), download_hash);
1246 applied += 1;
1247 }
1248
1249 for filename in &updated_modified {
1250 let local_file = skills_dir.join(filename);
1251 let local_content = std::fs::read_to_string(&local_file).unwrap_or_default();
1252 let remote_content = fetch_file(
1253 &client,
1254 &base_url,
1255 &format!("{}{}", manifest.packs.skills.path, filename),
1256 )
1257 .await?;
1258
1259 let download_hash = bytes_sha256(remote_content.as_bytes());
1261 if let Some(expected) = manifest.packs.skills.files.get(filename.as_str())
1262 && download_hash != *expected
1263 {
1264 tracing::warn!(
1265 filename,
1266 expected,
1267 actual = %download_hash,
1268 "skill download hash mismatch — skipping"
1269 );
1270 continue;
1271 }
1272
1273 println!();
1274 println!(" {YELLOW}{filename}{RESET} -- local modifications detected:");
1275 print_diff(&local_content, &remote_content);
1276
1277 match confirm_overwrite(filename) {
1278 OverwriteChoice::Overwrite => {
1279 std::fs::write(&local_file, &remote_content)?;
1280 file_hashes.insert(filename.clone(), download_hash.clone());
1281 applied += 1;
1282 }
1283 OverwriteChoice::Backup => {
1284 let backup = local_file.with_extension("md.bak");
1285 std::fs::copy(&local_file, &backup)?;
1286 println!(" {DETAIL} Backed up to {}", backup.display());
1287 std::fs::write(&local_file, &remote_content)?;
1288 file_hashes.insert(filename.clone(), download_hash.clone());
1289 applied += 1;
1290 }
1291 OverwriteChoice::Skip => {
1292 println!(" Skipped {filename}.");
1293 }
1294 }
1295 }
1296
1297 let mut state = UpdateState::load();
1298 state.installed_content.skills = Some(SkillsRecord {
1299 version: manifest.version.clone(),
1300 files: file_hashes,
1301 installed_at: now_iso(),
1302 });
1303 state.last_check = now_iso();
1304 state
1305 .save()
1306 .inspect_err(
1307 |e| tracing::warn!(error = %e, "failed to save update state after skills install"),
1308 )
1309 .ok();
1310
1311 println!();
1312 println!(
1313 " {OK} Applied {applied} skill update(s) (v{})",
1314 manifest.version
1315 );
1316 Ok(true)
1317}
1318
1319fn semver_gte(local: &str, remote: &str) -> bool {
1325 fn parse(v: &str) -> (Vec<u64>, bool) {
1329 let v = v.trim_start_matches('v');
1330 let v = v.split_once('+').map(|(core, _)| core).unwrap_or(v);
1332 let (core, has_pre) = match v.split_once('-') {
1334 Some((c, _)) => (c, true),
1335 None => (v, false),
1336 };
1337 let parts = core
1338 .split('.')
1339 .map(|s| s.parse::<u64>().unwrap_or(0))
1340 .collect();
1341 (parts, has_pre)
1342 }
1343 let (l, l_pre) = parse(local);
1344 let (r, r_pre) = parse(remote);
1345 let len = l.len().max(r.len());
1346 for i in 0..len {
1347 let lv = l.get(i).copied().unwrap_or(0);
1348 let rv = r.get(i).copied().unwrap_or(0);
1349 match lv.cmp(&rv) {
1350 std::cmp::Ordering::Greater => return true,
1351 std::cmp::Ordering::Less => return false,
1352 std::cmp::Ordering::Equal => {}
1353 }
1354 }
1355 if l_pre && !r_pre {
1359 return false;
1360 }
1361 true
1362}
1363
1364pub(crate) async fn apply_multi_registry_skills_update(
1371 yes: bool,
1372 cli_registry_override: Option<&str>,
1373 config_path: &str,
1374) -> Result<bool, Box<dyn std::error::Error>> {
1375 let (_, BOLD, _, _, _, _, _, RESET, _) = colors();
1376 let (OK, _, WARN, _, _) = icons();
1377
1378 if let Some(url) = cli_registry_override {
1380 return apply_skills_update(yes, url, config_path).await;
1381 }
1382
1383 let registries = match std::fs::read_to_string(config_path).ok().and_then(|raw| {
1385 let table: toml::Value = toml::from_str(&raw).ok()?;
1386 let update_val = table.get("update")?.clone();
1387 let update_cfg: roboticus_core::config::UpdateConfig = update_val.try_into().ok()?;
1388 Some(update_cfg.resolve_registries())
1389 }) {
1390 Some(regs) => regs,
1391 None => {
1392 let url = resolve_registry_url(None, config_path);
1394 return apply_skills_update(yes, &url, config_path).await;
1395 }
1396 };
1397
1398 if registries.len() <= 1
1401 && registries
1402 .first()
1403 .map(|r| r.name == "default")
1404 .unwrap_or(true)
1405 {
1406 let url = registries
1407 .first()
1408 .map(|r| r.url.as_str())
1409 .unwrap_or(DEFAULT_REGISTRY_URL);
1410 return apply_skills_update(yes, url, config_path).await;
1411 }
1412
1413 let mut sorted = registries.clone();
1415 sorted.sort_by(|a, b| b.priority.cmp(&a.priority));
1416
1417 println!("\n {BOLD}Skills (multi-registry){RESET}\n");
1418
1419 let non_default: Vec<_> = sorted
1421 .iter()
1422 .filter(|r| r.enabled && r.name != "default")
1423 .collect();
1424 if !non_default.is_empty() {
1425 for r in &non_default {
1426 println!(
1427 " {WARN} Non-default registry: {BOLD}{}{RESET} ({})",
1428 r.name, r.url
1429 );
1430 }
1431 if !yes && !confirm_action("Install skills from non-default registries?", false) {
1432 println!(" Skipped non-default registries.");
1433 let url = sorted
1435 .iter()
1436 .find(|r| r.name == "default")
1437 .map(|r| r.url.as_str())
1438 .unwrap_or(DEFAULT_REGISTRY_URL);
1439 return apply_skills_update(yes, url, config_path).await;
1440 }
1441 }
1442
1443 let client = http_client()?;
1444 let skills_dir = skills_local_dir(config_path);
1445 if !skills_dir.exists() {
1446 std::fs::create_dir_all(&skills_dir)?;
1447 }
1448
1449 let state = UpdateState::load();
1450 let mut any_changed = false;
1451 let mut claimed_files: HashMap<String, String> = HashMap::new();
1453
1454 for reg in &sorted {
1455 if !reg.enabled {
1456 continue;
1457 }
1458
1459 let manifest = match fetch_manifest(&client, ®.url).await {
1460 Ok(m) => m,
1461 Err(e) => {
1462 println!(
1463 " {WARN} [{name}] Could not fetch manifest: {e}",
1464 name = reg.name
1465 );
1466 continue;
1467 }
1468 };
1469
1470 let installed_version = state
1472 .installed_content
1473 .skills
1474 .as_ref()
1475 .map(|s| s.version.as_str())
1476 .unwrap_or("0.0.0");
1477 if semver_gte(installed_version, &manifest.version) {
1478 let all_match = manifest.packs.skills.files.iter().all(|(fname, hash)| {
1480 let local = skills_dir.join(fname);
1481 local.exists() && file_sha256(&local).unwrap_or_default() == *hash
1482 });
1483 if all_match {
1484 println!(
1485 " {OK} [{name}] All skills are up to date (v{ver})",
1486 name = reg.name,
1487 ver = manifest.version
1488 );
1489 continue;
1490 }
1491 }
1492
1493 if reg.name.contains("..") || reg.name.contains('/') || reg.name.contains('\\') {
1496 tracing::warn!(registry = %reg.name, "skipping registry with suspicious name");
1497 continue;
1498 }
1499 let target_dir = if reg.name == "default" {
1500 skills_dir.clone()
1501 } else {
1502 let ns_dir = skills_dir.join(®.name);
1503 if !ns_dir.exists() {
1504 std::fs::create_dir_all(&ns_dir)?;
1505 }
1506 ns_dir
1507 };
1508
1509 let base_url = registry_base_url(®.url);
1510 let mut applied = 0u32;
1511
1512 for (filename, remote_hash) in &manifest.packs.skills.files {
1513 if !is_safe_skill_path(&target_dir, filename) {
1515 tracing::warn!(
1516 registry = %reg.name,
1517 filename,
1518 "skipping manifest entry with suspicious path"
1519 );
1520 continue;
1521 }
1522
1523 let resolved_key = target_dir.join(filename).to_string_lossy().to_string();
1527 if let Some(owner) = claimed_files.get(&resolved_key)
1528 && *owner != reg.name
1529 {
1530 continue;
1531 }
1532 claimed_files.insert(resolved_key, reg.name.clone());
1533
1534 let local_file = target_dir.join(filename);
1535 if local_file.exists() {
1536 let current_hash = file_sha256(&local_file).unwrap_or_default();
1537 if current_hash == *remote_hash {
1538 continue; }
1540 }
1541
1542 match fetch_file(
1544 &client,
1545 &base_url,
1546 &format!("{}{}", manifest.packs.skills.path, filename),
1547 )
1548 .await
1549 {
1550 Ok(content) => {
1551 let download_hash = bytes_sha256(content.as_bytes());
1552 if download_hash != *remote_hash {
1553 tracing::warn!(
1554 registry = %reg.name,
1555 filename,
1556 expected = %remote_hash,
1557 actual = %download_hash,
1558 "skill download hash mismatch — skipping"
1559 );
1560 continue;
1561 }
1562 std::fs::write(&local_file, &content)?;
1563 applied += 1;
1564 }
1565 Err(e) => {
1566 println!(
1567 " {WARN} [{name}] Failed to fetch {filename}: {e}",
1568 name = reg.name
1569 );
1570 }
1571 }
1572 }
1573
1574 if applied > 0 {
1575 any_changed = true;
1576 println!(
1577 " {OK} [{name}] Applied {applied} skill update(s) (v{ver})",
1578 name = reg.name,
1579 ver = manifest.version
1580 );
1581 } else {
1582 println!(
1583 " {OK} [{name}] All skills are up to date",
1584 name = reg.name
1585 );
1586 }
1587 }
1588
1589 {
1593 let mut state = UpdateState::load();
1594 state.last_check = now_iso();
1595 if any_changed {
1596 let mut file_hashes: HashMap<String, String> = state
1598 .installed_content
1599 .skills
1600 .as_ref()
1601 .map(|s| s.files.clone())
1602 .unwrap_or_default();
1603 if let Ok(entries) = std::fs::read_dir(&skills_dir) {
1605 for entry in entries.flatten() {
1606 let path = entry.path();
1607 if path.is_file()
1608 && let Some(name) = path.file_name().and_then(|n| n.to_str())
1609 && let Ok(hash) = file_sha256(&path)
1610 {
1611 file_hashes.insert(name.to_string(), hash);
1612 }
1613 }
1614 }
1615 let max_version = sorted
1617 .iter()
1618 .filter(|r| r.enabled)
1619 .map(|r| r.name.as_str())
1620 .next()
1621 .unwrap_or("0.0.0");
1622 let _ = max_version; state.installed_content.skills = Some(SkillsRecord {
1624 version: "multi".into(),
1625 files: file_hashes,
1626 installed_at: now_iso(),
1627 });
1628 }
1629 state
1630 .save()
1631 .inspect_err(
1632 |e| tracing::warn!(error = %e, "failed to save update state after multi-registry sync"),
1633 )
1634 .ok();
1635 }
1636
1637 Ok(any_changed)
1638}
1639
1640fn skills_local_dir(config_path: &str) -> PathBuf {
1641 if let Ok(content) = std::fs::read_to_string(config_path)
1642 && let Ok(config) = content.parse::<toml::Value>()
1643 && let Some(path) = config
1644 .get("skills")
1645 .and_then(|s| s.get("skills_dir"))
1646 .and_then(|v| v.as_str())
1647 {
1648 return PathBuf::from(path);
1649 }
1650 roboticus_home().join("skills")
1651}
1652
1653pub async fn cmd_update_check(
1656 channel: &str,
1657 registry_url_override: Option<&str>,
1658 config_path: &str,
1659) -> Result<(), Box<dyn std::error::Error>> {
1660 let (DIM, BOLD, _, GREEN, _, _, _, RESET, MONO) = colors();
1661 let (OK, _, WARN, _, _) = icons();
1662
1663 heading("Update Check");
1664 let current = env!("CARGO_PKG_VERSION");
1665 let client = http_client()?;
1666
1667 println!("\n {BOLD}Binary{RESET}");
1669 println!(" Current: {MONO}v{current}{RESET}");
1670 println!(" Channel: {DIM}{channel}{RESET}");
1671
1672 match check_binary_version(&client).await? {
1673 Some(latest) => {
1674 if is_newer(&latest, current) {
1675 println!(" Latest: {GREEN}v{latest}{RESET} (update available)");
1676 } else {
1677 println!(" {OK} Up to date (v{current})");
1678 }
1679 }
1680 None => println!(" {WARN} Could not check crates.io"),
1681 }
1682
1683 let registries: Vec<roboticus_core::config::RegistrySource> =
1685 if let Some(url) = registry_url_override {
1686 vec![roboticus_core::config::RegistrySource {
1688 name: "cli-override".into(),
1689 url: url.to_string(),
1690 priority: 100,
1691 enabled: true,
1692 }]
1693 } else {
1694 std::fs::read_to_string(config_path)
1695 .ok()
1696 .and_then(|raw| {
1697 let table: toml::Value = toml::from_str(&raw).ok()?;
1698 let update_val = table.get("update")?.clone();
1699 let update_cfg: roboticus_core::config::UpdateConfig =
1700 update_val.try_into().ok()?;
1701 Some(update_cfg.resolve_registries())
1702 })
1703 .unwrap_or_else(|| {
1704 let url = resolve_registry_url(None, config_path);
1706 vec![roboticus_core::config::RegistrySource {
1707 name: "default".into(),
1708 url,
1709 priority: 50,
1710 enabled: true,
1711 }]
1712 })
1713 };
1714
1715 let enabled: Vec<_> = registries.iter().filter(|r| r.enabled).collect();
1716
1717 println!("\n {BOLD}Content Packs{RESET}");
1718 if enabled.len() == 1 {
1719 println!(" Registry: {DIM}{}{RESET}", enabled[0].url);
1720 } else {
1721 for reg in &enabled {
1722 println!(" Registry: {DIM}{}{RESET} ({})", reg.url, reg.name);
1723 }
1724 }
1725
1726 let primary_url = enabled
1728 .first()
1729 .map(|r| r.url.as_str())
1730 .unwrap_or(DEFAULT_REGISTRY_URL);
1731
1732 match fetch_manifest(&client, primary_url).await {
1733 Ok(manifest) => {
1734 let state = UpdateState::load();
1735 println!(" Pack version: {MONO}v{}{RESET}", manifest.version);
1736
1737 let providers_path = providers_local_path(config_path);
1739 if providers_path.exists() {
1740 let local_hash = file_sha256(&providers_path).unwrap_or_default();
1741 if local_hash == manifest.packs.providers.sha256 {
1742 println!(" {OK} Providers: up to date");
1743 } else {
1744 println!(" {GREEN}\u{25b6}{RESET} Providers: update available");
1745 }
1746 } else {
1747 println!(" {GREEN}+{RESET} Providers: new (not yet installed locally)");
1748 }
1749
1750 let skills_dir = skills_local_dir(config_path);
1752 let mut skills_new = 0u32;
1753 let mut skills_changed = 0u32;
1754 let mut skills_ok = 0u32;
1755 for (filename, remote_hash) in &manifest.packs.skills.files {
1756 let local_file = skills_dir.join(filename);
1757 if !local_file.exists() {
1758 skills_new += 1;
1759 } else {
1760 let local_hash = file_sha256(&local_file).unwrap_or_default();
1761 if local_hash == *remote_hash {
1762 skills_ok += 1;
1763 } else {
1764 skills_changed += 1;
1765 }
1766 }
1767 }
1768
1769 if skills_new == 0 && skills_changed == 0 {
1770 println!(" {OK} Skills: up to date ({skills_ok} files)");
1771 } else {
1772 println!(
1773 " {GREEN}\u{25b6}{RESET} Skills: {skills_new} new, {skills_changed} changed, {skills_ok} current"
1774 );
1775 }
1776
1777 for reg in enabled.iter().skip(1) {
1779 match fetch_manifest(&client, ®.url).await {
1780 Ok(m) => println!(" {OK} {}: reachable (v{})", reg.name, m.version),
1781 Err(e) => println!(" {WARN} {}: unreachable ({e})", reg.name),
1782 }
1783 }
1784
1785 if let Some(ref providers) = state.installed_content.providers {
1786 println!(
1787 "\n {DIM}Last content update: {}{RESET}",
1788 providers.installed_at
1789 );
1790 }
1791 }
1792 Err(e) => {
1793 println!(" {WARN} Could not reach registry: {e}");
1794 }
1795 }
1796
1797 println!();
1798 Ok(())
1799}
1800
1801pub async fn cmd_update_all(
1802 channel: &str,
1803 yes: bool,
1804 no_restart: bool,
1805 registry_url_override: Option<&str>,
1806 config_path: &str,
1807 hygiene_fn: Option<&HygieneFn>,
1808 daemon_cbs: Option<&DaemonCallbacks>,
1809) -> Result<(), Box<dyn std::error::Error>> {
1810 let (_, BOLD, _, _, _, _, _, RESET, _) = colors();
1811 let (OK, _, WARN, DETAIL, _) = icons();
1812 heading("Roboticus Update");
1813
1814 println!();
1816 println!(" {BOLD}IMPORTANT — PLEASE READ{RESET}");
1817 println!();
1818 println!(" Roboticus is an autonomous AI agent that can execute actions,");
1819 println!(" interact with external services, and manage digital assets");
1820 println!(" including cryptocurrency wallets and on-chain transactions.");
1821 println!();
1822 println!(" THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND.");
1823 println!(" The developers and contributors bear {BOLD}no responsibility{RESET} for:");
1824 println!();
1825 println!(" - Actions taken by the agent, whether intended or unintended");
1826 println!(" - Loss of funds, income, cryptocurrency, or other digital assets");
1827 println!(" - Security vulnerabilities, compromises, or unauthorized access");
1828 println!(" - Damages arising from the agent's use, misuse, or malfunction");
1829 println!(" - Any financial, legal, or operational consequences whatsoever");
1830 println!();
1831 println!(" By proceeding, you acknowledge that you use Roboticus entirely");
1832 println!(" at your own risk and accept full responsibility for its operation.");
1833 println!();
1834 if !yes && !confirm_action("I understand and accept these terms", true) {
1835 println!("\n Update cancelled.\n");
1836 return Ok(());
1837 }
1838
1839 let binary_updated = apply_binary_update(yes, "download").await?;
1840
1841 let registry_url = resolve_registry_url(registry_url_override, config_path);
1842 apply_providers_update(yes, ®istry_url, config_path).await?;
1843 apply_multi_registry_skills_update(yes, registry_url_override, config_path).await?;
1844 run_oauth_storage_maintenance();
1845 run_mechanic_checks_maintenance(config_path, hygiene_fn);
1846 if let Err(e) = apply_removed_legacy_config_migration(config_path) {
1847 println!(" {WARN} Legacy config migration skipped: {e}");
1848 }
1849
1850 if let Err(e) = apply_security_config_migration(config_path) {
1854 println!(" {WARN} Security config migration skipped: {e}");
1855 }
1856
1857 if let Some(daemon) = daemon_cbs {
1859 if binary_updated && !no_restart && (daemon.is_installed)() {
1860 println!("\n Restarting daemon to apply update...");
1861 match (daemon.restart)() {
1862 Ok(()) => println!(" {OK} Daemon restarted"),
1863 Err(e) => {
1864 println!(" {WARN} Could not restart daemon: {e}");
1865 println!(" {DETAIL} Run `roboticus daemon restart` manually.");
1866 }
1867 }
1868 } else if binary_updated && no_restart {
1869 println!("\n {DETAIL} Skipping daemon restart (--no-restart).");
1870 println!(" {DETAIL} Run `roboticus daemon restart` to apply the update.");
1871 }
1872 }
1873
1874 println!("\n {BOLD}Update complete.{RESET}\n");
1875 Ok(())
1876}
1877
1878pub async fn cmd_update_binary(
1879 _channel: &str,
1880 yes: bool,
1881 method: &str,
1882 hygiene_fn: Option<&HygieneFn>,
1883) -> Result<(), Box<dyn std::error::Error>> {
1884 heading("Roboticus Binary Update");
1885 apply_binary_update(yes, method).await?;
1886 run_oauth_storage_maintenance();
1887 let config_path = roboticus_core::config::resolve_config_path(None)
1888 .unwrap_or_else(|| home_dir().join(".roboticus").join("roboticus.toml"));
1889 run_mechanic_checks_maintenance(&config_path.to_string_lossy(), hygiene_fn);
1890 println!();
1891 Ok(())
1892}
1893
1894pub async fn cmd_update_providers(
1895 yes: bool,
1896 registry_url_override: Option<&str>,
1897 config_path: &str,
1898 hygiene_fn: Option<&HygieneFn>,
1899) -> Result<(), Box<dyn std::error::Error>> {
1900 heading("Provider Config Update");
1901 let registry_url = resolve_registry_url(registry_url_override, config_path);
1902 apply_providers_update(yes, ®istry_url, config_path).await?;
1903 run_oauth_storage_maintenance();
1904 run_mechanic_checks_maintenance(config_path, hygiene_fn);
1905 println!();
1906 Ok(())
1907}
1908
1909pub async fn cmd_update_skills(
1910 yes: bool,
1911 registry_url_override: Option<&str>,
1912 config_path: &str,
1913 hygiene_fn: Option<&HygieneFn>,
1914) -> Result<(), Box<dyn std::error::Error>> {
1915 heading("Skills Update");
1916 apply_multi_registry_skills_update(yes, registry_url_override, config_path).await?;
1917 run_oauth_storage_maintenance();
1918 run_mechanic_checks_maintenance(config_path, hygiene_fn);
1919 println!();
1920 Ok(())
1921}
1922
1923fn apply_security_config_migration(config_path: &str) -> Result<(), Box<dyn std::error::Error>> {
1929 let path = Path::new(config_path);
1930 if !path.exists() {
1931 return Ok(());
1932 }
1933
1934 let raw = std::fs::read_to_string(path)?;
1935 let normalized = raw.replace("\r\n", "\n").replace('\r', "\n");
1937
1938 let has_security = normalized.lines().any(|line| line.trim() == "[security]");
1940
1941 if has_security {
1942 return Ok(());
1943 }
1944
1945 let (_, BOLD, _, _, _, _, _, RESET, _) = super::colors();
1947 let (_, ERR, WARN, DETAIL, _) = super::icons();
1948
1949 println!();
1950 println!(" {ERR} {BOLD}SECURITY MODEL CHANGE{RESET}");
1951 println!();
1952 println!(
1953 " Empty channel allow-lists now {BOLD}DENY all messages{RESET} (previously allowed all)."
1954 );
1955 println!(
1956 " This is a critical security fix — your agent was previously open to the internet."
1957 );
1958 println!();
1959
1960 if let Ok(config) = roboticus_core::RoboticusConfig::from_file(path) {
1962 let channels_status = describe_channel_allowlists(&config);
1963 if !channels_status.is_empty() {
1964 println!(" Your current configuration:");
1965 for line in &channels_status {
1966 println!(" {line}");
1967 }
1968 println!();
1969 }
1970
1971 if config.channels.trusted_sender_ids.is_empty() {
1972 println!(" {WARN} trusted_sender_ids = [] (no Creator-level users configured)");
1973 println!();
1974 }
1975 }
1976
1977 println!(" Run {BOLD}roboticus mechanic --repair{RESET} for guided security setup.");
1978 println!();
1979
1980 let security_section = r#"
1982# Security: Claim-based RBAC authority resolution.
1983# See `roboticus mechanic` for guided configuration.
1984[security]
1985deny_on_empty_allowlist = true # empty allow-lists deny all messages (secure default)
1986allowlist_authority = "Peer" # allow-listed senders get Peer authority
1987trusted_authority = "Creator" # trusted_sender_ids get Creator authority
1988api_authority = "Creator" # HTTP API callers get Creator authority
1989threat_caution_ceiling = "External" # threat-flagged inputs are capped at External
1990"#;
1991
1992 let backup = path.with_extension("toml.bak");
1994 if !backup.exists() {
1995 std::fs::copy(path, &backup)?;
1996 }
1997
1998 let mut content = normalized;
1999 content.push_str(security_section);
2000
2001 let tmp = path.with_extension("toml.tmp");
2002 std::fs::write(&tmp, &content)?;
2003 std::fs::rename(&tmp, path)?;
2004
2005 println!(" {DETAIL} Added [security] section to {config_path} (backup: .toml.bak)");
2006 println!();
2007
2008 Ok(())
2009}
2010
2011fn describe_channel_allowlists(config: &roboticus_core::RoboticusConfig) -> Vec<String> {
2013 let mut lines = Vec::new();
2014
2015 if let Some(ref tg) = config.channels.telegram {
2016 if tg.allowed_chat_ids.is_empty() {
2017 lines.push("Telegram: allowed_chat_ids = [] (was: open to all → now: deny all)".into());
2018 } else {
2019 lines.push(format!(
2020 "Telegram: {} chat ID(s) configured",
2021 tg.allowed_chat_ids.len()
2022 ));
2023 }
2024 }
2025
2026 if let Some(ref dc) = config.channels.discord {
2027 if dc.allowed_guild_ids.is_empty() {
2028 lines.push("Discord: allowed_guild_ids = [] (was: open to all → now: deny all)".into());
2029 } else {
2030 lines.push(format!(
2031 "Discord: {} guild ID(s) configured",
2032 dc.allowed_guild_ids.len()
2033 ));
2034 }
2035 }
2036
2037 if let Some(ref wa) = config.channels.whatsapp {
2038 if wa.allowed_numbers.is_empty() {
2039 lines.push("WhatsApp: allowed_numbers = [] (was: open to all → now: deny all)".into());
2040 } else {
2041 lines.push(format!(
2042 "WhatsApp: {} number(s) configured",
2043 wa.allowed_numbers.len()
2044 ));
2045 }
2046 }
2047
2048 if let Some(ref sig) = config.channels.signal {
2049 if sig.allowed_numbers.is_empty() {
2050 lines.push("Signal: allowed_numbers = [] (was: open to all → now: deny all)".into());
2051 } else {
2052 lines.push(format!(
2053 "Signal: {} number(s) configured",
2054 sig.allowed_numbers.len()
2055 ));
2056 }
2057 }
2058
2059 if !config.channels.email.allowed_senders.is_empty() {
2060 lines.push(format!(
2061 "Email: {} sender(s) configured",
2062 config.channels.email.allowed_senders.len()
2063 ));
2064 } else if config.channels.email.enabled {
2065 lines.push("Email: allowed_senders = [] (was: open to all → now: deny all)".into());
2066 }
2067
2068 lines
2069}
2070
2071#[cfg(test)]
2074mod tests {
2075 use super::*;
2076 use crate::test_support::EnvGuard;
2077 use axum::{Json, Router, extract::State, routing::get};
2078 use tokio::net::TcpListener;
2079
2080 #[derive(Clone)]
2081 struct MockRegistry {
2082 manifest: String,
2083 providers: String,
2084 skill_payload: String,
2085 }
2086
2087 async fn start_mock_registry(
2088 providers: String,
2089 skill_draft: String,
2090 ) -> (String, tokio::task::JoinHandle<()>) {
2091 let providers_hash = bytes_sha256(providers.as_bytes());
2092 let draft_hash = bytes_sha256(skill_draft.as_bytes());
2093 let manifest = serde_json::json!({
2094 "version": "0.8.0",
2095 "packs": {
2096 "providers": {
2097 "sha256": providers_hash,
2098 "path": "registry/providers.toml"
2099 },
2100 "skills": {
2101 "sha256": null,
2102 "path": "registry/skills/",
2103 "files": {
2104 "draft.md": draft_hash
2105 }
2106 }
2107 }
2108 })
2109 .to_string();
2110
2111 let state = MockRegistry {
2112 manifest,
2113 providers,
2114 skill_payload: skill_draft,
2115 };
2116
2117 async fn manifest_h(State(st): State<MockRegistry>) -> Json<serde_json::Value> {
2118 Json(serde_json::from_str(&st.manifest).unwrap())
2119 }
2120 async fn providers_h(State(st): State<MockRegistry>) -> String {
2121 st.providers
2122 }
2123 async fn skill_h(State(st): State<MockRegistry>) -> String {
2124 st.skill_payload
2125 }
2126
2127 let app = Router::new()
2128 .route("/manifest.json", get(manifest_h))
2129 .route("/registry/providers.toml", get(providers_h))
2130 .route("/registry/skills/draft.md", get(skill_h))
2131 .with_state(state);
2132 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2133 let addr = listener.local_addr().unwrap();
2134 let handle = tokio::spawn(async move {
2135 axum::serve(listener, app).await.unwrap();
2136 });
2137 (
2138 format!("http://{}:{}/manifest.json", addr.ip(), addr.port()),
2139 handle,
2140 )
2141 }
2142
2143 #[test]
2144 fn update_state_serde_roundtrip() {
2145 let state = UpdateState {
2146 binary_version: "0.2.0".into(),
2147 last_check: "2026-02-20T00:00:00Z".into(),
2148 registry_url: DEFAULT_REGISTRY_URL.into(),
2149 installed_content: InstalledContent {
2150 providers: Some(ContentRecord {
2151 version: "0.2.0".into(),
2152 sha256: "abc123".into(),
2153 installed_at: "2026-02-20T00:00:00Z".into(),
2154 }),
2155 skills: Some(SkillsRecord {
2156 version: "0.2.0".into(),
2157 files: {
2158 let mut m = HashMap::new();
2159 m.insert("draft.md".into(), "hash1".into());
2160 m.insert("rust.md".into(), "hash2".into());
2161 m
2162 },
2163 installed_at: "2026-02-20T00:00:00Z".into(),
2164 }),
2165 },
2166 };
2167
2168 let json = serde_json::to_string_pretty(&state).unwrap();
2169 let parsed: UpdateState = serde_json::from_str(&json).unwrap();
2170 assert_eq!(parsed.binary_version, "0.2.0");
2171 assert_eq!(
2172 parsed.installed_content.providers.as_ref().unwrap().sha256,
2173 "abc123"
2174 );
2175 assert_eq!(
2176 parsed
2177 .installed_content
2178 .skills
2179 .as_ref()
2180 .unwrap()
2181 .files
2182 .len(),
2183 2
2184 );
2185 }
2186
2187 #[test]
2188 fn update_state_default_is_empty() {
2189 let state = UpdateState::default();
2190 assert_eq!(state.binary_version, "");
2191 assert!(state.installed_content.providers.is_none());
2192 assert!(state.installed_content.skills.is_none());
2193 }
2194
2195 #[test]
2196 fn file_sha256_computes_correctly() {
2197 let dir = tempfile::tempdir().unwrap();
2198 let path = dir.path().join("test.txt");
2199 std::fs::write(&path, "hello world\n").unwrap();
2200
2201 let hash = file_sha256(&path).unwrap();
2202 assert_eq!(hash.len(), 64);
2203
2204 let expected = bytes_sha256(b"hello world\n");
2205 assert_eq!(hash, expected);
2206 }
2207
2208 #[test]
2209 fn file_sha256_error_on_missing() {
2210 let result = file_sha256(Path::new("/nonexistent/file.txt"));
2211 assert!(result.is_err());
2212 }
2213
2214 #[test]
2215 fn bytes_sha256_deterministic() {
2216 let h1 = bytes_sha256(b"test data");
2217 let h2 = bytes_sha256(b"test data");
2218 assert_eq!(h1, h2);
2219 assert_ne!(bytes_sha256(b"different"), h1);
2220 }
2221
2222 #[test]
2223 fn modification_detection_unmodified() {
2224 let dir = tempfile::tempdir().unwrap();
2225 let path = dir.path().join("providers.toml");
2226 let content = "[providers.openai]\nurl = \"https://api.openai.com\"\n";
2227 std::fs::write(&path, content).unwrap();
2228
2229 let installed_hash = bytes_sha256(content.as_bytes());
2230 let current_hash = file_sha256(&path).unwrap();
2231 assert_eq!(current_hash, installed_hash);
2232 }
2233
2234 #[test]
2235 fn modification_detection_modified() {
2236 let dir = tempfile::tempdir().unwrap();
2237 let path = dir.path().join("providers.toml");
2238 let original = "[providers.openai]\nurl = \"https://api.openai.com\"\n";
2239 let modified = "[providers.openai]\nurl = \"https://custom.endpoint.com\"\n";
2240
2241 let installed_hash = bytes_sha256(original.as_bytes());
2242 std::fs::write(&path, modified).unwrap();
2243
2244 let current_hash = file_sha256(&path).unwrap();
2245 assert_ne!(current_hash, installed_hash);
2246 }
2247
2248 #[test]
2249 fn manifest_parse() {
2250 let json = r#"{
2251 "version": "0.2.0",
2252 "packs": {
2253 "providers": { "sha256": "abc123", "path": "registry/providers.toml" },
2254 "skills": {
2255 "sha256": null,
2256 "path": "registry/skills/",
2257 "files": {
2258 "draft.md": "hash1",
2259 "rust.md": "hash2"
2260 }
2261 }
2262 }
2263 }"#;
2264 let manifest: RegistryManifest = serde_json::from_str(json).unwrap();
2265 assert_eq!(manifest.version, "0.2.0");
2266 assert_eq!(manifest.packs.providers.sha256, "abc123");
2267 assert_eq!(manifest.packs.skills.files.len(), 2);
2268 assert_eq!(manifest.packs.skills.files["draft.md"], "hash1");
2269 }
2270
2271 #[test]
2272 fn diff_lines_identical() {
2273 let result = diff_lines("a\nb\nc", "a\nb\nc");
2274 assert!(result.iter().all(|l| matches!(l, DiffLine::Same(_))));
2275 }
2276
2277 #[test]
2278 fn diff_lines_changed() {
2279 let result = diff_lines("a\nb\nc", "a\nB\nc");
2280 assert_eq!(result.len(), 4);
2281 assert_eq!(result[0], DiffLine::Same("a".into()));
2282 assert_eq!(result[1], DiffLine::Removed("b".into()));
2283 assert_eq!(result[2], DiffLine::Added("B".into()));
2284 assert_eq!(result[3], DiffLine::Same("c".into()));
2285 }
2286
2287 #[test]
2288 fn diff_lines_added() {
2289 let result = diff_lines("a\nb", "a\nb\nc");
2290 assert_eq!(result.len(), 3);
2291 assert_eq!(result[2], DiffLine::Added("c".into()));
2292 }
2293
2294 #[test]
2295 fn diff_lines_removed() {
2296 let result = diff_lines("a\nb\nc", "a\nb");
2297 assert_eq!(result.len(), 3);
2298 assert_eq!(result[2], DiffLine::Removed("c".into()));
2299 }
2300
2301 #[test]
2302 fn diff_lines_empty_to_content() {
2303 let result = diff_lines("", "a\nb");
2304 assert!(result.iter().any(|l| matches!(l, DiffLine::Added(_))));
2305 }
2306
2307 #[test]
2308 fn semver_parse_basic() {
2309 assert_eq!(parse_semver("1.2.3"), (1, 2, 3));
2310 assert_eq!(parse_semver("v0.1.0"), (0, 1, 0));
2311 assert_eq!(parse_semver("10.20.30"), (10, 20, 30));
2312 }
2313
2314 #[test]
2315 fn is_newer_works() {
2316 assert!(is_newer("0.2.0", "0.1.0"));
2317 assert!(is_newer("1.0.0", "0.9.9"));
2318 assert!(!is_newer("0.1.0", "0.1.0"));
2319 assert!(!is_newer("0.1.0", "0.2.0"));
2320 }
2321
2322 #[test]
2323 fn registry_base_url_strips_filename() {
2324 let url = "https://roboticus.ai/registry/manifest.json";
2325 assert_eq!(registry_base_url(url), "https://roboticus.ai/registry");
2326 }
2327
2328 #[test]
2329 fn resolve_registry_url_cli_override() {
2330 let result = resolve_registry_url(
2331 Some("https://custom.registry/manifest.json"),
2332 "nonexistent.toml",
2333 );
2334 assert_eq!(result, "https://custom.registry/manifest.json");
2335 }
2336
2337 #[test]
2338 fn resolve_registry_url_default() {
2339 let result = resolve_registry_url(None, "nonexistent.toml");
2340 assert_eq!(result, DEFAULT_REGISTRY_URL);
2341 }
2342
2343 #[test]
2344 fn resolve_registry_url_from_config() {
2345 let dir = tempfile::tempdir().unwrap();
2346 let config = dir.path().join("roboticus.toml");
2347 std::fs::write(
2348 &config,
2349 "[update]\nregistry_url = \"https://my.registry/manifest.json\"\n",
2350 )
2351 .unwrap();
2352
2353 let result = resolve_registry_url(None, config.to_str().unwrap());
2354 assert_eq!(result, "https://my.registry/manifest.json");
2355 }
2356
2357 #[test]
2358 fn update_state_save_load_roundtrip() {
2359 let dir = tempfile::tempdir().unwrap();
2360 let path = dir.path().join("update_state.json");
2361
2362 let state = UpdateState {
2363 binary_version: "0.3.0".into(),
2364 last_check: "2026-03-01T12:00:00Z".into(),
2365 registry_url: "https://example.com/manifest.json".into(),
2366 installed_content: InstalledContent::default(),
2367 };
2368
2369 let json = serde_json::to_string_pretty(&state).unwrap();
2370 std::fs::write(&path, &json).unwrap();
2371
2372 let loaded: UpdateState =
2373 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2374 assert_eq!(loaded.binary_version, "0.3.0");
2375 assert_eq!(loaded.registry_url, "https://example.com/manifest.json");
2376 }
2377
2378 #[test]
2379 fn bytes_sha256_empty_input() {
2380 let hash = bytes_sha256(b"");
2381 assert_eq!(hash.len(), 64);
2382 assert_eq!(
2383 hash,
2384 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
2385 );
2386 }
2387
2388 #[test]
2389 fn parse_semver_partial_version() {
2390 assert_eq!(parse_semver("1"), (1, 0, 0));
2391 assert_eq!(parse_semver("1.2"), (1, 2, 0));
2392 }
2393
2394 #[test]
2395 fn parse_semver_empty() {
2396 assert_eq!(parse_semver(""), (0, 0, 0));
2397 }
2398
2399 #[test]
2400 fn parse_semver_with_v_prefix() {
2401 assert_eq!(parse_semver("v1.2.3"), (1, 2, 3));
2402 }
2403
2404 #[test]
2405 fn parse_semver_ignores_build_and_prerelease_metadata() {
2406 assert_eq!(parse_semver("0.9.4+hotfix.1"), (0, 9, 4));
2407 assert_eq!(parse_semver("v1.2.3-rc.1"), (1, 2, 3));
2408 }
2409
2410 #[test]
2411 fn is_newer_patch_bump() {
2412 assert!(is_newer("1.0.1", "1.0.0"));
2413 assert!(!is_newer("1.0.0", "1.0.1"));
2414 }
2415
2416 #[test]
2417 fn is_newer_same_version() {
2418 assert!(!is_newer("1.0.0", "1.0.0"));
2419 }
2420
2421 #[test]
2422 fn platform_archive_name_supported() {
2423 let name = platform_archive_name("1.2.3");
2424 if let Some(n) = name {
2425 assert!(n.contains("roboticus-1.2.3-"));
2426 }
2427 }
2428
2429 #[test]
2430 fn diff_lines_both_empty() {
2431 let result = diff_lines("", "");
2432 assert!(result.is_empty() || result.iter().all(|l| matches!(l, DiffLine::Same(_))));
2433 }
2434
2435 #[test]
2436 fn diff_lines_content_to_empty() {
2437 let result = diff_lines("a\nb", "");
2438 assert!(result.iter().any(|l| matches!(l, DiffLine::Removed(_))));
2439 }
2440
2441 #[test]
2442 fn registry_base_url_no_slash() {
2443 assert_eq!(registry_base_url("manifest.json"), "manifest.json");
2444 }
2445
2446 #[test]
2447 fn registry_base_url_nested() {
2448 assert_eq!(
2449 registry_base_url("https://cdn.example.com/v1/registry/manifest.json"),
2450 "https://cdn.example.com/v1/registry"
2451 );
2452 }
2453
2454 #[test]
2455 fn installed_content_default_is_empty() {
2456 let ic = InstalledContent::default();
2457 assert!(ic.skills.is_none());
2458 assert!(ic.providers.is_none());
2459 }
2460
2461 #[test]
2462 fn parse_sha256sums_for_artifact_finds_exact_entry() {
2463 let sums = "\
2464abc123 roboticus-0.8.0-darwin-aarch64.tar.gz\n\
2465def456 roboticus-0.8.0-linux-x86_64.tar.gz\n";
2466 let hash = parse_sha256sums_for_artifact(sums, "roboticus-0.8.0-linux-x86_64.tar.gz");
2467 assert_eq!(hash.as_deref(), Some("def456"));
2468 }
2469
2470 #[test]
2471 fn find_file_recursive_finds_nested_target() {
2472 let dir = tempfile::tempdir().unwrap();
2473 let nested = dir.path().join("a").join("b");
2474 std::fs::create_dir_all(&nested).unwrap();
2475 let target = nested.join("needle.txt");
2476 std::fs::write(&target, "x").unwrap();
2477 let found = find_file_recursive(dir.path(), "needle.txt").unwrap();
2478 assert_eq!(found.as_deref(), Some(target.as_path()));
2479 }
2480
2481 #[test]
2482 fn local_path_helpers_fallback_when_config_missing() {
2483 let p = providers_local_path("/no/such/file.toml");
2484 let s = skills_local_dir("/no/such/file.toml");
2485 assert!(p.ends_with("providers.toml"));
2486 assert!(s.ends_with("skills"));
2487 }
2488
2489 #[test]
2490 fn parse_sha256sums_for_artifact_returns_none_when_missing() {
2491 let sums = "abc123 file-a.tar.gz\n";
2492 assert!(parse_sha256sums_for_artifact(sums, "file-b.tar.gz").is_none());
2493 }
2494
2495 #[test]
2496 fn select_release_for_download_prefers_exact_tag() {
2497 let archive = platform_archive_name("0.9.4").unwrap();
2498 let releases = vec![
2499 GitHubRelease {
2500 tag_name: "v0.9.4+hotfix.1".into(),
2501 draft: false,
2502 prerelease: false,
2503 published_at: Some("2026-03-05T11:36:51Z".into()),
2504 assets: vec![
2505 GitHubAsset {
2506 name: "SHA256SUMS.txt".into(),
2507 },
2508 GitHubAsset {
2509 name: format!(
2510 "roboticus-0.9.4+hotfix.1-{}",
2511 &archive["roboticus-0.9.4-".len()..]
2512 ),
2513 },
2514 ],
2515 },
2516 GitHubRelease {
2517 tag_name: "v0.9.4".into(),
2518 draft: false,
2519 prerelease: false,
2520 published_at: Some("2026-03-05T10:00:00Z".into()),
2521 assets: vec![
2522 GitHubAsset {
2523 name: "SHA256SUMS.txt".into(),
2524 },
2525 GitHubAsset {
2526 name: archive.clone(),
2527 },
2528 ],
2529 },
2530 ];
2531
2532 let selected = select_release_for_download(&releases, "0.9.4");
2533 assert_eq!(
2534 selected.as_ref().map(|(tag, _)| tag.as_str()),
2535 Some("v0.9.4")
2536 );
2537 }
2538
2539 #[test]
2540 fn select_release_for_download_falls_back_to_hotfix_tag() {
2541 let archive = platform_archive_name("0.9.4").unwrap();
2542 let suffix = &archive["roboticus-0.9.4-".len()..];
2543 let releases = vec![
2544 GitHubRelease {
2545 tag_name: "v0.9.4".into(),
2546 draft: false,
2547 prerelease: false,
2548 published_at: Some("2026-03-05T10:00:00Z".into()),
2549 assets: vec![GitHubAsset {
2550 name: "PROVENANCE.json".into(),
2551 }],
2552 },
2553 GitHubRelease {
2554 tag_name: "v0.9.4+hotfix.2".into(),
2555 draft: false,
2556 prerelease: false,
2557 published_at: Some("2026-03-05T12:00:00Z".into()),
2558 assets: vec![
2559 GitHubAsset {
2560 name: "SHA256SUMS.txt".into(),
2561 },
2562 GitHubAsset {
2563 name: format!("roboticus-0.9.4+hotfix.2-{suffix}"),
2564 },
2565 ],
2566 },
2567 ];
2568
2569 let selected = select_release_for_download(&releases, "0.9.4");
2570 let expected_archive = format!("roboticus-0.9.4+hotfix.2-{suffix}");
2571 assert_eq!(
2572 selected.as_ref().map(|(tag, _)| tag.as_str()),
2573 Some("v0.9.4+hotfix.2")
2574 );
2575 assert_eq!(
2576 selected
2577 .as_ref()
2578 .map(|(_, archive_name)| archive_name.as_str()),
2579 Some(expected_archive.as_str())
2580 );
2581 }
2582
2583 #[test]
2584 fn find_file_recursive_returns_none_when_not_found() {
2585 let dir = tempfile::tempdir().unwrap();
2586 let found = find_file_recursive(dir.path(), "does-not-exist.txt").unwrap();
2587 assert!(found.is_none());
2588 }
2589
2590 #[serial_test::serial]
2591 #[tokio::test]
2592 async fn apply_providers_update_fetches_and_writes_local_file() {
2593 let temp = tempfile::tempdir().unwrap();
2594 let _home_guard = EnvGuard::set("HOME", temp.path().to_str().unwrap());
2595 let config_path = temp.path().join("roboticus.toml");
2596 let providers_path = temp.path().join("providers.toml");
2597 std::fs::write(
2598 &config_path,
2599 format!(
2600 "providers_file = \"{}\"\n",
2601 providers_path.display().to_string().replace('\\', "/")
2602 ),
2603 )
2604 .unwrap();
2605
2606 let providers = "[providers.openai]\nurl = \"https://api.openai.com\"\n".to_string();
2607 let (registry_url, handle) =
2608 start_mock_registry(providers.clone(), "# hello\nbody\n".to_string()).await;
2609
2610 let changed = apply_providers_update(true, ®istry_url, config_path.to_str().unwrap())
2611 .await
2612 .unwrap();
2613 assert!(changed);
2614 assert_eq!(std::fs::read_to_string(&providers_path).unwrap(), providers);
2615
2616 let changed_second =
2617 apply_providers_update(true, ®istry_url, config_path.to_str().unwrap())
2618 .await
2619 .unwrap();
2620 assert!(!changed_second);
2621 handle.abort();
2622 }
2623
2624 #[serial_test::serial]
2625 #[tokio::test]
2626 async fn apply_skills_update_installs_and_then_reports_up_to_date() {
2627 let temp = tempfile::tempdir().unwrap();
2628 let _home_guard = EnvGuard::set("HOME", temp.path().to_str().unwrap());
2629 let skills_dir = temp.path().join("skills");
2630 let config_path = temp.path().join("roboticus.toml");
2631 std::fs::write(
2632 &config_path,
2633 format!(
2634 "[skills]\nskills_dir = \"{}\"\n",
2635 skills_dir.display().to_string().replace('\\', "/")
2636 ),
2637 )
2638 .unwrap();
2639
2640 let draft = "# draft\nfrom registry\n".to_string();
2641 let (registry_url, handle) = start_mock_registry(
2642 "[providers.openai]\nurl=\"https://api.openai.com\"\n".to_string(),
2643 draft.clone(),
2644 )
2645 .await;
2646
2647 let changed = apply_skills_update(true, ®istry_url, config_path.to_str().unwrap())
2648 .await
2649 .unwrap();
2650 assert!(changed);
2651 assert_eq!(
2652 std::fs::read_to_string(skills_dir.join("draft.md")).unwrap(),
2653 draft
2654 );
2655
2656 let changed_second =
2657 apply_skills_update(true, ®istry_url, config_path.to_str().unwrap())
2658 .await
2659 .unwrap();
2660 assert!(!changed_second);
2661 handle.abort();
2662 }
2663
2664 #[test]
2667 fn semver_gte_equal_versions() {
2668 assert!(semver_gte("1.0.0", "1.0.0"));
2669 }
2670
2671 #[test]
2672 fn semver_gte_local_newer() {
2673 assert!(semver_gte("1.1.0", "1.0.0"));
2674 assert!(semver_gte("2.0.0", "1.9.9"));
2675 assert!(semver_gte("0.9.6", "0.9.5"));
2676 }
2677
2678 #[test]
2679 fn semver_gte_local_older() {
2680 assert!(!semver_gte("1.0.0", "1.0.1"));
2681 assert!(!semver_gte("0.9.5", "0.9.6"));
2682 assert!(!semver_gte("0.8.9", "0.9.0"));
2683 }
2684
2685 #[test]
2686 fn semver_gte_different_segment_counts() {
2687 assert!(semver_gte("1.0.0", "1.0"));
2688 assert!(semver_gte("1.0", "1.0.0"));
2689 assert!(!semver_gte("1.0", "1.0.1"));
2690 }
2691
2692 #[test]
2693 fn semver_gte_strips_prerelease_and_build_metadata() {
2694 assert!(!semver_gte("1.0.0-rc.1", "1.0.0"));
2697 assert!(semver_gte("1.0.0", "1.0.0-rc.1"));
2698 assert!(semver_gte("1.0.0+build.42", "1.0.0"));
2700 assert!(semver_gte("1.0.0", "1.0.0+build.42"));
2701 assert!(!semver_gte("1.0.0-rc.1+build.42", "1.0.0"));
2703 assert!(!semver_gte("v1.0.0-rc.1", "1.0.0"));
2705 assert!(!semver_gte("v0.9.5-beta.1", "0.9.6"));
2706 assert!(semver_gte("1.0.0-rc.2", "1.0.0-rc.1"));
2708 }
2709
2710 async fn start_namespaced_mock_registry(
2714 registry_name: &str,
2715 skill_filename: &str,
2716 skill_content: String,
2717 ) -> (String, tokio::task::JoinHandle<()>) {
2718 let content_hash = bytes_sha256(skill_content.as_bytes());
2719 let manifest = serde_json::json!({
2720 "version": "1.0.0",
2721 "packs": {
2722 "providers": {
2723 "sha256": "unused",
2724 "path": "registry/providers.toml"
2725 },
2726 "skills": {
2727 "sha256": null,
2728 "path": format!("registry/{registry_name}/"),
2729 "files": {
2730 skill_filename: content_hash
2731 }
2732 }
2733 }
2734 })
2735 .to_string();
2736
2737 let skill_route = format!("/registry/{registry_name}/{skill_filename}");
2738
2739 let state = MockRegistry {
2740 manifest,
2741 providers: String::new(),
2742 skill_payload: skill_content,
2743 };
2744
2745 async fn manifest_h(State(st): State<MockRegistry>) -> Json<serde_json::Value> {
2746 Json(serde_json::from_str(&st.manifest).unwrap())
2747 }
2748 async fn providers_h(State(st): State<MockRegistry>) -> String {
2749 st.providers.clone()
2750 }
2751 async fn skill_h(State(st): State<MockRegistry>) -> String {
2752 st.skill_payload.clone()
2753 }
2754
2755 let app = Router::new()
2756 .route("/manifest.json", get(manifest_h))
2757 .route("/registry/providers.toml", get(providers_h))
2758 .route(&skill_route, get(skill_h))
2759 .with_state(state);
2760 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2761 let addr = listener.local_addr().unwrap();
2762 let handle = tokio::spawn(async move {
2763 axum::serve(listener, app).await.unwrap();
2764 });
2765 (
2766 format!("http://{}:{}/manifest.json", addr.ip(), addr.port()),
2767 handle,
2768 )
2769 }
2770
2771 #[serial_test::serial]
2772 #[tokio::test]
2773 async fn multi_registry_namespaces_non_default_skills() {
2774 let temp = tempfile::tempdir().unwrap();
2775 let _home_guard = EnvGuard::set("HOME", temp.path().to_str().unwrap());
2776 let skills_dir = temp.path().join("skills");
2777 let config_path = temp.path().join("roboticus.toml");
2778
2779 let skill_content = "# community skill\nbody\n".to_string();
2780 let (registry_url, handle) =
2781 start_namespaced_mock_registry("community", "helper.md", skill_content.clone()).await;
2782
2783 let config_toml = format!(
2785 r#"[skills]
2786skills_dir = "{}"
2787
2788[update]
2789registry_url = "{}"
2790
2791[[update.registries]]
2792name = "community"
2793url = "{}"
2794priority = 40
2795enabled = true
2796"#,
2797 skills_dir.display().to_string().replace('\\', "/"),
2798 registry_url,
2799 registry_url,
2800 );
2801 std::fs::write(&config_path, &config_toml).unwrap();
2802
2803 let changed = apply_multi_registry_skills_update(true, None, config_path.to_str().unwrap())
2804 .await
2805 .unwrap();
2806
2807 assert!(changed);
2808 let namespaced_path = skills_dir.join("community").join("helper.md");
2810 assert!(
2811 namespaced_path.exists(),
2812 "expected skill at {}, files in skills_dir: {:?}",
2813 namespaced_path.display(),
2814 std::fs::read_dir(&skills_dir)
2815 .map(|rd| rd.flatten().map(|e| e.path()).collect::<Vec<_>>())
2816 .unwrap_or_default()
2817 );
2818 assert_eq!(
2819 std::fs::read_to_string(&namespaced_path).unwrap(),
2820 skill_content
2821 );
2822
2823 handle.abort();
2824 }
2825}