mirror of
https://github.com/openai/codex.git
synced 2026-06-01 19:02:59 +00:00
feat: Add curated plugin marketplace + Metadata Cleanup. (#13712)
1. Add a synced curated plugin marketplace and include it in marketplace discovery. 2. Expose optional plugin.json interface metadata in plugin/list 3. Tighten plugin and marketplace path handling using validated absolute paths. 4. Let manifests override skill, MCP, and app config paths. 5. Restrict plugin enablement/config loading to the user config layer so plugin enablement is at global level
This commit is contained in:
353
codex-rs/core/src/plugins/curated_repo.rs
Normal file
353
codex-rs/core/src/plugins/curated_repo.rs
Normal file
@@ -0,0 +1,353 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::process::Output;
|
||||
use std::process::Stdio;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
const OPENAI_PLUGINS_REPO_URL: &str = "https://github.com/openai/plugins.git";
|
||||
const CURATED_PLUGINS_RELATIVE_DIR: &str = ".tmp/plugins";
|
||||
const CURATED_PLUGINS_SHA_FILE: &str = ".tmp/plugins.sha";
|
||||
const CURATED_PLUGINS_GIT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
pub(crate) fn curated_plugins_repo_path(codex_home: &Path) -> PathBuf {
|
||||
codex_home.join(CURATED_PLUGINS_RELATIVE_DIR)
|
||||
}
|
||||
|
||||
pub(crate) fn sync_openai_plugins_repo(codex_home: &Path) -> Result<(), String> {
|
||||
let repo_path = curated_plugins_repo_path(codex_home);
|
||||
let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE);
|
||||
let remote_sha = git_ls_remote_head_sha()?;
|
||||
let local_sha = read_local_sha(&repo_path, &sha_path);
|
||||
|
||||
if local_sha.as_deref() == Some(remote_sha.as_str()) && repo_path.join(".git").is_dir() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(parent) = repo_path.parent() else {
|
||||
return Err(format!(
|
||||
"failed to determine curated plugins parent directory for {}",
|
||||
repo_path.display()
|
||||
));
|
||||
};
|
||||
fs::create_dir_all(parent).map_err(|err| {
|
||||
format!(
|
||||
"failed to create curated plugins parent directory {}: {err}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let clone_dir = tempfile::Builder::new()
|
||||
.prefix("plugins-clone-")
|
||||
.tempdir_in(parent)
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
"failed to create temporary curated plugins directory in {}: {err}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
let cloned_repo_path = clone_dir.path().join("repo");
|
||||
let clone_output = run_git_command_with_timeout(
|
||||
Command::new("git")
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.arg("clone")
|
||||
.arg("--depth")
|
||||
.arg("1")
|
||||
.arg(OPENAI_PLUGINS_REPO_URL)
|
||||
.arg(&cloned_repo_path),
|
||||
"git clone curated plugins repo",
|
||||
CURATED_PLUGINS_GIT_TIMEOUT,
|
||||
)?;
|
||||
ensure_git_success(&clone_output, "git clone curated plugins repo")?;
|
||||
|
||||
let cloned_sha = git_head_sha(&cloned_repo_path)?;
|
||||
if cloned_sha != remote_sha {
|
||||
return Err(format!(
|
||||
"curated plugins clone HEAD mismatch: expected {remote_sha}, got {cloned_sha}"
|
||||
));
|
||||
}
|
||||
|
||||
if repo_path.exists() {
|
||||
let backup_dir = tempfile::Builder::new()
|
||||
.prefix("plugins-backup-")
|
||||
.tempdir_in(parent)
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
"failed to create curated plugins backup directory in {}: {err}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
let backup_repo_path = backup_dir.path().join("repo");
|
||||
|
||||
fs::rename(&repo_path, &backup_repo_path).map_err(|err| {
|
||||
format!(
|
||||
"failed to move previous curated plugins repo out of the way at {}: {err}",
|
||||
repo_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Err(err) = fs::rename(&cloned_repo_path, &repo_path) {
|
||||
let rollback_result = fs::rename(&backup_repo_path, &repo_path);
|
||||
return match rollback_result {
|
||||
Ok(()) => Err(format!(
|
||||
"failed to activate new curated plugins repo at {}: {err}",
|
||||
repo_path.display()
|
||||
)),
|
||||
Err(rollback_err) => {
|
||||
let backup_path = backup_dir.keep().join("repo");
|
||||
Err(format!(
|
||||
"failed to activate new curated plugins repo at {}: {err}; failed to restore previous repo (left at {}): {rollback_err}",
|
||||
repo_path.display(),
|
||||
backup_path.display()
|
||||
))
|
||||
}
|
||||
};
|
||||
}
|
||||
} else {
|
||||
fs::rename(&cloned_repo_path, &repo_path).map_err(|err| {
|
||||
format!(
|
||||
"failed to activate curated plugins repo at {}: {err}",
|
||||
repo_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
if let Some(parent) = sha_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|err| {
|
||||
format!(
|
||||
"failed to create curated plugins sha directory {}: {err}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
fs::write(&sha_path, format!("{cloned_sha}\n")).map_err(|err| {
|
||||
format!(
|
||||
"failed to write curated plugins sha file {}: {err}",
|
||||
sha_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_local_sha(repo_path: &Path, sha_path: &Path) -> Option<String> {
|
||||
if repo_path.join(".git").is_dir()
|
||||
&& let Ok(sha) = git_head_sha(repo_path)
|
||||
{
|
||||
return Some(sha);
|
||||
}
|
||||
|
||||
fs::read_to_string(sha_path)
|
||||
.ok()
|
||||
.map(|sha| sha.trim().to_string())
|
||||
.filter(|sha| !sha.is_empty())
|
||||
}
|
||||
|
||||
fn git_ls_remote_head_sha() -> Result<String, String> {
|
||||
let output = run_git_command_with_timeout(
|
||||
Command::new("git")
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.arg("ls-remote")
|
||||
.arg(OPENAI_PLUGINS_REPO_URL)
|
||||
.arg("HEAD"),
|
||||
"git ls-remote curated plugins repo",
|
||||
CURATED_PLUGINS_GIT_TIMEOUT,
|
||||
)?;
|
||||
ensure_git_success(&output, "git ls-remote curated plugins repo")?;
|
||||
|
||||
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 curated plugins repo".to_string());
|
||||
};
|
||||
let Some((sha, _)) = first_line.split_once('\t') else {
|
||||
return Err(format!(
|
||||
"unexpected git ls-remote output for curated plugins repo: {first_line}"
|
||||
));
|
||||
};
|
||||
if sha.is_empty() {
|
||||
return Err("git ls-remote returned empty sha for curated plugins repo".to_string());
|
||||
}
|
||||
Ok(sha.to_string())
|
||||
}
|
||||
|
||||
fn git_head_sha(repo_path: &Path) -> Result<String, String> {
|
||||
let output = Command::new("git")
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.arg("-C")
|
||||
.arg(repo_path)
|
||||
.arg("rev-parse")
|
||||
.arg("HEAD")
|
||||
.output()
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
"failed to run git rev-parse HEAD in {}: {err}",
|
||||
repo_path.display()
|
||||
)
|
||||
})?;
|
||||
ensure_git_success(&output, "git rev-parse HEAD")?;
|
||||
|
||||
let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if sha.is_empty() {
|
||||
return Err(format!(
|
||||
"git rev-parse HEAD returned empty output in {}",
|
||||
repo_path.display()
|
||||
));
|
||||
}
|
||||
Ok(sha)
|
||||
}
|
||||
|
||||
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 = 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 {
|
||||
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}")),
|
||||
}
|
||||
|
||||
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()
|
||||
))
|
||||
};
|
||||
}
|
||||
|
||||
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 pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn curated_plugins_repo_path_uses_codex_home_tmp_dir() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
assert_eq!(
|
||||
curated_plugins_repo_path(tmp.path()),
|
||||
tmp.path().join(".tmp/plugins")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_local_sha_prefers_repo_head_when_available() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let repo_path = tmp.path().join("repo");
|
||||
let sha_path = tmp.path().join("plugins.sha");
|
||||
|
||||
fs::create_dir_all(&repo_path).expect("create repo dir");
|
||||
fs::write(&sha_path, "abc123\n").expect("write sha");
|
||||
let init_output = Command::new("git")
|
||||
.arg("init")
|
||||
.arg(&repo_path)
|
||||
.output()
|
||||
.expect("git init should run");
|
||||
ensure_git_success(&init_output, "git init").expect("git init should succeed");
|
||||
let config_name_output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repo_path)
|
||||
.arg("config")
|
||||
.arg("user.name")
|
||||
.arg("Codex")
|
||||
.output()
|
||||
.expect("git config user.name should run");
|
||||
ensure_git_success(&config_name_output, "git config user.name")
|
||||
.expect("git config user.name should succeed");
|
||||
let config_email_output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repo_path)
|
||||
.arg("config")
|
||||
.arg("user.email")
|
||||
.arg("codex@example.com")
|
||||
.output()
|
||||
.expect("git config user.email should run");
|
||||
ensure_git_success(&config_email_output, "git config user.email")
|
||||
.expect("git config user.email should succeed");
|
||||
fs::write(repo_path.join("README.md"), "demo\n").expect("write file");
|
||||
let add_output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repo_path)
|
||||
.arg("add")
|
||||
.arg(".")
|
||||
.output()
|
||||
.expect("git add should run");
|
||||
ensure_git_success(&add_output, "git add").expect("git add should succeed");
|
||||
let commit_output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repo_path)
|
||||
.arg("commit")
|
||||
.arg("-m")
|
||||
.arg("init")
|
||||
.output()
|
||||
.expect("git commit should run");
|
||||
ensure_git_success(&commit_output, "git commit").expect("git commit should succeed");
|
||||
|
||||
let sha = read_local_sha(&repo_path, &sha_path);
|
||||
assert_eq!(sha, Some(git_head_sha(&repo_path).expect("repo head sha")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_local_sha_falls_back_to_sha_file() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let repo_path = tmp.path().join("repo");
|
||||
let sha_path = tmp.path().join("plugins.sha");
|
||||
fs::write(&sha_path, "abc123\n").expect("write sha");
|
||||
|
||||
let sha = read_local_sha(&repo_path, &sha_path);
|
||||
assert_eq!(sha.as_deref(), Some("abc123"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,89 @@
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Deserialize;
|
||||
use std::fs;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json";
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct PluginManifest {
|
||||
#[serde(default)]
|
||||
pub(crate) name: String,
|
||||
#[serde(default)]
|
||||
pub(crate) description: Option<String>,
|
||||
// Keep manifest paths as raw strings so we can validate the required `./...` syntax before
|
||||
// resolving them under the plugin root.
|
||||
#[serde(default)]
|
||||
skills: Option<String>,
|
||||
#[serde(default)]
|
||||
mcp_servers: Option<String>,
|
||||
#[serde(default)]
|
||||
apps: Option<String>,
|
||||
#[serde(default)]
|
||||
interface: Option<PluginManifestInterface>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginManifestPaths {
|
||||
pub skills: Option<AbsolutePathBuf>,
|
||||
pub mcp_servers: Option<AbsolutePathBuf>,
|
||||
pub apps: Option<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginManifestInterfaceSummary {
|
||||
pub display_name: Option<String>,
|
||||
pub short_description: Option<String>,
|
||||
pub long_description: Option<String>,
|
||||
pub developer_name: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub capabilities: Vec<String>,
|
||||
pub website_url: Option<String>,
|
||||
pub privacy_policy_url: Option<String>,
|
||||
pub terms_of_service_url: Option<String>,
|
||||
pub default_prompt: Option<String>,
|
||||
pub brand_color: Option<String>,
|
||||
pub composer_icon: Option<AbsolutePathBuf>,
|
||||
pub logo: Option<AbsolutePathBuf>,
|
||||
pub screenshots: Vec<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PluginManifestInterface {
|
||||
#[serde(default)]
|
||||
display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
short_description: Option<String>,
|
||||
#[serde(default)]
|
||||
long_description: Option<String>,
|
||||
#[serde(default)]
|
||||
developer_name: Option<String>,
|
||||
#[serde(default)]
|
||||
category: Option<String>,
|
||||
#[serde(default)]
|
||||
capabilities: Vec<String>,
|
||||
#[serde(default)]
|
||||
#[serde(alias = "websiteURL")]
|
||||
website_url: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(alias = "privacyPolicyURL")]
|
||||
privacy_policy_url: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(alias = "termsOfServiceURL")]
|
||||
terms_of_service_url: Option<String>,
|
||||
#[serde(default)]
|
||||
default_prompt: Option<String>,
|
||||
#[serde(default)]
|
||||
brand_color: Option<String>,
|
||||
#[serde(default)]
|
||||
composer_icon: Option<String>,
|
||||
#[serde(default)]
|
||||
logo: Option<String>,
|
||||
#[serde(default)]
|
||||
screenshots: Vec<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest> {
|
||||
@@ -36,3 +112,123 @@ pub(crate) fn plugin_manifest_name(manifest: &PluginManifest, plugin_root: &Path
|
||||
.unwrap_or(&manifest.name)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn plugin_manifest_interface(
|
||||
manifest: &PluginManifest,
|
||||
plugin_root: &Path,
|
||||
) -> Option<PluginManifestInterfaceSummary> {
|
||||
let interface = manifest.interface.as_ref()?;
|
||||
let interface = PluginManifestInterfaceSummary {
|
||||
display_name: interface.display_name.clone(),
|
||||
short_description: interface.short_description.clone(),
|
||||
long_description: interface.long_description.clone(),
|
||||
developer_name: interface.developer_name.clone(),
|
||||
category: interface.category.clone(),
|
||||
capabilities: interface.capabilities.clone(),
|
||||
website_url: interface.website_url.clone(),
|
||||
privacy_policy_url: interface.privacy_policy_url.clone(),
|
||||
terms_of_service_url: interface.terms_of_service_url.clone(),
|
||||
default_prompt: interface.default_prompt.clone(),
|
||||
brand_color: interface.brand_color.clone(),
|
||||
composer_icon: resolve_interface_asset_path(
|
||||
plugin_root,
|
||||
"interface.composerIcon",
|
||||
interface.composer_icon.as_deref(),
|
||||
),
|
||||
logo: resolve_interface_asset_path(
|
||||
plugin_root,
|
||||
"interface.logo",
|
||||
interface.logo.as_deref(),
|
||||
),
|
||||
screenshots: interface
|
||||
.screenshots
|
||||
.iter()
|
||||
.filter_map(|screenshot| {
|
||||
resolve_interface_asset_path(plugin_root, "interface.screenshots", Some(screenshot))
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
let has_fields = interface.display_name.is_some()
|
||||
|| interface.short_description.is_some()
|
||||
|| interface.long_description.is_some()
|
||||
|| interface.developer_name.is_some()
|
||||
|| interface.category.is_some()
|
||||
|| !interface.capabilities.is_empty()
|
||||
|| interface.website_url.is_some()
|
||||
|| interface.privacy_policy_url.is_some()
|
||||
|| interface.terms_of_service_url.is_some()
|
||||
|| interface.default_prompt.is_some()
|
||||
|| interface.brand_color.is_some()
|
||||
|| interface.composer_icon.is_some()
|
||||
|| interface.logo.is_some()
|
||||
|| !interface.screenshots.is_empty();
|
||||
|
||||
has_fields.then_some(interface)
|
||||
}
|
||||
|
||||
pub(crate) fn plugin_manifest_paths(
|
||||
manifest: &PluginManifest,
|
||||
plugin_root: &Path,
|
||||
) -> PluginManifestPaths {
|
||||
PluginManifestPaths {
|
||||
skills: resolve_manifest_path(plugin_root, "skills", manifest.skills.as_deref()),
|
||||
mcp_servers: resolve_manifest_path(
|
||||
plugin_root,
|
||||
"mcpServers",
|
||||
manifest.mcp_servers.as_deref(),
|
||||
),
|
||||
apps: resolve_manifest_path(plugin_root, "apps", manifest.apps.as_deref()),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_interface_asset_path(
|
||||
plugin_root: &Path,
|
||||
field: &'static str,
|
||||
path: Option<&str>,
|
||||
) -> Option<AbsolutePathBuf> {
|
||||
resolve_manifest_path(plugin_root, field, path)
|
||||
}
|
||||
|
||||
fn resolve_manifest_path(
|
||||
plugin_root: &Path,
|
||||
field: &'static str,
|
||||
path: Option<&str>,
|
||||
) -> Option<AbsolutePathBuf> {
|
||||
// `plugin.json` paths are required to be relative to the plugin root and we return the
|
||||
// normalized absolute path to the rest of the system.
|
||||
let path = path?;
|
||||
if path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let Some(relative_path) = path.strip_prefix("./") else {
|
||||
tracing::warn!("ignoring {field}: path must start with `./` relative to plugin root");
|
||||
return None;
|
||||
};
|
||||
if relative_path.is_empty() {
|
||||
tracing::warn!("ignoring {field}: path must not be `./`");
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut normalized = std::path::PathBuf::new();
|
||||
for component in Path::new(relative_path).components() {
|
||||
match component {
|
||||
Component::Normal(component) => normalized.push(component),
|
||||
Component::ParentDir => {
|
||||
tracing::warn!("ignoring {field}: path must not contain '..'");
|
||||
return None;
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!("ignoring {field}: path must stay within the plugin root");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AbsolutePathBuf::try_from(plugin_root.join(normalized))
|
||||
.map_err(|err| {
|
||||
tracing::warn!("ignoring {field}: path must resolve to an absolute path: {err}");
|
||||
err
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
use super::PluginManifestInterfaceSummary;
|
||||
use super::load_plugin_manifest;
|
||||
use super::plugin_manifest_interface;
|
||||
use super::store::PluginId;
|
||||
use super::store::PluginIdError;
|
||||
use crate::git_info::get_git_repo_root;
|
||||
@@ -21,7 +24,7 @@ pub struct ResolvedMarketplacePlugin {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MarketplaceSummary {
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
pub path: AbsolutePathBuf,
|
||||
pub plugins: Vec<MarketplacePluginSummary>,
|
||||
}
|
||||
|
||||
@@ -29,11 +32,12 @@ pub struct MarketplaceSummary {
|
||||
pub struct MarketplacePluginSummary {
|
||||
pub name: String,
|
||||
pub source: MarketplacePluginSourceSummary,
|
||||
pub interface: Option<PluginManifestInterfaceSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum MarketplacePluginSourceSummary {
|
||||
Local { path: PathBuf },
|
||||
Local { path: AbsolutePathBuf },
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -73,7 +77,7 @@ pub fn resolve_marketplace_plugin(
|
||||
marketplace_path: &AbsolutePathBuf,
|
||||
plugin_name: &str,
|
||||
) -> Result<ResolvedMarketplacePlugin, MarketplaceError> {
|
||||
let marketplace = load_marketplace(marketplace_path.as_path())?;
|
||||
let marketplace = load_marketplace(marketplace_path)?;
|
||||
let marketplace_name = marketplace.name;
|
||||
let plugin = marketplace
|
||||
.plugins
|
||||
@@ -92,7 +96,7 @@ pub fn resolve_marketplace_plugin(
|
||||
})?;
|
||||
Ok(ResolvedMarketplacePlugin {
|
||||
plugin_id,
|
||||
source_path: resolve_plugin_source_path(marketplace_path.as_path(), plugin.source)?,
|
||||
source_path: resolve_plugin_source_path(marketplace_path, plugin.source)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -109,23 +113,21 @@ fn list_marketplaces_with_home(
|
||||
let mut marketplaces = Vec::new();
|
||||
|
||||
for marketplace_path in discover_marketplace_paths_from_roots(additional_roots, home_dir) {
|
||||
let marketplace = load_marketplace(marketplace_path.as_path())?;
|
||||
let marketplace = load_marketplace(&marketplace_path)?;
|
||||
let mut plugins = Vec::new();
|
||||
|
||||
for plugin in marketplace.plugins {
|
||||
let source = match plugin.source {
|
||||
MarketplacePluginSource::Local { path } => MarketplacePluginSourceSummary::Local {
|
||||
path: resolve_plugin_source_path(
|
||||
marketplace_path.as_path(),
|
||||
MarketplacePluginSource::Local { path },
|
||||
)?
|
||||
.into_path_buf(),
|
||||
},
|
||||
let source_path = resolve_plugin_source_path(&marketplace_path, plugin.source)?;
|
||||
let source = MarketplacePluginSourceSummary::Local {
|
||||
path: source_path.clone(),
|
||||
};
|
||||
let interface = load_plugin_manifest(source_path.as_path())
|
||||
.and_then(|manifest| plugin_manifest_interface(&manifest, source_path.as_path()));
|
||||
|
||||
plugins.push(MarketplacePluginSummary {
|
||||
name: plugin.name,
|
||||
source,
|
||||
interface,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -142,30 +144,34 @@ fn list_marketplaces_with_home(
|
||||
fn discover_marketplace_paths_from_roots(
|
||||
additional_roots: &[AbsolutePathBuf],
|
||||
home_dir: Option<&Path>,
|
||||
) -> Vec<PathBuf> {
|
||||
) -> Vec<AbsolutePathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
|
||||
if let Some(home) = home_dir {
|
||||
let path = home.join(MARKETPLACE_RELATIVE_PATH);
|
||||
if path.is_file() {
|
||||
if path.is_file()
|
||||
&& let Ok(path) = AbsolutePathBuf::try_from(path)
|
||||
{
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
for root in additional_roots {
|
||||
if let Some(repo_root) = get_git_repo_root(root.as_path()) {
|
||||
let path = repo_root.join(MARKETPLACE_RELATIVE_PATH);
|
||||
if path.is_file() && !paths.contains(&path) {
|
||||
paths.push(path);
|
||||
}
|
||||
if let Some(repo_root) = get_git_repo_root(root.as_path())
|
||||
&& let Ok(repo_root) = AbsolutePathBuf::try_from(repo_root)
|
||||
&& let Ok(path) = repo_root.join(MARKETPLACE_RELATIVE_PATH)
|
||||
&& path.as_path().is_file()
|
||||
&& !paths.contains(&path)
|
||||
{
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
fn load_marketplace(path: &Path) -> Result<MarketplaceFile, MarketplaceError> {
|
||||
let contents = fs::read_to_string(path).map_err(|err| {
|
||||
fn load_marketplace(path: &AbsolutePathBuf) -> Result<MarketplaceFile, MarketplaceError> {
|
||||
let contents = fs::read_to_string(path.as_path()).map_err(|err| {
|
||||
if err.kind() == io::ErrorKind::NotFound {
|
||||
MarketplaceError::MarketplaceNotFound {
|
||||
path: path.to_path_buf(),
|
||||
@@ -181,7 +187,7 @@ fn load_marketplace(path: &Path) -> Result<MarketplaceFile, MarketplaceError> {
|
||||
}
|
||||
|
||||
fn resolve_plugin_source_path(
|
||||
marketplace_path: &Path,
|
||||
marketplace_path: &AbsolutePathBuf,
|
||||
source: MarketplacePluginSource,
|
||||
) -> Result<AbsolutePathBuf, MarketplaceError> {
|
||||
match source {
|
||||
@@ -206,25 +212,61 @@ fn resolve_plugin_source_path(
|
||||
{
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: "local plugin source path must stay within the marketplace directory"
|
||||
message: "local plugin source path must stay within the marketplace root"
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let source_path = marketplace_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.join(relative_source_path);
|
||||
AbsolutePathBuf::try_from(source_path).map_err(|err| {
|
||||
MarketplaceError::InvalidMarketplaceFile {
|
||||
// `marketplace.json` lives under `<root>/.agents/plugins/`, but local plugin paths
|
||||
// are resolved relative to `<root>`, not relative to the `plugins/` directory.
|
||||
marketplace_root_dir(marketplace_path)?
|
||||
.join(relative_source_path)
|
||||
.map_err(|err| MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: format!("plugin source path must resolve to an absolute path: {err}"),
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn marketplace_root_dir(
|
||||
marketplace_path: &AbsolutePathBuf,
|
||||
) -> Result<AbsolutePathBuf, MarketplaceError> {
|
||||
let Some(plugins_dir) = marketplace_path.parent() else {
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: "marketplace file must live under `<root>/.agents/plugins/`".to_string(),
|
||||
});
|
||||
};
|
||||
let Some(dot_agents_dir) = plugins_dir.parent() else {
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: "marketplace file must live under `<root>/.agents/plugins/`".to_string(),
|
||||
});
|
||||
};
|
||||
let Some(marketplace_root) = dot_agents_dir.parent() else {
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: "marketplace file must live under `<root>/.agents/plugins/`".to_string(),
|
||||
});
|
||||
};
|
||||
|
||||
if plugins_dir.as_path().file_name().and_then(|s| s.to_str()) != Some("plugins")
|
||||
|| dot_agents_dir
|
||||
.as_path()
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
!= Some(".agents")
|
||||
{
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: "marketplace file must live under `<root>/.agents/plugins/`".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(marketplace_root)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct MarketplaceFile {
|
||||
name: String,
|
||||
@@ -284,8 +326,7 @@ mod tests {
|
||||
ResolvedMarketplacePlugin {
|
||||
plugin_id: PluginId::new("local-plugin".to_string(), "codex-curated".to_string())
|
||||
.unwrap(),
|
||||
source_path: AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/plugin-1"))
|
||||
.unwrap(),
|
||||
source_path: AbsolutePathBuf::try_from(repo_root.join("plugin-1")).unwrap(),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -381,37 +422,51 @@ mod tests {
|
||||
vec![
|
||||
MarketplaceSummary {
|
||||
name: "codex-curated".to_string(),
|
||||
path: home_root.join(".agents/plugins/marketplace.json"),
|
||||
path: AbsolutePathBuf::try_from(
|
||||
home_root.join(".agents/plugins/marketplace.json"),
|
||||
)
|
||||
.unwrap(),
|
||||
plugins: vec![
|
||||
MarketplacePluginSummary {
|
||||
name: "shared-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: home_root.join(".agents/plugins/home-shared"),
|
||||
path: AbsolutePathBuf::try_from(home_root.join("home-shared"))
|
||||
.unwrap(),
|
||||
},
|
||||
interface: None,
|
||||
},
|
||||
MarketplacePluginSummary {
|
||||
name: "home-only".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: home_root.join(".agents/plugins/home-only"),
|
||||
path: AbsolutePathBuf::try_from(home_root.join("home-only"))
|
||||
.unwrap(),
|
||||
},
|
||||
interface: None,
|
||||
},
|
||||
],
|
||||
},
|
||||
MarketplaceSummary {
|
||||
name: "codex-curated".to_string(),
|
||||
path: repo_root.join(".agents/plugins/marketplace.json"),
|
||||
path: AbsolutePathBuf::try_from(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
)
|
||||
.unwrap(),
|
||||
plugins: vec![
|
||||
MarketplacePluginSummary {
|
||||
name: "shared-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: repo_root.join(".agents/plugins/repo-shared"),
|
||||
path: AbsolutePathBuf::try_from(repo_root.join("repo-shared"))
|
||||
.unwrap(),
|
||||
},
|
||||
interface: None,
|
||||
},
|
||||
MarketplacePluginSummary {
|
||||
name: "repo-only".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: repo_root.join(".agents/plugins/repo-only"),
|
||||
path: AbsolutePathBuf::try_from(repo_root.join("repo-only"))
|
||||
.unwrap(),
|
||||
},
|
||||
interface: None,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -475,22 +530,24 @@ mod tests {
|
||||
vec![
|
||||
MarketplaceSummary {
|
||||
name: "codex-curated".to_string(),
|
||||
path: home_marketplace,
|
||||
path: AbsolutePathBuf::try_from(home_marketplace).unwrap(),
|
||||
plugins: vec![MarketplacePluginSummary {
|
||||
name: "local-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: home_root.join(".agents/plugins/home-plugin"),
|
||||
path: AbsolutePathBuf::try_from(home_root.join("home-plugin")).unwrap(),
|
||||
},
|
||||
interface: None,
|
||||
}],
|
||||
},
|
||||
MarketplaceSummary {
|
||||
name: "codex-curated".to_string(),
|
||||
path: repo_marketplace.clone(),
|
||||
path: AbsolutePathBuf::try_from(repo_marketplace.clone()).unwrap(),
|
||||
plugins: vec![MarketplacePluginSummary {
|
||||
name: "local-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: repo_root.join(".agents/plugins/repo-plugin"),
|
||||
path: AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap(),
|
||||
},
|
||||
interface: None,
|
||||
}],
|
||||
},
|
||||
]
|
||||
@@ -504,7 +561,7 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
resolved.source_path,
|
||||
AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/repo-plugin")).unwrap()
|
||||
AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -547,17 +604,152 @@ mod tests {
|
||||
marketplaces,
|
||||
vec![MarketplaceSummary {
|
||||
name: "codex-curated".to_string(),
|
||||
path: repo_root.join(".agents/plugins/marketplace.json"),
|
||||
path: AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json"))
|
||||
.unwrap(),
|
||||
plugins: vec![MarketplacePluginSummary {
|
||||
name: "local-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: repo_root.join(".agents/plugins/plugin"),
|
||||
path: AbsolutePathBuf::try_from(repo_root.join("plugin")).unwrap(),
|
||||
},
|
||||
interface: None,
|
||||
}],
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marketplaces_resolves_plugin_interface_paths_to_absolute() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
let plugin_root = repo_root.join("plugins/demo-plugin");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "demo-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/demo-plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{
|
||||
"name": "demo-plugin",
|
||||
"interface": {
|
||||
"displayName": "Demo",
|
||||
"capabilities": ["Interactive", "Write"],
|
||||
"composerIcon": "./assets/icon.png",
|
||||
"logo": "./assets/logo.png",
|
||||
"screenshots": ["./assets/shot1.png"]
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let marketplaces =
|
||||
list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
marketplaces[0].plugins[0].interface,
|
||||
Some(PluginManifestInterfaceSummary {
|
||||
display_name: Some("Demo".to_string()),
|
||||
short_description: None,
|
||||
long_description: None,
|
||||
developer_name: None,
|
||||
category: None,
|
||||
capabilities: vec!["Interactive".to_string(), "Write".to_string()],
|
||||
website_url: None,
|
||||
privacy_policy_url: None,
|
||||
terms_of_service_url: None,
|
||||
default_prompt: None,
|
||||
brand_color: None,
|
||||
composer_icon: Some(
|
||||
AbsolutePathBuf::try_from(plugin_root.join("assets/icon.png")).unwrap(),
|
||||
),
|
||||
logo: Some(AbsolutePathBuf::try_from(plugin_root.join("assets/logo.png")).unwrap()),
|
||||
screenshots: vec![
|
||||
AbsolutePathBuf::try_from(plugin_root.join("assets/shot1.png")).unwrap(),
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marketplaces_ignores_plugin_interface_assets_without_dot_slash() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
let plugin_root = repo_root.join("plugins/demo-plugin");
|
||||
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "demo-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/demo-plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{
|
||||
"name": "demo-plugin",
|
||||
"interface": {
|
||||
"displayName": "Demo",
|
||||
"capabilities": ["Interactive"],
|
||||
"composerIcon": "assets/icon.png",
|
||||
"logo": "/tmp/logo.png",
|
||||
"screenshots": ["assets/shot1.png"]
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let marketplaces =
|
||||
list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
marketplaces[0].plugins[0].interface,
|
||||
Some(PluginManifestInterfaceSummary {
|
||||
display_name: Some("Demo".to_string()),
|
||||
short_description: None,
|
||||
long_description: None,
|
||||
developer_name: None,
|
||||
category: None,
|
||||
capabilities: vec!["Interactive".to_string()],
|
||||
website_url: None,
|
||||
privacy_policy_url: None,
|
||||
terms_of_service_url: None,
|
||||
default_prompt: None,
|
||||
brand_color: None,
|
||||
composer_icon: None,
|
||||
logo: None,
|
||||
screenshots: Vec::new(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_marketplace_plugin_rejects_non_relative_local_paths() {
|
||||
let tmp = tempdir().unwrap();
|
||||
@@ -634,7 +826,7 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
resolved.source_path,
|
||||
AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/first")).unwrap()
|
||||
AbsolutePathBuf::try_from(repo_root.join("first")).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod curated_repo;
|
||||
mod injection;
|
||||
mod manager;
|
||||
mod manifest;
|
||||
@@ -5,6 +6,8 @@ mod marketplace;
|
||||
mod render;
|
||||
mod store;
|
||||
|
||||
pub(crate) use curated_repo::curated_plugins_repo_path;
|
||||
pub(crate) use curated_repo::sync_openai_plugins_repo;
|
||||
pub(crate) use injection::build_plugin_injections;
|
||||
pub use manager::AppConnectorId;
|
||||
pub use manager::ConfiguredMarketplacePluginSummary;
|
||||
@@ -17,8 +20,12 @@ pub use manager::PluginLoadOutcome;
|
||||
pub use manager::PluginsManager;
|
||||
pub use manager::load_plugin_apps;
|
||||
pub(crate) use manager::plugin_namespace_for_skill_path;
|
||||
pub use manifest::PluginManifestInterfaceSummary;
|
||||
pub(crate) use manifest::PluginManifestPaths;
|
||||
pub(crate) use manifest::load_plugin_manifest;
|
||||
pub(crate) use manifest::plugin_manifest_interface;
|
||||
pub(crate) use manifest::plugin_manifest_name;
|
||||
pub(crate) use manifest::plugin_manifest_paths;
|
||||
pub use marketplace::MarketplaceError;
|
||||
pub use marketplace::MarketplacePluginSourceSummary;
|
||||
pub(crate) use render::render_explicit_plugin_instructions;
|
||||
|
||||
@@ -61,7 +61,7 @@ impl PluginId {
|
||||
pub struct PluginInstallResult {
|
||||
pub plugin_id: PluginId,
|
||||
pub plugin_version: String,
|
||||
pub installed_path: PathBuf,
|
||||
pub installed_path: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -92,19 +92,25 @@ impl PluginStore {
|
||||
.unwrap_or_else(|err| panic!("plugin cache path should resolve to an absolute path: {err}"))
|
||||
}
|
||||
|
||||
pub fn is_installed(&self, plugin_id: &PluginId) -> bool {
|
||||
self.plugin_root(plugin_id, DEFAULT_PLUGIN_VERSION)
|
||||
.as_path()
|
||||
.is_dir()
|
||||
}
|
||||
|
||||
pub fn install(
|
||||
&self,
|
||||
source_path: PathBuf,
|
||||
source_path: AbsolutePathBuf,
|
||||
plugin_id: PluginId,
|
||||
) -> Result<PluginInstallResult, PluginStoreError> {
|
||||
if !source_path.is_dir() {
|
||||
if !source_path.as_path().is_dir() {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"plugin source path is not a directory: {}",
|
||||
source_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let plugin_name = plugin_name_for_source(&source_path)?;
|
||||
let plugin_name = plugin_name_for_source(source_path.as_path())?;
|
||||
if plugin_name != plugin_id.plugin_name {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"plugin manifest name `{plugin_name}` does not match marketplace plugin name `{}`",
|
||||
@@ -112,18 +118,16 @@ impl PluginStore {
|
||||
)));
|
||||
}
|
||||
let plugin_version = DEFAULT_PLUGIN_VERSION.to_string();
|
||||
let installed_path = self
|
||||
.plugin_root(&plugin_id, &plugin_version)
|
||||
.into_path_buf();
|
||||
let installed_path = self.plugin_root(&plugin_id, &plugin_version);
|
||||
|
||||
if let Some(parent) = installed_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|err| {
|
||||
fs::create_dir_all(parent.as_path()).map_err(|err| {
|
||||
PluginStoreError::io("failed to create plugin cache directory", err)
|
||||
})?;
|
||||
}
|
||||
|
||||
remove_existing_target(&installed_path)?;
|
||||
copy_dir_recursive(&source_path, &installed_path)?;
|
||||
remove_existing_target(installed_path.as_path())?;
|
||||
copy_dir_recursive(source_path.as_path(), installed_path.as_path())?;
|
||||
|
||||
Ok(PluginInstallResult {
|
||||
plugin_id,
|
||||
@@ -257,7 +261,10 @@ mod tests {
|
||||
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
|
||||
|
||||
let result = PluginStore::new(tmp.path().to_path_buf())
|
||||
.install(tmp.path().join("sample-plugin"), plugin_id.clone())
|
||||
.install(
|
||||
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
|
||||
plugin_id.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let installed_path = tmp.path().join("plugins/cache/debug/sample-plugin/local");
|
||||
@@ -266,7 +273,7 @@ mod tests {
|
||||
PluginInstallResult {
|
||||
plugin_id,
|
||||
plugin_version: "local".to_string(),
|
||||
installed_path: installed_path.clone(),
|
||||
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
|
||||
}
|
||||
);
|
||||
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
|
||||
@@ -280,7 +287,10 @@ mod tests {
|
||||
let plugin_id = PluginId::new("manifest-name".to_string(), "market".to_string()).unwrap();
|
||||
|
||||
let result = PluginStore::new(tmp.path().to_path_buf())
|
||||
.install(tmp.path().join("source-dir"), plugin_id.clone())
|
||||
.install(
|
||||
AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(),
|
||||
plugin_id.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
@@ -288,7 +298,10 @@ mod tests {
|
||||
PluginInstallResult {
|
||||
plugin_id,
|
||||
plugin_version: "local".to_string(),
|
||||
installed_path: tmp.path().join("plugins/cache/market/manifest-name/local"),
|
||||
installed_path: AbsolutePathBuf::try_from(
|
||||
tmp.path().join("plugins/cache/market/manifest-name/local"),
|
||||
)
|
||||
.unwrap(),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -327,7 +340,7 @@ mod tests {
|
||||
|
||||
let err = PluginStore::new(tmp.path().to_path_buf())
|
||||
.install(
|
||||
tmp.path().join("source-dir"),
|
||||
AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(),
|
||||
PluginId::new("source-dir".to_string(), "debug".to_string()).unwrap(),
|
||||
)
|
||||
.unwrap_err();
|
||||
@@ -355,7 +368,7 @@ mod tests {
|
||||
|
||||
let err = PluginStore::new(tmp.path().to_path_buf())
|
||||
.install(
|
||||
tmp.path().join("source-dir"),
|
||||
AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(),
|
||||
PluginId::new("different-name".to_string(), "debug".to_string()).unwrap(),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
Reference in New Issue
Block a user