Compare commits

..
Author SHA1 Message Date
LukeGus 7066215bd9 feat: plugin builds bundle worker entries and import wasm and fonts as bytes 2026-10-16 21:43:07 -05:00
LukeGus 214372d104 chore: update the overview video and add a play button to its thumbnail 2026-10-16 18:20:53 -05:00
LukeGus bb9bca5303 chore: add the overview video to the readme 2026-10-16 16:24:11 -05:00
LukeGus cf62f108d2 chore: update translations 2026-10-16 15:14:15 +00:00
LukeGus 5cc65d87cd feat(sdk): let a plugin raise its public route rate limit 2026-10-15 22:18:48 -05:00
LukeGus e166994ec4 fix: use the vite proxy for plugin sockets on any dev port 2026-10-15 22:05:08 -05:00
LukeGus 3a851ad189 feat: setting to let the hover side menu push content again 2026-10-15 22:00:24 -05:00
LukeGus d2851982a6 fix(sdk): skip comment-only chunks when splitting migrations 2026-10-15 21:52:11 -05:00
LukeGus cc5dce18e2 fix: show why a client tunnel failed to start (Plugin-Tunnels#2) 2026-10-15 21:42:28 -05:00
LukeGus 6084ed4d3c fix: say the server is down and keep retrying instead of a database error (#1637) 2026-10-15 21:40:07 -05:00
LukeGus c3f6db69e9 fix: let the hover side menu overlay content instead of resizing it (Plugin-Remote-Desktop#2) 2026-10-15 21:15:36 -05:00
LukeGus 8e2221cb8f feat: sort the dashboard host status card (Plugin-Host-Metrics#6) 2026-10-15 21:11:58 -05:00
LukeGus 7324982f47 feat: option to hide the sidebar after opening a host (#1652) 2026-10-15 21:06:47 -05:00
LukeGus f20d64b4f2 chore: test that an update keeps a changed auth type (#1601) 2026-10-15 21:04:04 -05:00
LukeGus aa0b32a142 fix: stop building ssh2 native crypto in the docker image (#1644) 2026-10-15 21:02:54 -05:00
LukeGus c8a907538e chore: add changelog entries for 26.10.2 fixes 2026-10-15 21:02:13 -05:00
LukeGus dd5573cc10 fix: smooth host editor jumps to lower sections 2026-10-15 15:08:40 -05:00
ZacharyZcR 071711dafc fix(sync): don't delete a local row when the server can't send its copy (#1650)
* fix(sync): don't delete a local row when the server can't send its copy

A push conflict on a live record whose server row failed to serialize
came back as row: null, deleted: false, and the desktop treated that as
a delete. The server now rejects that op as unavailable, the desktop
never deletes on a conflict without a row, and serialization failures
are logged.

* fix(sync): retry temporarily unavailable records
2026-10-15 14:59:59 +08:00
ZacharyZcR bc74fba3f5 fix(settings): leave user manage view when switching admin sections (#1649)
The admin panel stays mounted across Settings pages, so an open user
manage view kept replacing every other admin section until Back to users
was clicked. Reset it whenever the selected section changes.
2026-10-15 14:55:00 +08:00
Ferdina Kusumah 694dc34b19 fix(i18n): keep Indonesian technical terms consistent (#1636)
Use the English terms Indonesian users know for tunnel, snippet, deploy,
file manager, remote desktop, jump host, passphrase, sidebar, toolbar,
endpoint, browser and SSH agent, instead of mixing literal translations
(terowongan, cuplikan, terapkan, bilah sisi, titik akhir, ...) with the
English words. Match the split shortcuts to the split screen menu.

Also fix the user data export description, which said secrets are
encrypted while the English says they are decrypted.
2026-10-15 14:24:11 +08:00
ZacharyZcR b3b08b4ff2 fix(sync): let a linked desktop enable mirrored plugins it does not trust on its own (#1632)
* fix(sync): let a linked desktop enable mirrored plugins it does not trust on its own

* fix(sync): switch off an untrusted mirrored plugin when the server updates it

* fix: require local consent for mirrored plugin activation

* docs: describe consent fields on the plugin state endpoint
2026-10-14 09:11:55 +08:00
ZacharyZcR 9b0af07607 fix: drop ssh_data.vault_profile_id so removing the vault plugin keeps hosts writable (#1633)
The vault plugin renames vault_profiles to p_vault_profiles, and SQLite
rewrites ssh_data's foreign key to follow it. Uninstalling the plugin then
dropped p_vault_profiles and left ssh_data pointing at a missing table, so
creating, cloning and deleting hosts failed with "no such table:
main.p_vault_profiles". Postgres and MySQL refused the uninstall instead.

The column was already copied into the vault plugin's host settings by 2.9,
so retire it on every engine. The SQLite boot drop also repairs databases
already in the broken state; MySQL drops the column's foreign key first.
2026-10-14 08:59:57 +08:00
ZacharyZcR c044dbde87 fix(sync): send shared hosts with text columns in their stored form (#1631)
resolveHostById turns forceKeyboardInteractive into a boolean, and the
shared-host export passed it on as is. A desktop could not bind the boolean
into the text column, so every shared host failed to apply.
2026-10-14 08:59:52 +08:00
ZacharyZcR 1396b3a70c fix(desktop): find spawn-helper in universal macOS builds (#1630)
The universal build ships app-arm64.asar and app-x64.asar instead of
app.asar, so node-pty's helper path rewrite never pointed at the
unpacked copy and every local terminal failed with posix_spawnp failed.
2026-10-14 08:59:46 +08:00
ZacharyZcR 4c9ee5923e fix(sidebar): sort hosts by IP address numerically (#1629) 2026-10-14 08:59:40 +08:00
ZacharyZcR 25e58a6425 fix(desktop): harden external editor and session cookie IPC (#1627)
* fix(desktop): stop the renderer choosing the external editor binary or reading the session cookie

open-external-editor now launches only the editor saved from a file dialog
and refuses to hand executable file types to the OS opener. The unused
get-session-cookie and wait-session-cookie channels, which returned the
httpOnly jwt, are removed.

* fix: validate setting keys before editor authorization
2026-10-14 08:22:15 +08:00
ZacharyZcR 7d13b2a9fe fix: don't auto-enable synced plugins signed only by a server-sent key (#1623)
* fix: don't auto-enable synced plugins signed only by a server-sent key

Registry keys a linked server sends still let its plugins load on the desktop, but no longer count as a signer for switching them on or copying their grants. Keys the desktop added itself keep their trust.

* fix: enforce local plugin trust across remote source labels

* fix: require positive local trust for synced plugin keys
2026-10-14 08:20:09 +08:00
ZacharyZcR e610aba0e5 fix: enforce unique usernames (#1628)
* fix: enforce unique usernames

Registration checked for a taken name, hashed the password, then
inserted, with nothing in the schema enforcing uniqueness. Concurrent
sign-ups could create two accounts with one name, after which login,
password reset, proxy login and admin lookups by username picked
between them arbitrarily.

Add a unique index on users.username for every dialect. Existing
duplicates are renamed (later rows get a -<id> suffix, the oldest keeps
the name) rather than deleted. Registration and admin user creation now
return the same 409 when the insert loses the race.

* fix: avoid username collisions during migration
2026-10-14 08:17:47 +08:00
ZacharyZcR 3941dd4ecc fix: keep foreign keys on during user data import (#1626)
Import credentials before hosts and remap the file's credential ids to
the ones created here, so hosts and protocol logins no longer point at
another server's ids. With rows written in dependency order, the import
no longer turns off SQLite foreign keys on the shared connection. The
uploaded file is now removed on every exit path.
2026-10-14 08:13:04 +08:00
ZacharyZcR 872e5eba73 fix: check trusted proxy login against nginx's peer, not nginx (#1625)
The backend only sees the bundled nginx on loopback, so the allowlist
could only work by trusting 127.0.0.1, which let any client that reached
nginx log in as anyone by sending the identity headers. nginx now passes
its own TCP peer (before real_ip rewriting) in X-Termix-Proxy-Peer, and
loopback connections are judged by that address instead.
2026-10-14 08:12:58 +08:00
ZacharyZcR 8c3f816407 fix: only trust the server's origin in the desktop login frame (#1624)
The desktop sign-in iframe accepted AUTH_SUCCESS and system browser requests from whatever page it had navigated to, and posted replies (including 2FA temp tokens) to any origin. Messages must now come from the server's origin and replies are posted to it.

The web UI also treated any framing page as the desktop app and posted the JWT to it. It now also requires the Termix-Desktop user agent.
2026-10-14 08:12:53 +08:00
ZacharyZcR 13f7aa95a3 fix(plugins): read artifact manifest with the same tar parser as the unpack (#1622)
The consent check read the first manifest.json with a hand-written tar
reader, while the unpack keeps the last one and also follows pax and
long-name headers. An archive with two manifests could pass the check
with one capability list and get granted another. Read the manifest
with node-tar, count only plain files, and refuse archives that hold
more than one manifest.json.
2026-10-14 08:12:47 +08:00
ZacharyZcR 8e5a50623c fix(electron): validate external login URL scheme and callback port (#1621) 2026-10-14 08:12:42 +08:00
ZacharyZcR d791e52d82 fix: stop auto-accepting changed jump host keys (#1620)
A jump hop whose key no longer matched the pinned fingerprint was
silently accepted and the pin overwritten. Background status probes
reconnect to jump hosts every cycle, so a MITM on that hop got the
credentials with no user interaction. Changed jump host keys now go
through the same path as any other host: prompt when there is a
terminal socket, otherwise refuse.
2026-10-14 08:12:37 +08:00
ZacharyZcR fb04edc497 fix: keep concurrent ssh pool connects from overwriting each other (#1619) 2026-10-14 08:12:16 +08:00
402 changed files with 79577 additions and 29709 deletions

No files matched your search

+4 -33
View File
@@ -143,12 +143,12 @@ jobs:
- name: Build Windows (All Architectures)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npm run build && npx electron-builder --win --x64 --ia32 --arm64 --publish never
run: npm run build && npx electron-builder --win --x64 --ia32 --publish never
# The updater only installs the x64 NSIS build; 32-bit and ARM installs get a link.
# The combined multi-arch installer is never uploaded.
# The updater only installs the x64 NSIS build; 32-bit installs get a link.
# The combined x64+ia32 installer is never uploaded.
- name: Fix Windows update files
run: node scripts/fix-update-info.cjs --drop ia32 --drop arm64 --drop termix_windows_nsis.exe release/latest.yml release/beta.yml
run: node scripts/fix-update-info.cjs --drop ia32 --drop termix_windows_nsis.exe release/latest.yml release/beta.yml
- name: Upload Windows x64 NSIS Installer
uses: actions/upload-artifact@v7
@@ -166,14 +166,6 @@ jobs:
path: release/termix_windows_ia32_nsis.exe
retention-days: 30
- name: Upload Windows arm64 NSIS Installer
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_windows_arm64_nsis.exe') != '' && inputs.artifact_destination != 'none'
with:
name: termix_windows_arm64_nsis
path: release/termix_windows_arm64_nsis.exe
retention-days: 30
- name: Upload Windows x64 MSI Installer
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_windows_x64_msi.msi') != '' && inputs.artifact_destination != 'none'
@@ -190,14 +182,6 @@ jobs:
path: release/termix_windows_ia32_msi.msi
retention-days: 30
- name: Upload Windows arm64 MSI Installer
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_windows_arm64_msi.msi') != '' && inputs.artifact_destination != 'none'
with:
name: termix_windows_arm64_msi
path: release/termix_windows_arm64_msi.msi
retention-days: 30
- name: Create Windows x64 Portable zip
if: hashFiles('release/win-unpacked/*') != ''
run: |
@@ -208,11 +192,6 @@ jobs:
run: |
Compress-Archive -Path "release\win-ia32-unpacked\*" -DestinationPath "termix_windows_ia32_portable.zip"
- name: Create Windows arm64 Portable zip
if: hashFiles('release/win-arm64-unpacked/*') != ''
run: |
Compress-Archive -Path "release\win-arm64-unpacked\*" -DestinationPath "termix_windows_arm64_portable.zip"
- name: Upload Windows update files
uses: actions/upload-artifact@v7
if: hashFiles('release/*.yml') != '' && inputs.artifact_destination != 'none'
@@ -240,14 +219,6 @@ jobs:
path: termix_windows_ia32_portable.zip
retention-days: 30
- name: Upload Windows arm64 Portable
uses: actions/upload-artifact@v7
if: hashFiles('termix_windows_arm64_portable.zip') != '' && inputs.artifact_destination != 'none'
with:
name: termix_windows_arm64_portable
path: termix_windows_arm64_portable.zip
retention-days: 30
build-linux:
runs-on: blacksmith-8vcpu-ubuntu-2404
if: (inputs.build_type == 'all' || inputs.build_type == 'linux' || inputs.build_type == '') && inputs.artifact_destination != 'submit'
+1
View File
@@ -11,3 +11,4 @@ db
*.min.css
openapi.json
drizzle/
src/ui/settings/settings-search-index.ts
-32
View File
@@ -1,40 +1,15 @@
# Changelog
## 26.10.3
### Added
- Choose the layout of the whole app in Settings, Appearance. Admins can also set the default layout and theme for everyone
- Plugins can add layouts and themes
- A repair screen shows when the chosen layout cannot load, with ways to update, switch, reset or turn it off. Open it any time with ?safe-layout=1, or for everyone with TERMIX_SAFE_LAYOUT=1
- Hovering a theme in Settings previews it
- Restore the bundled copy of a plugin from the repair screen, which also pins the shipped version
- Switching layouts closes the tabs only the old layout could show, like its Settings tab
- A chosen layout whose plugin was uninstalled falls back to the default instead of the repair screen
### Changed
- The icon rail, sidebar, tab bar, dock, Dashboard, Manage, Settings and command palette now come from a bundled layout. It can be swapped for another layout but not removed, and a fix to it no longer needs a Termix release
- Your theme is saved to your account so your other devices use it too, unless you keep settings on this device only
- The themes besides light and dark now come from a bundled themes plugin. It can be swapped but not removed, and Termix keeps a basic light and dark theme of its own
- A theme that no longer exists shows as System instead of an unstyled page
## 26.10.2
### Added
- Hide sidebar after opening, in Customize Sidebar, closes the host list once you open a host
- Sort the dashboard's Host Status card by name, IP address or status
- Windows on ARM builds of the desktop app (installer, MSI and portable)
- Plugins get an event when a host is added
- Plugins can start a short-lived session as a user and read the user's language, for tasks that run without a browser
- Plugins can pin a panel to the right edge of the window that pushes the page over
### Changed
- The Docker Compose files no longer start a guacd container. npm run dev -- --guacd also points remote desktop at the guacd it starts
- The side menu now opens over the page when it expands on hover, so open sessions like remote desktop are not resized each time. Turn on Push content when it expands in Settings to get the old behavior back
- On macOS, moving between split panes is now Cmd+Option+Arrow, so Option+Arrow can move the cursor by word in a terminal
### Fixed
@@ -59,13 +34,6 @@
- nginx answered 404 instead of 502 while the backend was down
- A client tunnel that could not start always said "Endpoint SSH connection closed" on the desktop app instead of the real reason, like the target refusing the connection
- The Docker server crashed and restarted every few hours in ssh2's native crypto on Node 24
- Plugin names and descriptions showed in English in the plugin manager, setup picker and host editor no matter the UI language
- Creating a host through the API without an auth type failed with a server error. It now picks the type from the credential, key or password, or says auth type is required
- The desktop app would not start its local server when another program used port 30001. It now picks a free port on its own
- A linked desktop could never finish syncing with a server on Postgres
- Routine plugin activity filled the audit log and pushed out real security entries
- Opening a full-screen view link while signed out showed a spinner forever. It now shows the login and opens the link after
- A linked desktop behind a proxy that needs extra headers, or with an allowed self-signed certificate, could not open remote terminals or tunnels
## 26.10.1
+2 -2
View File
@@ -1,6 +1,6 @@
cask "termix" do
version "26.10.2"
sha256 "9df1850b5f75a3c0a1b3876847d46b7b4ff993aa1e50da55f4c88819308ba223"
version "26.10.1"
sha256 "08a8fdc6ce3f29bd9031b050a4f6364f3ca446f17cb272392f1b3b488cea5d30"
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
name "Termix"
+22 -1
View File
@@ -137,7 +137,7 @@ Want to build your own? Start from the [plugin template](https://github.com/Term
See the [docs](https://docs.termix.site/install) for install steps on every platform.
Sample Docker Compose file. Remote desktop (RDP, VNC and Telnet) is built in, so nothing else needs to run next to it:
Sample Docker Compose file (you can leave out `guacd` and the network if you don't plan to use the Remote Desktop plugin):
```yaml
services:
@@ -151,10 +151,31 @@ services:
- termix-data:/app/data
environment:
PORT: "8080"
GUACD_HOST: "guacd"
GUACD_TUNNEL_HOST: "termix"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
depends_on:
- guacd
networks:
- termix-net
guacd:
image: guacamole/guacd:1.6.0
container_name: guacd
restart: unless-stopped
volumes:
- termix-data:/termix-data
networks:
- termix-net
volumes:
termix-data:
driver: local
networks:
termix-net:
driver: bridge
```
### Cloud Hosting
+1 -1
View File
@@ -26,7 +26,7 @@ COPY . .
RUN find public/fonts -name "*.ttf" ! -name "*Regular.ttf" ! -name "*Bold.ttf" ! -name "*Italic.ttf" -delete
RUN npm cache clean --force && \
TERMIX_RUNTIME_BASE_PATH=true NODE_OPTIONS="--max-old-space-size=3072" npm run build
NODE_OPTIONS="--max-old-space-size=3072" npm run build
# Stage 3: Build backend
FROM deps AS backend-builder
+68 -82
View File
@@ -3,20 +3,20 @@
{
"id": "acme-ssl",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.2/acme-ssl-1.0.2.tmxplug",
"sha256": "157d0f96dc42136ff64e618b6904db7862079cd31c1f1cf5f19846b1d127b00f"
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.1/acme-ssl-1.0.1.tmxplug",
"sha256": "cb5b7c56a281272cfff229d01a2729a4fa3936de04c0ac487d65bcf8dbd2f38e"
},
{
"id": "ai",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.1.0/ai-1.1.0.tmxplug",
"sha256": "71dda6940d62c9eede7821bbac21e99443fbc08728a47759f215e2416f1d162f"
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.0.1/ai-1.0.1.tmxplug",
"sha256": "2558c91e3e6823b3e18873d7b074762c4d76a1a0b96ff38d2dad13e0404b74a7"
},
{
"id": "alerts",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.2/alerts-1.0.2.tmxplug",
"sha256": "50768e77019276343a5d162c85d081659fb89d1cc3733ac45cd0db4436918faa",
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.1/alerts-1.0.1.tmxplug",
"sha256": "f5f233c0ffdeee956e1839a17a323c18ce8eb389a08569c3c6e5e24b698dd72a",
"onboarding": {
"recommended": true,
"consent": false
@@ -25,28 +25,14 @@
{
"id": "automations",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.2/automations-1.0.2.tmxplug",
"sha256": "674eb04276b4a0c77c794b30647f8680218a78de8d52b73e99ec900cc1b3fdb3"
},
{
"id": "classic-layout",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.0/classic-layout-1.0.0.tmxplug",
"sha256": "3a51cc9a4c966478f2ebfd58dbbe025ff0ebe8bbdcd3ba73156151e555f62dec",
"defaultLook": true
},
{
"id": "classic-themes",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Classic-Themes/releases/download/v1.0.0/classic-themes-1.0.0.tmxplug",
"sha256": "17561e569811476ea4e1f7cdf8d9afbe33edf061bd09f6b05699b44968da41ad",
"defaultLook": true
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.1/automations-1.0.1.tmxplug",
"sha256": "d2bb5298f8afac3f479ded81d5b1a799d9844b3f4990c8aaf2dd8fed7222428d"
},
{
"id": "docker",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.2/docker-1.0.2.tmxplug",
"sha256": "0e1c722682faac6a027fd9b01e6910eb53ce5acb13332c6c8d8868554f3701ab",
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.1/docker-1.0.1.tmxplug",
"sha256": "00e170e61ea9b7952449ea9ce30b09b983433f8d18d29f523cf3c6f98e7286e3",
"onboarding": {
"recommended": true,
"consent": false
@@ -55,8 +41,8 @@
{
"id": "file-manager",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.3/file-manager-1.0.3.tmxplug",
"sha256": "250e9779a6b1caf11721f1ee2baf76655ae6e52d963e894d8db84eb9677cf0b3",
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.2/file-manager-1.0.2.tmxplug",
"sha256": "4c735f5b795a8c676590a0dc25db1bcc6036ac250b9817a61867cebdc17931c6",
"onboarding": {
"recommended": true,
"consent": false
@@ -65,20 +51,20 @@
{
"id": "fleets",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.2/fleets-1.0.2.tmxplug",
"sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59"
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.1/fleets-1.0.1.tmxplug",
"sha256": "82ac71e69022c4991570e6fed109a9eec79421f081af0a1dcb6d8b8e4251ee3b"
},
{
"id": "homepage",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.2/homepage-1.0.2.tmxplug",
"sha256": "88cdea8ba8f458fcf124e7ab97e3d419896d8d22ffa9a372af34f1818872975b"
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.1/homepage-1.0.1.tmxplug",
"sha256": "573375726f334e37bbf504d9411c4ad2d4bfc4363219ff10dcea6cb2cb00ed32"
},
{
"id": "host-metrics",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.3/host-metrics-1.0.3.tmxplug",
"sha256": "8f6022568349fc4318d509781bdb9d5b40973c010a9e27f720eed6baf68b881e",
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.2/host-metrics-1.0.2.tmxplug",
"sha256": "433d5297e2fc263f2262b7ea33d7a795c9a970089aea328077861c3f032a23f5",
"onboarding": {
"recommended": true,
"consent": false
@@ -87,32 +73,32 @@
{
"id": "ldap",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.2/ldap-1.0.2.tmxplug",
"sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03"
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.1/ldap-1.0.1.tmxplug",
"sha256": "f5ba13c682d11060f373f85a9c7aaa131e587ec59c0498c0dabb013c2e474f5c"
},
{
"id": "network-topology",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.2/network-topology-1.0.2.tmxplug",
"sha256": "8d32340a0d0f9674b872da5d3d53e90f529b80dfe0d73ab294eed00e565fbb56"
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.1/network-topology-1.0.1.tmxplug",
"sha256": "4c8ec1ae0c39b8b72a89e0dce4b20a36df4adc826abe215b8ee9b69b44fe75ca"
},
{
"id": "opkssh",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.2/opkssh-1.0.2.tmxplug",
"sha256": "4bb8f1ac5d39bd18ac3d0fe82ddbf2f26ab4c01bd75e6f7a073d65c2e9aef219"
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.1/opkssh-1.0.1.tmxplug",
"sha256": "c19eadfe763e4dbecbd9a5e9e753576c577c75296ddc8e0af4cce98c2376b26f"
},
{
"id": "proxmox",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.2/proxmox-1.0.2.tmxplug",
"sha256": "b92be2644b0c8121732103b922069a6cb3cf00caad4e6549f7a8cb471bda5e4e"
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.1/proxmox-1.0.1.tmxplug",
"sha256": "4e0989856e061b9ab1ec25cc727051e03ea5e3dc1603e08895bd54e7ce132db7"
},
{
"id": "remote-desktop",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.1.0/remote-desktop-1.1.0.tmxplug",
"sha256": "6f74f1d26f91a43b2d2203417d744211f366c335d5fff7bed34bfce33874dd01",
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.0.2/remote-desktop-1.0.2.tmxplug",
"sha256": "70823a3f5161f6722fee22a9d5fe274dd47623960bc2188429c39a1c7e3e2b39",
"onboarding": {
"recommended": true,
"consent": false
@@ -121,14 +107,14 @@
{
"id": "secret-sources",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.2/secret-sources-1.0.2.tmxplug",
"sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134"
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.1/secret-sources-1.0.1.tmxplug",
"sha256": "9f5246325d3128c6376962f4cdbed33e78c487a4837f69235dfdcafeb293229a"
},
{
"id": "serial",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.2/serial-1.0.2.tmxplug",
"sha256": "6ede7e8726a7312429473f34a46f93ba6657c5b7ebc12bed18f80ec62c211715",
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.1/serial-1.0.1.tmxplug",
"sha256": "9908467fc184e40f108ff750bf2fb8044e000a436f84d0a7121109f4fcd5d121",
"onboarding": {
"recommended": "desktop",
"consent": false
@@ -137,20 +123,20 @@
{
"id": "session-recording",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.2/session-recording-1.0.2.tmxplug",
"sha256": "55ace6897710ce9a63f24e3d736a9e533cac557be0f3d98974956c94cf417d1c"
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.1/session-recording-1.0.1.tmxplug",
"sha256": "45a08f950244c862a3774f9a653b3e3645285edb507a49b5c8da123832faf1e7"
},
{
"id": "session-sharing",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.2/session-sharing-1.0.2.tmxplug",
"sha256": "6408e7949a9549ac5ec94b35205729099138e730df1598d50f4bb618d8d90804"
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.1/session-sharing-1.0.1.tmxplug",
"sha256": "753aa9c0998f194ccf5fc49fd39c133a87f21f21e803014c4bfac6ccc7f9acb1"
},
{
"id": "snippets",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.2/snippets-1.0.2.tmxplug",
"sha256": "cc1f5d74cf97f9a9c8f800fccf481c8a000e6fa516bc260ce24a7249d4f4c8e5",
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.1/snippets-1.0.1.tmxplug",
"sha256": "bf643828beba86677220d802c0df74b441d7954538637f37413f78d59c08585f",
"onboarding": {
"recommended": true,
"consent": false
@@ -159,8 +145,8 @@
{
"id": "ssh-terminal",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.3/ssh-terminal-1.0.3.tmxplug",
"sha256": "bc288bd843e37b64bcda7462d175039147b75dc6de8866769e4a938036a9d023",
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.2/ssh-terminal-1.0.2.tmxplug",
"sha256": "e65102a8cd6afdaa890095fa698f3f455a1035729b9fdca800a31cc6e5ed760f",
"onboarding": {
"recommended": true,
"consent": false
@@ -169,8 +155,8 @@
{
"id": "sso",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.3/sso-1.0.3.tmxplug",
"sha256": "87b03c0789d981dbc806162d73dfcc2c624150375277efd2ae40ab9d7657d48e",
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.2/sso-1.0.2.tmxplug",
"sha256": "29ab0e4748ee71cb11f9b8dacd630430d284b6ce24561cffae9b4d7fe64e9c3b",
"onboarding": {
"recommended": false,
"consent": false,
@@ -180,20 +166,20 @@
{
"id": "step-ca",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.2/step-ca-1.0.2.tmxplug",
"sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a"
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.1/step-ca-1.0.1.tmxplug",
"sha256": "ff4df5bae8c48ff61fe659b6ed69a40ac070862bc423972711a194d558d77cc8"
},
{
"id": "tailscale",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.2/tailscale-1.0.2.tmxplug",
"sha256": "5c7b00d6b6b0186d833302e39a9d31b92ea92d4542e9e2dab550b95837a59d97"
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.1/tailscale-1.0.1.tmxplug",
"sha256": "0efe1cbf0f431763611926b6a9e92630ca8c1a89ea57bb796c5d7bd3ce0ab55f"
},
{
"id": "telemetry",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.2/telemetry-1.0.2.tmxplug",
"sha256": "5ffdc58b05cd8b37fd0a207f99d2d3a256164130c950032501c499f869edb9ec",
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.1/telemetry-1.0.1.tmxplug",
"sha256": "c7f70e149c67a570f2b004c2cceeba27ffe8cbd95cd4df4bbffd880cc3451469",
"onboarding": {
"recommended": true,
"consent": true
@@ -202,20 +188,20 @@
{
"id": "termix-identity",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.2/termix-identity-1.0.2.tmxplug",
"sha256": "ac6c90a48b4f1e9cff6773a5fd267b2bf7319d2fc7e2aa2cd4004cec82f0f927"
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.1/termix-identity-1.0.1.tmxplug",
"sha256": "dc423ec53f012fdcc2cdccd872ac01353025e4da2ee9fea0d2a2d5988518a5a0"
},
{
"id": "tmux-monitor",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.3/tmux-monitor-1.0.3.tmxplug",
"sha256": "f152d64f433d3f1de7c3fdf172e752ea7e333909f0fdda77336a6b8dddb9f242"
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.2/tmux-monitor-1.0.2.tmxplug",
"sha256": "ea41f1f90bdeab6164cfa2a594f359492afa123ed06990afa338be69a9d974c6"
},
{
"id": "totp",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.2/totp-1.0.2.tmxplug",
"sha256": "94a0772660fa8e4dfd9afc15b68d5bc9fe02fe28a1acfd35e65a57c304d460c0",
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.1/totp-1.0.1.tmxplug",
"sha256": "4a083871663c2f6034904bd49bb02c2d4dd4ed4d50fbce1522172dae84991474",
"onboarding": {
"recommended": true,
"consent": false
@@ -224,8 +210,8 @@
{
"id": "tunnels",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.3/tunnels-1.0.3.tmxplug",
"sha256": "d0de9e59ccc9e1a0f2817dd1ec9951021f2d7e89304894bb1e8cefd65aebff0b",
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.2/tunnels-1.0.2.tmxplug",
"sha256": "44799c0bdbcc576fb0e1c1ecce96f283d2cc32b44ac5aeeb7253f8ca0b650860",
"onboarding": {
"recommended": true,
"consent": false
@@ -234,32 +220,32 @@
{
"id": "vault",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.2/vault-1.0.2.tmxplug",
"sha256": "48ef402d030912a836489edf0fdfcaf7929cbd67c21f16a2079cb4925134cc28"
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.1/vault-1.0.1.tmxplug",
"sha256": "c828eeebe37bf576020908e16a28e73073fc77178062b323058624f557a8c72a"
},
{
"id": "wake-on-lan",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.2/wake-on-lan-1.0.2.tmxplug",
"sha256": "3e9cf9a133b4dd96ec5f2b9fbf2d6a3ff7e08f8dfbf1e86b8d45de0c2f024f42"
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.1/wake-on-lan-1.0.1.tmxplug",
"sha256": "5301d26138cb37812e8ef1fc5d3df688415517d545667b365fca2f41166a8402"
},
{
"id": "warpgate",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.2/warpgate-1.0.2.tmxplug",
"sha256": "5c2c1c9aef40dbbf98424d02303f2ee77e6e869334247881e8aa0fcd8c415b47"
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.1/warpgate-1.0.1.tmxplug",
"sha256": "2f3b3169101ff04d0ea6229b31270ba0b5b8ff2c3d16e3f21a9e6c569d463ea8"
},
{
"id": "web-endpoint",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.2/web-endpoint-1.0.2.tmxplug",
"sha256": "0326edb1052d1debd083004c2dd1711d21a902cfacb55ce74d1a8856a5122477"
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.1/web-endpoint-1.0.1.tmxplug",
"sha256": "88477231ab3c93206c99393c6cc8628c1ff8159af6978d0d09db969c28e1d637"
},
{
"id": "webauthn",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.2/webauthn-1.0.2.tmxplug",
"sha256": "8269f6e407897332648c8526b65383c4db071ea782bf1e58aa7c7c00627f3f4b",
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.1/webauthn-1.0.1.tmxplug",
"sha256": "f9ccd5d6fe110b5c7ad8b90fd95e0a201e03d440462c2c3f6559406222364464",
"onboarding": {
"recommended": true,
"consent": false
@@ -268,8 +254,8 @@
{
"id": "workspaces",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.2/workspaces-1.0.2.tmxplug",
"sha256": "97519a01fbb2eb2eecf21d7b9ab9028c740457bcff9fc6b075fb6facbc5ccb6b",
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.1/workspaces-1.0.1.tmxplug",
"sha256": "17f53044318d6f53ef1e332d204f6469c7043fd9985c4d1eae16b6a1c3248153",
"onboarding": {
"recommended": true,
"consent": false
+15
View File
@@ -12,9 +12,24 @@ services:
environment:
PORT: "8080"
NODE_ENV: development
GUACD_HOST: "guacd-dev"
GUACD_TUNNEL_HOST: "termix-dev"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
# REDIS_URL: "redis://redis:6379"
# TERMIX_REDIS_PREFIX: "termix:collab"
# TERMIX_STEP_CA_REDIS_PREFIX: "termix:step-ca"
depends_on:
- guacd-dev
networks:
- termix-dev-net
guacd-dev:
image: guacamole/guacd:1.6.0
container_name: guacd-dev
restart: unless-stopped
volumes:
- termix-dev-data:/termix-data
networks:
- termix-dev-net
+17
View File
@@ -9,6 +9,12 @@ services:
- termix-data:/app/data
environment:
PORT: "8080"
GUACD_HOST: "guacd"
GUACD_TUNNEL_HOST: "termix"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
# Where guacd stores files for RDP drive redirection (one folder per
# user is created underneath). Must be writable by guacd's user.
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
# Multi-instance deployments should point every backend at the same
# Redis. Without it, collaboration rooms keep their single-instance
# in-memory fallback.
@@ -30,6 +36,17 @@ services:
# TRUSTED_PROXY_AUTH_ROLE_MAP: '{"operators":["user"]}'
# TRUSTED_PROXY_AUTH_USERNAME_HEADER: "x-forwarded-username"
# TRUSTED_PROXY_AUTH_ROLE_HEADER: "x-forwarded-role"
depends_on:
- guacd
networks:
- termix-net
guacd:
image: guacamole/guacd:1.6.0
container_name: guacd
restart: unless-stopped
volumes:
- termix-data:/termix-data
networks:
- termix-net
@@ -1,2 +0,0 @@
ALTER TABLE `user_preferences` ADD `layout_id` text;--> statement-breakpoint
ALTER TABLE `user_preferences` ADD `theme_id` text;
File diff suppressed because it is too large. Load diff
-7
View File
@@ -512,13 +512,6 @@
"when": 1791935283211,
"tag": "0072_unique_username",
"breakpoints": true
},
{
"idx": 73,
"version": "5",
"when": 1792357129912,
"tag": "0073_user_look_preferences",
"breakpoints": true
}
]
}
@@ -1,2 +0,0 @@
ALTER TABLE "user_preferences" ADD COLUMN "layout_id" text;--> statement-breakpoint
ALTER TABLE "user_preferences" ADD COLUMN "theme_id" text;
File diff suppressed because it is too large. Load diff
-7
View File
@@ -512,13 +512,6 @@
"when": 1791935282340,
"tag": "0072_unique_username",
"breakpoints": true
},
{
"idx": 73,
"version": "7",
"when": 1792357128746,
"tag": "0073_user_look_preferences",
"breakpoints": true
}
]
}
@@ -1,2 +0,0 @@
ALTER TABLE `user_preferences` ADD `layout_id` text;--> statement-breakpoint
ALTER TABLE `user_preferences` ADD `theme_id` text;
File diff suppressed because it is too large. Load diff
-7
View File
@@ -477,13 +477,6 @@
"when": 1791935281343,
"tag": "0067_unique_username",
"breakpoints": true
},
{
"idx": 68,
"version": "6",
"when": 1792357127581,
"tag": "0068_user_look_preferences",
"breakpoints": true
}
]
}
+2 -2
View File
@@ -46,11 +46,11 @@
"target": [
{
"target": "nsis",
"arch": ["x64", "ia32", "arm64"]
"arch": ["x64", "ia32"]
},
{
"target": "msi",
"arch": ["x64", "ia32", "arm64"]
"arch": ["x64", "ia32"]
}
],
"icon": "public/icon.ico",
+1 -8
View File
@@ -6,16 +6,9 @@
let linked = null;
const WEBSOCKET_TO_HTTP = { "ws:": "http:", "wss:": "https:" };
// A WebSocket to the linked server (wss://host/...) counts as the link's
// https://host origin, so its upgrade carries the proxy headers too.
function getOrigin(url) {
try {
const parsed = new URL(url);
const httpProtocol = WEBSOCKET_TO_HTTP[parsed.protocol];
if (httpProtocol) parsed.protocol = httpProtocol;
return parsed.origin;
return new URL(url).origin;
} catch {
return null;
}
+2 -8
View File
@@ -330,11 +330,7 @@ const AUTH_TOKEN_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
// path, so nothing here names it. Only a /plugin-api/<id> path is accepted,
// which keeps every transfer on the backend's plugin routes.
const API_PATH_PATTERN = /^\/plugin-api\/[a-z0-9][a-z0-9-]*$/;
let localBackendPort = 30001;
function setLocalBackendPort(port) {
localBackendPort = port;
}
const LOCAL_BACKEND = "http://localhost:30001";
let registeredApiPath = null;
function setTransferApiPath(apiPath) {
@@ -407,8 +403,7 @@ function createTargetResolver({ localBaseUrl, getLinkedServer, apiPath }) {
}
return {
url: `${normalizeHttpBase(
localBaseUrl ??
`http://localhost:${localBackendPort}${resolveApiPath()}`,
localBaseUrl ?? `${LOCAL_BACKEND}${resolveApiPath()}`,
"Local backend URL",
)}${routePath}`,
headers,
@@ -1055,7 +1050,6 @@ module.exports = {
createLocalFileHandlers,
createTargetResolver,
setTransferApiPath,
setLocalBackendPort,
publishDownload,
defaultPublishFs,
assertWithinRoot,
+11 -39
View File
@@ -35,10 +35,7 @@ const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs");
const { quitApp } = require("./app-quit.cjs");
const { selectLinuxPasswordStore } = require("./linux-password-store.cjs");
const { resolveLocalShell, repairSpawnHelpers } = require("./local-shell.cjs");
const {
registerLocalFileHandlers,
setLocalBackendPort,
} = require("./local-files.cjs");
const { registerLocalFileHandlers } = require("./local-files.cjs");
const {
EXTERNAL_EDITOR_SETTING,
isLaunchableFileName,
@@ -442,6 +439,14 @@ function isInsecureModeEnabled() {
);
}
function getOrigin(url) {
try {
return new URL(url).origin;
} catch {
return null;
}
}
function isPrivateNetworkHost(hostname) {
if (
hostname === "localhost" ||
@@ -485,7 +490,7 @@ function isInvalidCertificateAllowedForUrl(url) {
// allows a self-signed certificate.
const linked = linkedServer.getLinkedServer();
if (!linked?.allowInvalidCertificate) return false;
return linkedServer.isLinkedOrigin(url);
return getOrigin(url) === linked.origin;
}
function getTlsVerificationOptions(url) {
@@ -1119,35 +1124,6 @@ function reapOrphanedBackendProcess() {
}
}
const DEFAULT_BACKEND_PORT = 30001;
let backendPort = DEFAULT_BACKEND_PORT;
function canListenOn(port) {
return new Promise((resolve) => {
const probe = net.createServer();
probe.once("error", () => resolve(null));
probe.listen(port, "127.0.0.1", () => {
const { port: bound } = probe.address();
probe.close(() => resolve(bound));
});
});
}
// 30001 when it is free, otherwise any free port. Only the desktop app talks
// to this backend, so the number itself does not matter.
async function findBackendPort() {
const preferred = await canListenOn(DEFAULT_BACKEND_PORT);
if (preferred) return preferred;
const fallback = await canListenOn(0);
if (fallback) {
logToFile(
`Port ${DEFAULT_BACKEND_PORT} is in use, starting the backend on ${fallback}`,
);
return fallback;
}
return DEFAULT_BACKEND_PORT;
}
function startBackendServer() {
reapOrphanedBackendProcess();
backendFailure = null;
@@ -1188,8 +1164,7 @@ function startBackendServer() {
DATA_DIR: dataDir,
NODE_ENV: "production",
ELECTRON_EMBEDDED: "true",
PORT: String(backendPort),
EMBEDDED_BACKEND_PORT: String(backendPort),
PORT: "30001",
VERSION: app.getVersion(),
},
stdio: ["pipe", "pipe", "pipe", "ipc"],
@@ -1454,7 +1429,6 @@ function createWindow() {
sandbox: true,
webSecurity: true,
preload: path.join(__dirname, "preload.js"),
additionalArguments: [`--termix-backend-port=${backendPort}`],
partition: termixSessionPartition,
allowRunningInsecureContent: false,
webviewTag: false,
@@ -3840,8 +3814,6 @@ app.whenReady().then(async () => {
await clearElectronJwtCookiesAtStartup();
if (!isDev) {
backendPort = await findBackendPort();
setLocalBackendPort(backendPort);
const result = await startBackendServer();
logToFile("startBackendServer result:", result);
} else {
-9
View File
@@ -14,16 +14,7 @@ function invokeAllowed(channel, ...args) {
return ipcRenderer.invoke(channel, ...args);
}
// The embedded backend's port, picked by the main process at startup.
const backendPortArg = process.argv.find((arg) =>
arg.startsWith("--termix-backend-port="),
);
const backendPort = backendPortArg
? Number(backendPortArg.split("=")[1])
: undefined;
contextBridge.exposeInMainWorld("electronAPI", {
backendPort,
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
// Kept so the interface size can reset a zoom an earlier build applied.
setZoomFactor: (factor) => {
+10 -12
View File
@@ -71,6 +71,7 @@
"group": "server",
"description": "SSH agent socket used for hosts set to agent auth when they do not name their own socket."
},
{
"name": "ENABLE_SSL",
"group": "https",
@@ -107,6 +108,7 @@
"internal": true,
"description": "Set by the image when nginx serves HTTPS in front of the backend."
},
{
"name": "DATABASE_DIALECT",
"group": "database",
@@ -159,6 +161,7 @@
"internal": true,
"description": "Where database migrations are read from. Set by the image."
},
{
"name": "JWT_SECRET",
"group": "secrets",
@@ -201,6 +204,7 @@
"secret": true,
"description": "Only read to move data from installs older than 2.5. Never change it on an install that has it."
},
{
"name": "ALLOW_PASSWORD_LOGIN",
"group": "auth",
@@ -279,6 +283,7 @@
"group": "auth",
"description": "JSON map from proxy roles to Termix roles, like {\"admins\":[\"admin\"],\"staff\":[\"user\"]}. Required when trusted proxy login is on."
},
{
"name": "TRUSTED_PROXIES",
"group": "proxy",
@@ -310,6 +315,7 @@
"group": "proxy",
"description": "Comma separated hosts that skip the proxy."
},
{
"name": "LOG_LEVEL",
"group": "logging",
@@ -349,6 +355,7 @@
"deprecated": "the session-recording plugin setting",
"description": "Only read once to carry a 2.8 value into the session-recording plugin."
},
{
"name": "TERMIX_PLUGIN_REGISTRY_URL",
"group": "plugins",
@@ -370,12 +377,6 @@
"default": "false",
"description": "Only load plugins signed by a trusted key. Blocks uploads of unsigned files even in developer mode."
},
{
"name": "TERMIX_SAFE_LAYOUT",
"group": "plugins",
"default": "false",
"description": "Start everyone in the layout repair view instead of the chosen layout. Use it when a layout plugin breaks the app."
},
{
"name": "TERMIX_BUNDLED_PLUGINS_DIR",
"group": "plugins",
@@ -405,6 +406,7 @@
"internal": true,
"description": "Set by npm run dev."
},
{
"name": "ENABLE_INSECURE_MODE",
"group": "desktop",
@@ -433,12 +435,6 @@
"internal": true,
"description": "Set by the desktop app when it runs its own backend."
},
{
"name": "EMBEDDED_BACKEND_PORT",
"group": "desktop",
"internal": true,
"description": "Set by the desktop app to the port its own backend listens on. 30001 unless another program holds it."
},
{
"name": "TERMIX_DATA_DIR",
"group": "desktop",
@@ -463,12 +459,14 @@
"internal": true,
"description": "Set by Flatpak. The desktop app links new versions instead of installing them."
},
{
"name": "DESKTOP_SESSION",
"group": "desktop",
"internal": true,
"description": "Read on Linux to pick the password store."
},
{
"name": "VITE_BASE_PATH",
"group": "internal",
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "termix",
"version": "26.10.3",
"version": "26.10.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "termix",
"version": "26.10.3",
"version": "26.10.1",
"hasInstallScript": true,
"workspaces": [
"packages/*"
@@ -15058,7 +15058,7 @@
},
"packages/plugin-sdk": {
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.12",
"version": "1.0.9",
"license": "Apache-2.0",
"dependencies": {
"@deadendjs/swagger-jsdoc": "^8.1.2",
+3 -3
View File
@@ -4,7 +4,7 @@
"workspaces": [
"packages/*"
],
"version": "26.10.3",
"version": "26.10.1",
"description": "Self-hosted, plugin-based server management.",
"author": "Karmaa",
"main": "electron/main.cjs",
@@ -18,7 +18,7 @@
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs",
"prepare": "husky || true",
"prebuild": "node scripts/write-electron-build-info.cjs",
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-sdk-guide.cjs && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs",
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-sdk-guide.cjs && node scripts/generate-settings-search-index.cjs --check && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs",
"lint:fix": "eslint --fix .",
"type-check": "npm run build:sdk && tsc -b --force",
"test": "vitest run",
@@ -30,7 +30,7 @@
"dev:electron": "node scripts/dev.mjs electron",
"dev:docker": "node scripts/dev.mjs docker",
"dev:frontend": "vite",
"build": "npm run build:sdk && vite build && node scripts/stamp-sw-build-info.cjs && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
"build": "npm run build:sdk && vite build && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
"build:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
"generate:openapi": "npm run build:sdk && node scripts/generate-openapi.mjs",
"electron:rebuild": "electron-rebuild -f -o better-sqlite3,@serialport/bindings-cpp,node-pty",
-36
View File
@@ -1,41 +1,5 @@
# Changelog
## 1.0.12
Plugin API 1.4.0.
### Added
- `contributes.layouts` and `app.registerLayout()` for a whole-app layout, with the `ui:layout` capability. Needs `engine.api` 1.4
- `contributes.themes` for CSS-only themes, with the `ui:theme` capability. `termix-plugin validate` checks theme CSS and `pack` ships it
- `useShell()` gives a layout the live shell: tabs, splits, hosts, docks, the command palette and the registries. The shell's home tab is left out until some plugin registers a `dashboard` tab type
- `TabStage` and `ShellConfirmCapture` in `@termix-ssh/plugin-sdk/ui`, for a layout to place tabs
- `validateThemeCss()` in `@termix-ssh/plugin-sdk/manifest`
- `@termix-ssh/plugin-sdk/ui` also exports what a whole layout is built from: the API client, shared types, hooks, `useShellController()` and `APP_VERSION`. These reach into core, so they can change with a core release
- `@termix-ssh/plugin-sdk/testing-host` exports the registries and permission seam a layout's own tests fill directly
- A plugin that only holds themes needs no source: `termix-plugin build` writes an empty backend and `validate` does not ask for a frontend
- `termix-plugin validate` flags `ui:layout` or `ui:theme` with nothing contributed, and a layout plugin with no frontend
### Fixed
- The manifest JSON schema lists the `notify:hub` and `plugins:read` capabilities
- Plugin tests and type checks work with the SDK linked from a local Termix checkout
## 1.0.11
Plugin API 1.3.0.
### Added
- `ctx.auth.issueUserToken()` mints a short-lived session for a user, for calling Termix's own API as them from background work. Needs `users:impersonate` and `engine.api` 1.3
- `ctx.users.getLocale()` reads a user's UI language. Needs `users:read` and `engine.api` 1.3
## 1.0.10
### Changed
- `termix-plugin pack` includes the plugin's `LICENSE` and `NOTICE` files, so third-party notices ship with it
## 1.0.9
### Added
+37 -152
View File
@@ -1,6 +1,6 @@
# Termix plugin guide
SDK 1.0.12 | Plugin API 1.4.0
SDK 1.0.9 | Plugin API 1.2.0
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
@@ -90,7 +90,7 @@ npm test # termix-plugin test: Vitest
npm run typecheck # tsc
npm run validate # termix-plugin validate: manifest, files, migrations, changelog, docs
npm run migrations # termix-plugin migrations: write migrations after changing tables.ts
npm run pack # termix-plugin pack: write <id>-<version>.tmxplug (with LICENSE and NOTICE)
npm run pack # termix-plugin pack: write <id>-<version>.tmxplug
npm run format # Prettier
npm run format:check
```
@@ -105,7 +105,7 @@ Before you call a change finished:
- [ ] `npm run build` passes
- [ ] `npm run format:check` passes
- [ ] `npx termix-plugin migrations --check` passes (you ran `npm run migrations` after changing tables)
- [ ] New strings are in `locales/en.json`, and `plugin.name` / `plugin.description` match the manifest
- [ ] New strings are in `locales/en.json`
- [ ] New routes have an `@openapi` block and a test, including without the permission
- [ ] New env vars are listed in `env` in the manifest
- [ ] `docs/` and the "About this plugin" section of `AGENTS.md` still match what the plugin does
@@ -175,7 +175,7 @@ Unknown fields are refused at every level, so a typo fails validation. The schem
`contributes` holds `tabs`, `panels`, `dashboardCards`, `actions`, `actionSlots`, `permissions`,
`settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`,
`uiPresets`, `uses`, `layouts` and `themes`. Frontend `register*` calls need their ids declared here first.
`uiPresets` and `uses`. Frontend `register*` calls need their ids declared here first.
Capabilities and permissions are different things. A capability (`hosts:read`, with a colon) is what your
code may reach. A permission (`hello.use`, with a dot) is what a user may do.
@@ -225,8 +225,6 @@ Bundled as ESM for Node 22+ into `dist/backend.js`. Backend imports of your own
| `audit.record` | none | Write your own audit line |
| `sync` | none | Sync your tables to a linked desktop |
| `auth` | `auth:provide` | Login methods, second factors, SSH auth types |
| `auth.issueUserToken` | `users:impersonate` | A short-lived session for calling Termix's API as a user |
| `users.getLocale` | `users:read` | A user's UI language |
| `system` | `system:tls` | The server's HTTPS certificate |
| `plugins.list()` | `plugins:read` | Which plugins are installed |
| `asUser(userId, fn)` | always audited | Run background work as a user |
@@ -239,13 +237,6 @@ In a route the actor is the signed-in user, set by Termix. `ctx.currentActor()`
work with no request (a timer, an event) uses `ctx.asUser(userId, fn)`, which is audited and still applies
that user's permissions.
Background work that must call Termix's own HTTP API (the Termix CLI, say) asks for a session with
`ctx.auth.issueUserToken(userId, { purpose, ttlSeconds })` and sends it as `Authorization: Bearer <token>`.
It needs `users:impersonate` and `engine.api` `^1.3`, lasts 300 seconds by default and 900 at most, and is
audited as `plugin_user_token_issued`. The user's permissions apply, and it is refused on routes that change
their password, second factors, API keys or linked logins. `ctx.users.getLocale(userId)` gives the user's
UI language, like `zh_CN`, or null, for writing to them in it.
### Routes
Routes live at `/plugin-api/<id>/`, sockets at `/plugin-ws/<id>/`, on the main Termix port. You never need
@@ -478,7 +469,6 @@ your plugin's error boundary and Suspense.
| `registerComponent`, `registerExtension` | Offer a component by id, add to an extension point |
| `registerOnboardingStep` | A first run setup step |
| `registerLoginMethod`, `registerSecondFactorUI`, `registerSshAuthEditor` | Sign in UI |
| `registerLayout` | A whole-app layout (`contributes.layouts`, API 1.4) |
| `api` | Axios client for your `/plugin-api/<id>/` routes, auth included |
| `fetch(path)`, `wsUrl(path)` | Raw fetch for streams, socket URL and auth |
| `docs`, `tabs`, `confirm`, `listHosts`, `getHost`, `t`, `hasPermission`, `desktop` | Helpers |
@@ -486,7 +476,7 @@ your plugin's error boundary and Suspense.
Hooks from `@termix-ssh/plugin-sdk/frontend`: `useTranslation`, `usePermission`, `useSettings`, `useHost`,
`useHosts`, `usePluginApi`, `useCurrentUser`, `useTheme`, `useToast`, `useDocsUrl`, `useTabs`,
`useSlotContributions`, `useSshAuthTypes`, `useExtensions`, `useShell`.
`useSlotContributions`, `useSshAuthTypes`, `useExtensions`.
Shared from Termix and never bundled: `react`, `react-dom`, `i18next`, `react-i18next`, `sonner` and the
SDK's `frontend` and `ui`. Anything else, like `lucide-react`, is bundled. A host your code gets only carries
@@ -494,98 +484,9 @@ your own host settings, under `host.pluginSettings[<id>]`.
Slots you can fill include `terminal.toolbar`, `terminal.toolbarStatus`, `terminal.sidePanel`,
`terminal.overlay`, `dashboard.counters`, `dashboard.hostMetrics`, `admin.userTabs`, `hosts.importMenu`,
`hosts.panel`, `credentials.badges`, `shell.overlay`, `shell.rightEdge` and `tab.menu`. See
`hosts.panel`, `credentials.badges`, `shell.overlay` and `tab.menu`. See
https://docs.termix.site/develop/more-extension-points.
## Layouts and themes
A plugin can replace the whole app layout or add themes (API 1.4). Users pick both in Settings, Appearance;
admins set the default for everyone.
A layout is declared in `contributes.layouts` (`id`, `label`, optional `description` and `defaultTheme`) and
needs the `ui:layout` capability, which admins see as high risk because a layout draws every screen. Register
it in `activate`:
```tsx
import { useShell, type TermixApp } from "@termix-ssh/plugin-sdk/frontend";
import {
ShellConfirmCapture,
SurfaceScope,
TabStage,
} from "@termix-ssh/plugin-sdk/ui";
function MyLayout() {
const shell = useShell();
if (!shell) return null;
return (
<div className="flex h-dvh w-screen flex-col bg-background">
<header>
{/* tabs from shell.tabs.topLevel, shell.palette.open() and so on */}
</header>
<SurfaceScope kind="tab">
<ShellConfirmCapture />
<TabStage />
</SurfaceScope>
</div>
);
}
export function activate(app: TermixApp) {
app.registerLayout({
id: "my-layout",
label: "layout.name",
component: MyLayout,
});
}
```
- `useShell()` is the live shell with nothing drawn: tabs, splits, hosts, the left and right docks, the
command palette, the registries as hooks and the user. Core keeps all the state, so switching layouts keeps
every session.
- `TabStage` is where tabs show. Place it anywhere; tabs are never remounted when it moves, so a terminal
keeps its connection. Wrap it in `SurfaceScope kind="tab"` with a `ShellConfirmCapture` inside, so
close questions and inline views show over the tab area. Overlays and onboarding stay in core.
- The layout draws the command palette. Core keeps whether it is open (`palette.isOpen`, Ctrl+K calls
`palette.open()`) and what is in it (`usePaletteEntries`, `usePaletteGroups`, rail items, hosts).
- The shell always keeps a home tab with the id and type `dashboard`. `useShell()` leaves it out until some
plugin registers a `dashboard` tab type, so a layout without a home screen has no empty tab to draw.
- Settings, Manage and the host editor belong to Classic Layout. A layout without them should still render
`LookSettings` somewhere so people can switch back. `tabs.openSingleton("plugins")` opens core's own
Plugins tab in any layout. Switching layouts closes the tabs the old layout's plugin declared.
- `mobileComponent` is used on phones and narrow windows. The desktop app always gets `component`.
- Only the chosen layout's plugin is loaded. If it fails to load or crashes twice, Termix shows a repair
screen where people can switch, reset, update, turn it off or (for a bundled plugin) restore the copy that
shipped. `?safe-layout=1` opens it any time.
- Termix has no layout of its own. The default is the bundled Classic Layout plugin
(https://github.com/Termix-SSH/Plugin-Classic-Layout), which is also a full example. It owns the rail,
sidebar, tab bar, docks, Dashboard, Manage, Settings and command palette, registers the `dashboard`,
`host-manager` and `settings` tab types, and offers the host list and dashboard as the components
`classic-layout.hostTree` and `classic-layout.dashboard` for other plugins to place with `PluginComponent`.
It reads the shell with `useShellController()` from `@termix-ssh/plugin-sdk/ui`, which exposes more than
`useShell()` and may change with Termix. A third party layout should start from `useShell()`.
- Example Top Bar Layout (https://github.com/Termix-SSH/Plugin-Example-Topbar-Layout) is a small third party
style layout built only on `useShell()`, `TabStage` and `@termix-ssh/plugin-sdk/ui`, with a phone version.
A theme is CSS only. Declare it in `contributes.themes` (`id`, `label`, `mode` light or dark, `css` path,
optional `swatch` colors for the picker) with the `ui:theme` capability. The id becomes a class on `<html>`,
so the file may only hold rules whose selectors start with `.<id>`, `:root.<id>` or `html.<id>`, optionally
inside `@media` or `@supports`. `@import`, `url()` to another host and other at-rules are refused by
`termix-plugin validate` and by the server. Set the same tokens core's themes set (`--background`,
`--foreground`, `--card`, `--border`, `--muted` and the rest).
A plugin that only holds themes needs no code. Leave out `src/`: `termix-plugin build` writes an empty backend and
`validate` does not ask for a frontend. Keep the CSS in `themes/` and a test that checks it with
`validateThemeCss()`. Notes:
- A theme with the id of a built-in (`light` or `dark`) replaces core's basic copy while the plugin runs.
- The first theme a bundled default-look plugin lists is the default for new installs.
- Themes show in the picker in the order core lists its own, then the plugin's order.
- A theme someone saved that no longer exists shows as System. It comes back when the plugin does.
- Accent color, UI font, font size and density are not part of a theme. Core applies them on top.
- Bundled Classic Themes (https://github.com/Termix-SSH/Plugin-Classic-Themes) is a full example, and
Example Theme (https://github.com/Termix-SSH/Plugin-Example-Theme) a small one with one light and one dark
theme.
## Looking native
Build UI from `@termix-ssh/plugin-sdk/ui` so it matches Termix and follows the theme. The full list of
@@ -637,20 +538,6 @@ Plurals use `_one` and `_other` with `t("hosts", { count })`. Other languages go
`locales/translated/<code>.json` (like `de_DE.json`) with the same keys. Keep `{{placeholders}}` as they are.
Untranslated strings show in English.
Termix shows your plugin's name and description in the plugin manager, the setup picker and settings.
Put them in `locales/en.json` as `plugin.name` and `plugin.description`, with the same text as `name` and
`description` in the manifest, so they get translated too. Without them, the English manifest text shows in
every language.
```json
{
"plugin": {
"name": "Host Metrics",
"description": "Live CPU, memory, disk, network and GPU stats."
}
}
```
## Docs
`docs/index.md` is the first page: what the plugin does in a sentence or two, how to turn it on, how to use
@@ -853,39 +740,37 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
## Capabilities
| Capability | Risk | What it allows |
| -------------------- | -------- | ------------------------------------------------------- |
| `credentials:read` | critical | Read stored secrets for hosts the user can reach |
| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
| `ssh:connect` | high | Run any command on hosts, with the user's access |
| `process:spawn` | high | Run programs on the Termix server |
| `users:write` | high | Add users and change their roles |
| `users:impersonate` | high | Act as another user, or get a session for them, audited |
| `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
| `device:serial` | high | Open serial and USB ports on the server |
| `notify:hub` | high | Receive every alert (the alerts hub only) |
| `ui:layout` | high | Replace the whole app layout |
| `hosts:write` | medium | Add and edit hosts |
| `credentials:use` | medium | Connect with saved credentials without seeing them |
| `credentials:write` | medium | Save new credentials |
| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
| `network:serve` | medium | HTTP routes and sockets |
| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN |
| `users:read` | medium | Usernames, roles and UI language |
| `events:core` | medium | Listen to core events |
| `notify:send` | medium | Send alerts |
| `audit:read` | medium | Read the audit log |
| `desktop:window` | medium | Open windows in the desktop app |
| `hosts:read` | low | Read hosts the user can see |
| `db:own` | low | Your own tables |
| `kv:own` | low | Small key value state |
| `files:own` | low | Your own folder on disk |
| `secrets:own` | low | Your own encrypted secrets |
| `settings:read-core` | low | Read Termix server settings |
| `plugins:read` | low | See installed plugins |
| `ui:surface` | low | Add tabs, panels and settings |
| `ui:theme` | low | Add color themes, CSS only |
| Capability | Risk | What it allows |
| -------------------- | -------- | ------------------------------------------------------ |
| `credentials:read` | critical | Read stored secrets for hosts the user can reach |
| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
| `ssh:connect` | high | Run any command on hosts, with the user's access |
| `process:spawn` | high | Run programs on the Termix server |
| `users:write` | high | Add users and change their roles |
| `users:impersonate` | high | Run background work as another user, audited |
| `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
| `device:serial` | high | Open serial and USB ports on the server |
| `notify:hub` | high | Receive every alert (the alerts hub only) |
| `hosts:write` | medium | Add and edit hosts |
| `credentials:use` | medium | Connect with saved credentials without seeing them |
| `credentials:write` | medium | Save new credentials |
| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
| `network:serve` | medium | HTTP routes and sockets |
| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN |
| `users:read` | medium | Usernames and roles |
| `events:core` | medium | Listen to core events |
| `notify:send` | medium | Send alerts |
| `audit:read` | medium | Read the audit log |
| `desktop:window` | medium | Open windows in the desktop app |
| `hosts:read` | low | Read hosts the user can see |
| `db:own` | low | Your own tables |
| `kv:own` | low | Small key value state |
| `files:own` | low | Your own folder on disk |
| `secrets:own` | low | Your own encrypted secrets |
| `settings:read-core` | low | Read Termix server settings |
| `plugins:read` | low | See installed plugins |
| `ui:surface` | low | Add tabs, panels and settings |
An update that adds a capability waits for an admin to approve it.
+22 -36
View File
@@ -5,12 +5,7 @@ import { fileURLToPath } from "node:url";
import * as esbuild from "esbuild";
import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
import { staticUrlImports } from "../lib/static-url-imports.mjs";
import {
readManifest,
resolveEntry,
copyDir,
isThemeOnly,
} from "../lib/plugin-dir.mjs";
import { readManifest, resolveEntry, copyDir } from "../lib/plugin-dir.mjs";
import { openapi } from "./openapi.mjs";
import { splitByCoreClasses } from "../lib/css-classes.mjs";
@@ -237,41 +232,32 @@ export async function build({ cwd }) {
fs.mkdirSync(outDir, { recursive: true });
const backendEntry = resolveEntry(cwd, BACKEND_ENTRIES);
if (!backendEntry && !isThemeOnly(manifest)) {
if (!backendEntry) {
throw new Error(
`${pluginId}: no backend entry (looked for ${BACKEND_ENTRIES.join(", ")})`,
);
}
if (backendEntry) {
await esbuild.build({
entryPoints: [backendEntry],
outfile: path.join(outDir, "backend.js"),
bundle: true,
format: "esm",
platform: "node",
target: "node22",
sourcemap: true,
logLevel: "warning",
// A native dependency's compiled .node binary is resolved by the
// package's own relative paths, which break once esbuild inlines its JS
// elsewhere. Declaring it in nativeDependencies keeps it a real
// node_modules import instead, resolved at runtime like a host-provided
// package.
external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])],
loader: BINARY_LOADERS,
// Bundled CJS deps still call require() for Node builtins at runtime.
// ESM has no ambient require, so give esbuild's require shim a real one.
banner: { js: REQUIRE_BANNER },
});
} else {
// Core loads every plugin through a backend entry, so a theme only
// plugin gets an empty one.
fs.writeFileSync(
path.join(outDir, "backend.js"),
"export function activate() {}\n",
);
}
await esbuild.build({
entryPoints: [backendEntry],
outfile: path.join(outDir, "backend.js"),
bundle: true,
format: "esm",
platform: "node",
target: "node22",
sourcemap: true,
logLevel: "warning",
// A native dependency's compiled .node binary is resolved by the
// package's own relative paths, which break once esbuild inlines its JS
// elsewhere. Declaring it in nativeDependencies keeps it a real
// node_modules import instead, resolved at runtime like a host-provided
// package.
external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])],
loader: BINARY_LOADERS,
// Bundled CJS deps still call require() for Node builtins at runtime.
// ESM has no ambient require, so give esbuild's require shim a real one.
banner: { js: REQUIRE_BANNER },
});
for (const worker of readWorkerEntries(cwd, pluginId)) {
await esbuild.build({
+2 -12
View File
@@ -1,12 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { readManifest } from "../lib/plugin-dir.mjs";
import {
collectFiles,
createTmxplug,
themeFiles,
TMXPLUG_CONTENTS,
} from "../lib/tmxplug.mjs";
import { collectFiles, createTmxplug } from "../lib/tmxplug.mjs";
import { sha256 } from "../lib/signing.mjs";
import { validate } from "./validate.mjs";
@@ -37,12 +32,7 @@ export async function pack({ cwd, args = [] }) {
fs.mkdirSync(outDir, { recursive: true });
const outfile = path.join(outDir, `${pluginId}-${version}.tmxplug`);
const archive = createTmxplug(
cwd,
collectFiles(cwd, [
...new Set([...TMXPLUG_CONTENTS, ...themeFiles(manifest)]),
]),
);
const archive = createTmxplug(cwd, collectFiles(cwd));
fs.writeFileSync(outfile, archive);
console.log(`packed ${path.basename(outfile)}`);
+3 -63
View File
@@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { readManifest, isThemeOnly } from "../lib/plugin-dir.mjs";
import { readManifest } from "../lib/plugin-dir.mjs";
/**
* The manifest rules live in src/manifest.ts, which the server uses too, so
@@ -17,7 +17,7 @@ export async function loadSdkModule(name) {
export async function validate({ cwd }) {
const raw = readManifest(cwd);
const { parseManifest, validateThemeCss } = await loadSdkModule("manifest");
const { parseManifest } = await loadSdkModule("manifest");
const { errors } = parseManifest(raw);
const problems = [...errors];
@@ -25,8 +25,7 @@ export async function validate({ cwd }) {
// The manifest names files. They have to be there.
const referenced = [
raw.backend ?? "dist/backend.js",
// A theme only plugin has no frontend code.
...(isThemeOnly(raw) ? [] : [raw.frontend ?? "dist/frontend.js"]),
raw.frontend ?? "dist/frontend.js",
raw.locales ?? "locales",
];
for (const rel of referenced) {
@@ -35,8 +34,6 @@ export async function validate({ cwd }) {
}
}
problems.push(...validateThemeFiles(cwd, raw, validateThemeCss));
problems.push(...validateLookCapabilities(cwd, raw));
problems.push(
...(await validateMigrations(cwd, raw.id ?? path.basename(cwd))),
);
@@ -56,63 +53,6 @@ export async function validate({ cwd }) {
console.log(`ok ${raw.id ?? path.basename(cwd)}`);
}
/** Every declared theme file exists and holds only CSS a theme may hold. */
export function validateThemeFiles(cwd, raw, validateThemeCss) {
const problems = [];
const themes = raw?.contributes?.themes;
if (!Array.isArray(themes)) return problems;
for (const theme of themes) {
if (typeof theme?.css !== "string" || typeof theme?.id !== "string") {
continue;
}
const file = path.join(cwd, theme.css);
if (!fs.existsSync(file)) {
problems.push(`theme ${theme.id}: ${theme.css} does not exist`);
continue;
}
for (const error of validateThemeCss(
fs.readFileSync(file, "utf8"),
theme.id,
)) {
problems.push(`theme ${theme.id}: ${error}`);
}
}
return problems;
}
/**
* ui:layout and ui:theme must match what the manifest contributes, both
* ways. The manifest check already flags a contribution without its
* capability; this flags a capability with nothing behind it, and a layout
* with no frontend to register it.
*/
export function validateLookCapabilities(cwd, raw) {
const problems = [];
const capabilities = Array.isArray(raw?.capabilities) ? raw.capabilities : [];
const layouts = raw?.contributes?.layouts;
const themes = raw?.contributes?.themes;
const hasLayouts = Array.isArray(layouts) && layouts.length > 0;
const hasThemes = Array.isArray(themes) && themes.length > 0;
if (capabilities.includes("ui:layout") && !hasLayouts) {
problems.push('"ui:layout" is declared but contributes.layouts is empty');
}
if (capabilities.includes("ui:theme") && !hasThemes) {
problems.push('"ui:theme" is declared but contributes.themes is empty');
}
if (hasLayouts) {
const entries = ["index.tsx", "index.ts", "index.mjs", "index.js"];
const frontend = entries.some((entry) =>
fs.existsSync(path.join(cwd, "src", "frontend", entry)),
);
if (!frontend && raw.frontend === undefined) {
problems.push(
"contributes.layouts needs a frontend that calls app.registerLayout",
);
}
}
return problems;
}
/** Env vars core sets for everyone, so a plugin need not list them. */
const CORE_ENV = new Set([
"DATA_DIR",
@@ -14,17 +14,6 @@ export function readManifest(cwd) {
}
/** The first of these that exists, or null. */
/** Only themes: CSS files and no code, so no source entry is needed. */
export function isThemeOnly(manifest) {
const capabilities = manifest.capabilities ?? [];
return (
capabilities.length > 0 &&
capabilities.every((capability) => capability === "ui:theme") &&
Array.isArray(manifest.contributes?.themes) &&
manifest.contributes.themes.length > 0
);
}
export function resolveEntry(cwd, candidates) {
for (const candidate of candidates) {
const full = path.join(cwd, candidate);
-12
View File
@@ -2,15 +2,6 @@ import fs from "node:fs";
import path from "node:path";
import zlib from "node:zlib";
/** Theme CSS files a manifest declares, which travel with the plugin too. */
export function themeFiles(manifest) {
const themes = manifest?.contributes?.themes;
if (!Array.isArray(themes)) return [];
return themes
.map((theme) => theme?.css)
.filter((css) => typeof css === "string");
}
/** What a .tmxplug holds, in the order the spec lists it. */
export const TMXPLUG_CONTENTS = [
"manifest.json",
@@ -20,9 +11,6 @@ export const TMXPLUG_CONTENTS = [
"README.md",
"CHANGELOG.md",
"icon.svg",
// Licence texts, so third-party notices travel with the plugin.
"LICENSE",
"NOTICE",
];
const BLOCK = 512;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.12",
"version": "1.0.9",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0",
"repository": {
+8 -111
View File
@@ -82,11 +82,7 @@
"features": {
"type": "array",
"maxItems": 20,
"items": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"items": { "type": "string", "minLength": 1, "maxLength": 160 },
"description": "Short lines listing what the plugin does, shown on its page."
},
"docs": {
@@ -102,24 +98,11 @@
"additionalProperties": false,
"required": ["name", "description"],
"properties": {
"name": {
"type": "string",
"pattern": "^[A-Z][A-Z0-9_]{0,63}$"
},
"description": {
"type": "string",
"minLength": 1,
"maxLength": 300
},
"default": {
"type": "string"
},
"required": {
"type": "boolean"
},
"secret": {
"type": "boolean"
}
"name": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,63}$" },
"description": { "type": "string", "minLength": 1, "maxLength": 300 },
"default": { "type": "string" },
"required": { "type": "boolean" },
"secret": { "type": "boolean" }
}
}
},
@@ -172,11 +155,7 @@
"files:own",
"secrets:own",
"settings:read-core",
"ui:surface",
"notify:hub",
"plugins:read",
"ui:layout",
"ui:theme"
"ui:surface"
]
}
},
@@ -965,9 +944,7 @@
"defaultLevels": {
"type": "array",
"minItems": 1,
"items": {
"enum": ["admin", "user", "folder"]
},
"items": { "enum": ["admin", "user", "folder"] },
"description": "Levels that may set a default for the field. All three by default."
},
"personal": {
@@ -1150,86 +1127,6 @@
"type": "string",
"pattern": "^[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+$"
}
},
"layouts": {
"type": "array",
"description": "Whole-app layouts the frontend registers with app.registerLayout. Needs the ui:layout capability.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "label"],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]{1,39}$"
},
"label": {
"type": "string",
"minLength": 1,
"description": "i18n key in the plugin namespace, or plain text."
},
"description": {
"type": "string",
"minLength": 1
},
"defaultTheme": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]{1,39}$",
"description": "Theme picked with this layout when nobody chose one."
},
"default": {
"type": "boolean",
"description": "The layout a default-look plugin starts with. At most one."
}
}
}
},
"themes": {
"type": "array",
"description": "Themes: one CSS file each with token rules scoped under .<id>. Needs the ui:theme capability.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "label", "mode", "css"],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]{1,39}$"
},
"label": {
"type": "string",
"minLength": 1
},
"mode": {
"type": "string",
"enum": ["light", "dark"]
},
"css": {
"type": "string",
"pattern": ".css$",
"description": "Path of the CSS file inside the plugin."
},
"swatch": {
"type": "object",
"additionalProperties": false,
"required": ["background", "panel", "text"],
"properties": {
"background": {
"type": "string",
"pattern": "^#[0-9a-fA-F]{3,8}$"
},
"panel": {
"type": "string",
"pattern": "^#[0-9a-fA-F]{3,8}$"
},
"text": {
"type": "string",
"pattern": "^#[0-9a-fA-F]{3,8}$"
}
}
}
}
}
}
}
},
+3 -43
View File
@@ -1464,20 +1464,6 @@ export interface PluginSecondFactor {
reset?: (userId: string) => Promise<void>;
}
export interface PluginUserTokenOptions {
/** Why the token exists, kept in the audit log and on the session. */
purpose: string;
/** Seconds until it expires. Defaults to 300 and is capped at 900. */
ttlSeconds?: number;
}
export interface PluginUserToken {
/** Send as `Authorization: Bearer <token>` to core's own API. */
token: string;
/** ISO time the session ends. */
expiresAt: string;
}
/**
* Login methods, second factors and SSH auth types. Every register call needs
* auth:provide and a matching contributes.auth entry, and is undone on
@@ -1539,26 +1525,6 @@ export interface PluginAuth {
* delete a provider people still sign in with.
*/
countLinkedUsers: (provider: string) => Promise<number>;
/**
* A short-lived session for an existing user, for calling core's own HTTP
* API as them from work with no request behind it, like a scheduled task
* driving the Termix CLI. The user's RBAC applies unchanged. Needs
* users:impersonate, not auth:provide, and every call is audited.
*
* The session is marked with the plugin and purpose. It cannot change the
* user's password, second factors, API keys or linked logins, so it can
* never outlive itself.
*/
issueUserToken: (
userId: string,
options: PluginUserTokenOptions,
) => Promise<PluginUserToken>;
}
/** What a plugin may learn about a user. Needs users:read. */
export interface PluginUsers {
/** The user's UI language, like "zh_CN", or null when they never chose one. */
getLocale: (userId: string) => Promise<string | null>;
}
export interface PluginOpenIsolatedWindowRequest {
@@ -2047,13 +2013,8 @@ export interface PluginContext {
readonly hosts: PluginHosts;
/** SSH through core's connect pipeline. Needs ssh:connect and credentials:use. */
readonly ssh: PluginSsh;
/**
* Login methods, second factors and SSH auth types. Needs auth:provide,
* except issueUserToken, which needs users:impersonate.
*/
/** Login methods, second factors and SSH auth types. Needs auth:provide. */
readonly auth: PluginAuth;
/** What a plugin may learn about a user. Needs users:read. */
readonly users: PluginUsers;
/** Opens Electron windows outside the main renderer. Needs desktop:window. */
readonly desktop: PluginDesktop;
/** Host protocol credentials and the user's saved SSH keys. See PluginCredentials. */
@@ -2078,9 +2039,8 @@ export interface PluginContext {
* no request behind it. Always audited, and every core API inside still
* applies that user's RBAC.
*
* Needs users:impersonate. That, auth.issueUserToken and a `userId` option
* on services.get or secrets.getShared are the only ways a plugin can act
* as a user it names.
* Needs users:impersonate. That and a `userId` option on services.get or
* secrets.getShared are the only ways a plugin can name a user.
*/
asUser: <T>(userId: string, fn: () => Promise<T> | T) => Promise<T>;
-2
View File
@@ -49,7 +49,6 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("auth:provide", "high"),
entry("device:serial", "high"),
entry("notify:hub", "high"),
entry("ui:layout", "high"),
entry("hosts:write", "medium"),
entry("credentials:use", "medium"),
@@ -71,7 +70,6 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("settings:read-core", "low"),
entry("plugins:read", "low"),
entry("ui:surface", "low"),
entry("ui:theme", "low"),
] as const;
export const CAPABILITY_IDS: readonly string[] = CAPABILITY_CATALOG.map(
-138
View File
@@ -248,129 +248,6 @@ export interface TabsApi extends Pick<
onReady: (listener: () => void) => Disposer;
}
// ---------------------------------------------------------------------------
// Layouts (plugin API 1.4)
// ---------------------------------------------------------------------------
/**
* A whole-app layout. Core renders the chosen one inside its providers and an
* error boundary; everything it shows comes from useShell(). Declare it in
* manifest contributes.layouts and add the ui:layout capability.
*/
export interface LayoutContribution {
id: string;
/** Your own i18n key or plain text, the same as the manifest's label. */
label: string;
component: ComponentType;
/** Shown on phones and narrow windows. The desktop app never uses it. */
mobileComponent?: ComponentType;
/** Theme to start with when nobody chose one. */
defaultTheme?: string;
}
/** A tab as a layout sees it. Splits are tabs too, with a `split` field. */
export interface ShellTabState extends PluginTabRecord {
instanceId: string;
customLabel?: string;
/** Set while the tab sits in a split pane. */
parentSplitTabId?: string;
openedAt?: number;
}
/** What a dock region shows and how it is sized, in Normal-size pixels. */
export interface ShellDockState {
view: string | null;
open: boolean;
width: number;
dragging: boolean;
setView: (view: string | null) => void;
setWidth: (width: number) => void;
/** Starts a drag from the dock's edge, from a mousedown. */
startResize: (event: { clientX: number; preventDefault: () => void }) => void;
resetWidth: () => void;
}
/** The live shell: state and actions, nothing drawn. Read with useShell(). */
export interface ShellController {
tabs: {
list: ShellTabState[];
/** What a tab bar shows: tabs not sitting in a split. */
topLevel: ShellTabState[];
activeId: string;
/** True once saved tabs were restored after login. */
ready: boolean;
/** The user turned on tab numbers. */
showNumbers: boolean;
setActive: (id: string) => void;
reorder: (topLevel: ShellTabState[]) => void;
close: (id: string) => Promise<void>;
rename: (id: string, label: string) => void;
/** Reconnects or reloads a session tab. */
refresh: (id: string) => void;
reconnectAll: () => void;
openSingleton: (type: string) => string | undefined;
};
split: {
/** The split shown now, or null. */
activeId: string | null;
focusedPaneId: string | null;
/** Every split with its panes, for a split menu. */
summaries: Array<{
id: string;
label: string;
full: boolean;
panes: Array<{
id: string;
index: number;
tabId: string | null;
label: string | null;
}>;
}>;
activeFull: boolean;
/** Split a tab, apply a preset, unsplit, add to a pane. */
handleAction: (action: Record<string, unknown> & { kind: string }) => void;
};
hosts: {
list: PluginHostRecord[];
loading: boolean;
loaded: boolean;
reload: () => Promise<void>;
};
regions: {
left: ShellDockState & { view: string };
right: ShellDockState;
/** What a rail click does: toggles or switches the left dock. */
railClick: (view: string) => void;
openInRightDock: (view: string) => void;
toggleRightDock: () => void;
};
palette: { isOpen: boolean; open: () => void; close: () => void };
fullscreen: { active: boolean; toggle: () => void };
/** The same callbacks tabs and panels get. */
actions: ShellApi;
env: {
isMobile: boolean;
isElectron: boolean;
user: { username: string; isAdmin: boolean };
permissions: string[];
branding: {
appName: string | null;
tagline: string | null;
logo: string | null;
};
};
/** Core registries, as hooks a layout calls while rendering. */
registries: {
useRailItems: () => ReadonlyArray<{ id: string } & Record<string, unknown>>;
usePanels: () => ReadonlyArray<{ id: string } & Record<string, unknown>>;
useTabTypes: () => ReadonlyArray<{ id: string } & Record<string, unknown>>;
useDashboardCards: () => ReadonlyArray<
{ id: string } & Record<string, unknown>
>;
};
logout: (options?: { manual?: boolean }) => void;
}
// ---------------------------------------------------------------------------
// Registrations
// ---------------------------------------------------------------------------
@@ -1111,12 +988,6 @@ export interface TermixApp extends TermixAppInfo {
registerSshAuthEditor: (editor: SshAuthEditorContribution) => Disposer;
registerLoginMethod: (method: LoginMethodContribution) => Disposer;
registerSecondFactorUI: (factor: SecondFactorContribution) => Disposer;
/**
* A whole-app layout. Declared in manifest contributes.layouts with the
* ui:layout capability. Only the chosen layout's plugin is loaded, so
* register it in activate. Needs plugin API 1.4.
*/
registerLayout: (layout: LayoutContribution) => Disposer;
/**
* Translates a key from the plugin's own locales, for code outside a
@@ -1251,7 +1122,6 @@ export interface PluginHostBridge {
useHosts: () => { hosts: PluginHostRecord[]; loaded: boolean };
useCurrentUser: () => CurrentUser | null;
useTheme: () => { theme: "light" | "dark" };
useShell: () => ShellController | null;
toast: ToastApi;
getApi: (pluginId: string) => PluginApiClient;
getApiFor: (pluginId: string, origin: unknown) => PluginApiClient;
@@ -1502,14 +1372,6 @@ export function useTheme(): { theme: "light" | "dark" } {
return requireHost().useTheme();
}
/**
* The live shell, for a layout. Null outside the signed-in app. Needs plugin
* API 1.4.
*/
export function useShell(): ShellController | null {
return requireHost().useShell();
}
export function useToast(): ToastApi {
return requireHost().toast;
}
+1 -164
View File
@@ -19,7 +19,6 @@ import {
type PluginEnvVar,
} from "./docs.js";
export { validateThemeCss } from "./theme-css.js";
export {
ENV_NAME_PATTERN,
MAX_ENV_DESCRIPTION_LENGTH,
@@ -37,7 +36,7 @@ export {
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
* version by the server.
*/
export const PLUGIN_API_VERSION = "1.4.0";
export const PLUGIN_API_VERSION = "1.2.0";
/** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String(
@@ -427,40 +426,6 @@ export interface PluginAuthContribution {
keyboardInteractive?: string[];
}
/** A layout id or theme id: short, lowercase, used as a CSS class for themes. */
export const LOOK_ID_PATTERN = /^[a-z][a-z0-9-]{1,39}$/;
/**
* A whole-app layout the frontend registers with app.registerLayout. Needs
* the ui:layout capability. Declared here so core can list and choose it
* before any plugin code runs.
*/
export interface PluginLayoutContribution {
id: string;
/** i18n key in the plugin's namespace, or plain text. */
label: string;
description?: string;
/** Theme picked with this layout when nobody chose one. */
defaultTheme?: string;
/** The layout a default-look plugin starts with. */
default?: boolean;
}
/**
* A theme: one CSS file of token rules scoped under `.<id>`, applied as a
* class on <html>. Needs the ui:theme capability. No code runs for a theme.
*/
export interface PluginThemeContribution {
id: string;
/** i18n key in the plugin's namespace, or plain text. */
label: string;
mode: "light" | "dark";
/** Path of the CSS file inside the plugin. */
css: string;
/** Colors the theme picker previews it with. */
swatch?: { background: string; panel: string; text: string };
}
export interface PluginContributions {
tabs?: PluginTabContribution[];
panels?: PluginViewContribution[];
@@ -522,10 +487,6 @@ export interface PluginContributions {
* ctx.credentials.resolveHostProtocol.
*/
protocols?: PluginProtocolContribution[];
/** Whole-app layouts. Needs the ui:layout capability. */
layouts?: PluginLayoutContribution[];
/** Themes. Needs the ui:theme capability. */
themes?: PluginThemeContribution[];
}
/** A field a protocol login carries besides username and password. */
@@ -747,8 +708,6 @@ const ALLOWED_CONTRIBUTES = new Set([
"uses",
"keybindingActions",
"protocols",
"layouts",
"themes",
]);
const ALLOWED_SETTINGS_FIELD = [
@@ -1341,111 +1300,6 @@ function validateContributes(
validateUiPresets(contributes.uiPresets, errors);
validateKeybindingActions(contributes.keybindingActions, errors);
validateProtocols(contributes.protocols, errors);
validateLayouts(contributes.layouts, errors);
validateThemes(contributes.themes, errors);
}
/** An array of objects with unique look ids; calls `each` for every valid entry. */
function validateLookList(
value: unknown,
field: string,
allowed: string[],
errors: string[],
each: (raw: Record<string, unknown>, where: string) => void,
): void {
if (value === undefined) return;
if (!Array.isArray(value)) {
errors.push(`Field "contributes.${field}" must be an array`);
return;
}
const seen = new Set<string>();
value.forEach((raw, index) => {
const where = `contributes.${field}[${index}]`;
if (!isPlainObject(raw)) {
errors.push(`${where} must be an object`);
return;
}
rejectUnknown(raw, allowed, where, errors);
if (typeof raw.id !== "string" || !LOOK_ID_PATTERN.test(raw.id)) {
errors.push(
`${where}.id must match ${LOOK_ID_PATTERN} (lowercase letters, digits, dashes)`,
);
} else {
if (seen.has(raw.id)) errors.push(`${where}.id duplicates "${raw.id}"`);
seen.add(raw.id);
}
requireString(raw.label, `${where}.label`, errors);
each(raw, where);
});
}
function validateLayouts(value: unknown, errors: string[]): void {
validateLookList(
value,
"layouts",
["id", "label", "description", "defaultTheme", "default"],
errors,
(raw, where) => {
if (raw.description !== undefined)
requireString(raw.description, `${where}.description`, errors);
if (
raw.defaultTheme !== undefined &&
(typeof raw.defaultTheme !== "string" ||
!LOOK_ID_PATTERN.test(raw.defaultTheme))
) {
errors.push(`${where}.defaultTheme must be a theme id`);
}
if (raw.default !== undefined && typeof raw.default !== "boolean") {
errors.push(`${where}.default must be a boolean`);
}
},
);
if (
Array.isArray(value) &&
value.filter((raw) => isPlainObject(raw) && raw.default === true).length > 1
) {
errors.push('Only one entry in "contributes.layouts" may set default');
}
}
const HEX_COLOR = /^#[0-9a-fA-F]{3,8}$/;
function validateThemes(value: unknown, errors: string[]): void {
validateLookList(
value,
"themes",
["id", "label", "mode", "css", "swatch"],
errors,
(raw, where) => {
if (raw.mode !== "light" && raw.mode !== "dark") {
errors.push(`${where}.mode must be "light" or "dark"`);
}
if (requireString(raw.css, `${where}.css`, errors)) {
requireRelativePath(raw.css, `${where}.css`, errors);
if (!raw.css.endsWith(".css")) {
errors.push(`${where}.css must be a .css file`);
}
}
if (raw.swatch !== undefined) {
if (!isPlainObject(raw.swatch)) {
errors.push(`${where}.swatch must be an object`);
return;
}
rejectUnknown(
raw.swatch,
["background", "panel", "text"],
`${where}.swatch`,
errors,
);
for (const key of ["background", "panel", "text"]) {
const color = raw.swatch[key];
if (typeof color !== "string" || !HEX_COLOR.test(color)) {
errors.push(`${where}.swatch.${key} must be a hex color`);
}
}
}
},
);
}
export const PROTOCOL_ID_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
@@ -2470,23 +2324,6 @@ export function parseManifest(raw: unknown): ParsedManifest {
);
}
if (
(manifest.contributes?.layouts?.length ?? 0) > 0 &&
!manifest.capabilities.includes("ui:layout")
) {
errors.push(
'contributes.layouts needs the "ui:layout" capability in the capabilities array',
);
}
if (
(manifest.contributes?.themes?.length ?? 0) > 0 &&
!manifest.capabilities.includes("ui:theme")
) {
errors.push(
'contributes.themes needs the "ui:theme" capability in the capabilities array',
);
}
for (const secret of manifest.requiresSecret ?? []) {
if (secret.plugin === manifest.id) {
errors.push(
+1 -33
View File
@@ -174,8 +174,6 @@ export interface FakeContextOptions {
linkedUsers?: Record<string, number>;
/** Failures before ctx.auth.loginRateLimit locks a key. Defaults to 5. */
loginAttemptLimit?: number;
/** What ctx.users.getLocale answers, by user id. A missing user answers null. */
locales?: Record<string, string>;
}
export interface FakeProcessOptions {
@@ -249,8 +247,6 @@ export interface FakeAuthRegistrations {
}>;
/** Failures recorded through ctx.auth.loginRateLimit, by "<ip>|<key>". */
loginFailures: Map<string, number>;
/** Every ctx.auth.issueUserToken call, in order. */
userTokens: Array<{ userId: string; purpose: string; ttlSeconds?: number }>;
}
export interface FakePluginContext {
@@ -578,7 +574,6 @@ export function createFakeContext(
completedLogins: [],
revokedSessions: [],
loginFailures: new Map(),
userTokens: [],
};
const sshClient = options.sshClient ?? {};
let actor = options.actor;
@@ -1145,18 +1140,6 @@ export function createFakeContext(
},
countLinkedUsers: async (provider) =>
options.linkedUsers?.[provider] ?? 0,
issueUserToken: async (userId, tokenOptions) => {
auth.userTokens.push({ userId, ...tokenOptions });
const ttl = Math.min(tokenOptions.ttlSeconds ?? 300, 900);
return {
token: `fake-token-${auth.userTokens.length}`,
expiresAt: new Date(Date.now() + ttl * 1000).toISOString(),
};
},
},
users: {
getLocale: async (userId) => options.locales?.[userId] ?? null,
},
desktop: {
@@ -1459,8 +1442,6 @@ export interface MockContextOptions {
linkedUsers?: Record<string, number>;
/** See FakeContextOptions. */
loginAttemptLimit?: number;
/** See FakeContextOptions. */
locales?: Record<string, string>;
}
export interface MockPluginContext extends FakePluginContext {
@@ -1477,8 +1458,7 @@ export interface MockPluginContext extends FakePluginContext {
* topic outside the plugin's own namespace, settings:read-core on
* ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh,
* notify:send on ctx.notify (notify:hub on serve), network:outbound on ctx.fetch, process:spawn on
* ctx.process, system:tls on ctx.system, plugins:read on ctx.plugins, auth:provide on ctx.auth
* (users:impersonate on issueUserToken), and users:read on ctx.users. Reading a plugin's own settings is deliberately
* ctx.process, system:tls on ctx.system, plugins:read on ctx.plugins, and auth:provide on ctx.auth. Reading a plugin's own settings is deliberately
* ungated. As the SDK grows a member, add its gate here in the same shape.
*/
export function createMockCtx(
@@ -1515,7 +1495,6 @@ export function createMockCtx(
baseUrl: options.baseUrl,
linkedUsers: options.linkedUsers,
loginAttemptLimit: options.loginAttemptLimit,
locales: options.locales,
manifest: {
capabilities: options.capabilities ?? [],
...options.manifest,
@@ -1807,17 +1786,6 @@ export function createMockCtx(
require("auth:provide");
return ctx.auth.countLinkedUsers(provider);
},
issueUserToken: async (userId, tokenOptions) => {
require("users:impersonate");
return ctx.auth.issueUserToken(userId, tokenOptions);
},
},
users: {
getLocale: async (userId) => {
require("users:read");
return ctx.users.getLocale(userId);
},
},
desktop: {
-1
View File
@@ -97,7 +97,6 @@ const offline = () =>
useHostStatus: () => null,
useCurrentUser: () => null,
useTheme: () => ({ theme: "dark" }),
useShell: () => null,
toast: { success: noop, error: noop, info: noop, warning: noop },
getApi: () => ({
get: offline,
-150
View File
@@ -1,150 +0,0 @@
/**
* Checks a theme's CSS before core serves it.
*
* A theme is colors and styles only, so its file may hold nothing but rules
* whose every selector starts with the theme's own class (`.nord`,
* `:root.nord`, `html.nord ...`), optionally inside @media or @supports.
* Anything that could load from elsewhere or run code is refused: @import,
* url() to another host, other at-rules, expression() and javascript:.
*/
const ALLOWED_BLOCK_AT_RULES = new Set(["media", "supports"]);
function stripComments(css: string): string {
return css.replace(/\/\*[\s\S]*?\*\//g, " ");
}
/** Splits on commas that are not inside parentheses or brackets. */
function splitSelectors(prelude: string): string[] {
const parts: string[] = [];
let depth = 0;
let current = "";
for (const ch of prelude) {
if (ch === "(" || ch === "[") depth += 1;
if (ch === ")" || ch === "]") depth -= 1;
if (ch === "," && depth === 0) {
parts.push(current);
current = "";
continue;
}
current += ch;
}
parts.push(current);
return parts.map((part) => part.trim());
}
function withoutStrings(css: string): string {
return css.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, '""');
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** Index of the brace closing the one at `open`, skipping strings. */
function matchingBrace(css: string, open: number): number {
let depth = 0;
let quote: string | null = null;
for (let i = open; i < css.length; i += 1) {
const ch = css[i];
if (quote) {
if (ch === "\\") i += 1;
else if (ch === quote) quote = null;
continue;
}
if (ch === '"' || ch === "'") quote = ch;
else if (ch === "{") depth += 1;
else if (ch === "}") {
depth -= 1;
if (depth === 0) return i;
}
}
return -1;
}
function checkUrls(css: string, errors: string[]): void {
const urls = css.matchAll(/url\(\s*(['"]?)([^'")]*)\1\s*\)/gi);
for (const match of urls) {
const target = match[2].trim();
if (target.startsWith("data:")) continue;
if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("//")) {
errors.push(`url(${target}) points outside the plugin`);
}
}
}
function checkRules(
css: string,
scope: RegExp,
themeId: string,
errors: string[],
): void {
let index = 0;
while (index < css.length) {
const rest = css.slice(index);
if (rest.trim() === "") return;
const open = css.indexOf("{", index);
const semicolon = css.indexOf(";", index);
if (semicolon !== -1 && (open === -1 || semicolon < open)) {
const statement = css.slice(index, semicolon).trim();
errors.push(
statement.startsWith("@")
? `${statement.split(/\s/)[0]} is not allowed in a theme`
: `Unexpected "${statement}" outside a rule`,
);
index = semicolon + 1;
continue;
}
if (open === -1) {
errors.push(`Unexpected "${rest.trim()}" at the end`);
return;
}
const close = matchingBrace(css, open);
if (close === -1) {
errors.push("A rule is missing its closing brace");
return;
}
const prelude = css.slice(index, open).trim();
const body = css.slice(open + 1, close);
if (prelude.startsWith("@")) {
const name = prelude.slice(1).split(/[\s(]/)[0].toLowerCase();
if (ALLOWED_BLOCK_AT_RULES.has(name)) {
checkRules(body, scope, themeId, errors);
} else {
errors.push(`@${name} is not allowed in a theme`);
}
} else {
for (const selector of splitSelectors(prelude)) {
if (!scope.test(selector)) {
errors.push(
`Selector "${selector}" must start with .${themeId}, :root.${themeId} or html.${themeId}`,
);
}
}
if (withoutStrings(body).includes("{")) {
errors.push(`Nested rules are not allowed in "${prelude}"`);
}
}
index = close + 1;
}
}
/** Every problem with a theme's CSS, empty when it is safe to serve. */
export function validateThemeCss(css: string, themeId: string): string[] {
const errors: string[] = [];
const clean = stripComments(css);
if (/@import\b/i.test(clean))
errors.push("@import is not allowed in a theme");
if (/expression\s*\(/i.test(clean))
errors.push("expression() is not allowed in a theme");
if (/javascript:/i.test(clean))
errors.push("javascript: is not allowed in a theme");
if (/-moz-binding|behavior\s*:/i.test(clean))
errors.push("Script bindings are not allowed in a theme");
checkUrls(clean, errors);
const id = escapeRegExp(themeId);
const scope = new RegExp(`^(?::root|html)?\\.${id}(?![\\w-])`);
checkRules(clean, scope, themeId, errors);
return [...new Set(errors)];
}
+3 -15
View File
@@ -117,19 +117,9 @@ export function pluginVitestConfig(
const plugins = [wasmBinaryPlugin()];
// A local SDK linked into node_modules sits outside the plugin folder, so
// Vite must be allowed to read it, and React must come from the plugin.
const sdkDir = fs.realpathSync(fileURLToPath(new URL("..", import.meta.url)));
const server = { fs: { allow: [pluginDir, sdkDir] } };
const resolve = {
alias,
dedupe: ["react", "react-dom", "react-i18next", "i18next"],
};
return {
plugins,
server,
resolve,
resolve: { alias },
test: {
globals: true,
setupFiles: [setupFile],
@@ -137,8 +127,7 @@ export function pluginVitestConfig(
projects: [
{
plugins,
server,
resolve,
resolve: { alias },
test: {
name: `${pluginId}:backend`,
root: pluginDir,
@@ -151,8 +140,7 @@ export function pluginVitestConfig(
},
{
plugins,
server,
resolve,
resolve: { alias },
test: {
name: `${pluginId}:frontend`,
root: pluginDir,
-1
View File
@@ -12,7 +12,6 @@
"moduleDetection": "force",
"esModuleInterop": true,
"resolveJsonModule": true,
"preserveSymlinks": true,
"noEmit": true,
"strict": false,
"noUnusedLocals": false,
-408
View File
@@ -1,10 +1,5 @@
ACCENT_PRESET_COLORS (value): { label: string; value: string; }[]
APP_VERSION (value): string
AccessRecord (type): AccessRecord
ActionSlot (value): ({ slotId, context, className, enabled, renderItem, hideLabels, when, }: ActionSlotProps) => Element
ActiveSessionInfo (type): ActiveSessionInfo
AddButton (value): ({ label, compact, className, ...rest }: { label?: string; compact?: boolean; className?: string; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => Element
AdminSection (type): AdminSection
Alert (value): ({ className, variant, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; destructive: string; }; }> & ClassProp) => string>) => Element
AlertDescription (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
AlertDialog (value): ({ ...props }: AlertDialogProps) => Element
@@ -19,23 +14,14 @@ AlertDialogPortal (value): ({ ...props }: AlertDialogPortalProps) => Element
AlertDialogTitle (value): ({ className, ...props }: AlertDialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
AlertDialogTrigger (value): ({ ...props }: AlertDialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
AlertTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
ApiKey (type): ApiKey
AuditLog (type): AuditLog
AuditLogFilters (type): AuditLogFilters
AuthEnrollmentSections (value): () => Element
AuthOverrideProtocol (type): string
BackButton (value): ({ onClick, label, className, }: { onClick: () => void; label?: string; className?: string; }) => Element
Badge (value): ({ className, variant, asChild, ...props }: ClassAttributes<HTMLSpanElement> & HTMLAttributes<HTMLSpanElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; secondary: string; destructive: string; outline: string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element
BarSeries (value): ({ items, max, className, colorClassName, }: BarSeriesProps) => Element
BarSeriesItem (type): BarSeriesItem
BarSeriesProps (type): BarSeriesProps
BrandingSettings (type): BrandingSettings
BrowserSignInDialog (value): ({ isOpen, label, url, code, onContinue, onCancel, onOpenUrl, backgroundColor, }: BrowserSignInDialogProps) => Element
Button (value): ({ className, variant, size, asChild, ...props }: ClassAttributes<HTMLButtonElement> & ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; outline: string; secondary: string; ghost: string; destructive: string; link: string; }; size: { default: string; xs: string; sm: string; lg: string; icon: string; "icon-xs": string; "icon-sm": string; "icon-lg": string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element
ButtonProps (type): ButtonProps
CORE_HOST_DEFAULTS (value): readonly CoreHostDefault[]
CORE_NAMESPACE (value): "core"
CREDENTIAL_ROW_FIELD_KEYS (value): (keyof CredentialRowFields)[]
Card (value): ({ className, size, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & { size?: "default" | "sm"; }) => Element
CardAction (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
CardContent (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
@@ -47,12 +33,6 @@ CardMasonry (value): ({ columns, stack, className, children, }: { columns: numbe
CardTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
Checkbox (value): ({ className, ...props }: CheckboxProps & RefAttributes<HTMLButtonElement>) => Element
ColumnCountStepper (value): ({ columns, onChange, }: { columns: number; onChange: (columns: number) => void; }) => Element
Command (value): ForwardRefExoticComponent<Omit<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; shouldFilter?: boolean; filter?: CommandFilter; defaultValue?: string; value?: string; onValueChange?: (value: string) => void; loop?: boolean; disablePointerSelection?: boolean; vimBindings?: boolean; } & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
CommandEmpty (value): ForwardRefExoticComponent<Omit<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
CommandGroup (value): ForwardRefExoticComponent<Omit<Children & Omit<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>>, "value" | "heading"> & { heading?: ReactNode; value?: string; forceMount?: boolean; } & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
CommandItem (value): ForwardRefExoticComponent<Omit<Children & Omit<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>>, "disabled" | "value" | "onSelect"> & { disabled?: boolean; onSelect?: (value: string) => void; value?: string; keywords?: string[]; forceMount?: boolean; } & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
CommandList (value): ForwardRefExoticComponent<Omit<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; } & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
CommandPrimitive (value): ForwardRefExoticComponent<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; shouldFilter?: boolean; filter?: CommandFilter; defaultValue?: string; value?: string; onValueChange?: (value: string) => void; loop?: boolean; disablePointerSelection?: boolean; vimBindings?: boolean; } & RefAttributes<HTMLDivElement>> & { List: ForwardRefExoticComponent<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; } & RefAttributes<HTMLDivElement>>; Item: ForwardRefExoticComponent<Children & Omit<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>>, "disabled" | "value" | "onSelect"> & { disabled?: boolean; onSelect?: (value: string) => void; value?: string; keywords?: string[]; forceMount?: boolean; } & RefAttributes<HTMLDivElement>>; Input: ForwardRefExoticComponent<Omit<Pick<Pick<DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, "key" | keyof InputHTMLAttributes<HTMLInputElement>> & { ref?: Ref<HTMLInputElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof InputHTMLAttributes<HTMLInputElement>>, "type" | "value" | "onChange"> & { value?: string; onValueChange?: (search: string) => void; } & RefAttributes<HTMLInputElement>>; Group: ForwardRefExoticComponent<Children & Omit<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>>, "value" | "heading"> & { heading?: ReactNode; value?: string; forceMount?: boolean; } & RefAttributes<HTMLDivElement>>; Separator: ForwardRefExoticComponent<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { alwaysRender?: boolean; } & RefAttributes<HTMLDivElement>>; Dialog: ForwardRefExoticComponent<DialogProps & Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; shouldFilter?: boolean; filter?: CommandFilter; defaultValue?: string; value?: string; onValueChange?: (value: string) => void; loop?: boolean; disablePointerSelection?: boolean; vimBindings?: boolean; } & { overlayClassName?: string; contentClassName?: string; container?: HTMLElement; } & RefAttributes<HTMLDivElement>>; Empty: ForwardRefExoticComponent<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & RefAttributes<HTMLDivElement>>; Loading: ForwardRefExoticComponent<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { progress?: number; label?: string; } & RefAttributes<HTMLDivElement>>; }
ComponentSlot (value): ({ slotId, props, when, }: { slotId: string; props?: Record<string, unknown>; when?: Record<string, unknown>; }) => Element
ConfirmOptions (type): ConfirmOptions
ConnectionGate (value): (props: ConnectionGateProps) => Element
@@ -65,26 +45,9 @@ ConnectionScreen (value): ({ status, message, detail, errorDetail, unavailable,
ConnectionStage (type): ConnectionStage
ConnectionStatus (type): ConnectionStatus
ConnectionUnavailable (type): ConnectionUnavailable
CreatedApiKey (type): CreatedApiKey
Credential (type): Credential
CredentialDensity (type): CredentialDensity
CredentialPermissionLevel (type): CredentialPermissionLevel
CredentialRowFields (type): CredentialRowFields
CredentialSidebarFilterState (type): CredentialSidebarFilterState
CredentialSidebarPreferences (type): CredentialSidebarPreferences
CredentialSortKey (type): CredentialSortKey
CredentialTrayTrigger (type): CredentialTrayTrigger
CustomKeybinding (type): CustomKeybinding
DASHBOARD_CARDS (value): DashboardCardConfig[]
DEFAULT_FOLDER_COLOR (value): "#f59145"
DEFAULT_FOLDER_ICON (value): "folder"
DashboardCardId (type): DashboardCardId
DataColumn (type): DataColumn<T>
DataView (value): <T>({ items, view, density, getKey, renderCard, columns, listColumns, onRowClick, isRowActive, empty, className, }: { items: T[]; view: PanelViewMode; density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; columns: GridColumns; listColumns: DataColumn<T>[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element
DefaultOverrides (type): DefaultOverrides
DefaultsOnly (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
DesktopUpdateMode (type): DesktopUpdateMode
DesktopUpdateState (type): DesktopUpdateState
Dialog (value): ({ ...props }: DialogProps) => Element
DialogClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
DialogContent (value): ({ className, children, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { showCloseButton?: boolean; }) => Element
@@ -97,7 +60,6 @@ DialogTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<
DialogTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
DocsLink (value): ({ core, page, anchor, href, variant, children, className, }: DocsLinkProps) => Element
DocsLinkProps (type): DocsLinkProps
DocsPage (type): "dashboard" | "users" | "sessions" | "roles" | "branding" | "database" | "manage" | "settings" | "plugins" | "install" | "cli" | "develop" | "home" | "updating" | "betas" | "envVars" | "https" | "reverseProxy" | "desktopSync" | "interface" | "hosts" | "portKnocking" | "hostDefaults" | "credentials" | "sharing" | "account" | "accountSecurity" | "accountData" | "apiKeys" | "commandPalette" | "interfaceSettings" | "appearance" | "keybindings" | "adminGeneral" | "adminDatabase" | "adminSsl" | "auditLog" | "importExport" | "pluginCatalog" | "communityRegistry" | "pluginRegistries" | "hostRegistry"
DropdownMenu (value): ({ ...props }: DropdownMenuProps) => Element
DropdownMenuCheckboxItem (value): ({ className, children, checked, inset, ...props }: DropdownMenuCheckboxItemProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
DropdownMenuContent (value): ({ className, align, sideOffset, ...props }: DropdownMenuContentProps & RefAttributes<HTMLDivElement>) => Element
@@ -115,17 +77,11 @@ DropdownMenuSubTrigger (value): ({ className, inset, children, ...props }: Dropd
DropdownMenuTrigger (value): ({ ...props }: DropdownMenuTriggerProps & RefAttributes<HTMLButtonElement>) => Element
EditingWidth (type): EditingWidth
EmptyState (value): ({ icon: Icon, title, hint, action, className, }: { icon?: LucideIcon; title: string; hint?: string; action?: ReactNode; className?: string; }) => Element
ExportPayload (type): ExportPayload
FOLDER_COLORS (value): string[]
FONT_SIZES (value): { id: FontSizeId; label: string; }[]
Facts (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element
FakeSwitch (value): ({ defaultChecked, disabled, checked, onChange, }: { defaultChecked?: boolean; disabled?: boolean; checked?: boolean; onChange?: (v: boolean) => void; }) => Element
Field (value): ({ label, hint, error, aside, htmlFor, className, children, }: { label: string; hint?: ReactNode; error?: string; aside?: ReactNode; htmlFor?: string; className?: string; children: ReactNode; }) => Element
FieldGroup (type): FieldGroup
FieldPair (value): ({ children }: { children: ReactNode; }) => Element
FolderAccessRule (type): FolderAccessRule
FolderIconEl (value): ({ icon, className, style, }: { icon?: string; className?: string; style?: CSSProperties; }) => Element
FontSizeId (type): FontSizeId
FormFooter (value): ({ onCancel, onSave, saveLabel, cancelLabel, saving, dirty, status, disabled, onDelete, deleteLabel, extra, saveType, form, }: { onCancel?: () => void; onSave?: () => void; saveLabel?: string; cancelLabel?: string; saving?: boolean; dirty?: boolean; status?: ReactNode; disabled?: boolean; onDelete?: () => void; deleteLabel?: string; extra?: ReactNode; saveType?: "button" | "submit"; form?: string; }) => Element
FullScreenAppPhase (type): FullScreenAppPhase
FullScreenAppWrapper (value): FC<FullScreenAppWrapperProps>
@@ -135,33 +91,11 @@ GridColumns (type): GridColumns
GridLayout (type): GridLayout
GridSlot (type): GridSlot
GroupHeading (value): ({ title, count, action, className, }: { title: string; count?: number; action?: ReactNode; className?: string; }) => Element
HexColorPicker (value): ({ value, onChange, }: { value: string; onChange: (hex: string) => void; }) => Element
Host (type): Host
HostActionDef (type): HostActionDef
HostClickBehavior (type): HostClickBehavior
HostData (type): HostData
HostDefaultBadge (value): ({ settingKey }: { settingKey: string; }) => Element
HostDefaultField (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
HostDefaultsChange (type): HostDefaultsChange
HostDefaultsContext (value): Context<HostDefaultsContextValue>
HostDefaultsContextValue (type): HostDefaultsContextValue
HostDefaultsLevel (type): HostDefaultsLevel
HostDefaultsTarget (type): HostDefaultsTarget
HostDensity (type): HostDensity
HostFeatureFields (value): ({ form, updateForm, }: HostFeatureFieldsProps) => Element
HostFeatureFieldsProps (type): HostFeatureFieldsProps
HostFolder (type): HostFolder
HostGroupKey (type): HostGroupKey
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
HostOnly (value): ({ children }: { children: ReactNode; }) => Element
HostProtocolAuthInput (type): HostProtocolAuthInput
HostProtocolDef (type): HostProtocolDef
HostProtocols (type): HostProtocols
HostRowFields (type): HostRowFields
HostSidebarFilterState (type): HostSidebarFilterState
HostSidebarPreferences (type): HostSidebarPreferences
HostTrayTrigger (type): HostTrayTrigger
IconPicker (value): ({ value, color, onChange, }: { value: string; color?: string; onChange: (icon: string) => void; }) => Element
InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; icon?: ReactNode; status?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; footer?: ReactNode; width?: EditingWidth; onBeforeClose?: () => boolean | Promise<boolean>; scroll?: boolean; bare?: boolean; className?: string; children: ReactNode; }) => ReactPortal
Input (value): ({ className, type, ...props }: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) => Element
Kbd (value): ForwardRefExoticComponent<KbdProps & RefAttributes<HTMLElement>>
@@ -169,14 +103,10 @@ KbdKey (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): El
KbdSeparator (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): Element; displayName: string; }
KeyCombo (type): KeyCombo
KeybindingAction (type): KeybindingAction
KeybindingActionDef (type): KeybindingActionDef
KeybindingActionType (type): string
LANGUAGES (value): readonly { code: string; label: string; }[]
Label (value): ({ className, ...props }: LabelProps & RefAttributes<HTMLLabelElement>) => Element
LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element
LineChartSeries (type): LineChartSeries
LinkServerDialog (value): ({ open, onOpenChange, onLinked, relogin, }: LinkServerDialogProps) => Element
LinkedAccountInfo (type): LinkedAccountInfo
ListBadge (value): ({ tone, className, children, }: { tone?: "destructive" | "muted" | "brand" | "warning" | "success"; className?: string; children: ReactNode; }) => Element
ListRow (value): ({ title, icon, badges, meta, stripe, tone, color, selected, active, dimmed, leading, trailing, actions, onClick, className, children, ...rest }: { title: ReactNode; icon?: ReactNode; badges?: ReactNode; meta?: ReactNode; stripe?: number; tone?: ListRowTone; color?: string; selected?: boolean; active?: boolean; dimmed?: boolean; leading?: ReactNode; trailing?: ReactNode; actions?: ReactNode; onClick?: () => void; className?: string; children?: ReactNode; } & Omit<HTMLAttributes<HTMLDivElement>, "title" | "children" | "onClick">) => Element
ListRowAction (value): ({ label, tone, className, children, ...rest }: { label: string; tone?: "default" | "destructive" | "brand"; className?: string; children: ReactNode; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => Element
@@ -184,12 +114,7 @@ ListRowFolder (value): ({ name, icon, count, open, onToggle, stripe, actions, dr
ListRowTone (type): ListRowTone
LogContext (type): LogContext
LogEntry (type): LogEntry
LookSettings (value): ({ isAdmin }: { isAdmin: boolean; }) => Element
MANAGE_REQUEST_EVENT (value): "manage:request"
MAX_PANES (value): 8
MFAPromptMode (type): MFAPromptMode
ManageMode (type): ManageMode
ManageRequest (type): ManageRequest
ManagerCardError (type): ManagerCardError
ManagerCardShell (value): ({ title, icon, loading, error, onRefresh, empty, emptyMessage, children, headerExtra, }: { title: string; icon: ReactNode; loading: boolean; error: ManagerCardError; onRefresh: () => void; empty?: boolean; emptyMessage?: string; children: ReactNode; headerExtra?: ReactNode; }) => Element
ManagerSearch (value): ({ value, onChange, placeholder, count, extra, }: { value: string; onChange: (value: string) => void; placeholder?: string; count?: number; extra?: ReactNode; }) => Element
@@ -197,13 +122,10 @@ Meter (value): ({ percent, className, }: { percent: number; className?: string;
MetricCard (value): ({ title, icon, action, children, bodyClassName, scroll, scrollMax, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; bodyClassName?: string; scroll?: boolean; scrollMax?: number; }) => Element
MiniStat (value): ({ caption, value, valueClassName, className, }: { caption: ReactNode; value: ReactNode; valueClassName?: string; className?: string; }) => Element
NumberField (value): ({ label, value, onChange, placeholder, hint, error, min, max, disabled, className, }: { label: string; value: number; onChange: (value: number) => void; placeholder?: string; hint?: ReactNode; error?: string; min?: number; max?: number; disabled?: boolean; className?: string; }) => Element
OPEN_PLUGINS_EVENT (value): "termix:open-plugins"
OpenTabRecord (type): OpenTabRecord
PANEL (value): { readonly body: "p-2.5"; readonly gap: "gap-2"; readonly band: "px-3 py-2"; readonly cardHead: "px-3 py-2.5"; readonly header: "h-12.5"; }
PROMPT_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-border px-3 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
PROMPT_DESTRUCTIVE_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 bg-destructive px-3 text-xs font-medium text-white transition-colors hover:bg-destructive/90 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
PROMPT_PRIMARY_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-accent-brand/40 px-3 text-xs font-medium text-accent-brand transition-colors hover:bg-accent-brand/10 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
PaletteItemDef (type): PaletteItemDef
PanePrompt (value): ({ open, title, titleId, description, icon, tone, actions, onCancel, role, labelledBy, layer, backgroundColor, className, children, }: { open: boolean; title: ReactNode; titleId?: string; description?: ReactNode; icon?: ReactNode; tone?: "default" | "destructive"; actions?: ReactNode; onCancel?: () => void; role?: "dialog" | "alertdialog"; labelledBy?: string; layer?: "connection" | "pane"; backgroundColor?: string; className?: string; children?: ReactNode; }) => Element
PanelDensity (type): PanelDensity
PanelList (value): ({ empty, className, children, }: { empty?: ReactNode; className?: string; children?: ReactNode; }) => Element
@@ -212,35 +134,18 @@ PanelShell (value): ({ chrome, icon, title, status, leading, actions, toolbar, t
PanelViewMode (type): PanelViewMode
PassphraseDialog (value): ({ isOpen, onSubmit, onCancel, hostInfo, backgroundColor, }: PassphraseDialogProps) => Element
PasswordInput (value): ForwardRefExoticComponent<PasswordInputProps & RefAttributes<HTMLInputElement>>
PermissionCatalogEntry (type): PermissionCatalogEntry
PluginComponent (value): ({ id, fallback, ...props }: { [prop: string]: unknown; id: string; fallback?: ReactNode; }) => ReactNode
PluginIcon (value): ({ name, ...props }: { name?: string; } & LucideProps) => Element
PluginSettingsErrors (type): PluginSettingsErrors
PluginSettingsField (type): PluginSettingsField
PluginSettingsValidationError (value): typeof PluginSettingsValidationError
PluginSummary (type): PluginSummary
PluginViewPlaceholder (value): ({ kind, viewId, compact, }: { kind: ViewKind; viewId: string; compact?: boolean; }) => Element
Popover (value): ({ ...props }: PopoverProps) => Element
PopoverAnchor (value): ({ ...props }: PopoverAnchorProps & RefAttributes<HTMLDivElement>) => Element
PopoverContent (value): ({ className, align, sideOffset, ...props }: PopoverContentProps & RefAttributes<HTMLDivElement>) => Element
PopoverTrigger (value): ({ ...props }: PopoverTriggerProps & RefAttributes<HTMLButtonElement>) => Element
ProxyNode (type): ProxyNode
ProxySettingsFields (value): ({ customHeaders, basicAuth, onChange, }: ProxySettingsFieldsProps) => Element
RadialGauge (value): ({ value, label, caption, size, strokeWidth, className, colorClassName, }: RadialGaugeProps) => Element
RadialGaugeProps (type): RadialGaugeProps
RailGroup (type): RailGroup
RailItemDef (type): RailItemDef
RecentActivityItem (type): RecentActivityItem
ReleaseInfo (type): ReleaseInfo
Repeater (value): <T>({ label, items, onChange, makeItem, empty, addLabel, hint, renderItem, }: { label: string; items: T[]; onChange: (items: T[]) => void; makeItem: () => T; empty: string; addLabel: string; hint?: ReactNode; renderItem: (item: T, update: (next: T) => void, index: number) => ReactNode; }) => Element
ResolvedHostDefault (type): ResolvedHostDefault
RobustClipboardProvider (value): typeof RobustClipboardProvider
Role (type): Role
SSHAuthDialog (value): ({ isOpen, reason, onSubmit, onCancel, hostInfo, backgroundColor, }: SSHAuthDialogProps) => Element
SSHHost (type): Host
SSHHostData (type): HostData
SSHHostWithStatus (type): SSHHostWithStatus
SSH_AUTH_PROTOCOL (value): "ssh"
ScrollArea (value): ({ className, children, ...props }: ScrollAreaProps & RefAttributes<HTMLDivElement>) => Element
ScrollBar (value): ({ className, orientation, ...props }: ScrollAreaScrollbarProps & RefAttributes<HTMLDivElement>) => Element
SecretField (value): ({ label, value, onChange, placeholder, hint, error, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; disabled?: boolean; className?: string; }) => Element
@@ -260,9 +165,6 @@ SelectTrigger (value): ({ className, size, children, ...props }: SelectTriggerPr
SelectValue (value): ({ ...props }: SelectValueProps & RefAttributes<HTMLSpanElement>) => Element
Separator (value): ({ className, orientation, decorative, ...props }: SeparatorProps & RefAttributes<HTMLDivElement>) => Element
SettingRow (value): ({ label, badge, description, defaultKey, rowId, children, }: { label: string; badge?: string; description?: ReactNode; defaultKey?: string; rowId?: string; children: ReactNode; }) => Element
SettingsFieldRow (value): ({ pluginId, field, values, setValue, running, error, disabled, defaultKey, }: SettingsFieldProps) => Element
SharePermissionLevel (type): SharePermissionLevel
ShareTarget (type): ShareTarget
Sheet (value): ({ ...props }: DialogProps) => Element
SheetClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
SheetContent (value): ({ className, children, side, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { side?: "top" | "right" | "bottom" | "left"; showCloseButton?: boolean; }) => Element
@@ -271,381 +173,71 @@ SheetFooter (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<
SheetHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
SheetTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
SheetTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
ShellConfirmCapture (value): () => null
Skeleton (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderProps & RefAttributes<HTMLSpanElement>) => Element
SortKey (type): SortKey
Sparkline (value): ({ data, domain, className, colorClassName, showLastDot, height, }: SparklineProps) => Element
SparklineProps (type): SparklineProps
SplitLayoutMenu (value): ({ activeIsSplit, canAddPane, onSplit, onPreset, }: { activeIsSplit: boolean; canAddPane: boolean; onSplit: (edge: "right" | "bottom") => void; onPreset: (id: SplitPresetId) => void; }) => Element
SplitOpenTarget (type): SplitOpenTarget
SplitSummary (type): SplitSummary
StatRow (value): ({ label, value, mono, className, }: { label: ReactNode; value: ReactNode; mono?: boolean; className?: string; }) => Element
StatusValue (type): StatusValue
SurfaceKind (type): SurfaceKind
SurfaceScope (value): ({ kind, onEditingChange, onClose, className, children, }: { kind?: SurfaceKind; onClose?: () => void; onEditingChange?: (editing: EditingWidth) => void; className?: string; children: ReactNode; }) => Element
Switch (value): ({ className, ...props }: SwitchProps & RefAttributes<HTMLButtonElement>) => Element
SwitchRow (value): ({ label, description, badge, checked, onChange, disabled, defaultKey, rowId, }: { label: string; description?: ReactNode; badge?: string; checked: boolean; onChange: (value: boolean) => void; disabled?: boolean; defaultKey?: string; rowId?: string; }) => Element
SyncBasicAuth (type): SyncBasicAuth
SyncConflictItem (type): SyncConflictItem
SyncEntityInfo (type): SyncEntityInfo
SyncErrorItem (type): SyncErrorItem
SyncProxyHeader (type): SyncProxyHeader
SyncStatus (type): SyncStatus
TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element
Tab (type): Tab
TabShellCallbacks (type): TabShellCallbacks
TabSplitAction (type): TabSplitAction
TabStage (value): ({ className }: { className?: string; }) => Element
TabStrip (value): ({ tabs, activeTab, onTabChange, isActive, variant, trailing, className, }: { tabs: TabStripItem[]; activeTab: string; onTabChange: (id: string) => void; isActive?: (id: string) => boolean; variant?: "secondary" | "primary"; trailing?: ReactNode; className?: string; }) => Element
TabStripItem (type): TabStripItem
TabType (type): TabType
TagInput (value): ({ tags, input, placeholder, onTagsChange, onInputChange, }: { tags: string[]; input: string; placeholder: string; onTagsChange: (tags: string[]) => void; onInputChange: (value: string) => void; }) => Element
TextAreaField (value): ({ label, value, onChange, placeholder, rows, hint, error, aside, mono, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; rows?: number; hint?: ReactNode; error?: string; aside?: ReactNode; mono?: boolean; disabled?: boolean; className?: string; }) => Element
TextField (value): ({ label, value, onChange, placeholder, hint, error, aside, disabled, mono, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; aside?: ReactNode; disabled?: boolean; mono?: boolean; className?: string; }) => Element
Textarea (value): ForwardRefExoticComponent<TextareaProps & RefAttributes<HTMLTextAreaElement>>
TextareaProps (type): TextareaProps
ThemeId (type): string
TlsStatus (type): TlsStatus
ToolsTab (type): ToolsTab
Tooltip (value): ({ ...props }: TooltipProps) => Element
TooltipContent (value): ({ className, sideOffset, children, ...props }: TooltipContentProps & RefAttributes<HTMLDivElement>) => Element
TooltipProvider (value): ({ delayDuration, ...props }: TooltipProviderProps) => Element
TooltipTrigger (value): ({ ...props }: TooltipTriggerProps & RefAttributes<HTMLButtonElement>) => Element
UI_AREA_KEYS (value): UiAreaKey[]
UI_FONTS (value): { id: UiFontId; label: string; family: string; }[]
UiAreaKey (type): UiAreaKey
UiFontId (type): UiFontId
UiPreset (type): UiPreset
UpdateChannelState (type): UpdateChannelState
UptimeInfo (type): UptimeInfo
UsagePair (value): ({ label, percent, detail, }: { label?: string; percent: number; detail?: string; }) => Element
UserPreferences (type): UserPreferences
UserRole (type): UserRole
UserSecondFactorSummary (type): UserSecondFactorSummary
VersionBadge (value): ({ status, releaseUrl, className, }: VersionBadgeProps) => Element
VersionInfo (type): VersionInfo
ViewToggle (value): ({ view, onView, density, onDensity, }: { view: PanelViewMode; onView: (next: PanelViewMode) => void; density?: PanelDensity; onDensity?: (next: PanelDensity) => void; }) => Element
WebSocketConnectionTarget (type): WebSocketConnectionTarget
WidgetTitle (value): ({ title, icon, fallback }: WidgetTitleProps) => Element
actionOrder (value): (action: HostActionDef) => number
activityTarget (value): (type: string) => ActivityTarget
adminCreateUser (value): (username: string, password: string) => Promise<Record<string, unknown>>
adminCreateUserCredential (value): (targetUserId: string, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
adminCreateUserHost (value): (targetUserId: string, hostData: HostData) => Promise<Host>
adminDeleteUserCredential (value): (targetUserId: string, credentialId: number) => Promise<Record<string, unknown>>
adminDeleteUserHost (value): (targetUserId: string, hostId: number) => Promise<Record<string, unknown>>
adminExportUserData (value): (userId: string) => Promise<Record<string, unknown>>
adminGetHostPassword (value): (targetUserId: string, hostId: number, field?: "key" | "password" | "sudoPassword" | "keyPassword") => Promise<string>
adminGetUserCredentials (value): (targetUserId: string) => Promise<Record<string, unknown> | Record<string, unknown>[]>
adminGetUserHosts (value): (targetUserId: string) => Promise<Host[]>
adminResetUserPassword (value): (userId: string, newPassword: string, confirmDataWipe?: boolean) => Promise<{ message: string; dataWiped?: boolean; }>
adminUpdateUserCredential (value): (targetUserId: string, credentialId: number, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
adminUpdateUserHost (value): (targetUserId: string, hostId: number, hostData: HostData) => Promise<Host>
applyAccentColor (value): (colorValue: string) => void
applyFontSize (value): (id: FontSizeId) => void
applyPresetSideEffects (value): (preset: "advanced" | "simple" | "balanced") => Promise<void>
applyUiFont (value): (id: UiFontId) => void
assignRoleToUser (value): (userId: string, roleId: number) => Promise<{ success: boolean; }>
beginSplitDrag (value): (source: SplitDragSource, x: number, y: number) => void
buildExportPayload (value): (raw: ExportPayload, selected: Set<string>, groups: Set<FieldGroup>, withCredentials: boolean, pluginSecrets?: PluginSecretKeys) => ExportPayload
buildOriginWsUrl (value): ({ origin, localPort, localPath, remotePath, includeJwt, }: { origin: ConnectionOrigin; localPort: number; localPath: string; remotePath: string; includeJwt?: boolean; }) => Promise<WebSocketConnectionTarget>
bulkImportSSHHosts (value): (hosts: HostData[], overwrite?: boolean, credentials?: Record<string, unknown>[]) => Promise<{ message: string; success: number; updated: number; skipped: number; failed: number; errors: string[]; }>
bulkUpdateSSHHosts (value): (hostIds: number[], updates: Record<string, unknown>) => Promise<{ updated: number; failed: number; errors: string[]; }>
canConfigureUpdates (value): (state: DesktopUpdateState) => state is DesktopUpdateState
canJoinSplit (value): (tab: Tab) => boolean
changeAppLanguage (value): (language: string) => Promise<string>
changePassword (value): (oldPassword: string, newPassword: string) => Promise<any>
clearLocalAdaptivePreferences (value): () => void
cn (value): (...inputs: ClassValue[]) => string
copyToClipboard (value): (text: string) => Promise<boolean>
createApiKey (value): (name: string, userId: string, expiresAt?: string) => Promise<CreatedApiKey>
createCredential (value): (credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger
createQuickConnectHost (value): (input: QuickConnectInput) => Host
createRole (value): (roleData: { name: string; displayName: string; description?: string; }) => Promise<{ role: Role; }>
createSSHHost (value): (hostData: HostData) => Promise<Host>
defaultConnectAction (value): (all: HostActionDef[], host: Host) => HostActionDef
defaultCredentialRowFields (value): () => CredentialRowFields
defaultCredentialSidebarPreferences (value): () => CredentialSidebarPreferences
defaultHostRowFields (value): () => HostRowFields
defaultHostSidebarPreferences (value): () => HostSidebarPreferences
defaultValuesEqual (value): (a: unknown, b: unknown) => boolean
deleteAccount (value): (password: string) => Promise<Record<string, unknown>>
deleteAllHostsInFolder (value): (folderName: string) => Promise<{ deletedCount: number; }>
deleteApiKey (value): (keyId: string) => Promise<{ success: boolean; }>
deleteCredential (value): (credentialId: number) => Promise<Record<string, unknown>>
deleteOpenTab (value): (instanceId: string) => Promise<void>
deleteRole (value): (roleId: number) => Promise<{ success: boolean; }>
deleteSSHHost (value): (hostId: number) => Promise<Record<string, unknown>>
deleteUser (value): (username: string, successorUserId?: string) => Promise<Record<string, unknown>>
deployCredentialToHost (value): (credentialId: number, targetHostId: number) => Promise<Record<string, unknown>>
desktopUpdates (value): () => DesktopUpdatesBridge
docsUrl (value): (page?: "dashboard" | "users" | "sessions" | "roles" | "branding" | "database" | "manage" | "settings" | "plugins" | "install" | "cli" | "develop" | "home" | "updating" | "betas" | "envVars" | "https" | "reverseProxy" | "desktopSync" | "interface" | "hosts" | "portKnocking" | "hostDefaults" | "credentials" | "sharing" | "account" | "accountSecurity" | "accountData" | "apiKeys" | "commandPalette" | "interfaceSettings" | "appearance" | "keybindings" | "adminGeneral" | "adminDatabase" | "adminSsl" | "auditLog" | "importExport" | "pluginCatalog" | "communityRegistry" | "pluginRegistries" | "hostRegistry") => string
duplicateCredential (value): (credentialId: number, data: Record<string, unknown>) => Promise<Record<string, unknown>>
enabledHostProtocols (value): (host: { pluginSettings?: Record<string, Record<string, unknown>>; }, list?: HostProtocolDef[]) => HostProtocolDef[]
endSplitDrag (value): (commit: boolean) => void
exportAllSSHHosts (value): { (): Promise<{ hosts: Host[]; }>; (options: { share: true; }): Promise<{ version: string; exportedAt: string; credentials: Record<string, unknown>[]; hosts: Host[]; }>; }
filterPaletteItems (value): (items: PaletteItemDef[], query: string, showWhenEmpty?: boolean) => PaletteItemDef[]
findCoreHostDefault (value): (key: string) => CoreHostDefault
findMatchingKeybinding (value): (e: KeyboardEvent, bindings: CustomKeybinding[]) => CustomKeybinding
generateKeyPair (value): (keyType: "ssh-ed25519" | "ssh-rsa" | "ecdsa-sha2-nistp256", keySize?: number, passphrase?: string) => Promise<GeneratedKeyPair>
generatePublicKeyFromPrivate (value): (privateKey: string, keyPassword?: string) => Promise<GeneratedPublicKey>
getActiveSessions (value): () => Promise<ActiveSessionInfo[]>
getAdaptiveResourceBudget (value): (kind: AdaptiveTaskKind) => AdaptiveResourceBudget
getApiKeys (value): () => Promise<{ apiKeys: ApiKey[]; }>
getAuditForwarding (value): () => Promise<AuditForwardingSettings>
getAuditLogActions (value): () => Promise<{ actions: string[]; }>
getAuditLogs (value): (filters?: AuditLogFilters) => Promise<AuditLogResponse>
getBasePath (value): () => string
getBranding (value): () => Promise<BrandingSettings>
getChannelReleases (value): () => Promise<{ localVersion: string; stable: ReleaseInfo; beta: ReleaseInfo; }>
getConnectedRemoteApi (value): () => Promise<AxiosInstance>
getCredentialAccess (value): (credentialId: number) => Promise<{ access: AccessRecord[]; }>
getCredentialDetails (value): (credentialId: number) => Promise<Record<string, unknown>>
getCredentialSidebarPreferences (value): () => Promise<CredentialSidebarPreferences>
getCredentials (value): () => Promise<Record<string, unknown> | Record<string, unknown>[]>
getDatabaseHealth (value): () => Promise<Record<string, unknown>>
getDatabaseTransferUrl (value): (operation: "export" | "import", { electron, configuredServerUrl, location }: DatabaseTransferUrlOptions) => string
getDefaultConnectionTab (value): (host: Host) => TabType
getDeveloperMode (value): () => Promise<DeveloperModeState>
getDeviceId (value): () => string
getErrorMessage (value): (error: unknown, fallback?: string) => string
getExternalAutoProvision (value): () => Promise<{ enabled: boolean; }>
getFolderAccess (value): (folder: string) => Promise<{ rules: FolderAccessRule[]; }>
getFolderDefaultsId (value): (name: string) => Promise<number>
getHostAccess (value): (hostId: number, syncId?: string) => Promise<{ accessList: AccessRecord[]; isOwner?: boolean; }>
getHostAuthOverride (value): (hostId: number, protocol: string, sharedCopySyncId?: string) => Promise<{ protocol: string; credentialId: number; }>
getHostDefaultsLevel (value): (target: HostDefaultsTarget) => Promise<HostDefaultsLevelData>
getHostEditorSection (value): (id: string) => HostEditorSectionDef
getHostPassword (value): (hostId: number, field?: "key" | "password" | "sudoPassword" | "keyPassword") => Promise<string>
getHostSidebarPreferences (value): () => Promise<HostSidebarPreferences>
getHostTags (value): () => Promise<string[]>
getLiveHostStatus (value): (hostId: number) => StatusValue
getLogLevel (value): () => Promise<{ level: string; }>
getNotificationPrivateEndpoints (value): () => Promise<string[]>
getPanel (value): (id: string) => PanelDef
getPasswordLoginAllowed (value): () => Promise<{ allowed: boolean; forced?: boolean; }>
getPasswordResetAllowed (value): () => Promise<boolean>
getPermissionsCatalog (value): () => Promise<{ catalog: PermissionCatalogEntry[]; }>
getPluginAdminSettings (value): (pluginId: string) => Promise<PluginSettingsValues>
getPluginHostSettings (value): (pluginId: string, hostId: number) => Promise<PluginSettingsValues>
getPluginUserSettings (value): (pluginId: string) => Promise<PluginSettingsValues>
getPlugins (value): () => Promise<PluginSummary[]>
getPollingEnvironmentMultiplier (value): (connection?: NetworkInformationLike) => number
getPreferredHostAction (value): (hostId: string, candidates: readonly TabType[], fallback: TabType) => TabType
getRecentActivity (value): (limit?: number) => Promise<RecentActivityItem[]>
getRegisteredDashboardCard (value): (id: string) => RegisteredDashboardCard
getRegistrationAllowed (value): () => Promise<{ allowed: boolean; }>
getRoles (value): () => Promise<{ roles: Role[]; }>
getSSHFolders (value): () => Promise<SSHFolder[]>
getSSHHosts (value): () => Promise<SSHHostWithStatus[]>
getSecondFactorAfterExternalLogin (value): () => Promise<{ enabled: boolean; }>
getSessionTimeout (value): () => Promise<{ timeoutHours: number; }>
getSessionTimeoutMinutes (value): () => Promise<number>
getSessions (value): () => Promise<{ sessions: { id: string; userId: string; username?: string; deviceType: string; deviceInfo: string; createdAt: string; expiresAt: string; lastActiveAt: string; isRevoked?: boolean; isCurrentSession?: boolean; }[]; }>
getStatusCheckSettings (value): () => Promise<{ statusCheckInterval: number; }>
getStatusClasses (value): (status: boolean | "online" | "offline" | "unknown", scheme: StatusColorScheme, variant: "dot" | "stripe" | "badge", loading?: boolean) => string
getSyncConflicts (value): () => Promise<SyncConflictItem[]>
getSyncErrors (value): () => Promise<SyncErrorItem[]>
getTabType (value): (id: string) => TabTypeDef
getTlsStatus (value): () => Promise<TlsStatus>
getUpdateChannel (value): () => Promise<UpdateChannelState>
getUptime (value): () => Promise<UptimeInfo>
getUserInfo (value): () => Promise<UserInfo>
getUserList (value): (options?: UserListOptions) => Promise<{ users: UserInfo[]; total?: number; }>
getUserPreferences (value): () => Promise<UserPreferences>
getUserRoles (value): (userId: string) => Promise<{ roles: UserRole[]; }>
getUserSecondFactors (value): (userId: string) => Promise<UserSecondFactorSummary[]>
getVersionInfo (value): (checkRemote?: boolean) => Promise<VersionInfo>
globalShortcutHandler (value): { current: (e: KeyboardEvent) => void; }
groupRailItems (value): (items: RailItemDef[]) => { group: RailGroup; items: RailItemDef[]; }[]
hasVisibleFields (value): (fields: PluginSettingsField[]) => boolean
hostActionsFor (value): (all: HostActionDef[], host: Host, kind?: "open" | "connect") => HostActionDef[]
hostBadgesFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
hostKey (value): (host: Record<string, unknown>) => string
hostMenuItemsFor (value): (all: HostContextMenuItemDef[], host: Host) => HostContextMenuItemDef[]
hostProtocolFlags (value): (host: { enableSsh?: boolean; pluginSettings?: Record<string, Record<string, unknown>>; }, list?: HostProtocolDef[]) => HostProtocols
hydrateLocalSharedHostAuth (value): <T>(host: T) => Promise<T>
importSSHConfigHosts (value): (content: string, overwrite?: boolean) => Promise<{ message: string; success: number; updated: number; skipped: number; failed: number; errors: string[]; }>
invalidateHostsAndStatusCaches (value): () => void
invalidateServerStatusCache (value): () => void
isCoreRailView (value): (id: string) => boolean
isElectron (value): () => boolean
isFieldShown (value): (field: PluginSettingsField, values: Record<string, unknown>) => boolean
isMacPlatform (value): () => boolean
isPersistentTabType (value): (type: string) => boolean
isPrerelease (value): (version: string) => boolean
isSessionTabType (value): (type: string) => boolean
isSplitDragging (value): () => boolean
isSplitTab (value): (tab: Tab) => tab is SplitTab
isTabJumpHotkey (value): (e: KeyboardEvent) => boolean
linkExternalToPasswordAccount (value): (externalUserId: string, targetUsername: string) => Promise<{ success: boolean; message: string; }>
linkedServerUrl (value): () => Promise<string>
listHostActions (value): () => HostActionDef[]
listHostProtocols (value): () => HostProtocolDef[]
loadPaletteGroup (value): (group: PaletteGroupDef) => Promise<PaletteItemDef[]>
logoutUser (value): () => Promise<{ success: boolean; message: string; }>
makeHostSshSubTabs (value): (t: (key: string, options?: { defaultValue?: string; }) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean) => HostTab[]
makeHostTabs (value): (t: (key: string, options?: { defaultValue?: string; }) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean) => HostTab[]
makeUserAdmin (value): (userId: string) => Promise<Record<string, unknown>>
mapCredentials (value): (res: unknown) => Credential[]
markAdaptiveResourceUsed (value): (kind: AdaptiveTaskKind, action: string, now?: number) => void
markTabSurfaceUsed (value): (type: TabType) => void
maskSecrets (value): (payload: ExportPayload, pluginSecrets?: PluginSecretKeys) => ExportPayload
moveRailItem (value): (items: RailItemDef[], id: string, beforeId: string) => string[]
moveSplitDrag (value): (x: number, y: number) => void
normalizeLanguageCode (value): (language?: string) => string
notifySyncChanged (value): () => void
openInSplit (value): (target: SplitOpenTarget, open: () => void) => void
paletteEntriesFor (value): (all: PaletteEntryDef[], scope: "global" | "host", host?: Host) => PaletteEntryDef[]
parseCustomKeybindings (value): (raw?: string) => CustomKeybinding[]
parseServerTime (value): (ts: string) => Date
pluginKey (value): { (pluginId: string, key: string): string; (pluginId: string, key: string): string; }
pluginName (value): (t: Translator, plugin: { id: string; name: string; }) => string
pluginSecretKeys (value): (summaries: { id: string; contributes?: { settings?: { host?: { fields?: { key: string; secretKeys?: string[]; }[]; }; }; }; }[]) => PluginSecretKeys
pluginWsUrl (value): (pluginId: string, path: string, options?: { origin?: ConnectionOrigin; }) => Promise<WebSocketConnectionTarget>
preloadTabSurface (value): (type: TabType) => void
previewHostDefaultsLevel (value): (target: HostDefaultsTarget, change: HostDefaultsChange) => Promise<{ changedHosts: number; }>
promotableIds (value): () => string[]
protocolEnabled (value): (pluginSettings: Record<string, Record<string, unknown>>, protocol: HostProtocolDef) => boolean
protocolPort (value): (pluginSettings: Record<string, Record<string, unknown>>, protocol: HostProtocolDef) => number
quickConnectTargets (value): (all: HostActionDef[], host: Host) => HostActionDef[]
railItemLabel (value): (id: string, t: (key: string) => string) => string
readFromClipboard (value): () => Promise<string>
recordHostActionPreference (value): (hostId: string, action: TabType) => void
refreshSyncStatus (value): () => void
registerLayout (value): (item: RegisteredLayout) => () => void
registerTabCloseGuard (value): (tabId: string, guard: Guard) => () => void
releaseUrlFrom (value): (info: VersionInfo) => string
rem (value): (px: number) => string
removeAdminStatus (value): (userId: string) => Promise<Record<string, unknown>>
removeRoleFromUser (value): (userId: string, roleId: number) => Promise<{ success: boolean; }>
renameCredentialFolder (value): (oldName: string, newName: string) => Promise<Record<string, unknown>>
renameFolder (value): (oldName: string, newName: string) => Promise<Record<string, unknown>>
reorderCredentials (value): (positions: { id: number; sortOrder: number; }[]) => Promise<{ updated: number; }>
reorderFolders (value): (positions: { name: string; sortOrder: number; }[]) => Promise<{ updated: number; }>
reorderSSHHosts (value): (positions: { id: number; sortOrder: number; }[]) => Promise<{ updated: number; }>
reportBetaFeedbackUrl (value): (termixVersion?: string, betaPlugins?: { id: string; version: string; }[]) => string
reportCoreIssueUrl (value): (termixVersion?: string) => string
requestFromLegacyEvent (value): (name: string, detail?: unknown) => ManageRequest
requestManage (value): (request: ManageRequest) => void
resetHostToDefaults (value): (hostIds: number[], reset: { all?: boolean; namespaces?: string[]; keys?: string[]; }) => Promise<void>
resetRecentActivity (value): () => Promise<{ message: string; }>
resetUserSecondFactors (value): (userId: string) => Promise<void>
resolveConnectionOrigin (value): (host: OriginResolvableHost, options?: { defaultRemote?: boolean; }) => Promise<ConnectionOrigin>
resolveHostDefaults (value): (where: { hostId?: number; folder?: string; parentHostId?: number; }, adminTargetUserId?: string) => Promise<Record<string, ResolvedHostDefault>>
resolveHostTabType (value): (host: Host, preferredType?: TabType) => TabType
resolveRemoteHostId (value): (syncId: string) => Promise<number>
retrySyncErrors (value): () => Promise<SyncStatus>
revokeAllUserSessions (value): (userId: string) => Promise<{ success: boolean; message: string; }>
revokeCredentialAccess (value): (credentialId: number, accessId: number) => Promise<void>
revokeFolderAccess (value): (ruleId: number) => Promise<void>
revokeHostAccess (value): (hostId: number, accessId: number, syncId?: string) => Promise<{ success: boolean; }>
revokeSession (value): (sessionId: string) => Promise<{ success: boolean; message: string; }>
rightDockableIds (value): () => string[]
roleLabel (value): <T extends string | null | undefined>(t: Translator, displayName: T) => string | T
runAdaptiveBackgroundTask (value): (kind: AdaptiveTaskKind, action: string, task: () => Promise<unknown>, options?: AdaptiveTaskOptions) => boolean
runAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePollResult>, policy: AdaptivePollingPolicy, options?: { enabled?: () => boolean; visible?: () => boolean; runImmediately?: boolean; random?: () => number; intervalMultiplier?: () => number; onError?: (error: unknown) => void; }) => () => void
runHostAction (value): (action: HostActionDef, host: Host, shell: TabShellCallbacks) => void
runVisibleInterval (value): (tick: () => void, intervalMs: number) => () => void
sanitizeCredentialSidebarPreferences (value): (input: unknown) => CredentialSidebarPreferences
sanitizeHostSidebarPreferences (value): (input: unknown) => HostSidebarPreferences
saveCredentialSidebarPreferences (value): (preferences: CredentialSidebarPreferences) => Promise<void>
saveHostDefaultsLevel (value): (target: HostDefaultsTarget, change: HostDefaultsChange) => Promise<{ changedHosts?: number; jobId?: string; }>
saveHostSidebarPreferences (value): (preferences: HostSidebarPreferences) => Promise<void>
saveHostTags (value): (tags: string[]) => Promise<string[]>
saveUserPreferences (value): (prefs: Partial<UserPreferences>) => Promise<void>
setAllPluginChannels (value): (channel: PluginChannel) => Promise<{ changed: string[]; }>
setDeveloperMode (value): (enabled: boolean) => Promise<void>
setHostAuthOverride (value): (hostId: number, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; protocol: string; credentialId: number; }>
setNotificationPrivateEndpoints (value): (hosts: string[]) => Promise<string[]>
setPluginOptions (value): (pluginId: string, options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel; }) => Promise<{ autoUpdate: boolean; pinnedVersion: string; channel: PluginChannel; }>
setSyncStatus (value): (next: SyncStatus) => void
setUpdateChannel (value): (channel: UpdateChannel) => Promise<UpdateChannelState>
settleSyncConflict (value): (id: number, keep: "mine" | "server") => Promise<void>
shareCredential (value): (credentialId: number, targets: ShareTarget[], permissionLevel: CredentialPermissionLevel, durationHours?: number) => Promise<{ success: boolean; expiresAt: string; }>
shareFolder (value): (folder: string, shareData: { targets: ShareTarget[]; permissionLevel: SharePermissionLevel; durationHours?: number; }) => Promise<{ success: boolean; expiresAt: string; hostsShared: number; hostsTotal: number; hostResults: { hostId: number; shared: boolean; reason?: string; }[]; }>
shareHost (value): (hostId: number, shareData: { targets: ShareTarget[]; permissionLevel: SharePermissionLevel; durationHours?: number; }, syncId?: string) => Promise<{ success: boolean; expiresAt: string; results: { type: "role" | "user"; id: string | number; accessId: number; created: boolean; }[]; }>
shell (value): TabShellCallbacks
splitDefaultKey (value): (fullKey: string) => [string, string]
sshHostToHost (value): (h: SSHHostWithStatus) => Host
syncNow (value): () => Promise<SyncStatus>
tabIcon (value): (type: TabType) => Element
tabJumpHotkeyKeys (value): () => string[]
takePendingManageRequest (value): () => ManageRequest
unlinkExternalFromPasswordAccount (value): (userId: string) => Promise<{ success: boolean; message: string; }>
unlinkServer (value): (keepData: boolean) => Promise<SyncStatus>
updateAuditForwarding (value): (url: string, token?: string) => Promise<{ url: string; hasToken: boolean; }>
updateBranding (value): (update: BrandingSettingsUpdate) => Promise<BrandingSettings>
updateCredential (value): (credentialId: number, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
updateExternalAutoProvision (value): (enabled: boolean) => Promise<Record<string, unknown>>
updateFolderMetadata (value): (name: string, color?: string, icon?: string, credentialId?: number, localOnly?: boolean) => Promise<void>
updateHostAccess (value): (hostId: number, accessId: number, update: { permissionLevel?: SharePermissionLevel; durationHours?: number; }, syncId?: string) => Promise<{ success: boolean; expiresAt: string; }>
updateLogLevel (value): (level: string) => Promise<void>
updatePasswordLoginAllowed (value): (allowed: boolean) => Promise<{ allowed: boolean; }>
updatePasswordResetAllowed (value): (allowed: boolean) => Promise<{ allowed: boolean; }>
updatePluginAdminSettings (value): (pluginId: string, values: PluginSettingsValues) => Promise<PluginSettingsValues>
updatePluginHostSettings (value): (pluginId: string, hostId: number, values: PluginSettingsValues) => Promise<PluginSettingsValues>
updatePluginUserSettings (value): (pluginId: string, values: PluginSettingsValues) => Promise<PluginSettingsValues>
updateRegistrationAllowed (value): (allowed: boolean) => Promise<Record<string, unknown>>
updateRole (value): (roleId: number, roleData: { displayName?: string; description?: string; permissions?: string[]; }) => Promise<{ role: Role; }>
updateSSHHost (value): (hostId: number, hostData: HostData) => Promise<Host>
updateSecondFactorAfterExternalLogin (value): (enabled: boolean) => Promise<Record<string, unknown>>
updateSessionTimeout (value): (timeoutHours: number) => Promise<void>
updateStatusCheckSettings (value): (settings: { statusCheckInterval: number; }) => Promise<void>
updateSyncSettings (value): (settings: { disabledTypes?: string[]; customHeaders?: SyncProxyHeader[]; basicAuth?: SyncBasicAuth; allowInvalidCertificate?: boolean; }) => Promise<SyncStatus>
uploadTlsCertificate (value): (payload: { certificate: string; privateKey: string; }) => Promise<TlsStatus & { success: boolean; reloadMessage?: string; }>
usageBarColor (value): (percent: number) => string
usageColor (value): (percent: number) => string
useActionSlot (value): (slotId: string, context?: Record<string, unknown>) => SlotContribution[]
useAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePollResult>, policy: AdaptivePollingPolicy, enabled?: boolean, options?: { runImmediately?: boolean; onError?: (error: unknown) => void; }) => void
useAppTheme (value): () => ThemeProviderState
useAreaPreferences (value): <A extends UiAreaKey>(area: A) => UiAreaPreferences[A]
useBranding (value): () => BrandingContextValue
useChooseTheme (value): () => (themeId: string) => void
useConfirm (value): () => (options: ConfirmOptions) => Promise<boolean>
useConnectionGate (value): () => ConnectionGateController
useConnectionLog (value): () => ConnectionLogContextType
useDesktopUpdate (value): () => DesktopUpdateState
useEditorDefaultKeys (value): () => Map<string, EditorDefaultKey>
useHostActions (value): () => HostActionDef[]
useHostBadges (value): () => HostBadgeDef[]
useHostContextMenuItems (value): () => HostContextMenuItemDef[]
useHostEditorSections (value): () => HostEditorSectionDef[]
useHostProtocols (value): () => HostProtocolDef[]
useHostStatus (value): (hostId: number, statusCheckEnabled?: boolean) => StatusValue
useInstalledPlugins (value): () => PluginSummary[]
useIsDefaultsEditor (value): () => boolean
useIsMobile (value): () => boolean
useKeybindingActions (value): () => KeybindingActionDef[]
useKeybindingDefaults (value): () => KeybindingDefaultDef[]
useOptionalConnectionLog (value): () => ConnectionLogContextType
usePageVisibleInterval (value): (callback: () => void, intervalMs: number, enabled?: boolean, options?: Options) => void
usePaletteEntries (value): () => PaletteEntryDef[]
usePaletteGroups (value): () => PaletteGroupDef[]
usePanels (value): () => PanelDef[]
usePermissions (value): () => PermissionsState
usePluginDocsUrl (value): (pluginId: string, page?: string, anchor?: string) => string
usePluginHostSections (value): () => PluginSummary[]
usePluginStore (value): () => StoreState
useRailItems (value): () => RailItemDef[]
useRegisteredDashboardCards (value): () => RegisteredDashboardCard[]
useServerStatus (value): () => ServerStatusContextType
useServerStatusMeta (value): () => { initialLoadComplete: boolean; }
useShellController (value): () => ShellController
useSplitTargets (value): () => SplitTargetsSnapshot
useSshAuthEditors (value): () => SshAuthEditorDef[]
useSshAuthProviders (value): () => { providers: SshAuthTypeOption[]; loaded: boolean; find: (type: string) => SshAuthTypeOption; }
useStatusColorScheme (value): () => StatusColorScheme
useSurfaceKind (value): () => SurfaceKind
useSyncStatus (value): () => any
useTabHandlesVersion (value): () => number
useTabs (value): () => TabContextType
useTabsSafe (value): () => TabContextType
useTheme (value): () => ThemeProviderState
useThemeOptions (value): () => ThemeOption[]
useUiPreference (value): <A extends UiAreaKey, K extends keyof UiAreaPreferences[A]>(area: A, key: K) => UiAreaPreferences[A][K]
useUiPreferencesContext (value): () => UiPreferencesContextValue
waitForDefaultsJob (value): (id: string, intervalMs?: number, maxWaitMs?: number) => Promise<number>
withProtocolSettings (value): (pluginSettings: Record<string, Record<string, unknown>>, flags: HostProtocols, list?: HostProtocolDef[]) => Record<string, Record<string, unknown>>
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 46 KiB

+1 -6
View File
@@ -1,9 +1,4 @@
// CACHE_NAME carries the package version so a new release always gets a fresh
// cache: the activate handler below deletes every cache whose name differs
// from this one, which is what evicts a previous build's entries. Both
// placeholders on these lines are stamped by scripts/stamp-sw-build-info.cjs
// during `npm run build` - vite copies public/ verbatim and never transforms it.
const CACHE_NAME = "termix-static-" + "__TERMIX_CACHE_VERSION__";
const CACHE_NAME = "termix-static-v2";
const BASE_PATH = "__TERMIX_SW_BASE_PATH__";
const STATIC_ASSETS = [
`${BASE_PATH}/favicon.ico`,
-68
View File
@@ -416,71 +416,3 @@ describe("rebuilding local plugins", () => {
expect(run.sort()).toEqual(["a", "b"]);
});
});
describe("default look", () => {
const entry = { source: "tmxplug", path: "a", sha256: SHA };
it("reads the defaultLook flag and refuses anything but a boolean", () => {
const { plugins, problems } = parseBundledPlugins({
plugins: [
{ ...entry, id: "look", defaultLook: true },
{ ...entry, id: "plain" },
{ ...entry, id: "odd", defaultLook: "yes" },
],
});
expect(problems).toEqual(["odd defaultLook must be a boolean"]);
expect(
plugins.map((p: { id: string; defaultLook: boolean }) => [
p.id,
p.defaultLook,
]),
).toEqual([
["look", true],
["plain", false],
]);
});
it("carries it into the bundled index and through a re-pin", () => {
expect(
buildBundledIndex(
[
{ id: "look", onboarding: null, defaultLook: true },
{ id: "plain", onboarding: null, defaultLook: false },
],
["look", "plain"],
),
).toEqual({ version: 1, plugins: { look: { defaultLook: true } } });
const index = {
plugins: [
{
id: "look",
versions: [
{ version: "1.0.0", url: "https://x/l.tmxplug", sha256: SHA },
],
},
],
};
expect(
pinsFromIndex(index, null, [{ id: "look", defaultLook: true }])[0],
).toMatchObject({ id: "look", defaultLook: true });
});
it("copies a local build's theme files", () => {
const repo = tempDir();
fs.mkdirSync(path.join(repo, "dist"), { recursive: true });
fs.mkdirSync(path.join(repo, "themes"), { recursive: true });
fs.writeFileSync(path.join(repo, "dist", "backend.js"), "export {}");
fs.writeFileSync(path.join(repo, "themes", "paper.css"), ".paper{}");
fs.writeFileSync(
path.join(repo, "manifest.json"),
JSON.stringify({
id: "inks",
contributes: { themes: [{ id: "paper", css: "themes/paper.css" }] },
}),
);
const out = path.join(tempDir(), "inks");
copyLocalBuild(repo, out);
expect(fs.existsSync(path.join(out, "themes", "paper.css"))).toBe(true);
});
});
-2
View File
@@ -33,8 +33,6 @@ const OFFICIAL_IDS = [
"ai",
"alerts",
"automations",
"classic-layout",
"classic-themes",
"docker",
"file-manager",
"fleets",
+1 -409
View File
@@ -19,13 +19,8 @@ const SDK_UI = path.join(ROOT, "src", "ui", "plugin-host", "sdk-ui.ts");
const TSCONFIG = path.join(ROOT, "tsconfig.app.json");
const ALLOWED = [
"ACCENT_PRESET_COLORS",
"APP_VERSION",
"AccessRecord",
"ActionSlot",
"ActiveSessionInfo",
"AddButton",
"AdminSection",
"Alert",
"AlertDescription",
"AlertDialog",
@@ -40,23 +35,14 @@ const ALLOWED = [
"AlertDialogTitle",
"AlertDialogTrigger",
"AlertTitle",
"ApiKey",
"AuditLog",
"AuditLogFilters",
"AuthEnrollmentSections",
"AuthOverrideProtocol",
"BackButton",
"Badge",
"BarSeries",
"BarSeriesItem",
"BarSeriesProps",
"BrandingSettings",
"BrowserSignInDialog",
"Button",
"ButtonProps",
"CORE_HOST_DEFAULTS",
"CORE_NAMESPACE",
"CREDENTIAL_ROW_FIELD_KEYS",
"Card",
"CardAction",
"CardContent",
@@ -68,12 +54,6 @@ const ALLOWED = [
"CardTitle",
"Checkbox",
"ColumnCountStepper",
"Command",
"CommandEmpty",
"CommandGroup",
"CommandItem",
"CommandList",
"CommandPrimitive",
"ComponentSlot",
"ConfirmOptions",
"ConnectionGate",
@@ -86,26 +66,9 @@ const ALLOWED = [
"ConnectionStage",
"ConnectionStatus",
"ConnectionUnavailable",
"CreatedApiKey",
"Credential",
"CredentialDensity",
"CredentialPermissionLevel",
"CredentialRowFields",
"CredentialSidebarFilterState",
"CredentialSidebarPreferences",
"CredentialSortKey",
"CredentialTrayTrigger",
"CustomKeybinding",
"DASHBOARD_CARDS",
"DEFAULT_FOLDER_COLOR",
"DEFAULT_FOLDER_ICON",
"DashboardCardId",
"DataColumn",
"DataView",
"DefaultOverrides",
"DefaultsOnly",
"DesktopUpdateMode",
"DesktopUpdateState",
"Dialog",
"DialogClose",
"DialogContent",
@@ -118,7 +81,6 @@ const ALLOWED = [
"DialogTrigger",
"DocsLink",
"DocsLinkProps",
"DocsPage",
"DropdownMenu",
"DropdownMenuCheckboxItem",
"DropdownMenuContent",
@@ -136,17 +98,11 @@ const ALLOWED = [
"DropdownMenuTrigger",
"EditingWidth",
"EmptyState",
"ExportPayload",
"FOLDER_COLORS",
"FONT_SIZES",
"Facts",
"FakeSwitch",
"Field",
"FieldGroup",
"FieldPair",
"FolderAccessRule",
"FolderIconEl",
"FontSizeId",
"FormFooter",
"FullScreenAppPhase",
"FullScreenAppWrapper",
@@ -156,33 +112,11 @@ const ALLOWED = [
"GridLayout",
"GridSlot",
"GroupHeading",
"HexColorPicker",
"Host",
"HostActionDef",
"HostClickBehavior",
"HostData",
"HostDefaultBadge",
"HostDefaultField",
"HostDefaultsChange",
"HostDefaultsContext",
"HostDefaultsContextValue",
"HostDefaultsLevel",
"HostDefaultsTarget",
"HostDensity",
"HostFeatureFields",
"HostFeatureFieldsProps",
"HostFolder",
"HostGroupKey",
"HostKeyVerificationDialog",
"HostOnly",
"HostProtocolAuthInput",
"HostProtocolDef",
"HostProtocols",
"HostRowFields",
"HostSidebarFilterState",
"HostSidebarPreferences",
"HostTrayTrigger",
"IconPicker",
"InlineView",
"Input",
"Kbd",
@@ -190,14 +124,10 @@ const ALLOWED = [
"KbdSeparator",
"KeyCombo",
"KeybindingAction",
"KeybindingActionDef",
"KeybindingActionType",
"LANGUAGES",
"Label",
"LineChart",
"LineChartSeries",
"LinkServerDialog",
"LinkedAccountInfo",
"ListBadge",
"ListRow",
"ListRowAction",
@@ -205,12 +135,7 @@ const ALLOWED = [
"ListRowTone",
"LogContext",
"LogEntry",
"LookSettings",
"MANAGE_REQUEST_EVENT",
"MAX_PANES",
"MFAPromptMode",
"ManageMode",
"ManageRequest",
"ManagerCardError",
"ManagerCardShell",
"ManagerSearch",
@@ -218,14 +143,11 @@ const ALLOWED = [
"MetricCard",
"MiniStat",
"NumberField",
"OPEN_PLUGINS_EVENT",
"OpenTabRecord",
"PANEL",
"PanePrompt",
"PROMPT_BUTTON",
"PROMPT_DESTRUCTIVE_BUTTON",
"PROMPT_PRIMARY_BUTTON",
"PaletteItemDef",
"PanePrompt",
"PanelDensity",
"PanelList",
"PanelSearch",
@@ -233,35 +155,18 @@ const ALLOWED = [
"PanelViewMode",
"PassphraseDialog",
"PasswordInput",
"PermissionCatalogEntry",
"PluginComponent",
"PluginIcon",
"PluginSettingsErrors",
"PluginSettingsField",
"PluginSettingsValidationError",
"PluginSummary",
"PluginViewPlaceholder",
"Popover",
"PopoverAnchor",
"PopoverContent",
"PopoverTrigger",
"ProxyNode",
"ProxySettingsFields",
"RadialGauge",
"RadialGaugeProps",
"RailGroup",
"RailItemDef",
"RecentActivityItem",
"ReleaseInfo",
"Repeater",
"ResolvedHostDefault",
"RobustClipboardProvider",
"Role",
"SSHAuthDialog",
"SSHHost",
"SSHHostData",
"SSHHostWithStatus",
"SSH_AUTH_PROTOCOL",
"ScrollArea",
"ScrollBar",
"SecretField",
@@ -281,9 +186,6 @@ const ALLOWED = [
"SelectValue",
"Separator",
"SettingRow",
"SettingsFieldRow",
"SharePermissionLevel",
"ShareTarget",
"Sheet",
"SheetClose",
"SheetContent",
@@ -292,384 +194,74 @@ const ALLOWED = [
"SheetHeader",
"SheetTitle",
"SheetTrigger",
"ShellConfirmCapture",
"Skeleton",
"Slider",
"SortKey",
"Sparkline",
"SparklineProps",
"SplitLayoutMenu",
"SplitOpenTarget",
"SplitSummary",
"StatRow",
"StatusValue",
"SurfaceKind",
"SurfaceScope",
"Switch",
"SwitchRow",
"SyncBasicAuth",
"SyncConflictItem",
"SyncEntityInfo",
"SyncErrorItem",
"SyncProxyHeader",
"SyncStatus",
"TOTPDialog",
"Tab",
"TabShellCallbacks",
"TabSplitAction",
"TabStage",
"TabStrip",
"TabStripItem",
"TabType",
"TagInput",
"TextAreaField",
"TextField",
"Textarea",
"TextareaProps",
"ThemeId",
"TlsStatus",
"ToolsTab",
"Tooltip",
"TooltipContent",
"TooltipProvider",
"TooltipTrigger",
"UI_AREA_KEYS",
"UI_FONTS",
"UiAreaKey",
"UiFontId",
"UiPreset",
"UpdateChannelState",
"UptimeInfo",
"UsagePair",
"UserPreferences",
"UserRole",
"UserSecondFactorSummary",
"VersionBadge",
"VersionInfo",
"ViewToggle",
"WebSocketConnectionTarget",
"WidgetTitle",
"actionOrder",
"activityTarget",
"adminCreateUser",
"adminCreateUserCredential",
"adminCreateUserHost",
"adminDeleteUserCredential",
"adminDeleteUserHost",
"adminExportUserData",
"adminGetHostPassword",
"adminGetUserCredentials",
"adminGetUserHosts",
"adminResetUserPassword",
"adminUpdateUserCredential",
"adminUpdateUserHost",
"applyAccentColor",
"applyFontSize",
"applyPresetSideEffects",
"applyUiFont",
"assignRoleToUser",
"beginSplitDrag",
"buildExportPayload",
"buildOriginWsUrl",
"bulkImportSSHHosts",
"bulkUpdateSSHHosts",
"canConfigureUpdates",
"canJoinSplit",
"changeAppLanguage",
"changePassword",
"clearLocalAdaptivePreferences",
"cn",
"copyToClipboard",
"createApiKey",
"createCredential",
"createFrontendLogger",
"createQuickConnectHost",
"createRole",
"createSSHHost",
"defaultConnectAction",
"defaultCredentialRowFields",
"defaultCredentialSidebarPreferences",
"defaultHostRowFields",
"defaultHostSidebarPreferences",
"defaultValuesEqual",
"deleteAccount",
"deleteAllHostsInFolder",
"deleteApiKey",
"deleteCredential",
"deleteOpenTab",
"deleteRole",
"deleteSSHHost",
"deleteUser",
"deployCredentialToHost",
"desktopUpdates",
"docsUrl",
"duplicateCredential",
"enabledHostProtocols",
"endSplitDrag",
"exportAllSSHHosts",
"filterPaletteItems",
"findCoreHostDefault",
"findMatchingKeybinding",
"generateKeyPair",
"generatePublicKeyFromPrivate",
"getActiveSessions",
"getAdaptiveResourceBudget",
"getApiKeys",
"getAuditForwarding",
"getAuditLogActions",
"getAuditLogs",
"getBasePath",
"getBranding",
"getChannelReleases",
"getConnectedRemoteApi",
"getCredentialAccess",
"getCredentialDetails",
"getCredentialSidebarPreferences",
"getCredentials",
"getDatabaseHealth",
"getDatabaseTransferUrl",
"getDefaultConnectionTab",
"getDeveloperMode",
"getDeviceId",
"getErrorMessage",
"getExternalAutoProvision",
"getFolderAccess",
"getFolderDefaultsId",
"getHostAccess",
"getHostAuthOverride",
"getHostDefaultsLevel",
"getHostEditorSection",
"getHostPassword",
"getHostSidebarPreferences",
"getHostTags",
"getLiveHostStatus",
"getLogLevel",
"getNotificationPrivateEndpoints",
"getPanel",
"getPasswordLoginAllowed",
"getPasswordResetAllowed",
"getPermissionsCatalog",
"getPluginAdminSettings",
"getPluginHostSettings",
"getPluginUserSettings",
"getPlugins",
"getPollingEnvironmentMultiplier",
"getPreferredHostAction",
"getRecentActivity",
"getRegisteredDashboardCard",
"getRegistrationAllowed",
"getRoles",
"getSSHFolders",
"getSSHHosts",
"getSecondFactorAfterExternalLogin",
"getSessionTimeout",
"getSessionTimeoutMinutes",
"getSessions",
"getStatusCheckSettings",
"getStatusClasses",
"getSyncConflicts",
"getSyncErrors",
"getTabType",
"getTlsStatus",
"getUpdateChannel",
"getUptime",
"getUserInfo",
"getUserList",
"getUserPreferences",
"getUserRoles",
"getUserSecondFactors",
"getVersionInfo",
"globalShortcutHandler",
"groupRailItems",
"hasVisibleFields",
"hostActionsFor",
"hostBadgesFor",
"hostKey",
"hostMenuItemsFor",
"hostProtocolFlags",
"hydrateLocalSharedHostAuth",
"importSSHConfigHosts",
"invalidateHostsAndStatusCaches",
"invalidateServerStatusCache",
"isCoreRailView",
"isElectron",
"isFieldShown",
"isMacPlatform",
"isPersistentTabType",
"isPrerelease",
"isSessionTabType",
"isSplitDragging",
"isSplitTab",
"isTabJumpHotkey",
"linkExternalToPasswordAccount",
"linkedServerUrl",
"listHostActions",
"listHostProtocols",
"loadPaletteGroup",
"logoutUser",
"makeHostSshSubTabs",
"makeHostTabs",
"makeUserAdmin",
"mapCredentials",
"markAdaptiveResourceUsed",
"markTabSurfaceUsed",
"maskSecrets",
"moveRailItem",
"moveSplitDrag",
"normalizeLanguageCode",
"notifySyncChanged",
"openInSplit",
"paletteEntriesFor",
"parseCustomKeybindings",
"parseServerTime",
"pluginKey",
"pluginName",
"pluginSecretKeys",
"pluginWsUrl",
"preloadTabSurface",
"previewHostDefaultsLevel",
"promotableIds",
"protocolEnabled",
"protocolPort",
"quickConnectTargets",
"railItemLabel",
"readFromClipboard",
"recordHostActionPreference",
"refreshSyncStatus",
"registerLayout",
"registerTabCloseGuard",
"releaseUrlFrom",
"rem",
"removeAdminStatus",
"removeRoleFromUser",
"renameCredentialFolder",
"renameFolder",
"reorderCredentials",
"reorderFolders",
"reorderSSHHosts",
"reportBetaFeedbackUrl",
"reportCoreIssueUrl",
"requestFromLegacyEvent",
"requestManage",
"resetHostToDefaults",
"resetRecentActivity",
"resetUserSecondFactors",
"resolveConnectionOrigin",
"resolveHostDefaults",
"resolveHostTabType",
"resolveRemoteHostId",
"retrySyncErrors",
"revokeAllUserSessions",
"revokeCredentialAccess",
"revokeFolderAccess",
"revokeHostAccess",
"revokeSession",
"rightDockableIds",
"roleLabel",
"runAdaptiveBackgroundTask",
"runAdaptivePolling",
"runHostAction",
"runVisibleInterval",
"sanitizeCredentialSidebarPreferences",
"sanitizeHostSidebarPreferences",
"saveCredentialSidebarPreferences",
"saveHostDefaultsLevel",
"saveHostSidebarPreferences",
"saveHostTags",
"saveUserPreferences",
"setAllPluginChannels",
"setDeveloperMode",
"setHostAuthOverride",
"setNotificationPrivateEndpoints",
"setPluginOptions",
"setSyncStatus",
"setUpdateChannel",
"settleSyncConflict",
"shareCredential",
"shareFolder",
"shareHost",
"shell",
"splitDefaultKey",
"sshHostToHost",
"syncNow",
"tabIcon",
"tabJumpHotkeyKeys",
"takePendingManageRequest",
"unlinkExternalFromPasswordAccount",
"unlinkServer",
"updateAuditForwarding",
"updateBranding",
"updateCredential",
"updateExternalAutoProvision",
"updateFolderMetadata",
"updateHostAccess",
"updateLogLevel",
"updatePasswordLoginAllowed",
"updatePasswordResetAllowed",
"updatePluginAdminSettings",
"updatePluginHostSettings",
"updatePluginUserSettings",
"updateRegistrationAllowed",
"updateRole",
"updateSSHHost",
"updateSecondFactorAfterExternalLogin",
"updateSessionTimeout",
"updateStatusCheckSettings",
"updateSyncSettings",
"uploadTlsCertificate",
"usageBarColor",
"usageColor",
"useActionSlot",
"useAdaptivePolling",
"useAppTheme",
"useAreaPreferences",
"useBranding",
"useChooseTheme",
"useConfirm",
"useConnectionGate",
"useConnectionLog",
"useDesktopUpdate",
"useEditorDefaultKeys",
"useHostActions",
"useHostBadges",
"useHostContextMenuItems",
"useHostEditorSections",
"useHostProtocols",
"useHostStatus",
"useInstalledPlugins",
"useIsDefaultsEditor",
"useIsMobile",
"useKeybindingActions",
"useKeybindingDefaults",
"useOptionalConnectionLog",
"usePageVisibleInterval",
"usePaletteEntries",
"usePaletteGroups",
"usePanels",
"usePermissions",
"usePluginDocsUrl",
"usePluginHostSections",
"usePluginStore",
"useRailItems",
"useRegisteredDashboardCards",
"useServerStatus",
"useServerStatusMeta",
"useShellController",
"useSplitTargets",
"useSshAuthEditors",
"useSshAuthProviders",
"useStatusColorScheme",
"useSurfaceKind",
"useSyncStatus",
"useTabHandlesVersion",
"useTabs",
"useTabsSafe",
"useTheme",
"useThemeOptions",
"useUiPreference",
"useUiPreferencesContext",
"waitForDefaultsJob",
"withProtocolSettings",
];
/** Every name a module exports, resolved the way the compiler sees it. */
-2
View File
@@ -42,7 +42,6 @@ describe("serviceEnv", () => {
DATABASE_URL: "postgres://termix:termix@127.0.0.1:55432/termix",
GUACD_HOST: "127.0.0.1",
GUACD_PORT: "4822",
REMOTE_DESKTOP_ENGINE: "guacd",
});
});
@@ -54,7 +53,6 @@ describe("serviceEnv", () => {
DATABASE_URL: "mysql://root:termix@termix-dev-mysql:3306/termix",
GUACD_HOST: "termix-dev-guacd",
GUACD_PORT: "4822",
REMOTE_DESKTOP_ENGINE: "guacd",
});
});
});
+1 -2
View File
@@ -14,8 +14,7 @@
* mounted. A plugin change rebuilds it and restarts the container. Core
* changes wait for "r", since an image build is slow.
*
* --db postgres|mysql and --guacd run those in Docker next to it; --guacd also
* switches remote desktop from its built-in engine to that guacd.
* --db postgres|mysql and --guacd run those in Docker next to it.
*/
import fs from "node:fs";
-1
View File
@@ -53,7 +53,6 @@ function start() {
isDev: false,
appRoot: "/app",
BACKEND_STDERR_TAIL_LIMIT: 4096,
backendPort: 30001,
handleBackendRequest,
setTimeout,
clearTimeout,
+1 -1
View File
@@ -38,7 +38,7 @@ function buildTable(version, mobileVersion, mobileTag) {
`| Architecture | Windows | Linux | Mac | Android | iOS |`,
`|------------------|------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------|---------------------------------------------|-----------------------------------|`,
`| **x86-64 (64-bit)** | [EXE](${win("termix_windows_x64_nsis.exe")}) · [MSI](${win("termix_windows_x64_msi.msi")}) · [Portable](${win("termix_windows_x64_portable.zip")}) | [AppImage](${linux("termix_linux_x64_appimage.AppImage")}) · [DEB](${linux("termix_linux_x64_deb.deb")}) · [Portable](${linux("termix_linux_x64_portable.tar.gz")}) | [DMG](${mac("termix_macos_x64_dmg.dmg")}) | - | - |`,
`| **AArch64 (ARM64)** | [EXE](${win("termix_windows_arm64_nsis.exe")}) · [MSI](${win("termix_windows_arm64_msi.msi")}) · [Portable](${win("termix_windows_arm64_portable.zip")}) | [AppImage](${linux("termix_linux_arm64_appimage.AppImage")}) · [DEB](${linux("termix_linux_arm64_deb.deb")}) · [Portable](${linux("termix_linux_arm64_portable.tar.gz")}) | [DMG](${mac("termix_macos_arm64_dmg.dmg")}) | [APK (${mobileVersion})](${mobileBase}/termix_android.apk) | [IPA (${mobileVersion})](${mobileBase}/termix_ios.ipa) |`,
`| **AArch64 (ARM64)** | - | [AppImage](${linux("termix_linux_arm64_appimage.AppImage")}) · [DEB](${linux("termix_linux_arm64_deb.deb")}) · [Portable](${linux("termix_linux_arm64_portable.tar.gz")}) | [DMG](${mac("termix_macos_arm64_dmg.dmg")}) | [APK (${mobileVersion})](${mobileBase}/termix_android.apk) | [IPA (${mobileVersion})](${mobileBase}/termix_ios.ipa) |`,
`| **ARMv7 (32-bit)** | - | [AppImage](${linux("termix_linux_armv7l_appimage.AppImage")}) · [DEB](${linux("termix_linux_armv7l_deb.deb")}) · [Portable](${linux("termix_linux_armv7l_portable.tar.gz")}) | - | - | - |`,
`| **x86-32 (32-bit)** | [EXE](${win("termix_windows_ia32_nsis.exe")}) · [MSI](${win("termix_windows_ia32_msi.msi")}) · [Portable](${win("termix_windows_ia32_portable.zip")}) | - | - | - | - |`,
`| **Universal** | [Chocolatey](https://docs.termix.site/install/apps/windows) | [Flatpak](https://docs.termix.site/install/apps/linux) | [DMG](${mac("termix_macos_universal_dmg.dmg")}) · [App Store](https://apps.apple.com/us/app/termix-ssh-companion/id6752672071) · [Homebrew](https://docs.termix.site/install/apps/macos) | - | - |`,
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env node
/**
* Builds src/ui/settings/settings-search-index.ts: the i18n keys each core
* settings section shows, so the Settings search can match on any label in
* a section without mounting it. Run it after changing a settings section.
*
* --check fails when the committed file is out of date.
*/
const fs = require("node:fs");
const path = require("node:path");
const ROOT = path.resolve(__dirname, "..");
const SIDEBAR = path.join(ROOT, "src", "ui", "sidebar");
const OUT = path.join(
ROOT,
"src",
"ui",
"settings",
"settings-search-index.ts",
);
function flatKeys(obj, prefix = "") {
return Object.entries(obj).flatMap(([key, value]) =>
typeof value === "object"
? flatKeys(value, `${prefix}${key}.`)
: [`${prefix}${key}`],
);
}
const EN_KEYS = new Set(
flatKeys(require(path.join(ROOT, "src", "ui", "locales", "en.json"))),
);
// t("x", { count }) reads x_one or x_other, so index the plural key.
function realKey(key) {
return EN_KEYS.has(key) || !EN_KEYS.has(`${key}_other`)
? key
: `${key}_other`;
}
function keysIn(text) {
return [
...new Set(
[...text.matchAll(/\bt\(\s*"([\w.-]+)"/g)].map((m) => realKey(m[1])),
),
];
}
function read(file) {
return fs.readFileSync(path.join(SIDEBAR, file), "utf8");
}
/** The body of one exported or local function, by name. */
function functionBody(source, name) {
const start = source.search(new RegExp(`function ${name}\\b`));
if (start < 0) throw new Error(`No function ${name}`);
const next = source.slice(start + 1).search(/\n(export )?function \w+/);
return next < 0 ? source.slice(start) : source.slice(start, start + 1 + next);
}
/** Profile sections, split on their AccordionSection ids. */
function profileSections() {
const source = read("UserProfilePanel.tsx");
const lines = source.split("\n");
const starts = [];
lines.forEach((line, i) => {
if (/^\s{6}<AccordionSection\b/.test(line)) starts.push(i);
});
const out = {};
starts.forEach((start, k) => {
const end = k + 1 < starts.length ? starts[k + 1] : lines.length;
const chunk = lines.slice(start, end).join("\n");
const id = (chunk.match(/\bid="([^"]+)"/) || [])[1];
if (id) out[id] = keysIn(chunk);
});
out.interface = [
...new Set([
...(out.interface ?? []),
...keysIn(read("InterfacePresetSettings.tsx")),
]),
];
out.security = [
...new Set([
...(out.security ?? []),
...keysIn(read("AuthEnrollmentSections.tsx")),
...keysIn(functionBody(source, "PasswordChangeSection")),
]),
];
out["api-keys"] = [
...new Set([
...(out["api-keys"] ?? []),
...keysIn(functionBody(source, "NewApiKeyDialog")),
]),
];
out.keybindings = keysIn(
fs.readFileSync(
path.join(ROOT, "src", "ui", "settings", "KeybindingsSettings.tsx"),
"utf8",
),
);
return out;
}
function adminSections() {
const settings = read("AdminSettingsSections.tsx");
const management = read("AdminManagementSections.tsx");
return {
"admin-general": [
...new Set([
...keysIn(functionBody(settings, "AdminGeneralSettingsSection")),
...keysIn(read("AdminPluginDeveloperMode.tsx")),
]),
],
"admin-users": [
...new Set([
...keysIn(functionBody(management, "AdminUsersSection")),
...keysIn(read("AdminUserDialogs.tsx")),
]),
],
"admin-sessions": keysIn(functionBody(management, "AdminSessionsSection")),
"admin-roles": keysIn(functionBody(management, "AdminRolesSection")),
"admin-host-defaults": [
...new Set([
...keysIn(functionBody(settings, "AdminHostDefaultsSection")),
...keysIn(read("AdminHostTags.tsx")),
]),
],
"admin-branding": keysIn(read("AdminBrandingSection.tsx")),
"admin-database": keysIn(functionBody(settings, "AdminDatabaseSection")),
"admin-ssl": keysIn(functionBody(settings, "AdminSSLSection")),
"admin-api-keys": keysIn(read("AdminApiKeysSection.tsx")),
"admin-audit-log": keysIn(read("AdminAuditLogSection.tsx")),
updates: [
...new Set(
["UpdatesSettings.tsx", "DesktopUpdatesCard.tsx"].flatMap((file) =>
keysIn(
fs.readFileSync(
path.join(ROOT, "src", "ui", "settings", file),
"utf8",
),
),
),
),
],
};
}
function render() {
const index = { ...profileSections(), ...adminSections() };
const body = Object.entries(index)
.sort(([a], [b]) => a.localeCompare(b))
.map(
([id, keys]) =>
` ${JSON.stringify(id)}: [\n${keys
.sort()
.map((key) => ` ${JSON.stringify(key)},`)
.join("\n")}\n ],`,
)
.join("\n");
return `// Generated by scripts/generate-settings-search-index.cjs. Do not edit.\n\n/** The i18n keys each core settings section shows, for search. */\nexport const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {\n${body}\n};\n`;
}
const next = render();
if (process.argv.includes("--check")) {
const current = fs.existsSync(OUT) ? fs.readFileSync(OUT, "utf8") : "";
if (current !== next) {
console.error(
"src/ui/settings/settings-search-index.ts is out of date. Run: node scripts/generate-settings-search-index.cjs",
);
process.exit(1);
}
} else {
fs.writeFileSync(OUT, next);
console.log(`Wrote ${path.relative(ROOT, OUT)}`);
}
+3 -38
View File
@@ -15,11 +15,6 @@
* enabledByEnv counts the plugin as recommended when any of those
* environment variables is set, for a server configured before first boot.
* Plugins without it start unchecked.
*
* "defaultLook": true marks the plugin whose layout (and that layout's
* theme) a fresh install shows. Core finds the default look this way, so it
* never names a plugin. Such a plugin always starts enabled and cannot be
* uninstalled, only swapped for another look.
*/
const crypto = require("node:crypto");
@@ -115,12 +110,6 @@ function parseBundledPlugins(raw) {
}
const before = problems.length;
const onboarding = parseOnboarding(entry.onboarding, entry.id, problems);
if (
entry.defaultLook !== undefined &&
typeof entry.defaultLook !== "boolean"
) {
problems.push(`${entry.id} defaultLook must be a boolean`);
}
if (problems.length > before) continue;
plugins.push({
id: entry.id,
@@ -129,7 +118,6 @@ function parseBundledPlugins(raw) {
path: hasPath ? entry.path : null,
sha256: entry.sha256,
onboarding,
defaultLook: entry.defaultLook === true,
});
} else {
problems.push(`${entry.id} has an unknown source "${entry.source}"`);
@@ -230,12 +218,8 @@ function buildBundledIndex(plugins, stagedIds) {
const staged = new Set(stagedIds);
const out = {};
for (const plugin of plugins) {
if (!staged.has(plugin.id)) continue;
if (!plugin.onboarding && !plugin.defaultLook) continue;
out[plugin.id] = {
...(plugin.onboarding ? { onboarding: plugin.onboarding } : {}),
...(plugin.defaultLook ? { defaultLook: true } : {}),
};
if (!staged.has(plugin.id) || !plugin.onboarding) continue;
out[plugin.id] = { onboarding: plugin.onboarding };
}
return { version: 1, plugins: out };
}
@@ -254,14 +238,12 @@ function pinsFromIndex(index, ids, current = []) {
const latest = byId.get(id)?.versions?.[0];
if (!latest) throw new Error(`${id} is not in the registry index`);
const onboarding = currentById.get(id)?.onboarding;
const defaultLook = currentById.get(id)?.defaultLook === true;
return {
id,
source: "tmxplug",
url: latest.url,
sha256: latest.sha256,
...(onboarding ? { onboarding } : {}),
...(defaultLook ? { defaultLook } : {}),
};
});
}
@@ -375,26 +357,9 @@ function rebuildLocalPlugins(dir, ids, run) {
}
/** Copies a local plugin build into the staging folder. */
/** Theme CSS files the repo's manifest declares, which ship with it too. */
function localThemeFiles(repo) {
try {
const manifest = JSON.parse(
fs.readFileSync(path.join(repo, "manifest.json"), "utf8"),
);
const themes = manifest?.contributes?.themes;
return Array.isArray(themes)
? themes
.map((theme) => theme?.css)
.filter((css) => typeof css === "string")
: [];
} catch {
return [];
}
}
function copyLocalBuild(repo, destination) {
fs.mkdirSync(destination, { recursive: true });
for (const entry of new Set([...LOCAL_FILES, ...localThemeFiles(repo)])) {
for (const entry of LOCAL_FILES) {
const from = path.join(repo, entry);
if (fs.existsSync(from)) {
fs.cpSync(from, path.join(destination, entry), { recursive: true });
-2
View File
@@ -85,8 +85,6 @@ export function serviceEnv(options, inDocker) {
const guacd = SERVICES.guacd;
env.GUACD_HOST = inDocker ? guacd.container : "127.0.0.1";
env.GUACD_PORT = String(inDocker ? guacd.port : guacd.hostPort);
// Remote desktop runs built in unless told to use guacd.
env.REMOTE_DESKTOP_ENGINE = "guacd";
}
return env;
}
+1 -1
View File
@@ -125,7 +125,7 @@ Options
--plugins <dir> Plugin repos to build and watch (default ../Plugins)
--no-local Use only the plugins pinned in docker/bundled-plugins.json
--db <engine> sqlite (default), postgres or mysql. Postgres and MySQL run in Docker
--guacd Run guacd in Docker and use it instead of the built-in remote desktop
--guacd Run guacd in Docker for remote desktop
--fresh Start from empty data (first run, onboarding)
--data <dir> Data folder for the backend (default db/data, db/data-desktop for
electron, plus -<engine> for postgres or mysql)
-36
View File
@@ -35,42 +35,6 @@ function fixturePlugin(): string {
}
describe("termix-plugin build", () => {
it("builds a theme only plugin with an empty backend and no source", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
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: "theme-fixture",
capabilities: ["ui:theme"],
contributes: { themes: [{ id: "paper" }] },
}),
);
await build({ cwd: dir });
expect(
fs.readFileSync(path.join(dir, "dist", "backend.js"), "utf8"),
).toContain("export function activate()");
expect(fs.existsSync(path.join(dir, "dist", "frontend.js"))).toBe(false);
});
it("still needs a backend entry once a plugin holds more than themes", async () => {
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: "mixed-fixture",
capabilities: ["ui:theme", "ui:layout"],
contributes: { themes: [{ id: "paper" }] },
}),
);
await expect(build({ cwd: dir })).rejects.toThrow("no backend entry");
});
it("marks dist as ESM so Node loads backend.js without reparsing", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const dir = fixturePlugin();
-30
View File
@@ -46,8 +46,6 @@ function fixturePlugin(manifest: Record<string, unknown> = {}): string {
writeFile(dir, "migrations/mysql/0001_init.sql", "SELECT 1;\n");
writeFile(dir, "README.md", "# Fixture\n");
writeFile(dir, "icon.svg", "<svg/>\n");
writeFile(dir, "LICENSE", "Apache-2.0\n");
writeFile(dir, "NOTICE", "Third-party notices\n");
writeFile(dir, "src/backend/index.ts", "left out of the archive\n");
return dir;
}
@@ -93,8 +91,6 @@ describe("termix-plugin pack", () => {
const names = await entries(file);
expect(names).toEqual([
"LICENSE",
"NOTICE",
"README.md",
"dist/backend.js",
"dist/frontend.js",
@@ -142,29 +138,3 @@ describe("termix-plugin pack", () => {
expect(fs.readdirSync(out)).toEqual([]);
});
});
describe("termix-plugin pack themes", () => {
it("ships the CSS files the manifest's themes declare", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
const dir = fixturePlugin({
capabilities: ["ui:theme"],
contributes: {
themes: [
{
id: "paper",
label: "Paper",
mode: "light",
css: "styles/paper.css",
},
],
},
});
writeFile(dir, "styles/paper.css", ".paper { --background: #fff; }\n");
writeFile(dir, "styles/draft.css", ".draft {}\n");
const file = await pack({ cwd: dir, args: ["--out", tempDir("t-")] });
const names = await entries(file);
expect(names).toContain("styles/paper.css");
expect(names).not.toContain("styles/draft.css");
});
});
-95
View File
@@ -49,98 +49,3 @@ describe("termix-plugin validate migrations", () => {
expect(output).not.toMatch(/migrations\/\w+ is missing/);
});
});
describe("termix-plugin validate themes", () => {
function themed(css: string | null) {
const root = plugin([]);
fs.writeFileSync(
path.join(root, "manifest.json"),
JSON.stringify({
id: "fixture",
version: "1.0.0",
capabilities: ["ui:theme"],
contributes: {
themes: [
{
id: "paper",
label: "Paper",
mode: "light",
css: "themes/paper.css",
},
],
},
}),
);
if (css !== null) {
fs.mkdirSync(path.join(root, "themes"));
fs.writeFileSync(path.join(root, "themes", "paper.css"), css);
}
return root;
}
it("reports a missing theme file", async () => {
expect(await problems(themed(null))).toContain(
"theme paper: themes/paper.css does not exist",
);
});
it("reports theme CSS that reaches outside or leaves its class", async () => {
const output = await problems(
themed('@import "x.css"; body { color: red }'),
);
expect(output).toContain("theme paper: @import is not allowed in a theme");
expect(output).toContain(
'theme paper: Selector "body" must start with .paper',
);
});
it("says nothing about CSS that is fine", async () => {
const output = await problems(themed(".paper { --background: #fff; }"));
expect(output).not.toContain("theme paper");
});
});
describe("termix-plugin validate layouts and capabilities", () => {
function withManifest(extra: Record<string, unknown>) {
const root = plugin([]);
fs.writeFileSync(
path.join(root, "manifest.json"),
JSON.stringify({ id: "fixture", version: "1.0.0", ...extra }),
);
return root;
}
it("flags ui:theme with no themes and ui:layout with no layouts", async () => {
const output = await problems(
withManifest({ capabilities: ["ui:theme", "ui:layout"] }),
);
expect(output).toContain(
'"ui:theme" is declared but contributes.themes is empty',
);
expect(output).toContain(
'"ui:layout" is declared but contributes.layouts is empty',
);
});
it("flags a layout plugin with no frontend source", async () => {
const output = await problems(
withManifest({
capabilities: ["ui:layout"],
contributes: { layouts: [{ id: "wide", label: "Wide" }] },
}),
);
expect(output).toContain("contributes.layouts needs a frontend");
});
it("accepts a layout plugin that has a frontend entry", async () => {
const root = withManifest({
capabilities: ["ui:layout"],
contributes: { layouts: [{ id: "wide", label: "Wide" }] },
});
fs.mkdirSync(path.join(root, "src", "frontend"), { recursive: true });
fs.writeFileSync(path.join(root, "src", "frontend", "index.tsx"), "");
const output = await problems(root);
expect(output).not.toContain("contributes.layouts needs a frontend");
expect(output).not.toContain("is declared but");
});
});
-54
View File
@@ -1,54 +0,0 @@
#!/usr/bin/env node
/** Stamp the cache version and static-build base path.
* Docker keeps the base-path placeholder for entrypoint.sh to fill at startup.
*/
const fs = require("node:fs");
const path = require("node:path");
const ROOT = path.resolve(__dirname, "..");
const SW_PATH = path.join(ROOT, "dist", "sw.js");
const VERSION_PLACEHOLDER = "__TERMIX_CACHE_VERSION__";
const BASE_PATH_PLACEHOLDER = "__TERMIX_SW_BASE_PATH__";
const pkg = JSON.parse(
fs.readFileSync(path.join(ROOT, "package.json"), "utf8"),
);
const cacheVersion = String(pkg.version || "0.0.0").replace(/\./g, "-");
const rawBasePath = process.env.VITE_BASE_PATH || "./";
// "./" is what vite emits for a root deployment; cache URLs stay root-relative.
const basePath =
rawBasePath === "./"
? ""
: `/${rawBasePath.replace(/^\/+/, "").replace(/\/+$/, "")}`;
if (!fs.existsSync(SW_PATH)) {
console.error(`[stamp-sw] dist/sw.js not found - run vite build first`);
process.exit(1);
}
let sw = fs.readFileSync(SW_PATH, "utf8");
const rel = path.relative(ROOT, SW_PATH);
const replacements = [
[VERSION_PLACEHOLDER, cacheVersion],
...(process.env.TERMIX_RUNTIME_BASE_PATH === "true"
? []
: [[BASE_PATH_PLACEHOLDER, basePath]]),
];
let changed = false;
for (const [placeholder, value] of replacements) {
if (sw.includes(placeholder)) {
sw = sw.split(placeholder).join(value);
changed = true;
console.log(
`[stamp-sw] ${rel}: ${placeholder} -> ${JSON.stringify(value)}`,
);
}
}
if (changed) {
fs.writeFileSync(SW_PATH, sw);
}
-59
View File
@@ -1,59 +0,0 @@
import { execFileSync } from "node:child_process";
import {
mkdtempSync,
mkdirSync,
readFileSync,
writeFileSync,
rmSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, it } from "vitest";
it.each([
[false, "", ""],
[false, "/termix/", "/termix"],
[true, "", "__TERMIX_SW_BASE_PATH__"],
])(
"stamps service worker paths (runtime=%s, base=%s)",
(runtime, base, expected) => {
const dir = mkdtempSync(path.join(tmpdir(), "termix-sw-"));
try {
mkdirSync(path.join(dir, "scripts"));
mkdirSync(path.join(dir, "dist"));
writeFileSync(
path.join(dir, "scripts/stamp-sw-build-info.cjs"),
readFileSync("scripts/stamp-sw-build-info.cjs"),
);
writeFileSync(
path.join(dir, "package.json"),
JSON.stringify({ version: "26.10.2" }),
);
writeFileSync(path.join(dir, "dist/sw.js"), readFileSync("public/sw.js"));
execFileSync(
process.execPath,
[path.join(dir, "scripts/stamp-sw-build-info.cjs")],
{
env: {
...process.env,
VITE_BASE_PATH: base,
TERMIX_RUNTIME_BASE_PATH: String(runtime),
},
},
);
const sw = readFileSync(path.join(dir, "dist/sw.js"), "utf8");
expect(sw).toContain('"26-10-2"');
expect(sw).toContain(`const BASE_PATH = "${expected}";`);
if (runtime) {
expect(sw.replaceAll("__TERMIX_SW_BASE_PATH__", "/termix")).toContain(
'const BASE_PATH = "/termix";',
);
expect(readFileSync("docker/Dockerfile", "utf8")).toContain(
"TERMIX_RUNTIME_BASE_PATH=true",
);
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
);
+1 -8
View File
@@ -22,7 +22,6 @@ import sshAuthRoutes from "./routes/ssh-auth-routes.js";
import rbacRoutes from "./routes/rbac.js";
import openTabsRoutes from "./routes/open-tabs.js";
import userPreferencesRoutes from "./routes/user-preferences.js";
import lookRoutes from "./routes/look.js";
import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
import uiPreferencesRoutes from "./routes/ui-preferences.js";
@@ -39,7 +38,6 @@ import { attachPluginWebSockets } from "../plugins/ws.js";
import pluginRoutes from "./routes/plugins.js";
import { createPluginAssetsRouter } from "../plugins/assets.js";
import { getPluginRuntime } from "../plugins/index.js";
import { pluginEvents, TOPICS } from "../plugins/events.js";
import { createCorsMiddleware } from "../utils/cors-config.js";
import { createCompressionMiddleware } from "../utils/compression-config.js";
import fs from "fs";
@@ -1390,9 +1388,6 @@ app.post(
}
}
await applyDefaultsAfterHostWrites(importedHostIds);
for (const hostId of importedHostIds) {
pluginEvents.emit(TOPICS.hostCreated, { hostId, userId });
}
} catch {
apiLogger.info("ssh_data table not found in import file, skipping");
}
@@ -1617,7 +1612,6 @@ app.use("/credentials", credentialsRoutes);
app.use("/ssh-auth", sshAuthRoutes);
app.use("/rbac", rbacRoutes);
app.use("/open-tabs", openTabsRoutes);
app.use("/user-preferences/look", lookRoutes);
app.use("/user-preferences", userPreferencesRoutes);
app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
@@ -1722,8 +1716,7 @@ app.use(
},
);
// The desktop app moves its embedded backend off 30001 when that is taken.
const HTTP_PORT = Number(process.env.EMBEDDED_BACKEND_PORT) || 30001;
const HTTP_PORT = 30001;
async function initializeSecurity() {
try {
-2
View File
@@ -985,8 +985,6 @@ const migrateSchema = () => {
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT");
addColumnIfNotExists("user_preferences", "custom_keybindings", "TEXT");
addColumnIfNotExists("user_preferences", "layout_id", "TEXT");
addColumnIfNotExists("user_preferences", "theme_id", "TEXT");
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
-2
View File
@@ -709,8 +709,6 @@ export const userPreferences = mysqlTable("user_preferences", {
compactHostView: boolean("compact_host_view"),
statusColorScheme: text("status_color_scheme"),
customKeybindings: text("custom_keybindings"),
layoutId: text("layout_id"),
themeId: text("theme_id"),
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
-2
View File
@@ -709,8 +709,6 @@ export const userPreferences = pgTable("user_preferences", {
compactHostView: boolean("compact_host_view"),
statusColorScheme: text("status_color_scheme"),
customKeybindings: text("custom_keybindings"),
layoutId: text("layout_id"),
themeId: text("theme_id"),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
-2
View File
@@ -703,8 +703,6 @@ export const userPreferences = sqliteTable("user_preferences", {
compactHostView: integer("compact_host_view", { mode: "boolean" }),
statusColorScheme: text("status_color_scheme"),
customKeybindings: text("custom_keybindings"),
layoutId: text("layout_id"),
themeId: text("theme_id"),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
+35 -55
View File
@@ -140,14 +140,7 @@ export function parseSSHConfig(content: string): SSHConfigHost[] {
* Tells status checks and other pollers about hosts an import wrote, the way
* a single host create or update does.
*/
export function notifyImportedHosts(
userId: string,
hostIds: number[],
createdHostIds: number[],
): void {
for (const hostId of createdHostIds) {
pluginEvents.emit(TOPICS.hostCreated, { hostId, userId });
}
export function notifyImportedHosts(userId: string, hostIds: number[]): void {
for (const hostId of hostIds) {
pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId });
}
@@ -575,26 +568,26 @@ export function registerHostBulkRoutes(
);
}
// Loaded for both modes: overwrite updates a match, skip leaves it alone.
let existingHostMap: Map<string, { id: number }> | undefined;
const hostRepository = createCurrentHostRepository();
try {
const allHosts =
await createCurrentHostResolutionRepository().findHostsByUserId(
userId,
);
existingHostMap = new Map();
for (const h of allHosts) {
const key = `${h.ip}:${h.port}:${h.username}`;
existingHostMap.set(key, { id: h.id as number });
if (overwrite) {
try {
const allHosts =
await createCurrentHostResolutionRepository().findHostsByUserId(
userId,
);
existingHostMap = new Map();
for (const h of allHosts) {
const key = `${h.ip}:${h.port}:${h.username}`;
existingHostMap.set(key, { id: h.id as number });
}
} catch {
existingHostMap = undefined;
}
} catch {
existingHostMap = undefined;
}
const knownAuthTypes = listKnownAuthTypes();
const writtenHostIds: number[] = [];
const createdHostIds: number[] = [];
for (const { host: hostData, index: i, exportId } of orderedHosts) {
try {
const effectiveConnectionType = hostData.connectionType || "ssh";
@@ -631,15 +624,6 @@ export function registerHostBulkRoutes(
continue;
}
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
const existing = existingHostMap?.get(lookupKey);
if (existing && !overwrite) {
// Hosts that jump through this one still resolve to the kept host.
if (exportId !== undefined) importedIds.set(exportId, existing.id);
results.skipped++;
continue;
}
if (
effectiveConnectionType === "ssh" &&
hostData.authType &&
@@ -785,6 +769,8 @@ export function registerHostBulkRoutes(
hostData.authType === "key" ? hostData.keyType || "auto" : null;
}
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
const existing = existingHostMap?.get(lookupKey);
await withHostDefaults(
userId,
existing?.id ?? null,
@@ -811,7 +797,6 @@ export function registerHostBulkRoutes(
);
if (exportId !== undefined) importedIds.set(exportId, saved.id);
savedHostId = saved.id;
createdHostIds.push(saved.id);
results.success++;
}
@@ -843,10 +828,10 @@ export function registerHostBulkRoutes(
}
await applyDefaultsAfterHostWrites(writtenHostIds);
notifyImportedHosts(userId, writtenHostIds, createdHostIds);
notifyImportedHosts(userId, writtenHostIds);
res.json({
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`,
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
success: results.success,
updated: results.updated,
skipped: results.skipped,
@@ -946,22 +931,23 @@ export function registerHostBulkRoutes(
let existingHostMap: Map<string, { id: number }> | undefined;
const hostRepository = createCurrentHostRepository();
try {
const allHosts =
await createCurrentHostResolutionRepository().findHostsByUserId(
userId,
);
existingHostMap = new Map();
for (const h of allHosts) {
const key = `${h.ip}:${h.port}:${h.username}`;
existingHostMap.set(key, { id: h.id as number });
if (overwrite) {
try {
const allHosts =
await createCurrentHostResolutionRepository().findHostsByUserId(
userId,
);
existingHostMap = new Map();
for (const h of allHosts) {
const key = `${h.ip}:${h.port}:${h.username}`;
existingHostMap.set(key, { id: h.id as number });
}
} catch {
existingHostMap = undefined;
}
} catch {
existingHostMap = undefined;
}
const writtenHostIds: number[] = [];
const createdHostIds: number[] = [];
for (let i = 0; i < hostsToImport.length; i++) {
const hostData = normalizeImportedHost(
hostsToImport[i] as Record<string, unknown>,
@@ -976,13 +962,6 @@ export function registerHostBulkRoutes(
continue;
}
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
const existing = existingHostMap?.get(lookupKey);
if (existing && !overwrite) {
results.skipped++;
continue;
}
const sshDataObj: Record<string, unknown> = {
userId,
connectionType: "ssh",
@@ -1020,6 +999,8 @@ export function registerHostBulkRoutes(
updatedAt: new Date().toISOString(),
};
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
const existing = existingHostMap?.get(lookupKey);
await withHostDefaults(
userId,
existing?.id ?? null,
@@ -1042,7 +1023,6 @@ export function registerHostBulkRoutes(
sshDataObj,
);
writtenHostIds.push(saved.id);
createdHostIds.push(saved.id);
results.success++;
}
} catch (error) {
@@ -1054,10 +1034,10 @@ export function registerHostBulkRoutes(
}
await applyDefaultsAfterHostWrites(writtenHostIds);
notifyImportedHosts(userId, writtenHostIds, createdHostIds);
notifyImportedHosts(userId, writtenHostIds);
res.json({
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`,
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
success: results.success,
updated: results.updated,
skipped: results.skipped,
@@ -88,23 +88,6 @@ export function keepsStoredSshAuth(authType: unknown): boolean {
return authType === undefined || authType === null;
}
export function isOptionalAuthType(value: unknown): boolean {
return value === undefined || value === null || typeof value === "string";
}
/**
* The auth type a new SSH host's secrets point to when the body names none,
* as an API client creating a host from a credential does.
*/
export function impliedSshAuthType(
body: Record<string, unknown>,
): string | undefined {
if (body.credentialId) return "credential";
if (body.key) return "key";
if (body.password) return "password";
return undefined;
}
const FOLDER_PATH_SEPARATOR = " / ";
/**
+2 -18
View File
@@ -31,10 +31,8 @@ import {
import {
applyHostKeyTypeUpdate,
containsOwnerPrivateAuthUpdate,
impliedSshAuthType,
keepsStoredSshAuth,
isNonEmptyString,
isOptionalAuthType,
isOptionalBoolean,
isValidPort,
normalizeProtocolEnableFields,
@@ -261,9 +259,7 @@ router.post(
!isNonEmptyString(ip) ||
!isValidPort(port) ||
!isOptionalBoolean(shareSshAuth) ||
!isOptionalBoolean(enableSsh) ||
!isOptionalAuthType(authType) ||
!isOptionalAuthType(authMethod)
!isOptionalBoolean(enableSsh)
) {
sshLogger.warn("Invalid SSH data input validation failed", {
operation: "host_create",
@@ -296,9 +292,7 @@ router.post(
const effectiveAuthType =
authType ||
authMethod ||
(effectiveConnectionType !== "ssh"
? "password"
: impliedSshAuthType(hostData));
(effectiveConnectionType !== "ssh" ? "password" : undefined);
const effectiveUsername =
username || firstProtocolUsername(protocolAuthPatch);
const effectiveName =
@@ -409,13 +403,6 @@ router.post(
body: hostData,
}),
);
// Host defaults may have supplied it.
if (!sshDataObj.authType) {
return res.status(400).json({
error:
"authType is required (password, key, credential, agent or none)",
});
}
const result = await createCurrentHostRepository().createEncryptedForUser(
userId,
sshDataObj,
@@ -486,7 +473,6 @@ router.post(
});
res.json(await withProtocolAuth(stripSensitiveFields(resolvedHost)));
pluginEvents.emit(TOPICS.hostCreated, { hostId: createdHost.id, userId });
notifyStatsHostUpdated(createdHost.id as number, userId, "host_create");
} catch (err) {
sshLogger.error("Failed to save SSH host to database", err, {
@@ -841,8 +827,6 @@ router.put(
!isValidPort(port) ||
!isOptionalBoolean(shareSshAuth) ||
!isOptionalBoolean(enableSsh) ||
!isOptionalAuthType(authType) ||
!isOptionalAuthType(authMethod) ||
!hostId
) {
sshLogger.warn("Invalid SSH data input validation failed for update", {
-210
View File
@@ -1,210 +0,0 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import {
createCurrentSettingsRepository,
createCurrentUserPreferenceRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
import { getPluginRuntime } from "../../plugins/index.js";
import {
LAYOUT_SETTING,
LOOK_ID_PATTERN,
THEME_SETTING,
installedLayoutIds,
resolveDefaultLook,
safeLayoutForced,
unavailableLooks,
} from "../../plugins/looks.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
async function adminDefaults() {
const settings = createCurrentSettingsRepository();
return {
layout: await settings.get(LAYOUT_SETTING),
theme: await settings.get(THEME_SETTING),
};
}
/**
* @openapi
* /user-preferences/look:
* get:
* summary: Get the layout and theme to show the current user
* description: The user's own choice, the admin defaults (a layout no installed plugin brings reads as null), the default look of the bundled plugins, whether TERMIX_SAFE_LAYOUT forces the repair view, and installed layout or theme plugins that could not load. The browser resolves the final look from these.
* tags:
* - User Preferences
* responses:
* 200:
* description: What the look is chosen from.
* content:
* application/json:
* schema:
* type: object
* properties:
* layoutId:
* type: string
* nullable: true
* themeId:
* type: string
* nullable: true
* defaults:
* type: object
* description: Admin defaults, null where unset.
* properties:
* layout:
* type: string
* nullable: true
* theme:
* type: string
* nullable: true
* defaultLook:
* type: object
* description: The default-look plugin's layout and theme, null where none is installed.
* properties:
* layout:
* type: string
* nullable: true
* theme:
* type: string
* nullable: true
* safeLayout:
* type: boolean
* unavailable:
* type: array
* items:
* type: object
* properties:
* id:
* type: string
* version:
* type: string
* nullable: true
* layouts:
* type: array
* items:
* type: string
* themes:
* type: array
* items:
* type: string
* error:
* type: string
*/
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const preferences =
await createCurrentUserPreferenceRepository().findByUserId(userId);
const { loader } = getPluginRuntime();
const installed = installedLayoutIds(loader);
const known = (id: string | null | undefined) =>
id && installed.has(id) ? id : null;
const defaults = await adminDefaults();
res.json({
layoutId: known(preferences?.layoutId),
themeId: preferences?.themeId ?? null,
defaults: { ...defaults, layout: known(defaults.layout) },
defaultLook: resolveDefaultLook(loader),
safeLayout: safeLayoutForced(),
unavailable: unavailableLooks(loader),
});
} catch (error) {
databaseLogger.error("Failed to get the look", error, {
operation: "get_look",
userId,
});
res.status(500).json({ error: "Failed to get the look" });
}
});
/**
* @openapi
* /user-preferences/look/defaults:
* put:
* summary: Set the default layout and theme (admin only)
* description: What everyone sees until they choose their own. Null clears a default so the default look applies.
* tags:
* - User Preferences
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* layout:
* type: string
* nullable: true
* theme:
* type: string
* nullable: true
* responses:
* 200:
* description: The defaults now in effect.
* 400:
* description: Not a layout or theme id.
* 403:
* description: Not an admin.
*/
router.put(
"/defaults",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const actor = await createCurrentUserRepository().findById(userId);
if (!actor?.isAdmin) {
return res.status(403).json({ error: "Not authorized" });
}
const body = (req.body ?? {}) as { layout?: unknown; theme?: unknown };
const changes: Array<[string, unknown]> = [
[LAYOUT_SETTING, body.layout],
[THEME_SETTING, body.theme],
];
for (const [key, value] of changes) {
if (
value !== undefined &&
value !== null &&
(typeof value !== "string" || !LOOK_ID_PATTERN.test(value))
) {
return res
.status(400)
.json({ error: `${key} must be a layout or theme id, or null` });
}
}
const settings = createCurrentSettingsRepository();
for (const [key, value] of changes) {
if (value === undefined) continue;
if (value === null) await settings.delete(key);
else await settings.set(key, value as string);
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor.username ?? userId,
action: "update_default_look",
resourceType: "setting",
details: JSON.stringify({ layout: body.layout, theme: body.theme }),
ipAddress,
userAgent,
success: true,
});
res.json(await adminDefaults());
} catch (error) {
databaseLogger.error("Failed to set the default look", error, {
operation: "set_default_look",
userId,
});
res.status(500).json({ error: "Failed to set the default look" });
}
},
);
export default router;
+1 -60
View File
@@ -14,7 +14,6 @@ import {
} from "../repositories/factory.js";
import { getPluginRuntime } from "../../plugins/index.js";
import { describePluginFrontend } from "../../plugins/assets.js";
import { isDefaultLookPlugin } from "../../plugins/bundled-index.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
@@ -353,7 +352,6 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
dependencies,
optionalDependencies,
docs,
defaultLook: isDefaultLookPlugin(record.id),
...describePluginFrontend(loaded),
};
@@ -2281,63 +2279,6 @@ router.post(
},
);
/**
* @openapi
* /plugins/{id}/restore-bundled:
* post:
* summary: Restore the bundled copy of a plugin
* description: >
* Drops any update downloaded over a plugin that ships with Termix and
* loads the copy from the image again, so a broken release can be rolled
* back from the layout repair view. The shipped version is pinned so
* auto update does not bring the release back. Dependents restart.
* tags:
* - Plugins
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The restored version and the plugin's state.
* 400:
* description: Invalid plugin id.
* 403:
* description: The caller lacks admin.plugins.manage.
* 409:
* description: The plugin has no bundled copy, or a linked server manages it.
*/
router.post(
"/:id/restore-bundled",
authenticateJWT,
requireManagePlugins,
async (req: Request, res: Response) => {
const pluginId = String(req.params.id);
if (!ID_PATTERN.test(pluginId)) {
res.status(400).json({ error: "Invalid plugin id" });
return;
}
if (await refuseOnLinkedDesktop(res)) return;
try {
const { restoreBundledPlugin } = await import("../../plugins/manage.js");
const result = await restoreBundledPlugin(pluginId);
await auditPluginAction(req, "restore_bundled_plugin", pluginId, {
version: result.version,
});
res.json(result);
} catch (error) {
sendManageError(
res,
error,
"Failed to restore the bundled copy",
"plugin_restore_bundled",
);
}
},
);
/**
* @openapi
* /plugins/{id}:
@@ -2365,7 +2306,7 @@ router.post(
* 404:
* description: No such plugin.
* 409:
* description: Other running plugins depend on it, it is the default look, or a linked server manages it.
* description: Other running plugins depend on it, or managed by a linked server.
*/
router.delete(
"/:id",
@@ -32,13 +32,8 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
compactHostView: row?.compactHostView ?? null,
statusColorScheme: row?.statusColorScheme ?? null,
customKeybindings: row?.customKeybindings ?? null,
layoutId: row?.layoutId ?? null,
themeId: row?.themeId ?? null,
});
/** Layout and theme ids, the same shape the plugin manifest allows. */
const LOOK_ID = /^[a-z][a-z0-9-]{1,39}$/;
/**
* @openapi
* /user-preferences:
@@ -109,14 +104,6 @@ const LOOK_ID = /^[a-z][a-z0-9-]{1,39}$/;
* type: string
* nullable: true
* description: JSON-encoded array of the user's custom keybindings. An action's type is one of the shell's own or one a plugin declares in contributes.keybindingActions.
* layoutId:
* type: string
* nullable: true
* description: The layout this user chose, overriding the admin default. Null follows the default.
* themeId:
* type: string
* nullable: true
* description: The theme this user chose, overriding the admin default. Null follows the default.
*/
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -178,14 +165,6 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
* customKeybindings:
* type: string
* description: JSON-encoded array of the user's custom keybindings. Each action is checked against the shell's own types and the parameters a plugin declares for its type.
* layoutId:
* type: string
* nullable: true
* description: A layout id, or null to follow the admin default.
* themeId:
* type: string
* nullable: true
* description: A theme id, or null to follow the admin default.
* responses:
* 200:
* description: Preferences updated successfully.
@@ -207,8 +186,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
confirmTabClose,
hiddenRailTabs,
customKeybindings,
layoutId,
themeId,
} = req.body as {
reopenTabsOnLogin?: boolean;
theme?: string | null;
@@ -224,8 +201,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
confirmTabClose?: boolean | null;
hiddenRailTabs?: string | null;
customKeybindings?: string | null;
layoutId?: string | null;
themeId?: string | null;
};
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme are no
// longer writable here -- they moved to /host-sidebar/preferences as of the
@@ -283,18 +258,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
}
}
for (const [key, value] of Object.entries({ layoutId, themeId })) {
if (
value !== undefined &&
value !== null &&
(typeof value !== "string" || !LOOK_ID.test(value))
) {
return res
.status(400)
.json({ error: `${key} must be a layout or theme id, or null` });
}
}
const boolFields: Record<string, boolean | null | undefined> = {
commandPaletteEnabled,
pinAppRail,
@@ -327,8 +290,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
if (confirmTabClose !== undefined) updates.confirmTabClose = confirmTabClose;
if (customKeybindings !== undefined)
updates.customKeybindings = customKeybindings;
if (layoutId !== undefined) updates.layoutId = layoutId;
if (themeId !== undefined) updates.themeId = themeId;
if (Object.keys(updates).length === 1) {
return res.status(400).json({ error: "No preferences provided" });
+3 -36
View File
@@ -1,5 +1,4 @@
// Serves a plugin's built frontend: its bundle, stylesheet, locales and the
// CSS files its themes declare.
// Serves a plugin's built frontend: its bundle, stylesheet and locales.
//
// Unauthenticated on purpose, the same as /assets: import() cannot carry the
// bearer header, and nothing here is secret. It only ever serves files from
@@ -29,22 +28,6 @@ export interface PluginFrontendInfo {
assetVersion: string | null;
/** Language codes with a locale file, "en" plus xx_YY names. */
locales: string[];
/** Changes whenever a theme's CSS does, for its ?v= link. */
themeVersion: string | null;
}
/** Where a theme's CSS is served: /plugin-assets/<id>/_themes/<theme>.css. */
export const THEME_ASSET_PREFIX = "_themes/";
/** The declared CSS file of each of the plugin's themes, by theme id. */
function themeFiles(plugin: AssetPlugin): Map<string, string> {
const files = new Map<string, string>();
const manifest = plugin.manifest as PluginManifest;
for (const theme of manifest.contributes?.themes ?? []) {
const file = path.resolve(plugin.dir, theme.css);
if (isInside(plugin.dir, file)) files.set(theme.id, file);
}
return files;
}
function frontendEntry(
@@ -89,16 +72,9 @@ export function describePluginFrontend(
css: false,
assetVersion: null,
locales: [],
themeVersion: null,
};
if (!plugin) return none;
const themes = [...themeFiles(plugin).values()].filter(fileExists);
const themeVersion =
themes.length > 0
? contentVersion(themes.sort(), plugin.manifest.version)
: null;
const locales: string[] = [];
const localeFiles: string[] = [];
const dir = localesDir(plugin);
@@ -120,8 +96,7 @@ export function describePluginFrontend(
}
const entry = frontendEntry(plugin);
if (!entry || !fileExists(entry.file))
return { ...none, locales, themeVersion };
if (!entry || !fileExists(entry.file)) return { ...none, locales };
const cssFile = path.join(entry.dir, "frontend.css");
const css = fileExists(cssFile);
@@ -136,7 +111,6 @@ export function describePluginFrontend(
css,
assetVersion,
locales: locales.sort(),
themeVersion,
};
}
@@ -184,14 +158,7 @@ export function resolvePluginAsset(
let root: string | null;
let rest: string;
if (clean.startsWith(THEME_ASSET_PREFIX)) {
// Only a declared theme file, which the loader checked on load.
const themeId = clean.slice(THEME_ASSET_PREFIX.length, -".css".length);
const file = themeFiles(plugin).get(themeId);
if (!file || !clean.endsWith(".css")) return null;
root = path.dirname(file);
rest = path.basename(file);
} else if (clean.startsWith("locales/")) {
if (clean.startsWith("locales/")) {
root = localesDir(plugin);
rest = clean.slice("locales/".length);
} else {
+1 -21
View File
@@ -1,5 +1,5 @@
/**
* The onboarding defaults and the default look of the bundled plugins, from
* The onboarding defaults of the bundled plugins, from
* dist/plugins/bundled-index.json (written by scripts/build-plugins.cjs from
* docker/bundled-plugins.json). Core never names plugins, so which ones a
* fresh install keeps lives in that file and not here.
@@ -27,7 +27,6 @@ interface BundledIndex {
consent?: boolean;
enabledByEnv?: string[];
};
defaultLook?: boolean;
}
>;
}
@@ -53,23 +52,6 @@ export function readBundledIndex(): BundledIndex | null {
return cached;
}
/**
* Bundled plugins whose layout and theme a fresh install shows. Core reads
* the default look from here so it never has to name a plugin.
*/
export function defaultLookPluginIds(): string[] {
const index = readBundledIndex();
if (!index) return [];
return Object.entries(index.plugins)
.filter(([, entry]) => entry?.defaultLook === true)
.map(([id]) => id)
.sort();
}
export function isDefaultLookPlugin(pluginId: string): boolean {
return defaultLookPluginIds().includes(pluginId);
}
export function resetBundledIndexCache(): void {
cached = undefined;
}
@@ -114,8 +96,6 @@ export function initialPluginState(
): "enabled" | "disabled" {
if (source !== "bundled") return "disabled";
if (!freshInstall) return "enabled";
// The default look is what the app is drawn with.
if (isDefaultLookPlugin(pluginId)) return "enabled";
const { recommended, consent } = onboardingDefaults(pluginId);
return recommended && !consent ? "enabled" : "disabled";
}
-2
View File
@@ -30,7 +30,6 @@ import { hostStatusService } from "../hosts/status/host-status-service.js";
import { parseSshOptions } from "../hosts/ssh-options.js";
import { hostTerminalExport } from "../database/routes/host-normalizers.js";
import type { DisposableBag } from "./disposables.js";
import { pluginEvents, TOPICS } from "./events.js";
import { pluginLogger } from "../utils/logger.js";
type AuditFn = (
@@ -503,7 +502,6 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
await writeSettings(created.id, writes);
await markSettingsOwn(created.id, writes);
await audit("hosts_create", `host ${created.id}`, { success: true });
pluginEvents.emit(TOPICS.hostCreated, { hostId: created.id, userId });
return (
await toRecords([created as unknown as Record<string, unknown>])
)[0];
-3
View File
@@ -25,7 +25,6 @@ import {
import { getActor, getActorSessionId } from "./actor.js";
import type { DisposableBag } from "./disposables.js";
import { redactHostSecrets } from "./ctx-hosts.js";
import { issueUserToken } from "./ctx-users.js";
import {
getSshAuthProvider,
listSshAuthProviders,
@@ -902,7 +901,5 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
await import("../database/repositories/factory.js");
return createCurrentUserAuthRepository().countUsersForProvider(provider);
},
issueUserToken: (userId, options) =>
issueUserToken(manifest, userId, options),
};
}
-94
View File
@@ -1,94 +0,0 @@
/**
* ctx.users, and ctx.auth.issueUserToken: what a plugin may learn about a
* user, and a short-lived session for calling core's own API as one.
*/
import type {
PluginUserToken,
PluginUserTokenOptions,
PluginUsers,
} from "@termix-ssh/plugin-sdk/backend";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import { assertCapability } from "./permissions.js";
import { getActor } from "./actor.js";
const DEFAULT_TOKEN_TTL_SECONDS = 300;
const MAX_TOKEN_TTL_SECONDS = 900;
const MAX_PURPOSE_LENGTH = 200;
export function createPluginUsers(manifest: PluginManifest): PluginUsers {
return {
getLocale: async (userId) => {
await assertCapability(
manifest.id,
"users:read",
manifest.capabilities,
"users_get_locale",
);
const { createCurrentUserPreferenceRepository } =
await import("../database/repositories/factory.js");
const preferences =
await createCurrentUserPreferenceRepository().findByUserId(userId);
return preferences?.language ?? null;
},
};
}
export async function issueUserToken(
manifest: PluginManifest,
userId: string,
options: PluginUserTokenOptions,
): Promise<PluginUserToken> {
await assertCapability(
manifest.id,
"users:impersonate",
manifest.capabilities,
"user_token_issued",
);
const { logAudit } = await import("../utils/audit-logger.js");
const purpose = String(options?.purpose ?? "")
.trim()
.slice(0, MAX_PURPOSE_LENGTH);
const ttlSeconds = Math.min(
options?.ttlSeconds ?? DEFAULT_TOKEN_TTL_SECONDS,
MAX_TOKEN_TTL_SECONDS,
);
const audit = (success: boolean, errorMessage?: string) =>
logAudit({
userId: getActor() ?? null,
username: `plugin:${manifest.id}`,
action: "plugin_user_token_issued",
resourceType: "user",
resourceId: userId,
details: JSON.stringify({ pluginId: manifest.id, purpose, ttlSeconds }),
success,
errorMessage,
});
try {
if (!purpose) {
throw new Error("ctx.auth.issueUserToken needs a purpose");
}
if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) {
throw new Error("ttlSeconds must be a positive whole number");
}
const { createCurrentUserRepository } =
await import("../database/repositories/factory.js");
const user = userId
? await createCurrentUserRepository().findById(userId)
: null;
if (!user) throw new Error(`User "${userId}" does not exist`);
const { AuthManager } = await import("../utils/auth-manager.js");
const issued = await AuthManager.getInstance().issuePluginSession(user.id, {
pluginId: manifest.id,
purpose,
ttlSeconds,
});
await audit(true);
return issued;
} catch (error) {
await audit(false, error instanceof Error ? error.message : String(error));
throw error;
}
}
-2
View File
@@ -62,7 +62,6 @@ import { createPluginFetch, createPluginNotify } from "./ctx-notify.js";
import { createPluginProcess } from "./ctx-process.js";
import { createPluginSystem } from "./ctx-system.js";
import { createPluginPlugins } from "./ctx-plugins.js";
import { createPluginUsers } from "./ctx-users.js";
import { createPluginCredentials } from "./ctx-credentials.js";
import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js";
@@ -958,7 +957,6 @@ export function createPluginContext(
auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
system: createPluginSystem({ manifest, bag: handle.bag, audit: auditCall }),
plugins: createPluginPlugins(manifest),
users: createPluginUsers(manifest),
desktop: {
openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request),
-2
View File
@@ -26,8 +26,6 @@ export const TOPICS = {
hostStatus: "host.status",
/** A host's key was accepted again after it changed. */
hostKeyUpdated: "host.key.updated",
/** A host was added and anything inventorying hosts should pick it up. */
hostCreated: "host.created",
/** A host's connection details changed and pollers should re-read them. */
hostUpdated: "host.updated",
/** A host was deleted and any poller holding it should drop it. */
+1 -91
View File
@@ -18,11 +18,7 @@ import path from "node:path";
import semver from "semver";
import * as tar from "tar";
import { pluginLogger } from "../utils/logger.js";
import {
isTermixCompatible,
parseManifest,
validateThemeCss,
} from "./manifest.js";
import { isTermixCompatible, parseManifest } from "./manifest.js";
import { getLocalVersion } from "../utils/app-version.js";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import {
@@ -89,19 +85,6 @@ export interface LoadedPlugin {
signedTier?: KeyTier;
}
/**
* A plugin folder that did not load. Kept so the layout repair view can say
* why a chosen layout or theme is missing instead of only "not installed".
*/
export interface SkippedPlugin {
/** Manifest id when it could be read, else the folder or file name. */
id: string;
version: string | null;
layouts: string[];
themes: string[];
error: string;
}
export interface PluginLoaderOptions {
errorThreshold?: number;
errorWindowMs?: number;
@@ -109,70 +92,8 @@ export interface PluginLoaderOptions {
onFailed?: (plugin: LoadedPlugin) => void;
}
/**
* Every theme file a manifest declares must sit inside the plugin and hold
* only what a theme may hold. Refusing the plugin keeps a bad file from ever
* being served.
*/
function validatePluginThemes(dir: string, manifest: PluginManifest): void {
for (const theme of manifest.contributes?.themes ?? []) {
const file = path.join(dir, theme.css);
if (!fs.existsSync(file) || !isRealPathInside(dir, file)) {
throw new Error(
`Plugin ${manifest.id} theme ${theme.id} file ${theme.css} is missing`,
);
}
const errors = validateThemeCss(fs.readFileSync(file, "utf8"), theme.id);
if (errors.length > 0) {
throw new Error(
`Plugin ${manifest.id} theme ${theme.id} is not allowed: ${errors.join("; ")}`,
);
}
}
}
/** What can still be said about a plugin folder that failed to load. */
function describeSkipped(
dir: string,
name: string,
isArtifact: boolean,
error: unknown,
): SkippedPlugin {
const skipped: SkippedPlugin = {
id: isArtifact ? name.replace(/(-\d[^/]*)?\.tmxplug$/, "") : name,
version: null,
layouts: [],
themes: [],
error: error instanceof Error ? error.message : String(error),
};
if (isArtifact) return skipped;
try {
const raw = JSON.parse(
fs.readFileSync(getPluginManifestPath(dir), "utf8"),
) as {
id?: unknown;
version?: unknown;
contributes?: { layouts?: unknown; themes?: unknown };
};
const ids = (list: unknown) =>
Array.isArray(list)
? list
.map((entry) => (entry as { id?: unknown })?.id)
.filter((id): id is string => typeof id === "string")
: [];
if (typeof raw.id === "string") skipped.id = raw.id;
if (typeof raw.version === "string") skipped.version = raw.version;
skipped.layouts = ids(raw.contributes?.layouts);
skipped.themes = ids(raw.contributes?.themes);
} catch {
// An unreadable manifest leaves only the folder name.
}
return skipped;
}
export class PluginLoader {
private readonly plugins = new Map<string, LoadedPlugin>();
private skippedPlugins: SkippedPlugin[] = [];
/** Version of each bundled plugin as shipped, the floor for any update. */
private readonly bundledVersions = new Map<string, string>();
/** Activation order, so shutdown can run it backwards. */
@@ -192,11 +113,6 @@ export class PluginLoader {
return [...this.plugins.values()];
}
/** Plugin folders the last full load skipped, with the reason. */
skipped(): SkippedPlugin[] {
return [...this.skippedPlugins];
}
get(pluginId: string): LoadedPlugin | undefined {
return this.plugins.get(pluginId);
}
@@ -294,8 +210,6 @@ export class PluginLoader {
);
}
validatePluginThemes(dir, manifest);
if (this.plugins.has(manifest.id)) {
throw new Error(`another plugin already uses the id "${manifest.id}"`);
}
@@ -344,7 +258,6 @@ export class PluginLoader {
async loadAll(): Promise<LoadedPlugin[]> {
this.plugins.clear();
this.bundledVersions.clear();
this.skippedPlugins = [];
await removeStaleStaging();
const loaded: LoadedPlugin[] = [];
// Every bundled folder name, loaded or not: a bundled plugin that fails
@@ -409,9 +322,6 @@ export class PluginLoader {
}
loaded.push(plugin);
} catch (error) {
this.skippedPlugins.push(
describeSkipped(dir, entry.name, isArtifact, error),
);
pluginLogger.error(
`Skipping plugin directory ${entry.name} in ${root}`,
error instanceof Error ? error : new Error(String(error)),
-76
View File
@@ -1,76 +0,0 @@
/**
* Layouts and themes on the server side: which look a fresh install shows,
* and which installed looks could not load. Core names no plugin here; the
* default look comes from the bundled index's defaultLook flag.
*/
import { defaultLookPluginIds } from "./bundled-index.js";
import type { LoadedPlugin, SkippedPlugin } from "./loader.js";
export interface DefaultLook {
layout: string | null;
theme: string | null;
}
interface LookLoader {
get(pluginId: string): Pick<LoadedPlugin, "manifest"> | undefined;
list(): Array<Pick<LoadedPlugin, "manifest">>;
skipped(): SkippedPlugin[];
}
/**
* The first default-look plugin's default layout (else its first one) and
* that layout's theme. A default-look plugin with themes only fills in the
* theme. Null where none is installed, and the browser uses its built-in.
*/
export function resolveDefaultLook(loader: LookLoader): DefaultLook {
const look: DefaultLook = { layout: null, theme: null };
for (const id of defaultLookPluginIds()) {
const contributes = loader.get(id)?.manifest.contributes;
const layouts = contributes?.layouts ?? [];
const layout = layouts.find((entry) => entry.default) ?? layouts[0];
if (layout && !look.layout) {
look.layout = layout.id;
look.theme ??= layout.defaultTheme ?? null;
}
if (!look.theme && contributes?.themes?.length) {
look.theme = contributes.themes[0].id;
}
}
return look;
}
/** Plugins with a layout or theme that were found but did not load. */
export function unavailableLooks(loader: LookLoader): SkippedPlugin[] {
return loader
.skipped()
.filter((plugin) => plugin.layouts.length > 0 || plugin.themes.length > 0);
}
/**
* Layout ids some installed plugin brings, whether it runs, is turned off or
* failed to load. A choice outside this set was uninstalled, so the next
* choice down applies instead of the repair view.
*/
export function installedLayoutIds(loader: LookLoader): Set<string> {
const ids = new Set<string>();
for (const plugin of loader.list()) {
for (const layout of plugin.manifest.contributes?.layouts ?? []) {
ids.add(layout.id);
}
}
for (const plugin of loader.skipped()) {
for (const id of plugin.layouts) ids.add(id);
}
return ids;
}
/** TERMIX_SAFE_LAYOUT=1 starts everyone in the layout repair view. */
export function safeLayoutForced(): boolean {
const value = process.env.TERMIX_SAFE_LAYOUT?.trim().toLowerCase();
return value === "1" || value === "true";
}
export const LOOK_ID_PATTERN = /^[a-z][a-z0-9-]{1,39}$/;
export const LAYOUT_SETTING = "ui.layout";
export const THEME_SETTING = "ui.theme";
+24 -87
View File
@@ -7,9 +7,7 @@
* sha256 and signature checks and stays marked unverified. Bundled plugins ship in the
* image and can still be uninstalled: their files are deleted and the id is
* recorded in the settings table, so the shipped copy is ignored from then
* on and a reinstall downloads the plugin like any other. The one exception
* is a default-look plugin (bundled-index.ts): the app is drawn with it, so
* it can be disabled or swapped but never uninstalled.
* on and a reinstall downloads the plugin like any other.
*/
import crypto from "node:crypto";
@@ -17,7 +15,6 @@ import fs from "node:fs";
import path from "node:path";
import semver from "semver";
import { pluginLogger } from "../utils/logger.js";
import { isDefaultLookPlugin } from "./bundled-index.js";
import {
activatePlugin,
deactivatePlugin,
@@ -1157,13 +1154,6 @@ export async function uninstallPluginUnlocked(
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
}
const shipped = loader.bundledIds().has(pluginId);
if (shipped && isDefaultLookPlugin(pluginId)) {
throw new PluginManageError(
"The default look cannot be uninstalled",
409,
"DEFAULT_LOOK",
);
}
const dependents = dependentChain(pluginId, await enabledIds());
if (dependents.length > 0) {
@@ -1644,94 +1634,41 @@ export function installUpload(
});
}
/**
* Loads the copy in the bundled folder again. Running dependents stop first
* and start again after. `prepare` runs once the plugin is stopped, for work
* that needs its files out of use.
*/
async function reloadBundledUnlocked(
pluginId: string,
prepare?: (previous: LoadedPlugin | undefined) => Promise<void>,
): Promise<{ id: string; version: string; state: string }> {
const { loader } = getPluginRuntime();
const previous = loader.get(pluginId);
if (previous && previous.source !== "bundled" && !prepare) {
throw new PluginManageError(
`${pluginId} is not loaded from the bundled folder`,
409,
"NOT_BUNDLED",
);
}
const dependents = previous
? dependentChain(pluginId, await enabledIds()).filter(
(id) => loader.get(id)?.state === "active",
)
: [];
for (const id of dependents) await deactivatePlugin(id);
await unloadPlugin(pluginId);
if (prepare) await prepare(previous);
const plugin = await loader.loadBundled(pluginId);
await registerLoadedPlugins([plugin]);
let state = "stopped";
if ((await enabledIds()).has(pluginId)) {
const { missing } = dependencyChain(pluginId);
state = missing.length > 0 ? "blocked" : await startAndRecord(pluginId);
}
for (const id of [...dependents].reverse()) await startAndRecord(id);
return { id: pluginId, version: plugin.manifest.version, state };
}
/**
* Loads the copy in the bundled folder again after it was rebuilt, for the
* dev runner.
* dev runner. Running dependents stop first and start again after.
*/
export function reloadBundledPlugin(
pluginId: string,
): Promise<{ id: string; version: string; state: string }> {
return serialized(() => reloadBundledUnlocked(pluginId));
}
/**
* Puts a bundled plugin back to the copy that shipped with this install,
* dropping any update downloaded over it. The shipped version is pinned so
* auto update does not bring a broken release straight back; unpin it in the
* Plugins tab to follow updates again.
*/
export function restoreBundledPlugin(
pluginId: string,
): Promise<{ id: string; version: string; state: string }> {
return serialized(async () => {
if (!ID_PATTERN.test(pluginId)) {
throw new PluginManageError("Invalid plugin id", 400, "INVALID_ID");
}
const { loader } = getPluginRuntime();
if (!loader.bundledIds().has(pluginId) || !isBundledOnDisk(pluginId)) {
const previous = loader.get(pluginId);
if (previous && previous.source !== "bundled") {
throw new PluginManageError(
"This plugin has no bundled copy",
`${pluginId} is not loaded from the bundled folder`,
409,
"NO_BUNDLED_COPY",
"NOT_BUNDLED",
);
}
const { createCurrentPluginRepository } = await repos();
const repository = createCurrentPluginRepository();
const result = await reloadBundledUnlocked(pluginId, async (previous) => {
await removeArtifactFiles(pluginId, previous?.artifact);
await fs.promises.rm(path.join(getUnpackedPluginsDir(), pluginId), {
recursive: true,
force: true,
});
});
await repository.update(pluginId, {
autoUpdate: false,
pinnedVersion: result.version,
});
await applyPin(pluginId, result.version);
pluginLogger.info(`Restored the bundled copy of ${pluginId}`, {
operation: "plugin_restore_bundled",
});
return result;
const dependents = previous
? dependentChain(pluginId, await enabledIds()).filter(
(id) => loader.get(id)?.state === "active",
)
: [];
for (const id of dependents) await deactivatePlugin(id);
await unloadPlugin(pluginId);
const plugin = await loader.loadBundled(pluginId);
await registerLoadedPlugins([plugin]);
let state = "stopped";
if ((await enabledIds()).has(pluginId)) {
const { missing } = dependencyChain(pluginId);
state = missing.length > 0 ? "blocked" : await startAndRecord(pluginId);
}
for (const id of [...dependents].reverse()) await startAndRecord(id);
return { id: pluginId, version: plugin.manifest.version, state };
});
}
-1
View File
@@ -21,7 +21,6 @@ export {
pluginFeatures,
pluginDocsUrl,
pluginEnvVars,
validateThemeCss,
} from "@termix-ssh/plugin-sdk/manifest";
export type {
+7 -18
View File
@@ -499,21 +499,17 @@ export class SyncEngine {
);
all.push(...result.changes);
next = result.nextCursor;
this.learnTypes(result);
this.serverTypes = new Set(result.entityTypes.map((entry) => entry.type));
this.state.serverTypes = result.entityTypes.map((entry) => ({
type: entry.type,
owner: entry.owner,
readOnly: entry.readOnly,
}));
if (!result.hasMore) break;
}
return { changes: all, cursor: next };
}
private learnTypes(page: ChangesPage): void {
this.serverTypes = new Set(page.entityTypes.map((entry) => entry.type));
this.state.serverTypes = page.entityTypes.map((entry) => ({
type: entry.type,
owner: entry.owner,
readOnly: entry.readOnly,
}));
}
private async pull(link: SyncLink): Promise<void> {
const { changes, cursor } = await this.fetchChanges(link, link.cursor);
const known = new Set(link.knownTypes);
@@ -532,14 +528,7 @@ export class SyncEngine {
/** Pulls every row of types this device has not pulled before. */
private async backfill(link: SyncLink): Promise<void> {
if (!this.serverTypes) {
// Only the entity types are wanted, so a one-row page is enough.
this.learnTypes(
await remoteJson<ChangesPage>(
link,
`/sync/v2/changes?cursor=${link.cursor}&limit=1`,
{ timeoutMs: 120_000 },
),
);
await this.fetchChanges(link, Number.MAX_SAFE_INTEGER);
}
const fresh = listEntities()
.map((entity) => entity.type)
+1 -5
View File
@@ -22,7 +22,6 @@ import {
import { FieldCrypto } from "../utils/field-crypto.js";
import { syncLogger } from "../utils/logger.js";
import { getPluginRuntime } from "../plugins/index.js";
import { pluginEvents, TOPICS } from "../plugins/events.js";
import { singletonSyncId } from "./wire.js";
import { parseDefaultOverrides } from "../../types/host-defaults.js";
import {
@@ -175,7 +174,7 @@ export function registerCoreSyncEntities(): void {
)
: {},
}),
afterWrite: async ({ id, userId, wire, created, resolveId }) => {
afterWrite: async ({ id, userId, wire, resolveId }) => {
if (id === null) return;
// The server owns host defaults: it keeps what the host set itself
// and resolves the rest. A desktop takes the server's values as sent.
@@ -196,9 +195,6 @@ export function registerCoreSyncEntities(): void {
const { SharedHostSecretsManager } =
await import("../utils/shared-host-secrets-manager.js");
await SharedHostSecretsManager.getInstance().resyncHost(id);
if (created) {
pluginEvents.emit(TOPICS.hostCreated, { hostId: id, userId });
}
},
remove: async (row, userId) => {
const { deleteOwnedHost } = await import("../hosts/delete-host.js");
+1 -5
View File
@@ -140,9 +140,6 @@ export async function listRecordErrors(userId: string): Promise<SyncRecord[]> {
return rows.filter((row) => !!row.error);
}
/** The largest seq the column holds: a 32-bit integer on Postgres and MySQL. */
const MAX_SEQ = 2_147_483_647;
export async function listChanges(
userId: string,
cursor: number,
@@ -151,8 +148,7 @@ export async function listChanges(
): Promise<SyncRecord[]> {
const conditions = [
eq(syncRecords.userId, userId),
// Postgres rejects a bound value the column cannot hold.
gt(syncRecords.seq, Math.min(cursor, MAX_SEQ)),
gt(syncRecords.seq, cursor),
];
if (types && types.length > 0) {
conditions.push(inArray(syncRecords.entityType, types));
+5 -19
View File
@@ -37,17 +37,6 @@ function userOf(req: Request): string {
return (req as AuthenticatedRequest).userId;
}
/** A non-negative integer query value, the fallback when absent, null when malformed. */
function queryInt(value: unknown, fallback: number): number | null {
if (value === undefined) return fallback;
const parsed = Number(value);
return typeof value === "string" &&
/^\d+$/.test(value) &&
Number.isSafeInteger(parsed)
? parsed
: null;
}
/**
* @openapi
* /sync/v2/info:
@@ -235,8 +224,6 @@ router.post(
* responses:
* 200:
* description: A page of changes, the next cursor and whether more remain.
* 400:
* description: The cursor or limit is not a non-negative integer.
* 500:
* description: Failed to read changes.
*/
@@ -245,12 +232,11 @@ router.get(
authenticateJWT,
async (req: Request, res: Response) => {
const userId = userOf(req);
const cursor = queryInt(req.query.cursor, 0);
const pageSize = queryInt(req.query.limit, MAX_PAGE);
if (cursor === null || pageSize === null) {
return res.status(400).json({ error: "Invalid cursor or limit" });
}
const limit = Math.min(MAX_PAGE, Math.max(1, pageSize));
const cursor = Math.max(0, Number(req.query.cursor) || 0);
const limit = Math.min(
MAX_PAGE,
Math.max(1, Number(req.query.limit) || MAX_PAGE),
);
const types =
typeof req.query.types === "string" && req.query.types
? req.query.types.split(",").filter(Boolean)
@@ -48,7 +48,6 @@ function exportFile(): string {
async function invoke(path: string, user: unknown = { isExternal: false }) {
const writes: string[] = [];
const events: Array<[string, unknown]> = [];
const hosts: Array<Record<string, unknown>> = [];
const importHostProtocolLogins = vi.fn();
const handler = runInNewContext(
@@ -99,11 +98,6 @@ async function invoke(path: string, user: unknown = { isExternal: false }) {
importHostDefaults: async () => ({ imported: 0, skipped: 0 }),
DatabaseSaveTrigger: { forceSave: async () => {} },
getErrorMessage: (error: Error) => error.message,
pluginEvents: {
emit: (topic: string, payload: unknown) =>
events.push([topic, payload]),
},
TOPICS: { hostCreated: "host.created" },
},
);
const res = { json: vi.fn(), status: vi.fn() };
@@ -112,7 +106,7 @@ async function invoke(path: string, user: unknown = { isExternal: false }) {
{ userId: "user-1", file: { path, originalname: "export.db" } },
res,
);
return { res, writes, hosts, events, importHostProtocolLogins };
return { res, writes, hosts, importHostProtocolLogins };
}
it("points imported hosts at the credentials imported with them", async () => {
@@ -128,17 +122,6 @@ it("points imported hosts at the credentials imported with them", async () => {
]);
});
it("publishes host.created once for each imported host", async () => {
const { events } = await invoke(exportFile());
expect(events).toEqual(
[501, 502, 503, 504].map((hostId) => [
"host.created",
{ hostId, userId: "user-1" },
]),
);
});
it("removes the uploaded file when the user is gone", async () => {
const path = exportFile();
const { res } = await invoke(path, null);
@@ -1,55 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { sql } from "drizzle-orm";
import { TestSqliteDatabase } from "./test-support.js";
import type { DatabaseContext } from "../../../database/repositories/database-context.js";
const state = vi.hoisted(() => ({
context: null as DatabaseContext | null,
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentRepositoryContext: () => state.context,
}));
vi.mock("../../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: { triggerSave: vi.fn() },
}));
const { listChanges } = await import("../../../sync/records.js");
// Runs against Postgres and MySQL too in the database-dialects CI job, where
// sync_records.seq is a 32-bit integer.
describe("listChanges", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
state.context = null;
});
async function seed(): Promise<void> {
adapter = new TestSqliteDatabase();
state.context = await adapter.connect();
await adapter.run(
sql`INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'user-1', '')`,
);
await adapter.run(
sql`INSERT INTO sync_records (user_id, entity_type, sync_id, revision, seq) VALUES ('user-1', 'hosts', 'a', 1, 1), ('user-1', 'hosts', 'b', 1, 2)`,
);
}
it("lists records after the cursor in seq order", async () => {
await seed();
const records = await listChanges("user-1", 1, 10);
expect(records.map((record) => record.syncId)).toEqual(["b"]);
});
it("answers no records for a cursor past what seq can hold", async () => {
await seed();
expect(await listChanges("user-1", Number.MAX_SAFE_INTEGER, 10)).toEqual(
[],
);
});
});
@@ -143,7 +143,7 @@ describe("notifyImportedHosts", () => {
seen.push(payload);
});
try {
notifyImportedHosts("user-1", [4, 7], []);
notifyImportedHosts("user-1", [4, 7]);
} finally {
off();
}
@@ -1,216 +0,0 @@
/**
* The create and update host routes against a real SQLite database. A body
* that leaves out authType must never reach the NOT NULL auth_type column.
*/
import express, {
type NextFunction,
type Request,
type Response,
} from "express";
import request from "supertest";
import { eq, sql } from "drizzle-orm";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DatabaseContext } from "../../../database/repositories/database-context.js";
const state = vi.hoisted(() => ({
ctx: null as null | { drizzle: unknown },
}));
const USER = "user-1";
const pass = (_req: Request, _res: Response, next: NextFunction) => next();
vi.mock("../../../database/db/index.js", () => ({
getDb: () => state.ctx!.drizzle,
getSqlite: () => null,
}));
vi.mock("../../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: {
triggerSave: vi.fn(),
forceSave: vi.fn(async () => {}),
batched: <T>(work: T) => work,
},
}));
vi.mock("../../../utils/system-crypto.js", () => ({
SystemCrypto: {
getInstance: () => ({
getEncryptionKey: async () => Buffer.alloc(32, 7),
getJWTSecret: async () => "secret",
}),
},
}));
vi.mock("../../../utils/data-crypto.js", () => ({
DataCrypto: {
getUserDataKey: () => Buffer.alloc(32, 3),
validateUserAccess: () => Buffer.alloc(32, 3),
encryptRecord: (_table: string, record: unknown) => record,
decryptRecord: (_table: string, record: unknown) => record,
decryptRecords: (_table: string, records: unknown) => records,
},
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() => (req: Request, _res: Response, next: NextFunction) => {
(req as Request & { userId: string }).userId = USER;
next();
},
createDataAccessMiddleware: () => pass,
createAdminMiddleware: () => pass,
}),
},
}));
vi.mock("../../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
requirePermission: () => pass,
canAccessHost: async () => ({ hasAccess: true, isOwner: true }),
hasPermission: async () => true,
isAdmin: async () => true,
getUserPermissions: async () => [],
}),
},
}));
vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: async () => {},
getAuditUsername: async () => USER,
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
}));
const { TestSqliteDatabase } = await import("../repositories/test-support.js");
const { hosts, sshCredentials } =
await import("../../../database/db/schema.js");
const { saveLevel } = await import("../../../hosts/defaults/service.js");
const { default: hostRouter } =
await import("../../../database/routes/host.js");
const { pluginEvents, TOPICS } = await import("../../../plugins/events.js");
const app = express();
app.use(express.json());
app.use("/host", hostRouter);
let db: InstanceType<typeof TestSqliteDatabase>;
let ctx: DatabaseContext;
beforeEach(async () => {
db = new TestSqliteDatabase("sqlite");
ctx = await db.connect();
state.ctx = ctx;
await ctx.drizzle.run(
sql`INSERT INTO users (id, username, password_hash, is_admin) VALUES (${USER}, ${USER}, '', 1)`,
);
});
afterEach(async () => {
await db.close();
});
async function addCredential(): Promise<number> {
const [row] = await ctx.drizzle
.insert(sshCredentials)
.values({
userId: USER,
name: "ops",
username: "root",
authType: "password",
password: "secret",
})
.returning({ id: sshCredentials.id });
return row.id;
}
async function storedAuthType(id: number) {
const [row] = await ctx.drizzle
.select({ authType: hosts.authType })
.from(hosts)
.where(eq(hosts.id, id));
return row?.authType;
}
const base = { ip: "10.0.0.5", port: 22, username: "root" };
describe("host create without authType", () => {
it("infers credential auth from a credentialId", async () => {
const credentialId = await addCredential();
const res = await request(app)
.post("/host/db/host")
.send({ ...base, credentialId });
expect(res.status).toBe(200);
expect(await storedAuthType(res.body.id)).toBe("credential");
});
it("infers password auth from a password", async () => {
const res = await request(app)
.post("/host/db/host")
.send({ ...base, password: "pw" });
expect(res.status).toBe(200);
expect(await storedAuthType(res.body.id)).toBe("password");
});
it("rejects a body that says nothing about authentication", async () => {
const res = await request(app).post("/host/db/host").send(base);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/authType is required/);
});
it("takes the auth type from the user's host defaults", async () => {
const credentialId = await addCredential();
await saveLevel(
{ level: "user", userId: USER },
{
set: new Map([["core.auth", { authType: "credential", credentialId }]]),
unset: new Set(),
errors: {},
},
USER,
);
const res = await request(app).post("/host/db/host").send(base);
expect(res.status).toBe(200);
expect(await storedAuthType(res.body.id)).toBe("credential");
});
});
describe("host update authType", () => {
it("keeps the stored auth type when the update leaves it out", async () => {
const created = await request(app)
.post("/host/db/host")
.send({ ...base, authType: "password", password: "pw" });
const res = await request(app)
.put(`/host/db/host/${created.body.id}`)
.send({ ...base, name: "renamed" });
expect(res.status).toBe(200);
expect(await storedAuthType(created.body.id)).toBe("password");
});
it("rejects an authType that is not a string", async () => {
const created = await request(app)
.post("/host/db/host")
.send({ ...base, authType: "password", password: "pw" });
const res = await request(app)
.put(`/host/db/host/${created.body.id}`)
.send({ ...base, authType: { bad: true } });
expect(res.status).toBe(400);
});
});
describe("host.created", () => {
it("is published once when a host is created, and not on update", async () => {
const seen: unknown[] = [];
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
seen.push(payload);
});
try {
const created = await request(app)
.post("/host/db/host")
.send({ ...base, authType: "password", password: "pw" });
expect(created.status).toBe(200);
await request(app)
.put(`/host/db/host/${created.body.id}`)
.send({ ...base, name: "renamed" });
expect(seen).toEqual([{ hostId: created.body.id, userId: USER }]);
} finally {
off();
}
});
});
@@ -37,14 +37,11 @@ vi.mock("../../../database/repositories/factory.js", () => ({
}));
import { registerHostBulkRoutes } from "../../../database/routes/host-bulk-routes.js";
import { buildExportPayload } from "../../../../ui/sidebar/host-export-payload";
import { pluginEvents, TOPICS } from "../../../plugins/events.js";
let handler: RequestHandler;
let sshConfigHandler: RequestHandler;
const router = {
post: (path: string, ...handlers: RequestHandler[]) => {
if (path === "/bulk-import") handler = handlers.at(-1)!;
if (path === "/ssh-config-import") sshConfigHandler = handlers.at(-1)!;
},
put: () => {},
patch: () => {},
@@ -69,13 +66,12 @@ beforeEach(() => {
vi.clearAllMocks();
mocks.create.mockResolvedValue({ id: 71 });
mocks.update.mockResolvedValue({ id: 19 });
mocks.list.mockResolvedValue([]);
mocks.list.mockResolvedValue([{ ...host, id: 19 }]);
});
it.each([false, true])(
"carries plugin host settings through export selection and import (overwrite=%s)",
async (overwrite) => {
if (overwrite) mocks.list.mockResolvedValue([{ ...host, id: 19 }]);
const payload = buildExportPayload(
{ hosts: [host] },
null,
@@ -172,60 +168,6 @@ it.each([20, "20"])(
},
);
it("skips hosts that already exist when not overwriting", async () => {
mocks.list.mockResolvedValue([{ ...host, id: 19 }]);
const res = await importHosts([host]);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ success: 0, updated: 0, skipped: 1, failed: 0 }),
);
expect(mocks.create).not.toHaveBeenCalled();
expect(mocks.update).not.toHaveBeenCalled();
expect(mocks.applyPluginSettings).not.toHaveBeenCalled();
});
it("maps a skipped jump host reference to its existing destination ID", async () => {
mocks.list.mockResolvedValue([{ ...host, ip: "192.0.2.2", id: 801 }]);
const res = await importHosts([
{ ...host, exportId: 10, jumpHosts: [{ hostId: 20 }] },
{ ...host, ip: "192.0.2.2", exportId: 20 },
]);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ success: 1, skipped: 1, failed: 0 }),
);
expect(mocks.create).toHaveBeenCalledTimes(1);
expect(mocks.update).not.toHaveBeenCalled();
expect(JSON.parse(mocks.create.mock.calls[0][1].jumpHosts)).toEqual([
{ hostId: 801 },
]);
});
it.each([
[false, { success: 0, updated: 0, skipped: 1 }],
[true, { success: 0, updated: 1, skipped: 0 }],
])(
"SSH config import with an existing host (overwrite=%s)",
async (overwrite, counts) => {
mocks.list.mockResolvedValue([{ ...host, id: 19 }]);
const res = { json: vi.fn(), status: vi.fn().mockReturnThis() };
await sshConfigHandler(
{
userId: "user-1",
body: {
content: "Host web\n HostName 192.0.2.1\n User alice\n Port 22\n",
overwrite,
},
} as unknown as Request,
res as unknown as Response,
vi.fn(),
);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ ...counts, failed: 0 }),
);
expect(mocks.create).not.toHaveBeenCalled();
expect(mocks.update).toHaveBeenCalledTimes(overwrite ? 1 : 0);
},
);
it.each([
[{ ...host, jumpHosts: [{ hostId: 20 }] }],
[{ ...host, jumpHosts: [{ hostId: "20" }] }],
@@ -260,53 +202,3 @@ it("does not save dependent hosts when importing their jump host fails", async (
);
expect(mocks.create).toHaveBeenCalledTimes(1);
});
function collectCreated() {
const seen: unknown[] = [];
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
seen.push(payload);
});
return { seen, off };
}
it("publishes host.created once for each host a JSON import creates", async () => {
mocks.list.mockResolvedValue([{ ...host, id: 19 }]);
mocks.create.mockResolvedValueOnce({ id: 501 });
const { seen, off } = collectCreated();
try {
await importHosts([host, { ...host, ip: "192.0.2.2" }], true);
} finally {
off();
}
// The overwritten host already existed, so only the new one is announced.
expect(seen).toEqual([{ hostId: 501, userId: "user-1" }]);
});
it("publishes host.created once for each host an SSH config import creates", async () => {
mocks.create
.mockResolvedValueOnce({ id: 601 })
.mockResolvedValueOnce({ id: 602 });
const { seen, off } = collectCreated();
try {
await sshConfigHandler(
{
userId: "user-1",
body: {
content:
"Host a\n HostName 10.0.0.1\n User root\nHost b\n HostName 10.0.0.2\n User root\n",
},
} as unknown as Request,
{
json: vi.fn(),
status: vi.fn().mockReturnThis(),
} as unknown as Response,
vi.fn(),
);
} finally {
off();
}
expect(seen).toEqual([
{ hostId: 601, userId: "user-1" },
{ hostId: 602, userId: "user-1" },
]);
});
@@ -3,7 +3,6 @@ import { setHostProtocolSource } from "../../../hosts/protocol-auth/registry.js"
import {
applyHostKeyTypeUpdate,
containsOwnerPrivateAuthUpdate,
impliedSshAuthType,
isNonEmptyString,
keepsStoredSshAuth,
isOptionalBoolean,
@@ -516,16 +515,3 @@ describe("keepsStoredSshAuth", () => {
expect(keepsStoredSshAuth("none")).toBe(false);
});
});
describe("impliedSshAuthType", () => {
it("reads the auth type from the secret a create body carries", () => {
expect(impliedSshAuthType({ credentialId: 2, password: "pw" })).toBe(
"credential",
);
expect(impliedSshAuthType({ key: "-----BEGIN", password: "pw" })).toBe(
"key",
);
expect(impliedSshAuthType({ password: "pw" })).toBe("password");
expect(impliedSshAuthType({})).toBeUndefined();
});
});
@@ -1,228 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import http from "node:http";
import type { AddressInfo } from "node:net";
import express from "express";
const state = vi.hoisted(() => ({
userId: "user-1",
admins: new Set(["admin-1"]),
prefs: null as { layoutId: string | null; themeId: string | null } | null,
settings: new Map<string, string>(),
audits: [] as unknown[],
skipped: [] as unknown[],
defaultLookIds: [] as string[],
installed: [] as string[],
}));
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: async (entry: unknown) => {
state.audits.push(entry);
},
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() => (req: express.Request, _res: unknown, next: () => void) => {
(req as unknown as { userId: string }).userId = state.userId;
next();
},
}),
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentUserPreferenceRepository: () => ({
findByUserId: async () => state.prefs,
}),
createCurrentUserRepository: () => ({
findById: async (id: string) => ({
id,
username: id,
isAdmin: state.admins.has(id),
}),
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.settings.get(key) ?? null,
set: async (key: string, value: string) => {
state.settings.set(key, value);
},
delete: async (key: string) => {
state.settings.delete(key);
},
}),
}));
vi.mock("../../../plugins/index.js", () => ({
getPluginRuntime: () => ({
loader: {
get: (id: string) =>
id === "shipped-look"
? {
manifest: {
contributes: {
layouts: [
{ id: "shipped", label: "Shipped", defaultTheme: "ink" },
],
},
},
}
: undefined,
list: () => [
{
manifest: {
contributes: {
layouts: state.installed.map((id) => ({ id, label: id })),
},
},
},
],
skipped: () => state.skipped,
},
}),
}));
vi.mock("../../../plugins/bundled-index.js", () => ({
defaultLookPluginIds: () => state.defaultLookIds,
}));
const { default: lookRoutes } =
await import("../../../database/routes/look.js");
let server: http.Server;
let baseUrl: string;
beforeEach(async () => {
state.userId = "user-1";
state.prefs = null;
state.settings.clear();
state.audits = [];
state.skipped = [];
state.defaultLookIds = [];
state.installed = [];
delete process.env.TERMIX_SAFE_LAYOUT;
const app = express();
app.use(express.json());
app.use("/user-preferences/look", lookRoutes);
server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
delete process.env.TERMIX_SAFE_LAYOUT;
await new Promise<void>((resolve) => server.close(() => resolve()));
});
async function getLook() {
const res = await fetch(`${baseUrl}/user-preferences/look`);
return { status: res.status, body: await res.json() };
}
async function putDefaults(body: unknown) {
const res = await fetch(`${baseUrl}/user-preferences/look/defaults`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
return { status: res.status, body: await res.json() };
}
describe("GET /user-preferences/look", () => {
it("is all empty on a fresh install", async () => {
expect((await getLook()).body).toEqual({
layoutId: null,
themeId: null,
defaults: { layout: null, theme: null },
defaultLook: { layout: null, theme: null },
safeLayout: false,
unavailable: [],
});
});
it("returns the user's pick, the admin defaults and the default look", async () => {
state.prefs = { layoutId: "mine", themeId: "nord" };
state.installed = ["mine", "admin-layout"];
state.settings.set("ui.layout", "admin-layout");
state.settings.set("ui.theme", "gruvbox");
state.defaultLookIds = ["shipped-look"];
state.skipped = [
{
id: "broken",
version: "1.0.0",
layouts: ["b"],
themes: [],
error: "x",
},
{ id: "other", version: null, layouts: [], themes: [], error: "y" },
];
process.env.TERMIX_SAFE_LAYOUT = "1";
const { body } = await getLook();
expect(body).toMatchObject({
layoutId: "mine",
themeId: "nord",
defaults: { layout: "admin-layout", theme: "gruvbox" },
defaultLook: { layout: "shipped", theme: "ink" },
safeLayout: true,
});
expect(body.unavailable.map((plugin: { id: string }) => plugin.id)).toEqual(
["broken"],
);
});
});
describe("GET /user-preferences/look with an uninstalled layout", () => {
it("drops a choice no installed plugin brings, so the next one applies", async () => {
state.prefs = { layoutId: "deleted", themeId: null };
state.settings.set("ui.layout", "also-deleted");
state.installed = ["kept"];
const { body } = await getLook();
expect(body.layoutId).toBeNull();
expect(body.defaults.layout).toBeNull();
});
it("keeps a choice whose plugin failed to load, for the repair view", async () => {
state.prefs = { layoutId: "b", themeId: null };
state.skipped = [
{
id: "broken",
version: "1.0.0",
layouts: ["b"],
themes: [],
error: "x",
},
];
expect((await getLook()).body.layoutId).toBe("b");
});
});
describe("PUT /user-preferences/look/defaults", () => {
it("is for admins only", async () => {
const { status } = await putDefaults({ layout: "x" });
expect(status).toBe(403);
expect(state.settings.size).toBe(0);
});
it("sets and clears the defaults, and audits it", async () => {
state.userId = "admin-1";
let res = await putDefaults({ layout: "fancy", theme: "nord" });
expect(res).toEqual({
status: 200,
body: { layout: "fancy", theme: "nord" },
});
res = await putDefaults({ layout: null });
expect(res.body).toEqual({ layout: null, theme: "nord" });
expect(state.audits).toHaveLength(2);
expect(state.audits[0]).toMatchObject({ action: "update_default_look" });
});
it("refuses something that is not an id", async () => {
state.userId = "admin-1";
expect((await putDefaults({ theme: "../evil" })).status).toBe(400);
expect((await putDefaults({ layout: 5 })).status).toBe(400);
expect(state.settings.size).toBe(0);
});
});
@@ -169,7 +169,6 @@ const manageMock = vi.hoisted(() => ({
updatePlugin: vi.fn(),
updateAllPlugins: vi.fn(),
uninstallPlugin: vi.fn(),
restoreBundledPlugin: vi.fn(),
setPluginOptions: vi.fn(),
setAllPluginChannels: vi.fn(),
getPluginDataSummary: vi.fn(),
@@ -434,7 +433,6 @@ describe("plugins route", () => {
"name",
"optionalDependencies",
"state",
"themeVersion",
"version",
].sort(),
);
@@ -626,12 +624,10 @@ describe("plugins route", () => {
contributes: null,
dependencies: {},
optionalDependencies: {},
defaultLook: false,
frontend: false,
css: false,
assetVersion: null,
locales: [],
themeVersion: null,
});
});
@@ -1142,35 +1138,6 @@ describe("plugins route", () => {
expect(manageMock.uninstallPlugin).toHaveBeenCalledWith("docker");
});
it("restores the bundled copy", async () => {
manageMock.restoreBundledPlugin.mockResolvedValue({
id: "docker",
version: "1.0.0",
state: "active",
});
const res = await post("/plugins/docker/restore-bundled");
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
id: "docker",
version: "1.0.0",
state: "active",
});
expect(manageMock.restoreBundledPlugin).toHaveBeenCalledWith("docker");
});
it("answers 409 when a plugin has no bundled copy", async () => {
manageMock.restoreBundledPlugin.mockRejectedValue(
new PluginManageError(
"This plugin has no bundled copy",
409,
"NO_BUNDLED_COPY",
),
);
const res = await post("/plugins/docker/restore-bundled");
expect(res.status).toBe(409);
expect((await res.json()).code).toBe("NO_BUNDLED_COPY");
});
it("refuses changes on a linked desktop", async () => {
manageMock.isLinkedDesktop.mockResolvedValue(true);
const res = await post("/plugins/docker/install");
@@ -1,96 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import http from "node:http";
import type { AddressInfo } from "node:net";
import express from "express";
const state = vi.hoisted(() => ({
row: null as Record<string, unknown> | null,
upserts: [] as Record<string, unknown>[],
}));
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() => (req: express.Request, _res: unknown, next: () => void) => {
(req as unknown as { userId: string }).userId = "user-1";
next();
},
}),
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentUserPreferenceRepository: () => ({
findByUserId: async () => state.row,
upsert: async (_userId: string, updates: Record<string, unknown>) => {
state.upserts.push(updates);
state.row = { ...(state.row ?? {}), ...updates };
},
}),
}));
vi.mock("../../../database/routes/keybinding-validation.js", () => ({
isValidKeybinding: () => true,
}));
const { default: routes } =
await import("../../../database/routes/user-preferences.js");
let server: http.Server;
let baseUrl: string;
beforeEach(async () => {
state.row = null;
state.upserts = [];
const app = express();
app.use(express.json());
app.use("/user-preferences", routes);
server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
async function put(body: unknown) {
const res = await fetch(`${baseUrl}/user-preferences`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
return { status: res.status, body: await res.json() };
}
describe("user preferences: layout and theme", () => {
it("reads null for both until chosen", async () => {
const body = await (await fetch(`${baseUrl}/user-preferences`)).json();
expect(body).toMatchObject({ layoutId: null, themeId: null });
});
it("saves a layout and theme id, and null to follow the default", async () => {
expect((await put({ layoutId: "fancy", themeId: "nord" })).status).toBe(
200,
);
expect(state.upserts[0]).toMatchObject({
layoutId: "fancy",
themeId: "nord",
});
const body = await (await fetch(`${baseUrl}/user-preferences`)).json();
expect(body).toMatchObject({ layoutId: "fancy", themeId: "nord" });
await put({ layoutId: null });
expect(state.upserts[1]).toMatchObject({ layoutId: null });
expect(state.upserts[1]).not.toHaveProperty("themeId");
});
it("refuses anything that is not an id", async () => {
for (const bad of ["Fancy", "../x", "a", 7]) {
expect((await put({ themeId: bad })).status).toBe(400);
}
expect(state.upserts).toHaveLength(0);
});
});
+1 -1
View File
@@ -8,7 +8,7 @@ const main = fs.readFileSync(
"utf8",
);
const shell = fs.readFileSync(
new URL("../../../ui/shell/controller/use-tabs.ts", import.meta.url),
new URL("../../../ui/AppShell.tsx", import.meta.url),
"utf8",
);
@@ -1,65 +0,0 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import vm from "node:vm";
import { describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const linkedServer = require("../../../../electron/linked-server.cjs") as {
setLinkedServer: (config: unknown) => void;
};
const source = fs.readFileSync(
new URL("../../../../electron/main.cjs", import.meta.url),
"utf8",
);
const tls = source.slice(
source.indexOf("const INSECURE_MODE_VALUES"),
source.indexOf("function httpFetch("),
);
const ORIGIN = "https://termix.example";
const TERMINAL = "wss://termix.example/plugin-ws/ssh-terminal/terminal";
function load(link: Record<string, unknown> | null) {
linkedServer.setLinkedServer(link);
const context = vm.createContext({
URL,
process: { env: {} },
linkedServer,
});
vm.runInContext(tls, context);
return context as unknown as {
isInvalidCertificateAllowedForUrl: (url: string) => boolean;
getWebSocketOptions: (
url: string,
options?: Record<string, unknown>,
) => Record<string, unknown>;
};
}
describe("linked server certificate exception", () => {
it("covers WebSockets to the linked server", () => {
const main = load({ origin: ORIGIN, allowInvalidCertificate: true });
expect(main.isInvalidCertificateAllowedForUrl(TERMINAL)).toBe(true);
expect(main.getWebSocketOptions(TERMINAL, { headers: {} })).toEqual({
headers: {},
rejectUnauthorized: false,
});
});
it.each([
"ws://termix.example/relay",
"wss://termix.example:8443/relay",
"wss://elsewhere.example/relay",
])("keeps verifying %s", (url) => {
const main = load({ origin: ORIGIN, allowInvalidCertificate: true });
expect(main.isInvalidCertificateAllowedForUrl(url)).toBe(false);
});
it("keeps verifying when the link does not allow it", () => {
const main = load({ origin: ORIGIN, allowInvalidCertificate: false });
expect(main.getWebSocketOptions(TERMINAL)).toEqual({
rejectUnauthorized: true,
});
});
});
Loaded 100 of 402 files, more files were not shown because too many files have changed in this diff. Show more