Codex Handbook
core-plugins/src/marketplace_add.rs 402 lines
use crate::installed_marketplaces::marketplace_install_root;use crate::is_openai_curated_marketplace_name;use codex_utils_absolute_path::AbsolutePathBuf;use std::fs;use std::path::Path;use std::path::PathBuf;use tempfile::Builder;mod install;mod metadata;mod source;use install::clone_git_source;use install::ensure_marketplace_destination_is_inside_install_root;use install::marketplace_staging_root;use install::replace_marketplace_root;use install::safe_marketplace_dir_name;use metadata::MarketplaceInstallMetadata;use metadata::find_marketplace_root_by_name;use metadata::installed_marketplace_root_for_source;use metadata::record_added_marketplace_entry;use source::MarketplaceSource;pub(crate) use source::parse_marketplace_source;use source::stage_marketplace_source;use source::validate_marketplace_source_root;#[derive(Debug, Clone, PartialEq, Eq)]pub struct MarketplaceAddRequest {    pub source: String,    pub ref_name: Option<String>,    pub sparse_paths: Vec<String>,}#[derive(Debug, Clone, PartialEq, Eq)]pub struct MarketplaceAddOutcome {    pub marketplace_name: String,    pub source_display: String,    pub installed_root: AbsolutePathBuf,    pub already_added: bool,}#[derive(Debug, thiserror::Error)]pub enum MarketplaceAddError {    #[error("{0}")]    InvalidRequest(String),    #[error("{0}")]    Internal(String),}pub async fn add_marketplace(    codex_home: PathBuf,    request: MarketplaceAddRequest,) -> Result<MarketplaceAddOutcome, MarketplaceAddError> {    tokio::task::spawn_blocking(move || add_marketplace_sync(codex_home.as_path(), request))        .await        .map_err(|err| MarketplaceAddError::Internal(format!("failed to add marketplace: {err}")))?}pub fn is_local_marketplace_source(    source: &str,    explicit_ref: Option<String>,) -> Result<bool, MarketplaceAddError> {    Ok(matches!(        parse_marketplace_source(source, explicit_ref)?,        source::MarketplaceSource::Local { .. }    ))}fn add_marketplace_sync(    codex_home: &Path,    request: MarketplaceAddRequest,) -> Result<MarketplaceAddOutcome, MarketplaceAddError> {    add_marketplace_sync_with_cloner(codex_home, request, clone_git_source)}fn add_marketplace_sync_with_cloner<F>(    codex_home: &Path,    request: MarketplaceAddRequest,    clone_source: F,) -> Result<MarketplaceAddOutcome, MarketplaceAddError>where    F: Fn(&str, Option<&str>, &[String], &Path) -> Result<(), MarketplaceAddError>,{    let MarketplaceAddRequest {        source,        ref_name,        sparse_paths,    } = request;    let source = parse_marketplace_source(&source, ref_name)?;    if !sparse_paths.is_empty() && !matches!(source, MarketplaceSource::Git { .. }) {        return Err(MarketplaceAddError::InvalidRequest(            "--sparse is only supported for git marketplace sources".to_string(),        ));    }    let install_root = marketplace_install_root(codex_home);    fs::create_dir_all(&install_root).map_err(|err| {        MarketplaceAddError::Internal(format!(            "failed to create marketplace install directory {}: {err}",            install_root.display()        ))    })?;    let install_metadata = MarketplaceInstallMetadata::from_source(&source, &sparse_paths);    if let Some(existing_root) =        installed_marketplace_root_for_source(codex_home, &install_root, &install_metadata)?    {        let marketplace_name = validate_marketplace_source_root(&existing_root)?;        record_added_marketplace_entry(codex_home, &marketplace_name, &install_metadata)?;        return Ok(MarketplaceAddOutcome {            marketplace_name,            source_display: source.display(),            installed_root: AbsolutePathBuf::try_from(existing_root).map_err(|err| {                MarketplaceAddError::Internal(format!(                    "failed to resolve installed marketplace root: {err}"                ))            })?,            already_added: true,        });    }    if let MarketplaceSource::Local { path } = &source {        let marketplace_name = validate_marketplace_source_root(path)?;        if is_openai_curated_marketplace_name(&marketplace_name) {            return Err(MarketplaceAddError::InvalidRequest(format!(                "marketplace '{marketplace_name}' is reserved and cannot be added from this source"            )));        }        if find_marketplace_root_by_name(codex_home, &install_root, &marketplace_name)?.is_some() {            return Err(MarketplaceAddError::InvalidRequest(format!(                "marketplace '{marketplace_name}' is already added from a different source; remove it before adding this source"            )));        }        record_added_marketplace_entry(codex_home, &marketplace_name, &install_metadata)?;        return Ok(MarketplaceAddOutcome {            marketplace_name,            source_display: source.display(),            installed_root: AbsolutePathBuf::try_from(path.clone()).map_err(|err| {                MarketplaceAddError::Internal(format!(                    "failed to resolve installed marketplace root: {err}"                ))            })?,            already_added: false,        });    }    let staging_root = marketplace_staging_root(&install_root);    fs::create_dir_all(&staging_root).map_err(|err| {        MarketplaceAddError::Internal(format!(            "failed to create marketplace staging directory {}: {err}",            staging_root.display()        ))    })?;    let staged_root = Builder::new()        .prefix("marketplace-add-")        .tempdir_in(&staging_root)        .map_err(|err| {            MarketplaceAddError::Internal(format!(                "failed to create temporary marketplace directory in {}: {err}",                staging_root.display()            ))        })?;    let staged_root = staged_root.keep();    stage_marketplace_source(&source, &sparse_paths, &staged_root, clone_source)?;    let marketplace_name = validate_marketplace_source_root(&staged_root)?;    if is_openai_curated_marketplace_name(&marketplace_name) {        return Err(MarketplaceAddError::InvalidRequest(format!(            "marketplace '{marketplace_name}' is reserved and cannot be added from this source"        )));    }    let destination = install_root.join(safe_marketplace_dir_name(&marketplace_name)?);    ensure_marketplace_destination_is_inside_install_root(&install_root, &destination)?;    if destination.exists() {        return Err(MarketplaceAddError::InvalidRequest(format!(            "marketplace '{marketplace_name}' is already added from a different source; remove it before adding this source"        )));    }    replace_marketplace_root(&staged_root, &destination).map_err(|err| {        MarketplaceAddError::Internal(format!(            "failed to install marketplace at {}: {err}",            destination.display()        ))    })?;    if let Err(err) =        record_added_marketplace_entry(codex_home, &marketplace_name, &install_metadata)    {        if let Err(rollback_err) = fs::rename(&destination, &staged_root) {            return Err(MarketplaceAddError::Internal(format!(                "{err}; additionally failed to roll back installed marketplace at {}: {rollback_err}",                destination.display()            )));        }        return Err(err);    }    Ok(MarketplaceAddOutcome {        marketplace_name,        source_display: source.display(),        installed_root: AbsolutePathBuf::try_from(destination).map_err(|err| {            MarketplaceAddError::Internal(format!(                "failed to resolve installed marketplace root: {err}"            ))        })?,        already_added: false,    })}#[cfg(test)]mod tests {    use super::*;    use anyhow::Result;    use pretty_assertions::assert_eq;    use tempfile::TempDir;    #[test]    fn add_marketplace_sync_installs_marketplace_and_updates_config() -> Result<()> {        let codex_home = TempDir::new()?;        let source_root = TempDir::new()?;        write_marketplace_source(source_root.path(), "remote copy")?;        let result = add_marketplace_sync_with_cloner(            codex_home.path(),            MarketplaceAddRequest {                source: "https://github.com/owner/repo.git".to_string(),                ref_name: None,                sparse_paths: Vec::new(),            },            |_url, _ref_name, _sparse_paths, destination| {                copy_dir_all(source_root.path(), destination)                    .map_err(|err| MarketplaceAddError::Internal(err.to_string()))            },        )?;        assert_eq!(result.marketplace_name, "debug");        assert_eq!(result.source_display, "https://github.com/owner/repo.git");        assert!(!result.already_added);        assert!(            result                .installed_root                .as_path()                .join(".agents/plugins/marketplace.json")                .is_file()        );        let config = fs::read_to_string(codex_home.path().join(codex_config::CONFIG_TOML_FILE))?;        assert!(config.contains("[marketplaces.debug]"));        assert!(config.contains("source_type = \"git\""));        assert!(config.contains("source = \"https://github.com/owner/repo.git\""));        Ok(())    }    #[test]    fn add_marketplace_sync_installs_local_directory_source_and_updates_config() -> Result<()> {        let codex_home = TempDir::new()?;        let source_root = TempDir::new()?;        write_marketplace_source(source_root.path(), "local copy")?;        let result = add_marketplace_sync_with_cloner(            codex_home.path(),            MarketplaceAddRequest {                source: source_root.path().display().to_string(),                ref_name: None,                sparse_paths: Vec::new(),            },            |_url, _ref_name, _sparse_paths, _destination| {                panic!("git cloner should not be called for local marketplace sources")            },        )?;        let expected_source = source_root.path().canonicalize()?.display().to_string();        assert_eq!(result.marketplace_name, "debug");        assert_eq!(result.source_display, expected_source);        let expected_installed_root =            AbsolutePathBuf::from_absolute_path(source_root.path().canonicalize()?)?;        assert_eq!(result.installed_root, expected_installed_root);        assert!(!result.already_added);        assert!(            !marketplace_install_root(codex_home.path())                .join("debug")                .exists()        );        let config = fs::read_to_string(codex_home.path().join(codex_config::CONFIG_TOML_FILE))?;        let config: toml::Value = toml::from_str(&config)?;        assert_eq!(            config["marketplaces"]["debug"]["source_type"].as_str(),            Some("local")        );        assert_eq!(            config["marketplaces"]["debug"]["source"].as_str(),            Some(expected_source.as_str())        );        Ok(())    }    #[test]    fn add_marketplace_sync_rejects_sparse_checkout_for_local_directory_source() -> Result<()> {        let codex_home = TempDir::new()?;        let source_root = TempDir::new()?;        write_marketplace_source(source_root.path(), "local copy")?;        let err = add_marketplace_sync_with_cloner(            codex_home.path(),            MarketplaceAddRequest {                source: source_root.path().display().to_string(),                ref_name: None,                sparse_paths: vec![".agents".to_string()],            },            |_url, _ref_name, _sparse_paths, _destination| {                panic!("git cloner should not be called for local marketplace sources")            },        )        .unwrap_err();        assert_eq!(            err.to_string(),            "--sparse is only supported for git marketplace sources"        );        assert!(            !codex_home                .path()                .join(codex_config::CONFIG_TOML_FILE)                .exists()        );        Ok(())    }    #[test]    fn add_marketplace_sync_treats_existing_local_directory_source_as_already_added() -> Result<()>    {        let codex_home = TempDir::new()?;        let source_root = TempDir::new()?;        write_marketplace_source(source_root.path(), "local copy")?;        let request = MarketplaceAddRequest {            source: source_root.path().display().to_string(),            ref_name: None,            sparse_paths: Vec::new(),        };        let first_result = add_marketplace_sync_with_cloner(codex_home.path(), request.clone(), {            |_url, _ref_name, _sparse_paths, _destination| {                panic!("git cloner should not be called for local marketplace sources")            }        })?;        let second_result = add_marketplace_sync_with_cloner(codex_home.path(), request, {            |_url, _ref_name, _sparse_paths, _destination| {                panic!("git cloner should not be called for local marketplace sources")            }        })?;        assert!(!first_result.already_added);        assert!(second_result.already_added);        assert_eq!(second_result.installed_root, first_result.installed_root);        Ok(())    }    fn write_marketplace_source(source: &Path, marker: &str) -> std::io::Result<()> {        fs::create_dir_all(source.join(".agents/plugins"))?;        fs::create_dir_all(source.join("plugins/sample/.codex-plugin"))?;        fs::write(            source.join(".agents/plugins/marketplace.json"),            r#"{  "name": "debug",  "plugins": [    {      "name": "sample",      "source": {        "source": "local",        "path": "./plugins/sample"      }    }  ]}"#,        )?;        fs::write(            source.join("plugins/sample/.codex-plugin/plugin.json"),            r#"{"name":"sample"}"#,        )?;        fs::write(source.join("plugins/sample/marker.txt"), marker)?;        Ok(())    }    fn copy_dir_all(source: &Path, destination: &Path) -> std::io::Result<()> {        fs::create_dir_all(destination)?;        for entry in fs::read_dir(source)? {            let entry = entry?;            let source_path = entry.path();            let destination_path = destination.join(entry.file_name());            if source_path.is_dir() {                copy_dir_all(&source_path, &destination_path)?;            } else {                fs::copy(&source_path, &destination_path)?;            }        }        Ok(())    }}