Compare commits

..
Author SHA1 Message Date
LukeGus 5f332aa5c3 feat: add an auto or fixed columns picker to card grids 2026-10-21 21:35:25 -05:00
266 changed files with 768 additions and 59756 deletions

No files matched your search

+3 -15
View File
@@ -41,15 +41,9 @@ on:
default: "" default: ""
jobs: jobs:
# One image per plugin profile (docker/bundled-plugins.json). standard keeps
# the plain tags; minimal and full add a suffix.
build: build:
runs-on: blacksmith-8vcpu-ubuntu-2404 runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 20 timeout-minutes: 20
strategy:
fail-fast: false
matrix:
profile: [standard, minimal, full]
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v7
@@ -86,17 +80,12 @@ jobs:
TAGS=() TAGS=()
ALL_TAGS=() ALL_TAGS=()
SUFFIX=""
if [ "${{ matrix.profile }}" != "standard" ]; then
SUFFIX="-${{ matrix.profile }}"
fi
if [ "$BUILD_TYPE" = "Production" ]; then if [ "$BUILD_TYPE" = "Production" ]; then
TAGS+=("$VERSION$SUFFIX" "latest$SUFFIX") TAGS+=("$VERSION" "latest")
elif [ "$BUILD_TYPE" = "Beta" ]; then elif [ "$BUILD_TYPE" = "Beta" ]; then
TAGS+=("beta$SUFFIX" "$VERSION$SUFFIX") TAGS+=("beta" "$VERSION")
else else
TAGS+=("dev-$VERSION$SUFFIX") TAGS+=("dev-$VERSION")
fi fi
for tag in "${TAGS[@]}"; do for tag in "${TAGS[@]}"; do
@@ -135,7 +124,6 @@ jobs:
tags: ${{ env.ALL_TAGS }} tags: ${{ env.ALL_TAGS }}
build-args: | build-args: |
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1 BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
PLUGIN_PROFILE=${{ matrix.profile }}
labels: | labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }} org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ env.SOURCE_SHA }} org.opencontainers.image.revision=${{ env.SOURCE_SHA }}
+1 -72
View File
@@ -250,27 +250,6 @@ jobs:
path: termix_windows_arm64_portable.zip path: termix_windows_arm64_portable.zip
retention-days: 30 retention-days: 30
# The same app with every official plugin, for installs without
# internet. Built last so nothing above packs the full set, and with
# no update files: a full install updates through the normal build and
# downloads the plugins it had on.
- name: Build Windows x64 full installer
if: inputs.artifact_destination != 'none'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TERMIX_PLUGIN_PROFILE: full
run: |
npm run build:plugins
npx electron-builder --win nsis --x64 --publish never '-c.nsis.artifactName=termix_windows_x64_full_nsis.${ext}'
- name: Upload Windows x64 full installer
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_windows_x64_full_nsis.exe') != '' && inputs.artifact_destination != 'none'
with:
name: termix_windows_x64_full_nsis
path: release/termix_windows_x64_full_nsis.exe
retention-days: 30
build-linux: build-linux:
runs-on: blacksmith-8vcpu-ubuntu-2404 runs-on: blacksmith-8vcpu-ubuntu-2404
if: (inputs.build_type == 'all' || inputs.build_type == 'linux' || inputs.build_type == '') && inputs.artifact_destination != 'submit' if: (inputs.build_type == 'all' || inputs.build_type == 'linux' || inputs.build_type == '') && inputs.artifact_destination != 'submit'
@@ -509,28 +488,6 @@ jobs:
path: release/com.karmaa.termix.flatpakref path: release/com.karmaa.termix.flatpakref
retention-days: 30 retention-days: 30
# Every official plugin, for installs without internet. No update files.
- name: Build Linux x64 full packages
if: inputs.artifact_destination != 'none'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TERMIX_PLUGIN_PROFILE: full
run: |
npm run build:plugins
npx electron-builder --linux AppImage deb --x64 --publish never \
'-c.appImage.artifactName=termix_linux_x64_full_appimage.${ext}' \
'-c.deb.artifactName=termix_linux_x64_full_deb.${ext}'
- name: Upload Linux x64 full packages
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_linux_x64_full_appimage.AppImage') != '' && inputs.artifact_destination != 'none'
with:
name: termix_linux_x64_full
path: |
release/termix_linux_x64_full_appimage.AppImage
release/termix_linux_x64_full_deb.deb
retention-days: 30
build-macos: build-macos:
runs-on: blacksmith-6vcpu-macos-latest runs-on: blacksmith-6vcpu-macos-latest
if: (inputs.build_type == 'macos' || inputs.build_type == 'all') && inputs.artifact_destination != 'submit' if: (inputs.build_type == 'macos' || inputs.build_type == 'all') && inputs.artifact_destination != 'submit'
@@ -806,32 +763,6 @@ jobs:
gh release upload "$RELEASE_TAG" homebrew-generated/termix.rb --repo ${{ github.repository }} --clobber gh release upload "$RELEASE_TAG" homebrew-generated/termix.rb --repo ${{ github.repository }} --clobber
# Every official plugin, for installs without internet. Signed like the
# normal DMG, with no update files.
- name: Build macOS universal full DMG
if: inputs.artifact_destination != 'none'
env:
ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
NODE_OPTIONS: --max-old-space-size=4096
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TERMIX_PLUGIN_PROFILE: full
run: |
npm run build:plugins
npx electron-builder --mac dmg --universal --publish never \
'-c.dmg.artifactName=termix_macos_${arch}_full_dmg.${ext}'
- name: Upload macOS universal full DMG
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_macos_universal_full_dmg.dmg') != '' && inputs.artifact_destination != 'none'
with:
name: termix_macos_universal_full_dmg
path: release/termix_macos_universal_full_dmg.dmg
retention-days: 30
- name: Clean up keychains - name: Clean up keychains
if: always() if: always()
run: | run: |
@@ -1316,9 +1247,7 @@ jobs:
--app_identifier "com.karmaa.termix" \ --app_identifier "com.karmaa.termix" \
--skip_metadata false \ --skip_metadata false \
--metadata_path "${{ steps.asc_notes.outputs.metadata_path }}" \ --metadata_path "${{ steps.asc_notes.outputs.metadata_path }}" \
--skip_screenshots false \ --skip_screenshots true \
--screenshots_path packaging/appstore/screenshots \
--overwrite_screenshots true \
--skip_app_version_update false \ --skip_app_version_update false \
--submit_for_review true \ --submit_for_review true \
--automatic_release true \ --automatic_release true \
+7 -15
View File
@@ -161,10 +161,9 @@ jobs:
echo "sha=$SHA" >> "$GITHUB_OUTPUT" echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "prev_ref=$PREV" >> "$GITHUB_OUTPUT" echo "prev_ref=$PREV" >> "$GITHUB_OUTPUT"
# Translates new or changed strings with the Termix LLM server and pins the # Translates new or changed strings with the Termix LLM server and commits
# bundled plugins to their newest registry releases, then commits both to the # them to the dev branch, so the beta or stable build includes them. If the
# dev branch so the beta or stable build includes them. If the server is down # server is down it warns and the release goes on with what is there.
# it warns and the release goes on with what is there.
translate: translate:
needs: [prep] needs: [prep]
if: ${{ needs.prep.outputs.skip != 'true' && needs.prep.outputs.mode != 'Overwrite release' }} if: ${{ needs.prep.outputs.skip != 'true' && needs.prep.outputs.mode != 'Overwrite release' }}
@@ -192,26 +191,19 @@ jobs:
languages: src/ui/locales/languages.json languages: src/ui/locales/languages.json
api-key: ${{ secrets.TERMIX_LLM_API_KEY }} api-key: ${{ secrets.TERMIX_LLM_API_KEY }}
- name: Install dependencies - name: Commit translations
run: npm ci --ignore-scripts
# Newest stable plugin release whose engine.termix allows this version.
- name: Pin bundled plugins
run: node scripts/pin-bundled-plugins.cjs --all --core-version "${{ needs.prep.outputs.version }}"
- name: Commit translations and plugin pins
id: commit id: commit
run: | run: |
git add src/ui/locales docker/bundled-plugins.json git add src/ui/locales
if git diff --cached --quiet; then if git diff --cached --quiet; then
echo "No translation changes." echo "No translation changes."
elif [ "${{ needs.prep.outputs.mode }}" = "Dry run" ]; then elif [ "${{ needs.prep.outputs.mode }}" = "Dry run" ]; then
echo "DRY RUN: would commit these translation and pin changes:" echo "DRY RUN: would commit these translation changes:"
git --no-pager diff --cached --stat git --no-pager diff --cached --stat
else else
git config user.name "LukeGus" git config user.name "LukeGus"
git config user.email "bugattiguy527@gmail.com" git config user.email "bugattiguy527@gmail.com"
git commit -m "chore: update translations and plugin pins" git commit -m "chore: update translations"
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}" git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
fi fi
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
-79
View File
@@ -1,90 +1,11 @@
# Changelog # Changelog
## 26.11.1
### Added
- Termix actions: plugins can declare what they do once, and it works from the API with permission checks, dry runs, run history and approvals
- API keys can be limited to reading or running actions, when an admin makes them
- A click on a host can open its host page, when a plugin offers one
### Changed
- Host status, import and export, and credential tools now start on for new installs
- Sync now starts on in a new desktop app, so it can link to a server
- The first run plugin picker leaves plugins you did not choose off instead of removing them
### Fixed
- The first run plugin picker no longer sets the default layout and themes to Remove. They always stay on
- Alerts with no icon no longer squeeze their text to one word per line
- Password fields with the show button are the same height as the other fields next to them
- The Secure your account setup step no longer shows up empty when no passkey or authenticator plugin is on
## 26.11.0
### Fixed
- Saving the database no longer holds up the whole server while it encrypts and writes. A big database is also saved less often by background writes, which fixes slow requests and dropped connections
- The macOS menu bar icon shows the logo instead of an empty shape on multi-architecture builds
- Disabled plugins no longer show update or review buttons, and no longer count as pending updates
- One slow request, like a plugin waiting on an SSH host, no longer shows Server connection lost. The app now checks the server first
- Canceled requests no longer show Server connection lost
- The macOS Window menu has an item to reopen the main window after it is closed
- Hosts with both an SSH key and a password can log in to servers set to AuthenticationMethods publickey,password
## 26.10.6
### Added
- The mobile app can sign in through your browser with any login method, including passkeys and security keys
- Plugins can say how they show in the mobile app
- The plugin list tells the mobile app which plugin API each plugin needs, so the app can say when it or a plugin needs an update
- Hosts can have a color and an icon, which sync to the desktop and mobile apps
- A sidebar setting to turn off alternating row colors
- TERMIX_NGINX_TEMPLATE, TERMIX_NGINX_CONF, TERMIX_NGINX_PID and TERMIX_APP_DIR let an install outside Docker, like the Proxmox LXC script, switch nginx to HTTPS when a certificate is added
- Removing a user or role from a shared folder can also remove their access from every host in the folder and its subfolders
- A personal login set once on a folder is used for every host shared with you in it and its subfolders, unless a host has its own
### Changed
- body-parser is updated to 2.4.0 for a security fix
### Fixed
- The log line for a rejected proxy login now shows the source address, so you can add it to TRUSTED_PROXY_AUTH_TRUSTED_PROXIES
- Deleting your own account now removes all of its data, like an admin delete does, instead of leaving some behind
- An SSH key with a passphrase is unlocked once and kept unlocked in memory for a short time, so connecting to many hosts with it no longer freezes the server for minutes
- A change pushed from one linked desktop or phone now reaches your other linked devices right away, instead of on their next check a few minutes later
- The macOS menu bar icon shows the Termix logo instead of an empty square
- Cmd+1 (Alt+1 on Windows and Linux) and the other number shortcuts pick the open tabs as numbered in the tab bar, not the home tab
- A plugin the app no longer ships and could not download (for example on a locked down network) shows as failed with the reason on the Plugins tab, instead of as installed
- The desktop app no longer shows This layout could not be shown when the layout is asked for before its plugin has finished starting; it tries again
- On the desktop, a plugin installed from the registry that uses core's packages through require (like ACME SSL and axios) starts instead of failing with Cannot find module
- Adding a certificate on an install where Termix cannot manage nginx says what to set instead of only asking for a restart
- A login someone shared with you works on a host shared with you, for personal SSH authentication and when the owner shares SSH authentication
- Lists in plugins (like the AI Assets table) use their full width on narrow screens, keep cells under the right header and no longer overlap rows
- Installing an official plugin from a file works offline: pick the .tmxplug and its .sig from the release together and it installs as official, so plugins like Fleets no longer stop with migration refused. It can also fill in a plugin that was on but could not be downloaded
## 26.10.5 ## 26.10.5
### Added
- Safe mode: start the server with TERMIX_SAFE_MODE=1 to run only layout and theme plugins, so a broken plugin can be turned off
- TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS lets plugin registries live on a private address, like a registry in your LAN
- Plugin registry browsing and downloads use HTTP_PROXY and HTTPS_PROXY when they are set
- Docker image sizes: the default image now ships the most used plugins, the minimal tag ships only the look plugins and the full tag ships every official plugin
- Plugins an older image shipped and you had on are downloaded from the registry on the first start of a smaller image, so nothing goes missing
### Changed ### Changed
- Card grids in the Plugins tab and in plugins can show Auto (as many as fit) or a set number of cards per row - Card grids in the Plugins tab and in plugins can show Auto (as many as fit) or a set number of cards per row
### Fixed
- The macOS app asks for Local Network access, so it can reach servers on your LAN
- The desktop app can change the connection origin of a host shared with you, instead of failing to save
## 26.10.4 ## 26.10.4
### Fixed ### Fixed
+2 -2
View File
@@ -1,6 +1,6 @@
cask "termix" do cask "termix" do
version "26.11.0" version "26.10.3"
sha256 "1b873e1315d769f8e416c1690032142efb152d1265c2f078b33f72cec3e6e03e" sha256 "a0c9db16f144c1e2be6ce2f4ead9268a010d12e0d0608feb34976750661a2239"
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg" url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
name "Termix" name "Termix"
+5 -9
View File
@@ -71,8 +71,8 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [Databases](https://github.com/Termix-SSH/Plugin-Databases) | Browse and edit MySQL, MariaDB, PostgreSQL and SQLite databases on your hosts from one tab. | | [Databases](https://github.com/Termix-SSH/Plugin-Databases) | Browse and edit MySQL, MariaDB, PostgreSQL and SQLite databases on your hosts from one tab. |
| [Docker](https://github.com/Termix-SSH/Plugin-Docker) | Manage Docker and Podman containers on your hosts over SSH, with logs, stats and a console. | | [Docker](https://github.com/Termix-SSH/Plugin-Docker) | Manage Docker and Podman containers on your hosts over SSH, with logs, stats and a console. |
| [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. | | [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. |
| [Firewall](https://github.com/Termix-SSH/Plugin-Firewall) | Manage UFW, firewalld, nftables and iptables rules on your hosts over SSH, with lockout protection. |
| [Fleets](https://github.com/Termix-SSH/Plugin-Fleets) | Group hosts into fleets and run commands, package actions and file transfers on all of them at once. | | [Fleets](https://github.com/Termix-SSH/Plugin-Fleets) | Group hosts into fleets and run commands, package actions and file transfers on all of them at once. |
| [HashiCorp Vault](https://github.com/Termix-SSH/Plugin-Vault) | Connect to hosts with short-lived SSH certificates signed by HashiCorp Vault. |
| [Homepage](https://github.com/Termix-SSH/Plugin-Homepage) | A drag and drop widget page for your servers and services, plus service links for the dashboard. | | [Homepage](https://github.com/Termix-SSH/Plugin-Homepage) | A drag and drop widget page for your servers and services, plus service links for the dashboard. |
| [Host Metrics](https://github.com/Termix-SSH/Plugin-Host-Metrics) | Live CPU, memory, disk, network and GPU stats, plus tools to manage your hosts. | | [Host Metrics](https://github.com/Termix-SSH/Plugin-Host-Metrics) | Live CPU, memory, disk, network and GPU stats, plus tools to manage your hosts. |
| [Host Status](https://github.com/Termix-SSH/Plugin-Host-Status) | Shows if each host is online with a dot in the host list, checked by connecting to its port. | | [Host Status](https://github.com/Termix-SSH/Plugin-Host-Status) | Shows if each host is online with a dot in the host list, checked by connecting to its port. |
@@ -82,7 +82,6 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. | | [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. |
| [Login Security](https://github.com/Termix-SSH/Plugin-Login-Security) | Sign people out when idle, limit session length, turn off remember me and require a second factor at login. | | [Login Security](https://github.com/Termix-SSH/Plugin-Login-Security) | Sign people out when idle, limit session length, turn off remember me and require a second factor at login. |
| [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. | | [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. |
| [Operations Center](https://github.com/Termix-SSH/Plugin-Operations-Center) | Run, roll out, approve and track every plugin action across your hosts, with a page per host and tables across all of them. |
| [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. | | [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. |
| [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. | | [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. |
| [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. | | [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. |
@@ -94,10 +93,8 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [Services](https://github.com/Termix-SSH/Plugin-Service-Manager) | Manage services, timers and logs on your hosts over SSH. | | [Services](https://github.com/Termix-SSH/Plugin-Service-Manager) | Manage services, timers and logs on your hosts over SSH. |
| [Session Recording](https://github.com/Termix-SSH/Plugin-Session-Recording) | Record terminal and remote desktop sessions and play them back or download them later. | | [Session Recording](https://github.com/Termix-SSH/Plugin-Session-Recording) | Record terminal and remote desktop sessions and play them back or download them later. |
| [Session Sharing](https://github.com/Termix-SSH/Plugin-Session-Sharing) | Share live sessions by link or with another user, and present sessions to a group in meeting rooms. | | [Session Sharing](https://github.com/Termix-SSH/Plugin-Session-Sharing) | Share live sessions by link or with another user, and present sessions to a group in meeting rooms. |
| [Simple Layout](https://github.com/Termix-SSH/Plugin-Simple-Layout) | A simple Termix layout for small setups with a top bar, a Home screen and only the basics. |
| [Single sign-on](https://github.com/Termix-SSH/Plugin-SSO) | Sign in with any OpenID Connect provider, GitHub or Google. | | [Single sign-on](https://github.com/Termix-SSH/Plugin-SSO) | Sign in with any OpenID Connect provider, GitHub or Google. |
| [Snippets](https://github.com/Termix-SSH/Plugin-Snippets) | Save the commands you run often and run them in a terminal or on many hosts in one click. | | [Snippets](https://github.com/Termix-SSH/Plugin-Snippets) | Save the commands you run often and run them in a terminal or on many hosts in one click. |
| [SSH Certificate Signer](https://github.com/Termix-SSH/Plugin-Vault) | Connect to hosts with short-lived SSH certificates signed by HashiCorp Vault or OpenBao. |
| [SSH Terminal](https://github.com/Termix-SSH/Plugin-SSH-Terminal) | SSH terminal sessions with tabs, split screen, history, macros and a local terminal in the desktop app. | | [SSH Terminal](https://github.com/Termix-SSH/Plugin-SSH-Terminal) | SSH terminal sessions with tabs, split screen, history, macros and a local terminal in the desktop app. |
| [Step CA](https://github.com/Termix-SSH/Plugin-Step-CA) | Connect to hosts with short-lived SSH certificates from a smallstep step-ca server. | | [Step CA](https://github.com/Termix-SSH/Plugin-Step-CA) | Connect to hosts with short-lived SSH certificates from a smallstep step-ca server. |
| [Sync](https://github.com/Termix-SSH/Plugin-Sync) | Sync the desktop app with one or more servers, and manage other Termix servers as remote sites from a master. | | [Sync](https://github.com/Termix-SSH/Plugin-Sync) | Sync the desktop app with one or more servers, and manage other Termix servers as remote sites from a master. |
@@ -107,10 +104,9 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [TOTP](https://github.com/Termix-SSH/Plugin-TOTP) | Two-factor sign in with a code from an authenticator app, with backup codes. | | [TOTP](https://github.com/Termix-SSH/Plugin-TOTP) | Two-factor sign in with a code from an authenticator app, with backup codes. |
| [Tunnels](https://github.com/Termix-SSH/Plugin-Tunnels) | SSH port forwarding that reconnects on its own, plus client tunnels from the desktop app. | | [Tunnels](https://github.com/Termix-SSH/Plugin-Tunnels) | SSH port forwarding that reconnects on its own, plus client tunnels from the desktop app. |
| [Usage Statistics](https://github.com/Termix-SSH/Plugin-Telemetry) | Sends a small anonymous daily report so we can see how Termix is used. | | [Usage Statistics](https://github.com/Termix-SSH/Plugin-Telemetry) | Sends a small anonymous daily report so we can see how Termix is used. |
| [Wake-on-LAN](https://github.com/Termix-SSH/Plugin-Wake-On-LAN) | Wake, shut down and track your machines from one power dashboard. | | [Wake-on-LAN](https://github.com/Termix-SSH/Plugin-Wake-On-LAN) | Wake sleeping machines by sending a Wake-on-LAN packet to their MAC address. |
| [Warpgate](https://github.com/Termix-SSH/Plugin-Warpgate) | Connect to hosts through a Warpgate SSH bastion. | | [Warpgate](https://github.com/Termix-SSH/Plugin-Warpgate) | Connect to hosts through a Warpgate SSH bastion. |
| [Web Endpoint](https://github.com/Termix-SSH/Plugin-Web-Endpoint) | Open a host's web UI inside Termix, directly or over an SSH tunnel. | | [Web Endpoint](https://github.com/Termix-SSH/Plugin-Web-Endpoint) | Open a host's web UI inside Termix, directly or over an SSH tunnel. |
| [WireGuard](https://github.com/Termix-SSH/Plugin-WireGuard) | Install WireGuard on your hosts and manage interfaces, clients and configs from one tab. |
| [Workspaces](https://github.com/Termix-SSH/Plugin-Workspaces) | Save your open tabs and split layouts and reopen them in one click. | | [Workspaces](https://github.com/Termix-SSH/Plugin-Workspaces) | Save your open tabs and split layouts and reopen them in one click. |
Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry). Turn on the community registry in Plugins, Registries to install plugins made by other people, or add your own registry. See [submitting a plugin](https://docs.termix.site/develop/community-registry) and [hosting your own registry](https://docs.termix.site/develop/registries#host-your-own-registry). Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry). Turn on the community registry in Plugins, Registries to install plugins made by other people, or add your own registry. See [submitting a plugin](https://docs.termix.site/develop/community-registry) and [hosting your own registry](https://docs.termix.site/develop/registries#host-your-own-registry).
@@ -130,15 +126,15 @@ Want to build your own? Start from the [plugin template](https://github.com/Term
</tr> </tr>
<tr> <tr>
<td align="center"><b>Windows</b> <sub>x64/ia32</sub></td> <td align="center"><b>Windows</b> <sub>x64/ia32</sub></td>
<td>Portable | EXE and MSI Installer | Full EXE (all plugins) | Chocolatey</td> <td>Portable | EXE and MSI Installer | Chocolatey</td>
</tr> </tr>
<tr> <tr>
<td align="center"><b>Linux</b> <sub>x64/arm64/armv7l</sub></td> <td align="center"><b>Linux</b> <sub>x64/arm64/armv7l</sub></td>
<td>Portable | AUR | AppImage | Deb | Full AppImage and Deb (all plugins) | Flatpak</td> <td>Portable | AUR | AppImage | Deb | Flatpak</td>
</tr> </tr>
<tr> <tr>
<td align="center"><b>macOS</b> <sub>Universal/x64/arm64, v12.0+</sub></td> <td align="center"><b>macOS</b> <sub>Universal/x64/arm64, v12.0+</sub></td>
<td>Apple App Store | DMG | Full DMG (all plugins) | Homebrew</td> <td>Apple App Store | DMG | Homebrew</td>
</tr> </tr>
<tr> <tr>
<td align="center"><b>iOS/iPadOS</b> <sub>v15.1+</sub></td> <td align="center"><b>iOS/iPadOS</b> <sub>v15.1+</sub></td>
+4 -15
View File
@@ -34,20 +34,9 @@ WORKDIR /app
COPY . . COPY . .
RUN npm run build:backend:core
# Stage 3b: Stage the bundled plugins for one profile (minimal, standard or
# full, see docker/bundled-plugins.json). Only this stage and the final one
# differ between profiles, so the other stages are built once.
FROM deps AS plugins
WORKDIR /app
ARG PLUGIN_PROFILE=standard
COPY scripts/build-plugins.cjs ./scripts/build-plugins.cjs
COPY scripts/lib/bundled-plugins.cjs ./scripts/lib/bundled-plugins.cjs
COPY docker/bundled-plugins.json ./docker/bundled-plugins.json
# Bundled plugin downloads show in the plugin store, so keep them between builds. # Bundled plugin downloads show in the plugin store, so keep them between builds.
RUN --mount=type=cache,target=/app/node_modules/.cache/termix-bundled-plugins \ RUN --mount=type=cache,target=/app/node_modules/.cache/termix-bundled-plugins \
node scripts/build-plugins.cjs --profile "$PLUGIN_PROFILE" npm run build:backend
# Stage 4: Download OPKSSH binary for the target platform so the image works offline # Stage 4: Download OPKSSH binary for the target platform so the image works offline
FROM node:24-slim AS opkssh-downloader FROM node:24-slim AS opkssh-downloader
@@ -128,9 +117,9 @@ COPY --chown=node:node --from=frontend-builder /app/dist /app/html
COPY --chown=node:node --from=production-deps /app/node_modules /app/node_modules COPY --chown=node:node --from=production-deps /app/node_modules /app/node_modules
COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend
# Plugins of the chosen profile pinned in docker/bundled-plugins.json, resolved # Plugins pinned in docker/bundled-plugins.json, resolved as dist/plugins by
# as dist/plugins by getBundledPluginsDir(). # getBundledPluginsDir().
COPY --chown=node:node --from=plugins /app/dist/plugins ./dist/plugins COPY --chown=node:node --from=backend-builder /app/dist/plugins ./dist/plugins
# Target of node_modules/@termix-ssh/plugin-sdk, which core and the plugins import. # Target of node_modules/@termix-ssh/plugin-sdk, which core and the plugins import.
COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/package.json ./packages/plugin-sdk/package.json COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/package.json ./packages/plugin-sdk/package.json
COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/dist ./packages/plugin-sdk/dist COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/dist ./packages/plugin-sdk/dist
+65 -173
View File
@@ -3,23 +3,20 @@
{ {
"id": "acme-ssl", "id": "acme-ssl",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.1.0/acme-ssl-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.2/acme-ssl-1.0.2.tmxplug",
"sha256": "fb75541fae110007b45ed69d58aa4b9b6f6a79b6b1b762c6e54b635aa7cb6e4a", "sha256": "157d0f96dc42136ff64e618b6904db7862079cd31c1f1cf5f19846b1d127b00f"
"tier": "full"
}, },
{ {
"id": "ai", "id": "ai",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.3.1/ai-1.3.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.2.0/ai-1.2.0.tmxplug",
"sha256": "dbee8bc101b8584e9e356bc3539787bb534ffc25809cd92c4f5d45d52cad4ce2", "sha256": "3b486133af78ae5168e6f5b8a4c71f3db3403cfdae537ed1ca8d6cc16035d8dd"
"tier": "full"
}, },
{ {
"id": "alerts", "id": "alerts",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.2.0/alerts-1.2.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.2/alerts-1.0.2.tmxplug",
"sha256": "488947482eed0306893c2983f701a0e14c55975ca2a0c4bb0799a40fce9db054", "sha256": "50768e77019276343a5d162c85d081659fb89d1cc3733ac45cd0db4436918faa",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -29,22 +26,19 @@
"id": "audit-tools", "id": "audit-tools",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Audit-Tools/releases/download/v1.0.0/audit-tools-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Audit-Tools/releases/download/v1.0.0/audit-tools-1.0.0.tmxplug",
"sha256": "131479c294d692b1c0ed4d6cec273730a3a67a28ecd44825ffd36bade9aa863b", "sha256": "131479c294d692b1c0ed4d6cec273730a3a67a28ecd44825ffd36bade9aa863b"
"tier": "full"
}, },
{ {
"id": "automations", "id": "automations",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.2.0/automations-1.2.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.3/automations-1.0.3.tmxplug",
"sha256": "2ec9c718d73a094be3b59121aea049f0237e5783f53d193c4ea24a524584aeea", "sha256": "b5e5b39a053010b72ad1ba03ea6bb05afbbb2b789a86e0e98634d9ada543d02d"
"tier": "full"
}, },
{ {
"id": "classic-layout", "id": "classic-layout",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.1.1/classic-layout-1.1.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.1/classic-layout-1.0.1.tmxplug",
"sha256": "2b0cee0c57b21d8017ed833aa923e934b57b908fa68ff395951b4d52e5113e47", "sha256": "6b2d9acd77ef4b33fa0761c1aea7ed3c6171c325ee8febcedff94b5b2bc7be34",
"tier": "minimal",
"defaultLook": true "defaultLook": true
}, },
{ {
@@ -52,40 +46,19 @@
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Classic-Themes/releases/download/v1.0.0/classic-themes-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Classic-Themes/releases/download/v1.0.0/classic-themes-1.0.0.tmxplug",
"sha256": "919283f95701d41727c826637a7d349bd6a646abd61f9ac9017149bd0bee782c", "sha256": "919283f95701d41727c826637a7d349bd6a646abd61f9ac9017149bd0bee782c",
"tier": "minimal",
"defaultLook": true "defaultLook": true
}, },
{
"id": "cloud-providers",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Cloud-Providers/releases/download/v1.1.0/cloud-providers-1.1.0.tmxplug",
"sha256": "7f61eafc3b99bdf1328fffa95791db1d27f8e1b683e5a5925603b5840057a9c7",
"tier": "full"
},
{ {
"id": "credential-tools", "id": "credential-tools",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Credential-Tools/releases/download/v1.0.0/credential-tools-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Credential-Tools/releases/download/v1.0.0/credential-tools-1.0.0.tmxplug",
"sha256": "413eff710b5c832a2575a890bbab1c90d28ea55f10cd7a7b94b851d51dd77dce", "sha256": "413eff710b5c832a2575a890bbab1c90d28ea55f10cd7a7b94b851d51dd77dce"
"tier": "standard",
"onboarding": {
"recommended": true,
"consent": false
}
},
{
"id": "databases",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Databases/releases/download/v1.1.1/databases-1.1.1.tmxplug",
"sha256": "316e26c682f5b07b0809e87f8ea94e2b819a9c46fc0e22611b24069c804312d0",
"tier": "full"
}, },
{ {
"id": "docker", "id": "docker",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.2.3/docker-1.2.3.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.1.0/docker-1.1.0.tmxplug",
"sha256": "c997d5d1a751703facef544e901fb52e93fb79d5dc1c59496d4cb624210bbe14", "sha256": "da32dfbe9d4cffec5d80c4a1037a79e7591a3d16c50f882db6a146322a50c2b7",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -94,9 +67,8 @@
{ {
"id": "file-manager", "id": "file-manager",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.3/file-manager-1.1.3.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.0/file-manager-1.1.0.tmxplug",
"sha256": "b17315011c06db2223f04dac4606da505249dd8f2fec8aa24f1322c4dfd4d6ca", "sha256": "807e9ffa80d6054a5911522721a555e0daa3e50f6edffdebe97efe7c91aea7a2",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -105,23 +77,20 @@
{ {
"id": "fleets", "id": "fleets",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.1.0/fleets-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.2/fleets-1.0.2.tmxplug",
"sha256": "5e0218c6145524ae8776b8fcbae043749c893d9b04fb67a9b3fe262ff76c0573", "sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59"
"tier": "full"
}, },
{ {
"id": "homepage", "id": "homepage",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.1.0/homepage-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.1.0/homepage-1.1.0.tmxplug",
"sha256": "5a9d5d2cc337fcee5590ea957e35ddeea5a71596aeb59410bf1420065b9b5b00", "sha256": "5a9d5d2cc337fcee5590ea957e35ddeea5a71596aeb59410bf1420065b9b5b00"
"tier": "full"
}, },
{ {
"id": "host-metrics", "id": "host-metrics",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.3.1/host-metrics-1.3.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.1.0/host-metrics-1.1.0.tmxplug",
"sha256": "3c14a72b3efa2a8b0010229acba64a6a4098e8c2e35d5a01cbc3cb731df39fb5", "sha256": "4f1d422ec1d0565c79f94fa814ee0864d556d7367793b11035e68239e49e297f",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -131,150 +100,93 @@
"id": "host-status", "id": "host-status",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Host-Status/releases/download/v1.0.0/host-status-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Host-Status/releases/download/v1.0.0/host-status-1.0.0.tmxplug",
"sha256": "1ba62577dc7373a2a2787bab7efdb89b544a30a8003b1de91ce51f372fc4f170", "sha256": "1ba62577dc7373a2a2787bab7efdb89b544a30a8003b1de91ce51f372fc4f170"
"tier": "standard",
"onboarding": {
"recommended": true,
"consent": false
}
}, },
{ {
"id": "hosts-config", "id": "hosts-config",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Hosts-Config/releases/download/v1.0.0/hosts-config-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Hosts-Config/releases/download/v1.0.0/hosts-config-1.0.0.tmxplug",
"sha256": "60a2b2a6d9ba13bad12a511c93af70a30a10fbc560066b6419acf533d806a3ad", "sha256": "60a2b2a6d9ba13bad12a511c93af70a30a10fbc560066b6419acf533d806a3ad"
"tier": "full"
}, },
{ {
"id": "import-export", "id": "import-export",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.1/import-export-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.0/import-export-1.0.0.tmxplug",
"sha256": "d8f2f865e1e6b226f4fe38501bf69b752f47a643bed1d9d127181083e27da2f0", "sha256": "6670b6c147e81eed0ddfd249f9a7c9564f1ab50e45f2771161d2e30c47f6fd58"
"tier": "standard",
"onboarding": {
"recommended": true,
"consent": false
}
},
{
"id": "kubernetes",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Kubernetes/releases/download/v1.0.1/kubernetes-1.0.1.tmxplug",
"sha256": "31d6b200579d326d07244712713ae291ff473270d44b478a884d07c863a394f0",
"tier": "full"
}, },
{ {
"id": "ldap", "id": "ldap",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.2/ldap-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.2/ldap-1.0.2.tmxplug",
"sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03", "sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03"
"tier": "full"
}, },
{ {
"id": "login-security", "id": "login-security",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Login-Security/releases/download/v1.0.0/login-security-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Login-Security/releases/download/v1.0.0/login-security-1.0.0.tmxplug",
"sha256": "b21dff5722e72a02022d563ef27130e8b56a53e273f1668fade4ec7d030374aa", "sha256": "b21dff5722e72a02022d563ef27130e8b56a53e273f1668fade4ec7d030374aa"
"tier": "full"
}, },
{ {
"id": "network-topology", "id": "network-topology",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.2/network-topology-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.2/network-topology-1.0.2.tmxplug",
"sha256": "8d32340a0d0f9674b872da5d3d53e90f529b80dfe0d73ab294eed00e565fbb56", "sha256": "8d32340a0d0f9674b872da5d3d53e90f529b80dfe0d73ab294eed00e565fbb56"
"tier": "full"
}, },
{ {
"id": "opkssh", "id": "opkssh",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.2/opkssh-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.2/opkssh-1.0.2.tmxplug",
"sha256": "4bb8f1ac5d39bd18ac3d0fe82ddbf2f26ab4c01bd75e6f7a073d65c2e9aef219", "sha256": "4bb8f1ac5d39bd18ac3d0fe82ddbf2f26ab4c01bd75e6f7a073d65c2e9aef219"
"tier": "full"
}, },
{ {
"id": "proxmox", "id": "proxmox",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.2.1/proxmox-1.2.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.2/proxmox-1.0.2.tmxplug",
"sha256": "d5a6e9cd50756785ede6aec351e2de08927816f71e54f3162c52331e0a804d74", "sha256": "b92be2644b0c8121732103b922069a6cb3cf00caad4e6549f7a8cb471bda5e4e"
"tier": "full"
}, },
{ {
"id": "remote-desktop", "id": "remote-desktop",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.3.1/remote-desktop-1.3.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.2.0/remote-desktop-1.2.0.tmxplug",
"sha256": "41483f8d64995206e0199cca7d37033e5b1f1194725da3525e1b8afa1b4e09a9", "sha256": "0de40896bfeb4b1a702c3ce815355b7b7b0bb4bfe424f33e905bb5a0209d02f6",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
} }
}, },
{
"id": "reverse-proxy",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Reverse-Proxy/releases/download/v1.1.1/reverse-proxy-1.1.1.tmxplug",
"sha256": "f5e0089ca5eae1254f21ce18b222e70eb2beeae3bd874c2abc4faa1a68d3e47a",
"tier": "full"
},
{
"id": "samba",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Samba/releases/download/v1.0.0/samba-1.0.0.tmxplug",
"sha256": "6e131ccdca998a9d0802becdd439a88305b64fcd7f07335bec9c7ed6791c0f53",
"tier": "full"
},
{ {
"id": "secret-sources", "id": "secret-sources",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.1.1/secret-sources-1.1.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.2/secret-sources-1.0.2.tmxplug",
"sha256": "2211ce2dac6355db49590532dfc339dc44df9cf14008157b420c7d4533d669ee", "sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134"
"tier": "full"
}, },
{ {
"id": "serial", "id": "serial",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.2/serial-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.2/serial-1.0.2.tmxplug",
"sha256": "6ede7e8726a7312429473f34a46f93ba6657c5b7ebc12bed18f80ec62c211715", "sha256": "6ede7e8726a7312429473f34a46f93ba6657c5b7ebc12bed18f80ec62c211715",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": "desktop", "recommended": "desktop",
"consent": false "consent": false
} }
}, },
{
"id": "service-manager",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Service-Manager/releases/download/v1.1.1/service-manager-1.1.1.tmxplug",
"sha256": "5731a04336d341a330416cba7b7999f0d8e6215752eabbf978ed58c4543c5036",
"tier": "full"
},
{ {
"id": "session-recording", "id": "session-recording",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.1.1/session-recording-1.1.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.2/session-recording-1.0.2.tmxplug",
"sha256": "8e77087f98227ac37a8610dcc5380ad65162a58aebd9174a6c966d5a16b16c9e", "sha256": "55ace6897710ce9a63f24e3d736a9e533cac557be0f3d98974956c94cf417d1c"
"tier": "full"
}, },
{ {
"id": "session-sharing", "id": "session-sharing",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.1.0/session-sharing-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.2/session-sharing-1.0.2.tmxplug",
"sha256": "2988b39221d93b3dbc5e2a81e104e5dc01ce264afb1e66df3407492658ab9fdc", "sha256": "6408e7949a9549ac5ec94b35205729099138e730df1598d50f4bb618d8d90804"
"tier": "full"
},
{
"id": "simple-layout",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Simple-Layout/releases/download/v1.0.1/simple-layout-1.0.1.tmxplug",
"sha256": "838f08050335b44b62295b87006b50d1db1029699bfe6a35fa75808d4aea2cde",
"tier": "full"
}, },
{ {
"id": "snippets", "id": "snippets",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.2.0/snippets-1.2.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.2/snippets-1.0.2.tmxplug",
"sha256": "a72c0e1f38e49cf86ed8993b6913a6fbbdad7814ae132d5186c097ee3ea09bc3", "sha256": "cc1f5d74cf97f9a9c8f800fccf481c8a000e6fa516bc260ce24a7249d4f4c8e5",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -283,9 +195,8 @@
{ {
"id": "ssh-terminal", "id": "ssh-terminal",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.3.0/ssh-terminal-1.3.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.1.0/ssh-terminal-1.1.0.tmxplug",
"sha256": "ad39449f1e60724a9f84451e1190982e5d3ecd17e1751ec09a3b21c228e6dd5e", "sha256": "dacdbadb3c6a0d7014b1de4f62b499f80abf358c7799a6e6326674660b055e4a",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -294,9 +205,8 @@
{ {
"id": "sso", "id": "sso",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.5/sso-1.0.5.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.3/sso-1.0.3.tmxplug",
"sha256": "c1353d17fd25c1f503528c2a64a31ef14a638d9845c1d6cd70bb540e0321d41c", "sha256": "87b03c0789d981dbc806162d73dfcc2c624150375277efd2ae40ab9d7657d48e",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": false, "recommended": false,
"consent": false, "consent": false,
@@ -307,33 +217,25 @@
"id": "step-ca", "id": "step-ca",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.2/step-ca-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.2/step-ca-1.0.2.tmxplug",
"sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a", "sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a"
"tier": "full"
}, },
{ {
"id": "sync", "id": "sync",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.1.0/sync-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.0.0/sync-1.0.0.tmxplug",
"sha256": "9023d547f0e0bcef582c6ba7b7a8ab2ef68d8d0b973dde17d9e8c84d92b6429e", "sha256": "12ced05523f7c1a9151b5e680726bd31f53888e8bb0047d7bd12a2570ea168bf"
"tier": "standard",
"onboarding": {
"recommended": "desktop",
"consent": false
}
}, },
{ {
"id": "tailscale", "id": "tailscale",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.1.0/tailscale-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.2/tailscale-1.0.2.tmxplug",
"sha256": "8784a77a1fc1b0011d8243d9520574300df31bc40c31f86339868f4c9b76d1bf", "sha256": "5c7b00d6b6b0186d833302e39a9d31b92ea92d4542e9e2dab550b95837a59d97"
"tier": "full"
}, },
{ {
"id": "telemetry", "id": "telemetry",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.2/telemetry-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.2/telemetry-1.0.2.tmxplug",
"sha256": "5ffdc58b05cd8b37fd0a207f99d2d3a256164130c950032501c499f869edb9ec", "sha256": "5ffdc58b05cd8b37fd0a207f99d2d3a256164130c950032501c499f869edb9ec",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": true "consent": true
@@ -343,22 +245,19 @@
"id": "termix-identity", "id": "termix-identity",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.2/termix-identity-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.2/termix-identity-1.0.2.tmxplug",
"sha256": "ac6c90a48b4f1e9cff6773a5fd267b2bf7319d2fc7e2aa2cd4004cec82f0f927", "sha256": "ac6c90a48b4f1e9cff6773a5fd267b2bf7319d2fc7e2aa2cd4004cec82f0f927"
"tier": "full"
}, },
{ {
"id": "tmux-monitor", "id": "tmux-monitor",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.1.0/tmux-monitor-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.4/tmux-monitor-1.0.4.tmxplug",
"sha256": "3cdc54a34e1ca30c4e3c217bbc612e40ef3cd3e4d5de704ea92d914b32d98006", "sha256": "42b8a2284e68a321c1736984f5c641b1857c87c6a65ce8cf694c6bc946de2444"
"tier": "full"
}, },
{ {
"id": "totp", "id": "totp",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.2/totp-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.2/totp-1.0.2.tmxplug",
"sha256": "94a0772660fa8e4dfd9afc15b68d5bc9fe02fe28a1acfd35e65a57c304d460c0", "sha256": "94a0772660fa8e4dfd9afc15b68d5bc9fe02fe28a1acfd35e65a57c304d460c0",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -367,9 +266,8 @@
{ {
"id": "tunnels", "id": "tunnels",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.2.3/tunnels-1.2.3.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.1.0/tunnels-1.1.0.tmxplug",
"sha256": "5a23953ee19b2951bb895e033d2099b8f6d5abc0121253ef3da25328134984ad", "sha256": "b1e4f1b6dd2bbd15a9a240ae1c1d381111c3609258c8946d19493a83f861ea1e",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -379,36 +277,31 @@
"id": "vault", "id": "vault",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.2/vault-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.2/vault-1.0.2.tmxplug",
"sha256": "48ef402d030912a836489edf0fdfcaf7929cbd67c21f16a2079cb4925134cc28", "sha256": "48ef402d030912a836489edf0fdfcaf7929cbd67c21f16a2079cb4925134cc28"
"tier": "full"
}, },
{ {
"id": "wake-on-lan", "id": "wake-on-lan",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.1.0/wake-on-lan-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.2/wake-on-lan-1.0.2.tmxplug",
"sha256": "1afb708c12631ededec40938aed3ab587bc9a519215fa5e93350b1f0a843d2a8", "sha256": "3e9cf9a133b4dd96ec5f2b9fbf2d6a3ff7e08f8dfbf1e86b8d45de0c2f024f42"
"tier": "full"
}, },
{ {
"id": "warpgate", "id": "warpgate",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.2/warpgate-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.2/warpgate-1.0.2.tmxplug",
"sha256": "5c2c1c9aef40dbbf98424d02303f2ee77e6e869334247881e8aa0fcd8c415b47", "sha256": "5c2c1c9aef40dbbf98424d02303f2ee77e6e869334247881e8aa0fcd8c415b47"
"tier": "full"
}, },
{ {
"id": "web-endpoint", "id": "web-endpoint",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.1.0/web-endpoint-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.3/web-endpoint-1.0.3.tmxplug",
"sha256": "79e691abc456b17c8ce651017b47097a7ff71e94267e668341548a05b911a92c", "sha256": "014cbf23c82cab20e485bc4f4c9b8baa779aee9bb3ed644b41f22a9b1be95687"
"tier": "full"
}, },
{ {
"id": "webauthn", "id": "webauthn",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.2.0/webauthn-1.2.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.1.0/webauthn-1.1.0.tmxplug",
"sha256": "4e7e06d05a17b6e474fdb0d45c8fc320f2ea89375bda440f824636cc393d4615", "sha256": "83a61a530db0273f9fef2080d6aa027a82ef8374fb4e0f158a8559a2770299de",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -417,9 +310,8 @@
{ {
"id": "workspaces", "id": "workspaces",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.1.0/workspaces-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.2/workspaces-1.0.2.tmxplug",
"sha256": "7189d767c3f7cddfb59366f6cceeca27a0b29e41da1dcc89d57a25317a0fe4bf", "sha256": "97519a01fbb2eb2eecf21d7b9ab9028c740457bcff9fc6b075fb6facbc5ccb6b",
"tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
-9
View File
@@ -276,15 +276,6 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
} }
location ~ ^/operations(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/plugins(/.*)?$ { location ~ ^/plugins(/.*)?$ {
proxy_pass http://127.0.0.1:30001; proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1; proxy_http_version 1.1;
-9
View File
@@ -245,15 +245,6 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
} }
location ~ ^/operations(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/plugins(/.*)?$ { location ~ ^/plugins(/.*)?$ {
proxy_pass http://127.0.0.1:30001; proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1; proxy_http_version 1.1;
-2
View File
@@ -1,2 +0,0 @@
ALTER TABLE `ssh_data` ADD `color` text;--> statement-breakpoint
ALTER TABLE `ssh_data` ADD `icon` text;
@@ -1,14 +0,0 @@
CREATE TABLE `shared_folder_auth_overrides` (
`id` int AUTO_INCREMENT NOT NULL,
`user_id` varchar(255) NOT NULL,
`folder` varchar(255) NOT NULL,
`protocol` varchar(255) NOT NULL DEFAULT 'ssh',
`credential_id` int NOT NULL,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT `shared_folder_auth_overrides_id` PRIMARY KEY(`id`),
CONSTRAINT `shared_folder_auth_overrides_user_folder_protocol_unique` UNIQUE(`user_id`,`folder`,`protocol`)
);
--> statement-breakpoint
ALTER TABLE `shared_folder_auth_overrides` ADD CONSTRAINT `shared_folder_auth_overrides_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `shared_folder_auth_overrides` ADD CONSTRAINT `shared_folder_auth_overrides_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE cascade ON UPDATE no action;
-70
View File
@@ -1,70 +0,0 @@
CREATE TABLE `operation_approvals` (
`id` varchar(255) NOT NULL,
`run_id` varchar(255) NOT NULL,
`requested_by` text,
`reason` text,
`allow_self` boolean NOT NULL DEFAULT false,
`status` varchar(255) NOT NULL,
`decided_by` text,
`comment` text,
`created_at` text NOT NULL,
`expires_at` varchar(255) NOT NULL,
`decided_at` text,
CONSTRAINT `operation_approvals_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `operation_events` (
`id` varchar(255) NOT NULL,
`event_id` text NOT NULL,
`plugin_id` text NOT NULL,
`host_id` int,
`severity` text,
`payload` text,
`run_id` text,
`created_at` varchar(255) NOT NULL,
CONSTRAINT `operation_events_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `operation_runs` (
`id` varchar(255) NOT NULL,
`action_id` text NOT NULL,
`action_version` int NOT NULL DEFAULT 1,
`plugin_id` text NOT NULL,
`plugin_version` text NOT NULL,
`user_id` varchar(255),
`username` text NOT NULL,
`origin` text NOT NULL,
`host_id` int,
`host_name` text,
`batch_id` varchar(255),
`dry_run` boolean NOT NULL DEFAULT false,
`risk` text NOT NULL,
`status` varchar(255) NOT NULL,
`summary` text,
`changed` boolean,
`input` text,
`sealed_input` text,
`output` text,
`undo_input` text,
`error` text,
`error_code` text,
`undo_of` text,
`idempotency_key` text,
`created_at` varchar(255) NOT NULL,
`started_at` text,
`finished_at` text,
CONSTRAINT `operation_runs_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
ALTER TABLE `api_keys` ADD `scopes` text;--> statement-breakpoint
ALTER TABLE `operation_approvals` ADD CONSTRAINT `operation_approvals_run_id_operation_runs_id_fk` FOREIGN KEY (`run_id`) REFERENCES `operation_runs`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `operation_runs` ADD CONSTRAINT `operation_runs_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `idx_operation_approvals_status` ON `operation_approvals` (`status`,`expires_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_approvals_run` ON `operation_approvals` (`run_id`);--> statement-breakpoint
CREATE INDEX `idx_operation_events_created` ON `operation_events` (`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_events_host_created` ON `operation_events` (`host_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_created` ON `operation_runs` (`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_user_created` ON `operation_runs` (`user_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_host_created` ON `operation_runs` (`host_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_status` ON `operation_runs` (`status`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_batch` ON `operation_runs` (`batch_id`);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-21
View File
@@ -533,27 +533,6 @@
"when": 1792430772041, "when": 1792430772041,
"tag": "0075_session_policy", "tag": "0075_session_policy",
"breakpoints": true "breakpoints": true
},
{
"idx": 76,
"version": "5",
"when": 1792945361809,
"tag": "0076_host_color_icon",
"breakpoints": true
},
{
"idx": 77,
"version": "5",
"when": 1793304929772,
"tag": "0077_shared_folder_auth_overrides",
"breakpoints": true
},
{
"idx": 78,
"version": "5",
"when": 1793765383557,
"tag": "0078_sweet_king_cobra",
"breakpoints": true
} }
] ]
} }
@@ -1,2 +0,0 @@
ALTER TABLE "ssh_data" ADD COLUMN "color" text;--> statement-breakpoint
ALTER TABLE "ssh_data" ADD COLUMN "icon" text;
@@ -1,13 +0,0 @@
CREATE TABLE "shared_folder_auth_overrides" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" varchar(255) NOT NULL,
"folder" varchar(255) NOT NULL,
"protocol" varchar(255) DEFAULT 'ssh' NOT NULL,
"credential_id" integer NOT NULL,
"created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
ALTER TABLE "shared_folder_auth_overrides" ADD CONSTRAINT "shared_folder_auth_overrides_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shared_folder_auth_overrides" ADD CONSTRAINT "shared_folder_auth_overrides_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "shared_folder_auth_overrides_user_folder_protocol_unique" ON "shared_folder_auth_overrides" USING btree ("user_id","folder","protocol");
@@ -1,67 +0,0 @@
CREATE TABLE "operation_approvals" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"run_id" varchar(255) NOT NULL,
"requested_by" text,
"reason" text,
"allow_self" boolean DEFAULT false NOT NULL,
"status" varchar(255) NOT NULL,
"decided_by" text,
"comment" text,
"created_at" text NOT NULL,
"expires_at" varchar(255) NOT NULL,
"decided_at" text
);
--> statement-breakpoint
CREATE TABLE "operation_events" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"event_id" text NOT NULL,
"plugin_id" text NOT NULL,
"host_id" integer,
"severity" text,
"payload" text,
"run_id" text,
"created_at" varchar(255) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "operation_runs" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"action_id" text NOT NULL,
"action_version" integer DEFAULT 1 NOT NULL,
"plugin_id" text NOT NULL,
"plugin_version" text NOT NULL,
"user_id" varchar(255),
"username" text NOT NULL,
"origin" text NOT NULL,
"host_id" integer,
"host_name" text,
"batch_id" varchar(255),
"dry_run" boolean DEFAULT false NOT NULL,
"risk" text NOT NULL,
"status" varchar(255) NOT NULL,
"summary" text,
"changed" boolean,
"input" text,
"sealed_input" text,
"output" text,
"undo_input" text,
"error" text,
"error_code" text,
"undo_of" text,
"idempotency_key" text,
"created_at" varchar(255) NOT NULL,
"started_at" text,
"finished_at" text
);
--> statement-breakpoint
ALTER TABLE "api_keys" ADD COLUMN "scopes" text;--> statement-breakpoint
ALTER TABLE "operation_approvals" ADD CONSTRAINT "operation_approvals_run_id_operation_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."operation_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "operation_runs" ADD CONSTRAINT "operation_runs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_operation_approvals_status" ON "operation_approvals" USING btree ("status","expires_at");--> statement-breakpoint
CREATE INDEX "idx_operation_approvals_run" ON "operation_approvals" USING btree ("run_id");--> statement-breakpoint
CREATE INDEX "idx_operation_events_created" ON "operation_events" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "idx_operation_events_host_created" ON "operation_events" USING btree ("host_id","created_at");--> statement-breakpoint
CREATE INDEX "idx_operation_runs_created" ON "operation_runs" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "idx_operation_runs_user_created" ON "operation_runs" USING btree ("user_id","created_at");--> statement-breakpoint
CREATE INDEX "idx_operation_runs_host_created" ON "operation_runs" USING btree ("host_id","created_at");--> statement-breakpoint
CREATE INDEX "idx_operation_runs_status" ON "operation_runs" USING btree ("status");--> statement-breakpoint
CREATE INDEX "idx_operation_runs_batch" ON "operation_runs" USING btree ("batch_id");
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-21
View File
@@ -533,27 +533,6 @@
"when": 1792430770990, "when": 1792430770990,
"tag": "0075_session_policy", "tag": "0075_session_policy",
"breakpoints": true "breakpoints": true
},
{
"idx": 76,
"version": "7",
"when": 1792945360507,
"tag": "0076_host_color_icon",
"breakpoints": true
},
{
"idx": 77,
"version": "7",
"when": 1793304928399,
"tag": "0077_shared_folder_auth_overrides",
"breakpoints": true
},
{
"idx": 78,
"version": "7",
"when": 1793765382955,
"tag": "0078_clean_living_mummy",
"breakpoints": true
} }
] ]
} }
-2
View File
@@ -1,2 +0,0 @@
ALTER TABLE `ssh_data` ADD `color` text;--> statement-breakpoint
ALTER TABLE `ssh_data` ADD `icon` text;
@@ -1,13 +0,0 @@
CREATE TABLE `shared_folder_auth_overrides` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` text NOT NULL,
`folder` text NOT NULL,
`protocol` text DEFAULT 'ssh' NOT NULL,
`credential_id` integer NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `shared_folder_auth_overrides_user_folder_protocol_unique` ON `shared_folder_auth_overrides` (`user_id`,`folder`,`protocol`);
@@ -1,67 +0,0 @@
CREATE TABLE `operation_approvals` (
`id` text PRIMARY KEY NOT NULL,
`run_id` text NOT NULL,
`requested_by` text,
`reason` text,
`allow_self` integer DEFAULT false NOT NULL,
`status` text NOT NULL,
`decided_by` text,
`comment` text,
`created_at` text NOT NULL,
`expires_at` text NOT NULL,
`decided_at` text,
FOREIGN KEY (`run_id`) REFERENCES `operation_runs`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_operation_approvals_status` ON `operation_approvals` (`status`,`expires_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_approvals_run` ON `operation_approvals` (`run_id`);--> statement-breakpoint
CREATE TABLE `operation_events` (
`id` text PRIMARY KEY NOT NULL,
`event_id` text NOT NULL,
`plugin_id` text NOT NULL,
`host_id` integer,
`severity` text,
`payload` text,
`run_id` text,
`created_at` text NOT NULL
);
--> statement-breakpoint
CREATE INDEX `idx_operation_events_created` ON `operation_events` (`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_events_host_created` ON `operation_events` (`host_id`,`created_at`);--> statement-breakpoint
CREATE TABLE `operation_runs` (
`id` text PRIMARY KEY NOT NULL,
`action_id` text NOT NULL,
`action_version` integer DEFAULT 1 NOT NULL,
`plugin_id` text NOT NULL,
`plugin_version` text NOT NULL,
`user_id` text,
`username` text NOT NULL,
`origin` text NOT NULL,
`host_id` integer,
`host_name` text,
`batch_id` text,
`dry_run` integer DEFAULT false NOT NULL,
`risk` text NOT NULL,
`status` text NOT NULL,
`summary` text,
`changed` integer,
`input` text,
`sealed_input` text,
`output` text,
`undo_input` text,
`error` text,
`error_code` text,
`undo_of` text,
`idempotency_key` text,
`created_at` text NOT NULL,
`started_at` text,
`finished_at` text,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `idx_operation_runs_created` ON `operation_runs` (`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_user_created` ON `operation_runs` (`user_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_host_created` ON `operation_runs` (`host_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_status` ON `operation_runs` (`status`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_batch` ON `operation_runs` (`batch_id`);--> statement-breakpoint
ALTER TABLE `api_keys` ADD `scopes` text;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-21
View File
@@ -498,27 +498,6 @@
"when": 1792430769899, "when": 1792430769899,
"tag": "0070_session_policy", "tag": "0070_session_policy",
"breakpoints": true "breakpoints": true
},
{
"idx": 71,
"version": "6",
"when": 1792945434651,
"tag": "0071_host_color_icon",
"breakpoints": true
},
{
"idx": 72,
"version": "6",
"when": 1793304926942,
"tag": "0072_shared_folder_auth_overrides",
"breakpoints": true
},
{
"idx": 73,
"version": "6",
"when": 1793765382283,
"tag": "0073_dazzling_amazoness",
"breakpoints": true
} }
] ]
} }
-6
View File
@@ -128,10 +128,6 @@
"category": "public.app-category.developer-tools", "category": "public.app-category.developer-tools",
"hardenedRuntime": true, "hardenedRuntime": true,
"gatekeeperAssess": false, "gatekeeperAssess": false,
"extendInfo": {
"NSLocalNetworkUsageDescription": "Termix connects to servers and hosts on your local network.",
"NSBonjourServices": ["_http._tcp", "_https._tcp", "_ssh._tcp"]
},
"entitlements": "packaging/build/entitlements.mac.plist", "entitlements": "packaging/build/entitlements.mac.plist",
"entitlementsInherit": "packaging/build/entitlements.mac.inherit.plist", "entitlementsInherit": "packaging/build/entitlements.mac.inherit.plist",
"type": "distribution", "type": "distribution",
@@ -157,8 +153,6 @@
"artifactName": "termix_macos_${arch}_mas.${ext}", "artifactName": "termix_macos_${arch}_mas.${ext}",
"extendInfo": { "extendInfo": {
"ITSAppUsesNonExemptEncryption": false, "ITSAppUsesNonExemptEncryption": false,
"NSLocalNetworkUsageDescription": "Termix connects to servers and hosts on your local network.",
"NSBonjourServices": ["_http._tcp", "_https._tcp", "_ssh._tcp"],
"NSAppleEventsUsageDescription": "Termix needs access to control other applications for terminal operations." "NSAppleEventsUsageDescription": "Termix needs access to control other applications for terminal operations."
} }
}, },
+8 -16
View File
@@ -1235,14 +1235,19 @@ function createTray() {
// use the unpacked path so the OS sees a real file. // use the unpacked path so the OS sees a real file.
const publicRoot = isDev const publicRoot = isDev
? path.join(appRoot, "public") ? path.join(appRoot, "public")
: path.join(getUnpackedAppRoot(appRoot), "public"); : path.join(
appRoot.replace(
/app(-[a-z0-9]+)?\.asar(?!\.unpacked)/,
"app.asar.unpacked",
),
"public",
);
let trayIcon; let trayIcon;
if (process.platform === "darwin") { if (process.platform === "darwin") {
const iconPath = path.join(publicRoot, "icons", "16x16.png"); const iconPath = path.join(publicRoot, "icons", "16x16.png");
// The logo is full color on a solid tile. As a template image macOS
// keeps only its outline, which showed as an empty square.
trayIcon = nativeImage.createFromPath(iconPath); trayIcon = nativeImage.createFromPath(iconPath);
trayIcon.setTemplateImage(true);
} else if (process.platform === "win32") { } else if (process.platform === "win32") {
trayIcon = path.join(publicRoot, "icon.ico"); trayIcon = path.join(publicRoot, "icon.ico");
} else { } else {
@@ -2021,19 +2026,6 @@ function createMenu() {
{ type: "separator" }, { type: "separator" },
{ role: "front" }, { role: "front" },
{ type: "separator" }, { type: "separator" },
{
label: app.name,
accelerator: "CmdOrCtrl+0",
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
} else {
createWindow();
}
},
},
{ role: "window" }, { role: "window" },
], ],
}, },
+1 -36
View File
@@ -83,30 +83,6 @@
"default": "8443", "default": "8443",
"description": "Port for HTTPS." "description": "Port for HTTPS."
}, },
{
"name": "TERMIX_NGINX_TEMPLATE",
"group": "https",
"default": "/app/nginx/nginx-https.conf.template",
"description": "The HTTPS nginx template Termix renders when a certificate is added. Set it on an install outside Docker."
},
{
"name": "TERMIX_NGINX_CONF",
"group": "https",
"default": "/tmp/nginx/nginx.conf",
"description": "Where Termix writes the rendered nginx config before it reloads nginx."
},
{
"name": "TERMIX_NGINX_PID",
"group": "https",
"default": "/tmp/nginx/nginx.pid",
"description": "The nginx pid file. Termix only reloads nginx when it exists."
},
{
"name": "TERMIX_APP_DIR",
"group": "https",
"default": "/app",
"description": "Where Termix is installed, used in place of /app in the nginx template."
},
{ {
"name": "SSL_CERT_PATH", "name": "SSL_CERT_PATH",
"group": "https", "group": "https",
@@ -322,7 +298,7 @@
{ {
"name": "HTTP_PROXY", "name": "HTTP_PROXY",
"group": "proxy", "group": "proxy",
"description": "Proxy for outbound HTTP requests, like plugin registry downloads and webhooks." "description": "Proxy for outbound HTTP requests, like registry downloads and webhooks."
}, },
{ {
"name": "HTTPS_PROXY", "name": "HTTPS_PROXY",
@@ -377,11 +353,6 @@
"group": "plugins", "group": "plugins",
"description": "Where plugin download counts are read from. Defaults to stats.json next to the registry index." "description": "Where plugin download counts are read from. Defaults to stats.json next to the registry index."
}, },
{
"name": "TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS",
"group": "plugins",
"description": "Comma-separated host names or IP addresses that plugin registry downloads may reach on a private address, like a registry in your LAN. List bare hosts: entries with a scheme, port or path are ignored. Matched by name only, on any port, so only list names your own DNS answers for. With HTTPS_PROXY set, add them to NO_PROXY too."
},
{ {
"name": "TERMIX_REQUIRE_SIGNED_PLUGINS", "name": "TERMIX_REQUIRE_SIGNED_PLUGINS",
"group": "plugins", "group": "plugins",
@@ -394,12 +365,6 @@
"default": "false", "default": "false",
"description": "Start everyone in the layout repair view instead of the chosen layout. Use it when a layout plugin breaks the app." "description": "Start everyone in the layout repair view instead of the chosen layout. Use it when a layout plugin breaks the app."
}, },
{
"name": "TERMIX_SAFE_MODE",
"group": "plugins",
"default": "false",
"description": "Start only layout and theme plugins. Every other plugin stays off until a restart without it, so you can turn off a plugin that breaks the server or the app."
},
{ {
"name": "TERMIX_BUNDLED_PLUGINS_DIR", "name": "TERMIX_BUNDLED_PLUGINS_DIR",
"group": "plugins", "group": "plugins",
+10 -10
View File
@@ -1,12 +1,12 @@
{ {
"name": "termix", "name": "termix",
"version": "26.11.1", "version": "26.10.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "termix", "name": "termix",
"version": "26.11.1", "version": "26.10.5",
"hasInstallScript": true, "hasInstallScript": true,
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
@@ -19,7 +19,7 @@
"axios": "^1.19.0", "axios": "^1.19.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
"body-parser": "^2.4.0", "body-parser": "^2.3.0",
"chalk": "^6.0.0", "chalk": "^6.0.0",
"compression": "^1.8.2", "compression": "^1.8.2",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
@@ -7470,18 +7470,18 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.4.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bytes": "^3.1.2", "bytes": "^3.1.2",
"content-type": "^2.1.0", "content-type": "^2.0.0",
"debug": "^4.4.3", "debug": "^4.4.3",
"http-errors": "^2.0.1", "http-errors": "^2.0.1",
"iconv-lite": "^0.7.3", "iconv-lite": "^0.7.2",
"on-finished": "^2.4.1", "on-finished": "^2.4.1",
"qs": "^6.16.1", "qs": "^6.15.2",
"raw-body": "^3.0.2", "raw-body": "^3.0.2",
"type-is": "^2.1.0" "type-is": "^2.1.0"
}, },
@@ -15058,7 +15058,7 @@
}, },
"packages/plugin-sdk": { "packages/plugin-sdk": {
"name": "@termix-ssh/plugin-sdk", "name": "@termix-ssh/plugin-sdk",
"version": "1.0.22", "version": "1.0.13",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@deadendjs/swagger-jsdoc": "^8.1.2", "@deadendjs/swagger-jsdoc": "^8.1.2",
+3 -4
View File
@@ -4,7 +4,7 @@
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
"version": "26.11.1", "version": "26.10.5",
"description": "Self-hosted, plugin-based server management.", "description": "Self-hosted, plugin-based server management.",
"author": "Karmaa", "author": "Karmaa",
"main": "electron/main.cjs", "main": "electron/main.cjs",
@@ -31,8 +31,7 @@
"dev:docker": "node scripts/dev.mjs docker", "dev:docker": "node scripts/dev.mjs docker",
"dev:frontend": "vite", "dev:frontend": "vite",
"build": "npm run build:sdk && vite build && node scripts/stamp-sw-build-info.cjs && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins", "build": "npm run build:sdk && vite build && 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:core": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\"", "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",
"build:backend": "npm run build:backend:core && npm run build:plugins",
"generate:openapi": "npm run build:sdk && node scripts/generate-openapi.mjs", "generate:openapi": "npm run build:sdk && node scripts/generate-openapi.mjs",
"electron:rebuild": "electron-rebuild -f -o better-sqlite3,@serialport/bindings-cpp,node-pty", "electron:rebuild": "electron-rebuild -f -o better-sqlite3,@serialport/bindings-cpp,node-pty",
"build:win-portable": "npm run build && npm run electron:rebuild && electron-builder --win --dir", "build:win-portable": "npm run build && npm run electron:rebuild && electron-builder --win --dir",
@@ -55,7 +54,7 @@
"axios": "^1.19.0", "axios": "^1.19.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
"body-parser": "^2.4.0", "body-parser": "^2.3.0",
"chalk": "^6.0.0", "chalk": "^6.0.0",
"compression": "^1.8.2", "compression": "^1.8.2",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
-58
View File
@@ -1,63 +1,5 @@
# Changelog # Changelog
## 1.0.25
Plugin API 1.14.0.
### Added
- Host click behavior `hostPage` opens a host page when a plugin offers one
- `createApiKey` takes scopes, to limit a key to actions
## 1.0.24
Plugin API 1.14.0.
### Added
- `app.registerHostSection` adds a card to the host page
- `/ui` exports `HostTabEmbed`, which draws any tab type inside another view, the host section registry, and `operationsApi` for core's `/operations` routes
## 1.0.23
Plugin API 1.14.0.
### Added
- `contributes.operations` declares a plugin's actions, events, resources and signals, so Automations, Fleets, the assistant, the CLI, the phone app and the command palette can find them with no wiring
- `ctx.operations` registers handlers, runs other plugins' declared actions, emits events and provides approval rules, with the new `operations:provide`, `operations:invoke` and `operations:approve` capabilities
- `@termix-ssh/plugin-sdk/operations` has the types, input checks, redaction and JSON Schema for declared actions
- `contributes.hostSections` declares cards for the host page
## 1.0.22
Plugin API 1.13.0.
### Added
- `contributes.mobile` in the manifest says how a plugin shows in the Termix mobile app: `webview`, `native` or `none`, with an optional mobile bundle, screen sizes, views and a note
- `termix-plugin build` bundles `src/mobile` into the `contributes.mobile.entry` file, and `validate` checks that it exists
- `@termix-ssh/plugin-sdk/frame-host` runs one plugin view outside the shell, with core's bridge, registries and CSS, so the mobile app can show `webview` plugins
- SSH transport providers get the host's owner, sync id, connection origin and local only flag in `claims` and `open`, and core's SSH relay takes a `syncId` instead of a host id
- `ctx.sync.host.entities.idsOf` maps sync ids to the local ids of the user's own rows of one type
- Rail items take `useStatus` for a small ok, warning or error dot on the icon
- `shell.editHost(hostId)` opens the editor of a saved host
- A sync entity can be read only with `load`, `write` and `erase`, so a plugin can sync rows a user does not own, like a shared library, down to linked desktops
- `getFolderAuthOverride` and `setFolderAuthOverride` in `@termix-ssh/plugin-sdk/ui` read and set a user's own login for the shared hosts in a folder
### Fixed
- `@termix-ssh/plugin-sdk/ui` is built in production mode, so outside Termix it no longer sends requests to the Vite dev server path
- `execCommand` buffers output and decodes it once as strict UTF-8 with a GBK fallback, exposed as `decodeCommandOutput`, so messages from a Windows host on a legacy codepage are readable instead of � characters
## 1.0.21
Plugin API 1.12.0.
### Added
- `@termix-ssh/plugin-sdk/host-scripts`: Docker, Linux metrics, systemd, journal and OpenRC command builders and parsers, plus `scriptShell()` and `passwordSudoScript()` to send scripts to `bash -s` on stdin. It has no Node or ssh2 imports, so the Termix mobile app runs the same code
## 1.0.20 ## 1.0.20
Plugin API 1.12.0. Plugin API 1.12.0.
+4 -134
View File
@@ -1,6 +1,6 @@
# Termix plugin guide # Termix plugin guide
SDK 1.0.25 | Plugin API 1.14.0 SDK 1.0.20 | Plugin API 1.12.0
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside 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. `@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
@@ -175,49 +175,7 @@ Unknown fields are refused at every level, so a typo fails validation. The schem
`contributes` holds `tabs`, `panels`, `dashboardCards`, `actions`, `actionSlots`, `permissions`, `contributes` holds `tabs`, `panels`, `dashboardCards`, `actions`, `actionSlots`, `permissions`,
`settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`, `settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`,
`uiPresets`, `uses`, `layouts`, `themes` and `mobile`. Frontend `register*` calls need their ids declared here first. `uiPresets`, `uses`, `layouts` and `themes`. Frontend `register*` calls need their ids declared here first.
### Mobile app
`contributes.mobile` says how the plugin shows in the Termix mobile app. Leave it out and the app treats the
plugin as having nothing to show (official plugins are mapped by the app itself).
```json
"mobile": { "mode": "webview", "views": ["hello"], "sizes": ["phone", "tablet"] }
```
| Field | Notes |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mode` | `webview` (the app shows your UI from the server), `native` (the app has its own screens) or `none`. |
| `entry` | `webview` only. A bundle made for the app, like `dist/mobile.js`, next to `frontend.js`. Unset: your normal frontend runs in a phone sized frame. |
| `sizes` | `webview` only. `phone`, `tablet` or both (the default). |
| `views` | `webview` only. Ids from your `tabs`, `panels` or `dashboardCards` the app offers. Default: every tab. |
| `feature` | `native` only. The app feature that stands in for the plugin. Default: the plugin id. |
| `note` | A locale key or plain text the app shows next to the plugin. |
`build` makes `entry` from `src/mobile/index.tsx`, and it shares `frontend.css`.
How a `webview` plugin runs in the app:
- Only your plugin loads, with the same app object, SDK hooks and `/ui` kit as the web app. Each view opens
on its own screen: a tab with `host-context-menu` in `openFrom` opens from a host, the rest from the app's
plugin list.
- Every request and socket goes through the app, which adds the login, proxy headers and client
certificate. The app only lets through your own `/plugin-api`, `/plugin-ws` and `/plugin-assets`, your
settings and a short list of core reads (hosts, folders, permissions, current user, version, health,
activity). Other core routes answer 403, so call your own backend instead.
- Images load straight from the server, without proxy headers or a client certificate.
- Nothing in `localStorage` survives closing the view. Keep preferences in settings.
- Other plugins' views, extensions and components are not there, and neither is the shell (`useShell()`
is null). `/ui` names newer than the app's copy of the SDK make the view say it needs a newer app.
- The app checks your `engine.api` against the plugin API of its own copy of the SDK. When the app's API
is too old for the range, it asks the user to update the app instead of opening the view. When the range
is for an older API major, it asks to update the plugin. Ask only for the API you use.
- Turning a plugin off on the server hides it in the app the next time the app checks: when it comes back
to the front, when the list is pulled down, or when any request answers 503 with your `pluginId`.
Termix 26.10.5 and older refuse any manifest with `mobile`, so a plugin that declares it needs
`"engine": { "termix": ">=26.10.6", "api": "^1.13" }`.
Capabilities and permissions are different things. A capability (`hosts:read`, with a colon) is what your 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. code may reach. A permission (`hello.use`, with a dot) is what a user may do.
@@ -346,7 +304,7 @@ router.post("/notes", async (req, res) => {
- `ctx.rbac.require("manage")` on a single route, `await ctx.rbac.has("manage")` in code. A full id like - `ctx.rbac.require("manage")` on a single route, `await ctx.rbac.has("manage")` in code. A full id like
`hosts.edit` checks a core permission. `hosts.edit` checks a core permission.
- Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`. - Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`.
- Transports: with `ssh:transport`, `ctx.ssh.transports.provide({ claims(hostId, host), open(host) })`, where `host` has `id`, `ip`, `port` and on Termix 26.10.6+ also `userId`, `syncId`, `connectionOrigin` and `localOnly` - Transports: with `ssh:transport`, `ctx.ssh.transports.provide({ claims(hostId), open({ id, ip, port }) })`
returns a stream the SSH client uses instead of a direct, proxy or jump host connection. Core also serves returns a stream the SSH client uses instead of a direct, proxy or jump host connection. Core also serves
`/plugin-ws/core/ssh-relay?hostId=` for callers with connect access, so one Termix can relay to another. `/plugin-ws/core/ssh-relay?hostId=` for callers with connect access, so one Termix can relay to another.
- Managed hosts: with `hosts:lock`, `ctx.hosts.locks.provide({ isLocked, note, allowManualCreate })` makes - Managed hosts: with `hosts:lock`, `ctx.hosts.locks.provide({ isLocked, note, allowManualCreate })` makes
@@ -377,9 +335,7 @@ try {
`ctx.ssh.withConnection(hostId, { pool, purpose }, fn)` borrows a pooled connection for frequent short `ctx.ssh.withConnection(hostId, { pool, purpose }, fn)` borrows a pooled connection for frequent short
commands. `@termix-ssh/plugin-sdk/host-commands` has helpers to detect the OS and run package manager and commands. `@termix-ssh/plugin-sdk/host-commands` has helpers to detect the OS and run package manager and
sudo commands. `@termix-ssh/plugin-sdk/host-scripts` builds Docker, metrics, systemd and journal sudo commands. Hosts from `ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`.
commands and parses their output; send a script on stdin to the command `scriptShell()` returns. Hosts from
`ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`.
### The desktop app ### The desktop app
@@ -414,70 +370,6 @@ ctx.schedule.every(60_000, () => refresh(), { jitterMs: 5_000 });
- Use a service: declare it in `requires`, then `ctx.services.get<T>("snippets.access")`. It returns - Use a service: declare it in `requires`, then `ctx.services.get<T>("snippets.access")`. It returns
nothing while no provider runs, so always handle that. nothing while no provider runs, so always handle that.
### Operations
Declare what your plugin can do in `contributes.operations`, and it shows up in Automations, Fleets, the
assistant, the CLI, the phone app and the command palette with no other wiring. It also gets per host results,
dry runs, run history, approvals and drift checks.
```json
"capabilities": ["operations:provide"],
"engine": { "termix": ">=26.11.1-0", "api": "^1.13" },
"contributes": {
"operations": {
"actions": [{
"id": "demo.restart",
"titleKey": "ops.restart.title",
"scope": "host",
"inputs": [{ "key": "name", "type": "string", "labelKey": "ops.restart.name", "required": true }],
"risk": "change",
"permission": "use",
"hostAccess": "edit",
"dryRun": "describe"
}]
}
}
```
```ts
ctx.operations.register("demo.restart", {
describe: (input) => `Would restart ${input.name}`,
run: async (input, { hostId, signal, log }) => {
return { summary: `Restarted ${input.name}`, changed: true };
},
});
```
- Actions: `scope` is `host`, `resource` or `server`. `risk` is `read`, `change` or `destructive`. Host actions declare
`hostAccess`. Use `edit` for changes unless people with `connect` already do this today. A destructive action
must use `edit` or `manage` and needs a confirm.
- Inputs use the settings field types, plus `required`, `pattern`, `optionsFrom` (a read action fills the choices)
and type `resource`. Keys you did not declare are refused before your handler runs. `secret` values reach the handler
but are never stored or shown. Mark private outputs `sensitive`.
- `dryRun`: `native` calls `run` with `dryRun: true` and nothing may change. `describe` calls `describe` instead.
- `check` names a read action that proves the result, used for drift. `undo` names the reverse action, and `run` returns
`undoInput`. `idempotent` lets a rollout retry it. `exclusive` allows one at a time per host. `when` limits it to
hosts by setting or OS.
- `surfaces`: the assistant and MCP are off until you set them true. Only do that for actions safe for a model to
ask for. The rest are on unless you set them false.
- Resources describe a kind of thing, such as a container: a read action on one host returns `{ rows }` as `data`, and
core draws the All hosts table and host page section. Attach actions with `scope: "resource"`.
- Signals are status tiles: a read action returns `{ value, status, label }` as `data`, with status `ok`, `warn`, `fail` or
`unknown`. `fix` names the action offered when it is not ok. Keep these reads cheap, they run for every host.
- Events: declare them, then call `ctx.operations.emit(id, payload, { hostId })`.
- Handlers must watch `signal` and stop when it fires. Throw on failure; a failed run never counts against your
plugin's error budget. Throw an error with `code = "NEEDS_INTERACTION"` when a host needs a person to sign in first.
- Handlers have no request and no session. Take what you need from the input and `hostId`.
- Keep your REST routes. Inside one, call `ctx.operations.run(id, { hostId, input })` so a button press goes through
the same checks and lands in the same history.
- To run another plugin's action, add `operations:invoke`, list it in `operations.invokes` and call
`ctx.operations.invoke(id, { hostId, input, via: "automation" })`. It acts as the current user and rules still apply.
- `risk` is your plugin's claim. Plugins run in-process, so this is not a sandbox. Actions from plugins not signed as
official count as `change` when rules decide what needs a person, and stay off the assistant and MCP until an admin
allows them.
- Set `engine.termix` to at least `>=26.11.1-0`. Keep `engine.api` as it was unless you import something new from
`/ui`, or the phone app will ask people to update to see your plugin.
### Shared packages ### Shared packages
These come from Termix and are never bundled: `@termix-ssh/plugin-sdk`, `express`, `ssh2`, `ws`, `multer`, These come from Termix and are never bundled: `@termix-ssh/plugin-sdk`, `express`, `ssh2`, `ws`, `multer`,
@@ -559,23 +451,6 @@ Other storage: `ctx.kv` for a few values, `ctx.settings` for settings, `ctx.secr
`ctx.files.dataDir()` for files. To sync a table to a linked desktop, use `ctx.sync.registerEntity` and `ctx.files.dataDir()` for files. To sync a table to a linked desktop, use `ctx.sync.registerEntity` and
`contributes.syncEntities`. The entity name is part of the protocol and never changes. `contributes.syncEntities`. The entity name is part of the protocol and never changes.
A registered table syncs the rows each user owns, both ways. For data you keep for the whole server (a shared
library, rows other users share), register a read only entity with `table: null`, `readOnly: true` and `load`,
`write` and `erase` (Termix 26.10.6+). `load(userId)` returns the rows that user should get, each with a
`syncId`. The desktop keeps them with `write` and drops them with `erase`, and never pushes them back:
```ts
ctx.sync.registerEntity({
type: "sharedIcons",
table: null,
readOnly: true,
order: 70,
load: (userId) => iconsVisibleTo(userId),
write: (userId, row) => storeCopy(userId, row),
erase: (userId, syncId) => dropCopy(userId, syncId),
});
```
## Frontend ## Frontend
```tsx ```tsx
@@ -1026,12 +901,10 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
| `@termix-ssh/plugin-sdk/db` | `defineTable` and column builders | | `@termix-ssh/plugin-sdk/db` | `defineTable` and column builders |
| `@termix-ssh/plugin-sdk/manifest` | `PluginManifest` type and manifest checks | | `@termix-ssh/plugin-sdk/manifest` | `PluginManifest` type and manifest checks |
| `@termix-ssh/plugin-sdk/capabilities` | The capability catalog | | `@termix-ssh/plugin-sdk/capabilities` | The capability catalog |
| `@termix-ssh/plugin-sdk/operations` | Types, input checks and JSON Schema for declared actions |
| `@termix-ssh/plugin-sdk/settings` | Settings field validation | | `@termix-ssh/plugin-sdk/settings` | Settings field validation |
| `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` | | `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` |
| `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` | | `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` |
| `@termix-ssh/plugin-sdk/host-commands` | OS detection, package manager and sudo helpers over ssh2 | | `@termix-ssh/plugin-sdk/host-commands` | OS detection, package manager and sudo helpers over ssh2 |
| `@termix-ssh/plugin-sdk/host-scripts` | Docker, metrics, systemd and journal command builders and parsers, scripts sent to `bash -s` |
| `@termix-ssh/plugin-sdk/ssh-certs` | `applyCertificateAuth()` for certificate based SSH auth | | `@termix-ssh/plugin-sdk/ssh-certs` | `applyCertificateAuth()` for certificate based SSH auth |
| `@termix-ssh/plugin-sdk/ssh-keys` | `generateSshKeyPair()`, `detectSshKeyType()`, `publicKeyFromPrivate()`, `validateSshKeyPair()` | | `@termix-ssh/plugin-sdk/ssh-keys` | `generateSshKeyPair()`, `detectSshKeyType()`, `publicKeyFromPrivate()`, `validateSshKeyPair()` |
| `@termix-ssh/plugin-sdk/desktop` | The desktop app bridge, typed. Check it exists before use | | `@termix-ssh/plugin-sdk/desktop` | The desktop app bridge, typed. Check it exists before use |
@@ -1058,8 +931,6 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
| `desktop:local` | high | Use the user's own computer, desktop app only | | `desktop:local` | high | Use the user's own computer, desktop app only |
| `hosts:export` | high | Export hosts with their saved logins | | `hosts:export` | high | Export hosts with their saved logins |
| `notify:hub` | high | Receive every alert (the alerts hub only) | | `notify:hub` | high | Receive every alert (the alerts hub only) |
| `operations:invoke` | high | Run other plugins' declared actions, listed in `invokes` |
| `operations:approve` | high | Decide which actions need a person |
| `ui:layout` | high | Replace the whole app layout | | `ui:layout` | high | Replace the whole app layout |
| `hosts:write` | medium | Add and edit hosts | | `hosts:write` | medium | Add and edit hosts |
| `hosts:status-provide` | medium | Be the source of host status dots | | `hosts:status-provide` | medium | Be the source of host status dots |
@@ -1075,7 +946,6 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
| `users:read` | medium | Usernames, roles and UI language | | `users:read` | medium | Usernames, roles and UI language |
| `events:core` | medium | Listen to core events | | `events:core` | medium | Listen to core events |
| `notify:send` | medium | Send alerts | | `notify:send` | medium | Send alerts |
| `operations:provide` | medium | Offer declared actions and events |
| `audit:read` | medium | Read the audit log | | `audit:read` | medium | Read the audit log |
| `desktop:window` | medium | Open windows in the desktop app | | `desktop:window` | medium | Open windows in the desktop app |
| `hosts:read` | low | Read hosts the user can see | | `hosts:read` | low | Read hosts the user can see |
+36 -55
View File
@@ -25,43 +25,6 @@ const FRONTEND_ENTRIES = [
"src/frontend/index.mjs", "src/frontend/index.mjs",
"src/frontend/index.js", "src/frontend/index.js",
]; ];
const MOBILE_ENTRIES = [
"src/mobile/index.tsx",
"src/mobile/index.ts",
"src/mobile/index.mjs",
"src/mobile/index.js",
];
/** Builds a bundle that runs in a browser against core's shared modules. */
async function buildBrowserBundle(entry, outfile, outDir) {
await esbuild.build({
entryPoints: [entry],
outfile,
bundle: true,
format: "esm",
platform: "browser",
target: "es2022",
jsx: "automatic",
sourcemap: true,
logLevel: "warning",
external: FRONTEND_EXTERNALS,
// Vite replaces these in the dev server; a plugin bundle has to have
// them too, or code that reads them throws when the plugin loads.
define: {
"process.env.NODE_ENV": '"production"',
"import.meta.env.DEV": "false",
"import.meta.env.PROD": "true",
"import.meta.env.MODE": '"production"',
"import.meta.env.SSR": "false",
"import.meta.env":
'{"DEV":false,"PROD":true,"MODE":"production","SSR":false,"BASE_URL":"/"}',
},
plugins: [
externalRequireInterop(FRONTEND_EXTERNALS),
staticUrlImports({ outDir }),
],
});
}
/** /**
* A bundled CommonJS package that calls require("react/jsx-runtime") (or any * A bundled CommonJS package that calls require("react/jsx-runtime") (or any
@@ -229,12 +192,15 @@ export async function buildTailwind(cwd, outDir) {
// build() adds to what a compiler already emitted, so each half gets its own. // build() adds to what a compiler already emitted, so each half gets its own.
const compiler = () => const compiler = () =>
compile(input, { base: path.dirname(THEME_CSS), onDependency: () => {} }); compile(input, { base: path.dirname(THEME_CSS), onDependency: () => {} });
// The mobile bundle uses the same stylesheet. const scanner = new Scanner({
const sources = ["frontend", "mobile"] sources: [
.map((dir) => path.join(cwd, "src", dir)) {
.filter((dir, index) => index === 0 || fs.existsSync(dir)) base: path.join(cwd, "src", "frontend"),
.map((base) => ({ base, pattern: "**/*", negated: false })); pattern: "**/*",
const scanner = new Scanner({ sources }); negated: false,
},
],
});
// Flattened and minified the way core's own CSS is, so it does not rely // Flattened and minified the way core's own CSS is, so it does not rely
// on native CSS nesting. // on native CSS nesting.
const emit = async (candidates, options) => const emit = async (candidates, options) =>
@@ -331,18 +297,33 @@ export async function build({ cwd }) {
const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES); const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES);
if (frontendEntry) { if (frontendEntry) {
await buildBrowserBundle( await esbuild.build({
frontendEntry, entryPoints: [frontendEntry],
path.join(outDir, "frontend.js"), outfile: path.join(outDir, "frontend.js"),
outDir, bundle: true,
); format: "esm",
} platform: "browser",
target: "es2022",
// A bundle the mobile app loads in its WebView, next to the frontend one. jsx: "automatic",
const mobileEntry = resolveEntry(cwd, MOBILE_ENTRIES); sourcemap: true,
const mobileOut = manifest.contributes?.mobile?.entry; logLevel: "warning",
if (mobileEntry && typeof mobileOut === "string") { external: FRONTEND_EXTERNALS,
await buildBrowserBundle(mobileEntry, path.join(cwd, mobileOut), outDir); // Vite replaces these in the dev server; a plugin bundle has to have
// them too, or code that reads them throws when the plugin loads.
define: {
"process.env.NODE_ENV": '"production"',
"import.meta.env.DEV": "false",
"import.meta.env.PROD": "true",
"import.meta.env.MODE": '"production"',
"import.meta.env.SSR": "false",
"import.meta.env":
'{"DEV":false,"PROD":true,"MODE":"production","SSR":false,"BASE_URL":"/"}',
},
plugins: [
externalRequireInterop(FRONTEND_EXTERNALS),
staticUrlImports({ outDir }),
],
});
} }
if (frontendEntry) await buildTailwind(cwd, outDir); if (frontendEntry) await buildTailwind(cwd, outDir);
@@ -28,10 +28,6 @@ export async function validate({ cwd }) {
// A theme only plugin has no frontend code. // A theme only plugin has no frontend code.
...(isThemeOnly(raw) ? [] : [raw.frontend ?? "dist/frontend.js"]), ...(isThemeOnly(raw) ? [] : [raw.frontend ?? "dist/frontend.js"]),
raw.locales ?? "locales", raw.locales ?? "locales",
// Built from src/mobile by termix-plugin build.
...(typeof raw.contributes?.mobile?.entry === "string"
? [raw.contributes.mobile.entry]
: []),
]; ];
for (const rel of referenced) { for (const rel of referenced) {
if (!fs.existsSync(path.join(cwd, rel))) { if (!fs.existsSync(path.join(cwd, rel))) {
+1 -13
View File
@@ -1,6 +1,6 @@
{ {
"name": "@termix-ssh/plugin-sdk", "name": "@termix-ssh/plugin-sdk",
"version": "1.0.25", "version": "1.0.20",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
@@ -61,10 +61,6 @@
"types": "./dist/openapi.d.ts", "types": "./dist/openapi.d.ts",
"default": "./dist/openapi.js" "default": "./dist/openapi.js"
}, },
"./operations": {
"types": "./dist/operations.d.ts",
"default": "./dist/operations.js"
},
"./settings": { "./settings": {
"types": "./dist/settings.d.ts", "types": "./dist/settings.d.ts",
"default": "./dist/settings.js" "default": "./dist/settings.js"
@@ -85,10 +81,6 @@
"types": "./dist/host-commands.d.ts", "types": "./dist/host-commands.d.ts",
"default": "./dist/host-commands.js" "default": "./dist/host-commands.js"
}, },
"./host-scripts": {
"types": "./dist/host-scripts/index.d.ts",
"default": "./dist/host-scripts/index.js"
},
"./ssh-certs": { "./ssh-certs": {
"types": "./dist/ssh-certs.d.ts", "types": "./dist/ssh-certs.d.ts",
"default": "./dist/ssh-certs.js" "default": "./dist/ssh-certs.js"
@@ -106,10 +98,6 @@
"./testing-host": { "./testing-host": {
"default": "./dist/host/testing-host.js" "default": "./dist/host/testing-host.js"
}, },
"./frame-host": {
"default": "./dist/host/frame-host.js"
},
"./frame-host.css": "./dist/host/frame-host.css",
"./theme.css": "./dist/host/theme.css" "./theme.css": "./dist/host/theme.css"
}, },
"files": [ "files": [
@@ -536,31 +536,6 @@
} }
} }
}, },
"hostSections": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "titleKey"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"titleKey": { "type": "string", "minLength": 1 },
"icon": { "type": "string", "minLength": 1 }
}
}
},
"operations": {
"type": "object",
"description": "What this plugin can do. Every surface finds these on its own. See the operations section of PLUGIN-GUIDE.md.",
"additionalProperties": false,
"properties": {
"actions": { "type": "array", "items": { "type": "object" } },
"events": { "type": "array", "items": { "type": "object" } },
"resources": { "type": "array", "items": { "type": "object" } },
"signals": { "type": "array", "items": { "type": "object" } },
"invokes": { "type": "array", "items": { "type": "string" } }
}
},
"dashboardCards": { "dashboardCards": {
"type": "array", "type": "array",
"items": { "items": {
@@ -1264,48 +1239,6 @@
} }
} }
} }
},
"mobile": {
"type": "object",
"description": "How the plugin shows in the Termix mobile app. Needs plugin API 1.13 (Termix 26.10.6).",
"additionalProperties": false,
"required": ["mode"],
"properties": {
"mode": {
"type": "string",
"enum": ["webview", "native", "none"],
"description": "webview: the app shows the plugin's UI from the server. native: the app has its own screens. none: nothing to show in the app."
},
"entry": {
"type": "string",
"pattern": "\\.js$",
"description": "webview only. A bundle built for the app, in the same folder as the frontend bundle. Unset means the normal frontend runs in a phone sized frame."
},
"sizes": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "enum": ["phone", "tablet"] },
"description": "webview only. Screen sizes the UI works at. Defaults to both."
},
"views": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 },
"description": "webview only. Tab, panel or card ids the app offers. Defaults to every tab."
},
"feature": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]{1,39}$",
"description": "native only. The app feature that stands in for this plugin. Defaults to the plugin id."
},
"note": {
"type": "string",
"minLength": 1,
"description": "i18n key in the plugin's namespace, or plain text, shown in the app."
}
}
} }
} }
}, },
+7 -174
View File
@@ -14,11 +14,6 @@ import type { HostSshOptions } from "./ssh-options.js";
export type { HostSshOptions } from "./ssh-options.js"; export type { HostSshOptions } from "./ssh-options.js";
import type { PluginManifest, PluginSettingsField } from "./manifest.js"; import type { PluginManifest, PluginSettingsField } from "./manifest.js";
import type { PluginTableDefinition } from "./db.js"; import type { PluginTableDefinition } from "./db.js";
import type {
OperationResult,
OperationRisk,
OperationSurfaces,
} from "./operations.js";
/** /**
* Thrown when a plugin calls a guarded member without the capability. * Thrown when a plugin calls a guarded member without the capability.
@@ -182,24 +177,8 @@ export interface SyncWriteEvent {
export interface SyncEntityRegistration { export interface SyncEntityRegistration {
/** Stable wire name. Never change it: records on both sides match on it. */ /** Stable wire name. Never change it: records on both sides match on it. */
type: string; type: string;
/** The table, as returned by ctx.db.define. Null for an entity with `load`. */ /** The table, as returned by ctx.db.define. */
table: unknown; table: unknown;
/**
* Server to desktop only: a desktop never pushes these rows. Required with
* `load`.
*/
readOnly?: boolean;
/**
* Builds the rows the user gets instead of reading the rows they own in
* `table`, for data kept for the whole server (a shared library, rows other
* users share with them). Rows are on the wire: plaintext, each with a
* syncId. Needs `readOnly: true`, `write` and `erase`.
*/
load?: (userId: string) => Promise<SyncRow[]>;
/** Keeps a copy of an inbound row on the desktop, for an entity with `load`. */
write?: (userId: string, wire: SyncRow) => Promise<void>;
/** Drops the desktop copy with this syncId, for an entity with `load`. */
erase?: (userId: string, syncId: string) => Promise<void>;
/** Column holding the owning user id. Defaults to "userId". */ /** Column holding the owning user id. Defaults to "userId". */
userColumn?: string; userColumn?: string;
/** Fields stored encrypted with the owner's data key. Plaintext on the wire. */ /** Fields stored encrypted with the owner's data key. Plaintext on the wire. */
@@ -491,12 +470,6 @@ export interface PluginSyncHost {
ensureCore: () => Promise<void>; ensureCore: () => Promise<void>;
onChanged: (listener: () => void) => () => void; onChanged: (listener: () => void) => () => void;
onLocalWrite: (listener: () => void) => () => void; onLocalWrite: (listener: () => void) => () => void;
/** Local ids of the user's own rows of a type, by sync id. Termix 26.10.6+. */
idsOf?: (
type: string,
userId: string,
syncIds: string[],
) => Promise<Record<string, number>>;
}; };
/** Rows and bookkeeping of one local user's replica. */ /** Rows and bookkeeping of one local user's replica. */
@@ -1840,21 +1813,13 @@ export interface PluginNetwork {
* asked for every connection, so it must be quick. `open` returns a stream * asked for every connection, so it must be quick. `open` returns a stream
* the SSH client runs over, like a relayed socket. * the SSH client runs over, like a relayed socket.
*/ */
/** The host a transport is asked about. Fields past port come from Termix 26.10.6. */
export interface PluginSshTransportHost {
id: number;
ip: string;
port: number;
/** Owner of the host row. */
userId?: string | null;
syncId?: string | null;
connectionOrigin?: "local" | "remote" | null;
localOnly?: boolean;
}
export interface PluginSshTransportProvider { export interface PluginSshTransportProvider {
claims: (hostId: number, host?: PluginSshTransportHost) => boolean; claims: (hostId: number) => boolean;
open: (host: PluginSshTransportHost) => Promise<import("node:stream").Duplex>; open: (host: {
id: number;
ip: string;
port: number;
}) => Promise<import("node:stream").Duplex>;
} }
export interface PluginSshTransports { export interface PluginSshTransports {
@@ -3109,136 +3074,6 @@ export interface PluginPlugins {
list: () => Promise<PluginSummary[]>; list: () => Promise<PluginSummary[]>;
} }
/** What a handler gets besides its input. */
export interface PluginOperationContext {
hostId?: number;
/** The row a resource action targets. */
resourceKey?: string;
/** Null for a background read. */
runId: string | null;
/** Nothing may change when this is set. */
dryRun: boolean;
/** Fires when the run is cancelled, times out or the plugin stops. */
signal: AbortSignal;
log: (line: string) => void;
}
export interface PluginOperationHandler {
run: (
input: Record<string, unknown>,
context: PluginOperationContext,
) => Promise<OperationResult> | OperationResult;
/** The dry run text for an action declared with dryRun "describe". */
describe?: (
input: Record<string, unknown>,
context: PluginOperationContext,
) => string | Promise<string>;
}
export interface PluginOperationInvokeOptions {
hostId?: number;
resourceKey?: string;
input?: Record<string, unknown>;
dryRun?: boolean;
/** A person agreed to a destructive action. */
confirmed?: boolean;
idempotencyKey?: string;
batchId?: string;
/** What this plugin is doing: automation, assistant, mcp, rollout and so on. */
via?: string;
}
export interface PluginOperationOutcome {
runId: string | null;
status: string;
summary?: string;
changed?: boolean;
data?: unknown;
error?: string;
errorCode?: string;
approvalId?: string;
}
export interface PluginOperationApprovalInput {
actionId: string;
pluginId: string;
risk: OperationRisk;
/** What rules should use. Plugins not signed as official count as change. */
effectiveRisk: OperationRisk;
origin: { kind: string; pluginId?: string; via?: string };
userId: string;
hostId?: number;
hostTags?: string[];
batchId?: string;
}
export interface PluginOperationApprovalDecision {
decision: "allow" | "approve" | "deny";
reason?: string;
allowSelf?: boolean;
expiresInMinutes?: number;
}
export interface PluginOperationEvent {
id: string;
eventId: string;
pluginId: string;
hostId?: number;
severity?: string;
payload?: unknown;
runId?: string;
depth: number;
at: string;
}
/**
* Declared actions and events. See contributes.operations in the manifest.
* register and emit need operations:provide. invoke, list and onEvent need
* operations:invoke, and invoke only reaches ids in the manifest's invokes list.
*/
export interface PluginOperations {
register: (id: string, handler: PluginOperationHandler) => void;
/**
* Runs one of this plugin's own actions from its route, so a button press
* goes through the same checks and lands in the same history.
*/
run: (
actionId: string,
options?: PluginOperationInvokeOptions,
) => Promise<PluginOperationOutcome>;
invoke: (
actionId: string,
options?: PluginOperationInvokeOptions,
) => Promise<PluginOperationOutcome>;
list: (options?: {
lang?: string;
surface?: keyof OperationSurfaces;
}) => Promise<unknown>;
emit: (
eventId: string,
payload?: unknown,
options?: { hostId?: number },
) => Promise<void>;
onEvent: (subscription: {
pattern: string;
handler: (event: PluginOperationEvent) => void | Promise<void>;
userId?: string;
cooldownMs?: number;
}) => () => void;
/** Rules for what needs a person. Needs operations:approve. */
approvals: {
provide: (
provider: (
input: PluginOperationApprovalInput,
) =>
| PluginOperationApprovalDecision
| null
| undefined
| Promise<PluginOperationApprovalDecision | null | undefined>,
) => () => void;
};
}
export interface PluginContext { export interface PluginContext {
readonly pluginId: string; readonly pluginId: string;
readonly manifest: PluginManifest; readonly manifest: PluginManifest;
@@ -3279,8 +3114,6 @@ export interface PluginContext {
readonly schedule: PluginSchedule; readonly schedule: PluginSchedule;
/** Core's notification channels. Needs notify:send. */ /** Core's notification channels. Needs notify:send. */
readonly notify: PluginNotify; readonly notify: PluginNotify;
/** Declared actions and events, reachable from every surface. */
readonly operations: PluginOperations;
/** Outbound HTTP through core's SSRF guard. Needs network:outbound. */ /** Outbound HTTP through core's SSRF guard. Needs network:outbound. */
readonly fetch: PluginFetch; readonly fetch: PluginFetch;
/** Plain reachability checks. Needs network:probe. */ /** Plain reachability checks. Needs network:probe. */
-3
View File
@@ -52,8 +52,6 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("ssh:transport", "high"), entry("ssh:transport", "high"),
entry("device:serial", "high"), entry("device:serial", "high"),
entry("notify:hub", "high"), entry("notify:hub", "high"),
entry("operations:invoke", "high"),
entry("operations:approve", "high"),
entry("ui:layout", "high"), entry("ui:layout", "high"),
entry("hosts:write", "medium"), entry("hosts:write", "medium"),
@@ -70,7 +68,6 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("users:read", "medium"), entry("users:read", "medium"),
entry("events:core", "medium"), entry("events:core", "medium"),
entry("notify:send", "medium"), entry("notify:send", "medium"),
entry("operations:provide", "medium"),
entry("audit:read", "medium"), entry("audit:read", "medium"),
entry("desktop:window", "medium"), entry("desktop:window", "medium"),
entry("desktop:local", "high"), entry("desktop:local", "high"),
-28
View File
@@ -206,8 +206,6 @@ export interface ShellApi {
* user reviews and saves it. Absent in some shells. * user reviews and saves it. Absent in some shells.
*/ */
openHostEditor?: (draft?: HostDraft) => void; openHostEditor?: (draft?: HostDraft) => void;
/** Opens the editor of a saved host. Termix 26.10.6+, absent in some shells. */
editHost?: (hostId: number) => void;
/** Opens a rail view in the left sidebar. */ /** Opens a rail view in the left sidebar. */
openRailView: (id: string) => void; openRailView: (id: string) => void;
/** Closes a rail view wherever it is shown. */ /** Closes a rail view wherever it is shown. */
@@ -427,11 +425,6 @@ export interface RailItemContribution {
* Zero or nothing shows no badge. * Zero or nothing shows no badge.
*/ */
useBadge?: () => number | null | undefined; useBadge?: () => number | null | undefined;
/**
* A hook for a small status dot on the icon: "ok" (green), "warning" or
* "error". Nothing shows no dot. Layouts from Termix 26.10.6 draw it.
*/
useStatus?: () => "ok" | "warning" | "error" | null | undefined;
} }
export interface PanelProps { export interface PanelProps {
@@ -670,22 +663,6 @@ export type HostStatusIndicatorContribution = HostBadgeContribution;
/** A control in the host editor's toolbar. */ /** A control in the host editor's toolbar. */
export type HostEditorToolbarItemContribution = HostBadgeContribution; export type HostEditorToolbarItemContribution = HostBadgeContribution;
export interface HostSectionProps {
host: PluginHostRecord;
isVisible: boolean;
shell: ShellApi;
}
export interface HostSectionContribution {
/** Declare it in manifest contributes.hostSections too. */
id: string;
titleKey: string;
/** Lower sorts first. */
order?: number;
when?: (host: PluginHostRecord) => boolean;
component: ComponentType<HostSectionProps>;
}
export interface HostContextMenuItemContribution { export interface HostContextMenuItemContribution {
id: string; id: string;
titleKey: string; titleKey: string;
@@ -1188,11 +1165,6 @@ export interface TermixApp extends TermixAppInfo {
registerHostContextMenuItem: ( registerHostContextMenuItem: (
item: HostContextMenuItemContribution, item: HostContextMenuItemContribution,
) => Disposer; ) => Disposer;
/**
* A card on the host page. Core 26.11.1 and newer; call it as
* app.registerHostSection?.(...) if your plugin allows older cores.
*/
registerHostSection: (section: HostSectionContribution) => Disposer;
registerPaletteEntry: (entry: PaletteEntryContribution) => Disposer; registerPaletteEntry: (entry: PaletteEntryContribution) => Disposer;
registerPaletteGroup: (group: PaletteGroupContribution) => Disposer; registerPaletteGroup: (group: PaletteGroupContribution) => Disposer;
registerKeybindingAction: (action: KeybindingActionContribution) => Disposer; registerKeybindingAction: (action: KeybindingActionContribution) => Disposer;
+13 -38
View File
@@ -11,9 +11,6 @@
export type { Client, ClientChannel } from "ssh2"; export type { Client, ClientChannel } from "ssh2";
import type { Client, ClientChannel } from "ssh2"; import type { Client, ClientChannel } from "ssh2";
// Homebrew and OrbStack paths a non-interactive shell misses.
import { EXTRA_PATH_DIRS } from "./host-scripts/docker.js";
export { EXTRA_PATH_DIRS };
export interface ExecResult { export interface ExecResult {
stdout: string; stdout: string;
@@ -21,30 +18,6 @@ export interface ExecResult {
code: number | null; code: number | null;
} }
/**
* Decodes raw command output. Strict UTF-8 first; when that fails, tries GBK,
* which is what a Chinese-locale Windows console sends (its bytes are almost
* never valid UTF-8) and keeps it only when it decodes without replacement
* characters. Anything else falls back to lossy UTF-8, the old behavior.
*/
export function decodeCommandOutput(buf: Buffer): string {
try {
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
buf,
);
} catch {
// not valid UTF-8, try the legacy codepage
}
try {
// A Node built with small-ICU has no GBK decoder and throws here.
const gbk = new TextDecoder("gbk").decode(buf);
if (!gbk.includes("�")) return gbk;
} catch {
// expected - fall through to lossy UTF-8
}
return buf.toString("utf8");
}
/** Runs one non-interactive command over an existing ssh2 client. */ /** Runs one non-interactive command over an existing ssh2 client. */
export function execCommand( export function execCommand(
client: Client, client: Client,
@@ -89,10 +62,8 @@ export function execCommand(
} }
stream = _stream; stream = _stream;
// Chunks are buffered and decoded once on close: a multi-byte character let stdout = "";
// can be split across chunks, and charset detection needs it whole. let stderr = "";
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
let exitCode: number | null = null; let exitCode: number | null = null;
stream stream
@@ -101,15 +72,11 @@ export function execCommand(
settled = true; settled = true;
exitCode = typeof code === "number" ? code : null; exitCode = typeof code === "number" ? code : null;
cleanup(); cleanup();
resolve({ resolve({ stdout, stderr, code: exitCode });
stdout: decodeCommandOutput(Buffer.concat(stdout)),
stderr: decodeCommandOutput(Buffer.concat(stderr)),
code: exitCode,
});
} }
}) })
.on("data", (data: Buffer) => { .on("data", (data: Buffer) => {
stdout.push(data); stdout += data.toString("utf8");
}) })
.on("error", (streamErr: Error) => { .on("error", (streamErr: Error) => {
if (!settled) { if (!settled) {
@@ -122,7 +89,7 @@ export function execCommand(
if (stream.stderr) { if (stream.stderr) {
stream.stderr stream.stderr
.on("data", (data: Buffer) => { .on("data", (data: Buffer) => {
stderr.push(data); stderr += data.toString("utf8");
}) })
.on("error", (stderrErr: Error) => { .on("error", (stderrErr: Error) => {
if (!settled) { if (!settled) {
@@ -147,6 +114,14 @@ export interface PlatformInfo {
osPrettyName: string | null; osPrettyName: string | null;
} }
/**
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
* so PATH additions from installers like Homebrew or OrbStack are missing.
* Extend PATH with their common install locations before probing.
*/
export const EXTRA_PATH_DIRS =
'/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:"$HOME/.orbstack/bin"';
const DOCKER_PROBE = const DOCKER_PROBE =
`command -v docker >/dev/null 2>&1` + `command -v docker >/dev/null 2>&1` +
` || [ -S /var/run/docker.sock ]` + ` || [ -S /var/run/docker.sock ]` +
@@ -1,468 +0,0 @@
/**
* Docker and Podman command builders and output parsers.
*
* Container ids and names are checked with CONTAINER_ID_RE before they go on
* a command line, so they need no quoting. Anything free form is quoted.
*/
import { HostScriptError } from "./text.js";
export type ContainerRuntime = "docker" | "podman";
export function normalizeContainerRuntime(value: unknown): ContainerRuntime {
return value === "podman" ? "podman" : "docker";
}
export function getRuntimeLabel(runtime: ContainerRuntime): string {
return runtime === "podman" ? "Podman" : "Docker";
}
/**
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
* so PATH additions from installers like Homebrew or OrbStack are missing.
*/
export const EXTRA_PATH_DIRS =
'/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:"$HOME/.orbstack/bin"';
export function containerCommand(
runtime: ContainerRuntime | undefined,
args: string,
isWindows = false,
): string {
const cli = `${normalizeContainerRuntime(runtime)} ${args}`;
// cmd and PowerShell don't understand the POSIX env prefix.
return isWindows ? cli : `PATH="${EXTRA_PATH_DIRS}:$PATH" ${cli}`;
}
export const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
export const DOCKER_TIMESTAMP_RE = /^[0-9T:.Z+-]+$/;
export function isContainerId(value: unknown): value is string {
return (
typeof value === "string" &&
value.length <= 256 &&
CONTAINER_ID_RE.test(value)
);
}
function requireContainer(id: string): string {
if (!isContainerId(id))
throw new HostScriptError("That is not a valid container id");
return id;
}
export const CONTAINER_ACTIONS = [
"start",
"stop",
"restart",
"pause",
"unpause",
] as const;
export type ContainerAction = (typeof CONTAINER_ACTIONS)[number];
const LIST_FORMAT_UNIX = `'{"id":"{{.ID}}","name":"{{.Names}}","image":"{{.Image}}","status":"{{.Status}}","state":"{{.State}}","ports":"{{.Ports}}","created":"{{.CreatedAt}}"}' `;
const LIST_FORMAT_WINDOWS = `"{\\"id\\":\\"{{.ID}}\\",\\"name\\":\\"{{.Names}}\\",\\"image\\":\\"{{.Image}}\\",\\"status\\":\\"{{.Status}}\\",\\"state\\":\\"{{.State}}\\",\\"ports\\":\\"{{.Ports}}\\",\\"created\\":\\"{{.CreatedAt}}\\"}"`;
const STATS_FORMAT_UNIX = `'{"cpu":"{{.CPUPerc}}","memory":"{{.MemUsage}}","memoryPercent":"{{.MemPerc}}","netIO":"{{.NetIO}}","blockIO":"{{.BlockIO}}","pids":"{{.PIDs}}"}' `;
const STATS_FORMAT_WINDOWS = `"{\\"cpu\\":\\"{{.CPUPerc}}\\",\\"memory\\":\\"{{.MemUsage}}\\",\\"memoryPercent\\":\\"{{.MemPerc}}\\",\\"netIO\\":\\"{{.NetIO}}\\",\\"blockIO\\":\\"{{.BlockIO}}\\",\\"pids\\":\\"{{.PIDs}}\\"}"`;
export function listFormat(isWindows: boolean): string {
return isWindows ? LIST_FORMAT_WINDOWS : LIST_FORMAT_UNIX;
}
export function statsFormat(isWindows: boolean): string {
return isWindows ? STATS_FORMAT_WINDOWS : STATS_FORMAT_UNIX;
}
/** One row of `ps --format listFormat()`. */
export interface ContainerRow {
id: string;
name: string;
image: string;
status: string;
state: string;
ports: string;
created: string;
}
/** CLI arguments, without the runtime, for each container command. */
export const dockerArgs = {
version: () => "--version",
ping: () => "ps",
list: (all: boolean, isWindows = false) =>
`ps ${all ? "-a " : ""}--format ${listFormat(isWindows)}`,
inspect: (id: string) => `inspect ${requireContainer(id)}`,
stats: (id: string, isWindows = false) =>
`stats ${requireContainer(id)} --no-stream --format ${statsFormat(isWindows)}`,
action: (action: ContainerAction, id: string) => {
if (!CONTAINER_ACTIONS.includes(action))
throw new HostScriptError("Unknown action");
return `${action} ${requireContainer(id)}`;
},
remove: (id: string, force: boolean) =>
`rm ${force ? "-f " : ""}${requireContainer(id)}`,
logs: (
id: string,
options: {
tail?: number;
timestamps?: boolean;
since?: string;
until?: string;
} = {},
) => {
let args = `logs ${requireContainer(id)}`;
if (options.tail && options.tail > 0)
args += ` --tail ${Math.floor(options.tail)}`;
if (options.timestamps) args += " --timestamps";
if (options.since && DOCKER_TIMESTAMP_RE.test(options.since)) {
args += ` --since ${options.since}`;
}
if (options.until && DOCKER_TIMESTAMP_RE.test(options.until)) {
args += ` --until ${options.until}`;
}
return `${args} 2>&1`;
},
states: () => `ps -a --format ${PS_STATE_FORMAT}`,
composeProjects: () => "compose ls -a --format json",
};
/** The version from `docker --version` or `podman --version`. */
export function parseRuntimeVersion(output: string): string {
return output.match(/(?:Docker|podman) version ([^\s,]+)/i)?.[1] ?? "unknown";
}
export type RuntimeProblem =
"NOT_INSTALLED" | "DAEMON_NOT_RUNNING" | "PERMISSION_DENIED" | "DOCKER_ERROR";
/** Sorts a failed `docker ps` into a reason the UI can explain. */
export function classifyRuntimeError(message: string): RuntimeProblem {
if (
/command not found|not found: (docker|podman)|No such file or directory/i.test(
message,
)
) {
return "NOT_INSTALLED";
}
if (message.includes("Cannot connect to the Docker daemon"))
return "DAEMON_NOT_RUNNING";
if (/permission denied/i.test(message)) return "PERMISSION_DENIED";
return "DOCKER_ERROR";
}
/** Parses `ps --format listFormat()` output, skipping lines that are not JSON. */
export function parseContainerList(output: string): Record<string, unknown>[] {
return output
.split("\n")
.filter((line) => line.trim())
.flatMap((line) => {
try {
return [JSON.parse(line) as Record<string, unknown>];
} catch {
return [];
}
});
}
export interface ContainerStats {
cpu: string;
memoryUsed: string;
memoryLimit: string;
memoryPercent: string;
netInput: string;
netOutput: string;
blockRead: string;
blockWrite: string;
pids: string;
}
/** Parses one `stats --no-stream --format statsFormat()` line. Throws on bad JSON. */
export function parseContainerStats(output: string): ContainerStats {
const raw = JSON.parse(output.trim()) as Record<string, string>;
const split = (value: string | undefined) => {
const [first, second] = (value ?? "").split(" / ");
return [first?.trim() || "0B", second?.trim() || "0B"];
};
const [memoryUsed, memoryLimit] = split(raw.memory);
const [netInput, netOutput] = split(raw.netIO);
const [blockRead, blockWrite] = split(raw.blockIO);
return {
cpu: raw.cpu,
memoryUsed,
memoryLimit,
memoryPercent: raw.memoryPercent,
netInput,
netOutput,
blockRead,
blockWrite,
pids: raw.pids,
};
}
export interface ContainerState {
/** Docker's own state word: running, exited, restarting, ... */
state: string;
/** Health from the status text, when the image declares a healthcheck. */
unhealthy: boolean;
}
export type DockerEventName = "exited" | "started" | "unhealthy" | "restarting";
export const PS_STATE_FORMAT = `'{"name":"{{.Names}}","state":"{{.State}}","status":"{{.Status}}"}'`;
/** Parses `ps -a --format PS_STATE_FORMAT` output, one JSON object per line. */
export function parseContainerStates(
output: string,
): Map<string, ContainerState> {
const states = new Map<string, ContainerState>();
for (const line of output.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed = JSON.parse(trimmed) as {
name?: string;
state?: string;
status?: string;
};
if (!parsed.name) continue;
states.set(parsed.name, {
state: (parsed.state ?? "").toLowerCase(),
unhealthy: /\(unhealthy\)/i.test(parsed.status ?? ""),
});
} catch {
// A partial line is not worth failing the whole poll over.
}
}
return states;
}
/**
* The events a pair of snapshots implies. A container missing from the
* previous snapshot counts as newly seen rather than started, so the first
* poll after a restart does not replay every running container.
*/
export function diffContainerStates(
previous: Map<string, ContainerState>,
current: Map<string, ContainerState>,
): Array<{ container: string; event: DockerEventName }> {
const events: Array<{ container: string; event: DockerEventName }> = [];
for (const [name, now] of current) {
const before = previous.get(name);
if (!before) continue;
if (before.state !== now.state) {
if (now.state === "exited")
events.push({ container: name, event: "exited" });
else if (now.state === "running")
events.push({ container: name, event: "started" });
else if (now.state === "restarting") {
events.push({ container: name, event: "restarting" });
}
}
// A container can go unhealthy while it stays up.
if (!before.unhealthy && now.unhealthy) {
events.push({ container: name, event: "unhealthy" });
}
}
return events;
}
export interface ContainerCounts {
running: number;
stopped: number;
unhealthy: number;
total: number;
}
/** Counts from `ps -a --format PS_STATE_FORMAT` output. */
export function countContainers(output: string): ContainerCounts {
const states = [...parseContainerStates(output).values()];
const running = states.filter((s) => s.state === "running").length;
return {
running,
stopped: states.length - running,
unhealthy: states.filter((s) => s.unhealthy).length,
total: states.length,
};
}
// Compose stacks
export const STACK_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,62}$/;
export const STACK_SERVICE_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,126}$/;
export const COMPOSE_FILES = [
"compose.yaml",
"compose.yml",
"docker-compose.yaml",
"docker-compose.yml",
];
export const STACK_ACTIONS = [
"deploy",
"redeploy",
"pull",
"start",
"stop",
"restart",
"pause",
"unpause",
"down",
"destroy",
] as const;
export type StackAction = (typeof STACK_ACTIONS)[number];
/** Actions that can target one service of a stack. */
export const STACK_SERVICE_ACTIONS: StackAction[] = [
"deploy",
"pull",
"start",
"stop",
"restart",
"pause",
"unpause",
];
export type StackState =
"running" | "partial" | "stopped" | "paused" | "not-deployed";
export interface StackSummary {
name: string;
managed: boolean;
state: StackState;
running: number;
total: number;
composeFile: string | null;
}
export interface StackService {
service: string;
name: string;
id: string;
image: string;
state: string;
status: string;
health: string;
ports: string;
}
/** `compose ls` and `compose ps --format json` print one object per line or one array. */
export function parseJsonLines(output: string): Record<string, unknown>[] {
const text = output.trim();
if (!text) return [];
if (text.startsWith("[")) {
try {
return JSON.parse(text) as Record<string, unknown>[];
} catch {
return [];
}
}
return text.split("\n").flatMap((line) => {
try {
return [JSON.parse(line) as Record<string, unknown>];
} catch {
return [];
}
});
}
const str = (value: unknown): string =>
typeof value === "string" ? value : "";
export function parseServices(output: string): StackService[] {
return parseJsonLines(output).map((row) => {
const publishers = Array.isArray(row.Publishers)
? (row.Publishers as Record<string, unknown>[])
: [];
const ports = publishers
.filter((p) => Number(p.PublishedPort) > 0)
.map((p) => `${p.PublishedPort}:${p.TargetPort}`)
.filter((value, index, all) => all.indexOf(value) === index)
.join(", ");
return {
service: str(row.Service),
name: str(row.Name),
id: str(row.ID),
image: str(row.Image),
state: str(row.State),
status: str(row.Status),
health: str(row.Health),
ports,
};
});
}
function stateOf(running: number, paused: number, total: number): StackState {
if (total === 0) return "not-deployed";
if (paused === total) return "paused";
if (running === total) return "running";
if (running > 0) return "partial";
return "stopped";
}
/** Turns the `compose ls` status text ("running(2), exited(1)") into a state. */
export function parseStackStatus(status: string): {
state: StackState;
running: number;
total: number;
} {
let running = 0;
let paused = 0;
let total = 0;
for (const part of status.split(",")) {
const match = part.trim().match(/^([a-z]+)\((\d+)\)$/i);
if (!match) continue;
const count = Number(match[2]);
total += count;
if (match[1] === "running") running += count;
if (match[1] === "paused") paused += count;
}
return { state: stateOf(running, paused, total), running, total };
}
export function stackStateFromServices(services: StackService[]) {
const total = services.length;
const running = services.filter((s) => s.state === "running").length;
const paused = services.filter((s) => s.state === "paused").length;
return { state: stateOf(running, paused, total), running, total };
}
/** Stacks from `compose ls -a --format json`, for hosts with no stacks folder. */
export function parseComposeProjects(output: string): StackSummary[] {
return parseJsonLines(output)
.flatMap((project) => {
const name = str(project.Name);
if (!STACK_NAME_RE.test(name)) return [];
return [
{
name,
managed: false,
...parseStackStatus(str(project.Status)),
composeFile: str(project.ConfigFiles).split(",")[0] || null,
},
];
})
.sort((a, b) => a.name.localeCompare(b.name));
}
/** The compose commands one action runs, in order. */
export function composeArgsFor(
action: StackAction,
service: string | undefined,
options: { removeVolumes?: boolean },
): string[] {
const target = service ? ` ${service}` : "";
switch (action) {
case "deploy":
return [`up -d --remove-orphans${target}`];
case "redeploy":
return [`pull${target}`, `up -d --remove-orphans${target}`];
case "pull":
return [`pull${target}`];
case "start":
case "stop":
case "restart":
case "pause":
case "unpause":
return [`${action}${target}`];
case "down":
return ["down --remove-orphans"];
case "destroy":
return [`down --remove-orphans${options.removeVolumes ? " -v" : ""}`];
}
}
@@ -1,15 +0,0 @@
/**
* Host scripts: command builders and output parsers for Docker, Linux
* metrics, systemd, the journal and OpenRC.
*
* Pure TypeScript with no Node or ssh2 imports, so the same code runs in a
* plugin backend and in the mobile app. Run a script by sending it on stdin
* to the command from `scriptShell()`.
*/
export * from "./text.js";
export * from "./shell.js";
export * from "./docker.js";
export * from "./metrics.js";
export * from "./systemd.js";
export * from "./journal.js";
export * from "./openrc.js";
@@ -1,242 +0,0 @@
/** journalctl scripts and parsers. */
import { isUnit } from "./systemd.js";
import { HostScriptError, shellQuote as q, utf8Decode } from "./text.js";
export const MAX_JOURNAL_LINES = 5000;
const FIELDS = "MESSAGE,PRIORITY,_SYSTEMD_UNIT,SYSLOG_IDENTIFIER,_PID,_COMM";
export interface JournalQuery {
units: string[];
priority?: number;
since?: string;
until?: string;
boot?: string;
grep?: string;
lines: number;
kernel: boolean;
}
export interface JournalEntry {
/** Unix ms. */
time: number;
priority: number;
unit: string;
ident: string;
pid: string;
message: string;
}
export interface BootView {
offset: number;
id: string;
first: string;
last: string;
}
const RELATIVE_RE = /^-\d{1,5}(s|m|min|h|d|w)$/;
const DATE_RE = /^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$/;
const WORDS = ["today", "yesterday", "now"];
export function isJournalTime(value: string): boolean {
return (
RELATIVE_RE.test(value) || DATE_RE.test(value) || WORDS.includes(value)
);
}
function str(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
/** Reads journal filters from a query string. Anything odd throws a HostScriptError. */
export function parseJournalQuery(
input: Record<string, unknown>,
): JournalQuery {
const rawUnits = Array.isArray(input.unit) ? input.unit : [input.unit];
const units = rawUnits.map(str).filter(Boolean);
for (const unit of units) {
if (!isUnit(unit))
throw new HostScriptError("That is not a valid unit name");
}
if (units.length > 20) throw new HostScriptError("Too many units");
const query: JournalQuery = {
units,
lines: 300,
kernel: input.kernel === "true" || input.kernel === true,
};
const priority = str(input.priority);
if (priority) {
const value = Number(priority);
if (!Number.isInteger(value) || value < 0 || value > 7) {
throw new HostScriptError("priority must be 0 to 7");
}
query.priority = value;
}
for (const key of ["since", "until"] as const) {
const value = str(input[key]);
if (!value) continue;
if (!isJournalTime(value))
throw new HostScriptError(`${key} is not a valid time`);
query[key] = value;
}
const boot = str(input.boot);
if (boot) {
if (!/^(-?\d{1,4}|[0-9a-f]{32})$/.test(boot))
throw new HostScriptError("boot is not valid");
query.boot = boot;
}
const grep = typeof input.grep === "string" ? input.grep : "";
if (grep) {
if (grep.length > 200 || /[\r\n\0]/.test(grep)) {
throw new HostScriptError("The search is too long");
}
query.grep = grep;
}
const lines = Number(str(input.lines) || 300);
if (Number.isFinite(lines) && lines > 0) {
query.lines = Math.min(MAX_JOURNAL_LINES, Math.round(lines));
}
return query;
}
/** journalctl arguments for a query, each one quoted. */
export function journalArgs(
query: Omit<JournalQuery, "lines"> & { lines?: number },
options: { follow?: boolean; useGrep?: boolean } = {},
): string {
const args = ["journalctl", "--no-pager", "-q", "-o", "json"];
args.push(`--output-fields=${FIELDS}`);
if (options.follow) args.push("-f");
if (query.lines) args.push("-n", String(Math.floor(query.lines)));
for (const unit of query.units) {
if (!isUnit(unit))
throw new HostScriptError("That is not a valid unit name");
args.push("-u", q(unit));
}
if (query.kernel) args.push("-k");
if (query.priority !== undefined)
args.push("-p", String(Math.floor(query.priority)));
if (query.since) args.push("--since", q(query.since));
if (query.until) args.push("--until", q(query.until));
if (query.boot) args.push("-b", q(query.boot));
if (query.grep && options.useGrep !== false) {
args.push("--case-sensitive=false", "-g", q(query.grep));
}
return args.join(" ");
}
export function journalScript(query: JournalQuery): string {
// Older journalctl builds have no -g. Then the search runs on our side instead.
const noGrep = journalArgs(
{ ...query, lines: query.grep ? MAX_JOURNAL_LINES : query.lines },
{ useGrep: false },
);
return `
command -v journalctl >/dev/null 2>&1 || { echo "SCRIPT_ERR:This host has no journal"; exit 1; }
${journalArgs(query)} 2>/tmp/termix-journal.$$
RC=$?
if [ $RC -ne 0 ] && grep -qi "pattern" /tmp/termix-journal.$$; then
rm -f /tmp/termix-journal.$$
echo "@@TERMIX_NOGREP@@"
${noGrep}
exit 0
fi
cat /tmp/termix-journal.$$ >&2; rm -f /tmp/termix-journal.$$
exit $RC
`;
}
function messageOf(value: unknown): string {
if (typeof value === "string") return value;
if (Array.isArray(value)) {
return utf8Decode(value.filter((n): n is number => typeof n === "number"));
}
return value == null ? "" : String(value);
}
/** One `journalctl -o json` line, or null when it is not an entry. */
export function parseEntry(line: string): JournalEntry | null {
const text = line.trim();
if (!text.startsWith("{")) return null;
let raw: Record<string, unknown>;
try {
raw = JSON.parse(text) as Record<string, unknown>;
} catch {
return null;
}
const micros = Number(raw.__REALTIME_TIMESTAMP);
const priority = Number(raw.PRIORITY);
return {
time: Number.isFinite(micros) ? Math.floor(micros / 1000) : 0,
priority: Number.isInteger(priority) ? priority : 6,
unit: messageOf(raw._SYSTEMD_UNIT),
ident: messageOf(raw.SYSLOG_IDENTIFIER ?? raw._COMM),
pid: messageOf(raw._PID),
message: messageOf(raw.MESSAGE),
};
}
export function parseJournal(
output: string,
query: JournalQuery,
): JournalEntry[] {
const fallback = output.includes("@@TERMIX_NOGREP@@");
const body = fallback ? output.split("@@TERMIX_NOGREP@@")[1] : output;
let entries = body
.split("\n")
.map(parseEntry)
.filter((entry): entry is JournalEntry => entry !== null);
if (fallback && query.grep) {
const needle = query.grep.toLowerCase();
entries = entries
.filter((entry) => entry.message.toLowerCase().includes(needle))
.slice(-query.lines);
}
return entries;
}
export const BOOTS_SCRIPT = `journalctl --list-boots --no-pager 2>/dev/null | tail -n 50; exit 0\n`;
export function parseBoots(output: string): BootView[] {
const boots: BootView[] = [];
for (const line of output.split("\n")) {
const match =
/^\s*(-?\d+)\s+([0-9a-f]{32})\s+(\w{3} \S+ \S+ \S+)\s*.?\s*(\w{3} \S+ \S+ \S+)?/.exec(
line,
);
if (!match) continue;
boots.push({
offset: Number(match[1]),
id: match[2],
first: match[3],
last: match[4] ?? "",
});
}
return boots.reverse();
}
export const USAGE_SCRIPT = `journalctl --disk-usage 2>&1; exit 0\n`;
export function parseUsage(output: string): string {
return /take up (\S+)/.exec(output)?.[1] ?? "";
}
const SIZE_RE = /^\d{1,6}(K|M|G)$/;
const AGE_RE = /^\d{1,4}(d|w|month|months|year|years)$/;
export function vacuumScript(body: Record<string, unknown>): string {
const size = str(body.size);
const time = str(body.time);
if (!size && !time) throw new HostScriptError("Give a size or an age");
if (size && !SIZE_RE.test(size))
throw new HostScriptError("Size looks like 500M or 2G");
if (time && !AGE_RE.test(time))
throw new HostScriptError("Age looks like 7d or 4w");
const flags = [
size ? `--vacuum-size=${size}` : "",
time ? `--vacuum-time=${time}` : "",
]
.filter(Boolean)
.join(" ");
return `journalctl ${flags} 2>&1 || { echo "SCRIPT_ERR:journalctl vacuum failed"; exit 1; }\n`;
}
@@ -1,433 +0,0 @@
/**
* Linux metrics: one awk sample of /proc for CPU, memory, load, network and
* processes, plus df parsing for disks.
*/
// One awk reads every /proc file itself, so a sample is one exec of awk instead
// of a shell plus six tools. Output sections are split by @@; each process line
// is pid, utime+stime ticks, rss pages, starttime, state, ppid, comm.
const AWK = String.raw`BEGIN{f="/proc/stat";while((getline l<f)>0)if(l~/^cpu/)print l;close(f);print "@@";f="/proc/meminfo";while((getline l<f)>0)if(l~/^(MemTotal|MemFree|MemAvailable|Buffers|Cached|SwapTotal|SwapFree):/)print l;close(f);print "@@";f="/proc/loadavg";getline l<f;print l;close(f);f="/proc/uptime";getline l<f;print l;close(f);print "@@";f="/proc/net/dev";while((getline l<f)>0)print l;close(f);print "@@";print pg;print "@@";for(i=1;i<ARGC;i++){f=ARGV[i];if((getline s<f)>0){a=index(s,"(");if(a&&match(s,/\)[^)]*$/)){split(substr(s,RSTART+2),x," ");print substr(s,1,a-2)" "(x[12]+x[13])" "x[22]"\t"x[20]"\t"x[1]" "x[2]" "substr(s,a+1,RSTART-a-1)}}close(f)}}`;
/** The live sample command. The page size never changes on a host, so pass it back once known. */
export const liveCmd = (pageBytes?: number) =>
"awk -v pg=" +
(pageBytes ?? '"$(getconf PAGESIZE)"') +
" '" +
AWK +
"' /proc/[0-9]*/stat";
export interface LiveSample {
/** The admin minimum between samples; the client never polls faster. */
minIntervalS?: number;
cpu: number | null;
cpuMix?: { user: number; system: number; iowait: number; steal: number };
cores: Array<number | null>;
coreIds: string[];
memTotalKb: number;
memCachedKb: number;
memFreeKb: number;
memUsedKb: number;
swapTotalKb: number;
swapUsedKb: number;
load: [number, number, number];
net: Array<{ name: string; rxBps: number | null; txBps: number | null }>;
count: number;
running: number;
procs: Array<{
pid: number;
name: string;
cpu: number;
memKb: number;
state: string;
ppid: number;
}>;
/** Seconds since boot. */
uptime?: number;
/** Total bytes received and sent on every interface but lo, since boot. */
netTotals?: { rx: number; tx: number };
}
/** What the next call needs from this one. */
export interface LivePrev {
at: number;
uptime: number;
pageBytes: number;
cpu: Map<string, { total: number; idle: number }>;
/** Aggregate user nice system idle iowait irq softirq steal ticks, for the breakdown. */
cpuRaw?: number[];
/** Keyed by pid:starttime so a reused pid never inherits another process's ticks. */
ticks: Map<string, number>;
net: Map<string, { rx: number; tx: number }>;
}
/** Total and idle ticks from a /proc/stat `cpu` line. */
export function parseCpuLine(
cpuLine: string,
): { total: number; idle: number } | undefined {
const parts = cpuLine.trim().split(/\s+/);
if (parts[0] !== "cpu") return undefined;
const nums = parts
.slice(1)
.map((n) => Number(n))
.filter((n) => Number.isFinite(n));
if (nums.length < 4) return undefined;
const idle = (nums[3] ?? 0) + (nums[4] ?? 0);
const total = nums.reduce((a, b) => a + b, 0);
return { total, idle };
}
const TOP_N = 60;
/** Parses `liveCmd()` output. Rates need the `next` of the previous call. */
export function parseLive(
output: string,
prev: LivePrev | undefined,
at = Date.now(),
): { sample: LiveSample; next: LivePrev } {
const [cpuS = "", memS = "", loadS = "", netS = "", pageS = "", procS = ""] =
output.split("@@\n");
// Only the first 8 fields: guest and guest_nice are already inside user and nice.
const cpu = new Map<string, { total: number; idle: number }>();
let cpuRaw: number[] | undefined;
for (const l of cpuS.split("\n")) {
const name = l.split(/\s+/)[0];
if (!name.startsWith("cpu")) continue;
if (name === "cpu") cpuRaw = l.split(/\s+/).slice(1, 9).map(Number);
const c = parseCpuLine(
l
.replace(/^cpu\d*/, "cpu")
.split(/\s+/)
.slice(0, 9)
.join(" "),
);
if (c) cpu.set(name, c);
}
// Matched by name, so an offline core does not shift the ones after it.
const pct = (name: string) => {
const p = prev?.cpu.get(name);
const c = cpu.get(name);
if (!p || !c || c.total <= p.total) return null;
const used = c.total - p.total - (c.idle - p.idle);
return Math.max(0, Math.min(100, (used / (c.total - p.total)) * 100));
};
// Where the busy time went since the last sample, as percent of all ticks.
let cpuMix: LiveSample["cpuMix"];
if (prev?.cpuRaw && cpuRaw && cpuRaw.length === 8) {
const d = cpuRaw.map((v, i) => v - (prev.cpuRaw?.[i] ?? 0));
const total = d.reduce((a, b) => a + b, 0);
if (total > 0 && d.every((v) => v >= 0)) {
const p = (n: number) => (n / total) * 100;
cpuMix = {
user: p(d[0] + d[1]),
system: p(d[2] + d[5] + d[6]),
iowait: p(d[4]),
steal: p(d[7]),
};
}
}
const mem: Record<string, number> = {};
for (const l of memS.split("\n")) {
const m = l.match(/^(\w+):\s+(\d+)/);
if (m) mem[m[1]] = Number(m[2]);
}
const [la = "", up = ""] = loadS.trim().split("\n");
const laParts = la.split(/\s+/).map(Number);
const uptime = Number(up.split(/\s+/)[0]) || 0;
const nets = new Map<string, { rx: number; tx: number }>();
for (const l of netS.split("\n")) {
const [name, rest] = l.split(":");
if (!rest || name.trim() === "lo") continue;
const f = rest.trim().split(/\s+/);
nets.set(name.trim(), { rx: Number(f[0]) || 0, tx: Number(f[8]) || 0 });
}
// A reboot makes uptime go backwards, so rates start again.
const dt = prev && uptime > prev.uptime ? uptime - prev.uptime : 0;
const rate = (now: number, before: number | undefined) =>
dt > 0 && before !== undefined && now >= before
? (now - before) / dt
: null;
const pageBytes = Number(pageS.trim()) || prev?.pageBytes || 4096;
const pageKb = pageBytes / 1024;
const aggDelta =
prev && cpu.has("cpu") && prev.cpu.has("cpu")
? cpu.get("cpu")!.total - prev.cpu.get("cpu")!.total
: 0;
const cores = [...cpu.keys()].filter((k) => k !== "cpu");
const ncpu = Math.max(1, cores.length);
const ticks = new Map<string, number>();
const procs: LiveSample["procs"] = [];
for (const l of procS.split("\n")) {
const [pid, t, rss, start, state, ppid, ...rest] = l.split("\t");
const name = rest.join("\t");
if (!name) continue;
const key = pid + ":" + start;
const tk = Number(t) || 0;
ticks.set(key, tk);
const before = prev?.ticks.get(key);
procs.push({
pid: Number(pid),
name,
cpu:
before !== undefined && aggDelta > 0 && tk >= before
? ((tk - before) / aggDelta) * ncpu * 100
: 0,
memKb: (Number(rss) || 0) * pageKb,
state: state || "?",
ppid: Number(ppid) || 0,
});
}
procs.sort((a, b) => b.cpu - a.cpu || b.memKb - a.memKb);
// Awk exits 0 when /proc is missing (macOS, restricted containers); that is not a sample.
if (!cpu.has("cpu") || !mem.MemTotal || !uptime) {
throw new Error("live sample unavailable: /proc data missing");
}
const memTotalKb = mem.MemTotal;
const swapTotalKb = mem.SwapTotal ?? 0;
let rxTotal = 0;
let txTotal = 0;
for (const c of nets.values()) {
rxTotal += c.rx;
txTotal += c.tx;
}
return {
sample: {
cpu: pct("cpu"),
cores: cores.map(pct),
coreIds: cores.map((k) => k.slice(3)),
cpuMix,
memTotalKb,
memCachedKb: (mem.Buffers ?? 0) + (mem.Cached ?? 0),
memFreeKb: mem.MemFree ?? 0,
memUsedKb: memTotalKb - (mem.MemAvailable ?? 0),
swapTotalKb,
swapUsedKb: swapTotalKb - (mem.SwapFree ?? 0),
load: [laParts[0] || 0, laParts[1] || 0, laParts[2] || 0],
net: [...nets].map(([name, c]) => ({
name,
rxBps: rate(c.rx, prev?.net.get(name)?.rx),
txBps: rate(c.tx, prev?.net.get(name)?.tx),
})),
count: procs.length,
running: procs.filter((p) => p.state === "R").length,
procs: procs.slice(0, TOP_N),
uptime,
netTotals: { rx: rxTotal, tx: txTotal },
},
next: { at, uptime, pageBytes, cpu, cpuRaw, ticks, net: nets },
};
}
// Disks
const DARWIN_DATA_MOUNT = "/System/Volumes/Data";
const PSEUDO_FS_RE =
/^(tmpfs|devtmpfs|overlay|udev|none|shm|squashfs|efivarfs)$/;
/** GNU df sizes in KiB, without the header. */
export const DF_BYTES_COMMAND = "df -TkP | tail -n +2";
/** GNU df sizes as short human text, without the header. */
export const DF_HUMAN_COMMAND = "df -hT -P | tail -n +2";
export interface DfRow {
filesystem: string;
type: string;
mount: string;
parts: string[];
}
export interface DiskFilesystem {
filesystem: string;
type: string;
mount: string;
percent: number | null;
usedHuman: string | null;
totalHuman: string | null;
availableHuman: string | null;
usedBytes: number | null;
totalBytes: number | null;
availableBytes: number | null;
label?: string;
}
export interface MonitoredMount {
path: string;
label?: string;
}
function toFixedNum(n: number | null | undefined, digits = 2): number | null {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
}
/**
* Parses `df -T -P` style output: Filesystem, Type, then the size columns,
* with Mounted-on last. Pseudo filesystems are left out.
*/
export function parseDfLines(output: string): DfRow[] {
return output
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.map((line) => {
const parts = line.split(/\s+/);
return {
filesystem: parts[0] || "",
type: parts[1] || "",
mount: parts[6] || "",
parts,
};
})
.filter((row) => row.parts.length >= 7 && !PSEUDO_FS_RE.test(row.type));
}
/** The most used real filesystem in a `df -TkP` row set. */
export function findWorstMountIndex(bytesRows: DfRow[]): {
index: number;
usedBytes: number;
totalBytes: number;
} {
let worstIndex = -1;
let worstUsedBytes = -1;
let worstTotalBytes = 0;
bytesRows.forEach((row, index) => {
const totalBytes = Number(row.parts[2]);
const usedBytes = Number(row.parts[3]);
if (
!Number.isFinite(totalBytes) ||
!Number.isFinite(usedBytes) ||
totalBytes <= 0
) {
return;
}
const usedRatio = usedBytes / totalBytes;
const worstRatio =
worstTotalBytes > 0 ? worstUsedBytes / worstTotalBytes : -1;
if (usedRatio > worstRatio) {
worstIndex = index;
worstUsedBytes = usedBytes;
worstTotalBytes = totalBytes;
}
});
return {
index: worstIndex,
usedBytes: worstUsedBytes,
totalBytes: worstTotalBytes,
};
}
/**
* Merges `df -TkP` size rows (parts[2..4] in `blockSizeBytes` units) and
* `df -hT -P` human rows into one list. Human rows only supply the display
* text, matched by mount point so a mismatched row count can't shift them.
*/
export function buildFilesystemList(
bytesRows: DfRow[],
humanRows: DfRow[],
blockSizeBytes = 1,
): DiskFilesystem[] {
const aligned = humanRows.length === bytesRows.length;
return bytesRows
.map((row, index) => {
const totalBytes = Number(row.parts[2]) * blockSizeBytes;
const usedBytes = Number(row.parts[3]) * blockSizeBytes;
const availableBytes = Number(row.parts[4]) * blockSizeBytes;
if (!Number.isFinite(totalBytes) || totalBytes <= 0) return null;
const humanRow = aligned
? humanRows[index]
: humanRows.find((h) => h.mount === row.mount);
const percent = Number.isFinite(usedBytes)
? Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100))
: null;
return {
filesystem: row.filesystem,
type: row.type,
mount: row.mount,
percent: toFixedNum(percent, 0),
usedHuman: humanRow?.parts[3] || null,
totalHuman: humanRow?.parts[2] || null,
availableHuman: humanRow?.parts[4] || null,
usedBytes: Number.isFinite(usedBytes) ? usedBytes : null,
totalBytes,
availableBytes: Number.isFinite(availableBytes) ? availableBytes : null,
};
})
.filter((fs): fs is DiskFilesystem => fs !== null);
}
/**
* The headline disk is the root filesystem. On macOS "/" is the small sealed
* system volume, so the data volume wins there. With no root mount the most
* used one is shown.
*/
export function selectPrimaryFilesystem(
filesystems: DiskFilesystem[],
): DiskFilesystem | null {
if (filesystems.length === 0) return null;
const root =
filesystems.find((fs) => fs.mount === DARWIN_DATA_MOUNT) ??
filesystems.find((fs) => fs.mount === "/");
if (root) return root;
let best = filesystems[0];
for (const fs of filesystems) {
const ratio = (fs.usedBytes ?? 0) / (fs.totalBytes || 1);
const bestRatio = (best.usedBytes ?? 0) / (best.totalBytes || 1);
if (ratio > bestRatio) best = fs;
}
return best;
}
/**
* Drops mounts by exact path ("/mnt/nas") or by filesystem type substring
* ("nfs" matches nfs and nfs4), so network shares can be hidden.
*/
export function filterExcludedFilesystems(
filesystems: DiskFilesystem[],
excludedMounts?: string[] | null,
): DiskFilesystem[] {
if (!excludedMounts || excludedMounts.length === 0) return filesystems;
const normalized = excludedMounts
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
if (normalized.length === 0) return filesystems;
return filesystems.filter((fs) => {
const mount = fs.mount.toLowerCase();
const type = fs.type.toLowerCase();
return !normalized.some(
(entry) => mount === entry || (type && type.includes(entry)),
);
});
}
export function mergeMonitoredFilesystems(
filesystems: DiskFilesystem[],
monitored: MonitoredMount[],
customFilesystems: Array<DiskFilesystem | null>,
): DiskFilesystem[] {
const result = [...filesystems];
monitored.forEach((entry, index) => {
const path = entry.path.trim();
const custom = customFilesystems[index];
if (!path || !custom) return;
const existing = result.find((fs) => fs.mount === path);
if (existing) {
existing.label = entry.label?.trim() || undefined;
return;
}
result.push({
...custom,
mount: path,
label: entry.label?.trim() || undefined,
});
});
return result;
}
/** Host name, OS, kernel and arch in one exec, as key=value lines. */
export const SYSTEM_INFO_COMMAND = `echo "hostname=$(hostname 2>/dev/null || cat /etc/hostname 2>/dev/null)"; echo "os=$( . /etc/os-release 2>/dev/null; echo "$PRETTY_NAME")"; echo "kernel=$(uname -r 2>/dev/null)"; echo "arch=$(uname -m 2>/dev/null)"`;
@@ -1,94 +0,0 @@
/** OpenRC scripts, with states mapped onto the words systemd uses. */
import { isOpenRcName, type UnitAction, type UnitSummary } from "./systemd.js";
import { HostScriptError, shellQuote as q } from "./text.js";
export const OPENRC_LIST_SCRIPT = `
rc-status --servicelist 2>/dev/null
echo "@@TERMIX_RUNLEVELS@@"
rc-update show -v 2>/dev/null
exit 0
`;
function activeOf(state: string): { active: string; sub: string } {
switch (state) {
case "started":
return { active: "active", sub: "running" };
case "crashed":
return { active: "failed", sub: "crashed" };
case "starting":
return { active: "activating", sub: "starting" };
case "stopping":
return { active: "deactivating", sub: "stopping" };
case "inactive":
case "hotplugged":
return { active: "inactive", sub: state };
default:
return { active: "inactive", sub: state || "stopped" };
}
}
export function parseOpenRcList(output: string): UnitSummary[] {
const [list = "", levels = ""] = output.split("@@TERMIX_RUNLEVELS@@");
const units = new Map<string, UnitSummary>();
for (const line of list.split("\n")) {
const match = /^\s*(\S+)\s+\[\s*([a-z ]+?)\s*\]/.exec(line);
if (!match) continue;
const { active, sub } = activeOf(match[2]);
units.set(match[1], {
name: match[1],
type: "service",
load: "loaded",
active,
sub,
description: "",
fileState: "disabled",
preset: "",
});
}
for (const line of levels.split("\n")) {
const match = /^\s*(\S+)\s*\|\s*(.*)$/.exec(line);
if (!match) continue;
const runlevels = match[2].trim();
const known = units.get(match[1]) ?? {
name: match[1],
type: "service",
load: "loaded",
active: "inactive",
sub: "stopped",
description: "",
fileState: "disabled",
preset: "",
};
known.fileState = runlevels ? "enabled" : "disabled";
known.description = runlevels ? `Runlevels: ${runlevels}` : "";
units.set(match[1], known);
}
return [...units.values()].sort((a, b) => a.name.localeCompare(b.name));
}
function requireName(name: string): string {
if (!isOpenRcName(name))
throw new HostScriptError("That is not a valid service name");
return name;
}
export function openRcActionScript(name: string, action: UnitAction): string {
const s = q(requireName(name));
switch (action) {
case "start":
case "stop":
case "restart":
return `rc-service ${s} ${action} 2>&1 || { echo "SCRIPT_ERR:rc-service ${action} failed"; exit 1; }\n`;
case "enable":
return `rc-update add ${s} default 2>&1 || { echo "SCRIPT_ERR:rc-update add failed"; exit 1; }\n`;
case "disable":
return `rc-update del ${s} 2>&1 || { echo "SCRIPT_ERR:rc-update del failed"; exit 1; }\n`;
default:
throw new HostScriptError("OpenRC can't do that", 400);
}
}
export function openRcStatusScript(name: string): string {
const s = q(requireName(name));
return `rc-service ${s} status 2>&1; rc-update show -v 2>/dev/null | grep -E '^ *'${s}' *\\|'; exit 0\n`;
}
@@ -1,117 +0,0 @@
/**
* How a script reaches the host: the command line runs a shell that reads
* the script from stdin (`bash -s`), so names and values in a script are
* never parsed out of argv.
*/
import { shellQuote, toBase64 } from "./text.js";
// systemctl and friends often live in sbin, which a normal login skips.
// TZ=UTC so tools print every time in UTC, which parses the same anywhere.
const ENV = "SYSTEMD_COLORS=0 SYSTEMD_PAGER=cat LC_ALL=C TZ=UTC";
/** Puts sbin on PATH and sets a plain, UTC environment. */
export const SCRIPT_PRELUDE = `export PATH="$PATH:/usr/sbin:/sbin:/usr/local/sbin"; export ${ENV}; `;
const SUDO = `sudo -n env PATH="$PATH" ${ENV}`;
/**
* - `plain`: the login user.
* - `auto`: root when it can be had without a password, else the login user.
* - `root`: root or passwordless sudo, fails on anything else.
* - `root-only`: root logins only, for hosts where sudo is turned off.
*/
export type ScriptShellMode = "plain" | "auto" | "root" | "root-only";
const ERR = "SCRIPT_ERR:";
/** The command line that runs a script sent on stdin. */
export function scriptShell(mode: ScriptShellMode): string {
switch (mode) {
case "plain":
return `${SCRIPT_PRELUDE}exec bash -s`;
case "auto":
return `${SCRIPT_PRELUDE}if [ "$(id -u)" = "0" ]; then exec bash -s; elif sudo -n true 2>/dev/null; then exec ${SUDO} bash -s; else exec bash -s; fi`;
case "root":
return `${SCRIPT_PRELUDE}if [ "$(id -u)" = "0" ]; then exec bash -s; else exec ${SUDO} bash -s; fi`;
case "root-only":
return `${SCRIPT_PRELUDE}if [ "$(id -u)" = "0" ]; then exec bash -s; else echo "${ERR}This needs root, or turn on Use sudo for this host"; exit 1; fi`;
}
}
/**
* Runs a script as root with a sudo password. The command is `plain`; all of
* the returned stdin goes to the host. The password is read by the first
* line into a shell variable and piped to `sudo -S`, so it is never in argv,
* never in the environment and never run as a command. Root logins and
* passwordless sudo skip the password. The script itself travels as base64.
*/
export function passwordSudoScript(
script: string,
password: string,
): { command: string; stdin: string } {
if (/[\r\n\0]/.test(password)) {
throw new Error("A sudo password can't contain a line break");
}
const run = `bash -c "$TERMIX_S"`;
const stdin = `IFS= read -r TERMIX_PW
${password}
TERMIX_S=$(printf '%s' ${shellQuote(toBase64(script))} | base64 -d)
if [ "$(id -u)" = "0" ]; then unset TERMIX_PW; exec ${run}
elif sudo -n true 2>/dev/null; then unset TERMIX_PW; exec ${SUDO} ${run}
else P=$TERMIX_PW; unset TERMIX_PW; printf '%s\\n' "$P" | sudo -k -S -p '' env PATH="$PATH" ${ENV} ${run}; exit $?
fi
`;
return { command: scriptShell("plain"), stdin };
}
/** sudo asked for a password or a terminal it did not get. */
export const SUDO_REFUSED_RE =
/password is required|a terminal is required|no tty present/i;
/** sudo got a password and it was wrong. */
export const SUDO_WRONG_RE =
/incorrect password|sorry, try again|no password was provided/i;
/** The user may not use sudo at all. */
export const NOT_SUDOER_RE =
/is not in the sudoers file|not allowed to run sudo/i;
/**
* Scripts print `SCRIPT_ERR:` lines (or the older `SERVICES_ERR:`) before a
* non zero exit to explain a failure.
*/
export function scriptError(stdout: string): string {
return stdout
.split("\n")
.map((line) => {
if (line.startsWith(ERR)) return line.slice(ERR.length);
if (line.startsWith("SERVICES_ERR:")) return line.slice(13);
return null;
})
.filter((line): line is string => line !== null)
.join("\n")
.trim();
}
/** A line a script prints to explain why it stops. */
export function errLine(message: string): string {
return `echo ${shellQuote(ERR + message)}`;
}
/**
* Runs a long command in the background and stops it once stdin closes,
* which happens when the SSH channel ends. A pty can't be trusted for this:
* sudo keeps its own and the command would outlive the viewer.
*/
export function watchdog(command: string): string {
return `${command} &
P=$!
trap 'kill $P 2>/dev/null' EXIT
cat >/dev/null
`;
}
/** The wrapper a streamed command runs in, as one shell command line. */
export function streamCommand(script: string, sudo: boolean): string {
const quoted = shellQuote(`${SCRIPT_PRELUDE}${script}`);
return sudo
? `if [ "$(id -u)" = "0" ]; then exec bash -c ${quoted}; elif sudo -n true 2>/dev/null; then exec ${SUDO} bash -c ${quoted}; else exec bash -c ${quoted}; fi`
: `exec bash -c ${quoted}`;
}
@@ -1,389 +0,0 @@
/**
* systemd unit scripts and parsers, plus init system detection.
* Every script is meant for `scriptShell()`, sent on stdin.
*/
import { HostScriptError, parseKeys, shellQuote as q } from "./text.js";
export const UNIT_TYPES = [
"service",
"socket",
"timer",
"target",
"mount",
"automount",
"path",
"swap",
"slice",
"scope",
"device",
] as const;
export type UnitType = (typeof UNIT_TYPES)[number];
// Backslash is allowed for escaped names like dev-disk-by\x2duuid.
const UNIT_RE = new RegExp(
`^[A-Za-z0-9_][A-Za-z0-9:_.@\\\\-]{0,250}\\.(${UNIT_TYPES.join("|")})$`,
);
const OPENRC_RE = /^[A-Za-z0-9_][A-Za-z0-9_.@-]{0,127}$/;
export function isUnit(name: unknown): name is string {
return typeof name === "string" && UNIT_RE.test(name);
}
export function unitType(name: string): UnitType {
return name.slice(name.lastIndexOf(".") + 1) as UnitType;
}
/** Units a file may be written for. Templates and device style names are out. */
export function isWritableUnitName(name: string): boolean {
return (
isUnit(name) &&
!name.includes("\\") &&
["service", "socket", "timer", "target", "path", "mount"].includes(
unitType(name),
)
);
}
export function isOpenRcName(name: unknown): name is string {
return typeof name === "string" && OPENRC_RE.test(name);
}
export function requireUnit(name: unknown): string {
if (!isUnit(name)) throw new HostScriptError("That is not a valid unit name");
return name;
}
export type InitSystem = "systemd" | "openrc" | "none";
/** A unit name or, on OpenRC, a service name. */
export function requireService(name: unknown, init: string): string {
if (init === "openrc") {
if (!isOpenRcName(name))
throw new HostScriptError("That is not a valid service name");
return name;
}
return requireUnit(name);
}
export interface UnitSummary {
name: string;
type: string;
load: string;
active: string;
sub: string;
description: string;
/** enabled, disabled, static, masked and so on. Empty when unknown. */
fileState: string;
preset: string;
}
export interface UnitDetail {
name: string;
props: Record<string, string>;
status: string;
}
export interface TimerView {
name: string;
description: string;
active: string;
fileState: string;
/** Unix ms, or null when not set. */
next: number | null;
last: number | null;
activates: string;
schedule: string[];
}
export const UNIT_ACTIONS = [
"start",
"stop",
"restart",
"reload",
"enable",
"disable",
"enable-now",
"disable-now",
"mask",
"unmask",
"reset-failed",
"revert",
] as const;
export type UnitAction = (typeof UNIT_ACTIONS)[number];
/** Actions OpenRC can do. */
export const OPENRC_ACTIONS: readonly UnitAction[] = [
"start",
"stop",
"restart",
"enable",
"disable",
];
export const DETECT_SCRIPT = `
INIT=none
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then INIT=systemd
elif command -v rc-service >/dev/null 2>&1; then INIT=openrc; fi
echo "init=$INIT"
if [ "$INIT" = systemd ]; then echo "version=$(systemctl --version 2>/dev/null | head -n1 | awk '{print $2}')"; fi
if [ "$INIT" = openrc ]; then echo "version=$(openrc --version 2>/dev/null | head -n1 | awk '{print $NF}')"; fi
echo "os=$( . /etc/os-release 2>/dev/null; echo "$PRETTY_NAME")"
echo "hostname=$(hostname 2>/dev/null || cat /etc/hostname 2>/dev/null)"
echo "uid=$(id -u)"
if command -v journalctl >/dev/null 2>&1; then echo "journal=1"; fi
if [ "$INIT" = systemd ]; then echo "failed=$(systemctl list-units --state=failed --plain --no-legend --no-pager 2>/dev/null | wc -l)"; fi
if [ "$INIT" = openrc ]; then echo "failed=$(rc-status --crashed 2>/dev/null | wc -l)"; fi
exit 0
`;
export interface Detected {
init: InitSystem;
version: string;
os: string;
hostname: string;
root: boolean;
journal: boolean;
failed: number;
}
export function parseDetect(output: string): Detected {
const keys = parseKeys(output);
const init =
keys.init === "systemd" || keys.init === "openrc" ? keys.init : "none";
return {
init,
version: keys.version ?? "",
os: keys.os ?? "",
hostname: keys.hostname ?? "",
root: keys.uid === "0",
journal: keys.journal === "1",
failed: Number(keys.failed) || 0,
};
}
/** Types the list can show. */
export const LIST_TYPES = [
"service",
"socket",
"timer",
"target",
"path",
"mount",
] as const;
export type ListType = (typeof LIST_TYPES)[number] | "all";
export function listUnitsScript(type: ListType): string {
const types = type === "all" ? LIST_TYPES.join(",") : type;
if (type !== "all" && !LIST_TYPES.includes(type))
throw new HostScriptError("Unknown unit type");
return `
systemctl list-units --all --plain --no-legend --no-pager --full --type=${types} 2>/dev/null
echo "@@TERMIX_FILES@@"
systemctl list-unit-files --plain --no-legend --no-pager --full --type=${types} 2>/dev/null
exit 0
`;
}
/** Merges list-units and list-unit-files. Template units are left out. */
export function parseUnitList(output: string): UnitSummary[] {
const [loaded = "", files = ""] = output.split("@@TERMIX_FILES@@");
const units = new Map<string, UnitSummary>();
for (const raw of loaded.split("\n")) {
const line = raw.replace(/^[●*\s]+/, "").trimEnd();
if (!line) continue;
const match = /^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s*(.*)$/.exec(line);
if (!match) continue;
const [, name, load, active, sub, description] = match;
if (!name.includes(".") || name.includes("@.")) continue;
units.set(name, {
name,
type: unitType(name),
load,
active,
sub,
description: description.trim(),
fileState: "",
preset: "",
});
}
for (const raw of files.split("\n")) {
const line = raw.trim();
if (!line) continue;
const [name, state = "", preset = ""] = line.split(/\s+/);
if (!name || !name.includes(".") || name.includes("@.")) continue;
const known = units.get(name);
const cleanPreset = preset === "-" ? "" : preset;
if (known) {
known.fileState = state;
known.preset = cleanPreset;
} else {
units.set(name, {
name,
type: unitType(name),
load: state === "masked" ? "masked" : "unloaded",
active: "inactive",
sub: "dead",
description: "",
fileState: state,
preset: cleanPreset,
});
}
}
return [...units.values()].sort((a, b) => a.name.localeCompare(b.name));
}
export const DETAIL_PROPS = [
"Id",
"Description",
"LoadState",
"ActiveState",
"SubState",
"UnitFileState",
"UnitFilePreset",
"Result",
"MainPID",
"ExecMainStartTimestamp",
"ActiveEnterTimestamp",
"InactiveEnterTimestamp",
"MemoryCurrent",
"MemoryPeak",
"CPUUsageNSec",
"TasksCurrent",
"NRestarts",
"FragmentPath",
"DropInPaths",
"Requires",
"Wants",
"After",
"WantedBy",
"TriggeredBy",
"Triggers",
"User",
"Group",
"Restart",
"Type",
"ExecStart",
"CanStart",
"CanStop",
"CanReload",
] as const;
export function detailScript(unit: string): string {
const props = DETAIL_PROPS.map((p) => `-p ${p}`).join(" ");
return `
systemctl show --no-pager ${props} -- ${q(requireUnit(unit))}
echo "@@TERMIX_STATUS@@"
systemctl status --no-pager --full -n 0 -- ${q(unit)} 2>&1
exit 0
`;
}
export function parseDetail(unit: string, output: string): UnitDetail {
const [props = "", status = ""] = output.split("@@TERMIX_STATUS@@");
return { name: unit, props: parseKeys(props), status: status.trim() };
}
const VERBS: Record<UnitAction, string> = {
start: "start",
stop: "stop",
restart: "restart",
reload: "reload",
enable: "enable",
disable: "disable",
"enable-now": "enable --now",
"disable-now": "disable --now",
mask: "mask",
unmask: "unmask",
"reset-failed": "reset-failed",
revert: "revert",
};
/** The systemctl call for an action. Prints `state=` after it. */
export function actionScript(unit: string, action: UnitAction): string {
const verb = VERBS[action];
if (!verb) throw new HostScriptError("Unknown action");
const u = q(requireUnit(unit));
return `
systemctl ${verb} -- ${u} 2>&1 || { echo "SCRIPT_ERR:systemctl ${verb} failed"; exit 1; }
echo "state=$(systemctl is-active -- ${u} 2>/dev/null)"
`;
}
/** Times systemctl prints under TZ=UTC, like "Thu 2026-10-22 02:03:51 UTC". */
export function parseSystemdTime(value: string | undefined): number | null {
if (!value) return null;
const match = /(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/.exec(value);
if (!match) return null;
const [, y, mo, d, h, mi, s] = match.map(Number);
return Date.UTC(y, mo - 1, d, h, mi, s);
}
export const TIMERS_SCRIPT = `
UNITS=$( { systemctl list-units --all --type=timer --plain --no-legend --no-pager --full 2>/dev/null | awk '{print $1}'; systemctl list-unit-files --type=timer --plain --no-legend --no-pager --full 2>/dev/null | awk '{print $1}'; } | grep '\\.timer$' | grep -v '@\\.timer$' | sort -u)
[ -n "$UNITS" ] || exit 0
systemctl show --no-pager -p Id -p Description -p ActiveState -p UnitFileState -p NextElapseUSecRealtime -p LastTriggerUSec -p Triggers -p TimersCalendar -p TimersMonotonic -- $UNITS
exit 0
`;
/** Reads systemctl show output for many units, split by blank lines. */
export function parseShowBlocks(output: string): Array<Record<string, string>> {
const blocks: Array<Record<string, string>> = [];
for (const block of output.split(/\n\s*\n/)) {
const lines = block.split("\n").filter((line) => line.includes("="));
if (!lines.length) continue;
// A unit can have several TimersCalendar lines; keep all of them.
const keys: Record<string, string> = {};
for (const line of lines) {
const eq = line.indexOf("=");
const key = line.slice(0, eq).trim();
const value = line.slice(eq + 1).trim();
keys[key] = key in keys ? `${keys[key]}\n${value}` : value;
}
blocks.push(keys);
}
return blocks;
}
function schedules(value: string | undefined): string[] {
if (!value) return [];
const out: string[] = [];
for (const line of value.split("\n")) {
const match = /\{\s*(\w+)=(.+?)\s*;/.exec(line);
if (match) out.push(`${match[1]}=${match[2]}`);
}
return out;
}
export function parseTimers(output: string): TimerView[] {
return parseShowBlocks(output)
.filter((keys) => keys.Id?.endsWith(".timer"))
.map((keys) => ({
name: keys.Id,
description: keys.Description ?? "",
active: keys.ActiveState ?? "",
fileState: keys.UnitFileState ?? "",
next: parseSystemdTime(keys.NextElapseUSecRealtime),
last: parseSystemdTime(keys.LastTriggerUSec),
activates: (keys.Triggers ?? "").split(/\s+/)[0] ?? "",
schedule: [
...schedules(keys.TimersCalendar),
...schedules(keys.TimersMonotonic),
],
}))
.sort((a, b) => (a.next ?? Infinity) - (b.next ?? Infinity));
}
/** One line per unit from systemctl is-active, in the order asked. */
export function isActiveScript(units: string[]): string {
return `systemctl is-active -- ${units.map((u) => q(requireUnit(u))).join(" ")} 2>/dev/null; exit 0\n`;
}
export function parseIsActive(
units: string[],
output: string,
): Map<string, string> {
const lines = output.split("\n").map((line) => line.trim());
const out = new Map<string, string>();
units.forEach((unit, i) => out.set(unit, lines[i] || "unknown"));
return out;
}
@@ -1,118 +0,0 @@
/**
* Quoting, encoding and the error type the host scripts share.
*
* No Node APIs here: this entry also runs in the mobile app.
*/
/** A bad input to a host script builder. `status` is the HTTP status to answer with. */
export class HostScriptError extends Error {
constructor(
message: string,
readonly status = 400,
readonly extra: Record<string, unknown> = {},
) {
super(message);
this.name = "HostScriptError";
}
}
/** Wraps a value in single quotes for a POSIX shell. */
export function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
// Written by hand because some JS engines (Hermes) have no TextDecoder.
/** UTF-8 bytes of a string. */
export function utf8Encode(text: string): Uint8Array {
const out: number[] = [];
for (const char of text) {
const cp = char.codePointAt(0) ?? 0xfffd;
if (cp < 0x80) out.push(cp);
else if (cp < 0x800) out.push(0xc0 | (cp >> 6), 0x80 | (cp & 63));
else if (cp < 0x10000) {
out.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 63), 0x80 | (cp & 63));
} else {
out.push(
0xf0 | (cp >> 18),
0x80 | ((cp >> 12) & 63),
0x80 | ((cp >> 6) & 63),
0x80 | (cp & 63),
);
}
}
return Uint8Array.from(out);
}
/** A string from UTF-8 bytes. Bad sequences become U+FFFD. */
export function utf8Decode(bytes: ArrayLike<number>): string {
let out = "";
let i = 0;
while (i < bytes.length) {
const b = bytes[i];
let need = 0;
let cp = 0;
if (b < 0x80) cp = b;
else if (b >= 0xc2 && b < 0xe0) [need, cp] = [1, b & 31];
else if (b >= 0xe0 && b < 0xf0) [need, cp] = [2, b & 15];
else if (b >= 0xf0 && b < 0xf5) [need, cp] = [3, b & 7];
else {
out += "\ufffd";
i++;
continue;
}
let j = 1;
for (; j <= need; j++) {
const next = bytes[i + j];
if (next === undefined || (next & 0xc0) !== 0x80) break;
cp = (cp << 6) | (next & 63);
}
const tooSmall = [0, 0x80, 0x800, 0x10000][need];
if (
j <= need ||
cp < tooSmall ||
cp > 0x10ffff ||
(cp >= 0xd800 && cp < 0xe000)
) {
out += "\ufffd";
i += Math.max(1, j);
continue;
}
out += String.fromCodePoint(cp);
i += need + 1;
}
return out;
}
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/** Base64 of the UTF-8 bytes of a string, for `base64 -d` on the host. */
export function toBase64(text: string): string {
const bytes = utf8Encode(text);
let out = "";
for (let i = 0; i < bytes.length; i += 3) {
const a = bytes[i];
const b = i + 1 < bytes.length ? bytes[i + 1] : 0;
const c = i + 2 < bytes.length ? bytes[i + 2] : 0;
const n = (a << 16) | (b << 8) | c;
out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63];
out += i + 1 < bytes.length ? B64[(n >> 6) & 63] : "=";
out += i + 2 < bytes.length ? B64[n & 63] : "=";
}
return out;
}
/** Reads key=value lines. Only the first = splits a line. */
export function parseKeys(output: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of output.split("\n")) {
const eq = line.indexOf("=");
if (eq > 0) out[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
}
return out;
}
/** Trims output and keeps it short enough to show. */
export function scrub(text: string, max = 600): string {
return text.trim().slice(0, max);
}
+1 -176
View File
@@ -12,11 +12,6 @@
import semver from "semver"; import semver from "semver";
import { isKnownCapability } from "./capabilities.js"; import { isKnownCapability } from "./capabilities.js";
import {
checkOperationReferences,
validateOperations,
type PluginOperationsContribution,
} from "./operations.js";
import { import {
ENV_NAME_PATTERN, ENV_NAME_PATTERN,
MAX_ENV_DESCRIPTION_LENGTH, MAX_ENV_DESCRIPTION_LENGTH,
@@ -42,7 +37,7 @@ export {
* "^1.2" needs 1.2 or later). engine.termix is checked against the core * "^1.2" needs 1.2 or later). engine.termix is checked against the core
* version by the server. * version by the server.
*/ */
export const PLUGIN_API_VERSION = "1.14.0"; export const PLUGIN_API_VERSION = "1.12.0";
/** The API major, the default engine.api for a new plugin. */ /** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String( export const SUPPORTED_PLUGIN_API_VERSION = String(
@@ -470,10 +465,6 @@ export interface PluginContributions {
tabs?: PluginTabContribution[]; tabs?: PluginTabContribution[];
panels?: PluginViewContribution[]; panels?: PluginViewContribution[];
dashboardCards?: PluginViewContribution[]; dashboardCards?: PluginViewContribution[];
/** Cards on the host page, filled with app.registerHostSection. */
hostSections?: PluginViewContribution[];
/** What this plugin can do, for every surface to find on its own. */
operations?: PluginOperationsContribution;
/** /**
* The frontend also runs on anonymous guest pages (shared-session and * The frontend also runs on anonymous guest pages (shared-session and
* collab links), where it is activated with app.guest set. * collab links), where it is activated with app.guest set.
@@ -535,36 +526,6 @@ export interface PluginContributions {
layouts?: PluginLayoutContribution[]; layouts?: PluginLayoutContribution[];
/** Themes. Needs the ui:theme capability. */ /** Themes. Needs the ui:theme capability. */
themes?: PluginThemeContribution[]; themes?: PluginThemeContribution[];
/** How the plugin shows in the Termix mobile app. Plugin API 1.13. */
mobile?: PluginMobileContribution;
}
/**
* - "webview": the app shows the plugin's UI from the server in a WebView.
* - "native": the app has its own screens for this plugin.
* - "none": nothing to show in the app.
*/
export const MOBILE_MODES = ["webview", "native", "none"] as const;
export type PluginMobileMode = (typeof MOBILE_MODES)[number];
export const MOBILE_SIZES = ["phone", "tablet"] as const;
export type PluginMobileSize = (typeof MOBILE_SIZES)[number];
export interface PluginMobileContribution {
mode: PluginMobileMode;
/**
* webview: a bundle built for the app, in the same folder as the frontend
* bundle. Unset means the normal frontend runs in a phone sized frame.
*/
entry?: string;
/** webview: screen sizes the UI works at. Defaults to both. */
sizes?: PluginMobileSize[];
/** webview: tab, panel or card ids the app offers. Defaults to every tab. */
views?: string[];
/** native: the app feature that stands in for it. Defaults to the plugin id. */
feature?: string;
/** i18n key in the plugin's namespace, or plain text, shown in the app. */
note?: string;
} }
/** A field a protocol login carries besides username and password. */ /** A field a protocol login carries besides username and password. */
@@ -788,8 +749,6 @@ const ALLOWED_CONTRIBUTES = new Set([
"tabs", "tabs",
"panels", "panels",
"dashboardCards", "dashboardCards",
"hostSections",
"operations",
"guest", "guest",
"guestViews", "guestViews",
"actions", "actions",
@@ -806,7 +765,6 @@ const ALLOWED_CONTRIBUTES = new Set([
"protocols", "protocols",
"layouts", "layouts",
"themes", "themes",
"mobile",
]); ]);
const ALLOWED_SETTINGS_FIELD = [ const ALLOWED_SETTINGS_FIELD = [
@@ -988,135 +946,10 @@ export function validateManifest(manifest: unknown): string[] {
typeof m.id === "string" ? m.id : undefined, typeof m.id === "string" ? m.id : undefined,
errors, errors,
); );
if (isPlainObject(m.contributes)) {
validateMobile(
m.contributes,
typeof m.frontend === "string" ? m.frontend : DEFAULT_FRONTEND_ENTRY,
errors,
);
}
return errors; return errors;
} }
function folderOf(file: string): string {
const parts = file.split("/").filter((part) => part !== "" && part !== ".");
return parts.slice(0, -1).join("/");
}
function validateMobile(
contributes: Record<string, unknown>,
frontend: string,
errors: string[],
): void {
const mobile = contributes.mobile;
if (mobile === undefined) return;
const where = "contributes.mobile";
if (!isPlainObject(mobile)) {
errors.push(`Field "${where}" must be an object`);
return;
}
rejectUnknown(
mobile,
["mode", "entry", "sizes", "views", "feature", "note"],
`"${where}"`,
errors,
);
const mode = mobile.mode;
if (!(MOBILE_MODES as readonly unknown[]).includes(mode)) {
errors.push(`${where}.mode must be one of: ${MOBILE_MODES.join(", ")}`);
return;
}
for (const key of ["entry", "sizes", "views"]) {
if (mobile[key] !== undefined && mode !== "webview") {
errors.push(`${where}.${key} is only for mode "webview"`);
}
}
if (mobile.feature !== undefined && mode !== "native") {
errors.push(`${where}.feature is only for mode "native"`);
}
if (
mobile.entry !== undefined &&
requireString(mobile.entry, `${where}.entry`, errors)
) {
const before = errors.length;
requireRelativePath(mobile.entry, `${where}.entry`, errors);
if (!mobile.entry.endsWith(".js")) {
errors.push(`${where}.entry must be a .js file`);
} else if (
errors.length === before &&
folderOf(mobile.entry) !== folderOf(frontend)
) {
// Core only serves the folder the frontend bundle sits in.
errors.push(
`${where}.entry must sit in the same folder as the frontend bundle (${frontend})`,
);
}
}
if (mobile.sizes !== undefined) {
const sizes = mobile.sizes;
if (!Array.isArray(sizes) || sizes.length === 0) {
errors.push(`${where}.sizes must be a non-empty array`);
} else {
const seen = new Set<unknown>();
for (const size of sizes) {
if (!(MOBILE_SIZES as readonly unknown[]).includes(size)) {
errors.push(
`${where}.sizes entries must be one of: ${MOBILE_SIZES.join(", ")}`,
);
} else if (seen.has(size)) {
errors.push(`${where}.sizes duplicates "${String(size)}"`);
}
seen.add(size);
}
}
}
if (mobile.views !== undefined) {
const views = mobile.views;
if (!Array.isArray(views) || views.length === 0) {
errors.push(`${where}.views must be a non-empty array`);
} else {
const declared = new Set<string>();
for (const field of ["tabs", "panels", "dashboardCards"]) {
const list = contributes[field];
if (!Array.isArray(list)) continue;
for (const view of list) {
if (isPlainObject(view) && typeof view.id === "string") {
declared.add(view.id);
}
}
}
const seen = new Set<unknown>();
for (const view of views) {
if (typeof view !== "string" || !declared.has(view)) {
errors.push(
`${where}.views entries must be ids from contributes.tabs, panels or dashboardCards, got: ${JSON.stringify(view)}`,
);
} else if (seen.has(view)) {
errors.push(`${where}.views duplicates "${view}"`);
}
seen.add(view);
}
}
}
if (
mobile.feature !== undefined &&
(typeof mobile.feature !== "string" ||
!LOOK_ID_PATTERN.test(mobile.feature))
) {
errors.push(
`${where}.feature must match ${LOOK_ID_PATTERN} (lowercase letters, digits, dashes)`,
);
}
if (mobile.note !== undefined) {
requireString(mobile.note, `${where}.note`, errors);
}
}
function validateFeatures(features: unknown, errors: string[]): void { function validateFeatures(features: unknown, errors: string[]): void {
if (features === undefined) return; if (features === undefined) return;
if (!Array.isArray(features)) { if (!Array.isArray(features)) {
@@ -1511,8 +1344,6 @@ function validateContributes(
validateTabs(contributes.tabs, errors); validateTabs(contributes.tabs, errors);
validateViews(contributes.panels, "panels", errors); validateViews(contributes.panels, "panels", errors);
validateViews(contributes.dashboardCards, "dashboardCards", errors); validateViews(contributes.dashboardCards, "dashboardCards", errors);
validateViews(contributes.hostSections, "hostSections", errors);
validateOperations(contributes.operations, pluginId, errors);
if ( if (
contributes.guest !== undefined && contributes.guest !== undefined &&
typeof contributes.guest !== "boolean" typeof contributes.guest !== "boolean"
@@ -2635,12 +2466,6 @@ export function parseManifest(raw: unknown): ParsedManifest {
); );
} }
} }
checkOperationReferences(
manifest.contributes?.operations,
manifest.capabilities,
(name) => declared.has(qualifyPermission(manifest.id, name)),
errors,
);
// A settings field may gate on one of this plugin's own permissions, or on // A settings field may gate on one of this plugin's own permissions, or on
// a core/other-plugin id used as given. Only the first form is checkable // a core/other-plugin id used as given. Only the first form is checkable
// here, and an undeclared one would hide the field from every admin. // here, and an undeclared one would hide the field from every admin.
-951
View File
@@ -1,951 +0,0 @@
/**
* The operations contract: what a plugin declares it can do, in one shape every
* surface reads (Automations, Fleets, AI, CLI, Mobile, palette, API, MCP).
*
* Declarations are data in the manifest, so a surface can list and describe an
* action without loading the plugin's code. Handlers are registered at
* activation with ctx.operations.register and must match a declaration.
*
* No Node imports: the frontend, the CLI and Mobile read this file too.
*/
import { coerceSettingValue, validateSettingValue } from "./settings.js";
import type { PluginSettingsField, PluginSettingsOption } from "./manifest.js";
export const OPERATION_RISKS = ["read", "change", "destructive"] as const;
export type OperationRisk = (typeof OPERATION_RISKS)[number];
export const OPERATION_SCOPES = ["host", "resource", "server"] as const;
export type OperationScope = (typeof OPERATION_SCOPES)[number];
export const OPERATION_HOST_ACCESS = [
"connect",
"view",
"edit",
"manage",
] as const;
export type OperationHostAccess = (typeof OPERATION_HOST_ACCESS)[number];
export const OPERATION_DRY_RUN = ["native", "describe"] as const;
/** native: the handler checks ctx.dryRun. describe: core reports its text. */
export type OperationDryRun = (typeof OPERATION_DRY_RUN)[number];
export const OPERATION_FIELD_TYPES = [
"boolean",
"string",
"number",
"select",
"multiselect",
"secret",
"textarea",
"json",
"resource",
] as const;
export type OperationFieldType = (typeof OPERATION_FIELD_TYPES)[number];
export const OPERATION_SEVERITIES = ["info", "warning", "critical"] as const;
export type OperationSeverity = (typeof OPERATION_SEVERITIES)[number];
export const SIGNAL_KINDS = ["count", "gauge", "status", "list"] as const;
export type SignalKind = (typeof SIGNAL_KINDS)[number];
export const SIGNAL_STATUSES = ["ok", "warn", "fail", "unknown"] as const;
export type SignalStatus = (typeof SIGNAL_STATUSES)[number];
export const RESOURCE_COLUMN_TYPES = [
"text",
"status",
"bytes",
"percent",
"time",
"tags",
] as const;
export type ResourceColumnType = (typeof RESOURCE_COLUMN_TYPES)[number];
/** One input or output of an action. A subset of a settings field, plus rules. */
export interface OperationField {
key: string;
type: OperationFieldType;
labelKey?: string;
descriptionKey?: string;
placeholderKey?: string;
default?: unknown;
options?: PluginSettingsOption[];
min?: number;
max?: number;
/** Key of a boolean field in the same list that must be on. */
requires?: string;
group?: string;
required?: boolean;
/** string and textarea only. A regular expression the whole value matches. */
pattern?: string;
/** Read action whose rows fill the choices, for a list known only at run time. */
optionsFrom?: string;
/** type "resource" only: the resource id whose list fills the picker. */
resource?: string;
/** Outputs only: dropped before a run is stored or shown. */
sensitive?: boolean;
}
export interface OperationWhen {
/** Host setting values that must match, such as { enableDocker: true }. */
settings?: Record<string, boolean | string | number>;
/** Lowercase OS names. A host with no known OS passes. */
os?: string[];
}
export interface OperationSurfaces {
/** Offered to the AI assistant. Off unless set. */
assistant?: boolean;
/** Offered over MCP. Off unless set. */
mcp?: boolean;
/** Set false to keep out of the command palette. */
palette?: boolean;
/** Set false to keep out of Automations. */
automations?: boolean;
/** Set false to keep out of the phone app. */
mobile?: boolean;
/** Set false to keep out of the CLI. */
cli?: boolean;
}
export interface OperationAction {
/** `<pluginId>.<name>`. */
id: string;
/** Bump when inputs change in a way saved references cannot follow. */
version?: number;
titleKey: string;
descriptionKey?: string;
icon?: string;
scope: OperationScope;
/** Required for scope "resource": the resource this acts on. */
resource?: string;
inputs?: OperationField[];
outputs?: OperationField[];
risk: OperationRisk;
/** One of this plugin's own permissions, short or full. */
permission: string;
/** Lowest share level. Required for host and resource scopes. */
hostAccess?: OperationHostAccess;
dryRun?: OperationDryRun;
/** A read action of this plugin that proves the result. */
check?: string;
/** An action of this plugin that reverses this one. */
undo?: string;
/** Safe to run twice, so a rollout may retry it. */
idempotent?: boolean;
/** Only one such action per host at a time. */
exclusive?: boolean;
timeoutMs?: number;
when?: OperationWhen;
surfaces?: OperationSurfaces;
}
export interface OperationEvent {
id: string;
titleKey: string;
descriptionKey?: string;
severity?: OperationSeverity;
scope: "host" | "server";
payload?: OperationField[];
}
export interface ResourceColumn {
key: string;
titleKey: string;
type?: ResourceColumnType;
}
export interface OperationResource {
id: string;
titleKey: string;
icon?: string;
/** Read action that returns { rows: [...] } for one host. */
list: string;
/** Row field that is unique on a host. */
key: string;
/** Row field shown as the name. */
name: string;
/** Row field holding a status word. */
status?: string;
/** Row field holding a link, for auto links on the homepage. */
url?: string;
/** Tab type that opens this on a host. */
tab?: string;
columns?: ResourceColumn[];
filters?: string[];
}
export interface OperationSignal {
id: string;
titleKey: string;
/** Read action that returns a SignalResult. */
read: string;
scope: "host" | "server";
kind: SignalKind;
refreshSec?: number;
/** Action offered as the fix when this signal is not ok. */
fix?: string;
}
export interface PluginOperationsContribution {
actions?: OperationAction[];
events?: OperationEvent[];
resources?: OperationResource[];
signals?: OperationSignal[];
/**
* Actions this plugin may run through ctx.operations.invoke: exact ids,
* `<pluginId>.*`, or `*`.
*/
invokes?: string[];
}
/** What a signal's read action returns. */
export interface SignalResult {
value?: number | string | null;
status: SignalStatus;
label?: string;
items?: { label: string; status?: SignalStatus }[];
link?: { tab?: string; data?: Record<string, unknown> };
}
/** What a resource's list action returns. */
export interface ResourceListResult {
rows: Record<string, unknown>[];
}
/** What a handler returns. */
export interface OperationResult {
/** One plain line for history and per host results. */
summary: string;
/** Whether anything actually changed. */
changed?: boolean;
data?: unknown;
/** Input for the `undo` action that reverses this exact run. */
undoInput?: Record<string, unknown>;
}
export const OPERATION_ID_PATTERN = /^[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*$/;
const FIELD_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
const INVOKE_PATTERN = /^(\*|[a-z0-9][a-z0-9-]*\.(\*|[a-z0-9][a-z0-9-]*))$/;
export const MAX_OPERATION_TIMEOUT_MS = 3_600_000;
export const REDACTED_VALUE = "[redacted]";
const ALLOWED_OPERATIONS = [
"actions",
"events",
"resources",
"signals",
"invokes",
];
const ALLOWED_ACTION = [
"id",
"version",
"titleKey",
"descriptionKey",
"icon",
"scope",
"resource",
"inputs",
"outputs",
"risk",
"permission",
"hostAccess",
"dryRun",
"check",
"undo",
"idempotent",
"exclusive",
"timeoutMs",
"when",
"surfaces",
];
const ALLOWED_FIELD = [
"key",
"type",
"labelKey",
"descriptionKey",
"placeholderKey",
"default",
"options",
"min",
"max",
"requires",
"group",
"required",
"pattern",
"optionsFrom",
"resource",
"sensitive",
];
const ALLOWED_EVENT = [
"id",
"titleKey",
"descriptionKey",
"severity",
"scope",
"payload",
];
const ALLOWED_RESOURCE = [
"id",
"titleKey",
"icon",
"list",
"key",
"name",
"status",
"url",
"tab",
"columns",
"filters",
];
const ALLOWED_SIGNAL = [
"id",
"titleKey",
"read",
"scope",
"kind",
"refreshSec",
"fix",
];
const ALLOWED_SURFACES = [
"assistant",
"mcp",
"palette",
"automations",
"mobile",
"cli",
];
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function rejectUnknown(
raw: Record<string, unknown>,
allowed: string[],
where: string,
errors: string[],
): void {
for (const key of Object.keys(raw)) {
if (!allowed.includes(key))
errors.push(`Unknown field "${key}" in ${where}`);
}
}
function requireEnum(
value: unknown,
allowed: readonly string[],
where: string,
errors: string[],
): void {
if (typeof value !== "string" || !allowed.includes(value)) {
errors.push(`${where} must be one of: ${allowed.join(", ")}`);
}
}
function requireText(value: unknown, where: string, errors: string[]): void {
if (typeof value !== "string" || value.trim().length === 0) {
errors.push(`${where} must be a non-empty string`);
}
}
function optionalText(value: unknown, where: string, errors: string[]): void {
if (value !== undefined) requireText(value, where, errors);
}
function optionalBoolean(
value: unknown,
where: string,
errors: string[],
): void {
if (value !== undefined && typeof value !== "boolean") {
errors.push(`${where} must be a boolean`);
}
}
function requireId(
value: unknown,
pluginId: string | undefined,
where: string,
seen: Set<string>,
errors: string[],
): void {
if (typeof value !== "string" || !OPERATION_ID_PATTERN.test(value)) {
errors.push(`${where}.id must look like "<pluginId>.<name>" in lowercase`);
return;
}
if (pluginId && !value.startsWith(`${pluginId}.`)) {
errors.push(`${where}.id must start with "${pluginId}."`);
}
if (seen.has(value)) errors.push(`${where}.id "${value}" is declared twice`);
seen.add(value);
}
function eachObject(
value: unknown,
where: string,
errors: string[],
each: (raw: Record<string, unknown>, at: string) => void,
): void {
if (value === undefined) return;
if (!Array.isArray(value)) {
errors.push(`${where} must be an array`);
return;
}
value.forEach((raw, index) => {
const at = `${where}[${index}]`;
if (!isObject(raw)) {
errors.push(`${at} must be an object`);
return;
}
each(raw, at);
});
}
function validateFields(value: unknown, where: string, errors: string[]): void {
const keys = new Set<string>();
eachObject(value, where, errors, (raw, at) => {
rejectUnknown(raw, ALLOWED_FIELD, at, errors);
if (typeof raw.key !== "string" || !FIELD_KEY_PATTERN.test(raw.key)) {
errors.push(`${at}.key must be a short name starting with a letter`);
} else if (keys.has(raw.key)) {
errors.push(`${at}.key "${raw.key}" is declared twice`);
} else {
keys.add(raw.key);
}
requireEnum(raw.type, OPERATION_FIELD_TYPES, `${at}.type`, errors);
optionalText(raw.labelKey, `${at}.labelKey`, errors);
optionalText(raw.descriptionKey, `${at}.descriptionKey`, errors);
optionalText(raw.placeholderKey, `${at}.placeholderKey`, errors);
optionalText(raw.group, `${at}.group`, errors);
optionalText(raw.requires, `${at}.requires`, errors);
optionalText(raw.optionsFrom, `${at}.optionsFrom`, errors);
optionalBoolean(raw.required, `${at}.required`, errors);
optionalBoolean(raw.sensitive, `${at}.sensitive`, errors);
if (raw.type === "resource" && typeof raw.resource !== "string") {
errors.push(`${at}.resource is required for type "resource"`);
}
if (raw.resource !== undefined && raw.type !== "resource") {
errors.push(`${at}.resource needs type "resource"`);
}
if (raw.type === "secret" && raw.default !== undefined) {
errors.push(`${at}.default is not allowed on a secret`);
}
if (
(raw.type === "select" || raw.type === "multiselect") &&
!raw.optionsFrom &&
(!Array.isArray(raw.options) || raw.options.length === 0)
) {
errors.push(`${at} needs options or optionsFrom`);
}
for (const bound of ["min", "max"] as const) {
if (raw[bound] !== undefined && typeof raw[bound] !== "number") {
errors.push(`${at}.${bound} must be a number`);
}
}
if (raw.pattern !== undefined) {
if (typeof raw.pattern !== "string") {
errors.push(`${at}.pattern must be a string`);
} else {
try {
new RegExp(raw.pattern);
} catch {
errors.push(`${at}.pattern is not a valid regular expression`);
}
}
}
});
}
function validateWhen(value: unknown, at: string, errors: string[]): void {
if (value === undefined) return;
if (!isObject(value)) {
errors.push(`${at} must be an object`);
return;
}
rejectUnknown(value, ["settings", "os"], at, errors);
if (value.settings !== undefined && !isObject(value.settings)) {
errors.push(`${at}.settings must be an object`);
}
if (
value.os !== undefined &&
(!Array.isArray(value.os) ||
value.os.some((entry) => typeof entry !== "string"))
) {
errors.push(`${at}.os must be an array of strings`);
}
}
function validateAction(
raw: Record<string, unknown>,
at: string,
errors: string[],
): void {
rejectUnknown(raw, ALLOWED_ACTION, at, errors);
requireText(raw.titleKey, `${at}.titleKey`, errors);
optionalText(raw.descriptionKey, `${at}.descriptionKey`, errors);
optionalText(raw.icon, `${at}.icon`, errors);
requireEnum(raw.scope, OPERATION_SCOPES, `${at}.scope`, errors);
requireEnum(raw.risk, OPERATION_RISKS, `${at}.risk`, errors);
requireText(raw.permission, `${at}.permission`, errors);
validateFields(raw.inputs, `${at}.inputs`, errors);
validateFields(raw.outputs, `${at}.outputs`, errors);
validateWhen(raw.when, `${at}.when`, errors);
optionalBoolean(raw.idempotent, `${at}.idempotent`, errors);
optionalBoolean(raw.exclusive, `${at}.exclusive`, errors);
if (
raw.version !== undefined &&
(!Number.isInteger(raw.version) || (raw.version as number) < 1)
) {
errors.push(`${at}.version must be a whole number from 1`);
}
if (raw.dryRun !== undefined) {
requireEnum(raw.dryRun, OPERATION_DRY_RUN, `${at}.dryRun`, errors);
}
if (
raw.timeoutMs !== undefined &&
(typeof raw.timeoutMs !== "number" ||
raw.timeoutMs <= 0 ||
raw.timeoutMs > MAX_OPERATION_TIMEOUT_MS)
) {
errors.push(`${at}.timeoutMs must be above 0 and at most 1 hour`);
}
if (raw.scope === "resource" && typeof raw.resource !== "string") {
errors.push(`${at}.resource is required for scope "resource"`);
}
if (raw.scope !== "resource" && raw.resource !== undefined) {
errors.push(`${at}.resource needs scope "resource"`);
}
if (raw.scope === "server" && raw.hostAccess !== undefined) {
errors.push(`${at}.hostAccess does not apply to scope "server"`);
}
if (raw.scope === "host" || raw.scope === "resource") {
requireEnum(
raw.hostAccess,
OPERATION_HOST_ACCESS,
`${at}.hostAccess`,
errors,
);
if (
raw.risk === "destructive" &&
(raw.hostAccess === "connect" || raw.hostAccess === "view")
) {
errors.push(`${at}.hostAccess must be edit or manage when destructive`);
}
}
if (raw.surfaces !== undefined) {
if (!isObject(raw.surfaces)) {
errors.push(`${at}.surfaces must be an object`);
} else {
rejectUnknown(raw.surfaces, ALLOWED_SURFACES, `${at}.surfaces`, errors);
for (const [key, value] of Object.entries(raw.surfaces)) {
optionalBoolean(value, `${at}.surfaces.${key}`, errors);
}
}
}
}
function validateResource(
raw: Record<string, unknown>,
at: string,
errors: string[],
): void {
rejectUnknown(raw, ALLOWED_RESOURCE, at, errors);
requireText(raw.titleKey, `${at}.titleKey`, errors);
requireText(raw.list, `${at}.list`, errors);
requireText(raw.key, `${at}.key`, errors);
requireText(raw.name, `${at}.name`, errors);
optionalText(raw.icon, `${at}.icon`, errors);
optionalText(raw.status, `${at}.status`, errors);
optionalText(raw.url, `${at}.url`, errors);
optionalText(raw.tab, `${at}.tab`, errors);
eachObject(raw.columns, `${at}.columns`, errors, (column, where) => {
rejectUnknown(column, ["key", "titleKey", "type"], where, errors);
requireText(column.key, `${where}.key`, errors);
requireText(column.titleKey, `${where}.titleKey`, errors);
if (column.type !== undefined) {
requireEnum(column.type, RESOURCE_COLUMN_TYPES, `${where}.type`, errors);
}
});
if (
raw.filters !== undefined &&
(!Array.isArray(raw.filters) ||
raw.filters.some((entry) => typeof entry !== "string"))
) {
errors.push(`${at}.filters must be an array of strings`);
}
}
/** Shape checks for contributes.operations, run by validateManifest. */
export function validateOperations(
value: unknown,
pluginId: string | undefined,
errors: string[],
): void {
if (value === undefined) return;
const root = "contributes.operations";
if (!isObject(value)) {
errors.push(`Field "${root}" must be an object`);
return;
}
rejectUnknown(value, ALLOWED_OPERATIONS, `"${root}"`, errors);
const ids = new Set<string>();
eachObject(value.actions, `${root}.actions`, errors, (raw, at) => {
requireId(raw.id, pluginId, at, ids, errors);
validateAction(raw, at, errors);
});
const eventIds = new Set<string>();
eachObject(value.events, `${root}.events`, errors, (raw, at) => {
requireId(raw.id, pluginId, at, eventIds, errors);
rejectUnknown(raw, ALLOWED_EVENT, at, errors);
requireText(raw.titleKey, `${at}.titleKey`, errors);
optionalText(raw.descriptionKey, `${at}.descriptionKey`, errors);
requireEnum(raw.scope, ["host", "server"], `${at}.scope`, errors);
if (raw.severity !== undefined) {
requireEnum(raw.severity, OPERATION_SEVERITIES, `${at}.severity`, errors);
}
validateFields(raw.payload, `${at}.payload`, errors);
});
const resourceIds = new Set<string>();
eachObject(value.resources, `${root}.resources`, errors, (raw, at) => {
requireId(raw.id, pluginId, at, resourceIds, errors);
validateResource(raw, at, errors);
});
const signalIds = new Set<string>();
eachObject(value.signals, `${root}.signals`, errors, (raw, at) => {
requireId(raw.id, pluginId, at, signalIds, errors);
rejectUnknown(raw, ALLOWED_SIGNAL, at, errors);
requireText(raw.titleKey, `${at}.titleKey`, errors);
requireText(raw.read, `${at}.read`, errors);
requireEnum(raw.scope, ["host", "server"], `${at}.scope`, errors);
requireEnum(raw.kind, SIGNAL_KINDS, `${at}.kind`, errors);
optionalText(raw.fix, `${at}.fix`, errors);
if (
raw.refreshSec !== undefined &&
(typeof raw.refreshSec !== "number" || raw.refreshSec < 10)
) {
errors.push(`${at}.refreshSec must be at least 10`);
}
});
if (value.invokes !== undefined) {
if (
!Array.isArray(value.invokes) ||
value.invokes.some(
(entry) => typeof entry !== "string" || !INVOKE_PATTERN.test(entry),
)
) {
errors.push(
`${root}.invokes must list action ids, "<pluginId>.*" or "*"`,
);
}
}
}
/**
* The cross-field rules: every id one declaration names must exist in this
* plugin, or a surface would offer a button that goes nowhere.
*/
export function checkOperationReferences(
operations: PluginOperationsContribution | undefined,
capabilities: readonly string[],
hasPermission: (name: string) => boolean,
errors: string[],
): void {
if (!operations) return;
const root = "contributes.operations";
const actions = new Map(
(operations.actions ?? []).map((action) => [action.id, action]),
);
const resources = new Set((operations.resources ?? []).map((r) => r.id));
const declaresAnything =
actions.size > 0 ||
(operations.events?.length ?? 0) > 0 ||
resources.size > 0 ||
(operations.signals?.length ?? 0) > 0;
if (declaresAnything && !capabilities.includes("operations:provide")) {
errors.push(`${root} needs the "operations:provide" capability`);
}
if (
operations.invokes?.length &&
!capabilities.includes("operations:invoke")
) {
errors.push(`${root}.invokes needs the "operations:invoke" capability`);
}
const readAction = (id: string, where: string) => {
const target = actions.get(id);
if (!target) errors.push(`${where} "${id}" is not a declared action`);
else if (target.risk !== "read") {
errors.push(`${where} "${id}" must be a read action`);
}
};
for (const action of actions.values()) {
const at = `${root}.actions["${action.id}"]`;
if (!hasPermission(action.permission)) {
errors.push(
`${at}.permission "${action.permission}" is not declared in contributes.permissions`,
);
}
if (action.resource && !resources.has(action.resource)) {
errors.push(`${at}.resource "${action.resource}" is not declared`);
}
if (action.check) readAction(action.check, `${at}.check`);
if (action.undo && !actions.has(action.undo)) {
errors.push(`${at}.undo "${action.undo}" is not a declared action`);
}
for (const field of action.inputs ?? []) {
if (field.optionsFrom) {
readAction(field.optionsFrom, `${at} input "${field.key}" optionsFrom`);
}
if (field.resource && !resources.has(field.resource)) {
errors.push(`${at} input "${field.key}" names an unknown resource`);
}
}
}
for (const resource of operations.resources ?? []) {
readAction(resource.list, `${root}.resources["${resource.id}"].list`);
}
for (const signal of operations.signals ?? []) {
const at = `${root}.signals["${signal.id}"]`;
readAction(signal.read, `${at}.read`);
if (signal.fix && !actions.has(signal.fix)) {
errors.push(`${at}.fix "${signal.fix}" is not a declared action`);
}
}
}
/** Whether a plugin's invokes list lets it run an action. */
export function invokeAllowed(
invokes: readonly string[] | undefined,
actionId: string,
): boolean {
for (const entry of invokes ?? []) {
if (entry === "*" || entry === actionId) return true;
if (entry.endsWith(".*") && actionId.startsWith(entry.slice(0, -1))) {
return true;
}
}
return false;
}
const RISK_RANK: Record<OperationRisk, number> = {
read: 0,
change: 1,
destructive: 2,
};
export function riskRank(risk: OperationRisk): number {
return RISK_RANK[risk];
}
/** A surface's own switch. The assistant and MCP are opt in, the rest opt out. */
export function onSurface(
action: Pick<OperationAction, "surfaces">,
surface: keyof OperationSurfaces,
): boolean {
const value = action.surfaces?.[surface];
if (surface === "assistant" || surface === "mcp") return value === true;
return value !== false;
}
/** Whether an action applies to a host, from fields any client can read. */
export function matchesWhen(
when: OperationWhen | undefined,
host: { settings?: Record<string, unknown>; os?: string | null },
): boolean {
if (!when) return true;
for (const [key, expected] of Object.entries(when.settings ?? {})) {
if (host.settings?.[key] !== expected) return false;
}
if (when.os?.length && host.os) {
if (!when.os.includes(host.os.toLowerCase())) return false;
}
return true;
}
function asSettingsField(field: OperationField): PluginSettingsField {
const type = field.type === "resource" ? "string" : field.type;
return { ...field, type } as PluginSettingsField;
}
function isEmpty(value: unknown): boolean {
return value === undefined || value === null || value === "";
}
export interface OperationInputResult {
values: Record<string, unknown>;
errors: string[];
}
/**
* Turns raw input into validated values. Keys no field declares are refused,
* so nothing a handler did not ask for ever reaches it.
*/
export function parseOperationInput(
fields: readonly OperationField[] | undefined,
raw: unknown,
): OperationInputResult {
const errors: string[] = [];
const values: Record<string, unknown> = {};
const list = fields ?? [];
if (raw !== undefined && raw !== null && !isObject(raw)) {
return { values, errors: ["Input must be an object"] };
}
const input = (raw ?? {}) as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (!list.some((field) => field.key === key)) {
errors.push(`"${key}" is not an input of this action`);
}
}
for (const field of list) {
if (field.requires && input[field.requires] !== true) continue;
let value = input[field.key];
if (isEmpty(value)) {
if (field.default !== undefined) {
values[field.key] = field.default;
} else if (field.required) {
errors.push(`"${field.key}" is required`);
}
continue;
}
const settingsField = asSettingsField(field);
value = coerceSettingValue(settingsField, value);
if (field.type === "secret" && typeof value !== "string") {
errors.push(`"${field.key}" must be a string`);
continue;
}
if (field.type === "json" && typeof value === "string") {
try {
value = JSON.parse(value);
} catch {
errors.push(`"${field.key}" must be valid JSON`);
continue;
}
}
// A list known only at run time is checked by the handler.
const skipChoices =
field.optionsFrom &&
(field.type === "select" || field.type === "multiselect");
const problem = skipChoices
? field.type === "select"
? typeof value === "string"
? null
: `"${field.key}" must be a string`
: Array.isArray(value)
? null
: `"${field.key}" must be an array`
: validateSettingValue(settingsField, value);
if (problem) {
errors.push(problem);
continue;
}
if (
field.pattern &&
typeof value === "string" &&
!new RegExp(`^(?:${field.pattern})$`).test(value)
) {
errors.push(`"${field.key}" has the wrong format`);
continue;
}
values[field.key] = value;
}
return { values, errors };
}
/** A copy safe to store or show: secret and sensitive values are replaced. */
export function redactFields(
fields: readonly OperationField[] | undefined,
values: unknown,
): unknown {
if (!isObject(values)) return values;
const copy: Record<string, unknown> = { ...values };
for (const field of fields ?? []) {
if (field.type !== "secret" && !field.sensitive) continue;
if (field.key in copy) copy[field.key] = REDACTED_VALUE;
}
return copy;
}
/** Trims text to a byte-friendly length with a plain marker. */
export function capText(text: string, maxLength: number): string {
return text.length <= maxLength
? text
: `${text.slice(0, maxLength)}\n[cut at ${maxLength} characters]`;
}
function fieldSchema(field: OperationField): Record<string, unknown> {
const schema: Record<string, unknown> = {};
if (field.default !== undefined) schema.default = field.default;
const values = field.optionsFrom
? undefined
: field.options?.map((option) => option.value);
switch (field.type) {
case "boolean":
schema.type = "boolean";
break;
case "number":
schema.type = "number";
if (field.min !== undefined) schema.minimum = field.min;
if (field.max !== undefined) schema.maximum = field.max;
break;
case "select":
schema.type = "string";
if (values) schema.enum = values;
break;
case "multiselect":
schema.type = "array";
schema.items = values
? { type: "string", enum: values }
: { type: "string" };
break;
case "json":
break;
default:
schema.type = "string";
if (field.pattern) schema.pattern = `^(?:${field.pattern})$`;
}
if (field.type === "resource" && field.resource) {
schema["x-termix-resource"] = field.resource;
}
if (field.type === "secret") schema.writeOnly = true;
return schema;
}
/** JSON Schema for a field list, for AI tools, MCP and OpenAPI. */
export function toJsonSchema(
fields: readonly OperationField[] | undefined,
describe?: (field: OperationField) => string | undefined,
): Record<string, unknown> {
const properties: Record<string, unknown> = {};
const required: string[] = [];
for (const field of fields ?? []) {
const schema = fieldSchema(field);
const description = describe?.(field);
if (description) schema.description = description;
properties[field.key] = schema;
if (field.required && field.default === undefined && !field.requires) {
required.push(field.key);
}
}
return { type: "object", properties, required, additionalProperties: false };
}
+1 -53
View File
@@ -68,11 +68,7 @@ import type {
PluginSshKeyCredential, PluginSshKeyCredential,
PluginSshKeyCredentialInput, PluginSshKeyCredentialInput,
} from "./backend.js"; } from "./backend.js";
import type { import type { PluginSyncHost, SyncEntityRegistration } from "./backend.js";
PluginOperationHandler,
PluginSyncHost,
SyncEntityRegistration,
} from "./backend.js";
import type { PluginDatabase } from "./backend.js"; import type { PluginDatabase } from "./backend.js";
import type { import type {
PluginExternalClientRequest, PluginExternalClientRequest,
@@ -359,10 +355,6 @@ export interface FakePluginContext {
}>; }>;
/** The hub the plugin registered with ctx.notify.serve, if any. */ /** The hub the plugin registered with ctx.notify.serve, if any. */
notifyHub: { current: PluginNotifyHub | null }; notifyHub: { current: PluginNotifyHub | null };
/** Handlers from ctx.operations.register, by action id. */
operationHandlers: Map<string, PluginOperationHandler>;
/** Every ctx.operations.emit call. */
operationEvents: { eventId: string; payload: unknown; hostId?: number }[];
/** Backing store behind ctx.secrets.get/set, keyed "<userId>:<key>". */ /** Backing store behind ctx.secrets.get/set, keyed "<userId>:<key>". */
secretStore: Map<string, string>; secretStore: Map<string, string>;
/** Every ctx.fetch call, in order. */ /** Every ctx.fetch call, in order. */
@@ -574,8 +566,6 @@ export function createFakeContext(
const createdSshKeys: FakePluginContext["createdSshKeys"] = []; const createdSshKeys: FakePluginContext["createdSshKeys"] = [];
const notifications: FakePluginContext["notifications"] = []; const notifications: FakePluginContext["notifications"] = [];
const notifyHub: FakePluginContext["notifyHub"] = { current: null }; const notifyHub: FakePluginContext["notifyHub"] = { current: null };
const operationHandlers: FakePluginContext["operationHandlers"] = new Map();
const operationEvents: FakePluginContext["operationEvents"] = [];
const fetches: FakePluginContext["fetches"] = []; const fetches: FakePluginContext["fetches"] = [];
const processRuns: FakePluginContext["processRuns"] = []; const processRuns: FakePluginContext["processRuns"] = [];
const binaries: FakePluginContext["binaries"] = []; const binaries: FakePluginContext["binaries"] = [];
@@ -1401,46 +1391,6 @@ export function createFakeContext(
}, },
}, },
operations: {
register: (id, handler) => {
operationHandlers.set(id, handler);
},
run: async (actionId, options = {}) => {
const handler = operationHandlers.get(actionId);
if (!handler) throw new Error(`No action "${actionId}"`);
const result = await handler.run(options.input ?? {}, {
hostId: options.hostId,
resourceKey: options.resourceKey,
runId: null,
dryRun: options.dryRun === true,
signal: new AbortController().signal,
log: () => {},
});
return { runId: null, status: "success", ...result };
},
invoke: async (actionId, options = {}) => {
const handler = operationHandlers.get(actionId);
if (!handler) throw new Error(`No action "${actionId}"`);
const result = await handler.run(options.input ?? {}, {
hostId: options.hostId,
resourceKey: options.resourceKey,
runId: null,
dryRun: options.dryRun === true,
signal: new AbortController().signal,
log: () => {},
});
return { runId: null, status: "success", ...result };
},
list: async () => ({
actions: manifest.contributes?.operations?.actions ?? [],
}),
emit: async (eventId, payload, options) => {
operationEvents.push({ eventId, payload, hostId: options?.hostId });
},
onEvent: () => () => {},
approvals: { provide: () => () => {} },
},
notify: { notify: {
channels: async () => { channels: async () => {
if (!actor) throw new Error("ctx.notify needs an acting user"); if (!actor) throw new Error("ctx.notify needs an acting user");
@@ -1604,8 +1554,6 @@ export function createFakeContext(
createdSshKeys, createdSshKeys,
notifications, notifications,
notifyHub, notifyHub,
operationHandlers,
operationEvents,
fetches, fetches,
processRuns, processRuns,
binaries, binaries,
+2 -10
View File
@@ -159,10 +159,8 @@ HostProtocolAuthInput (type): HostProtocolAuthInput
HostProtocolDef (type): HostProtocolDef HostProtocolDef (type): HostProtocolDef
HostProtocols (type): HostProtocols HostProtocols (type): HostProtocols
HostRowFields (type): HostRowFields HostRowFields (type): HostRowFields
HostSectionDef (type): HostSectionDef
HostSidebarFilterState (type): HostSidebarFilterState HostSidebarFilterState (type): HostSidebarFilterState
HostSidebarPreferences (type): HostSidebarPreferences HostSidebarPreferences (type): HostSidebarPreferences
HostTabEmbed (value): ({ host, type, label, data, isVisible, shell, }: { host: Host; type: string; label?: string; data?: Record<string, unknown>; isVisible?: boolean; shell: TabShellCallbacks; }) => Element
HostTrayTrigger (type): HostTrayTrigger HostTrayTrigger (type): HostTrayTrigger
HttpsSettings (type): HttpsSettings HttpsSettings (type): HttpsSettings
IconPicker (value): ({ value, color, onChange, }: { value: string; color?: string; onChange: (icon: string) => void; }) => Element IconPicker (value): ({ value, color, onChange, }: { value: string; color?: string; onChange: (icon: string) => void; }) => Element
@@ -356,7 +354,7 @@ changePassword (value): (oldPassword: string, newPassword: string) => Promise<an
clearLocalAdaptivePreferences (value): () => void clearLocalAdaptivePreferences (value): () => void
cn (value): (...inputs: ClassValue[]) => string cn (value): (...inputs: ClassValue[]) => string
copyToClipboard (value): (text: string) => Promise<boolean> copyToClipboard (value): (text: string) => Promise<boolean>
createApiKey (value): (name: string, userId: string, expiresAt?: string, scopes?: string[]) => Promise<CreatedApiKey> createApiKey (value): (name: string, userId: string, expiresAt?: string) => Promise<CreatedApiKey>
createCredential (value): (credentialData: Record<string, unknown>) => Promise<Record<string, unknown>> createCredential (value): (credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger
createQuickConnectHost (value): (input: QuickConnectInput) => Host createQuickConnectHost (value): (input: QuickConnectInput) => Host
@@ -405,7 +403,6 @@ getDeviceId (value): () => string
getErrorMessage (value): (error: unknown, fallback?: string) => string getErrorMessage (value): (error: unknown, fallback?: string) => string
getExternalAutoProvision (value): () => Promise<{ enabled: boolean; }> getExternalAutoProvision (value): () => Promise<{ enabled: boolean; }>
getFolderAccess (value): (folder: string) => Promise<{ rules: FolderAccessRule[]; }> getFolderAccess (value): (folder: string) => Promise<{ rules: FolderAccessRule[]; }>
getFolderAuthOverride (value): (folder: string, protocol: string, sharedCopySyncId?: string) => Promise<{ folder: string; protocol: string; credentialId: number; }>
getFolderDefaultsId (value): (name: string) => Promise<number> getFolderDefaultsId (value): (name: string) => Promise<number>
getHostAccess (value): (hostId: number, syncId?: string) => Promise<{ accessList: AccessRecord[]; isOwner?: boolean; }> getHostAccess (value): (hostId: number, syncId?: string) => Promise<{ accessList: AccessRecord[]; isOwner?: boolean; }>
getHostAuthOverride (value): (hostId: number, protocol: string, sharedCopySyncId?: string) => Promise<{ protocol: string; credentialId: number; }> getHostAuthOverride (value): (hostId: number, protocol: string, sharedCopySyncId?: string) => Promise<{ protocol: string; credentialId: number; }>
@@ -459,7 +456,6 @@ hostBadgesFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
hostEditorToolbarItemsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[] hostEditorToolbarItemsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
hostMenuItemsFor (value): (all: HostContextMenuItemDef[], host: Host) => HostContextMenuItemDef[] hostMenuItemsFor (value): (all: HostContextMenuItemDef[], host: Host) => HostContextMenuItemDef[]
hostProtocolFlags (value): (host: { enableSsh?: boolean; pluginSettings?: Record<string, Record<string, unknown>>; }, list?: HostProtocolDef[]) => HostProtocols hostProtocolFlags (value): (host: { enableSsh?: boolean; pluginSettings?: Record<string, Record<string, unknown>>; }, list?: HostProtocolDef[]) => HostProtocols
hostSectionsFor (value): (all: HostSectionDef[], host: Host) => HostSectionDef[]
hostStatusIndicatorsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[] hostStatusIndicatorsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
hydrateLocalSharedHostAuth (value): <T>(host: T) => Promise<T> hydrateLocalSharedHostAuth (value): <T>(host: T) => Promise<T>
invalidateHostsAndStatusCaches (value): () => void invalidateHostsAndStatusCaches (value): () => void
@@ -478,7 +474,6 @@ linkExternalToPasswordAccount (value): (externalUserId: string, targetUsername:
linkedServerUrl (value): () => Promise<string> linkedServerUrl (value): () => Promise<string>
listHostActions (value): () => HostActionDef[] listHostActions (value): () => HostActionDef[]
listHostProtocols (value): () => HostProtocolDef[] listHostProtocols (value): () => HostProtocolDef[]
listHostSections (value): () => HostSectionDef[]
loadPaletteGroup (value): (group: PaletteGroupDef) => Promise<PaletteItemDef[]> loadPaletteGroup (value): (group: PaletteGroupDef) => Promise<PaletteItemDef[]>
logoutUser (value): () => Promise<{ success: boolean; message: string; }> logoutUser (value): () => Promise<{ success: boolean; message: string; }>
makeHostSshSubTabs (value): (t: (key: string, options?: { defaultValue?: string; }) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean) => HostTab[] makeHostSshSubTabs (value): (t: (key: string, options?: { defaultValue?: string; }) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean) => HostTab[]
@@ -492,7 +487,6 @@ moveSplitDrag (value): (x: number, y: number) => void
normalizeLanguageCode (value): (language?: string) => string normalizeLanguageCode (value): (language?: string) => string
notifySyncChanged (value): () => void notifySyncChanged (value): () => void
openInSplit (value): (target: SplitOpenTarget, open: () => void) => void openInSplit (value): (target: SplitOpenTarget, open: () => void) => void
operationsApi (value): AxiosInstance
paletteEntriesFor (value): (all: PaletteEntryDef[], scope: "global" | "host", host?: Host) => PaletteEntryDef[] paletteEntriesFor (value): (all: PaletteEntryDef[], scope: "global" | "host", host?: Host) => PaletteEntryDef[]
parseCustomKeybindings (value): (raw?: string) => CustomKeybinding[] parseCustomKeybindings (value): (raw?: string) => CustomKeybinding[]
parseServerTime (value): (ts: string) => Date parseServerTime (value): (ts: string) => Date
@@ -532,7 +526,7 @@ resolveHostTabType (value): (host: Host, preferredType?: TabType) => TabType
resolveRemoteHostId (value): (syncId: string) => Promise<number> resolveRemoteHostId (value): (syncId: string) => Promise<number>
revokeAllUserSessions (value): (userId: string) => Promise<{ success: boolean; message: string; }> revokeAllUserSessions (value): (userId: string) => Promise<{ success: boolean; message: string; }>
revokeCredentialAccess (value): (credentialId: number, accessId: number) => Promise<void> revokeCredentialAccess (value): (credentialId: number, accessId: number) => Promise<void>
revokeFolderAccess (value): (ruleId: number, options?: { fromHosts?: boolean; }) => Promise<void> revokeFolderAccess (value): (ruleId: number) => Promise<void>
revokeHostAccess (value): (hostId: number, accessId: number, syncId?: string) => Promise<{ success: boolean; }> revokeHostAccess (value): (hostId: number, accessId: number, syncId?: string) => Promise<{ success: boolean; }>
revokeSession (value): (sessionId: string) => Promise<{ success: boolean; message: string; }> revokeSession (value): (sessionId: string) => Promise<{ success: boolean; message: string; }>
rightDockableIds (value): () => string[] rightDockableIds (value): () => string[]
@@ -550,7 +544,6 @@ saveHostTags (value): (tags: string[]) => Promise<string[]>
saveUserPreferences (value): (prefs: Partial<UserPreferences>) => Promise<void> saveUserPreferences (value): (prefs: Partial<UserPreferences>) => Promise<void>
setAllPluginChannels (value): (channel: PluginChannel) => Promise<{ changed: string[]; }> setAllPluginChannels (value): (channel: PluginChannel) => Promise<{ changed: string[]; }>
setDeveloperMode (value): (enabled: boolean) => Promise<void> setDeveloperMode (value): (enabled: boolean) => Promise<void>
setFolderAuthOverride (value): (folder: string, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; folder: string; protocol: string; credentialId: number; }>
setHostAuthOverride (value): (hostId: number, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; protocol: string; credentialId: number; }> setHostAuthOverride (value): (hostId: number, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; protocol: string; credentialId: number; }>
setNotificationPrivateEndpoints (value): (hosts: string[]) => Promise<string[]> setNotificationPrivateEndpoints (value): (hosts: string[]) => Promise<string[]>
setPluginOptions (value): (pluginId: string, options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel; }) => Promise<{ autoUpdate: boolean; pinnedVersion: string; channel: PluginChannel; }> setPluginOptions (value): (pluginId: string, options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel; }) => Promise<{ autoUpdate: boolean; pinnedVersion: string; channel: PluginChannel; }>
@@ -603,7 +596,6 @@ useHostContextMenuItems (value): () => HostContextMenuItemDef[]
useHostEditorSections (value): () => HostEditorSectionDef[] useHostEditorSections (value): () => HostEditorSectionDef[]
useHostEditorToolbarItems (value): () => HostBadgeDef[] useHostEditorToolbarItems (value): () => HostBadgeDef[]
useHostProtocols (value): () => HostProtocolDef[] useHostProtocols (value): () => HostProtocolDef[]
useHostSections (value): () => HostSectionDef[]
useHostStatus (value): (hostId: number, statusCheckEnabled?: boolean) => StatusValue useHostStatus (value): (hostId: number, statusCheckEnabled?: boolean) => StatusValue
useHostStatusIndicators (value): () => HostBadgeDef[] useHostStatusIndicators (value): () => HostBadgeDef[]
useInstalledPlugins (value): () => PluginSummary[] useInstalledPlugins (value): () => PluginSummary[]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

+2 -7
View File
@@ -3,8 +3,6 @@
* *
* getBundledPluginsDir() in src/backend/plugins/paths.ts resolves * getBundledPluginsDir() in src/backend/plugins/paths.ts resolves
* dist/backend/backend/plugins -> dist/plugins, which is where this writes. * dist/backend/backend/plugins -> dist/plugins, which is where this writes.
* --profile minimal|standard|full (or TERMIX_PLUGIN_PROFILE, default standard)
* picks which tiers of the file are staged.
* Each .tmxplug is downloaded (or read from a path), checked against its * Each .tmxplug is downloaded (or read from a path), checked against its
* pinned sha256 and unpacked. * pinned sha256 and unpacked.
* *
@@ -27,16 +25,13 @@ const {
localSdkBuildCommand, localSdkBuildCommand,
copyLocalBuild, copyLocalBuild,
buildBundledIndex, buildBundledIndex,
resolveProfile,
pluginsForProfile,
} = require("./lib/bundled-plugins.cjs"); } = require("./lib/bundled-plugins.cjs");
const root = path.resolve(__dirname, ".."); const root = path.resolve(__dirname, "..");
const destination = path.join(root, "dist", "plugins"); const destination = path.join(root, "dist", "plugins");
async function main() { async function main() {
const profile = resolveProfile(); const plugins = loadBundledPlugins(root);
const plugins = pluginsForProfile(loadBundledPlugins(root), profile);
const flag = process.argv.indexOf("--local"); const flag = process.argv.indexOf("--local");
const localArg = const localArg =
flag !== -1 ? process.argv[flag + 1] : process.env.TERMIX_LOCAL_PLUGINS; flag !== -1 ? process.argv[flag + 1] : process.env.TERMIX_LOCAL_PLUGINS;
@@ -79,7 +74,7 @@ async function main() {
); );
console.log( console.log(
`Bundled ${plugins.length + extra.length} plugin(s) (${profile}): ${[...pinned, ...extra].join(", ")}`, `Bundled ${plugins.length + extra.length} plugin(s): ${[...pinned, ...extra].join(", ")}`,
); );
} }
+6 -14
View File
@@ -1,11 +1,9 @@
/** /**
* Builds the pieces of the plugin SDK that come from core: * Builds the two pieces of the plugin SDK that come from core:
* *
* dist/host/ui.js @termix-ssh/plugin-sdk/ui, the shell's UI kit * dist/host/ui.js @termix-ssh/plugin-sdk/ui, the shell's UI kit
* dist/host/testing-host.js what renderWithApp() renders plugins into * dist/host/testing-host.js what renderWithApp() renders plugins into
* dist/host/core-classes.json the classes core's CSS has rules for * dist/host/core-classes.json the classes core's CSS has rules for
* dist/host/frame-host.js runs one plugin outside the shell (mobile app)
* dist/host/frame-host.css core's compiled CSS for that frame
* *
* Inside Termix the page's import map points @termix-ssh/plugin-sdk/ui at the * Inside Termix the page's import map points @termix-ssh/plugin-sdk/ui at the
* shell's own copy, so ui.js is never loaded there. It exists so a plugin in * shell's own copy, so ui.js is never loaded there. It exists so a plugin in
@@ -48,7 +46,6 @@ await esbuild.build({
entryPoints: { entryPoints: {
ui: path.join(root, "src/ui/plugin-host/sdk-ui.ts"), ui: path.join(root, "src/ui/plugin-host/sdk-ui.ts"),
"testing-host": path.join(root, "src/ui/plugin-host/testing-host.tsx"), "testing-host": path.join(root, "src/ui/plugin-host/testing-host.tsx"),
"frame-host": path.join(root, "src/ui/plugin-host/frame-host.tsx"),
}, },
outdir: outDir, outdir: outDir,
bundle: true, bundle: true,
@@ -75,9 +72,6 @@ await esbuild.build({
// so the translated locale files it would list are simply none. // so the translated locale files it would list are simply none.
banner: { js: "const __termixNoGlob = () => ({});" }, banner: { js: "const __termixNoGlob = () => ({});" },
define: { define: {
// esbuild picks "development" for an unminified build, which sends core's
// requests to the Vite dev proxy on any server without a port.
"process.env.NODE_ENV": '"production"',
"import.meta.glob": "__termixNoGlob", "import.meta.glob": "__termixNoGlob",
"import.meta.env.DEV": "false", "import.meta.env.DEV": "false",
"import.meta.env.PROD": "true", "import.meta.env.PROD": "true",
@@ -163,16 +157,14 @@ const rootSource =
const scanner = new Scanner({ const scanner = new Scanner({
sources: [...rootSource, ...compiler.sources], sources: [...rootSource, ...compiler.sources],
}); });
const coreCss = optimize(compiler.build(scanner.scan()), { const coreClasses = [
minify: true, ...classNamesInCss(
}).code; optimize(compiler.build(scanner.scan()), { minify: true }).code,
const coreClasses = [...classNamesInCss(coreCss)].sort(); ),
].sort();
fs.writeFileSync( fs.writeFileSync(
path.join(outDir, "core-classes.json"), path.join(outDir, "core-classes.json"),
`${JSON.stringify(coreClasses)}\n`, `${JSON.stringify(coreClasses)}\n`,
); );
// The frame host has no Vite build to load core's CSS, so it gets it here.
fs.writeFileSync(path.join(outDir, "frame-host.css"), coreCss);
console.log(`Built the SDK host modules into ${path.relative(root, outDir)}`); console.log(`Built the SDK host modules into ${path.relative(root, outDir)}`);
-134
View File
@@ -16,8 +16,6 @@ import {
localSdkBuildCommand, localSdkBuildCommand,
rebuildLocalPlugins, rebuildLocalPlugins,
copyLocalBuild, copyLocalBuild,
resolveProfile,
pluginsForProfile,
} from "./lib/bundled-plugins.cjs"; } from "./lib/bundled-plugins.cjs";
const cleanups: Array<() => void> = []; const cleanups: Array<() => void> = [];
@@ -265,52 +263,10 @@ describe("pinsFromIndex", () => {
source: "tmxplug", source: "tmxplug",
url: "https://example.test/beta-1.1.0.tmxplug", url: "https://example.test/beta-1.1.0.tmxplug",
sha256: "b".repeat(64), sha256: "b".repeat(64),
tier: "full",
}); });
expect(parseBundledPlugins({ plugins: pins }).problems).toEqual([]); expect(parseBundledPlugins({ plugins: pins }).problems).toEqual([]);
}); });
it("keeps the tier of an existing pin", () => {
const pins = pinsFromIndex(index, null, [
{ id: "alpha", tier: "standard" },
]);
expect(pins[0].tier).toBe("standard");
expect(pins[1].tier).toBe("full");
});
it("skips releases whose engine.termix excludes the core version", () => {
const withEngine = {
plugins: [
{
id: "gamma",
versions: [
{
version: "2.0.0",
url: "https://example.test/gamma-2.0.0.tmxplug",
sha256: "d".repeat(64),
engine: { api: "^1", termix: ">=26.10.6" },
},
{
version: "1.0.0",
url: "https://example.test/gamma-1.0.0.tmxplug",
sha256: "e".repeat(64),
engine: { api: "^1", termix: ">=26.10.0" },
},
],
},
],
};
expect(pinsFromIndex(withEngine, null, [], "26.10.5")[0].url).toBe(
"https://example.test/gamma-1.0.0.tmxplug",
);
expect(pinsFromIndex(withEngine, null, [], "26.10.6")[0].url).toBe(
"https://example.test/gamma-2.0.0.tmxplug",
);
expect(pinsFromIndex(withEngine, null)[0].url).toBe(
"https://example.test/gamma-2.0.0.tmxplug",
);
});
it("keeps the onboarding defaults of an existing pin", () => { it("keeps the onboarding defaults of an existing pin", () => {
const current = [ const current = [
{ id: "beta", onboarding: { recommended: true, consent: false } }, { id: "beta", onboarding: { recommended: true, consent: false } },
@@ -537,38 +493,6 @@ describe("default look", () => {
).toMatchObject({ id: "look", defaultLook: true }); ).toMatchObject({ id: "look", defaultLook: true });
}); });
it("leaves out new plugins with no usable release when pinning all", () => {
const stable = {
version: "1.0.0",
url: "https://x/a.tmxplug",
sha256: SHA,
};
const index = {
plugins: [
{ id: "ready", versions: [stable] },
{
id: "beta-only",
versions: [],
prereleases: [{ ...stable, version: "1.0.0-beta.1" }],
},
{
id: "too-new",
versions: [],
gated: [{ ...stable, engine: { termix: ">=99.0.0" } }],
},
],
};
expect(
pinsFromIndex(index, null, [], "26.11.1").map(
(pin: { id: string }) => pin.id,
),
).toEqual(["ready"]);
expect(() =>
pinsFromIndex(index, null, [{ id: "beta-only" }], "26.11.1"),
).toThrow(/beta-only/);
expect(() => pinsFromIndex(index, ["beta-only"])).toThrow(/beta-only/);
});
it("copies a local build's theme files", () => { it("copies a local build's theme files", () => {
const repo = tempDir(); const repo = tempDir();
fs.mkdirSync(path.join(repo, "dist"), { recursive: true }); fs.mkdirSync(path.join(repo, "dist"), { recursive: true });
@@ -587,61 +511,3 @@ describe("default look", () => {
expect(fs.existsSync(path.join(out, "themes", "paper.css"))).toBe(true); expect(fs.existsSync(path.join(out, "themes", "paper.css"))).toBe(true);
}); });
}); });
describe("plugin profiles", () => {
const entry = (id: string, tier?: string) => ({
id,
source: "tmxplug",
url: `https://x/${id}.tmxplug`,
sha256: SHA,
...(tier ? { tier } : {}),
});
const { plugins } = parseBundledPlugins({
plugins: [
entry("look", "minimal"),
entry("term", "standard"),
entry("extra", "full"),
entry("untiered"),
],
});
const ids = (profile: string) =>
pluginsForProfile(plugins, profile).map((p: { id: string }) => p.id);
it("ships each tier and every smaller one", () => {
expect(ids("minimal")).toEqual(["look"]);
expect(ids("standard")).toEqual(["look", "term"]);
expect(ids("full")).toEqual(["look", "term", "extra", "untiered"]);
});
it("rejects an unknown tier", () => {
const { problems } = parseBundledPlugins({ plugins: [entry("x", "huge")] });
expect(problems[0]).toMatch(/tier must be one of/);
});
it("reads the profile from the flag, then the environment", () => {
expect(resolveProfile(["node", "x"], {})).toBe("standard");
expect(
resolveProfile(["node", "x"], { TERMIX_PLUGIN_PROFILE: "full" }),
).toBe("full");
expect(
resolveProfile(["node", "x", "--profile", "minimal"], {
TERMIX_PLUGIN_PROFILE: "full",
}),
).toBe("minimal");
expect(() =>
resolveProfile(["node", "x", "--profile", "tiny"], {}),
).toThrow(/unknown plugin profile/);
});
it("keeps the real file valid with a default look in the minimal tier", () => {
const real = loadBundledPlugins(path.resolve(__dirname, ".."));
const minimal = pluginsForProfile(real, "minimal");
expect(minimal.length).toBeGreaterThan(0);
expect(minimal.every((p: { defaultLook: boolean }) => p.defaultLook)).toBe(
true,
);
expect(pluginsForProfile(real, "standard").length).toBeLessThan(
real.length,
);
});
});
-2
View File
@@ -16,8 +16,6 @@ const SDK = path.join(ROOT, "packages", "plugin-sdk");
const INTERNAL_EXPORTS = new Set([ const INTERNAL_EXPORTS = new Set([
"./testing/setup", "./testing/setup",
"./testing-host", "./testing-host",
"./frame-host",
"./frame-host.css",
"./tsconfig.plugin.json", "./tsconfig.plugin.json",
"./schema/manifest.schema.json", "./schema/manifest.schema.json",
"./theme.css", "./theme.css",
-8
View File
@@ -19,12 +19,6 @@ const SDK_UI = path.join(ROOT, "src", "ui", "plugin-host", "sdk-ui.ts");
const TSCONFIG = path.join(ROOT, "tsconfig.app.json"); const TSCONFIG = path.join(ROOT, "tsconfig.app.json");
const ALLOWED = [ const ALLOWED = [
"useHostSections",
"operationsApi",
"listHostSections",
"hostSectionsFor",
"HostTabEmbed",
"HostSectionDef",
"ACCENT_PRESET_COLORS", "ACCENT_PRESET_COLORS",
"APP_VERSION", "APP_VERSION",
"AccessRecord", "AccessRecord",
@@ -432,7 +426,6 @@ const ALLOWED = [
"getFolderAccess", "getFolderAccess",
"getFolderDefaultsId", "getFolderDefaultsId",
"getHostAccess", "getHostAccess",
"getFolderAuthOverride",
"getHostAuthOverride", "getHostAuthOverride",
"getHostDefaultsLevel", "getHostDefaultsLevel",
"getHostEditorSection", "getHostEditorSection",
@@ -572,7 +565,6 @@ const ALLOWED = [
"saveUserPreferences", "saveUserPreferences",
"setAllPluginChannels", "setAllPluginChannels",
"setDeveloperMode", "setDeveloperMode",
"setFolderAuthOverride",
"setHostAuthOverride", "setHostAuthOverride",
"setNotificationPrivateEndpoints", "setNotificationPrivateEndpoints",
"setPluginOptions", "setPluginOptions",
+5 -31
View File
@@ -404,7 +404,6 @@ function watchBackend() {
{ cwd: root, stdio: ["ignore", "pipe", "inherit"] }, { cwd: root, stdio: ["ignore", "pipe", "inherit"] },
); );
let first = true; let first = true;
let restartQueued = false;
let buffer = ""; let buffer = "";
tsc.stdout.on("data", (chunk) => { tsc.stdout.on("data", (chunk) => {
buffer += chunk.toString(); buffer += chunk.toString();
@@ -424,21 +423,14 @@ function watchBackend() {
? "starting the backend" ? "starting the backend"
: "backend changed, restarting it"; : "backend changed, restarting it";
first = false; first = false;
if (restartQueued) continue; void queue(() => restartBackend(reason));
restartQueued = true;
void queue(() => {
restartQueued = false;
return restartBackend(reason);
});
} }
}); });
} }
function watchSdk() { function watchSdk() {
let buildStarted = Date.now();
const changed = createDebouncer(300, () => const changed = createDebouncer(300, () =>
queue(async () => { queue(async () => {
buildStarted = Date.now();
try { try {
await run("npm", ["run", "build:sdk"]); await run("npm", ["run", "build:sdk"]);
} catch (error) { } catch (error) {
@@ -448,16 +440,7 @@ function watchSdk() {
await restartBackend("SDK changed, restarting the backend"); await restartBackend("SDK changed, restarting the backend");
}), }),
); );
// Windows also reports reads (access time), so only a file saved after the last build counts watch(SDK_SRC, true, () => changed("sdk"));
watch(SDK_SRC, true, (_event, filename) => {
try {
const stat = fs.statSync(path.join(SDK_SRC, String(filename ?? "")));
if (stat.isDirectory() || stat.mtimeMs < buildStarted) return;
} catch {
// deleted or renamed, still a change
}
changed("sdk");
});
} }
function watchElectron() { function watchElectron() {
@@ -597,18 +580,9 @@ function watchPlugins(dir) {
queue(() => rebuildPlugin(byId.get(id))), queue(() => rebuildPlugin(byId.get(id))),
); );
for (const plugin of plugins) { for (const plugin of plugins) {
// Only source folders, so node_modules and .git are never watched watch(plugin.repo, true, (_event, filename) => {
const onEvent = (prefix) => (_event, filename) => { if (isPluginEdit(plugin.repo, filename)) changed(plugin.id);
const name = prefix });
? path.join(prefix, String(filename ?? ""))
: filename;
if (isPluginEdit(plugin.repo, name)) changed(plugin.id);
};
watch(plugin.repo, false, onEvent(""));
for (const sub of ["src", "locales", "migrations"]) {
const target = path.join(plugin.repo, sub);
if (fs.existsSync(target)) watch(target, true, onEvent(sub));
}
} }
log(`watching ${plugins.length} plugin(s) in ${dir}`); log(`watching ${plugins.length} plugin(s) in ${dir}`);
} }
-14
View File
@@ -45,18 +45,6 @@ function buildTable(version, mobileVersion, mobileTag) {
].join("\n"); ].join("\n");
} }
function buildFullBuilds(version) {
const base = `https://github.com/Termix-SSH/Termix/releases/download/v${version}`;
const link = (label, file) => `[${label}](${base}/${file})`;
return [
"The builds above include the standard plugins. The full builds include every official plugin, which helps on a network with no internet. You can still turn plugins on or off in the Plugins tab. Full builds are x64 only (universal on Mac):",
"",
`- Windows: ${link("EXE", "termix_windows_x64_full_nsis.exe")}`,
`- Linux: ${link("AppImage", "termix_linux_x64_full_appimage.AppImage")} · ${link("DEB", "termix_linux_x64_full_deb.deb")}`,
`- Mac: ${link("DMG", "termix_macos_universal_full_dmg.dmg")}`,
].join("\n");
}
function buildBody({ version, mobileVersion, mobileTag, changelog }) { function buildBody({ version, mobileVersion, mobileTag, changelog }) {
const notes = changelogSection(changelog, version); const notes = changelogSection(changelog, version);
if (!notes) fail(`CHANGELOG.md has no notes for ${version}`); if (!notes) fail(`CHANGELOG.md has no notes for ${version}`);
@@ -74,8 +62,6 @@ function buildBody({ version, mobileVersion, mobileTag, changelog }) {
"### Downloads", "### Downloads",
"", "",
buildTable(version, mobileVersion, mobileTag), buildTable(version, mobileVersion, mobileTag),
"",
buildFullBuilds(version),
].join("\n"); ].join("\n");
} }
-12
View File
@@ -34,18 +34,6 @@ describe("buildBody", () => {
); );
}); });
it("links the full builds", () => {
const body = buildBody({
version: "26.10.0",
mobileVersion: "1.2.0",
changelog: CHANGELOG,
});
expect(body).toContain("v26.10.0/termix_windows_x64_full_nsis.exe");
expect(body).toContain("v26.10.0/termix_linux_x64_full_appimage.AppImage");
expect(body).toContain("v26.10.0/termix_linux_x64_full_deb.deb");
expect(body).toContain("v26.10.0/termix_macos_universal_full_dmg.dmg");
});
it("works for the real changelog", () => { it("works for the real changelog", () => {
const fs = require("fs"); const fs = require("fs");
const body = buildBody({ const body = buildBody({
+4 -56
View File
@@ -16,11 +16,6 @@
* environment variables is set, for a server configured before first boot. * environment variables is set, for a server configured before first boot.
* Plugins without it start unchecked. * Plugins without it start unchecked.
* *
* "tier" says which image profile ships the plugin: "minimal" (only what the
* app cannot start without, the default look), "standard" (the default image)
* or "full" (everything else). A profile includes its own tier and every
* smaller one. A plugin with no tier counts as "full".
*
* "defaultLook": true marks the plugin whose layout (and that layout's * "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 * 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 * never names a plugin. Such a plugin always starts enabled and cannot be
@@ -36,8 +31,6 @@ const ID = /^[a-z0-9][a-z0-9-]*$/;
const ENV_NAME = /^[A-Z][A-Z0-9_]*$/; const ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
const ONBOARDING_KEYS = new Set(["recommended", "consent", "enabledByEnv"]); const ONBOARDING_KEYS = new Set(["recommended", "consent", "enabledByEnv"]);
const SHA256 = /^[0-9a-f]{64}$/; const SHA256 = /^[0-9a-f]{64}$/;
const PROFILES = ["minimal", "standard", "full"];
const DEFAULT_PROFILE = "standard";
function parseOnboarding(value, id, problems) { function parseOnboarding(value, id, problems) {
if (value === undefined) return null; if (value === undefined) return null;
@@ -129,9 +122,6 @@ function parseBundledPlugins(raw) {
) { ) {
problems.push(`${entry.id} defaultLook must be a boolean`); problems.push(`${entry.id} defaultLook must be a boolean`);
} }
if (entry.tier !== undefined && !PROFILES.includes(entry.tier)) {
problems.push(`${entry.id} tier must be one of ${PROFILES.join(", ")}`);
}
if (problems.length > before) continue; if (problems.length > before) continue;
plugins.push({ plugins.push({
id: entry.id, id: entry.id,
@@ -141,7 +131,6 @@ function parseBundledPlugins(raw) {
sha256: entry.sha256, sha256: entry.sha256,
onboarding, onboarding,
defaultLook: entry.defaultLook === true, defaultLook: entry.defaultLook === true,
tier: entry.tier ?? "full",
}); });
} else { } else {
problems.push(`${entry.id} has an unknown source "${entry.source}"`); problems.push(`${entry.id} has an unknown source "${entry.source}"`);
@@ -252,73 +241,35 @@ function buildBundledIndex(plugins, stagedIds) {
return { version: 1, plugins: out }; return { version: 1, plugins: out };
} }
/** The profile to build: --profile, then TERMIX_PLUGIN_PROFILE, then standard. */
function resolveProfile(argv = process.argv, env = process.env) {
const flag = argv.indexOf("--profile");
const value =
(flag !== -1 ? argv[flag + 1] : env.TERMIX_PLUGIN_PROFILE) ||
DEFAULT_PROFILE;
if (!PROFILES.includes(value)) {
throw new Error(
`unknown plugin profile "${value}", use ${PROFILES.join(", ")}`,
);
}
return value;
}
/** The plugins a profile ships: its own tier and every smaller one. */
function pluginsForProfile(plugins, profile) {
const rank = PROFILES.indexOf(profile);
return plugins.filter((plugin) => PROFILES.indexOf(plugin.tier) <= rank);
}
/** /**
* Pins the newest registry version of each wanted plugin. `ids` is the list * Pins the newest registry version of each wanted plugin. `ids` is the list
* to pin, or null for every plugin in the index. `current` is the existing * to pin, or null for every plugin in the index. `current` is the existing
* pin list, so fields the registry does not know (onboarding, tier) carry over; * pin list, so fields the registry does not know (onboarding) carry over.
* a plugin new to the list starts in the full tier. coreVersion, when given,
* skips releases whose engine.termix range does not include it, so the image
* never ships a plugin its own core cannot run. With ids null, a plugin new to
* the list that has no usable release (only betas, say) is left out.
*/ */
function pinsFromIndex(index, ids, current = [], coreVersion = null) { function pinsFromIndex(index, ids, current = []) {
const plugins = Array.isArray(index?.plugins) ? index.plugins : []; const plugins = Array.isArray(index?.plugins) ? index.plugins : [];
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin])); const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
const currentById = new Map(current.map((entry) => [entry.id, entry])); const currentById = new Map(current.map((entry) => [entry.id, entry]));
const wanted = ids ?? plugins.map((plugin) => plugin.id); const wanted = ids ?? plugins.map((plugin) => plugin.id);
const pins = [...wanted].sort().map((id) => { return [...wanted].sort().map((id) => {
const listed = byId.get(id); const listed = byId.get(id);
// gated holds releases that need a newer Termix than old builds check for. // gated holds releases that need a newer Termix than old builds check for.
const latest = [...(listed?.versions ?? []), ...(listed?.gated ?? [])] const latest = [...(listed?.versions ?? []), ...(listed?.gated ?? [])]
.filter((entry) => semver.valid(entry.version)) .filter((entry) => semver.valid(entry.version))
.filter((entry) => !semver.prerelease(entry.version)) .filter((entry) => !semver.prerelease(entry.version))
.filter(
(entry) =>
!coreVersion ||
!entry.engine?.termix ||
semver.satisfies(coreVersion, entry.engine.termix, {
includePrerelease: true,
}),
)
.sort((a, b) => semver.rcompare(a.version, b.version))[0]; .sort((a, b) => semver.rcompare(a.version, b.version))[0];
if (!latest) { if (!latest) throw new Error(`${id} is not in the registry index`);
if (ids === null && !currentById.has(id)) return null;
throw new Error(`${id} is not in the registry index`);
}
const onboarding = currentById.get(id)?.onboarding; const onboarding = currentById.get(id)?.onboarding;
const defaultLook = currentById.get(id)?.defaultLook === true; const defaultLook = currentById.get(id)?.defaultLook === true;
const tier = currentById.get(id)?.tier ?? "full";
return { return {
id, id,
source: "tmxplug", source: "tmxplug",
url: latest.url, url: latest.url,
sha256: latest.sha256, sha256: latest.sha256,
tier,
...(onboarding ? { onboarding } : {}), ...(onboarding ? { onboarding } : {}),
...(defaultLook ? { defaultLook } : {}), ...(defaultLook ? { defaultLook } : {}),
}; };
}); });
return pins.filter(Boolean);
} }
const LOCAL_FILES = [ const LOCAL_FILES = [
@@ -458,9 +409,6 @@ function copyLocalBuild(repo, destination) {
} }
module.exports = { module.exports = {
PROFILES,
resolveProfile,
pluginsForProfile,
findLocalPluginBuilds, findLocalPluginBuilds,
isLocalBuildStale, isLocalBuildStale,
localSdkBuildCommand, localSdkBuildCommand,
+2 -7
View File
@@ -4,10 +4,7 @@
* re-released. * re-released.
* *
* node scripts/pin-bundled-plugins.cjs refresh the plugins already listed * node scripts/pin-bundled-plugins.cjs refresh the plugins already listed
* node scripts/pin-bundled-plugins.cjs --all also add plugins new to the index that have a stable release (tier full) * node scripts/pin-bundled-plugins.cjs --all list every plugin in the index
* --core-version X skip releases whose engine.termix excludes X
*
* The release workflow runs it with --all and the version being released.
*/ */
const fs = require("node:fs"); const fs = require("node:fs");
@@ -25,8 +22,6 @@ const configPath = path.join(root, "docker", "bundled-plugins.json");
async function main() { async function main() {
const all = process.argv.includes("--all"); const all = process.argv.includes("--all");
const flag = process.argv.indexOf("--core-version");
const coreVersion = flag !== -1 ? process.argv[flag + 1] : null;
const response = await fetch(INDEX_URL); const response = await fetch(INDEX_URL);
if (!response.ok) { if (!response.ok) {
throw new Error(`registry index download failed with ${response.status}`); throw new Error(`registry index download failed with ${response.status}`);
@@ -35,7 +30,7 @@ async function main() {
const current = loadBundledPlugins(root); const current = loadBundledPlugins(root);
const ids = all ? null : current.map((plugin) => plugin.id); const ids = all ? null : current.map((plugin) => plugin.id);
const plugins = pinsFromIndex(index, ids, current, coreVersion); const plugins = pinsFromIndex(index, ids, current);
fs.writeFileSync(configPath, `${JSON.stringify({ plugins }, null, 2)}\n`); fs.writeFileSync(configPath, `${JSON.stringify({ plugins }, null, 2)}\n`);
console.log( console.log(
`Pinned ${plugins.length} plugin(s) in docker/bundled-plugins.json`, `Pinned ${plugins.length} plugin(s) in docker/bundled-plugins.json`,
-29
View File
@@ -144,32 +144,3 @@ describe("termix-plugin validate layouts and capabilities", () => {
expect(output).not.toContain("is declared but"); expect(output).not.toContain("is declared but");
}); });
}); });
describe("termix-plugin validate mobile entry", () => {
function mobile(built: boolean) {
const root = plugin([]);
fs.writeFileSync(
path.join(root, "manifest.json"),
JSON.stringify({
id: "fixture",
version: "1.0.0",
contributes: { mobile: { mode: "webview", entry: "dist/mobile.js" } },
}),
);
if (built) {
fs.mkdirSync(path.join(root, "dist"), { recursive: true });
fs.writeFileSync(path.join(root, "dist", "mobile.js"), "");
}
return root;
}
it("reports a mobile entry that was not built", async () => {
expect(await problems(mobile(false))).toContain(
"manifest references dist/mobile.js, which does not exist",
);
});
it("finds a built mobile entry", async () => {
expect(await problems(mobile(true))).not.toContain("dist/mobile.js");
});
});
-2
View File
@@ -30,7 +30,6 @@ import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
import { syncForwarder } from "../sync/provider.js"; import { syncForwarder } from "../sync/provider.js";
import { syncChangeWatcher } from "../sync/server/change-watcher.js"; import { syncChangeWatcher } from "../sync/server/change-watcher.js";
import dashboardRoutes from "./routes/dashboard-routes.js"; import dashboardRoutes from "./routes/dashboard-routes.js";
import operationsRoutes from "./routes/operations-routes.js";
import { import {
mountPluginApi, mountPluginApi,
mountPluginLegacyPaths, mountPluginLegacyPaths,
@@ -1628,7 +1627,6 @@ app.use("/ui-preferences", uiPreferencesRoutes);
registerAuditLogRoutes(app, authenticateJWT); registerAuditLogRoutes(app, authenticateJWT);
app.use("/sync", syncForwarder); app.use("/sync", syncForwarder);
app.use("/dashboard", dashboardRoutes); app.use("/dashboard", dashboardRoutes);
app.use("/operations", operationsRoutes);
app.use("/plugins", pluginRoutes); app.use("/plugins", pluginRoutes);
app.use( app.use(
"/plugin-assets", "/plugin-assets",
+4 -86
View File
@@ -484,19 +484,6 @@ async function initializeCompleteDatabase(): Promise<void> {
); );
CREATE INDEX IF NOT EXISTS idx_folder_access_owner_folder ON folder_access (owner_user_id, folder); CREATE INDEX IF NOT EXISTS idx_folder_access_owner_folder ON folder_access (owner_user_id, folder);
CREATE TABLE IF NOT EXISTS shared_folder_auth_overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
folder TEXT NOT NULL,
protocol TEXT NOT NULL DEFAULT 'ssh',
credential_id INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS shared_folder_auth_overrides_user_folder_protocol_unique ON shared_folder_auth_overrides (user_id, folder, protocol);
CREATE TABLE IF NOT EXISTS plugins ( CREATE TABLE IF NOT EXISTS plugins (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
@@ -599,70 +586,6 @@ async function initializeCompleteDatabase(): Promise<void> {
UNIQUE (role_name, permission) UNIQUE (role_name, permission)
); );
CREATE TABLE IF NOT EXISTS operation_runs (
id TEXT PRIMARY KEY,
action_id TEXT NOT NULL,
action_version INTEGER NOT NULL DEFAULT 1,
plugin_id TEXT NOT NULL,
plugin_version TEXT NOT NULL,
user_id TEXT REFERENCES users (id) ON DELETE SET NULL,
username TEXT NOT NULL,
origin TEXT NOT NULL,
host_id INTEGER,
host_name TEXT,
batch_id TEXT,
dry_run INTEGER NOT NULL DEFAULT 0,
risk TEXT NOT NULL,
status TEXT NOT NULL,
summary TEXT,
changed INTEGER,
input TEXT,
sealed_input TEXT,
output TEXT,
undo_input TEXT,
error TEXT,
error_code TEXT,
undo_of TEXT,
idempotency_key TEXT,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_operation_runs_created ON operation_runs (created_at);
CREATE INDEX IF NOT EXISTS idx_operation_runs_user_created ON operation_runs (user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_operation_runs_host_created ON operation_runs (host_id, created_at);
CREATE INDEX IF NOT EXISTS idx_operation_runs_status ON operation_runs (status);
CREATE INDEX IF NOT EXISTS idx_operation_runs_batch ON operation_runs (batch_id);
CREATE TABLE IF NOT EXISTS operation_approvals (
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL REFERENCES operation_runs (id) ON DELETE CASCADE,
requested_by TEXT,
reason TEXT,
allow_self INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL,
decided_by TEXT,
comment TEXT,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
decided_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_operation_approvals_status ON operation_approvals (status, expires_at);
CREATE INDEX IF NOT EXISTS idx_operation_approvals_run ON operation_approvals (run_id);
CREATE TABLE IF NOT EXISTS operation_events (
id TEXT PRIMARY KEY,
event_id TEXT NOT NULL,
plugin_id TEXT NOT NULL,
host_id INTEGER,
severity TEXT,
payload TEXT,
run_id TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_operation_events_created ON operation_events (created_at);
CREATE INDEX IF NOT EXISTS idx_operation_events_host_created ON operation_events (host_id, created_at);
CREATE TABLE IF NOT EXISTS api_keys ( CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
@@ -1025,7 +948,6 @@ const enforceUniqueUsernames = () => {
const migrateSchema = () => { const migrateSchema = () => {
repairSnippetsColumns(sqlite); repairSnippetsColumns(sqlite);
addColumnIfNotExists("api_keys", "scopes", "TEXT");
addColumnIfNotExists("user_preferences", "theme", "TEXT"); addColumnIfNotExists("user_preferences", "theme", "TEXT");
addColumnIfNotExists("user_preferences", "font_size", "TEXT"); addColumnIfNotExists("user_preferences", "font_size", "TEXT");
addColumnIfNotExists("user_preferences", "accent_color", "TEXT"); addColumnIfNotExists("user_preferences", "accent_color", "TEXT");
@@ -1394,8 +1316,6 @@ const migrateSchema = () => {
{ column: "ssh_port", sql: "ALTER TABLE ssh_data ADD COLUMN ssh_port INTEGER DEFAULT 22" }, { column: "ssh_port", sql: "ALTER TABLE ssh_data ADD COLUMN ssh_port INTEGER DEFAULT 22" },
{ column: "connection_origin", sql: "ALTER TABLE ssh_data ADD COLUMN connection_origin TEXT" }, { column: "connection_origin", sql: "ALTER TABLE ssh_data ADD COLUMN connection_origin TEXT" },
{ column: "parent_host_id", sql: "ALTER TABLE ssh_data ADD COLUMN parent_host_id INTEGER REFERENCES ssh_data(id) ON DELETE SET NULL" }, { column: "parent_host_id", sql: "ALTER TABLE ssh_data ADD COLUMN parent_host_id INTEGER REFERENCES ssh_data(id) ON DELETE SET NULL" },
{ column: "color", sql: "ALTER TABLE ssh_data ADD COLUMN color TEXT" },
{ column: "icon", sql: "ALTER TABLE ssh_data ADD COLUMN icon TEXT" },
]; ];
for (const migration of sshDataMigrations) { for (const migration of sshDataMigrations) {
@@ -1877,8 +1797,6 @@ async function saveMemoryDatabaseToFile(): Promise<void> {
if (!memoryDatabase) return; if (!memoryDatabase) return;
try { try {
// Before the copy, so a write during the slow part below stays dirty.
DatabaseSaveTrigger.markClean();
const buffer = memoryDatabase.serialize(); const buffer = memoryDatabase.serialize();
if (!fs.existsSync(dataDir)) { if (!fs.existsSync(dataDir)) {
@@ -1899,10 +1817,11 @@ async function saveMemoryDatabaseToFile(): Promise<void> {
encryptedDbPath, encryptedDbPath,
); );
} else { } else {
await fs.promises.writeFile(dbPath, buffer); fs.writeFileSync(dbPath, buffer);
} }
DatabaseSaveTrigger.markClean();
} catch (error) { } catch (error) {
DatabaseSaveTrigger.markDirty();
databaseLogger.error("Failed to save in-memory database", error, { databaseLogger.error("Failed to save in-memory database", error, {
operation: "memory_db_save_failed", operation: "memory_db_save_failed",
enableFileEncryption, enableFileEncryption,
@@ -1921,8 +1840,7 @@ async function handlePostInitFileEncryption() {
setInterval(() => { setInterval(() => {
if (DatabaseSaveTrigger.isDirty) { if (DatabaseSaveTrigger.isDirty) {
// Through the trigger so it waits for any save already running. saveMemoryDatabaseToFile();
void DatabaseSaveTrigger.forceSave("periodic_save").catch(() => {});
} }
}, 5 * 60 * 1000); }, 5 * 60 * 1000);
} }
-127
View File
@@ -193,9 +193,6 @@ export const hosts = mysqlTable(
// legacy algorithms, agent, environment). JSON. // legacy algorithms, agent, environment). JSON.
sshOptions: text("ssh_options"), sshOptions: text("ssh_options"),
notes: text("notes"), notes: text("notes"),
// Hex color and Lucide icon name a layout may show next to the host.
color: text("color"),
icon: text("icon"),
enableSsh: boolean("enable_ssh").notNull().default(true), enableSsh: boolean("enable_ssh").notNull().default(true),
sshPort: int("ssh_port").default(22), sshPort: int("ssh_port").default(22),
@@ -490,38 +487,6 @@ export const sharedHostAuthOverrides = mysqlTable(
], ],
); );
/**
* A recipient's own login for the shared hosts in a folder ("Parent / Child")
* and its subfolders. A host's own override still wins.
*/
export const sharedFolderAuthOverrides = mysqlTable(
"shared_folder_auth_overrides",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
folder: varchar("folder", { length: 255 }).notNull(),
protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"),
credentialId: int("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
uniqueIndex("shared_folder_auth_overrides_user_folder_protocol_unique").on(
table.userId,
table.folder,
table.protocol,
),
],
);
export const sharedHostSecrets = mysqlTable( export const sharedHostSecrets = mysqlTable(
"shared_host_secrets", "shared_host_secrets",
{ {
@@ -673,102 +638,10 @@ export const apiKeys = mysqlTable(
expiresAt: text("expires_at"), expiresAt: text("expires_at"),
lastUsedAt: text("last_used_at"), lastUsedAt: text("last_used_at"),
isActive: boolean("is_active").notNull().default(true), isActive: boolean("is_active").notNull().default(true),
// JSON list. Null means full access, which every older key has.
scopes: text("scopes"),
}, },
(table) => [index("idx_api_keys_user_id").on(table.userId)], (table) => [index("idx_api_keys_user_id").on(table.userId)],
); );
export const operationRuns = mysqlTable(
"operation_runs",
{
id: varchar("id", { length: 255 }).primaryKey(),
actionId: text("action_id").notNull(),
actionVersion: int("action_version").notNull().default(1),
pluginId: text("plugin_id").notNull(),
pluginVersion: text("plugin_version").notNull(),
// Nullable on purpose: history outlives the account, like audit_logs.
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "set null" }),
username: text("username").notNull(),
origin: text("origin").notNull(),
hostId: int("host_id"),
hostName: text("host_name"),
batchId: varchar("batch_id", { length: 255 }),
dryRun: boolean("dry_run").notNull().default(false),
risk: text("risk").notNull(),
status: varchar("status", { length: 255 }).notNull(),
summary: text("summary"),
changed: boolean("changed"),
input: text("input"),
// Only until the run ends, then wiped.
sealedInput: text("sealed_input"),
output: text("output"),
undoInput: text("undo_input"),
error: text("error"),
errorCode: text("error_code"),
undoOf: text("undo_of"),
idempotencyKey: text("idempotency_key"),
createdAt: varchar("created_at", { length: 255 }).notNull(),
startedAt: text("started_at"),
finishedAt: text("finished_at"),
},
(table) => [
index("idx_operation_runs_created").on(table.createdAt),
index("idx_operation_runs_user_created").on(table.userId, table.createdAt),
index("idx_operation_runs_host_created").on(table.hostId, table.createdAt),
index("idx_operation_runs_status").on(table.status),
index("idx_operation_runs_batch").on(table.batchId),
],
);
export const operationApprovals = mysqlTable(
"operation_approvals",
{
id: varchar("id", { length: 255 }).primaryKey(),
runId: varchar("run_id", { length: 255 })
.notNull()
.references(() => operationRuns.id, { onDelete: "cascade" }),
requestedBy: text("requested_by"),
reason: text("reason"),
allowSelf: boolean("allow_self")
.notNull()
.default(false),
status: varchar("status", { length: 255 }).notNull(),
decidedBy: text("decided_by"),
comment: text("comment"),
createdAt: text("created_at").notNull(),
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
decidedAt: text("decided_at"),
},
(table) => [
index("idx_operation_approvals_status").on(table.status, table.expiresAt),
index("idx_operation_approvals_run").on(table.runId),
],
);
export const operationEvents = mysqlTable(
"operation_events",
{
id: varchar("id", { length: 255 }).primaryKey(),
eventId: text("event_id").notNull(),
pluginId: text("plugin_id").notNull(),
hostId: int("host_id"),
severity: text("severity"),
payload: text("payload"),
runId: text("run_id"),
createdAt: varchar("created_at", { length: 255 }).notNull(),
},
(table) => [
index("idx_operation_events_created").on(table.createdAt),
index("idx_operation_events_host_created").on(
table.hostId,
table.createdAt,
),
],
);
export const userOpenTabs = mysqlTable( export const userOpenTabs = mysqlTable(
"user_open_tabs", "user_open_tabs",
{ {
-127
View File
@@ -193,9 +193,6 @@ export const hosts = pgTable(
// legacy algorithms, agent, environment). JSON. // legacy algorithms, agent, environment). JSON.
sshOptions: text("ssh_options"), sshOptions: text("ssh_options"),
notes: text("notes"), notes: text("notes"),
// Hex color and Lucide icon name a layout may show next to the host.
color: text("color"),
icon: text("icon"),
enableSsh: boolean("enable_ssh").notNull().default(true), enableSsh: boolean("enable_ssh").notNull().default(true),
sshPort: integer("ssh_port").default(22), sshPort: integer("ssh_port").default(22),
@@ -490,38 +487,6 @@ export const sharedHostAuthOverrides = pgTable(
], ],
); );
/**
* A recipient's own login for the shared hosts in a folder ("Parent / Child")
* and its subfolders. A host's own override still wins.
*/
export const sharedFolderAuthOverrides = pgTable(
"shared_folder_auth_overrides",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
folder: varchar("folder", { length: 255 }).notNull(),
protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"),
credentialId: integer("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("shared_folder_auth_overrides_user_folder_protocol_unique").on(
table.userId,
table.folder,
table.protocol,
),
],
);
export const sharedHostSecrets = pgTable( export const sharedHostSecrets = pgTable(
"shared_host_secrets", "shared_host_secrets",
{ {
@@ -673,102 +638,10 @@ export const apiKeys = pgTable(
expiresAt: text("expires_at"), expiresAt: text("expires_at"),
lastUsedAt: text("last_used_at"), lastUsedAt: text("last_used_at"),
isActive: boolean("is_active").notNull().default(true), isActive: boolean("is_active").notNull().default(true),
// JSON list. Null means full access, which every older key has.
scopes: text("scopes"),
}, },
(table) => [index("idx_api_keys_user_id").on(table.userId)], (table) => [index("idx_api_keys_user_id").on(table.userId)],
); );
export const operationRuns = pgTable(
"operation_runs",
{
id: varchar("id", { length: 255 }).primaryKey(),
actionId: text("action_id").notNull(),
actionVersion: integer("action_version").notNull().default(1),
pluginId: text("plugin_id").notNull(),
pluginVersion: text("plugin_version").notNull(),
// Nullable on purpose: history outlives the account, like audit_logs.
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "set null" }),
username: text("username").notNull(),
origin: text("origin").notNull(),
hostId: integer("host_id"),
hostName: text("host_name"),
batchId: varchar("batch_id", { length: 255 }),
dryRun: boolean("dry_run").notNull().default(false),
risk: text("risk").notNull(),
status: varchar("status", { length: 255 }).notNull(),
summary: text("summary"),
changed: boolean("changed"),
input: text("input"),
// Only until the run ends, then wiped.
sealedInput: text("sealed_input"),
output: text("output"),
undoInput: text("undo_input"),
error: text("error"),
errorCode: text("error_code"),
undoOf: text("undo_of"),
idempotencyKey: text("idempotency_key"),
createdAt: varchar("created_at", { length: 255 }).notNull(),
startedAt: text("started_at"),
finishedAt: text("finished_at"),
},
(table) => [
index("idx_operation_runs_created").on(table.createdAt),
index("idx_operation_runs_user_created").on(table.userId, table.createdAt),
index("idx_operation_runs_host_created").on(table.hostId, table.createdAt),
index("idx_operation_runs_status").on(table.status),
index("idx_operation_runs_batch").on(table.batchId),
],
);
export const operationApprovals = pgTable(
"operation_approvals",
{
id: varchar("id", { length: 255 }).primaryKey(),
runId: varchar("run_id", { length: 255 })
.notNull()
.references(() => operationRuns.id, { onDelete: "cascade" }),
requestedBy: text("requested_by"),
reason: text("reason"),
allowSelf: boolean("allow_self")
.notNull()
.default(false),
status: varchar("status", { length: 255 }).notNull(),
decidedBy: text("decided_by"),
comment: text("comment"),
createdAt: text("created_at").notNull(),
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
decidedAt: text("decided_at"),
},
(table) => [
index("idx_operation_approvals_status").on(table.status, table.expiresAt),
index("idx_operation_approvals_run").on(table.runId),
],
);
export const operationEvents = pgTable(
"operation_events",
{
id: varchar("id", { length: 255 }).primaryKey(),
eventId: text("event_id").notNull(),
pluginId: text("plugin_id").notNull(),
hostId: integer("host_id"),
severity: text("severity"),
payload: text("payload"),
runId: text("run_id"),
createdAt: varchar("created_at", { length: 255 }).notNull(),
},
(table) => [
index("idx_operation_events_created").on(table.createdAt),
index("idx_operation_events_host_created").on(
table.hostId,
table.createdAt,
),
],
);
export const userOpenTabs = pgTable( export const userOpenTabs = pgTable(
"user_open_tabs", "user_open_tabs",
{ {
-127
View File
@@ -183,9 +183,6 @@ export const hosts = sqliteTable(
// legacy algorithms, agent, environment). JSON. // legacy algorithms, agent, environment). JSON.
sshOptions: text("ssh_options"), sshOptions: text("ssh_options"),
notes: text("notes"), notes: text("notes"),
// Hex color and Lucide icon name a layout may show next to the host.
color: text("color"),
icon: text("icon"),
enableSsh: integer("enable_ssh", { mode: "boolean" }).notNull().default(true), enableSsh: integer("enable_ssh", { mode: "boolean" }).notNull().default(true),
sshPort: integer("ssh_port").default(22), sshPort: integer("ssh_port").default(22),
@@ -480,38 +477,6 @@ export const sharedHostAuthOverrides = sqliteTable(
], ],
); );
/**
* A recipient's own login for the shared hosts in a folder ("Parent / Child")
* and its subfolders. A host's own override still wins.
*/
export const sharedFolderAuthOverrides = sqliteTable(
"shared_folder_auth_overrides",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
folder: text("folder").notNull(),
protocol: text("protocol").notNull().default("ssh"),
credentialId: integer("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("shared_folder_auth_overrides_user_folder_protocol_unique").on(
table.userId,
table.folder,
table.protocol,
),
],
);
export const sharedHostSecrets = sqliteTable( export const sharedHostSecrets = sqliteTable(
"shared_host_secrets", "shared_host_secrets",
{ {
@@ -663,102 +628,10 @@ export const apiKeys = sqliteTable(
expiresAt: text("expires_at"), expiresAt: text("expires_at"),
lastUsedAt: text("last_used_at"), lastUsedAt: text("last_used_at"),
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true), isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
// JSON list. Null means full access, which every older key has.
scopes: text("scopes"),
}, },
(table) => [index("idx_api_keys_user_id").on(table.userId)], (table) => [index("idx_api_keys_user_id").on(table.userId)],
); );
export const operationRuns = sqliteTable(
"operation_runs",
{
id: text("id").primaryKey(),
actionId: text("action_id").notNull(),
actionVersion: integer("action_version").notNull().default(1),
pluginId: text("plugin_id").notNull(),
pluginVersion: text("plugin_version").notNull(),
// Nullable on purpose: history outlives the account, like audit_logs.
userId: text("user_id").references(() => users.id, { onDelete: "set null" }),
username: text("username").notNull(),
origin: text("origin").notNull(),
hostId: integer("host_id"),
hostName: text("host_name"),
batchId: text("batch_id"),
dryRun: integer("dry_run", { mode: "boolean" }).notNull().default(false),
risk: text("risk").notNull(),
status: text("status").notNull(),
summary: text("summary"),
changed: integer("changed", { mode: "boolean" }),
input: text("input"),
// Only until the run ends, then wiped.
sealedInput: text("sealed_input"),
output: text("output"),
undoInput: text("undo_input"),
error: text("error"),
errorCode: text("error_code"),
undoOf: text("undo_of"),
idempotencyKey: text("idempotency_key"),
createdAt: text("created_at").notNull(),
startedAt: text("started_at"),
finishedAt: text("finished_at"),
},
(table) => [
index("idx_operation_runs_created").on(table.createdAt),
index("idx_operation_runs_user_created").on(table.userId, table.createdAt),
index("idx_operation_runs_host_created").on(table.hostId, table.createdAt),
index("idx_operation_runs_status").on(table.status),
index("idx_operation_runs_batch").on(table.batchId),
],
);
export const operationApprovals = sqliteTable(
"operation_approvals",
{
id: text("id").primaryKey(),
runId: text("run_id")
.notNull()
.references(() => operationRuns.id, { onDelete: "cascade" }),
requestedBy: text("requested_by"),
reason: text("reason"),
allowSelf: integer("allow_self", { mode: "boolean" })
.notNull()
.default(false),
status: text("status").notNull(),
decidedBy: text("decided_by"),
comment: text("comment"),
createdAt: text("created_at").notNull(),
expiresAt: text("expires_at").notNull(),
decidedAt: text("decided_at"),
},
(table) => [
index("idx_operation_approvals_status").on(table.status, table.expiresAt),
index("idx_operation_approvals_run").on(table.runId),
],
);
export const operationEvents = sqliteTable(
"operation_events",
{
id: text("id").primaryKey(),
eventId: text("event_id").notNull(),
pluginId: text("plugin_id").notNull(),
hostId: integer("host_id"),
severity: text("severity"),
payload: text("payload"),
runId: text("run_id"),
createdAt: text("created_at").notNull(),
},
(table) => [
index("idx_operation_events_created").on(table.createdAt),
index("idx_operation_events_host_created").on(
table.hostId,
table.createdAt,
),
],
);
export const userOpenTabs = sqliteTable( export const userOpenTabs = sqliteTable(
"user_open_tabs", "user_open_tabs",
{ {
@@ -17,8 +17,6 @@ export interface ApiKeyListRecord {
expiresAt: string | null; expiresAt: string | null;
lastUsedAt: string | null; lastUsedAt: string | null;
isActive: boolean; isActive: boolean;
/** JSON list, or null for full access. */
scopes: string | null;
} }
export class ApiKeyRepository { export class ApiKeyRepository {
@@ -45,7 +43,6 @@ export class ApiKeyRepository {
expiresAt: apiKeys.expiresAt, expiresAt: apiKeys.expiresAt,
lastUsedAt: apiKeys.lastUsedAt, lastUsedAt: apiKeys.lastUsedAt,
isActive: apiKeys.isActive, isActive: apiKeys.isActive,
scopes: apiKeys.scopes,
}) })
.from(apiKeys) .from(apiKeys)
.leftJoin(users, eq(apiKeys.userId, users.id)) .leftJoin(users, eq(apiKeys.userId, users.id))
@@ -13,7 +13,6 @@ import { HostSidebarPreferenceRepository } from "./host-sidebar-preference-repos
import { CredentialSidebarPreferenceRepository } from "./credential-sidebar-preference-repository.js"; import { CredentialSidebarPreferenceRepository } from "./credential-sidebar-preference-repository.js";
import { UiPreferenceRepository } from "./ui-preference-repository.js"; import { UiPreferenceRepository } from "./ui-preference-repository.js";
import { OpenTabRepository } from "./open-tab-repository.js"; import { OpenTabRepository } from "./open-tab-repository.js";
import { OperationRepository } from "./operation-repository.js";
import { PluginRepository } from "./plugin-repository.js"; import { PluginRepository } from "./plugin-repository.js";
import { PluginStorageRepository } from "./plugin-storage-repository.js"; import { PluginStorageRepository } from "./plugin-storage-repository.js";
import { PluginSettingsRepository } from "./plugin-settings-repository.js"; import { PluginSettingsRepository } from "./plugin-settings-repository.js";
@@ -32,7 +31,6 @@ import { FolderAccessRepository } from "./folder-access-repository.js";
import { SettingsRepository } from "./settings-repository.js"; import { SettingsRepository } from "./settings-repository.js";
import { HostProtocolAuthRepository } from "./host-protocol-auth-repository.js"; import { HostProtocolAuthRepository } from "./host-protocol-auth-repository.js";
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js"; import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
import { SharedFolderAuthOverrideRepository } from "./shared-folder-auth-override-repository.js";
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js"; import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js"; import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
import { TrustedDeviceRepository } from "./trusted-device-repository.js"; import { TrustedDeviceRepository } from "./trusted-device-repository.js";
@@ -135,14 +133,6 @@ export function createCurrentAuditLogRepository(): AuditLogRepository {
); );
} }
export function createCurrentOperationRepository(): OperationRepository {
return new OperationRepository(
createCurrentRepositoryContext(),
// Rows land on every run. A rollout saves once on its own when it starts.
createCurrentRepositoryLazyWriteHook("operation_repository_write"),
);
}
export function createCurrentCredentialRepository(): CredentialRepository { export function createCurrentCredentialRepository(): CredentialRepository {
return new CredentialRepository( return new CredentialRepository(
createCurrentRepositoryContext(), createCurrentRepositoryContext(),
@@ -326,15 +316,6 @@ export function createCurrentSharedHostAuthOverrideRepository(): SharedHostAuthO
); );
} }
export function createCurrentSharedFolderAuthOverrideRepository(): SharedFolderAuthOverrideRepository {
return new SharedFolderAuthOverrideRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook(
"shared_folder_auth_override_repository_write",
),
);
}
export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageRepository { export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageRepository {
return new SshCredentialUsageRepository( return new SshCredentialUsageRepository(
createCurrentRepositoryContext(), createCurrentRepositoryContext(),
@@ -1,255 +0,0 @@
import { and, desc, eq, inArray, lt, or, sql } from "drizzle-orm";
import {
operationApprovals,
operationEvents,
operationRuns,
} from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { countValue, rowsAffected } from "./mutation-result.js";
export type OperationRunRecord = typeof operationRuns.$inferSelect;
export type NewOperationRunRecord = typeof operationRuns.$inferInsert;
export type OperationApprovalRecord = typeof operationApprovals.$inferSelect;
export type NewOperationApprovalRecord = typeof operationApprovals.$inferInsert;
export type OperationEventRecord = typeof operationEvents.$inferSelect;
export type NewOperationEventRecord = typeof operationEvents.$inferInsert;
export interface OperationRunFilters {
userId?: string;
/** Rows also visible because the viewer owns the host. */
orHostIds?: number[];
hostId?: number;
actionId?: string;
status?: string;
batchId?: string;
before?: string;
}
const MAX_PAGE = 200;
export class OperationRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async insertRun(run: NewOperationRunRecord): Promise<void> {
await this.context.drizzle.insert(operationRuns).values(run);
await this.onWrite?.();
}
async findRun(id: string): Promise<OperationRunRecord | null> {
const rows = await this.context.drizzle
.select()
.from(operationRuns)
.where(eq(operationRuns.id, id))
.limit(1);
return rows[0] ?? null;
}
async findByIdempotencyKey(
userId: string,
key: string,
): Promise<OperationRunRecord | null> {
const rows = await this.context.drizzle
.select()
.from(operationRuns)
.where(
and(
eq(operationRuns.userId, userId),
eq(operationRuns.idempotencyKey, key),
),
)
.limit(1);
return rows[0] ?? null;
}
async listRuns(
filters: OperationRunFilters,
limit = 50,
): Promise<OperationRunRecord[]> {
const owner = filters.userId
? filters.orHostIds?.length
? or(
eq(operationRuns.userId, filters.userId),
inArray(operationRuns.hostId, filters.orHostIds),
)
: eq(operationRuns.userId, filters.userId)
: undefined;
const conditions = [
owner,
filters.hostId !== undefined
? eq(operationRuns.hostId, filters.hostId)
: undefined,
filters.actionId
? eq(operationRuns.actionId, filters.actionId)
: undefined,
filters.status ? eq(operationRuns.status, filters.status) : undefined,
filters.batchId ? eq(operationRuns.batchId, filters.batchId) : undefined,
filters.before ? lt(operationRuns.createdAt, filters.before) : undefined,
].filter((condition) => condition !== undefined);
return this.context.drizzle
.select()
.from(operationRuns)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(operationRuns.createdAt))
.limit(Math.min(Math.max(limit, 1), MAX_PAGE));
}
/** Moves a run on only from the status given, so two writers cannot race. */
async updateRunFrom(
id: string,
fromStatus: string,
patch: Partial<NewOperationRunRecord>,
): Promise<boolean> {
const result = await this.context.drizzle
.update(operationRuns)
.set(patch)
.where(
and(eq(operationRuns.id, id), eq(operationRuns.status, fromStatus)),
);
await this.onWrite?.();
return rowsAffected(result) > 0;
}
/** Anything still running when the server stopped has an unknown outcome. */
async markRunningInterrupted(finishedAt: string): Promise<number> {
const result = await this.context.drizzle
.update(operationRuns)
.set({
status: "interrupted",
finishedAt,
sealedInput: null,
error: "The server stopped during this run. Check the host.",
})
.where(eq(operationRuns.status, "running"));
await this.onWrite?.();
return rowsAffected(result);
}
async insertApproval(approval: NewOperationApprovalRecord): Promise<void> {
await this.context.drizzle.insert(operationApprovals).values(approval);
await this.onWrite?.();
}
async findApproval(id: string): Promise<OperationApprovalRecord | null> {
const rows = await this.context.drizzle
.select()
.from(operationApprovals)
.where(eq(operationApprovals.id, id))
.limit(1);
return rows[0] ?? null;
}
async findApprovalForRun(
runId: string,
): Promise<OperationApprovalRecord | null> {
const rows = await this.context.drizzle
.select()
.from(operationApprovals)
.where(eq(operationApprovals.runId, runId))
.limit(1);
return rows[0] ?? null;
}
async listPendingApprovals(limit = 100): Promise<OperationApprovalRecord[]> {
return this.context.drizzle
.select()
.from(operationApprovals)
.where(eq(operationApprovals.status, "pending"))
.orderBy(desc(operationApprovals.createdAt))
.limit(Math.min(Math.max(limit, 1), MAX_PAGE));
}
/** One shot: only the first decision on a pending row wins. */
async decideApproval(
id: string,
status: "approved" | "rejected" | "expired",
decidedBy: string | null,
comment: string | null,
decidedAt: string,
): Promise<boolean> {
const result = await this.context.drizzle
.update(operationApprovals)
.set({ status, decidedBy, comment, decidedAt })
.where(
and(
eq(operationApprovals.id, id),
eq(operationApprovals.status, "pending"),
),
);
await this.onWrite?.();
return rowsAffected(result) > 0;
}
async listExpiredApprovals(now: string): Promise<OperationApprovalRecord[]> {
return this.context.drizzle
.select()
.from(operationApprovals)
.where(
and(
eq(operationApprovals.status, "pending"),
lt(operationApprovals.expiresAt, now),
),
)
.limit(MAX_PAGE);
}
async insertEvent(event: NewOperationEventRecord): Promise<void> {
await this.context.drizzle.insert(operationEvents).values(event);
await this.onWrite?.();
}
async listEvents(
filters: { hostId?: number; before?: string },
limit = 50,
): Promise<OperationEventRecord[]> {
const conditions = [
filters.hostId !== undefined
? eq(operationEvents.hostId, filters.hostId)
: undefined,
filters.before
? lt(operationEvents.createdAt, filters.before)
: undefined,
].filter((condition) => condition !== undefined);
return this.context.drizzle
.select()
.from(operationEvents)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(operationEvents.createdAt))
.limit(Math.min(Math.max(limit, 1), MAX_PAGE));
}
/** Drops rows older than the cutoff, finished runs only. */
async prune(before: string): Promise<number> {
const runs = await this.context.drizzle
.delete(operationRuns)
.where(
and(
lt(operationRuns.createdAt, before),
sql`${operationRuns.status} NOT IN ('running', 'pending_approval')`,
),
);
const events = await this.context.drizzle
.delete(operationEvents)
.where(lt(operationEvents.createdAt, before));
await this.onWrite?.();
return rowsAffected(runs) + rowsAffected(events);
}
async countRuns(): Promise<number> {
const rows = await this.context.drizzle
.select({ value: sql<number>`count(*)` })
.from(operationRuns);
return countValue(rows[0]?.value);
}
/** History outlives the account, but the name goes with it. */
async anonymizeUser(userId: string): Promise<void> {
await this.context.drizzle
.update(operationRuns)
.set({ username: "deleted user" })
.where(eq(operationRuns.userId, userId));
await this.onWrite?.();
}
}
@@ -1,115 +0,0 @@
import { and, eq, inArray } from "drizzle-orm";
import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js";
import { sharedFolderAuthOverrides } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { deleteReturning, upsert } from "./returning.js";
/** "A / B / C" and the folders above it, closest first. */
export function folderAndParents(folder: string | null | undefined): string[] {
const parts = (folder ?? "")
.split(" / ")
.map((part) => part.trim())
.filter(Boolean);
const out: string[] = [];
for (let i = parts.length; i > 0; i--) {
out.push(parts.slice(0, i).join(" / "));
}
return out;
}
export class SharedFolderAuthOverrideRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async findCredentialId(
userId: string,
folder: string,
protocol: AuthOverrideProtocol,
): Promise<number | null> {
const rows = await this.context.drizzle
.select({ credentialId: sharedFolderAuthOverrides.credentialId })
.from(sharedFolderAuthOverrides)
.where(
and(
eq(sharedFolderAuthOverrides.userId, userId),
eq(sharedFolderAuthOverrides.folder, folder),
eq(sharedFolderAuthOverrides.protocol, protocol),
),
)
.limit(1);
return rows[0]?.credentialId ?? null;
}
/**
* The login set on the host's folder or the closest folder above it, for
* one protocol. Null when none is set.
*/
async findClosest(
userId: string,
hostFolder: string | null | undefined,
protocol: AuthOverrideProtocol,
): Promise<{ folder: string; credentialId: number } | null> {
const folders = folderAndParents(hostFolder);
if (folders.length === 0) return null;
const rows = await this.context.drizzle
.select({
folder: sharedFolderAuthOverrides.folder,
credentialId: sharedFolderAuthOverrides.credentialId,
})
.from(sharedFolderAuthOverrides)
.where(
and(
eq(sharedFolderAuthOverrides.userId, userId),
eq(sharedFolderAuthOverrides.protocol, protocol),
inArray(sharedFolderAuthOverrides.folder, folders),
),
);
for (const folder of folders) {
const row = rows.find((candidate) => candidate.folder === folder);
if (row) return row;
}
return null;
}
async setCredential(
userId: string,
folder: string,
protocol: AuthOverrideProtocol,
credentialId: number,
): Promise<void> {
await upsert(
this.context,
sharedFolderAuthOverrides,
{ userId, folder, protocol, credentialId },
{
target: [
sharedFolderAuthOverrides.userId,
sharedFolderAuthOverrides.folder,
sharedFolderAuthOverrides.protocol,
],
set: { credentialId, updatedAt: new Date().toISOString() },
},
);
await this.onWrite?.();
}
async clearCredential(
userId: string,
folder: string,
protocol: AuthOverrideProtocol,
): Promise<boolean> {
const rows = await deleteReturning(
this.context,
sharedFolderAuthOverrides,
and(
eq(sharedFolderAuthOverrides.userId, userId),
eq(sharedFolderAuthOverrides.folder, folder),
eq(sharedFolderAuthOverrides.protocol, protocol),
),
);
if (rows.length > 0) await this.onWrite?.();
return rows.length > 0;
}
}
@@ -3,7 +3,6 @@ import { recentActivity } from "../../plugins/activity-provider.js";
import { import {
createCurrentApiKeyRepository, createCurrentApiKeyRepository,
createCurrentAuditLogRepository, createCurrentAuditLogRepository,
createCurrentOperationRepository,
createCurrentCredentialRepository, createCurrentCredentialRepository,
createCurrentHostFolderRepository, createCurrentHostFolderRepository,
createCurrentHostRepository, createCurrentHostRepository,
@@ -62,7 +61,6 @@ export async function deleteUserAndRelatedData(
await createCurrentRoleRepository().removeAllRolesFromUser(userId); await createCurrentRoleRepository().removeAllRolesFromUser(userId);
await createCurrentAuditLogRepository().anonymizeByUserId(userId); await createCurrentAuditLogRepository().anonymizeByUserId(userId);
await createCurrentOperationRepository().anonymizeUser(userId);
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId); await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
@@ -30,33 +30,6 @@ export function applyHostKeyTypeUpdate(
} }
} }
const HOST_COLOR = /^#[0-9a-fA-F]{6}$/;
const HOST_ICON = /^[a-z0-9-]{1,64}$/;
/**
* Color and icon from a host write. A field left out stays as stored, so
* editors that don't know about them never clear them. Bad values store null.
*/
export function normalizeHostLookFields(values: Record<string, unknown>): {
color?: string | null;
icon?: string | null;
} {
const result: { color?: string | null; icon?: string | null } = {};
if (values.color !== undefined) {
result.color =
typeof values.color === "string" && HOST_COLOR.test(values.color)
? values.color.toLowerCase()
: null;
}
if (values.icon !== undefined) {
result.icon =
typeof values.icon === "string" && HOST_ICON.test(values.icon)
? values.icon
: null;
}
return result;
}
const PROTOCOL_ENABLE_FIELDS = ["enableSsh"] as const; const PROTOCOL_ENABLE_FIELDS = ["enableSsh"] as const;
export function normalizeProtocolEnableFields( export function normalizeProtocolEnableFields(
-3
View File
@@ -33,7 +33,6 @@ import {
isOptionalAuthType, isOptionalAuthType,
isOptionalBoolean, isOptionalBoolean,
isValidPort, isValidPort,
normalizeHostLookFields,
normalizeProtocolEnableFields, normalizeProtocolEnableFields,
OWNER_PRIVATE_AUTH_FIELDS, OWNER_PRIVATE_AUTH_FIELDS,
OWNER_PRIVATE_SSH_OPTION_FIELDS, OWNER_PRIVATE_SSH_OPTION_FIELDS,
@@ -336,7 +335,6 @@ router.post(
? JSON.stringify(portKnockSequence) ? JSON.stringify(portKnockSequence)
: null, : null,
...normalizeProtocolEnableFields(hostData), ...normalizeProtocolEnableFields(hostData),
...normalizeHostLookFields(hostData),
sshPort: sshPort || port || 22, sshPort: sshPort || port || 22,
}; };
@@ -914,7 +912,6 @@ router.put(
? JSON.stringify(portKnockSequence) ? JSON.stringify(portKnockSequence)
: null, : null,
...normalizeProtocolEnableFields(hostData), ...normalizeProtocolEnableFields(hostData),
...normalizeHostLookFields(hostData),
sshPort: sshPort || port || 22, sshPort: sshPort || port || 22,
}; };
+1 -5
View File
@@ -18,7 +18,6 @@ import {
safeLayoutForced, safeLayoutForced,
unavailableLooks, unavailableLooks,
} from "../../plugins/looks.js"; } from "../../plugins/looks.js";
import { safeModeOn } from "../../plugins/safe-mode.js";
const router = express.Router(); const router = express.Router();
const authManager = AuthManager.getInstance(); const authManager = AuthManager.getInstance();
@@ -37,7 +36,7 @@ async function adminDefaults() {
* /user-preferences/look: * /user-preferences/look:
* get: * get:
* summary: Get the layout and theme to show the current user * 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, whether TERMIX_SAFE_MODE runs only layout and theme plugins, and installed layout or theme plugins that could not load. The browser resolves the final look from these. * 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: * tags:
* - User Preferences * - User Preferences
* responses: * responses:
@@ -76,8 +75,6 @@ async function adminDefaults() {
* nullable: true * nullable: true
* safeLayout: * safeLayout:
* type: boolean * type: boolean
* safeMode:
* type: boolean
* unavailable: * unavailable:
* type: array * type: array
* items: * items:
@@ -115,7 +112,6 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
defaults: { ...defaults, layout: known(defaults.layout) }, defaults: { ...defaults, layout: known(defaults.layout) },
defaultLook: resolveDefaultLook(loader), defaultLook: resolveDefaultLook(loader),
safeLayout: safeLayoutForced(), safeLayout: safeLayoutForced(),
safeMode: safeModeOn(),
unavailable: unavailableLooks(loader), unavailable: unavailableLooks(loader),
}); });
} catch (error) { } catch (error) {
@@ -1,760 +0,0 @@
import express, { type Request, type Response } from "express";
import type {
ResourceListResult,
SignalResult,
} from "@termix-ssh/plugin-sdk/operations";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { databaseLogger } from "../../utils/logger.js";
import { getErrorMessage } from "../../utils/error-message.js";
import { createCurrentOperationRepository } from "../repositories/factory.js";
import type { OperationRunRecord } from "../repositories/operation-repository.js";
import { buildCatalog } from "../../operations/catalog.js";
import {
cancelOperationRun,
decideOperationApproval,
OperationError,
runOperation,
} from "../../operations/gate.js";
import { getAction } from "../../operations/registry.js";
import {
flattenResourceRows,
readAcrossHosts,
} from "../../operations/resources.js";
import type { ActorOrigin } from "../../plugins/actor.js";
const router = express.Router();
const authenticateJWT = AuthManager.getInstance().createAuthMiddleware();
const permissions = PermissionManager.getInstance();
/** Sees every run on the server, not only their own. */
export const VIEW_ALL_RUNS = "admin.operations.view";
type OperationsRequest = Request & {
userId: string;
apiKeyId?: string;
apiKeyScopes?: string[] | null;
actingAdminUserId?: string;
};
function originOf(req: OperationsRequest): ActorOrigin {
return { kind: req.apiKeyId ? "api" : "ui" };
}
function sendError(res: Response, error: unknown, operation: string): void {
if (error instanceof OperationError) {
res.status(error.status).json({
error: error.message,
code: error.code,
details: error.details,
});
return;
}
databaseLogger.error("Operations route failed", error, { operation });
res.status(500).json({ error: getErrorMessage(error, "Request failed") });
}
function parseJson(text: string | null): unknown {
if (!text) return undefined;
try {
return JSON.parse(text);
} catch {
return undefined;
}
}
/** Never includes the sealed input. */
function publicRun(run: OperationRunRecord) {
return {
id: run.id,
actionId: run.actionId,
pluginId: run.pluginId,
pluginVersion: run.pluginVersion,
userId: run.userId,
username: run.username,
origin: parseJson(run.origin),
hostId: run.hostId,
hostName: run.hostName,
batchId: run.batchId,
dryRun: run.dryRun,
risk: run.risk,
status: run.status,
summary: run.summary,
changed: run.changed,
input: parseJson(run.input),
output: parseJson(run.output),
undoAvailable: run.undoInput !== null,
error: run.error,
errorCode: run.errorCode,
undoOf: run.undoOf,
createdAt: run.createdAt,
startedAt: run.startedAt,
finishedAt: run.finishedAt,
};
}
async function canSeeRun(
run: OperationRunRecord,
userId: string,
): Promise<boolean> {
if (run.userId === userId) return true;
if (await permissions.hasPermission(userId, VIEW_ALL_RUNS)) return true;
if (run.hostId === null) return false;
const access = await permissions.canAccessHost(userId, run.hostId, "manage");
return access.hasAccess;
}
function hostIdsFrom(value: unknown): number[] | undefined {
if (typeof value !== "string" || value === "") return undefined;
const ids = value
.split(",")
.map(Number)
.filter((id) => Number.isInteger(id));
return ids.length > 0 ? ids : undefined;
}
function limitFrom(value: unknown): number | undefined {
const limit = Number(value);
return Number.isInteger(limit) && limit > 0 ? limit : undefined;
}
/**
* @openapi
* /operations/catalog:
* get:
* summary: List what this user can do
* description: Every action, event, resource and signal from running plugins that the caller has permission for, with titles in the requested language.
* tags:
* - Operations
* parameters:
* - in: query
* name: lang
* schema:
* type: string
* - in: query
* name: surface
* schema:
* type: string
* enum: [assistant, mcp, palette, automations, mobile, cli]
* responses:
* 200:
* description: The catalog.
*/
router.get("/catalog", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
res.json(
await buildCatalog({
userId,
lang: typeof req.query.lang === "string" ? req.query.lang : undefined,
surface:
typeof req.query.surface === "string"
? (req.query.surface as never)
: undefined,
}),
);
} catch (error) {
sendError(res, error, "operations_catalog");
}
});
/**
* @openapi
* /operations/catalog/{id}:
* get:
* summary: Describe one action
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The action.
* 404:
* description: No such action, or the caller may not use it.
*/
router.get("/catalog/:id", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const catalog = await buildCatalog({
userId,
lang: typeof req.query.lang === "string" ? req.query.lang : undefined,
});
const action = catalog.actions.find((entry) => entry.id === req.params.id);
if (!action) {
res.status(404).json({ error: "Action not found" });
return;
}
res.json(action);
} catch (error) {
sendError(res, error, "operations_catalog_action");
}
});
/**
* @openapi
* /operations/runs:
* get:
* summary: List runs
* description: The caller's own runs, runs on hosts they manage, or every run with the view permission.
* tags:
* - Operations
* parameters:
* - in: query
* name: hostId
* schema:
* type: integer
* - in: query
* name: actionId
* schema:
* type: string
* - in: query
* name: status
* schema:
* type: string
* - in: query
* name: batchId
* schema:
* type: string
* - in: query
* name: before
* schema:
* type: string
* - in: query
* name: limit
* schema:
* type: integer
* responses:
* 200:
* description: Runs, newest first.
*/
router.get("/runs", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const viewAll = await permissions.hasPermission(userId, VIEW_ALL_RUNS);
const hostId = limitFrom(req.query.hostId);
let managed = false;
if (!viewAll && hostId !== undefined) {
managed = (await permissions.canAccessHost(userId, hostId, "manage"))
.hasAccess;
}
const runs = await createCurrentOperationRepository().listRuns(
{
userId: viewAll || managed ? undefined : userId,
hostId,
actionId:
typeof req.query.actionId === "string"
? req.query.actionId
: undefined,
status:
typeof req.query.status === "string" ? req.query.status : undefined,
batchId:
typeof req.query.batchId === "string" ? req.query.batchId : undefined,
before:
typeof req.query.before === "string" ? req.query.before : undefined,
},
limitFrom(req.query.limit),
);
res.json({ runs: runs.map(publicRun) });
} catch (error) {
sendError(res, error, "operations_runs");
}
});
/**
* @openapi
* /operations/runs/{id}:
* get:
* summary: Get a run
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The run.
* 404:
* description: Not found, or not visible to the caller.
*/
router.get("/runs/:id", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const run = await createCurrentOperationRepository().findRun(
String(req.params.id),
);
if (!run || !(await canSeeRun(run, userId))) {
res.status(404).json({ error: "Run not found" });
return;
}
res.json(publicRun(run));
} catch (error) {
sendError(res, error, "operations_run");
}
});
/**
* @openapi
* /operations/runs/{id}/cancel:
* post:
* summary: Cancel a run
* description: Stops a running action, or withdraws one waiting for approval.
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Whether anything was cancelled.
* 404:
* description: Not found, or not visible to the caller.
*/
router.post("/runs/:id/cancel", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const run = await createCurrentOperationRepository().findRun(
String(req.params.id),
);
if (!run || !(await canSeeRun(run, userId))) {
res.status(404).json({ error: "Run not found" });
return;
}
res.json({ cancelled: await cancelOperationRun(run) });
} catch (error) {
sendError(res, error, "operations_cancel");
}
});
/**
* @openapi
* /operations/runs/{id}/undo:
* post:
* summary: Undo a run
* description: Runs the action's undo with the input the run recorded. It goes through the same checks as any run.
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The new run.
* 404:
* description: Not found, or nothing to undo.
*/
router.post("/runs/:id/undo", authenticateJWT, async (req, res) => {
const authReq = req as OperationsRequest;
try {
const run = await createCurrentOperationRepository().findRun(
String(req.params.id),
);
const undo = run ? getAction(run.actionId)?.action.undo : undefined;
if (
!run ||
!undo ||
!run.undoInput ||
!(await canSeeRun(run, authReq.userId))
) {
res.status(404).json({ error: "Nothing to undo" });
return;
}
res.json(
await runOperation({
actionId: undo,
hostId: run.hostId ?? undefined,
input: parseJson(run.undoInput),
confirmed: req.body?.confirmed === true,
undoOf: run.id,
userId: authReq.userId,
origin: originOf(authReq),
apiKeyScopes: authReq.apiKeyScopes,
}),
);
} catch (error) {
sendError(res, error, "operations_undo");
}
});
/**
* @openapi
* /operations/approvals:
* get:
* summary: List waiting approvals
* description: Requests the caller could decide, with the exact input that would run.
* tags:
* - Operations
* responses:
* 200:
* description: Waiting approvals.
*/
router.get("/approvals", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const repository = createCurrentOperationRepository();
const approvals = [];
for (const approval of await repository.listPendingApprovals()) {
const run = await repository.findRun(approval.runId);
if (run && (await canSeeRun(run, userId))) {
approvals.push({ ...approval, run: publicRun(run) });
}
}
res.json({ approvals });
} catch (error) {
sendError(res, error, "operations_approvals");
}
});
/**
* @openapi
* /operations/approvals/{id}:
* post:
* summary: Approve or reject a request
* description: One shot. What runs is the sealed request, never anything sent here.
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [approve]
* properties:
* approve:
* type: boolean
* comment:
* type: string
* responses:
* 200:
* description: The run's outcome.
* 403:
* description: The caller may not decide this.
* 409:
* description: Already decided, expired, or the host changed.
*/
router.post("/approvals/:id", authenticateJWT, async (req, res) => {
const authReq = req as OperationsRequest;
if (typeof req.body?.approve !== "boolean") {
res.status(400).json({ error: "approve must be true or false" });
return;
}
// A key could be handed to a script, so a person decides from a session.
if (authReq.apiKeyScopes) {
res.status(403).json({ error: "This API key may not approve" });
return;
}
try {
res.json(
await decideOperationApproval({
approvalId: String(req.params.id),
approverId: authReq.userId,
approve: req.body.approve,
comment:
typeof req.body.comment === "string" ? req.body.comment : undefined,
impersonated: !!authReq.actingAdminUserId,
}),
);
} catch (error) {
sendError(res, error, "operations_approve");
}
});
/**
* @openapi
* /operations/events:
* get:
* summary: List declared events
* tags:
* - Operations
* responses:
* 200:
* description: Declared events from running plugins, plus core's run events.
*/
router.get("/events", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const catalog = await buildCatalog({
userId,
lang: typeof req.query.lang === "string" ? req.query.lang : undefined,
});
res.json({ events: catalog.events });
} catch (error) {
sendError(res, error, "operations_events");
}
});
/**
* @openapi
* /operations/timeline:
* get:
* summary: Recent events
* description: Events the caller may see, newest first. Events tied to a host need access to it.
* tags:
* - Operations
* parameters:
* - in: query
* name: hostId
* schema:
* type: integer
* - in: query
* name: before
* schema:
* type: string
* - in: query
* name: limit
* schema:
* type: integer
* responses:
* 200:
* description: Events.
*/
router.get("/timeline", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const rows = await createCurrentOperationRepository().listEvents(
{
hostId: limitFrom(req.query.hostId),
before:
typeof req.query.before === "string" ? req.query.before : undefined,
},
limitFrom(req.query.limit),
);
const viewAll = await permissions.hasPermission(userId, VIEW_ALL_RUNS);
const access = new Map<number, boolean>();
const events = [];
for (const row of rows) {
if (row.hostId === null) {
if (viewAll) events.push(row);
continue;
}
let allowed = access.get(row.hostId);
if (allowed === undefined) {
allowed = (
await permissions.canAccessHost(userId, row.hostId, "connect")
).hasAccess;
access.set(row.hostId, allowed);
}
if (allowed) events.push(row);
}
res.json({
events: events.map((event) => ({
...event,
payload: parseJson(event.payload),
})),
});
} catch (error) {
sendError(res, error, "operations_timeline");
}
});
/**
* @openapi
* /operations/signals:
* get:
* summary: Read status tiles
* description: Answers from memory right away and refreshes stale hosts behind it, so a page should poll. Reads hosts the caller can reach.
* tags:
* - Operations
* parameters:
* - in: query
* name: hostIds
* description: Comma separated. Leave out for every host.
* schema:
* type: string
* - in: query
* name: ids
* description: Comma separated signal ids. Leave out for all.
* schema:
* type: string
* responses:
* 200:
* description: One entry per signal with per host results.
*/
router.get("/signals", authenticateJWT, async (req, res) => {
const authReq = req as OperationsRequest;
try {
const catalog = await buildCatalog({ userId: authReq.userId });
const wanted =
typeof req.query.ids === "string" && req.query.ids
? new Set(req.query.ids.split(","))
: null;
const hostIds = hostIdsFrom(req.query.hostIds);
const signals = [];
for (const signal of catalog.signals) {
if (wanted && !wanted.has(signal.id)) continue;
if (signal.scope === "server") {
const outcome = await runOperation({
actionId: signal.read,
internal: true,
userId: authReq.userId,
origin: originOf(authReq),
}).catch(() => null);
signals.push({
id: signal.id,
hosts: [],
result: outcome?.data as SignalResult | undefined,
});
continue;
}
signals.push({
id: signal.id,
hosts: await readAcrossHosts<SignalResult>({
userId: authReq.userId,
actionId: signal.read,
hostIds,
origin: originOf(authReq),
}),
});
}
res.json({ signals });
} catch (error) {
sendError(res, error, "operations_signals");
}
});
/**
* @openapi
* /operations/resources/{id}:
* get:
* summary: Read one kind of thing across hosts
* description: Rows from cache plus each host's state (ok, loading, offline, error, no-access). Answers right away and refreshes stale hosts behind it, so a page should poll until no host is loading.
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* - in: query
* name: hostIds
* description: Comma separated. Leave out for every host.
* schema:
* type: string
* - in: query
* name: refresh
* schema:
* type: boolean
* responses:
* 200:
* description: Rows and per host state.
* 404:
* description: No such resource, or the caller may not read it.
*/
router.get("/resources/:id", authenticateJWT, async (req, res) => {
const authReq = req as OperationsRequest;
try {
const catalog = await buildCatalog({ userId: authReq.userId });
const resource = catalog.resources.find(
(entry) => entry.id === req.params.id,
);
if (!resource) {
res.status(404).json({ error: "Resource not found" });
return;
}
const reads = await readAcrossHosts<ResourceListResult>({
userId: authReq.userId,
actionId: resource.list,
hostIds: hostIdsFrom(req.query.hostIds),
refresh: req.query.refresh === "true",
origin: originOf(authReq),
});
res.json(flattenResourceRows(reads, resource.key));
} catch (error) {
sendError(res, error, "operations_resources");
}
});
/**
* @openapi
* /operations/{id}/run:
* post:
* summary: Run an action
* description: Goes through permission, host access, input and approval checks. A change returns as soon as it starts unless wait is true, then poll the run. A read always answers inline.
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* resourceKey:
* type: string
* input:
* type: object
* dryRun:
* type: boolean
* confirmed:
* type: boolean
* idempotencyKey:
* type: string
* wait:
* type: boolean
* responses:
* 200:
* description: The outcome, or a run waiting for approval.
* 400:
* description: Invalid input.
* 403:
* description: Not allowed.
* 409:
* description: Needs a confirm, or safe mode is on.
*/
router.post("/:id/run", authenticateJWT, async (req, res) => {
const authReq = req as OperationsRequest;
const body = (req.body ?? {}) as Record<string, unknown>;
try {
res.json(
await runOperation({
actionId: String(req.params.id),
hostId: typeof body.hostId === "number" ? body.hostId : undefined,
resourceKey:
typeof body.resourceKey === "string" ? body.resourceKey : undefined,
input: body.input,
dryRun: body.dryRun === true,
confirmed: body.confirmed === true,
idempotencyKey:
typeof body.idempotencyKey === "string"
? body.idempotencyKey.slice(0, 128)
: undefined,
userId: authReq.userId,
origin: originOf(authReq),
apiKeyScopes: authReq.apiKeyScopes,
}),
);
} catch (error) {
sendError(res, error, "operations_run_action");
}
});
export default router;
+4 -43
View File
@@ -16,7 +16,6 @@ import { getPluginRuntime } from "../../plugins/index.js";
import { resolvePluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest"; import { resolvePluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest";
import { describePluginFrontend } from "../../plugins/assets.js"; import { describePluginFrontend } from "../../plugins/assets.js";
import { isDefaultLookPlugin } from "../../plugins/bundled-index.js"; import { isDefaultLookPlugin } from "../../plugins/bundled-index.js";
import { mayRunInSafeMode } from "../../plugins/safe-mode.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js"; import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { getPluginPublicHttpRoutes } from "../../plugins/http.js"; import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
import { getPluginPublicWsRoutes } from "../../plugins/ws.js"; import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
@@ -244,17 +243,12 @@ export async function listPreLoginPlugins(): Promise<
* `assetVersion` is its cache key, `css` says a stylesheet sits beside * `assetVersion` is its cache key, `css` says a stylesheet sits beside
* it, and `locales` lists the languages it ships ("en" and xx_YY). * it, and `locales` lists the languages it ships ("en" and xx_YY).
* `dependencies` and `optionalDependencies` let the loader activate * `dependencies` and `optionalDependencies` let the loader activate
* frontends in the same order the server does. `engine` holds the * frontends in the same order the server does.
* manifest's `api` and `termix` ranges, so other clients (the mobile
* app) can tell whether they understand the plugin.
* *
* On a desktop linked to a server, `runsOnServer` marks plugins that * On a desktop linked to a server, `runsOnServer` marks plugins that
* only run on that server and stay off here on purpose, and, for * only run on that server and stay off here on purpose, and, for
* admins, `managedByServer` marks plugins whose on/off state the server * admins, `managedByServer` marks plugins whose on/off state the server
* decides. * decides.
*
* While TERMIX_SAFE_MODE is on, an enabled plugin that safe mode keeps
* off reports `state: "safe-mode"`.
* tags: * tags:
* - Plugins * - Plugins
* responses: * responses:
@@ -305,7 +299,6 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
let features: string[] = []; let features: string[] = [];
let docs: string | undefined; let docs: string | undefined;
let env: PluginEnvVar[] = []; let env: PluginEnvVar[] = [];
const engine: { api?: string; termix?: string } = {};
let desktopMode: unknown = loaded let desktopMode: unknown = loaded
? resolvePluginDesktopMode(loaded.manifest) ? resolvePluginDesktopMode(loaded.manifest)
: undefined; : undefined;
@@ -325,14 +318,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
optionalDependencies?: Record<string, string>; optionalDependencies?: Record<string, string>;
desktop?: unknown; desktop?: unknown;
runtime?: unknown; runtime?: unknown;
engine?: { api?: unknown; termix?: unknown };
}; };
if (typeof manifest?.engine?.api === "string") {
engine.api = manifest.engine.api;
}
if (typeof manifest?.engine?.termix === "string") {
engine.termix = manifest.engine.termix;
}
desktopMode = loaded desktopMode = loaded
? resolvePluginDesktopMode(loaded.manifest) ? resolvePluginDesktopMode(loaded.manifest)
: (manifest?.desktop ?? : (manifest?.desktop ??
@@ -368,14 +354,8 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
name: record.name, name: record.name,
version: record.version, version: record.version,
enabled: record.state === "enabled", enabled: record.state === "enabled",
state: state: loaded?.state ?? record.state,
record.state === "enabled" &&
loaded?.state !== "active" &&
!mayRunInSafeMode(record.id, loader.list())
? "safe-mode"
: (loaded?.state ?? record.state),
runsOnServer: linked && desktopMode === "server", runsOnServer: linked && desktopMode === "server",
engine,
contributes, contributes,
icon, icon,
dependencies, dependencies,
@@ -956,7 +936,7 @@ router.delete(
* 404: * 404:
* description: No such plugin. * description: No such plugin.
* 409: * 409:
* description: This desktop is linked to a server, which decides whether the plugin runs, or safe mode is on and the plugin is not a layout or theme. * description: This desktop is linked to a server, which decides whether the plugin runs.
*/ */
router.patch( router.patch(
"/:id/state", "/:id/state",
@@ -1070,8 +1050,6 @@ router.patch(
* description: The caller lacks admin.plugins.manage. * description: The caller lacks admin.plugins.manage.
* 404: * 404:
* description: No such plugin, or it is not on disk. * description: No such plugin, or it is not on disk.
* 409:
* description: Safe mode is on and the plugin is not a layout or theme.
* 500: * 500:
* description: The plugin failed to start again. * description: The plugin failed to start again.
*/ */
@@ -1095,13 +1073,6 @@ router.post(
res.status(404).json({ error: "Plugin is not present on disk" }); res.status(404).json({ error: "Plugin is not present on disk" });
return; return;
} }
if (!mayRunInSafeMode(pluginId, loader.list())) {
res.status(409).json({
error: "Safe mode is on, so only layout and theme plugins can start",
code: "SAFE_MODE",
});
return;
}
await repository.update(pluginId, { await repository.update(pluginId, {
state: "enabled", state: "enabled",
@@ -2706,10 +2677,6 @@ router.put(
* summary: Upload a .tmxplug to install from a file * summary: Upload a .tmxplug to install from a file
* description: > * description: >
* Developer mode only. The body is the raw .tmxplug (at most 50 MB). * Developer mode only. The body is the raw .tmxplug (at most 50 MB).
The release's .sig may come base64 encoded in the
X-Termix-Plugin-Signature header; an official one makes the file
install as an official plugin, and it may then fill in a plugin
whose download failed.
* The file is checked (archive, manifest, plugin API) and held for 30 * The file is checked (archive, manifest, plugin API) and held for 30
* minutes, but nothing is loaded. The answer lists what it asks for so * minutes, but nothing is loaded. The answer lists what it asks for so
* the admin can agree, then POST /plugins/upload/{token}/install * the admin can agree, then POST /plugins/upload/{token}/install
@@ -2755,13 +2722,7 @@ router.post(
} }
try { try {
const { stageUpload } = await import("../../plugins/manage.js"); const { stageUpload } = await import("../../plugins/manage.js");
// The release's .sig, base64 encoded, so an official file installs as res.json(await stageUpload(req.body));
// official (and may write the tables its 2.8 version left).
const header = req.get("x-termix-plugin-signature");
const signature = header
? Buffer.from(header, "base64").toString("utf8")
: null;
res.json(await stageUpload(req.body, signature));
} catch (error) { } catch (error) {
sendManageError( sendManageError(
res, res,
+3 -209
View File
@@ -11,7 +11,6 @@ import {
import { isAuthOverrideProtocol } from "../../hosts/protocol-auth/registry.js"; import { isAuthOverrideProtocol } from "../../hosts/protocol-auth/registry.js";
import { import {
assertHostsUnlocked, assertHostsUnlocked,
hostLock,
hasHostLocks, hasHostLocks,
requireUnlockedHost, requireUnlockedHost,
sendHostLocked, sendHostLocked,
@@ -37,10 +36,8 @@ import {
createCurrentHostResolutionRepository, createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository, createCurrentRbacAccessRepository,
createCurrentRoleRepository, createCurrentRoleRepository,
createCurrentSharedFolderAuthOverrideRepository,
createCurrentUserRepository, createCurrentUserRepository,
} from "../repositories/factory.js"; } from "../repositories/factory.js";
import { findUsableCredential } from "../../hosts/usable-credential.js";
const router = express.Router(); const router = express.Router();
@@ -725,40 +722,14 @@ router.get(
}, },
); );
/** The host grants that went to the same user or role as a folder rule. */
export function grantsForFolderRule<
T extends { userId?: string | null; roleId?: number | null },
>(entries: T[], rule: { userId?: string | null; roleId?: number | null }): T[] {
return entries.filter((entry) =>
rule.userId
? entry.userId === rule.userId
: rule.roleId != null && entry.roleId === rule.roleId,
);
}
/** /**
* @openapi * @openapi
* /rbac/folder/access/{id}: * /rbac/folder/access/{id}:
* delete: * delete:
* summary: Stop a folder share from applying to hosts added later * summary: Stop a folder share from applying to hosts added later
* description: Grants already fanned out to hosts stay, unless fromHosts=true, which also removes the same user's or role's access from every host in the folder and its subfolders that the caller may manage sharing on. * description: Grants already fanned out to hosts stay; revoke those per host.
* tags: * tags:
* - RBAC * - RBAC
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* - in: query
* name: fromHosts
* schema:
* type: boolean
* responses:
* 200:
* description: Removed. removedFromHosts counts the host grants removed.
* 404:
* description: No such rule.
*/ */
router.delete( router.delete(
"/folder/access/:id", "/folder/access/:id",
@@ -770,35 +741,11 @@ router.delete(
} }
try { try {
const repository = createCurrentFolderAccessRepository(); const repository = createCurrentFolderAccessRepository();
const rule = await repository.findById(ruleId, req.userId!); if (!(await repository.findById(ruleId, req.userId!))) {
if (!rule) {
return res.status(404).json({ error: "Rule not found" }); return res.status(404).json({ error: "Rule not found" });
} }
await repository.deleteById(ruleId, req.userId!); await repository.deleteById(ruleId, req.userId!);
res.json({ success: true });
let removedFromHosts = 0;
if (req.query.fromHosts === "true") {
const hosts =
await createCurrentHostFolderRepository().listHostsInFolder(
req.userId!,
rule.folder,
);
const access = createCurrentRbacAccessRepository();
for (const host of hosts) {
if (hostLock(host.id)) continue;
if (!(await canManageHostSharing(req.userId!, host.id)).allowed)
continue;
let changed = false;
const entries = await access.listHostAccess(host.id);
for (const entry of grantsForFolderRule(entries, rule)) {
await access.revokeHostAccess(entry.id, host.id);
removedFromHosts++;
changed = true;
}
if (changed) await pruneHostGrants(host.id);
}
}
res.json({ success: true, removedFromHosts });
} catch (error) { } catch (error) {
databaseLogger.error("Failed to delete folder access rule", error, { databaseLogger.error("Failed to delete folder access rule", error, {
operation: "delete_folder_access", operation: "delete_folder_access",
@@ -2227,157 +2174,4 @@ router.get(
}, },
); );
/** A folder path as hosts store it ("Parent / Child"), or null. */
function folderParam(value: unknown): string | null {
if (typeof value !== "string") return null;
const folder = value
.split(" / ")
.map((part) => part.trim())
.filter(Boolean)
.join(" / ");
return folder && folder.length <= 255 ? folder : null;
}
/**
* @openapi
* /rbac/folder-auth/{protocol}:
* get:
* summary: Get the current user's personal login for shared hosts in a folder
* tags: [RBAC]
* parameters:
* - in: path
* name: protocol
* required: true
* schema: { type: string }
* - in: query
* name: folder
* required: true
* schema: { type: string }
* responses:
* 200:
* description: The folder, protocol and credential id, or null when none is set.
* 400:
* description: Invalid folder or protocol.
*/
router.get(
"/folder-auth/:protocol",
authenticateJWT,
requireDataAccess,
async (req: express.Request, res: express.Response) => {
try {
const userId = (req as AuthenticatedRequest).userId!;
const protocol = req.params.protocol;
const folder = folderParam(req.query.folder);
if (!folder) return res.status(400).json({ error: "Invalid folder" });
if (!isAuthOverrideProtocol(protocol)) {
return res
.status(400)
.json({ error: "Invalid authentication protocol" });
}
const credentialId =
await createCurrentSharedFolderAuthOverrideRepository().findCredentialId(
userId,
folder,
protocol,
);
res.json({ folder, protocol, credentialId });
} catch (error) {
databaseLogger.error("Failed to get folder login", error);
res.status(500).json({ error: "Failed to fetch credential" });
}
},
);
/**
* @openapi
* /rbac/folder-auth/{protocol}:
* put:
* summary: Set the current user's personal login for shared hosts in a folder
* description: >
* Used for every host shared with the user in this folder and its
* subfolders that has no personal login of its own. credentialId null
* clears it.
* tags: [RBAC]
* parameters:
* - in: path
* name: protocol
* required: true
* schema: { type: string }
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [folder, credentialId]
* properties:
* folder: { type: string }
* credentialId: { type: integer, nullable: true }
* responses:
* 200:
* description: Saved.
* 400:
* description: Invalid folder, protocol or credential id.
* 404:
* description: The credential is not one the user can use.
*/
router.put(
"/folder-auth/:protocol",
authenticateJWT,
requireDataAccess,
async (req: express.Request, res: express.Response) => {
try {
const userId = (req as AuthenticatedRequest).userId!;
const protocol = req.params.protocol;
const folder = folderParam(req.body?.folder);
const credentialId = req.body?.credentialId ?? null;
if (!folder) return res.status(400).json({ error: "Invalid folder" });
if (!isAuthOverrideProtocol(protocol)) {
return res
.status(400)
.json({ error: "Invalid authentication protocol" });
}
if (
credentialId !== null &&
(!Number.isInteger(credentialId) || credentialId <= 0)
) {
return res.status(400).json({
error: "credentialId must be a positive integer or null",
});
}
const repository = createCurrentSharedFolderAuthOverrideRepository();
if (credentialId === null) {
await repository.clearCredential(userId, folder, protocol);
} else {
if (!(await findUsableCredential(credentialId, userId))) {
return res.status(404).json({ error: "Credential not found" });
}
await repository.setCredential(userId, folder, protocol, credentialId);
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action:
credentialId === null
? "clear_shared_folder_auth_override"
: "set_shared_folder_auth_override",
resourceType: "folder",
resourceName: folder,
details: JSON.stringify({ protocol, credentialId }),
ipAddress,
userAgent,
success: true,
}).catch(() => undefined);
res.json({ success: true, folder, protocol, credentialId });
} catch (error) {
databaseLogger.error("Failed to set folder login", error);
res.status(500).json({ error: "Failed to update credential" });
}
},
);
export default router; export default router;
@@ -1,6 +1,5 @@
import type { RequestHandler, Router } from "express"; import type { RequestHandler, Router } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js"; import type { AuthenticatedRequest } from "../../../types/index.js";
import { API_KEY_SCOPES } from "../../operations/key-scopes.js";
import crypto from "crypto"; import crypto from "crypto";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
@@ -43,12 +42,6 @@ export function registerUserApiKeyRoutes(
* type: string * type: string
* format: date-time * format: date-time
* description: Optional expiration date. Null means the key never expires. * description: Optional expiration date. Null means the key never expires.
* scopes:
* type: array
* items:
* type: string
* enum: [operations:read, operations:run]
* description: Limits the key to actions only. Leave out for full access.
* responses: * responses:
* 201: * 201:
* description: API key created. Contains the full token (shown only once). * description: API key created. Contains the full token (shown only once).
@@ -63,7 +56,7 @@ export function registerUserApiKeyRoutes(
*/ */
router.post("/api-keys", requireAdmin, async (req, res) => { router.post("/api-keys", requireAdmin, async (req, res) => {
try { try {
const { name, userId: targetUserId, expiresAt, scopes } = req.body; const { name, userId: targetUserId, expiresAt } = req.body;
const apiKeyRepository = createCurrentApiKeyRepository(); const apiKeyRepository = createCurrentApiKeyRepository();
const userRepository = createCurrentUserRepository(); const userRepository = createCurrentUserRepository();
@@ -93,21 +86,6 @@ export function registerUserApiKeyRoutes(
expiresAtValue = parsed.toISOString(); expiresAtValue = parsed.toISOString();
} }
let scopesValue: string[] | null = null;
if (scopes !== undefined && scopes !== null) {
if (
!Array.isArray(scopes) ||
scopes.some(
(scope) =>
!(API_KEY_SCOPES as readonly string[]).includes(String(scope)),
)
) {
return res.status(400).json({ error: "Unknown API key scope" });
}
scopesValue =
scopes.length > 0 ? [...new Set(scopes as string[])] : null;
}
const rawToken = "tmx_" + crypto.randomBytes(32).toString("hex"); const rawToken = "tmx_" + crypto.randomBytes(32).toString("hex");
const tokenPrefix = rawToken.substring(0, 12); const tokenPrefix = rawToken.substring(0, 12);
const tokenHash = await bcrypt.hash(rawToken, 10); const tokenHash = await bcrypt.hash(rawToken, 10);
@@ -124,7 +102,6 @@ export function registerUserApiKeyRoutes(
expiresAt: expiresAtValue, expiresAt: expiresAtValue,
lastUsedAt: null, lastUsedAt: null,
isActive: true, isActive: true,
scopes: scopesValue ? JSON.stringify(scopesValue) : null,
}); });
const actorId = (req as AuthenticatedRequest).userId; const actorId = (req as AuthenticatedRequest).userId;
@@ -142,7 +119,6 @@ export function registerUserApiKeyRoutes(
details: JSON.stringify({ details: JSON.stringify({
targetUserId, targetUserId,
targetUsername: targetUser.username, targetUsername: targetUser.username,
scopes: scopesValue,
}), }),
ipAddress, ipAddress,
userAgent, userAgent,
@@ -157,7 +133,6 @@ export function registerUserApiKeyRoutes(
tokenPrefix, tokenPrefix,
createdAt: now, createdAt: now,
expiresAt: expiresAtValue, expiresAt: expiresAtValue,
scopes: scopesValue,
token: rawToken, token: rawToken,
}); });
} catch (err) { } catch (err) {
@@ -1,95 +0,0 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import bcrypt from "bcryptjs";
import { authLogger } from "../../utils/logger.js";
import { createCurrentUserRepository } from "../repositories/factory.js";
import { deleteUserAndRelatedData } from "./delete-user-data.js";
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
export function registerUserDeleteAccountRoutes(
router: Router,
authenticateJWT: RequestHandler,
): void {
/**
* @openapi
* /users/delete-account:
* delete:
* summary: Delete user account
* description: Deletes the authenticated user's account and all of their data.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* password:
* type: string
* responses:
* 200:
* description: Account deleted successfully.
* 400:
* description: Password is required.
* 401:
* description: Incorrect password.
* 403:
* description: Cannot delete external authentication accounts or the last admin user.
* 404:
* description: User not found.
* 500:
* description: Failed to delete account.
*/
router.delete("/delete-account", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { password } = req.body ?? {};
if (!isNonEmptyString(password)) {
return res
.status(400)
.json({ error: "Password is required to delete account" });
}
try {
const userRepository = createCurrentUserRepository();
const userRecord = await userRepository.findById(userId);
if (!userRecord) {
return res.status(404).json({ error: "User not found" });
}
if (userRecord.isExternal) {
return res.status(403).json({
error:
"Cannot delete external authentication accounts through this endpoint",
});
}
const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
if (!isMatch) {
authLogger.warn(
`Incorrect password provided for account deletion: ${userRecord.username}`,
);
return res.status(401).json({ error: "Incorrect password" });
}
if (userRecord.isAdmin && (await userRepository.countAdmins()) <= 1) {
return res
.status(403)
.json({ error: "Cannot delete the last admin user" });
}
// Same cleanup as an admin delete, so plugins hear user.deleted too.
await deleteUserAndRelatedData(userId);
authLogger.success(`User account deleted: ${userRecord.username}`);
res.json({ message: "Account deleted successfully" });
} catch (err) {
authLogger.error("Failed to delete user account", err);
res.status(500).json({ error: "Failed to delete account" });
}
});
}
+81 -3
View File
@@ -31,7 +31,6 @@ import { shownIdentity } from "../../sync/shown-identity.js";
import { registerUserExternalAccountRoutes } from "./user-external-account-routes.js"; import { registerUserExternalAccountRoutes } from "./user-external-account-routes.js";
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js"; import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
import { registerUserAdminRoutes } from "./user-admin-routes.js"; import { registerUserAdminRoutes } from "./user-admin-routes.js";
import { registerUserDeleteAccountRoutes } from "./user-delete-account-routes.js";
import { registerUserDataAccessRoutes } from "./user-data-access-routes.js"; import { registerUserDataAccessRoutes } from "./user-data-access-routes.js";
import { listExternalLoginMethods, registerAuthRoutes } from "./auth-routes.js"; import { listExternalLoginMethods, registerAuthRoutes } from "./auth-routes.js";
import { registerAuthCompatRoutes } from "./auth-compat-routes.js"; import { registerAuthCompatRoutes } from "./auth-compat-routes.js";
@@ -319,7 +318,7 @@ router.post("/proxy-login", async (req, res) => {
try { try {
if (!isTrustedProxyAddress(sourceAddress, config.trustedProxies)) { if (!isTrustedProxyAddress(sourceAddress, config.trustedProxies)) {
authLogger.warn( authLogger.warn(
`Rejected proxy authentication from an untrusted source: ${sourceAddress ?? "unknown"}`, "Rejected proxy authentication from an untrusted source",
{ {
operation: "trusted_proxy_auth_rejected", operation: "trusted_proxy_auth_rejected",
sourceAddress, sourceAddress,
@@ -1230,7 +1229,86 @@ router.patch("/password-reset-allowed", authenticateJWT, async (req, res) => {
} }
}); });
registerUserDeleteAccountRoutes(router, authenticateJWT); /**
* @openapi
* /users/delete-account:
* delete:
* summary: Delete user account
* description: Deletes the authenticated user's account.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* password:
* type: string
* responses:
* 200:
* description: Account deleted successfully.
* 400:
* description: Password is required.
* 401:
* description: Incorrect password.
* 403:
* description: Cannot delete external authentication accounts or the last admin user.
* 404:
* description: User not found.
* 500:
* description: Failed to delete account.
*/
router.delete("/delete-account", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { password } = req.body;
if (!isNonEmptyString(password)) {
return res
.status(400)
.json({ error: "Password is required to delete account" });
}
try {
const userRecord = await findCurrentUser(userId);
if (!userRecord) {
return res.status(404).json({ error: "User not found" });
}
if (userRecord.isExternal) {
return res.status(403).json({
error:
"Cannot delete external authentication accounts through this endpoint",
});
}
const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
if (!isMatch) {
authLogger.warn(
`Incorrect password provided for account deletion: ${userRecord.username}`,
);
return res.status(401).json({ error: "Incorrect password" });
}
if (userRecord.isAdmin) {
const adminCount = await createCurrentUserRepository().countAdmins();
if (adminCount <= 1) {
return res
.status(403)
.json({ error: "Cannot delete the last admin user" });
}
}
await createCurrentUserRepository().delete(userId);
authLogger.success(`User account deleted: ${userRecord.username}`);
res.json({ message: "Account deleted successfully" });
} catch (err) {
authLogger.error("Failed to delete user account", err);
res.status(500).json({ error: "Failed to delete account" });
}
});
registerUserPasswordResetRoutes(router, { authManager }); registerUserPasswordResetRoutes(router, { authManager });
@@ -99,12 +99,7 @@ async function prepareKey(
if (passphrase) config.passphrase = passphrase; if (passphrase) config.passphrase = passphrase;
// Lets a server that wants key then password (or password only) still work. // Lets a server that wants key then password (or password only) still work.
// ssh2 tries password first by default, so it has none left to answer with if (host.password) config.password = host.password;
// once the key was accepted as the first step.
if (host.password) {
config.password = host.password;
config.authHandler = ["publickey", "password", "keyboard-interactive"];
}
const certPublicKey = host.certPublicKey; const certPublicKey = host.certPublicKey;
if (certPublicKey && certPublicKey.trim()) { if (certPublicKey && certPublicKey.trim()) {
@@ -7,10 +7,7 @@
*/ */
import type { Duplex } from "node:stream"; import type { Duplex } from "node:stream";
import type { import type { PluginSshTransportProvider } from "@termix-ssh/plugin-sdk/backend";
PluginSshTransportHost,
PluginSshTransportProvider,
} from "@termix-ssh/plugin-sdk/backend";
interface Entry { interface Entry {
pluginId: string; pluginId: string;
@@ -36,13 +33,15 @@ export function clearSshTransports(): void {
} }
/** Opens a stream to the host if a provider claims it, else null. */ /** Opens a stream to the host if a provider claims it, else null. */
export async function openProvidedTransport( export async function openProvidedTransport(host: {
host: PluginSshTransportHost, id: number;
): Promise<Duplex | null> { ip: string;
port: number;
}): Promise<Duplex | null> {
for (const { provider } of providers.values()) { for (const { provider } of providers.values()) {
let claimed = false; let claimed = false;
try { try {
claimed = provider.claims(host.id, host); claimed = provider.claims(host.id);
} catch { } catch {
claimed = false; claimed = false;
} }
-4
View File
@@ -99,10 +99,6 @@ export async function openSshTransport(
id: host.id, id: host.id,
ip: host.ip, ip: host.ip,
port: host.port || 22, port: host.port || 22,
userId: host.userId ?? null,
syncId: host.syncId ?? null,
connectionOrigin: host.connectionOrigin ?? null,
localOnly: Boolean(host.localOnly),
}); });
if (provided) { if (provided) {
config.sock = provided; config.sock = provided;
-3
View File
@@ -24,9 +24,6 @@ export interface SshConnectHost {
port: number; port: number;
username: string; username: string;
userId?: string | null; userId?: string | null;
syncId?: string | null;
connectionOrigin?: "local" | "remote" | null;
localOnly?: boolean | null;
authType?: string | null; authType?: string | null;
password?: string | null; password?: string | null;
key?: string | Buffer | null; key?: string | Buffer | null;
-2
View File
@@ -73,8 +73,6 @@ async function exportRow(
: resolvedHost.tags || [], : resolvedHost.tags || [],
pin: !!resolvedHost.pin, pin: !!resolvedHost.pin,
notes: resolvedHost.notes || null, notes: resolvedHost.notes || null,
color: resolvedHost.color || null,
icon: resolvedHost.icon || null,
// Every plugin's host settings, secrets redacted, for import to hand back. // Every plugin's host settings, secrets redacted, for import to hand back.
pluginSettings: pluginSettings.get(host.id as number) ?? {}, pluginSettings: pluginSettings.get(host.id as number) ?? {},
// Each plugin protocol's login, secrets included like the SSH ones. // Each plugin protocol's login, secrets included like the SSH ones.
-2
View File
@@ -28,7 +28,6 @@ import { applyPluginHostImportSettings } from "../../database/routes/host-plugin
import { import {
isNonEmptyString, isNonEmptyString,
isValidPort, isValidPort,
normalizeHostLookFields,
normalizeImportedHost, normalizeImportedHost,
} from "../../database/routes/host-normalizers.js"; } from "../../database/routes/host-normalizers.js";
import { hasHostLocks, hostLock, manualCreateBlockedBy } from "../locks.js"; import { hasHostLocks, hostLock, manualCreateBlockedBy } from "../locks.js";
@@ -572,7 +571,6 @@ export async function runBulkImport(
? "true" ? "true"
: "false", : "false",
notes: hostData.notes || null, notes: hostData.notes || null,
...normalizeHostLookFields(hostData as Record<string, unknown>),
useSocks5: hostData.useSocks5 ? 1 : 0, useSocks5: hostData.useSocks5 ? 1 : 0,
socks5Host: hostData.socks5Host || null, socks5Host: hostData.socks5Host || null,
socks5Port: hostData.socks5Port || null, socks5Port: hostData.socks5Port || null,
Loaded 100 of 266 files, more files were not shown because too many files have changed in this diff. Show more