fix(scripts): make the mirror's mode check umask-independent
The previous round widened path_manifest from the exec bit to full permission bits, and that made check-plugin-content-sync fail at pre-push on a pristine tree. hooks/hooks.json is not copied from the bundle -- sync_hooks_json writes it with printf, i.e. at the runtime umask -- while the real side comes from the checkout. On a umask-002 clone the two disagree, 664 vs 644, and no commit can reconcile them because git tracks no non-exec mode. The rule adopted: record a mode for a path this pipeline copies, never for one it writes. A copied path's mode traces to the same checkout on both sides, so comparing it means something; a written path's mode is the writer's umask on one side and the checkout's on the other, which are independent. That is the same rationale the directory exclusion already carried -- what broke was the premise that files are immune. Normalising instead was rejected: pinning the generated side cannot fix a checked-out side that is already 664. The unconditional chmod 644 in reinject_mcp_servers goes for the same reason; writing through the destination inode already closed the original 0600 bug. The mode coverage added for the two plugin.json manifests is removed rather than documented, because it measured nothing on any axis. In check mode the expected side is a cp -a of the real plugin root, so apm rewrites an existing inode and inherits its mode; and a symlinked manifest is copied as a symlink and written straight through, so both sides agreed no matter what. That symlink case is a real hazard -- the re-injection corrupts the link's target -- so it is now asserted directly instead. Also: an unparseable or non-object per-plugin plugin.json killed the manifest walk mid-loop; the source-less-entry guard closed only source: null and let every other malformed value through; the select it backstops was extracted so a test can exercise it independently, which nothing could before; and two more `|| pwd` fallbacks now hard-error -- with a decoy marketplace.json in $PWD, --all derived its plugin list from it. Tests: 63 -> 77 and 23 -> 31 assertions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
This commit is contained in:
@@ -467,7 +467,83 @@ JSON
|
||||
printf 'name: lint\nversion: 0.1.0\ntype: skill\n' > "$FIXTURE11/plugins/lint/apm.yml"
|
||||
assert_fails_with "$FIXTURE11" \
|
||||
"an entry with no source: is reported by name instead of silently disabling both checks" \
|
||||
'no `source` field' 'lint'
|
||||
'`source` is neither a local path string nor a remote source object' 'lint (source: null)'
|
||||
|
||||
# --- 11b. Any other unclassifiable `source` is rejected the same way ---
|
||||
# The guard used to test `.source == null` specifically, so every OTHER malformed value
|
||||
# reached exactly the state the null case was fixed for: `"source": 42` passed the
|
||||
# assert, was skipped by list_marketplace_local_plugins for not being a string, AND was
|
||||
# rescued by the disk -> marketplace name axis (whose select was the denylist
|
||||
# `(.source|type) != "string"`, true for a number). Verbatim the same defect, one value
|
||||
# over. Only two shapes are classifiable -- a local path string and a remote source
|
||||
# object -- so the guard is typed as "neither of those", not as a list of known-bad
|
||||
# values.
|
||||
echo ""
|
||||
echo "--- a non-string, non-object source: is rejected by type, not by enumerating null ---"
|
||||
for BAD_SOURCE in '42' '[]' 'true'; do
|
||||
FIXTURE11B="$(mktemp -d)"
|
||||
FIXTURES+=("$FIXTURE11B")
|
||||
mkdir -p "$FIXTURE11B/.claude-plugin" "$FIXTURE11B/plugins/lint"
|
||||
printf '{ "name": "test-marketplace", "plugins": [ { "name": "lint", "source": %s } ] }\n' \
|
||||
"$BAD_SOURCE" > "$FIXTURE11B/.claude-plugin/marketplace.json"
|
||||
printf 'name: lint\nversion: 0.1.0\ntype: skill\n' > "$FIXTURE11B/plugins/lint/apm.yml"
|
||||
assert_fails_with "$FIXTURE11B" \
|
||||
"a source: of $BAD_SOURCE is rejected instead of silently disabling both checks" \
|
||||
'`source` is neither a local path string nor a remote source object' 'lint (source:'
|
||||
done
|
||||
|
||||
# --- 11c. The disk -> marketplace name axis, exercised WITHOUT the precondition ---
|
||||
# This is the one assertion that cannot go through bash "$SCRIPT": every malformed entry
|
||||
# the select must reject is rejected first by assert_marketplace_manifest_usable, which
|
||||
# exits before the select ever runs. So reverting the select alone left the whole suite
|
||||
# green -- the code carried a comment claiming it "must not depend on that check running
|
||||
# first", and nothing tested that independence. Call the function directly instead.
|
||||
#
|
||||
# The invariant: the name axis exists solely for a plugin vendored on disk under a
|
||||
# REMOTE (object) `source:`, which has no local path to match on. Every other shape --
|
||||
# a local string (which matches by path and needs no name fallback, see case 9d) and
|
||||
# every unclassifiable value -- must produce no name at all.
|
||||
echo ""
|
||||
echo "--- list_marketplace_remote_plugin_names emits object-source names only ---"
|
||||
# shellcheck source=scripts/lib/marketplace-plugins.sh
|
||||
source "$REPO_ROOT/scripts/lib/marketplace-plugins.sh"
|
||||
FIXTURE11C="$(mktemp -d)"
|
||||
FIXTURES+=("$FIXTURE11C")
|
||||
cat > "$FIXTURE11C/marketplace.json" <<'JSON'
|
||||
{
|
||||
"name": "test-marketplace",
|
||||
"plugins": [
|
||||
{ "name": "remote-obj", "source": { "repo": "someorg/somerepo", "source": "github" } },
|
||||
{ "name": "local-str", "source": "./plugins/local-str" },
|
||||
{ "name": "null-src" },
|
||||
{ "name": "explicit-null", "source": null },
|
||||
{ "name": "number-src", "source": 42 },
|
||||
{ "name": "array-src", "source": [] },
|
||||
{ "name": "bool-src", "source": true }
|
||||
]
|
||||
}
|
||||
JSON
|
||||
NAMES11C="$(list_marketplace_remote_plugin_names "$FIXTURE11C/marketplace.json")"
|
||||
if [[ "$NAMES11C" == "remote-obj" ]]; then
|
||||
pass "only the remote object-source entry yields a name for the disk -> marketplace name axis"
|
||||
else
|
||||
fail "the name axis emitted $(printf '%s' "$NAMES11C" | tr '\n' ' ')— expected exactly 'remote-obj'; every other shape would rescue a same-named orphan directory"
|
||||
fi
|
||||
|
||||
# --- 11d. A valid-JSON, non-object marketplace root is named, not left to crash jq ---
|
||||
# `jq empty` passes on `[]`, `"x"` and `123`; the `.plugins` lookup on the next line then
|
||||
# died with a raw `jq: error: Cannot index array with string "plugins"` and rc=5,
|
||||
# attributed to nothing at all.
|
||||
echo ""
|
||||
echo "--- a valid-JSON non-object marketplace root is reported as such ---"
|
||||
FIXTURE11D="$(mktemp -d)"
|
||||
FIXTURES+=("$FIXTURE11D")
|
||||
mkdir -p "$FIXTURE11D/.claude-plugin" "$FIXTURE11D/plugins/one"
|
||||
printf '[]\n' > "$FIXTURE11D/.claude-plugin/marketplace.json"
|
||||
printf 'name: one\nversion: 0.1.0\ntype: skill\n' > "$FIXTURE11D/plugins/one/apm.yml"
|
||||
assert_fails_with "$FIXTURE11D" \
|
||||
"a JSON array at the marketplace root is named as a root-shape error" \
|
||||
'is a JSON array at its top level'
|
||||
|
||||
# --- 12. A `skills` string (a legal shape per the host docs) is resolved, not counted ---
|
||||
# `jq '.skills | if . then length else 0 end'` is null-safe but not type-safe: on the
|
||||
@@ -623,6 +699,73 @@ mkdir -p "$FIXTURE18B/plugins/scratch/notes" "$FIXTURE18B/docs"
|
||||
assert_passes "$FIXTURE18B" \
|
||||
"no marketplace.json and no plugin-marked directories is a genuine no-op, not a failure"
|
||||
|
||||
# --- 19. An unparseable per-plugin plugin.json is attributed, and the walk continues ---
|
||||
# check_pointer_field reads the manifest with bare `$(jq ...)` assignments, so under
|
||||
# `set -e` a parse failure aborted the whole script mid-loop: rc=5, a raw
|
||||
# `jq: parse error` on stderr, no `Manifest check failed:` summary, and every plugin
|
||||
# later in the marketplace silently unchecked. That is the same failure class the
|
||||
# marketplace's own `jq empty` precondition closes -- and .claude-plugin/plugin.json is
|
||||
# equally generated output, so it is equally capable of being corrupt.
|
||||
#
|
||||
# The second entry is broken in an unrelated way; both messages plus the summary must
|
||||
# appear, which is what proves the walk survived the first fault.
|
||||
echo ""
|
||||
echo "--- an unparseable plugin.json is reported and does not abort the marketplace walk ---"
|
||||
FIXTURE19="$(mktemp -d)"
|
||||
FIXTURES+=("$FIXTURE19")
|
||||
mkdir -p "$FIXTURE19/plugins/corrupt/.claude-plugin" "$FIXTURE19/plugins/second"
|
||||
write_marketplace "$FIXTURE19" "corrupt=./plugins/corrupt" "second=./plugins/second"
|
||||
printf '{ "name": "corrupt",\n' > "$FIXTURE19/plugins/corrupt/.claude-plugin/plugin.json"
|
||||
assert_fails_with "$FIXTURE19" \
|
||||
"an unparseable plugin.json is named and later plugins are still checked" \
|
||||
"plugin 'corrupt': .claude-plugin/plugin.json is not valid JSON" \
|
||||
"plugin 'second': .claude-plugin/plugin.json not found" \
|
||||
'Manifest check failed: 2 error(s)'
|
||||
|
||||
# --- 19b. A valid-JSON but non-object plugin.json is caught too ---
|
||||
# `jq empty` passes on `[]`; it is the `.skills` lookup on such a root that aborts
|
||||
# ("Cannot index array with string"), not the parse -- so the parse check alone would
|
||||
# leave this exact crash reachable.
|
||||
echo ""
|
||||
echo "--- a valid-JSON non-object plugin.json is reported, not left to crash jq ---"
|
||||
FIXTURE19B="$(mktemp -d)"
|
||||
FIXTURES+=("$FIXTURE19B")
|
||||
mkdir -p "$FIXTURE19B/plugins/arrayjson/.claude-plugin" "$FIXTURE19B/plugins/second"
|
||||
write_marketplace "$FIXTURE19B" "arrayjson=./plugins/arrayjson" "second=./plugins/second"
|
||||
printf '[]\n' > "$FIXTURE19B/plugins/arrayjson/.claude-plugin/plugin.json"
|
||||
assert_fails_with "$FIXTURE19B" \
|
||||
"a non-object plugin.json is named by type and later plugins are still checked" \
|
||||
"plugin 'arrayjson': .claude-plugin/plugin.json is a JSON array at its top level" \
|
||||
"plugin 'second': .claude-plugin/plugin.json not found" \
|
||||
'Manifest check failed: 2 error(s)'
|
||||
|
||||
# --- 20. Run with no argument outside a worktree: refuse, do not guess $PWD ---
|
||||
# Every path this script touches hangs off REPO_ROOT, and its exit-0 path is "no
|
||||
# manifest and nothing on disk" -- so `|| pwd` made a run from an empty directory
|
||||
# outside any worktree exit 0, silently, having inspected no repository at all. Same
|
||||
# reasoning as scripts/sync-marketplace-mirror.sh, which dropped its fallback first.
|
||||
#
|
||||
# `env -u GIT_DIR -u GIT_WORK_TREE` because run-tests.sh runs as a pre-push hook and git
|
||||
# hooks export both, which would re-target `git rev-parse --show-toplevel` at the LIVE
|
||||
# repo from any cwd -- making this case pass for the wrong reason.
|
||||
echo ""
|
||||
echo "--- with no argument outside a git worktree, it refuses instead of guessing \$PWD ---"
|
||||
FIXTURE20="$(mktemp -d)"
|
||||
FIXTURES+=("$FIXTURE20")
|
||||
if (cd "$FIXTURE20" && env -u GIT_DIR -u GIT_WORK_TREE git rev-parse --show-toplevel) >/dev/null 2>&1; then
|
||||
fail "fixture precondition: $FIXTURE20 is inside a git worktree, so this case cannot test the no-worktree path"
|
||||
else
|
||||
RC20=0
|
||||
OUT20="$(cd "$FIXTURE20" && env -u GIT_DIR -u GIT_WORK_TREE bash "$SCRIPT" 2>&1)" || RC20=$?
|
||||
if [[ $RC20 -eq 0 ]]; then
|
||||
fail "exited 0 from outside a worktree with no argument -- it checked nothing and said so to no one"
|
||||
elif [[ "$OUT20" != *"not inside a git worktree"* ]]; then
|
||||
fail "exited $RC20 outside a worktree but not for the stated reason. Output: $OUT20"
|
||||
else
|
||||
pass "refuses to guess \$PWD when it cannot locate the repository root"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]]
|
||||
|
||||
@@ -324,21 +324,39 @@ else
|
||||
pass "mcpServers is not an inlined object"
|
||||
fi
|
||||
|
||||
# --- 10b. .github/plugin/plugin.json is world-readable 0644, not mktemp's 0600 ---
|
||||
# --- 10b. The re-injection preserves the manifest's own mode, rather than importing one ---
|
||||
# reinject_mcp_servers used to build its replacement in a `mktemp` file (mode 0600)
|
||||
# and `mv` it over the manifest, carrying 0600 onto a tracked, published file. Git
|
||||
# records only the exec bit, so the demotion survived every commit unnoticed — this
|
||||
# repo's own plugins/bin/.github/plugin/plugin.json really was 0600 on disk while
|
||||
# its five siblings were 0644.
|
||||
#
|
||||
# The fix is `cat "$tmp" >"$dst"`, which keeps the destination inode: the manifest
|
||||
# ends up at whatever mode apm pack gave it a moment earlier, i.e. 0666 & ~umask like
|
||||
# any other freshly created file. So this is asserted across two umasks rather than
|
||||
# against a hardcoded 644 — that is what distinguishes "preserved" from "assigned".
|
||||
# A `mv` of the mktemp yields 600 under both; a `chmod 644` yields 644 under both,
|
||||
# which is the umask dependence that made --check fail on a umask-002 checkout.
|
||||
mode_of() {
|
||||
stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" 2>/dev/null
|
||||
}
|
||||
echo ""
|
||||
echo "--- a real sync leaves .github/plugin/plugin.json at mode 644 ---"
|
||||
MODE10="$(stat -c '%a' "$FIXTURE10/.github/plugin/plugin.json" 2>/dev/null \
|
||||
|| stat -f '%Lp' "$FIXTURE10/.github/plugin/plugin.json" 2>/dev/null)"
|
||||
if [[ "$MODE10" == "644" ]]; then
|
||||
pass "mcpServers re-injection leaves the manifest at 644"
|
||||
else
|
||||
fail "mcpServers re-injection left .github/plugin/plugin.json at mode $MODE10, expected 644"
|
||||
fi
|
||||
echo "--- the mcpServers re-injection leaves the manifest at the umask's own file mode ---"
|
||||
for UMASK10B in 022 002; do
|
||||
case "$UMASK10B" in
|
||||
022) EXPECT10B=644 ;;
|
||||
002) EXPECT10B=664 ;;
|
||||
esac
|
||||
FIXTURE10B="$(umask "$UMASK10B"; make_fixture_with_mcp '{"mcpServers":{"demo":{"command":"demo-server","type":"stdio"}}}')"
|
||||
track "$FIXTURE10B"
|
||||
(umask "$UMASK10B"; bash "$SCRIPT" "$FIXTURE10B" > /dev/null 2>&1)
|
||||
MODE10B="$(mode_of "$FIXTURE10B/.github/plugin/plugin.json")"
|
||||
if [[ "$MODE10B" == "$EXPECT10B" ]]; then
|
||||
pass "under umask $UMASK10B the re-injected manifest is $EXPECT10B (its own mode, not mktemp's 600 and not a hardcoded one)"
|
||||
else
|
||||
fail "under umask $UMASK10B the re-injected manifest is $MODE10B, expected $EXPECT10B"
|
||||
fi
|
||||
done
|
||||
|
||||
# --- 11. An empty .mcp.json does not add a redundant mcpServers: {} ---
|
||||
echo ""
|
||||
@@ -782,35 +800,62 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 25. --check sees a mode change on the generated Copilot manifest ---
|
||||
# sync_plugin_manifest diffs that file's CONTENT only, and check_path_modes did not
|
||||
# cover .github/plugin/ at all — so the mode reinject_mcp_servers writes was outside
|
||||
# --check's manifest entirely, and check and sync could disagree about it forever.
|
||||
# --- 25. The generated manifests: --check and a real sync agree about their mode ---
|
||||
# The gate's contract is agreement between --check and a real sync, not "every
|
||||
# property is repaired". Neither touches a generated manifest's permission bits, and
|
||||
# neither can: in check mode the expected side is a `cp -a` of the real plugin root,
|
||||
# so apm pack rewrites a file whose mode is already the actual side's. A revision that
|
||||
# listed these paths in the mode manifest was measuring that inheritance, not the
|
||||
# mirror — `chmod 600` left --check at exit 0 for as long as it was listed. This case
|
||||
# pins the agreement instead, so a future "fix" that makes --check report a mode it
|
||||
# cannot repair fails here.
|
||||
echo ""
|
||||
echo "--- --check detects a mode change on .github/plugin/plugin.json ---"
|
||||
echo "--- --check and a real sync agree that a manifest's mode is not theirs to change ---"
|
||||
FIXTURE25="$(make_fixture_with_mcp '{"mcpServers":{"demo":{"command":"demo-server","type":"stdio"}}}')"; track "$FIXTURE25"
|
||||
bash "$SCRIPT" "$FIXTURE25" > /dev/null 2>&1
|
||||
if ! bash "$SCRIPT" --check "$FIXTURE25" > /dev/null 2>&1; then
|
||||
fail "check reported drift right after the initial sync — cannot test the manifest-mode case"
|
||||
else
|
||||
chmod 600 "$FIXTURE25/.github/plugin/plugin.json"
|
||||
CHECK25="$(bash "$SCRIPT" --check "$FIXTURE25" 2>&1 || true)"
|
||||
case "$CHECK25" in
|
||||
*"mirrored paths/types/modes differ"*)
|
||||
pass "a mode change on .github/plugin/plugin.json is reported as drift" ;;
|
||||
*)
|
||||
fail "no mode drift reported after chmod 600 on .github/plugin/plugin.json" ;;
|
||||
esac
|
||||
bash "$SCRIPT" "$FIXTURE25" > /dev/null 2>&1
|
||||
MODE25="$(stat -c '%a' "$FIXTURE25/.github/plugin/plugin.json" 2>/dev/null \
|
||||
|| stat -f '%Lp' "$FIXTURE25/.github/plugin/plugin.json" 2>/dev/null)"
|
||||
if [[ "$MODE25" == "644" ]] && bash "$SCRIPT" --check "$FIXTURE25" > /dev/null 2>&1; then
|
||||
pass "re-sync restores mode 644 and clears the drift"
|
||||
if bash "$SCRIPT" --check "$FIXTURE25" > /dev/null 2>&1; then
|
||||
bash "$SCRIPT" "$FIXTURE25" > /dev/null 2>&1
|
||||
MODE25="$(mode_of "$FIXTURE25/.github/plugin/plugin.json")"
|
||||
if [[ "$MODE25" == "600" ]]; then
|
||||
pass "--check reports no mode drift on a manifest, and a real sync indeed leaves the mode alone"
|
||||
else
|
||||
fail "--check reported no mode drift but a real sync changed the mode to $MODE25 — check and sync disagree"
|
||||
fi
|
||||
else
|
||||
fail "re-sync left .github/plugin/plugin.json at mode $MODE25 / did not clear the drift"
|
||||
fail "--check reported drift after chmod 600 on .github/plugin/plugin.json, but a real sync cannot repair it — an unfixable pre-push failure"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 25b. A manifest replaced by a SYMLINK is real drift and is reported ---
|
||||
# This is the one property of the generated manifests worth asserting, and the reason
|
||||
# it cannot live in check_path_modes: that compares against a `cp -a` of the same
|
||||
# plugin root, which reproduces the symlink on the expected side and calls the two
|
||||
# equal (verified — it sat at exit 0). The hazard is concrete: apm pack opens the
|
||||
# manifest for writing and reinject_mcp_servers redirects into it, and both follow the
|
||||
# link, so a real sync rewrites the link's TARGET instead of the manifest.
|
||||
echo ""
|
||||
echo "--- a plugin.json replaced by a symlink is reported as drift ---"
|
||||
FIXTURE25B="$(make_fixture_with_mcp '{"mcpServers":{"demo":{"command":"demo-server","type":"stdio"}}}')"; track "$FIXTURE25B"
|
||||
bash "$SCRIPT" "$FIXTURE25B" > /dev/null 2>&1
|
||||
if [[ ! -f "$FIXTURE25B/.claude-plugin/plugin.json" ]]; then
|
||||
fail "sync produced no .claude-plugin/plugin.json — cannot test the symlinked-manifest case"
|
||||
else
|
||||
cp "$FIXTURE25B/.claude-plugin/plugin.json" "$FIXTURE25B/decoy.json"
|
||||
rm -f "$FIXTURE25B/.claude-plugin/plugin.json"
|
||||
ln -s ../decoy.json "$FIXTURE25B/.claude-plugin/plugin.json"
|
||||
CHECK25B="$(bash "$SCRIPT" --check "$FIXTURE25B" 2>&1 || true)"
|
||||
case "$CHECK25B" in
|
||||
*"DRIFT $FIXTURE25B/.claude-plugin/plugin.json: is a symlink"*)
|
||||
pass "a symlinked .claude-plugin/plugin.json is reported as drift" ;;
|
||||
*)
|
||||
fail "no drift reported for a symlinked .claude-plugin/plugin.json — a real sync would write through it. Output: $CHECK25B" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# --- 26. --check compares full permission bits, not just the exec bit ---
|
||||
# The manifest used to record a bare exec/file kind, so `chmod 444` on a mirrored
|
||||
# SKILL.md left --check at exit 0 while a real sync restored 644 — the same
|
||||
@@ -838,6 +883,47 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 26b. The mode comparison must not depend on the runtime umask ---
|
||||
# Case 26's widening from the exec bit to full permission bits is correct for the
|
||||
# files this script COPIES — both sides of the comparison trace to the same checkout.
|
||||
# It is wrong for the files it WRITES: sync_hooks_json creates hooks/hooks.json with
|
||||
# `printf '%s\n' >`, at the RUNTIME umask, while the real side carries the umask of
|
||||
# the checkout that produced the committed file. Those are independent, so on a
|
||||
# umask-002 machine `--check --all` over this repo's own umask-022 checkout reported
|
||||
# `< file 664 hooks/hooks.json` / `> file 644` for every plugin with hooks — a pre-push
|
||||
# failure with nothing wrong, and unfixable by committing, since git records no
|
||||
# non-exec mode and the next --check from a umask-022 machine fails the other way.
|
||||
#
|
||||
# Two directions, because a one-sided assertion passes on the wrong fix: (a) the same
|
||||
# tree checked under several runtime umasks, and (b) a tree whose GENERATED files carry
|
||||
# a foreign umask — which is exactly what a umask-002 clone of a umask-022 commit looks
|
||||
# like, git having recorded nothing to distinguish them.
|
||||
echo ""
|
||||
echo "--- --check is umask-independent over the files this script generates ---"
|
||||
FIXTURE26B="$(umask 022; make_fixture)"; track "$FIXTURE26B"
|
||||
(umask 022; bash "$SCRIPT" "$FIXTURE26B" > /dev/null 2>&1)
|
||||
if [[ ! -f "$FIXTURE26B/hooks/hooks.json" ]]; then
|
||||
fail "sync did not create hooks/hooks.json — cannot test the umask-independence case"
|
||||
else
|
||||
for UMASK26B in 022 002 077; do
|
||||
if (umask "$UMASK26B"; bash "$SCRIPT" --check "$FIXTURE26B" > /dev/null 2>&1); then
|
||||
pass "--check at umask $UMASK26B is clean on a tree synced at umask 022"
|
||||
else
|
||||
fail "--check at umask $UMASK26B reported drift on a tree synced at umask 022 — the gate is reporting the runner's umask, not the mirror"
|
||||
fi
|
||||
done
|
||||
# What a umask-002 clone of the same commit looks like on disk.
|
||||
chmod 664 "$FIXTURE26B/hooks/hooks.json"
|
||||
[[ -f "$FIXTURE26B/.claude-plugin/plugin.json" ]] && chmod 664 "$FIXTURE26B/.claude-plugin/plugin.json"
|
||||
for UMASK26B in 022 002; do
|
||||
if (umask "$UMASK26B"; bash "$SCRIPT" --check "$FIXTURE26B" > /dev/null 2>&1); then
|
||||
pass "--check at umask $UMASK26B is clean when the generated files carry a umask-002 checkout's mode"
|
||||
else
|
||||
fail "--check at umask $UMASK26B reported drift on generated files carrying a umask-002 checkout's mode — no commit can fix that"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# --- 27. A plugin-dir argument whose basename is . or .. is rejected ---
|
||||
# Every scratch path is "$SCRATCH_ROOT/$(basename "$plugin_dir")", so `..` resolves
|
||||
# to the scratch root's PARENT: `apm pack -o` then writes outside the tree the EXIT
|
||||
@@ -963,9 +1049,46 @@ check_all_fails_with "unparseable JSON" \
|
||||
check_all_fails_with "a plugins field that is not an array" \
|
||||
'{"plugins":{"a":1}}' \
|
||||
"expected an array of plugin entries"
|
||||
check_all_fails_with "a JSON array at the marketplace root" \
|
||||
'[]' \
|
||||
"is a JSON array at its top level"
|
||||
check_all_fails_with "an entry with no source field" \
|
||||
'{"plugins":[{"name":"orphan"}]}' \
|
||||
"entries with no \`source\` field"
|
||||
"\`source\` is neither a local path string nor a remote source object"
|
||||
# The guard used to name `.source == null` specifically, so every other malformed
|
||||
# value walked straight through it into the same silence.
|
||||
check_all_fails_with "an entry whose source is a number" \
|
||||
'{"plugins":[{"name":"orphan","source":42}]}' \
|
||||
"\`source\` is neither a local path string nor a remote source object"
|
||||
check_all_fails_with "an entry whose source is an array" \
|
||||
'{"plugins":[{"name":"orphan","source":[]}]}' \
|
||||
"\`source\` is neither a local path string nor a remote source object"
|
||||
|
||||
# --- 28b. --all outside a git worktree refuses instead of guessing $PWD ---
|
||||
# --all's entire work list hangs off REPO_ROOT, so a `|| pwd` fallback lets it derive
|
||||
# that list from a marketplace.json belonging to some other tree. Same reasoning
|
||||
# scripts/sync-marketplace-mirror.sh dropped its own fallback on. Run from a directory
|
||||
# with no marketplace.json the old form happened to hit the "--all requires ..." error,
|
||||
# but only by accident — the dangerous case is a $PWD that HAS one.
|
||||
echo ""
|
||||
echo "--- --all outside a git worktree refuses to guess the repository root ---"
|
||||
NOGIT="$(mktemp -d)"; track "$NOGIT"
|
||||
mkdir -p "$NOGIT/.claude-plugin"
|
||||
printf '%s' '{"plugins":[{"name":"decoy","source":"./plugins/decoy"}]}' > "$NOGIT/.claude-plugin/marketplace.json"
|
||||
if (cd "$NOGIT" && env -u GIT_DIR -u GIT_WORK_TREE git rev-parse --show-toplevel) >/dev/null 2>&1; then
|
||||
fail "fixture precondition: $NOGIT is inside a git worktree, so this case cannot test the no-worktree path"
|
||||
else
|
||||
RC28B=0
|
||||
OUT28B="$(cd "$NOGIT" && env -u GIT_DIR -u GIT_WORK_TREE bash "$SCRIPT" --check --all 2>&1)" || RC28B=$?
|
||||
case "$RC28B:$OUT28B" in
|
||||
0:*)
|
||||
fail "--check --all exited 0 outside a worktree, having derived its plugin list from \$PWD" ;;
|
||||
*"not inside a git worktree"*)
|
||||
pass "--all refuses to guess \$PWD when it cannot locate the repository root" ;;
|
||||
*)
|
||||
fail "--check --all exited $RC28B outside a worktree but not for the stated reason: $OUT28B" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
|
||||
Reference in New Issue
Block a user