@termix-ssh/plugin-sdk 1.0.10 → 1.0.12

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 (123) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/PLUGIN-GUIDE.md +151 -36
  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 +37 -3
  9. package/dist/capabilities.js +2 -0
  10. package/dist/frontend.d.ts +152 -0
  11. package/dist/frontend.js +7 -0
  12. package/dist/host/PluginsTab-EV4MSGY6.js +2933 -0
  13. package/dist/host/{chunk-G346FUE4.js → chunk-AWR6FYDO.js} +8877 -11159
  14. package/dist/host/{chunk-NZG6HNQH.js → chunk-RUPLD6RF.js} +1504 -15
  15. package/dist/host/{chunk-DIOJ3KW2.js → chunk-VEWOYAMZ.js} +6 -1
  16. package/dist/host/chunk-X4QZKO5G.js +3120 -0
  17. package/dist/host/chunk-XKA3DBB2.js +10375 -0
  18. package/dist/host/chunk-YGYBM7SQ.js +58 -0
  19. package/dist/host/core-classes.json +1 -1
  20. package/dist/host/{linked-server-QAKKDBFM.js → linked-server-B4NEG4MA.js} +2 -2
  21. package/dist/host/{rbac-api-5267EMX7.js → rbac-api-OUO3LUOC.js} +2 -2
  22. package/dist/host/testing-host.js +123 -57
  23. package/dist/host/types/src/types/ui-types.d.ts +2 -1
  24. package/dist/host/types/src/ui/api/audit-log-api.d.ts +46 -0
  25. package/dist/host/types/src/ui/api/host-defaults-api.d.ts +42 -0
  26. package/dist/host/types/src/ui/api/host-tags-api.d.ts +2 -0
  27. package/dist/host/types/src/ui/api/look-api.d.ts +40 -0
  28. package/dist/host/types/src/ui/api/open-tabs-api.d.ts +4 -0
  29. package/dist/host/types/src/ui/api/plugins-api.d.ts +26 -0
  30. package/dist/host/types/src/ui/api/private-endpoints-api.d.ts +6 -0
  31. package/dist/host/types/src/ui/api/tls-api.d.ts +25 -0
  32. package/dist/host/types/src/ui/auth/ElectronLoginForm.d.ts +9 -0
  33. package/dist/host/types/src/ui/components/command.d.ts +78 -0
  34. package/dist/host/types/src/ui/components/folder-style.d.ts +16 -0
  35. package/dist/host/types/src/ui/components/theme-provider.d.ts +2 -1
  36. package/dist/host/types/src/ui/components/version-badge.d.ts +7 -0
  37. package/dist/host/types/src/ui/hooks/use-page-visible-interval.d.ts +10 -0
  38. package/dist/host/types/src/ui/lib/activity-types.d.ts +15 -0
  39. package/dist/host/types/src/ui/lib/app-version.d.ts +2 -0
  40. package/dist/host/types/src/ui/lib/apply-ui-preset.d.ts +19 -0
  41. package/dist/host/types/src/ui/lib/connection-origin.d.ts +4 -5
  42. package/dist/host/types/src/ui/lib/create-id.d.ts +2 -0
  43. package/dist/host/types/src/ui/lib/database-transfer-url.d.ts +8 -0
  44. package/dist/host/types/src/ui/lib/desktop-updates.d.ts +29 -0
  45. package/dist/host/types/src/ui/lib/electron.d.ts +5 -0
  46. package/dist/host/types/src/ui/lib/host-connection-tabs.d.ts +14 -0
  47. package/dist/host/types/src/ui/lib/host-to-ssh-host.d.ts +4 -0
  48. package/dist/host/types/src/ui/lib/issue-url.d.ts +21 -0
  49. package/dist/host/types/src/ui/lib/local-adaptive-preferences.d.ts +9 -0
  50. package/dist/host/types/src/ui/lib/pane-nav-hotkey.d.ts +7 -0
  51. package/dist/host/types/src/ui/lib/plugin-i18n.d.ts +16 -0
  52. package/dist/host/types/src/ui/lib/role-label.d.ts +3 -0
  53. package/dist/host/types/src/ui/look/LookSettings.d.ts +8 -0
  54. package/dist/host/types/src/ui/look/layout-registry.d.ts +34 -0
  55. package/dist/host/types/src/ui/look/look-store.d.ts +29 -0
  56. package/dist/host/types/src/ui/look/resolve-look.d.ts +18 -0
  57. package/dist/host/types/src/ui/look/theme-css.d.ts +7 -0
  58. package/dist/host/types/src/ui/look/theme-registry.d.ts +41 -0
  59. package/dist/host/types/src/ui/look/use-look-theme.d.ts +12 -0
  60. package/dist/host/types/src/ui/manage/manage-requests.d.ts +34 -0
  61. package/dist/host/types/src/ui/plugin-host/loader.d.ts +2 -0
  62. package/dist/host/types/src/ui/plugin-host/scope.d.ts +6 -0
  63. package/dist/host/types/src/ui/plugin-host/sdk-ui.d.ts +102 -0
  64. package/dist/host/types/src/ui/plugin-host/view-ownership.d.ts +1 -1
  65. package/dist/host/types/src/ui/plugins/PluginConsentPrompt.d.ts +20 -0
  66. package/dist/host/types/src/ui/plugins/PluginDetail.d.ts +7 -0
  67. package/dist/host/types/src/ui/plugins/PluginReleaseNotes.d.ts +14 -0
  68. package/dist/host/types/src/ui/plugins/PluginsTab.d.ts +1 -0
  69. package/dist/host/types/src/ui/plugins/RegistriesSection.d.ts +4 -0
  70. package/dist/host/types/src/ui/plugins/plugin-bits.d.ts +23 -0
  71. package/dist/host/types/src/ui/plugins/plugin-model.d.ts +116 -0
  72. package/dist/host/types/src/ui/plugins/use-plugins-manager.d.ts +75 -0
  73. package/dist/host/types/src/ui/settings/appearance/appearance-controls.d.ts +53 -0
  74. package/dist/host/types/src/ui/settings/sync/LinkServerDialog.d.ts +12 -0
  75. package/dist/host/types/src/ui/settings/sync/ProxySettingsFields.d.ts +15 -0
  76. package/dist/host/types/src/ui/settings/sync/saved-servers.d.ts +3 -0
  77. package/dist/host/types/src/ui/shell/controller/ShellControllerProvider.d.ts +20 -0
  78. package/dist/host/types/src/ui/shell/controller/plugin-shell.d.ts +7 -0
  79. package/dist/host/types/src/ui/shell/controller/shell-store.d.ts +4 -0
  80. package/dist/host/types/src/ui/shell/controller/types.d.ts +156 -0
  81. package/dist/host/types/src/ui/shell/controller/use-hosts.d.ts +10 -0
  82. package/dist/host/types/src/ui/shell/controller/use-regions.d.ts +5 -0
  83. package/dist/host/types/src/ui/shell/controller/use-shell-keybindings.d.ts +12 -0
  84. package/dist/host/types/src/ui/shell/controller/use-shell-lifecycle.d.ts +17 -0
  85. package/dist/host/types/src/ui/shell/controller/use-split.d.ts +21 -0
  86. package/dist/host/types/src/ui/shell/controller/use-tabs.d.ts +70 -0
  87. package/dist/host/types/src/ui/shell/controller/use-workspace-layout.d.ts +4 -0
  88. package/dist/host/types/src/ui/shell/reconnect-tabs.d.ts +6 -0
  89. package/dist/host/types/src/ui/shell/shell-layout.d.ts +46 -0
  90. package/dist/host/types/src/ui/shell/split/EmptyPanePicker.d.ts +32 -0
  91. package/dist/host/types/src/ui/shell/split/SplitDropOverlay.d.ts +9 -0
  92. package/dist/host/types/src/ui/shell/split/SplitLayoutMenu.d.ts +16 -0
  93. package/dist/host/types/src/ui/shell/split/SplitView.d.ts +31 -0
  94. package/dist/host/types/src/ui/shell/split/split-drag.d.ts +58 -0
  95. package/dist/host/types/src/ui/shell/split/split-presets.d.ts +28 -0
  96. package/dist/host/types/src/ui/shell/split/split-tabs.d.ts +95 -0
  97. package/dist/host/types/src/ui/shell/split/split-targets.d.ts +30 -0
  98. package/dist/host/types/src/ui/shell/split/split-tree.d.ts +89 -0
  99. package/dist/host/types/src/ui/shell/stage/TabStage.d.ts +9 -0
  100. package/dist/host/types/src/ui/shell/stage/tab-stage-store.d.ts +24 -0
  101. package/dist/host/types/src/ui/shell/tab-close-guards.d.ts +9 -0
  102. package/dist/host/types/src/ui/shell/tab-handles.d.ts +8 -0
  103. package/dist/host/types/src/ui/shell/tabUtils.d.ts +15 -0
  104. package/dist/host/types/src/ui/sidebar/AuthEnrollmentSections.d.ts +5 -0
  105. package/dist/host/types/src/ui/sidebar/HostManagerData.d.ts +4 -0
  106. package/dist/host/types/src/ui/sidebar/HostManagerTabs.d.ts +10 -4
  107. package/dist/host/types/src/ui/sidebar/build-host-tree.d.ts +25 -0
  108. package/dist/host/types/src/ui/sidebar/host-export-payload.d.ts +30 -0
  109. package/dist/host/ui.js +6238 -4054
  110. package/dist/host/{use-permissions-2P2TP6GY.js → use-permissions-6VSHHUMB.js} +2 -2
  111. package/dist/manifest.d.ts +41 -1
  112. package/dist/manifest.js +89 -1
  113. package/dist/testing/setup.js +1 -0
  114. package/dist/testing.d.ts +12 -1
  115. package/dist/testing.js +25 -1
  116. package/dist/theme-css.d.ts +11 -0
  117. package/dist/theme-css.js +142 -0
  118. package/dist/vitest-preset.d.ts +18 -0
  119. package/dist/vitest-preset.js +14 -3
  120. package/package.json +1 -1
  121. package/schema/manifest.schema.json +111 -8
  122. package/tsconfig.plugin.json +1 -0
  123. 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-DIOJ3KW2.js";
11
- import "./chunk-YC2MAKSP.js";
10
+ } from "./chunk-VEWOYAMZ.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.2.0";
23
+ export declare const PLUGIN_API_VERSION = "1.4.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 {
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.2.0";
25
+ export const PLUGIN_API_VERSION = "1.4.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. */
@@ -243,6 +246,8 @@ const ALLOWED_CONTRIBUTES = new Set([
243
246
  "uses",
244
247
  "keybindingActions",
245
248
  "protocols",
249
+ "layouts",
250
+ "themes",
246
251
  ]);
247
252
  const ALLOWED_SETTINGS_FIELD = [
248
253
  "key",
@@ -734,6 +739,81 @@ function validateContributes(contributes, pluginId, errors) {
734
739
  validateUiPresets(contributes.uiPresets, errors);
735
740
  validateKeybindingActions(contributes.keybindingActions, errors);
736
741
  validateProtocols(contributes.protocols, errors);
742
+ validateLayouts(contributes.layouts, errors);
743
+ validateThemes(contributes.themes, errors);
744
+ }
745
+ /** An array of objects with unique look ids; calls `each` for every valid entry. */
746
+ function validateLookList(value, field, allowed, errors, each) {
747
+ if (value === undefined)
748
+ return;
749
+ if (!Array.isArray(value)) {
750
+ errors.push(`Field "contributes.${field}" must be an array`);
751
+ return;
752
+ }
753
+ const seen = new Set();
754
+ value.forEach((raw, index) => {
755
+ const where = `contributes.${field}[${index}]`;
756
+ if (!isPlainObject(raw)) {
757
+ errors.push(`${where} must be an object`);
758
+ return;
759
+ }
760
+ rejectUnknown(raw, allowed, where, errors);
761
+ if (typeof raw.id !== "string" || !LOOK_ID_PATTERN.test(raw.id)) {
762
+ errors.push(`${where}.id must match ${LOOK_ID_PATTERN} (lowercase letters, digits, dashes)`);
763
+ }
764
+ else {
765
+ if (seen.has(raw.id))
766
+ errors.push(`${where}.id duplicates "${raw.id}"`);
767
+ seen.add(raw.id);
768
+ }
769
+ requireString(raw.label, `${where}.label`, errors);
770
+ each(raw, where);
771
+ });
772
+ }
773
+ function validateLayouts(value, errors) {
774
+ validateLookList(value, "layouts", ["id", "label", "description", "defaultTheme", "default"], errors, (raw, where) => {
775
+ if (raw.description !== undefined)
776
+ requireString(raw.description, `${where}.description`, errors);
777
+ if (raw.defaultTheme !== undefined &&
778
+ (typeof raw.defaultTheme !== "string" ||
779
+ !LOOK_ID_PATTERN.test(raw.defaultTheme))) {
780
+ errors.push(`${where}.defaultTheme must be a theme id`);
781
+ }
782
+ if (raw.default !== undefined && typeof raw.default !== "boolean") {
783
+ errors.push(`${where}.default must be a boolean`);
784
+ }
785
+ });
786
+ if (Array.isArray(value) &&
787
+ value.filter((raw) => isPlainObject(raw) && raw.default === true).length > 1) {
788
+ errors.push('Only one entry in "contributes.layouts" may set default');
789
+ }
790
+ }
791
+ const HEX_COLOR = /^#[0-9a-fA-F]{3,8}$/;
792
+ function validateThemes(value, errors) {
793
+ validateLookList(value, "themes", ["id", "label", "mode", "css", "swatch"], errors, (raw, where) => {
794
+ if (raw.mode !== "light" && raw.mode !== "dark") {
795
+ errors.push(`${where}.mode must be "light" or "dark"`);
796
+ }
797
+ if (requireString(raw.css, `${where}.css`, errors)) {
798
+ requireRelativePath(raw.css, `${where}.css`, errors);
799
+ if (!raw.css.endsWith(".css")) {
800
+ errors.push(`${where}.css must be a .css file`);
801
+ }
802
+ }
803
+ if (raw.swatch !== undefined) {
804
+ if (!isPlainObject(raw.swatch)) {
805
+ errors.push(`${where}.swatch must be an object`);
806
+ return;
807
+ }
808
+ rejectUnknown(raw.swatch, ["background", "panel", "text"], `${where}.swatch`, errors);
809
+ for (const key of ["background", "panel", "text"]) {
810
+ const color = raw.swatch[key];
811
+ if (typeof color !== "string" || !HEX_COLOR.test(color)) {
812
+ errors.push(`${where}.swatch.${key} must be a hex color`);
813
+ }
814
+ }
815
+ }
816
+ });
737
817
  }
738
818
  export const PROTOCOL_ID_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
739
819
  export const PROTOCOL_FIELD_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9]{0,31}$/;
@@ -1595,6 +1675,14 @@ export function parseManifest(raw) {
1595
1675
  if (contributesAuth && !manifest.capabilities.includes("auth:provide")) {
1596
1676
  errors.push('contributes.auth needs the "auth:provide" capability in the capabilities array');
1597
1677
  }
1678
+ if ((manifest.contributes?.layouts?.length ?? 0) > 0 &&
1679
+ !manifest.capabilities.includes("ui:layout")) {
1680
+ errors.push('contributes.layouts needs the "ui:layout" capability in the capabilities array');
1681
+ }
1682
+ if ((manifest.contributes?.themes?.length ?? 0) > 0 &&
1683
+ !manifest.capabilities.includes("ui:theme")) {
1684
+ errors.push('contributes.themes needs the "ui:theme" capability in the capabilities array');
1685
+ }
1598
1686
  for (const secret of manifest.requiresSecret ?? []) {
1599
1687
  if (secret.plugin === manifest.id) {
1600
1688
  errors.push(`requiresSecret["${secret.key}"] points at this plugin; use ctx.secrets.get instead`);
@@ -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
@@ -112,6 +112,8 @@ export interface FakeContextOptions {
112
112
  linkedUsers?: Record<string, number>;
113
113
  /** Failures before ctx.auth.loginRateLimit locks a key. Defaults to 5. */
114
114
  loginAttemptLimit?: number;
115
+ /** What ctx.users.getLocale answers, by user id. A missing user answers null. */
116
+ locales?: Record<string, string>;
115
117
  }
116
118
  export interface FakeProcessOptions {
117
119
  run?: (file: string, args: readonly string[], options?: PluginProcessOptions) => PluginProcessHandle | Promise<PluginProcessHandle>;
@@ -147,6 +149,12 @@ export interface FakeAuthRegistrations {
147
149
  }>;
148
150
  /** Failures recorded through ctx.auth.loginRateLimit, by "<ip>|<key>". */
149
151
  loginFailures: Map<string, number>;
152
+ /** Every ctx.auth.issueUserToken call, in order. */
153
+ userTokens: Array<{
154
+ userId: string;
155
+ purpose: string;
156
+ ttlSeconds?: number;
157
+ }>;
150
158
  }
151
159
  export interface FakePluginContext {
152
160
  ctx: PluginContext;
@@ -363,6 +371,8 @@ export interface MockContextOptions {
363
371
  linkedUsers?: Record<string, number>;
364
372
  /** See FakeContextOptions. */
365
373
  loginAttemptLimit?: number;
374
+ /** See FakeContextOptions. */
375
+ locales?: Record<string, string>;
366
376
  }
367
377
  export interface MockPluginContext extends FakePluginContext {
368
378
  /** Capability ids checked during the test, in order, denied ones included. */
@@ -377,7 +387,8 @@ export interface MockPluginContext extends FakePluginContext {
377
387
  * topic outside the plugin's own namespace, settings:read-core on
378
388
  * ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh,
379
389
  * notify:send on ctx.notify (notify:hub on serve), network:outbound on ctx.fetch, process:spawn on
380
- * ctx.process, system:tls on ctx.system, plugins:read on ctx.plugins, and auth:provide on ctx.auth. Reading a plugin's own settings is deliberately
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
381
392
  * ungated. As the SDK grows a member, add its gate here in the same shape.
382
393
  */
383
394
  export declare function createMockCtx(options?: MockContextOptions): MockPluginContext;
package/dist/testing.js CHANGED
@@ -194,6 +194,7 @@ export function createFakeContext(options = {}) {
194
194
  completedLogins: [],
195
195
  revokedSessions: [],
196
196
  loginFailures: new Map(),
197
+ userTokens: [],
197
198
  };
198
199
  const sshClient = options.sshClient ?? {};
199
200
  let actor = options.actor;
@@ -673,6 +674,17 @@ export function createFakeContext(options = {}) {
673
674
  },
674
675
  },
675
676
  countLinkedUsers: async (provider) => options.linkedUsers?.[provider] ?? 0,
677
+ issueUserToken: async (userId, tokenOptions) => {
678
+ auth.userTokens.push({ userId, ...tokenOptions });
679
+ const ttl = Math.min(tokenOptions.ttlSeconds ?? 300, 900);
680
+ return {
681
+ token: `fake-token-${auth.userTokens.length}`,
682
+ expiresAt: new Date(Date.now() + ttl * 1000).toISOString(),
683
+ };
684
+ },
685
+ },
686
+ users: {
687
+ getLocale: async (userId) => options.locales?.[userId] ?? null,
676
688
  },
677
689
  desktop: {
678
690
  openIsolatedWindow: async (request) => {
@@ -914,7 +926,8 @@ export function createFakeContext(options = {}) {
914
926
  * topic outside the plugin's own namespace, settings:read-core on
915
927
  * ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh,
916
928
  * notify:send on ctx.notify (notify:hub on serve), network:outbound on ctx.fetch, process:spawn on
917
- * ctx.process, system:tls on ctx.system, plugins:read on ctx.plugins, and auth:provide on ctx.auth. Reading a plugin's own settings is deliberately
929
+ * ctx.process, system:tls on ctx.system, plugins:read on ctx.plugins, auth:provide on ctx.auth
930
+ * (users:impersonate on issueUserToken), and users:read on ctx.users. Reading a plugin's own settings is deliberately
918
931
  * ungated. As the SDK grows a member, add its gate here in the same shape.
919
932
  */
920
933
  export function createMockCtx(options = {}) {
@@ -948,6 +961,7 @@ export function createMockCtx(options = {}) {
948
961
  baseUrl: options.baseUrl,
949
962
  linkedUsers: options.linkedUsers,
950
963
  loginAttemptLimit: options.loginAttemptLimit,
964
+ locales: options.locales,
951
965
  manifest: {
952
966
  capabilities: options.capabilities ?? [],
953
967
  ...options.manifest,
@@ -1218,6 +1232,16 @@ export function createMockCtx(options = {}) {
1218
1232
  require("auth:provide");
1219
1233
  return ctx.auth.countLinkedUsers(provider);
1220
1234
  },
1235
+ issueUserToken: async (userId, tokenOptions) => {
1236
+ require("users:impersonate");
1237
+ return ctx.auth.issueUserToken(userId, tokenOptions);
1238
+ },
1239
+ },
1240
+ users: {
1241
+ getLocale: async (userId) => {
1242
+ require("users:read");
1243
+ return ctx.users.getLocale(userId);
1244
+ },
1221
1245
  },
1222
1246
  desktop: {
1223
1247
  openIsolatedWindow: async (request) => {
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Checks a theme's CSS before core serves it.
3
+ *
4
+ * A theme is colors and styles only, so its file may hold nothing but rules
5
+ * whose every selector starts with the theme's own class (`.nord`,
6
+ * `:root.nord`, `html.nord ...`), optionally inside @media or @supports.
7
+ * Anything that could load from elsewhere or run code is refused: @import,
8
+ * url() to another host, other at-rules, expression() and javascript:.
9
+ */
10
+ /** Every problem with a theme's CSS, empty when it is safe to serve. */
11
+ export declare function validateThemeCss(css: string, themeId: string): string[];
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Checks a theme's CSS before core serves it.
3
+ *
4
+ * A theme is colors and styles only, so its file may hold nothing but rules
5
+ * whose every selector starts with the theme's own class (`.nord`,
6
+ * `:root.nord`, `html.nord ...`), optionally inside @media or @supports.
7
+ * Anything that could load from elsewhere or run code is refused: @import,
8
+ * url() to another host, other at-rules, expression() and javascript:.
9
+ */
10
+ const ALLOWED_BLOCK_AT_RULES = new Set(["media", "supports"]);
11
+ function stripComments(css) {
12
+ return css.replace(/\/\*[\s\S]*?\*\//g, " ");
13
+ }
14
+ /** Splits on commas that are not inside parentheses or brackets. */
15
+ function splitSelectors(prelude) {
16
+ const parts = [];
17
+ let depth = 0;
18
+ let current = "";
19
+ for (const ch of prelude) {
20
+ if (ch === "(" || ch === "[")
21
+ depth += 1;
22
+ if (ch === ")" || ch === "]")
23
+ depth -= 1;
24
+ if (ch === "," && depth === 0) {
25
+ parts.push(current);
26
+ current = "";
27
+ continue;
28
+ }
29
+ current += ch;
30
+ }
31
+ parts.push(current);
32
+ return parts.map((part) => part.trim());
33
+ }
34
+ function withoutStrings(css) {
35
+ return css.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, '""');
36
+ }
37
+ function escapeRegExp(value) {
38
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
39
+ }
40
+ /** Index of the brace closing the one at `open`, skipping strings. */
41
+ function matchingBrace(css, open) {
42
+ let depth = 0;
43
+ let quote = null;
44
+ for (let i = open; i < css.length; i += 1) {
45
+ const ch = css[i];
46
+ if (quote) {
47
+ if (ch === "\\")
48
+ i += 1;
49
+ else if (ch === quote)
50
+ quote = null;
51
+ continue;
52
+ }
53
+ if (ch === '"' || ch === "'")
54
+ quote = ch;
55
+ else if (ch === "{")
56
+ depth += 1;
57
+ else if (ch === "}") {
58
+ depth -= 1;
59
+ if (depth === 0)
60
+ return i;
61
+ }
62
+ }
63
+ return -1;
64
+ }
65
+ function checkUrls(css, errors) {
66
+ const urls = css.matchAll(/url\(\s*(['"]?)([^'")]*)\1\s*\)/gi);
67
+ for (const match of urls) {
68
+ const target = match[2].trim();
69
+ if (target.startsWith("data:"))
70
+ continue;
71
+ if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("//")) {
72
+ errors.push(`url(${target}) points outside the plugin`);
73
+ }
74
+ }
75
+ }
76
+ function checkRules(css, scope, themeId, errors) {
77
+ let index = 0;
78
+ while (index < css.length) {
79
+ const rest = css.slice(index);
80
+ if (rest.trim() === "")
81
+ return;
82
+ const open = css.indexOf("{", index);
83
+ const semicolon = css.indexOf(";", index);
84
+ if (semicolon !== -1 && (open === -1 || semicolon < open)) {
85
+ const statement = css.slice(index, semicolon).trim();
86
+ errors.push(statement.startsWith("@")
87
+ ? `${statement.split(/\s/)[0]} is not allowed in a theme`
88
+ : `Unexpected "${statement}" outside a rule`);
89
+ index = semicolon + 1;
90
+ continue;
91
+ }
92
+ if (open === -1) {
93
+ errors.push(`Unexpected "${rest.trim()}" at the end`);
94
+ return;
95
+ }
96
+ const close = matchingBrace(css, open);
97
+ if (close === -1) {
98
+ errors.push("A rule is missing its closing brace");
99
+ return;
100
+ }
101
+ const prelude = css.slice(index, open).trim();
102
+ const body = css.slice(open + 1, close);
103
+ if (prelude.startsWith("@")) {
104
+ const name = prelude.slice(1).split(/[\s(]/)[0].toLowerCase();
105
+ if (ALLOWED_BLOCK_AT_RULES.has(name)) {
106
+ checkRules(body, scope, themeId, errors);
107
+ }
108
+ else {
109
+ errors.push(`@${name} is not allowed in a theme`);
110
+ }
111
+ }
112
+ else {
113
+ for (const selector of splitSelectors(prelude)) {
114
+ if (!scope.test(selector)) {
115
+ errors.push(`Selector "${selector}" must start with .${themeId}, :root.${themeId} or html.${themeId}`);
116
+ }
117
+ }
118
+ if (withoutStrings(body).includes("{")) {
119
+ errors.push(`Nested rules are not allowed in "${prelude}"`);
120
+ }
121
+ }
122
+ index = close + 1;
123
+ }
124
+ }
125
+ /** Every problem with a theme's CSS, empty when it is safe to serve. */
126
+ export function validateThemeCss(css, themeId) {
127
+ const errors = [];
128
+ const clean = stripComments(css);
129
+ if (/@import\b/i.test(clean))
130
+ errors.push("@import is not allowed in a theme");
131
+ if (/expression\s*\(/i.test(clean))
132
+ errors.push("expression() is not allowed in a theme");
133
+ if (/javascript:/i.test(clean))
134
+ errors.push("javascript: is not allowed in a theme");
135
+ if (/-moz-binding|behavior\s*:/i.test(clean))
136
+ errors.push("Script bindings are not allowed in a theme");
137
+ checkUrls(clean, errors);
138
+ const id = escapeRegExp(themeId);
139
+ const scope = new RegExp(`^(?::root|html)?\\.${id}(?![\\w-])`);
140
+ checkRules(clean, scope, themeId, errors);
141
+ return [...new Set(errors)];
142
+ }
@@ -27,8 +27,14 @@ export declare function pluginVitestConfig(configUrl: string, options?: PluginVi
27
27
  enforce: "pre";
28
28
  load(id: string): string;
29
29
  }[];
30
+ server: {
31
+ fs: {
32
+ allow: string[];
33
+ };
34
+ };
30
35
  resolve: {
31
36
  alias: Record<string, string>;
37
+ dedupe: string[];
32
38
  };
33
39
  test: {
34
40
  globals: boolean;
@@ -40,8 +46,14 @@ export declare function pluginVitestConfig(configUrl: string, options?: PluginVi
40
46
  enforce: "pre";
41
47
  load(id: string): string;
42
48
  }[];
49
+ server: {
50
+ fs: {
51
+ allow: string[];
52
+ };
53
+ };
43
54
  resolve: {
44
55
  alias: Record<string, string>;
56
+ dedupe: string[];
45
57
  };
46
58
  test: {
47
59
  name: string;
@@ -59,8 +71,14 @@ export declare function pluginVitestConfig(configUrl: string, options?: PluginVi
59
71
  enforce: "pre";
60
72
  load(id: string): string;
61
73
  }[];
74
+ server: {
75
+ fs: {
76
+ allow: string[];
77
+ };
78
+ };
62
79
  resolve: {
63
80
  alias: Record<string, string>;
81
+ dedupe: string[];
64
82
  };
65
83
  test: {
66
84
  name: string;
@@ -80,9 +80,18 @@ export function pluginVitestConfig(configUrl, options = {}) {
80
80
  // unexpectedly". Threads don't.
81
81
  const pool = "threads";
82
82
  const plugins = [wasmBinaryPlugin()];
83
+ // A local SDK linked into node_modules sits outside the plugin folder, so
84
+ // Vite must be allowed to read it, and React must come from the plugin.
85
+ const sdkDir = fs.realpathSync(fileURLToPath(new URL("..", import.meta.url)));
86
+ const server = { fs: { allow: [pluginDir, sdkDir] } };
87
+ const resolve = {
88
+ alias,
89
+ dedupe: ["react", "react-dom", "react-i18next", "i18next"],
90
+ };
83
91
  return {
84
92
  plugins,
85
- resolve: { alias },
93
+ server,
94
+ resolve,
86
95
  test: {
87
96
  globals: true,
88
97
  setupFiles: [setupFile],
@@ -90,7 +99,8 @@ export function pluginVitestConfig(configUrl, options = {}) {
90
99
  projects: [
91
100
  {
92
101
  plugins,
93
- resolve: { alias },
102
+ server,
103
+ resolve,
94
104
  test: {
95
105
  name: `${pluginId}:backend`,
96
106
  root: pluginDir,
@@ -103,7 +113,8 @@ export function pluginVitestConfig(configUrl, options = {}) {
103
113
  },
104
114
  {
105
115
  plugins,
106
- resolve: { alias },
116
+ server,
117
+ resolve,
107
118
  test: {
108
119
  name: `${pluginId}:frontend`,
109
120
  root: pluginDir,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@termix-ssh/plugin-sdk",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
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": {