use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use synaps_cli::skills::{
install,
marketplace::{
derive_git_clone_url, fetch_manifest, fetch_marketplace, is_safe_plugin_name, is_trusted,
trust_host_for_source,
},
manifest::PluginManifest,
plugin_index::{
PluginIndexCapabilities, PluginIndexChecksum, PluginIndexCompatibility, PluginIndexEntry,
PluginIndexTrust,
},
post_install,
reload_registry,
registry::CommandRegistry,
state::{CachedPluginIndexMetadata, InstalledPlugin, Marketplace, PluginsState, SetupStatus},
update_diff::diff_plugin_manifests,
};
use super::progress::{parse_progress_line, InstallProgress, InstallProgressHandle};
use super::state::{PendingInstallTask, PluginsModalState, RightMode};
fn commit_plugins_state(file: &PluginsState) -> std::io::Result<()> {
file.save_to(&PluginsState::default_path())
}
fn install_dir_for(name: &str) -> Result<PathBuf, String> {
if !is_safe_plugin_name(name) {
return Err("refused to install plugin with unsafe name".into());
}
Ok(synaps_cli::config::resolve_write_path("plugins").join(name))
}
fn now_rfc3339() -> String {
chrono::Utc::now().to_rfc3339()
}
fn cached_index_metadata(entry: &PluginIndexEntry) -> CachedPluginIndexMetadata {
CachedPluginIndexMetadata {
repository: entry.repository.clone(),
subdir: entry.subdir.clone(),
checksum_algorithm: entry.checksum.algorithm.clone(),
checksum_value: entry.checksum.value.clone(),
compatibility_synaps: entry.compatibility.synaps.clone(),
compatibility_extension_protocol: entry.compatibility.extension_protocol.clone(),
has_extension: entry.capabilities.has_extension,
skills: entry.capabilities.skills.clone(),
permissions: entry.capabilities.permissions.clone(),
hooks: entry.capabilities.hooks.clone(),
commands: entry.capabilities.commands.clone(),
providers: entry.capabilities.providers.clone(),
trust_publisher: entry.trust.as_ref().and_then(|t| t.publisher.clone()),
trust_homepage: entry.trust.as_ref().and_then(|t| t.homepage.clone()),
}
}
fn resolve_install_target(
cached_source: &str,
marketplace: &Marketplace,
) -> Result<(String, Option<String>), String> {
let s = cached_source.trim();
if let Some(subdir) = s.strip_prefix("./") {
if !is_safe_plugin_name(subdir) {
return Err(format!("refusing unsafe relative source '{}'", s));
}
let clone_url = marketplace
.repo_url
.clone()
.or_else(|| derive_git_clone_url(&marketplace.url).ok())
.ok_or_else(|| {
format!(
"marketplace '{}' has a repo-relative source but no clone URL is known",
marketplace.name
)
})?;
Ok((clone_url, Some(subdir.to_string())))
} else {
Ok((s.to_string(), None))
}
}
pub(crate) async fn apply_add_marketplace(state: &mut PluginsModalState, url: String) {
let (manifest, used_url) = match fetch_marketplace(&url).await {
Ok(v) => v,
Err(e) => {
set_editor_or_row_error(state, e);
return;
}
};
let repo_url = derive_git_clone_url(&url).ok();
let cached_plugins = manifest
.plugins
.iter()
.map(|p| synaps_cli::skills::state::CachedPlugin {
name: p.name.clone(),
source: p.source.clone().unwrap_or_else(|| p.index.as_ref().map(|idx| idx.repository.clone()).unwrap_or_default()),
version: p.version.clone(),
description: p.description.clone(),
index: p.index.as_ref().map(cached_index_metadata),
})
.collect();
let new_m = Marketplace {
name: manifest.name.clone(),
url: used_url,
description: manifest.description.clone(),
last_refreshed: Some(now_rfc3339()),
cached_plugins,
repo_url,
};
if let Some(slot) = state.file.marketplaces.iter_mut().find(|m| m.name == new_m.name) {
*slot = new_m;
} else {
state.file.marketplaces.push(new_m);
}
if let Err(e) = commit_plugins_state(&state.file) {
set_editor_or_row_error(state, format!("save failed: {}", e));
return;
}
state.mode = RightMode::List;
state.row_error = None;
}
pub(crate) async fn apply_install(
state: &mut PluginsModalState,
marketplace_name: String,
plugin_name: String,
registry: &Arc<CommandRegistry>,
config: &synaps_cli::SynapsConfig,
) {
let Some(marketplace) = state
.file
.marketplaces
.iter()
.find(|m| m.name == marketplace_name)
.cloned()
else {
state.row_error = Some(format!("marketplace '{}' not found", marketplace_name));
return;
};
let Some(cached) = marketplace
.cached_plugins
.iter()
.find(|p| p.name == plugin_name)
.cloned()
else {
state.row_error = Some(format!("plugin '{}' not found in '{}'", plugin_name, marketplace_name));
return;
};
let summary = permission_summary_for_plugin_name(&plugin_name)
.unwrap_or_else(|| vec![format!("source: {}", cached.source)]);
let (effective_source, subdir) = match resolve_install_target(&cached.source, &marketplace) {
Ok(v) => v,
Err(e) => {
state.row_error = Some(e);
return;
}
};
let host = match trust_host_for_source(&effective_source) {
Ok(h) => h,
Err(e) => {
state.row_error = Some(e);
return;
}
};
if !is_trusted(&effective_source, &state.file.trusted_hosts) {
state.mode = RightMode::TrustPrompt {
plugin_name: plugin_name.clone(),
host,
pending_source: effective_source,
summary,
};
return;
}
if let Some(index) = cached.index.clone() {
let entry = plugin_index_entry_from_cached(&cached.name, &cached, index);
apply_install_from_index_entry(state, marketplace_name, entry, registry, config).await;
return;
}
run_install_flow(
state, plugin_name, effective_source, subdir, Some(marketplace_name), None,
registry, config,
);
}
fn plugin_index_entry_from_cached(
fallback_name: &str,
cached: &synaps_cli::skills::state::CachedPlugin,
index: CachedPluginIndexMetadata,
) -> PluginIndexEntry {
PluginIndexEntry {
id: fallback_name.to_string(),
name: cached.name.clone(),
version: cached.version.clone().unwrap_or_else(|| "0.0.0".to_string()),
description: cached.description.clone().unwrap_or_default(),
repository: index.repository,
subdir: index.subdir,
license: None,
categories: vec![],
keywords: vec![],
checksum: PluginIndexChecksum {
algorithm: index.checksum_algorithm,
value: index.checksum_value,
},
compatibility: PluginIndexCompatibility {
synaps: index.compatibility_synaps,
extension_protocol: index.compatibility_extension_protocol,
},
capabilities: PluginIndexCapabilities {
skills: index.skills,
has_extension: index.has_extension,
permissions: index.permissions,
hooks: index.hooks,
commands: index.commands,
providers: index.providers,
},
trust: if index.trust_publisher.is_some() || index.trust_homepage.is_some() {
Some(PluginIndexTrust {
publisher: index.trust_publisher,
homepage: index.trust_homepage,
})
} else {
None
},
}
}
pub(crate) async fn apply_install_from_index_entry(
state: &mut PluginsModalState,
marketplace_name: String,
entry: PluginIndexEntry,
registry: &Arc<CommandRegistry>,
config: &synaps_cli::SynapsConfig,
) {
let mut summary = vec![
format!("index plugin: {} {}", entry.id, entry.version),
format!("repository: {}", entry.repository),
format!("checksum: {}:{}", entry.checksum.algorithm, entry.checksum.value),
format!("executable extension: {}", if entry.capabilities.has_extension { "yes" } else { "no" }),
format!("permissions: {}", if entry.capabilities.permissions.is_empty() { "none".to_string() } else { entry.capabilities.permissions.join(", ") }),
format!("hooks: {}", if entry.capabilities.hooks.is_empty() { "none".to_string() } else { entry.capabilities.hooks.join(", ") }),
];
if !entry.capabilities.providers.is_empty() {
summary.push(format!(
"providers: {}",
entry.capabilities.providers.iter().map(|p| format!("{} ({})", p.id, p.models.join(", "))).collect::<Vec<_>>().join("; ")
));
}
if entry.capabilities.permissions.iter().any(|permission| permission == "providers.register") {
summary.push("HIGH IMPACT: selected provider models receive conversation content".to_string());
}
summary.push("fetched plugin manifest will be re-inspected before final install".to_string());
let host = match trust_host_for_source(&entry.repository) {
Ok(h) => h,
Err(e) => {
state.row_error = Some(e);
return;
}
};
if !is_trusted(&entry.repository, &state.file.trusted_hosts) {
state.mode = RightMode::TrustPrompt {
plugin_name: entry.id,
host,
pending_source: entry.repository,
summary,
};
return;
}
let install_source = state
.file
.marketplaces
.iter()
.find(|m| m.name == marketplace_name)
.and_then(|m| m.cached_plugins.iter().find(|p| p.name == entry.id))
.map(|p| p.source.clone())
.unwrap_or_else(|| entry.repository.clone());
let checksum = Some((entry.checksum.algorithm.clone(), entry.checksum.value.clone()));
run_install_flow(
state,
entry.id,
install_source,
entry.subdir,
Some(marketplace_name),
checksum,
registry,
config,
);
}
pub(crate) async fn apply_trust_and_install(
state: &mut PluginsModalState,
plugin_name: String,
host: String,
source: String,
_summary: Vec<String>,
registry: &Arc<CommandRegistry>,
config: &synaps_cli::SynapsConfig,
) {
if !state.file.trusted_hosts.iter().any(|h| h == &host) {
state.file.trusted_hosts.push(host);
}
let mut marketplace_name: Option<String> = None;
let mut subdir: Option<String> = None;
for m in &state.file.marketplaces {
for p in &m.cached_plugins {
if p.name != plugin_name { continue; }
marketplace_name = Some(m.name.clone());
if let Some(rest) = p.source.strip_prefix("./") {
subdir = Some(rest.to_string());
}
break;
}
}
run_install_flow(state, plugin_name, source, subdir, marketplace_name, None, registry, config);
}
fn install_plugin_to_temp_with_checksum(
plugin_name: &str,
source_url: &str,
subdir: Option<String>,
final_dest: &std::path::Path,
expected_checksum: Option<(String, String)>,
) -> Result<(String, PathBuf), String> {
install_plugin_to_temp_with_progress(
plugin_name,
source_url,
subdir,
final_dest,
expected_checksum,
|_| {},
)
}
fn install_plugin_to_temp_with_progress(
plugin_name: &str,
source_url: &str,
subdir: Option<String>,
final_dest: &std::path::Path,
expected_checksum: Option<(String, String)>,
on_chunk: impl FnMut(&str),
) -> Result<(String, PathBuf), String> {
let parent = final_dest.parent().ok_or_else(|| "dest has no parent directory".to_string())?;
std::fs::create_dir_all(parent).map_err(|e| format!("mkdir {}: {}", parent.display(), e))?;
let tmp = parent.join(format!(".{}-pending-install", plugin_name));
let _ = std::fs::remove_dir_all(&tmp);
let sha = match subdir {
Some(s) => install::install_plugin_from_subdir_with_progress(source_url, &s, &tmp, on_chunk),
None => install::install_plugin_with_progress(source_url, &tmp, on_chunk),
}?;
if let Some((algorithm, expected)) = expected_checksum {
if let Err(e) = install::verify_plugin_dir_checksum(&tmp, &algorithm, &expected) {
let _ = std::fs::remove_dir_all(&tmp);
return Err(e);
}
}
Ok((sha, tmp))
}
fn finalize_pending_install(temp_dir: &std::path::Path, final_dir: &std::path::Path) -> Result<(), String> {
if final_dir.exists() {
let _ = std::fs::remove_dir_all(temp_dir);
return Err(format!("{} already exists on disk; uninstall first", final_dir.display()));
}
std::fs::rename(temp_dir, final_dir)
.map_err(|e| format!("finalize install {} -> {}: {}", temp_dir.display(), final_dir.display(), e))
}
fn cancel_pending_temp(temp_dir: &std::path::Path) {
let _ = std::fs::remove_dir_all(temp_dir);
}
#[derive(Debug, Clone)]
enum PostInstallOutcome {
NotRequired,
BinaryAlreadyPresent,
PrebuiltInstalled,
SetupSucceeded(PathBuf),
}
impl PostInstallOutcome {
fn setup_status(&self) -> SetupStatus {
match self {
PostInstallOutcome::SetupSucceeded(path) => SetupStatus::Succeeded {
log_path: Some(path.display().to_string()),
},
_ => SetupStatus::NotRequired,
}
}
}
#[derive(Debug, thiserror::Error)]
enum PostInstallError {
#[error("{0}")]
Hard(String),
#[error("{0}")]
SoftSetup(String),
}
impl PostInstallError {
fn setup_status(&self) -> SetupStatus {
SetupStatus::Failed {
message: self.to_string(),
log_path: None,
}
}
}
async fn run_post_install_setup_for_dir(
plugin_name: &str,
final_dir: &std::path::Path,
) -> Result<PostInstallOutcome, PostInstallError> {
let manifest = match read_plugin_manifest(final_dir) {
Ok(m) => m,
Err(e) => {
return Err(PostInstallError::Hard(format!(
"installed but could not read manifest to check for setup script: {e}"
)));
}
};
if let Ok(Some(_resolved)) = post_install::verify_extension_command(&manifest, final_dir) {
return Ok(PostInstallOutcome::BinaryAlreadyPresent);
}
match post_install::try_install_from_prebuilt(&manifest, final_dir).await {
Ok(_resolved) => return Ok(PostInstallOutcome::PrebuiltInstalled),
Err(post_install::PrebuiltError::NoMatchingAsset) => {}
Err(e) => {
return Err(PostInstallError::Hard(format!("prebuilt asset install failed: {e}")));
}
}
let resolved = post_install::resolve_setup_script(&manifest, final_dir)
.map_err(|e| PostInstallError::Hard(e.to_string()))?;
let Some(script) = resolved else {
if manifest.extension.is_some() {
post_install::verify_extension_command(&manifest, final_dir)
.map_err(|e| PostInstallError::Hard(format!("extension command verification failed: {e}")))?;
}
return Ok(PostInstallOutcome::NotRequired);
};
let logs_root = synaps_cli::config::resolve_write_path("logs");
let log_path = post_install::install_log_path(&logs_root, plugin_name, &now_rfc3339());
let setup_log = match post_install::run_setup_script(
&script,
final_dir,
&log_path,
post_install::SETUP_TIMEOUT,
)
.await
{
Ok(outcome) => outcome.log_path,
Err(e) => return Err(PostInstallError::SoftSetup(e.to_string())),
};
if manifest.extension.is_some() {
if let Err(e) = post_install::verify_extension_command(&manifest, final_dir) {
return Err(PostInstallError::SoftSetup(format!(
"setup script ran (see {}) but did not produce the declared extension binary: {}",
setup_log.display(),
e
)));
}
}
Ok(PostInstallOutcome::SetupSucceeded(setup_log))
}
fn read_plugin_manifest(path: &std::path::Path) -> Result<PluginManifest, String> {
let manifest_path = path.join(".synaps-plugin/plugin.json");
let body = std::fs::read_to_string(&manifest_path)
.map_err(|e| format!("read {}: {}", manifest_path.display(), e))?;
serde_json::from_str(&body)
.map_err(|e| format!("parse {}: {}", manifest_path.display(), e))
}
fn summarize_plugin_dir(path: &std::path::Path) -> Vec<String> {
let Some(parent) = path.parent() else {
return vec!["plugin manifest could not be inspected before install".to_string()];
};
let Ok(path_abs) = path.canonicalize() else {
return vec!["plugin manifest could not be inspected before install".to_string()];
};
let (plugins, _) = synaps_cli::skills::loader::load_all(&[parent.to_path_buf()]);
if let Some(plugin) = plugins.into_iter().find(|plugin| plugin.root == path_abs) {
synaps_cli::skills::trust::summarize_plugin_permissions(&plugin).lines()
} else {
vec!["plugin manifest could not be inspected before install".to_string()]
}
}
fn record_installed_plugin(
state: &mut PluginsModalState,
plugin_name: String,
marketplace_name: Option<String>,
source_url: String,
installed_commit: String,
source_subdir: Option<String>,
checksum: Option<(String, String)>,
setup_status: SetupStatus,
) {
state.file.installed.push(InstalledPlugin {
name: plugin_name,
marketplace: marketplace_name,
source_url,
installed_commit,
latest_commit: None,
installed_at: now_rfc3339(),
source_subdir,
checksum_algorithm: checksum.as_ref().map(|(algorithm, _)| algorithm.clone()),
checksum_value: checksum.map(|(_, value)| value),
setup_status,
});
}
fn expected_update_checksum(state: &PluginsState, installed: &InstalledPlugin) -> Option<(String, String)> {
let marketplace_name = installed.marketplace.as_deref()?;
state
.marketplaces
.iter()
.find(|m| m.name == marketplace_name)
.and_then(|m| m.cached_plugins.iter().find(|p| p.name == installed.name))
.and_then(|p| p.index.as_ref())
.map(|index| (index.checksum_algorithm.clone(), index.checksum_value.clone()))
}
fn run_install_flow(
state: &mut PluginsModalState,
plugin_name: String,
source_url: String,
subdir: Option<String>,
marketplace_name: Option<String>,
expected_checksum: Option<(String, String)>,
_registry: &Arc<CommandRegistry>,
_config: &synaps_cli::SynapsConfig,
) {
let dest = match install_dir_for(&plugin_name) {
Ok(d) => d,
Err(e) => {
state.row_error = Some(e);
return;
}
};
if state.pending_install.is_some() {
return;
}
let progress: InstallProgressHandle =
Arc::new(Mutex::new(InstallProgress::new(plugin_name.clone())));
let progress_for_task = Arc::clone(&progress);
let src = source_url.clone();
let dest_clone = dest.clone();
let subdir_for_install = subdir.clone();
let plugin_name_for_task = plugin_name.clone();
let expected_for_task = expected_checksum.clone();
let join = tokio::task::spawn_blocking(move || {
install_plugin_to_temp_with_progress(
&plugin_name_for_task,
&src,
subdir_for_install,
&dest_clone,
expected_for_task,
|chunk| {
if let Some(snap) = parse_progress_line(chunk) {
if let Ok(mut p) = progress_for_task.lock() {
p.apply(snap);
}
}
},
)
});
state.mode = RightMode::Installing {
progress: Arc::clone(&progress),
};
state.pending_install = Some(PendingInstallTask {
join,
progress,
plugin_name,
source_url,
subdir,
marketplace_name,
expected_checksum,
final_dir: dest,
});
state.row_error = None;
}
pub(crate) async fn complete_pending_install_clone(
state: &mut PluginsModalState,
registry: &Arc<CommandRegistry>,
config: &synaps_cli::SynapsConfig,
) {
let Some(task) = state.pending_install.take() else {
return;
};
state.mode = RightMode::List;
let PendingInstallTask {
join,
progress,
plugin_name,
source_url,
subdir,
marketplace_name,
expected_checksum,
final_dir: dest,
} = task;
let install_res = join.await;
let (sha, temp_dir) = match install_res {
Ok(Ok(v)) => {
if let Ok(mut p) = progress.lock() {
p.finish_clone();
}
v
}
Ok(Err(e)) => {
if let Ok(mut p) = progress.lock() {
p.fail(e.clone());
}
state.row_error = Some(e);
return;
}
Err(e) => {
let msg = format!("install task join error: {}", e);
if let Ok(mut p) = progress.lock() {
p.fail(msg.clone());
}
state.row_error = Some(msg);
return;
}
};
let summary = summarize_plugin_dir(&temp_dir);
let has_executable_extension = read_plugin_manifest(&temp_dir)
.map(|manifest| manifest.extension.is_some())
.unwrap_or(true);
if has_executable_extension {
state.mode = RightMode::PendingInstallConfirm {
plugin_name,
source_url,
subdir,
marketplace_name,
summary,
installed_commit: sha,
checksum_algorithm: expected_checksum
.as_ref()
.map(|(algorithm, _)| algorithm.clone()),
checksum_value: expected_checksum
.as_ref()
.map(|(_, value)| value.clone()),
temp_dir,
final_dir: dest,
};
state.row_error = None;
return;
}
if let Err(e) = finalize_pending_install(&temp_dir, &dest) {
state.row_error = Some(e);
return;
}
if let Ok(mut p) = progress.lock() {
p.set_setup_running();
}
let setup_outcome = run_post_install_setup_for_dir(&plugin_name, &dest).await;
let setup_status = setup_outcome.as_ref().map(|o| o.setup_status()).unwrap_or_else(|e| e.setup_status());
let plugin_name_for_msg = plugin_name.clone();
record_installed_plugin(
state,
plugin_name,
marketplace_name,
source_url,
sha,
subdir,
expected_checksum,
setup_status,
);
if let Err(e) = commit_plugins_state(&state.file) {
state.row_error = Some(format!(
"installed '{}' but failed to save state: {}. Restart may lose this install.",
plugin_name_for_msg, e
));
return;
}
reload_registry(registry, config);
state.mode = RightMode::List;
state.row_error = match setup_outcome {
Ok(_) => None,
Err(msg) => Some(format!(
"installed '{plugin_name_for_msg}' but setup script failed: {msg}"
)),
};
}
pub(crate) async fn apply_confirm_pending_install(
state: &mut PluginsModalState,
registry: &Arc<CommandRegistry>,
config: &synaps_cli::SynapsConfig,
) {
let RightMode::PendingInstallConfirm {
plugin_name,
source_url,
subdir,
marketplace_name,
installed_commit,
checksum_algorithm,
checksum_value,
temp_dir,
final_dir,
..
} = std::mem::replace(&mut state.mode, RightMode::List) else {
return;
};
let final_dir_for_task = final_dir.clone();
let temp_dir_for_task = temp_dir.clone();
let finalize_res = tokio::task::spawn_blocking(move || {
finalize_pending_install(&temp_dir_for_task, &final_dir_for_task)
}).await;
match finalize_res {
Ok(Ok(())) => {}
Ok(Err(e)) => {
state.row_error = Some(e);
return;
}
Err(e) => {
state.row_error = Some(format!("install finalize task join error: {}", e));
return;
}
}
let setup_outcome = run_post_install_setup_for_dir(&plugin_name, &final_dir).await;
let setup_status = setup_outcome.as_ref().map(|o| o.setup_status()).unwrap_or_else(|e| e.setup_status());
record_installed_plugin(
state,
plugin_name.clone(),
marketplace_name,
source_url,
installed_commit,
subdir,
checksum_algorithm.zip(checksum_value),
setup_status,
);
if let Err(e) = commit_plugins_state(&state.file) {
state.row_error = Some(format!(
"installed '{}' but failed to save state: {}. Restart may lose this install.",
plugin_name, e
));
return;
}
reload_registry(registry, config);
state.row_error = match setup_outcome {
Ok(_) => None,
Err(msg) => Some(format!(
"installed '{plugin_name}' but setup script failed: {msg}"
)),
};
}
pub(crate) fn apply_cancel_pending_install(state: &mut PluginsModalState) {
let old = std::mem::replace(&mut state.mode, RightMode::List);
if let RightMode::PendingInstallConfirm { temp_dir, .. } = old {
cancel_pending_temp(&temp_dir);
}
state.row_error = None;
}
pub(crate) async fn apply_uninstall(
state: &mut PluginsModalState,
name: String,
registry: &Arc<CommandRegistry>,
config: &synaps_cli::SynapsConfig,
) {
let dir = match install_dir_for(&name) {
Ok(d) => d,
Err(e) => {
state.row_error = Some(e);
return;
}
};
let uninstall_res = tokio::task::spawn_blocking(move || install::uninstall_plugin(&dir)).await;
match uninstall_res {
Ok(Ok(())) => {}
Ok(Err(e)) => {
state.row_error = Some(e);
return;
}
Err(e) => {
state.row_error = Some(format!("uninstall task join error: {}", e));
return;
}
}
state.file.installed.retain(|p| p.name != name);
if let Err(e) = commit_plugins_state(&state.file) {
state.row_error = Some(format!(
"uninstalled '{}' but failed to save state: {}. State may be stale.",
name, e
));
return;
}
reload_registry(registry, config);
state.row_error = None;
}
pub(crate) async fn apply_update(
state: &mut PluginsModalState,
name: String,
_registry: &Arc<CommandRegistry>,
_config: &synaps_cli::SynapsConfig,
) {
let final_dir = match install_dir_for(&name) {
Ok(d) => d,
Err(e) => {
state.row_error = Some(e);
return;
}
};
let installed = state.file.installed.iter().find(|p| p.name == name).cloned();
let Some(installed) = installed else {
state.row_error = Some(format!("plugin '{}' is not installed", name));
return;
};
let old_manifest = match read_plugin_manifest(&final_dir) {
Ok(m) => m,
Err(e) => {
state.row_error = Some(e);
return;
}
};
let parent = match final_dir.parent() {
Some(p) => p.to_path_buf(),
None => {
state.row_error = Some("plugin install path has no parent".to_string());
return;
}
};
let temp_dir = parent.join(format!(".{}-pending-update", name));
let source = installed.source_url.clone();
let subdir = installed.source_subdir.clone();
let expected_checksum = expected_update_checksum(&state.file, &installed)
.or_else(|| installed.checksum_algorithm.clone().zip(installed.checksum_value.clone()));
let temp_for_task = temp_dir.clone();
let update_res = tokio::task::spawn_blocking(move || {
let _ = std::fs::remove_dir_all(&temp_for_task);
let sha = match subdir {
Some(subdir) => install::install_plugin_from_subdir(&source, &subdir, &temp_for_task),
None => install::install_plugin(&source, &temp_for_task),
}?;
if let Some((algorithm, expected)) = expected_checksum {
if let Err(e) = install::verify_plugin_dir_checksum(&temp_for_task, &algorithm, &expected) {
let _ = std::fs::remove_dir_all(&temp_for_task);
return Err(e);
}
}
Ok(sha)
}).await;
let sha = match update_res {
Ok(Ok(sha)) => sha,
Ok(Err(e)) => {
state.row_error = Some(e);
return;
}
Err(e) => {
state.row_error = Some(format!("update preview task join error: {}", e));
return;
}
};
let new_manifest = match read_plugin_manifest(&temp_dir) {
Ok(m) => m,
Err(e) => {
cancel_pending_temp(&temp_dir);
state.row_error = Some(e);
return;
}
};
let diff = diff_plugin_manifests(&old_manifest, &new_manifest);
state.mode = RightMode::PendingUpdateConfirm {
plugin_name: name,
summary: diff.lines(),
installed_commit: sha,
temp_dir,
final_dir,
};
state.row_error = None;
}
pub(crate) async fn apply_confirm_pending_update(
state: &mut PluginsModalState,
registry: &Arc<CommandRegistry>,
config: &synaps_cli::SynapsConfig,
) {
let RightMode::PendingUpdateConfirm {
plugin_name,
installed_commit,
temp_dir,
final_dir,
..
} = std::mem::replace(&mut state.mode, RightMode::List) else {
return;
};
let final_for_task = final_dir.clone();
let temp_for_task = temp_dir.clone();
let apply_res = tokio::task::spawn_blocking(move || -> Result<(), String> {
let backup = final_for_task.with_extension("update-backup");
let _ = std::fs::remove_dir_all(&backup);
std::fs::rename(&final_for_task, &backup)
.map_err(|e| format!("backup existing plugin {}: {}", final_for_task.display(), e))?;
if let Err(e) = std::fs::rename(&temp_for_task, &final_for_task) {
let _ = std::fs::rename(&backup, &final_for_task);
return Err(format!("apply update {} -> {}: {}", temp_for_task.display(), final_for_task.display(), e));
}
let _ = std::fs::remove_dir_all(&backup);
Ok(())
}).await;
match apply_res {
Ok(Ok(())) => {}
Ok(Err(e)) => {
state.row_error = Some(e);
return;
}
Err(e) => {
state.row_error = Some(format!("update apply task join error: {}", e));
return;
}
}
let setup_outcome = run_post_install_setup_for_dir(&plugin_name, &final_dir).await;
let setup_status = setup_outcome.as_ref().map(|o| o.setup_status()).unwrap_or_else(|e| e.setup_status());
if let Some(p) = state.file.installed.iter_mut().find(|p| p.name == plugin_name) {
p.installed_commit = installed_commit;
p.latest_commit = None;
p.setup_status = setup_status;
}
if let Err(e) = commit_plugins_state(&state.file) {
state.row_error = Some(format!(
"updated '{}' but failed to save state: {}. State may be stale.",
plugin_name, e
));
return;
}
reload_registry(registry, config);
state.row_error = match setup_outcome {
Ok(_) => None,
Err(msg) => Some(format!(
"updated '{plugin_name}' but setup script failed: {msg}"
)),
};
}
pub(crate) fn apply_cancel_pending_update(state: &mut PluginsModalState) {
let old = std::mem::replace(&mut state.mode, RightMode::List);
if let RightMode::PendingUpdateConfirm { temp_dir, .. } = old {
cancel_pending_temp(&temp_dir);
}
state.row_error = None;
}
pub(crate) async fn apply_refresh_marketplace(state: &mut PluginsModalState, name: String) {
let Some(url) = state
.file
.marketplaces
.iter()
.find(|m| m.name == name)
.map(|m| m.url.clone())
else {
state.row_error = Some(format!("marketplace '{}' not found", name));
return;
};
let manifest = match fetch_manifest(&url).await {
Ok(m) => m,
Err(e) => {
state.row_error = Some(e);
return;
}
};
let installed_sources: Vec<(String, String)> = state
.file
.installed
.iter()
.filter(|p| p.marketplace.as_deref() == Some(name.as_str()))
.map(|p| (p.name.clone(), p.source_url.clone()))
.collect();
let ls_results = tokio::task::spawn_blocking(move || {
installed_sources
.into_iter()
.map(|(n, s)| (n, install::ls_remote_head(&s)))
.collect::<Vec<_>>()
})
.await
.unwrap_or_default();
if let Some(m) = state.file.marketplaces.iter_mut().find(|m| m.name == name) {
m.cached_plugins = manifest
.plugins
.iter()
.map(|p| synaps_cli::skills::state::CachedPlugin {
name: p.name.clone(),
source: p.source.clone().unwrap_or_else(|| p.index.as_ref().map(|idx| idx.repository.clone()).unwrap_or_default()),
version: p.version.clone(),
description: p.description.clone(),
index: p.index.as_ref().map(cached_index_metadata),
})
.collect();
m.last_refreshed = Some(now_rfc3339());
}
let mut failed: Vec<String> = Vec::new();
for (plugin_name, res) in ls_results {
match res {
Ok(sha) => {
if let Some(p) = state.file.installed.iter_mut().find(|p| p.name == plugin_name) {
p.latest_commit = Some(sha);
}
}
Err(_) => {
failed.push(plugin_name);
}
}
}
if let Err(e) = commit_plugins_state(&state.file) {
state.row_error = Some(format!("save failed: {}", e));
return;
}
if !failed.is_empty() {
state.row_error = Some(format!(
"refreshed '{}', but could not check updates for: {}",
name,
failed.join(", ")
));
} else {
state.row_error = None;
}
state.mode = RightMode::List;
}
pub(crate) async fn apply_remove_marketplace(
state: &mut PluginsModalState,
name: String,
registry: &Arc<CommandRegistry>,
config: &synaps_cli::SynapsConfig,
) {
let to_uninstall: Vec<String> = state
.file
.installed
.iter()
.filter(|p| p.marketplace.as_deref() == Some(name.as_str()))
.map(|p| p.name.clone())
.collect();
let mut failed: Vec<String> = Vec::new();
for plugin_name in &to_uninstall {
let dir = match install_dir_for(plugin_name) {
Ok(d) => d,
Err(e) => { failed.push(format!("{}: {}", plugin_name, e)); continue; }
};
let res = tokio::task::spawn_blocking(move || install::uninstall_plugin(&dir)).await;
match res {
Ok(Ok(())) => {}
Ok(Err(e)) => failed.push(format!("{}: {}", plugin_name, e)),
Err(e) => failed.push(format!("{}: join error: {}", plugin_name, e)),
}
}
state.file.installed.retain(|p| p.marketplace.as_deref() != Some(name.as_str()));
state.file.marketplaces.retain(|m| m.name != name);
if let Err(e) = commit_plugins_state(&state.file) {
state.row_error = Some(format!("save failed: {}", e));
return;
}
reload_registry(registry, config);
let n = state.left_rows().len();
if state.selected_left >= n && n > 0 {
state.selected_left = n - 1;
}
state.row_error = if failed.is_empty() {
None
} else {
Some(format!(
"removed marketplace '{}', but failed to fully uninstall: {}",
name,
failed.join("; ")
))
};
}
pub(crate) fn toggle_plugin_config(
name: &str,
enabled: bool,
config: &mut synaps_cli::SynapsConfig,
registry: &Arc<CommandRegistry>,
) -> Result<(), String> {
let mut new_disabled = config.disabled_plugins.clone();
if enabled {
new_disabled.retain(|p| p != name);
} else if !new_disabled.iter().any(|p| p == name) {
new_disabled.push(name.to_string());
}
let csv = new_disabled.join(", ");
synaps_cli::config::write_config_value("disabled_plugins", &csv)
.map_err(|e| e.to_string())?;
config.disabled_plugins = new_disabled;
reload_registry(registry, config);
Ok(())
}
pub(crate) fn apply_toggle_plugin(
state: &mut PluginsModalState,
name: String,
enabled: bool,
registry: &Arc<CommandRegistry>,
config: &mut synaps_cli::SynapsConfig,
) {
match toggle_plugin_config(&name, enabled, config, registry) {
Ok(()) => {
state.row_error = None;
}
Err(e) => {
state.row_error = Some(e);
}
}
}
fn permission_summary_for_plugin_name(name: &str) -> Option<Vec<String>> {
let roots = synaps_cli::skills::loader::default_roots();
let (plugins, _) = synaps_cli::skills::loader::load_all(&roots);
plugins
.into_iter()
.find(|plugin| plugin.name == name)
.map(|plugin| synaps_cli::skills::trust::summarize_plugin_permissions(&plugin).lines())
}
pub(crate) fn confirm_enable_plugin(
state: &mut PluginsModalState,
name: String,
) {
let summary = permission_summary_for_plugin_name(&name).unwrap_or_else(|| {
vec!["plugin manifest not found; enabling will reload available plugin content".to_string()]
});
state.mode = RightMode::Confirm {
prompt: format!("Enable plugin '{}'? y/n", name),
on_yes: super::state::ConfirmAction::EnablePlugin(name),
summary,
};
}
fn set_editor_or_row_error(state: &mut PluginsModalState, msg: String) {
if let RightMode::AddMarketplaceEditor { error, .. } = &mut state.mode {
*error = Some(msg);
} else {
state.row_error = Some(msg);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, Mutex};
use synaps_cli::skills::registry::CommandRegistry;
use synaps_cli::skills::state::{CachedPlugin, Marketplace, PluginsState};
use synaps_cli::skills::plugin_index::{
PluginIndexCapabilities, PluginIndexChecksum, PluginIndexCompatibility, PluginIndexEntry,
};
static BASE_DIR_TEST_LOCK: Mutex<()> = Mutex::new(());
struct EnvGuard {
old_base_dir: Option<String>,
old_git_config_global: Option<String>,
}
impl EnvGuard {
fn set_base_dir(path: &Path, git_config_global: &Path) -> Self {
let old_base_dir = std::env::var("SYNAPS_BASE_DIR").ok();
let old_git_config_global = std::env::var("GIT_CONFIG_GLOBAL").ok();
synaps_cli::config::set_base_dir_for_tests(path.to_path_buf());
std::env::set_var("GIT_CONFIG_GLOBAL", git_config_global);
Self { old_base_dir, old_git_config_global }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
if let Some(old) = &self.old_base_dir {
std::env::set_var("SYNAPS_BASE_DIR", old);
} else {
std::env::remove_var("SYNAPS_BASE_DIR");
}
if let Some(old) = &self.old_git_config_global {
std::env::set_var("GIT_CONFIG_GLOBAL", old);
} else {
std::env::remove_var("GIT_CONFIG_GLOBAL");
}
}
}
fn git(args: &[&str], cwd: &Path) {
let status = Command::new("git")
.args(args)
.current_dir(cwd)
.status()
.unwrap();
assert!(status.success(), "git {:?} failed", args);
}
fn fixture_plugin_repo() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let work = dir.path().join("work");
fs::create_dir_all(work.join(".synaps-plugin")).unwrap();
fs::write(
work.join(".synaps-plugin/plugin.json"),
r#"{
"name": "policy-test",
"description": "test plugin with an executable extension",
"extension": {
"protocol_version": 1,
"runtime": "process",
"command": "python3",
"args": ["extension.py"],
"permissions": ["tools.intercept"],
"hooks": [{"hook": "before_tool_call", "tool": "bash"}]
}
}"#,
)
.unwrap();
fs::write(work.join("extension.py"), "#!/usr/bin/env python3\n").unwrap();
git(&["init", "-q"], &work);
git(&["config", "user.email", "t@t"], &work);
git(&["config", "user.name", "t"], &work);
git(&["add", "."], &work);
git(&["commit", "-q", "-m", "init"], &work);
let bare = dir.path().join("plugin.git");
let work_s = work.to_string_lossy().to_string();
let bare_s = bare.to_string_lossy().to_string();
git(&["clone", "--bare", "-q", &work_s, &bare_s], dir.path());
(dir, bare)
}
fn push_manifest_update(work: &Path, version: &str, extra_permission: Option<&str>) {
let permissions = match extra_permission {
Some(permission) => format!("[\"tools.intercept\",\"{}\"]", permission),
None => "[\"tools.intercept\"]".to_string(),
};
fs::write(
work.join(".synaps-plugin/plugin.json"),
format!(r#"{{
"name": "policy-test",
"version": "{}",
"description": "test plugin with an executable extension",
"extension": {{
"protocol_version": 1,
"runtime": "process",
"command": "python3",
"args": ["extension.py"],
"permissions": {},
"hooks": [{{"hook": "before_tool_call", "tool": "bash"}}]
}}
}}"#, version, permissions),
)
.unwrap();
git(&["add", "."], work);
git(&["commit", "-q", "-m", "update"], work);
}
fn fixture_plugin_repo_with_work() -> (tempfile::TempDir, PathBuf, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let work = dir.path().join("work");
fs::create_dir_all(work.join(".synaps-plugin")).unwrap();
fs::write(work.join("extension.py"), "#!/usr/bin/env python3\n").unwrap();
git(&["init", "-q"], &work);
git(&["config", "user.email", "t@t"], &work);
git(&["config", "user.name", "t"], &work);
push_manifest_update(&work, "0.1.0", None);
git(&["branch", "-M", "main"], &work);
let bare = dir.path().join("plugin.git");
let work_s = work.to_string_lossy().to_string();
let bare_s = bare.to_string_lossy().to_string();
git(&["clone", "--bare", "-q", &work_s, &bare_s], dir.path());
(dir, bare, work)
}
fn cloned_plugin_checksum(source: &str) -> String {
let clone_parent = tempfile::tempdir().unwrap();
let clone = clone_parent.path().join("clone");
install::install_plugin(source, &clone).unwrap();
install::plugin_dir_sha256(&clone).unwrap()
}
fn state_for_marketplace(source_url: String) -> PluginsModalState {
PluginsModalState::new(PluginsState {
marketplaces: vec![Marketplace {
name: "local-market".into(),
url: "https://example.invalid/marketplace.json".into(),
description: None,
last_refreshed: None,
cached_plugins: vec![CachedPlugin {
name: "policy-test".into(),
source: source_url,
version: None,
description: Some("fixture".into()),
index: None,
}],
repo_url: None,
}],
installed: vec![],
trusted_hosts: vec!["example.invalid/owner".into()],
})
}
fn index_entry(repository: String) -> PluginIndexEntry {
PluginIndexEntry {
id: "policy-test".into(),
name: "policy-test".into(),
version: "0.1.0".into(),
description: "fixture".into(),
repository,
subdir: None,
license: None,
categories: vec![],
keywords: vec![],
checksum: PluginIndexChecksum {
algorithm: "sha256".into(),
value: "abc123".into(),
},
compatibility: PluginIndexCompatibility {
synaps: Some(">=0.1.0".into()),
extension_protocol: Some("1".into()),
},
capabilities: PluginIndexCapabilities {
skills: vec![],
has_extension: true,
permissions: vec!["tools.intercept".into()],
hooks: vec!["before_tool_call".into()],
commands: vec![],
providers: vec![],
},
trust: None,
}
}
#[tokio::test(flavor = "current_thread")]
async fn install_from_index_entry_uses_pending_install_and_reinspects_manifest() {
let _guard = BASE_DIR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = tempfile::tempdir().unwrap();
let _env = EnvGuard::set_base_dir(home.path(), &home.path().join("gitconfig"));
let (_repo_tmp, bare) = fixture_plugin_repo();
let source = format!("file://{}", bare.display());
let checksum = cloned_plugin_checksum(&source);
let repository = "https://example.invalid/owner/policy-test.git".to_string();
let mut state = PluginsModalState::new(PluginsState {
marketplaces: vec![Marketplace {
name: "local-index".into(),
url: "file:///tmp/plugin-index.json".into(),
description: None,
last_refreshed: None,
cached_plugins: vec![CachedPlugin {
name: "policy-test".into(),
source: source.clone(),
version: Some("0.1.0".into()),
description: Some("fixture".into()),
index: Some(CachedPluginIndexMetadata {
repository: repository.clone(),
subdir: None,
checksum_algorithm: "sha256".into(),
checksum_value: checksum.clone(),
compatibility_synaps: Some(">=0.1.0".into()),
compatibility_extension_protocol: Some("1".into()),
has_extension: true,
skills: vec![],
permissions: vec!["tools.intercept".into()],
hooks: vec!["before_tool_call".into()],
commands: vec![],
providers: vec![],
trust_publisher: Some("Example".into()),
trust_homepage: Some("https://example.com".into()),
}),
}],
repo_url: None,
}],
installed: vec![],
trusted_hosts: vec!["example.invalid/owner".into()],
});
let registry = Arc::new(CommandRegistry::new(&[], vec![]));
let config = synaps_cli::SynapsConfig::default();
apply_install_from_index_entry(
&mut state,
"local-index".into(),
PluginIndexEntry {
repository: repository.clone(),
checksum: PluginIndexChecksum {
algorithm: "sha256".into(),
value: checksum.clone(),
},
..index_entry(source.clone())
},
®istry,
&config,
)
.await;
complete_pending_install_clone(&mut state, ®istry, &config).await;
let RightMode::PendingInstallConfirm {
source_url,
marketplace_name,
summary,
temp_dir,
final_dir,
..
} = &state.mode else {
panic!("expected pending install confirmation, got {:?}; error {:?}", state.mode, state.row_error);
};
assert_eq!(source_url, &source);
assert_eq!(marketplace_name.as_deref(), Some("local-index"));
assert!(summary.iter().any(|line| line == "executable extension: yes"));
assert!(temp_dir.exists());
assert!(!final_dir.exists());
}
#[tokio::test(flavor = "current_thread")]
async fn update_rejects_index_checksum_mismatch_before_preview() {
let _guard = BASE_DIR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = tempfile::tempdir().unwrap();
let _env = EnvGuard::set_base_dir(home.path(), &home.path().join("gitconfig"));
let (_repo_tmp, bare) = fixture_plugin_repo();
let source = format!("file://{}", bare.display());
let mut state = PluginsModalState::new(PluginsState {
marketplaces: vec![],
installed: vec![InstalledPlugin {
name: "policy-test".into(),
marketplace: None,
source_url: source.clone(),
installed_commit: "abc".into(),
latest_commit: Some("def".into()),
installed_at: "now".into(),
source_subdir: None,
checksum_algorithm: Some("sha256".into()),
checksum_value: Some("0000000000000000000000000000000000000000000000000000000000000000".into()),
setup_status: Default::default(),
}],
trusted_hosts: vec![],
});
let registry = Arc::new(CommandRegistry::new(&[], vec![]));
let config = synaps_cli::SynapsConfig::default();
let final_dir = install_dir_for("policy-test").unwrap();
install::install_plugin(&source, &final_dir).unwrap();
apply_update(&mut state, "policy-test".into(), ®istry, &config).await;
assert!(matches!(state.mode, RightMode::List));
assert!(state.row_error.as_deref().unwrap_or_default().contains("checksum mismatch"));
assert!(!final_dir.parent().unwrap().join(".policy-test-pending-update").exists());
}
#[tokio::test(flavor = "current_thread")]
async fn update_preview_uses_refreshed_index_checksum_and_shows_manifest_diff() {
let _guard = BASE_DIR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = tempfile::tempdir().unwrap();
let _env = EnvGuard::set_base_dir(home.path(), &home.path().join("gitconfig"));
let (_repo_tmp, bare, work) = fixture_plugin_repo_with_work();
let source = format!("file://{}", bare.display());
let old_checksum = cloned_plugin_checksum(&source);
let final_dir = install_dir_for("policy-test").unwrap();
let installed_sha = install::install_plugin(&source, &final_dir).unwrap();
push_manifest_update(&work, "0.2.0", Some("privacy.llm_content"));
git(&["push", "-q", bare.to_str().unwrap(), "HEAD:main"], &work);
let new_checksum = cloned_plugin_checksum(&source);
let mut state = PluginsModalState::new(PluginsState {
marketplaces: vec![Marketplace {
name: "local-index".into(),
url: "file:///tmp/plugin-index.json".into(),
description: None,
last_refreshed: None,
cached_plugins: vec![CachedPlugin {
name: "policy-test".into(),
source: source.clone(),
version: Some("0.2.0".into()),
description: Some("fixture".into()),
index: Some(CachedPluginIndexMetadata {
repository: source.clone(),
subdir: None,
checksum_algorithm: "sha256".into(),
checksum_value: new_checksum,
compatibility_synaps: Some(">=0.1.0".into()),
compatibility_extension_protocol: Some("1".into()),
has_extension: true,
skills: vec![],
permissions: vec!["tools.intercept".into(), "privacy.llm_content".into()],
hooks: vec!["before_tool_call".into()],
commands: vec![],
providers: vec![],
trust_publisher: None,
trust_homepage: None,
}),
}],
repo_url: None,
}],
installed: vec![InstalledPlugin {
name: "policy-test".into(),
marketplace: Some("local-index".into()),
source_url: source.clone(),
installed_commit: installed_sha,
latest_commit: Some("updated".into()),
installed_at: "now".into(),
source_subdir: None,
checksum_algorithm: Some("sha256".into()),
checksum_value: Some(old_checksum),
setup_status: Default::default(),
}],
trusted_hosts: vec![],
});
let registry = Arc::new(CommandRegistry::new(&[], vec![]));
let config = synaps_cli::SynapsConfig::default();
apply_update(&mut state, "policy-test".into(), ®istry, &config).await;
let RightMode::PendingUpdateConfirm { summary, temp_dir, final_dir: pending_final, .. } = &state.mode else {
panic!("expected pending update confirmation, got {:?}; error {:?}", state.mode, state.row_error);
};
assert_eq!(pending_final, &final_dir);
assert!(temp_dir.exists());
assert!(summary.iter().any(|line| line == "version: 0.1.0 -> 0.2.0"));
assert!(summary.iter().any(|line| line == "added permissions: privacy.llm_content"));
}
#[tokio::test(flavor = "current_thread")]
async fn pending_extension_install_can_be_cancelled_without_touching_real_state() {
let _guard = BASE_DIR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = tempfile::tempdir().unwrap();
let _env = EnvGuard::set_base_dir(home.path(), &home.path().join("gitconfig"));
let (_repo_tmp, bare) = fixture_plugin_repo();
let source = format!("file://{}", bare.display());
let mut state = state_for_marketplace(source);
let registry = Arc::new(CommandRegistry::new(&[], vec![]));
let config = synaps_cli::SynapsConfig::default();
run_install_flow(
&mut state,
"policy-test".into(),
format!("file://{}", bare.display()),
None,
Some("local-market".into()),
None,
®istry,
&config,
);
complete_pending_install_clone(&mut state, ®istry, &config).await;
let (temp_dir, final_dir) = match &state.mode {
RightMode::PendingInstallConfirm { temp_dir, final_dir, summary, .. } => {
assert!(summary.iter().any(|line| line == "executable extension: yes"));
(temp_dir.clone(), final_dir.clone())
}
other => panic!("expected pending install confirmation, got {other:?}"),
};
assert!(temp_dir.exists());
assert!(temp_dir.ends_with(".policy-test-pending-install"));
assert!(!final_dir.exists());
assert!(state.file.installed.is_empty());
apply_cancel_pending_install(&mut state);
assert!(matches!(state.mode, RightMode::List));
assert!(!temp_dir.exists());
assert!(!final_dir.exists());
assert!(state.file.installed.is_empty());
assert!(!home.path().join("plugins.json").exists());
}
#[tokio::test(flavor = "current_thread")]
async fn pending_extension_install_confirm_moves_temp_and_records_plugin() {
let _guard = BASE_DIR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = tempfile::tempdir().unwrap();
let _env = EnvGuard::set_base_dir(home.path(), &home.path().join("gitconfig"));
let (_repo_tmp, bare) = fixture_plugin_repo();
let source = format!("file://{}", bare.display());
let mut state = state_for_marketplace(source.clone());
let registry = Arc::new(CommandRegistry::new(&[], vec![]));
let config = synaps_cli::SynapsConfig::default();
run_install_flow(
&mut state,
"policy-test".into(),
source.clone(),
None,
Some("local-market".into()),
None,
®istry,
&config,
);
complete_pending_install_clone(&mut state, ®istry, &config).await;
let (temp_dir, final_dir) = match &state.mode {
RightMode::PendingInstallConfirm { temp_dir, final_dir, .. } => {
(temp_dir.clone(), final_dir.clone())
}
other => panic!("expected pending install confirmation, got {other:?}"),
};
assert!(temp_dir.exists());
assert!(!final_dir.exists());
apply_confirm_pending_install(&mut state, ®istry, &config).await;
assert!(matches!(state.mode, RightMode::List));
assert!(!temp_dir.exists());
assert!(final_dir.join(".synaps-plugin/plugin.json").exists());
assert_eq!(state.file.installed.len(), 1);
let installed = &state.file.installed[0];
assert_eq!(installed.name, "policy-test");
assert_eq!(installed.marketplace.as_deref(), Some("local-market"));
assert_eq!(installed.source_url, source);
assert_eq!(installed.installed_commit.len(), 40);
let saved = PluginsState::load_from(&home.path().join("plugins.json")).unwrap();
assert_eq!(saved.installed.len(), 1);
assert_eq!(saved.installed[0].name, "policy-test");
}
#[tokio::test(flavor = "current_thread")]
async fn post_install_setup_runs_declared_script() {
let _guard = BASE_DIR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = tempfile::tempdir().unwrap();
let _env = EnvGuard::set_base_dir(home.path(), &home.path().join("gitconfig"));
let plugin_dir = tempfile::tempdir().unwrap();
let synaps_dir = plugin_dir.path().join(".synaps-plugin");
std::fs::create_dir_all(&synaps_dir).unwrap();
std::fs::write(
synaps_dir.join("plugin.json"),
r#"{"name":"hook-test","provides":{"sidecar":{"command":"bin/x","setup":"scripts/setup.sh","protocol_version":1}}}"#,
).unwrap();
let scripts = plugin_dir.path().join("scripts");
std::fs::create_dir(&scripts).unwrap();
let setup = scripts.join("setup.sh");
let marker = plugin_dir.path().join("ran.marker");
std::fs::write(
&setup,
format!("#!/bin/bash\ntouch {}\necho ok\n", marker.display()),
).unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&setup, std::fs::Permissions::from_mode(0o755)).unwrap();
let res = run_post_install_setup_for_dir("hook-test", plugin_dir.path()).await;
assert!(res.is_ok(), "got {res:?}");
let log = match res.unwrap() { PostInstallOutcome::SetupSucceeded(p) => p, other => panic!("expected setup success, got {other:?}") };
assert!(marker.exists(), "setup script should have created the marker");
assert!(log.starts_with(home.path().join("logs/install")));
}
#[tokio::test(flavor = "current_thread")]
async fn post_install_setup_returns_ok_none_when_no_setup_declared() {
let _guard = BASE_DIR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = tempfile::tempdir().unwrap();
let _env = EnvGuard::set_base_dir(home.path(), &home.path().join("gitconfig"));
let plugin_dir = tempfile::tempdir().unwrap();
let synaps_dir = plugin_dir.path().join(".synaps-plugin");
std::fs::create_dir_all(&synaps_dir).unwrap();
std::fs::write(
synaps_dir.join("plugin.json"),
r#"{"name":"no-setup-test"}"#,
).unwrap();
let res = run_post_install_setup_for_dir("no-setup-test", plugin_dir.path()).await;
match res {
Ok(PostInstallOutcome::NotRequired | PostInstallOutcome::BinaryAlreadyPresent | PostInstallOutcome::PrebuiltInstalled) => {}
other => panic!("expected Ok(None), got {other:?}"),
}
}
#[tokio::test(flavor = "current_thread")]
async fn post_install_setup_propagates_non_zero_exit() {
let _guard = BASE_DIR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = tempfile::tempdir().unwrap();
let _env = EnvGuard::set_base_dir(home.path(), &home.path().join("gitconfig"));
let plugin_dir = tempfile::tempdir().unwrap();
let synaps_dir = plugin_dir.path().join(".synaps-plugin");
std::fs::create_dir_all(&synaps_dir).unwrap();
std::fs::write(
synaps_dir.join("plugin.json"),
r#"{"name":"fail-test","provides":{"sidecar":{"command":"bin/x","setup":"fail.sh","protocol_version":1}}}"#,
).unwrap();
let setup = plugin_dir.path().join("fail.sh");
std::fs::write(&setup, "#!/bin/bash\necho boom\nexit 13\n").unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&setup, std::fs::Permissions::from_mode(0o755)).unwrap();
let res = run_post_install_setup_for_dir("fail-test", plugin_dir.path()).await;
let err = res.expect_err("non-zero exit should propagate");
assert!(err.to_string().contains("13"), "error should mention exit code 13: {err}");
assert!(err.to_string().contains(".log"), "error should point at log path: {err}");
}
#[tokio::test(flavor = "current_thread")]
async fn post_install_setup_skipped_when_extension_binary_already_present() {
let _guard = BASE_DIR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = tempfile::tempdir().unwrap();
let _env = EnvGuard::set_base_dir(home.path(), &home.path().join("gitconfig"));
let plugin_dir = tempfile::tempdir().unwrap();
let synaps_dir = plugin_dir.path().join(".synaps-plugin");
std::fs::create_dir_all(&synaps_dir).unwrap();
std::fs::write(
synaps_dir.join("plugin.json"),
r#"{"name":"skip-test","extension":{"runtime":"process","command":"bin/ext","setup":"scripts/setup.sh"}}"#,
).unwrap();
let bin = plugin_dir.path().join("bin/ext");
std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
std::fs::write(&bin, "#!/bin/sh\necho prebuilt").unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
let scripts = plugin_dir.path().join("scripts");
std::fs::create_dir(&scripts).unwrap();
let marker = plugin_dir.path().join("setup-ran.marker");
std::fs::write(
scripts.join("setup.sh"),
format!("#!/bin/bash\ntouch {}\necho ran\n", marker.display()),
).unwrap();
std::fs::set_permissions(scripts.join("setup.sh"), std::fs::Permissions::from_mode(0o755)).unwrap();
let res = run_post_install_setup_for_dir("skip-test", plugin_dir.path()).await;
match res {
Ok(PostInstallOutcome::NotRequired | PostInstallOutcome::BinaryAlreadyPresent | PostInstallOutcome::PrebuiltInstalled) => {}
other => panic!("expected Ok(None) (fast-path skip), got {other:?}"),
}
assert!(!marker.exists(), "setup script must NOT have run on fast-path");
}
#[tokio::test(flavor = "current_thread")]
async fn post_install_setup_fails_when_setup_does_not_produce_extension_binary() {
let _guard = BASE_DIR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = tempfile::tempdir().unwrap();
let _env = EnvGuard::set_base_dir(home.path(), &home.path().join("gitconfig"));
let plugin_dir = tempfile::tempdir().unwrap();
let synaps_dir = plugin_dir.path().join(".synaps-plugin");
std::fs::create_dir_all(&synaps_dir).unwrap();
std::fs::write(
synaps_dir.join("plugin.json"),
r#"{"name":"verify-test","extension":{"runtime":"process","command":"bin/missing","setup":"scripts/setup.sh"}}"#,
).unwrap();
let scripts = plugin_dir.path().join("scripts");
std::fs::create_dir(&scripts).unwrap();
std::fs::write(scripts.join("setup.sh"), "#!/bin/bash\necho lying setup\nexit 0\n").unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(scripts.join("setup.sh"), std::fs::Permissions::from_mode(0o755)).unwrap();
let res = run_post_install_setup_for_dir("verify-test", plugin_dir.path()).await;
let err = res.expect_err("missing post-build artifact must error");
assert!(
err.to_string().contains("did not produce"),
"error should explain build didn't produce binary: {err}"
);
assert!(err.to_string().contains("bin/missing"), "error should name the missing path: {err}");
}
#[tokio::test(flavor = "current_thread")]
async fn post_install_setup_fails_when_no_setup_and_extension_binary_missing() {
let _guard = BASE_DIR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = tempfile::tempdir().unwrap();
let _env = EnvGuard::set_base_dir(home.path(), &home.path().join("gitconfig"));
let plugin_dir = tempfile::tempdir().unwrap();
let synaps_dir = plugin_dir.path().join(".synaps-plugin");
std::fs::create_dir_all(&synaps_dir).unwrap();
std::fs::write(
synaps_dir.join("plugin.json"),
r#"{"name":"no-setup-no-bin","extension":{"runtime":"process","command":"bin/never-built"}}"#,
).unwrap();
let res = run_post_install_setup_for_dir("no-setup-no-bin", plugin_dir.path()).await;
let err = res.expect_err("missing binary with no setup must error");
assert!(err.to_string().contains("verification failed"), "expected verify msg: {err}");
}
#[cfg(all(unix, any(target_arch = "x86_64", target_arch = "aarch64")))]
#[tokio::test(flavor = "current_thread")]
async fn post_install_rejects_file_prebuilt_url_in_production_path() {
let _guard = BASE_DIR_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = tempfile::tempdir().unwrap();
let _env = EnvGuard::set_base_dir(home.path(), &home.path().join("gitconfig"));
let staging = tempfile::tempdir().unwrap();
let work = staging.path().join("staging");
std::fs::create_dir_all(work.join("bin")).unwrap();
std::fs::write(work.join("bin/ext"), "#!/bin/sh\necho prebuilt\n").unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(
work.join("bin/ext"),
std::fs::Permissions::from_mode(0o755),
)
.unwrap();
let archive = staging.path().join("ext.tar.gz");
let out = std::process::Command::new("tar")
.arg("-czf")
.arg(&archive)
.arg("-C")
.arg(&work)
.arg("bin/ext")
.output()
.expect("system tar must be present");
assert!(out.status.success());
let bytes = std::fs::read(&archive).unwrap();
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(&bytes);
let sha: String = h.finalize().iter().map(|b| format!("{:02x}", b)).collect();
let triple = synaps_cli::skills::post_install::host_triple()
.expect("supported test host");
let plugin_dir = tempfile::tempdir().unwrap();
let synaps_plugin_dir = plugin_dir.path().join(".synaps-plugin");
std::fs::create_dir_all(&synaps_plugin_dir).unwrap();
let manifest_json = format!(
r#"{{
"name":"prebuilt-test",
"extension":{{
"runtime":"process",
"command":"bin/ext",
"setup":"scripts/setup.sh",
"prebuilt":{{
"{triple}":{{
"url":"file://{archive}",
"sha256":"{sha}"
}}
}}
}}
}}"#,
triple = triple,
archive = archive.display(),
sha = sha,
);
std::fs::write(synaps_plugin_dir.join("plugin.json"), &manifest_json).unwrap();
let scripts = plugin_dir.path().join("scripts");
std::fs::create_dir(&scripts).unwrap();
let marker = plugin_dir.path().join("setup-ran.marker");
std::fs::write(
scripts.join("setup.sh"),
format!("#!/bin/bash\ntouch {}\n", marker.display()),
)
.unwrap();
std::fs::set_permissions(scripts.join("setup.sh"), std::fs::Permissions::from_mode(0o755))
.unwrap();
let res = run_post_install_setup_for_dir("prebuilt-test", plugin_dir.path()).await;
let err = res.expect_err("file:// prebuilt should be rejected outside post_install cfg(test) unit tests");
assert!(err.to_string().contains("refusing non-https prebuilt url"), "got: {err}");
assert!(!marker.exists(), "setup script must not run after unsafe prebuilt URL");
}
}