Marketplace sync script: review fixes (round 1)
- Validate manifest structure up front; malformed items are skipped with a
reason instead of aborting extraction; an unreadable manifest changes
nothing (no removals).
- Empty/whitespace settings.json reads as {}; non-object settings are left
untouched; hook installs/updates/removals are reported and recorded only
once their entries are actually merged; mv failures are checked.
- Dangling symlinks at user paths count as occupied.
- Removal paths are derived from kind+key, never taken from state.json.
- A symlinked settings.json is written through, not replaced.
- mktemp failure emits a JSON report instead of exiting silently.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -35,9 +35,14 @@ if ! command -v jq >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
R=$(mktemp -d) || exit 2
|
||||
R=$(mktemp -d 2>/dev/null) || R=""
|
||||
if [ -z "$R" ] || [ ! -d "$R" ]; then
|
||||
printf '%s\n' '{"errors":["a temporary directory could not be created in the container, so marketplace items were not applied"]}'
|
||||
exit 0
|
||||
fi
|
||||
trap 'rm -rf "$R"' EXIT
|
||||
for f in installed updated removed skipped errors newstate new_slugs final_slugs; do
|
||||
for f in installed updated removed skipped errors newstate new_slugs final_slugs \
|
||||
hook_pending hook_removals plugin_items; do
|
||||
: >"$R/$f"
|
||||
done
|
||||
|
||||
@@ -89,19 +94,44 @@ claude_cmd() {
|
||||
|
||||
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
|
||||
}
|
||||
# Every "<kind>:<key>" 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")"; }
|
||||
|
||||
outcome() {
|
||||
# $1 = installed|updated|none for this id at this commit.
|
||||
outcome_of() {
|
||||
p=$(prev_commit "$1")
|
||||
if [ -z "$p" ]; then
|
||||
report installed "$1"
|
||||
echo installed
|
||||
elif [ "$p" != "$2" ]; then
|
||||
report updated "$1"
|
||||
echo updated
|
||||
else
|
||||
echo none
|
||||
fi
|
||||
}
|
||||
outcome() {
|
||||
o=$(outcome_of "$1" "$2")
|
||||
[ "$o" = none ] || report "$o" "$1"
|
||||
}
|
||||
# Something is in the way at a user-owned location (dangling links included).
|
||||
occupied() { [ -e "$1" ] || [ -L "$1" ]; }
|
||||
|
||||
# The one place a destination is derived; removal never trusts a stored path.
|
||||
item_path() {
|
||||
case "$1" in
|
||||
agent | command) printf '%s\n' "$CLAUDE_DIR/${1}s/$2.md" ;;
|
||||
skill) printf '%s\n' "$CLAUDE_DIR/skills/$2" ;;
|
||||
hook) printf '%s\n' "$BASE/hooks/$2" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
malformed() {
|
||||
rm -rf "$WORK"
|
||||
fail "$1"
|
||||
emit_report
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ── Unpack ───────────────────────────────────────────────────────────────────
|
||||
if [ ! -f "$INCOMING/payload.tar" ]; then
|
||||
@@ -129,27 +159,58 @@ if [ -n "$(find "$WORK" -type l -print | head -n 1)" ]; then
|
||||
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
|
||||
malformed "the payload manifest is missing or has an unsupported version"
|
||||
fi
|
||||
# Nothing is changed (and, above all, nothing removed) unless the manifest is
|
||||
# structurally sound and every extraction below succeeds.
|
||||
if ! jq -e '(.items | type) == "array" and (.plugin_marketplaces | type) == "array"' \
|
||||
"$MANIFEST" >/dev/null 2>&1; then
|
||||
malformed "the payload manifest is malformed, so nothing was changed"
|
||||
fi
|
||||
# One line per item. Fields carry a "_" prefix so an empty one cannot make
|
||||
# `read` shift the rest (tab is IFS whitespace); @tsv escapes tabs/newlines.
|
||||
# A malformed item becomes a "bad" line instead of aborting the extraction.
|
||||
if ! {
|
||||
jq -r '
|
||||
.items[]
|
||||
| if type == "object" and (.kind | type) == "string" and (.key | type) == "string"
|
||||
and (.commit | type) == "string"
|
||||
then ["ok", .kind, .key, .commit, (if (.slug | type) == "string" then .slug else "" end)]
|
||||
else ["bad",
|
||||
(if type == "object" then .kind | tostring else "?" end),
|
||||
(if type == "object" then .key | tostring else "?" end), "", ""]
|
||||
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" &&
|
||||
jq -r '.plugin_marketplaces[]
|
||||
| if type == "object" and (.slug | type) == "string" then .slug else "" end
|
||||
| [.] | @tsv' "$MANIFEST" >"$R/new_slugs"
|
||||
}; then
|
||||
malformed "the payload manifest could not be read, so nothing was changed"
|
||||
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
|
||||
while IFS="$TAB" read -r status kind key commit slug; do
|
||||
status=${status#_} kind=${kind#_} key=${key#_} commit=${commit#_} slug=${slug#_}
|
||||
id="$kind:$key"
|
||||
if [ "$status" != ok ]; then skip "$id" "malformed manifest entry"; continue; fi
|
||||
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
|
||||
plugin)
|
||||
# Applied per plugin marketplace below.
|
||||
printf '%s\t%s\t%s\n' "_$key" "_$commit" "_$slug" >>"$R/plugin_items"
|
||||
;;
|
||||
agent | command)
|
||||
dir="$CLAUDE_DIR/${kind}s"
|
||||
src="$WORK/${kind}s/$key.md"
|
||||
dest="$dir/$key.md"
|
||||
dest=$(item_path "$kind" "$key")
|
||||
if [ ! -f "$src" ]; then fail "$id: missing from the payload"; continue; fi
|
||||
if [ -e "$dest" ] && ! owned "$id"; then
|
||||
if occupied "$dest" && ! owned "$id"; then
|
||||
skip "$id" "~/.claude/${kind}s/$key.md already exists and was not installed by Triple-C"
|
||||
continue
|
||||
fi
|
||||
@@ -164,9 +225,9 @@ while IFS="$TAB" read -r kind key commit; do
|
||||
skill)
|
||||
dir="$CLAUDE_DIR/skills"
|
||||
src="$WORK/skills/$key"
|
||||
dest="$dir/$key"
|
||||
dest=$(item_path skill "$key")
|
||||
if [ ! -d "$src" ]; then fail "$id: missing from the payload"; continue; fi
|
||||
if [ -e "$dest" ] && ! owned "$id"; then
|
||||
if occupied "$dest" && ! owned "$id"; then
|
||||
skip "$id" "~/.claude/skills/$key already exists and was not installed by Triple-C"
|
||||
continue
|
||||
fi
|
||||
@@ -179,9 +240,9 @@ while IFS="$TAB" read -r kind key commit; do
|
||||
;;
|
||||
hook)
|
||||
src="$WORK/hooks/$key"
|
||||
dest="$BASE/hooks/$key"
|
||||
dest=$(item_path hook "$key")
|
||||
entries=$(jq -c --arg k "$key" \
|
||||
'first(.items[] | select(.kind == "hook" and .key == $k) | .settings) // {}' "$MANIFEST")
|
||||
'first(.items[] | objects | 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
|
||||
@@ -191,7 +252,8 @@ while IFS="$TAB" read -r kind key commit; do
|
||||
fail "$id: could not write $dest"
|
||||
continue
|
||||
fi
|
||||
outcome "$id" "$commit"
|
||||
# Reported only once its entries are in settings.json (see below).
|
||||
printf '%s\t%s\n' "$(outcome_of "$id" "$commit")" "$id" >>"$R/hook_pending"
|
||||
record "$id" "$(jq -cn --arg c "$commit" --arg p "$dest" --argjson e "$entries" \
|
||||
'{commit: $c, path: $p, entries: $e}')"
|
||||
;;
|
||||
@@ -209,14 +271,18 @@ while read -r id; do
|
||||
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"/*)
|
||||
kind=${id%%:*}
|
||||
key=${id#*:}
|
||||
if ! valid_key "$key" || ! path=$(item_path "$kind" "$key"); then
|
||||
fail "$id: dropped an unrecognised record from the marketplace state"
|
||||
continue
|
||||
fi
|
||||
if [ "$kind" = hook ]; then
|
||||
# Removed once its entries are out of settings.json (see below).
|
||||
printf '%s\n' "$id" >>"$R/hook_removals"
|
||||
continue
|
||||
fi
|
||||
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 ────────────────────────────────────────────
|
||||
@@ -227,16 +293,28 @@ 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"
|
||||
# A dotfiles symlink stays a symlink: write through to its target.
|
||||
target="$SETTINGS"
|
||||
if [ -L "$SETTINGS" ]; then
|
||||
target=$(readlink -f "$SETTINGS" 2>/dev/null) || target=""
|
||||
fi
|
||||
tmp="$target.tmp.$$"
|
||||
# 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 [ -z "$target" ] || { [ -e "$target" ] && [ ! -f "$target" ]; }; then
|
||||
HOOKS_FAILED=1
|
||||
fail "~/.claude/settings.json is not a regular file, so hook changes were not applied"
|
||||
elif [ -f "$target" ] && ! jq -s '
|
||||
if length == 0 then {}
|
||||
elif length == 1 and (.[0] | type) == "object" then .[0]
|
||||
else error("not a JSON object") end' "$target" >"$R/current.json" 2>/dev/null; then
|
||||
HOOKS_FAILED=1
|
||||
fail "~/.claude/settings.json is not a JSON object, so hook changes were not applied"
|
||||
else
|
||||
# Missing, empty and whitespace-only files all read as {}.
|
||||
[ -f "$target" ] || printf '{}\n' >"$R/current.json"
|
||||
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
|
||||
@@ -249,21 +327,30 @@ if [ "$OLD_HOOKS" != "{}" ] || [ "$NEW_HOOKS" != "{}" ]; then
|
||||
.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"
|
||||
' "$R/current.json" >"$tmp" 2>/dev/null &&
|
||||
jq -e 'type == "object"' "$tmp" >/dev/null 2>&1 &&
|
||||
mv -f "$tmp" "$target"; then
|
||||
chmod 600 "$target"
|
||||
else
|
||||
rm -f "$SETTINGS.tmp.$$"
|
||||
rm -f "$tmp"
|
||||
HOOKS_FAILED=1
|
||||
fail "~/.claude/settings.json is not valid JSON, so hook changes were not applied"
|
||||
fail "~/.claude/settings.json could not be updated, so hook changes were not applied"
|
||||
fi
|
||||
fi
|
||||
umask "$saved_umask"
|
||||
fi
|
||||
if [ "$HOOKS_FAILED" = 0 ]; then
|
||||
while IFS="$TAB" read -r o id; do
|
||||
[ "$o" = none ] || report "$o" "$id"
|
||||
done <"$R/hook_pending"
|
||||
while read -r id; do
|
||||
key=${id#hook:}
|
||||
if rm -rf "$(item_path hook "$key")"; then report removed "$id"; else fail "$id: could not remove its files"; fi
|
||||
done <"$R/hook_removals"
|
||||
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"
|
||||
@@ -278,12 +365,10 @@ while read -r slug; do
|
||||
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
|
||||
while IFS="$TAB" read -r key commit pslug; do
|
||||
key=${key#_} commit=${commit#_} pslug=${pslug#_}
|
||||
[ "$pslug" = "$slug" ] || continue
|
||||
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; }
|
||||
@@ -294,7 +379,7 @@ while read -r slug; do
|
||||
report updated "$id"
|
||||
fi
|
||||
record "$id" "$(jq -cn --arg c "$commit" --arg s "$slug" '{commit: $c, slug: $s}')"
|
||||
done <"$R/plugins.tsv"
|
||||
done <"$R/plugin_items"
|
||||
done <"$R/new_slugs"
|
||||
|
||||
# Plugins no longer selected.
|
||||
|
||||
@@ -93,9 +93,15 @@ fn run(env: &Env) -> SyncReport {
|
||||
|
||||
/// Run the script under a specific shell (`sh` is dash on Ubuntu).
|
||||
fn run_with(env: &Env, shell: &str) -> SyncReport {
|
||||
run_full(env, shell, &[])
|
||||
}
|
||||
|
||||
/// Run the script with extra environment variables.
|
||||
fn run_full(env: &Env, shell: &str, extra: &[(&str, &str)]) -> SyncReport {
|
||||
let out = Command::new(shell)
|
||||
.arg(&env.script)
|
||||
.env_clear()
|
||||
.envs(extra.iter().copied())
|
||||
.env("HOME", &env.home)
|
||||
.env(
|
||||
"PATH",
|
||||
@@ -588,3 +594,260 @@ fn sync_skips_user_owned_skill_and_command() {
|
||||
assert_eq!(fs::read_to_string(&skill).unwrap(), "mine\n");
|
||||
assert_eq!(fs::read_to_string(&command).unwrap(), "mine\n");
|
||||
}
|
||||
|
||||
// ── Review fix round 1 ──────────────────────────────────────────────────────
|
||||
|
||||
fn install_all(env: &Env) {
|
||||
let (files, manifest) = all_kinds(C1);
|
||||
payload(env, &files, manifest);
|
||||
let r = run(env);
|
||||
assert_eq!(r.installed.len(), 4, "{r:?}");
|
||||
}
|
||||
|
||||
fn assert_all_installed(env: &Env) {
|
||||
let claude = env.home.join(".claude");
|
||||
assert!(claude.join("agents/code-reviewer.md").is_file());
|
||||
assert!(claude.join("skills/example-skill/SKILL.md").is_file());
|
||||
assert!(claude.join("commands/example-command.md").is_file());
|
||||
assert!(claude
|
||||
.join("triple-c/hooks/notify-on-stop/notify.sh")
|
||||
.is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_malformed_item_neither_aborts_nor_removes() {
|
||||
let Some(env) = env() else { return };
|
||||
install_all(&env);
|
||||
|
||||
// Malformed entries first, then the valid ones; the still-selected agent
|
||||
// has a non-string commit.
|
||||
let (files, mut manifest) = all_kinds(C1);
|
||||
manifest["items"][0]["commit"] = json!(7);
|
||||
let items = manifest["items"].as_array().unwrap().clone();
|
||||
let mut all = vec![
|
||||
json!({ "kind": "agent", "key": { "x": 1 }, "marketplace": "m1", "commit": C1 }),
|
||||
json!(5),
|
||||
json!({ "kind": ["agent"], "key": "k", "marketplace": "m1", "commit": C1 }),
|
||||
json!({ "kind": "plugin", "key": { "y": 1 }, "marketplace": "m1", "commit": C1, "slug": SLUG }),
|
||||
];
|
||||
all.extend(items);
|
||||
manifest["items"] = Value::Array(all);
|
||||
payload(&env, &files, manifest);
|
||||
|
||||
let r = run(&env);
|
||||
|
||||
assert!(r.removed.is_empty(), "{r:?}");
|
||||
assert!(r.errors.is_empty(), "{r:?}");
|
||||
assert_all_installed(&env);
|
||||
let skipped = r
|
||||
.skipped
|
||||
.iter()
|
||||
.map(|s| s.item.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(skipped.contains(&"agent:code-reviewer"), "{r:?}");
|
||||
assert_eq!(r.skipped.len(), 5, "{r:?}");
|
||||
|
||||
// The carried-forward record is still owned: deselecting removes it.
|
||||
payload(&env, &[], empty_manifest());
|
||||
let r = run(&env);
|
||||
assert_eq!(r.removed.len(), 4, "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_structurally_bad_manifest_removes_nothing() {
|
||||
let Some(env) = env() else { return };
|
||||
install_all(&env);
|
||||
for bad in [
|
||||
json!({ "version": 1 }),
|
||||
json!({ "version": 1, "items": {}, "plugin_marketplaces": [] }),
|
||||
json!({ "version": 1, "items": [], "plugin_marketplaces": "x" }),
|
||||
] {
|
||||
payload(&env, &[], bad.clone());
|
||||
let r = run(&env);
|
||||
assert!(r.removed.is_empty(), "{bad}: {r:?}");
|
||||
assert_eq!(r.errors.len(), 1, "{bad}: {r:?}");
|
||||
assert_all_installed(&env);
|
||||
}
|
||||
// State survived: a real deselection still removes everything.
|
||||
payload(&env, &[], empty_manifest());
|
||||
assert_eq!(run(&env).removed.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_treats_blank_settings_as_empty() {
|
||||
let Some(env) = env() else { return };
|
||||
let settings_path = env.home.join(".claude/settings.json");
|
||||
fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
|
||||
fs::write(&settings_path, " \n\t\n").unwrap();
|
||||
let (files, manifest) = all_kinds(C1);
|
||||
payload(&env, &files, manifest);
|
||||
|
||||
let r = run(&env);
|
||||
|
||||
assert!(r.errors.is_empty(), "{r:?}");
|
||||
assert!(
|
||||
r.installed.contains(&"hook:notify-on-stop".to_string()),
|
||||
"{r:?}"
|
||||
);
|
||||
assert_eq!(read_json(&settings_path)["hooks"], hook_settings());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_does_not_report_hooks_it_could_not_wire() {
|
||||
let Some(env) = env() else { return };
|
||||
let settings_path = env.home.join(".claude/settings.json");
|
||||
fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
|
||||
for bad in ["[1]", "{\"a\":", "{} {}"] {
|
||||
fs::write(&settings_path, bad).unwrap();
|
||||
let (files, manifest) = all_kinds(C1);
|
||||
payload(&env, &files, manifest);
|
||||
let r = run(&env);
|
||||
assert!(
|
||||
!r.installed.contains(&"hook:notify-on-stop".to_string()),
|
||||
"{bad}: {r:?}"
|
||||
);
|
||||
assert_eq!(r.errors.len(), 1, "{bad}: {r:?}");
|
||||
assert_eq!(
|
||||
fs::read_to_string(&settings_path).unwrap(),
|
||||
bad,
|
||||
"left untouched"
|
||||
);
|
||||
}
|
||||
// Once settings.json is fixed the hook is installed for real.
|
||||
fs::write(&settings_path, "{}").unwrap();
|
||||
let (files, manifest) = all_kinds(C1);
|
||||
payload(&env, &files, manifest);
|
||||
let r = run(&env);
|
||||
assert_eq!(r.installed, vec!["hook:notify-on-stop"], "{r:?}");
|
||||
assert_eq!(read_json(&settings_path)["hooks"], hook_settings());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn sync_skips_dangling_user_symlinks() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let Some(env) = env() else { return };
|
||||
let claude = env.home.join(".claude");
|
||||
let agent = claude.join("agents/code-reviewer.md");
|
||||
let skill = claude.join("skills/example-skill");
|
||||
for p in [&agent, &skill] {
|
||||
fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
symlink("/nonexistent/dotfiles/target", p).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!["agent:code-reviewer", "skill:example-skill"],
|
||||
"{r:?}"
|
||||
);
|
||||
for p in [&agent, &skill] {
|
||||
assert_eq!(
|
||||
fs::read_link(p).unwrap(),
|
||||
Path::new("/nonexistent/dotfiles/target")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_removal_never_uses_paths_from_state() {
|
||||
let Some(env) = env() else { return };
|
||||
install_all(&env);
|
||||
let claude = env.home.join(".claude");
|
||||
let keep = claude.join("keep.txt");
|
||||
fs::write(&keep, "keep").unwrap();
|
||||
let state_path = claude.join("triple-c/marketplace/state.json");
|
||||
let mut state = read_json(&state_path);
|
||||
state["items"]["agent:code-reviewer"]["path"] = json!(format!("{}/", claude.display()));
|
||||
state["items"]["skill:example-skill"]["path"] =
|
||||
json!(format!("{}/.claude", env.home.display()));
|
||||
state["items"]["command:example-command"]["path"] = json!(keep.display().to_string());
|
||||
fs::write(&state_path, state.to_string()).unwrap();
|
||||
|
||||
payload(&env, &[], empty_manifest());
|
||||
let r = run(&env);
|
||||
|
||||
assert_eq!(r.removed.len(), 4, "{r:?}");
|
||||
assert_eq!(fs::read_to_string(&keep).unwrap(), "keep");
|
||||
assert!(!claude.join("agents/code-reviewer.md").exists());
|
||||
assert!(!claude.join("skills/example-skill").exists());
|
||||
assert!(!claude.join("commands/example-command.md").exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn sync_writes_through_a_symlinked_settings_json() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let Some(env) = env() else { return };
|
||||
let dotfiles = env.home.join("dotfiles/settings.json");
|
||||
fs::create_dir_all(dotfiles.parent().unwrap()).unwrap();
|
||||
fs::write(&dotfiles, r#"{"model":"opus"}"#).unwrap();
|
||||
let settings_path = env.home.join(".claude/settings.json");
|
||||
fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
|
||||
symlink(&dotfiles, &settings_path).unwrap();
|
||||
let (files, manifest) = all_kinds(C1);
|
||||
payload(&env, &files, manifest);
|
||||
|
||||
let r = run(&env);
|
||||
|
||||
assert!(r.errors.is_empty(), "{r:?}");
|
||||
assert!(
|
||||
fs::symlink_metadata(&settings_path)
|
||||
.unwrap()
|
||||
.file_type()
|
||||
.is_symlink(),
|
||||
"link kept"
|
||||
);
|
||||
let merged = read_json(&dotfiles);
|
||||
assert_eq!(merged["model"], "opus");
|
||||
assert_eq!(merged["hooks"], hook_settings());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_reports_when_mktemp_fails() {
|
||||
let Some(env) = env() else { return };
|
||||
let r = run_full(&env, "sh", &[("TMPDIR", "/nonexistent/triple-c-tmp")]);
|
||||
assert_eq!(r.errors.len(), 1, "{r:?}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn sync_settings_move_failure_is_reported_and_not_recorded() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let Some(env) = env() else { return };
|
||||
// An `mv` that refuses to replace settings.json and delegates otherwise.
|
||||
let mv = env.stub_dir.join("mv");
|
||||
fs::write(
|
||||
&mv,
|
||||
"#!/bin/sh\nfor a; do last=$a; done\ncase \"$last\" in */settings.json) exit 1 ;; esac\nexec /bin/mv \"$@\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::set_permissions(&mv, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let (files, manifest) = all_kinds(C1);
|
||||
payload(&env, &files, manifest);
|
||||
|
||||
let r = run(&env);
|
||||
|
||||
assert_eq!(r.errors.len(), 1, "{r:?}");
|
||||
assert!(
|
||||
!r.installed.contains(&"hook:notify-on-stop".to_string()),
|
||||
"{r:?}"
|
||||
);
|
||||
let claude = env.home.join(".claude");
|
||||
assert!(!claude.join("settings.json").exists());
|
||||
let leftovers: Vec<_> = fs::read_dir(&claude)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
|
||||
.collect();
|
||||
assert!(leftovers.is_empty(), "temp file cleaned up");
|
||||
let state = read_json(&claude.join("triple-c/marketplace/state.json"));
|
||||
assert!(
|
||||
state["items"].get("hook:notify-on-stop").is_none(),
|
||||
"{state}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user