mirror of
https://github.com/soconnor0919/robot-plugins.git
synced 2025-12-15 08:24:45 -05:00
Update for new HRIStudio build
This commit is contained in:
383
scripts/validate-plugin.js
Normal file
383
scripts/validate-plugin.js
Normal file
@@ -0,0 +1,383 @@
|
||||
#!/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
|
||||
};
|
||||
Reference in New Issue
Block a user