@termix-ssh/plugin-sdk 1.0.21 → 1.0.23

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 (45) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/PLUGIN-GUIDE.md +130 -3
  3. package/cli/commands/build.mjs +55 -36
  4. package/cli/commands/validate.mjs +4 -0
  5. package/dist/backend.d.ts +139 -7
  6. package/dist/capabilities.js +3 -0
  7. package/dist/frontend.d.ts +7 -0
  8. package/dist/host/{PluginsTab-HQGSDSHS.js → PluginsTab-MNQU5OJ3.js} +22 -14
  9. package/dist/host/{chunk-JFQGI4OD.js → chunk-6WLECSNB.js} +71 -12
  10. package/dist/host/chunk-BP42N374.js +53 -0
  11. package/dist/host/{chunk-WWFRBU2E.js → chunk-C52NRTA6.js} +82 -51
  12. package/dist/host/{chunk-GTHINW3L.js → chunk-DN6YKMFJ.js} +34650 -34968
  13. package/dist/host/chunk-GBBKHJRQ.js +1526 -0
  14. package/dist/host/{chunk-FGX27MAA.js → chunk-GRH7GSKG.js} +1 -1
  15. package/dist/host/chunk-IMPLAAUM.js +345 -0
  16. package/dist/host/{chunk-A5CULDWV.js → chunk-X65V7ZRW.js} +121 -1574
  17. package/dist/host/core-classes.json +1 -1
  18. package/dist/host/frame-host.css +2 -0
  19. package/dist/host/frame-host.js +242 -0
  20. package/dist/host/{linked-server-UDSFY3N7.js → linked-server-YCAJ2NX3.js} +1 -1
  21. package/dist/host/{rbac-api-AAAPMF52.js → rbac-api-A64TXDL5.js} +5 -1
  22. package/dist/host/testing-host.js +9 -6
  23. package/dist/host/types/src/types/host-sidebar-preferences.d.ts +2 -0
  24. package/dist/host/types/src/types/index.d.ts +8 -0
  25. package/dist/host/types/src/ui/api/plugins-api.d.ts +2 -1
  26. package/dist/host/types/src/ui/api/rbac-api.d.ts +19 -1
  27. package/dist/host/types/src/ui/components/data-view.d.ts +1 -1
  28. package/dist/host/types/src/ui/lib/db-health-monitor.d.ts +5 -0
  29. package/dist/host/types/src/ui/main-axios.d.ts +1 -1
  30. package/dist/host/types/src/ui/plugin-host/loader.d.ts +6 -0
  31. package/dist/host/types/src/ui/plugin-host/sdk-ui.d.ts +1 -1
  32. package/dist/host/types/src/ui/plugins/use-plugins-manager.d.ts +1 -1
  33. package/dist/host/types/src/ui/shell/tab-registry.d.ts +2 -0
  34. package/dist/host/ui.js +357 -370
  35. package/dist/host/{use-permissions-VAND5GEG.js → use-permissions-FLQAN6I7.js} +1 -1
  36. package/dist/host-commands.d.ts +7 -0
  37. package/dist/host-commands.js +35 -5
  38. package/dist/manifest.d.ts +33 -1
  39. package/dist/manifest.js +114 -1
  40. package/dist/operations.d.ts +222 -0
  41. package/dist/operations.js +650 -0
  42. package/dist/testing.d.ts +9 -1
  43. package/dist/testing.js +45 -0
  44. package/package.json +9 -1
  45. package/schema/manifest.schema.json +67 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.23
4
+
5
+ Plugin API 1.14.0.
6
+
7
+ ### Added
8
+
9
+ - `contributes.operations` declares a plugin's actions, events, resources and signals, so Automations, Fleets, the assistant, the CLI, the phone app and the command palette can find them with no wiring
10
+ - `ctx.operations` registers handlers, runs other plugins' declared actions, emits events and provides approval rules, with the new `operations:provide`, `operations:invoke` and `operations:approve` capabilities
11
+ - `@termix-ssh/plugin-sdk/operations` has the types, input checks, redaction and JSON Schema for declared actions
12
+ - `contributes.hostSections` declares cards for the host page
13
+
14
+ ## 1.0.22
15
+
16
+ Plugin API 1.13.0.
17
+
18
+ ### Added
19
+
20
+ - `contributes.mobile` in the manifest says how a plugin shows in the Termix mobile app: `webview`, `native` or `none`, with an optional mobile bundle, screen sizes, views and a note
21
+ - `termix-plugin build` bundles `src/mobile` into the `contributes.mobile.entry` file, and `validate` checks that it exists
22
+ - `@termix-ssh/plugin-sdk/frame-host` runs one plugin view outside the shell, with core's bridge, registries and CSS, so the mobile app can show `webview` plugins
23
+ - SSH transport providers get the host's owner, sync id, connection origin and local only flag in `claims` and `open`, and core's SSH relay takes a `syncId` instead of a host id
24
+ - `ctx.sync.host.entities.idsOf` maps sync ids to the local ids of the user's own rows of one type
25
+ - Rail items take `useStatus` for a small ok, warning or error dot on the icon
26
+ - `shell.editHost(hostId)` opens the editor of a saved host
27
+ - A sync entity can be read only with `load`, `write` and `erase`, so a plugin can sync rows a user does not own, like a shared library, down to linked desktops
28
+ - `getFolderAuthOverride` and `setFolderAuthOverride` in `@termix-ssh/plugin-sdk/ui` read and set a user's own login for the shared hosts in a folder
29
+
30
+ ### Fixed
31
+
32
+ - `@termix-ssh/plugin-sdk/ui` is built in production mode, so outside Termix it no longer sends requests to the Vite dev server path
33
+ - `execCommand` buffers output and decodes it once as strict UTF-8 with a GBK fallback, exposed as `decodeCommandOutput`, so messages from a Windows host on a legacy codepage are readable instead of � characters
34
+
3
35
  ## 1.0.21
4
36
 
5
37
  Plugin API 1.12.0.
package/PLUGIN-GUIDE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Termix plugin guide
2
2
 
3
- SDK 1.0.21 | Plugin API 1.12.0
3
+ SDK 1.0.23 | Plugin API 1.14.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.
@@ -175,7 +175,49 @@ 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`, `uses`, `layouts` and `themes`. Frontend `register*` calls need their ids declared here first.
178
+ `uiPresets`, `uses`, `layouts`, `themes` and `mobile`. Frontend `register*` calls need their ids declared here first.
179
+
180
+ ### Mobile app
181
+
182
+ `contributes.mobile` says how the plugin shows in the Termix mobile app. Leave it out and the app treats the
183
+ plugin as having nothing to show (official plugins are mapped by the app itself).
184
+
185
+ ```json
186
+ "mobile": { "mode": "webview", "views": ["hello"], "sizes": ["phone", "tablet"] }
187
+ ```
188
+
189
+ | Field | Notes |
190
+ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
191
+ | `mode` | `webview` (the app shows your UI from the server), `native` (the app has its own screens) or `none`. |
192
+ | `entry` | `webview` only. A bundle made for the app, like `dist/mobile.js`, next to `frontend.js`. Unset: your normal frontend runs in a phone sized frame. |
193
+ | `sizes` | `webview` only. `phone`, `tablet` or both (the default). |
194
+ | `views` | `webview` only. Ids from your `tabs`, `panels` or `dashboardCards` the app offers. Default: every tab. |
195
+ | `feature` | `native` only. The app feature that stands in for the plugin. Default: the plugin id. |
196
+ | `note` | A locale key or plain text the app shows next to the plugin. |
197
+
198
+ `build` makes `entry` from `src/mobile/index.tsx`, and it shares `frontend.css`.
199
+
200
+ How a `webview` plugin runs in the app:
201
+
202
+ - Only your plugin loads, with the same app object, SDK hooks and `/ui` kit as the web app. Each view opens
203
+ on its own screen: a tab with `host-context-menu` in `openFrom` opens from a host, the rest from the app's
204
+ plugin list.
205
+ - Every request and socket goes through the app, which adds the login, proxy headers and client
206
+ certificate. The app only lets through your own `/plugin-api`, `/plugin-ws` and `/plugin-assets`, your
207
+ settings and a short list of core reads (hosts, folders, permissions, current user, version, health,
208
+ activity). Other core routes answer 403, so call your own backend instead.
209
+ - Images load straight from the server, without proxy headers or a client certificate.
210
+ - Nothing in `localStorage` survives closing the view. Keep preferences in settings.
211
+ - Other plugins' views, extensions and components are not there, and neither is the shell (`useShell()`
212
+ is null). `/ui` names newer than the app's copy of the SDK make the view say it needs a newer app.
213
+ - The app checks your `engine.api` against the plugin API of its own copy of the SDK. When the app's API
214
+ is too old for the range, it asks the user to update the app instead of opening the view. When the range
215
+ is for an older API major, it asks to update the plugin. Ask only for the API you use.
216
+ - Turning a plugin off on the server hides it in the app the next time the app checks: when it comes back
217
+ to the front, when the list is pulled down, or when any request answers 503 with your `pluginId`.
218
+
219
+ Termix 26.10.5 and older refuse any manifest with `mobile`, so a plugin that declares it needs
220
+ `"engine": { "termix": ">=26.10.6", "api": "^1.13" }`.
179
221
 
180
222
  Capabilities and permissions are different things. A capability (`hosts:read`, with a colon) is what your
181
223
  code may reach. A permission (`hello.use`, with a dot) is what a user may do.
@@ -304,7 +346,7 @@ router.post("/notes", async (req, res) => {
304
346
  - `ctx.rbac.require("manage")` on a single route, `await ctx.rbac.has("manage")` in code. A full id like
305
347
  `hosts.edit` checks a core permission.
306
348
  - Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`.
307
- - Transports: with `ssh:transport`, `ctx.ssh.transports.provide({ claims(hostId), open({ id, ip, port }) })`
349
+ - Transports: with `ssh:transport`, `ctx.ssh.transports.provide({ claims(hostId, host), open(host) })`, where `host` has `id`, `ip`, `port` and on Termix 26.10.6+ also `userId`, `syncId`, `connectionOrigin` and `localOnly`
308
350
  returns a stream the SSH client uses instead of a direct, proxy or jump host connection. Core also serves
309
351
  `/plugin-ws/core/ssh-relay?hostId=` for callers with connect access, so one Termix can relay to another.
310
352
  - Managed hosts: with `hosts:lock`, `ctx.hosts.locks.provide({ isLocked, note, allowManualCreate })` makes
@@ -372,6 +414,70 @@ ctx.schedule.every(60_000, () => refresh(), { jitterMs: 5_000 });
372
414
  - Use a service: declare it in `requires`, then `ctx.services.get<T>("snippets.access")`. It returns
373
415
  nothing while no provider runs, so always handle that.
374
416
 
417
+ ### Operations
418
+
419
+ Declare what your plugin can do in `contributes.operations`, and it shows up in Automations, Fleets, the
420
+ assistant, the CLI, the phone app and the command palette with no other wiring. It also gets per host results,
421
+ dry runs, run history, approvals and drift checks.
422
+
423
+ ```json
424
+ "capabilities": ["operations:provide"],
425
+ "engine": { "termix": ">=26.11.1-0", "api": "^1.13" },
426
+ "contributes": {
427
+ "operations": {
428
+ "actions": [{
429
+ "id": "demo.restart",
430
+ "titleKey": "ops.restart.title",
431
+ "scope": "host",
432
+ "inputs": [{ "key": "name", "type": "string", "labelKey": "ops.restart.name", "required": true }],
433
+ "risk": "change",
434
+ "permission": "use",
435
+ "hostAccess": "edit",
436
+ "dryRun": "describe"
437
+ }]
438
+ }
439
+ }
440
+ ```
441
+
442
+ ```ts
443
+ ctx.operations.register("demo.restart", {
444
+ describe: (input) => `Would restart ${input.name}`,
445
+ run: async (input, { hostId, signal, log }) => {
446
+ return { summary: `Restarted ${input.name}`, changed: true };
447
+ },
448
+ });
449
+ ```
450
+
451
+ - Actions: `scope` is `host`, `resource` or `server`. `risk` is `read`, `change` or `destructive`. Host actions declare
452
+ `hostAccess`. Use `edit` for changes unless people with `connect` already do this today. A destructive action
453
+ must use `edit` or `manage` and needs a confirm.
454
+ - Inputs use the settings field types, plus `required`, `pattern`, `optionsFrom` (a read action fills the choices)
455
+ and type `resource`. Keys you did not declare are refused before your handler runs. `secret` values reach the handler
456
+ but are never stored or shown. Mark private outputs `sensitive`.
457
+ - `dryRun`: `native` calls `run` with `dryRun: true` and nothing may change. `describe` calls `describe` instead.
458
+ - `check` names a read action that proves the result, used for drift. `undo` names the reverse action, and `run` returns
459
+ `undoInput`. `idempotent` lets a rollout retry it. `exclusive` allows one at a time per host. `when` limits it to
460
+ hosts by setting or OS.
461
+ - `surfaces`: the assistant and MCP are off until you set them true. Only do that for actions safe for a model to
462
+ ask for. The rest are on unless you set them false.
463
+ - Resources describe a kind of thing, such as a container: a read action on one host returns `{ rows }` as `data`, and
464
+ core draws the All hosts table and host page section. Attach actions with `scope: "resource"`.
465
+ - Signals are status tiles: a read action returns `{ value, status, label }` as `data`, with status `ok`, `warn`, `fail` or
466
+ `unknown`. `fix` names the action offered when it is not ok. Keep these reads cheap, they run for every host.
467
+ - Events: declare them, then call `ctx.operations.emit(id, payload, { hostId })`.
468
+ - Handlers must watch `signal` and stop when it fires. Throw on failure; a failed run never counts against your
469
+ plugin's error budget. Throw an error with `code = "NEEDS_INTERACTION"` when a host needs a person to sign in first.
470
+ - Handlers have no request and no session. Take what you need from the input and `hostId`.
471
+ - Keep your REST routes. Inside one, call `ctx.operations.run(id, { hostId, input })` so a button press goes through
472
+ the same checks and lands in the same history.
473
+ - To run another plugin's action, add `operations:invoke`, list it in `operations.invokes` and call
474
+ `ctx.operations.invoke(id, { hostId, input, via: "automation" })`. It acts as the current user and rules still apply.
475
+ - `risk` is your plugin's claim. Plugins run in-process, so this is not a sandbox. Actions from plugins not signed as
476
+ official count as `change` when rules decide what needs a person, and stay off the assistant and MCP until an admin
477
+ allows them.
478
+ - Set `engine.termix` to at least `>=26.11.1-0`. Keep `engine.api` as it was unless you import something new from
479
+ `/ui`, or the phone app will ask people to update to see your plugin.
480
+
375
481
  ### Shared packages
376
482
 
377
483
  These come from Termix and are never bundled: `@termix-ssh/plugin-sdk`, `express`, `ssh2`, `ws`, `multer`,
@@ -453,6 +559,23 @@ Other storage: `ctx.kv` for a few values, `ctx.settings` for settings, `ctx.secr
453
559
  `ctx.files.dataDir()` for files. To sync a table to a linked desktop, use `ctx.sync.registerEntity` and
454
560
  `contributes.syncEntities`. The entity name is part of the protocol and never changes.
455
561
 
562
+ A registered table syncs the rows each user owns, both ways. For data you keep for the whole server (a shared
563
+ library, rows other users share), register a read only entity with `table: null`, `readOnly: true` and `load`,
564
+ `write` and `erase` (Termix 26.10.6+). `load(userId)` returns the rows that user should get, each with a
565
+ `syncId`. The desktop keeps them with `write` and drops them with `erase`, and never pushes them back:
566
+
567
+ ```ts
568
+ ctx.sync.registerEntity({
569
+ type: "sharedIcons",
570
+ table: null,
571
+ readOnly: true,
572
+ order: 70,
573
+ load: (userId) => iconsVisibleTo(userId),
574
+ write: (userId, row) => storeCopy(userId, row),
575
+ erase: (userId, syncId) => dropCopy(userId, syncId),
576
+ });
577
+ ```
578
+
456
579
  ## Frontend
457
580
 
458
581
  ```tsx
@@ -903,6 +1026,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
903
1026
  | `@termix-ssh/plugin-sdk/db` | `defineTable` and column builders |
904
1027
  | `@termix-ssh/plugin-sdk/manifest` | `PluginManifest` type and manifest checks |
905
1028
  | `@termix-ssh/plugin-sdk/capabilities` | The capability catalog |
1029
+ | `@termix-ssh/plugin-sdk/operations` | Types, input checks and JSON Schema for declared actions |
906
1030
  | `@termix-ssh/plugin-sdk/settings` | Settings field validation |
907
1031
  | `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` |
908
1032
  | `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` |
@@ -934,6 +1058,8 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
934
1058
  | `desktop:local` | high | Use the user's own computer, desktop app only |
935
1059
  | `hosts:export` | high | Export hosts with their saved logins |
936
1060
  | `notify:hub` | high | Receive every alert (the alerts hub only) |
1061
+ | `operations:invoke` | high | Run other plugins' declared actions, listed in `invokes` |
1062
+ | `operations:approve` | high | Decide which actions need a person |
937
1063
  | `ui:layout` | high | Replace the whole app layout |
938
1064
  | `hosts:write` | medium | Add and edit hosts |
939
1065
  | `hosts:status-provide` | medium | Be the source of host status dots |
@@ -949,6 +1075,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
949
1075
  | `users:read` | medium | Usernames, roles and UI language |
950
1076
  | `events:core` | medium | Listen to core events |
951
1077
  | `notify:send` | medium | Send alerts |
1078
+ | `operations:provide` | medium | Offer declared actions and events |
952
1079
  | `audit:read` | medium | Read the audit log |
953
1080
  | `desktop:window` | medium | Open windows in the desktop app |
954
1081
  | `hosts:read` | low | Read hosts the user can see |
@@ -25,6 +25,43 @@ const FRONTEND_ENTRIES = [
25
25
  "src/frontend/index.mjs",
26
26
  "src/frontend/index.js",
27
27
  ];
28
+ const MOBILE_ENTRIES = [
29
+ "src/mobile/index.tsx",
30
+ "src/mobile/index.ts",
31
+ "src/mobile/index.mjs",
32
+ "src/mobile/index.js",
33
+ ];
34
+
35
+ /** Builds a bundle that runs in a browser against core's shared modules. */
36
+ async function buildBrowserBundle(entry, outfile, outDir) {
37
+ await esbuild.build({
38
+ entryPoints: [entry],
39
+ outfile,
40
+ bundle: true,
41
+ format: "esm",
42
+ platform: "browser",
43
+ target: "es2022",
44
+ jsx: "automatic",
45
+ sourcemap: true,
46
+ logLevel: "warning",
47
+ external: FRONTEND_EXTERNALS,
48
+ // Vite replaces these in the dev server; a plugin bundle has to have
49
+ // them too, or code that reads them throws when the plugin loads.
50
+ define: {
51
+ "process.env.NODE_ENV": '"production"',
52
+ "import.meta.env.DEV": "false",
53
+ "import.meta.env.PROD": "true",
54
+ "import.meta.env.MODE": '"production"',
55
+ "import.meta.env.SSR": "false",
56
+ "import.meta.env":
57
+ '{"DEV":false,"PROD":true,"MODE":"production","SSR":false,"BASE_URL":"/"}',
58
+ },
59
+ plugins: [
60
+ externalRequireInterop(FRONTEND_EXTERNALS),
61
+ staticUrlImports({ outDir }),
62
+ ],
63
+ });
64
+ }
28
65
 
29
66
  /**
30
67
  * A bundled CommonJS package that calls require("react/jsx-runtime") (or any
@@ -192,15 +229,12 @@ export async function buildTailwind(cwd, outDir) {
192
229
  // build() adds to what a compiler already emitted, so each half gets its own.
193
230
  const compiler = () =>
194
231
  compile(input, { base: path.dirname(THEME_CSS), onDependency: () => {} });
195
- const scanner = new Scanner({
196
- sources: [
197
- {
198
- base: path.join(cwd, "src", "frontend"),
199
- pattern: "**/*",
200
- negated: false,
201
- },
202
- ],
203
- });
232
+ // The mobile bundle uses the same stylesheet.
233
+ const sources = ["frontend", "mobile"]
234
+ .map((dir) => path.join(cwd, "src", dir))
235
+ .filter((dir, index) => index === 0 || fs.existsSync(dir))
236
+ .map((base) => ({ base, pattern: "**/*", negated: false }));
237
+ const scanner = new Scanner({ sources });
204
238
  // Flattened and minified the way core's own CSS is, so it does not rely
205
239
  // on native CSS nesting.
206
240
  const emit = async (candidates, options) =>
@@ -297,33 +331,18 @@ export async function build({ cwd }) {
297
331
 
298
332
  const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES);
299
333
  if (frontendEntry) {
300
- await esbuild.build({
301
- entryPoints: [frontendEntry],
302
- outfile: path.join(outDir, "frontend.js"),
303
- bundle: true,
304
- format: "esm",
305
- platform: "browser",
306
- target: "es2022",
307
- jsx: "automatic",
308
- sourcemap: true,
309
- logLevel: "warning",
310
- external: FRONTEND_EXTERNALS,
311
- // Vite replaces these in the dev server; a plugin bundle has to have
312
- // them too, or code that reads them throws when the plugin loads.
313
- define: {
314
- "process.env.NODE_ENV": '"production"',
315
- "import.meta.env.DEV": "false",
316
- "import.meta.env.PROD": "true",
317
- "import.meta.env.MODE": '"production"',
318
- "import.meta.env.SSR": "false",
319
- "import.meta.env":
320
- '{"DEV":false,"PROD":true,"MODE":"production","SSR":false,"BASE_URL":"/"}',
321
- },
322
- plugins: [
323
- externalRequireInterop(FRONTEND_EXTERNALS),
324
- staticUrlImports({ outDir }),
325
- ],
326
- });
334
+ await buildBrowserBundle(
335
+ frontendEntry,
336
+ path.join(outDir, "frontend.js"),
337
+ outDir,
338
+ );
339
+ }
340
+
341
+ // A bundle the mobile app loads in its WebView, next to the frontend one.
342
+ const mobileEntry = resolveEntry(cwd, MOBILE_ENTRIES);
343
+ const mobileOut = manifest.contributes?.mobile?.entry;
344
+ if (mobileEntry && typeof mobileOut === "string") {
345
+ await buildBrowserBundle(mobileEntry, path.join(cwd, mobileOut), outDir);
327
346
  }
328
347
 
329
348
  if (frontendEntry) await buildTailwind(cwd, outDir);
@@ -28,6 +28,10 @@ export async function validate({ cwd }) {
28
28
  // A theme only plugin has no frontend code.
29
29
  ...(isThemeOnly(raw) ? [] : [raw.frontend ?? "dist/frontend.js"]),
30
30
  raw.locales ?? "locales",
31
+ // Built from src/mobile by termix-plugin build.
32
+ ...(typeof raw.contributes?.mobile?.entry === "string"
33
+ ? [raw.contributes.mobile.entry]
34
+ : []),
31
35
  ];
32
36
  for (const rel of referenced) {
33
37
  if (!fs.existsSync(path.join(cwd, rel))) {
package/dist/backend.d.ts CHANGED
@@ -13,6 +13,7 @@ import type { HostSshOptions } from "./ssh-options.js";
13
13
  export type { HostSshOptions } from "./ssh-options.js";
14
14
  import type { PluginManifest, PluginSettingsField } from "./manifest.js";
15
15
  import type { PluginTableDefinition } from "./db.js";
16
+ import type { OperationResult, OperationRisk, OperationSurfaces } from "./operations.js";
16
17
  /**
17
18
  * Thrown when a plugin calls a guarded member without the capability.
18
19
  *
@@ -149,8 +150,24 @@ export interface SyncWriteEvent {
149
150
  export interface SyncEntityRegistration {
150
151
  /** Stable wire name. Never change it: records on both sides match on it. */
151
152
  type: string;
152
- /** The table, as returned by ctx.db.define. */
153
+ /** The table, as returned by ctx.db.define. Null for an entity with `load`. */
153
154
  table: unknown;
155
+ /**
156
+ * Server to desktop only: a desktop never pushes these rows. Required with
157
+ * `load`.
158
+ */
159
+ readOnly?: boolean;
160
+ /**
161
+ * Builds the rows the user gets instead of reading the rows they own in
162
+ * `table`, for data kept for the whole server (a shared library, rows other
163
+ * users share with them). Rows are on the wire: plaintext, each with a
164
+ * syncId. Needs `readOnly: true`, `write` and `erase`.
165
+ */
166
+ load?: (userId: string) => Promise<SyncRow[]>;
167
+ /** Keeps a copy of an inbound row on the desktop, for an entity with `load`. */
168
+ write?: (userId: string, wire: SyncRow) => Promise<void>;
169
+ /** Drops the desktop copy with this syncId, for an entity with `load`. */
170
+ erase?: (userId: string, syncId: string) => Promise<void>;
154
171
  /** Column holding the owning user id. Defaults to "userId". */
155
172
  userColumn?: string;
156
173
  /** Fields stored encrypted with the owner's data key. Plaintext on the wire. */
@@ -391,6 +408,8 @@ export interface PluginSyncHost {
391
408
  ensureCore: () => Promise<void>;
392
409
  onChanged: (listener: () => void) => () => void;
393
410
  onLocalWrite: (listener: () => void) => () => void;
411
+ /** Local ids of the user's own rows of a type, by sync id. Termix 26.10.6+. */
412
+ idsOf?: (type: string, userId: string, syncIds: string[]) => Promise<Record<string, number>>;
394
413
  };
395
414
  /** Rows and bookkeeping of one local user's replica. */
396
415
  replica: {
@@ -1503,13 +1522,20 @@ export interface PluginNetwork {
1503
1522
  * asked for every connection, so it must be quick. `open` returns a stream
1504
1523
  * the SSH client runs over, like a relayed socket.
1505
1524
  */
1525
+ /** The host a transport is asked about. Fields past port come from Termix 26.10.6. */
1526
+ export interface PluginSshTransportHost {
1527
+ id: number;
1528
+ ip: string;
1529
+ port: number;
1530
+ /** Owner of the host row. */
1531
+ userId?: string | null;
1532
+ syncId?: string | null;
1533
+ connectionOrigin?: "local" | "remote" | null;
1534
+ localOnly?: boolean;
1535
+ }
1506
1536
  export interface PluginSshTransportProvider {
1507
- claims: (hostId: number) => boolean;
1508
- open: (host: {
1509
- id: number;
1510
- ip: string;
1511
- port: number;
1512
- }) => Promise<import("node:stream").Duplex>;
1537
+ claims: (hostId: number, host?: PluginSshTransportHost) => boolean;
1538
+ open: (host: PluginSshTransportHost) => Promise<import("node:stream").Duplex>;
1513
1539
  }
1514
1540
  export interface PluginSshTransports {
1515
1541
  /** Needs ssh:transport. Removed on deactivate. */
@@ -2655,6 +2681,110 @@ export interface PluginSummary {
2655
2681
  export interface PluginPlugins {
2656
2682
  list: () => Promise<PluginSummary[]>;
2657
2683
  }
2684
+ /** What a handler gets besides its input. */
2685
+ export interface PluginOperationContext {
2686
+ hostId?: number;
2687
+ /** The row a resource action targets. */
2688
+ resourceKey?: string;
2689
+ /** Null for a background read. */
2690
+ runId: string | null;
2691
+ /** Nothing may change when this is set. */
2692
+ dryRun: boolean;
2693
+ /** Fires when the run is cancelled, times out or the plugin stops. */
2694
+ signal: AbortSignal;
2695
+ log: (line: string) => void;
2696
+ }
2697
+ export interface PluginOperationHandler {
2698
+ run: (input: Record<string, unknown>, context: PluginOperationContext) => Promise<OperationResult> | OperationResult;
2699
+ /** The dry run text for an action declared with dryRun "describe". */
2700
+ describe?: (input: Record<string, unknown>, context: PluginOperationContext) => string | Promise<string>;
2701
+ }
2702
+ export interface PluginOperationInvokeOptions {
2703
+ hostId?: number;
2704
+ resourceKey?: string;
2705
+ input?: Record<string, unknown>;
2706
+ dryRun?: boolean;
2707
+ /** A person agreed to a destructive action. */
2708
+ confirmed?: boolean;
2709
+ idempotencyKey?: string;
2710
+ batchId?: string;
2711
+ /** What this plugin is doing: automation, assistant, mcp, rollout and so on. */
2712
+ via?: string;
2713
+ }
2714
+ export interface PluginOperationOutcome {
2715
+ runId: string | null;
2716
+ status: string;
2717
+ summary?: string;
2718
+ changed?: boolean;
2719
+ data?: unknown;
2720
+ error?: string;
2721
+ errorCode?: string;
2722
+ approvalId?: string;
2723
+ }
2724
+ export interface PluginOperationApprovalInput {
2725
+ actionId: string;
2726
+ pluginId: string;
2727
+ risk: OperationRisk;
2728
+ /** What rules should use. Plugins not signed as official count as change. */
2729
+ effectiveRisk: OperationRisk;
2730
+ origin: {
2731
+ kind: string;
2732
+ pluginId?: string;
2733
+ via?: string;
2734
+ };
2735
+ userId: string;
2736
+ hostId?: number;
2737
+ hostTags?: string[];
2738
+ batchId?: string;
2739
+ }
2740
+ export interface PluginOperationApprovalDecision {
2741
+ decision: "allow" | "approve" | "deny";
2742
+ reason?: string;
2743
+ allowSelf?: boolean;
2744
+ expiresInMinutes?: number;
2745
+ }
2746
+ export interface PluginOperationEvent {
2747
+ id: string;
2748
+ eventId: string;
2749
+ pluginId: string;
2750
+ hostId?: number;
2751
+ severity?: string;
2752
+ payload?: unknown;
2753
+ runId?: string;
2754
+ depth: number;
2755
+ at: string;
2756
+ }
2757
+ /**
2758
+ * Declared actions and events. See contributes.operations in the manifest.
2759
+ * register and emit need operations:provide. invoke, list and onEvent need
2760
+ * operations:invoke, and invoke only reaches ids in the manifest's invokes list.
2761
+ */
2762
+ export interface PluginOperations {
2763
+ register: (id: string, handler: PluginOperationHandler) => void;
2764
+ /**
2765
+ * Runs one of this plugin's own actions from its route, so a button press
2766
+ * goes through the same checks and lands in the same history.
2767
+ */
2768
+ run: (actionId: string, options?: PluginOperationInvokeOptions) => Promise<PluginOperationOutcome>;
2769
+ invoke: (actionId: string, options?: PluginOperationInvokeOptions) => Promise<PluginOperationOutcome>;
2770
+ list: (options?: {
2771
+ lang?: string;
2772
+ surface?: keyof OperationSurfaces;
2773
+ }) => Promise<unknown>;
2774
+ emit: (eventId: string, payload?: unknown, options?: {
2775
+ hostId?: number;
2776
+ }) => Promise<void>;
2777
+ onEvent: (subscription: {
2778
+ pattern: string;
2779
+ handler: (event: PluginOperationEvent) => void | Promise<void>;
2780
+ userId?: string;
2781
+ cooldownMs?: number;
2782
+ }) => () => void;
2783
+ /** Rules for what needs a person. Needs operations:approve. */
2784
+ approvals: {
2785
+ provide: (provider: (input: PluginOperationApprovalInput) => PluginOperationApprovalDecision | null | undefined | Promise<PluginOperationApprovalDecision | null | undefined>) => () => void;
2786
+ };
2787
+ }
2658
2788
  export interface PluginContext {
2659
2789
  readonly pluginId: string;
2660
2790
  readonly manifest: PluginManifest;
@@ -2695,6 +2825,8 @@ export interface PluginContext {
2695
2825
  readonly schedule: PluginSchedule;
2696
2826
  /** Core's notification channels. Needs notify:send. */
2697
2827
  readonly notify: PluginNotify;
2828
+ /** Declared actions and events, reachable from every surface. */
2829
+ readonly operations: PluginOperations;
2698
2830
  /** Outbound HTTP through core's SSRF guard. Needs network:outbound. */
2699
2831
  readonly fetch: PluginFetch;
2700
2832
  /** Plain reachability checks. Needs network:probe. */
@@ -38,6 +38,8 @@ export const CAPABILITY_CATALOG = [
38
38
  entry("ssh:transport", "high"),
39
39
  entry("device:serial", "high"),
40
40
  entry("notify:hub", "high"),
41
+ entry("operations:invoke", "high"),
42
+ entry("operations:approve", "high"),
41
43
  entry("ui:layout", "high"),
42
44
  entry("hosts:write", "medium"),
43
45
  entry("hosts:export", "high"),
@@ -53,6 +55,7 @@ export const CAPABILITY_CATALOG = [
53
55
  entry("users:read", "medium"),
54
56
  entry("events:core", "medium"),
55
57
  entry("notify:send", "medium"),
58
+ entry("operations:provide", "medium"),
56
59
  entry("audit:read", "medium"),
57
60
  entry("desktop:window", "medium"),
58
61
  entry("desktop:local", "high"),
@@ -185,6 +185,8 @@ export interface ShellApi {
185
185
  * user reviews and saves it. Absent in some shells.
186
186
  */
187
187
  openHostEditor?: (draft?: HostDraft) => void;
188
+ /** Opens the editor of a saved host. Termix 26.10.6+, absent in some shells. */
189
+ editHost?: (hostId: number) => void;
188
190
  /** Opens a rail view in the left sidebar. */
189
191
  openRailView: (id: string) => void;
190
192
  /** Closes a rail view wherever it is shown. */
@@ -412,6 +414,11 @@ export interface RailItemContribution {
412
414
  * Zero or nothing shows no badge.
413
415
  */
414
416
  useBadge?: () => number | null | undefined;
417
+ /**
418
+ * A hook for a small status dot on the icon: "ok" (green), "warning" or
419
+ * "error". Nothing shows no dot. Layouts from Termix 26.10.6 draw it.
420
+ */
421
+ useStatus?: () => "ok" | "warning" | "error" | null | undefined;
415
422
  }
416
423
  export interface PanelProps {
417
424
  /**