diff --git a/app/src-tauri/src/marketplace/sync.sh b/app/src-tauri/src/marketplace/sync.sh index 381419d..befe3fb 100644 --- a/app/src-tauri/src/marketplace/sync.sh +++ b/app/src-tauri/src/marketplace/sync.sh @@ -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 ":" the manifest names with string fields, well-formed or +# State ids are ":", except plugins: "plugin:/", since +# two marketplaces may ship a plugin of the same name. Reports keep +# ":" for every kind. $OLD is the state as read at the start +# (legacy "plugin:" 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:") +# 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:/"). + 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) }' \ diff --git a/app/src-tauri/src/marketplace/sync_script_tests.rs b/app/src-tauri/src/marketplace/sync_script_tests.rs index a0606af..e6489dd 100644 --- a/app/src-tauri/src/marketplace/sync_script_tests.rs +++ b/app/src-tauri/src/marketplace/sync_script_tests.rs @@ -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:". + 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!({})); +} diff --git a/docs/superpowers/specs/2026-09-27-marketplace-design.md b/docs/superpowers/specs/2026-09-27-marketplace-design.md index 40387ec..ab0abd3 100644 --- a/docs/superpowers/specs/2026-09-27-marketplace-design.md +++ b/docs/superpowers/specs/2026-09-27-marketplace-design.md @@ -252,7 +252,9 @@ installed last time, including the exact hook entries it inserted). - Plugins: marketplace name `triple-c-`; copy the generated tree to `~/.claude/triple-c/plugins//`; `claude plugin marketplace add` it the first time, else `claude plugin marketplace update triple-c-`; `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:/`), so two marketplaces may ship a plugin of the same + name; older `plugin:` 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