@termix-ssh/plugin-sdk 1.0.1 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/LICENSE +202 -0
  3. package/README.md +4 -3
  4. package/cli/commands/build.mjs +37 -12
  5. package/cli/commands/dev.mjs +1 -1
  6. package/cli/commands/openapi.mjs +39 -0
  7. package/cli/commands/validate.mjs +67 -0
  8. package/cli/index.mjs +3 -0
  9. package/cli/lib/css-classes.mjs +37 -0
  10. package/dist/changelog.d.ts +3 -8
  11. package/dist/changelog.js +6 -14
  12. package/dist/docs.d.ts +28 -0
  13. package/dist/docs.js +39 -0
  14. package/dist/frontend.d.ts +17 -0
  15. package/dist/frontend.js +8 -0
  16. package/dist/host/{chunk-COGRSHKO.js → chunk-F5RZH6HE.js} +13 -2
  17. package/dist/host/{chunk-3KG44G55.js → chunk-O2OHNVFX.js} +1 -1
  18. package/dist/host/{chunk-7GL25GFS.js → chunk-RU4E5XLY.js} +531 -300
  19. package/dist/host/core-classes.json +1 -0
  20. package/dist/host/{linked-server-BD573BEG.js → linked-server-4VWYYABP.js} +1 -1
  21. package/dist/host/{rbac-api-7GHDJLU4.js → rbac-api-WIJVZH3M.js} +1 -1
  22. package/dist/host/testing-host.js +3 -3
  23. package/dist/host/types/src/ui/api/plugins-api.d.ts +23 -0
  24. package/dist/host/types/src/ui/api/system-status-api.d.ts +24 -0
  25. package/dist/host/types/src/ui/components/docs-link.d.ts +25 -0
  26. package/dist/host/types/src/ui/components/panel-layout.d.ts +3 -1
  27. package/dist/host/types/src/ui/hooks/use-installed-plugins.d.ts +6 -0
  28. package/dist/host/types/src/ui/lib/docs.d.ts +10 -0
  29. package/dist/host/types/src/ui/main-axios.d.ts +1 -0
  30. package/dist/host/types/src/ui/plugin-host/sdk-ui.d.ts +1 -0
  31. package/dist/host/types/src/ui/settings/settings-fields-util.d.ts +10 -0
  32. package/dist/host/ui.js +139 -110
  33. package/dist/host/{use-permissions-SKWXHWDJ.js → use-permissions-KIQCJ35M.js} +1 -1
  34. package/dist/manifest.d.ts +16 -1
  35. package/dist/manifest.js +84 -1
  36. package/dist/openapi.d.ts +43 -0
  37. package/dist/openapi.js +60 -0
  38. package/dist/testing/setup.js +1 -0
  39. package/package.json +12 -2
  40. package/schema/manifest.schema.json +27 -0
@@ -9,6 +9,8 @@
9
9
  * "permissionGroups" used to validate clean and be silently dropped, which
10
10
  * made a manifest look like it declared something it did not.
11
11
  */
12
+ import { type PluginEnvVar } from "./docs.js";
13
+ export { ENV_NAME_PATTERN, MAX_ENV_DESCRIPTION_LENGTH, pluginDocsPage, pluginDocsUrl, pluginEnvVars, type PluginEnvVar, } from "./docs.js";
12
14
  /**
13
15
  * The plugin API this build implements. A minor bump adds to the API and
14
16
  * never removes; a major bump may break plugins.
@@ -17,7 +19,7 @@
17
19
  * "^1.2" needs 1.2 or later). engine.termix is checked against the core
18
20
  * version by the server.
19
21
  */
20
- export declare const PLUGIN_API_VERSION = "1.1.0";
22
+ export declare const PLUGIN_API_VERSION = "1.2.0";
21
23
  /** The API major, the default engine.api for a new plugin. */
22
24
  export declare const SUPPORTED_PLUGIN_API_VERSION: string;
23
25
  /** Whether this build's plugin API satisfies a manifest's engine.api. */
@@ -27,6 +29,13 @@ export declare function isApiCompatible(range: string): boolean;
27
29
  * core (2.9.0-beta.1) counts as that release.
28
30
  */
29
31
  export declare function isTermixCompatible(range: string, coreVersion: string | null): boolean;
32
+ export declare const MAX_FEATURES = 20;
33
+ export declare const MAX_FEATURE_LENGTH = 160;
34
+ /**
35
+ * The usable feature lines of a manifest or registry entry. Anything that is
36
+ * not a short non-empty string is dropped, so an index cannot flood the page.
37
+ */
38
+ export declare function pluginFeatures(value: unknown): string[];
30
39
  /**
31
40
  * The video id of a YouTube link, or null for anything else. Only the id is
32
41
  * ever used, so a manifest can never point the embed at another site.
@@ -416,6 +425,12 @@ export interface PluginManifest {
416
425
  icon?: string;
417
426
  /** A YouTube link shown at the top of the plugin's page. */
418
427
  video?: string;
428
+ /** Short lines listing what the plugin does, shown on its page. */
429
+ features?: string[];
430
+ /** Where the plugin's docs live. https only. */
431
+ docs?: string;
432
+ /** Environment variables the plugin reads. */
433
+ env?: PluginEnvVar[];
419
434
  engine: PluginEngine;
420
435
  /** Catalog capability ids. */
421
436
  capabilities: string[];
package/dist/manifest.js CHANGED
@@ -11,6 +11,8 @@
11
11
  */
12
12
  import semver from "semver";
13
13
  import { isKnownCapability } from "./capabilities.js";
14
+ import { ENV_NAME_PATTERN, MAX_ENV_DESCRIPTION_LENGTH, pluginDocsUrl, } from "./docs.js";
15
+ export { ENV_NAME_PATTERN, MAX_ENV_DESCRIPTION_LENGTH, pluginDocsPage, pluginDocsUrl, pluginEnvVars, } from "./docs.js";
14
16
  /**
15
17
  * The plugin API this build implements. A minor bump adds to the API and
16
18
  * never removes; a major bump may break plugins.
@@ -19,7 +21,7 @@ import { isKnownCapability } from "./capabilities.js";
19
21
  * "^1.2" needs 1.2 or later). engine.termix is checked against the core
20
22
  * version by the server.
21
23
  */
22
- export const PLUGIN_API_VERSION = "1.1.0";
24
+ export const PLUGIN_API_VERSION = "1.2.0";
23
25
  /** The API major, the default engine.api for a new plugin. */
24
26
  export const SUPPORTED_PLUGIN_API_VERSION = String(semver.major(PLUGIN_API_VERSION));
25
27
  /** Whether this build's plugin API satisfies a manifest's engine.api. */
@@ -47,6 +49,22 @@ const YOUTUBE_HOSTS = new Set([
47
49
  "youtube-nocookie.com",
48
50
  "www.youtube-nocookie.com",
49
51
  ]);
52
+ export const MAX_FEATURES = 20;
53
+ export const MAX_FEATURE_LENGTH = 160;
54
+ /**
55
+ * The usable feature lines of a manifest or registry entry. Anything that is
56
+ * not a short non-empty string is dropped, so an index cannot flood the page.
57
+ */
58
+ export function pluginFeatures(value) {
59
+ if (!Array.isArray(value))
60
+ return [];
61
+ return value
62
+ .filter((f) => typeof f === "string" &&
63
+ f.trim().length > 0 &&
64
+ f.length <= MAX_FEATURE_LENGTH)
65
+ .map((f) => f.trim())
66
+ .slice(0, MAX_FEATURES);
67
+ }
50
68
  /**
51
69
  * The video id of a YouTube link, or null for anything else. Only the id is
52
70
  * ever used, so a manifest can never point the embed at another site.
@@ -190,6 +208,9 @@ const ALLOWED_TOP_LEVEL = new Set([
190
208
  "repository",
191
209
  "icon",
192
210
  "video",
211
+ "features",
212
+ "docs",
213
+ "env",
193
214
  "dependencies",
194
215
  "optionalDependencies",
195
216
  "provides",
@@ -319,6 +340,11 @@ export function validateManifest(manifest) {
319
340
  if ("video" in m && youtubeVideoId(m.video) === null) {
320
341
  errors.push(`Field "video" must be a YouTube link such as https://www.youtube.com/watch?v=..., got: ${JSON.stringify(m.video)}`);
321
342
  }
343
+ validateFeatures(m.features, errors);
344
+ if ("docs" in m && pluginDocsUrl(m.docs) === null) {
345
+ errors.push(`Field "docs" must be an https URL, got: ${JSON.stringify(m.docs)}`);
346
+ }
347
+ validateEnv(m.env, errors);
322
348
  for (const field of ["backend", "frontend", "locales"]) {
323
349
  if (field in m && requireString(m[field], field, errors)) {
324
350
  requireRelativePath(m[field], field, errors);
@@ -343,6 +369,63 @@ export function validateManifest(manifest) {
343
369
  validateContributes(m.contributes, typeof m.id === "string" ? m.id : undefined, errors);
344
370
  return errors;
345
371
  }
372
+ function validateFeatures(features, errors) {
373
+ if (features === undefined)
374
+ return;
375
+ if (!Array.isArray(features)) {
376
+ errors.push('Field "features" must be an array of strings');
377
+ return;
378
+ }
379
+ if (features.length > MAX_FEATURES) {
380
+ errors.push(`Field "features" may list at most ${MAX_FEATURES} items`);
381
+ }
382
+ features.forEach((feature, i) => {
383
+ if (typeof feature !== "string" ||
384
+ feature.trim().length === 0 ||
385
+ feature.length > MAX_FEATURE_LENGTH) {
386
+ errors.push(`Field "features[${i}]" must be a non-empty string of at most ${MAX_FEATURE_LENGTH} characters`);
387
+ }
388
+ });
389
+ }
390
+ function validateEnv(env, errors) {
391
+ if (env === undefined)
392
+ return;
393
+ if (!Array.isArray(env)) {
394
+ errors.push('Field "env" must be an array');
395
+ return;
396
+ }
397
+ const seen = new Set();
398
+ env.forEach((entry, i) => {
399
+ const where = `env[${i}]`;
400
+ if (!isPlainObject(entry)) {
401
+ errors.push(`Field "${where}" must be an object`);
402
+ return;
403
+ }
404
+ rejectUnknown(entry, ["name", "description", "default", "required", "secret"], where, errors);
405
+ if (typeof entry.name !== "string" || !ENV_NAME_PATTERN.test(entry.name)) {
406
+ errors.push(`Field "${where}.name" must be an uppercase name like MY_PLUGIN_URL, got: ${JSON.stringify(entry.name)}`);
407
+ }
408
+ else if (seen.has(entry.name)) {
409
+ errors.push(`Field "${where}.name" repeats ${entry.name}`);
410
+ }
411
+ else {
412
+ seen.add(entry.name);
413
+ }
414
+ if (typeof entry.description !== "string" ||
415
+ entry.description.trim().length === 0 ||
416
+ entry.description.length > MAX_ENV_DESCRIPTION_LENGTH) {
417
+ errors.push(`Field "${where}.description" must be a non-empty string of at most ${MAX_ENV_DESCRIPTION_LENGTH} characters`);
418
+ }
419
+ if ("default" in entry && typeof entry.default !== "string") {
420
+ errors.push(`Field "${where}.default" must be a string`);
421
+ }
422
+ for (const flag of ["required", "secret"]) {
423
+ if (flag in entry && typeof entry[flag] !== "boolean") {
424
+ errors.push(`Field "${where}.${flag}" must be true or false`);
425
+ }
426
+ }
427
+ });
428
+ }
346
429
  function validateAuthor(author, errors) {
347
430
  if (author === undefined)
348
431
  return;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Builds an OpenAPI spec from the @openapi JSDoc blocks above routes. Core and
3
+ * every plugin use this, so the docs site renders them all the same way.
4
+ */
5
+ export interface OpenApiTag {
6
+ name: string;
7
+ description?: string;
8
+ }
9
+ export interface BuildOpenApiOptions {
10
+ title: string;
11
+ version: string;
12
+ description?: string;
13
+ /** Source files or globs holding @openapi blocks. */
14
+ files: string[];
15
+ tags?: OpenApiTag[];
16
+ }
17
+ export type OpenApiSpec = Record<string, unknown> & {
18
+ paths?: Record<string, unknown>;
19
+ };
20
+ /** How every Termix route is signed in to. */
21
+ export declare const TERMIX_SECURITY_SCHEMES: {
22
+ readonly bearerAuth: {
23
+ readonly type: "http";
24
+ readonly scheme: "bearer";
25
+ readonly bearerFormat: "JWT";
26
+ readonly description: "Session token sent in the Authorization header as Bearer and the token.";
27
+ };
28
+ readonly cookieAuth: {
29
+ readonly type: "apiKey";
30
+ readonly in: "cookie";
31
+ readonly name: "jwt";
32
+ readonly description: "Session JWT cookie set at sign-in. When present, it takes precedence over the Authorization header.";
33
+ };
34
+ readonly apiKeyAuth: {
35
+ readonly type: "http";
36
+ readonly scheme: "bearer";
37
+ readonly bearerFormat: "API key starting with tmx_";
38
+ readonly description: "API key sent in the Authorization header as Bearer and the key, which starts with tmx_. It acts as the key owner and cannot act as anyone else.";
39
+ };
40
+ };
41
+ export declare function buildOpenApi(options: BuildOpenApiOptions): Promise<OpenApiSpec>;
42
+ /** Whether a spec documents any route. */
43
+ export declare function hasOpenApiPaths(spec: OpenApiSpec): boolean;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Builds an OpenAPI spec from the @openapi JSDoc blocks above routes. Core and
3
+ * every plugin use this, so the docs site renders them all the same way.
4
+ */
5
+ import swaggerJSDoc from "@deadendjs/swagger-jsdoc";
6
+ /** How every Termix route is signed in to. */
7
+ export const TERMIX_SECURITY_SCHEMES = {
8
+ bearerAuth: {
9
+ type: "http",
10
+ scheme: "bearer",
11
+ bearerFormat: "JWT",
12
+ description: "Session token sent in the Authorization header as Bearer and the token.",
13
+ },
14
+ cookieAuth: {
15
+ type: "apiKey",
16
+ in: "cookie",
17
+ name: "jwt",
18
+ description: "Session JWT cookie set at sign-in. When present, it takes precedence over the Authorization header.",
19
+ },
20
+ apiKeyAuth: {
21
+ type: "http",
22
+ scheme: "bearer",
23
+ bearerFormat: "API key starting with tmx_",
24
+ description: "API key sent in the Authorization header as Bearer and the key, which starts with tmx_. It acts as the key owner and cannot act as anyone else.",
25
+ },
26
+ };
27
+ export async function buildOpenApi(options) {
28
+ const spec = (await swaggerJSDoc({
29
+ definition: {
30
+ openapi: "3.0.3",
31
+ info: {
32
+ title: options.title,
33
+ version: options.version,
34
+ ...(options.description ? { description: options.description } : {}),
35
+ },
36
+ servers: [{ url: "/", description: "Your Termix server" }],
37
+ components: {
38
+ securitySchemes: TERMIX_SECURITY_SCHEMES,
39
+ schemas: {
40
+ Error: {
41
+ type: "object",
42
+ properties: {
43
+ error: { type: "string" },
44
+ details: { type: "string" },
45
+ },
46
+ },
47
+ },
48
+ },
49
+ security: [{ bearerAuth: [] }, { cookieAuth: [] }, { apiKeyAuth: [] }],
50
+ ...(options.tags?.length ? { tags: options.tags } : {}),
51
+ },
52
+ apis: options.files.map((f) => f.replace(/\\/g, "/")),
53
+ failOnErrors: true,
54
+ }));
55
+ return spec;
56
+ }
57
+ /** Whether a spec documents any route. */
58
+ export function hasOpenApiPaths(spec) {
59
+ return Object.keys(spec.paths ?? {}).length > 0;
60
+ }
@@ -77,6 +77,7 @@ const noop = () => { };
77
77
  const offline = () => Promise.reject(new Error("No plugin API in a unit test; mock it"));
78
78
  globalThis.__termixTestPluginHost = {
79
79
  usePluginId: () => "test-plugin",
80
+ useDocsUrl: () => null,
80
81
  useTranslation: () => ({
81
82
  t: (key) => key,
82
83
  language: "en",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@termix-ssh/plugin-sdk",
3
- "version": "1.0.1",
3
+ "version": "1.0.4",
4
4
  "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -14,7 +14,7 @@
14
14
  "node": ">=22"
15
15
  },
16
16
  "bin": {
17
- "termix-plugin": "./cli/index.mjs"
17
+ "termix-plugin": "cli/index.mjs"
18
18
  },
19
19
  "exports": {
20
20
  "./backend": {
@@ -53,6 +53,14 @@
53
53
  "types": "./dist/changelog.d.ts",
54
54
  "default": "./dist/changelog.js"
55
55
  },
56
+ "./docs": {
57
+ "types": "./dist/docs.d.ts",
58
+ "default": "./dist/docs.js"
59
+ },
60
+ "./openapi": {
61
+ "types": "./dist/openapi.d.ts",
62
+ "default": "./dist/openapi.js"
63
+ },
56
64
  "./settings": {
57
65
  "types": "./dist/settings.d.ts",
58
66
  "default": "./dist/settings.js"
@@ -94,6 +102,7 @@
94
102
  "schema",
95
103
  "tsconfig.plugin.json",
96
104
  "README.md",
105
+ "CHANGELOG.md",
97
106
  "client.d.ts"
98
107
  ],
99
108
  "scripts": {
@@ -101,6 +110,7 @@
101
110
  "prepublishOnly": "npm run build"
102
111
  },
103
112
  "dependencies": {
113
+ "@deadendjs/swagger-jsdoc": "^8.1.2",
104
114
  "@tailwindcss/node": "^4.2.4",
105
115
  "@tailwindcss/oxide": "^4.2.4",
106
116
  "esbuild": "^0.28.1",
@@ -79,6 +79,33 @@
79
79
  "pattern": "^https://(www\\.|m\\.)?(youtube\\.com|youtu\\.be|youtube-nocookie\\.com)/",
80
80
  "description": "A YouTube link shown at the top of the plugin's page."
81
81
  },
82
+ "features": {
83
+ "type": "array",
84
+ "maxItems": 20,
85
+ "items": { "type": "string", "minLength": 1, "maxLength": 160 },
86
+ "description": "Short lines listing what the plugin does, shown on its page."
87
+ },
88
+ "docs": {
89
+ "type": "string",
90
+ "pattern": "^https://",
91
+ "description": "Where the plugin's docs live. Shown as a Documentation link in Termix."
92
+ },
93
+ "env": {
94
+ "type": "array",
95
+ "description": "Environment variables the plugin reads, listed in its docs.",
96
+ "items": {
97
+ "type": "object",
98
+ "additionalProperties": false,
99
+ "required": ["name", "description"],
100
+ "properties": {
101
+ "name": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,63}$" },
102
+ "description": { "type": "string", "minLength": 1, "maxLength": 300 },
103
+ "default": { "type": "string" },
104
+ "required": { "type": "boolean" },
105
+ "secret": { "type": "boolean" }
106
+ }
107
+ }
108
+ },
82
109
  "engine": {
83
110
  "type": "object",
84
111
  "additionalProperties": false,