mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-12 14:49:19 +00:00
Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal plugin offers and the ai plugin fills, gated on ai.services.use.
496 lines
15 KiB
JavaScript
496 lines
15 KiB
JavaScript
/**
|
|
* Validates a plugin manifest.json against scripts/plugin-manifest.schema.json.
|
|
*
|
|
* Hand-rolled rather than pulling in ajv: the schema is small and the checks
|
|
* that matter (required fields, id pattern, semver, known permissions) are a
|
|
* handful of straightforward comparisons.
|
|
*
|
|
* Usage: node scripts/validate-plugin-manifest.cjs <path-to-manifest.json>
|
|
*/
|
|
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const semver = require("semver");
|
|
|
|
const SCHEMA_PATH = path.join(__dirname, "plugin-manifest.schema.json");
|
|
|
|
const ID_PATTERN = /^[a-z0-9-]+$/;
|
|
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/;
|
|
const API_VERSION_PATTERN = /^[0-9]+$/;
|
|
const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
|
|
// Dotted like a service name, but segments may be camelCase: these name a
|
|
// frontend function ("ai.openWithContext"), not a lowercase service contract.
|
|
const ACTION_ID_PATTERN = /^[a-z0-9-]+(\.[a-zA-Z0-9-]+)+$/;
|
|
const HANDLER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
const OPEN_FROM_VALUES = ["rail", "host-context-menu", "palette"];
|
|
const ACTION_CONTRIBUTION_KINDS = ["button"];
|
|
|
|
function loadSchema() {
|
|
const raw = fs.readFileSync(SCHEMA_PATH, "utf8");
|
|
return JSON.parse(raw);
|
|
}
|
|
|
|
function knownPermissions(schema) {
|
|
return schema.properties.permissions.items.enum;
|
|
}
|
|
|
|
function knownCategories(schema) {
|
|
return schema.properties.category.enum;
|
|
}
|
|
|
|
function validateManifest(manifest, schema) {
|
|
const errors = [];
|
|
|
|
const requiredTopLevel = schema.required;
|
|
for (const field of requiredTopLevel) {
|
|
if (!(field in manifest)) {
|
|
errors.push(`Missing required field: "${field}"`);
|
|
}
|
|
}
|
|
|
|
if (typeof manifest.id === "string" && !ID_PATTERN.test(manifest.id)) {
|
|
errors.push(`Field "id" must match ${ID_PATTERN}, got: "${manifest.id}"`);
|
|
}
|
|
|
|
if (
|
|
typeof manifest.version === "string" &&
|
|
!SEMVER_PATTERN.test(manifest.version)
|
|
) {
|
|
errors.push(
|
|
`Field "version" must be valid semver, got: "${manifest.version}"`,
|
|
);
|
|
}
|
|
|
|
if (manifest.author && typeof manifest.author === "object") {
|
|
if (!manifest.author.name) {
|
|
errors.push('Field "author.name" is required');
|
|
}
|
|
}
|
|
|
|
if (typeof manifest.category === "string") {
|
|
const categories = knownCategories(schema);
|
|
if (!categories.includes(manifest.category)) {
|
|
errors.push(
|
|
`Field "category" must be one of: ${categories.join(", ")}, got: "${manifest.category}"`,
|
|
);
|
|
}
|
|
} else if ("category" in manifest) {
|
|
errors.push('Field "category" must be a string');
|
|
}
|
|
|
|
if ("icon" in manifest && typeof manifest.icon !== "string") {
|
|
errors.push('Field "icon" must be a string');
|
|
}
|
|
|
|
if (manifest.engine && typeof manifest.engine === "object") {
|
|
if (!manifest.engine.termix) {
|
|
errors.push('Field "engine.termix" is required');
|
|
}
|
|
if (!API_VERSION_PATTERN.test(String(manifest.engine.api))) {
|
|
errors.push(
|
|
`Field "engine.api" must be an integer-as-string, got: "${manifest.engine.api}"`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (manifest.capabilities && typeof manifest.capabilities === "object") {
|
|
for (const field of ["backend", "frontend", "electron"]) {
|
|
if (typeof manifest.capabilities[field] !== "boolean") {
|
|
errors.push(`Field "capabilities.${field}" must be a boolean`);
|
|
}
|
|
}
|
|
if (!Array.isArray(manifest.capabilities.platforms)) {
|
|
errors.push('Field "capabilities.platforms" must be an array');
|
|
}
|
|
}
|
|
|
|
const known = knownPermissions(schema);
|
|
if (Array.isArray(manifest.permissions)) {
|
|
for (const permission of manifest.permissions) {
|
|
if (!known.includes(permission)) {
|
|
errors.push(
|
|
`Unknown permission: "${permission}". Known values: ${known.join(", ")}`,
|
|
);
|
|
}
|
|
}
|
|
} else if ("permissions" in manifest) {
|
|
errors.push('Field "permissions" must be an array');
|
|
}
|
|
|
|
if (Array.isArray(manifest.sidecars)) {
|
|
manifest.sidecars.forEach((sidecar, index) => {
|
|
if (!sidecar || typeof sidecar !== "object") {
|
|
errors.push(`sidecars[${index}] must be an object`);
|
|
return;
|
|
}
|
|
if (!sidecar.id) errors.push(`sidecars[${index}].id is required`);
|
|
if (!sidecar.binary) errors.push(`sidecars[${index}].binary is required`);
|
|
});
|
|
} else if ("sidecars" in manifest) {
|
|
errors.push('Field "sidecars" must be an array');
|
|
}
|
|
|
|
errors.push(...validateProvides(manifest.provides));
|
|
errors.push(...validateRequires(manifest.requires));
|
|
|
|
if (manifest.contributes && typeof manifest.contributes === "object") {
|
|
errors.push(...validateContributes(manifest.contributes));
|
|
}
|
|
|
|
// A service permission has to be one the plugin's own permissionGroup
|
|
// declares, or no admin could ever grant it.
|
|
const declared = manifest.contributes?.permissionGroup?.permissions;
|
|
if (Array.isArray(manifest.provides) && Array.isArray(declared)) {
|
|
for (const entry of manifest.provides) {
|
|
if (entry && entry.permission && !declared.includes(entry.permission)) {
|
|
errors.push(
|
|
`Service "${entry.service}" is gated by "${entry.permission}", which is not declared in contributes.permissionGroup.permissions`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Same rule for UI actions: a permission the catalog never sees is one no
|
|
// admin can grant, so the action would be invisible rather than denied.
|
|
const actions = manifest.contributes?.actions;
|
|
if (Array.isArray(actions)) {
|
|
for (const action of actions) {
|
|
if (!action || !action.permission) continue;
|
|
if (!Array.isArray(declared) || !declared.includes(action.permission)) {
|
|
errors.push(
|
|
`Action "${action.id}" is gated by "${action.permission}", which is not declared in contributes.permissionGroup.permissions`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
function validateProvides(provides) {
|
|
if (provides === undefined) return [];
|
|
if (!Array.isArray(provides)) return ['Field "provides" must be an array'];
|
|
|
|
const errors = [];
|
|
const seen = new Set();
|
|
|
|
provides.forEach((entry, index) => {
|
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
errors.push(`provides[${index}] must be an object`);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
typeof entry.service !== "string" ||
|
|
!SERVICE_PATTERN.test(entry.service)
|
|
) {
|
|
errors.push(
|
|
`provides[${index}].service must match ${SERVICE_PATTERN}, got: "${entry.service}"`,
|
|
);
|
|
} else if (seen.has(entry.service)) {
|
|
errors.push(
|
|
`provides[${index}].service is a duplicate: "${entry.service}"`,
|
|
);
|
|
} else {
|
|
seen.add(entry.service);
|
|
}
|
|
|
|
if (
|
|
typeof entry.version !== "string" ||
|
|
!SEMVER_PATTERN.test(entry.version)
|
|
) {
|
|
errors.push(
|
|
`provides[${index}].version must be valid semver, got: "${entry.version}"`,
|
|
);
|
|
}
|
|
|
|
if (typeof entry.permission !== "string" || entry.permission.length === 0) {
|
|
errors.push(`provides[${index}].permission is required`);
|
|
}
|
|
});
|
|
|
|
return errors;
|
|
}
|
|
|
|
function validateRequires(requires) {
|
|
if (requires === undefined) return [];
|
|
if (!Array.isArray(requires)) return ['Field "requires" must be an array'];
|
|
|
|
const errors = [];
|
|
const seen = new Set();
|
|
|
|
requires.forEach((entry, index) => {
|
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
errors.push(`requires[${index}] must be an object`);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
typeof entry.service !== "string" ||
|
|
!SERVICE_PATTERN.test(entry.service)
|
|
) {
|
|
errors.push(
|
|
`requires[${index}].service must match ${SERVICE_PATTERN}, got: "${entry.service}"`,
|
|
);
|
|
} else if (seen.has(entry.service)) {
|
|
errors.push(
|
|
`requires[${index}].service is a duplicate: "${entry.service}"`,
|
|
);
|
|
} else {
|
|
seen.add(entry.service);
|
|
}
|
|
|
|
if (
|
|
typeof entry.versionRange !== "string" ||
|
|
semver.validRange(entry.versionRange) === null
|
|
) {
|
|
errors.push(
|
|
`requires[${index}].versionRange must be a valid semver range, got: "${entry.versionRange}"`,
|
|
);
|
|
}
|
|
|
|
if ("optional" in entry && typeof entry.optional !== "boolean") {
|
|
errors.push(`requires[${index}].optional must be a boolean`);
|
|
}
|
|
});
|
|
|
|
return errors;
|
|
}
|
|
|
|
function validateContributes(contributes) {
|
|
const errors = [];
|
|
|
|
if ("tabs" in contributes) {
|
|
if (!Array.isArray(contributes.tabs)) {
|
|
errors.push('Field "contributes.tabs" must be an array');
|
|
} else {
|
|
contributes.tabs.forEach((tab, index) => {
|
|
for (const field of ["id", "titleKey", "icon", "openFrom"]) {
|
|
if (!(field in (tab || {}))) {
|
|
errors.push(`contributes.tabs[${index}].${field} is required`);
|
|
}
|
|
}
|
|
if (Array.isArray(tab?.openFrom)) {
|
|
for (const value of tab.openFrom) {
|
|
if (!OPEN_FROM_VALUES.includes(value)) {
|
|
errors.push(
|
|
`contributes.tabs[${index}].openFrom has unknown value: "${value}"`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
if ("hostCapability" in contributes) {
|
|
for (const field of ["key", "labelKey", "editorTab"]) {
|
|
if (!(field in (contributes.hostCapability || {}))) {
|
|
errors.push(`contributes.hostCapability.${field} is required`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if ("permissionGroup" in contributes) {
|
|
const group = contributes.permissionGroup || {};
|
|
if (!group.group)
|
|
errors.push("contributes.permissionGroup.group is required");
|
|
if (!Array.isArray(group.permissions) || group.permissions.length === 0) {
|
|
errors.push(
|
|
"contributes.permissionGroup.permissions must be a non-empty array",
|
|
);
|
|
}
|
|
}
|
|
|
|
if ("settingsPanel" in contributes) {
|
|
if (!contributes.settingsPanel?.titleKey) {
|
|
errors.push("contributes.settingsPanel.titleKey is required");
|
|
}
|
|
}
|
|
|
|
if ("dashboardCards" in contributes) {
|
|
if (!Array.isArray(contributes.dashboardCards)) {
|
|
errors.push('Field "contributes.dashboardCards" must be an array');
|
|
} else {
|
|
contributes.dashboardCards.forEach((card, index) => {
|
|
for (const field of ["id", "titleKey"]) {
|
|
if (!(field in (card || {}))) {
|
|
errors.push(
|
|
`contributes.dashboardCards[${index}].${field} is required`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
if ("actions" in contributes) {
|
|
errors.push(...validateActions(contributes.actions));
|
|
}
|
|
|
|
if ("actionSlots" in contributes) {
|
|
errors.push(...validateActionSlots(contributes.actionSlots));
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
function validateActions(actions) {
|
|
if (!Array.isArray(actions)) {
|
|
return ['Field "contributes.actions" must be an array'];
|
|
}
|
|
|
|
const errors = [];
|
|
const seen = new Set();
|
|
|
|
actions.forEach((entry, index) => {
|
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
errors.push(`contributes.actions[${index}] must be an object`);
|
|
return;
|
|
}
|
|
|
|
if (typeof entry.id !== "string" || !ACTION_ID_PATTERN.test(entry.id)) {
|
|
errors.push(
|
|
`contributes.actions[${index}].id must match ${ACTION_ID_PATTERN}, got: "${entry.id}"`,
|
|
);
|
|
} else if (seen.has(entry.id)) {
|
|
errors.push(
|
|
`contributes.actions[${index}].id is a duplicate: "${entry.id}"`,
|
|
);
|
|
} else {
|
|
seen.add(entry.id);
|
|
}
|
|
|
|
if (typeof entry.titleKey !== "string" || entry.titleKey.length === 0) {
|
|
errors.push(`contributes.actions[${index}].titleKey is required`);
|
|
}
|
|
|
|
if (
|
|
typeof entry.handler !== "string" ||
|
|
!HANDLER_PATTERN.test(entry.handler)
|
|
) {
|
|
errors.push(
|
|
`contributes.actions[${index}].handler must match ${HANDLER_PATTERN}, got: "${entry.handler}"`,
|
|
);
|
|
}
|
|
|
|
if ("icon" in entry && typeof entry.icon !== "string") {
|
|
errors.push(`contributes.actions[${index}].icon must be a string`);
|
|
}
|
|
|
|
if ("permission" in entry && typeof entry.permission !== "string") {
|
|
errors.push(`contributes.actions[${index}].permission must be a string`);
|
|
}
|
|
|
|
if (
|
|
"slot" in entry &&
|
|
(typeof entry.slot !== "string" || !ACTION_ID_PATTERN.test(entry.slot))
|
|
) {
|
|
errors.push(
|
|
`contributes.actions[${index}].slot must match ${ACTION_ID_PATTERN}, got: "${entry.slot}"`,
|
|
);
|
|
}
|
|
|
|
if ("kind" in entry && !ACTION_CONTRIBUTION_KINDS.includes(entry.kind)) {
|
|
errors.push(
|
|
`contributes.actions[${index}].kind must be one of: ${ACTION_CONTRIBUTION_KINDS.join(", ")}, got: "${entry.kind}"`,
|
|
);
|
|
}
|
|
});
|
|
|
|
return errors;
|
|
}
|
|
|
|
function validateActionSlots(slots) {
|
|
if (!Array.isArray(slots)) {
|
|
return ['Field "contributes.actionSlots" must be an array'];
|
|
}
|
|
|
|
const errors = [];
|
|
const seen = new Set();
|
|
|
|
slots.forEach((entry, index) => {
|
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
errors.push(`contributes.actionSlots[${index}] must be an object`);
|
|
return;
|
|
}
|
|
|
|
if (typeof entry.id !== "string" || !ACTION_ID_PATTERN.test(entry.id)) {
|
|
errors.push(
|
|
`contributes.actionSlots[${index}].id must match ${ACTION_ID_PATTERN}, got: "${entry.id}"`,
|
|
);
|
|
} else if (seen.has(entry.id)) {
|
|
errors.push(
|
|
`contributes.actionSlots[${index}].id is a duplicate: "${entry.id}"`,
|
|
);
|
|
} else {
|
|
seen.add(entry.id);
|
|
}
|
|
|
|
if (!Array.isArray(entry.accepts) || entry.accepts.length === 0) {
|
|
errors.push(
|
|
`contributes.actionSlots[${index}].accepts must be a non-empty array`,
|
|
);
|
|
} else {
|
|
for (const kind of entry.accepts) {
|
|
if (!ACTION_CONTRIBUTION_KINDS.includes(kind)) {
|
|
errors.push(
|
|
`contributes.actionSlots[${index}].accepts has unknown value: "${kind}". Known values: ${ACTION_CONTRIBUTION_KINDS.join(", ")}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if ("descriptionKey" in entry && typeof entry.descriptionKey !== "string") {
|
|
errors.push(
|
|
`contributes.actionSlots[${index}].descriptionKey must be a string`,
|
|
);
|
|
}
|
|
});
|
|
|
|
return errors;
|
|
}
|
|
|
|
function main() {
|
|
const manifestPath = process.argv[2];
|
|
if (!manifestPath) {
|
|
console.error(
|
|
"Usage: node scripts/validate-plugin-manifest.cjs <path-to-manifest.json>",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const resolvedPath = path.resolve(manifestPath);
|
|
if (!fs.existsSync(resolvedPath)) {
|
|
console.error(`Manifest not found: ${resolvedPath}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
let manifest;
|
|
try {
|
|
manifest = JSON.parse(fs.readFileSync(resolvedPath, "utf8"));
|
|
} catch (err) {
|
|
console.error(`Manifest is not valid JSON: ${err.message}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const schema = loadSchema();
|
|
const errors = validateManifest(manifest, schema);
|
|
|
|
if (errors.length > 0) {
|
|
console.error(`Invalid plugin manifest: ${resolvedPath}`);
|
|
for (const error of errors) {
|
|
console.error(` - ${error}`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`Valid plugin manifest: ${resolvedPath}`);
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main();
|
|
}
|
|
|
|
module.exports = { validateManifest, loadSchema };
|