Auto-upgrade configured marketplaces

This commit is contained in:
xli-oai
2026-04-10 21:25:43 -07:00
parent 0c8f3173e4
commit 8f29fbf19e
8 changed files with 816 additions and 0 deletions

View File

@@ -6349,6 +6349,7 @@ impl CodexMessageProcessor {
return;
}
};
plugins_manager.maybe_start_configured_marketplace_upgrade_for_config(&config);
let mut remote_sync_error = None;
let auth = self.auth_manager.auth().await;

View File

@@ -186,6 +186,7 @@ fn record_added_marketplace(
let last_updated = utc_timestamp_now()?;
let update = MarketplaceConfigUpdate {
last_updated: &last_updated,
last_revision: None,
source_type: install_metadata.config_source_type(),
source: &source,
ref_name: install_metadata.ref_name(),

View File

@@ -12,6 +12,7 @@ use crate::CONFIG_TOML_FILE;
pub struct MarketplaceConfigUpdate<'a> {
pub last_updated: &'a str,
pub last_revision: Option<&'a str>,
pub source_type: &'a str,
pub source: &'a str,
pub ref_name: Option<&'a str>,
@@ -63,6 +64,9 @@ fn upsert_marketplace(
let mut entry = TomlTable::new();
entry.set_implicit(false);
entry["last_updated"] = value(update.last_updated.to_string());
if let Some(last_revision) = update.last_revision {
entry["last_revision"] = value(last_revision.to_string());
}
entry["source_type"] = value(update.source_type.to_string());
entry["source"] = value(update.source.to_string());
if let Some(ref_name) = update.ref_name {

View File

@@ -614,6 +614,9 @@ pub struct MarketplaceConfig {
/// Last time Codex successfully added or refreshed this marketplace.
#[serde(default)]
pub last_updated: Option<String>,
/// Git revision Codex last successfully activated for this marketplace.
#[serde(default)]
pub last_revision: Option<String>,
/// Source kind used to install this marketplace.
#[serde(default)]
pub source_type: Option<MarketplaceSourceType>,

View File

@@ -770,6 +770,11 @@
"MarketplaceConfig": {
"additionalProperties": false,
"properties": {
"last_revision": {
"default": null,
"description": "Git revision Codex last successfully activated for this marketplace.",
"type": "string"
},
"last_updated": {
"default": null,
"description": "Last time Codex successfully added or refreshed this marketplace.",

View File

@@ -15,6 +15,7 @@ use super::marketplace::ResolvedMarketplacePlugin;
use super::marketplace::list_marketplaces;
use super::marketplace::load_marketplace;
use super::marketplace::resolve_marketplace_plugin;
use super::marketplace_upgrade::upgrade_configured_git_marketplaces;
use super::read_curated_plugins_sha;
use super::remote::RemotePluginFetchError;
use super::remote::RemotePluginMutationError;
@@ -108,6 +109,12 @@ struct NonCuratedCacheRefreshState {
in_flight: bool,
}
#[derive(Default)]
struct ConfiguredMarketplaceUpgradeState {
in_flight: bool,
completed: bool,
}
fn featured_plugin_ids_cache_key(
config: &Config,
auth: Option<&CodexAuth>,
@@ -321,6 +328,7 @@ pub struct PluginsManager {
codex_home: PathBuf,
store: PluginStore,
featured_plugin_ids_cache: RwLock<Option<CachedFeaturedPluginIds>>,
configured_marketplace_upgrade_state: RwLock<ConfiguredMarketplaceUpgradeState>,
non_curated_cache_refresh_state: RwLock<NonCuratedCacheRefreshState>,
cached_enabled_outcome: RwLock<Option<PluginLoadOutcome>>,
remote_sync_lock: Mutex<()>,
@@ -348,6 +356,9 @@ impl PluginsManager {
codex_home: codex_home.clone(),
store: PluginStore::new(codex_home),
featured_plugin_ids_cache: RwLock::new(None),
configured_marketplace_upgrade_state: RwLock::new(
ConfiguredMarketplaceUpgradeState::default(),
),
non_curated_cache_refresh_state: RwLock::new(NonCuratedCacheRefreshState::default()),
cached_enabled_outcome: RwLock::new(None),
remote_sync_lock: Mutex::new(()),
@@ -1062,6 +1073,7 @@ impl PluginsManager {
) {
if config.features.enabled(Feature::Plugins) {
self.start_curated_repo_sync();
self.maybe_start_configured_marketplace_upgrade_for_config(config);
start_startup_remote_plugin_sync_once(
Arc::clone(self),
self.codex_home.clone(),
@@ -1086,6 +1098,76 @@ impl PluginsManager {
}
}
pub fn maybe_start_configured_marketplace_upgrade_for_config(
self: &Arc<Self>,
config: &Config,
) {
if !config.features.enabled(Feature::Plugins) {
return;
}
let should_spawn = {
let mut state = match self.configured_marketplace_upgrade_state.write() {
Ok(state) => state,
Err(err) => err.into_inner(),
};
if state.completed || state.in_flight {
return;
}
state.in_flight = true;
true
};
if !should_spawn {
return;
}
let manager = Arc::clone(self);
let codex_home = self.codex_home.clone();
let config = config.clone();
if let Err(err) = std::thread::Builder::new()
.name("plugins-marketplace-auto-upgrade".to_string())
.spawn(move || {
let outcome = upgrade_configured_git_marketplaces(codex_home.as_path(), &config);
let cache_refresh_succeeded = if outcome.upgraded_roots.is_empty() {
true
} else {
match refresh_non_curated_plugin_cache(
codex_home.as_path(),
&outcome.upgraded_roots,
) {
Ok(cache_refreshed) => {
if cache_refreshed {
manager.clear_cache();
}
true
}
Err(err) => {
manager.clear_cache();
warn!(
"failed to refresh non-curated plugin cache after marketplace auto-upgrade: {err}"
);
false
}
}
};
let mut state = match manager.configured_marketplace_upgrade_state.write() {
Ok(state) => state,
Err(err) => err.into_inner(),
};
state.in_flight = false;
state.completed = outcome.all_succeeded && cache_refresh_succeeded;
})
{
let mut state = match self.configured_marketplace_upgrade_state.write() {
Ok(state) => state,
Err(err) => err.into_inner(),
};
state.in_flight = false;
warn!("failed to start configured marketplace auto-upgrade task: {err}");
}
}
pub fn maybe_start_non_curated_plugin_cache_refresh_for_roots(
self: &Arc<Self>,
roots: &[AbsolutePathBuf],

View File

@@ -0,0 +1,719 @@
use super::installed_marketplaces::marketplace_install_root;
use super::validate_marketplace_root;
use codex_config::MarketplaceConfigUpdate;
use codex_config::record_user_marketplace;
use codex_config::types::MarketplaceConfig;
use codex_config::types::MarketplaceSourceType;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::path::Path;
use std::process::Command;
use std::process::Output;
use std::process::Stdio;
use std::time::Duration;
use tempfile::TempDir;
use tracing::warn;
use crate::config::Config;
const MARKETPLACE_UPGRADE_GIT_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Default, PartialEq, Eq)]
pub(super) struct ConfiguredMarketplaceUpgradeOutcome {
pub upgraded_roots: Vec<AbsolutePathBuf>,
pub all_succeeded: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ConfiguredGitMarketplace {
name: String,
source: String,
ref_name: Option<String>,
sparse_paths: Vec<String>,
last_revision: Option<String>,
}
pub(super) fn upgrade_configured_git_marketplaces(
codex_home: &Path,
config: &Config,
) -> ConfiguredMarketplaceUpgradeOutcome {
let marketplaces = configured_git_marketplaces(config);
if marketplaces.is_empty() {
return ConfiguredMarketplaceUpgradeOutcome {
all_succeeded: true,
..Default::default()
};
}
let install_root = marketplace_install_root(codex_home);
let mut upgraded_roots = Vec::new();
let mut all_succeeded = true;
for marketplace in marketplaces {
match upgrade_configured_git_marketplace(codex_home, &install_root, &marketplace) {
Ok(Some(upgraded_root)) => upgraded_roots.push(upgraded_root),
Ok(None) => {}
Err(err) => {
all_succeeded = false;
warn!(
marketplace = marketplace.name,
source = marketplace.source,
error = %err,
"failed to auto-upgrade configured marketplace"
);
}
}
}
ConfiguredMarketplaceUpgradeOutcome {
upgraded_roots,
all_succeeded,
}
}
fn configured_git_marketplaces(config: &Config) -> Vec<ConfiguredGitMarketplace> {
let Some(user_layer) = config.config_layer_stack.get_user_layer() else {
return Vec::new();
};
let Some(marketplaces_value) = user_layer.config.get("marketplaces") else {
return Vec::new();
};
let marketplaces = match marketplaces_value
.clone()
.try_into::<std::collections::HashMap<String, MarketplaceConfig>>()
{
Ok(marketplaces) => marketplaces,
Err(err) => {
warn!("invalid marketplaces config while preparing auto-upgrade: {err}");
return Vec::new();
}
};
let mut configured = marketplaces
.into_iter()
.filter_map(|(name, marketplace)| {
let MarketplaceConfig {
last_updated: _,
last_revision,
source_type,
source,
ref_name,
sparse_paths,
} = marketplace;
if source_type != Some(MarketplaceSourceType::Git) {
return None;
}
let Some(source) = source else {
warn!(
marketplace = name,
"ignoring configured Git marketplace without source"
);
return None;
};
Some(ConfiguredGitMarketplace {
name,
source,
ref_name,
sparse_paths: sparse_paths.unwrap_or_default(),
last_revision,
})
})
.collect::<Vec<_>>();
configured.sort_unstable_by(|left, right| left.name.cmp(&right.name));
configured
}
fn upgrade_configured_git_marketplace(
codex_home: &Path,
install_root: &Path,
marketplace: &ConfiguredGitMarketplace,
) -> Result<Option<AbsolutePathBuf>, String> {
super::validate_plugin_segment(&marketplace.name, "marketplace name")?;
let remote_revision = git_remote_revision(
&marketplace.source,
marketplace.ref_name.as_deref(),
MARKETPLACE_UPGRADE_GIT_TIMEOUT,
)?;
let destination = install_root.join(&marketplace.name);
if destination
.join(".agents/plugins/marketplace.json")
.is_file()
&& marketplace.last_revision.as_deref() == Some(remote_revision.as_str())
{
return Ok(None);
}
let staging_parent = install_root.join(".staging");
std::fs::create_dir_all(&staging_parent).map_err(|err| {
format!(
"failed to create marketplace upgrade staging directory {}: {err}",
staging_parent.display()
)
})?;
let staged_dir = tempfile::Builder::new()
.prefix("marketplace-upgrade-")
.tempdir_in(&staging_parent)
.map_err(|err| {
format!(
"failed to create temporary marketplace upgrade directory in {}: {err}",
staging_parent.display()
)
})?;
clone_git_source(
&marketplace.source,
marketplace.ref_name.as_deref(),
&marketplace.sparse_paths,
staged_dir.path(),
MARKETPLACE_UPGRADE_GIT_TIMEOUT,
)?;
let marketplace_name = validate_marketplace_root(staged_dir.path())
.map_err(|err| format!("failed to validate upgraded marketplace root: {err}"))?;
if marketplace_name != marketplace.name {
return Err(format!(
"upgraded marketplace name `{marketplace_name}` does not match configured marketplace `{}`",
marketplace.name
));
}
let last_updated = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let update = MarketplaceConfigUpdate {
last_updated: &last_updated,
last_revision: Some(&remote_revision),
source_type: "git",
source: &marketplace.source,
ref_name: marketplace.ref_name.as_deref(),
sparse_paths: &marketplace.sparse_paths,
};
activate_marketplace_root(&destination, staged_dir, || {
record_user_marketplace(codex_home, &marketplace.name, &update).map_err(|err| {
format!(
"failed to record upgraded marketplace `{}` in user config.toml: {err}",
marketplace.name
)
})
})?;
AbsolutePathBuf::try_from(destination)
.map(Some)
.map_err(|err| format!("upgraded marketplace path is not absolute: {err}"))
}
fn git_remote_revision(
source: &str,
ref_name: Option<&str>,
timeout: Duration,
) -> Result<String, String> {
if let Some(ref_name) = ref_name
&& is_full_git_sha(ref_name)
{
return Ok(ref_name.to_string());
}
let ref_name = ref_name.unwrap_or("HEAD");
let output = run_git_command_with_timeout(
Command::new("git")
.env("GIT_OPTIONAL_LOCKS", "0")
.arg("ls-remote")
.arg(source)
.arg(ref_name),
"git ls-remote marketplace source",
timeout,
)?;
ensure_git_success(&output, "git ls-remote marketplace source")?;
let stdout = String::from_utf8_lossy(&output.stdout);
let Some(first_line) = stdout.lines().next() else {
return Err("git ls-remote returned empty output for marketplace source".to_string());
};
let Some((revision, _)) = first_line.split_once('\t') else {
return Err(format!(
"unexpected git ls-remote output for marketplace source: {first_line}"
));
};
let revision = revision.trim();
if revision.is_empty() {
return Err("git ls-remote returned empty revision for marketplace source".to_string());
}
Ok(revision.to_string())
}
fn is_full_git_sha(value: &str) -> bool {
value.len() == 40 && value.chars().all(|ch| ch.is_ascii_hexdigit())
}
fn clone_git_source(
source: &str,
ref_name: Option<&str>,
sparse_paths: &[String],
destination: &Path,
timeout: Duration,
) -> Result<(), String> {
if sparse_paths.is_empty() {
let output = run_git_command_with_timeout(
Command::new("git")
.env("GIT_OPTIONAL_LOCKS", "0")
.arg("clone")
.arg(source)
.arg(destination),
"git clone marketplace source",
timeout,
)?;
ensure_git_success(&output, "git clone marketplace source")?;
if let Some(ref_name) = ref_name {
let output = run_git_command_with_timeout(
Command::new("git")
.env("GIT_OPTIONAL_LOCKS", "0")
.arg("-C")
.arg(destination)
.arg("checkout")
.arg(ref_name),
"git checkout marketplace ref",
timeout,
)?;
ensure_git_success(&output, "git checkout marketplace ref")?;
}
return Ok(());
}
let output = run_git_command_with_timeout(
Command::new("git")
.env("GIT_OPTIONAL_LOCKS", "0")
.arg("clone")
.arg("--filter=blob:none")
.arg("--no-checkout")
.arg(source)
.arg(destination),
"git clone marketplace source",
timeout,
)?;
ensure_git_success(&output, "git clone marketplace source")?;
let mut sparse_checkout = Command::new("git");
sparse_checkout
.env("GIT_OPTIONAL_LOCKS", "0")
.arg("-C")
.arg(destination)
.arg("sparse-checkout")
.arg("set")
.args(sparse_paths);
let output = run_git_command_with_timeout(
&mut sparse_checkout,
"git sparse-checkout marketplace source",
timeout,
)?;
ensure_git_success(&output, "git sparse-checkout marketplace source")?;
let output = run_git_command_with_timeout(
Command::new("git")
.env("GIT_OPTIONAL_LOCKS", "0")
.arg("-C")
.arg(destination)
.arg("checkout")
.arg(ref_name.unwrap_or("HEAD")),
"git checkout marketplace ref",
timeout,
)?;
ensure_git_success(&output, "git checkout marketplace ref")
}
fn activate_marketplace_root(
destination: &Path,
staged_dir: TempDir,
after_activate: impl FnOnce() -> Result<(), String>,
) -> Result<(), String> {
let staged_root = staged_dir.path();
let Some(parent) = destination.parent() else {
return Err(format!(
"failed to determine marketplace install parent for {}",
destination.display()
));
};
std::fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create marketplace install parent {}: {err}",
parent.display()
)
})?;
if destination.exists() {
let backup_dir = tempfile::Builder::new()
.prefix("marketplace-backup-")
.tempdir_in(parent)
.map_err(|err| {
format!(
"failed to create marketplace backup directory in {}: {err}",
parent.display()
)
})?;
let backup_root = backup_dir.path().join("root");
std::fs::rename(destination, &backup_root).map_err(|err| {
format!(
"failed to move previous marketplace root out of the way at {}: {err}",
destination.display()
)
})?;
if let Err(err) = std::fs::rename(staged_root, destination) {
let rollback_result = std::fs::rename(&backup_root, destination);
return match rollback_result {
Ok(()) => Err(format!(
"failed to activate upgraded marketplace at {}: {err}",
destination.display()
)),
Err(rollback_err) => {
let backup_path = backup_dir.keep().join("root");
Err(format!(
"failed to activate upgraded marketplace at {}: {err}; failed to restore previous marketplace root (left at {}): {rollback_err}",
destination.display(),
backup_path.display()
))
}
};
}
if let Err(err) = after_activate() {
let remove_result = std::fs::remove_dir_all(destination);
let rollback_result =
remove_result.and_then(|()| std::fs::rename(&backup_root, destination));
return match rollback_result {
Ok(()) => Err(err),
Err(rollback_err) => {
let backup_path = backup_dir.keep().join("root");
Err(format!(
"{err}; failed to restore previous marketplace root at {} (left at {}): {rollback_err}",
destination.display(),
backup_path.display()
))
}
};
}
} else {
std::fs::rename(staged_root, destination).map_err(|err| {
format!(
"failed to activate upgraded marketplace at {}: {err}",
destination.display()
)
})?;
if let Err(err) = after_activate() {
let remove_result = std::fs::remove_dir_all(destination);
return match remove_result {
Ok(()) => Err(err),
Err(remove_err) => Err(format!(
"{err}; failed to remove newly activated marketplace root at {}: {remove_err}",
destination.display()
)),
};
}
}
Ok(())
}
fn run_git_command_with_timeout(
command: &mut Command,
context: &str,
timeout: Duration,
) -> Result<Output, String> {
let mut child = command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|err| format!("failed to run {context}: {err}"))?;
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => {
return child
.wait_with_output()
.map_err(|err| format!("failed to wait for {context}: {err}"));
}
Ok(None) => {}
Err(err) => return Err(format!("failed to poll {context}: {err}")),
}
if start.elapsed() >= timeout {
let _ = child.kill();
let output = child
.wait_with_output()
.map_err(|err| format!("failed to wait for {context} after timeout: {err}"))?;
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return if stderr.is_empty() {
Err(format!("{context} timed out after {}s", timeout.as_secs()))
} else {
Err(format!(
"{context} timed out after {}s: {stderr}",
timeout.as_secs()
))
};
}
std::thread::sleep(Duration::from_millis(100));
}
}
fn ensure_git_success(output: &Output, context: &str) -> Result<(), String> {
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
Err(format!("{context} failed with status {}", output.status))
} else {
Err(format!(
"{context} failed with status {}: {stderr}",
output.status
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::CONFIG_TOML_FILE;
use crate::plugins::test_support::load_plugins_config;
use crate::plugins::test_support::write_file;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
#[tokio::test]
async fn upgrade_configured_git_marketplace_installs_new_revision() {
let codex_home = TempDir::new().unwrap();
let source_repo = TempDir::new().unwrap();
write_marketplace_repo(source_repo.path(), "debug", "new");
init_git_repo(source_repo.path());
let revision = git_output(source_repo.path(), &["rev-parse", "HEAD"]);
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
&marketplace_config(source_repo.path(), "old-revision"),
);
let config = load_plugins_config(codex_home.path()).await;
let outcome = upgrade_configured_git_marketplaces(codex_home.path(), &config);
assert_eq!(
outcome,
ConfiguredMarketplaceUpgradeOutcome {
upgraded_roots: vec![
AbsolutePathBuf::try_from(
marketplace_install_root(codex_home.path()).join("debug")
)
.unwrap()
],
all_succeeded: true,
}
);
assert_eq!(
std::fs::read_to_string(
marketplace_install_root(codex_home.path()).join("debug/plugins/sample/marker.txt")
)
.unwrap(),
"new"
);
let config = std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).unwrap();
assert!(config.contains(&format!(r#"last_revision = "{revision}""#)));
}
#[tokio::test]
async fn upgrade_configured_git_marketplace_skips_matching_revision() {
let codex_home = TempDir::new().unwrap();
let source_repo = TempDir::new().unwrap();
write_marketplace_repo(source_repo.path(), "debug", "new");
init_git_repo(source_repo.path());
let revision = git_output(source_repo.path(), &["rev-parse", "HEAD"]);
let installed_root = marketplace_install_root(codex_home.path()).join("debug");
write_marketplace_repo(&installed_root, "debug", "old");
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
&marketplace_config(source_repo.path(), &revision),
);
let config = load_plugins_config(codex_home.path()).await;
let outcome = upgrade_configured_git_marketplaces(codex_home.path(), &config);
assert_eq!(
outcome,
ConfiguredMarketplaceUpgradeOutcome {
upgraded_roots: Vec::new(),
all_succeeded: true,
}
);
assert_eq!(
std::fs::read_to_string(installed_root.join("plugins/sample/marker.txt")).unwrap(),
"old"
);
}
#[tokio::test]
async fn upgrade_configured_git_marketplace_keeps_existing_root_on_name_mismatch() {
let codex_home = TempDir::new().unwrap();
let source_repo = TempDir::new().unwrap();
write_marketplace_repo(source_repo.path(), "other", "new");
init_git_repo(source_repo.path());
let installed_root = marketplace_install_root(codex_home.path()).join("debug");
write_marketplace_repo(&installed_root, "debug", "old");
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
&marketplace_config(source_repo.path(), "old-revision"),
);
let config = load_plugins_config(codex_home.path()).await;
let outcome = upgrade_configured_git_marketplaces(codex_home.path(), &config);
assert_eq!(
outcome,
ConfiguredMarketplaceUpgradeOutcome {
upgraded_roots: Vec::new(),
all_succeeded: false,
}
);
assert_eq!(
std::fs::read_to_string(installed_root.join("plugins/sample/marker.txt")).unwrap(),
"old"
);
}
#[tokio::test]
async fn upgrade_configured_git_marketplace_keeps_existing_root_on_git_failure() {
let codex_home = TempDir::new().unwrap();
let missing_repo = codex_home.path().join("missing-repo");
let installed_root = marketplace_install_root(codex_home.path()).join("debug");
write_marketplace_repo(&installed_root, "debug", "old");
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
&marketplace_config(&missing_repo, "old-revision"),
);
let config = load_plugins_config(codex_home.path()).await;
let outcome = upgrade_configured_git_marketplaces(codex_home.path(), &config);
assert_eq!(
outcome,
ConfiguredMarketplaceUpgradeOutcome {
upgraded_roots: Vec::new(),
all_succeeded: false,
}
);
assert_eq!(
std::fs::read_to_string(installed_root.join("plugins/sample/marker.txt")).unwrap(),
"old"
);
}
#[tokio::test]
async fn upgrade_configured_git_marketplaces_ignores_local_unconfigured_marketplace() {
let codex_home = TempDir::new().unwrap();
write_marketplace_repo(codex_home.path(), "local", "local");
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
let config = load_plugins_config(codex_home.path()).await;
let outcome = upgrade_configured_git_marketplaces(codex_home.path(), &config);
assert_eq!(
outcome,
ConfiguredMarketplaceUpgradeOutcome {
upgraded_roots: Vec::new(),
all_succeeded: true,
}
);
assert!(
!marketplace_install_root(codex_home.path())
.join("local")
.exists()
);
}
#[test]
fn full_git_sha_ref_is_already_a_remote_revision() {
assert!(is_full_git_sha("0123456789abcdef0123456789abcdef01234567"));
assert!(!is_full_git_sha("main"));
assert!(!is_full_git_sha("0123456"));
}
fn marketplace_config(source_repo: &Path, last_revision: &str) -> String {
format!(
r#"[features]
plugins = true
[marketplaces.debug]
last_updated = "2026-04-10T00:00:00Z"
last_revision = "{last_revision}"
source_type = "git"
source = "{}"
"#,
source_repo.display()
)
}
fn write_marketplace_repo(root: &Path, marketplace_name: &str, marker: &str) {
write_file(
&root.join(".agents/plugins/marketplace.json"),
&format!(
r#"{{
"name": "{marketplace_name}",
"plugins": [
{{
"name": "sample",
"source": {{
"source": "local",
"path": "./plugins/sample"
}}
}}
]
}}"#
),
);
write_file(
&root.join("plugins/sample/.codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
);
write_file(&root.join("plugins/sample/marker.txt"), marker);
}
fn init_git_repo(repo: &Path) {
git(repo, &["init"]);
git(repo, &["config", "user.email", "codex-test@example.com"]);
git(repo, &["config", "user.name", "Codex Test"]);
git(repo, &["add", "."]);
git(repo, &["commit", "-m", "initial marketplace"]);
}
fn git(repo: &Path, args: &[&str]) {
let output = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.output()
.expect("git should run");
assert!(
output.status.success(),
"git -C {} {} failed\nstdout:\n{}\nstderr:\n{}",
repo.display(),
args.join(" "),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
fn git_output(repo: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.output()
.expect("git should run");
assert!(
output.status.success(),
"git -C {} {} failed\nstdout:\n{}\nstderr:\n{}",
repo.display(),
args.join(" "),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
}

View File

@@ -6,6 +6,7 @@ mod installed_marketplaces;
mod manager;
mod manifest;
mod marketplace;
mod marketplace_upgrade;
mod mentions;
mod remote;
mod render;