Files
robot-plugins/scripts/validate-plugin.js

384 lines
10 KiB
JavaScript
Executable File
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <plugin-file>');
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 <plugin-file> 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
};