@termix-ssh/plugin-sdk 1.0.11 → 1.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/PLUGIN-GUIDE.md +178 -54
  3. package/cli/commands/build.mjs +36 -22
  4. package/cli/commands/pack.mjs +12 -2
  5. package/cli/commands/validate.mjs +63 -3
  6. package/cli/lib/plugin-dir.mjs +11 -0
  7. package/cli/lib/tmxplug.mjs +9 -0
  8. package/dist/backend.d.ts +404 -0
  9. package/dist/capabilities.js +7 -0
  10. package/dist/db.js +1 -0
  11. package/dist/desktop.d.ts +0 -243
  12. package/dist/frontend.d.ts +175 -2
  13. package/dist/frontend.js +7 -0
  14. package/dist/host/PluginsTab-OXQQ7M4I.js +3266 -0
  15. package/dist/host/chunk-54KCK6IZ.js +3167 -0
  16. package/dist/host/chunk-CALNJCHN.js +10435 -0
  17. package/dist/host/{chunk-7RRTYD6B.js → chunk-CN5UBTBP.js} +1412 -14
  18. package/dist/host/{chunk-RLQBKJRK.js → chunk-ERGN4YW6.js} +9204 -11492
  19. package/dist/host/{chunk-LSWB3ZZV.js → chunk-T2PZWXFJ.js} +6 -1
  20. package/dist/host/chunk-YGYBM7SQ.js +58 -0
  21. package/dist/host/core-classes.json +1 -1
  22. package/dist/host/{linked-server-UCW74T2V.js → linked-server-5VFO4XQB.js} +2 -2
  23. package/dist/host/{rbac-api-6UYMFIFV.js → rbac-api-5YMBJAO5.js} +2 -2
  24. package/dist/host/testing-host.js +123 -57
  25. package/dist/host/types/src/types/onboarding.d.ts +0 -2
  26. package/dist/host/types/src/types/ui-types.d.ts +2 -1
  27. package/dist/host/types/src/ui/api/audit-log-api.d.ts +36 -0
  28. package/dist/host/types/src/ui/api/credentials-api.d.ts +0 -22
  29. package/dist/host/types/src/ui/api/host-defaults-api.d.ts +42 -0
  30. package/dist/host/types/src/ui/api/host-tags-api.d.ts +2 -0
  31. package/dist/host/types/src/ui/api/look-api.d.ts +40 -0
  32. package/dist/host/types/src/ui/api/open-tabs-api.d.ts +4 -0
  33. package/dist/host/types/src/ui/api/plugins-api.d.ts +30 -0
  34. package/dist/host/types/src/ui/api/private-endpoints-api.d.ts +6 -0
  35. package/dist/host/types/src/ui/api/settings-api.d.ts +0 -6
  36. package/dist/host/types/src/ui/api/ssh-host-management-api.d.ts +0 -28
  37. package/dist/host/types/src/ui/api/tls-api.d.ts +25 -0
  38. package/dist/host/types/src/ui/auth/ElectronLoginForm.d.ts +9 -0
  39. package/dist/host/types/src/ui/components/command.d.ts +78 -0
  40. package/dist/host/types/src/ui/components/folder-style.d.ts +16 -0
  41. package/dist/host/types/src/ui/components/theme-provider.d.ts +2 -1
  42. package/dist/host/types/src/ui/components/version-badge.d.ts +7 -0
  43. package/dist/host/types/src/ui/hooks/use-page-visible-interval.d.ts +10 -0
  44. package/dist/host/types/src/ui/lib/activity-types.d.ts +15 -0
  45. package/dist/host/types/src/ui/lib/app-version.d.ts +2 -0
  46. package/dist/host/types/src/ui/lib/apply-ui-preset.d.ts +19 -0
  47. package/dist/host/types/src/ui/lib/create-id.d.ts +2 -0
  48. package/dist/host/types/src/ui/lib/database-transfer-url.d.ts +8 -0
  49. package/dist/host/types/src/ui/lib/desktop-updates.d.ts +29 -0
  50. package/dist/host/types/src/ui/lib/host-connection-tabs.d.ts +14 -0
  51. package/dist/host/types/src/ui/lib/host-to-ssh-host.d.ts +4 -0
  52. package/dist/host/types/src/ui/lib/issue-url.d.ts +21 -0
  53. package/dist/host/types/src/ui/lib/local-adaptive-preferences.d.ts +9 -0
  54. package/dist/host/types/src/ui/lib/pane-nav-hotkey.d.ts +7 -0
  55. package/dist/host/types/src/ui/lib/plugin-i18n.d.ts +4 -3
  56. package/dist/host/types/src/ui/lib/role-label.d.ts +3 -0
  57. package/dist/host/types/src/ui/look/LookSettings.d.ts +8 -0
  58. package/dist/host/types/src/ui/look/layout-registry.d.ts +34 -0
  59. package/dist/host/types/src/ui/look/look-store.d.ts +29 -0
  60. package/dist/host/types/src/ui/look/resolve-look.d.ts +18 -0
  61. package/dist/host/types/src/ui/look/theme-css.d.ts +7 -0
  62. package/dist/host/types/src/ui/look/theme-registry.d.ts +41 -0
  63. package/dist/host/types/src/ui/look/use-look-theme.d.ts +12 -0
  64. package/dist/host/types/src/ui/main-axios.d.ts +2 -2
  65. package/dist/host/types/src/ui/manage/manage-requests.d.ts +34 -0
  66. package/dist/host/types/src/ui/onboarding/onboarding-registry.d.ts +3 -1
  67. package/dist/host/types/src/ui/plugin-host/loader.d.ts +2 -0
  68. package/dist/host/types/src/ui/plugin-host/scope.d.ts +6 -0
  69. package/dist/host/types/src/ui/plugin-host/sdk-ui.d.ts +101 -0
  70. package/dist/host/types/src/ui/plugin-host/view-ownership.d.ts +1 -1
  71. package/dist/host/types/src/ui/plugins/PluginConsentPrompt.d.ts +20 -0
  72. package/dist/host/types/src/ui/plugins/PluginDetail.d.ts +7 -0
  73. package/dist/host/types/src/ui/plugins/PluginReleaseNotes.d.ts +14 -0
  74. package/dist/host/types/src/ui/plugins/PluginsTab.d.ts +1 -0
  75. package/dist/host/types/src/ui/plugins/RegistriesSection.d.ts +4 -0
  76. package/dist/host/types/src/ui/plugins/plugin-bits.d.ts +23 -0
  77. package/dist/host/types/src/ui/plugins/plugin-model.d.ts +136 -0
  78. package/dist/host/types/src/ui/plugins/use-plugins-manager.d.ts +78 -0
  79. package/dist/host/types/src/ui/settings/appearance/appearance-controls.d.ts +53 -0
  80. package/dist/host/types/src/ui/settings/sync/LinkServerDialog.d.ts +12 -0
  81. package/dist/host/types/src/ui/settings/sync/ProxySettingsFields.d.ts +15 -0
  82. package/dist/host/types/src/ui/settings/sync/saved-servers.d.ts +3 -0
  83. package/dist/host/types/src/ui/shell/controller/ShellControllerProvider.d.ts +20 -0
  84. package/dist/host/types/src/ui/shell/controller/plugin-shell.d.ts +7 -0
  85. package/dist/host/types/src/ui/shell/controller/shell-store.d.ts +4 -0
  86. package/dist/host/types/src/ui/shell/controller/types.d.ts +156 -0
  87. package/dist/host/types/src/ui/shell/controller/use-hosts.d.ts +10 -0
  88. package/dist/host/types/src/ui/shell/controller/use-regions.d.ts +5 -0
  89. package/dist/host/types/src/ui/shell/controller/use-shell-keybindings.d.ts +12 -0
  90. package/dist/host/types/src/ui/shell/controller/use-shell-lifecycle.d.ts +17 -0
  91. package/dist/host/types/src/ui/shell/controller/use-split.d.ts +21 -0
  92. package/dist/host/types/src/ui/shell/controller/use-tabs.d.ts +70 -0
  93. package/dist/host/types/src/ui/shell/controller/use-workspace-layout.d.ts +4 -0
  94. package/dist/host/types/src/ui/shell/reconnect-tabs.d.ts +6 -0
  95. package/dist/host/types/src/ui/shell/shell-layout.d.ts +46 -0
  96. package/dist/host/types/src/ui/shell/split/EmptyPanePicker.d.ts +32 -0
  97. package/dist/host/types/src/ui/shell/split/SplitDropOverlay.d.ts +9 -0
  98. package/dist/host/types/src/ui/shell/split/SplitLayoutMenu.d.ts +16 -0
  99. package/dist/host/types/src/ui/shell/split/SplitView.d.ts +31 -0
  100. package/dist/host/types/src/ui/shell/split/split-drag.d.ts +58 -0
  101. package/dist/host/types/src/ui/shell/split/split-presets.d.ts +28 -0
  102. package/dist/host/types/src/ui/shell/split/split-tabs.d.ts +95 -0
  103. package/dist/host/types/src/ui/shell/split/split-targets.d.ts +30 -0
  104. package/dist/host/types/src/ui/shell/split/split-tree.d.ts +89 -0
  105. package/dist/host/types/src/ui/shell/stage/TabStage.d.ts +9 -0
  106. package/dist/host/types/src/ui/shell/stage/tab-stage-store.d.ts +24 -0
  107. package/dist/host/types/src/ui/shell/tab-close-guards.d.ts +9 -0
  108. package/dist/host/types/src/ui/shell/tab-handles.d.ts +8 -0
  109. package/dist/host/types/src/ui/shell/tabUtils.d.ts +15 -0
  110. package/dist/host/types/src/ui/sidebar/AuthEnrollmentSections.d.ts +5 -0
  111. package/dist/host/types/src/ui/sidebar/HostManagerData.d.ts +4 -0
  112. package/dist/host/types/src/ui/sidebar/build-host-tree.d.ts +25 -0
  113. package/dist/host/types/src/ui/sidebar/host-contributions.d.ts +10 -0
  114. package/dist/host/ui.js +6045 -4055
  115. package/dist/host/{use-permissions-GYCBY2AU.js → use-permissions-ISEDRY3J.js} +2 -2
  116. package/dist/manifest.d.ts +50 -1
  117. package/dist/manifest.js +104 -1
  118. package/dist/ssh-keys.d.ts +29 -0
  119. package/dist/ssh-keys.js +85 -0
  120. package/dist/testing/setup.js +1 -0
  121. package/dist/testing.d.ts +32 -15
  122. package/dist/testing.js +218 -1
  123. package/dist/theme-css.d.ts +11 -0
  124. package/dist/theme-css.js +142 -0
  125. package/dist/vitest-preset.d.ts +18 -0
  126. package/dist/vitest-preset.js +14 -3
  127. package/package.json +5 -1
  128. package/schema/manifest.schema.json +121 -8
  129. package/tsconfig.plugin.json +1 -0
  130. package/dist/host/chunk-YC2MAKSP.js +0 -14
package/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.13
4
+
5
+ Plugin API 1.5.0.
6
+
7
+ ### Added
8
+
9
+ - `runtime: "desktop"` in the manifest for a plugin that only loads on the desktop app
10
+ - `desktop:local` and `ctx.desktop.local`: file system, local shell and machine info on the desktop app's own backend
11
+ - `ctx.audit.subscribe`, `ctx.audit.list` and `ctx.audit.exportBatches` under `audit:read`
12
+ - `ctx.hosts.importMany`, `importSshConfig`, `exportMany` and `registerImportNormalizer`, with the `hosts:export` capability
13
+ - `ctx.credentials.publicKey` and `resolveKeyRefs`, `ctx.ssh.exec`, and `@termix-ssh/plugin-sdk/ssh-keys`
14
+ - `ctx.hosts.status.provide` and `ctx.hosts.activity.provide` so a plugin can own host status and recent activity, with `ctx.network.tcpProbe`, `ctx.ssh.probe`, `ctx.hosts.probeTargets` and `ctx.hosts.sessionActive`
15
+ - `registerHostStatusIndicator` and `registerHostEditorToolbarItem`, and `order` for host badges
16
+ - Onboarding steps can sit in a `start` section, take a `position` and `replaces` a core step
17
+ - A plugin can provide the app name, tagline and logo through the `<id>.brandingProvider` registry key
18
+
19
+ ## 1.0.12
20
+
21
+ Plugin API 1.4.0.
22
+
23
+ ### Added
24
+
25
+ - `contributes.layouts` and `app.registerLayout()` for a whole-app layout, with the `ui:layout` capability. Needs `engine.api` 1.4
26
+ - `contributes.themes` for CSS-only themes, with the `ui:theme` capability. `termix-plugin validate` checks theme CSS and `pack` ships it
27
+ - `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
28
+ - `TabStage` and `ShellConfirmCapture` in `@termix-ssh/plugin-sdk/ui`, for a layout to place tabs
29
+ - `validateThemeCss()` in `@termix-ssh/plugin-sdk/manifest`
30
+ - `@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
31
+ - `@termix-ssh/plugin-sdk/testing-host` exports the registries and permission seam a layout's own tests fill directly
32
+ - A plugin that only holds themes needs no source: `termix-plugin build` writes an empty backend and `validate` does not ask for a frontend
33
+ - `termix-plugin validate` flags `ui:layout` or `ui:theme` with nothing contributed, and a layout plugin with no frontend
34
+
35
+ ### Fixed
36
+
37
+ - The manifest JSON schema lists the `notify:hub` and `plugins:read` capabilities
38
+ - Plugin tests and type checks work with the SDK linked from a local Termix checkout
39
+
3
40
  ## 1.0.11
4
41
 
5
42
  Plugin API 1.3.0.
package/PLUGIN-GUIDE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Termix plugin guide
2
2
 
3
- SDK 1.0.11 | Plugin API 1.3.0
3
+ SDK 1.0.13 | Plugin API 1.5.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.
@@ -164,7 +164,7 @@ Unknown fields are refused at every level, so a typo fails validation. The schem
164
164
  | `icon` | A [Lucide](https://lucide.dev/icons/) icon name, like `Hand`. |
165
165
  | `features` | Up to 20 short lines shown on the plugin's page. |
166
166
  | `docs` | An https link to your docs. |
167
- | `engine.termix` | Termix versions you work with, like `>=26.10.0`. |
167
+ | `engine.termix` | Termix versions you work with, like `>=26.10.0`. Older Termix builds don't list a release they can't run. |
168
168
  | `engine.api` | Plugin API range, like `^1.2`. A major bump of the API is breaking. |
169
169
  | `capabilities` | What your code may do. See the reference at the end. |
170
170
  | `dependencies`, `optionalDependencies` | Other plugins, `{ "snippets": "^1.0.0" }`. You must keep working without optional ones. |
@@ -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.
@@ -325,6 +325,24 @@ try {
325
325
  commands. `@termix-ssh/plugin-sdk/host-commands` has helpers to detect the OS and run package manager and
326
326
  sudo commands. Hosts from `ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`.
327
327
 
328
+ ### The desktop app
329
+
330
+ Set `"runtime": "desktop"` in the manifest for a plugin that only makes sense on a desktop app. It never
331
+ loads on a server, ignores server sync and may declare `desktop:local`. Any plugin can declare
332
+ `desktop:local` and check `ctx.desktop.local.available()` at runtime.
333
+
334
+ `ctx.desktop.local` works on the desktop app's own backend only:
335
+
336
+ - `info()`: home folder, platform, user data folder, backend port and the linked server (url and token) or null
337
+ - `fs`: `list`, `mkdir`, `createFile`, `rename`, `ensureDir`, `exists`, `walk`, `trash`, `reveal`, `open`,
338
+ `openRead` and `openDownload`. Downloads stay inside the folder the user picked, links in it are refused and
339
+ an existing file is never replaced by accident
340
+ - `pty.spawn()`: a local shell, stopped when the plugin stops
341
+ - `pluginFetch(origin, subPath, init)`: calls this plugin's own routes on the desktop's backend ("local") or the linked server ("remote") as the acting user. Bodies can be streams, so a plugin can move big files without the renderer
342
+
343
+ Use `ctx.audit.subscribe`, `list` and `exportBatches` (`audit:read`) to read the audit trail. Subscribers
344
+ are called after a line is accepted and never delay the action that wrote it.
345
+
328
346
  ### Events, timers, alerts, services
329
347
 
330
348
  ```ts
@@ -470,22 +488,35 @@ your plugin's error boundary and Suspense.
470
488
  | `registerHostEditorSection` | Your own host editor section (host settings get one for free) |
471
489
  | `registerHostProtocol` | A connection protocol next to SSH |
472
490
  | `registerHostBadge`, `registerHostContextMenuItem` | Badges and menu items on host rows |
491
+ | `registerHostStatusIndicator` | The status dot or stripe on a host row (a layout draws it) |
492
+ | `registerHostEditorToolbarItem` | A control in the host editor toolbar (a layout draws it) |
473
493
  | `registerPaletteEntry`, `registerPaletteGroup` | Command palette entries |
474
494
  | `registerKeybindingAction`, `registerKeybindingDefault` | Bindable actions |
475
495
  | `registerSettingsComponent` | A component for a `custom` settings field |
476
496
  | `registerAction`, `invokeAction` | Actions across plugins |
477
497
  | `registerSlotContribution`, `declareActionSlot` | Fill or own a UI slot |
478
498
  | `registerComponent`, `registerExtension` | Offer a component by id, add to an extension point |
479
- | `registerOnboardingStep` | A first run setup step |
499
+ | `registerOnboardingStep` | A first run setup step (`section`, `position`, `replaces`) |
480
500
  | `registerLoginMethod`, `registerSecondFactorUI`, `registerSshAuthEditor` | Sign in UI |
501
+ | `registerLayout` | A whole-app layout (`contributes.layouts`, API 1.4) |
481
502
  | `api` | Axios client for your `/plugin-api/<id>/` routes, auth included |
482
503
  | `fetch(path)`, `wsUrl(path)` | Raw fetch for streams, socket URL and auth |
483
504
  | `docs`, `tabs`, `confirm`, `listHosts`, `getHost`, `t`, `hasPermission`, `desktop` | Helpers |
484
505
  | `onSettingsChanged`, `onDispose` | Hooks |
485
506
 
507
+ Badges, status indicators and toolbar items take `{ id, when(host), component, order? }`; lower `order`
508
+ comes first. An onboarding step sits in a section (`start`, `setup`, `explore`, `security`) or at an exact
509
+ `position` from 1 to 99. `replaces: ["preset"]` lists older step keys the step took over, so people who saw
510
+ those are not shown it again.
511
+
512
+ Branding: a plugin that owns the app name, tagline and logo provides
513
+ `ctx.registry.provide("<id>.brandingProvider", async () => ({ appName, tagline, logo }))` in its backend.
514
+ Core reads it for `GET /users/branding` (the login page, the browser tab and the sync server name) and falls
515
+ back to "Termix" with no logo when no plugin provides one. `logo` is a PNG data URL or null.
516
+
486
517
  Hooks from `@termix-ssh/plugin-sdk/frontend`: `useTranslation`, `usePermission`, `useSettings`, `useHost`,
487
518
  `useHosts`, `usePluginApi`, `useCurrentUser`, `useTheme`, `useToast`, `useDocsUrl`, `useTabs`,
488
- `useSlotContributions`, `useSshAuthTypes`, `useExtensions`.
519
+ `useSlotContributions`, `useSshAuthTypes`, `useExtensions`, `useShell`.
489
520
 
490
521
  Shared from Termix and never bundled: `react`, `react-dom`, `i18next`, `react-i18next`, `sonner` and the
491
522
  SDK's `frontend` and `ui`. Anything else, like `lucide-react`, is bundled. A host your code gets only carries
@@ -496,6 +527,91 @@ Slots you can fill include `terminal.toolbar`, `terminal.toolbarStatus`, `termin
496
527
  `hosts.panel`, `credentials.badges`, `shell.overlay`, `shell.rightEdge` and `tab.menu`. See
497
528
  https://docs.termix.site/develop/more-extension-points.
498
529
 
530
+ ## Layouts and themes
531
+
532
+ A plugin can replace the whole app layout or add themes (API 1.4). Users pick both in Settings, Appearance;
533
+ admins set the default for everyone.
534
+
535
+ A layout is declared in `contributes.layouts` (`id`, `label`, optional `description` and `defaultTheme`) and
536
+ needs the `ui:layout` capability, which admins see as high risk because a layout draws every screen. Register
537
+ it in `activate`:
538
+
539
+ ```tsx
540
+ import { useShell, type TermixApp } from "@termix-ssh/plugin-sdk/frontend";
541
+ import {
542
+ ShellConfirmCapture,
543
+ SurfaceScope,
544
+ TabStage,
545
+ } from "@termix-ssh/plugin-sdk/ui";
546
+
547
+ function MyLayout() {
548
+ const shell = useShell();
549
+ if (!shell) return null;
550
+ return (
551
+ <div className="flex h-dvh w-screen flex-col bg-background">
552
+ <header>
553
+ {/* tabs from shell.tabs.topLevel, shell.palette.open() and so on */}
554
+ </header>
555
+ <SurfaceScope kind="tab">
556
+ <ShellConfirmCapture />
557
+ <TabStage />
558
+ </SurfaceScope>
559
+ </div>
560
+ );
561
+ }
562
+
563
+ export function activate(app: TermixApp) {
564
+ app.registerLayout({
565
+ id: "my-layout",
566
+ label: "layout.name",
567
+ component: MyLayout,
568
+ });
569
+ }
570
+ ```
571
+
572
+ - `useShell()` is the live shell with nothing drawn: tabs, splits, hosts, the left and right docks, the
573
+ command palette, the registries as hooks and the user. Core keeps all the state, so switching layouts keeps
574
+ every session.
575
+ - `TabStage` is where tabs show. Place it anywhere; tabs are never remounted when it moves, so a terminal
576
+ keeps its connection. Wrap it in `SurfaceScope kind="tab"` with a `ShellConfirmCapture` inside, so
577
+ close questions and inline views show over the tab area. Overlays and onboarding stay in core.
578
+ - The layout draws the command palette. Core keeps whether it is open (`palette.isOpen`, Ctrl+K calls
579
+ `palette.open()`) and what is in it (`usePaletteEntries`, `usePaletteGroups`, rail items, hosts).
580
+ - The shell always keeps a home tab with the id and type `dashboard`. `useShell()` leaves it out until some
581
+ plugin registers a `dashboard` tab type, so a layout without a home screen has no empty tab to draw.
582
+ - Settings, Manage and the host editor belong to Classic Layout. A layout without them should still render
583
+ `LookSettings` somewhere so people can switch back. `tabs.openSingleton("plugins")` opens core's own
584
+ Plugins tab in any layout. Switching layouts closes the tabs the old layout's plugin declared.
585
+ - `mobileComponent` is used on phones and narrow windows. The desktop app always gets `component`.
586
+ - Only the chosen layout's plugin is loaded. If it fails to load or crashes twice, Termix shows a repair
587
+ screen where people can switch, reset, update, turn it off or (for a bundled plugin) restore the copy that
588
+ shipped. `?safe-layout=1` opens it any time.
589
+ - Termix has no layout of its own. The default is the bundled Classic Layout plugin
590
+ (https://github.com/Termix-SSH/Plugin-Classic-Layout), which is also a full example. It owns the rail,
591
+ sidebar, tab bar, docks, Dashboard, Manage, Settings and command palette, registers the `dashboard`,
592
+ `host-manager` and `settings` tab types, and offers the host list and dashboard as the components
593
+ `classic-layout.hostTree` and `classic-layout.dashboard` for other plugins to place with `PluginComponent`.
594
+ It reads the shell with `useShellController()` from `@termix-ssh/plugin-sdk/ui`, which exposes more than
595
+ `useShell()` and may change with Termix. A third party layout should start from `useShell()`.
596
+
597
+ A theme is CSS only. Declare it in `contributes.themes` (`id`, `label`, `mode` light or dark, `css` path,
598
+ optional `swatch` colors for the picker) with the `ui:theme` capability. The id becomes a class on `<html>`,
599
+ so the file may only hold rules whose selectors start with `.<id>`, `:root.<id>` or `html.<id>`, optionally
600
+ inside `@media` or `@supports`. `@import`, `url()` to another host and other at-rules are refused by
601
+ `termix-plugin validate` and by the server. Set the same tokens core's themes set (`--background`,
602
+ `--foreground`, `--card`, `--border`, `--muted` and the rest).
603
+
604
+ A plugin that only holds themes needs no code. Leave out `src/`: `termix-plugin build` writes an empty backend and
605
+ `validate` does not ask for a frontend. Keep the CSS in `themes/` and a test that checks it with
606
+ `validateThemeCss()`. Notes:
607
+
608
+ - A theme with the id of a built-in (`light` or `dark`) replaces core's basic copy while the plugin runs.
609
+ - The first theme a bundled default-look plugin lists is the default for new installs.
610
+ - Themes show in the picker in the order core lists its own, then the plugin's order.
611
+ - A theme someone saved that no longer exists shows as System. It comes back when the plugin does.
612
+ - Accent color, UI font, font size and density are not part of a theme. Core applies them on top.
613
+ - Bundled Classic Themes (https://github.com/Termix-SSH/Plugin-Classic-Themes) is a full example.
614
+
499
615
  ## Looking native
500
616
 
501
617
  Build UI from `@termix-ssh/plugin-sdk/ui` so it matches Termix and follows the theme. The full list of
@@ -742,58 +858,66 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
742
858
 
743
859
  ## SDK entry points
744
860
 
745
- | Import | What it is |
746
- | -------------------------------------------------------------------- | --------------------------------------------------------------------- |
747
- | `@termix-ssh/plugin-sdk/backend` | `PluginContext` and the backend types |
748
- | `@termix-ssh/plugin-sdk/frontend` | `TermixApp`, `TabProps` and the hooks |
749
- | `@termix-ssh/plugin-sdk/ui` | The UI kit, served by Termix at runtime |
750
- | `@termix-ssh/plugin-sdk/db` | `defineTable` and column builders |
751
- | `@termix-ssh/plugin-sdk/manifest` | `PluginManifest` type and manifest checks |
752
- | `@termix-ssh/plugin-sdk/capabilities` | The capability catalog |
753
- | `@termix-ssh/plugin-sdk/settings` | Settings field validation |
754
- | `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` |
755
- | `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` |
756
- | `@termix-ssh/plugin-sdk/host-commands` | OS detection, package manager and sudo helpers over ssh2 |
757
- | `@termix-ssh/plugin-sdk/ssh-certs` | `applyCertificateAuth()` for certificate based SSH auth |
758
- | `@termix-ssh/plugin-sdk/desktop` | The desktop app bridge, typed. Check it exists before use |
759
- | `@termix-ssh/plugin-sdk/openapi` | `buildOpenApi()` |
760
- | `@termix-ssh/plugin-sdk/changelog` | Changelog parsing and checks |
761
- | `@termix-ssh/plugin-sdk/docs` | Docs link and env var helpers |
762
- | `@termix-ssh/plugin-sdk/ddl`, `@termix-ssh/plugin-sdk/table-builder` | Table to SQL and Drizzle mapping. You rarely need these |
861
+ | Import | What it is |
862
+ | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
863
+ | `@termix-ssh/plugin-sdk/backend` | `PluginContext` and the backend types |
864
+ | `@termix-ssh/plugin-sdk/frontend` | `TermixApp`, `TabProps` and the hooks |
865
+ | `@termix-ssh/plugin-sdk/ui` | The UI kit, served by Termix at runtime |
866
+ | `@termix-ssh/plugin-sdk/db` | `defineTable` and column builders |
867
+ | `@termix-ssh/plugin-sdk/manifest` | `PluginManifest` type and manifest checks |
868
+ | `@termix-ssh/plugin-sdk/capabilities` | The capability catalog |
869
+ | `@termix-ssh/plugin-sdk/settings` | Settings field validation |
870
+ | `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` |
871
+ | `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` |
872
+ | `@termix-ssh/plugin-sdk/host-commands` | OS detection, package manager and sudo helpers over ssh2 |
873
+ | `@termix-ssh/plugin-sdk/ssh-certs` | `applyCertificateAuth()` for certificate based SSH auth |
874
+ | `@termix-ssh/plugin-sdk/ssh-keys` | `generateSshKeyPair()`, `detectSshKeyType()`, `publicKeyFromPrivate()`, `validateSshKeyPair()` |
875
+ | `@termix-ssh/plugin-sdk/desktop` | The desktop app bridge, typed. Check it exists before use |
876
+ | `@termix-ssh/plugin-sdk/openapi` | `buildOpenApi()` |
877
+ | `@termix-ssh/plugin-sdk/changelog` | Changelog parsing and checks |
878
+ | `@termix-ssh/plugin-sdk/docs` | Docs link and env var helpers |
879
+ | `@termix-ssh/plugin-sdk/ddl`, `@termix-ssh/plugin-sdk/table-builder` | Table to SQL and Drizzle mapping. You rarely need these |
763
880
 
764
881
  ## Capabilities
765
882
 
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 |
883
+ | Capability | Risk | What it allows |
884
+ | ------------------------ | -------- | ------------------------------------------------------- |
885
+ | `credentials:read` | critical | Read stored secrets for hosts the user can reach |
886
+ | `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
887
+ | `ssh:connect` | high | Run any command on hosts, with the user's access |
888
+ | `process:spawn` | high | Run programs on the Termix server |
889
+ | `users:write` | high | Add users and change their roles |
890
+ | `users:impersonate` | high | Act as another user, or get a session for them, audited |
891
+ | `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
892
+ | `auth:provide` | high | Add login methods, second factors, SSH auth types |
893
+ | `device:serial` | high | Open serial and USB ports on the server |
894
+ | `desktop:local` | high | Use the user's own computer, desktop app only |
895
+ | `hosts:export` | high | Export hosts with their saved logins |
896
+ | `notify:hub` | high | Receive every alert (the alerts hub only) |
897
+ | `ui:layout` | high | Replace the whole app layout |
898
+ | `hosts:write` | medium | Add and edit hosts |
899
+ | `hosts:status-provide` | medium | Be the source of host status dots |
900
+ | `hosts:activity-provide` | medium | Be the source of the recent activity list |
901
+ | `network:probe` | medium | Check that a host answers on a TCP port |
902
+ | `credentials:use` | medium | Connect with saved credentials without seeing them |
903
+ | `credentials:write` | medium | Save new credentials |
904
+ | `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
905
+ | `network:serve` | medium | HTTP routes and sockets |
906
+ | `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN |
907
+ | `users:read` | medium | Usernames, roles and UI language |
908
+ | `events:core` | medium | Listen to core events |
909
+ | `notify:send` | medium | Send alerts |
910
+ | `audit:read` | medium | Read the audit log |
911
+ | `desktop:window` | medium | Open windows in the desktop app |
912
+ | `hosts:read` | low | Read hosts the user can see |
913
+ | `db:own` | low | Your own tables |
914
+ | `kv:own` | low | Small key value state |
915
+ | `files:own` | low | Your own folder on disk |
916
+ | `secrets:own` | low | Your own encrypted secrets |
917
+ | `settings:read-core` | low | Read Termix server settings |
918
+ | `plugins:read` | low | See installed plugins |
919
+ | `ui:surface` | low | Add tabs, panels and settings |
920
+ | `ui:theme` | low | Add color themes, CSS only |
797
921
 
798
922
  An update that adds a capability waits for an admin to approve it.
799
923
 
@@ -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",