@termix-ssh/plugin-sdk 1.0.11 → 1.0.13

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 (130) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/PLUGIN-GUIDE.md +178 -54
  3. package/cli/commands/build.mjs +36 -22
  4. package/cli/commands/pack.mjs +12 -2
  5. package/cli/commands/validate.mjs +63 -3
  6. package/cli/lib/plugin-dir.mjs +11 -0
  7. package/cli/lib/tmxplug.mjs +9 -0
  8. package/dist/backend.d.ts +404 -0
  9. package/dist/capabilities.js +7 -0
  10. package/dist/db.js +1 -0
  11. package/dist/desktop.d.ts +0 -243
  12. package/dist/frontend.d.ts +175 -2
  13. package/dist/frontend.js +7 -0
  14. package/dist/host/PluginsTab-OXQQ7M4I.js +3266 -0
  15. package/dist/host/chunk-54KCK6IZ.js +3167 -0
  16. package/dist/host/chunk-CALNJCHN.js +10435 -0
  17. package/dist/host/{chunk-7RRTYD6B.js → chunk-CN5UBTBP.js} +1412 -14
  18. package/dist/host/{chunk-RLQBKJRK.js → chunk-ERGN4YW6.js} +9204 -11492
  19. package/dist/host/{chunk-LSWB3ZZV.js → chunk-T2PZWXFJ.js} +6 -1
  20. package/dist/host/chunk-YGYBM7SQ.js +58 -0
  21. package/dist/host/core-classes.json +1 -1
  22. package/dist/host/{linked-server-UCW74T2V.js → linked-server-5VFO4XQB.js} +2 -2
  23. package/dist/host/{rbac-api-6UYMFIFV.js → rbac-api-5YMBJAO5.js} +2 -2
  24. package/dist/host/testing-host.js +123 -57
  25. package/dist/host/types/src/types/onboarding.d.ts +0 -2
  26. package/dist/host/types/src/types/ui-types.d.ts +2 -1
  27. package/dist/host/types/src/ui/api/audit-log-api.d.ts +36 -0
  28. package/dist/host/types/src/ui/api/credentials-api.d.ts +0 -22
  29. package/dist/host/types/src/ui/api/host-defaults-api.d.ts +42 -0
  30. package/dist/host/types/src/ui/api/host-tags-api.d.ts +2 -0
  31. package/dist/host/types/src/ui/api/look-api.d.ts +40 -0
  32. package/dist/host/types/src/ui/api/open-tabs-api.d.ts +4 -0
  33. package/dist/host/types/src/ui/api/plugins-api.d.ts +30 -0
  34. package/dist/host/types/src/ui/api/private-endpoints-api.d.ts +6 -0
  35. package/dist/host/types/src/ui/api/settings-api.d.ts +0 -6
  36. package/dist/host/types/src/ui/api/ssh-host-management-api.d.ts +0 -28
  37. package/dist/host/types/src/ui/api/tls-api.d.ts +25 -0
  38. package/dist/host/types/src/ui/auth/ElectronLoginForm.d.ts +9 -0
  39. package/dist/host/types/src/ui/components/command.d.ts +78 -0
  40. package/dist/host/types/src/ui/components/folder-style.d.ts +16 -0
  41. package/dist/host/types/src/ui/components/theme-provider.d.ts +2 -1
  42. package/dist/host/types/src/ui/components/version-badge.d.ts +7 -0
  43. package/dist/host/types/src/ui/hooks/use-page-visible-interval.d.ts +10 -0
  44. package/dist/host/types/src/ui/lib/activity-types.d.ts +15 -0
  45. package/dist/host/types/src/ui/lib/app-version.d.ts +2 -0
  46. package/dist/host/types/src/ui/lib/apply-ui-preset.d.ts +19 -0
  47. package/dist/host/types/src/ui/lib/create-id.d.ts +2 -0
  48. package/dist/host/types/src/ui/lib/database-transfer-url.d.ts +8 -0
  49. package/dist/host/types/src/ui/lib/desktop-updates.d.ts +29 -0
  50. package/dist/host/types/src/ui/lib/host-connection-tabs.d.ts +14 -0
  51. package/dist/host/types/src/ui/lib/host-to-ssh-host.d.ts +4 -0
  52. package/dist/host/types/src/ui/lib/issue-url.d.ts +21 -0
  53. package/dist/host/types/src/ui/lib/local-adaptive-preferences.d.ts +9 -0
  54. package/dist/host/types/src/ui/lib/pane-nav-hotkey.d.ts +7 -0
  55. package/dist/host/types/src/ui/lib/plugin-i18n.d.ts +4 -3
  56. package/dist/host/types/src/ui/lib/role-label.d.ts +3 -0
  57. package/dist/host/types/src/ui/look/LookSettings.d.ts +8 -0
  58. package/dist/host/types/src/ui/look/layout-registry.d.ts +34 -0
  59. package/dist/host/types/src/ui/look/look-store.d.ts +29 -0
  60. package/dist/host/types/src/ui/look/resolve-look.d.ts +18 -0
  61. package/dist/host/types/src/ui/look/theme-css.d.ts +7 -0
  62. package/dist/host/types/src/ui/look/theme-registry.d.ts +41 -0
  63. package/dist/host/types/src/ui/look/use-look-theme.d.ts +12 -0
  64. package/dist/host/types/src/ui/main-axios.d.ts +2 -2
  65. package/dist/host/types/src/ui/manage/manage-requests.d.ts +34 -0
  66. package/dist/host/types/src/ui/onboarding/onboarding-registry.d.ts +3 -1
  67. package/dist/host/types/src/ui/plugin-host/loader.d.ts +2 -0
  68. package/dist/host/types/src/ui/plugin-host/scope.d.ts +6 -0
  69. package/dist/host/types/src/ui/plugin-host/sdk-ui.d.ts +101 -0
  70. package/dist/host/types/src/ui/plugin-host/view-ownership.d.ts +1 -1
  71. package/dist/host/types/src/ui/plugins/PluginConsentPrompt.d.ts +20 -0
  72. package/dist/host/types/src/ui/plugins/PluginDetail.d.ts +7 -0
  73. package/dist/host/types/src/ui/plugins/PluginReleaseNotes.d.ts +14 -0
  74. package/dist/host/types/src/ui/plugins/PluginsTab.d.ts +1 -0
  75. package/dist/host/types/src/ui/plugins/RegistriesSection.d.ts +4 -0
  76. package/dist/host/types/src/ui/plugins/plugin-bits.d.ts +23 -0
  77. package/dist/host/types/src/ui/plugins/plugin-model.d.ts +136 -0
  78. package/dist/host/types/src/ui/plugins/use-plugins-manager.d.ts +78 -0
  79. package/dist/host/types/src/ui/settings/appearance/appearance-controls.d.ts +53 -0
  80. package/dist/host/types/src/ui/settings/sync/LinkServerDialog.d.ts +12 -0
  81. package/dist/host/types/src/ui/settings/sync/ProxySettingsFields.d.ts +15 -0
  82. package/dist/host/types/src/ui/settings/sync/saved-servers.d.ts +3 -0
  83. package/dist/host/types/src/ui/shell/controller/ShellControllerProvider.d.ts +20 -0
  84. package/dist/host/types/src/ui/shell/controller/plugin-shell.d.ts +7 -0
  85. package/dist/host/types/src/ui/shell/controller/shell-store.d.ts +4 -0
  86. package/dist/host/types/src/ui/shell/controller/types.d.ts +156 -0
  87. package/dist/host/types/src/ui/shell/controller/use-hosts.d.ts +10 -0
  88. package/dist/host/types/src/ui/shell/controller/use-regions.d.ts +5 -0
  89. package/dist/host/types/src/ui/shell/controller/use-shell-keybindings.d.ts +12 -0
  90. package/dist/host/types/src/ui/shell/controller/use-shell-lifecycle.d.ts +17 -0
  91. package/dist/host/types/src/ui/shell/controller/use-split.d.ts +21 -0
  92. package/dist/host/types/src/ui/shell/controller/use-tabs.d.ts +70 -0
  93. package/dist/host/types/src/ui/shell/controller/use-workspace-layout.d.ts +4 -0
  94. package/dist/host/types/src/ui/shell/reconnect-tabs.d.ts +6 -0
  95. package/dist/host/types/src/ui/shell/shell-layout.d.ts +46 -0
  96. package/dist/host/types/src/ui/shell/split/EmptyPanePicker.d.ts +32 -0
  97. package/dist/host/types/src/ui/shell/split/SplitDropOverlay.d.ts +9 -0
  98. package/dist/host/types/src/ui/shell/split/SplitLayoutMenu.d.ts +16 -0
  99. package/dist/host/types/src/ui/shell/split/SplitView.d.ts +31 -0
  100. package/dist/host/types/src/ui/shell/split/split-drag.d.ts +58 -0
  101. package/dist/host/types/src/ui/shell/split/split-presets.d.ts +28 -0
  102. package/dist/host/types/src/ui/shell/split/split-tabs.d.ts +95 -0
  103. package/dist/host/types/src/ui/shell/split/split-targets.d.ts +30 -0
  104. package/dist/host/types/src/ui/shell/split/split-tree.d.ts +89 -0
  105. package/dist/host/types/src/ui/shell/stage/TabStage.d.ts +9 -0
  106. package/dist/host/types/src/ui/shell/stage/tab-stage-store.d.ts +24 -0
  107. package/dist/host/types/src/ui/shell/tab-close-guards.d.ts +9 -0
  108. package/dist/host/types/src/ui/shell/tab-handles.d.ts +8 -0
  109. package/dist/host/types/src/ui/shell/tabUtils.d.ts +15 -0
  110. package/dist/host/types/src/ui/sidebar/AuthEnrollmentSections.d.ts +5 -0
  111. package/dist/host/types/src/ui/sidebar/HostManagerData.d.ts +4 -0
  112. package/dist/host/types/src/ui/sidebar/build-host-tree.d.ts +25 -0
  113. package/dist/host/types/src/ui/sidebar/host-contributions.d.ts +10 -0
  114. package/dist/host/ui.js +6045 -4055
  115. package/dist/host/{use-permissions-GYCBY2AU.js → use-permissions-ISEDRY3J.js} +2 -2
  116. package/dist/manifest.d.ts +50 -1
  117. package/dist/manifest.js +104 -1
  118. package/dist/ssh-keys.d.ts +29 -0
  119. package/dist/ssh-keys.js +85 -0
  120. package/dist/testing/setup.js +1 -0
  121. package/dist/testing.d.ts +32 -15
  122. package/dist/testing.js +218 -1
  123. package/dist/theme-css.d.ts +11 -0
  124. package/dist/theme-css.js +142 -0
  125. package/dist/vitest-preset.d.ts +18 -0
  126. package/dist/vitest-preset.js +14 -3
  127. package/package.json +5 -1
  128. package/schema/manifest.schema.json +121 -8
  129. package/tsconfig.plugin.json +1 -0
  130. package/dist/host/chunk-YC2MAKSP.js +0 -14
@@ -7,8 +7,8 @@ import {
7
7
  resetPermissionsCache,
8
8
  setPermissionsForTesting,
9
9
  usePermissions
10
- } from "./chunk-LSWB3ZZV.js";
11
- import "./chunk-YC2MAKSP.js";
10
+ } from "./chunk-T2PZWXFJ.js";
11
+ import "./chunk-YGYBM7SQ.js";
12
12
  export {
13
13
  hasPermission,
14
14
  loadPermissionChecker,
@@ -10,6 +10,7 @@
10
10
  * made a manifest look like it declared something it did not.
11
11
  */
12
12
  import { type PluginEnvVar } from "./docs.js";
13
+ export { validateThemeCss } from "./theme-css.js";
13
14
  export { ENV_NAME_PATTERN, MAX_ENV_DESCRIPTION_LENGTH, pluginDocsPage, pluginDocsUrl, pluginEnvVars, type PluginEnvVar, } from "./docs.js";
14
15
  /**
15
16
  * The plugin API this build implements. A minor bump adds to the API and
@@ -19,7 +20,7 @@ export { ENV_NAME_PATTERN, MAX_ENV_DESCRIPTION_LENGTH, pluginDocsPage, pluginDoc
19
20
  * "^1.2" needs 1.2 or later). engine.termix is checked against the core
20
21
  * version by the server.
21
22
  */
22
- export declare const PLUGIN_API_VERSION = "1.3.0";
23
+ export declare const PLUGIN_API_VERSION = "1.5.0";
23
24
  /** The API major, the default engine.api for a new plugin. */
24
25
  export declare const SUPPORTED_PLUGIN_API_VERSION: string;
25
26
  /** Whether this build's plugin API satisfies a manifest's engine.api. */
@@ -265,6 +266,41 @@ export interface PluginAuthContribution {
265
266
  /** Keyboard-interactive handler ids, e.g. "warpgate". */
266
267
  keyboardInteractive?: string[];
267
268
  }
269
+ /** A layout id or theme id: short, lowercase, used as a CSS class for themes. */
270
+ export declare const LOOK_ID_PATTERN: RegExp;
271
+ /**
272
+ * A whole-app layout the frontend registers with app.registerLayout. Needs
273
+ * the ui:layout capability. Declared here so core can list and choose it
274
+ * before any plugin code runs.
275
+ */
276
+ export interface PluginLayoutContribution {
277
+ id: string;
278
+ /** i18n key in the plugin's namespace, or plain text. */
279
+ label: string;
280
+ description?: string;
281
+ /** Theme picked with this layout when nobody chose one. */
282
+ defaultTheme?: string;
283
+ /** The layout a default-look plugin starts with. */
284
+ default?: boolean;
285
+ }
286
+ /**
287
+ * A theme: one CSS file of token rules scoped under `.<id>`, applied as a
288
+ * class on <html>. Needs the ui:theme capability. No code runs for a theme.
289
+ */
290
+ export interface PluginThemeContribution {
291
+ id: string;
292
+ /** i18n key in the plugin's namespace, or plain text. */
293
+ label: string;
294
+ mode: "light" | "dark";
295
+ /** Path of the CSS file inside the plugin. */
296
+ css: string;
297
+ /** Colors the theme picker previews it with. */
298
+ swatch?: {
299
+ background: string;
300
+ panel: string;
301
+ text: string;
302
+ };
303
+ }
268
304
  export interface PluginContributions {
269
305
  tabs?: PluginTabContribution[];
270
306
  panels?: PluginViewContribution[];
@@ -323,6 +359,10 @@ export interface PluginContributions {
323
359
  * ctx.credentials.resolveHostProtocol.
324
360
  */
325
361
  protocols?: PluginProtocolContribution[];
362
+ /** Whole-app layouts. Needs the ui:layout capability. */
363
+ layouts?: PluginLayoutContribution[];
364
+ /** Themes. Needs the ui:theme capability. */
365
+ themes?: PluginThemeContribution[];
326
366
  }
327
367
  /** A field a protocol login carries besides username and password. */
328
368
  export interface PluginProtocolCredentialField {
@@ -460,7 +500,16 @@ export interface PluginManifest {
460
500
  nativeDependencies?: string[];
461
501
  /** How a linked desktop treats this plugin. Defaults to "mirror". */
462
502
  desktop?: PluginDesktopMode;
503
+ /**
504
+ * "desktop": the plugin only loads on a desktop app's own embedded backend,
505
+ * never on a server. It implies desktop "local" and may declare desktop:local.
506
+ */
507
+ runtime?: PluginRuntime;
463
508
  }
509
+ export type PluginRuntime = "desktop";
510
+ export declare const PLUGIN_RUNTIMES: readonly PluginRuntime[];
511
+ /** The desktop mode a manifest resolves to. */
512
+ export declare function resolvePluginDesktopMode(m: Pick<PluginManifest, "desktop" | "runtime">): PluginDesktopMode;
464
513
  export declare const DEFAULT_BACKEND_ENTRY = "dist/backend.js";
465
514
  export declare const DEFAULT_FRONTEND_ENTRY = "dist/frontend.js";
466
515
  export declare const DEFAULT_LOCALES_DIR = "locales";
package/dist/manifest.js CHANGED
@@ -12,6 +12,7 @@
12
12
  import semver from "semver";
13
13
  import { isKnownCapability } from "./capabilities.js";
14
14
  import { ENV_NAME_PATTERN, MAX_ENV_DESCRIPTION_LENGTH, pluginDocsUrl, } from "./docs.js";
15
+ export { validateThemeCss } from "./theme-css.js";
15
16
  export { ENV_NAME_PATTERN, MAX_ENV_DESCRIPTION_LENGTH, pluginDocsPage, pluginDocsUrl, pluginEnvVars, } from "./docs.js";
16
17
  /**
17
18
  * The plugin API this build implements. A minor bump adds to the API and
@@ -21,7 +22,7 @@ export { ENV_NAME_PATTERN, MAX_ENV_DESCRIPTION_LENGTH, pluginDocsPage, pluginDoc
21
22
  * "^1.2" needs 1.2 or later). engine.termix is checked against the core
22
23
  * version by the server.
23
24
  */
24
- export const PLUGIN_API_VERSION = "1.3.0";
25
+ export const PLUGIN_API_VERSION = "1.5.0";
25
26
  /** The API major, the default engine.api for a new plugin. */
26
27
  export const SUPPORTED_PLUGIN_API_VERSION = String(semver.major(PLUGIN_API_VERSION));
27
28
  /** Whether this build's plugin API satisfies a manifest's engine.api. */
@@ -168,6 +169,8 @@ export const SETTINGS_FIELD_TYPES = [
168
169
  "custom",
169
170
  ];
170
171
  export const SETTINGS_SCOPES = ["admin", "user", "host"];
172
+ /** A layout id or theme id: short, lowercase, used as a CSS class for themes. */
173
+ export const LOOK_ID_PATTERN = /^[a-z][a-z0-9-]{1,39}$/;
171
174
  /** Protocol ids core keeps for itself. */
172
175
  export const RESERVED_PROTOCOL_IDS = ["ssh"];
173
176
  /** Login keys a declared credential field may not reuse. */
@@ -189,6 +192,11 @@ export const PLUGIN_DESKTOP_MODES = [
189
192
  "local",
190
193
  "server",
191
194
  ];
195
+ export const PLUGIN_RUNTIMES = ["desktop"];
196
+ /** The desktop mode a manifest resolves to. */
197
+ export function resolvePluginDesktopMode(m) {
198
+ return m.desktop ?? (m.runtime === "desktop" ? "local" : "mirror");
199
+ }
192
200
  export const DEFAULT_BACKEND_ENTRY = "dist/backend.js";
193
201
  export const DEFAULT_FRONTEND_ENTRY = "dist/frontend.js";
194
202
  export const DEFAULT_LOCALES_DIR = "locales";
@@ -224,6 +232,7 @@ const ALLOWED_TOP_LEVEL = new Set([
224
232
  "platforms",
225
233
  "nativeDependencies",
226
234
  "desktop",
235
+ "runtime",
227
236
  ]);
228
237
  const ALLOWED_CONTRIBUTES = new Set([
229
238
  "tabs",
@@ -243,6 +252,8 @@ const ALLOWED_CONTRIBUTES = new Set([
243
252
  "uses",
244
253
  "keybindingActions",
245
254
  "protocols",
255
+ "layouts",
256
+ "themes",
246
257
  ]);
247
258
  const ALLOWED_SETTINGS_FIELD = [
248
259
  "key",
@@ -360,6 +371,15 @@ export function validateManifest(manifest) {
360
371
  !PLUGIN_DESKTOP_MODES.includes(m.desktop)) {
361
372
  errors.push(`Field "desktop" must be one of: ${PLUGIN_DESKTOP_MODES.join(", ")}`);
362
373
  }
374
+ if (m.runtime !== undefined &&
375
+ !PLUGIN_RUNTIMES.includes(m.runtime)) {
376
+ errors.push(`Field "runtime" must be one of: ${PLUGIN_RUNTIMES.join(", ")}`);
377
+ }
378
+ if (m.runtime === "desktop" &&
379
+ m.desktop !== undefined &&
380
+ m.desktop !== "local") {
381
+ errors.push(`A "desktop" runtime plugin must use desktop "local"`);
382
+ }
363
383
  validateDependencyMap(m.dependencies, "dependencies", errors);
364
384
  validateDependencyMap(m.optionalDependencies, "optionalDependencies", errors);
365
385
  validateProvides(m.provides, errors);
@@ -734,6 +754,81 @@ function validateContributes(contributes, pluginId, errors) {
734
754
  validateUiPresets(contributes.uiPresets, errors);
735
755
  validateKeybindingActions(contributes.keybindingActions, errors);
736
756
  validateProtocols(contributes.protocols, errors);
757
+ validateLayouts(contributes.layouts, errors);
758
+ validateThemes(contributes.themes, errors);
759
+ }
760
+ /** An array of objects with unique look ids; calls `each` for every valid entry. */
761
+ function validateLookList(value, field, allowed, errors, each) {
762
+ if (value === undefined)
763
+ return;
764
+ if (!Array.isArray(value)) {
765
+ errors.push(`Field "contributes.${field}" must be an array`);
766
+ return;
767
+ }
768
+ const seen = new Set();
769
+ value.forEach((raw, index) => {
770
+ const where = `contributes.${field}[${index}]`;
771
+ if (!isPlainObject(raw)) {
772
+ errors.push(`${where} must be an object`);
773
+ return;
774
+ }
775
+ rejectUnknown(raw, allowed, where, errors);
776
+ if (typeof raw.id !== "string" || !LOOK_ID_PATTERN.test(raw.id)) {
777
+ errors.push(`${where}.id must match ${LOOK_ID_PATTERN} (lowercase letters, digits, dashes)`);
778
+ }
779
+ else {
780
+ if (seen.has(raw.id))
781
+ errors.push(`${where}.id duplicates "${raw.id}"`);
782
+ seen.add(raw.id);
783
+ }
784
+ requireString(raw.label, `${where}.label`, errors);
785
+ each(raw, where);
786
+ });
787
+ }
788
+ function validateLayouts(value, errors) {
789
+ validateLookList(value, "layouts", ["id", "label", "description", "defaultTheme", "default"], errors, (raw, where) => {
790
+ if (raw.description !== undefined)
791
+ requireString(raw.description, `${where}.description`, errors);
792
+ if (raw.defaultTheme !== undefined &&
793
+ (typeof raw.defaultTheme !== "string" ||
794
+ !LOOK_ID_PATTERN.test(raw.defaultTheme))) {
795
+ errors.push(`${where}.defaultTheme must be a theme id`);
796
+ }
797
+ if (raw.default !== undefined && typeof raw.default !== "boolean") {
798
+ errors.push(`${where}.default must be a boolean`);
799
+ }
800
+ });
801
+ if (Array.isArray(value) &&
802
+ value.filter((raw) => isPlainObject(raw) && raw.default === true).length > 1) {
803
+ errors.push('Only one entry in "contributes.layouts" may set default');
804
+ }
805
+ }
806
+ const HEX_COLOR = /^#[0-9a-fA-F]{3,8}$/;
807
+ function validateThemes(value, errors) {
808
+ validateLookList(value, "themes", ["id", "label", "mode", "css", "swatch"], errors, (raw, where) => {
809
+ if (raw.mode !== "light" && raw.mode !== "dark") {
810
+ errors.push(`${where}.mode must be "light" or "dark"`);
811
+ }
812
+ if (requireString(raw.css, `${where}.css`, errors)) {
813
+ requireRelativePath(raw.css, `${where}.css`, errors);
814
+ if (!raw.css.endsWith(".css")) {
815
+ errors.push(`${where}.css must be a .css file`);
816
+ }
817
+ }
818
+ if (raw.swatch !== undefined) {
819
+ if (!isPlainObject(raw.swatch)) {
820
+ errors.push(`${where}.swatch must be an object`);
821
+ return;
822
+ }
823
+ rejectUnknown(raw.swatch, ["background", "panel", "text"], `${where}.swatch`, errors);
824
+ for (const key of ["background", "panel", "text"]) {
825
+ const color = raw.swatch[key];
826
+ if (typeof color !== "string" || !HEX_COLOR.test(color)) {
827
+ errors.push(`${where}.swatch.${key} must be a hex color`);
828
+ }
829
+ }
830
+ }
831
+ });
737
832
  }
738
833
  export const PROTOCOL_ID_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
739
834
  export const PROTOCOL_FIELD_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9]{0,31}$/;
@@ -1595,6 +1690,14 @@ export function parseManifest(raw) {
1595
1690
  if (contributesAuth && !manifest.capabilities.includes("auth:provide")) {
1596
1691
  errors.push('contributes.auth needs the "auth:provide" capability in the capabilities array');
1597
1692
  }
1693
+ if ((manifest.contributes?.layouts?.length ?? 0) > 0 &&
1694
+ !manifest.capabilities.includes("ui:layout")) {
1695
+ errors.push('contributes.layouts needs the "ui:layout" capability in the capabilities array');
1696
+ }
1697
+ if ((manifest.contributes?.themes?.length ?? 0) > 0 &&
1698
+ !manifest.capabilities.includes("ui:theme")) {
1699
+ errors.push('contributes.themes needs the "ui:theme" capability in the capabilities array');
1700
+ }
1598
1701
  for (const secret of manifest.requiresSecret ?? []) {
1599
1702
  if (secret.plugin === manifest.id) {
1600
1703
  errors.push(`requiresSecret["${secret.key}"] points at this plugin; use ctx.secrets.get instead`);
@@ -0,0 +1,29 @@
1
+ /**
2
+ * SSH key helpers for plugins that make, check or deploy keys: generate a key
3
+ * pair, tell what a key is, derive a public key and check a pair belongs
4
+ * together. Pure functions over ssh2, which the host supplies. Core keeps its
5
+ * own copy for the connect pipeline.
6
+ */
7
+ export type SshKeyKind = "ssh-ed25519" | "ssh-rsa" | "ecdsa-sha2-nistp256";
8
+ export interface GeneratedSshKeyPair {
9
+ privateKey: string;
10
+ publicKey: string;
11
+ keyType: SshKeyKind;
12
+ }
13
+ export interface GenerateSshKeyPairOptions {
14
+ /** RSA only. 2048 to 8192, default 4096. */
15
+ bits?: number;
16
+ passphrase?: string;
17
+ comment?: string;
18
+ }
19
+ /** Makes a new key pair; the public half is one authorized_keys line. */
20
+ export declare function generateSshKeyPair(type: SshKeyKind, options?: GenerateSshKeyPairOptions): GeneratedSshKeyPair;
21
+ /** The key's algorithm, like "ssh-ed25519", or null when it cannot be read. */
22
+ export declare function detectSshKeyType(key: string, passphrase?: string): string | null;
23
+ /** The authorized_keys line for a private key, or null. */
24
+ export declare function publicKeyFromPrivate(privateKey: string, passphrase?: string): string | null;
25
+ /** Whether a public key line belongs to the private key. */
26
+ export declare function validateSshKeyPair(privateKey: string, publicKey: string, passphrase?: string): {
27
+ isValid: boolean;
28
+ error?: string;
29
+ };
@@ -0,0 +1,85 @@
1
+ /**
2
+ * SSH key helpers for plugins that make, check or deploy keys: generate a key
3
+ * pair, tell what a key is, derive a public key and check a pair belongs
4
+ * together. Pure functions over ssh2, which the host supplies. Core keeps its
5
+ * own copy for the connect pipeline.
6
+ */
7
+ import ssh2 from "ssh2";
8
+ /** Makes a new key pair; the public half is one authorized_keys line. */
9
+ export function generateSshKeyPair(type, options = {}) {
10
+ const algorithm = type === "ssh-rsa" ? "rsa" : type === "ssh-ed25519" ? "ed25519" : "ecdsa";
11
+ if (type !== "ssh-rsa" &&
12
+ type !== "ssh-ed25519" &&
13
+ type !== "ecdsa-sha2-nistp256") {
14
+ throw new Error(`Unsupported key type: ${String(type)}`);
15
+ }
16
+ const generateOptions = {
17
+ comment: options.comment ?? "",
18
+ };
19
+ if (options.passphrase) {
20
+ generateOptions.passphrase = options.passphrase;
21
+ generateOptions.cipher = "aes256-ctr";
22
+ }
23
+ if (type === "ssh-rsa") {
24
+ const bits = options.bits ?? 4096;
25
+ if (!Number.isInteger(bits) || bits < 2048 || bits > 8192) {
26
+ throw new Error("RSA keys need 2048 to 8192 bits");
27
+ }
28
+ generateOptions.bits = bits;
29
+ }
30
+ if (type === "ecdsa-sha2-nistp256")
31
+ generateOptions.bits = 256;
32
+ const pair = ssh2.utils.generateKeyPairSync(algorithm, generateOptions);
33
+ return {
34
+ privateKey: pair.private,
35
+ publicKey: pair.public.trim(),
36
+ keyType: type,
37
+ };
38
+ }
39
+ function parse(key, passphrase) {
40
+ const parsed = ssh2.utils.parseKey(key.trim(), passphrase || undefined);
41
+ const first = Array.isArray(parsed) ? parsed[0] : parsed;
42
+ if (!first || first instanceof Error) {
43
+ throw first instanceof Error ? first : new Error("Not a readable SSH key");
44
+ }
45
+ return first;
46
+ }
47
+ /** The key's algorithm, like "ssh-ed25519", or null when it cannot be read. */
48
+ export function detectSshKeyType(key, passphrase) {
49
+ try {
50
+ return parse(key, passphrase).type;
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ /** The authorized_keys line for a private key, or null. */
57
+ export function publicKeyFromPrivate(privateKey, passphrase) {
58
+ try {
59
+ const parsed = parse(privateKey, passphrase);
60
+ return `${parsed.type} ${parsed.getPublicSSH().toString("base64")}`;
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ /** Whether a public key line belongs to the private key. */
67
+ export function validateSshKeyPair(privateKey, publicKey, passphrase) {
68
+ try {
69
+ const priv = parse(privateKey, passphrase);
70
+ const pub = parse(publicKey);
71
+ const same = priv.getPublicSSH().equals(pub.getPublicSSH());
72
+ return same
73
+ ? { isValid: true }
74
+ : {
75
+ isValid: false,
76
+ error: "The public key does not match the private key",
77
+ };
78
+ }
79
+ catch (error) {
80
+ return {
81
+ isValid: false,
82
+ error: error instanceof Error ? error.message : String(error),
83
+ };
84
+ }
85
+ }
@@ -89,6 +89,7 @@ globalThis.__termixTestPluginHost = {
89
89
  useHostStatus: () => null,
90
90
  useCurrentUser: () => null,
91
91
  useTheme: () => ({ theme: "dark" }),
92
+ useShell: () => null,
92
93
  toast: { success: noop, error: noop, info: noop, warning: noop },
93
94
  getApi: () => ({
94
95
  get: offline,
package/dist/testing.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * The vitest config these run under comes from @termix-ssh/plugin-sdk/vitest-preset.
9
9
  */
10
- import type { PluginContext } from "./backend.js";
10
+ import type { PluginAuditEntry, PluginContext, PluginDesktopLocal } from "./backend.js";
11
11
  import type { HostActionContribution, TermixApp } from "./frontend.js";
12
12
  import type { PluginManifest } from "./manifest.js";
13
13
  import type { PluginTableDefinition } from "./db.js";
@@ -25,7 +25,7 @@ export interface FakeWsRoute {
25
25
  import type { PluginLoginMethod, PluginVerifiedIdentity, PluginSecondFactor, PluginSshAuthProvider, PluginSshConnectOptions, PluginSshHost, PluginHostSummary, PluginShareableUser, PluginShareableRole, PluginSshKeyCredential, PluginSshKeyCredentialInput } from "./backend.js";
26
26
  import type { SyncEntityRegistration } from "./backend.js";
27
27
  import type { PluginDatabase } from "./backend.js";
28
- import type { PluginExternalClientRequest, PluginProtocolTarget, PluginHostStatusEntry, PluginNotification, PluginNotificationChannel, PluginNotifyHub, PluginFetchInit, PluginBinarySpec, PluginTlsCertificateInfo, PluginTlsStatus, PluginSummary, PluginKeyboardInteractiveHandler, PluginProcessHandle, PluginProcessOptions } from "./backend.js";
28
+ import type { PluginExternalClientRequest, PluginProtocolTarget, PluginHostProbeTarget, PluginHostStatusEntry, PluginActivityProvider, PluginHostStatusProvider, PluginNotification, PluginNotificationChannel, PluginNotifyHub, PluginFetchInit, PluginBinarySpec, PluginTlsCertificateInfo, PluginTlsStatus, PluginSummary, PluginKeyboardInteractiveHandler, PluginProcessHandle, PluginProcessOptions } from "./backend.js";
29
29
  /** A timer registered through ctx.schedule, run by hand with runScheduled. */
30
30
  export interface FakeScheduledJob {
31
31
  kind: "every" | "after";
@@ -42,6 +42,8 @@ export interface FakeContextOptions {
42
42
  settings?: Record<string, unknown>;
43
43
  /** Seed for the core settings ctx.settings.readCore can reach. */
44
44
  coreSettings?: Record<string, string>;
45
+ /** What ctx.files.dataDir() answers. Defaults to /tmp/<pluginId>. */
46
+ dataDir?: string;
45
47
  /** What ctx.ssh.connect and withConnection hand back as the client. */
46
48
  sshClient?: unknown;
47
49
  /** A real database behind ctx.db, usually createTestDb().database. */
@@ -75,8 +77,18 @@ export interface FakeContextOptions {
75
77
  sshKeyCredentials?: PluginSshKeyCredential[];
76
78
  /** What ctx.desktop.available() answers. Defaults to false. */
77
79
  desktopAvailable?: boolean;
80
+ /** What ctx.desktop.local is. Calls throw when this is not given. */
81
+ desktopLocal?: PluginDesktopLocal;
82
+ /** The stored audit lines ctx.audit.list and exportBatches serve. */
83
+ auditEntries?: PluginAuditEntry[];
78
84
  /** What ctx.hosts.status.get and check answer, by host id. */
79
85
  hostStatuses?: Record<number, PluginHostStatusEntry>;
86
+ /** What ctx.hosts.probeTargets answers. */
87
+ probeTargets?: PluginHostProbeTarget[];
88
+ /** Host ids ctx.hosts.sessionActive answers true for. */
89
+ activeSessions?: number[];
90
+ /** What ctx.network.tcpProbe and ctx.ssh.probe answer. Defaults to false. */
91
+ reachable?: (target: string | number, port?: number) => boolean;
80
92
  /** The acting user's channels, what ctx.notify.channels answers. */
81
93
  notificationChannels?: PluginNotificationChannel[];
82
94
  /**
@@ -164,6 +176,10 @@ export interface FakePluginContext {
164
176
  }) => Promise<Record<string, string>>;
165
177
  /** Everything the plugin registered through ctx.disposables. */
166
178
  disposals: Array<() => void | Promise<void>>;
179
+ /** Providers registered through ctx.hosts.status.provide. */
180
+ hostStatusProviders: PluginHostStatusProvider[];
181
+ /** Every provider passed to ctx.hosts.activity.provide that is still registered. */
182
+ activityProviders: PluginActivityProvider[];
167
183
  /** Topics emitted through ctx.events.emit, in order. */
168
184
  emitted: Array<{
169
185
  topic: string;
@@ -255,6 +271,8 @@ export interface FakePluginContext {
255
271
  challenges: Map<string, string>;
256
272
  renewers: number;
257
273
  };
274
+ /** Calls every ctx.audit.subscribe handler, like a line core just accepted. */
275
+ emitAudit: (entry: PluginAuditEntry) => void;
258
276
  /** Every ctx.audit.record entry, in order. */
259
277
  audits: Array<{
260
278
  action: string;
@@ -321,6 +339,8 @@ export interface MockContextOptions {
321
339
  settings?: Record<string, unknown>;
322
340
  /** Seed for the core settings readCore can reach. */
323
341
  coreSettings?: Record<string, string>;
342
+ /** What ctx.files.dataDir() answers. */
343
+ dataDir?: string;
324
344
  /** Acting user returned by currentActor and used by asUser. */
325
345
  actor?: string;
326
346
  /** What ctx.ssh.connect and withConnection hand back as the client. */
@@ -346,8 +366,18 @@ export interface MockContextOptions {
346
366
  sshKeyCredentials?: PluginSshKeyCredential[];
347
367
  /** What ctx.desktop.available() answers. */
348
368
  desktopAvailable?: boolean;
369
+ /** What ctx.desktop.local is. See FakeContextOptions. */
370
+ desktopLocal?: PluginDesktopLocal;
371
+ /** See FakeContextOptions. */
372
+ auditEntries?: PluginAuditEntry[];
349
373
  /** What ctx.hosts.status answers. See FakeContextOptions. */
350
374
  hostStatuses?: Record<number, PluginHostStatusEntry>;
375
+ /** What ctx.hosts.probeTargets answers. */
376
+ probeTargets?: PluginHostProbeTarget[];
377
+ /** Host ids ctx.hosts.sessionActive answers true for. */
378
+ activeSessions?: number[];
379
+ /** What ctx.network.tcpProbe and ctx.ssh.probe answer. */
380
+ reachable?: (target: string | number, port?: number) => boolean;
351
381
  /** The acting user's channels, what ctx.notify.channels answers. */
352
382
  notificationChannels?: PluginNotificationChannel[];
353
383
  /**
@@ -378,19 +408,6 @@ export interface MockPluginContext extends FakePluginContext {
378
408
  /** Capability ids checked during the test, in order, denied ones included. */
379
409
  checked: string[];
380
410
  }
381
- /**
382
- * A context double that gates on capabilities the way src/backend/plugins/ctx.ts
383
- * does, so a test can prove a plugin fails closed without a running server.
384
- *
385
- * The gates on today's ctx surface: kv:own on every ctx.kv call, db:own on
386
- * ctx.db, network:serve on ctx.http and ctx.ws, events:core on emitting a
387
- * topic outside the plugin's own namespace, settings:read-core on
388
- * ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh,
389
- * notify:send on ctx.notify (notify:hub on serve), network:outbound on ctx.fetch, process:spawn on
390
- * ctx.process, system:tls on ctx.system, plugins:read on ctx.plugins, auth:provide on ctx.auth
391
- * (users:impersonate on issueUserToken), and users:read on ctx.users. Reading a plugin's own settings is deliberately
392
- * ungated. As the SDK grows a member, add its gate here in the same shape.
393
- */
394
411
  export declare function createMockCtx(options?: MockContextOptions): MockPluginContext;
395
412
  /** A sqlite handle as createTestDb exposes it. Structural, like better-sqlite3's. */
396
413
  export interface TestSqlite {