mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf62f108d2 | ||
|
|
5cc65d87cd | ||
|
|
e166994ec4 | ||
|
|
3a851ad189 | ||
|
|
d2851982a6 | ||
|
|
cc5dce18e2 | ||
|
|
6084ed4d3c | ||
|
|
c3f6db69e9 | ||
|
|
8e2221cb8f | ||
|
|
7324982f47 | ||
|
|
f20d64b4f2 | ||
|
|
aa0b32a142 | ||
|
|
c8a907538e | ||
|
|
dd5573cc10 | ||
|
|
071711dafc | ||
|
|
bc74fba3f5 | ||
|
|
694dc34b19 | ||
|
|
b3b08b4ff2 | ||
|
|
9b0af07607 | ||
|
|
c044dbde87 | ||
|
|
1396b3a70c | ||
|
|
4c9ee5923e | ||
|
|
25e58a6425 | ||
|
|
7d13b2a9fe | ||
|
|
e610aba0e5 | ||
|
|
3941dd4ecc | ||
|
|
872e5eba73 | ||
|
|
8c3f816407 | ||
|
|
13f7aa95a3 | ||
|
|
8e5a50623c | ||
|
|
d791e52d82 | ||
|
|
fb04edc497 |
No files matched your search
Binary file not shown.
|
Before Width: | Height: | Size: 71 KiB |
@@ -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'
|
||||
|
||||
@@ -6,16 +6,10 @@
|
||||
|
||||
- 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
|
||||
|
||||
@@ -40,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
|
||||
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
<a href="https://donate.termix.site/"><img alt="Donations this month" src="https://img.shields.io/badge/dynamic/json?style=for-the-badge&label=Donations%20this%20month&query=%24.fiatTotal&prefix=%24&url=https%3A%2F%2Ftermix.site%2Fdonation-snapshot.json&color=F39044&labelColor=1a1a1a" /></a>
|
||||
</p>
|
||||
|
||||
<a href="https://www.youtube.com/watch?v=vhtkP-UqV5U"><img src="./.github/readme/overview.jpg" width="720" alt="Watch the Termix overview on YouTube" /></a>
|
||||
|
||||
</div>
|
||||
|
||||
<br />
|
||||
@@ -137,7 +135,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 +149,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
@@ -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
-68
@@ -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,14 +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"
|
||||
"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
|
||||
@@ -41,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
|
||||
@@ -51,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
|
||||
@@ -73,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
|
||||
@@ -107,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
|
||||
@@ -123,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
|
||||
@@ -145,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
|
||||
@@ -155,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,
|
||||
@@ -166,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
|
||||
@@ -188,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
|
||||
@@ -210,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
|
||||
@@ -220,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
|
||||
@@ -254,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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
-6
@@ -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",
|
||||
@@ -399,6 +406,7 @@
|
||||
"internal": true,
|
||||
"description": "Set by npm run dev."
|
||||
},
|
||||
|
||||
{
|
||||
"name": "ENABLE_INSECURE_MODE",
|
||||
"group": "desktop",
|
||||
@@ -427,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",
|
||||
@@ -457,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",
|
||||
|
||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"version": "26.10.2",
|
||||
"version": "26.10.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "termix",
|
||||
"version": "26.10.2",
|
||||
"version": "26.10.1",
|
||||
"hasInstallScript": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
@@ -15058,7 +15058,7 @@
|
||||
},
|
||||
"packages/plugin-sdk": {
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.0.11",
|
||||
"version": "1.0.8",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"version": "26.10.2",
|
||||
"version": "26.10.1",
|
||||
"description": "Self-hosted, plugin-based server management.",
|
||||
"author": "Karmaa",
|
||||
"main": "electron/main.cjs",
|
||||
@@ -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",
|
||||
|
||||
@@ -1,31 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
- Worker entries in `package.json` `termix.workers`, each bundled on its own into `dist/workers/<name>.js`
|
||||
- A `.wasm`, `.ttf` or `.otf` import gives the file's bytes, in `termix-plugin build` and in plugin tests
|
||||
|
||||
### Fixed
|
||||
|
||||
- The plugin guide listed jszip, guacamole-lite and @anthropic-ai/sdk as shared packages, but they are bundled
|
||||
|
||||
## 1.0.8
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Termix plugin guide
|
||||
|
||||
SDK 1.0.11 | Plugin API 1.3.0
|
||||
SDK 1.0.8 | 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
|
||||
@@ -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
|
||||
@@ -343,27 +334,8 @@ ctx.schedule.every(60_000, () => refresh(), { jitterMs: 5_000 });
|
||||
### Shared packages
|
||||
|
||||
These come from Termix and are never bundled: `@termix-ssh/plugin-sdk`, `express`, `ssh2`, `ws`, `multer`,
|
||||
`cookie-parser`, `axios`, `drizzle-orm`, `sharp` and every Node builtin. List the ones you use in
|
||||
`peerDependencies`. Any other npm package is bundled.
|
||||
|
||||
### Workers and WebAssembly
|
||||
|
||||
Heavy work (decoding, encoding, parsing) belongs in a worker thread so it never blocks the server. Name
|
||||
each worker entry in `package.json`:
|
||||
|
||||
```json
|
||||
"termix": { "workers": { "engine": "src/backend/engine/worker.ts" } }
|
||||
```
|
||||
|
||||
`build` bundles it alone into `dist/workers/engine.js`; start it with
|
||||
`new Worker(new URL("./workers/engine.js", import.meta.url))`. A worker bundles everything it imports and
|
||||
can't use the shared packages above, so it must not import `@termix-ssh/plugin-sdk`. Talk to it with
|
||||
`postMessage`.
|
||||
|
||||
Importing a `.wasm`, `.ttf` or `.otf` file gives its bytes as a `Uint8Array`, in `build` and in tests, so a module ships
|
||||
inside the bundle with no file to find. Compile it once on the main thread with `new WebAssembly.Module`
|
||||
and post that to each worker. Add `declare module "*.wasm" { const bytes: Uint8Array; export default bytes; }`
|
||||
to a `.d.ts` in the plugin.
|
||||
`cookie-parser`, `axios`, `jszip`, `guacamole-lite`, `@anthropic-ai/sdk`, `drizzle-orm`, `sharp` and every
|
||||
Node builtin. List the ones you use in `peerDependencies`. Any other npm package is bundled.
|
||||
|
||||
## Database
|
||||
|
||||
@@ -493,7 +465,7 @@ 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.
|
||||
|
||||
## Looking native
|
||||
@@ -547,20 +519,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
|
||||
@@ -725,20 +683,20 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
|
||||
|
||||
## termix-plugin CLI
|
||||
|
||||
| Command | What it does |
|
||||
| ------------------------------ | ----------------------------------------------------------------------------------------------- |
|
||||
| `build` | Bundle `dist/backend.js`, `dist/frontend.js`, workers, CSS, locales, migrations, `openapi.json` |
|
||||
| `validate` | Check the manifest, its files, migrations for every database, the changelog and `package.json` |
|
||||
| `test` | Apply patches, run Vitest |
|
||||
| `migrations` | Write a migration per database from `tables.ts`. `--check` fails when one is missing |
|
||||
| `openapi` | Write `dist/openapi.json` from `@openapi` blocks |
|
||||
| `pack` | Validate, then write `<id>-<version>.tmxplug`. `--out <dir>` |
|
||||
| `sign <file>` | Sign a `.tmxplug` with `TERMIX_PLUGIN_SIGNING_KEY` |
|
||||
| `verify <file> --key <base64>` | Check a `.tmxplug` against its `.sig` |
|
||||
| `keygen` | Write a new signing key pair. `--out <dir>` |
|
||||
| `changelog` | Print one version's notes. `--version x.y.z` |
|
||||
| `patch` | Apply the plugin's dependency patches (`build` and `test` do this too) |
|
||||
| `dev` | Build, pack and install on a server, again on every change. `--server`, `--key`, `--once` |
|
||||
| Command | What it does |
|
||||
| ------------------------------ | ---------------------------------------------------------------------------------------------- |
|
||||
| `build` | Bundle `dist/backend.js` and `dist/frontend.js`, CSS, locales, migrations, `dist/openapi.json` |
|
||||
| `validate` | Check the manifest, its files, migrations for every database, the changelog and `package.json` |
|
||||
| `test` | Apply patches, run Vitest |
|
||||
| `migrations` | Write a migration per database from `tables.ts`. `--check` fails when one is missing |
|
||||
| `openapi` | Write `dist/openapi.json` from `@openapi` blocks |
|
||||
| `pack` | Validate, then write `<id>-<version>.tmxplug`. `--out <dir>` |
|
||||
| `sign <file>` | Sign a `.tmxplug` with `TERMIX_PLUGIN_SIGNING_KEY` |
|
||||
| `verify <file> --key <base64>` | Check a `.tmxplug` against its `.sig` |
|
||||
| `keygen` | Write a new signing key pair. `--out <dir>` |
|
||||
| `changelog` | Print one version's notes. `--version x.y.z` |
|
||||
| `patch` | Apply the plugin's dependency patches (`build` and `test` do this too) |
|
||||
| `dev` | Build, pack and install on a server, again on every change. `--server`, `--key`, `--once` |
|
||||
|
||||
## SDK entry points
|
||||
|
||||
@@ -763,37 +721,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) |
|
||||
| `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 |
|
||||
| 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.
|
||||
|
||||
|
||||
@@ -52,43 +52,6 @@ export function externalRequireInterop(externals) {
|
||||
};
|
||||
}
|
||||
|
||||
// A .wasm or font import becomes its bytes (a Uint8Array) inside the bundle,
|
||||
// so it ships in backend.js or a worker with no separate file to find.
|
||||
export const BINARY_EXTENSIONS = [".wasm", ".ttf", ".otf"];
|
||||
const BINARY_LOADERS = Object.fromEntries(
|
||||
BINARY_EXTENSIONS.map((ext) => [ext, "binary"]),
|
||||
);
|
||||
|
||||
const REQUIRE_BANNER =
|
||||
"import { createRequire as __termixCreateRequire } from 'node:module'; const require = __termixCreateRequire(import.meta.url);";
|
||||
|
||||
/**
|
||||
* package.json "termix": { "workers": { "engine": "src/backend/engine/worker.ts" } }
|
||||
* bundles each entry on its own into dist/workers/<name>.js, for
|
||||
* new Worker(new URL("./workers/<name>.js", import.meta.url)). Nothing is left
|
||||
* external: core's import fallback only runs on the main thread, so a worker
|
||||
* can't rely on host packages and must not import the SDK.
|
||||
*/
|
||||
export function readWorkerEntries(cwd, pluginId) {
|
||||
const pkgPath = path.join(cwd, "package.json");
|
||||
const pkg = fs.existsSync(pkgPath)
|
||||
? JSON.parse(fs.readFileSync(pkgPath, "utf8"))
|
||||
: {};
|
||||
const workers = pkg.termix?.workers ?? {};
|
||||
return Object.entries(workers).map(([name, entry]) => {
|
||||
if (!/^[a-z0-9-]+$/.test(name)) {
|
||||
throw new Error(`${pluginId}: worker name "${name}" must be lowercase`);
|
||||
}
|
||||
const file = path.resolve(cwd, String(entry));
|
||||
if (!fs.existsSync(file)) {
|
||||
throw new Error(
|
||||
`${pluginId}: worker "${name}" entry ${entry} is missing`,
|
||||
);
|
||||
}
|
||||
return { name, file };
|
||||
});
|
||||
}
|
||||
|
||||
const SDK_PATCHES = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
@@ -253,27 +216,13 @@ export async function build({ cwd }) {
|
||||
// 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 },
|
||||
banner: {
|
||||
js: "import { createRequire as __termixCreateRequire } from 'node:module'; const require = __termixCreateRequire(import.meta.url);",
|
||||
},
|
||||
});
|
||||
|
||||
for (const worker of readWorkerEntries(cwd, pluginId)) {
|
||||
await esbuild.build({
|
||||
entryPoints: [worker.file],
|
||||
outfile: path.join(outDir, "workers", `${worker.name}.js`),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node22",
|
||||
sourcemap: true,
|
||||
logLevel: "warning",
|
||||
loader: BINARY_LOADERS,
|
||||
banner: { js: REQUIRE_BANNER },
|
||||
});
|
||||
}
|
||||
|
||||
// Without this Node finds the host's typeless package.json, tries backend.js
|
||||
// as CommonJS first and warns before reparsing it as ESM.
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -11,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.0.11",
|
||||
"version": "1.0.8",
|
||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -36,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.3.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(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -30,25 +30,6 @@ function findRepoRoot(startDir: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* termix-plugin build turns a .wasm or font import into its bytes. Tests get the same,
|
||||
* so code that imports a module compiles it the same way under vitest.
|
||||
*/
|
||||
export function wasmBinaryPlugin() {
|
||||
return {
|
||||
name: "termix-wasm-binary",
|
||||
enforce: "pre" as const,
|
||||
load(id: string) {
|
||||
const file = id.split("?")[0];
|
||||
if (![".wasm", ".ttf", ".otf"].some((ext) => file.endsWith(ext))) {
|
||||
return null;
|
||||
}
|
||||
const base64 = fs.readFileSync(file).toString("base64");
|
||||
return `export default new Uint8Array(Buffer.from(${JSON.stringify(base64)}, "base64"));`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface PluginVitestOptions {
|
||||
/** Extra aliases merged over the defaults. */
|
||||
alias?: Record<string, string>;
|
||||
@@ -115,10 +96,7 @@ export function pluginVitestConfig(
|
||||
// unexpectedly". Threads don't.
|
||||
const pool = "threads" as const;
|
||||
|
||||
const plugins = [wasmBinaryPlugin()];
|
||||
|
||||
return {
|
||||
plugins,
|
||||
resolve: { alias },
|
||||
test: {
|
||||
globals: true,
|
||||
@@ -126,7 +104,6 @@ export function pluginVitestConfig(
|
||||
pool,
|
||||
projects: [
|
||||
{
|
||||
plugins,
|
||||
resolve: { alias },
|
||||
test: {
|
||||
name: `${pluginId}:backend`,
|
||||
@@ -139,7 +116,6 @@ export function pluginVitestConfig(
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins,
|
||||
resolve: { alias },
|
||||
test: {
|
||||
name: `${pluginId}:frontend`,
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 46 KiB |
+1
-6
@@ -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`,
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -53,7 +53,6 @@ function start() {
|
||||
isDev: false,
|
||||
appRoot: "/app",
|
||||
BACKEND_STDERR_TAIL_LIMIT: 4096,
|
||||
backendPort: 30001,
|
||||
handleBackendRequest,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
|
||||
@@ -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) | - | - |`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -92,74 +92,6 @@ describe("termix-plugin build", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("termix-plugin build workers and wasm", () => {
|
||||
// The smallest valid module: magic and version, no sections.
|
||||
const EMPTY_WASM = Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0, 0, 0]);
|
||||
|
||||
it("inlines a .wasm import as its bytes", async () => {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const dir = fixturePlugin();
|
||||
fs.writeFileSync(path.join(dir, "src", "backend", "mod.wasm"), EMPTY_WASM);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "src", "backend", "index.ts"),
|
||||
'import bytes from "./mod.wasm";\nexport const valid = WebAssembly.validate(bytes);\nexport function activate() {}\n',
|
||||
);
|
||||
|
||||
await build({ cwd: dir });
|
||||
|
||||
const mod = await import(
|
||||
pathToFileURL(path.join(dir, "dist", "backend.js")).href
|
||||
);
|
||||
expect(mod.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("bundles each worker entry on its own into dist/workers", async () => {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const dir = fixturePlugin();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "package.json"),
|
||||
JSON.stringify({ termix: { workers: { engine: "src/worker.ts" } } }),
|
||||
);
|
||||
const dep = path.join(dir, "node_modules", "worker-dep");
|
||||
fs.mkdirSync(dep, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dep, "package.json"),
|
||||
JSON.stringify({ name: "worker-dep", main: "index.js" }),
|
||||
);
|
||||
fs.writeFileSync(path.join(dep, "index.js"), "exports.answer = 42;\n");
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "src", "worker.ts"),
|
||||
'import { parentPort } from "node:worker_threads";\nimport { answer } from "worker-dep";\nparentPort?.postMessage(answer);\n',
|
||||
);
|
||||
|
||||
await build({ cwd: dir });
|
||||
|
||||
const file = path.join(dir, "dist", "workers", "engine.js");
|
||||
expect(fs.readFileSync(file, "utf8")).not.toContain('from "worker-dep"');
|
||||
const { Worker } = await import("node:worker_threads");
|
||||
const answer = await new Promise((resolve, reject) => {
|
||||
const worker = new Worker(file);
|
||||
worker.once("message", (value) => {
|
||||
void worker.terminate();
|
||||
resolve(value);
|
||||
});
|
||||
worker.once("error", reject);
|
||||
});
|
||||
expect(answer).toBe(42);
|
||||
});
|
||||
|
||||
it("refuses a worker whose entry is missing", async () => {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const dir = fixturePlugin();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "package.json"),
|
||||
JSON.stringify({ termix: { workers: { engine: "src/nope.ts" } } }),
|
||||
);
|
||||
|
||||
await expect(build({ cwd: dir })).rejects.toThrow(/entry src\/nope.ts/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin CSS layers", () => {
|
||||
it("renames the Tailwind layers so core's utilities always win", () => {
|
||||
expect(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -38,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";
|
||||
@@ -1389,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");
|
||||
}
|
||||
@@ -1720,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 {
|
||||
|
||||
@@ -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 = " / ";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -39,48 +39,6 @@ describe("linked server in Electron main", () => {
|
||||
).toEqual({ Accept: "x" });
|
||||
});
|
||||
|
||||
it("treats WebSocket upgrades to the linked server as the linked origin", () => {
|
||||
linked.setLinkedServer({ origin: ORIGIN });
|
||||
expect(linked.isLinkedOrigin(`${ORIGIN}/host/status`)).toBe(true);
|
||||
expect(
|
||||
linked.isLinkedOrigin("wss://termix.example/plugin-ws/ssh-terminal"),
|
||||
).toBe(true);
|
||||
expect(linked.isLinkedOrigin("wss://termix.example:443/")).toBe(true);
|
||||
expect(linked.isLinkedOrigin("ws://termix.example/")).toBe(false);
|
||||
expect(linked.isLinkedOrigin("wss://termix.example:8443/")).toBe(false);
|
||||
expect(linked.isLinkedOrigin("wss://other.example/")).toBe(false);
|
||||
expect(linked.isLinkedOrigin("not a url")).toBe(false);
|
||||
|
||||
linked.setLinkedServer({ origin: "http://termix.example:8080" });
|
||||
expect(linked.isLinkedOrigin("ws://termix.example:8080/ws")).toBe(true);
|
||||
expect(linked.isLinkedOrigin("wss://termix.example:8080/ws")).toBe(false);
|
||||
expect(linked.isLinkedOrigin("ws://termix.example/ws")).toBe(false);
|
||||
});
|
||||
|
||||
it("adds proxy headers and basic auth to the terminal WebSocket", () => {
|
||||
linked.setLinkedServer({
|
||||
origin: ORIGIN,
|
||||
headers: [{ name: "CF-Access-Client-Id", value: "id" }],
|
||||
basicAuth: { username: "gate", password: "pw" },
|
||||
});
|
||||
expect(
|
||||
linked.applyLinkHeaders(
|
||||
"wss://termix.example/plugin-ws/ssh-terminal/terminal",
|
||||
{ Upgrade: "websocket" },
|
||||
),
|
||||
).toEqual({
|
||||
Upgrade: "websocket",
|
||||
"CF-Access-Client-Id": "id",
|
||||
Authorization: `Basic ${Buffer.from("gate:pw").toString("base64")}`,
|
||||
});
|
||||
expect(
|
||||
linked.answerLogin({}, "wss://termix.example/plugin-ws/ssh-terminal"),
|
||||
).toEqual({ username: "gate", password: "pw" });
|
||||
expect(
|
||||
linked.applyLinkHeaders("wss://other.example/", { Upgrade: "websocket" }),
|
||||
).toEqual({ Upgrade: "websocket" });
|
||||
});
|
||||
|
||||
it("moves a Termix bearer into the jwt cookie when basic auth is in front", () => {
|
||||
linked.setLinkedServer({
|
||||
origin: ORIGIN,
|
||||
|
||||
@@ -207,7 +207,6 @@ vi.mock("../../hosts/delete-host.js", () => ({
|
||||
const { createPluginHosts } = await import("../../plugins/ctx-hosts.js");
|
||||
const { PluginCapabilityError } =
|
||||
await import("@termix-ssh/plugin-sdk/backend");
|
||||
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
|
||||
|
||||
function manifest(capabilities: string[]) {
|
||||
return {
|
||||
@@ -437,29 +436,6 @@ describe("ctx.hosts", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("publishes host.created for a host it creates", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const seen: unknown[] = [];
|
||||
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
|
||||
seen.push(payload);
|
||||
});
|
||||
try {
|
||||
await hosts.create({
|
||||
ip: "10.0.0.5",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
});
|
||||
} finally {
|
||||
off();
|
||||
}
|
||||
expect(seen).toEqual([{ hostId: 99, userId: "user-1" }]);
|
||||
});
|
||||
|
||||
it("refuses a field the SDK does not take instead of dropping it", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
const hosts = createPluginHosts({
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
/**
|
||||
* ctx.auth.issueUserToken mints a short-lived session core's own auth
|
||||
* middleware accepts as the named user, under users:impersonate, audited and
|
||||
* kept off account security routes. ctx.users.getLocale reads the user's UI
|
||||
* language under users:read.
|
||||
*/
|
||||
|
||||
import crypto from "crypto";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
grants: [] as string[],
|
||||
users: new Map<string, { id: string; username: string; isAdmin: boolean }>(),
|
||||
sessions: new Map<string, Record<string, unknown>>(),
|
||||
languages: new Map<string, string | null>(),
|
||||
audits: [] as Array<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
db: {},
|
||||
getDb: () => ({}),
|
||||
saveMemoryDatabaseToFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginPermissionGrantRepository: () => ({
|
||||
listByPlugin: async (pluginId: string) =>
|
||||
state.grants.map((capability) => ({ pluginId, capability })),
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({ get: async () => null }),
|
||||
createCurrentSessionRepository: () => ({
|
||||
create: async (session: Record<string, unknown>) => {
|
||||
state.sessions.set(session.id as string, session);
|
||||
return session;
|
||||
},
|
||||
findById: async (id: string) => state.sessions.get(id) ?? null,
|
||||
touch: async () => {},
|
||||
revoke: async (id: string) => state.sessions.delete(id),
|
||||
}),
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async (id: string) => state.users.get(id) ?? null,
|
||||
}),
|
||||
createCurrentUserPreferenceRepository: () => ({
|
||||
findByUserId: async (userId: string) =>
|
||||
state.languages.has(userId)
|
||||
? { userId, language: state.languages.get(userId) }
|
||||
: null,
|
||||
}),
|
||||
createCurrentApiKeyRepository: () => ({}),
|
||||
createCurrentTrustedDeviceRepository: () => ({}),
|
||||
getCurrentSettingValue: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
state.audits.push(entry);
|
||||
},
|
||||
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/system-crypto.js", () => ({
|
||||
SystemCrypto: {
|
||||
getInstance: () => ({
|
||||
getJWTSecret: async () => "a".repeat(64),
|
||||
getEncryptionKey: async () => crypto.randomBytes(32),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/user-keys.js", () => ({
|
||||
UserKeyManager: {
|
||||
getInstance: () => ({
|
||||
hasUserDEK: () => true,
|
||||
tryGetUserDEK: () => null,
|
||||
invalidate: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/crypto-migration/dek-migration.js", () => ({
|
||||
adoptRecoveredDEK: vi.fn(async () => {}),
|
||||
migratePasswordUserAtLogin: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
const { createPluginContext, createPluginHandle } =
|
||||
await import("../../plugins/ctx.js");
|
||||
const { invalidatePluginPermissionCache } =
|
||||
await import("../../plugins/permissions.js");
|
||||
const { AuthManager } = await import("../../utils/auth-manager.js");
|
||||
const { registerSecondFactor, resetAuthRegistryForTests } =
|
||||
await import("../../auth/registry.js");
|
||||
const { PluginCapabilityError } =
|
||||
await import("@termix-ssh/plugin-sdk/backend");
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticate = authManager.createAuthMiddleware();
|
||||
const requireAdmin = authManager.createAdminMiddleware();
|
||||
|
||||
function contextFor(capabilities: string[], grants = capabilities) {
|
||||
state.grants = grants;
|
||||
const manifest = {
|
||||
id: "ai",
|
||||
name: "AI",
|
||||
version: "1.0.0",
|
||||
capabilities,
|
||||
} as never;
|
||||
return createPluginContext(manifest, createPluginHandle("ai", {} as never));
|
||||
}
|
||||
|
||||
type Middleware = typeof authenticate;
|
||||
|
||||
async function call(middleware: Middleware, token: string, url: string) {
|
||||
const req = {
|
||||
cookies: {},
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
method: "POST",
|
||||
originalUrl: url,
|
||||
url,
|
||||
} as unknown as Parameters<Middleware>[0] & { userId?: string };
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: null as unknown,
|
||||
status(code: number) {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
},
|
||||
json(body: unknown) {
|
||||
res.body = body;
|
||||
return res;
|
||||
},
|
||||
clearCookie() {
|
||||
return res;
|
||||
},
|
||||
};
|
||||
let passed = false;
|
||||
await middleware(req, res as never, () => {
|
||||
passed = true;
|
||||
});
|
||||
return { passed, status: res.statusCode, body: res.body, userId: req.userId };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.users = new Map([
|
||||
["u1", { id: "u1", username: "alice", isAdmin: false }],
|
||||
["admin", { id: "admin", username: "root", isAdmin: true }],
|
||||
]);
|
||||
state.sessions.clear();
|
||||
state.languages.clear();
|
||||
state.audits = [];
|
||||
invalidatePluginPermissionCache();
|
||||
resetAuthRegistryForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("ctx.auth.issueUserToken", () => {
|
||||
it("mints a session core's auth middleware accepts as that user", async () => {
|
||||
const ctx = contextFor(["users:impersonate"]);
|
||||
|
||||
const { token, expiresAt } = await ctx.auth.issueUserToken("u1", {
|
||||
purpose: "nightly report",
|
||||
});
|
||||
const result = await call(authenticate, token, "/host/db/host");
|
||||
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.userId).toBe("u1");
|
||||
const session = [...state.sessions.values()][0];
|
||||
expect(session).toMatchObject({
|
||||
userId: "u1",
|
||||
deviceType: "plugin",
|
||||
deviceInfo: "ai: nightly report",
|
||||
expiresAt,
|
||||
});
|
||||
const payload = jwt.decode(token) as { exp: number; iat: number };
|
||||
expect(payload.exp - payload.iat).toBe(300);
|
||||
});
|
||||
|
||||
it("keeps the user's own permissions", async () => {
|
||||
const ctx = contextFor(["users:impersonate"]);
|
||||
const user = await ctx.auth.issueUserToken("u1", { purpose: "check" });
|
||||
const admin = await ctx.auth.issueUserToken("admin", { purpose: "check" });
|
||||
|
||||
const refused = await call(requireAdmin, user.token, "/users/list");
|
||||
expect(refused.passed).toBe(false);
|
||||
expect(refused.status).toBe(403);
|
||||
expect((await call(requireAdmin, admin.token, "/users/list")).passed).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("caps the lifetime at 900 seconds", async () => {
|
||||
const ctx = contextFor(["users:impersonate"]);
|
||||
|
||||
const { token, expiresAt } = await ctx.auth.issueUserToken("u1", {
|
||||
purpose: "long task",
|
||||
ttlSeconds: 86_400,
|
||||
});
|
||||
|
||||
const payload = jwt.decode(token) as { exp: number; iat: number };
|
||||
expect(payload.exp - payload.iat).toBe(900);
|
||||
expect(new Date(expiresAt).getTime() - Date.now()).toBeLessThanOrEqual(
|
||||
900_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("stops working when the session expires", async () => {
|
||||
const ctx = contextFor(["users:impersonate"]);
|
||||
const { token } = await ctx.auth.issueUserToken("u1", {
|
||||
purpose: "short",
|
||||
ttlSeconds: 60,
|
||||
});
|
||||
|
||||
vi.useFakeTimers({ now: Date.now() + 61_000 });
|
||||
const result = await call(authenticate, token, "/host/db/host");
|
||||
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.status).toBe(401);
|
||||
});
|
||||
|
||||
it("audits every issue with the plugin, the user and the purpose", async () => {
|
||||
const ctx = contextFor(["users:impersonate"]);
|
||||
|
||||
await ctx.auth.issueUserToken("u1", { purpose: "nightly report" });
|
||||
|
||||
const entry = state.audits.find(
|
||||
(audit) => audit.action === "plugin_user_token_issued",
|
||||
);
|
||||
expect(entry).toMatchObject({
|
||||
username: "plugin:ai",
|
||||
resourceType: "user",
|
||||
resourceId: "u1",
|
||||
success: true,
|
||||
});
|
||||
expect(JSON.parse(entry!.details as string)).toEqual({
|
||||
pluginId: "ai",
|
||||
purpose: "nightly report",
|
||||
ttlSeconds: 300,
|
||||
});
|
||||
});
|
||||
|
||||
it("is refused without a granted users:impersonate", async () => {
|
||||
for (const ctx of [
|
||||
contextFor([]),
|
||||
contextFor(["users:impersonate"], []),
|
||||
contextFor(["users:read"]),
|
||||
]) {
|
||||
await expect(
|
||||
ctx.auth.issueUserToken("u1", { purpose: "x" }),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
}
|
||||
expect(state.sessions.size).toBe(0);
|
||||
expect(state.audits.every((audit) => audit.success === false)).toBe(true);
|
||||
});
|
||||
|
||||
it("is refused for a user that does not exist, or without a purpose", async () => {
|
||||
const ctx = contextFor(["users:impersonate"]);
|
||||
|
||||
await expect(
|
||||
ctx.auth.issueUserToken("ghost", { purpose: "x" }),
|
||||
).rejects.toThrow(/does not exist/);
|
||||
await expect(
|
||||
ctx.auth.issueUserToken("u1", { purpose: " " }),
|
||||
).rejects.toThrow(/purpose/);
|
||||
expect(state.sessions.size).toBe(0);
|
||||
expect(
|
||||
state.audits.filter(
|
||||
(audit) =>
|
||||
audit.action === "plugin_user_token_issued" && !audit.success,
|
||||
),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("cannot change how the user signs in or mint lasting credentials", async () => {
|
||||
registerSecondFactor({ id: "totp", pluginId: "totp" } as never);
|
||||
const ctx = contextFor(["users:impersonate"]);
|
||||
const { token } = await ctx.auth.issueUserToken("admin", {
|
||||
purpose: "ops",
|
||||
});
|
||||
|
||||
for (const url of [
|
||||
"/users/change-password",
|
||||
"/users/link-external-to-password",
|
||||
"/users/admin/reset-password",
|
||||
"/users/admin/u1/second-factors",
|
||||
"/plugin-api/totp/setup",
|
||||
]) {
|
||||
const result = await call(authenticate, token, url);
|
||||
expect(result.passed, url).toBe(false);
|
||||
expect(result.body).toMatchObject({ code: "PLUGIN_SESSION_NOT_ALLOWED" });
|
||||
}
|
||||
const apiKeys = await call(requireAdmin, token, "/users/api-keys");
|
||||
expect(apiKeys.passed).toBe(false);
|
||||
expect(apiKeys.body).toMatchObject({ code: "PLUGIN_SESSION_NOT_ALLOWED" });
|
||||
expect(
|
||||
(await call(authenticate, token, "/plugin-api/other/run")).passed,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a normal login session free to use those routes", async () => {
|
||||
const token = await authManager.generateJWTToken("u1", {
|
||||
deviceType: "web",
|
||||
deviceInfo: "Chrome",
|
||||
});
|
||||
|
||||
expect(
|
||||
(await call(authenticate, token, "/users/change-password")).passed,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.users.getLocale", () => {
|
||||
it("returns the stored language, or null", async () => {
|
||||
state.languages.set("u1", "zh_CN");
|
||||
state.languages.set("admin", null);
|
||||
const ctx = contextFor(["users:read"]);
|
||||
|
||||
await expect(ctx.users.getLocale("u1")).resolves.toBe("zh_CN");
|
||||
await expect(ctx.users.getLocale("admin")).resolves.toBeNull();
|
||||
await expect(ctx.users.getLocale("nobody")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("is refused without a granted users:read", async () => {
|
||||
state.languages.set("u1", "zh_CN");
|
||||
|
||||
await expect(
|
||||
contextFor(["users:read"], []).users.getLocale("u1"),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
await expect(
|
||||
contextFor(["users:impersonate"]).users.getLocale("u1"),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
});
|
||||
});
|
||||
@@ -75,8 +75,7 @@ const UNGATED: Record<string, string> = {
|
||||
"schedule.every": "the plugin's own timers",
|
||||
"schedule.after": "the plugin's own timers",
|
||||
"audit.record": "writes under the runtime's actor, never a plugin value",
|
||||
asUser:
|
||||
"audited as plugin_as_user (identical successful repeats coalesced for 10 minutes)",
|
||||
asUser: "always audited as plugin_as_user",
|
||||
currentActor: "reads the actor, cannot set it",
|
||||
"desktop.available": "a yes/no about the environment",
|
||||
"http.baseUrl": "reads the request's public origin",
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const { listChanges } = vi.hoisted(() => ({ listChanges: vi.fn() }));
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: { getInstance: () => ({ createAuthMiddleware: () => vi.fn() }) },
|
||||
}));
|
||||
vi.mock("../../../utils/logger.js", () => ({ syncLogger: { error: vi.fn() } }));
|
||||
vi.mock("../../../utils/app-version.js", () => ({ getLocalVersion: vi.fn() }));
|
||||
vi.mock("../../../plugins/index.js", () => ({ getPluginRuntime: vi.fn() }));
|
||||
vi.mock("../../../plugins/sync-registry.js", () => ({
|
||||
getEntity: vi.fn(),
|
||||
listEntities: () => [{ type: "hosts", owner: "core", order: 10 }],
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({}));
|
||||
vi.mock("../../../sync/entities.js", () => ({
|
||||
registerCoreSyncEntities: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../sync/records.js", () => ({
|
||||
listChanges,
|
||||
recordKey: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../sync/server/feed.js", () => ({
|
||||
withUserLock: (_userId: string, fn: () => unknown) => fn(),
|
||||
reconcileUser: async () => ({ wire: new Map() }),
|
||||
}));
|
||||
vi.mock("../../../sync/server/push.js", () => ({}));
|
||||
vi.mock("../../../database/routes/branding-settings.js", () => ({}));
|
||||
|
||||
import router from "../../../sync/server/routes.js";
|
||||
|
||||
const route = router.stack.find(
|
||||
(layer) => layer.route?.path === "/v2/changes",
|
||||
)!.route!;
|
||||
const handler = route.stack[route.stack.length - 1].handle;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
listChanges.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
async function changes(query: Record<string, string>) {
|
||||
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() };
|
||||
await handler({ userId: "user-1", query } as never, res as never, vi.fn());
|
||||
return res;
|
||||
}
|
||||
|
||||
it("answers an empty page with the entity types past the newest change", async () => {
|
||||
const cursor = String(Number.MAX_SAFE_INTEGER);
|
||||
const res = await changes({ cursor, limit: "1000" });
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
changes: [],
|
||||
nextCursor: Number.MAX_SAFE_INTEGER,
|
||||
hasMore: false,
|
||||
entityTypes: [{ type: "hosts", owner: "core", order: 10, readOnly: false }],
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults a missing cursor and limit", async () => {
|
||||
await changes({});
|
||||
expect(listChanges).toHaveBeenCalledWith("user-1", 0, 1001, undefined);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ cursor: "abc" },
|
||||
{ cursor: "-1" },
|
||||
{ cursor: "1.5" },
|
||||
{ cursor: "Infinity" },
|
||||
{ limit: "1.5" },
|
||||
{ limit: "-5" },
|
||||
])("rejects a malformed query %o", async (query) => {
|
||||
const res = await changes(query);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(listChanges).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -15,7 +15,6 @@ const state = vi.hoisted(() => ({
|
||||
server: null as null | { drizzle: unknown },
|
||||
allow: true,
|
||||
sharedHostIds: new Set<number>(),
|
||||
paths: [] as string[],
|
||||
}));
|
||||
|
||||
const SERVER_USER = "srv-user";
|
||||
@@ -124,7 +123,6 @@ vi.mock("../../sync/client/http.js", async (importOriginal) => {
|
||||
path: string,
|
||||
init: { method?: string; body?: unknown } = {},
|
||||
) => {
|
||||
state.paths.push(path);
|
||||
const previous = state.current;
|
||||
state.current = state.server;
|
||||
try {
|
||||
@@ -163,7 +161,6 @@ const { createResolvers, writeWireRow } = await import("../../sync/store.js");
|
||||
const { getEntity } = await import("../../plugins/sync-registry.js");
|
||||
const { FieldCrypto } = await import("../../utils/field-crypto.js");
|
||||
const { syncLogger } = await import("../../utils/logger.js");
|
||||
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: "5mb" }));
|
||||
@@ -252,7 +249,6 @@ beforeEach(async () => {
|
||||
resetSeqAllocator();
|
||||
state.allow = true;
|
||||
state.sharedHostIds.clear();
|
||||
state.paths = [];
|
||||
server = await side(SERVER_USER);
|
||||
state.server = server.ctx;
|
||||
desktops = [await side("desk-a"), await side("desk-b")];
|
||||
@@ -321,60 +317,6 @@ describe("linking a desktop", () => {
|
||||
expect(host.ip).toBe("new");
|
||||
expect(await on(desk, () => listConflicts(desk.userId))).toEqual([]);
|
||||
});
|
||||
|
||||
it("never asks for a cursor past what the server's seq column holds", async () => {
|
||||
const [desk] = desktops;
|
||||
await on(server, () => addHost(server, { name: "srv" }));
|
||||
|
||||
const engine = await linkDesktop(desk);
|
||||
await pass(desk, engine);
|
||||
|
||||
const cursors = state.paths
|
||||
.filter((path) => path.startsWith("/sync/v2/changes?"))
|
||||
.map((path) =>
|
||||
Number(new URLSearchParams(path.split("?")[1]).get("cursor")),
|
||||
);
|
||||
expect(cursors.length).toBeGreaterThan(0);
|
||||
expect(Math.max(...cursors)).toBeLessThanOrEqual(2_147_483_647);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a synced host", () => {
|
||||
it("publishes host.created when it is written for the first time only", async () => {
|
||||
const [desk] = desktops;
|
||||
const seen: unknown[] = [];
|
||||
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
|
||||
seen.push(payload);
|
||||
});
|
||||
const wire = {
|
||||
syncId: "host-new",
|
||||
name: "new",
|
||||
ip: "10.0.0.2",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
};
|
||||
try {
|
||||
await on(desk, async () => {
|
||||
const resolvers = createResolvers(desk.userId);
|
||||
const first = await writeWireRow(
|
||||
getEntity("hosts")!,
|
||||
desk.userId,
|
||||
wire,
|
||||
resolvers,
|
||||
);
|
||||
await writeWireRow(
|
||||
getEntity("hosts")!,
|
||||
desk.userId,
|
||||
{ ...wire, name: "renamed" },
|
||||
resolvers,
|
||||
);
|
||||
expect(seen).toEqual([{ hostId: first.id, userId: desk.userId }]);
|
||||
});
|
||||
} finally {
|
||||
off();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("after linking", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const createMock = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
@@ -60,101 +60,6 @@ describe("logAudit", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("logAudit plugin routine coalescing", () => {
|
||||
const WINDOW_MS = 10 * 60 * 1000;
|
||||
let run = 0;
|
||||
let details = "";
|
||||
|
||||
const routine = (overrides: Record<string, unknown> = {}) =>
|
||||
logAudit({
|
||||
userId: null,
|
||||
username: "plugin:automations",
|
||||
action: "plugin_as_user",
|
||||
resourceType: "plugin",
|
||||
resourceId: "automations",
|
||||
details,
|
||||
success: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
createMock.mockResolvedValue(undefined);
|
||||
vi.useFakeTimers();
|
||||
// The coalescing memory is module state: a fresh key per test keeps them
|
||||
// independent.
|
||||
details = `automations ran background work as user-${++run}`;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("writes one row for identical successful plugin_as_user repeats in the window", async () => {
|
||||
await routine();
|
||||
vi.advanceTimersByTime(WINDOW_MS - 1);
|
||||
await routine();
|
||||
await routine();
|
||||
|
||||
expect(createMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("writes one row for identical successful plugin service calls in the window", async () => {
|
||||
const service = {
|
||||
action: "plugin_service_host_metrics_open",
|
||||
resourceId: "host-metrics",
|
||||
};
|
||||
await routine(service);
|
||||
await routine(service);
|
||||
|
||||
expect(createMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("writes again once the window has passed", async () => {
|
||||
await routine();
|
||||
vi.advanceTimersByTime(WINDOW_MS);
|
||||
await routine();
|
||||
|
||||
expect(createMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("always writes failures", async () => {
|
||||
await routine();
|
||||
await routine({ success: false, errorMessage: "boom" });
|
||||
await routine({ success: false, errorMessage: "boom" });
|
||||
|
||||
expect(createMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("never coalesces non-plugin or non-routine actions", async () => {
|
||||
const login = {
|
||||
userId: "user-1",
|
||||
username: "alice",
|
||||
action: "login",
|
||||
resourceType: "user",
|
||||
};
|
||||
await routine(login);
|
||||
await routine(login);
|
||||
const secretRead = { action: "plugin_secret_shared_read" };
|
||||
await routine(secretRead);
|
||||
await routine(secretRead);
|
||||
const notPlugin = { username: "alice" };
|
||||
await routine(notPlugin);
|
||||
await routine(notPlugin);
|
||||
|
||||
expect(createMock).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
it("does not coalesce entries with different details or resourceId", async () => {
|
||||
await routine();
|
||||
await routine({ details: `${details} (other)` });
|
||||
await routine({ resourceId: "other-plugin" });
|
||||
await routine({ userId: "user-2" });
|
||||
|
||||
expect(createMock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRequestMeta", () => {
|
||||
it("uses the proxy-validated Express IP", () => {
|
||||
const req = {
|
||||
|
||||
@@ -35,49 +35,7 @@ export interface AuditLogParams {
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
// Plugin routine calls (a headless viewer's heartbeat through ctx.asUser and a
|
||||
// service) repeat identically every few seconds; written each time they push
|
||||
// real security entries out of the capped log. Only successful plugin_as_user
|
||||
// and plugin_service_* lines are coalesced; failures and everything else are
|
||||
// always written.
|
||||
const ROUTINE_COALESCE_MS = 10 * 60 * 1000;
|
||||
const ROUTINE_COALESCE_MAX_KEYS = 5000;
|
||||
const routineLastWritten = new Map<string, number>();
|
||||
|
||||
function isCoalescedRoutine(params: AuditLogParams): boolean {
|
||||
if (
|
||||
!params.success ||
|
||||
!params.username.startsWith("plugin:") ||
|
||||
!(
|
||||
params.action === "plugin_as_user" ||
|
||||
params.action.startsWith("plugin_service_")
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const key = JSON.stringify([
|
||||
params.userId,
|
||||
params.username,
|
||||
params.action,
|
||||
params.resourceType,
|
||||
params.resourceId,
|
||||
params.details,
|
||||
]);
|
||||
const now = Date.now();
|
||||
const last = routineLastWritten.get(key);
|
||||
if (last !== undefined && now - last < ROUTINE_COALESCE_MS) return true;
|
||||
if (routineLastWritten.size >= ROUTINE_COALESCE_MAX_KEYS) {
|
||||
for (const [k, at] of routineLastWritten) {
|
||||
if (now - at >= ROUTINE_COALESCE_MS) routineLastWritten.delete(k);
|
||||
}
|
||||
}
|
||||
routineLastWritten.set(key, now);
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function logAudit(params: AuditLogParams): Promise<void> {
|
||||
if (isCoalescedRoutine(params)) return;
|
||||
|
||||
// Local storage is the source of truth and runs first; forwarding is a copy
|
||||
// and must never delay or fail the audited operation.
|
||||
void forwardAuditEntry(params).catch(() => {});
|
||||
|
||||
@@ -15,7 +15,6 @@ import bcrypt from "bcryptjs";
|
||||
import { nanoid } from "nanoid";
|
||||
import { inArray, isNotNull } from "drizzle-orm";
|
||||
import type { ExternalSessionRef } from "../auth/types.js";
|
||||
import { listSecondFactors } from "../auth/registry.js";
|
||||
import type { DeviceType } from "./user-agent-parser.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { sessions } from "../database/db/schema.js";
|
||||
@@ -45,11 +44,6 @@ interface JWTPayload {
|
||||
pendingTOTP?: boolean;
|
||||
/** Set on a short-lived socket ticket, which only a socket upgrade takes. */
|
||||
purpose?: "ws";
|
||||
/**
|
||||
* A plugin minted the session. Read from the session row on verify, never
|
||||
* from the token, so a refreshed token stays marked.
|
||||
*/
|
||||
pluginSession?: boolean;
|
||||
dataKeyWrap?: WrappedDataKey;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
@@ -109,26 +103,6 @@ const IMPERSONATION_PATH_ALLOWLIST = [/^\/host\/db\//, /^\/credentials(\/|$)/];
|
||||
|
||||
const PLUGIN_API_PATH = /^\/plugin-api\/([a-z][a-z0-9-]*)(\/|$)/;
|
||||
|
||||
/** sessions.device_type of a session a plugin minted with ctx.auth.issueUserToken. */
|
||||
export const PLUGIN_SESSION_DEVICE_TYPE = "plugin";
|
||||
|
||||
// Routes that change how a user signs in or mint a credential that outlives
|
||||
// the session. A plugin-minted session is refused on them, and on every
|
||||
// route of a plugin that provides a second factor.
|
||||
const PLUGIN_SESSION_BLOCKLIST = [
|
||||
/^\/users\/(change-password|api-keys|link-external-to-password|unlink-external-from-password|admin\/reset-password)(\/|$)/,
|
||||
/^\/users\/admin\/[^/]+\/second-factors(\/|$)/,
|
||||
];
|
||||
|
||||
/** Whether a session a plugin minted may call this path. */
|
||||
export function allowsPluginSession(path: string): boolean {
|
||||
if (PLUGIN_SESSION_BLOCKLIST.some((pattern) => pattern.test(path))) {
|
||||
return false;
|
||||
}
|
||||
const plugin = PLUGIN_API_PATH.exec(path)?.[1];
|
||||
return !listSecondFactors().some((factor) => factor.pluginId === plugin);
|
||||
}
|
||||
|
||||
let pluginAllowsImpersonation: (pluginId: string) => boolean = () => false;
|
||||
|
||||
/** Set by the plugin runtime: whether a running plugin opted in. */
|
||||
@@ -433,38 +407,6 @@ class AuthManager {
|
||||
} as jwt.SignOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* A session a plugin mints for a user, to call core's API as them. It is
|
||||
* an ordinary session row, so it verifies, expires and shows up like a
|
||||
* login, marked with the plugin and why.
|
||||
*/
|
||||
async issuePluginSession(
|
||||
userId: string,
|
||||
options: { pluginId: string; purpose: string; ttlSeconds: number },
|
||||
): Promise<{ token: string; expiresAt: string }> {
|
||||
const sessionId = nanoid();
|
||||
const token = jwt.sign(
|
||||
{ userId, sessionId } satisfies JWTPayload,
|
||||
await this.systemCrypto.getJWTSecret(),
|
||||
{ expiresIn: options.ttlSeconds },
|
||||
);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(
|
||||
now.getTime() + options.ttlSeconds * 1000,
|
||||
).toISOString();
|
||||
await createCurrentSessionRepository().create({
|
||||
id: sessionId,
|
||||
userId,
|
||||
jwtToken: token,
|
||||
deviceType: PLUGIN_SESSION_DEVICE_TYPE,
|
||||
deviceInfo: `${options.pluginId}: ${options.purpose}`,
|
||||
createdAt: now.toISOString(),
|
||||
expiresAt,
|
||||
lastActiveAt: now.toISOString(),
|
||||
});
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
async verifyJWTToken(
|
||||
token: string,
|
||||
options: { allowSocketTicket?: boolean } = {},
|
||||
@@ -498,8 +440,6 @@ class AuthManager {
|
||||
if (new Date(sessionRecord.expiresAt).getTime() < Date.now()) {
|
||||
return null;
|
||||
}
|
||||
payload.pluginSession =
|
||||
sessionRecord.deviceType === PLUGIN_SESSION_DEVICE_TYPE;
|
||||
|
||||
await this.migrateDataKeyFromPayload(payload);
|
||||
} catch (dbError) {
|
||||
@@ -847,8 +787,6 @@ class AuthManager {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.refusePluginSession(req, res, payload)) return;
|
||||
|
||||
if (payload.sessionId) {
|
||||
try {
|
||||
const sessionRepository = createCurrentSessionRepository();
|
||||
@@ -1015,21 +953,6 @@ class AuthManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Sends the 403 and returns true when a plugin session may not use this route. */
|
||||
private refusePluginSession(
|
||||
req: Request,
|
||||
res: Response,
|
||||
payload: JWTPayload,
|
||||
): boolean {
|
||||
const path = (req.originalUrl || req.url || "").split("?")[0];
|
||||
if (!payload.pluginSession || allowsPluginSession(path)) return false;
|
||||
res.status(403).json({
|
||||
error: "A session a plugin issued cannot use this route",
|
||||
code: "PLUGIN_SESSION_NOT_ALLOWED",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
createDataAccessMiddleware() {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
@@ -1084,8 +1007,6 @@ class AuthManager {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.refusePluginSession(req, res, payload)) return;
|
||||
|
||||
try {
|
||||
const user = await createCurrentUserRepository().findById(
|
||||
payload.userId,
|
||||
|
||||
+2
-27
@@ -6,8 +6,6 @@ import "./ui/index.css";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import "./ui/i18n/i18n";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { isEmbeddedAuthFrame } from "@/lib/embedded-frame";
|
||||
import { rememberViewLink, resumeViewLink } from "@/lib/view-link-resume";
|
||||
import { getEmbeddedServerFailure } from "@/lib/embedded-server-status";
|
||||
import { Toaster } from "@/components/sonner";
|
||||
import {
|
||||
@@ -86,15 +84,6 @@ function FullscreenApp() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A signed-out browser gets the normal login for a full-screen link and comes
|
||||
* back to it afterwards. The desktop app and the embedded sign-in frames
|
||||
* hand a login to their own shell, so they keep the bare view.
|
||||
*/
|
||||
function signsInForViewLink(): boolean {
|
||||
return !isElectron() && !isEmbeddedAuthFrame();
|
||||
}
|
||||
|
||||
function FullscreenAppGate() {
|
||||
const { t } = useTranslation();
|
||||
const [ready, setReady] = useState(false);
|
||||
@@ -120,16 +109,7 @@ function FullscreenAppGate() {
|
||||
if (!cancelled) setReady(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
if (signsInForViewLink()) {
|
||||
rememberViewLink();
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
window.location.pathname,
|
||||
);
|
||||
}
|
||||
setAuthFailed(true);
|
||||
if (!cancelled) setAuthFailed(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -138,7 +118,7 @@ function FullscreenAppGate() {
|
||||
}, []);
|
||||
|
||||
if (authFailed) {
|
||||
return signsInForViewLink() ? <App /> : <FullscreenApp />;
|
||||
return <FullscreenApp />;
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
@@ -201,7 +181,6 @@ function App() {
|
||||
// Non-fatal: WebSocket connections will fall back to cookie auth
|
||||
}
|
||||
}
|
||||
if (resumeViewLink()) return;
|
||||
setPhase("loading-app");
|
||||
await Promise.all([
|
||||
startPluginRuntime()
|
||||
@@ -283,10 +262,6 @@ function App() {
|
||||
function handleLogin(u: string) {
|
||||
loggingOutRef.current = false;
|
||||
clearDesktopManualLogout();
|
||||
if (resumeViewLink()) {
|
||||
setPhase("loading-app");
|
||||
return;
|
||||
}
|
||||
setAuthUsername(u);
|
||||
setPhase("loading-app");
|
||||
void (async () => {
|
||||
|
||||
+14
-9
@@ -125,7 +125,6 @@ import type {
|
||||
} from "@/types/ui-types";
|
||||
import { applyAccentColor, applyFontSize } from "@/lib/theme";
|
||||
import { globalShortcutHandler } from "@/lib/global-shortcut-handler";
|
||||
import { getPaneNavDirection } from "@/lib/pane-nav-hotkey";
|
||||
import { getTabJumpDigit } from "@/lib/tab-jump-hotkey";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import {
|
||||
@@ -830,11 +829,20 @@ export function AppShell({
|
||||
}
|
||||
}
|
||||
|
||||
// Alt+Arrow (Cmd+Option+Arrow on macOS) moves between panes of the active split
|
||||
const paneDirection = getPaneNavDirection(e);
|
||||
if (paneDirection && splitActionsRef.current?.navigate(paneDirection)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
// Alt+Arrow moves between panes of the active split
|
||||
if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) {
|
||||
const direction = (
|
||||
{
|
||||
ArrowLeft: "left",
|
||||
ArrowRight: "right",
|
||||
ArrowUp: "up",
|
||||
ArrowDown: "down",
|
||||
} as Record<string, NavDirection>
|
||||
)[e.code];
|
||||
if (direction && splitActionsRef.current?.navigate(direction)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Cmd+1..9 on macOS, Alt+1..9 elsewhere: jump directly to the tab at that position
|
||||
@@ -3126,9 +3134,6 @@ export function AppShell({
|
||||
{renderSidebarPanels(rightRailView, false)}
|
||||
</DockPanel>
|
||||
)}
|
||||
|
||||
{/* Plugin panels pinned to the right edge, sized by the plugin */}
|
||||
<ComponentSlot slotId="shell.rightEdge" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -20,10 +20,11 @@ interface OriginResolvableHost {
|
||||
* the desktop-wide default.
|
||||
*
|
||||
* `defaultRemote` makes a host left on Default resolve to "remote" instead of
|
||||
* following the desktop-wide setting. Remote desktop passes it when this
|
||||
* desktop is set to use an external guacd, so a host is not moved onto a
|
||||
* guacd that isn't there (see Termix-SSH/Support#1240). With the built-in
|
||||
* engine it runs locally like SSH.
|
||||
* following the desktop-wide setting. Remote desktop passes it: it needs a
|
||||
* guacd, which the desktop does not ship, so originating it here only works
|
||||
* once the user has pointed Termix at one of their own. Making that opt-in
|
||||
* per host keeps an upgrade from moving working connections onto a guacd
|
||||
* that isn't there -- see Termix-SSH/Support#1240.
|
||||
*/
|
||||
export async function resolveConnectionOrigin(
|
||||
host: OriginResolvableHost,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getBasePath } from "./base-path";
|
||||
import { localBackendPort } from "./electron";
|
||||
|
||||
type BrowserLocation = Pick<
|
||||
Location,
|
||||
@@ -17,8 +16,7 @@ export function getDatabaseTransferUrl(
|
||||
{ electron, configuredServerUrl, location }: DatabaseTransferUrlOptions,
|
||||
): string {
|
||||
if (electron) {
|
||||
const serverUrl =
|
||||
configuredServerUrl || `http://localhost:${localBackendPort()}`;
|
||||
const serverUrl = configuredServerUrl || "http://localhost:30001";
|
||||
return `${serverUrl.replace(/\/$/, "")}/database/${operation}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ type ElectronWindow = Window &
|
||||
IS_ELECTRON?: boolean;
|
||||
electronAPI?: {
|
||||
isElectron?: boolean;
|
||||
backendPort?: number;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -17,20 +16,3 @@ export function isElectron(): boolean {
|
||||
|
||||
return hasISElectron || hasElectronAPI || isElectronProp;
|
||||
}
|
||||
|
||||
const DEFAULT_LOCAL_BACKEND_PORT = 30001;
|
||||
|
||||
/**
|
||||
* The port of the desktop app's embedded backend. It moves off 30001 when
|
||||
* another program holds that port.
|
||||
*/
|
||||
export function localBackendPort(): number {
|
||||
if (typeof window === "undefined") return DEFAULT_LOCAL_BACKEND_PORT;
|
||||
const port = (window as ElectronWindow).electronAPI?.backendPort;
|
||||
return typeof port === "number" &&
|
||||
Number.isInteger(port) &&
|
||||
port > 0 &&
|
||||
port < 65536
|
||||
? port
|
||||
: DEFAULT_LOCAL_BACKEND_PORT;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { isMacPlatform } from "./tab-jump-hotkey";
|
||||
|
||||
export type PaneNavDirection = "left" | "right" | "up" | "down";
|
||||
|
||||
const ARROWS: Record<string, PaneNavDirection> = {
|
||||
ArrowLeft: "left",
|
||||
ArrowRight: "right",
|
||||
ArrowUp: "up",
|
||||
ArrowDown: "down",
|
||||
};
|
||||
|
||||
/**
|
||||
* The split pane to move to for this key, if any. Cmd+Option+Arrow on macOS,
|
||||
* where Option+Arrow moves the shell cursor by word, Alt+Arrow elsewhere.
|
||||
*/
|
||||
export function getPaneNavDirection(e: KeyboardEvent): PaneNavDirection | null {
|
||||
const direction = ARROWS[e.code];
|
||||
if (!direction || e.ctrlKey || e.shiftKey || !e.altKey) return null;
|
||||
if (e.metaKey !== isMacPlatform()) return null;
|
||||
return direction;
|
||||
}
|
||||
|
||||
export function paneNavHotkeyLabel(): string {
|
||||
return isMacPlatform() ? "Cmd+Option+Arrows" : "Alt+Arrows";
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
/**
|
||||
* Where a plugin's i18n keys resolve from.
|
||||
*
|
||||
@@ -21,25 +19,3 @@ export function pluginKey(
|
||||
if (!key) return key;
|
||||
return key.includes(":") ? key : `${pluginId}:${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin's name in the UI language: "plugin.name" from its own locales,
|
||||
* else the manifest's. A plugin whose strings are not loaded, like one only
|
||||
* listed in a registry, shows the manifest name.
|
||||
*/
|
||||
export function pluginName(
|
||||
t: TFunction,
|
||||
plugin: { id: string; name: string },
|
||||
): string {
|
||||
return t(pluginKey(plugin.id, "plugin.name"), { defaultValue: plugin.name });
|
||||
}
|
||||
|
||||
/** Same as pluginName, for "plugin.description". */
|
||||
export function pluginDescription(
|
||||
t: TFunction,
|
||||
plugin: { id: string; description?: string | null },
|
||||
): string {
|
||||
return t(pluginKey(plugin.id, "plugin.description"), {
|
||||
defaultValue: plugin.description ?? "",
|
||||
});
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
import type { AxiosInstance } from "axios";
|
||||
import { authApi, createRemoteOriginApiInstance } from "@/main-axios";
|
||||
import { getBasePath } from "@/lib/base-path";
|
||||
import { isElectron, localBackendPort } from "@/lib/electron";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { getDeviceId } from "@/lib/device-id";
|
||||
import { websocketAuthProtocols } from "@/lib/ws-auth";
|
||||
import {
|
||||
@@ -165,7 +165,7 @@ export async function pluginWsUrl(
|
||||
if (isElectron()) {
|
||||
return buildOriginWsUrl({
|
||||
origin: options.origin ?? "local",
|
||||
localPort: localBackendPort(),
|
||||
localPort: BACKEND_PORT,
|
||||
localPath: route,
|
||||
remotePath: route,
|
||||
});
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* Brings a signed-out full-screen `?view=` link back after sign-in. The link
|
||||
* waits in sessionStorage, which survives a redirect login in the same tab,
|
||||
* so no return path is ever sent to the server.
|
||||
*/
|
||||
|
||||
const PENDING_VIEW_LINK_KEY = "termix-pending-view-link";
|
||||
|
||||
/** The value as a same-origin path with a `view` parameter, or null. */
|
||||
export function toViewLinkPath(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
if (!value.startsWith("/") || value.startsWith("//")) return null;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value, window.location.origin);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.origin !== window.location.origin) return null;
|
||||
if (!url.searchParams.get("view")) return null;
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
}
|
||||
|
||||
export function rememberViewLink(): void {
|
||||
const { pathname, search, hash } = window.location;
|
||||
const path = toViewLinkPath(`${pathname}${search}${hash}`);
|
||||
if (!path) return;
|
||||
try {
|
||||
sessionStorage.setItem(PENDING_VIEW_LINK_KEY, path);
|
||||
} catch {
|
||||
// Without storage the user just lands on the app after signing in.
|
||||
}
|
||||
}
|
||||
|
||||
/** The remembered link, cleared whether or not it is still valid. */
|
||||
export function consumeViewLink(): string | null {
|
||||
try {
|
||||
const stored = sessionStorage.getItem(PENDING_VIEW_LINK_KEY);
|
||||
sessionStorage.removeItem(PENDING_VIEW_LINK_KEY);
|
||||
return toViewLinkPath(stored);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the remembered link, if there is one. The navigation waits a tick so
|
||||
* history updates the login callback makes right after sign-in cannot cancel
|
||||
* it.
|
||||
*/
|
||||
export function resumeViewLink(): boolean {
|
||||
const path = consumeViewLink();
|
||||
if (!path) return false;
|
||||
setTimeout(() => window.location.replace(path), 0);
|
||||
return true;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import axios, {
|
||||
import { toast } from "sonner";
|
||||
import i18n from "i18next";
|
||||
import { getBasePath } from "@/lib/base-path";
|
||||
import { isElectron, localBackendPort } from "@/lib/electron";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { clearTermixSessionStorage } from "@/shell/TabContext";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
|
||||
@@ -603,7 +603,7 @@ function getApiUrl(path: string, defaultPort: number): string {
|
||||
if (electronMode) {
|
||||
// The desktop app always talks to its embedded backend. The server it
|
||||
// may be linked to is reached through createRemoteOriginApiInstance.
|
||||
return `http://localhost:${localBackendPort()}${path}`;
|
||||
return `http://localhost:${defaultPort}${path}`;
|
||||
} else if (devMode) {
|
||||
if (!import.meta.env.VITE_API_HOST) {
|
||||
return `/__termix_api/${defaultPort}${path}`;
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
recommendedChoices,
|
||||
} from "../plugin-choices";
|
||||
import { DocsLink } from "@/components/docs-link";
|
||||
import { pluginDescription, pluginName } from "@/lib/plugin-i18n";
|
||||
|
||||
const CATEGORY_ORDER = [
|
||||
"Terminal",
|
||||
@@ -80,8 +79,8 @@ export function PluginsStep({
|
||||
}, [list, choices]);
|
||||
|
||||
const names = useMemo(
|
||||
() => new Map(list?.plugins.map((p) => [p.id, pluginName(t, p)]) ?? []),
|
||||
[list, t],
|
||||
() => new Map(list?.plugins.map((p) => [p.id, p.name]) ?? []),
|
||||
[list],
|
||||
);
|
||||
|
||||
const changed = useMemo(() => {
|
||||
@@ -261,7 +260,7 @@ export function PluginsStep({
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium">
|
||||
{pluginName(t, plugin)}
|
||||
{plugin.name}
|
||||
{plugin.recommended && (
|
||||
<span className="border border-accent-brand/40 px-1 text-[9px] font-semibold uppercase tracking-wider text-accent-brand">
|
||||
{t("onboarding.pluginsRecommended")}
|
||||
@@ -269,7 +268,7 @@ export function PluginsStep({
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[10px] leading-snug text-muted-foreground">
|
||||
{pluginDescription(t, plugin)}
|
||||
{plugin.description}
|
||||
</span>
|
||||
{forcedBy.has(plugin.id) && (
|
||||
<span className="text-[10px] text-accent-brand">
|
||||
@@ -282,7 +281,7 @@ export function PluginsStep({
|
||||
</div>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={pluginName(t, plugin)}
|
||||
aria-label={plugin.name}
|
||||
className="flex shrink-0 border border-border"
|
||||
>
|
||||
{CHOICES.map((option) => (
|
||||
@@ -336,11 +335,9 @@ export function PluginsStep({
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-medium">
|
||||
{pluginName(t, plugin)}
|
||||
</span>
|
||||
<span className="text-xs font-medium">{plugin.name}</span>
|
||||
<span className="text-[10px] leading-snug text-muted-foreground">
|
||||
{pluginDescription(t, plugin)}
|
||||
{plugin.description}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/80">
|
||||
{t("onboarding.pluginsConsentNote")}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { CircleSlash } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/button";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { pluginName } from "@/lib/plugin-i18n";
|
||||
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
|
||||
import { usePluginStore } from "./plugin-store";
|
||||
import { unregisteredViewStatus, type ViewKind } from "./view-ownership";
|
||||
@@ -28,7 +27,7 @@ export function PluginViewPlaceholder({
|
||||
usePluginStore();
|
||||
const { has } = usePermissions();
|
||||
const { status, owner } = unregisteredViewStatus(kind, viewId);
|
||||
const name = owner ? pluginName(t, owner.summary) : viewId;
|
||||
const name = owner?.summary.name ?? viewId;
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from "react";
|
||||
import { scopeHostFields } from "./host-scope";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { pluginName } from "@/lib/plugin-i18n";
|
||||
import { getPluginRecord } from "./plugin-store";
|
||||
|
||||
const PluginScopeContext = createContext<string | null>(null);
|
||||
@@ -28,8 +27,7 @@ function CrashNotice({
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const summary = getPluginRecord(pluginId)?.summary;
|
||||
const name = summary ? pluginName(t, summary) : pluginId;
|
||||
const name = getPluginRecord(pluginId)?.summary.name ?? pluginId;
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 p-4 text-center text-sm text-muted-foreground">
|
||||
<span>{t("plugins.runtime.crashed", { name })}</span>
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
type RegistryListing,
|
||||
} from "@/api/plugins-api";
|
||||
import { useConfirm } from "@/components/surface/surface-scope";
|
||||
import { pluginDescription, pluginName } from "@/lib/plugin-i18n";
|
||||
import { mergePlugins, uploadEntry, type PluginEntry } from "./plugin-model";
|
||||
import type { ConsentRequest } from "./PluginConsentPrompt";
|
||||
|
||||
@@ -110,16 +109,9 @@ export function usePluginsManager() {
|
||||
window.removeEventListener(DEVELOPER_MODE_CHANGED_EVENT, onChange);
|
||||
}, []);
|
||||
|
||||
// Named in the UI language once here, so lists, sorting, search, dialogs
|
||||
// and toasts all show the same name.
|
||||
const plugins = useMemo(
|
||||
() =>
|
||||
mergePlugins(installed, registry?.plugins ?? null).map((plugin) => ({
|
||||
...plugin,
|
||||
name: pluginName(t, plugin),
|
||||
description: pluginDescription(t, plugin),
|
||||
})),
|
||||
[installed, registry, t],
|
||||
() => mergePlugins(installed, registry?.plugins ?? null),
|
||||
[installed, registry],
|
||||
);
|
||||
|
||||
const run = useCallback(
|
||||
|
||||
@@ -11,7 +11,7 @@ import { toast } from "sonner";
|
||||
import { Button } from "@/components/button";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
import { AccordionSection } from "@/sidebar/AdminSettingsShared";
|
||||
import { pluginKey, pluginName } from "@/lib/plugin-i18n";
|
||||
import { pluginKey } from "@/lib/plugin-i18n";
|
||||
import { PluginIcon } from "@/lib/plugin-icon";
|
||||
import {
|
||||
getPluginAdminSettings,
|
||||
@@ -68,7 +68,9 @@ export function FeatureSettingsSection({
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<AccordionSection
|
||||
label={pluginName(t, plugin)}
|
||||
label={t(pluginKey(plugin.id, "plugin.name"), {
|
||||
defaultValue: plugin.name,
|
||||
})}
|
||||
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
@@ -196,7 +198,9 @@ export function FeatureSettingsForm({
|
||||
{!running && (
|
||||
<div className="border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||
{t("settings.featureUnavailable", {
|
||||
name: pluginName(t, plugin),
|
||||
name: t(pluginKey(plugin.id, "plugin.name"), {
|
||||
defaultValue: plugin.name,
|
||||
}),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -15,7 +15,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
import { DocsLink } from "@/components/docs-link";
|
||||
import { PluginIcon } from "@/lib/plugin-icon";
|
||||
import { pluginName } from "@/lib/plugin-i18n";
|
||||
import { type PluginSummary } from "@/api/plugins-api";
|
||||
import { useInstalledPlugins } from "@/hooks/use-installed-plugins";
|
||||
import { usePluginScope } from "@/plugin-host/scope";
|
||||
@@ -142,15 +141,13 @@ function HostPluginSection({
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={pluginName(t, plugin)}
|
||||
title={plugin.name}
|
||||
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
|
||||
action={<DocsLink href={plugin.docs} />}
|
||||
>
|
||||
{!running && (
|
||||
<div className="my-3 border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||
{t("settings.featureUnavailable", {
|
||||
name: pluginName(t, plugin),
|
||||
})}
|
||||
{t("settings.featureUnavailable", { name: plugin.name })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { EmptyState } from "@/components/empty-state";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { reportCoreIssueUrl } from "@/lib/issue-url";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { pluginKey, pluginName } from "@/lib/plugin-i18n";
|
||||
import { pluginKey } from "@/lib/plugin-i18n";
|
||||
import { PluginIcon } from "@/lib/plugin-icon";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { UserPreferences } from "@/main-axios";
|
||||
@@ -121,7 +121,9 @@ export function SettingsTab({
|
||||
id: `plugin:${plugin.id}`,
|
||||
band: "plugins",
|
||||
labelKey: pluginKey(plugin.id, "plugin.name"),
|
||||
label: pluginName(t, plugin),
|
||||
label: t(pluginKey(plugin.id, "plugin.name"), {
|
||||
defaultValue: plugin.name,
|
||||
}),
|
||||
icon: SettingsIcon,
|
||||
pluginId: plugin.id,
|
||||
searchKeys: fields.flatMap((field) =>
|
||||
|
||||
@@ -22,7 +22,6 @@ import { isElectron } from "@/lib/electron";
|
||||
import { isPrerelease } from "@/plugins/plugin-model";
|
||||
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
|
||||
import { docsUrl } from "@/lib/docs";
|
||||
import { pluginName } from "@/lib/plugin-i18n";
|
||||
import { DesktopUpdatesCard } from "./DesktopUpdatesCard";
|
||||
|
||||
const DOCKER_BETA_IMAGE = "ghcr.io/termix-ssh/termix:beta";
|
||||
@@ -263,7 +262,7 @@ function InstanceUpdates() {
|
||||
{betaPlugins.map((plugin) => (
|
||||
<SettingRow
|
||||
key={plugin.id}
|
||||
label={pluginName(t, plugin)}
|
||||
label={plugin.name}
|
||||
description={
|
||||
plugin.channel === "beta"
|
||||
? `v${plugin.version}`
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
*/
|
||||
|
||||
import { PluginIcon } from "@/lib/plugin-icon";
|
||||
import { pluginKey } from "@/lib/plugin-i18n";
|
||||
import {
|
||||
hostEditorSectionList,
|
||||
registerHostEditorSection,
|
||||
@@ -96,7 +95,7 @@ export function syncHostFeatureTabs(plugins: PluginSummary[]): string[] {
|
||||
pluginId: plugin.id,
|
||||
group: placement?.group ?? host.editorGroup ?? "top",
|
||||
order: placement?.order ?? host.editorOrder ?? 100,
|
||||
labelKey: pluginKey(plugin.id, "plugin.name"),
|
||||
labelKey: plugin.name,
|
||||
label: plugin.name,
|
||||
// Nothing but the plugin's host settings, so it works for defaults too.
|
||||
defaults: ownSections.has(plugin.id) ? "only" : true,
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
type SplitPresetId,
|
||||
} from "./split-presets";
|
||||
import { MAX_PANES, type PaneEdge } from "./split-tree";
|
||||
import { paneNavHotkeyLabel } from "@/lib/pane-nav-hotkey";
|
||||
|
||||
function NodeThumb({ node }: { node: LayoutNode }) {
|
||||
if (node.kind === "pane") {
|
||||
@@ -130,7 +129,7 @@ export function SplitLayoutMenu({
|
||||
<div className="flex flex-col gap-1 px-2 pb-1.5 text-xs text-muted-foreground">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span>{t("splitScreen.shortcutNavigate")}</span>
|
||||
<kbd className="font-mono text-[10px]">{paneNavHotkeyLabel()}</kbd>
|
||||
<kbd className="font-mono text-[10px]">Alt+Arrows</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span>{t("splitScreen.zoom")}</span>
|
||||
|
||||
@@ -730,7 +730,7 @@ export function HostEditor({
|
||||
<EditorSection
|
||||
key={id}
|
||||
id={id}
|
||||
label={t(section.labelKey, { defaultValue: section.label })}
|
||||
label={section.label ?? t(section.labelKey)}
|
||||
>
|
||||
<LazySection>
|
||||
<Section
|
||||
|
||||
@@ -45,7 +45,7 @@ export interface HostEditorSectionDef {
|
||||
pluginId?: string;
|
||||
group: "top" | "ssh";
|
||||
labelKey: string;
|
||||
/** Shown when labelKey has no translation. */
|
||||
/** Already-translated label, used instead of labelKey when set. */
|
||||
label?: string;
|
||||
icon?: ComponentType<{ className?: string }>;
|
||||
/** Position among the group's tabs, core ones included. */
|
||||
@@ -88,7 +88,7 @@ function sectionVisible(
|
||||
function mergeByOrder(
|
||||
core: (HostTab & { order: number })[],
|
||||
group: "top" | "ssh",
|
||||
t: (key: string, options?: { defaultValue?: string }) => string,
|
||||
t: (key: string) => string,
|
||||
protocols: Record<string, boolean> | undefined,
|
||||
defaultsMode = false,
|
||||
): HostTab[] {
|
||||
@@ -104,7 +104,7 @@ function mergeByOrder(
|
||||
const Icon = section.icon;
|
||||
return {
|
||||
id: section.id,
|
||||
label: t(section.labelKey, { defaultValue: section.label }),
|
||||
label: section.label ?? t(section.labelKey),
|
||||
icon: Icon ? <Icon className="size-3" /> : null,
|
||||
order: section.order ?? 1000,
|
||||
};
|
||||
@@ -119,7 +119,7 @@ function mergeByOrder(
|
||||
* `visible`; without, every registered top tab is listed.
|
||||
*/
|
||||
export function makeHostTabs(
|
||||
t: (key: string, options?: { defaultValue?: string }) => string,
|
||||
t: (key: string) => string,
|
||||
protocols?: Record<string, boolean>,
|
||||
defaultsMode = false,
|
||||
): HostTab[] {
|
||||
@@ -146,7 +146,7 @@ export function makeHostTabs(
|
||||
}
|
||||
|
||||
export function makeHostSshSubTabs(
|
||||
t: (key: string, options?: { defaultValue?: string }) => string,
|
||||
t: (key: string) => string,
|
||||
protocols?: Record<string, boolean>,
|
||||
defaultsMode = false,
|
||||
): HostTab[] {
|
||||
@@ -167,7 +167,7 @@ export function makeHostSshSubTabs(
|
||||
}
|
||||
|
||||
export function makeCredentialTabs(
|
||||
t: (key: string, options?: { defaultValue?: string }) => string,
|
||||
t: (key: string) => string,
|
||||
): CredentialTab[] {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -55,7 +55,6 @@ import type { Host, HostFolder, TabType } from "@/types/ui-types";
|
||||
import { sortHostTree, type SortKey } from "@/sidebar/host-sort";
|
||||
import { withLiveStatus } from "@/sidebar/live-host-status";
|
||||
import { useServerStatus } from "@/lib/ServerStatusContext";
|
||||
import { pluginName } from "@/lib/plugin-i18n";
|
||||
import { useHostSidebarPreferences } from "@/sidebar/tree/hooks/useHostSidebarPreferences";
|
||||
import { useArrangeLock } from "@/sidebar/use-arrange-lock";
|
||||
import type {
|
||||
@@ -817,7 +816,7 @@ export function HostsPanel({
|
||||
}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{pluginName(t, plugin)}
|
||||
{plugin.name}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
{allTags.length > 0 && (
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import { Button } from "@/components/button";
|
||||
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
|
||||
import { applyPresetSideEffects } from "@/lib/apply-ui-preset";
|
||||
import { pluginName } from "@/lib/plugin-i18n";
|
||||
import { usePluginStore } from "@/plugin-host/plugin-store";
|
||||
import {
|
||||
UI_AREA_KEYS,
|
||||
@@ -68,10 +67,9 @@ export function InterfacePresetSettings({
|
||||
)
|
||||
.map((area) => {
|
||||
const pluginId = area.slice("plugin:".length);
|
||||
const summary = pluginStore.records.get(pluginId)?.summary;
|
||||
return {
|
||||
key: area,
|
||||
label: summary ? pluginName(t, summary) : pluginId,
|
||||
label: pluginStore.records.get(pluginId)?.summary.name ?? pluginId,
|
||||
};
|
||||
});
|
||||
return [...core, ...plugins];
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
import { updatePluginHostSettings } from "@/api/plugins-api";
|
||||
import { resetHostToDefaults } from "@/api/host-defaults-api";
|
||||
import { PluginIcon } from "@/lib/plugin-icon";
|
||||
import { pluginName } from "@/lib/plugin-i18n";
|
||||
import { usePluginHostSections } from "@/settings/HostPluginSections";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
@@ -1285,7 +1284,7 @@ export function SidebarTree({
|
||||
value
|
||||
? "hosts.enablePluginFeature"
|
||||
: "hosts.disablePluginFeature",
|
||||
{ name: pluginName(t, plugin) },
|
||||
{ name: plugin.name },
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)),
|
||||
@@ -1308,7 +1307,7 @@ export function SidebarTree({
|
||||
{ id: "core", label: t("hostDefaults.resetCore") },
|
||||
...hostSettingPlugins.map((plugin) => ({
|
||||
id: plugin.id,
|
||||
label: pluginName(t, plugin),
|
||||
label: plugin.name,
|
||||
})),
|
||||
].map((option) => (
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { localBackendPort } from "@/lib/electron";
|
||||
|
||||
function setElectronAPI(api: unknown) {
|
||||
(window as unknown as { electronAPI?: unknown }).electronAPI = api;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
|
||||
});
|
||||
|
||||
describe("localBackendPort", () => {
|
||||
it("uses the port the desktop app picked", () => {
|
||||
setElectronAPI({ backendPort: 41234 });
|
||||
expect(localBackendPort()).toBe(41234);
|
||||
});
|
||||
|
||||
it("falls back to 30001", () => {
|
||||
expect(localBackendPort()).toBe(30001);
|
||||
setElectronAPI({ backendPort: Number.NaN });
|
||||
expect(localBackendPort()).toBe(30001);
|
||||
setElectronAPI({ backendPort: 70000 });
|
||||
expect(localBackendPort()).toBe(30001);
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getPaneNavDirection,
|
||||
paneNavHotkeyLabel,
|
||||
} from "../../lib/pane-nav-hotkey";
|
||||
|
||||
function keyEvent(init: Partial<KeyboardEvent>): KeyboardEvent {
|
||||
return {
|
||||
code: "ArrowLeft",
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
...init,
|
||||
} as KeyboardEvent;
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("pane-nav-hotkey", () => {
|
||||
it("uses Alt+Arrow outside macOS", () => {
|
||||
vi.stubGlobal("navigator", { platform: "Win32" });
|
||||
expect(getPaneNavDirection(keyEvent({ altKey: true }))).toBe("left");
|
||||
expect(
|
||||
getPaneNavDirection(keyEvent({ code: "ArrowDown", altKey: true })),
|
||||
).toBe("down");
|
||||
expect(
|
||||
getPaneNavDirection(keyEvent({ altKey: true, metaKey: true })),
|
||||
).toBeNull();
|
||||
expect(paneNavHotkeyLabel()).toBe("Alt+Arrows");
|
||||
});
|
||||
|
||||
it("leaves Option+Arrow to the shell on macOS", () => {
|
||||
vi.stubGlobal("navigator", { platform: "MacIntel" });
|
||||
expect(getPaneNavDirection(keyEvent({ altKey: true }))).toBeNull();
|
||||
expect(
|
||||
getPaneNavDirection(
|
||||
keyEvent({ code: "ArrowRight", altKey: true, metaKey: true }),
|
||||
),
|
||||
).toBe("right");
|
||||
expect(paneNavHotkeyLabel()).toBe("Cmd+Option+Arrows");
|
||||
});
|
||||
|
||||
it("ignores other keys and modifiers", () => {
|
||||
vi.stubGlobal("navigator", { platform: "Win32" });
|
||||
expect(
|
||||
getPaneNavDirection(keyEvent({ code: "KeyA", altKey: true })),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getPaneNavDirection(keyEvent({ altKey: true, shiftKey: true })),
|
||||
).toBeNull();
|
||||
expect(getPaneNavDirection(keyEvent({}))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import i18next from "i18next";
|
||||
import { pluginDescription, pluginName } from "@/lib/plugin-i18n";
|
||||
|
||||
async function zhCN() {
|
||||
const i18n = i18next.createInstance();
|
||||
await i18n.init({
|
||||
lng: "zh-CN",
|
||||
fallbackLng: "en",
|
||||
fallbackNS: "translation",
|
||||
resources: {
|
||||
"zh-CN": {
|
||||
translation: { common: { save: "保存" } },
|
||||
"host-metrics": {
|
||||
plugin: { name: "主机指标", description: "查看主机负载" },
|
||||
},
|
||||
"no-description": { plugin: { name: "无描述" } },
|
||||
},
|
||||
},
|
||||
});
|
||||
return i18n.t;
|
||||
}
|
||||
|
||||
describe("pluginName", () => {
|
||||
it("reads the name from the plugin's own namespace", async () => {
|
||||
const t = await zhCN();
|
||||
expect(pluginName(t, { id: "host-metrics", name: "Host Metrics" })).toBe(
|
||||
"主机指标",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the manifest name when the plugin has no strings", async () => {
|
||||
const t = await zhCN();
|
||||
expect(pluginName(t, { id: "not-loaded", name: "Not Loaded" })).toBe(
|
||||
"Not Loaded",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pluginDescription", () => {
|
||||
it("reads the description from the plugin's own namespace", async () => {
|
||||
const t = await zhCN();
|
||||
expect(
|
||||
pluginDescription(t, {
|
||||
id: "host-metrics",
|
||||
description: "See host load",
|
||||
}),
|
||||
).toBe("查看主机负载");
|
||||
});
|
||||
|
||||
it("falls back to the manifest description, or nothing", async () => {
|
||||
const t = await zhCN();
|
||||
expect(
|
||||
pluginDescription(t, { id: "no-description", description: "Plain" }),
|
||||
).toBe("Plain");
|
||||
expect(pluginDescription(t, { id: "not-loaded" })).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { consumeViewLink, toViewLinkPath } from "@/lib/view-link-resume";
|
||||
|
||||
describe("toViewLinkPath", () => {
|
||||
it.each([
|
||||
["/?view=example-plugin.request&request=123", true],
|
||||
["/termix/?view=example-plugin.request#top", true],
|
||||
["//evil.com/?view=x", false],
|
||||
["/\\evil.com/?view=x", false],
|
||||
["https://evil.com/?view=x", false],
|
||||
["/foo", false],
|
||||
["/?view=", false],
|
||||
["not a url", false],
|
||||
])("%s -> %s", (value, accepted) => {
|
||||
expect(toViewLinkPath(value)).toBe(accepted ? value : null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("consumeViewLink", () => {
|
||||
it("clears the stored link even when it is invalid", () => {
|
||||
sessionStorage.setItem("termix-pending-view-link", "//evil.com/?view=x");
|
||||
expect(consumeViewLink()).toBeNull();
|
||||
expect(sessionStorage.getItem("termix-pending-view-link")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const KEY = "termix-pending-view-link";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
rendered: null as ReactNode,
|
||||
isElectron: vi.fn(() => false),
|
||||
getStoredAuth: vi.fn(),
|
||||
getUserInfo: vi.fn(),
|
||||
fetchGuestViews: vi.fn(),
|
||||
startPluginRuntime: vi.fn(async () => {}),
|
||||
standaloneViewFor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-dom/client", () => ({
|
||||
createRoot: () => ({
|
||||
render: (node: ReactNode) => {
|
||||
mocks.rendered = node;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/client-cache-version", () => ({
|
||||
prepareClientCacheVersion: () => Promise.resolve(),
|
||||
}));
|
||||
vi.mock("@/i18n/i18n", () => ({}));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("@/components/theme-provider", () => ({
|
||||
ThemeProvider: ({ children }: { children: ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("@/contexts/BrandingContext", () => ({
|
||||
BrandingProvider: ({ children }: { children: ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("@/contexts/UiPreferencesContext", () => ({
|
||||
UiPreferencesProvider: ({ children }: { children: ReactNode }) => children,
|
||||
}));
|
||||
vi.mock("@/components/sonner", () => ({ Toaster: () => null }));
|
||||
vi.mock("@/hooks/use-service-worker", () => ({ useServiceWorker: () => {} }));
|
||||
vi.mock("@/lib/electron-wheel-zoom", () => ({
|
||||
installElectronWheelZoomGuard: () => {},
|
||||
}));
|
||||
vi.mock("@/lib/electron", () => ({ isElectron: mocks.isElectron }));
|
||||
vi.mock("@/lib/embedded-server-status", () => ({
|
||||
getEmbeddedServerFailure: async () => null,
|
||||
}));
|
||||
vi.mock("@/auth/Auth", () => ({
|
||||
Auth: ({ onLogin }: { onLogin: (username: string) => void }) => (
|
||||
<button onClick={() => onLogin("alice")}>sign in</button>
|
||||
),
|
||||
getStoredAuth: mocks.getStoredAuth,
|
||||
clearStoredAuth: vi.fn(),
|
||||
markDesktopManualLogout: vi.fn(),
|
||||
clearDesktopManualLogout: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/main-axios", () => ({
|
||||
getUserInfo: mocks.getUserInfo,
|
||||
getCurrentToken: vi.fn(async () => null),
|
||||
appReadyPromise: Promise.resolve(),
|
||||
}));
|
||||
vi.mock("@/plugin-host/loader", () => ({
|
||||
fetchGuestViews: mocks.fetchGuestViews,
|
||||
startPluginRuntime: mocks.startPluginRuntime,
|
||||
stopPluginRuntime: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/plugin-host/plugin-store", () => ({
|
||||
settledPromise: async () => {},
|
||||
}));
|
||||
vi.mock("@/plugin-host/shell-bridge", () => ({ resetShellBridge: vi.fn() }));
|
||||
vi.mock("@/hooks/use-permissions", () => ({
|
||||
preloadPermissions: async () => {},
|
||||
}));
|
||||
vi.mock("@/api/open-tabs-api", () => ({
|
||||
getUserPreferences: async () => undefined,
|
||||
}));
|
||||
vi.mock("@/shell/tab-registry", () => ({
|
||||
standaloneViewFor: mocks.standaloneViewFor,
|
||||
}));
|
||||
vi.mock("@/plugin-host/PluginViewPlaceholder", () => ({
|
||||
PluginViewPlaceholder: ({ viewId }: { viewId: string }) => (
|
||||
<div>placeholder:{viewId}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/onboarding/startup", () => ({
|
||||
resolveStartupOnboarding: async () => null,
|
||||
}));
|
||||
vi.mock("@/AppShell", () => ({ AppShell: () => <div>app shell</div> }));
|
||||
vi.mock("@/onboarding/OnboardingStage", () => ({
|
||||
OnboardingStage: () => null,
|
||||
}));
|
||||
|
||||
const originalLocation = window.location;
|
||||
let replace: ReturnType<typeof vi.fn>;
|
||||
|
||||
/** Loads the entry point fresh, the way a page load would. */
|
||||
async function openPage(url: string) {
|
||||
window.history.replaceState({}, "", url);
|
||||
vi.resetModules();
|
||||
mocks.rendered = null;
|
||||
await import("../../main");
|
||||
await vi.waitFor(() => expect(mocks.rendered).not.toBeNull());
|
||||
render(<>{mocks.rendered}</>);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
mocks.isElectron.mockReturnValue(false);
|
||||
mocks.getStoredAuth.mockReturnValue(null);
|
||||
mocks.getUserInfo.mockRejectedValue({ status: 401 });
|
||||
mocks.fetchGuestViews.mockResolvedValue(["example-plugin.guest"]);
|
||||
mocks.standaloneViewFor.mockImplementation((view: string) => ({
|
||||
standalone: () => <div>standalone:{view}</div>,
|
||||
}));
|
||||
replace = vi.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: {
|
||||
get origin() {
|
||||
return originalLocation.origin;
|
||||
},
|
||||
get pathname() {
|
||||
return originalLocation.pathname;
|
||||
},
|
||||
get search() {
|
||||
return originalLocation.search;
|
||||
},
|
||||
get hash() {
|
||||
return originalLocation.hash;
|
||||
},
|
||||
replace,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
});
|
||||
|
||||
describe("full-screen view links", () => {
|
||||
it("shows the login when signed out and returns to the link after it", async () => {
|
||||
await openPage("/?view=example-plugin.request&request=123");
|
||||
|
||||
const signIn = await screen.findByText("sign in");
|
||||
expect(sessionStorage.getItem(KEY)).toBe(
|
||||
"/?view=example-plugin.request&request=123",
|
||||
);
|
||||
fireEvent.click(signIn);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(replace).toHaveBeenCalledWith(
|
||||
"/?view=example-plugin.request&request=123",
|
||||
),
|
||||
);
|
||||
expect(sessionStorage.getItem(KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the bare view in the desktop app", async () => {
|
||||
mocks.isElectron.mockReturnValue(true);
|
||||
mocks.standaloneViewFor.mockReturnValue(undefined);
|
||||
await openPage("/?view=example-plugin.request");
|
||||
|
||||
expect(
|
||||
await screen.findByText("placeholder:example-plugin.request"),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText("sign in")).toBeNull();
|
||||
expect(sessionStorage.getItem(KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("serves a guest view without a session or a login", async () => {
|
||||
await openPage("/?view=example-plugin.guest&room=1");
|
||||
|
||||
expect(
|
||||
await screen.findByText("standalone:example-plugin.guest"),
|
||||
).toBeTruthy();
|
||||
expect(mocks.getUserInfo).not.toHaveBeenCalled();
|
||||
expect(sessionStorage.getItem(KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import i18next from "i18next";
|
||||
import { I18nextProvider, initReactI18next } from "react-i18next";
|
||||
import type { OnboardingPluginList } from "@/api/plugins-api";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
@@ -188,38 +186,4 @@ describe("PluginsStep", () => {
|
||||
expect(ok).toBe(true);
|
||||
expect(api.applyOnboardingPlugins).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows plugin names and descriptions in the UI language", async () => {
|
||||
const i18n = i18next.createInstance();
|
||||
await i18n.use(initReactI18next).init({
|
||||
lng: "zh-CN",
|
||||
fallbackNS: "translation",
|
||||
resources: {
|
||||
"zh-CN": {
|
||||
translation: {},
|
||||
term: { plugin: { name: "终端", description: "终端插件" } },
|
||||
stats: { plugin: { name: "统计" } },
|
||||
},
|
||||
},
|
||||
});
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<PluginsStep
|
||||
mode="full"
|
||||
isAdmin
|
||||
isDesktop={false}
|
||||
shellReady={false}
|
||||
setCanContinue={() => {}}
|
||||
setBeforeNext={() => {}}
|
||||
/>
|
||||
</I18nextProvider>,
|
||||
);
|
||||
expect(await screen.findByText("终端")).toBeTruthy();
|
||||
expect(screen.getByText("终端插件")).toBeTruthy();
|
||||
expect(screen.getByRole("radiogroup", { name: "终端" })).toBeTruthy();
|
||||
expect(screen.getByText("统计")).toBeTruthy();
|
||||
expect(screen.getByText("stats plugin")).toBeTruthy();
|
||||
expect(screen.getByText("BASE")).toBeTruthy();
|
||||
expect(screen.getByText("base plugin")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -35,17 +35,9 @@ vi.mock("@/api/plugins-api", async (importOriginal) => ({
|
||||
vi.mock("@/components/surface/surface-scope", () => ({
|
||||
useConfirm: () => vi.fn(async () => true),
|
||||
}));
|
||||
const i18n = vi.hoisted(() => {
|
||||
const strings: Record<string, string> = {
|
||||
"sample-plugin:plugin.name": "示例",
|
||||
"sample-plugin:plugin.description": "一个示例插件",
|
||||
};
|
||||
return {
|
||||
t: (key: string, options?: { defaultValue?: string }) =>
|
||||
strings[key] ?? options?.defaultValue ?? key,
|
||||
};
|
||||
});
|
||||
vi.mock("react-i18next", () => ({ useTranslation: () => i18n }));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
|
||||
|
||||
import { usePluginsManager } from "@/plugins/use-plugins-manager";
|
||||
@@ -77,33 +69,3 @@ it("waits for local consent and sends the reviewed version and capabilities", as
|
||||
{ version: "1.0.0", capabilities: ["kv:own"] },
|
||||
);
|
||||
});
|
||||
|
||||
it("names plugins in the UI language, falling back to the manifest", async () => {
|
||||
api.getPluginRegistry.mockResolvedValueOnce({
|
||||
plugins: [
|
||||
{
|
||||
id: "browse-only",
|
||||
name: "Browse Only",
|
||||
description: "Not installed",
|
||||
author: "someone",
|
||||
category: "Tools",
|
||||
latestVersion: "1.0.0",
|
||||
versions: [],
|
||||
bundled: false,
|
||||
},
|
||||
],
|
||||
managedByServer: true,
|
||||
registry: {},
|
||||
} as never);
|
||||
const { result } = renderHook(() => usePluginsManager());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(
|
||||
result.current.plugins.map(({ name, description }) => ({
|
||||
name,
|
||||
description,
|
||||
})),
|
||||
).toEqual([
|
||||
{ name: "示例", description: "一个示例插件" },
|
||||
{ name: "Browse Only", description: "Not installed" },
|
||||
]);
|
||||
});
|
||||
@@ -33,10 +33,7 @@ vi.mock("@/api/plugins-api", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { defaultValue?: string }) =>
|
||||
options?.defaultValue ?? key,
|
||||
}),
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
|
||||
@@ -78,10 +75,9 @@ describe("syncHostFeatureTabs", () => {
|
||||
"feature:docker",
|
||||
"feature:warpgate",
|
||||
]);
|
||||
expect(getHostEditorSection(hostFeatureTabId("docker"))).toMatchObject({
|
||||
labelKey: "docker:plugin.name",
|
||||
label: "Docker",
|
||||
});
|
||||
expect(getHostEditorSection(hostFeatureTabId("docker"))?.label).toBe(
|
||||
"Docker",
|
||||
);
|
||||
expect(getHostEditorSection(hostFeatureTabId("warpgate"))).toBeDefined();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user