feat: add --uninstall and .uninstall for installed bundles
This commit is contained in:
+22
-1
@@ -53,7 +53,7 @@ pub enum McpScopeArg {
|
|||||||
"install_from", "sync_models", "list_models", "list_roles",
|
"install_from", "sync_models", "list_models", "list_roles",
|
||||||
"list_sessions", "list_agents", "list_rags", "list_macros",
|
"list_sessions", "list_agents", "list_rags", "list_macros",
|
||||||
"list_skills", "list_bundles", "skill", "tail_logs", "completions",
|
"list_skills", "list_bundles", "skill", "tail_logs", "completions",
|
||||||
"update", "update_bundle",
|
"update", "update_bundle", "uninstall",
|
||||||
])
|
])
|
||||||
),
|
),
|
||||||
group(
|
group(
|
||||||
@@ -210,6 +210,12 @@ pub struct Cli {
|
|||||||
/// Update an installed bundle from its recorded source (NAME may be suffixed with #<ref> to move a pin)
|
/// Update an installed bundle from its recorded source (NAME may be suffixed with #<ref> to move a pin)
|
||||||
#[arg(long, value_name = "NAME", help_heading = "Installation & Updates")]
|
#[arg(long, value_name = "NAME", help_heading = "Installation & Updates")]
|
||||||
pub update_bundle: Option<String>,
|
pub update_bundle: Option<String>,
|
||||||
|
/// Uninstall a bundle: delete its owned files and remove its mcp.json entries
|
||||||
|
#[arg(long, value_name = "NAME", help_heading = "Installation & Updates")]
|
||||||
|
pub uninstall: Option<String>,
|
||||||
|
/// Skip uninstall confirmation prompts (locally modified items are still kept)
|
||||||
|
#[arg(long, requires = "uninstall", help_heading = "Installation & Updates")]
|
||||||
|
pub yes: bool,
|
||||||
/// Sync models updates
|
/// Sync models updates
|
||||||
#[arg(long, help_heading = "Installation & Updates")]
|
#[arg(long, help_heading = "Installation & Updates")]
|
||||||
pub sync_models: bool,
|
pub sync_models: bool,
|
||||||
@@ -519,6 +525,21 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_uninstall_flag_takes_name() {
|
||||||
|
assert_eq!(
|
||||||
|
parse(&["--uninstall", "foo"]).uninstall.as_deref(),
|
||||||
|
Some("foo")
|
||||||
|
);
|
||||||
|
assert!(!parse(&["--uninstall", "foo"]).yes);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_yes_flag_requires_uninstall() {
|
||||||
|
assert!(parse(&["--uninstall", "foo", "--yes"]).yes);
|
||||||
|
assert!(Cli::try_parse_from(["coyote", "--yes"]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_multiple_skill_flags_preserves_order() {
|
fn parse_multiple_skill_flags_preserves_order() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
+112
-15
@@ -43,6 +43,10 @@ pub(crate) struct McpServerRecord {
|
|||||||
pub(crate) name: String,
|
pub(crate) name: String,
|
||||||
pub(crate) action: McpAction,
|
pub(crate) action: McpAction,
|
||||||
pub(crate) renamed_to: Option<String>,
|
pub(crate) renamed_to: Option<String>,
|
||||||
|
/// Hash of the mcp.json entry as written; a later mismatch means the user
|
||||||
|
/// modified it. Absent on records made before entry hashing existed.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub(crate) sha256: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl McpServerRecord {
|
impl McpServerRecord {
|
||||||
@@ -171,7 +175,6 @@ impl BundleStore {
|
|||||||
|
|
||||||
/// Look up the record installed from `url`, comparing canonical source URLs
|
/// Look up the record installed from `url`, comparing canonical source URLs
|
||||||
/// so https/scp/`.git` spellings of the same remote all match.
|
/// so https/scp/`.git` spellings of the same remote all match.
|
||||||
#[allow(dead_code)]
|
|
||||||
pub(crate) fn find_by_source(&self, url: &str) -> Option<(&str, &BundleRecord)> {
|
pub(crate) fn find_by_source(&self, url: &str) -> Option<(&str, &BundleRecord)> {
|
||||||
let canonical = canonical_source_url(url);
|
let canonical = canonical_source_url(url);
|
||||||
self.bundles
|
self.bundles
|
||||||
@@ -342,6 +345,28 @@ impl BundleStore {
|
|||||||
self.save()
|
self.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop one mcp.json entry from a bundle's owned servers, matched by the
|
||||||
|
/// key it occupies in mcp.json, and persist.
|
||||||
|
pub(crate) fn remove_mcp_record(&mut self, bundle: &str, effective_key: &str) -> Result<()> {
|
||||||
|
self.ensure_bundle_exists(bundle)?;
|
||||||
|
let record = self
|
||||||
|
.bundles
|
||||||
|
.get_mut(bundle)
|
||||||
|
.expect("bundle existence checked above");
|
||||||
|
record
|
||||||
|
.mcp_servers
|
||||||
|
.retain(|owned| owned.effective_key() != effective_key);
|
||||||
|
self.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a bundle's record entirely and persist. Used once an uninstall
|
||||||
|
/// has released everything the record owned.
|
||||||
|
pub(crate) fn remove_bundle(&mut self, name: &str) -> Result<()> {
|
||||||
|
self.ensure_bundle_exists(name)?;
|
||||||
|
self.bundles.remove(name);
|
||||||
|
self.save()
|
||||||
|
}
|
||||||
|
|
||||||
/// Record one written file and persist immediately, so an install aborted
|
/// Record one written file and persist immediately, so an install aborted
|
||||||
/// partway through still has provenance for everything already on disk.
|
/// partway through still has provenance for everything already on disk.
|
||||||
/// A path owned by another bundle transfers to `bundle`.
|
/// A path owned by another bundle transfers to `bundle`.
|
||||||
@@ -362,10 +387,11 @@ impl BundleStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Record the mcp.json entries an install wrote, in one persisted flush.
|
/// Record the mcp.json entries an install wrote, in one persisted flush.
|
||||||
/// An entry whose key another bundle owns transfers to `bundle`: the old
|
/// An entry whose key any bundle already owns — including `bundle` itself
|
||||||
/// owner drops it, and a `replaced` action is upgraded to `transferred`
|
/// on an update — transfers to `bundle`: the old owner drops it, and a
|
||||||
/// (removable at uninstall — plain `replaced` marks a pre-existing user
|
/// `replaced` action is upgraded to `transferred` (removable at uninstall
|
||||||
/// entry that uninstall must never delete).
|
/// — plain `replaced` marks a pre-existing user entry that uninstall must
|
||||||
|
/// never delete).
|
||||||
pub(crate) fn record_mcp_servers(
|
pub(crate) fn record_mcp_servers(
|
||||||
&mut self,
|
&mut self,
|
||||||
bundle: &str,
|
bundle: &str,
|
||||||
@@ -375,10 +401,7 @@ impl BundleStore {
|
|||||||
for mut entry in entries {
|
for mut entry in entries {
|
||||||
let key = entry.effective_key().to_string();
|
let key = entry.effective_key().to_string();
|
||||||
let mut previously_owned = false;
|
let mut previously_owned = false;
|
||||||
for (name, record) in self.bundles.iter_mut() {
|
for record in self.bundles.values_mut() {
|
||||||
if name == bundle {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let before = record.mcp_servers.len();
|
let before = record.mcp_servers.len();
|
||||||
record
|
record
|
||||||
.mcp_servers
|
.mcp_servers
|
||||||
@@ -388,14 +411,11 @@ impl BundleStore {
|
|||||||
if previously_owned && entry.action == McpAction::Replaced {
|
if previously_owned && entry.action == McpAction::Replaced {
|
||||||
entry.action = McpAction::Transferred;
|
entry.action = McpAction::Transferred;
|
||||||
}
|
}
|
||||||
let record = self
|
self.bundles
|
||||||
.bundles
|
|
||||||
.get_mut(bundle)
|
.get_mut(bundle)
|
||||||
.expect("bundle existence checked above");
|
.expect("bundle existence checked above")
|
||||||
record
|
|
||||||
.mcp_servers
|
.mcp_servers
|
||||||
.retain(|owned| owned.effective_key() != key);
|
.push(entry);
|
||||||
record.mcp_servers.push(entry);
|
|
||||||
}
|
}
|
||||||
self.save()
|
self.save()
|
||||||
}
|
}
|
||||||
@@ -624,6 +644,7 @@ mod tests {
|
|||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
action,
|
action,
|
||||||
renamed_to: renamed_to.map(str::to_string),
|
renamed_to: renamed_to.map(str::to_string),
|
||||||
|
sha256: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -811,6 +832,27 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mcp_rerecord_of_own_entry_upgrades_replaced_to_transferred() {
|
||||||
|
let dir = TempStoreDir::new("bundles-mcp-self-update");
|
||||||
|
let mut store = dir.store();
|
||||||
|
store
|
||||||
|
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.record_mcp_servers("omc", vec![mcp_record("srv", McpAction::Added, None)])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut updated = mcp_record("srv", McpAction::Replaced, None);
|
||||||
|
updated.sha256 = Some("deadbeef".to_string());
|
||||||
|
store.record_mcp_servers("omc", vec![updated]).unwrap();
|
||||||
|
|
||||||
|
let servers = &store.get("omc").unwrap().mcp_servers;
|
||||||
|
assert_eq!(servers.len(), 1);
|
||||||
|
assert_eq!(servers[0].action, McpAction::Transferred);
|
||||||
|
assert_eq!(servers[0].sha256.as_deref(), Some("deadbeef"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mcp_transfer_matches_renamed_entries_by_effective_key() {
|
fn mcp_transfer_matches_renamed_entries_by_effective_key() {
|
||||||
let dir = TempStoreDir::new("bundles-mcp-renamed");
|
let dir = TempStoreDir::new("bundles-mcp-renamed");
|
||||||
@@ -1058,6 +1100,61 @@ mod tests {
|
|||||||
assert!(result.unwrap_err().to_string().contains("ghost"));
|
assert!(result.unwrap_err().to_string().contains("ghost"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_mcp_record_drops_only_the_named_effective_key() {
|
||||||
|
let dir = TempStoreDir::new("bundles-remove-mcp");
|
||||||
|
let mut store = dir.store();
|
||||||
|
store
|
||||||
|
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.record_mcp_servers(
|
||||||
|
"omc",
|
||||||
|
vec![
|
||||||
|
mcp_record("srv", McpAction::Renamed, Some("srv-remote")),
|
||||||
|
mcp_record("other", McpAction::Added, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
store.remove_mcp_record("omc", "srv-remote").unwrap();
|
||||||
|
|
||||||
|
let reloaded = dir.store();
|
||||||
|
let keys: Vec<&str> = reloaded
|
||||||
|
.get("omc")
|
||||||
|
.unwrap()
|
||||||
|
.mcp_servers
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.effective_key())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(keys, vec!["other"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_bundle_deletes_the_record_and_persists() {
|
||||||
|
let dir = TempStoreDir::new("bundles-remove-bundle");
|
||||||
|
let mut store = dir.store();
|
||||||
|
store
|
||||||
|
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
store.remove_bundle("omc").unwrap();
|
||||||
|
|
||||||
|
let reloaded = dir.store();
|
||||||
|
assert!(reloaded.get("omc").is_none());
|
||||||
|
assert!(reloaded.bundle_names().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_bundle_unknown_name_fails() {
|
||||||
|
let dir = TempStoreDir::new("bundles-remove-bundle-unknown");
|
||||||
|
let mut store = dir.store();
|
||||||
|
|
||||||
|
let result = store.remove_bundle("ghost");
|
||||||
|
|
||||||
|
assert!(result.unwrap_err().to_string().contains("ghost"));
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn failed_save_preserves_the_existing_store() {
|
fn failed_save_preserves_the_existing_store() {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use super::bundles::{
|
use super::bundles::{
|
||||||
BundleStore, FileAction, FileRecord, InstallMetadata, McpAction, McpServerRecord, hash_file,
|
BundleStore, FileAction, FileRecord, InstallMetadata, McpAction, McpServerRecord, hash_bytes,
|
||||||
|
hash_file,
|
||||||
};
|
};
|
||||||
use crate::config::{InstallFilter, paths};
|
use crate::config::{InstallFilter, paths};
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
@@ -16,7 +17,7 @@ use serde::Deserialize;
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::ffi::{OsStr, OsString};
|
use std::ffi::{OsStr, OsString};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
pub fn install_remote(git_url: &str, filter: Option<InstallFilter>, force: bool) -> Result<()> {
|
pub fn install_remote(git_url: &str, filter: Option<InstallFilter>, force: bool) -> Result<()> {
|
||||||
let (url, reference) = parse_url_with_ref(git_url)?;
|
let (url, reference) = parse_url_with_ref(git_url)?;
|
||||||
@@ -306,6 +307,365 @@ fn apply_obsolete_action(
|
|||||||
Ok(())
|
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<String>,
|
||||||
|
kept: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uninstall a bundle: delete the files it owns, remove its mcp.json entries,
|
||||||
|
/// and drop its store record once nothing is left. `spec` is the bundle name
|
||||||
|
/// or its source URL. Locally modified items are kept unless explicitly
|
||||||
|
/// confirmed at a prompt; 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 => {
|
||||||
|
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(", "));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process the files a bundle owns under `config_dir`. Intact files (content
|
||||||
|
/// still matches the recorded hash) are deleted outright; missing files just
|
||||||
|
/// drop out of the record; modified files are kept unless confirmed for
|
||||||
|
/// deletion. A failed deletion keeps the record so a re-run can retry, and
|
||||||
|
/// never aborts the remaining files. A recorded path that could escape
|
||||||
|
/// `config_dir` (absolute, or containing anything but plain components) is
|
||||||
|
/// never touched; its record survives.
|
||||||
|
fn uninstall_owned_files(
|
||||||
|
store: &mut BundleStore,
|
||||||
|
bundle: &str,
|
||||||
|
files: &[FileRecord],
|
||||||
|
config_dir: &Path,
|
||||||
|
assume_yes: bool,
|
||||||
|
) -> Result<UninstallFileSummary> {
|
||||||
|
let mut summary = UninstallFileSummary::default();
|
||||||
|
let mut sticky: Option<ObsoleteAction> = None;
|
||||||
|
for file in files {
|
||||||
|
let recorded = Path::new(&file.path);
|
||||||
|
if recorded.is_absolute()
|
||||||
|
|| !recorded
|
||||||
|
.components()
|
||||||
|
.all(|c| matches!(c, Component::Normal(_)))
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"skipping suspicious recorded path {}; keeping its record",
|
||||||
|
file.path
|
||||||
|
);
|
||||||
|
summary.failed += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let full = config_dir.join(recorded);
|
||||||
|
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<ObsoleteAction>,
|
||||||
|
) -> Result<ObsoleteAction> {
|
||||||
|
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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a bundle's entries from mcp.json. An entry is removed outright only
|
||||||
|
/// when its current content still matches the recorded hash; a modified (or
|
||||||
|
/// legacy, hash-less) entry is kept unless confirmed for deletion, and a
|
||||||
|
/// pre-existing server the bundle replaced is never removed. Keys the bundle
|
||||||
|
/// does not own are never touched. mcp.json is written before any ownership
|
||||||
|
/// record is dropped, so 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<UninstallMcpSummary> {
|
||||||
|
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::<McpServersConfig>(&content)
|
||||||
|
.with_context(|| format!("failed to parse {}", mcp_path.display()))?,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut changed = false;
|
||||||
|
let mut released = Vec::new();
|
||||||
|
let mut sticky: Option<ObsoleteAction> = 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> {
|
fn parse_filter(name: &str) -> Result<InstallFilter> {
|
||||||
InstallFilter::parse(name).with_context(|| {
|
InstallFilter::parse(name).with_context(|| {
|
||||||
format!(
|
format!(
|
||||||
@@ -1084,11 +1444,13 @@ fn record_mcp_merge(store: &mut BundleStore, bundle: &str, report: &McpMergeRepo
|
|||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
action: McpAction::Added,
|
action: McpAction::Added,
|
||||||
renamed_to: None,
|
renamed_to: None,
|
||||||
|
sha256: report.entry_hashes.get(name).cloned(),
|
||||||
}));
|
}));
|
||||||
entries.extend(report.replaced.iter().map(|name| McpServerRecord {
|
entries.extend(report.replaced.iter().map(|name| McpServerRecord {
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
action: McpAction::Replaced,
|
action: McpAction::Replaced,
|
||||||
renamed_to: None,
|
renamed_to: None,
|
||||||
|
sha256: report.entry_hashes.get(name).cloned(),
|
||||||
}));
|
}));
|
||||||
entries.extend(
|
entries.extend(
|
||||||
report
|
report
|
||||||
@@ -1098,6 +1460,7 @@ fn record_mcp_merge(store: &mut BundleStore, bundle: &str, report: &McpMergeRepo
|
|||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
action: McpAction::Renamed,
|
action: McpAction::Renamed,
|
||||||
renamed_to: Some(renamed_to.clone()),
|
renamed_to: Some(renamed_to.clone()),
|
||||||
|
sha256: report.entry_hashes.get(renamed_to).cloned(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
@@ -1186,6 +1549,9 @@ struct McpMergeReport {
|
|||||||
kept_local: Vec<String>,
|
kept_local: Vec<String>,
|
||||||
replaced: Vec<String>,
|
replaced: Vec<String>,
|
||||||
renamed: Vec<(String, String)>,
|
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<String, String>,
|
||||||
final_path: PathBuf,
|
final_path: PathBuf,
|
||||||
missing_secrets: Vec<String>,
|
missing_secrets: Vec<String>,
|
||||||
}
|
}
|
||||||
@@ -1226,6 +1592,7 @@ fn merge_mcp_json(
|
|||||||
kept_local: Vec::new(),
|
kept_local: Vec::new(),
|
||||||
replaced: Vec::new(),
|
replaced: Vec::new(),
|
||||||
renamed: Vec::new(),
|
renamed: Vec::new(),
|
||||||
|
entry_hashes: HashMap::new(),
|
||||||
final_path: final_path.clone(),
|
final_path: final_path.clone(),
|
||||||
missing_secrets: Vec::new(),
|
missing_secrets: Vec::new(),
|
||||||
};
|
};
|
||||||
@@ -1265,6 +1632,11 @@ fn merge_mcp_json(
|
|||||||
spec.validate(key).with_context(|| {
|
spec.validate(key).with_context(|| {
|
||||||
format!("MCP server '{key}' failed validation; refusing to write merged mcp.json")
|
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 =
|
let serialized =
|
||||||
@@ -2640,6 +3012,7 @@ mod tests {
|
|||||||
name: "srv".to_string(),
|
name: "srv".to_string(),
|
||||||
action: McpAction::Added,
|
action: McpAction::Added,
|
||||||
renamed_to: None,
|
renamed_to: None,
|
||||||
|
sha256: None,
|
||||||
}],
|
}],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -2648,6 +3021,7 @@ mod tests {
|
|||||||
kept_local: vec!["srv".to_string()],
|
kept_local: vec!["srv".to_string()],
|
||||||
replaced: Vec::new(),
|
replaced: Vec::new(),
|
||||||
renamed: Vec::new(),
|
renamed: Vec::new(),
|
||||||
|
entry_hashes: HashMap::new(),
|
||||||
final_path: dir.join("mcp.json"),
|
final_path: dir.join("mcp.json"),
|
||||||
missing_secrets: Vec::new(),
|
missing_secrets: Vec::new(),
|
||||||
};
|
};
|
||||||
@@ -3086,4 +3460,461 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let _ = fs::remove_dir_all(&src_root);
|
let _ = fs::remove_dir_all(&src_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<String>) -> 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_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).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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -32,7 +32,9 @@ pub use self::app_config::AppConfig;
|
|||||||
pub use self::app_state::AppState;
|
pub use self::app_state::AppState;
|
||||||
pub use self::bundles::list_installed_bundles;
|
pub use self::bundles::list_installed_bundles;
|
||||||
pub use self::input::Input;
|
pub use self::input::Input;
|
||||||
pub use self::install_remote::{install_remote, install_remote_from_repl_args, update_bundle};
|
pub use self::install_remote::{
|
||||||
|
install_remote, install_remote_from_repl_args, uninstall_bundle, update_bundle,
|
||||||
|
};
|
||||||
pub use self::macro_policy::{
|
pub use self::macro_policy::{
|
||||||
MacroAllowlistLevel, MacroPolicy, MacroSource, MacroState, RESERVED_MACRO_NAMES, ResolvedMacro,
|
MacroAllowlistLevel, MacroPolicy, MacroSource, MacroState, RESERVED_MACRO_NAMES, ResolvedMacro,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use super::bundles::BundleStore;
|
||||||
use super::rag_cache::{RagCache, RagKey};
|
use super::rag_cache::{RagCache, RagKey};
|
||||||
use super::session::Session;
|
use super::session::Session;
|
||||||
use super::skill::{SKILL_SCAFFOLD, Skill};
|
use super::skill::{SKILL_SCAFFOLD, Skill};
|
||||||
@@ -3265,6 +3266,18 @@ impl RequestContext {
|
|||||||
values.push("remote".to_string());
|
values.push("remote".to_string());
|
||||||
super::map_completion_values(values)
|
super::map_completion_values(values)
|
||||||
}
|
}
|
||||||
|
".uninstall" => {
|
||||||
|
let values = BundleStore::load()
|
||||||
|
.map(|store| {
|
||||||
|
store
|
||||||
|
.bundle_names()
|
||||||
|
.into_iter()
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
super::map_completion_values(values)
|
||||||
|
}
|
||||||
".macro" => {
|
".macro" => {
|
||||||
let policy = self.macro_policy();
|
let policy = self.macro_policy();
|
||||||
let mut values: Vec<(String, Option<String>)> = policy
|
let mut values: Vec<(String, Option<String>)> = policy
|
||||||
@@ -4938,6 +4951,34 @@ mod tests {
|
|||||||
assert!(!ctx.maybe_autoname_session());
|
assert!(!ctx.maybe_autoname_session());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn repl_complete_uninstall_offers_installed_bundle_names() {
|
||||||
|
let _guard = TestConfigDirGuard::new();
|
||||||
|
let mut store = BundleStore::load().unwrap();
|
||||||
|
store
|
||||||
|
.upsert_bundle(
|
||||||
|
"omc",
|
||||||
|
crate::config::bundles::InstallMetadata {
|
||||||
|
source: "https://github.com/x/omc".to_string(),
|
||||||
|
git_ref: None,
|
||||||
|
commit: "abc123".to_string(),
|
||||||
|
version: None,
|
||||||
|
description: None,
|
||||||
|
homepage: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let ctx = create_test_ctx();
|
||||||
|
|
||||||
|
let values = ctx.repl_complete(".uninstall", &[""], "");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
values.iter().any(|(name, _)| name == "omc"),
|
||||||
|
"got: {values:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn exit_agent_clears_all_agent_state() {
|
fn exit_agent_clears_all_agent_state() {
|
||||||
|
|||||||
@@ -142,6 +142,10 @@ async fn main() -> Result<()> {
|
|||||||
return config::update_bundle(spec);
|
return config::update_bundle(spec);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(name) = cli.uninstall.as_deref() {
|
||||||
|
return config::uninstall_bundle(name, cli.yes);
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(client_arg) = &cli.authenticate {
|
if let Some(client_arg) = &cli.authenticate {
|
||||||
let cfg = Config::load_with_interpolation(true).await?;
|
let cfg = Config::load_with_interpolation(true).await?;
|
||||||
let app_config = AppConfig::from_config(cfg)?;
|
let app_config = AppConfig::from_config(cfg)?;
|
||||||
|
|||||||
+14
-3
@@ -53,7 +53,7 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {"
|
|||||||
4. Continue with the next pending item now. Call tools immediately."
|
4. Continue with the next pending item now. Call tools immediately."
|
||||||
};
|
};
|
||||||
|
|
||||||
static REPL_COMMANDS: LazyLock<[ReplCommand; 60]> = LazyLock::new(|| {
|
static REPL_COMMANDS: LazyLock<[ReplCommand; 61]> = LazyLock::new(|| {
|
||||||
[
|
[
|
||||||
ReplCommand::new(".help", "Show this help guide", AssertState::pass()),
|
ReplCommand::new(".help", "Show this help guide", AssertState::pass()),
|
||||||
ReplCommand::new(".info", "Show system info", AssertState::pass()),
|
ReplCommand::new(".info", "Show system info", AssertState::pass()),
|
||||||
@@ -320,6 +320,11 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 60]> = LazyLock::new(|| {
|
|||||||
"Reinstall bundled assets, or install assets from a remote git repo (.install remote <url>)",
|
"Reinstall bundled assets, or install assets from a remote git repo (.install remote <url>)",
|
||||||
AssertState::pass(),
|
AssertState::pass(),
|
||||||
),
|
),
|
||||||
|
ReplCommand::new(
|
||||||
|
".uninstall",
|
||||||
|
"Uninstall an installed bundle (delete its owned files and MCP entries)",
|
||||||
|
AssertState::pass(),
|
||||||
|
),
|
||||||
ReplCommand::new(
|
ReplCommand::new(
|
||||||
".update",
|
".update",
|
||||||
"Update Coyote to the latest release (or a specified version)",
|
"Update Coyote to the latest release (or a specified version)",
|
||||||
@@ -1202,6 +1207,12 @@ pub async fn run_repl_command(
|
|||||||
println!("Usage: .delete <role|session|rag|macro|skill|agent-data>")
|
println!("Usage: .delete <role|session|rag|macro|skill|agent-data>")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
".uninstall" => match args {
|
||||||
|
Some(args) => {
|
||||||
|
config::uninstall_bundle(args.trim(), false)?;
|
||||||
|
}
|
||||||
|
_ => println!("Usage: .uninstall <bundle-name>"),
|
||||||
|
},
|
||||||
".list" => match args {
|
".list" => match args {
|
||||||
Some(args) => {
|
Some(args) => {
|
||||||
ctx.list_assets(args.trim())?;
|
ctx.list_assets(args.trim())?;
|
||||||
@@ -1791,8 +1802,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn repl_commands_has_60_entries() {
|
fn repl_commands_has_61_entries() {
|
||||||
assert_eq!(REPL_COMMANDS.len(), 60);
|
assert_eq!(REPL_COMMANDS.len(), 61);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user