diff --git a/data/examples/README.md b/data/examples/README.md index f9c7928..9234746 100644 --- a/data/examples/README.md +++ b/data/examples/README.md @@ -15,7 +15,7 @@ When adding a new entry to `data/{category}/`, use the corresponding example as | `data/agents/` | [agent.yaml](agent.yaml) | | `data/projects/` | [project.yaml](project.yaml) | | `data/resources/` | [resource.yaml](resource.yaml) | -| `data/forks/` | [fork.yaml](fork.yaml) | +| `data/projects/` (`kind: fork`) | [fork.yaml](fork.yaml) | ## Required vs Optional Fields diff --git a/data/examples/agent.yaml b/data/examples/agent.yaml index 9ebd7d0..d48fefd 100644 --- a/data/examples/agent.yaml +++ b/data/examples/agent.yaml @@ -16,6 +16,10 @@ description: A custom agent configuration for security-focused code review. - Predefined tools for vulnerability scanning - Skills for OWASP Top 10 detection +kind: agent # plugin, theme, agent, skill, mcp-server, client, tool, config, guide, fork +topics: # 1-3 from the fixed list in data/schema.json; the first is used for README grouping + - orchestration + # ============================================================================ # OPTIONAL FIELDS # ============================================================================ diff --git a/data/examples/fork.yaml b/data/examples/fork.yaml index 31734cf..2753204 100644 --- a/data/examples/fork.yaml +++ b/data/examples/fork.yaml @@ -1,6 +1,6 @@ # Example Fork Entry # Forks are modified builds of OpenCode with custom features or patches. -# Place actual entries in: data/forks/your-fork-name.yaml +# Place actual entries in: data/projects/your-fork-name.yaml # ============================================================================ # REQUIRED FIELDS @@ -17,6 +17,10 @@ description: A fork of OpenCode designed for enterprise environments. - Custom model routing - Air-gapped deployment support +kind: fork # plugin, theme, agent, skill, mcp-server, client, tool, config, guide, fork +topics: # 1-3 from the fixed list in data/schema.json; the first is used for README grouping + - environment + # ============================================================================ # OPTIONAL FIELDS # ============================================================================ diff --git a/data/examples/plugin.yaml b/data/examples/plugin.yaml index 7495182..02d07d8 100644 --- a/data/examples/plugin.yaml +++ b/data/examples/plugin.yaml @@ -15,6 +15,11 @@ description: A longer description explaining the plugin in detail. - Use cases - Key benefits +kind: plugin # plugin, theme, agent, skill, mcp-server, client, tool, config, guide, fork +topics: # 1-3 from the fixed list in data/schema.json; the first is used for README grouping + - workflow +compat: v2 # plugins only: v2, v1 or unknown (CI verifies it) + # ============================================================================ # OPTIONAL FIELDS # ============================================================================ diff --git a/data/examples/project.yaml b/data/examples/project.yaml index 3d1a9dd..0546337 100644 --- a/data/examples/project.yaml +++ b/data/examples/project.yaml @@ -18,6 +18,10 @@ description: A standalone web application for monitoring and managing - Token usage analytics - Multi-project overview +kind: client # plugin, theme, agent, skill, mcp-server, client, tool, config, guide, fork +topics: # 1-3 from the fixed list in data/schema.json; the first is used for README grouping + - sessions + # ============================================================================ # OPTIONAL FIELDS # ============================================================================ diff --git a/data/examples/resource.yaml b/data/examples/resource.yaml index 49f22e1..f6f1417 100644 --- a/data/examples/resource.yaml +++ b/data/examples/resource.yaml @@ -17,6 +17,10 @@ description: A Model Context Protocol (MCP) server that provides - Read-only query execution - Schema introspection +kind: guide # plugin, theme, agent, skill, mcp-server, client, tool, config, guide, fork +topics: # 1-3 from the fixed list in data/schema.json; the first is used for README grouping + - docs + # ============================================================================ # OPTIONAL FIELDS # ============================================================================ diff --git a/data/examples/theme.yaml b/data/examples/theme.yaml index 2001fda..37edd66 100644 --- a/data/examples/theme.yaml +++ b/data/examples/theme.yaml @@ -13,6 +13,10 @@ description: A dark theme designed for extended coding sessions. Features high contrast syntax highlighting and carefully chosen colors to reduce eye strain. +kind: theme # plugin, theme, agent, skill, mcp-server, client, tool, config, guide, fork +topics: # 1-3 from the fixed list in data/schema.json; the first is used for README grouping + - ui + # ============================================================================ # OPTIONAL FIELDS # ============================================================================ diff --git a/data/schema.json b/data/schema.json index 9b1812d..29bbb59 100644 --- a/data/schema.json +++ b/data/schema.json @@ -2,18 +2,35 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "https://github.com/Heretek-AI/awesome-opencode/data/schema.json", "title": "Awesome Opencode Entry", - "description": "Schema for validating YAML entries in the Heretek-AI/awesome-opencode list", + "description": "Schema for validating YAML entries in the Heretek-AI/awesome-opencode list. Cross-entry rules (unique repos, kind per category, compat for plugins) live in scripts/utils/catalog.js.", "type": "object", "properties": { "name": { "type": "string", "minLength": 1, + "maxLength": 80, "description": "Display name of the project or tool" }, "repo": { "type": "string", "format": "uri", - "description": "URL to the repository (https://github.com/...)" + "pattern": "^https://((github\\.com|gitlab\\.com|codeberg\\.org|gitee\\.com)/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+|github\\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/discussions/[0-9]+|gist\\.github\\.com/[A-Za-z0-9_-]+/[0-9a-f]+)/?$", + "description": "Repository root URL (no /tree/ or /blob/ paths, no npm or marketing pages). Gists and GitHub discussions (for guides) are also accepted. Use `path` for a package inside a monorepo." + }, + "path": { + "type": "string", + "pattern": "^(?!/)(?!.*\\.\\.)(?!.*/$)[A-Za-z0-9_.@/-]+$", + "description": "Subdirectory of `repo` that holds this entry, for monorepos (e.g. packages/my-plugin)" + }, + "npm": { + "type": "string", + "pattern": "^(@[a-z0-9-~][a-z0-9-._~]*/)?[a-z0-9-~][a-z0-9-._~]*$", + "description": "npm package name, for entries installed from npm. Used for the V2 compatibility check and health checks." + }, + "homepage": { + "type": "string", + "format": "uri", + "description": "Documentation URL if different from repository" }, "tagline": { "type": "string", @@ -24,13 +41,99 @@ "description": { "type": "string", "minLength": 1, + "maxLength": 1500, "description": "Full description of the project (shown when expanded)" }, + "kind": { + "type": "string", + "enum": [ + "plugin", + "theme", + "agent", + "skill", + "mcp-server", + "client", + "tool", + "config", + "guide", + "fork" + ], + "description": "What the entry is. plugin: OpenCode plugin package. theme: color theme. agent: agent/subagent configuration. skill: SKILL.md pack. mcp-server: Model Context Protocol server. client: GUI, TUI, web, mobile, editor or chat front-end for OpenCode. tool: CLI, proxy, library or other utility. config: starter config, commands or templates. guide: docs, tutorials or articles. fork: a modified build of OpenCode." + }, + "also": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "plugin", + "theme", + "agent", + "skill", + "mcp-server", + "client", + "tool", + "config", + "guide", + "fork" + ] + }, + "uniqueItems": true, + "maxItems": 3, + "description": "Secondary kinds, e.g. a plugin that also ships an MCP server. Cross-listed in the README and site." + }, + "topics": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "memory", + "context", + "orchestration", + "workflow", + "providers", + "cost", + "notifications", + "sessions", + "code-quality", + "security", + "git", + "remote", + "web", + "ui", + "voice", + "media", + "docs", + "environment", + "observability", + "integrations", + "code-intel", + "prompting", + "domain" + ] + }, + "minItems": 1, + "maxItems": 3, + "uniqueItems": true, + "description": "1-3 use-case topics from a fixed list. The first one is the primary topic used for README grouping." + }, + "compat": { + "type": "string", + "enum": [ + "v2", + "v1", + "n/a", + "unknown" + ], + "description": "OpenCode API generation. v2: depends on @opencode/* (e.g. @opencode/plugin with Plugin.define). v1: legacy @opencode-ai/* only. n/a: does not use the plugin/SDK API. unknown: not yet verified. Required for plugins; CI verifies it." + }, "scope": { "type": "array", "items": { "type": "string", - "enum": ["global", "project"] + "enum": [ + "global", + "project" + ] }, "minItems": 1, "uniqueItems": true, @@ -41,23 +144,25 @@ "items": { "type": "string" }, - "description": "Tags for filtering and discoverability" + "description": "Free-form search keywords. Not rendered; use `topics` for categorization." }, "min_version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+", - "description": "Minimum OpenCode version required (semver format, e.g. '1.0.0')" - }, - "homepage": { - "type": "string", - "format": "uri", - "description": "Documentation URL if different from repository" + "description": "Minimum OpenCode version required (semver format, e.g. '2.0.0')" }, "installation": { "type": "string", "description": "Detailed installation instructions in markdown format" } }, - "required": ["name", "repo", "tagline", "description"], - "additionalProperties": true + "required": [ + "name", + "repo", + "tagline", + "description", + "kind", + "topics" + ], + "additionalProperties": false } diff --git a/package-lock.json b/package-lock.json index d710478..36459c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,27 +11,9 @@ "dependencies": { "ajv": "^8.20.0", "ajv-formats": "^3.0.1", - "glob": "^11.0.0", "js-yaml": "^4.3.2" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -65,101 +47,12 @@ } } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -182,75 +75,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", - "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/jackspeak": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", - "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/js-yaml": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", @@ -279,70 +103,6 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, - "node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", - "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -351,241 +111,6 @@ "engines": { "node": ">=0.10.0" } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } } } } diff --git a/package.json b/package.json index 52ae1c8..38284a1 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,35 @@ { "name": "awesome-opencode", "version": "1.0.0", - "description": "A curated list of plugins, themes, agents, and resources for Opencode", + "description": "A curated, validated catalog of OpenCode plugins, themes, agents, MCP servers, clients and resources", "private": true, "scripts": { - "generate": "node scripts/generate-readme.js", "validate": "node scripts/validate.js", - "bootstrap": "node scripts/bootstrap.js" + "generate": "node scripts/generate-readme.js", + "export": "node scripts/export-json.js --pretty --output dist/registry.json", + "new": "node scripts/new-entry.js", + "fmt": "node scripts/fmt.js", + "check-pr": "node scripts/check-pr.js --base origin/main", + "health": "node scripts/health-check.js --dry-run", + "scout": "node scripts/scout.js run --dry-run", + "site": "node scripts/build-site.js", + "test": "node --test test/" }, - "keywords": ["opencode", "awesome-list", "plugins", "themes", "agents"], + "keywords": [ + "opencode", + "awesome-list", + "plugins", + "themes", + "agents", + "mcp" + ], "license": "CC0-1.0", "dependencies": { "ajv": "^8.20.0", "ajv-formats": "^3.0.1", - "glob": "^11.0.0", "js-yaml": "^4.3.2" + }, + "engines": { + "node": ">=20" } } diff --git a/scripts/fmt.js b/scripts/fmt.js new file mode 100644 index 0000000..47dfe5e --- /dev/null +++ b/scripts/fmt.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node +/** + * Rewrite catalog entries in canonical format (key order, safe quoting). + * Usage: node scripts/fmt.js [--check] [file.yaml ...] + * + * --check lists files that would change and exits 1 without writing. + */ + +const fs = require('fs'); +const path = require('path'); +const { loadCatalog, formatProblems } = require('./utils/catalog'); +const { dumpEntry } = require('./utils/format'); + +const ROOT = path.join(__dirname, '..'); + +function main() { + const argv = process.argv.slice(2); + const check = argv.includes('--check'); + const only = argv.filter(a => a.endsWith('.yaml')).map(f => path.relative(ROOT, path.resolve(f))); + + const { entries, errors } = loadCatalog(); + if (errors.length) { + console.error(formatProblems(errors)); + console.error('\nFix the YAML syntax errors above first.'); + process.exit(1); + } + + const changed = []; + for (const entry of entries) { + if (only.length && !only.includes(entry.file)) continue; + const target = path.join(ROOT, entry.file); + const formatted = dumpEntry(entry.data); + if (fs.readFileSync(target, 'utf8') !== formatted) { + changed.push(entry.file); + if (!check) fs.writeFileSync(target, formatted); + } + } + + if (check && changed.length) { + console.error(`${changed.length} file(s) are not in canonical format:\n${changed.map(f => ` ${f}`).join('\n')}\nRun: npm run fmt`); + process.exit(1); + } + console.log(check ? '✓ All entries are formatted.' : `✓ Formatted ${changed.length} file(s).`); +} + +main(); diff --git a/scripts/utils/catalog.js b/scripts/utils/catalog.js new file mode 100644 index 0000000..df25881 --- /dev/null +++ b/scripts/utils/catalog.js @@ -0,0 +1,250 @@ +/** + * Catalog loading and cross-entry rules for awesome-opencode. + * + * Every consumer (validate, generate-readme, export-json, CI checks) loads entries through + * loadCatalog() so a single unparseable file is reported as an error instead of silently + * dropping a whole category. + */ + +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); +const { validateEntry } = require('./validation'); +const schema = require('../../data/schema.json'); + +const DATA_DIR = path.join(__dirname, '../../data'); + +// Category processing order (README section order) +const CATEGORIES = ['plugins', 'themes', 'agents', 'projects', 'resources']; + +// Plugins still on the V1 API (compat: v1) are removed from the catalog after this date +const V1_SUNSET = '2027-01-01'; + +const KINDS = schema.properties.kind.enum; +const TOPICS = schema.properties.topics.items.enum; + +// Which kinds may live in which category directory +const CATEGORY_KINDS = { + plugins: ['plugin'], + themes: ['theme'], + agents: ['agent', 'skill'], + projects: ['client', 'tool', 'mcp-server', 'skill', 'agent', 'fork'], + resources: ['guide', 'config', 'mcp-server', 'skill', 'tool'] +}; + +const TOPIC_LABELS = { + memory: 'Memory', + context: 'Context & Tokens', + orchestration: 'Multi-Agent & Orchestration', + workflow: 'Workflows & Planning', + providers: 'Providers, Auth & Routing', + cost: 'Cost & Usage', + notifications: 'Notifications', + sessions: 'Sessions & History', + 'code-quality': 'Code Review & Quality', + security: 'Security & Sandboxing', + git: 'Git & GitHub', + remote: 'Remote & Mobile', + web: 'Web & Browser', + ui: 'UI & TUI', + voice: 'Voice', + media: 'Media & Documents', + docs: 'Docs & Research', + environment: 'Environment & Setup', + observability: 'Observability', + integrations: 'Integrations', + 'code-intel': 'Code Intelligence', + prompting: 'Prompting & Skills', + domain: 'Domain-Specific' +}; + +const KIND_LABELS = { + plugin: 'Plugins', + theme: 'Themes', + agent: 'Agents', + skill: 'Skills', + 'mcp-server': 'MCP Servers', + client: 'Clients & GUIs', + tool: 'Tools & Utilities', + config: 'Configs & Starters', + guide: 'Guides', + fork: 'Forks' +}; + +const FILENAME_RE = /^[a-z0-9]+(?:[-.][a-z0-9]+)*\.yaml$/; + +// Contributor-facing wording for schema pattern failures +const PATTERN_HINTS = { + '/repo': 'repo must be the repository root, e.g. https://github.com/owner/repo (no /tree/ or /blob/ paths, npm pages or websites; use `path` for a folder inside a monorepo)', + '/path': 'path must be a relative folder inside the repo, e.g. packages/my-plugin', + '/npm': 'npm must be a package name, e.g. opencode-my-plugin or @scope/opencode-my-plugin', + '/min_version': 'min_version must be semver, e.g. "2.0.0"' +}; +const MOJIBAKE_RE = /鈥|â€|Ã[\u0080-¿]/; +const PLACEHOLDER_OWNER_RE =/^(your[-_]?(user(name)?|org|name)|user(name)?|owner|example|org|me)$/i; + +/** + * Parse a repo URL into { host, owner, name } (gists return owner/id). + * @param {string} url + * @returns {{host: string, owner: string, name: string}|null} + */ +function parseRepoUrl(url) { + const match = String(url || '').match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/#?]+)/); + if (!match) return null; + return { host: match[1].toLowerCase(), owner: match[2], name: match[3].replace(/\.git$/, '') }; +} + +/** + * Canonical identity of an entry's target: lowercased host/owner/repo plus optional path. + * @param {object} data - Entry data + * @returns {string} + */ +function canonicalKey(data) { + const parsed = parseRepoUrl(data.repo); + const base = parsed + ? `${parsed.host}/${parsed.owner}/${parsed.name}`.toLowerCase() + : String(data.repo || '').toLowerCase().replace(/\/+$/, ''); + const sub = (data.path || '').replace(/^\/+|\/+$/g, '').toLowerCase(); + return sub ? `${base}#${sub}` : base; +} + +/** + * Load every entry in data//*.yaml. + * @param {object} [options] + * @param {string} [options.dataDir] - Override the data directory + * @returns {{entries: object[], errors: {file: string, message: string}[]}} + */ +function loadCatalog({ dataDir = DATA_DIR } = {}) { + const entries = []; + const errors = []; + + for (const category of CATEGORIES) { + const dir = path.join(dataDir, category); + if (!fs.existsSync(dir)) continue; + + for (const fileName of fs.readdirSync(dir).filter(f => f.endsWith('.yaml')).sort()) { + const file = path.join('data', category, fileName); + try { + const data = yaml.load(fs.readFileSync(path.join(dir, fileName), 'utf8')); + if (!data || typeof data !== 'object' || Array.isArray(data)) { + errors.push({ file, message: 'file is empty or not a YAML mapping' }); + continue; + } + entries.push({ category, id: fileName.replace(/\.yaml$/, ''), file, data }); + } catch (err) { + errors.push({ file, message: `invalid YAML: ${err.reason || err.message}` }); + } + } + } + + return { entries, errors }; +} + +/** + * Rules for a single entry: schema, kind/category fit, filename, placeholder repos. + * @param {object} entry - { category, id, file, data } + * @returns {string[]} Error messages + */ +function checkEntry(entry) { + const messages = []; + const result = validateEntry(entry.data, entry.file); + if (!result.valid) { + for (const err of result.errors) { + if (err.keyword === 'pattern' && PATTERN_HINTS[err.path]) { + messages.push(PATTERN_HINTS[err.path]); + continue; + } + const detail = err.keyword === 'additionalProperties' + ? `unknown field "${err.params.additionalProperty}"` + : err.keyword === 'enum' + ? `${err.message}: ${err.params.allowedValues.join(', ')}` + : err.message; + messages.push(`${err.path === '/' ? '' : err.path + ': '}${detail}`); + } + } + + if (!FILENAME_RE.test(path.basename(entry.file))) { + messages.push('filename must be kebab-case, e.g. my-plugin.yaml'); + } + + const allowed = CATEGORY_KINDS[entry.category]; + if (entry.data.kind && allowed && !allowed.includes(entry.data.kind)) { + messages.push(`kind "${entry.data.kind}" does not belong in data/${entry.category}/ (allowed: ${allowed.join(', ')})`); + } + + if (entry.category === 'plugins' && !entry.data.compat) { + messages.push('plugins must declare compat (v2, v1 or unknown); CI verifies it'); + } + + for (const field of ['name', 'tagline', 'description']) { + if (MOJIBAKE_RE.test(entry.data[field] || '')) { + messages.push(`${field} contains mis-encoded characters (e.g. "鈥?" or "—" instead of "—"); re-save the file as UTF-8`); + } + } + + const parsed = parseRepoUrl(entry.data.repo); + if (parsed && PLACEHOLDER_OWNER_RE.test(parsed.owner)) { + messages.push(`repo owner "${parsed.owner}" looks like a placeholder`); + } + + return messages; +} + +/** + * Validate the whole catalog: per-entry rules plus cross-entry uniqueness. + * @param {object[]} entries + * @returns {{file: string, message: string}[]} + */ +function checkCatalog(entries) { + const problems = []; + const byKey = new Map(); + + for (const entry of entries) { + for (const message of checkEntry(entry)) { + problems.push({ file: entry.file, message }); + } + if (!entry.data.repo) continue; + const key = canonicalKey(entry.data); + if (byKey.has(key)) { + problems.push({ + file: entry.file, + message: `duplicate of ${byKey.get(key)} (same repo${entry.data.path ? ' and path' : ''}); use \`path\` for separate packages in one monorepo` + }); + } else { + byKey.set(key, entry.file); + } + } + + return problems; +} + +/** + * Format problems for terminal output. + * @param {{file: string, message: string}[]} problems + * @returns {string} + */ +function formatProblems(problems) { + const byFile = new Map(); + for (const { file, message } of problems) { + if (!byFile.has(file)) byFile.set(file, []); + byFile.get(file).push(message); + } + return [...byFile].map(([file, messages]) => `✗ ${file}\n${messages.map(m => ` - ${m}`).join('\n')}`).join('\n'); +} + +module.exports = { + CATEGORIES, + CATEGORY_KINDS, + DATA_DIR, + KINDS, + KIND_LABELS, + TOPICS, + TOPIC_LABELS, + V1_SUNSET, + canonicalKey, + checkCatalog, + checkEntry, + formatProblems, + loadCatalog, + parseRepoUrl +}; diff --git a/scripts/utils/format.js b/scripts/utils/format.js new file mode 100644 index 0000000..c59d617 --- /dev/null +++ b/scripts/utils/format.js @@ -0,0 +1,46 @@ +/** + * Canonical YAML formatting for catalog entries. + * + * All bot-written and scaffolded entries go through dumpEntry() so quoting is always valid YAML + * (unquoted ": " in a tagline broke README generation for every entry before this existed). + */ + +const yaml = require('js-yaml'); + +const KEY_ORDER = [ + 'name', 'repo', 'path', 'npm', 'homepage', 'tagline', 'description', + 'kind', 'also', 'topics', 'compat', 'scope', 'tags', 'min_version', 'installation' +]; + +/** + * Order keys canonically; unknown keys are kept at the end so validation can report them. + * @param {object} data + * @returns {object} + */ +function orderEntry(data) { + const ordered = {}; + for (const key of KEY_ORDER) { + if (data[key] !== undefined && data[key] !== null && !(Array.isArray(data[key]) && data[key].length === 0)) { + ordered[key] = data[key]; + } + } + for (const key of Object.keys(data)) { + if (!(key in ordered) && !KEY_ORDER.includes(key) && !key.startsWith('_')) ordered[key] = data[key]; + } + return ordered; +} + +/** + * Serialize an entry to YAML text. + * @param {object} data + * @returns {string} + */ +function dumpEntry(data) { + const clean = orderEntry(data); + for (const key of ['name', 'tagline', 'description']) { + if (typeof clean[key] === 'string') clean[key] = clean[key].trim(); + } + return yaml.dump(clean, { lineWidth: -1, noRefs: true, quotingType: '"' }); +} + +module.exports = { KEY_ORDER, dumpEntry, orderEntry }; diff --git a/scripts/utils/yaml.js b/scripts/utils/yaml.js index dae0ec4..d6ccff3 100644 --- a/scripts/utils/yaml.js +++ b/scripts/utils/yaml.js @@ -1,42 +1,8 @@ /** - * YAML file reading utilities for awesome-opencode + * Filename helpers for awesome-opencode entries. + * (Catalog loading lives in ./catalog.js.) */ -const fs = require('fs'); -const path = require('path'); -const yaml = require('js-yaml'); -const { glob } = require('glob'); - -/** - * Read and parse a single YAML file - * @param {string} filePath - Path to YAML file - * @returns {object} Parsed YAML object - */ -function readYamlFile(filePath) { - try { - const content = fs.readFileSync(filePath, 'utf8'); - return yaml.load(content, { filename: filePath }); - } catch (e) { - throw new Error(`Failed to parse ${filePath}: ${e.message}`); - } -} - -/** - * Read all YAML files from a directory - * @param {string} dirPath - Directory containing YAML files - * @returns {Promise} Array of parsed YAML objects with _filePath metadata - */ -async function readYamlDir(dirPath) { - const pattern = path.join(dirPath, '*.yaml').replace(/\\/g, '/'); - const files = await glob(pattern); - - return files.map(file => ({ - ...readYamlFile(file), - _filePath: file, - _fileName: path.basename(file, '.yaml') - })); -} - /** * Convert a name to a filename-safe slug * @param {string} name - Entry name @@ -49,4 +15,4 @@ function slugify(name) { .replace(/^-+|-+$/g, ''); } -module.exports = { readYamlFile, readYamlDir, slugify }; +module.exports = { slugify }; diff --git a/scripts/validate.js b/scripts/validate.js index 26594f7..8c0f512 100644 --- a/scripts/validate.js +++ b/scripts/validate.js @@ -1,109 +1,33 @@ #!/usr/bin/env node /** - * Validate YAML files against the schema + * Validate catalog entries. * Usage: node scripts/validate.js [file1.yaml] [file2.yaml] ... - * If no files specified, validates all YAML files in data/ + * + * Always loads the whole catalog so cross-entry rules (duplicate repos) are enforced; when files are + * given, only problems in those files are reported. Exits 1 on any problem, including unparseable YAML. */ -const fs = require('fs'); const path = require('path'); -const glob = require('glob'); -const yaml = require('js-yaml'); -const { validateEntry, formatValidationErrors } = require('./utils/validation'); +const { checkCatalog, formatProblems, loadCatalog } = require('./utils/catalog'); -const DATA_DIR = path.join(__dirname, '../data'); +const ROOT = path.join(__dirname, '..'); -/** - * Get all YAML files in the data directory - * @returns {string[]} Array of file paths - */ -function getAllYamlFiles() { - const pattern = path.join(DATA_DIR, '**/*.yaml'); - return glob.sync(pattern); -} - -/** - * Load and parse a YAML file - * @param {string} filePath - Path to the YAML file - * @returns {object|null} Parsed YAML object or null on error - */ -function loadYamlFile(filePath) { - try { - const content = fs.readFileSync(filePath, 'utf8'); - return yaml.load(content); - } catch (err) { - console.error(`Error reading ${filePath}: ${err.message}`); - return null; - } -} - -/** - * Validate a single file - * @param {string} filePath - Path to the YAML file - * @returns {boolean} True if valid, false otherwise - */ -function validateFile(filePath) { - const data = loadYamlFile(filePath); - if (!data) { - return false; - } - - const result = validateEntry(data, filePath); - if (result.valid) { - console.log(`✓ ${filePath}`); - } else { - console.error(formatValidationErrors(result)); - } - - return result.valid; -} - -/** - * Main validation function - */ function main() { - const args = process.argv.slice(2); - let files = []; + const only = process.argv.slice(2) + .filter(f => f.endsWith('.yaml')) + .map(f => path.relative(ROOT, path.resolve(f))); - if (args.length > 0) { - // Validate specified files - files = args; - } else { - // Validate all YAML files in data/ - files = getAllYamlFiles(); - } + const { entries, errors } = loadCatalog(); + const problems = [...errors, ...checkCatalog(entries)] + .filter(p => only.length === 0 || only.includes(p.file)); - if (files.length === 0) { - console.log('No YAML files to validate.'); - process.exit(0); - } - - console.log(`Validating ${files.length} YAML file(s)...\n`); - - let allValid = true; - let validatedCount = 0; - - for (const file of files) { - if (fs.existsSync(file)) { - if (!validateFile(file)) { - allValid = false; - } - validatedCount++; - } else { - console.warn(`⚠ File not found: ${file}`); - allValid = false; - } - } - - console.log(''); - - if (allValid) { - console.log(`✓ All ${validatedCount} file(s) passed validation.`); - process.exit(0); - } else { - console.error(`✗ Validation failed for ${validatedCount} file(s).`); + const checked = only.length || entries.length + errors.length; + if (problems.length > 0) { + console.error(formatProblems(problems)); + console.error(`\n✗ ${new Set(problems.map(p => p.file)).size} of ${checked} file(s) failed validation.`); process.exit(1); } + console.log(`✓ All ${checked} file(s) passed validation.`); } main();