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
111 lines
5.9 KiB
Bash
111 lines
5.9 KiB
Bash
#!/usr/bin/env bash
|
|
# Shared helper: enumerates the local (string-source) plugin entries declared in
|
|
# .claude-plugin/marketplace.json. Sourced by scripts/sync-plugin-content.sh
|
|
# (--all) and scripts/check-manifests.sh so a future marketplace.json schema
|
|
# change (e.g. a new source type) only has to be handled in one place instead
|
|
# of drifting between two hand-maintained copies of the same walk.
|
|
#
|
|
# Requires jq. Not meant to be executed directly -- source it.
|
|
|
|
# assert_marketplace_manifest_usable <marketplace_json_path>
|
|
#
|
|
# Checks the preconditions list_marketplace_local_plugins depends on but cannot
|
|
# report on. Both callers run the walk inside a process substitution
|
|
# (`done < <(list_marketplace_local_plugins ...)`), which is its own subshell: a
|
|
# jq abort in there kills only that subshell, so an unparseable manifest yields
|
|
# zero lines and reads exactly like "this marketplace declares no local plugins".
|
|
# The caller then blames whatever its empty-set branch blames -- for
|
|
# check-manifests.sh, every plugin directory on disk being unlisted.
|
|
#
|
|
# It also rejects an entry whose `source` is neither a local path string nor a
|
|
# remote source object. Only those two shapes are classifiable: the walk below
|
|
# takes the string ones, and the object ones are remote. Anything else -- absent
|
|
# (`null`), a number, an array, a boolean -- is neither, so it silently drops out
|
|
# of every marketplace-derived work list: this walk's and, through it,
|
|
# sync-plugin-content.sh --all's.
|
|
#
|
|
# The check is deliberately typed as "not string AND not object" rather than
|
|
# enumerating `.source == null`. Rejecting null specifically left every other
|
|
# malformed value (`"source": 42`, `"source": []`) passing the assert, skipped by
|
|
# the walk below, AND rescued by check-manifests.sh's disk -> marketplace name
|
|
# axis -- i.e. exactly the defect the null case was fixed for, reached with a
|
|
# different value.
|
|
#
|
|
# Exits 1 with a specific message on any violation, so call it from the caller's
|
|
# own shell -- never inside `< <(...)`, which is the exact swallowing this guards.
|
|
assert_marketplace_manifest_usable() {
|
|
local marketplace="$1" root_type plugins_type unclassifiable
|
|
|
|
if ! jq empty "$marketplace" >/dev/null 2>&1; then
|
|
echo "Error: $marketplace is not valid JSON -- every marketplace-derived check reads as \"no plugins declared\" until it parses. Fix it, or recompile it with \`apm pack\`." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# `jq empty` passes on any valid JSON document, including `[]`, `"x"` and `123`.
|
|
# The `.plugins` lookup on the next line then aborts with a raw
|
|
# `jq: error: Cannot index array with string "plugins"` and rc=5, attributed to
|
|
# nothing -- so assert the root shape here, where it can be named.
|
|
root_type="$(jq -r 'type' "$marketplace")"
|
|
if [[ "$root_type" != "object" ]]; then
|
|
echo "Error: $marketplace is a JSON $root_type at its top level; expected an object with a \`plugins\` array. Recompile it with \`apm pack\`." >&2
|
|
exit 1
|
|
fi
|
|
|
|
plugins_type="$(jq -r '.plugins | type' "$marketplace")"
|
|
if [[ "$plugins_type" != "array" && "$plugins_type" != "null" ]]; then
|
|
echo "Error: $marketplace has a \`plugins\` field of type $plugins_type; expected an array of plugin entries." >&2
|
|
exit 1
|
|
fi
|
|
|
|
unclassifiable="$(jq -r '[.plugins[]?
|
|
| select((.source | type) as $t | $t != "string" and $t != "object")
|
|
| "\(.name // "<unnamed>") (source: \(.source | type))"] | join(", ")' "$marketplace")"
|
|
if [[ -n "$unclassifiable" ]]; then
|
|
echo "Error: $marketplace has entries whose \`source\` is neither a local path string nor a remote source object: $unclassifiable. Such an entry is neither local nor remote, so it is skipped by every marketplace-derived check while still claiming its name. Fix it in root apm.yml's marketplace.packages[] and recompile." >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# list_marketplace_local_plugins <repo_root> <marketplace_json_path>
|
|
#
|
|
# Prints one "<name>\t<absolute_plugin_dir>" line per local (string `source:`)
|
|
# marketplace entry. Remote sources (github/git/npm objects) have no local
|
|
# directory to walk and are skipped, matching both callers' prior behavior.
|
|
list_marketplace_local_plugins() {
|
|
local repo_root="$1" marketplace="$2"
|
|
local plugin_count i name source_type source
|
|
|
|
plugin_count="$(jq '.plugins | length' "$marketplace")"
|
|
for ((i = 0; i < plugin_count; i++)); do
|
|
source_type="$(jq -r ".plugins[$i].source | type" "$marketplace")"
|
|
[[ "$source_type" == "string" ]] || continue
|
|
name="$(jq -r ".plugins[$i].name" "$marketplace")"
|
|
source="$(jq -r ".plugins[$i].source" "$marketplace")"
|
|
source="${source#./}"
|
|
printf '%s\t%s\n' "$name" "$repo_root/$source"
|
|
done
|
|
}
|
|
|
|
# list_marketplace_remote_plugin_names <marketplace_json_path>
|
|
#
|
|
# Prints the `name` of every REMOTE (object `source:`) marketplace entry, one per
|
|
# line -- the exact complement of list_marketplace_local_plugins.
|
|
#
|
|
# check-manifests.sh's disk -> marketplace pass uses it as its name axis: a plugin
|
|
# vendored on disk but declared with the remote-object shape has no local entry to
|
|
# path-match against, so without a name match it would be reported as unlisted when
|
|
# its entry is in fact right there.
|
|
#
|
|
# The select is `(.source | type) == "object"`, an allowlist of the one shape that
|
|
# axis is actually for -- NOT the denylist `(.source | type) != "string"` it used to
|
|
# be. That denylist was true for `null` (and for numbers, arrays, booleans), so a
|
|
# malformed entry marked its same-named directory "listed" while the local walk above
|
|
# skipped it for lacking a string source: one bad entry disabled BOTH directions of
|
|
# the check at once. assert_marketplace_manifest_usable rejects those shapes too, but
|
|
# this function must be correct on its own -- it is called from a different script,
|
|
# and a precondition that stops running is not a property of this select.
|
|
list_marketplace_remote_plugin_names() {
|
|
local marketplace="$1"
|
|
jq -r '.plugins[]? | select((.source | type) == "object") | .name // empty' "$marketplace"
|
|
}
|