Marketplace sync: track plugin state per marketplace (final review I1)
Two marketplaces shipping a plugin of the same name shared one "plugin:<key>" state record, so every sync reinstalled one copy and reported it updated, and removing one marketplace never uninstalled its copy. Plugin state ids are now "plugin:<slug>/<key>"; the slug and key for an uninstall are derived from the id and re-validated. Older "plugin:<key>" records are migrated using their recorded slug, so existing installs are neither reinstalled nor orphaned. Reports keep "plugin:<key>". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -92,12 +92,17 @@ claude_cmd() {
|
||||
fi
|
||||
}
|
||||
|
||||
owned() { jq -e --arg id "$1" '.items | has($id)' "$STATE" >/dev/null 2>&1; }
|
||||
prev_commit() { jq -r --arg id "$1" '.items[$id].commit // ""' "$STATE"; }
|
||||
# Every "<kind>:<key>" the manifest names with string fields, well-formed or
|
||||
# State ids are "<kind>:<key>", except plugins: "plugin:<slug>/<key>", since
|
||||
# two marketplaces may ship a plugin of the same name. Reports keep
|
||||
# "<kind>:<key>" for every kind. $OLD is the state as read at the start
|
||||
# (legacy "plugin:<key>" records migrated); $STATE is written once, at the end.
|
||||
OLD="$R/state.json"
|
||||
owned() { jq -e --arg id "$1" '.items | has($id)' "$OLD" >/dev/null 2>&1; }
|
||||
prev_commit() { jq -r --arg id "$1" '.items[$id].commit // ""' "$OLD"; }
|
||||
# Every state id the manifest names with string fields, well-formed or
|
||||
# not: a selected item that failed this run must not be removed.
|
||||
in_manifest() { grep -qxF "$1" "$R/manifest_ids"; }
|
||||
carry_forward() { record "$1" "$(jq -c --arg id "$1" '.items[$id]' "$STATE")"; }
|
||||
carry_forward() { record "$1" "$(jq -c --arg id "$1" '.items[$id]' "$OLD")"; }
|
||||
# $1 = installed|updated|none for this id at this commit.
|
||||
outcome_of() {
|
||||
p=$(prev_commit "$1")
|
||||
@@ -182,7 +187,9 @@ if ! {
|
||||
end
|
||||
| map("_" + .) | @tsv' "$MANIFEST" >"$R/items.tsv" &&
|
||||
jq -r '.items[] | objects | select((.kind | type) == "string" and (.key | type) == "string")
|
||||
| [.kind + ":" + .key] | @tsv' "$MANIFEST" >"$R/manifest_ids" &&
|
||||
| [if .kind == "plugin" and (.slug | type) == "string"
|
||||
then "plugin:" + .slug + "/" + .key else .kind + ":" + .key end] | @tsv' \
|
||||
"$MANIFEST" >"$R/manifest_ids" &&
|
||||
jq -r '.plugin_marketplaces[]
|
||||
| if type == "object" and (.slug | type) == "string" then .slug else "" end
|
||||
| [.] | @tsv' "$MANIFEST" >"$R/new_slugs"
|
||||
@@ -192,6 +199,16 @@ fi
|
||||
if ! jq -e '(.items | type) == "object"' "$STATE" >/dev/null 2>&1; then
|
||||
printf '%s\n' '{"version":1,"items":{},"plugin_marketplaces":[]}' >"$STATE"
|
||||
fi
|
||||
# Records from before plugins were tracked per marketplace ("plugin:<key>")
|
||||
# carry their slug: rename them so they are neither reinstalled nor orphaned.
|
||||
# One without a string slug keeps its id and is dropped as unrecognised below.
|
||||
if ! jq '.items |= with_entries(
|
||||
if (.key | startswith("plugin:")) and (.key | contains("/") | not)
|
||||
and (.value | type) == "object" and (.value.slug | type) == "string"
|
||||
then .key = "plugin:" + .value.slug + "/" + (.key | ltrimstr("plugin:"))
|
||||
else . end)' "$STATE" >"$OLD" 2>/dev/null; then
|
||||
malformed "the marketplace state could not be read, so nothing was changed"
|
||||
fi
|
||||
|
||||
# ── Agents, skills, commands, hooks ──────────────────────────────────────────
|
||||
while IFS="$TAB" read -r status kind key commit slug; do
|
||||
@@ -265,7 +282,7 @@ done <"$R/items.tsv"
|
||||
|
||||
# ── Removals (non-plugin) ────────────────────────────────────────────────────
|
||||
cut -f1 "$R/newstate" >"$R/new_ids"
|
||||
jq -r '.items | keys[]' "$STATE" >"$R/old_ids"
|
||||
jq -r '.items | keys[]' "$OLD" >"$R/old_ids"
|
||||
while read -r id; do
|
||||
case "$id" in plugin:*) continue ;; esac
|
||||
if grep -qxF "$id" "$R/new_ids"; then continue; fi
|
||||
@@ -296,7 +313,7 @@ done <"$R/old_ids"
|
||||
# shellcheck disable=SC2016 # jq program, not shell
|
||||
MERGE_ENTRIES='[.[] | .entries? // empty]
|
||||
| reduce .[] as $e ({}; reduce ($e | to_entries[]) as $x (.; .[$x.key] += $x.value))'
|
||||
OLD_HOOKS=$(jq -c "[.items[]] | $MERGE_ENTRIES" "$STATE")
|
||||
OLD_HOOKS=$(jq -c "[.items[]] | $MERGE_ENTRIES" "$OLD")
|
||||
NEW_HOOKS=$(cut -f2- "$R/newstate" | jq -cs "$MERGE_ENTRIES")
|
||||
HOOKS_FAILED=0
|
||||
if [ "$OLD_HOOKS" != "{}" ] || [ "$NEW_HOOKS" != "{}" ]; then
|
||||
@@ -358,7 +375,7 @@ if [ "$HOOKS_FAILED" = 0 ]; then
|
||||
fi
|
||||
|
||||
# ── Plugins ──────────────────────────────────────────────────────────────────
|
||||
jq -r '.plugin_marketplaces[]?' "$STATE" >"$R/old_slugs"
|
||||
jq -r '.plugin_marketplaces[]?' "$OLD" >"$R/old_slugs"
|
||||
while read -r slug; do
|
||||
if ! valid_slug "$slug"; then fail "invalid plugin marketplace name"; continue; fi
|
||||
mname="triple-c-$slug"
|
||||
@@ -377,16 +394,17 @@ while read -r slug; do
|
||||
key=${key#_} commit=${commit#_} pslug=${pslug#_}
|
||||
[ "$pslug" = "$slug" ] || continue
|
||||
id="plugin:$key"
|
||||
p=$(prev_commit "$id")
|
||||
sid="plugin:$slug/$key"
|
||||
p=$(prev_commit "$sid")
|
||||
if [ -z "$p" ]; then
|
||||
claude_cmd plugin install "$key@$mname" || { fail "$id: install failed"; continue; }
|
||||
claude_cmd plugin install "$key@$mname" || { fail "$id ($mname): install failed"; continue; }
|
||||
report installed "$id"
|
||||
elif [ "$p" != "$commit" ]; then
|
||||
claude_cmd plugin uninstall "$key@$mname"
|
||||
claude_cmd plugin install "$key@$mname" || { fail "$id: reinstall failed"; continue; }
|
||||
claude_cmd plugin install "$key@$mname" || { fail "$id ($mname): reinstall failed"; continue; }
|
||||
report updated "$id"
|
||||
fi
|
||||
record "$id" "$(jq -cn --arg c "$commit" --arg s "$slug" '{commit: $c, slug: $s}')"
|
||||
record "$sid" "$(jq -cn --arg c "$commit" --arg s "$slug" '{commit: $c, slug: $s}')"
|
||||
done <"$R/plugin_items"
|
||||
done <"$R/new_slugs"
|
||||
|
||||
@@ -395,16 +413,20 @@ while read -r id; do
|
||||
case "$id" in plugin:*) ;; *) continue ;; esac
|
||||
if grep -qxF "$id" "$R/new_ids" || cut -f1 "$R/newstate" | grep -qxF "$id"; then continue; fi
|
||||
if in_manifest "$id"; then carry_forward "$id"; continue; fi
|
||||
key=${id#plugin:}
|
||||
slug=$(jq -r --arg id "$id" '.items[$id].slug // ""' "$STATE")
|
||||
# Name and marketplace come from the id alone ("plugin:<slug>/<key>").
|
||||
rest=${id#plugin:}
|
||||
case "$rest" in
|
||||
*/*) slug=${rest%%/*} key=${rest#*/} ;;
|
||||
*) slug="" key="" ;;
|
||||
esac
|
||||
if ! valid_key "$key" || ! valid_slug "$slug"; then
|
||||
fail "$id: dropped an unrecognised record from the marketplace state"
|
||||
continue
|
||||
fi
|
||||
if claude_cmd plugin uninstall "$key@triple-c-$slug"; then
|
||||
report removed "$id"
|
||||
report removed "plugin:$key"
|
||||
else
|
||||
fail "$id: uninstall failed"
|
||||
fail "plugin:$key (triple-c-$slug): uninstall failed"
|
||||
carry_forward "$id"
|
||||
fi
|
||||
done <"$R/old_ids"
|
||||
@@ -426,7 +448,7 @@ if [ "$HOOKS_FAILED" = 1 ]; then
|
||||
jq -s '.[0] as $new | .[1].items as $old
|
||||
| ($new | with_entries(select(.key | startswith("hook:") | not)))
|
||||
+ ($old | with_entries(select(.key | startswith("hook:"))))' \
|
||||
"$R/items.json" "$STATE" >"$R/items2.json" && mv -f "$R/items2.json" "$R/items.json"
|
||||
"$R/items.json" "$OLD" >"$R/items2.json" && mv -f "$R/items2.json" "$R/items.json"
|
||||
fi
|
||||
if jq -n --slurpfile it "$R/items.json" --rawfile sl "$R/final_slugs" \
|
||||
'{ version: 1, items: $it[0], plugin_marketplaces: ($sl | split("\n") | map(select(length > 0)) | unique) }' \
|
||||
|
||||
@@ -884,3 +884,143 @@ fn sync_drops_state_records_without_a_kind_key_id() {
|
||||
let state = read_json(&state_path);
|
||||
assert_eq!(state["items"], json!({}), "bogus records dropped");
|
||||
}
|
||||
|
||||
// ── Plugin state per marketplace (final review I1) ───────────────────────────
|
||||
|
||||
const SLUG_A: &str = "mp-aaaaaaaa";
|
||||
const SLUG_B: &str = "mp-bbbbbbbb";
|
||||
|
||||
/// A payload in which each marketplace ships plugin `p` at its own commit.
|
||||
fn shared_plugin_payload(env: &Env, slugs: &[(&str, &str)]) {
|
||||
let mut files: Vec<(String, String)> = Vec::new();
|
||||
let mut items = Vec::new();
|
||||
let mut groups = Vec::new();
|
||||
for (slug, commit) in slugs {
|
||||
files.push((
|
||||
format!("plugins/{slug}/.claude-plugin/marketplace.json"),
|
||||
format!(
|
||||
r#"{{"name":"triple-c-{slug}","owner":{{"name":"Triple-C"}},"plugins":[{{"name":"p","source":"./p"}}]}}"#
|
||||
),
|
||||
));
|
||||
files.push((
|
||||
format!("plugins/{slug}/p/.claude-plugin/plugin.json"),
|
||||
r#"{"name":"p"}"#.to_string(),
|
||||
));
|
||||
items.push(json!({ "kind": "plugin", "key": "p", "marketplace": slug, "commit": commit, "slug": slug }));
|
||||
groups.push(json!({ "slug": slug, "dir": format!("plugins/{slug}"), "plugins": ["p"] }));
|
||||
}
|
||||
let refs: Vec<(&str, &str, bool)> = files
|
||||
.iter()
|
||||
.map(|(p, t)| (p.as_str(), t.as_str(), false))
|
||||
.collect();
|
||||
payload(
|
||||
env,
|
||||
&refs,
|
||||
json!({ "version": 1, "items": items, "plugin_marketplaces": groups }),
|
||||
);
|
||||
}
|
||||
|
||||
fn nothing_reported(r: &SyncReport) -> bool {
|
||||
r.installed.is_empty() && r.updated.is_empty() && r.removed.is_empty() && r.errors.is_empty()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_marketplaces_sharing_a_plugin_name_reach_a_steady_state() {
|
||||
let Some(env) = env() else { return };
|
||||
|
||||
shared_plugin_payload(&env, &[(SLUG_A, C1), (SLUG_B, C2)]);
|
||||
let r = run(&env);
|
||||
assert_eq!(r.installed, vec!["plugin:p", "plugin:p"], "{r:?}");
|
||||
assert!(r.errors.is_empty(), "{r:?}");
|
||||
|
||||
// Nothing changed: no reinstall, nothing reported.
|
||||
for _ in 0..2 {
|
||||
fs::remove_file(&env.log).unwrap();
|
||||
shared_plugin_payload(&env, &[(SLUG_A, C1), (SLUG_B, C2)]);
|
||||
let r = run(&env);
|
||||
assert!(nothing_reported(&r), "{r:?}");
|
||||
assert_eq!(
|
||||
claude_log(&env),
|
||||
vec![
|
||||
format!("plugin marketplace update triple-c-{SLUG_A}"),
|
||||
format!("plugin marketplace update triple-c-{SLUG_B}"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// One marketplace's copy is removed: only that copy is uninstalled.
|
||||
fs::remove_file(&env.log).unwrap();
|
||||
shared_plugin_payload(&env, &[(SLUG_A, C1)]);
|
||||
let r = run(&env);
|
||||
assert_eq!(r.removed, vec!["plugin:p"], "{r:?}");
|
||||
assert!(r.installed.is_empty() && r.updated.is_empty(), "{r:?}");
|
||||
assert_eq!(
|
||||
claude_log(&env),
|
||||
vec![
|
||||
format!("plugin marketplace update triple-c-{SLUG_A}"),
|
||||
format!("plugin uninstall p@triple-c-{SLUG_B}"),
|
||||
format!("plugin marketplace remove triple-c-{SLUG_B}"),
|
||||
]
|
||||
);
|
||||
|
||||
// And the survivor stays put.
|
||||
fs::remove_file(&env.log).unwrap();
|
||||
shared_plugin_payload(&env, &[(SLUG_A, C1)]);
|
||||
let r = run(&env);
|
||||
assert!(nothing_reported(&r), "{r:?}");
|
||||
assert_eq!(
|
||||
claude_log(&env),
|
||||
vec![format!("plugin marketplace update triple-c-{SLUG_A}")]
|
||||
);
|
||||
}
|
||||
|
||||
fn write_state(env: &Env, state: Value) -> PathBuf {
|
||||
let p = env.home.join(".claude/triple-c/marketplace/state.json");
|
||||
fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
fs::write(&p, state.to_string()).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_plugin_records_are_migrated_not_reinstalled() {
|
||||
let Some(env) = env() else { return };
|
||||
// State as written by an earlier sync.sh: plugins keyed "plugin:<key>".
|
||||
let state_path = write_state(
|
||||
&env,
|
||||
json!({ "version": 1, "plugin_marketplaces": [SLUG_A],
|
||||
"items": { "plugin:p": { "commit": C1, "slug": SLUG_A } } }),
|
||||
);
|
||||
|
||||
shared_plugin_payload(&env, &[(SLUG_A, C1)]);
|
||||
let r = run(&env);
|
||||
assert!(nothing_reported(&r), "{r:?}");
|
||||
assert_eq!(
|
||||
claude_log(&env),
|
||||
vec![format!("plugin marketplace update triple-c-{SLUG_A}")]
|
||||
);
|
||||
let items = read_json(&state_path)["items"].clone();
|
||||
assert!(items.get("plugin:p").is_none(), "{items}");
|
||||
assert_eq!(items[format!("plugin:{SLUG_A}/p")]["commit"], C1, "{items}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deselected_legacy_plugin_record_is_still_uninstalled() {
|
||||
let Some(env) = env() else { return };
|
||||
let state_path = write_state(
|
||||
&env,
|
||||
json!({ "version": 1, "plugin_marketplaces": [SLUG_A],
|
||||
"items": { "plugin:p": { "commit": C1, "slug": SLUG_A } } }),
|
||||
);
|
||||
|
||||
payload(&env, &[], empty_manifest());
|
||||
let r = run(&env);
|
||||
assert_eq!(r.removed, vec!["plugin:p"], "{r:?}");
|
||||
assert_eq!(
|
||||
claude_log(&env),
|
||||
vec![
|
||||
format!("plugin uninstall p@triple-c-{SLUG_A}"),
|
||||
format!("plugin marketplace remove triple-c-{SLUG_A}"),
|
||||
]
|
||||
);
|
||||
assert_eq!(read_json(&state_path)["items"], json!({}));
|
||||
}
|
||||
|
||||
@@ -252,7 +252,9 @@ installed last time, including the exact hook entries it inserted).
|
||||
- Plugins: marketplace name `triple-c-<slug>`; copy the generated tree to
|
||||
`~/.claude/triple-c/plugins/<slug>/`; `claude plugin marketplace add` it the first time, else
|
||||
`claude plugin marketplace update triple-c-<slug>`; `install` newly selected, `uninstall`
|
||||
removed; drop the marketplace registration when it has no plugins left.
|
||||
removed; drop the marketplace registration when it has no plugins left. Plugin state is kept
|
||||
per marketplace (`plugin:<slug>/<key>`), so two marketplaces may ship a plugin of the same
|
||||
name; older `plugin:<key>` records are migrated using their recorded slug.
|
||||
- Emits a report: `{installed, updated, removed, skipped: [{item, reason}], errors: [...]}`.
|
||||
|
||||
**Failure handling.** A sync failure never fails the container start; it is logged, stored as the
|
||||
|
||||
Reference in New Issue
Block a user