mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
fix: finish 2.9.0 plugin moves and get every check green
This commit is contained in:
1 parent
8c1e02af89
commit
6489b62ece
335 files changed
+29878
-17749
No files matched your search
@@ -342,13 +342,30 @@ attached after the sanitizers, because the connect-level projection of a shared
|
||||
host reduces it to an allowlist.
|
||||
|
||||
`ctx.settings` is `get/set`, `getUser/setUser`, `getHost/setHost`, `getAll`,
|
||||
`onChange` (disposed with the plugin) and `readCore`. **Reading and writing a
|
||||
`onChange` (disposed with the plugin), `onValidate`, `listHostValues` and
|
||||
`readCore`. **Reading and writing a
|
||||
plugin's own settings needs no capability**: the manifest already declares
|
||||
every field, and a plugin that had to ask permission to read its own
|
||||
configuration would be useless. Only `readCore`, which reaches outside the
|
||||
plugin's namespace, is gated on `settings:read-core`, and it serves a short
|
||||
allowlist (`CORE_SETTINGS_ALLOWLIST`) rather than the whole settings table.
|
||||
|
||||
**D0** added three pieces:
|
||||
|
||||
- `onValidate(scope, validator)` checks a save from the settings screen or the
|
||||
host editor before anything is written. The validator gets every value in
|
||||
the save (and the host id for host scope) and returns field key to message
|
||||
for what is wrong. ssh-terminal checks that its image paths are absolute,
|
||||
step-ca that its CA URL is https and its fingerprint a SHA-256 digest.
|
||||
- `listHostValues(key)` lists every host that saved a value for one of the
|
||||
plugin's host fields, across all users, with the host's owner, for a
|
||||
background job that must know which hosts to act on and as whom before any
|
||||
request exists (proxmox's auto-sync). It needs `hosts:read` and refuses a
|
||||
secret field.
|
||||
- Host fields take `enableDefault` (on the host section), `defaultFrom`,
|
||||
`shareRead` and `ownerOnly`; see the manifest reference. A host settings
|
||||
save also bumps the host's `updated_at`, so remote sync carries it.
|
||||
|
||||
### 8. UI
|
||||
|
||||
A plugin frontend is a built ESM bundle, `dist/frontend.js`, exporting
|
||||
@@ -606,32 +623,56 @@ dark), shared by the SSH and local terminals, the docker console and serial
|
||||
from `src/ui/lib/terminal-look/`; keyboard handling shared with the shell
|
||||
(`findMatchingKeybinding`, `globalShortcutHandler`, `isTabJumpHotkey`); the
|
||||
connection helpers (`isElectron`, `resolveConnectionOrigin`, `pluginWsUrl`,
|
||||
`hydrateLocalSharedHostAuth`, `useConnectionDefaults`); and a few core APIs
|
||||
(`logActivity`, `getHostPassword`, `patchOpenTab`, `getUserPreferences`,
|
||||
`parseCustomKeybindings`, `setHostAutoTmux`, `getCookie`). The APIs are a
|
||||
stopgap in the right place: D1 turns them into typed bridge members.
|
||||
`hydrateLocalSharedHostAuth`, `useConnectionDefaults`). **D0** moved the core
|
||||
calls a plugin makes for the signed-in user onto the typed host bridge, as
|
||||
`@termix/plugin-sdk/frontend` functions: `logActivity`, `getHostPassword`,
|
||||
`patchOpenTab`, `getCustomKeybindings`, `setHostAutoTmux` and
|
||||
`getClientPreference`.
|
||||
**B16** added `LineChart`, `useAdaptivePolling`, `useAreaPreferences` (the
|
||||
Appearance density and chart options), and the homepage widget pieces
|
||||
`WidgetTitle` and `runVisibleInterval` (moved to `src/ui/lib/` in **B19**,
|
||||
once a plugin rather than a core feature became their biggest caller), for
|
||||
host-metrics's cards and its metrics chart widget. **B15** added `useUiPreferencesContext`, whose `setOverride` the
|
||||
Docker manager uses to remember the card or table layout the user picked.
|
||||
The `docker` and `hostMetrics` areas are still declared in core's
|
||||
`types/ui-preferences.ts`; a plugin cannot contribute its own area yet. The frontend SDK gained `useHostStatus(hostId)`: core's
|
||||
status for a host, from the shell's own polling, or null outside the shell.
|
||||
**B14** added `buildOriginWsUrl`, `getBasePath`, `resolveRemoteHostId` and the
|
||||
`ConnectionStage` type, for remote desktop's display socket and its remote
|
||||
host lookup. **B17** added `NotificationChannelDialog` and the channel API
|
||||
(`getNotificationChannels`, `deleteNotificationChannel`,
|
||||
`testNotificationChannel`, the `NotificationChannel` type): channels are core,
|
||||
so a plugin that manages them shows core's dialog. `useConnectionDefaults` now carries terminal defaults only; the
|
||||
RDP half became remote desktop's user settings. Publishing its
|
||||
`.d.ts` for plugins outside this repo is a follow-up for the repo split.
|
||||
**B19** added `PluginViewPlaceholder`, for the homepage plugin's widget
|
||||
shell to render in place of a widget type whose owning plugin (docker,
|
||||
tunnels, host-metrics, file-manager) is off, and moved `WidgetTitle` and
|
||||
`runVisibleInterval` here from the deleted homepage feature directory
|
||||
(above), unchanged for their existing callers.
|
||||
host-metrics's cards and its metrics chart widget.
|
||||
|
||||
**D0** gave plugins their own Appearance area. A manifest's
|
||||
`contributes.uiPresets` names a value per interface preset (simple, balanced,
|
||||
advanced), and `usePluginUiPreferences()` returns the one for the user's
|
||||
level with their changes on top, plus a `set` that saves a change. Changes
|
||||
are stored in the UI preferences under `plugin:<id>` and listed, with the
|
||||
plugin's name, where Appearance lets a user revert them. Docker (list or
|
||||
detail view, card or table layout) and host-metrics (column count) use it;
|
||||
version 2 of the UI preferences moved their 2.8 `docker` and `hostMetrics`
|
||||
overrides across. The terminal toolbar density and the file manager's view
|
||||
mode are still preset-seeded localStorage keys core writes, as in 2.8.
|
||||
|
||||
**D0** also added:
|
||||
|
||||
- `usePluginApiFor(origin)`: the plugin's client for a resolved connection
|
||||
origin, as `app.apiFor` but usable in a component. The terminal toolbar's
|
||||
metrics bars use it for a host on the desktop app's remote server, and
|
||||
tunnels merges the remote server's statuses through `app.apiFor("remote")`.
|
||||
- `app.onSettingsChanged(listener)`: fires after the settings screen or host
|
||||
editor saved one of the plugin's settings, instead of polling on focus.
|
||||
- A `tab.menu` slot: entries in a tab's right-click menu, invoked with the
|
||||
tab's surface handle; `when` sees `{ tab, handle }`. Session sharing adds its
|
||||
share entry there, for a terminal whose toolbar is hidden.
|
||||
- The terminal toolbar lists every plugin's `open` host actions for its host
|
||||
(tmux monitor, docker, tunnels, host metrics) as quick links, so no plugin
|
||||
has to contribute a toolbar button to be reachable from a terminal. The frontend SDK gained `useHostStatus(hostId)`: core's
|
||||
status for a host, from the shell's own polling, or null outside the shell.
|
||||
**B14** added `buildOriginWsUrl`, `getBasePath`, `resolveRemoteHostId` and the
|
||||
`ConnectionStage` type, for remote desktop's display socket and its remote
|
||||
host lookup. **B17** added `NotificationChannelDialog` and the channel API
|
||||
(`getNotificationChannels`, `deleteNotificationChannel`,
|
||||
`testNotificationChannel`, the `NotificationChannel` type): channels are core,
|
||||
so a plugin that manages them shows core's dialog. `useConnectionDefaults` now carries terminal defaults only; the
|
||||
RDP half became remote desktop's user settings. Publishing its
|
||||
`.d.ts` for plugins outside this repo is a follow-up for the repo split.
|
||||
**B19** added `PluginViewPlaceholder`, for the homepage plugin's widget
|
||||
shell to render in place of a widget type whose owning plugin (docker,
|
||||
tunnels, host-metrics, file-manager) is off, and moved `WidgetTitle` and
|
||||
`runVisibleInterval` here from the deleted homepage feature directory
|
||||
(above), unchanged for their existing callers.
|
||||
|
||||
#### Strings
|
||||
|
||||
@@ -686,6 +727,30 @@ register one; remote desktop's (**B14**) also reads a Termix export's
|
||||
carry every plugin's host settings (secrets redacted) under `pluginSettings`,
|
||||
which is what makes the round trip work without core naming a plugin.
|
||||
|
||||
**D0** finished the generic host settings paths, all keyed off manifests:
|
||||
|
||||
- Import writes an export's `pluginSettings` (non-secret declared fields,
|
||||
validated like any save) before the plugin's normalizer runs. The raw
|
||||
SQLite export carries host-scope `plugin_settings` rows in its own table,
|
||||
and its import reads them back, plus the 2.8 columns through the
|
||||
normalizers.
|
||||
- The Hosts panel's feature filter and the bulk enable and disable menu list
|
||||
every plugin whose host section has an `enableKey`; the bulk route takes
|
||||
`pluginEnable: { [pluginId]: boolean }`.
|
||||
- Remote sync carries a host's non-secret plugin values. A value that is a
|
||||
local row id (vault's `profileId`) is translated with
|
||||
`ctx.registry.provide("<id>.hostSettingsSync", { exportValue, importValue })`.
|
||||
- `ctx.registry.provide("<id>.hostPayloadLegacy", fn)` puts fields back on
|
||||
the host payload in their 2.8 shape for clients outside Termix.
|
||||
host-metrics uses it for the `statsConfig` Termix-Mobile reads; remove it
|
||||
once the mobile app reads `pluginSettings` and `GET /host/status`.
|
||||
- A host field's `shareRead` hides it from shared recipients below that
|
||||
level, and `ownerOnly` keeps a shared editor from changing it. Remote
|
||||
desktop's `guacamoleConfig` (which can hold a gateway password) is
|
||||
`shareRead: "edit"`, vault's `profileId` is `ownerOnly`.
|
||||
- The bulk import accepts any auth type core or a plugin registers or
|
||||
declares in `contributes.auth.sshAuthTypes`.
|
||||
|
||||
**B9**'s terminal provides `sessions.live` (live SSH sessions: look up, end,
|
||||
remove a share's participants, hand a room share's control over, subscribe to
|
||||
output, write) and `terminal.history` (the actor's command history, which the
|
||||
@@ -758,9 +823,9 @@ adopts `session_shares`, `session_share_participants`, `collab_rooms` and
|
||||
`collab_room_members`, and reaches sessions only through `sessions.live`. Its
|
||||
two public routes, `/resolve/:linkToken` and `/guest/:token`, check the token
|
||||
themselves and are rate limited per IP. The share button is a contribution to
|
||||
`terminal.toolbar` and `remote-desktop.toolbar`; the tab bar's share entry and
|
||||
the `shareable`, `canShare`, `openShareModal` and `getShareTarget` members of
|
||||
`TabOptions`/`TabHandle` are gone. Core's active connections list gets the
|
||||
`terminal.toolbar` and `remote-desktop.toolbar`, and (**D0**) to the tab
|
||||
bar's `tab.menu` slot, which reads the terminal handle's `getShareTarget`. The
|
||||
`shareable`, `canShare` and `openShareModal` members of `TabOptions` are gone. Core's active connections list gets the
|
||||
sessions shared with a user from the `sessions.sharedWithMe` action.
|
||||
|
||||
**Automations (B17)** imports nothing from core. It reaches snippets
|
||||
@@ -949,7 +1014,9 @@ export default pluginVitestConfig(import.meta.url);
|
||||
|
||||
The preset supplies both projects, the shared setup file
|
||||
(`@termix/plugin-sdk/testing/setup`, which core's `vitest.setup.ts` re-exports
|
||||
so there is one copy) and the `@/` alias the frontends still use.
|
||||
so there is one copy) and the `@/` alias the frontends still use. The setup
|
||||
also keeps a test server started on port 0 off the ports `fetch` refuses,
|
||||
which Windows can hand out.
|
||||
|
||||
`npm run test` runs core only, `npm run test:plugins` runs every plugin and
|
||||
`npm run test:all` runs both. Core tests (`src/backend/tests`, `src/ui/tests`)
|
||||
@@ -1500,6 +1567,46 @@ dropping one in the same drizzle-kit run makes it ask whether that is a
|
||||
rename, which it cannot do without a terminal, so B16 generated two
|
||||
migrations: the added columns first, then the no-op drop.
|
||||
|
||||
**D0** dropped the rest this way (the file manager, tunnels, web endpoint,
|
||||
terminal, tmux monitor, session sharing and session recording columns and the
|
||||
dead `show_*_in_sidebar` ones), rewrote the two proxmox drops that really ran
|
||||
`DROP COLUMN` on Postgres and MySQL, and moved every copy onto
|
||||
`selectLegacyRows`, which reads a column schema.ts no longer declares on all
|
||||
three engines and answers nothing on a database that never had it. A 2.8
|
||||
boolean read raw goes through `legacyFlag`, since SQLite and MySQL hand back
|
||||
0 and 1. The copies live in `src/backend/plugins/boot-migrations.ts`
|
||||
(`runPluginDataMigrations`), which the starter runs after the plugins
|
||||
activate and which runs again when an admin enables a plugin without a
|
||||
restart. The core half of boot (keys, auth, core data migrations) is
|
||||
`runCoreBootMigrations` in `src/backend/boot.ts`; the boot integration tests
|
||||
share both with the starter.
|
||||
|
||||
A host switch whose 2.8 column defaulted to on declares `enableDefault: true`
|
||||
(file manager, terminal), because a copy only writes the hosts that changed
|
||||
it, and every other host has to keep reading on.
|
||||
|
||||
**Left for 3.0.0.** These stay in 2.9.0 so a downgrade still works, and go
|
||||
once every install has booted it:
|
||||
|
||||
- The moved `ssh_data` columns (every plugin host column above, the proxmox,
|
||||
remote desktop, docker, warpgate, vault and stats columns) and
|
||||
`user_preferences.rdp_defaults`.
|
||||
- `users.totp_secret`, `totp_enabled` and `totp_backup_codes`; `users.is_oidc`,
|
||||
`oidc_identifier`, `sso_provider_id` and the 2.8 per-user OIDC columns, and
|
||||
`sessions.sso_provider_id`, `oidc_sub` and `oidc_sid`, once nothing needs the
|
||||
2.8 shape.
|
||||
- The legacy settings rows the plugins copied: the terminal's
|
||||
(`terminal_session_*`, `command_history_enabled`, `touch_input_settings`,
|
||||
`terminal_image_*`), `session_sharing_globally_enabled`, `guac_enabled`,
|
||||
`guac_url`, the `step_ca_*` rows and `acme_ssl_settings`.
|
||||
- `DATA_DIR/.opk`, `DATA_DIR/certbot` and `DATA_DIR/acme-webroot`.
|
||||
- The 2.8 URLs kept for identity providers and Termix-Mobile:
|
||||
`auth-compat-routes.ts` (`/users/oidc/*`, `/users/ldap/login` and the rest),
|
||||
`host-compat-routes.ts` (`/host/opkssh-callback`, `/host/step-ca-callback`)
|
||||
and the `/termix-id/u/` and `/vault` redirects.
|
||||
- The `@termix/plugin-sdk/ui` typings for plugins built outside this repo,
|
||||
which arrive with the split into separate repositories.
|
||||
|
||||
---
|
||||
|
||||
## Manifest v2 reference
|
||||
@@ -1796,7 +1903,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
|
||||
| `ctx.credentials.registerSecretResolver` | `auth:provide` | **B20** |
|
||||
| `ctx.credentials.listSshKeys` | `credentials:use` | **C6** |
|
||||
| `ctx.credentials.createSshKey` | `credentials:write` | **C6** |
|
||||
| `ctx.audit.record` | none, the actor is the runtime's | **B9** |
|
||||
| `ctx.audit.record` | none, the actor is the runtime's | **B9**, request **D0** |
|
||||
| `ctx.schedule.every` / `.after` | none | **B16** |
|
||||
| `ctx.fetch` | `network:outbound` | **B17**, signal **B18**, tls **C4** |
|
||||
| `ctx.process.run` / `.ensureBinary` | `process:spawn` (+ `network:outbound` to download) | **C3** |
|
||||
@@ -2322,7 +2429,7 @@ free. A **community plugin installed from a tarball has none of this**: there
|
||||
is no repo-root `npm install` to hoist its dependency into, no shared
|
||||
`node_modules` its bundle can walk up to. Shipping a native dependency in a
|
||||
plugin nobody's build system already vendors is an open problem this step
|
||||
does not solve - see `packages/plugin-sdk/FINISH-LIST.md`. A community plugin
|
||||
does not solve, and it waits for the split into separate repositories. A community plugin
|
||||
author who hits this today has three honest options, worst to best: ask the
|
||||
person installing it to `npm install` the native package into the server's
|
||||
own `node_modules` by hand (fragile, easy to get wrong on upgrade); avoid a
|
||||
@@ -2675,8 +2782,8 @@ What the lint fence enforces today, in `eslint.config.mjs`:
|
||||
| Core importing a plugin backend | **Error** | 0 | - |
|
||||
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
|
||||
| The shell importing plugin code | **Error** | 0 | - |
|
||||
| A plugin frontend importing core through `@/` | Warning | 53 files | D1 |
|
||||
| A plugin importing core by relative path | Warning | 4 files | D1 |
|
||||
| A plugin frontend importing core through `@/` | Warning | 51 files | D1 |
|
||||
| A plugin importing core by relative path | Warning | 3 files | D1 |
|
||||
| A plugin importing another plugin's source | Warning | 0 files | - |
|
||||
|
||||
A warning does not fail a build, so the counts are held by
|
||||
|
||||
@@ -1,559 +1,7 @@
|
||||
# Finish list
|
||||
|
||||
Running list of things intentionally left unfinished by a Phase B/C step,
|
||||
so D0 (or an earlier owner named below) can pick them up. Remove a line once
|
||||
it's done.
|
||||
|
||||
- **B3 (tailscale):** `ManagerCardShell`/`ManagerSearch`-equivalent presentation
|
||||
is duplicated as `TailscaleManagerShell`/`TailscaleManagerSearch` in
|
||||
`plugins/tailscale/src/frontend/TailscaleManagerShell.tsx` rather than
|
||||
shared with host-metrics's originals. Promote a shared version into
|
||||
`@termix/plugin-sdk/ui` once a second manager-card plugin needs one, per the
|
||||
contract's "add to the SDK when a second plugin needs it" rule. Owner: D1.
|
||||
- **B5 (proxmox):** `runAdaptivePolling` and `cn` are duplicated in
|
||||
`plugins/proxmox/src/frontend/stats/` rather than shared with core's
|
||||
`src/ui/lib/` originals. `useConnectionRetry` (the third duplicate B5 left
|
||||
here) is resolved: **B6** promoted it into `@termix/plugin-sdk/frontend`
|
||||
once file-manager became its fourth caller (after proxmox, docker,
|
||||
host-metrics, remote-desktop) and switched proxmox's own copy over too.
|
||||
`runAdaptivePolling` and `cn` are exported from `@termix/plugin-sdk/ui`
|
||||
now (B9), and host-metrics uses them there (B16); only proxmox's copies are
|
||||
left to switch. Owner: D1.
|
||||
- **B5 (proxmox):** `runDueProxmoxAutoSyncs` in
|
||||
`plugins/proxmox/src/backend/routes.ts` still reaches
|
||||
`createCurrentPluginSettingsRepository`/`createCurrentHostRepository` by
|
||||
relative import instead of `ctx`, because it needs to scan every host's
|
||||
Proxmox settings across every user before it knows which actor to run each
|
||||
sync as, and neither `ctx.settings` nor `ctx.hosts` expose a cross-user
|
||||
bulk read today. A `ctx.settings.listHostsWithKey` or similar SDK addition
|
||||
would close this, if a second plugin's background scan ever needs the same
|
||||
shape. Owner: D1.
|
||||
- **B6 (file-manager):** `plugins/file-manager/src/frontend/components/PdfPreview.tsx`
|
||||
runs pdfjs without a worker (main-thread parsing) because `termix-plugin
|
||||
build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
|
||||
build resolves a `?url` import to a hashed `/assets/` file. Fine for the
|
||||
small files this preview usually opens; restoring a real worker needs the
|
||||
CLI (`packages/plugin-sdk/cli/commands/build.mjs`) to gain a way to emit an
|
||||
extra file into `dist/` and have the frontend reference it by its served
|
||||
`/plugin-assets/<id>/` path. Owner: D1, or whichever step first needs a
|
||||
worker or other static asset from a plugin bundle.
|
||||
- **B6 (file-manager):** `transfer-tuning.ts`'s adaptive-tuning profile still
|
||||
persists to a JSON file under `DATA_DIR` rather than `ctx.kv`, and
|
||||
`HostFilesTab.tsx`'s `t()` calls plus the manifest's `contributes.settings.host`
|
||||
`labelKey`s deliberately point at two different key paths (`hosts.*` and
|
||||
`settings.host.*`) for the same three fields, both carrying the same English
|
||||
text - a minor duplication from keeping the host editor's exact existing
|
||||
section verbatim (`registerHostEditorSection`) instead of switching it to
|
||||
the schema-driven settings form. Neither blocks anything; flagging for
|
||||
whoever next touches this plugin's settings UI.
|
||||
- **B6 (file-manager):** `TabContext.tsx`'s `computeUniqueTitle` compares
|
||||
`tabType === "file_manager"` (an activity-log type string, not a tab type -
|
||||
the real tab type has always been `"files"`), so that branch has never
|
||||
matched and silently falls through to the terminal title. Pre-existing,
|
||||
unrelated to the conversion; noted rather than fixed per the lean-mode
|
||||
rules. Owner: whoever next touches tab title logic.
|
||||
- **B6 (file-manager):** `enable_file_manager`, `default_path` and
|
||||
`scp_legacy` are still live `ssh_data` columns; only the copy-into-plugin-
|
||||
settings migration shipped (`file-manager-settings-migration.ts`). Every
|
||||
core read site the earlier exploration found (`host.ts`,
|
||||
`host-normalizers.ts`, `host-bulk-routes.ts`, `credentials.ts`,
|
||||
`host-internal-routes.ts`, `database.ts`'s encrypt/decrypt round trip,
|
||||
`plugins/host-view.ts`, `ctx-hosts.ts`, plus the frontend's
|
||||
`HostEditorData.ts`/`HostManagerData.ts`/`SidebarTree.tsx`/quick-connect
|
||||
mapping) still reads the column directly rather than
|
||||
`ctx.settings.getHost`. Switching every one of those and then dropping the
|
||||
column in lockstep (`schema.ts`, `db/index.ts`, a drizzle migration per
|
||||
dialect, `schema:generate`) is real, separate work this step didn't
|
||||
attempt, since none of those reads block the plugin conversion itself.
|
||||
Owner: a dedicated follow-up step, or D0.
|
||||
- **B7 (tunnels):** `enable_tunnel` and `tunnel_connections` are still live
|
||||
`ssh_data` columns; only the copy-into-plugin-settings migration shipped
|
||||
(`tunnels-settings-migration.ts`), and the plugin reads its host settings
|
||||
now. Core still carries the columns through the host routes, the
|
||||
encrypt/decrypt round trip, `SidebarTree.tsx`'s host duplicate payload, the
|
||||
quick-connect mapping and the `Host`/`SSHHost` types, none of which the
|
||||
plugin reads any more. Dropping them in lockstep (`schema.ts`, `db/index.ts`,
|
||||
a drizzle migration per dialect, `schema:generate`) is the same follow-up the
|
||||
B6 file-manager line above describes. Owner: a dedicated follow-up step, or D0.
|
||||
- **B7 (tunnels):** host export/import, host duplicate, the Hosts panel
|
||||
feature filter and the bulk enable/disable menu all work off `ssh_data`
|
||||
columns, so none of them can see a plugin's host settings. For tunnels the
|
||||
"Tunnel" filter and the bulk enable/disable entries were removed, and the
|
||||
export's "Tunnels" group became "Proxy" (SOCKS5 fields only) since
|
||||
`tunnelConnections` there is now stale. **B15** removed the "Docker" filter
|
||||
and the bulk Docker toggles the same way. Bringing these back needs a way for
|
||||
core to read and write host-scope plugin settings generically (keyed off
|
||||
`contributes.settings.host` or `hostCapability`) rather than per column.
|
||||
Owner: D1.
|
||||
- **B7 (tunnels):** the tunnel tab no longer writes a recent-activity entry:
|
||||
`logActivity` is a core `@/main-axios` call and the plugin imports nothing
|
||||
from `@/`. Old "tunnel" entries still reopen the tab through the plugin's
|
||||
`activityTypes`. An `app.logActivity` (or equivalent bridge member) would
|
||||
restore it for this plugin and let file-manager drop its own
|
||||
`@/main-axios` import for it too (host-metrics and docker use the
|
||||
`logActivity` the ui entry exports). Owner: D1.
|
||||
- **B7 (tunnels):** on the desktop app, tunnel statuses from a connected
|
||||
remote server are no longer merged into the tab: the old code polled the
|
||||
remote tunnel service through `getRemoteTunnelApi()`, and the SDK has no
|
||||
remote-origin plugin API client. `subscribeTunnelStatuses` already takes a
|
||||
`fetchRemote` and merges with local statuses winning, so it only needs a
|
||||
client for `/plugin-api/tunnels/status` on the remote server. Owner: D1.
|
||||
- **B8 (web-endpoint):** `enable_web_ui` and `web_ui_config` are still live
|
||||
`ssh_data` columns; only the copy-into-plugin-settings migration shipped
|
||||
(`web-endpoint-settings-migration.ts`). Unlike B6/B7, this step DID convert
|
||||
every core read/write of those two fields (`host.ts`'s create/update and
|
||||
export routes, `host-normalizers.ts`, `host-bulk-routes.ts`,
|
||||
`database.ts`'s encrypt/decrypt round trip is the one exception - it copies
|
||||
whatever is already in the column for the raw DB export/backup tool, which
|
||||
needs no change since nothing reads that copy through `pluginSettings`).
|
||||
Dropping the columns in lockstep (`schema.ts`, `db/index.ts`, a drizzle
|
||||
migration per dialect, `schema:generate`) is the only remaining piece.
|
||||
Owner: a dedicated follow-up step, or D0.
|
||||
- **B8 (web-endpoint):** added the first generic cross-plugin hook for core
|
||||
routes that touch host-scope plugin settings without importing a plugin:
|
||||
`ctx.registry.provide("<id>.hostImportNormalizer", fn)` plus
|
||||
`applyPluginHostImportSettings` in `host-plugin-settings.ts`, called from
|
||||
`host-bulk-routes.ts`'s Termix-JSON import path. Only import validation is
|
||||
covered; B7's TODO above about the Hosts panel feature filter and the bulk
|
||||
enable/disable menu (`hostCapability`-driven, not import-driven) is
|
||||
unrelated and still open. Owner: D1 for extending the same pattern there.
|
||||
- **B8 (web-endpoint):** noticed while sanity-checking with `npm run lint`
|
||||
(not required by this step): `eslint.config.mjs`'s
|
||||
`globalIgnores(["dist", ...])` only matches a top-level `dist/` folder, not
|
||||
nested `packages/*/dist` or `plugins/*/dist`, so `npm run lint` fails on
|
||||
bundled output repo-wide with "Definition for rule X was not found"
|
||||
errors. Pre-existing, not introduced by this or any single plugin step.
|
||||
Owner: whoever next needs a clean `npm run lint`, or D0.
|
||||
- **B8 (web-endpoint):** the isolated-window Electron IPC bridge
|
||||
(`src/backend/utils/electron-ipc-bridge.ts`, `ctx.desktop.openIsolatedWindow`)
|
||||
is a single request/response channel keyed by a random id with one
|
||||
registered backend-request handler (`open-isolated-window`) in
|
||||
`electron/main.cjs`. Fine for today's one caller; if a second plugin needs
|
||||
to ask Electron's main process for something, extend
|
||||
`BACKEND_REQUEST_HANDLERS` there rather than building a parallel channel.
|
||||
No owner needed unless a second caller appears.
|
||||
- **B9 (ssh-terminal), for D2:** the terminal socket is a public route with
|
||||
`optionalAuth`, because share-link and room guests authenticate inside the
|
||||
handler with their token. Guest auth itself is unchanged; review it with the
|
||||
rest of the public routes.
|
||||
- **B9 (ssh-terminal):** recording has no provider yet. The terminal calls the
|
||||
optional `recordings.writer` v1 `open(meta)` and gets a sink with
|
||||
`append(chunk)` (one call per 300 ms batch, the first starting with the
|
||||
asciicast header), `persist(summary)` and `discard()`. Without it nothing is
|
||||
recorded and no `session_recordings` row is written. Owner: B13, which
|
||||
writes the file under `DATA_DIR/session_logs/<user>/<session>.cast` (mkdir
|
||||
and writeFile on the first append, appendFile after, never per chunk, see
|
||||
issue #1049) and creates or updates the row as the old session manager did.
|
||||
- **B9 (ssh-terminal):** `enable_terminal`, `enable_terminal_toolbar` and
|
||||
`enable_command_history` are still live `ssh_data` columns. The copy into
|
||||
ssh-terminal host settings shipped (`ssh-terminal-settings-migration.ts`)
|
||||
and the terminal reads its settings, but core host routes, types, the
|
||||
export/import sample and `HostEditorData` still carry the columns.
|
||||
**B14** gave remote desktop its own `enableToolbar` host setting, copied
|
||||
from `enable_terminal_toolbar`, so nothing else holds the column up. Drop
|
||||
all three the B14 way (see "Order at boot" in ARCHITECTURE.md). Owner: D0.
|
||||
- **B9 (ssh-terminal):** the legacy `settings` rows for the terminal
|
||||
(`terminal_session_timeout_minutes`, `terminal_session_persistence_enabled`,
|
||||
`command_history_enabled`, `touch_input_settings`, `terminal_image_*`) are
|
||||
left in place for a release after the move; nothing reads them. Delete them
|
||||
in 3.0.0. `sessionPersistence` is carried over but, as before, nothing acts
|
||||
on it. Owner: D0 to decide.
|
||||
- **B9 (ssh-terminal):** the image storage paths used to be rejected at save
|
||||
when not absolute; the schema-driven settings form accepts any string and
|
||||
the resolver ignores a bad one with a warning. A custom field or a validate
|
||||
hook on plugin settings would restore the save-time error. Owner: D1.
|
||||
- **B9 (ssh-terminal):** `@termix/plugin-sdk/ui` now re-exports a handful of
|
||||
core APIs the terminal needs (`logActivity`, `getHostPassword`,
|
||||
`patchOpenTab`, `getUserPreferences`, `parseCustomKeybindings`,
|
||||
`setHostAutoTmux`, `getCookie`) and the connection helpers. They belong on
|
||||
the typed host bridge, which is also where B7's `app.logActivity` line
|
||||
above lands. Owner: D1.
|
||||
- **B9 (host-metrics):** the terminal toolbar's CPU/memory/disk bars
|
||||
(`TerminalMetricsStatus`) poll the local backend only; on the desktop app a
|
||||
host that lives on a connected remote server shows no bars. Same fix as the
|
||||
B7 remote-origin line above. Owner: D1.
|
||||
- **B9:** the Hosts panel's "Terminal" feature filter was removed along with
|
||||
the column reads, the same as B7's tunnel filter. Owner: D1, with the
|
||||
generic host-settings filter.
|
||||
- **B9:** the startup purge of expired open tabs in `db/index.ts` read
|
||||
`terminal_session_timeout_minutes` from core settings; it was removed and
|
||||
expired tabs are dropped when the tab list is read, against the terminal's
|
||||
own timeout (`GET /open-tabs/session-timeout`, 30 while the terminal is
|
||||
off). No owner unless the table growth matters.
|
||||
- **Pre-existing (B8), found in B9:** `node scripts/check-shell-plugin-ids.cjs`
|
||||
fails on `src/ui/lib/host-to-ssh-host.ts` spelling `web-endpoint` (the
|
||||
`pluginSettings` lookup B8 added). Not allowlisted and not introduced here.
|
||||
Owner: D0 (read it through a registry or allowlist it with a reason).
|
||||
- **B10 (tmux-monitor):** `enable_tmux_monitor` is still a live `ssh_data`
|
||||
column; only the copy-into-plugin-settings migration shipped
|
||||
(`tmux-monitor-settings-migration.ts`), the same as B6/B7's own columns.
|
||||
Every core read site (`host.ts`, `host-normalizers.ts`,
|
||||
`host-bulk-routes.ts`, `database.ts`'s encrypt/decrypt round trip) still
|
||||
reads the column directly. Dropping it in lockstep (`schema.ts`,
|
||||
`db/index.ts`, a drizzle migration per dialect, `schema:generate`) is the
|
||||
same follow-up the B6/B7/B8 lines above describe. Owner: a dedicated
|
||||
follow-up step, or D0.
|
||||
- **B10 (tmux-monitor):** `terminalToolbar.openTmuxMonitor` and
|
||||
`terminalToolbar.tmuxDetach` are dead locale keys (nothing calls
|
||||
`t("terminalToolbar.openTmuxMonitor")` or `t("terminalToolbar.tmuxDetach")`
|
||||
anywhere in the tree; the terminal's own live detach button uses
|
||||
`terminalToolbar.detachTmux`/`detachTmuxDescription` instead). Pre-existing,
|
||||
not introduced by this step; sibling dead keys
|
||||
(`copyTerminalUrlAction`/`copyDockerUrlAction`/`copyHostMetricsUrlAction`,
|
||||
`terminalUrlCopied`/`dockerUrlCopied`/`hostMetricsUrlCopied`) are the same
|
||||
vintage, from before the generic `hosts.copiedToClipboard`/
|
||||
`copyViewUrlAction` replaced them. Owner: whoever next cleans up
|
||||
`src/ui/locales/en.json`, or D0.
|
||||
- **B10 (tmux-monitor):** no plugin contributes an "Open Tmux Monitor" (or
|
||||
"Open Docker" / "Open Tunnel" / "Open Host Metrics") quick link into
|
||||
`terminal.toolbar` today; that whole quick-link row was dropped when the
|
||||
toolbar was rebuilt as a generic action slot (`ai` is the only real
|
||||
`terminal.toolbar` contributor, for its assistant button) and never
|
||||
replaced per-feature. Adding tmux-monitor's own link is straightforward
|
||||
(`app.registerSlotContribution("terminal.toolbar", ...)` plus
|
||||
`app.registerAction` opening the singleton tab) but is a shared gap across
|
||||
four plugins, not specific to this step. Owner: D1, or whoever revisits the
|
||||
toolbar's quick-link row.
|
||||
- **B11 (serial):** `plugins/serial/src/frontend/Serial.tsx` uses a small
|
||||
hardcoded light/dark xterm palette and a generic monospace font stack
|
||||
instead of the app's real terminal theme system
|
||||
(`resolveTermixThemeColors`, `DEFAULT_TERMINAL_CONFIG`, `TERMINAL_FONTS`,
|
||||
font loading), which is still core-only (`src/ui/features/terminal/`,
|
||||
`src/ui/lib/terminal-themes.ts`) and not yet exposed through the SDK. The
|
||||
serial console therefore does not match a user's chosen terminal theme or
|
||||
font. Promoting that system into `@termix/plugin-sdk/ui` is really
|
||||
ssh-terminal's own Phase B step's call to make (it is the plugin that
|
||||
currently owns the terminal component itself, per the "Known specifics"
|
||||
note above), so this line just points at the gap rather than building a
|
||||
premature shared surface for one caller. Owner: ssh-terminal's own Phase B
|
||||
step, or D1.
|
||||
- **B11 (serial):** shipping a plugin's own native dependency (`serialport`)
|
||||
works for this repo's three bundled deployments only because they all
|
||||
install the whole npm workspace at once and copy/rebuild one shared
|
||||
`node_modules` - see "Native dependencies" in ARCHITECTURE.md for the full
|
||||
reasoning. A community plugin installed later from a tarball has no such
|
||||
shared `node_modules` to land in, so it cannot ship a native dependency the
|
||||
same way. No fix attempted here; the honest workarounds are listed in that
|
||||
section. Owner: whoever designs the 3.0.0 install flow (prebuild-per-platform
|
||||
packaging, or a documented "avoid native code" rule for community plugins).
|
||||
|
||||
- **B12 (session-sharing):** `allow_session_sharing` is still a live `ssh_data`
|
||||
column. The copy into the plugin's host setting shipped
|
||||
(`session-sharing-settings-migration.ts`) and nothing in core reads or
|
||||
writes it any more; drop it in lockstep (`schema.ts`, `db/index.ts`, a
|
||||
drizzle migration per dialect, `schema:generate`). Owner: D0.
|
||||
- **B12 (session-sharing):** the legacy `session_sharing_globally_enabled`
|
||||
settings row is left in place for a release; nothing reads it. Delete it in
|
||||
3.0.0. Owner: D0 to decide.
|
||||
- **B12 (session-sharing):** room audit lines go through `ctx.audit.record`,
|
||||
which records the actor but not the request's IP address and user agent the
|
||||
old `logAudit` calls carried. Adding those to `ctx.audit.record` would
|
||||
restore them. Owner: D1.
|
||||
- **B12 (session-sharing):** the share button lives in the terminal and remote
|
||||
desktop toolbars now; the tab bar's share button is gone, so a host with the
|
||||
terminal toolbar switched off has no share button. A generic tab-menu slot
|
||||
would bring it back if that matters. Owner: D1, if wanted.
|
||||
- **B13 (session-recording):** `enable_session_logging` is still a live
|
||||
`ssh_data` column; only the copy-into-plugin-settings migration shipped
|
||||
(`session-recording-settings-migration.ts`). The legacy default was `true`;
|
||||
the plugin's enable switch defaults to `false` like every other plugin's, so
|
||||
the migration copies every host's value rather than only the ones that
|
||||
turned it off - a fresh host created after 2.9.0 defaults to recording off,
|
||||
a real behaviour change from the old column default, made once here rather
|
||||
than silently. `ssh-terminal/src/backend/terminal-socket.ts` still reads
|
||||
`resolvedHostData?.enableSessionLogging ?? hostConfig.enableSessionLogging`
|
||||
off the resolved SSH host instead of `ctx.settings.getHost` (remote desktop
|
||||
asks `recordings.writer.enabledFor` since B14), and `HostEditorData.ts`,
|
||||
`HostManagerData.ts`, `quick-connect-host.ts` and the export/import sample
|
||||
all still read the column directly. The General tab's checkbox was removed
|
||||
from `HostEditor.tsx` (the schema-driven Plugins group replaces it); the
|
||||
data plumbing above was not switched over. Dropping the column in lockstep
|
||||
(`schema.ts`, `db/index.ts`, a drizzle migration per dialect,
|
||||
`schema:generate`) needs every one of those switched first. Owner: a
|
||||
dedicated follow-up step, or D0.
|
||||
- **B13 (session-recording):** `accessId` (the legacy `access_id` column,
|
||||
referencing the now-core-only `host_access` table) is carried in the
|
||||
adopted table's shape as a plain unreferenced integer, since the SDK's
|
||||
`defineTable` has no builder for a foreign key into an arbitrary core
|
||||
table beyond `refUser()`/`refHost()`. Nothing has ever populated it (true
|
||||
in core before this step too). Owner: none needed unless a future step
|
||||
wants to actually use it.
|
||||
|
||||
- **B14 (remote-desktop):** the moved columns stay in the database, unused:
|
||||
`ssh_data.enable_rdp`, `enable_vnc`, `enable_telnet`, `rdp_port`,
|
||||
`vnc_port`, `telnet_port`, `rdp_security`, `rdp_ignore_cert`, `security`,
|
||||
`ignore_cert`, `guacamole_config`, and `user_preferences.rdp_defaults`.
|
||||
Their drizzle migrations are `SELECT 1;` on purpose (the copy runs after
|
||||
drizzle on Postgres and MySQL). Drop them physically in 3.0.0, once every
|
||||
install has booted 2.9.0. The legacy `guac_enabled` and `guac_url` rows in
|
||||
`settings` are left too. **B15** did the same with `ssh_data.enable_docker`
|
||||
and `docker_config` (copied by `docker-settings-migration.ts`). Owner: 3.0.0.
|
||||
- **B14, found in B5 (proxmox):** `drizzle/postgres/0036_redundant_ender_wiggin.sql`
|
||||
and `drizzle/mysql/0035_clumsy_leech.sql` really `DROP COLUMN` the four
|
||||
proxmox host columns. On Postgres and MySQL that runs at boot before
|
||||
`runProxmoxSettingsMigration` copies them, so an upgrade there loses every
|
||||
host's Proxmox settings. Since nothing has shipped, rewrite those two files
|
||||
to the `SELECT 1;` pattern (keeping their journal entries). Owner: D0.
|
||||
- **B14, found in B5 to B13:** every earlier host-column copy
|
||||
(`proxmox`, `file-manager`, `tunnels`, `web-endpoint`, `ssh-terminal`,
|
||||
`tmux-monitor`, `session-sharing`, `session-recording` settings migrations)
|
||||
reads the old columns with `getDb().all(sql...)`, which only exists on
|
||||
SQLite. On Postgres and MySQL the call throws, the migration's catch logs a
|
||||
warning and nothing is copied. Switch them to `selectRows` from
|
||||
`utils/crypto-migration/raw-rows.ts`, which B14 added and uses. Owner: D0.
|
||||
- **B14, found in B13:** `session-recording-settings-migration.ts` writes
|
||||
`row.enable_session_logging !== false`, but SQLite returns `0`, so a host
|
||||
that had recording off migrates as on. Its test only uses real booleans.
|
||||
Owner: D0.
|
||||
- **B14 (remote-desktop), for D2:** host payloads carry every plugin's host
|
||||
settings after the sharing sanitizers, so a connect-level recipient now
|
||||
receives remote desktop's `guacamoleConfig`, which can hold an RDP gateway
|
||||
password in plain text. A view-level recipient saw it before B14 too.
|
||||
Decide whether plugin host settings need a per-field sharing level, or make
|
||||
the gateway password its own secret field. Owner: D2.
|
||||
- **B14 (remote-desktop):** core still holds some locale keys that look dead
|
||||
after this step but may be built dynamically: `hosts.guac.noCredential`,
|
||||
`authType*`, `recording*`, `saveHostFirst`, `sharingOptionsAfterSave`,
|
||||
`permissionLevel`, `typeHeader`, `targetHeader`, `permissionHeader`,
|
||||
`hosts.connectRdp/Vnc/Telnet`, `hosts.copy*UrlAction`, `hosts.telnet` and
|
||||
`homepage.connType_*`. Owner: whoever next cleans up `src/ui/locales/en.json`,
|
||||
or D0.
|
||||
- **Pre-existing, found in B14:** `npm run test -w plugins/web-endpoint`
|
||||
fails "answers 503 while the tunnels plugin is not available": the route
|
||||
expects `ctx.services.get` to throw for a missing service, but since B12
|
||||
the runtime and the test doubles hand back an empty handle, so the route
|
||||
calls an undefined `forward` and answers 500. Check `typeof forward` the
|
||||
way remote desktop does. Owner: D0.
|
||||
- **Pre-existing, found in B14:** each plugin's own `typecheck` script
|
||||
(`tsc -p tsconfig.json`) fails with the SDK's `tsconfig.plugin.json`
|
||||
(node16 resolution wants `.js` on relative imports and cannot resolve
|
||||
`@termix/plugin-sdk/ui`), workspaces included. `npm run type-check` covers
|
||||
plugins through `tsconfig.plugins*.json` and passes. Owner: D0.
|
||||
|
||||
- **B16 (host-metrics):** Termix-Mobile read `statsConfig` on each host
|
||||
(`statusCheckEnabled`, `enabledWidgets`) and the host status from the
|
||||
metrics API. The host payload now carries `statusCheckEnabled` and
|
||||
`statusCheckInterval` at the top level, the metrics options under
|
||||
`pluginSettings["host-metrics"]`, and statuses come from `GET /host/status`.
|
||||
The mobile app needs the matching change, or the host payload a
|
||||
`statsConfig` built from those fields for one release. Owner: D1.
|
||||
- **B16 (host-metrics):** a host's status is only checked once its owner has
|
||||
asked for statuses (opened the app) since the server started: a user who
|
||||
only has a host shared with them sees no dot for it until then. This was
|
||||
already true before B16 (the first user to load the host list started
|
||||
polling for their own hosts only). Starting checks for the owners of every
|
||||
host a requester can see would fix it. Owner: D1.
|
||||
- **B16 (host-metrics):** the host JSON export does not carry host-metrics's
|
||||
host settings (it did carry `statsConfig`); import reads both the old
|
||||
`statsConfig` and `pluginSettings["host-metrics"]` through the plugin's
|
||||
import normalizer. Same gap as the B7 line about exporting plugin host
|
||||
settings generically. Owner: D1.
|
||||
- **B16 (host-metrics):** the CPU and network collectors keep their last
|
||||
sample per host in module-level maps (`widgets/cpu-collector.ts`,
|
||||
`widgets/network-collector.ts`) to compute rates. The poller clears them on
|
||||
deactivate and per host, so nothing leaks, but they are still module state
|
||||
rather than per-activation. Owner: D1.
|
||||
- **B15 (docker), for D2:** the console socket `/plugin-ws/docker/console`
|
||||
is a public route with `optionalAuth`. The handler refuses a socket with no
|
||||
signed-in user or without `docker.use` and checks the data key per connect
|
||||
message, but review it with the rest of the public routes.
|
||||
- **B15 (docker):** `ssh_data.show_docker_in_sidebar` (and its siblings for
|
||||
terminal, files, tunnels and server stats) are dead columns nothing reads;
|
||||
only the raw database export still copies them. Drop them the B14 way.
|
||||
Owner: D0.
|
||||
- **B15 (docker):** the Docker manager's card or table layout is still an
|
||||
override on core's Appearance presets (`types/ui-preferences.ts` declares a
|
||||
`docker` area, as it does `hostMetrics`), reached through the ui entry's
|
||||
`useAreaPreferences` and `useUiPreferencesContext`. A plugin cannot declare
|
||||
its own area yet. Owner: D1.
|
||||
- **B15 (docker):** the raw SQLite database import (`database.ts`) no longer
|
||||
brings `enable_docker` across, the same gap every earlier column move left
|
||||
there: it writes host rows only and never plugin settings. Owner: D1, with
|
||||
the generic host-settings export and import.
|
||||
|
||||
- **B17 (automations), for D2:** the ctx table says `ctx.events.on` needs
|
||||
`events:core` for core topics, but the runtime only checks `emit`.
|
||||
docker, file-manager, host-metrics, session-recording and snippets
|
||||
subscribe to core topics (`host.*`, `user.*`) without declaring it.
|
||||
Either gate `on` and add `events:core` to those five, or change the doc to
|
||||
say subscribing is ungated. Owner: D2.
|
||||
- **B17 (automations):** the 2.8 webhook URL `/automations/webhook/<token>`
|
||||
is kept only by a rewrite in `docker/nginx.conf` and
|
||||
`docker/nginx-https.conf`. An install that serves the backend without
|
||||
nginx (the desktop app's embedded backend, a hand-rolled proxy) answers
|
||||
404 there. Decide whether that matters enough for a generic legacy-path
|
||||
alias in the plugin HTTP layer. Owner: D0.
|
||||
- **B17 (automations):** there are two notification senders now:
|
||||
`deliverNotification` (ctx.notify, per-channel private opt-in, throws) and
|
||||
the older `sendWebhook`/`sendNtfy`/`sendDiscord` that the channel "test"
|
||||
route in `notification-channels-routes.ts` still uses (always applies the
|
||||
allowlist, retries once). Point the test route at `deliverNotification`
|
||||
and delete the old ones. Owner: D1.
|
||||
- **B17 (automations):** the scheduler no longer skips a user whose data key
|
||||
cannot be resolved (`DataCrypto.canUserAccessData` has no SDK equivalent).
|
||||
With system-wrapped DEKs that check was effectively always true; if it
|
||||
ever is not, the run now fails on its first SSH step instead of being
|
||||
skipped silently. Owner: none unless a background "is this user unlocked"
|
||||
check is added to the SDK.
|
||||
- **B18 (ai):** the "allow read-only diagnostic commands" user setting only
|
||||
adds a line to the system prompt. No tool runs a command directly;
|
||||
`tools/command-allowlist.ts` (`isReadOnlyCommand`) is tested but unused, so
|
||||
every command is still a proposal. Either add the direct tool or drop the
|
||||
setting. Owner: D0.
|
||||
- **B18 (ai):** provider requests now go through `ctx.fetch`, which has no
|
||||
outbound proxy support. Before, an admin-allowlisted private provider went
|
||||
through `fetchWithProxy` (public ones never did). Decide whether
|
||||
`ctx.fetch` should honour the proxy settings. Owner: D0.
|
||||
- **B18:** `scripts/generate-dialect-schema.cjs` decides varchar or text by
|
||||
column name across every table, so removing the ai indexes on
|
||||
`created_at`, `updated_at` and `label` turned those columns into `text`
|
||||
everywhere in `schema.pg.ts` and `schema.mysql.ts`. drizzle-kit then wrote
|
||||
about a hundred type changes, which B18 left out of
|
||||
`drizzle/postgres/0048` and `drizzle/mysql/0047` (both a no-op), so real
|
||||
databases keep `varchar(255)` while the snapshots say `text`. Key the
|
||||
generator per table, regenerate, and check `verify:dialect`. Owner: D0.
|
||||
- **B18:** saving a plugin setting from the Settings screen tells nobody. The
|
||||
ai frontend re-reads its status on `termix:plugins-changed` and on window
|
||||
focus (throttled to 30 seconds), so an admin turning it on shows up on the
|
||||
next focus. A settings-changed hook on the app object would be cleaner.
|
||||
Owner: D1.
|
||||
- **B18:** tunnels builds its own SSE URL off the axios base instead of the
|
||||
new `app.fetch`. Owner: D1.
|
||||
|
||||
- **Pre-existing, found in B19:** `createMockCtx`'s `wsRoutes` double (B15)
|
||||
records `{ path, raw, handler, options }` per route, but
|
||||
`plugins/serial/tests/backend/activate.test.ts`,
|
||||
`plugins/ssh-terminal/tests/backend/activate.test.ts` and
|
||||
`plugins/tunnels/tests/backend/activate.test.ts` still assert the old
|
||||
two-field shape (`{ path, raw }`) and fail on `toEqual`. Not touched by
|
||||
this step; update each assertion to match or to check only the fields it
|
||||
cares about. Owner: D0.
|
||||
- **Pre-existing, found in B19:** `src/backend/hosts/file-manager/` (21
|
||||
files) is dead code left over from before the file-manager plugin (B6):
|
||||
nothing in `src/backend/starter.ts` or elsewhere imports
|
||||
`hosts/file-manager/index.ts`, so its own port (30004, still listed in
|
||||
`src/backend/utils/swagger.ts`'s server list) never actually starts and
|
||||
its `axios.post("http://localhost:30006/activity/log", ...)` calls (now a
|
||||
dead port after this step) never run. Delete the directory once confirmed
|
||||
nothing else references it. Owner: D0.
|
||||
- **Pre-existing, found in B19:** `src/ui/main-axios.ts`'s `tmuxMonitorApi`
|
||||
still points at `getApiUrl("/tmux_monitor", 30010)`, a port tmux-monitor's
|
||||
own B10 step moved off of (per `plugins/tmux-monitor/CHANGELOG.md`) without
|
||||
updating this frontend constant to `/plugin-api/tmux-monitor`. Not
|
||||
something this step touched or verified further. Owner: D0.
|
||||
- **Pre-existing, found in B20:** `npm run test:plugins` fails on three tests
|
||||
unrelated to wake-on-lan or secret-sources: `ssh-terminal` and `tunnels`
|
||||
both assert `mock.wsRoutes` equals `[{ path, raw }]`, but the mock now also
|
||||
carries `handler` and `options` (added for **B15**'s doubles, per the
|
||||
contract's "Tests" section), so the exact-equality assertion fails; and
|
||||
`web-endpoint`'s "answers 503 while the tunnels plugin is not available"
|
||||
test gets 502. Confirmed present on `dev-2.9.0` before this step (checked
|
||||
by stashing B20's changes and re-running). Owner: D0.
|
||||
- **C1 (totp):** `totp-migration.ts` moves a user's TOTP secret only when
|
||||
their data key opens at boot. A 2.8 user whose key is still a pre-2.5.1
|
||||
password wrap keeps their enrolment row (fail closed) but has no secret in
|
||||
`p_totp_enrollments` until a boot after their next password login migrates
|
||||
the key, so that one login fails at the TOTP step. Likewise, enabling the
|
||||
totp plugin for the first time without a restart does not move secrets
|
||||
until the next boot. Decide whether either needs handling (a migration run
|
||||
on plugin activation, or an admin reset message). Owner: D0.
|
||||
- **C1 (totp/webauthn):** the `users` columns `totp_secret`, `totp_enabled`
|
||||
and `totp_backup_codes` stay in the database (dropped from `schema.ts`, the
|
||||
drizzle drop is a no-op) until 3.0.0 removes them, after the boot
|
||||
migration has run everywhere. Owner: 3.0.0.
|
||||
- **C2 (sso/ldap):** `users.is_oidc`, `oidc_identifier`, `sso_provider_id`
|
||||
and the 2.8 per-user OIDC columns (`client_id`, `issuer_url`, ...), and
|
||||
`sessions.sso_provider_id`, `oidc_sub`, `oidc_sid`, stay in core. Core
|
||||
still reads `is_oidc`/`oidc_identifier` for account linking, `/users/me` and
|
||||
the `$oidc.preferred_username` placeholder, and still writes them from an
|
||||
identity's `legacy` field so a downgrade works. Rename or drop them once
|
||||
nothing needs the 2.8 shape. Owner: 3.0.0.
|
||||
- **C2 (sso/ldap):** `database/routes/auth-compat-routes.ts` keeps the 2.8
|
||||
URLs (`/users/oidc/*`, `/users/oidc-config`, `/users/sso-providers`,
|
||||
`/users/ldap/login`) and so names the `oidc`/`ldap` method ids and the sso
|
||||
plugin's `/plugin-api/sso/` paths. Keep until identity providers have moved
|
||||
to the new redirect URI and Termix-Mobile uses `/users/auth/*`. Owner:
|
||||
3.0.0.
|
||||
- **C2 (sso):** the `oidc_auto_provision` and `oidc_silent_login_default`
|
||||
settings, their routes and admin toggles are generic (every external
|
||||
method, every redirect method) but still carry OIDC in their names and
|
||||
i18n. Rename with a settings migration if wanted. Owner: D0.
|
||||
- **Unrelated, found in C2:** `node scripts/check-shell-plugin-ids.cjs`
|
||||
fails on `src/ui/lib/host-to-ssh-host.ts` naming `"web-endpoint"`
|
||||
(`h.pluginSettings?.["web-endpoint"]`). The file is untouched since before
|
||||
C2. Owner: D0.
|
||||
|
||||
- **C3 (opkssh), C4 (step-ca):** `database/routes/host-compat-routes.ts`
|
||||
answers the 2.8 redirect URIs `/host/opkssh-callback` and
|
||||
`/host/step-ca-callback` with a 307 to the plugins and so names their
|
||||
paths. Keep it until identity providers have moved to
|
||||
`/plugin-api/opkssh/callback` and `/plugin-api/step-ca/callback` (each
|
||||
plugin's admin page shows it, and its `legacyCallback` setting switches
|
||||
over). Owner: 3.0.0.
|
||||
- **C3 (opkssh/warpgate):** `ssh_data.use_warpgate` and the `.opk` folder
|
||||
under `DATA_DIR` stay on disk, unused (the drizzle drop is `SELECT 1;`, the
|
||||
config is copied, not moved). Remove them in 3.0.0. Owner: 3.0.0.
|
||||
- **C3 (opkssh):** the Termix docs still describe `DATA_DIR/.opk/config.yml`
|
||||
and `/host/opkssh-callback`. They need the new config path
|
||||
(`DATA_DIR/plugins/opkssh/config.yml`), the new redirect URI and the
|
||||
`legacyCallback` setting. Owner: D0 (docs repo).
|
||||
- **C3 (warpgate):** Termix-Mobile's file manager and docker clients call
|
||||
`/ssh/connect-warpgate` and read `requiresWarpgate`; the plugins answer
|
||||
`connect-browser-sign-in` and `requires_browser_sign_in` now (both clients
|
||||
were already on paths B6 and B15 moved). The terminal socket keeps
|
||||
`warpgate_auth_required` / `warpgate_auth_continue`. Owner: D1, with the
|
||||
rest of the mobile client changes.
|
||||
- **C3:** `host-bulk-routes.ts` still validates imported auth types against a
|
||||
fixed list that names plugin types (`opkssh`, `stepca`, `tailscale`,
|
||||
`vault`), and the host create/update routes no longer accept a
|
||||
`useWarpgate` field inline (the host editor sends plugin settings). The
|
||||
bulk list should come from the registered providers plus
|
||||
`contributes.auth.sshAuthTypes`. Owner: D1.
|
||||
- **C4 (step-ca):** the 2.8 `step_ca_url`, `step_ca_fingerprint`,
|
||||
`step_ca_provisioner` and `step_ca_private_endpoint_allowlist` rows stay in
|
||||
core `settings`, unused, after `step-ca-settings-migration.ts` copies them
|
||||
(kept for one release so a downgrade works). Delete them in 3.0.0.
|
||||
Owner: 3.0.0.
|
||||
- **C4 (step-ca):** the admin fields are plain settings now, so a bad CA URL
|
||||
or fingerprint is reported when someone signs in (in the terminal dialog)
|
||||
instead of when the admin saves, as the 2.8 route did. A settings
|
||||
validation hook for plugins would bring the save-time check back.
|
||||
Owner: D0.
|
||||
- **C4 (step-ca):** the Termix docs still describe Step CA under Admin
|
||||
Settings and the `/host/step-ca-callback` redirect URI. They need the
|
||||
plugin settings page, the new redirect URI and the `legacyCallback`
|
||||
setting. Owner: D0 (docs repo).
|
||||
- **Pre-existing, found in C3:** the Docker `opkssh-downloader` stage's
|
||||
`OPKSSH_VERSION` build arg is not passed on at runtime, so building with
|
||||
another version gives a prebaked binary the plugin's pinned checksum
|
||||
rejects (it then downloads the pinned version instead). Pass
|
||||
`OPKSSH_VERSION` and the matching `OPKSSH_SHA256` as `ENV` in the final
|
||||
stage. Owner: D0.
|
||||
- **C5 (vault):** the Termix docs still describe Vault profiles with the
|
||||
`/vault/oidc/callback` redirect URI. They need the plugin settings page,
|
||||
the new redirect URI (`/plugin-api/vault/oidc/callback`) and the
|
||||
`legacyCallback` setting. Owner: D0 (docs repo).
|
||||
- **C5 (vault):** a host's Vault profile is the plugin's `profileId` host
|
||||
setting now, and host-scope plugin settings do not travel over remote sync
|
||||
(the host entity used to carry `vaultProfileSyncId`). Profiles still sync
|
||||
as `vaultProfiles`; the host on the other side has to pick its profile
|
||||
again. Needs a generic way to sync host-scope plugin settings, the same gap
|
||||
the B7 export/import line describes. Owner: D1.
|
||||
- **C5 (vault), for D2:** `vaultProfileId` was an owner-private auth field a
|
||||
shared-host recipient with edit access could not change. As a host setting
|
||||
it is written through `/plugins/vault/settings/host/:hostId`, which only
|
||||
checks edit access, so such a recipient can now point the host at another
|
||||
profile. Same question as the B14 line on per-field sharing levels.
|
||||
Owner: D2.
|
||||
- **C5 (vault):** `ssh_data.vault_profile_id` stays in the database,
|
||||
unused, after `vault-settings-migration.ts` copies it (kept so a downgrade
|
||||
works, and the pg/mysql foreign key on it now points at
|
||||
`p_vault_profiles`). Drop it in 3.0.0. Owner: 3.0.0.
|
||||
- **Pre-existing, found in C5:** `npm run test -w plugins/ssh-terminal`
|
||||
fails "ssh-terminal activate > serves the terminal socket": it expects
|
||||
`mock.wsRoutes` to equal `[{ path, raw }]`, but `FakeWsRoute` now also
|
||||
records `handler` and `options`. Use `toMatchObject` or
|
||||
`expect.objectContaining`. Owner: D0.
|
||||
Everything a Phase B/C step left for D0 is done. What is left needs a person
|
||||
with a real install, a real device or a repo outside this one.
|
||||
|
||||
## Manual checks after 2.9.0
|
||||
|
||||
@@ -657,33 +105,52 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
|
||||
editor (sharing only offered with "Share Vault profiles"); switch to the new
|
||||
redirect URI after allowing it in the Vault role; disable the vault plugin
|
||||
and a Vault host says it needs it, then enable it again.
|
||||
- **C6 (termix-identity):** only the public resolver URLs under
|
||||
`/termix-id/u/` redirect to the plugin. The 2.8 management routes
|
||||
(`/termix-id/me`, `/termix-id/keys`, `/termix-id/ca` and the rest) now 404.
|
||||
Nothing in this repo calls them; confirm Termix-Mobile does not before
|
||||
2.9.0 ships, and add redirects if it does. Owner: D0.
|
||||
- **C6 (termix-identity):** on MySQL the adopted `p_termix_identity_keys` has
|
||||
no `idx_termix_identity_keys_identity` (core never created one there and
|
||||
an adoption writes no MySQL indexes), so the public resolver scans the
|
||||
keys table. Add it in a later plugin migration if a MySQL install needs
|
||||
it. Owner: D0.
|
||||
- **C6 (termix-identity):** the docs site still shows the
|
||||
`/termix-id/u/<handle>` resolver URL. It keeps working through the 308,
|
||||
but the docs should show `/plugin-api/termix-identity/u/<handle>`. Owner:
|
||||
D0.
|
||||
- **C7 (acme-ssl):** the 2.8 leftovers stay on disk and in the database for
|
||||
one release: the `acme_ssl_settings` settings row (the Cloudflare token is
|
||||
already stripped from it), `DATA_DIR/certbot` and `DATA_DIR/acme-webroot`.
|
||||
Delete them in the release after 2.9.0. Owner: after D4.
|
||||
- **C7 (acme-ssl):** the docs page linked from Admin Settings > HTTPS
|
||||
certificate (`docs.termix.site/features/networking/ssl`) still describes
|
||||
certbot and the webroot challenge. Point it at the plugin. Owner: D0.
|
||||
- **C7, pre-existing:** `AutoSSLSetup.setupEnvironmentVariables` in
|
||||
`src/backend/tls/self-signed.ts` rewrites `SSL_CERT_PATH`, `SSL_KEY_PATH`
|
||||
and `SSL_DOMAIN` to the defaults in `DATA_DIR/.env` on every boot, so custom
|
||||
certificate paths set in the environment are lost after the first start.
|
||||
Owner: D0.
|
||||
- **C7, pre-existing:** `src/backend/scripts/setup-ssl.sh` and
|
||||
`enable-ssl.sh` are not called by anything and write a `JWT_SECRET` and
|
||||
`DATABASE_KEY` to `.env`, which SystemCrypto now owns. Delete or rewrite
|
||||
them. Owner: D0.
|
||||
- Termix-Mobile: the 2.8 termix-id management routes (`/termix-id/me`,
|
||||
`/termix-id/keys`, `/termix-id/ca` and the rest) now 404, only the
|
||||
`/termix-id/u/` resolver redirects. Confirm the mobile app does not call
|
||||
them, and that it still reads a host's metrics settings (`statsConfig` is
|
||||
put back on the host payload by host-metrics) and the renamed Warpgate
|
||||
fields. Once it reads `pluginSettings` and `GET /host/status`, drop
|
||||
host-metrics's `hostPayloadLegacy` hook.
|
||||
- Docs site: point these at the plugins: the OPKSSH config path and redirect
|
||||
URI, the step-ca settings, the Vault redirect URI, the Termix ID resolver
|
||||
URL (`/plugin-api/termix-identity/u/<handle>`, the old one still 308s) and
|
||||
the HTTPS certificate page (`docs.termix.site/features/networking/ssl`,
|
||||
which still describes certbot and the webroot challenge).
|
||||
- Upgrade a real 2.8 install on Postgres and on MySQL (SQLite is covered by
|
||||
the upgrade test): proxmox host settings, tunnels, web endpoints, the file
|
||||
manager's default path and SCP switch, and the terminal, tmux, sharing and
|
||||
recording switches all survive. Postgres was checked by hand against a
|
||||
scratch database; MySQL only runs in CI.
|
||||
- Host settings round trip: filter the Hosts panel by a plugin feature, bulk
|
||||
enable and disable a plugin on a few hosts, export hosts to JSON and import
|
||||
them on another install (plugin settings come across, secrets do not), the
|
||||
raw SQLite export and import, and remote sync of a host with a Vault
|
||||
profile selected (the profile follows it).
|
||||
- Desktop app with a remote server: tunnels on a remote host show their
|
||||
status next to local ones, and the terminal toolbar's metrics bars work
|
||||
for a host on the remote server.
|
||||
- Shared hosts: a remote desktop host shared at "connect" does not show its
|
||||
gateway settings; a shared editor cannot change a Vault host's profile; the
|
||||
host list status dot works for a host someone shared with you.
|
||||
- The PDF preview in the file manager, in the browser and in the desktop app.
|
||||
- The tab right-click menu's share entry on a terminal whose toolbar is
|
||||
hidden, and the terminal toolbar's quick links (tmux monitor, docker,
|
||||
tunnels, host metrics) for a host with those plugins on.
|
||||
- Appearance: switching the interface preset changes the Docker layout and
|
||||
the host metrics column count, a change made in either shows under
|
||||
Appearance with the plugin's name and reverts from there, and a 2.8 user's
|
||||
Docker and host metrics overrides are still applied.
|
||||
- A new host takes the admin "command history for new hosts" setting (a 2.8
|
||||
install that had it off in host defaults keeps it off), and the file
|
||||
manager and terminal are on for a new host.
|
||||
- Settings saves with a bad value are refused with a message: a relative
|
||||
terminal background image path, a step-ca URL that is not https.
|
||||
- Custom `SSL_CERT_PATH` and `SSL_KEY_PATH` in the environment survive a
|
||||
restart and no self-signed certificate is generated over them.
|
||||
- A LAN ntfy or webhook target in the admin allowlist is reached through the
|
||||
configured outbound proxy.
|
||||
- An admin enables a plugin that was off since the upgrade: its 2.8 data is
|
||||
copied in without a restart.
|
||||
- A password user who signs in for the first time after the upgrade keeps
|
||||
their TOTP (the copy runs once their keys unlock).
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import * as esbuild from "esbuild";
|
||||
import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
|
||||
import { legacyCoreImports } from "../lib/legacy-core-imports.mjs";
|
||||
import { staticUrlImports } from "../lib/static-url-imports.mjs";
|
||||
import { readManifest, resolveEntry, copyDir } from "../lib/plugin-dir.mjs";
|
||||
|
||||
const BACKEND_ENTRIES = [
|
||||
@@ -75,6 +76,7 @@ export async function build({ cwd }) {
|
||||
logLevel: "warning",
|
||||
external: FRONTEND_EXTERNALS,
|
||||
plugins: [
|
||||
staticUrlImports({ outDir }),
|
||||
legacyCoreImports({
|
||||
pluginId,
|
||||
platform: "browser",
|
||||
|
||||
@@ -20,11 +20,6 @@ const CORE_BACKEND = /^(?:\.\.\/)+src\/backend\//;
|
||||
// value (AUTOMATION_DEFINITION_VERSION), so this has to resolve at runtime.
|
||||
const CORE_TYPES = /^(?:\.\.\/)+src\/types\//;
|
||||
const CORE_UI = /^(?:\.\.\/)+src\/ui\//;
|
||||
// "../../../<other-plugin>/backend/" -- only plugins/ai/.../executor.ts today.
|
||||
// esbuild filters are Go RE2, which has no lookahead, so "src" is excluded in
|
||||
// the callback rather than the pattern.
|
||||
const CROSS_PLUGIN = /^(?:\.\.\/)+([a-z0-9-]+)\/(?:src\/)?backend\//;
|
||||
|
||||
/** dist/plugins/<id>/dist/backend.js -> dist/backend/... */
|
||||
const COMPILED_CORE = "../../../backend/backend/";
|
||||
const COMPILED_TYPES = "../../../backend/types/";
|
||||
@@ -68,12 +63,6 @@ export function legacyCoreImports({ pluginId, platform, insideTermix = true }) {
|
||||
path: args.path.replace(CORE_TYPES, COMPILED_TYPES),
|
||||
external: true,
|
||||
}));
|
||||
|
||||
build.onResolve({ filter: CROSS_PLUGIN }, (args) => {
|
||||
const [, other] = CROSS_PLUGIN.exec(args.path);
|
||||
if (other === "src" || other === pluginId) return null;
|
||||
return { path: `../../${other}/dist/backend.js`, external: true };
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* `import url from "some/file.js?url"` in a plugin frontend, as core's Vite
|
||||
* build allows: the file is copied next to the bundle under assets/ and the
|
||||
* import becomes its URL, resolved against the bundle's own URL so it works
|
||||
* under /plugin-assets/<id>/. Only scripts, since that is all the asset
|
||||
* route serves (a pdfjs or Monaco worker, say).
|
||||
*/
|
||||
export function staticUrlImports({ outDir }) {
|
||||
return {
|
||||
name: "static-url-imports",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /\?url$/ }, async (args) => {
|
||||
const target = args.path.slice(0, -"?url".length);
|
||||
const resolved = await build.resolve(target, {
|
||||
kind: args.kind,
|
||||
resolveDir: args.resolveDir,
|
||||
importer: args.importer,
|
||||
});
|
||||
if (resolved.errors.length > 0) return { errors: resolved.errors };
|
||||
return { path: resolved.path, namespace: "static-url" };
|
||||
});
|
||||
|
||||
build.onLoad({ filter: /.*/, namespace: "static-url" }, (args) => {
|
||||
if (!/\.(m?js|cjs)$/.test(args.path)) {
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
text: `${args.path}: only script files can be imported with ?url`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
const bytes = fs.readFileSync(args.path);
|
||||
const hash = crypto
|
||||
.createHash("sha256")
|
||||
.update(bytes)
|
||||
.digest("hex")
|
||||
.slice(0, 8);
|
||||
const base = path.basename(args.path).replace(/\.(m?js|cjs)$/, "");
|
||||
const name = `${base}-${hash}.js`;
|
||||
fs.mkdirSync(path.join(outDir, "assets"), { recursive: true });
|
||||
fs.writeFileSync(path.join(outDir, "assets", name), bytes);
|
||||
return {
|
||||
contents: `export default new URL("./assets/${name}", import.meta.url).href;`,
|
||||
loader: "js",
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -170,7 +170,10 @@
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]*$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -273,12 +276,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"guest": { "type": "boolean" },
|
||||
"guest": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"guestViews": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]*$"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"type": "object",
|
||||
@@ -286,23 +294,38 @@
|
||||
"properties": {
|
||||
"sshAuthTypes": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]{0,63}$"
|
||||
}
|
||||
},
|
||||
"loginMethods": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]{0,63}$"
|
||||
}
|
||||
},
|
||||
"secondFactors": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]{0,63}$"
|
||||
}
|
||||
},
|
||||
"secretSchemes": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]{0,63}$"
|
||||
}
|
||||
},
|
||||
"keyboardInteractive": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]{0,63}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -313,9 +336,18 @@
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "titleKey"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"titleKey": { "type": "string", "minLength": 1 },
|
||||
"icon": { "type": "string", "minLength": 1 }
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"titleKey": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -326,9 +358,18 @@
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "titleKey"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"titleKey": { "type": "string", "minLength": 1 },
|
||||
"icon": { "type": "string", "minLength": 1 }
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"titleKey": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -727,9 +768,31 @@
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Registered component id. Required when type is \"custom\"."
|
||||
},
|
||||
"hidden": {
|
||||
"type": "boolean",
|
||||
"description": "Stored and validated, but not drawn by the generic settings form."
|
||||
},
|
||||
"defaultFrom": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "A non-secret admin field whose value a new host starts with."
|
||||
},
|
||||
"shareRead": {
|
||||
"type": "string",
|
||||
"enum": ["connect", "view", "edit", "manage"],
|
||||
"description": "Lowest share level whose recipients see the value in the host payload."
|
||||
},
|
||||
"ownerOnly": {
|
||||
"type": "boolean",
|
||||
"description": "Only the host's owner may change it."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"enableDefault": {
|
||||
"type": "boolean",
|
||||
"description": "What the enable switch reads before a host saves it. Off by default."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -749,6 +812,95 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"http": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "HTTP options for the plugin's /plugin-api/<id>/ router.",
|
||||
"properties": {
|
||||
"legacyPaths": {
|
||||
"type": "array",
|
||||
"description": "Old URLs under /<plugin id>/ served by the plugin's router with that prefix removed.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^/[a-z][a-z0-9-]*(/[A-Za-z0-9_.-]+)+$"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"uiPresets": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["simple", "balanced", "advanced"],
|
||||
"description": "The plugin's Appearance defaults for each interface preset, read with usePluginUiPreferences.",
|
||||
"properties": {
|
||||
"simple": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"balanced": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"advanced": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -456,6 +456,16 @@ export interface PluginSettings {
|
||||
value: unknown,
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Every host that saved a value for one of this plugin's host fields, across
|
||||
* all users, with the host's owner. For a background job that must know
|
||||
* which hosts to act on, and as whom, before any request is in flight.
|
||||
* Requires hosts:read. Secret fields cannot be listed.
|
||||
*/
|
||||
listHostValues: <T = unknown>(
|
||||
key: string,
|
||||
) => Promise<Array<{ hostId: number; userId: string; value: T }>>;
|
||||
|
||||
/** Every value in one scope, with declared defaults merged in. */
|
||||
getAll: (
|
||||
scope: "admin" | "user" | "host",
|
||||
@@ -468,6 +478,19 @@ export interface PluginSettings {
|
||||
*/
|
||||
onChange: (key: string, listener: (value: unknown) => void) => () => void;
|
||||
|
||||
/**
|
||||
* Checks a save from the settings screen or host editor before anything is
|
||||
* written. Return field key to message for what is wrong; nothing means the
|
||||
* save goes ahead. Disposed automatically on deactivate.
|
||||
*/
|
||||
onValidate: (
|
||||
scope: "admin" | "user" | "host",
|
||||
validator: (
|
||||
values: Record<string, unknown>,
|
||||
context: { hostId?: number },
|
||||
) => Record<string, string> | void | Promise<Record<string, string> | void>,
|
||||
) => () => void;
|
||||
|
||||
/**
|
||||
* Reads a core server setting from a small documented allowlist.
|
||||
* Requires the settings:read-core capability.
|
||||
@@ -513,9 +536,6 @@ export interface PluginHostRecord {
|
||||
folder: string | null;
|
||||
jumpHosts?: unknown;
|
||||
enableSsh?: boolean | null;
|
||||
enableTerminal?: boolean | null;
|
||||
enableFileManager?: boolean | null;
|
||||
enableTunnel?: boolean | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
[key: string]: unknown;
|
||||
@@ -1454,6 +1474,11 @@ export interface PluginAudit {
|
||||
details?: string;
|
||||
success: boolean;
|
||||
errorMessage?: string;
|
||||
/**
|
||||
* The HTTP request or WebSocket upgrade this happened on, for the line's
|
||||
* IP address and user agent.
|
||||
*/
|
||||
request?: unknown;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -644,6 +644,16 @@ export interface TermixApp extends TermixAppInfo {
|
||||
tabs: TabsApi;
|
||||
/** The desktop app, when the frontend runs in it. */
|
||||
desktop: DesktopApi;
|
||||
/**
|
||||
* Fires after this plugin's settings are saved from the settings screen or
|
||||
* the host editor. Disposed automatically.
|
||||
*/
|
||||
onSettingsChanged: (
|
||||
listener: (change: {
|
||||
scope: "admin" | "user" | "host";
|
||||
hostId?: number;
|
||||
}) => void,
|
||||
) => Disposer;
|
||||
/** Registered cleanup, run when the plugin is disabled. */
|
||||
onDispose: (dispose: Disposer) => void;
|
||||
}
|
||||
@@ -722,6 +732,7 @@ export interface PluginHostBridge {
|
||||
useTheme: () => { theme: "light" | "dark" };
|
||||
toast: ToastApi;
|
||||
getApi: (pluginId: string) => PluginApiClient;
|
||||
getApiFor: (pluginId: string, origin: unknown) => PluginApiClient;
|
||||
useTabs: () => TabsApi;
|
||||
invokeAction: (id: string, ...args: unknown[]) => Promise<unknown>;
|
||||
useSshAuthTypes: () => { types: SshAuthTypeInfo[]; loaded: boolean };
|
||||
@@ -739,6 +750,81 @@ export interface PluginHostBridge {
|
||||
activityTarget: (type: string) => ActivityTargetInfo | undefined;
|
||||
useHomepageWidgetTypes: () => HomepageWidgetContribution[];
|
||||
homepageWidgetType: (id: string) => HomepageWidgetContribution | undefined;
|
||||
core: PluginCoreApi;
|
||||
usePluginUiPreferences: (pluginId: string) => {
|
||||
values: Record<string, unknown>;
|
||||
set: (key: string, value: unknown) => void;
|
||||
};
|
||||
}
|
||||
|
||||
/** A key combination a user bound in Appearance > Keybindings. */
|
||||
export interface KeyCombo {
|
||||
key: string;
|
||||
isCode: boolean;
|
||||
ctrl: boolean;
|
||||
alt: boolean;
|
||||
shift: boolean;
|
||||
meta: boolean;
|
||||
}
|
||||
|
||||
export type KeybindingActionType =
|
||||
| "copy"
|
||||
| "paste"
|
||||
| "sendControlCode"
|
||||
| "sendText"
|
||||
| "runSnippet"
|
||||
| "nextTab"
|
||||
| "previousTab"
|
||||
| "openCommandPalette"
|
||||
| "reconnectSession";
|
||||
|
||||
export interface KeybindingAction {
|
||||
type: KeybindingActionType;
|
||||
text?: string;
|
||||
controlCode?: string;
|
||||
snippetId?: string;
|
||||
appendEnter?: boolean;
|
||||
}
|
||||
|
||||
export interface CustomKeybinding {
|
||||
id: string;
|
||||
combo: KeyCombo;
|
||||
action: KeybindingAction;
|
||||
enabled: boolean;
|
||||
overridesDefaultId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Core calls a plugin frontend makes for the signed-in user. */
|
||||
export interface PluginCoreApi {
|
||||
/** Adds a recent-activity entry. `type` is one of the plugin's activityTypes. */
|
||||
logActivity: (
|
||||
type: string,
|
||||
hostId: number,
|
||||
hostName: string,
|
||||
) => Promise<void>;
|
||||
/** A host's stored password or sudo password, for autofill. Null when unset. */
|
||||
getHostPassword: (
|
||||
hostId: number,
|
||||
field: "password" | "sudoPassword",
|
||||
) => Promise<string | null>;
|
||||
/** Updates the saved open-tab record a page reload reattaches from. */
|
||||
patchOpenTab: (
|
||||
instanceId: string,
|
||||
updates: {
|
||||
hostId?: number;
|
||||
label?: string;
|
||||
tabOrder?: number;
|
||||
backendSessionId?: string | null;
|
||||
},
|
||||
) => Promise<void>;
|
||||
/** The user's custom keybindings, enabled or not. */
|
||||
getCustomKeybindings: () => Promise<CustomKeybinding[]>;
|
||||
/** Turns auto tmux on or off in a host's terminal options. */
|
||||
setHostAutoTmux: (hostId: number, autoTmux: boolean) => Promise<void>;
|
||||
/** A browser-side UI preference (a cookie, or the desktop app's store). */
|
||||
getClientPreference: (name: string) => string | undefined;
|
||||
}
|
||||
|
||||
/** What a recent-activity entry opens and how it is labelled. */
|
||||
@@ -849,11 +935,72 @@ export function useToast(): ToastApi {
|
||||
return requireHost().toast;
|
||||
}
|
||||
|
||||
export function logActivity(
|
||||
type: string,
|
||||
hostId: number,
|
||||
hostName: string,
|
||||
): Promise<void> {
|
||||
return requireHost().core.logActivity(type, hostId, hostName);
|
||||
}
|
||||
|
||||
export function getHostPassword(
|
||||
hostId: number,
|
||||
field: "password" | "sudoPassword",
|
||||
): Promise<string | null> {
|
||||
return requireHost().core.getHostPassword(hostId, field);
|
||||
}
|
||||
|
||||
export function patchOpenTab(
|
||||
instanceId: string,
|
||||
updates: Parameters<PluginCoreApi["patchOpenTab"]>[1],
|
||||
): Promise<void> {
|
||||
return requireHost().core.patchOpenTab(instanceId, updates);
|
||||
}
|
||||
|
||||
export function getCustomKeybindings(): Promise<CustomKeybinding[]> {
|
||||
return requireHost().core.getCustomKeybindings();
|
||||
}
|
||||
|
||||
export function setHostAutoTmux(
|
||||
hostId: number,
|
||||
autoTmux: boolean,
|
||||
): Promise<void> {
|
||||
return requireHost().core.setHostAutoTmux(hostId, autoTmux);
|
||||
}
|
||||
|
||||
export function getClientPreference(name: string): string | undefined {
|
||||
return requireHost().core.getClientPreference(name);
|
||||
}
|
||||
|
||||
export function usePluginApi(): PluginApiClient {
|
||||
const bridge = requireHost();
|
||||
return bridge.getApi(bridge.usePluginId());
|
||||
}
|
||||
|
||||
/**
|
||||
* This plugin's client for a resolved connection origin: "remote" reaches the
|
||||
* desktop app's connected remote server, anything else is usePluginApi's.
|
||||
*/
|
||||
export function usePluginApiFor(origin: unknown): PluginApiClient {
|
||||
const bridge = requireHost();
|
||||
return bridge.getApiFor(bridge.usePluginId(), origin);
|
||||
}
|
||||
|
||||
/**
|
||||
* This plugin's Appearance area: the preset it declared in
|
||||
* contributes.uiPresets for the user's level, with the user's changes on top.
|
||||
* `set` saves one change; null clears it back to the preset.
|
||||
*/
|
||||
export function usePluginUiPreferences<
|
||||
T extends Record<string, unknown> = Record<string, unknown>,
|
||||
>(): {
|
||||
values: T;
|
||||
set: <K extends keyof T & string>(key: K, value: T[K] | null) => void;
|
||||
} {
|
||||
const bridge = requireHost();
|
||||
return bridge.usePluginUiPreferences(bridge.usePluginId()) as never;
|
||||
}
|
||||
|
||||
export function useTabs(): TabsApi {
|
||||
return requireHost().useTabs();
|
||||
}
|
||||
|
||||
@@ -224,6 +224,19 @@ export interface PluginSettingsField {
|
||||
group?: string;
|
||||
/** Registered component id. Required when type is "custom". */
|
||||
component?: string;
|
||||
/**
|
||||
* Host scope only: the admin field whose value a new host starts with,
|
||||
* instead of `default`. Hosts that never saved the field still read
|
||||
* `default`.
|
||||
*/
|
||||
defaultFrom?: string;
|
||||
/**
|
||||
* Host scope only: the lowest share level whose recipients see this value
|
||||
* in the host payload. Defaults to "connect", everyone who sees the host.
|
||||
*/
|
||||
shareRead?: "connect" | "view" | "edit" | "manage";
|
||||
/** Host scope only: only the host's owner may change it. */
|
||||
ownerOnly?: boolean;
|
||||
/**
|
||||
* Stored and validated like any field, but not drawn by the generic
|
||||
* settings form, because the plugin edits it in its own UI (a host editor
|
||||
@@ -236,6 +249,8 @@ export interface PluginHostSettingsContribution {
|
||||
/** Boolean field rendered first, gating the rest of the section. */
|
||||
enableKey?: string;
|
||||
enableLabelKey?: string;
|
||||
/** What the enable switch reads before a host saves it. Off by default. */
|
||||
enableDefault?: boolean;
|
||||
fields: PluginSettingsField[];
|
||||
}
|
||||
|
||||
@@ -288,6 +303,27 @@ export interface PluginContributions {
|
||||
*/
|
||||
hostCapability?: HostCapabilityContribution | HostCapabilityContribution[];
|
||||
auth?: PluginAuthContribution;
|
||||
http?: PluginHttpContribution;
|
||||
/**
|
||||
* This plugin's Appearance defaults for each interface preset, read in the
|
||||
* frontend with usePluginUiPreferences. Every preset names the same keys;
|
||||
* values are booleans, numbers, strings or string arrays.
|
||||
*/
|
||||
uiPresets?: Record<
|
||||
"simple" | "balanced" | "advanced",
|
||||
Record<string, unknown>
|
||||
>;
|
||||
}
|
||||
|
||||
export interface PluginHttpContribution {
|
||||
/**
|
||||
* Old URLs something outside Termix still calls (a webhook a third party
|
||||
* posts to). Each must start with "/<plugin id>/"; a request under it is
|
||||
* served by this plugin's router with that prefix removed, so
|
||||
* "/automations/webhook/x" reaches the route "/webhook/x". Core routes win
|
||||
* a clash.
|
||||
*/
|
||||
legacyPaths?: string[];
|
||||
}
|
||||
|
||||
export interface PluginManifest {
|
||||
@@ -375,6 +411,8 @@ const ALLOWED_CONTRIBUTES = new Set([
|
||||
"permissions",
|
||||
"settings",
|
||||
"hostCapability",
|
||||
"http",
|
||||
"uiPresets",
|
||||
"auth",
|
||||
]);
|
||||
|
||||
@@ -393,8 +431,13 @@ const ALLOWED_SETTINGS_FIELD = [
|
||||
"group",
|
||||
"component",
|
||||
"hidden",
|
||||
"defaultFrom",
|
||||
"shareRead",
|
||||
"ownerOnly",
|
||||
];
|
||||
|
||||
const SHARE_LEVELS = ["connect", "view", "edit", "manage"];
|
||||
|
||||
/** Settings keys are stored as-is, so they stay short and index-safe. */
|
||||
const SETTINGS_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
|
||||
|
||||
@@ -782,6 +825,78 @@ function validateContributes(
|
||||
validateSettings(contributes.settings, errors);
|
||||
validateHostCapability(contributes.hostCapability, errors);
|
||||
validateAuthContribution(contributes.auth, errors);
|
||||
validateHttpContribution(contributes.http, pluginId, errors);
|
||||
validateUiPresets(contributes.uiPresets, errors);
|
||||
}
|
||||
|
||||
function isPresetValue(value: unknown): boolean {
|
||||
return (
|
||||
typeof value === "boolean" ||
|
||||
(typeof value === "number" && Number.isFinite(value)) ||
|
||||
typeof value === "string" ||
|
||||
(Array.isArray(value) && value.every((item) => typeof item === "string"))
|
||||
);
|
||||
}
|
||||
|
||||
function validateUiPresets(value: unknown, errors: string[]): void {
|
||||
if (value === undefined) return;
|
||||
const where = "contributes.uiPresets";
|
||||
if (!isPlainObject(value)) {
|
||||
errors.push(`Field "${where}" must be an object`);
|
||||
return;
|
||||
}
|
||||
rejectUnknown(value, ["simple", "balanced", "advanced"], where, errors);
|
||||
let keys: string | undefined;
|
||||
for (const level of ["simple", "balanced", "advanced"]) {
|
||||
const preset = value[level];
|
||||
if (!isPlainObject(preset)) {
|
||||
errors.push(`${where}.${level} must be an object`);
|
||||
continue;
|
||||
}
|
||||
for (const [key, entry] of Object.entries(preset)) {
|
||||
if (!isPresetValue(entry)) {
|
||||
errors.push(
|
||||
`${where}.${level}.${key} must be a boolean, number, string or string array`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const names = Object.keys(preset).sort().join(",");
|
||||
if (keys === undefined) keys = names;
|
||||
else if (keys !== names) {
|
||||
errors.push(`${where} presets must all name the same keys`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const LEGACY_PATH_PATTERN = /^\/[a-z][a-z0-9-]*(\/[A-Za-z0-9_.-]+)+$/;
|
||||
|
||||
function validateHttpContribution(
|
||||
value: unknown,
|
||||
pluginId: string | undefined,
|
||||
errors: string[],
|
||||
): void {
|
||||
if (value === undefined) return;
|
||||
if (!isPlainObject(value)) {
|
||||
errors.push('Field "contributes.http" must be an object');
|
||||
return;
|
||||
}
|
||||
rejectUnknown(value, ["legacyPaths"], "contributes.http", errors);
|
||||
const paths = value.legacyPaths;
|
||||
if (paths === undefined) return;
|
||||
if (!Array.isArray(paths)) {
|
||||
errors.push('Field "contributes.http.legacyPaths" must be an array');
|
||||
return;
|
||||
}
|
||||
paths.forEach((path, index) => {
|
||||
const at = `contributes.http.legacyPaths[${index}]`;
|
||||
if (typeof path !== "string" || !LEGACY_PATH_PATTERN.test(path)) {
|
||||
errors.push(`${at} must be an absolute path like "/<plugin id>/name"`);
|
||||
return;
|
||||
}
|
||||
if (pluginId && !path.startsWith(`/${pluginId}/`)) {
|
||||
errors.push(`${at} must start with "/${pluginId}/"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const AUTH_ID_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
|
||||
@@ -861,7 +976,7 @@ function validateSettings(settings: unknown, errors: string[]): void {
|
||||
}
|
||||
rejectUnknown(
|
||||
host,
|
||||
["enableKey", "enableLabelKey", "fields"],
|
||||
["enableKey", "enableLabelKey", "enableDefault", "fields"],
|
||||
`"${at}"`,
|
||||
errors,
|
||||
);
|
||||
@@ -876,6 +991,9 @@ function validateSettings(settings: unknown, errors: string[]): void {
|
||||
// An enable switch with no label is a blank row in the host editor.
|
||||
requireString(host.enableLabelKey, `${at}.enableLabelKey`, errors);
|
||||
}
|
||||
if ("enableDefault" in host && typeof host.enableDefault !== "boolean") {
|
||||
errors.push(`${at}.enableDefault must be a boolean`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(host.fields)) {
|
||||
errors.push(`${at}.fields must be an array`);
|
||||
@@ -887,6 +1005,48 @@ function validateSettings(settings: unknown, errors: string[]): void {
|
||||
errors,
|
||||
typeof host.enableKey === "string" ? host.enableKey : undefined,
|
||||
);
|
||||
|
||||
const adminKeys = new Set(
|
||||
(Array.isArray(settings.admin) ? settings.admin : [])
|
||||
.filter(isPlainObject)
|
||||
// A secret never becomes a default the host editor can read.
|
||||
.filter((field) => field.type !== "secret")
|
||||
.map((field) => field.key),
|
||||
);
|
||||
host.fields.forEach((raw, index) => {
|
||||
if (!isPlainObject(raw)) return;
|
||||
const fieldAt = `${at}.fields[${index}]`;
|
||||
if ("defaultFrom" in raw && !adminKeys.has(raw.defaultFrom as string)) {
|
||||
errors.push(
|
||||
`${fieldAt}.defaultFrom must name one of this plugin's non-secret admin fields`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
"shareRead" in raw &&
|
||||
!SHARE_LEVELS.includes(raw.shareRead as string)
|
||||
) {
|
||||
errors.push(
|
||||
`${fieldAt}.shareRead must be one of: ${SHARE_LEVELS.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if ("ownerOnly" in raw && typeof raw.ownerOnly !== "boolean") {
|
||||
errors.push(`${fieldAt}.ownerOnly must be a boolean`);
|
||||
}
|
||||
});
|
||||
}
|
||||
for (const scope of ["admin", "user"] as const) {
|
||||
const fields = settings[scope];
|
||||
if (!Array.isArray(fields)) continue;
|
||||
fields.forEach((raw, index) => {
|
||||
if (!isPlainObject(raw)) return;
|
||||
for (const key of ["defaultFrom", "shareRead", "ownerOnly"]) {
|
||||
if (key in raw) {
|
||||
errors.push(
|
||||
`${where}.${scope}[${index}].${key} is only valid on host fields`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -243,6 +243,12 @@ export interface FakeAuthRegistrations {
|
||||
|
||||
export interface FakePluginContext {
|
||||
ctx: PluginContext;
|
||||
/** Runs what ctx.settings.onValidate registered, as a settings save would. */
|
||||
validateSettings: (
|
||||
scope: "admin" | "user" | "host",
|
||||
values: Record<string, unknown>,
|
||||
context?: { hostId?: number },
|
||||
) => Promise<Record<string, string>>;
|
||||
/** Everything the plugin registered through ctx.disposables. */
|
||||
disposals: Array<() => void | Promise<void>>;
|
||||
/** Topics emitted through ctx.events.emit, in order. */
|
||||
@@ -417,6 +423,10 @@ export function createFakeContext(
|
||||
Object.entries(options.coreSettings ?? {}),
|
||||
);
|
||||
const settingsListeners = new Map<string, Set<(value: unknown) => void>>();
|
||||
const settingsValidators = new Set<{
|
||||
scope: "admin" | "user" | "host";
|
||||
validator: Parameters<PluginContext["settings"]["onValidate"]>[1];
|
||||
}>();
|
||||
const sshConnections: FakePluginContext["sshConnections"] = [];
|
||||
const hostShares: FakePluginContext["hostShares"] = [];
|
||||
const desktopWindows: FakePluginContext["desktopWindows"] = [];
|
||||
@@ -499,6 +509,15 @@ export function createFakeContext(
|
||||
: null;
|
||||
const holds = (permission: string) =>
|
||||
held === null || held.has(qualifyPermission(pluginId, permission));
|
||||
// Like the runtime, a route can only require a permission the manifest
|
||||
// declares. Checked when the test hands in a manifest.
|
||||
const declaredPermissions = options.manifest?.contributes?.permissions
|
||||
? new Set(
|
||||
options.manifest.contributes.permissions.map((entry) =>
|
||||
qualifyPermission(pluginId, entry.name),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
for (const [key, value] of Object.entries(options.settings ?? {})) {
|
||||
settings.set(settingsKey("admin", undefined, key), value);
|
||||
@@ -694,8 +713,16 @@ export function createFakeContext(
|
||||
// other plugins' namespaces is covered by core's tests, not here.
|
||||
has: async (permission) => holds(permission),
|
||||
hasFor: async (_userId, permission) => holds(permission),
|
||||
require: (permission) =>
|
||||
((_req, res, next) => {
|
||||
require: (permission) => {
|
||||
if (
|
||||
declaredPermissions &&
|
||||
!declaredPermissions.has(qualifyPermission(pluginId, permission))
|
||||
) {
|
||||
throw new Error(
|
||||
`Plugin ${pluginId} cannot require permission "${permission}": it is not declared in contributes.permissions`,
|
||||
);
|
||||
}
|
||||
return ((_req, res, next) => {
|
||||
if (holds(permission)) {
|
||||
next();
|
||||
return;
|
||||
@@ -704,7 +731,8 @@ export function createFakeContext(
|
||||
error: "Insufficient permissions",
|
||||
required: qualifyPermission(pluginId, permission),
|
||||
});
|
||||
}) as PluginMiddleware,
|
||||
}) as PluginMiddleware;
|
||||
},
|
||||
},
|
||||
|
||||
// Nothing here is capability-checked, per the module doc; createMockCtx
|
||||
@@ -729,6 +757,22 @@ export function createFakeContext(
|
||||
setHost: async (hostId, key, value) =>
|
||||
writeSetting("host", String(hostId), key, value),
|
||||
|
||||
listHostValues: async (key) => {
|
||||
const found: Array<{ hostId: number; userId: string; value: never }> =
|
||||
[];
|
||||
for (const [storedKey, value] of settings) {
|
||||
const [scope, scopeId, name] = storedKey.split(":");
|
||||
if (scope !== "host" || name !== key || value === undefined) continue;
|
||||
const hostId = Number(scopeId);
|
||||
found.push({
|
||||
hostId,
|
||||
userId: hostRecordsById.get(hostId)?.userId ?? actor ?? "",
|
||||
value: value as never,
|
||||
});
|
||||
}
|
||||
return found;
|
||||
},
|
||||
|
||||
getAll: async (scope, scopeId) => {
|
||||
const prefix = `${scope}:${scopeId ?? ""}:`;
|
||||
const all: Record<string, unknown> = {};
|
||||
@@ -754,6 +798,16 @@ export function createFakeContext(
|
||||
return unsubscribe;
|
||||
},
|
||||
|
||||
onValidate: (scope, validator) => {
|
||||
const entry = { scope, validator };
|
||||
settingsValidators.add(entry);
|
||||
const unsubscribe = () => {
|
||||
settingsValidators.delete(entry);
|
||||
};
|
||||
disposals.push(unsubscribe);
|
||||
return unsubscribe;
|
||||
},
|
||||
|
||||
readCore: async (key) => coreSettings.get(key) ?? null,
|
||||
},
|
||||
|
||||
@@ -919,12 +973,17 @@ export function createFakeContext(
|
||||
),
|
||||
requiresSecret: (authType) =>
|
||||
["password", "key", "credential", "agent"].includes(authType),
|
||||
supportsBackground: (authType) =>
|
||||
authType !== "none" &&
|
||||
!auth.sshAuthProviders.some(
|
||||
(provider) =>
|
||||
provider.type === authType && provider.supportsBackground === false,
|
||||
),
|
||||
// Matches core: the built-in types, plus any registered provider that
|
||||
// does not opt out. An unknown type cannot connect unattended.
|
||||
supportsBackground: (authType) => {
|
||||
if (["password", "key", "credential", "agent"].includes(authType)) {
|
||||
return true;
|
||||
}
|
||||
const provider = auth.sshAuthProviders.find(
|
||||
(candidate) => candidate.type === authType,
|
||||
);
|
||||
return !!provider && provider.supportsBackground !== false;
|
||||
},
|
||||
},
|
||||
|
||||
auth: {
|
||||
@@ -1150,6 +1209,14 @@ export function createFakeContext(
|
||||
|
||||
return {
|
||||
ctx,
|
||||
validateSettings: async (scope, values, context = {}) => {
|
||||
const errors: Record<string, string> = {};
|
||||
for (const entry of settingsValidators) {
|
||||
if (entry.scope !== scope) continue;
|
||||
Object.assign(errors, (await entry.validator(values, context)) ?? {});
|
||||
}
|
||||
return errors;
|
||||
},
|
||||
disposals,
|
||||
emitted,
|
||||
kv,
|
||||
@@ -1385,7 +1452,15 @@ export function createMockCtx(
|
||||
}
|
||||
ctx.events.emit(topic, payload);
|
||||
},
|
||||
on: (topic, listener) => ctx.events.on(topic, listener),
|
||||
on: (topic, listener) => {
|
||||
if (!topic.startsWith("plugin.") && !granted.has("events:core")) {
|
||||
throw new Error(
|
||||
`Plugin ${pluginId} may only listen to "plugin.*" topics. ` +
|
||||
`Declare the events:core capability to listen to core topics.`,
|
||||
);
|
||||
}
|
||||
return ctx.events.on(topic, listener);
|
||||
},
|
||||
},
|
||||
|
||||
http: {
|
||||
@@ -1411,6 +1486,10 @@ export function createMockCtx(
|
||||
// Reading and writing a plugin's OWN settings is ungated, exactly as in
|
||||
// the real runtime. Only readCore reaches outside the plugin.
|
||||
...ctx.settings,
|
||||
listHostValues: async (key) => {
|
||||
require("hosts:read");
|
||||
return ctx.settings.listHostValues(key);
|
||||
},
|
||||
readCore: async (key) => {
|
||||
require("settings:read-core");
|
||||
return ctx.settings.readCore(key);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Keeps a test server's random port off the fetch spec's blocked list.
|
||||
*
|
||||
* Tests start servers on port 0 and call them with fetch, which refuses
|
||||
* ports like 6000 or 6667 with "bad port". Linux never hands those out as
|
||||
* ephemeral ports, but Windows can (its dynamic range may start at 1024), so
|
||||
* a server that lands on one closes and listens again.
|
||||
*/
|
||||
|
||||
import net from "node:net";
|
||||
|
||||
export const FETCH_BLOCKED_PORTS = new Set([
|
||||
1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79,
|
||||
87, 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137,
|
||||
139, 143, 161, 179, 389, 427, 465, 512, 513, 514, 515, 526, 530, 531, 532,
|
||||
540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993, 995, 1719, 1720, 1723,
|
||||
2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669,
|
||||
6679, 6697, 10080,
|
||||
]);
|
||||
|
||||
function wantsRandomPort(args: unknown[]): boolean {
|
||||
const first = args[0];
|
||||
if (first === 0 || first === "0") return true;
|
||||
if (first && typeof first === "object" && !Array.isArray(first)) {
|
||||
const port = (first as { port?: unknown }).port;
|
||||
return port === 0 || port === "0";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const PATCHED = Symbol.for("termix.safePorts");
|
||||
|
||||
export function installSafePorts(): void {
|
||||
const proto = net.Server.prototype as net.Server & { [PATCHED]?: boolean };
|
||||
if (proto[PATCHED]) return;
|
||||
proto[PATCHED] = true;
|
||||
|
||||
const listen = proto.listen as (
|
||||
this: net.Server,
|
||||
...args: unknown[]
|
||||
) => net.Server;
|
||||
proto.listen = function (this: net.Server, ...args: unknown[]) {
|
||||
if (wantsRandomPort(args)) retryOnBlockedPort(this, args);
|
||||
return listen.apply(this, args);
|
||||
} as typeof proto.listen;
|
||||
}
|
||||
|
||||
function retryOnBlockedPort(server: net.Server, args: unknown[]): void {
|
||||
const emit = server.emit;
|
||||
// The caller's callback is already a pending "listening" listener, so a
|
||||
// retry leaves it out and it fires once, on the good port.
|
||||
const retryArgs = args.filter((arg) => typeof arg !== "function");
|
||||
server.emit = ((event: string | symbol, ...rest: unknown[]) => {
|
||||
if (event === "listening") {
|
||||
const address = server.address();
|
||||
if (
|
||||
address &&
|
||||
typeof address === "object" &&
|
||||
FETCH_BLOCKED_PORTS.has(address.port)
|
||||
) {
|
||||
server.close(() => server.listen(...(retryArgs as [])));
|
||||
return true;
|
||||
}
|
||||
server.emit = emit;
|
||||
}
|
||||
return emit.call(server, event, ...rest);
|
||||
}) as typeof server.emit;
|
||||
}
|
||||
@@ -6,6 +6,9 @@
|
||||
*/
|
||||
|
||||
import { afterEach, vi } from "vitest";
|
||||
import { installSafePorts } from "./safe-ports.js";
|
||||
|
||||
installSafePorts();
|
||||
|
||||
if (typeof window !== "undefined" && !window.matchMedia) {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
@@ -114,6 +117,28 @@ const offline = () =>
|
||||
}),
|
||||
useSlotContributions: () => [],
|
||||
usePluginComponent: () => undefined,
|
||||
getApiFor: () => ({
|
||||
get: offline,
|
||||
delete: offline,
|
||||
post: offline,
|
||||
put: offline,
|
||||
patch: offline,
|
||||
}),
|
||||
hostProtocols: () => ["ssh"],
|
||||
useHostActions: () => [],
|
||||
useActivityTypes: () => [],
|
||||
activityTarget: () => undefined,
|
||||
useHomepageWidgetTypes: () => [],
|
||||
homepageWidgetType: () => undefined,
|
||||
usePluginUiPreferences: () => ({ values: {}, set: noop }),
|
||||
core: {
|
||||
logActivity: async () => {},
|
||||
getHostPassword: async () => null,
|
||||
patchOpenTab: async () => {},
|
||||
getCustomKeybindings: async () => [],
|
||||
setHostAutoTmux: async () => {},
|
||||
getClientPreference: () => undefined,
|
||||
},
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"skipLibCheck": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
|
||||
Reference in new issue
Block a user