lean_ctx/core/addons/
install.rs1use super::manifest::AddonManifest;
11use super::store::{InstalledAddon, InstalledStore};
12use crate::core::config::Config;
13
14pub struct InstallOutcome {
16 pub name: String,
17 pub gateway_server: String,
18 pub enabled_gateway: bool,
20}
21
22pub fn install(manifest: &AddonManifest, source: &str) -> Result<InstallOutcome, String> {
28 manifest.validate()?;
29 let server = manifest.to_gateway_server();
30 server.resolve().map_err(|e| {
31 format!(
32 "addon `{}` has no runnable MCP endpoint: {e}",
33 manifest.addon.name
34 )
35 })?;
36
37 let name = manifest.addon.name.clone();
38 let server_name = server.name.clone();
39 let mut enabled_gateway = false;
40
41 Config::update_global(|cfg| {
42 if !cfg.gateway.enabled {
43 cfg.gateway.enabled = true;
44 enabled_gateway = true;
45 }
46 cfg.gateway.servers.retain(|s| s.name != server_name);
47 cfg.gateway.servers.push(server.clone());
48 })
49 .map_err(|e| e.to_string())?;
50
51 let mut store = InstalledStore::load();
52 store.upsert(InstalledAddon {
53 name: name.clone(),
54 version: manifest.addon.version.clone(),
55 source: source.to_string(),
56 gateway_server: server_name.clone(),
57 });
58 store.save()?;
59
60 crate::core::gateway::catalog::invalidate();
61
62 Ok(InstallOutcome {
63 name,
64 gateway_server: server_name,
65 enabled_gateway,
66 })
67}
68
69pub struct RemoveOutcome {
71 pub name: String,
72 pub gateway_server: String,
73 pub last_removed: bool,
75}
76
77pub fn remove(name: &str) -> Result<RemoveOutcome, String> {
79 let mut store = InstalledStore::load();
80 let entry = store
81 .get(name)
82 .cloned()
83 .ok_or_else(|| format!("addon `{name}` is not installed"))?;
84 let server_name = entry.gateway_server.clone();
85
86 Config::update_global(|cfg| {
87 cfg.gateway.servers.retain(|s| s.name != server_name);
88 })
89 .map_err(|e| e.to_string())?;
90
91 store.remove(name);
92 let last_removed = store.addons.is_empty();
93 store.save()?;
94
95 crate::core::gateway::catalog::invalidate();
96
97 Ok(RemoveOutcome {
98 name: name.to_string(),
99 gateway_server: server_name,
100 last_removed,
101 })
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107 use crate::core::data_dir::isolated_data_dir;
108
109 fn manifest(name: &str) -> AddonManifest {
110 AddonManifest::from_toml(&format!(
111 "[addon]\nname = \"{name}\"\nversion = \"0.1.0\"\n\
112 [mcp]\ntransport = \"stdio\"\ncommand = \"{name}-mcp\"\n"
113 ))
114 .expect("parse")
115 }
116
117 #[test]
118 fn install_then_remove_round_trip() {
119 let _iso = isolated_data_dir();
120
121 let out = install(&manifest("demo"), "registry").expect("install");
122 assert_eq!(out.gateway_server, "demo");
123 assert!(out.enabled_gateway, "gateway was off, install enables it");
124
125 let cfg = Config::load();
127 assert!(cfg.gateway.enabled);
128 assert!(cfg.gateway.servers.iter().any(|s| s.name == "demo"));
129
130 assert!(InstalledStore::load().get("demo").is_some());
132
133 let out2 = install(&manifest("demo"), "registry").expect("reinstall");
135 assert!(!out2.enabled_gateway, "already enabled");
136 let cfg = Config::load();
137 assert_eq!(
138 cfg.gateway
139 .servers
140 .iter()
141 .filter(|s| s.name == "demo")
142 .count(),
143 1
144 );
145
146 let rm = remove("demo").expect("remove");
148 assert!(rm.last_removed);
149 let cfg = Config::load();
150 assert!(!cfg.gateway.servers.iter().any(|s| s.name == "demo"));
151 assert!(InstalledStore::load().get("demo").is_none());
152 }
153
154 #[test]
155 fn remove_unknown_is_error() {
156 let _iso = isolated_data_dir();
157 assert!(remove("nope").is_err());
158 }
159
160 #[test]
161 fn listed_only_manifest_refuses_install() {
162 let _iso = isolated_data_dir();
163 let listed = AddonManifest::from_toml("[addon]\nname = \"listed\"\n").expect("parse");
164 assert!(install(&listed, "registry").is_err());
165 }
166}