@termix-ssh/plugin-sdk 1.0.9 → 1.0.11

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.11
4
+
5
+ Plugin API 1.3.0.
6
+
7
+ ### Added
8
+
9
+ - `ctx.auth.issueUserToken()` mints a short-lived session for a user, for calling Termix's own API as them from background work. Needs `users:impersonate` and `engine.api` 1.3
10
+ - `ctx.users.getLocale()` reads a user's UI language. Needs `users:read` and `engine.api` 1.3
11
+
12
+ ## 1.0.10
13
+
14
+ ### Changed
15
+
16
+ - `termix-plugin pack` includes the plugin's `LICENSE` and `NOTICE` files, so third-party notices ship with it
17
+
3
18
  ## 1.0.9
4
19
 
5
20
  ### Added
package/PLUGIN-GUIDE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Termix plugin guide
2
2
 
3
- SDK 1.0.9 | Plugin API 1.2.0
3
+ SDK 1.0.11 | Plugin API 1.3.0
4
4
 
5
5
  This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
6
6
  `@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
@@ -90,7 +90,7 @@ npm test # termix-plugin test: Vitest
90
90
  npm run typecheck # tsc
91
91
  npm run validate # termix-plugin validate: manifest, files, migrations, changelog, docs
92
92
  npm run migrations # termix-plugin migrations: write migrations after changing tables.ts
93
- npm run pack # termix-plugin pack: write <id>-<version>.tmxplug
93
+ npm run pack # termix-plugin pack: write <id>-<version>.tmxplug (with LICENSE and NOTICE)
94
94
  npm run format # Prettier
95
95
  npm run format:check
96
96
  ```
@@ -105,7 +105,7 @@ Before you call a change finished:
105
105
  - [ ] `npm run build` passes
106
106
  - [ ] `npm run format:check` passes
107
107
  - [ ] `npx termix-plugin migrations --check` passes (you ran `npm run migrations` after changing tables)
108
- - [ ] New strings are in `locales/en.json`
108
+ - [ ] New strings are in `locales/en.json`, and `plugin.name` / `plugin.description` match the manifest
109
109
  - [ ] New routes have an `@openapi` block and a test, including without the permission
110
110
  - [ ] New env vars are listed in `env` in the manifest
111
111
  - [ ] `docs/` and the "About this plugin" section of `AGENTS.md` still match what the plugin does
@@ -225,6 +225,8 @@ Bundled as ESM for Node 22+ into `dist/backend.js`. Backend imports of your own
225
225
  | `audit.record` | none | Write your own audit line |
226
226
  | `sync` | none | Sync your tables to a linked desktop |
227
227
  | `auth` | `auth:provide` | Login methods, second factors, SSH auth types |
228
+ | `auth.issueUserToken` | `users:impersonate` | A short-lived session for calling Termix's API as a user |
229
+ | `users.getLocale` | `users:read` | A user's UI language |
228
230
  | `system` | `system:tls` | The server's HTTPS certificate |
229
231
  | `plugins.list()` | `plugins:read` | Which plugins are installed |
230
232
  | `asUser(userId, fn)` | always audited | Run background work as a user |
@@ -237,6 +239,13 @@ In a route the actor is the signed-in user, set by Termix. `ctx.currentActor()`
237
239
  work with no request (a timer, an event) uses `ctx.asUser(userId, fn)`, which is audited and still applies
238
240
  that user's permissions.
239
241
 
242
+ Background work that must call Termix's own HTTP API (the Termix CLI, say) asks for a session with
243
+ `ctx.auth.issueUserToken(userId, { purpose, ttlSeconds })` and sends it as `Authorization: Bearer <token>`.
244
+ It needs `users:impersonate` and `engine.api` `^1.3`, lasts 300 seconds by default and 900 at most, and is
245
+ audited as `plugin_user_token_issued`. The user's permissions apply, and it is refused on routes that change
246
+ their password, second factors, API keys or linked logins. `ctx.users.getLocale(userId)` gives the user's
247
+ UI language, like `zh_CN`, or null, for writing to them in it.
248
+
240
249
  ### Routes
241
250
 
242
251
  Routes live at `/plugin-api/<id>/`, sockets at `/plugin-ws/<id>/`, on the main Termix port. You never need
@@ -484,7 +493,7 @@ your own host settings, under `host.pluginSettings[<id>]`.
484
493
 
485
494
  Slots you can fill include `terminal.toolbar`, `terminal.toolbarStatus`, `terminal.sidePanel`,
486
495
  `terminal.overlay`, `dashboard.counters`, `dashboard.hostMetrics`, `admin.userTabs`, `hosts.importMenu`,
487
- `hosts.panel`, `credentials.badges`, `shell.overlay` and `tab.menu`. See
496
+ `hosts.panel`, `credentials.badges`, `shell.overlay`, `shell.rightEdge` and `tab.menu`. See
488
497
  https://docs.termix.site/develop/more-extension-points.
489
498
 
490
499
  ## Looking native
@@ -538,6 +547,20 @@ Plurals use `_one` and `_other` with `t("hosts", { count })`. Other languages go
538
547
  `locales/translated/<code>.json` (like `de_DE.json`) with the same keys. Keep `{{placeholders}}` as they are.
539
548
  Untranslated strings show in English.
540
549
 
550
+ Termix shows your plugin's name and description in the plugin manager, the setup picker and settings.
551
+ Put them in `locales/en.json` as `plugin.name` and `plugin.description`, with the same text as `name` and
552
+ `description` in the manifest, so they get translated too. Without them, the English manifest text shows in
553
+ every language.
554
+
555
+ ```json
556
+ {
557
+ "plugin": {
558
+ "name": "Host Metrics",
559
+ "description": "Live CPU, memory, disk, network and GPU stats."
560
+ }
561
+ }
562
+ ```
563
+
541
564
  ## Docs
542
565
 
543
566
  `docs/index.md` is the first page: what the plugin does in a sentence or two, how to turn it on, how to use
@@ -740,37 +763,37 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
740
763
 
741
764
  ## Capabilities
742
765
 
743
- | Capability | Risk | What it allows |
744
- | -------------------- | -------- | ------------------------------------------------------ |
745
- | `credentials:read` | critical | Read stored secrets for hosts the user can reach |
746
- | `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
747
- | `ssh:connect` | high | Run any command on hosts, with the user's access |
748
- | `process:spawn` | high | Run programs on the Termix server |
749
- | `users:write` | high | Add users and change their roles |
750
- | `users:impersonate` | high | Run background work as another user, audited |
751
- | `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
752
- | `auth:provide` | high | Add login methods, second factors, SSH auth types |
753
- | `device:serial` | high | Open serial and USB ports on the server |
754
- | `notify:hub` | high | Receive every alert (the alerts hub only) |
755
- | `hosts:write` | medium | Add and edit hosts |
756
- | `credentials:use` | medium | Connect with saved credentials without seeing them |
757
- | `credentials:write` | medium | Save new credentials |
758
- | `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
759
- | `network:serve` | medium | HTTP routes and sockets |
760
- | `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN |
761
- | `users:read` | medium | Usernames and roles |
762
- | `events:core` | medium | Listen to core events |
763
- | `notify:send` | medium | Send alerts |
764
- | `audit:read` | medium | Read the audit log |
765
- | `desktop:window` | medium | Open windows in the desktop app |
766
- | `hosts:read` | low | Read hosts the user can see |
767
- | `db:own` | low | Your own tables |
768
- | `kv:own` | low | Small key value state |
769
- | `files:own` | low | Your own folder on disk |
770
- | `secrets:own` | low | Your own encrypted secrets |
771
- | `settings:read-core` | low | Read Termix server settings |
772
- | `plugins:read` | low | See installed plugins |
773
- | `ui:surface` | low | Add tabs, panels and settings |
766
+ | Capability | Risk | What it allows |
767
+ | -------------------- | -------- | ------------------------------------------------------- |
768
+ | `credentials:read` | critical | Read stored secrets for hosts the user can reach |
769
+ | `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
770
+ | `ssh:connect` | high | Run any command on hosts, with the user's access |
771
+ | `process:spawn` | high | Run programs on the Termix server |
772
+ | `users:write` | high | Add users and change their roles |
773
+ | `users:impersonate` | high | Act as another user, or get a session for them, audited |
774
+ | `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
775
+ | `auth:provide` | high | Add login methods, second factors, SSH auth types |
776
+ | `device:serial` | high | Open serial and USB ports on the server |
777
+ | `notify:hub` | high | Receive every alert (the alerts hub only) |
778
+ | `hosts:write` | medium | Add and edit hosts |
779
+ | `credentials:use` | medium | Connect with saved credentials without seeing them |
780
+ | `credentials:write` | medium | Save new credentials |
781
+ | `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
782
+ | `network:serve` | medium | HTTP routes and sockets |
783
+ | `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN |
784
+ | `users:read` | medium | Usernames, roles and UI language |
785
+ | `events:core` | medium | Listen to core events |
786
+ | `notify:send` | medium | Send alerts |
787
+ | `audit:read` | medium | Read the audit log |
788
+ | `desktop:window` | medium | Open windows in the desktop app |
789
+ | `hosts:read` | low | Read hosts the user can see |
790
+ | `db:own` | low | Your own tables |
791
+ | `kv:own` | low | Small key value state |
792
+ | `files:own` | low | Your own folder on disk |
793
+ | `secrets:own` | low | Your own encrypted secrets |
794
+ | `settings:read-core` | low | Read Termix server settings |
795
+ | `plugins:read` | low | See installed plugins |
796
+ | `ui:surface` | low | Add tabs, panels and settings |
774
797
 
775
798
  An update that adds a capability waits for an admin to approve it.
776
799
 
@@ -11,6 +11,9 @@ export const TMXPLUG_CONTENTS = [
11
11
  "README.md",
12
12
  "CHANGELOG.md",
13
13
  "icon.svg",
14
+ // Licence texts, so third-party notices travel with the plugin.
15
+ "LICENSE",
16
+ "NOTICE",
14
17
  ];
15
18
 
16
19
  const BLOCK = 512;
package/dist/backend.d.ts CHANGED
@@ -1223,6 +1223,18 @@ export interface PluginSecondFactor {
1223
1223
  /** Removes the user's enrolment. Called by the admin reset. */
1224
1224
  reset?: (userId: string) => Promise<void>;
1225
1225
  }
1226
+ export interface PluginUserTokenOptions {
1227
+ /** Why the token exists, kept in the audit log and on the session. */
1228
+ purpose: string;
1229
+ /** Seconds until it expires. Defaults to 300 and is capped at 900. */
1230
+ ttlSeconds?: number;
1231
+ }
1232
+ export interface PluginUserToken {
1233
+ /** Send as `Authorization: Bearer <token>` to core's own API. */
1234
+ token: string;
1235
+ /** ISO time the session ends. */
1236
+ expiresAt: string;
1237
+ }
1226
1238
  /**
1227
1239
  * Login methods, second factors and SSH auth types. Every register call needs
1228
1240
  * auth:provide and a matching contributes.auth entry, and is undone on
@@ -1278,6 +1290,22 @@ export interface PluginAuth {
1278
1290
  * delete a provider people still sign in with.
1279
1291
  */
1280
1292
  countLinkedUsers: (provider: string) => Promise<number>;
1293
+ /**
1294
+ * A short-lived session for an existing user, for calling core's own HTTP
1295
+ * API as them from work with no request behind it, like a scheduled task
1296
+ * driving the Termix CLI. The user's RBAC applies unchanged. Needs
1297
+ * users:impersonate, not auth:provide, and every call is audited.
1298
+ *
1299
+ * The session is marked with the plugin and purpose. It cannot change the
1300
+ * user's password, second factors, API keys or linked logins, so it can
1301
+ * never outlive itself.
1302
+ */
1303
+ issueUserToken: (userId: string, options: PluginUserTokenOptions) => Promise<PluginUserToken>;
1304
+ }
1305
+ /** What a plugin may learn about a user. Needs users:read. */
1306
+ export interface PluginUsers {
1307
+ /** The user's UI language, like "zh_CN", or null when they never chose one. */
1308
+ getLocale: (userId: string) => Promise<string | null>;
1281
1309
  }
1282
1310
  export interface PluginOpenIsolatedWindowRequest {
1283
1311
  /** http(s) only. Refused when it points anywhere else. */
@@ -1738,8 +1766,13 @@ export interface PluginContext {
1738
1766
  readonly hosts: PluginHosts;
1739
1767
  /** SSH through core's connect pipeline. Needs ssh:connect and credentials:use. */
1740
1768
  readonly ssh: PluginSsh;
1741
- /** Login methods, second factors and SSH auth types. Needs auth:provide. */
1769
+ /**
1770
+ * Login methods, second factors and SSH auth types. Needs auth:provide,
1771
+ * except issueUserToken, which needs users:impersonate.
1772
+ */
1742
1773
  readonly auth: PluginAuth;
1774
+ /** What a plugin may learn about a user. Needs users:read. */
1775
+ readonly users: PluginUsers;
1743
1776
  /** Opens Electron windows outside the main renderer. Needs desktop:window. */
1744
1777
  readonly desktop: PluginDesktop;
1745
1778
  /** Host protocol credentials and the user's saved SSH keys. See PluginCredentials. */
@@ -1763,8 +1796,9 @@ export interface PluginContext {
1763
1796
  * no request behind it. Always audited, and every core API inside still
1764
1797
  * applies that user's RBAC.
1765
1798
  *
1766
- * Needs users:impersonate. That and a `userId` option on services.get or
1767
- * secrets.getShared are the only ways a plugin can name a user.
1799
+ * Needs users:impersonate. That, auth.issueUserToken and a `userId` option
1800
+ * on services.get or secrets.getShared are the only ways a plugin can act
1801
+ * as a user it names.
1768
1802
  */
1769
1803
  asUser: <T>(userId: string, fn: () => Promise<T> | T) => Promise<T>;
1770
1804
  /** The acting user for the current call, when there is one. */
@@ -13,6 +13,12 @@ function isElectron() {
13
13
  const isElectronProp = win.electronAPI?.isElectron === true;
14
14
  return hasISElectron || hasElectronAPI || isElectronProp;
15
15
  }
16
+ var DEFAULT_LOCAL_BACKEND_PORT = 30001;
17
+ function localBackendPort() {
18
+ if (typeof window === "undefined") return DEFAULT_LOCAL_BACKEND_PORT;
19
+ const port = window.electronAPI?.backendPort;
20
+ return typeof port === "number" && Number.isInteger(port) && port > 0 && port < 65536 ? port : DEFAULT_LOCAL_BACKEND_PORT;
21
+ }
16
22
 
17
23
  // src/ui/lib/error-message.ts
18
24
  function getErrorMessage(error, fallback = "Unknown error") {
@@ -5038,7 +5044,7 @@ async function updateRole(roleId, roleData) {
5038
5044
  try {
5039
5045
  const response = await rbacApi.put(`/rbac/roles/${roleId}`, roleData);
5040
5046
  if (roleData.permissions) {
5041
- const { notifyPermissionsChanged } = await import("./use-permissions-2P2TP6GY.js");
5047
+ const { notifyPermissionsChanged } = await import("./use-permissions-GYCBY2AU.js");
5042
5048
  notifyPermissionsChanged();
5043
5049
  }
5044
5050
  return response.data;
@@ -5575,7 +5581,7 @@ function getApiUrl(path, defaultPort) {
5575
5581
  const devMode = isDev();
5576
5582
  const electronMode = isElectron();
5577
5583
  if (electronMode) {
5578
- return `http://localhost:${defaultPort}${path}`;
5584
+ return `http://localhost:${localBackendPort()}${path}`;
5579
5585
  } else if (devMode) {
5580
5586
  if (!define_import_meta_env_default.VITE_API_HOST) {
5581
5587
  return `/__termix_api/${defaultPort}${path}`;
@@ -5595,7 +5601,7 @@ function createRemoteOriginApiInstance(path) {
5595
5601
  });
5596
5602
  instance.interceptors.request.use(
5597
5603
  async (config) => {
5598
- const { getLinkedSession: getLinkedSession2 } = await import("./linked-server-QAKKDBFM.js");
5604
+ const { getLinkedSession: getLinkedSession2 } = await import("./linked-server-UCW74T2V.js");
5599
5605
  const linked = await getLinkedSession2();
5600
5606
  config.baseURL = linked ? `${linked.serverUrl}${path}` : "http://no-server-configured";
5601
5607
  if (config.headers.set) {
@@ -5958,6 +5964,7 @@ export {
5958
5964
  getPluginRecord,
5959
5965
  knownPluginIds,
5960
5966
  isElectron,
5967
+ localBackendPort,
5961
5968
  createRegistry,
5962
5969
  byOrderThenId,
5963
5970
  manifestDeclares,
@@ -21,7 +21,7 @@ var inFlight = null;
21
21
  function load() {
22
22
  if (cache) return Promise.resolve(cache);
23
23
  if (inFlight) return inFlight;
24
- inFlight = import("./rbac-api-5267EMX7.js").then(({ getMyPermissions }) => getMyPermissions()).then((result) => {
24
+ inFlight = import("./rbac-api-6UYMFIFV.js").then(({ getMyPermissions }) => getMyPermissions()).then((result) => {
25
25
  cache = {
26
26
  permissions: result.permissions ?? [],
27
27
  isAdmin: !!result.isAdmin
@@ -2,7 +2,7 @@ const __termixNoGlob = () => ({});
2
2
  import {
3
3
  hasPermission,
4
4
  usePermissions
5
- } from "./chunk-DIOJ3KW2.js";
5
+ } from "./chunk-LSWB3ZZV.js";
6
6
  import {
7
7
  PRESETS,
8
8
  SYNC_CHANGED_EVENT,
@@ -25,6 +25,7 @@ import {
25
25
  handleApiError,
26
26
  isElectron,
27
27
  knownPluginIds,
28
+ localBackendPort,
28
29
  logActivity,
29
30
  manifestDeclares,
30
31
  markUserAuthenticated,
@@ -39,7 +40,7 @@ import {
39
40
  usePluginStore,
40
41
  useTabTypes,
41
42
  websocketAuthProtocols
42
- } from "./chunk-NZG6HNQH.js";
43
+ } from "./chunk-7RRTYD6B.js";
43
44
  import {
44
45
  __export,
45
46
  define_import_meta_env_default
@@ -49078,6 +49079,17 @@ function scopeHostCallback(fn, pluginId) {
49078
49079
 
49079
49080
  // src/ui/plugin-host/scope.tsx
49080
49081
  import { useTranslation } from "react-i18next";
49082
+
49083
+ // src/ui/lib/plugin-i18n.ts
49084
+ function pluginKey(pluginId, key) {
49085
+ if (!key) return key;
49086
+ return key.includes(":") ? key : `${pluginId}:${key}`;
49087
+ }
49088
+ function pluginName(t, plugin) {
49089
+ return t(pluginKey(plugin.id, "plugin.name"), { defaultValue: plugin.name });
49090
+ }
49091
+
49092
+ // src/ui/plugin-host/scope.tsx
49081
49093
  import { jsx as jsx29, jsxs as jsxs7 } from "react/jsx-runtime";
49082
49094
  var PluginScopeContext = createContext6(null);
49083
49095
  function usePluginScope() {
@@ -49088,7 +49100,8 @@ function CrashNotice({
49088
49100
  onRetry
49089
49101
  }) {
49090
49102
  const { t } = useTranslation();
49091
- const name = getPluginRecord(pluginId)?.summary.name ?? pluginId;
49103
+ const summary = getPluginRecord(pluginId)?.summary;
49104
+ const name = summary ? pluginName(t, summary) : pluginId;
49092
49105
  return /* @__PURE__ */ jsxs7("div", { className: "flex flex-col items-center justify-center gap-2 p-4 text-center text-sm text-muted-foreground", children: [
49093
49106
  /* @__PURE__ */ jsx29("span", { children: t("plugins.runtime.crashed", { name }) }),
49094
49107
  /* @__PURE__ */ jsx29(
@@ -53579,7 +53592,7 @@ async function pluginWsUrl(pluginId, path2, options2 = {}) {
53579
53592
  if (isElectron()) {
53580
53593
  return buildOriginWsUrl({
53581
53594
  origin: options2.origin ?? "local",
53582
- localPort: BACKEND_PORT,
53595
+ localPort: localBackendPort(),
53583
53596
  localPath: route,
53584
53597
  remotePath: route
53585
53598
  });
@@ -54432,12 +54445,6 @@ function onRemoteServerChange(listener) {
54432
54445
  return () => window.removeEventListener(SYNC_CHANGED_EVENT, handler);
54433
54446
  }
54434
54447
 
54435
- // src/ui/lib/plugin-i18n.ts
54436
- function pluginKey(pluginId, key) {
54437
- if (!key) return key;
54438
- return key.includes(":") ? key : `${pluginId}:${key}`;
54439
- }
54440
-
54441
54448
  // src/ui/plugin-host/app.ts
54442
54449
  var ONBOARDING_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
54443
54450
  function createDocsApi(manifest) {
@@ -54973,7 +54980,7 @@ function PluginViewPlaceholder({
54973
54980
  usePluginStore();
54974
54981
  const { has } = usePermissions();
54975
54982
  const { status, owner } = unregisteredViewStatus(kind, viewId);
54976
- const name = owner?.summary.name ?? viewId;
54983
+ const name = owner ? pluginName(t, owner.summary) : viewId;
54977
54984
  if (status === "loading") {
54978
54985
  return /* @__PURE__ */ jsx39(
54979
54986
  "div",
@@ -55101,6 +55108,8 @@ export {
55101
55108
  setShellHosts,
55102
55109
  notifyShellReady,
55103
55110
  resetShellBridge,
55111
+ pluginKey,
55112
+ pluginName,
55104
55113
  usePluginScope,
55105
55114
  DocsLink,
55106
55115
  PANEL,
@@ -55177,7 +55186,6 @@ export {
55177
55186
  installPluginHostBridge,
55178
55187
  setRemoteServerUrlForTesting,
55179
55188
  remoteServerUrl,
55180
- pluginKey,
55181
55189
  createPluginApp,
55182
55190
  ActionSlot,
55183
55191
  ComponentSlot,
@@ -3,7 +3,7 @@ import {
3
3
  SYNC_CHANGED_EVENT,
4
4
  getLinkedSession,
5
5
  notifySyncChanged
6
- } from "./chunk-NZG6HNQH.js";
6
+ } from "./chunk-7RRTYD6B.js";
7
7
  import "./chunk-YC2MAKSP.js";
8
8
  export {
9
9
  SYNC_CHANGED_EVENT,
@@ -23,7 +23,7 @@ import {
23
23
  shareHost,
24
24
  updateHostAccess,
25
25
  updateRole
26
- } from "./chunk-NZG6HNQH.js";
26
+ } from "./chunk-7RRTYD6B.js";
27
27
  import "./chunk-YC2MAKSP.js";
28
28
  export {
29
29
  assignRoleToUser,
@@ -40,16 +40,16 @@ import {
40
40
  setShellCallbacks,
41
41
  setShellHosts,
42
42
  setShellLayoutProvider
43
- } from "./chunk-G346FUE4.js";
43
+ } from "./chunk-RLQBKJRK.js";
44
44
  import {
45
45
  setPermissionsForTesting
46
- } from "./chunk-DIOJ3KW2.js";
46
+ } from "./chunk-LSWB3ZZV.js";
47
47
  import {
48
48
  getTabType,
49
49
  listTabTypes,
50
50
  setFrontendState,
51
51
  setPluginSummaries
52
- } from "./chunk-NZG6HNQH.js";
52
+ } from "./chunk-7RRTYD6B.js";
53
53
  import "./chunk-YC2MAKSP.js";
54
54
 
55
55
  // src/ui/plugin-host/testing-host.tsx
@@ -14,11 +14,10 @@ interface OriginResolvableHost {
14
14
  * the desktop-wide default.
15
15
  *
16
16
  * `defaultRemote` makes a host left on Default resolve to "remote" instead of
17
- * following the desktop-wide setting. Remote desktop passes it: it needs a
18
- * guacd, which the desktop does not ship, so originating it here only works
19
- * once the user has pointed Termix at one of their own. Making that opt-in
20
- * per host keeps an upgrade from moving working connections onto a guacd
21
- * that isn't there -- see Termix-SSH/Support#1240.
17
+ * following the desktop-wide setting. Remote desktop passes it when this
18
+ * desktop is set to use an external guacd, so a host is not moved onto a
19
+ * guacd that isn't there (see Termix-SSH/Support#1240). With the built-in
20
+ * engine it runs locally like SSH.
22
21
  */
23
22
  export declare function resolveConnectionOrigin(host: OriginResolvableHost, options?: {
24
23
  defaultRemote?: boolean;
@@ -1 +1,6 @@
1
1
  export declare function isElectron(): boolean;
2
+ /**
3
+ * The port of the desktop app's embedded backend. It moves off 30001 when
4
+ * another program holds that port.
5
+ */
6
+ export declare function localBackendPort(): number;
@@ -1,3 +1,4 @@
1
+ import type { TFunction } from "i18next";
1
2
  /**
2
3
  * Where a plugin's i18n keys resolve from.
3
4
  *
@@ -9,3 +10,17 @@
9
10
  */
10
11
  export declare function pluginKey(pluginId: string, key: string): string;
11
12
  export declare function pluginKey(pluginId: string, key: string | undefined): string | undefined;
13
+ /**
14
+ * A plugin's name in the UI language: "plugin.name" from its own locales,
15
+ * else the manifest's. A plugin whose strings are not loaded, like one only
16
+ * listed in a registry, shows the manifest name.
17
+ */
18
+ export declare function pluginName(t: TFunction, plugin: {
19
+ id: string;
20
+ name: string;
21
+ }): string;
22
+ /** Same as pluginName, for "plugin.description". */
23
+ export declare function pluginDescription(t: TFunction, plugin: {
24
+ id: string;
25
+ description?: string | null;
26
+ }): string;
@@ -34,7 +34,7 @@ export interface HostEditorSectionDef {
34
34
  pluginId?: string;
35
35
  group: "top" | "ssh";
36
36
  labelKey: string;
37
- /** Already-translated label, used instead of labelKey when set. */
37
+ /** Shown when labelKey has no translation. */
38
38
  label?: string;
39
39
  icon?: ComponentType<{
40
40
  className?: string;
@@ -59,7 +59,13 @@ export declare function isSshGroupTab(id: string): boolean;
59
59
  * The main strip. With `protocols`, registered tabs are filtered by their
60
60
  * `visible`; without, every registered top tab is listed.
61
61
  */
62
- export declare function makeHostTabs(t: (key: string) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean): HostTab[];
63
- export declare function makeHostSshSubTabs(t: (key: string) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean): HostTab[];
64
- export declare function makeCredentialTabs(t: (key: string) => string): CredentialTab[];
62
+ export declare function makeHostTabs(t: (key: string, options?: {
63
+ defaultValue?: string;
64
+ }) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean): HostTab[];
65
+ export declare function makeHostSshSubTabs(t: (key: string, options?: {
66
+ defaultValue?: string;
67
+ }) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean): HostTab[];
68
+ export declare function makeCredentialTabs(t: (key: string, options?: {
69
+ defaultValue?: string;
70
+ }) => string): CredentialTab[];
65
71
  export { TabStrip } from "../components/tab-strip";
package/dist/host/ui.js CHANGED
@@ -120,6 +120,7 @@ import {
120
120
  isRedactedSecret,
121
121
  normalizeLanguageCode,
122
122
  pluginKey,
123
+ pluginName,
123
124
  pluginWsUrl,
124
125
  registerHostEditorSection,
125
126
  rememberLoginLanguage,
@@ -139,8 +140,8 @@ import {
139
140
  useSurfaceKind,
140
141
  useTheme,
141
142
  verifySecondFactor
142
- } from "./chunk-G346FUE4.js";
143
- import "./chunk-DIOJ3KW2.js";
143
+ } from "./chunk-RLQBKJRK.js";
144
+ import "./chunk-LSWB3ZZV.js";
144
145
  import {
145
146
  TabProvider,
146
147
  buildOriginWsUrl,
@@ -179,7 +180,7 @@ import {
179
180
  useTabs,
180
181
  useTabsSafe,
181
182
  verifyPasswordResetCode
182
- } from "./chunk-NZG6HNQH.js";
183
+ } from "./chunk-7RRTYD6B.js";
183
184
  import "./chunk-YC2MAKSP.js";
184
185
 
185
186
  // src/ui/components/alert.tsx
@@ -3852,11 +3853,13 @@ function HostPluginSection({
3852
3853
  return /* @__PURE__ */ jsxs24(
3853
3854
  SectionCard,
3854
3855
  {
3855
- title: plugin.name,
3856
+ title: pluginName(t2, plugin),
3856
3857
  icon: /* @__PURE__ */ jsx36(PluginIcon, { name: plugin.icon, className: "size-3.5" }),
3857
3858
  action: /* @__PURE__ */ jsx36(DocsLink, { href: plugin.docs }),
3858
3859
  children: [
3859
- !running && /* @__PURE__ */ jsx36("div", { className: "my-3 border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning", children: t2("settings.featureUnavailable", { name: plugin.name }) }),
3860
+ !running && /* @__PURE__ */ jsx36("div", { className: "my-3 border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning", children: t2("settings.featureUnavailable", {
3861
+ name: pluginName(t2, plugin)
3862
+ }) }),
3860
3863
  enableKey && /* @__PURE__ */ jsx36(
3861
3864
  SettingsFieldRow,
3862
3865
  {
@@ -4164,7 +4167,7 @@ function syncHostFeatureTabs(plugins) {
4164
4167
  pluginId: plugin.id,
4165
4168
  group: placement?.group ?? host.editorGroup ?? "top",
4166
4169
  order: placement?.order ?? host.editorOrder ?? 100,
4167
- labelKey: plugin.name,
4170
+ labelKey: pluginKey(plugin.id, "plugin.name"),
4168
4171
  label: plugin.name,
4169
4172
  // Nothing but the plugin's host settings, so it works for defaults too.
4170
4173
  defaults: ownSections.has(plugin.id) ? "only" : true,
@@ -7,7 +7,7 @@ import {
7
7
  resetPermissionsCache,
8
8
  setPermissionsForTesting,
9
9
  usePermissions
10
- } from "./chunk-DIOJ3KW2.js";
10
+ } from "./chunk-LSWB3ZZV.js";
11
11
  import "./chunk-YC2MAKSP.js";
12
12
  export {
13
13
  hasPermission,
@@ -19,7 +19,7 @@ export { ENV_NAME_PATTERN, MAX_ENV_DESCRIPTION_LENGTH, pluginDocsPage, pluginDoc
19
19
  * "^1.2" needs 1.2 or later). engine.termix is checked against the core
20
20
  * version by the server.
21
21
  */
22
- export declare const PLUGIN_API_VERSION = "1.2.0";
22
+ export declare const PLUGIN_API_VERSION = "1.3.0";
23
23
  /** The API major, the default engine.api for a new plugin. */
24
24
  export declare const SUPPORTED_PLUGIN_API_VERSION: string;
25
25
  /** Whether this build's plugin API satisfies a manifest's engine.api. */
package/dist/manifest.js CHANGED
@@ -21,7 +21,7 @@ export { ENV_NAME_PATTERN, MAX_ENV_DESCRIPTION_LENGTH, pluginDocsPage, pluginDoc
21
21
  * "^1.2" needs 1.2 or later). engine.termix is checked against the core
22
22
  * version by the server.
23
23
  */
24
- export const PLUGIN_API_VERSION = "1.2.0";
24
+ export const PLUGIN_API_VERSION = "1.3.0";
25
25
  /** The API major, the default engine.api for a new plugin. */
26
26
  export const SUPPORTED_PLUGIN_API_VERSION = String(semver.major(PLUGIN_API_VERSION));
27
27
  /** Whether this build's plugin API satisfies a manifest's engine.api. */
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) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@termix-ssh/plugin-sdk",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
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": {