use anyhow::{anyhow, bail, Context, Result};
use std::path::Path;
use std::process::Command;
use super::manifest::{load_marketplace_manifest, MarketplaceManifest, PluginSource};
use super::paths;
use super::state::{load_marketplaces_file, save_marketplaces_file, MarketplaceEntry};
use super::url::{infer_marketplace_name_from_url, validate_git_url};
pub(super) fn sanitize_name(name: &str) -> String {
name.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' })
.collect()
}
#[derive(Debug, Clone)]
pub struct MarketplaceInfo {
pub name: String,
pub source: String,
pub git_commit: String,
pub plugins: Vec<String>,
}
pub fn add_marketplace(url: &str) -> Result<MarketplaceInfo> {
validate_git_url(url)?;
let url_tail = infer_marketplace_name_from_url(url)?;
let mp_root = paths::marketplaces_root().ok_or_else(|| anyhow!("no plugin home"))?;
std::fs::create_dir_all(&mp_root).ok();
let tmp_suffix: u128 = {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0)
};
let tmp_dir = mp_root.join(format!(".tmp-{}-{}", std::process::id(), tmp_suffix));
if tmp_dir.exists() {
std::fs::remove_dir_all(&tmp_dir).ok();
}
let cleanup = |p: &Path| {
if p.exists() {
std::fs::remove_dir_all(p).ok();
}
};
if let Err(e) = git_clone(url, &tmp_dir) {
cleanup(&tmp_dir);
return Err(e).with_context(|| format!("clone {}", url));
}
let commit = match git_rev_parse(&tmp_dir) {
Ok(c) => c,
Err(e) => {
cleanup(&tmp_dir);
return Err(e);
}
};
let manifest = match load_marketplace_manifest(&tmp_dir) {
Ok(m) => m,
Err(e) => {
cleanup(&tmp_dir);
return Err(e);
}
};
let (raw_mp_name, plugins) = resolve_marketplace_identity(&manifest, &url_tail);
let mp_name = sanitize_name(&raw_mp_name);
if mp_name.is_empty() {
cleanup(&tmp_dir);
bail!("marketplace name `{}` sanitized to empty string", raw_mp_name);
}
let target = mp_root.join(&mp_name);
let mp_file = paths::marketplaces_file().unwrap();
let mut state = load_marketplaces_file(&mp_file)?;
if state.marketplaces.contains_key(&mp_name) {
cleanup(&tmp_dir);
bail!("marketplace `{}` already exists; remove first", mp_name);
}
if target.exists() {
cleanup(&tmp_dir);
bail!(
"directory {} already exists but is not registered; remove it manually",
target.display()
);
}
if let Err(e) = std::fs::rename(&tmp_dir, &target) {
cleanup(&tmp_dir);
return Err(anyhow!("rename {} -> {}: {}", tmp_dir.display(), target.display(), e));
}
let plugins_list = plugins.iter().map(|p| sanitize_name(&p.name)).collect::<Vec<_>>();
state.marketplaces.insert(
mp_name.clone(),
MarketplaceEntry {
source: url.to_string(),
added_at: now_rfc3339(),
git_commit: commit.clone(),
plugins: plugins_list.clone(),
},
);
save_marketplaces_file(&mp_file, &state)?;
Ok(MarketplaceInfo {
name: mp_name,
source: url.to_string(),
git_commit: commit,
plugins: plugins_list,
})
}
pub(super) fn resolve_marketplace_identity(
manifest: &Option<MarketplaceManifest>,
dir_name: &str,
) -> (String, Vec<super::manifest::PluginEntry>) {
use super::manifest::PluginEntry;
match manifest {
Some(m) => (m.name.clone(), m.plugins.clone()),
None => (
dir_name.to_string(),
vec![PluginEntry {
name: dir_name.to_string(),
source: PluginSource::Inline("./".into()),
description: None,
}],
),
}
}
fn git_clone(url: &str, target: &Path) -> Result<()> {
let out = Command::new("git")
.args(["clone", "--depth", "1", url])
.arg(target)
.output()
.context("spawn git")?;
if !out.status.success() {
bail!("git clone failed: {}", String::from_utf8_lossy(&out.stderr));
}
Ok(())
}
fn git_rev_parse(repo: &Path) -> Result<String> {
let out = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo)
.output()
.context("spawn git rev-parse")?;
if !out.status.success() {
bail!("git rev-parse failed: {}", String::from_utf8_lossy(&out.stderr));
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
fn now_rfc3339() -> String {
chrono::Utc::now().to_rfc3339()
}
pub fn remove_marketplace(name: &str) -> Result<()> {
let mp_file = paths::marketplaces_file().ok_or_else(|| anyhow!("no plugin home"))?;
let mut state = load_marketplaces_file(&mp_file)?;
if !state.marketplaces.contains_key(name) {
bail!("marketplace `{}` not found", name);
}
let installed = super::state::load_installed_plugins_file(
&paths::installed_plugins_file().unwrap(),
)?;
if installed
.plugins
.values()
.any(|p| p.marketplace == name)
{
bail!(
"marketplace `{}` has installed plugins; uninstall them first",
name
);
}
let target = paths::marketplaces_root().unwrap().join(name);
if target.exists() {
std::fs::remove_dir_all(&target).ok();
}
state.marketplaces.remove(name);
save_marketplaces_file(&mp_file, &state)?;
Ok(())
}
pub fn update_marketplace(name: &str) -> Result<MarketplaceInfo> {
let mp_file = paths::marketplaces_file().ok_or_else(|| anyhow!("no plugin home"))?;
let mut state = load_marketplaces_file(&mp_file)?;
let entry = state
.marketplaces
.get(name)
.ok_or_else(|| anyhow!("marketplace `{}` not found", name))?
.clone();
let target = paths::marketplaces_root().unwrap().join(name);
let out = Command::new("git")
.args(["pull", "--ff-only"])
.current_dir(&target)
.output()
.context("spawn git pull")?;
if !out.status.success() {
bail!("git pull failed: {}", String::from_utf8_lossy(&out.stderr));
}
let commit = git_rev_parse(&target)?;
let manifest = load_marketplace_manifest(&target)?;
let (_mp_name, plugins) = resolve_marketplace_identity(&manifest, name);
let plugins_list: Vec<String> = plugins.iter().map(|p| sanitize_name(&p.name)).collect();
state.marketplaces.insert(
name.to_string(),
MarketplaceEntry {
source: entry.source.clone(),
added_at: entry.added_at.clone(),
git_commit: commit.clone(),
plugins: plugins_list.clone(),
},
);
save_marketplaces_file(&mp_file, &state)?;
Ok(MarketplaceInfo {
name: name.to_string(),
source: entry.source,
git_commit: commit,
plugins: plugins_list,
})
}
pub fn list_marketplaces() -> Result<Vec<MarketplaceInfo>> {
let mp_file = paths::marketplaces_file().ok_or_else(|| anyhow!("no plugin home"))?;
let state = load_marketplaces_file(&mp_file)?;
Ok(state
.marketplaces
.into_iter()
.map(|(name, e)| MarketplaceInfo {
name,
source: e.source,
git_commit: e.git_commit,
plugins: e.plugins,
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plugin::test_support::isolated_home;
use std::path::PathBuf;
fn make_bare_repo_with_manifest(name: &str, manifest: Option<&str>) -> PathBuf {
let work = tempfile::tempdir().unwrap().keep();
let repo = work.join(name);
std::fs::create_dir_all(&repo).unwrap();
Command::new("git").args(["init", "-q"]).current_dir(&repo).status().unwrap();
Command::new("git").args(["config", "user.email", "t@t"]).current_dir(&repo).status().unwrap();
Command::new("git").args(["config", "user.name", "t"]).current_dir(&repo).status().unwrap();
if let Some(m) = manifest {
std::fs::create_dir_all(repo.join(".atomcode-plugin")).unwrap();
std::fs::write(repo.join(".atomcode-plugin/marketplace.json"), m).unwrap();
}
std::fs::write(repo.join("README"), "x").unwrap();
Command::new("git").args(["add", "-A"]).current_dir(&repo).status().unwrap();
Command::new("git").args(["commit", "-q", "-m", "init"]).current_dir(&repo).status().unwrap();
repo
}
#[test]
#[serial_test::serial]
fn add_marketplace_with_manifest() {
let _home = isolated_home();
let repo = make_bare_repo_with_manifest(
"ascend-model-agent-plugin",
Some(r#"{"name":"ascend-model-agent-plugin","plugins":[{"name":"ascend-model-agent-plugin","source":"./"}]}"#),
);
let url = format!("file://{}", repo.display());
let info = add_marketplace(&url).unwrap();
assert_eq!(info.name, "ascend-model-agent-plugin");
assert_eq!(info.plugins, vec!["ascend-model-agent-plugin"]);
assert!(!info.git_commit.is_empty());
}
#[test]
#[serial_test::serial]
fn add_marketplace_single_plugin_fallback() {
let _home = isolated_home();
let repo = make_bare_repo_with_manifest("solo-plugin", None);
let url = format!("file://{}", repo.display());
let info = add_marketplace(&url).unwrap();
assert_eq!(info.name, "solo-plugin");
assert_eq!(info.plugins, vec!["solo-plugin"]);
}
#[test]
#[serial_test::serial]
fn add_marketplace_rejects_duplicate() {
let _home = isolated_home();
let repo = make_bare_repo_with_manifest("dup", None);
let url = format!("file://{}", repo.display());
add_marketplace(&url).unwrap();
let err = add_marketplace(&url).unwrap_err();
assert!(err.to_string().contains("already exists"));
}
#[test]
#[serial_test::serial]
fn remove_marketplace_works() {
let _home = isolated_home();
let repo = make_bare_repo_with_manifest("rm-mp", None);
let url = format!("file://{}", repo.display());
add_marketplace(&url).unwrap();
remove_marketplace("rm-mp").unwrap();
assert!(list_marketplaces().unwrap().is_empty());
}
#[test]
#[serial_test::serial]
fn list_marketplaces_returns_added() {
let _home = isolated_home();
let repo = make_bare_repo_with_manifest("list-mp", None);
let url = format!("file://{}", repo.display());
add_marketplace(&url).unwrap();
let list = list_marketplaces().unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].name, "list-mp");
}
#[test]
#[serial_test::serial]
fn add_marketplace_canonical_name_differs_from_url_tail() {
let _home = isolated_home();
let repo = make_bare_repo_with_manifest(
"url-tail-name",
Some(r#"{"name":"canonical-name","plugins":[{"name":"canonical-name","source":"./"}]}"#),
);
let url = format!("file://{}", repo.display());
let info = add_marketplace(&url).unwrap();
assert_eq!(info.name, "canonical-name");
let updated = update_marketplace("canonical-name").unwrap();
assert_eq!(updated.name, "canonical-name");
let mp_root = paths::marketplaces_root().unwrap();
assert!(mp_root.join("canonical-name").exists());
assert!(!mp_root.join("url-tail-name").exists());
}
#[test]
#[serial_test::serial]
fn add_marketplace_sanitizes_traversal_in_manifest_name() {
let _home = isolated_home();
let repo = make_bare_repo_with_manifest(
"evil-source",
Some(r#"{"name":"../evil","plugins":[{"name":"p","source":"./"}]}"#),
);
let url = format!("file://{}", repo.display());
let info = add_marketplace(&url).unwrap();
assert_eq!(info.name, "---evil");
let mp_root = paths::marketplaces_root().unwrap();
assert!(mp_root.join("---evil").exists());
assert!(!mp_root.parent().unwrap().join("evil").exists());
}
}