mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
Compare commits
17
Commits
v26.10.1
...
sdk-v1.0.12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec3cc9cf18 | ||
|
|
4c235bb23f | ||
|
|
b414b85746 | ||
|
|
d049588f7f | ||
|
|
86161d66e2 | ||
|
|
aff29a9581 | ||
|
|
66e8ce6df7 | ||
|
|
3517b2f0ed | ||
|
|
220f5c8676 | ||
|
|
4dfad3efb3 | ||
|
|
e152bacc7c | ||
|
|
8d9c83b0f6 | ||
|
|
2fe7530cd4 | ||
|
|
4389bf2013 | ||
|
|
50f80b975d | ||
|
|
1ccb30f0a7 | ||
|
|
d44a504c04 |
No files matched your search
Binary file not shown.
|
After 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 --publish never
|
||||
run: npm run build && npx electron-builder --win --x64 --ia32 --arm64 --publish never
|
||||
|
||||
# The updater only installs the x64 NSIS build; 32-bit installs get a link.
|
||||
# The combined x64+ia32 installer is never uploaded.
|
||||
# The updater only installs the x64 NSIS build; 32-bit and ARM installs get a link.
|
||||
# The combined multi-arch installer is never uploaded.
|
||||
- name: Fix Windows update files
|
||||
run: node scripts/fix-update-info.cjs --drop ia32 --drop termix_windows_nsis.exe release/latest.yml release/beta.yml release/alpha.yml
|
||||
run: node scripts/fix-update-info.cjs --drop ia32 --drop arm64 --drop termix_windows_nsis.exe release/latest.yml release/beta.yml
|
||||
|
||||
- name: Upload Windows x64 NSIS Installer
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -166,6 +166,14 @@ 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'
|
||||
@@ -182,6 +190,14 @@ 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: |
|
||||
@@ -192,6 +208,11 @@ 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'
|
||||
@@ -219,6 +240,14 @@ 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'
|
||||
@@ -1153,6 +1182,49 @@ jobs:
|
||||
--locales "en-US"
|
||||
echo "metadata_path=$META_DIR" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Apple won't create a new version while the last one is still in
|
||||
# review. Pull it out so deliver can rename it to this version.
|
||||
- name: Cancel pending App Store review
|
||||
if: steps.check_certs.outputs.has_certs == 'true' && steps.check_asc_creds.outputs.has_credentials == 'true'
|
||||
run: |
|
||||
LANE_DIR="$RUNNER_TEMP/asc_cancel/fastlane"
|
||||
mkdir -p "$LANE_DIR"
|
||||
|
||||
cat > "$LANE_DIR/Fastfile" <<'EOF'
|
||||
default_platform(:mac)
|
||||
|
||||
lane :cancel_pending_review do
|
||||
Spaceship::ConnectAPI.token = Spaceship::ConnectAPI::Token.from_json_file("/tmp/asc_keys/api_key.json")
|
||||
app = Spaceship::ConnectAPI::App.find("com.karmaa.termix")
|
||||
UI.user_error!("App not found") unless app
|
||||
|
||||
app.get_app_store_versions(filter: { platform: "MAC_OS" }, limit: 5).each do |v|
|
||||
UI.message("Version #{v.version_string}: #{v.app_version_state || v.app_store_state}")
|
||||
end
|
||||
|
||||
active = %w[WAITING_FOR_REVIEW IN_REVIEW UNRESOLVED_ISSUES]
|
||||
canceled = false
|
||||
app.get_review_submissions(filter: { platform: "MAC_OS" }).each do |sub|
|
||||
UI.message("Review submission #{sub.id}: #{sub.state}")
|
||||
next unless active.include?(sub.state)
|
||||
sub.cancel_submission
|
||||
canceled = true
|
||||
UI.important("Canceled review submission #{sub.id}")
|
||||
end
|
||||
next unless canceled
|
||||
|
||||
30.times do
|
||||
busy = app.get_review_submissions(filter: { platform: "MAC_OS" }).any? do |sub|
|
||||
(active + %w[CANCELING]).include?(sub.state)
|
||||
end
|
||||
break unless busy
|
||||
sleep(10)
|
||||
end
|
||||
end
|
||||
EOF
|
||||
|
||||
cd "$RUNNER_TEMP/asc_cancel" && fastlane cancel_pending_review
|
||||
|
||||
- name: Upload and submit to Mac App Store
|
||||
if: steps.check_certs.outputs.has_certs == 'true' && steps.check_asc_creds.outputs.has_credentials == 'true'
|
||||
run: |
|
||||
|
||||
@@ -11,4 +11,3 @@ db
|
||||
*.min.css
|
||||
openapi.json
|
||||
drizzle/
|
||||
src/ui/settings/settings-search-index.ts
|
||||
@@ -1,5 +1,72 @@
|
||||
# Changelog
|
||||
|
||||
## 26.10.3
|
||||
|
||||
### Added
|
||||
|
||||
- Choose the layout of the whole app in Settings, Appearance. Admins can also set the default layout and theme for everyone
|
||||
- Plugins can add layouts and themes
|
||||
- A repair screen shows when the chosen layout cannot load, with ways to update, switch, reset or turn it off. Open it any time with ?safe-layout=1, or for everyone with TERMIX_SAFE_LAYOUT=1
|
||||
- Hovering a theme in Settings previews it
|
||||
- Restore the bundled copy of a plugin from the repair screen, which also pins the shipped version
|
||||
- Switching layouts closes the tabs only the old layout could show, like its Settings tab
|
||||
- A chosen layout whose plugin was uninstalled falls back to the default instead of the repair screen
|
||||
|
||||
### Changed
|
||||
|
||||
- The icon rail, sidebar, tab bar, dock, Dashboard, Manage, Settings and command palette now come from a bundled layout. It can be swapped for another layout but not removed, and a fix to it no longer needs a Termix release
|
||||
- Your theme is saved to your account so your other devices use it too, unless you keep settings on this device only
|
||||
- The themes besides light and dark now come from a bundled themes plugin. It can be swapped but not removed, and Termix keeps a basic light and dark theme of its own
|
||||
- A theme that no longer exists shows as System instead of an unstyled page
|
||||
|
||||
## 26.10.2
|
||||
|
||||
### Added
|
||||
|
||||
- Hide sidebar after opening, in Customize Sidebar, closes the host list once you open a host
|
||||
- Sort the dashboard's Host Status card by name, IP address or status
|
||||
- Windows on ARM builds of the desktop app (installer, MSI and portable)
|
||||
- Plugins get an event when a host is added
|
||||
- Plugins can start a short-lived session as a user and read the user's language, for tasks that run without a browser
|
||||
- Plugins can pin a panel to the right edge of the window that pushes the page over
|
||||
|
||||
### Changed
|
||||
|
||||
- The Docker Compose files no longer start a guacd container. npm run dev -- --guacd also points remote desktop at the guacd it starts
|
||||
- The side menu now opens over the page when it expands on hover, so open sessions like remote desktop are not resized each time. Turn on Push content when it expands in Settings to get the old behavior back
|
||||
- On macOS, moving between split panes is now Cmd+Option+Arrow, so Option+Arrow can move the cursor by word in a terminal
|
||||
|
||||
### Fixed
|
||||
|
||||
- Picking a section lower down in the host editor, like Warpgate, stuttered on the way and sometimes stopped before it got there
|
||||
- Sorting hosts by IP address put 10.0.0.210 before 10.0.0.3
|
||||
- The local terminal failed to start in the universal macOS build
|
||||
- Hosts shared with you never showed up on a linked desktop
|
||||
- A linked desktop could never turn on an unsigned plugin the server had enabled
|
||||
- Creating, cloning or deleting hosts failed with "no such table: main.p_vault_profiles" after the Vault plugin was removed
|
||||
- Opening a user in Settings, Users blocked the other admin sections until you went back to the user list
|
||||
- A linked desktop could keep showing the same sync conflicts, or delete a local host, when the server could not send its copy
|
||||
- Usernames are now unique
|
||||
- Importing user data keeps foreign keys on
|
||||
- Trusted proxy login checks the address nginx saw, not nginx itself
|
||||
- The desktop login frame only trusts the server's own origin
|
||||
- External login URLs and callback ports are checked on the desktop app
|
||||
- A changed jump host key is no longer accepted without asking
|
||||
- Synced plugins signed only by a key the server sent are no longer turned on by themselves
|
||||
- Two connects to the same host at once could overwrite each other's connection
|
||||
- Indonesian uses the same technical terms everywhere
|
||||
- When the server was down or still starting, the login page said it could not connect to the database and stopped trying. It now says the server is not responding and continues on its own once the server is back
|
||||
- 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
|
||||
|
||||
### Added
|
||||
|
||||
+8
-8
@@ -25,7 +25,7 @@ Termix is a small core plus plugins. This repo is the core: hosts, credentials,
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This builds everything, starts the backend and then the Vite server at `http://localhost:5173/`. Backend changes restart the backend, and plugin changes in `../Termix-Plugins` rebuild and reload that plugin. `npm run dev:electron` does the same with the desktop app, and `npm run dev -- --help` lists the other options.
|
||||
This builds everything, starts the backend and then the Vite server at `http://localhost:5173/`. Backend changes restart the backend, and plugin changes in `../Plugins` rebuild and reload that plugin. `npm run dev:electron` does the same with the desktop app, and `npm run dev -- --help` lists the other options.
|
||||
|
||||
## Useful commands
|
||||
|
||||
@@ -38,21 +38,21 @@ npm run format # format the code with Prettier
|
||||
|
||||
## Making a change
|
||||
|
||||
1. **Fork the repository**: Click "Fork" at the top right of the [repository page](https://github.com/Termix-SSH/Termix).
|
||||
2. **Create a branch**:
|
||||
1. Fork the [repo](https://github.com/Termix-SSH/Termix).
|
||||
2. Create a branch:
|
||||
```sh
|
||||
git checkout -b feature/my-new-feature
|
||||
```
|
||||
3. **Make your changes** and add or update tests for them.
|
||||
4. **Commit your changes**:
|
||||
3. Make your changes and add or update tests for them.
|
||||
4. Commit your changes:
|
||||
```sh
|
||||
git commit -m "feat: add my new feature"
|
||||
```
|
||||
5. **Push to your fork**:
|
||||
5. Push to your fork:
|
||||
```sh
|
||||
git push origin feature/my-new-feature
|
||||
```
|
||||
6. **Open a pull request** against the current `dev-*` branch with a clear description.
|
||||
6. Open a pull request against the current `dev-*` branch with a clear description.
|
||||
|
||||
## Guidelines
|
||||
|
||||
@@ -72,4 +72,4 @@ Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Tem
|
||||
|
||||
## Support
|
||||
|
||||
Bugs and ideas for Termix itself go in [this repo](https://github.com/Termix-SSH/Termix/issues/new/choose). Plugin problems go in that plugin's own `Plugin-<Name>` repo. Please be as detailed as possible, preferably in English. You can also ask in the [Discord](https://discord.gg/jVQGdvHDrf) server.
|
||||
Bugs and ideas for Termix itself go in [this repo](https://github.com/Termix-SSH/Termix/issues/new/choose). Plugin problems go in that plugin's own `Plugin-<Name>` repo. Add as much detail as you can, in English if possible. You can also ask in the [Discord](https://discord.gg/jVQGdvHDrf) server.
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
cask "termix" do
|
||||
version "26.10.0"
|
||||
sha256 "66a76a62fbea3306bdf35b0951e9b1200fd1a084a95b4fee9d40edd6e326e11a"
|
||||
version "26.10.2"
|
||||
sha256 "9df1850b5f75a3c0a1b3876847d46b7b4ff993aa1e50da55f4c88819308ba223"
|
||||
|
||||
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
|
||||
name "Termix"
|
||||
|
||||
@@ -18,36 +18,38 @@
|
||||
<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 />
|
||||
|
||||
## Overview
|
||||
|
||||
Termix is a free, open source, self-hosted platform for managing your servers, built on plugins. The core is small. It keeps your hosts, credentials, users, sharing and sessions. Everything else, like the SSH terminal, remote desktop, file manager, Docker and metrics, is a plugin you can turn on, turn off or remove.
|
||||
Termix is a free, open source, self-hosted server management platform built on plugins. The core keeps your hosts, credentials, users, sharing and sessions. Everything else, like the SSH terminal, remote desktop, file manager, Docker and metrics, is a plugin you can turn on, off or remove.
|
||||
|
||||
It runs on web, desktop and mobile, and it is a self-hosted alternative to Termius that stays free forever.
|
||||
It runs on web, desktop and mobile. Think of it as a free, self-hosted Termius.
|
||||
|
||||
<br />
|
||||
|
||||
## Try the Demo
|
||||
|
||||
Want to see it before you install it? Try the demo at [demo.termix.site](https://demo.termix.site/). Any username and password works.
|
||||
Try it at [demo.termix.site](https://demo.termix.site/). Any username and password works.
|
||||
|
||||
<br />
|
||||
|
||||
## Core
|
||||
|
||||
What every Termix install has, with or without plugins:
|
||||
What every install has, with or without plugins:
|
||||
|
||||
- **Hosts:** Save hosts in nested folders with tags and parent hosts, bulk edit, import and export, and use Quick Connect for one-off connections
|
||||
- **Credentials:** Reuse saved passwords and keys across hosts, with host defaults you can set for the server, a user or a folder
|
||||
- **Sharing and Roles:** Share hosts, credentials and folders with users or roles at four levels: connect, view, edit and manage
|
||||
- **Accounts:** Local accounts, sessions you can see and revoke, trusted devices, API keys and an audit log
|
||||
- **Security:** Passwords, keys and other secrets are encrypted per user, and the database can be encrypted on disk
|
||||
- **Desktop Sync:** The desktop app runs on its own and can link to a Termix server to keep an offline copy of your account
|
||||
- **Interface:** Tabs and split screen, a command palette (double tap left shift), rebindable shortcuts, themes, branding and around 30 languages
|
||||
- **Databases:** SQLite by default, with PostgreSQL and MySQL supported too
|
||||
- Hosts: Save hosts in nested folders with tags and parent hosts, bulk edit, import and export, and use Quick Connect for one-off connections
|
||||
- Credentials: Reuse saved passwords and keys across hosts, with host defaults you can set for the server, a user or a folder
|
||||
- Sharing and Roles: Share hosts, credentials and folders with users or roles at four levels: connect, view, edit and manage
|
||||
- Accounts: Local accounts, sessions you can see and revoke, trusted devices, API keys and an audit log
|
||||
- Security: Passwords, keys and other secrets are encrypted per user, and the database can be encrypted on disk
|
||||
- Desktop Sync: The desktop app runs on its own and can link to a Termix server to keep an offline copy of your account
|
||||
- Interface: Tabs and split screen, a command palette (double tap left shift), rebindable shortcuts, themes, branding and around 30 languages
|
||||
- Databases: SQLite by default, with PostgreSQL and MySQL supported too
|
||||
|
||||
<br />
|
||||
|
||||
@@ -133,9 +135,9 @@ Want to build your own? Start from the [plugin template](https://github.com/Term
|
||||
|
||||
## Installation
|
||||
|
||||
Visit the [Termix Docs](https://docs.termix.site/install) for full install steps on every platform.
|
||||
See the [docs](https://docs.termix.site/install) for install steps on every platform.
|
||||
|
||||
Sample Docker Compose file (you can leave out `guacd` and the network if you don't plan to use the Remote Desktop plugin):
|
||||
Sample Docker Compose file. Remote desktop (RDP, VNC and Telnet) is built in, so nothing else needs to run next to it:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -149,44 +151,23 @@ 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
|
||||
|
||||
You can run Termix on a VPS instead of inside your own network. If Termix runs on the network it manages, an outage takes Termix down with it, right when you need it to fix things. Running it somewhere else keeps it reachable, gives you a static IP and lets you get in from anywhere without a VPN or port forward.
|
||||
If Termix runs on the network it manages, an outage takes it down right when you need it. A VPS keeps it reachable, gives you a static IP and works from anywhere without a VPN or port forward.
|
||||
|
||||
[GINERNET](https://docs.termix.site/install/ginernet) sponsors Termix, and the docs have a step by step guide for their VPS platform.
|
||||
[GINERNET](https://docs.termix.site/install/ginernet) sponsors Termix and the docs have a setup guide for their VPS.
|
||||
|
||||
<br />
|
||||
|
||||
## Donate
|
||||
|
||||
Termix is free and open source with no subscriptions or paid plans. If you find it useful, consider donating to help cover server costs, domains and development time.
|
||||
Termix has no paid plans. Donations cover servers, domains and development time.
|
||||
|
||||
[Donate](https://donate.termix.site/)
|
||||
|
||||
@@ -194,7 +175,7 @@ Termix is free and open source with no subscriptions or paid plans. If you find
|
||||
|
||||
## Sponsors
|
||||
|
||||
Interested in a paid placement to support development? Email [mail@termix.site](mailto:mail@termix.site).
|
||||
For a paid sponsor spot, email [mail@termix.site](mailto:mail@termix.site).
|
||||
|
||||
<!-- SPONSORS:START -->
|
||||
|
||||
@@ -242,7 +223,7 @@ Bugs and ideas for Termix itself (login, hosts, credentials, sharing, sync, the
|
||||
|
||||
Every plugin has its own repo, so a problem with the terminal, file manager, Docker, remote desktop or any other plugin goes in that plugin's repo (see the table above, or use Report an issue on the plugin's page in the app, which fills in your versions for you). Not sure where it goes? Open it here and it will be moved.
|
||||
|
||||
You need to be logged in to GitHub. Please be as detailed as possible, preferably in English.
|
||||
You need a GitHub account. Add as much detail as you can, in English if possible.
|
||||
|
||||
For discussions and questions, join the [Discord](https://discord.gg/jVQGdvHDrf) server. Update videos are on [YouTube](https://www.youtube.com/@TermixSSH/videos).
|
||||
|
||||
@@ -250,4 +231,4 @@ For discussions and questions, join the [Discord](https://discord.gg/jVQGdvHDrf)
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the Apache License Version 2.0. See [LICENSE](LICENSE) for more information.
|
||||
Apache License 2.0. See [LICENSE](LICENSE).
|
||||
+5
-2
@@ -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 && \
|
||||
NODE_OPTIONS="--max-old-space-size=3072" npm run build
|
||||
TERMIX_RUNTIME_BASE_PATH=true NODE_OPTIONS="--max-old-space-size=3072" npm run build
|
||||
|
||||
# Stage 3: Build backend
|
||||
FROM deps AS backend-builder
|
||||
@@ -74,12 +74,15 @@ COPY vendor ./vendor
|
||||
|
||||
COPY scripts/patch-ssh2-agent.cjs ./scripts/
|
||||
|
||||
# ssh2's native crypto crashes Node 24 when a cipher is freed, so ssh2 uses
|
||||
# OpenSSL instead (chacha20-poly1305 is then left out).
|
||||
RUN npm ci --omit=dev --ignore-scripts && \
|
||||
node scripts/patch-ssh2-agent.cjs && \
|
||||
rm -rf node_modules/better-sqlite3/prebuilds && \
|
||||
npm run build-release --prefix node_modules/better-sqlite3 && \
|
||||
test -f node_modules/better-sqlite3/build/Release/better_sqlite3.node && \
|
||||
npm rebuild bcryptjs ssh2 && \
|
||||
npm rebuild bcryptjs && \
|
||||
rm -rf node_modules/ssh2/lib/protocol/crypto/build && \
|
||||
npm cache clean --force
|
||||
|
||||
# Stage 6: Final optimized image
|
||||
|
||||
+82
-68
@@ -3,20 +3,20 @@
|
||||
{
|
||||
"id": "acme-ssl",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.1/acme-ssl-1.0.1.tmxplug",
|
||||
"sha256": "cb5b7c56a281272cfff229d01a2729a4fa3936de04c0ac487d65bcf8dbd2f38e"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.2/acme-ssl-1.0.2.tmxplug",
|
||||
"sha256": "157d0f96dc42136ff64e618b6904db7862079cd31c1f1cf5f19846b1d127b00f"
|
||||
},
|
||||
{
|
||||
"id": "ai",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.0.1/ai-1.0.1.tmxplug",
|
||||
"sha256": "2558c91e3e6823b3e18873d7b074762c4d76a1a0b96ff38d2dad13e0404b74a7"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.1.0/ai-1.1.0.tmxplug",
|
||||
"sha256": "71dda6940d62c9eede7821bbac21e99443fbc08728a47759f215e2416f1d162f"
|
||||
},
|
||||
{
|
||||
"id": "alerts",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.1/alerts-1.0.1.tmxplug",
|
||||
"sha256": "f5f233c0ffdeee956e1839a17a323c18ce8eb389a08569c3c6e5e24b698dd72a",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.2/alerts-1.0.2.tmxplug",
|
||||
"sha256": "50768e77019276343a5d162c85d081659fb89d1cc3733ac45cd0db4436918faa",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -25,14 +25,28 @@
|
||||
{
|
||||
"id": "automations",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.1/automations-1.0.1.tmxplug",
|
||||
"sha256": "d2bb5298f8afac3f479ded81d5b1a799d9844b3f4990c8aaf2dd8fed7222428d"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.2/automations-1.0.2.tmxplug",
|
||||
"sha256": "674eb04276b4a0c77c794b30647f8680218a78de8d52b73e99ec900cc1b3fdb3"
|
||||
},
|
||||
{
|
||||
"id": "classic-layout",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.0/classic-layout-1.0.0.tmxplug",
|
||||
"sha256": "3a51cc9a4c966478f2ebfd58dbbe025ff0ebe8bbdcd3ba73156151e555f62dec",
|
||||
"defaultLook": true
|
||||
},
|
||||
{
|
||||
"id": "classic-themes",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Classic-Themes/releases/download/v1.0.0/classic-themes-1.0.0.tmxplug",
|
||||
"sha256": "17561e569811476ea4e1f7cdf8d9afbe33edf061bd09f6b05699b44968da41ad",
|
||||
"defaultLook": true
|
||||
},
|
||||
{
|
||||
"id": "docker",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.1/docker-1.0.1.tmxplug",
|
||||
"sha256": "00e170e61ea9b7952449ea9ce30b09b983433f8d18d29f523cf3c6f98e7286e3",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.2/docker-1.0.2.tmxplug",
|
||||
"sha256": "0e1c722682faac6a027fd9b01e6910eb53ce5acb13332c6c8d8868554f3701ab",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -41,8 +55,8 @@
|
||||
{
|
||||
"id": "file-manager",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.2/file-manager-1.0.2.tmxplug",
|
||||
"sha256": "4c735f5b795a8c676590a0dc25db1bcc6036ac250b9817a61867cebdc17931c6",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.3/file-manager-1.0.3.tmxplug",
|
||||
"sha256": "250e9779a6b1caf11721f1ee2baf76655ae6e52d963e894d8db84eb9677cf0b3",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -51,20 +65,20 @@
|
||||
{
|
||||
"id": "fleets",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.1/fleets-1.0.1.tmxplug",
|
||||
"sha256": "82ac71e69022c4991570e6fed109a9eec79421f081af0a1dcb6d8b8e4251ee3b"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.2/fleets-1.0.2.tmxplug",
|
||||
"sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59"
|
||||
},
|
||||
{
|
||||
"id": "homepage",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.1/homepage-1.0.1.tmxplug",
|
||||
"sha256": "573375726f334e37bbf504d9411c4ad2d4bfc4363219ff10dcea6cb2cb00ed32"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.2/homepage-1.0.2.tmxplug",
|
||||
"sha256": "88cdea8ba8f458fcf124e7ab97e3d419896d8d22ffa9a372af34f1818872975b"
|
||||
},
|
||||
{
|
||||
"id": "host-metrics",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.2/host-metrics-1.0.2.tmxplug",
|
||||
"sha256": "433d5297e2fc263f2262b7ea33d7a795c9a970089aea328077861c3f032a23f5",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.3/host-metrics-1.0.3.tmxplug",
|
||||
"sha256": "8f6022568349fc4318d509781bdb9d5b40973c010a9e27f720eed6baf68b881e",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -73,32 +87,32 @@
|
||||
{
|
||||
"id": "ldap",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.1/ldap-1.0.1.tmxplug",
|
||||
"sha256": "f5ba13c682d11060f373f85a9c7aaa131e587ec59c0498c0dabb013c2e474f5c"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.2/ldap-1.0.2.tmxplug",
|
||||
"sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03"
|
||||
},
|
||||
{
|
||||
"id": "network-topology",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.1/network-topology-1.0.1.tmxplug",
|
||||
"sha256": "4c8ec1ae0c39b8b72a89e0dce4b20a36df4adc826abe215b8ee9b69b44fe75ca"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.2/network-topology-1.0.2.tmxplug",
|
||||
"sha256": "8d32340a0d0f9674b872da5d3d53e90f529b80dfe0d73ab294eed00e565fbb56"
|
||||
},
|
||||
{
|
||||
"id": "opkssh",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.1/opkssh-1.0.1.tmxplug",
|
||||
"sha256": "c19eadfe763e4dbecbd9a5e9e753576c577c75296ddc8e0af4cce98c2376b26f"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.2/opkssh-1.0.2.tmxplug",
|
||||
"sha256": "4bb8f1ac5d39bd18ac3d0fe82ddbf2f26ab4c01bd75e6f7a073d65c2e9aef219"
|
||||
},
|
||||
{
|
||||
"id": "proxmox",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.1/proxmox-1.0.1.tmxplug",
|
||||
"sha256": "4e0989856e061b9ab1ec25cc727051e03ea5e3dc1603e08895bd54e7ce132db7"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.2/proxmox-1.0.2.tmxplug",
|
||||
"sha256": "b92be2644b0c8121732103b922069a6cb3cf00caad4e6549f7a8cb471bda5e4e"
|
||||
},
|
||||
{
|
||||
"id": "remote-desktop",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.0.2/remote-desktop-1.0.2.tmxplug",
|
||||
"sha256": "70823a3f5161f6722fee22a9d5fe274dd47623960bc2188429c39a1c7e3e2b39",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.1.0/remote-desktop-1.1.0.tmxplug",
|
||||
"sha256": "6f74f1d26f91a43b2d2203417d744211f366c335d5fff7bed34bfce33874dd01",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -107,14 +121,14 @@
|
||||
{
|
||||
"id": "secret-sources",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.1/secret-sources-1.0.1.tmxplug",
|
||||
"sha256": "9f5246325d3128c6376962f4cdbed33e78c487a4837f69235dfdcafeb293229a"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.2/secret-sources-1.0.2.tmxplug",
|
||||
"sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134"
|
||||
},
|
||||
{
|
||||
"id": "serial",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.1/serial-1.0.1.tmxplug",
|
||||
"sha256": "9908467fc184e40f108ff750bf2fb8044e000a436f84d0a7121109f4fcd5d121",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.2/serial-1.0.2.tmxplug",
|
||||
"sha256": "6ede7e8726a7312429473f34a46f93ba6657c5b7ebc12bed18f80ec62c211715",
|
||||
"onboarding": {
|
||||
"recommended": "desktop",
|
||||
"consent": false
|
||||
@@ -123,20 +137,20 @@
|
||||
{
|
||||
"id": "session-recording",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.1/session-recording-1.0.1.tmxplug",
|
||||
"sha256": "45a08f950244c862a3774f9a653b3e3645285edb507a49b5c8da123832faf1e7"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.2/session-recording-1.0.2.tmxplug",
|
||||
"sha256": "55ace6897710ce9a63f24e3d736a9e533cac557be0f3d98974956c94cf417d1c"
|
||||
},
|
||||
{
|
||||
"id": "session-sharing",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.1/session-sharing-1.0.1.tmxplug",
|
||||
"sha256": "753aa9c0998f194ccf5fc49fd39c133a87f21f21e803014c4bfac6ccc7f9acb1"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.2/session-sharing-1.0.2.tmxplug",
|
||||
"sha256": "6408e7949a9549ac5ec94b35205729099138e730df1598d50f4bb618d8d90804"
|
||||
},
|
||||
{
|
||||
"id": "snippets",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.1/snippets-1.0.1.tmxplug",
|
||||
"sha256": "bf643828beba86677220d802c0df74b441d7954538637f37413f78d59c08585f",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.2/snippets-1.0.2.tmxplug",
|
||||
"sha256": "cc1f5d74cf97f9a9c8f800fccf481c8a000e6fa516bc260ce24a7249d4f4c8e5",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -145,8 +159,8 @@
|
||||
{
|
||||
"id": "ssh-terminal",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.2/ssh-terminal-1.0.2.tmxplug",
|
||||
"sha256": "e65102a8cd6afdaa890095fa698f3f455a1035729b9fdca800a31cc6e5ed760f",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.3/ssh-terminal-1.0.3.tmxplug",
|
||||
"sha256": "bc288bd843e37b64bcda7462d175039147b75dc6de8866769e4a938036a9d023",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -155,8 +169,8 @@
|
||||
{
|
||||
"id": "sso",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.2/sso-1.0.2.tmxplug",
|
||||
"sha256": "29ab0e4748ee71cb11f9b8dacd630430d284b6ce24561cffae9b4d7fe64e9c3b",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.3/sso-1.0.3.tmxplug",
|
||||
"sha256": "87b03c0789d981dbc806162d73dfcc2c624150375277efd2ae40ab9d7657d48e",
|
||||
"onboarding": {
|
||||
"recommended": false,
|
||||
"consent": false,
|
||||
@@ -166,20 +180,20 @@
|
||||
{
|
||||
"id": "step-ca",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.1/step-ca-1.0.1.tmxplug",
|
||||
"sha256": "ff4df5bae8c48ff61fe659b6ed69a40ac070862bc423972711a194d558d77cc8"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.2/step-ca-1.0.2.tmxplug",
|
||||
"sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a"
|
||||
},
|
||||
{
|
||||
"id": "tailscale",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.1/tailscale-1.0.1.tmxplug",
|
||||
"sha256": "0efe1cbf0f431763611926b6a9e92630ca8c1a89ea57bb796c5d7bd3ce0ab55f"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.2/tailscale-1.0.2.tmxplug",
|
||||
"sha256": "5c7b00d6b6b0186d833302e39a9d31b92ea92d4542e9e2dab550b95837a59d97"
|
||||
},
|
||||
{
|
||||
"id": "telemetry",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.1/telemetry-1.0.1.tmxplug",
|
||||
"sha256": "c7f70e149c67a570f2b004c2cceeba27ffe8cbd95cd4df4bbffd880cc3451469",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.2/telemetry-1.0.2.tmxplug",
|
||||
"sha256": "5ffdc58b05cd8b37fd0a207f99d2d3a256164130c950032501c499f869edb9ec",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": true
|
||||
@@ -188,20 +202,20 @@
|
||||
{
|
||||
"id": "termix-identity",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.1/termix-identity-1.0.1.tmxplug",
|
||||
"sha256": "dc423ec53f012fdcc2cdccd872ac01353025e4da2ee9fea0d2a2d5988518a5a0"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.2/termix-identity-1.0.2.tmxplug",
|
||||
"sha256": "ac6c90a48b4f1e9cff6773a5fd267b2bf7319d2fc7e2aa2cd4004cec82f0f927"
|
||||
},
|
||||
{
|
||||
"id": "tmux-monitor",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.2/tmux-monitor-1.0.2.tmxplug",
|
||||
"sha256": "ea41f1f90bdeab6164cfa2a594f359492afa123ed06990afa338be69a9d974c6"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.3/tmux-monitor-1.0.3.tmxplug",
|
||||
"sha256": "f152d64f433d3f1de7c3fdf172e752ea7e333909f0fdda77336a6b8dddb9f242"
|
||||
},
|
||||
{
|
||||
"id": "totp",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.1/totp-1.0.1.tmxplug",
|
||||
"sha256": "4a083871663c2f6034904bd49bb02c2d4dd4ed4d50fbce1522172dae84991474",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.2/totp-1.0.2.tmxplug",
|
||||
"sha256": "94a0772660fa8e4dfd9afc15b68d5bc9fe02fe28a1acfd35e65a57c304d460c0",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -210,8 +224,8 @@
|
||||
{
|
||||
"id": "tunnels",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.2/tunnels-1.0.2.tmxplug",
|
||||
"sha256": "44799c0bdbcc576fb0e1c1ecce96f283d2cc32b44ac5aeeb7253f8ca0b650860",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.3/tunnels-1.0.3.tmxplug",
|
||||
"sha256": "d0de9e59ccc9e1a0f2817dd1ec9951021f2d7e89304894bb1e8cefd65aebff0b",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -220,32 +234,32 @@
|
||||
{
|
||||
"id": "vault",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.1/vault-1.0.1.tmxplug",
|
||||
"sha256": "c828eeebe37bf576020908e16a28e73073fc77178062b323058624f557a8c72a"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.2/vault-1.0.2.tmxplug",
|
||||
"sha256": "48ef402d030912a836489edf0fdfcaf7929cbd67c21f16a2079cb4925134cc28"
|
||||
},
|
||||
{
|
||||
"id": "wake-on-lan",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.1/wake-on-lan-1.0.1.tmxplug",
|
||||
"sha256": "5301d26138cb37812e8ef1fc5d3df688415517d545667b365fca2f41166a8402"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.2/wake-on-lan-1.0.2.tmxplug",
|
||||
"sha256": "3e9cf9a133b4dd96ec5f2b9fbf2d6a3ff7e08f8dfbf1e86b8d45de0c2f024f42"
|
||||
},
|
||||
{
|
||||
"id": "warpgate",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.1/warpgate-1.0.1.tmxplug",
|
||||
"sha256": "2f3b3169101ff04d0ea6229b31270ba0b5b8ff2c3d16e3f21a9e6c569d463ea8"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.2/warpgate-1.0.2.tmxplug",
|
||||
"sha256": "5c2c1c9aef40dbbf98424d02303f2ee77e6e869334247881e8aa0fcd8c415b47"
|
||||
},
|
||||
{
|
||||
"id": "web-endpoint",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.1/web-endpoint-1.0.1.tmxplug",
|
||||
"sha256": "88477231ab3c93206c99393c6cc8628c1ff8159af6978d0d09db969c28e1d637"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.2/web-endpoint-1.0.2.tmxplug",
|
||||
"sha256": "0326edb1052d1debd083004c2dd1711d21a902cfacb55ce74d1a8856a5122477"
|
||||
},
|
||||
{
|
||||
"id": "webauthn",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.1/webauthn-1.0.1.tmxplug",
|
||||
"sha256": "f9ccd5d6fe110b5c7ad8b90fd95e0a201e03d440462c2c3f6559406222364464",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.2/webauthn-1.0.2.tmxplug",
|
||||
"sha256": "8269f6e407897332648c8526b65383c4db071ea782bf1e58aa7c7c00627f3f4b",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -254,8 +268,8 @@
|
||||
{
|
||||
"id": "workspaces",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.1/workspaces-1.0.1.tmxplug",
|
||||
"sha256": "17f53044318d6f53ef1e332d204f6469c7043fd9985c4d1eae16b6a1c3248153",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.2/workspaces-1.0.2.tmxplug",
|
||||
"sha256": "97519a01fbb2eb2eecf21d7b9ab9028c740457bcff9fc6b075fb6facbc5ccb6b",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
|
||||
@@ -12,24 +12,9 @@ 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,12 +9,6 @@ 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.
|
||||
@@ -28,21 +22,14 @@ services:
|
||||
# Trusted reverse-proxy authentication is disabled by default. When
|
||||
# enabled, do not expose this container directly to untrusted clients.
|
||||
# TRUSTED_PROXY_AUTH_ENABLED: "true"
|
||||
# TRUSTED_PROXY_AUTH_TRUSTED_PROXIES: "172.16.0.0/12"
|
||||
# List the address your authenticating proxy connects from, as this
|
||||
# container sees it (for a proxy container on termix-net, its IP there).
|
||||
# Keep it narrow: published ports can reach the container from the
|
||||
# Docker gateway, so do not trust whole private ranges.
|
||||
# TRUSTED_PROXY_AUTH_TRUSTED_PROXIES: "172.20.0.10"
|
||||
# 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
|
||||
|
||||
|
||||
@@ -200,6 +200,9 @@ http {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
# The TCP peer before set_real_ip_from rewrites $remote_addr from
|
||||
# X-Forwarded-For; trusted proxy login checks this address.
|
||||
proxy_set_header X-Termix-Proxy-Peer $realip_remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||
@@ -473,9 +476,12 @@ http {
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
# Only nginx's own errors land here (the backend is down or too slow).
|
||||
# There is no 50x page, so answer directly instead of a 404.
|
||||
location = /50x.html {
|
||||
root /app/html;
|
||||
internal;
|
||||
default_type application/json;
|
||||
return 502 '{"error":"The Termix server is not responding"}';
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -169,6 +169,9 @@ http {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
# The TCP peer before set_real_ip_from rewrites $remote_addr from
|
||||
# X-Forwarded-For; trusted proxy login checks this address.
|
||||
proxy_set_header X-Termix-Proxy-Peer $realip_remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||
@@ -442,9 +445,12 @@ http {
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
# Only nginx's own errors land here (the backend is down or too slow).
|
||||
# There is no 50x page, so answer directly instead of a 404.
|
||||
location = /50x.html {
|
||||
root /app/html;
|
||||
internal;
|
||||
default_type application/json;
|
||||
return 502 '{"error":"The Termix server is not responding"}';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Choose an unused prefix from N + 1 disjoint prefixes for N existing users.
|
||||
WITH ranked AS (
|
||||
SELECT id, username,
|
||||
ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence,
|
||||
ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence
|
||||
FROM users
|
||||
), candidates AS (
|
||||
SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked
|
||||
), available AS (
|
||||
SELECT MIN(candidate) AS candidate FROM candidates
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM ranked WHERE username LIKE CONCAT(CONCAT('termix-recovered-', candidate, '-'), '%')
|
||||
)
|
||||
)
|
||||
UPDATE users JOIN ranked ON ranked.id = users.id CROSS JOIN available
|
||||
SET users.username = CONCAT('termix-recovered-', available.candidate, '-', ranked.sequence)
|
||||
WHERE ranked.occurrence > 1;--> statement-breakpoint
|
||||
ALTER TABLE `users` MODIFY COLUMN `username` varchar(255) NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `users` ADD CONSTRAINT `idx_users_username` UNIQUE(`username`);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `user_preferences` ADD `layout_id` text;--> statement-breakpoint
|
||||
ALTER TABLE `user_preferences` ADD `theme_id` text;
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -505,6 +505,20 @@
|
||||
"when": 1791755801066,
|
||||
"tag": "0071_drop_install_count_source",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 72,
|
||||
"version": "5",
|
||||
"when": 1791935283211,
|
||||
"tag": "0072_unique_username",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 73,
|
||||
"version": "5",
|
||||
"when": 1792357129912,
|
||||
"tag": "0073_user_look_preferences",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Choose an unused prefix from N + 1 disjoint prefixes for N existing users.
|
||||
WITH ranked AS MATERIALIZED (
|
||||
SELECT id, username,
|
||||
ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence,
|
||||
ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence
|
||||
FROM users
|
||||
), candidates AS (
|
||||
SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked
|
||||
), available AS (
|
||||
SELECT MIN(candidate) AS candidate FROM candidates
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM ranked WHERE username LIKE 'termix-recovered-' || candidate || '-' || '%'
|
||||
)
|
||||
)
|
||||
UPDATE users SET username = 'termix-recovered-' || (SELECT candidate FROM available) || '-' || ranked.sequence
|
||||
FROM ranked WHERE ranked.id = users.id AND ranked.occurrence > 1;--> statement-breakpoint
|
||||
ALTER TABLE "users" ALTER COLUMN "username" SET DATA TYPE varchar(255);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_users_username" ON "users" USING btree ("username");
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "user_preferences" ADD COLUMN "layout_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "user_preferences" ADD COLUMN "theme_id" text;
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -505,6 +505,20 @@
|
||||
"when": 1791755800419,
|
||||
"tag": "0071_drop_install_count_source",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 72,
|
||||
"version": "7",
|
||||
"when": 1791935282340,
|
||||
"tag": "0072_unique_username",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 73,
|
||||
"version": "7",
|
||||
"when": 1792357128746,
|
||||
"tag": "0073_user_look_preferences",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Choose an unused prefix from N + 1 disjoint prefixes for N existing users.
|
||||
WITH ranked AS MATERIALIZED (
|
||||
SELECT id, username,
|
||||
ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence,
|
||||
ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence
|
||||
FROM users
|
||||
), candidates AS (
|
||||
SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked
|
||||
), available AS (
|
||||
SELECT MIN(candidate) AS candidate FROM candidates
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM ranked WHERE username LIKE 'termix-recovered-' || candidate || '-' || '%'
|
||||
)
|
||||
)
|
||||
UPDATE users SET username = 'termix-recovered-' || (SELECT candidate FROM available) || '-' || ranked.sequence
|
||||
FROM ranked WHERE ranked.id = users.id AND ranked.occurrence > 1;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `idx_users_username` ON `users` (`username`);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `user_preferences` ADD `layout_id` text;--> statement-breakpoint
|
||||
ALTER TABLE `user_preferences` ADD `theme_id` text;
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -470,6 +470,20 @@
|
||||
"when": 1791755799726,
|
||||
"tag": "0066_drop_install_count_source",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 67,
|
||||
"version": "6",
|
||||
"when": 1791935281343,
|
||||
"tag": "0067_unique_username",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 68,
|
||||
"version": "6",
|
||||
"when": 1792357127581,
|
||||
"tag": "0068_user_look_preferences",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -46,11 +46,11 @@
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": ["x64", "ia32"]
|
||||
"arch": ["x64", "ia32", "arm64"]
|
||||
},
|
||||
{
|
||||
"target": "msi",
|
||||
"arch": ["x64", "ia32"]
|
||||
"arch": ["x64", "ia32", "arm64"]
|
||||
}
|
||||
],
|
||||
"icon": "public/icon.ico",
|
||||
@@ -156,6 +156,6 @@
|
||||
"NSAppleEventsUsageDescription": "Termix needs access to control other applications for terminal operations."
|
||||
}
|
||||
},
|
||||
"generateUpdatesFilesForAllChannels": true,
|
||||
"generateUpdatesFilesForAllChannels": false,
|
||||
"msiProjectCreated": "packaging/build/msi-shortcuts.cjs"
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
const path = require("path");
|
||||
|
||||
// Saved by the file manager after the user picks an editor in a file dialog.
|
||||
const EXTERNAL_EDITOR_SETTING = "fileManager.externalEditorPath";
|
||||
|
||||
// Extensions the OS would run rather than open in an editor. With no editor
|
||||
// configured the temp copy goes to shell.openPath, and its name comes from
|
||||
// the remote server.
|
||||
const LAUNCHABLE_EXTENSIONS = new Set([
|
||||
// Windows
|
||||
"application",
|
||||
"appref-ms",
|
||||
"bat",
|
||||
"chm",
|
||||
"cmd",
|
||||
"com",
|
||||
"cpl",
|
||||
"exe",
|
||||
"gadget",
|
||||
"hta",
|
||||
"inf",
|
||||
"jar",
|
||||
"js",
|
||||
"jse",
|
||||
"library-ms",
|
||||
"lnk",
|
||||
"msc",
|
||||
"msi",
|
||||
"msp",
|
||||
"mst",
|
||||
"pif",
|
||||
"ps1",
|
||||
"psd1",
|
||||
"psm1",
|
||||
"py",
|
||||
"pyw",
|
||||
"reg",
|
||||
"scf",
|
||||
"scr",
|
||||
"search-ms",
|
||||
"settingcontent-ms",
|
||||
"url",
|
||||
"vbe",
|
||||
"vbs",
|
||||
"ws",
|
||||
"wsf",
|
||||
"wsh",
|
||||
"xll",
|
||||
// macOS
|
||||
"app",
|
||||
"command",
|
||||
"pkg",
|
||||
"terminal",
|
||||
"tool",
|
||||
"workflow",
|
||||
// Linux
|
||||
"appimage",
|
||||
"desktop",
|
||||
"run",
|
||||
"sh",
|
||||
]);
|
||||
|
||||
function isLaunchableFileName(fileName) {
|
||||
// Windows drops trailing dots and spaces, so "a.exe. " is saved as a.exe.
|
||||
const name = path.basename(String(fileName || "")).replace(/[. ]+$/, "");
|
||||
const extension = path.extname(name).slice(1).toLowerCase();
|
||||
return LAUNCHABLE_EXTENSIONS.has(extension);
|
||||
}
|
||||
|
||||
module.exports = { EXTERNAL_EDITOR_SETTING, isLaunchableFileName };
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* The login URL a server hands the desktop app goes to shell.openExternal,
|
||||
* so only web pages pass: any other scheme (file:, smb:, ms-msdt:, ...)
|
||||
* would launch whatever OS handler is registered for it.
|
||||
*/
|
||||
function isExternalLoginUrl(value) {
|
||||
try {
|
||||
const { protocol } = new URL(value);
|
||||
return protocol === "https:" || protocol === "http:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** A non-privileged port for the loopback callback listener. */
|
||||
function isCallbackPort(value) {
|
||||
return Number.isInteger(value) && value >= 1024 && value <= 65535;
|
||||
}
|
||||
|
||||
module.exports = { isExternalLoginUrl, isCallbackPort };
|
||||
@@ -6,9 +6,16 @@
|
||||
|
||||
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 {
|
||||
return new URL(url).origin;
|
||||
const parsed = new URL(url);
|
||||
const httpProtocol = WEBSOCKET_TO_HTTP[parsed.protocol];
|
||||
if (httpProtocol) parsed.protocol = httpProtocol;
|
||||
return parsed.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -330,7 +330,11 @@ 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-]*$/;
|
||||
const LOCAL_BACKEND = "http://localhost:30001";
|
||||
let localBackendPort = 30001;
|
||||
|
||||
function setLocalBackendPort(port) {
|
||||
localBackendPort = port;
|
||||
}
|
||||
let registeredApiPath = null;
|
||||
|
||||
function setTransferApiPath(apiPath) {
|
||||
@@ -403,7 +407,8 @@ function createTargetResolver({ localBaseUrl, getLinkedServer, apiPath }) {
|
||||
}
|
||||
return {
|
||||
url: `${normalizeHttpBase(
|
||||
localBaseUrl ?? `${LOCAL_BACKEND}${resolveApiPath()}`,
|
||||
localBaseUrl ??
|
||||
`http://localhost:${localBackendPort}${resolveApiPath()}`,
|
||||
"Local backend URL",
|
||||
)}${routePath}`,
|
||||
headers,
|
||||
@@ -1050,6 +1055,7 @@ module.exports = {
|
||||
createLocalFileHandlers,
|
||||
createTargetResolver,
|
||||
setTransferApiPath,
|
||||
setLocalBackendPort,
|
||||
publishDownload,
|
||||
defaultPublishFs,
|
||||
assertWithinRoot,
|
||||
|
||||
@@ -39,7 +39,11 @@ function resolveLocalShell(
|
||||
* how many helpers were fixed.
|
||||
*/
|
||||
function repairSpawnHelpers(ptyDir, fsImpl = fs) {
|
||||
const dir = ptyDir.replace(/app\.asar(?!\.unpacked)/, "app.asar.unpacked");
|
||||
// A universal macOS build ships app-arm64.asar and app-x64.asar.
|
||||
const dir = ptyDir.replace(
|
||||
/\b(app(?:-[a-z0-9]+)?)\.asar(?!\.unpacked)/,
|
||||
"$1.asar.unpacked",
|
||||
);
|
||||
const candidates = [
|
||||
path.join(dir, "build", "Release", "spawn-helper"),
|
||||
path.join(
|
||||
|
||||
+93
-115
@@ -35,7 +35,15 @@ 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 } = require("./local-files.cjs");
|
||||
const {
|
||||
registerLocalFileHandlers,
|
||||
setLocalBackendPort,
|
||||
} = require("./local-files.cjs");
|
||||
const {
|
||||
EXTERNAL_EDITOR_SETTING,
|
||||
isLaunchableFileName,
|
||||
} = require("./external-editor.cjs");
|
||||
const { isExternalLoginUrl, isCallbackPort } = require("./external-login.cjs");
|
||||
|
||||
const localTerminalSessions = new Map();
|
||||
|
||||
@@ -434,14 +442,6 @@ function isInsecureModeEnabled() {
|
||||
);
|
||||
}
|
||||
|
||||
function getOrigin(url) {
|
||||
try {
|
||||
return new URL(url).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isPrivateNetworkHost(hostname) {
|
||||
if (
|
||||
hostname === "localhost" ||
|
||||
@@ -485,7 +485,7 @@ function isInvalidCertificateAllowedForUrl(url) {
|
||||
// allows a self-signed certificate.
|
||||
const linked = linkedServer.getLinkedServer();
|
||||
if (!linked?.allowInvalidCertificate) return false;
|
||||
return getOrigin(url) === linked.origin;
|
||||
return linkedServer.isLinkedOrigin(url);
|
||||
}
|
||||
|
||||
function getTlsVerificationOptions(url) {
|
||||
@@ -700,6 +700,9 @@ let tray = null;
|
||||
let isQuitting = false;
|
||||
const tempFiles = new Map();
|
||||
const externalEditorSessions = new Map();
|
||||
// Paths the user picked in an open dialog this session. Only these may be
|
||||
// saved as the external editor, so the renderer cannot name an executable.
|
||||
const dialogChosenPaths = new Set();
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
|
||||
const appRoot = isDev ? process.cwd() : path.join(__dirname, "..");
|
||||
@@ -1116,6 +1119,35 @@ 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;
|
||||
@@ -1156,7 +1188,8 @@ function startBackendServer() {
|
||||
DATA_DIR: dataDir,
|
||||
NODE_ENV: "production",
|
||||
ELECTRON_EMBEDDED: "true",
|
||||
PORT: "30001",
|
||||
PORT: String(backendPort),
|
||||
EMBEDDED_BACKEND_PORT: String(backendPort),
|
||||
VERSION: app.getVersion(),
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe", "ipc"],
|
||||
@@ -1421,6 +1454,7 @@ function createWindow() {
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
additionalArguments: [`--termix-backend-port=${backendPort}`],
|
||||
partition: termixSessionPartition,
|
||||
allowRunningInsecureContent: false,
|
||||
webviewTag: false,
|
||||
@@ -1663,6 +1697,12 @@ ipcMain.handle("get-embedded-server-status", () => {
|
||||
ipcMain.handle(
|
||||
"external-browser-login",
|
||||
async (_event, authUrl, callbackPort) => {
|
||||
if (!isExternalLoginUrl(authUrl)) {
|
||||
return { success: false, error: "Invalid authentication URL" };
|
||||
}
|
||||
if (!isCallbackPort(callbackPort)) {
|
||||
return { success: false, error: "Invalid callback port" };
|
||||
}
|
||||
const http = require("http");
|
||||
|
||||
return new Promise((resolve) => {
|
||||
@@ -2375,6 +2415,8 @@ async function testC2SRelay(
|
||||
if (runtime) runtime.authSocket = ws;
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
// The relay sends its reason and then closes; keep the reason.
|
||||
let relayError = null;
|
||||
const settle = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
@@ -2411,6 +2453,10 @@ async function testC2SRelay(
|
||||
settle({ success: true });
|
||||
} else if (message.type === "error") {
|
||||
clearTimeout(timer);
|
||||
relayError = normalizeC2SErrorMessage(
|
||||
message.error,
|
||||
"Tunnel test failed",
|
||||
);
|
||||
settle(createC2SFailure(message.error, "Tunnel test failed"));
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -2424,7 +2470,10 @@ async function testC2SRelay(
|
||||
});
|
||||
ws.on("close", () => {
|
||||
if (runtime && c2sTunnelRuntimes.get(tunnel.name) === runtime) {
|
||||
setC2STunnelError(tunnel.name, "Endpoint SSH connection closed");
|
||||
setC2STunnelError(
|
||||
tunnel.name,
|
||||
relayError || "Endpoint SSH connection closed",
|
||||
);
|
||||
}
|
||||
clearTimeout(timer);
|
||||
settle({ success: false, error: "Tunnel test connection closed" });
|
||||
@@ -3156,18 +3205,16 @@ ipcMain.handle("get-c2s-tunnel-preset-default-name", () => {
|
||||
return `[${date}] ${computerName} (${platform} ${release})`;
|
||||
});
|
||||
|
||||
function readSetting(key) {
|
||||
const settingsPath = path.join(app.getPath("userData"), "settings.json");
|
||||
if (!fs.existsSync(settingsPath)) return null;
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
||||
return settings[key] !== undefined ? settings[key] : null;
|
||||
}
|
||||
|
||||
ipcMain.handle("get-setting", (event, key) => {
|
||||
try {
|
||||
const userDataPath = app.getPath("userData");
|
||||
const settingsPath = path.join(userDataPath, "settings.json");
|
||||
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const settingsData = fs.readFileSync(settingsPath, "utf8");
|
||||
const settings = JSON.parse(settingsData);
|
||||
return settings[key] !== undefined ? settings[key] : null;
|
||||
return readSetting(key);
|
||||
} catch (error) {
|
||||
console.error("Error reading setting:", error);
|
||||
return null;
|
||||
@@ -3175,6 +3222,16 @@ ipcMain.handle("get-setting", (event, key) => {
|
||||
});
|
||||
|
||||
ipcMain.handle("set-setting", (event, key, value) => {
|
||||
if (typeof key !== "string") {
|
||||
return { success: false, error: "Invalid setting key" };
|
||||
}
|
||||
if (
|
||||
key === EXTERNAL_EDITOR_SETTING &&
|
||||
value &&
|
||||
!dialogChosenPaths.has(value)
|
||||
) {
|
||||
return { success: false, error: "Choose the editor in the file dialog" };
|
||||
}
|
||||
try {
|
||||
const userDataPath = app.getPath("userData");
|
||||
const settingsPath = path.join(userDataPath, "settings.json");
|
||||
@@ -3198,94 +3255,6 @@ ipcMain.handle("set-setting", (event, key, value) => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("get-session-cookie", async (_event, name, targetUrl) => {
|
||||
try {
|
||||
const ses = mainWindow?.webContents?.session;
|
||||
if (!ses) return null;
|
||||
const cookies = await ses.cookies.get({
|
||||
name,
|
||||
...(targetUrl ? { url: targetUrl } : {}),
|
||||
});
|
||||
const cookie = cookies.find((candidate) =>
|
||||
cookieMatchesUrl(candidate, targetUrl),
|
||||
);
|
||||
return (
|
||||
cookie?.value ||
|
||||
getRememberedElectronAuthCookie(name, targetUrl)?.value ||
|
||||
null
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to get session cookie:", error);
|
||||
return getRememberedElectronAuthCookie(name, targetUrl)?.value || null;
|
||||
}
|
||||
});
|
||||
|
||||
function cookieMatchesUrl(cookie, targetUrl) {
|
||||
if (!targetUrl) return true;
|
||||
|
||||
try {
|
||||
const targetHost = new URL(targetUrl).hostname;
|
||||
const cookieDomain = (cookie.domain || "").replace(/^\./, "");
|
||||
|
||||
return (
|
||||
cookieDomain === targetHost ||
|
||||
targetHost.endsWith(`.${cookieDomain}`) ||
|
||||
(!cookieDomain && targetHost === "localhost")
|
||||
);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle(
|
||||
"wait-session-cookie",
|
||||
async (_event, name, targetUrl, previousValue, timeoutMs = 5000) => {
|
||||
const ses = mainWindow?.webContents?.session;
|
||||
if (!ses) return { success: false, error: "No Electron session" };
|
||||
|
||||
const existingCookies = await ses.cookies.get({
|
||||
name,
|
||||
...(targetUrl ? { url: targetUrl } : {}),
|
||||
});
|
||||
const existingCookie = existingCookies.find((cookie) =>
|
||||
cookieMatchesUrl(cookie, targetUrl),
|
||||
);
|
||||
if (existingCookie?.value && existingCookie.value !== previousValue) {
|
||||
return { success: true, value: existingCookie.value };
|
||||
}
|
||||
|
||||
const rememberedCookie = getRememberedElectronAuthCookie(name, targetUrl);
|
||||
if (rememberedCookie?.value && rememberedCookie.value !== previousValue) {
|
||||
return { success: true, value: rememberedCookie.value };
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
ses.cookies.off("changed", onCookieChanged);
|
||||
resolve({ success: false, error: "Timed out waiting for cookie" });
|
||||
}, timeoutMs);
|
||||
|
||||
function onCookieChanged(_event, cookie, _cause, removed) {
|
||||
if (
|
||||
removed ||
|
||||
cookie.name !== name ||
|
||||
!cookie.value ||
|
||||
cookie.value === previousValue ||
|
||||
!cookieMatchesUrl(cookie, targetUrl)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timeout);
|
||||
ses.cookies.off("changed", onCookieChanged);
|
||||
resolve({ success: true, value: cookie.value });
|
||||
}
|
||||
|
||||
ses.cookies.on("changed", onCookieChanged);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle("clear-session-cookies", async () => {
|
||||
try {
|
||||
clearPersistedElectronAuthCookies();
|
||||
@@ -3413,7 +3382,9 @@ ipcMain.handle("show-save-dialog", async (_event, options) => {
|
||||
});
|
||||
|
||||
ipcMain.handle("show-open-dialog", async (_event, options) => {
|
||||
return dialog.showOpenDialog(mainWindow, options || {});
|
||||
const result = await dialog.showOpenDialog(mainWindow, options || {});
|
||||
for (const filePath of result.filePaths) dialogChosenPaths.add(filePath);
|
||||
return result;
|
||||
});
|
||||
|
||||
function toLocalFileType(stat) {
|
||||
@@ -3736,6 +3707,15 @@ ipcMain.handle("cleanup-temp-file", (_event, tempId) => {
|
||||
|
||||
ipcMain.handle("open-external-editor", async (_event, fileData) => {
|
||||
try {
|
||||
// The editor comes from the saved setting, never from the caller.
|
||||
const editorPath = readSetting(EXTERNAL_EDITOR_SETTING) || null;
|
||||
if (!editorPath && isLaunchableFileName(fileData?.fileName)) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Choose an external editor to open this file type",
|
||||
};
|
||||
}
|
||||
|
||||
const result = createManagedTempFile(
|
||||
fileData?.fileName,
|
||||
fileData?.content,
|
||||
@@ -3757,10 +3737,6 @@ ipcMain.handle("open-external-editor", async (_event, fileData) => {
|
||||
lastMtimeMs: stat.mtimeMs,
|
||||
});
|
||||
|
||||
const editorPath =
|
||||
typeof fileData?.editorPath === "string" && fileData.editorPath.trim()
|
||||
? fileData.editorPath.trim()
|
||||
: null;
|
||||
const openError = await openPathWithEditor(result.path, editorPath);
|
||||
if (openError) {
|
||||
closeExternalEditorSession(editId);
|
||||
@@ -3864,6 +3840,8 @@ app.whenReady().then(async () => {
|
||||
await clearElectronJwtCookiesAtStartup();
|
||||
|
||||
if (!isDev) {
|
||||
backendPort = await findBackendPort();
|
||||
setLocalBackendPort(backendPort);
|
||||
const result = await startBackendServer();
|
||||
logToFile("startBackendServer result:", result);
|
||||
} else {
|
||||
|
||||
+9
-10
@@ -14,7 +14,16 @@ 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) => {
|
||||
@@ -87,16 +96,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
},
|
||||
|
||||
clearSessionCookies: () => ipcRenderer.invoke("clear-session-cookies"),
|
||||
getSessionCookie: (name, targetUrl) =>
|
||||
ipcRenderer.invoke("get-session-cookie", name, targetUrl),
|
||||
waitForSessionCookie: (name, targetUrl, previousValue, timeoutMs) =>
|
||||
ipcRenderer.invoke(
|
||||
"wait-session-cookie",
|
||||
name,
|
||||
targetUrl,
|
||||
previousValue,
|
||||
timeoutMs,
|
||||
),
|
||||
|
||||
externalBrowserLogin: (authUrl, callbackPort) =>
|
||||
ipcRenderer.invoke("external-browser-login", authUrl, callbackPort),
|
||||
|
||||
+12
-10
@@ -71,7 +71,6 @@
|
||||
"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",
|
||||
@@ -108,7 +107,6 @@
|
||||
"internal": true,
|
||||
"description": "Set by the image when nginx serves HTTPS in front of the backend."
|
||||
},
|
||||
|
||||
{
|
||||
"name": "DATABASE_DIALECT",
|
||||
"group": "database",
|
||||
@@ -161,7 +159,6 @@
|
||||
"internal": true,
|
||||
"description": "Where database migrations are read from. Set by the image."
|
||||
},
|
||||
|
||||
{
|
||||
"name": "JWT_SECRET",
|
||||
"group": "secrets",
|
||||
@@ -204,7 +201,6 @@
|
||||
"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",
|
||||
@@ -283,7 +279,6 @@
|
||||
"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",
|
||||
@@ -315,7 +310,6 @@
|
||||
"group": "proxy",
|
||||
"description": "Comma separated hosts that skip the proxy."
|
||||
},
|
||||
|
||||
{
|
||||
"name": "LOG_LEVEL",
|
||||
"group": "logging",
|
||||
@@ -355,7 +349,6 @@
|
||||
"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",
|
||||
@@ -377,6 +370,12 @@
|
||||
"default": "false",
|
||||
"description": "Only load plugins signed by a trusted key. Blocks uploads of unsigned files even in developer mode."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_SAFE_LAYOUT",
|
||||
"group": "plugins",
|
||||
"default": "false",
|
||||
"description": "Start everyone in the layout repair view instead of the chosen layout. Use it when a layout plugin breaks the app."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_BUNDLED_PLUGINS_DIR",
|
||||
"group": "plugins",
|
||||
@@ -406,7 +405,6 @@
|
||||
"internal": true,
|
||||
"description": "Set by npm run dev."
|
||||
},
|
||||
|
||||
{
|
||||
"name": "ENABLE_INSECURE_MODE",
|
||||
"group": "desktop",
|
||||
@@ -435,6 +433,12 @@
|
||||
"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",
|
||||
@@ -459,14 +463,12 @@
|
||||
"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.1",
|
||||
"version": "26.10.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "termix",
|
||||
"version": "26.10.1",
|
||||
"version": "26.10.3",
|
||||
"hasInstallScript": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
@@ -15058,7 +15058,7 @@
|
||||
},
|
||||
"packages/plugin-sdk": {
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.0.6",
|
||||
"version": "1.0.12",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"version": "26.10.1",
|
||||
"version": "26.10.3",
|
||||
"description": "Self-hosted, plugin-based server management.",
|
||||
"author": "Karmaa",
|
||||
"main": "electron/main.cjs",
|
||||
@@ -18,7 +18,7 @@
|
||||
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs",
|
||||
"prepare": "husky || true",
|
||||
"prebuild": "node scripts/write-electron-build-info.cjs",
|
||||
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-sdk-guide.cjs && node scripts/generate-settings-search-index.cjs --check && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs",
|
||||
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-sdk-guide.cjs && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"type-check": "npm run build:sdk && tsc -b --force",
|
||||
"test": "vitest run",
|
||||
@@ -30,7 +30,7 @@
|
||||
"dev:electron": "node scripts/dev.mjs electron",
|
||||
"dev:docker": "node scripts/dev.mjs docker",
|
||||
"dev:frontend": "vite",
|
||||
"build": "npm run build:sdk && vite build && 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 && 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: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,5 +1,64 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.12
|
||||
|
||||
Plugin API 1.4.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `contributes.layouts` and `app.registerLayout()` for a whole-app layout, with the `ui:layout` capability. Needs `engine.api` 1.4
|
||||
- `contributes.themes` for CSS-only themes, with the `ui:theme` capability. `termix-plugin validate` checks theme CSS and `pack` ships it
|
||||
- `useShell()` gives a layout the live shell: tabs, splits, hosts, docks, the command palette and the registries. The shell's home tab is left out until some plugin registers a `dashboard` tab type
|
||||
- `TabStage` and `ShellConfirmCapture` in `@termix-ssh/plugin-sdk/ui`, for a layout to place tabs
|
||||
- `validateThemeCss()` in `@termix-ssh/plugin-sdk/manifest`
|
||||
- `@termix-ssh/plugin-sdk/ui` also exports what a whole layout is built from: the API client, shared types, hooks, `useShellController()` and `APP_VERSION`. These reach into core, so they can change with a core release
|
||||
- `@termix-ssh/plugin-sdk/testing-host` exports the registries and permission seam a layout's own tests fill directly
|
||||
- A plugin that only holds themes needs no source: `termix-plugin build` writes an empty backend and `validate` does not ask for a frontend
|
||||
- `termix-plugin validate` flags `ui:layout` or `ui:theme` with nothing contributed, and a layout plugin with no frontend
|
||||
|
||||
### Fixed
|
||||
|
||||
- The manifest JSON schema lists the `notify:hub` and `plugins:read` capabilities
|
||||
- Plugin tests and type checks work with the SDK linked from a local Termix checkout
|
||||
|
||||
## 1.0.11
|
||||
|
||||
Plugin API 1.3.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `ctx.auth.issueUserToken()` mints a short-lived session for a user, for calling Termix's own API as them from background work. Needs `users:impersonate` and `engine.api` 1.3
|
||||
- `ctx.users.getLocale()` reads a user's UI language. Needs `users:read` and `engine.api` 1.3
|
||||
|
||||
## 1.0.10
|
||||
|
||||
### Changed
|
||||
|
||||
- `termix-plugin pack` includes the plugin's `LICENSE` and `NOTICE` files, so third-party notices ship with it
|
||||
|
||||
## 1.0.9
|
||||
|
||||
### Added
|
||||
|
||||
- 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
|
||||
|
||||
- `publicRateLimit` on `ctx.http.router()`, for a plugin whose public routes serve a proxied page
|
||||
|
||||
## 1.0.7
|
||||
|
||||
### Fixed
|
||||
|
||||
- A migration file, or a part of one, holding only comments made core fail to start the plugin
|
||||
|
||||
## 1.0.6
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Termix plugin guide
|
||||
|
||||
SDK 1.0.6 | Plugin API 1.2.0
|
||||
SDK 1.0.12 | Plugin API 1.4.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
|
||||
npm run pack # termix-plugin pack: write <id>-<version>.tmxplug (with LICENSE and NOTICE)
|
||||
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`
|
||||
- [ ] New strings are in `locales/en.json`, and `plugin.name` / `plugin.description` match the manifest
|
||||
- [ ] New routes have an `@openapi` block and a test, including without the permission
|
||||
- [ ] New env vars are listed in `env` in the manifest
|
||||
- [ ] `docs/` and the "About this plugin" section of `AGENTS.md` still match what the plugin does
|
||||
@@ -175,7 +175,7 @@ Unknown fields are refused at every level, so a typo fails validation. The schem
|
||||
|
||||
`contributes` holds `tabs`, `panels`, `dashboardCards`, `actions`, `actionSlots`, `permissions`,
|
||||
`settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`,
|
||||
`uiPresets` and `uses`. Frontend `register*` calls need their ids declared here first.
|
||||
`uiPresets`, `uses`, `layouts` and `themes`. Frontend `register*` calls need their ids declared here first.
|
||||
|
||||
Capabilities and permissions are different things. A capability (`hosts:read`, with a colon) is what your
|
||||
code may reach. A permission (`hello.use`, with a dot) is what a user may do.
|
||||
@@ -225,6 +225,8 @@ 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 |
|
||||
@@ -237,6 +239,13 @@ 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
|
||||
@@ -283,7 +292,8 @@ router.post("/notes", async (req, res) => {
|
||||
- The `@openapi` path is the full path, starting with `/plugin-api/<id>/`. `build` turns these into
|
||||
`dist/openapi.json` and the docs site renders them.
|
||||
- `ctx.http.router({ public: ["/webhook/:token"], bodyLimit: "20mb", rawBody: false })`. Public routes
|
||||
have no actor: check their token yourself and rate limit them.
|
||||
have no actor: check their token yourself. Core allows 120 public requests a minute per address;
|
||||
`publicRateLimit` raises that for a proxied page that loads its own assets.
|
||||
- `ctx.http.baseUrl(req)` is the public address, for callback URLs.
|
||||
- `ctx.ws.route("/live", (socket, conn) => ...)`. Long lived sockets should check
|
||||
`conn.isDataUnlocked()` per message.
|
||||
@@ -333,8 +343,27 @@ 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`, `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.
|
||||
`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.
|
||||
|
||||
## Database
|
||||
|
||||
@@ -449,6 +478,7 @@ your plugin's error boundary and Suspense.
|
||||
| `registerComponent`, `registerExtension` | Offer a component by id, add to an extension point |
|
||||
| `registerOnboardingStep` | A first run setup step |
|
||||
| `registerLoginMethod`, `registerSecondFactorUI`, `registerSshAuthEditor` | Sign in UI |
|
||||
| `registerLayout` | A whole-app layout (`contributes.layouts`, API 1.4) |
|
||||
| `api` | Axios client for your `/plugin-api/<id>/` routes, auth included |
|
||||
| `fetch(path)`, `wsUrl(path)` | Raw fetch for streams, socket URL and auth |
|
||||
| `docs`, `tabs`, `confirm`, `listHosts`, `getHost`, `t`, `hasPermission`, `desktop` | Helpers |
|
||||
@@ -456,7 +486,7 @@ your plugin's error boundary and Suspense.
|
||||
|
||||
Hooks from `@termix-ssh/plugin-sdk/frontend`: `useTranslation`, `usePermission`, `useSettings`, `useHost`,
|
||||
`useHosts`, `usePluginApi`, `useCurrentUser`, `useTheme`, `useToast`, `useDocsUrl`, `useTabs`,
|
||||
`useSlotContributions`, `useSshAuthTypes`, `useExtensions`.
|
||||
`useSlotContributions`, `useSshAuthTypes`, `useExtensions`, `useShell`.
|
||||
|
||||
Shared from Termix and never bundled: `react`, `react-dom`, `i18next`, `react-i18next`, `sonner` and the
|
||||
SDK's `frontend` and `ui`. Anything else, like `lucide-react`, is bundled. A host your code gets only carries
|
||||
@@ -464,9 +494,98 @@ 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` and `tab.menu`. See
|
||||
`hosts.panel`, `credentials.badges`, `shell.overlay`, `shell.rightEdge` and `tab.menu`. See
|
||||
https://docs.termix.site/develop/more-extension-points.
|
||||
|
||||
## Layouts and themes
|
||||
|
||||
A plugin can replace the whole app layout or add themes (API 1.4). Users pick both in Settings, Appearance;
|
||||
admins set the default for everyone.
|
||||
|
||||
A layout is declared in `contributes.layouts` (`id`, `label`, optional `description` and `defaultTheme`) and
|
||||
needs the `ui:layout` capability, which admins see as high risk because a layout draws every screen. Register
|
||||
it in `activate`:
|
||||
|
||||
```tsx
|
||||
import { useShell, type TermixApp } from "@termix-ssh/plugin-sdk/frontend";
|
||||
import {
|
||||
ShellConfirmCapture,
|
||||
SurfaceScope,
|
||||
TabStage,
|
||||
} from "@termix-ssh/plugin-sdk/ui";
|
||||
|
||||
function MyLayout() {
|
||||
const shell = useShell();
|
||||
if (!shell) return null;
|
||||
return (
|
||||
<div className="flex h-dvh w-screen flex-col bg-background">
|
||||
<header>
|
||||
{/* tabs from shell.tabs.topLevel, shell.palette.open() and so on */}
|
||||
</header>
|
||||
<SurfaceScope kind="tab">
|
||||
<ShellConfirmCapture />
|
||||
<TabStage />
|
||||
</SurfaceScope>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp) {
|
||||
app.registerLayout({
|
||||
id: "my-layout",
|
||||
label: "layout.name",
|
||||
component: MyLayout,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- `useShell()` is the live shell with nothing drawn: tabs, splits, hosts, the left and right docks, the
|
||||
command palette, the registries as hooks and the user. Core keeps all the state, so switching layouts keeps
|
||||
every session.
|
||||
- `TabStage` is where tabs show. Place it anywhere; tabs are never remounted when it moves, so a terminal
|
||||
keeps its connection. Wrap it in `SurfaceScope kind="tab"` with a `ShellConfirmCapture` inside, so
|
||||
close questions and inline views show over the tab area. Overlays and onboarding stay in core.
|
||||
- The layout draws the command palette. Core keeps whether it is open (`palette.isOpen`, Ctrl+K calls
|
||||
`palette.open()`) and what is in it (`usePaletteEntries`, `usePaletteGroups`, rail items, hosts).
|
||||
- The shell always keeps a home tab with the id and type `dashboard`. `useShell()` leaves it out until some
|
||||
plugin registers a `dashboard` tab type, so a layout without a home screen has no empty tab to draw.
|
||||
- Settings, Manage and the host editor belong to Classic Layout. A layout without them should still render
|
||||
`LookSettings` somewhere so people can switch back. `tabs.openSingleton("plugins")` opens core's own
|
||||
Plugins tab in any layout. Switching layouts closes the tabs the old layout's plugin declared.
|
||||
- `mobileComponent` is used on phones and narrow windows. The desktop app always gets `component`.
|
||||
- Only the chosen layout's plugin is loaded. If it fails to load or crashes twice, Termix shows a repair
|
||||
screen where people can switch, reset, update, turn it off or (for a bundled plugin) restore the copy that
|
||||
shipped. `?safe-layout=1` opens it any time.
|
||||
- Termix has no layout of its own. The default is the bundled Classic Layout plugin
|
||||
(https://github.com/Termix-SSH/Plugin-Classic-Layout), which is also a full example. It owns the rail,
|
||||
sidebar, tab bar, docks, Dashboard, Manage, Settings and command palette, registers the `dashboard`,
|
||||
`host-manager` and `settings` tab types, and offers the host list and dashboard as the components
|
||||
`classic-layout.hostTree` and `classic-layout.dashboard` for other plugins to place with `PluginComponent`.
|
||||
It reads the shell with `useShellController()` from `@termix-ssh/plugin-sdk/ui`, which exposes more than
|
||||
`useShell()` and may change with Termix. A third party layout should start from `useShell()`.
|
||||
- Example Top Bar Layout (https://github.com/Termix-SSH/Plugin-Example-Topbar-Layout) is a small third party
|
||||
style layout built only on `useShell()`, `TabStage` and `@termix-ssh/plugin-sdk/ui`, with a phone version.
|
||||
|
||||
A theme is CSS only. Declare it in `contributes.themes` (`id`, `label`, `mode` light or dark, `css` path,
|
||||
optional `swatch` colors for the picker) with the `ui:theme` capability. The id becomes a class on `<html>`,
|
||||
so the file may only hold rules whose selectors start with `.<id>`, `:root.<id>` or `html.<id>`, optionally
|
||||
inside `@media` or `@supports`. `@import`, `url()` to another host and other at-rules are refused by
|
||||
`termix-plugin validate` and by the server. Set the same tokens core's themes set (`--background`,
|
||||
`--foreground`, `--card`, `--border`, `--muted` and the rest).
|
||||
|
||||
A plugin that only holds themes needs no code. Leave out `src/`: `termix-plugin build` writes an empty backend and
|
||||
`validate` does not ask for a frontend. Keep the CSS in `themes/` and a test that checks it with
|
||||
`validateThemeCss()`. Notes:
|
||||
|
||||
- A theme with the id of a built-in (`light` or `dark`) replaces core's basic copy while the plugin runs.
|
||||
- The first theme a bundled default-look plugin lists is the default for new installs.
|
||||
- Themes show in the picker in the order core lists its own, then the plugin's order.
|
||||
- A theme someone saved that no longer exists shows as System. It comes back when the plugin does.
|
||||
- Accent color, UI font, font size and density are not part of a theme. Core applies them on top.
|
||||
- Bundled Classic Themes (https://github.com/Termix-SSH/Plugin-Classic-Themes) is a full example, and
|
||||
Example Theme (https://github.com/Termix-SSH/Plugin-Example-Theme) a small one with one light and one dark
|
||||
theme.
|
||||
|
||||
## Looking native
|
||||
|
||||
Build UI from `@termix-ssh/plugin-sdk/ui` so it matches Termix and follows the theme. The full list of
|
||||
@@ -518,6 +637,20 @@ 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
|
||||
@@ -682,20 +815,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` 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` |
|
||||
| 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` |
|
||||
|
||||
## SDK entry points
|
||||
|
||||
@@ -720,37 +853,39 @@ 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 | 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 |
|
||||
| Capability | Risk | What it allows |
|
||||
| -------------------- | -------- | ------------------------------------------------------- |
|
||||
| `credentials:read` | critical | Read stored secrets for hosts the user can reach |
|
||||
| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
|
||||
| `ssh:connect` | high | Run any command on hosts, with the user's access |
|
||||
| `process:spawn` | high | Run programs on the Termix server |
|
||||
| `users:write` | high | Add users and change their roles |
|
||||
| `users:impersonate` | high | Act as another user, or get a session for them, audited |
|
||||
| `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
|
||||
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
|
||||
| `device:serial` | high | Open serial and USB ports on the server |
|
||||
| `notify:hub` | high | Receive every alert (the alerts hub only) |
|
||||
| `ui:layout` | high | Replace the whole app layout |
|
||||
| `hosts:write` | medium | Add and edit hosts |
|
||||
| `credentials:use` | medium | Connect with saved credentials without seeing them |
|
||||
| `credentials:write` | medium | Save new credentials |
|
||||
| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
|
||||
| `network:serve` | medium | HTTP routes and sockets |
|
||||
| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN |
|
||||
| `users:read` | medium | Usernames, roles and UI language |
|
||||
| `events:core` | medium | Listen to core events |
|
||||
| `notify:send` | medium | Send alerts |
|
||||
| `audit:read` | medium | Read the audit log |
|
||||
| `desktop:window` | medium | Open windows in the desktop app |
|
||||
| `hosts:read` | low | Read hosts the user can see |
|
||||
| `db:own` | low | Your own tables |
|
||||
| `kv:own` | low | Small key value state |
|
||||
| `files:own` | low | Your own folder on disk |
|
||||
| `secrets:own` | low | Your own encrypted secrets |
|
||||
| `settings:read-core` | low | Read Termix server settings |
|
||||
| `plugins:read` | low | See installed plugins |
|
||||
| `ui:surface` | low | Add tabs, panels and settings |
|
||||
| `ui:theme` | low | Add color themes, CSS only |
|
||||
|
||||
An update that adds a capability waits for an admin to approve it.
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ in place of the flags. Reload the page to pick up frontend changes.
|
||||
|
||||
## Support
|
||||
|
||||
Bugs and ideas for the SDK or the `termix-plugin` CLI go in the [Termix repo](https://github.com/Termix-SSH/Termix/issues/new/choose). You need to be logged in to GitHub. Please be as detailed as possible, preferably in English.
|
||||
Bugs and ideas for the SDK or the `termix-plugin` CLI go in the [Termix repo](https://github.com/Termix-SSH/Termix/issues/new/choose). Add as much detail as you can, in English if possible.
|
||||
|
||||
For discussions and questions, join the [Discord](https://discord.gg/jVQGdvHDrf) server.
|
||||
|
||||
@@ -58,4 +58,4 @@ For discussions and questions, join the [Discord](https://discord.gg/jVQGdvHDrf)
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the Apache License Version 2.0. See [LICENSE](https://github.com/Termix-SSH/Termix/blob/main/LICENSE) for more information.
|
||||
Apache License 2.0. See [LICENSE](https://github.com/Termix-SSH/Termix/blob/main/LICENSE).
|
||||
@@ -5,7 +5,12 @@ import { fileURLToPath } from "node:url";
|
||||
import * as esbuild from "esbuild";
|
||||
import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
|
||||
import { staticUrlImports } from "../lib/static-url-imports.mjs";
|
||||
import { readManifest, resolveEntry, copyDir } from "../lib/plugin-dir.mjs";
|
||||
import {
|
||||
readManifest,
|
||||
resolveEntry,
|
||||
copyDir,
|
||||
isThemeOnly,
|
||||
} from "../lib/plugin-dir.mjs";
|
||||
import { openapi } from "./openapi.mjs";
|
||||
import { splitByCoreClasses } from "../lib/css-classes.mjs";
|
||||
|
||||
@@ -52,6 +57,43 @@ 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)),
|
||||
"..",
|
||||
@@ -195,33 +237,56 @@ export async function build({ cwd }) {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const backendEntry = resolveEntry(cwd, BACKEND_ENTRIES);
|
||||
if (!backendEntry) {
|
||||
if (!backendEntry && !isThemeOnly(manifest)) {
|
||||
throw new Error(
|
||||
`${pluginId}: no backend entry (looked for ${BACKEND_ENTRIES.join(", ")})`,
|
||||
);
|
||||
}
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [backendEntry],
|
||||
outfile: path.join(outDir, "backend.js"),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node22",
|
||||
sourcemap: true,
|
||||
logLevel: "warning",
|
||||
// A native dependency's compiled .node binary is resolved by the
|
||||
// package's own relative paths, which break once esbuild inlines its JS
|
||||
// elsewhere. Declaring it in nativeDependencies keeps it a real
|
||||
// node_modules import instead, resolved at runtime like a host-provided
|
||||
// package.
|
||||
external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])],
|
||||
// Bundled CJS deps still call require() for Node builtins at runtime.
|
||||
// ESM has no ambient require, so give esbuild's require shim a real one.
|
||||
banner: {
|
||||
js: "import { createRequire as __termixCreateRequire } from 'node:module'; const require = __termixCreateRequire(import.meta.url);",
|
||||
},
|
||||
});
|
||||
if (backendEntry) {
|
||||
await esbuild.build({
|
||||
entryPoints: [backendEntry],
|
||||
outfile: path.join(outDir, "backend.js"),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node22",
|
||||
sourcemap: true,
|
||||
logLevel: "warning",
|
||||
// A native dependency's compiled .node binary is resolved by the
|
||||
// package's own relative paths, which break once esbuild inlines its JS
|
||||
// elsewhere. Declaring it in nativeDependencies keeps it a real
|
||||
// node_modules import instead, resolved at runtime like a host-provided
|
||||
// package.
|
||||
external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])],
|
||||
loader: BINARY_LOADERS,
|
||||
// Bundled CJS deps still call require() for Node builtins at runtime.
|
||||
// ESM has no ambient require, so give esbuild's require shim a real one.
|
||||
banner: { js: REQUIRE_BANNER },
|
||||
});
|
||||
} else {
|
||||
// Core loads every plugin through a backend entry, so a theme only
|
||||
// plugin gets an empty one.
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, "backend.js"),
|
||||
"export function activate() {}\n",
|
||||
);
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { readManifest } from "../lib/plugin-dir.mjs";
|
||||
import { collectFiles, createTmxplug } from "../lib/tmxplug.mjs";
|
||||
import {
|
||||
collectFiles,
|
||||
createTmxplug,
|
||||
themeFiles,
|
||||
TMXPLUG_CONTENTS,
|
||||
} from "../lib/tmxplug.mjs";
|
||||
import { sha256 } from "../lib/signing.mjs";
|
||||
import { validate } from "./validate.mjs";
|
||||
|
||||
@@ -32,7 +37,12 @@ export async function pack({ cwd, args = [] }) {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const outfile = path.join(outDir, `${pluginId}-${version}.tmxplug`);
|
||||
|
||||
const archive = createTmxplug(cwd, collectFiles(cwd));
|
||||
const archive = createTmxplug(
|
||||
cwd,
|
||||
collectFiles(cwd, [
|
||||
...new Set([...TMXPLUG_CONTENTS, ...themeFiles(manifest)]),
|
||||
]),
|
||||
);
|
||||
fs.writeFileSync(outfile, archive);
|
||||
|
||||
console.log(`packed ${path.basename(outfile)}`);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { readManifest } from "../lib/plugin-dir.mjs";
|
||||
import { readManifest, isThemeOnly } from "../lib/plugin-dir.mjs";
|
||||
|
||||
/**
|
||||
* The manifest rules live in src/manifest.ts, which the server uses too, so
|
||||
@@ -17,7 +17,7 @@ export async function loadSdkModule(name) {
|
||||
|
||||
export async function validate({ cwd }) {
|
||||
const raw = readManifest(cwd);
|
||||
const { parseManifest } = await loadSdkModule("manifest");
|
||||
const { parseManifest, validateThemeCss } = await loadSdkModule("manifest");
|
||||
const { errors } = parseManifest(raw);
|
||||
|
||||
const problems = [...errors];
|
||||
@@ -25,7 +25,8 @@ export async function validate({ cwd }) {
|
||||
// The manifest names files. They have to be there.
|
||||
const referenced = [
|
||||
raw.backend ?? "dist/backend.js",
|
||||
raw.frontend ?? "dist/frontend.js",
|
||||
// A theme only plugin has no frontend code.
|
||||
...(isThemeOnly(raw) ? [] : [raw.frontend ?? "dist/frontend.js"]),
|
||||
raw.locales ?? "locales",
|
||||
];
|
||||
for (const rel of referenced) {
|
||||
@@ -34,6 +35,8 @@ export async function validate({ cwd }) {
|
||||
}
|
||||
}
|
||||
|
||||
problems.push(...validateThemeFiles(cwd, raw, validateThemeCss));
|
||||
problems.push(...validateLookCapabilities(cwd, raw));
|
||||
problems.push(
|
||||
...(await validateMigrations(cwd, raw.id ?? path.basename(cwd))),
|
||||
);
|
||||
@@ -53,6 +56,63 @@ export async function validate({ cwd }) {
|
||||
console.log(`ok ${raw.id ?? path.basename(cwd)}`);
|
||||
}
|
||||
|
||||
/** Every declared theme file exists and holds only CSS a theme may hold. */
|
||||
export function validateThemeFiles(cwd, raw, validateThemeCss) {
|
||||
const problems = [];
|
||||
const themes = raw?.contributes?.themes;
|
||||
if (!Array.isArray(themes)) return problems;
|
||||
for (const theme of themes) {
|
||||
if (typeof theme?.css !== "string" || typeof theme?.id !== "string") {
|
||||
continue;
|
||||
}
|
||||
const file = path.join(cwd, theme.css);
|
||||
if (!fs.existsSync(file)) {
|
||||
problems.push(`theme ${theme.id}: ${theme.css} does not exist`);
|
||||
continue;
|
||||
}
|
||||
for (const error of validateThemeCss(
|
||||
fs.readFileSync(file, "utf8"),
|
||||
theme.id,
|
||||
)) {
|
||||
problems.push(`theme ${theme.id}: ${error}`);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* ui:layout and ui:theme must match what the manifest contributes, both
|
||||
* ways. The manifest check already flags a contribution without its
|
||||
* capability; this flags a capability with nothing behind it, and a layout
|
||||
* with no frontend to register it.
|
||||
*/
|
||||
export function validateLookCapabilities(cwd, raw) {
|
||||
const problems = [];
|
||||
const capabilities = Array.isArray(raw?.capabilities) ? raw.capabilities : [];
|
||||
const layouts = raw?.contributes?.layouts;
|
||||
const themes = raw?.contributes?.themes;
|
||||
const hasLayouts = Array.isArray(layouts) && layouts.length > 0;
|
||||
const hasThemes = Array.isArray(themes) && themes.length > 0;
|
||||
if (capabilities.includes("ui:layout") && !hasLayouts) {
|
||||
problems.push('"ui:layout" is declared but contributes.layouts is empty');
|
||||
}
|
||||
if (capabilities.includes("ui:theme") && !hasThemes) {
|
||||
problems.push('"ui:theme" is declared but contributes.themes is empty');
|
||||
}
|
||||
if (hasLayouts) {
|
||||
const entries = ["index.tsx", "index.ts", "index.mjs", "index.js"];
|
||||
const frontend = entries.some((entry) =>
|
||||
fs.existsSync(path.join(cwd, "src", "frontend", entry)),
|
||||
);
|
||||
if (!frontend && raw.frontend === undefined) {
|
||||
problems.push(
|
||||
"contributes.layouts needs a frontend that calls app.registerLayout",
|
||||
);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/** Env vars core sets for everyone, so a plugin need not list them. */
|
||||
const CORE_ENV = new Set([
|
||||
"DATA_DIR",
|
||||
|
||||
@@ -14,6 +14,17 @@ export function readManifest(cwd) {
|
||||
}
|
||||
|
||||
/** The first of these that exists, or null. */
|
||||
/** Only themes: CSS files and no code, so no source entry is needed. */
|
||||
export function isThemeOnly(manifest) {
|
||||
const capabilities = manifest.capabilities ?? [];
|
||||
return (
|
||||
capabilities.length > 0 &&
|
||||
capabilities.every((capability) => capability === "ui:theme") &&
|
||||
Array.isArray(manifest.contributes?.themes) &&
|
||||
manifest.contributes.themes.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveEntry(cwd, candidates) {
|
||||
for (const candidate of candidates) {
|
||||
const full = path.join(cwd, candidate);
|
||||
|
||||
@@ -2,6 +2,15 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import zlib from "node:zlib";
|
||||
|
||||
/** Theme CSS files a manifest declares, which travel with the plugin too. */
|
||||
export function themeFiles(manifest) {
|
||||
const themes = manifest?.contributes?.themes;
|
||||
if (!Array.isArray(themes)) return [];
|
||||
return themes
|
||||
.map((theme) => theme?.css)
|
||||
.filter((css) => typeof css === "string");
|
||||
}
|
||||
|
||||
/** What a .tmxplug holds, in the order the spec lists it. */
|
||||
export const TMXPLUG_CONTENTS = [
|
||||
"manifest.json",
|
||||
@@ -11,6 +20,9 @@ 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.6",
|
||||
"version": "1.0.12",
|
||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -82,7 +82,11 @@
|
||||
"features": {
|
||||
"type": "array",
|
||||
"maxItems": 20,
|
||||
"items": { "type": "string", "minLength": 1, "maxLength": 160 },
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 160
|
||||
},
|
||||
"description": "Short lines listing what the plugin does, shown on its page."
|
||||
},
|
||||
"docs": {
|
||||
@@ -98,11 +102,24 @@
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "description"],
|
||||
"properties": {
|
||||
"name": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,63}$" },
|
||||
"description": { "type": "string", "minLength": 1, "maxLength": 300 },
|
||||
"default": { "type": "string" },
|
||||
"required": { "type": "boolean" },
|
||||
"secret": { "type": "boolean" }
|
||||
"name": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Z][A-Z0-9_]{0,63}$"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 300
|
||||
},
|
||||
"default": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"secret": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -155,7 +172,11 @@
|
||||
"files:own",
|
||||
"secrets:own",
|
||||
"settings:read-core",
|
||||
"ui:surface"
|
||||
"ui:surface",
|
||||
"notify:hub",
|
||||
"plugins:read",
|
||||
"ui:layout",
|
||||
"ui:theme"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -944,7 +965,9 @@
|
||||
"defaultLevels": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "enum": ["admin", "user", "folder"] },
|
||||
"items": {
|
||||
"enum": ["admin", "user", "folder"]
|
||||
},
|
||||
"description": "Levels that may set a default for the field. All three by default."
|
||||
},
|
||||
"personal": {
|
||||
@@ -1127,6 +1150,86 @@
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+$"
|
||||
}
|
||||
},
|
||||
"layouts": {
|
||||
"type": "array",
|
||||
"description": "Whole-app layouts the frontend registers with app.registerLayout. Needs the ui:layout capability.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "label"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]{1,39}$"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "i18n key in the plugin namespace, or plain text."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"defaultTheme": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]{1,39}$",
|
||||
"description": "Theme picked with this layout when nobody chose one."
|
||||
},
|
||||
"default": {
|
||||
"type": "boolean",
|
||||
"description": "The layout a default-look plugin starts with. At most one."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"themes": {
|
||||
"type": "array",
|
||||
"description": "Themes: one CSS file each with token rules scoped under .<id>. Needs the ui:theme capability.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "label", "mode", "css"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]{1,39}$"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["light", "dark"]
|
||||
},
|
||||
"css": {
|
||||
"type": "string",
|
||||
"pattern": ".css$",
|
||||
"description": "Path of the CSS file inside the plugin."
|
||||
},
|
||||
"swatch": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["background", "panel", "text"],
|
||||
"properties": {
|
||||
"background": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9a-fA-F]{3,8}$"
|
||||
},
|
||||
"panel": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9a-fA-F]{3,8}$"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9a-fA-F]{3,8}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -357,6 +357,13 @@ export interface PluginRouterOptions {
|
||||
* (multer, a raw body for signature checking, a proxied stream).
|
||||
*/
|
||||
rawBody?: boolean;
|
||||
/**
|
||||
* Requests per minute one address may make to this plugin's public routes.
|
||||
* Defaults to 120, which a proxied page loading its own scripts and images
|
||||
* can pass on its own. Capped at 10000, and an admin's
|
||||
* TERMIX_PLUGIN_PUBLIC_RATE_LIMIT wins over it.
|
||||
*/
|
||||
publicRateLimit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1457,6 +1464,20 @@ 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
|
||||
@@ -1518,6 +1539,26 @@ 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 {
|
||||
@@ -2006,8 +2047,13 @@ 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. */
|
||||
/**
|
||||
* Login methods, second factors and SSH auth types. Needs auth:provide,
|
||||
* except issueUserToken, which needs users:impersonate.
|
||||
*/
|
||||
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. */
|
||||
@@ -2032,8 +2078,9 @@ export interface PluginContext {
|
||||
* no request behind it. Always audited, and every core API inside still
|
||||
* applies that user's RBAC.
|
||||
*
|
||||
* Needs users:impersonate. That and a `userId` option on services.get or
|
||||
* secrets.getShared are the only ways a plugin can name a user.
|
||||
* 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.
|
||||
*/
|
||||
asUser: <T>(userId: string, fn: () => Promise<T> | T) => Promise<T>;
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
||||
entry("auth:provide", "high"),
|
||||
entry("device:serial", "high"),
|
||||
entry("notify:hub", "high"),
|
||||
entry("ui:layout", "high"),
|
||||
|
||||
entry("hosts:write", "medium"),
|
||||
entry("credentials:use", "medium"),
|
||||
@@ -70,6 +71,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
||||
entry("settings:read-core", "low"),
|
||||
entry("plugins:read", "low"),
|
||||
entry("ui:surface", "low"),
|
||||
entry("ui:theme", "low"),
|
||||
] as const;
|
||||
|
||||
export const CAPABILITY_IDS: readonly string[] = CAPABILITY_CATALOG.map(
|
||||
|
||||
@@ -309,17 +309,27 @@ export function splitStatements(source: string): string[] {
|
||||
continue;
|
||||
}
|
||||
if (char === ";") {
|
||||
if (current.trim()) statements.push(current.trim());
|
||||
if (hasSql(current)) statements.push(current.trim());
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
|
||||
if (current.trim()) statements.push(current.trim());
|
||||
if (hasSql(current)) statements.push(current.trim());
|
||||
return statements;
|
||||
}
|
||||
|
||||
/** False for a chunk that is only comments, which a driver refuses to run. */
|
||||
function hasSql(chunk: string): boolean {
|
||||
return (
|
||||
chunk
|
||||
.replace(/--[^\n]*/g, "")
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.trim() !== ""
|
||||
);
|
||||
}
|
||||
|
||||
/** DROP statement for a definition, for removePluginData. */
|
||||
export function dropTableSql(
|
||||
dialect: SqlDialect,
|
||||
|
||||
@@ -248,6 +248,129 @@ export interface TabsApi extends Pick<
|
||||
onReady: (listener: () => void) => Disposer;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layouts (plugin API 1.4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A whole-app layout. Core renders the chosen one inside its providers and an
|
||||
* error boundary; everything it shows comes from useShell(). Declare it in
|
||||
* manifest contributes.layouts and add the ui:layout capability.
|
||||
*/
|
||||
export interface LayoutContribution {
|
||||
id: string;
|
||||
/** Your own i18n key or plain text, the same as the manifest's label. */
|
||||
label: string;
|
||||
component: ComponentType;
|
||||
/** Shown on phones and narrow windows. The desktop app never uses it. */
|
||||
mobileComponent?: ComponentType;
|
||||
/** Theme to start with when nobody chose one. */
|
||||
defaultTheme?: string;
|
||||
}
|
||||
|
||||
/** A tab as a layout sees it. Splits are tabs too, with a `split` field. */
|
||||
export interface ShellTabState extends PluginTabRecord {
|
||||
instanceId: string;
|
||||
customLabel?: string;
|
||||
/** Set while the tab sits in a split pane. */
|
||||
parentSplitTabId?: string;
|
||||
openedAt?: number;
|
||||
}
|
||||
|
||||
/** What a dock region shows and how it is sized, in Normal-size pixels. */
|
||||
export interface ShellDockState {
|
||||
view: string | null;
|
||||
open: boolean;
|
||||
width: number;
|
||||
dragging: boolean;
|
||||
setView: (view: string | null) => void;
|
||||
setWidth: (width: number) => void;
|
||||
/** Starts a drag from the dock's edge, from a mousedown. */
|
||||
startResize: (event: { clientX: number; preventDefault: () => void }) => void;
|
||||
resetWidth: () => void;
|
||||
}
|
||||
|
||||
/** The live shell: state and actions, nothing drawn. Read with useShell(). */
|
||||
export interface ShellController {
|
||||
tabs: {
|
||||
list: ShellTabState[];
|
||||
/** What a tab bar shows: tabs not sitting in a split. */
|
||||
topLevel: ShellTabState[];
|
||||
activeId: string;
|
||||
/** True once saved tabs were restored after login. */
|
||||
ready: boolean;
|
||||
/** The user turned on tab numbers. */
|
||||
showNumbers: boolean;
|
||||
setActive: (id: string) => void;
|
||||
reorder: (topLevel: ShellTabState[]) => void;
|
||||
close: (id: string) => Promise<void>;
|
||||
rename: (id: string, label: string) => void;
|
||||
/** Reconnects or reloads a session tab. */
|
||||
refresh: (id: string) => void;
|
||||
reconnectAll: () => void;
|
||||
openSingleton: (type: string) => string | undefined;
|
||||
};
|
||||
split: {
|
||||
/** The split shown now, or null. */
|
||||
activeId: string | null;
|
||||
focusedPaneId: string | null;
|
||||
/** Every split with its panes, for a split menu. */
|
||||
summaries: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
full: boolean;
|
||||
panes: Array<{
|
||||
id: string;
|
||||
index: number;
|
||||
tabId: string | null;
|
||||
label: string | null;
|
||||
}>;
|
||||
}>;
|
||||
activeFull: boolean;
|
||||
/** Split a tab, apply a preset, unsplit, add to a pane. */
|
||||
handleAction: (action: Record<string, unknown> & { kind: string }) => void;
|
||||
};
|
||||
hosts: {
|
||||
list: PluginHostRecord[];
|
||||
loading: boolean;
|
||||
loaded: boolean;
|
||||
reload: () => Promise<void>;
|
||||
};
|
||||
regions: {
|
||||
left: ShellDockState & { view: string };
|
||||
right: ShellDockState;
|
||||
/** What a rail click does: toggles or switches the left dock. */
|
||||
railClick: (view: string) => void;
|
||||
openInRightDock: (view: string) => void;
|
||||
toggleRightDock: () => void;
|
||||
};
|
||||
palette: { isOpen: boolean; open: () => void; close: () => void };
|
||||
fullscreen: { active: boolean; toggle: () => void };
|
||||
/** The same callbacks tabs and panels get. */
|
||||
actions: ShellApi;
|
||||
env: {
|
||||
isMobile: boolean;
|
||||
isElectron: boolean;
|
||||
user: { username: string; isAdmin: boolean };
|
||||
permissions: string[];
|
||||
branding: {
|
||||
appName: string | null;
|
||||
tagline: string | null;
|
||||
logo: string | null;
|
||||
};
|
||||
};
|
||||
/** Core registries, as hooks a layout calls while rendering. */
|
||||
registries: {
|
||||
useRailItems: () => ReadonlyArray<{ id: string } & Record<string, unknown>>;
|
||||
usePanels: () => ReadonlyArray<{ id: string } & Record<string, unknown>>;
|
||||
useTabTypes: () => ReadonlyArray<{ id: string } & Record<string, unknown>>;
|
||||
useDashboardCards: () => ReadonlyArray<
|
||||
{ id: string } & Record<string, unknown>
|
||||
>;
|
||||
};
|
||||
logout: (options?: { manual?: boolean }) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registrations
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -988,6 +1111,12 @@ export interface TermixApp extends TermixAppInfo {
|
||||
registerSshAuthEditor: (editor: SshAuthEditorContribution) => Disposer;
|
||||
registerLoginMethod: (method: LoginMethodContribution) => Disposer;
|
||||
registerSecondFactorUI: (factor: SecondFactorContribution) => Disposer;
|
||||
/**
|
||||
* A whole-app layout. Declared in manifest contributes.layouts with the
|
||||
* ui:layout capability. Only the chosen layout's plugin is loaded, so
|
||||
* register it in activate. Needs plugin API 1.4.
|
||||
*/
|
||||
registerLayout: (layout: LayoutContribution) => Disposer;
|
||||
|
||||
/**
|
||||
* Translates a key from the plugin's own locales, for code outside a
|
||||
@@ -1122,6 +1251,7 @@ export interface PluginHostBridge {
|
||||
useHosts: () => { hosts: PluginHostRecord[]; loaded: boolean };
|
||||
useCurrentUser: () => CurrentUser | null;
|
||||
useTheme: () => { theme: "light" | "dark" };
|
||||
useShell: () => ShellController | null;
|
||||
toast: ToastApi;
|
||||
getApi: (pluginId: string) => PluginApiClient;
|
||||
getApiFor: (pluginId: string, origin: unknown) => PluginApiClient;
|
||||
@@ -1372,6 +1502,14 @@ export function useTheme(): { theme: "light" | "dark" } {
|
||||
return requireHost().useTheme();
|
||||
}
|
||||
|
||||
/**
|
||||
* The live shell, for a layout. Null outside the signed-in app. Needs plugin
|
||||
* API 1.4.
|
||||
*/
|
||||
export function useShell(): ShellController | null {
|
||||
return requireHost().useShell();
|
||||
}
|
||||
|
||||
export function useToast(): ToastApi {
|
||||
return requireHost().toast;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type PluginEnvVar,
|
||||
} from "./docs.js";
|
||||
|
||||
export { validateThemeCss } from "./theme-css.js";
|
||||
export {
|
||||
ENV_NAME_PATTERN,
|
||||
MAX_ENV_DESCRIPTION_LENGTH,
|
||||
@@ -36,7 +37,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.2.0";
|
||||
export const PLUGIN_API_VERSION = "1.4.0";
|
||||
|
||||
/** The API major, the default engine.api for a new plugin. */
|
||||
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
||||
@@ -426,6 +427,40 @@ export interface PluginAuthContribution {
|
||||
keyboardInteractive?: string[];
|
||||
}
|
||||
|
||||
/** A layout id or theme id: short, lowercase, used as a CSS class for themes. */
|
||||
export const LOOK_ID_PATTERN = /^[a-z][a-z0-9-]{1,39}$/;
|
||||
|
||||
/**
|
||||
* A whole-app layout the frontend registers with app.registerLayout. Needs
|
||||
* the ui:layout capability. Declared here so core can list and choose it
|
||||
* before any plugin code runs.
|
||||
*/
|
||||
export interface PluginLayoutContribution {
|
||||
id: string;
|
||||
/** i18n key in the plugin's namespace, or plain text. */
|
||||
label: string;
|
||||
description?: string;
|
||||
/** Theme picked with this layout when nobody chose one. */
|
||||
defaultTheme?: string;
|
||||
/** The layout a default-look plugin starts with. */
|
||||
default?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A theme: one CSS file of token rules scoped under `.<id>`, applied as a
|
||||
* class on <html>. Needs the ui:theme capability. No code runs for a theme.
|
||||
*/
|
||||
export interface PluginThemeContribution {
|
||||
id: string;
|
||||
/** i18n key in the plugin's namespace, or plain text. */
|
||||
label: string;
|
||||
mode: "light" | "dark";
|
||||
/** Path of the CSS file inside the plugin. */
|
||||
css: string;
|
||||
/** Colors the theme picker previews it with. */
|
||||
swatch?: { background: string; panel: string; text: string };
|
||||
}
|
||||
|
||||
export interface PluginContributions {
|
||||
tabs?: PluginTabContribution[];
|
||||
panels?: PluginViewContribution[];
|
||||
@@ -487,6 +522,10 @@ export interface PluginContributions {
|
||||
* ctx.credentials.resolveHostProtocol.
|
||||
*/
|
||||
protocols?: PluginProtocolContribution[];
|
||||
/** Whole-app layouts. Needs the ui:layout capability. */
|
||||
layouts?: PluginLayoutContribution[];
|
||||
/** Themes. Needs the ui:theme capability. */
|
||||
themes?: PluginThemeContribution[];
|
||||
}
|
||||
|
||||
/** A field a protocol login carries besides username and password. */
|
||||
@@ -708,6 +747,8 @@ const ALLOWED_CONTRIBUTES = new Set([
|
||||
"uses",
|
||||
"keybindingActions",
|
||||
"protocols",
|
||||
"layouts",
|
||||
"themes",
|
||||
]);
|
||||
|
||||
const ALLOWED_SETTINGS_FIELD = [
|
||||
@@ -1300,6 +1341,111 @@ function validateContributes(
|
||||
validateUiPresets(contributes.uiPresets, errors);
|
||||
validateKeybindingActions(contributes.keybindingActions, errors);
|
||||
validateProtocols(contributes.protocols, errors);
|
||||
validateLayouts(contributes.layouts, errors);
|
||||
validateThemes(contributes.themes, errors);
|
||||
}
|
||||
|
||||
/** An array of objects with unique look ids; calls `each` for every valid entry. */
|
||||
function validateLookList(
|
||||
value: unknown,
|
||||
field: string,
|
||||
allowed: string[],
|
||||
errors: string[],
|
||||
each: (raw: Record<string, unknown>, where: string) => void,
|
||||
): void {
|
||||
if (value === undefined) return;
|
||||
if (!Array.isArray(value)) {
|
||||
errors.push(`Field "contributes.${field}" must be an array`);
|
||||
return;
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
value.forEach((raw, index) => {
|
||||
const where = `contributes.${field}[${index}]`;
|
||||
if (!isPlainObject(raw)) {
|
||||
errors.push(`${where} must be an object`);
|
||||
return;
|
||||
}
|
||||
rejectUnknown(raw, allowed, where, errors);
|
||||
if (typeof raw.id !== "string" || !LOOK_ID_PATTERN.test(raw.id)) {
|
||||
errors.push(
|
||||
`${where}.id must match ${LOOK_ID_PATTERN} (lowercase letters, digits, dashes)`,
|
||||
);
|
||||
} else {
|
||||
if (seen.has(raw.id)) errors.push(`${where}.id duplicates "${raw.id}"`);
|
||||
seen.add(raw.id);
|
||||
}
|
||||
requireString(raw.label, `${where}.label`, errors);
|
||||
each(raw, where);
|
||||
});
|
||||
}
|
||||
|
||||
function validateLayouts(value: unknown, errors: string[]): void {
|
||||
validateLookList(
|
||||
value,
|
||||
"layouts",
|
||||
["id", "label", "description", "defaultTheme", "default"],
|
||||
errors,
|
||||
(raw, where) => {
|
||||
if (raw.description !== undefined)
|
||||
requireString(raw.description, `${where}.description`, errors);
|
||||
if (
|
||||
raw.defaultTheme !== undefined &&
|
||||
(typeof raw.defaultTheme !== "string" ||
|
||||
!LOOK_ID_PATTERN.test(raw.defaultTheme))
|
||||
) {
|
||||
errors.push(`${where}.defaultTheme must be a theme id`);
|
||||
}
|
||||
if (raw.default !== undefined && typeof raw.default !== "boolean") {
|
||||
errors.push(`${where}.default must be a boolean`);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (
|
||||
Array.isArray(value) &&
|
||||
value.filter((raw) => isPlainObject(raw) && raw.default === true).length > 1
|
||||
) {
|
||||
errors.push('Only one entry in "contributes.layouts" may set default');
|
||||
}
|
||||
}
|
||||
|
||||
const HEX_COLOR = /^#[0-9a-fA-F]{3,8}$/;
|
||||
|
||||
function validateThemes(value: unknown, errors: string[]): void {
|
||||
validateLookList(
|
||||
value,
|
||||
"themes",
|
||||
["id", "label", "mode", "css", "swatch"],
|
||||
errors,
|
||||
(raw, where) => {
|
||||
if (raw.mode !== "light" && raw.mode !== "dark") {
|
||||
errors.push(`${where}.mode must be "light" or "dark"`);
|
||||
}
|
||||
if (requireString(raw.css, `${where}.css`, errors)) {
|
||||
requireRelativePath(raw.css, `${where}.css`, errors);
|
||||
if (!raw.css.endsWith(".css")) {
|
||||
errors.push(`${where}.css must be a .css file`);
|
||||
}
|
||||
}
|
||||
if (raw.swatch !== undefined) {
|
||||
if (!isPlainObject(raw.swatch)) {
|
||||
errors.push(`${where}.swatch must be an object`);
|
||||
return;
|
||||
}
|
||||
rejectUnknown(
|
||||
raw.swatch,
|
||||
["background", "panel", "text"],
|
||||
`${where}.swatch`,
|
||||
errors,
|
||||
);
|
||||
for (const key of ["background", "panel", "text"]) {
|
||||
const color = raw.swatch[key];
|
||||
if (typeof color !== "string" || !HEX_COLOR.test(color)) {
|
||||
errors.push(`${where}.swatch.${key} must be a hex color`);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const PROTOCOL_ID_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
|
||||
@@ -2324,6 +2470,23 @@ export function parseManifest(raw: unknown): ParsedManifest {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
(manifest.contributes?.layouts?.length ?? 0) > 0 &&
|
||||
!manifest.capabilities.includes("ui:layout")
|
||||
) {
|
||||
errors.push(
|
||||
'contributes.layouts needs the "ui:layout" capability in the capabilities array',
|
||||
);
|
||||
}
|
||||
if (
|
||||
(manifest.contributes?.themes?.length ?? 0) > 0 &&
|
||||
!manifest.capabilities.includes("ui:theme")
|
||||
) {
|
||||
errors.push(
|
||||
'contributes.themes needs the "ui:theme" capability in the capabilities array',
|
||||
);
|
||||
}
|
||||
|
||||
for (const secret of manifest.requiresSecret ?? []) {
|
||||
if (secret.plugin === manifest.id) {
|
||||
errors.push(
|
||||
|
||||
@@ -174,6 +174,8 @@ 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 {
|
||||
@@ -247,6 +249,8 @@ 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 {
|
||||
@@ -574,6 +578,7 @@ export function createFakeContext(
|
||||
completedLogins: [],
|
||||
revokedSessions: [],
|
||||
loginFailures: new Map(),
|
||||
userTokens: [],
|
||||
};
|
||||
const sshClient = options.sshClient ?? {};
|
||||
let actor = options.actor;
|
||||
@@ -1140,6 +1145,18 @@ 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: {
|
||||
@@ -1442,6 +1459,8 @@ export interface MockContextOptions {
|
||||
linkedUsers?: Record<string, number>;
|
||||
/** See FakeContextOptions. */
|
||||
loginAttemptLimit?: number;
|
||||
/** See FakeContextOptions. */
|
||||
locales?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface MockPluginContext extends FakePluginContext {
|
||||
@@ -1458,7 +1477,8 @@ 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, and auth:provide on ctx.auth. Reading a plugin's own settings is deliberately
|
||||
* 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
|
||||
* ungated. As the SDK grows a member, add its gate here in the same shape.
|
||||
*/
|
||||
export function createMockCtx(
|
||||
@@ -1495,6 +1515,7 @@ export function createMockCtx(
|
||||
baseUrl: options.baseUrl,
|
||||
linkedUsers: options.linkedUsers,
|
||||
loginAttemptLimit: options.loginAttemptLimit,
|
||||
locales: options.locales,
|
||||
manifest: {
|
||||
capabilities: options.capabilities ?? [],
|
||||
...options.manifest,
|
||||
@@ -1786,6 +1807,17 @@ 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: {
|
||||
|
||||
@@ -97,6 +97,7 @@ const offline = () =>
|
||||
useHostStatus: () => null,
|
||||
useCurrentUser: () => null,
|
||||
useTheme: () => ({ theme: "dark" }),
|
||||
useShell: () => null,
|
||||
toast: { success: noop, error: noop, info: noop, warning: noop },
|
||||
getApi: () => ({
|
||||
get: offline,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Checks a theme's CSS before core serves it.
|
||||
*
|
||||
* A theme is colors and styles only, so its file may hold nothing but rules
|
||||
* whose every selector starts with the theme's own class (`.nord`,
|
||||
* `:root.nord`, `html.nord ...`), optionally inside @media or @supports.
|
||||
* Anything that could load from elsewhere or run code is refused: @import,
|
||||
* url() to another host, other at-rules, expression() and javascript:.
|
||||
*/
|
||||
|
||||
const ALLOWED_BLOCK_AT_RULES = new Set(["media", "supports"]);
|
||||
|
||||
function stripComments(css: string): string {
|
||||
return css.replace(/\/\*[\s\S]*?\*\//g, " ");
|
||||
}
|
||||
|
||||
/** Splits on commas that are not inside parentheses or brackets. */
|
||||
function splitSelectors(prelude: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
let current = "";
|
||||
for (const ch of prelude) {
|
||||
if (ch === "(" || ch === "[") depth += 1;
|
||||
if (ch === ")" || ch === "]") depth -= 1;
|
||||
if (ch === "," && depth === 0) {
|
||||
parts.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
parts.push(current);
|
||||
return parts.map((part) => part.trim());
|
||||
}
|
||||
|
||||
function withoutStrings(css: string): string {
|
||||
return css.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, '""');
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/** Index of the brace closing the one at `open`, skipping strings. */
|
||||
function matchingBrace(css: string, open: number): number {
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
for (let i = open; i < css.length; i += 1) {
|
||||
const ch = css[i];
|
||||
if (quote) {
|
||||
if (ch === "\\") i += 1;
|
||||
else if (ch === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") quote = ch;
|
||||
else if (ch === "{") depth += 1;
|
||||
else if (ch === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function checkUrls(css: string, errors: string[]): void {
|
||||
const urls = css.matchAll(/url\(\s*(['"]?)([^'")]*)\1\s*\)/gi);
|
||||
for (const match of urls) {
|
||||
const target = match[2].trim();
|
||||
if (target.startsWith("data:")) continue;
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("//")) {
|
||||
errors.push(`url(${target}) points outside the plugin`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkRules(
|
||||
css: string,
|
||||
scope: RegExp,
|
||||
themeId: string,
|
||||
errors: string[],
|
||||
): void {
|
||||
let index = 0;
|
||||
while (index < css.length) {
|
||||
const rest = css.slice(index);
|
||||
if (rest.trim() === "") return;
|
||||
const open = css.indexOf("{", index);
|
||||
const semicolon = css.indexOf(";", index);
|
||||
if (semicolon !== -1 && (open === -1 || semicolon < open)) {
|
||||
const statement = css.slice(index, semicolon).trim();
|
||||
errors.push(
|
||||
statement.startsWith("@")
|
||||
? `${statement.split(/\s/)[0]} is not allowed in a theme`
|
||||
: `Unexpected "${statement}" outside a rule`,
|
||||
);
|
||||
index = semicolon + 1;
|
||||
continue;
|
||||
}
|
||||
if (open === -1) {
|
||||
errors.push(`Unexpected "${rest.trim()}" at the end`);
|
||||
return;
|
||||
}
|
||||
const close = matchingBrace(css, open);
|
||||
if (close === -1) {
|
||||
errors.push("A rule is missing its closing brace");
|
||||
return;
|
||||
}
|
||||
const prelude = css.slice(index, open).trim();
|
||||
const body = css.slice(open + 1, close);
|
||||
|
||||
if (prelude.startsWith("@")) {
|
||||
const name = prelude.slice(1).split(/[\s(]/)[0].toLowerCase();
|
||||
if (ALLOWED_BLOCK_AT_RULES.has(name)) {
|
||||
checkRules(body, scope, themeId, errors);
|
||||
} else {
|
||||
errors.push(`@${name} is not allowed in a theme`);
|
||||
}
|
||||
} else {
|
||||
for (const selector of splitSelectors(prelude)) {
|
||||
if (!scope.test(selector)) {
|
||||
errors.push(
|
||||
`Selector "${selector}" must start with .${themeId}, :root.${themeId} or html.${themeId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (withoutStrings(body).includes("{")) {
|
||||
errors.push(`Nested rules are not allowed in "${prelude}"`);
|
||||
}
|
||||
}
|
||||
index = close + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Every problem with a theme's CSS, empty when it is safe to serve. */
|
||||
export function validateThemeCss(css: string, themeId: string): string[] {
|
||||
const errors: string[] = [];
|
||||
const clean = stripComments(css);
|
||||
if (/@import\b/i.test(clean))
|
||||
errors.push("@import is not allowed in a theme");
|
||||
if (/expression\s*\(/i.test(clean))
|
||||
errors.push("expression() is not allowed in a theme");
|
||||
if (/javascript:/i.test(clean))
|
||||
errors.push("javascript: is not allowed in a theme");
|
||||
if (/-moz-binding|behavior\s*:/i.test(clean))
|
||||
errors.push("Script bindings are not allowed in a theme");
|
||||
checkUrls(clean, errors);
|
||||
const id = escapeRegExp(themeId);
|
||||
const scope = new RegExp(`^(?::root|html)?\\.${id}(?![\\w-])`);
|
||||
checkRules(clean, scope, themeId, errors);
|
||||
return [...new Set(errors)];
|
||||
}
|
||||
@@ -30,6 +30,25 @@ 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>;
|
||||
@@ -96,15 +115,30 @@ export function pluginVitestConfig(
|
||||
// unexpectedly". Threads don't.
|
||||
const pool = "threads" as const;
|
||||
|
||||
const plugins = [wasmBinaryPlugin()];
|
||||
|
||||
// A local SDK linked into node_modules sits outside the plugin folder, so
|
||||
// Vite must be allowed to read it, and React must come from the plugin.
|
||||
const sdkDir = fs.realpathSync(fileURLToPath(new URL("..", import.meta.url)));
|
||||
const server = { fs: { allow: [pluginDir, sdkDir] } };
|
||||
const resolve = {
|
||||
alias,
|
||||
dedupe: ["react", "react-dom", "react-i18next", "i18next"],
|
||||
};
|
||||
|
||||
return {
|
||||
resolve: { alias },
|
||||
plugins,
|
||||
server,
|
||||
resolve,
|
||||
test: {
|
||||
globals: true,
|
||||
setupFiles: [setupFile],
|
||||
pool,
|
||||
projects: [
|
||||
{
|
||||
resolve: { alias },
|
||||
plugins,
|
||||
server,
|
||||
resolve,
|
||||
test: {
|
||||
name: `${pluginId}:backend`,
|
||||
root: pluginDir,
|
||||
@@ -116,7 +150,9 @@ export function pluginVitestConfig(
|
||||
},
|
||||
},
|
||||
{
|
||||
resolve: { alias },
|
||||
plugins,
|
||||
server,
|
||||
resolve,
|
||||
test: {
|
||||
name: `${pluginId}:frontend`,
|
||||
root: pluginDir,
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"moduleDetection": "force",
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"preserveSymlinks": true,
|
||||
"noEmit": true,
|
||||
"strict": false,
|
||||
"noUnusedLocals": false,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
ACCENT_PRESET_COLORS (value): { label: string; value: string; }[]
|
||||
APP_VERSION (value): string
|
||||
AccessRecord (type): AccessRecord
|
||||
ActionSlot (value): ({ slotId, context, className, enabled, renderItem, hideLabels, when, }: ActionSlotProps) => Element
|
||||
ActiveSessionInfo (type): ActiveSessionInfo
|
||||
AddButton (value): ({ label, compact, className, ...rest }: { label?: string; compact?: boolean; className?: string; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => Element
|
||||
AdminSection (type): AdminSection
|
||||
Alert (value): ({ className, variant, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; destructive: string; }; }> & ClassProp) => string>) => Element
|
||||
AlertDescription (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
AlertDialog (value): ({ ...props }: AlertDialogProps) => Element
|
||||
@@ -14,14 +19,23 @@ AlertDialogPortal (value): ({ ...props }: AlertDialogPortalProps) => Element
|
||||
AlertDialogTitle (value): ({ className, ...props }: AlertDialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
|
||||
AlertDialogTrigger (value): ({ ...props }: AlertDialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
AlertTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
ApiKey (type): ApiKey
|
||||
AuditLog (type): AuditLog
|
||||
AuditLogFilters (type): AuditLogFilters
|
||||
AuthEnrollmentSections (value): () => Element
|
||||
AuthOverrideProtocol (type): string
|
||||
BackButton (value): ({ onClick, label, className, }: { onClick: () => void; label?: string; className?: string; }) => Element
|
||||
Badge (value): ({ className, variant, asChild, ...props }: ClassAttributes<HTMLSpanElement> & HTMLAttributes<HTMLSpanElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; secondary: string; destructive: string; outline: string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element
|
||||
BarSeries (value): ({ items, max, className, colorClassName, }: BarSeriesProps) => Element
|
||||
BarSeriesItem (type): BarSeriesItem
|
||||
BarSeriesProps (type): BarSeriesProps
|
||||
BrandingSettings (type): BrandingSettings
|
||||
BrowserSignInDialog (value): ({ isOpen, label, url, code, onContinue, onCancel, onOpenUrl, backgroundColor, }: BrowserSignInDialogProps) => Element
|
||||
Button (value): ({ className, variant, size, asChild, ...props }: ClassAttributes<HTMLButtonElement> & ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; outline: string; secondary: string; ghost: string; destructive: string; link: string; }; size: { default: string; xs: string; sm: string; lg: string; icon: string; "icon-xs": string; "icon-sm": string; "icon-lg": string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element
|
||||
ButtonProps (type): ButtonProps
|
||||
CORE_HOST_DEFAULTS (value): readonly CoreHostDefault[]
|
||||
CORE_NAMESPACE (value): "core"
|
||||
CREDENTIAL_ROW_FIELD_KEYS (value): (keyof CredentialRowFields)[]
|
||||
Card (value): ({ className, size, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & { size?: "default" | "sm"; }) => Element
|
||||
CardAction (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
CardContent (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
@@ -33,6 +47,12 @@ CardMasonry (value): ({ columns, stack, className, children, }: { columns: numbe
|
||||
CardTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
Checkbox (value): ({ className, ...props }: CheckboxProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
ColumnCountStepper (value): ({ columns, onChange, }: { columns: number; onChange: (columns: number) => void; }) => Element
|
||||
Command (value): ForwardRefExoticComponent<Omit<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; shouldFilter?: boolean; filter?: CommandFilter; defaultValue?: string; value?: string; onValueChange?: (value: string) => void; loop?: boolean; disablePointerSelection?: boolean; vimBindings?: boolean; } & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
|
||||
CommandEmpty (value): ForwardRefExoticComponent<Omit<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
|
||||
CommandGroup (value): ForwardRefExoticComponent<Omit<Children & Omit<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>>, "value" | "heading"> & { heading?: ReactNode; value?: string; forceMount?: boolean; } & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
|
||||
CommandItem (value): ForwardRefExoticComponent<Omit<Children & Omit<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>>, "disabled" | "value" | "onSelect"> & { disabled?: boolean; onSelect?: (value: string) => void; value?: string; keywords?: string[]; forceMount?: boolean; } & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
|
||||
CommandList (value): ForwardRefExoticComponent<Omit<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; } & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
|
||||
CommandPrimitive (value): ForwardRefExoticComponent<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; shouldFilter?: boolean; filter?: CommandFilter; defaultValue?: string; value?: string; onValueChange?: (value: string) => void; loop?: boolean; disablePointerSelection?: boolean; vimBindings?: boolean; } & RefAttributes<HTMLDivElement>> & { List: ForwardRefExoticComponent<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; } & RefAttributes<HTMLDivElement>>; Item: ForwardRefExoticComponent<Children & Omit<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>>, "disabled" | "value" | "onSelect"> & { disabled?: boolean; onSelect?: (value: string) => void; value?: string; keywords?: string[]; forceMount?: boolean; } & RefAttributes<HTMLDivElement>>; Input: ForwardRefExoticComponent<Omit<Pick<Pick<DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, "key" | keyof InputHTMLAttributes<HTMLInputElement>> & { ref?: Ref<HTMLInputElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof InputHTMLAttributes<HTMLInputElement>>, "type" | "value" | "onChange"> & { value?: string; onValueChange?: (search: string) => void; } & RefAttributes<HTMLInputElement>>; Group: ForwardRefExoticComponent<Children & Omit<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>>, "value" | "heading"> & { heading?: ReactNode; value?: string; forceMount?: boolean; } & RefAttributes<HTMLDivElement>>; Separator: ForwardRefExoticComponent<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { alwaysRender?: boolean; } & RefAttributes<HTMLDivElement>>; Dialog: ForwardRefExoticComponent<DialogProps & Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; shouldFilter?: boolean; filter?: CommandFilter; defaultValue?: string; value?: string; onValueChange?: (value: string) => void; loop?: boolean; disablePointerSelection?: boolean; vimBindings?: boolean; } & { overlayClassName?: string; contentClassName?: string; container?: HTMLElement; } & RefAttributes<HTMLDivElement>>; Empty: ForwardRefExoticComponent<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & RefAttributes<HTMLDivElement>>; Loading: ForwardRefExoticComponent<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { progress?: number; label?: string; } & RefAttributes<HTMLDivElement>>; }
|
||||
ComponentSlot (value): ({ slotId, props, when, }: { slotId: string; props?: Record<string, unknown>; when?: Record<string, unknown>; }) => Element
|
||||
ConfirmOptions (type): ConfirmOptions
|
||||
ConnectionGate (value): (props: ConnectionGateProps) => Element
|
||||
@@ -45,9 +65,26 @@ ConnectionScreen (value): ({ status, message, detail, errorDetail, unavailable,
|
||||
ConnectionStage (type): ConnectionStage
|
||||
ConnectionStatus (type): ConnectionStatus
|
||||
ConnectionUnavailable (type): ConnectionUnavailable
|
||||
CreatedApiKey (type): CreatedApiKey
|
||||
Credential (type): Credential
|
||||
CredentialDensity (type): CredentialDensity
|
||||
CredentialPermissionLevel (type): CredentialPermissionLevel
|
||||
CredentialRowFields (type): CredentialRowFields
|
||||
CredentialSidebarFilterState (type): CredentialSidebarFilterState
|
||||
CredentialSidebarPreferences (type): CredentialSidebarPreferences
|
||||
CredentialSortKey (type): CredentialSortKey
|
||||
CredentialTrayTrigger (type): CredentialTrayTrigger
|
||||
CustomKeybinding (type): CustomKeybinding
|
||||
DASHBOARD_CARDS (value): DashboardCardConfig[]
|
||||
DEFAULT_FOLDER_COLOR (value): "#f59145"
|
||||
DEFAULT_FOLDER_ICON (value): "folder"
|
||||
DashboardCardId (type): DashboardCardId
|
||||
DataColumn (type): DataColumn<T>
|
||||
DataView (value): <T>({ items, view, density, getKey, renderCard, columns, listColumns, onRowClick, isRowActive, empty, className, }: { items: T[]; view: PanelViewMode; density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; columns: GridColumns; listColumns: DataColumn<T>[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element
|
||||
DefaultOverrides (type): DefaultOverrides
|
||||
DefaultsOnly (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
|
||||
DesktopUpdateMode (type): DesktopUpdateMode
|
||||
DesktopUpdateState (type): DesktopUpdateState
|
||||
Dialog (value): ({ ...props }: DialogProps) => Element
|
||||
DialogClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
DialogContent (value): ({ className, children, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { showCloseButton?: boolean; }) => Element
|
||||
@@ -60,6 +97,7 @@ DialogTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<
|
||||
DialogTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
DocsLink (value): ({ core, page, anchor, href, variant, children, className, }: DocsLinkProps) => Element
|
||||
DocsLinkProps (type): DocsLinkProps
|
||||
DocsPage (type): "dashboard" | "users" | "sessions" | "roles" | "branding" | "database" | "manage" | "settings" | "plugins" | "install" | "cli" | "develop" | "home" | "updating" | "betas" | "envVars" | "https" | "reverseProxy" | "desktopSync" | "interface" | "hosts" | "portKnocking" | "hostDefaults" | "credentials" | "sharing" | "account" | "accountSecurity" | "accountData" | "apiKeys" | "commandPalette" | "interfaceSettings" | "appearance" | "keybindings" | "adminGeneral" | "adminDatabase" | "adminSsl" | "auditLog" | "importExport" | "pluginCatalog" | "communityRegistry" | "pluginRegistries" | "hostRegistry"
|
||||
DropdownMenu (value): ({ ...props }: DropdownMenuProps) => Element
|
||||
DropdownMenuCheckboxItem (value): ({ className, children, checked, inset, ...props }: DropdownMenuCheckboxItemProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
|
||||
DropdownMenuContent (value): ({ className, align, sideOffset, ...props }: DropdownMenuContentProps & RefAttributes<HTMLDivElement>) => Element
|
||||
@@ -77,11 +115,17 @@ DropdownMenuSubTrigger (value): ({ className, inset, children, ...props }: Dropd
|
||||
DropdownMenuTrigger (value): ({ ...props }: DropdownMenuTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
EditingWidth (type): EditingWidth
|
||||
EmptyState (value): ({ icon: Icon, title, hint, action, className, }: { icon?: LucideIcon; title: string; hint?: string; action?: ReactNode; className?: string; }) => Element
|
||||
ExportPayload (type): ExportPayload
|
||||
FOLDER_COLORS (value): string[]
|
||||
FONT_SIZES (value): { id: FontSizeId; label: string; }[]
|
||||
Facts (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element
|
||||
FakeSwitch (value): ({ defaultChecked, disabled, checked, onChange, }: { defaultChecked?: boolean; disabled?: boolean; checked?: boolean; onChange?: (v: boolean) => void; }) => Element
|
||||
Field (value): ({ label, hint, error, aside, htmlFor, className, children, }: { label: string; hint?: ReactNode; error?: string; aside?: ReactNode; htmlFor?: string; className?: string; children: ReactNode; }) => Element
|
||||
FieldGroup (type): FieldGroup
|
||||
FieldPair (value): ({ children }: { children: ReactNode; }) => Element
|
||||
FolderAccessRule (type): FolderAccessRule
|
||||
FolderIconEl (value): ({ icon, className, style, }: { icon?: string; className?: string; style?: CSSProperties; }) => Element
|
||||
FontSizeId (type): FontSizeId
|
||||
FormFooter (value): ({ onCancel, onSave, saveLabel, cancelLabel, saving, dirty, status, disabled, onDelete, deleteLabel, extra, saveType, form, }: { onCancel?: () => void; onSave?: () => void; saveLabel?: string; cancelLabel?: string; saving?: boolean; dirty?: boolean; status?: ReactNode; disabled?: boolean; onDelete?: () => void; deleteLabel?: string; extra?: ReactNode; saveType?: "button" | "submit"; form?: string; }) => Element
|
||||
FullScreenAppPhase (type): FullScreenAppPhase
|
||||
FullScreenAppWrapper (value): FC<FullScreenAppWrapperProps>
|
||||
@@ -91,11 +135,33 @@ GridColumns (type): GridColumns
|
||||
GridLayout (type): GridLayout
|
||||
GridSlot (type): GridSlot
|
||||
GroupHeading (value): ({ title, count, action, className, }: { title: string; count?: number; action?: ReactNode; className?: string; }) => Element
|
||||
HexColorPicker (value): ({ value, onChange, }: { value: string; onChange: (hex: string) => void; }) => Element
|
||||
Host (type): Host
|
||||
HostActionDef (type): HostActionDef
|
||||
HostClickBehavior (type): HostClickBehavior
|
||||
HostData (type): HostData
|
||||
HostDefaultBadge (value): ({ settingKey }: { settingKey: string; }) => Element
|
||||
HostDefaultField (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
|
||||
HostDefaultsChange (type): HostDefaultsChange
|
||||
HostDefaultsContext (value): Context<HostDefaultsContextValue>
|
||||
HostDefaultsContextValue (type): HostDefaultsContextValue
|
||||
HostDefaultsLevel (type): HostDefaultsLevel
|
||||
HostDefaultsTarget (type): HostDefaultsTarget
|
||||
HostDensity (type): HostDensity
|
||||
HostFeatureFields (value): ({ form, updateForm, }: HostFeatureFieldsProps) => Element
|
||||
HostFeatureFieldsProps (type): HostFeatureFieldsProps
|
||||
HostFolder (type): HostFolder
|
||||
HostGroupKey (type): HostGroupKey
|
||||
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
|
||||
HostOnly (value): ({ children }: { children: ReactNode; }) => Element
|
||||
HostProtocolAuthInput (type): HostProtocolAuthInput
|
||||
HostProtocolDef (type): HostProtocolDef
|
||||
HostProtocols (type): HostProtocols
|
||||
HostRowFields (type): HostRowFields
|
||||
HostSidebarFilterState (type): HostSidebarFilterState
|
||||
HostSidebarPreferences (type): HostSidebarPreferences
|
||||
HostTrayTrigger (type): HostTrayTrigger
|
||||
IconPicker (value): ({ value, color, onChange, }: { value: string; color?: string; onChange: (icon: string) => void; }) => Element
|
||||
InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; icon?: ReactNode; status?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; footer?: ReactNode; width?: EditingWidth; onBeforeClose?: () => boolean | Promise<boolean>; scroll?: boolean; bare?: boolean; className?: string; children: ReactNode; }) => ReactPortal
|
||||
Input (value): ({ className, type, ...props }: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) => Element
|
||||
Kbd (value): ForwardRefExoticComponent<KbdProps & RefAttributes<HTMLElement>>
|
||||
@@ -103,10 +169,14 @@ KbdKey (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): El
|
||||
KbdSeparator (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): Element; displayName: string; }
|
||||
KeyCombo (type): KeyCombo
|
||||
KeybindingAction (type): KeybindingAction
|
||||
KeybindingActionDef (type): KeybindingActionDef
|
||||
KeybindingActionType (type): string
|
||||
LANGUAGES (value): readonly { code: string; label: string; }[]
|
||||
Label (value): ({ className, ...props }: LabelProps & RefAttributes<HTMLLabelElement>) => Element
|
||||
LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element
|
||||
LineChartSeries (type): LineChartSeries
|
||||
LinkServerDialog (value): ({ open, onOpenChange, onLinked, relogin, }: LinkServerDialogProps) => Element
|
||||
LinkedAccountInfo (type): LinkedAccountInfo
|
||||
ListBadge (value): ({ tone, className, children, }: { tone?: "destructive" | "muted" | "brand" | "warning" | "success"; className?: string; children: ReactNode; }) => Element
|
||||
ListRow (value): ({ title, icon, badges, meta, stripe, tone, color, selected, active, dimmed, leading, trailing, actions, onClick, className, children, ...rest }: { title: ReactNode; icon?: ReactNode; badges?: ReactNode; meta?: ReactNode; stripe?: number; tone?: ListRowTone; color?: string; selected?: boolean; active?: boolean; dimmed?: boolean; leading?: ReactNode; trailing?: ReactNode; actions?: ReactNode; onClick?: () => void; className?: string; children?: ReactNode; } & Omit<HTMLAttributes<HTMLDivElement>, "title" | "children" | "onClick">) => Element
|
||||
ListRowAction (value): ({ label, tone, className, children, ...rest }: { label: string; tone?: "default" | "destructive" | "brand"; className?: string; children: ReactNode; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => Element
|
||||
@@ -114,7 +184,12 @@ ListRowFolder (value): ({ name, icon, count, open, onToggle, stripe, actions, dr
|
||||
ListRowTone (type): ListRowTone
|
||||
LogContext (type): LogContext
|
||||
LogEntry (type): LogEntry
|
||||
LookSettings (value): ({ isAdmin }: { isAdmin: boolean; }) => Element
|
||||
MANAGE_REQUEST_EVENT (value): "manage:request"
|
||||
MAX_PANES (value): 8
|
||||
MFAPromptMode (type): MFAPromptMode
|
||||
ManageMode (type): ManageMode
|
||||
ManageRequest (type): ManageRequest
|
||||
ManagerCardError (type): ManagerCardError
|
||||
ManagerCardShell (value): ({ title, icon, loading, error, onRefresh, empty, emptyMessage, children, headerExtra, }: { title: string; icon: ReactNode; loading: boolean; error: ManagerCardError; onRefresh: () => void; empty?: boolean; emptyMessage?: string; children: ReactNode; headerExtra?: ReactNode; }) => Element
|
||||
ManagerSearch (value): ({ value, onChange, placeholder, count, extra, }: { value: string; onChange: (value: string) => void; placeholder?: string; count?: number; extra?: ReactNode; }) => Element
|
||||
@@ -122,10 +197,13 @@ Meter (value): ({ percent, className, }: { percent: number; className?: string;
|
||||
MetricCard (value): ({ title, icon, action, children, bodyClassName, scroll, scrollMax, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; bodyClassName?: string; scroll?: boolean; scrollMax?: number; }) => Element
|
||||
MiniStat (value): ({ caption, value, valueClassName, className, }: { caption: ReactNode; value: ReactNode; valueClassName?: string; className?: string; }) => Element
|
||||
NumberField (value): ({ label, value, onChange, placeholder, hint, error, min, max, disabled, className, }: { label: string; value: number; onChange: (value: number) => void; placeholder?: string; hint?: ReactNode; error?: string; min?: number; max?: number; disabled?: boolean; className?: string; }) => Element
|
||||
OPEN_PLUGINS_EVENT (value): "termix:open-plugins"
|
||||
OpenTabRecord (type): OpenTabRecord
|
||||
PANEL (value): { readonly body: "p-2.5"; readonly gap: "gap-2"; readonly band: "px-3 py-2"; readonly cardHead: "px-3 py-2.5"; readonly header: "h-12.5"; }
|
||||
PROMPT_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-border px-3 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
PROMPT_DESTRUCTIVE_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 bg-destructive px-3 text-xs font-medium text-white transition-colors hover:bg-destructive/90 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
PROMPT_PRIMARY_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-accent-brand/40 px-3 text-xs font-medium text-accent-brand transition-colors hover:bg-accent-brand/10 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
PaletteItemDef (type): PaletteItemDef
|
||||
PanePrompt (value): ({ open, title, titleId, description, icon, tone, actions, onCancel, role, labelledBy, layer, backgroundColor, className, children, }: { open: boolean; title: ReactNode; titleId?: string; description?: ReactNode; icon?: ReactNode; tone?: "default" | "destructive"; actions?: ReactNode; onCancel?: () => void; role?: "dialog" | "alertdialog"; labelledBy?: string; layer?: "connection" | "pane"; backgroundColor?: string; className?: string; children?: ReactNode; }) => Element
|
||||
PanelDensity (type): PanelDensity
|
||||
PanelList (value): ({ empty, className, children, }: { empty?: ReactNode; className?: string; children?: ReactNode; }) => Element
|
||||
@@ -134,18 +212,35 @@ PanelShell (value): ({ chrome, icon, title, status, leading, actions, toolbar, t
|
||||
PanelViewMode (type): PanelViewMode
|
||||
PassphraseDialog (value): ({ isOpen, onSubmit, onCancel, hostInfo, backgroundColor, }: PassphraseDialogProps) => Element
|
||||
PasswordInput (value): ForwardRefExoticComponent<PasswordInputProps & RefAttributes<HTMLInputElement>>
|
||||
PermissionCatalogEntry (type): PermissionCatalogEntry
|
||||
PluginComponent (value): ({ id, fallback, ...props }: { [prop: string]: unknown; id: string; fallback?: ReactNode; }) => ReactNode
|
||||
PluginIcon (value): ({ name, ...props }: { name?: string; } & LucideProps) => Element
|
||||
PluginSettingsErrors (type): PluginSettingsErrors
|
||||
PluginSettingsField (type): PluginSettingsField
|
||||
PluginSettingsValidationError (value): typeof PluginSettingsValidationError
|
||||
PluginSummary (type): PluginSummary
|
||||
PluginViewPlaceholder (value): ({ kind, viewId, compact, }: { kind: ViewKind; viewId: string; compact?: boolean; }) => Element
|
||||
Popover (value): ({ ...props }: PopoverProps) => Element
|
||||
PopoverAnchor (value): ({ ...props }: PopoverAnchorProps & RefAttributes<HTMLDivElement>) => Element
|
||||
PopoverContent (value): ({ className, align, sideOffset, ...props }: PopoverContentProps & RefAttributes<HTMLDivElement>) => Element
|
||||
PopoverTrigger (value): ({ ...props }: PopoverTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
ProxyNode (type): ProxyNode
|
||||
ProxySettingsFields (value): ({ customHeaders, basicAuth, onChange, }: ProxySettingsFieldsProps) => Element
|
||||
RadialGauge (value): ({ value, label, caption, size, strokeWidth, className, colorClassName, }: RadialGaugeProps) => Element
|
||||
RadialGaugeProps (type): RadialGaugeProps
|
||||
RailGroup (type): RailGroup
|
||||
RailItemDef (type): RailItemDef
|
||||
RecentActivityItem (type): RecentActivityItem
|
||||
ReleaseInfo (type): ReleaseInfo
|
||||
Repeater (value): <T>({ label, items, onChange, makeItem, empty, addLabel, hint, renderItem, }: { label: string; items: T[]; onChange: (items: T[]) => void; makeItem: () => T; empty: string; addLabel: string; hint?: ReactNode; renderItem: (item: T, update: (next: T) => void, index: number) => ReactNode; }) => Element
|
||||
ResolvedHostDefault (type): ResolvedHostDefault
|
||||
RobustClipboardProvider (value): typeof RobustClipboardProvider
|
||||
Role (type): Role
|
||||
SSHAuthDialog (value): ({ isOpen, reason, onSubmit, onCancel, hostInfo, backgroundColor, }: SSHAuthDialogProps) => Element
|
||||
SSHHost (type): Host
|
||||
SSHHostData (type): HostData
|
||||
SSHHostWithStatus (type): SSHHostWithStatus
|
||||
SSH_AUTH_PROTOCOL (value): "ssh"
|
||||
ScrollArea (value): ({ className, children, ...props }: ScrollAreaProps & RefAttributes<HTMLDivElement>) => Element
|
||||
ScrollBar (value): ({ className, orientation, ...props }: ScrollAreaScrollbarProps & RefAttributes<HTMLDivElement>) => Element
|
||||
SecretField (value): ({ label, value, onChange, placeholder, hint, error, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; disabled?: boolean; className?: string; }) => Element
|
||||
@@ -165,6 +260,9 @@ SelectTrigger (value): ({ className, size, children, ...props }: SelectTriggerPr
|
||||
SelectValue (value): ({ ...props }: SelectValueProps & RefAttributes<HTMLSpanElement>) => Element
|
||||
Separator (value): ({ className, orientation, decorative, ...props }: SeparatorProps & RefAttributes<HTMLDivElement>) => Element
|
||||
SettingRow (value): ({ label, badge, description, defaultKey, rowId, children, }: { label: string; badge?: string; description?: ReactNode; defaultKey?: string; rowId?: string; children: ReactNode; }) => Element
|
||||
SettingsFieldRow (value): ({ pluginId, field, values, setValue, running, error, disabled, defaultKey, }: SettingsFieldProps) => Element
|
||||
SharePermissionLevel (type): SharePermissionLevel
|
||||
ShareTarget (type): ShareTarget
|
||||
Sheet (value): ({ ...props }: DialogProps) => Element
|
||||
SheetClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
SheetContent (value): ({ className, children, side, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { side?: "top" | "right" | "bottom" | "left"; showCloseButton?: boolean; }) => Element
|
||||
@@ -173,71 +271,381 @@ SheetFooter (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<
|
||||
SheetHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
SheetTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
|
||||
SheetTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
ShellConfirmCapture (value): () => null
|
||||
Skeleton (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderProps & RefAttributes<HTMLSpanElement>) => Element
|
||||
SortKey (type): SortKey
|
||||
Sparkline (value): ({ data, domain, className, colorClassName, showLastDot, height, }: SparklineProps) => Element
|
||||
SparklineProps (type): SparklineProps
|
||||
SplitLayoutMenu (value): ({ activeIsSplit, canAddPane, onSplit, onPreset, }: { activeIsSplit: boolean; canAddPane: boolean; onSplit: (edge: "right" | "bottom") => void; onPreset: (id: SplitPresetId) => void; }) => Element
|
||||
SplitOpenTarget (type): SplitOpenTarget
|
||||
SplitSummary (type): SplitSummary
|
||||
StatRow (value): ({ label, value, mono, className, }: { label: ReactNode; value: ReactNode; mono?: boolean; className?: string; }) => Element
|
||||
StatusValue (type): StatusValue
|
||||
SurfaceKind (type): SurfaceKind
|
||||
SurfaceScope (value): ({ kind, onEditingChange, onClose, className, children, }: { kind?: SurfaceKind; onClose?: () => void; onEditingChange?: (editing: EditingWidth) => void; className?: string; children: ReactNode; }) => Element
|
||||
Switch (value): ({ className, ...props }: SwitchProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
SwitchRow (value): ({ label, description, badge, checked, onChange, disabled, defaultKey, rowId, }: { label: string; description?: ReactNode; badge?: string; checked: boolean; onChange: (value: boolean) => void; disabled?: boolean; defaultKey?: string; rowId?: string; }) => Element
|
||||
SyncBasicAuth (type): SyncBasicAuth
|
||||
SyncConflictItem (type): SyncConflictItem
|
||||
SyncEntityInfo (type): SyncEntityInfo
|
||||
SyncErrorItem (type): SyncErrorItem
|
||||
SyncProxyHeader (type): SyncProxyHeader
|
||||
SyncStatus (type): SyncStatus
|
||||
TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element
|
||||
Tab (type): Tab
|
||||
TabShellCallbacks (type): TabShellCallbacks
|
||||
TabSplitAction (type): TabSplitAction
|
||||
TabStage (value): ({ className }: { className?: string; }) => Element
|
||||
TabStrip (value): ({ tabs, activeTab, onTabChange, isActive, variant, trailing, className, }: { tabs: TabStripItem[]; activeTab: string; onTabChange: (id: string) => void; isActive?: (id: string) => boolean; variant?: "secondary" | "primary"; trailing?: ReactNode; className?: string; }) => Element
|
||||
TabStripItem (type): TabStripItem
|
||||
TabType (type): TabType
|
||||
TagInput (value): ({ tags, input, placeholder, onTagsChange, onInputChange, }: { tags: string[]; input: string; placeholder: string; onTagsChange: (tags: string[]) => void; onInputChange: (value: string) => void; }) => Element
|
||||
TextAreaField (value): ({ label, value, onChange, placeholder, rows, hint, error, aside, mono, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; rows?: number; hint?: ReactNode; error?: string; aside?: ReactNode; mono?: boolean; disabled?: boolean; className?: string; }) => Element
|
||||
TextField (value): ({ label, value, onChange, placeholder, hint, error, aside, disabled, mono, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; aside?: ReactNode; disabled?: boolean; mono?: boolean; className?: string; }) => Element
|
||||
Textarea (value): ForwardRefExoticComponent<TextareaProps & RefAttributes<HTMLTextAreaElement>>
|
||||
TextareaProps (type): TextareaProps
|
||||
ThemeId (type): string
|
||||
TlsStatus (type): TlsStatus
|
||||
ToolsTab (type): ToolsTab
|
||||
Tooltip (value): ({ ...props }: TooltipProps) => Element
|
||||
TooltipContent (value): ({ className, sideOffset, children, ...props }: TooltipContentProps & RefAttributes<HTMLDivElement>) => Element
|
||||
TooltipProvider (value): ({ delayDuration, ...props }: TooltipProviderProps) => Element
|
||||
TooltipTrigger (value): ({ ...props }: TooltipTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
UI_AREA_KEYS (value): UiAreaKey[]
|
||||
UI_FONTS (value): { id: UiFontId; label: string; family: string; }[]
|
||||
UiAreaKey (type): UiAreaKey
|
||||
UiFontId (type): UiFontId
|
||||
UiPreset (type): UiPreset
|
||||
UpdateChannelState (type): UpdateChannelState
|
||||
UptimeInfo (type): UptimeInfo
|
||||
UsagePair (value): ({ label, percent, detail, }: { label?: string; percent: number; detail?: string; }) => Element
|
||||
UserPreferences (type): UserPreferences
|
||||
UserRole (type): UserRole
|
||||
UserSecondFactorSummary (type): UserSecondFactorSummary
|
||||
VersionBadge (value): ({ status, releaseUrl, className, }: VersionBadgeProps) => Element
|
||||
VersionInfo (type): VersionInfo
|
||||
ViewToggle (value): ({ view, onView, density, onDensity, }: { view: PanelViewMode; onView: (next: PanelViewMode) => void; density?: PanelDensity; onDensity?: (next: PanelDensity) => void; }) => Element
|
||||
WebSocketConnectionTarget (type): WebSocketConnectionTarget
|
||||
WidgetTitle (value): ({ title, icon, fallback }: WidgetTitleProps) => Element
|
||||
actionOrder (value): (action: HostActionDef) => number
|
||||
activityTarget (value): (type: string) => ActivityTarget
|
||||
adminCreateUser (value): (username: string, password: string) => Promise<Record<string, unknown>>
|
||||
adminCreateUserCredential (value): (targetUserId: string, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||
adminCreateUserHost (value): (targetUserId: string, hostData: HostData) => Promise<Host>
|
||||
adminDeleteUserCredential (value): (targetUserId: string, credentialId: number) => Promise<Record<string, unknown>>
|
||||
adminDeleteUserHost (value): (targetUserId: string, hostId: number) => Promise<Record<string, unknown>>
|
||||
adminExportUserData (value): (userId: string) => Promise<Record<string, unknown>>
|
||||
adminGetHostPassword (value): (targetUserId: string, hostId: number, field?: "key" | "password" | "sudoPassword" | "keyPassword") => Promise<string>
|
||||
adminGetUserCredentials (value): (targetUserId: string) => Promise<Record<string, unknown> | Record<string, unknown>[]>
|
||||
adminGetUserHosts (value): (targetUserId: string) => Promise<Host[]>
|
||||
adminResetUserPassword (value): (userId: string, newPassword: string, confirmDataWipe?: boolean) => Promise<{ message: string; dataWiped?: boolean; }>
|
||||
adminUpdateUserCredential (value): (targetUserId: string, credentialId: number, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||
adminUpdateUserHost (value): (targetUserId: string, hostId: number, hostData: HostData) => Promise<Host>
|
||||
applyAccentColor (value): (colorValue: string) => void
|
||||
applyFontSize (value): (id: FontSizeId) => void
|
||||
applyPresetSideEffects (value): (preset: "advanced" | "simple" | "balanced") => Promise<void>
|
||||
applyUiFont (value): (id: UiFontId) => void
|
||||
assignRoleToUser (value): (userId: string, roleId: number) => Promise<{ success: boolean; }>
|
||||
beginSplitDrag (value): (source: SplitDragSource, x: number, y: number) => void
|
||||
buildExportPayload (value): (raw: ExportPayload, selected: Set<string>, groups: Set<FieldGroup>, withCredentials: boolean, pluginSecrets?: PluginSecretKeys) => ExportPayload
|
||||
buildOriginWsUrl (value): ({ origin, localPort, localPath, remotePath, includeJwt, }: { origin: ConnectionOrigin; localPort: number; localPath: string; remotePath: string; includeJwt?: boolean; }) => Promise<WebSocketConnectionTarget>
|
||||
bulkImportSSHHosts (value): (hosts: HostData[], overwrite?: boolean, credentials?: Record<string, unknown>[]) => Promise<{ message: string; success: number; updated: number; skipped: number; failed: number; errors: string[]; }>
|
||||
bulkUpdateSSHHosts (value): (hostIds: number[], updates: Record<string, unknown>) => Promise<{ updated: number; failed: number; errors: string[]; }>
|
||||
canConfigureUpdates (value): (state: DesktopUpdateState) => state is DesktopUpdateState
|
||||
canJoinSplit (value): (tab: Tab) => boolean
|
||||
changeAppLanguage (value): (language: string) => Promise<string>
|
||||
changePassword (value): (oldPassword: string, newPassword: string) => Promise<any>
|
||||
clearLocalAdaptivePreferences (value): () => void
|
||||
cn (value): (...inputs: ClassValue[]) => string
|
||||
copyToClipboard (value): (text: string) => Promise<boolean>
|
||||
createApiKey (value): (name: string, userId: string, expiresAt?: string) => Promise<CreatedApiKey>
|
||||
createCredential (value): (credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||
createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger
|
||||
createQuickConnectHost (value): (input: QuickConnectInput) => Host
|
||||
createRole (value): (roleData: { name: string; displayName: string; description?: string; }) => Promise<{ role: Role; }>
|
||||
createSSHHost (value): (hostData: HostData) => Promise<Host>
|
||||
defaultConnectAction (value): (all: HostActionDef[], host: Host) => HostActionDef
|
||||
defaultCredentialRowFields (value): () => CredentialRowFields
|
||||
defaultCredentialSidebarPreferences (value): () => CredentialSidebarPreferences
|
||||
defaultHostRowFields (value): () => HostRowFields
|
||||
defaultHostSidebarPreferences (value): () => HostSidebarPreferences
|
||||
defaultValuesEqual (value): (a: unknown, b: unknown) => boolean
|
||||
deleteAccount (value): (password: string) => Promise<Record<string, unknown>>
|
||||
deleteAllHostsInFolder (value): (folderName: string) => Promise<{ deletedCount: number; }>
|
||||
deleteApiKey (value): (keyId: string) => Promise<{ success: boolean; }>
|
||||
deleteCredential (value): (credentialId: number) => Promise<Record<string, unknown>>
|
||||
deleteOpenTab (value): (instanceId: string) => Promise<void>
|
||||
deleteRole (value): (roleId: number) => Promise<{ success: boolean; }>
|
||||
deleteSSHHost (value): (hostId: number) => Promise<Record<string, unknown>>
|
||||
deleteUser (value): (username: string, successorUserId?: string) => Promise<Record<string, unknown>>
|
||||
deployCredentialToHost (value): (credentialId: number, targetHostId: number) => Promise<Record<string, unknown>>
|
||||
desktopUpdates (value): () => DesktopUpdatesBridge
|
||||
docsUrl (value): (page?: "dashboard" | "users" | "sessions" | "roles" | "branding" | "database" | "manage" | "settings" | "plugins" | "install" | "cli" | "develop" | "home" | "updating" | "betas" | "envVars" | "https" | "reverseProxy" | "desktopSync" | "interface" | "hosts" | "portKnocking" | "hostDefaults" | "credentials" | "sharing" | "account" | "accountSecurity" | "accountData" | "apiKeys" | "commandPalette" | "interfaceSettings" | "appearance" | "keybindings" | "adminGeneral" | "adminDatabase" | "adminSsl" | "auditLog" | "importExport" | "pluginCatalog" | "communityRegistry" | "pluginRegistries" | "hostRegistry") => string
|
||||
duplicateCredential (value): (credentialId: number, data: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||
enabledHostProtocols (value): (host: { pluginSettings?: Record<string, Record<string, unknown>>; }, list?: HostProtocolDef[]) => HostProtocolDef[]
|
||||
endSplitDrag (value): (commit: boolean) => void
|
||||
exportAllSSHHosts (value): { (): Promise<{ hosts: Host[]; }>; (options: { share: true; }): Promise<{ version: string; exportedAt: string; credentials: Record<string, unknown>[]; hosts: Host[]; }>; }
|
||||
filterPaletteItems (value): (items: PaletteItemDef[], query: string, showWhenEmpty?: boolean) => PaletteItemDef[]
|
||||
findCoreHostDefault (value): (key: string) => CoreHostDefault
|
||||
findMatchingKeybinding (value): (e: KeyboardEvent, bindings: CustomKeybinding[]) => CustomKeybinding
|
||||
generateKeyPair (value): (keyType: "ssh-ed25519" | "ssh-rsa" | "ecdsa-sha2-nistp256", keySize?: number, passphrase?: string) => Promise<GeneratedKeyPair>
|
||||
generatePublicKeyFromPrivate (value): (privateKey: string, keyPassword?: string) => Promise<GeneratedPublicKey>
|
||||
getActiveSessions (value): () => Promise<ActiveSessionInfo[]>
|
||||
getAdaptiveResourceBudget (value): (kind: AdaptiveTaskKind) => AdaptiveResourceBudget
|
||||
getApiKeys (value): () => Promise<{ apiKeys: ApiKey[]; }>
|
||||
getAuditForwarding (value): () => Promise<AuditForwardingSettings>
|
||||
getAuditLogActions (value): () => Promise<{ actions: string[]; }>
|
||||
getAuditLogs (value): (filters?: AuditLogFilters) => Promise<AuditLogResponse>
|
||||
getBasePath (value): () => string
|
||||
getBranding (value): () => Promise<BrandingSettings>
|
||||
getChannelReleases (value): () => Promise<{ localVersion: string; stable: ReleaseInfo; beta: ReleaseInfo; }>
|
||||
getConnectedRemoteApi (value): () => Promise<AxiosInstance>
|
||||
getCredentialAccess (value): (credentialId: number) => Promise<{ access: AccessRecord[]; }>
|
||||
getCredentialDetails (value): (credentialId: number) => Promise<Record<string, unknown>>
|
||||
getCredentialSidebarPreferences (value): () => Promise<CredentialSidebarPreferences>
|
||||
getCredentials (value): () => Promise<Record<string, unknown> | Record<string, unknown>[]>
|
||||
getDatabaseHealth (value): () => Promise<Record<string, unknown>>
|
||||
getDatabaseTransferUrl (value): (operation: "export" | "import", { electron, configuredServerUrl, location }: DatabaseTransferUrlOptions) => string
|
||||
getDefaultConnectionTab (value): (host: Host) => TabType
|
||||
getDeveloperMode (value): () => Promise<DeveloperModeState>
|
||||
getDeviceId (value): () => string
|
||||
getErrorMessage (value): (error: unknown, fallback?: string) => string
|
||||
getExternalAutoProvision (value): () => Promise<{ enabled: boolean; }>
|
||||
getFolderAccess (value): (folder: string) => Promise<{ rules: FolderAccessRule[]; }>
|
||||
getFolderDefaultsId (value): (name: string) => Promise<number>
|
||||
getHostAccess (value): (hostId: number, syncId?: string) => Promise<{ accessList: AccessRecord[]; isOwner?: boolean; }>
|
||||
getHostAuthOverride (value): (hostId: number, protocol: string, sharedCopySyncId?: string) => Promise<{ protocol: string; credentialId: number; }>
|
||||
getHostDefaultsLevel (value): (target: HostDefaultsTarget) => Promise<HostDefaultsLevelData>
|
||||
getHostEditorSection (value): (id: string) => HostEditorSectionDef
|
||||
getHostPassword (value): (hostId: number, field?: "key" | "password" | "sudoPassword" | "keyPassword") => Promise<string>
|
||||
getHostSidebarPreferences (value): () => Promise<HostSidebarPreferences>
|
||||
getHostTags (value): () => Promise<string[]>
|
||||
getLiveHostStatus (value): (hostId: number) => StatusValue
|
||||
getLogLevel (value): () => Promise<{ level: string; }>
|
||||
getNotificationPrivateEndpoints (value): () => Promise<string[]>
|
||||
getPanel (value): (id: string) => PanelDef
|
||||
getPasswordLoginAllowed (value): () => Promise<{ allowed: boolean; forced?: boolean; }>
|
||||
getPasswordResetAllowed (value): () => Promise<boolean>
|
||||
getPermissionsCatalog (value): () => Promise<{ catalog: PermissionCatalogEntry[]; }>
|
||||
getPluginAdminSettings (value): (pluginId: string) => Promise<PluginSettingsValues>
|
||||
getPluginHostSettings (value): (pluginId: string, hostId: number) => Promise<PluginSettingsValues>
|
||||
getPluginUserSettings (value): (pluginId: string) => Promise<PluginSettingsValues>
|
||||
getPlugins (value): () => Promise<PluginSummary[]>
|
||||
getPollingEnvironmentMultiplier (value): (connection?: NetworkInformationLike) => number
|
||||
getPreferredHostAction (value): (hostId: string, candidates: readonly TabType[], fallback: TabType) => TabType
|
||||
getRecentActivity (value): (limit?: number) => Promise<RecentActivityItem[]>
|
||||
getRegisteredDashboardCard (value): (id: string) => RegisteredDashboardCard
|
||||
getRegistrationAllowed (value): () => Promise<{ allowed: boolean; }>
|
||||
getRoles (value): () => Promise<{ roles: Role[]; }>
|
||||
getSSHFolders (value): () => Promise<SSHFolder[]>
|
||||
getSSHHosts (value): () => Promise<SSHHostWithStatus[]>
|
||||
getSecondFactorAfterExternalLogin (value): () => Promise<{ enabled: boolean; }>
|
||||
getSessionTimeout (value): () => Promise<{ timeoutHours: number; }>
|
||||
getSessionTimeoutMinutes (value): () => Promise<number>
|
||||
getSessions (value): () => Promise<{ sessions: { id: string; userId: string; username?: string; deviceType: string; deviceInfo: string; createdAt: string; expiresAt: string; lastActiveAt: string; isRevoked?: boolean; isCurrentSession?: boolean; }[]; }>
|
||||
getStatusCheckSettings (value): () => Promise<{ statusCheckInterval: number; }>
|
||||
getStatusClasses (value): (status: boolean | "online" | "offline" | "unknown", scheme: StatusColorScheme, variant: "dot" | "stripe" | "badge", loading?: boolean) => string
|
||||
getSyncConflicts (value): () => Promise<SyncConflictItem[]>
|
||||
getSyncErrors (value): () => Promise<SyncErrorItem[]>
|
||||
getTabType (value): (id: string) => TabTypeDef
|
||||
getTlsStatus (value): () => Promise<TlsStatus>
|
||||
getUpdateChannel (value): () => Promise<UpdateChannelState>
|
||||
getUptime (value): () => Promise<UptimeInfo>
|
||||
getUserInfo (value): () => Promise<UserInfo>
|
||||
getUserList (value): (options?: UserListOptions) => Promise<{ users: UserInfo[]; total?: number; }>
|
||||
getUserPreferences (value): () => Promise<UserPreferences>
|
||||
getUserRoles (value): (userId: string) => Promise<{ roles: UserRole[]; }>
|
||||
getUserSecondFactors (value): (userId: string) => Promise<UserSecondFactorSummary[]>
|
||||
getVersionInfo (value): (checkRemote?: boolean) => Promise<VersionInfo>
|
||||
globalShortcutHandler (value): { current: (e: KeyboardEvent) => void; }
|
||||
groupRailItems (value): (items: RailItemDef[]) => { group: RailGroup; items: RailItemDef[]; }[]
|
||||
hasVisibleFields (value): (fields: PluginSettingsField[]) => boolean
|
||||
hostActionsFor (value): (all: HostActionDef[], host: Host, kind?: "open" | "connect") => HostActionDef[]
|
||||
hostBadgesFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
|
||||
hostKey (value): (host: Record<string, unknown>) => string
|
||||
hostMenuItemsFor (value): (all: HostContextMenuItemDef[], host: Host) => HostContextMenuItemDef[]
|
||||
hostProtocolFlags (value): (host: { enableSsh?: boolean; pluginSettings?: Record<string, Record<string, unknown>>; }, list?: HostProtocolDef[]) => HostProtocols
|
||||
hydrateLocalSharedHostAuth (value): <T>(host: T) => Promise<T>
|
||||
importSSHConfigHosts (value): (content: string, overwrite?: boolean) => Promise<{ message: string; success: number; updated: number; skipped: number; failed: number; errors: string[]; }>
|
||||
invalidateHostsAndStatusCaches (value): () => void
|
||||
invalidateServerStatusCache (value): () => void
|
||||
isCoreRailView (value): (id: string) => boolean
|
||||
isElectron (value): () => boolean
|
||||
isFieldShown (value): (field: PluginSettingsField, values: Record<string, unknown>) => boolean
|
||||
isMacPlatform (value): () => boolean
|
||||
isPersistentTabType (value): (type: string) => boolean
|
||||
isPrerelease (value): (version: string) => boolean
|
||||
isSessionTabType (value): (type: string) => boolean
|
||||
isSplitDragging (value): () => boolean
|
||||
isSplitTab (value): (tab: Tab) => tab is SplitTab
|
||||
isTabJumpHotkey (value): (e: KeyboardEvent) => boolean
|
||||
linkExternalToPasswordAccount (value): (externalUserId: string, targetUsername: string) => Promise<{ success: boolean; message: string; }>
|
||||
linkedServerUrl (value): () => Promise<string>
|
||||
listHostActions (value): () => HostActionDef[]
|
||||
listHostProtocols (value): () => HostProtocolDef[]
|
||||
loadPaletteGroup (value): (group: PaletteGroupDef) => Promise<PaletteItemDef[]>
|
||||
logoutUser (value): () => Promise<{ success: boolean; message: string; }>
|
||||
makeHostSshSubTabs (value): (t: (key: string, options?: { defaultValue?: string; }) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean) => HostTab[]
|
||||
makeHostTabs (value): (t: (key: string, options?: { defaultValue?: string; }) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean) => HostTab[]
|
||||
makeUserAdmin (value): (userId: string) => Promise<Record<string, unknown>>
|
||||
mapCredentials (value): (res: unknown) => Credential[]
|
||||
markAdaptiveResourceUsed (value): (kind: AdaptiveTaskKind, action: string, now?: number) => void
|
||||
markTabSurfaceUsed (value): (type: TabType) => void
|
||||
maskSecrets (value): (payload: ExportPayload, pluginSecrets?: PluginSecretKeys) => ExportPayload
|
||||
moveRailItem (value): (items: RailItemDef[], id: string, beforeId: string) => string[]
|
||||
moveSplitDrag (value): (x: number, y: number) => void
|
||||
normalizeLanguageCode (value): (language?: string) => string
|
||||
notifySyncChanged (value): () => void
|
||||
openInSplit (value): (target: SplitOpenTarget, open: () => void) => void
|
||||
paletteEntriesFor (value): (all: PaletteEntryDef[], scope: "global" | "host", host?: Host) => PaletteEntryDef[]
|
||||
parseCustomKeybindings (value): (raw?: string) => CustomKeybinding[]
|
||||
parseServerTime (value): (ts: string) => Date
|
||||
pluginKey (value): { (pluginId: string, key: string): string; (pluginId: string, key: string): string; }
|
||||
pluginName (value): (t: Translator, plugin: { id: string; name: string; }) => string
|
||||
pluginSecretKeys (value): (summaries: { id: string; contributes?: { settings?: { host?: { fields?: { key: string; secretKeys?: string[]; }[]; }; }; }; }[]) => PluginSecretKeys
|
||||
pluginWsUrl (value): (pluginId: string, path: string, options?: { origin?: ConnectionOrigin; }) => Promise<WebSocketConnectionTarget>
|
||||
preloadTabSurface (value): (type: TabType) => void
|
||||
previewHostDefaultsLevel (value): (target: HostDefaultsTarget, change: HostDefaultsChange) => Promise<{ changedHosts: number; }>
|
||||
promotableIds (value): () => string[]
|
||||
protocolEnabled (value): (pluginSettings: Record<string, Record<string, unknown>>, protocol: HostProtocolDef) => boolean
|
||||
protocolPort (value): (pluginSettings: Record<string, Record<string, unknown>>, protocol: HostProtocolDef) => number
|
||||
quickConnectTargets (value): (all: HostActionDef[], host: Host) => HostActionDef[]
|
||||
railItemLabel (value): (id: string, t: (key: string) => string) => string
|
||||
readFromClipboard (value): () => Promise<string>
|
||||
recordHostActionPreference (value): (hostId: string, action: TabType) => void
|
||||
refreshSyncStatus (value): () => void
|
||||
registerLayout (value): (item: RegisteredLayout) => () => void
|
||||
registerTabCloseGuard (value): (tabId: string, guard: Guard) => () => void
|
||||
releaseUrlFrom (value): (info: VersionInfo) => string
|
||||
rem (value): (px: number) => string
|
||||
removeAdminStatus (value): (userId: string) => Promise<Record<string, unknown>>
|
||||
removeRoleFromUser (value): (userId: string, roleId: number) => Promise<{ success: boolean; }>
|
||||
renameCredentialFolder (value): (oldName: string, newName: string) => Promise<Record<string, unknown>>
|
||||
renameFolder (value): (oldName: string, newName: string) => Promise<Record<string, unknown>>
|
||||
reorderCredentials (value): (positions: { id: number; sortOrder: number; }[]) => Promise<{ updated: number; }>
|
||||
reorderFolders (value): (positions: { name: string; sortOrder: number; }[]) => Promise<{ updated: number; }>
|
||||
reorderSSHHosts (value): (positions: { id: number; sortOrder: number; }[]) => Promise<{ updated: number; }>
|
||||
reportBetaFeedbackUrl (value): (termixVersion?: string, betaPlugins?: { id: string; version: string; }[]) => string
|
||||
reportCoreIssueUrl (value): (termixVersion?: string) => string
|
||||
requestFromLegacyEvent (value): (name: string, detail?: unknown) => ManageRequest
|
||||
requestManage (value): (request: ManageRequest) => void
|
||||
resetHostToDefaults (value): (hostIds: number[], reset: { all?: boolean; namespaces?: string[]; keys?: string[]; }) => Promise<void>
|
||||
resetRecentActivity (value): () => Promise<{ message: string; }>
|
||||
resetUserSecondFactors (value): (userId: string) => Promise<void>
|
||||
resolveConnectionOrigin (value): (host: OriginResolvableHost, options?: { defaultRemote?: boolean; }) => Promise<ConnectionOrigin>
|
||||
resolveHostDefaults (value): (where: { hostId?: number; folder?: string; parentHostId?: number; }, adminTargetUserId?: string) => Promise<Record<string, ResolvedHostDefault>>
|
||||
resolveHostTabType (value): (host: Host, preferredType?: TabType) => TabType
|
||||
resolveRemoteHostId (value): (syncId: string) => Promise<number>
|
||||
retrySyncErrors (value): () => Promise<SyncStatus>
|
||||
revokeAllUserSessions (value): (userId: string) => Promise<{ success: boolean; message: string; }>
|
||||
revokeCredentialAccess (value): (credentialId: number, accessId: number) => Promise<void>
|
||||
revokeFolderAccess (value): (ruleId: number) => Promise<void>
|
||||
revokeHostAccess (value): (hostId: number, accessId: number, syncId?: string) => Promise<{ success: boolean; }>
|
||||
revokeSession (value): (sessionId: string) => Promise<{ success: boolean; message: string; }>
|
||||
rightDockableIds (value): () => string[]
|
||||
roleLabel (value): <T extends string | null | undefined>(t: Translator, displayName: T) => string | T
|
||||
runAdaptiveBackgroundTask (value): (kind: AdaptiveTaskKind, action: string, task: () => Promise<unknown>, options?: AdaptiveTaskOptions) => boolean
|
||||
runAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePollResult>, policy: AdaptivePollingPolicy, options?: { enabled?: () => boolean; visible?: () => boolean; runImmediately?: boolean; random?: () => number; intervalMultiplier?: () => number; onError?: (error: unknown) => void; }) => () => void
|
||||
runHostAction (value): (action: HostActionDef, host: Host, shell: TabShellCallbacks) => void
|
||||
runVisibleInterval (value): (tick: () => void, intervalMs: number) => () => void
|
||||
sanitizeCredentialSidebarPreferences (value): (input: unknown) => CredentialSidebarPreferences
|
||||
sanitizeHostSidebarPreferences (value): (input: unknown) => HostSidebarPreferences
|
||||
saveCredentialSidebarPreferences (value): (preferences: CredentialSidebarPreferences) => Promise<void>
|
||||
saveHostDefaultsLevel (value): (target: HostDefaultsTarget, change: HostDefaultsChange) => Promise<{ changedHosts?: number; jobId?: string; }>
|
||||
saveHostSidebarPreferences (value): (preferences: HostSidebarPreferences) => Promise<void>
|
||||
saveHostTags (value): (tags: string[]) => Promise<string[]>
|
||||
saveUserPreferences (value): (prefs: Partial<UserPreferences>) => Promise<void>
|
||||
setAllPluginChannels (value): (channel: PluginChannel) => Promise<{ changed: string[]; }>
|
||||
setDeveloperMode (value): (enabled: boolean) => Promise<void>
|
||||
setHostAuthOverride (value): (hostId: number, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; protocol: string; credentialId: number; }>
|
||||
setNotificationPrivateEndpoints (value): (hosts: string[]) => Promise<string[]>
|
||||
setPluginOptions (value): (pluginId: string, options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel; }) => Promise<{ autoUpdate: boolean; pinnedVersion: string; channel: PluginChannel; }>
|
||||
setSyncStatus (value): (next: SyncStatus) => void
|
||||
setUpdateChannel (value): (channel: UpdateChannel) => Promise<UpdateChannelState>
|
||||
settleSyncConflict (value): (id: number, keep: "mine" | "server") => Promise<void>
|
||||
shareCredential (value): (credentialId: number, targets: ShareTarget[], permissionLevel: CredentialPermissionLevel, durationHours?: number) => Promise<{ success: boolean; expiresAt: string; }>
|
||||
shareFolder (value): (folder: string, shareData: { targets: ShareTarget[]; permissionLevel: SharePermissionLevel; durationHours?: number; }) => Promise<{ success: boolean; expiresAt: string; hostsShared: number; hostsTotal: number; hostResults: { hostId: number; shared: boolean; reason?: string; }[]; }>
|
||||
shareHost (value): (hostId: number, shareData: { targets: ShareTarget[]; permissionLevel: SharePermissionLevel; durationHours?: number; }, syncId?: string) => Promise<{ success: boolean; expiresAt: string; results: { type: "role" | "user"; id: string | number; accessId: number; created: boolean; }[]; }>
|
||||
shell (value): TabShellCallbacks
|
||||
splitDefaultKey (value): (fullKey: string) => [string, string]
|
||||
sshHostToHost (value): (h: SSHHostWithStatus) => Host
|
||||
syncNow (value): () => Promise<SyncStatus>
|
||||
tabIcon (value): (type: TabType) => Element
|
||||
tabJumpHotkeyKeys (value): () => string[]
|
||||
takePendingManageRequest (value): () => ManageRequest
|
||||
unlinkExternalFromPasswordAccount (value): (userId: string) => Promise<{ success: boolean; message: string; }>
|
||||
unlinkServer (value): (keepData: boolean) => Promise<SyncStatus>
|
||||
updateAuditForwarding (value): (url: string, token?: string) => Promise<{ url: string; hasToken: boolean; }>
|
||||
updateBranding (value): (update: BrandingSettingsUpdate) => Promise<BrandingSettings>
|
||||
updateCredential (value): (credentialId: number, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||
updateExternalAutoProvision (value): (enabled: boolean) => Promise<Record<string, unknown>>
|
||||
updateFolderMetadata (value): (name: string, color?: string, icon?: string, credentialId?: number, localOnly?: boolean) => Promise<void>
|
||||
updateHostAccess (value): (hostId: number, accessId: number, update: { permissionLevel?: SharePermissionLevel; durationHours?: number; }, syncId?: string) => Promise<{ success: boolean; expiresAt: string; }>
|
||||
updateLogLevel (value): (level: string) => Promise<void>
|
||||
updatePasswordLoginAllowed (value): (allowed: boolean) => Promise<{ allowed: boolean; }>
|
||||
updatePasswordResetAllowed (value): (allowed: boolean) => Promise<{ allowed: boolean; }>
|
||||
updatePluginAdminSettings (value): (pluginId: string, values: PluginSettingsValues) => Promise<PluginSettingsValues>
|
||||
updatePluginHostSettings (value): (pluginId: string, hostId: number, values: PluginSettingsValues) => Promise<PluginSettingsValues>
|
||||
updatePluginUserSettings (value): (pluginId: string, values: PluginSettingsValues) => Promise<PluginSettingsValues>
|
||||
updateRegistrationAllowed (value): (allowed: boolean) => Promise<Record<string, unknown>>
|
||||
updateRole (value): (roleId: number, roleData: { displayName?: string; description?: string; permissions?: string[]; }) => Promise<{ role: Role; }>
|
||||
updateSSHHost (value): (hostId: number, hostData: HostData) => Promise<Host>
|
||||
updateSecondFactorAfterExternalLogin (value): (enabled: boolean) => Promise<Record<string, unknown>>
|
||||
updateSessionTimeout (value): (timeoutHours: number) => Promise<void>
|
||||
updateStatusCheckSettings (value): (settings: { statusCheckInterval: number; }) => Promise<void>
|
||||
updateSyncSettings (value): (settings: { disabledTypes?: string[]; customHeaders?: SyncProxyHeader[]; basicAuth?: SyncBasicAuth; allowInvalidCertificate?: boolean; }) => Promise<SyncStatus>
|
||||
uploadTlsCertificate (value): (payload: { certificate: string; privateKey: string; }) => Promise<TlsStatus & { success: boolean; reloadMessage?: string; }>
|
||||
usageBarColor (value): (percent: number) => string
|
||||
usageColor (value): (percent: number) => string
|
||||
useActionSlot (value): (slotId: string, context?: Record<string, unknown>) => SlotContribution[]
|
||||
useAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePollResult>, policy: AdaptivePollingPolicy, enabled?: boolean, options?: { runImmediately?: boolean; onError?: (error: unknown) => void; }) => void
|
||||
useAppTheme (value): () => ThemeProviderState
|
||||
useAreaPreferences (value): <A extends UiAreaKey>(area: A) => UiAreaPreferences[A]
|
||||
useBranding (value): () => BrandingContextValue
|
||||
useChooseTheme (value): () => (themeId: string) => void
|
||||
useConfirm (value): () => (options: ConfirmOptions) => Promise<boolean>
|
||||
useConnectionGate (value): () => ConnectionGateController
|
||||
useConnectionLog (value): () => ConnectionLogContextType
|
||||
useDesktopUpdate (value): () => DesktopUpdateState
|
||||
useEditorDefaultKeys (value): () => Map<string, EditorDefaultKey>
|
||||
useHostActions (value): () => HostActionDef[]
|
||||
useHostBadges (value): () => HostBadgeDef[]
|
||||
useHostContextMenuItems (value): () => HostContextMenuItemDef[]
|
||||
useHostEditorSections (value): () => HostEditorSectionDef[]
|
||||
useHostProtocols (value): () => HostProtocolDef[]
|
||||
useHostStatus (value): (hostId: number, statusCheckEnabled?: boolean) => StatusValue
|
||||
useInstalledPlugins (value): () => PluginSummary[]
|
||||
useIsDefaultsEditor (value): () => boolean
|
||||
useIsMobile (value): () => boolean
|
||||
useKeybindingActions (value): () => KeybindingActionDef[]
|
||||
useKeybindingDefaults (value): () => KeybindingDefaultDef[]
|
||||
useOptionalConnectionLog (value): () => ConnectionLogContextType
|
||||
usePageVisibleInterval (value): (callback: () => void, intervalMs: number, enabled?: boolean, options?: Options) => void
|
||||
usePaletteEntries (value): () => PaletteEntryDef[]
|
||||
usePaletteGroups (value): () => PaletteGroupDef[]
|
||||
usePanels (value): () => PanelDef[]
|
||||
usePermissions (value): () => PermissionsState
|
||||
usePluginDocsUrl (value): (pluginId: string, page?: string, anchor?: string) => string
|
||||
usePluginHostSections (value): () => PluginSummary[]
|
||||
usePluginStore (value): () => StoreState
|
||||
useRailItems (value): () => RailItemDef[]
|
||||
useRegisteredDashboardCards (value): () => RegisteredDashboardCard[]
|
||||
useServerStatus (value): () => ServerStatusContextType
|
||||
useServerStatusMeta (value): () => { initialLoadComplete: boolean; }
|
||||
useShellController (value): () => ShellController
|
||||
useSplitTargets (value): () => SplitTargetsSnapshot
|
||||
useSshAuthEditors (value): () => SshAuthEditorDef[]
|
||||
useSshAuthProviders (value): () => { providers: SshAuthTypeOption[]; loaded: boolean; find: (type: string) => SshAuthTypeOption; }
|
||||
useStatusColorScheme (value): () => StatusColorScheme
|
||||
useSurfaceKind (value): () => SurfaceKind
|
||||
useSyncStatus (value): () => any
|
||||
useTabHandlesVersion (value): () => number
|
||||
useTabs (value): () => TabContextType
|
||||
useTabsSafe (value): () => TabContextType
|
||||
useTheme (value): () => ThemeProviderState
|
||||
useThemeOptions (value): () => ThemeOption[]
|
||||
useUiPreference (value): <A extends UiAreaKey, K extends keyof UiAreaPreferences[A]>(area: A, key: K) => UiAreaPreferences[A][K]
|
||||
useUiPreferencesContext (value): () => UiPreferencesContextValue
|
||||
waitForDefaultsJob (value): (id: string, intervalMs?: number, maxWaitMs?: number) => Promise<number>
|
||||
withProtocolSettings (value): (pluginSettings: Record<string, Record<string, unknown>>, flags: HostProtocols, list?: HostProtocolDef[]) => Record<string, Record<string, unknown>>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 47 KiB |
+6
-1
@@ -1,4 +1,9 @@
|
||||
const CACHE_NAME = "termix-static-v2";
|
||||
// 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 BASE_PATH = "__TERMIX_SW_BASE_PATH__";
|
||||
const STATIC_ASSETS = [
|
||||
`${BASE_PATH}/favicon.ico`,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* pinned sha256 and unpacked.
|
||||
*
|
||||
* For development, `--local <dir>` (or TERMIX_LOCAL_PLUGINS) points at a
|
||||
* folder of plugin repos like ../Termix-Plugins. A plugin there whose sources
|
||||
* folder of plugin repos like ../Plugins. A plugin there whose sources
|
||||
* changed since its last build is rebuilt first with this checkout's SDK, so
|
||||
* SDK edits apply without publishing, then any built one (has
|
||||
* dist/) is copied in place of its pin, or added when it has no pin.
|
||||
|
||||
@@ -416,3 +416,71 @@ describe("rebuilding local plugins", () => {
|
||||
expect(run.sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("default look", () => {
|
||||
const entry = { source: "tmxplug", path: "a", sha256: SHA };
|
||||
|
||||
it("reads the defaultLook flag and refuses anything but a boolean", () => {
|
||||
const { plugins, problems } = parseBundledPlugins({
|
||||
plugins: [
|
||||
{ ...entry, id: "look", defaultLook: true },
|
||||
{ ...entry, id: "plain" },
|
||||
{ ...entry, id: "odd", defaultLook: "yes" },
|
||||
],
|
||||
});
|
||||
expect(problems).toEqual(["odd defaultLook must be a boolean"]);
|
||||
expect(
|
||||
plugins.map((p: { id: string; defaultLook: boolean }) => [
|
||||
p.id,
|
||||
p.defaultLook,
|
||||
]),
|
||||
).toEqual([
|
||||
["look", true],
|
||||
["plain", false],
|
||||
]);
|
||||
});
|
||||
|
||||
it("carries it into the bundled index and through a re-pin", () => {
|
||||
expect(
|
||||
buildBundledIndex(
|
||||
[
|
||||
{ id: "look", onboarding: null, defaultLook: true },
|
||||
{ id: "plain", onboarding: null, defaultLook: false },
|
||||
],
|
||||
["look", "plain"],
|
||||
),
|
||||
).toEqual({ version: 1, plugins: { look: { defaultLook: true } } });
|
||||
|
||||
const index = {
|
||||
plugins: [
|
||||
{
|
||||
id: "look",
|
||||
versions: [
|
||||
{ version: "1.0.0", url: "https://x/l.tmxplug", sha256: SHA },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(
|
||||
pinsFromIndex(index, null, [{ id: "look", defaultLook: true }])[0],
|
||||
).toMatchObject({ id: "look", defaultLook: true });
|
||||
});
|
||||
|
||||
it("copies a local build's theme files", () => {
|
||||
const repo = tempDir();
|
||||
fs.mkdirSync(path.join(repo, "dist"), { recursive: true });
|
||||
fs.mkdirSync(path.join(repo, "themes"), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo, "dist", "backend.js"), "export {}");
|
||||
fs.writeFileSync(path.join(repo, "themes", "paper.css"), ".paper{}");
|
||||
fs.writeFileSync(
|
||||
path.join(repo, "manifest.json"),
|
||||
JSON.stringify({
|
||||
id: "inks",
|
||||
contributes: { themes: [{ id: "paper", css: "themes/paper.css" }] },
|
||||
}),
|
||||
);
|
||||
const out = path.join(tempDir(), "inks");
|
||||
copyLocalBuild(repo, out);
|
||||
expect(fs.existsSync(path.join(out, "themes", "paper.css"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,8 @@ const OFFICIAL_IDS = [
|
||||
"ai",
|
||||
"alerts",
|
||||
"automations",
|
||||
"classic-layout",
|
||||
"classic-themes",
|
||||
"docker",
|
||||
"file-manager",
|
||||
"fleets",
|
||||
|
||||
@@ -19,8 +19,13 @@ const SDK_UI = path.join(ROOT, "src", "ui", "plugin-host", "sdk-ui.ts");
|
||||
const TSCONFIG = path.join(ROOT, "tsconfig.app.json");
|
||||
|
||||
const ALLOWED = [
|
||||
"ACCENT_PRESET_COLORS",
|
||||
"APP_VERSION",
|
||||
"AccessRecord",
|
||||
"ActionSlot",
|
||||
"ActiveSessionInfo",
|
||||
"AddButton",
|
||||
"AdminSection",
|
||||
"Alert",
|
||||
"AlertDescription",
|
||||
"AlertDialog",
|
||||
@@ -35,14 +40,23 @@ const ALLOWED = [
|
||||
"AlertDialogTitle",
|
||||
"AlertDialogTrigger",
|
||||
"AlertTitle",
|
||||
"ApiKey",
|
||||
"AuditLog",
|
||||
"AuditLogFilters",
|
||||
"AuthEnrollmentSections",
|
||||
"AuthOverrideProtocol",
|
||||
"BackButton",
|
||||
"Badge",
|
||||
"BarSeries",
|
||||
"BarSeriesItem",
|
||||
"BarSeriesProps",
|
||||
"BrandingSettings",
|
||||
"BrowserSignInDialog",
|
||||
"Button",
|
||||
"ButtonProps",
|
||||
"CORE_HOST_DEFAULTS",
|
||||
"CORE_NAMESPACE",
|
||||
"CREDENTIAL_ROW_FIELD_KEYS",
|
||||
"Card",
|
||||
"CardAction",
|
||||
"CardContent",
|
||||
@@ -54,6 +68,12 @@ const ALLOWED = [
|
||||
"CardTitle",
|
||||
"Checkbox",
|
||||
"ColumnCountStepper",
|
||||
"Command",
|
||||
"CommandEmpty",
|
||||
"CommandGroup",
|
||||
"CommandItem",
|
||||
"CommandList",
|
||||
"CommandPrimitive",
|
||||
"ComponentSlot",
|
||||
"ConfirmOptions",
|
||||
"ConnectionGate",
|
||||
@@ -66,9 +86,26 @@ const ALLOWED = [
|
||||
"ConnectionStage",
|
||||
"ConnectionStatus",
|
||||
"ConnectionUnavailable",
|
||||
"CreatedApiKey",
|
||||
"Credential",
|
||||
"CredentialDensity",
|
||||
"CredentialPermissionLevel",
|
||||
"CredentialRowFields",
|
||||
"CredentialSidebarFilterState",
|
||||
"CredentialSidebarPreferences",
|
||||
"CredentialSortKey",
|
||||
"CredentialTrayTrigger",
|
||||
"CustomKeybinding",
|
||||
"DASHBOARD_CARDS",
|
||||
"DEFAULT_FOLDER_COLOR",
|
||||
"DEFAULT_FOLDER_ICON",
|
||||
"DashboardCardId",
|
||||
"DataColumn",
|
||||
"DataView",
|
||||
"DefaultOverrides",
|
||||
"DefaultsOnly",
|
||||
"DesktopUpdateMode",
|
||||
"DesktopUpdateState",
|
||||
"Dialog",
|
||||
"DialogClose",
|
||||
"DialogContent",
|
||||
@@ -81,6 +118,7 @@ const ALLOWED = [
|
||||
"DialogTrigger",
|
||||
"DocsLink",
|
||||
"DocsLinkProps",
|
||||
"DocsPage",
|
||||
"DropdownMenu",
|
||||
"DropdownMenuCheckboxItem",
|
||||
"DropdownMenuContent",
|
||||
@@ -98,11 +136,17 @@ const ALLOWED = [
|
||||
"DropdownMenuTrigger",
|
||||
"EditingWidth",
|
||||
"EmptyState",
|
||||
"ExportPayload",
|
||||
"FOLDER_COLORS",
|
||||
"FONT_SIZES",
|
||||
"Facts",
|
||||
"FakeSwitch",
|
||||
"Field",
|
||||
"FieldGroup",
|
||||
"FieldPair",
|
||||
"FolderAccessRule",
|
||||
"FolderIconEl",
|
||||
"FontSizeId",
|
||||
"FormFooter",
|
||||
"FullScreenAppPhase",
|
||||
"FullScreenAppWrapper",
|
||||
@@ -112,11 +156,33 @@ const ALLOWED = [
|
||||
"GridLayout",
|
||||
"GridSlot",
|
||||
"GroupHeading",
|
||||
"HexColorPicker",
|
||||
"Host",
|
||||
"HostActionDef",
|
||||
"HostClickBehavior",
|
||||
"HostData",
|
||||
"HostDefaultBadge",
|
||||
"HostDefaultField",
|
||||
"HostDefaultsChange",
|
||||
"HostDefaultsContext",
|
||||
"HostDefaultsContextValue",
|
||||
"HostDefaultsLevel",
|
||||
"HostDefaultsTarget",
|
||||
"HostDensity",
|
||||
"HostFeatureFields",
|
||||
"HostFeatureFieldsProps",
|
||||
"HostFolder",
|
||||
"HostGroupKey",
|
||||
"HostKeyVerificationDialog",
|
||||
"HostOnly",
|
||||
"HostProtocolAuthInput",
|
||||
"HostProtocolDef",
|
||||
"HostProtocols",
|
||||
"HostRowFields",
|
||||
"HostSidebarFilterState",
|
||||
"HostSidebarPreferences",
|
||||
"HostTrayTrigger",
|
||||
"IconPicker",
|
||||
"InlineView",
|
||||
"Input",
|
||||
"Kbd",
|
||||
@@ -124,10 +190,14 @@ const ALLOWED = [
|
||||
"KbdSeparator",
|
||||
"KeyCombo",
|
||||
"KeybindingAction",
|
||||
"KeybindingActionDef",
|
||||
"KeybindingActionType",
|
||||
"LANGUAGES",
|
||||
"Label",
|
||||
"LineChart",
|
||||
"LineChartSeries",
|
||||
"LinkServerDialog",
|
||||
"LinkedAccountInfo",
|
||||
"ListBadge",
|
||||
"ListRow",
|
||||
"ListRowAction",
|
||||
@@ -135,7 +205,12 @@ const ALLOWED = [
|
||||
"ListRowTone",
|
||||
"LogContext",
|
||||
"LogEntry",
|
||||
"LookSettings",
|
||||
"MANAGE_REQUEST_EVENT",
|
||||
"MAX_PANES",
|
||||
"MFAPromptMode",
|
||||
"ManageMode",
|
||||
"ManageRequest",
|
||||
"ManagerCardError",
|
||||
"ManagerCardShell",
|
||||
"ManagerSearch",
|
||||
@@ -143,11 +218,14 @@ const ALLOWED = [
|
||||
"MetricCard",
|
||||
"MiniStat",
|
||||
"NumberField",
|
||||
"OPEN_PLUGINS_EVENT",
|
||||
"OpenTabRecord",
|
||||
"PANEL",
|
||||
"PanePrompt",
|
||||
"PROMPT_BUTTON",
|
||||
"PROMPT_DESTRUCTIVE_BUTTON",
|
||||
"PROMPT_PRIMARY_BUTTON",
|
||||
"PaletteItemDef",
|
||||
"PanePrompt",
|
||||
"PanelDensity",
|
||||
"PanelList",
|
||||
"PanelSearch",
|
||||
@@ -155,18 +233,35 @@ const ALLOWED = [
|
||||
"PanelViewMode",
|
||||
"PassphraseDialog",
|
||||
"PasswordInput",
|
||||
"PermissionCatalogEntry",
|
||||
"PluginComponent",
|
||||
"PluginIcon",
|
||||
"PluginSettingsErrors",
|
||||
"PluginSettingsField",
|
||||
"PluginSettingsValidationError",
|
||||
"PluginSummary",
|
||||
"PluginViewPlaceholder",
|
||||
"Popover",
|
||||
"PopoverAnchor",
|
||||
"PopoverContent",
|
||||
"PopoverTrigger",
|
||||
"ProxyNode",
|
||||
"ProxySettingsFields",
|
||||
"RadialGauge",
|
||||
"RadialGaugeProps",
|
||||
"RailGroup",
|
||||
"RailItemDef",
|
||||
"RecentActivityItem",
|
||||
"ReleaseInfo",
|
||||
"Repeater",
|
||||
"ResolvedHostDefault",
|
||||
"RobustClipboardProvider",
|
||||
"Role",
|
||||
"SSHAuthDialog",
|
||||
"SSHHost",
|
||||
"SSHHostData",
|
||||
"SSHHostWithStatus",
|
||||
"SSH_AUTH_PROTOCOL",
|
||||
"ScrollArea",
|
||||
"ScrollBar",
|
||||
"SecretField",
|
||||
@@ -186,6 +281,9 @@ const ALLOWED = [
|
||||
"SelectValue",
|
||||
"Separator",
|
||||
"SettingRow",
|
||||
"SettingsFieldRow",
|
||||
"SharePermissionLevel",
|
||||
"ShareTarget",
|
||||
"Sheet",
|
||||
"SheetClose",
|
||||
"SheetContent",
|
||||
@@ -194,74 +292,384 @@ const ALLOWED = [
|
||||
"SheetHeader",
|
||||
"SheetTitle",
|
||||
"SheetTrigger",
|
||||
"ShellConfirmCapture",
|
||||
"Skeleton",
|
||||
"Slider",
|
||||
"SortKey",
|
||||
"Sparkline",
|
||||
"SparklineProps",
|
||||
"SplitLayoutMenu",
|
||||
"SplitOpenTarget",
|
||||
"SplitSummary",
|
||||
"StatRow",
|
||||
"StatusValue",
|
||||
"SurfaceKind",
|
||||
"SurfaceScope",
|
||||
"Switch",
|
||||
"SwitchRow",
|
||||
"SyncBasicAuth",
|
||||
"SyncConflictItem",
|
||||
"SyncEntityInfo",
|
||||
"SyncErrorItem",
|
||||
"SyncProxyHeader",
|
||||
"SyncStatus",
|
||||
"TOTPDialog",
|
||||
"Tab",
|
||||
"TabShellCallbacks",
|
||||
"TabSplitAction",
|
||||
"TabStage",
|
||||
"TabStrip",
|
||||
"TabStripItem",
|
||||
"TabType",
|
||||
"TagInput",
|
||||
"TextAreaField",
|
||||
"TextField",
|
||||
"Textarea",
|
||||
"TextareaProps",
|
||||
"ThemeId",
|
||||
"TlsStatus",
|
||||
"ToolsTab",
|
||||
"Tooltip",
|
||||
"TooltipContent",
|
||||
"TooltipProvider",
|
||||
"TooltipTrigger",
|
||||
"UI_AREA_KEYS",
|
||||
"UI_FONTS",
|
||||
"UiAreaKey",
|
||||
"UiFontId",
|
||||
"UiPreset",
|
||||
"UpdateChannelState",
|
||||
"UptimeInfo",
|
||||
"UsagePair",
|
||||
"UserPreferences",
|
||||
"UserRole",
|
||||
"UserSecondFactorSummary",
|
||||
"VersionBadge",
|
||||
"VersionInfo",
|
||||
"ViewToggle",
|
||||
"WebSocketConnectionTarget",
|
||||
"WidgetTitle",
|
||||
"actionOrder",
|
||||
"activityTarget",
|
||||
"adminCreateUser",
|
||||
"adminCreateUserCredential",
|
||||
"adminCreateUserHost",
|
||||
"adminDeleteUserCredential",
|
||||
"adminDeleteUserHost",
|
||||
"adminExportUserData",
|
||||
"adminGetHostPassword",
|
||||
"adminGetUserCredentials",
|
||||
"adminGetUserHosts",
|
||||
"adminResetUserPassword",
|
||||
"adminUpdateUserCredential",
|
||||
"adminUpdateUserHost",
|
||||
"applyAccentColor",
|
||||
"applyFontSize",
|
||||
"applyPresetSideEffects",
|
||||
"applyUiFont",
|
||||
"assignRoleToUser",
|
||||
"beginSplitDrag",
|
||||
"buildExportPayload",
|
||||
"buildOriginWsUrl",
|
||||
"bulkImportSSHHosts",
|
||||
"bulkUpdateSSHHosts",
|
||||
"canConfigureUpdates",
|
||||
"canJoinSplit",
|
||||
"changeAppLanguage",
|
||||
"changePassword",
|
||||
"clearLocalAdaptivePreferences",
|
||||
"cn",
|
||||
"copyToClipboard",
|
||||
"createApiKey",
|
||||
"createCredential",
|
||||
"createFrontendLogger",
|
||||
"createQuickConnectHost",
|
||||
"createRole",
|
||||
"createSSHHost",
|
||||
"defaultConnectAction",
|
||||
"defaultCredentialRowFields",
|
||||
"defaultCredentialSidebarPreferences",
|
||||
"defaultHostRowFields",
|
||||
"defaultHostSidebarPreferences",
|
||||
"defaultValuesEqual",
|
||||
"deleteAccount",
|
||||
"deleteAllHostsInFolder",
|
||||
"deleteApiKey",
|
||||
"deleteCredential",
|
||||
"deleteOpenTab",
|
||||
"deleteRole",
|
||||
"deleteSSHHost",
|
||||
"deleteUser",
|
||||
"deployCredentialToHost",
|
||||
"desktopUpdates",
|
||||
"docsUrl",
|
||||
"duplicateCredential",
|
||||
"enabledHostProtocols",
|
||||
"endSplitDrag",
|
||||
"exportAllSSHHosts",
|
||||
"filterPaletteItems",
|
||||
"findCoreHostDefault",
|
||||
"findMatchingKeybinding",
|
||||
"generateKeyPair",
|
||||
"generatePublicKeyFromPrivate",
|
||||
"getActiveSessions",
|
||||
"getAdaptiveResourceBudget",
|
||||
"getApiKeys",
|
||||
"getAuditForwarding",
|
||||
"getAuditLogActions",
|
||||
"getAuditLogs",
|
||||
"getBasePath",
|
||||
"getBranding",
|
||||
"getChannelReleases",
|
||||
"getConnectedRemoteApi",
|
||||
"getCredentialAccess",
|
||||
"getCredentialDetails",
|
||||
"getCredentialSidebarPreferences",
|
||||
"getCredentials",
|
||||
"getDatabaseHealth",
|
||||
"getDatabaseTransferUrl",
|
||||
"getDefaultConnectionTab",
|
||||
"getDeveloperMode",
|
||||
"getDeviceId",
|
||||
"getErrorMessage",
|
||||
"getExternalAutoProvision",
|
||||
"getFolderAccess",
|
||||
"getFolderDefaultsId",
|
||||
"getHostAccess",
|
||||
"getHostAuthOverride",
|
||||
"getHostDefaultsLevel",
|
||||
"getHostEditorSection",
|
||||
"getHostPassword",
|
||||
"getHostSidebarPreferences",
|
||||
"getHostTags",
|
||||
"getLiveHostStatus",
|
||||
"getLogLevel",
|
||||
"getNotificationPrivateEndpoints",
|
||||
"getPanel",
|
||||
"getPasswordLoginAllowed",
|
||||
"getPasswordResetAllowed",
|
||||
"getPermissionsCatalog",
|
||||
"getPluginAdminSettings",
|
||||
"getPluginHostSettings",
|
||||
"getPluginUserSettings",
|
||||
"getPlugins",
|
||||
"getPollingEnvironmentMultiplier",
|
||||
"getPreferredHostAction",
|
||||
"getRecentActivity",
|
||||
"getRegisteredDashboardCard",
|
||||
"getRegistrationAllowed",
|
||||
"getRoles",
|
||||
"getSSHFolders",
|
||||
"getSSHHosts",
|
||||
"getSecondFactorAfterExternalLogin",
|
||||
"getSessionTimeout",
|
||||
"getSessionTimeoutMinutes",
|
||||
"getSessions",
|
||||
"getStatusCheckSettings",
|
||||
"getStatusClasses",
|
||||
"getSyncConflicts",
|
||||
"getSyncErrors",
|
||||
"getTabType",
|
||||
"getTlsStatus",
|
||||
"getUpdateChannel",
|
||||
"getUptime",
|
||||
"getUserInfo",
|
||||
"getUserList",
|
||||
"getUserPreferences",
|
||||
"getUserRoles",
|
||||
"getUserSecondFactors",
|
||||
"getVersionInfo",
|
||||
"globalShortcutHandler",
|
||||
"groupRailItems",
|
||||
"hasVisibleFields",
|
||||
"hostActionsFor",
|
||||
"hostBadgesFor",
|
||||
"hostKey",
|
||||
"hostMenuItemsFor",
|
||||
"hostProtocolFlags",
|
||||
"hydrateLocalSharedHostAuth",
|
||||
"importSSHConfigHosts",
|
||||
"invalidateHostsAndStatusCaches",
|
||||
"invalidateServerStatusCache",
|
||||
"isCoreRailView",
|
||||
"isElectron",
|
||||
"isFieldShown",
|
||||
"isMacPlatform",
|
||||
"isPersistentTabType",
|
||||
"isPrerelease",
|
||||
"isSessionTabType",
|
||||
"isSplitDragging",
|
||||
"isSplitTab",
|
||||
"isTabJumpHotkey",
|
||||
"linkExternalToPasswordAccount",
|
||||
"linkedServerUrl",
|
||||
"listHostActions",
|
||||
"listHostProtocols",
|
||||
"loadPaletteGroup",
|
||||
"logoutUser",
|
||||
"makeHostSshSubTabs",
|
||||
"makeHostTabs",
|
||||
"makeUserAdmin",
|
||||
"mapCredentials",
|
||||
"markAdaptiveResourceUsed",
|
||||
"markTabSurfaceUsed",
|
||||
"maskSecrets",
|
||||
"moveRailItem",
|
||||
"moveSplitDrag",
|
||||
"normalizeLanguageCode",
|
||||
"notifySyncChanged",
|
||||
"openInSplit",
|
||||
"paletteEntriesFor",
|
||||
"parseCustomKeybindings",
|
||||
"parseServerTime",
|
||||
"pluginKey",
|
||||
"pluginName",
|
||||
"pluginSecretKeys",
|
||||
"pluginWsUrl",
|
||||
"preloadTabSurface",
|
||||
"previewHostDefaultsLevel",
|
||||
"promotableIds",
|
||||
"protocolEnabled",
|
||||
"protocolPort",
|
||||
"quickConnectTargets",
|
||||
"railItemLabel",
|
||||
"readFromClipboard",
|
||||
"recordHostActionPreference",
|
||||
"refreshSyncStatus",
|
||||
"registerLayout",
|
||||
"registerTabCloseGuard",
|
||||
"releaseUrlFrom",
|
||||
"rem",
|
||||
"removeAdminStatus",
|
||||
"removeRoleFromUser",
|
||||
"renameCredentialFolder",
|
||||
"renameFolder",
|
||||
"reorderCredentials",
|
||||
"reorderFolders",
|
||||
"reorderSSHHosts",
|
||||
"reportBetaFeedbackUrl",
|
||||
"reportCoreIssueUrl",
|
||||
"requestFromLegacyEvent",
|
||||
"requestManage",
|
||||
"resetHostToDefaults",
|
||||
"resetRecentActivity",
|
||||
"resetUserSecondFactors",
|
||||
"resolveConnectionOrigin",
|
||||
"resolveHostDefaults",
|
||||
"resolveHostTabType",
|
||||
"resolveRemoteHostId",
|
||||
"retrySyncErrors",
|
||||
"revokeAllUserSessions",
|
||||
"revokeCredentialAccess",
|
||||
"revokeFolderAccess",
|
||||
"revokeHostAccess",
|
||||
"revokeSession",
|
||||
"rightDockableIds",
|
||||
"roleLabel",
|
||||
"runAdaptiveBackgroundTask",
|
||||
"runAdaptivePolling",
|
||||
"runHostAction",
|
||||
"runVisibleInterval",
|
||||
"sanitizeCredentialSidebarPreferences",
|
||||
"sanitizeHostSidebarPreferences",
|
||||
"saveCredentialSidebarPreferences",
|
||||
"saveHostDefaultsLevel",
|
||||
"saveHostSidebarPreferences",
|
||||
"saveHostTags",
|
||||
"saveUserPreferences",
|
||||
"setAllPluginChannels",
|
||||
"setDeveloperMode",
|
||||
"setHostAuthOverride",
|
||||
"setNotificationPrivateEndpoints",
|
||||
"setPluginOptions",
|
||||
"setSyncStatus",
|
||||
"setUpdateChannel",
|
||||
"settleSyncConflict",
|
||||
"shareCredential",
|
||||
"shareFolder",
|
||||
"shareHost",
|
||||
"shell",
|
||||
"splitDefaultKey",
|
||||
"sshHostToHost",
|
||||
"syncNow",
|
||||
"tabIcon",
|
||||
"tabJumpHotkeyKeys",
|
||||
"takePendingManageRequest",
|
||||
"unlinkExternalFromPasswordAccount",
|
||||
"unlinkServer",
|
||||
"updateAuditForwarding",
|
||||
"updateBranding",
|
||||
"updateCredential",
|
||||
"updateExternalAutoProvision",
|
||||
"updateFolderMetadata",
|
||||
"updateHostAccess",
|
||||
"updateLogLevel",
|
||||
"updatePasswordLoginAllowed",
|
||||
"updatePasswordResetAllowed",
|
||||
"updatePluginAdminSettings",
|
||||
"updatePluginHostSettings",
|
||||
"updatePluginUserSettings",
|
||||
"updateRegistrationAllowed",
|
||||
"updateRole",
|
||||
"updateSSHHost",
|
||||
"updateSecondFactorAfterExternalLogin",
|
||||
"updateSessionTimeout",
|
||||
"updateStatusCheckSettings",
|
||||
"updateSyncSettings",
|
||||
"uploadTlsCertificate",
|
||||
"usageBarColor",
|
||||
"usageColor",
|
||||
"useActionSlot",
|
||||
"useAdaptivePolling",
|
||||
"useAppTheme",
|
||||
"useAreaPreferences",
|
||||
"useBranding",
|
||||
"useChooseTheme",
|
||||
"useConfirm",
|
||||
"useConnectionGate",
|
||||
"useConnectionLog",
|
||||
"useDesktopUpdate",
|
||||
"useEditorDefaultKeys",
|
||||
"useHostActions",
|
||||
"useHostBadges",
|
||||
"useHostContextMenuItems",
|
||||
"useHostEditorSections",
|
||||
"useHostProtocols",
|
||||
"useHostStatus",
|
||||
"useInstalledPlugins",
|
||||
"useIsDefaultsEditor",
|
||||
"useIsMobile",
|
||||
"useKeybindingActions",
|
||||
"useKeybindingDefaults",
|
||||
"useOptionalConnectionLog",
|
||||
"usePageVisibleInterval",
|
||||
"usePaletteEntries",
|
||||
"usePaletteGroups",
|
||||
"usePanels",
|
||||
"usePermissions",
|
||||
"usePluginDocsUrl",
|
||||
"usePluginHostSections",
|
||||
"usePluginStore",
|
||||
"useRailItems",
|
||||
"useRegisteredDashboardCards",
|
||||
"useServerStatus",
|
||||
"useServerStatusMeta",
|
||||
"useShellController",
|
||||
"useSplitTargets",
|
||||
"useSshAuthEditors",
|
||||
"useSshAuthProviders",
|
||||
"useStatusColorScheme",
|
||||
"useSurfaceKind",
|
||||
"useSyncStatus",
|
||||
"useTabHandlesVersion",
|
||||
"useTabs",
|
||||
"useTabsSafe",
|
||||
"useTheme",
|
||||
"useThemeOptions",
|
||||
"useUiPreference",
|
||||
"useUiPreferencesContext",
|
||||
"waitForDefaultsJob",
|
||||
"withProtocolSettings",
|
||||
];
|
||||
|
||||
/** Every name a module exports, resolved the way the compiler sees it. */
|
||||
|
||||
@@ -42,6 +42,7 @@ 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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,6 +54,7 @@ describe("serviceEnv", () => {
|
||||
DATABASE_URL: "mysql://root:termix@termix-dev-mysql:3306/termix",
|
||||
GUACD_HOST: "termix-dev-guacd",
|
||||
GUACD_PORT: "4822",
|
||||
REMOTE_DESKTOP_ENGINE: "guacd",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+4
-5
@@ -3,7 +3,7 @@
|
||||
* and options.
|
||||
*
|
||||
* Every target builds the SDK and stages plugins (local builds from
|
||||
* ../Termix-Plugins when it exists), then:
|
||||
* ../Plugins when it exists), then:
|
||||
*
|
||||
* - web: compiles the backend in watch mode, starts it, then starts Vite. A
|
||||
* backend or SDK change restarts the backend. A plugin change rebuilds that
|
||||
@@ -14,7 +14,8 @@
|
||||
* 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.
|
||||
* --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.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
@@ -652,9 +653,7 @@ async function main() {
|
||||
const pluginsDir = options.local
|
||||
? path.resolve(
|
||||
root,
|
||||
options.pluginsDir ??
|
||||
process.env.TERMIX_LOCAL_PLUGINS ??
|
||||
"../Termix-Plugins",
|
||||
options.pluginsDir ?? process.env.TERMIX_LOCAL_PLUGINS ?? "../Plugins",
|
||||
)
|
||||
: null;
|
||||
const localPlugins =
|
||||
|
||||
@@ -53,6 +53,7 @@ function start() {
|
||||
isDev: false,
|
||||
appRoot: "/app",
|
||||
BACKEND_STDERR_TAIL_LIMIT: 4096,
|
||||
backendPort: 30001,
|
||||
handleBackendRequest,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { isLaunchableFileName } = require("../electron/external-editor.cjs") as {
|
||||
isLaunchableFileName: (fileName: unknown) => boolean;
|
||||
};
|
||||
|
||||
describe("Electron external editor", () => {
|
||||
it("refuses remote file names the OS would run instead of open", () => {
|
||||
for (const name of [
|
||||
"payload.exe",
|
||||
"run.BAT",
|
||||
"script.ps1",
|
||||
"a.exe. ",
|
||||
"a.cmd...",
|
||||
"../../evil.vbs",
|
||||
"start.command",
|
||||
"launch.desktop",
|
||||
"install.sh",
|
||||
]) {
|
||||
expect(isLaunchableFileName(name)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("opens ordinary text files", () => {
|
||||
for (const name of [
|
||||
"nginx.conf",
|
||||
"README.md",
|
||||
"Dockerfile",
|
||||
".bashrc",
|
||||
"config.json",
|
||||
"index.ts",
|
||||
"",
|
||||
undefined,
|
||||
]) {
|
||||
expect(isLaunchableFileName(name)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { isExternalLoginUrl, isCallbackPort } =
|
||||
require("../electron/external-login.cjs") as {
|
||||
isExternalLoginUrl: (value: unknown) => boolean;
|
||||
isCallbackPort: (value: unknown) => boolean;
|
||||
};
|
||||
|
||||
describe("Electron external login", () => {
|
||||
it.each([
|
||||
"https://idp.example.com/authorize?client_id=termix",
|
||||
"http://keycloak.lan:8080/realms/home/protocol/openid-connect/auth",
|
||||
])("opens web login URLs: %s", (url) => {
|
||||
expect(isExternalLoginUrl(url)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"file:///C:/Windows/System32/calc.exe",
|
||||
"smb://attacker/share",
|
||||
"ms-msdt:/id PCWDiagnostic",
|
||||
"javascript:alert(1)",
|
||||
"not a url",
|
||||
undefined,
|
||||
])("refuses other schemes: %s", (url) => {
|
||||
expect(isExternalLoginUrl(url)).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts the ports the login screen picks", () => {
|
||||
expect(isCallbackPort(17832)).toBe(true);
|
||||
expect(isCallbackPort(17931)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([0, 80, 65536, 17832.5, "17832", NaN, undefined])(
|
||||
"refuses callback port %s",
|
||||
(port) => {
|
||||
expect(isCallbackPort(port)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -32,9 +32,62 @@ describe("Electron security boundary", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("never hands the session cookie to the renderer over IPC", () => {
|
||||
for (const channel of ["get-session-cookie", "wait-session-cookie"]) {
|
||||
expect(main).not.toContain(`"${channel}"`);
|
||||
expect(preload).not.toContain(`"${channel}"`);
|
||||
}
|
||||
});
|
||||
|
||||
it("launches only the editor saved from a file dialog", () => {
|
||||
expect(main).not.toContain("fileData?.editorPath");
|
||||
expect(main).toContain("readSetting(EXTERNAL_EDITOR_SETTING)");
|
||||
expect(main).toContain("dialogChosenPaths.has(value)");
|
||||
});
|
||||
|
||||
it("scopes proxy headers and basic auth to the linked origin", () => {
|
||||
expect(main).toContain('"sync-proxy-config"');
|
||||
expect(main).toContain("linkedServer.applyLinkHeaders(");
|
||||
expect(main).toContain("linkedServer.answerLogin(");
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects setting keys that coerce to the protected editor key", async () => {
|
||||
const { runInNewContext } = await import("node:vm");
|
||||
const { vi } = await import("vitest");
|
||||
const writeFileSync = vi.fn();
|
||||
let handler: (event: unknown, key: unknown, value: unknown) => unknown;
|
||||
runInNewContext(
|
||||
main.slice(
|
||||
main.indexOf('ipcMain.handle("set-setting"'),
|
||||
main.indexOf('ipcMain.handle("clear-session-cookies"'),
|
||||
),
|
||||
{
|
||||
ipcMain: {
|
||||
handle: (_: string, callback: typeof handler) => {
|
||||
handler = callback;
|
||||
},
|
||||
},
|
||||
EXTERNAL_EDITOR_SETTING: "fileManager.externalEditorPath",
|
||||
dialogChosenPaths: new Set(["/chosen/editor"]),
|
||||
app: { getPath: () => "/settings" },
|
||||
path: { join: (...parts: string[]) => parts.join("/") },
|
||||
fs: { existsSync: () => false, mkdirSync: vi.fn(), writeFileSync },
|
||||
console,
|
||||
},
|
||||
);
|
||||
for (const key of [["fileManager.externalEditorPath"], null, {}, 42]) {
|
||||
expect(handler!({}, key, "/unapproved/editor")).toEqual({
|
||||
success: false,
|
||||
error: "Invalid setting key",
|
||||
});
|
||||
}
|
||||
expect(writeFileSync).not.toHaveBeenCalled();
|
||||
expect(
|
||||
handler!({}, "fileManager.externalEditorPath", "/unapproved/editor"),
|
||||
).toEqual({ success: false, error: "Choose the editor in the file dialog" });
|
||||
expect(
|
||||
handler!({}, "fileManager.externalEditorPath", "/chosen/editor"),
|
||||
).toEqual({ success: true });
|
||||
expect(writeFileSync).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -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)** | - | [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)** | [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) |`,
|
||||
`| **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) | - | - |`,
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Builds src/ui/settings/settings-search-index.ts: the i18n keys each core
|
||||
* settings section shows, so the Settings search can match on any label in
|
||||
* a section without mounting it. Run it after changing a settings section.
|
||||
*
|
||||
* --check fails when the committed file is out of date.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
const SIDEBAR = path.join(ROOT, "src", "ui", "sidebar");
|
||||
const OUT = path.join(
|
||||
ROOT,
|
||||
"src",
|
||||
"ui",
|
||||
"settings",
|
||||
"settings-search-index.ts",
|
||||
);
|
||||
|
||||
function flatKeys(obj, prefix = "") {
|
||||
return Object.entries(obj).flatMap(([key, value]) =>
|
||||
typeof value === "object"
|
||||
? flatKeys(value, `${prefix}${key}.`)
|
||||
: [`${prefix}${key}`],
|
||||
);
|
||||
}
|
||||
|
||||
const EN_KEYS = new Set(
|
||||
flatKeys(require(path.join(ROOT, "src", "ui", "locales", "en.json"))),
|
||||
);
|
||||
|
||||
// t("x", { count }) reads x_one or x_other, so index the plural key.
|
||||
function realKey(key) {
|
||||
return EN_KEYS.has(key) || !EN_KEYS.has(`${key}_other`)
|
||||
? key
|
||||
: `${key}_other`;
|
||||
}
|
||||
|
||||
function keysIn(text) {
|
||||
return [
|
||||
...new Set(
|
||||
[...text.matchAll(/\bt\(\s*"([\w.-]+)"/g)].map((m) => realKey(m[1])),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function read(file) {
|
||||
return fs.readFileSync(path.join(SIDEBAR, file), "utf8");
|
||||
}
|
||||
|
||||
/** The body of one exported or local function, by name. */
|
||||
function functionBody(source, name) {
|
||||
const start = source.search(new RegExp(`function ${name}\\b`));
|
||||
if (start < 0) throw new Error(`No function ${name}`);
|
||||
const next = source.slice(start + 1).search(/\n(export )?function \w+/);
|
||||
return next < 0 ? source.slice(start) : source.slice(start, start + 1 + next);
|
||||
}
|
||||
|
||||
/** Profile sections, split on their AccordionSection ids. */
|
||||
function profileSections() {
|
||||
const source = read("UserProfilePanel.tsx");
|
||||
const lines = source.split("\n");
|
||||
const starts = [];
|
||||
lines.forEach((line, i) => {
|
||||
if (/^\s{6}<AccordionSection\b/.test(line)) starts.push(i);
|
||||
});
|
||||
const out = {};
|
||||
starts.forEach((start, k) => {
|
||||
const end = k + 1 < starts.length ? starts[k + 1] : lines.length;
|
||||
const chunk = lines.slice(start, end).join("\n");
|
||||
const id = (chunk.match(/\bid="([^"]+)"/) || [])[1];
|
||||
if (id) out[id] = keysIn(chunk);
|
||||
});
|
||||
out.interface = [
|
||||
...new Set([
|
||||
...(out.interface ?? []),
|
||||
...keysIn(read("InterfacePresetSettings.tsx")),
|
||||
]),
|
||||
];
|
||||
out.security = [
|
||||
...new Set([
|
||||
...(out.security ?? []),
|
||||
...keysIn(read("AuthEnrollmentSections.tsx")),
|
||||
...keysIn(functionBody(source, "PasswordChangeSection")),
|
||||
]),
|
||||
];
|
||||
out["api-keys"] = [
|
||||
...new Set([
|
||||
...(out["api-keys"] ?? []),
|
||||
...keysIn(functionBody(source, "NewApiKeyDialog")),
|
||||
]),
|
||||
];
|
||||
out.keybindings = keysIn(
|
||||
fs.readFileSync(
|
||||
path.join(ROOT, "src", "ui", "settings", "KeybindingsSettings.tsx"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
function adminSections() {
|
||||
const settings = read("AdminSettingsSections.tsx");
|
||||
const management = read("AdminManagementSections.tsx");
|
||||
return {
|
||||
"admin-general": [
|
||||
...new Set([
|
||||
...keysIn(functionBody(settings, "AdminGeneralSettingsSection")),
|
||||
...keysIn(read("AdminPluginDeveloperMode.tsx")),
|
||||
]),
|
||||
],
|
||||
"admin-users": [
|
||||
...new Set([
|
||||
...keysIn(functionBody(management, "AdminUsersSection")),
|
||||
...keysIn(read("AdminUserDialogs.tsx")),
|
||||
]),
|
||||
],
|
||||
"admin-sessions": keysIn(functionBody(management, "AdminSessionsSection")),
|
||||
"admin-roles": keysIn(functionBody(management, "AdminRolesSection")),
|
||||
"admin-host-defaults": [
|
||||
...new Set([
|
||||
...keysIn(functionBody(settings, "AdminHostDefaultsSection")),
|
||||
...keysIn(read("AdminHostTags.tsx")),
|
||||
]),
|
||||
],
|
||||
"admin-branding": keysIn(read("AdminBrandingSection.tsx")),
|
||||
"admin-database": keysIn(functionBody(settings, "AdminDatabaseSection")),
|
||||
"admin-ssl": keysIn(functionBody(settings, "AdminSSLSection")),
|
||||
"admin-api-keys": keysIn(read("AdminApiKeysSection.tsx")),
|
||||
"admin-audit-log": keysIn(read("AdminAuditLogSection.tsx")),
|
||||
updates: [
|
||||
...new Set(
|
||||
["UpdatesSettings.tsx", "DesktopUpdatesCard.tsx"].flatMap((file) =>
|
||||
keysIn(
|
||||
fs.readFileSync(
|
||||
path.join(ROOT, "src", "ui", "settings", file),
|
||||
"utf8",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function render() {
|
||||
const index = { ...profileSections(), ...adminSections() };
|
||||
const body = Object.entries(index)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(
|
||||
([id, keys]) =>
|
||||
` ${JSON.stringify(id)}: [\n${keys
|
||||
.sort()
|
||||
.map((key) => ` ${JSON.stringify(key)},`)
|
||||
.join("\n")}\n ],`,
|
||||
)
|
||||
.join("\n");
|
||||
return `// Generated by scripts/generate-settings-search-index.cjs. Do not edit.\n\n/** The i18n keys each core settings section shows, for search. */\nexport const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {\n${body}\n};\n`;
|
||||
}
|
||||
|
||||
const next = render();
|
||||
if (process.argv.includes("--check")) {
|
||||
const current = fs.existsSync(OUT) ? fs.readFileSync(OUT, "utf8") : "";
|
||||
if (current !== next) {
|
||||
console.error(
|
||||
"src/ui/settings/settings-search-index.ts is out of date. Run: node scripts/generate-settings-search-index.cjs",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
fs.writeFileSync(OUT, next);
|
||||
console.log(`Wrote ${path.relative(ROOT, OUT)}`);
|
||||
}
|
||||
@@ -15,6 +15,11 @@
|
||||
* enabledByEnv counts the plugin as recommended when any of those
|
||||
* environment variables is set, for a server configured before first boot.
|
||||
* Plugins without it start unchecked.
|
||||
*
|
||||
* "defaultLook": true marks the plugin whose layout (and that layout's
|
||||
* theme) a fresh install shows. Core finds the default look this way, so it
|
||||
* never names a plugin. Such a plugin always starts enabled and cannot be
|
||||
* uninstalled, only swapped for another look.
|
||||
*/
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
@@ -110,6 +115,12 @@ function parseBundledPlugins(raw) {
|
||||
}
|
||||
const before = problems.length;
|
||||
const onboarding = parseOnboarding(entry.onboarding, entry.id, problems);
|
||||
if (
|
||||
entry.defaultLook !== undefined &&
|
||||
typeof entry.defaultLook !== "boolean"
|
||||
) {
|
||||
problems.push(`${entry.id} defaultLook must be a boolean`);
|
||||
}
|
||||
if (problems.length > before) continue;
|
||||
plugins.push({
|
||||
id: entry.id,
|
||||
@@ -118,6 +129,7 @@ function parseBundledPlugins(raw) {
|
||||
path: hasPath ? entry.path : null,
|
||||
sha256: entry.sha256,
|
||||
onboarding,
|
||||
defaultLook: entry.defaultLook === true,
|
||||
});
|
||||
} else {
|
||||
problems.push(`${entry.id} has an unknown source "${entry.source}"`);
|
||||
@@ -218,8 +230,12 @@ function buildBundledIndex(plugins, stagedIds) {
|
||||
const staged = new Set(stagedIds);
|
||||
const out = {};
|
||||
for (const plugin of plugins) {
|
||||
if (!staged.has(plugin.id) || !plugin.onboarding) continue;
|
||||
out[plugin.id] = { onboarding: plugin.onboarding };
|
||||
if (!staged.has(plugin.id)) continue;
|
||||
if (!plugin.onboarding && !plugin.defaultLook) continue;
|
||||
out[plugin.id] = {
|
||||
...(plugin.onboarding ? { onboarding: plugin.onboarding } : {}),
|
||||
...(plugin.defaultLook ? { defaultLook: true } : {}),
|
||||
};
|
||||
}
|
||||
return { version: 1, plugins: out };
|
||||
}
|
||||
@@ -238,12 +254,14 @@ function pinsFromIndex(index, ids, current = []) {
|
||||
const latest = byId.get(id)?.versions?.[0];
|
||||
if (!latest) throw new Error(`${id} is not in the registry index`);
|
||||
const onboarding = currentById.get(id)?.onboarding;
|
||||
const defaultLook = currentById.get(id)?.defaultLook === true;
|
||||
return {
|
||||
id,
|
||||
source: "tmxplug",
|
||||
url: latest.url,
|
||||
sha256: latest.sha256,
|
||||
...(onboarding ? { onboarding } : {}),
|
||||
...(defaultLook ? { defaultLook } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -357,9 +375,26 @@ function rebuildLocalPlugins(dir, ids, run) {
|
||||
}
|
||||
|
||||
/** Copies a local plugin build into the staging folder. */
|
||||
/** Theme CSS files the repo's manifest declares, which ship with it too. */
|
||||
function localThemeFiles(repo) {
|
||||
try {
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(repo, "manifest.json"), "utf8"),
|
||||
);
|
||||
const themes = manifest?.contributes?.themes;
|
||||
return Array.isArray(themes)
|
||||
? themes
|
||||
.map((theme) => theme?.css)
|
||||
.filter((css) => typeof css === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function copyLocalBuild(repo, destination) {
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
for (const entry of LOCAL_FILES) {
|
||||
for (const entry of new Set([...LOCAL_FILES, ...localThemeFiles(repo)])) {
|
||||
const from = path.join(repo, entry);
|
||||
if (fs.existsSync(from)) {
|
||||
fs.cpSync(from, path.join(destination, entry), { recursive: true });
|
||||
|
||||
@@ -85,6 +85,8 @@ 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;
|
||||
}
|
||||
|
||||
@@ -122,10 +122,10 @@ Targets
|
||||
docker Build the production image and run it with your local plugins
|
||||
|
||||
Options
|
||||
--plugins <dir> Plugin repos to build and watch (default ../Termix-Plugins)
|
||||
--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 for remote desktop
|
||||
--guacd Run guacd in Docker and use it instead of the built-in 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)
|
||||
|
||||
+48
-34
@@ -3,43 +3,45 @@ const path = require("node:path");
|
||||
|
||||
const nodePtyDir = path.join(__dirname, "..", "node_modules", "node-pty");
|
||||
|
||||
if (!fs.existsSync(nodePtyDir)) {
|
||||
console.log("[patch-node-pty] node-pty not found, skipping");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const unixTerminalPath = path.join(nodePtyDir, "lib", "unixTerminal.js");
|
||||
|
||||
const originalHelperRewrite = [
|
||||
"helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
|
||||
"helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
|
||||
].join("\n");
|
||||
// Older installs may still carry the first version of this patch.
|
||||
const knownHelperRewrites = [
|
||||
[
|
||||
"helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
|
||||
"helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
|
||||
].join("\n"),
|
||||
[
|
||||
"if (!helperPath.includes('app.asar.unpacked')) {",
|
||||
" helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
|
||||
"}",
|
||||
"if (!helperPath.includes('node_modules.asar.unpacked')) {",
|
||||
" helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
|
||||
"}",
|
||||
].join("\n"),
|
||||
];
|
||||
|
||||
const patchedHelperRewrite = [
|
||||
"if (!helperPath.includes('app.asar.unpacked')) {",
|
||||
" helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
|
||||
"}",
|
||||
"if (!helperPath.includes('node_modules.asar.unpacked')) {",
|
||||
" helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
|
||||
"}",
|
||||
].join("\n");
|
||||
// A universal macOS build ships app-arm64.asar and app-x64.asar, not app.asar.
|
||||
const patchedHelperRewrite =
|
||||
"helperPath = helperPath.replace(/\\b(app(?:-[a-z0-9]+)?|node_modules)\\.asar(?!\\.unpacked)/g, '$1.asar.unpacked');";
|
||||
|
||||
function patchUnixTerminalHelperRewrite() {
|
||||
if (!fs.existsSync(unixTerminalPath)) {
|
||||
function patchUnixTerminalHelperRewrite(file = unixTerminalPath) {
|
||||
if (!fs.existsSync(file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const source = fs.readFileSync(unixTerminalPath, "utf8");
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
if (source.includes(patchedHelperRewrite)) {
|
||||
return false;
|
||||
}
|
||||
if (!source.includes(originalHelperRewrite)) {
|
||||
const known = knownHelperRewrites.find((rewrite) => source.includes(rewrite));
|
||||
if (!known) {
|
||||
return false;
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
unixTerminalPath,
|
||||
source.replace(originalHelperRewrite, patchedHelperRewrite),
|
||||
file,
|
||||
source.replace(known, () => patchedHelperRewrite),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -67,19 +69,31 @@ function chmodSpawnHelpers(rootDir) {
|
||||
return fixed;
|
||||
}
|
||||
|
||||
const helperRewritePatched = patchUnixTerminalHelperRewrite();
|
||||
const helpersChmodded = chmodSpawnHelpers(nodePtyDir);
|
||||
// Tests and afterPack require this file; only a direct run patches node_modules.
|
||||
function main() {
|
||||
if (!fs.existsSync(nodePtyDir)) {
|
||||
console.log("[patch-node-pty] node-pty not found, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
if (helperRewritePatched) {
|
||||
console.log("[patch-node-pty] Patched unixTerminal helper path rewrite");
|
||||
const helperRewritePatched = patchUnixTerminalHelperRewrite();
|
||||
const helpersChmodded = chmodSpawnHelpers(nodePtyDir);
|
||||
|
||||
if (helperRewritePatched) {
|
||||
console.log("[patch-node-pty] Patched unixTerminal helper path rewrite");
|
||||
}
|
||||
if (helpersChmodded > 0) {
|
||||
console.log(
|
||||
`[patch-node-pty] Restored execute bit on ${helpersChmodded} spawn-helper binary(ies)`,
|
||||
);
|
||||
}
|
||||
if (!helperRewritePatched && helpersChmodded === 0) {
|
||||
console.log("[patch-node-pty] Already patched or target files not found");
|
||||
}
|
||||
}
|
||||
if (helpersChmodded > 0) {
|
||||
console.log(
|
||||
`[patch-node-pty] Restored execute bit on ${helpersChmodded} spawn-helper binary(ies)`,
|
||||
);
|
||||
}
|
||||
if (!helperRewritePatched && helpersChmodded === 0) {
|
||||
console.log("[patch-node-pty] Already patched or target files not found");
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { chmodSpawnHelpers, patchUnixTerminalHelperRewrite };
|
||||
@@ -35,6 +35,42 @@ function fixturePlugin(): string {
|
||||
}
|
||||
|
||||
describe("termix-plugin build", () => {
|
||||
it("builds a theme only plugin with an empty backend and no source", async () => {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-build-"));
|
||||
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "manifest.json"),
|
||||
JSON.stringify({
|
||||
id: "theme-fixture",
|
||||
capabilities: ["ui:theme"],
|
||||
contributes: { themes: [{ id: "paper" }] },
|
||||
}),
|
||||
);
|
||||
|
||||
await build({ cwd: dir });
|
||||
|
||||
expect(
|
||||
fs.readFileSync(path.join(dir, "dist", "backend.js"), "utf8"),
|
||||
).toContain("export function activate()");
|
||||
expect(fs.existsSync(path.join(dir, "dist", "frontend.js"))).toBe(false);
|
||||
});
|
||||
|
||||
it("still needs a backend entry once a plugin holds more than themes", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-build-"));
|
||||
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "manifest.json"),
|
||||
JSON.stringify({
|
||||
id: "mixed-fixture",
|
||||
capabilities: ["ui:theme", "ui:layout"],
|
||||
contributes: { themes: [{ id: "paper" }] },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(build({ cwd: dir })).rejects.toThrow("no backend entry");
|
||||
});
|
||||
|
||||
it("marks dist as ESM so Node loads backend.js without reparsing", async () => {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const dir = fixturePlugin();
|
||||
@@ -92,6 +128,74 @@ 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,6 +46,8 @@ 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;
|
||||
}
|
||||
@@ -91,6 +93,8 @@ describe("termix-plugin pack", () => {
|
||||
|
||||
const names = await entries(file);
|
||||
expect(names).toEqual([
|
||||
"LICENSE",
|
||||
"NOTICE",
|
||||
"README.md",
|
||||
"dist/backend.js",
|
||||
"dist/frontend.js",
|
||||
@@ -138,3 +142,29 @@ describe("termix-plugin pack", () => {
|
||||
expect(fs.readdirSync(out)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("termix-plugin pack themes", () => {
|
||||
it("ships the CSS files the manifest's themes declare", async () => {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const dir = fixturePlugin({
|
||||
capabilities: ["ui:theme"],
|
||||
contributes: {
|
||||
themes: [
|
||||
{
|
||||
id: "paper",
|
||||
label: "Paper",
|
||||
mode: "light",
|
||||
css: "styles/paper.css",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
writeFile(dir, "styles/paper.css", ".paper { --background: #fff; }\n");
|
||||
writeFile(dir, "styles/draft.css", ".draft {}\n");
|
||||
const file = await pack({ cwd: dir, args: ["--out", tempDir("t-")] });
|
||||
const names = await entries(file);
|
||||
expect(names).toContain("styles/paper.css");
|
||||
expect(names).not.toContain("styles/draft.css");
|
||||
});
|
||||
});
|
||||
@@ -49,3 +49,98 @@ describe("termix-plugin validate migrations", () => {
|
||||
expect(output).not.toMatch(/migrations\/\w+ is missing/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("termix-plugin validate themes", () => {
|
||||
function themed(css: string | null) {
|
||||
const root = plugin([]);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "manifest.json"),
|
||||
JSON.stringify({
|
||||
id: "fixture",
|
||||
version: "1.0.0",
|
||||
capabilities: ["ui:theme"],
|
||||
contributes: {
|
||||
themes: [
|
||||
{
|
||||
id: "paper",
|
||||
label: "Paper",
|
||||
mode: "light",
|
||||
css: "themes/paper.css",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (css !== null) {
|
||||
fs.mkdirSync(path.join(root, "themes"));
|
||||
fs.writeFileSync(path.join(root, "themes", "paper.css"), css);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
it("reports a missing theme file", async () => {
|
||||
expect(await problems(themed(null))).toContain(
|
||||
"theme paper: themes/paper.css does not exist",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports theme CSS that reaches outside or leaves its class", async () => {
|
||||
const output = await problems(
|
||||
themed('@import "x.css"; body { color: red }'),
|
||||
);
|
||||
expect(output).toContain("theme paper: @import is not allowed in a theme");
|
||||
expect(output).toContain(
|
||||
'theme paper: Selector "body" must start with .paper',
|
||||
);
|
||||
});
|
||||
|
||||
it("says nothing about CSS that is fine", async () => {
|
||||
const output = await problems(themed(".paper { --background: #fff; }"));
|
||||
expect(output).not.toContain("theme paper");
|
||||
});
|
||||
});
|
||||
|
||||
describe("termix-plugin validate layouts and capabilities", () => {
|
||||
function withManifest(extra: Record<string, unknown>) {
|
||||
const root = plugin([]);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "manifest.json"),
|
||||
JSON.stringify({ id: "fixture", version: "1.0.0", ...extra }),
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
it("flags ui:theme with no themes and ui:layout with no layouts", async () => {
|
||||
const output = await problems(
|
||||
withManifest({ capabilities: ["ui:theme", "ui:layout"] }),
|
||||
);
|
||||
expect(output).toContain(
|
||||
'"ui:theme" is declared but contributes.themes is empty',
|
||||
);
|
||||
expect(output).toContain(
|
||||
'"ui:layout" is declared but contributes.layouts is empty',
|
||||
);
|
||||
});
|
||||
|
||||
it("flags a layout plugin with no frontend source", async () => {
|
||||
const output = await problems(
|
||||
withManifest({
|
||||
capabilities: ["ui:layout"],
|
||||
contributes: { layouts: [{ id: "wide", label: "Wide" }] },
|
||||
}),
|
||||
);
|
||||
expect(output).toContain("contributes.layouts needs a frontend");
|
||||
});
|
||||
|
||||
it("accepts a layout plugin that has a frontend entry", async () => {
|
||||
const root = withManifest({
|
||||
capabilities: ["ui:layout"],
|
||||
contributes: { layouts: [{ id: "wide", label: "Wide" }] },
|
||||
});
|
||||
fs.mkdirSync(path.join(root, "src", "frontend"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "src", "frontend", "index.tsx"), "");
|
||||
const output = await problems(root);
|
||||
expect(output).not.toContain("contributes.layouts needs a frontend");
|
||||
expect(output).not.toContain("is declared but");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/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);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
+221
-223
@@ -22,6 +22,7 @@ import sshAuthRoutes from "./routes/ssh-auth-routes.js";
|
||||
import rbacRoutes from "./routes/rbac.js";
|
||||
import openTabsRoutes from "./routes/open-tabs.js";
|
||||
import userPreferencesRoutes from "./routes/user-preferences.js";
|
||||
import lookRoutes from "./routes/look.js";
|
||||
import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
|
||||
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
|
||||
import uiPreferencesRoutes from "./routes/ui-preferences.js";
|
||||
@@ -38,6 +39,7 @@ 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";
|
||||
@@ -79,7 +81,6 @@ import {
|
||||
createCurrentSshCredentialUsageRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "./repositories/factory.js";
|
||||
import { withCurrentSqliteForeignKeysDisabled } from "./repositories/sqlite-foreign-keys.js";
|
||||
import { applyPluginHostImportSettings } from "./routes/host-plugin-settings.js";
|
||||
import {
|
||||
selectExportableSettings,
|
||||
@@ -1235,227 +1236,221 @@ app.post(
|
||||
};
|
||||
|
||||
try {
|
||||
await withCurrentSqliteForeignKeysDisabled(async () => {
|
||||
try {
|
||||
const importedHosts = importDb
|
||||
.prepare("SELECT * FROM ssh_data")
|
||||
.all();
|
||||
const importedHostIds: number[] = [];
|
||||
for (const host of importedHosts) {
|
||||
try {
|
||||
const hostRepository = createCurrentHostRepository();
|
||||
const exists = await hostRepository.existsForImportIdentity(
|
||||
userId,
|
||||
host.ip,
|
||||
host.port,
|
||||
host.username,
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
result.summary.skippedItems++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const hostData = {
|
||||
userId: userId,
|
||||
name: host.name,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
folder: host.folder,
|
||||
tags: host.tags,
|
||||
pin: Boolean(host.pin),
|
||||
authType: host.auth_type,
|
||||
forceKeyboardInteractive: host.force_keyboard_interactive,
|
||||
password: host.password,
|
||||
key: host.key,
|
||||
keyPassword: host.key_password,
|
||||
keyType: host.key_type,
|
||||
sudoPassword: host.sudo_password,
|
||||
credentialId: host.credential_id || null,
|
||||
overrideCredentialUsername: Boolean(
|
||||
host.override_credential_username,
|
||||
),
|
||||
jumpHosts: host.jump_hosts,
|
||||
...legacyStatusCheck(host),
|
||||
// Exports from before 2.9.0 carry these in terminal_config.
|
||||
sshOptions:
|
||||
host.ssh_options ??
|
||||
sshOptionsForWrite({
|
||||
terminalConfig: host.terminal_config,
|
||||
}) ??
|
||||
null,
|
||||
notes: host.notes,
|
||||
useSocks5: Boolean(host.use_socks5),
|
||||
socks5Host: host.socks5_host,
|
||||
socks5Port: host.socks5_port,
|
||||
socks5Username: host.socks5_username,
|
||||
socks5Password: host.socks5_password,
|
||||
socks5ProxyChain: host.socks5_proxy_chain,
|
||||
createdAt: host.created_at || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
(hostData as Record<string, unknown>).defaultOverrides =
|
||||
JSON.stringify(
|
||||
await applyHostDefaultsToWrite({
|
||||
ownerId: userId,
|
||||
hostId: null,
|
||||
columns: hostData as Record<string, unknown>,
|
||||
body: hostData as Record<string, unknown>,
|
||||
}),
|
||||
);
|
||||
const created = await hostRepository.createEncryptedForUser(
|
||||
userId,
|
||||
hostData,
|
||||
);
|
||||
await applyPluginHostImportSettings(
|
||||
Number(created.id),
|
||||
importedHostPluginSettings(importDb, host),
|
||||
);
|
||||
importedHostIds.push(Number(created.id));
|
||||
await importHostProtocolLogins(
|
||||
importDb,
|
||||
host,
|
||||
Number(created.id),
|
||||
userId,
|
||||
);
|
||||
result.summary.sshHostsImported++;
|
||||
} catch (hostError) {
|
||||
result.summary.errors.push(
|
||||
`SSH host import error: ${hostError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
await applyDefaultsAfterHostWrites(importedHostIds);
|
||||
} catch {
|
||||
apiLogger.info("ssh_data table not found in import file, skipping");
|
||||
}
|
||||
|
||||
try {
|
||||
const importedCreds = importDb
|
||||
.prepare("SELECT * FROM ssh_credentials")
|
||||
.all();
|
||||
for (const cred of importedCreds) {
|
||||
try {
|
||||
const credentialRepository =
|
||||
createCurrentCredentialRepository();
|
||||
const exists =
|
||||
await credentialRepository.existsForImportIdentity(
|
||||
userId,
|
||||
cred.name,
|
||||
cred.username,
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
result.summary.skippedItems++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const credData = {
|
||||
userId: userId,
|
||||
name: cred.name,
|
||||
description: cred.description,
|
||||
folder: cred.folder,
|
||||
tags: cred.tags,
|
||||
authType: cred.auth_type,
|
||||
username: cred.username,
|
||||
password: cred.password,
|
||||
key: cred.key,
|
||||
privateKey: cred.private_key,
|
||||
publicKey: cred.public_key,
|
||||
keyPassword: cred.key_password,
|
||||
keyType: cred.key_type,
|
||||
detectedKeyType: cred.detected_key_type,
|
||||
usageCount: cred.usage_count || 0,
|
||||
lastUsed: cred.last_used,
|
||||
createdAt: cred.created_at || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await credentialRepository.createEncryptedForUser(
|
||||
userId,
|
||||
credData,
|
||||
);
|
||||
result.summary.sshCredentialsImported++;
|
||||
} catch (credError) {
|
||||
result.summary.errors.push(
|
||||
`SSH credential import error: ${credError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info(
|
||||
"ssh_credentials table not found in import file, skipping",
|
||||
);
|
||||
}
|
||||
|
||||
const pluginRows = await importUserPluginRows(importDb, userId);
|
||||
result.summary.pluginItemsImported += pluginRows.imported;
|
||||
result.summary.skippedItems += pluginRows.skipped;
|
||||
result.summary.errors.push(...pluginRows.errors);
|
||||
|
||||
const hostDefaults = await importHostDefaults(importDb, userId);
|
||||
result.summary.skippedItems += hostDefaults.skipped;
|
||||
if (hostDefaults.imported > 0) {
|
||||
await recompute({ userIds: [userId] });
|
||||
}
|
||||
|
||||
const targetUser = await userRepository.findById(userId);
|
||||
if (targetUser?.isAdmin) {
|
||||
// The file's credential ids belong to the server that wrote it.
|
||||
const credentialIds = new Map<number, number>();
|
||||
try {
|
||||
const importedCreds = importDb
|
||||
.prepare("SELECT * FROM ssh_credentials")
|
||||
.all();
|
||||
for (const cred of importedCreds) {
|
||||
try {
|
||||
const importedSettings = readImportedSettings(importDb);
|
||||
for (const setting of importedSettings) {
|
||||
try {
|
||||
await upsertImportedSetting(setting);
|
||||
result.summary.settingsImported++;
|
||||
} catch (settingError) {
|
||||
result.summary.errors.push(
|
||||
`Setting import error (${setting.key}): ${settingError.message}`,
|
||||
);
|
||||
}
|
||||
const credentialRepository = createCurrentCredentialRepository();
|
||||
const existingId =
|
||||
await credentialRepository.findIdForImportIdentity(
|
||||
userId,
|
||||
cred.name,
|
||||
cred.username,
|
||||
);
|
||||
|
||||
if (existingId !== null) {
|
||||
credentialIds.set(cred.id, existingId);
|
||||
result.summary.skippedItems++;
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info(
|
||||
"settings table not found in import file, skipping",
|
||||
|
||||
const credData = {
|
||||
userId: userId,
|
||||
name: cred.name,
|
||||
description: cred.description,
|
||||
folder: cred.folder,
|
||||
tags: cred.tags,
|
||||
authType: cred.auth_type,
|
||||
username: cred.username,
|
||||
password: cred.password,
|
||||
key: cred.key,
|
||||
privateKey: cred.private_key,
|
||||
publicKey: cred.public_key,
|
||||
keyPassword: cred.key_password,
|
||||
keyType: cred.key_type,
|
||||
detectedKeyType: cred.detected_key_type,
|
||||
usageCount: cred.usage_count || 0,
|
||||
lastUsed: cred.last_used,
|
||||
createdAt: cred.created_at || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const created = await credentialRepository.createEncryptedForUser(
|
||||
userId,
|
||||
credData,
|
||||
);
|
||||
credentialIds.set(cred.id, Number(created.id));
|
||||
result.summary.sshCredentialsImported++;
|
||||
} catch (credError) {
|
||||
result.summary.errors.push(
|
||||
`SSH credential import error: ${credError.message}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
apiLogger.info(
|
||||
"Settings import skipped - only admin users can import settings",
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info(
|
||||
"ssh_credentials table not found in import file, skipping",
|
||||
);
|
||||
}
|
||||
|
||||
result.success = true;
|
||||
|
||||
try {
|
||||
await DatabaseSaveTrigger.forceSave("database_import");
|
||||
} catch (saveError) {
|
||||
apiLogger.error(
|
||||
"Failed to persist imported data to disk",
|
||||
saveError,
|
||||
{
|
||||
operation: "import_force_save_failed",
|
||||
try {
|
||||
const importedHosts = importDb
|
||||
.prepare("SELECT * FROM ssh_data")
|
||||
.all();
|
||||
const importedHostIds: number[] = [];
|
||||
for (const host of importedHosts) {
|
||||
try {
|
||||
const hostRepository = createCurrentHostRepository();
|
||||
const exists = await hostRepository.existsForImportIdentity(
|
||||
userId,
|
||||
},
|
||||
);
|
||||
host.ip,
|
||||
host.port,
|
||||
host.username,
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
result.summary.skippedItems++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const hostData = {
|
||||
userId: userId,
|
||||
name: host.name,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
folder: host.folder,
|
||||
tags: host.tags,
|
||||
pin: Boolean(host.pin),
|
||||
authType: host.auth_type,
|
||||
forceKeyboardInteractive: host.force_keyboard_interactive,
|
||||
password: host.password,
|
||||
key: host.key,
|
||||
keyPassword: host.key_password,
|
||||
keyType: host.key_type,
|
||||
sudoPassword: host.sudo_password,
|
||||
credentialId: credentialIds.get(host.credential_id) ?? null,
|
||||
overrideCredentialUsername: Boolean(
|
||||
host.override_credential_username,
|
||||
),
|
||||
jumpHosts: host.jump_hosts,
|
||||
...legacyStatusCheck(host),
|
||||
// Exports from before 2.9.0 carry these in terminal_config.
|
||||
sshOptions:
|
||||
host.ssh_options ??
|
||||
sshOptionsForWrite({
|
||||
terminalConfig: host.terminal_config,
|
||||
}) ??
|
||||
null,
|
||||
notes: host.notes,
|
||||
useSocks5: Boolean(host.use_socks5),
|
||||
socks5Host: host.socks5_host,
|
||||
socks5Port: host.socks5_port,
|
||||
socks5Username: host.socks5_username,
|
||||
socks5Password: host.socks5_password,
|
||||
socks5ProxyChain: host.socks5_proxy_chain,
|
||||
createdAt: host.created_at || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
(hostData as Record<string, unknown>).defaultOverrides =
|
||||
JSON.stringify(
|
||||
await applyHostDefaultsToWrite({
|
||||
ownerId: userId,
|
||||
hostId: null,
|
||||
columns: hostData as Record<string, unknown>,
|
||||
body: hostData as Record<string, unknown>,
|
||||
}),
|
||||
);
|
||||
const created = await hostRepository.createEncryptedForUser(
|
||||
userId,
|
||||
hostData,
|
||||
);
|
||||
await applyPluginHostImportSettings(
|
||||
Number(created.id),
|
||||
importedHostPluginSettings(importDb, host),
|
||||
);
|
||||
importedHostIds.push(Number(created.id));
|
||||
await importHostProtocolLogins(
|
||||
importDb,
|
||||
host,
|
||||
Number(created.id),
|
||||
userId,
|
||||
credentialIds,
|
||||
);
|
||||
result.summary.sshHostsImported++;
|
||||
} catch (hostError) {
|
||||
result.summary.errors.push(
|
||||
`SSH host import error: ${hostError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
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");
|
||||
}
|
||||
|
||||
const pluginRows = await importUserPluginRows(importDb, userId);
|
||||
result.summary.pluginItemsImported += pluginRows.imported;
|
||||
result.summary.skippedItems += pluginRows.skipped;
|
||||
result.summary.errors.push(...pluginRows.errors);
|
||||
|
||||
const hostDefaults = await importHostDefaults(importDb, userId);
|
||||
result.summary.skippedItems += hostDefaults.skipped;
|
||||
if (hostDefaults.imported > 0) {
|
||||
await recompute({ userIds: [userId] });
|
||||
}
|
||||
|
||||
const targetUser = await userRepository.findById(userId);
|
||||
if (targetUser?.isAdmin) {
|
||||
try {
|
||||
const importedSettings = readImportedSettings(importDb);
|
||||
for (const setting of importedSettings) {
|
||||
try {
|
||||
await upsertImportedSetting(setting);
|
||||
result.summary.settingsImported++;
|
||||
} catch (settingError) {
|
||||
result.summary.errors.push(
|
||||
`Setting import error (${setting.key}): ${settingError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info("settings table not found in import file, skipping");
|
||||
}
|
||||
} else {
|
||||
apiLogger.info(
|
||||
"Settings import skipped - only admin users can import settings",
|
||||
);
|
||||
}
|
||||
|
||||
result.success = true;
|
||||
|
||||
try {
|
||||
await DatabaseSaveTrigger.forceSave("database_import");
|
||||
} catch (saveError) {
|
||||
apiLogger.error(
|
||||
"Failed to persist imported data to disk",
|
||||
saveError,
|
||||
{
|
||||
operation: "import_force_save_failed",
|
||||
userId,
|
||||
},
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (importDb) {
|
||||
importDb.close();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fs.unlinkSync(req.file.path);
|
||||
} catch {
|
||||
apiLogger.warn("Failed to clean up uploaded file", {
|
||||
operation: "file_cleanup_warning",
|
||||
filePath: req.file.path,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: result.success,
|
||||
message: result.success
|
||||
@@ -1472,17 +1467,6 @@ app.post(
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (req.file?.path && fs.existsSync(req.file.path)) {
|
||||
try {
|
||||
fs.unlinkSync(req.file.path);
|
||||
} catch {
|
||||
apiLogger.warn("Failed to clean up uploaded file after error", {
|
||||
operation: "file_cleanup_error",
|
||||
filePath: req.file.path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
apiLogger.error("SQLite import failed", error, {
|
||||
operation: "sqlite_import_api_failed",
|
||||
userId: (req as AuthenticatedRequest).userId,
|
||||
@@ -1491,6 +1475,17 @@ app.post(
|
||||
error: "Failed to import SQLite data",
|
||||
details: getErrorMessage(error),
|
||||
});
|
||||
} finally {
|
||||
if (req.file) {
|
||||
try {
|
||||
fs.rmSync(req.file.path, { force: true });
|
||||
} catch {
|
||||
apiLogger.warn("Failed to clean up uploaded file", {
|
||||
operation: "file_cleanup_warning",
|
||||
filePath: req.file.path,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1622,6 +1617,7 @@ app.use("/credentials", credentialsRoutes);
|
||||
app.use("/ssh-auth", sshAuthRoutes);
|
||||
app.use("/rbac", rbacRoutes);
|
||||
app.use("/open-tabs", openTabsRoutes);
|
||||
app.use("/user-preferences/look", lookRoutes);
|
||||
app.use("/user-preferences", userPreferencesRoutes);
|
||||
app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
|
||||
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
|
||||
@@ -1726,7 +1722,8 @@ app.use(
|
||||
},
|
||||
);
|
||||
|
||||
const HTTP_PORT = 30001;
|
||||
// The desktop app moves its embedded backend off 30001 when that is taken.
|
||||
const HTTP_PORT = Number(process.env.EMBEDDED_BACKEND_PORT) || 30001;
|
||||
|
||||
async function initializeSecurity() {
|
||||
try {
|
||||
@@ -2012,14 +2009,15 @@ function legacyStatusCheck(row: Record<string, unknown>): {
|
||||
|
||||
/**
|
||||
* A host's plugin protocol logins from an export. The file's credential ids
|
||||
* belong to the server that wrote it, so a login keeps one only when it is
|
||||
* a credential the importing user can use.
|
||||
* belong to the server that wrote it, so a login keeps one only when it maps
|
||||
* to a credential imported alongside it.
|
||||
*/
|
||||
async function importHostProtocolLogins(
|
||||
importDb: Database.Database,
|
||||
host: Record<string, unknown>,
|
||||
hostId: number,
|
||||
userId: string,
|
||||
credentialIds: Map<number, number>,
|
||||
): Promise<void> {
|
||||
let rows: Array<Record<string, unknown>>;
|
||||
try {
|
||||
@@ -2040,7 +2038,7 @@ async function importHostProtocolLogins(
|
||||
}
|
||||
protocolAuth[String(row.protocol)] = {
|
||||
authType: row.auth_type,
|
||||
credentialId: row.credential_id,
|
||||
credentialId: credentialIds.get(row.credential_id as number) ?? null,
|
||||
username: row.username,
|
||||
password: row.password,
|
||||
fields,
|
||||
|
||||
@@ -925,6 +925,38 @@ const dropRetiredColumns = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Registration checked for a taken username and then inserted with nothing
|
||||
// enforcing it, so concurrent sign-ups could create two accounts with one
|
||||
// name, and every lookup by username picked between them arbitrarily. Later
|
||||
// duplicates are renamed rather than deleted, so no account is lost.
|
||||
const enforceUniqueUsernames = () => {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
WITH ranked AS MATERIALIZED (
|
||||
SELECT id, username,
|
||||
ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence,
|
||||
ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence
|
||||
FROM users
|
||||
), candidates AS (
|
||||
SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked
|
||||
), available AS (
|
||||
SELECT MIN(candidate) AS candidate FROM candidates
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM ranked WHERE username LIKE 'termix-recovered-' || candidate || '-' || '%'
|
||||
)
|
||||
)
|
||||
UPDATE users SET username = 'termix-recovered-' || (SELECT candidate FROM available) || '-' || ranked.sequence
|
||||
FROM ranked WHERE ranked.id = users.id AND ranked.occurrence > 1;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users (username);
|
||||
`);
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Failed to enforce unique usernames", {
|
||||
operation: "schema_migration",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const migrateSchema = () => {
|
||||
repairSnippetsColumns(sqlite);
|
||||
addColumnIfNotExists("user_preferences", "theme", "TEXT");
|
||||
@@ -953,6 +985,8 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "custom_keybindings", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "layout_id", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "theme_id", "TEXT");
|
||||
|
||||
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
||||
|
||||
@@ -996,6 +1030,7 @@ const migrateSchema = () => {
|
||||
);
|
||||
}
|
||||
}
|
||||
enforceUniqueUsernames();
|
||||
addColumnIfNotExists("sessions", "external_session_ref", "TEXT");
|
||||
|
||||
addColumnIfNotExists("ssh_data", "name", "TEXT");
|
||||
@@ -1893,6 +1928,23 @@ async function moveRemoteLegacySudoPasswords(remote: {
|
||||
}
|
||||
}
|
||||
|
||||
async function dropMysqlForeignKeys(
|
||||
remote: { execute: (query: SQL) => Promise<unknown> },
|
||||
table: string,
|
||||
column: string,
|
||||
): Promise<void> {
|
||||
const keys = resultRows<{ name: string }>(
|
||||
await remote.execute(
|
||||
sql`SELECT CONSTRAINT_NAME AS name FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ${table} AND COLUMN_NAME = ${column} AND REFERENCED_TABLE_NAME IS NOT NULL`,
|
||||
),
|
||||
);
|
||||
for (const key of keys) {
|
||||
await remote.execute(
|
||||
sql`ALTER TABLE ${sql.identifier(table)} DROP FOREIGN KEY ${sql.identifier(key.name)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The columns 2.9.0 left in place without a drizzle migration, so drizzle no
|
||||
* longer knows them. Dropped by hand, only where they still exist.
|
||||
@@ -1928,6 +1980,10 @@ async function dropRemoteRetired(
|
||||
for (const column of columns) {
|
||||
if (!existing.has(`${table}.${column}`)) continue;
|
||||
try {
|
||||
// Postgres drops a column's foreign keys with it; MySQL refuses.
|
||||
if (dialect === "mysql") {
|
||||
await dropMysqlForeignKeys(remote, table, column);
|
||||
}
|
||||
await remote.execute(
|
||||
sql`ALTER TABLE ${sql.identifier(table)} DROP COLUMN ${sql.identifier(column)}`,
|
||||
);
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
*
|
||||
* Kept on purpose: columns a move only reads once the owner's data key opens
|
||||
* at a password login (users.totp_*, the rdp, vnc and telnet logins and
|
||||
* domain), so a user who never signed in to 2.9 still gets them, and
|
||||
* ssh_data.vault_profile_id, whose foreign key SQLite cannot drop in place.
|
||||
* domain), so a user who never signed in to 2.9 still gets them.
|
||||
*
|
||||
* ssh_data.vault_profile_id goes because its foreign key follows the vault
|
||||
* plugin's rename of vault_profiles: once the plugin is uninstalled, SQLite
|
||||
* refuses every write to ssh_data and the other engines refuse the uninstall.
|
||||
*/
|
||||
|
||||
/** Columns drizzle never knew were removed, dropped by hand on every engine. */
|
||||
@@ -52,6 +55,7 @@ export const REMOTE_RETIRED_COLUMNS: Record<string, string[]> = {
|
||||
"telnet_port",
|
||||
"tunnel_connections",
|
||||
"use_warpgate",
|
||||
"vault_profile_id",
|
||||
"vnc_port",
|
||||
"web_ui_config",
|
||||
"wol_broadcast_address",
|
||||
|
||||
@@ -21,13 +21,19 @@ import {
|
||||
} from "drizzle-orm/mysql-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export const users = mysqlTable("users", {
|
||||
id: varchar("id", { length: 255 }).primaryKey(),
|
||||
username: text("username").notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: boolean("is_admin").notNull().default(false),
|
||||
registeredAt: text("registered_at").notNull().default(sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
export const users = mysqlTable(
|
||||
"users",
|
||||
{
|
||||
id: varchar("id", { length: 255 }).primaryKey(),
|
||||
username: varchar("username", { length: 255 }).notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: boolean("is_admin").notNull().default(false),
|
||||
registeredAt: text("registered_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [uniqueIndex("idx_users_username").on(table.username)],
|
||||
);
|
||||
|
||||
export const settings = mysqlTable("settings", {
|
||||
key: varchar("key", { length: 255 }).primaryKey(),
|
||||
@@ -703,6 +709,8 @@ export const userPreferences = mysqlTable("user_preferences", {
|
||||
compactHostView: boolean("compact_host_view"),
|
||||
statusColorScheme: text("status_color_scheme"),
|
||||
customKeybindings: text("custom_keybindings"),
|
||||
layoutId: text("layout_id"),
|
||||
themeId: text("theme_id"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
|
||||
@@ -21,13 +21,19 @@ import {
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export const users = pgTable("users", {
|
||||
id: varchar("id", { length: 255 }).primaryKey(),
|
||||
username: text("username").notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: boolean("is_admin").notNull().default(false),
|
||||
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
export const users = pgTable(
|
||||
"users",
|
||||
{
|
||||
id: varchar("id", { length: 255 }).primaryKey(),
|
||||
username: varchar("username", { length: 255 }).notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: boolean("is_admin").notNull().default(false),
|
||||
registeredAt: text("registered_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [uniqueIndex("idx_users_username").on(table.username)],
|
||||
);
|
||||
|
||||
export const settings = pgTable("settings", {
|
||||
key: varchar("key", { length: 255 }).primaryKey(),
|
||||
@@ -703,6 +709,8 @@ export const userPreferences = pgTable("user_preferences", {
|
||||
compactHostView: boolean("compact_host_view"),
|
||||
statusColorScheme: text("status_color_scheme"),
|
||||
customKeybindings: text("custom_keybindings"),
|
||||
layoutId: text("layout_id"),
|
||||
themeId: text("theme_id"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
|
||||
@@ -9,13 +9,19 @@ import {
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export const users = sqliteTable("users", {
|
||||
id: text("id").primaryKey(),
|
||||
username: text("username").notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
|
||||
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
export const users = sqliteTable(
|
||||
"users",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
username: text("username").notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
|
||||
registeredAt: text("registered_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [uniqueIndex("idx_users_username").on(table.username)],
|
||||
);
|
||||
|
||||
export const settings = sqliteTable("settings", {
|
||||
key: text("key").primaryKey(),
|
||||
@@ -697,6 +703,8 @@ export const userPreferences = sqliteTable("user_preferences", {
|
||||
compactHostView: integer("compact_host_view", { mode: "boolean" }),
|
||||
statusColorScheme: text("status_color_scheme"),
|
||||
customKeybindings: text("custom_keybindings"),
|
||||
layoutId: text("layout_id"),
|
||||
themeId: text("theme_id"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
|
||||
@@ -103,11 +103,11 @@ export class CredentialRepository {
|
||||
.orderBy(desc(sshCredentials.updatedAt));
|
||||
}
|
||||
|
||||
async existsForImportIdentity(
|
||||
async findIdForImportIdentity(
|
||||
userId: string,
|
||||
name: string,
|
||||
username: string | null,
|
||||
): Promise<boolean> {
|
||||
): Promise<number | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: sshCredentials.id })
|
||||
.from(sshCredentials)
|
||||
@@ -120,7 +120,7 @@ export class CredentialRepository {
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
return rows[0]?.id ?? null;
|
||||
}
|
||||
|
||||
async findDecryptedByIdForUser(
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { getCurrentRepositorySqlite } from "./factory.js";
|
||||
import { needsExplicitPersist, resolveDatabaseDialect } from "../db/dialect.js";
|
||||
|
||||
export interface SqliteForeignKeyClient {
|
||||
exec(sql: string): unknown;
|
||||
}
|
||||
|
||||
export async function withSqliteForeignKeysDisabled<T>(
|
||||
sqlite: SqliteForeignKeyClient,
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
sqlite.exec("PRAGMA foreign_keys = OFF");
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
sqlite.exec("PRAGMA foreign_keys = ON");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a bulk import with foreign keys relaxed.
|
||||
*
|
||||
* Backup restore writes tables in an order that is not dependency-safe, so the
|
||||
* constraints have to stand down for the duration.
|
||||
*
|
||||
* Postgres and MySQL keep their constraints enabled. The portable importer
|
||||
* writes through repositories and handles individual row failures, so it must
|
||||
* still be allowed to run there; only SQLite needs this connection-local
|
||||
* relaxation for legacy backups whose rows are not dependency ordered.
|
||||
*/
|
||||
export async function withCurrentSqliteForeignKeysDisabled<T>(
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const dialect = resolveDatabaseDialect();
|
||||
if (!needsExplicitPersist(dialect)) {
|
||||
return operation();
|
||||
}
|
||||
|
||||
return withSqliteForeignKeysDisabled(getCurrentRepositorySqlite(), operation);
|
||||
}
|
||||
@@ -24,6 +24,27 @@ const userColumns = {
|
||||
),
|
||||
};
|
||||
|
||||
// The unique-violation codes of better-sqlite3, node-postgres and mysql2.
|
||||
const UNIQUE_VIOLATION_CODES = new Set([
|
||||
"SQLITE_CONSTRAINT_UNIQUE",
|
||||
"23505",
|
||||
"ER_DUP_ENTRY",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Whether creating a user failed because the username is taken. The id is
|
||||
* generated, so the username index is the only one a new row can collide on.
|
||||
* drizzle wraps driver errors, so the code may sit on a cause.
|
||||
*/
|
||||
export function isUsernameTaken(error: unknown): boolean {
|
||||
for (let e = error; e instanceof Error; e = e.cause) {
|
||||
if (UNIQUE_VIOLATION_CODES.has((e as { code?: string }).code ?? "")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export class UserRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
|
||||
@@ -140,7 +140,14 @@ 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[]): void {
|
||||
export function notifyImportedHosts(
|
||||
userId: string,
|
||||
hostIds: number[],
|
||||
createdHostIds: number[],
|
||||
): void {
|
||||
for (const hostId of createdHostIds) {
|
||||
pluginEvents.emit(TOPICS.hostCreated, { hostId, userId });
|
||||
}
|
||||
for (const hostId of hostIds) {
|
||||
pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId });
|
||||
}
|
||||
@@ -568,26 +575,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();
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
const knownAuthTypes = listKnownAuthTypes();
|
||||
const writtenHostIds: number[] = [];
|
||||
const createdHostIds: number[] = [];
|
||||
for (const { host: hostData, index: i, exportId } of orderedHosts) {
|
||||
try {
|
||||
const effectiveConnectionType = hostData.connectionType || "ssh";
|
||||
@@ -624,6 +631,15 @@ 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 &&
|
||||
@@ -769,8 +785,6 @@ 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,
|
||||
@@ -797,6 +811,7 @@ export function registerHostBulkRoutes(
|
||||
);
|
||||
if (exportId !== undefined) importedIds.set(exportId, saved.id);
|
||||
savedHostId = saved.id;
|
||||
createdHostIds.push(saved.id);
|
||||
results.success++;
|
||||
}
|
||||
|
||||
@@ -828,10 +843,10 @@ export function registerHostBulkRoutes(
|
||||
}
|
||||
|
||||
await applyDefaultsAfterHostWrites(writtenHostIds);
|
||||
notifyImportedHosts(userId, writtenHostIds);
|
||||
notifyImportedHosts(userId, writtenHostIds, createdHostIds);
|
||||
|
||||
res.json({
|
||||
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
|
||||
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`,
|
||||
success: results.success,
|
||||
updated: results.updated,
|
||||
skipped: results.skipped,
|
||||
@@ -931,23 +946,22 @@ export function registerHostBulkRoutes(
|
||||
|
||||
let existingHostMap: Map<string, { id: number }> | undefined;
|
||||
const hostRepository = createCurrentHostRepository();
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
const writtenHostIds: number[] = [];
|
||||
const createdHostIds: number[] = [];
|
||||
for (let i = 0; i < hostsToImport.length; i++) {
|
||||
const hostData = normalizeImportedHost(
|
||||
hostsToImport[i] as Record<string, unknown>,
|
||||
@@ -962,6 +976,13 @@ 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",
|
||||
@@ -999,8 +1020,6 @@ 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,
|
||||
@@ -1023,6 +1042,7 @@ export function registerHostBulkRoutes(
|
||||
sshDataObj,
|
||||
);
|
||||
writtenHostIds.push(saved.id);
|
||||
createdHostIds.push(saved.id);
|
||||
results.success++;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1034,10 +1054,10 @@ export function registerHostBulkRoutes(
|
||||
}
|
||||
|
||||
await applyDefaultsAfterHostWrites(writtenHostIds);
|
||||
notifyImportedHosts(userId, writtenHostIds);
|
||||
notifyImportedHosts(userId, writtenHostIds, createdHostIds);
|
||||
|
||||
res.json({
|
||||
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
|
||||
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`,
|
||||
success: results.success,
|
||||
updated: results.updated,
|
||||
skipped: results.skipped,
|
||||
|
||||
@@ -88,6 +88,23 @@ 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,8 +31,10 @@ import {
|
||||
import {
|
||||
applyHostKeyTypeUpdate,
|
||||
containsOwnerPrivateAuthUpdate,
|
||||
impliedSshAuthType,
|
||||
keepsStoredSshAuth,
|
||||
isNonEmptyString,
|
||||
isOptionalAuthType,
|
||||
isOptionalBoolean,
|
||||
isValidPort,
|
||||
normalizeProtocolEnableFields,
|
||||
@@ -259,7 +261,9 @@ router.post(
|
||||
!isNonEmptyString(ip) ||
|
||||
!isValidPort(port) ||
|
||||
!isOptionalBoolean(shareSshAuth) ||
|
||||
!isOptionalBoolean(enableSsh)
|
||||
!isOptionalBoolean(enableSsh) ||
|
||||
!isOptionalAuthType(authType) ||
|
||||
!isOptionalAuthType(authMethod)
|
||||
) {
|
||||
sshLogger.warn("Invalid SSH data input validation failed", {
|
||||
operation: "host_create",
|
||||
@@ -292,7 +296,9 @@ router.post(
|
||||
const effectiveAuthType =
|
||||
authType ||
|
||||
authMethod ||
|
||||
(effectiveConnectionType !== "ssh" ? "password" : undefined);
|
||||
(effectiveConnectionType !== "ssh"
|
||||
? "password"
|
||||
: impliedSshAuthType(hostData));
|
||||
const effectiveUsername =
|
||||
username || firstProtocolUsername(protocolAuthPatch);
|
||||
const effectiveName =
|
||||
@@ -403,6 +409,13 @@ 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,
|
||||
@@ -473,6 +486,7 @@ 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, {
|
||||
@@ -827,6 +841,8 @@ router.put(
|
||||
!isValidPort(port) ||
|
||||
!isOptionalBoolean(shareSshAuth) ||
|
||||
!isOptionalBoolean(enableSsh) ||
|
||||
!isOptionalAuthType(authType) ||
|
||||
!isOptionalAuthType(authMethod) ||
|
||||
!hostId
|
||||
) {
|
||||
sshLogger.warn("Invalid SSH data input validation failed for update", {
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||
import {
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserPreferenceRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import { getPluginRuntime } from "../../plugins/index.js";
|
||||
import {
|
||||
LAYOUT_SETTING,
|
||||
LOOK_ID_PATTERN,
|
||||
THEME_SETTING,
|
||||
installedLayoutIds,
|
||||
resolveDefaultLook,
|
||||
safeLayoutForced,
|
||||
unavailableLooks,
|
||||
} from "../../plugins/looks.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
async function adminDefaults() {
|
||||
const settings = createCurrentSettingsRepository();
|
||||
return {
|
||||
layout: await settings.get(LAYOUT_SETTING),
|
||||
theme: await settings.get(THEME_SETTING),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /user-preferences/look:
|
||||
* get:
|
||||
* summary: Get the layout and theme to show the current user
|
||||
* description: The user's own choice, the admin defaults (a layout no installed plugin brings reads as null), the default look of the bundled plugins, whether TERMIX_SAFE_LAYOUT forces the repair view, and installed layout or theme plugins that could not load. The browser resolves the final look from these.
|
||||
* tags:
|
||||
* - User Preferences
|
||||
* responses:
|
||||
* 200:
|
||||
* description: What the look is chosen from.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* layoutId:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* themeId:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* defaults:
|
||||
* type: object
|
||||
* description: Admin defaults, null where unset.
|
||||
* properties:
|
||||
* layout:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* theme:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* defaultLook:
|
||||
* type: object
|
||||
* description: The default-look plugin's layout and theme, null where none is installed.
|
||||
* properties:
|
||||
* layout:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* theme:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* safeLayout:
|
||||
* type: boolean
|
||||
* unavailable:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* version:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* layouts:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* themes:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* error:
|
||||
* type: string
|
||||
*/
|
||||
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const preferences =
|
||||
await createCurrentUserPreferenceRepository().findByUserId(userId);
|
||||
const { loader } = getPluginRuntime();
|
||||
const installed = installedLayoutIds(loader);
|
||||
const known = (id: string | null | undefined) =>
|
||||
id && installed.has(id) ? id : null;
|
||||
const defaults = await adminDefaults();
|
||||
res.json({
|
||||
layoutId: known(preferences?.layoutId),
|
||||
themeId: preferences?.themeId ?? null,
|
||||
defaults: { ...defaults, layout: known(defaults.layout) },
|
||||
defaultLook: resolveDefaultLook(loader),
|
||||
safeLayout: safeLayoutForced(),
|
||||
unavailable: unavailableLooks(loader),
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get the look", error, {
|
||||
operation: "get_look",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to get the look" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /user-preferences/look/defaults:
|
||||
* put:
|
||||
* summary: Set the default layout and theme (admin only)
|
||||
* description: What everyone sees until they choose their own. Null clears a default so the default look applies.
|
||||
* tags:
|
||||
* - User Preferences
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* layout:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* theme:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The defaults now in effect.
|
||||
* 400:
|
||||
* description: Not a layout or theme id.
|
||||
* 403:
|
||||
* description: Not an admin.
|
||||
*/
|
||||
router.put(
|
||||
"/defaults",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const actor = await createCurrentUserRepository().findById(userId);
|
||||
if (!actor?.isAdmin) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
const body = (req.body ?? {}) as { layout?: unknown; theme?: unknown };
|
||||
const changes: Array<[string, unknown]> = [
|
||||
[LAYOUT_SETTING, body.layout],
|
||||
[THEME_SETTING, body.theme],
|
||||
];
|
||||
for (const [key, value] of changes) {
|
||||
if (
|
||||
value !== undefined &&
|
||||
value !== null &&
|
||||
(typeof value !== "string" || !LOOK_ID_PATTERN.test(value))
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: `${key} must be a layout or theme id, or null` });
|
||||
}
|
||||
}
|
||||
const settings = createCurrentSettingsRepository();
|
||||
for (const [key, value] of changes) {
|
||||
if (value === undefined) continue;
|
||||
if (value === null) await settings.delete(key);
|
||||
else await settings.set(key, value as string);
|
||||
}
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor.username ?? userId,
|
||||
action: "update_default_look",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ layout: body.layout, theme: body.theme }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
res.json(await adminDefaults());
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to set the default look", error, {
|
||||
operation: "set_default_look",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to set the default look" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "../repositories/factory.js";
|
||||
import { getPluginRuntime } from "../../plugins/index.js";
|
||||
import { describePluginFrontend } from "../../plugins/assets.js";
|
||||
import { isDefaultLookPlugin } from "../../plugins/bundled-index.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
||||
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
|
||||
@@ -243,7 +244,9 @@ export async function listPreLoginPlugins(): Promise<
|
||||
* frontends in the same order the server does.
|
||||
*
|
||||
* On a desktop linked to a server, `runsOnServer` marks plugins that
|
||||
* only run on that server and stay off here on purpose.
|
||||
* only run on that server and stay off here on purpose, and, for
|
||||
* admins, `managedByServer` marks plugins whose on/off state the server
|
||||
* decides.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* responses:
|
||||
@@ -350,6 +353,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
dependencies,
|
||||
optionalDependencies,
|
||||
docs,
|
||||
defaultLook: isDefaultLookPlugin(record.id),
|
||||
...describePluginFrontend(loaded),
|
||||
};
|
||||
|
||||
@@ -370,6 +374,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
features,
|
||||
env,
|
||||
signedBy: loaded?.signedBy ?? null,
|
||||
managedByServer: linked && (await isManagedByLinkedServer(record.id)),
|
||||
capabilities,
|
||||
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
|
||||
lastError: loaded?.lastError ?? record.lastError ?? null,
|
||||
@@ -393,9 +398,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
async function isManagedByLinkedServer(pluginId: string): Promise<boolean> {
|
||||
if (process.env.ELECTRON_EMBEDDED !== "true") return false;
|
||||
const { getLink } = await import("../../sync/client/link-store.js");
|
||||
if (!(await getLink())) return false;
|
||||
if (!(await isLinkedDesktop())) return false;
|
||||
const { isServerManaged } = await import("../../sync/client/plugins.js");
|
||||
return isServerManaged(pluginId);
|
||||
}
|
||||
@@ -500,7 +503,7 @@ router.get(
|
||||
* /plugins/onboarding/apply:
|
||||
* post:
|
||||
* summary: Apply the onboarding plugin picker
|
||||
* description: Sets every listed plugin to enabled, disabled or removed in one pass. Choices are first raised so that every kept plugin still has its hard dependencies (the adjustments list says which and why). Enabling runs dependencies first, disabling and removing run dependents first, and one plugin failing does not stop the rest. Removing a bundled plugin deletes it and its data; it can be reinstalled from the registry later. Clears the fresh-install pending flag. With dryRun nothing changes and the response lists what would.
|
||||
* description: Sets every listed plugin to enabled, disabled or removed in one pass. Choices are first raised so that every kept plugin still has its hard dependencies (the adjustments list says which and why). Enabling runs dependencies first, disabling and removing run dependents first, and one plugin failing does not stop the rest. Removing a bundled plugin deletes it and its data; it can be reinstalled from the registry later. Clears the fresh-install pending flag. With dryRun nothing changes and the response lists what would. On a linked desktop, locally managed plugins require version and capabilities matching the reviewed manifest when enabling.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* requestBody:
|
||||
@@ -887,7 +890,7 @@ router.delete(
|
||||
* /plugins/{id}/state:
|
||||
* patch:
|
||||
* summary: Enable or disable a plugin
|
||||
* description: Persists the new state and starts or stops the plugin immediately. Disabling releases everything the plugin owns, including any port it was listening on. Enabling also starts any installed hard dependency that is off, and disabling also stops the running plugins that depend on it. With dryRun nothing changes and the response lists what would.
|
||||
* description: Persists the new state and starts or stops the plugin immediately. Disabling releases everything the plugin owns, including any port it was listening on. Enabling also starts any installed hard dependency that is off, and disabling also stops the running plugins that depend on it. With dryRun nothing changes and the response lists what would. On a linked desktop, locally managed plugins require version and capabilities matching the reviewed manifest when enabling.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
@@ -909,6 +912,12 @@ router.delete(
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* version:
|
||||
* type: string
|
||||
* capabilities:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The plugin's new state.
|
||||
@@ -962,7 +971,26 @@ router.patch(
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await setPluginState(pluginId, enabled);
|
||||
let result;
|
||||
if (enabled && (await isLinkedDesktop())) {
|
||||
const version = readVersion(req.body);
|
||||
const capabilities = readCapabilities(req.body);
|
||||
if (version === null || capabilities === null) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: "Invalid plugin version or capabilities" });
|
||||
return;
|
||||
}
|
||||
const { enablePluginWithConsent } =
|
||||
await import("../../plugins/manage.js");
|
||||
result = await enablePluginWithConsent(pluginId, {
|
||||
version,
|
||||
capabilities,
|
||||
userId: (req as AuthenticatedRequest).userId as string,
|
||||
});
|
||||
} else {
|
||||
result = await setPluginState(pluginId, enabled);
|
||||
}
|
||||
await auditPluginAction(
|
||||
req,
|
||||
enabled ? "enable_plugin" : "disable_plugin",
|
||||
@@ -2253,6 +2281,63 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}/restore-bundled:
|
||||
* post:
|
||||
* summary: Restore the bundled copy of a plugin
|
||||
* description: >
|
||||
* Drops any update downloaded over a plugin that ships with Termix and
|
||||
* loads the copy from the image again, so a broken release can be rolled
|
||||
* back from the layout repair view. The shipped version is pinned so
|
||||
* auto update does not bring the release back. Dependents restart.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The restored version and the plugin's state.
|
||||
* 400:
|
||||
* description: Invalid plugin id.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 409:
|
||||
* description: The plugin has no bundled copy, or a linked server manages it.
|
||||
*/
|
||||
router.post(
|
||||
"/:id/restore-bundled",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
const pluginId = String(req.params.id);
|
||||
if (!ID_PATTERN.test(pluginId)) {
|
||||
res.status(400).json({ error: "Invalid plugin id" });
|
||||
return;
|
||||
}
|
||||
if (await refuseOnLinkedDesktop(res)) return;
|
||||
try {
|
||||
const { restoreBundledPlugin } = await import("../../plugins/manage.js");
|
||||
const result = await restoreBundledPlugin(pluginId);
|
||||
await auditPluginAction(req, "restore_bundled_plugin", pluginId, {
|
||||
version: result.version,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
sendManageError(
|
||||
res,
|
||||
error,
|
||||
"Failed to restore the bundled copy",
|
||||
"plugin_restore_bundled",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}:
|
||||
@@ -2280,7 +2365,7 @@ router.post(
|
||||
* 404:
|
||||
* description: No such plugin.
|
||||
* 409:
|
||||
* description: Other running plugins depend on it, or managed by a linked server.
|
||||
* description: Other running plugins depend on it, it is the default look, or a linked server manages it.
|
||||
*/
|
||||
router.delete(
|
||||
"/:id",
|
||||
|
||||
@@ -17,9 +17,10 @@ import {
|
||||
listUserSecondFactors,
|
||||
resetUserSecondFactors,
|
||||
} from "../../auth/second-factor-admin.js";
|
||||
import type {
|
||||
UserRecord,
|
||||
UserRepository,
|
||||
import {
|
||||
isUsernameTaken,
|
||||
type UserRecord,
|
||||
type UserRepository,
|
||||
} from "../repositories/user-repository.js";
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
@@ -541,6 +542,9 @@ export function registerUserAdminRoutes(
|
||||
toast: { type: "success", message: `User created: ${username}` },
|
||||
});
|
||||
} catch (err) {
|
||||
if (isUsernameTaken(err)) {
|
||||
return res.status(409).json({ error: "Username already exists" });
|
||||
}
|
||||
authLogger.error("Failed to admin-create user", err);
|
||||
res.status(500).json({ error: "Failed to create user" });
|
||||
}
|
||||
|
||||
@@ -32,8 +32,13 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
|
||||
compactHostView: row?.compactHostView ?? null,
|
||||
statusColorScheme: row?.statusColorScheme ?? null,
|
||||
customKeybindings: row?.customKeybindings ?? null,
|
||||
layoutId: row?.layoutId ?? null,
|
||||
themeId: row?.themeId ?? null,
|
||||
});
|
||||
|
||||
/** Layout and theme ids, the same shape the plugin manifest allows. */
|
||||
const LOOK_ID = /^[a-z][a-z0-9-]{1,39}$/;
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /user-preferences:
|
||||
@@ -104,6 +109,14 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
|
||||
* type: string
|
||||
* nullable: true
|
||||
* description: JSON-encoded array of the user's custom keybindings. An action's type is one of the shell's own or one a plugin declares in contributes.keybindingActions.
|
||||
* layoutId:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* description: The layout this user chose, overriding the admin default. Null follows the default.
|
||||
* themeId:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* description: The theme this user chose, overriding the admin default. Null follows the default.
|
||||
*/
|
||||
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
@@ -165,6 +178,14 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
* customKeybindings:
|
||||
* type: string
|
||||
* description: JSON-encoded array of the user's custom keybindings. Each action is checked against the shell's own types and the parameters a plugin declares for its type.
|
||||
* layoutId:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* description: A layout id, or null to follow the admin default.
|
||||
* themeId:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* description: A theme id, or null to follow the admin default.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Preferences updated successfully.
|
||||
@@ -186,6 +207,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
confirmTabClose,
|
||||
hiddenRailTabs,
|
||||
customKeybindings,
|
||||
layoutId,
|
||||
themeId,
|
||||
} = req.body as {
|
||||
reopenTabsOnLogin?: boolean;
|
||||
theme?: string | null;
|
||||
@@ -201,6 +224,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
confirmTabClose?: boolean | null;
|
||||
hiddenRailTabs?: string | null;
|
||||
customKeybindings?: string | null;
|
||||
layoutId?: string | null;
|
||||
themeId?: string | null;
|
||||
};
|
||||
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme are no
|
||||
// longer writable here -- they moved to /host-sidebar/preferences as of the
|
||||
@@ -258,6 +283,18 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries({ layoutId, themeId })) {
|
||||
if (
|
||||
value !== undefined &&
|
||||
value !== null &&
|
||||
(typeof value !== "string" || !LOOK_ID.test(value))
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: `${key} must be a layout or theme id, or null` });
|
||||
}
|
||||
}
|
||||
|
||||
const boolFields: Record<string, boolean | null | undefined> = {
|
||||
commandPaletteEnabled,
|
||||
pinAppRail,
|
||||
@@ -290,6 +327,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
if (confirmTabClose !== undefined) updates.confirmTabClose = confirmTabClose;
|
||||
if (customKeybindings !== undefined)
|
||||
updates.customKeybindings = customKeybindings;
|
||||
if (layoutId !== undefined) updates.layoutId = layoutId;
|
||||
if (themeId !== undefined) updates.themeId = themeId;
|
||||
|
||||
if (Object.keys(updates).length === 1) {
|
||||
return res.status(400).json({ error: "No preferences provided" });
|
||||
|
||||
@@ -41,9 +41,13 @@ import {
|
||||
createCurrentUserAuthRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import type { UserRecord } from "../repositories/user-repository.js";
|
||||
import {
|
||||
isUsernameTaken,
|
||||
type UserRecord,
|
||||
} from "../repositories/user-repository.js";
|
||||
import {
|
||||
getTrustedProxyAuthConfig,
|
||||
getTrustedProxySourceAddress,
|
||||
isTrustedProxyAddress,
|
||||
isTrustedProxyAuthEnabled,
|
||||
resolveTrustedProxyRoles,
|
||||
@@ -264,6 +268,10 @@ router.post("/create", async (req, res) => {
|
||||
toast: { type: "success", message: `User created: ${username}` },
|
||||
});
|
||||
} catch (err) {
|
||||
// Lost a race with a concurrent sign-up for the same name.
|
||||
if (isUsernameTaken(err)) {
|
||||
return res.status(409).json({ error: "Username already exists" });
|
||||
}
|
||||
authLogger.error("Failed to create user", err);
|
||||
res.status(500).json({ error: "Failed to create user" });
|
||||
}
|
||||
@@ -304,7 +312,7 @@ router.post("/proxy-login", async (req, res) => {
|
||||
}
|
||||
if (!config.enabled) return res.json({ enabled: false });
|
||||
|
||||
const sourceAddress = req.socket.remoteAddress;
|
||||
const sourceAddress = getTrustedProxySourceAddress(req);
|
||||
try {
|
||||
if (!isTrustedProxyAddress(sourceAddress, config.trustedProxies)) {
|
||||
authLogger.warn(
|
||||
|
||||
Loaded 100 of 467 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user