@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.12
4
+
5
+ Plugin API 1.4.0.
6
+
7
+ ### Added
8
+
9
+ - `contributes.layouts` and `app.registerLayout()` for a whole-app layout, with the `ui:layout` capability. Needs `engine.api` 1.4
10
+ - `contributes.themes` for CSS-only themes, with the `ui:theme` capability. `termix-plugin validate` checks theme CSS and `pack` ships it
11
+ - `useShell()` gives a layout the live shell: tabs, splits, hosts, docks, the command palette and the registries. The shell's home tab is left out until some plugin registers a `dashboard` tab type
12
+ - `TabStage` and `ShellConfirmCapture` in `@termix-ssh/plugin-sdk/ui`, for a layout to place tabs
13
+ - `validateThemeCss()` in `@termix-ssh/plugin-sdk/manifest`
14
+ - `@termix-ssh/plugin-sdk/ui` also exports what a whole layout is built from: the API client, shared types, hooks, `useShellController()` and `APP_VERSION`. These reach into core, so they can change with a core release
15
+ - `@termix-ssh/plugin-sdk/testing-host` exports the registries and permission seam a layout's own tests fill directly
16
+ - A plugin that only holds themes needs no source: `termix-plugin build` writes an empty backend and `validate` does not ask for a frontend
17
+ - `termix-plugin validate` flags `ui:layout` or `ui:theme` with nothing contributed, and a layout plugin with no frontend
18
+
19
+ ### Fixed
20
+
21
+ - The manifest JSON schema lists the `notify:hub` and `plugins:read` capabilities
22
+ - Plugin tests and type checks work with the SDK linked from a local Termix checkout
23
+
24
+ ## 1.0.11
25
+
26
+ Plugin API 1.3.0.
27
+
28
+ ### Added
29
+
30
+ - `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
31
+ - `ctx.users.getLocale()` reads a user's UI language. Needs `users:read` and `engine.api` 1.3
32
+
3
33
  ## 1.0.10
4
34
 
5
35
  ### Changed
package/PLUGIN-GUIDE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Termix plugin guide
2
2
 
3
- SDK 1.0.10 | Plugin API 1.2.0
3
+ SDK 1.0.12 | Plugin API 1.4.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.
@@ -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
@@ -175,7 +175,7 @@ Unknown fields are refused at every level, so a typo fails validation. The schem
175
175
 
176
176
  `contributes` holds `tabs`, `panels`, `dashboardCards`, `actions`, `actionSlots`, `permissions`,
177
177
  `settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`,
178
- `uiPresets` and `uses`. Frontend `register*` calls need their ids declared here first.
178
+ `uiPresets`, `uses`, `layouts` and `themes`. Frontend `register*` calls need their ids declared here first.
179
179
 
180
180
  Capabilities and permissions are different things. A capability (`hosts:read`, with a colon) is what your
181
181
  code may reach. A permission (`hello.use`, with a dot) is what a user may do.
@@ -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
@@ -469,6 +478,7 @@ your plugin's error boundary and Suspense.
469
478
  | `registerComponent`, `registerExtension` | Offer a component by id, add to an extension point |
470
479
  | `registerOnboardingStep` | A first run setup step |
471
480
  | `registerLoginMethod`, `registerSecondFactorUI`, `registerSshAuthEditor` | Sign in UI |
481
+ | `registerLayout` | A whole-app layout (`contributes.layouts`, API 1.4) |
472
482
  | `api` | Axios client for your `/plugin-api/<id>/` routes, auth included |
473
483
  | `fetch(path)`, `wsUrl(path)` | Raw fetch for streams, socket URL and auth |
474
484
  | `docs`, `tabs`, `confirm`, `listHosts`, `getHost`, `t`, `hasPermission`, `desktop` | Helpers |
@@ -476,7 +486,7 @@ your plugin's error boundary and Suspense.
476
486
 
477
487
  Hooks from `@termix-ssh/plugin-sdk/frontend`: `useTranslation`, `usePermission`, `useSettings`, `useHost`,
478
488
  `useHosts`, `usePluginApi`, `useCurrentUser`, `useTheme`, `useToast`, `useDocsUrl`, `useTabs`,
479
- `useSlotContributions`, `useSshAuthTypes`, `useExtensions`.
489
+ `useSlotContributions`, `useSshAuthTypes`, `useExtensions`, `useShell`.
480
490
 
481
491
  Shared from Termix and never bundled: `react`, `react-dom`, `i18next`, `react-i18next`, `sonner` and the
482
492
  SDK's `frontend` and `ui`. Anything else, like `lucide-react`, is bundled. A host your code gets only carries
@@ -484,9 +494,98 @@ your own host settings, under `host.pluginSettings[<id>]`.
484
494
 
485
495
  Slots you can fill include `terminal.toolbar`, `terminal.toolbarStatus`, `terminal.sidePanel`,
486
496
  `terminal.overlay`, `dashboard.counters`, `dashboard.hostMetrics`, `admin.userTabs`, `hosts.importMenu`,
487
- `hosts.panel`, `credentials.badges`, `shell.overlay` and `tab.menu`. See
497
+ `hosts.panel`, `credentials.badges`, `shell.overlay`, `shell.rightEdge` and `tab.menu`. See
488
498
  https://docs.termix.site/develop/more-extension-points.
489
499
 
500
+ ## Layouts and themes
501
+
502
+ A plugin can replace the whole app layout or add themes (API 1.4). Users pick both in Settings, Appearance;
503
+ admins set the default for everyone.
504
+
505
+ A layout is declared in `contributes.layouts` (`id`, `label`, optional `description` and `defaultTheme`) and
506
+ needs the `ui:layout` capability, which admins see as high risk because a layout draws every screen. Register
507
+ it in `activate`:
508
+
509
+ ```tsx
510
+ import { useShell, type TermixApp } from "@termix-ssh/plugin-sdk/frontend";
511
+ import {
512
+ ShellConfirmCapture,
513
+ SurfaceScope,
514
+ TabStage,
515
+ } from "@termix-ssh/plugin-sdk/ui";
516
+
517
+ function MyLayout() {
518
+ const shell = useShell();
519
+ if (!shell) return null;
520
+ return (
521
+ <div className="flex h-dvh w-screen flex-col bg-background">
522
+ <header>
523
+ {/* tabs from shell.tabs.topLevel, shell.palette.open() and so on */}
524
+ </header>
525
+ <SurfaceScope kind="tab">
526
+ <ShellConfirmCapture />
527
+ <TabStage />
528
+ </SurfaceScope>
529
+ </div>
530
+ );
531
+ }
532
+
533
+ export function activate(app: TermixApp) {
534
+ app.registerLayout({
535
+ id: "my-layout",
536
+ label: "layout.name",
537
+ component: MyLayout,
538
+ });
539
+ }
540
+ ```
541
+
542
+ - `useShell()` is the live shell with nothing drawn: tabs, splits, hosts, the left and right docks, the
543
+ command palette, the registries as hooks and the user. Core keeps all the state, so switching layouts keeps
544
+ every session.
545
+ - `TabStage` is where tabs show. Place it anywhere; tabs are never remounted when it moves, so a terminal
546
+ keeps its connection. Wrap it in `SurfaceScope kind="tab"` with a `ShellConfirmCapture` inside, so
547
+ close questions and inline views show over the tab area. Overlays and onboarding stay in core.
548
+ - The layout draws the command palette. Core keeps whether it is open (`palette.isOpen`, Ctrl+K calls
549
+ `palette.open()`) and what is in it (`usePaletteEntries`, `usePaletteGroups`, rail items, hosts).
550
+ - The shell always keeps a home tab with the id and type `dashboard`. `useShell()` leaves it out until some
551
+ plugin registers a `dashboard` tab type, so a layout without a home screen has no empty tab to draw.
552
+ - Settings, Manage and the host editor belong to Classic Layout. A layout without them should still render
553
+ `LookSettings` somewhere so people can switch back. `tabs.openSingleton("plugins")` opens core's own
554
+ Plugins tab in any layout. Switching layouts closes the tabs the old layout's plugin declared.
555
+ - `mobileComponent` is used on phones and narrow windows. The desktop app always gets `component`.
556
+ - Only the chosen layout's plugin is loaded. If it fails to load or crashes twice, Termix shows a repair
557
+ screen where people can switch, reset, update, turn it off or (for a bundled plugin) restore the copy that
558
+ shipped. `?safe-layout=1` opens it any time.
559
+ - Termix has no layout of its own. The default is the bundled Classic Layout plugin
560
+ (https://github.com/Termix-SSH/Plugin-Classic-Layout), which is also a full example. It owns the rail,
561
+ sidebar, tab bar, docks, Dashboard, Manage, Settings and command palette, registers the `dashboard`,
562
+ `host-manager` and `settings` tab types, and offers the host list and dashboard as the components
563
+ `classic-layout.hostTree` and `classic-layout.dashboard` for other plugins to place with `PluginComponent`.
564
+ It reads the shell with `useShellController()` from `@termix-ssh/plugin-sdk/ui`, which exposes more than
565
+ `useShell()` and may change with Termix. A third party layout should start from `useShell()`.
566
+ - Example Top Bar Layout (https://github.com/Termix-SSH/Plugin-Example-Topbar-Layout) is a small third party
567
+ style layout built only on `useShell()`, `TabStage` and `@termix-ssh/plugin-sdk/ui`, with a phone version.
568
+
569
+ A theme is CSS only. Declare it in `contributes.themes` (`id`, `label`, `mode` light or dark, `css` path,
570
+ optional `swatch` colors for the picker) with the `ui:theme` capability. The id becomes a class on `<html>`,
571
+ so the file may only hold rules whose selectors start with `.<id>`, `:root.<id>` or `html.<id>`, optionally
572
+ inside `@media` or `@supports`. `@import`, `url()` to another host and other at-rules are refused by
573
+ `termix-plugin validate` and by the server. Set the same tokens core's themes set (`--background`,
574
+ `--foreground`, `--card`, `--border`, `--muted` and the rest).
575
+
576
+ A plugin that only holds themes needs no code. Leave out `src/`: `termix-plugin build` writes an empty backend and
577
+ `validate` does not ask for a frontend. Keep the CSS in `themes/` and a test that checks it with
578
+ `validateThemeCss()`. Notes:
579
+
580
+ - A theme with the id of a built-in (`light` or `dark`) replaces core's basic copy while the plugin runs.
581
+ - The first theme a bundled default-look plugin lists is the default for new installs.
582
+ - Themes show in the picker in the order core lists its own, then the plugin's order.
583
+ - A theme someone saved that no longer exists shows as System. It comes back when the plugin does.
584
+ - Accent color, UI font, font size and density are not part of a theme. Core applies them on top.
585
+ - Bundled Classic Themes (https://github.com/Termix-SSH/Plugin-Classic-Themes) is a full example, and
586
+ Example Theme (https://github.com/Termix-SSH/Plugin-Example-Theme) a small one with one light and one dark
587
+ theme.
588
+
490
589
  ## Looking native
491
590
 
492
591
  Build UI from `@termix-ssh/plugin-sdk/ui` so it matches Termix and follows the theme. The full list of
@@ -538,6 +637,20 @@ Plurals use `_one` and `_other` with `t("hosts", { count })`. Other languages go
538
637
  `locales/translated/<code>.json` (like `de_DE.json`) with the same keys. Keep `{{placeholders}}` as they are.
539
638
  Untranslated strings show in English.
540
639
 
640
+ Termix shows your plugin's name and description in the plugin manager, the setup picker and settings.
641
+ Put them in `locales/en.json` as `plugin.name` and `plugin.description`, with the same text as `name` and
642
+ `description` in the manifest, so they get translated too. Without them, the English manifest text shows in
643
+ every language.
644
+
645
+ ```json
646
+ {
647
+ "plugin": {
648
+ "name": "Host Metrics",
649
+ "description": "Live CPU, memory, disk, network and GPU stats."
650
+ }
651
+ }
652
+ ```
653
+
541
654
  ## Docs
542
655
 
543
656
  `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 +853,39 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
740
853
 
741
854
  ## Capabilities
742
855
 
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 |
856
+ | Capability | Risk | What it allows |
857
+ | -------------------- | -------- | ------------------------------------------------------- |
858
+ | `credentials:read` | critical | Read stored secrets for hosts the user can reach |
859
+ | `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
860
+ | `ssh:connect` | high | Run any command on hosts, with the user's access |
861
+ | `process:spawn` | high | Run programs on the Termix server |
862
+ | `users:write` | high | Add users and change their roles |
863
+ | `users:impersonate` | high | Act as another user, or get a session for them, audited |
864
+ | `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
865
+ | `auth:provide` | high | Add login methods, second factors, SSH auth types |
866
+ | `device:serial` | high | Open serial and USB ports on the server |
867
+ | `notify:hub` | high | Receive every alert (the alerts hub only) |
868
+ | `ui:layout` | high | Replace the whole app layout |
869
+ | `hosts:write` | medium | Add and edit hosts |
870
+ | `credentials:use` | medium | Connect with saved credentials without seeing them |
871
+ | `credentials:write` | medium | Save new credentials |
872
+ | `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
873
+ | `network:serve` | medium | HTTP routes and sockets |
874
+ | `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN |
875
+ | `users:read` | medium | Usernames, roles and UI language |
876
+ | `events:core` | medium | Listen to core events |
877
+ | `notify:send` | medium | Send alerts |
878
+ | `audit:read` | medium | Read the audit log |
879
+ | `desktop:window` | medium | Open windows in the desktop app |
880
+ | `hosts:read` | low | Read hosts the user can see |
881
+ | `db:own` | low | Your own tables |
882
+ | `kv:own` | low | Small key value state |
883
+ | `files:own` | low | Your own folder on disk |
884
+ | `secrets:own` | low | Your own encrypted secrets |
885
+ | `settings:read-core` | low | Read Termix server settings |
886
+ | `plugins:read` | low | See installed plugins |
887
+ | `ui:surface` | low | Add tabs, panels and settings |
888
+ | `ui:theme` | low | Add color themes, CSS only |
774
889
 
775
890
  An update that adds a capability waits for an admin to approve it.
776
891
 
@@ -5,7 +5,12 @@ import { fileURLToPath } from "node:url";
5
5
  import * as esbuild from "esbuild";
6
6
  import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
7
7
  import { staticUrlImports } from "../lib/static-url-imports.mjs";
8
- import { readManifest, resolveEntry, copyDir } from "../lib/plugin-dir.mjs";
8
+ import {
9
+ readManifest,
10
+ resolveEntry,
11
+ copyDir,
12
+ isThemeOnly,
13
+ } from "../lib/plugin-dir.mjs";
9
14
  import { openapi } from "./openapi.mjs";
10
15
  import { splitByCoreClasses } from "../lib/css-classes.mjs";
11
16
 
@@ -232,32 +237,41 @@ export async function build({ cwd }) {
232
237
  fs.mkdirSync(outDir, { recursive: true });
233
238
 
234
239
  const backendEntry = resolveEntry(cwd, BACKEND_ENTRIES);
235
- if (!backendEntry) {
240
+ if (!backendEntry && !isThemeOnly(manifest)) {
236
241
  throw new Error(
237
242
  `${pluginId}: no backend entry (looked for ${BACKEND_ENTRIES.join(", ")})`,
238
243
  );
239
244
  }
240
245
 
241
- await esbuild.build({
242
- entryPoints: [backendEntry],
243
- outfile: path.join(outDir, "backend.js"),
244
- bundle: true,
245
- format: "esm",
246
- platform: "node",
247
- target: "node22",
248
- sourcemap: true,
249
- logLevel: "warning",
250
- // A native dependency's compiled .node binary is resolved by the
251
- // package's own relative paths, which break once esbuild inlines its JS
252
- // elsewhere. Declaring it in nativeDependencies keeps it a real
253
- // node_modules import instead, resolved at runtime like a host-provided
254
- // package.
255
- external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])],
256
- loader: BINARY_LOADERS,
257
- // Bundled CJS deps still call require() for Node builtins at runtime.
258
- // ESM has no ambient require, so give esbuild's require shim a real one.
259
- banner: { js: REQUIRE_BANNER },
260
- });
246
+ if (backendEntry) {
247
+ await esbuild.build({
248
+ entryPoints: [backendEntry],
249
+ outfile: path.join(outDir, "backend.js"),
250
+ bundle: true,
251
+ format: "esm",
252
+ platform: "node",
253
+ target: "node22",
254
+ sourcemap: true,
255
+ logLevel: "warning",
256
+ // A native dependency's compiled .node binary is resolved by the
257
+ // package's own relative paths, which break once esbuild inlines its JS
258
+ // elsewhere. Declaring it in nativeDependencies keeps it a real
259
+ // node_modules import instead, resolved at runtime like a host-provided
260
+ // package.
261
+ external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])],
262
+ loader: BINARY_LOADERS,
263
+ // Bundled CJS deps still call require() for Node builtins at runtime.
264
+ // ESM has no ambient require, so give esbuild's require shim a real one.
265
+ banner: { js: REQUIRE_BANNER },
266
+ });
267
+ } else {
268
+ // Core loads every plugin through a backend entry, so a theme only
269
+ // plugin gets an empty one.
270
+ fs.writeFileSync(
271
+ path.join(outDir, "backend.js"),
272
+ "export function activate() {}\n",
273
+ );
274
+ }
261
275
 
262
276
  for (const worker of readWorkerEntries(cwd, pluginId)) {
263
277
  await esbuild.build({
@@ -1,7 +1,12 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { readManifest } from "../lib/plugin-dir.mjs";
4
- import { collectFiles, createTmxplug } from "../lib/tmxplug.mjs";
4
+ import {
5
+ collectFiles,
6
+ createTmxplug,
7
+ themeFiles,
8
+ TMXPLUG_CONTENTS,
9
+ } from "../lib/tmxplug.mjs";
5
10
  import { sha256 } from "../lib/signing.mjs";
6
11
  import { validate } from "./validate.mjs";
7
12
 
@@ -32,7 +37,12 @@ export async function pack({ cwd, args = [] }) {
32
37
  fs.mkdirSync(outDir, { recursive: true });
33
38
  const outfile = path.join(outDir, `${pluginId}-${version}.tmxplug`);
34
39
 
35
- const archive = createTmxplug(cwd, collectFiles(cwd));
40
+ const archive = createTmxplug(
41
+ cwd,
42
+ collectFiles(cwd, [
43
+ ...new Set([...TMXPLUG_CONTENTS, ...themeFiles(manifest)]),
44
+ ]),
45
+ );
36
46
  fs.writeFileSync(outfile, archive);
37
47
 
38
48
  console.log(`packed ${path.basename(outfile)}`);
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import { readManifest } from "../lib/plugin-dir.mjs";
4
+ import { readManifest, isThemeOnly } from "../lib/plugin-dir.mjs";
5
5
 
6
6
  /**
7
7
  * The manifest rules live in src/manifest.ts, which the server uses too, so
@@ -17,7 +17,7 @@ export async function loadSdkModule(name) {
17
17
 
18
18
  export async function validate({ cwd }) {
19
19
  const raw = readManifest(cwd);
20
- const { parseManifest } = await loadSdkModule("manifest");
20
+ const { parseManifest, validateThemeCss } = await loadSdkModule("manifest");
21
21
  const { errors } = parseManifest(raw);
22
22
 
23
23
  const problems = [...errors];
@@ -25,7 +25,8 @@ export async function validate({ cwd }) {
25
25
  // The manifest names files. They have to be there.
26
26
  const referenced = [
27
27
  raw.backend ?? "dist/backend.js",
28
- raw.frontend ?? "dist/frontend.js",
28
+ // A theme only plugin has no frontend code.
29
+ ...(isThemeOnly(raw) ? [] : [raw.frontend ?? "dist/frontend.js"]),
29
30
  raw.locales ?? "locales",
30
31
  ];
31
32
  for (const rel of referenced) {
@@ -34,6 +35,8 @@ export async function validate({ cwd }) {
34
35
  }
35
36
  }
36
37
 
38
+ problems.push(...validateThemeFiles(cwd, raw, validateThemeCss));
39
+ problems.push(...validateLookCapabilities(cwd, raw));
37
40
  problems.push(
38
41
  ...(await validateMigrations(cwd, raw.id ?? path.basename(cwd))),
39
42
  );
@@ -53,6 +56,63 @@ export async function validate({ cwd }) {
53
56
  console.log(`ok ${raw.id ?? path.basename(cwd)}`);
54
57
  }
55
58
 
59
+ /** Every declared theme file exists and holds only CSS a theme may hold. */
60
+ export function validateThemeFiles(cwd, raw, validateThemeCss) {
61
+ const problems = [];
62
+ const themes = raw?.contributes?.themes;
63
+ if (!Array.isArray(themes)) return problems;
64
+ for (const theme of themes) {
65
+ if (typeof theme?.css !== "string" || typeof theme?.id !== "string") {
66
+ continue;
67
+ }
68
+ const file = path.join(cwd, theme.css);
69
+ if (!fs.existsSync(file)) {
70
+ problems.push(`theme ${theme.id}: ${theme.css} does not exist`);
71
+ continue;
72
+ }
73
+ for (const error of validateThemeCss(
74
+ fs.readFileSync(file, "utf8"),
75
+ theme.id,
76
+ )) {
77
+ problems.push(`theme ${theme.id}: ${error}`);
78
+ }
79
+ }
80
+ return problems;
81
+ }
82
+
83
+ /**
84
+ * ui:layout and ui:theme must match what the manifest contributes, both
85
+ * ways. The manifest check already flags a contribution without its
86
+ * capability; this flags a capability with nothing behind it, and a layout
87
+ * with no frontend to register it.
88
+ */
89
+ export function validateLookCapabilities(cwd, raw) {
90
+ const problems = [];
91
+ const capabilities = Array.isArray(raw?.capabilities) ? raw.capabilities : [];
92
+ const layouts = raw?.contributes?.layouts;
93
+ const themes = raw?.contributes?.themes;
94
+ const hasLayouts = Array.isArray(layouts) && layouts.length > 0;
95
+ const hasThemes = Array.isArray(themes) && themes.length > 0;
96
+ if (capabilities.includes("ui:layout") && !hasLayouts) {
97
+ problems.push('"ui:layout" is declared but contributes.layouts is empty');
98
+ }
99
+ if (capabilities.includes("ui:theme") && !hasThemes) {
100
+ problems.push('"ui:theme" is declared but contributes.themes is empty');
101
+ }
102
+ if (hasLayouts) {
103
+ const entries = ["index.tsx", "index.ts", "index.mjs", "index.js"];
104
+ const frontend = entries.some((entry) =>
105
+ fs.existsSync(path.join(cwd, "src", "frontend", entry)),
106
+ );
107
+ if (!frontend && raw.frontend === undefined) {
108
+ problems.push(
109
+ "contributes.layouts needs a frontend that calls app.registerLayout",
110
+ );
111
+ }
112
+ }
113
+ return problems;
114
+ }
115
+
56
116
  /** Env vars core sets for everyone, so a plugin need not list them. */
57
117
  const CORE_ENV = new Set([
58
118
  "DATA_DIR",
@@ -14,6 +14,17 @@ export function readManifest(cwd) {
14
14
  }
15
15
 
16
16
  /** The first of these that exists, or null. */
17
+ /** Only themes: CSS files and no code, so no source entry is needed. */
18
+ export function isThemeOnly(manifest) {
19
+ const capabilities = manifest.capabilities ?? [];
20
+ return (
21
+ capabilities.length > 0 &&
22
+ capabilities.every((capability) => capability === "ui:theme") &&
23
+ Array.isArray(manifest.contributes?.themes) &&
24
+ manifest.contributes.themes.length > 0
25
+ );
26
+ }
27
+
17
28
  export function resolveEntry(cwd, candidates) {
18
29
  for (const candidate of candidates) {
19
30
  const full = path.join(cwd, candidate);
@@ -2,6 +2,15 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import zlib from "node:zlib";
4
4
 
5
+ /** Theme CSS files a manifest declares, which travel with the plugin too. */
6
+ export function themeFiles(manifest) {
7
+ const themes = manifest?.contributes?.themes;
8
+ if (!Array.isArray(themes)) return [];
9
+ return themes
10
+ .map((theme) => theme?.css)
11
+ .filter((css) => typeof css === "string");
12
+ }
13
+
5
14
  /** What a .tmxplug holds, in the order the spec lists it. */
6
15
  export const TMXPLUG_CONTENTS = [
7
16
  "manifest.json",
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. */
@@ -35,6 +35,7 @@ export const CAPABILITY_CATALOG = [
35
35
  entry("auth:provide", "high"),
36
36
  entry("device:serial", "high"),
37
37
  entry("notify:hub", "high"),
38
+ entry("ui:layout", "high"),
38
39
  entry("hosts:write", "medium"),
39
40
  entry("credentials:use", "medium"),
40
41
  entry("credentials:write", "medium"),
@@ -54,6 +55,7 @@ export const CAPABILITY_CATALOG = [
54
55
  entry("settings:read-core", "low"),
55
56
  entry("plugins:read", "low"),
56
57
  entry("ui:surface", "low"),
58
+ entry("ui:theme", "low"),
57
59
  ];
58
60
  export const CAPABILITY_IDS = CAPABILITY_CATALOG.map((capability) => capability.id);
59
61
  const BY_ID = new Map(CAPABILITY_CATALOG.map((capability) => [capability.id, capability]));