diff --git a/README.md b/README.md index 0d74bba..7b1d4aa 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,7 @@ version = "1.0.0" description = "Web development MCP stack" authors = ["yourname"] keywords = ["web", "browser", "git"] +min_tool_version = "0.4.0" # optional: minimum weave version required [[servers]] name = "puppeteer" @@ -247,6 +248,7 @@ Packs can also declare: - **Slash commands / skills** — copied into `~/.claude/commands/` or `~/.codex/skills/` - **System prompt fragments** — appended to `CLAUDE.md` / `GEMINI.md` / `AGENTS.md` between tagged delimiters - **Settings fragments** — deep-merged into Claude Code and Gemini CLI JSON settings; merged as top-level keys in Codex CLI's TOML config +- **Minimum tool version** — `min_tool_version` rejects install on older weave versions - **Environment variable declarations** — written as `${VAR}` references, never values > [!IMPORTANT] diff --git a/src/core/install.rs b/src/core/install.rs index 12d81ad..1174792 100644 --- a/src/core/install.rs +++ b/src/core/install.rs @@ -17,6 +17,27 @@ use crate::core::resolver::Resolver; use crate::core::store::Store; use crate::error::{Result, WeaveError}; +/// Check that the current weave version satisfies the pack's `min_tool_version`. +/// +/// Returns `Ok(())` if the pack has no `min_tool_version` requirement or if the +/// current version meets or exceeds it. Returns `Err(IncompatibleToolVersion)` +/// otherwise. This check runs in both normal and dry-run paths so users always +/// see version incompatibilities early. +pub fn check_min_tool_version(pack: &Pack) -> Result<()> { + if let Some(ref min_version) = pack.min_tool_version { + let current = semver::Version::parse(env!("CARGO_PKG_VERSION")) + .expect("CARGO_PKG_VERSION is always valid semver"); + if current < *min_version { + return Err(WeaveError::IncompatibleToolVersion { + pack_name: pack.name.clone(), + required: min_version.clone(), + current, + }); + } + } + Ok(()) +} + /// Returns the names of adapters that a pack would target, based on the pack's /// `targets` flags and which adapters are installed. pub fn target_adapters(pack: &Pack, adapters: &[Box]) -> Vec { @@ -124,6 +145,7 @@ pub fn install_from_registry( reason: "registry release missing pack.toml".to_string(), })?; let pack = Pack::from_toml(pack_toml, &std::path::PathBuf::from("pack.toml"))?; + check_min_tool_version(&pack)?; // Validate manifest matches what was resolved, same as the normal path. if pack.name != *name { @@ -177,6 +199,7 @@ pub fn install_from_registry( // Load the pack manifest let pack = Pack::load(&pack_dir)?; + check_min_tool_version(&pack)?; // Validate that the manifest matches what was resolved. if pack.name != *name { @@ -303,6 +326,7 @@ pub fn install_local( dry_run: bool, ) -> Result { let pack = Pack::load(path)?; + check_min_tool_version(&pack)?; let name = &pack.name; let version = &pack.version; @@ -612,6 +636,86 @@ mod tests { use std::collections::HashSet; use std::path::PathBuf; + /// Helper: build a Pack from TOML for testing. + fn pack_from_toml(toml: &str) -> Pack { + Pack::from_toml(toml, &PathBuf::from("test.toml")).unwrap() + } + + // ── min_tool_version tests ────────────────────────────────────────────── + + #[test] + fn check_min_tool_version_higher_than_current_errors() { + let toml = r#" +[pack] +name = "future-pack" +version = "1.0.0" +description = "Needs a future weave" +authors = ["tester"] +min_tool_version = "99.0.0" +"#; + let pack = pack_from_toml(toml); + let result = check_min_tool_version(&pack); + assert!( + result.is_err(), + "should fail when min_tool_version exceeds current" + ); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("99.0.0"), + "error should mention required version: {err_msg}" + ); + assert!( + err_msg.contains("please upgrade"), + "error should tell user to upgrade: {err_msg}" + ); + assert!( + err_msg.contains("future-pack"), + "error should mention pack name: {err_msg}" + ); + } + + #[test] + fn check_min_tool_version_equal_to_current_succeeds() { + let current_version = env!("CARGO_PKG_VERSION"); + let toml = format!( + r#" +[pack] +name = "current-pack" +version = "1.0.0" +description = "Needs exactly the current weave" +authors = ["tester"] +min_tool_version = "{current_version}" +"# + ); + let pack = pack_from_toml(&toml); + let result = check_min_tool_version(&pack); + assert!( + result.is_ok(), + "should succeed when min_tool_version equals current: {:?}", + result.err() + ); + } + + #[test] + fn check_min_tool_version_none_succeeds() { + let toml = r#" +[pack] +name = "no-min-pack" +version = "1.0.0" +description = "No min_tool_version specified" +authors = ["tester"] +"#; + let pack = pack_from_toml(toml); + let result = check_min_tool_version(&pack); + assert!( + result.is_ok(), + "should succeed when min_tool_version is absent: {:?}", + result.err() + ); + } + + // ── apply_to_adapters rollback tests ──────────────────────────────────── + /// A mock adapter that succeeds on apply. struct SucceedAdapter { adapter_name: &'static str, @@ -736,25 +840,18 @@ mod tests { let (applied, errors) = apply_to_adapters(&resolved, &adapters, &options); - // applied should be empty because rollback occurred assert!( applied.is_empty(), "expected empty applied list after rollback, got: {applied:?}" ); - - // Should have errors: one for the failure, one for the rollback assert!( errors.len() >= 2, "expected at least 2 errors (failure + rollback), got: {errors:?}" ); - - // The first error should be from the failing adapter assert!( errors[0].contains("Gemini CLI"), "first error should mention failing adapter: {errors:?}" ); - - // The second error should mention the rollback assert!( errors[1].contains("rolled back"), "second error should mention rollback: {errors:?}" @@ -774,9 +871,7 @@ mod tests { let (applied, errors) = apply_to_adapters(&resolved, &adapters, &options); - // applied should be empty — the first adapter failed before any succeeded assert!(applied.is_empty()); - // Only one error — the failure itself, no rollbacks needed assert_eq!(errors.len(), 1); assert!(errors[0].contains("Gemini CLI")); } diff --git a/src/core/use_profile.rs b/src/core/use_profile.rs index acda5f2..1b2ba70 100644 --- a/src/core/use_profile.rs +++ b/src/core/use_profile.rs @@ -243,8 +243,20 @@ pub fn switch( &installed.version, Some(&installed.source), ) { - Ok(pack) => crate::core::install::target_adapters(&pack, adapters), - Err(_) => adapters.iter().map(|a| a.name().to_string()).collect(), + Ok(pack) => { + crate::core::install::check_min_tool_version(&pack)?; + crate::core::install::target_adapters(&pack, adapters) + } + Err(_) => { + // Pack not in local cache — version compatibility and target + // adapters cannot be verified in dry-run mode. + log::warn!( + "pack {}@{} not cached locally — dry-run preview may be approximate", + installed.name, + installed.version + ); + adapters.iter().map(|a| a.name().to_string()).collect() + } }; apply_result.applied_adapters = target_names; } else { @@ -265,6 +277,8 @@ pub fn switch( } }; + crate::core::install::check_min_tool_version(&pack)?; + let resolved = ResolvedPack { pack, source: installed.source.clone(), diff --git a/src/error.rs b/src/error.rs index 004f08c..896bce9 100644 --- a/src/error.rs +++ b/src/error.rs @@ -25,6 +25,15 @@ pub enum WeaveError { #[error("pack '{name}' is not installed — {hint}")] NotInstalled { name: String, hint: String }, + #[error( + "pack '{pack_name}' requires weave {required} or later, but this is weave {current} — please upgrade" + )] + IncompatibleToolVersion { + pack_name: String, + required: semver::Version, + current: semver::Version, + }, + // Install/update validation errors #[error( "pack manifest {field} '{actual}' does not match resolved {field} '{expected}' — the archive may be corrupt or tampered" diff --git a/tests/e2e/cli_install.rs b/tests/e2e/cli_install.rs index 3888b62..31f6906 100644 --- a/tests/e2e/cli_install.rs +++ b/tests/e2e/cli_install.rs @@ -580,3 +580,36 @@ async fn remove_http_server_cleans_up() { "remote-api should be removed from ~/.claude.json after pack removal" ); } + +// ── min_tool_version enforcement ────────────────────────────────────────────── + +#[cfg(not(target_os = "windows"))] +#[tokio::test] +async fn install_local_pack_with_incompatible_min_tool_version() { + let env = TestEnv::new().await; + + // Create a local pack that requires a far-future weave version. + let pack_dir = env.project_dir.path().join("future-pack"); + std::fs::create_dir_all(&pack_dir).unwrap(); + std::fs::write( + pack_dir.join("pack.toml"), + r#"[pack] +name = "future-pack" +version = "0.1.0" +description = "requires future weave" +authors = ["tester"] +min_tool_version = "99.0.0" +"#, + ) + .unwrap(); + + env.weave_cmd() + .args(["install", "./future-pack"]) + .assert() + .failure() + .stderr( + predicate::str::contains("99.0.0") + .and(predicate::str::contains("please upgrade")) + .from_utf8(), + ); +}