release-2.9.0 (#1532)

* fix(macos): skip single-instance lock in Mac App Store builds (#1454)

fix(macos): skip single-instance lock in Mac App Store builds

* Fix/backend optional sharp (#1455)

Fix/backend optional sharp

* feat: issue #1218 (#1218)

https://github.com/Termix-SSH/Support/issues/1218

* feat: Add option to use saved global custom theme under Connection Defaults (#1209)

https://github.com/Termix-SSH/Support/issues/1209

* feat: Add Fleet Sharing functionality to the UI (#1228)

https://github.com/Termix-SSH/Support/issues/1228

* feat: Add a list view to the Docker management page (#1237)

https://github.com/Termix-SSH/Support/issues/1237

* feat: issue #1264 (#1264)

https://github.com/Termix-SSH/Support/issues/1264

* feat: 2FA With FortiToken (#1288)

https://github.com/Termix-SSH/Support/issues/1288

* feat: Allow to disable showing paths to folders (#1274)

https://github.com/Termix-SSH/Support/issues/1274

* feat: Sync Network Graph between Desktop and Remote Server (#1245)

https://github.com/Termix-SSH/Support/issues/1245

* feat: Version Number on offline servers (#1291)

https://github.com/Termix-SSH/Support/issues/1291

* feat: Support OrbStack Docker socket path on macOS / Fix "Docker is not installed" on mac... (#1302)

https://github.com/Termix-SSH/Support/issues/1302

* feat: Sidebar host click should focus existing tab instead of opening a new connection (#1289)

https://github.com/Termix-SSH/Support/issues/1289

* fix: Search in side bar shows overlapping hosts when grouped by tags (#1303)

https://github.com/Termix-SSH/Support/issues/1303

* fix: I can't send a file on its own. (#1304)

https://github.com/Termix-SSH/Support/issues/1304

* fix: sudo password not autofilling (#1248)

https://github.com/Termix-SSH/Support/issues/1248

* fix: node-MainThread (#1300)

https://github.com/Termix-SSH/Support/issues/1300

* fix: Tunnel authentication with shared credentials (#1295)

https://github.com/Termix-SSH/Support/issues/1295

* fix: host key updates not syncing, metrics 404 race on first connect

Host key writes never bumped updatedAt so sync never picked them up.
Also retry the first metrics fetch briefly instead of failing right away.

* chore: increment ver

* feat: add category and icon fields to plugin manifest schema

* ci(deps): bump the github-actions group with 2 updates (#1458)

Bumps the github-actions group with 2 updates: [crowdin/github-action](https://github.com/crowdin/github-action) and [actions/github-script](https://github.com/actions/github-script).


Updates `crowdin/github-action` from 2 to 3
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/v2...v3)

Updates `actions/github-script` from 7 to 9
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)

---
updated-dependencies:
- dependency-name: crowdin/github-action
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump the dev-patch-updates group with 13 updates (#1459)

Bumps the dev-patch-updates group with 13 updates:

| Package | From | To |
| --- | --- | --- |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.11.0` | `6.11.1` |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.1` | `6.7.2` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.9` | `6.43.12` |
| [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` |
| [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.5` | `14.6.7` |
| [@types/ssh2](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ssh2) | `1.15.5` | `1.15.6` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.0` | `6.1.1` |
| [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.34.1` | `3.34.3` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.4` | `0.5.7` |
| [i18next](https://github.com/i18next/i18next) | `26.4.0` | `26.4.2` |
| [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.8` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.12` | `17.0.14` |


Updates `@codemirror/commands` from 6.11.0 to 6.11.1
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/commits)

Updates `@codemirror/search` from 6.7.1 to 6.7.2
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)

Updates `@codemirror/view` from 6.43.9 to 6.43.12
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `@testing-library/dom` from 10.4.1 to 10.4.2
- [Release notes](https://github.com/testing-library/dom-testing-library/releases)
- [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2)

Updates `@testing-library/react` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3)

Updates `@testing-library/user-event` from 14.6.5 to 14.6.7
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.5...v14.6.7)

Updates `@types/ssh2` from 1.15.5 to 1.15.6
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ssh2)

Updates `@vitejs/plugin-react` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.1/packages/plugin-react)

Updates `cytoscape` from 3.34.1 to 3.34.3
- [Release notes](https://github.com/cytoscape/cytoscape.js/releases)
- [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.34.1...v3.34.3)

Updates `eslint-plugin-react-refresh` from 0.5.4 to 0.5.7
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.4...v0.5.7)

Updates `i18next` from 26.4.0 to 26.4.2
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.4.0...v26.4.2)

Updates `prettier` from 3.9.6 to 3.9.8
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8)

Updates `react-i18next` from 17.0.12 to 17.0.14
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.12...v17.0.14)

---
updated-dependencies:
- dependency-name: "@codemirror/commands"
  dependency-version: 6.11.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/search"
  dependency-version: 6.7.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/dom"
  dependency-version: 10.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/react"
  dependency-version: 16.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/user-event"
  dependency-version: 14.6.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@types/ssh2"
  dependency-version: 1.15.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: cytoscape
  dependency-version: 3.34.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: i18next
  dependency-version: 26.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: prettier
  dependency-version: 3.9.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: react-i18next
  dependency-version: 17.0.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the prod-patch-updates group with 6 updates (#1461)

Bumps the prod-patch-updates group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.10` | `3.14.13` |
| [compression](https://github.com/expressjs/compression) | `1.8.1` | `1.8.2` |
| [jose](https://github.com/panva/jose) | `6.2.9` | `6.2.12` |
| [jszip](https://github.com/Stuk/jszip) | `3.10.1` | `3.10.2` |
| [socks](https://github.com/JoshGlazebrook/socks) | `2.8.9` | `2.8.10` |
| [undici](https://github.com/nodejs/undici) | `8.10.0` | `8.10.2` |


Updates `@tanstack/react-virtual` from 3.14.10 to 3.14.13
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.13/packages/react-virtual)

Updates `compression` from 1.8.1 to 1.8.2
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2)

Updates `jose` from 6.2.9 to 6.2.12
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.9...v6.2.12)

Updates `jszip` from 3.10.1 to 3.10.2
- [Changelog](https://github.com/Stuk/jszip/blob/main/CHANGES.md)
- [Commits](https://github.com/Stuk/jszip/compare/v3.10.1...v3.10.2)

Updates `socks` from 2.8.9 to 2.8.10
- [Release notes](https://github.com/JoshGlazebrook/socks/releases)
- [Commits](https://github.com/JoshGlazebrook/socks/compare/2.8.9...2.8.10)

Updates `undici` from 8.10.0 to 8.10.2
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.10.0...v8.10.2)

---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
  dependency-version: 3.14.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: compression
  dependency-version: 1.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: jose
  dependency-version: 6.2.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: jszip
  dependency-version: 3.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: socks
  dependency-version: 2.8.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: undici
  dependency-version: 8.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat: add plugin worker protocol, bootstrap and plugin_storage table

Worker-side ctx proxy plus the message envelope it talks over, and the
per-plugin key/value table behind ctx.storage with migrations for all
three dialects. Rows cascade with the plugin so uninstalling leaves
nothing behind.

* feat: add allowlisted host view for plugins

An allowlist rather than a list of fields to strip. A blocklist rots:
the moment a new secret-bearing column lands on SSHHost it starts
leaking silently. With an allowlist a new field stays invisible to
plugins until someone adds it here on purpose.

* feat: add plugin loader with manifest validation and crash restart

Validates an unpacked plugin, spawns a worker_threads worker and manages
activate/deactivate/crash-restart with backoff, disabling after three
tries.

The crash counter clears only after a plugin stays up past a stability
window, not on activation. Clearing it on activation let a plugin that
activates cleanly and then dies restart forever, because every attempt
looked like the first.

loader.ts documents what the worker boundary does and does not buy you.
Node's permission model is process-wide, not per-worker, so it cannot be
enabled for plugin workers alone and is not claimed here.

* feat: route internal events through a shared plugin event bus

Formalises three ad-hoc publish paths that already existed:
automation-events.ts, metrics/automation-bridge.ts and the
hostSessionStatus singleton, now the host.session.status topic.

Both invariants the old code relied on are preserved. Publishing stays
fire-and-forget, so a failing subscriber cannot disturb a metrics poll or
a host delete. The automations engine subscribes to the bus at scheduler
start instead of being imported ad hoc, which keeps the edge
one-directional: automations may import repositories, hosts modules must
not import automations.

* feat: add plugin ctx broker with permission gate and audit

Every ctx call a plugin makes is checked against its granted
capabilities, performed by the main thread, then audited. A capability
must be both declared in the manifest and granted in the database, so
widening a plugin's reach always needs a new manifest the user can see,
never just a database row.

Audit attribution is set by the broker, not the plugin. The plugin
supplies only the details, so it cannot forge the actor.

fix: stop plugin workers seeing plaintext credentials in error messages

A failing ctx.ssh.connect handed the worker the raw error from deep in
the SSH stack, which embeds the connect config. The worker received, in
full:

  Authentication failed for root using password <SECRET>

where <SECRET> was the host's real plaintext password. Any plugin with
ssh.exec could read it by catching the error, defeating the whole point
of the handle-based ssh API.

Errors crossing the boundary are now sanitised. Only PluginFacingError
and PluginPermissionError, both authored here, reach a plugin verbatim;
anything else becomes a generic message and the real one goes to the
log. This uses a marker class rather than matching on message text,
because matching would start leaking again the moment an upstream error
happened to contain a familiar phrase.

Covered by credential-isolation.test.ts, which plants a sentinel in
every secret-bearing field and asserts it appears in no message the
worker ever receives.

* feat: dispatch plugin HTTP routes and wire the runtime into startup

ctx.http.route now registers a real router on the /plugin-api/:pluginId
dispatcher, replacing its stub 404. A plugin never touches the Express
req/res: it gets a plain summary and returns a plain object, so sockets
and session cookies stay on this side of the boundary. Authorization and
cookie headers are not forwarded.

Plugins load last in the start-up sequence, after the server is fully
wired, and are terminated before the database on shutdown so none can
outlive it. A plugin that fails to load cannot stop the backend.

fix: correct the nginx plugin-api location regex

The pattern was ^/plugin-api/(/.*)?$, which needs a double slash and so
never matched /plugin-api/<id>/<route>. Every other block in the file
uses ^/prefix(/.*)?$. Left alone, plugin routes would work in dev and
404 only behind Docker.

* fix: update stale SFTP transfer test for single file destination paths

The test still asserted transferToHost was called with the destination
directory "/srv". Commit 7727fa09 (#1304) deliberately changed a single
file transfer to send the full destination path including the filename,
so the correct expectation is "/srv/remote-1.txt". The code was right
and the test was left behind.

* feat: ship the ssh terminal as a first-party in-process plugin

Adds a hardcoded process:transport-owner tier so the terminal can own its
WebSocket server and ssh2 clients, which cannot cross the worker boundary.

* feat: ship docker as a first-party in-process plugin

Moves docker backend and frontend into plugins/docker/, alongside
ssh-terminal. Adds a host editor tab seam, a dashboard card registry,
and a build step to compile plugin backends into dist.

* feat: ship host metrics as a first-party in-process plugin

* fix: harden plugin auth and add per-request identity for ctx calls

Require auth on /plugin-api, gate plugin enable/grant/revoke routes
behind admin.plugins.manage, and thread the calling user through
worker ctx.hosts/ctx.ssh calls instead of always using the install
owner. Also adds the plugin permissions grant/revoke UI.

* feat: ship the AI assistant as a first-party in-process plugin

Moves src/backend/ai and its routes into plugins/ai, mounted through a
small /ai dispatcher instead of its own port. Moves the ai-enabled and
ai-private-endpoints settings routes out of user-settings-routes.ts
into the plugin too, so it has no leftover core dependents.

* fix: remove compiled plugin JS left behind in plugins/ after build

copy-bundled-plugins.cjs compiled and rewrote plugin backend TS in
place, copied it to dist/plugins, but never deleted the .js it had
just emitted next to the .ts sources. Those files shadowed the real
source for vitest/tsx and had import paths rewritten for the dist
layout, so tests failed with "Cannot find module" once a local build
had run. Affects docker, host-metrics and ai, not just this one.

* feat: add a permission-gated backend service registry for plugins

* feat: add a permission-gated UI action registry and action slots for plugins

Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal
plugin offers and the ai plugin fills, gated on ai.services.use.

* feat: add cross-plugin secret references gated by the provider's RBAC permission

* feat: move proxmox discovery/import/sync into a first-party plugin

* feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin

Relocates guacamole backend/frontend source and tests into
plugins/remote-desktop, following the ssh-terminal/docker pattern.

* feat: wire remote-desktop plugin into core and rename i18n keys

Repoints core consumers (dispatcher, collab, session-sharing, tab
utils) at the plugin, adds isTabTypeAvailable gating for rdp/vnc/
telnet, widens hostCapability to accept an array, and renames the
guacamole i18n namespace to remoteDesktop.

* feat: move fleets into a first-party plugin

* feat: move automations into a first-party plugin

* feat: remove legacy alert rules, superseded by automations

Alert rules already auto-migrate into automations on boot and the old
engine stands down after. Deletes the rule/firing tables, routes, UI,
and AI tools; keeps notification channels since automations still use
them. Also fixes automations' notify step, which still called the
deleted repository.

* feat: move network topology into a first-party plugin

* feat: move tailscale device discovery and settings into a plugin

Adds plugins/tailscale/ with a devices sidebar tab, admin settings
section, and re-auth dialog. Host Metrics tailscale manager card and
SSH re-auth banner parsing stay in core.

* feat: move workspaces into a first-party plugin

* feat: move web endpoint into a first-party plugin

* feat: collapse plugin runtime to one tier behind a plugin SDK

* feat: make each plugin an npm workspace built by a termix-plugin CLI

* feat: give plugins their own tables, migrations and sync entities

* feat: serve plugin http and websockets from one mount instead of ports

* feat: namespace plugin permissions and keep them valid when a plugin is off

* feat: give plugins their own settings and one settings screen

* feat: load plugin frontends at runtime and remove plugin ids from the shell

* fix: mock the ai status fetch in its activate test

* feat: add auth extension points and one ssh connect pipeline

* feat: rebuild workspaces as the reference plugin

* fix: gate second factors after SSO/LDAP behind an admin setting

* feat: convert network-topology to a full plugin (B1)

* feat: move snippets backend and tables into a plugin (B2)

* feat: point core frontend snippets pickers at the plugin (B2)

* feat: add the snippets plugin (B2)

* feat: wire ai, automations, fleets and ssh-terminal to snippets (B2)

* feat: finish the tailscale plugin, including SSH auth (B3)

* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)

* chore: update proxmox and host-metrics locale files for B5

* chore: regenerate drizzle migration journals for B5

* feat: add ctx.hosts create/update to the SDK, host-metrics drops proxmox

* feat: move proxmox node stats and collectors into the proxmox plugin

* feat: drop proxmox host columns, use plugin settings instead

* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)

* feat: convert file manager into a plugin

* feat: convert ssh tunnels into a plugin

* feat: finish the web-endpoint plugin, add ctx.desktop (B8)

* feat: move the terminal fully into the ssh-terminal plugin (B9)

* feat: convert tmux monitoring into a plugin (B10)

* feat: convert serial console into the serial plugin

* feat: move session sharing and collab rooms into a plugin (B12)

* feat: turn session recording into a plugin (B13)

* feat: finish the remote-desktop plugin (B14)

* feat: finish the host-metrics plugin, move host status into core (B16)

* feat: finish the docker plugin (B15)

* feat: finish the automations plugin (B17)

* feat: finish the ai plugin (B18)

* feat: finish the homepage plugin, move service links and canvas out of core (B19)

* feat: convert wake-on-lan into a plugin (B20)

* feat: convert secret-sources into a plugin (B20)

* feat: move totp and passkeys into plugins (C1)

* feat: move sso and ldap logins into plugins (C2)

* feat: move opkssh and warpgate into plugins (C3)

* feat: move step-ca into a plugin (C4)

* feat: move vault ssh signing into a plugin (C5)

* feat: move termix identity into a plugin (C6)

* feat: move acme certificate automation into a plugin (C7)

* fix: finish 2.9.0 plugin moves and get every check green

* chore: lock the plugin boundary and remove 2.9.0 leftovers

* fix: harden the plugin runtime after the 2.9.0 security review

* feat: add plugin packing, signing, pinned trust and tmxplug bundling

* test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught

* fix: give bundled plugin backends a real require for CJS deps

* feat: improve UI loading time and jitter

* feat: show credential sharing inline in the sidebar like host sharing

* feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning

* feat: unify alerts into one plugin, channels, rules and inbox

* feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling

* fix: set blacksmith builder cache-key to stop broken gha cache fallback

* fix: link plugin-sdk workspace before npm ci in docker deps stage

* fix: link every plugin workspace before npm ci in docker deps stage

* fix(host-metrics): exclude image and firmware filesystems (#1469)

* fix(host-metrics): exclude image and firmware filesystems

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(hosts): normalize imported jump host references (#1470)

* fix(hosts): normalize imported jump host references

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(ci): build plugin SDK and repair MySQL settings migration (#1471)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): keep inline rename consistent (#1476)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): keep inline rename consistent

* fix(dashboard): reuse translated host status labels (#1475)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(dashboard): reuse translated host status labels

* docs(api): clarify local and remote version fields (#1474)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* docs(api): clarify local and remote version fields

* fix(file-manager): preserve selected backgrounds on hover (#1473)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): preserve selection feedback on hover

* test(ui): await dialog focus cleanup before teardown

* fix(file-manager): restore Ctrl/Cmd+F file search (#1472)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): focus file search with Ctrl or Cmd F

* fix: keep rounded-full circular in production builds

* fix(file-manager): cancel uploads and stop remote writes (#1477)

* fix(file-manager): cancel uploads and stop remote writes

* test(css): exercise the configured PostCSS plugin chain

* fix(file-manager): apply compact density to directory tree (#1478)

* fix(file-manager): apply compact density to directory tree

* test(css): exercise the configured PostCSS plugin chain

* fix(file-manager): preserve font sizes in compact mode (#1480)

* fix(file-manager): preserve font sizes in compact mode

* test(css): exercise the configured PostCSS plugin chain

* fix(file-manager): prioritize file names in narrow lists (#1483)

* fix(file-manager): prioritize file names in narrow lists

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test

* fix(remote-desktop): probe the saved host guacd endpoint (#1481)

* fix(remote-desktop): probe the saved host guacd endpoint

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* fix(homepage): check allowlisted private service reachability (#1479)

* fix(homepage): check allowlisted private service reachability

* test(css): exercise the configured PostCSS plugin chain

* fix(remote-desktop): stop unsolicited clipboard read prompts (#1482)

* fix(remote-desktop): read clipboard only on explicit paste

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test

* chore: simplify plugin readmes, changelogs and manifest descriptions

* fix: keep plugins loaded after signing in from the login screen

* fix(file-manager): accept OS file drops over existing rows (#1484)

* fix(file-manager): accept OS file drops over existing rows

* test(file-manager): target the file label in drop tests

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test

* fix(file-manager): pipeline SFTP download reads (#1485)

* feat: add plugin sdk build to beta release

* fix: make link-store secret check immune to random base64 matches

* fix(ldap): preserve the directory-provided bind DN (#1486)

* fix: compute beta changes across dev branches and move the beta tag

* chore: untrack plugin sdk ARCHITECTURE.md

* chore: remove references to plugin sdk ARCHITECTURE.md

* feat: merge host editor terminal tabs into one owned by ssh-terminal

* feat: add descriptions to plugin host enable toggles

* fix(file-manager): show download progress in file viewers (#1487)

* fix(electron): signal backend readiness over IPC (#1488)

* fix(split-screen): allow layout creation without randomUUID (#1490)

* fix(split-screen): allow layout creation without randomUUID

* test(credentials): avoid flaky Ed25519 key generation

* fix(sidebar): remeasure visible rows after density changes (#1489)

* fix(sidebar): remeasure visible rows after density changes

* test(credentials): avoid flaky Ed25519 key generation

* feat(split-screen): redesign split screen with free-form panes and pane picker

* feat(sidebar): add host click behavior setting, default to always opening a new tab

* feat(snippets): redesign panel to match the host list

* fix(session-recording): stop session logs panel from refetching in a loop

* feat(snippets): bring back sharing, target hosts and drag reorder

* feat(ssh-terminal): add toolbar position, start state, display and fade host settings

* feat(ssh-terminal): add host setting to turn off password prompt auto-fill

* fix(ai): stop streams getting cut off and keep tool steps across messages

* feat: improve quick connect system using plugin api

* fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport

* feat: move terminal settings, look and ssh tools into ssh-terminal plugin

* feat: move snippets, keybinding actions and macros out of core into plugins

* feat: store rdp, vnc and telnet logins in a generic per-protocol table

* feat: type the plugin host record, move plugin strings out of core and add lint checks

* feat: replace core homepage widget registry with generic extension points

* feat: plugins only see their own host settings in the browser

* feat: harden plugin api for separate repos, signed updates and generic external login

* fix: keep plugin css below core utilities and version plugin assets by their css

* fix: load and save plugin host settings in the editor and tighten settings row spacing

* feat: redesign user host defaults system

* fix(remote-desktop): release held keys on focus loss (#1498)

* fix(session-sharing): replace local presentation after takeover (#1500)

* fix(file-manager): refresh disk usage with directory listing (#1503)

* fix(ssh-terminal): preserve input order during local echo reconciliation (#1505)

* feat(remote-desktop): expose display zoom controls for RDP (#1514)

* fix(file-manager): restore text selection and copying in Markdown previews (#1518)

* fix(sidebar): keep compact host addresses visible (#1524)

* fix(hosts): preserve plugin settings when reopening the editor (#1499)

* fix(ssh-terminal): close owner sockets when presentations unmount (#1501)

* fix(session-sharing): synchronize shared SSH terminal dimensions (#1502)

* fix(session-sharing): show participant presence in member terminals (#1504)

* fix(ai): sync provider configuration and keys across linked devices (#1506)

* feat(ssh-terminal): add a duplicate tab action (#1510)

* feat(file-manager): allow disabling move-to-trash confirmation (#1511)

* feat(file-manager): default to the remote login directory (#1512)

* feat(homepage): synchronize canvas layouts across devices (#1515)

* fix(shortcuts): prevent accidental double-Shift palette activation (#1519)

* feat(tmux-monitor): add a unified multi-host view (#1520)

* fix(electron): allow explicit quit with active connections (#1522)

* feat: reconnect all disconnected terminal tabs (#1523)

* fix(hosts): preserve recording and sharing settings in the dev editor (#1509)

* fix(database): preserve RDP domain during startup backfill (#1497)

* feat(sidebar): add keyboard type-ahead search for hosts (#1516)

* feat(automations): add per-host maintenance mode and recurring schedules (#1521)

* feat(tmux): add a per-host mouse toggle and preserve remote configuration (#1513)

* fix(session-sharing): use the linked server for desktop meetings and links (#1507)

* fix(tunnels): support TOTP for client tunnels and jump hosts (#1508)

* fix: type errors and lint failures from merged plugin prs

* feat(installer): make the Windows desktop shortcut optional (#1517)

* fix: ship the plugin sdk and serialport in desktop builds

* fix: prefer ipv4 when resolving ssh host names (#1365)

* fix: readable terminal selection with the light theme (#1319)

* fix: build desktop session share links on the linked server (#1350)

* fix: show meeting guests to members and hide the roster from guests (#1353)

* fix: reliable file manager saves with sudo prompt and no base64 guessing (#1335)

* fix: show disk usage for the mount being browsed (#1347)

* fix: load the linux tray icon reliably and log tray failures (#1332)

* fix: cover the gnome close path in the desktop quit test (#1332)

* fix: prompt for jump host TOTP in terminal, file manager and metrics (#1312)

* feat: optional auto reconnect for dropped ssh sessions (#1321)

* feat: tighter compact host rows with inline tags (#1325)

* feat: bring back the alert feed homepage widget

* feat: pick target terminals for snippets again

* feat: share button on connection tabs again

* feat: recent servers list when linking the desktop app

* feat: list the fixed app shortcuts in keyboard shortcuts

* fix: translate hardcoded toasts and drop em dashes from ui text

* fix: translate leftover hardcoded ui text

* fix: toolbar files button opens at the shell directory again

* fix: show the remote desktop note under connection origin again

* chore: openapi docs for host metrics manager routes

* chore: openapi docs for undocumented routes and fix two broken doc blocks

* fix: declare terminal.listSessions use for snippet targets

* revert: drop the alert feed widget, the notification center replaces it

* feat: restore the alert feed homepage widget

* fix: quick connect fills the host address from plugin auth editors like tailscale

* fix: docker container logs use the base background color

* chore: fix dependabot alerts and apply safe npm upgrades

* fix: proxmox stats tab reads its switch from plugin host settings

* fix: count plugin ssh logins toward host status and stop showing reachable as a warning

* fix: hide the built-in source pill in the host editor

* fix: host status shows online when the port answers and only warns on a failed login

* fix: drop the top border on the split layout menu

* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default

* fix: remote desktop auth method labels and notes for vnc and telnet

* fix: add description to the web endpoint enable switch

* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release

* feat: move telemetry out of core into a usage statistics plugin

* fix: recent activity card no longer cuts off host names early

* fix: remove duplicate top border on split screen menu

* fix: redesign session log player controls, fix tooltip overlap and drop dot separators

* fix: file manager and sftp text showing raw i18n keys

* fix: onboarding rerunning for existing users after upgrading

* fix: fleet detail buttons overflowing the sidebar

* fix: session log player scrolling off screen horizontally

* fix: network graph lag when adding hosts and on every render

* fix: maintenance index name, shared host read only view and badge polling

* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag

* chore: update release notes

* fix: remote desktop tabs reconnecting on every tab switch

* fix: build plugin sdk before linting in release workflow

* chore: lint, format, and bump version to 2.9.0

* chore: skip crowdin pre-translate in release for now

* chore: sync Crowdin translations for 2.9.0

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Koi <38512047+datlt4@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-01 16:20:16 -05:00
1 parent 49fa488093
commit 3643e7af97
4405 files changed
+1341026 -289722

No files matched your search

+228
View File
@@ -0,0 +1,228 @@
# Finish list
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
- SSH terminal: password auth, key auth (and an encrypted key's passphrase
prompt), TOTP keyboard-interactive, a jump host, Warpgate, split view,
snippets sent to a terminal, reconnect after a network drop and after a
page reload (session reattach), and the local terminal in the desktop app.
- Serial: the Electron backend path against a real device path/COM port, and
the Web Serial browser picker in Chrome or Edge.
- Session sharing: a link share (read-only and read-write) opened in a private
window, a user share joined from Active Connections, revoke and "end for
everyone", and a collab room with two members, control hand-off, a guest
link, rotate and end.
- Session recording: an SSH session recorded end to end (playback matches what
happened, including a resize mid-session), a host with recording switched
off records nothing, retention prunes an old recording on schedule, a guacd
RDP/VNC/Telnet recording still gets a row and plays back, and deleting a
user anonymizes their recordings instead of removing them.
- Host status and metrics: on a 2.8 database, hosts whose status checks were
off stay off and a custom interval survives; the host list dot goes
reachable, then online once the Host Metrics tab or a terminal logs in;
an RDP-only host on a custom port shows online; the metrics tab, the
dashboard host bars, the homepage host status widget, the metrics chart
widget, the terminal toolbar bars and the file manager's disk bar; a
manager card (services, packages) and a health check; TOTP on the metrics
connect; the admin metrics interval, history retention and new-host default;
the temperature unit; disabling host metrics leaves every other plugin's
SSH working, then enabling it again.
- Remote desktop: an RDP, a VNC and a Telnet host; a host behind one jump
host (tunnels plugin on and off) and behind a chain of two; macOS Screen
Sharing over VNC with no username; RDP recording (and none with
session-recording off or the host's switch off); file transfer through the
RDP drive, drag and drop included; clipboard both ways; sharing an RDP
session by link (read-only and read-write) and presenting it in a collab
room; opening Windows Remote Desktop from the desktop app on Windows;
changing the guacd URL in admin settings without a restart; turning Remote
Desktop off in admin (actions disappear, connects answer 403); a 2.8 host
that only had `connection_type = rdp` comes up as RDP with SSH off; RDP user
defaults apply to a saved host; Quick Connect over RDP and VNC; the desktop
app with a host on the remote server and one on This device.
- Docker: a 2.8 host with Docker on (and one on Podman) keeps its switch and
runtime; list, start, stop, logs, stats and the console on a Linux host;
TOTP (including a wrong code first) and Warpgate on the connect; a host
with no stored password asks for credentials; the homepage Docker widget;
an automation Docker step and a `docker_event` trigger; disable Docker with
a console open, then enable it and open another.
- Automations: on a 2.8 database the automations, their run history and
their channel links are there; a schedule fires; a webhook fires on both
`/plugin-api/automations/webhook/<token>` and the old
`/automations/webhook/<token>`; a metric threshold on a host nobody has
open still fires (headless viewers); a `docker_event` trigger and a Docker
step; a notify step to webhook, ntfy and Discord channels, and to a LAN
ntfy with the private opt-in plus the admin allowlist; disable Docker and
the automation shows "Needs docker", its scheduled runs are recorded as
skipped and the others keep running, then enable it again; the AI
assistant lists and creates automations, and says they are unavailable
with automations off.
- Homepage: on a 2.8 database every widget, its layout and every service
link are there; add, move, resize, edit and delete a widget of each type;
the favicon, RSS, ping and custom API widgets against a real remote URL,
and each refuses a private/loopback target; the dashboard's Service
Links and Homepage Preview cards; the Dashboard/Homepage toggle and its
copy-link and open-full-view buttons; `?view=homepage`; docker's, tunnels'
and file-manager's own homepage widgets still work with the homepage
plugin disabled; the AI assistant lists homepage items and says
unavailable with homepage off; disable homepage in admin (the tab, its
dashboard cards and the toggle disappear) then enable it again.
- TOTP and passkeys: on a 2.8 database a TOTP user is still asked for a
code (and a backup code works once); set up TOTP from Settings > Security,
add a second device, regenerate backup codes, disable it; enabling signs
out other sessions; "remember me" skips it next time; disable the totp
plugin and a TOTP user's login is refused until an admin resets their
factors; register a passkey, sign in with it with and without a PIN (no
PIN still asks for TOTP), delete it; existing 2.8 passkeys still sign in.
- SSO and LDAP: on a 2.8 database every SSO provider and LDAP directory is
there, in Settings > Plugins > Single sign-on and > LDAP; an existing
provider still signs in through its identity provider without changing
anything there (its redirect URI shows `/users/oidc/callback`); add the
new redirect URI at the provider, switch it over and sign in again; a new
provider, GitHub and Google; an existing SSO user and an existing LDAP
user land in their old accounts; the env-configured provider; admin group
and role map; back-channel logout from Keycloak ends the session; the
desktop app's system browser login and Termix-Mobile's login; silent
sign-in; a second factor after an external login with the admin setting
on and off; disable each plugin and its buttons go away and its URLs stop
signing anyone in, then enable it again.
- OPKSSH and Warpgate: on a 2.8 Docker install with OPKSSH set up, the config
shows up under the plugin, the admin page shows the old redirect URI and a
terminal sign-in to an OPKSSH host works through the provider chooser
without touching the identity provider; switch to the new URI, register
it and sign in again; a second connect within 24 hours skips the browser;
an offline Docker install uses the baked binary; disable the opkssh plugin
and the host says it needs it. A Warpgate host's toggle is on in the host
editor's Plugins group after the upgrade, and the terminal, file manager
and docker each show the sign-in dialog and connect after it.
- Vault: on a 2.8 database every Vault profile is there and each Vault host
still has its profile selected in the host editor; the admin page shows the
old redirect URI; a terminal connect opens Vault's sign-in, signs a
certificate and connects, and a second connect skips the browser until the
certificate expires; create, edit, share and delete a profile from the host
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.
- 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).
- Run `bash scripts/upgrade-check.sh` once with Docker running. It upgrades
the D4 fixture on real Postgres 16 and MySQL 8. The Postgres half passed
against an in-process PGlite server during D4; MySQL has not run yet.
- 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).
- 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.
- Termix-Mobile: after a redirect login (SSO) with the admin setting
"second factor after external login" on, the server answers with the
second-factor step (pending cookie and `temp_token`) instead of a session.
The mobile app has to handle that step; that change is in the mobile repo.
- D2 changes to check by hand: a remote desktop session still opens, and a
touch mode switch within five minutes reuses its token (after that the app
mints a new one); the terminal, file manager and host metrics still connect
and sudo still works (they now declare `credentials:read`); Docker and
automations connect with a redacted host handed back to core; Admin >
Plugins > Permissions lists a plugin's public routes; a disabled plugin's
`/plugin-api` URL answers 503; a share-link guest who leaves cannot type.
## After D3 (needs the repos and keys)
- Run `termix-plugin keygen`, then pin the public key in
`TRUSTED_PLUGIN_KEYS` (`src/backend/plugins/trust.ts`) and save it as
`keys/official.pub` in Termix-Registry. Until then the list is empty and
`TERMIX_REQUIRE_SIGNED_PLUGINS=true` blocks every user plugin. Steps are in
the Termix-Registry README.
- Create the Termix-Registry and Termix-Plugin-Template repos from the local
folders next to this repo, and set `TERMIX_PLUGIN_SIGNING_KEY` and
`REGISTRY_TOKEN` secrets.
- Publish the SDK (`npm run build:sdk && npm publish --workspace
@termix/plugin-sdk`), then run `npm install` in Termix-Plugin-Template and
commit its `package-lock.json`; its release workflow uses `npm ci`.
- Plugins outside this repo cannot use `renderWithApp` or
`@termix/plugin-sdk/ui` (types and the test host come from core's source).
Ship them in the SDK package before community plugins need UI components.
- `npm run test:plugins` on Windows sometimes fails with "Worker exited
unexpectedly" in a random plugin (file-manager once, workspaces once); the
same suite passes alone and a rerun of the full run passed. Find out why
the vitest fork dies (D0).
- Check by hand: drop a signed `.tmxplug` into `<DATA_DIR>/plugins/` on a
real install, with and without `TERMIX_REQUIRE_SIGNED_PLUGINS=true`.
## 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/`.
- D2 low findings, not fixed: plugin sockets have no Origin check (a
SameSite=lax cookie is not sent on a cross-site WebSocket, so only a
sibling subdomain could try); a public `:param` path matches every HTTP
method and would also match a sibling literal route; `/plugins/public-manifest`
shows plugin versions before login; `ctx.process.ensureBinary` hashes any
`prebuilt` path on disk; `credentials.resolveHostProtocol` falls back to the
SSH password for an RDP login (2.8 behaviour, behind `credentials:read`);
a remote desktop display token can be replayed within its five minute TTL
(strict single use would break the touch mode switch and reconnect, which
reuse it). Revisit with signing and the install UI in 3.0.0.
+18
View File
@@ -0,0 +1,18 @@
# @termix/plugin-sdk
Everything a [Termix](https://github.com/Termix-SSH/Termix) plugin builds
against: the types for `activate(ctx)` and `activate(app)`, table
definitions, the manifest schema, test helpers, a vitest preset and the
`termix-plugin` CLI.
```bash
npm install --save-dev @termix/plugin-sdk
npx termix-plugin build # bundle dist/backend.js and dist/frontend.js
npx termix-plugin test # run the plugin's vitest suite
npx termix-plugin validate # check manifest.json and the files it names
npx termix-plugin pack # write <id>-<version>.tmxplug
npx termix-plugin sign <file.tmxplug> # needs TERMIX_PLUGIN_SIGNING_KEY
```
Start from
[Termix-Plugin-Template](https://github.com/Termix-SSH/Termix-Plugin-Template).
+246
View File
@@ -0,0 +1,246 @@
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import * as esbuild from "esbuild";
import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
import { staticUrlImports } from "../lib/static-url-imports.mjs";
import { readManifest, resolveEntry, copyDir } from "../lib/plugin-dir.mjs";
const BACKEND_ENTRIES = [
"src/backend/index.ts",
"src/backend/index.mjs",
"src/backend/index.js",
];
const FRONTEND_ENTRIES = [
"src/frontend/index.tsx",
"src/frontend/index.ts",
"src/frontend/index.mjs",
"src/frontend/index.js",
];
/**
* A bundled CommonJS package that calls require("react/jsx-runtime") (or any
* other host-provided package) cannot reach it: the package is external, and
* esbuild's ESM output has no require, so the plugin throws "Dynamic require
* is not supported" as soon as it loads. Each such require is pointed at a
* small ES module that imports the external and re-exports it, which esbuild
* can hand to CommonJS code.
*/
export function externalRequireInterop(externals) {
const names = new Set(externals.filter((name) => !name.includes("*")));
return {
name: "termix-external-require",
setup(build) {
build.onResolve({ filter: /.*/ }, (args) => {
if (args.kind !== "require-call" || !names.has(args.path)) return;
return { path: args.path, namespace: "termix-external-require" };
});
build.onLoad(
{ filter: /.*/, namespace: "termix-external-require" },
(args) => {
const id = JSON.stringify(args.path);
return {
contents: `import * as mod from ${id};\nexport * from ${id};\nexport default mod.default ?? mod;\n`,
loader: "js",
};
},
);
},
};
}
const SDK_PATCHES = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"patches",
);
/**
* Patches a dependency before it is bundled, so the fix ships inside the
* plugin rather than depending on whoever ran npm install.
*
* package.json "termix": { "patches": ["xterm-android-ime"] } names patches
* the SDK ships for libraries several plugins share. Any .cjs file in the
* plugin's own patches/ folder runs too. Every patch must be safe to run
* twice.
*/
export function applyPatches(cwd, pluginId) {
const pkgPath = path.join(cwd, "package.json");
const pkg = fs.existsSync(pkgPath)
? JSON.parse(fs.readFileSync(pkgPath, "utf8"))
: {};
const scripts = [];
for (const name of pkg.termix?.patches ?? []) {
const file = path.join(SDK_PATCHES, `${name}.cjs`);
if (!/^[a-z0-9-]+$/.test(name) || !fs.existsSync(file)) {
throw new Error(`${pluginId}: the SDK has no patch named "${name}"`);
}
scripts.push(file);
}
const own = path.join(cwd, "patches");
if (fs.existsSync(own)) {
for (const file of fs.readdirSync(own).sort()) {
if (file.endsWith(".cjs")) scripts.push(path.join(own, file));
}
}
for (const file of scripts) {
const result = spawnSync(process.execPath, [file], {
cwd,
env: { ...process.env, TERMIX_PATCH_ROOT: cwd },
stdio: "inherit",
});
if (result.status !== 0) {
throw new Error(`${pluginId}: patch ${path.basename(file)} failed`);
}
}
}
const THEME_CSS = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"..",
"dist",
"host",
"theme.css",
);
/**
* Compiles the Tailwind classes the plugin's frontend uses into
* dist/frontend.css, against the theme core ships in the SDK. Core only
* compiles its own classes, so a class no core file happens to use would
* otherwise have no CSS at all. Appended to whatever CSS the bundle already
* produced (a library's stylesheet the plugin imports).
*/
/**
* Moves a plugin's Tailwind layers under their own names. Core orders
* termix-plugin-utilities above its base styles and below its own utilities,
* so a plugin's copy of `.hidden` can never beat core's `md:flex` just
* because the plugin's file loaded later.
*/
export function lowerPluginLayers(css) {
return css.replace(
/@layer (properties|utilities)(?=[\s{;,])/g,
"@layer termix-plugin-$1",
);
}
export async function buildTailwind(cwd, outDir) {
const { compile, optimize } = await import("@tailwindcss/node");
const { Scanner } = await import("@tailwindcss/oxide");
if (!fs.existsSync(THEME_CSS)) {
throw new Error("The SDK theme is missing. Run: npm run build:sdk");
}
const input = [
'@import "tailwindcss/theme.css" theme(reference);',
`@import ${JSON.stringify(THEME_CSS.replaceAll("\\", "/"))};`,
'@import "tailwindcss/utilities.css" layer(utilities);',
].join("\n");
const compiler = await compile(input, {
base: path.dirname(THEME_CSS),
onDependency: () => {},
});
const scanner = new Scanner({
sources: [
{
base: path.join(cwd, "src", "frontend"),
pattern: "**/*",
negated: false,
},
],
});
// Flattened and minified the way core's own CSS is, so it does not rely
// on native CSS nesting.
const css = lowerPluginLayers(
optimize(compiler.build(scanner.scan()), { minify: true }).code.trim(),
);
if (!css) return;
const target = path.join(outDir, "frontend.css");
const existing = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : "";
fs.writeFileSync(target, `${existing}${existing ? "\n" : ""}${css}\n`);
}
export async function build({ cwd }) {
const manifest = readManifest(cwd);
const pluginId = manifest.id ?? path.basename(cwd);
const outDir = path.join(cwd, "dist");
applyPatches(cwd, pluginId);
fs.rmSync(outDir, { recursive: true, force: true });
fs.mkdirSync(outDir, { recursive: true });
const backendEntry = resolveEntry(cwd, BACKEND_ENTRIES);
if (!backendEntry) {
throw new Error(
`${pluginId}: no backend entry (looked for ${BACKEND_ENTRIES.join(", ")})`,
);
}
await esbuild.build({
entryPoints: [backendEntry],
outfile: path.join(outDir, "backend.js"),
bundle: true,
format: "esm",
platform: "node",
target: "node22",
sourcemap: true,
logLevel: "warning",
// A native dependency's compiled .node binary is resolved by the
// package's own relative paths, which break once esbuild inlines its JS
// elsewhere. Declaring it in nativeDependencies keeps it a real
// node_modules import instead, resolved at runtime like a host-provided
// package.
external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])],
// Bundled CJS deps still call require() for Node builtins at runtime.
// ESM has no ambient require, so give esbuild's require shim a real one.
banner: {
js: "import { createRequire as __termixCreateRequire } from 'node:module'; const require = __termixCreateRequire(import.meta.url);",
},
});
// Without this Node finds the host's typeless package.json, tries backend.js
// as CommonJS first and warns before reparsing it as ESM.
fs.writeFileSync(
path.join(outDir, "package.json"),
`${JSON.stringify({ type: "module" }, null, 2)}\n`,
);
const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES);
if (frontendEntry) {
await esbuild.build({
entryPoints: [frontendEntry],
outfile: path.join(outDir, "frontend.js"),
bundle: true,
format: "esm",
platform: "browser",
target: "es2022",
jsx: "automatic",
sourcemap: true,
logLevel: "warning",
external: FRONTEND_EXTERNALS,
// Vite replaces these in the dev server; a plugin bundle has to have
// them too, or code that reads them throws when the plugin loads.
define: {
"process.env.NODE_ENV": '"production"',
"import.meta.env.DEV": "false",
"import.meta.env.PROD": "true",
"import.meta.env.MODE": '"production"',
"import.meta.env.SSR": "false",
"import.meta.env":
'{"DEV":false,"PROD":true,"MODE":"production","SSR":false,"BASE_URL":"/"}',
},
plugins: [
externalRequireInterop(FRONTEND_EXTERNALS),
staticUrlImports({ outDir }),
],
});
}
if (frontendEntry) await buildTailwind(cwd, outDir);
copyDir(path.join(cwd, "locales"), path.join(outDir, "locales"));
copyDir(path.join(cwd, "migrations"), path.join(outDir, "migrations"));
console.log(`built ${pluginId}`);
}
@@ -0,0 +1,248 @@
/**
* Generates a plugin's migrations from its table definitions.
*
* Not drizzle-kit. Core's own SQLite schema is hand-written DDL and only the
* client-server engines use drizzle migrations, so a plugin on drizzle-kit
* would still need a hand-rolled SQLite path, three configs and three
* generated schema modules. This diffs the definitions against a committed
* snapshot and writes plain SQL for all three dialects, which is what the
* server's runner applies.
*
* Usage, from a plugin directory:
* termix-plugin migrations [name]
* termix-plugin migrations --check verifies nothing is undeclared
*
* The plugin exports its definitions from src/backend/tables.ts as `tables`.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import * as esbuild from "esbuild";
import { BACKEND_EXTERNALS } from "../lib/externals.mjs";
import { readManifest, resolveEntry } from "../lib/plugin-dir.mjs";
const DIALECTS = ["sqlite", "postgres", "mysql"];
const TABLE_ENTRIES = [
"src/backend/tables.ts",
"src/backend/tables.mjs",
"src/backend/tables.js",
];
/** Loads the plugin's table definitions by bundling them to a temp file. */
async function loadDefinitions(cwd) {
const entry = resolveEntry(cwd, TABLE_ENTRIES);
if (!entry) return null;
// Emitted inside the plugin rather than in the system temp dir: the SDK
// stays external to the bundle, so the output has to sit somewhere node can
// resolve @termix/plugin-sdk from.
const outdir = fs.mkdtempSync(path.join(cwd, ".termix-tables-"));
const outfile = path.join(outdir, "tables.mjs");
try {
await esbuild.build({
entryPoints: [entry],
outfile,
bundle: true,
format: "esm",
platform: "node",
target: "node22",
logLevel: "warning",
external: BACKEND_EXTERNALS,
});
const module = await import(`file://${outfile.split(path.sep).join("/")}`);
const tables = module.tables ?? module.default;
if (!Array.isArray(tables)) {
throw new Error(
`${path.relative(cwd, entry)} must export a "tables" array of defineTable() results`,
);
}
return tables;
} finally {
fs.rmSync(outdir, { recursive: true, force: true });
}
}
function snapshotPath(cwd) {
return path.join(cwd, "migrations", "snapshot.json");
}
function readSnapshot(cwd) {
const file = snapshotPath(cwd);
if (!fs.existsSync(file)) return { version: 1, tables: {} };
return JSON.parse(fs.readFileSync(file, "utf8"));
}
/** What a definition looks like in the snapshot, so a diff can compare them. */
function snapshotTable(definition) {
return {
columns: definition.columns,
indexes: definition.indexes,
...(definition.adopts ? { adopts: definition.adopts } : {}),
};
}
function nextSequence(cwd) {
let highest = 0;
for (const dialect of DIALECTS) {
const dir = path.join(cwd, "migrations", dialect);
if (!fs.existsSync(dir)) continue;
for (const file of fs.readdirSync(dir)) {
const match = /^(\d{4})_/.exec(file);
if (match) highest = Math.max(highest, Number(match[1]));
}
}
return highest + 1;
}
/**
* What changed since the snapshot.
*
* New tables and new columns are generated. A changed or removed column is
* reported rather than guessed at: renames and type changes need a data
* decision this cannot make, so the author writes that migration by hand.
*/
function diff(previous, definitions) {
const created = [];
const added = [];
const manual = [];
for (const definition of definitions) {
const before = previous.tables[definition.name];
if (!before) {
created.push(definition);
continue;
}
for (const [property, column] of Object.entries(definition.columns)) {
const existing = before.columns[property];
if (!existing) {
added.push({ definition, property, column });
continue;
}
if (JSON.stringify(existing) !== JSON.stringify(column)) {
manual.push(`${definition.name}.${property} changed type or modifiers`);
}
}
for (const property of Object.keys(before.columns)) {
if (!definition.columns[property]) {
manual.push(`${definition.name}.${property} was removed`);
}
}
}
for (const name of Object.keys(previous.tables)) {
if (!definitions.some((definition) => definition.name === name)) {
manual.push(`table ${name} was removed`);
}
}
return { created, added, manual };
}
export async function migrations({ cwd, args }) {
const manifest = readManifest(cwd);
const pluginId = manifest.id ?? path.basename(cwd);
const check = args.includes("--check");
const name = args.find((arg) => !arg.startsWith("--")) ?? "update";
const definitions = await loadDefinitions(cwd);
if (definitions === null) {
console.log(`${pluginId}: no table definitions, nothing to generate`);
return;
}
const { LEGACY_TABLE_OWNERS } = await import(
new URL("../../dist/db.js", import.meta.url).href
);
for (const definition of definitions) {
if (
definition.adopts &&
LEGACY_TABLE_OWNERS[definition.adopts] !== pluginId
) {
throw new Error(
`${pluginId}: table ${definition.name} adopts "${definition.adopts}", which belongs to the "${LEGACY_TABLE_OWNERS[definition.adopts]}" plugin`,
);
}
}
const { createTableSql, adoptTableSql, addColumnSql } = await loadEmitter();
const previous = readSnapshot(cwd);
const { created, added, manual } = diff(previous, definitions);
if (manual.length > 0) {
for (const problem of manual) console.error(` ${problem}`);
throw new Error(
`${pluginId}: these changes need a hand-written migration, because a rename or a type change is a data decision.`,
);
}
if (created.length === 0 && added.length === 0) {
console.log(`${pluginId}: migrations are up to date`);
return;
}
if (check) {
throw new Error(
`${pluginId}: table definitions have changed but no migration was generated. Run: termix-plugin migrations`,
);
}
const sequence = String(nextSequence(cwd)).padStart(4, "0");
const file = `${sequence}_${name.replace(/[^a-z0-9_]/gi, "_").toLowerCase()}.sql`;
for (const dialect of DIALECTS) {
const statements = [];
for (const definition of created) {
// An adopted table renames the legacy one into place, creating it first
// on a fresh install, so existing rows are kept rather than copied.
statements.push(
...(definition.adopts
? adoptTableSql(dialect, pluginId, definition)
: createTableSql(dialect, pluginId, definition)),
);
}
for (const entry of added) {
statements.push(
addColumnSql(dialect, pluginId, entry.definition, entry.property),
);
}
const dir = path.join(cwd, "migrations", dialect);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, file),
`-- ${pluginId} ${sequence}: ${name}\n-- Generated by termix-plugin migrations. Review before committing.\n\n${statements.join("\n")}\n`,
);
}
const snapshot = { version: 1, tables: {} };
for (const definition of definitions) {
snapshot.tables[definition.name] = snapshotTable(definition);
}
fs.writeFileSync(snapshotPath(cwd), `${JSON.stringify(snapshot, null, 2)}\n`);
console.log(
`${pluginId}: wrote ${DIALECTS.length} dialect files for ${sequence}_${name}`,
);
}
/**
* The DDL emitter, shared with the server.
*
* One mapping, two consumers: the SQL written here is the SQL the server's
* migration runner applies, so a second copy could drift into producing a
* migration the runner cannot reproduce.
*/
async function loadEmitter() {
const entry = new URL("../../dist/ddl.js", import.meta.url);
if (!fs.existsSync(fileURLToPath(entry))) {
throw new Error("The plugin SDK is not built yet. Run: npm run build:sdk");
}
return import(entry.href);
}
+42
View File
@@ -0,0 +1,42 @@
import fs from "node:fs";
import path from "node:path";
import { readManifest } from "../lib/plugin-dir.mjs";
import { collectFiles, createTmxplug } from "../lib/tmxplug.mjs";
import { sha256 } from "../lib/signing.mjs";
import { validate } from "./validate.mjs";
function readOption(args, name) {
const index = args.indexOf(name);
if (index === -1) return null;
const value = args[index + 1];
if (!value) throw new Error(`${name} needs a value`);
return value;
}
/**
* Writes <id>-<version>.tmxplug. Validates first and refuses on any error,
* so a broken manifest never becomes a release.
*/
export async function pack({ cwd, args = [] }) {
const manifest = readManifest(cwd);
const pluginId = manifest.id ?? path.basename(cwd);
const version = manifest.version ?? "0.0.0";
if (!fs.existsSync(path.join(cwd, "dist", "backend.js"))) {
throw new Error(`${pluginId}: nothing built. Run termix-plugin build.`);
}
await validate({ cwd });
const outDir = path.resolve(cwd, readOption(args, "--out") ?? ".");
fs.mkdirSync(outDir, { recursive: true });
const outfile = path.join(outDir, `${pluginId}-${version}.tmxplug`);
const archive = createTmxplug(cwd, collectFiles(cwd));
fs.writeFileSync(outfile, archive);
console.log(`packed ${path.basename(outfile)}`);
console.log(` size ${archive.length}`);
console.log(` sha256 ${sha256(archive).toString("hex")}`);
return outfile;
}
+83
View File
@@ -0,0 +1,83 @@
import fs from "node:fs";
import path from "node:path";
import {
SIGNING_KEY_ENV,
generateKeyPair,
loadPrivateKey,
sha256,
signArtifact,
verifyArtifact,
} from "../lib/signing.mjs";
function readOption(args, name) {
const index = args.indexOf(name);
if (index === -1) return null;
const value = args[index + 1];
if (!value) throw new Error(`${name} needs a value`);
return value;
}
function artifactArg(cwd, args) {
const file = args.find((arg) => arg.endsWith(".tmxplug"));
if (!file) throw new Error("Pass the .tmxplug file to use");
const full = path.resolve(cwd, file);
if (!fs.existsSync(full)) throw new Error(`${file} does not exist`);
return full;
}
/** Writes <file>.sig: base64 Ed25519 over the sha256 of the .tmxplug. */
export async function sign({ cwd, args = [], env = process.env }) {
const file = artifactArg(cwd, args);
const key = loadPrivateKey(env[SIGNING_KEY_ENV]);
const buffer = fs.readFileSync(file);
const signature = signArtifact(buffer, key);
fs.writeFileSync(`${file}.sig`, `${signature}\n`);
console.log(`signed ${path.basename(file)}`);
console.log(` sha256 ${sha256(buffer).toString("hex")}`);
console.log(` signature ${signature}`);
return signature;
}
/** Checks a .tmxplug against its .sig and one or more public keys. */
export async function verify({ cwd, args = [] }) {
const file = artifactArg(cwd, args);
const keys = (readOption(args, "--key") ?? "")
.split(",")
.map((key) => key.trim())
.filter(Boolean);
if (keys.length === 0) throw new Error("Pass --key <base64 public key>");
const sigFile = readOption(args, "--sig") ?? `${file}.sig`;
if (!fs.existsSync(sigFile)) throw new Error(`${sigFile} does not exist`);
const matched = verifyArtifact(
fs.readFileSync(file),
fs.readFileSync(sigFile, "utf8"),
keys,
);
if (!matched)
throw new Error(`${path.basename(file)}: signature is not valid`);
console.log(`ok ${path.basename(file)} signed by key ${matched}`);
return matched;
}
/**
* Writes a new private key to a file and prints the public half. The
* private key never goes to stdout, so it does not end up in a CI log.
*/
export async function keygen({ cwd, args = [] }) {
const outDir = path.resolve(cwd, readOption(args, "--out") ?? ".");
const keyFile = path.join(outDir, "termix-plugin-signing.key");
if (fs.existsSync(keyFile)) {
throw new Error(`${keyFile} already exists; move it first`);
}
const pair = generateKeyPair();
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(keyFile, `${pair.privateKey}\n`, { mode: 0o600 });
console.log(`private key ${keyFile}`);
console.log(`public key ${pair.publicKey}`);
console.log(`key id ${pair.keyId}`);
console.log(
`Store the private key as ${SIGNING_KEY_ENV}, then delete the file.`,
);
return { keyFile, publicKey: pair.publicKey, keyId: pair.keyId };
}
+33
View File
@@ -0,0 +1,33 @@
import { spawnSync } from "node:child_process";
import path from "node:path";
import { createRequire } from "node:module";
/**
* Runs vitest through its own entry rather than the npx shim, so no shell is
* involved and paths with spaces work the same on every platform.
*/
export async function test({ cwd, args }) {
const require = createRequire(import.meta.url);
let vitestBin;
try {
// vitest.mjs is the package bin, not an exported subpath, so resolve the
// manifest and walk to it.
const manifest = require.resolve("vitest/package.json", { paths: [cwd] });
vitestBin = path.join(path.dirname(manifest), "vitest.mjs");
} catch {
throw new Error(
"vitest is not installed. Run npm install at the repo root.",
);
}
const result = spawnSync(
process.execPath,
[vitestBin, "run", "--config", "vitest.config.ts", ...args],
{ cwd, stdio: "inherit" },
);
if (result.status !== 0) {
throw new Error(`vitest exited with ${result.status}`);
}
}
@@ -0,0 +1,189 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { readManifest } from "../lib/plugin-dir.mjs";
/**
* The manifest rules live in src/manifest.ts, which the server uses too, so
* there is one implementation rather than a copy here that drifts.
*/
async function loadParseManifest() {
const entry = new URL("../../dist/manifest.js", import.meta.url);
if (!fs.existsSync(fileURLToPath(entry))) {
throw new Error("The plugin SDK is not built yet. Run: npm run build:sdk");
}
const mod = await import(entry.href);
return mod.parseManifest;
}
export async function validate({ cwd }) {
const raw = readManifest(cwd);
const parseManifest = await loadParseManifest();
const { errors } = parseManifest(raw);
const problems = [...errors];
// The manifest names files. They have to be there.
const referenced = [
raw.backend ?? "dist/backend.js",
raw.frontend ?? "dist/frontend.js",
raw.locales ?? "locales",
];
for (const rel of referenced) {
if (!fs.existsSync(path.join(cwd, rel))) {
problems.push(`manifest references ${rel}, which does not exist`);
}
}
problems.push(
...(await validateMigrations(cwd, raw.id ?? path.basename(cwd))),
);
problems.push(...validateNativeDependencies(cwd, raw));
problems.push(...validatePackage(cwd, raw));
if (problems.length > 0) {
for (const problem of problems) console.error(` ${problem}`);
throw new Error(`${raw.id ?? path.basename(cwd)}: manifest is not valid.`);
}
console.log(`ok ${raw.id ?? path.basename(cwd)}`);
}
/**
* package.json has to agree with the manifest: the same version, and a real
* SDK range rather than "*", so a plugin built against a newer SDK says so.
*/
function validatePackage(cwd, raw) {
const problems = [];
const pkgPath = path.join(cwd, "package.json");
if (!fs.existsSync(pkgPath)) return problems;
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
if (pkg.version && raw.version && pkg.version !== raw.version) {
problems.push(
`package.json version ${pkg.version} does not match manifest version ${raw.version}`,
);
}
const sdkRange =
pkg.peerDependencies?.["@termix/plugin-sdk"] ??
pkg.dependencies?.["@termix/plugin-sdk"] ??
pkg.devDependencies?.["@termix/plugin-sdk"];
if (!sdkRange) {
problems.push("package.json does not depend on @termix/plugin-sdk");
} else if (sdkRange === "*" || sdkRange === "latest") {
problems.push(
`@termix/plugin-sdk is "${sdkRange}"; pin a range such as "^1.0.0"`,
);
}
return problems;
}
/**
* A native dependency has to actually be a real dependency of the plugin, or
* the build's external declaration points at nothing node_modules can
* resolve at runtime.
*/
function validateNativeDependencies(cwd, raw) {
const problems = [];
const nativeDependencies = raw.nativeDependencies ?? [];
if (nativeDependencies.length === 0) return problems;
const pkgPath = path.join(cwd, "package.json");
const pkg = fs.existsSync(pkgPath)
? JSON.parse(fs.readFileSync(pkgPath, "utf8"))
: {};
const deps = { ...pkg.dependencies };
for (const name of nativeDependencies) {
if (!(name in deps)) {
problems.push(
`nativeDependencies names "${name}", which is not in this plugin's own package.json dependencies`,
);
}
}
return problems;
}
const DIALECTS = ["sqlite", "postgres", "mysql"];
/**
* Checks that a plugin's migrations only touch tables it owns.
*
* The server enforces this too, because a plugin installed from a tarball
* never ran this command. Here it is a build-time error with a file name
* attached, rather than a plugin that fails to activate later.
*/
async function validateMigrations(cwd, pluginId) {
const { LEGACY_TABLE_OWNERS: LEGACY_TABLES } =
await import("../../dist/db.js");
const { collectOwnedIndexes, findUnownedTableWrites } =
await import("../../dist/ddl.js");
const problems = [];
const legacy = new Set(
Object.entries(LEGACY_TABLES)
.filter(([, owner]) => owner === pluginId)
.map(([table]) => table),
);
const root = path.join(cwd, "migrations");
if (!fs.existsSync(root)) return problems;
const present = DIALECTS.filter((dialect) =>
fs.existsSync(path.join(root, dialect)),
);
const byDialect = new Map();
for (const dialect of present) {
const dir = path.join(root, dialect);
const files = fs
.readdirSync(dir)
.filter((file) => file.endsWith(".sql"))
.sort();
byDialect.set(dialect, files);
const sqls = files.map((file) =>
fs.readFileSync(path.join(dir, file), "utf8"),
);
const indexes = collectOwnedIndexes(pluginId, sqls, legacy);
for (const [index, file] of files.entries()) {
if (!/^\d{4}_[a-z0-9_]+\.sql$/.test(file)) {
problems.push(
`migrations/${dialect}/${file} must be named NNNN_name.sql, lower snake_case`,
);
}
const sql = sqls[index];
for (const problem of findUnownedTableWrites(
pluginId,
sql,
legacy,
indexes,
)) {
problems.push(`migrations/${dialect}/${file} ${problem}`);
}
}
}
// A migration that exists for one engine and not another leaves that
// deployment without the table, which is how alert_rules ended up
// SQLite-only in core.
const [first, ...rest] = present;
for (const dialect of rest) {
const a = byDialect.get(first);
const b = byDialect.get(dialect);
for (const file of a) {
if (!b.includes(file)) {
problems.push(`migrations/${dialect}/${file} is missing`);
}
}
for (const file of b) {
if (!a.includes(file)) {
problems.push(`migrations/${first}/${file} is missing`);
}
}
}
return problems;
}
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env node
/**
* termix-plugin: build, validate, test, pack and sign a Termix plugin.
*
* Run from a plugin directory (npm run build inside plugins/<id>/ does).
*/
import process from "node:process";
import { applyPatches, build } from "./commands/build.mjs";
import { readManifest } from "./lib/plugin-dir.mjs";
import { validate } from "./commands/validate.mjs";
import { test } from "./commands/test.mjs";
import { pack } from "./commands/pack.mjs";
import { sign, verify, keygen } from "./commands/sign.mjs";
import { migrations } from "./commands/migrations.mjs";
const COMMANDS = {
build,
patch: async ({ cwd }) => applyPatches(cwd, readManifest(cwd).id),
validate,
test,
pack,
sign,
verify,
keygen,
migrations,
};
const [command, ...args] = process.argv.slice(2);
if (!command || command === "--help" || command === "-h") {
console.log(
[
"Usage: termix-plugin <command>",
"",
" build Bundle the plugin into dist/",
" patch Apply the plugin's dependency patches (build does this too)",
" validate Check manifest.json and the files it names",
" test Run the plugin's vitest suite",
" pack Write <id>-<version>.tmxplug of the built plugin [--out dir]",
" sign Sign a .tmxplug with TERMIX_PLUGIN_SIGNING_KEY <file>",
" verify Check a .tmxplug's .sig <file> --key <base64>[,...]",
" keygen Write a new signing key pair [--out dir]",
" migrations Generate migrations from the plugin's table definitions",
].join("\n"),
);
process.exit(command ? 0 : 1);
}
const run = COMMANDS[command];
if (!run) {
console.error(`Unknown command: ${command}`);
process.exit(1);
}
try {
await run({ cwd: process.cwd(), args });
} catch (error) {
console.error(error?.message ?? error);
process.exit(1);
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Host-provided packages.
*
* These ship with the Termix server and are never bundled into a plugin:
* bundling them would give each plugin its own copy of express or React, and
* a second ssh2 would mean a second set of native bindings. A plugin may
* depend on anything outside these lists, and that does get bundled.
*
* A plugin lists the ones it uses as peerDependencies. Only add a package here
* when core itself ships it; a library one plugin needs belongs in that
* plugin's own dependencies, so it can update without a core release.
*
* Node builtins are external automatically through platform: "node".
*/
export const BACKEND_EXTERNALS = [
"@termix/plugin-sdk",
"@termix/plugin-sdk/*",
"express",
"ssh2",
"ws",
"multer",
"cookie-parser",
"axios",
"drizzle-orm",
"drizzle-orm/*",
// Native image processing, loaded on first use by the terminal's image upload.
"sharp",
];
/**
* Resolved by the page's import map to the shell's own copies, because these
* must be one instance: React, i18next, the toast store, and the SDK entries
* that share state with core. Anything else a plugin imports is bundled.
*/
export const FRONTEND_EXTERNALS = [
"react",
"react-dom",
"react/jsx-runtime",
"react-dom/client",
"i18next",
"react-i18next",
"sonner",
"@termix/plugin-sdk/frontend",
"@termix/plugin-sdk/ui",
];
@@ -0,0 +1,29 @@
import fs from "node:fs";
import path from "node:path";
export function readManifest(cwd) {
const manifestPath = path.join(cwd, "manifest.json");
if (!fs.existsSync(manifestPath)) {
throw new Error(`No manifest.json in ${cwd}`);
}
try {
return JSON.parse(fs.readFileSync(manifestPath, "utf8"));
} catch (error) {
throw new Error(`manifest.json is not valid JSON: ${error.message}`);
}
}
/** The first of these that exists, or null. */
export function resolveEntry(cwd, candidates) {
for (const candidate of candidates) {
const full = path.join(cwd, candidate);
if (fs.existsSync(full)) return full;
}
return null;
}
export function copyDir(from, to) {
if (!fs.existsSync(from)) return false;
fs.cpSync(from, to, { recursive: true });
return true;
}
+86
View File
@@ -0,0 +1,86 @@
import crypto from "node:crypto";
/** The env var that holds the base64 PKCS8 private key. Never commit it. */
export const SIGNING_KEY_ENV = "TERMIX_PLUGIN_SIGNING_KEY";
// DER prefix of an Ed25519 SubjectPublicKeyInfo; the raw 32-byte key follows.
const SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
export function sha256(buffer) {
return crypto.createHash("sha256").update(buffer).digest();
}
export function publicKeyFromRaw(base64) {
const raw = Buffer.from(base64.trim(), "base64");
if (raw.length !== 32) throw new Error("A public key is 32 bytes, base64");
return crypto.createPublicKey({
key: Buffer.concat([SPKI_PREFIX, raw]),
format: "der",
type: "spki",
});
}
export function rawPublicKey(keyObject) {
const der = keyObject.export({ format: "der", type: "spki" });
return der.subarray(SPKI_PREFIX.length).toString("base64");
}
/** First 16 hex chars of the sha256 of the raw public key. */
export function keyId(publicKeyBase64) {
return sha256(Buffer.from(publicKeyBase64, "base64"))
.toString("hex")
.slice(0, 16);
}
export function generateKeyPair() {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicBase64 = rawPublicKey(publicKey);
return {
publicKey: publicBase64,
privateKey: privateKey
.export({ format: "der", type: "pkcs8" })
.toString("base64"),
keyId: keyId(publicBase64),
};
}
/** Accepts base64 PKCS8 DER (what keygen writes) or a PEM block. */
export function loadPrivateKey(value) {
if (!value || !value.trim()) {
throw new Error(`${SIGNING_KEY_ENV} is not set`);
}
const trimmed = value.trim();
const key = trimmed.startsWith("-----BEGIN")
? crypto.createPrivateKey(trimmed)
: crypto.createPrivateKey({
key: Buffer.from(trimmed, "base64"),
format: "der",
type: "pkcs8",
});
if (key.asymmetricKeyType !== "ed25519") {
throw new Error("The signing key must be an Ed25519 key");
}
return key;
}
/** Ed25519 over the raw 32-byte sha256 of the artifact, base64. */
export function signArtifact(buffer, privateKey) {
return crypto.sign(null, sha256(buffer), privateKey).toString("base64");
}
/** The id of the first key that verifies the signature, or null. */
export function verifyArtifact(buffer, signatureBase64, publicKeys) {
const digest = sha256(buffer);
const signature = Buffer.from(signatureBase64.trim(), "base64");
if (signature.length !== 64) return null;
for (const key of publicKeys) {
try {
if (crypto.verify(null, digest, publicKeyFromRaw(key), signature)) {
return keyId(key);
}
} catch {
// A malformed key is just a key that does not match.
}
}
return null;
}
@@ -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",
};
});
},
};
}
+106
View File
@@ -0,0 +1,106 @@
import fs from "node:fs";
import path from "node:path";
import zlib from "node:zlib";
/** What a .tmxplug holds, in the order the spec lists it. */
export const TMXPLUG_CONTENTS = [
"manifest.json",
"dist",
"locales",
"migrations",
"README.md",
"CHANGELOG.md",
"icon.svg",
];
const BLOCK = 512;
/**
* Every file under the given top-level entries, as sorted posix paths.
* Directories are implied by their files, so none get their own entry.
*/
export function collectFiles(cwd, entries = TMXPLUG_CONTENTS) {
const files = [];
const walk = (rel) => {
const full = path.join(cwd, rel);
const stat = fs.lstatSync(full);
if (stat.isSymbolicLink()) {
throw new Error(`${rel} is a symlink; a .tmxplug holds plain files only`);
}
if (stat.isDirectory()) {
for (const child of fs.readdirSync(full)) {
walk(path.posix.join(rel, child));
}
} else if (stat.isFile()) {
files.push(rel);
}
};
for (const entry of entries) {
if (fs.existsSync(path.join(cwd, entry))) walk(entry);
}
return files.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
}
function writeString(buf, offset, length, value) {
const bytes = Buffer.from(value, "utf8");
if (bytes.length > length) throw new Error(`"${value}" is too long`);
bytes.copy(buf, offset);
}
function writeOctal(buf, offset, length, value) {
writeString(buf, offset, length, value.toString(8).padStart(length - 1, "0"));
}
function splitName(name) {
if (Buffer.byteLength(name) <= 100) return { name, prefix: "" };
for (let i = name.length - 1; i > 0; i--) {
if (name[i] !== "/") continue;
const prefix = name.slice(0, i);
const rest = name.slice(i + 1);
if (Buffer.byteLength(prefix) <= 155 && Buffer.byteLength(rest) <= 100) {
return { name: rest, prefix };
}
}
throw new Error(`${name} is too long for a .tmxplug entry`);
}
function header(name, size) {
const buf = Buffer.alloc(BLOCK);
const split = splitName(name);
writeString(buf, 0, 100, split.name);
writeOctal(buf, 100, 8, 0o644);
writeOctal(buf, 108, 8, 0);
writeOctal(buf, 116, 8, 0);
writeOctal(buf, 124, 12, size);
writeOctal(buf, 136, 12, 0);
buf.fill(0x20, 148, 156);
buf[156] = 0x30;
writeString(buf, 257, 6, "ustar");
writeString(buf, 263, 2, "00");
writeString(buf, 345, 155, split.prefix);
let sum = 0;
for (const byte of buf) sum += byte;
writeString(buf, 148, 8, sum.toString(8).padStart(6, "0") + "\0 ");
return buf;
}
/**
* A gzipped ustar archive that depends only on file names and contents:
* fixed mode, owner and mtime, sorted entries, and a gzip header with no
* timestamp and a fixed OS byte (Node on Windows writes a different one).
* Rebuilding from the same tag gives the same sha256 on any machine.
*/
export function createTmxplug(cwd, files) {
const parts = [];
for (const rel of files) {
const data = fs.readFileSync(path.join(cwd, rel));
parts.push(header(rel, data.length), data);
const pad = (BLOCK - (data.length % BLOCK)) % BLOCK;
if (pad) parts.push(Buffer.alloc(pad));
}
parts.push(Buffer.alloc(BLOCK * 2));
const gz = zlib.gzipSync(Buffer.concat(parts), { level: 9 });
gz.writeUInt32LE(0, 4);
gz[9] = 0x03;
return gz;
}
@@ -0,0 +1,76 @@
const fs = require("fs");
const path = require("path");
// Run by `termix-plugin build` with TERMIX_PATCH_ROOT set to the plugin, so
// the package resolves the way the plugin's bundle will resolve it.
function packageDir(name) {
const root = process.env.TERMIX_PATCH_ROOT || process.cwd();
// Walked by hand: a package whose exports map hides package.json cannot be
// found with require.resolve.
for (let dir = path.resolve(root); ; dir = path.dirname(dir)) {
const candidate = path.join(dir, "node_modules", name);
if (fs.existsSync(path.join(candidate, "package.json"))) return candidate;
if (path.dirname(dir) === dir) return path.join(root, "node_modules", name);
}
}
const packageRoot = path.join(packageDir("guacamole-common-js"));
const bundlePaths = [
path.join(packageRoot, "dist", "esm", "guacamole-common.js"),
path.join(packageRoot, "dist", "cjs", "guacamole-common.js"),
];
const oldFlushBlock =
" if (window.requestAnimationFrame && document.hasFocus())\n" +
" asyncFlush();\n" +
" else\n" +
" syncFlush();";
const newFlushBlock =
" // Electron can throttle or skip requestAnimationFrame() for inactive\n" +
" // windows/tabs even while guacd is still sending display frames. Flush\n" +
" // synchronously so Guacamole connections do not stall while waiting for\n" +
" // a frame callback that may never run.\n" +
" syncFlush();";
let patched = false;
let foundBundle = false;
for (const bundlePath of bundlePaths) {
if (!fs.existsSync(bundlePath)) {
console.log(
`[patch-guacamole-common-js] ${bundlePath} not found, skipping`,
);
continue;
}
foundBundle = true;
let content = fs.readFileSync(bundlePath, "utf8");
if (content.includes(newFlushBlock)) continue;
if (!content.includes(oldFlushBlock)) {
console.log(
`[patch-guacamole-common-js] Flush target not found in ${bundlePath}, skipping`,
);
continue;
}
content = content.replace(oldFlushBlock, newFlushBlock);
fs.writeFileSync(bundlePath, content);
patched = true;
}
if (!foundBundle) {
console.log("[patch-guacamole-common-js] File not found, skipping");
process.exit(0);
}
if (!patched) {
console.log("[patch-guacamole-common-js] Already patched");
process.exit(0);
}
console.log(
"[patch-guacamole-common-js] Patched display flush to avoid Electron requestAnimationFrame stalls",
);
@@ -0,0 +1,130 @@
const fs = require("node:fs");
const path = require("node:path");
// Run by `termix-plugin build` with TERMIX_PATCH_ROOT set to the plugin, so
// the package resolves the way the plugin's bundle will resolve it.
function packageDir(name) {
const root = process.env.TERMIX_PATCH_ROOT || process.cwd();
// Walked by hand: a package whose exports map hides package.json cannot be
// found with require.resolve.
for (let dir = path.resolve(root); ; dir = path.dirname(dir)) {
const candidate = path.join(dir, "node_modules", name);
if (fs.existsSync(path.join(candidate, "package.json"))) return candidate;
if (path.dirname(dir) === dir) return path.join(root, "node_modules", name);
}
}
const xtermDir = path.join(packageDir("@xterm/xterm"), "lib");
// Backport the textarea-shrink fix from gmuxapp/xterm.js@6a011cf while
// xtermjs/xterm.js#3600 remains unresolved upstream. Android IMEs can restart
// composition on the previous word and replace it with a shorter value (for
// example, Vietnamese "Hoar" -> "Hỏa"). xterm 6.0 otherwise emits nothing.
//
// Also fixes _handleAnyTextareaChanges, which iOS Safari/WKWebView drives
// for ordinary typing (it reports keyCode 229 for all software-keyboard
// input, not just IME composition). That handler diffs the textarea value
// via `newValue.replace(oldValue, "")`, a literal substring removal. When
// keystrokes arrive faster than the function's setTimeout(0) callback runs,
// several overlapping callbacks each capture a stale oldValue, so the
// literal-substring search fails to match and the diff silently comes back
// empty - characters are dropped instead of sent. Swap in the same
// common-prefix diff used for composition-end above so a stale oldValue
// still yields the correct delta.
const patches = [
{
file: "xterm.mjs",
replacements: [
[
[
'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
],
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._pendingTextareaValue=null,this._dataAlreadySent=""',
],
[
'this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent=""',
'this._compositionPosition.start=this._textarea.value.length,this._preCompositionValue=this._textarea.value,this._compositionView.textContent=""',
],
[
"let e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0",
"let e={start:this._compositionPosition.start,end:this._compositionPosition.end};const s=this._preCompositionValue;this._isSendingComposition=!0",
],
[
"e.start+=this._dataAlreadySent.length,this._isComposing?i=this._textarea.value.substring(e.start,this._compositionPosition.start):i=this._textarea.value.substring(e.start),i.length>0&&",
"e.start+=this._dataAlreadySent.length;if(this._isComposing)i=this._textarea.value.substring(e.start,this._compositionPosition.start);else{const t=this._textarea.value;if(t.length<s.length){let e=0;const r=Math.min(t.length,s.length);for(;e<r&&t.charCodeAt(e)===s.charCodeAt(e);)e++;i=b.DEL.repeat(s.length-e)+t.substring(e)}else i=t.substring(e.start)}i.length>0&&",
],
[
[
'_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,i=e.replace(t,"");this._dataAlreadySent=i,e.length>t.length?this._coreService.triggerDataEvent(i,!0):e.length<t.length?this._coreService.triggerDataEvent(`${b.DEL}`,!0):e.length===t.length&&e!==t&&this._coreService.triggerDataEvent(e,!0)}},0)}',
"_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r<n&&e.charCodeAt(r)===t.charCodeAt(r);)r++;let i=e.length<t.length?b.DEL.repeat(t.length-r)+e.substring(r):e.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}},0)}",
],
"_handleAnyTextareaChanges(){if(this._pendingTextareaValue!==null)return;this._pendingTextareaValue=this._textarea.value,setTimeout(()=>{const t=this._pendingTextareaValue;this._pendingTextareaValue=null;if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r<n&&e.charCodeAt(r)===t.charCodeAt(r);)r++;let i=e.length<t.length?b.DEL.repeat(t.length-r)+e.substring(r):e.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}},0)}",
],
],
},
{
file: "xterm.js",
replacements: [
[
[
'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
],
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._pendingTextareaValue=null,this._dataAlreadySent=""',
],
[
'this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent=""',
'this._compositionPosition.start=this._textarea.value.length,this._preCompositionValue=this._textarea.value,this._compositionView.textContent=""',
],
[
"const e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0",
"const e={start:this._compositionPosition.start,end:this._compositionPosition.end},i=this._preCompositionValue;this._isSendingComposition=!0",
],
[
"e.start+=this._dataAlreadySent.length,t=this._isComposing?this._textarea.value.substring(e.start,this._compositionPosition.start):this._textarea.value.substring(e.start),t.length>0&&",
"e.start+=this._dataAlreadySent.length;this._isComposing?t=this._textarea.value.substring(e.start,this._compositionPosition.start):(()=>{const s=this._textarea.value;if(s.length<i.length){let e=0;const r=Math.min(s.length,i.length);for(;e<r&&s.charCodeAt(e)===i.charCodeAt(e);)e++;t=a.C0.DEL.repeat(i.length-e)+s.substring(e)}else t=s.substring(e.start)})(),t.length>0&&",
],
[
[
'_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.length<e.length?this._coreService.triggerDataEvent(`${a.C0.DEL}`,!0):t.length===e.length&&t!==e&&this._coreService.triggerDataEvent(t,!0)}}),0)}',
"_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r<n&&t.charCodeAt(r)===e.charCodeAt(r);)r++;const i=t.length<e.length?a.C0.DEL.repeat(e.length-r)+t.substring(r):t.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}}),0)}",
],
"_handleAnyTextareaChanges(){if(this._pendingTextareaValue!==null)return;this._pendingTextareaValue=this._textarea.value,setTimeout((()=>{const e=this._pendingTextareaValue;this._pendingTextareaValue=null;if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r<n&&t.charCodeAt(r)===e.charCodeAt(r);)r++;const i=t.length<e.length?a.C0.DEL.repeat(e.length-r)+t.substring(r):t.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}}),0)}",
],
],
},
];
for (const { file, replacements } of patches) {
const filePath = path.join(xtermDir, file);
if (!fs.existsSync(filePath)) {
throw new Error(`[patch-xterm-android-ime] Missing ${filePath}`);
}
let source = fs.readFileSync(filePath, "utf8");
let changed = false;
for (const [original, patched] of replacements) {
if (source.includes(patched)) {
continue;
}
const originals = Array.isArray(original) ? original : [original];
const matched = originals.find((candidate) => source.includes(candidate));
if (!matched) {
throw new Error(
`[patch-xterm-android-ime] Expected source not found in ${file}`,
);
}
source = source.replace(matched, patched);
changed = true;
}
if (!changed) {
console.log(`[patch-xterm-android-ime] ${file} already patched`);
continue;
}
fs.writeFileSync(filePath, source);
console.log(`[patch-xterm-android-ime] Patched ${file}`);
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Import forms `termix-plugin build` understands beyond plain modules. Picked
* up through tsconfig.plugin.json.
*/
/** A script copied next to the bundle; the import is its URL. */
declare module "*?url" {
const url: string;
export default url;
}
/** A stylesheet imported for its side effect; the build emits frontend.css. */
declare module "*.css";
+142
View File
@@ -0,0 +1,142 @@
{
"name": "@termix/plugin-sdk",
"version": "1.0.0",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/Termix-SSH/Termix.git",
"directory": "packages/plugin-sdk"
},
"homepage": "https://github.com/Termix-SSH/Termix/tree/main/packages/plugin-sdk#readme",
"type": "module",
"engines": {
"node": ">=22"
},
"bin": {
"termix-plugin": "./cli/index.mjs"
},
"exports": {
"./backend": {
"types": "./dist/backend.d.ts",
"default": "./dist/backend.js"
},
"./db": {
"types": "./dist/db.d.ts",
"default": "./dist/db.js"
},
"./ddl": {
"types": "./dist/ddl.d.ts",
"default": "./dist/ddl.js"
},
"./table-builder": {
"types": "./dist/table-builder.d.ts",
"default": "./dist/table-builder.js"
},
"./frontend": {
"types": "./dist/frontend.d.ts",
"default": "./dist/frontend.js"
},
"./desktop": {
"types": "./dist/desktop.d.ts",
"default": "./dist/desktop.js"
},
"./manifest": {
"types": "./dist/manifest.d.ts",
"default": "./dist/manifest.js"
},
"./capabilities": {
"types": "./dist/capabilities.d.ts",
"default": "./dist/capabilities.js"
},
"./settings": {
"types": "./dist/settings.d.ts",
"default": "./dist/settings.js"
},
"./testing": {
"types": "./dist/testing.d.ts",
"default": "./dist/testing.js"
},
"./testing/setup": {
"types": "./dist/testing/setup.d.ts",
"default": "./dist/testing/setup.js"
},
"./vitest-preset": {
"types": "./dist/vitest-preset.d.ts",
"default": "./dist/vitest-preset.js"
},
"./host-commands": {
"types": "./dist/host-commands.d.ts",
"default": "./dist/host-commands.js"
},
"./ssh-certs": {
"types": "./dist/ssh-certs.d.ts",
"default": "./dist/ssh-certs.js"
},
"./tsconfig.plugin.json": "./tsconfig.plugin.json",
"./schema/manifest.schema.json": "./schema/manifest.schema.json",
"./ui": {
"types": "./dist/host/ui.d.ts",
"default": "./dist/host/ui.js"
},
"./testing-host": {
"default": "./dist/host/testing-host.js"
},
"./theme.css": "./dist/host/theme.css"
},
"files": [
"dist",
"cli",
"schema",
"tsconfig.plugin.json",
"README.md",
"client.d.ts"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"prepublishOnly": "npm run build"
},
"dependencies": {
"@tailwindcss/node": "^4.2.4",
"@tailwindcss/oxide": "^4.2.4",
"esbuild": "^0.28.1",
"semver": "^7.6.3",
"tailwindcss": "^4.2.4",
"tar": "^7.5.22"
},
"peerDependencies": {
"@testing-library/react": "^16.3.3",
"better-sqlite3": "*",
"drizzle-orm": "*",
"i18next": "^26.4.2",
"react": "*",
"react-dom": "^19.2.8",
"react-i18next": "^17.0.14",
"sonner": "^2.0.8",
"ssh2": "*",
"vitest": "*"
},
"peerDependenciesMeta": {
"drizzle-orm": {
"optional": true
},
"ssh2": {
"optional": true
},
"better-sqlite3": {
"optional": true
},
"react": {
"optional": true
},
"vitest": {
"optional": true
},
"@testing-library/react": {
"optional": true
}
},
"publishConfig": {
"access": "public"
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+105
View File
@@ -0,0 +1,105 @@
/**
* What a plugin's code may do.
*
* A capability is declared in the manifest and checked by the SDK method that
* needs it. It is not a user permission: those stay in core RBAC and are dotted
* (hosts.view), while capabilities are colon-separated (hosts:read) so the two
* can never be confused in a grant table or a log line.
*
* Adding one means an entry here plus the SDK method that checks it. The i18n
* keys resolve against core's en.json under plugins.capabilities.<id>.
*
* The copy those keys carry describes the consequence to the person deciding,
* not the mechanism. "Run commands on your servers", not "grants ssh:connect".
*/
export type CapabilityRisk = "low" | "medium" | "high" | "critical";
export interface CapabilityInfo {
id: string;
risk: CapabilityRisk;
/** i18n key for the short consequence, e.g. "Run commands on your servers". */
titleKey: string;
/** i18n key for the qualifying sentence under the title. */
consequenceKey: string;
}
function entry(id: string, risk: CapabilityRisk): CapabilityInfo {
return {
id,
risk,
titleKey: `plugins.capabilities.${id}.title`,
consequenceKey: `plugins.capabilities.${id}.consequence`,
};
}
/**
* Ordered critical -> low. The install and review UI reads this order directly
* so the worst line is never folded into a summary row.
*/
export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("credentials:read", "critical"),
entry("system:tls", "critical"),
entry("ssh:connect", "high"),
entry("process:spawn", "high"),
entry("users:write", "high"),
entry("users:impersonate", "high"),
entry("db:core-refs", "high"),
entry("auth:provide", "high"),
entry("device:serial", "high"),
entry("notify:hub", "high"),
entry("hosts:write", "medium"),
entry("credentials:use", "medium"),
entry("credentials:write", "medium"),
entry("network:outbound", "medium"),
entry("network:serve", "medium"),
entry("network:broadcast", "medium"),
entry("users:read", "medium"),
entry("events:core", "medium"),
entry("notify:send", "medium"),
entry("audit:read", "medium"),
entry("desktop:window", "medium"),
entry("hosts:read", "low"),
entry("db:own", "low"),
entry("kv:own", "low"),
entry("files:own", "low"),
entry("secrets:own", "low"),
entry("settings:read-core", "low"),
entry("plugins:read", "low"),
entry("ui:surface", "low"),
] as const;
export const CAPABILITY_IDS: readonly string[] = CAPABILITY_CATALOG.map(
(capability) => capability.id,
);
/**
* Kept as a string rather than a union of the catalog ids: a capability is
* data the runtime checks at load time, and a union here would force every
* caller holding a manifest value to cast.
*/
export type Capability = string;
const BY_ID = new Map(
CAPABILITY_CATALOG.map((capability) => [capability.id, capability]),
);
export function isKnownCapability(capability: string): boolean {
return BY_ID.has(capability);
}
export function getCapabilityInfo(
capability: string,
): CapabilityInfo | undefined {
return BY_ID.get(capability);
}
export const CAPABILITY_RISK_ORDER: readonly CapabilityRisk[] = [
"critical",
"high",
"medium",
"low",
];
+291
View File
@@ -0,0 +1,291 @@
/**
* Table definitions a plugin owns.
*
* A definition is a plain descriptor, not a live Drizzle table. Core turns it
* into one at runtime (src/backend/plugins/table-builder.ts) and emits the DDL
* for whichever dialect the server is on, which keeps this entry a description
* of the contract rather than an implementation a plugin could reach around.
*
* Why one table object and three DDL emitters, rather than three table
* objects: the sqlite-core definitions already encode correctly for every
* engine at query time. text and integer encode as themselves; a boolean
* writes 1/0, which Postgres and MySQL both accept; real is a plain number.
* What genuinely differs between the engines is DDL. See the header of
* scripts/generate-dialect-schema.cjs, which this mapping is taken from.
*/
export type PluginColumnType =
| "id"
| "text"
| "varchar"
| "integer"
| "bigint"
| "real"
| "boolean"
| "timestamp"
| "json"
| "encryptedText"
| "refUser"
| "refHost";
export interface PluginColumn {
readonly type: PluginColumnType;
/** varchar only. The ceiling MySQL can index. */
readonly length?: number;
readonly notNull?: boolean;
readonly primaryKey?: boolean;
readonly unique?: boolean;
readonly defaultValue?: string | number | boolean | null;
/** timestamp only: default CURRENT_TIMESTAMP. */
readonly defaultNow?: boolean;
}
/** The longest string MySQL will index, and so the width of every key column. */
export const KEY_LENGTH = 255;
interface Builder<T extends PluginColumn = PluginColumn> {
notNull: () => Builder<T>;
primaryKey: () => Builder<T>;
unique: () => Builder<T>;
default: (value: string | number | boolean | null) => Builder<T>;
defaultNow: () => Builder<T>;
readonly column: PluginColumn;
}
function builder(column: PluginColumn): Builder {
const next = (patch: Partial<PluginColumn>) =>
builder({ ...column, ...patch });
return {
column,
notNull: () => next({ notNull: true }),
primaryKey: () => next({ primaryKey: true, notNull: true }),
unique: () => next({ unique: true }),
default: (defaultValue) => next({ defaultValue }),
defaultNow: () => next({ defaultNow: true }),
};
}
/** Auto-incrementing surrogate key: integer on sqlite, serial, auto_increment. */
export const id = () =>
builder({ type: "id", primaryKey: true, notNull: true });
/** Unbounded string. Never indexable: MySQL cannot index TEXT. */
export const text = () => builder({ type: "text" });
/** Bounded string. Indexable on every engine. */
export const varchar = (length: number = KEY_LENGTH) =>
builder({ type: "varchar", length });
export const integer = () => builder({ type: "integer" });
export const bigint = () => builder({ type: "bigint" });
/** Floating point. A plain number on every engine. Never indexable. */
export const real = () => builder({ type: "real" });
export const boolean = () => builder({ type: "boolean" });
/** Stored as text on every engine. See src/backend/database/repositories/sql-timestamp.ts. */
export const timestamp = () => builder({ type: "timestamp" });
/** JSON held as text. Never indexable, for the same reason as text(). */
export const json = () => builder({ type: "json" });
/** Text encrypted with the owning user's key before it is written. */
export const encryptedText = () => builder({ type: "encryptedText" });
/** Foreign key to users.id, cascading on delete. */
export const refUser = () =>
builder({ type: "refUser", length: KEY_LENGTH, notNull: true });
/** Foreign key to ssh_data.id, cascading on delete. */
export const refHost = () => builder({ type: "refHost", notNull: true });
export interface PluginTableIndex {
readonly name: string;
readonly columns: readonly string[];
readonly unique?: boolean;
}
export interface PluginTableOptions {
readonly indexes?: readonly PluginTableIndex[];
readonly uniques?: readonly PluginTableIndex[];
}
export interface PluginTableDefinition {
/** The unprefixed name as the plugin wrote it. */
readonly name: string;
readonly columns: Readonly<Record<string, PluginColumn>>;
readonly indexes: readonly PluginTableIndex[];
/** A legacy core table this definition takes over by rename. See adoptLegacyTable. */
readonly adopts?: string;
}
/** Columns that can never carry an index, because MySQL cannot index them. */
const UNINDEXABLE: ReadonlySet<PluginColumnType> = new Set([
"text",
"json",
"encryptedText",
]);
const NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
type Columns = Record<string, Builder | PluginColumn>;
function unwrap(value: Builder | PluginColumn): PluginColumn {
return "column" in value ? value.column : value;
}
/**
* Declares a table this plugin owns.
*
* The name is unprefixed here; core prefixes it with p_<plugin id>_ when the
* definition is registered, so a plugin cannot name a table into another
* plugin's namespace or into core's.
*/
export function defineTable(
name: string,
columns: Columns,
options: PluginTableOptions = {},
): PluginTableDefinition {
if (!NAME_PATTERN.test(name)) {
throw new Error(
`Table name "${name}" must be lower snake_case and start with a letter`,
);
}
const resolved: Record<string, PluginColumn> = {};
for (const [key, value] of Object.entries(columns)) {
resolved[key] = unwrap(value);
}
if (Object.keys(resolved).length === 0) {
throw new Error(`Table "${name}" declares no columns`);
}
const indexes = [
...(options.uniques ?? []).map((entry) => ({ ...entry, unique: true })),
...(options.indexes ?? []),
];
// Checked here rather than at migration time so the error names the table
// and column while the author is still looking at the definition.
for (const index of indexes) {
if (!NAME_PATTERN.test(index.name)) {
throw new Error(
`Index name "${index.name}" must be lower snake_case and start with a letter`,
);
}
if (index.columns.length === 0) {
throw new Error(`Index "${index.name}" names no columns`);
}
for (const column of index.columns) {
const declared = resolved[column];
if (!declared) {
throw new Error(
`Index "${index.name}" names "${column}", which table "${name}" does not declare`,
);
}
if (UNINDEXABLE.has(declared.type)) {
throw new Error(
`Index "${index.name}" covers "${column}", a ${declared.type} column. ` +
`MySQL cannot index an unbounded column: use varchar(n) instead.`,
);
}
}
}
return { name, columns: resolved, indexes };
}
/** p_<id with - as _>_ - the prefix every table a plugin owns carries. */
export function tablePrefix(pluginId: string): string {
return `p_${pluginId.replace(/-/g, "_")}_`;
}
/** The physical table name for a definition owned by a plugin. */
export function prefixedTableName(pluginId: string, name: string): string {
return `${tablePrefix(pluginId)}${name}`;
}
/**
* Legacy core tables, and the one plugin allowed to adopt each.
*
* Adoption is how a feature keeps its rows when it moves out of core: the
* table is renamed into the plugin's namespace rather than copied. One owner
* per table, because two plugins adopting the same one would mean whichever
* activated second found it already gone.
*
* Shared with the CLI so a migration that reaches for someone else's table is
* caught at build time as well as at activation.
*/
export const LEGACY_TABLE_OWNERS: Readonly<Record<string, string>> = {
fleets: "fleets",
fleet_members: "fleets",
fleet_inventory: "fleets",
user_workspaces: "workspaces",
network_topology: "network-topology",
snippets: "snippets",
snippet_folders: "snippets",
snippet_access: "snippets",
c2s_tunnel_presets: "tunnels",
command_history: "ssh-terminal",
file_manager_recent: "file-manager",
file_manager_pinned: "file-manager",
file_manager_shortcuts: "file-manager",
transfer_recent: "file-manager",
tmux_session_tags: "tmux-monitor",
session_shares: "session-sharing",
session_share_participants: "session-sharing",
collab_rooms: "session-sharing",
collab_room_members: "session-sharing",
session_recordings: "session-recording",
host_metrics_preferences: "host-metrics",
host_health_checks: "host-metrics",
host_health_history: "host-metrics",
host_metrics_history: "host-metrics",
proxmox_stats_preferences: "proxmox",
proxmox_node_history: "proxmox",
automations: "automations",
automation_trigger_state: "automations",
automation_schedules: "automations",
automation_runs: "automations",
automation_run_steps: "automations",
automation_channels: "automations",
ai_providers: "ai",
ai_conversations: "ai",
ai_messages: "ai",
ai_proposals: "ai",
homepage_items: "homepage",
homepage_layouts: "homepage",
dashboard_service_links: "homepage",
secret_sources: "secret-sources",
opkssh_tokens: "opkssh",
vault_profiles: "vault",
vault_tokens: "vault",
termix_identities: "termix-identity",
termix_identity_keys: "termix-identity",
termix_identity_ca: "termix-identity",
webauthn_credentials: "webauthn",
sso_providers: "sso",
dismissed_alerts: "alerts",
};
/**
* Marks a definition as the new home of a legacy core table.
*
* The migration the CLI writes for it renames the legacy table into the
* plugin's namespace, and creates it first when there is none (a fresh
* install), so both paths end with the same table. Keep the legacy column and
* index names in the definition: the rename carries the old indexes across,
* and reusing their names is what stops the migration from creating a second
* copy of each.
*/
export function adoptLegacyTable(
legacyName: string,
definition: PluginTableDefinition,
): PluginTableDefinition {
if (!Object.prototype.hasOwnProperty.call(LEGACY_TABLE_OWNERS, legacyName)) {
throw new Error(
`"${legacyName}" is not a legacy core table a plugin may adopt. See LEGACY_TABLE_OWNERS.`,
);
}
return { ...definition, adopts: legacyName };
}
+763
View File
@@ -0,0 +1,763 @@
/**
* The DDL a table definition becomes, per dialect.
*
* Lives in the SDK because two things need the identical mapping: the CLI,
* which writes a plugin's migration files, and the server, which applies them.
* A second copy would drift and produce a migration the runner could not
* reproduce.
*
* The mapping itself is the one scripts/generate-dialect-schema.cjs applies to
* core's schema, kept deliberately in step with it.
*/
import { KEY_LENGTH, prefixedTableName, tablePrefix } from "./db.js";
import type {
PluginColumn,
PluginTableDefinition,
PluginTableIndex,
} from "./db.js";
/** Which engine the DDL is for. Matches core's DatabaseDialect exactly. */
export type SqlDialect = "sqlite" | "postgres" | "mysql";
/** The core tables a plugin may point a foreign key at, and nothing else. */
export const REFERENCEABLE = {
refUser: { table: "users", column: "id" },
refHost: { table: "ssh_data", column: "id" },
} as const;
function camelToSnake(value: string): string {
return value.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
}
/** The SQL column name for a declared property. */
export function columnName(property: string, column?: PluginColumn): string {
void column;
return camelToSnake(property);
}
function quote(dialect: SqlDialect, identifier: string): string {
if (dialect === "mysql") return "`" + identifier + "`";
return '"' + identifier + '"';
}
/**
* The physical type for a column on one dialect.
*
* Mirrors generate-dialect-schema.cjs: an autoincrement key is serial /
* auto_increment, a boolean is native off sqlite, and anything carrying a key
* has to be varchar because MySQL will not index TEXT.
*/
function columnType(
dialect: SqlDialect,
column: PluginColumn,
keyed: boolean,
): string {
switch (column.type) {
case "id":
if (dialect === "postgres") return "serial";
if (dialect === "mysql") return "int AUTO_INCREMENT";
return "integer";
case "integer":
return dialect === "mysql" ? "int" : "integer";
case "bigint":
return dialect === "sqlite" ? "integer" : "bigint";
case "real":
if (dialect === "sqlite") return "real";
return dialect === "mysql" ? "double" : "double precision";
case "boolean":
return dialect === "sqlite" ? "integer" : "boolean";
case "varchar":
return dialect === "sqlite"
? "text"
: `varchar(${column.length ?? KEY_LENGTH})`;
case "refUser":
return dialect === "sqlite" ? "text" : `varchar(${KEY_LENGTH})`;
case "refHost":
return dialect === "mysql" ? "int" : "integer";
default:
// A keyed string has to be indexable, so it narrows to varchar even
// though it was declared unbounded. defineTable refuses to index a
// text/json column, so in practice this only catches a primary key.
if (keyed && dialect !== "sqlite") return `varchar(${KEY_LENGTH})`;
return "text";
}
}
/** Column types that are TEXT on MySQL when nothing keys them. */
const TEXT_ON_MYSQL: ReadonlySet<PluginColumn["type"]> = new Set([
"text",
"json",
"encryptedText",
"timestamp",
]);
function defaultClause(dialect: SqlDialect, column: PluginColumn): string {
if (column.defaultNow) {
// MySQL rejects a bare DEFAULT CURRENT_TIMESTAMP on a text column; an
// expression default is written parenthesised. MariaDB takes either.
return dialect === "mysql"
? " DEFAULT (CURRENT_TIMESTAMP)"
: " DEFAULT CURRENT_TIMESTAMP";
}
if (column.defaultValue === undefined) return "";
const value = column.defaultValue;
if (value === null) return " DEFAULT NULL";
if (typeof value === "boolean") {
return dialect === "sqlite"
? ` DEFAULT ${value ? 1 : 0}`
: ` DEFAULT ${value ? "true" : "false"}`;
}
if (typeof value === "number") return ` DEFAULT ${value}`;
const literal = `'${String(value).replace(/'/g, "''")}'`;
// MySQL refuses a literal default on a TEXT column but takes an expression.
if (dialect === "mysql" && TEXT_ON_MYSQL.has(column.type)) {
return ` DEFAULT (${literal})`;
}
return ` DEFAULT ${literal}`;
}
/** Every column that carries a key, and so must be indexable on MySQL. */
export function keyedColumns(
definition: PluginTableDefinition,
): ReadonlySet<string> {
const keyed = new Set<string>();
for (const [property, column] of Object.entries(definition.columns)) {
if (column.primaryKey || column.unique) keyed.add(property);
if (column.type === "refUser" || column.type === "refHost") {
keyed.add(property);
}
}
for (const index of definition.indexes) {
for (const property of index.columns) keyed.add(property);
}
return keyed;
}
function indexStatement(
dialect: SqlDialect,
physical: string,
definition: PluginTableDefinition,
entry: PluginTableIndex,
): string {
const columns = entry.columns
.map((property) =>
quote(dialect, columnName(property, definition.columns[property])),
)
.join(", ");
const unique = entry.unique ? "UNIQUE " : "";
// MySQL has no CREATE INDEX IF NOT EXISTS. A plugin migration runs once,
// recorded in plugin_migrations, so the guard is the ledger, not the DDL.
const guard = dialect === "mysql" ? "" : "IF NOT EXISTS ";
return `CREATE ${unique}INDEX ${guard}${quote(dialect, entry.name)} ON ${quote(dialect, physical)} (${columns});`;
}
function createStatements(
dialect: SqlDialect,
physical: string,
definition: PluginTableDefinition,
): { table: string; indexes: string[] } {
const keyed = keyedColumns(definition);
const lines: string[] = [];
const constraints: string[] = [];
for (const [property, column] of Object.entries(definition.columns)) {
const name = columnName(property, column);
const type = columnType(dialect, column, keyed.has(property));
let line = ` ${quote(dialect, name)} ${type}`;
if (column.type === "id") {
line += " PRIMARY KEY";
if (dialect === "sqlite") line += " AUTOINCREMENT";
} else {
if (column.primaryKey) line += " PRIMARY KEY";
if (column.notNull) line += " NOT NULL";
if (column.unique) line += " UNIQUE";
line += defaultClause(dialect, column);
}
lines.push(line);
const reference =
column.type === "refUser"
? REFERENCEABLE.refUser
: column.type === "refHost"
? REFERENCEABLE.refHost
: null;
if (reference) {
constraints.push(
` FOREIGN KEY (${quote(dialect, name)}) REFERENCES ${quote(dialect, reference.table)} (${quote(dialect, reference.column)}) ON DELETE CASCADE`,
);
}
}
const body = [...lines, ...constraints].join(",\n");
return {
table: `CREATE TABLE IF NOT EXISTS ${quote(dialect, physical)} (\n${body}\n);`,
indexes: definition.indexes.map((entry) =>
indexStatement(dialect, physical, definition, entry),
),
};
}
/** The CREATE TABLE and CREATE INDEX statements for one definition. */
export function createTableSql(
dialect: SqlDialect,
pluginId: string,
definition: PluginTableDefinition,
): string[] {
const physical = prefixedTableName(pluginId, definition.name);
const { table, indexes } = createStatements(dialect, physical, definition);
return [table, ...indexes];
}
/**
* Takes over a legacy core table by renaming it into the plugin's namespace.
*
* A plain SQL migration cannot ask whether the legacy table exists, so it
* creates it first when it does not (a fresh install) and then renames either
* way. Both paths end with the same table and the same rows.
*
* Indexes are created on the legacy name with IF NOT EXISTS before the rename,
* which is why an adopted definition keeps the legacy index names: an index
* that already exists is left alone and follows the table. MySQL has no
* CREATE INDEX IF NOT EXISTS, so there none are emitted. That is safe because
* every legacy table reached MySQL through core's drizzle migrations, which
* already created its indexes.
*/
export function adoptTableSql(
dialect: SqlDialect,
pluginId: string,
definition: PluginTableDefinition,
): string[] {
const legacy = definition.adopts;
if (!legacy) {
throw new Error(`Table "${definition.name}" does not adopt a legacy table`);
}
const physical = prefixedTableName(pluginId, definition.name);
const { table, indexes } = createStatements(dialect, legacy, definition);
const rename =
dialect === "mysql"
? `RENAME TABLE ${quote(dialect, legacy)} TO ${quote(dialect, physical)};`
: `ALTER TABLE ${quote(dialect, legacy)} RENAME TO ${quote(dialect, physical)};`;
return [table, ...(dialect === "mysql" ? [] : indexes), rename];
}
/**
* Splits a migration file into statements.
*
* Deliberately simple: statements end at a semicolon that is not inside a
* string literal or a comment. A plugin needing more than that should put the
* logic in its own code, not in DDL.
*/
export function splitStatements(source: string): string[] {
const statements: string[] = [];
let current = "";
let quoteChar: string | null = null;
let lineComment = false;
let blockComment = false;
for (let i = 0; i < source.length; i++) {
const char = source[i];
const next = source[i + 1];
if (lineComment) {
current += char;
if (char === "\n") lineComment = false;
continue;
}
if (blockComment) {
current += char;
if (char === "*" && next === "/") {
current += next;
i++;
blockComment = false;
}
continue;
}
if (quoteChar) {
current += char;
// '' inside a quoted string is an escaped quote, not a terminator.
if (char === quoteChar) {
if (next === quoteChar) {
current += next;
i++;
} else {
quoteChar = null;
}
}
continue;
}
if (char === "-" && next === "-") {
lineComment = true;
current += char;
continue;
}
if (char === "/" && next === "*") {
blockComment = true;
current += char;
continue;
}
if (char === "'" || char === '"' || char === "`") {
quoteChar = char;
current += char;
continue;
}
if (char === ";") {
if (current.trim()) statements.push(current.trim());
current = "";
continue;
}
current += char;
}
if (current.trim()) statements.push(current.trim());
return statements;
}
/** DROP statement for a definition, for removePluginData. */
export function dropTableSql(
dialect: SqlDialect,
pluginId: string,
definition: PluginTableDefinition,
): string {
const physical = prefixedTableName(pluginId, definition.name);
return `DROP TABLE IF EXISTS ${quote(dialect, physical)};`;
}
/** The ALTER that adds one newly declared column. */
export function addColumnSql(
dialect: SqlDialect,
pluginId: string,
definition: PluginTableDefinition,
property: string,
): string {
const physical = prefixedTableName(pluginId, definition.name);
const column = definition.columns[property];
const keyed = keyedColumns(definition).has(property);
const name = columnName(property, column);
let line = `${quote(dialect, name)} ${columnType(dialect, column, keyed)}`;
if (column.notNull) line += " NOT NULL";
line += defaultClause(dialect, column);
return `ALTER TABLE ${quote(dialect, physical)} ADD COLUMN ${line};`;
}
interface SqlToken {
/** As written: a bare word keeps its case, a quoted name loses its quotes. */
value: string;
kind: "word" | "quoted" | "punct";
}
/**
* Words and identifiers in one statement, with comments and string literals
* dropped. `"x"`, `` `x` `` and `[x]` all come out as the identifier x, so no
* quoting style hides a table name from the ownership check.
*/
function tokenize(source: string): SqlToken[] {
const tokens: SqlToken[] = [];
let i = 0;
while (i < source.length) {
const char = source[i];
const next = source[i + 1];
if (/\s/.test(char)) {
i++;
} else if (char === "-" && next === "-") {
while (i < source.length && source[i] !== "\n") i++;
} else if (char === "/" && next === "*") {
const end = source.indexOf("*/", i + 2);
i = end < 0 ? source.length : end + 2;
} else if (char === "'") {
i++;
while (i < source.length) {
if (source[i] === "'" && source[i + 1] === "'") i += 2;
else if (source[i] === "'") break;
else i++;
}
i++;
tokens.push({ value: "'", kind: "punct" });
} else if (char === '"' || char === "`" || char === "[") {
const close = char === "[" ? "]" : char;
let value = "";
i++;
while (i < source.length) {
if (source[i] === close && source[i + 1] === close && close !== "]") {
value += close;
i += 2;
} else if (source[i] === close) {
break;
} else {
value += source[i++];
}
}
i++;
tokens.push({ value, kind: "quoted" });
} else if (/[A-Za-z0-9_$]/.test(char)) {
let value = "";
while (i < source.length && /[A-Za-z0-9_$]/.test(source[i])) {
value += source[i++];
}
tokens.push({ value, kind: "word" });
} else {
tokens.push({ value: char, kind: "punct" });
i++;
}
}
return tokens;
}
/** Statements a plugin migration may never contain. */
const REFUSED_STATEMENTS = new Set([
"ATTACH",
"DETACH",
"PRAGMA",
"GRANT",
"REVOKE",
"COPY",
"LOAD",
"DO",
"CALL",
"HANDLER",
"WITH",
"SET",
"VACUUM",
]);
/** What a migration may CREATE, ALTER or DROP. Anything else is refused. */
const TABLE_OBJECTS = new Set(["TABLE", "VIEW"]);
/** Allowed, but only under the plugin's own prefix. */
const PREFIXED_OBJECTS = new Set(["SEQUENCE", "TYPE"]);
/**
* Functions that read files, change server settings or run a string as SQL.
* A SELECT calling one of them could do anything, so it is refused.
*/
const REFUSED_FUNCTIONS = new Set([
"SET_CONFIG",
"PG_READ_FILE",
"PG_READ_BINARY_FILE",
"PG_LS_DIR",
"PG_STAT_FILE",
"PG_FILE_WRITE",
"LO_IMPORT",
"LO_EXPORT",
"LO_FROM_BYTEA",
"DBLINK",
"DBLINK_EXEC",
"QUERY_TO_XML",
"QUERY_TO_XML_AND_XMLSCHEMA",
"PG_TERMINATE_BACKEND",
"PG_CANCEL_BACKEND",
"PG_RELOAD_CONF",
"LOAD_FILE",
"LOAD_EXTENSION",
"SYS_EXEC",
"SYS_EVAL",
]);
const CREATE_MODIFIERS = [
"OR",
"REPLACE",
"TEMP",
"TEMPORARY",
"UNLOGGED",
"GLOBAL",
"LOCAL",
"VIRTUAL",
"UNIQUE",
"CONCURRENTLY",
];
/**
* Every problem with the tables a migration writes to.
*
* The check has to hold against a plugin that is trying, not only one that
* made a typo, so it reads each statement the way the engine does rather
* than matching one pattern: a comment between a keyword and a name, bracket
* quoting, a second name in a DROP list, a schema-qualified name and
* `ALTER ... RENAME TO users` are all seen. Reads (FROM, JOIN, REFERENCES)
* are allowed; writes outside the plugin's prefix are not.
*
* `ownedLegacy` lists the legacy core tables this plugin adopts.
*/
export function findUnownedTableWrites(
pluginId: string,
sql: string,
ownedLegacy: ReadonlySet<string> = new Set(),
ownedIndexes: ReadonlySet<string> = new Set(),
): string[] {
const prefix = tablePrefix(pluginId);
const problems: string[] = [];
const indexes = new Set([
...ownedIndexes,
...collectOwnedIndexes(pluginId, [sql], ownedLegacy),
]);
// The splitter and tokenizer follow standard quoting only. MySQL backslash
// escapes, E'' strings and Postgres dollar quoting would let a string end
// somewhere else for the engine than for this check, so none are allowed.
if (sql.includes("\\")) {
problems.push("backslashes are not allowed in a plugin migration");
}
if (/\$[A-Za-z_0-9]*\$/.test(sql)) {
problems.push(
"dollar-quoted strings are not allowed in a plugin migration",
);
}
if (problems.length > 0) return problems;
for (const statement of splitStatements(sql)) {
const tokens = tokenize(statement);
if (tokens.length === 0) continue;
const upper = (at: number) =>
tokens[at]?.kind === "word" ? tokens[at].value.toUpperCase() : "";
/** Checks the table name at `at`, returns the index after it. */
const table = (at: number): number => {
const name = tokens[at];
if (!name || name.kind === "punct") {
problems.push("a statement writes to a table with no name");
return at + 1;
}
if (tokens[at + 1]?.value === ".") {
problems.push(
`"${name.value}.${tokens[at + 2]?.value ?? ""}" names a schema; a plugin may only write its own tables`,
);
return at + 3;
}
if (!name.value.startsWith(prefix) && !ownedLegacy.has(name.value)) {
problems.push(
`writes to "${name.value}", which is not prefixed "${prefix}"`,
);
}
return at + 1;
};
const skip = (at: number, words: string[]) => {
while (words.includes(upper(at))) at++;
return at;
};
const find = (word: string, from = 0) => {
for (let at = from; at < tokens.length; at++) {
if (upper(at) === word) return at;
}
return -1;
};
const first = upper(0);
if (REFUSED_STATEMENTS.has(first)) {
problems.push(`${first} is not allowed in a plugin migration`);
continue;
}
if (first !== "CREATE" && find("CASCADE") > 0) {
problems.push("CASCADE is not allowed in a plugin migration");
continue;
}
if (first === "CREATE" || first === "ALTER" || first === "DROP") {
let at = skip(1, CREATE_MODIFIERS);
const object = upper(at);
if (object === "INDEX") {
// The index name is free; the table after ON is what it writes to.
const on = find("ON", at);
if (on > 0) {
table(skip(on + 1, ["ONLY"]));
} else if (first === "CREATE") {
problems.push("CREATE INDEX without ON is not allowed");
} else {
// DROP INDEX name: only an index this plugin created.
const nameAt = skip(at + 1, ["IF", "EXISTS", "CONCURRENTLY"]);
const name = tokens[nameAt];
if (
!name ||
name.kind === "punct" ||
tokens[nameAt + 1]?.value === "." ||
(!name.value.startsWith(prefix) && !indexes.has(name.value))
) {
problems.push(
`${first} INDEX "${name?.value ?? ""}" is not an index this plugin created`,
);
}
}
continue;
}
if (PREFIXED_OBJECTS.has(object)) {
const name = tokens[skip(at + 1, ["IF", "NOT", "EXISTS"])];
if (!name || !name.value.startsWith(prefix)) {
problems.push(
`${first} ${object} "${name?.value ?? ""}" is not prefixed "${prefix}"`,
);
}
continue;
}
if (!TABLE_OBJECTS.has(object)) {
problems.push(
`${first} ${object || tokens[at]?.value || ""} is not allowed in a plugin migration`.trim(),
);
continue;
}
at = table(skip(at + 1, ["IF", "NOT", "EXISTS", "ONLY"]));
// DROP TABLE a, b drops both.
while (first === "DROP" && tokens[at]?.value === ",") {
at = table(at + 1);
}
// ALTER TABLE p_x RENAME TO users moves a table out of the namespace.
// RENAME [COLUMN] a TO b renames a column and is fine.
for (let rename = find("RENAME", at); rename > 0;) {
const next = upper(rename + 1);
if (next === "TO" || next === "AS") table(rename + 2);
rename = find("RENAME", rename + 1);
}
continue;
}
if (first === "RENAME") {
// RENAME TABLE a TO b, c TO d
let at = skip(1, ["TABLE"]);
while (at < tokens.length) {
at = table(at);
if (upper(at) !== "TO") break;
at = table(at + 1);
if (tokens[at]?.value !== ",") break;
at++;
}
continue;
}
if (first === "TRUNCATE") {
let at = table(skip(1, ["TABLE", "ONLY"]));
while (tokens[at]?.value === ",") at = table(at + 1);
continue;
}
if (first === "INSERT" || first === "REPLACE") {
const into = find("INTO");
if (into > 0) table(into + 1);
else problems.push(`${first} without INTO is not allowed`);
continue;
}
if (first === "UPDATE") {
// MySQL writes every table before SET: UPDATE a JOIN b ... SET b.x = 1.
const set = find("SET");
const end = set > 0 ? set : tokens.length;
for (let at = 1; at < end; at++) {
if (upper(at) === "JOIN" || tokens[at].value === ",") {
table(at + 1);
}
}
table(
skip(1, [
"OR",
"ROLLBACK",
"ABORT",
"REPLACE",
"FAIL",
"IGNORE",
"ONLY",
"LOW_PRIORITY",
]),
);
continue;
}
if (first === "DELETE") {
const from = find("FROM");
if (from < 0) {
problems.push("DELETE without FROM is not allowed");
continue;
}
// MySQL: DELETE a, b FROM ... deletes from a and b.
let at = skip(1, ["LOW_PRIORITY", "QUICK", "IGNORE"]);
if (at < from) {
while (at < from) {
at = table(at);
if (tokens[at]?.value !== ",") break;
at++;
}
continue;
}
at = table(skip(from + 1, ["ONLY"]));
// DELETE FROM a, b USING ... deletes from both.
while (tokens[at]?.value === ",") at = table(at + 1);
continue;
}
if (first === "SELECT") {
// SELECT ... INTO creates a table on Postgres and writes a file on
// MySQL.
if (find("INTO") > 0) {
problems.push("SELECT INTO is not allowed in a plugin migration");
continue;
}
for (let at = 0; at < tokens.length; at++) {
if (REFUSED_FUNCTIONS.has(upper(at)) && tokens[at + 1]?.value === "(") {
problems.push(
`${tokens[at].value}() is not allowed in a plugin migration`,
);
}
}
// setval can break a core table's ids, so only the plugin's own.
for (const match of statement.matchAll(/setval\s*\(\s*([^,]*)/gi)) {
const target = /'([^']*)'/.exec(match[1])?.[1];
if (!target || !target.startsWith(prefix)) {
problems.push("setval() may only target this plugin's own sequences");
}
}
continue;
}
if (first !== "COMMENT") {
problems.push(
`"${tokens[0].value}" statements are not allowed in a plugin migration`,
);
}
}
return problems;
}
/**
* Names of the indexes a plugin's migrations create on its own tables, so a
* later DROP INDEX with no ON clause can be checked against them.
*/
export function collectOwnedIndexes(
pluginId: string,
sqls: readonly string[],
ownedLegacy: ReadonlySet<string> = new Set(),
): Set<string> {
const prefix = tablePrefix(pluginId);
const owned = new Set<string>();
for (const sql of sqls) {
for (const statement of splitStatements(sql)) {
const tokens = tokenize(statement);
const upper = (at: number) =>
tokens[at]?.kind === "word" ? tokens[at].value.toUpperCase() : "";
if (upper(0) !== "CREATE") continue;
let at = 1;
while (CREATE_MODIFIERS.includes(upper(at))) at++;
if (upper(at) !== "INDEX") continue;
at++;
while (["IF", "NOT", "EXISTS"].includes(upper(at))) at++;
const name = tokens[at];
let on = at;
while (on < tokens.length && upper(on) !== "ON") on++;
if (upper(on + 1) === "ONLY") on++;
const target = tokens[on + 1];
if (
name &&
name.kind !== "punct" &&
target &&
tokens[on + 2]?.value !== "." &&
(target.value.startsWith(prefix) || ownedLegacy.has(target.value))
) {
owned.add(name.value);
}
}
}
return owned;
}
+405
View File
@@ -0,0 +1,405 @@
/**
* The desktop app's bridge (window.electronAPI), typed for plugins. It exists
* only inside the Termix desktop app; check isElectron before calling it.
*/
import type { PluginSshPromptRequest } from "./backend.js";
export interface ServerConfig {
serverUrl?: string;
allowInvalidCertificate?: boolean;
[key: string]: unknown;
}
export interface ConnectionTestResult {
success: boolean;
error?: string;
[key: string]: unknown;
}
export interface DialogOptions {
title?: string;
defaultPath?: string;
buttonLabel?: string;
filters?: Array<{ name: string; extensions: string[] }>;
properties?: string[];
[key: string]: unknown;
}
export interface DialogResult {
canceled: boolean;
filePath?: string;
filePaths?: string[];
[key: string]: unknown;
}
export interface LocalDirectoryResult {
success: boolean;
path: string;
parent?: string;
entries: LocalFileEntry[];
error?: string;
}
export interface LocalCollectedFile {
path: string;
name: string;
relativePath: string;
size: number;
created?: string;
modified: string;
}
export type LocalPathMutationResult =
| ({ success: true } & Partial<LocalFileEntry>)
| { success: false; error?: string };
export interface ElectronAPI {
getAppVersion: () => Promise<string>;
setZoomFactor?: (factor: number) => void;
getPlatform: () => Promise<string>;
getSetting?: (key: string) => Promise<string | null | undefined>;
setSetting?: (key: string, value: string) => Promise<void>;
getServerConfig: () => Promise<ServerConfig>;
saveServerConfig: (config: ServerConfig) => Promise<{ success: boolean }>;
testServerConnection: (
serverUrl: string,
allowInvalidCertificate?: boolean,
) => Promise<ConnectionTestResult>;
answerC2SAuth: (id: string, answer: string | null) => Promise<boolean>;
onC2SAuthPrompt: (
callback: (prompt: {
id: string;
closed?: boolean;
tunnelName?: string;
request?: PluginSshPromptRequest;
}) => void,
) => () => void;
getC2STunnelConfig: () => Promise<unknown[]>;
saveC2STunnelConfig: (
config: unknown[],
) => Promise<{ success: boolean; error?: string }>;
checkLocalPortAvailable: (
host: string,
port: number,
) => Promise<{ available: boolean; error?: string }>;
getC2STunnelPresetDefaultName: () => Promise<string>;
startC2STunnel: (
tunnel: unknown,
index: number,
authToken?: string,
) => Promise<{
success: boolean;
tunnelName?: string;
error?: string;
code?: string;
}>;
testC2STunnel: (
tunnel: unknown,
index: number,
authToken?: string,
) => Promise<{
success: boolean;
message?: string;
error?: string;
code?: string;
}>;
stopC2STunnel: (
tunnelName: string,
) => Promise<{ success: boolean; error?: string }>;
getC2STunnelStatuses: () => Promise<Record<string, unknown>>;
onC2STunnelStatuses?: (
callback: (statuses: Record<string, unknown>) => void,
) => () => void;
startC2SAutoStartTunnels: () => Promise<{
success: boolean;
started: number;
errors: string[];
}>;
/** Where the linked server relays client tunnel streams, a /plugin-ws/ path. */
setC2SRelayPath?: (
relayPath: string,
) => Promise<{ success: boolean; error?: string }>;
onCloseActiveTab?: (callback: () => void) => () => void;
clearSessionCookies: () => Promise<void>;
getSessionCookie: (
name: string,
targetUrl?: string,
) => Promise<string | null>;
waitForSessionCookie: (
name: string,
targetUrl?: string,
previousValue?: string | null,
timeoutMs?: number,
) => Promise<{ success: boolean; value?: string; error?: string }>;
showSaveDialog: (options: DialogOptions) => Promise<DialogResult>;
showOpenDialog: (options: DialogOptions) => Promise<DialogResult>;
getLocalHomeDirectory?: () => Promise<string>;
listLocalDirectory?: (path?: string) => Promise<LocalDirectoryResult>;
statLocalPaths?: (
paths: string[],
) => Promise<
Array<
| (LocalFileEntry & { success: true })
| { success: false; path: string; error?: string }
>
>;
collectLocalFiles?: (paths: string[]) => Promise<{
success: boolean;
files: LocalCollectedFile[];
truncated?: boolean;
error?: string;
}>;
createLocalFolder?: (
parentPath: string,
folderName: string,
) => Promise<LocalPathMutationResult>;
renameLocalPath?: (
path: string,
newName: string,
) => Promise<LocalPathMutationResult>;
trashLocalPath?: (path: string) => Promise<LocalPathMutationResult>;
chmodLocalPath?: (
path: string,
permissions: string,
) => Promise<LocalPathMutationResult>;
openExternalEditor: (fileData: {
fileName: string;
content: string;
encoding?: "utf8" | "base64";
editorPath?: string | null;
}) => Promise<{
success: boolean;
editId?: string;
path?: string;
error?: string;
}>;
closeExternalEditor: (editId: string) => Promise<{
success: boolean;
error?: string;
}>;
onExternalEditorSaved?: (
callback: (payload: {
editId: string;
content: string;
encoding: "utf8";
path: string;
}) => void,
) => () => void;
onUpdateAvailable: (callback: () => void) => void;
onUpdateDownloaded: (callback: () => void) => void;
removeAllListeners: (channel: string) => void;
isElectron: boolean;
isDev: boolean;
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>;
createTempFile: (fileData: {
fileName: string;
content: string;
encoding?: "base64" | "utf8";
}) => Promise<{
success: boolean;
tempId?: string;
path?: string;
error?: string;
}>;
createTempFolder: (folderData: {
folderName: string;
files: Array<{
relativePath: string;
content: string;
encoding?: "base64" | "utf8";
}>;
}) => Promise<{
success: boolean;
tempId?: string;
path?: string;
error?: string;
}>;
startDragToDesktop: (dragData: {
tempId: string;
fileName: string;
}) => Promise<{
success: boolean;
error?: string;
}>;
cleanupTempFile: (tempId: string) => Promise<{
success: boolean;
error?: string;
}>;
startLocalTerminal(dimensions: {
cols: number;
rows: number;
shell?: "default" | "wsl";
}): Promise<{ sessionId: string; shell: string }>;
readyLocalTerminal(sessionId: string): Promise<boolean>;
writeLocalTerminal(sessionId: string, data: string): Promise<boolean>;
resizeLocalTerminal(
sessionId: string,
cols: number,
rows: number,
): Promise<boolean>;
closeLocalTerminal(sessionId: string): Promise<boolean>;
onLocalTerminalData(
sessionId: string,
callback: (data: string) => void,
): () => void;
onLocalTerminalExit(
sessionId: string,
callback: (exitCode: number) => void,
): () => void;
/** Local disk browsing for the dual-pane file manager (desktop only). */
localFs?: {
home: () => Promise<LocalFsResult<LocalFsHomeInfo>>;
list: (dirPath: string) => Promise<LocalFsResult<LocalDirectoryListing>>;
mkdir: (
parentPath: string,
name: string,
) => Promise<LocalFsResult<{ path: string }>>;
createFile: (
parentPath: string,
name: string,
) => Promise<LocalFsResult<{ path: string }>>;
rename: (
oldPath: string,
newName: string,
) => Promise<LocalFsResult<{ path: string }>>;
trash: (paths: string[]) => Promise<LocalFsResult<LocalTrashResult>>;
ensureDir: (
dirPath: string,
rootPath?: string,
) => Promise<LocalFsResult<{ path: string }>>;
exists: (paths: string[]) => Promise<LocalFsResult<{ existing: string[] }>>;
walk: (paths: string[]) => Promise<LocalFsResult<LocalWalkResult>>;
reveal: (targetPath: string) => Promise<LocalFsResult<unknown>>;
open: (targetPath: string) => Promise<LocalFsResult<unknown>>;
};
/** Streamed local<->remote transfers driven by the main process. */
localTransfer?: {
/** The /plugin-api/<id> path that serves the streaming routes. */
setApiPath?: (apiPath: string) => Promise<LocalFsResult<object>>;
upload: (
options: LocalUploadRequest,
) => Promise<LocalFsResult<{ bytes: number }>>;
download: (
options: LocalDownloadRequest,
) => Promise<LocalFsResult<{ path: string }>>;
cancel: (
transferId: string,
) => Promise<LocalFsResult<{ cancelled: boolean }>>;
onProgress: (
callback: (payload: LocalTransferProgress) => void,
) => () => void;
};
}
export type LocalFsResult<T> =
({ success: true } & T) | { success: false; error: string; code?: string };
export interface LocalFsHomeInfo {
home: string;
separator: string;
platform: string;
}
export interface LocalFileEntry {
name: string;
path: string;
type: "file" | "directory" | "link" | "other";
created?: string;
modified?: string;
permissions?: string;
owner?: string;
group?: string;
size: number;
modifiedTimestamp?: number;
linkTarget?: string;
hidden: boolean;
}
export interface LocalDirectoryListing {
path: string;
parent: string | null;
entries: LocalFileEntry[];
}
export interface LocalTrashResult {
trashed: number;
failed: Array<{ path: string; error: string }>;
}
export interface LocalWalkFile {
localPath: string;
/** Path relative to the drop root; "/"-separated and includes the root's own name. */
relativePath: string;
size: number;
}
export interface LocalWalkResult {
files: LocalWalkFile[];
emptyDirs: string[];
totalBytes: number;
}
/**
* Which Termix backend a transfer talks to. The main process resolves the
* actual URL and credentials for the origin; the renderer never supplies them.
*/
export type LocalTransferOrigin = "local" | "remote";
export interface LocalUploadRequest {
transferId: string;
origin: LocalTransferOrigin;
fields: Record<string, string>;
localPath: string;
fileName: string;
deviceId?: string;
/** The renderer's token for the embedded backend (origin "local"); sent as a Bearer header. */
authToken?: string;
}
export interface LocalDownloadRequest {
transferId: string;
origin: LocalTransferOrigin;
body: Record<string, unknown>;
destPath: string;
/** Selected download folder; destPath must resolve strictly inside it (code EINVAL otherwise). */
rootPath: string;
expectedSize?: number;
/** Replace an existing file at destPath; otherwise the transfer is refused with code EEXIST. */
overwrite?: boolean;
deviceId?: string;
/** The renderer's token for the embedded backend (origin "local"); sent as a Bearer header. */
authToken?: string;
}
export interface LocalTransferProgress {
transferId: string;
transferred: number;
total?: number;
}
declare global {
interface Window {
electronAPI: ElectronAPI;
IS_ELECTRON: boolean;
electronClipboard?: {
writeText(text: string): Promise<boolean>;
readText(): Promise<string>;
};
}
}
File diff suppressed because it is too large. Load diff
+498
View File
@@ -0,0 +1,498 @@
/**
* Generic host command helpers: platform detection, package manager actions
* and the shell-argument validators that guard them.
*
* Pure functions and thin ssh2 wrappers, no core imports. fleets and
* host-metrics both build their platform/package features on this instead of
* duplicating it or reaching into core.
*
* A plugin passes the `Client` it got from ctx.ssh.connect/withConnection.
*/
export type { Client, ClientChannel } from "ssh2";
import type { Client, ClientChannel } from "ssh2";
export interface ExecResult {
stdout: string;
stderr: string;
code: number | null;
}
/** Runs one non-interactive command over an existing ssh2 client. */
export function execCommand(
client: Client,
command: string,
timeoutMs = 30000,
): Promise<ExecResult> {
return new Promise((resolve, reject) => {
let settled = false;
let stream: ClientChannel | null = null;
const timeout = setTimeout(() => {
if (!settled) {
settled = true;
cleanup();
reject(new Error(`Command timeout after ${timeoutMs}ms: ${command}`));
}
}, timeoutMs);
const cleanup = () => {
clearTimeout(timeout);
if (stream) {
try {
stream.removeAllListeners();
if (stream.stderr) {
stream.stderr.removeAllListeners();
}
stream.destroy();
} catch {
// expected - cleanup errors ignored
}
}
};
client.exec(command, { pty: false }, (err, _stream) => {
if (err) {
if (!settled) {
settled = true;
cleanup();
reject(err);
}
return;
}
stream = _stream;
let stdout = "";
let stderr = "";
let exitCode: number | null = null;
stream
.on("close", (code: number | undefined) => {
if (!settled) {
settled = true;
exitCode = typeof code === "number" ? code : null;
cleanup();
resolve({ stdout, stderr, code: exitCode });
}
})
.on("data", (data: Buffer) => {
stdout += data.toString("utf8");
})
.on("error", (streamErr: Error) => {
if (!settled) {
settled = true;
cleanup();
reject(streamErr);
}
});
if (stream.stderr) {
stream.stderr
.on("data", (data: Buffer) => {
stderr += data.toString("utf8");
})
.on("error", (stderrErr: Error) => {
if (!settled) {
settled = true;
cleanup();
reject(stderrErr);
}
});
}
});
});
}
export type PackageManager = "apt" | "dnf" | "yum" | "pacman" | null;
export interface PlatformInfo {
hasSystemd: boolean;
pkg: PackageManager;
hasCertbot: boolean;
hasAcmeSh: boolean;
hasDocker: boolean;
osPrettyName: string | null;
}
/**
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
* so PATH additions from installers like Homebrew or OrbStack are missing.
* Extend PATH with their common install locations before probing.
*/
export const EXTRA_PATH_DIRS =
'/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:"$HOME/.orbstack/bin"';
const DOCKER_PROBE =
`command -v docker >/dev/null 2>&1` +
` || [ -S /var/run/docker.sock ]` +
` || [ -S "$HOME/.orbstack/run/docker.sock" ]`;
/**
* Single probe that reports which tooling is available. Each line is
* "key=value" so the parser is trivial and order-independent.
*/
export const PLATFORM_PROBE_COMMAND = [
`PATH="${EXTRA_PATH_DIRS}:$PATH"`,
"echo systemd=$(command -v systemctl >/dev/null 2>&1 && echo 1 || echo 0)",
"echo apt=$(command -v apt-get >/dev/null 2>&1 && echo 1 || echo 0)",
"echo dnf=$(command -v dnf >/dev/null 2>&1 && echo 1 || echo 0)",
"echo yum=$(command -v yum >/dev/null 2>&1 && echo 1 || echo 0)",
"echo pacman=$(command -v pacman >/dev/null 2>&1 && echo 1 || echo 0)",
"echo certbot=$(command -v certbot >/dev/null 2>&1 && echo 1 || echo 0)",
'echo acmesh=$( { command -v acme.sh >/dev/null 2>&1 || [ -x "$HOME/.acme.sh/acme.sh" ]; } && echo 1 || echo 0)',
`echo docker=$(${DOCKER_PROBE} && echo 1 || echo 0)`,
'echo os=$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME")',
].join("; ");
export function parsePlatformProbe(output: string): PlatformInfo {
const map = new Map<string, string>();
for (const line of output.split("\n")) {
const idx = line.indexOf("=");
if (idx === -1) continue;
map.set(line.slice(0, idx).trim(), line.slice(idx + 1).trim());
}
const on = (k: string) => map.get(k) === "1";
// Prefer dnf over yum when both exist (dnf is the modern front-end).
let pkg: PackageManager = null;
if (on("apt")) pkg = "apt";
else if (on("dnf")) pkg = "dnf";
else if (on("yum")) pkg = "yum";
else if (on("pacman")) pkg = "pacman";
const os = map.get("os");
return {
hasSystemd: on("systemd"),
pkg,
hasCertbot: on("certbot"),
hasAcmeSh: on("acmesh"),
hasDocker: on("docker"),
osPrettyName: os && os.length > 0 ? os : null,
};
}
export async function detectPlatform(client: Client): Promise<PlatformInfo> {
const { stdout } = await execCommand(client, PLATFORM_PROBE_COMMAND, 15000);
return parsePlatformProbe(stdout);
}
export type HostPlatform = "darwin" | "linux" | "windows" | "other";
// Windows OpenSSH's default shell is usually cmd.exe (occasionally
// PowerShell), so scripts are sent base64-encoded via -EncodedCommand -
// that avoids every cmd.exe quoting/escaping pitfall entirely, since the
// argument ends up being plain alphanumeric text with no shell metacharacters.
export function encodePowerShellCommand(script: string): string {
return Buffer.from(script, "utf16le").toString("base64");
}
export function execPowerShell(
client: Client,
script: string,
timeoutMs = 30000,
): Promise<ExecResult> {
const encoded = encodePowerShellCommand(script);
return execCommand(
client,
`powershell -NoProfile -NonInteractive -EncodedCommand ${encoded}`,
timeoutMs,
);
}
export async function detectHostPlatform(
client: Client,
): Promise<HostPlatform> {
try {
const { stdout, code } = await execCommand(client, "uname -s", 10000);
const kernel = stdout.trim().toLowerCase();
if (kernel === "darwin") return "darwin";
if (kernel === "linux") return "linux";
if (code === 0 && kernel) return "other";
} catch {
// expected on hosts with no POSIX shell (e.g. Windows via cmd.exe)
}
try {
const { stdout } = await execPowerShell(client, "'WIN_OK'", 10000);
if (stdout.includes("WIN_OK")) return "windows";
} catch {
// expected on hosts with no PowerShell available
}
return "other";
}
export interface UpgradablePackage {
name: string;
currentVersion?: string;
newVersion?: string;
}
export function buildListUpgradableCommand(pkg: PackageManager): string | null {
switch (pkg) {
case "apt":
return "apt list --upgradable 2>/dev/null | tail -n +2";
case "dnf":
return "dnf -q check-update 2>/dev/null || true";
case "yum":
return "yum -q check-update 2>/dev/null || true";
case "pacman":
return "pacman -Qu 2>/dev/null || true";
default:
return null;
}
}
export function parseUpgradable(
pkg: PackageManager,
output: string,
): UpgradablePackage[] {
const out: UpgradablePackage[] = [];
const lines = output
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
if (pkg === "apt") {
for (const line of lines) {
// name/suite newver arch [upgradable from: oldver]
const m = line.match(
/^([^/\s]+)\/\S+\s+(\S+)\s+\S+(?:\s+\[upgradable from:\s+(\S+)\])?/,
);
if (m) out.push({ name: m[1], newVersion: m[2], currentVersion: m[3] });
}
} else if (pkg === "dnf" || pkg === "yum") {
for (const line of lines) {
if (/^(Last metadata|Obsoleting|Security:)/i.test(line)) continue;
const m = line.match(/^(\S+)\s+(\S+)\s+\S+$/);
if (m && m[1].includes(".")) out.push({ name: m[1], newVersion: m[2] });
}
} else if (pkg === "pacman") {
for (const line of lines) {
const m = line.match(/^(\S+)\s+(\S+)\s+->\s+(\S+)$/);
if (m) out.push({ name: m[1], currentVersion: m[2], newVersion: m[3] });
}
}
return out;
}
export type PackageAction = "upgrade-all" | "install" | "upgrade";
export function buildPackageActionCommand(
pkg: PackageManager,
action: PackageAction,
name?: string,
): string | null {
const target = name ? ` ${name}` : "";
switch (pkg) {
case "apt":
if (action === "upgrade-all")
return "DEBIAN_FRONTEND=noninteractive apt-get -y upgrade";
return `DEBIAN_FRONTEND=noninteractive apt-get -y install${target}`;
case "dnf":
return action === "upgrade-all"
? "dnf -y upgrade"
: `dnf -y install${target}`;
case "yum":
return action === "upgrade-all"
? "yum -y update"
: `yum -y install${target}`;
case "pacman":
return action === "upgrade-all"
? "pacman -Syu --noconfirm"
: `pacman -S --noconfirm${target}`;
default:
return null;
}
}
export function buildPackageRemoveCommand(
pkg: PackageManager,
name: string,
): string | null {
switch (pkg) {
case "apt":
return `DEBIAN_FRONTEND=noninteractive apt-get -y remove ${name}`;
case "dnf":
return `dnf -y remove ${name}`;
case "yum":
return `yum -y remove ${name}`;
case "pacman":
return `pacman -R --noconfirm ${name}`;
default:
return null;
}
}
const PACKAGE_RE = /^[A-Za-z0-9][A-Za-z0-9.+_-]*$/;
export function isValidPackageName(pkg: unknown): pkg is string {
return typeof pkg === "string" && pkg.length <= 128 && PACKAGE_RE.test(pkg);
}
export type ElevationErrorCode = "SUDO_REQUIRED" | "SUDO_FAILED" | "NOT_SUDOER";
export class ElevationError extends Error {
code: ElevationErrorCode;
constructor(code: ElevationErrorCode, message: string) {
super(message);
this.name = "ElevationError";
this.code = code;
}
}
export interface ElevatedResult {
stdout: string;
stderr: string;
code: number | null;
usedSudo: boolean;
}
const PERMISSION_DENIED = [
"permission denied",
"operation not permitted",
"must be run as root",
"must be superuser",
"you need to be root",
"are you root",
"access denied",
];
/**
* Phrases sudo itself prints to STDERR when authentication or authorization
* fails. Matched only against sudo's own stderr (never command output), so a
* command that happens to print "permission denied" on stdout is not mistaken
* for a sudo failure.
*/
const SUDO_AUTH_FAILED = [
"incorrect password",
"a password is required",
"a terminal is required",
"no tty present",
"sorry, try again",
"no password was provided",
"1 incorrect password attempt",
];
const SUDO_NOT_SUDOER = [
"is not in the sudoers file",
"not allowed to run sudo",
"not allowed to execute",
];
/**
* Marker printed only after sudo has successfully authenticated and started
* the inner shell. Its presence on stdout is the authoritative "elevation
* worked" signal; its absence (together with sudo stderr) means auth failed.
*/
const SUDO_OK_MARKER = "__TX_SUDO_OK__";
const SUDO_OK_LINE_RE = new RegExp(`^${SUDO_OK_MARKER}\\r?\\n?`);
/** Escape a value for single-quoted shell context. */
export function shellSingleQuote(value: string): string {
return `'${value.replace(/'/g, "'\"'\"'")}'`;
}
/**
* Build the elevated command string:
* echo '<pw>' | sudo -S -p '' sh -c 'echo __TX_SUDO_OK__; <command>'
*
* `-p ''` suppresses the prompt. stderr is NOT merged into stdout, so the
* command's own output stays clean and sudo's auth errors stay on stderr. The
* marker is echoed by the inner shell once sudo authenticates, letting us
* tell a genuine auth failure from command output that merely contains scary
* words.
*/
export function buildSudoCommand(
command: string,
sudoPassword: string,
): string {
const pw = shellSingleQuote(sudoPassword);
const inner = shellSingleQuote(`echo ${SUDO_OK_MARKER}; ${command}`);
return `echo ${pw} | sudo -S -p '' sh -c ${inner}`;
}
function includesAny(text: string, needles: string[]): boolean {
const lower = text.toLowerCase();
return needles.some((n) => lower.includes(n));
}
function looksLikePermissionDenied(text: string): boolean {
return includesAny(text, PERMISSION_DENIED);
}
/** Strip the success marker line from the front of stdout. */
function stripMarker(stdout: string): string {
return stdout.replace(SUDO_OK_LINE_RE, "");
}
/**
* Run a command on a client, elevating with the host's stored sudo password
* only when needed (or when forced). Throws a typed `ElevationError` when
* elevation is required but unavailable/incorrect.
*/
export async function execElevated(
client: Client,
command: string,
sudoPassword: string | undefined,
opts: { forceSudo?: boolean; timeoutMs?: number } = {},
): Promise<ElevatedResult> {
const timeoutMs = opts.timeoutMs ?? 30000;
if (!opts.forceSudo) {
const direct = await execCommand(client, command, timeoutMs);
if (direct.code === 0) {
return { ...direct, usedSudo: false };
}
// Only escalate when the failure looks like a privilege problem. A
// permission-denied phrase on stderr (not arbitrary stdout) is the signal.
if (!looksLikePermissionDenied(direct.stderr)) {
return { ...direct, usedSudo: false };
}
if (!sudoPassword) {
throw new ElevationError(
"SUDO_REQUIRED",
"This action requires elevated privileges. Set a sudo password for this host to continue.",
);
}
} else if (!sudoPassword) {
throw new ElevationError(
"SUDO_REQUIRED",
"This action requires elevated privileges. Set a sudo password for this host to continue.",
);
}
const sudoCmd = buildSudoCommand(command, sudoPassword as string);
const result = await execCommand(client, sudoCmd, timeoutMs);
const authenticated = result.stdout.includes(SUDO_OK_MARKER);
if (!authenticated) {
// Elevation never started: diagnose from sudo's stderr only.
const notSudoer = includesAny(result.stderr, SUDO_NOT_SUDOER);
const authFailed = includesAny(result.stderr, SUDO_AUTH_FAILED);
if (notSudoer) {
throw new ElevationError(
"NOT_SUDOER",
"The connected user is not permitted to use sudo on this host.",
);
}
if (authFailed) {
throw new ElevationError(
"SUDO_FAILED",
"Elevation failed. Check the host's sudo password.",
);
}
// No marker and no recognizable sudo error: treat as a generic failure
// but keep the original output so the caller can surface it.
throw new ElevationError(
"SUDO_FAILED",
"Elevation failed. Check the host's sudo password.",
);
}
return {
stdout: stripMarker(result.stdout),
stderr: result.stderr,
code: result.code,
usedSudo: true,
};
}
File diff suppressed because it is too large. Load diff
+203
View File
@@ -0,0 +1,203 @@
/**
* Settings field validation, shared by core and the frontend.
*
* Both the HTTP routes and the form renderer call these, so "what counts as a
* valid value" is decided once. A field the manifest never declared has no
* entry here and is rejected rather than stored, which is what stops a PUT
* from writing arbitrary keys into a plugin's namespace.
*/
import type { PluginSettingsField } from "./manifest.js";
/** The permission an admin-scope field requires when it names none itself. */
export const ADMIN_SETTINGS_PERMISSION = "admin.plugins.manage";
/** What a secret looks like on the wire. The value never leaves the server. */
export interface RedactedSecret {
set: boolean;
}
export function isRedactedSecret(value: unknown): value is RedactedSecret {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.keys(value).length === 1 &&
typeof (value as RedactedSecret).set === "boolean"
);
}
/**
* Turns whatever arrived on the wire into the type the field declares.
*
* Forms send everything as strings, so a number field that refused "30" would
* reject every value the UI can produce. Anything genuinely unconvertible is
* left alone for validateSettingValue to reject with a readable message.
*/
export function coerceSettingValue(
field: PluginSettingsField,
value: unknown,
): unknown {
if (value === null || value === undefined) return value;
switch (field.type) {
case "boolean":
if (typeof value === "boolean") return value;
if (value === "true") return true;
if (value === "false") return false;
return value;
case "number": {
if (typeof value === "number") return value;
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
return Number.isNaN(parsed) ? value : parsed;
}
return value;
}
case "multiselect":
return Array.isArray(value) ? value : value === "" ? [] : value;
case "string":
case "secret":
case "textarea":
case "select":
return typeof value === "number" || typeof value === "boolean"
? String(value)
: value;
default:
return value;
}
}
/**
* Returns an error message, or null when the value is acceptable.
*
* Messages are plain English rather than i18n keys: they name the offending
* field and the rule it broke, and the UI shows them beside the input.
*/
export function validateSettingValue(
field: PluginSettingsField,
value: unknown,
): string | null {
// Clearing a field is always allowed; the default takes over on read.
if (value === null || value === undefined) return null;
switch (field.type) {
case "boolean":
return typeof value === "boolean"
? null
: `"${field.key}" must be true or false`;
case "number": {
if (typeof value !== "number" || !Number.isFinite(value)) {
return `"${field.key}" must be a number`;
}
if (field.min !== undefined && value < field.min) {
return `"${field.key}" must be at least ${field.min}`;
}
if (field.max !== undefined && value > field.max) {
return `"${field.key}" must be at most ${field.max}`;
}
return null;
}
case "select": {
if (typeof value !== "string") {
return `"${field.key}" must be a string`;
}
const allowed = (field.options ?? []).map((option) => option.value);
return allowed.includes(value)
? null
: `"${field.key}" must be one of: ${allowed.join(", ")}`;
}
case "multiselect": {
if (!Array.isArray(value)) {
return `"${field.key}" must be an array`;
}
const allowed = (field.options ?? []).map((option) => option.value);
for (const entry of value) {
if (typeof entry !== "string" || !allowed.includes(entry)) {
return `"${field.key}" may only contain: ${allowed.join(", ")}`;
}
}
return null;
}
case "json": {
if (typeof value === "string") {
try {
JSON.parse(value);
return null;
} catch {
return `"${field.key}" must be valid JSON`;
}
}
// Already-parsed JSON is fine as long as it survives a round trip.
try {
JSON.stringify(value);
return null;
} catch {
return `"${field.key}" must be valid JSON`;
}
}
case "secret":
// A redacted echo is a no-op rather than a value, handled by the caller.
if (isRedactedSecret(value)) return null;
return typeof value === "string"
? null
: `"${field.key}" must be a string`;
case "string":
case "textarea":
return typeof value === "string"
? null
: `"${field.key}" must be a string`;
case "custom":
// The component owns its own shape; core only stores what it sends.
return null;
default:
return `"${field.key}" has an unknown type`;
}
}
/** The declared defaults for a scope, used when no row has been written. */
export function defaultsFor(
fields: readonly PluginSettingsField[] = [],
): Record<string, unknown> {
const defaults: Record<string, unknown> = {};
for (const field of fields) {
if (field.default !== undefined) defaults[field.key] = field.default;
}
return defaults;
}
/** Looks a field up by key, for a caller holding only the declared list. */
export function findSettingsField(
fields: readonly PluginSettingsField[] = [],
key: string,
): PluginSettingsField | undefined {
return fields.find((field) => field.key === key);
}
/**
* Whether a field should be shown or accepted, given the rest of the scope.
*
* A field declaring `requires` is inert while its named boolean is off, so the
* UI hides it and a write to it is pointless rather than wrong.
*/
export function isFieldActive(
field: PluginSettingsField,
values: Record<string, unknown>,
): boolean {
if (!field.requires) return true;
return values[field.requires] === true;
}
export type { PluginSettingsField } from "./manifest.js";
+340
View File
@@ -0,0 +1,340 @@
/**
* OpenSSH certificate auth for ssh2, which has no native support for it.
*
* Grafts the certificate onto the parsed key, converts ECDSA signatures from
* DER to SSH wire format, and patches Protocol.authPK so the signature
* wrapper carries the base algorithm (OpenSSH's sshkey_check_sigtype wants
* that). Pure: no core state, it only touches the config and client it gets.
*
* Used by core key auth with a CA-signed certificate and by every plugin that
* issues short-lived certificates (opkssh, step-ca, vault).
*/
import type {
AnyAuthMethod,
AuthHandlerMiddleware,
AuthenticationType,
Client,
ConnectConfig,
PublicKeyAuthMethod,
} from "ssh2";
type SignCallback = (
data: Buffer,
callback: (signature: Buffer) => void,
) => void;
interface ParsedPrivateKey {
type: string;
sign: (data: Buffer, algo?: string) => Buffer | Error;
getPublicSSH: () => Buffer;
[key: symbol]: unknown;
}
interface PatchedProtocol {
authPK: (
user: string,
pubKey: ParsedPrivateKey,
keyAlgo: string | undefined,
cbSign?: SignCallback,
) => unknown;
_kex: {
sessionID: Buffer;
};
_packetRW: {
write: {
alloc: (payloadLength: number) => Buffer;
allocStart: number;
finalize: (packet: Buffer) => Buffer;
};
};
_authsQueue: string[];
_debug?: (message: string) => void;
_cipher: {
encrypt: (packet: Buffer) => void;
};
}
type PatchedClient = Client & {
_protocol?: PatchedProtocol;
};
type NextAuthHandler = (
authInfo: AuthenticationType | AnyAuthMethod | false,
) => void;
// Grafts an OpenSSH certificate onto an already-parsed private key object and
// patches the ssh2 client so that certificate-based publickey auth succeeds.
async function applyCertToConnection(
config: ConnectConfig,
client: Client,
privKey: ParsedPrivateKey,
certStr: string,
): Promise<void> {
// Extract cert type and blob from the stored certificate
const certParts = certStr.trim().split(/\s+/);
if (certParts.length < 2) {
throw new Error(
"Invalid certificate format: expected '<type> <base64>' string",
);
}
const certType = certParts[0];
const certBlob = Buffer.from(certParts[1], "base64");
// Graft cert type and blob onto the parsed private key
privKey.type = certType;
const pubSSHSym = Object.getOwnPropertySymbols(privKey).find(
(s) => String(s) === "Symbol(Public key SSH)",
);
if (!pubSSHSym) {
throw new Error(
"Cannot find public SSH symbol on parsed key; ssh2 internals may have changed",
);
}
privKey[pubSSHSym] = certBlob;
// Wrap sign() for ECDSA cert keys (DER → SSH wire format)
if (privKey.type.startsWith("ecdsa-")) {
const origSign = privKey.sign.bind(privKey);
privKey.sign = (data: Buffer, algo?: string) => {
const sigAlgo = algo?.includes("-cert-")
? algo.replace(/-cert-v\d+@openssh\.com$/, "")
: algo;
const sig = origSign(data, sigAlgo);
if (sig instanceof Error || sig[0] !== 0x30) return sig;
// Convert DER-encoded ECDSA signature to SSH wire format
try {
let pos = 2;
if (sig[1] & 0x80) pos += sig[1] & 0x7f;
pos++;
const rLen = sig[pos++];
const r = sig.subarray(pos, pos + rLen);
pos += rLen + 1;
const sLen = sig[pos++];
const s = sig.subarray(pos, pos + sLen);
const out = Buffer.allocUnsafe(4 + r.length + 4 + s.length);
out.writeUInt32BE(r.length, 0);
r.copy(out, 4);
out.writeUInt32BE(s.length, 4 + r.length);
s.copy(out, 4 + r.length + 4);
return out;
} catch {
return sig;
}
};
}
// Set up authHandler to bypass ssh2's cert type rejection
let certAuthAttempted = false;
const authHandler: AuthHandlerMiddleware = (
methodsLeft: string[],
_partialSuccess: boolean,
callback,
) => {
const next = callback as NextAuthHandler;
if (
!certAuthAttempted &&
(!methodsLeft || methodsLeft.includes("publickey"))
) {
certAuthAttempted = true;
next({
type: "publickey",
username: (config as Record<string, unknown>).username as string,
key: privKey as unknown as PublicKeyAuthMethod["key"],
});
} else {
next(false);
}
};
config.authHandler = authHandler;
// Monkey-patch Protocol.authPK after connect() to fix the signature
// wrapper algorithm for cert types.
const baseAlgo = certType.replace(/-cert-v\d+@openssh\.com$/, "");
const origConnect = client.connect.bind(client);
const patchedClient = client as PatchedClient;
patchedClient.connect = (cfg: ConnectConfig) => {
const connectedClient = origConnect(cfg);
const proto = patchedClient._protocol;
if (!proto) return connectedClient;
const origAuthPK = proto.authPK.bind(proto);
proto.authPK = (
user: string,
pubKey: ParsedPrivateKey,
keyAlgo: string | undefined,
cbSign?: SignCallback,
) => {
const isCertAuth = !!cbSign && pubKey?.type?.includes("-cert-");
if (!isCertAuth) {
return origAuthPK(user, pubKey, keyAlgo, cbSign);
}
// Signed auth with cert type: rebuild packet with base algo in
// the signature wrapper. keyAlgo may be undefined for ECDSA.
const certAlgo = keyAlgo || pubKey.type;
const pubSSH = pubKey.getPublicSSH();
const sessionID = proto._kex.sessionID;
const sesLen = sessionID.length;
const userLen = Buffer.byteLength(user);
const certAlgoLen = Buffer.byteLength(certAlgo);
const baseAlgoLen = Buffer.byteLength(baseAlgo);
const pubKeyLen = pubSSH.length;
// Build data to sign (uses the cert algo, which the server verifies against)
const sigDataLen =
4 +
sesLen +
1 +
4 +
userLen +
4 +
14 +
4 +
9 +
1 +
4 +
certAlgoLen +
4 +
pubKeyLen;
const sigData = Buffer.allocUnsafe(sigDataLen);
let sp = 0;
sigData.writeUInt32BE(sesLen, sp);
sp += 4;
sessionID.copy(sigData, sp);
sp += sesLen;
sigData[sp++] = 50; // SSH_MSG_USERAUTH_REQUEST
sigData.writeUInt32BE(userLen, sp);
sp += 4;
sigData.write(user, sp, userLen, "utf8");
sp += userLen;
sigData.writeUInt32BE(14, sp);
sp += 4;
sigData.write("ssh-connection", sp, 14, "utf8");
sp += 14;
sigData.writeUInt32BE(9, sp);
sp += 4;
sigData.write("publickey", sp, 9, "utf8");
sp += 9;
sigData[sp++] = 1; // TRUE
sigData.writeUInt32BE(certAlgoLen, sp);
sp += 4;
sigData.write(certAlgo, sp, certAlgoLen, "utf8");
sp += certAlgoLen;
sigData.writeUInt32BE(pubKeyLen, sp);
sp += 4;
pubSSH.copy(sigData, sp);
cbSign(sigData, (signature: Buffer) => {
const sigLen = signature.length;
const payloadLen =
1 +
4 +
userLen +
4 +
14 +
4 +
9 +
1 +
4 +
certAlgoLen +
4 +
pubKeyLen +
4 +
4 +
baseAlgoLen +
4 +
sigLen;
const packet = proto._packetRW.write.alloc(payloadLen);
let pp = proto._packetRW.write.allocStart;
packet[pp] = 50; // SSH_MSG_USERAUTH_REQUEST
packet.writeUInt32BE(userLen, ++pp);
pp += 4;
packet.write(user, pp, userLen, "utf8");
pp += userLen;
packet.writeUInt32BE(14, pp);
pp += 4;
packet.write("ssh-connection", pp, 14, "utf8");
pp += 14;
packet.writeUInt32BE(9, pp);
pp += 4;
packet.write("publickey", pp, 9, "utf8");
pp += 9;
packet[pp++] = 1; // TRUE
// Header: cert type
packet.writeUInt32BE(certAlgoLen, pp);
pp += 4;
packet.write(certAlgo, pp, certAlgoLen, "utf8");
pp += certAlgoLen;
// Public key blob
packet.writeUInt32BE(pubKeyLen, pp);
pp += 4;
pubSSH.copy(packet, pp);
pp += pubKeyLen;
// Signature wrapper: base algo (NOT cert type)
packet.writeUInt32BE(4 + baseAlgoLen + 4 + sigLen, pp);
pp += 4;
packet.writeUInt32BE(baseAlgoLen, pp);
pp += 4;
packet.write(baseAlgo, pp, baseAlgoLen, "utf8");
pp += baseAlgoLen;
packet.writeUInt32BE(sigLen, pp);
pp += 4;
signature.copy(packet, pp);
proto._authsQueue.push("publickey");
proto._debug?.("Outbound: Sending USERAUTH_REQUEST (publickey)");
const finalized = proto._packetRW.write.finalize(packet);
proto._cipher.encrypt(finalized);
});
};
return connectedClient;
};
}
export interface CertificateCredentials {
/** OpenSSH or PEM private key. */
privateKey: Buffer | string;
/** The certificate line, e.g. "ssh-ed25519-cert-v01@openssh.com AAAA...". */
certificate: string;
passphrase?: string;
}
/**
* Sets up certificate publickey auth on an ssh2 client. Call before
* `client.connect(config)`; it sets `config.authHandler` and
* `config.username`.
*/
export async function applyCertificateAuth(
config: ConnectConfig,
client: Client,
credentials: CertificateCredentials,
username: string,
): Promise<void> {
const { createRequire } = await import("node:module");
const esmRequire = createRequire(import.meta.url);
const {
utils: { parseKey },
} = esmRequire("ssh2");
(config as Record<string, unknown>).username = username;
const keyBuf = Buffer.isBuffer(credentials.privateKey)
? credentials.privateKey
: Buffer.from(credentials.privateKey, "utf8");
const parsed = credentials.passphrase
? parseKey(keyBuf, credentials.passphrase)
: parseKey(keyBuf);
if (parsed instanceof Error || !parsed) {
const reason = parsed instanceof Error ? parsed.message : "unknown error";
throw new Error(
`Failed to parse private key for certificate auth: ${reason}`,
);
}
const privKey = (
Array.isArray(parsed) ? parsed[0] : parsed
) as ParsedPrivateKey;
await applyCertToConnection(config, client, privKey, credentials.certificate);
}
+20
View File
@@ -0,0 +1,20 @@
/**
* A host's SSH connection options, as core's connect pipeline reads them
* (`ssh_data.ssh_options`). Shared by the backend and frontend host records.
*/
export interface HostSshOptions {
/** Seconds between keepalive packets. */
keepaliveInterval?: number;
/** Missed keepalives before the connection is dropped. */
keepaliveCountMax?: number;
/** Offer older key exchange, cipher and MAC algorithms. On unless false. */
allowLegacyAlgorithms?: boolean;
/** Agent auth: the agent socket to use instead of the default. */
agentSocketPath?: string | null;
/** Agent auth: the key comment or fingerprint to offer first. */
agentIdentity?: string | null;
/** Forward the agent (or the host's key) into the session. */
agentForwarding?: boolean;
/** Variables exported into an interactive session. */
environmentVariables?: Array<{ key: string; value: string }>;
}
+120
View File
@@ -0,0 +1,120 @@
/**
* Turns a plugin's table definition into the Drizzle table it is queried
* through.
*
* One table object for every dialect. The sqlite-core definitions already
* encode correctly on each engine at query time, so the repositories use them
* everywhere; only DDL genuinely differs, and that lives in the SDK's ddl
* entry so the CLI and the server share one mapping.
*/
import {
sqliteTable,
text as sqliteText,
integer as sqliteInteger,
real as sqliteReal,
index as sqliteIndex,
uniqueIndex as sqliteUniqueIndex,
type AnySQLiteColumn,
} from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
import { prefixedTableName } from "./db.js";
import type { PluginTableDefinition } from "./db.js";
import { columnName } from "./ddl.js";
type ColumnChain = {
notNull: () => unknown;
primaryKey: () => unknown;
unique: () => unknown;
default: (value: unknown) => unknown;
};
/**
* Builds the queryable table object.
*
* Always sqlite-core, on every dialect. drizzle's three table classes share no
* base class, and the query-builder surface a plugin uses is identical across
* them.
*/
export function buildTable(
pluginId: string,
definition: PluginTableDefinition,
): unknown {
const physical = prefixedTableName(pluginId, definition.name);
const columns: Record<string, unknown> = {};
for (const [property, column] of Object.entries(definition.columns)) {
const name = columnName(property, column);
let built: unknown;
switch (column.type) {
case "id":
built = sqliteInteger(name).primaryKey({ autoIncrement: true });
break;
case "integer":
case "bigint":
case "refHost":
built = sqliteInteger(name);
break;
case "real":
built = sqliteReal(name);
break;
case "boolean":
built = sqliteInteger(name, { mode: "boolean" });
break;
default:
built = sqliteText(name);
break;
}
if (column.type !== "id") {
if (column.primaryKey) built = (built as ColumnChain).primaryKey();
if (column.notNull) built = (built as ColumnChain).notNull();
if (column.unique) built = (built as ColumnChain).unique();
if (column.defaultNow) {
built = (built as ColumnChain).default(sql`CURRENT_TIMESTAMP`);
} else if (column.defaultValue !== undefined) {
built = (built as ColumnChain).default(column.defaultValue);
}
}
columns[property] = built;
}
return sqliteTable(physical, columns as never, (table) =>
definition.indexes.map((entry) => {
const make = entry.unique ? sqliteUniqueIndex : sqliteIndex;
const target = table as unknown as Record<string, AnySQLiteColumn>;
return make(entry.name).on(
...(entry.columns.map((c) => target[c]) as [
AnySQLiteColumn,
...AnySQLiteColumn[],
]),
);
}),
);
}
/**
* A minimal stand-in for a core table, for tests that join against users,
* ssh_data, roles or user_roles without core's schema. Only what
* createTestDb's stubs declare. The property name is camelCased and mapped
* to its snake_case column the same way buildTable does, so e.g. a stub's
* "displayName" property reads the real "display_name" SQL column.
*/
export function buildRefTable(
name: string,
columns: Record<string, "text" | "integer" | "boolean">,
): unknown {
const built: Record<string, unknown> = {};
for (const [property, type] of Object.entries(columns)) {
const columnSqlName = columnName(property);
built[property] =
type === "boolean"
? sqliteInteger(columnSqlName, { mode: "boolean" })
: type === "integer"
? sqliteInteger(columnSqlName)
: sqliteText(columnSqlName);
}
return sqliteTable(name, built as never);
}
File diff suppressed because it is too large. Load diff
@@ -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;
}
+154
View File
@@ -0,0 +1,154 @@
/**
* Shared vitest setup for core and for every plugin suite.
*
* The root vitest.setup.ts re-exports this so there is one copy of the
* polyfills rather than two that drift apart.
*/
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) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}
/**
* jsdom in this project ships without a `localStorage` global, and Node only
* provides one when started with --localstorage-file (which persists to disk
* and is shared across test files -- both wrong for tests). Suites that touch
* storage therefore threw `Cannot read properties of undefined`, taking out
* every test in the file rather than just the ones that used it.
*
* A per-process in-memory implementation is enough: it satisfies the Storage
* interface the app uses and keeps nothing on disk.
*/
if (typeof globalThis.localStorage === "undefined") {
const createMemoryStorage = (): Storage => {
const store = new Map<string, string>();
return {
get length() {
return store.size;
},
key: (index: number) => Array.from(store.keys())[index] ?? null,
getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
setItem: (key: string, value: string) => {
store.set(key, String(value));
},
removeItem: (key: string) => {
store.delete(key);
},
clear: () => {
store.clear();
},
} as Storage;
};
Object.defineProperty(globalThis, "localStorage", {
value: createMemoryStorage(),
configurable: true,
writable: true,
});
Object.defineProperty(globalThis, "sessionStorage", {
value: createMemoryStorage(),
configurable: true,
writable: true,
});
}
/**
* A plain plugin host for components a test renders on its own: keys come
* back as text, every permission is held, and nothing reaches a server.
* renderWithApp installs Termix's real host instead, which takes precedence.
*/
const noop = () => {};
const offline = () =>
Promise.reject(new Error("No plugin API in a unit test; mock it"));
(globalThis as Record<string, unknown>).__termixTestPluginHost = {
usePluginId: () => "test-plugin",
useTranslation: () => ({
t: (key: string) => key,
language: "en",
}),
usePermission: () => true,
useSettings: () => ({ values: {}, loaded: true, save: async () => {} }),
useHost: () => null,
useHosts: () => ({ hosts: [], loaded: true }),
useHostStatus: () => null,
useCurrentUser: () => null,
useTheme: () => ({ theme: "dark" }),
toast: { success: noop, error: noop, info: noop, warning: noop },
getApi: () => ({
get: offline,
delete: offline,
post: offline,
put: offline,
patch: offline,
}),
useTabs: () => ({
openTab: noop,
openSingletonTab: noop,
connectHost: noop,
closeTab: noop,
getLayout: () => null,
applyLayout: async () => ({ skipped: [] }),
onChange: () => noop,
onReady: () => noop,
}),
invokeAction: async () => undefined,
useSshAuthTypes: () => ({
types: ["password", "key", "credential", "agent", "none"].map((type) => ({
type,
labelKey: type,
pluginId: "core",
credentialType: type === "password" || type === "key",
supportsBackground: type !== "none",
quickConnect: true,
})),
loaded: true,
}),
useSlotContributions: () => [],
usePluginComponent: () => undefined,
getApiFor: () => ({
get: offline,
delete: offline,
post: offline,
put: offline,
patch: offline,
}),
hostProtocols: () => ["ssh"],
useHostActions: () => [],
useActivityTypes: () => [],
activityTarget: () => undefined,
useExtensions: () => [],
getExtension: () => undefined,
usePluginUiPreferences: () => ({ values: {}, set: noop }),
core: {
logActivity: async () => {},
getHostPassword: async () => null,
patchOpenTab: async () => {},
getCustomKeybindings: async () => [],
runKeybindingAction: () => false,
getClientPreference: () => undefined,
setClientPreference: () => {},
listHosts: async () => [],
listCredentials: async () => [],
notifyHostsChanged: () => {},
getHostStatusColorScheme: () => "accent",
getLocalAuthToken: () => null,
},
};
afterEach(() => {
vi.restoreAllMocks();
});
+130
View File
@@ -0,0 +1,130 @@
/**
* The vitest config every plugin uses.
*
* A plugin's vitest.config.ts is one line:
*
* import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
* export default pluginVitestConfig(import.meta.url);
*
* Two projects: tests/backend runs in node, tests/frontend in jsdom.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
/**
* Walks up from a plugin directory to a Termix checkout, if it is in one.
*
* Inside the repo the SDK's browser entries and the test host resolve to
* core's source, so a test exercises the real registries. Outside it they
* resolve to the builds the SDK ships in dist/host.
*/
function findRepoRoot(startDir: string): string | null {
let dir = startDir;
for (;;) {
if (fs.existsSync(path.join(dir, "src", "ui"))) return dir;
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
export interface PluginVitestOptions {
/** Extra aliases merged over the defaults. */
alias?: Record<string, string>;
}
export function pluginVitestConfig(
configUrl: string,
options: PluginVitestOptions = {},
) {
const pluginDir = path.dirname(fileURLToPath(configUrl));
const manifestPath = path.join(pluginDir, "manifest.json");
const pluginId: string =
(fs.existsSync(manifestPath) &&
JSON.parse(fs.readFileSync(manifestPath, "utf8")).id) ||
path.basename(pluginDir);
const setupFile = fileURLToPath(
new URL("./testing/setup.js", import.meta.url),
);
const repoRoot = findRepoRoot(pluginDir);
// Inside a Termix checkout the SDK's browser entries resolve to source and
// the test host is core's, so renderWithApp exercises the real registries
// and the plugin shares one SDK instance with them.
const alias: Record<string, string> = {
...(repoRoot
? {
"@termix/plugin-sdk/frontend": path.join(
repoRoot,
"packages",
"plugin-sdk",
"src",
"frontend.ts",
),
"@termix/plugin-sdk/ui": path.join(
repoRoot,
"src",
"ui",
"plugin-host",
"sdk-ui.ts",
),
"@termix/plugin-host/testing": path.join(
repoRoot,
"src",
"ui",
"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"),
}
: {
// Outside a Termix checkout: the UI kit resolves through the
// package, and the test host is the copy the SDK ships.
"@termix/plugin-host/testing": fileURLToPath(
new URL("./host/testing-host.js", import.meta.url),
),
}),
...options.alias,
};
return {
resolve: { alias },
test: {
globals: true,
setupFiles: [setupFile],
projects: [
{
resolve: { alias },
test: {
name: `${pluginId}:backend`,
root: pluginDir,
environment: "node",
globals: true,
setupFiles: [setupFile],
include: ["tests/backend/**/*.test.ts"],
},
},
{
resolve: { alias },
test: {
name: `${pluginId}:frontend`,
root: pluginDir,
environment: "jsdom",
globals: true,
setupFiles: [setupFile],
// Run the SDK through Vite rather than plain Node, so its import
// of the test host sees the alias above and the plugin and the
// host share one copy of @termix/plugin-sdk/frontend.
server: { deps: { inline: [/@termix\/plugin-sdk/] } },
include: ["tests/frontend/**/*.test.{ts,tsx}"],
},
},
],
},
};
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"tsBuildInfoFile": "../../node_modules/.tmp/tsconfig.plugin-sdk.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"types": ["node"],
"module": "nodenext",
"moduleResolution": "nodenext",
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"esModuleInterop": true,
"declaration": true,
"noEmit": false,
"rootDir": "./src",
"outDir": "./dist",
"strict": false,
"resolveJsonModule": true
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts"]
}
+31
View File
@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"allowImportingTsExtensions": true,
"types": ["node", "vitest/globals"],
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"esModuleInterop": true,
"resolveJsonModule": true,
"noEmit": true,
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": false,
"noFallthroughCasesInSwitch": false,
"noUncheckedSideEffectImports": false,
"noImplicitAny": false,
"noImplicitThis": false,
"noUncheckedIndexedAccess": false,
"exactOptionalPropertyTypes": false,
"noPropertyAccessFromIndexSignature": false,
"allowUnusedLabels": true,
"allowUnreachableCode": true
},
"files": ["./client.d.ts"]
}
+188
View File
@@ -0,0 +1,188 @@
ActionSlot (value): ({ slotId, context, className, enabled, renderItem, hideLabels, when, }: ActionSlotProps) => Element
Alert (value): ({ className, variant, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; destructive: string; }; }> & ClassProp) => string>) => Element
AlertDescription (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
AlertDialog (value): ({ ...props }: AlertDialogProps) => Element
AlertDialogAction (value): ({ className, ...props }: AlertDialogActionProps & RefAttributes<HTMLButtonElement>) => Element
AlertDialogCancel (value): ({ className, ...props }: AlertDialogCancelProps & RefAttributes<HTMLButtonElement>) => Element
AlertDialogContent (value): ({ className, ...props }: AlertDialogContentProps & RefAttributes<HTMLDivElement>) => Element
AlertDialogDescription (value): ({ className, ...props }: AlertDialogDescriptionProps & RefAttributes<HTMLParagraphElement>) => Element
AlertDialogFooter (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
AlertDialogHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
AlertDialogOverlay (value): ({ className, ...props }: AlertDialogOverlayProps & RefAttributes<HTMLDivElement>) => Element
AlertDialogPortal (value): ({ ...props }: AlertDialogPortalProps) => Element
AlertDialogTitle (value): ({ className, ...props }: AlertDialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
AlertDialogTrigger (value): ({ ...props }: AlertDialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
AlertTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
Badge (value): ({ className, variant, asChild, ...props }: ClassAttributes<HTMLSpanElement> & HTMLAttributes<HTMLSpanElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; secondary: string; destructive: string; outline: string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element
BarSeries (value): ({ items, max, className, colorClassName, }: BarSeriesProps) => Element
BarSeriesItem (type): BarSeriesItem
BarSeriesProps (type): BarSeriesProps
BrowserSignInDialog (value): ({ isOpen, label, url, code, onContinue, onCancel, onOpenUrl, backgroundColor, }: BrowserSignInDialogProps) => Element
Button (value): ({ className, variant, size, asChild, ...props }: ClassAttributes<HTMLButtonElement> & ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; outline: string; secondary: string; ghost: string; destructive: string; link: string; }; size: { default: string; xs: string; sm: string; lg: string; icon: string; "icon-xs": string; "icon-sm": string; "icon-lg": string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element
ButtonProps (type): ButtonProps
Card (value): ({ className, size, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & { size?: "default" | "sm"; }) => Element
CardAction (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
CardContent (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
CardDescription (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
CardFooter (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
CardGridCanvas (value): ({ slots, columns, editMode, renderCard, cardCatalog, onChange, cardLabel, className, }: CardGridCanvasProps) => Element
CardHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
CardTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
Checkbox (value): ({ className, ...props }: CheckboxProps & RefAttributes<HTMLButtonElement>) => Element
ColumnCountStepper (value): ({ columns, onChange, }: { columns: number; onChange: (columns: number) => void; }) => Element
ComponentSlot (value): ({ slotId, props, when, }: { slotId: string; props?: Record<string, unknown>; when?: Record<string, unknown>; }) => Element
ConnectionLogPanel (value): ({ isConnecting, isConnected, hasConnectionError, position, className, }: ConnectionLogPanelProps) => Element
ConnectionLogProvider (value): ({ children, }: { children: ReactNode; }) => Element
ConnectionOrigin (type): ConnectionOrigin
ConnectionScreen (value): ({ status, message, backgroundColor, attempt, maxAttempts, nextRetryInMs, onManualRetry, retryLabel, disconnectedMessage, extraActions, logPosition, emptyState, className, }: ConnectionScreenProps) => Element
ConnectionStage (type): ConnectionStage
ConnectionStatus (type): ConnectionStatus
CustomKeybinding (type): CustomKeybinding
Dialog (value): ({ ...props }: DialogProps) => Element
DialogClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
DialogContent (value): ({ className, children, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { showCloseButton?: boolean; }) => Element
DialogDescription (value): ({ className, ...props }: DialogDescriptionProps & RefAttributes<HTMLParagraphElement>) => Element
DialogFooter (value): ({ className, showCloseButton, children, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & { showCloseButton?: boolean; }) => Element
DialogHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
DialogOverlay (value): ({ className, ...props }: DialogOverlayProps & RefAttributes<HTMLDivElement>) => Element
DialogPortal (value): ({ ...props }: DialogPortalProps) => Element
DialogTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
DialogTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
DropdownMenu (value): ({ ...props }: DropdownMenuProps) => Element
DropdownMenuCheckboxItem (value): ({ className, children, checked, inset, ...props }: DropdownMenuCheckboxItemProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
DropdownMenuContent (value): ({ className, align, sideOffset, ...props }: DropdownMenuContentProps & RefAttributes<HTMLDivElement>) => Element
DropdownMenuGroup (value): ({ ...props }: DropdownMenuGroupProps & RefAttributes<HTMLDivElement>) => Element
DropdownMenuItem (value): ({ className, inset, variant, ...props }: DropdownMenuItemProps & RefAttributes<HTMLDivElement> & { inset?: boolean; variant?: "default" | "destructive"; }) => Element
DropdownMenuLabel (value): ({ className, inset, ...props }: DropdownMenuLabelProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
DropdownMenuPortal (value): ({ ...props }: DropdownMenuPortalProps) => Element
DropdownMenuRadioGroup (value): ({ ...props }: DropdownMenuRadioGroupProps & RefAttributes<HTMLDivElement>) => Element
DropdownMenuRadioItem (value): ({ className, children, inset, ...props }: DropdownMenuRadioItemProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
DropdownMenuSeparator (value): ({ className, ...props }: DropdownMenuSeparatorProps & RefAttributes<HTMLDivElement>) => Element
DropdownMenuShortcut (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>) => Element
DropdownMenuSub (value): ({ ...props }: DropdownMenuSubProps) => Element
DropdownMenuSubContent (value): ({ className, sideOffset, alignOffset, ...props }: DropdownMenuSubContentProps & RefAttributes<HTMLDivElement>) => Element
DropdownMenuSubTrigger (value): ({ className, inset, children, ...props }: DropdownMenuSubTriggerProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
DropdownMenuTrigger (value): ({ ...props }: DropdownMenuTriggerProps & RefAttributes<HTMLButtonElement>) => Element
EmptyState (value): ({ icon: Icon, title, hint, action, className, }: { icon?: LucideIcon; title: string; hint?: string; action?: ReactNode; className?: string; }) => Element
FOLDER_COLORS (value): string[]
FakeSwitch (value): ({ defaultChecked, disabled, checked, onChange, }: { defaultChecked?: boolean; disabled?: boolean; checked?: boolean; onChange?: (v: boolean) => void; }) => Element
FullScreenAppPhase (type): FullScreenAppPhase
FullScreenAppWrapper (value): FC<FullScreenAppWrapperProps>
GridCardCatalogEntry (type): GridCardCatalogEntry
GridColSpan (type): GridColSpan
GridLayout (type): GridLayout
GridSlot (type): GridSlot
HostDefaultBadge (value): ({ settingKey }: { settingKey: string; }) => Element
HostDefaultField (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
HostFeatureFields (value): ({ form, updateForm, }: HostFeatureFieldsProps) => Element
HostFeatureFieldsProps (type): HostFeatureFieldsProps
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, algorithm, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
Input (value): ({ className, type, ...props }: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) => Element
Kbd (value): ForwardRefExoticComponent<KbdProps & RefAttributes<HTMLElement>>
KbdKey (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): Element; displayName: string; }
KbdSeparator (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): Element; displayName: string; }
KeyCombo (type): KeyCombo
KeybindingAction (type): KeybindingAction
KeybindingActionType (type): string
Label (value): ({ className, ...props }: LabelProps & RefAttributes<HTMLLabelElement>) => Element
LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element
LineChartSeries (type): LineChartSeries
LogContext (type): LogContext
LogEntry (type): LogEntry
MFAPromptMode (type): MFAPromptMode
ManagerCardError (type): ManagerCardError
ManagerCardShell (value): ({ title, icon, loading, error, onRefresh, empty, emptyMessage, children, headerExtra, }: { title: string; icon: ReactNode; loading: boolean; error: ManagerCardError; onRefresh: () => void; empty?: boolean; emptyMessage?: string; children: ReactNode; headerExtra?: ReactNode; }) => Element
ManagerSearch (value): ({ value, onChange, placeholder, count, extra, }: { value: string; onChange: (value: string) => void; placeholder?: string; count?: number; extra?: ReactNode; }) => Element
MetricCard (value): ({ title, icon, action, children, bodyClassName, scroll, scrollMax, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; bodyClassName?: string; scroll?: boolean; scrollMax?: number; }) => Element
MiniStat (value): ({ caption, value, valueClassName, className, }: { caption: ReactNode; value: ReactNode; valueClassName?: string; className?: string; }) => Element
PassphraseDialog (value): ({ isOpen, onSubmit, onCancel, hostInfo, backgroundColor, }: PassphraseDialogProps) => Element
PasswordInput (value): ForwardRefExoticComponent<PasswordInputProps & RefAttributes<HTMLInputElement>>
PluginComponent (value): ({ id, fallback, ...props }: { [prop: string]: unknown; id: string; fallback?: ReactNode; }) => ReactNode
PluginViewPlaceholder (value): ({ kind, viewId, compact, }: { kind: ViewKind; viewId: string; compact?: boolean; }) => Element
Popover (value): ({ ...props }: PopoverProps) => Element
PopoverAnchor (value): ({ ...props }: PopoverAnchorProps & RefAttributes<HTMLDivElement>) => Element
PopoverContent (value): ({ className, align, sideOffset, ...props }: PopoverContentProps & RefAttributes<HTMLDivElement>) => Element
PopoverTrigger (value): ({ ...props }: PopoverTriggerProps & RefAttributes<HTMLButtonElement>) => Element
RadialGauge (value): ({ value, label, caption, size, strokeWidth, className, colorClassName, }: RadialGaugeProps) => Element
RadialGaugeProps (type): RadialGaugeProps
RecentActivityItem (type): RecentActivityItem
RobustClipboardProvider (value): typeof RobustClipboardProvider
SSHAuthDialog (value): ({ isOpen, reason, onSubmit, onCancel, hostInfo, backgroundColor, }: SSHAuthDialogProps) => Element
ScrollArea (value): ({ className, children, ...props }: ScrollAreaProps & RefAttributes<HTMLDivElement>) => Element
ScrollBar (value): ({ className, orientation, ...props }: ScrollAreaScrollbarProps & RefAttributes<HTMLDivElement>) => Element
SectionCard (value): ({ title, icon, action, children, className, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; className?: string; }) => Element
Select (value): ({ ...props }: SelectSharedProps & { value?: string; defaultValue?: string; onValueChange?(value: string): void; }) => Element
Select2 (value): ({ className, children, value, defaultValue, onChange, disabled, placeholder, align, contentClassName, name, id, required, "aria-label": ariaLabel, ...props }: Select2Props) => Element
SelectContent (value): ({ className, children, position, ...props }: SelectContentProps & RefAttributes<HTMLDivElement>) => Element
SelectGroup (value): ({ ...props }: SelectGroupProps & RefAttributes<HTMLDivElement>) => Element
SelectItem (value): ({ className, children, ...props }: SelectItemProps & RefAttributes<HTMLDivElement>) => Element
SelectLabel (value): ({ className, ...props }: SelectLabelProps & RefAttributes<HTMLDivElement>) => Element
SelectScrollDownButton (value): ({ className, ...props }: SelectScrollDownButtonProps & RefAttributes<HTMLDivElement>) => Element
SelectScrollUpButton (value): ({ className, ...props }: SelectScrollUpButtonProps & RefAttributes<HTMLDivElement>) => Element
SelectSeparator (value): ({ className, ...props }: SelectSeparatorProps & RefAttributes<HTMLDivElement>) => Element
SelectTrigger (value): ({ className, size, children, ...props }: SelectTriggerProps & RefAttributes<HTMLButtonElement> & { size?: "default" | "sm"; }) => Element
SelectValue (value): ({ ...props }: SelectValueProps & RefAttributes<HTMLSpanElement>) => Element
Separator (value): ({ className, orientation, decorative, ...props }: SeparatorProps & RefAttributes<HTMLDivElement>) => Element
SettingRow (value): ({ label, badge, description, defaultKey, children, }: { label: string; badge?: string; description?: ReactNode; defaultKey?: string; children: ReactNode; }) => Element
Sheet (value): ({ ...props }: DialogProps) => Element
SheetClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
SheetContent (value): ({ className, children, side, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { side?: "top" | "right" | "bottom" | "left"; showCloseButton?: boolean; }) => Element
SheetDescription (value): ({ className, ...props }: DialogDescriptionProps & RefAttributes<HTMLParagraphElement>) => Element
SheetFooter (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
SheetHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
SheetTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
SheetTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
Skeleton (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderProps & RefAttributes<HTMLSpanElement>) => Element
Sparkline (value): ({ data, domain, className, colorClassName, showLastDot, height, }: SparklineProps) => Element
SparklineProps (type): SparklineProps
StatRow (value): ({ label, value, mono, className, }: { label: ReactNode; value: ReactNode; mono?: boolean; className?: string; }) => Element
Switch (value): ({ className, ...props }: SwitchProps & RefAttributes<HTMLButtonElement>) => Element
TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element
Textarea (value): ForwardRefExoticComponent<TextareaProps & RefAttributes<HTMLTextAreaElement>>
TextareaProps (type): TextareaProps
Tooltip (value): ({ ...props }: TooltipProps) => Element
TooltipContent (value): ({ className, sideOffset, children, ...props }: TooltipContentProps & RefAttributes<HTMLDivElement>) => Element
TooltipProvider (value): ({ delayDuration, ...props }: TooltipProviderProps) => Element
TooltipTrigger (value): ({ ...props }: TooltipTriggerProps & RefAttributes<HTMLButtonElement>) => Element
UptimeInfo (type): UptimeInfo
VersionInfo (type): VersionInfo
WebSocketConnectionTarget (type): WebSocketConnectionTarget
WidgetTitle (value): ({ title, icon, fallback }: WidgetTitleProps) => Element
buildOriginWsUrl (value): ({ origin, localPort, localPath, remotePath, includeJwt, }: { origin: ConnectionOrigin; localPort: number; localPath: string; remotePath: string; includeJwt?: boolean; }) => Promise<WebSocketConnectionTarget>
cn (value): (...inputs: ClassValue[]) => string
copyToClipboard (value): (text: string) => Promise<boolean>
createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger
createQuickConnectHost (value): (input: QuickConnectInput) => Host
findMatchingKeybinding (value): (e: KeyboardEvent, bindings: CustomKeybinding[]) => CustomKeybinding
getAdaptiveResourceBudget (value): (kind: AdaptiveTaskKind) => AdaptiveResourceBudget
getBasePath (value): () => string
getDatabaseHealth (value): () => Promise<Record<string, unknown>>
getDeviceId (value): () => string
getPollingEnvironmentMultiplier (value): (connection?: NetworkInformationLike) => number
getRecentActivity (value): (limit?: number) => Promise<RecentActivityItem[]>
getUptime (value): () => Promise<UptimeInfo>
getVersionInfo (value): (checkRemote?: boolean) => Promise<VersionInfo>
globalShortcutHandler (value): { current: (e: KeyboardEvent) => void; }
hydrateLocalSharedHostAuth (value): <T>(host: T) => Promise<T>
isElectron (value): () => boolean
isMacPlatform (value): () => boolean
isTabJumpHotkey (value): (e: KeyboardEvent) => boolean
linkedServerUrl (value): () => Promise<string>
markAdaptiveResourceUsed (value): (kind: AdaptiveTaskKind, action: string, now?: number) => void
pluginWsUrl (value): (pluginId: string, path: string, options?: { origin?: ConnectionOrigin; }) => Promise<WebSocketConnectionTarget>
readFromClipboard (value): () => Promise<string>
resolveConnectionOrigin (value): (host: OriginResolvableHost, options?: { defaultRemote?: boolean; }) => Promise<ConnectionOrigin>
resolveRemoteHostId (value): (syncId: string) => Promise<number>
runAdaptiveBackgroundTask (value): (kind: AdaptiveTaskKind, action: string, task: () => Promise<unknown>, options?: AdaptiveTaskOptions) => boolean
runAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePollResult>, policy: AdaptivePollingPolicy, options?: { enabled?: () => boolean; visible?: () => boolean; runImmediately?: boolean; random?: () => number; intervalMultiplier?: () => number; onError?: (error: unknown) => void; }) => () => void
runVisibleInterval (value): (tick: () => void, intervalMs: number) => () => void
useAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePollResult>, policy: AdaptivePollingPolicy, enabled?: boolean, options?: { runImmediately?: boolean; onError?: (error: unknown) => void; }) => void
useAppTheme (value): () => ThemeProviderState
useConfirmation (value): () => { isOpen: boolean; options: ConfirmationOptions; confirm: (opts: ConfirmationOptions, callback: () => void) => void; handleConfirm: () => void; handleCancel: () => void; confirmWithToast: (opts: string | ConfirmationOptions, callback?: () => void, variantOrConfirmLabel?: string, cancelLabel?: string, toastOptions?: ToastConfirmOptions) => Promise<boolean>; }
useConnectionLog (value): () => ConnectionLogContextType
useIsDefaultsEditor (value): () => boolean
useIsMobile (value): () => boolean
useOptionalConnectionLog (value): () => ConnectionLogContextType
useTabs (value): () => TabContextType
useTabsSafe (value): () => TabContextType