add (auto) updating for plugins (#9061)
This commit is contained in:
@@ -14,7 +14,7 @@ use goose_mcp::{AutoVisualiserRouter, ComputerControllerServer, MemoryServer, Tu
|
||||
use crate::commands::configure::configure_telemetry_consent_dialog;
|
||||
use crate::commands::configure::handle_configure;
|
||||
use crate::commands::info::handle_info;
|
||||
use crate::commands::plugin::handle_plugin_install;
|
||||
use crate::commands::plugin::{handle_plugin_install, handle_plugin_update};
|
||||
use crate::commands::project::{handle_project_default, handle_projects_interactive};
|
||||
use crate::commands::recipe::{handle_deeplink, handle_list, handle_open, handle_validate};
|
||||
use crate::commands::term::{
|
||||
@@ -649,9 +649,22 @@ enum PluginCommand {
|
||||
/// Install a plugin from a git repository URL
|
||||
#[command(about = "Install a plugin from a git repository URL")]
|
||||
Install {
|
||||
#[arg(
|
||||
long,
|
||||
help = "Automatically update this plugin before plugin skills are loaded"
|
||||
)]
|
||||
auto_update: bool,
|
||||
|
||||
#[arg(help = "URL to a git repository containing a supported plugin")]
|
||||
url: String,
|
||||
},
|
||||
|
||||
/// Update an installed git-backed plugin
|
||||
#[command(about = "Update an installed git-backed plugin")]
|
||||
Update {
|
||||
#[arg(help = "Name of the installed plugin to update")]
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -1550,7 +1563,8 @@ async fn handle_schedule_command(command: SchedulerCommand) -> Result<()> {
|
||||
|
||||
fn handle_plugin_subcommand(command: PluginCommand) -> Result<()> {
|
||||
match command {
|
||||
PluginCommand::Install { url } => handle_plugin_install(&url),
|
||||
PluginCommand::Install { url, auto_update } => handle_plugin_install(&url, auto_update),
|
||||
PluginCommand::Update { name } => handle_plugin_update(&name),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use anyhow::Result;
|
||||
use console::style;
|
||||
|
||||
pub fn handle_plugin_install(url: &str) -> Result<()> {
|
||||
let install = goose::plugins::install_plugin(url)?;
|
||||
pub fn handle_plugin_install(url: &str, auto_update: bool) -> Result<()> {
|
||||
let install = goose::plugins::install_plugin_with_options(
|
||||
url,
|
||||
goose::plugins::PluginInstallOptions { auto_update },
|
||||
)?;
|
||||
|
||||
println!(
|
||||
"{} Installed {} plugin '{}' ({})",
|
||||
@@ -11,6 +14,27 @@ pub fn handle_plugin_install(url: &str) -> Result<()> {
|
||||
style(&install.name).bold(),
|
||||
install.version
|
||||
);
|
||||
print_plugin_install(&install);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn handle_plugin_update(name: &str) -> Result<()> {
|
||||
let install = goose::plugins::update_plugin(name)?;
|
||||
|
||||
println!(
|
||||
"{} Updated {} plugin '{}' ({})",
|
||||
style("✓").green(),
|
||||
install.format,
|
||||
style(&install.name).bold(),
|
||||
install.version
|
||||
);
|
||||
print_plugin_install(&install);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_plugin_install(install: &goose::plugins::PluginInstall) {
|
||||
println!(" Source: {}", install.source);
|
||||
println!(" Location: {}", install.directory.display());
|
||||
|
||||
@@ -18,10 +42,8 @@ pub fn handle_plugin_install(url: &str) -> Result<()> {
|
||||
println!(" No skills imported.");
|
||||
} else {
|
||||
println!(" Imported skills:");
|
||||
for skill in install.skills {
|
||||
for skill in &install.skills {
|
||||
println!(" - {}", skill.name);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
use crate::plugins::{
|
||||
copy_dir_all, plugin_install_dir, write_install_metadata, FormatNotSupported, ImportedSkill,
|
||||
PluginFormat, PluginInstall,
|
||||
copy_dir_all, write_install_metadata, FormatNotSupported, ImportedSkill, PluginFormat,
|
||||
PluginInstall, PluginInstallOptions,
|
||||
};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use fs_err as fs;
|
||||
use serde::Deserialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(super) const MANIFEST: &str = "gemini-extension.json";
|
||||
pub(in crate::plugins) const MANIFEST: &str = "gemini-extension.json";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GeminiManifest {
|
||||
@@ -20,14 +21,12 @@ struct SkillCandidate {
|
||||
relative_directory: PathBuf,
|
||||
}
|
||||
|
||||
pub fn try_install_from_manifest(source: &str, checkout_dir: &Path) -> Result<PluginInstall> {
|
||||
install_from_manifest(source, checkout_dir, &plugin_install_dir())
|
||||
}
|
||||
|
||||
fn install_from_manifest(
|
||||
pub(in crate::plugins) fn try_install_from_manifest_at_root(
|
||||
source: &str,
|
||||
checkout_dir: &Path,
|
||||
install_root: &Path,
|
||||
options: &PluginInstallOptions,
|
||||
last_update_check: Option<DateTime<Utc>>,
|
||||
) -> Result<PluginInstall> {
|
||||
let manifest_path = checkout_dir.join(MANIFEST);
|
||||
if !manifest_path.is_file() {
|
||||
@@ -58,7 +57,13 @@ fn install_from_manifest(
|
||||
}
|
||||
|
||||
copy_dir_all(checkout_dir, &destination)?;
|
||||
write_install_metadata(&destination, source, "gemini")?;
|
||||
write_install_metadata(
|
||||
&destination,
|
||||
source,
|
||||
"gemini",
|
||||
options.auto_update,
|
||||
last_update_check,
|
||||
)?;
|
||||
|
||||
Ok(PluginInstall {
|
||||
name: manifest.name,
|
||||
@@ -170,10 +175,12 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let installed = install_from_manifest(
|
||||
let installed = try_install_from_manifest_at_root(
|
||||
"https://example.invalid/repo.git",
|
||||
repo.path(),
|
||||
install_root.path(),
|
||||
&PluginInstallOptions::default(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
||||
+379
-26
@@ -3,12 +3,15 @@ pub mod formats;
|
||||
use crate::config::paths::Paths;
|
||||
use crate::subprocess::SubprocessExt;
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use fs_err as fs;
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use tracing::warn;
|
||||
|
||||
const INSTALL_METADATA: &str = ".goose-plugin-install.json";
|
||||
const AUTO_UPDATE_INTERVAL_HOURS: i64 = 24;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PluginFormat {
|
||||
@@ -33,6 +36,17 @@ pub struct PluginInstall {
|
||||
pub skills: Vec<ImportedSkill>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PluginInstallOptions {
|
||||
pub auto_update: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PluginAutoUpdateResult {
|
||||
pub name: String,
|
||||
pub result: Result<PluginInstall>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ImportedSkill {
|
||||
pub name: String,
|
||||
@@ -43,11 +57,16 @@ pub struct ImportedSkill {
|
||||
#[error("format not supported")]
|
||||
pub struct FormatNotSupported;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct InstallMetadata<'a> {
|
||||
source: &'a str,
|
||||
source_type: &'a str,
|
||||
format: &'a str,
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct InstallMetadata {
|
||||
source: String,
|
||||
source_type: String,
|
||||
#[allow(dead_code)]
|
||||
format: String,
|
||||
#[serde(default)]
|
||||
auto_update: bool,
|
||||
#[serde(default)]
|
||||
last_update_check: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
pub fn plugin_install_dir() -> PathBuf {
|
||||
@@ -56,6 +75,15 @@ pub fn plugin_install_dir() -> PathBuf {
|
||||
|
||||
pub fn installed_plugin_skill_dirs() -> Vec<PathBuf> {
|
||||
let plugins_dir = plugin_install_dir();
|
||||
for update in auto_update_plugins_at_root(Utc::now(), &plugins_dir) {
|
||||
if let Err(err) = update.result {
|
||||
warn!(
|
||||
"Failed to auto-update plugin '{}': {}. Using currently installed version.",
|
||||
update.name, err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let entries = match fs::read_dir(plugins_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return Vec::new(),
|
||||
@@ -69,6 +97,21 @@ pub fn installed_plugin_skill_dirs() -> Vec<PathBuf> {
|
||||
}
|
||||
|
||||
pub fn install_plugin(source: &str) -> Result<PluginInstall> {
|
||||
install_plugin_with_options(source, PluginInstallOptions::default())
|
||||
}
|
||||
|
||||
pub fn install_plugin_with_options(
|
||||
source: &str,
|
||||
options: PluginInstallOptions,
|
||||
) -> Result<PluginInstall> {
|
||||
install_plugin_with_options_at_root(source, options, &plugin_install_dir())
|
||||
}
|
||||
|
||||
fn install_plugin_with_options_at_root(
|
||||
source: &str,
|
||||
options: PluginInstallOptions,
|
||||
install_root: &Path,
|
||||
) -> Result<PluginInstall> {
|
||||
if source.trim().is_empty() {
|
||||
bail!("Plugin source URL must not be empty");
|
||||
}
|
||||
@@ -77,15 +120,140 @@ pub fn install_plugin(source: &str) -> Result<PluginInstall> {
|
||||
let checkout_dir = temp_dir.path().join("checkout");
|
||||
clone_git_repo(source, &checkout_dir)?;
|
||||
|
||||
install_from_checkout(source, &checkout_dir)
|
||||
install_from_checkout_at_root(
|
||||
source,
|
||||
&checkout_dir,
|
||||
install_root,
|
||||
&options,
|
||||
options.auto_update.then_some(Utc::now()),
|
||||
)
|
||||
}
|
||||
|
||||
fn install_from_checkout(source: &str, checkout_dir: &Path) -> Result<PluginInstall> {
|
||||
match formats::gemini::try_install_from_manifest(source, checkout_dir) {
|
||||
pub fn update_plugin(name: &str) -> Result<PluginInstall> {
|
||||
update_plugin_at_root(Utc::now(), &plugin_install_dir(), name)
|
||||
}
|
||||
|
||||
pub fn auto_update_plugins() -> Vec<PluginAutoUpdateResult> {
|
||||
auto_update_plugins_at_root(Utc::now(), &plugin_install_dir())
|
||||
}
|
||||
|
||||
fn auto_update_plugins_at_root(
|
||||
now: DateTime<Utc>,
|
||||
plugins_dir: &Path,
|
||||
) -> Vec<PluginAutoUpdateResult> {
|
||||
let entries = match fs::read_dir(plugins_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
entries
|
||||
.flatten()
|
||||
.filter_map(|entry| {
|
||||
let plugin_dir = entry.path();
|
||||
if !plugin_dir.is_dir() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
let metadata = match read_install_metadata(&plugin_dir) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(_) => return None,
|
||||
};
|
||||
|
||||
if !metadata.auto_update || metadata.source_type != "git" {
|
||||
return None;
|
||||
}
|
||||
|
||||
if !should_auto_update(now, metadata.last_update_check) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let result = mark_last_update_check(&plugin_dir, now)
|
||||
.and_then(|_| update_plugin_at_root(now, plugins_dir, &name));
|
||||
|
||||
Some(PluginAutoUpdateResult {
|
||||
name: name.clone(),
|
||||
result,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn should_auto_update(now: DateTime<Utc>, last_update_check: Option<DateTime<Utc>>) -> bool {
|
||||
last_update_check
|
||||
.is_none_or(|checked_at| now - checked_at >= Duration::hours(AUTO_UPDATE_INTERVAL_HOURS))
|
||||
}
|
||||
|
||||
fn update_plugin_at_root(
|
||||
now: DateTime<Utc>,
|
||||
install_root: &Path,
|
||||
name: &str,
|
||||
) -> Result<PluginInstall> {
|
||||
if name.trim().is_empty() {
|
||||
bail!("Plugin name must not be empty");
|
||||
}
|
||||
|
||||
let current_install_dir = install_root.join(name);
|
||||
if !current_install_dir.is_dir() {
|
||||
bail!("Plugin '{}' is not installed", name);
|
||||
}
|
||||
|
||||
let metadata = read_install_metadata(¤t_install_dir)?;
|
||||
if metadata.source_type != "git" {
|
||||
bail!(
|
||||
"Plugin '{}' was installed from '{}' and cannot be updated with this command",
|
||||
name,
|
||||
metadata.source_type
|
||||
);
|
||||
}
|
||||
|
||||
fs::create_dir_all(install_root)?;
|
||||
let temp_dir = tempfile::tempdir_in(install_root)?;
|
||||
let checkout_dir = temp_dir.path().join("checkout");
|
||||
clone_git_repo(&metadata.source, &checkout_dir)?;
|
||||
|
||||
let options = PluginInstallOptions {
|
||||
auto_update: metadata.auto_update,
|
||||
};
|
||||
let updated = install_from_checkout_at_root(
|
||||
&metadata.source,
|
||||
&checkout_dir,
|
||||
temp_dir.path(),
|
||||
&options,
|
||||
Some(now),
|
||||
)?;
|
||||
if updated.name != name {
|
||||
bail!(
|
||||
"Updated plugin name '{}' does not match installed plugin '{}'",
|
||||
updated.name,
|
||||
name
|
||||
);
|
||||
}
|
||||
|
||||
replace_plugin_dir(&updated.directory, ¤t_install_dir)?;
|
||||
|
||||
Ok(PluginInstall {
|
||||
directory: current_install_dir,
|
||||
..updated
|
||||
})
|
||||
}
|
||||
|
||||
fn install_from_checkout_at_root(
|
||||
source: &str,
|
||||
checkout_dir: &Path,
|
||||
install_root: &Path,
|
||||
options: &PluginInstallOptions,
|
||||
last_update_check: Option<DateTime<Utc>>,
|
||||
) -> Result<PluginInstall> {
|
||||
match formats::gemini::try_install_from_manifest_at_root(
|
||||
source,
|
||||
checkout_dir,
|
||||
install_root,
|
||||
options,
|
||||
last_update_check,
|
||||
) {
|
||||
Ok(install) => Ok(install),
|
||||
Err(err) if err.is::<FormatNotSupported>() => {
|
||||
bail!("No supported plugin format found")
|
||||
}
|
||||
Err(err) if err.is::<FormatNotSupported>() => bail!("No supported plugin format found"),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
@@ -111,11 +279,41 @@ fn clone_git_repo(source: &str, destination: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_install_metadata(destination: &Path, source: &str, format: &str) -> Result<()> {
|
||||
fn read_install_metadata(directory: &Path) -> Result<InstallMetadata> {
|
||||
let metadata_path = directory.join(INSTALL_METADATA);
|
||||
if !metadata_path.is_file() {
|
||||
bail!(
|
||||
"Plugin at {} does not contain install metadata and cannot be updated",
|
||||
directory.display()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(serde_json::from_str(&fs::read_to_string(metadata_path)?)?)
|
||||
}
|
||||
|
||||
fn mark_last_update_check(directory: &Path, checked_at: DateTime<Utc>) -> Result<()> {
|
||||
let mut metadata = read_install_metadata(directory)?;
|
||||
metadata.last_update_check = Some(checked_at);
|
||||
fs::write(
|
||||
directory.join(INSTALL_METADATA),
|
||||
serde_json::to_string_pretty(&metadata)?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_install_metadata(
|
||||
destination: &Path,
|
||||
source: &str,
|
||||
format: &str,
|
||||
auto_update: bool,
|
||||
last_update_check: Option<DateTime<Utc>>,
|
||||
) -> Result<()> {
|
||||
let metadata = InstallMetadata {
|
||||
source,
|
||||
source_type: "git",
|
||||
format,
|
||||
source: source.to_string(),
|
||||
source_type: "git".to_string(),
|
||||
format: format.to_string(),
|
||||
auto_update,
|
||||
last_update_check,
|
||||
};
|
||||
fs::write(
|
||||
destination.join(INSTALL_METADATA),
|
||||
@@ -124,6 +322,22 @@ fn write_install_metadata(destination: &Path, source: &str, format: &str) -> Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_plugin_dir(source: &Path, destination: &Path) -> Result<()> {
|
||||
let parent = destination
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow!("Plugin destination has no parent directory"))?;
|
||||
let backup_dir = tempfile::tempdir_in(parent)?;
|
||||
let backup_plugin_dir = backup_dir.path().join("plugin");
|
||||
|
||||
fs::rename(destination, &backup_plugin_dir)?;
|
||||
if let Err(err) = fs::rename(source, destination) {
|
||||
fs::rename(&backup_plugin_dir, destination)?;
|
||||
return Err(err.into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_dir_all(source: &Path, destination: &Path) -> Result<()> {
|
||||
fs::create_dir_all(destination)?;
|
||||
|
||||
@@ -165,21 +379,160 @@ fn copy_symlink(source: &Path, destination: &Path) -> Result<()> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[test]
|
||||
fn rejects_repo_without_supported_manifest() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::env::set_var("GOOSE_PATH_ROOT", root.path());
|
||||
fn updates_git_backed_plugin() {
|
||||
let install_root = tempfile::tempdir().unwrap();
|
||||
let repo = tempfile::tempdir().unwrap();
|
||||
write_gemini_plugin(repo.path(), "1.0.0", "Audit code");
|
||||
init_git_repo(repo.path());
|
||||
commit_git_repo(repo.path(), "initial");
|
||||
let source = repo.path().to_path_buf();
|
||||
|
||||
let installed = install_plugin_with_options_at_root(
|
||||
source.to_str().unwrap(),
|
||||
PluginInstallOptions::default(),
|
||||
install_root.path(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(installed.version, "1.0.0");
|
||||
|
||||
write_gemini_plugin(&source, "2.0.0", "Audit updated code");
|
||||
commit_git_repo(&source, "update");
|
||||
|
||||
let updated =
|
||||
update_plugin_at_root(Utc::now(), install_root.path(), "test-plugin").unwrap();
|
||||
|
||||
assert_eq!(updated.version, "2.0.0");
|
||||
assert_eq!(updated.directory, install_root.path().join("test-plugin"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(updated.directory.join("skills/audit/SKILL.md")).unwrap(),
|
||||
"---\nname: audit\ndescription: Audit updated code\n---\nDo an audit."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_update_plugins_updates_enabled_plugins() {
|
||||
let install_root = tempfile::tempdir().unwrap();
|
||||
let repo = tempfile::tempdir().unwrap();
|
||||
write_gemini_plugin(repo.path(), "1.0.0", "Audit code");
|
||||
init_git_repo(repo.path());
|
||||
commit_git_repo(repo.path(), "initial");
|
||||
let source = repo.path().to_path_buf();
|
||||
|
||||
let installed = install_plugin_with_options_at_root(
|
||||
source.to_str().unwrap(),
|
||||
PluginInstallOptions { auto_update: true },
|
||||
install_root.path(),
|
||||
)
|
||||
.unwrap();
|
||||
let old_check = Utc::now() - Duration::hours(AUTO_UPDATE_INTERVAL_HOURS + 1);
|
||||
mark_last_update_check(&installed.directory, old_check).unwrap();
|
||||
|
||||
write_gemini_plugin(&source, "2.0.0", "Audit updated code");
|
||||
commit_git_repo(&source, "update");
|
||||
|
||||
let updates = auto_update_plugins_at_root(Utc::now(), install_root.path());
|
||||
|
||||
assert_eq!(updates.len(), 1);
|
||||
assert!(updates[0].result.is_ok());
|
||||
assert_eq!(
|
||||
fs::read_to_string(installed.directory.join("skills/audit/SKILL.md")).unwrap(),
|
||||
"---\nname: audit\ndescription: Audit updated code\n---\nDo an audit."
|
||||
);
|
||||
let metadata = read_install_metadata(&installed.directory).unwrap();
|
||||
assert!(metadata.auto_update);
|
||||
assert!(metadata.last_update_check.unwrap() > old_check);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_update_plugins_skips_recently_checked_plugins() {
|
||||
let install_root = tempfile::tempdir().unwrap();
|
||||
let repo = tempfile::tempdir().unwrap();
|
||||
write_gemini_plugin(repo.path(), "1.0.0", "Audit code");
|
||||
init_git_repo(repo.path());
|
||||
commit_git_repo(repo.path(), "initial");
|
||||
let source = repo.path().to_path_buf();
|
||||
|
||||
let installed = install_plugin_with_options_at_root(
|
||||
source.to_str().unwrap(),
|
||||
PluginInstallOptions { auto_update: true },
|
||||
install_root.path(),
|
||||
)
|
||||
.unwrap();
|
||||
let recent_check = Utc::now();
|
||||
mark_last_update_check(&installed.directory, recent_check).unwrap();
|
||||
|
||||
write_gemini_plugin(&source, "2.0.0", "Audit updated code");
|
||||
commit_git_repo(&source, "update");
|
||||
|
||||
let updates =
|
||||
auto_update_plugins_at_root(recent_check + Duration::hours(1), install_root.path());
|
||||
|
||||
assert!(updates.is_empty());
|
||||
assert_eq!(
|
||||
fs::read_to_string(installed.directory.join("skills/audit/SKILL.md")).unwrap(),
|
||||
"---\nname: audit\ndescription: Audit code\n---\nDo an audit."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_rejects_non_git_backed_plugin() {
|
||||
let install_root = tempfile::tempdir().unwrap();
|
||||
let plugin_dir = install_root.path().join("test-plugin");
|
||||
fs::create_dir_all(&plugin_dir).unwrap();
|
||||
fs::write(
|
||||
plugin_dir.join(INSTALL_METADATA),
|
||||
r#"{"source":"/tmp/test-plugin","source_type":"local","format":"gemini"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err =
|
||||
install_from_checkout("https://example.invalid/repo.git", repo.path()).unwrap_err();
|
||||
update_plugin_at_root(Utc::now(), install_root.path(), "test-plugin").unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("No supported plugin format found"));
|
||||
std::env::remove_var("GOOSE_PATH_ROOT");
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("cannot be updated with this command"));
|
||||
}
|
||||
|
||||
fn write_gemini_plugin(repo: &Path, version: &str, description: &str) {
|
||||
fs::write(
|
||||
repo.join(formats::gemini::MANIFEST),
|
||||
format!(r#"{{"name":"test-plugin","version":"{version}"}}"#),
|
||||
)
|
||||
.unwrap();
|
||||
let skill_dir = repo.join("skills").join("audit");
|
||||
fs::create_dir_all(&skill_dir).unwrap();
|
||||
fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
format!("---\nname: audit\ndescription: {description}\n---\nDo an audit."),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn init_git_repo(repo: &Path) {
|
||||
run_git(repo, &["init"]);
|
||||
run_git(repo, &["config", "user.email", "goose@example.com"]);
|
||||
run_git(repo, &["config", "user.name", "Goose"]);
|
||||
}
|
||||
|
||||
fn commit_git_repo(repo: &Path, message: &str) {
|
||||
run_git(repo, &["add", "."]);
|
||||
run_git(repo, &["commit", "-m", message]);
|
||||
}
|
||||
|
||||
fn run_git(repo: &Path, args: &[&str]) {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(repo)
|
||||
.set_no_window()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"git {} failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user