mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[codex] Add marketplace remove command and shared logic (#17752)
## Summary Move the marketplace remove implementation into shared core logic so both the CLI command and follow-up app-server RPC can reuse the same behavior. This change: - adds a shared `codex_core::plugins::remove_marketplace(...)` flow - moves validation, config removal, and installed-root deletion out of the CLI - keeps the CLI as a thin wrapper over the shared implementation - adds focused core coverage for the shared remove path ## Validation - `just fmt` - focused local coverage for the shared remove path - heavier follow-up validation deferred to stacked PR CI
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
use crate::plugins::marketplace_install_root;
|
||||
use crate::plugins::validate_plugin_segment;
|
||||
use codex_config::RemoveMarketplaceConfigOutcome;
|
||||
use codex_config::remove_user_marketplace_config;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MarketplaceRemoveRequest {
|
||||
pub marketplace_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MarketplaceRemoveOutcome {
|
||||
pub marketplace_name: String,
|
||||
pub removed_installed_root: Option<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum MarketplaceRemoveError {
|
||||
#[error("{0}")]
|
||||
InvalidRequest(String),
|
||||
#[error("{0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
pub async fn remove_marketplace(
|
||||
codex_home: PathBuf,
|
||||
request: MarketplaceRemoveRequest,
|
||||
) -> Result<MarketplaceRemoveOutcome, MarketplaceRemoveError> {
|
||||
tokio::task::spawn_blocking(move || remove_marketplace_sync(codex_home.as_path(), request))
|
||||
.await
|
||||
.map_err(|err| {
|
||||
MarketplaceRemoveError::Internal(format!("failed to remove marketplace: {err}"))
|
||||
})?
|
||||
}
|
||||
|
||||
fn remove_marketplace_sync(
|
||||
codex_home: &Path,
|
||||
request: MarketplaceRemoveRequest,
|
||||
) -> Result<MarketplaceRemoveOutcome, MarketplaceRemoveError> {
|
||||
let marketplace_name = request.marketplace_name;
|
||||
validate_plugin_segment(&marketplace_name, "marketplace name")
|
||||
.map_err(MarketplaceRemoveError::InvalidRequest)?;
|
||||
|
||||
let destination = marketplace_install_root(codex_home).join(&marketplace_name);
|
||||
let config_outcome =
|
||||
remove_user_marketplace_config(codex_home, &marketplace_name).map_err(|err| {
|
||||
MarketplaceRemoveError::Internal(format!(
|
||||
"failed to remove marketplace '{marketplace_name}' from user config.toml: {err}"
|
||||
))
|
||||
})?;
|
||||
if let RemoveMarketplaceConfigOutcome::NameCaseMismatch { configured_name } = &config_outcome {
|
||||
return Err(MarketplaceRemoveError::InvalidRequest(format!(
|
||||
"marketplace `{marketplace_name}` does not match configured marketplace `{configured_name}` exactly"
|
||||
)));
|
||||
}
|
||||
|
||||
let removed_config = config_outcome == RemoveMarketplaceConfigOutcome::Removed;
|
||||
let removed_installed_root = remove_marketplace_root(&destination)?;
|
||||
|
||||
if removed_installed_root.is_none() && !removed_config {
|
||||
return Err(MarketplaceRemoveError::InvalidRequest(format!(
|
||||
"marketplace `{marketplace_name}` is not configured or installed"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(MarketplaceRemoveOutcome {
|
||||
marketplace_name,
|
||||
removed_installed_root,
|
||||
})
|
||||
}
|
||||
|
||||
fn remove_marketplace_root(root: &Path) -> Result<Option<AbsolutePathBuf>, MarketplaceRemoveError> {
|
||||
if !root.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let removed_root = AbsolutePathBuf::try_from(root.to_path_buf()).map_err(|err| {
|
||||
MarketplaceRemoveError::Internal(format!(
|
||||
"failed to resolve installed marketplace root {}: {err}",
|
||||
root.display()
|
||||
))
|
||||
})?;
|
||||
let metadata = fs::symlink_metadata(root).map_err(|err| {
|
||||
MarketplaceRemoveError::Internal(format!(
|
||||
"failed to inspect installed marketplace root {}: {err}",
|
||||
root.display()
|
||||
))
|
||||
})?;
|
||||
let remove_result = if metadata.is_dir() {
|
||||
fs::remove_dir_all(root)
|
||||
} else {
|
||||
fs::remove_file(root)
|
||||
};
|
||||
remove_result.map_err(|err| {
|
||||
MarketplaceRemoveError::Internal(format!(
|
||||
"failed to remove installed marketplace root {}: {err}",
|
||||
root.display()
|
||||
))
|
||||
})?;
|
||||
Ok(Some(removed_root))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_config::MarketplaceConfigUpdate;
|
||||
use codex_config::record_user_marketplace;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn remove_marketplace_sync_removes_config_and_installed_root() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
record_user_marketplace(
|
||||
codex_home.path(),
|
||||
"debug",
|
||||
&MarketplaceConfigUpdate {
|
||||
last_updated: "2026-04-13T00:00:00Z",
|
||||
last_revision: None,
|
||||
source_type: "git",
|
||||
source: "https://github.com/owner/repo.git",
|
||||
ref_name: Some("main"),
|
||||
sparse_paths: &[],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let installed_root = marketplace_install_root(codex_home.path()).join("debug");
|
||||
fs::create_dir_all(installed_root.join(".agents/plugins")).unwrap();
|
||||
fs::write(
|
||||
installed_root.join(".agents/plugins/marketplace.json"),
|
||||
"{}",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let outcome = remove_marketplace_sync(
|
||||
codex_home.path(),
|
||||
MarketplaceRemoveRequest {
|
||||
marketplace_name: "debug".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(outcome.marketplace_name, "debug");
|
||||
assert_eq!(
|
||||
outcome.removed_installed_root,
|
||||
Some(AbsolutePathBuf::try_from(installed_root.clone()).unwrap())
|
||||
);
|
||||
let config =
|
||||
fs::read_to_string(codex_home.path().join(codex_config::CONFIG_TOML_FILE)).unwrap();
|
||||
assert!(!config.contains("[marketplaces.debug]"));
|
||||
assert!(!installed_root.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_marketplace_sync_rejects_unknown_marketplace() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
|
||||
let err = remove_marketplace_sync(
|
||||
codex_home.path(),
|
||||
MarketplaceRemoveRequest {
|
||||
marketplace_name: "debug".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"marketplace `debug` is not configured or installed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_marketplace_sync_rejects_case_mismatched_configured_name() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
record_user_marketplace(
|
||||
codex_home.path(),
|
||||
"debug",
|
||||
&MarketplaceConfigUpdate {
|
||||
last_updated: "2026-04-13T00:00:00Z",
|
||||
last_revision: None,
|
||||
source_type: "git",
|
||||
source: "https://github.com/owner/repo.git",
|
||||
ref_name: Some("main"),
|
||||
sparse_paths: &[],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let installed_root = marketplace_install_root(codex_home.path()).join("debug");
|
||||
fs::create_dir_all(&installed_root).unwrap();
|
||||
|
||||
let err = remove_marketplace_sync(
|
||||
codex_home.path(),
|
||||
MarketplaceRemoveRequest {
|
||||
marketplace_name: "Debug".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"marketplace `Debug` does not match configured marketplace `debug` exactly"
|
||||
);
|
||||
assert!(installed_root.exists());
|
||||
let config =
|
||||
fs::read_to_string(codex_home.path().join(codex_config::CONFIG_TOML_FILE)).unwrap();
|
||||
assert!(config.contains("[marketplaces.debug]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_marketplace_sync_keeps_installed_root_when_config_removal_fails() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
fs::write(
|
||||
codex_home.path().join(codex_config::CONFIG_TOML_FILE),
|
||||
"[marketplaces.debug\n",
|
||||
)
|
||||
.unwrap();
|
||||
let installed_root = marketplace_install_root(codex_home.path()).join("debug");
|
||||
fs::create_dir_all(&installed_root).unwrap();
|
||||
|
||||
let err = remove_marketplace_sync(
|
||||
codex_home.path(),
|
||||
MarketplaceRemoveRequest {
|
||||
marketplace_name: "debug".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("failed to remove marketplace 'debug' from user config.toml")
|
||||
);
|
||||
assert!(installed_root.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_marketplace_sync_removes_file_installed_root() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
record_user_marketplace(
|
||||
codex_home.path(),
|
||||
"debug",
|
||||
&MarketplaceConfigUpdate {
|
||||
last_updated: "2026-04-13T00:00:00Z",
|
||||
last_revision: None,
|
||||
source_type: "git",
|
||||
source: "https://github.com/owner/repo.git",
|
||||
ref_name: Some("main"),
|
||||
sparse_paths: &[],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let installed_root = marketplace_install_root(codex_home.path()).join("debug");
|
||||
fs::create_dir_all(installed_root.parent().unwrap()).unwrap();
|
||||
fs::write(&installed_root, "corrupt install root").unwrap();
|
||||
|
||||
let outcome = remove_marketplace_sync(
|
||||
codex_home.path(),
|
||||
MarketplaceRemoveRequest {
|
||||
marketplace_name: "debug".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
outcome,
|
||||
MarketplaceRemoveOutcome {
|
||||
marketplace_name: "debug".to_string(),
|
||||
removed_installed_root: Some(
|
||||
AbsolutePathBuf::try_from(installed_root.clone()).unwrap()
|
||||
),
|
||||
}
|
||||
);
|
||||
assert!(!installed_root.exists());
|
||||
let config =
|
||||
fs::read_to_string(codex_home.path().join(codex_config::CONFIG_TOML_FILE)).unwrap();
|
||||
assert!(!config.contains("[marketplaces.debug]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_marketplace_sync_removes_inline_config_entry() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
fs::write(
|
||||
codex_home.path().join(codex_config::CONFIG_TOML_FILE),
|
||||
r#"
|
||||
marketplaces = { debug = { source_type = "git", source = "https://github.com/owner/repo.git" } }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let installed_root = marketplace_install_root(codex_home.path()).join("debug");
|
||||
fs::create_dir_all(&installed_root).unwrap();
|
||||
|
||||
let outcome = remove_marketplace_sync(
|
||||
codex_home.path(),
|
||||
MarketplaceRemoveRequest {
|
||||
marketplace_name: "debug".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(outcome.marketplace_name, "debug");
|
||||
assert_eq!(
|
||||
outcome.removed_installed_root,
|
||||
Some(AbsolutePathBuf::try_from(installed_root.clone()).unwrap())
|
||||
);
|
||||
assert!(!installed_root.exists());
|
||||
let config =
|
||||
fs::read_to_string(codex_home.path().join(codex_config::CONFIG_TOML_FILE)).unwrap();
|
||||
assert!(!config.contains("debug"));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ mod injection;
|
||||
mod installed_marketplaces;
|
||||
mod manager;
|
||||
mod marketplace_add;
|
||||
mod marketplace_remove;
|
||||
mod mentions;
|
||||
mod render;
|
||||
mod startup_sync;
|
||||
@@ -52,6 +53,10 @@ pub use marketplace_add::MarketplaceAddRequest;
|
||||
pub use marketplace_add::add_marketplace;
|
||||
pub(crate) use marketplace_add::is_local_marketplace_source;
|
||||
pub(crate) use marketplace_add::parse_marketplace_source;
|
||||
pub use marketplace_remove::MarketplaceRemoveError;
|
||||
pub use marketplace_remove::MarketplaceRemoveOutcome;
|
||||
pub use marketplace_remove::MarketplaceRemoveRequest;
|
||||
pub use marketplace_remove::remove_marketplace;
|
||||
pub(crate) use render::render_explicit_plugin_instructions;
|
||||
pub(crate) use render::render_plugins_section;
|
||||
pub(crate) use startup_sync::curated_plugins_repo_path;
|
||||
|
||||
Reference in New Issue
Block a user