@termix-ssh/plugin-sdk 1.0.21 → 1.0.22

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 (39) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/PLUGIN-GUIDE.md +62 -3
  3. package/cli/commands/build.mjs +55 -36
  4. package/cli/commands/validate.mjs +4 -0
  5. package/dist/backend.d.ts +32 -7
  6. package/dist/frontend.d.ts +7 -0
  7. package/dist/host/{PluginsTab-HQGSDSHS.js → PluginsTab-CM2B33BD.js} +22 -14
  8. package/dist/host/chunk-7J4QNGFM.js +345 -0
  9. package/dist/host/{chunk-A5CULDWV.js → chunk-HQ6NNFSX.js} +109 -1574
  10. package/dist/host/chunk-I5F2PSMR.js +1526 -0
  11. package/dist/host/{chunk-FGX27MAA.js → chunk-JRV2HIDK.js} +1 -1
  12. package/dist/host/{chunk-JFQGI4OD.js → chunk-P26QKKU5.js} +38 -9
  13. package/dist/host/{chunk-WWFRBU2E.js → chunk-ROB2SH5Q.js} +76 -49
  14. package/dist/host/chunk-TK3Y437H.js +53 -0
  15. package/dist/host/{chunk-GTHINW3L.js → chunk-VKNGNEF3.js} +34650 -34968
  16. package/dist/host/core-classes.json +1 -1
  17. package/dist/host/frame-host.css +2 -0
  18. package/dist/host/frame-host.js +242 -0
  19. package/dist/host/{linked-server-UDSFY3N7.js → linked-server-J5G457OA.js} +1 -1
  20. package/dist/host/{rbac-api-AAAPMF52.js → rbac-api-PY22W5UY.js} +5 -1
  21. package/dist/host/testing-host.js +9 -6
  22. package/dist/host/types/src/types/host-sidebar-preferences.d.ts +2 -0
  23. package/dist/host/types/src/types/index.d.ts +8 -0
  24. package/dist/host/types/src/ui/api/plugins-api.d.ts +2 -1
  25. package/dist/host/types/src/ui/api/rbac-api.d.ts +19 -1
  26. package/dist/host/types/src/ui/components/data-view.d.ts +1 -1
  27. package/dist/host/types/src/ui/main-axios.d.ts +1 -1
  28. package/dist/host/types/src/ui/plugin-host/loader.d.ts +6 -0
  29. package/dist/host/types/src/ui/plugin-host/sdk-ui.d.ts +1 -1
  30. package/dist/host/types/src/ui/plugins/use-plugins-manager.d.ts +1 -1
  31. package/dist/host/types/src/ui/shell/tab-registry.d.ts +2 -0
  32. package/dist/host/ui.js +356 -369
  33. package/dist/host/{use-permissions-VAND5GEG.js → use-permissions-EWRI3WLT.js} +1 -1
  34. package/dist/host-commands.d.ts +7 -0
  35. package/dist/host-commands.js +35 -5
  36. package/dist/manifest.d.ts +28 -1
  37. package/dist/manifest.js +108 -1
  38. package/package.json +5 -1
  39. package/schema/manifest.schema.json +42 -0
@@ -7,7 +7,7 @@ import {
7
7
  resetPermissionsCache,
8
8
  setPermissionsForTesting,
9
9
  usePermissions
10
- } from "./chunk-FGX27MAA.js";
10
+ } from "./chunk-JRV2HIDK.js";
11
11
  import "./chunk-YGYBM7SQ.js";
12
12
  export {
13
13
  hasPermission,
@@ -17,6 +17,13 @@ export interface ExecResult {
17
17
  stderr: string;
18
18
  code: number | null;
19
19
  }
20
+ /**
21
+ * Decodes raw command output. Strict UTF-8 first; when that fails, tries GBK,
22
+ * which is what a Chinese-locale Windows console sends (its bytes are almost
23
+ * never valid UTF-8) and keeps it only when it decodes without replacement
24
+ * characters. Anything else falls back to lossy UTF-8, the old behavior.
25
+ */
26
+ export declare function decodeCommandOutput(buf: Buffer): string;
20
27
  /** Runs one non-interactive command over an existing ssh2 client. */
21
28
  export declare function execCommand(client: Client, command: string, timeoutMs?: number): Promise<ExecResult>;
22
29
  export type PackageManager = "apt" | "dnf" | "yum" | "pacman" | null;
@@ -11,6 +11,30 @@
11
11
  // Homebrew and OrbStack paths a non-interactive shell misses.
12
12
  import { EXTRA_PATH_DIRS } from "./host-scripts/docker.js";
13
13
  export { EXTRA_PATH_DIRS };
14
+ /**
15
+ * Decodes raw command output. Strict UTF-8 first; when that fails, tries GBK,
16
+ * which is what a Chinese-locale Windows console sends (its bytes are almost
17
+ * never valid UTF-8) and keeps it only when it decodes without replacement
18
+ * characters. Anything else falls back to lossy UTF-8, the old behavior.
19
+ */
20
+ export function decodeCommandOutput(buf) {
21
+ try {
22
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(buf);
23
+ }
24
+ catch {
25
+ // not valid UTF-8, try the legacy codepage
26
+ }
27
+ try {
28
+ // A Node built with small-ICU has no GBK decoder and throws here.
29
+ const gbk = new TextDecoder("gbk").decode(buf);
30
+ if (!gbk.includes("�"))
31
+ return gbk;
32
+ }
33
+ catch {
34
+ // expected - fall through to lossy UTF-8
35
+ }
36
+ return buf.toString("utf8");
37
+ }
14
38
  /** Runs one non-interactive command over an existing ssh2 client. */
15
39
  export function execCommand(client, command, timeoutMs = 30000) {
16
40
  return new Promise((resolve, reject) => {
@@ -48,8 +72,10 @@ export function execCommand(client, command, timeoutMs = 30000) {
48
72
  return;
49
73
  }
50
74
  stream = _stream;
51
- let stdout = "";
52
- let stderr = "";
75
+ // Chunks are buffered and decoded once on close: a multi-byte character
76
+ // can be split across chunks, and charset detection needs it whole.
77
+ const stdout = [];
78
+ const stderr = [];
53
79
  let exitCode = null;
54
80
  stream
55
81
  .on("close", (code) => {
@@ -57,11 +83,15 @@ export function execCommand(client, command, timeoutMs = 30000) {
57
83
  settled = true;
58
84
  exitCode = typeof code === "number" ? code : null;
59
85
  cleanup();
60
- resolve({ stdout, stderr, code: exitCode });
86
+ resolve({
87
+ stdout: decodeCommandOutput(Buffer.concat(stdout)),
88
+ stderr: decodeCommandOutput(Buffer.concat(stderr)),
89
+ code: exitCode,
90
+ });
61
91
  }
62
92
  })
63
93
  .on("data", (data) => {
64
- stdout += data.toString("utf8");
94
+ stdout.push(data);
65
95
  })
66
96
  .on("error", (streamErr) => {
67
97
  if (!settled) {
@@ -73,7 +103,7 @@ export function execCommand(client, command, timeoutMs = 30000) {
73
103
  if (stream.stderr) {
74
104
  stream.stderr
75
105
  .on("data", (data) => {
76
- stderr += data.toString("utf8");
106
+ stderr.push(data);
77
107
  })
78
108
  .on("error", (stderrErr) => {
79
109
  if (!settled) {
@@ -20,7 +20,7 @@ export { ENV_NAME_PATTERN, MAX_ENV_DESCRIPTION_LENGTH, pluginDocsPage, pluginDoc
20
20
  * "^1.2" needs 1.2 or later). engine.termix is checked against the core
21
21
  * version by the server.
22
22
  */
23
- export declare const PLUGIN_API_VERSION = "1.12.0";
23
+ export declare const PLUGIN_API_VERSION = "1.13.0";
24
24
  /** The API major, the default engine.api for a new plugin. */
25
25
  export declare const SUPPORTED_PLUGIN_API_VERSION: string;
26
26
  /** Whether this build's plugin API satisfies a manifest's engine.api. */
@@ -363,6 +363,33 @@ export interface PluginContributions {
363
363
  layouts?: PluginLayoutContribution[];
364
364
  /** Themes. Needs the ui:theme capability. */
365
365
  themes?: PluginThemeContribution[];
366
+ /** How the plugin shows in the Termix mobile app. Plugin API 1.13. */
367
+ mobile?: PluginMobileContribution;
368
+ }
369
+ /**
370
+ * - "webview": the app shows the plugin's UI from the server in a WebView.
371
+ * - "native": the app has its own screens for this plugin.
372
+ * - "none": nothing to show in the app.
373
+ */
374
+ export declare const MOBILE_MODES: readonly ["webview", "native", "none"];
375
+ export type PluginMobileMode = (typeof MOBILE_MODES)[number];
376
+ export declare const MOBILE_SIZES: readonly ["phone", "tablet"];
377
+ export type PluginMobileSize = (typeof MOBILE_SIZES)[number];
378
+ export interface PluginMobileContribution {
379
+ mode: PluginMobileMode;
380
+ /**
381
+ * webview: a bundle built for the app, in the same folder as the frontend
382
+ * bundle. Unset means the normal frontend runs in a phone sized frame.
383
+ */
384
+ entry?: string;
385
+ /** webview: screen sizes the UI works at. Defaults to both. */
386
+ sizes?: PluginMobileSize[];
387
+ /** webview: tab, panel or card ids the app offers. Defaults to every tab. */
388
+ views?: string[];
389
+ /** native: the app feature that stands in for it. Defaults to the plugin id. */
390
+ feature?: string;
391
+ /** i18n key in the plugin's namespace, or plain text, shown in the app. */
392
+ note?: string;
366
393
  }
367
394
  /** A field a protocol login carries besides username and password. */
368
395
  export interface PluginProtocolCredentialField {
package/dist/manifest.js CHANGED
@@ -22,7 +22,7 @@ export { ENV_NAME_PATTERN, MAX_ENV_DESCRIPTION_LENGTH, pluginDocsPage, pluginDoc
22
22
  * "^1.2" needs 1.2 or later). engine.termix is checked against the core
23
23
  * version by the server.
24
24
  */
25
- export const PLUGIN_API_VERSION = "1.12.0";
25
+ export const PLUGIN_API_VERSION = "1.13.0";
26
26
  /** The API major, the default engine.api for a new plugin. */
27
27
  export const SUPPORTED_PLUGIN_API_VERSION = String(semver.major(PLUGIN_API_VERSION));
28
28
  /** Whether this build's plugin API satisfies a manifest's engine.api. */
@@ -171,6 +171,13 @@ export const SETTINGS_FIELD_TYPES = [
171
171
  export const SETTINGS_SCOPES = ["admin", "user", "host"];
172
172
  /** A layout id or theme id: short, lowercase, used as a CSS class for themes. */
173
173
  export const LOOK_ID_PATTERN = /^[a-z][a-z0-9-]{1,39}$/;
174
+ /**
175
+ * - "webview": the app shows the plugin's UI from the server in a WebView.
176
+ * - "native": the app has its own screens for this plugin.
177
+ * - "none": nothing to show in the app.
178
+ */
179
+ export const MOBILE_MODES = ["webview", "native", "none"];
180
+ export const MOBILE_SIZES = ["phone", "tablet"];
174
181
  /** Protocol ids core keeps for itself. */
175
182
  export const RESERVED_PROTOCOL_IDS = ["ssh"];
176
183
  /** Login keys a declared credential field may not reuse. */
@@ -254,6 +261,7 @@ const ALLOWED_CONTRIBUTES = new Set([
254
261
  "protocols",
255
262
  "layouts",
256
263
  "themes",
264
+ "mobile",
257
265
  ]);
258
266
  const ALLOWED_SETTINGS_FIELD = [
259
267
  "key",
@@ -387,8 +395,107 @@ export function validateManifest(manifest) {
387
395
  validateProvidesSecret(m.providesSecret, errors);
388
396
  validateRequiresSecret(m.requiresSecret, errors);
389
397
  validateContributes(m.contributes, typeof m.id === "string" ? m.id : undefined, errors);
398
+ if (isPlainObject(m.contributes)) {
399
+ validateMobile(m.contributes, typeof m.frontend === "string" ? m.frontend : DEFAULT_FRONTEND_ENTRY, errors);
400
+ }
390
401
  return errors;
391
402
  }
403
+ function folderOf(file) {
404
+ const parts = file.split("/").filter((part) => part !== "" && part !== ".");
405
+ return parts.slice(0, -1).join("/");
406
+ }
407
+ function validateMobile(contributes, frontend, errors) {
408
+ const mobile = contributes.mobile;
409
+ if (mobile === undefined)
410
+ return;
411
+ const where = "contributes.mobile";
412
+ if (!isPlainObject(mobile)) {
413
+ errors.push(`Field "${where}" must be an object`);
414
+ return;
415
+ }
416
+ rejectUnknown(mobile, ["mode", "entry", "sizes", "views", "feature", "note"], `"${where}"`, errors);
417
+ const mode = mobile.mode;
418
+ if (!MOBILE_MODES.includes(mode)) {
419
+ errors.push(`${where}.mode must be one of: ${MOBILE_MODES.join(", ")}`);
420
+ return;
421
+ }
422
+ for (const key of ["entry", "sizes", "views"]) {
423
+ if (mobile[key] !== undefined && mode !== "webview") {
424
+ errors.push(`${where}.${key} is only for mode "webview"`);
425
+ }
426
+ }
427
+ if (mobile.feature !== undefined && mode !== "native") {
428
+ errors.push(`${where}.feature is only for mode "native"`);
429
+ }
430
+ if (mobile.entry !== undefined &&
431
+ requireString(mobile.entry, `${where}.entry`, errors)) {
432
+ const before = errors.length;
433
+ requireRelativePath(mobile.entry, `${where}.entry`, errors);
434
+ if (!mobile.entry.endsWith(".js")) {
435
+ errors.push(`${where}.entry must be a .js file`);
436
+ }
437
+ else if (errors.length === before &&
438
+ folderOf(mobile.entry) !== folderOf(frontend)) {
439
+ // Core only serves the folder the frontend bundle sits in.
440
+ errors.push(`${where}.entry must sit in the same folder as the frontend bundle (${frontend})`);
441
+ }
442
+ }
443
+ if (mobile.sizes !== undefined) {
444
+ const sizes = mobile.sizes;
445
+ if (!Array.isArray(sizes) || sizes.length === 0) {
446
+ errors.push(`${where}.sizes must be a non-empty array`);
447
+ }
448
+ else {
449
+ const seen = new Set();
450
+ for (const size of sizes) {
451
+ if (!MOBILE_SIZES.includes(size)) {
452
+ errors.push(`${where}.sizes entries must be one of: ${MOBILE_SIZES.join(", ")}`);
453
+ }
454
+ else if (seen.has(size)) {
455
+ errors.push(`${where}.sizes duplicates "${String(size)}"`);
456
+ }
457
+ seen.add(size);
458
+ }
459
+ }
460
+ }
461
+ if (mobile.views !== undefined) {
462
+ const views = mobile.views;
463
+ if (!Array.isArray(views) || views.length === 0) {
464
+ errors.push(`${where}.views must be a non-empty array`);
465
+ }
466
+ else {
467
+ const declared = new Set();
468
+ for (const field of ["tabs", "panels", "dashboardCards"]) {
469
+ const list = contributes[field];
470
+ if (!Array.isArray(list))
471
+ continue;
472
+ for (const view of list) {
473
+ if (isPlainObject(view) && typeof view.id === "string") {
474
+ declared.add(view.id);
475
+ }
476
+ }
477
+ }
478
+ const seen = new Set();
479
+ for (const view of views) {
480
+ if (typeof view !== "string" || !declared.has(view)) {
481
+ errors.push(`${where}.views entries must be ids from contributes.tabs, panels or dashboardCards, got: ${JSON.stringify(view)}`);
482
+ }
483
+ else if (seen.has(view)) {
484
+ errors.push(`${where}.views duplicates "${view}"`);
485
+ }
486
+ seen.add(view);
487
+ }
488
+ }
489
+ }
490
+ if (mobile.feature !== undefined &&
491
+ (typeof mobile.feature !== "string" ||
492
+ !LOOK_ID_PATTERN.test(mobile.feature))) {
493
+ errors.push(`${where}.feature must match ${LOOK_ID_PATTERN} (lowercase letters, digits, dashes)`);
494
+ }
495
+ if (mobile.note !== undefined) {
496
+ requireString(mobile.note, `${where}.note`, errors);
497
+ }
498
+ }
392
499
  function validateFeatures(features, errors) {
393
500
  if (features === undefined)
394
501
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@termix-ssh/plugin-sdk",
3
- "version": "1.0.21",
3
+ "version": "1.0.22",
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": {
@@ -102,6 +102,10 @@
102
102
  "./testing-host": {
103
103
  "default": "./dist/host/testing-host.js"
104
104
  },
105
+ "./frame-host": {
106
+ "default": "./dist/host/frame-host.js"
107
+ },
108
+ "./frame-host.css": "./dist/host/frame-host.css",
105
109
  "./theme.css": "./dist/host/theme.css"
106
110
  },
107
111
  "files": [
@@ -1239,6 +1239,48 @@
1239
1239
  }
1240
1240
  }
1241
1241
  }
1242
+ },
1243
+ "mobile": {
1244
+ "type": "object",
1245
+ "description": "How the plugin shows in the Termix mobile app. Needs plugin API 1.13 (Termix 26.10.6).",
1246
+ "additionalProperties": false,
1247
+ "required": ["mode"],
1248
+ "properties": {
1249
+ "mode": {
1250
+ "type": "string",
1251
+ "enum": ["webview", "native", "none"],
1252
+ "description": "webview: the app shows the plugin's UI from the server. native: the app has its own screens. none: nothing to show in the app."
1253
+ },
1254
+ "entry": {
1255
+ "type": "string",
1256
+ "pattern": "\\.js$",
1257
+ "description": "webview only. A bundle built for the app, in the same folder as the frontend bundle. Unset means the normal frontend runs in a phone sized frame."
1258
+ },
1259
+ "sizes": {
1260
+ "type": "array",
1261
+ "minItems": 1,
1262
+ "uniqueItems": true,
1263
+ "items": { "type": "string", "enum": ["phone", "tablet"] },
1264
+ "description": "webview only. Screen sizes the UI works at. Defaults to both."
1265
+ },
1266
+ "views": {
1267
+ "type": "array",
1268
+ "minItems": 1,
1269
+ "uniqueItems": true,
1270
+ "items": { "type": "string", "minLength": 1 },
1271
+ "description": "webview only. Tab, panel or card ids the app offers. Defaults to every tab."
1272
+ },
1273
+ "feature": {
1274
+ "type": "string",
1275
+ "pattern": "^[a-z][a-z0-9-]{1,39}$",
1276
+ "description": "native only. The app feature that stands in for this plugin. Defaults to the plugin id."
1277
+ },
1278
+ "note": {
1279
+ "type": "string",
1280
+ "minLength": 1,
1281
+ "description": "i18n key in the plugin's namespace, or plain text, shown in the app."
1282
+ }
1283
+ }
1242
1284
  }
1243
1285
  }
1244
1286
  },