Skip to main content

atomcode_core/plugin/
marketplace.rs

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
10/// Sanitize a name into a path-safe segment (CC convention).
11pub(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
25/// Clone a marketplace, parse its manifest, and persist registration.
26/// Caller is responsible for showing UX (spinner). This call blocks on git.
27pub 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    // Clone into a temp directory inside marketplaces/ so we can read the
35    // manifest, then determine the canonical (sanitized) name and rename to
36    // its final location atomically. This avoids the prior key/dir mismatch
37    // when manifest.name differs from the URL tail.
38    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    // Sanitize the chosen name so a hostile manifest cannot escape the
76    // marketplaces/ directory via "..", "/" or absolute paths.
77    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
125/// Decide the marketplace name + plugin list. When manifest is absent, fall
126/// back to single-plugin mode where mp_name = plugin_name = directory name.
127pub(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    // Refuse if any installed plugin still references this marketplace.
180    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    /// B1 regression: when manifest.name differs from the URL tail, the
340    /// directory must be renamed so the registered key matches the on-disk
341    /// path. Otherwise update_marketplace cannot find the working tree.
342    #[test]
343    #[serial_test::serial]
344    fn add_marketplace_canonical_name_differs_from_url_tail() {
345        let _home = isolated_home();
346        // Repo on disk is "url-tail-name", but manifest declares
347        // "canonical-name".
348        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        // Directory must be at marketplaces/canonical-name, not
357        // marketplaces/url-tail-name. update_marketplace exercises this:
358        // it computes the working directory from the registered key.
359        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    /// B2 regression: a manifest whose `name` contains traversal or
368    /// separators must be sanitized; the marketplace must land inside
369    /// `marketplaces/`, not somewhere else on disk.
370    #[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        // "../evil" -> "---evil" after sanitize_name (3 specials become 3 dashes).
381        assert_eq!(info.name, "---evil");
382
383        let mp_root = paths::marketplaces_root().unwrap();
384        assert!(mp_root.join("---evil").exists());
385        // Crucially: nothing landed in the parent of mp_root.
386        assert!(!mp_root.parent().unwrap().join("evil").exists());
387    }
388}