mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-10 17:10:18 -10:00
fix(plugins): a marketplace catalog is its source, not the name an add happened to use
`/plugin marketplace add <name> <path>` refused an id that already existed, so re-adding the codewhale marketplace to refresh it was impossible once the name was taken — and the workaround is visible in this machine's own state: two snapshots of `codewhale-plugin-marketplace/marketplace.json`, the second one hand-named `cw2`, both stale, sitting side by side. This is the identity half of the grokbuild model (`xai-grok-plugin-marketplace`'s `MarketplaceSource::identity`: the URL or expanded path is the stable identity; the display name is not): - `add` now keys on the canonical source document: re-adding the same source updates that catalog in place and renames it to the requested id when the name differs. A *different* source under a taken name still refuses, so a name never silently re-points. - `load` collapses catalogs that share one source, keeping the entry named after the document itself (else the most recently added). Older states self-heal on the next read; the projection persists on the next write. What this does not do: it does not refresh a catalog when its local document changed on disk — that is the next slice (re-parse on add is a refresh today). And it does not yet split catalog *entry kinds* (plugin vs skill), which is what makes the skill-name suppression deletable. Evidence: marketplace filter "44 passed; 0 failed", including the un-staled command roundtrip (same-source re-add succeeds; different-source clash is refused) and new store tests for rename-in-place, clash refusal, and the two-name collapse; clippy (CI's invocation, tui lib) clean.
This commit is contained in:
1 parent
341151fff1
commit
751ea338f2
2 files changed
+212
-6
No files matched your search
@@ -198,13 +198,39 @@ fn marketplace_add_list_show_remove_roundtrip() {
|
||||
"list/show must not rewrite marketplace state"
|
||||
);
|
||||
|
||||
// duplicate name is refused
|
||||
let dup = plugins_with_kimi_home_override(
|
||||
// Re-adding the same source is a refresh, not a duplicate: a catalog is
|
||||
// keyed by its document, so the second add updates it in place. (The old
|
||||
// refusal here is what produced a second snapshot of one marketplace
|
||||
// under a hand-made name.)
|
||||
let refreshed = plugins_with_kimi_home_override(
|
||||
&mut app,
|
||||
Some(&format!("marketplace add kimi {}", catalog_path.display())),
|
||||
None,
|
||||
);
|
||||
assert!(dup.is_error);
|
||||
assert!(!refreshed.is_error, "{:?}", refreshed.message);
|
||||
// A *different* source under the same name is still refused.
|
||||
let other_catalog = catalogs.join("other-marketplace.json");
|
||||
fs::write(
|
||||
&other_catalog,
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"version": "2",
|
||||
"plugins": [
|
||||
{
|
||||
"id": "other-bundle",
|
||||
"source": "./other-bundle",
|
||||
"displayName": "Other Bundle"
|
||||
}
|
||||
]
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let clash = plugins_with_kimi_home_override(
|
||||
&mut app,
|
||||
Some(&format!("marketplace add kimi {}", other_catalog.display())),
|
||||
None,
|
||||
);
|
||||
assert!(clash.is_error, "{:?}", clash.message);
|
||||
|
||||
let removed = plugins_with_kimi_home_override(&mut app, Some("marketplace remove kimi"), None);
|
||||
assert!(!removed.is_error, "{:?}", removed.message);
|
||||
|
||||
@@ -130,23 +130,46 @@ impl MarketplaceStore {
|
||||
self.path.display()
|
||||
));
|
||||
}
|
||||
Ok(state)
|
||||
Ok(collapse_same_source_catalogs(state))
|
||||
}
|
||||
|
||||
/// Insert a catalog under `id`, refusing to replace an existing entry.
|
||||
/// Insert a catalog under `id`, keyed by its source document.
|
||||
///
|
||||
/// A catalog *is* its source: re-adding the same document updates that
|
||||
/// catalog in place — and renames it to the requested `id` when the name
|
||||
/// differs — instead of colliding on whatever name the previous add chose.
|
||||
/// That collision is what produced a second, stale snapshot of the
|
||||
/// codewhale marketplace under a hand-made name ("cw2") sitting beside
|
||||
/// `codewhale`, two copies of one source. An existing *different* source
|
||||
/// under `id` still refuses, so a name never silently re-points.
|
||||
pub fn add(
|
||||
&self,
|
||||
id: &MarketplaceCatalogId,
|
||||
entry: StoredMarketplaceCatalog,
|
||||
) -> Result<(), String> {
|
||||
self.mutate(|state| {
|
||||
if state.catalogs.contains_key(id.as_str()) {
|
||||
let source = canonical_source_path(&entry.source_path);
|
||||
if let Some(existing) = state.catalogs.get(id.as_str())
|
||||
&& canonical_source_path(&existing.source_path) != source
|
||||
{
|
||||
return Err(format!(
|
||||
"a marketplace named `{}` already exists; /plugin marketplace remove {} first",
|
||||
id.as_str(),
|
||||
id.as_str()
|
||||
));
|
||||
}
|
||||
let duplicates: Vec<String> = state
|
||||
.catalogs
|
||||
.iter()
|
||||
.filter(|(key, catalog)| {
|
||||
key.as_str() != id.as_str()
|
||||
&& canonical_source_path(&catalog.source_path) == source
|
||||
})
|
||||
.map(|(key, _)| key.clone())
|
||||
.collect();
|
||||
for key in duplicates {
|
||||
state.catalogs.remove(&key);
|
||||
}
|
||||
state.catalogs.insert(id.as_str().to_string(), entry);
|
||||
Ok(())
|
||||
})
|
||||
@@ -184,6 +207,66 @@ impl MarketplaceStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical form of a catalog's source document, for source-identity
|
||||
/// comparison. This is the identity the store keys updates and duplicate
|
||||
/// detection on (the marketplace sibling of grokbuild's
|
||||
/// `MarketplaceSource::identity`). Falls back to the stored string when the
|
||||
/// path is not readable as a file — a GitHub URL, or a document that has
|
||||
/// since moved away.
|
||||
fn canonical_source_path(path: &str) -> PathBuf {
|
||||
std::fs::canonicalize(path).unwrap_or_else(|_| PathBuf::from(path))
|
||||
}
|
||||
|
||||
/// Collapse catalogs that point at the same source document.
|
||||
///
|
||||
/// Older states can hold one marketplace twice under two names (that is what
|
||||
/// `"cw2"` was: a second snapshot of the same document, taken only because the
|
||||
/// first name was already in use). Every surface should see one catalog per
|
||||
/// source. The survivor is the entry whose key is the document's own declared
|
||||
/// name when one exists, otherwise the most recently added; the projection is
|
||||
/// in-memory, and the next write persists it.
|
||||
fn collapse_same_source_catalogs(mut state: MarketplaceState) -> MarketplaceState {
|
||||
let mut groups: BTreeMap<PathBuf, Vec<String>> = BTreeMap::new();
|
||||
for (key, catalog) in &state.catalogs {
|
||||
groups
|
||||
.entry(canonical_source_path(&catalog.source_path))
|
||||
.or_default()
|
||||
.push(key.clone());
|
||||
}
|
||||
for keys in groups.values() {
|
||||
if keys.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let named_after_document = keys.iter().find(|key| {
|
||||
state
|
||||
.catalogs
|
||||
.get(key.as_str())
|
||||
.is_some_and(|catalog| catalog.catalog.name == key.as_str())
|
||||
});
|
||||
let keeper = named_after_document.cloned().or_else(|| {
|
||||
keys.iter()
|
||||
.max_by(|left, right| {
|
||||
let added = |key: &String| {
|
||||
state
|
||||
.catalogs
|
||||
.get(key.as_str())
|
||||
.map(|catalog| catalog.added_at.as_str())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
added(left).cmp(added(right))
|
||||
})
|
||||
.cloned()
|
||||
});
|
||||
let Some(keeper) = keeper else { continue };
|
||||
for key in keys {
|
||||
if *key != keeper {
|
||||
state.catalogs.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
fn first_party_catalog() -> Result<StoredMarketplaceCatalog, String> {
|
||||
#[derive(Deserialize)]
|
||||
struct Snapshot {
|
||||
@@ -275,4 +358,101 @@ mod tests {
|
||||
assert!(store.load().is_err());
|
||||
assert_eq!(fs::read_to_string(store.path()).unwrap(), "broken");
|
||||
}
|
||||
|
||||
fn catalog_from_source(source: &Path) -> StoredMarketplaceCatalog {
|
||||
let mut catalog = first_party_catalog().unwrap();
|
||||
catalog.source_path = source.display().to_string();
|
||||
catalog
|
||||
}
|
||||
|
||||
/// `add` keys on the source document, not the name the previous add used:
|
||||
/// re-adding the same marketplace under its real name replaces the
|
||||
/// hand-made duplicate instead of colliding or leaving both behind.
|
||||
#[test]
|
||||
fn re_adding_the_same_source_renames_in_place() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let store = MarketplaceStore::open(Some(&root.path().join("plugins/state.json"))).unwrap();
|
||||
let document = root.path().join("marketplace.json");
|
||||
fs::write(&document, "{}").unwrap();
|
||||
|
||||
store
|
||||
.add(
|
||||
&MarketplaceCatalogId::new("cw2"),
|
||||
catalog_from_source(&document),
|
||||
)
|
||||
.unwrap();
|
||||
store
|
||||
.add(
|
||||
&MarketplaceCatalogId::new("codewhale"),
|
||||
catalog_from_source(&document),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let state = store.load().unwrap();
|
||||
assert!(state.get("cw2").is_none(), "the duplicate name is gone");
|
||||
assert_eq!(
|
||||
state.get("codewhale").unwrap().source_path,
|
||||
document.display().to_string()
|
||||
);
|
||||
}
|
||||
|
||||
/// A name is never silently re-pointed at a different source.
|
||||
#[test]
|
||||
fn a_name_never_re_points_at_a_different_source() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let store = MarketplaceStore::open(Some(&root.path().join("plugins/state.json"))).unwrap();
|
||||
let first = root.path().join("first.json");
|
||||
let second = root.path().join("second.json");
|
||||
fs::write(&first, "{}").unwrap();
|
||||
fs::write(&second, "{}").unwrap();
|
||||
|
||||
store
|
||||
.add(
|
||||
&MarketplaceCatalogId::new("codewhale"),
|
||||
catalog_from_source(&first),
|
||||
)
|
||||
.unwrap();
|
||||
let refused = store
|
||||
.add(
|
||||
&MarketplaceCatalogId::new("codewhale"),
|
||||
catalog_from_source(&second),
|
||||
)
|
||||
.expect_err("a different source under a taken name must refuse");
|
||||
assert!(refused.contains("already exists"), "{refused}");
|
||||
assert_eq!(
|
||||
store.load().unwrap().get("codewhale").unwrap().source_path,
|
||||
first.display().to_string()
|
||||
);
|
||||
}
|
||||
|
||||
/// A state written before source identity was keyed can hold one source
|
||||
/// twice under two names; the projection collapses it to the entry named
|
||||
/// after the document itself.
|
||||
#[test]
|
||||
fn load_collapses_one_source_stored_under_two_names() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let store = MarketplaceStore::open(Some(&root.path().join("plugins/state.json"))).unwrap();
|
||||
let document = root.path().join("marketplace.json");
|
||||
fs::write(&document, "{}").unwrap();
|
||||
|
||||
let mut state = MarketplaceState::default();
|
||||
for (key, added_at) in [
|
||||
("codewhale", "2026-09-02T00:00:00Z"),
|
||||
("cw2", "2026-09-16T00:00:00Z"),
|
||||
] {
|
||||
let mut catalog = catalog_from_source(&document);
|
||||
catalog.added_at = added_at.to_string();
|
||||
state.catalogs.insert(key.to_string(), catalog);
|
||||
}
|
||||
ensure_private_plugin_state_directory(store.path().parent().unwrap()).unwrap();
|
||||
fs::write(store.path(), serde_json::to_string(&state).unwrap()).unwrap();
|
||||
|
||||
let loaded = store.load().unwrap();
|
||||
assert!(loaded.get("cw2").is_none());
|
||||
assert!(loaded.get("codewhale").is_some());
|
||||
assert_eq!(
|
||||
loaded.get("codewhale").unwrap().source_path,
|
||||
document.display().to_string()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user