diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cf3411f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,152 @@ +name: CI + +# This repository has no build and no package manager. CI is therefore the +# lint/validate proxies documented in AGENTS.md, plus the sync-local regression +# suite, run on every push and pull request. +# +# Scope note: the repo-root config files synced from melodic-software/standards +# (.editorconfig, .gitattributes, _typos.toml, lychee.toml, .gitleaks.toml, +# .markdownlint-cli2.jsonc, .editorconfig-checker.json) are validated by their +# own tooling upstream and are deliberately not re-linted here. + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + shell: + name: Shell (shellcheck + regression suite) + runs-on: ubuntu-latest + steps: + # actions/checkout v5.1.0 — pinned to a full-length commit SHA because this + # org requires it; a tag or short SHA is rejected at "Set up job". + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + + - name: Install shellcheck + run: sudo apt-get update && sudo apt-get install -y shellcheck + + - name: shellcheck (must be zero findings) + run: shellcheck scripts/*.sh + + - name: bash -n (syntax floor) + run: for f in scripts/*.sh; do bash -n "$f"; done + + # pwsh is preinstalled on ubuntu-latest, so the suite exercises BOTH twins + # here and asserts their output is byte-identical. + - name: sync-local regression suite + run: bash scripts/test-sync-local.sh + + powershell: + name: PowerShell (PSScriptAnalyzer) + runs-on: ubuntu-latest + steps: + # actions/checkout v5.1.0 — pinned to a full-length commit SHA because this + # org requires it; a tag or short SHA is rejected at "Set up job". + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + + - name: Install PSScriptAnalyzer + shell: pwsh + run: | + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module PSScriptAnalyzer -Scope CurrentUser -Force + + # Suppressions in sync-local.ps1 are deliberate and each carries a written + # Justification; -SuppressedOnly is printed so a reviewer can see what is + # being suppressed rather than having it silently disappear. + - name: PSScriptAnalyzer (must be zero unsuppressed findings) + shell: pwsh + run: | + $suppressed = Invoke-ScriptAnalyzer -Path ./scripts/sync-local.ps1 -SuppressedOnly + if ($suppressed) { + Write-Host "Deliberately suppressed (each carries a Justification):" + $suppressed | Format-Table RuleName, @{n='Count';e={1}} -AutoSize | Out-String | Write-Host + } + $findings = Invoke-ScriptAnalyzer -Path ./scripts/sync-local.ps1 + if ($findings) { + $findings | Format-Table Severity, RuleName, Line, Message -AutoSize -Wrap | Out-String | Write-Host + throw "PSScriptAnalyzer reported $($findings.Count) finding(s)." + } + Write-Host "PSScriptAnalyzer: 0 findings." + + manifests: + name: Manifests (JSON + official Cursor schemas) + runs-on: ubuntu-latest + steps: + # actions/checkout v5.1.0 — pinned to a full-length commit SHA because this + # org requires it; a tag or short SHA is rejected at "Set up job". + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + + - name: jq — manifests are well-formed JSON + run: | + jq empty .cursor-plugin/marketplace.json + for f in plugins/*/.cursor-plugin/plugin.json; do jq empty "$f"; done + + - name: Skill frontmatter — each SKILL.md opens with a YAML block + run: | + status=0 + for f in plugins/*/skills/*/SKILL.md; do + if [ "$(head -1 "$f")" != "---" ]; then + echo "::error file=$f::SKILL.md must start with a --- YAML frontmatter block" + status=1 + fi + done + exit $status + + # Validated against Cursor's PUBLISHED schemas, fetched at run time. These + # are the authority: Cursor's prose reference and its schemas disagree in + # both directions, so a prose-derived checklist would pass files the + # schema rejects. A fetch failure fails the job rather than skipping the + # check silently. + - name: Validate manifests against the official schemas + run: | + set -euo pipefail + npm init -y >/dev/null 2>&1 + npm install --no-fund --no-audit --silent ajv@8 ajv-formats + base=https://raw.githubusercontent.com/cursor/plugins/main/schemas + curl -fsS "$base/marketplace.schema.json" -o marketplace.schema.json + curl -fsS "$base/plugin.schema.json" -o plugin.schema.json + node scripts/validate-manifests.mjs + + links: + name: Docs (internal link integrity) + runs-on: ubuntu-latest + steps: + # actions/checkout v5.1.0 — pinned to a full-length commit SHA because this + # org requires it; a tag or short SHA is rejected at "Set up job". + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + + # Relative links only. External URLs are deliberately NOT checked here: + # GitHub and cursor.directory return 403/429 to CI runners, which would + # make this job fail for reasons that say nothing about the repository. + # + # Inline code spans are stripped BEFORE extracting links. Without that, a + # regex inside backticks parses as link syntax and reports a false + # positive: the plugin-name pattern `^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$` + # looks exactly like ](...) to a naive matcher. + - name: Every relative markdown link resolves on disk + run: | + status=0 + while IFS= read -r src; do + dir=$(dirname "$src") + while IFS= read -r link; do + case "$link" in http*|mailto:*|'') continue ;; esac + target=${link%%#*} + [ -z "$target" ] && continue + if [ ! -e "$dir/$target" ]; then + echo "::error file=$src::broken relative link -> $link" + status=1 + fi + done < <(sed 's/`[^`]*`//g' "$src" | + grep -oE '\]\([^)#][^)]*\)' | + sed 's/^](//; s/)$//') + done < <(git ls-files '*.md') + exit $status diff --git a/.shellcheckrc b/.shellcheckrc index 1d004dc..41ae405 100644 --- a/.shellcheckrc +++ b/.shellcheckrc @@ -1,7 +1,8 @@ # ShellCheck configuration for this repository. # -# Scope: scripts/*.sh. Run `shellcheck scripts/sync-local.sh`; the default rule -# set must stay at zero findings. +# Scope: scripts/*.sh. Run `shellcheck scripts/*.sh`; the default rule set must +# stay at zero findings across every script in that directory. Site counts quoted +# below were measured against scripts/sync-local.sh, the largest of them. # # Nothing is disabled here. ShellCheck's default rules are all in force, and no # `# shellcheck disable=` directive appears anywhere in the tree. This file exists diff --git a/AGENTS.md b/AGENTS.md index 457d69d..3d6fb1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,9 +10,22 @@ and the `scripts/sync-local.*` sync tooling. See `README.md` and ### Environment / dependencies -- There is **no package manager, build step, or test framework**. Nothing needs to be - installed to work here — the required tools (`bash`, `git`, `python3`, `jq`) are all - present in the base image, so the startup update script is a no-op. +- There is **no package manager and no build step**. The tools the repo's own scripts + need at runtime (`bash`, `git`, `python3`, `jq`) are all present in the base image, so + the startup update script is a no-op. +- There **is** a test suite: `bash scripts/test-sync-local.sh`. See "Test" below. +- The lint and test tooling is **not** all preinstalled. Probe with `command -v` and + install what is missing; never assume: + + | Tool | Needed for | If missing | + | --- | --- | --- | + | `shellcheck` | linting `scripts/*.sh` | `sudo apt-get install -y shellcheck` | + | `pwsh` | running/testing `sync-local.ps1` | see the caveat below — the `.sh` twin covers Linux | + | `PSScriptAnalyzer` | linting `sync-local.ps1` | `pwsh -c "Install-Module PSScriptAnalyzer -Scope CurrentUser -Force"` | + | `node` + `ajv` | validating manifests against Cursor's schemas | `npm install ajv@8 ajv-formats` | + + Only `bash`, `git`, `python3` and `jq` are relied on at runtime; everything in that + table is for checking the repo, not for using it. ### Lint / validate (there is no configured linter — use these proxies) @@ -29,6 +42,31 @@ and the `scripts/sync-local.*` sync tooling. See `README.md` and - JSON manifests: `jq empty .cursor-plugin/marketplace.json` and `jq empty plugins/*/.cursor-plugin/plugin.json` - Skill frontmatter: each `plugins/*/skills/*/SKILL.md` must start with a `---` YAML block. +- PowerShell: `pwsh -c "Invoke-ScriptAnalyzer -Path ./scripts/sync-local.ps1"` must report + **zero** findings. `sync-local.ps1` carries two file-level + `SuppressMessageAttribute` entries, each with a written Justification; add + `-SuppressedOnly` to see them. Do not add a suppression without one. +- Manifest **schemas**: `jq empty` only proves the JSON parses. Cursor publishes real + schemas, and its prose reference disagrees with them **in both directions** — the prose + lists plugin-entry fields the schema forbids, and marks `owner` required where the + schema does not. The schema wins. CI fetches both and runs + `node scripts/validate-manifests.mjs`; run it the same way locally. + +### Test + +- `bash scripts/test-sync-local.sh` — the sync-local regression suite. Every case runs + **both** twins against the same fixture and asserts the same exit code and + byte-identical stdout, because a divergence between documented parity twins is itself a + defect. It builds its fixtures under `$TMPDIR` and redirects `HOME` per case, so it + never touches your real `~/.cursor`. +- `pwsh` is optional: without it the PowerShell half reports as skipped and the bash half + still runs. +- Add a case whenever you fix a defect here. The suite already pins the ones that were + found the hard way: path traversal via a plugin `name` and via an entry `source`, a + `pluginRoot` that escapes the repo, an empty `plugins/` and `"plugins": []` (which used + to exit 0 printing `Synced (0)`), a nameless marketplace entry (which used to dump a + Python traceback), prefix-name sort order, and a symlinked plugin directory (which must + install as a **real** directory, not a link). ### Run (the "application") @@ -56,6 +94,19 @@ and the `scripts/sync-local.*` sync tooling. See `README.md` and that date". Never bump one without fetching. A 403/429 is a blocked fetch, not a verification and not a dead link — record it as blocked and leave the old date. +### CI + +`.github/workflows/ci.yml` runs the checks above on every push and pull request: shell +lint plus the regression suite, PSScriptAnalyzer, JSON and schema validation of the +manifests, and internal markdown link integrity. It deliberately does **not** check +external URLs — GitHub and cursor.directory answer CI runners with 403/429, which would +fail the build for reasons that say nothing about the repository. Note that GitHub's +runners preinstall `pwsh`, so CI exercises both twins even though a Cursor Cloud box may +not. + +The repo-root files synced from `melodic-software/standards` are not re-linted here; they +are validated upstream, and this repo must not hand-edit them. + ### Non-obvious caveats - The PowerShell twin `scripts/sync-local.ps1` requires `pwsh` (PowerShell), which is diff --git a/scripts/test-sync-local.sh b/scripts/test-sync-local.sh new file mode 100755 index 0000000..0c0d5dc --- /dev/null +++ b/scripts/test-sync-local.sh @@ -0,0 +1,276 @@ +#!/usr/bin/env bash +# Regression suite for the sync-local parity twins. +# +# Every case runs BOTH scripts/sync-local.sh and scripts/sync-local.ps1 against +# the same fixture and asserts the same exit code and the same stdout, because +# the two are documented parity twins and a divergence between them is itself a +# defect. Only stdout is compared: PowerShell wraps stderr in its own exception +# frame, so the twins' error *text* matches while their error *framing* does not. +# +# pwsh is optional. When it is absent the PowerShell half is reported as skipped +# and the bash half still runs, so this suite is useful on a machine that only +# has bash. +# +# Usage: bash scripts/test-sync-local.sh +# Exit: 0 all assertions passed, 1 otherwise. +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +sh_script="$repo_root/scripts/sync-local.sh" +ps_script="$repo_root/scripts/sync-local.ps1" + +have_pwsh=0 +if command -v pwsh >/dev/null 2>&1; then have_pwsh=1; fi + +pass=0 +fail=0 +skip=0 +failed_names=() + +work="$(mktemp -d "${TMPDIR:-/tmp}/sync-local-tests.XXXXXX")" +trap 'rm -rf "$work"' EXIT + +# --- assertions --------------------------------------------------------------- + +ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; } + +not_ok() { + fail=$((fail + 1)) + failed_names+=("$1") + printf ' FAIL %s\n' "$1" + shift + while [[ $# -gt 0 ]]; do printf ' %s\n' "$1"; shift; done +} + +# run_sh / run_ps write stdout to $out_file and return the exit code. HOME is +# redirected per call so a test never touches the real ~/.cursor. +run_sh() { + local home="$1" out="$2" + shift 2 + local rc=0 + HOME="$home" bash "$sh_script" "$@" >"$out" 2>"$out.err" || rc=$? + return "$rc" +} + +run_ps() { + local home="$1" out="$2" + shift 2 + local rc=0 + env -u USERPROFILE HOME="$home" pwsh -NoProfile -File "$ps_script" "$@" \ + >"$out" 2>"$out.err" || rc=$? + return "$rc" +} + +# Normalize the sandbox HOME out of the output so the two twins are comparable. +normalize() { sed "s|$1||g" "$2"; } + +# assert_twins -- +# Asserts both twins exit with and produce identical stdout. +assert_twins() { + local name="$1" want="$2" + shift 2 + local sh_args=() ps_args=() seen=0 + while [[ $# -gt 0 ]]; do + if [[ "$1" == "--" ]]; then seen=1; shift; continue; fi + if [[ "$seen" -eq 0 ]]; then sh_args+=("$1"); else ps_args+=("$1"); fi + shift + done + + local h1 h2 o1 o2 rc1 rc2 + h1="$work/h.$name.sh"; h2="$work/h.$name.ps" + o1="$work/o.$name.sh"; o2="$work/o.$name.ps" + mkdir -p "$h1" "$h2" + + rc1=0; run_sh "$h1" "$o1" "${sh_args[@]}" || rc1=$? + if [[ "$rc1" -ne "$want" ]]; then + not_ok "$name (sh exit)" "expected $want, got $rc1" "$(head -3 "$o1" "$o1.err" 2>/dev/null)" + else + ok "$name (sh exit $want)" + fi + + if [[ "$have_pwsh" -eq 0 ]]; then + skip=$((skip + 1)); printf ' skip %s (pwsh not installed)\n' "$name" + return 0 + fi + + rc2=0; run_ps "$h2" "$o2" "${ps_args[@]}" || rc2=$? + if [[ "$rc2" -ne "$want" ]]; then + not_ok "$name (ps1 exit)" "expected $want, got $rc2" "$(head -3 "$o2" "$o2.err" 2>/dev/null)" + else + ok "$name (ps1 exit $want)" + fi + + if diff <(normalize "$h1" "$o1") <(normalize "$h2" "$o2") >"$work/d.$name" 2>&1; then + ok "$name (twins byte-identical)" + else + not_ok "$name (twin parity)" "$(head -8 "$work/d.$name")" + fi +} + +# assert_contains +assert_contains() { + if grep -qF -- "$3" "$2"; then ok "$1"; else + not_ok "$1" "expected to find: $3" "got: $(head -3 "$2")" + fi +} + +# assert_absent +assert_absent() { + if grep -qF -- "$3" "$2"; then + not_ok "$1" "should NOT contain: $3" "got: $(head -5 "$2")" + else ok "$1"; fi +} + +# --- fixtures ----------------------------------------------------------------- + +mkplugin() { # mkplugin + mkdir -p "$1/.cursor-plugin" + printf '{ "name": "%s" }\n' "$2" >"$1/.cursor-plugin/plugin.json" +} + +mkmarket() { # mkmarket [pluginRoot] + mkdir -p "$1/.cursor-plugin" + printf '{ "name": "fx", "metadata": { "pluginRoot": "%s" }, "plugins": [%s] }\n' \ + "${3:-plugins}" "$2" >"$1/.cursor-plugin/marketplace.json" +} + +# 1. a well-formed marketplace with two plugins +f_ok="$work/f_ok" +mkmarket "$f_ok" '{"name":"alpha","source":"alpha"},{"name":"beta","source":"beta"}' +mkplugin "$f_ok/plugins/alpha" alpha +mkplugin "$f_ok/plugins/beta" beta + +# 2. empty plugins/ directory, no marketplace.json +f_empty="$work/f_empty"; mkdir -p "$f_empty/plugins" + +# 3. marketplace declaring no plugins at all +f_noplugins="$work/f_noplugins"; mkmarket "$f_noplugins" '' + +# 4. a plugin name that tries to escape the destination root +f_badname="$work/f_badname" +mkmarket "$f_badname" '{"name":"../../pwned","source":"alpha"},{"name":"alpha","source":"alpha"}' +mkplugin "$f_badname/plugins/alpha" alpha + +# 5. a plugin source that tries to escape the plugin root +f_badsrc="$work/f_badsrc" +mkmarket "$f_badsrc" '{"name":"escapee","source":"../../../etc"},{"name":"alpha","source":"alpha"}' +mkplugin "$f_badsrc/plugins/alpha" alpha + +# 6. a pluginRoot that escapes the source repo +f_badroot="$work/f_badroot"; mkmarket "$f_badroot" '{"name":"a","source":"a"}' '../../..' + +# 7. a marketplace entry with no name key at all +f_noname="$work/f_noname"; mkmarket "$f_noname" '{"source":"alpha"}' +mkplugin "$f_noname/plugins/alpha" alpha + +# 8. plugins/ fallback layout containing a SYMLINK to a plugin outside plugins/ +f_link="$work/f_link" +mkplugin "$f_link/plugins/real" real +mkplugin "$f_link/vendor/linked" linked +ln -s ../vendor/linked "$f_link/plugins/linked" + +# 9. prefix-related names, to pin enumeration order +f_sort="$work/f_sort" +for n in ai ai-briefing docs docs-hygiene; do mkplugin "$f_sort/plugins/$n" "$n"; done + +# 10. a single-plugin repo +f_single="$work/f_single"; mkplugin "$f_single" solo + +# --- cases -------------------------------------------------------------------- + +printf 'sync-local regression suite\n' +printf 'bash: %s\n' "$(bash --version | head -1)" +if [[ "$have_pwsh" -eq 1 ]]; then + printf 'pwsh: %s\n\n' "$(pwsh --version)" +else + printf 'pwsh: NOT INSTALLED (PowerShell half will be skipped)\n\n' +fi + +printf 'happy path\n' +assert_twins repo-dry-run 0 --dry-run "$repo_root" -- -Source "$repo_root" -DryRun +assert_twins marketplace 0 --dry-run "$f_ok" -- -Source "$f_ok" -DryRun + +printf 'failures must not look like success\n' +assert_twins missing-source 1 --dry-run "$work/does-not-exist" -- -Source "$work/does-not-exist" -DryRun +assert_twins empty-plugins-dir 1 --dry-run "$f_empty" -- -Source "$f_empty" -DryRun +assert_twins empty-plugins-array 1 --dry-run "$f_noplugins" -- -Source "$f_noplugins" -DryRun +assert_twins nameless-entry 1 --dry-run "$f_noname" -- -Source "$f_noname" -DryRun +assert_twins plugin-root-escape 1 --dry-run "$f_badroot" -- -Source "$f_badroot" -DryRun +assert_twins single-filter-miss 1 --dry-run "$f_single" nomatch -- -Source "$f_single" -Plugin nomatch -DryRun + +printf 'traversal is refused on both sides\n' +assert_twins name-escape 0 --dry-run "$f_badname" -- -Source "$f_badname" -DryRun +assert_twins source-escape 0 --dry-run "$f_badsrc" -- -Source "$f_badsrc" -DryRun + +printf 'enumeration\n' +assert_twins symlinked-plugin 0 --dry-run "$f_link" -- -Source "$f_link" -DryRun +assert_twins prefix-sort-order 0 --dry-run "$f_sort" -- -Source "$f_sort" -DryRun + +# --- content assertions ------------------------------------------------------- + +printf 'output content\n' +h="$work/h.content"; mkdir -p "$h" + +o="$work/c.badname"; run_sh "$h" "$o" --dry-run "$f_badname" || true +assert_contains "escaping name is skipped, not synced" "$o" '(invalid plugin name)' +assert_absent "escaping name never reaches the sync list" "$o" 'Would sync (2)' + +o="$work/c.badsrc"; run_sh "$h" "$o" --dry-run "$f_badsrc" || true +assert_contains "escaping source is skipped" "$o" '(source escapes plugin root)' +assert_contains "the sibling plugin still syncs" "$o" 'alpha' + +o="$work/c.noname"; run_sh "$h" "$o" --dry-run "$f_noname" || true +assert_absent "a nameless entry does not raise a Python traceback" "$o.err" 'Traceback' +assert_absent "a nameless entry does not raise KeyError" "$o.err" 'KeyError' + +o="$work/c.sort"; run_sh "$h" "$o" --dry-run "$f_sort" || true +assert_contains "prefix names keep basename sort order" "$o" 'ai, ai-briefing, docs, docs-hygiene' + +o="$work/c.dry"; run_sh "$h" "$o" --dry-run "$f_ok" || true +assert_contains "a dry run says it would sync" "$o" 'Would sync' +assert_absent "a dry run never claims it synced" "$o" 'Synced (' + +# --ref with no value is bash-only: pwsh's parameter binder owns that path. +rc=0; run_sh "$h" "$work/c.ref" --dry-run "$f_ok" --ref || rc=$? +if [[ "$rc" -eq 2 ]]; then ok "--ref with no value exits 2"; else + not_ok "--ref with no value" "expected exit 2, got $rc" +fi +assert_contains "--ref with no value explains itself" "$work/c.ref.err" 'Missing value for --ref' + +# --- real (non-dry) sync: the destination must be a real directory ------------ + +printf 'real sync\n' +h="$work/h.real"; mkdir -p "$h" +run_sh "$h" "$work/r.link" "$f_link" || true +linked="$h/.cursor/plugins/local/linked" +if [[ -L "$linked" ]]; then + not_ok "a symlinked plugin installs as a real directory" "installed a symlink instead" +elif [[ -f "$linked/.cursor-plugin/plugin.json" ]]; then + ok "a symlinked plugin installs as a real directory" +else + not_ok "a symlinked plugin installs as a real directory" "destination missing or empty" +fi + +if [[ "$have_pwsh" -eq 1 ]]; then + h="$work/h.real.ps"; mkdir -p "$h" + run_ps "$h" "$work/r.link.ps" -Source "$f_link" || true + linked="$h/.cursor/plugins/local/linked" + if [[ -L "$linked" ]]; then + not_ok "pwsh: a symlinked plugin installs as a real directory" "installed a symlink" + elif [[ -f "$linked/.cursor-plugin/plugin.json" ]]; then + ok "pwsh: a symlinked plugin installs as a real directory" + else + not_ok "pwsh: a symlinked plugin installs as a real directory" "destination missing" + fi +fi + +# --- summary ------------------------------------------------------------------ + +printf '\n%s\n' "----------------------------------------" +printf 'passed %d, failed %d, skipped %d\n' "$pass" "$fail" "$skip" +if [[ "$fail" -gt 0 ]]; then + printf 'failing:\n' + for n in "${failed_names[@]}"; do printf ' - %s\n' "$n"; done + exit 1 +fi +printf 'all assertions passed\n' diff --git a/scripts/validate-manifests.mjs b/scripts/validate-manifests.mjs new file mode 100644 index 0000000..75e251e --- /dev/null +++ b/scripts/validate-manifests.mjs @@ -0,0 +1,69 @@ +// Validate this repository's Cursor manifests against Cursor's PUBLISHED JSON +// schemas. +// +// The schemas are the authority, not the prose reference. The two disagree in +// both directions: the reference page's "Plugin entry fields" table lists ~16 +// keys that marketplace.schema.json forbids (the plugin entry is +// additionalProperties: false with only name/source/description/ +// minClientVersions), and its "Marketplace manifest fields" table marks `owner` +// required where the schema's required array is only ["name","plugins"]. A +// checklist derived from the prose would therefore pass files a validator +// rejects, and vice versa. +// +// Expects marketplace.schema.json and plugin.schema.json in the working +// directory (CI fetches them at run time so a schema change upstream surfaces +// here rather than going unnoticed). +// +// Usage: node scripts/validate-manifests.mjs +// Exit: 0 all manifests valid, 1 otherwise. + +import { readFileSync, existsSync } from "node:fs"; +import { globSync } from "node:fs"; +import Ajv from "ajv"; +import addFormats from "ajv-formats"; + +const readJson = (path) => JSON.parse(readFileSync(path, "utf8")); + +const targets = [ + { schema: "marketplace.schema.json", doc: ".cursor-plugin/marketplace.json" }, + ...globSync("plugins/*/.cursor-plugin/plugin.json").map((doc) => ({ + schema: "plugin.schema.json", + doc, + })), +]; + +let failed = 0; + +for (const { schema, doc } of targets) { + if (!existsSync(schema)) { + console.error(`::error::schema not found: ${schema}`); + failed = 1; + continue; + } + if (!existsSync(doc)) { + console.error(`::error::manifest not found: ${doc}`); + failed = 1; + continue; + } + + // strict:false — Cursor's schemas use keywords Ajv's strict mode rejects; + // that is a property of their authoring, not of our manifests. + const ajv = new Ajv({ strict: false, allErrors: true }); + addFormats(ajv); + + const validate = ajv.compile(readJson(schema)); + if (validate(readJson(doc))) { + console.log(`PASS ${doc} (${schema})`); + continue; + } + + failed = 1; + console.log(`FAIL ${doc} (${schema})`); + for (const e of validate.errors) { + const where = e.instancePath || "/"; + const extra = e.params ? ` ${JSON.stringify(e.params)}` : ""; + console.error(`::error file=${doc}::${where} ${e.message}${extra}`); + } +} + +process.exit(failed);