mirror of
https://github.com/Dokploy/templates.git
synced 2026-07-10 00:15:28 +02:00
feat: per-template metadata — eliminate meta.json merge conflicts
- move each template's metadata to blueprints/<id>/meta.json (442 files, byte-identical roundtrip with the old root meta.json) - generate the served meta.json at build time into app/public/meta.json (gitignored); pnpm dev/build run the generator first - CI validates every blueprints/<id>/meta.json via generate-meta.js --check (required fields, id/folder match, logo exists, folder<->meta bidirectionality) and rejects any committed root meta.json - remove root meta.json, dedupe-and-sort-meta.js and build-scripts/process-meta.js (obsolete) - update CONTRIBUTING.md, AGENTS.md and README.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
55
build-scripts/explode-meta.js
Normal file
55
build-scripts/explode-meta.js
Normal file
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* One-shot migration: splits the root meta.json into per-template files at
|
||||
* blueprints/<id>/meta.json. Kept in the repo for reference and for forks
|
||||
* that need to migrate.
|
||||
*
|
||||
* Usage: node build-scripts/explode-meta.js [path/to/meta.json]
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const metaPath = process.argv[2] || "meta.json";
|
||||
const blueprintsDir = "blueprints";
|
||||
|
||||
if (!fs.existsSync(metaPath)) {
|
||||
console.error(`❌ ${metaPath} not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const entries = JSON.parse(fs.readFileSync(metaPath, "utf8"));
|
||||
if (!Array.isArray(entries)) {
|
||||
console.error("❌ meta.json must be an array");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let written = 0;
|
||||
const missingDirs = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.id) {
|
||||
console.error(`❌ Entry without id: ${JSON.stringify(entry).slice(0, 80)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const dir = path.join(blueprintsDir, entry.id);
|
||||
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
||||
missingDirs.push(entry.id);
|
||||
continue;
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "meta.json"),
|
||||
JSON.stringify(entry, null, 2) + "\n"
|
||||
);
|
||||
written++;
|
||||
}
|
||||
|
||||
console.log(`✅ Wrote ${written} blueprints/<id>/meta.json files`);
|
||||
if (missingDirs.length) {
|
||||
console.warn(
|
||||
`⚠️ ${missingDirs.length} entries have no blueprint folder and were NOT written:`
|
||||
);
|
||||
for (const id of missingDirs) console.warn(` - ${id}`);
|
||||
process.exit(2);
|
||||
}
|
||||
119
build-scripts/generate-meta.js
Normal file
119
build-scripts/generate-meta.js
Normal file
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Aggregates every blueprints/<id>/meta.json into a single meta.json artifact
|
||||
* (the file the templates app serves at /meta.json).
|
||||
*
|
||||
* Each template owns its metadata inside its own folder, so pull requests
|
||||
* never touch a shared file and meta.json merge conflicts cannot happen.
|
||||
*
|
||||
* Usage:
|
||||
* node build-scripts/generate-meta.js # writes app/public/meta.json
|
||||
* node build-scripts/generate-meta.js --output <file> # custom output path
|
||||
* node build-scripts/generate-meta.js --check # validate only, write nothing
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const checkOnly = args.includes("--check");
|
||||
const outIdx = args.indexOf("--output");
|
||||
|
||||
// Anchor all paths to the repo root so the script works from any cwd
|
||||
// (app/package.json invokes it as `node ../build-scripts/generate-meta.js`).
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const outputPath =
|
||||
outIdx >= 0
|
||||
? path.resolve(args[outIdx + 1])
|
||||
: path.join(repoRoot, "app", "public", "meta.json");
|
||||
|
||||
const blueprintsDir = path.join(repoRoot, "blueprints");
|
||||
const REQUIRED_FIELDS = ["id", "name", "version", "description", "links", "logo", "tags"];
|
||||
const REQUIRED_LINKS = ["github", "website", "docs"];
|
||||
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const entries = [];
|
||||
|
||||
const dirs = fs
|
||||
.readdirSync(blueprintsDir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
for (const dir of dirs) {
|
||||
const metaFile = path.join(blueprintsDir, dir, "meta.json");
|
||||
if (!fs.existsSync(metaFile)) {
|
||||
errors.push(`${dir}: missing blueprints/${dir}/meta.json`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let entry;
|
||||
try {
|
||||
entry = JSON.parse(fs.readFileSync(metaFile, "utf8"));
|
||||
} catch (e) {
|
||||
errors.push(`${dir}/meta.json: invalid JSON (${e.message})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(entry) || typeof entry !== "object" || entry === null) {
|
||||
errors.push(`${dir}/meta.json: must be a single JSON object`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.id !== dir) {
|
||||
errors.push(`${dir}/meta.json: "id" must be "${dir}" (got ${JSON.stringify(entry.id)})`);
|
||||
}
|
||||
|
||||
for (const field of REQUIRED_FIELDS) {
|
||||
if (
|
||||
entry[field] === undefined ||
|
||||
entry[field] === null ||
|
||||
entry[field] === ""
|
||||
) {
|
||||
errors.push(`${dir}/meta.json: missing required field "${field}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.links && typeof entry.links === "object") {
|
||||
for (const link of REQUIRED_LINKS) {
|
||||
// An empty string is allowed ("this project has no website"); only a
|
||||
// missing key is an error — same semantics as the previous CI check.
|
||||
if (entry.links[link] === undefined || entry.links[link] === null) {
|
||||
errors.push(`${dir}/meta.json: links is missing required field "${link}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.tags !== undefined && (!Array.isArray(entry.tags) || entry.tags.length === 0)) {
|
||||
errors.push(`${dir}/meta.json: tags must be a non-empty array`);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof entry.logo === "string" &&
|
||||
entry.logo &&
|
||||
!fs.existsSync(path.join(blueprintsDir, dir, entry.logo))
|
||||
) {
|
||||
errors.push(`${dir}: logo file "${entry.logo}" not found in blueprints/${dir}/`);
|
||||
}
|
||||
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
entries.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
||||
|
||||
for (const w of warnings) console.warn(`⚠️ ${w}`);
|
||||
if (errors.length) {
|
||||
for (const e of errors) console.error(`❌ ${e}`);
|
||||
console.error(`\n🚨 ${errors.length} error(s) found across ${dirs.length} blueprints`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`✅ ${entries.length} templates validated`);
|
||||
|
||||
if (!checkOnly) {
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(outputPath, JSON.stringify(entries, null, 2) + "\n");
|
||||
console.log(`📦 Wrote ${outputPath}`);
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Production build script for processing meta.json
|
||||
* This script is designed to be run during CI/CD or build processes
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
class MetaProcessor {
|
||||
constructor(options = {}) {
|
||||
this.options = {
|
||||
inputFile: options.inputFile || "meta.json",
|
||||
outputFile: options.outputFile || null, // If null, overwrites input
|
||||
createBackup: options.createBackup || false, // Default false
|
||||
verbose: options.verbose || false,
|
||||
validateSchema: options.validateSchema !== false, // Default true
|
||||
exitOnError: options.exitOnError !== false, // Default true
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
log(message, level = "info") {
|
||||
if (!this.options.verbose && level === "debug") return;
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const prefix =
|
||||
{
|
||||
info: "🔧",
|
||||
success: "✅",
|
||||
warning: "⚠️",
|
||||
error: "❌",
|
||||
debug: "🔍",
|
||||
}[level] || "ℹ️";
|
||||
|
||||
console.log(`[${timestamp}] ${prefix} ${message}`);
|
||||
}
|
||||
|
||||
validateSchema(item, index) {
|
||||
const requiredFields = [
|
||||
"id",
|
||||
"name",
|
||||
"version",
|
||||
"description",
|
||||
"links",
|
||||
"logo",
|
||||
"tags",
|
||||
];
|
||||
const missing = requiredFields.filter((field) => !item[field]);
|
||||
|
||||
if (missing.length > 0) {
|
||||
this.log(
|
||||
`Item at index ${index} missing required fields: ${missing.join(", ")}`,
|
||||
"warning"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate links structure
|
||||
if (typeof item.links !== "object" || !item.links.github) {
|
||||
this.log(`Item "${item.id}" has invalid links structure`, "warning");
|
||||
}
|
||||
|
||||
// Validate tags is array
|
||||
if (!Array.isArray(item.tags)) {
|
||||
this.log(
|
||||
`Item "${item.id}" has invalid tags (should be array)`,
|
||||
"warning"
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async process() {
|
||||
const startTime = Date.now();
|
||||
this.log(`Starting meta.json processing...`);
|
||||
|
||||
try {
|
||||
// Read input file
|
||||
if (!fs.existsSync(this.options.inputFile)) {
|
||||
throw new Error(`Input file not found: ${this.options.inputFile}`);
|
||||
}
|
||||
|
||||
const fileContent = fs.readFileSync(this.options.inputFile, "utf8");
|
||||
let data;
|
||||
|
||||
try {
|
||||
data = JSON.parse(fileContent);
|
||||
} catch (parseError) {
|
||||
throw new Error(
|
||||
`Invalid JSON in ${this.options.inputFile}: ${parseError.message}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error(
|
||||
`Expected array in ${this.options.inputFile}, got ${typeof data}`
|
||||
);
|
||||
}
|
||||
|
||||
this.log(`Found ${data.length} total entries`);
|
||||
|
||||
// Process data
|
||||
const results = this.dedupeAndSort(data);
|
||||
|
||||
// Create backup if requested
|
||||
if (this.options.createBackup) {
|
||||
const backupPath = `${this.options.inputFile}.backup.${Date.now()}`;
|
||||
fs.writeFileSync(backupPath, fileContent, "utf8");
|
||||
this.log(`Backup created: ${backupPath}`, "debug");
|
||||
}
|
||||
|
||||
// Write output
|
||||
const outputFile = this.options.outputFile || this.options.inputFile;
|
||||
const newContent = this.formatJSON(results.unique) + "\n";
|
||||
fs.writeFileSync(outputFile, newContent, "utf8");
|
||||
|
||||
// Report results
|
||||
const duration = Date.now() - startTime;
|
||||
this.log(`Processing completed in ${duration}ms`, "success");
|
||||
this.log(`Statistics:`, "info");
|
||||
this.log(` • Original entries: ${results.original}`, "info");
|
||||
this.log(` • Duplicates removed: ${results.duplicatesRemoved}`, "info");
|
||||
this.log(` • Final entries: ${results.final}`, "info");
|
||||
this.log(` • Schema violations: ${results.schemaViolations}`, "info");
|
||||
|
||||
if (results.duplicates.length > 0) {
|
||||
this.log(`Removed duplicates:`, "warning");
|
||||
results.duplicates.forEach((dup) => {
|
||||
this.log(` • "${dup.id}" (${dup.name})`, "warning");
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
this.log(`Processing failed: ${error.message}`, "error");
|
||||
if (this.options.exitOnError) {
|
||||
process.exit(1);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
dedupeAndSort(data) {
|
||||
const seenIds = new Set();
|
||||
const duplicates = [];
|
||||
const unique = [];
|
||||
let schemaViolations = 0;
|
||||
|
||||
data.forEach((item, index) => {
|
||||
if (!item || typeof item !== "object") {
|
||||
this.log(`Skipping invalid item at index ${index}`, "warning");
|
||||
schemaViolations++;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!item.id) {
|
||||
this.log(
|
||||
`Skipping item without ID at index ${index}: ${
|
||||
item.name || "Unknown"
|
||||
}`,
|
||||
"warning"
|
||||
);
|
||||
schemaViolations++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate schema if enabled
|
||||
if (this.options.validateSchema) {
|
||||
if (!this.validateSchema(item, index)) {
|
||||
schemaViolations++;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
if (seenIds.has(item.id)) {
|
||||
duplicates.push({
|
||||
id: item.id,
|
||||
name: item.name || "Unknown",
|
||||
originalIndex: index,
|
||||
});
|
||||
this.log(
|
||||
`Duplicate ID found: "${item.id}" (${item.name || "Unknown"})`,
|
||||
"warning"
|
||||
);
|
||||
} else {
|
||||
seenIds.add(item.id);
|
||||
unique.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort alphabetically by ID (ASCII order)
|
||||
unique.sort((a, b) => {
|
||||
const idA = a.id.toLowerCase();
|
||||
const idB = b.id.toLowerCase();
|
||||
return idA < idB ? -1 : idA > idB ? 1 : 0;
|
||||
});
|
||||
|
||||
return {
|
||||
original: data.length,
|
||||
duplicatesRemoved: duplicates.length,
|
||||
final: unique.length,
|
||||
duplicates,
|
||||
unique,
|
||||
schemaViolations,
|
||||
};
|
||||
}
|
||||
|
||||
formatJSON(data) {
|
||||
// Custom JSON formatter that keeps small arrays compact
|
||||
return JSON.stringify(
|
||||
data,
|
||||
(key, value) => {
|
||||
if (Array.isArray(value)) {
|
||||
// Keep arrays compact if they're small and contain only strings
|
||||
if (
|
||||
value.length <= 5 &&
|
||||
value.every((item) => typeof item === "string" && item.length < 50)
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
},
|
||||
2
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// CLI usage
|
||||
if (require.main === module) {
|
||||
const args = process.argv.slice(2);
|
||||
const options = {};
|
||||
|
||||
// Parse command line arguments
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
switch (arg) {
|
||||
case "--input":
|
||||
case "-i":
|
||||
options.inputFile = args[++i];
|
||||
break;
|
||||
case "--output":
|
||||
case "-o":
|
||||
options.outputFile = args[++i];
|
||||
break;
|
||||
case "--backup":
|
||||
options.createBackup = true;
|
||||
break;
|
||||
case "--no-backup":
|
||||
options.createBackup = false;
|
||||
break;
|
||||
case "--verbose":
|
||||
case "-v":
|
||||
options.verbose = true;
|
||||
break;
|
||||
case "--no-schema-validation":
|
||||
options.validateSchema = false;
|
||||
break;
|
||||
case "--help":
|
||||
case "-h":
|
||||
console.log(`
|
||||
Usage: node process-meta.js [options]
|
||||
|
||||
Options:
|
||||
-i, --input <file> Input file path (default: meta.json)
|
||||
-o, --output <file> Output file path (default: same as input)
|
||||
--backup Create backup file (disabled by default)
|
||||
-v, --verbose Verbose output
|
||||
--no-schema-validation Skip schema validation
|
||||
-h, --help Show this help message
|
||||
|
||||
Examples:
|
||||
node process-meta.js
|
||||
node process-meta.js --input data/meta.json --output dist/meta.json
|
||||
node process-meta.js --verbose --no-backup
|
||||
`);
|
||||
process.exit(0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const processor = new MetaProcessor(options);
|
||||
processor.process().catch((error) => {
|
||||
console.error("Process failed:", error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = MetaProcessor;
|
||||
Reference in New Issue
Block a user