mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: collapse plugin runtime to one tier behind a plugin SDK
This commit is contained in:
1 parent
6e058f6e9e
commit
4758016f41
96 files changed
+34631
-8551
No files matched your search
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"id": "sample-bad-action",
|
||||
"name": "Sample Bad Action",
|
||||
"id": "sample-plugin",
|
||||
"name": "Sample",
|
||||
"version": "1.0.0",
|
||||
"description": "An action gated by a permission the plugin never declares.",
|
||||
"description": "A sample.",
|
||||
"author": {
|
||||
"name": "Jane Doe"
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"category": "Productivity",
|
||||
@@ -12,26 +12,19 @@
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": {
|
||||
"backend": false,
|
||||
"frontend": true,
|
||||
"electron": false,
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
},
|
||||
"permissions": ["ui.tab"],
|
||||
"capabilities": ["kv:own", "ui:surface"],
|
||||
"contributes": {
|
||||
"permissionGroup": {
|
||||
"group": "samplebadaction",
|
||||
"permissions": ["samplebadaction.use"]
|
||||
"group": "sample-plugin",
|
||||
"permissions": ["sample-plugin.use"]
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"id": "samplebadaction.open",
|
||||
"titleKey": "sampleBadAction.open",
|
||||
"id": "sample.open",
|
||||
"titleKey": "sample.open",
|
||||
"handler": "open",
|
||||
"permission": "samplebadaction.never_declared"
|
||||
"permission": "sample-plugin.nope"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sidecars": []
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"id": "Sample_Plugin!",
|
||||
"name": "Sample Plugin",
|
||||
"id": "Sample_Plugin",
|
||||
"name": "Sample",
|
||||
"version": "1.0.0",
|
||||
"description": "Has an id that violates the required pattern.",
|
||||
"description": "A sample.",
|
||||
"author": {
|
||||
"name": "Jane Doe"
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"category": "Productivity",
|
||||
@@ -12,12 +12,5 @@
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": {
|
||||
"backend": true,
|
||||
"frontend": true,
|
||||
"electron": false,
|
||||
"platforms": ["linux"]
|
||||
},
|
||||
"permissions": [],
|
||||
"sidecars": []
|
||||
"capabilities": ["kv:own", "ui:surface"]
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"id": "sample-plugin",
|
||||
"name": "Sample Plugin",
|
||||
"name": "Sample",
|
||||
"version": "1.0.0",
|
||||
"description": "A sample.",
|
||||
"author": {
|
||||
"name": "Jane Doe"
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"category": "Productivity",
|
||||
@@ -11,12 +11,5 @@
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": {
|
||||
"backend": true,
|
||||
"frontend": true,
|
||||
"electron": false,
|
||||
"platforms": ["linux"]
|
||||
},
|
||||
"permissions": [],
|
||||
"sidecars": []
|
||||
"capabilities": ["kv:own", "ui:surface"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"id": "sample-plugin",
|
||||
"name": "Sample",
|
||||
"version": "1.0.0",
|
||||
"description": "A sample.",
|
||||
"author": {
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"category": "Productivity",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": ["kv:own", "ui:surface"],
|
||||
"contributes": {
|
||||
"tabs": [
|
||||
{
|
||||
"id": "t",
|
||||
"titleKey": "k",
|
||||
"icon": "i",
|
||||
"openFrom": ["rail"],
|
||||
"extra": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"id": "sample-plugin",
|
||||
"name": "Sample",
|
||||
"version": "1.0.0",
|
||||
"description": "A sample.",
|
||||
"author": {
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"category": "Productivity",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": ["kv:own", "ui:surface"],
|
||||
"contributes": {
|
||||
"permissionGroup": {
|
||||
"group": "sample-plugin",
|
||||
"permissions": ["sample-plugin.use"],
|
||||
"defaultForRole": {
|
||||
"user": ["admin.users.manage"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"id": "sample-provider",
|
||||
"name": "Sample Provider",
|
||||
"id": "sample-plugin",
|
||||
"name": "Sample",
|
||||
"version": "1.0.0",
|
||||
"description": "Gates a service behind a permission its own group never declares.",
|
||||
"description": "A sample.",
|
||||
"author": {
|
||||
"name": "Jane Doe"
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"category": "Productivity",
|
||||
@@ -12,25 +12,18 @@
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": {
|
||||
"backend": true,
|
||||
"frontend": false,
|
||||
"electron": false,
|
||||
"platforms": ["linux"]
|
||||
},
|
||||
"permissions": ["hosts.read"],
|
||||
"contributes": {
|
||||
"permissionGroup": {
|
||||
"group": "sampleprovider",
|
||||
"permissions": ["sampleprovider.greet.use"]
|
||||
}
|
||||
},
|
||||
"capabilities": ["kv:own", "ui:surface"],
|
||||
"provides": [
|
||||
{
|
||||
"service": "sampleprovider.greet",
|
||||
"version": "1.2.0",
|
||||
"permission": "sampleprovider.undeclared"
|
||||
"service": "sample.thing",
|
||||
"version": "1.0.0",
|
||||
"permission": "sample-plugin.undeclared"
|
||||
}
|
||||
],
|
||||
"sidecars": []
|
||||
"contributes": {
|
||||
"permissionGroup": {
|
||||
"group": "sample-plugin",
|
||||
"permissions": ["sample-plugin.use"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "sample-plugin",
|
||||
"name": "Sample",
|
||||
"version": "1.0.0",
|
||||
"description": "A sample.",
|
||||
"author": {
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"category": "Productivity",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": ["hosts.read"]
|
||||
}
|
||||
@@ -1,23 +1,16 @@
|
||||
{
|
||||
"id": "sample-plugin",
|
||||
"name": "Sample Plugin",
|
||||
"name": "Sample",
|
||||
"version": "1.0.0",
|
||||
"description": "Has a category that is not in the known category list.",
|
||||
"description": "A sample.",
|
||||
"author": {
|
||||
"name": "Jane Doe"
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"category": "Miscellaneous",
|
||||
"category": "Nonsense",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": {
|
||||
"backend": true,
|
||||
"frontend": true,
|
||||
"electron": false,
|
||||
"platforms": ["linux"]
|
||||
},
|
||||
"permissions": [],
|
||||
"sidecars": []
|
||||
"capabilities": ["kv:own", "ui:surface"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "sample-plugin",
|
||||
"name": "Sample",
|
||||
"version": "1.0.0",
|
||||
"description": "A sample.",
|
||||
"author": {
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"category": "Productivity",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": ["kv:own", "ui:surface"],
|
||||
"sudoEverything": true
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"id": "sample-plugin",
|
||||
"name": "Sample Plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "Requests a permission that is not in the known capability list.",
|
||||
"author": {
|
||||
"name": "Jane Doe"
|
||||
},
|
||||
"license": "MIT",
|
||||
"category": "Productivity",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": {
|
||||
"backend": true,
|
||||
"frontend": true,
|
||||
"electron": false,
|
||||
"platforms": ["linux"]
|
||||
},
|
||||
"permissions": ["credentials.read"],
|
||||
"sidecars": []
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"id": "sample-actions",
|
||||
"name": "Sample Actions",
|
||||
"id": "sample-plugin",
|
||||
"name": "Sample",
|
||||
"version": "1.0.0",
|
||||
"description": "A plugin that contributes a UI action and offers a slot of its own.",
|
||||
"description": "A sample.",
|
||||
"author": {
|
||||
"name": "Jane Doe"
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"category": "Productivity",
|
||||
@@ -12,41 +12,19 @@
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": {
|
||||
"backend": false,
|
||||
"frontend": true,
|
||||
"electron": false,
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
},
|
||||
"permissions": ["ui.tab"],
|
||||
"capabilities": ["kv:own", "ui:surface"],
|
||||
"contributes": {
|
||||
"permissionGroup": {
|
||||
"group": "sampleactions",
|
||||
"permissions": ["sampleactions.use"]
|
||||
"group": "sample-plugin",
|
||||
"permissions": ["sample-plugin.use"]
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"id": "sampleactions.openWithContext",
|
||||
"titleKey": "sampleActions.open",
|
||||
"handler": "openWithContext",
|
||||
"icon": "Sparkles",
|
||||
"permission": "sampleactions.use",
|
||||
"slot": "terminal.toolbar",
|
||||
"kind": "button"
|
||||
},
|
||||
{
|
||||
"id": "sampleactions.ungated",
|
||||
"titleKey": "sampleActions.ungated",
|
||||
"handler": "ungated"
|
||||
}
|
||||
],
|
||||
"actionSlots": [
|
||||
{
|
||||
"id": "sampleactions.panel",
|
||||
"accepts": ["button"],
|
||||
"descriptionKey": "sampleActions.panelDescription"
|
||||
"id": "sample.open",
|
||||
"titleKey": "sample.open",
|
||||
"handler": "open",
|
||||
"permission": "sample-plugin.use"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sidecars": []
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"id": "sample-provider",
|
||||
"name": "Sample Provider",
|
||||
"id": "sample-plugin",
|
||||
"name": "Sample",
|
||||
"version": "1.0.0",
|
||||
"description": "A plugin that both provides and requires a named service.",
|
||||
"description": "A sample.",
|
||||
"author": {
|
||||
"name": "Jane Doe"
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"category": "Productivity",
|
||||
@@ -12,32 +12,18 @@
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": {
|
||||
"backend": true,
|
||||
"frontend": false,
|
||||
"electron": false,
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
},
|
||||
"permissions": ["hosts.read"],
|
||||
"contributes": {
|
||||
"permissionGroup": {
|
||||
"group": "sampleprovider",
|
||||
"permissions": ["sampleprovider.greet.use"]
|
||||
}
|
||||
},
|
||||
"capabilities": ["kv:own", "ui:surface"],
|
||||
"provides": [
|
||||
{
|
||||
"service": "sampleprovider.greet",
|
||||
"version": "1.2.0",
|
||||
"permission": "sampleprovider.greet.use"
|
||||
"service": "sample.thing",
|
||||
"version": "1.0.0",
|
||||
"permission": "sample-plugin.use"
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
{
|
||||
"service": "other.thing",
|
||||
"versionRange": "^1.0.0",
|
||||
"optional": true
|
||||
"contributes": {
|
||||
"permissionGroup": {
|
||||
"group": "sample-plugin",
|
||||
"permissions": ["sample-plugin.use"]
|
||||
}
|
||||
],
|
||||
"sidecars": []
|
||||
}
|
||||
}
|
||||
@@ -1,45 +1,16 @@
|
||||
{
|
||||
"id": "sample-plugin",
|
||||
"name": "Sample Plugin",
|
||||
"name": "Sample",
|
||||
"version": "1.0.0",
|
||||
"description": "A sample plugin used for schema validation tests.",
|
||||
"description": "A sample.",
|
||||
"author": {
|
||||
"name": "Jane Doe",
|
||||
"url": "https://example.com"
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/example/sample-plugin",
|
||||
"category": "Productivity",
|
||||
"icon": "Box",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": {
|
||||
"backend": true,
|
||||
"frontend": true,
|
||||
"electron": false,
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
},
|
||||
"permissions": ["hosts.read", "ui.tab", "ui.rail"],
|
||||
"contributes": {
|
||||
"tabs": [
|
||||
{
|
||||
"id": "sample-plugin-tab",
|
||||
"titleKey": "samplePlugin.tab.title",
|
||||
"icon": "Box",
|
||||
"openFrom": ["rail", "palette"]
|
||||
}
|
||||
],
|
||||
"settingsPanel": {
|
||||
"titleKey": "samplePlugin.settings.title"
|
||||
},
|
||||
"apiPrefix": "sample"
|
||||
},
|
||||
"dependencies": {
|
||||
"plugins": {
|
||||
"other-plugin": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"sidecars": []
|
||||
"capabilities": ["kv:own", "ui:surface"]
|
||||
}
|
||||
@@ -1,415 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://termix.site/schemas/plugin-manifest.schema.json",
|
||||
"title": "Termix Plugin Manifest",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"version",
|
||||
"description",
|
||||
"author",
|
||||
"license",
|
||||
"engine",
|
||||
"category",
|
||||
"capabilities",
|
||||
"permissions",
|
||||
"sidecars"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9-]+$",
|
||||
"description": "Unique plugin id, lowercase alphanumeric and hyphens only."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z-.]+)?(\\+[0-9A-Za-z-.]+)?$",
|
||||
"description": "Semver version string."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"author": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": { "type": "string", "minLength": 1 },
|
||||
"url": { "type": "string", "format": "uri" }
|
||||
}
|
||||
},
|
||||
"license": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"repository": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Terminal",
|
||||
"Files & Transfer",
|
||||
"Infrastructure",
|
||||
"Monitoring",
|
||||
"Networking",
|
||||
"Access & Security",
|
||||
"Productivity"
|
||||
],
|
||||
"description": "Store category used for browsing/filtering. Must match one of the categories the store UI filters by."
|
||||
},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Either a Lucide icon name (same convention as contributes.tabs[].icon) or a relative path to an icon.svg shipped in the plugin package root. If both a Lucide name and an icon.svg are present, the registry-index build should prefer icon.svg."
|
||||
},
|
||||
"engine": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["termix", "api"],
|
||||
"properties": {
|
||||
"termix": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Informational-only Termix version range this plugin targets. Not mechanically enforced."
|
||||
},
|
||||
"api": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]+$",
|
||||
"description": "Plugin SDK major version, as a string. The real compatibility gate."
|
||||
}
|
||||
}
|
||||
},
|
||||
"capabilities": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["backend", "frontend", "electron", "platforms"],
|
||||
"properties": {
|
||||
"backend": { "type": "boolean" },
|
||||
"frontend": { "type": "boolean" },
|
||||
"electron": { "type": "boolean" },
|
||||
"platforms": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"hosts.read",
|
||||
"hosts.write",
|
||||
"credentials.use",
|
||||
"ssh.exec",
|
||||
"ssh.sftp",
|
||||
"storage.own",
|
||||
"storage.secrets",
|
||||
"network.outbound",
|
||||
"events.read",
|
||||
"notify.send",
|
||||
"users.read",
|
||||
"process.sidecar",
|
||||
"process:transport-owner",
|
||||
"ui.tab",
|
||||
"ui.rail",
|
||||
"ui.card",
|
||||
"ui.settings",
|
||||
"ui.palette"
|
||||
]
|
||||
}
|
||||
},
|
||||
"contributes": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tabs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "titleKey", "icon", "openFrom"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"titleKey": { "type": "string", "minLength": 1 },
|
||||
"icon": { "type": "string", "minLength": 1 },
|
||||
"openFrom": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["rail", "host-context-menu", "palette"]
|
||||
},
|
||||
"minItems": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"hostCapability": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["key", "labelKey", "editorTab"],
|
||||
"properties": {
|
||||
"key": { "type": "string", "minLength": 1 },
|
||||
"labelKey": { "type": "string", "minLength": 1 },
|
||||
"editorTab": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["key", "labelKey", "editorTab"],
|
||||
"properties": {
|
||||
"key": { "type": "string", "minLength": 1 },
|
||||
"labelKey": { "type": "string", "minLength": 1 },
|
||||
"editorTab": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"permissionGroup": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["group", "permissions"],
|
||||
"properties": {
|
||||
"group": { "type": "string", "minLength": 1 },
|
||||
"permissions": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 },
|
||||
"minItems": 1
|
||||
},
|
||||
"defaultForRole": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settingsPanel": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["titleKey"],
|
||||
"properties": {
|
||||
"titleKey": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
},
|
||||
"dashboardCards": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "titleKey"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"titleKey": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"apiPrefix": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9-]+$",
|
||||
"description": "Mounted at /plugin-api/<id>/<apiPrefix>/*."
|
||||
},
|
||||
"actions": {
|
||||
"type": "array",
|
||||
"description": "Named frontend actions this plugin exposes for other plugins to invoke.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "titleKey", "handler"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9-]+(\\.[a-zA-Z0-9-]+)+$",
|
||||
"description": "Dotted, globally unique action name, e.g. \"ai.openWithContext\"."
|
||||
},
|
||||
"titleKey": { "type": "string", "minLength": 1 },
|
||||
"handler": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z_$][A-Za-z0-9_$]*$",
|
||||
"description": "Name of the frontend export implementing this action. The runtime binding is registerAction(id, fn)."
|
||||
},
|
||||
"icon": { "type": "string", "minLength": 1 },
|
||||
"permission": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Role permission a user needs. Must also appear in contributes.permissionGroup.permissions."
|
||||
},
|
||||
"slot": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9-]+(\\.[a-zA-Z0-9-]+)+$",
|
||||
"description": "Slot this action is intended for. Advisory; binding happens at runtime."
|
||||
},
|
||||
"kind": { "type": "string", "enum": ["button"] }
|
||||
}
|
||||
}
|
||||
},
|
||||
"actionSlots": {
|
||||
"type": "array",
|
||||
"description": "Places this plugin offers other plugins to contribute a UI element.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "accepts"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9-]+(\\.[a-zA-Z0-9-]+)+$",
|
||||
"description": "Dotted slot name, e.g. \"terminal.toolbar\"."
|
||||
},
|
||||
"accepts": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "type": "string", "enum": ["button"] },
|
||||
"description": "Contribution kinds this slot will render. Checked in the frontend at registration time."
|
||||
},
|
||||
"descriptionKey": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"plugins": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Semver range string."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"provides": {
|
||||
"type": "array",
|
||||
"description": "Named services this plugin exposes to other plugins.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["service", "version", "permission"],
|
||||
"properties": {
|
||||
"service": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9-]+(\\.[a-z0-9-]+)+$",
|
||||
"description": "Dotted service name, e.g. \"ssh.transport\"."
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z-.]+)?(\\+[0-9A-Za-z-.]+)?$",
|
||||
"description": "Semver version of this service contract."
|
||||
},
|
||||
"permission": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Role permission a calling user needs. Must also appear in contributes.permissionGroup.permissions."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"requires": {
|
||||
"type": "array",
|
||||
"description": "Named services this plugin needs another active plugin to provide.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["service", "versionRange"],
|
||||
"properties": {
|
||||
"service": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9-]+(\\.[a-z0-9-]+)+$"
|
||||
},
|
||||
"versionRange": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Semver range, e.g. \"^1.2.0\"."
|
||||
},
|
||||
"optional": {
|
||||
"type": "boolean",
|
||||
"description": "When true, an unsatisfied requirement is skipped instead of failing activation."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"providesSecret": {
|
||||
"type": "array",
|
||||
"description": "Secrets this plugin offers other plugins by reference. The value is never copied; a borrower re-reads it per call.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["key", "permission"],
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9-]+$",
|
||||
"description": "Secret name within this plugin, e.g. \"api-key\"."
|
||||
},
|
||||
"permission": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Role permission a borrowing user needs. Must also appear in contributes.permissionGroup.permissions."
|
||||
},
|
||||
"descriptionKey": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "i18n key describing the sharing, shown in the admin UI."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"requiresSecret": {
|
||||
"type": "array",
|
||||
"description": "Secrets this plugin borrows from another plugin. Reads resolve to null when the provider is absent or has nothing set.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["plugin", "key"],
|
||||
"properties": {
|
||||
"plugin": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9-]+$",
|
||||
"description": "Plugin id that offers the secret."
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9-]+$"
|
||||
},
|
||||
"optional": {
|
||||
"type": "boolean",
|
||||
"description": "When true, an absent provider is normal rather than reported as an unmet requirement."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidecars": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "binary"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"binary": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,622 +1,88 @@
|
||||
/**
|
||||
* Validates a plugin manifest.json against scripts/plugin-manifest.schema.json.
|
||||
* Validates plugin manifests against the SDK contract.
|
||||
*
|
||||
* 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.
|
||||
* node scripts/validate-plugin-manifest.cjs # every bundled manifest
|
||||
* node scripts/validate-plugin-manifest.cjs path/to/manifest.json
|
||||
*
|
||||
* Usage: node scripts/validate-plugin-manifest.cjs <path-to-manifest.json>
|
||||
* The rules live in packages/plugin-sdk/src/manifest.ts, which the server
|
||||
* uses too, so there is one implementation rather than a copy here that
|
||||
* drifts. This file only finds the manifests and prints the result.
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const semver = require("semver");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
|
||||
const SCHEMA_PATH = path.join(__dirname, "plugin-manifest.schema.json");
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const sdkEntry = path.join(root, "packages", "plugin-sdk", "dist", "manifest.js");
|
||||
|
||||
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-]+)+$/;
|
||||
const SECRET_KEY_PATTERN = /^[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));
|
||||
errors.push(...validateProvidesSecret(manifest.providesSecret));
|
||||
errors.push(...validateRequiresSecret(manifest.requiresSecret));
|
||||
|
||||
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 a shared secret: sharing rides on the provider's own RBAC
|
||||
// permission rather than a separate grant mechanism.
|
||||
if (Array.isArray(manifest.providesSecret) && Array.isArray(declared)) {
|
||||
for (const entry of manifest.providesSecret) {
|
||||
if (entry && entry.permission && !declared.includes(entry.permission)) {
|
||||
errors.push(
|
||||
`Secret "${entry.key}" is gated by "${entry.permission}", which is not declared in contributes.permissionGroup.permissions`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(manifest.requiresSecret)) {
|
||||
for (const entry of manifest.requiresSecret) {
|
||||
if (entry && entry.plugin && entry.plugin === manifest.id) {
|
||||
errors.push(
|
||||
`requiresSecret entry for "${entry.key}" names this plugin itself; use ctx.secrets.get instead`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 validateProvidesSecret(providesSecret) {
|
||||
if (providesSecret === undefined) return [];
|
||||
if (!Array.isArray(providesSecret)) {
|
||||
return ['Field "providesSecret" must be an array'];
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
const seen = new Set();
|
||||
|
||||
providesSecret.forEach((entry, index) => {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
errors.push(`providesSecret[${index}] must be an object`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof entry.key !== "string" || !SECRET_KEY_PATTERN.test(entry.key)) {
|
||||
errors.push(
|
||||
`providesSecret[${index}].key must match ${SECRET_KEY_PATTERN}, got: "${entry.key}"`,
|
||||
);
|
||||
} else if (seen.has(entry.key)) {
|
||||
errors.push(
|
||||
`providesSecret[${index}].key is a duplicate: "${entry.key}"`,
|
||||
);
|
||||
} else {
|
||||
seen.add(entry.key);
|
||||
}
|
||||
|
||||
if (typeof entry.permission !== "string" || entry.permission.length === 0) {
|
||||
errors.push(`providesSecret[${index}].permission is required`);
|
||||
}
|
||||
|
||||
if (
|
||||
"descriptionKey" in entry &&
|
||||
(typeof entry.descriptionKey !== "string" ||
|
||||
entry.descriptionKey.length === 0)
|
||||
) {
|
||||
errors.push(`providesSecret[${index}].descriptionKey must be a string`);
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateRequiresSecret(requiresSecret) {
|
||||
if (requiresSecret === undefined) return [];
|
||||
if (!Array.isArray(requiresSecret)) {
|
||||
return ['Field "requiresSecret" must be an array'];
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
const seen = new Set();
|
||||
|
||||
requiresSecret.forEach((entry, index) => {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
errors.push(`requiresSecret[${index}] must be an object`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof entry.plugin !== "string" || !ID_PATTERN.test(entry.plugin)) {
|
||||
errors.push(
|
||||
`requiresSecret[${index}].plugin must match ${ID_PATTERN}, got: "${entry.plugin}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof entry.key !== "string" || !SECRET_KEY_PATTERN.test(entry.key)) {
|
||||
errors.push(
|
||||
`requiresSecret[${index}].key must match ${SECRET_KEY_PATTERN}, got: "${entry.key}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof entry.plugin === "string" && typeof entry.key === "string") {
|
||||
const id = `${entry.plugin}:${entry.key}`;
|
||||
if (seen.has(id)) {
|
||||
errors.push(`requiresSecret[${index}] is a duplicate: "${id}"`);
|
||||
} else {
|
||||
seen.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
if ("optional" in entry && typeof entry.optional !== "boolean") {
|
||||
errors.push(`requiresSecret[${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) {
|
||||
const raw = contributes.hostCapability;
|
||||
const entries = Array.isArray(raw) ? raw : [raw];
|
||||
const seenKeys = new Set();
|
||||
entries.forEach((entry, index) => {
|
||||
const hc = entry || {};
|
||||
const prefix = Array.isArray(raw)
|
||||
? `contributes.hostCapability[${index}]`
|
||||
: "contributes.hostCapability";
|
||||
for (const field of ["key", "labelKey", "editorTab"]) {
|
||||
if (!(field in hc)) {
|
||||
errors.push(`${prefix}.${field} is required`);
|
||||
}
|
||||
}
|
||||
if (typeof hc.key === "string") {
|
||||
if (seenKeys.has(hc.key)) {
|
||||
errors.push(`${prefix}.key is a duplicate: "${hc.key}"`);
|
||||
}
|
||||
seenKeys.add(hc.key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
async function loadValidator() {
|
||||
if (!fs.existsSync(sdkEntry)) {
|
||||
console.error(
|
||||
"Usage: node scripts/validate-plugin-manifest.cjs <path-to-manifest.json>",
|
||||
`The plugin SDK is not built yet (${path.relative(root, sdkEntry)} is missing).\n` +
|
||||
`Run: npm run build:sdk`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return import(pathToFileURL(sdkEntry).href);
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(manifestPath);
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
console.error(`Manifest not found: ${resolvedPath}`);
|
||||
process.exit(1);
|
||||
function bundledManifests() {
|
||||
const pluginsDir = path.join(root, "plugins");
|
||||
if (!fs.existsSync(pluginsDir)) return [];
|
||||
|
||||
return fs
|
||||
.readdirSync(pluginsDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(pluginsDir, entry.name, "manifest.json"))
|
||||
.filter((file) => fs.existsSync(file));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { parseManifest } = await loadValidator();
|
||||
|
||||
const argument = process.argv[2];
|
||||
const files = argument
|
||||
? [path.resolve(process.cwd(), argument)]
|
||||
: bundledManifests();
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log("No plugin manifests found.");
|
||||
return;
|
||||
}
|
||||
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(fs.readFileSync(resolvedPath, "utf8"));
|
||||
} catch (err) {
|
||||
console.error(`Manifest is not valid JSON: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
let failed = 0;
|
||||
|
||||
const schema = loadSchema();
|
||||
const errors = validateManifest(manifest, schema);
|
||||
for (const file of files) {
|
||||
const relative = path.relative(root, file);
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(`Invalid plugin manifest: ${resolvedPath}`);
|
||||
for (const error of errors) {
|
||||
console.error(` - ${error}`);
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
} catch (error) {
|
||||
console.error(`${relative}: not valid JSON: ${error.message}`);
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
process.exit(1);
|
||||
|
||||
const { errors } = parseManifest(raw);
|
||||
if (errors.length > 0) {
|
||||
console.error(`${relative}:`);
|
||||
for (const error of errors) console.error(` - ${error}`);
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`ok ${relative}`);
|
||||
}
|
||||
|
||||
console.log(`Valid plugin manifest: ${resolvedPath}`);
|
||||
if (failed > 0) {
|
||||
console.error(`\n${failed} manifest(s) failed validation.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { validateManifest, loadSchema };
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -5,13 +5,12 @@ import path from "node:path";
|
||||
const VALIDATOR = path.join(__dirname, "validate-plugin-manifest.cjs");
|
||||
const FIXTURES = path.join(__dirname, "__fixtures__", "manifests");
|
||||
|
||||
function runValidator(fixture: string): { status: number; output: string } {
|
||||
function runValidator(fixture?: string): { status: number; output: string } {
|
||||
const args = fixture
|
||||
? [VALIDATOR, path.join(FIXTURES, fixture)]
|
||||
: [VALIDATOR];
|
||||
try {
|
||||
const output = execFileSync(
|
||||
"node",
|
||||
[VALIDATOR, path.join(FIXTURES, fixture)],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
const output = execFileSync("node", args, { encoding: "utf8" });
|
||||
return { status: 0, output };
|
||||
} catch (err) {
|
||||
const error = err as { status: number; stdout: string; stderr: string };
|
||||
@@ -23,54 +22,76 @@ describe("validate-plugin-manifest.cjs", () => {
|
||||
it("accepts a valid manifest", () => {
|
||||
const { status, output } = runValidator("valid.json");
|
||||
expect(status).toBe(0);
|
||||
expect(output).toContain("Valid plugin manifest");
|
||||
expect(output).toContain("ok");
|
||||
});
|
||||
|
||||
it("accepts a manifest declaring services", () => {
|
||||
expect(runValidator("valid-services.json").status).toBe(0);
|
||||
});
|
||||
|
||||
it("accepts a manifest declaring actions", () => {
|
||||
expect(runValidator("valid-actions.json").status).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a manifest missing a required field", () => {
|
||||
const { status, output } = runValidator("invalid-missing-field.json");
|
||||
expect(status).not.toBe(0);
|
||||
expect(output).toContain('Missing required field: "description"');
|
||||
expect(status).toBe(1);
|
||||
expect(output).toContain("id");
|
||||
});
|
||||
|
||||
it("rejects a manifest with a bad id pattern", () => {
|
||||
it("rejects an id that is not a lowercase slug", () => {
|
||||
const { status, output } = runValidator("invalid-bad-id.json");
|
||||
expect(status).not.toBe(0);
|
||||
expect(output).toMatch(/Field "id" must match/);
|
||||
expect(status).toBe(1);
|
||||
expect(output).toContain("id");
|
||||
});
|
||||
|
||||
it("accepts a manifest declaring provides and requires", () => {
|
||||
const { status, output } = runValidator("valid-services.json");
|
||||
it("rejects an unknown category", () => {
|
||||
expect(runValidator("invalid-unknown-category.json").status).toBe(1);
|
||||
});
|
||||
|
||||
// The dotted spelling was the v1 form. Capabilities are colon-separated now
|
||||
// so they can never be confused with RBAC permissions.
|
||||
it("rejects a dotted capability name", () => {
|
||||
const { status, output } = runValidator("invalid-unknown-capability.json");
|
||||
expect(status).toBe(1);
|
||||
expect(output).toContain("hosts.read");
|
||||
});
|
||||
|
||||
it("rejects a service permission the plugin does not declare", () => {
|
||||
expect(runValidator("invalid-service-permission.json").status).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects an action permission the plugin does not declare", () => {
|
||||
expect(runValidator("invalid-action-permission.json").status).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects an unknown top-level field", () => {
|
||||
const { status, output } = runValidator("invalid-unknown-field.json");
|
||||
expect(status).toBe(1);
|
||||
expect(output).toContain("sudoEverything");
|
||||
});
|
||||
|
||||
it("rejects an unknown field nested inside contributes", () => {
|
||||
const { status, output } = runValidator(
|
||||
"invalid-nested-unknown-field.json",
|
||||
);
|
||||
expect(status).toBe(1);
|
||||
expect(output).toContain("extra");
|
||||
});
|
||||
|
||||
// A manifest must not be able to hand a role a permission the plugin does
|
||||
// not own, which is how a plugin could otherwise grant itself admin rights.
|
||||
it("rejects a role default outside the plugin's own namespace", () => {
|
||||
const { status, output } = runValidator(
|
||||
"invalid-role-default-escalation.json",
|
||||
);
|
||||
expect(status).toBe(1);
|
||||
expect(output).toContain("admin.users.manage");
|
||||
});
|
||||
|
||||
it("validates every bundled manifest when given no argument", () => {
|
||||
const { status, output } = runValidator();
|
||||
expect(status).toBe(0);
|
||||
expect(output).toContain("Valid plugin manifest");
|
||||
});
|
||||
|
||||
it("rejects a service gated by an undeclared permission", () => {
|
||||
const { status, output } = runValidator("invalid-service-permission.json");
|
||||
expect(status).not.toBe(0);
|
||||
expect(output).toContain("sampleprovider.undeclared");
|
||||
});
|
||||
|
||||
it("accepts a manifest declaring actions and actionSlots", () => {
|
||||
const { status, output } = runValidator("valid-actions.json");
|
||||
expect(status).toBe(0);
|
||||
expect(output).toContain("Valid plugin manifest");
|
||||
});
|
||||
|
||||
it("rejects an action gated by an undeclared permission", () => {
|
||||
const { status, output } = runValidator("invalid-action-permission.json");
|
||||
expect(status).not.toBe(0);
|
||||
expect(output).toContain("samplebadaction.never_declared");
|
||||
});
|
||||
|
||||
it("rejects a manifest with an unknown permission", () => {
|
||||
const { status, output } = runValidator("invalid-unknown-permission.json");
|
||||
expect(status).not.toBe(0);
|
||||
expect(output).toContain('Unknown permission: "credentials.read"');
|
||||
});
|
||||
|
||||
it("rejects a manifest with an unknown category", () => {
|
||||
const { status, output } = runValidator("invalid-unknown-category.json");
|
||||
expect(status).not.toBe(0);
|
||||
expect(output).toMatch(/Field "category" must be one of/);
|
||||
expect(output).toContain("ssh-terminal");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user