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