mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
chore: lock the plugin boundary and remove 2.9.0 leftovers
This commit is contained in:
1 parent
6489b62ece
commit
2961297989
399 files changed
+3964
-5627
No files matched your search
+194
-177
@@ -43,8 +43,16 @@ dependencies. Entry points:
|
||||
|
||||
Plugins never import from `src/` or `@/`, and core never imports from
|
||||
`plugins/`. Anything a plugin needs from core becomes a typed, documented SDK
|
||||
API. Enforced by lint, with the exceptions recorded under
|
||||
[legacy core imports](#legacy-core-imports-the-debt-d1-removes).
|
||||
API. Enforced by lint, with no exceptions:
|
||||
|
||||
- `eslint.config.mjs` runs a local rule, `termix-plugins/stay-inside`, on
|
||||
every file under `plugins/*/src` and `plugins/*/tests`. It refuses `@/...`
|
||||
and any relative import that resolves outside `plugins/<id>/`, which covers
|
||||
core (`src/`) and every other plugin.
|
||||
- The same config refuses a `src/` file importing `plugins/*/src` or
|
||||
`plugins/*/dist`.
|
||||
- `scripts/check-core-plugin-ids.cjs` (see [UI](#8-ui)) refuses core naming a
|
||||
plugin at all.
|
||||
|
||||
The SDK holds the contract: types, the catalog, and pure helpers. The ctx
|
||||
implementation lives in core (`src/backend/plugins/ctx.ts`) and is injected at
|
||||
@@ -78,7 +86,7 @@ can never be confused with colon-separated capabilities.
|
||||
`contributes.permissions`, and core registers each as `<pluginId>.<name>`. The
|
||||
plugin never writes the prefix, so it cannot claim a core group or another
|
||||
plugin's namespace: `parseManifest` refuses a name starting with `hosts`,
|
||||
`snippets`, `credentials`, `admin` or the plugin's own id, and the runtime
|
||||
`credentials`, `admin` or the plugin's own id, and the runtime
|
||||
re-checks against every namespace already in the catalog, because the validator
|
||||
cannot know which other plugins exist.
|
||||
|
||||
@@ -184,6 +192,34 @@ Automations replaced them and A3 drops them. A core table needs all three
|
||||
artifacts in lockstep: the `schema.ts` declaration, the `index.ts` DDL, and the
|
||||
regenerated `schema.pg.ts`/`schema.mysql.ts` plus drizzle migrations.
|
||||
|
||||
**What stays in core.** After 2.9.0, `schema.ts`, the SQLite DDL in
|
||||
`db/index.ts`, `performance-indexes.ts`, the repository factory and the
|
||||
drizzle migrations hold only these 38 tables. Anything else is a plugin's,
|
||||
under `p_<id>_`.
|
||||
|
||||
| Area | Tables |
|
||||
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Accounts | `users`, `sessions`, `trusted_devices`, `api_keys`, `user_external_identities`, `user_second_factors` |
|
||||
| Preferences | `settings`, `user_preferences`, `ui_preferences`, `host_sidebar_preferences`, `credential_sidebar_preferences`, `user_open_tabs` |
|
||||
| Hosts | `ssh_data`, `ssh_folders`, `recent_activity`, `dismissed_alerts` |
|
||||
| Credentials | `ssh_credentials`, `ssh_credential_usage` |
|
||||
| Sharing and RBAC | `roles`, `user_roles`, `host_access`, `folder_access`, `credential_access`, `shared_host_secrets`, `shared_host_auth_overrides`, `shared_credential_secrets`, `rbac_known_permissions`, `rbac_applied_defaults` |
|
||||
| Audit and alerts | `audit_logs`, `notification_channels` |
|
||||
| Sync | `sync_tombstones` |
|
||||
| Plugin runtime | `plugins`, `plugin_permission_grants`, `plugin_registries`, `plugin_install_counts`, `plugin_storage`, `plugin_settings`, `plugin_migrations` |
|
||||
|
||||
A few core tables still carry columns a plugin reads through its own host or
|
||||
user settings after the upgrade (the moved `ssh_data` feature columns, the
|
||||
2.8 OIDC columns on `users`, `sessions.oidc_sub`/`oidc_sid`). They are
|
||||
dropped in 3.0.0; see [Moving a host column into a plugin](#moving-a-host-column-into-a-plugin).
|
||||
|
||||
**Exports and imports follow the plugins.** The per-user SQLite export and
|
||||
the JSON user export include every row the user owns in a plugin table: any
|
||||
table a running plugin declared with a `refUser()` column
|
||||
(`src/backend/plugins/user-data.ts`). Encrypted columns stay behind, since
|
||||
they are sealed with this server's keys. An import reads each table by its
|
||||
`p_` name, or by the legacy name it adopted for a file written before 2.9.
|
||||
|
||||
**Sync entities are registered, not hardcoded.** `ctx.sync.registerEntity`
|
||||
adds an entity to remote sync; core registers its own the same way in
|
||||
`database/routes/sync-entities.ts`. The wire names are unchanged, because
|
||||
@@ -272,6 +308,26 @@ handler (noted for D2). Every route connection now also carries `clientIp`,
|
||||
`requestOrigin` and `isDataUnlocked()`, the data-key check a long-lived socket
|
||||
repeats per message so an expired session stops being served.
|
||||
|
||||
**Old URLs outside the namespace.** A few 2.8 URLs are called by something
|
||||
outside Termix: identity provider callbacks, the Vault OIDC callback, the
|
||||
Termix ID resolver, back-channel logout. A plugin lists them in
|
||||
`contributes.http.legacyRedirects` as `{ from, to, status? }`. Core redirects
|
||||
a request at `from` (or under it) to `/plugin-api/<id><to>` with the rest of
|
||||
the path and the query kept, 307 by default or 308 for a permanent move.
|
||||
`from` may be any absolute path outside the plugin framework's own prefixes,
|
||||
and core routes win a clash. It is a redirect rather than a forward because a
|
||||
cookie the plugin set is scoped to its own path. `contributes.http.legacyPaths`
|
||||
is the other form, for URLs already under `/<plugin id>/` (automation
|
||||
webhooks), which the plugin's router serves in place. 2.8 clients' login
|
||||
routes (`/users/<method>/authorize`, `/users/<method>/login`) stay in core as
|
||||
generic aliases of the login pipeline.
|
||||
|
||||
The nginx configs keep one small group of blocks for those public 2.8 URLs
|
||||
(`/vault/oidc/callback`, `/termix-id/u/`, `/automations/webhook/`); the rest
|
||||
already fall under core's `/users` and `/host` blocks. A new plugin never
|
||||
needs an nginx change: everything it serves is under `/plugin-api/`,
|
||||
`/plugin-ws/` or `/plugin-assets/`.
|
||||
|
||||
Socket and ssh2 event listeners fire outside the upgrade's async context, so a
|
||||
plugin that calls privileged ctx members from them binds them with Node's
|
||||
`AsyncResource.bind` while the actor is still set; the terminal does this for
|
||||
@@ -371,14 +427,26 @@ allowlist (`CORE_SETTINGS_ALLOWLIST`) rather than the whole settings table.
|
||||
A plugin frontend is a built ESM bundle, `dist/frontend.js`, exporting
|
||||
`activate(app)` and optionally `deactivate()`. The shell has no plugin ids and
|
||||
no imports from `plugins/`; everything a plugin shows goes through the `app`
|
||||
object. `scripts/check-shell-plugin-ids.cjs` (run by lint) fails on a quoted
|
||||
plugin id in `src/ui` outside tests, apart from the entries in
|
||||
`scripts/shell-plugin-id-allowlist.json`, each with a reason (host data like
|
||||
the `docker` runtime name, which Phase B moves). **B14** removed the `rdp`,
|
||||
`vnc` and `telnet` entries: the shell learns those protocols from
|
||||
`app.registerHostProtocol`. **B15** removed both `docker` entries: the Hosts
|
||||
panel's Docker filter and the bulk Docker toggles went with the column, and
|
||||
the host editor no longer carries a Proxmox default that spelled `docker`.
|
||||
object. `scripts/check-core-plugin-ids.cjs` (run by lint) scans all of `src/`
|
||||
and fails on any plugin id core spells: a bare literal (`"docker"`), a plugin
|
||||
route (`"/plugin-api/docker/..."`), or an action, slot or permission id that
|
||||
starts with one (`"docker.open"`). The shell (`src/ui`) also may not spell a
|
||||
view a plugin owns (a tab, panel or dashboard card id). There is no
|
||||
allowlist. Three things are exempt, by rule rather than by entry:
|
||||
|
||||
- tests and locales;
|
||||
- `src/backend/upgrade/`, the one-time 2.8 to 2.9 data moves, which have to
|
||||
name the plugin each piece of data moves to, the same way
|
||||
`LEGACY_TABLE_OWNERS` names the plugin that adopts each legacy table;
|
||||
- `"totp"` inside `src/backend/hosts/connect/`, where it is the SSH
|
||||
keyboard-interactive one-time-code prompt kind, not the login plugin.
|
||||
|
||||
Where core needs something from whichever plugin provides it, core names the
|
||||
contract and a plugin fills it: a slot (`dashboard.counters`,
|
||||
`admin.userTabs`), an action (`host.openFiles`, `snippet.list`,
|
||||
`snippet.resolveForTerminal`) or a component id (`credentials.secretHint`,
|
||||
`credentials.secretManager`). With no provider, the slot renders nothing and
|
||||
the action answers `undefined`.
|
||||
|
||||
#### The loader
|
||||
|
||||
@@ -425,8 +493,8 @@ the full pass does not load the same bundle twice. See Auth below.
|
||||
#### Shared modules and the import map
|
||||
|
||||
A bundle keeps `react`, `react-dom`, `react-dom/client`, `react/jsx-runtime`,
|
||||
`i18next`, `react-i18next`, `sonner`, `@termix/plugin-sdk/frontend`,
|
||||
`@termix/plugin-sdk/ui` and `@termix/legacy-core/*` as bare imports. Core's
|
||||
`i18next`, `react-i18next`, `sonner`, `@termix/plugin-sdk/frontend` and
|
||||
`@termix/plugin-sdk/ui` as bare imports. Core's
|
||||
Vite build (`scripts/vite-plugin-termix-plugins.mjs`) emits one entry per
|
||||
shared module, writes a stable unhashed shim for each at
|
||||
`dist/shared/<name>.js`, and injects an import map into `index.html` mapping
|
||||
@@ -434,8 +502,7 @@ the bare names to those shims. In dev the map points at Vite's virtual
|
||||
modules instead. The map is inline, so both nginx configs allow it by hash in
|
||||
`script-src`; `scripts/check-importmap-csp.cjs` (run by lint) fails when the
|
||||
hash is out of date and `--write` updates it. The map changes only when the
|
||||
list of shared modules does, which includes the legacy-core modules plugins
|
||||
import.
|
||||
list of shared modules does.
|
||||
|
||||
Electron loads `index.html` from `file://`. The map uses relative URLs, and
|
||||
plugin bundles come from the embedded backend
|
||||
@@ -482,6 +549,22 @@ boundary and Suspense.
|
||||
| `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces; `tabs.openRailView` (**B12**) opens a rail view |
|
||||
| `guest`, `info`, `onDispose` | Guest mode flag, plugin info, extra cleanup |
|
||||
| `desktop` | `available`, `remoteServerUrl()` and `onRemoteServerChange`: whether the frontend runs in the desktop app and which remote server it syncs with (**C6**) |
|
||||
| `registerComponent` | A component other plugins and core render by id with `PluginComponent` |
|
||||
| `onSettingsChanged` | Called when one of the plugin's settings changes |
|
||||
|
||||
A few contribution fields core reads generically:
|
||||
|
||||
| Field | Effect |
|
||||
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `RailItemContribution.simplePreset` | The item stays visible in the Simple interface preset, which hides every other plugin rail item |
|
||||
| `HostActionContribution.quickConnect` | The action is offered as a button in Quick Connect, opening its `tabType` for an address that is never saved |
|
||||
| `PluginSshAuthProvider.quickConnect` | (backend) The auth type works for an unsaved host, so Quick Connect lists it and renders the plugin's `SshAuthEditor` for its fields |
|
||||
| `SshAuthTypeInfo.quickConnect` | What `useSshAuthTypes()` reports for the above |
|
||||
|
||||
Core calls outside a component (`@termix/plugin-sdk/frontend`): `logActivity`,
|
||||
`getHostPassword`, `patchOpenTab`, `getCustomKeybindings`, `setHostAutoTmux`,
|
||||
`getClientPreference`, `listHosts` (every host the user can see, fetched now)
|
||||
and `listCredentials` (the user's credentials without secrets, for a picker).
|
||||
|
||||
Hooks: `useTranslation` (the plugin's namespace), `usePermission` (a short
|
||||
name resolves to `<id>.<name>`), `useSettings`, `useHost`, `useHosts`,
|
||||
@@ -494,7 +577,21 @@ to list manager cards plugins added to `host-metrics.managers` next to its
|
||||
own). The SDK has no runtime dependencies: the hooks delegate to a host
|
||||
bridge core installs.
|
||||
|
||||
Slots core owns: `terminal.toolbar`, `terminal.toolbarStatus`,
|
||||
Slots and actions core names for plugins to fill: `dashboard.counters`
|
||||
(component slot in the dashboard's counters card; tunnels shows its active
|
||||
tunnel count there), `admin.userTabs` (component slot, one tab per
|
||||
contribution in the admin "manage user" panel, rendered with `{ user }` and
|
||||
replaced by core's locked notice while the user's data is locked; snippets
|
||||
adds its tab), the `host.openFiles` action (the tab bar's file manager
|
||||
button), the `snippet.list` and `snippet.resolveForTerminal` actions (core's
|
||||
snippet pickers and the command palette's run flow) and the
|
||||
`credentials.secretHint` / `credentials.secretManager` components (the
|
||||
credential and host editors' secret reference UI). **D1** added these in
|
||||
place of `tunnels.statuses`, `tunnels.open`, `files.openHost`,
|
||||
`snippets.resolveForTerminal` and the `secret-sources.*` component ids, which
|
||||
core used to call by the plugin's name.
|
||||
|
||||
Other slots: `terminal.toolbar`, `terminal.toolbarStatus`,
|
||||
`terminal.sidePanel`, `terminal.overlay` (declared by ssh-terminal; **B9**
|
||||
renamed `terminal.dock` to `terminal.sidePanel` and added the status slot,
|
||||
where host-metrics puts its CPU, memory and disk bars; **C3** gave overlay
|
||||
@@ -526,10 +623,7 @@ invoked with `{ hostId, sessionId, protocol, tabInstanceId }`.
|
||||
**B3**) is host-metrics's own: tailscale contributes its manager card there
|
||||
instead of host-metrics knowing tailscale exists, and host-metrics works with
|
||||
or without tailscale enabled. Cross-plugin frontend calls go through actions:
|
||||
`automations.list`, `fleets.list`, `session.remoteDisplay.token`. Core calls
|
||||
plugin actions the same way when a core view shows a plugin's data:
|
||||
`files.openHost`, and **B7**'s `tunnels.statuses` and `tunnels.open` behind the
|
||||
dashboard's active tunnel counter, which reads as zero while tunnels is off.
|
||||
`automations.list`, `fleets.list`, `session.remoteDisplay.token`.
|
||||
**B16** added `host-metrics.disk` (the file manager's disk usage bar) and
|
||||
`snippets.get` / `snippets.execute` (host-metrics's quick actions); each
|
||||
answers undefined while its plugin is off.
|
||||
@@ -628,6 +722,16 @@ 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`.
|
||||
**D1** added what the last plugins reaching into core used: `Kbd`,
|
||||
`KbdKey` and `KbdSeparator`; `ConnectionLogPanel` and the `LogEntry`
|
||||
type; `createFrontendLogger(name)`, a console logger in core's format; the
|
||||
adaptive resource budget (`getAdaptiveResourceBudget`,
|
||||
`markAdaptiveResourceUsed`, `runAdaptiveBackgroundTask`) so a plugin's
|
||||
background work shares one budget with the shell; `getDeviceId`;
|
||||
`createQuickConnectHost` and `isQuickConnectHost`; and the desktop app's
|
||||
local file bridge types (`LocalFileEntry`, `LocalDirectoryListing`,
|
||||
`LocalWalkResult`, `LocalTransferProgress` and the rest), for a plugin that
|
||||
drives `window.electronAPI.localFs` itself.
|
||||
**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**,
|
||||
@@ -685,8 +789,13 @@ where Crowdin writes them. Only `en.json` is edited by hand.
|
||||
|
||||
#### Type checking
|
||||
|
||||
`tsconfig.plugins.frontend.json` checks every plugin frontend and its tests
|
||||
with bundler resolution, and is part of `npm run type-check`.
|
||||
`npm run type-check` covers every plugin through two root configs:
|
||||
`tsconfig.plugins.frontend.json` (plugin frontends and their tests, bundler
|
||||
resolution; it extends the app config because `@termix/plugin-sdk/ui` is
|
||||
core's own `sdk-ui.ts`) and `tsconfig.plugins.backend.json` (plugin backends,
|
||||
`nodenext`, the SDK's built `.d.ts`). Neither gives a plugin a path into
|
||||
core; the `@/` alias the frontend config inherits exists for `sdk-ui.ts`
|
||||
itself, and lint refuses it in plugin code.
|
||||
|
||||
### 9. Cross-plugin use
|
||||
|
||||
@@ -1296,8 +1405,8 @@ Every SSH connection, core's and plugins', goes through
|
||||
decryption, shared-host overrides and shared agent, and `op://` external
|
||||
secret references.
|
||||
2. `buildConnectConfig` builds the ssh2 config: the defaults for the
|
||||
connection's purpose (terminal, file-manager, tmux, metrics, fleet, docker,
|
||||
...), the host key verifier, then the auth provider's `prepare`.
|
||||
connection's profile (terminal, session, stream, forward, background or
|
||||
jump), the host key verifier, then the auth provider's `prepare`.
|
||||
3. `openSshTransport` does port knocking, the Cloudflare Access tunnel, the
|
||||
jump host chain (every hop through steps 1 and 2) or SOCKS5.
|
||||
4. `connectHost` connects with a keyboard-interactive handler: a prompt channel
|
||||
@@ -1867,48 +1976,51 @@ deciding, not the mechanism.
|
||||
## The ctx surface
|
||||
|
||||
Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
|
||||
Every member is listed; the types in `@termix/plugin-sdk/backend` are the
|
||||
authority for signatures.
|
||||
|
||||
| Member | Capability | Status |
|
||||
| ------------------------------------------------ | -------------------------------------------------- | ----------------------------------- |
|
||||
| `ctx.pluginId`, `ctx.manifest` | none | **A1** |
|
||||
| `ctx.log.*` | none | **A1** |
|
||||
| `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** |
|
||||
| `ctx.kv.get/set/delete/list` | `kv:own` | **A1** |
|
||||
| `ctx.files.dataDir` | `files:own` | **B13** |
|
||||
| `ctx.registry.*` | none | **A1** |
|
||||
| `ctx.services.provide` / `.get` / `.providers` | per-service RBAC | **A1**, named **B12** |
|
||||
| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** |
|
||||
| `ctx.secrets.get` / `.set` / `.delete` | `secrets:own` | **B18** |
|
||||
| `ctx.secrets.seal` / `.unseal` | `secrets:own` | **C1** |
|
||||
| `ctx.disposables.add` | none | **A1** |
|
||||
| `ctx.asUser(userId, fn)` | none, always audited | **A1** |
|
||||
| `ctx.currentActor()` | none | **A1** |
|
||||
| `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** |
|
||||
| `ctx.db.persist` / `.dialect` | `db:own` (persist only) | **A9**, lazy **B16** |
|
||||
| `ctx.sync.registerEntity` | none | **A3** |
|
||||
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
|
||||
| `ctx.http.baseUrl(req)` | none | **C2** |
|
||||
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
|
||||
| `ctx.capabilities.has` / `.require` | the capability itself | **B11** |
|
||||
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4**, **B5**, **B18** |
|
||||
| `ctx.hosts.status.*` | `hosts:read` | **B16** |
|
||||
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** |
|
||||
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** |
|
||||
| `ctx.notify.channels` / `.send` | `notify:send` | **B17** |
|
||||
| `ctx.auth.*` | `auth:provide` | **A8** |
|
||||
| `ctx.auth.completeRedirectLogin` and the rest | `auth:provide` | **C2** |
|
||||
| `ctx.desktop.openIsolatedWindow` | `desktop:window` | **B8** |
|
||||
| `ctx.desktop.launchNativeRdp` / `.available` | `desktop:window` (launch only) | **B14** |
|
||||
| `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** |
|
||||
| `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**, 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** |
|
||||
| `ctx.auth.registerKeyboardInteractiveHandler` | `auth:provide` | **C3** |
|
||||
| `ctx.system.*` | `system:tls` | **C7** |
|
||||
| Member | Capability | What it does |
|
||||
| -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `pluginId`, `manifest` | none | The plugin's id and parsed manifest |
|
||||
| `log.debug/info/warn/error` | none | Lines in core's log, tagged with the plugin |
|
||||
| `events.emit/on` | `events:core` for core topics | Topics under `plugin.<id>.*`, or core's (`user.deleted`, `user.data_wiped`, host events) |
|
||||
| `kv.get/set/delete/list` | `kv:own` | Small key/value state |
|
||||
| `files.dataDir()` | `files:own` | A per-plugin folder under `DATA_DIR` |
|
||||
| `db.define/client/refs/persist/dialect` | `db:own` (not `dialect`) | The plugin's own tables through Drizzle; `persist` flushes SQLite to disk |
|
||||
| `sync.registerEntity/recordTombstone` | none | Adds a table to remote sync, and records a delete for it |
|
||||
| `registry.provide/consume/revoke` | none | The low-level named registry services sit on |
|
||||
| `services.provide/get/providers` | per-service RBAC | Typed, versioned cross-plugin services, checked per call |
|
||||
| `secrets.get/set/delete/seal/unseal` | `secrets:own` | The plugin's own encrypted values |
|
||||
| `secrets.offer/withdraw/getShared` | per-secret RBAC | Secrets one plugin lends another |
|
||||
| `http.router(options)`, `http.baseUrl(req)` | `network:serve` (router) | The router mounted at `/plugin-api/<id>/`, and the public origin for callback URLs |
|
||||
| `ws.route/upgrade` | `network:serve` | Sockets at `/plugin-ws/<id>/<path>` |
|
||||
| `rbac.has/hasFor/require` | own permissions (require) | The acting user's role permissions; `require` is route middleware |
|
||||
| `settings.get/set/getUser/setUser/getHost/setHost/listHostValues/getAll/onChange/onValidate` | `hosts:read` (listHostValues) | The plugin's admin, user and host settings from its manifest |
|
||||
| `settings.readCore(key)` | `settings:read-core` | One of the core keys in `CORE_SETTINGS_ALLOWLIST` |
|
||||
| `disposables.add` | none | Extra cleanup on deactivate |
|
||||
| `capabilities.has/require` | the named capability | A generic check for privileged code no ctx member wraps |
|
||||
| `hosts.list/get/checkAccess` | `hosts:read` | Hosts the acting user can see, without secrets |
|
||||
| `hosts.create/update/delete/listOwned/share/listUsers/listRoles` | `hosts:write` | Host changes and sharing on the user's behalf |
|
||||
| `hosts.trackSession/recordActivity` | `hosts:read` | The online indicator and a recent activity entry |
|
||||
| `hosts.status.get/check/reportLogin/registerPort` | `hosts:read` | Core's reachability status |
|
||||
| `ssh.connect/withConnection/jumpChain` | `ssh:connect`, `credentials:use` | Connections through core's one connect pipeline |
|
||||
| `ssh.resolveHost/prepare/openTransport/classifyKeyboardInteractive/autoResponses/startInteraction/cancelInteraction` | `ssh:connect`, `credentials:use` | The lower level, for a transport with its own prompt flow |
|
||||
| `ssh.poolKey/dropPooled/requiresSecret/supportsBackground` | `ssh:connect` | Pool housekeeping and questions for an auth type's provider |
|
||||
| `auth.registerLoginMethod/registerSecondFactor/recordEnrollment/removeEnrollment` | `auth:provide` | Login methods and second factors |
|
||||
| `auth.completeRedirectLogin/revokeSessions/loginRateLimit/countLinkedUsers` | `auth:provide` | What a login method needs from core's session and provisioning code |
|
||||
| `auth.registerSshAuthProvider/registerKeyboardInteractiveHandler` | `auth:provide` | SSH auth types and keyboard-interactive handlers |
|
||||
| `credentials.listSshKeys` / `.createSshKey` | `credentials:use` / `credentials:write` | The user's saved SSH keys |
|
||||
| `credentials.resolveHostProtocol` | `credentials:read` | A host's RDP, VNC or Telnet login |
|
||||
| `credentials.registerSecretResolver` | `auth:provide` | Resolves external secret references in host and credential fields |
|
||||
| `desktop.openIsolatedWindow/launchNativeRdp/available` | `desktop:window` (not `available`) | Electron windows and the native RDP client |
|
||||
| `audit.record` | none | An audit line under the plugin's own action name |
|
||||
| `schedule.every/after` | none | Timers that stop on deactivate and never overlap |
|
||||
| `notify.channels/send` | `notify:send` | Core's notification channels, as the acting user |
|
||||
| `fetch(url, init)` | `network:outbound` | Outbound HTTP through core's SSRF guard; `allowPrivateHosts` hosts also go through the configured proxy |
|
||||
| `process.run/ensureBinary` | `process:spawn` (+ `network:outbound` to download) | Programs on the server |
|
||||
| `system.tlsStatus/writeTlsCertificate/reloadTls/publishHttpChallenge/registerTlsRenewer` | `system:tls` | The server certificate |
|
||||
| `asUser(userId, fn)` | none, always audited | Background work as a user |
|
||||
| `currentActor()` | none | The acting user for the current call |
|
||||
|
||||
**B11** added `ctx.capabilities.has(capability)` / `.require(capability)`, a
|
||||
generic check for a capability no other ctx member wraps. Unlike every other
|
||||
@@ -1948,14 +2060,14 @@ connect pipeline instead of importing ssh2 helpers from core:
|
||||
has no auth code of its own left.
|
||||
- `requiresSecret(authType)` and `supportsBackground(authType)` ask the
|
||||
provider.
|
||||
- **B6** added `"file-manager"` and `"file-transfer"` to `PluginSshPurpose`
|
||||
(core's `SshConnectPurpose` already had them; the SDK type had lagged),
|
||||
since the file-manager plugin's interactive connect route and its dedicated
|
||||
transfer sessions both need their own keepalive/timeout defaults rather than
|
||||
falling back to the generic `"plugin"` purpose.
|
||||
- **B10** added `"tmux"` to `PluginSshPurpose` the same way (core's
|
||||
`SshConnectPurpose` already had it), for the tmux-monitor plugin's pooled
|
||||
connection and the `tmux.sessions` service it provides to the terminal.
|
||||
- `purpose` is a free label (`"terminal"`, `"docker"`) that auth providers
|
||||
see in `connectOptions`. **D1** moved the keepalive, timeout and
|
||||
environment defaults onto `profile`: `"terminal"` (an interactive shell),
|
||||
`"session"` (a long-lived browsing session such as the file manager or
|
||||
Docker), `"stream"` (a long transfer or console), `"forward"` (port
|
||||
forwarding) or `"background"`, the default, for short exec work. Core's own
|
||||
jump chain uses a `"jump"` profile plugins cannot pick. Core no longer keeps
|
||||
a list of purposes, so it names no plugin.
|
||||
- **B9** added what an interactive transport needs, for the terminal:
|
||||
`resolveHost(hostId, { syncId? })` (the host with secrets, by sync id first,
|
||||
audited), the `"terminal"` purpose, `interactive` and `hostKeySocket` on
|
||||
@@ -2349,7 +2461,8 @@ keeps working unchanged.
|
||||
what Docker and electron-builder package. `npm run build`, `build:backend` and
|
||||
`dev:backend` all call it.
|
||||
|
||||
TypeScript only type-checks (`tsconfig.plugins.json`, `noEmit`); esbuild does
|
||||
TypeScript only type-checks (`tsconfig.plugins.backend.json` and
|
||||
`tsconfig.plugins.frontend.json`, `noEmit`); esbuild does
|
||||
every emit. Nothing is written next to a source file.
|
||||
|
||||
### Host-provided packages
|
||||
@@ -2365,9 +2478,8 @@ and that is bundled into its output.
|
||||
the terminal's image upload loads it on first use).
|
||||
|
||||
**Frontend**: `react`, `react-dom`, `react-dom/client`, `react/jsx-runtime`,
|
||||
`i18next`, `react-i18next`, `sonner`, `@termix/plugin-sdk/frontend`,
|
||||
`@termix/plugin-sdk/ui` and `@termix/legacy-core/*`, all resolved through the
|
||||
import map (see UI). Everything else, including `lucide-react`, `axios`,
|
||||
`i18next`, `react-i18next`, `sonner`, `@termix/plugin-sdk/frontend` and
|
||||
`@termix/plugin-sdk/ui`, all resolved through the import map (see UI). Everything else, including `lucide-react`, `axios`,
|
||||
`cytoscape` and `guacamole-common-js`, is bundled into the plugin that uses
|
||||
it.
|
||||
|
||||
@@ -2673,10 +2785,10 @@ install (workspaces leaves out its `last_session` row).
|
||||
2. Register everything in `activate(app)`: rail item (with `permission` when
|
||||
there is one), panel, tabs, host editor sections, actions and slot
|
||||
contributions. Use `app.onDispose` for timers.
|
||||
3. Remove the shell's references: imports from the feature, hardcoded ids and
|
||||
switch cases, and the matching entries in
|
||||
`scripts/shell-plugin-id-allowlist.json`. `node scripts/check-shell-plugin-ids.cjs`
|
||||
must pass.
|
||||
3. Remove core's references: imports from the feature, hardcoded ids and
|
||||
switch cases. Where core still needs the feature, name a slot, action or
|
||||
component id in core's own vocabulary and let the plugin fill it.
|
||||
`node scripts/check-core-plugin-ids.cjs` must pass.
|
||||
4. The rail item appears in Appearance > Sidebar > Navigation on its own
|
||||
because it is registered; confirm it there.
|
||||
5. No hardcoded text (a relative time is a translated string), no em dashes,
|
||||
@@ -2715,14 +2827,7 @@ install (workspaces leaves out its `last_session` row).
|
||||
4. `cd plugins/<id> && npx vitest run` while working; `npm run test:plugins` at
|
||||
the end.
|
||||
|
||||
### 14. Boundary allowlist
|
||||
|
||||
`node scripts/check-plugin-boundaries.cjs --write`, then
|
||||
`git diff scripts/plugin-boundary-allowlist.json`: the plugin's entries are
|
||||
gone and **no other line was added**. Update the counts in the table under
|
||||
[Legacy core imports](#legacy-core-imports-the-debt-d1-removes).
|
||||
|
||||
### 15. Build, test and check by hand
|
||||
### 14. Build, test and check by hand
|
||||
|
||||
```bash
|
||||
npm run build:sdk
|
||||
@@ -2745,91 +2850,3 @@ Then, with the app running:
|
||||
views show the "needs the plugin" placeholder while off and come back after.
|
||||
5. On Postgres or MySQL if available: `npm run verify:dialect`, then boot.
|
||||
6. Update this document for anything the step added to the contract.
|
||||
|
||||
---
|
||||
|
||||
## Legacy core imports: the debt D1 removes
|
||||
|
||||
The bundled plugins predate the SDK, apart from workspaces (A9), snippets
|
||||
(B2), remote-desktop (B14), docker (B15), host-metrics (B16), automations
|
||||
(B17), ai (B18), homepage (B19), totp and webauthn (C1), sso and ldap (C2),
|
||||
opkssh and warpgate (C3), step-ca (C4), vault (C5), termix-identity (C6),
|
||||
acme-ssl (C7),
|
||||
which import nothing from core. The others still reach core by relative
|
||||
path (`../../../../src/backend/...`), which an esbuild plugin,
|
||||
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle
|
||||
and rewrites to the compiled output path (`../../../backend/backend/...`, or
|
||||
`../../../backend/types/...` for shared types). The rewrite is output-relative,
|
||||
so a source file at any nesting collapses to the same prefix. D1 deletes that
|
||||
file.
|
||||
|
||||
Frontends do the same with `@/`: in the browser build, `@/x` and
|
||||
`../src/ui/x` become the bare `@termix/legacy-core/ui/x` (or
|
||||
`@termix/legacy-core/types/x`), which core's Vite build exposes as a shared
|
||||
module so plugin and shell use one instance of each core module. The CLI
|
||||
refuses the rewrite outside a Termix checkout. Every such import is counted
|
||||
in the `plugin-frontend-to-core` direction below.
|
||||
|
||||
This is expected and temporary. The SDK does not yet expose what they need:
|
||||
the SSH connection pool, host resolution, the repository layer, the shared
|
||||
component library. D1 removes the debt once A3 through A8 have given them
|
||||
supported APIs to move onto.
|
||||
|
||||
What the lint fence enforces today, in `eslint.config.mjs`:
|
||||
|
||||
| Direction | Severity | Offenders | Emptied by |
|
||||
| ------------------------------------------------ | --------- | --------- | ---------- |
|
||||
| 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 | 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
|
||||
`scripts/check-plugin-boundaries.cjs`, run by `npm run lint`. Every offender is
|
||||
listed in `scripts/plugin-boundary-allowlist.json`: a new one fails, and so
|
||||
does an entry that stopped being an offender, so the list shrinks as each step
|
||||
lands rather than rotting. `--write` regenerates it. D1 empties it and the two
|
||||
plugin-side warnings become errors.
|
||||
|
||||
The two plugin-backend directions use different rule names
|
||||
(`no-restricted-imports` and `@typescript-eslint/no-restricted-imports`)
|
||||
because flat config replaces a rule's options rather than merging them, so one
|
||||
rule cannot carry both severities on the same files.
|
||||
|
||||
Known specifics:
|
||||
|
||||
- **B17** removed the last plugin-to-plugin import: ai's proposal executor
|
||||
and automation read tools call automations through the `automations.access`
|
||||
service, and automations is an optional dependency of ai rather than a hard
|
||||
one.
|
||||
- **B16** moved host-metrics onto the SDK: it imports nothing from core or
|
||||
automations. Automations reaches it through the `host-metrics.viewers`
|
||||
service and its `plugin.host-metrics.*` events, and core reaches it through
|
||||
the `dashboard.hostMetrics` and `homepage.hostMetrics` slots and the
|
||||
`host-metrics.disk` action. Core's `hosts/metrics-shared/` is gone: ai,
|
||||
automations, proxmox and tailscale use `@termix/plugin-sdk/host-commands`.
|
||||
- Core feature servers that are not plugins yet still own ports: dashboard
|
||||
30006 and homepage 30012. Each keeps its nginx block until its own Phase B
|
||||
step. No plugin owns a port any more (A4). **B6** moved file-manager off
|
||||
port 30004 onto `/plugin-api/file-manager/`, **B7** moved tunnels off port
|
||||
30003 onto `/plugin-api/tunnels/` and `/plugin-ws/tunnels/c2s/stream`,
|
||||
**B10** moved tmux monitoring off port 30010, and **B11** moved serial off
|
||||
port 30011 onto `/plugin-ws/serial/console`.
|
||||
- **B9** moved the terminal, its session manager, the local terminal and the
|
||||
command history panel into ssh-terminal, which imports nothing from core.
|
||||
The file manager's terminal window renders the `terminal.view` component
|
||||
instead of importing the core terminal.
|
||||
- Host data columns (`enableFileManager` and so on) stay in core types until
|
||||
Phase B moves them. Only UI branches on them left the shell. **B15** moved
|
||||
docker's `enableDocker` and `docker_config` (now `containerRuntime`) into
|
||||
its host settings. **B14** moved
|
||||
remote desktop's (`enableRdp`, `enableVnc`, `enableTelnet`, the three ports,
|
||||
`rdpSecurity`, `rdpIgnoreCert`, `guacamoleConfig`) into its host settings;
|
||||
the protocol logins stay core host fields.
|
||||
- **B14** moved remote desktop onto the SDK: it imports nothing from core,
|
||||
owns no module state, and its guacamole-lite server is built in `activate`
|
||||
with its own signal handlers removed (guacamole-lite installs SIGTERM and
|
||||
SIGINT handlers in its constructor). guacd is always an external service,
|
||||
so it does not use `ctx.process`; its address is an admin setting.
|
||||
@@ -154,3 +154,32 @@ with a real install, a real device or a repo outside this one.
|
||||
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).
|
||||
- D1 changes to check by hand: Quick Connect lists Tailscale next to the
|
||||
core auth types and connects with it, and its "Connect to Files" button
|
||||
comes from the file manager; the admin "manage user" panel's Snippets tab
|
||||
(add, edit, delete a snippet for another user); the dashboard's active
|
||||
tunnel counter and its click; the tab bar's file manager button; the
|
||||
secret reference hint and manager in the host and credential editors;
|
||||
Proxmox guest discovery and import (now its own `/plugin-api/proxmox/import`
|
||||
route); the Tailscale device list behind an outbound proxy and against a
|
||||
LAN Headscale; dragging a local file into a host in the desktop app (its
|
||||
transfer URL moved off the dead port 30004); a per-user database export and
|
||||
import that carries file manager bookmarks; the Simple preset hiding plugin
|
||||
rail items except Snippets.
|
||||
|
||||
## Left for later
|
||||
|
||||
- 3.0.0: drop the migrated 2.8 settings rows (`guac_url`, `step_ca_url`,
|
||||
`global_metrics_interval` and the rest). The upgrade moves leave them in
|
||||
place so a downgrade to 2.8 still works; nothing reads them in 2.9.
|
||||
- Plugin repo split: knip still reports about 150 unused exports inside
|
||||
plugins (constants exported for readability, `tables.ts` exports the CLI
|
||||
reads, test-only helpers). Prune them per plugin when it moves to its own
|
||||
repo; core and the SDK are clean.
|
||||
- Plugin OpenAPI docs: plugins carry `@openapi` comments but
|
||||
`src/backend/utils/swagger.ts` only scans core routes, so
|
||||
`npm run generate:openapi` leaves every `/plugin-api/` route out.
|
||||
- Three backend tests sit next to their source, against the test layout
|
||||
rule: `src/backend/hosts/host-session-status.test.ts`,
|
||||
`jump-host-proxy.test.ts` and `ssh-keepalive.test.ts`. Move them under
|
||||
`src/backend/tests/hosts/`.
|
||||
@@ -2,7 +2,6 @@ import fs from "node:fs";
|
||||
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";
|
||||
|
||||
@@ -18,17 +17,6 @@ const FRONTEND_ENTRIES = [
|
||||
"src/frontend/index.js",
|
||||
];
|
||||
|
||||
/** Whether the plugin sits in a Termix checkout, i.e. is a bundled plugin. */
|
||||
function insideTermixCheckout(cwd) {
|
||||
let dir = path.resolve(cwd);
|
||||
for (;;) {
|
||||
if (fs.existsSync(path.join(dir, "src", "ui", "plugin-host"))) return true;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return false;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
export async function build({ cwd }) {
|
||||
const manifest = readManifest(cwd);
|
||||
const pluginId = manifest.id ?? path.basename(cwd);
|
||||
@@ -59,7 +47,6 @@ export async function build({ cwd }) {
|
||||
// node_modules import instead, resolved at runtime like a host-provided
|
||||
// package.
|
||||
external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])],
|
||||
plugins: [legacyCoreImports({ pluginId, platform: "node" })],
|
||||
});
|
||||
|
||||
const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES);
|
||||
@@ -75,14 +62,7 @@ export async function build({ cwd }) {
|
||||
sourcemap: true,
|
||||
logLevel: "warning",
|
||||
external: FRONTEND_EXTERNALS,
|
||||
plugins: [
|
||||
staticUrlImports({ outDir }),
|
||||
legacyCoreImports({
|
||||
pluginId,
|
||||
platform: "browser",
|
||||
insideTermix: insideTermixCheckout(cwd),
|
||||
}),
|
||||
],
|
||||
plugins: [staticUrlImports({ outDir })],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,4 @@ export const FRONTEND_EXTERNALS = [
|
||||
"sonner",
|
||||
"@termix/plugin-sdk/frontend",
|
||||
"@termix/plugin-sdk/ui",
|
||||
// Legacy: the shell's own modules, see legacy-core-imports.mjs. D1 removes.
|
||||
"@termix/legacy-core/*",
|
||||
];
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* Legacy core imports: the debt D1 removes.
|
||||
*
|
||||
* Plugin code predates the SDK and still reaches core, and in one case a
|
||||
* sibling plugin, by relative path. Those specifiers must stay out of the
|
||||
* bundle and be rewritten to where the built server puts the target.
|
||||
*
|
||||
* The rewrite is output-relative, not source-relative. Every plugin bundles
|
||||
* to plugins/<id>/dist/backend.js, which sits at the same depth the old
|
||||
* per-file output did, so one prefix covers a source file at any nesting.
|
||||
*
|
||||
* Delete this file in D1, once every plugin reaches core through the SDK.
|
||||
*/
|
||||
|
||||
import { legacyCoreSpecifier } from "./legacy-core-specifier.mjs";
|
||||
|
||||
// "../../../src/backend/" or "../../../../src/backend/", any depth.
|
||||
const CORE_BACKEND = /^(?:\.\.\/)+src\/backend\//;
|
||||
// Shared types. Mostly erased at compile, but automations imports a real
|
||||
// value (AUTOMATION_DEFINITION_VERSION), so this has to resolve at runtime.
|
||||
const CORE_TYPES = /^(?:\.\.\/)+src\/types\//;
|
||||
const CORE_UI = /^(?:\.\.\/)+src\/ui\//;
|
||||
/** dist/plugins/<id>/dist/backend.js -> dist/backend/... */
|
||||
const COMPILED_CORE = "../../../backend/backend/";
|
||||
const COMPILED_TYPES = "../../../backend/types/";
|
||||
|
||||
const CORE_ALIAS = /^@\//;
|
||||
|
||||
export function legacyCoreImports({ pluginId, platform, insideTermix = true }) {
|
||||
return {
|
||||
name: "legacy-core-imports",
|
||||
setup(build) {
|
||||
// The shell's own modules, reached as "@/..." or by relative path.
|
||||
// Bundling them would give the plugin a second copy of the shell's
|
||||
// contexts and API client, so each becomes a bare specifier the page's
|
||||
// import map resolves to the shell's own module.
|
||||
if (platform === "browser") {
|
||||
const toLegacy = (args) => {
|
||||
const specifier = legacyCoreSpecifier(args.path);
|
||||
if (!specifier) return null;
|
||||
if (!insideTermix) {
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
text: `${pluginId}: "${args.path}" reaches Termix core. Only plugins built inside a Termix checkout may; use @termix/plugin-sdk instead.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return { path: specifier, external: true };
|
||||
};
|
||||
build.onResolve({ filter: CORE_ALIAS }, toLegacy);
|
||||
build.onResolve({ filter: CORE_UI }, toLegacy);
|
||||
build.onResolve({ filter: CORE_TYPES }, toLegacy);
|
||||
}
|
||||
|
||||
build.onResolve({ filter: CORE_BACKEND }, (args) => ({
|
||||
path: args.path.replace(CORE_BACKEND, COMPILED_CORE),
|
||||
external: true,
|
||||
}));
|
||||
|
||||
build.onResolve({ filter: CORE_TYPES }, (args) => ({
|
||||
path: args.path.replace(CORE_TYPES, COMPILED_TYPES),
|
||||
external: true,
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* Legacy core modules a bundled plugin frontend still imports.
|
||||
*
|
||||
* Bundled plugin frontends predate the SDK and import the shell's own modules
|
||||
* as "@/..." (or, in a couple of places, by relative path into src/). Bundling
|
||||
* those would give the plugin a second copy of the shell's contexts and API
|
||||
* client, so the CLI rewrites each one to a bare specifier under
|
||||
* "@termix/legacy-core/" and core's build exposes exactly that module through
|
||||
* the import map. Both sides use this one normalizer so they cannot disagree.
|
||||
*
|
||||
* Only plugins built inside a Termix checkout may do this. D1 deletes it.
|
||||
*/
|
||||
|
||||
export const LEGACY_CORE_PREFIX = "@termix/legacy-core/";
|
||||
|
||||
const EXTENSION = /\.(tsx|ts|jsx|js|mjs)$/;
|
||||
|
||||
function clean(rest) {
|
||||
return rest.replace(EXTENSION, "").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* "@/components/button.tsx" -> "@termix/legacy-core/ui/components/button"
|
||||
* "@/types" -> "@termix/legacy-core/types"
|
||||
* "@/types/connection-log" -> "@termix/legacy-core/types/connection-log"
|
||||
* "../../../src/ui/x.tsx" -> "@termix/legacy-core/ui/x"
|
||||
* Anything else -> null
|
||||
*/
|
||||
export function legacyCoreSpecifier(specifier) {
|
||||
if (specifier.startsWith("@/")) {
|
||||
const rest = clean(specifier.slice(2));
|
||||
if (rest === "types" || rest.startsWith("types/")) {
|
||||
return LEGACY_CORE_PREFIX + rest;
|
||||
}
|
||||
return `${LEGACY_CORE_PREFIX}ui/${rest}`;
|
||||
}
|
||||
const relative = /^(?:\.\.\/)+src\/(ui|types)(\/.*)?$/.exec(specifier);
|
||||
if (relative) {
|
||||
return LEGACY_CORE_PREFIX + clean(`${relative[1]}${relative[2] ?? ""}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every value import in a source file that names a legacy core module.
|
||||
* Type-only imports are skipped: they are erased and never reach the map.
|
||||
*/
|
||||
export function findLegacyCoreImports(source) {
|
||||
const found = new Set();
|
||||
const patterns = [
|
||||
/^\s*(?:import|export)\s+(?!type\s)[^'";]*?\sfrom\s+["']([^"']+)["']/gm,
|
||||
/^\s*import\s+["']([^"']+)["']/gm,
|
||||
/import\(\s*["']([^"']+)["']\s*\)/g,
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
const normalized = legacyCoreSpecifier(match[1]);
|
||||
if (normalized) found.add(normalized);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
@@ -825,6 +825,26 @@
|
||||
"type": "string",
|
||||
"pattern": "^/[a-z][a-z0-9-]*(/[A-Za-z0-9_.-]+)+$"
|
||||
}
|
||||
},
|
||||
"legacyRedirects": {
|
||||
"type": "array",
|
||||
"description": "Old URLs outside the plugin's namespace, redirected to a path in its router with the rest of the path and the query kept.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["from", "to"],
|
||||
"properties": {
|
||||
"from": {
|
||||
"type": "string",
|
||||
"pattern": "^(/[A-Za-z0-9_.-]+)+$"
|
||||
},
|
||||
"to": {
|
||||
"type": "string",
|
||||
"pattern": "^(/[A-Za-z0-9_.-]+)+$"
|
||||
},
|
||||
"status": { "enum": [307, 308] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -737,19 +737,17 @@ export interface PluginSshHost {
|
||||
}
|
||||
|
||||
/** Picks keepalive and timeout defaults. "plugin" is the generic one. */
|
||||
export type PluginSshPurpose =
|
||||
| "plugin"
|
||||
| "docker"
|
||||
| "docker-console"
|
||||
| "metrics"
|
||||
| "proxmox"
|
||||
| "fleet"
|
||||
| "remote-desktop"
|
||||
| "tunnel"
|
||||
| "file-manager"
|
||||
| "file-transfer"
|
||||
| "terminal"
|
||||
| "tmux";
|
||||
/** What a connection is for, as a free label. Auth providers see it. */
|
||||
export type PluginSshPurpose = string;
|
||||
|
||||
/**
|
||||
* Keepalive, timeout and environment defaults for a kind of connection.
|
||||
* terminal: an interactive shell. session: a long-lived browsing session.
|
||||
* stream: a long transfer or console. forward: port forwarding. background
|
||||
* (the default): short exec work such as polling.
|
||||
*/
|
||||
export type PluginSshProfile =
|
||||
"terminal" | "session" | "stream" | "forward" | "background";
|
||||
|
||||
export type PluginSshAuthOutcome =
|
||||
| { status: "ready" }
|
||||
@@ -868,6 +866,8 @@ export interface PluginSshPromptChannel {
|
||||
|
||||
export interface PluginSshConnectOptions {
|
||||
purpose?: PluginSshPurpose;
|
||||
/** Keepalive and timeout defaults. Defaults to "background". */
|
||||
profile?: PluginSshProfile;
|
||||
/** Overall time to reach "ready". Defaults to 30s. */
|
||||
timeoutMs?: number;
|
||||
/** Without one, the stored password answers password prompts. */
|
||||
@@ -956,6 +956,8 @@ export interface PluginSsh {
|
||||
host: PluginSshHost,
|
||||
options: {
|
||||
purpose?: PluginSshPurpose;
|
||||
/** Keepalive and timeout defaults. Defaults to "background". */
|
||||
profile?: PluginSshProfile;
|
||||
client: unknown;
|
||||
/** Server-side host id when it differs from host.id. */
|
||||
serverHostId?: number;
|
||||
@@ -1079,6 +1081,11 @@ export interface PluginSshAuthProvider {
|
||||
requiresSecret?: boolean;
|
||||
/** Can connect unattended. Defaults to true. */
|
||||
supportsBackground?: boolean;
|
||||
/**
|
||||
* Works for a host that was never saved (nothing in host settings, no host
|
||||
* id to key a cache on), so Quick Connect offers it. Default false.
|
||||
*/
|
||||
quickConnect?: boolean;
|
||||
/** Interaction name its outcomes use, for startInteraction routing. */
|
||||
interaction?: string;
|
||||
connectOptions?: (
|
||||
|
||||
@@ -128,6 +128,11 @@ export interface RailItemContribution {
|
||||
kind?: "panel" | "tab";
|
||||
/** Users may hide it from Appearance > Sidebar > Navigation. Default true. */
|
||||
hideable?: boolean;
|
||||
/**
|
||||
* Stays visible in the Simple interface preset, which hides every other
|
||||
* plugin rail item. For the one or two things a new user reaches for.
|
||||
*/
|
||||
simplePreset?: boolean;
|
||||
/** Can also open as a full-width tab. */
|
||||
promotable?: boolean;
|
||||
/** Can open in the right dock. */
|
||||
@@ -302,6 +307,11 @@ export interface HostActionContribution {
|
||||
copyUrlView?: string;
|
||||
/** Where a host overview (the dashboard's host status list) sends a click. */
|
||||
overview?: boolean;
|
||||
/**
|
||||
* Offered as a button in Quick Connect, next to the default connect button,
|
||||
* for an address that is never saved. Opens tabType.
|
||||
*/
|
||||
quickConnect?: boolean;
|
||||
/**
|
||||
* Position in the host row. Core: Files 20, Tunnel 40, Tmux 70. Connect
|
||||
* actions default to 100 and sit after a separator.
|
||||
@@ -709,6 +719,8 @@ export interface SshAuthTypeInfo {
|
||||
credentialType: boolean;
|
||||
/** Can connect unattended, for polling. */
|
||||
supportsBackground: boolean;
|
||||
/** Offered in Quick Connect, for a host that is never saved. */
|
||||
quickConnect: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -825,6 +837,18 @@ export interface PluginCoreApi {
|
||||
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;
|
||||
/** Every host the user can see, fetched now rather than from the shell's cache. */
|
||||
listHosts: () => Promise<PluginHostRecord[]>;
|
||||
/** The user's stored credentials, without their secrets. */
|
||||
listCredentials: () => Promise<PluginCredentialSummary[]>;
|
||||
}
|
||||
|
||||
/** A stored credential as a picker needs it. Secrets never leave core. */
|
||||
export interface PluginCredentialSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
username?: string;
|
||||
authType?: string;
|
||||
}
|
||||
|
||||
/** What a recent-activity entry opens and how it is labelled. */
|
||||
@@ -972,6 +996,14 @@ export function getClientPreference(name: string): string | undefined {
|
||||
return requireHost().core.getClientPreference(name);
|
||||
}
|
||||
|
||||
export function listHosts(): Promise<PluginHostRecord[]> {
|
||||
return requireHost().core.listHosts();
|
||||
}
|
||||
|
||||
export function listCredentials(): Promise<PluginCredentialSummary[]> {
|
||||
return requireHost().core.listCredentials();
|
||||
}
|
||||
|
||||
export function usePluginApi(): PluginApiClient {
|
||||
const bridge = requireHost();
|
||||
return bridge.getApi(bridge.usePluginId());
|
||||
|
||||
@@ -62,7 +62,6 @@ export type SystemRoleName = (typeof SYSTEM_ROLE_NAMES)[number];
|
||||
*/
|
||||
export const RESERVED_PERMISSION_PREFIXES = [
|
||||
"hosts",
|
||||
"snippets",
|
||||
"credentials",
|
||||
"admin",
|
||||
] as const;
|
||||
@@ -324,6 +323,23 @@ export interface PluginHttpContribution {
|
||||
* a clash.
|
||||
*/
|
||||
legacyPaths?: string[];
|
||||
/**
|
||||
* Old URLs outside the plugin's namespace that something outside Termix
|
||||
* still sends people to (an identity provider's callback, a published
|
||||
* resolver URL). A request at `from`, or under it, is redirected to `to`
|
||||
* under /plugin-api/<id>/ with the rest of the path and the query kept.
|
||||
* Core routes win a clash.
|
||||
*/
|
||||
legacyRedirects?: PluginLegacyRedirect[];
|
||||
}
|
||||
|
||||
export interface PluginLegacyRedirect {
|
||||
/** An absolute path, e.g. "/users/oidc/callback". */
|
||||
from: string;
|
||||
/** A path in this plugin's router, e.g. "/callback". */
|
||||
to: string;
|
||||
/** 307 (the default) or 308 for a permanent move. */
|
||||
status?: 307 | 308;
|
||||
}
|
||||
|
||||
export interface PluginManifest {
|
||||
@@ -869,6 +885,40 @@ function validateUiPresets(value: unknown, errors: string[]): void {
|
||||
}
|
||||
|
||||
const LEGACY_PATH_PATTERN = /^\/[a-z][a-z0-9-]*(\/[A-Za-z0-9_.-]+)+$/;
|
||||
const REDIRECT_PATH_PATTERN = /^(\/[A-Za-z0-9_.-]+)+$/;
|
||||
const RESERVED_PREFIXES = ["/plugin-api", "/plugin-ws", "/plugin-assets"];
|
||||
|
||||
function validateLegacyRedirects(value: unknown, errors: string[]): void {
|
||||
if (value === undefined) return;
|
||||
if (!Array.isArray(value)) {
|
||||
errors.push('Field "contributes.http.legacyRedirects" must be an array');
|
||||
return;
|
||||
}
|
||||
value.forEach((entry, index) => {
|
||||
const at = `contributes.http.legacyRedirects[${index}]`;
|
||||
if (!isPlainObject(entry)) {
|
||||
errors.push(`${at} must be an object`);
|
||||
return;
|
||||
}
|
||||
rejectUnknown(entry, ["from", "to", "status"], at, errors);
|
||||
const { from, to, status } = entry;
|
||||
if (typeof from !== "string" || !REDIRECT_PATH_PATTERN.test(from)) {
|
||||
errors.push(`${at}.from must be an absolute path`);
|
||||
} else if (
|
||||
RESERVED_PREFIXES.some(
|
||||
(prefix) => from === prefix || from.startsWith(`${prefix}/`),
|
||||
)
|
||||
) {
|
||||
errors.push(`${at}.from cannot be under ${from.split("/")[1]}`);
|
||||
}
|
||||
if (typeof to !== "string" || !REDIRECT_PATH_PATTERN.test(to)) {
|
||||
errors.push(`${at}.to must be a path in the plugin's router`);
|
||||
}
|
||||
if (status !== undefined && status !== 307 && status !== 308) {
|
||||
errors.push(`${at}.status must be 307 or 308`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateHttpContribution(
|
||||
value: unknown,
|
||||
@@ -880,7 +930,13 @@ function validateHttpContribution(
|
||||
errors.push('Field "contributes.http" must be an object');
|
||||
return;
|
||||
}
|
||||
rejectUnknown(value, ["legacyPaths"], "contributes.http", errors);
|
||||
rejectUnknown(
|
||||
value,
|
||||
["legacyPaths", "legacyRedirects"],
|
||||
"contributes.http",
|
||||
errors,
|
||||
);
|
||||
validateLegacyRedirects(value.legacyRedirects, errors);
|
||||
const paths = value.legacyPaths;
|
||||
if (paths === undefined) return;
|
||||
if (!Array.isArray(paths)) {
|
||||
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
PluginDisposables,
|
||||
PluginLogger,
|
||||
} from "./backend.js";
|
||||
import type { TermixApp } from "./frontend.js";
|
||||
import type { HostActionContribution, TermixApp } from "./frontend.js";
|
||||
import type { PluginManifest } from "./manifest.js";
|
||||
import type { PluginTableDefinition } from "./db.js";
|
||||
import type {
|
||||
@@ -1967,6 +1967,8 @@ export interface RenderedPluginApp {
|
||||
tabs: () => string[];
|
||||
panels: () => string[];
|
||||
hostActions: () => Array<{ id: string; tabType?: string }>;
|
||||
/** This plugin's host actions that apply to `host`, as the shell sees them. */
|
||||
hostActionsFor: (host: unknown) => HostActionContribution[];
|
||||
hostProtocols: () => string[];
|
||||
hostEditorSections: () => string[];
|
||||
dashboardCards: () => string[];
|
||||
|
||||
@@ -112,6 +112,7 @@ const offline = () =>
|
||||
pluginId: "core",
|
||||
credentialType: type === "password" || type === "key",
|
||||
supportsBackground: type !== "none",
|
||||
quickConnect: true,
|
||||
})),
|
||||
loaded: true,
|
||||
}),
|
||||
@@ -138,6 +139,8 @@ const offline = () =>
|
||||
getCustomKeybindings: async () => [],
|
||||
setHostAutoTmux: async () => {},
|
||||
getClientPreference: () => undefined,
|
||||
listHosts: async () => [],
|
||||
listCredentials: async () => [],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -75,6 +75,8 @@ export function pluginVitestConfig(
|
||||
"plugin-host",
|
||||
"testing-host.tsx",
|
||||
),
|
||||
// Core's own modules behind sdk-ui and the test host import each
|
||||
// other this way. Plugin code may not (eslint refuses it).
|
||||
"@/types": path.join(repoRoot, "src", "types"),
|
||||
"@": path.join(repoRoot, "src", "ui"),
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user