From 152b14bbc1d5127d9cf292fc2f5a240628d5ff7a Mon Sep 17 00:00:00 2001 From: "b10x-bot[bot]" <316511680+b10x-bot[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:43:55 +0200 Subject: [PATCH] feat: daily freshness check, verified releases and tooling pins; release 0.14.11 - b10x check refreshes the newest releases at most once a day (check::refresh, resolve::latest_tags, 3 s timeouts, parallel) and names /b10x:upgrade when plugins or a CLI are older. - make_plan plans from the remote release when the local marketplace clone is older (resolve::stale); upgrade reported 0.14.7 as current. - verified.json: releases the skills were verified against; the daily tools check fails on a newer aep, ess or worktree release. - b10x pin / unpin and b10x.toml (pins.rs): exact or minor-line pins, honoured by init, upgrade, setup plan and install; check warns on a mismatch and when skills describe a newer release. - task check: 115 tests passed. --- .agents/skills/improving-by-trial/SKILL.md | 10 + AGENTS.md | 3 + CHANGELOG.md | 21 ++ Cargo.lock | 4 +- Cargo.toml | 2 +- crates/agentplugins-check/src/main.rs | 1 + crates/agentplugins-check/src/tools.rs | 76 ++++- crates/b10x/src/check.rs | 258 +++++++++++++++-- crates/b10x/src/main.rs | 142 ++++++++- crates/b10x/src/pins.rs | 274 ++++++++++++++++++ crates/b10x/src/plan.rs | 157 +++++++++- crates/b10x/src/resolve.rs | 239 ++++++++++++++- plugins/aep/.claude-plugin/plugin.json | 2 +- plugins/aep/.codex-plugin/plugin.json | 2 +- plugins/b10x/.claude-plugin/plugin.json | 2 +- plugins/b10x/.codex-plugin/plugin.json | 2 +- plugins/b10x/skills/upgrade/SKILL.md | 4 +- plugins/connectors/.claude-plugin/plugin.json | 2 +- plugins/connectors/.codex-plugin/plugin.json | 2 +- plugins/ess/.claude-plugin/plugin.json | 2 +- plugins/ess/.codex-plugin/plugin.json | 2 +- plugins/worktree/.claude-plugin/plugin.json | 2 +- plugins/worktree/.codex-plugin/plugin.json | 2 +- verified.json | 5 + website/docs/install.md | 15 + website/docs/plugins/b10x.md | 20 +- 26 files changed, 1180 insertions(+), 71 deletions(-) create mode 100644 crates/b10x/src/pins.rs create mode 100644 verified.json diff --git a/.agents/skills/improving-by-trial/SKILL.md b/.agents/skills/improving-by-trial/SKILL.md index 35d1e18..e5f20cc 100644 --- a/.agents/skills/improving-by-trial/SKILL.md +++ b/.agents/skills/improving-by-trial/SKILL.md @@ -159,6 +159,16 @@ Each round runs every trial in `trials/`: 4 ESS trials (`ess-new`, a new specifi round so the agents cannot copy the previous answer from the skills; a changed trial starts a new baseline entry. +### Every product release is re-verified + +`verified.json` names, per CLI (`aep`, `ess`, `worktree`), the release the skills were last +verified against. The daily `agentplugins-check tools` run fails with one line per CLI whose newest +release is newer. Then: + +1. Run `agentplugins-check tools` and fix every command it reports. +2. Run an ESS trial round (at least `ess-full-package`) against the new release. +3. Set the CLI to the new release in `verified.json` in the same pull request. + ## 7. Clean up `trial:run` deletes the credentials it copied. When the round is released, check that no diff --git a/AGENTS.md b/AGENTS.md index d36b61f..fc8118c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ CLIs. Serves O2 (decisions as data) and O3 (any harness). | `plugins//` | every plugin: `b10x`, `aep`, `ess`, `worktree`, `connectors` ([structure](website/docs/structure.md)) | | `.claude-plugin/marketplace.json`, `.agents/plugins/marketplace.json` | the two marketplace files | | `catalog.json` | products, plugins, binaries, retired names — no versions | +| `verified.json` | per CLI, the release the skills were last verified against; `tools` fails when a newer one is out | | `crates/b10x/` | the setup CLI; `plan.rs` is pure and fixture-tested | | `crates/agentplugins-check/` | the gate: marketplace, catalog, concept, retired names, CLI spellings, evals; `tools` checks skills against the newest CLI releases | | `evals/` | eval corpus ([`evals/README.md`](evals/README.md)) | @@ -29,6 +30,8 @@ CLIs. Serves O2 (decisions as data) and O3 (any harness). - Retired names appear only where the gate allows them (`CHANGELOG.md`, `changes/`, `.engineering/`, `catalog.json`, `crates/b10x/`, the checker's own table). - Anything executable is Rust. +- Every `aep`, `ess` and `worktree` release is re-verified before `verified.json` moves to it: + `agentplugins-check tools`, then an ESS trial round ([`improving-by-trial`](.agents/skills/improving-by-trial/SKILL.md)). - Trial findings for another repository become an issue there, labelled `trial-finding` by the bot; the skill here documents the workaround until the fix is released ([`improving-by-trial`](.agents/skills/improving-by-trial/SKILL.md)). diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f85054..722c1ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## [0.14.11] — 2026-09-26 + +Keeping installs current, keeping the skills matched to each product release, and pinning a tooling +version per repository. + +- `b10x check` (the session-start line) refreshes the newest releases from GitHub at most once a day, + in parallel with a short timeout, and names `/b10x:upgrade` when the installed plugins or a CLI are + older. It used to compare against a record written only by the last plan, so a machine on 0.14.7 + heard nothing about 0.14.10. +- `b10x upgrade` and `b10x setup plan` read plugin versions from the newest release when the local + marketplace copy is older than it; they reported 0.14.7 as current until the apply step refreshed + the copy. +- `verified.json` records the aep, ess and worktree releases the skills were last verified against; + the daily `agentplugins-check tools` fails when a newer release exists, until the skills are + re-verified and the file is bumped. +- `b10x pin ` and `b10x unpin ` keep a committed `b10x.toml` (`[pins]`, exact or + a minor line such as `0.32`). `init`, `upgrade`, `setup plan` and `install` install the pinned + release; `upgrade` reports newer ones and changes nothing; `b10x check` warns when the CLI on + `PATH` differs from the pin and when the skills describe a newer release. The matching `requires` + line in `ess-inputs.yaml` waits for beyond10x/ess#106. + ## [0.14.10] — 2026-09-25 From round 5, the first measured trial round: seven trials on 0.14.9, all isolated. The diff --git a/Cargo.lock b/Cargo.lock index 0e43bdd..7ed17ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "agentplugins-check" -version = "0.14.10" +version = "0.14.11" dependencies = [ "clap", "serde", @@ -76,7 +76,7 @@ dependencies = [ [[package]] name = "b10x" -version = "0.14.10" +version = "0.14.11" dependencies = [ "clap", "serde", diff --git a/Cargo.toml b/Cargo.toml index 2be2824..2ed9b0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/agentplugins-check", "crates/b10x"] [workspace.package] -version = "0.14.10" +version = "0.14.11" edition = "2021" rust-version = "1.85" license = "Apache-2.0" diff --git a/crates/agentplugins-check/src/main.rs b/crates/agentplugins-check/src/main.rs index 77bba12..32e29d3 100644 --- a/crates/agentplugins-check/src/main.rs +++ b/crates/agentplugins-check/src/main.rs @@ -917,6 +917,7 @@ fn check(root: &Path) -> Result<(), String> { marketplace(root, ".agents/plugins/marketplace.json")?; marketplace(root, ".claude-plugin/marketplace.json")?; catalog(root)?; + tools::verified(root)?; for (name, required) in PLUGINS { plugin(root, name, required)?; } diff --git a/crates/agentplugins-check/src/tools.rs b/crates/agentplugins-check/src/tools.rs index f01a5db..685fff8 100644 --- a/crates/agentplugins-check/src/tools.rs +++ b/crates/agentplugins-check/src/tools.rs @@ -6,6 +6,10 @@ //! checked against its `SHA256SUMS` — and runs ` --help` for every command a //! code span or code block in that plugin spells. ESS's syntax example must also still validate. //! It runs on every pull request, every `main` push and daily; a red run is fixed by a skill edit. +//! +//! It also fails when a CLI's newest release is newer than `verified.json`, the release its skills +//! were last verified against: every product release is re-verified (this check and an ESS trial +//! round) before `verified.json` moves. The offline gate only checks that file's shape. use std::collections::BTreeSet; use std::path::{Path, PathBuf}; @@ -21,6 +25,53 @@ const TOOLS: &[(&str, &str, &str)] = &[ /// Most subcommand words taken from one spelled command. const DEPTH: usize = 4; +/// The file naming, per CLI, the release the skills were last verified against. +pub const VERIFIED: &str = "verified.json"; + +/// A key for an `x.y.z` version or tag. +fn key(version: &str) -> Option<(u64, u64, u64)> { + let mut parts = version.trim_start_matches('v').split('.'); + let triple = ( + parts.next()?.parse().ok()?, + parts.next()?.parse().ok()?, + parts.next()?.parse().ok()?, + ); + parts.next().is_none().then_some(triple) +} + +/// Offline: `verified.json` names exactly the CLIs the skills drive, each at an `x.y.z` release. +pub fn verified(root: &Path) -> Result, String> { + let path = root.join(VERIFIED); + let text = std::fs::read_to_string(&path).map_err(|error| format!("{VERIFIED}: {error}"))?; + let map: std::collections::BTreeMap = serde_json::from_str(&text) + .map_err(|error| format!("{VERIFIED}: an object of CLI → `x.y.z` release: {error}"))?; + let expected: BTreeSet<&str> = TOOLS.iter().map(|(_, cli, _)| *cli).collect(); + let named: BTreeSet<&str> = map.keys().map(String::as_str).collect(); + if named != expected { + return Err(format!( + "{VERIFIED}: names {named:?}; it must name exactly {expected:?}" + )); + } + for (cli, release) in &map { + if key(release).is_none() { + return Err(format!( + "{VERIFIED}: `{cli}` is `{release}`, not an `x.y.z` release" + )); + } + } + Ok(map) +} + +/// The line for a CLI whose newest release is newer than the one its skills were verified against. +#[must_use] +pub fn unverified(cli: &str, newest: &str, verified: &str) -> Option { + (key(newest)? > key(verified)?).then(|| { + format!( + "{cli} {newest} is newer than {VERIFIED} ({verified}): re-verify the skills (`agentplugins-check tools` and an ESS trial round), then set `{cli}` to {newest} in {VERIFIED}" + ) + }) +} + fn run(program: &str, arguments: &[&str]) -> Result { let output = Command::new(program) .args(arguments) @@ -264,9 +315,16 @@ pub fn verify(root: &Path) -> Result<(), String> { std::env::temp_dir().join(format!("agentplugins-check-tools-{}", std::process::id())); let _ = std::fs::remove_dir_all(&scratch); let result = (|| { + let verified = verified(root)?; let mut problems = Vec::new(); for (plugin, cli, repository) in TOOLS { let (tag, binary) = fetch(cli, repository, &scratch)?; + if let Some(line) = verified + .get(*cli) + .and_then(|release| unverified(cli, &tag, release)) + { + problems.push(line); + } let mut checked = 0; for file in markdown(&root.join("plugins").join(plugin)) { let text = std::fs::read_to_string(&file).map_err(|error| error.to_string())?; @@ -296,7 +354,7 @@ pub fn verify(root: &Path) -> Result<(), String> { Ok(()) } else { Err(format!( - "{} spelled command(s) the newest releases do not have:\n {}", + "{} problem(s) against the newest releases:\n {}", problems.len(), problems.join("\n ") )) @@ -310,6 +368,22 @@ pub fn verify(root: &Path) -> Result<(), String> { mod tests { use super::*; + #[test] + fn a_newer_release_than_verified_is_named_with_the_step() { + let line = unverified("ess", "0.32.2", "0.32.1").unwrap(); + assert!(line.starts_with("ess 0.32.2 is newer than verified.json (0.32.1)")); + assert!(line.contains("ESS trial round")); + assert_eq!(unverified("ess", "0.32.1", "0.32.1"), None); + assert_eq!(unverified("ess", "v0.32.0", "0.32.1"), None); + } + + #[test] + fn the_committed_verified_file_has_its_shape() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let map = verified(&root).unwrap(); + assert_eq!(map.len(), TOOLS.len()); + } + #[test] fn commands_are_read_from_code_only() { let text = "Run `ess specify validate --path ` then prose ess binary.\n\n```console\n$ ess generate project openapi --out x\nb10x skill ess:init\n```\n`ess` alone, `ess specify|generate `\n"; diff --git a/crates/b10x/src/check.rs b/crates/b10x/src/check.rs index eab262e..6600532 100644 --- a/crates/b10x/src/check.rs +++ b/crates/b10x/src/check.rs @@ -1,12 +1,52 @@ -//! The session-start check. Offline and quick: it reads what the hosts recorded on disk, runs each -//! installed product's `--version`, and compares with the newest releases the last plan saw. It -//! prints one line per problem, nothing when all is well, and never fails the session. +//! The session-start check. Quick: it reads what the hosts recorded on disk, runs each installed +//! product's `--version`, and compares with the newest releases recorded in +//! `~/.local/state/b10x/latest.json`. That record is refreshed from GitHub at most once a day, +//! within a few seconds; offline, the old record stands. It prints one line per problem, nothing +//! when all is well, and never fails the session. +use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; use crate::catalog::Catalog; use crate::inventory::{copies_on_path, home}; -use crate::version; +use crate::pins::{self, PinFile, Spec}; +use crate::{resolve, version}; + +/// The releases the skills were last verified against: the repository's `verified.json`. +const VERIFIED: &str = include_str!("../../../verified.json"); + +/// How often the newest-release record is refreshed from GitHub. +const REFRESH_EVERY: u64 = 86_400; + +/// How long one refresh may take, in seconds; the repositories are read in parallel. +const REFRESH_SECONDS: u64 = 3; + +/// CLI → the release its skills were last verified against. +#[must_use] +pub fn verified() -> BTreeMap { + serde_json::from_str(VERIFIED).unwrap_or_default() +} + +/// Refresh the newest-release record when the last try is a day old: the marketplace repository +/// and every catalog binary's, each within [`REFRESH_SECONDS`]. Failures leave the record as it was. +pub fn refresh(home: &Path, catalog: &Catalog, now: u64) { + let record = resolve::recorded(home); + let last = ["checked_at", "attempted_at"] + .iter() + .filter_map(|key| record.get(*key).and_then(serde_json::Value::as_u64)) + .max(); + if last.is_some_and(|last| now.saturating_sub(last) < REFRESH_EVERY) { + return; + } + let mut repositories = BTreeSet::from([catalog.marketplace.repository.clone()]); + for product in &catalog.products { + for binary in &product.binaries { + repositories.insert(binary.install.repository().to_owned()); + } + } + let tags = resolve::latest_tags(&repositories, REFRESH_SECONDS); + resolve::record(home, &tags, now); +} /// A recorded install: `name`, `marketplace`, version. pub type Recorded = (String, String, Option); @@ -52,17 +92,52 @@ pub fn latest(home: &Path) -> Option<(u64, serde_json::Map { + /// The pin file that applies in the session's directory. + pub pins: Option<&'a PinFile>, + /// CLI → the release the skills were verified against. + pub verified: &'a BTreeMap, +} + /// The lines to print. #[must_use] +#[allow(clippy::too_many_lines)] pub fn lines( catalog: &Catalog, plugins: &[Recorded], binary_version: Probe<'_>, last: Option<(u64, &serde_json::Map)>, now: u64, + expected: &Expected<'_>, ) -> Vec { let mut lines = Vec::new(); let name = catalog.marketplace.name.as_str(); + let newest_of = |repository: &str| { + last.and_then(|(_, map)| map.get(repository)) + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }; + let oldest_plugin = plugins + .iter() + .filter(|(_, marketplace, _)| marketplace == name) + .filter_map(|(_, _, found)| found.as_deref()) + .filter(|found| version::key(found).is_some()) + .min_by_key(|found| version::key(found)); + if let (Some(have), Some(newest)) = (oldest_plugin, newest_of(&catalog.marketplace.repository)) + { + if version::key(have) < version::key(&newest) { + lines.push(format!( + "b10x: the Beyond10x plugins ({have}) are older than the newest release {newest}; /b10x:upgrade updates them." + )); + } + } + let pinned = |binary: &str| { + expected + .pins + .and_then(|file| Some((file.pins.get(binary)?, file.path.display().to_string()))) + }; for (plugin, marketplace, _) in plugins { if catalog.retired_marketplace(marketplace) || catalog.retired_plugins.contains_key(plugin) { @@ -87,15 +162,14 @@ pub fn lines( } any_product = true; for binary in product.binaries.iter().filter(|binary| !binary.optional) { - let newest = last - .and_then(|(_, map)| map.get(binary.install.repository())) - .and_then(serde_json::Value::as_str) - .map(str::to_owned); + let newest = newest_of(binary.install.repository()); match binary_version(&binary.name) { None => lines.push(format!( "b10x: the `{}` plugin is installed but the `{}` CLI is not on PATH; /{}:init installs it.", product.id, binary.name, product.id )), + // A pinned CLI is held to its pin below, not to the newest release. + Some(_) if pinned(&binary.name).is_some() => {} Some((path, found)) => { let found = found.unwrap_or_else(|| "an unknown version".to_owned()); if let Some(newest) = newest { @@ -105,8 +179,8 @@ pub fn lines( ); if behind { lines.push(format!( - "b10x: `{}` {found} ({path}) is older than the newest release {newest}; /{}:upgrade updates it.", - binary.name, product.id + "b10x: `{}` {found} ({path}) is older than the newest release {newest}; /b10x:upgrade updates it.", + binary.name )); } } @@ -114,6 +188,33 @@ pub fn lines( } } } + for product in &catalog.products { + for binary in &product.binaries { + let Some((spec, file)) = pinned(&binary.name) else { + continue; + }; + let Ok(parsed) = Spec::parse(spec) else { + continue; + }; + if let Some((path, found)) = binary_version(&binary.name) { + let found = found.unwrap_or_else(|| "an unknown version".to_owned()); + if !parsed.matches(&found) { + lines.push(format!( + "b10x: `{}` {found} ({path}) does not match the pin {spec} in {file}; `b10x install {}` installs the pinned release.", + binary.name, binary.name + )); + } + } + if let Some(described) = expected.verified.get(&binary.name) { + if parsed.older_than(described) { + lines.push(format!( + "b10x: skills describe {} {described}; this repository pins {spec} ({file}).", + binary.name + )); + } + } + } + } if any_product { let days = last.map(|(at, _)| now.saturating_sub(at) / 86_400); match days { @@ -141,6 +242,8 @@ pub fn lines( /// Run the check and print its lines. pub fn run(catalog: &Catalog) { let home = home(); + let now = resolve::now(); + refresh(&home, catalog, now); let plugins = recorded(&home); let first = |name: &str| { copies_on_path(name) @@ -149,11 +252,21 @@ pub fn run(catalog: &Catalog) { .map(|copy| (copy.path, copy.version)) }; let cached = latest(&home); - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |elapsed| elapsed.as_secs()); let last = cached.as_ref().map(|(at, map)| (*at, map)); - for line in lines(catalog, &plugins, &first, last, now) { + let pin_file = match std::env::current_dir().map(|here| pins::read(&here, &home)) { + Ok(Ok(found)) => found, + Ok(Err(error)) => { + println!("b10x: {error}"); + None + } + Err(_) => None, + }; + let verified = verified(); + let expected = Expected { + pins: pin_file.as_ref(), + verified: &verified, + }; + for line in lines(catalog, &plugins, &first, last, now, &expected) { println!("{line}"); } } @@ -172,6 +285,105 @@ mod tests { map } + static NOTHING_VERIFIED: BTreeMap = BTreeMap::new(); + + fn unpinned() -> Expected<'static> { + Expected { + pins: None, + verified: &NOTHING_VERIFIED, + } + } + + fn ess_at(version: &'static str) -> impl Fn(&str) -> Option<(String, Option)> { + move |_: &str| { + Some(( + "/opt/b10x-home/.local/bin/ess".to_owned(), + Some(version.to_owned()), + )) + } + } + + #[test] + fn the_embedded_verified_releases_parse() { + let verified = verified(); + for cli in ["aep", "ess", "worktree"] { + assert!( + verified.get(cli).and_then(|v| version::key(v)).is_some(), + "{cli}: {verified:?}" + ); + } + } + + #[test] + fn plugins_older_than_the_newest_agentplugins_release_name_upgrade() { + let catalog = Catalog::embedded(); + let plugins = [ + plugin("b10x", "b10x", "0.14.7"), + plugin("ess", "b10x", "0.14.7"), + ]; + let mut map = cache("0.30.0"); + map.insert( + "beyond10x/agentplugins".to_owned(), + serde_json::json!("0.14.10"), + ); + let out = lines( + &catalog, + &plugins, + &ess_at("0.30.0"), + Some((1_000, &map)), + 1_000, + &unpinned(), + ); + assert_eq!( + out, + ["b10x: the Beyond10x plugins (0.14.7) are older than the newest release 0.14.10; /b10x:upgrade updates them."] + ); + } + + #[test] + fn a_pinned_cli_is_held_to_its_pin_and_newer_skills_are_noted() { + let catalog = Catalog::embedded(); + let plugins = [plugin("ess", "b10x", "0.14.0")]; + let map = cache("0.32.1"); + let file = PinFile { + path: std::path::PathBuf::from("/work/repo/b10x.toml"), + pins: BTreeMap::from([("ess".to_owned(), "0.32.0".to_owned())]), + }; + let verified = BTreeMap::from([("ess".to_owned(), "0.32.1".to_owned())]); + let expected = Expected { + pins: Some(&file), + verified: &verified, + }; + // At the pin: no "older than the newest" nag, only the skills note. + let out = lines( + &catalog, + &plugins, + &ess_at("0.32.0"), + Some((1_000, &map)), + 1_000, + &expected, + ); + assert_eq!( + out, + ["b10x: skills describe ess 0.32.1; this repository pins 0.32.0 (/work/repo/b10x.toml)."] + ); + // Off the pin: the fixing command is named. + let out = lines( + &catalog, + &plugins, + &ess_at("0.32.1"), + Some((1_000, &map)), + 1_000, + &expected, + ); + assert!( + out.iter() + .any(|l| l.contains("does not match the pin 0.32.0") + && l.contains("`b10x install ess`")), + "{out:#?}" + ); + } + #[test] fn silent_when_current_and_recently_checked() { let catalog = Catalog::embedded(); @@ -191,7 +403,8 @@ mod tests { &plugins, &found, Some((1_000, &map)), - 1_000 + 86_400 + 1_000 + 86_400, + &unpinned() ) .is_empty()); } @@ -210,12 +423,19 @@ mod tests { )) }; let map = cache("0.30.0"); - let out = lines(&catalog, &plugins, &found, Some((0, &map)), 30 * 86_400); + let out = lines( + &catalog, + &plugins, + &found, + Some((0, &map)), + 30 * 86_400, + &unpinned(), + ); assert_eq!(out.len(), 3, "{out:#?}"); assert!(out .iter() .any(|l| l.contains("older than the newest release 0.30.0") - && l.contains("/ess:upgrade"))); + && l.contains("/b10x:upgrade"))); assert!(out .iter() .any(|l| l.contains("workspace-hygiene@beyond10x"))); @@ -226,7 +446,7 @@ mod tests { fn points_at_init_when_nothing_is_set_up() { let catalog = Catalog::embedded(); let plugins = [plugin("b10x", "b10x", "0.14.0")]; - let out = lines(&catalog, &plugins, &|_: &str| None, None, 0); + let out = lines(&catalog, &plugins, &|_: &str| None, None, 0, &unpinned()); assert_eq!(out.len(), 1); assert!(out[0].contains("/b10x:init")); } @@ -238,7 +458,7 @@ mod tests { plugin("b10x", "b10x", "0.12.0"), plugin("aep-plan", "b10x", "0.12.0"), ]; - let out = lines(&catalog, &plugins, &|_: &str| None, None, 0); + let out = lines(&catalog, &plugins, &|_: &str| None, None, 0, &unpinned()); assert!(!out.iter().any(|l| l.contains("/b10x:init")), "{out:#?}"); } } diff --git a/crates/b10x/src/main.rs b/crates/b10x/src/main.rs index c77fc8a..931a68e 100644 --- a/crates/b10x/src/main.rs +++ b/crates/b10x/src/main.rs @@ -5,6 +5,7 @@ mod catalog; mod check; mod install; mod inventory; +mod pins; mod plan; mod resolve; mod skill; @@ -73,8 +74,22 @@ enum Top { #[command(subcommand)] command: Setup, }, - /// Offline drift check for a session-start hook; prints only problems; always exits 0. + /// Drift check for a session-start hook; prints only problems; always exits 0. Refreshes the + /// newest-release record from GitHub at most once a day, within a few seconds. Check, + /// Pin a CLI for this repository in `b10x.toml` (here, or the nearest one above): + /// `0.32.0` is that release, `0.59` the newest `0.59.x`. + Pin { + /// Catalog binary, e.g. `ess`. + name: String, + /// `x.y.z` or `x.y`. + version: String, + }, + /// Remove a CLI's pin from the nearest `b10x.toml`. + Unpin { + /// Catalog binary, e.g. `ess`. + name: String, + }, /// Print an installed skill or agent (`ess:specifying`), or list a plugin's (`ess`). A host loads /// new plugins only in a new session; this works in the session that installed them. Skill { @@ -85,7 +100,7 @@ enum Top { Install { /// Binary name, e.g. `ess`. name: String, - /// Release tag; defaults to the newest release. + /// Release tag; defaults to this repository's pin (`b10x.toml`), else the newest release. #[arg(long)] tag: Option, /// Target directory; defaults to `~/.local/bin` (prebuilt) or `~/.cargo/bin` (cargo). @@ -180,6 +195,8 @@ fn main() -> ExitCode { check::run(&Catalog::embedded()); Ok(ExitCode::SUCCESS) } + Top::Pin { name, version } => pin(&name, &version), + Top::Unpin { name } => unpin(&name), Top::Install { name, tag, @@ -297,9 +314,16 @@ fn make_plan( .as_deref() .map(std::path::Path::new) .filter(|path| path.is_dir()); + // The marketplace's newest release; a host clone older than it would call old plugins current + // (the clone is refreshed only by applying), so the plan then reads the repository itself. + let upstream = local + .is_none() + .then(|| embedded.marketplace.repository.clone()); + let newest = upstream.as_deref().and_then(resolve::latest_tag); let source = match (local, resolve::clone_of(&inventory, &embedded)) { - (Some(path), _) | (None, Some(path)) => Source::Clone(path), - (None, None) => Source::Remote(&embedded.marketplace.repository), + (Some(path), _) => Source::Clone(path), + (None, Some(path)) if !resolve::stale(path, newest.as_deref()) => Source::Clone(path), + (None, _) => Source::Remote(&embedded.marketplace.repository), }; let mut catalog = resolve::catalog(&source); if let Some(source) = &overridden { @@ -316,8 +340,12 @@ fn make_plan( } } } - let resolved = resolve::resolve(&catalog, &source); let home = inventory::home(); + let pins = repository_pins(&catalog, &home)?; + let mut resolved = resolve::resolve(&catalog, &source, pins.as_ref()); + if let (Some(repository), Some(tag)) = (upstream, newest) { + resolved.latest.insert(repository, tag); + } resolve::remember(&home, &resolved); let context = plan::Context { catalog: &catalog, @@ -333,6 +361,83 @@ fn make_plan( Ok(plan::plan(&context, &inventory)) } +/// The pins that apply in the current directory; every pinned name must be a catalog binary. +fn repository_pins( + catalog: &Catalog, + home: &std::path::Path, +) -> Result, String> { + let Ok(here) = std::env::current_dir() else { + return Ok(None); + }; + let found = pins::read(&here, home)?; + if let Some(file) = &found { + for name in file.pins.keys() { + if catalog.binary(name).is_none() { + return Err(format!( + "{} pins `{name}`, which is not a catalog binary; `b10x unpin {name}` removes it", + file.path.display() + )); + } + } + } + Ok(found) +} + +fn pin(name: &str, version: &str) -> Result { + let catalog = Catalog::embedded(); + let (_, binary) = catalog.binary(name).ok_or_else(|| { + let known: Vec<&str> = catalog + .products + .iter() + .flat_map(|product| product.binaries.iter().map(|binary| binary.name.as_str())) + .collect(); + format!( + "`{name}` is not a catalog binary; choose from {}", + known.join(", ") + ) + })?; + let spec = pins::Spec::parse(version)?; + let repository = binary.install.repository(); + let newest = resolve::latest_tag(repository); + let tag = resolve::pinned_tag(repository, spec, newest.as_deref()) + .filter(|tag| resolve::release_exists(repository, tag)) + .ok_or_else(|| format!("{repository} has no release matching `{version}`"))?; + let home = inventory::home(); + let here = std::env::current_dir().map_err(|error| error.to_string())?; + if here == home { + return Err(format!( + "run it inside a repository: a {} in $HOME pins nothing", + pins::FILE + )); + } + let path = pins::find(&here, &home).unwrap_or_else(|| here.join(pins::FILE)); + pins::set(&path, name, version.trim().trim_start_matches('v'))?; + println!( + "pinned {name} to {version} ({tag}) in {}; `b10x install {name}` installs it, commit the file to share the pin", + path.display() + ); + if let Some(newest) = newest.filter(|newest| !version::same(newest, &tag)) { + println!("the newest release is {newest}; `b10x unpin {name}` follows it again"); + } + Ok(ExitCode::SUCCESS) +} + +fn unpin(name: &str) -> Result { + let home = inventory::home(); + let here = std::env::current_dir().map_err(|error| error.to_string())?; + let path = pins::find(&here, &home) + .ok_or_else(|| format!("no {} here or above; nothing is pinned", pins::FILE))?; + if pins::remove(&path, name)? { + println!( + "unpinned {name} in {}; `b10x upgrade` follows the newest release", + path.display() + ); + Ok(ExitCode::SUCCESS) + } else { + Err(format!("{} does not pin `{name}`", path.display())) + } +} + fn setup_plan( products: Option>, host: Hosts, @@ -566,9 +671,30 @@ fn install_one( .binary(name) .ok_or_else(|| format!("`{name}` is not a catalog binary"))?; let repository = binary.install.repository(); - let tag = match tag { - Some(tag) => tag, - None => resolve::latest_tag(repository) + let pinned = match &tag { + Some(_) => None, + None => repository_pins(&catalog, &inventory::home())? + .and_then(|file| Some((file.pins.get(name)?.clone(), file.path))), + }; + let tag = match (tag, pinned) { + (Some(tag), _) => tag, + (None, Some((spec, file))) => { + let parsed = pins::Spec::parse(&spec)?; + let tag = resolve::pinned_tag( + repository, + parsed, + resolve::latest_tag(repository).as_deref(), + ) + .ok_or_else(|| { + format!( + "{repository} has no release matching the pin {spec} in {}", + file.display() + ) + })?; + println!("{name} is pinned to {spec} by {}", file.display()); + tag + } + (None, None) => resolve::latest_tag(repository) .ok_or_else(|| format!("no release found for {repository}"))?, }; let target = install::target().ok(); diff --git a/crates/b10x/src/pins.rs b/crates/b10x/src/pins.rs new file mode 100644 index 0000000..05bac8f --- /dev/null +++ b/crates/b10x/src/pins.rs @@ -0,0 +1,274 @@ +//! Per-repository CLI pins. A repository may commit `b10x.toml`: +//! +//! ```toml +//! [pins] +//! ess = "0.32.0" # exactly this release +//! aep = "0.59" # the newest 0.59.x +//! ``` +//! +//! `b10x` finds it from the current directory upward, stopping below `$HOME` or at the filesystem +//! root, and resolves a pinned CLI to its pinned release instead of the newest. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::version; + +/// The pin file's name. +pub const FILE: &str = "b10x.toml"; + +/// What a pin asks for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Spec { + /// Exactly `x.y.z`. + Exact((u64, u64, u64)), + /// The newest `x.y.*`. + Line(u64, u64), +} + +impl Spec { + /// Parse `0.32.0` or `0.59` (a leading `v` is allowed). + pub fn parse(text: &str) -> Result { + let bare = text.trim().trim_start_matches('v'); + if let Some(key) = version::key(bare) { + return Ok(Spec::Exact(key)); + } + let mut parts = bare.split('.'); + match ( + parts.next().and_then(|part| part.parse().ok()), + parts.next().and_then(|part| part.parse().ok()), + parts.next(), + ) { + (Some(major), Some(minor), None) => Ok(Spec::Line(major, minor)), + _ => Err(format!( + "`{text}` is not a version: use `x.y.z` for one release or `x.y` for the newest `x.y.*`" + )), + } + } + + /// Whether a version (or tag) satisfies this pin. + #[must_use] + pub fn matches(&self, found: &str) -> bool { + match (self, version::key(found)) { + (Spec::Exact(want), Some(have)) => *want == have, + (Spec::Line(major, minor), Some((a, b, _))) => (*major, *minor) == (a, b), + _ => false, + } + } + + /// The newest tag that satisfies this pin. + #[must_use] + pub fn select<'a>(&self, tags: impl IntoIterator) -> Option<&'a str> { + tags.into_iter() + .filter(|tag| self.matches(tag)) + .max_by_key(|tag| version::key(tag)) + } + + /// Whether `version` is newer than anything this pin allows. + #[must_use] + pub fn older_than(&self, version: &str) -> bool { + match (self, version::key(version)) { + (Spec::Exact(want), Some(have)) => have > *want, + (Spec::Line(major, minor), Some((a, b, _))) => (a, b) > (*major, *minor), + _ => false, + } + } +} + +/// A pin resolved against the releases, as a plan carries it. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct Pinned { + /// What the file asks for: `0.32.0` or `0.59`. + pub spec: String, + /// The release it resolves to; `None` when none could be read. + pub tag: Option, + /// The pin file. + pub file: String, +} + +/// A pin file and its `[pins]`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PinFile { + /// Where it is. + pub path: PathBuf, + /// CLI name → spec, as written. + pub pins: BTreeMap, +} + +/// The nearest `b10x.toml` from `start` upward. `$HOME` itself and everything above it are not +/// searched, so a stray file in the home directory pins nothing. +#[must_use] +pub fn find(start: &Path, home: &Path) -> Option { + let mut directory = Some(start); + while let Some(current) = directory { + if current == home { + return None; + } + let candidate = current.join(FILE); + if candidate.is_file() { + return Some(candidate); + } + directory = current.parent(); + } + None +} + +fn table(path: &Path) -> Result { + let text = + std::fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))?; + text.parse::() + .map_err(|error| format!("{}: {error}", path.display())) +} + +/// Read one pin file; every pin must parse. +pub fn load(path: &Path) -> Result { + let table = table(path)?; + let mut pins = BTreeMap::new(); + if let Some(value) = table.get("pins") { + let entries = value + .as_table() + .ok_or_else(|| format!("{}: `pins` must be a table", path.display()))?; + for (name, spec) in entries { + let spec = spec + .as_str() + .ok_or_else(|| format!("{}: pin `{name}` must be a string", path.display()))?; + Spec::parse(spec).map_err(|error| format!("{}: {name}: {error}", path.display()))?; + pins.insert(name.clone(), spec.to_owned()); + } + } + Ok(PinFile { + path: path.to_path_buf(), + pins, + }) +} + +/// The pins that apply in `start`, if a pin file is found. +pub fn read(start: &Path, home: &Path) -> Result, String> { + find(start, home).map(|path| load(&path)).transpose() +} + +/// Set one pin in `path`, creating the file when needed; other content is kept. +pub fn set(path: &Path, name: &str, spec: &str) -> Result<(), String> { + let mut table = if path.is_file() { + table(path)? + } else { + toml::Table::new() + }; + let pins = table + .entry("pins") + .or_insert_with(|| toml::Value::Table(toml::Table::new())) + .as_table_mut() + .ok_or_else(|| format!("{}: `pins` must be a table", path.display()))?; + pins.insert(name.to_owned(), toml::Value::String(spec.to_owned())); + write(path, &table) +} + +/// Remove one pin from `path`; the file goes when nothing is left in it. `false` when not pinned. +pub fn remove(path: &Path, name: &str) -> Result { + let mut table = table(path)?; + let Some(pins) = table.get_mut("pins").and_then(toml::Value::as_table_mut) else { + return Ok(false); + }; + if pins.remove(name).is_none() { + return Ok(false); + } + if pins.is_empty() { + table.remove("pins"); + } + if table.is_empty() { + std::fs::remove_file(path).map_err(|error| format!("{}: {error}", path.display()))?; + return Ok(true); + } + write(path, &table)?; + Ok(true) +} + +fn write(path: &Path, table: &toml::Table) -> Result<(), String> { + let text = toml::to_string(table).map_err(|error| error.to_string())?; + std::fs::write(path, text).map_err(|error| format!("{}: {error}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch(name: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!("b10x-pins-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + root + } + + #[test] + fn specs_parse_exact_and_minor_lines() { + assert_eq!(Spec::parse("0.32.0"), Ok(Spec::Exact((0, 32, 0)))); + assert_eq!(Spec::parse("v0.59"), Ok(Spec::Line(0, 59))); + assert!(Spec::parse("0").is_err()); + assert!(Spec::parse("latest").is_err()); + assert!(Spec::parse("0.1.2.3").is_err()); + } + + #[test] + fn exact_and_minor_line_pins_select_their_release() { + let tags = ["0.58.4", "0.59.0", "0.59.3", "v0.59.1", "0.60.0"]; + assert_eq!( + Spec::parse("0.59").unwrap().select(tags.iter().copied()), + Some("0.59.3") + ); + assert_eq!( + Spec::parse("0.59.1").unwrap().select(tags.iter().copied()), + Some("v0.59.1") + ); + assert_eq!( + Spec::parse("0.61").unwrap().select(tags.iter().copied()), + None + ); + assert!(Spec::parse("0.59").unwrap().older_than("0.60.0")); + assert!(!Spec::parse("0.59").unwrap().older_than("0.59.9")); + assert!(Spec::parse("0.32.0").unwrap().older_than("0.32.1")); + } + + #[test] + fn the_nearest_pin_file_is_found_below_home_only() { + let home = scratch("lookup"); + let repo = home.join("work/repo"); + let deep = repo.join("crates/a/src"); + std::fs::create_dir_all(&deep).unwrap(); + assert_eq!(find(&deep, &home), None); + std::fs::write(home.join(FILE), "[pins]\ness = \"0.1.0\"\n").unwrap(); + assert_eq!(find(&deep, &home), None, "a file in $HOME pins nothing"); + std::fs::write( + repo.join(FILE), + "[pins]\ness = \"0.32.0\"\naep = \"0.59\"\n", + ) + .unwrap(); + let found = read(&deep, &home).unwrap().unwrap(); + assert_eq!(found.path, repo.join(FILE)); + assert_eq!(found.pins.get("aep").map(String::as_str), Some("0.59")); + std::fs::write(repo.join(FILE), "[pins]\ness = \"soon\"\n").unwrap(); + assert!(read(&deep, &home).is_err()); + std::fs::remove_dir_all(&home).unwrap(); + } + + #[test] + fn pins_are_set_and_removed_keeping_other_content() { + let root = scratch("edit"); + let path = root.join(FILE); + set(&path, "ess", "0.32.0").unwrap(); + set(&path, "aep", "0.59").unwrap(); + assert_eq!(load(&path).unwrap().pins.len(), 2); + assert!(remove(&path, "ess").unwrap()); + assert!(!remove(&path, "ess").unwrap()); + assert!(remove(&path, "aep").unwrap()); + assert!(!path.exists(), "an empty pin file is removed"); + std::fs::write(&path, "[other]\nkeep = true\n").unwrap(); + set(&path, "ess", "0.32").unwrap(); + remove(&path, "ess").unwrap(); + assert!(std::fs::read_to_string(&path) + .unwrap() + .contains("keep = true")); + std::fs::remove_dir_all(&root).unwrap(); + } +} diff --git a/crates/b10x/src/plan.rs b/crates/b10x/src/plan.rs index 1da847a..b02e2dd 100644 --- a/crates/b10x/src/plan.rs +++ b/crates/b10x/src/plan.rs @@ -857,21 +857,54 @@ fn plan_binaries( } for binary in &product.binaries { let subject = binary.name.clone(); - let Some(tag) = context + let newest = context .resolved .latest .get(binary.install.repository()) - .cloned() - else { + .cloned(); + let pin = context.resolved.pinned.get(&binary.name); + let Some(tag) = (match pin { + Some(pin) => pin.tag.clone(), + None => newest.clone(), + }) else { findings.push(Finding { level: Level::Warn, host: None, subject, - detail: "its newest release could not be read (offline?); not checked" - .to_owned(), + detail: match pin { + Some(pin) => format!( + "pinned to {} by {}, but no such release could be read (offline, or no such release?); not checked", + pin.spec, pin.file + ), + None => "its newest release could not be read (offline?); not checked" + .to_owned(), + }, }); continue; }; + // How the finding names the release the plan holds the binary to. + let (held, pinned_by) = match pin { + Some(pin) => ( + format!("{tag}, pinned to {} by {}", pin.spec, pin.file), + Some(pin), + ), + None => (format!("the newest release {tag}"), None), + }; + if let (Some(pin), Some(newest)) = (pinned_by, &newest) { + if !version::same(newest, &tag) + && crate::pins::Spec::parse(&pin.spec).is_ok_and(|spec| spec.older_than(newest)) + { + findings.push(Finding { + level: Level::Note, + host: None, + subject: subject.clone(), + detail: format!( + "newer release {newest} exists; pinned to {} by {}, so it stays at {tag} (`b10x unpin {}` follows the newest)", + pin.spec, pin.file, binary.name + ), + }); + } + } let copies = inventory .binaries .iter() @@ -908,7 +941,28 @@ fn plan_binaries( level: Level::Ok, host: None, subject: subject.clone(), - detail: format!("{tag} at {} is the newest release", first.path), + detail: match pinned_by { + Some(pin) => format!( + "{tag} at {}, pinned to {} by {}", + first.path, pin.spec, pin.file + ), + None => format!("{tag} at {} is the newest release", first.path), + }, + }); + } else if let (true, Some(pin), Some(first)) = (context.upgrade, pinned_by, current) { + // An upgrade never moves a pinned CLI; it only says how to match the pin. + findings.push(Finding { + level: Level::Warn, + host: None, + subject: subject.clone(), + detail: format!( + "{} at {} does not match its pin {} ({}); upgrade leaves it, `b10x install {}` installs {tag}", + first.version.as_deref().unwrap_or("unknown"), + first.path, + pin.spec, + pin.file, + binary.name + ), }); } else if current.is_none() && binary.optional { findings.push(Finding { @@ -941,11 +995,11 @@ fn plan_binaries( .filter(|parent| parent.starts_with(context.home)) .unwrap_or(default_directory); let (verb, detail) = match current { - None => ("install", format!("not on PATH; install {tag}")), + None => ("install", format!("not on PATH; install {held}")), Some(first) => ( "upgrade", format!( - "{} at {} is not the newest release {tag}; replace it", + "{} at {} is not {held}; replace it", first .version .clone() @@ -1070,9 +1124,96 @@ mod tests { BTreeSet::from([X86_LINUX.to_owned()]), ), ]), + pinned: BTreeMap::new(), } } + fn pinned_ess(spec: &str, tag: &str) -> Resolved { + let mut resolved = resolved(); + resolved.pinned.insert( + "ess".to_owned(), + crate::pins::Pinned { + spec: spec.to_owned(), + tag: Some(tag.to_owned()), + file: "/work/repo/b10x.toml".to_owned(), + }, + ); + resolved + } + + fn run_pinned(inventory: &Inventory, resolved: &Resolved, upgrade: bool) -> Plan { + let catalog = Catalog::embedded(); + let context = Context { + catalog: &catalog, + resolved, + selection: Some(BTreeSet::from(["ess".to_owned()])), + hosts: vec![Host::Claude], + home: Path::new("/opt/b10x-home"), + only: true, + method: None, + target: Some(X86_LINUX.to_owned()), + upgrade, + }; + plan(&context, inventory) + } + + fn installs(plan: &Plan) -> Vec { + plan.actions + .iter() + .filter_map(|action| match action { + Action::InstallBinary { name, tag, .. } => Some(format!("{name} {tag}")), + _ => None, + }) + .collect() + } + + #[test] + fn a_pinned_cli_installs_its_pinned_release_and_the_finding_names_the_pin() { + let inventory = Inventory { + claude: Some(HostState::default()), + binaries: binaries(&[("/opt/b10x-home/.local/bin/ess", "0.30.0")]), + ..Inventory::default() + }; + let plan = run_pinned(&inventory, &pinned_ess("0.29", "0.29.4"), false); + assert_eq!(installs(&plan), ["ess 0.29.4"]); + assert!(plan.findings.iter().any(|f| f.level == Level::Change + && f.detail.contains("pinned to 0.29 by /work/repo/b10x.toml"))); + assert!(plan + .findings + .iter() + .any(|f| f.level == Level::Note && f.detail.contains("newer release 0.30.0"))); + } + + #[test] + fn a_cli_at_its_pin_is_ok_and_upgrade_reports_the_newer_release_but_changes_nothing() { + let inventory = Inventory { + claude: Some(HostState::default()), + binaries: binaries(&[("/opt/b10x-home/.local/bin/ess", "0.29.4")]), + ..Inventory::default() + }; + let plan = run_pinned(&inventory, &pinned_ess("0.29.4", "0.29.4"), true); + assert!(installs(&plan).is_empty(), "{:#?}", plan.actions); + assert!(plan.findings.iter().any(|f| f.level == Level::Ok + && f.detail == "0.29.4 at /opt/b10x-home/.local/bin/ess, pinned to 0.29.4 by /work/repo/b10x.toml")); + assert!(plan + .findings + .iter() + .any(|f| f.detail.contains("newer release 0.30.0"))); + // A copy that drifted from its pin is named, not moved, by an upgrade. + let drifted = Inventory { + claude: Some(HostState::default()), + binaries: binaries(&[("/opt/b10x-home/.local/bin/ess", "0.30.0")]), + ..Inventory::default() + }; + let plan = run_pinned(&drifted, &pinned_ess("0.29.4", "0.29.4"), true); + assert!(installs(&plan).is_empty(), "{:#?}", plan.actions); + assert!(plan + .findings + .iter() + .any(|f| f.level == Level::Warn + && f.detail.contains("`b10x install ess` installs 0.29.4"))); + } + const X86_LINUX: &str = "x86_64-unknown-linux-gnu"; const ARM_LINUX: &str = "aarch64-unknown-linux-gnu"; const ARM_MACOS: &str = "aarch64-apple-darwin"; diff --git a/crates/b10x/src/resolve.rs b/crates/b10x/src/resolve.rs index f44f705..14fac85 100644 --- a/crates/b10x/src/resolve.rs +++ b/crates/b10x/src/resolve.rs @@ -12,6 +12,8 @@ use serde::{Deserialize, Serialize}; use crate::catalog::Catalog; use crate::install::listed_targets; use crate::inventory::Inventory; +use crate::pins::{PinFile, Pinned, Spec}; +use crate::version; /// Versions setup compares against. #[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] @@ -20,10 +22,13 @@ pub struct Resolved { pub plugins: BTreeMap, /// `owner/repo` → newest release tag. pub latest: BTreeMap, - /// Binary name → the targets its newest release carries a prebuilt archive for, as its - /// `SHA256SUMS` lists them; absent or empty when the release has none. + /// Binary name → the targets the release the plan installs (the pinned one, else the newest) + /// carries a prebuilt archive for, as its `SHA256SUMS` lists them; absent or empty when none. #[serde(default)] pub archive_targets: BTreeMap>, + /// Binary name → its pin in the repository's `b10x.toml`, resolved to a release. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub pinned: BTreeMap, } /// Where marketplace files are read from. @@ -66,29 +71,72 @@ pub fn cache_path(home: &Path) -> std::path::PathBuf { home.join(".local/state/b10x/latest.json") } -/// Record the newest release tags and when they were read. -pub fn remember(home: &Path, resolved: &Resolved) { - let now = std::time::SystemTime::now() +/// Seconds since the Unix epoch. +#[must_use] +pub fn now() -> u64 { + std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |elapsed| elapsed.as_secs()); - let value = serde_json::json!({"checked_at": now, "latest": resolved.latest}); + .map_or(0, |elapsed| elapsed.as_secs()) +} + +/// The newest-release record: `checked_at` (last successful read), `attempted_at` (last try) and +/// `latest` (`owner/repo` → tag). +#[must_use] +pub fn recorded(home: &Path) -> serde_json::Map { + std::fs::read_to_string(cache_path(home)) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + .and_then(|value| value.as_object().cloned()) + .unwrap_or_default() +} + +/// Merge newly read tags into the record. `checked_at` moves only when something was read. +pub fn record(home: &Path, tags: &BTreeMap, at: u64) { + let mut value = recorded(home); + let mut latest = value + .get("latest") + .and_then(serde_json::Value::as_object) + .cloned() + .unwrap_or_default(); + for (repository, tag) in tags { + latest.insert(repository.clone(), serde_json::json!(tag)); + } + if !tags.is_empty() { + value.insert("checked_at".to_owned(), serde_json::json!(at)); + } + value.insert("attempted_at".to_owned(), serde_json::json!(at)); + value.insert("latest".to_owned(), serde_json::Value::Object(latest)); let path = cache_path(home); if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } - let _ = std::fs::write(path, value.to_string()); + let _ = std::fs::write(path, serde_json::Value::Object(value).to_string()); +} + +/// Record the newest release tags a plan read, and when. +pub fn remember(home: &Path, resolved: &Resolved) { + record(home, &resolved.latest, now()); } /// The newest release tag of `owner/repo`, from the `releases/latest` redirect (no API token). #[must_use] pub fn latest_tag(repository: &str) -> Option { + latest_tag_within(repository, 30) +} + +/// [`latest_tag`], giving up after `seconds`. +#[must_use] +pub fn latest_tag_within(repository: &str, seconds: u64) -> Option { + let seconds = seconds.to_string(); let output = Command::new("curl") .args([ "-fsS", "-o", "/dev/null", + "--connect-timeout", + &seconds, "--max-time", - "30", + &seconds, "-w", "%{redirect_url}", &format!("/{repository}/releases/latest"), @@ -100,6 +148,113 @@ pub fn latest_tag(repository: &str) -> Option { (!tag.is_empty()).then(|| tag.to_owned()) } +/// The newest release tag of each repository, read in parallel, each within `seconds`. +#[must_use] +pub fn latest_tags(repositories: &BTreeSet, seconds: u64) -> BTreeMap { + std::thread::scope(|scope| { + let handles: Vec<_> = repositories + .iter() + .map(|repository| { + scope.spawn(move || { + latest_tag_within(repository, seconds).map(|tag| (repository.clone(), tag)) + }) + }) + .collect(); + handles + .into_iter() + .filter_map(|handle| handle.join().ok().flatten()) + .collect() + }) +} + +/// Every tag of `owner/repo`, from `git ls-remote` (no API token). +#[must_use] +pub fn tags(repository: &str) -> Option> { + let output = Command::new("git") + .args([ + "ls-remote", + "--tags", + "--refs", + &format!("/{repository}.git"), + ]) + .env("GIT_TERMINAL_PROMPT", "0") + .output() + .ok()?; + output.status.success().then(|| { + String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| line.split_once("refs/tags/")) + .map(|(_, tag)| tag.trim().to_owned()) + .collect() + }) +} + +/// Whether `owner/repo` has a published GitHub Release for `tag`. +#[must_use] +pub fn release_exists(repository: &str, tag: &str) -> bool { + Command::new("curl") + .args([ + "-fsS", + "-o", + "/dev/null", + "--max-time", + "30", + "-H", + "Accept: application/vnd.github+json", + &format!("https://api.github.com/repos/{repository}/releases/tags/{tag}"), + ]) + .output() + .is_ok_and(|output| output.status.success()) +} + +/// The release a pin resolves to: the newest release when it satisfies the pin, else the newest +/// matching tag of the repository. +#[must_use] +pub fn pinned_tag(repository: &str, spec: Spec, newest: Option<&str>) -> Option { + if let Some(newest) = newest.filter(|newest| spec.matches(newest)) { + return Some(newest.to_owned()); + } + let tags = tags(repository)?; + spec.select(tags.iter().map(String::as_str)) + .map(str::to_owned) +} + +/// The version a marketplace checkout carries: the highest version among its carried plugins. +#[must_use] +pub fn carried_version(source: &Source<'_>) -> Option { + let marketplace = source + .read(".claude-plugin/marketplace.json") + .and_then(|text| serde_json::from_str::(&text).ok())?; + let carried: serde_json::Value = serde_json::json!({ + "plugins": marketplace + .get("plugins") + .and_then(serde_json::Value::as_array) + .map(|entries| { + entries + .iter() + .filter(|entry| entry.get("source").is_some_and(serde_json::Value::is_string)) + .cloned() + .collect::>() + }) + .unwrap_or_default() + }); + plugin_versions(&carried, source, &mut BTreeMap::new()) + .into_values() + .max_by_key(|found| version::key(found)) +} + +/// Whether a host's marketplace clone is older than the marketplace's newest release. The hosts +/// refresh their clone only when a plan is applied, so a plan read from a stale clone would call +/// old plugins current. +#[must_use] +pub fn stale(clone: &Path, newest: Option<&str>) -> bool { + let have = carried_version(&Source::Clone(clone)); + matches!( + (have.as_deref().and_then(version::key), newest.and_then(version::key)), + (Some(have), Some(newest)) if have < newest + ) +} + /// `owner/repo` from a GitHub URL. #[must_use] pub fn repository_of(url: &str) -> Option { @@ -194,9 +349,9 @@ fn tag(latest: &mut BTreeMap, repository: &str) -> Option) -> Resolved { +pub fn resolve(catalog: &Catalog, source: &Source<'_>, pins: Option<&PinFile>) -> Resolved { let mut latest = BTreeMap::new(); let plugins = source .read(".claude-plugin/marketplace.json") @@ -204,13 +359,34 @@ pub fn resolve(catalog: &Catalog, source: &Source<'_>) -> Resolved { .map(|marketplace| plugin_versions(&marketplace, source, &mut latest)) .unwrap_or_default(); let mut archive_targets = BTreeMap::new(); + let mut pinned = BTreeMap::new(); let mut sums_of = BTreeMap::new(); for product in &catalog.products { for binary in &product.binaries { let repository = binary.install.repository(); - if let (Some(tag), Some(_)) = (tag(&mut latest, repository), &binary.install.archive) { - let sums: &Option = - sums_of.entry(repository.to_owned()).or_insert_with(|| { + let newest = tag(&mut latest, repository); + let pin = pins.and_then(|file| { + let spec = file.pins.get(&binary.name)?; + let tag = Spec::parse(spec) + .ok() + .and_then(|parsed| pinned_tag(repository, parsed, newest.as_deref())); + Some(Pinned { + spec: spec.clone(), + tag, + file: file.path.to_string_lossy().into_owned(), + }) + }); + let installs = match &pin { + Some(pin) => pin.tag.clone(), + None => newest, + }; + if let Some(pin) = pin { + pinned.insert(binary.name.clone(), pin); + } + if let (Some(tag), Some(_)) = (installs, &binary.install.archive) { + let sums: &Option = sums_of + .entry((repository.to_owned(), tag.clone())) + .or_insert_with(|| { fetch(&format!( "/{repository}/releases/download/{tag}/SHA256SUMS" )) @@ -228,6 +404,7 @@ pub fn resolve(catalog: &Catalog, source: &Source<'_>) -> Resolved { plugins, latest, archive_targets, + pinned, } } @@ -260,6 +437,40 @@ mod tests { assert_eq!(versions.get("aep").map(String::as_str), Some("0.12.0")); } + fn marketplace_at(name: &str, version: &str) -> std::path::PathBuf { + let root = std::env::temp_dir().join(format!("b10x-clone-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + for plugin in ["b10x", "ess"] { + let manifest = root.join(format!("plugins/{plugin}/.claude-plugin")); + std::fs::create_dir_all(&manifest).unwrap(); + std::fs::write( + manifest.join("plugin.json"), + format!(r#"{{"name":"{plugin}","version":"{version}"}}"#), + ) + .unwrap(); + } + std::fs::create_dir_all(root.join(".claude-plugin")).unwrap(); + std::fs::write( + root.join(".claude-plugin/marketplace.json"), + r#"{"plugins":[{"name":"b10x","source":"./plugins/b10x"},{"name":"ess","source":"./plugins/ess"},{"name":"x","source":{"source":"url","url":"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/a/x.git"}}]}"#, + ) + .unwrap(); + root + } + + #[test] + fn a_clone_older_than_the_newest_release_is_stale() { + let clone = marketplace_at("stale", "0.14.7"); + assert_eq!( + carried_version(&Source::Clone(&clone)).as_deref(), + Some("0.14.7") + ); + assert!(stale(&clone, Some("0.14.10")), "0.14.7 < 0.14.10"); + assert!(!stale(&clone, Some("0.14.7"))); + assert!(!stale(&clone, None), "offline: the clone is all there is"); + std::fs::remove_dir_all(&clone).unwrap(); + } + #[test] fn pointed_plugins_take_the_known_newest_tag() { let marketplace = serde_json::json!({"plugins":[{"name":"ess","source":{"source":"git-subdir","url":"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/beyond10x/ess.git","path":"plugins/ess"}}]}); diff --git a/plugins/aep/.claude-plugin/plugin.json b/plugins/aep/.claude-plugin/plugin.json index 2a0a725..78e31cb 100644 --- a/plugins/aep/.claude-plugin/plugin.json +++ b/plugins/aep/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "aep", "displayName": "AEP", "description": "Plan governed work in the AEP artifact store and deliver it in reviewed waves: decomposition, plan critique, reverse engineering, story scoping, implementation and adversarial review.", - "version": "0.14.10", + "version": "0.14.11", "author": { "name": "Beyond10x" }, diff --git a/plugins/aep/.codex-plugin/plugin.json b/plugins/aep/.codex-plugin/plugin.json index d5ee62d..40267ab 100644 --- a/plugins/aep/.codex-plugin/plugin.json +++ b/plugins/aep/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "aep", - "version": "0.14.10", + "version": "0.14.11", "description": "Plan governed work in the AEP artifact store and deliver it in reviewed waves.", "author": { "name": "Beyond10x" diff --git a/plugins/b10x/.claude-plugin/plugin.json b/plugins/b10x/.claude-plugin/plugin.json index 8c84369..9c2aad7 100644 --- a/plugins/b10x/.claude-plugin/plugin.json +++ b/plugins/b10x/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "b10x", "displayName": "Beyond10x", "description": "Set up, upgrade and check the Beyond10x plugins and binaries, route work to them, and create portable plugins.", - "version": "0.14.10", + "version": "0.14.11", "author": { "name": "Beyond10x" }, diff --git a/plugins/b10x/.codex-plugin/plugin.json b/plugins/b10x/.codex-plugin/plugin.json index f9d3c03..c0a646b 100644 --- a/plugins/b10x/.codex-plugin/plugin.json +++ b/plugins/b10x/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "b10x", - "version": "0.14.10", + "version": "0.14.11", "description": "Set up, upgrade and check the Beyond10x plugins and binaries, route work to them, and create portable plugins.", "author": { "name": "Beyond10x" diff --git a/plugins/b10x/skills/upgrade/SKILL.md b/plugins/b10x/skills/upgrade/SKILL.md index 8840a6c..a08e4e6 100644 --- a/plugins/b10x/skills/upgrade/SKILL.md +++ b/plugins/b10x/skills/upgrade/SKILL.md @@ -11,7 +11,9 @@ b10x upgrade --host claude --out ~/.local/state/b10x/plan.json Use `--host codex` in Codex. It checks each installed product — plugin against the marketplace, CLI on `PATH` against the newest release — plus earlier installs under retired names, and prints each difference with the action that -fixes it. Nothing is changed yet. One product only: `b10x upgrade ess`. +fixes it. Nothing is changed yet. One product only: `b10x upgrade ess`. A CLI the repository pins +in `b10x.toml` stays at its pin: the plan names a newer release but does not install it +(`b10x unpin ` follows the newest again). - Nothing to change: say "Beyond10x is current" with the versions, and stop. - Otherwise show the actions in one list and ask once. After a clear yes: diff --git a/plugins/connectors/.claude-plugin/plugin.json b/plugins/connectors/.claude-plugin/plugin.json index 3d642bc..33119c2 100644 --- a/plugins/connectors/.claude-plugin/plugin.json +++ b/plugins/connectors/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "connectors", - "version": "0.14.10", + "version": "0.14.11", "description": "Set up, inspect, and invoke governed integrations through the connectors CLI.", "author": { "name": "Beyond10x" }, "license": "Apache-2.0", diff --git a/plugins/connectors/.codex-plugin/plugin.json b/plugins/connectors/.codex-plugin/plugin.json index 42a89d4..b08de98 100644 --- a/plugins/connectors/.codex-plugin/plugin.json +++ b/plugins/connectors/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "connectors", - "version": "0.14.10", + "version": "0.14.11", "description": "Set up, inspect, and invoke governed integrations through the connectors CLI.", "author": { "name": "Beyond10x" }, "license": "Apache-2.0", diff --git a/plugins/ess/.claude-plugin/plugin.json b/plugins/ess/.claude-plugin/plugin.json index 303346b..df480cf 100644 --- a/plugins/ess/.claude-plugin/plugin.json +++ b/plugins/ess/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "ess", "displayName": "ESS", "description": "Write, retrofit, validate and project Executable System Specifications, and hold implementations to them with conformance suites.", - "version": "0.14.10", + "version": "0.14.11", "author": { "name": "Beyond10x" }, diff --git a/plugins/ess/.codex-plugin/plugin.json b/plugins/ess/.codex-plugin/plugin.json index 12fa315..66db651 100644 --- a/plugins/ess/.codex-plugin/plugin.json +++ b/plugins/ess/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ess", - "version": "0.14.10", + "version": "0.14.11", "description": "Write, retrofit, validate and project Executable System Specifications, and hold implementations to them with conformance suites.", "author": { "name": "Beyond10x" diff --git a/plugins/worktree/.claude-plugin/plugin.json b/plugins/worktree/.claude-plugin/plugin.json index 384a0cd..9c8f3f1 100644 --- a/plugins/worktree/.claude-plugin/plugin.json +++ b/plugins/worktree/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "worktree", "displayName": "Worktree", "description": "Create, lease, finish, audit and safely clean isolated Git worktrees through the worktree CLI.", - "version": "0.14.10", + "version": "0.14.11", "author": { "name": "Beyond10x" }, diff --git a/plugins/worktree/.codex-plugin/plugin.json b/plugins/worktree/.codex-plugin/plugin.json index 30b8fc9..82df035 100644 --- a/plugins/worktree/.codex-plugin/plugin.json +++ b/plugins/worktree/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "worktree", - "version": "0.14.10", + "version": "0.14.11", "description": "Create, lease, finish, audit and safely clean isolated Git worktrees through the worktree CLI.", "author": { "name": "Beyond10x" diff --git a/verified.json b/verified.json new file mode 100644 index 0000000..80f5ca6 --- /dev/null +++ b/verified.json @@ -0,0 +1,5 @@ +{ + "aep": "0.59.3", + "ess": "0.32.1", + "worktree": "0.7.2" +} diff --git a/website/docs/install.md b/website/docs/install.md index b29d16d..6a6b4d2 100644 --- a/website/docs/install.md +++ b/website/docs/install.md @@ -145,3 +145,18 @@ After installation, invoke the skill by its displayed name or ask the agent for plugin describes. Start with `b10x:routing` if you want the front door to select a specialist. Installation does not grant filesystem, network, credential, or approval authority; the host and repository rules still decide those boundaries. + +## Pin a version + +A repository can hold a CLI at one release instead of the newest: + +```bash +b10x pin ess 0.32.0 # exactly this release +b10x pin aep 0.59 # the newest 0.59.x +b10x unpin ess +``` + +The pins go into `b10x.toml` in the current directory (or the nearest one above); commit it. +`b10x init`, `b10x upgrade`, `b10x setup plan` and `b10x install` then use the pinned release, +`upgrade` names a newer one without installing it, and `b10x check` says when the CLI on `PATH` +does not match the pin. diff --git a/website/docs/plugins/b10x.md b/website/docs/plugins/b10x.md index ad2e417..c7b58d0 100644 --- a/website/docs/plugins/b10x.md +++ b/website/docs/plugins/b10x.md @@ -31,8 +31,10 @@ It provides: | `b10x setup plan [--products …]` | the whole desired state at once: the named products installed, other catalog plugins removed | | `b10x skill [:]` | list an installed plugin's skills, or print one — usable before a restart | | `b10x setup guide` | print `/b10x:init`, for an agent that has no plugin yet | -| `b10x check` | the session-start drift check; offline, prints only problems | -| `b10x install [--tag ] [--method cargo\|prebuilt]` | install one CLI | +| `b10x check` | the session-start drift check; prints only problems, refreshes the newest releases at most daily | +| `b10x install [--tag ] [--method cargo\|prebuilt]` | install one CLI (the repository's pin, else the newest) | +| `b10x pin ` | pin a CLI for this repository in `b10x.toml` | +| `b10x unpin ` | remove that pin | The CLIs are `aep`, `ess` and `worktree`, plus the optional `metaharness` and `b10x-harness` of the `aep` product. They install from the release's checksummed prebuilt archive by default, and with @@ -46,11 +48,15 @@ checkout instead of this repository, for testing an unpublished marketplace. - **Every plugin lives here** and carries this repository's version. `catalog.json` lists products, plugins, CLIs and retired names, and no versions. -- **CLIs are always the newest release.** `agentplugins-check tools` (every pull request, `main` - push and daily) downloads the newest `aep`, `ess` and `worktree`, runs every command the skills - spell with `--help`, and validates the ESS syntax example. -- **On the machine**, `b10x check` runs at session start and says when a CLI is older than the - newest release `b10x` last saw, when an earlier install is still there, or when nothing is set up. +- **CLIs are the newest release**, unless a repository pins one ([Pin a version](../install.md#pin-a-version)). + `agentplugins-check tools` (every pull request, `main` push and daily) downloads the newest + `aep`, `ess` and `worktree`, runs every command the skills spell with `--help`, validates the ESS + syntax example, and fails when a release is newer than `verified.json`, the release the skills + were last verified against. +- **On the machine**, `b10x check` runs at session start, reads the newest releases from GitHub at + most once a day, and says when the plugins or a CLI are older than the newest release, when a CLI + does not match its repository's pin, when an earlier install is still there, or when nothing is + set up. ## Portable plugin creation