diff --git a/app/src-tauri/src/marketplace/mod.rs b/app/src-tauri/src/marketplace/mod.rs index 84695ed..3087750 100644 --- a/app/src-tauri/src/marketplace/mod.rs +++ b/app/src-tauri/src/marketplace/mod.rs @@ -3,4 +3,8 @@ pub mod auth; pub mod catalog; pub mod git; +pub mod sync; pub mod tree; + +#[cfg(test)] +mod sync_script_tests; diff --git a/app/src-tauri/src/marketplace/sync.rs b/app/src-tauri/src/marketplace/sync.rs new file mode 100644 index 0000000..699faac --- /dev/null +++ b/app/src-tauri/src/marketplace/sync.rs @@ -0,0 +1,9 @@ +//! Pushes a project's marketplace payload into its container and runs the +//! sync script there (spec §4). + +/// Where the payload and the script are uploaded. Owned by `claude`. +pub const INCOMING_DIR: &str = "/home/claude/.claude/triple-c/marketplace/incoming"; + +/// The sync script. Shipped with the app and uploaded on every sync, so a new +/// app version reaches existing containers without an image migration. +pub const SYNC_SCRIPT: &str = include_str!("sync.sh"); diff --git a/app/src-tauri/src/marketplace/sync.sh b/app/src-tauri/src/marketplace/sync.sh new file mode 100644 index 0000000..06f37e4 --- /dev/null +++ b/app/src-tauri/src/marketplace/sync.sh @@ -0,0 +1,344 @@ +#!/bin/sh +# Messages name paths as the user sees them ("~/.claude/..."), deliberately. +# shellcheck disable=SC2088 +# Triple-C marketplace sync: applies the payload the app uploaded. +# +# A constant script, shipped inside the app and uploaded next to the payload on +# every sync. Nothing is ever interpolated into it: its only inputs are the +# files under $MARKETPLACE_INCOMING (written by the host) and $HOME. Item keys +# and slugs are re-validated here although the host validated them, and every +# destination path is derived from them rather than taken from the manifest. +# +# Progress and tool output go to stderr. stdout carries exactly one line: the +# JSON report. Exit status is 0 unless HOME is unset; per-item failures are +# reported, never fatal. +set -u + +if [ -z "${HOME:-}" ]; then + echo "triple-c-marketplace-sync: HOME is not set" >&2 + exit 2 +fi +PATH="$HOME/.claude/bin:$HOME/.local/bin:$PATH" +export PATH + +CLAUDE_DIR="$HOME/.claude" +BASE="$CLAUDE_DIR/triple-c" +INCOMING="${MARKETPLACE_INCOMING:-$BASE/marketplace/incoming}" +LOCK="${MARKETPLACE_LOCK:-/tmp/.triple-c-claude-update.lock}" +STATE="$BASE/marketplace/state.json" +WORK="$BASE/marketplace/work" +SETTINGS="$CLAUDE_DIR/settings.json" +TAB=$(printf '\t') + +if ! command -v jq >/dev/null 2>&1; then + printf '%s\n' '{"errors":["jq is not installed in this container, so marketplace items were not applied"]}' + exit 0 +fi + +R=$(mktemp -d) || exit 2 +trap 'rm -rf "$R"' EXIT +for f in installed updated removed skipped errors newstate new_slugs final_slugs; do + : >"$R/$f" +done + +report() { printf '%s\n' "$2" >>"$R/$1"; } +skip() { printf '%s\t%s\n' "$1" "$2" >>"$R/skipped"; } +fail() { printf '%s\n' "$1" >>"$R/errors"; } +record() { printf '%s\t%s\n' "$1" "$2" >>"$R/newstate"; } + +emit_report() { + jq -cn \ + --rawfile i "$R/installed" --rawfile u "$R/updated" --rawfile d "$R/removed" \ + --rawfile s "$R/skipped" --rawfile e "$R/errors" ' + def lines: split("\n") | map(select(length > 0)); + { installed: ($i | lines), updated: ($u | lines), removed: ($d | lines), + skipped: ($s | lines | map(split("\t") | { item: .[0], reason: (.[1:] | join("\t")) })), + errors: ($e | lines) }' +} + +valid_key() { + case "$1" in + '' | [!A-Za-z0-9]* | *[!A-Za-z0-9._-]*) return 1 ;; + esac + [ "${#1}" -le 64 ] +} + +valid_slug() { + case "$1" in + '' | -* | *[!a-z0-9-]*) return 1 ;; + esac + [ "${#1}" -le 64 ] +} + +valid_commit() { + case "$1" in + '' | *[!0-9a-f]*) return 1 ;; + esac + [ "${#1}" -eq 40 ] +} + +# Run `claude` serialised with the entrypoint's and every session's +# `claude update`, which rewrite ~/.claude/bin under the same lock. +claude_cmd() { + if command -v flock >/dev/null 2>&1; then + flock -w 120 "$LOCK" claude "$@" &2 + else + claude "$@" &2 + 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"; } +in_manifest() { + jq -e --arg id "$1" 'any(.items[]; (.kind + ":" + .key) == $id)' "$MANIFEST" >/dev/null 2>&1 +} +carry_forward() { record "$1" "$(jq -c --arg id "$1" '.items[$id]' "$STATE")"; } + +outcome() { + p=$(prev_commit "$1") + if [ -z "$p" ]; then + report installed "$1" + elif [ "$p" != "$2" ]; then + report updated "$1" + fi +} + +# ── Unpack ─────────────────────────────────────────────────────────────────── +if [ ! -f "$INCOMING/payload.tar" ]; then + fail "no payload was uploaded" + emit_report + exit 0 +fi +mkdir -p "$BASE/marketplace" "$BASE/hooks" "$BASE/plugins" +rm -rf "$WORK" +mkdir -p "$WORK" +if ! tar -xf "$INCOMING/payload.tar" -C "$WORK" >&2; then + rm -f "$INCOMING/payload.tar" + fail "the payload could not be unpacked" + emit_report + exit 0 +fi +rm -f "$INCOMING/payload.tar" +# The host never packs links (they make an item invalid); refuse any that +# arrive rather than copy through them. +if [ -n "$(find "$WORK" -type l -print | head -n 1)" ]; then + rm -rf "$WORK" + fail "the payload contains a symbolic link, so it was not applied" + emit_report + exit 0 +fi +MANIFEST="$WORK/manifest.json" +if ! jq -e '.version == 1' "$MANIFEST" >/dev/null 2>&1; then + fail "the payload manifest is missing or has an unsupported version" + emit_report + exit 0 +fi +if ! jq -e '(.items | type) == "object"' "$STATE" >/dev/null 2>&1; then + printf '%s\n' '{"version":1,"items":{},"plugin_marketplaces":[]}' >"$STATE" +fi + +# ── Agents, skills, commands, hooks ────────────────────────────────────────── +jq -r '.items[] | select(.kind != "plugin") | [.kind, .key, .commit] | @tsv' "$MANIFEST" >"$R/items.tsv" +while IFS="$TAB" read -r kind key commit; do + id="$kind:$key" + if ! valid_key "$key"; then skip "$id" "invalid item name"; continue; fi + if ! valid_commit "$commit"; then skip "$id" "invalid commit"; continue; fi + case "$kind" in + agent | command) + dir="$CLAUDE_DIR/${kind}s" + src="$WORK/${kind}s/$key.md" + dest="$dir/$key.md" + if [ ! -f "$src" ]; then fail "$id: missing from the payload"; continue; fi + if [ -e "$dest" ] && ! owned "$id"; then + skip "$id" "~/.claude/${kind}s/$key.md already exists and was not installed by Triple-C" + continue + fi + if ! { mkdir -p "$dir" && cp "$src" "$dest.tmp.$$" && mv -f "$dest.tmp.$$" "$dest"; }; then + rm -f "$dest.tmp.$$" + fail "$id: could not write $dest" + continue + fi + outcome "$id" "$commit" + record "$id" "$(jq -cn --arg c "$commit" --arg p "$dest" '{commit: $c, path: $p}')" + ;; + skill) + dir="$CLAUDE_DIR/skills" + src="$WORK/skills/$key" + dest="$dir/$key" + if [ ! -d "$src" ]; then fail "$id: missing from the payload"; continue; fi + if [ -e "$dest" ] && ! owned "$id"; then + skip "$id" "~/.claude/skills/$key already exists and was not installed by Triple-C" + continue + fi + if ! { mkdir -p "$dir" && rm -rf "$dest" && cp -R "$src" "$dest"; }; then + fail "$id: could not write $dest" + continue + fi + outcome "$id" "$commit" + record "$id" "$(jq -cn --arg c "$commit" --arg p "$dest" '{commit: $c, path: $p}')" + ;; + hook) + src="$WORK/hooks/$key" + dest="$BASE/hooks/$key" + entries=$(jq -c --arg k "$key" \ + 'first(.items[] | select(.kind == "hook" and .key == $k) | .settings) // {}' "$MANIFEST") + if ! printf '%s' "$entries" | jq -e 'type == "object" and all(.[]; type == "array")' >/dev/null 2>&1; then + skip "$id" "its hook settings are not an object of arrays" + continue + fi + if [ ! -d "$src" ]; then fail "$id: missing from the payload"; continue; fi + if ! { rm -rf "$dest" && cp -R "$src" "$dest"; }; then + fail "$id: could not write $dest" + continue + fi + outcome "$id" "$commit" + record "$id" "$(jq -cn --arg c "$commit" --arg p "$dest" --argjson e "$entries" \ + '{commit: $c, path: $p, entries: $e}')" + ;; + *) + skip "$id" "unknown item kind" + ;; + esac +done <"$R/items.tsv" + +# ── Removals (non-plugin) ──────────────────────────────────────────────────── +cut -f1 "$R/newstate" >"$R/new_ids" +jq -r '.items | keys[]' "$STATE" >"$R/old_ids" +while read -r id; do + case "$id" in plugin:*) continue ;; esac + if grep -qxF "$id" "$R/new_ids"; then continue; fi + # Still selected but failed this run: keep the old files and record. + if in_manifest "$id"; then carry_forward "$id"; continue; fi + path=$(jq -r --arg id "$id" '.items[$id].path // ""' "$STATE") + case "$path" in + */../* | */..) fail "$id: refusing to remove unexpected path $path" ;; + "$CLAUDE_DIR"/*) + if rm -rf "$path"; then report removed "$id"; else fail "$id: could not remove $path"; fi + ;; + *) fail "$id: refusing to remove unexpected path $path" ;; + esac +done <"$R/old_ids" + +# ── Hook entries in settings.json ──────────────────────────────────────────── +# 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") +NEW_HOOKS=$(cut -f2- "$R/newstate" | jq -cs "$MERGE_ENTRIES") +HOOKS_FAILED=0 +if [ "$OLD_HOOKS" != "{}" ] || [ "$NEW_HOOKS" != "{}" ]; then + if [ -f "$SETTINGS" ]; then + current="$SETTINGS" + else + printf '{}\n' >"$R/empty.json" + current="$R/empty.json" + fi + # settings.json may hold secrets and the entrypoint keeps it 0600: create + # the replacement private and keep it that way (pre-flight N11). + saved_umask=$(umask) + umask 077 + if jq --argjson old "$OLD_HOOKS" --argjson new "$NEW_HOOKS" ' + def remove_first($x): + (to_entries | map(select(.value == $x)) | first(.[].key) // null) as $i + | if $i == null then . else del(.[$i]) end; + reduce ($old | to_entries[]) as $ev (.; + if (.hooks[$ev.key] | type) == "array" + then reduce $ev.value[] as $g (.; .hooks[$ev.key] |= remove_first($g)) + else . end) + | reduce ($new | to_entries[]) as $ev (.; + .hooks[$ev.key] = ((.hooks[$ev.key] // []) + $ev.value)) + | if (.hooks | type) == "object" then .hooks |= with_entries(select(.value != [])) else . end + | if .hooks == {} then del(.hooks) else . end + ' "$current" >"$SETTINGS.tmp.$$"; then + mv -f "$SETTINGS.tmp.$$" "$SETTINGS" + chmod 600 "$SETTINGS" + else + rm -f "$SETTINGS.tmp.$$" + HOOKS_FAILED=1 + fail "~/.claude/settings.json is not valid JSON, so hook changes were not applied" + fi + umask "$saved_umask" +fi + +# ── Plugins ────────────────────────────────────────────────────────────────── +jq -r '.plugin_marketplaces[]?' "$STATE" >"$R/old_slugs" +# @tsv escapes newlines and tabs, so one hostile slug stays one (invalid) line. +jq -r '.plugin_marketplaces[] | [.slug] | @tsv' "$MANIFEST" >"$R/new_slugs" +while read -r slug; do + if ! valid_slug "$slug"; then fail "invalid plugin marketplace name"; continue; fi + mname="triple-c-$slug" + dest="$BASE/plugins/$slug" + if ! { rm -rf "$dest" && cp -R "$WORK/plugins/$slug" "$dest"; }; then + fail "$mname: could not write $dest" + continue + fi + if grep -qxF "$slug" "$R/old_slugs"; then + claude_cmd plugin marketplace update "$mname" || fail "$mname: marketplace update failed" + elif ! claude_cmd plugin marketplace add "$dest"; then + claude_cmd plugin marketplace update "$mname" || { fail "$mname: could not be registered"; continue; } + fi + printf '%s\n' "$slug" >>"$R/final_slugs" + jq -r --arg s "$slug" '.items[] | select(.kind == "plugin" and .slug == $s) | [.key, .commit] | @tsv' \ + "$MANIFEST" >"$R/plugins.tsv" + while IFS="$TAB" read -r key commit; do + id="plugin:$key" + if ! valid_key "$key"; then skip "$id" "invalid item name"; continue; fi + if ! valid_commit "$commit"; then skip "$id" "invalid commit"; continue; fi + p=$(prev_commit "$id") + if [ -z "$p" ]; then + claude_cmd plugin install "$key@$mname" || { fail "$id: 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; } + report updated "$id" + fi + record "$id" "$(jq -cn --arg c "$commit" --arg s "$slug" '{commit: $c, slug: $s}')" + done <"$R/plugins.tsv" +done <"$R/new_slugs" + +# Plugins no longer selected. +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") + if valid_key "$key" && valid_slug "$slug" && claude_cmd plugin uninstall "$key@triple-c-$slug"; then + report removed "$id" + else + fail "$id: uninstall failed" + carry_forward "$id" + fi +done <"$R/old_ids" + +# Plugin marketplaces with nothing left in them. +cut -f2- "$R/newstate" | jq -r 'select(has("slug")) | .slug' >>"$R/final_slugs" +while read -r slug; do + if grep -qxF "$slug" "$R/final_slugs"; then continue; fi + valid_slug "$slug" || continue + claude_cmd plugin marketplace remove "triple-c-$slug" || fail "triple-c-$slug: could not be removed" + rm -rf "$BASE/plugins/$slug" +done <"$R/old_slugs" + +# ── State ──────────────────────────────────────────────────────────────────── +jq -Rn '[inputs | split("\t") | { key: .[0], value: (.[1:] | join("\t") | fromjson) }] | from_entries' \ + <"$R/newstate" >"$R/items.json" +if [ "$HOOKS_FAILED" = 1 ]; then + # settings.json still holds the old entries, so the old records stay true. + 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" +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) }' \ + >"$STATE.tmp.$$"; then + mv -f "$STATE.tmp.$$" "$STATE" +else + rm -f "$STATE.tmp.$$" + fail "the marketplace state could not be saved" +fi + +rm -rf "$WORK" +emit_report diff --git a/app/src-tauri/src/marketplace/sync_script_tests.rs b/app/src-tauri/src/marketplace/sync_script_tests.rs new file mode 100644 index 0000000..664ef6b --- /dev/null +++ b/app/src-tauri/src/marketplace/sync_script_tests.rs @@ -0,0 +1,590 @@ +//! Runs the real `sync.sh` against a throwaway `$HOME`, with a stub `claude` +//! on `PATH` that records its arguments. Skipped when `jq` or `tar` is missing. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde_json::{json, Value}; + +use super::sync::SYNC_SCRIPT; +use crate::models::marketplace::SyncReport; + +const C1: &str = "1111111111111111111111111111111111111111"; +const C2: &str = "2222222222222222222222222222222222222222"; +const SLUG: &str = "team-tools-m1aaaaaa"; + +fn have(tool: &str) -> bool { + Command::new(tool) + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +struct Env { + _root: tempfile::TempDir, + home: PathBuf, + incoming: PathBuf, + stub_dir: PathBuf, + log: PathBuf, + script: PathBuf, + lock: PathBuf, +} + +fn env() -> Option { + if !have("jq") || !have("tar") { + eprintln!("skipping: jq or tar is not installed"); + return None; + } + let root = tempfile::tempdir().unwrap(); + let home = root.path().join("home"); + let incoming = root.path().join("incoming"); + let stub_dir = root.path().join("bin"); + for d in [&home, &incoming, &stub_dir] { + fs::create_dir_all(d).unwrap(); + } + let log = root.path().join("claude.log"); + let stub = stub_dir.join("claude"); + fs::write( + &stub, + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$CLAUDE_LOG\"\nexit 0\n", + ) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&stub, fs::Permissions::from_mode(0o755)).unwrap(); + } + let script = root.path().join("sync.sh"); + fs::write(&script, SYNC_SCRIPT).unwrap(); + let lock = root.path().join("lock"); + Some(Env { + home, + incoming, + stub_dir, + log, + script, + lock, + _root: root, + }) +} + +/// Write `payload.tar` into the incoming dir: `files` plus `manifest.json`. +fn payload(env: &Env, files: &[(&str, &str, bool)], manifest: Value) { + let mut b = tar::Builder::new(Vec::new()); + let mut add = |path: &str, data: &[u8], exec: bool| { + let mut h = tar::Header::new_gnu(); + h.set_size(data.len() as u64); + h.set_mode(if exec { 0o755 } else { 0o644 }); + h.set_entry_type(tar::EntryType::Regular); + b.append_data(&mut h, path, data).unwrap(); + }; + for (path, text, exec) in files { + add(path, text.as_bytes(), *exec); + } + add("manifest.json", manifest.to_string().as_bytes(), false); + fs::write(env.incoming.join("payload.tar"), b.into_inner().unwrap()).unwrap(); +} + +fn run(env: &Env) -> SyncReport { + run_with(env, "sh") +} + +/// Run the script under a specific shell (`sh` is dash on Ubuntu). +fn run_with(env: &Env, shell: &str) -> SyncReport { + let out = Command::new(shell) + .arg(&env.script) + .env_clear() + .env("HOME", &env.home) + .env( + "PATH", + format!("{}:/usr/local/bin:/usr/bin:/bin", env.stub_dir.display()), + ) + .env("MARKETPLACE_INCOMING", &env.incoming) + .env("MARKETPLACE_LOCK", &env.lock) + .env("CLAUDE_LOG", &env.log) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + out.status.success(), + "script failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let last = stdout + .lines() + .rev() + .find(|l| !l.trim().is_empty()) + .expect("a report line"); + serde_json::from_str(last).unwrap_or_else(|e| panic!("bad report {last:?}: {e}")) +} + +fn claude_log(env: &Env) -> Vec { + fs::read_to_string(&env.log) + .unwrap_or_default() + .lines() + .map(str::to_string) + .collect() +} + +fn read_json(p: &Path) -> Value { + serde_json::from_str(&fs::read_to_string(p).unwrap()).unwrap() +} + +fn hook_settings() -> Value { + json!({ "Stop": [{ "hooks": [{ "type": "command", + "command": "/home/claude/.claude/triple-c/hooks/notify-on-stop/notify.sh" }] }] }) +} + +fn all_kinds(commit: &str) -> (Vec<(&'static str, &'static str, bool)>, Value) { + ( + vec![ + ("agents/code-reviewer.md", "agent body\n", false), + ("skills/example-skill/SKILL.md", "skill body\n", false), + ("commands/example-command.md", "command body\n", false), + ("hooks/notify-on-stop/hook.json", "{}", false), + ( + "hooks/notify-on-stop/notify.sh", + "#!/bin/sh\necho hi\n", + true, + ), + ], + json!({ "version": 1, "plugin_marketplaces": [], "items": [ + { "kind": "agent", "key": "code-reviewer", "marketplace": "m1", "commit": commit, "file": "agents/code-reviewer.md" }, + { "kind": "skill", "key": "example-skill", "marketplace": "m1", "commit": commit, "dir": "skills/example-skill" }, + { "kind": "command", "key": "example-command", "marketplace": "m1", "commit": commit, "file": "commands/example-command.md" }, + { "kind": "hook", "key": "notify-on-stop", "marketplace": "m1", "commit": commit, "dir": "hooks/notify-on-stop", "settings": hook_settings() } + ]}), + ) +} + +fn empty_manifest() -> Value { + json!({ "version": 1, "items": [], "plugin_marketplaces": [] }) +} + +fn sorted(mut v: Vec) -> Vec { + v.sort(); + v +} + +#[test] +fn sync_installs_all_kinds() { + let Some(env) = env() else { return }; + let (files, manifest) = all_kinds(C1); + payload(&env, &files, manifest); + + let r = run(&env); + + assert_eq!(r.errors, Vec::::new()); + assert_eq!( + sorted(r.installed), + vec![ + "agent:code-reviewer", + "command:example-command", + "hook:notify-on-stop", + "skill:example-skill" + ] + ); + let claude = env.home.join(".claude"); + assert_eq!( + fs::read_to_string(claude.join("agents/code-reviewer.md")).unwrap(), + "agent body\n" + ); + assert!(claude.join("skills/example-skill/SKILL.md").is_file()); + assert!(claude.join("commands/example-command.md").is_file()); + let script = claude.join("triple-c/hooks/notify-on-stop/notify.sh"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_ne!( + fs::metadata(&script).unwrap().permissions().mode() & 0o111, + 0, + "hook script must stay executable" + ); + } + assert_eq!( + read_json(&claude.join("settings.json"))["hooks"], + hook_settings() + ); + assert!( + !env.incoming.join("payload.tar").exists(), + "payload is consumed" + ); + + // A second identical run is a no-op in the report. + payload(&env, &all_kinds(C1).0, all_kinds(C1).1); + let again = run(&env); + assert!( + again.installed.is_empty() && again.updated.is_empty() && again.removed.is_empty(), + "{again:?}" + ); + assert_eq!( + read_json(&claude.join("settings.json"))["hooks"]["Stop"] + .as_array() + .unwrap() + .len(), + 1 + ); +} + +#[test] +fn sync_updates_report_changed_commits() { + let Some(env) = env() else { return }; + let (files, manifest) = all_kinds(C1); + payload(&env, &files, manifest); + run(&env); + let (files, manifest) = all_kinds(C2); + payload(&env, &files, manifest); + + let r = run(&env); + + assert_eq!(r.updated.len(), 4, "{r:?}"); + assert!(r.installed.is_empty()); +} + +#[test] +fn sync_removes_deselected() { + let Some(env) = env() else { return }; + let (files, manifest) = all_kinds(C1); + payload(&env, &files, manifest); + run(&env); + payload(&env, &[], empty_manifest()); + + let r = run(&env); + + assert_eq!( + sorted(r.removed), + vec![ + "agent:code-reviewer", + "command:example-command", + "hook:notify-on-stop", + "skill:example-skill" + ] + ); + let claude = env.home.join(".claude"); + assert!(!claude.join("agents/code-reviewer.md").exists()); + assert!(!claude.join("skills/example-skill").exists()); + assert!(!claude.join("commands/example-command.md").exists()); + assert!(!claude.join("triple-c/hooks/notify-on-stop").exists()); + assert_eq!(read_json(&claude.join("settings.json")).get("hooks"), None); +} + +#[test] +fn sync_skips_user_owned_agent() { + let Some(env) = env() else { return }; + let mine = env.home.join(".claude/agents/code-reviewer.md"); + fs::create_dir_all(mine.parent().unwrap()).unwrap(); + fs::write(&mine, "mine\n").unwrap(); + let (files, manifest) = all_kinds(C1); + payload(&env, &files, manifest); + + let r = run(&env); + + assert_eq!(r.skipped.len(), 1, "{r:?}"); + assert_eq!(r.skipped[0].item, "agent:code-reviewer"); + assert!( + r.skipped[0] + .reason + .contains("was not installed by Triple-C"), + "{}", + r.skipped[0].reason + ); + assert_eq!(fs::read_to_string(&mine).unwrap(), "mine\n"); + + // Deselecting everything must not delete the user's own file either. + payload(&env, &[], empty_manifest()); + let r = run(&env); + assert!(!r.removed.contains(&"agent:code-reviewer".to_string())); + assert_eq!(fs::read_to_string(&mine).unwrap(), "mine\n"); +} + +#[test] +fn sync_preserves_user_hooks() { + let Some(env) = env() else { return }; + let settings_path = env.home.join(".claude/settings.json"); + fs::create_dir_all(settings_path.parent().unwrap()).unwrap(); + let original = json!({ + "model": "opus", + "hooks": { + "Stop": [{ "hooks": [{ "type": "command", "command": "echo mine" }] }], + "PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "echo pre" }] }] + } + }); + fs::write( + &settings_path, + serde_json::to_string_pretty(&original).unwrap(), + ) + .unwrap(); + + let (files, manifest) = all_kinds(C1); + payload(&env, &files, manifest); + run(&env); + let merged = read_json(&settings_path); + assert_eq!(merged["model"], "opus"); + assert_eq!( + merged["hooks"]["PreToolUse"], + original["hooks"]["PreToolUse"] + ); + assert_eq!( + merged["hooks"]["Stop"][0], original["hooks"]["Stop"][0], + "user hook stays first" + ); + assert_eq!(merged["hooks"]["Stop"][1], hook_settings()["Stop"][0]); + + // An update with a changed hook entry replaces only ours. + let (files, mut manifest) = all_kinds(C2); + manifest["items"][3]["settings"]["Stop"][0]["hooks"][0]["timeout"] = json!(5); + payload(&env, &files, manifest); + run(&env); + let updated = read_json(&settings_path); + assert_eq!(updated["hooks"]["Stop"].as_array().unwrap().len(), 2); + assert_eq!(updated["hooks"]["Stop"][0], original["hooks"]["Stop"][0]); + assert_eq!(updated["hooks"]["Stop"][1]["hooks"][0]["timeout"], 5); + + // Uninstalling everything restores the user's settings exactly. + payload(&env, &[], empty_manifest()); + run(&env); + assert_eq!(read_json(&settings_path), original); +} + +#[test] +fn sync_plugin_calls() { + let Some(env) = env() else { return }; + let files = [ + ( + "plugins/team-tools-m1aaaaaa/.claude-plugin/marketplace.json", + r#"{"name":"triple-c-team-tools-m1aaaaaa","owner":{"name":"Triple-C"},"plugins":[{"name":"example-plugin","source":"./example-plugin"}]}"#, + false, + ), + ( + "plugins/team-tools-m1aaaaaa/example-plugin/.claude-plugin/plugin.json", + r#"{"name":"example-plugin"}"#, + false, + ), + ]; + let manifest = |commit: &str| { + json!({ "version": 1, + "items": [{ "kind": "plugin", "key": "example-plugin", "marketplace": "m1", "commit": commit, "slug": SLUG }], + "plugin_marketplaces": [{ "slug": SLUG, "dir": format!("plugins/{SLUG}"), "plugins": ["example-plugin"] }] }) + }; + let tree = env.home.join(".claude/triple-c/plugins").join(SLUG); + + payload(&env, &files, manifest(C1)); + let r = run(&env); + assert_eq!(r.installed, vec!["plugin:example-plugin"]); + assert_eq!( + claude_log(&env), + vec![ + format!("plugin marketplace add {}", tree.display()), + format!("plugin install example-plugin@triple-c-{SLUG}"), + ] + ); + assert!(tree.join(".claude-plugin/marketplace.json").is_file()); + + // Same commit again: catalog refreshed, nothing reinstalled. + fs::remove_file(&env.log).unwrap(); + payload(&env, &files, manifest(C1)); + run(&env); + assert_eq!( + claude_log(&env), + vec![format!("plugin marketplace update triple-c-{SLUG}")] + ); + + // New commit: uninstall + install. + fs::remove_file(&env.log).unwrap(); + payload(&env, &files, manifest(C2)); + let r = run(&env); + assert_eq!(r.updated, vec!["plugin:example-plugin"]); + assert_eq!( + claude_log(&env), + vec![ + format!("plugin marketplace update triple-c-{SLUG}"), + format!("plugin uninstall example-plugin@triple-c-{SLUG}"), + format!("plugin install example-plugin@triple-c-{SLUG}"), + ] + ); + + // Deselected: uninstall, drop the registration and the tree. + fs::remove_file(&env.log).unwrap(); + payload(&env, &[], empty_manifest()); + let r = run(&env); + assert_eq!(r.removed, vec!["plugin:example-plugin"]); + assert_eq!( + claude_log(&env), + vec![ + format!("plugin uninstall example-plugin@triple-c-{SLUG}"), + format!("plugin marketplace remove triple-c-{SLUG}"), + ] + ); + assert!(!tree.exists()); +} + +#[test] +fn sync_rejects_bad_keys() { + let Some(env) = env() else { return }; + payload( + &env, + &[("agents/x.md", "x", false)], + json!({ "version": 1, "plugin_marketplaces": [], "items": [ + { "kind": "agent", "key": "../../evil", "marketplace": "m1", "commit": C1 }, + { "kind": "agent", "key": "-rf", "marketplace": "m1", "commit": C1 }, + { "kind": "agent", "key": "ok", "marketplace": "m1", "commit": "not-a-sha" } + ]}), + ); + let r = run(&env); + assert_eq!(r.skipped.len(), 3, "{r:?}"); + assert!(r.installed.is_empty()); + assert!(!env.home.join("evil.md").exists()); +} + +#[test] +fn a_missing_payload_is_reported_not_fatal() { + let Some(env) = env() else { return }; + let r = run(&env); + assert_eq!(r.errors, vec!["no payload was uploaded"]); +} + +#[test] +fn sync_keeps_settings_json_private() { + // Pre-flight N11: the entrypoint keeps settings.json at 0600; a hook + // merge must not leave it world-readable. + let Some(env) = env() else { return }; + let settings_path = env.home.join(".claude/settings.json"); + let (files, manifest) = all_kinds(C1); + payload(&env, &files, manifest); + run(&env); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = |p: &Path| fs::metadata(p).unwrap().permissions().mode() & 0o777; + assert_eq!(mode(&settings_path), 0o600, "created by the merge"); + + fs::set_permissions(&settings_path, fs::Permissions::from_mode(0o644)).unwrap(); + payload(&env, &[], empty_manifest()); + run(&env); + assert_eq!( + mode(&settings_path), + 0o600, + "rewritten by the uninstall merge" + ); + } +} + +#[test] +fn sync_runs_under_bash_and_dash() { + let Some(env) = env() else { return }; + for shell in ["bash", "dash"] { + if !Path::new("/bin").join(shell).exists() && !have(shell) { + eprintln!("skipping {shell}: not installed"); + continue; + } + let (files, manifest) = all_kinds(C1); + payload(&env, &files, manifest); + let r = run_with(&env, shell); + assert_eq!(r.errors, Vec::::new(), "{shell}"); + assert_eq!(r.installed.len(), 4, "{shell}: {r:?}"); + payload(&env, &[], empty_manifest()); + let r = run_with(&env, shell); + assert_eq!(r.removed.len(), 4, "{shell}: {r:?}"); + } +} + +#[test] +fn sync_never_interpolates_hostile_keys() { + let Some(env) = env() else { return }; + let canary = env.home.join("pwned"); + let evil = format!("a$(touch {})", canary.display()); + let evil_tab = "a\tb"; + let evil_nl = "ok\nagent\tgood"; + let evil_slug = format!("s;touch {}", canary.display()); + payload( + &env, + &[("agents/a.md", "x", false)], + json!({ "version": 1, + "plugin_marketplaces": [{ "slug": evil_slug, "dir": "plugins/x", "plugins": [] }], + "items": [ + { "kind": "agent", "key": evil, "marketplace": "m1", "commit": C1 }, + { "kind": "agent", "key": evil_tab, "marketplace": "m1", "commit": C1 }, + { "kind": "agent", "key": evil_nl, "marketplace": "m1", "commit": C1 }, + { "kind": "agent", "key": "a".repeat(65), "marketplace": "m1", "commit": C1 }, + { "kind": "agent$(id)", "key": "a", "marketplace": "m1", "commit": C1 }, + { "kind": "plugin", "key": "p", "marketplace": "m1", "commit": C1, "slug": evil_slug } + ]}), + ); + let r = run(&env); + assert!(r.installed.is_empty(), "{r:?}"); + assert_eq!(r.skipped.len(), 5, "{r:?}"); + assert!(!canary.exists(), "a manifest value was executed"); + assert!(claude_log(&env).is_empty(), "{:?}", claude_log(&env)); + assert!(!env.home.join(".claude/agents").exists()); +} + +#[test] +fn sync_rejects_a_payload_with_symlinks() { + let Some(env) = env() else { return }; + let outside = env.home.join("outside"); + fs::create_dir_all(&outside).unwrap(); + let mut b = tar::Builder::new(Vec::new()); + let mut h = tar::Header::new_gnu(); + h.set_entry_type(tar::EntryType::Symlink); + h.set_size(0); + h.set_mode(0o777); + b.append_link(&mut h, "skills/example-skill", &outside) + .unwrap(); + let manifest = json!({ "version": 1, "plugin_marketplaces": [], "items": [ + { "kind": "skill", "key": "example-skill", "marketplace": "m1", "commit": C1, "dir": "skills/example-skill" } + ]}) + .to_string(); + let mut h = tar::Header::new_gnu(); + h.set_size(manifest.len() as u64); + h.set_mode(0o644); + b.append_data(&mut h, "manifest.json", manifest.as_bytes()) + .unwrap(); + fs::write(env.incoming.join("payload.tar"), b.into_inner().unwrap()).unwrap(); + + let r = run(&env); + + assert!(r.installed.is_empty(), "{r:?}"); + assert_eq!(r.errors.len(), 1, "{r:?}"); + assert!(r.errors[0].contains("symbolic link"), "{r:?}"); + assert!(!env.home.join(".claude/skills/example-skill").exists()); +} + +#[test] +fn sync_skips_user_owned_skill_and_command() { + let Some(env) = env() else { return }; + let claude = env.home.join(".claude"); + let skill = claude.join("skills/example-skill/SKILL.md"); + let command = claude.join("commands/example-command.md"); + for p in [&skill, &command] { + fs::create_dir_all(p.parent().unwrap()).unwrap(); + fs::write(p, "mine\n").unwrap(); + } + let (files, manifest) = all_kinds(C1); + payload(&env, &files, manifest); + + let r = run(&env); + + let skipped = sorted(r.skipped.iter().map(|s| s.item.clone()).collect()); + assert_eq!( + skipped, + vec!["command:example-command", "skill:example-skill"], + "{r:?}" + ); + assert_eq!( + sorted(r.installed), + vec!["agent:code-reviewer", "hook:notify-on-stop"] + ); + assert_eq!(fs::read_to_string(&skill).unwrap(), "mine\n"); + assert_eq!(fs::read_to_string(&command).unwrap(), "mine\n"); + + payload(&env, &[], empty_manifest()); + let r = run(&env); + assert_eq!( + sorted(r.removed), + vec!["agent:code-reviewer", "hook:notify-on-stop"] + ); + assert_eq!(fs::read_to_string(&skill).unwrap(), "mine\n"); + assert_eq!(fs::read_to_string(&command).unwrap(), "mine\n"); +}