bamboo_plugin/installer.rs
1//! The `PluginInstaller` trait — the method surface later agents implement.
2//!
3//! This crate lives at the `infra` layer and has no access to `AppState`
4//! (`bamboo-server`, an `app`-layer crate). Actually registering capabilities
5//! — merging into `config.json`, calling `mcp_manager.start_server`, appending
6//! to `prompt-presets.json`, validating in-place workflow publication — all need `AppState` (or
7//! equivalent handles), so a REAL implementation of this trait has to live in
8//! `bamboo-server` (or a sibling crate that depends on it), implemented by a
9//! later agent (see `PLUGIN_PLAN.md` § Installer-core agent). Because the
10//! trait is foreign there and the implementing type is local, that's a
11//! perfectly ordinary downstream `impl` — no orphan-rule issue.
12//!
13//! [`LocalPluginInstaller`] below is a reference skeleton that implements
14//! everything this crate CAN implement without `AppState` (manifest
15//! validation, platform gating, MCP-entry token resolution, the
16//! disposition/upgrade decision, the `provides.skills`-authoritative check,
17//! provenance listing) and returns [`PluginError::NotImplemented`] at the
18//! exact points that need capability-registration wiring, with a comment
19//! enumerating what goes there and citing the exact files. It is a
20//! reference/example, not a requirement to reuse verbatim — Wave-2's
21//! installer-core agent may replace it entirely with a type that holds an
22//! `AppState` handle.
23//!
24//! # Ownership + upgrade contract (why uninstall is provably safe)
25//!
26//! Two invariants make uninstall/upgrade never touch a user's own entries:
27//!
28//! 1. **Only plugin-created entries are ever recorded as removable.** For MCP
29//! servers, the
30//! installer MUST run [`crate::registry::reconcile_exclusive`] against the
31//! live shared store before touching anything: a declared id/filename that
32//! already exists and is not owned by this plugin lands in
33//! `foreign_conflicts` and the install is REFUSED
34//! ([`PluginError::Conflict`]) — it is never registered and never written
35//! into [`crate::registry::RegisteredCapabilities`]. So the `registered`
36//! set an [`InstalledPlugin`] carries contains ONLY entries this plugin
37//! genuinely created; `uninstall` iterating that set can only ever delete
38//! the plugin's own entries. (Prompt presets are the one exception: they
39//! rename on collision via bamboo-server's `ensure_unique_preset_id`
40//! instead of refusing, and the RENAMED id is what gets recorded.)
41//! 2. **Upgrade de-registers what the new version dropped.** `install` with
42//! [`InstallDisposition::Upgrade`] loads the prior [`InstalledPlugin`],
43//! computes [`crate::registry::RegisteredCapabilities::removed_since`] (old
44//! minus new), de-registers those dropped capabilities, THEN registers the
45//! new set and upserts provenance — so a capability the old version had and
46//! the new one dropped can't leak as an orphan.
47
48use std::path::{Path, PathBuf};
49
50use async_trait::async_trait;
51use chrono::{DateTime, Utc};
52
53use crate::error::{PluginError, PluginResult};
54use crate::manifest::{Platform, PluginManifest};
55use crate::registry::{InstalledPlugin, InstalledPlugins, PluginSource};
56
57/// How [`PluginInstaller::install`] must treat a plugin id that is ALREADY
58/// installed. Maps directly to the CLI verbs: `bamboo plugin install` uses
59/// [`Self::FailIfInstalled`], `bamboo plugin update` (or `install --force`)
60/// uses [`Self::Upgrade`].
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum InstallDisposition {
63 /// Refuse a re-install of an already-installed id with
64 /// [`PluginError::AlreadyInstalled`]. A first-time `install` should not
65 /// silently replace an existing plugin.
66 FailIfInstalled,
67 /// Upgrade in place: de-register the capabilities the new version dropped
68 /// (via [`crate::registry::RegisteredCapabilities::removed_since`]),
69 /// register the new set, then upsert provenance.
70 Upgrade,
71}
72
73/// The installer method surface. `install`/`uninstall`/`list` are the verbs
74/// the CLI (`bamboo plugin install/list/remove/update`) and the HTTP routes
75/// (`/api/v1/plugins`) both call through.
76#[async_trait]
77pub trait PluginInstaller {
78 /// Install (or, with [`InstallDisposition::Upgrade`], upgrade) a plugin
79 /// already unpacked at `plugin_dir` (source handling — copying a local
80 /// dir, unpacking a `.tar.gz`, fetching+verifying a URL + selecting the
81 /// per-platform artifact — happens BEFORE this is called; by the time
82 /// `install` runs, `plugin_dir` already contains `plugin.json` plus the
83 /// `skills/`/`prompts/`/`workflows/`/`bin/` layout the manifest declares).
84 ///
85 /// Contract (see the module docs for the full rationale):
86 /// - `disposition` decides already-installed handling (fail vs upgrade).
87 /// - MCP-server collisions with non-plugin entries must be refused through
88 /// [`crate::registry::reconcile_exclusive`]. Workflow markdown remains
89 /// isolated in place and therefore cannot clobber a user source.
90 /// - On upgrade, capabilities the new version dropped MUST be
91 /// de-registered ([`crate::registry::RegisteredCapabilities::removed_since`]).
92 /// - The returned [`InstalledPlugin`] must have already been persisted
93 /// into `installed.json`, and its `registered` set must contain ONLY
94 /// entries this plugin genuinely created (so uninstall is safe).
95 async fn install(
96 &self,
97 manifest: &PluginManifest,
98 plugin_dir: &Path,
99 source: PluginSource,
100 disposition: InstallDisposition,
101 installed_at: DateTime<Utc>,
102 ) -> PluginResult<InstalledPlugin>;
103
104 /// Reverse everything `install` registered for `id` (stop + remove MCP
105 /// servers, remove prompt presets, and clean legacy copied workflow files), then remove
106 /// the provenance entry and delete `plugin_dir` from disk. Safe by
107 /// construction: the provenance `registered` set only ever names
108 /// plugin-created entries (invariant 1 in the module docs).
109 async fn uninstall(&self, id: &str) -> PluginResult<()>;
110
111 /// All currently-installed plugins (a thin read of `installed.json`).
112 async fn list(&self) -> PluginResult<Vec<InstalledPlugin>>;
113}
114
115/// Directory names directly under `<plugin_dir>/skills/` that contain a
116/// `SKILL.md` (i.e. what discovery would actually pick up in place). Used
117/// by [`preflight_install`] to enforce that `provides.skills` is
118/// authoritative (MAJOR 4).
119pub async fn on_disk_skill_dirs(plugin_dir: &Path) -> Vec<String> {
120 let skills_root = plugin_dir.join("skills");
121 let mut found = Vec::new();
122 let Ok(mut entries) = tokio::fs::read_dir(&skills_root).await else {
123 return found;
124 };
125 while let Ok(Some(entry)) = entries.next_entry().await {
126 let is_dir = entry
127 .file_type()
128 .await
129 .map(|file_type| file_type.is_dir())
130 .unwrap_or(false);
131 if !is_dir {
132 continue;
133 }
134 let has_skill_md = tokio::fs::try_exists(entry.path().join("SKILL.md"))
135 .await
136 .unwrap_or(false);
137 if !has_skill_md {
138 continue;
139 }
140 if let Some(name) = entry.file_name().to_str() {
141 found.push(name.to_string());
142 }
143 }
144 found
145}
146
147/// Everything an `install` can validate/resolve WITHOUT touching `AppState`:
148/// manifest validation, platform gating, per-entry MCP resolution (so a
149/// malformed entry — e.g. an empty stdio command — fails fast before any
150/// registration happens), and the `provides.skills` / `provides.workflows`
151/// on-disk existence + authoritative checks (MAJOR 4).
152///
153/// Shared by [`LocalPluginInstaller`] and any real `AppState`-backed
154/// installer (see `PLUGIN_PLAN.md` § Installer-core agent) so the two can
155/// never drift apart. Returns the resolved MCP server configs (the caller
156/// needs them anyway to register step 1) so a real installer doesn't have to
157/// re-resolve them a second time.
158pub async fn preflight_install(
159 manifest: &PluginManifest,
160 plugin_dir: &Path,
161) -> PluginResult<Vec<bamboo_domain::mcp_config::McpServerConfig>> {
162 manifest.validate()?;
163
164 let current_platform = Platform::current();
165 if let Some(platforms) = &manifest.platforms {
166 // An unrecognized host OS (`current_platform == None`) fails closed
167 // rather than guessing.
168 let supported = current_platform.is_some_and(|platform| platforms.contains(&platform));
169 if !supported {
170 return Err(PluginError::UnsupportedPlatform {
171 plugin_id: manifest.id.clone(),
172 platform: current_platform
173 .map(|platform| platform.as_str().to_string())
174 .unwrap_or_else(|| std::env::consts::OS.to_string()),
175 });
176 }
177 }
178
179 // Resolve what each declared MCP server WOULD look like once registered.
180 // Pure — fails early on an unresolvable entry (e.g. empty stdio command)
181 // and hands the caller the resolved configs to register in step 1.
182 let platform = current_platform.unwrap_or(Platform::Linux);
183 let resolved_mcp_servers = manifest
184 .provides
185 .mcp_servers
186 .iter()
187 .map(|entry| entry.resolve(plugin_dir, &manifest.id, platform))
188 .collect::<PluginResult<Vec<_>>>()?;
189
190 // Sanity-check declared skill dirs exist on disk. Skills need no further
191 // action here — they're discovered in place by bamboo-skills' plugin
192 // discovery-dir extension, not copied.
193 for skill_dir in &manifest.provides.skills {
194 let skill_md = plugin_dir.join("skills").join(skill_dir).join("SKILL.md");
195 if !tokio::fs::try_exists(&skill_md).await.unwrap_or(false) {
196 return Err(PluginError::InvalidManifest(format!(
197 "declared skill '{skill_dir}' has no SKILL.md at {}",
198 skill_md.display()
199 )));
200 }
201 }
202 // `provides.skills` is AUTHORITATIVE: reject any on-disk skill dir the
203 // manifest does not declare. Discovery is a dumb globber that picks up
204 // every `<plugin_dir>/skills/*` with a SKILL.md, so without this a
205 // bundle could smuggle an undeclared skill live past its own manifest.
206 {
207 use std::collections::HashSet;
208 let declared: HashSet<&str> = manifest
209 .provides
210 .skills
211 .iter()
212 .map(String::as_str)
213 .collect();
214 for on_disk in on_disk_skill_dirs(plugin_dir).await {
215 if !declared.contains(on_disk.as_str()) {
216 return Err(PluginError::InvalidManifest(format!(
217 "skill directory '{on_disk}' exists under skills/ but is not declared in \
218 provides.skills (a plugin must declare every skill it ships)"
219 )));
220 }
221 }
222 }
223 for workflow_file in &manifest.provides.workflows {
224 let workflow_path = plugin_dir.join("workflows").join(workflow_file);
225 if !tokio::fs::try_exists(&workflow_path).await.unwrap_or(false) {
226 return Err(PluginError::InvalidManifest(format!(
227 "declared workflow '{workflow_file}' not found at {}",
228 workflow_path.display()
229 )));
230 }
231 }
232 let workflows_root = plugin_dir.join("workflows");
233 if tokio::fs::try_exists(&workflows_root)
234 .await
235 .unwrap_or(false)
236 {
237 let declared: std::collections::HashSet<&str> = manifest
238 .provides
239 .workflows
240 .iter()
241 .map(String::as_str)
242 .collect();
243 let mut actual = std::collections::HashSet::new();
244 let mut entries = tokio::fs::read_dir(&workflows_root).await?;
245 while let Some(entry) = entries.next_entry().await? {
246 let path = entry.path();
247 if path.extension().and_then(|value| value.to_str()) != Some("md") {
248 continue;
249 }
250 let Some(filename) = entry.file_name().to_str().map(str::to_string) else {
251 return Err(PluginError::InvalidManifest(
252 "plugin workflow filename must be UTF-8".to_string(),
253 ));
254 };
255 actual.insert(filename);
256 }
257 if actual.len() != declared.len()
258 || !actual.iter().all(|name| declared.contains(name.as_str()))
259 {
260 return Err(PluginError::InvalidManifest(
261 "provides.workflows must declare every workflows/*.md file exactly once"
262 .to_string(),
263 ));
264 }
265 }
266
267 Ok(resolved_mcp_servers)
268}
269
270/// Loads prior provenance for `plugin_id` from `installed_json_path` and
271/// applies the [`InstallDisposition`] gate: [`InstallDisposition::FailIfInstalled`]
272/// errors [`PluginError::AlreadyInstalled`] when a COMPLETED entry already
273/// exists; [`InstallDisposition::Upgrade`] passes through either way. Returns
274/// the previous entry (`None` for a fresh install), which the caller needs for
275/// the upgrade drop-diff (BLOCKER 2) and for crash recovery.
276///
277/// Crash-recovery exception: a previous row with
278/// [`crate::registry::PluginInstallStatus::Installing`] is a leftover from an
279/// interrupted install (a hard kill mid-registration), NOT a completed
280/// install — so it does NOT trip `AlreadyInstalled` even under
281/// `FailIfInstalled`. It is returned like any other `previous` so the caller
282/// treats its (intended) `registered` set as this-plugin-owned and cleans it
283/// up as an upgrade-over-incomplete, instead of the leftover's half-written
284/// entries reading as a foreign conflict on retry.
285pub async fn load_previous_for_disposition(
286 installed_json_path: &Path,
287 plugin_id: &str,
288 disposition: InstallDisposition,
289) -> PluginResult<Option<InstalledPlugin>> {
290 use crate::registry::PluginInstallStatus;
291
292 let existing = InstalledPlugins::load(installed_json_path).await?;
293 let previous = existing.get(plugin_id).cloned();
294 let is_completed = previous
295 .as_ref()
296 .is_some_and(|plugin| plugin.status == PluginInstallStatus::Installed);
297 if is_completed && disposition == InstallDisposition::FailIfInstalled {
298 return Err(PluginError::AlreadyInstalled(plugin_id.to_string()));
299 }
300 Ok(previous)
301}
302
303/// Reference skeleton — see module docs. Holds only a `bamboo_dir` so tests
304/// can point it at a tempdir instead of the real `~/.bamboo`.
305pub struct LocalPluginInstaller {
306 bamboo_dir: PathBuf,
307}
308
309impl LocalPluginInstaller {
310 pub fn new(bamboo_dir: PathBuf) -> Self {
311 Self { bamboo_dir }
312 }
313
314 fn plugins_dir(&self) -> PathBuf {
315 self.bamboo_dir.join("plugins")
316 }
317
318 fn installed_json_path(&self) -> PathBuf {
319 self.plugins_dir().join("installed.json")
320 }
321}
322
323impl Default for LocalPluginInstaller {
324 fn default() -> Self {
325 Self::new(bamboo_config::paths::bamboo_dir())
326 }
327}
328
329#[async_trait]
330impl PluginInstaller for LocalPluginInstaller {
331 async fn install(
332 &self,
333 manifest: &PluginManifest,
334 plugin_dir: &Path,
335 _source: PluginSource,
336 disposition: InstallDisposition,
337 _installed_at: DateTime<Utc>,
338 ) -> PluginResult<InstalledPlugin> {
339 // --- Disposition / upgrade decision (fully implemented here) ---
340 //
341 // Load prior provenance FIRST so we can (a) reject a first-time
342 // install of an already-installed id, and (b) on upgrade, capture the
343 // old registered set for the drop-diff below.
344 let previous =
345 load_previous_for_disposition(&self.installed_json_path(), &manifest.id, disposition)
346 .await?;
347 // On upgrade this is the set the installer-core agent must
348 // `removed_since`-diff against the new registered set and de-register.
349 let _previous_registered = previous.as_ref().map(|plugin| plugin.registered.clone());
350
351 // --- Validation + pure path resolution (fully implemented here,
352 // shared with any real AppState-backed installer via
353 // `preflight_install`) ---
354 let _resolved_mcp_servers = preflight_install(manifest, plugin_dir).await?;
355
356 // --- Capability-registration wiring (TODO: installer-core agent) ---
357 //
358 // None of this can be implemented in this infra-layer crate — it all
359 // needs `AppState` (bamboo-server, an app-layer crate this crate must
360 // not depend on). See PLUGIN_PLAN.md § Installer-core agent for the
361 // full breakdown. In order:
362 //
363 // 0. UPGRADE DROP-DIFF (only when `previous` is Some): compute
364 // `new_registered.removed_since(&_previous_registered.unwrap())`
365 // and DE-register those dropped mcp ids / preset ids / workflow
366 // files (same removal ops as `uninstall`) BEFORE registering the
367 // new set, so a capability the old version had and the new one
368 // dropped never leaks (BLOCKER 2).
369 // 1. MCP: run `registry::reconcile_exclusive(declared_mcp_ids,
370 // existing_mcp_ids_in_config, previously_owned_mcp_ids)`. If
371 // `foreign_conflicts` is non-empty → return `PluginError::Conflict`
372 // (BLOCKER 1 — do NOT clobber). Otherwise merge only `to_register`
373 // into `Config.mcp.servers` via `AppState::update_config`
374 // (crates/app/bamboo-server/src/app_state/config_runtime.rs),
375 // reusing the merge-by-id logic in
376 // crates/app/bamboo-server/src/handlers/agent/mcp/server_handlers/import.rs
377 // (`import_servers`), then `state.mcp_manager.start_server(..)`
378 // for each enabled one. Record exactly `to_register` as owned.
379 // 2. Prompts: append `manifest.provides.prompts` into
380 // `prompt-presets.json`
381 // (crates/app/bamboo-server/src/handlers/agent/prompt_presets/storage.rs),
382 // reusing `validate_preset_id` / `ensure_unique_preset_id` — on an
383 // id collision RENAME (don't refuse), and record the RENAMED id as
384 // owned (not the manifest's nominal one).
385 // 3. Workflows: validate each declared in-place
386 // `<plugin_dir>/workflows/<name>.md` with
387 // `bamboo_config::paths::is_safe_workflow_name`. SkillStore
388 // discovers the files in place; do not copy them globally.
389 // 4. Skills: nothing to register (discovered in place). Record the
390 // declared+validated dir names as owned.
391 // 5. Only once 0-4 succeed: build the `RegisteredCapabilities`
392 // reflecting exactly what got registered (renamed preset ids, the
393 // `to_register` MCP subset — NOT a blind copy of
394 // `manifest.provides`), then upsert provenance via
395 // `InstalledPlugins::load` + `.add(..)` + `.save(..)` at
396 // `self.installed_json_path()`.
397 let _ = self.installed_json_path(); // reserved for step 5 above.
398 Err(PluginError::NotImplemented(
399 "capability registration wiring — see PLUGIN_PLAN.md \
400 \u{a7} Installer-core agent"
401 .to_string(),
402 ))
403 }
404
405 async fn uninstall(&self, id: &str) -> PluginResult<()> {
406 let installed = InstalledPlugins::load(&self.installed_json_path()).await?;
407 if installed.get(id).is_none() {
408 return Err(PluginError::NotFound(id.to_string()));
409 }
410
411 // TODO(installer-core agent): using the found entry's `registered`
412 // capabilities (which, by construction, name ONLY plugin-created
413 // entries — see module docs), stop + remove each `mcp_server_ids`
414 // entry from `config.json` (`AppState::update_config` +
415 // `mcp_manager.stop_server`), remove each `preset_ids` entry from
416 // `prompt-presets.json`, delete each `workflow_filenames` file from
417 // `bamboo_config::paths::workflows_dir()`. THEN remove the provenance
418 // entry (`InstalledPlugins::remove` + `.save(..)`) and finally
419 // `tokio::fs::remove_dir_all(entry.plugin_dir)`.
420 Err(PluginError::NotImplemented(
421 "capability de-registration wiring — see PLUGIN_PLAN.md \
422 \u{a7} Installer-core agent"
423 .to_string(),
424 ))
425 }
426
427 async fn list(&self) -> PluginResult<Vec<InstalledPlugin>> {
428 let installed = InstalledPlugins::load(&self.installed_json_path()).await?;
429 Ok(installed.plugins)
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use crate::manifest::PluginManifest;
437 use crate::registry::RegisteredCapabilities;
438
439 fn manifest_with(skills: Vec<&str>, workflows: Vec<&str>) -> PluginManifest {
440 let json = serde_json::json!({
441 "id": "hello-plugin",
442 "name": "Hello Plugin",
443 "version": "0.1.0",
444 "provides": {
445 "skills": skills,
446 "workflows": workflows,
447 }
448 });
449 PluginManifest::parse_str(&json.to_string()).expect("parse manifest")
450 }
451
452 /// Create `<plugin_dir>/skills/<id>/SKILL.md` for each id.
453 async fn write_skill_dirs(plugin_dir: &Path, ids: &[&str]) {
454 for id in ids {
455 let skill_dir = plugin_dir.join("skills").join(id);
456 tokio::fs::create_dir_all(&skill_dir).await.unwrap();
457 tokio::fs::write(
458 skill_dir.join("SKILL.md"),
459 format!("---\nname: {id}\ndescription: demo\n---\nHi\n"),
460 )
461 .await
462 .unwrap();
463 }
464 }
465
466 #[tokio::test]
467 async fn list_on_fresh_bamboo_dir_is_empty() {
468 let dir = tempfile::tempdir().expect("tempdir");
469 let installer = LocalPluginInstaller::new(dir.path().to_path_buf());
470 let plugins = installer.list().await.expect("list");
471 assert!(plugins.is_empty());
472 }
473
474 #[tokio::test]
475 async fn uninstall_unknown_plugin_is_not_found() {
476 let dir = tempfile::tempdir().expect("tempdir");
477 let installer = LocalPluginInstaller::new(dir.path().to_path_buf());
478 let error = installer
479 .uninstall("does-not-exist")
480 .await
481 .expect_err("should be not-found");
482 assert!(matches!(error, PluginError::NotFound(_)));
483 }
484
485 #[tokio::test]
486 async fn install_validates_declared_skill_exists_on_disk() {
487 let dir = tempfile::tempdir().expect("tempdir");
488 let installer = LocalPluginInstaller::new(dir.path().join("bamboo-home"));
489
490 let plugin_dir = dir.path().join("plugin-src");
491 tokio::fs::create_dir_all(&plugin_dir).await.unwrap();
492 // Note: no `skills/hello-world/SKILL.md` created — install should
493 // reject the manifest for a missing declared skill before it ever
494 // reaches the (currently NotImplemented) registration step.
495 let manifest = manifest_with(vec!["hello-world"], vec![]);
496
497 let error = installer
498 .install(
499 &manifest,
500 &plugin_dir,
501 PluginSource::LocalDir {
502 path: plugin_dir.clone(),
503 },
504 InstallDisposition::FailIfInstalled,
505 Utc::now(),
506 )
507 .await
508 .expect_err("missing SKILL.md should fail validation");
509 assert!(matches!(error, PluginError::InvalidManifest(_)));
510 }
511
512 #[tokio::test]
513 async fn install_rejects_undeclared_on_disk_skill_dir() {
514 let dir = tempfile::tempdir().expect("tempdir");
515 let installer = LocalPluginInstaller::new(dir.path().join("bamboo-home"));
516
517 let plugin_dir = dir.path().join("plugin-src");
518 // Bundle ships TWO skills on disk but declares only one.
519 write_skill_dirs(&plugin_dir, &["hello-world", "sneaky-extra"]).await;
520 let manifest = manifest_with(vec!["hello-world"], vec![]);
521
522 let error = installer
523 .install(
524 &manifest,
525 &plugin_dir,
526 PluginSource::LocalDir {
527 path: plugin_dir.clone(),
528 },
529 InstallDisposition::FailIfInstalled,
530 Utc::now(),
531 )
532 .await
533 .expect_err("undeclared skill dir should be rejected");
534 assert!(matches!(error, PluginError::InvalidManifest(_)));
535 assert!(error.to_string().contains("sneaky-extra"));
536 }
537
538 #[tokio::test]
539 async fn install_reaches_not_implemented_once_declared_files_exist() {
540 let dir = tempfile::tempdir().expect("tempdir");
541 let installer = LocalPluginInstaller::new(dir.path().join("bamboo-home"));
542
543 let plugin_dir = dir.path().join("plugin-src");
544 write_skill_dirs(&plugin_dir, &["hello-world"]).await;
545 let manifest = manifest_with(vec!["hello-world"], vec![]);
546
547 let error = installer
548 .install(
549 &manifest,
550 &plugin_dir,
551 PluginSource::LocalDir {
552 path: plugin_dir.clone(),
553 },
554 InstallDisposition::FailIfInstalled,
555 Utc::now(),
556 )
557 .await
558 .expect_err("registration wiring is a later-agent TODO");
559 assert!(matches!(error, PluginError::NotImplemented(_)));
560
561 // And it must not have been committed to provenance either, since
562 // registration never completed.
563 let plugins = installer.list().await.expect("list");
564 assert!(plugins.is_empty());
565 }
566
567 #[tokio::test]
568 async fn install_fails_if_already_installed_under_fail_disposition() {
569 let dir = tempfile::tempdir().expect("tempdir");
570 let bamboo_home = dir.path().join("bamboo-home");
571 let installer = LocalPluginInstaller::new(bamboo_home.clone());
572
573 // Seed provenance with an existing install of the same id.
574 let mut store = InstalledPlugins::default();
575 store.add(InstalledPlugin {
576 id: "hello-plugin".to_string(),
577 version: "0.0.1".to_string(),
578 source: PluginSource::LocalDir {
579 path: dir.path().to_path_buf(),
580 },
581 plugin_dir: bamboo_home.join("plugins").join("hello-plugin"),
582 installed_at: Utc::now(),
583 status: crate::registry::PluginInstallStatus::Installed,
584 registered: RegisteredCapabilities::default(),
585 });
586 store
587 .save(&bamboo_home.join("plugins").join("installed.json"))
588 .await
589 .unwrap();
590
591 let plugin_dir = dir.path().join("plugin-src");
592 write_skill_dirs(&plugin_dir, &["hello-world"]).await;
593 let manifest = manifest_with(vec!["hello-world"], vec![]);
594
595 let error = installer
596 .install(
597 &manifest,
598 &plugin_dir,
599 PluginSource::LocalDir {
600 path: plugin_dir.clone(),
601 },
602 InstallDisposition::FailIfInstalled,
603 Utc::now(),
604 )
605 .await
606 .expect_err("already-installed under FailIfInstalled should error");
607 assert!(matches!(error, PluginError::AlreadyInstalled(_)));
608 }
609
610 #[tokio::test]
611 async fn upgrade_disposition_proceeds_past_the_already_installed_gate() {
612 let dir = tempfile::tempdir().expect("tempdir");
613 let bamboo_home = dir.path().join("bamboo-home");
614 let installer = LocalPluginInstaller::new(bamboo_home.clone());
615
616 // Same seed as the FailIfInstalled test.
617 let mut store = InstalledPlugins::default();
618 store.add(InstalledPlugin {
619 id: "hello-plugin".to_string(),
620 version: "0.0.1".to_string(),
621 source: PluginSource::LocalDir {
622 path: dir.path().to_path_buf(),
623 },
624 plugin_dir: bamboo_home.join("plugins").join("hello-plugin"),
625 installed_at: Utc::now(),
626 status: crate::registry::PluginInstallStatus::Installed,
627 registered: RegisteredCapabilities::default(),
628 });
629 store
630 .save(&bamboo_home.join("plugins").join("installed.json"))
631 .await
632 .unwrap();
633
634 let plugin_dir = dir.path().join("plugin-src");
635 write_skill_dirs(&plugin_dir, &["hello-world"]).await;
636 let manifest = manifest_with(vec!["hello-world"], vec![]);
637
638 // Upgrade must NOT hit AlreadyInstalled; it proceeds to the (still
639 // later-agent) registration TODO.
640 let error = installer
641 .install(
642 &manifest,
643 &plugin_dir,
644 PluginSource::LocalDir {
645 path: plugin_dir.clone(),
646 },
647 InstallDisposition::Upgrade,
648 Utc::now(),
649 )
650 .await
651 .expect_err("registration wiring is a later-agent TODO");
652 assert!(matches!(error, PluginError::NotImplemented(_)));
653 }
654}