feat: add --uninstall and .uninstall for installed bundles

This commit is contained in:
2026-08-24 11:15:12 -06:00
parent 0a806da8d2
commit 0e5d85f2ff
7 changed files with 1029 additions and 22 deletions
+112 -15
View File
@@ -43,6 +43,10 @@ pub(crate) struct McpServerRecord {
pub(crate) name: String,
pub(crate) action: McpAction,
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 {
@@ -171,7 +175,6 @@ impl BundleStore {
/// Look up the record installed from `url`, comparing canonical source URLs
/// 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)> {
let canonical = canonical_source_url(url);
self.bundles
@@ -342,6 +345,28 @@ impl BundleStore {
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
/// partway through still has provenance for everything already on disk.
/// 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.
/// An entry whose key another bundle owns transfers to `bundle`: the old
/// owner drops it, and a `replaced` action is upgraded to `transferred`
/// (removable at uninstall — plain `replaced` marks a pre-existing user
/// entry that uninstall must never delete).
/// An entry whose key any bundle already owns — including `bundle` itself
/// on an update — transfers to `bundle`: the old owner drops it, and a
/// `replaced` action is upgraded to `transferred` (removable at uninstall
/// — plain `replaced` marks a pre-existing user entry that uninstall must
/// never delete).
pub(crate) fn record_mcp_servers(
&mut self,
bundle: &str,
@@ -375,10 +401,7 @@ impl BundleStore {
for mut entry in entries {
let key = entry.effective_key().to_string();
let mut previously_owned = false;
for (name, record) in self.bundles.iter_mut() {
if name == bundle {
continue;
}
for record in self.bundles.values_mut() {
let before = record.mcp_servers.len();
record
.mcp_servers
@@ -388,14 +411,11 @@ impl BundleStore {
if previously_owned && entry.action == McpAction::Replaced {
entry.action = McpAction::Transferred;
}
let record = self
.bundles
self.bundles
.get_mut(bundle)
.expect("bundle existence checked above");
record
.expect("bundle existence checked above")
.mcp_servers
.retain(|owned| owned.effective_key() != key);
record.mcp_servers.push(entry);
.push(entry);
}
self.save()
}
@@ -624,6 +644,7 @@ mod tests {
name: name.to_string(),
action,
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]
fn mcp_transfer_matches_renamed_entries_by_effective_key() {
let dir = TempStoreDir::new("bundles-mcp-renamed");
@@ -1058,6 +1100,61 @@ mod tests {
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)]
#[test]
fn failed_save_preserves_the_existing_store() {