mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05: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
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"id": "sample-plugin",
|
||||
"name": "Sample",
|
||||
"version": "1.0.0",
|
||||
"description": "A sample.",
|
||||
"author": {
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"category": "Productivity",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": ["kv:own", "ui:surface"],
|
||||
"contributes": {
|
||||
"permissionGroup": {
|
||||
"group": "sample-plugin",
|
||||
"permissions": ["sample-plugin.use"]
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"id": "sample.open",
|
||||
"titleKey": "sample.open",
|
||||
"handler": "open",
|
||||
"permission": "sample-plugin.nope"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,16 @@
|
||||
{
|
||||
"id": "Sample_Plugin!",
|
||||
"name": "Sample Plugin",
|
||||
"id": "Sample_Plugin",
|
||||
"name": "Sample",
|
||||
"version": "1.0.0",
|
||||
"description": "Has an id that violates the required pattern.",
|
||||
"description": "A sample.",
|
||||
"author": {
|
||||
"name": "Jane Doe"
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"category": "Productivity",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": {
|
||||
"backend": true,
|
||||
"frontend": true,
|
||||
"electron": false,
|
||||
"platforms": ["linux"]
|
||||
},
|
||||
"permissions": [],
|
||||
"sidecars": []
|
||||
"capabilities": ["kv:own", "ui:surface"]
|
||||
}
|
||||
@@ -1,21 +1,15 @@
|
||||
{
|
||||
"id": "sample-plugin",
|
||||
"name": "Sample Plugin",
|
||||
"name": "Sample",
|
||||
"version": "1.0.0",
|
||||
"description": "A sample.",
|
||||
"author": {
|
||||
"name": "Jane Doe"
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"category": "Productivity",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": {
|
||||
"backend": true,
|
||||
"frontend": true,
|
||||
"electron": false,
|
||||
"platforms": ["linux"]
|
||||
},
|
||||
"permissions": [],
|
||||
"sidecars": []
|
||||
"capabilities": ["kv:own", "ui:surface"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"id": "sample-plugin",
|
||||
"name": "Sample",
|
||||
"version": "1.0.0",
|
||||
"description": "A sample.",
|
||||
"author": {
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"category": "Productivity",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": ["kv:own", "ui:surface"],
|
||||
"contributes": {
|
||||
"tabs": [
|
||||
{
|
||||
"id": "t",
|
||||
"titleKey": "k",
|
||||
"icon": "i",
|
||||
"openFrom": ["rail"],
|
||||
"extra": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"id": "sample-plugin",
|
||||
"name": "Sample",
|
||||
"version": "1.0.0",
|
||||
"description": "A sample.",
|
||||
"author": {
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"category": "Productivity",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": ["kv:own", "ui:surface"],
|
||||
"contributes": {
|
||||
"permissions": [
|
||||
{
|
||||
"name": "admin.users.manage",
|
||||
"titleKey": "permissions.admin.users.manage.title",
|
||||
"descriptionKey": "permissions.admin.users.manage.description"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"id": "sample-plugin",
|
||||
"name": "Sample",
|
||||
"version": "1.0.0",
|
||||
"description": "A sample.",
|
||||
"author": {
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"category": "Productivity",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": ["kv:own", "ui:surface"],
|
||||
"provides": [
|
||||
{
|
||||
"service": "sample.thing",
|
||||
"version": "1.0.0",
|
||||
"permission": "sample-plugin.undeclared"
|
||||
}
|
||||
],
|
||||
"contributes": {
|
||||
"permissionGroup": {
|
||||
"group": "sample-plugin",
|
||||
"permissions": ["sample-plugin.use"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "sample-plugin",
|
||||
"name": "Sample",
|
||||
"version": "1.0.0",
|
||||
"description": "A sample.",
|
||||
"author": {
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"category": "Productivity",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": ["hosts.read"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "sample-plugin",
|
||||
"name": "Sample",
|
||||
"version": "1.0.0",
|
||||
"description": "A sample.",
|
||||
"author": {
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"category": "Nonsense",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": ["kv:own", "ui:surface"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "sample-plugin",
|
||||
"name": "Sample",
|
||||
"version": "1.0.0",
|
||||
"description": "A sample.",
|
||||
"author": {
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"category": "Productivity",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": ["kv:own", "ui:surface"],
|
||||
"sudoEverything": true
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"id": "sample-plugin",
|
||||
"name": "Sample Plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "Requests a permission that is not in the known capability list.",
|
||||
"author": {
|
||||
"name": "Jane Doe"
|
||||
},
|
||||
"license": "MIT",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": {
|
||||
"backend": true,
|
||||
"frontend": true,
|
||||
"electron": false,
|
||||
"platforms": ["linux"]
|
||||
},
|
||||
"permissions": ["credentials.read"],
|
||||
"sidecars": []
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"id": "sample-plugin",
|
||||
"name": "Sample",
|
||||
"version": "1.0.0",
|
||||
"description": "A sample.",
|
||||
"author": {
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"category": "Productivity",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": ["kv:own", "ui:surface"],
|
||||
"contributes": {
|
||||
"permissions": [
|
||||
{
|
||||
"name": "use",
|
||||
"titleKey": "permissions.use.title",
|
||||
"descriptionKey": "permissions.use.description"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"id": "sample.open",
|
||||
"titleKey": "sample.open",
|
||||
"handler": "open",
|
||||
"permission": "sample-plugin.use"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"id": "sample-plugin",
|
||||
"name": "Sample",
|
||||
"version": "1.0.0",
|
||||
"description": "A sample.",
|
||||
"author": {
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"category": "Productivity",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": ["kv:own", "ui:surface"],
|
||||
"provides": [
|
||||
{
|
||||
"service": "sample.thing",
|
||||
"version": "1.0.0",
|
||||
"permission": "sample-plugin.use"
|
||||
}
|
||||
],
|
||||
"contributes": {
|
||||
"permissions": [
|
||||
{
|
||||
"name": "use",
|
||||
"titleKey": "permissions.use.title",
|
||||
"descriptionKey": "permissions.use.description"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,16 @@
|
||||
{
|
||||
"id": "sample-plugin",
|
||||
"name": "Sample Plugin",
|
||||
"name": "Sample",
|
||||
"version": "1.0.0",
|
||||
"description": "A sample plugin used for schema validation tests.",
|
||||
"description": "A sample.",
|
||||
"author": {
|
||||
"name": "Jane Doe",
|
||||
"url": "https://example.com"
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/example/sample-plugin",
|
||||
"category": "Productivity",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": {
|
||||
"backend": true,
|
||||
"frontend": true,
|
||||
"electron": false,
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
},
|
||||
"permissions": ["hosts.read", "ui.tab", "ui.rail"],
|
||||
"contributes": {
|
||||
"tabs": [
|
||||
{
|
||||
"id": "sample-plugin-tab",
|
||||
"titleKey": "samplePlugin.tab.title",
|
||||
"icon": "Box",
|
||||
"openFrom": ["rail", "palette"]
|
||||
}
|
||||
],
|
||||
"settingsPanel": {
|
||||
"titleKey": "samplePlugin.settings.title"
|
||||
},
|
||||
"apiPrefix": "sample"
|
||||
},
|
||||
"dependencies": {
|
||||
"plugins": {
|
||||
"other-plugin": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"sidecars": []
|
||||
"capabilities": ["kv:own", "ui:surface"]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Applies every workspace plugin's dependency patches after npm install, so
|
||||
* the Vite dev server serves the same patched libraries a build bundles.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const cli = path.join(root, "packages", "plugin-sdk", "cli", "index.mjs");
|
||||
const plugins = path.join(root, "plugins");
|
||||
|
||||
for (const id of fs.readdirSync(plugins)) {
|
||||
const dir = path.join(plugins, id);
|
||||
const pkgPath = path.join(dir, "package.json");
|
||||
if (!fs.existsSync(pkgPath)) continue;
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
||||
if (!pkg.termix?.patches?.length && !fs.existsSync(path.join(dir, "patches")))
|
||||
continue;
|
||||
const result = spawnSync(process.execPath, [cli, "patch"], {
|
||||
cwd: dir,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Stages the plugins listed in docker/bundled-plugins.json in dist/plugins.
|
||||
*
|
||||
* getBundledPluginsDir() in src/backend/plugins/paths.ts resolves
|
||||
* dist/backend/backend/plugins -> dist/plugins, which is where this writes.
|
||||
* A workspace plugin is built from plugins/<id>; a tmxplug plugin is
|
||||
* downloaded (or read from a path), checked against its pinned sha256 and
|
||||
* unpacked. Either way only what a server needs at runtime lands there.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const {
|
||||
loadBundledPlugins,
|
||||
fetchArtifact,
|
||||
extractArtifact,
|
||||
} = require("./lib/bundled-plugins.cjs");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const source = path.join(root, "plugins");
|
||||
const destination = path.join(root, "dist", "plugins");
|
||||
const cli = path.join(root, "packages", "plugin-sdk", "cli", "index.mjs");
|
||||
|
||||
// Same list as a .tmxplug, see packages/plugin-sdk/cli/lib/tmxplug.mjs.
|
||||
const SHIPPED = [
|
||||
"manifest.json",
|
||||
"dist",
|
||||
"locales",
|
||||
"migrations",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"icon.svg",
|
||||
];
|
||||
|
||||
function stageWorkspace(id) {
|
||||
const pluginDir = path.join(source, id);
|
||||
|
||||
execFileSync(process.execPath, [cli, "build"], {
|
||||
cwd: pluginDir,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (!fs.existsSync(path.join(pluginDir, "dist", "backend.js"))) {
|
||||
throw new Error(`${id} produced no dist/backend.js`);
|
||||
}
|
||||
|
||||
const outDir = path.join(destination, id);
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
for (const entry of SHIPPED) {
|
||||
const from = path.join(pluginDir, entry);
|
||||
if (!fs.existsSync(from)) continue;
|
||||
fs.cpSync(from, path.join(outDir, entry), { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const plugins = loadBundledPlugins(root);
|
||||
|
||||
fs.rmSync(destination, { recursive: true, force: true });
|
||||
|
||||
for (const plugin of plugins) {
|
||||
if (plugin.source === "workspace") {
|
||||
stageWorkspace(plugin.id);
|
||||
} else {
|
||||
const buffer = await fetchArtifact(plugin, root);
|
||||
await extractArtifact(
|
||||
buffer,
|
||||
path.join(destination, plugin.id),
|
||||
plugin.id,
|
||||
);
|
||||
console.log(`unpacked ${plugin.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Bundled ${plugins.length} plugin(s): ${plugins.map((p) => p.id).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error?.message ?? error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Builds the two pieces of the plugin SDK that come from core:
|
||||
*
|
||||
* dist/host/ui.js @termix/plugin-sdk/ui, the shell's UI kit
|
||||
* dist/host/testing-host.js what renderWithApp() renders plugins into
|
||||
*
|
||||
* Inside Termix the page's import map points @termix/plugin-sdk/ui at the
|
||||
* shell's own copy, so ui.js is never loaded there. It exists so a plugin in
|
||||
* its own repo can typecheck and run its tests without a Termix checkout.
|
||||
*
|
||||
* One esbuild run with code splitting, so both entries share one copy of the
|
||||
* registries: a test host with its own registries would never see what the
|
||||
* plugin's components registered.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import * as esbuild from "esbuild";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const outDir = path.join(root, "packages", "plugin-sdk", "dist", "host");
|
||||
|
||||
// Shared with the plugin, so never a second copy. Same list the page's import
|
||||
// map provides, plus the test library the host renders with.
|
||||
const EXTERNALS = [
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
"react-dom/client",
|
||||
"i18next",
|
||||
"react-i18next",
|
||||
"sonner",
|
||||
"@termix/plugin-sdk/frontend",
|
||||
"@testing-library/react",
|
||||
];
|
||||
|
||||
fs.rmSync(outDir, { recursive: true, force: true });
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: {
|
||||
ui: path.join(root, "src/ui/plugin-host/sdk-ui.ts"),
|
||||
"testing-host": path.join(root, "src/ui/plugin-host/testing-host.tsx"),
|
||||
},
|
||||
outdir: outDir,
|
||||
bundle: true,
|
||||
splitting: true,
|
||||
format: "esm",
|
||||
platform: "browser",
|
||||
target: "es2022",
|
||||
jsx: "automatic",
|
||||
logLevel: "warning",
|
||||
external: EXTERNALS,
|
||||
alias: {
|
||||
"@/types": path.join(root, "src/types"),
|
||||
"@": path.join(root, "src/ui"),
|
||||
},
|
||||
loader: {
|
||||
".css": "empty",
|
||||
".svg": "dataurl",
|
||||
".png": "dataurl",
|
||||
".woff": "empty",
|
||||
".woff2": "empty",
|
||||
".ttf": "empty",
|
||||
},
|
||||
// import.meta.glob is Vite's; outside Termix the host ships English only,
|
||||
// so the translated locale files it would list are simply none.
|
||||
banner: { js: "const __termixNoGlob = () => ({});" },
|
||||
define: {
|
||||
"import.meta.glob": "__termixNoGlob",
|
||||
"import.meta.env.DEV": "false",
|
||||
"import.meta.env.PROD": "true",
|
||||
"import.meta.env.MODE": '"production"',
|
||||
"import.meta.env.BASE_URL": '"/"',
|
||||
"import.meta.env":
|
||||
'{"DEV":false,"PROD":true,"MODE":"production","SSR":false,"BASE_URL":"/"}',
|
||||
},
|
||||
});
|
||||
|
||||
// Types for @termix/plugin-sdk/ui: the declarations tsc emits for sdk-ui.ts
|
||||
// and everything it reaches, with core's "@/" paths made relative.
|
||||
const typesDir = path.join(outDir, "types");
|
||||
const tsc = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(root, "node_modules", "typescript", "bin", "tsc"),
|
||||
"-p",
|
||||
path.join(root, "tsconfig.sdk-ui.json"),
|
||||
"--outDir",
|
||||
typesDir,
|
||||
],
|
||||
{ cwd: root, stdio: "inherit" },
|
||||
);
|
||||
if (tsc.status !== 0) process.exit(tsc.status ?? 1);
|
||||
// tsc follows @termix/plugin-sdk/frontend into the SDK's source; plugins get
|
||||
// those types from the package itself.
|
||||
fs.rmSync(path.join(typesDir, "packages"), { recursive: true, force: true });
|
||||
|
||||
const srcTypes = path.join(typesDir, "src");
|
||||
function rewrite(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const file = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
rewrite(file);
|
||||
continue;
|
||||
}
|
||||
if (!entry.name.endsWith(".d.ts")) continue;
|
||||
const text = fs
|
||||
.readFileSync(file, "utf8")
|
||||
.replace(
|
||||
/(["'])@\/(types(?=[/"'])|)([^"']*)\1/g,
|
||||
(_match, quote, types, rest) => {
|
||||
const target = types
|
||||
? path.join(srcTypes, "types", rest.replace(/^\//, ""))
|
||||
: path.join(srcTypes, "ui", rest);
|
||||
let relative = path
|
||||
.relative(path.dirname(file), target)
|
||||
.replaceAll("\\", "/")
|
||||
.replace(/\.tsx?$/, "");
|
||||
if (!relative.startsWith(".")) relative = `./${relative}`;
|
||||
return `${quote}${relative}${quote}`;
|
||||
},
|
||||
);
|
||||
fs.writeFileSync(file, text);
|
||||
}
|
||||
}
|
||||
rewrite(srcTypes);
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, "ui.d.ts"),
|
||||
'export * from "./types/src/ui/plugin-host/sdk-ui.js";\n',
|
||||
);
|
||||
|
||||
// The design tokens termix-plugin build compiles a plugin's classes against.
|
||||
fs.copyFileSync(
|
||||
path.join(root, "src/ui/theme.css"),
|
||||
path.join(outDir, "theme.css"),
|
||||
);
|
||||
|
||||
console.log(`Built the SDK host modules into ${path.relative(root, outDir)}`);
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Writes src/backend/tests/fixtures/upgrade/{db.sqlite,fixture.env,files/}: a 2.8 install's
|
||||
* data directory with every feature table filled. The schema is 2.8's own
|
||||
* dump, the rows come from rows.ts, and the columns 2.8 encrypted are
|
||||
* encrypted the way 2.8 did it (a v3 system-wrapped data key per user, then
|
||||
* FieldCrypto per field), so the upgrade test has to really decrypt them.
|
||||
*
|
||||
* Usage: npx tsx scripts/build-upgrade-fixture.ts
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import Database from "better-sqlite3";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { FieldCrypto } from "../src/backend/utils/field-crypto.js";
|
||||
import {
|
||||
ENCRYPTED_COLUMNS,
|
||||
FIXTURE_PASSWORD,
|
||||
RECORDING_FILES,
|
||||
ROWS,
|
||||
type Row,
|
||||
} from "../src/backend/tests/fixtures/upgrade/rows.js";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const FIXTURES = path.join(ROOT, "src/backend/tests/fixtures");
|
||||
const OUT = path.join(FIXTURES, "upgrade");
|
||||
|
||||
const hex = (label: string) =>
|
||||
crypto
|
||||
.createHash("sha256")
|
||||
.update(`termix-d4-fixture:${label}`)
|
||||
.digest("hex");
|
||||
|
||||
const ENV = {
|
||||
JWT_SECRET: hex("jwt") + hex("jwt-2"),
|
||||
DATABASE_KEY: hex("database"),
|
||||
ENCRYPTION_KEY: hex("encryption"),
|
||||
INTERNAL_AUTH_TOKEN: hex("internal"),
|
||||
};
|
||||
const masterKey = Buffer.from(ENV.ENCRYPTION_KEY, "hex");
|
||||
|
||||
/** UserKeyManager's v3 wrap, unchanged since 2.8. */
|
||||
function wrapDek(userId: string, dek: Buffer): string {
|
||||
const wrapKey = Buffer.from(
|
||||
crypto.hkdfSync(
|
||||
"sha256",
|
||||
masterKey,
|
||||
Buffer.alloc(0),
|
||||
`termix:dek-wrap:v3:${userId}`,
|
||||
32,
|
||||
),
|
||||
);
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv("aes-256-gcm", wrapKey, iv);
|
||||
cipher.setAAD(Buffer.from(userId, "utf8"));
|
||||
const ct = Buffer.concat([cipher.update(dek), cipher.final()]);
|
||||
return JSON.stringify({
|
||||
v: 3,
|
||||
alg: "aes-256-gcm",
|
||||
iv: iv.toString("base64"),
|
||||
ct: ct.toString("base64"),
|
||||
tag: cipher.getAuthTag().toString("base64"),
|
||||
createdAt: "2026-05-01T10:00:00.000Z",
|
||||
});
|
||||
}
|
||||
|
||||
const deks = new Map<string, Buffer>();
|
||||
const dekFor = (userId: string) => {
|
||||
let dek = deks.get(userId);
|
||||
if (!dek) {
|
||||
dek = Buffer.from(hex(`dek:${userId}`), "hex");
|
||||
deks.set(userId, dek);
|
||||
}
|
||||
return dek;
|
||||
};
|
||||
|
||||
function prepareRow(table: string, row: Row): Row {
|
||||
const out: Row = { ...row };
|
||||
if (table === "users" && out.password_hash === undefined) {
|
||||
out.password_hash = bcrypt.hashSync(FIXTURE_PASSWORD, 10);
|
||||
}
|
||||
for (const [encTable, column, field, userColumn] of ENCRYPTED_COLUMNS) {
|
||||
if (encTable !== table || typeof out[column] !== "string" || !out[column])
|
||||
continue;
|
||||
const userId = String(out[userColumn]);
|
||||
const recordId = table === "users" ? userId : String(out.id);
|
||||
out[column] = FieldCrypto.encryptField(
|
||||
out[column] as string,
|
||||
dekFor(userId),
|
||||
recordId,
|
||||
field,
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function insert(sqlite: Database.Database, table: string, row: Row): void {
|
||||
const keys = Object.keys(row);
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO "${table}" (${keys.map((key) => `"${key}"`).join(", ")}) VALUES (${keys.map(() => "?").join(", ")})`,
|
||||
)
|
||||
.run(...keys.map((key) => row[key]));
|
||||
}
|
||||
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
const dbPath = path.join(OUT, "db.sqlite");
|
||||
fs.rmSync(dbPath, { force: true });
|
||||
|
||||
const sqlite = new Database(dbPath);
|
||||
sqlite.pragma("journal_mode = DELETE");
|
||||
sqlite.exec(
|
||||
fs.readFileSync(path.join(FIXTURES, "sqlite-2.8-schema.sql"), "utf8"),
|
||||
);
|
||||
sqlite.pragma("foreign_keys = ON");
|
||||
|
||||
sqlite.transaction(() => {
|
||||
for (const [table, rows] of Object.entries(ROWS)) {
|
||||
for (const row of rows) insert(sqlite, table, prepareRow(table, row));
|
||||
}
|
||||
for (const user of ROWS.users) {
|
||||
const userId = String(user.id);
|
||||
insert(sqlite, "settings", {
|
||||
key: `user_dek_v3_${userId}`,
|
||||
value: wrapDek(userId, dekFor(userId)),
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
const violations = sqlite.pragma("foreign_key_check") as unknown[];
|
||||
if (violations.length > 0) {
|
||||
throw new Error(`Foreign key violations: ${JSON.stringify(violations)}`);
|
||||
}
|
||||
sqlite.exec("VACUUM");
|
||||
sqlite.close();
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(OUT, "fixture.env"),
|
||||
Object.entries(ENV)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join("\n") + "\n",
|
||||
);
|
||||
|
||||
// The recording files 2.8 left in DATA_DIR, which the rows point at.
|
||||
const files = path.join(OUT, "files");
|
||||
fs.rmSync(files, { recursive: true, force: true });
|
||||
for (const [kind, relative] of Object.entries(RECORDING_FILES)) {
|
||||
const file = path.join(files, relative);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
`d4 ${kind} recording
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Wrote ${path.relative(ROOT, dbPath)} and fixture.env`);
|
||||
@@ -0,0 +1,161 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import * as tar from "tar";
|
||||
import {
|
||||
parseBundledPlugins,
|
||||
loadBundledPlugins,
|
||||
fetchArtifact,
|
||||
extractArtifact,
|
||||
} from "./lib/bundled-plugins.cjs";
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
function tempDir(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-bundled-"));
|
||||
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanups.length) cleanups.pop()?.();
|
||||
});
|
||||
|
||||
const SHA = "a".repeat(64);
|
||||
|
||||
async function artifactFor(id: string): Promise<Buffer> {
|
||||
const src = tempDir();
|
||||
fs.writeFileSync(path.join(src, "manifest.json"), JSON.stringify({ id }));
|
||||
fs.mkdirSync(path.join(src, "dist"));
|
||||
fs.writeFileSync(path.join(src, "dist", "backend.js"), "export {}\n");
|
||||
const file = path.join(tempDir(), `${id}.tmxplug`);
|
||||
await tar.c({ gzip: true, file, cwd: src }, ["manifest.json", "dist"]);
|
||||
return fs.readFileSync(file);
|
||||
}
|
||||
|
||||
const sha256 = (buffer: Buffer) =>
|
||||
crypto.createHash("sha256").update(buffer).digest("hex");
|
||||
|
||||
describe("parseBundledPlugins", () => {
|
||||
it("accepts workspace and tmxplug entries", () => {
|
||||
const { plugins, problems } = parseBundledPlugins(
|
||||
{
|
||||
plugins: [
|
||||
{ id: "one", source: "workspace" },
|
||||
{
|
||||
id: "two",
|
||||
source: "tmxplug",
|
||||
url: "https://x/two.tmxplug",
|
||||
sha256: SHA,
|
||||
},
|
||||
{
|
||||
id: "three",
|
||||
source: "tmxplug",
|
||||
path: "vendor/three.tmxplug",
|
||||
sha256: SHA,
|
||||
},
|
||||
],
|
||||
},
|
||||
["one"],
|
||||
);
|
||||
expect(problems).toEqual([]);
|
||||
expect(plugins.map((p: { id: string }) => p.id)).toEqual([
|
||||
"one",
|
||||
"two",
|
||||
"three",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports a plugins folder nobody listed", () => {
|
||||
const { problems } = parseBundledPlugins(
|
||||
{ plugins: [{ id: "one", source: "workspace" }] },
|
||||
["one", "stray"],
|
||||
);
|
||||
expect(problems).toEqual([
|
||||
"plugins/stray is not listed in docker/bundled-plugins.json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports bad entries", () => {
|
||||
const { problems } = parseBundledPlugins(
|
||||
{
|
||||
plugins: [
|
||||
{ id: "gone", source: "workspace" },
|
||||
{ id: "nohash", source: "tmxplug", url: "https://x" },
|
||||
{ id: "plain", source: "tmxplug", url: "http://x", sha256: SHA },
|
||||
{ id: "still-here", source: "tmxplug", path: "a", sha256: SHA },
|
||||
{ id: "gone", source: "workspace" },
|
||||
{ id: "odd", source: "npm" },
|
||||
],
|
||||
},
|
||||
["still-here"],
|
||||
);
|
||||
expect(problems).toEqual([
|
||||
"gone is a workspace plugin but plugins/gone does not exist",
|
||||
"nohash needs a lowercase hex sha256",
|
||||
"plain needs exactly one of an https url or a path",
|
||||
"still-here comes from a .tmxplug, so delete plugins/still-here",
|
||||
"gone is listed twice",
|
||||
'odd has an unknown source "npm"',
|
||||
]);
|
||||
});
|
||||
|
||||
it("matches the plugins folder in this repo", () => {
|
||||
const root = path.resolve(__dirname, "..");
|
||||
expect(() => loadBundledPlugins(root)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tmxplug entries", () => {
|
||||
it("unpacks an artifact that matches its pinned sha256", async () => {
|
||||
const root = tempDir();
|
||||
const buffer = await artifactFor("demo");
|
||||
fs.writeFileSync(path.join(root, "demo.tmxplug"), buffer);
|
||||
|
||||
const fetched = await fetchArtifact(
|
||||
{ id: "demo", path: "demo.tmxplug", sha256: sha256(buffer) },
|
||||
root,
|
||||
);
|
||||
const out = path.join(root, "dist", "plugins", "demo");
|
||||
await extractArtifact(fetched, out, "demo");
|
||||
expect(fs.existsSync(path.join(out, "dist", "backend.js"))).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses an artifact whose sha256 differs", async () => {
|
||||
const root = tempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(root, "demo.tmxplug"),
|
||||
await artifactFor("demo"),
|
||||
);
|
||||
await expect(
|
||||
fetchArtifact({ id: "demo", path: "demo.tmxplug", sha256: SHA }, root),
|
||||
).rejects.toThrow(/bundled-plugins.json pins/);
|
||||
});
|
||||
|
||||
it("downloads once and reuses the cache", async () => {
|
||||
const root = tempDir();
|
||||
const buffer = await artifactFor("demo");
|
||||
let calls = 0;
|
||||
const fakeFetch = async () => {
|
||||
calls++;
|
||||
return new Response(buffer);
|
||||
};
|
||||
const entry = {
|
||||
id: "demo",
|
||||
url: "https://example.test/demo.tmxplug",
|
||||
sha256: sha256(buffer),
|
||||
};
|
||||
await fetchArtifact(entry, root, fakeFetch);
|
||||
await fetchArtifact(entry, root, fakeFetch);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("refuses an artifact for a different id", async () => {
|
||||
const out = path.join(tempDir(), "demo");
|
||||
await expect(
|
||||
extractArtifact(await artifactFor("other"), out, "demo"),
|
||||
).rejects.toThrow(/is for "other"/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Core knows no plugin by name.
|
||||
*
|
||||
* Fails when anything under src/ imports from plugins/, or spells a plugin id:
|
||||
* a bare literal ("docker"), a plugin route ("/plugin-api/docker/..."), or an
|
||||
* action, slot or permission id that starts with one ("docker.open"). The
|
||||
* shell (src/ui) also may not spell a view a plugin owns (a tab, panel or
|
||||
* dashboard card id from a manifest). What core needs from a plugin comes
|
||||
* through the registries instead. Regex literals are read too, so
|
||||
* /^\/plugin-api\/docker/ counts the same as "/plugin-api/docker".
|
||||
*
|
||||
* Ids come from every plugin manifest and from docker/bundled-plugins.json, so
|
||||
* the check still means something once plugins live in their own repos. An
|
||||
* action, slot or extension point id a plugin owns ("terminal.open") counts
|
||||
* too, whatever its prefix. electron/ and vite.config.ts are read as well.
|
||||
*
|
||||
* src/backend/tests, src/ui/tests and src/ui/locales are exempt, and so is
|
||||
* src/backend/upgrade/: the one-time
|
||||
* 2.8 to 2.9 data moves have to name the plugin each piece of data moves to,
|
||||
* the same way LEGACY_TABLE_OWNERS in the SDK names the plugin that adopts
|
||||
* each legacy table.
|
||||
*
|
||||
* One plugin id is also an SSH term: "totp" is the one-time-code prompt kind
|
||||
* in keyboard-interactive auth, which the connect pipeline in
|
||||
* src/backend/hosts/connect/ classifies. It is not the login plugin.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const { owners } = require("./check-plugin-uses.cjs");
|
||||
const SSH_TERMS = new Set(["totp"]);
|
||||
|
||||
function paths(root) {
|
||||
const src = path.join(root, "src");
|
||||
return {
|
||||
root,
|
||||
src,
|
||||
ui: path.join(src, "ui"),
|
||||
plugins: path.join(root, "plugins"),
|
||||
bundled: path.join(root, "docker", "bundled-plugins.json"),
|
||||
extra: [path.join(root, "electron"), path.join(root, "vite.config.ts")],
|
||||
exempt: [
|
||||
path.join(src, "backend", "upgrade"),
|
||||
path.join(src, "backend", "tests"),
|
||||
path.join(src, "ui", "tests"),
|
||||
path.join(src, "ui", "locales"),
|
||||
],
|
||||
sshConnect: path.join(src, "backend", "hosts", "connect"),
|
||||
};
|
||||
}
|
||||
|
||||
function manifests(dir) {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(dir, entry.name, "manifest.json"))
|
||||
.filter((file) => fs.existsSync(file))
|
||||
.map((file) => JSON.parse(fs.readFileSync(file, "utf8")));
|
||||
}
|
||||
|
||||
function names(pluginsDir, bundledFile) {
|
||||
const ids = new Set();
|
||||
const views = new Set();
|
||||
if (bundledFile && fs.existsSync(bundledFile)) {
|
||||
for (const entry of JSON.parse(fs.readFileSync(bundledFile, "utf8"))
|
||||
.plugins ?? []) {
|
||||
if (typeof entry?.id === "string") ids.add(entry.id);
|
||||
}
|
||||
}
|
||||
for (const manifest of manifests(pluginsDir)) {
|
||||
ids.add(manifest.id);
|
||||
const contributes = manifest.contributes ?? {};
|
||||
for (const list of [
|
||||
contributes.tabs,
|
||||
contributes.panels,
|
||||
contributes.dashboardCards,
|
||||
]) {
|
||||
for (const view of list ?? []) views.add(view.id);
|
||||
}
|
||||
}
|
||||
return { ids, views };
|
||||
}
|
||||
|
||||
function walk(dir, exempt, out) {
|
||||
if (exempt.includes(dir) || !fs.existsSync(dir)) return out;
|
||||
if (fs.statSync(dir).isFile()) {
|
||||
out.push(dir);
|
||||
return out;
|
||||
}
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "node_modules") continue;
|
||||
walk(full, exempt, out);
|
||||
} else if (/\.(tsx?|mjs|cjs|jsx?)$/.test(entry.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every string literal in a file, template literals included. */
|
||||
function literals(source) {
|
||||
const out = [];
|
||||
const pattern =
|
||||
/"((?:[^"\\\n]|\\.)*)"|'((?:[^'\\\n]|\\.)*)'|`((?:[^`\\]|\\.)*)`/g;
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
out.push(match[1] ?? match[2] ?? match[3] ?? "");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every regex literal in a file, with escaped slashes undone, so a pattern
|
||||
* reads the way the path it matches does.
|
||||
*/
|
||||
function regexLiterals(source) {
|
||||
const out = [];
|
||||
const pattern =
|
||||
/(^|[=(,:[!&|?{};]|\breturn)\s*\/((?:[^/\\\n[]|\\.|\[(?:[^\]\\\n]|\\.)*\])+)\/[dgimsuyv]*/gm;
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
const body = match[2];
|
||||
// A comment, not a pattern.
|
||||
if (body.startsWith("/") || body.startsWith("*")) continue;
|
||||
out.push(body.replace(/\\(.)/g, "$1"));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function scan(root = path.resolve(__dirname, "..")) {
|
||||
const { src, ui, plugins, bundled, extra, exempt, sshConnect } = paths(root);
|
||||
const { ids, views } = names(plugins, bundled);
|
||||
const owned = fs.existsSync(plugins) ? owners(plugins) : new Map();
|
||||
const found = {};
|
||||
const add = (file, what) => {
|
||||
const key = path.relative(root, file).replaceAll("\\", "/");
|
||||
(found[key] ??= new Set()).add(what);
|
||||
};
|
||||
|
||||
const files = walk(src, exempt, []);
|
||||
for (const target of extra) walk(target, exempt, files);
|
||||
for (const file of files) {
|
||||
// A line marked "plugin-id-ok" names a plugin on purpose, such as a key
|
||||
// an older release stored; the marker has to say why.
|
||||
const source = fs
|
||||
.readFileSync(file, "utf8")
|
||||
.split("\n")
|
||||
.filter((line) => !/plugin-id-ok: \S/.test(line))
|
||||
.join("\n");
|
||||
const inShell = file.startsWith(ui + path.sep);
|
||||
const inConnect = file.startsWith(sshConnect + path.sep);
|
||||
for (const match of source.matchAll(
|
||||
/(?:from|import)\s*\(?\s*["']([^"']+)["']/g,
|
||||
)) {
|
||||
const target = match[1].startsWith(".")
|
||||
? path.resolve(path.dirname(file), match[1])
|
||||
: "";
|
||||
if (target.startsWith(plugins + path.sep)) {
|
||||
add(file, `import ${match[1]}`);
|
||||
}
|
||||
}
|
||||
for (const text of literals(source)) {
|
||||
const sshTerm = inConnect && SSH_TERMS.has(text);
|
||||
if ((ids.has(text) && !sshTerm) || (inShell && views.has(text))) {
|
||||
add(file, text);
|
||||
}
|
||||
for (const route of text.matchAll(
|
||||
/\/plugin-(?:api|ws|assets)\/([a-z0-9-]+)/g,
|
||||
)) {
|
||||
if (ids.has(route[1])) add(file, route[0]);
|
||||
}
|
||||
const prefix = /^([a-z][a-z0-9-]*)\.[a-zA-Z]/.exec(text);
|
||||
if (prefix && ids.has(prefix[1])) add(file, text);
|
||||
if (owned.has(text)) add(file, `${text} (owned by ${owned.get(text)})`);
|
||||
}
|
||||
for (const pattern of regexLiterals(source)) {
|
||||
for (const route of pattern.matchAll(
|
||||
/\/plugin-(?:api|ws|assets)\/([a-z0-9-]+)/g,
|
||||
)) {
|
||||
if (ids.has(route[1])) add(file, `/${pattern}/`);
|
||||
}
|
||||
const sshTerm = inConnect && SSH_TERMS.has(pattern);
|
||||
if (ids.has(pattern.replace(/^\^|\$$/g, "")) && !sshTerm) {
|
||||
add(file, `/${pattern}/`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(found).map(([file, set]) => [file, [...set].sort()]),
|
||||
);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const found = scan();
|
||||
const entries = Object.entries(found);
|
||||
if (process.argv.includes("--list")) {
|
||||
console.log(JSON.stringify(found, null, 2));
|
||||
return;
|
||||
}
|
||||
if (entries.length > 0) {
|
||||
console.error("Core names plugins it should reach through a registry:");
|
||||
for (const [file, list] of entries) {
|
||||
console.error(` ${file}: ${list.join(", ")}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = { scan, regexLiterals };
|
||||
@@ -0,0 +1,114 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { scan, regexLiterals } = require("./check-core-plugin-ids.cjs") as {
|
||||
scan: (root: string) => Record<string, string[]>;
|
||||
regexLiterals: (source: string) => string[];
|
||||
};
|
||||
|
||||
let root: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) fs.rmSync(root, { recursive: true, force: true });
|
||||
root = null;
|
||||
});
|
||||
|
||||
function fixture(files: Record<string, string>): string {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "core-plugin-ids-"));
|
||||
const all = {
|
||||
"plugins/docker/manifest.json": JSON.stringify({
|
||||
id: "docker",
|
||||
contributes: { tabs: [{ id: "docker-tab" }] },
|
||||
}),
|
||||
...files,
|
||||
};
|
||||
for (const [file, content] of Object.entries(all)) {
|
||||
const full = path.join(root, file);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, content);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("regexLiterals", () => {
|
||||
it("reads patterns with their slashes unescaped, not divisions or comments", () => {
|
||||
const source = [
|
||||
"if (/^\\/plugin-api\\/snippets/.test(p)) return x / 2;",
|
||||
"const r = a.match(/[a/b]c/g); // not /here/",
|
||||
"return /docker/;",
|
||||
].join("\n");
|
||||
expect(regexLiterals(source)).toEqual([
|
||||
"^/plugin-api/snippets",
|
||||
"[a/b]c",
|
||||
"docker",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scan", () => {
|
||||
it("passes core code that names no plugin", () => {
|
||||
const dir = fixture({
|
||||
"src/backend/ok.ts": 'const route = "/plugin-api/" + id;\n',
|
||||
});
|
||||
expect(scan(dir)).toEqual({});
|
||||
});
|
||||
|
||||
it("fails a plugin route in a string or a regex literal", () => {
|
||||
const dir = fixture({
|
||||
"src/backend/string.ts": 'const a = "/plugin-api/docker/list";\n',
|
||||
"src/backend/regex.ts":
|
||||
"const b = /^\\/plugin-api\\/docker(\\/|$)/.test(url);\n",
|
||||
});
|
||||
expect(scan(dir)).toEqual({
|
||||
"src/backend/regex.ts": ["/^/plugin-api/docker(/|$)/"],
|
||||
"src/backend/string.ts": ["/plugin-api/docker"],
|
||||
});
|
||||
});
|
||||
|
||||
it("fails a bare id, and a view id in the shell", () => {
|
||||
const dir = fixture({
|
||||
"src/backend/id.ts": 'if (pluginId === "docker") {}\n',
|
||||
"src/ui/view.ts": 'open("docker-tab");\n',
|
||||
"src/ui/tests/skip.ts": 'open("docker");\n',
|
||||
});
|
||||
expect(scan(dir)).toEqual({
|
||||
"src/backend/id.ts": ["docker"],
|
||||
"src/ui/view.ts": ["docker-tab"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("scan beyond src with plugins elsewhere", () => {
|
||||
it("knows the bundled ids even when plugins/ is empty", () => {
|
||||
const dir = fixture({
|
||||
"docker/bundled-plugins.json": JSON.stringify({
|
||||
plugins: [{ id: "tunnels", source: "workspace" }],
|
||||
}),
|
||||
"src/ui/x.ts": 'const route = "/plugin-ws/tunnels/c2s";\n',
|
||||
});
|
||||
fs.rmSync(path.join(dir, "plugins"), { recursive: true, force: true });
|
||||
expect(scan(dir)["src/ui/x.ts"]).toContain("/plugin-ws/tunnels");
|
||||
});
|
||||
|
||||
it("reads electron and catches an action a plugin owns", () => {
|
||||
const dir = fixture({
|
||||
"plugins/docker/src/frontend/index.tsx":
|
||||
'app.registerAction("terminal.open", fn);\n',
|
||||
"electron/main.cjs": 'invoke("terminal.open");\n',
|
||||
});
|
||||
expect(scan(dir)["electron/main.cjs"]).toEqual([
|
||||
"terminal.open (owned by docker)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips a line marked plugin-id-ok with a reason", () => {
|
||||
const dir = fixture({
|
||||
"src/types/a.ts": 'const legacy = "docker"; // plugin-id-ok: 2.8 key\n',
|
||||
});
|
||||
expect(scan(dir)).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Keeps the nginx CSP in step with the plugin import map.
|
||||
*
|
||||
* index.html carries an inline <script type="importmap"> so plugin bundles
|
||||
* share the shell's React. The CSP only allows 'self' scripts, so the map is
|
||||
* allowed by its hash. The map's text is deterministic (see
|
||||
* scripts/lib/plugin-import-map.mjs), so its hash only changes when the list
|
||||
* of shared modules does, e.g. when a plugin starts importing another core
|
||||
* module. This fails lint until the nginx configs carry the new hash.
|
||||
*
|
||||
* node scripts/check-importmap-csp.cjs check
|
||||
* node scripts/check-importmap-csp.cjs --write update the configs
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
const CONFIGS = ["docker/nginx.conf", "docker/nginx-https.conf"];
|
||||
const SCRIPT_SRC = /script-src ([^;"]*)/;
|
||||
|
||||
async function main() {
|
||||
const { sharedModules, productionImportMap, importMapCspHash } =
|
||||
await import("./lib/plugin-import-map.mjs");
|
||||
const expected = importMapCspHash(productionImportMap(sharedModules()));
|
||||
const write = process.argv.includes("--write");
|
||||
const problems = [];
|
||||
|
||||
for (const relative of CONFIGS) {
|
||||
const file = path.join(ROOT, relative);
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
let changed = false;
|
||||
const next = source.replace(
|
||||
new RegExp(SCRIPT_SRC.source, "g"),
|
||||
(match, sources) => {
|
||||
const kept = sources
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.filter((token) => !token.startsWith("'sha256-"));
|
||||
const updated = `script-src ${[...kept, expected].join(" ")}`;
|
||||
if (updated !== match) changed = true;
|
||||
return updated;
|
||||
},
|
||||
);
|
||||
if (!SCRIPT_SRC.test(source)) {
|
||||
problems.push(`${relative}: no script-src directive`);
|
||||
} else if (changed) {
|
||||
if (write) fs.writeFileSync(file, next);
|
||||
else problems.push(`${relative}: script-src does not allow ${expected}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
for (const problem of problems) console.error(problem);
|
||||
console.error(
|
||||
"The plugin import map changed. Run: node scripts/check-importmap-csp.cjs --write",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (write) console.log(`import map CSP hash: ${expected}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* A plugin's strings live in its own locales/en.json.
|
||||
*
|
||||
* A plugin's i18next namespace falls back to core's, so a plugin can use
|
||||
* `common.*` without copying it. Any other key a plugin uses has to be in the
|
||||
* plugin's own en.json: one that only resolves through the fallback breaks
|
||||
* the moment core renames or drops it, and core cannot tell it is in use.
|
||||
*
|
||||
* Reads t("...") calls and i18n key props (titleKey, labelKey and the rest)
|
||||
* in plugins/<id>/src, and the *Key values in plugins/<id>/manifest.json.
|
||||
* A key with a namespace ("docker:hosts.x") is checked against that plugin.
|
||||
*
|
||||
* --list prints what is missing per plugin, with whether core has the key.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
const PLUGINS = path.join(ROOT, "plugins");
|
||||
const CORE_LOCALE = path.join(ROOT, "src", "ui", "locales", "en.json");
|
||||
|
||||
const SHARED_PREFIXES = ["common."];
|
||||
const KEY = /^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$/;
|
||||
const KEY_PROPS = new Set([
|
||||
"titleKey",
|
||||
"labelKey",
|
||||
"descriptionKey",
|
||||
"noHostMessageKey",
|
||||
"paletteKey",
|
||||
"hintKey",
|
||||
"messageKey",
|
||||
"placeholderKey",
|
||||
"tooltipKey",
|
||||
"i18nKey",
|
||||
"enableLabelKey",
|
||||
"enableDescriptionKey",
|
||||
"consequenceKey",
|
||||
"groupKey",
|
||||
"emptyKey",
|
||||
"nameKey",
|
||||
]);
|
||||
const PLURAL_SUFFIXES = [
|
||||
"_zero",
|
||||
"_one",
|
||||
"_two",
|
||||
"_few",
|
||||
"_many",
|
||||
"_other",
|
||||
"_plural",
|
||||
];
|
||||
|
||||
function readJson(file) {
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a dotted key resolves in a locale tree, the way i18next finds it:
|
||||
* a segment may itself hold dots ("services.use"), and a last segment may be
|
||||
* a plural form.
|
||||
*/
|
||||
function hasKey(tree, key) {
|
||||
const find = (node, parts) => {
|
||||
if (parts.length === 0) return node !== undefined;
|
||||
if (!node || typeof node !== "object") return false;
|
||||
for (let end = parts.length; end >= 1; end--) {
|
||||
const name = parts.slice(0, end).join(".");
|
||||
if (name in node && find(node[name], parts.slice(end))) return true;
|
||||
if (
|
||||
end === parts.length &&
|
||||
PLURAL_SUFFIXES.some((suffix) => `${name}${suffix}` in node)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return find(tree, key.split("."));
|
||||
}
|
||||
|
||||
function isShared(key) {
|
||||
return SHARED_PREFIXES.some((prefix) => key.startsWith(prefix));
|
||||
}
|
||||
|
||||
function walk(dir, out = []) {
|
||||
if (!fs.existsSync(dir)) return out;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full, out);
|
||||
else if (/\.(tsx?|jsx?)$/.test(entry.name)) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Drops comments, so an example in one is not read as a call. */
|
||||
function stripComments(source) {
|
||||
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Every literal i18n key a source file uses. A prefix a key is built from at
|
||||
* run time ends in ".*".
|
||||
*/
|
||||
function keysInSource(text) {
|
||||
const source = stripComments(text);
|
||||
const keys = new Set();
|
||||
for (const match of source.matchAll(
|
||||
/\bt\(\s*(["'])((?:(?!\1)[^\\\n$`])+)\1/g,
|
||||
)) {
|
||||
keys.add(match[2]);
|
||||
}
|
||||
for (const match of source.matchAll(
|
||||
/\b([A-Za-z]+Key)\s*[:=]\s*\{?\s*(["'])((?:(?!\2)[^\\\n])+)\2/g,
|
||||
)) {
|
||||
if (KEY_PROPS.has(match[1])) keys.add(match[3]);
|
||||
}
|
||||
// A key built at run time: `hosts.sharing.levels.${level}.label` needs the
|
||||
// whole hosts.sharing.levels subtree. A one-segment prefix says too little
|
||||
// to check.
|
||||
for (const match of source.matchAll(
|
||||
/\bt\(\s*`((?:[A-Za-z0-9_-]+\.){2,})\$\{/g,
|
||||
)) {
|
||||
keys.add(`${match[1]}*`);
|
||||
}
|
||||
return [...keys].filter((key) =>
|
||||
KEY.test(key.replace(/^[a-z0-9-]+:/, "").replace(/\.\*$/, "")),
|
||||
);
|
||||
}
|
||||
|
||||
function keysInManifest(value, out = new Set()) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) keysInManifest(item, out);
|
||||
} else if (value && typeof value === "object") {
|
||||
for (const [name, child] of Object.entries(value)) {
|
||||
if (KEY_PROPS.has(name) && typeof child === "string") out.add(child);
|
||||
else keysInManifest(child, out);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pluginIds(pluginsDir) {
|
||||
return fs
|
||||
.readdirSync(pluginsDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.filter((id) => fs.existsSync(path.join(pluginsDir, id, "manifest.json")))
|
||||
.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys each plugin uses but does not define, other than the shared ones.
|
||||
* Returns { [pluginId]: [{ key, file, inCore }] }.
|
||||
*/
|
||||
function scan({ pluginsDir = PLUGINS, coreLocale = CORE_LOCALE } = {}) {
|
||||
const core = fs.existsSync(coreLocale) ? readJson(coreLocale) : {};
|
||||
const ids = pluginIds(pluginsDir);
|
||||
const locales = new Map(
|
||||
ids.map((id) => {
|
||||
const file = path.join(pluginsDir, id, "locales", "en.json");
|
||||
return [id, fs.existsSync(file) ? readJson(file) : {}];
|
||||
}),
|
||||
);
|
||||
|
||||
const missing = {};
|
||||
const check = (owner, rawKey, file) => {
|
||||
const colon = rawKey.indexOf(":");
|
||||
const namespace = colon > 0 ? rawKey.slice(0, colon) : owner;
|
||||
const key = colon > 0 ? rawKey.slice(colon + 1) : rawKey;
|
||||
if (isShared(key)) return;
|
||||
const tree = locales.get(namespace);
|
||||
const lookup = key.endsWith(".*") ? key.slice(0, -2) : key;
|
||||
if (tree && hasKey(tree, lookup)) return;
|
||||
const inCore = hasKey(core, lookup);
|
||||
// A bare word that is nowhere is a local helper named t, not a string.
|
||||
if (!key.includes(".") && !inCore) return;
|
||||
const list = (missing[owner] ??= []);
|
||||
if (!list.some((entry) => entry.key === rawKey)) {
|
||||
list.push({
|
||||
key: rawKey,
|
||||
file: path.relative(ROOT, file).replaceAll("\\", "/"),
|
||||
inCore,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
for (const id of ids) {
|
||||
const manifestFile = path.join(pluginsDir, id, "manifest.json");
|
||||
for (const key of keysInManifest(readJson(manifestFile))) {
|
||||
if (KEY.test(key.replace(/^[a-z0-9-]+:/, ""))) {
|
||||
check(id, key, manifestFile);
|
||||
}
|
||||
}
|
||||
for (const file of walk(path.join(pluginsDir, id, "src"))) {
|
||||
for (const key of keysInSource(fs.readFileSync(file, "utf8"))) {
|
||||
check(id, key, file);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const list of Object.values(missing)) {
|
||||
list.sort((a, b) => a.key.localeCompare(b.key));
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const missing = scan();
|
||||
if (process.argv.includes("--list")) {
|
||||
console.log(JSON.stringify(missing, null, 2));
|
||||
return;
|
||||
}
|
||||
const entries = Object.entries(missing);
|
||||
if (entries.length === 0) return;
|
||||
console.error(
|
||||
"Plugins use i18n keys their own locales/en.json does not define:",
|
||||
);
|
||||
for (const [id, list] of entries) {
|
||||
for (const { key, file } of list) {
|
||||
console.error(` ${id}: ${key} (${file})`);
|
||||
}
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = { scan, keysInSource, hasKey };
|
||||
@@ -0,0 +1,127 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { scan, keysInSource, hasKey } =
|
||||
require("./check-plugin-locales.cjs") as {
|
||||
scan: (options: {
|
||||
pluginsDir: string;
|
||||
coreLocale: string;
|
||||
}) => Record<string, Array<{ key: string; file: string; inCore: boolean }>>;
|
||||
keysInSource: (source: string) => string[];
|
||||
hasKey: (tree: unknown, key: string) => boolean;
|
||||
};
|
||||
|
||||
let dir: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
||||
dir = null;
|
||||
});
|
||||
|
||||
function fixture(files: Record<string, unknown>) {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-locales-"));
|
||||
for (const [file, content] of Object.entries(files)) {
|
||||
const full = path.join(dir, file);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
full,
|
||||
typeof content === "string" ? content : JSON.stringify(content),
|
||||
);
|
||||
}
|
||||
return {
|
||||
pluginsDir: path.join(dir, "plugins"),
|
||||
coreLocale: path.join(dir, "core.json"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("keysInSource", () => {
|
||||
it("reads t() calls, key props and run time prefixes, not comments", () => {
|
||||
const keys = keysInSource(
|
||||
[
|
||||
'const a = t("hosts.one");',
|
||||
"const b = i18next.t('docker:hosts.two', { x: 1 });",
|
||||
'app.registerTab("x", X, { titleKey: "nav.three" });',
|
||||
'<EmptyState messageKey="hosts.four" />',
|
||||
"t(`hosts.sharing.levels.${level}.label`);",
|
||||
"t(`hosts.${label}`);",
|
||||
'// t("hosts.commented")',
|
||||
'storageKey: "not.a.string.key"',
|
||||
].join("\n"),
|
||||
);
|
||||
expect(keys.sort()).toEqual(
|
||||
[
|
||||
"docker:hosts.two",
|
||||
"hosts.four",
|
||||
"hosts.one",
|
||||
"hosts.sharing.levels.*",
|
||||
"nav.three",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasKey", () => {
|
||||
it("finds nested keys, dotted names and plural forms", () => {
|
||||
const tree = {
|
||||
a: { b: "x" },
|
||||
permissions: { "services.use": { title: "t" } },
|
||||
items: { count_one: "1", count_other: "n" },
|
||||
};
|
||||
expect(hasKey(tree, "a.b")).toBe(true);
|
||||
expect(hasKey(tree, "a")).toBe(true);
|
||||
expect(hasKey(tree, "permissions.services.use.title")).toBe(true);
|
||||
expect(hasKey(tree, "items.count")).toBe(true);
|
||||
expect(hasKey(tree, "a.c")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scan", () => {
|
||||
it("passes keys a plugin defines and the shared common ones", () => {
|
||||
const options = fixture({
|
||||
"core.json": { common: { save: "Save" }, hosts: { name: "Name" } },
|
||||
"plugins/demo/manifest.json": { id: "demo", titleKey: "title" },
|
||||
"plugins/demo/locales/en.json": { title: "Demo", hosts: { name: "N" } },
|
||||
"plugins/demo/src/frontend/index.tsx":
|
||||
't("common.save"); t("hosts.name"); t("title");',
|
||||
});
|
||||
expect(scan(options)).toEqual({});
|
||||
});
|
||||
|
||||
it("fails a key that only resolves through core's fallback", () => {
|
||||
const options = fixture({
|
||||
"core.json": { hosts: { name: "Name" } },
|
||||
"plugins/demo/manifest.json": { id: "demo" },
|
||||
"plugins/demo/locales/en.json": {},
|
||||
"plugins/demo/src/frontend/index.tsx": 't("hosts.name");',
|
||||
});
|
||||
expect(scan(options)).toEqual({
|
||||
demo: [
|
||||
{
|
||||
key: "hosts.name",
|
||||
file: expect.stringContaining("index.tsx"),
|
||||
inCore: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("checks a namespaced key against that plugin and a prefix as a subtree", () => {
|
||||
const options = fixture({
|
||||
"core.json": { hosts: { levels: { view: "View" } } },
|
||||
"plugins/demo/manifest.json": { id: "demo" },
|
||||
"plugins/demo/locales/en.json": {},
|
||||
"plugins/demo/src/frontend/index.tsx":
|
||||
't("other:known.key"); t("other:gone.key"); t(`hosts.levels.${l}`);',
|
||||
"plugins/other/manifest.json": { id: "other" },
|
||||
"plugins/other/locales/en.json": { known: { key: "k" } },
|
||||
});
|
||||
expect(scan(options).demo.map((entry) => entry.key)).toEqual([
|
||||
"hosts.levels.*",
|
||||
"other:gone.key",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* A plugin that calls another plugin's action, fills its slot or extends its
|
||||
* extension point has to say so in manifest.json contributes.uses. Nothing
|
||||
* else records that coupling: the ids are plain strings, so without it a
|
||||
* plugin moved to its own repo silently loses a feature when the other one is
|
||||
* renamed, and nobody can tell what depends on what.
|
||||
*
|
||||
* Owners are read from the code: a plugin owns the actions it registers, the
|
||||
* slots it renders (ActionSlot/ComponentSlot/useSlotContributions/
|
||||
* declareActionSlot) and the extension points it reads (useExtensions). An id
|
||||
* nobody owns is core's and needs no declaration.
|
||||
*
|
||||
* --list prints what each plugin uses from which owner.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
const PLUGINS = path.join(ROOT, "plugins");
|
||||
|
||||
// Each pattern captures a string literal or a constant's name; constants are
|
||||
// resolved against the plugin's own `const NAME = "id"` declarations.
|
||||
const ID = String.raw`(?:"([^"]+)"|([A-Z][A-Z0-9_]*))`;
|
||||
const OWNS = [
|
||||
new RegExp(String.raw`registerAction\(\s*${ID}`, "g"),
|
||||
new RegExp(String.raw`declareActionSlot\(\s*\{\s*id:\s*${ID}`, "g"),
|
||||
new RegExp(
|
||||
String.raw`<(?:ActionSlot|ComponentSlot)[^>]*?slotId=\{?${ID}`,
|
||||
"g",
|
||||
),
|
||||
new RegExp(String.raw`useSlotContributions\(\s*${ID}`, "g"),
|
||||
new RegExp(String.raw`useExtensions(?:<[^>]*>)?\(\s*${ID}`, "g"),
|
||||
new RegExp(String.raw`getExtension\(\s*${ID}`, "g"),
|
||||
];
|
||||
const USES = [
|
||||
new RegExp(String.raw`invokeAction\(\s*${ID}`, "g"),
|
||||
new RegExp(String.raw`registerSlotContribution\(\s*${ID}`, "g"),
|
||||
new RegExp(String.raw`registerExtension\(\s*${ID}`, "g"),
|
||||
new RegExp(
|
||||
String.raw`<(?:ActionSlot|ComponentSlot)[^>]*?slotId=\{?${ID}`,
|
||||
"g",
|
||||
),
|
||||
new RegExp(String.raw`useSlotContributions\(\s*${ID}`, "g"),
|
||||
];
|
||||
|
||||
function sourceOf(dir) {
|
||||
let text = "";
|
||||
const walk = (current) => {
|
||||
if (!fs.existsSync(current)) return;
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const file = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) walk(file);
|
||||
else if (/\.(ts|tsx)$/.test(entry.name)) {
|
||||
text += fs.readFileSync(file, "utf8");
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(path.join(dir, "src"));
|
||||
return text;
|
||||
}
|
||||
|
||||
function constants(text) {
|
||||
const map = new Map();
|
||||
for (const match of text.matchAll(
|
||||
/const ([A-Z][A-Z0-9_]*)\s*=\s*"([^"]+)"/g,
|
||||
)) {
|
||||
map.set(match[1], match[2]);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function matches(patterns, text) {
|
||||
const names = constants(text);
|
||||
const found = new Set();
|
||||
for (const pattern of patterns) {
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
const id = match[1] ?? names.get(match[2]);
|
||||
if (id) found.add(id);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function collect(pluginsDir = PLUGINS) {
|
||||
const plugins = fs
|
||||
.readdirSync(pluginsDir, { withFileTypes: true })
|
||||
.filter((entry) =>
|
||||
fs.existsSync(path.join(pluginsDir, entry.name, "manifest.json")),
|
||||
)
|
||||
.map((entry) => {
|
||||
const dir = path.join(pluginsDir, entry.name);
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(dir, "manifest.json"), "utf8"),
|
||||
);
|
||||
const source = sourceOf(dir);
|
||||
return {
|
||||
id: manifest.id,
|
||||
declared: new Set(manifest.contributes?.uses ?? []),
|
||||
owns: matches(OWNS, source),
|
||||
uses: matches(USES, source),
|
||||
};
|
||||
});
|
||||
|
||||
const owner = new Map();
|
||||
for (const plugin of plugins) {
|
||||
for (const id of plugin.owns) if (!owner.has(id)) owner.set(id, plugin.id);
|
||||
}
|
||||
|
||||
const problems = [];
|
||||
const usage = {};
|
||||
for (const plugin of plugins) {
|
||||
for (const id of plugin.uses) {
|
||||
const by = owner.get(id);
|
||||
if (!by || by === plugin.id || plugin.owns.has(id)) continue;
|
||||
(usage[plugin.id] ??= []).push(`${id} (${by})`);
|
||||
if (!plugin.declared.has(id)) {
|
||||
problems.push(
|
||||
`${plugin.id} uses "${id}" from the ${by} plugin; add it to contributes.uses in plugins/${plugin.id}/manifest.json`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const id of plugin.declared) {
|
||||
if (!plugin.uses.has(id)) {
|
||||
problems.push(
|
||||
`${plugin.id} declares "${id}" in contributes.uses but never uses it`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { problems, usage, owner };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { problems, usage } = collect();
|
||||
if (process.argv.includes("--list")) {
|
||||
console.log(JSON.stringify(usage, null, 2));
|
||||
return;
|
||||
}
|
||||
if (problems.length === 0) return;
|
||||
for (const problem of problems) console.error(problem);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
/** Every action, slot and extension point id a plugin owns, by owner. */
|
||||
function owners(pluginsDir = PLUGINS) {
|
||||
return collect(pluginsDir).owner;
|
||||
}
|
||||
|
||||
module.exports = { collect, owners };
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { collect } = require("./check-plugin-uses.cjs") as {
|
||||
collect: (dir: string) => { problems: string[]; owner: Map<string, string> };
|
||||
};
|
||||
|
||||
let root: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) fs.rmSync(root, { recursive: true, force: true });
|
||||
root = null;
|
||||
});
|
||||
|
||||
function plugins(
|
||||
specs: Record<string, { source: string; uses?: string[] }>,
|
||||
): string {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-uses-"));
|
||||
for (const [id, spec] of Object.entries(specs)) {
|
||||
const dir = path.join(root, id);
|
||||
fs.mkdirSync(path.join(dir, "src"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "manifest.json"),
|
||||
JSON.stringify({ id, contributes: spec.uses ? { uses: spec.uses } : {} }),
|
||||
);
|
||||
fs.writeFileSync(path.join(dir, "src", "index.tsx"), spec.source);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("check-plugin-uses", () => {
|
||||
it("finds owners through string literals and constants", () => {
|
||||
const dir = plugins({
|
||||
terminal: {
|
||||
source:
|
||||
'const SLOT = "terminal.toolbar";\napp.declareActionSlot({ id: SLOT });\napp.registerAction("terminal.open", f);\n',
|
||||
},
|
||||
});
|
||||
const { owner } = collect(dir);
|
||||
expect(owner.get("terminal.open")).toBe("terminal");
|
||||
expect(owner.get("terminal.toolbar")).toBe("terminal");
|
||||
});
|
||||
|
||||
it("requires another plugin's id in contributes.uses", () => {
|
||||
const dir = plugins({
|
||||
terminal: { source: 'app.registerAction("terminal.open", f);\n' },
|
||||
files: { source: 'invokeAction("terminal.open", host);\n' },
|
||||
});
|
||||
expect(collect(dir).problems).toEqual([
|
||||
expect.stringContaining('files uses "terminal.open" from the terminal'),
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes a declared use and flags a declared one that is unused", () => {
|
||||
const dir = plugins({
|
||||
terminal: { source: 'app.registerAction("terminal.open", f);\n' },
|
||||
files: {
|
||||
source: 'invokeAction("terminal.open", host);\n',
|
||||
uses: ["terminal.open", "terminal.gone"],
|
||||
},
|
||||
});
|
||||
expect(collect(dir).problems).toEqual([
|
||||
expect.stringContaining('declares "terminal.gone"'),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @termix/plugin-sdk/ui is public API, implemented by
|
||||
* src/ui/plugin-host/sdk-ui.ts. Adding to it is a contract change and
|
||||
* removing from it is a breaking one, so neither should happen by accident.
|
||||
*
|
||||
* Resolves every name the module exports (through its `export *` lines too)
|
||||
* and fails when that differs from ALLOWED. A new export goes on the list in
|
||||
* the same change, which is what makes it a deliberate one. Only generic
|
||||
* shell pieces belong here: anything one feature needs lives in its plugin.
|
||||
*
|
||||
* --list prints what the module exports now.
|
||||
*/
|
||||
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
const SDK_UI = path.join(ROOT, "src", "ui", "plugin-host", "sdk-ui.ts");
|
||||
const TSCONFIG = path.join(ROOT, "tsconfig.app.json");
|
||||
|
||||
const ALLOWED = [
|
||||
"ActionSlot",
|
||||
"Alert",
|
||||
"AlertDescription",
|
||||
"AlertDialog",
|
||||
"AlertDialogAction",
|
||||
"AlertDialogCancel",
|
||||
"AlertDialogContent",
|
||||
"AlertDialogDescription",
|
||||
"AlertDialogFooter",
|
||||
"AlertDialogHeader",
|
||||
"AlertDialogOverlay",
|
||||
"AlertDialogPortal",
|
||||
"AlertDialogTitle",
|
||||
"AlertDialogTrigger",
|
||||
"AlertTitle",
|
||||
"Badge",
|
||||
"BarSeries",
|
||||
"BarSeriesItem",
|
||||
"BarSeriesProps",
|
||||
"BrowserSignInDialog",
|
||||
"Button",
|
||||
"ButtonProps",
|
||||
"Card",
|
||||
"CardAction",
|
||||
"CardContent",
|
||||
"CardDescription",
|
||||
"CardFooter",
|
||||
"CardGridCanvas",
|
||||
"CardHeader",
|
||||
"CardTitle",
|
||||
"Checkbox",
|
||||
"ColumnCountStepper",
|
||||
"ComponentSlot",
|
||||
"ConnectionLogPanel",
|
||||
"ConnectionLogProvider",
|
||||
"ConnectionOrigin",
|
||||
"ConnectionScreen",
|
||||
"ConnectionStage",
|
||||
"ConnectionStatus",
|
||||
"CustomKeybinding",
|
||||
"Dialog",
|
||||
"DialogClose",
|
||||
"DialogContent",
|
||||
"DialogDescription",
|
||||
"DialogFooter",
|
||||
"DialogHeader",
|
||||
"DialogOverlay",
|
||||
"DialogPortal",
|
||||
"DialogTitle",
|
||||
"DialogTrigger",
|
||||
"DropdownMenu",
|
||||
"DropdownMenuCheckboxItem",
|
||||
"DropdownMenuContent",
|
||||
"DropdownMenuGroup",
|
||||
"DropdownMenuItem",
|
||||
"DropdownMenuLabel",
|
||||
"DropdownMenuPortal",
|
||||
"DropdownMenuRadioGroup",
|
||||
"DropdownMenuRadioItem",
|
||||
"DropdownMenuSeparator",
|
||||
"DropdownMenuShortcut",
|
||||
"DropdownMenuSub",
|
||||
"DropdownMenuSubContent",
|
||||
"DropdownMenuSubTrigger",
|
||||
"DropdownMenuTrigger",
|
||||
"EmptyState",
|
||||
"FOLDER_COLORS",
|
||||
"FakeSwitch",
|
||||
"FullScreenAppPhase",
|
||||
"FullScreenAppWrapper",
|
||||
"GridCardCatalogEntry",
|
||||
"GridColSpan",
|
||||
"GridLayout",
|
||||
"GridSlot",
|
||||
"HostDefaultBadge",
|
||||
"HostDefaultField",
|
||||
"HostFeatureFields",
|
||||
"HostFeatureFieldsProps",
|
||||
"HostKeyVerificationDialog",
|
||||
"Input",
|
||||
"Kbd",
|
||||
"KbdKey",
|
||||
"KbdSeparator",
|
||||
"KeyCombo",
|
||||
"KeybindingAction",
|
||||
"KeybindingActionType",
|
||||
"Label",
|
||||
"LineChart",
|
||||
"LineChartSeries",
|
||||
"LogContext",
|
||||
"LogEntry",
|
||||
"MFAPromptMode",
|
||||
"ManagerCardError",
|
||||
"ManagerCardShell",
|
||||
"ManagerSearch",
|
||||
"MetricCard",
|
||||
"MiniStat",
|
||||
"PassphraseDialog",
|
||||
"PasswordInput",
|
||||
"PluginComponent",
|
||||
"PluginViewPlaceholder",
|
||||
"Popover",
|
||||
"PopoverAnchor",
|
||||
"PopoverContent",
|
||||
"PopoverTrigger",
|
||||
"RadialGauge",
|
||||
"RadialGaugeProps",
|
||||
"RecentActivityItem",
|
||||
"RobustClipboardProvider",
|
||||
"SSHAuthDialog",
|
||||
"ScrollArea",
|
||||
"ScrollBar",
|
||||
"SectionCard",
|
||||
"Select",
|
||||
"Select2",
|
||||
"SelectContent",
|
||||
"SelectGroup",
|
||||
"SelectItem",
|
||||
"SelectLabel",
|
||||
"SelectScrollDownButton",
|
||||
"SelectScrollUpButton",
|
||||
"SelectSeparator",
|
||||
"SelectTrigger",
|
||||
"SelectValue",
|
||||
"Separator",
|
||||
"SettingRow",
|
||||
"Sheet",
|
||||
"SheetClose",
|
||||
"SheetContent",
|
||||
"SheetDescription",
|
||||
"SheetFooter",
|
||||
"SheetHeader",
|
||||
"SheetTitle",
|
||||
"SheetTrigger",
|
||||
"Skeleton",
|
||||
"Slider",
|
||||
"Sparkline",
|
||||
"SparklineProps",
|
||||
"StatRow",
|
||||
"Switch",
|
||||
"TOTPDialog",
|
||||
"Textarea",
|
||||
"TextareaProps",
|
||||
"Tooltip",
|
||||
"TooltipContent",
|
||||
"TooltipProvider",
|
||||
"TooltipTrigger",
|
||||
"UptimeInfo",
|
||||
"VersionInfo",
|
||||
"WebSocketConnectionTarget",
|
||||
"WidgetTitle",
|
||||
"buildOriginWsUrl",
|
||||
"cn",
|
||||
"copyToClipboard",
|
||||
"createFrontendLogger",
|
||||
"createQuickConnectHost",
|
||||
"findMatchingKeybinding",
|
||||
"getAdaptiveResourceBudget",
|
||||
"getBasePath",
|
||||
"getDatabaseHealth",
|
||||
"getDeviceId",
|
||||
"getPollingEnvironmentMultiplier",
|
||||
"getRecentActivity",
|
||||
"getUptime",
|
||||
"getVersionInfo",
|
||||
"globalShortcutHandler",
|
||||
"hydrateLocalSharedHostAuth",
|
||||
"isElectron",
|
||||
"isMacPlatform",
|
||||
"isTabJumpHotkey",
|
||||
"linkedServerUrl",
|
||||
"markAdaptiveResourceUsed",
|
||||
"pluginWsUrl",
|
||||
"readFromClipboard",
|
||||
"resolveConnectionOrigin",
|
||||
"resolveRemoteHostId",
|
||||
"runAdaptiveBackgroundTask",
|
||||
"runAdaptivePolling",
|
||||
"runVisibleInterval",
|
||||
"useAdaptivePolling",
|
||||
"useAppTheme",
|
||||
"useConfirmation",
|
||||
"useConnectionLog",
|
||||
"useIsDefaultsEditor",
|
||||
"useIsMobile",
|
||||
"useOptionalConnectionLog",
|
||||
"useTabs",
|
||||
"useTabsSafe",
|
||||
];
|
||||
|
||||
/** Every name a module exports, resolved the way the compiler sees it. */
|
||||
function exportedNames(file = SDK_UI, tsconfig = TSCONFIG) {
|
||||
const ts = require("typescript");
|
||||
const parsed = ts.getParsedCommandLineOfConfigFile(
|
||||
tsconfig,
|
||||
{},
|
||||
{ ...ts.sys, onUnRecoverableConfigFileDiagnostic: () => {} },
|
||||
);
|
||||
const program = ts.createProgram([file], {
|
||||
...(parsed?.options ?? {}),
|
||||
noEmit: true,
|
||||
});
|
||||
const checker = program.getTypeChecker();
|
||||
const source = program.getSourceFile(file);
|
||||
const symbol = source && checker.getSymbolAtLocation(source);
|
||||
if (!symbol) throw new Error(`Cannot read the exports of ${file}`);
|
||||
return checker
|
||||
.getExportsOfModule(symbol)
|
||||
.map((entry) => entry.getName())
|
||||
.sort();
|
||||
}
|
||||
|
||||
const SNAPSHOT = path.join(ROOT, "packages", "plugin-sdk", "ui.api.txt");
|
||||
|
||||
/**
|
||||
* One line per export with its type as the compiler prints it. Committed as
|
||||
* packages/plugin-sdk/ui.api.txt, so a changed prop or signature shows up as
|
||||
* a diff instead of reaching plugins unnoticed.
|
||||
*/
|
||||
function exportSignatures(file = SDK_UI, tsconfig = TSCONFIG) {
|
||||
const ts = require("typescript");
|
||||
const parsed = ts.getParsedCommandLineOfConfigFile(
|
||||
tsconfig,
|
||||
{},
|
||||
{ ...ts.sys, onUnRecoverableConfigFileDiagnostic: () => {} },
|
||||
);
|
||||
const program = ts.createProgram([file], {
|
||||
...(parsed?.options ?? {}),
|
||||
noEmit: true,
|
||||
});
|
||||
const checker = program.getTypeChecker();
|
||||
const source = program.getSourceFile(file);
|
||||
const symbol = source && checker.getSymbolAtLocation(source);
|
||||
const flags =
|
||||
ts.TypeFormatFlags.NoTruncation |
|
||||
ts.TypeFormatFlags.WriteArrowStyleSignature;
|
||||
return checker
|
||||
.getExportsOfModule(symbol)
|
||||
.map((entry) => {
|
||||
const target =
|
||||
entry.flags & ts.SymbolFlags.Alias
|
||||
? checker.getAliasedSymbol(entry)
|
||||
: entry;
|
||||
const isValue = !!(target.flags & ts.SymbolFlags.Value);
|
||||
const declaration = target.declarations?.[0] ?? source;
|
||||
const type = isValue
|
||||
? checker.getTypeOfSymbolAtLocation(target, declaration)
|
||||
: checker.getDeclaredTypeOfSymbol(target);
|
||||
const text = checker
|
||||
.typeToString(type, undefined, flags)
|
||||
.replace(/import\("[^"]*"\)\./g, "")
|
||||
.replace(/\s+/g, " ");
|
||||
return `${entry.getName()} (${isValue ? "value" : "type"}): ${text}`;
|
||||
})
|
||||
.sort()
|
||||
.join("\n")
|
||||
.concat("\n");
|
||||
}
|
||||
|
||||
/** What the module adds to, and drops from, the allowlist. */
|
||||
function compare(names, allowed = ALLOWED) {
|
||||
const allowedSet = new Set(allowed);
|
||||
const nameSet = new Set(names);
|
||||
return {
|
||||
added: names.filter((name) => !allowedSet.has(name)),
|
||||
removed: allowed.filter((name) => !nameSet.has(name)),
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const fs = require("node:fs");
|
||||
if (/^export (type )?\*/m.test(fs.readFileSync(SDK_UI, "utf8"))) {
|
||||
console.error(
|
||||
"sdk-ui.ts uses export *; name each export so nothing becomes public API by accident.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (process.argv.includes("--update-snapshot")) {
|
||||
fs.writeFileSync(SNAPSHOT, exportSignatures());
|
||||
console.log(`Wrote ${path.relative(ROOT, SNAPSHOT)}`);
|
||||
return;
|
||||
}
|
||||
const saved = fs.existsSync(SNAPSHOT)
|
||||
? fs.readFileSync(SNAPSHOT, "utf8")
|
||||
: "";
|
||||
const current = exportSignatures();
|
||||
if (current !== saved) {
|
||||
const before = new Set(saved.split("\n"));
|
||||
const after = new Set(current.split("\n"));
|
||||
console.error(
|
||||
"@termix/plugin-sdk/ui changed shape. If that is deliberate, run: node scripts/check-sdk-ui-exports.cjs --update-snapshot",
|
||||
);
|
||||
for (const line of saved.split("\n")) {
|
||||
if (line && !after.has(line)) console.error(` - ${line.slice(0, 200)}`);
|
||||
}
|
||||
for (const line of current.split("\n")) {
|
||||
if (line && !before.has(line)) console.error(` + ${line.slice(0, 200)}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
const names = exportedNames();
|
||||
if (process.argv.includes("--list")) {
|
||||
console.log(names.join("\n"));
|
||||
return;
|
||||
}
|
||||
const { added, removed } = compare(names);
|
||||
if (added.length === 0 && removed.length === 0) return;
|
||||
if (added.length > 0) {
|
||||
console.error(
|
||||
"@termix/plugin-sdk/ui exports names that are not on the allowlist in scripts/check-sdk-ui-exports.cjs:",
|
||||
);
|
||||
for (const name of added) console.error(` + ${name}`);
|
||||
}
|
||||
if (removed.length > 0) {
|
||||
console.error(
|
||||
"@termix/plugin-sdk/ui no longer exports these allowlisted names (a breaking change for plugins):",
|
||||
);
|
||||
for (const name of removed) console.error(` - ${name}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = { ALLOWED, compare, exportedNames, exportSignatures };
|
||||
@@ -0,0 +1,61 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { ALLOWED, compare, exportedNames } =
|
||||
require("./check-sdk-ui-exports.cjs") as {
|
||||
ALLOWED: string[];
|
||||
compare: (
|
||||
names: string[],
|
||||
allowed?: string[],
|
||||
) => { added: string[]; removed: string[] };
|
||||
exportedNames: (file?: string, tsconfig?: string) => string[];
|
||||
};
|
||||
|
||||
let dir: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
||||
dir = null;
|
||||
});
|
||||
|
||||
describe("compare", () => {
|
||||
it("names what was added and what was dropped", () => {
|
||||
expect(compare(["a", "b", "c"], ["a", "b", "d"])).toEqual({
|
||||
added: ["c"],
|
||||
removed: ["d"],
|
||||
});
|
||||
expect(compare(["a"], ["a"])).toEqual({ added: [], removed: [] });
|
||||
});
|
||||
|
||||
it("keeps the allowlist free of duplicates", () => {
|
||||
expect(new Set(ALLOWED).size).toBe(ALLOWED.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exportedNames", () => {
|
||||
it("follows export * and renamed exports", () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "sdk-ui-exports-"));
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "tsconfig.json"),
|
||||
JSON.stringify({ compilerOptions: { module: "esnext", strict: true } }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "parts.ts"),
|
||||
"export const Button = 1;\nexport type ButtonProps = { a: 1 };\n",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "entry.ts"),
|
||||
'export * from "./parts";\nconst theme = 2;\nexport { theme as useAppTheme };\n',
|
||||
);
|
||||
expect(
|
||||
exportedNames(
|
||||
path.join(dir, "entry.ts"),
|
||||
path.join(dir, "tsconfig.json"),
|
||||
),
|
||||
).toEqual(["Button", "ButtonProps", "useAppTheme"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Tests mirror the source tree in their own directories and never sit next
|
||||
* to the code they test. A *.test.ts(x) file may only live under
|
||||
* src/backend/tests/, src/ui/tests/, scripts/ or plugins/<id>/tests/.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
const SKIP = new Set([
|
||||
"node_modules",
|
||||
".git",
|
||||
"dist",
|
||||
"build",
|
||||
"release",
|
||||
"coverage",
|
||||
".vite",
|
||||
".turbo",
|
||||
]);
|
||||
const TEST_FILE = /\.test\.tsx?$/;
|
||||
const ALLOWED = [
|
||||
/^src\/backend\/tests\//,
|
||||
/^src\/ui\/tests\//,
|
||||
/^scripts\//,
|
||||
/^plugins\/[^/]+\/tests\//,
|
||||
];
|
||||
|
||||
function walk(dir, root, out) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (SKIP.has(entry.name)) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full, root, out);
|
||||
else if (TEST_FILE.test(entry.name)) {
|
||||
out.push(path.relative(root, full).replaceAll("\\", "/"));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Test files outside the directories tests belong in. */
|
||||
function misplaced(root = ROOT) {
|
||||
return walk(root, root, [])
|
||||
.filter((file) => !ALLOWED.some((pattern) => pattern.test(file)))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function main() {
|
||||
const found = misplaced();
|
||||
if (found.length === 0) return;
|
||||
console.error(
|
||||
"Tests belong in src/backend/tests/, src/ui/tests/, scripts/ or plugins/<id>/tests/, mirroring the source:",
|
||||
);
|
||||
for (const file of found) console.error(` ${file}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = { misplaced };
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { misplaced } = require("./check-test-locations.cjs") as {
|
||||
misplaced: (root: string) => string[];
|
||||
};
|
||||
|
||||
let root: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) fs.rmSync(root, { recursive: true, force: true });
|
||||
root = null;
|
||||
});
|
||||
|
||||
function fixture(files: string[]): string {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "test-locations-"));
|
||||
for (const file of files) {
|
||||
const full = path.join(root, file);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, "");
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("misplaced", () => {
|
||||
it("accepts the mirrored test trees", () => {
|
||||
const dir = fixture([
|
||||
"src/backend/tests/utils/a.test.ts",
|
||||
"src/ui/tests/lib/b.test.tsx",
|
||||
"scripts/c.test.ts",
|
||||
"plugins/docker/tests/backend/d.test.ts",
|
||||
"plugins/docker/node_modules/pkg/e.test.ts",
|
||||
"plugins/docker/dist/f.test.ts",
|
||||
]);
|
||||
expect(misplaced(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a test next to its source", () => {
|
||||
const dir = fixture([
|
||||
"src/backend/utils/a.test.ts",
|
||||
"src/ui/lib/b.test.tsx",
|
||||
"plugins/docker/src/frontend/c.test.tsx",
|
||||
"packages/plugin-sdk/src/d.test.ts",
|
||||
"src/backend/utils/not-a-test.ts",
|
||||
]);
|
||||
expect(misplaced(dir)).toEqual([
|
||||
"packages/plugin-sdk/src/d.test.ts",
|
||||
"plugins/docker/src/frontend/c.test.tsx",
|
||||
"src/backend/utils/a.test.ts",
|
||||
"src/ui/lib/b.test.tsx",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -52,14 +52,20 @@ async function resolveProjectId() {
|
||||
return { id: match.id, targetLanguageIds: match.targetLanguageIds || [] };
|
||||
}
|
||||
|
||||
async function resolveFileId(projectId) {
|
||||
const files = await paged(`/projects/${projectId}/files`);
|
||||
const match = files.find(
|
||||
(f) => f.name === SOURCE_FILE || f.path === `/${SOURCE_FILE}`,
|
||||
// Core is /en.json and each plugin is /plugins/<id>/locales/en.json.
|
||||
function isSourceFile(file) {
|
||||
return (
|
||||
file.path === `/${SOURCE_FILE}` ||
|
||||
/^\/plugins\/[^/]+\/locales\/en\.json$/.test(file.path || "")
|
||||
);
|
||||
if (!match)
|
||||
throw new Error(`source file "${SOURCE_FILE}" not found in project`);
|
||||
return match.id;
|
||||
}
|
||||
|
||||
async function resolveFileIds(projectId) {
|
||||
const files = await paged(`/projects/${projectId}/files`);
|
||||
const ids = files.filter(isSourceFile).map((f) => f.id);
|
||||
if (ids.length === 0)
|
||||
throw new Error(`no "${SOURCE_FILE}" source files found in project`);
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function pollPreTranslation(projectId, preTranslationId) {
|
||||
@@ -84,31 +90,35 @@ async function main() {
|
||||
throw new Error("project has no target languages configured");
|
||||
}
|
||||
|
||||
const fileId = await resolveFileId(projectId);
|
||||
const fileIds = await resolveFileIds(projectId);
|
||||
|
||||
console.log(
|
||||
`Pre-translating project ${projectId}, file ${fileId}, ${targetLanguageIds.length} languages via MT engine ${ENGINE_ID}`,
|
||||
`Pre-translating project ${projectId}, ${fileIds.length} files, ${targetLanguageIds.length} languages`,
|
||||
);
|
||||
|
||||
const { data } = await request(
|
||||
"POST",
|
||||
`/projects/${projectId}/pre-translations`,
|
||||
{
|
||||
languageIds: targetLanguageIds,
|
||||
fileIds: [fileId],
|
||||
method: "mt",
|
||||
engineId: ENGINE_ID,
|
||||
scope: "untranslated",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await pollPreTranslation(projectId, data.identifier);
|
||||
console.log(`Pre-translation finished (${result.progress}%)`);
|
||||
// Translation memory first so strings that moved between files keep their
|
||||
// existing translations, then machine translation for whatever is left.
|
||||
const passes = [
|
||||
{ method: "tm", translateUntranslatedOnly: true },
|
||||
{ method: "mt", engineId: ENGINE_ID, scope: "untranslated" },
|
||||
];
|
||||
for (const pass of passes) {
|
||||
const { data } = await request(
|
||||
"POST",
|
||||
`/projects/${projectId}/pre-translations`,
|
||||
{ languageIds: targetLanguageIds, fileIds, ...pass },
|
||||
);
|
||||
const result = await pollPreTranslation(projectId, data.identifier);
|
||||
console.log(
|
||||
`${pass.method} pre-translation finished (${result.progress}%)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveProjectId,
|
||||
resolveFileId,
|
||||
resolveFileIds,
|
||||
isSourceFile,
|
||||
pollPreTranslation,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { isSourceFile } = require("./crowdin-pretranslate.cjs") as {
|
||||
isSourceFile: (file: { path?: string }) => boolean;
|
||||
};
|
||||
|
||||
describe("isSourceFile", () => {
|
||||
it("matches the core and plugin source files", () => {
|
||||
expect(isSourceFile({ path: "/en.json" })).toBe(true);
|
||||
expect(isSourceFile({ path: "/plugins/acme-ssl/locales/en.json" })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips anything else", () => {
|
||||
expect(isSourceFile({ path: "/src/ui/locales/en.json" })).toBe(false);
|
||||
expect(isSourceFile({ path: "/plugins/a/b/locales/en.json" })).toBe(false);
|
||||
expect(isSourceFile({ path: "/plugins/acme-ssl/locales/de.json" })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isSourceFile({})).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
getGlobalLogLevel,
|
||||
setGlobalLogLevel,
|
||||
systemLogger,
|
||||
} from "../src/backend/utils/logger";
|
||||
|
||||
const starter = readFileSync(
|
||||
new URL("../src/backend/starter.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const announceReady = starter.slice(
|
||||
starter.indexOf('systemLogger.success("Termix backend started successfully"'),
|
||||
starter.indexOf("const gracefulShutdown"),
|
||||
);
|
||||
|
||||
const main = readFileSync(
|
||||
new URL("../electron/main.cjs", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const startBackend = main.slice(
|
||||
main.indexOf("function startBackendServer()"),
|
||||
main.indexOf("function clearBackendPidFile()"),
|
||||
);
|
||||
|
||||
function start() {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdout: new EventEmitter(),
|
||||
stderr: new EventEmitter(),
|
||||
pid: 123,
|
||||
});
|
||||
const handleBackendRequest = vi.fn();
|
||||
const context = {
|
||||
reapOrphanedBackendProcess: vi.fn(),
|
||||
getBackendPaths: () => ({ entryPath: "/backend.js", backendCwd: "/app" }),
|
||||
getBackendDataDir: () => "/data",
|
||||
getBackendPidFilePath: () => "/data/backend.pid",
|
||||
clearBackendPidFile: vi.fn(),
|
||||
classifyBackendFailure: () => ({ reason: "crashed", port: null }),
|
||||
logToFile: vi.fn(),
|
||||
fs: {
|
||||
existsSync: () => true,
|
||||
statSync: () => ({ isDirectory: () => true }),
|
||||
writeFileSync: vi.fn(),
|
||||
},
|
||||
fork: () => child,
|
||||
app: { isPackaged: true, getVersion: () => "2.9.0" },
|
||||
process: { env: { LOG_LEVEL: "error" } },
|
||||
isDev: false,
|
||||
appRoot: "/app",
|
||||
BACKEND_STDERR_TAIL_LIMIT: 4096,
|
||||
handleBackendRequest,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
};
|
||||
const ready = runInNewContext(
|
||||
`${startBackend}\nstartBackendServer()`,
|
||||
context,
|
||||
) as Promise<boolean>;
|
||||
const settled = vi.fn();
|
||||
void ready.then(settled);
|
||||
return { child, ready, settled, handleBackendRequest };
|
||||
}
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("embedded backend readiness", () => {
|
||||
it("starts immediately on IPC readiness without any success log", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { child, settled } = start();
|
||||
child.emit("message", { type: "backend-ready" });
|
||||
await Promise.resolve();
|
||||
expect(settled).toHaveBeenCalledWith(true);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("does not mistake another IPC request for readiness", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { child, settled, handleBackendRequest } = start();
|
||||
const request = { type: "backend-request", channel: "sync-proxy-config" };
|
||||
child.emit("message", request);
|
||||
await Promise.resolve();
|
||||
expect(handleBackendRequest).toHaveBeenCalledWith(request);
|
||||
expect(settled).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
expect(settled).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("preserves the legacy success log and tolerates duplicate ready signals", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { child, settled } = start();
|
||||
child.stdout.emit(
|
||||
"data",
|
||||
Buffer.from("Termix backend started successfully"),
|
||||
);
|
||||
child.emit("message", { type: "backend-ready" });
|
||||
await Promise.resolve();
|
||||
expect(settled).toHaveBeenCalledExactlyOnceWith(true);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it.each(["exit", "error"])(
|
||||
"reports %s before readiness as a failed start",
|
||||
async (event) => {
|
||||
vi.useFakeTimers();
|
||||
const { child, ready } = start();
|
||||
child.emit(event, event === "exit" ? 1 : new Error("spawn failed"));
|
||||
await expect(ready).resolves.toBe(false);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("backend readiness announcement", () => {
|
||||
it.each(["warn", "error"])(
|
||||
"sends readiness even when %s logging hides startup success",
|
||||
(level) => {
|
||||
const original = getGlobalLogLevel();
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const send = vi.fn();
|
||||
try {
|
||||
setGlobalLogLevel(level);
|
||||
runInNewContext(announceReady, {
|
||||
systemLogger,
|
||||
process: { env: { ELECTRON_EMBEDDED: "true" }, send },
|
||||
initStartTime: Date.now(),
|
||||
});
|
||||
expect(log).not.toHaveBeenCalled();
|
||||
expect(send).toHaveBeenCalledExactlyOnceWith({ type: "backend-ready" });
|
||||
} finally {
|
||||
setGlobalLogLevel(original);
|
||||
log.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("does not require an IPC channel in the standalone backend", () => {
|
||||
expect(() =>
|
||||
runInNewContext(announceReady, {
|
||||
systemLogger: { success: vi.fn() },
|
||||
process: { env: {} },
|
||||
initStartTime: Date.now(),
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -19,8 +19,22 @@ describe("Electron security boundary", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("allows the renderer to hand the local session to Remote Sync", () => {
|
||||
expect(main).toContain('ipcMain.handle("notify-local-login"');
|
||||
expect(preload).toContain('"notify-local-login"');
|
||||
it("never hands the linked server's session to the renderer over IPC", () => {
|
||||
// The embedded backend owns the link; the renderer asks it, with its
|
||||
// own local session, rather than main.
|
||||
for (const channel of [
|
||||
"get-remote-sync-jwt",
|
||||
"save-remote-sync-jwt",
|
||||
"notify-local-login",
|
||||
]) {
|
||||
expect(main).not.toContain(`"${channel}"`);
|
||||
expect(preload).not.toContain(`"${channel}"`);
|
||||
}
|
||||
});
|
||||
|
||||
it("scopes proxy headers and basic auth to the linked origin", () => {
|
||||
expect(main).toContain('"sync-proxy-config"');
|
||||
expect(main).toContain("linkedServer.applyLinkHeaders(");
|
||||
expect(main).toContain("linkedServer.answerLogin(");
|
||||
});
|
||||
});
|
||||
@@ -55,63 +55,124 @@ const TARGETS = {
|
||||
const KEY_LENGTH = 255;
|
||||
|
||||
/**
|
||||
* Columns that must be varchar rather than text on MySQL. A column qualifies if
|
||||
* it is a primary key, is unique, or is either end of a foreign key.
|
||||
* Columns that must be varchar rather than text on MySQL, per table. A column
|
||||
* qualifies if it is a primary key, is unique, is either end of a foreign key
|
||||
* or is part of an index in its own table. Keyed per table: a column name that
|
||||
* happens to be indexed in one table says nothing about another.
|
||||
*/
|
||||
function collectKeyColumns(source) {
|
||||
const keyed = new Set();
|
||||
|
||||
// `name: text("col")....primaryKey()` / `.unique()` / `.references(...)`
|
||||
const declaration =
|
||||
/(\w+):\s*text\("([a-z0-9_]+)"\)((?:\s*\.\w+\([^)]*\))*)/g;
|
||||
function splitTables(source) {
|
||||
const starts = [];
|
||||
const header = /export const (\w+) = sqliteTable\(\s*"([a-z0-9_]+)"/g;
|
||||
let match;
|
||||
while ((match = declaration.exec(source)) !== null) {
|
||||
const [, prop, column, modifiers] = match;
|
||||
if (/\.(primaryKey|unique|references)\(/.test(modifiers)) {
|
||||
keyed.add(column);
|
||||
}
|
||||
void prop;
|
||||
while ((match = header.exec(source)) !== null) {
|
||||
starts.push({ variable: match[1], table: match[2], index: match.index });
|
||||
}
|
||||
return starts.map((entry, i) => ({
|
||||
...entry,
|
||||
end: i + 1 < starts.length ? starts[i + 1].index : source.length,
|
||||
}));
|
||||
}
|
||||
|
||||
// Multi-line form: the modifiers land on following lines.
|
||||
const multiline =
|
||||
/(\w+):\s*text\("([a-z0-9_]+)"\)\s*\n(\s*\.\w+\([\s\S]*?\),)/g;
|
||||
while ((match = multiline.exec(source)) !== null) {
|
||||
if (/\.(primaryKey|unique|references)\(/.test(match[3])) {
|
||||
keyed.add(match[2]);
|
||||
function collectKeyColumns(source) {
|
||||
const tables = splitTables(source);
|
||||
const byVariable = new Map(tables.map((t) => [t.variable, t.table]));
|
||||
const keyed = new Map(tables.map((t) => [t.table, new Set()]));
|
||||
const add = (table, column) => keyed.get(table)?.add(column);
|
||||
|
||||
for (const { table, index, end } of tables) {
|
||||
const block = source.slice(index, end);
|
||||
let match;
|
||||
|
||||
// `name: text("col")....primaryKey()` / `.unique()` / `.references(...)`
|
||||
const declaration =
|
||||
/(\w+):\s*text\("([a-z0-9_]+)"\)((?:\s*\.\w+\([^)]*\))*)/g;
|
||||
while ((match = declaration.exec(block)) !== null) {
|
||||
if (/\.(primaryKey|unique|references)\(/.test(match[3])) {
|
||||
add(table, match[2]);
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-line form: the modifiers land on following lines.
|
||||
const multiline =
|
||||
/(\w+):\s*text\("([a-z0-9_]+)"\)\s*\n(\s*\.\w+\([\s\S]*?\),)/g;
|
||||
while ((match = multiline.exec(block)) !== null) {
|
||||
if (/\.(primaryKey|unique|references)\(/.test(match[3])) {
|
||||
add(table, match[2]);
|
||||
}
|
||||
}
|
||||
|
||||
// A referenced column implies the target side too; both must match.
|
||||
const reference = /\.references\(\(\)\s*=>\s*(\w+)\.(\w+)/g;
|
||||
while ((match = reference.exec(block)) !== null) {
|
||||
const target = byVariable.get(match[1]);
|
||||
if (target) add(target, camelToSnake(match[2]));
|
||||
}
|
||||
|
||||
// foreignKey({ columns: [table.a], foreignColumns: [other.b] })
|
||||
const foreign =
|
||||
/columns:\s*\[([^\]]*)\],\s*foreignColumns:\s*\[([^\]]*)\]/g;
|
||||
while ((match = foreign.exec(block)) !== null) {
|
||||
for (const column of match[1].split(",")) {
|
||||
const name = column.trim().replace(/^\w+\./, "");
|
||||
if (name) add(table, camelToSnake(name));
|
||||
}
|
||||
for (const column of match[2].split(",")) {
|
||||
const [variable, name] = column.trim().split(".");
|
||||
const target = byVariable.get(variable);
|
||||
if (target && name) add(target, camelToSnake(name));
|
||||
}
|
||||
}
|
||||
|
||||
// Table-level indexes: `(table) => [uniqueIndex("x").on(table.a, table.b)]`,
|
||||
// and the same for the plain `index("x")` used by the performance indexes.
|
||||
// MySQL 8 rejects a TEXT column in a key without a length; MariaDB takes it.
|
||||
const tableIndex = /\b(?:unique)?[iI]ndex\("[a-z0-9_]+"\)\.on\(([^)]*)\)/g;
|
||||
while ((match = tableIndex.exec(block)) !== null) {
|
||||
for (const column of match[1].split(",")) {
|
||||
const name = column.trim().replace(/^\w+\./, "");
|
||||
if (name) add(table, camelToSnake(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A referenced column implies the referencing side too; both must match.
|
||||
const reference = /\.references\(\(\)\s*=>\s*\w+\.(\w+)/g;
|
||||
while ((match = reference.exec(source)) !== null) {
|
||||
keyed.add(camelToSnake(match[1]));
|
||||
}
|
||||
|
||||
// Table-level indexes: `(table) => [uniqueIndex("x").on(table.a, table.b)]`,
|
||||
// and the same for the plain `index("x")` used by the performance indexes.
|
||||
// These were invisible here at first, and MySQL rejected the migration with
|
||||
// "BLOB/TEXT column used in key specification without a key length" — but
|
||||
// only on MySQL 8; MariaDB took it.
|
||||
const tableIndex = /\b(?:unique)?[iI]ndex\("[a-z0-9_]+"\)\.on\(([^)]*)\)/g;
|
||||
while ((match = tableIndex.exec(source)) !== null) {
|
||||
for (const column of match[1].split(",")) {
|
||||
const name = column.trim().replace(/^\w+\./, "");
|
||||
if (name) keyed.add(camelToSnake(name));
|
||||
}
|
||||
}
|
||||
|
||||
return keyed;
|
||||
return { tables, keyed };
|
||||
}
|
||||
|
||||
function camelToSnake(value) {
|
||||
return value.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
||||
}
|
||||
|
||||
function keyTables(source, tables, keyed, dialect) {
|
||||
let out = "";
|
||||
let cursor = 0;
|
||||
for (const { table, index, end } of tables) {
|
||||
out += source.slice(cursor, index);
|
||||
const columns = keyed.get(table);
|
||||
out += source
|
||||
.slice(index, end)
|
||||
.replace(
|
||||
/\btext\("([a-z0-9_]+)"(?:,\s*\{\s*enum:\s*(\[[^\]]*\]),?\s*\})?\)/g,
|
||||
(whole, col, values) => {
|
||||
// Closed sets stay compact without shortening identifiers or keys.
|
||||
if (values && dialect === "mysql")
|
||||
return `mysqlEnum("${col}", ${values})`;
|
||||
const options = values ? `, enum: ${values}` : "";
|
||||
return columns.has(col)
|
||||
? `varchar("${col}", { length: ${KEY_LENGTH}${options} })`
|
||||
: whole;
|
||||
},
|
||||
);
|
||||
cursor = end;
|
||||
}
|
||||
return out + source.slice(cursor);
|
||||
}
|
||||
|
||||
function transform(source, dialect) {
|
||||
const keyed = collectKeyColumns(source);
|
||||
const { tables, keyed } = collectKeyColumns(source);
|
||||
const isPg = dialect === "postgres";
|
||||
let out = source;
|
||||
// Key-bearing strings must be indexable. First, while the table offsets
|
||||
// still match the source.
|
||||
let out = keyTables(source, tables, keyed, dialect);
|
||||
|
||||
// Autoincrement primary keys, before the plain integer rule below.
|
||||
out = out.replace(
|
||||
@@ -150,11 +211,6 @@ function transform(source, dialect) {
|
||||
isPg ? `doublePrecision("${col}")` : `double("${col}")`,
|
||||
);
|
||||
|
||||
// Key-bearing strings must be indexable.
|
||||
out = out.replace(/\btext\("([a-z0-9_]+)"\)/g, (whole, col) =>
|
||||
keyed.has(col) ? `varchar("${col}", { length: ${KEY_LENGTH} })` : whole,
|
||||
);
|
||||
|
||||
// text("x", { length: n }) is sqlite-only sugar; drop the length.
|
||||
out = out.replace(
|
||||
/\btext\("([a-z0-9_]+)",\s*\{\s*length:\s*\d+\s*\}\)/g,
|
||||
@@ -170,9 +226,42 @@ function transform(source, dialect) {
|
||||
isPg ? "AnyPgColumn" : "AnyMySqlColumn",
|
||||
);
|
||||
|
||||
const imports = isPg
|
||||
? `import {\n pgTable,\n text,\n varchar,\n integer,\n serial,\n boolean,\n doublePrecision,\n index,\n uniqueIndex,\n foreignKey,\n type AnyPgColumn,\n} from "drizzle-orm/pg-core";`
|
||||
: `import {\n mysqlTable,\n text,\n varchar,\n int,\n boolean,\n double,\n index,\n uniqueIndex,\n foreignKey,\n type AnyMySqlColumn,\n} from "drizzle-orm/mysql-core";`;
|
||||
// Only the helpers the output uses, so an unused one never fails lint.
|
||||
const helpers = isPg
|
||||
? [
|
||||
"pgTable",
|
||||
"text",
|
||||
"varchar",
|
||||
"integer",
|
||||
"serial",
|
||||
"boolean",
|
||||
"doublePrecision",
|
||||
"index",
|
||||
"uniqueIndex",
|
||||
"foreignKey",
|
||||
]
|
||||
: [
|
||||
"mysqlTable",
|
||||
"mysqlEnum",
|
||||
"text",
|
||||
"varchar",
|
||||
"int",
|
||||
"boolean",
|
||||
"double",
|
||||
"index",
|
||||
"uniqueIndex",
|
||||
"foreignKey",
|
||||
];
|
||||
const body = out.replace(
|
||||
/import\s*\{[^}]*\}\s*from\s*"drizzle-orm\/sqlite-core";/,
|
||||
"",
|
||||
);
|
||||
const used = helpers.filter((name) =>
|
||||
new RegExp("\\b" + name + "\\(").test(body),
|
||||
);
|
||||
const anyColumn = isPg ? "AnyPgColumn" : "AnyMySqlColumn";
|
||||
if (body.includes(anyColumn)) used.push(`type ${anyColumn}`);
|
||||
const imports = `import {\n${used.map((name) => ` ${name},`).join("\n")}\n} from "drizzle-orm/${isPg ? "pg-core" : "mysql-core"}";`;
|
||||
|
||||
out = out.replace(
|
||||
/import\s*\{[^}]*\}\s*from\s*"drizzle-orm\/sqlite-core";/,
|
||||
|
||||
@@ -5,7 +5,9 @@ const require = createRequire(import.meta.url);
|
||||
const { transform, collectKeyColumns } =
|
||||
require("./generate-dialect-schema.cjs") as {
|
||||
transform: (source: string, dialect: "postgres" | "mysql") => string;
|
||||
collectKeyColumns: (source: string) => Set<string>;
|
||||
collectKeyColumns: (source: string) => {
|
||||
keyed: Map<string, Set<string>>;
|
||||
};
|
||||
};
|
||||
|
||||
const SOURCE = `import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
|
||||
@@ -36,21 +38,36 @@ export const folders = sqliteTable("folders", {
|
||||
`;
|
||||
|
||||
describe("collectKeyColumns", () => {
|
||||
it("finds columns that must be indexable", () => {
|
||||
const keyed = collectKeyColumns(SOURCE);
|
||||
it("finds columns that must be indexable, per table", () => {
|
||||
const { keyed } = collectKeyColumns(SOURCE);
|
||||
|
||||
// primary key, unique, and both ends of the foreign key
|
||||
expect(keyed.has("id")).toBe(true);
|
||||
expect(keyed.has("sync_id")).toBe(true);
|
||||
expect(keyed.has("user_id")).toBe(true);
|
||||
expect(keyed.get("users")?.has("id")).toBe(true);
|
||||
expect(keyed.get("folders")?.has("sync_id")).toBe(true);
|
||||
expect(keyed.get("folders")?.has("user_id")).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves ordinary strings alone", () => {
|
||||
const keyed = collectKeyColumns(SOURCE);
|
||||
const { keyed } = collectKeyColumns(SOURCE);
|
||||
|
||||
expect(keyed.has("username")).toBe(false);
|
||||
expect(keyed.has("name")).toBe(false);
|
||||
expect(keyed.has("cert")).toBe(false);
|
||||
expect(keyed.get("users")?.has("username")).toBe(false);
|
||||
expect(keyed.get("folders")?.has("name")).toBe(false);
|
||||
expect(keyed.get("folders")?.has("cert")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not key a column because another table indexes the same name", () => {
|
||||
const { keyed } = collectKeyColumns(`
|
||||
export const a = sqliteTable("a", {
|
||||
label: text("label"),
|
||||
}, (table) => [index("idx_a_label").on(table.label)]);
|
||||
|
||||
export const b = sqliteTable("b", {
|
||||
label: text("label"),
|
||||
});
|
||||
`);
|
||||
|
||||
expect(keyed.get("a")?.has("label")).toBe(true);
|
||||
expect(keyed.get("b")?.has("label")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,6 +141,27 @@ describe("mysql output", () => {
|
||||
expect(out).toContain('varchar("user_id", { length: 255 })');
|
||||
expect(out).toContain('text("name")');
|
||||
});
|
||||
|
||||
it("keeps closed scopes compact in four-column setting indexes", () => {
|
||||
const source = `import { sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
export const pluginSettings = sqliteTable("plugin_settings", {
|
||||
pluginId: text("plugin_id").notNull(),
|
||||
scope: text("scope", { enum: ["admin", "user", "host", "secret"] }).notNull(),
|
||||
scopeId: text("scope_id"),
|
||||
key: text("key").notNull(),
|
||||
}, (table) => [uniqueIndex("scope_key").on(table.pluginId, table.scope, table.scopeId, table.key)]);
|
||||
`;
|
||||
const mysql = transform(source, "mysql");
|
||||
expect(mysql).toContain(
|
||||
'mysqlEnum("scope", ["admin", "user", "host", "secret"])',
|
||||
);
|
||||
for (const name of ["plugin_id", "scope_id", "key"]) {
|
||||
expect(mysql).toContain(`varchar("${name}", { length: 255 })`);
|
||||
}
|
||||
expect(transform(source, "postgres")).toContain(
|
||||
'varchar("scope", { length: 255, enum: ["admin", "user", "host", "secret"] })',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("determinism", () => {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Installs the native dependencies bundled plugins declare. esbuild leaves a
|
||||
* nativeDependencies package out of the plugin bundle, so the server's own
|
||||
* node_modules has to have it. Run in the production image after npm ci.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const plugins = path.resolve(root, process.argv[2] ?? "plugins");
|
||||
const wanted = new Map();
|
||||
|
||||
for (const id of fs.existsSync(plugins) ? fs.readdirSync(plugins) : []) {
|
||||
const manifestPath = path.join(plugins, id, "manifest.json");
|
||||
const pkgPath = path.join(plugins, id, "package.json");
|
||||
if (!fs.existsSync(manifestPath) || !fs.existsSync(pkgPath)) continue;
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
||||
for (const name of manifest.nativeDependencies ?? []) {
|
||||
const range = pkg.dependencies?.[name];
|
||||
if (!range) {
|
||||
console.error(`${id}: native dependency ${name} has no version`);
|
||||
process.exit(1);
|
||||
}
|
||||
wanted.set(name, range);
|
||||
}
|
||||
}
|
||||
|
||||
if (wanted.size === 0) process.exit(0);
|
||||
|
||||
const specs = [...wanted].map(([name, range]) => `${name}@${range}`);
|
||||
console.log(`Installing native plugin dependencies: ${specs.join(" ")}`);
|
||||
const result = spawnSync(
|
||||
"npm",
|
||||
["install", "--no-save", "--omit=dev", "--no-audit", "--no-fund", ...specs],
|
||||
{ cwd: root, stdio: "inherit", shell: process.platform === "win32" },
|
||||
);
|
||||
process.exit(result.status ?? 1);
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Reads docker/bundled-plugins.json: which plugins ship in the image and
|
||||
* where each comes from.
|
||||
*
|
||||
* { "id": "x", "source": "workspace" } built from plugins/x
|
||||
* { "id": "x", "source": "tmxplug", "url": "...", "sha256": "..." }
|
||||
* { "id": "x", "source": "tmxplug", "path": "...", "sha256": "..." }
|
||||
*
|
||||
* A tmxplug entry is pinned by sha256 in this file, which is reviewed like
|
||||
* any other change, so the build does not need the registry signature.
|
||||
*/
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ID = /^[a-z0-9][a-z0-9-]*$/;
|
||||
const SHA256 = /^[0-9a-f]{64}$/;
|
||||
|
||||
function parseBundledPlugins(raw, workspaceIds) {
|
||||
const problems = [];
|
||||
const list = raw && Array.isArray(raw.plugins) ? raw.plugins : null;
|
||||
if (!list) {
|
||||
return {
|
||||
plugins: [],
|
||||
problems: ['bundled-plugins.json needs a "plugins" array'],
|
||||
};
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const plugins = [];
|
||||
for (const [index, entry] of list.entries()) {
|
||||
const where = `plugins[${index}]`;
|
||||
if (!entry || typeof entry.id !== "string" || !ID.test(entry.id)) {
|
||||
problems.push(`${where} needs a valid id`);
|
||||
continue;
|
||||
}
|
||||
if (seen.has(entry.id)) {
|
||||
problems.push(`${entry.id} is listed twice`);
|
||||
continue;
|
||||
}
|
||||
seen.add(entry.id);
|
||||
|
||||
if (entry.source === "workspace") {
|
||||
if (!workspaceIds.includes(entry.id)) {
|
||||
problems.push(
|
||||
`${entry.id} is a workspace plugin but plugins/${entry.id} does not exist`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
plugins.push({ id: entry.id, source: "workspace" });
|
||||
} else if (entry.source === "tmxplug") {
|
||||
const hasUrl =
|
||||
typeof entry.url === "string" && entry.url.startsWith("https://");
|
||||
const hasPath = typeof entry.path === "string" && entry.path.length > 0;
|
||||
if (hasUrl === hasPath) {
|
||||
problems.push(
|
||||
`${entry.id} needs exactly one of an https url or a path`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (typeof entry.sha256 !== "string" || !SHA256.test(entry.sha256)) {
|
||||
problems.push(`${entry.id} needs a lowercase hex sha256`);
|
||||
continue;
|
||||
}
|
||||
if (workspaceIds.includes(entry.id)) {
|
||||
problems.push(
|
||||
`${entry.id} comes from a .tmxplug, so delete plugins/${entry.id}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
plugins.push({
|
||||
id: entry.id,
|
||||
source: "tmxplug",
|
||||
url: hasUrl ? entry.url : null,
|
||||
path: hasPath ? entry.path : null,
|
||||
sha256: entry.sha256,
|
||||
});
|
||||
} else {
|
||||
problems.push(`${entry.id} has an unknown source "${entry.source}"`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of workspaceIds) {
|
||||
if (!seen.has(id)) {
|
||||
problems.push(
|
||||
`plugins/${id} is not listed in docker/bundled-plugins.json`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { plugins, problems };
|
||||
}
|
||||
|
||||
function loadBundledPlugins(root) {
|
||||
const configPath = path.join(root, "docker", "bundled-plugins.json");
|
||||
const pluginsDir = path.join(root, "plugins");
|
||||
const workspaceIds = fs.existsSync(pluginsDir)
|
||||
? fs
|
||||
.readdirSync(pluginsDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.filter((entry) =>
|
||||
fs.existsSync(path.join(pluginsDir, entry.name, "manifest.json")),
|
||||
)
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
: [];
|
||||
|
||||
const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
const { plugins, problems } = parseBundledPlugins(raw, workspaceIds);
|
||||
if (problems.length > 0) {
|
||||
throw new Error(`docker/bundled-plugins.json:\n ${problems.join("\n ")}`);
|
||||
}
|
||||
return plugins;
|
||||
}
|
||||
|
||||
/** Refuses an artifact whose bytes are not the ones the config pinned. */
|
||||
function checkSha256(buffer, expected, id) {
|
||||
const actual = crypto.createHash("sha256").update(buffer).digest("hex");
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`${id}: sha256 is ${actual}, bundled-plugins.json pins ${expected}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The .tmxplug bytes for an entry: a local path, or a download cached by
|
||||
* sha256 so rebuilds do not fetch it again.
|
||||
*/
|
||||
async function fetchArtifact(entry, root, fetchImpl = fetch) {
|
||||
if (entry.path) {
|
||||
const buffer = fs.readFileSync(path.resolve(root, entry.path));
|
||||
checkSha256(buffer, entry.sha256, entry.id);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
const cacheDir = path.join(
|
||||
root,
|
||||
"node_modules",
|
||||
".cache",
|
||||
"termix-bundled-plugins",
|
||||
);
|
||||
const cached = path.join(cacheDir, `${entry.sha256}.tmxplug`);
|
||||
if (fs.existsSync(cached)) {
|
||||
const buffer = fs.readFileSync(cached);
|
||||
checkSha256(buffer, entry.sha256, entry.id);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
const response = await fetchImpl(entry.url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${entry.id}: download failed with ${response.status}`);
|
||||
}
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
checkSha256(buffer, entry.sha256, entry.id);
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
fs.writeFileSync(cached, buffer);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/** Unpacks a checked .tmxplug into outDir and confirms its manifest id. */
|
||||
async function extractArtifact(buffer, outDir, id) {
|
||||
const tar = require("tar");
|
||||
const { Readable } = require("node:stream");
|
||||
const { pipeline } = require("node:stream/promises");
|
||||
|
||||
fs.rmSync(outDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
await pipeline(
|
||||
Readable.from(buffer),
|
||||
tar.x({
|
||||
cwd: outDir,
|
||||
strict: true,
|
||||
filter: (_p, entry) =>
|
||||
entry.type === "File" || entry.type === "Directory",
|
||||
}),
|
||||
);
|
||||
|
||||
const manifestPath = path.join(outDir, "manifest.json");
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
throw new Error(`${id}: the .tmxplug has no manifest.json`);
|
||||
}
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
if (manifest.id !== id) {
|
||||
throw new Error(`${id}: the .tmxplug is for "${manifest.id}"`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseBundledPlugins,
|
||||
loadBundledPlugins,
|
||||
checkSha256,
|
||||
fetchArtifact,
|
||||
extractArtifact,
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* The modules the shell shares with plugin bundles, and the import map that
|
||||
* points plugin imports at them.
|
||||
*
|
||||
* One instance of React, i18next and the SDK has to exist across the shell
|
||||
* and every plugin, so plugin bundles leave these imports bare and the page's
|
||||
* import map resolves them to the shell's own copies. Used by the Vite plugin
|
||||
* that builds the map and by scripts/check-importmap-csp.cjs, which keeps the
|
||||
* nginx CSP hash in step with it.
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
|
||||
/**
|
||||
* Third-party modules that must be one instance. Keep in step with the CLI's
|
||||
* FRONTEND_EXTERNALS. `cjs` modules need their named exports listed, since
|
||||
* `export *` drops them; `hasDefault` says an ES module has a default export.
|
||||
*/
|
||||
export const SHARED_VENDORS = [
|
||||
{ specifier: "react", cjs: true },
|
||||
{ specifier: "react-dom", cjs: true },
|
||||
{ specifier: "react-dom/client", cjs: true },
|
||||
{ specifier: "react/jsx-runtime", cjs: true },
|
||||
{ specifier: "i18next", cjs: false, hasDefault: true },
|
||||
{ specifier: "react-i18next", cjs: false, hasDefault: false },
|
||||
{ specifier: "sonner", cjs: false, hasDefault: false },
|
||||
];
|
||||
|
||||
export const SHARED_VENDOR_MODULES = SHARED_VENDORS.map(
|
||||
(vendor) => vendor.specifier,
|
||||
);
|
||||
|
||||
/** SDK entries that are implemented by, or hold state shared with, core. */
|
||||
export const SHARED_SDK_MODULES = [
|
||||
"@termix/plugin-sdk/frontend",
|
||||
"@termix/plugin-sdk/ui",
|
||||
];
|
||||
|
||||
export function sharedModules() {
|
||||
return [...SHARED_VENDOR_MODULES, ...SHARED_SDK_MODULES];
|
||||
}
|
||||
|
||||
/** A file-name-safe name for a shared module's shim. */
|
||||
export function shimName(specifier) {
|
||||
return specifier
|
||||
.replace(/^@/, "")
|
||||
.replace(/[^a-zA-Z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
/** Where a shared module's stable shim lands in the build output. */
|
||||
export function shimPath(specifier) {
|
||||
return `shared/${shimName(specifier)}.js`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The import map text for a production build. Deterministic: sorted, compact,
|
||||
* and pointing at the unhashed shims, so it only changes when the list of
|
||||
* shared modules does. That keeps its CSP hash stable across builds.
|
||||
*/
|
||||
export function productionImportMap(specifiers) {
|
||||
const imports = {};
|
||||
for (const specifier of [...specifiers].sort()) {
|
||||
imports[specifier] = `./${shimPath(specifier)}`;
|
||||
}
|
||||
return JSON.stringify({ imports });
|
||||
}
|
||||
|
||||
export function importMapCspHash(text) {
|
||||
return `'sha256-${crypto.createHash("sha256").update(text, "utf8").digest("base64")}'`;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Every SQL statement a 2.8 install's Postgres or MySQL database runs on its
|
||||
* way to 2.9.0, in the order it runs them: core's drizzle migrations added
|
||||
* since release-2.8.0-tag, then each bundled plugin's own migrations in the
|
||||
* order the loader activates them. The data copies that run after that
|
||||
* (src/backend/upgrade/) are TypeScript, not SQL, and are covered by the
|
||||
* upgrade test and scripts/upgrade-check.sh.
|
||||
*
|
||||
* Snapshot-tested by scripts/upgrade-sql.test.ts, so any change to the
|
||||
* upgrade path shows up as a diff in review.
|
||||
*
|
||||
* Usage: node scripts/lib/upgrade-sql.mjs postgres|mysql
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../..",
|
||||
);
|
||||
|
||||
/** The last core migration release-2.8.0-tag shipped, per dialect. */
|
||||
export const LAST_28_MIGRATION = {
|
||||
postgres: "0024_flimsy_forge",
|
||||
mysql: "0023_even_beast",
|
||||
};
|
||||
|
||||
/** Plugin ids in the order the loader activates them: dependencies first. */
|
||||
export function pluginActivationOrder(root = ROOT) {
|
||||
const dir = path.join(root, "plugins");
|
||||
const manifests = new Map(
|
||||
fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isDirectory() &&
|
||||
fs.existsSync(path.join(dir, entry.name, "manifest.json")),
|
||||
)
|
||||
.map((entry) => [
|
||||
entry.name,
|
||||
JSON.parse(
|
||||
fs.readFileSync(path.join(dir, entry.name, "manifest.json"), "utf8"),
|
||||
),
|
||||
])
|
||||
.sort(([a], [b]) => a.localeCompare(b)),
|
||||
);
|
||||
|
||||
const order = [];
|
||||
const done = new Set();
|
||||
const visit = (id) => {
|
||||
if (done.has(id) || !manifests.has(id)) return;
|
||||
done.add(id);
|
||||
const manifest = manifests.get(id);
|
||||
for (const dependency of Object.keys(manifest.dependencies ?? {})) {
|
||||
visit(dependency);
|
||||
}
|
||||
for (const dependency of Object.keys(manifest.optionalDependencies ?? {})) {
|
||||
visit(dependency);
|
||||
}
|
||||
order.push(id);
|
||||
};
|
||||
for (const id of manifests.keys()) visit(id);
|
||||
return order;
|
||||
}
|
||||
|
||||
function section(title, sql) {
|
||||
return `-- ==== ${title} ====\n${sql.trim()}\n`;
|
||||
}
|
||||
|
||||
export function buildUpgradeSql(dialect, root = ROOT) {
|
||||
if (!(dialect in LAST_28_MIGRATION)) {
|
||||
throw new Error(`unsupported dialect "${dialect}"`);
|
||||
}
|
||||
const parts = [
|
||||
`-- Termix 2.8.0 to 2.9.0 on ${dialect}, generated by scripts/lib/upgrade-sql.mjs\n`,
|
||||
];
|
||||
|
||||
const coreDir = path.join(root, "drizzle", dialect);
|
||||
const journal = JSON.parse(
|
||||
fs.readFileSync(path.join(coreDir, "meta", "_journal.json"), "utf8"),
|
||||
);
|
||||
const tags = journal.entries.map((entry) => entry.tag);
|
||||
const last = tags.indexOf(LAST_28_MIGRATION[dialect]);
|
||||
if (last === -1) {
|
||||
throw new Error(
|
||||
`${LAST_28_MIGRATION[dialect]} is not in the ${dialect} journal`,
|
||||
);
|
||||
}
|
||||
for (const tag of tags.slice(last + 1)) {
|
||||
parts.push(
|
||||
section(
|
||||
`core ${tag}`,
|
||||
fs.readFileSync(path.join(coreDir, `${tag}.sql`), "utf8"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (const id of pluginActivationOrder(root)) {
|
||||
const dir = path.join(root, "plugins", id, "migrations", dialect);
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
for (const file of fs
|
||||
.readdirSync(dir)
|
||||
.filter((name) => name.endsWith(".sql"))
|
||||
.sort()) {
|
||||
parts.push(
|
||||
section(
|
||||
`plugin ${id} ${file}`,
|
||||
fs.readFileSync(path.join(dir, file), "utf8"),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join("\n").replace(/\r\n/g, "\n");
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
fileURLToPath(import.meta.url) === path.resolve(process.argv[1])
|
||||
) {
|
||||
process.stdout.write(buildUpgradeSql(process.argv[2] ?? "postgres"));
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const packageRoot = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"node_modules",
|
||||
"guacamole-common-js",
|
||||
);
|
||||
|
||||
const bundlePaths = [
|
||||
path.join(packageRoot, "dist", "esm", "guacamole-common.js"),
|
||||
path.join(packageRoot, "dist", "cjs", "guacamole-common.js"),
|
||||
];
|
||||
|
||||
const oldFlushBlock =
|
||||
" if (window.requestAnimationFrame && document.hasFocus())\n" +
|
||||
" asyncFlush();\n" +
|
||||
" else\n" +
|
||||
" syncFlush();";
|
||||
|
||||
const newFlushBlock =
|
||||
" // Electron can throttle or skip requestAnimationFrame() for inactive\n" +
|
||||
" // windows/tabs even while guacd is still sending display frames. Flush\n" +
|
||||
" // synchronously so Guacamole connections do not stall while waiting for\n" +
|
||||
" // a frame callback that may never run.\n" +
|
||||
" syncFlush();";
|
||||
|
||||
let patched = false;
|
||||
let foundBundle = false;
|
||||
|
||||
for (const bundlePath of bundlePaths) {
|
||||
if (!fs.existsSync(bundlePath)) {
|
||||
console.log(
|
||||
`[patch-guacamole-common-js] ${bundlePath} not found, skipping`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
foundBundle = true;
|
||||
let content = fs.readFileSync(bundlePath, "utf8");
|
||||
if (content.includes(newFlushBlock)) continue;
|
||||
|
||||
if (!content.includes(oldFlushBlock)) {
|
||||
console.log(
|
||||
`[patch-guacamole-common-js] Flush target not found in ${bundlePath}, skipping`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
content = content.replace(oldFlushBlock, newFlushBlock);
|
||||
fs.writeFileSync(bundlePath, content);
|
||||
patched = true;
|
||||
}
|
||||
|
||||
if (!foundBundle) {
|
||||
console.log("[patch-guacamole-common-js] File not found, skipping");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!patched) {
|
||||
console.log("[patch-guacamole-common-js] Already patched");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(
|
||||
"[patch-guacamole-common-js] Patched display flush to avoid Electron requestAnimationFrame stalls",
|
||||
);
|
||||
@@ -1,403 +0,0 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const guacdClientPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"node_modules",
|
||||
"guacamole-lite",
|
||||
"lib",
|
||||
"GuacdClient.js",
|
||||
);
|
||||
const cryptPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"node_modules",
|
||||
"guacamole-lite",
|
||||
"lib",
|
||||
"Crypt.js",
|
||||
);
|
||||
const clientConnectionPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"node_modules",
|
||||
"guacamole-lite",
|
||||
"lib",
|
||||
"ClientConnection.js",
|
||||
);
|
||||
const serverPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"node_modules",
|
||||
"guacamole-lite",
|
||||
"lib",
|
||||
"Server.js",
|
||||
);
|
||||
|
||||
if (
|
||||
!fs.existsSync(guacdClientPath) ||
|
||||
!fs.existsSync(cryptPath) ||
|
||||
!fs.existsSync(clientConnectionPath) ||
|
||||
!fs.existsSync(serverPath)
|
||||
) {
|
||||
console.log("[patch-guacamole-lite] File not found, skipping");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Every patch below is required for correctness: protocol negotiation, the
|
||||
// guacd 1.6.0 name handshake, dynamic argument answering, UTF-8 tokens and
|
||||
// read-only joins. If an upstream release moves an anchor string, silently
|
||||
// skipping would ship a Termix that looks fine and then drops VNC/RDP sessions
|
||||
// at runtime, so a missing anchor has to stop the install instead.
|
||||
function missingAnchor(patch) {
|
||||
console.error(
|
||||
`[patch-guacamole-lite] ${patch} anchor not found in guacamole-lite. ` +
|
||||
"The upstream file has changed and this patch no longer applies — " +
|
||||
"update scripts/patch-guacamole-lite.cjs to match the new source.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let guacdClientContent = fs.readFileSync(guacdClientPath, "utf8");
|
||||
let cryptContent = fs.readFileSync(cryptPath, "utf8");
|
||||
let clientConnectionContent = fs.readFileSync(clientConnectionPath, "utf8");
|
||||
let serverContent = fs.readFileSync(serverPath, "utf8");
|
||||
|
||||
// Patch 1: protocol version negotiation.
|
||||
// guacamole-lite originally only accepted 1.0.0/1.1.0. Support the protocol
|
||||
// versions Termix can handle, and conservatively answer future 1.x versions as
|
||||
// VERSION_1_5_0 so guacd still sees support for `require`/`name` without us
|
||||
// claiming support for unknown instructions.
|
||||
const oldVersionBlock =
|
||||
" if (version === '1_0_0' || version === '1_1_0') {\n" +
|
||||
" protocolVersion = version;\n" +
|
||||
" } else {\n" +
|
||||
" protocolVersion = '1_1_0';\n" +
|
||||
" }";
|
||||
const oldPatchedVersionBlock =
|
||||
" if (version === '1_0_0' || version === '1_1_0' || version === '1_3_0' || version === '1_5_0') {\n" +
|
||||
" protocolVersion = version;\n" +
|
||||
" } else {\n" +
|
||||
" protocolVersion = '1_1_0';\n" +
|
||||
" }";
|
||||
const newVersionBlock =
|
||||
" if (version === '1_0_0' || version === '1_1_0' || version === '1_3_0' || version === '1_5_0') {\n" +
|
||||
" protocolVersion = version;\n" +
|
||||
" } else if (/^1_\\d+_0$/.test(version)) {\n" +
|
||||
" protocolVersion = '1_5_0';\n" +
|
||||
" } else {\n" +
|
||||
" protocolVersion = '1_1_0';\n" +
|
||||
" }";
|
||||
|
||||
// Patch 2: timezone instruction must be sent for all protocols >= 1.1.0, not just 1.1.0
|
||||
const oldTimezone = "if (protocolVersion === '1_1_0') {";
|
||||
const newTimezone = "if (protocolVersion !== '1_0_0') {";
|
||||
|
||||
// Patch 3: send the `name` handshake instruction for all protocol versions >= 1.1.0.
|
||||
// The Guacamole protocol added `name` in 1.3.0, but guacd 1.6.0 began requiring it
|
||||
// during the VNC handshake even when negotiating VERSION_1_1_0, causing connections to
|
||||
// silently drop right after "User joined". Sending it for all non-1.0.0 sessions is
|
||||
// harmless (guacd ignores unknown handshake instructions for older versions). See
|
||||
// Termix-SSH/Support#567 and #734.
|
||||
const oldConnect =
|
||||
" this.sendInstruction(['connect'].concat(connectArgs));";
|
||||
const oldNameConnect =
|
||||
" if (protocolVersion === '1_3_0' || protocolVersion === '1_5_0') {\n" +
|
||||
" this.sendInstruction(['name', this.connectionSettings.name || 'guacamole-lite']);\n" +
|
||||
" }\n" +
|
||||
"\n" +
|
||||
" this.sendInstruction(['connect'].concat(connectArgs));";
|
||||
const newConnect =
|
||||
" if (protocolVersion !== '1_0_0') {\n" +
|
||||
" this.sendInstruction(['name', this.connectionSettings.name || 'guacamole-lite']);\n" +
|
||||
" }\n" +
|
||||
"\n" +
|
||||
" this.sendInstruction(['connect'].concat(connectArgs));";
|
||||
|
||||
// Patch 4: answer guacd's dynamic argument requests locally.
|
||||
// macOS Screen Sharing can request VNC username/password through the
|
||||
// post-handshake `required`/`require` flow. guacamole-lite forwards those
|
||||
// instructions to the browser, but Termix already keeps the credentials in the
|
||||
// server-side token and the browser does not provide an onrequired handler.
|
||||
const oldSendBuffer =
|
||||
" this.lastActivity = Date.now();\n" + " this.sendBuffer = '';";
|
||||
const newSendBuffer =
|
||||
" this.lastActivity = Date.now();\n" +
|
||||
" this.sendBuffer = '';\n" +
|
||||
" this.nextArgumentStreamIndex = 0;";
|
||||
|
||||
const oldSendInstructionBlock =
|
||||
" sendInstruction(instruction) {\n" +
|
||||
" // convert every element in the instruction array to a string. convert null to an empty string\n" +
|
||||
" instruction = instruction.map((element) => {\n" +
|
||||
" if (element === null || element === undefined) {\n" +
|
||||
" return '';\n" +
|
||||
" }\n" +
|
||||
" return String(element);\n" +
|
||||
" });\n" +
|
||||
"\n" +
|
||||
" const instructionString = GuacamoleParser.toInstruction(instruction);\n" +
|
||||
" this.send(instructionString);\n" +
|
||||
" }\n";
|
||||
const newSendInstructionBlock =
|
||||
oldSendInstructionBlock +
|
||||
"\n" +
|
||||
" sendArgumentValue(name, value) {\n" +
|
||||
" const stream = this.nextArgumentStreamIndex++;\n" +
|
||||
" this.sendInstruction(['argv', stream, 'text/plain', name]);\n" +
|
||||
" this.sendInstruction(['blob', stream, Buffer.from(String(value ?? ''), 'utf8').toString('base64')]);\n" +
|
||||
" this.sendInstruction(['end', stream]);\n" +
|
||||
" }\n" +
|
||||
"\n" +
|
||||
" sendRequiredArguments(params) {\n" +
|
||||
" params.forEach((name) => {\n" +
|
||||
" this.sendArgumentValue(name, this.connectionSettings[name]);\n" +
|
||||
" });\n" +
|
||||
" }\n";
|
||||
|
||||
const oldReadyHandler =
|
||||
' // Handle "ready" instruction\n' +
|
||||
" if (opcode === 'ready') {";
|
||||
const newReadyHandler =
|
||||
" // Handle dynamic argument requests from guacd\n" +
|
||||
" if (opcode === 'required' || opcode === 'require') {\n" +
|
||||
" this.sendRequiredArguments(params);\n" +
|
||||
" return;\n" +
|
||||
" }\n" +
|
||||
"\n" +
|
||||
oldReadyHandler;
|
||||
|
||||
let patched = false;
|
||||
|
||||
if (!guacdClientContent.includes("} else if (/^1_\\d+_0$/.test(version)) {")) {
|
||||
if (guacdClientContent.includes(oldPatchedVersionBlock)) {
|
||||
guacdClientContent = guacdClientContent.replace(
|
||||
oldPatchedVersionBlock,
|
||||
newVersionBlock,
|
||||
);
|
||||
} else if (guacdClientContent.includes(oldVersionBlock)) {
|
||||
guacdClientContent = guacdClientContent.replace(
|
||||
oldVersionBlock,
|
||||
newVersionBlock,
|
||||
);
|
||||
} else {
|
||||
missingAnchor("Version check");
|
||||
}
|
||||
patched = true;
|
||||
}
|
||||
|
||||
if (!guacdClientContent.includes(newTimezone)) {
|
||||
if (!guacdClientContent.includes(oldTimezone)) {
|
||||
missingAnchor("Timezone");
|
||||
}
|
||||
guacdClientContent = guacdClientContent.replace(oldTimezone, newTimezone);
|
||||
patched = true;
|
||||
}
|
||||
|
||||
if (!guacdClientContent.includes(newConnect)) {
|
||||
if (guacdClientContent.includes(oldNameConnect)) {
|
||||
guacdClientContent = guacdClientContent.replace(oldNameConnect, newConnect);
|
||||
} else if (guacdClientContent.includes(oldConnect)) {
|
||||
guacdClientContent = guacdClientContent.replace(oldConnect, newConnect);
|
||||
} else {
|
||||
missingAnchor("Connect");
|
||||
}
|
||||
patched = true;
|
||||
}
|
||||
|
||||
if (!guacdClientContent.includes("this.nextArgumentStreamIndex = 0;")) {
|
||||
if (!guacdClientContent.includes(oldSendBuffer)) {
|
||||
missingAnchor("Argument stream index");
|
||||
}
|
||||
guacdClientContent = guacdClientContent.replace(oldSendBuffer, newSendBuffer);
|
||||
patched = true;
|
||||
}
|
||||
|
||||
if (!guacdClientContent.includes("sendRequiredArguments(params) {")) {
|
||||
if (!guacdClientContent.includes(oldSendInstructionBlock)) {
|
||||
missingAnchor("Required argument helper");
|
||||
}
|
||||
guacdClientContent = guacdClientContent.replace(
|
||||
oldSendInstructionBlock,
|
||||
newSendInstructionBlock,
|
||||
);
|
||||
patched = true;
|
||||
}
|
||||
|
||||
if (
|
||||
!guacdClientContent.includes("opcode === 'required' || opcode === 'require'")
|
||||
) {
|
||||
if (!guacdClientContent.includes(oldReadyHandler)) {
|
||||
missingAnchor("Required opcode");
|
||||
}
|
||||
guacdClientContent = guacdClientContent.replace(
|
||||
oldReadyHandler,
|
||||
newReadyHandler,
|
||||
);
|
||||
patched = true;
|
||||
}
|
||||
|
||||
// Patch 5: guacamole-lite decrypts token JSON through ASCII/binary strings,
|
||||
// which corrupts IV/ciphertext bytes and non-ASCII connection settings such as
|
||||
// RDP/VNC passwords with umlauts. Keep the encrypted fields as Buffers and
|
||||
// decode the plaintext JSON as UTF-8.
|
||||
const oldDecryptBlock =
|
||||
" let encoded = JSON.parse(this.constructor.base64decode(encodedString));\n" +
|
||||
"\n" +
|
||||
" encoded.iv = this.constructor.base64decode(encoded.iv);\n" +
|
||||
" encoded.value = this.constructor.base64decode(encoded.value, 'binary');\n" +
|
||||
"\n" +
|
||||
" const decipher = Crypto.createDecipheriv(this.cypher, this.key, encoded.iv);\n" +
|
||||
"\n" +
|
||||
" let decrypted = decipher.update(encoded.value, 'binary', 'ascii');\n" +
|
||||
" decrypted += decipher.final('ascii');";
|
||||
const oldPartiallyPatchedDecryptBlock =
|
||||
" let encoded = JSON.parse(this.constructor.base64decode(encodedString));\n" +
|
||||
"\n" +
|
||||
" encoded.iv = this.constructor.base64decode(encoded.iv);\n" +
|
||||
" encoded.value = this.constructor.base64decode(encoded.value, 'binary');\n" +
|
||||
"\n" +
|
||||
" const decipher = Crypto.createDecipheriv(this.cypher, this.key, encoded.iv);\n" +
|
||||
"\n" +
|
||||
" let decrypted = decipher.update(encoded.value, 'binary', 'utf8');\n" +
|
||||
" decrypted += decipher.final('utf8');";
|
||||
const newDecryptBlock =
|
||||
" const encoded = JSON.parse(Buffer.from(encodedString, 'base64').toString('utf8'));\n" +
|
||||
"\n" +
|
||||
" const iv = Buffer.from(encoded.iv, 'base64');\n" +
|
||||
" const value = Buffer.from(encoded.value, 'base64');\n" +
|
||||
"\n" +
|
||||
" const decipher = Crypto.createDecipheriv(this.cypher, this.key, iv);\n" +
|
||||
"\n" +
|
||||
" let decrypted = decipher.update(value, undefined, 'utf8');\n" +
|
||||
" decrypted += decipher.final('utf8');";
|
||||
|
||||
if (!cryptContent.includes(newDecryptBlock)) {
|
||||
if (cryptContent.includes(oldDecryptBlock)) {
|
||||
cryptContent = cryptContent.replace(oldDecryptBlock, newDecryptBlock);
|
||||
} else if (cryptContent.includes(oldPartiallyPatchedDecryptBlock)) {
|
||||
cryptContent = cryptContent.replace(
|
||||
oldPartiallyPatchedDecryptBlock,
|
||||
newDecryptBlock,
|
||||
);
|
||||
} else {
|
||||
missingAnchor("UTF-8 token decrypt");
|
||||
}
|
||||
patched = true;
|
||||
}
|
||||
|
||||
// Patch 7: drop client-to-guacd input instructions from read-only session-share
|
||||
// joins. guacd has no native read-only enforcement in the versions this project
|
||||
// targets, so Termix must gate here. Denylist (not allowlist) on purpose: an
|
||||
// unrecognized opcode is far more likely to be protocol plumbing (sync, blob,
|
||||
// clipboard streams) than a new input vector, so failing open is the safer
|
||||
// default for a client we already control.
|
||||
const oldSendMessageToGuacd =
|
||||
" sendMessageToGuacd(message) {\n" +
|
||||
" this.lastActivity = Date.now();\n" +
|
||||
" this.logger.log(LOGLEVEL.DEBUG, '[ >>> # ] Received from WS: ```' + message + '```');\n" +
|
||||
"\n" +
|
||||
" if (this.guacdClient) {\n" +
|
||||
" this.guacdClient.send(message, true);\n" +
|
||||
" }\n" +
|
||||
" }";
|
||||
const newSendMessageToGuacd =
|
||||
" sendMessageToGuacd(message) {\n" +
|
||||
" this.lastActivity = Date.now();\n" +
|
||||
" this.logger.log(LOGLEVEL.DEBUG, '[ >>> # ] Received from WS: ```' + message + '```');\n" +
|
||||
"\n" +
|
||||
" if (this.isReadOnlyJoin() && this.isInputInstruction(message)) {\n" +
|
||||
" return;\n" +
|
||||
" }\n" +
|
||||
"\n" +
|
||||
" if (this.guacdClient) {\n" +
|
||||
" this.guacdClient.send(message, true);\n" +
|
||||
" }\n" +
|
||||
" }\n" +
|
||||
"\n" +
|
||||
" isReadOnlyJoin() {\n" +
|
||||
" const connection = this.connectionSettings && this.connectionSettings.connection;\n" +
|
||||
" return !!(connection && connection.join && connection.readOnly === true);\n" +
|
||||
" }\n" +
|
||||
"\n" +
|
||||
" // Termix-only read-only gate, not part of the vendored library: extracts just\n" +
|
||||
" // the leading opcode from a raw '<len>.<opcode>,...;' instruction without the\n" +
|
||||
" // overhead of a full stateful parse.\n" +
|
||||
" isInputInstruction(message) {\n" +
|
||||
" const dot = message.indexOf('.');\n" +
|
||||
" if (dot === -1) return false;\n" +
|
||||
" const len = parseInt(message.substring(0, dot), 10);\n" +
|
||||
" if (isNaN(len)) return false;\n" +
|
||||
" const opcode = message.substring(dot + 1, dot + 1 + len);\n" +
|
||||
" return ['mouse', 'key', 'touch', 'size'].includes(opcode);\n" +
|
||||
" }";
|
||||
|
||||
if (!clientConnectionContent.includes("isReadOnlyJoin()")) {
|
||||
if (!clientConnectionContent.includes(oldSendMessageToGuacd)) {
|
||||
missingAnchor("sendMessageToGuacd");
|
||||
}
|
||||
clientConnectionContent = clientConnectionContent.replace(
|
||||
oldSendMessageToGuacd,
|
||||
newSendMessageToGuacd,
|
||||
);
|
||||
patched = true;
|
||||
}
|
||||
|
||||
// Patch 8: mergeConnectionOptions only preserves `join` across the settings
|
||||
// merge, dropping Termix's `readOnly` flag before sendMessageToGuacd can see it.
|
||||
const oldPreserveJoin =
|
||||
" // For join connections, preserve the join property\n" +
|
||||
" if (this.connectionSettings.connection.join) {\n" +
|
||||
" compiledSettings.join = this.connectionSettings.connection.join;\n" +
|
||||
" }";
|
||||
const newPreserveJoin =
|
||||
" // For join connections, preserve the join property\n" +
|
||||
" if (this.connectionSettings.connection.join) {\n" +
|
||||
" compiledSettings.join = this.connectionSettings.connection.join;\n" +
|
||||
" compiledSettings.readOnly = this.connectionSettings.connection.readOnly === true;\n" +
|
||||
" }";
|
||||
|
||||
if (!clientConnectionContent.includes("compiledSettings.readOnly")) {
|
||||
if (!clientConnectionContent.includes(oldPreserveJoin)) {
|
||||
missingAnchor("join-preserve");
|
||||
}
|
||||
clientConnectionContent = clientConnectionContent.replace(
|
||||
oldPreserveJoin,
|
||||
newPreserveJoin,
|
||||
);
|
||||
patched = true;
|
||||
}
|
||||
|
||||
// Patch 9: ClientConnection closes malformed-token WebSockets in its
|
||||
// constructor, but Server.newConnection still called connect() afterwards.
|
||||
// That dereferenced the absent connection settings and turned one bad token
|
||||
// into an unhandled rejection that could terminate the backend process.
|
||||
const oldConnectionSetup =
|
||||
" newConnection.on('ready', async (clientConnection) => {";
|
||||
const newConnectionSetup =
|
||||
" if (!newConnection.connectionSettings || !newConnection.connectionSettings.connection) {\n" +
|
||||
" return;\n" +
|
||||
" }\n" +
|
||||
"\n" +
|
||||
oldConnectionSetup;
|
||||
|
||||
if (!serverContent.includes("!newConnection.connectionSettings.connection")) {
|
||||
if (!serverContent.includes(oldConnectionSetup)) {
|
||||
missingAnchor("invalid-token connection guard");
|
||||
}
|
||||
serverContent = serverContent.replace(oldConnectionSetup, newConnectionSetup);
|
||||
patched = true;
|
||||
}
|
||||
|
||||
if (!patched) {
|
||||
console.log("[patch-guacamole-lite] Already patched");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
fs.writeFileSync(guacdClientPath, guacdClientContent);
|
||||
fs.writeFileSync(cryptPath, cryptContent);
|
||||
fs.writeFileSync(clientConnectionPath, clientConnectionContent);
|
||||
fs.writeFileSync(serverPath, serverContent);
|
||||
console.log(
|
||||
"[patch-guacamole-lite] Patched protocol negotiation, name handshake, required arguments, UTF-8 token decrypt, read-only joins, and malformed-token handling",
|
||||
);
|
||||
@@ -1,124 +0,0 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const xtermDir = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"node_modules",
|
||||
"@xterm",
|
||||
"xterm",
|
||||
"lib",
|
||||
);
|
||||
|
||||
// Backport the textarea-shrink fix from gmuxapp/xterm.js@6a011cf while
|
||||
// xtermjs/xterm.js#3600 remains unresolved upstream. Android IMEs can restart
|
||||
// composition on the previous word and replace it with a shorter value (for
|
||||
// example, Vietnamese "Hoar" -> "Hỏa"). xterm 6.0 otherwise emits nothing.
|
||||
//
|
||||
// Also fixes _handleAnyTextareaChanges, which iOS Safari/WKWebView drives
|
||||
// for ordinary typing (it reports keyCode 229 for all software-keyboard
|
||||
// input, not just IME composition). That handler diffs the textarea value
|
||||
// via `newValue.replace(oldValue, "")`, a literal substring removal. When
|
||||
// keystrokes arrive faster than the function's setTimeout(0) callback runs,
|
||||
// several overlapping callbacks each capture a stale oldValue, so the
|
||||
// literal-substring search fails to match and the diff silently comes back
|
||||
// empty - characters are dropped instead of sent. Swap in the same
|
||||
// common-prefix diff used for composition-end above so a stale oldValue
|
||||
// still yields the correct delta.
|
||||
const patches = [
|
||||
{
|
||||
file: "xterm.mjs",
|
||||
replacements: [
|
||||
[
|
||||
[
|
||||
'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
|
||||
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
|
||||
],
|
||||
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._pendingTextareaValue=null,this._dataAlreadySent=""',
|
||||
],
|
||||
[
|
||||
'this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent=""',
|
||||
'this._compositionPosition.start=this._textarea.value.length,this._preCompositionValue=this._textarea.value,this._compositionView.textContent=""',
|
||||
],
|
||||
[
|
||||
"let e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0",
|
||||
"let e={start:this._compositionPosition.start,end:this._compositionPosition.end};const s=this._preCompositionValue;this._isSendingComposition=!0",
|
||||
],
|
||||
[
|
||||
"e.start+=this._dataAlreadySent.length,this._isComposing?i=this._textarea.value.substring(e.start,this._compositionPosition.start):i=this._textarea.value.substring(e.start),i.length>0&&",
|
||||
"e.start+=this._dataAlreadySent.length;if(this._isComposing)i=this._textarea.value.substring(e.start,this._compositionPosition.start);else{const t=this._textarea.value;if(t.length<s.length){let e=0;const r=Math.min(t.length,s.length);for(;e<r&&t.charCodeAt(e)===s.charCodeAt(e);)e++;i=b.DEL.repeat(s.length-e)+t.substring(e)}else i=t.substring(e.start)}i.length>0&&",
|
||||
],
|
||||
[
|
||||
[
|
||||
'_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,i=e.replace(t,"");this._dataAlreadySent=i,e.length>t.length?this._coreService.triggerDataEvent(i,!0):e.length<t.length?this._coreService.triggerDataEvent(`${b.DEL}`,!0):e.length===t.length&&e!==t&&this._coreService.triggerDataEvent(e,!0)}},0)}',
|
||||
"_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r<n&&e.charCodeAt(r)===t.charCodeAt(r);)r++;let i=e.length<t.length?b.DEL.repeat(t.length-r)+e.substring(r):e.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}},0)}",
|
||||
],
|
||||
"_handleAnyTextareaChanges(){if(this._pendingTextareaValue!==null)return;this._pendingTextareaValue=this._textarea.value,setTimeout(()=>{const t=this._pendingTextareaValue;this._pendingTextareaValue=null;if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r<n&&e.charCodeAt(r)===t.charCodeAt(r);)r++;let i=e.length<t.length?b.DEL.repeat(t.length-r)+e.substring(r):e.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}},0)}",
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "xterm.js",
|
||||
replacements: [
|
||||
[
|
||||
[
|
||||
'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
|
||||
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
|
||||
],
|
||||
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._pendingTextareaValue=null,this._dataAlreadySent=""',
|
||||
],
|
||||
[
|
||||
'this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent=""',
|
||||
'this._compositionPosition.start=this._textarea.value.length,this._preCompositionValue=this._textarea.value,this._compositionView.textContent=""',
|
||||
],
|
||||
[
|
||||
"const e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0",
|
||||
"const e={start:this._compositionPosition.start,end:this._compositionPosition.end},i=this._preCompositionValue;this._isSendingComposition=!0",
|
||||
],
|
||||
[
|
||||
"e.start+=this._dataAlreadySent.length,t=this._isComposing?this._textarea.value.substring(e.start,this._compositionPosition.start):this._textarea.value.substring(e.start),t.length>0&&",
|
||||
"e.start+=this._dataAlreadySent.length;this._isComposing?t=this._textarea.value.substring(e.start,this._compositionPosition.start):(()=>{const s=this._textarea.value;if(s.length<i.length){let e=0;const r=Math.min(s.length,i.length);for(;e<r&&s.charCodeAt(e)===i.charCodeAt(e);)e++;t=a.C0.DEL.repeat(i.length-e)+s.substring(e)}else t=s.substring(e.start)})(),t.length>0&&",
|
||||
],
|
||||
[
|
||||
[
|
||||
'_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.length<e.length?this._coreService.triggerDataEvent(`${a.C0.DEL}`,!0):t.length===e.length&&t!==e&&this._coreService.triggerDataEvent(t,!0)}}),0)}',
|
||||
"_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r<n&&t.charCodeAt(r)===e.charCodeAt(r);)r++;const i=t.length<e.length?a.C0.DEL.repeat(e.length-r)+t.substring(r):t.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}}),0)}",
|
||||
],
|
||||
"_handleAnyTextareaChanges(){if(this._pendingTextareaValue!==null)return;this._pendingTextareaValue=this._textarea.value,setTimeout((()=>{const e=this._pendingTextareaValue;this._pendingTextareaValue=null;if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r<n&&t.charCodeAt(r)===e.charCodeAt(r);)r++;const i=t.length<e.length?a.C0.DEL.repeat(e.length-r)+t.substring(r):t.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}}),0)}",
|
||||
],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const { file, replacements } of patches) {
|
||||
const filePath = path.join(xtermDir, file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`[patch-xterm-android-ime] Missing ${filePath}`);
|
||||
}
|
||||
|
||||
let source = fs.readFileSync(filePath, "utf8");
|
||||
let changed = false;
|
||||
|
||||
for (const [original, patched] of replacements) {
|
||||
if (source.includes(patched)) {
|
||||
continue;
|
||||
}
|
||||
const originals = Array.isArray(original) ? original : [original];
|
||||
const matched = originals.find((candidate) => source.includes(candidate));
|
||||
if (!matched) {
|
||||
throw new Error(
|
||||
`[patch-xterm-android-ime] Expected source not found in ${file}`,
|
||||
);
|
||||
}
|
||||
source = source.replace(matched, patched);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
console.log(`[patch-xterm-android-ime] ${file} already patched`);
|
||||
continue;
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, source);
|
||||
console.log(`[patch-xterm-android-ime] Patched ${file}`);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
build,
|
||||
lowerPluginLayers,
|
||||
} from "../packages/plugin-sdk/cli/commands/build.mjs";
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanups.length) cleanups.pop()?.();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function fixturePlugin(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-build-"));
|
||||
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "manifest.json"),
|
||||
JSON.stringify({ id: "build-fixture" }),
|
||||
);
|
||||
fs.mkdirSync(path.join(dir, "src", "backend"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "src", "backend", "index.ts"),
|
||||
"export function activate() {}\n",
|
||||
);
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("termix-plugin build", () => {
|
||||
it("marks dist as ESM so Node loads backend.js without reparsing", async () => {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const dir = fixturePlugin();
|
||||
|
||||
await build({ cwd: dir });
|
||||
|
||||
const pkg = JSON.parse(
|
||||
fs.readFileSync(path.join(dir, "dist", "package.json"), "utf8"),
|
||||
);
|
||||
expect(pkg).toEqual({ type: "module" });
|
||||
expect(fs.existsSync(path.join(dir, "dist", "backend.js"))).toBe(true);
|
||||
});
|
||||
|
||||
it("loads a bundled CommonJS package that requires a host-provided one, with Vite's build constants", async () => {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const dir = fixturePlugin();
|
||||
const dep = path.join(dir, "node_modules", "cjs-dep");
|
||||
fs.mkdirSync(dep, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dep, "package.json"),
|
||||
JSON.stringify({ name: "cjs-dep", main: "index.js" }),
|
||||
);
|
||||
// What react-xtermjs does: a CommonJS require of React's JSX runtime.
|
||||
fs.writeFileSync(
|
||||
path.join(dep, "index.js"),
|
||||
'const runtime = require("react/jsx-runtime");\nexports.render = () => runtime.jsx("div", {});\n',
|
||||
);
|
||||
fs.mkdirSync(path.join(dir, "src", "frontend"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "src", "frontend", "index.ts"),
|
||||
'import { render } from "cjs-dep";\nexport const rendered = render();\nexport const dev = import.meta.env.DEV;\nexport const mode = process.env.NODE_ENV;\n',
|
||||
);
|
||||
|
||||
await build({ cwd: dir });
|
||||
|
||||
// Stand in for the import map: point the external at a stub module.
|
||||
const stub = path.join(dir, "jsx-runtime.mjs");
|
||||
fs.writeFileSync(
|
||||
stub,
|
||||
"export const jsx = (type) => ({ type });\nexport const jsxs = jsx;\n",
|
||||
);
|
||||
const bundle = fs
|
||||
.readFileSync(path.join(dir, "dist", "frontend.js"), "utf8")
|
||||
.replaceAll(
|
||||
'"react/jsx-runtime"',
|
||||
JSON.stringify(pathToFileURL(stub).href),
|
||||
);
|
||||
const runnable = path.join(dir, "dist", "runnable.mjs");
|
||||
fs.writeFileSync(runnable, bundle);
|
||||
|
||||
const mod = await import(pathToFileURL(runnable).href);
|
||||
expect(mod.rendered).toEqual({ type: "div" });
|
||||
expect(mod.dev).toBe(false);
|
||||
expect(mod.mode).toBe("production");
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin CSS layers", () => {
|
||||
it("renames the Tailwind layers so core's utilities always win", () => {
|
||||
expect(
|
||||
lowerPluginLayers(
|
||||
"@layer properties{a{b:c}}@layer utilities{.flex{display:flex}}",
|
||||
),
|
||||
).toBe(
|
||||
"@layer termix-plugin-properties{a{b:c}}@layer termix-plugin-utilities{.flex{display:flex}}",
|
||||
);
|
||||
expect(lowerPluginLayers("@layer utilities-extra{}")).toBe(
|
||||
"@layer utilities-extra{}",
|
||||
);
|
||||
});
|
||||
|
||||
it("is ordered in core's CSS between base and core's utilities", () => {
|
||||
const css = fs.readFileSync(
|
||||
path.join(__dirname, "..", "src", "ui", "index.css"),
|
||||
"utf8",
|
||||
);
|
||||
const order = /@layer ([^;{]+);/.exec(css)?.[1].split(/[\s,]+/);
|
||||
expect(order).toEqual([
|
||||
"properties",
|
||||
"termix-plugin-properties",
|
||||
"theme",
|
||||
"base",
|
||||
"components",
|
||||
"termix-plugin-utilities",
|
||||
"utilities",
|
||||
]);
|
||||
expect(css.indexOf("@layer properties,")).toBeLessThan(
|
||||
css.indexOf('@import "tailwindcss"'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0))
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function fixture() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-cli-bootstrap-"));
|
||||
roots.push(root);
|
||||
const repo = path.resolve(import.meta.dirname, "..");
|
||||
fs.cpSync(
|
||||
path.join(repo, "packages/plugin-sdk/cli"),
|
||||
path.join(root, "packages/plugin-sdk/cli"),
|
||||
{ recursive: true },
|
||||
);
|
||||
fs.symlinkSync(
|
||||
path.join(repo, "node_modules"),
|
||||
path.join(root, "node_modules"),
|
||||
"dir",
|
||||
);
|
||||
fs.mkdirSync(path.join(root, "scripts"));
|
||||
fs.copyFileSync(
|
||||
path.join(repo, "scripts/apply-plugin-patches.cjs"),
|
||||
path.join(root, "scripts/apply-plugin-patches.cjs"),
|
||||
);
|
||||
const plugin = path.join(root, "plugins/fixture");
|
||||
fs.mkdirSync(path.join(plugin, "patches"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(plugin, "manifest.json"),
|
||||
JSON.stringify({ id: "fixture" }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(plugin, "package.json"),
|
||||
JSON.stringify({ name: "fixture" }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(plugin, "patches/check.cjs"),
|
||||
'require("node:fs").writeFileSync("patched", "ok");',
|
||||
);
|
||||
const run = (...args: string[]) =>
|
||||
spawnSync(process.execPath, args, { cwd: plugin, encoding: "utf8" });
|
||||
return {
|
||||
root,
|
||||
plugin,
|
||||
run,
|
||||
cli: path.join(root, "packages/plugin-sdk/cli/index.mjs"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("plugin CLI before the SDK is built", () => {
|
||||
it("runs the postinstall plugin patches without SDK dist files", () => {
|
||||
const { root, plugin, run } = fixture();
|
||||
expect(fs.existsSync(path.join(root, "packages/plugin-sdk/dist"))).toBe(
|
||||
false,
|
||||
);
|
||||
const result = run(path.join(root, "scripts/apply-plugin-patches.cjs"));
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(fs.readFileSync(path.join(plugin, "patched"), "utf8")).toBe("ok");
|
||||
});
|
||||
it("prints help without SDK dist files", () => {
|
||||
const { cli, run } = fixture();
|
||||
const result = run(cli, "--help");
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.stdout).toContain("Usage: termix-plugin");
|
||||
});
|
||||
it("still requires a built SDK for validation with an actionable error", () => {
|
||||
const { cli, run } = fixture();
|
||||
const result = run(cli, "validate");
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("Run: npm run build:sdk");
|
||||
expect(result.stderr).not.toContain("ERR_MODULE_NOT_FOUND");
|
||||
});
|
||||
});
|
||||
@@ -1,220 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://termix.site/schemas/plugin-manifest.schema.json",
|
||||
"title": "Termix Plugin Manifest",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"version",
|
||||
"description",
|
||||
"author",
|
||||
"license",
|
||||
"engine",
|
||||
"capabilities",
|
||||
"permissions",
|
||||
"sidecars"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9-]+$",
|
||||
"description": "Unique plugin id, lowercase alphanumeric and hyphens only."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z-.]+)?(\\+[0-9A-Za-z-.]+)?$",
|
||||
"description": "Semver version string."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"author": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": { "type": "string", "minLength": 1 },
|
||||
"url": { "type": "string", "format": "uri" }
|
||||
}
|
||||
},
|
||||
"license": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"repository": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"engine": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["termix", "api"],
|
||||
"properties": {
|
||||
"termix": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Informational-only Termix version range this plugin targets. Not mechanically enforced."
|
||||
},
|
||||
"api": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]+$",
|
||||
"description": "Plugin SDK major version, as a string. The real compatibility gate."
|
||||
}
|
||||
}
|
||||
},
|
||||
"capabilities": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["backend", "frontend", "electron", "platforms"],
|
||||
"properties": {
|
||||
"backend": { "type": "boolean" },
|
||||
"frontend": { "type": "boolean" },
|
||||
"electron": { "type": "boolean" },
|
||||
"platforms": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"hosts.read",
|
||||
"hosts.write",
|
||||
"credentials.use",
|
||||
"ssh.exec",
|
||||
"ssh.sftp",
|
||||
"storage.own",
|
||||
"storage.secrets",
|
||||
"network.outbound",
|
||||
"events.read",
|
||||
"notify.send",
|
||||
"users.read",
|
||||
"process.sidecar",
|
||||
"ui.tab",
|
||||
"ui.rail",
|
||||
"ui.card",
|
||||
"ui.settings",
|
||||
"ui.palette"
|
||||
]
|
||||
}
|
||||
},
|
||||
"contributes": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tabs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "titleKey", "icon", "openFrom"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"titleKey": { "type": "string", "minLength": 1 },
|
||||
"icon": { "type": "string", "minLength": 1 },
|
||||
"openFrom": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["rail", "host-context-menu", "palette"]
|
||||
},
|
||||
"minItems": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"hostCapability": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["key", "labelKey", "editorTab"],
|
||||
"properties": {
|
||||
"key": { "type": "string", "minLength": 1 },
|
||||
"labelKey": { "type": "string", "minLength": 1 },
|
||||
"editorTab": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
},
|
||||
"permissionGroup": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["group", "permissions"],
|
||||
"properties": {
|
||||
"group": { "type": "string", "minLength": 1 },
|
||||
"permissions": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 },
|
||||
"minItems": 1
|
||||
},
|
||||
"defaultForRole": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settingsPanel": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["titleKey"],
|
||||
"properties": {
|
||||
"titleKey": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
},
|
||||
"dashboardCards": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "titleKey"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"titleKey": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"apiPrefix": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9-]+$",
|
||||
"description": "Mounted at /plugin-api/<id>/<apiPrefix>/*."
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"plugins": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Semver range string."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidecars": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "binary"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"binary": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import * as tar from "tar";
|
||||
import { pack } from "../packages/plugin-sdk/cli/commands/pack.mjs";
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
function tempDir(prefix: string): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
return dir;
|
||||
}
|
||||
|
||||
function writeFile(root: string, rel: string, content: string) {
|
||||
const full = path.join(root, rel);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, content);
|
||||
}
|
||||
|
||||
function fixturePlugin(manifest: Record<string, unknown> = {}): string {
|
||||
const dir = path.join(tempDir("termix-pack-"), "pack-fixture");
|
||||
writeFile(
|
||||
dir,
|
||||
"manifest.json",
|
||||
JSON.stringify({
|
||||
id: "pack-fixture",
|
||||
name: "Pack Fixture",
|
||||
version: "1.2.3",
|
||||
description: "Fixture for the pack tests.",
|
||||
author: { name: "Termix Tests" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities: [],
|
||||
...manifest,
|
||||
}),
|
||||
);
|
||||
writeFile(dir, "dist/backend.js", "export async function activate() {}\n");
|
||||
writeFile(dir, "dist/frontend.js", "export function activate() {}\n");
|
||||
writeFile(dir, "locales/en.json", "{}\n");
|
||||
writeFile(dir, "migrations/sqlite/0001_init.sql", "SELECT 1;\n");
|
||||
writeFile(dir, "migrations/postgres/0001_init.sql", "SELECT 1;\n");
|
||||
writeFile(dir, "migrations/mysql/0001_init.sql", "SELECT 1;\n");
|
||||
writeFile(dir, "README.md", "# Fixture\n");
|
||||
writeFile(dir, "icon.svg", "<svg/>\n");
|
||||
writeFile(dir, "src/backend/index.ts", "left out of the archive\n");
|
||||
return dir;
|
||||
}
|
||||
|
||||
function sha(file: string): string {
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
.update(fs.readFileSync(file))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
async function entries(file: string): Promise<string[]> {
|
||||
const names: string[] = [];
|
||||
await tar.t({ file, onReadEntry: (entry) => names.push(entry.path) });
|
||||
return names;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
while (cleanups.length) cleanups.pop()?.();
|
||||
});
|
||||
|
||||
describe("termix-plugin pack", () => {
|
||||
it("gives the same sha256 when rebuilt with new mtimes", async () => {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const dir = fixturePlugin();
|
||||
const first = await pack({ cwd: dir, args: ["--out", tempDir("a-")] });
|
||||
|
||||
const later = new Date(Date.now() + 86_400_000);
|
||||
for (const rel of ["manifest.json", "dist/backend.js", "README.md"]) {
|
||||
fs.utimesSync(path.join(dir, rel), later, later);
|
||||
}
|
||||
const second = await pack({ cwd: dir, args: ["--out", tempDir("b-")] });
|
||||
|
||||
expect(path.basename(first)).toBe("pack-fixture-1.2.3.tmxplug");
|
||||
expect(sha(first)).toBe(sha(second));
|
||||
});
|
||||
|
||||
it("holds only the shipped files, sorted, with fixed metadata", async () => {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const dir = fixturePlugin();
|
||||
const file = await pack({ cwd: dir, args: ["--out", tempDir("c-")] });
|
||||
|
||||
const names = await entries(file);
|
||||
expect(names).toEqual([
|
||||
"README.md",
|
||||
"dist/backend.js",
|
||||
"dist/frontend.js",
|
||||
"icon.svg",
|
||||
"locales/en.json",
|
||||
"manifest.json",
|
||||
"migrations/mysql/0001_init.sql",
|
||||
"migrations/postgres/0001_init.sql",
|
||||
"migrations/sqlite/0001_init.sql",
|
||||
]);
|
||||
|
||||
const meta: Array<{ mode: number; mtime: number; uid: number }> = [];
|
||||
await tar.t({
|
||||
file,
|
||||
onReadEntry: (entry) =>
|
||||
meta.push({
|
||||
mode: entry.mode ?? -1,
|
||||
mtime: entry.mtime?.getTime() ?? -1,
|
||||
uid: entry.uid ?? -1,
|
||||
}),
|
||||
});
|
||||
expect(new Set(meta.map((m) => JSON.stringify(m)))).toEqual(
|
||||
new Set([JSON.stringify({ mode: 0o644, mtime: 0, uid: 0 })]),
|
||||
);
|
||||
|
||||
const gz = fs.readFileSync(file);
|
||||
expect(gz.readUInt32LE(4)).toBe(0);
|
||||
expect(gz[9]).toBe(0x03);
|
||||
|
||||
const out = tempDir("x-");
|
||||
await tar.x({ file, cwd: out });
|
||||
expect(fs.readFileSync(path.join(out, "dist/backend.js"), "utf8")).toBe(
|
||||
"export async function activate() {}\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a manifest with errors", async () => {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const dir = fixturePlugin({ capabilities: ["not:a-capability"] });
|
||||
const out = tempDir("d-");
|
||||
await expect(pack({ cwd: dir, args: ["--out", out] })).rejects.toThrow(
|
||||
/manifest is not valid/,
|
||||
);
|
||||
expect(fs.readdirSync(out)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
keygen,
|
||||
sign,
|
||||
verify,
|
||||
} from "../packages/plugin-sdk/cli/commands/sign.mjs";
|
||||
import {
|
||||
generateKeyPair,
|
||||
keyId,
|
||||
loadPrivateKey,
|
||||
signArtifact,
|
||||
verifyArtifact,
|
||||
} from "../packages/plugin-sdk/cli/lib/signing.mjs";
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
function tempDir(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-sign-"));
|
||||
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
while (cleanups.length) cleanups.pop()?.();
|
||||
});
|
||||
|
||||
describe("signing helpers", () => {
|
||||
const artifact = Buffer.from("plugin bytes");
|
||||
|
||||
it("verifies a signature from the matching key", () => {
|
||||
const pair = generateKeyPair();
|
||||
const signature = signArtifact(artifact, loadPrivateKey(pair.privateKey));
|
||||
expect(verifyArtifact(artifact, signature, [pair.publicKey])).toBe(
|
||||
pair.keyId,
|
||||
);
|
||||
expect(keyId(pair.publicKey)).toMatch(/^[0-9a-f]{16}$/);
|
||||
});
|
||||
|
||||
it("rejects a tampered artifact", () => {
|
||||
const pair = generateKeyPair();
|
||||
const signature = signArtifact(artifact, loadPrivateKey(pair.privateKey));
|
||||
expect(
|
||||
verifyArtifact(Buffer.from("plugin bytez"), signature, [pair.publicKey]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects the wrong key", () => {
|
||||
const signer = generateKeyPair();
|
||||
const other = generateKeyPair();
|
||||
const signature = signArtifact(artifact, loadPrivateKey(signer.privateKey));
|
||||
expect(verifyArtifact(artifact, signature, [other.publicKey])).toBeNull();
|
||||
expect(
|
||||
verifyArtifact(artifact, signature, [other.publicKey, signer.publicKey]),
|
||||
).toBe(signer.keyId);
|
||||
});
|
||||
|
||||
it("refuses a missing or non-Ed25519 key", () => {
|
||||
expect(() => loadPrivateKey(undefined)).toThrow(/not set/);
|
||||
expect(() => loadPrivateKey("not a key")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("keygen, sign and verify commands", () => {
|
||||
it("round trips and never prints the private key", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const dir = tempDir();
|
||||
const file = path.join(dir, "demo-1.0.0.tmxplug");
|
||||
fs.writeFileSync(file, "archive");
|
||||
|
||||
const { keyFile, publicKey } = await keygen({
|
||||
cwd: dir,
|
||||
args: ["--out", dir],
|
||||
});
|
||||
const privateKey = fs.readFileSync(keyFile, "utf8").trim();
|
||||
expect(log.mock.calls.flat().join("\n")).not.toContain(privateKey);
|
||||
|
||||
await sign({
|
||||
cwd: dir,
|
||||
args: [file],
|
||||
env: { TERMIX_PLUGIN_SIGNING_KEY: privateKey },
|
||||
});
|
||||
expect(fs.existsSync(`${file}.sig`)).toBe(true);
|
||||
await expect(
|
||||
verify({ cwd: dir, args: [file, "--key", publicKey] }),
|
||||
).resolves.toMatch(/^[0-9a-f]{16}$/);
|
||||
|
||||
fs.writeFileSync(file, "archivE");
|
||||
await expect(
|
||||
verify({ cwd: dir, args: [file, "--key", publicKey] }),
|
||||
).rejects.toThrow(/not valid/);
|
||||
});
|
||||
|
||||
it("refuses to sign without the key env var", async () => {
|
||||
const dir = tempDir();
|
||||
const file = path.join(dir, "demo-1.0.0.tmxplug");
|
||||
fs.writeFileSync(file, "archive");
|
||||
await expect(sign({ cwd: dir, args: [file], env: {} })).rejects.toThrow(
|
||||
/TERMIX_PLUGIN_SIGNING_KEY is not set/,
|
||||
);
|
||||
});
|
||||
|
||||
it("will not overwrite an existing key file", async () => {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const dir = tempDir();
|
||||
await keygen({ cwd: dir, args: ["--out", dir] });
|
||||
await expect(keygen({ cwd: dir, args: ["--out", dir] })).rejects.toThrow(
|
||||
/already exists/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createRequire } from "node:module";
|
||||
import postcss, { type AcceptedPlugin } from "postcss";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const config = require("../postcss.config.cjs") as {
|
||||
plugins: AcceptedPlugin[];
|
||||
};
|
||||
|
||||
async function convert(css: string, from = "src/ui/index.css") {
|
||||
const result = await postcss(config.plugins).process(css, { from });
|
||||
return result.css;
|
||||
}
|
||||
|
||||
describe("interface size conversion", () => {
|
||||
it("preserves circular radii emitted in exponent notation", async () => {
|
||||
expect(await convert(".a{border-radius:3.40282e38px}")).toBe(
|
||||
".a{border-radius:calc(infinity * 1px)}",
|
||||
);
|
||||
});
|
||||
|
||||
it("turns px into rem against the 14px Normal root", async () => {
|
||||
expect(await convert(".a{font-size:10px;width:280px}")).toBe(
|
||||
".a{font-size:0.71429rem;width:20rem}",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps hairlines, breakpoints and the root size rules in px", async () => {
|
||||
expect(await convert(".b{border-width:1px}")).toBe(".b{border-width:1px}");
|
||||
expect(await convert("@media (min-width:768px){.c{gap:7px}}")).toBe(
|
||||
"@media (min-width:768px){.c{gap:0.5rem}}",
|
||||
);
|
||||
expect(await convert("html.fs-lg{font-size:17px}")).toBe(
|
||||
"html.fs-lg{font-size:17px}",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves third-party CSS alone", async () => {
|
||||
expect(
|
||||
await convert(
|
||||
".xterm{padding:4px}",
|
||||
"node_modules/@xterm/xterm/css/xterm.css",
|
||||
),
|
||||
).toBe(".xterm{padding:4px}");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Runs every plugin's own vitest suite.
|
||||
*
|
||||
* Core's `npm run test` covers src/ only. A plugin's tests live beside it and
|
||||
* run under its own config, so a plugin that moves out of this repo keeps a
|
||||
* working suite.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const source = path.join(root, "plugins");
|
||||
const cli = path.join(root, "packages", "plugin-sdk", "cli", "index.mjs");
|
||||
|
||||
const pluginIds = fs
|
||||
.readdirSync(source, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.filter((entry) =>
|
||||
fs.existsSync(path.join(source, entry.name, "vitest.config.ts")),
|
||||
)
|
||||
.map((entry) => entry.name);
|
||||
|
||||
const failed = [];
|
||||
const skipped = [];
|
||||
let ran = 0;
|
||||
|
||||
// A vitest fork that dies mid-run (seen on Windows under memory pressure)
|
||||
// fails the suite without any test failing. That is retried once; a real
|
||||
// test failure is not.
|
||||
const WORKER_CRASH = /Worker exited unexpectedly|Worker forks emitted error/;
|
||||
|
||||
function runSuite(pluginDir) {
|
||||
const result = spawnSync(process.execPath, [cli, "test"], {
|
||||
cwd: pluginDir,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
process.stdout.write(result.stdout ?? "");
|
||||
process.stderr.write(result.stderr ?? "");
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const id of pluginIds) {
|
||||
const pluginDir = path.join(source, id);
|
||||
|
||||
const hasTests = ["tests/backend", "tests/frontend"].some((dir) => {
|
||||
const full = path.join(pluginDir, dir);
|
||||
return (
|
||||
fs.existsSync(full) &&
|
||||
fs
|
||||
.readdirSync(full, { recursive: true })
|
||||
.some(
|
||||
(name) =>
|
||||
String(name).endsWith(".test.ts") ||
|
||||
String(name).endsWith(".test.tsx"),
|
||||
)
|
||||
);
|
||||
});
|
||||
if (!hasTests) {
|
||||
skipped.push(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
ran++;
|
||||
let result = runSuite(pluginDir);
|
||||
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
||||
if (result.status !== 0 && WORKER_CRASH.test(output)) {
|
||||
console.warn(`\n${id}: a test worker crashed, running the suite again`);
|
||||
result = runSuite(pluginDir);
|
||||
}
|
||||
if (result.status !== 0) failed.push(id);
|
||||
}
|
||||
|
||||
if (skipped.length > 0) {
|
||||
console.warn(`\nNo tests found for: ${skipped.join(", ")}`);
|
||||
}
|
||||
|
||||
if (failed.length > 0) {
|
||||
console.error(`\nPlugin tests failed: ${failed.join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\nPlugin tests passed for ${ran} plugin(s).`);
|
||||
@@ -0,0 +1,35 @@
|
||||
# Throwaway Postgres and MySQL servers for scripts/upgrade-check.sh. Data
|
||||
# lives in tmpfs, so every run starts from an empty server.
|
||||
name: termix-upgrade-check
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_USER: termix
|
||||
POSTGRES_PASSWORD: termix
|
||||
POSTGRES_DB: termix
|
||||
ports:
|
||||
- "55433:5432"
|
||||
tmpfs:
|
||||
- /var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U termix -d termix"]
|
||||
interval: 2s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
|
||||
mysql:
|
||||
image: mysql:8
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: termix
|
||||
MYSQL_DATABASE: termix
|
||||
ports:
|
||||
- "33307:3306"
|
||||
tmpfs:
|
||||
- /var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-ptermix"]
|
||||
interval: 2s
|
||||
timeout: 5s
|
||||
retries: 60
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
# Upgrades the 2.8 fixture install to 2.9.0 on real Postgres and MySQL
|
||||
# servers and runs the same checks the SQLite upgrade test runs.
|
||||
#
|
||||
# Needs Docker and a built tree (npm run build). Run from anywhere:
|
||||
# bash scripts/upgrade-check.sh # both engines
|
||||
# bash scripts/upgrade-check.sh postgres # one engine
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
COMPOSE=(docker compose -f "$ROOT/scripts/upgrade-check.compose.yml")
|
||||
TAG="release-2.8.0-tag"
|
||||
if [ "$#" -gt 0 ]; then
|
||||
DIALECTS=("$@")
|
||||
else
|
||||
DIALECTS=(postgres mysql)
|
||||
fi
|
||||
|
||||
cd "$ROOT"
|
||||
|
||||
if [ ! -f dist/plugins/snippets/dist/backend.js ]; then
|
||||
echo "dist/plugins is missing. Run npm run build first." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
|
||||
echo "Tag $TAG is missing. Run git fetch --tags first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LEGACY="$(mktemp -d)"
|
||||
cleanup() {
|
||||
"${COMPOSE[@]}" down -v >/dev/null 2>&1 || true
|
||||
rm -rf "$LEGACY"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# 2.8's own drizzle migrations build the 2.8 schema on each server.
|
||||
git archive "$TAG" drizzle | tar -x -C "$LEGACY"
|
||||
|
||||
"${COMPOSE[@]}" up -d --wait
|
||||
|
||||
status=0
|
||||
for dialect in "${DIALECTS[@]}"; do
|
||||
case "$dialect" in
|
||||
postgres) url="postgres://termix:termix@127.0.0.1:55433/termix" ;;
|
||||
mysql) url="mysql://root:termix@127.0.0.1:33307/termix" ;;
|
||||
*)
|
||||
echo "Unknown dialect $dialect (postgres or mysql)" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
echo
|
||||
echo "== 2.8 to 2.9.0 on $dialect"
|
||||
if ! TEST_DIALECT="$dialect" TEST_DATABASE_URL="$url" \
|
||||
UPGRADE_LEGACY_MIGRATIONS_DIR="$LEGACY/drizzle" \
|
||||
npx vitest run src/backend/tests/plugins/upgrade-fixture.test.ts \
|
||||
--project backend; then
|
||||
status=1
|
||||
fi
|
||||
done
|
||||
|
||||
exit "$status"
|
||||
@@ -0,0 +1,44 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { LEGACY_TABLE_OWNERS } from "../packages/plugin-sdk/src/db";
|
||||
import { buildUpgradeSql } from "./lib/upgrade-sql.mjs";
|
||||
|
||||
const SNAPSHOTS = path.resolve(
|
||||
__dirname,
|
||||
"../src/backend/tests/fixtures/upgrade",
|
||||
);
|
||||
|
||||
const RENAME = {
|
||||
postgres: /^ALTER TABLE "([a-z0-9_]+)" RENAME TO "(p_[a-z0-9_]+)";$/gm,
|
||||
mysql: /^RENAME TABLE `([a-z0-9_]+)` TO `(p_[a-z0-9_]+)`;$/gm,
|
||||
};
|
||||
|
||||
describe.each(["postgres", "mysql"] as const)(
|
||||
"the 2.8 to 2.9.0 upgrade SQL on %s",
|
||||
(dialect) => {
|
||||
const sql = buildUpgradeSql(dialect);
|
||||
const statements = sql.replace(/^--.*$/gm, "");
|
||||
|
||||
it("matches the reviewed snapshot", async () => {
|
||||
await expect(sql).toMatchFileSnapshot(
|
||||
path.join(SNAPSHOTS, `upgrade-${dialect}.sql`),
|
||||
);
|
||||
});
|
||||
|
||||
it("adopts every legacy table by renaming it, never by dropping it", () => {
|
||||
const renamed = new Set(
|
||||
[...sql.matchAll(RENAME[dialect])].map((match) => match[1]),
|
||||
);
|
||||
for (const table of Object.keys(LEGACY_TABLE_OWNERS)) {
|
||||
expect(renamed, table).toContain(table);
|
||||
expect(statements).not.toMatch(
|
||||
new RegExp(`DROP TABLE[^;]*[\`"]${table}[\`"]`, "i"),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("never drops a column", () => {
|
||||
expect(statements).not.toMatch(/DROP COLUMN/i);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,225 +1,94 @@
|
||||
/**
|
||||
* Validates a plugin manifest.json against scripts/plugin-manifest.schema.json.
|
||||
* Validates plugin manifests against the SDK contract.
|
||||
*
|
||||
* Hand-rolled rather than pulling in ajv: the schema is small and the checks
|
||||
* that matter (required fields, id pattern, semver, known permissions) are a
|
||||
* handful of straightforward comparisons.
|
||||
* node scripts/validate-plugin-manifest.cjs # every bundled manifest
|
||||
* node scripts/validate-plugin-manifest.cjs path/to/manifest.json
|
||||
*
|
||||
* Usage: node scripts/validate-plugin-manifest.cjs <path-to-manifest.json>
|
||||
* The rules live in packages/plugin-sdk/src/manifest.ts, which the server
|
||||
* uses too, so there is one implementation rather than a copy here that
|
||||
* drifts. This file only finds the manifests and prints the result.
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
|
||||
const SCHEMA_PATH = path.join(__dirname, "plugin-manifest.schema.json");
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const sdkEntry = path.join(
|
||||
root,
|
||||
"packages",
|
||||
"plugin-sdk",
|
||||
"dist",
|
||||
"manifest.js",
|
||||
);
|
||||
|
||||
const ID_PATTERN = /^[a-z0-9-]+$/;
|
||||
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/;
|
||||
const API_VERSION_PATTERN = /^[0-9]+$/;
|
||||
const OPEN_FROM_VALUES = ["rail", "host-context-menu", "palette"];
|
||||
|
||||
function loadSchema() {
|
||||
const raw = fs.readFileSync(SCHEMA_PATH, "utf8");
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
function knownPermissions(schema) {
|
||||
return schema.properties.permissions.items.enum;
|
||||
}
|
||||
|
||||
function validateManifest(manifest, schema) {
|
||||
const errors = [];
|
||||
|
||||
const requiredTopLevel = schema.required;
|
||||
for (const field of requiredTopLevel) {
|
||||
if (!(field in manifest)) {
|
||||
errors.push(`Missing required field: "${field}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof manifest.id === "string" && !ID_PATTERN.test(manifest.id)) {
|
||||
errors.push(`Field "id" must match ${ID_PATTERN}, got: "${manifest.id}"`);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof manifest.version === "string" &&
|
||||
!SEMVER_PATTERN.test(manifest.version)
|
||||
) {
|
||||
errors.push(
|
||||
`Field "version" must be valid semver, got: "${manifest.version}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (manifest.author && typeof manifest.author === "object") {
|
||||
if (!manifest.author.name) {
|
||||
errors.push('Field "author.name" is required');
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.engine && typeof manifest.engine === "object") {
|
||||
if (!manifest.engine.termix) {
|
||||
errors.push('Field "engine.termix" is required');
|
||||
}
|
||||
if (!API_VERSION_PATTERN.test(String(manifest.engine.api))) {
|
||||
errors.push(
|
||||
`Field "engine.api" must be an integer-as-string, got: "${manifest.engine.api}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.capabilities && typeof manifest.capabilities === "object") {
|
||||
for (const field of ["backend", "frontend", "electron"]) {
|
||||
if (typeof manifest.capabilities[field] !== "boolean") {
|
||||
errors.push(`Field "capabilities.${field}" must be a boolean`);
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(manifest.capabilities.platforms)) {
|
||||
errors.push('Field "capabilities.platforms" must be an array');
|
||||
}
|
||||
}
|
||||
|
||||
const known = knownPermissions(schema);
|
||||
if (Array.isArray(manifest.permissions)) {
|
||||
for (const permission of manifest.permissions) {
|
||||
if (!known.includes(permission)) {
|
||||
errors.push(
|
||||
`Unknown permission: "${permission}". Known values: ${known.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if ("permissions" in manifest) {
|
||||
errors.push('Field "permissions" must be an array');
|
||||
}
|
||||
|
||||
if (Array.isArray(manifest.sidecars)) {
|
||||
manifest.sidecars.forEach((sidecar, index) => {
|
||||
if (!sidecar || typeof sidecar !== "object") {
|
||||
errors.push(`sidecars[${index}] must be an object`);
|
||||
return;
|
||||
}
|
||||
if (!sidecar.id) errors.push(`sidecars[${index}].id is required`);
|
||||
if (!sidecar.binary) errors.push(`sidecars[${index}].binary is required`);
|
||||
});
|
||||
} else if ("sidecars" in manifest) {
|
||||
errors.push('Field "sidecars" must be an array');
|
||||
}
|
||||
|
||||
if (manifest.contributes && typeof manifest.contributes === "object") {
|
||||
errors.push(...validateContributes(manifest.contributes));
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateContributes(contributes) {
|
||||
const errors = [];
|
||||
|
||||
if ("tabs" in contributes) {
|
||||
if (!Array.isArray(contributes.tabs)) {
|
||||
errors.push('Field "contributes.tabs" must be an array');
|
||||
} else {
|
||||
contributes.tabs.forEach((tab, index) => {
|
||||
for (const field of ["id", "titleKey", "icon", "openFrom"]) {
|
||||
if (!(field in (tab || {}))) {
|
||||
errors.push(`contributes.tabs[${index}].${field} is required`);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(tab?.openFrom)) {
|
||||
for (const value of tab.openFrom) {
|
||||
if (!OPEN_FROM_VALUES.includes(value)) {
|
||||
errors.push(
|
||||
`contributes.tabs[${index}].openFrom has unknown value: "${value}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if ("hostCapability" in contributes) {
|
||||
for (const field of ["key", "labelKey", "editorTab"]) {
|
||||
if (!(field in (contributes.hostCapability || {}))) {
|
||||
errors.push(`contributes.hostCapability.${field} is required`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ("permissionGroup" in contributes) {
|
||||
const group = contributes.permissionGroup || {};
|
||||
if (!group.group)
|
||||
errors.push("contributes.permissionGroup.group is required");
|
||||
if (!Array.isArray(group.permissions) || group.permissions.length === 0) {
|
||||
errors.push(
|
||||
"contributes.permissionGroup.permissions must be a non-empty array",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ("settingsPanel" in contributes) {
|
||||
if (!contributes.settingsPanel?.titleKey) {
|
||||
errors.push("contributes.settingsPanel.titleKey is required");
|
||||
}
|
||||
}
|
||||
|
||||
if ("dashboardCards" in contributes) {
|
||||
if (!Array.isArray(contributes.dashboardCards)) {
|
||||
errors.push('Field "contributes.dashboardCards" must be an array');
|
||||
} else {
|
||||
contributes.dashboardCards.forEach((card, index) => {
|
||||
for (const field of ["id", "titleKey"]) {
|
||||
if (!(field in (card || {}))) {
|
||||
errors.push(
|
||||
`contributes.dashboardCards[${index}].${field} is required`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const manifestPath = process.argv[2];
|
||||
if (!manifestPath) {
|
||||
async function loadValidator() {
|
||||
if (!fs.existsSync(sdkEntry)) {
|
||||
console.error(
|
||||
"Usage: node scripts/validate-plugin-manifest.cjs <path-to-manifest.json>",
|
||||
`The plugin SDK is not built yet (${path.relative(root, sdkEntry)} is missing).\n` +
|
||||
`Run: npm run build:sdk`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return import(pathToFileURL(sdkEntry).href);
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(manifestPath);
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
console.error(`Manifest not found: ${resolvedPath}`);
|
||||
process.exit(1);
|
||||
function bundledManifests() {
|
||||
const pluginsDir = path.join(root, "plugins");
|
||||
if (!fs.existsSync(pluginsDir)) return [];
|
||||
|
||||
return fs
|
||||
.readdirSync(pluginsDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(pluginsDir, entry.name, "manifest.json"))
|
||||
.filter((file) => fs.existsSync(file));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { parseManifest } = await loadValidator();
|
||||
|
||||
const argument = process.argv[2];
|
||||
const files = argument
|
||||
? [path.resolve(process.cwd(), argument)]
|
||||
: bundledManifests();
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log("No plugin manifests found.");
|
||||
return;
|
||||
}
|
||||
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(fs.readFileSync(resolvedPath, "utf8"));
|
||||
} catch (err) {
|
||||
console.error(`Manifest is not valid JSON: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
let failed = 0;
|
||||
|
||||
const schema = loadSchema();
|
||||
const errors = validateManifest(manifest, schema);
|
||||
for (const file of files) {
|
||||
const relative = path.relative(root, file);
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(`Invalid plugin manifest: ${resolvedPath}`);
|
||||
for (const error of errors) {
|
||||
console.error(` - ${error}`);
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
} catch (error) {
|
||||
console.error(`${relative}: not valid JSON: ${error.message}`);
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
process.exit(1);
|
||||
|
||||
const { errors } = parseManifest(raw);
|
||||
if (errors.length > 0) {
|
||||
console.error(`${relative}:`);
|
||||
for (const error of errors) console.error(` - ${error}`);
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`ok ${relative}`);
|
||||
}
|
||||
|
||||
console.log(`Valid plugin manifest: ${resolvedPath}`);
|
||||
if (failed > 0) {
|
||||
console.error(`\n${failed} manifest(s) failed validation.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { validateManifest, loadSchema };
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -5,13 +5,12 @@ import path from "node:path";
|
||||
const VALIDATOR = path.join(__dirname, "validate-plugin-manifest.cjs");
|
||||
const FIXTURES = path.join(__dirname, "__fixtures__", "manifests");
|
||||
|
||||
function runValidator(fixture: string): { status: number; output: string } {
|
||||
function runValidator(fixture?: string): { status: number; output: string } {
|
||||
const args = fixture
|
||||
? [VALIDATOR, path.join(FIXTURES, fixture)]
|
||||
: [VALIDATOR];
|
||||
try {
|
||||
const output = execFileSync(
|
||||
"node",
|
||||
[VALIDATOR, path.join(FIXTURES, fixture)],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
const output = execFileSync("node", args, { encoding: "utf8" });
|
||||
return { status: 0, output };
|
||||
} catch (err) {
|
||||
const error = err as { status: number; stdout: string; stderr: string };
|
||||
@@ -23,24 +22,76 @@ describe("validate-plugin-manifest.cjs", () => {
|
||||
it("accepts a valid manifest", () => {
|
||||
const { status, output } = runValidator("valid.json");
|
||||
expect(status).toBe(0);
|
||||
expect(output).toContain("Valid plugin manifest");
|
||||
expect(output).toContain("ok");
|
||||
});
|
||||
|
||||
it("accepts a manifest declaring services", () => {
|
||||
expect(runValidator("valid-services.json").status).toBe(0);
|
||||
});
|
||||
|
||||
it("accepts a manifest declaring actions", () => {
|
||||
expect(runValidator("valid-actions.json").status).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a manifest missing a required field", () => {
|
||||
const { status, output } = runValidator("invalid-missing-field.json");
|
||||
expect(status).not.toBe(0);
|
||||
expect(output).toContain('Missing required field: "description"');
|
||||
expect(status).toBe(1);
|
||||
expect(output).toContain("id");
|
||||
});
|
||||
|
||||
it("rejects a manifest with a bad id pattern", () => {
|
||||
it("rejects an id that is not a lowercase slug", () => {
|
||||
const { status, output } = runValidator("invalid-bad-id.json");
|
||||
expect(status).not.toBe(0);
|
||||
expect(output).toMatch(/Field "id" must match/);
|
||||
expect(status).toBe(1);
|
||||
expect(output).toContain("id");
|
||||
});
|
||||
|
||||
it("rejects a manifest with an unknown permission", () => {
|
||||
const { status, output } = runValidator("invalid-unknown-permission.json");
|
||||
expect(status).not.toBe(0);
|
||||
expect(output).toContain('Unknown permission: "credentials.read"');
|
||||
it("rejects an unknown category", () => {
|
||||
expect(runValidator("invalid-unknown-category.json").status).toBe(1);
|
||||
});
|
||||
|
||||
// The dotted spelling was the v1 form. Capabilities are colon-separated now
|
||||
// so they can never be confused with RBAC permissions.
|
||||
it("rejects a dotted capability name", () => {
|
||||
const { status, output } = runValidator("invalid-unknown-capability.json");
|
||||
expect(status).toBe(1);
|
||||
expect(output).toContain("hosts.read");
|
||||
});
|
||||
|
||||
it("rejects a service permission the plugin does not declare", () => {
|
||||
expect(runValidator("invalid-service-permission.json").status).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects an action permission the plugin does not declare", () => {
|
||||
expect(runValidator("invalid-action-permission.json").status).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects an unknown top-level field", () => {
|
||||
const { status, output } = runValidator("invalid-unknown-field.json");
|
||||
expect(status).toBe(1);
|
||||
expect(output).toContain("sudoEverything");
|
||||
});
|
||||
|
||||
it("rejects an unknown field nested inside contributes", () => {
|
||||
const { status, output } = runValidator(
|
||||
"invalid-nested-unknown-field.json",
|
||||
);
|
||||
expect(status).toBe(1);
|
||||
expect(output).toContain("extra");
|
||||
});
|
||||
|
||||
// A manifest must not be able to name a core namespace, which is how a
|
||||
// plugin could otherwise gate a route on admin authority it never had.
|
||||
it("rejects a permission name outside the plugin's own namespace", () => {
|
||||
const { status, output } = runValidator(
|
||||
"invalid-role-default-escalation.json",
|
||||
);
|
||||
expect(status).toBe(1);
|
||||
expect(output).toContain("admin.users.manage");
|
||||
});
|
||||
|
||||
it("validates every bundled manifest when given no argument", () => {
|
||||
const { status, output } = runValidator();
|
||||
expect(status).toBe(0);
|
||||
expect(output).toContain("ssh-terminal");
|
||||
});
|
||||
});
|
||||
@@ -132,6 +132,54 @@ check(
|
||||
await hosts.deleteForUser(userId, host.id);
|
||||
check("host really deleted", await hosts.findById(host.id), null);
|
||||
|
||||
// A plugin's own tables. The DDL is emitted from a table definition rather
|
||||
// than written by hand, so this is what proves the emitter's output is valid
|
||||
// on a real engine - the unit tests can only snapshot the string.
|
||||
const { defineTable, id, varchar, text, refUser, timestamp } =
|
||||
await import("../packages/plugin-sdk/dist/db.js");
|
||||
const { createTableSql, dropTableSql } =
|
||||
await import("../packages/plugin-sdk/dist/ddl.js");
|
||||
const { sql: raw } = await import("drizzle-orm");
|
||||
|
||||
const pluginTable = defineTable(
|
||||
"verify_note",
|
||||
{
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
title: varchar(200).notNull(),
|
||||
body: text(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
{ indexes: [{ name: "idx_verify_note_user", columns: ["userId"] }] },
|
||||
);
|
||||
|
||||
const pluginId = "verify";
|
||||
try {
|
||||
for (const statement of createTableSql(dialect, pluginId, pluginTable)) {
|
||||
await db.execute(raw.raw(statement));
|
||||
}
|
||||
check("plugin table DDL is accepted", true, true);
|
||||
|
||||
await db.execute(
|
||||
raw.raw(
|
||||
`INSERT INTO p_verify_verify_note (user_id, title) VALUES ('${userId}', 'from verify')`,
|
||||
),
|
||||
);
|
||||
const rows = await db.execute(
|
||||
raw.raw("SELECT title FROM p_verify_verify_note"),
|
||||
);
|
||||
const list = Array.isArray(rows) ? rows : (rows?.rows ?? []);
|
||||
check("plugin table round-trips a row", list.length > 0, true);
|
||||
} catch (error) {
|
||||
check(`plugin table DDL is accepted (${error.message})`, false, true);
|
||||
} finally {
|
||||
try {
|
||||
await db.execute(raw.raw(dropTableSql(dialect, pluginId, pluginTable)));
|
||||
} catch {
|
||||
// Best effort: the scratch database is the caller's to clean.
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
failures === 0
|
||||
? `\n${dialect}: all checks passed\n`
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Makes the shell a host for plugin bundles.
|
||||
*
|
||||
* - Every shared module (React, i18next and the SDK) becomes an entry chunk,
|
||||
* reached through a stable shim at dist/shared/<name>.js.
|
||||
* - index.html gets an import map pointing those bare specifiers at the shims,
|
||||
* so a plugin bundle's `import "react"` lands on the shell's React.
|
||||
* - In `vite dev`, src/ui/plugin-host/workspace-plugins.ts is replaced with
|
||||
* import.meta.glob calls, so the monorepo's plugins load through Vite with
|
||||
* HMR, and the map points at dev-server module URLs instead.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import {
|
||||
importMapCspHash,
|
||||
productionImportMap,
|
||||
sharedModules,
|
||||
shimName,
|
||||
shimPath,
|
||||
SHARED_VENDOR_MODULES,
|
||||
SHARED_VENDORS,
|
||||
} from "./lib/plugin-import-map.mjs";
|
||||
|
||||
const VIRTUAL_PREFIX = "\0termix-shared:";
|
||||
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
||||
|
||||
function hasDefaultExport(file) {
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
return (
|
||||
/\bexport\s+default\b/.test(source) ||
|
||||
/\bexport\s*\{[^}]*\bas\s+default\b/.test(source)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* CommonJS packages (React among them) lose their named exports through
|
||||
* `export *`, so their facade lists every name explicitly, read from the
|
||||
* package itself at build time.
|
||||
*/
|
||||
function vendorFacade(repoRoot, specifier) {
|
||||
const vendor = SHARED_VENDORS.find((item) => item.specifier === specifier);
|
||||
if (vendor && !vendor.cjs) {
|
||||
const target = JSON.stringify(specifier);
|
||||
return vendor.hasDefault
|
||||
? `export * from ${target};
|
||||
export { default } from ${target};`
|
||||
: `export * from ${target};`;
|
||||
}
|
||||
const require = createRequire(path.join(repoRoot, "package.json"));
|
||||
const names = Object.keys(require(specifier)).filter(
|
||||
(name) =>
|
||||
name !== "default" && name !== "__esModule" && IDENTIFIER.test(name),
|
||||
);
|
||||
const lines = [`import * as m from ${JSON.stringify(specifier)};`];
|
||||
lines.push("const d = m.default ?? m;");
|
||||
for (const name of names) {
|
||||
lines.push(`export const ${name} = m.${name} ?? d.${name};`);
|
||||
}
|
||||
lines.push("export default d;");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function sourceFacade(file) {
|
||||
const target = JSON.stringify(file.replaceAll("\\", "/"));
|
||||
const lines = [`export * from ${target};`];
|
||||
if (hasDefaultExport(file)) lines.push(`export { default } from ${target};`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function termixPluginHost({ repoRoot, sdkUiEntry, sdkFrontendEntry }) {
|
||||
let command = "build";
|
||||
let shared = [];
|
||||
const workspaceModule = path
|
||||
.join(repoRoot, "src", "ui", "plugin-host", "workspace-plugins.ts")
|
||||
.replaceAll("\\", "/");
|
||||
|
||||
const facadeSource = (specifier) => {
|
||||
if (SHARED_VENDOR_MODULES.includes(specifier)) {
|
||||
return vendorFacade(repoRoot, specifier);
|
||||
}
|
||||
if (specifier === "@termix/plugin-sdk/ui") return sourceFacade(sdkUiEntry);
|
||||
if (specifier === "@termix/plugin-sdk/frontend") {
|
||||
return sourceFacade(sdkFrontendEntry);
|
||||
}
|
||||
throw new Error(`termix-plugin-host: ${specifier} is not a shared module`);
|
||||
};
|
||||
|
||||
return {
|
||||
name: "termix-plugin-host",
|
||||
|
||||
config(_config, env) {
|
||||
command = env.command;
|
||||
shared = sharedModules();
|
||||
if (command !== "build") return;
|
||||
const input = { index: path.join(repoRoot, "index.html") };
|
||||
for (const specifier of shared) {
|
||||
input[`shared-${shimName(specifier)}`] = VIRTUAL_PREFIX + specifier;
|
||||
}
|
||||
return {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input,
|
||||
// Entry exports are the contract with plugin bundles; they must
|
||||
// survive tree-shaking even though nothing in the shell uses them.
|
||||
preserveEntrySignatures: "exports-only",
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
resolveId(id) {
|
||||
if (id.startsWith(VIRTUAL_PREFIX)) return id;
|
||||
return null;
|
||||
},
|
||||
|
||||
load(id) {
|
||||
if (id.startsWith(VIRTUAL_PREFIX)) {
|
||||
return facadeSource(id.slice(VIRTUAL_PREFIX.length));
|
||||
}
|
||||
if (
|
||||
command === "serve" &&
|
||||
id.replaceAll("\\", "/").split("?")[0] === workspaceModule
|
||||
) {
|
||||
return [
|
||||
'const frontends = import.meta.glob("/plugins/*/src/frontend/index.{tsx,ts}");',
|
||||
'const locales = import.meta.glob("/plugins/*/locales/**/*.json", { import: "default" });',
|
||||
"export const workspaceFrontends = {};",
|
||||
"export const workspaceLocales = {};",
|
||||
"for (const [file, load] of Object.entries(frontends)) {",
|
||||
' workspaceFrontends[file.split("/")[2]] = load;',
|
||||
"}",
|
||||
"for (const [file, load] of Object.entries(locales)) {",
|
||||
' const parts = file.split("/");',
|
||||
" const id = parts[2];",
|
||||
' const name = parts[parts.length - 1].replace(/\\.json$/, "");',
|
||||
" (workspaceLocales[id] ??= {})[name] = load;",
|
||||
"}",
|
||||
].join("\n");
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
generateBundle(_options, bundle) {
|
||||
for (const chunk of Object.values(bundle)) {
|
||||
if (chunk.type !== "chunk" || !chunk.isEntry) continue;
|
||||
const facade = chunk.facadeModuleId ?? "";
|
||||
if (!facade.startsWith(VIRTUAL_PREFIX)) continue;
|
||||
const specifier = facade.slice(VIRTUAL_PREFIX.length);
|
||||
const target = JSON.stringify(`../${chunk.fileName}`);
|
||||
const lines = [`export * from ${target};`];
|
||||
if (chunk.exports.includes("default")) {
|
||||
lines.push(`export { default } from ${target};`);
|
||||
}
|
||||
this.emitFile({
|
||||
type: "asset",
|
||||
fileName: shimPath(specifier),
|
||||
source: `${lines.join("\n")}\n`,
|
||||
});
|
||||
}
|
||||
const text = productionImportMap(shared);
|
||||
this.emitFile({
|
||||
type: "asset",
|
||||
fileName: "importmap.sha256",
|
||||
source: `${importMapCspHash(text)}\n`,
|
||||
});
|
||||
},
|
||||
|
||||
transformIndexHtml: {
|
||||
order: "pre",
|
||||
handler() {
|
||||
const text =
|
||||
command === "build"
|
||||
? productionImportMap(shared)
|
||||
: JSON.stringify({
|
||||
imports: Object.fromEntries(
|
||||
[...shared]
|
||||
.sort()
|
||||
.map((specifier) => [
|
||||
specifier,
|
||||
`/@id/__x00__${VIRTUAL_PREFIX.slice(1)}${specifier}`,
|
||||
]),
|
||||
),
|
||||
});
|
||||
return [
|
||||
{
|
||||
tag: "script",
|
||||
attrs: { type: "importmap" },
|
||||
children: text,
|
||||
injectTo: "head-prepend",
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user