use super::bundles::{ BundleStore, FileAction, FileRecord, InstallMetadata, McpAction, McpServerRecord, hash_bytes, hash_file, }; use crate::config::{AssetCategory, BUNDLE_MANIFEST_FILE, InstallFilter, paths}; #[cfg(not(windows))] use crate::function::Language; use crate::mcp::{McpServer, McpServersConfig}; use crate::utils; use crate::utils::IS_STDOUT_TERMINAL; use crate::vault::{SECRET_RE, Vault, create_vault_password_file, interpolate_secrets}; use anyhow::{Context, Result, anyhow, bail}; use clap::ValueEnum; use indexmap::IndexMap; use indoc::formatdoc; use inquire::{Confirm, Select}; use serde::Deserialize; use std::collections::{BTreeSet, HashMap, HashSet}; use std::ffi::{OsStr, OsString}; use std::path::{Component, Path, PathBuf}; use std::{fs, iter}; pub fn install_remote(git_url: &str, filter: Option, force: bool) -> Result<()> { let (url, reference) = parse_url_with_ref(git_url)?; let temp = clone_to_temp(&url, reference.as_deref())?; println!("Cloned {git_url} to {}", temp.path().display()); let mut layout = scan_remote_layout(temp.path())?; layout.head_sha = Some(temp.head_sha().to_string()); let layout = apply_filter(layout, filter); if layout.is_empty() { println!( "No recognized assets found in {git_url}. Expected one or more of: \ agents/, roles/, skills/, macros/, functions/tools/, functions/mcp.json" ); return Ok(()); } let mut store = BundleStore::load()?; let bundle = register_bundle( &mut store, &url, reference.as_deref(), layout.manifest.as_ref(), temp.head_sha(), false, )?; let plan = plan_changes(&layout)?; let plan = reclassify_owned_unmodified(plan, &store, &bundle)?; if !plan.files.is_empty() { print_plan_summary(&plan); let sticky = if force { StickyMode::ReplaceAll } else { StickyMode::None }; apply_plan(&plan, sticky, &mut store, &bundle)?; } if let Some((remote_mcp, local_mcp)) = &plan.mcp_json { let local = local_mcp.exists().then_some(local_mcp.as_path()); let report = merge_mcp_json(local, remote_mcp, local_mcp, force, &HashSet::new(), false)?; record_mcp_merge(&mut store, &bundle, &report)?; print_mcp_merge_report(&report); handle_missing_secrets(&report.missing_secrets)?; } Ok(()) } fn parse_repl_install_flags( command: &str, mut iter: impl Iterator, ) -> Result<(Option, bool, Option)> { let mut filter: Option = None; let mut force = false; let mut git_host: Option = None; while let Some(tok) = iter.next() { match tok.as_str() { "--force" => force = true, "--filter" => { let val = iter.next().with_context(|| { format!( "--filter requires a value (one of: {})", InstallFilter::NAMES.join(", ") ) })?; filter = Some(parse_filter(&val)?); } s if s.starts_with("--filter=") => { filter = Some(parse_filter(&s["--filter=".len()..])?); } "--git-host" => { let val = iter .next() .with_context(|| "--git-host requires a value (e.g. git.somedomain.com)")?; git_host = Some(val); } s if s.starts_with("--git-host=") => { git_host = Some(s["--git-host=".len()..].to_string()); } other => bail!("Unexpected argument to '{command}': {other}"), } } Ok((filter, force, git_host)) } #[derive(Debug, Clone, PartialEq)] enum InstallTarget { Category(AssetCategory), InstalledBundle, RemoteSource, Shorthand, Unknown, } fn classify_install_target(value: &str, installed_names: &[String]) -> InstallTarget { if let Some(category) = AssetCategory::parse(value) { return InstallTarget::Category(category); } let name = strip_ref_suffix(value); if installed_names.iter().any(|installed| installed == name) { return InstallTarget::InstalledBundle; } if looks_like_remote_source(value) { return InstallTarget::RemoteSource; } if is_repo_shorthand(value) { return InstallTarget::Shorthand; } InstallTarget::Unknown } fn looks_like_remote_source(value: &str) -> bool { if value.contains("://") || value.starts_with("./") || value.starts_with("../") || value.starts_with('/') || value.starts_with('~') { return true; } match value.split_once(':') { Some((host, _)) => { !host.is_empty() && !host.contains('/') && !host.chars().any(char::is_whitespace) } None => false, } } pub(crate) const DEFAULT_GIT_HOST: &str = "github.com"; fn is_repo_shorthand(value: &str) -> bool { let path = strip_ref_suffix(value); if path.contains("://") || path.contains(':') || path.contains('\\') || path.contains(char::is_whitespace) || path.starts_with(['/', '~', '.', '-']) { return false; } let mut segments = path.split('/'); segments.clone().count() >= 2 && segments.all(|segment| !segment.is_empty()) } fn expand_repo_shorthand(value: &str, git_host: Option<&str>) -> Result { let raw = git_host.unwrap_or(DEFAULT_GIT_HOST); let host = raw .strip_prefix("https://") .or_else(|| raw.strip_prefix("http://")) .unwrap_or(raw) .trim_matches('/'); if host.is_empty() || host.contains(['/', '#', '?']) || host.chars().any(char::is_whitespace) { bail!("invalid --git-host '{raw}': expected a bare host like git.somedomain.com"); } Ok(format!("https://{host}/{value}")) } pub fn install_or_update( value: &str, git_host: Option<&str>, filter: Option, force: bool, ) -> Result<()> { if let Some(host) = git_host { if !is_repo_shorthand(value) { bail!( "--git-host only applies to / shorthand values; \ '{value}' is not one" ); } let url = expand_repo_shorthand(value, Some(host))?; println!("Resolved '{value}' to '{url}'"); return install_remote(&url, filter, force); } let store = BundleStore::load()?; let installed: Vec = store .bundle_names() .into_iter() .map(str::to_string) .collect(); match classify_install_target(value, &installed) { InstallTarget::Category(category) => { let name = category .to_possible_value() .map_or_else(|| value.to_string(), |v| v.get_name().to_string()); let update_hint = if installed.iter().any(|installed| installed == value) { format!(" To update the installed bundle '{value}', use `--update-bundle {value}`.") } else { String::new() }; bail!( "'{value}' is an asset category, not a bundle; did you mean \ `--install-builtins {name}`? (categories are reinstalled from \ the assets built into coyote, not from a remote){update_hint}" ); } InstallTarget::InstalledBundle => { if filter.is_some() || force { bail!("--filter/--install-force only apply to remote installs, not bundle updates"); } update_bundle(value, false) } InstallTarget::RemoteSource => install_remote(value, filter, force), InstallTarget::Shorthand => { let url = expand_repo_shorthand(value, None)?; println!("Resolved '{value}' to '{url}'"); install_remote(&url, filter, force) } InstallTarget::Unknown => { let hint = "a remote source must be a git URL, an / shorthand \ (expanded against --git-host, default github.com), an scp-style \ host:path, or an explicit local path (./dir, /abs, ~)"; if installed.is_empty() { bail!("no bundle named '{value}' is installed; none are installed ({hint})"); } bail!( "no bundle named '{value}' is installed; installed bundles: {} ({hint})", installed.join(", ") ) } } } pub fn install_or_update_from_repl_args(args: &str) -> Result<()> { let tokens = shell_words::split(args) .with_context(|| format!("failed to parse '.install' args: {args}"))?; let mut iter = tokens.into_iter(); let value = iter.next().with_context(|| { format!( "Usage: .install \ [--git-host ] [--filter <{}>] [--force]", InstallFilter::NAMES.join("|") ) })?; let (filter, force, git_host) = parse_repl_install_flags(".install", iter)?; install_or_update(&value, git_host.as_deref(), filter, force) } /// The whole remote is always processed, including categories a filtered /// install excluded, because filtered installs merge into a single record. pub fn update_bundle(spec: &str, assume_yes: bool) -> Result<()> { let (name, ref_override) = parse_url_with_ref(spec)?; let mut store = BundleStore::load()?; let Some(record) = store.get(&name) else { let installed = store.bundle_names(); if installed.is_empty() { bail!("no bundle named '{name}' is installed; none are installed"); } bail!( "no bundle named '{name}' is installed; installed bundles: {}", installed.join(", ") ); }; let source = record.source.clone(); let recorded_ref = record.git_ref.clone(); let has_override = ref_override.is_some(); let effective_ref = ref_override.or(recorded_ref); if !has_override && let Some(pinned) = effective_ref.as_deref() && is_commit_sha(pinned) { println!("Bundle '{name}' is pinned to commit {pinned}; pass # to move the pin."); } let temp = clone_to_temp(&source, effective_ref.as_deref())?; println!("Cloned {source} to {}", temp.path().display()); let mut layout = scan_remote_layout(temp.path())?; layout.head_sha = Some(temp.head_sha().to_string()); if layout.is_empty() { println!( "The source for '{name}' no longer contains recognized assets; \ leaving installed files and the bundle record untouched." ); return Ok(()); } let bundle = register_bundle( &mut store, &source, effective_ref.as_deref(), layout.manifest.as_ref(), temp.head_sha(), true, )?; let plan = plan_changes(&layout)?; let plan = reclassify_owned_unmodified(plan, &store, &bundle)?; if !plan.files.is_empty() { print_plan_summary(&plan); let sticky = if assume_yes { StickyMode::KeepAll } else { StickyMode::None }; apply_plan(&plan, sticky, &mut store, &bundle)?; } handle_obsolete_files(&mut store, &bundle, &plan, assume_yes)?; if let Some((remote_mcp, local_mcp)) = &plan.mcp_json { let local = local_mcp.exists().then_some(local_mcp.as_path()); let auto_take = owned_unmodified_mcp_keys(&store, &bundle, local)?; let report = merge_mcp_json(local, remote_mcp, local_mcp, false, &auto_take, assume_yes)?; record_mcp_merge(&mut store, &bundle, &report)?; print_mcp_merge_report(&report); handle_missing_secrets(&report.missing_secrets)?; } let version = layout .manifest .as_ref() .and_then(|m| m.version.clone()) .unwrap_or_else(|| temp.head_sha().chars().take(7).collect()); store.set_bundle_versions(&bundle, temp.head_sha(), Some(version))?; store.mark_updated(&bundle)?; Ok(()) } fn reclassify_owned_unmodified( mut plan: InstallPlan, store: &BundleStore, bundle: &str, ) -> Result { let owned: HashMap<&str, &str> = store .get(bundle) .map(|record| { record .files .iter() .map(|file| (file.path.as_str(), file.sha256.as_str())) .collect() }) .unwrap_or_default(); for planned in &mut plan.files { if planned.kind != PlannedKind::Conflict { continue; } let Some(recorded) = owned.get(provenance_path(&planned.dst).as_str()) else { continue; }; if hash_file(&planned.dst)? == *recorded { planned.kind = PlannedKind::Refresh; } } Ok(plan) } /// Keys of this bundle's mcp entries whose recorded hash still matches the /// local entry: the bundle wrote them and the user never touched them, so an /// upstream change takes the remote side without prompting. fn owned_unmodified_mcp_keys( store: &BundleStore, bundle: &str, local: Option<&Path>, ) -> Result> { let mut keys = HashSet::new(); let (Some(local_path), Some(record)) = (local, store.get(bundle)) else { return Ok(keys); }; let content = fs::read_to_string(local_path) .with_context(|| format!("failed to read local mcp.json at {}", local_path.display()))?; let config: McpServersConfig = serde_json::from_str(&content) .with_context(|| format!("failed to parse local mcp.json at {}", local_path.display()))?; for server in &record.mcp_servers { let Some(recorded_hash) = server.sha256.as_deref() else { continue; }; let key = server.effective_key(); let Some(entry) = config.mcp_servers.get(key) else { continue; }; let serialized = serde_json::to_string(entry) .with_context(|| format!("failed to serialize MCP server '{key}'"))?; if hash_bytes(serialized.as_bytes()) == recorded_hash { keys.insert(key.to_string()); } } Ok(keys) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ObsoleteAction { Keep, Delete, } /// Kept files stay in the record, so a later uninstall still offers to /// remove them. fn handle_obsolete_files( store: &mut BundleStore, bundle: &str, plan: &InstallPlan, assume_yes: bool, ) -> Result<()> { let planned: HashSet = plan .files .iter() .map(|planned| provenance_path(&planned.dst)) .collect(); let obsolete: Vec = store .get(bundle) .map(|record| { record .files .iter() .map(|file| file.path.clone()) .filter(|path| !planned.contains(path)) .collect() }) .unwrap_or_default(); let config_dir = paths::config_dir(); let mut sticky: Option = assume_yes.then_some(ObsoleteAction::Keep); for path in obsolete { if !is_safe_relative_path(&path) { eprintln!("skipping suspicious recorded path {path}; keeping its record"); continue; } let full = config_dir.join(&path); if !full.exists() { println!("dropped record for obsolete file {path} (already absent locally)"); store.remove_file_record(bundle, &path)?; continue; } let action = resolve_obsolete(&path, &mut sticky)?; apply_obsolete_action(store, bundle, &path, &full, &config_dir, action)?; } Ok(()) } fn resolve_obsolete(path: &str, sticky: &mut Option) -> Result { if let Some(action) = *sticky { return Ok(action); } if !*IS_STDOUT_TERMINAL { return Ok(ObsoleteAction::Keep); } let prompt = format!("Obsolete file {path} is no longer shipped by the bundle"); let choice = Select::new(&prompt, vec!["keep", "delete", "keep-all", "delete-all"]) .prompt() .with_context(|| "failed to read obsolete-file choice")?; match choice { "keep" => Ok(ObsoleteAction::Keep), "delete" => Ok(ObsoleteAction::Delete), "keep-all" => { *sticky = Some(ObsoleteAction::Keep); Ok(ObsoleteAction::Keep) } "delete-all" => { *sticky = Some(ObsoleteAction::Delete); Ok(ObsoleteAction::Delete) } _ => unreachable!("inquire::Select returned an unexpected option"), } } fn apply_obsolete_action( store: &mut BundleStore, bundle: &str, path: &str, full: &Path, config_dir: &Path, action: ObsoleteAction, ) -> Result<()> { match action { ObsoleteAction::Keep => { println!("kept obsolete file {path} (no longer shipped by the bundle)"); } ObsoleteAction::Delete => { fs::remove_file(full) .with_context(|| format!("failed to delete obsolete file {}", full.display()))?; store.remove_file_record(bundle, path)?; prune_empty_dirs(full, config_dir); println!("deleted obsolete file {path}"); } } Ok(()) } #[derive(Debug, Default, PartialEq, Eq)] struct UninstallFileSummary { deleted: usize, kept: usize, missing: usize, failed: usize, /// Set when a functions/tools file was found on disk; its compiled binary /// lingers in the functions bin dir until the next --build-tools prune. tools_seen: bool, } #[derive(Debug, Default)] struct UninstallMcpSummary { removed: Vec, kept: Vec, secrets: Vec, } /// Kept and failed items stay in the record, so a re-run offers them again. pub fn uninstall_bundle(spec: &str, assume_yes: bool) -> Result<()> { let mut store = BundleStore::load()?; let name = match store.get(spec) { Some(_) => spec.to_string(), None => match store.find_by_source(spec) { Some((name, _)) => name.to_string(), None => match select_uninstall_candidate(&store, spec)? { Some(name) => name, None => { let installed = store.bundle_names(); if installed.is_empty() { bail!("no bundle named '{spec}' is installed; none are installed"); } bail!( "no bundle named '{spec}' is installed; installed bundles: {}", installed.join(", ") ); } }, }, }; let record = store.get(&name).expect("resolved above").clone(); if !assume_yes { if !*IS_STDOUT_TERMINAL { bail!( "refusing to uninstall bundle '{name}' non-interactively; \ re-run with --yes to confirm" ); } println!( "Bundle '{name}' owns {} file(s) and {} mcp.json server(s).", record.files.len(), record.mcp_servers.len() ); let proceed = Confirm::new(&format!("Uninstall bundle '{name}'?")) .with_default(false) .prompt() .with_context(|| "failed to read uninstall confirmation")?; if !proceed { println!("Uninstall of '{name}' aborted; nothing was changed."); return Ok(()); } } let files = uninstall_owned_files( &mut store, &name, &record.files, &paths::config_dir(), assume_yes, )?; if files.tools_seen { println!( "Note: compiled tool binaries remain in {} until the next --build-tools prune.", paths::functions_bin_dir().display() ); } let mcp = uninstall_mcp_entries( &mut store, &name, &record.mcp_servers, &paths::mcp_config_file(), assume_yes, )?; let empty = store .get(&name) .map(|record| record.files.is_empty() && record.mcp_servers.is_empty()) .unwrap_or(true); if empty { store.remove_bundle(&name)?; println!("\nUninstalled bundle '{name}' and removed its record."); } else { println!( "\nBundle '{name}' partially uninstalled; its record keeps the remaining \ items, so re-running --uninstall offers them again." ); } println!( " files: deleted={} kept={} missing={} failed={}", files.deleted, files.kept, files.missing, files.failed ); println!( " mcp servers: removed={} kept={}", mcp.removed.len(), mcp.kept.len() ); if !mcp.removed.is_empty() { println!(" - removed servers: {}", mcp.removed.join(", ")); } if !mcp.kept.is_empty() { println!(" = kept servers: {}", mcp.kept.join(", ")); } if !mcp.secrets.is_empty() { println!( " ~ vault secrets referenced by this bundle's servers \ (installed by this bundle, not removed): {}", mcp.secrets.join(", ") ); } Ok(()) } /// Never auto-picks between multiple matches: ambiguity is resolved by an /// interactive prompt, and non-interactive runs bail even under --yes. fn select_uninstall_candidate(store: &BundleStore, spec: &str) -> Result> { if !is_repo_shorthand(spec) { return Ok(None); } let needle = format!("/{}", canonical_source_url(spec)); let candidates: Vec<(String, String)> = store .iter() .filter(|(_, record)| canonical_source_url(&record.source).ends_with(&needle)) .map(|(name, record)| (name.to_string(), record.source.clone())) .collect(); match candidates.as_slice() { [] => Ok(None), [(name, _)] => Ok(Some(name.clone())), _ => { let described: Vec = candidates .iter() .map(|(name, source)| format!("{name} ({source})")) .collect(); if !*IS_STDOUT_TERMINAL { bail!( "'{spec}' matches multiple installed bundles: {}; re-run with the \ exact bundle name or source URL", described.join(", ") ); } let picked = Select::new( &format!("Multiple bundles match '{spec}'; which one should be uninstalled?"), described.clone(), ) .prompt() .with_context(|| "failed to read uninstall selection")?; let index = described .iter() .position(|option| option == &picked) .expect("selection came from the presented options"); Ok(Some(candidates[index].0.clone())) } } } /// True only when joining the path onto the config dir cannot escape it: /// relative and made of plain components. Anything else in a recorded path /// means a tampered store, and it must never become a file deletion. fn is_safe_relative_path(path: &str) -> bool { let recorded = Path::new(path); !recorded.is_absolute() && recorded .components() .all(|c| matches!(c, Component::Normal(_))) } fn uninstall_owned_files( store: &mut BundleStore, bundle: &str, files: &[FileRecord], config_dir: &Path, assume_yes: bool, ) -> Result { let mut summary = UninstallFileSummary::default(); let mut sticky: Option = None; for file in files { if !is_safe_relative_path(&file.path) { eprintln!( "skipping suspicious recorded path {}; keeping its record", file.path ); summary.failed += 1; continue; } let full = config_dir.join(Path::new(&file.path)); if !full.exists() { println!( "dropped record for missing file {} (already absent locally)", file.path ); store.remove_file_record(bundle, &file.path)?; summary.missing += 1; continue; } summary.tools_seen |= file.category == "functions/tools"; if matches!(hash_file(&full), Ok(hash) if hash == file.sha256) { delete_owned_file(store, bundle, &file.path, &full, config_dir, &mut summary)?; continue; } let prompt = format!("File {} was modified locally after install", file.path); let action = resolve_uninstall_action(&prompt, assume_yes, &mut sticky)?; apply_uninstall_file_action( store, bundle, &file.path, &full, config_dir, action, &mut summary, )?; } Ok(summary) } fn apply_uninstall_file_action( store: &mut BundleStore, bundle: &str, path: &str, full: &Path, config_dir: &Path, action: ObsoleteAction, summary: &mut UninstallFileSummary, ) -> Result<()> { match action { ObsoleteAction::Keep => { println!("kept modified file {path}"); summary.kept += 1; } ObsoleteAction::Delete => { delete_owned_file(store, bundle, path, full, config_dir, summary)?; } } Ok(()) } fn delete_owned_file( store: &mut BundleStore, bundle: &str, path: &str, full: &Path, config_dir: &Path, summary: &mut UninstallFileSummary, ) -> Result<()> { match fs::remove_file(full) { Ok(()) => { store.remove_file_record(bundle, path)?; println!("deleted {path}"); summary.deleted += 1; prune_empty_dirs(full, config_dir); } Err(err) => { eprintln!( "failed to delete {}: {err}; keeping its record", full.display() ); summary.failed += 1; } } Ok(()) } /// Remove now-empty ancestors of a deleted file, climbing strictly below /// `config_dir`. `fs::remove_dir` fails on a non-empty directory, which is /// the stop condition. fn prune_empty_dirs(full: &Path, config_dir: &Path) { let mut current = full.parent(); while let Some(dir) = current { if dir == config_dir || !dir.starts_with(config_dir) { break; } if fs::remove_dir(dir).is_err() { break; } current = dir.parent(); } } fn resolve_uninstall_action( prompt: &str, assume_yes: bool, sticky: &mut Option, ) -> Result { if assume_yes || !*IS_STDOUT_TERMINAL { return Ok(ObsoleteAction::Keep); } if let Some(action) = *sticky { return Ok(action); } let choice = Select::new(prompt, vec!["keep", "delete", "keep-all", "delete-all"]) .prompt() .with_context(|| "failed to read uninstall choice")?; match choice { "keep" => Ok(ObsoleteAction::Keep), "delete" => Ok(ObsoleteAction::Delete), "keep-all" => { *sticky = Some(ObsoleteAction::Keep); Ok(ObsoleteAction::Keep) } "delete-all" => { *sticky = Some(ObsoleteAction::Delete); Ok(ObsoleteAction::Delete) } _ => unreachable!("inquire::Select returned an unexpected option"), } } /// mcp.json is written before any ownership record is dropped: a failed write /// leaves every record intact for a retry, while a crash after it leaves /// stale records the absent-server branch cleans up on re-run. fn uninstall_mcp_entries( store: &mut BundleStore, bundle: &str, servers: &[McpServerRecord], mcp_path: &Path, assume_yes: bool, ) -> Result { let mut summary = UninstallMcpSummary::default(); if servers.is_empty() { return Ok(summary); } let mut config = if mcp_path.exists() { let content = fs::read_to_string(mcp_path) .with_context(|| format!("failed to read {}", mcp_path.display()))?; Some( serde_json::from_str::(&content) .with_context(|| format!("failed to parse {}", mcp_path.display()))?, ) } else { None }; let mut secret_names: BTreeSet = BTreeSet::new(); for server in servers { if let Some(entry) = config .as_ref() .and_then(|cfg| cfg.mcp_servers.get(server.effective_key())) && let Ok(serialized) = serde_json::to_string(entry) { for capture in SECRET_RE.captures_iter(&serialized) { if let Ok(capture) = capture && let Some(name) = capture.get(1) { secret_names.insert(name.as_str().to_string()); } } } } summary.secrets = secret_names.into_iter().collect(); let mut changed = false; let mut released = Vec::new(); let mut sticky: Option = None; for server in servers { let key = server.effective_key().to_string(); if server.action == McpAction::Replaced { println!("kept pre-existing server '{key}'"); released.push(key.clone()); summary.kept.push(key); continue; } let Some(entry) = config.as_ref().and_then(|cfg| cfg.mcp_servers.get(&key)) else { println!("dropped record for absent server '{key}'"); released.push(key); continue; }; let serialized = serde_json::to_string(entry) .with_context(|| format!("failed to serialize MCP server '{key}'"))?; let intact = server.sha256.as_deref() == Some(hash_bytes(serialized.as_bytes()).as_str()); let action = if intact { ObsoleteAction::Delete } else { let reason = if server.sha256.is_none() { "predates entry hashing" } else { "was modified locally after install" }; let prompt = format!("MCP server '{key}' {reason}"); resolve_uninstall_action(&prompt, assume_yes, &mut sticky)? }; match action { ObsoleteAction::Keep => { println!("kept server '{key}'"); summary.kept.push(key); } ObsoleteAction::Delete => { config .as_mut() .expect("entry was found in the config above") .mcp_servers .shift_remove(&key); changed = true; println!("removed server '{key}'"); released.push(key.clone()); summary.removed.push(key); } } } if changed { let config = config.expect("changed implies a parsed config"); let serialized = serde_json::to_string_pretty(&config) .context("failed to serialize mcp.json after uninstall")?; write_atomically(mcp_path, &serialized)?; } for key in &released { store.remove_mcp_record(bundle, key)?; } Ok(summary) } fn parse_filter(name: &str) -> Result { InstallFilter::parse(name).with_context(|| { format!( "Unknown filter '{name}'. Valid values: {}", InstallFilter::NAMES.join(", ") ) }) } fn parse_url_with_ref(input: &str) -> Result<(String, Option)> { match input.rsplit_once('#') { Some((url, refspec)) if !url.is_empty() => { if refspec.is_empty() { bail!("Empty ref after '#' in URL: {input}"); } if refspec.contains("..") { bail!("Invalid ref '{refspec}': cannot contain '..'"); } if refspec.starts_with('-') { bail!( "Invalid ref '{refspec}': cannot start with '-' \ (would be parsed by git as a CLI flag)" ); } if !refspec .chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '/' | '-' | '+')) { bail!("Invalid ref '{refspec}': only [A-Za-z0-9._/+-] characters allowed"); } Ok((url.to_string(), Some(refspec.to_string()))) } _ => Ok((input.to_string(), None)), } } struct TempRepoDir { path: PathBuf, head_sha: String, } impl TempRepoDir { fn path(&self) -> &Path { &self.path } fn head_sha(&self) -> &str { &self.head_sha } } impl Drop for TempRepoDir { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.path); } } fn is_commit_sha(reference: &str) -> bool { reference.len() >= 4 && reference.len() <= 40 && reference.chars().all(|c| c.is_ascii_hexdigit()) } fn clone_to_temp(url: &str, reference: Option<&str>) -> Result { let dest = utils::temp_file("coyote-remote-install-", ""); let dest_arg: OsString = dest.as_os_str().into(); let is_sha = reference.is_some_and(is_commit_sha); match reference { Some(r) if !is_sha => { run_git(vec![ "clone".into(), "--depth".into(), "1".into(), "--branch".into(), r.into(), "--".into(), url.into(), dest_arg, ])?; } Some(r) => { run_git(vec![ "clone".into(), "--".into(), url.into(), dest_arg.clone(), ])?; run_git(vec!["-C".into(), dest_arg, "checkout".into(), r.into()])?; } None => { run_git(vec![ "clone".into(), "--depth".into(), "1".into(), "--".into(), url.into(), dest_arg, ])?; } } let head_sha = run_git_capture(vec![ "-C".into(), dest.as_os_str().into(), "rev-parse".into(), "HEAD".into(), ])?; Ok(TempRepoDir { path: dest, head_sha, }) } fn run_git(args: Vec) -> Result<()> { let output = duct::cmd("git", &args) .env("GIT_TERMINAL_PROMPT", "0") .stdin_null() .stderr_to_stdout() .stdout_capture() .unchecked() .run() .context("failed to spawn git (is it installed and on PATH?)")?; if !output.status.success() { let combined = String::from_utf8_lossy(&output.stdout); bail!("git failed: {}", combined.trim()); } Ok(()) } fn run_git_capture(args: Vec) -> Result { let output = duct::cmd("git", &args) .env("GIT_TERMINAL_PROMPT", "0") .stdin_null() .stdout_capture() .stderr_capture() .unchecked() .run() .context("failed to spawn git (is it installed and on PATH?)")?; if !output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); bail!("git failed: {}", format!("{stdout} {stderr}").trim()); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } #[derive(Debug, Default)] struct RemoteLayout { agents: Option, roles: Option, skills: Option, macros: Option, functions_tools: Option, mcp_json: Option, manifest: Option, head_sha: Option, } impl RemoteLayout { fn is_empty(&self) -> bool { self.agents.is_none() && self.roles.is_none() && self.skills.is_none() && self.macros.is_none() && self.functions_tools.is_none() && self.mcp_json.is_none() } } fn scan_remote_layout(root: &Path) -> Result { let mut layout = RemoteLayout { manifest: parse_bundle_manifest(root)?, ..RemoteLayout::default() }; let agents = root.join("agents"); if agents.is_dir() { layout.agents = Some(agents); } let roles = root.join("roles"); if roles.is_dir() { layout.roles = Some(roles); } let skills = root.join("skills"); if skills.is_dir() { layout.skills = Some(skills); } let macros = root.join("macros"); if macros.is_dir() { layout.macros = Some(macros); } let functions = root.join("functions"); if functions.is_dir() { let tools = functions.join("tools"); if tools.is_dir() { layout.functions_tools = Some(tools); } let mcp = functions.join("mcp.json"); if mcp.is_file() { layout.mcp_json = Some(mcp); } } Ok(layout) } #[derive(Debug, Clone, PartialEq, Deserialize)] pub(crate) struct BundleManifest { pub(crate) name: String, pub(crate) version: Option, pub(crate) description: Option, pub(crate) homepage: Option, } /// Returns the record key, possibly migrated or owner-qualified, that all /// subsequent recording must use in place of the requested name. /// An update passes `preserve_versions` so the record keeps claiming the old /// commit and version until the new content actually lands on disk. fn register_bundle( store: &mut BundleStore, url: &str, git_ref: Option<&str>, manifest: Option<&BundleManifest>, commit: &str, preserve_versions: bool, ) -> Result { let resolved = store.resolve_bundle_name(url, manifest.map(|m| m.name.as_str()))?; let version = manifest .and_then(|m| m.version.clone()) .unwrap_or_else(|| commit.chars().take(7).collect()); let (commit, version) = match (preserve_versions, store.get(&resolved.name)) { (true, Some(existing)) => (existing.commit.clone(), existing.version.clone()), _ => (commit.to_string(), Some(version)), }; store.upsert_bundle( &resolved.name, InstallMetadata { source: url.to_string(), git_ref: git_ref.map(str::to_string), commit, version, description: manifest.and_then(|m| m.description.clone()), homepage: manifest.and_then(|m| m.homepage.clone()), }, )?; Ok(resolved.name) } fn parse_bundle_manifest(root: &Path) -> Result> { let path = root.join(BUNDLE_MANIFEST_FILE); if !path.is_file() { return Ok(None); } let content = fs::read_to_string(&path) .with_context(|| format!("failed to read bundle manifest at {}", path.display()))?; let manifest: BundleManifest = serde_yaml::from_str(&content) .with_context(|| format!("invalid bundle manifest at {}", path.display()))?; validate_bundle_name(&manifest.name) .with_context(|| format!("invalid bundle name in manifest at {}", path.display()))?; Ok(Some(manifest)) } pub(crate) fn validate_bundle_name(name: &str) -> Result<()> { let (owner, base) = match name.split_once('/') { Some((owner, base)) => (Some(owner), base), None => (None, name), }; if base.contains('/') { bail!( "Invalid bundle name '{name}': at most one '/' is allowed \ (as the owner qualifier separator)" ); } for part in owner.into_iter().chain(iter::once(base)) { if part.is_empty() { bail!("Invalid bundle name '{name}': name segments cannot be empty"); } if !part .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { bail!( "Invalid bundle name '{name}': only letters, digits, '-', and '_' are allowed \ (plus a single '/' separating an owner qualifier)" ); } } Ok(()) } fn strip_ref_suffix(url: &str) -> &str { match url.rsplit_once('#') { Some((base, _)) if !base.is_empty() => base, _ => url, } } fn split_host_and_path(url: &str) -> (String, String) { let url = strip_ref_suffix(url); let url = &url.replace('\\', "/"); if let Some((_, rest)) = url.split_once("://") { let (authority, path) = rest.split_once('/').unwrap_or((rest, "")); let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h); (host.to_string(), path.trim_matches('/').to_string()) } else if let Some((prefix, path)) = url.split_once(':') && !prefix.contains('/') { let host = prefix.rsplit_once('@').map_or(prefix, |(_, h)| h); (host.to_string(), path.trim_matches('/').to_string()) } else { (String::new(), url.trim_end_matches('/').to_string()) } } fn strip_git_suffix(segment: &str) -> &str { if segment.len() > 4 && segment.to_ascii_lowercase().ends_with(".git") { &segment[..segment.len() - 4] } else { segment } } pub(crate) fn repo_name_slug(url: &str) -> String { let (_, path) = split_host_and_path(url); let last = path.rsplit('/').next().unwrap_or(""); strip_git_suffix(last).to_string() } pub(crate) fn owner_qualifier(url: &str) -> Option { let (host, path) = split_host_and_path(url); let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); if segments.len() >= 2 { return Some(segments[segments.len() - 2].to_string()); } let sanitized = sanitize_host(&host); (!sanitized.is_empty()).then_some(sanitized) } fn sanitize_host(host: &str) -> String { host.to_ascii_lowercase() .chars() .map(|c| { if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' } }) .collect::() .trim_matches('-') .to_string() } /// The host compares case-insensitively but the path keeps its case: many /// self-hosted forges treat repository paths as case-sensitive, and collapsing /// distinct repos into one record misdirects updates and uninstalls. pub(crate) fn canonical_source_url(url: &str) -> String { let (host, path) = split_host_and_path(url); let mut path = path; if path.to_ascii_lowercase().ends_with(".git") { let stripped = &path[..path.len() - 4]; if !stripped.is_empty() && !stripped.ends_with('/') { path.truncate(path.len() - 4); } } let host = host.to_ascii_lowercase(); if host.is_empty() { path } else if path.is_empty() { host } else { format!("{host}/{path}") } } fn apply_filter(mut layout: RemoteLayout, filter: Option) -> RemoteLayout { let Some(filter) = filter else { return layout; }; let base = RemoteLayout { manifest: layout.manifest.take(), head_sha: layout.head_sha.take(), ..RemoteLayout::default() }; match filter { InstallFilter::Agents => RemoteLayout { agents: layout.agents.take(), ..base }, InstallFilter::Roles => RemoteLayout { roles: layout.roles.take(), ..base }, InstallFilter::Skills => RemoteLayout { skills: layout.skills.take(), ..base }, InstallFilter::Macros => RemoteLayout { macros: layout.macros.take(), ..base }, InstallFilter::Functions => RemoteLayout { functions_tools: layout.functions_tools.take(), ..base }, InstallFilter::McpConfig => RemoteLayout { mcp_json: layout.mcp_json.take(), ..base }, } } fn walk_files(root: &Path) -> Result> { let mut out = Vec::new(); walk_files_inner(root, &mut out)?; Ok(out) } fn walk_files_inner(dir: &Path, out: &mut Vec) -> Result<()> { for entry in fs::read_dir(dir).with_context(|| format!("failed to read {}", dir.display()))? { let entry = entry?; let file_type = entry.file_type()?; let name = entry.file_name(); if file_type.is_symlink() { bail!( "Symlink not allowed in remote install source: {}", entry.path().display() ); } if name == OsStr::new(".git") { continue; } if name == OsStr::new("..") { bail!( "Path traversal '..' not allowed: {}", entry.path().display() ); } let path = entry.path(); if file_type.is_dir() { walk_files_inner(&path, out)?; } else if file_type.is_file() { out.push(path); } } Ok(()) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum TopCategory { Agents, Roles, Skills, Macros, FunctionsTools, } impl TopCategory { fn label(&self) -> &'static str { match self { TopCategory::Agents => "agents", TopCategory::Roles => "roles", TopCategory::Skills => "skills", TopCategory::Macros => "macros", TopCategory::FunctionsTools => "functions/tools", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PlannedKind { New, Identical, Conflict, /// A conflict downgraded because this bundle owns the file and the local /// content still matches the recorded hash; applied without prompting. Refresh, } struct PlannedFile { src: PathBuf, dst: PathBuf, kind: PlannedKind, top_category: TopCategory, } struct InstallPlan { files: Vec, mcp_json: Option<(PathBuf, PathBuf)>, } fn plan_changes(layout: &RemoteLayout) -> Result { let mut files = Vec::new(); if let Some(src_dir) = &layout.agents { plan_dir_into( src_dir, &paths::agents_data_dir(), TopCategory::Agents, &mut files, )?; } if let Some(src_dir) = &layout.roles { plan_dir_into(src_dir, &paths::roles_dir(), TopCategory::Roles, &mut files)?; } if let Some(src_dir) = &layout.skills { plan_dir_into( src_dir, &paths::skills_dir(), TopCategory::Skills, &mut files, )?; } if let Some(src_dir) = &layout.macros { plan_dir_into( src_dir, &paths::macros_dir(), TopCategory::Macros, &mut files, )?; } if let Some(src_dir) = &layout.functions_tools { plan_dir_into( src_dir, &paths::functions_dir().join("tools"), TopCategory::FunctionsTools, &mut files, )?; } let mcp_json = layout .mcp_json .as_ref() .map(|src| (src.clone(), paths::mcp_config_file())); Ok(InstallPlan { files, mcp_json }) } fn plan_dir_into( src_dir: &Path, dst_dir: &Path, category: TopCategory, out: &mut Vec, ) -> Result<()> { for src in walk_files(src_dir)? { let rel = src .strip_prefix(src_dir) .expect("walk_files only returns paths under src_dir"); if category == TopCategory::Skills { let skill_name = rel .components() .next() .and_then(|c| c.as_os_str().to_str()) .ok_or_else(|| { anyhow!( "remote skill bundle has unparseable path component: {}", rel.display() ) })?; paths::validate_skill_name(skill_name).with_context(|| { format!( "remote skill '{skill_name}' has an invalid name \ (skill names must contain only ASCII alphanumerics, '-', or '_')" ) })?; } let dst = dst_dir.join(rel); let kind = classify_file(&src, &dst)?; out.push(PlannedFile { src, dst, kind, top_category: category, }); } Ok(()) } fn classify_file(src: &Path, dst: &Path) -> Result { if !dst.exists() { return Ok(PlannedKind::New); } if files_equal(src, dst)? { Ok(PlannedKind::Identical) } else { Ok(PlannedKind::Conflict) } } const LARGE_FILE_THRESHOLD: u64 = 8 * 1024 * 1024; fn files_equal(a: &Path, b: &Path) -> Result { let a_meta = fs::metadata(a).with_context(|| format!("stat {}", a.display()))?; let b_meta = fs::metadata(b).with_context(|| format!("stat {}", b.display()))?; if a_meta.len() != b_meta.len() { return Ok(false); } if a_meta.len() > LARGE_FILE_THRESHOLD { files_equal_streaming(a, b) } else { let a_bytes = fs::read(a).with_context(|| format!("read {}", a.display()))?; let b_bytes = fs::read(b).with_context(|| format!("read {}", b.display()))?; Ok(a_bytes == b_bytes) } } fn files_equal_streaming(a: &Path, b: &Path) -> Result { let mut fa = fs::File::open(a).with_context(|| format!("open {}", a.display()))?; let mut fb = fs::File::open(b).with_context(|| format!("open {}", b.display()))?; let mut buf_a = [0u8; 8192]; let mut buf_b = [0u8; 8192]; loop { let na = read_full(&mut fa, &mut buf_a)?; let nb = read_full(&mut fb, &mut buf_b)?; if na != nb { return Ok(false); } if na == 0 { return Ok(true); } if buf_a[..na] != buf_b[..nb] { return Ok(false); } } } /// `read` may return short counts without EOF; comparing partially filled /// buffers positionally would misreport identical files as different. fn read_full(file: &mut fs::File, buf: &mut [u8]) -> std::io::Result { use std::io::Read; let mut filled = 0; while filled < buf.len() { let n = file.read(&mut buf[filled..])?; if n == 0 { break; } filled += n; } Ok(filled) } fn print_plan_summary(plan: &InstallPlan) { println!("Plan:"); for cat in [ TopCategory::Agents, TopCategory::Roles, TopCategory::Skills, TopCategory::Macros, TopCategory::FunctionsTools, ] { let new_ = count_kind(plan, cat, PlannedKind::New); let identical = count_kind(plan, cat, PlannedKind::Identical); let conflict = count_kind(plan, cat, PlannedKind::Conflict); let refresh = count_kind(plan, cat, PlannedKind::Refresh); if new_ + identical + conflict + refresh > 0 { let mut line = format!( " {:<16} new={new_} identical={identical} conflict={conflict}", cat.label() ); if refresh > 0 { line.push_str(&format!(" refresh={refresh}")); } println!("{line}"); } } } fn count_kind(plan: &InstallPlan, cat: TopCategory, kind: PlannedKind) -> usize { plan.files .iter() .filter(|p| p.top_category == cat && p.kind == kind) .count() } #[derive(Debug, Clone, Copy, PartialEq)] enum StickyMode { None, KeepAll, ReplaceAll, } enum ConflictAction { Keep, Replace, } #[derive(Debug)] struct ApplyReport { new_count: usize, identical_count: usize, replaced_count: usize, refreshed_count: usize, kept_count: usize, } fn apply_plan( plan: &InstallPlan, initial_mode: StickyMode, store: &mut BundleStore, bundle: &str, ) -> Result { let mut report = ApplyReport { new_count: 0, identical_count: 0, replaced_count: 0, refreshed_count: 0, kept_count: 0, }; let mut sticky = initial_mode; for planned in &plan.files { match planned.kind { PlannedKind::New => { write_file(&planned.src, &planned.dst)?; record_written_file(store, bundle, planned, FileAction::New)?; report.new_count += 1; } PlannedKind::Identical => { report.identical_count += 1; } PlannedKind::Refresh => { write_file(&planned.src, &planned.dst)?; record_written_file(store, bundle, planned, FileAction::Replaced)?; report.refreshed_count += 1; } PlannedKind::Conflict => match resolve_conflict(planned, &mut sticky)? { ConflictAction::Keep => report.kept_count += 1, ConflictAction::Replace => { write_file(&planned.src, &planned.dst)?; record_written_file(store, bundle, planned, FileAction::Replaced)?; report.replaced_count += 1; } }, } } if report.refreshed_count > 0 { println!( "\nInstalled: {} new, {} refreshed, {} replaced, {} kept, {} identical.", report.new_count, report.refreshed_count, report.replaced_count, report.kept_count, report.identical_count ); } else { println!( "\nInstalled: {} new, {} replaced, {} kept, {} identical.", report.new_count, report.replaced_count, report.kept_count, report.identical_count ); } Ok(report) } /// Kept and identical files are never recorded, because ownership means /// "this content exists because of this bundle": a file another bundle's /// record already owns stays with that owner. fn record_written_file( store: &mut BundleStore, bundle: &str, planned: &PlannedFile, action: FileAction, ) -> Result<()> { store.record_file( bundle, FileRecord { path: provenance_path(&planned.dst), category: planned.top_category.label().to_string(), sha256: hash_file(&planned.dst)?, action, }, ) } fn provenance_path(dst: &Path) -> String { let rel = dst.strip_prefix(paths::config_dir()).unwrap_or(dst); rel.to_string_lossy().replace('\\', "/") } /// Entries the merge kept local are deliberately absent: an entry another /// bundle already owns stays with that owner. fn record_mcp_merge(store: &mut BundleStore, bundle: &str, report: &McpMergeReport) -> Result<()> { let mut entries: Vec = Vec::new(); entries.extend(report.added.iter().map(|name| McpServerRecord { name: name.clone(), action: McpAction::Added, renamed_to: None, sha256: report.entry_hashes.get(name).cloned(), })); entries.extend(report.replaced.iter().map(|name| McpServerRecord { name: name.clone(), action: McpAction::Replaced, renamed_to: None, sha256: report.entry_hashes.get(name).cloned(), })); entries.extend( report .renamed .iter() .map(|(name, renamed_to)| McpServerRecord { name: name.clone(), action: McpAction::Renamed, renamed_to: Some(renamed_to.clone()), sha256: report.entry_hashes.get(renamed_to).cloned(), }), ); if entries.is_empty() { return Ok(()); } store.record_mcp_servers(bundle, entries) } fn resolve_conflict(planned: &PlannedFile, sticky: &mut StickyMode) -> Result { match *sticky { StickyMode::KeepAll => return Ok(ConflictAction::Keep), StickyMode::ReplaceAll => return Ok(ConflictAction::Replace), StickyMode::None => {} } if !*IS_STDOUT_TERMINAL { bail!( "Refusing to overwrite local file {} non-interactively. \ Re-run in a terminal, with --install-force (installs), \ or with --yes (updates).", planned.dst.display() ); } let prompt = format!( "Conflict at {} (category: {})", planned.dst.display(), planned.top_category.label() ); let choice = Select::new( &prompt, vec!["keep", "replace", "keep-all", "replace-all", "abort"], ) .prompt() .with_context(|| "failed to read conflict choice")?; match choice { "keep" => Ok(ConflictAction::Keep), "replace" => Ok(ConflictAction::Replace), "keep-all" => { *sticky = StickyMode::KeepAll; Ok(ConflictAction::Keep) } "replace-all" => { *sticky = StickyMode::ReplaceAll; Ok(ConflictAction::Replace) } "abort" => bail!("Install aborted by user at conflict resolution."), _ => unreachable!("inquire::Select returned an unexpected option"), } } fn write_file(src: &Path, dst: &Path) -> Result<()> { if let Some(parent) = dst.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create directory {}", parent.display()))?; } fs::copy(src, dst) .with_context(|| format!("failed to copy {} to {}", src.display(), dst.display()))?; set_executable_bit_if_script(dst)?; Ok(()) } #[cfg(unix)] fn set_executable_bit_if_script(path: &Path) -> Result<()> { use std::os::unix::fs::PermissionsExt; let Some(ext) = path.extension().and_then(OsStr::to_str) else { return Ok(()); }; if Language::from_extension(ext) == Language::Unsupported { return Ok(()); } fs::set_permissions(path, fs::Permissions::from_mode(0o755)) .with_context(|| format!("chmod {}", path.display()))?; Ok(()) } #[cfg(not(unix))] fn set_executable_bit_if_script(_path: &Path) -> Result<()> { Ok(()) } #[derive(Debug)] struct McpMergeReport { added: Vec, kept_local: Vec, replaced: Vec, renamed: Vec<(String, String)>, /// Hash of each entry this merge wrote, by its final key in mcp.json, so /// uninstall can tell the entry apart from a later local edit. entry_hashes: HashMap, final_path: PathBuf, missing_secrets: Vec, } enum McpConflictAction { KeepLocal, TakeRemote, RenameRemote, } fn merge_mcp_json( local: Option<&Path>, remote: &Path, target: &Path, force: bool, auto_take: &HashSet, assume_yes: bool, ) -> Result { let remote_content = fs::read_to_string(remote) .with_context(|| format!("failed to read remote mcp.json at {}", remote.display()))?; let remote_config: McpServersConfig = serde_json::from_str(&remote_content) .with_context(|| format!("failed to parse remote mcp.json at {}", remote.display()))?; let mut merged = if let Some(local_path) = local { let content = fs::read_to_string(local_path).with_context(|| { format!("failed to read local mcp.json at {}", local_path.display()) })?; serde_json::from_str::(&content).with_context(|| { format!("failed to parse local mcp.json at {}", local_path.display()) })? } else { McpServersConfig { mcp_servers: IndexMap::new(), } }; let final_path = target.to_path_buf(); let mut report = McpMergeReport { added: Vec::new(), kept_local: Vec::new(), replaced: Vec::new(), renamed: Vec::new(), entry_hashes: HashMap::new(), final_path: final_path.clone(), missing_secrets: Vec::new(), }; let mut to_validate: Vec = Vec::new(); for (name, remote_server) in remote_config.mcp_servers { if let Some(local_server) = merged.mcp_servers.get(&name) { if local_server == &remote_server { continue; } let action = if auto_take.contains(&name) { McpConflictAction::TakeRemote } else { resolve_mcp_conflict(&name, force, assume_yes)? }; match action { McpConflictAction::KeepLocal => report.kept_local.push(name), McpConflictAction::TakeRemote => { merged.mcp_servers.insert(name.clone(), remote_server); report.replaced.push(name.clone()); to_validate.push(name); } McpConflictAction::RenameRemote => { let new_name = unique_renamed_key(&name, &merged.mcp_servers); merged.mcp_servers.insert(new_name.clone(), remote_server); report.renamed.push((name, new_name.clone())); to_validate.push(new_name); } } } else { merged.mcp_servers.insert(name.clone(), remote_server); report.added.push(name.clone()); to_validate.push(name); } } for key in &to_validate { let spec = merged .mcp_servers .get(key) .expect("entry was just inserted"); spec.validate(key).with_context(|| { format!("MCP server '{key}' failed validation; refusing to write merged mcp.json") })?; let serialized = serde_json::to_string(spec) .with_context(|| format!("failed to serialize MCP server '{key}'"))?; report .entry_hashes .insert(key.clone(), hash_bytes(serialized.as_bytes())); } let serialized = serde_json::to_string_pretty(&merged).context("failed to serialize merged mcp.json")?; write_atomically(&final_path, &serialized)?; let vault = Vault::init_bare()?; let missing = match interpolate_secrets(&serialized, &vault) { Ok((_, missing)) => missing, Err(e) => { eprintln!( "{}", formatdoc! {" Skipping secret resolution for merged mcp.json: {e:#} Continuing without resolving missing secrets You may need to add any additional missing secrets to the vault manually. "} ); Vec::new() } }; let mut deduped: Vec = Vec::new(); for s in missing { if !deduped.contains(&s) { deduped.push(s); } } report.missing_secrets = deduped; Ok(report) } fn resolve_mcp_conflict(name: &str, force: bool, assume_yes: bool) -> Result { if force { return Ok(McpConflictAction::TakeRemote); } if assume_yes { return Ok(McpConflictAction::KeepLocal); } if !*IS_STDOUT_TERMINAL { bail!( "MCP server '{name}' already exists locally. Refusing to merge non-interactively. \ Re-run in a terminal, with --install-force (installs), or with --yes (updates)." ); } let rename_label = format!("rename remote as \"{name}-remote\""); let prompt = format!("Conflict on MCP server '{name}'"); let choice = Select::new( &prompt, vec![ "keep local".to_string(), "take remote".to_string(), rename_label.clone(), "abort merge".to_string(), ], ) .prompt() .with_context(|| "failed to read MCP conflict choice")?; if choice == "keep local" { Ok(McpConflictAction::KeepLocal) } else if choice == "take remote" { Ok(McpConflictAction::TakeRemote) } else if choice == rename_label { Ok(McpConflictAction::RenameRemote) } else if choice == "abort merge" { bail!("Aborted MCP merge by user.") } else { unreachable!("inquire::Select returned an unexpected option") } } fn unique_renamed_key(name: &str, existing: &IndexMap) -> String { let base = format!("{name}-remote"); if !existing.contains_key(&base) { return base; } for i in 2..=u32::MAX { let candidate = format!("{name}-remote-{i}"); if !existing.contains_key(&candidate) { return candidate; } } unreachable!("ran out of suffix variants") } fn write_atomically(path: &Path, content: &str) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create directory {}", parent.display()))?; } let tmp_path = path.with_extension("json.tmp"); fs::write(&tmp_path, content) .with_context(|| format!("failed to write {}", tmp_path.display()))?; fs::rename(&tmp_path, path).with_context(|| { format!( "failed to rename {} to {}", tmp_path.display(), path.display() ) })?; Ok(()) } fn print_mcp_merge_report(report: &McpMergeReport) { println!("\nMCP merge ({}):", report.final_path.display()); println!( " added: {}, replaced: {}, kept local: {}, renamed: {}", report.added.len(), report.replaced.len(), report.kept_local.len(), report.renamed.len() ); if !report.added.is_empty() { println!(" + new servers: {}", report.added.join(", ")); } if !report.replaced.is_empty() { println!(" ~ replaced: {}", report.replaced.join(", ")); } if !report.kept_local.is_empty() { println!(" = kept local: {}", report.kept_local.join(", ")); } if !report.renamed.is_empty() { let pairs: Vec = report .renamed .iter() .map(|(orig, new_)| format!("{orig} -> {new_}")) .collect(); println!(" > renamed: {}", pairs.join(", ")); } } fn handle_missing_secrets(missing: &[String]) -> Result<()> { if missing.is_empty() { return Ok(()); } let (added, deferred) = if *IS_STDOUT_TERMINAL { println!( "\nThe merged mcp.json references {} secret(s) not yet in the vault.", missing.len() ); prompt_for_each_secret(missing)? } else { (Vec::new(), missing.to_vec()) }; print_secret_summary(&added, &deferred); Ok(()) } fn prompt_for_each_secret(missing: &[String]) -> Result<(Vec, Vec)> { let mut vault = Vault::init_bare()?; let mut password_file_ensured = false; let mut added = Vec::new(); let mut deferred = Vec::new(); for name in missing { let proceed = Confirm::new(&format!("Add {{{{ {name} }}}} to vault now?")) .with_default(false) .prompt() .with_context(|| format!("failed to read confirmation for secret '{name}'"))?; if !proceed { deferred.push(name.clone()); continue; } if !password_file_ensured { create_vault_password_file(&mut vault) .context("Failed to initialize the vault password file")?; password_file_ensured = true; } match vault.add_secret(name) { Ok(()) => added.push(name.clone()), Err(e) => { eprintln!("Failed to add '{name}' to vault: {e:#}"); deferred.push(name.clone()); } } } Ok((added, deferred)) } fn print_secret_summary(added: &[String], deferred: &[String]) { if !added.is_empty() { println!( "\nAdded {} secret(s) to the vault: {}", added.len(), added.join(", ") ); } if !deferred.is_empty() { println!( "\nThe following secrets are still required by your MCP servers. \ Add them with `coyote --add-secret ` or `.vault add ` in the REPL:" ); for name in deferred { println!(" {{{{ {name} }}}}"); } } } #[cfg(test)] mod tests { use super::*; use crate::sandbox::SANDBOX_ENV_FLAG; use crate::utils::get_env_name; use serial_test::serial; use std::env; use std::time::{SystemTime, UNIX_EPOCH}; struct TestVaultConfigGuard { dir_key: String, file_key: String, previous_dir: Option, previous_file: Option, path: PathBuf, } impl TestVaultConfigGuard { fn new(label: &str) -> Self { let dir_key = get_env_name("config_dir"); let file_key = get_env_name("config_file"); let previous_dir = env::var_os(&dir_key); let previous_file = env::var_os(&file_key); let unique = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); let path = env::temp_dir().join(format!("coyote-vault-test-{label}-{unique}")); fs::create_dir_all(&path).unwrap(); let config_path = path.join("config.yaml"); fs::write(&config_path, "{}").unwrap(); unsafe { env::set_var(&dir_key, &path); env::set_var(&file_key, &config_path); } Self { dir_key, file_key, previous_dir, previous_file, path, } } } impl Drop for TestVaultConfigGuard { fn drop(&mut self) { unsafe { match &self.previous_dir { Some(p) => env::set_var(&self.dir_key, p), None => env::remove_var(&self.dir_key), } match &self.previous_file { Some(p) => env::set_var(&self.file_key, p), None => env::remove_var(&self.file_key), } } let _ = fs::remove_dir_all(&self.path); } } #[test] fn parse_url_no_ref() { let (url, r) = parse_url_with_ref("https://github.com/foo/bar.git").unwrap(); assert_eq!(url, "https://github.com/foo/bar.git"); assert_eq!(r, None); } #[test] fn parse_url_with_branch_ref() { let (url, r) = parse_url_with_ref("https://github.com/foo/bar.git#main").unwrap(); assert_eq!(url, "https://github.com/foo/bar.git"); assert_eq!(r.as_deref(), Some("main")); } #[test] fn parse_url_with_tag_ref() { let (url, r) = parse_url_with_ref("https://github.com/foo/bar.git#v1.2.3").unwrap(); assert_eq!(url, "https://github.com/foo/bar.git"); assert_eq!(r.as_deref(), Some("v1.2.3")); } #[test] fn parse_url_with_sha_ref() { let (url, r) = parse_url_with_ref("https://github.com/foo/bar.git#abc1234").unwrap(); assert_eq!(url, "https://github.com/foo/bar.git"); assert_eq!(r.as_deref(), Some("abc1234")); } #[test] fn parse_url_with_slash_in_ref() { let (url, r) = parse_url_with_ref("git@github.com:foo/bar.git#release/v2").unwrap(); assert_eq!(url, "git@github.com:foo/bar.git"); assert_eq!(r.as_deref(), Some("release/v2")); } #[test] fn parse_url_rejects_empty_ref() { assert!(parse_url_with_ref("https://github.com/foo/bar.git#").is_err()); } #[test] fn parse_url_rejects_dotdot() { assert!(parse_url_with_ref("https://github.com/foo/bar.git#foo..bar").is_err()); } #[test] fn parse_url_rejects_leading_dash_argument_injection() { assert!(parse_url_with_ref("https://github.com/foo/bar.git#-evil").is_err()); } #[test] fn parse_url_rejects_shell_metachars() { assert!(parse_url_with_ref("https://github.com/foo/bar.git#foo bar").is_err()); assert!(parse_url_with_ref("https://github.com/foo/bar.git#$inject").is_err()); assert!(parse_url_with_ref("https://github.com/foo/bar.git#;rm -rf /").is_err()); } fn touch(p: &Path) { if let Some(parent) = p.parent() { fs::create_dir_all(parent).unwrap(); } fs::write(p, b"").unwrap(); } fn fresh_temp_dir(name: &str) -> PathBuf { let dir = utils::temp_file(name, ""); fs::create_dir_all(&dir).unwrap(); dir } #[test] fn remote_layout_is_empty_when_default() { assert!(RemoteLayout::default().is_empty()); } #[test] fn remote_layout_is_not_empty_when_any_field_set() { let l = RemoteLayout { agents: Some(PathBuf::from("/x")), ..RemoteLayout::default() }; assert!(!l.is_empty()); } #[test] fn apply_filter_none_passes_through() { let l = RemoteLayout { agents: Some(PathBuf::from("a")), roles: Some(PathBuf::from("r")), skills: Some(PathBuf::from("s")), macros: Some(PathBuf::from("m")), functions_tools: Some(PathBuf::from("f")), mcp_json: Some(PathBuf::from("j")), ..RemoteLayout::default() }; let out = apply_filter(l, None); assert!(out.agents.is_some() && out.roles.is_some() && out.skills.is_some()); assert!(out.macros.is_some() && out.functions_tools.is_some() && out.mcp_json.is_some()); } #[test] fn apply_filter_functions_keeps_only_tools_not_mcp() { let l = RemoteLayout { agents: Some(PathBuf::from("a")), roles: None, skills: Some(PathBuf::from("s")), macros: None, functions_tools: Some(PathBuf::from("f")), mcp_json: Some(PathBuf::from("j")), ..RemoteLayout::default() }; let out = apply_filter(l, Some(InstallFilter::Functions)); assert!(out.agents.is_none()); assert!(out.skills.is_none()); assert_eq!(out.functions_tools, Some(PathBuf::from("f"))); assert!(out.mcp_json.is_none()); } #[test] fn apply_filter_mcp_config_keeps_only_mcp_json() { let l = RemoteLayout { agents: Some(PathBuf::from("a")), roles: None, skills: Some(PathBuf::from("s")), macros: None, functions_tools: Some(PathBuf::from("f")), mcp_json: Some(PathBuf::from("j")), ..RemoteLayout::default() }; let out = apply_filter(l, Some(InstallFilter::McpConfig)); assert!(out.agents.is_none() && out.skills.is_none() && out.functions_tools.is_none()); assert_eq!(out.mcp_json, Some(PathBuf::from("j"))); } #[test] fn apply_filter_roles_keeps_only_roles() { let l = RemoteLayout { agents: Some(PathBuf::from("a")), roles: Some(PathBuf::from("r")), skills: Some(PathBuf::from("s")), macros: Some(PathBuf::from("m")), functions_tools: Some(PathBuf::from("f")), mcp_json: Some(PathBuf::from("j")), ..RemoteLayout::default() }; let out = apply_filter(l, Some(InstallFilter::Roles)); assert_eq!(out.roles, Some(PathBuf::from("r"))); assert!(out.agents.is_none() && out.skills.is_none() && out.macros.is_none()); assert!(out.functions_tools.is_none() && out.mcp_json.is_none()); } #[test] fn apply_filter_skills_keeps_only_skills() { let l = RemoteLayout { agents: Some(PathBuf::from("a")), roles: Some(PathBuf::from("r")), skills: Some(PathBuf::from("s")), macros: Some(PathBuf::from("m")), functions_tools: Some(PathBuf::from("f")), mcp_json: Some(PathBuf::from("j")), ..RemoteLayout::default() }; let out = apply_filter(l, Some(InstallFilter::Skills)); assert_eq!(out.skills, Some(PathBuf::from("s"))); assert!(out.agents.is_none() && out.roles.is_none() && out.macros.is_none()); assert!(out.functions_tools.is_none() && out.mcp_json.is_none()); } #[test] fn walk_files_skips_dot_git_and_collects_regular_files() { let root = fresh_temp_dir("walk-test-"); touch(&root.join("a.txt")); touch(&root.join("sub/b.txt")); touch(&root.join(".git/HEAD")); touch(&root.join(".git/objects/pack/foo")); let mut files = walk_files(&root).unwrap(); files.sort(); let rels: Vec<_> = files .iter() .map(|p| p.strip_prefix(&root).unwrap().to_owned()) .collect(); assert_eq!( rels, vec![PathBuf::from("a.txt"), PathBuf::from("sub/b.txt")] ); let _ = fs::remove_dir_all(&root); } #[cfg(unix)] #[test] fn walk_files_rejects_symlink() { let root = fresh_temp_dir("walk-symlink-test-"); touch(&root.join("real.txt")); std::os::unix::fs::symlink(root.join("real.txt"), root.join("link.txt")).unwrap(); let err = walk_files(&root).unwrap_err(); assert!( err.to_string().contains("Symlink not allowed"), "got error: {err}" ); let _ = fs::remove_dir_all(&root); } #[test] fn scan_remote_layout_finds_known_subdirs() { let root = fresh_temp_dir("scan-test-"); fs::create_dir_all(root.join("agents/sample")).unwrap(); fs::create_dir_all(root.join("roles")).unwrap(); fs::create_dir_all(root.join("skills")).unwrap(); fs::create_dir_all(root.join("macros")).unwrap(); fs::create_dir_all(root.join("functions/tools")).unwrap(); touch(&root.join("functions/mcp.json")); touch(&root.join("README.md")); let layout = scan_remote_layout(&root).unwrap(); assert!(layout.agents.is_some()); assert!(layout.roles.is_some()); assert!(layout.skills.is_some()); assert!(layout.macros.is_some()); assert!(layout.functions_tools.is_some()); assert!(layout.mcp_json.is_some()); let _ = fs::remove_dir_all(&root); } #[test] fn scan_remote_layout_finds_skills_only() { let root = fresh_temp_dir("scan-skills-only-"); fs::create_dir_all(root.join("skills/git-master")).unwrap(); touch(&root.join("skills/git-master/SKILL.md")); let layout = scan_remote_layout(&root).unwrap(); assert!(layout.skills.is_some()); assert!(layout.agents.is_none()); assert!(layout.roles.is_none()); assert!(layout.macros.is_none()); assert!(layout.functions_tools.is_none()); assert!(layout.mcp_json.is_none()); let _ = fs::remove_dir_all(&root); } #[test] fn scan_remote_layout_ignores_unrelated_files() { let root = fresh_temp_dir("scan-unrelated-"); fs::create_dir_all(root.join("docs")).unwrap(); touch(&root.join("docs/intro.md")); touch(&root.join("README.md")); let layout = scan_remote_layout(&root).unwrap(); assert!(layout.is_empty()); let _ = fs::remove_dir_all(&root); } #[test] fn classify_file_new_when_dst_missing() { let dir = fresh_temp_dir("classify-new-"); let src = dir.join("src"); fs::write(&src, b"hello").unwrap(); let dst = dir.join("dst"); assert_eq!(classify_file(&src, &dst).unwrap(), PlannedKind::New); let _ = fs::remove_dir_all(&dir); } #[test] fn classify_file_identical_when_bytes_match() { let dir = fresh_temp_dir("classify-identical-"); let src = dir.join("src"); let dst = dir.join("dst"); fs::write(&src, b"same bytes").unwrap(); fs::write(&dst, b"same bytes").unwrap(); assert_eq!(classify_file(&src, &dst).unwrap(), PlannedKind::Identical); let _ = fs::remove_dir_all(&dir); } #[test] fn classify_file_conflict_when_bytes_differ() { let dir = fresh_temp_dir("classify-conflict-"); let src = dir.join("src"); let dst = dir.join("dst"); fs::write(&src, b"version A").unwrap(); fs::write(&dst, b"version B").unwrap(); assert_eq!(classify_file(&src, &dst).unwrap(), PlannedKind::Conflict); let _ = fs::remove_dir_all(&dir); } fn write_mcp(path: &Path, json: &str) { if let Some(parent) = path.parent() { fs::create_dir_all(parent).unwrap(); } fs::write(path, json).unwrap(); } const FIXTURE_REMOTE: &str = r#"{ "mcpServers": { "alpha": {"type": "stdio", "command": "echo", "args": ["a"]}, "beta": {"type": "stdio", "command": "echo", "args": ["b"]} } }"#; #[test] fn unique_renamed_key_appends_remote_suffix() { let map: IndexMap = IndexMap::new(); assert_eq!(unique_renamed_key("foo", &map), "foo-remote"); } #[test] fn unique_renamed_key_appends_numeric_when_remote_taken() { let mut map: IndexMap = IndexMap::new(); map.insert( "foo-remote".to_string(), serde_json::from_str(r#"{"type":"stdio","command":"x"}"#).unwrap(), ); assert_eq!(unique_renamed_key("foo", &map), "foo-remote-2"); } #[test] #[serial] fn merge_into_empty_local_adds_all_remote_servers() { let _guard = TestVaultConfigGuard::new("merge-empty"); let dir = fresh_temp_dir("merge-empty-"); let remote = dir.join("remote.json"); let target = dir.join("target.json"); write_mcp(&remote, FIXTURE_REMOTE); let report = merge_mcp_json(None, &remote, &target, false, &HashSet::new(), false).unwrap(); assert_eq!(report.added, vec!["alpha", "beta"]); assert!(report.kept_local.is_empty()); assert!(report.replaced.is_empty()); assert!(report.renamed.is_empty()); assert!(target.exists()); let _ = fs::remove_dir_all(&dir); } #[test] #[serial] fn merge_force_replaces_local_on_conflict() { let _guard = TestVaultConfigGuard::new("merge-force"); let dir = fresh_temp_dir("merge-force-"); let remote = dir.join("remote.json"); let target = dir.join("target.json"); write_mcp( &target, r#"{"mcpServers": {"alpha": {"type": "stdio", "command": "OLD"}}}"#, ); write_mcp(&remote, FIXTURE_REMOTE); let report = merge_mcp_json( Some(&target), &remote, &target, true, &HashSet::new(), false, ) .unwrap(); assert_eq!(report.added, vec!["beta"]); assert_eq!(report.replaced, vec!["alpha"]); let written = fs::read_to_string(&target).unwrap(); assert!(written.contains("\"command\": \"echo\""), "got: {written}"); assert!(!written.contains("OLD")); let _ = fs::remove_dir_all(&dir); } #[test] fn merge_non_tty_conflict_aborts_without_force() { if *IS_STDOUT_TERMINAL { eprintln!( "Skipping merge_non_tty_conflict_aborts_without_force: requires non-TTY stdout" ); return; } let dir = fresh_temp_dir("merge-non-tty-"); let remote = dir.join("remote.json"); let target = dir.join("target.json"); write_mcp( &target, r#"{"mcpServers": {"alpha": {"type": "stdio", "command": "LOCAL"}}}"#, ); write_mcp(&remote, FIXTURE_REMOTE); let err = merge_mcp_json( Some(&target), &remote, &target, false, &HashSet::new(), false, ) .unwrap_err(); assert!( err.to_string() .contains("Refusing to merge non-interactively"), "got: {err}" ); let _ = fs::remove_dir_all(&dir); } #[test] fn merge_rejects_invalid_remote_server() { let dir = fresh_temp_dir("merge-invalid-"); let remote = dir.join("remote.json"); let target = dir.join("target.json"); write_mcp(&remote, r#"{"mcpServers": {"broken": {"type": "stdio"}}}"#); let err = merge_mcp_json(None, &remote, &target, false, &HashSet::new(), false).unwrap_err(); assert!( format!("{err:#}").contains("missing a \"command\" field"), "got: {err:#}" ); let _ = fs::remove_dir_all(&dir); } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] #[serial] async fn merge_detects_missing_secrets_in_output() { if env::var_os(SANDBOX_ENV_FLAG).is_some() { eprintln!( "Skipping merge_detects_missing_secrets_in_output: secret interpolation is disabled inside a sandbox" ); return; } let _guard = TestVaultConfigGuard::new("merge-secret"); let dir = fresh_temp_dir("merge-secret-"); let remote = dir.join("remote.json"); let target = dir.join("target.json"); write_mcp( &remote, r#"{"mcpServers": {"x": {"type":"stdio","command":"echo","env":{"K":"{{COYOTE_TEST_MERGE_SECRET}}"}}}}"#, ); let report = merge_mcp_json(None, &remote, &target, false, &HashSet::new(), false).unwrap(); assert_eq!(report.missing_secrets, vec!["COYOTE_TEST_MERGE_SECRET"]); let _ = fs::remove_dir_all(&dir); } #[test] #[serial] fn merge_is_idempotent_on_re_run() { let _guard = TestVaultConfigGuard::new("merge-idempotent"); let dir = fresh_temp_dir("merge-idempotent-"); let remote = dir.join("remote.json"); let target = dir.join("target.json"); write_mcp(&remote, FIXTURE_REMOTE); merge_mcp_json(None, &remote, &target, false, &HashSet::new(), false).unwrap(); let after_first = fs::read(&target).unwrap(); let report = merge_mcp_json( Some(&target), &remote, &target, false, &HashSet::new(), false, ) .unwrap(); assert!(report.added.is_empty(), "got: {:?}", report.added); let after_second = fs::read(&target).unwrap(); assert_eq!(after_first, after_second); let _ = fs::remove_dir_all(&dir); } #[test] fn handle_missing_secrets_noop_on_empty_input() { assert!(handle_missing_secrets(&[]).is_ok()); } #[test] fn handle_missing_secrets_defers_all_in_non_tty() { if *IS_STDOUT_TERMINAL { eprintln!( "Skipping handle_missing_secrets_defers_all_in_non_tty: requires non-TTY stdout" ); return; } let missing = vec![ "COYOTE_TEST_STEP4_A".to_string(), "COYOTE_TEST_STEP4_B".to_string(), ]; assert!(handle_missing_secrets(&missing).is_ok()); } fn write_manifest(root: &Path, yaml: &str) { fs::write(root.join(BUNDLE_MANIFEST_FILE), yaml).unwrap(); } #[test] fn scan_remote_layout_without_manifest_has_none() { let root = fresh_temp_dir("scan-no-manifest-"); fs::create_dir_all(root.join("macros")).unwrap(); let layout = scan_remote_layout(&root).unwrap(); assert_eq!(layout.manifest, None); let _ = fs::remove_dir_all(&root); } #[test] fn scan_remote_layout_parses_full_manifest_and_ignores_unknown_fields() { let root = fresh_temp_dir("scan-manifest-full-"); fs::create_dir_all(root.join("macros")).unwrap(); write_manifest( &root, "name: oh-my-coyote\n\ version: \"1.4.0\"\n\ description: Opinionated roles and macros\n\ homepage: https://github.com/x/oh-my-coyote\n\ future_field: ignored\n", ); let manifest = scan_remote_layout(&root).unwrap().manifest.unwrap(); assert_eq!(manifest.name, "oh-my-coyote"); assert_eq!(manifest.version.as_deref(), Some("1.4.0")); assert_eq!( manifest.description.as_deref(), Some("Opinionated roles and macros") ); assert_eq!( manifest.homepage.as_deref(), Some("https://github.com/x/oh-my-coyote") ); let _ = fs::remove_dir_all(&root); } #[test] fn scan_remote_layout_parses_name_only_manifest() { let root = fresh_temp_dir("scan-manifest-minimal-"); write_manifest(&root, "name: minimal\n"); let manifest = scan_remote_layout(&root).unwrap().manifest.unwrap(); assert_eq!(manifest.name, "minimal"); assert_eq!(manifest.version, None); assert_eq!(manifest.description, None); assert_eq!(manifest.homepage, None); let _ = fs::remove_dir_all(&root); } #[test] fn scan_remote_layout_fails_on_malformed_manifest() { let root = fresh_temp_dir("scan-manifest-malformed-"); write_manifest(&root, "name: [unclosed\n"); let err = scan_remote_layout(&root).unwrap_err(); assert!( format!("{err:#}").contains("invalid bundle manifest"), "got: {err:#}" ); let _ = fs::remove_dir_all(&root); } #[test] fn scan_remote_layout_fails_on_manifest_missing_name() { let root = fresh_temp_dir("scan-manifest-no-name-"); write_manifest(&root, "version: \"1.0\"\n"); let err = scan_remote_layout(&root).unwrap_err(); assert!( format!("{err:#}").contains("invalid bundle manifest"), "got: {err:#}" ); let _ = fs::remove_dir_all(&root); } #[test] fn scan_remote_layout_fails_on_invalid_manifest_name() { let root = fresh_temp_dir("scan-manifest-bad-name-"); write_manifest(&root, "name: not a valid name\n"); let err = scan_remote_layout(&root).unwrap_err(); assert!( format!("{err:#}").contains("Invalid bundle name"), "got: {err:#}" ); let _ = fs::remove_dir_all(&root); } #[test] fn apply_filter_carries_manifest_and_head_sha_through() { let l = RemoteLayout { macros: Some(PathBuf::from("m")), manifest: Some(BundleManifest { name: "bundle".to_string(), version: None, description: None, homepage: None, }), head_sha: Some("abc123".to_string()), ..RemoteLayout::default() }; let out = apply_filter(l, Some(InstallFilter::Macros)); assert_eq!(out.macros, Some(PathBuf::from("m"))); assert_eq!(out.manifest.unwrap().name, "bundle"); assert_eq!(out.head_sha.as_deref(), Some("abc123")); } #[test] fn validate_bundle_name_accepts_simple_names() { assert!(validate_bundle_name("oh-my-coyote").is_ok()); assert!(validate_bundle_name("under_score").is_ok()); assert!(validate_bundle_name("Abc123").is_ok()); } #[test] fn validate_bundle_name_accepts_owner_qualified_form() { assert!(validate_bundle_name("x/oh-my-coyote").is_ok()); } #[test] fn validate_bundle_name_rejects_empty() { assert!(validate_bundle_name("").is_err()); } #[test] fn validate_bundle_name_rejects_multiple_slashes() { assert!(validate_bundle_name("a/b/c").is_err()); } #[test] fn validate_bundle_name_rejects_empty_segments() { assert!(validate_bundle_name("/repo").is_err()); assert!(validate_bundle_name("owner/").is_err()); assert!(validate_bundle_name("/").is_err()); } #[test] fn validate_bundle_name_rejects_disallowed_characters() { assert!(validate_bundle_name("bad name").is_err()); assert!(validate_bundle_name("dot.name").is_err()); assert!(validate_bundle_name("owner/bad!base").is_err()); } #[test] fn repo_name_slug_from_https_url() { assert_eq!( repo_name_slug("https://github.com/x/oh-my-coyote"), "oh-my-coyote" ); } #[test] fn repo_name_slug_strips_git_suffix() { assert_eq!( repo_name_slug("https://github.com/x/oh-my-coyote.git"), "oh-my-coyote" ); } #[test] fn repo_name_slug_from_scp_style_url() { assert_eq!( repo_name_slug("git@github.com:x/oh-my-coyote.git"), "oh-my-coyote" ); } #[test] fn repo_name_slug_ignores_ref_suffix() { assert_eq!( repo_name_slug("https://github.com/x/repo.git#release/v2"), "repo" ); } #[test] fn repo_name_slug_ignores_trailing_slash() { assert_eq!(repo_name_slug("https://github.com/x/repo/"), "repo"); } #[test] fn owner_qualifier_from_https_url() { assert_eq!( owner_qualifier("https://github.com/x/repo.git").as_deref(), Some("x") ); } #[test] fn owner_qualifier_from_scp_style_url() { assert_eq!( owner_qualifier("git@github.com:x/repo.git").as_deref(), Some("x") ); } #[test] fn owner_qualifier_falls_back_to_sanitized_host() { assert_eq!( owner_qualifier("https://example.com/repo.git").as_deref(), Some("example-com") ); } #[test] fn owner_qualifier_scp_without_owner_falls_back_to_host() { assert_eq!( owner_qualifier("git@host.example.com:repo.git").as_deref(), Some("host-example-com") ); } #[test] fn owner_qualifier_none_without_owner_or_host() { assert_eq!(owner_qualifier("repo"), None); } #[test] fn canonical_source_url_treats_equivalent_forms_identically() { let canonical = canonical_source_url("https://github.com/x/r"); assert_eq!(canonical, "github.com/x/r"); assert_eq!( canonical_source_url("https://github.com/x/r.git"), canonical ); assert_eq!(canonical_source_url("git@github.com:x/r.git"), canonical); } #[test] fn canonical_source_url_lowercases_host_but_not_path() { assert_eq!( canonical_source_url("https://GitHub.COM/X/R.git"), "github.com/X/R" ); assert_ne!( canonical_source_url("https://gitlab.example.com/team/Repo"), canonical_source_url("https://gitlab.example.com/team/repo") ); } #[test] fn canonical_source_url_ignores_ref_suffix() { assert_eq!( canonical_source_url("https://github.com/x/r.git#main"), "github.com/x/r" ); } #[test] fn canonical_source_url_strips_userinfo() { assert_eq!( canonical_source_url("https://user@github.com/x/r.git"), "github.com/x/r" ); } fn git_in(dir: &Path, args: &[&str]) { let mut full: Vec = vec!["-C".into(), dir.as_os_str().into()]; full.extend(args.iter().map(OsString::from)); run_git(full).unwrap(); } fn commit_file(dir: &Path, name: &str, content: &str) -> String { fs::write(dir.join(name), content).unwrap(); git_in(dir, &["add", "."]); git_in( dir, &[ "-c", "user.email=coyote-test@localhost", "-c", "user.name=coyote-test", "-c", "commit.gpgsign=false", "commit", "-q", "-m", name, ], ); run_git_capture(vec![ "-C".into(), dir.as_os_str().into(), "rev-parse".into(), "HEAD".into(), ]) .unwrap() } fn init_git_repo(dir: &Path) -> String { run_git(vec!["init".into(), "-q".into(), dir.as_os_str().into()]).unwrap(); fs::write(dir.join(".gitattributes"), "* -text\n").unwrap(); commit_file(dir, "seed.txt", "one") } #[test] fn clone_to_temp_captures_resolved_head_sha() { let repo = fresh_temp_dir("clone-sha-"); let sha = init_git_repo(&repo); let temp = clone_to_temp(repo.to_str().unwrap(), None).unwrap(); assert_eq!(temp.head_sha(), sha); assert_eq!(temp.head_sha().len(), 40); assert!(temp.head_sha().chars().all(|c| c.is_ascii_hexdigit())); let _ = fs::remove_dir_all(&repo); } #[test] fn clone_to_temp_respects_sha_ref_pinning() { let repo = fresh_temp_dir("clone-pin-sha-"); let first = init_git_repo(&repo); let second = commit_file(&repo, "next.txt", "two"); assert_ne!(first, second); let temp = clone_to_temp(repo.to_str().unwrap(), Some(&first)).unwrap(); assert_eq!(temp.head_sha(), first); let _ = fs::remove_dir_all(&repo); } #[test] fn clone_to_temp_respects_branch_ref_pinning() { let repo = fresh_temp_dir("clone-pin-branch-"); let first = init_git_repo(&repo); git_in(&repo, &["branch", "pinned"]); let second = commit_file(&repo, "next.txt", "two"); assert_ne!(first, second); let temp = clone_to_temp(repo.to_str().unwrap(), Some("pinned")).unwrap(); assert_eq!(temp.head_sha(), first); let _ = fs::remove_dir_all(&repo); } fn write_src(root: &Path, rel: &str, content: &str) { let path = root.join(rel); if let Some(parent) = path.parent() { fs::create_dir_all(parent).unwrap(); } fs::write(path, content).unwrap(); } fn init_bundle_repo(dir: &Path) -> String { run_git(vec!["init".into(), "-q".into(), dir.as_os_str().into()]).unwrap(); fs::write(dir.join(".gitattributes"), "* -text\n").unwrap(); commit_file(dir, ".seed", "seed") } fn test_metadata(source: &str) -> InstallMetadata { InstallMetadata { source: source.to_string(), git_ref: None, commit: "abc123".to_string(), version: None, description: None, homepage: None, } } #[test] #[serial] fn install_remote_records_files_mcp_entries_and_metadata() { use crate::config::bundles::hash_bytes; let _guard = TestVaultConfigGuard::new("prov-full"); let src_root = fresh_temp_dir("prov-full-src-"); let repo = src_root.join("my-bundle"); write_src( &repo, BUNDLE_MANIFEST_FILE, "name: my-bundle\n\ version: \"2.0\"\n\ description: Test bundle\n\ homepage: https://example.com/my-bundle\n", ); write_src(&repo, "macros/hello.yaml", "name: hello\n"); write_src( &repo, "functions/mcp.json", r#"{"mcpServers": {"srv": {"type": "stdio", "command": "echo"}}}"#, ); let sha = init_bundle_repo(&repo); install_remote(repo.to_str().unwrap(), None, false).unwrap(); let store = BundleStore::load().unwrap(); let record = store.get("my-bundle").unwrap(); assert_eq!(record.source, repo.to_str().unwrap()); assert_eq!(record.git_ref, None); assert_eq!(record.commit, sha); assert_eq!(record.version.as_deref(), Some("2.0")); assert_eq!(record.description.as_deref(), Some("Test bundle")); assert_eq!( record.homepage.as_deref(), Some("https://example.com/my-bundle") ); assert_eq!(record.files.len(), 1); assert_eq!(record.files[0].path, "macros/hello.yaml"); assert_eq!(record.files[0].category, "macros"); assert_eq!(record.files[0].action, FileAction::New); assert_eq!(record.files[0].sha256, hash_bytes(b"name: hello\n")); assert_eq!(record.mcp_servers.len(), 1); assert_eq!(record.mcp_servers[0].name, "srv"); assert_eq!(record.mcp_servers[0].action, McpAction::Added); assert_eq!(record.mcp_servers[0].renamed_to, None); let _ = fs::remove_dir_all(&src_root); } #[test] #[serial] fn install_remote_without_manifest_uses_repo_slug_and_short_sha_version() { let _guard = TestVaultConfigGuard::new("prov-slug"); let src_root = fresh_temp_dir("prov-slug-src-"); let repo = src_root.join("plain.bundle"); write_src(&repo, "macros/m.yaml", "a: 1\n"); let sha = init_bundle_repo(&repo); install_remote(&format!("{}#{sha}", repo.display()), None, false).unwrap(); let store = BundleStore::load().unwrap(); let record = store.get("plain-bundle").unwrap(); assert_eq!(record.git_ref.as_deref(), Some(sha.as_str())); assert_eq!(record.commit, sha); assert_eq!(record.version.as_deref(), Some(&sha[..7])); let _ = fs::remove_dir_all(&src_root); } #[test] #[serial] fn apply_plan_records_files_written_before_a_mid_run_abort() { if *IS_STDOUT_TERMINAL { eprintln!( "Skipping apply_plan_records_files_written_before_a_mid_run_abort: requires non-TTY stdout" ); return; } let _guard = TestVaultConfigGuard::new("prov-abort"); let src_dir = fresh_temp_dir("prov-abort-src-"); let src_new = src_dir.join("new.yaml"); let src_conflict = src_dir.join("conflict.yaml"); fs::write(&src_new, "new content").unwrap(); fs::write(&src_conflict, "remote content").unwrap(); let dst_new = paths::macros_dir().join("new.yaml"); let dst_conflict = paths::macros_dir().join("conflict.yaml"); fs::create_dir_all(paths::macros_dir()).unwrap(); fs::write(&dst_conflict, "local content").unwrap(); let mut store = BundleStore::load().unwrap(); store .upsert_bundle("aborty", test_metadata("https://github.com/x/aborty")) .unwrap(); let plan = InstallPlan { files: vec![ PlannedFile { src: src_new, dst: dst_new.clone(), kind: PlannedKind::New, top_category: TopCategory::Macros, }, PlannedFile { src: src_conflict, dst: dst_conflict.clone(), kind: PlannedKind::Conflict, top_category: TopCategory::Macros, }, ], mcp_json: None, }; let err = apply_plan(&plan, StickyMode::None, &mut store, "aborty").unwrap_err(); assert!( err.to_string().contains("Refusing to overwrite"), "got: {err}" ); assert_eq!(fs::read_to_string(&dst_new).unwrap(), "new content"); assert_eq!(fs::read_to_string(&dst_conflict).unwrap(), "local content"); let reloaded = BundleStore::load().unwrap(); let files = &reloaded.get("aborty").unwrap().files; assert_eq!(files.len(), 1); assert_eq!(files[0].path, "macros/new.yaml"); assert_eq!(files[0].action, FileAction::New); let _ = fs::remove_dir_all(&src_dir); } #[test] fn apply_plan_keep_all_leaves_prior_owner_intact() { let dir = fresh_temp_dir("prov-keep-"); let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); store .upsert_bundle("alpha", test_metadata("https://github.com/a/alpha")) .unwrap(); store .upsert_bundle("beta", test_metadata("https://github.com/b/beta")) .unwrap(); let dst = dir.join("macros/owned.yaml"); write_src(&dir, "macros/owned.yaml", "alpha content"); store .record_file( "alpha", FileRecord { path: "macros/owned.yaml".to_string(), category: "macros".to_string(), sha256: hash_file(&dst).unwrap(), action: FileAction::New, }, ) .unwrap(); let src = dir.join("beta-src/owned.yaml"); write_src(&dir, "beta-src/owned.yaml", "beta content"); let plan = InstallPlan { files: vec![PlannedFile { src, dst: dst.clone(), kind: PlannedKind::Conflict, top_category: TopCategory::Macros, }], mcp_json: None, }; apply_plan(&plan, StickyMode::KeepAll, &mut store, "beta").unwrap(); assert_eq!(fs::read_to_string(&dst).unwrap(), "alpha content"); let reloaded = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); assert_eq!(reloaded.get("alpha").unwrap().files.len(), 1); assert!(reloaded.get("beta").unwrap().files.is_empty()); let _ = fs::remove_dir_all(&dir); } #[test] #[serial] fn second_install_transfers_file_and_mcp_ownership() { use crate::config::bundles::hash_bytes; let _guard = TestVaultConfigGuard::new("prov-transfer"); let src_root = fresh_temp_dir("prov-transfer-src-"); let alpha = src_root.join("alpha"); write_src(&alpha, "macros/shared.yaml", "from: alpha\n"); write_src( &alpha, "functions/mcp.json", r#"{"mcpServers": {"srv": {"type": "stdio", "command": "alpha"}}}"#, ); init_bundle_repo(&alpha); let beta = src_root.join("beta"); write_src(&beta, "macros/shared.yaml", "from: beta\n"); write_src( &beta, "functions/mcp.json", r#"{"mcpServers": {"srv": {"type": "stdio", "command": "beta"}}}"#, ); init_bundle_repo(&beta); install_remote(alpha.to_str().unwrap(), None, false).unwrap(); install_remote(beta.to_str().unwrap(), None, true).unwrap(); let store = BundleStore::load().unwrap(); let alpha_record = store.get("alpha").unwrap(); assert!(alpha_record.files.is_empty()); assert!(alpha_record.mcp_servers.is_empty()); let beta_record = store.get("beta").unwrap(); assert_eq!(beta_record.files.len(), 1); assert_eq!(beta_record.files[0].path, "macros/shared.yaml"); assert_eq!(beta_record.files[0].action, FileAction::Replaced); assert_eq!(beta_record.files[0].sha256, hash_bytes(b"from: beta\n")); assert_eq!(beta_record.mcp_servers.len(), 1); assert_eq!(beta_record.mcp_servers[0].name, "srv"); assert_eq!(beta_record.mcp_servers[0].action, McpAction::Transferred); let _ = fs::remove_dir_all(&src_root); } #[test] #[serial] fn filtered_installs_merge_into_one_record() { let _guard = TestVaultConfigGuard::new("prov-filter"); let src_root = fresh_temp_dir("prov-filter-src-"); let repo = src_root.join("combo"); write_src(&repo, "macros/m.yaml", "a: 1\n"); write_src(&repo, "skills/myskill/SKILL.md", "# skill\n"); let first_sha = init_bundle_repo(&repo); install_remote(repo.to_str().unwrap(), Some(InstallFilter::Macros), false).unwrap(); let second_sha = commit_file(&repo, "extra.txt", "two"); install_remote(repo.to_str().unwrap(), Some(InstallFilter::Skills), false).unwrap(); let store = BundleStore::load().unwrap(); assert_eq!(store.bundle_names(), vec!["combo"]); let record = store.get("combo").unwrap(); assert_ne!(first_sha, second_sha); assert_eq!(record.commit, second_sha); let mut owned: Vec<&str> = record.files.iter().map(|f| f.path.as_str()).collect(); owned.sort(); assert_eq!(owned, vec!["macros/m.yaml", "skills/myskill/SKILL.md"]); let _ = fs::remove_dir_all(&src_root); } #[test] fn record_mcp_merge_ignores_kept_local_entries() { let dir = fresh_temp_dir("prov-mcp-keep-"); let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); store .upsert_bundle("alpha", test_metadata("https://github.com/a/alpha")) .unwrap(); store .upsert_bundle("beta", test_metadata("https://github.com/b/beta")) .unwrap(); store .record_mcp_servers( "alpha", vec![McpServerRecord { name: "srv".to_string(), action: McpAction::Added, renamed_to: None, sha256: None, }], ) .unwrap(); let report = McpMergeReport { added: Vec::new(), kept_local: vec!["srv".to_string()], replaced: Vec::new(), renamed: Vec::new(), entry_hashes: HashMap::new(), final_path: dir.join("mcp.json"), missing_secrets: Vec::new(), }; record_mcp_merge(&mut store, "beta", &report).unwrap(); assert_eq!(store.get("alpha").unwrap().mcp_servers.len(), 1); assert!(store.get("beta").unwrap().mcp_servers.is_empty()); let _ = fs::remove_dir_all(&dir); } #[test] #[serial] fn builtin_asset_install_writes_no_provenance() { if *IS_STDOUT_TERMINAL { eprintln!( "Skipping builtin_asset_install_writes_no_provenance: requires non-TTY stdout" ); return; } let _guard = TestVaultConfigGuard::new("prov-builtin"); crate::config::install_assets(crate::config::AssetCategory::Macros).unwrap(); assert!(fs::read_dir(paths::macros_dir()).unwrap().next().is_some()); assert!(!paths::installed_bundles_file().exists()); } #[test] #[serial] fn update_silently_refreshes_owned_unmodified_files() { use crate::config::bundles::hash_bytes; if *IS_STDOUT_TERMINAL { eprintln!( "Skipping update_silently_refreshes_owned_unmodified_files: requires non-TTY stdout" ); return; } let _guard = TestVaultConfigGuard::new("upd-refresh"); let src_root = fresh_temp_dir("upd-refresh-src-"); let repo = src_root.join("bundle"); write_src(&repo, BUNDLE_MANIFEST_FILE, "name: refresh-bundle\n"); write_src(&repo, "macros/hello.yaml", "v1\n"); init_bundle_repo(&repo); install_remote(repo.to_str().unwrap(), None, false).unwrap(); commit_file(&repo, "macros/hello.yaml", "v2\n"); update_bundle("refresh-bundle", false).unwrap(); let installed = paths::macros_dir().join("hello.yaml"); assert_eq!(fs::read_to_string(&installed).unwrap(), "v2\n"); let store = BundleStore::load().unwrap(); let files = &store.get("refresh-bundle").unwrap().files; assert_eq!(files.len(), 1); assert_eq!(files[0].sha256, hash_bytes(b"v2\n")); assert_eq!(files[0].action, FileAction::Replaced); let _ = fs::remove_dir_all(&src_root); } #[test] #[serial] fn update_with_yes_keeps_modified_files_and_updates_the_rest() { if *IS_STDOUT_TERMINAL { eprintln!( "Skipping update_with_yes_keeps_modified_files_and_updates_the_rest: \ requires non-TTY stdout" ); return; } let _guard = TestVaultConfigGuard::new("upd-yes"); let src_root = fresh_temp_dir("upd-yes-src-"); let repo = src_root.join("bundle"); write_src(&repo, BUNDLE_MANIFEST_FILE, "name: yes-bundle\n"); write_src(&repo, "macros/edited.yaml", "v1\n"); write_src(&repo, "macros/pristine.yaml", "v1\n"); init_bundle_repo(&repo); install_remote(repo.to_str().unwrap(), None, false).unwrap(); fs::write(paths::macros_dir().join("edited.yaml"), "local\n").unwrap(); commit_file(&repo, "macros/edited.yaml", "v2\n"); commit_file(&repo, "macros/pristine.yaml", "v2\n"); let err = update_bundle("yes-bundle", false).unwrap_err(); assert!( format!("{err:#}").contains("Refusing to overwrite"), "err: {err:#}" ); update_bundle("yes-bundle", true).unwrap(); assert_eq!( fs::read_to_string(paths::macros_dir().join("edited.yaml")).unwrap(), "local\n" ); assert_eq!( fs::read_to_string(paths::macros_dir().join("pristine.yaml")).unwrap(), "v2\n" ); let _ = fs::remove_dir_all(&src_root); } #[test] #[serial] fn update_takes_remote_for_owned_unmodified_mcp_entries() { if *IS_STDOUT_TERMINAL { eprintln!( "Skipping update_takes_remote_for_owned_unmodified_mcp_entries: \ requires non-TTY stdout" ); return; } let _guard = TestVaultConfigGuard::new("upd-mcp-auto"); let src_root = fresh_temp_dir("upd-mcp-auto-src-"); let repo = src_root.join("bundle"); write_src(&repo, BUNDLE_MANIFEST_FILE, "name: mcp-auto-bundle\n"); write_src( &repo, "functions/mcp.json", r#"{"mcpServers": {"srv": {"type": "stdio", "command": "echo"}}}"#, ); init_bundle_repo(&repo); install_remote(repo.to_str().unwrap(), None, false).unwrap(); commit_file( &repo, "functions/mcp.json", r#"{"mcpServers": {"srv": {"type": "stdio", "command": "printf"}}}"#, ); update_bundle("mcp-auto-bundle", false).unwrap(); let merged = fs::read_to_string(paths::mcp_config_file()).unwrap(); assert!(merged.contains("printf"), "merged mcp.json: {merged}"); assert!(!merged.contains("echo"), "merged mcp.json: {merged}"); let _ = fs::remove_dir_all(&src_root); } #[test] fn apply_plan_keep_all_preserves_local_edit_and_stale_record() { let dir = fresh_temp_dir("upd-keep-modified-"); let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); store .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) .unwrap(); let dst = dir.join("macros/owned.yaml"); write_src(&dir, "macros/owned.yaml", "installed content"); let recorded_sha = hash_file(&dst).unwrap(); store .record_file( "omc", FileRecord { path: provenance_path(&dst), category: "macros".to_string(), sha256: recorded_sha.clone(), action: FileAction::New, }, ) .unwrap(); fs::write(&dst, "local edit").unwrap(); let src = dir.join("upstream/owned.yaml"); write_src(&dir, "upstream/owned.yaml", "upstream content"); let plan = InstallPlan { files: vec![PlannedFile { src, dst: dst.clone(), kind: PlannedKind::Conflict, top_category: TopCategory::Macros, }], mcp_json: None, }; apply_plan(&plan, StickyMode::KeepAll, &mut store, "omc").unwrap(); assert_eq!(fs::read_to_string(&dst).unwrap(), "local edit"); let reloaded = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); let files = &reloaded.get("omc").unwrap().files; assert_eq!(files.len(), 1); assert_eq!(files[0].sha256, recorded_sha); assert_ne!(files[0].sha256, hash_file(&dst).unwrap()); let _ = fs::remove_dir_all(&dir); } #[test] fn apply_plan_replace_all_takes_upstream_and_rerecords_hash() { use crate::config::bundles::hash_bytes; let dir = fresh_temp_dir("upd-replace-modified-"); let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); store .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) .unwrap(); let dst = dir.join("macros/owned.yaml"); write_src(&dir, "macros/owned.yaml", "installed content"); store .record_file( "omc", FileRecord { path: provenance_path(&dst), category: "macros".to_string(), sha256: hash_file(&dst).unwrap(), action: FileAction::New, }, ) .unwrap(); fs::write(&dst, "local edit").unwrap(); let src = dir.join("upstream/owned.yaml"); write_src(&dir, "upstream/owned.yaml", "upstream content"); let plan = InstallPlan { files: vec![PlannedFile { src, dst: dst.clone(), kind: PlannedKind::Conflict, top_category: TopCategory::Macros, }], mcp_json: None, }; apply_plan(&plan, StickyMode::ReplaceAll, &mut store, "omc").unwrap(); assert_eq!(fs::read_to_string(&dst).unwrap(), "upstream content"); let reloaded = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); let files = &reloaded.get("omc").unwrap().files; assert_eq!(files.len(), 1); assert_eq!(files[0].sha256, hash_bytes(b"upstream content")); assert_eq!(files[0].action, FileAction::Replaced); let _ = fs::remove_dir_all(&dir); } #[test] #[serial] fn update_bails_non_interactively_on_unowned_conflict() { if *IS_STDOUT_TERMINAL { eprintln!( "Skipping update_bails_non_interactively_on_unowned_conflict: requires non-TTY stdout" ); return; } let _guard = TestVaultConfigGuard::new("upd-unowned"); let src_root = fresh_temp_dir("upd-unowned-src-"); let repo = src_root.join("bundle"); write_src(&repo, BUNDLE_MANIFEST_FILE, "name: unowned-bundle\n"); write_src(&repo, "macros/a.yaml", "a\n"); init_bundle_repo(&repo); install_remote(repo.to_str().unwrap(), None, false).unwrap(); commit_file(&repo, "macros/user.yaml", "upstream\n"); fs::create_dir_all(paths::macros_dir()).unwrap(); fs::write(paths::macros_dir().join("user.yaml"), "local\n").unwrap(); let err = update_bundle("unowned-bundle", false).unwrap_err(); assert!( err.to_string().contains("Refusing to overwrite"), "got: {err}" ); assert_eq!( fs::read_to_string(paths::macros_dir().join("user.yaml")).unwrap(), "local\n" ); let _ = fs::remove_dir_all(&src_root); } #[test] fn apply_plan_replace_all_transfers_ownership_between_bundles() { let dir = fresh_temp_dir("upd-transfer-"); let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); store .upsert_bundle("alpha", test_metadata("https://github.com/a/alpha")) .unwrap(); store .upsert_bundle("beta", test_metadata("https://github.com/b/beta")) .unwrap(); let dst = dir.join("macros/shared.yaml"); write_src(&dir, "macros/shared.yaml", "alpha content"); store .record_file( "alpha", FileRecord { path: provenance_path(&dst), category: "macros".to_string(), sha256: hash_file(&dst).unwrap(), action: FileAction::New, }, ) .unwrap(); let src = dir.join("beta-src/shared.yaml"); write_src(&dir, "beta-src/shared.yaml", "beta content"); let plan = InstallPlan { files: vec![PlannedFile { src, dst: dst.clone(), kind: PlannedKind::Conflict, top_category: TopCategory::Macros, }], mcp_json: None, }; apply_plan(&plan, StickyMode::ReplaceAll, &mut store, "beta").unwrap(); assert_eq!(fs::read_to_string(&dst).unwrap(), "beta content"); let reloaded = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); assert!(reloaded.get("alpha").unwrap().files.is_empty()); let beta_files = &reloaded.get("beta").unwrap().files; assert_eq!(beta_files.len(), 1); assert_eq!(beta_files[0].path, provenance_path(&dst)); let _ = fs::remove_dir_all(&dir); } #[test] #[serial] fn update_keeps_obsolete_files_non_interactively() { if *IS_STDOUT_TERMINAL { eprintln!( "Skipping update_keeps_obsolete_files_non_interactively: requires non-TTY stdout" ); return; } let _guard = TestVaultConfigGuard::new("upd-obsolete-keep"); let src_root = fresh_temp_dir("upd-obsolete-keep-src-"); let repo = src_root.join("bundle"); write_src(&repo, BUNDLE_MANIFEST_FILE, "name: obs-keep\n"); write_src(&repo, "macros/keep.yaml", "k\n"); write_src(&repo, "macros/gone.yaml", "g\n"); init_bundle_repo(&repo); install_remote(repo.to_str().unwrap(), None, false).unwrap(); fs::remove_file(repo.join("macros/gone.yaml")).unwrap(); commit_file(&repo, "macros/keep.yaml", "k2\n"); update_bundle("obs-keep", false).unwrap(); assert_eq!( fs::read_to_string(paths::macros_dir().join("gone.yaml")).unwrap(), "g\n" ); let store = BundleStore::load().unwrap(); let record = store.get("obs-keep").unwrap(); assert!(record.files.iter().any(|f| f.path == "macros/gone.yaml")); let _ = fs::remove_dir_all(&src_root); } #[test] fn apply_obsolete_delete_removes_file_and_record() { let dir = fresh_temp_dir("upd-obsolete-delete-"); let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); store .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) .unwrap(); let dst = dir.join("macros/gone.yaml"); write_src(&dir, "macros/gone.yaml", "g"); let path = provenance_path(&dst); store .record_file( "omc", FileRecord { path: path.clone(), category: "macros".to_string(), sha256: hash_file(&dst).unwrap(), action: FileAction::New, }, ) .unwrap(); apply_obsolete_action(&mut store, "omc", &path, &dst, &dir, ObsoleteAction::Delete) .unwrap(); assert!(!dst.exists()); let reloaded = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); assert!(reloaded.get("omc").unwrap().files.is_empty()); let _ = fs::remove_dir_all(&dir); } #[test] fn handle_obsolete_drops_records_for_missing_files() { let dir = fresh_temp_dir("upd-obsolete-missing-"); let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); store .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) .unwrap(); store .record_file( "omc", FileRecord { path: "macros/ghost-bundle-test.yaml".to_string(), category: "macros".to_string(), sha256: "0".repeat(64), action: FileAction::New, }, ) .unwrap(); let plan = InstallPlan { files: Vec::new(), mcp_json: None, }; handle_obsolete_files(&mut store, "omc", &plan, false).unwrap(); assert!(store.get("omc").unwrap().files.is_empty()); let _ = fs::remove_dir_all(&dir); } #[test] fn handle_obsolete_never_touches_suspicious_recorded_paths() { let dir = fresh_temp_dir("upd-obsolete-suspicious-"); let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); store .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) .unwrap(); for hostile in ["/etc/nonexistent-coyote-test", "../escape.yaml"] { store .record_file( "omc", FileRecord { path: hostile.to_string(), category: "macros".to_string(), sha256: "0".repeat(64), action: FileAction::New, }, ) .unwrap(); } let plan = InstallPlan { files: Vec::new(), mcp_json: None, }; handle_obsolete_files(&mut store, "omc", &plan, false).unwrap(); assert_eq!(store.get("omc").unwrap().files.len(), 2); let _ = fs::remove_dir_all(&dir); } #[test] #[serial] fn update_refreshes_record_metadata_and_stamps_updated_at() { let _guard = TestVaultConfigGuard::new("upd-meta"); let src_root = fresh_temp_dir("upd-meta-src-"); let repo = src_root.join("bundle"); write_src( &repo, BUNDLE_MANIFEST_FILE, "name: meta-bundle\n\ version: \"1.0\"\n\ description: Old\n\ homepage: https://example.com/old\n", ); write_src(&repo, "macros/m.yaml", "a: 1\n"); init_bundle_repo(&repo); install_remote(repo.to_str().unwrap(), None, false).unwrap(); let installed_at = BundleStore::load() .unwrap() .get("meta-bundle") .unwrap() .installed_at .clone(); let new_sha = commit_file( &repo, BUNDLE_MANIFEST_FILE, "name: meta-bundle\n\ version: \"2.0\"\n\ description: New\n\ homepage: https://example.com/new\n", ); update_bundle("meta-bundle", false).unwrap(); let store = BundleStore::load().unwrap(); let record = store.get("meta-bundle").unwrap(); assert_eq!(record.commit, new_sha); assert_eq!(record.version.as_deref(), Some("2.0")); assert_eq!(record.description.as_deref(), Some("New")); assert_eq!(record.homepage.as_deref(), Some("https://example.com/new")); assert!(record.updated_at.is_some()); assert_eq!(record.installed_at, installed_at); let _ = fs::remove_dir_all(&src_root); } #[test] #[serial] fn update_unknown_bundle_lists_installed_names() { let _guard = TestVaultConfigGuard::new("upd-unknown-name"); let err = update_bundle("nope", false).unwrap_err(); assert!(err.to_string().contains("none are installed"), "got: {err}"); let src_root = fresh_temp_dir("upd-unknown-src-"); let repo = src_root.join("bundle"); write_src(&repo, BUNDLE_MANIFEST_FILE, "name: known-bundle\n"); write_src(&repo, "macros/m.yaml", "a: 1\n"); init_bundle_repo(&repo); install_remote(repo.to_str().unwrap(), None, false).unwrap(); let err = update_bundle("nope", false).unwrap_err(); assert!( err.to_string().contains("installed bundles: known-bundle"), "got: {err}" ); let _ = fs::remove_dir_all(&src_root); } #[test] #[serial] fn update_honors_recorded_sha_pin() { let _guard = TestVaultConfigGuard::new("upd-pin"); let src_root = fresh_temp_dir("upd-pin-src-"); let repo = src_root.join("bundle"); write_src(&repo, BUNDLE_MANIFEST_FILE, "name: pin-bundle\n"); write_src(&repo, "macros/one.yaml", "1\n"); let pinned = init_bundle_repo(&repo); install_remote(&format!("{}#{pinned}", repo.display()), None, false).unwrap(); let newer = commit_file(&repo, "macros/two.yaml", "2\n"); assert_ne!(pinned, newer); update_bundle("pin-bundle", false).unwrap(); let store = BundleStore::load().unwrap(); let record = store.get("pin-bundle").unwrap(); assert_eq!(record.commit, pinned); assert_eq!(record.git_ref.as_deref(), Some(pinned.as_str())); assert!(!paths::macros_dir().join("two.yaml").exists()); let _ = fs::remove_dir_all(&src_root); } #[test] #[serial] fn update_ref_override_moves_the_pin() { let _guard = TestVaultConfigGuard::new("upd-move-pin"); let src_root = fresh_temp_dir("upd-move-pin-src-"); let repo = src_root.join("bundle"); write_src(&repo, BUNDLE_MANIFEST_FILE, "name: move-bundle\n"); write_src(&repo, "macros/one.yaml", "1\n"); let pinned = init_bundle_repo(&repo); install_remote(&format!("{}#{pinned}", repo.display()), None, false).unwrap(); let newer = commit_file(&repo, "macros/two.yaml", "2\n"); update_bundle(&format!("move-bundle#{newer}"), false).unwrap(); let store = BundleStore::load().unwrap(); let record = store.get("move-bundle").unwrap(); assert_eq!(record.commit, newer); assert_eq!(record.git_ref.as_deref(), Some(newer.as_str())); assert_eq!( fs::read_to_string(paths::macros_dir().join("two.yaml")).unwrap(), "2\n" ); let _ = fs::remove_dir_all(&src_root); } fn owned_names(names: &[&str]) -> Vec { names.iter().map(|s| s.to_string()).collect() } #[test] fn classify_urls_and_paths_as_remote_sources() { for value in [ "https://github.com/x/y", "git@github.com:x/y.git", "./bundle-dir", "../up", "/abs/path", "~/home-rel", ] { assert_eq!( classify_install_target(value, &[]), InstallTarget::RemoteSource, "value: {value}" ); } } #[test] fn classify_categories_win_over_installed_names() { assert_eq!( classify_install_target("agents", &owned_names(&["agents"])), InstallTarget::Category(AssetCategory::Agents) ); assert_eq!( classify_install_target("mcp_config", &[]), InstallTarget::Category(AssetCategory::McpConfig) ); } #[test] fn classify_installed_names_with_optional_ref() { let installed = owned_names(&["omc"]); assert_eq!( classify_install_target("omc", &installed), InstallTarget::InstalledBundle ); assert_eq!( classify_install_target("omc#v2", &installed), InstallTarget::InstalledBundle ); } #[test] fn classify_bare_names_without_a_match_as_unknown() { assert_eq!(classify_install_target("omc", &[]), InstallTarget::Unknown); assert_eq!( classify_install_target("not-a-bundle", &owned_names(&["omc"])), InstallTarget::Unknown ); } #[test] #[serial] fn install_or_update_redirects_categories_to_install_builtins() { let _guard = TestVaultConfigGuard::new("iou-category"); let err = install_or_update("agents", None, None, false).unwrap_err(); assert!( err.to_string().contains("--install-builtins agents"), "got: {err}" ); } #[test] #[serial] fn install_or_update_unknown_name_lists_installed_bundles() { let _guard = TestVaultConfigGuard::new("iou-unknown"); let err = install_or_update("not-a-bundle", None, None, false).unwrap_err(); assert!(err.to_string().contains("none are installed"), "got: {err}"); let src_root = fresh_temp_dir("iou-unknown-src-"); let repo = src_root.join("bundle"); write_src(&repo, BUNDLE_MANIFEST_FILE, "name: known-bundle\n"); write_src(&repo, "macros/m.yaml", "a: 1\n"); init_bundle_repo(&repo); install_remote(repo.to_str().unwrap(), None, false).unwrap(); let err = install_or_update("not-a-bundle", None, None, false).unwrap_err(); assert!( err.to_string().contains("no bundle named 'not-a-bundle'"), "got: {err}" ); assert!( err.to_string().contains("installed bundles: known-bundle"), "got: {err}" ); let _ = fs::remove_dir_all(&src_root); } #[test] #[serial] fn install_or_update_category_error_hints_update_for_shadowed_bundle() { let _guard = TestVaultConfigGuard::new("iou-shadow"); let mut store = BundleStore::load().unwrap(); store .upsert_bundle( "agents", InstallMetadata { source: "https://github.com/x/agents".to_string(), git_ref: None, commit: "abc123".to_string(), version: None, description: None, homepage: None, }, ) .unwrap(); let err = install_or_update("agents", None, None, false).unwrap_err(); assert!( err.to_string().contains("--install-builtins agents"), "got: {err}" ); assert!( err.to_string().contains("--update-bundle agents"), "got: {err}" ); } #[test] #[serial] fn install_or_update_rejects_remote_flags_for_updates() { let _guard = TestVaultConfigGuard::new("iou-flags"); let src_root = fresh_temp_dir("iou-flags-src-"); let repo = src_root.join("bundle"); write_src(&repo, BUNDLE_MANIFEST_FILE, "name: flag-bundle\n"); write_src(&repo, "macros/m.yaml", "a: 1\n"); init_bundle_repo(&repo); install_remote(repo.to_str().unwrap(), None, false).unwrap(); let err = install_or_update("flag-bundle", None, Some(InstallFilter::Macros), false).unwrap_err(); assert!( err.to_string().contains("only apply to remote installs"), "got: {err}" ); let err = install_or_update("flag-bundle", None, None, true).unwrap_err(); assert!( err.to_string().contains("only apply to remote installs"), "got: {err}" ); let _ = fs::remove_dir_all(&src_root); } #[test] fn shorthand_accepts_owner_repo_and_deeper_paths() { assert!(is_repo_shorthand("someuser/oh-my-coyote")); assert!(is_repo_shorthand("group/subgroup/repo")); assert!(is_repo_shorthand("someuser/repo#v2")); } #[test] fn shorthand_rejects_urls_paths_and_bare_names() { assert!(!is_repo_shorthand("https://github.com/x/y")); assert!(!is_repo_shorthand("git@github.com:x/y.git")); assert!(!is_repo_shorthand("./local/dir")); assert!(!is_repo_shorthand("/abs/path")); assert!(!is_repo_shorthand("~/home/path")); assert!(!is_repo_shorthand("-flag/like")); assert!(!is_repo_shorthand("bare-name")); assert!(!is_repo_shorthand("a//b")); assert!(!is_repo_shorthand("a/b/")); assert!(!is_repo_shorthand("a\\b")); assert!(!is_repo_shorthand("a b/c")); } #[test] fn shorthand_expands_against_default_and_custom_hosts() { assert_eq!( expand_repo_shorthand("someuser/omc", None).unwrap(), "https://github.com/someuser/omc" ); assert_eq!( expand_repo_shorthand("someuser/omc#v2", Some("git.somedomain.com")).unwrap(), "https://git.somedomain.com/someuser/omc#v2" ); assert_eq!( expand_repo_shorthand("a/b", Some("https://git.x.com/")).unwrap(), "https://git.x.com/a/b" ); assert!(expand_repo_shorthand("a/b", Some("bad/host")).is_err()); assert!(expand_repo_shorthand("a/b", Some("")).is_err()); } #[test] fn classify_prefers_installed_names_over_shorthand() { assert_eq!( classify_install_target("someuser/omc", &[]), InstallTarget::Shorthand ); assert_eq!( classify_install_target("someuser/omc", &["someuser/omc".to_string()]), InstallTarget::InstalledBundle ); } #[test] fn install_or_update_git_host_rejects_non_shorthand_values() { let err = install_or_update("https://github.com/x/y", Some("git.x.com"), None, false) .unwrap_err(); assert!( err.to_string().contains("--git-host only applies"), "got: {err}" ); let err = install_or_update("bare-name", Some("git.x.com"), None, false).unwrap_err(); assert!( err.to_string().contains("--git-host only applies"), "got: {err}" ); } #[test] fn repl_install_flags_parse_git_host() { let (filter, force, host) = parse_repl_install_flags( ".install", vec!["--git-host".to_string(), "git.x.com".to_string()].into_iter(), ) .unwrap(); assert_eq!(host.as_deref(), Some("git.x.com")); assert!(filter.is_none() && !force); let (_, force, host) = parse_repl_install_flags( ".install", vec!["--git-host=git.y.com".to_string(), "--force".to_string()].into_iter(), ) .unwrap(); assert_eq!(host.as_deref(), Some("git.y.com")); assert!(force); } #[test] #[serial] fn uninstall_shorthand_resolves_a_single_source_match() { let _guard = TestVaultConfigGuard::new("uninst-short-one"); let mut store = BundleStore::load().unwrap(); store .upsert_bundle("omc", test_metadata("https://github.com/someuser/omc")) .unwrap(); drop(store); uninstall_bundle("someuser/omc", true).unwrap(); let store = BundleStore::load().unwrap(); assert!(store.get("omc").is_none()); } #[test] #[serial] fn uninstall_shorthand_with_multiple_matches_bails_non_interactively() { let _guard = TestVaultConfigGuard::new("uninst-short-multi"); let mut store = BundleStore::load().unwrap(); store .upsert_bundle("omc", test_metadata("https://github.com/someuser/omc")) .unwrap(); store .upsert_bundle( "omc-fork", test_metadata("https://git.somedomain.com/someuser/omc"), ) .unwrap(); drop(store); let err = uninstall_bundle("someuser/omc", true).unwrap_err(); assert!( err.to_string() .contains("matches multiple installed bundles"), "got: {err}" ); assert!(err.to_string().contains("omc-fork"), "got: {err}"); let store = BundleStore::load().unwrap(); assert!(store.get("omc").is_some()); assert!(store.get("omc-fork").is_some()); } #[test] #[serial] fn uninstall_exact_name_wins_over_shorthand_source_matches() { let _guard = TestVaultConfigGuard::new("uninst-short-name"); let mut store = BundleStore::load().unwrap(); store .upsert_bundle( "someuser/omc", test_metadata("https://git.somedomain.com/other/repo"), ) .unwrap(); store .upsert_bundle("omc", test_metadata("https://github.com/someuser/omc")) .unwrap(); drop(store); uninstall_bundle("someuser/omc", true).unwrap(); let store = BundleStore::load().unwrap(); assert!(store.get("someuser/omc").is_none()); assert!(store.get("omc").is_some()); } fn owned_file_record(path: &str, contents: &str) -> FileRecord { FileRecord { path: path.to_string(), category: "macros".to_string(), sha256: hash_bytes(contents.as_bytes()), action: FileAction::New, } } fn mcp_server_record(name: &str, action: McpAction, sha256: Option) -> McpServerRecord { McpServerRecord { name: name.to_string(), action, renamed_to: None, sha256, } } #[test] #[serial] fn uninstall_deletes_intact_files_and_removes_the_bundle_record() { let _guard = TestVaultConfigGuard::new("uninst-intact"); let mut store = BundleStore::load().unwrap(); store .upsert_bundle("gone", test_metadata("https://github.com/x/gone")) .unwrap(); let dst = paths::macros_dir().join("hello.yaml"); fs::create_dir_all(paths::macros_dir()).unwrap(); fs::write(&dst, "hi\n").unwrap(); store .record_file("gone", owned_file_record("macros/hello.yaml", "hi\n")) .unwrap(); uninstall_bundle("gone", true).unwrap(); assert!(!dst.exists()); assert!( !paths::macros_dir().exists(), "emptied macros dir should be pruned" ); assert!(BundleStore::load().unwrap().get("gone").is_none()); } #[test] #[serial] fn uninstall_keeps_modified_files_and_retains_the_record() { let _guard = TestVaultConfigGuard::new("uninst-modified"); let mut store = BundleStore::load().unwrap(); store .upsert_bundle("mods", test_metadata("https://github.com/x/mods")) .unwrap(); let dst = paths::macros_dir().join("edited.yaml"); fs::create_dir_all(paths::macros_dir()).unwrap(); fs::write(&dst, "user edit\n").unwrap(); store .record_file( "mods", owned_file_record("macros/edited.yaml", "original\n"), ) .unwrap(); uninstall_bundle("mods", true).unwrap(); assert_eq!(fs::read_to_string(&dst).unwrap(), "user edit\n"); let store = BundleStore::load().unwrap(); let record = store.get("mods").unwrap(); assert_eq!(record.files.len(), 1); assert_eq!(record.files[0].path, "macros/edited.yaml"); } #[test] fn apply_uninstall_delete_removes_file_record_and_empty_dirs() { let dir = fresh_temp_dir("uninst-apply-delete-"); let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); store .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) .unwrap(); let dst = dir.join("macros/gone.yaml"); write_src(&dir, "macros/gone.yaml", "user edit"); store .record_file("omc", owned_file_record("macros/gone.yaml", "original")) .unwrap(); let mut summary = UninstallFileSummary::default(); apply_uninstall_file_action( &mut store, "omc", "macros/gone.yaml", &dst, &dir, ObsoleteAction::Delete, &mut summary, ) .unwrap(); assert!(!dst.exists()); assert!(!dir.join("macros").exists()); assert_eq!(summary.deleted, 1); assert!(store.get("omc").unwrap().files.is_empty()); let _ = fs::remove_dir_all(&dir); } #[test] fn uninstall_files_drop_records_for_missing_files() { let dir = fresh_temp_dir("uninst-missing-"); let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); store .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) .unwrap(); store .record_file("omc", owned_file_record("macros/ghost.yaml", "gone")) .unwrap(); let files = store.get("omc").unwrap().files.clone(); let summary = uninstall_owned_files(&mut store, "omc", &files, &dir, true).unwrap(); assert_eq!( summary, UninstallFileSummary { missing: 1, ..UninstallFileSummary::default() } ); assert!(store.get("omc").unwrap().files.is_empty()); let _ = fs::remove_dir_all(&dir); } #[test] fn uninstall_files_never_touch_unowned_files() { let dir = fresh_temp_dir("uninst-unowned-"); let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); store .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) .unwrap(); write_src(&dir, "macros/owned.yaml", "a"); write_src(&dir, "macros/user.yaml", "mine"); store .record_file("omc", owned_file_record("macros/owned.yaml", "a")) .unwrap(); let files = store.get("omc").unwrap().files.clone(); let summary = uninstall_owned_files(&mut store, "omc", &files, &dir, true).unwrap(); assert_eq!(summary.deleted, 1); assert!(!dir.join("macros/owned.yaml").exists()); assert_eq!( fs::read_to_string(dir.join("macros/user.yaml")).unwrap(), "mine" ); let _ = fs::remove_dir_all(&dir); } #[cfg(unix)] #[test] fn uninstall_files_keep_records_when_deletion_fails_and_continue() { use std::os::unix::fs::PermissionsExt; struct RestorePerms(PathBuf); impl Drop for RestorePerms { fn drop(&mut self) { let _ = fs::set_permissions(&self.0, fs::Permissions::from_mode(0o755)); } } let dir = fresh_temp_dir("uninst-fail-"); let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); store .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) .unwrap(); write_src(&dir, "locked/stuck.yaml", "a"); write_src(&dir, "macros/ok.yaml", "b"); store .record_file("omc", owned_file_record("locked/stuck.yaml", "a")) .unwrap(); store .record_file("omc", owned_file_record("macros/ok.yaml", "b")) .unwrap(); let locked = dir.join("locked"); fs::set_permissions(&locked, fs::Permissions::from_mode(0o555)).unwrap(); let restore = RestorePerms(locked.clone()); let probe = locked.join(".write-probe"); if fs::write(&probe, "x").is_ok() { // A privileged user bypasses permission bits; the failure path // cannot be provoked this way. let _ = fs::remove_file(&probe); drop(restore); let _ = fs::remove_dir_all(&dir); return; } let files = store.get("omc").unwrap().files.clone(); let summary = uninstall_owned_files(&mut store, "omc", &files, &dir, true).unwrap(); drop(restore); assert_eq!(summary.failed, 1); assert_eq!(summary.deleted, 1); assert!(dir.join("locked/stuck.yaml").exists()); assert!(!dir.join("macros/ok.yaml").exists()); let paths: Vec<&str> = store .get("omc") .unwrap() .files .iter() .map(|f| f.path.as_str()) .collect(); assert_eq!(paths, vec!["locked/stuck.yaml"]); let _ = fs::remove_dir_all(&dir); } #[test] fn uninstall_files_skip_suspicious_recorded_paths() { let dir = fresh_temp_dir("uninst-suspicious-"); let config_dir = dir.join("config"); fs::create_dir_all(&config_dir).unwrap(); let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); store .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) .unwrap(); write_src(&dir, "evil.yaml", "outside"); let abs_victim = dir.join("abs.yaml"); fs::write(&abs_victim, "outside").unwrap(); store .record_file("omc", owned_file_record("../evil.yaml", "outside")) .unwrap(); store .record_file( "omc", owned_file_record(abs_victim.to_str().unwrap(), "outside"), ) .unwrap(); let files = store.get("omc").unwrap().files.clone(); let summary = uninstall_owned_files(&mut store, "omc", &files, &config_dir, true).unwrap(); assert_eq!(summary.failed, 2); assert_eq!(summary.deleted, 0); assert!(dir.join("evil.yaml").exists()); assert!(abs_victim.exists()); assert_eq!(store.get("omc").unwrap().files.len(), 2); let _ = fs::remove_dir_all(&dir); } #[test] fn uninstall_mcp_keeps_replaced_entries_and_drops_the_record() { let dir = fresh_temp_dir("uninst-mcp-replaced-"); let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); store .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) .unwrap(); store .record_mcp_servers( "omc", vec![mcp_server_record("srv", McpAction::Replaced, None)], ) .unwrap(); let mcp = dir.join("mcp.json"); write_mcp( &mcp, r#"{"mcpServers": {"srv": {"type": "stdio", "command": "echo"}}}"#, ); let servers = store.get("omc").unwrap().mcp_servers.clone(); let summary = uninstall_mcp_entries(&mut store, "omc", &servers, &mcp, true).unwrap(); assert_eq!(summary.kept, vec!["srv"]); assert!(summary.removed.is_empty()); let written: McpServersConfig = serde_json::from_str(&fs::read_to_string(&mcp).unwrap()).unwrap(); assert!(written.mcp_servers.contains_key("srv")); assert!(store.get("omc").unwrap().mcp_servers.is_empty()); let _ = fs::remove_dir_all(&dir); } #[test] fn uninstall_mcp_removes_intact_entries_and_leaves_unowned_keys() { let dir = fresh_temp_dir("uninst-mcp-intact-"); let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); store .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) .unwrap(); let mcp = dir.join("mcp.json"); write_mcp( &mcp, r#"{"mcpServers": { "srv": {"type": "stdio", "command": "echo"}, "user-srv": {"type": "stdio", "command": "mine"} }}"#, ); let parsed: McpServersConfig = serde_json::from_str(&fs::read_to_string(&mcp).unwrap()).unwrap(); let hash = hash_bytes( serde_json::to_string(parsed.mcp_servers.get("srv").unwrap()) .unwrap() .as_bytes(), ); store .record_mcp_servers( "omc", vec![mcp_server_record("srv", McpAction::Added, Some(hash))], ) .unwrap(); let servers = store.get("omc").unwrap().mcp_servers.clone(); let summary = uninstall_mcp_entries(&mut store, "omc", &servers, &mcp, true).unwrap(); assert_eq!(summary.removed, vec!["srv"]); assert!(summary.kept.is_empty()); let written: McpServersConfig = serde_json::from_str(&fs::read_to_string(&mcp).unwrap()).unwrap(); assert!(!written.mcp_servers.contains_key("srv")); assert!(written.mcp_servers.contains_key("user-srv")); assert!(store.get("omc").unwrap().mcp_servers.is_empty()); let _ = fs::remove_dir_all(&dir); } #[test] fn uninstall_mcp_reports_referenced_secrets_without_removing_them() { let dir = fresh_temp_dir("uninst-mcp-secrets-"); let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); store .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) .unwrap(); let mcp = dir.join("mcp.json"); write_mcp( &mcp, r#"{"mcpServers": { "srv": { "type": "stdio", "command": "echo", "env": {"TOKEN": "{{OMC_TOKEN}}", "ORG": "{{OMC_ORG}}"} } }}"#, ); let parsed: McpServersConfig = serde_json::from_str(&fs::read_to_string(&mcp).unwrap()).unwrap(); let hash = hash_bytes( serde_json::to_string(parsed.mcp_servers.get("srv").unwrap()) .unwrap() .as_bytes(), ); store .record_mcp_servers( "omc", vec![mcp_server_record("srv", McpAction::Added, Some(hash))], ) .unwrap(); let servers = store.get("omc").unwrap().mcp_servers.clone(); let summary = uninstall_mcp_entries(&mut store, "omc", &servers, &mcp, true).unwrap(); assert_eq!(summary.removed, vec!["srv"]); assert_eq!(summary.secrets, vec!["OMC_ORG", "OMC_TOKEN"]); let _ = fs::remove_dir_all(&dir); } #[test] fn uninstall_mcp_keeps_modified_entries_and_their_records() { let dir = fresh_temp_dir("uninst-mcp-modified-"); let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); store .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) .unwrap(); let mcp = dir.join("mcp.json"); write_mcp( &mcp, r#"{"mcpServers": {"srv": {"type": "stdio", "command": "edited"}}}"#, ); store .record_mcp_servers( "omc", vec![mcp_server_record( "srv", McpAction::Added, Some("0".repeat(64)), )], ) .unwrap(); let servers = store.get("omc").unwrap().mcp_servers.clone(); let summary = uninstall_mcp_entries(&mut store, "omc", &servers, &mcp, true).unwrap(); assert_eq!(summary.kept, vec!["srv"]); assert!(summary.removed.is_empty()); let written: McpServersConfig = serde_json::from_str(&fs::read_to_string(&mcp).unwrap()).unwrap(); assert!(written.mcp_servers.contains_key("srv")); assert_eq!(store.get("omc").unwrap().mcp_servers.len(), 1); let _ = fs::remove_dir_all(&dir); } #[test] fn uninstall_mcp_keeps_legacy_records_without_a_hash() { let dir = fresh_temp_dir("uninst-mcp-legacy-"); let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); store .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) .unwrap(); let mcp = dir.join("mcp.json"); write_mcp( &mcp, r#"{"mcpServers": {"srv": {"type": "stdio", "command": "echo"}}}"#, ); store .record_mcp_servers( "omc", vec![mcp_server_record("srv", McpAction::Added, None)], ) .unwrap(); let servers = store.get("omc").unwrap().mcp_servers.clone(); let summary = uninstall_mcp_entries(&mut store, "omc", &servers, &mcp, true).unwrap(); assert_eq!(summary.kept, vec!["srv"]); let written: McpServersConfig = serde_json::from_str(&fs::read_to_string(&mcp).unwrap()).unwrap(); assert!(written.mcp_servers.contains_key("srv")); assert_eq!(store.get("omc").unwrap().mcp_servers.len(), 1); let _ = fs::remove_dir_all(&dir); } #[test] #[serial] fn uninstall_unknown_name_lists_installed_and_url_spelling_resolves() { let _guard = TestVaultConfigGuard::new("uninst-unknown"); let err = uninstall_bundle("nope", true).unwrap_err(); assert!(err.to_string().contains("none are installed"), "got: {err}"); let mut store = BundleStore::load().unwrap(); store .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) .unwrap(); let err = uninstall_bundle("nope", true).unwrap_err(); assert!( err.to_string().contains("installed bundles: omc"), "got: {err}" ); uninstall_bundle("git@github.com:x/omc.git", true).unwrap(); assert!(BundleStore::load().unwrap().get("omc").is_none()); } #[test] #[serial] fn uninstall_refuses_non_interactive_without_yes() { if *IS_STDOUT_TERMINAL { eprintln!( "Skipping uninstall_refuses_non_interactive_without_yes: requires non-TTY stdout" ); return; } let _guard = TestVaultConfigGuard::new("uninst-non-tty"); let mut store = BundleStore::load().unwrap(); store .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) .unwrap(); let err = uninstall_bundle("omc", false).unwrap_err(); assert!(err.to_string().contains("--yes"), "got: {err}"); assert!(BundleStore::load().unwrap().get("omc").is_some()); } #[test] #[serial] fn record_mcp_merge_stores_entry_hashes() { let _guard = TestVaultConfigGuard::new("uninst-merge-hash"); let dir = fresh_temp_dir("uninst-merge-hash-"); let remote = dir.join("remote.json"); let target = dir.join("target.json"); write_mcp(&remote, FIXTURE_REMOTE); let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); store .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) .unwrap(); let report = merge_mcp_json(None, &remote, &target, false, &HashSet::new(), false).unwrap(); record_mcp_merge(&mut store, "omc", &report).unwrap(); let written: McpServersConfig = serde_json::from_str(&fs::read_to_string(&target).unwrap()).unwrap(); let expected = hash_bytes( serde_json::to_string(written.mcp_servers.get("alpha").unwrap()) .unwrap() .as_bytes(), ); let record = store.get("omc").unwrap(); let alpha = record .mcp_servers .iter() .find(|s| s.name == "alpha") .unwrap(); assert_eq!(alpha.sha256.as_deref(), Some(expected.as_str())); let _ = fs::remove_dir_all(&dir); } }