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