mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-04 09:35:24 +00:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc725c7889 | ||
|
|
b87fe3c6a5 | ||
|
|
b9df36b09a | ||
|
|
31df4b12e3 | ||
|
|
80612c304b | ||
|
|
a2aafc11ec | ||
|
|
3ab3022451 | ||
|
|
037b0329bd | ||
|
|
78a1e90d93 | ||
|
|
f5895f15f2 | ||
|
|
4fb91850a0 | ||
|
|
6b06059411 | ||
|
|
1d4eac3e20 | ||
|
|
c759a147c6 | ||
|
|
cba845a7b8 | ||
|
|
f90621a54e | ||
|
|
73c15f7c58 | ||
|
|
96b8520fb4 | ||
|
|
7b7b17e31f | ||
|
|
53c4e5fe9c | ||
|
|
28cb1bc554 | ||
|
|
b691d4f7cb | ||
|
|
a65b4a5d48 | ||
|
|
abaf89b732 | ||
|
|
72d868afc7 | ||
|
|
d35304e107 | ||
|
|
f4d87ddd8d | ||
|
|
4d7385f51d | ||
|
|
d3fc32928b | ||
|
|
1533c6837a |
No files matched your search
@@ -41,9 +41,15 @@ on:
|
||||
default: ""
|
||||
|
||||
jobs:
|
||||
# One image per plugin profile (docker/bundled-plugins.json). standard keeps
|
||||
# the plain tags; minimal and full add a suffix.
|
||||
build:
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
profile: [standard, minimal, full]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
@@ -80,12 +86,17 @@ jobs:
|
||||
TAGS=()
|
||||
ALL_TAGS=()
|
||||
|
||||
SUFFIX=""
|
||||
if [ "${{ matrix.profile }}" != "standard" ]; then
|
||||
SUFFIX="-${{ matrix.profile }}"
|
||||
fi
|
||||
|
||||
if [ "$BUILD_TYPE" = "Production" ]; then
|
||||
TAGS+=("$VERSION" "latest")
|
||||
TAGS+=("$VERSION$SUFFIX" "latest$SUFFIX")
|
||||
elif [ "$BUILD_TYPE" = "Beta" ]; then
|
||||
TAGS+=("beta" "$VERSION")
|
||||
TAGS+=("beta$SUFFIX" "$VERSION$SUFFIX")
|
||||
else
|
||||
TAGS+=("dev-$VERSION")
|
||||
TAGS+=("dev-$VERSION$SUFFIX")
|
||||
fi
|
||||
|
||||
for tag in "${TAGS[@]}"; do
|
||||
@@ -124,6 +135,7 @@ jobs:
|
||||
tags: ${{ env.ALL_TAGS }}
|
||||
build-args: |
|
||||
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
|
||||
PLUGIN_PROFILE=${{ matrix.profile }}
|
||||
labels: |
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ env.SOURCE_SHA }}
|
||||
|
||||
@@ -250,6 +250,27 @@ jobs:
|
||||
path: termix_windows_arm64_portable.zip
|
||||
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:
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
if: (inputs.build_type == 'all' || inputs.build_type == 'linux' || inputs.build_type == '') && inputs.artifact_destination != 'submit'
|
||||
@@ -488,6 +509,28 @@ jobs:
|
||||
path: release/com.karmaa.termix.flatpakref
|
||||
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:
|
||||
runs-on: blacksmith-6vcpu-macos-latest
|
||||
if: (inputs.build_type == 'macos' || inputs.build_type == 'all') && inputs.artifact_destination != 'submit'
|
||||
@@ -763,6 +806,32 @@ jobs:
|
||||
|
||||
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
|
||||
if: always()
|
||||
run: |
|
||||
@@ -1247,7 +1316,9 @@ jobs:
|
||||
--app_identifier "com.karmaa.termix" \
|
||||
--skip_metadata false \
|
||||
--metadata_path "${{ steps.asc_notes.outputs.metadata_path }}" \
|
||||
--skip_screenshots true \
|
||||
--skip_screenshots false \
|
||||
--screenshots_path packaging/appstore/screenshots \
|
||||
--overwrite_screenshots true \
|
||||
--skip_app_version_update false \
|
||||
--submit_for_review true \
|
||||
--automatic_release true \
|
||||
|
||||
@@ -161,9 +161,10 @@ jobs:
|
||||
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "prev_ref=$PREV" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Translates new or changed strings with the Termix LLM server and commits
|
||||
# them to the dev branch, so the beta or stable build includes them. If the
|
||||
# server is down it warns and the release goes on with what is there.
|
||||
# Translates new or changed strings with the Termix LLM server and pins the
|
||||
# bundled plugins to their newest registry releases, then commits both to the
|
||||
# dev branch so the beta or stable build includes them. If the server is down
|
||||
# it warns and the release goes on with what is there.
|
||||
translate:
|
||||
needs: [prep]
|
||||
if: ${{ needs.prep.outputs.skip != 'true' && needs.prep.outputs.mode != 'Overwrite release' }}
|
||||
@@ -191,19 +192,26 @@ jobs:
|
||||
languages: src/ui/locales/languages.json
|
||||
api-key: ${{ secrets.TERMIX_LLM_API_KEY }}
|
||||
|
||||
- name: Commit translations
|
||||
- name: Install dependencies
|
||||
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
|
||||
run: |
|
||||
git add src/ui/locales
|
||||
git add src/ui/locales docker/bundled-plugins.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "No translation changes."
|
||||
elif [ "${{ needs.prep.outputs.mode }}" = "Dry run" ]; then
|
||||
echo "DRY RUN: would commit these translation changes:"
|
||||
echo "DRY RUN: would commit these translation and pin changes:"
|
||||
git --no-pager diff --cached --stat
|
||||
else
|
||||
git config user.name "LukeGus"
|
||||
git config user.email "bugattiguy527@gmail.com"
|
||||
git commit -m "chore: update translations"
|
||||
git commit -m "chore: update translations and plugin pins"
|
||||
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
|
||||
fi
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -1,5 +1,95 @@
|
||||
# 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
|
||||
|
||||
### 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
|
||||
|
||||
### 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
|
||||
|
||||
- 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
|
||||
|
||||
### Fixed
|
||||
|
||||
- Pin classic layout plugin (fixes proxmox install)
|
||||
|
||||
## 26.10.3
|
||||
|
||||
### Added
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
cask "termix" do
|
||||
version "26.10.2"
|
||||
sha256 "9df1850b5f75a3c0a1b3876847d46b7b4ff993aa1e50da55f4c88819308ba223"
|
||||
version "26.11.0"
|
||||
sha256 "1b873e1315d769f8e416c1690032142efb152d1265c2f078b33f72cec3e6e03e"
|
||||
|
||||
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
|
||||
name "Termix"
|
||||
|
||||
@@ -66,16 +66,19 @@ Plugins add everything else. The official ones ship with Termix, and admins can
|
||||
| [Automations](https://github.com/Termix-SSH/Plugin-Automations) | Run steps on your hosts on a schedule or when something happens. |
|
||||
| [Classic Layout](https://github.com/Termix-SSH/Plugin-Classic-Layout) | The classic Termix layout with an icon rail, sidebar, tab bar and dock. |
|
||||
| [Classic Themes](https://github.com/Termix-SSH/Plugin-Classic-Themes) | Light, dark and seven more color themes for Termix. |
|
||||
| [Cloud Providers](https://github.com/Termix-SSH/Plugin-Cloud-Providers) | Add hosts from AWS, Azure, Google Cloud, DigitalOcean, Hetzner and other clouds, and keep them in sync. |
|
||||
| [Credential Tools](https://github.com/Termix-SSH/Plugin-Credential-Tools) | Generate SSH keys, check them and copy a public key to a host. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
| [Hosts Config](https://github.com/Termix-SSH/Plugin-Hosts-Config) | Keep hosts, folders and sharing in a YAML or JSON file. Termix loads them as read only hosts and reloads when the file changes. |
|
||||
| [Import and Export](https://github.com/Termix-SSH/Plugin-Import-Export) | Import hosts from a JSON file or an SSH config file and export them to share or back up. |
|
||||
| [Kubernetes](https://github.com/Termix-SSH/Plugin-Kubernetes) | Manage Kubernetes clusters from your hosts over SSH, with logs, metrics, YAML and a shell. |
|
||||
| [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. |
|
||||
| [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. |
|
||||
@@ -83,12 +86,17 @@ Plugins add everything else. The official ones ship with Termix, and admins can
|
||||
| [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. |
|
||||
| [Remote Desktop](https://github.com/Termix-SSH/Plugin-Remote-Desktop) | RDP, VNC and Telnet sessions in your browser, with clipboard, file transfer, sharing and recording. |
|
||||
| [Reverse Proxy](https://github.com/Termix-SSH/Plugin-Reverse-Proxy) | Reverse proxy for TCP, UDP, HTTP and HTTPS with free certificates and Termix login, managed per host. |
|
||||
| [Samba Shares](https://github.com/Termix-SSH/Plugin-Samba) | Create and manage Samba (SMB) file shares, users and permissions on your hosts from one tab. |
|
||||
| [Secret Sources](https://github.com/Termix-SSH/Plugin-Secret-Sources) | Pull host passwords and keys from 1Password when you connect instead of storing them in Termix. |
|
||||
| [Serial](https://github.com/Termix-SSH/Plugin-Serial) | Open a serial console to a router, switch or other device plugged into your computer. |
|
||||
| [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 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. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
@@ -98,9 +106,10 @@ 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. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
| [Wake-on-LAN](https://github.com/Termix-SSH/Plugin-Wake-On-LAN) | Wake, shut down and track your machines from one power dashboard. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
|
||||
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).
|
||||
@@ -120,15 +129,15 @@ Want to build your own? Start from the [plugin template](https://github.com/Term
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>Windows</b> <sub>x64/ia32</sub></td>
|
||||
<td>Portable | EXE and MSI Installer | Chocolatey</td>
|
||||
<td>Portable | EXE and MSI Installer | Full EXE (all plugins) | Chocolatey</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>Linux</b> <sub>x64/arm64/armv7l</sub></td>
|
||||
<td>Portable | AUR | AppImage | Deb | Flatpak</td>
|
||||
<td>Portable | AUR | AppImage | Deb | Full AppImage and Deb (all plugins) | Flatpak</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>macOS</b> <sub>Universal/x64/arm64, v12.0+</sub></td>
|
||||
<td>Apple App Store | DMG | Homebrew</td>
|
||||
<td>Apple App Store | DMG | Full DMG (all plugins) | Homebrew</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>iOS/iPadOS</b> <sub>v15.1+</sub></td>
|
||||
|
||||
+15
-4
@@ -34,9 +34,20 @@ WORKDIR /app
|
||||
|
||||
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.
|
||||
RUN --mount=type=cache,target=/app/node_modules/.cache/termix-bundled-plugins \
|
||||
npm run build:backend
|
||||
node scripts/build-plugins.cjs --profile "$PLUGIN_PROFILE"
|
||||
|
||||
# Stage 4: Download OPKSSH binary for the target platform so the image works offline
|
||||
FROM node:24-slim AS opkssh-downloader
|
||||
@@ -117,9 +128,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=backend-builder /app/dist/backend ./dist/backend
|
||||
# Plugins pinned in docker/bundled-plugins.json, resolved as dist/plugins by
|
||||
# getBundledPluginsDir().
|
||||
COPY --chown=node:node --from=backend-builder /app/dist/plugins ./dist/plugins
|
||||
# Plugins of the chosen profile pinned in docker/bundled-plugins.json, resolved
|
||||
# as dist/plugins by getBundledPluginsDir().
|
||||
COPY --chown=node:node --from=plugins /app/dist/plugins ./dist/plugins
|
||||
# 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/dist ./packages/plugin-sdk/dist
|
||||
|
||||
+210
-102
@@ -3,20 +3,23 @@
|
||||
{
|
||||
"id": "acme-ssl",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.2/acme-ssl-1.0.2.tmxplug",
|
||||
"sha256": "157d0f96dc42136ff64e618b6904db7862079cd31c1f1cf5f19846b1d127b00f"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.1.0/acme-ssl-1.1.0.tmxplug",
|
||||
"sha256": "fb75541fae110007b45ed69d58aa4b9b6f6a79b6b1b762c6e54b635aa7cb6e4a",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "ai",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.2.0/ai-1.2.0.tmxplug",
|
||||
"sha256": "3b486133af78ae5168e6f5b8a4c71f3db3403cfdae537ed1ca8d6cc16035d8dd"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.3.1/ai-1.3.1.tmxplug",
|
||||
"sha256": "dbee8bc101b8584e9e356bc3539787bb534ffc25809cd92c4f5d45d52cad4ce2",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "alerts",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.2/alerts-1.0.2.tmxplug",
|
||||
"sha256": "50768e77019276343a5d162c85d081659fb89d1cc3733ac45cd0db4436918faa",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.2.0/alerts-1.2.0.tmxplug",
|
||||
"sha256": "488947482eed0306893c2983f701a0e14c55975ca2a0c4bb0799a40fce9db054",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -26,19 +29,22 @@
|
||||
"id": "audit-tools",
|
||||
"source": "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",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.3/automations-1.0.3.tmxplug",
|
||||
"sha256": "b5e5b39a053010b72ad1ba03ea6bb05afbbb2b789a86e0e98634d9ada543d02d"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.2.0/automations-1.2.0.tmxplug",
|
||||
"sha256": "2ec9c718d73a094be3b59121aea049f0237e5783f53d193c4ea24a524584aeea",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "classic-layout",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.0/classic-layout-1.0.0.tmxplug",
|
||||
"sha256": "aff01b91a28a17013cd4cf64b47dcc02cb85a35710e3382aa9adfe7d93e0fb3a",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.1.1/classic-layout-1.1.1.tmxplug",
|
||||
"sha256": "2b0cee0c57b21d8017ed833aa923e934b57b908fa68ff395951b4d52e5113e47",
|
||||
"tier": "minimal",
|
||||
"defaultLook": true
|
||||
},
|
||||
{
|
||||
@@ -46,19 +52,40 @@
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Classic-Themes/releases/download/v1.0.0/classic-themes-1.0.0.tmxplug",
|
||||
"sha256": "919283f95701d41727c826637a7d349bd6a646abd61f9ac9017149bd0bee782c",
|
||||
"tier": "minimal",
|
||||
"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",
|
||||
"source": "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",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.1.0/docker-1.1.0.tmxplug",
|
||||
"sha256": "da32dfbe9d4cffec5d80c4a1037a79e7591a3d16c50f882db6a146322a50c2b7",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.2.3/docker-1.2.3.tmxplug",
|
||||
"sha256": "c997d5d1a751703facef544e901fb52e93fb79d5dc1c59496d4cb624210bbe14",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -67,8 +94,9 @@
|
||||
{
|
||||
"id": "file-manager",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.0/file-manager-1.1.0.tmxplug",
|
||||
"sha256": "807e9ffa80d6054a5911522721a555e0daa3e50f6edffdebe97efe7c91aea7a2",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.3/file-manager-1.1.3.tmxplug",
|
||||
"sha256": "b17315011c06db2223f04dac4606da505249dd8f2fec8aa24f1322c4dfd4d6ca",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -77,20 +105,23 @@
|
||||
{
|
||||
"id": "fleets",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.2/fleets-1.0.2.tmxplug",
|
||||
"sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.1.0/fleets-1.1.0.tmxplug",
|
||||
"sha256": "5e0218c6145524ae8776b8fcbae043749c893d9b04fb67a9b3fe262ff76c0573",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "homepage",
|
||||
"source": "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",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.1.0/host-metrics-1.1.0.tmxplug",
|
||||
"sha256": "4f1d422ec1d0565c79f94fa814ee0864d556d7367793b11035e68239e49e297f",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.3.1/host-metrics-1.3.1.tmxplug",
|
||||
"sha256": "3c14a72b3efa2a8b0010229acba64a6a4098e8c2e35d5a01cbc3cb731df39fb5",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -100,93 +131,150 @@
|
||||
"id": "host-status",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Status/releases/download/v1.0.0/host-status-1.0.0.tmxplug",
|
||||
"sha256": "1ba62577dc7373a2a2787bab7efdb89b544a30a8003b1de91ce51f372fc4f170"
|
||||
},
|
||||
{
|
||||
"id": "hosts-config",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Hosts-Config/releases/download/v1.0.0/hosts-config-1.0.0.tmxplug",
|
||||
"sha256": "60a2b2a6d9ba13bad12a511c93af70a30a10fbc560066b6419acf533d806a3ad"
|
||||
},
|
||||
{
|
||||
"id": "import-export",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.0/import-export-1.0.0.tmxplug",
|
||||
"sha256": "6670b6c147e81eed0ddfd249f9a7c9564f1ab50e45f2771161d2e30c47f6fd58"
|
||||
},
|
||||
{
|
||||
"id": "ldap",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.2/ldap-1.0.2.tmxplug",
|
||||
"sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03"
|
||||
},
|
||||
{
|
||||
"id": "login-security",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Login-Security/releases/download/v1.0.0/login-security-1.0.0.tmxplug",
|
||||
"sha256": "b21dff5722e72a02022d563ef27130e8b56a53e273f1668fade4ec7d030374aa"
|
||||
},
|
||||
{
|
||||
"id": "network-topology",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.2/network-topology-1.0.2.tmxplug",
|
||||
"sha256": "8d32340a0d0f9674b872da5d3d53e90f529b80dfe0d73ab294eed00e565fbb56"
|
||||
},
|
||||
{
|
||||
"id": "opkssh",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.2/opkssh-1.0.2.tmxplug",
|
||||
"sha256": "4bb8f1ac5d39bd18ac3d0fe82ddbf2f26ab4c01bd75e6f7a073d65c2e9aef219"
|
||||
},
|
||||
{
|
||||
"id": "proxmox",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.2/proxmox-1.0.2.tmxplug",
|
||||
"sha256": "b92be2644b0c8121732103b922069a6cb3cf00caad4e6549f7a8cb471bda5e4e"
|
||||
},
|
||||
{
|
||||
"id": "remote-desktop",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.2.0/remote-desktop-1.2.0.tmxplug",
|
||||
"sha256": "0de40896bfeb4b1a702c3ce815355b7b7b0bb4bfe424f33e905bb5a0209d02f6",
|
||||
"sha256": "1ba62577dc7373a2a2787bab7efdb89b544a30a8003b1de91ce51f372fc4f170",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hosts-config",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Hosts-Config/releases/download/v1.0.0/hosts-config-1.0.0.tmxplug",
|
||||
"sha256": "60a2b2a6d9ba13bad12a511c93af70a30a10fbc560066b6419acf533d806a3ad",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "import-export",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.1/import-export-1.0.1.tmxplug",
|
||||
"sha256": "d8f2f865e1e6b226f4fe38501bf69b752f47a643bed1d9d127181083e27da2f0",
|
||||
"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",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.2/ldap-1.0.2.tmxplug",
|
||||
"sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "login-security",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Login-Security/releases/download/v1.0.0/login-security-1.0.0.tmxplug",
|
||||
"sha256": "b21dff5722e72a02022d563ef27130e8b56a53e273f1668fade4ec7d030374aa",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "network-topology",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.2/network-topology-1.0.2.tmxplug",
|
||||
"sha256": "8d32340a0d0f9674b872da5d3d53e90f529b80dfe0d73ab294eed00e565fbb56",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "opkssh",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.2/opkssh-1.0.2.tmxplug",
|
||||
"sha256": "4bb8f1ac5d39bd18ac3d0fe82ddbf2f26ab4c01bd75e6f7a073d65c2e9aef219",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "proxmox",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.2.1/proxmox-1.2.1.tmxplug",
|
||||
"sha256": "d5a6e9cd50756785ede6aec351e2de08927816f71e54f3162c52331e0a804d74",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "remote-desktop",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.3.1/remote-desktop-1.3.1.tmxplug",
|
||||
"sha256": "41483f8d64995206e0199cca7d37033e5b1f1194725da3525e1b8afa1b4e09a9",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"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",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.2/secret-sources-1.0.2.tmxplug",
|
||||
"sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.1.1/secret-sources-1.1.1.tmxplug",
|
||||
"sha256": "2211ce2dac6355db49590532dfc339dc44df9cf14008157b420c7d4533d669ee",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "serial",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.2/serial-1.0.2.tmxplug",
|
||||
"sha256": "6ede7e8726a7312429473f34a46f93ba6657c5b7ebc12bed18f80ec62c211715",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": "desktop",
|
||||
"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",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.2/session-recording-1.0.2.tmxplug",
|
||||
"sha256": "55ace6897710ce9a63f24e3d736a9e533cac557be0f3d98974956c94cf417d1c"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.1.1/session-recording-1.1.1.tmxplug",
|
||||
"sha256": "8e77087f98227ac37a8610dcc5380ad65162a58aebd9174a6c966d5a16b16c9e",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "session-sharing",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.2/session-sharing-1.0.2.tmxplug",
|
||||
"sha256": "6408e7949a9549ac5ec94b35205729099138e730df1598d50f4bb618d8d90804"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.1.0/session-sharing-1.1.0.tmxplug",
|
||||
"sha256": "2988b39221d93b3dbc5e2a81e104e5dc01ce264afb1e66df3407492658ab9fdc",
|
||||
"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",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.2/snippets-1.0.2.tmxplug",
|
||||
"sha256": "cc1f5d74cf97f9a9c8f800fccf481c8a000e6fa516bc260ce24a7249d4f4c8e5",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.2.0/snippets-1.2.0.tmxplug",
|
||||
"sha256": "a72c0e1f38e49cf86ed8993b6913a6fbbdad7814ae132d5186c097ee3ea09bc3",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -195,8 +283,9 @@
|
||||
{
|
||||
"id": "ssh-terminal",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.1.0/ssh-terminal-1.1.0.tmxplug",
|
||||
"sha256": "dacdbadb3c6a0d7014b1de4f62b499f80abf358c7799a6e6326674660b055e4a",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.3.0/ssh-terminal-1.3.0.tmxplug",
|
||||
"sha256": "ad39449f1e60724a9f84451e1190982e5d3ecd17e1751ec09a3b21c228e6dd5e",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -205,8 +294,9 @@
|
||||
{
|
||||
"id": "sso",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.3/sso-1.0.3.tmxplug",
|
||||
"sha256": "87b03c0789d981dbc806162d73dfcc2c624150375277efd2ae40ab9d7657d48e",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.5/sso-1.0.5.tmxplug",
|
||||
"sha256": "c1353d17fd25c1f503528c2a64a31ef14a638d9845c1d6cd70bb540e0321d41c",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": false,
|
||||
"consent": false,
|
||||
@@ -217,25 +307,33 @@
|
||||
"id": "step-ca",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.2/step-ca-1.0.2.tmxplug",
|
||||
"sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a"
|
||||
"sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "sync",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.0.0/sync-1.0.0.tmxplug",
|
||||
"sha256": "12ced05523f7c1a9151b5e680726bd31f53888e8bb0047d7bd12a2570ea168bf"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.1.0/sync-1.1.0.tmxplug",
|
||||
"sha256": "9023d547f0e0bcef582c6ba7b7a8ab2ef68d8d0b973dde17d9e8c84d92b6429e",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": "desktop",
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tailscale",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.2/tailscale-1.0.2.tmxplug",
|
||||
"sha256": "5c7b00d6b6b0186d833302e39a9d31b92ea92d4542e9e2dab550b95837a59d97"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.1.0/tailscale-1.1.0.tmxplug",
|
||||
"sha256": "8784a77a1fc1b0011d8243d9520574300df31bc40c31f86339868f4c9b76d1bf",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "telemetry",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.2/telemetry-1.0.2.tmxplug",
|
||||
"sha256": "5ffdc58b05cd8b37fd0a207f99d2d3a256164130c950032501c499f869edb9ec",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": true
|
||||
@@ -245,19 +343,22 @@
|
||||
"id": "termix-identity",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.2/termix-identity-1.0.2.tmxplug",
|
||||
"sha256": "ac6c90a48b4f1e9cff6773a5fd267b2bf7319d2fc7e2aa2cd4004cec82f0f927"
|
||||
"sha256": "ac6c90a48b4f1e9cff6773a5fd267b2bf7319d2fc7e2aa2cd4004cec82f0f927",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "tmux-monitor",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.4/tmux-monitor-1.0.4.tmxplug",
|
||||
"sha256": "42b8a2284e68a321c1736984f5c641b1857c87c6a65ce8cf694c6bc946de2444"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.1.0/tmux-monitor-1.1.0.tmxplug",
|
||||
"sha256": "3cdc54a34e1ca30c4e3c217bbc612e40ef3cd3e4d5de704ea92d914b32d98006",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "totp",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.2/totp-1.0.2.tmxplug",
|
||||
"sha256": "94a0772660fa8e4dfd9afc15b68d5bc9fe02fe28a1acfd35e65a57c304d460c0",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -266,8 +367,9 @@
|
||||
{
|
||||
"id": "tunnels",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.1.0/tunnels-1.1.0.tmxplug",
|
||||
"sha256": "b1e4f1b6dd2bbd15a9a240ae1c1d381111c3609258c8946d19493a83f861ea1e",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.2.3/tunnels-1.2.3.tmxplug",
|
||||
"sha256": "5a23953ee19b2951bb895e033d2099b8f6d5abc0121253ef3da25328134984ad",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -277,31 +379,36 @@
|
||||
"id": "vault",
|
||||
"source": "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",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.2/wake-on-lan-1.0.2.tmxplug",
|
||||
"sha256": "3e9cf9a133b4dd96ec5f2b9fbf2d6a3ff7e08f8dfbf1e86b8d45de0c2f024f42"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.1.0/wake-on-lan-1.1.0.tmxplug",
|
||||
"sha256": "1afb708c12631ededec40938aed3ab587bc9a519215fa5e93350b1f0a843d2a8",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "warpgate",
|
||||
"source": "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",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.3/web-endpoint-1.0.3.tmxplug",
|
||||
"sha256": "014cbf23c82cab20e485bc4f4c9b8baa779aee9bb3ed644b41f22a9b1be95687"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.1.0/web-endpoint-1.1.0.tmxplug",
|
||||
"sha256": "79e691abc456b17c8ce651017b47097a7ff71e94267e668341548a05b911a92c",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "webauthn",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.1.0/webauthn-1.1.0.tmxplug",
|
||||
"sha256": "83a61a530db0273f9fef2080d6aa027a82ef8374fb4e0f158a8559a2770299de",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.2.0/webauthn-1.2.0.tmxplug",
|
||||
"sha256": "4e7e06d05a17b6e474fdb0d45c8fc320f2ea89375bda440f824636cc393d4615",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -310,8 +417,9 @@
|
||||
{
|
||||
"id": "workspaces",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.2/workspaces-1.0.2.tmxplug",
|
||||
"sha256": "97519a01fbb2eb2eecf21d7b9ab9028c740457bcff9fc6b075fb6facbc5ccb6b",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.1.0/workspaces-1.1.0.tmxplug",
|
||||
"sha256": "7189d767c3f7cddfb59366f6cceeca27a0b29e41da1dcc89d57a25317a0fe4bf",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
|
||||
@@ -276,6 +276,15 @@ http {
|
||||
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(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -245,6 +245,15 @@ http {
|
||||
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(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `ssh_data` ADD `color` text;--> statement-breakpoint
|
||||
ALTER TABLE `ssh_data` ADD `icon` text;
|
||||
@@ -0,0 +1,14 @@
|
||||
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;
|
||||
@@ -0,0 +1,70 @@
|
||||
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
@@ -533,6 +533,27 @@
|
||||
"when": 1792430772041,
|
||||
"tag": "0075_session_policy",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "ssh_data" ADD COLUMN "color" text;--> statement-breakpoint
|
||||
ALTER TABLE "ssh_data" ADD COLUMN "icon" text;
|
||||
@@ -0,0 +1,13 @@
|
||||
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");
|
||||
@@ -0,0 +1,67 @@
|
||||
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
@@ -533,6 +533,27 @@
|
||||
"when": 1792430770990,
|
||||
"tag": "0075_session_policy",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `ssh_data` ADD `color` text;--> statement-breakpoint
|
||||
ALTER TABLE `ssh_data` ADD `icon` text;
|
||||
@@ -0,0 +1,13 @@
|
||||
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`);
|
||||
@@ -0,0 +1,67 @@
|
||||
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
@@ -498,6 +498,27 @@
|
||||
"when": 1792430769899,
|
||||
"tag": "0070_session_policy",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -128,6 +128,10 @@
|
||||
"category": "public.app-category.developer-tools",
|
||||
"hardenedRuntime": true,
|
||||
"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",
|
||||
"entitlementsInherit": "packaging/build/entitlements.mac.inherit.plist",
|
||||
"type": "distribution",
|
||||
@@ -153,6 +157,8 @@
|
||||
"artifactName": "termix_macos_${arch}_mas.${ext}",
|
||||
"extendInfo": {
|
||||
"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."
|
||||
}
|
||||
},
|
||||
|
||||
+16
-8
@@ -1235,19 +1235,14 @@ function createTray() {
|
||||
// use the unpacked path so the OS sees a real file.
|
||||
const publicRoot = isDev
|
||||
? path.join(appRoot, "public")
|
||||
: path.join(
|
||||
appRoot.replace(
|
||||
/app(-[a-z0-9]+)?\.asar(?!\.unpacked)/,
|
||||
"app.asar.unpacked",
|
||||
),
|
||||
"public",
|
||||
);
|
||||
: path.join(getUnpackedAppRoot(appRoot), "public");
|
||||
|
||||
let trayIcon;
|
||||
if (process.platform === "darwin") {
|
||||
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.setTemplateImage(true);
|
||||
} else if (process.platform === "win32") {
|
||||
trayIcon = path.join(publicRoot, "icon.ico");
|
||||
} else {
|
||||
@@ -2026,6 +2021,19 @@ function createMenu() {
|
||||
{ type: "separator" },
|
||||
{ role: "front" },
|
||||
{ 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" },
|
||||
],
|
||||
},
|
||||
|
||||
+36
-1
@@ -83,6 +83,30 @@
|
||||
"default": "8443",
|
||||
"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",
|
||||
"group": "https",
|
||||
@@ -298,7 +322,7 @@
|
||||
{
|
||||
"name": "HTTP_PROXY",
|
||||
"group": "proxy",
|
||||
"description": "Proxy for outbound HTTP requests, like registry downloads and webhooks."
|
||||
"description": "Proxy for outbound HTTP requests, like plugin registry downloads and webhooks."
|
||||
},
|
||||
{
|
||||
"name": "HTTPS_PROXY",
|
||||
@@ -353,6 +377,11 @@
|
||||
"group": "plugins",
|
||||
"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",
|
||||
"group": "plugins",
|
||||
@@ -365,6 +394,12 @@
|
||||
"default": "false",
|
||||
"description": "Start everyone in the layout repair view instead of the chosen layout. Use it when a layout plugin breaks the app."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_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",
|
||||
"group": "plugins",
|
||||
|
||||
Generated
+10
-10
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"version": "26.10.3",
|
||||
"version": "26.11.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "termix",
|
||||
"version": "26.10.3",
|
||||
"version": "26.11.0",
|
||||
"hasInstallScript": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
@@ -19,7 +19,7 @@
|
||||
"axios": "^1.19.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"body-parser": "^2.3.0",
|
||||
"body-parser": "^2.4.0",
|
||||
"chalk": "^6.0.0",
|
||||
"compression": "^1.8.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
@@ -7470,18 +7470,18 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
|
||||
"integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^2.0.0",
|
||||
"content-type": "^2.1.0",
|
||||
"debug": "^4.4.3",
|
||||
"http-errors": "^2.0.1",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"iconv-lite": "^0.7.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"qs": "^6.15.2",
|
||||
"qs": "^6.16.1",
|
||||
"raw-body": "^3.0.2",
|
||||
"type-is": "^2.1.0"
|
||||
},
|
||||
@@ -15058,7 +15058,7 @@
|
||||
},
|
||||
"packages/plugin-sdk": {
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.0.13",
|
||||
"version": "1.0.22",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||
|
||||
+5
-4
@@ -4,7 +4,7 @@
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"version": "26.10.3",
|
||||
"version": "26.11.0",
|
||||
"description": "Self-hosted, plugin-based server management.",
|
||||
"author": "Karmaa",
|
||||
"main": "electron/main.cjs",
|
||||
@@ -18,7 +18,7 @@
|
||||
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs && node scripts/patch-ssh2-sk.cjs",
|
||||
"prepare": "husky || true",
|
||||
"prebuild": "node scripts/write-electron-build-info.cjs",
|
||||
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-sdk-guide.cjs && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs",
|
||||
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-button-colors.cjs && node scripts/check-sdk-guide.cjs && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"type-check": "npm run build:sdk && tsc -b --force",
|
||||
"test": "vitest run",
|
||||
@@ -31,7 +31,8 @@
|
||||
"dev:docker": "node scripts/dev.mjs docker",
|
||||
"dev:frontend": "vite",
|
||||
"build": "npm run build:sdk && vite build && node scripts/stamp-sw-build-info.cjs && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
|
||||
"build: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: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:backend:core && npm run build:plugins",
|
||||
"generate:openapi": "npm run build:sdk && node scripts/generate-openapi.mjs",
|
||||
"electron:rebuild": "electron-rebuild -f -o better-sqlite3,@serialport/bindings-cpp,node-pty",
|
||||
"build:win-portable": "npm run build && npm run electron:rebuild && electron-builder --win --dir",
|
||||
@@ -54,7 +55,7 @@
|
||||
"axios": "^1.19.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"body-parser": "^2.3.0",
|
||||
"body-parser": "^2.4.0",
|
||||
"chalk": "^6.0.0",
|
||||
"compression": "^1.8.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
|
||||
@@ -1,5 +1,55 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
Plugin API 1.12.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `columns` and `onColumns` on `ViewToggle` show a cards per row picker: Auto fits as many as the pane has room for, or pick 1 to 6
|
||||
- `size` and `minCardWidth` on `DataView`, so a card grid follows that pick, and the `GridSize` type
|
||||
- `usePluginPanelView` also keeps `columns`, the cards per row pick, with `setColumns`
|
||||
|
||||
## 1.0.19
|
||||
|
||||
Plugin API 1.11.0.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Termix plugin guide
|
||||
|
||||
SDK 1.0.19 | Plugin API 1.11.0
|
||||
SDK 1.0.23 | Plugin API 1.14.0
|
||||
|
||||
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
|
||||
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
|
||||
@@ -175,7 +175,49 @@ Unknown fields are refused at every level, so a typo fails validation. The schem
|
||||
|
||||
`contributes` holds `tabs`, `panels`, `dashboardCards`, `actions`, `actionSlots`, `permissions`,
|
||||
`settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`,
|
||||
`uiPresets`, `uses`, `layouts` and `themes`. Frontend `register*` calls need their ids declared here first.
|
||||
`uiPresets`, `uses`, `layouts`, `themes` and `mobile`. 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
|
||||
code may reach. A permission (`hello.use`, with a dot) is what a user may do.
|
||||
@@ -304,7 +346,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
|
||||
`hosts.edit` checks a core permission.
|
||||
- Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`.
|
||||
- Transports: with `ssh:transport`, `ctx.ssh.transports.provide({ claims(hostId), open({ id, ip, port }) })`
|
||||
- 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`
|
||||
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.
|
||||
- Managed hosts: with `hosts:lock`, `ctx.hosts.locks.provide({ isLocked, note, allowManualCreate })` makes
|
||||
@@ -335,7 +377,9 @@ try {
|
||||
|
||||
`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
|
||||
sudo commands. Hosts from `ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`.
|
||||
sudo commands. `@termix-ssh/plugin-sdk/host-scripts` builds Docker, metrics, systemd and journal
|
||||
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
|
||||
|
||||
@@ -370,6 +414,70 @@ 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
|
||||
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
|
||||
|
||||
These come from Termix and are never bundled: `@termix-ssh/plugin-sdk`, `express`, `ssh2`, `ws`, `multer`,
|
||||
@@ -451,6 +559,23 @@ 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
|
||||
`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
|
||||
|
||||
```tsx
|
||||
@@ -643,6 +768,9 @@ exports with props is `ui.api.txt` in the Termix repo
|
||||
`PanePrompt` for small prompts.
|
||||
- Every tab or panel sits in `PanelShell`. Lists use `PanelList` and `ListRow`, add with `AddButton`,
|
||||
forms end with `FormFooter`, empty lists show `EmptyState`.
|
||||
- Card grids use `DataView` with `ViewToggle`. Pass `columns` and `onColumns` to `ViewToggle` and the same
|
||||
value as `size` to `DataView`, so users can pick Auto (as many as fit) or a fixed number of cards per
|
||||
row. `usePluginPanelView` keeps the choice as `columns`. Older Termix versions ignore both props.
|
||||
- Forms: `TextField`, `NumberField`, `SelectField`, `SwitchRow`, `SecretField`, `SettingRow` and friends.
|
||||
- Host sessions use `ConnectionGate` for the whole connect flow (host keys, TOTP, passphrases, errors).
|
||||
- Mobile: check `useIsMobile()` and give small screens a layout that works. The desktop app always uses the
|
||||
@@ -898,10 +1026,12 @@ 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/manifest` | `PluginManifest` type and manifest checks |
|
||||
| `@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/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` |
|
||||
| `@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-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-keys` | `generateSshKeyPair()`, `detectSshKeyType()`, `publicKeyFromPrivate()`, `validateSshKeyPair()` |
|
||||
| `@termix-ssh/plugin-sdk/desktop` | The desktop app bridge, typed. Check it exists before use |
|
||||
@@ -928,6 +1058,8 @@ 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 |
|
||||
| `hosts:export` | high | Export hosts with their saved logins |
|
||||
| `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 |
|
||||
| `hosts:write` | medium | Add and edit hosts |
|
||||
| `hosts:status-provide` | medium | Be the source of host status dots |
|
||||
@@ -943,6 +1075,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
|
||||
| `users:read` | medium | Usernames, roles and UI language |
|
||||
| `events:core` | medium | Listen to core events |
|
||||
| `notify:send` | medium | Send alerts |
|
||||
| `operations:provide` | medium | Offer declared actions and events |
|
||||
| `audit:read` | medium | Read the audit log |
|
||||
| `desktop:window` | medium | Open windows in the desktop app |
|
||||
| `hosts:read` | low | Read hosts the user can see |
|
||||
|
||||
@@ -25,6 +25,43 @@ const FRONTEND_ENTRIES = [
|
||||
"src/frontend/index.mjs",
|
||||
"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
|
||||
@@ -192,15 +229,12 @@ export async function buildTailwind(cwd, outDir) {
|
||||
// build() adds to what a compiler already emitted, so each half gets its own.
|
||||
const compiler = () =>
|
||||
compile(input, { base: path.dirname(THEME_CSS), onDependency: () => {} });
|
||||
const scanner = new Scanner({
|
||||
sources: [
|
||||
{
|
||||
base: path.join(cwd, "src", "frontend"),
|
||||
pattern: "**/*",
|
||||
negated: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
// The mobile bundle uses the same stylesheet.
|
||||
const sources = ["frontend", "mobile"]
|
||||
.map((dir) => path.join(cwd, "src", dir))
|
||||
.filter((dir, index) => index === 0 || fs.existsSync(dir))
|
||||
.map((base) => ({ base, pattern: "**/*", negated: false }));
|
||||
const scanner = new Scanner({ sources });
|
||||
// Flattened and minified the way core's own CSS is, so it does not rely
|
||||
// on native CSS nesting.
|
||||
const emit = async (candidates, options) =>
|
||||
@@ -297,33 +331,18 @@ export async function build({ cwd }) {
|
||||
|
||||
const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES);
|
||||
if (frontendEntry) {
|
||||
await esbuild.build({
|
||||
entryPoints: [frontendEntry],
|
||||
outfile: path.join(outDir, "frontend.js"),
|
||||
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 }),
|
||||
],
|
||||
});
|
||||
await buildBrowserBundle(
|
||||
frontendEntry,
|
||||
path.join(outDir, "frontend.js"),
|
||||
outDir,
|
||||
);
|
||||
}
|
||||
|
||||
// A bundle the mobile app loads in its WebView, next to the frontend one.
|
||||
const mobileEntry = resolveEntry(cwd, MOBILE_ENTRIES);
|
||||
const mobileOut = manifest.contributes?.mobile?.entry;
|
||||
if (mobileEntry && typeof mobileOut === "string") {
|
||||
await buildBrowserBundle(mobileEntry, path.join(cwd, mobileOut), outDir);
|
||||
}
|
||||
|
||||
if (frontendEntry) await buildTailwind(cwd, outDir);
|
||||
|
||||
@@ -28,6 +28,10 @@ export async function validate({ cwd }) {
|
||||
// A theme only plugin has no frontend code.
|
||||
...(isThemeOnly(raw) ? [] : [raw.frontend ?? "dist/frontend.js"]),
|
||||
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) {
|
||||
if (!fs.existsSync(path.join(cwd, rel))) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.0.19",
|
||||
"version": "1.0.23",
|
||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -61,6 +61,10 @@
|
||||
"types": "./dist/openapi.d.ts",
|
||||
"default": "./dist/openapi.js"
|
||||
},
|
||||
"./operations": {
|
||||
"types": "./dist/operations.d.ts",
|
||||
"default": "./dist/operations.js"
|
||||
},
|
||||
"./settings": {
|
||||
"types": "./dist/settings.d.ts",
|
||||
"default": "./dist/settings.js"
|
||||
@@ -81,6 +85,10 @@
|
||||
"types": "./dist/host-commands.d.ts",
|
||||
"default": "./dist/host-commands.js"
|
||||
},
|
||||
"./host-scripts": {
|
||||
"types": "./dist/host-scripts/index.d.ts",
|
||||
"default": "./dist/host-scripts/index.js"
|
||||
},
|
||||
"./ssh-certs": {
|
||||
"types": "./dist/ssh-certs.d.ts",
|
||||
"default": "./dist/ssh-certs.js"
|
||||
@@ -98,6 +106,10 @@
|
||||
"./testing-host": {
|
||||
"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"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -536,6 +536,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -1239,6 +1264,48 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -14,6 +14,11 @@ import type { HostSshOptions } from "./ssh-options.js";
|
||||
export type { HostSshOptions } from "./ssh-options.js";
|
||||
import type { PluginManifest, PluginSettingsField } from "./manifest.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.
|
||||
@@ -177,8 +182,24 @@ export interface SyncWriteEvent {
|
||||
export interface SyncEntityRegistration {
|
||||
/** Stable wire name. Never change it: records on both sides match on it. */
|
||||
type: string;
|
||||
/** The table, as returned by ctx.db.define. */
|
||||
/** The table, as returned by ctx.db.define. Null for an entity with `load`. */
|
||||
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". */
|
||||
userColumn?: string;
|
||||
/** Fields stored encrypted with the owner's data key. Plaintext on the wire. */
|
||||
@@ -470,6 +491,12 @@ export interface PluginSyncHost {
|
||||
ensureCore: () => Promise<void>;
|
||||
onChanged: (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. */
|
||||
@@ -1813,13 +1840,21 @@ export interface PluginNetwork {
|
||||
* asked for every connection, so it must be quick. `open` returns a stream
|
||||
* the SSH client runs over, like a relayed socket.
|
||||
*/
|
||||
export interface PluginSshTransportProvider {
|
||||
claims: (hostId: number) => boolean;
|
||||
open: (host: {
|
||||
/** 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;
|
||||
}) => Promise<import("node:stream").Duplex>;
|
||||
/** Owner of the host row. */
|
||||
userId?: string | null;
|
||||
syncId?: string | null;
|
||||
connectionOrigin?: "local" | "remote" | null;
|
||||
localOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface PluginSshTransportProvider {
|
||||
claims: (hostId: number, host?: PluginSshTransportHost) => boolean;
|
||||
open: (host: PluginSshTransportHost) => Promise<import("node:stream").Duplex>;
|
||||
}
|
||||
|
||||
export interface PluginSshTransports {
|
||||
@@ -3074,6 +3109,136 @@ export interface PluginPlugins {
|
||||
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 {
|
||||
readonly pluginId: string;
|
||||
readonly manifest: PluginManifest;
|
||||
@@ -3114,6 +3279,8 @@ export interface PluginContext {
|
||||
readonly schedule: PluginSchedule;
|
||||
/** Core's notification channels. Needs notify:send. */
|
||||
readonly notify: PluginNotify;
|
||||
/** Declared actions and events, reachable from every surface. */
|
||||
readonly operations: PluginOperations;
|
||||
/** Outbound HTTP through core's SSRF guard. Needs network:outbound. */
|
||||
readonly fetch: PluginFetch;
|
||||
/** Plain reachability checks. Needs network:probe. */
|
||||
|
||||
@@ -52,6 +52,8 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
||||
entry("ssh:transport", "high"),
|
||||
entry("device:serial", "high"),
|
||||
entry("notify:hub", "high"),
|
||||
entry("operations:invoke", "high"),
|
||||
entry("operations:approve", "high"),
|
||||
entry("ui:layout", "high"),
|
||||
|
||||
entry("hosts:write", "medium"),
|
||||
@@ -68,6 +70,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
||||
entry("users:read", "medium"),
|
||||
entry("events:core", "medium"),
|
||||
entry("notify:send", "medium"),
|
||||
entry("operations:provide", "medium"),
|
||||
entry("audit:read", "medium"),
|
||||
entry("desktop:window", "medium"),
|
||||
entry("desktop:local", "high"),
|
||||
|
||||
@@ -206,6 +206,8 @@ export interface ShellApi {
|
||||
* user reviews and saves it. Absent in some shells.
|
||||
*/
|
||||
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. */
|
||||
openRailView: (id: string) => void;
|
||||
/** Closes a rail view wherever it is shown. */
|
||||
@@ -425,6 +427,11 @@ export interface RailItemContribution {
|
||||
* Zero or nothing shows no badge.
|
||||
*/
|
||||
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 {
|
||||
@@ -1711,28 +1718,49 @@ export function usePluginUiPreferences<
|
||||
return bridge.usePluginUiPreferences(bridge.usePluginId()) as never;
|
||||
}
|
||||
|
||||
type PanelGridSize = "auto" | "1" | "2" | "3" | "4" | "5" | "6";
|
||||
const PANEL_GRID_SIZES: readonly string[] = [
|
||||
"auto",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
];
|
||||
|
||||
/**
|
||||
* Grid or list and row density for one of this plugin's views, kept in its
|
||||
* Appearance area as `<prefix>viewMode` and `<prefix>density`. Declare those
|
||||
* keys in contributes.uiPresets so each preset level can pick a default.
|
||||
* Grid or list, row density and cards per row for one of this plugin's
|
||||
* views, kept in its Appearance area as `<prefix>viewMode`, `<prefix>density`
|
||||
* and `<prefix>columns`. Declare those keys in contributes.uiPresets so each
|
||||
* preset level can pick a default. Hand columns to ViewToggle and DataView.
|
||||
*/
|
||||
export function usePluginPanelView(prefix = ""): {
|
||||
view: "grid" | "list";
|
||||
density: "comfortable" | "compact";
|
||||
compact: boolean;
|
||||
columns: PanelGridSize;
|
||||
setView: (view: "grid" | "list") => void;
|
||||
setDensity: (density: "comfortable" | "compact") => void;
|
||||
setColumns: (columns: PanelGridSize) => void;
|
||||
} {
|
||||
const { values, set } = usePluginUiPreferences();
|
||||
const view = values[`${prefix}viewMode`] === "list" ? "list" : "grid";
|
||||
const density =
|
||||
values[`${prefix}density`] === "compact" ? "compact" : "comfortable";
|
||||
const saved = values[`${prefix}columns`];
|
||||
const columns =
|
||||
typeof saved === "string" && PANEL_GRID_SIZES.includes(saved)
|
||||
? (saved as PanelGridSize)
|
||||
: "auto";
|
||||
return {
|
||||
view,
|
||||
density,
|
||||
compact: density === "compact",
|
||||
columns,
|
||||
setView: (next) => set(`${prefix}viewMode`, next),
|
||||
setDensity: (next) => set(`${prefix}density`, next),
|
||||
setColumns: (next) => set(`${prefix}columns`, next),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
|
||||
export 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 {
|
||||
stdout: string;
|
||||
@@ -18,6 +21,30 @@ export interface ExecResult {
|
||||
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. */
|
||||
export function execCommand(
|
||||
client: Client,
|
||||
@@ -62,8 +89,10 @@ export function execCommand(
|
||||
}
|
||||
|
||||
stream = _stream;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
// Chunks are buffered and decoded once on close: a multi-byte character
|
||||
// can be split across chunks, and charset detection needs it whole.
|
||||
const stdout: Buffer[] = [];
|
||||
const stderr: Buffer[] = [];
|
||||
let exitCode: number | null = null;
|
||||
|
||||
stream
|
||||
@@ -72,11 +101,15 @@ export function execCommand(
|
||||
settled = true;
|
||||
exitCode = typeof code === "number" ? code : null;
|
||||
cleanup();
|
||||
resolve({ stdout, stderr, code: exitCode });
|
||||
resolve({
|
||||
stdout: decodeCommandOutput(Buffer.concat(stdout)),
|
||||
stderr: decodeCommandOutput(Buffer.concat(stderr)),
|
||||
code: exitCode,
|
||||
});
|
||||
}
|
||||
})
|
||||
.on("data", (data: Buffer) => {
|
||||
stdout += data.toString("utf8");
|
||||
stdout.push(data);
|
||||
})
|
||||
.on("error", (streamErr: Error) => {
|
||||
if (!settled) {
|
||||
@@ -89,7 +122,7 @@ export function execCommand(
|
||||
if (stream.stderr) {
|
||||
stream.stderr
|
||||
.on("data", (data: Buffer) => {
|
||||
stderr += data.toString("utf8");
|
||||
stderr.push(data);
|
||||
})
|
||||
.on("error", (stderrErr: Error) => {
|
||||
if (!settled) {
|
||||
@@ -114,14 +147,6 @@ export interface PlatformInfo {
|
||||
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 =
|
||||
`command -v docker >/dev/null 2>&1` +
|
||||
` || [ -S /var/run/docker.sock ]` +
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* 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" : ""}`];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 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";
|
||||
@@ -0,0 +1,242 @@
|
||||
/** 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`;
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* 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)"`;
|
||||
@@ -0,0 +1,94 @@
|
||||
/** 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`;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 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}`;
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -12,6 +12,11 @@
|
||||
|
||||
import semver from "semver";
|
||||
import { isKnownCapability } from "./capabilities.js";
|
||||
import {
|
||||
checkOperationReferences,
|
||||
validateOperations,
|
||||
type PluginOperationsContribution,
|
||||
} from "./operations.js";
|
||||
import {
|
||||
ENV_NAME_PATTERN,
|
||||
MAX_ENV_DESCRIPTION_LENGTH,
|
||||
@@ -37,7 +42,7 @@ export {
|
||||
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
||||
* version by the server.
|
||||
*/
|
||||
export const PLUGIN_API_VERSION = "1.11.0";
|
||||
export const PLUGIN_API_VERSION = "1.14.0";
|
||||
|
||||
/** The API major, the default engine.api for a new plugin. */
|
||||
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
||||
@@ -465,6 +470,10 @@ export interface PluginContributions {
|
||||
tabs?: PluginTabContribution[];
|
||||
panels?: 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
|
||||
* collab links), where it is activated with app.guest set.
|
||||
@@ -526,6 +535,36 @@ export interface PluginContributions {
|
||||
layouts?: PluginLayoutContribution[];
|
||||
/** Themes. Needs the ui:theme capability. */
|
||||
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. */
|
||||
@@ -749,6 +788,8 @@ const ALLOWED_CONTRIBUTES = new Set([
|
||||
"tabs",
|
||||
"panels",
|
||||
"dashboardCards",
|
||||
"hostSections",
|
||||
"operations",
|
||||
"guest",
|
||||
"guestViews",
|
||||
"actions",
|
||||
@@ -765,6 +806,7 @@ const ALLOWED_CONTRIBUTES = new Set([
|
||||
"protocols",
|
||||
"layouts",
|
||||
"themes",
|
||||
"mobile",
|
||||
]);
|
||||
|
||||
const ALLOWED_SETTINGS_FIELD = [
|
||||
@@ -946,10 +988,135 @@ export function validateManifest(manifest: unknown): string[] {
|
||||
typeof m.id === "string" ? m.id : undefined,
|
||||
errors,
|
||||
);
|
||||
if (isPlainObject(m.contributes)) {
|
||||
validateMobile(
|
||||
m.contributes,
|
||||
typeof m.frontend === "string" ? m.frontend : DEFAULT_FRONTEND_ENTRY,
|
||||
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 {
|
||||
if (features === undefined) return;
|
||||
if (!Array.isArray(features)) {
|
||||
@@ -1344,6 +1511,8 @@ function validateContributes(
|
||||
validateTabs(contributes.tabs, errors);
|
||||
validateViews(contributes.panels, "panels", errors);
|
||||
validateViews(contributes.dashboardCards, "dashboardCards", errors);
|
||||
validateViews(contributes.hostSections, "hostSections", errors);
|
||||
validateOperations(contributes.operations, pluginId, errors);
|
||||
if (
|
||||
contributes.guest !== undefined &&
|
||||
typeof contributes.guest !== "boolean"
|
||||
@@ -2466,6 +2635,12 @@ 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 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.
|
||||
|
||||
@@ -0,0 +1,951 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
@@ -68,7 +68,11 @@ import type {
|
||||
PluginSshKeyCredential,
|
||||
PluginSshKeyCredentialInput,
|
||||
} from "./backend.js";
|
||||
import type { PluginSyncHost, SyncEntityRegistration } from "./backend.js";
|
||||
import type {
|
||||
PluginOperationHandler,
|
||||
PluginSyncHost,
|
||||
SyncEntityRegistration,
|
||||
} from "./backend.js";
|
||||
import type { PluginDatabase } from "./backend.js";
|
||||
import type {
|
||||
PluginExternalClientRequest,
|
||||
@@ -355,6 +359,10 @@ export interface FakePluginContext {
|
||||
}>;
|
||||
/** The hub the plugin registered with ctx.notify.serve, if any. */
|
||||
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>". */
|
||||
secretStore: Map<string, string>;
|
||||
/** Every ctx.fetch call, in order. */
|
||||
@@ -566,6 +574,8 @@ export function createFakeContext(
|
||||
const createdSshKeys: FakePluginContext["createdSshKeys"] = [];
|
||||
const notifications: FakePluginContext["notifications"] = [];
|
||||
const notifyHub: FakePluginContext["notifyHub"] = { current: null };
|
||||
const operationHandlers: FakePluginContext["operationHandlers"] = new Map();
|
||||
const operationEvents: FakePluginContext["operationEvents"] = [];
|
||||
const fetches: FakePluginContext["fetches"] = [];
|
||||
const processRuns: FakePluginContext["processRuns"] = [];
|
||||
const binaries: FakePluginContext["binaries"] = [];
|
||||
@@ -1391,6 +1401,46 @@ 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: {
|
||||
channels: async () => {
|
||||
if (!actor) throw new Error("ctx.notify needs an acting user");
|
||||
@@ -1554,6 +1604,8 @@ export function createFakeContext(
|
||||
createdSshKeys,
|
||||
notifications,
|
||||
notifyHub,
|
||||
operationHandlers,
|
||||
operationEvents,
|
||||
fetches,
|
||||
processRuns,
|
||||
binaries,
|
||||
|
||||
@@ -80,7 +80,7 @@ DEFAULT_FOLDER_COLOR (value): "#f59145"
|
||||
DEFAULT_FOLDER_ICON (value): "folder"
|
||||
DashboardCardId (type): DashboardCardId
|
||||
DataColumn (type): DataColumn<T>
|
||||
DataView (value): <T>({ items, view, density, getKey, renderCard, columns, listColumns, onRowClick, isRowActive, empty, className, }: { items: T[]; view: PanelViewMode; density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; columns: GridColumns; listColumns: DataColumn<T>[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element
|
||||
DataView (value): <T>({ items, view, density, getKey, renderCard, columns, size, minCardWidth, listColumns, onRowClick, isRowActive, empty, className, }: { items: T[]; view: PanelViewMode; density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; columns: GridColumns; size?: GridSize; minCardWidth?: number; listColumns: DataColumn<T>[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element
|
||||
DefaultOverrides (type): DefaultOverrides
|
||||
DefaultsOnly (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
|
||||
DesktopUpdateMode (type): DesktopUpdateMode
|
||||
@@ -131,6 +131,7 @@ GridCardCatalogEntry (type): GridCardCatalogEntry
|
||||
GridColSpan (type): GridColSpan
|
||||
GridColumns (type): GridColumns
|
||||
GridLayout (type): GridLayout
|
||||
GridSize (type): GridSize
|
||||
GridSlot (type): GridSlot
|
||||
GroupHeading (value): ({ title, count, action, className, }: { title: string; count?: number; action?: ReactNode; className?: string; }) => Element
|
||||
HexColorPicker (value): ({ value, onChange, }: { value: string; onChange: (hex: string) => void; }) => Element
|
||||
@@ -321,7 +322,7 @@ UserRole (type): UserRole
|
||||
UserSecondFactorSummary (type): UserSecondFactorSummary
|
||||
VersionBadge (value): ({ status, releaseUrl, className, }: VersionBadgeProps) => Element
|
||||
VersionInfo (type): VersionInfo
|
||||
ViewToggle (value): ({ view, onView, density, onDensity, }: { view: PanelViewMode; onView: (next: PanelViewMode) => void; density?: PanelDensity; onDensity?: (next: PanelDensity) => void; }) => Element
|
||||
ViewToggle (value): ({ view, onView, density, onDensity, columns, onColumns, }: { view: PanelViewMode; onView: (next: PanelViewMode) => void; density?: PanelDensity; onDensity?: (next: PanelDensity) => void; columns?: GridSize; onColumns?: (next: GridSize) => void; }) => Element
|
||||
WebSocketConnectionTarget (type): WebSocketConnectionTarget
|
||||
WidgetTitle (value): ({ title, icon, fallback }: WidgetTitleProps) => Element
|
||||
actionOrder (value): (action: HostActionDef) => number
|
||||
@@ -402,6 +403,7 @@ getDeviceId (value): () => string
|
||||
getErrorMessage (value): (error: unknown, fallback?: string) => string
|
||||
getExternalAutoProvision (value): () => Promise<{ enabled: boolean; }>
|
||||
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>
|
||||
getHostAccess (value): (hostId: number, syncId?: string) => Promise<{ accessList: AccessRecord[]; isOwner?: boolean; }>
|
||||
getHostAuthOverride (value): (hostId: number, protocol: string, sharedCopySyncId?: string) => Promise<{ protocol: string; credentialId: number; }>
|
||||
@@ -525,7 +527,7 @@ resolveHostTabType (value): (host: Host, preferredType?: TabType) => TabType
|
||||
resolveRemoteHostId (value): (syncId: string) => Promise<number>
|
||||
revokeAllUserSessions (value): (userId: string) => Promise<{ success: boolean; message: string; }>
|
||||
revokeCredentialAccess (value): (credentialId: number, accessId: number) => Promise<void>
|
||||
revokeFolderAccess (value): (ruleId: number) => Promise<void>
|
||||
revokeFolderAccess (value): (ruleId: number, options?: { fromHosts?: boolean; }) => Promise<void>
|
||||
revokeHostAccess (value): (hostId: number, accessId: number, syncId?: string) => Promise<{ success: boolean; }>
|
||||
revokeSession (value): (sessionId: string) => Promise<{ success: boolean; message: string; }>
|
||||
rightDockableIds (value): () => string[]
|
||||
@@ -543,6 +545,7 @@ saveHostTags (value): (tags: string[]) => Promise<string[]>
|
||||
saveUserPreferences (value): (prefs: Partial<UserPreferences>) => Promise<void>
|
||||
setAllPluginChannels (value): (channel: PluginChannel) => Promise<{ changed: string[]; }>
|
||||
setDeveloperMode (value): (enabled: boolean) => Promise<void>
|
||||
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; }>
|
||||
setNotificationPrivateEndpoints (value): (hosts: string[]) => Promise<string[]>
|
||||
setPluginOptions (value): (pluginId: string, options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel; }) => Promise<{ autoUpdate: boolean; pinnedVersion: string; channel: PluginChannel; }>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 320 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 199 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 292 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 314 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 216 KiB |
@@ -3,6 +3,8 @@
|
||||
*
|
||||
* getBundledPluginsDir() in src/backend/plugins/paths.ts resolves
|
||||
* 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
|
||||
* pinned sha256 and unpacked.
|
||||
*
|
||||
@@ -25,13 +27,16 @@ const {
|
||||
localSdkBuildCommand,
|
||||
copyLocalBuild,
|
||||
buildBundledIndex,
|
||||
resolveProfile,
|
||||
pluginsForProfile,
|
||||
} = require("./lib/bundled-plugins.cjs");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const destination = path.join(root, "dist", "plugins");
|
||||
|
||||
async function main() {
|
||||
const plugins = loadBundledPlugins(root);
|
||||
const profile = resolveProfile();
|
||||
const plugins = pluginsForProfile(loadBundledPlugins(root), profile);
|
||||
const flag = process.argv.indexOf("--local");
|
||||
const localArg =
|
||||
flag !== -1 ? process.argv[flag + 1] : process.env.TERMIX_LOCAL_PLUGINS;
|
||||
@@ -74,7 +79,7 @@ async function main() {
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Bundled ${plugins.length + extra.length} plugin(s): ${[...pinned, ...extra].join(", ")}`,
|
||||
`Bundled ${plugins.length + extra.length} plugin(s) (${profile}): ${[...pinned, ...extra].join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* Builds the two pieces of the plugin SDK that come from core:
|
||||
* Builds the 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/testing-host.js what renderWithApp() renders plugins into
|
||||
* 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
|
||||
* shell's own copy, so ui.js is never loaded there. It exists so a plugin in
|
||||
@@ -46,6 +48,7 @@ await esbuild.build({
|
||||
entryPoints: {
|
||||
ui: path.join(root, "src/ui/plugin-host/sdk-ui.ts"),
|
||||
"testing-host": path.join(root, "src/ui/plugin-host/testing-host.tsx"),
|
||||
"frame-host": path.join(root, "src/ui/plugin-host/frame-host.tsx"),
|
||||
},
|
||||
outdir: outDir,
|
||||
bundle: true,
|
||||
@@ -72,6 +75,9 @@ await esbuild.build({
|
||||
// so the translated locale files it would list are simply none.
|
||||
banner: { js: "const __termixNoGlob = () => ({});" },
|
||||
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.env.DEV": "false",
|
||||
"import.meta.env.PROD": "true",
|
||||
@@ -157,14 +163,16 @@ const rootSource =
|
||||
const scanner = new Scanner({
|
||||
sources: [...rootSource, ...compiler.sources],
|
||||
});
|
||||
const coreClasses = [
|
||||
...classNamesInCss(
|
||||
optimize(compiler.build(scanner.scan()), { minify: true }).code,
|
||||
),
|
||||
].sort();
|
||||
const coreCss = optimize(compiler.build(scanner.scan()), {
|
||||
minify: true,
|
||||
}).code;
|
||||
const coreClasses = [...classNamesInCss(coreCss)].sort();
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, "core-classes.json"),
|
||||
`${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)}`);
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
localSdkBuildCommand,
|
||||
rebuildLocalPlugins,
|
||||
copyLocalBuild,
|
||||
resolveProfile,
|
||||
pluginsForProfile,
|
||||
} from "./lib/bundled-plugins.cjs";
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
@@ -263,10 +265,52 @@ describe("pinsFromIndex", () => {
|
||||
source: "tmxplug",
|
||||
url: "https://example.test/beta-1.1.0.tmxplug",
|
||||
sha256: "b".repeat(64),
|
||||
tier: "full",
|
||||
});
|
||||
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", () => {
|
||||
const current = [
|
||||
{ id: "beta", onboarding: { recommended: true, consent: false } },
|
||||
@@ -493,6 +537,38 @@ describe("default look", () => {
|
||||
).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", () => {
|
||||
const repo = tempDir();
|
||||
fs.mkdirSync(path.join(repo, "dist"), { recursive: true });
|
||||
@@ -511,3 +587,61 @@ describe("default look", () => {
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Buttons are never white.
|
||||
*
|
||||
* Every Button variant is the brand orange with a different background
|
||||
* opacity (or red for destructive, or transparent for ghost). Fails when a
|
||||
* variant in src/ui/components/button.tsx uses a neutral fill, or when a
|
||||
* <Button> anywhere in core or the local plugin checkouts sets a neutral fill
|
||||
* in its className.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const NEUTRAL =
|
||||
/(?:^|[\s"'`])(?:dark:)?(?:bg-(?:primary|white|foreground|background|secondary|card|popover|muted|accent|input|neutral-\d+|zinc-\d+|gray-\d+|slate-\d+|stone-\d+)(?![\w-])|text-primary-foreground)/;
|
||||
|
||||
/** The opening tag text of each <Button ...> in a source file. */
|
||||
function buttonTags(source) {
|
||||
const tags = [];
|
||||
const re = /<Button(?=[\s/>])/g;
|
||||
let match;
|
||||
while ((match = re.exec(source))) {
|
||||
let depth = 0;
|
||||
let quote = "";
|
||||
let i = match.index + match[0].length;
|
||||
for (; i < source.length; i++) {
|
||||
const c = source[i];
|
||||
if (quote) {
|
||||
if (c === "\\") i++;
|
||||
else if (c === quote) quote = "";
|
||||
} else if (c === '"' || c === "'" || c === "`") quote = c;
|
||||
else if (c === "{") depth++;
|
||||
else if (c === "}") depth--;
|
||||
else if (c === ">" && depth === 0) break;
|
||||
}
|
||||
tags.push(source.slice(match.index, i + 1));
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
/** Variant strings from the cva block of button.tsx. */
|
||||
function variantLines(source) {
|
||||
const start = source.indexOf("variant: {");
|
||||
const end = source.indexOf("size: {");
|
||||
if (start < 0 || end < 0) return [];
|
||||
return source
|
||||
.slice(start, end)
|
||||
.split("\n")
|
||||
.filter((line) => /["`]/.test(line) && !line.includes("variant: {"));
|
||||
}
|
||||
|
||||
function walk(dir, out) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (["node_modules", "dist", "tests", "__fixtures__"].includes(entry.name))
|
||||
continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full, out);
|
||||
else if (entry.name.endsWith(".tsx")) out.push(full);
|
||||
}
|
||||
}
|
||||
|
||||
/** Problems found under the given roots. */
|
||||
function scan(roots, buttonFile) {
|
||||
const problems = [];
|
||||
if (buttonFile && fs.existsSync(buttonFile)) {
|
||||
for (const line of variantLines(fs.readFileSync(buttonFile, "utf8"))) {
|
||||
if (NEUTRAL.test(line)) {
|
||||
problems.push(
|
||||
`${buttonFile}: neutral fill in a variant: ${line.trim()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const files = [];
|
||||
for (const root of roots) walk(root, files);
|
||||
for (const file of files) {
|
||||
for (const tag of buttonTags(fs.readFileSync(file, "utf8"))) {
|
||||
if (NEUTRAL.test(tag)) {
|
||||
problems.push(
|
||||
`${file}: <Button> with a neutral fill: ${tag.slice(0, 120)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
module.exports = { scan, buttonTags, variantLines };
|
||||
|
||||
if (require.main === module) {
|
||||
const core = path.resolve(__dirname, "..");
|
||||
const plugins = path.resolve(core, "..", "Plugins");
|
||||
const roots = [path.join(core, "src", "ui")];
|
||||
if (fs.existsSync(plugins)) {
|
||||
for (const entry of fs.readdirSync(plugins)) {
|
||||
roots.push(path.join(plugins, entry, "src"));
|
||||
}
|
||||
}
|
||||
const problems = scan(
|
||||
roots,
|
||||
path.join(core, "src", "ui", "components", "button.tsx"),
|
||||
);
|
||||
if (problems.length) {
|
||||
console.error("Buttons must be the brand orange, never white or neutral:");
|
||||
for (const p of problems) console.error(" " + p);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("ok button colors");
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { scan, buttonTags } = require("./check-button-colors.cjs") as {
|
||||
scan: (roots: string[], buttonFile?: string) => string[];
|
||||
buttonTags: (source: string) => string[];
|
||||
};
|
||||
|
||||
let root: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) fs.rmSync(root, { recursive: true, force: true });
|
||||
root = null;
|
||||
});
|
||||
|
||||
function fixture(files: Record<string, string>): string {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "button-colors-"));
|
||||
for (const [file, content] of Object.entries(files)) {
|
||||
const full = path.join(root, file);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, content);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("buttonTags", () => {
|
||||
it("reads a tag with arrow functions inside", () => {
|
||||
const tags = buttonTags(
|
||||
`<Button onClick={() => go(a > b)} className="x">Go</Button>`,
|
||||
);
|
||||
expect(tags).toHaveLength(1);
|
||||
expect(tags[0]).toContain('className="x"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("check-button-colors", () => {
|
||||
it("accepts orange buttons and hover fills", () => {
|
||||
const dir = fixture({
|
||||
"a.tsx": `<Button className="bg-accent-brand/15 text-accent-brand hover:bg-muted" />`,
|
||||
});
|
||||
expect(scan([dir])).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a white or neutral fill on a Button", () => {
|
||||
for (const cls of [
|
||||
"bg-primary",
|
||||
"bg-white",
|
||||
"bg-foreground text-background",
|
||||
"dark:bg-secondary",
|
||||
"bg-muted",
|
||||
"text-primary-foreground",
|
||||
]) {
|
||||
const dir = fixture({ "a.tsx": `<Button className="${cls}">x</Button>` });
|
||||
expect(scan([dir]), cls).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a neutral fill in a variant", () => {
|
||||
const dir = fixture({
|
||||
"button.tsx": `variant: {\n default: "bg-primary text-primary-foreground",\n ghost: "hover:bg-muted",\n},\nsize: {`,
|
||||
});
|
||||
expect(scan([], path.join(dir, "button.tsx"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("passes on the real button", () => {
|
||||
const button = path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"src",
|
||||
"ui",
|
||||
"components",
|
||||
"button.tsx",
|
||||
);
|
||||
expect(scan([], button)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,8 @@ const SDK = path.join(ROOT, "packages", "plugin-sdk");
|
||||
const INTERNAL_EXPORTS = new Set([
|
||||
"./testing/setup",
|
||||
"./testing-host",
|
||||
"./frame-host",
|
||||
"./frame-host.css",
|
||||
"./tsconfig.plugin.json",
|
||||
"./schema/manifest.schema.json",
|
||||
"./theme.css",
|
||||
|
||||
@@ -151,6 +151,7 @@ const ALLOWED = [
|
||||
"GridCardCatalogEntry",
|
||||
"GridColSpan",
|
||||
"GridColumns",
|
||||
"GridSize",
|
||||
"GridLayout",
|
||||
"GridSlot",
|
||||
"GroupHeading",
|
||||
@@ -425,6 +426,7 @@ const ALLOWED = [
|
||||
"getFolderAccess",
|
||||
"getFolderDefaultsId",
|
||||
"getHostAccess",
|
||||
"getFolderAuthOverride",
|
||||
"getHostAuthOverride",
|
||||
"getHostDefaultsLevel",
|
||||
"getHostEditorSection",
|
||||
@@ -564,6 +566,7 @@ const ALLOWED = [
|
||||
"saveUserPreferences",
|
||||
"setAllPluginChannels",
|
||||
"setDeveloperMode",
|
||||
"setFolderAuthOverride",
|
||||
"setHostAuthOverride",
|
||||
"setNotificationPrivateEndpoints",
|
||||
"setPluginOptions",
|
||||
|
||||
+31
-5
@@ -404,6 +404,7 @@ function watchBackend() {
|
||||
{ cwd: root, stdio: ["ignore", "pipe", "inherit"] },
|
||||
);
|
||||
let first = true;
|
||||
let restartQueued = false;
|
||||
let buffer = "";
|
||||
tsc.stdout.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
@@ -423,14 +424,21 @@ function watchBackend() {
|
||||
? "starting the backend"
|
||||
: "backend changed, restarting it";
|
||||
first = false;
|
||||
void queue(() => restartBackend(reason));
|
||||
if (restartQueued) continue;
|
||||
restartQueued = true;
|
||||
void queue(() => {
|
||||
restartQueued = false;
|
||||
return restartBackend(reason);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function watchSdk() {
|
||||
let buildStarted = Date.now();
|
||||
const changed = createDebouncer(300, () =>
|
||||
queue(async () => {
|
||||
buildStarted = Date.now();
|
||||
try {
|
||||
await run("npm", ["run", "build:sdk"]);
|
||||
} catch (error) {
|
||||
@@ -440,7 +448,16 @@ function watchSdk() {
|
||||
await restartBackend("SDK changed, restarting the backend");
|
||||
}),
|
||||
);
|
||||
watch(SDK_SRC, true, () => changed("sdk"));
|
||||
// Windows also reports reads (access time), so only a file saved after the last build counts
|
||||
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() {
|
||||
@@ -580,9 +597,18 @@ function watchPlugins(dir) {
|
||||
queue(() => rebuildPlugin(byId.get(id))),
|
||||
);
|
||||
for (const plugin of plugins) {
|
||||
watch(plugin.repo, true, (_event, filename) => {
|
||||
if (isPluginEdit(plugin.repo, filename)) changed(plugin.id);
|
||||
});
|
||||
// Only source folders, so node_modules and .git are never watched
|
||||
const onEvent = (prefix) => (_event, filename) => {
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,18 @@ function buildTable(version, mobileVersion, mobileTag) {
|
||||
].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 }) {
|
||||
const notes = changelogSection(changelog, version);
|
||||
if (!notes) fail(`CHANGELOG.md has no notes for ${version}`);
|
||||
@@ -62,6 +74,8 @@ function buildBody({ version, mobileVersion, mobileTag, changelog }) {
|
||||
"### Downloads",
|
||||
"",
|
||||
buildTable(version, mobileVersion, mobileTag),
|
||||
"",
|
||||
buildFullBuilds(version),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,18 @@ 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", () => {
|
||||
const fs = require("fs");
|
||||
const body = buildBody({
|
||||
|
||||
@@ -21,6 +21,8 @@ const packages = [
|
||||
"@img/sharp-win32-x64",
|
||||
].map((name) => `${name}@${sharpPackage.optionalDependencies[name]}`);
|
||||
|
||||
// A .cmd file only starts through a shell on current Node.
|
||||
execFileSync("npm.cmd", ["install", "--force", "--no-save", ...packages], {
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
});
|
||||
@@ -16,6 +16,11 @@
|
||||
* environment variables is set, for a server configured before first boot.
|
||||
* 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
|
||||
* 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
|
||||
@@ -31,6 +36,8 @@ const ID = /^[a-z0-9][a-z0-9-]*$/;
|
||||
const ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
|
||||
const ONBOARDING_KEYS = new Set(["recommended", "consent", "enabledByEnv"]);
|
||||
const SHA256 = /^[0-9a-f]{64}$/;
|
||||
const PROFILES = ["minimal", "standard", "full"];
|
||||
const DEFAULT_PROFILE = "standard";
|
||||
|
||||
function parseOnboarding(value, id, problems) {
|
||||
if (value === undefined) return null;
|
||||
@@ -122,6 +129,9 @@ function parseBundledPlugins(raw) {
|
||||
) {
|
||||
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;
|
||||
plugins.push({
|
||||
id: entry.id,
|
||||
@@ -131,6 +141,7 @@ function parseBundledPlugins(raw) {
|
||||
sha256: entry.sha256,
|
||||
onboarding,
|
||||
defaultLook: entry.defaultLook === true,
|
||||
tier: entry.tier ?? "full",
|
||||
});
|
||||
} else {
|
||||
problems.push(`${entry.id} has an unknown source "${entry.source}"`);
|
||||
@@ -241,35 +252,73 @@ function buildBundledIndex(plugins, stagedIds) {
|
||||
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
|
||||
* to pin, or null for every plugin in the index. `current` is the existing
|
||||
* pin list, so fields the registry does not know (onboarding) carry over.
|
||||
* pin list, so fields the registry does not know (onboarding, tier) 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 = []) {
|
||||
function pinsFromIndex(index, ids, current = [], coreVersion = null) {
|
||||
const plugins = Array.isArray(index?.plugins) ? index.plugins : [];
|
||||
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
|
||||
const currentById = new Map(current.map((entry) => [entry.id, entry]));
|
||||
const wanted = ids ?? plugins.map((plugin) => plugin.id);
|
||||
return [...wanted].sort().map((id) => {
|
||||
const pins = [...wanted].sort().map((id) => {
|
||||
const listed = byId.get(id);
|
||||
// gated holds releases that need a newer Termix than old builds check for.
|
||||
const latest = [...(listed?.versions ?? []), ...(listed?.gated ?? [])]
|
||||
.filter((entry) => semver.valid(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];
|
||||
if (!latest) throw new Error(`${id} is not in the registry index`);
|
||||
if (!latest) {
|
||||
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 defaultLook = currentById.get(id)?.defaultLook === true;
|
||||
const tier = currentById.get(id)?.tier ?? "full";
|
||||
return {
|
||||
id,
|
||||
source: "tmxplug",
|
||||
url: latest.url,
|
||||
sha256: latest.sha256,
|
||||
tier,
|
||||
...(onboarding ? { onboarding } : {}),
|
||||
...(defaultLook ? { defaultLook } : {}),
|
||||
};
|
||||
});
|
||||
return pins.filter(Boolean);
|
||||
}
|
||||
|
||||
const LOCAL_FILES = [
|
||||
@@ -409,6 +458,9 @@ function copyLocalBuild(repo, destination) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PROFILES,
|
||||
resolveProfile,
|
||||
pluginsForProfile,
|
||||
findLocalPluginBuilds,
|
||||
isLocalBuildStale,
|
||||
localSdkBuildCommand,
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
* re-released.
|
||||
*
|
||||
* node scripts/pin-bundled-plugins.cjs refresh the plugins already listed
|
||||
* node scripts/pin-bundled-plugins.cjs --all list every plugin in the index
|
||||
* node scripts/pin-bundled-plugins.cjs --all also add plugins new to the index that have a stable release (tier full)
|
||||
* --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");
|
||||
@@ -22,6 +25,8 @@ const configPath = path.join(root, "docker", "bundled-plugins.json");
|
||||
|
||||
async function main() {
|
||||
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);
|
||||
if (!response.ok) {
|
||||
throw new Error(`registry index download failed with ${response.status}`);
|
||||
@@ -30,7 +35,7 @@ async function main() {
|
||||
|
||||
const current = loadBundledPlugins(root);
|
||||
const ids = all ? null : current.map((plugin) => plugin.id);
|
||||
const plugins = pinsFromIndex(index, ids, current);
|
||||
const plugins = pinsFromIndex(index, ids, current, coreVersion);
|
||||
fs.writeFileSync(configPath, `${JSON.stringify({ plugins }, null, 2)}\n`);
|
||||
console.log(
|
||||
`Pinned ${plugins.length} plugin(s) in docker/bundled-plugins.json`,
|
||||
|
||||
@@ -144,3 +144,32 @@ describe("termix-plugin validate layouts and capabilities", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -137,9 +137,19 @@ function verifyArtifact(artifact) {
|
||||
console.log(`Verified macOS sharp packaging: ${path.basename(artifact)}`);
|
||||
} finally {
|
||||
if (mountedAt) {
|
||||
spawnSync("hdiutil", ["detach", mountedAt], { stdio: "ignore" });
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const args =
|
||||
attempt < 3 ? ["detach", mountedAt] : ["detach", "-force", mountedAt];
|
||||
if (spawnSync("hdiutil", args, { stdio: "ignore" }).status === 0) break;
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2000);
|
||||
}
|
||||
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
fs.rmSync(temporaryRoot, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 5,
|
||||
retryDelay: 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
|
||||
import { syncForwarder } from "../sync/provider.js";
|
||||
import { syncChangeWatcher } from "../sync/server/change-watcher.js";
|
||||
import dashboardRoutes from "./routes/dashboard-routes.js";
|
||||
import operationsRoutes from "./routes/operations-routes.js";
|
||||
import {
|
||||
mountPluginApi,
|
||||
mountPluginLegacyPaths,
|
||||
@@ -1627,6 +1628,7 @@ app.use("/ui-preferences", uiPreferencesRoutes);
|
||||
registerAuditLogRoutes(app, authenticateJWT);
|
||||
app.use("/sync", syncForwarder);
|
||||
app.use("/dashboard", dashboardRoutes);
|
||||
app.use("/operations", operationsRoutes);
|
||||
app.use("/plugins", pluginRoutes);
|
||||
app.use(
|
||||
"/plugin-assets",
|
||||
|
||||
@@ -484,6 +484,19 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
);
|
||||
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 (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
@@ -586,6 +599,70 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
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 (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -948,6 +1025,7 @@ const enforceUniqueUsernames = () => {
|
||||
|
||||
const migrateSchema = () => {
|
||||
repairSnippetsColumns(sqlite);
|
||||
addColumnIfNotExists("api_keys", "scopes", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "theme", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "font_size", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "accent_color", "TEXT");
|
||||
@@ -1316,6 +1394,8 @@ const migrateSchema = () => {
|
||||
{ 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: "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) {
|
||||
@@ -1797,6 +1877,8 @@ async function saveMemoryDatabaseToFile(): Promise<void> {
|
||||
if (!memoryDatabase) return;
|
||||
|
||||
try {
|
||||
// Before the copy, so a write during the slow part below stays dirty.
|
||||
DatabaseSaveTrigger.markClean();
|
||||
const buffer = memoryDatabase.serialize();
|
||||
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
@@ -1817,11 +1899,10 @@ async function saveMemoryDatabaseToFile(): Promise<void> {
|
||||
encryptedDbPath,
|
||||
);
|
||||
} else {
|
||||
fs.writeFileSync(dbPath, buffer);
|
||||
await fs.promises.writeFile(dbPath, buffer);
|
||||
}
|
||||
|
||||
DatabaseSaveTrigger.markClean();
|
||||
} catch (error) {
|
||||
DatabaseSaveTrigger.markDirty();
|
||||
databaseLogger.error("Failed to save in-memory database", error, {
|
||||
operation: "memory_db_save_failed",
|
||||
enableFileEncryption,
|
||||
@@ -1840,7 +1921,8 @@ async function handlePostInitFileEncryption() {
|
||||
|
||||
setInterval(() => {
|
||||
if (DatabaseSaveTrigger.isDirty) {
|
||||
saveMemoryDatabaseToFile();
|
||||
// Through the trigger so it waits for any save already running.
|
||||
void DatabaseSaveTrigger.forceSave("periodic_save").catch(() => {});
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
@@ -193,6 +193,9 @@ export const hosts = mysqlTable(
|
||||
// legacy algorithms, agent, environment). JSON.
|
||||
sshOptions: text("ssh_options"),
|
||||
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),
|
||||
|
||||
sshPort: int("ssh_port").default(22),
|
||||
@@ -487,6 +490,38 @@ 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(
|
||||
"shared_host_secrets",
|
||||
{
|
||||
@@ -638,10 +673,102 @@ export const apiKeys = mysqlTable(
|
||||
expiresAt: text("expires_at"),
|
||||
lastUsedAt: text("last_used_at"),
|
||||
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)],
|
||||
);
|
||||
|
||||
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(
|
||||
"user_open_tabs",
|
||||
{
|
||||
|
||||
@@ -193,6 +193,9 @@ export const hosts = pgTable(
|
||||
// legacy algorithms, agent, environment). JSON.
|
||||
sshOptions: text("ssh_options"),
|
||||
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),
|
||||
|
||||
sshPort: integer("ssh_port").default(22),
|
||||
@@ -487,6 +490,38 @@ 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(
|
||||
"shared_host_secrets",
|
||||
{
|
||||
@@ -638,10 +673,102 @@ export const apiKeys = pgTable(
|
||||
expiresAt: text("expires_at"),
|
||||
lastUsedAt: text("last_used_at"),
|
||||
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)],
|
||||
);
|
||||
|
||||
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(
|
||||
"user_open_tabs",
|
||||
{
|
||||
|
||||
@@ -183,6 +183,9 @@ export const hosts = sqliteTable(
|
||||
// legacy algorithms, agent, environment). JSON.
|
||||
sshOptions: text("ssh_options"),
|
||||
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),
|
||||
|
||||
sshPort: integer("ssh_port").default(22),
|
||||
@@ -477,6 +480,38 @@ 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(
|
||||
"shared_host_secrets",
|
||||
{
|
||||
@@ -628,10 +663,102 @@ export const apiKeys = sqliteTable(
|
||||
expiresAt: text("expires_at"),
|
||||
lastUsedAt: text("last_used_at"),
|
||||
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)],
|
||||
);
|
||||
|
||||
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(
|
||||
"user_open_tabs",
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ import { HostSidebarPreferenceRepository } from "./host-sidebar-preference-repos
|
||||
import { CredentialSidebarPreferenceRepository } from "./credential-sidebar-preference-repository.js";
|
||||
import { UiPreferenceRepository } from "./ui-preference-repository.js";
|
||||
import { OpenTabRepository } from "./open-tab-repository.js";
|
||||
import { OperationRepository } from "./operation-repository.js";
|
||||
import { PluginRepository } from "./plugin-repository.js";
|
||||
import { PluginStorageRepository } from "./plugin-storage-repository.js";
|
||||
import { PluginSettingsRepository } from "./plugin-settings-repository.js";
|
||||
@@ -31,6 +32,7 @@ import { FolderAccessRepository } from "./folder-access-repository.js";
|
||||
import { SettingsRepository } from "./settings-repository.js";
|
||||
import { HostProtocolAuthRepository } from "./host-protocol-auth-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 { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
|
||||
import { TrustedDeviceRepository } from "./trusted-device-repository.js";
|
||||
@@ -133,6 +135,14 @@ 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 {
|
||||
return new CredentialRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
@@ -316,6 +326,15 @@ export function createCurrentSharedHostAuthOverrideRepository(): SharedHostAuthO
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSharedFolderAuthOverrideRepository(): SharedFolderAuthOverrideRepository {
|
||||
return new SharedFolderAuthOverrideRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook(
|
||||
"shared_folder_auth_override_repository_write",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageRepository {
|
||||
return new SshCredentialUsageRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
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?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
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,6 +3,7 @@ import { recentActivity } from "../../plugins/activity-provider.js";
|
||||
import {
|
||||
createCurrentApiKeyRepository,
|
||||
createCurrentAuditLogRepository,
|
||||
createCurrentOperationRepository,
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentHostFolderRepository,
|
||||
createCurrentHostRepository,
|
||||
@@ -61,6 +62,7 @@ export async function deleteUserAndRelatedData(
|
||||
|
||||
await createCurrentRoleRepository().removeAllRolesFromUser(userId);
|
||||
await createCurrentAuditLogRepository().anonymizeByUserId(userId);
|
||||
await createCurrentOperationRepository().anonymizeUser(userId);
|
||||
|
||||
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
|
||||
|
||||
|
||||
@@ -30,6 +30,33 @@ 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;
|
||||
|
||||
export function normalizeProtocolEnableFields(
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
isOptionalAuthType,
|
||||
isOptionalBoolean,
|
||||
isValidPort,
|
||||
normalizeHostLookFields,
|
||||
normalizeProtocolEnableFields,
|
||||
OWNER_PRIVATE_AUTH_FIELDS,
|
||||
OWNER_PRIVATE_SSH_OPTION_FIELDS,
|
||||
@@ -335,6 +336,7 @@ router.post(
|
||||
? JSON.stringify(portKnockSequence)
|
||||
: null,
|
||||
...normalizeProtocolEnableFields(hostData),
|
||||
...normalizeHostLookFields(hostData),
|
||||
sshPort: sshPort || port || 22,
|
||||
};
|
||||
|
||||
@@ -912,6 +914,7 @@ router.put(
|
||||
? JSON.stringify(portKnockSequence)
|
||||
: null,
|
||||
...normalizeProtocolEnableFields(hostData),
|
||||
...normalizeHostLookFields(hostData),
|
||||
sshPort: sshPort || port || 22,
|
||||
};
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
safeLayoutForced,
|
||||
unavailableLooks,
|
||||
} from "../../plugins/looks.js";
|
||||
import { safeModeOn } from "../../plugins/safe-mode.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
@@ -36,7 +37,7 @@ async function adminDefaults() {
|
||||
* /user-preferences/look:
|
||||
* get:
|
||||
* summary: Get the layout and theme to show the current user
|
||||
* description: The user's own choice, the admin defaults (a layout no installed plugin brings reads as null), the default look of the bundled plugins, whether TERMIX_SAFE_LAYOUT forces the repair view, and installed layout or theme plugins that could not load. The browser resolves the final look from these.
|
||||
* 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.
|
||||
* tags:
|
||||
* - User Preferences
|
||||
* responses:
|
||||
@@ -75,6 +76,8 @@ async function adminDefaults() {
|
||||
* nullable: true
|
||||
* safeLayout:
|
||||
* type: boolean
|
||||
* safeMode:
|
||||
* type: boolean
|
||||
* unavailable:
|
||||
* type: array
|
||||
* items:
|
||||
@@ -112,6 +115,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
defaults: { ...defaults, layout: known(defaults.layout) },
|
||||
defaultLook: resolveDefaultLook(loader),
|
||||
safeLayout: safeLayoutForced(),
|
||||
safeMode: safeModeOn(),
|
||||
unavailable: unavailableLooks(loader),
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,760 @@
|
||||
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;
|
||||
@@ -16,6 +16,7 @@ import { getPluginRuntime } from "../../plugins/index.js";
|
||||
import { resolvePluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest";
|
||||
import { describePluginFrontend } from "../../plugins/assets.js";
|
||||
import { isDefaultLookPlugin } from "../../plugins/bundled-index.js";
|
||||
import { mayRunInSafeMode } from "../../plugins/safe-mode.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
||||
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
|
||||
@@ -243,12 +244,17 @@ export async function listPreLoginPlugins(): Promise<
|
||||
* `assetVersion` is its cache key, `css` says a stylesheet sits beside
|
||||
* it, and `locales` lists the languages it ships ("en" and xx_YY).
|
||||
* `dependencies` and `optionalDependencies` let the loader activate
|
||||
* frontends in the same order the server does.
|
||||
* frontends in the same order the server does. `engine` holds the
|
||||
* 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
|
||||
* only run on that server and stay off here on purpose, and, for
|
||||
* admins, `managedByServer` marks plugins whose on/off state the server
|
||||
* decides.
|
||||
*
|
||||
* While TERMIX_SAFE_MODE is on, an enabled plugin that safe mode keeps
|
||||
* off reports `state: "safe-mode"`.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* responses:
|
||||
@@ -299,6 +305,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
let features: string[] = [];
|
||||
let docs: string | undefined;
|
||||
let env: PluginEnvVar[] = [];
|
||||
const engine: { api?: string; termix?: string } = {};
|
||||
let desktopMode: unknown = loaded
|
||||
? resolvePluginDesktopMode(loaded.manifest)
|
||||
: undefined;
|
||||
@@ -318,7 +325,14 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
optionalDependencies?: Record<string, string>;
|
||||
desktop?: 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
|
||||
? resolvePluginDesktopMode(loaded.manifest)
|
||||
: (manifest?.desktop ??
|
||||
@@ -354,8 +368,14 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
name: record.name,
|
||||
version: record.version,
|
||||
enabled: record.state === "enabled",
|
||||
state: loaded?.state ?? record.state,
|
||||
state:
|
||||
record.state === "enabled" &&
|
||||
loaded?.state !== "active" &&
|
||||
!mayRunInSafeMode(record.id, loader.list())
|
||||
? "safe-mode"
|
||||
: (loaded?.state ?? record.state),
|
||||
runsOnServer: linked && desktopMode === "server",
|
||||
engine,
|
||||
contributes,
|
||||
icon,
|
||||
dependencies,
|
||||
@@ -936,7 +956,7 @@ router.delete(
|
||||
* 404:
|
||||
* description: No such plugin.
|
||||
* 409:
|
||||
* description: This desktop is linked to a server, which decides whether the plugin runs.
|
||||
* 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.
|
||||
*/
|
||||
router.patch(
|
||||
"/:id/state",
|
||||
@@ -1050,6 +1070,8 @@ router.patch(
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 404:
|
||||
* 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:
|
||||
* description: The plugin failed to start again.
|
||||
*/
|
||||
@@ -1073,6 +1095,13 @@ router.post(
|
||||
res.status(404).json({ error: "Plugin is not present on disk" });
|
||||
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, {
|
||||
state: "enabled",
|
||||
@@ -2677,6 +2706,10 @@ router.put(
|
||||
* summary: Upload a .tmxplug to install from a file
|
||||
* description: >
|
||||
* 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
|
||||
* minutes, but nothing is loaded. The answer lists what it asks for so
|
||||
* the admin can agree, then POST /plugins/upload/{token}/install
|
||||
@@ -2722,7 +2755,13 @@ router.post(
|
||||
}
|
||||
try {
|
||||
const { stageUpload } = await import("../../plugins/manage.js");
|
||||
res.json(await stageUpload(req.body));
|
||||
// The release's .sig, base64 encoded, so an official file installs as
|
||||
// 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) {
|
||||
sendManageError(
|
||||
res,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { isAuthOverrideProtocol } from "../../hosts/protocol-auth/registry.js";
|
||||
import {
|
||||
assertHostsUnlocked,
|
||||
hostLock,
|
||||
hasHostLocks,
|
||||
requireUnlockedHost,
|
||||
sendHostLocked,
|
||||
@@ -36,8 +37,10 @@ import {
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentRbacAccessRepository,
|
||||
createCurrentRoleRepository,
|
||||
createCurrentSharedFolderAuthOverrideRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import { findUsableCredential } from "../../hosts/usable-credential.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -722,14 +725,40 @@ 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
|
||||
* /rbac/folder/access/{id}:
|
||||
* delete:
|
||||
* summary: Stop a folder share from applying to hosts added later
|
||||
* description: Grants already fanned out to hosts stay; revoke those per host.
|
||||
* 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.
|
||||
* tags:
|
||||
* - 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(
|
||||
"/folder/access/:id",
|
||||
@@ -741,11 +770,35 @@ router.delete(
|
||||
}
|
||||
try {
|
||||
const repository = createCurrentFolderAccessRepository();
|
||||
if (!(await repository.findById(ruleId, req.userId!))) {
|
||||
const rule = await repository.findById(ruleId, req.userId!);
|
||||
if (!rule) {
|
||||
return res.status(404).json({ error: "Rule not found" });
|
||||
}
|
||||
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) {
|
||||
databaseLogger.error("Failed to delete folder access rule", error, {
|
||||
operation: "delete_folder_access",
|
||||
@@ -2174,4 +2227,157 @@ 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;
|
||||
@@ -0,0 +1,95 @@
|
||||
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" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import { shownIdentity } from "../../sync/shown-identity.js";
|
||||
import { registerUserExternalAccountRoutes } from "./user-external-account-routes.js";
|
||||
import { registerUserPasswordResetRoutes } from "./user-password-reset-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 { listExternalLoginMethods, registerAuthRoutes } from "./auth-routes.js";
|
||||
import { registerAuthCompatRoutes } from "./auth-compat-routes.js";
|
||||
@@ -318,7 +319,7 @@ router.post("/proxy-login", async (req, res) => {
|
||||
try {
|
||||
if (!isTrustedProxyAddress(sourceAddress, config.trustedProxies)) {
|
||||
authLogger.warn(
|
||||
"Rejected proxy authentication from an untrusted source",
|
||||
`Rejected proxy authentication from an untrusted source: ${sourceAddress ?? "unknown"}`,
|
||||
{
|
||||
operation: "trusted_proxy_auth_rejected",
|
||||
sourceAddress,
|
||||
@@ -1229,86 +1230,7 @@ router.patch("/password-reset-allowed", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @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" });
|
||||
}
|
||||
});
|
||||
registerUserDeleteAccountRoutes(router, authenticateJWT);
|
||||
|
||||
registerUserPasswordResetRoutes(router, { authManager });
|
||||
|
||||
|
||||
@@ -99,7 +99,12 @@ async function prepareKey(
|
||||
|
||||
if (passphrase) config.passphrase = passphrase;
|
||||
// Lets a server that wants key then password (or password only) still work.
|
||||
if (host.password) config.password = host.password;
|
||||
// ssh2 tries password first by default, so it has none left to answer with
|
||||
// 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;
|
||||
if (certPublicKey && certPublicKey.trim()) {
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
*/
|
||||
|
||||
import type { Duplex } from "node:stream";
|
||||
import type { PluginSshTransportProvider } from "@termix-ssh/plugin-sdk/backend";
|
||||
import type {
|
||||
PluginSshTransportHost,
|
||||
PluginSshTransportProvider,
|
||||
} from "@termix-ssh/plugin-sdk/backend";
|
||||
|
||||
interface Entry {
|
||||
pluginId: string;
|
||||
@@ -33,15 +36,13 @@ export function clearSshTransports(): void {
|
||||
}
|
||||
|
||||
/** Opens a stream to the host if a provider claims it, else null. */
|
||||
export async function openProvidedTransport(host: {
|
||||
id: number;
|
||||
ip: string;
|
||||
port: number;
|
||||
}): Promise<Duplex | null> {
|
||||
export async function openProvidedTransport(
|
||||
host: PluginSshTransportHost,
|
||||
): Promise<Duplex | null> {
|
||||
for (const { provider } of providers.values()) {
|
||||
let claimed = false;
|
||||
try {
|
||||
claimed = provider.claims(host.id);
|
||||
claimed = provider.claims(host.id, host);
|
||||
} catch {
|
||||
claimed = false;
|
||||
}
|
||||
|
||||
@@ -99,6 +99,10 @@ export async function openSshTransport(
|
||||
id: host.id,
|
||||
ip: host.ip,
|
||||
port: host.port || 22,
|
||||
userId: host.userId ?? null,
|
||||
syncId: host.syncId ?? null,
|
||||
connectionOrigin: host.connectionOrigin ?? null,
|
||||
localOnly: Boolean(host.localOnly),
|
||||
});
|
||||
if (provided) {
|
||||
config.sock = provided;
|
||||
|
||||
@@ -24,6 +24,9 @@ export interface SshConnectHost {
|
||||
port: number;
|
||||
username: string;
|
||||
userId?: string | null;
|
||||
syncId?: string | null;
|
||||
connectionOrigin?: "local" | "remote" | null;
|
||||
localOnly?: boolean | null;
|
||||
authType?: string | null;
|
||||
password?: string | null;
|
||||
key?: string | Buffer | null;
|
||||
|
||||
Loaded 100 of 270 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user