1use anyhow::{anyhow, bail, Context, Result};
2use std::path::Path;
3use std::process::Command;
4
5use super::manifest::{load_marketplace_manifest, MarketplaceManifest, PluginSource};
6use super::paths;
7use super::state::{load_marketplaces_file, save_marketplaces_file, MarketplaceEntry};
8use super::url::{infer_marketplace_name_from_url, validate_git_url};
9
10pub(super) fn sanitize_name(name: &str) -> String {
12 name.chars()
13 .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' })
14 .collect()
15}
16
17#[derive(Debug, Clone)]
18pub struct MarketplaceInfo {
19 pub name: String,
20 pub source: String,
21 pub git_commit: String,
22 pub plugins: Vec<String>,
23}
24
25pub fn add_marketplace(url: &str) -> Result<MarketplaceInfo> {
28 validate_git_url(url)?;
29 let url_tail = infer_marketplace_name_from_url(url)?;
30
31 let mp_root = paths::marketplaces_root().ok_or_else(|| anyhow!("no plugin home"))?;
32 std::fs::create_dir_all(&mp_root).ok();
33
34 let tmp_suffix: u128 = {
39 use std::time::{SystemTime, UNIX_EPOCH};
40 SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0)
41 };
42 let tmp_dir = mp_root.join(format!(".tmp-{}-{}", std::process::id(), tmp_suffix));
43 if tmp_dir.exists() {
44 std::fs::remove_dir_all(&tmp_dir).ok();
45 }
46
47 let cleanup = |p: &Path| {
48 if p.exists() {
49 std::fs::remove_dir_all(p).ok();
50 }
51 };
52
53 if let Err(e) = git_clone(url, &tmp_dir) {
54 cleanup(&tmp_dir);
55 return Err(e).with_context(|| format!("clone {}", url));
56 }
57
58 let commit = match git_rev_parse(&tmp_dir) {
59 Ok(c) => c,
60 Err(e) => {
61 cleanup(&tmp_dir);
62 return Err(e);
63 }
64 };
65
66 let manifest = match load_marketplace_manifest(&tmp_dir) {
67 Ok(m) => m,
68 Err(e) => {
69 cleanup(&tmp_dir);
70 return Err(e);
71 }
72 };
73
74 let (raw_mp_name, plugins) = resolve_marketplace_identity(&manifest, &url_tail);
75 let mp_name = sanitize_name(&raw_mp_name);
78 if mp_name.is_empty() {
79 cleanup(&tmp_dir);
80 bail!("marketplace name `{}` sanitized to empty string", raw_mp_name);
81 }
82
83 let target = mp_root.join(&mp_name);
84
85 let mp_file = paths::marketplaces_file().unwrap();
86 let mut state = load_marketplaces_file(&mp_file)?;
87 if state.marketplaces.contains_key(&mp_name) {
88 cleanup(&tmp_dir);
89 bail!("marketplace `{}` already exists; remove first", mp_name);
90 }
91 if target.exists() {
92 cleanup(&tmp_dir);
93 bail!(
94 "directory {} already exists but is not registered; remove it manually",
95 target.display()
96 );
97 }
98
99 if let Err(e) = std::fs::rename(&tmp_dir, &target) {
100 cleanup(&tmp_dir);
101 return Err(anyhow!("rename {} -> {}: {}", tmp_dir.display(), target.display(), e));
102 }
103
104 let plugins_list = plugins.iter().map(|p| sanitize_name(&p.name)).collect::<Vec<_>>();
105
106 state.marketplaces.insert(
107 mp_name.clone(),
108 MarketplaceEntry {
109 source: url.to_string(),
110 added_at: now_rfc3339(),
111 git_commit: commit.clone(),
112 plugins: plugins_list.clone(),
113 },
114 );
115 save_marketplaces_file(&mp_file, &state)?;
116
117 Ok(MarketplaceInfo {
118 name: mp_name,
119 source: url.to_string(),
120 git_commit: commit,
121 plugins: plugins_list,
122 })
123}
124
125pub(super) fn resolve_marketplace_identity(
128 manifest: &Option<MarketplaceManifest>,
129 dir_name: &str,
130) -> (String, Vec<super::manifest::PluginEntry>) {
131 use super::manifest::PluginEntry;
132 match manifest {
133 Some(m) => (m.name.clone(), m.plugins.clone()),
134 None => (
135 dir_name.to_string(),
136 vec![PluginEntry {
137 name: dir_name.to_string(),
138 source: PluginSource::Inline("./".into()),
139 description: None,
140 }],
141 ),
142 }
143}
144
145fn git_clone(url: &str, target: &Path) -> Result<()> {
146 let out = Command::new("git")
147 .args(["clone", "--depth", "1", url])
148 .arg(target)
149 .output()
150 .context("spawn git")?;
151 if !out.status.success() {
152 bail!("git clone failed: {}", String::from_utf8_lossy(&out.stderr));
153 }
154 Ok(())
155}
156
157fn git_rev_parse(repo: &Path) -> Result<String> {
158 let out = Command::new("git")
159 .args(["rev-parse", "HEAD"])
160 .current_dir(repo)
161 .output()
162 .context("spawn git rev-parse")?;
163 if !out.status.success() {
164 bail!("git rev-parse failed: {}", String::from_utf8_lossy(&out.stderr));
165 }
166 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
167}
168
169fn now_rfc3339() -> String {
170 chrono::Utc::now().to_rfc3339()
171}
172
173pub fn remove_marketplace(name: &str) -> Result<()> {
174 let mp_file = paths::marketplaces_file().ok_or_else(|| anyhow!("no plugin home"))?;
175 let mut state = load_marketplaces_file(&mp_file)?;
176 if !state.marketplaces.contains_key(name) {
177 bail!("marketplace `{}` not found", name);
178 }
179 let installed = super::state::load_installed_plugins_file(
181 &paths::installed_plugins_file().unwrap(),
182 )?;
183 if installed
184 .plugins
185 .values()
186 .any(|p| p.marketplace == name)
187 {
188 bail!(
189 "marketplace `{}` has installed plugins; uninstall them first",
190 name
191 );
192 }
193 let target = paths::marketplaces_root().unwrap().join(name);
194 if target.exists() {
195 std::fs::remove_dir_all(&target).ok();
196 }
197 state.marketplaces.remove(name);
198 save_marketplaces_file(&mp_file, &state)?;
199 Ok(())
200}
201
202pub fn update_marketplace(name: &str) -> Result<MarketplaceInfo> {
203 let mp_file = paths::marketplaces_file().ok_or_else(|| anyhow!("no plugin home"))?;
204 let mut state = load_marketplaces_file(&mp_file)?;
205 let entry = state
206 .marketplaces
207 .get(name)
208 .ok_or_else(|| anyhow!("marketplace `{}` not found", name))?
209 .clone();
210 let target = paths::marketplaces_root().unwrap().join(name);
211 let out = Command::new("git")
212 .args(["pull", "--ff-only"])
213 .current_dir(&target)
214 .output()
215 .context("spawn git pull")?;
216 if !out.status.success() {
217 bail!("git pull failed: {}", String::from_utf8_lossy(&out.stderr));
218 }
219 let commit = git_rev_parse(&target)?;
220 let manifest = load_marketplace_manifest(&target)?;
221 let (_mp_name, plugins) = resolve_marketplace_identity(&manifest, name);
222 let plugins_list: Vec<String> = plugins.iter().map(|p| sanitize_name(&p.name)).collect();
223 state.marketplaces.insert(
224 name.to_string(),
225 MarketplaceEntry {
226 source: entry.source.clone(),
227 added_at: entry.added_at.clone(),
228 git_commit: commit.clone(),
229 plugins: plugins_list.clone(),
230 },
231 );
232 save_marketplaces_file(&mp_file, &state)?;
233 Ok(MarketplaceInfo {
234 name: name.to_string(),
235 source: entry.source,
236 git_commit: commit,
237 plugins: plugins_list,
238 })
239}
240
241pub fn list_marketplaces() -> Result<Vec<MarketplaceInfo>> {
242 let mp_file = paths::marketplaces_file().ok_or_else(|| anyhow!("no plugin home"))?;
243 let state = load_marketplaces_file(&mp_file)?;
244 Ok(state
245 .marketplaces
246 .into_iter()
247 .map(|(name, e)| MarketplaceInfo {
248 name,
249 source: e.source,
250 git_commit: e.git_commit,
251 plugins: e.plugins,
252 })
253 .collect())
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259 use crate::plugin::test_support::isolated_home;
260 use std::path::PathBuf;
261
262 fn make_bare_repo_with_manifest(name: &str, manifest: Option<&str>) -> PathBuf {
263 let work = tempfile::tempdir().unwrap().keep();
264 let repo = work.join(name);
265 std::fs::create_dir_all(&repo).unwrap();
266 Command::new("git").args(["init", "-q"]).current_dir(&repo).status().unwrap();
267 Command::new("git").args(["config", "user.email", "t@t"]).current_dir(&repo).status().unwrap();
268 Command::new("git").args(["config", "user.name", "t"]).current_dir(&repo).status().unwrap();
269 if let Some(m) = manifest {
270 std::fs::create_dir_all(repo.join(".atomcode-plugin")).unwrap();
271 std::fs::write(repo.join(".atomcode-plugin/marketplace.json"), m).unwrap();
272 }
273 std::fs::write(repo.join("README"), "x").unwrap();
274 Command::new("git").args(["add", "-A"]).current_dir(&repo).status().unwrap();
275 Command::new("git").args(["commit", "-q", "-m", "init"]).current_dir(&repo).status().unwrap();
276 repo
277 }
278
279 #[test]
280 #[serial_test::serial]
281 fn add_marketplace_with_manifest() {
282 let _home = isolated_home();
283 let repo = make_bare_repo_with_manifest(
284 "ascend-model-agent-plugin",
285 Some(r#"{"name":"ascend-model-agent-plugin","plugins":[{"name":"ascend-model-agent-plugin","source":"./"}]}"#),
286 );
287 let url = format!("file://{}", repo.display());
288 let info = add_marketplace(&url).unwrap();
289 assert_eq!(info.name, "ascend-model-agent-plugin");
290 assert_eq!(info.plugins, vec!["ascend-model-agent-plugin"]);
291 assert!(!info.git_commit.is_empty());
292 }
293
294 #[test]
295 #[serial_test::serial]
296 fn add_marketplace_single_plugin_fallback() {
297 let _home = isolated_home();
298 let repo = make_bare_repo_with_manifest("solo-plugin", None);
299 let url = format!("file://{}", repo.display());
300 let info = add_marketplace(&url).unwrap();
301 assert_eq!(info.name, "solo-plugin");
302 assert_eq!(info.plugins, vec!["solo-plugin"]);
303 }
304
305 #[test]
306 #[serial_test::serial]
307 fn add_marketplace_rejects_duplicate() {
308 let _home = isolated_home();
309 let repo = make_bare_repo_with_manifest("dup", None);
310 let url = format!("file://{}", repo.display());
311 add_marketplace(&url).unwrap();
312 let err = add_marketplace(&url).unwrap_err();
313 assert!(err.to_string().contains("already exists"));
314 }
315
316 #[test]
317 #[serial_test::serial]
318 fn remove_marketplace_works() {
319 let _home = isolated_home();
320 let repo = make_bare_repo_with_manifest("rm-mp", None);
321 let url = format!("file://{}", repo.display());
322 add_marketplace(&url).unwrap();
323 remove_marketplace("rm-mp").unwrap();
324 assert!(list_marketplaces().unwrap().is_empty());
325 }
326
327 #[test]
328 #[serial_test::serial]
329 fn list_marketplaces_returns_added() {
330 let _home = isolated_home();
331 let repo = make_bare_repo_with_manifest("list-mp", None);
332 let url = format!("file://{}", repo.display());
333 add_marketplace(&url).unwrap();
334 let list = list_marketplaces().unwrap();
335 assert_eq!(list.len(), 1);
336 assert_eq!(list[0].name, "list-mp");
337 }
338
339 #[test]
343 #[serial_test::serial]
344 fn add_marketplace_canonical_name_differs_from_url_tail() {
345 let _home = isolated_home();
346 let repo = make_bare_repo_with_manifest(
349 "url-tail-name",
350 Some(r#"{"name":"canonical-name","plugins":[{"name":"canonical-name","source":"./"}]}"#),
351 );
352 let url = format!("file://{}", repo.display());
353 let info = add_marketplace(&url).unwrap();
354 assert_eq!(info.name, "canonical-name");
355
356 let updated = update_marketplace("canonical-name").unwrap();
360 assert_eq!(updated.name, "canonical-name");
361
362 let mp_root = paths::marketplaces_root().unwrap();
363 assert!(mp_root.join("canonical-name").exists());
364 assert!(!mp_root.join("url-tail-name").exists());
365 }
366
367 #[test]
371 #[serial_test::serial]
372 fn add_marketplace_sanitizes_traversal_in_manifest_name() {
373 let _home = isolated_home();
374 let repo = make_bare_repo_with_manifest(
375 "evil-source",
376 Some(r#"{"name":"../evil","plugins":[{"name":"p","source":"./"}]}"#),
377 );
378 let url = format!("file://{}", repo.display());
379 let info = add_marketplace(&url).unwrap();
380 assert_eq!(info.name, "---evil");
382
383 let mp_root = paths::marketplaces_root().unwrap();
384 assert!(mp_root.join("---evil").exists());
385 assert!(!mp_root.parent().unwrap().join("evil").exists());
387 }
388}