diff --git a/tools/manifest/schema.json b/tools/manifest/schema.json new file mode 100644 index 000000000..d90d14934 --- /dev/null +++ b/tools/manifest/schema.json @@ -0,0 +1,57 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Pack Manifest", + "description": "Defines a publishable pack in the monorepo", + "type": "object", + "required": ["id", "name", "type", "loader", "mc_version", "version", "release_type"], + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "description": "Path to this schema file." + }, + "id": { + "type": "string", + "description": "Matches the directory name", + "minLength": 1 + }, + "name": { + "type": "string", + "description": "Human-readable pack name shown on Modrinth/CurseForge. Ie. Re-Console Plus", + "minLength": 1 + }, + "type": { + "type": "string", + "description": "Pack type. 'modpack' exports via packwiz, 'datapack' zips the content directory", + "enum": ["modpack", "datapack"] + }, + "loader": { + "type": "string", + "description": "Mod loader this pack targets (e.g., 'fabric', 'neoforge', 'forge', 'quilt')", + "minLength": 1 + }, + "mc_version": { + "type": "string", + "description": "Target Minecraft version (e.g., '1.21.10')", + "minLength": 1 + }, + "version": { + "type": "string", + "description": "Pack version.", + "minLength": 1 + }, + "release_type": { + "type": "string", + "description": "Release channel", + "enum": ["release", "beta", "alpha"] + }, + "modrinth_id": { + "type": "string", + "description": "Modrinth project ID or slug (e.g., 'legacy-minecraft'). Leave empty if not publishing to Modrinth" + }, + "curseforge_id": { + "type": "string", + "description": "CurseForge project slug or numeric ID (e.g., 're-console'). Leave empty if not publishing to CurseForge" + } + } +} diff --git a/tools/manifest/validate.ts b/tools/manifest/validate.ts new file mode 100644 index 000000000..5f7f95e67 --- /dev/null +++ b/tools/manifest/validate.ts @@ -0,0 +1,112 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +interface Manifest { + id: string; + name: string; + type: 'modpack' | 'datapack'; + loader: string; + mc_version: string; + version: string; + release_type: 'release' | 'beta' | 'alpha'; + modrinth_id?: string; + curseforge_id?: string; +} + +function fail(msg: string): never { + console.error(`::error::${msg}`); + process.exit(1); +} + +function warn(msg: string): void { + console.warn(`::warning::${msg}`); +} + +function validate(manifestPath: string): void { + if (!fs.existsSync(manifestPath)) { + fail(`manifest not found: ${manifestPath}`); + } + + let manifest: Manifest; + try { + const raw = fs.readFileSync(manifestPath, 'utf-8'); + manifest = JSON.parse(raw); + } catch (e) { + fail(`failed to parse ${manifestPath}: ${e instanceof Error ? e.message : e}`); + } + + const required = [ + 'id', 'name', 'type', 'loader', 'mc_version', 'version', 'release_type', + ] as const; + for (const field of required) { + const v = manifest[field]; + if (v === undefined || v === null || v === '') { + fail(`manifest missing required field: ${field}`); + } + } + + if (!['modpack', 'datapack'].includes(manifest.type)) { + fail(`invalid 'type': ${manifest.type} (must be 'modpack' or 'datapack')`); + } + if (!['release', 'beta', 'alpha'].includes(manifest.release_type)) { + fail(`invalid 'release_type': ${manifest.release_type} (must be 'release', 'beta', or 'alpha')`); + } + + const hasMr = manifest.modrinth_id && manifest.modrinth_id.trim() !== ''; + const hasCf = manifest.curseforge_id && manifest.curseforge_id.trim() !== ''; + if (!hasMr && !hasCf) { + fail('manifest must set at least one of modrinth_id or curseforge_id'); + } + + const packDir = path.dirname(manifestPath); + + const changelogPath = path.join(packDir, 'changelog.md'); + if (!fs.existsSync(changelogPath)) { + fail(`changelog.md is missing at ${changelogPath}`); + } + const changelog = fs.readFileSync(changelogPath, 'utf-8').trim(); + if (changelog === '') { + fail(`changelog.md is empty at ${changelogPath}`); + } + const contentLines = changelog.split('\n').filter(l => l.trim() && !l.trim().startsWith('#')); + if (contentLines.length === 0) { + fail(`changelog.md has headers but no content at ${changelogPath}`); + } + + if (manifest.type === 'modpack') { + const mr = path.join(packDir, `${manifest.mc_version}-mr`); + const cf = path.join(packDir, `${manifest.mc_version}-cf`); + + if (hasMr && !fs.existsSync(mr)) { + fail(`modrinth_id is set but ${mr} does not exist`); + } + if (hasCf && !fs.existsSync(cf)) { + fail(`curseforge_id is set but ${cf} does not exist`); + } + if (fs.existsSync(mr) && !hasMr) { + warn(`${mr} exists but modrinth_id is not set`); + } + if (fs.existsSync(cf) && !hasCf) { + warn(`${cf} exists but curseforge_id is not set`); + } + } + + if (manifest.type === 'datapack') { + const content = path.join(packDir, 'content'); + if (!fs.existsSync(content)) { + fail(`datapack content directory missing: ${content}`); + } + } + + console.log(`${manifest.id} ${manifest.version} (${manifest.release_type}) — manifest OK`); +} + +const args = process.argv.slice(2); +if (args.length === 0) { + console.error('usage: tsx validate.ts [more manifests...]'); + process.exit(1); +} + +for (const manifestPath of args) { + validate(manifestPath); +}