#!/usr/bin/env node const fs = require("fs"); const path = require("path"); // Color output helpers const colors = { red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", blue: "\x1b[34m", reset: "\x1b[0m", }; function log(message, color = "reset") { console.log(`${colors[color]}${message}${colors.reset}`); } function error(message) { log(`❌ ${message}`, "red"); } function success(message) { log(`✅ ${message}`, "green"); } function warn(message) { log(`⚠️ ${message}`, "yellow"); } function info(message) { log(`ℹ️ ${message}`, "blue"); } // Plugin schema validation function validatePlugin(pluginPath) { if (!fs.existsSync(pluginPath)) { throw new Error(`Plugin file not found: ${pluginPath}`); } let plugin; try { plugin = JSON.parse(fs.readFileSync(pluginPath, "utf8")); } catch (e) { throw new Error(`Invalid JSON syntax: ${e.message}`); } const errors = []; const warnings = []; // Required fields validation const requiredFields = [ "robotId", "name", "platform", "version", "pluginApiVersion", "hriStudioVersion", "trustLevel", "category", ]; requiredFields.forEach((field) => { if (!plugin[field]) { errors.push(`Missing required field: ${field}`); } }); // Field format validation if (plugin.robotId && !/^[a-z0-9-]+$/.test(plugin.robotId)) { errors.push("robotId must be lowercase with hyphens only"); } if (plugin.version && !/^\d+\.\d+\.\d+/.test(plugin.version)) { errors.push("version must follow semantic versioning (e.g., 1.0.0)"); } if ( plugin.trustLevel && !["official", "verified", "community"].includes(plugin.trustLevel) ) { errors.push( `Invalid trustLevel: ${plugin.trustLevel}. Must be: official, verified, or community`, ); } // Category validation const validCategories = [ "mobile-robot", "humanoid-robot", "manipulator", "drone", "sensor-platform", "simulation", ]; if (plugin.category && !validCategories.includes(plugin.category)) { errors.push( `Invalid category: ${plugin.category}. Valid categories: ${validCategories.join(", ")}`, ); } // Actions validation if (!plugin.actions || !Array.isArray(plugin.actions)) { errors.push("Plugin must have an actions array"); } else if (plugin.actions.length === 0) { warnings.push("Plugin has no actions defined"); } else { plugin.actions.forEach((action, index) => { const actionErrors = validateAction(action, index); errors.push(...actionErrors); }); } // Assets validation if (plugin.assets) { if (!plugin.assets.thumbnailUrl) { errors.push("assets.thumbnailUrl is required"); } // Check if asset paths exist const assetChecks = [ ["thumbnailUrl", plugin.assets.thumbnailUrl], ["main image", plugin.assets.images?.main], ["logo", plugin.assets.images?.logo], ]; if (plugin.assets.images?.angles) { Object.entries(plugin.assets.images.angles).forEach( ([angle, assetPath]) => { assetChecks.push([`${angle} angle`, assetPath]); }, ); } assetChecks.forEach(([description, assetPath]) => { if (assetPath && assetPath.startsWith("assets/")) { const fullPath = path.resolve( path.dirname(pluginPath), "..", assetPath, ); if (!fs.existsSync(fullPath)) { warnings.push(`Asset not found: ${description} (${assetPath})`); } } }); } else { errors.push("Plugin must have assets definition"); } // Manufacturer validation if (!plugin.manufacturer?.name) { warnings.push("manufacturer.name is recommended"); } return { errors, warnings, plugin }; } function validateAction(action, index) { const errors = []; // Required action fields const requiredFields = ["id", "name", "category", "parameterSchema"]; requiredFields.forEach((field) => { if (!action[field]) { errors.push(`Action ${index}: missing required field '${field}'`); } }); // Action ID format if (action.id && !/^[a-z_]+$/.test(action.id)) { errors.push( `Action ${index}: id must be snake_case (lowercase with underscores)`, ); } // Action category validation const validActionCategories = ["movement", "interaction", "sensors", "logic"]; if (action.category && !validActionCategories.includes(action.category)) { errors.push( `Action ${index}: invalid category '${action.category}'. Valid: ${validActionCategories.join(", ")}`, ); } // Parameter schema validation if (action.parameterSchema) { if (action.parameterSchema.type !== "object") { errors.push(`Action ${index}: parameterSchema.type must be 'object'`); } if (!action.parameterSchema.properties) { errors.push(`Action ${index}: parameterSchema must have properties`); } if (!Array.isArray(action.parameterSchema.required)) { errors.push(`Action ${index}: parameterSchema.required must be an array`); } } // Communication protocol validation const hasRos2 = action.ros2; const hasNaoqi = action.naoqi; const hasRestApi = action.restApi; if (!hasRos2 && !hasNaoqi && !hasRestApi) { errors.push( `Action ${index}: must have at least one communication protocol (ros2, naoqi, or restApi)`, ); } return errors; } // Repository validation function validateRepository() { const repoPath = path.resolve("repository.json"); if (!fs.existsSync(repoPath)) { throw new Error("repository.json not found"); } let repo; try { repo = JSON.parse(fs.readFileSync(repoPath, "utf8")); } catch (e) { throw new Error(`Invalid repository.json: ${e.message}`); } const errors = []; const warnings = []; // Required repository fields const requiredFields = [ "id", "name", "apiVersion", "pluginApiVersion", "trust", ]; requiredFields.forEach((field) => { if (!repo[field]) { errors.push(`Missing required repository field: ${field}`); } }); // Validate plugin count const indexPath = path.resolve("plugins/index.json"); if (fs.existsSync(indexPath)) { const index = JSON.parse(fs.readFileSync(indexPath, "utf8")); const actualCount = index.length; const reportedCount = repo.stats?.plugins || 0; if (actualCount !== reportedCount) { errors.push( `Plugin count mismatch: reported ${reportedCount}, actual ${actualCount}`, ); } } return { errors, warnings, repo }; } // Update plugin index function updateIndex() { const pluginsDir = path.resolve("plugins"); const indexPath = path.join(pluginsDir, "index.json"); if (!fs.existsSync(pluginsDir)) { throw new Error("plugins directory not found"); } const pluginFiles = fs .readdirSync(pluginsDir) .filter((file) => file.endsWith(".json") && file !== "index.json") .sort(); fs.writeFileSync(indexPath, JSON.stringify(pluginFiles, null, 2)); success(`Updated index.json with ${pluginFiles.length} plugins`); // Update repository stats const repoPath = path.resolve("repository.json"); if (fs.existsSync(repoPath)) { const repo = JSON.parse(fs.readFileSync(repoPath, "utf8")); repo.stats = repo.stats || {}; repo.stats.plugins = pluginFiles.length; fs.writeFileSync(repoPath, JSON.stringify(repo, null, 2)); success(`Updated repository stats: ${pluginFiles.length} plugins`); } } // Main CLI function main() { const args = process.argv.slice(2); const command = args[0]; try { switch (command) { case "validate": const pluginPath = args[1]; if (!pluginPath) { error("Usage: validate "); process.exit(1); } info(`Validating plugin: ${pluginPath}`); const { errors, warnings } = validatePlugin(pluginPath); if (errors.length > 0) { error("Validation failed:"); errors.forEach((err) => console.log(` - ${err}`)); } if (warnings.length > 0) { warn("Warnings:"); warnings.forEach((warn) => console.log(` - ${warn}`)); } if (errors.length === 0) { success("Plugin validation passed!"); if (warnings.length === 0) { success("No warnings found"); } } else { process.exit(1); } break; case "validate-all": info("Validating all plugins..."); // Validate repository const repoResult = validateRepository(); if (repoResult.errors.length > 0) { error("Repository validation failed:"); repoResult.errors.forEach((err) => console.log(` - ${err}`)); process.exit(1); } // Validate all plugins const indexPath = path.resolve("plugins/index.json"); if (!fs.existsSync(indexPath)) { error("plugins/index.json not found"); process.exit(1); } const index = JSON.parse(fs.readFileSync(indexPath, "utf8")); let allValid = true; for (const pluginFile of index) { const pluginPath = path.resolve("plugins", pluginFile); try { const { errors } = validatePlugin(pluginPath); if (errors.length > 0) { error(`${pluginFile}: ${errors.length} errors`); errors.forEach((err) => console.log(` - ${err}`)); allValid = false; } else { success(`${pluginFile}: valid`); } } catch (e) { error(`${pluginFile}: ${e.message}`); allValid = false; } } if (allValid) { success("All plugins are valid!"); } else { process.exit(1); } break; case "update-index": info("Updating plugin index..."); updateIndex(); break; case "help": default: console.log(` HRIStudio Plugin Validator Usage: validate Validate a single plugin file validate-all Validate all plugins and repository update-index Update plugins/index.json with all plugin files help Show this help message Examples: ./scripts/validate-plugin.js validate plugins/my-robot.json ./scripts/validate-plugin.js validate-all ./scripts/validate-plugin.js update-index `); break; } } catch (e) { error(`Error: ${e.message}`); process.exit(1); } } if (require.main === module) { main(); } module.exports = { validatePlugin, validateRepository, updateIndex, };