mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
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:
4405 files changed
+1341026
-289722
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -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",
|
||||
];
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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}"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user