Files
omarchycn/bin/omarchy-plugin-validate
David Heinemeier HanssonandGitHub f8835df644 Plugin cloning via menu (#6433)
* Add plugin cloning via menu

* Split plugin commands by action

* Keep plugin enablement in action commands

* Remove unused plugin edit command

* Simplify plugin rescan arguments

* Assume Omarchy shell is running for plugin commands

* Remove plugin compatibility dispatcher

* Flatten plugin clone command

* Keep only shared plugin helpers

* Remove plugin rescan wrapper

* Keep plugin commands self-contained

* Simplify plugin clone lifecycle
2026-07-29 21:34:47 -04:00

119 lines
5.1 KiB
Bash
Executable File

#!/bin/bash
# omarchy:summary=Validate a plugin folder against the Omarchy plugin manifest schema
# omarchy:group=plugin
# omarchy:args=<plugin-folder>
# omarchy:examples=omarchy plugin validate ./my-plugin
# Mirrors the checks in shell/services/PluginRegistry.qml so the CLI refuses to
# install anything the running shell would silently reject (or worse, load
# unsafely). The shell never trusts manifests blindly; neither do we.
set -o pipefail
fail() {
echo "omarchy-plugin-validate: $*" >&2
exit 1
}
if [[ ${1:-} == -h || ${1:-} == --help ]]; then
cat <<USAGE
Usage: omarchy plugin validate <plugin-folder>
Checks a plugin folder's manifest.json against the schema the shell enforces:
schemaVersion, required fields, safe relative entry points that exist, an entry
point for each kind that needs one, no symlinks, and an id that is not reserved.
Exits 0 if valid — handy for plugin authors before publishing.
USAGE
exit 0
fi
PLUGIN_DIR="${1:-}"
[[ -n $PLUGIN_DIR && -d $PLUGIN_DIR ]] || fail "plugin folder not found: ${PLUGIN_DIR:-<none>}"
MANIFEST="$PLUGIN_DIR/manifest.json"
[[ -f $MANIFEST ]] || fail "missing manifest.json in $PLUGIN_DIR"
jq -e . "$MANIFEST" >/dev/null 2>&1 || fail "manifest.json is not valid JSON: $MANIFEST"
# schemaVersion must be exactly the JSON number 1 (the only version the registry
# understands). jq's == is type-aware, so the string "1" is correctly rejected
# just as the QML `schemaVersion !== 1` check rejects it.
jq -e '.schemaVersion == 1' "$MANIFEST" >/dev/null 2>&1 \
|| fail "unsupported or missing schemaVersion (expected 1) in $MANIFEST"
for field in id name version kinds entryPoints; do
jq -e --arg f "$field" 'has($f)' "$MANIFEST" >/dev/null 2>&1 \
|| fail "manifest missing required field '$field'"
done
ID=$(jq -r '.id // ""' "$MANIFEST")
[[ -n $ID ]] || fail "manifest 'id' is empty"
[[ $ID =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || fail "invalid plugin id '$ID'"
[[ $ID != *".."* ]] || fail "invalid plugin id '$ID'"
[[ $ID != omarchy.* ]] || fail "plugin id '$ID' uses the reserved omarchy.* namespace"
# kinds must be a non-empty array.
jq -e '(.kinds | type) == "array" and (.kinds | length) > 0' "$MANIFEST" >/dev/null 2>&1 \
|| fail "'kinds' must be a non-empty array"
# entryPoints must be an object and every value a safe relative path that exists.
jq -e '(.entryPoints | type) == "object"' "$MANIFEST" >/dev/null 2>&1 \
|| fail "'entryPoints' must be an object"
# A bar widget may declare where it should land when enabled without an
# explicit placement.
jq -e '
if ((.barWidget? | type) == "object" and (.barWidget | has("defaultSection"))) then
.barWidget.defaultSection as $section
| ($section | type) == "string"
and (["left", "center", "right"] | index($section)) != null
else
true
end
' "$MANIFEST" >/dev/null 2>&1 \
|| fail "'barWidget.defaultSection' must be left, center, or right"
# Read each entry point as a JSON-encoded string (one per line), then decode it,
# so a value that itself contains a newline stays one literal path instead of
# being split into fragments that each pass the checks.
while IFS= read -r ep_json; do
[[ -n $ep_json ]] || continue
ep=$(jq -r '.' <<<"$ep_json")
[[ -n $ep ]] || fail "entry point path is empty"
[[ $ep != *$'\n'* ]] || fail "entry point may not contain a newline"
[[ $ep != /* ]] || fail "entry point must be a relative path: '$ep'"
[[ $ep != *".."* ]] || fail "entry point may not contain '..': '$ep'"
[[ -f "$PLUGIN_DIR/$ep" ]] || fail "entry point file not found: '$ep'"
done < <(jq -c '.entryPoints | to_entries[] | .value' "$MANIFEST")
# A kind is a promise to supply something to load, and the shell looks for that
# something under a fixed key: entryPoints.bar to draw a bar, entryPoints.menu
# to open a menu, and so on. Claiming a kind without its entry point is accepted
# everywhere else -- the bar falls back to the built-in, the widget is skipped --
# leaving a plugin that installs, enables, and does nothing, explained only by a
# line on the shell's console. Refuse it here, while there is still someone to
# tell. First-party plugins are held to the same table in plugins-test.sh; a kind
# not listed is left alone rather than guessed at.
for kind_entry_point in \
"bar:bar" \
"bar-widget:barWidget" \
"menu:menu" \
"overlay:overlay" \
"panel:panel" \
"service:service"; do
kind="${kind_entry_point%%:*}"
entry_point="${kind_entry_point##*:}"
jq -e --arg kind "$kind" '(.kinds | index($kind)) != null' "$MANIFEST" >/dev/null 2>&1 || continue
jq -e --arg ep "$entry_point" '.entryPoints | has($ep)' "$MANIFEST" >/dev/null 2>&1 \
|| fail "kind '$kind' requires an 'entryPoints.$entry_point' to load"
done
# Refuse any symlink anywhere inside the plugin folder. Symlinks could point a
# copied plugin back at arbitrary files on disk after it lands in the trusted
# plugins directory. The .git dir is skipped: installed plugins are git
# checkouts, and git's internals are never loaded by the shell.
link=$(find "$PLUGIN_DIR" -name .git -prune -o -type l -print -quit 2>/dev/null)
[[ -z $link ]] || fail "symlinks are not allowed inside a plugin folder: $link"
exit 0