Skip to main content

roboticus_cli/cli/
update.rs

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// ── Registry manifest (remote) ───────────────────────────────
26
27#[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// ── Local update state ───────────────────────────────────────
55
56#[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
124// ── Helpers ──────────────────────────────────────────────────
125
126/// Validate that `filename` joined to `base_dir` stays within the base directory.
127///
128/// Defence-in-depth beyond the string-level `..` / `is_absolute` check:
129/// normalize the path components and verify the resolved path still has
130/// `base_dir` as a prefix.  This catches multi-component escapes and
131/// symlinks that might slip past the string-level guard.
132fn is_safe_skill_path(base_dir: &Path, filename: &str) -> bool {
133    // First-pass string guard (kept for clarity)
134    if filename.contains("..") || Path::new(filename).is_absolute() {
135        return false;
136    }
137    // Resolve the effective base: canonicalize if possible (resolves symlinks
138    // such as macOS's /var → /private/var), otherwise normalize components.
139    // Using the same effective base for both joining and prefix-checking avoids
140    // false negatives when the original path and canonical path differ.
141    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    // Join filename to the effective base, then normalize to resolve `.` and
148    // multi-component oddities without requiring the file to exist.
149    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
267/// Callback type for state hygiene. The closure receives a config file path
268/// and returns `Ok(Some((changed_rows, subagent, cron_payload, cron_disabled)))`
269/// on success, or an error.
270pub type HygieneFn = Box<
271    dyn Fn(&str) -> Result<Option<(u64, u64, u64, u64)>, Box<dyn std::error::Error>> + Send + Sync,
272>;
273
274/// Callback type for daemon operations (restart after update).
275pub type DaemonOps = Box<dyn Fn() -> Result<(), Box<dyn std::error::Error>> + Send + Sync>;
276
277/// Result of the daemon installed check + restart.
278pub 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
325// ── Version comparison ───────────────────────────────────────
326
327fn 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        // The script retries the copy for up to 60 seconds, logs success/failure,
558        // and cleans up the staging directory on success.
559        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) // DETACHED_PROCESS
586            .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            // best-effort: temp dir cleanup on extraction failure
677            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            // best-effort: temp dir cleanup on extraction failure
691            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    // best-effort: temp dir cleanup after successful install
706    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/// Spawn `cargo install` as a detached process on Windows so the running
764/// executable's file lock is released before the build tries to overwrite it.
765/// Returns `true` if the detached process was spawned successfully — the
766/// actual build result is logged to a temp file for the user.
767#[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    // Resolve cargo path so the detached process doesn't depend on shell
795    // profile having loaded PATH correctly.
796    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) // DETACHED_PROCESS
838        .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
935// ── TOML diff ────────────────────────────────────────────────
936
937pub 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
993// ── Binary update ────────────────────────────────────────────
994
995pub(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            // Unreachable — guarded by env::consts::OS check above.
1061            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                    // BUG-020 rationale preserved: always prompt, never silently switch.
1090                    // On Windows we spawn a detached process so the file lock is released.
1091                    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                    // BUG-020: Always prompt for build fallback regardless of --yes flag.
1107                    // The user chose download method explicitly; silently switching to a
1108                    // cargo build is a different operation (slower, requires Rust toolchain).
1109                    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
1139// ── Content update (providers + skills) ──────────────────────
1140
1141pub(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        // Path traversal guard: string check + component normalization.
1324        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 &current_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        // Verify downloaded content matches the manifest hash before writing to disk.
1409        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        // Verify downloaded content matches the manifest hash before offering to the user.
1437        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
1496// ── Multi-registry support ───────────────────────────────────
1497
1498/// Compare two semver-style version strings.  Returns `true` when
1499/// `local >= remote`, meaning an update is unnecessary.  Gracefully
1500/// falls back to string comparison for non-numeric segments.
1501fn semver_gte(local: &str, remote: &str) -> bool {
1502    /// Decompose a version string into (core_parts, has_pre_release).
1503    /// Per semver, a pre-release version has *lower* precedence than the
1504    /// same core version without a pre-release suffix: 1.0.0-rc.1 < 1.0.0.
1505    fn parse(v: &str) -> (Vec<u64>, bool) {
1506        let v = v.trim_start_matches('v');
1507        // Strip build metadata first (has no effect on precedence).
1508        let v = v.split_once('+').map(|(core, _)| core).unwrap_or(v);
1509        // Detect and strip pre-release suffix.
1510        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    // Core versions are equal.  A pre-release is *less than* the release:
1533    // local=1.0.0-rc.1 vs remote=1.0.0  →  local < remote  →  false
1534    // local=1.0.0      vs remote=1.0.0-rc.1 → local > remote → true
1535    if l_pre && !r_pre {
1536        return false;
1537    }
1538    true
1539}
1540
1541/// Apply skills updates from all configured registries.
1542///
1543/// Registries are processed in priority order (highest first). When two
1544/// registries publish a skill with the same filename, the higher-priority
1545/// one wins.  Non-default registries are namespaced into subdirectories
1546/// (e.g. `skills/community/`) so they coexist with the default set.
1547pub(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 the user supplied a CLI override, fall through to the single-registry path.
1556    if let Some(url) = cli_registry_override {
1557        return apply_skills_update(yes, url, config_path).await;
1558    }
1559
1560    // Parse just the [update] section to avoid requiring a full valid config.
1561    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            // Fallback: single default registry from legacy resolution.
1570            let url = resolve_registry_url(None, config_path);
1571            return apply_skills_update(yes, &url, config_path).await;
1572        }
1573    };
1574
1575    // Only one "default" registry (the common case) — delegate directly.
1576    // Non-default registries always need the namespace logic below.
1577    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    // Multiple registries — process in priority order (highest first).
1591    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    // Show configured registries and prompt before fetching from non-default sources.
1597    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            // Fall back to default-only.
1611            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    // Track claimed filenames to resolve cross-registry conflicts.
1629    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, &reg.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        // Version-based skip: if local installed version >= remote, skip.
1648        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            // Also verify all file hashes still match before declaring up-to-date.
1656            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        // Determine the target directory for this registry's files.
1671        // Guard: registry names must not contain path traversal components.
1672        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(&reg.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(&reg.url);
1687        let mut applied = 0u32;
1688
1689        for (filename, remote_hash) in &manifest.packs.skills.files {
1690            // Path traversal guard: string check + component normalization.
1691            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            // Cross-registry conflict: key on the resolved file path so that
1701            // different namespaced registries writing to different directories
1702            // don't falsely collide on the same bare filename.
1703            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; // Already up to date.
1716                }
1717            }
1718
1719            // Fetch and write the file, verifying hash matches manifest.
1720            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    // Save updated state — record file hashes so the next run can skip unchanged files.
1767    // Without persisting `installed_content.skills`, the multi-registry path would
1768    // re-download every file on every run because it couldn't prove they're up-to-date.
1769    {
1770        let mut state = UpdateState::load();
1771        state.last_check = now_iso();
1772        if any_changed {
1773            // Build a merged file-hash map across all registries.
1774            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            // Walk skills_dir to capture current on-disk hashes.
1781            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            // Use the highest manifest version across registries.
1793            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; // We don't have per-registry versions cached; use "multi".
1800            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
1830// ── Public CLI entry points ──────────────────────────────────
1831
1832pub 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    // Binary
1845    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    // Content packs — resolve all configured registries (multi-registry aware).
1861    let registries: Vec<roboticus_core::config::RegistrySource> =
1862        if let Some(url) = registry_url_override {
1863            // CLI override → single registry.
1864            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                    // Fallback: legacy single-URL resolution.
1882                    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    // Check the primary (first enabled) registry for providers + skills status.
1904    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            // Providers
1915            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            // Skills
1928            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            // Check additional non-default registries for reachability.
1955            for reg in enabled.iter().skip(1) {
1956                match fetch_manifest(&client, &reg.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    // ── Liability Waiver ──────────────────────────────────────────
1994    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, &registry_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    // ── Post-upgrade security config migration ─────────────────────
2030    // Detect pre-RBAC configs (no [security] section) and warn about
2031    // the breaking change: empty allow-lists now deny all messages.
2032    if let Err(e) = apply_security_config_migration(config_path) {
2033        println!("    {WARN} Security config migration skipped: {e}");
2034    }
2035
2036    // Restart the daemon if a binary update was applied and --no-restart was not passed.
2037    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, &registry_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
2102// ── Security config migration ────────────────────────────────
2103
2104/// Detect pre-RBAC config files (missing `[security]` section) and auto-append
2105/// the section with explicit defaults. Also prints a breaking-change warning
2106/// about the new deny-by-default behavior for empty channel allow-lists.
2107fn 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    // Normalize line endings for reliable section detection.
2115    let normalized = raw.replace("\r\n", "\n").replace('\r', "\n");
2116
2117    // Check if [security] section already exists (line-anchored, not substring).
2118    let has_security = normalized.lines().any(|line| line.trim() == "[security]");
2119
2120    if has_security {
2121        return Ok(());
2122    }
2123
2124    // ── Breaking change warning ──────────────────────────────
2125    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    // Parse the config to show per-channel status.
2140    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    // ── Auto-append [security] section with explicit defaults ─
2160    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    // Backup before modifying.
2172    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
2190/// Produce human-readable status lines for each configured channel's allow-list.
2191fn 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// ── Tests ────────────────────────────────────────────────────
2251
2252#[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, &registry_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, &registry_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, &registry_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, &registry_url, config_path.to_str().unwrap())
2891                .await
2892                .unwrap();
2893        assert!(!changed_second);
2894        handle.abort();
2895    }
2896
2897    // ── semver_gte tests ────────────────────────────────────────
2898
2899    #[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        // Per semver spec: pre-release has LOWER precedence than its release.
2928        // 1.0.0-rc.1 < 1.0.0
2929        assert!(!semver_gte("1.0.0-rc.1", "1.0.0"));
2930        assert!(semver_gte("1.0.0", "1.0.0-rc.1"));
2931        // Build metadata: "1.0.0+hotfix.1" should compare as 1.0.0
2932        assert!(semver_gte("1.0.0+build.42", "1.0.0"));
2933        assert!(semver_gte("1.0.0", "1.0.0+build.42"));
2934        // Combined: pre-release + build metadata → still pre-release < release
2935        assert!(!semver_gte("1.0.0-rc.1+build.42", "1.0.0"));
2936        // v prefix with pre-release
2937        assert!(!semver_gte("v1.0.0-rc.1", "1.0.0"));
2938        assert!(!semver_gte("v0.9.5-beta.1", "0.9.6"));
2939        // Two pre-releases with same core version — both are pre-release, so equal core → true
2940        assert!(semver_gte("1.0.0-rc.2", "1.0.0-rc.1"));
2941    }
2942
2943    // ── Multi-registry test ─────────────────────────────────────
2944
2945    /// Helper to start a mock registry that serves skills under a given namespace.
2946    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        // Write a config file with a multi-registry setup.
3017        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        // Skill should be namespaced under community/ subdirectory.
3042        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}