mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73c15f7c58 | ||
|
|
96b8520fb4 | ||
|
|
7b7b17e31f | ||
|
|
53c4e5fe9c | ||
|
|
28cb1bc554 | ||
|
|
b691d4f7cb | ||
|
|
a65b4a5d48 | ||
|
|
abaf89b732 | ||
|
|
72d868afc7 | ||
|
|
d35304e107 | ||
|
|
f4d87ddd8d | ||
|
|
4d7385f51d | ||
|
|
d3fc32928b | ||
|
|
1533c6837a | ||
|
|
a89ccc6b07 | ||
|
|
aff29a9581 |
No files matched your search
@@ -41,9 +41,15 @@ on:
|
|||||||
default: ""
|
default: ""
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# One image per plugin profile (docker/bundled-plugins.json). standard keeps
|
||||||
|
# the plain tags; minimal and full add a suffix.
|
||||||
build:
|
build:
|
||||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
profile: [standard, minimal, full]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
@@ -80,12 +86,17 @@ jobs:
|
|||||||
TAGS=()
|
TAGS=()
|
||||||
ALL_TAGS=()
|
ALL_TAGS=()
|
||||||
|
|
||||||
|
SUFFIX=""
|
||||||
|
if [ "${{ matrix.profile }}" != "standard" ]; then
|
||||||
|
SUFFIX="-${{ matrix.profile }}"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$BUILD_TYPE" = "Production" ]; then
|
if [ "$BUILD_TYPE" = "Production" ]; then
|
||||||
TAGS+=("$VERSION" "latest")
|
TAGS+=("$VERSION$SUFFIX" "latest$SUFFIX")
|
||||||
elif [ "$BUILD_TYPE" = "Beta" ]; then
|
elif [ "$BUILD_TYPE" = "Beta" ]; then
|
||||||
TAGS+=("beta" "$VERSION")
|
TAGS+=("beta$SUFFIX" "$VERSION$SUFFIX")
|
||||||
else
|
else
|
||||||
TAGS+=("dev-$VERSION")
|
TAGS+=("dev-$VERSION$SUFFIX")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
for tag in "${TAGS[@]}"; do
|
for tag in "${TAGS[@]}"; do
|
||||||
@@ -124,6 +135,7 @@ jobs:
|
|||||||
tags: ${{ env.ALL_TAGS }}
|
tags: ${{ env.ALL_TAGS }}
|
||||||
build-args: |
|
build-args: |
|
||||||
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
|
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
|
||||||
|
PLUGIN_PROFILE=${{ matrix.profile }}
|
||||||
labels: |
|
labels: |
|
||||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||||
org.opencontainers.image.revision=${{ env.SOURCE_SHA }}
|
org.opencontainers.image.revision=${{ env.SOURCE_SHA }}
|
||||||
|
|||||||
@@ -115,7 +115,9 @@ jobs:
|
|||||||
Write-Host "Spectre libs present: $($found.FullName)"
|
Write-Host "Spectre libs present: $($found.FullName)"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: |
|
||||||
|
npm ci
|
||||||
|
node scripts/install-windows-sharp.cjs
|
||||||
|
|
||||||
# Bundled plugins are counted as downloads in the plugin store, so a
|
# Bundled plugins are counted as downloads in the plugin store, so a
|
||||||
# build only fetches them when the pins change.
|
# build only fetches them when the pins change.
|
||||||
|
|||||||
@@ -161,9 +161,10 @@ jobs:
|
|||||||
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
||||||
echo "prev_ref=$PREV" >> "$GITHUB_OUTPUT"
|
echo "prev_ref=$PREV" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
# Translates new or changed strings with the Termix LLM server and commits
|
# Translates new or changed strings with the Termix LLM server and pins the
|
||||||
# them to the dev branch, so the beta or stable build includes them. If the
|
# bundled plugins to their newest registry releases, then commits both to the
|
||||||
# server is down it warns and the release goes on with what is there.
|
# 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:
|
translate:
|
||||||
needs: [prep]
|
needs: [prep]
|
||||||
if: ${{ needs.prep.outputs.skip != 'true' && needs.prep.outputs.mode != 'Overwrite release' }}
|
if: ${{ needs.prep.outputs.skip != 'true' && needs.prep.outputs.mode != 'Overwrite release' }}
|
||||||
@@ -191,19 +192,26 @@ jobs:
|
|||||||
languages: src/ui/locales/languages.json
|
languages: src/ui/locales/languages.json
|
||||||
api-key: ${{ secrets.TERMIX_LLM_API_KEY }}
|
api-key: ${{ secrets.TERMIX_LLM_API_KEY }}
|
||||||
|
|
||||||
- name: 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
|
id: commit
|
||||||
run: |
|
run: |
|
||||||
git add src/ui/locales
|
git add src/ui/locales docker/bundled-plugins.json
|
||||||
if git diff --cached --quiet; then
|
if git diff --cached --quiet; then
|
||||||
echo "No translation changes."
|
echo "No translation changes."
|
||||||
elif [ "${{ needs.prep.outputs.mode }}" = "Dry run" ]; then
|
elif [ "${{ needs.prep.outputs.mode }}" = "Dry run" ]; then
|
||||||
echo "DRY RUN: would commit these translation changes:"
|
echo "DRY RUN: would commit these translation and pin changes:"
|
||||||
git --no-pager diff --cached --stat
|
git --no-pager diff --cached --stat
|
||||||
else
|
else
|
||||||
git config user.name "LukeGus"
|
git config user.name "LukeGus"
|
||||||
git config user.email "bugattiguy527@gmail.com"
|
git config user.email "bugattiguy527@gmail.com"
|
||||||
git commit -m "chore: update translations"
|
git commit -m "chore: update translations and plugin pins"
|
||||||
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
|
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
|
||||||
fi
|
fi
|
||||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||||
|
|||||||
@@ -11,4 +11,3 @@ db
|
|||||||
*.min.css
|
*.min.css
|
||||||
openapi.json
|
openapi.json
|
||||||
drizzle/
|
drizzle/
|
||||||
src/ui/settings/settings-search-index.ts
|
|
||||||
@@ -1,5 +1,90 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- Choose the layout of the whole app in Settings, Appearance. Admins can also set the default layout and theme for everyone
|
||||||
|
- Plugins can add layouts and themes
|
||||||
|
- A repair screen shows when the chosen layout cannot load, with ways to update, switch, reset or turn it off. Open it any time with ?safe-layout=1, or for everyone with TERMIX_SAFE_LAYOUT=1
|
||||||
|
- Hovering a theme in Settings previews it
|
||||||
|
- Restore the bundled copy of a plugin from the repair screen, which also pins the shipped version
|
||||||
|
- Switching layouts closes the tabs only the old layout could show, like its Settings tab
|
||||||
|
- A chosen layout whose plugin was uninstalled falls back to the default instead of the repair screen
|
||||||
|
- Filter plugins by type, like layouts, themes or tabs, from a filter list beside the results
|
||||||
|
- Browse shows plugins a page at a time
|
||||||
|
- Plugins that need a newer Termix are hidden in Browse, with a toggle to show them
|
||||||
|
- Hosts can be managed from a config file through a plugin. Managed hosts are read only for everyone, but pins and order still work
|
||||||
|
- A plugin can block adding hosts by hand, so every host comes from one place
|
||||||
|
- Plugins can set login rules: sign out after a time without activity, limit how long a session lasts, turn off remember me and require a second factor
|
||||||
|
- A user who must have a second factor but has none can sign in only to set one up
|
||||||
|
- Admins can redirect HTTP to HTTPS and send HSTS while HTTPS is on. Off by default so reverse proxies keep working
|
||||||
|
- A plugin can answer all sync requests and use the replica store, change feed and linked server list, so sync no longer lives in the core app
|
||||||
|
- The desktop app can hold several accounts, each with its own copy of a server's data. The app signs in as the active one and its auto session follows a switch
|
||||||
|
- The linked server list holds one link per account, with room for more connection options per server
|
||||||
|
- A plugin can carry SSH connections over its own stream, and core relays a host's connection over a socket for another Termix
|
||||||
|
- Plugins can create a folder with a color and icon and remove it when it is empty
|
||||||
|
- Another Termix server can manage this one with an API key, which can now ask for a one minute ticket to open plugin sockets as its user
|
||||||
|
- FIDO2 security keys (ed25519-sk and ecdsa-sk) work through SSH Agent authentication when Termix runs in the desktop app. The key asks for a touch or PIN on your own machine
|
||||||
|
- Pasting a security key file now explains how to use it instead of failing to decode it
|
||||||
|
- Plugins can add a panel that sits beside a host tab, like a terminal. You can open, resize and move it
|
||||||
|
- The desktop app can link to a server that requires a client certificate (mutual TLS). It uses a PFX or P12 file for sync and the certificate in your system store for the app window
|
||||||
|
- Plugins can show a web page in a native view in the desktop app, for sites that do not load in an iframe
|
||||||
|
- The desktop app draws its own title bar in the colors of your theme instead of the native white one
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Server and desktop sync moved out of the core app into a plugin, including the Sync screen, the link wizard and the first run step. Existing links and offline copies keep working
|
||||||
|
- The icon rail, sidebar, tab bar, dock, Dashboard, Manage, Settings and command palette now come from a bundled layout. It can be swapped for another layout but not removed, and a fix to it no longer needs a Termix release
|
||||||
|
- Your theme is saved to your account so your other devices use it too, unless you keep settings on this device only
|
||||||
|
- The themes besides light and dark now come from a bundled themes plugin. It can be swapped but not removed, and Termix keeps a basic light and dark theme of its own
|
||||||
|
- A theme that no longer exists shows as System instead of an unstyled page
|
||||||
|
- Installs and updates from a registry check the Termix version a plugin needs, not only its plugin API
|
||||||
|
- Host online and offline dots now come from the Host Status plugin. The check interval setting moved to its settings and keeps its value. Without the plugin hosts show no dot
|
||||||
|
- Audit log forwarding and the CSV and NDJSON export now come from the Audit Tools plugin. The collector URL and token move to its settings on upgrade. The audit log viewer, retention and the audit log itself stay in Termix
|
||||||
|
- Client tunnels on your own computer and opening a host in Windows Remote Desktop moved out of the desktop app into plugins, so fixes to them no longer need a new desktop build
|
||||||
|
- The local terminal, the local side of the file manager with its transfers, and opening a file in an external editor moved out of the desktop app into plugins, so fixes to them no longer need a new desktop build
|
||||||
|
- The recent activity list and the first run steps for the preset and for theme, accent, font and language now come from bundled plugins. Existing entries and choices are kept, and people who went through those steps are not asked again
|
||||||
|
- Branding (app name, tagline and logo) is now saved by the bundled layout plugin. An existing setup keeps its branding, and the sign in screen still reads it from Termix
|
||||||
|
- Without the plugin that stores recent activity, the list stays empty and nothing is recorded
|
||||||
|
- Importing hosts from a JSON or SSH config file and exporting hosts now come from the Import and Export plugin. Exports are now written to the audit log. Without the plugin there is no import or export
|
||||||
|
- Generating and checking SSH keys in the credential editor and deploying a key to a host now come from the Credential Tools plugin. Deploys are now written to the audit log. Without the plugin those buttons are hidden
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Linking the desktop app to a server no longer stalls on the server dashboard after sign in
|
||||||
|
- Exporting your data no longer fails when a plugin table has a text key, like saved passkeys
|
||||||
|
- The Windows on ARM desktop app starts again instead of failing with a local server error
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- The desktop app no longer has its own client tunnel and native Remote Desktop code
|
||||||
|
|
||||||
## 26.10.2
|
## 26.10.2
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
cask "termix" do
|
cask "termix" do
|
||||||
version "26.10.1"
|
version "26.10.5"
|
||||||
sha256 "08a8fdc6ce3f29bd9031b050a4f6364f3ca446f17cb272392f1b3b488cea5d30"
|
sha256 "808c467c5c34402193c7950ce61df0fa0445782f276348cf31ef235a6427d21c"
|
||||||
|
|
||||||
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
|
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
|
||||||
name "Termix"
|
name "Termix"
|
||||||
|
|||||||
@@ -58,31 +58,47 @@ What every install has, with or without plugins:
|
|||||||
Plugins add everything else. The official ones ship with Termix, and admins can install, update or remove plugins from the Plugins tab. Each plugin has its own repo.
|
Plugins add everything else. The official ones ship with Termix, and admins can install, update or remove plugins from the Plugins tab. Each plugin has its own repo.
|
||||||
|
|
||||||
| Plugin | What it does |
|
| Plugin | What it does |
|
||||||
| --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| [ACME Certificates](https://github.com/Termix-SSH/Plugin-ACME-SSL) | Gets the Termix HTTPS certificate from Let's Encrypt or any ACME provider and renews it for you. |
|
| [ACME Certificates](https://github.com/Termix-SSH/Plugin-ACME-SSL) | Gets the Termix HTTPS certificate from Let's Encrypt or any ACME provider and renews it for you. |
|
||||||
| [AI Assistant](https://github.com/Termix-SSH/Plugin-AI) | An AI assistant that reads your setup and proposes changes for you to approve. |
|
| [AI Assistant](https://github.com/Termix-SSH/Plugin-AI) | An AI assistant that reads your setup and proposes changes for you to approve. |
|
||||||
| [Alerts](https://github.com/Termix-SSH/Plugin-Alerts) | One inbox for every alert in Termix, with delivery to webhooks, ntfy, Discord and email. |
|
| [Alerts](https://github.com/Termix-SSH/Plugin-Alerts) | One inbox for every alert in Termix, with delivery to webhooks, ntfy, Discord and email. |
|
||||||
|
| [Audit Tools](https://github.com/Termix-SSH/Plugin-Audit-Tools) | Send the audit log to your SIEM and export it as CSV or NDJSON. |
|
||||||
| [Automations](https://github.com/Termix-SSH/Plugin-Automations) | Run steps on your hosts on a schedule or when something happens. |
|
| [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. |
|
| [Docker](https://github.com/Termix-SSH/Plugin-Docker) | Manage Docker and Podman containers on your hosts over SSH, with logs, stats and a console. |
|
||||||
| [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. |
|
| [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. |
|
||||||
| [Fleets](https://github.com/Termix-SSH/Plugin-Fleets) | Group hosts into fleets and run commands, package actions and file transfers on all of them at once. |
|
| [Fleets](https://github.com/Termix-SSH/Plugin-Fleets) | Group hosts into fleets and run commands, package actions and file transfers on all of them at once. |
|
||||||
| [HashiCorp Vault](https://github.com/Termix-SSH/Plugin-Vault) | Connect to hosts with short-lived SSH certificates signed by HashiCorp Vault. |
|
| [HashiCorp Vault](https://github.com/Termix-SSH/Plugin-Vault) | Connect to hosts with short-lived SSH certificates signed by HashiCorp Vault. |
|
||||||
| [Homepage](https://github.com/Termix-SSH/Plugin-Homepage) | A drag and drop widget page for your servers and services, plus service links for the dashboard. |
|
| [Homepage](https://github.com/Termix-SSH/Plugin-Homepage) | A drag and drop widget page for your servers and services, plus service links for the dashboard. |
|
||||||
| [Host Metrics](https://github.com/Termix-SSH/Plugin-Host-Metrics) | Live CPU, memory, disk, network and GPU stats, plus tools to manage your hosts. |
|
| [Host Metrics](https://github.com/Termix-SSH/Plugin-Host-Metrics) | Live CPU, memory, disk, network and GPU stats, plus tools to manage your hosts. |
|
||||||
|
| [Host Status](https://github.com/Termix-SSH/Plugin-Host-Status) | Shows if each host is online with a dot in the host list, checked by connecting to its port. |
|
||||||
|
| [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. |
|
| [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. |
|
| [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. |
|
||||||
| [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. |
|
| [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. |
|
||||||
| [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. |
|
| [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. |
|
||||||
| [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. |
|
| [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. |
|
||||||
| [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. |
|
| [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. |
|
| [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. |
|
| [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 Recording](https://github.com/Termix-SSH/Plugin-Session-Recording) | Record terminal and remote desktop sessions and play them back or download them later. |
|
||||||
| [Session Sharing](https://github.com/Termix-SSH/Plugin-Session-Sharing) | Share live sessions by link or with another user, and present sessions to a group in meeting rooms. |
|
| [Session Sharing](https://github.com/Termix-SSH/Plugin-Session-Sharing) | Share live sessions by link or with another user, and present sessions to a group in meeting rooms. |
|
||||||
|
| [Simple Layout](https://github.com/Termix-SSH/Plugin-Simple-Layout) | A simple Termix layout for small setups with a top bar, a Home screen and only the basics. |
|
||||||
| [Single sign-on](https://github.com/Termix-SSH/Plugin-SSO) | Sign in with any OpenID Connect provider, GitHub or Google. |
|
| [Single sign-on](https://github.com/Termix-SSH/Plugin-SSO) | Sign in with any OpenID Connect provider, GitHub or Google. |
|
||||||
| [Snippets](https://github.com/Termix-SSH/Plugin-Snippets) | Save the commands you run often and run them in a terminal or on many hosts in one click. |
|
| [Snippets](https://github.com/Termix-SSH/Plugin-Snippets) | Save the commands you run often and run them in a terminal or on many hosts in one click. |
|
||||||
| [SSH Terminal](https://github.com/Termix-SSH/Plugin-SSH-Terminal) | SSH terminal sessions with tabs, split screen, history, macros and a local terminal in the desktop app. |
|
| [SSH Terminal](https://github.com/Termix-SSH/Plugin-SSH-Terminal) | SSH terminal sessions with tabs, split screen, history, macros and a local terminal in the desktop app. |
|
||||||
| [Step CA](https://github.com/Termix-SSH/Plugin-Step-CA) | Connect to hosts with short-lived SSH certificates from a smallstep step-ca server. |
|
| [Step CA](https://github.com/Termix-SSH/Plugin-Step-CA) | Connect to hosts with short-lived SSH certificates from a smallstep step-ca server. |
|
||||||
|
| [Sync](https://github.com/Termix-SSH/Plugin-Sync) | Sync the desktop app with one or more servers, and manage other Termix servers as remote sites from a master. |
|
||||||
| [Tailscale](https://github.com/Termix-SSH/Plugin-Tailscale) | Add tailnet devices as hosts and connect with Tailscale SSH. |
|
| [Tailscale](https://github.com/Termix-SSH/Plugin-Tailscale) | Add tailnet devices as hosts and connect with Tailscale SSH. |
|
||||||
| [Termix Identity](https://github.com/Termix-SSH/Plugin-Termix-Identity) | Publish your SSH public keys under a public handle and run your own SSH certificate authority. |
|
| [Termix Identity](https://github.com/Termix-SSH/Plugin-Termix-Identity) | Publish your SSH public keys under a public handle and run your own SSH certificate authority. |
|
||||||
| [Tmux Monitor](https://github.com/Termix-SSH/Plugin-Tmux-Monitor) | Browse and control tmux sessions, windows and panes across your hosts. |
|
| [Tmux Monitor](https://github.com/Termix-SSH/Plugin-Tmux-Monitor) | Browse and control tmux sessions, windows and panes across your hosts. |
|
||||||
|
|||||||
+15
-4
@@ -34,9 +34,20 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
RUN npm run build:backend:core
|
||||||
|
|
||||||
|
# Stage 3b: Stage the bundled plugins for one profile (minimal, standard or
|
||||||
|
# full, see docker/bundled-plugins.json). Only this stage and the final one
|
||||||
|
# differ between profiles, so the other stages are built once.
|
||||||
|
FROM deps AS plugins
|
||||||
|
WORKDIR /app
|
||||||
|
ARG PLUGIN_PROFILE=standard
|
||||||
|
COPY scripts/build-plugins.cjs ./scripts/build-plugins.cjs
|
||||||
|
COPY scripts/lib/bundled-plugins.cjs ./scripts/lib/bundled-plugins.cjs
|
||||||
|
COPY docker/bundled-plugins.json ./docker/bundled-plugins.json
|
||||||
# Bundled plugin downloads show in the plugin store, so keep them between builds.
|
# Bundled plugin downloads show in the plugin store, so keep them between builds.
|
||||||
RUN --mount=type=cache,target=/app/node_modules/.cache/termix-bundled-plugins \
|
RUN --mount=type=cache,target=/app/node_modules/.cache/termix-bundled-plugins \
|
||||||
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
|
# Stage 4: Download OPKSSH binary for the target platform so the image works offline
|
||||||
FROM node:24-slim AS opkssh-downloader
|
FROM node:24-slim AS opkssh-downloader
|
||||||
@@ -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=production-deps /app/node_modules /app/node_modules
|
||||||
COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend
|
COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend
|
||||||
# Plugins pinned in docker/bundled-plugins.json, resolved as dist/plugins by
|
# Plugins of the chosen profile pinned in docker/bundled-plugins.json, resolved
|
||||||
# getBundledPluginsDir().
|
# as dist/plugins by getBundledPluginsDir().
|
||||||
COPY --chown=node:node --from=backend-builder /app/dist/plugins ./dist/plugins
|
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.
|
# Target of node_modules/@termix-ssh/plugin-sdk, which core and the plugins import.
|
||||||
COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/package.json ./packages/plugin-sdk/package.json
|
COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/package.json ./packages/plugin-sdk/package.json
|
||||||
COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/dist ./packages/plugin-sdk/dist
|
COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/dist ./packages/plugin-sdk/dist
|
||||||
|
|||||||
+201
-53
@@ -3,36 +3,85 @@
|
|||||||
{
|
{
|
||||||
"id": "acme-ssl",
|
"id": "acme-ssl",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.2/acme-ssl-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.1.0/acme-ssl-1.1.0.tmxplug",
|
||||||
"sha256": "157d0f96dc42136ff64e618b6904db7862079cd31c1f1cf5f19846b1d127b00f"
|
"sha256": "fb75541fae110007b45ed69d58aa4b9b6f6a79b6b1b762c6e54b635aa7cb6e4a",
|
||||||
|
"tier": "full"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "ai",
|
"id": "ai",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.1.0/ai-1.1.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.3.0/ai-1.3.0.tmxplug",
|
||||||
"sha256": "71dda6940d62c9eede7821bbac21e99443fbc08728a47759f215e2416f1d162f"
|
"sha256": "87ad17324cec6036dc224135fee342250c37413dccb2963f5f6b554d4ab8f5a6",
|
||||||
|
"tier": "full"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "alerts",
|
"id": "alerts",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.2/alerts-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.1.0/alerts-1.1.0.tmxplug",
|
||||||
"sha256": "50768e77019276343a5d162c85d081659fb89d1cc3733ac45cd0db4436918faa",
|
"sha256": "a7ce9e20ce3c15f77c15479b4901563adf3c793e24a3aacd7226950491f0434c",
|
||||||
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
"tier": "full"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "automations",
|
"id": "automations",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.2/automations-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.2.0/automations-1.2.0.tmxplug",
|
||||||
"sha256": "674eb04276b4a0c77c794b30647f8680218a78de8d52b73e99ec900cc1b3fdb3"
|
"sha256": "2ec9c718d73a094be3b59121aea049f0237e5783f53d193c4ea24a524584aeea",
|
||||||
|
"tier": "full"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "classic-layout",
|
||||||
|
"source": "tmxplug",
|
||||||
|
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.2/classic-layout-1.0.2.tmxplug",
|
||||||
|
"sha256": "cf54f9b2e81c33b7e58cd3a21e203805f08ba37b3bc78412a08eb21b9fef8f14",
|
||||||
|
"tier": "minimal",
|
||||||
|
"defaultLook": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "classic-themes",
|
||||||
|
"source": "tmxplug",
|
||||||
|
"url": "https://github.com/Termix-SSH/Plugin-Classic-Themes/releases/download/v1.0.0/classic-themes-1.0.0.tmxplug",
|
||||||
|
"sha256": "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",
|
||||||
|
"tier": "standard"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "databases",
|
||||||
|
"source": "tmxplug",
|
||||||
|
"url": "https://github.com/Termix-SSH/Plugin-Databases/releases/download/v1.1.0/databases-1.1.0.tmxplug",
|
||||||
|
"sha256": "4d9504cde705688a6f1f7706139d4e4c49b2276eccc67538fdee8251f29920b8",
|
||||||
|
"tier": "full"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "docker",
|
"id": "docker",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.2/docker-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.2.1/docker-1.2.1.tmxplug",
|
||||||
"sha256": "0e1c722682faac6a027fd9b01e6910eb53ce5acb13332c6c8d8868554f3701ab",
|
"sha256": "b2ca797465d88cfb549a04c3f0161708669def22a5ca73cb107879847c2db8ff",
|
||||||
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
@@ -41,8 +90,9 @@
|
|||||||
{
|
{
|
||||||
"id": "file-manager",
|
"id": "file-manager",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.3/file-manager-1.0.3.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.1/file-manager-1.1.1.tmxplug",
|
||||||
"sha256": "250e9779a6b1caf11721f1ee2baf76655ae6e52d963e894d8db84eb9677cf0b3",
|
"sha256": "ebba0b7d14facb3354f22a00e059e9f5a69e233458cb24985bf4ed5e5f8d2abd",
|
||||||
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
@@ -52,91 +102,167 @@
|
|||||||
"id": "fleets",
|
"id": "fleets",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.2/fleets-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.2/fleets-1.0.2.tmxplug",
|
||||||
"sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59"
|
"sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59",
|
||||||
|
"tier": "full"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "homepage",
|
"id": "homepage",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.2/homepage-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.1.0/homepage-1.1.0.tmxplug",
|
||||||
"sha256": "88cdea8ba8f458fcf124e7ab97e3d419896d8d22ffa9a372af34f1818872975b"
|
"sha256": "5a9d5d2cc337fcee5590ea957e35ddeea5a71596aeb59410bf1420065b9b5b00",
|
||||||
|
"tier": "full"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "host-metrics",
|
"id": "host-metrics",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.3/host-metrics-1.0.3.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.2.0/host-metrics-1.2.0.tmxplug",
|
||||||
"sha256": "8f6022568349fc4318d509781bdb9d5b40973c010a9e27f720eed6baf68b881e",
|
"sha256": "8522cd23b558c3209821cee6d177daeb80f439b8ff652c577c5e4955315cc7a3",
|
||||||
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
"tier": "standard"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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.0/import-export-1.0.0.tmxplug",
|
||||||
|
"sha256": "6670b6c147e81eed0ddfd249f9a7c9564f1ab50e45f2771161d2e30c47f6fd58",
|
||||||
|
"tier": "standard"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kubernetes",
|
||||||
|
"source": "tmxplug",
|
||||||
|
"url": "https://github.com/Termix-SSH/Plugin-Kubernetes/releases/download/v1.0.1/kubernetes-1.0.1.tmxplug",
|
||||||
|
"sha256": "31d6b200579d326d07244712713ae291ff473270d44b478a884d07c863a394f0",
|
||||||
|
"tier": "full"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "ldap",
|
"id": "ldap",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.2/ldap-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.2/ldap-1.0.2.tmxplug",
|
||||||
"sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03"
|
"sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03",
|
||||||
|
"tier": "full"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "login-security",
|
||||||
|
"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",
|
"id": "network-topology",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.2/network-topology-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.2/network-topology-1.0.2.tmxplug",
|
||||||
"sha256": "8d32340a0d0f9674b872da5d3d53e90f529b80dfe0d73ab294eed00e565fbb56"
|
"sha256": "8d32340a0d0f9674b872da5d3d53e90f529b80dfe0d73ab294eed00e565fbb56",
|
||||||
|
"tier": "full"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "opkssh",
|
"id": "opkssh",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.2/opkssh-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.2/opkssh-1.0.2.tmxplug",
|
||||||
"sha256": "4bb8f1ac5d39bd18ac3d0fe82ddbf2f26ab4c01bd75e6f7a073d65c2e9aef219"
|
"sha256": "4bb8f1ac5d39bd18ac3d0fe82ddbf2f26ab4c01bd75e6f7a073d65c2e9aef219",
|
||||||
|
"tier": "full"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "proxmox",
|
"id": "proxmox",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.2/proxmox-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.1.0/proxmox-1.1.0.tmxplug",
|
||||||
"sha256": "b92be2644b0c8121732103b922069a6cb3cf00caad4e6549f7a8cb471bda5e4e"
|
"sha256": "8937d239ad40033d69fba59085fe8896ee25c61951c21f66ff98531fd4c63cbf",
|
||||||
|
"tier": "full"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "remote-desktop",
|
"id": "remote-desktop",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.1.0/remote-desktop-1.1.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.2.1/remote-desktop-1.2.1.tmxplug",
|
||||||
"sha256": "6f74f1d26f91a43b2d2203417d744211f366c335d5fff7bed34bfce33874dd01",
|
"sha256": "ad244f31016f0ea5df5c303bf6f48af35c6c8e52b12098d381c8296626b73de7",
|
||||||
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "reverse-proxy",
|
||||||
|
"source": "tmxplug",
|
||||||
|
"url": "https://github.com/Termix-SSH/Plugin-Reverse-Proxy/releases/download/v1.1.0/reverse-proxy-1.1.0.tmxplug",
|
||||||
|
"sha256": "c8a9851da7ca6291c1f47cf6ab235da5057455a86b4ed0dc1c1a3026afb30aa7",
|
||||||
|
"tier": "full"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "samba",
|
||||||
|
"source": "tmxplug",
|
||||||
|
"url": "https://github.com/Termix-SSH/Plugin-Samba/releases/download/v1.0.0/samba-1.0.0.tmxplug",
|
||||||
|
"sha256": "6e131ccdca998a9d0802becdd439a88305b64fcd7f07335bec9c7ed6791c0f53",
|
||||||
|
"tier": "full"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "secret-sources",
|
"id": "secret-sources",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.2/secret-sources-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.2/secret-sources-1.0.2.tmxplug",
|
||||||
"sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134"
|
"sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134",
|
||||||
|
"tier": "full"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "serial",
|
"id": "serial",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.2/serial-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.2/serial-1.0.2.tmxplug",
|
||||||
"sha256": "6ede7e8726a7312429473f34a46f93ba6657c5b7ebc12bed18f80ec62c211715",
|
"sha256": "6ede7e8726a7312429473f34a46f93ba6657c5b7ebc12bed18f80ec62c211715",
|
||||||
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": "desktop",
|
"recommended": "desktop",
|
||||||
"consent": false
|
"consent": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "service-manager",
|
||||||
|
"source": "tmxplug",
|
||||||
|
"url": "https://github.com/Termix-SSH/Plugin-Service-Manager/releases/download/v1.1.0/service-manager-1.1.0.tmxplug",
|
||||||
|
"sha256": "82b1c70abd97ed535f217535c667fe6c48e5e01e2bf8037a89b58b80d35206bf",
|
||||||
|
"tier": "full"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "session-recording",
|
"id": "session-recording",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.2/session-recording-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.1.0/session-recording-1.1.0.tmxplug",
|
||||||
"sha256": "55ace6897710ce9a63f24e3d736a9e533cac557be0f3d98974956c94cf417d1c"
|
"sha256": "31cd78d67e061242f8776849a2fae5f84ab48c7c765d487b32c5e1693c54b46a",
|
||||||
|
"tier": "full"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "session-sharing",
|
"id": "session-sharing",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.2/session-sharing-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.1.0/session-sharing-1.1.0.tmxplug",
|
||||||
"sha256": "6408e7949a9549ac5ec94b35205729099138e730df1598d50f4bb618d8d90804"
|
"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",
|
"id": "snippets",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.2/snippets-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.1.0/snippets-1.1.0.tmxplug",
|
||||||
"sha256": "cc1f5d74cf97f9a9c8f800fccf481c8a000e6fa516bc260ce24a7249d4f4c8e5",
|
"sha256": "e2d7d1ebe5bdf2986ffb4961ac6f6d32b392b02c30a64a333a7eeb2ce6a46bb3",
|
||||||
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
@@ -145,8 +271,9 @@
|
|||||||
{
|
{
|
||||||
"id": "ssh-terminal",
|
"id": "ssh-terminal",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.3/ssh-terminal-1.0.3.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.1.1/ssh-terminal-1.1.1.tmxplug",
|
||||||
"sha256": "bc288bd843e37b64bcda7462d175039147b75dc6de8866769e4a938036a9d023",
|
"sha256": "5cfb381a2de10b7a6fab108d79645bb1e6e7316fb5e4150c4529a3e76a2193d8",
|
||||||
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
@@ -155,8 +282,9 @@
|
|||||||
{
|
{
|
||||||
"id": "sso",
|
"id": "sso",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.3/sso-1.0.3.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.4/sso-1.0.4.tmxplug",
|
||||||
"sha256": "87b03c0789d981dbc806162d73dfcc2c624150375277efd2ae40ab9d7657d48e",
|
"sha256": "2aad33177085fbc92fe06cd007facffbfc8f42f9c9fa688ad5f37d68f62093d9",
|
||||||
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": false,
|
"recommended": false,
|
||||||
"consent": false,
|
"consent": false,
|
||||||
@@ -167,19 +295,29 @@
|
|||||||
"id": "step-ca",
|
"id": "step-ca",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.2/step-ca-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.2/step-ca-1.0.2.tmxplug",
|
||||||
"sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a"
|
"sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a",
|
||||||
|
"tier": "full"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sync",
|
||||||
|
"source": "tmxplug",
|
||||||
|
"url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.0.1/sync-1.0.1.tmxplug",
|
||||||
|
"sha256": "66a301fd04fb8d2a3ece18dfb230069cf78e1138ee78941dc511dfbd8ee4f91e",
|
||||||
|
"tier": "standard"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "tailscale",
|
"id": "tailscale",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.2/tailscale-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.1.0/tailscale-1.1.0.tmxplug",
|
||||||
"sha256": "5c7b00d6b6b0186d833302e39a9d31b92ea92d4542e9e2dab550b95837a59d97"
|
"sha256": "8784a77a1fc1b0011d8243d9520574300df31bc40c31f86339868f4c9b76d1bf",
|
||||||
|
"tier": "full"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "telemetry",
|
"id": "telemetry",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.2/telemetry-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.2/telemetry-1.0.2.tmxplug",
|
||||||
"sha256": "5ffdc58b05cd8b37fd0a207f99d2d3a256164130c950032501c499f869edb9ec",
|
"sha256": "5ffdc58b05cd8b37fd0a207f99d2d3a256164130c950032501c499f869edb9ec",
|
||||||
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": true
|
"consent": true
|
||||||
@@ -189,19 +327,22 @@
|
|||||||
"id": "termix-identity",
|
"id": "termix-identity",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.2/termix-identity-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.2/termix-identity-1.0.2.tmxplug",
|
||||||
"sha256": "ac6c90a48b4f1e9cff6773a5fd267b2bf7319d2fc7e2aa2cd4004cec82f0f927"
|
"sha256": "ac6c90a48b4f1e9cff6773a5fd267b2bf7319d2fc7e2aa2cd4004cec82f0f927",
|
||||||
|
"tier": "full"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "tmux-monitor",
|
"id": "tmux-monitor",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.3/tmux-monitor-1.0.3.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.1.0/tmux-monitor-1.1.0.tmxplug",
|
||||||
"sha256": "f152d64f433d3f1de7c3fdf172e752ea7e333909f0fdda77336a6b8dddb9f242"
|
"sha256": "3cdc54a34e1ca30c4e3c217bbc612e40ef3cd3e4d5de704ea92d914b32d98006",
|
||||||
|
"tier": "full"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "totp",
|
"id": "totp",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.2/totp-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.2/totp-1.0.2.tmxplug",
|
||||||
"sha256": "94a0772660fa8e4dfd9afc15b68d5bc9fe02fe28a1acfd35e65a57c304d460c0",
|
"sha256": "94a0772660fa8e4dfd9afc15b68d5bc9fe02fe28a1acfd35e65a57c304d460c0",
|
||||||
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
@@ -210,8 +351,9 @@
|
|||||||
{
|
{
|
||||||
"id": "tunnels",
|
"id": "tunnels",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.3/tunnels-1.0.3.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.2.1/tunnels-1.2.1.tmxplug",
|
||||||
"sha256": "d0de9e59ccc9e1a0f2817dd1ec9951021f2d7e89304894bb1e8cefd65aebff0b",
|
"sha256": "2871a8d31854e1a31416c4afe5839099a4c489e34d9d31573659f73b2515f159",
|
||||||
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
@@ -221,31 +363,36 @@
|
|||||||
"id": "vault",
|
"id": "vault",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.2/vault-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.2/vault-1.0.2.tmxplug",
|
||||||
"sha256": "48ef402d030912a836489edf0fdfcaf7929cbd67c21f16a2079cb4925134cc28"
|
"sha256": "48ef402d030912a836489edf0fdfcaf7929cbd67c21f16a2079cb4925134cc28",
|
||||||
|
"tier": "full"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "wake-on-lan",
|
"id": "wake-on-lan",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.2/wake-on-lan-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.1.0/wake-on-lan-1.1.0.tmxplug",
|
||||||
"sha256": "3e9cf9a133b4dd96ec5f2b9fbf2d6a3ff7e08f8dfbf1e86b8d45de0c2f024f42"
|
"sha256": "1afb708c12631ededec40938aed3ab587bc9a519215fa5e93350b1f0a843d2a8",
|
||||||
|
"tier": "full"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "warpgate",
|
"id": "warpgate",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.2/warpgate-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.2/warpgate-1.0.2.tmxplug",
|
||||||
"sha256": "5c2c1c9aef40dbbf98424d02303f2ee77e6e869334247881e8aa0fcd8c415b47"
|
"sha256": "5c2c1c9aef40dbbf98424d02303f2ee77e6e869334247881e8aa0fcd8c415b47",
|
||||||
|
"tier": "full"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "web-endpoint",
|
"id": "web-endpoint",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.2/web-endpoint-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.1.0/web-endpoint-1.1.0.tmxplug",
|
||||||
"sha256": "0326edb1052d1debd083004c2dd1711d21a902cfacb55ce74d1a8856a5122477"
|
"sha256": "79e691abc456b17c8ce651017b47097a7ff71e94267e668341548a05b911a92c",
|
||||||
|
"tier": "full"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "webauthn",
|
"id": "webauthn",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.2/webauthn-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.1.0/webauthn-1.1.0.tmxplug",
|
||||||
"sha256": "8269f6e407897332648c8526b65383c4db071ea782bf1e58aa7c7c00627f3f4b",
|
"sha256": "83a61a530db0273f9fef2080d6aa027a82ef8374fb4e0f158a8559a2770299de",
|
||||||
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
@@ -254,8 +401,9 @@
|
|||||||
{
|
{
|
||||||
"id": "workspaces",
|
"id": "workspaces",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.2/workspaces-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.1.0/workspaces-1.1.0.tmxplug",
|
||||||
"sha256": "97519a01fbb2eb2eecf21d7b9ab9028c740457bcff9fc6b075fb6facbc5ccb6b",
|
"sha256": "7189d767c3f7cddfb59366f6cceeca27a0b29e41da1dcc89d57a25317a0fe4bf",
|
||||||
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `user_preferences` ADD `layout_id` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user_preferences` ADD `theme_id` text;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- recent_activity moved to the homepage plugin, whose migration renames it to
|
||||||
|
-- p_homepage_recent_activity. Dropping it here would lose every entry, so this
|
||||||
|
-- only stops drizzle tracking it. SELECT 1 because MySQL rejects an empty query.
|
||||||
|
SELECT 1;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `sessions` ADD `restriction` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `sessions` ADD `idle_timeout` int;
|
||||||
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
@@ -512,6 +512,27 @@
|
|||||||
"when": 1791935283211,
|
"when": 1791935283211,
|
||||||
"tag": "0072_unique_username",
|
"tag": "0072_unique_username",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 73,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1792357129912,
|
||||||
|
"tag": "0073_user_look_preferences",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 74,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1792374489419,
|
||||||
|
"tag": "0074_mature_puff_adder",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 75,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1792430772041,
|
||||||
|
"tag": "0075_session_policy",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "user_preferences" ADD COLUMN "layout_id" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_preferences" ADD COLUMN "theme_id" text;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- recent_activity moved to the homepage plugin, whose migration renames it to
|
||||||
|
-- p_homepage_recent_activity. Dropping it here would lose every entry, so this
|
||||||
|
-- only stops drizzle tracking it. SELECT 1 because MySQL rejects an empty query.
|
||||||
|
SELECT 1;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "sessions" ADD COLUMN "restriction" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "sessions" ADD COLUMN "idle_timeout" integer;
|
||||||
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
@@ -512,6 +512,27 @@
|
|||||||
"when": 1791935282340,
|
"when": 1791935282340,
|
||||||
"tag": "0072_unique_username",
|
"tag": "0072_unique_username",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 73,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1792357128746,
|
||||||
|
"tag": "0073_user_look_preferences",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 74,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1792374488837,
|
||||||
|
"tag": "0074_bouncy_sersi",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 75,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1792430770990,
|
||||||
|
"tag": "0075_session_policy",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `user_preferences` ADD `layout_id` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user_preferences` ADD `theme_id` text;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- recent_activity moved to the homepage plugin, whose migration renames it to
|
||||||
|
-- p_homepage_recent_activity. Dropping it here would lose every entry, so this
|
||||||
|
-- only stops drizzle tracking it. SELECT 1 because MySQL rejects an empty query.
|
||||||
|
SELECT 1;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `sessions` ADD `restriction` text(32);--> statement-breakpoint
|
||||||
|
ALTER TABLE `sessions` ADD `idle_timeout` integer;
|
||||||
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
@@ -477,6 +477,27 @@
|
|||||||
"when": 1791935281343,
|
"when": 1791935281343,
|
||||||
"tag": "0067_unique_username",
|
"tag": "0067_unique_username",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 68,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1792357127581,
|
||||||
|
"tag": "0068_user_look_preferences",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 69,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1792374488196,
|
||||||
|
"tag": "0069_normal_ozymandias",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 70,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1792430769899,
|
||||||
|
"tag": "0070_session_policy",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -128,6 +128,10 @@
|
|||||||
"category": "public.app-category.developer-tools",
|
"category": "public.app-category.developer-tools",
|
||||||
"hardenedRuntime": true,
|
"hardenedRuntime": true,
|
||||||
"gatekeeperAssess": false,
|
"gatekeeperAssess": false,
|
||||||
|
"extendInfo": {
|
||||||
|
"NSLocalNetworkUsageDescription": "Termix connects to servers and hosts on your local network.",
|
||||||
|
"NSBonjourServices": ["_http._tcp", "_https._tcp", "_ssh._tcp"]
|
||||||
|
},
|
||||||
"entitlements": "packaging/build/entitlements.mac.plist",
|
"entitlements": "packaging/build/entitlements.mac.plist",
|
||||||
"entitlementsInherit": "packaging/build/entitlements.mac.inherit.plist",
|
"entitlementsInherit": "packaging/build/entitlements.mac.inherit.plist",
|
||||||
"type": "distribution",
|
"type": "distribution",
|
||||||
@@ -153,6 +157,8 @@
|
|||||||
"artifactName": "termix_macos_${arch}_mas.${ext}",
|
"artifactName": "termix_macos_${arch}_mas.${ext}",
|
||||||
"extendInfo": {
|
"extendInfo": {
|
||||||
"ITSAppUsesNonExemptEncryption": false,
|
"ITSAppUsesNonExemptEncryption": false,
|
||||||
|
"NSLocalNetworkUsageDescription": "Termix connects to servers and hosts on your local network.",
|
||||||
|
"NSBonjourServices": ["_http._tcp", "_https._tcp", "_ssh._tcp"],
|
||||||
"NSAppleEventsUsageDescription": "Termix needs access to control other applications for terminal operations."
|
"NSAppleEventsUsageDescription": "Termix needs access to control other applications for terminal operations."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
const { randomUUID } = require("node:crypto");
|
|
||||||
|
|
||||||
function createC2SAuthBridge(ipcMain, getWindow) {
|
|
||||||
const pending = new Map();
|
|
||||||
ipcMain.handle("answer-c2s-auth", (event, id, answer) => {
|
|
||||||
const item = pending.get(id);
|
|
||||||
if (!item || item.sender !== event.sender) return false;
|
|
||||||
if (answer !== null && (typeof answer !== "string" || answer.length > 8192))
|
|
||||||
return false;
|
|
||||||
item.finish(answer);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
return function attach(ws, tunnelName) {
|
|
||||||
const active = new Set();
|
|
||||||
const close = () => {
|
|
||||||
for (const id of active) pending.get(id)?.finish(null);
|
|
||||||
ws.off("message", onMessage);
|
|
||||||
ws.off("close", close);
|
|
||||||
ws.off("error", close);
|
|
||||||
};
|
|
||||||
const onMessage = (raw, binary) => {
|
|
||||||
if (binary) return;
|
|
||||||
let message;
|
|
||||||
try {
|
|
||||||
message = JSON.parse(raw.toString());
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (message.type !== "auth-prompt") return;
|
|
||||||
const window = getWindow();
|
|
||||||
if (
|
|
||||||
!window ||
|
|
||||||
window.isDestroyed() ||
|
|
||||||
active.size ||
|
|
||||||
typeof message.requestId !== "string" ||
|
|
||||||
!["totp", "input", "browser"].includes(message.request?.kind) // plugin-id-ok: SSH prompt kind, not the plugin
|
|
||||||
) {
|
|
||||||
ws.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sender = window.webContents;
|
|
||||||
const id = randomUUID();
|
|
||||||
const finish = (answer) => {
|
|
||||||
if (!pending.delete(id)) return;
|
|
||||||
active.delete(id);
|
|
||||||
clearTimeout(timer);
|
|
||||||
sender.off("destroyed", cancelled);
|
|
||||||
sender.off("did-start-loading", cancelled);
|
|
||||||
if (!sender.isDestroyed())
|
|
||||||
sender.send("c2s-auth-prompt", { id, closed: true });
|
|
||||||
if (ws.readyState === 1)
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "auth-response",
|
|
||||||
requestId: message.requestId,
|
|
||||||
answer,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (answer === null) ws.close();
|
|
||||||
};
|
|
||||||
const cancelled = () => finish(null);
|
|
||||||
const timer = setTimeout(cancelled, 60_000);
|
|
||||||
active.add(id);
|
|
||||||
pending.set(id, { sender, finish });
|
|
||||||
sender.once("destroyed", cancelled);
|
|
||||||
sender.once("did-start-loading", cancelled);
|
|
||||||
sender.send("c2s-auth-prompt", {
|
|
||||||
id,
|
|
||||||
tunnelName,
|
|
||||||
request: message.request,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
ws.on("message", onMessage);
|
|
||||||
ws.once("close", close);
|
|
||||||
ws.once("error", close);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
module.exports = { createC2SAuthBridge };
|
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
const { isolatedUrl } = require("./isolated-window.cjs");
|
||||||
|
|
||||||
|
function cleanBounds(bounds) {
|
||||||
|
const value = (n) => Math.max(0, Math.round(Number(n) || 0));
|
||||||
|
return {
|
||||||
|
x: value(bounds?.x),
|
||||||
|
y: value(bounds?.y),
|
||||||
|
width: value(bounds?.width),
|
||||||
|
height: value(bounds?.height),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native web views drawn inside the main window, for pages that do not work
|
||||||
|
* in a sandboxed iframe. Each one is a WebContentsView in its own
|
||||||
|
* non-persistent session, with the same limits as an isolated window: no
|
||||||
|
* permissions, no downloads, http(s) and ws(s) only. The renderer owns the
|
||||||
|
* placement and sends the bounds of the element the view should cover.
|
||||||
|
*/
|
||||||
|
function createEmbeddedViews({ WebContentsView, session, getMainWindow }) {
|
||||||
|
const views = new Map();
|
||||||
|
|
||||||
|
function destroy(id) {
|
||||||
|
const entry = views.get(id);
|
||||||
|
if (!entry) return;
|
||||||
|
views.delete(id);
|
||||||
|
const main = getMainWindow();
|
||||||
|
if (main && !main.isDestroyed()) {
|
||||||
|
try {
|
||||||
|
main.contentView.removeChildView(entry.view);
|
||||||
|
} catch {
|
||||||
|
/* already detached */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!entry.view.webContents.isDestroyed()) entry.view.webContents.close();
|
||||||
|
void Promise.allSettled([
|
||||||
|
entry.session.clearStorageData(),
|
||||||
|
entry.session.clearCache(),
|
||||||
|
entry.session.closeAllConnections(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyAll() {
|
||||||
|
for (const id of [...views.keys()]) destroy(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(options = {}) {
|
||||||
|
const main = getMainWindow();
|
||||||
|
if (!main) throw new Error("No window to attach a view to");
|
||||||
|
const id = String(options.id || "");
|
||||||
|
if (!id) throw new Error("A view needs an id");
|
||||||
|
const url = isolatedUrl(options.url);
|
||||||
|
const existing = views.get(id);
|
||||||
|
if (existing) {
|
||||||
|
if (
|
||||||
|
existing.url === url.href &&
|
||||||
|
existing.ignoreCert === !!options.ignoreCert
|
||||||
|
) {
|
||||||
|
setBounds(id, options.bounds);
|
||||||
|
setVisible(id, options.visible !== false);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
destroy(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isolated = session.fromPartition(
|
||||||
|
`embedded-view-${id}-${Date.now()}`,
|
||||||
|
{
|
||||||
|
cache: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
isolated.setPermissionRequestHandler((_c, _p, callback) => callback(false));
|
||||||
|
isolated.setPermissionCheckHandler(() => false);
|
||||||
|
isolated.on("will-download", (event) => event.preventDefault());
|
||||||
|
isolated.webRequest.onBeforeRequest((details, callback) => {
|
||||||
|
const protocol = new URL(details.url).protocol;
|
||||||
|
callback({
|
||||||
|
cancel: !["http:", "https:", "ws:", "wss:"].includes(protocol),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const view = new WebContentsView({
|
||||||
|
webPreferences: {
|
||||||
|
session: isolated,
|
||||||
|
sandbox: true,
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
webviewTag: false,
|
||||||
|
webSecurity: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const canNavigate = (target) => {
|
||||||
|
try {
|
||||||
|
return target === "about:blank" || !!isolatedUrl(target);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const name of ["will-navigate", "will-redirect"]) {
|
||||||
|
view.webContents.on(name, (event, target) => {
|
||||||
|
if (!canNavigate(target)) event.preventDefault();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
view.webContents.setWindowOpenHandler(({ url: target }) =>
|
||||||
|
canNavigate(target)
|
||||||
|
? {
|
||||||
|
action: "allow",
|
||||||
|
overrideBrowserWindowOptions: {
|
||||||
|
webPreferences: { session: isolated, sandbox: true },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { action: "deny" },
|
||||||
|
);
|
||||||
|
|
||||||
|
views.set(id, {
|
||||||
|
view,
|
||||||
|
session: isolated,
|
||||||
|
url: url.href,
|
||||||
|
origin: url.origin,
|
||||||
|
ignoreCert: !!options.ignoreCert,
|
||||||
|
});
|
||||||
|
main.contentView.addChildView(view);
|
||||||
|
setBounds(id, options.bounds);
|
||||||
|
setVisible(id, options.visible !== false);
|
||||||
|
view.webContents.loadURL(url.href).catch(() => {});
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBounds(id, bounds) {
|
||||||
|
const entry = views.get(id);
|
||||||
|
if (entry) entry.view.setBounds(cleanBounds(bounds));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVisible(id, visible) {
|
||||||
|
const entry = views.get(id);
|
||||||
|
if (entry) entry.view.setVisible(!!visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCertificateError(event, contents, url, callback) {
|
||||||
|
for (const entry of views.values()) {
|
||||||
|
if (entry.view.webContents !== contents) continue;
|
||||||
|
event.preventDefault();
|
||||||
|
let allowed = false;
|
||||||
|
try {
|
||||||
|
allowed = entry.ignoreCert && new URL(url).origin === entry.origin;
|
||||||
|
} catch {
|
||||||
|
/* malformed URL is refused */
|
||||||
|
}
|
||||||
|
callback(allowed);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
open,
|
||||||
|
setBounds,
|
||||||
|
setVisible,
|
||||||
|
destroy,
|
||||||
|
destroyAll,
|
||||||
|
handleCertificateError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createEmbeddedViews, cleanBounds };
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
const path = require("path");
|
|
||||||
|
|
||||||
// Saved by the file manager after the user picks an editor in a file dialog.
|
|
||||||
const EXTERNAL_EDITOR_SETTING = "fileManager.externalEditorPath";
|
|
||||||
|
|
||||||
// Extensions the OS would run rather than open in an editor. With no editor
|
|
||||||
// configured the temp copy goes to shell.openPath, and its name comes from
|
|
||||||
// the remote server.
|
|
||||||
const LAUNCHABLE_EXTENSIONS = new Set([
|
|
||||||
// Windows
|
|
||||||
"application",
|
|
||||||
"appref-ms",
|
|
||||||
"bat",
|
|
||||||
"chm",
|
|
||||||
"cmd",
|
|
||||||
"com",
|
|
||||||
"cpl",
|
|
||||||
"exe",
|
|
||||||
"gadget",
|
|
||||||
"hta",
|
|
||||||
"inf",
|
|
||||||
"jar",
|
|
||||||
"js",
|
|
||||||
"jse",
|
|
||||||
"library-ms",
|
|
||||||
"lnk",
|
|
||||||
"msc",
|
|
||||||
"msi",
|
|
||||||
"msp",
|
|
||||||
"mst",
|
|
||||||
"pif",
|
|
||||||
"ps1",
|
|
||||||
"psd1",
|
|
||||||
"psm1",
|
|
||||||
"py",
|
|
||||||
"pyw",
|
|
||||||
"reg",
|
|
||||||
"scf",
|
|
||||||
"scr",
|
|
||||||
"search-ms",
|
|
||||||
"settingcontent-ms",
|
|
||||||
"url",
|
|
||||||
"vbe",
|
|
||||||
"vbs",
|
|
||||||
"ws",
|
|
||||||
"wsf",
|
|
||||||
"wsh",
|
|
||||||
"xll",
|
|
||||||
// macOS
|
|
||||||
"app",
|
|
||||||
"command",
|
|
||||||
"pkg",
|
|
||||||
"terminal",
|
|
||||||
"tool",
|
|
||||||
"workflow",
|
|
||||||
// Linux
|
|
||||||
"appimage",
|
|
||||||
"desktop",
|
|
||||||
"run",
|
|
||||||
"sh",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function isLaunchableFileName(fileName) {
|
|
||||||
// Windows drops trailing dots and spaces, so "a.exe. " is saved as a.exe.
|
|
||||||
const name = path.basename(String(fileName || "")).replace(/[. ]+$/, "");
|
|
||||||
const extension = path.extname(name).slice(1).toLowerCase();
|
|
||||||
return LAUNCHABLE_EXTENSIONS.has(extension);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { EXTERNAL_EDITOR_SETTING, isLaunchableFileName };
|
|
||||||
@@ -34,6 +34,8 @@ function setLinkedServer(config) {
|
|||||||
basicAuth:
|
basicAuth:
|
||||||
config.basicAuth && config.basicAuth.username ? config.basicAuth : null,
|
config.basicAuth && config.basicAuth.username ? config.basicAuth : null,
|
||||||
allowInvalidCertificate: !!config.allowInvalidCertificate,
|
allowInvalidCertificate: !!config.allowInvalidCertificate,
|
||||||
|
clientCert:
|
||||||
|
config.clientCert && config.clientCert.pfx ? config.clientCert : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +47,16 @@ function isLinkedOrigin(url) {
|
|||||||
return !!linked && getOrigin(url) === linked.origin;
|
return !!linked && getOrigin(url) === linked.origin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The PFX of the link's client certificate, for TLS this process makes itself
|
||||||
|
// to the linked origin. Null for any other origin.
|
||||||
|
function clientCertOptionsFor(url) {
|
||||||
|
if (!isLinkedOrigin(url) || !linked.clientCert) return {};
|
||||||
|
return {
|
||||||
|
pfx: Buffer.from(linked.clientCert.pfx, "base64"),
|
||||||
|
passphrase: linked.clientCert.passphrase || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function basicAuthValue(basicAuth) {
|
function basicAuthValue(basicAuth) {
|
||||||
return `Basic ${Buffer.from(
|
return `Basic ${Buffer.from(
|
||||||
`${basicAuth.username}:${basicAuth.password || ""}`,
|
`${basicAuth.username}:${basicAuth.password || ""}`,
|
||||||
@@ -99,6 +111,7 @@ module.exports = {
|
|||||||
setLinkedServer,
|
setLinkedServer,
|
||||||
getLinkedServer,
|
getLinkedServer,
|
||||||
isLinkedOrigin,
|
isLinkedOrigin,
|
||||||
|
clientCertOptionsFor,
|
||||||
applyLinkHeaders,
|
applyLinkHeaders,
|
||||||
linkedRequestHeaders,
|
linkedRequestHeaders,
|
||||||
answerLogin,
|
answerLogin,
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,71 +0,0 @@
|
|||||||
const fs = require("node:fs");
|
|
||||||
const path = require("node:path");
|
|
||||||
|
|
||||||
function isUsableShell(file, exists) {
|
|
||||||
return !!file && (!path.isAbsolute(file) || exists(file));
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveLocalShell(
|
|
||||||
platform,
|
|
||||||
requestedShell,
|
|
||||||
env = process.env,
|
|
||||||
exists = fs.existsSync,
|
|
||||||
) {
|
|
||||||
if (platform === "win32") {
|
|
||||||
if (requestedShell === "wsl") {
|
|
||||||
return { file: "wsl.exe", args: [] };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
file: env.TERMIX_LOCAL_SHELL || "powershell.exe",
|
|
||||||
args: ["-NoLogo"],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// A SHELL left pointing at an uninstalled shell makes the spawn fail.
|
|
||||||
const fallbacks =
|
|
||||||
platform === "darwin"
|
|
||||||
? ["/bin/zsh", "/bin/bash", "/bin/sh"]
|
|
||||||
: ["/bin/bash", "/bin/sh"];
|
|
||||||
const file =
|
|
||||||
[env.TERMIX_LOCAL_SHELL, env.SHELL, ...fallbacks].find((candidate) =>
|
|
||||||
isUsableShell(candidate, exists),
|
|
||||||
) || fallbacks[0];
|
|
||||||
return { file, args: ["-l"] };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* node-pty starts every shell through its spawn-helper binary, and a packaged
|
|
||||||
* copy that lost its execute bit fails with "posix_spawnp failed". Returns
|
|
||||||
* how many helpers were fixed.
|
|
||||||
*/
|
|
||||||
function repairSpawnHelpers(ptyDir, fsImpl = fs) {
|
|
||||||
// A universal macOS build ships app-arm64.asar and app-x64.asar.
|
|
||||||
const dir = ptyDir.replace(
|
|
||||||
/\b(app(?:-[a-z0-9]+)?)\.asar(?!\.unpacked)/,
|
|
||||||
"$1.asar.unpacked",
|
|
||||||
);
|
|
||||||
const candidates = [
|
|
||||||
path.join(dir, "build", "Release", "spawn-helper"),
|
|
||||||
path.join(
|
|
||||||
dir,
|
|
||||||
"prebuilds",
|
|
||||||
`${process.platform}-${process.arch}`,
|
|
||||||
"spawn-helper",
|
|
||||||
),
|
|
||||||
];
|
|
||||||
let fixed = 0;
|
|
||||||
for (const helper of candidates) {
|
|
||||||
try {
|
|
||||||
const mode = fsImpl.statSync(helper).mode;
|
|
||||||
if ((mode & 0o111) === 0) {
|
|
||||||
fsImpl.chmodSync(helper, mode | 0o755);
|
|
||||||
fixed += 1;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Missing or read-only; nothing to fix here.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fixed;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { resolveLocalShell, repairSpawnHelpers };
|
|
||||||
+161
-1953
File diff suppressed because it is too large.
Load diff
@@ -1,85 +0,0 @@
|
|||||||
const fs = require("fs");
|
|
||||||
const os = require("os");
|
|
||||||
const path = require("path");
|
|
||||||
const { spawn } = require("child_process");
|
|
||||||
|
|
||||||
function singleLine(value, maxLength = 255) {
|
|
||||||
return String(value ?? "")
|
|
||||||
.replace(/[\r\n\0]/g, "")
|
|
||||||
.trim()
|
|
||||||
.slice(0, maxLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateNativeRdpOptions(options) {
|
|
||||||
const host = singleLine(options?.host);
|
|
||||||
const port = Number(options?.port ?? 3389);
|
|
||||||
if (!host || host.length > 253 || /[\\/]/.test(host)) {
|
|
||||||
throw new Error("Invalid RDP host");
|
|
||||||
}
|
|
||||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
||||||
throw new Error("Invalid RDP port");
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
username: singleLine(options?.username),
|
|
||||||
domain: singleLine(options?.domain),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRdpFile(options) {
|
|
||||||
const { host, port, username, domain } = validateNativeRdpOptions(options);
|
|
||||||
const address = host.includes(":") ? `[${host}]:${port}` : `${host}:${port}`;
|
|
||||||
const qualifiedUsername = username
|
|
||||||
? domain
|
|
||||||
? `${domain}\\${username}`
|
|
||||||
: username
|
|
||||||
: "";
|
|
||||||
return [
|
|
||||||
`full address:s:${address}`,
|
|
||||||
"prompt for credentials:i:1",
|
|
||||||
"administrative session:i:0",
|
|
||||||
...(qualifiedUsername ? [`username:s:${qualifiedUsername}`] : []),
|
|
||||||
"",
|
|
||||||
].join("\r\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function launchNativeRdp(options, platform = process.platform) {
|
|
||||||
if (platform !== "win32") {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: "Windows Remote Desktop is only available on Windows",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const rdpContent = buildRdpFile(options);
|
|
||||||
const tempDir = await fs.promises.mkdtemp(
|
|
||||||
path.join(os.tmpdir(), "termix-rdp-"),
|
|
||||||
);
|
|
||||||
const rdpPath = path.join(tempDir, "connection.rdp");
|
|
||||||
await fs.promises.writeFile(rdpPath, rdpContent, {
|
|
||||||
encoding: "utf8",
|
|
||||||
mode: 0o600,
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const child = spawn("mstsc.exe", [rdpPath], {
|
|
||||||
detached: true,
|
|
||||||
windowsHide: true,
|
|
||||||
stdio: "ignore",
|
|
||||||
});
|
|
||||||
const cleanup = () =>
|
|
||||||
fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
|
||||||
child.once("error", (error) => {
|
|
||||||
cleanup();
|
|
||||||
resolve({ success: false, error: error.message });
|
|
||||||
});
|
|
||||||
child.once("spawn", () => {
|
|
||||||
child.unref();
|
|
||||||
setTimeout(cleanup, 30_000).unref();
|
|
||||||
resolve({ success: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { buildRdpFile, launchNativeRdp, validateNativeRdpOptions };
|
|
||||||
+21
-109
@@ -32,6 +32,19 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
webFrame.setZoomFactor(Math.min(2, Math.max(0.5, value)));
|
webFrame.setZoomFactor(Math.min(2, Math.max(0.5, value)));
|
||||||
},
|
},
|
||||||
getPlatform: () => ipcRenderer.invoke("get-platform"),
|
getPlatform: () => ipcRenderer.invoke("get-platform"),
|
||||||
|
|
||||||
|
// Whether the app-drawn title bar shows in this window, and when it changes
|
||||||
|
// (full screen hides it).
|
||||||
|
getTitleBarVisible: () => ipcRenderer.invoke("get-title-bar-visible"),
|
||||||
|
onTitleBarVisibleChange: (callback) => {
|
||||||
|
const listener = (_event, visible) => callback(visible);
|
||||||
|
ipcRenderer.on("title-bar-visible-changed", listener);
|
||||||
|
return () =>
|
||||||
|
ipcRenderer.removeListener("title-bar-visible-changed", listener);
|
||||||
|
},
|
||||||
|
// Keeps the window controls the system draws in the colors of the theme.
|
||||||
|
setTitleBarOverlay: (colors) =>
|
||||||
|
ipcRenderer.invoke("set-title-bar-overlay", colors),
|
||||||
getEmbeddedServerStatus: () =>
|
getEmbeddedServerStatus: () =>
|
||||||
ipcRenderer.invoke("get-embedded-server-status"),
|
ipcRenderer.invoke("get-embedded-server-status"),
|
||||||
|
|
||||||
@@ -58,36 +71,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
|
|
||||||
getSetting: (key) => ipcRenderer.invoke("get-setting", key),
|
getSetting: (key) => ipcRenderer.invoke("get-setting", key),
|
||||||
setSetting: (key, value) => ipcRenderer.invoke("set-setting", key, value),
|
setSetting: (key, value) => ipcRenderer.invoke("set-setting", key, value),
|
||||||
answerC2SAuth: (id, answer) =>
|
|
||||||
ipcRenderer.invoke("answer-c2s-auth", id, answer),
|
|
||||||
onC2SAuthPrompt: (callback) => {
|
|
||||||
const listener = (_event, prompt) => callback(prompt);
|
|
||||||
ipcRenderer.on("c2s-auth-prompt", listener);
|
|
||||||
return () => ipcRenderer.removeListener("c2s-auth-prompt", listener);
|
|
||||||
},
|
|
||||||
getC2STunnelConfig: () => ipcRenderer.invoke("get-c2s-tunnel-config"),
|
|
||||||
saveC2STunnelConfig: (config) =>
|
|
||||||
ipcRenderer.invoke("save-c2s-tunnel-config", config),
|
|
||||||
checkLocalPortAvailable: (host, port) =>
|
|
||||||
ipcRenderer.invoke("check-local-port-available", host, port),
|
|
||||||
getC2STunnelPresetDefaultName: () =>
|
|
||||||
ipcRenderer.invoke("get-c2s-tunnel-preset-default-name"),
|
|
||||||
startC2STunnel: (tunnel, index, authToken) =>
|
|
||||||
ipcRenderer.invoke("start-c2s-tunnel", tunnel, index, authToken),
|
|
||||||
testC2STunnel: (tunnel, index, authToken) =>
|
|
||||||
ipcRenderer.invoke("test-c2s-tunnel", tunnel, index, authToken),
|
|
||||||
stopC2STunnel: (tunnelName) =>
|
|
||||||
ipcRenderer.invoke("stop-c2s-tunnel", tunnelName),
|
|
||||||
getC2STunnelStatuses: () => ipcRenderer.invoke("get-c2s-tunnel-statuses"),
|
|
||||||
onC2STunnelStatuses: (callback) => {
|
|
||||||
const listener = (_event, statuses) => callback(statuses);
|
|
||||||
ipcRenderer.on("c2s-tunnel-statuses", listener);
|
|
||||||
return () => ipcRenderer.removeListener("c2s-tunnel-statuses", listener);
|
|
||||||
},
|
|
||||||
startC2SAutoStartTunnels: () =>
|
|
||||||
ipcRenderer.invoke("start-c2s-autostart-tunnels"),
|
|
||||||
setC2SRelayPath: (relayPath) =>
|
|
||||||
ipcRenderer.invoke("set-c2s-relay-path", relayPath),
|
|
||||||
|
|
||||||
onCloseActiveTab: (callback) => {
|
onCloseActiveTab: (callback) => {
|
||||||
const listener = () => callback();
|
const listener = () => callback();
|
||||||
@@ -100,32 +83,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
externalBrowserLogin: (authUrl, callbackPort) =>
|
externalBrowserLogin: (authUrl, callbackPort) =>
|
||||||
ipcRenderer.invoke("external-browser-login", authUrl, callbackPort),
|
ipcRenderer.invoke("external-browser-login", authUrl, callbackPort),
|
||||||
|
|
||||||
openExternalEditor: (fileData) =>
|
|
||||||
ipcRenderer.invoke("open-external-editor", fileData),
|
|
||||||
closeExternalEditor: (editId) =>
|
|
||||||
ipcRenderer.invoke("close-external-editor", editId),
|
|
||||||
onExternalEditorSaved: (callback) => {
|
|
||||||
const listener = (_event, payload) => callback(payload);
|
|
||||||
ipcRenderer.on("external-editor-saved", listener);
|
|
||||||
return () => ipcRenderer.removeListener("external-editor-saved", listener);
|
|
||||||
},
|
|
||||||
|
|
||||||
showSaveDialog: (options) => ipcRenderer.invoke("show-save-dialog", options),
|
showSaveDialog: (options) => ipcRenderer.invoke("show-save-dialog", options),
|
||||||
showOpenDialog: (options) => ipcRenderer.invoke("show-open-dialog", options),
|
showOpenDialog: (options) => ipcRenderer.invoke("show-open-dialog", options),
|
||||||
getLocalHomeDirectory: () => ipcRenderer.invoke("get-local-home-directory"),
|
|
||||||
listLocalDirectory: (dirPath) =>
|
|
||||||
ipcRenderer.invoke("list-local-directory", dirPath),
|
|
||||||
statLocalPaths: (paths) => ipcRenderer.invoke("stat-local-paths", paths),
|
|
||||||
collectLocalFiles: (paths) =>
|
|
||||||
ipcRenderer.invoke("collect-local-files", paths),
|
|
||||||
createLocalFolder: (parentPath, folderName) =>
|
|
||||||
ipcRenderer.invoke("create-local-folder", parentPath, folderName),
|
|
||||||
renameLocalPath: (entryPath, newName) =>
|
|
||||||
ipcRenderer.invoke("rename-local-path", entryPath, newName),
|
|
||||||
trashLocalPath: (entryPath) =>
|
|
||||||
ipcRenderer.invoke("trash-local-path", entryPath),
|
|
||||||
chmodLocalPath: (entryPath, permissions) =>
|
|
||||||
ipcRenderer.invoke("chmod-local-path", entryPath, permissions),
|
|
||||||
createTempFile: (fileData) =>
|
createTempFile: (fileData) =>
|
||||||
ipcRenderer.invoke("create-temp-file", fileData),
|
ipcRenderer.invoke("create-temp-file", fileData),
|
||||||
createTempFolder: (folderData) =>
|
createTempFolder: (folderData) =>
|
||||||
@@ -134,61 +93,14 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
ipcRenderer.invoke("start-drag-to-desktop", dragData),
|
ipcRenderer.invoke("start-drag-to-desktop", dragData),
|
||||||
cleanupTempFile: (tempId) => ipcRenderer.invoke("cleanup-temp-file", tempId),
|
cleanupTempFile: (tempId) => ipcRenderer.invoke("cleanup-temp-file", tempId),
|
||||||
|
|
||||||
startLocalTerminal: (dimensions) =>
|
// Native web views placed over an element of the page (WebContentsView).
|
||||||
ipcRenderer.invoke("local-terminal-start", dimensions),
|
embeddedView: {
|
||||||
writeLocalTerminal: (sessionId, data) =>
|
open: (options) => ipcRenderer.invoke("embedded-view-open", options),
|
||||||
ipcRenderer.invoke("local-terminal-write", sessionId, data),
|
setBounds: (id, bounds) =>
|
||||||
readyLocalTerminal: (sessionId) =>
|
ipcRenderer.invoke("embedded-view-bounds", id, bounds),
|
||||||
ipcRenderer.invoke("local-terminal-ready", sessionId),
|
setVisible: (id, visible) =>
|
||||||
resizeLocalTerminal: (sessionId, cols, rows) =>
|
ipcRenderer.invoke("embedded-view-visible", id, visible),
|
||||||
ipcRenderer.invoke("local-terminal-resize", sessionId, cols, rows),
|
close: (id) => ipcRenderer.invoke("embedded-view-close", id),
|
||||||
closeLocalTerminal: (sessionId) =>
|
|
||||||
ipcRenderer.invoke("local-terminal-close", sessionId),
|
|
||||||
onLocalTerminalData: (sessionId, callback) => {
|
|
||||||
const channel = `local-terminal:data:${sessionId}`;
|
|
||||||
const listener = (_event, data) => callback(data);
|
|
||||||
ipcRenderer.on(channel, listener);
|
|
||||||
return () => ipcRenderer.removeListener(channel, listener);
|
|
||||||
},
|
|
||||||
onLocalTerminalExit: (sessionId, callback) => {
|
|
||||||
const channel = `local-terminal:exit:${sessionId}`;
|
|
||||||
const listener = (_event, exitCode) => callback(exitCode);
|
|
||||||
ipcRenderer.on(channel, listener);
|
|
||||||
return () => ipcRenderer.removeListener(channel, listener);
|
|
||||||
},
|
|
||||||
|
|
||||||
// Dual-pane file manager: local disk browsing and streamed transfers.
|
|
||||||
localFs: {
|
|
||||||
home: () => ipcRenderer.invoke("local-fs:home"),
|
|
||||||
list: (dirPath) => ipcRenderer.invoke("local-fs:list", dirPath),
|
|
||||||
mkdir: (parentPath, name) =>
|
|
||||||
ipcRenderer.invoke("local-fs:mkdir", parentPath, name),
|
|
||||||
createFile: (parentPath, name) =>
|
|
||||||
ipcRenderer.invoke("local-fs:create-file", parentPath, name),
|
|
||||||
rename: (oldPath, newName) =>
|
|
||||||
ipcRenderer.invoke("local-fs:rename", oldPath, newName),
|
|
||||||
trash: (paths) => ipcRenderer.invoke("local-fs:trash", paths),
|
|
||||||
ensureDir: (dirPath, rootPath) =>
|
|
||||||
ipcRenderer.invoke("local-fs:ensure-dir", dirPath, rootPath),
|
|
||||||
exists: (paths) => ipcRenderer.invoke("local-fs:exists", paths),
|
|
||||||
walk: (paths) => ipcRenderer.invoke("local-fs:walk", paths),
|
|
||||||
reveal: (targetPath) => ipcRenderer.invoke("local-fs:reveal", targetPath),
|
|
||||||
open: (targetPath) => ipcRenderer.invoke("local-fs:open", targetPath),
|
|
||||||
},
|
|
||||||
localTransfer: {
|
|
||||||
setApiPath: (apiPath) =>
|
|
||||||
ipcRenderer.invoke("local-transfer:set-api", apiPath),
|
|
||||||
upload: (options) => ipcRenderer.invoke("local-transfer:upload", options),
|
|
||||||
download: (options) =>
|
|
||||||
ipcRenderer.invoke("local-transfer:download", options),
|
|
||||||
cancel: (transferId) =>
|
|
||||||
ipcRenderer.invoke("local-transfer:cancel", transferId),
|
|
||||||
onProgress: (callback) => {
|
|
||||||
const listener = (_event, payload) => callback(payload);
|
|
||||||
ipcRenderer.on("local-transfer:progress", listener);
|
|
||||||
return () =>
|
|
||||||
ipcRenderer.removeListener("local-transfer:progress", listener);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
invoke: invokeAllowed,
|
invoke: invokeAllowed,
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
const fs = require("node:fs");
|
|
||||||
|
|
||||||
function renameLocalPath(source, target) {
|
|
||||||
if (source === target) return;
|
|
||||||
try {
|
|
||||||
fs.lstatSync(target);
|
|
||||||
const error = new Error("A file or folder with that name already exists");
|
|
||||||
error.code = "EEXIST";
|
|
||||||
throw error;
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code !== "ENOENT") throw error;
|
|
||||||
}
|
|
||||||
if (fs.lstatSync(source).isDirectory()) {
|
|
||||||
fs.renameSync(source, target);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// link is exclusive: a destination created concurrently is never replaced.
|
|
||||||
fs.linkSync(source, target);
|
|
||||||
fs.unlinkSync(source);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { renameLocalPath };
|
|
||||||
+24
-12
@@ -298,7 +298,7 @@
|
|||||||
{
|
{
|
||||||
"name": "HTTP_PROXY",
|
"name": "HTTP_PROXY",
|
||||||
"group": "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",
|
"name": "HTTPS_PROXY",
|
||||||
@@ -332,17 +332,6 @@
|
|||||||
"default": "10000",
|
"default": "10000",
|
||||||
"description": "Most audit entries kept. The oldest go first."
|
"description": "Most audit entries kept. The oldest go first."
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "AUDIT_LOG_FORWARD_URL",
|
|
||||||
"group": "logging",
|
|
||||||
"description": "Also POST every audit entry as JSON to this URL. Overrides the admin setting."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "AUDIT_LOG_FORWARD_TOKEN",
|
|
||||||
"group": "logging",
|
|
||||||
"secret": true,
|
|
||||||
"description": "Bearer token sent with forwarded audit entries."
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "SESSION_RECORDING_RETENTION_DAYS",
|
"name": "SESSION_RECORDING_RETENTION_DAYS",
|
||||||
"group": "logging",
|
"group": "logging",
|
||||||
@@ -364,12 +353,29 @@
|
|||||||
"group": "plugins",
|
"group": "plugins",
|
||||||
"description": "Where plugin download counts are read from. Defaults to stats.json next to the registry index."
|
"description": "Where plugin download counts are read from. Defaults to stats.json next to the registry index."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS",
|
||||||
|
"group": "plugins",
|
||||||
|
"description": "Comma-separated host names or IP addresses that plugin registry downloads may reach on a private address, like a registry in your LAN. List bare hosts: entries with a scheme, port or path are ignored. Matched by name only, on any port, so only list names your own DNS answers for. With HTTPS_PROXY set, add them to NO_PROXY too."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "TERMIX_REQUIRE_SIGNED_PLUGINS",
|
"name": "TERMIX_REQUIRE_SIGNED_PLUGINS",
|
||||||
"group": "plugins",
|
"group": "plugins",
|
||||||
"default": "false",
|
"default": "false",
|
||||||
"description": "Only load plugins signed by a trusted key. Blocks uploads of unsigned files even in developer mode."
|
"description": "Only load plugins signed by a trusted key. Blocks uploads of unsigned files even in developer mode."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "TERMIX_SAFE_LAYOUT",
|
||||||
|
"group": "plugins",
|
||||||
|
"default": "false",
|
||||||
|
"description": "Start everyone in the layout repair view instead of the chosen layout. Use it when a layout plugin breaks the app."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TERMIX_SAFE_MODE",
|
||||||
|
"group": "plugins",
|
||||||
|
"default": "false",
|
||||||
|
"description": "Start only layout and theme plugins. Every other plugin stays off until a restart without it, so you can turn off a plugin that breaks the server or the app."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "TERMIX_BUNDLED_PLUGINS_DIR",
|
"name": "TERMIX_BUNDLED_PLUGINS_DIR",
|
||||||
"group": "plugins",
|
"group": "plugins",
|
||||||
@@ -427,6 +433,12 @@
|
|||||||
"internal": true,
|
"internal": true,
|
||||||
"description": "Set by the desktop app when it runs its own backend."
|
"description": "Set by the desktop app when it runs its own backend."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "ELECTRON_USER_DATA",
|
||||||
|
"group": "desktop",
|
||||||
|
"internal": true,
|
||||||
|
"description": "Set by the desktop app to its user data folder."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "EMBEDDED_BACKEND_PORT",
|
"name": "EMBEDDED_BACKEND_PORT",
|
||||||
"group": "desktop",
|
"group": "desktop",
|
||||||
|
|||||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"version": "26.10.2",
|
"version": "26.10.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"version": "26.10.2",
|
"version": "26.10.5",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
@@ -15058,7 +15058,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin-sdk": {
|
"packages/plugin-sdk": {
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"name": "@termix-ssh/plugin-sdk",
|
||||||
"version": "1.0.11",
|
"version": "1.0.13",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||||
|
|||||||
+5
-4
@@ -4,7 +4,7 @@
|
|||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"version": "26.10.2",
|
"version": "26.10.5",
|
||||||
"description": "Self-hosted, plugin-based server management.",
|
"description": "Self-hosted, plugin-based server management.",
|
||||||
"author": "Karmaa",
|
"author": "Karmaa",
|
||||||
"main": "electron/main.cjs",
|
"main": "electron/main.cjs",
|
||||||
@@ -15,10 +15,10 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"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",
|
"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",
|
"prepare": "husky || true",
|
||||||
"prebuild": "node scripts/write-electron-build-info.cjs",
|
"prebuild": "node scripts/write-electron-build-info.cjs",
|
||||||
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-sdk-guide.cjs && node scripts/generate-settings-search-index.cjs --check && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs",
|
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-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 .",
|
"lint:fix": "eslint --fix .",
|
||||||
"type-check": "npm run build:sdk && tsc -b --force",
|
"type-check": "npm run build:sdk && tsc -b --force",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
@@ -31,7 +31,8 @@
|
|||||||
"dev:docker": "node scripts/dev.mjs docker",
|
"dev:docker": "node scripts/dev.mjs docker",
|
||||||
"dev:frontend": "vite",
|
"dev:frontend": "vite",
|
||||||
"build": "npm run build:sdk && vite build && node scripts/stamp-sw-build-info.cjs && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
|
"build": "npm run build:sdk && vite build && node scripts/stamp-sw-build-info.cjs && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
|
||||||
"build:backend": "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",
|
"generate:openapi": "npm run build:sdk && node scripts/generate-openapi.mjs",
|
||||||
"electron:rebuild": "electron-rebuild -f -o better-sqlite3,@serialport/bindings-cpp,node-pty",
|
"electron:rebuild": "electron-rebuild -f -o better-sqlite3,@serialport/bindings-cpp,node-pty",
|
||||||
"build:win-portable": "npm run build && npm run electron:rebuild && electron-builder --win --dir",
|
"build:win-portable": "npm run build && npm run electron:rebuild && electron-builder --win --dir",
|
||||||
|
|||||||
@@ -1,5 +1,117 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `app.registerCompanionLayout` and `useCompanions`, so a plugin can let each host turn the panel bar on or off, move it and choose which panels show and in what order
|
||||||
|
|
||||||
|
## 1.0.18
|
||||||
|
|
||||||
|
Plugin API 1.10.0.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `clientCert` on the sync link, link patch and remote target, and the `client_cert` remote error kind, so a sync plugin can link to a server that needs mutual TLS
|
||||||
|
|
||||||
|
## 1.0.17
|
||||||
|
|
||||||
|
Plugin API 1.9.0.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `app.registerCompanion` and `app.toggleCompanion`, so a plugin can add a panel beside any host tab. The user opens, resizes and moves it, and the tab keeps running
|
||||||
|
- `NativeView` in `@termix-ssh/plugin-sdk/ui`, which shows a web page in a native view in the desktop app, for sites that do not work in an iframe
|
||||||
|
|
||||||
|
## 1.0.16
|
||||||
|
|
||||||
|
Plugin API 1.8.0.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `ctx.sync.provide` and the `sync:provide` capability, so a plugin can answer every /sync request
|
||||||
|
- `ctx.ssh.transports.provide` and the `ssh:transport` capability, so a plugin can carry SSH connections over its own stream
|
||||||
|
- `ctx.hosts.folders.ensure` and `removeEmpty`
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- The sync screens and calls in `@termix-ssh/plugin-sdk/ui`: `LinkServerDialog`, `ProxySettingsFields`, `useSyncStatus`, `refreshSyncStatus`, `setSyncStatus`, `getSyncConflicts`, `getSyncErrors`, `retrySyncErrors`, `settleSyncConflict`, `syncNow`, `unlinkServer`, `updateSyncSettings` and their types. They moved into the sync plugin
|
||||||
|
|
||||||
|
## 1.0.15
|
||||||
|
|
||||||
|
Plugin API 1.7.0.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `ctx.auth.policy.provide` and the `auth:policy` capability, so a plugin can set an idle timeout, cap session length, turn off remember me and require a second factor
|
||||||
|
- `ctx.auth.listSecondFactors` lists the second factors from every running plugin
|
||||||
|
- `AuthEnrollmentSections` takes `factorsOnly`
|
||||||
|
- `getHttpsSettings` and `updateHttpsSettings` in `@termix-ssh/plugin-sdk/ui`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Plugin frontend tests against a locally linked SDK failed with a second copy of React
|
||||||
|
|
||||||
|
## 1.0.14
|
||||||
|
|
||||||
|
Plugin API 1.6.0.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `ctx.hosts.locks.provide` and the `hosts:lock` capability, so a plugin can manage hosts that stay read only for everyone else and block adding hosts by hand
|
||||||
|
- `ctx.hosts.setShares` to replace every grant on a host in one call
|
||||||
|
- `ctx.credentials.list` returns the acting user's credentials by name, without secrets
|
||||||
|
- `ctx.hosts.create` and `update` take `password`, `key`, `keyPassword`, `keyType`, `sudoPassword`, `protocolAuth` and `pluginEnable`
|
||||||
|
|
||||||
|
## 1.0.13
|
||||||
|
|
||||||
|
Plugin API 1.5.0.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `runtime: "desktop"` in the manifest for a plugin that only loads on the desktop app
|
||||||
|
- `desktop:local` and `ctx.desktop.local`: file system, local shell and machine info on the desktop app's own backend
|
||||||
|
- `ctx.audit.subscribe`, `ctx.audit.list` and `ctx.audit.exportBatches` under `audit:read`
|
||||||
|
- `ctx.hosts.importMany`, `importSshConfig`, `exportMany` and `registerImportNormalizer`, with the `hosts:export` capability
|
||||||
|
- `ctx.credentials.publicKey` and `resolveKeyRefs`, `ctx.ssh.exec`, and `@termix-ssh/plugin-sdk/ssh-keys`
|
||||||
|
- `ctx.hosts.status.provide` and `ctx.hosts.activity.provide` so a plugin can own host status and recent activity, with `ctx.network.tcpProbe`, `ctx.ssh.probe`, `ctx.hosts.probeTargets` and `ctx.hosts.sessionActive`
|
||||||
|
- `registerHostStatusIndicator` and `registerHostEditorToolbarItem`, and `order` for host badges
|
||||||
|
- Onboarding steps can sit in a `start` section, take a `position` and `replaces` a core step
|
||||||
|
- A plugin can provide the app name, tagline and logo through the `<id>.brandingProvider` registry key
|
||||||
|
|
||||||
|
## 1.0.12
|
||||||
|
|
||||||
|
Plugin API 1.4.0.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `contributes.layouts` and `app.registerLayout()` for a whole-app layout, with the `ui:layout` capability. Needs `engine.api` 1.4
|
||||||
|
- `contributes.themes` for CSS-only themes, with the `ui:theme` capability. `termix-plugin validate` checks theme CSS and `pack` ships it
|
||||||
|
- `useShell()` gives a layout the live shell: tabs, splits, hosts, docks, the command palette and the registries. The shell's home tab is left out until some plugin registers a `dashboard` tab type
|
||||||
|
- `TabStage` and `ShellConfirmCapture` in `@termix-ssh/plugin-sdk/ui`, for a layout to place tabs
|
||||||
|
- `validateThemeCss()` in `@termix-ssh/plugin-sdk/manifest`
|
||||||
|
- `@termix-ssh/plugin-sdk/ui` also exports what a whole layout is built from: the API client, shared types, hooks, `useShellController()` and `APP_VERSION`. These reach into core, so they can change with a core release
|
||||||
|
- `@termix-ssh/plugin-sdk/testing-host` exports the registries and permission seam a layout's own tests fill directly
|
||||||
|
- A plugin that only holds themes needs no source: `termix-plugin build` writes an empty backend and `validate` does not ask for a frontend
|
||||||
|
- `termix-plugin validate` flags `ui:layout` or `ui:theme` with nothing contributed, and a layout plugin with no frontend
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The manifest JSON schema lists the `notify:hub` and `plugins:read` capabilities
|
||||||
|
- Plugin tests and type checks work with the SDK linked from a local Termix checkout
|
||||||
|
|
||||||
## 1.0.11
|
## 1.0.11
|
||||||
|
|
||||||
Plugin API 1.3.0.
|
Plugin API 1.3.0.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Termix plugin guide
|
# Termix plugin guide
|
||||||
|
|
||||||
SDK 1.0.11 | Plugin API 1.3.0
|
SDK 1.0.20 | Plugin API 1.12.0
|
||||||
|
|
||||||
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
|
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
|
||||||
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
|
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
|
||||||
@@ -164,7 +164,7 @@ Unknown fields are refused at every level, so a typo fails validation. The schem
|
|||||||
| `icon` | A [Lucide](https://lucide.dev/icons/) icon name, like `Hand`. |
|
| `icon` | A [Lucide](https://lucide.dev/icons/) icon name, like `Hand`. |
|
||||||
| `features` | Up to 20 short lines shown on the plugin's page. |
|
| `features` | Up to 20 short lines shown on the plugin's page. |
|
||||||
| `docs` | An https link to your docs. |
|
| `docs` | An https link to your docs. |
|
||||||
| `engine.termix` | Termix versions you work with, like `>=26.10.0`. |
|
| `engine.termix` | Termix versions you work with, like `>=26.10.0`. Older Termix builds don't list a release they can't run. |
|
||||||
| `engine.api` | Plugin API range, like `^1.2`. A major bump of the API is breaking. |
|
| `engine.api` | Plugin API range, like `^1.2`. A major bump of the API is breaking. |
|
||||||
| `capabilities` | What your code may do. See the reference at the end. |
|
| `capabilities` | What your code may do. See the reference at the end. |
|
||||||
| `dependencies`, `optionalDependencies` | Other plugins, `{ "snippets": "^1.0.0" }`. You must keep working without optional ones. |
|
| `dependencies`, `optionalDependencies` | Other plugins, `{ "snippets": "^1.0.0" }`. You must keep working without optional ones. |
|
||||||
@@ -175,7 +175,7 @@ Unknown fields are refused at every level, so a typo fails validation. The schem
|
|||||||
|
|
||||||
`contributes` holds `tabs`, `panels`, `dashboardCards`, `actions`, `actionSlots`, `permissions`,
|
`contributes` holds `tabs`, `panels`, `dashboardCards`, `actions`, `actionSlots`, `permissions`,
|
||||||
`settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`,
|
`settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`,
|
||||||
`uiPresets` and `uses`. Frontend `register*` calls need their ids declared here first.
|
`uiPresets`, `uses`, `layouts` and `themes`. Frontend `register*` calls need their ids declared here first.
|
||||||
|
|
||||||
Capabilities and permissions are different things. A capability (`hosts:read`, with a colon) is what your
|
Capabilities and permissions are different things. A capability (`hosts:read`, with a colon) is what your
|
||||||
code may reach. A permission (`hello.use`, with a dot) is what a user may do.
|
code may reach. A permission (`hello.use`, with a dot) is what a user may do.
|
||||||
@@ -203,7 +203,7 @@ Bundled as ESM for Node 22+ into `dist/backend.js`. Backend imports of your own
|
|||||||
### What ctx has
|
### What ctx has
|
||||||
|
|
||||||
| Member | Capability | What it does |
|
| Member | Capability | What it does |
|
||||||
| ---------------------- | -------------------------------- | -------------------------------------------------------------- |
|
| ---------------------- | -------------------------------- | --------------------------------------------------------------- |
|
||||||
| `pluginId`, `manifest` | none | Your id and manifest |
|
| `pluginId`, `manifest` | none | Your id and manifest |
|
||||||
| `log` | none | Log lines tagged with your plugin |
|
| `log` | none | Log lines tagged with your plugin |
|
||||||
| `db` | `db:own` | Your own tables |
|
| `db` | `db:own` | Your own tables |
|
||||||
@@ -226,6 +226,10 @@ Bundled as ESM for Node 22+ into `dist/backend.js`. Backend imports of your own
|
|||||||
| `sync` | none | Sync your tables to a linked desktop |
|
| `sync` | none | Sync your tables to a linked desktop |
|
||||||
| `auth` | `auth:provide` | Login methods, second factors, SSH auth types |
|
| `auth` | `auth:provide` | Login methods, second factors, SSH auth types |
|
||||||
| `auth.issueUserToken` | `users:impersonate` | A short-lived session for calling Termix's API as a user |
|
| `auth.issueUserToken` | `users:impersonate` | A short-lived session for calling Termix's API as a user |
|
||||||
|
| `auth.policy` | `auth:policy` | Login rules: idle timeout, session length, second factors |
|
||||||
|
| `sync.provide` | `sync:provide` | Answers all /sync requests: server sync, linked desktops |
|
||||||
|
| `ssh.transports` | `ssh:transport` | Carries a host's SSH connection over your own stream |
|
||||||
|
| `hosts.folders` | `hosts:write` | Create a folder row with a color and icon, remove it when empty |
|
||||||
| `users.getLocale` | `users:read` | A user's UI language |
|
| `users.getLocale` | `users:read` | A user's UI language |
|
||||||
| `system` | `system:tls` | The server's HTTPS certificate |
|
| `system` | `system:tls` | The server's HTTPS certificate |
|
||||||
| `plugins.list()` | `plugins:read` | Which plugins are installed |
|
| `plugins.list()` | `plugins:read` | Which plugins are installed |
|
||||||
@@ -300,6 +304,14 @@ router.post("/notes", async (req, res) => {
|
|||||||
- `ctx.rbac.require("manage")` on a single route, `await ctx.rbac.has("manage")` in code. A full id like
|
- `ctx.rbac.require("manage")` on a single route, `await ctx.rbac.has("manage")` in code. A full id like
|
||||||
`hosts.edit` checks a core permission.
|
`hosts.edit` checks a core permission.
|
||||||
- Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`.
|
- Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`.
|
||||||
|
- Transports: with `ssh:transport`, `ctx.ssh.transports.provide({ claims(hostId), open({ id, ip, port }) })`
|
||||||
|
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
|
||||||
|
the hosts you manage read only for the UI, the API and other plugins (pins and order still work), and
|
||||||
|
`allowManualCreate: () => false` blocks adding hosts by hand. Your own `ctx.hosts` writes still go
|
||||||
|
through. `ctx.hosts.setShares(hostId, grants)` replaces a host's whole grant list, and
|
||||||
|
`ctx.credentials.list()` finds the acting user's credentials by name.
|
||||||
|
|
||||||
### Errors
|
### Errors
|
||||||
|
|
||||||
@@ -325,6 +337,24 @@ try {
|
|||||||
commands. `@termix-ssh/plugin-sdk/host-commands` has helpers to detect the OS and run package manager and
|
commands. `@termix-ssh/plugin-sdk/host-commands` has helpers to detect the OS and run package manager and
|
||||||
sudo commands. Hosts from `ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`.
|
sudo commands. Hosts from `ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`.
|
||||||
|
|
||||||
|
### The desktop app
|
||||||
|
|
||||||
|
Set `"runtime": "desktop"` in the manifest for a plugin that only makes sense on a desktop app. It never
|
||||||
|
loads on a server, ignores server sync and may declare `desktop:local`. Any plugin can declare
|
||||||
|
`desktop:local` and check `ctx.desktop.local.available()` at runtime.
|
||||||
|
|
||||||
|
`ctx.desktop.local` works on the desktop app's own backend only:
|
||||||
|
|
||||||
|
- `info()`: home folder, platform, user data folder, backend port and the linked server (url and token) or null
|
||||||
|
- `fs`: `list`, `mkdir`, `createFile`, `rename`, `ensureDir`, `exists`, `walk`, `trash`, `reveal`, `open`,
|
||||||
|
`openRead` and `openDownload`. Downloads stay inside the folder the user picked, links in it are refused and
|
||||||
|
an existing file is never replaced by accident
|
||||||
|
- `pty.spawn()`: a local shell, stopped when the plugin stops
|
||||||
|
- `pluginFetch(origin, subPath, init)`: calls this plugin's own routes on the desktop's backend ("local") or the linked server ("remote") as the acting user. Bodies can be streams, so a plugin can move big files without the renderer
|
||||||
|
|
||||||
|
Use `ctx.audit.subscribe`, `list` and `exportBatches` (`audit:read`) to read the audit trail. Subscribers
|
||||||
|
are called after a line is accepted and never delay the action that wrote it.
|
||||||
|
|
||||||
### Events, timers, alerts, services
|
### Events, timers, alerts, services
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
@@ -465,27 +495,42 @@ your plugin's error boundary and Suspense.
|
|||||||
| ---------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
| ---------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||||
| `registerRailItem` | A sidebar rail button |
|
| `registerRailItem` | A sidebar rail button |
|
||||||
| `registerTab`, `registerPanel` | A tab type or sidebar panel (id in `contributes.tabs` / `panels`) |
|
| `registerTab`, `registerPanel` | A tab type or sidebar panel (id in `contributes.tabs` / `panels`) |
|
||||||
|
| `registerCompanion`, `toggleCompanion` | A panel beside any host tab, bound to its host and handle |
|
||||||
|
| `registerCompanionLayout` | Per host on/off, side, order and hidden panels for a tab type |
|
||||||
| `registerDashboardCard` | A dashboard card (`contributes.dashboardCards`) |
|
| `registerDashboardCard` | A dashboard card (`contributes.dashboardCards`) |
|
||||||
| `registerHostAction` | A host button, palette entry or default connect |
|
| `registerHostAction` | A host button, palette entry or default connect |
|
||||||
| `registerHostEditorSection` | Your own host editor section (host settings get one for free) |
|
| `registerHostEditorSection` | Your own host editor section (host settings get one for free) |
|
||||||
| `registerHostProtocol` | A connection protocol next to SSH |
|
| `registerHostProtocol` | A connection protocol next to SSH |
|
||||||
| `registerHostBadge`, `registerHostContextMenuItem` | Badges and menu items on host rows |
|
| `registerHostBadge`, `registerHostContextMenuItem` | Badges and menu items on host rows |
|
||||||
|
| `registerHostStatusIndicator` | The status dot or stripe on a host row (a layout draws it) |
|
||||||
|
| `registerHostEditorToolbarItem` | A control in the host editor toolbar (a layout draws it) |
|
||||||
| `registerPaletteEntry`, `registerPaletteGroup` | Command palette entries |
|
| `registerPaletteEntry`, `registerPaletteGroup` | Command palette entries |
|
||||||
| `registerKeybindingAction`, `registerKeybindingDefault` | Bindable actions |
|
| `registerKeybindingAction`, `registerKeybindingDefault` | Bindable actions |
|
||||||
| `registerSettingsComponent` | A component for a `custom` settings field |
|
| `registerSettingsComponent` | A component for a `custom` settings field |
|
||||||
| `registerAction`, `invokeAction` | Actions across plugins |
|
| `registerAction`, `invokeAction` | Actions across plugins |
|
||||||
| `registerSlotContribution`, `declareActionSlot` | Fill or own a UI slot |
|
| `registerSlotContribution`, `declareActionSlot` | Fill or own a UI slot |
|
||||||
| `registerComponent`, `registerExtension` | Offer a component by id, add to an extension point |
|
| `registerComponent`, `registerExtension` | Offer a component by id, add to an extension point |
|
||||||
| `registerOnboardingStep` | A first run setup step |
|
| `registerOnboardingStep` | A first run setup step (`section`, `position`, `replaces`) |
|
||||||
| `registerLoginMethod`, `registerSecondFactorUI`, `registerSshAuthEditor` | Sign in UI |
|
| `registerLoginMethod`, `registerSecondFactorUI`, `registerSshAuthEditor` | Sign in UI |
|
||||||
|
| `registerLayout` | A whole-app layout (`contributes.layouts`, API 1.4) |
|
||||||
| `api` | Axios client for your `/plugin-api/<id>/` routes, auth included |
|
| `api` | Axios client for your `/plugin-api/<id>/` routes, auth included |
|
||||||
| `fetch(path)`, `wsUrl(path)` | Raw fetch for streams, socket URL and auth |
|
| `fetch(path)`, `wsUrl(path)` | Raw fetch for streams, socket URL and auth |
|
||||||
| `docs`, `tabs`, `confirm`, `listHosts`, `getHost`, `t`, `hasPermission`, `desktop` | Helpers |
|
| `docs`, `tabs`, `confirm`, `listHosts`, `getHost`, `t`, `hasPermission`, `desktop` | Helpers |
|
||||||
| `onSettingsChanged`, `onDispose` | Hooks |
|
| `onSettingsChanged`, `onDispose` | Hooks |
|
||||||
|
|
||||||
|
Badges, status indicators and toolbar items take `{ id, when(host), component, order? }`; lower `order`
|
||||||
|
comes first. An onboarding step sits in a section (`start`, `setup`, `explore`, `security`) or at an exact
|
||||||
|
`position` from 1 to 99. `replaces: ["preset"]` lists older step keys the step took over, so people who saw
|
||||||
|
those are not shown it again.
|
||||||
|
|
||||||
|
Branding: a plugin that owns the app name, tagline and logo provides
|
||||||
|
`ctx.registry.provide("<id>.brandingProvider", async () => ({ appName, tagline, logo }))` in its backend.
|
||||||
|
Core reads it for `GET /users/branding` (the login page, the browser tab and the sync server name) and falls
|
||||||
|
back to "Termix" with no logo when no plugin provides one. `logo` is a PNG data URL or null.
|
||||||
|
|
||||||
Hooks from `@termix-ssh/plugin-sdk/frontend`: `useTranslation`, `usePermission`, `useSettings`, `useHost`,
|
Hooks from `@termix-ssh/plugin-sdk/frontend`: `useTranslation`, `usePermission`, `useSettings`, `useHost`,
|
||||||
`useHosts`, `usePluginApi`, `useCurrentUser`, `useTheme`, `useToast`, `useDocsUrl`, `useTabs`,
|
`useHosts`, `usePluginApi`, `useCurrentUser`, `useTheme`, `useToast`, `useDocsUrl`, `useTabs`,
|
||||||
`useSlotContributions`, `useSshAuthTypes`, `useExtensions`.
|
`useSlotContributions`, `useSshAuthTypes`, `useExtensions`, `useShell`.
|
||||||
|
|
||||||
Shared from Termix and never bundled: `react`, `react-dom`, `i18next`, `react-i18next`, `sonner` and the
|
Shared from Termix and never bundled: `react`, `react-dom`, `i18next`, `react-i18next`, `sonner` and the
|
||||||
SDK's `frontend` and `ui`. Anything else, like `lucide-react`, is bundled. A host your code gets only carries
|
SDK's `frontend` and `ui`. Anything else, like `lucide-react`, is bundled. A host your code gets only carries
|
||||||
@@ -496,12 +541,100 @@ Slots you can fill include `terminal.toolbar`, `terminal.toolbarStatus`, `termin
|
|||||||
`hosts.panel`, `credentials.badges`, `shell.overlay`, `shell.rightEdge` and `tab.menu`. See
|
`hosts.panel`, `credentials.badges`, `shell.overlay`, `shell.rightEdge` and `tab.menu`. See
|
||||||
https://docs.termix.site/develop/more-extension-points.
|
https://docs.termix.site/develop/more-extension-points.
|
||||||
|
|
||||||
|
## Layouts and themes
|
||||||
|
|
||||||
|
A plugin can replace the whole app layout or add themes (API 1.4). Users pick both in Settings, Appearance;
|
||||||
|
admins set the default for everyone.
|
||||||
|
|
||||||
|
A layout is declared in `contributes.layouts` (`id`, `label`, optional `description` and `defaultTheme`) and
|
||||||
|
needs the `ui:layout` capability, which admins see as high risk because a layout draws every screen. Register
|
||||||
|
it in `activate`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useShell, type TermixApp } from "@termix-ssh/plugin-sdk/frontend";
|
||||||
|
import {
|
||||||
|
ShellConfirmCapture,
|
||||||
|
SurfaceScope,
|
||||||
|
TabStage,
|
||||||
|
} from "@termix-ssh/plugin-sdk/ui";
|
||||||
|
|
||||||
|
function MyLayout() {
|
||||||
|
const shell = useShell();
|
||||||
|
if (!shell) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex h-dvh w-screen flex-col bg-background">
|
||||||
|
<header>
|
||||||
|
{/* tabs from shell.tabs.topLevel, shell.palette.open() and so on */}
|
||||||
|
</header>
|
||||||
|
<SurfaceScope kind="tab">
|
||||||
|
<ShellConfirmCapture />
|
||||||
|
<TabStage />
|
||||||
|
</SurfaceScope>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activate(app: TermixApp) {
|
||||||
|
app.registerLayout({
|
||||||
|
id: "my-layout",
|
||||||
|
label: "layout.name",
|
||||||
|
component: MyLayout,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `useShell()` is the live shell with nothing drawn: tabs, splits, hosts, the left and right docks, the
|
||||||
|
command palette, the registries as hooks and the user. Core keeps all the state, so switching layouts keeps
|
||||||
|
every session.
|
||||||
|
- `TabStage` is where tabs show. Place it anywhere; tabs are never remounted when it moves, so a terminal
|
||||||
|
keeps its connection. Wrap it in `SurfaceScope kind="tab"` with a `ShellConfirmCapture` inside, so
|
||||||
|
close questions and inline views show over the tab area. Overlays and onboarding stay in core.
|
||||||
|
- The layout draws the command palette. Core keeps whether it is open (`palette.isOpen`, Ctrl+K calls
|
||||||
|
`palette.open()`) and what is in it (`usePaletteEntries`, `usePaletteGroups`, rail items, hosts).
|
||||||
|
- The shell always keeps a home tab with the id and type `dashboard`. `useShell()` leaves it out until some
|
||||||
|
plugin registers a `dashboard` tab type, so a layout without a home screen has no empty tab to draw.
|
||||||
|
- Settings, Manage and the host editor belong to Classic Layout. A layout without them should still render
|
||||||
|
`LookSettings` somewhere so people can switch back. `tabs.openSingleton("plugins")` opens core's own
|
||||||
|
Plugins tab in any layout. Switching layouts closes the tabs the old layout's plugin declared.
|
||||||
|
- `mobileComponent` is used on phones and narrow windows. The desktop app always gets `component`.
|
||||||
|
- Only the chosen layout's plugin is loaded. If it fails to load or crashes twice, Termix shows a repair
|
||||||
|
screen where people can switch, reset, update, turn it off or (for a bundled plugin) restore the copy that
|
||||||
|
shipped. `?safe-layout=1` opens it any time.
|
||||||
|
- Termix has no layout of its own. The default is the bundled Classic Layout plugin
|
||||||
|
(https://github.com/Termix-SSH/Plugin-Classic-Layout), which is also a full example. It owns the rail,
|
||||||
|
sidebar, tab bar, docks, Dashboard, Manage, Settings and command palette, registers the `dashboard`,
|
||||||
|
`host-manager` and `settings` tab types, and offers the host list and dashboard as the components
|
||||||
|
`classic-layout.hostTree` and `classic-layout.dashboard` for other plugins to place with `PluginComponent`.
|
||||||
|
It reads the shell with `useShellController()` from `@termix-ssh/plugin-sdk/ui`, which exposes more than
|
||||||
|
`useShell()` and may change with Termix. A third party layout should start from `useShell()`.
|
||||||
|
|
||||||
|
A theme is CSS only. Declare it in `contributes.themes` (`id`, `label`, `mode` light or dark, `css` path,
|
||||||
|
optional `swatch` colors for the picker) with the `ui:theme` capability. The id becomes a class on `<html>`,
|
||||||
|
so the file may only hold rules whose selectors start with `.<id>`, `:root.<id>` or `html.<id>`, optionally
|
||||||
|
inside `@media` or `@supports`. `@import`, `url()` to another host and other at-rules are refused by
|
||||||
|
`termix-plugin validate` and by the server. Set the same tokens core's themes set (`--background`,
|
||||||
|
`--foreground`, `--card`, `--border`, `--muted` and the rest).
|
||||||
|
|
||||||
|
A plugin that only holds themes needs no code. Leave out `src/`: `termix-plugin build` writes an empty backend and
|
||||||
|
`validate` does not ask for a frontend. Keep the CSS in `themes/` and a test that checks it with
|
||||||
|
`validateThemeCss()`. Notes:
|
||||||
|
|
||||||
|
- A theme with the id of a built-in (`light` or `dark`) replaces core's basic copy while the plugin runs.
|
||||||
|
- The first theme a bundled default-look plugin lists is the default for new installs.
|
||||||
|
- Themes show in the picker in the order core lists its own, then the plugin's order.
|
||||||
|
- A theme someone saved that no longer exists shows as System. It comes back when the plugin does.
|
||||||
|
- Accent color, UI font, font size and density are not part of a theme. Core applies them on top.
|
||||||
|
- Bundled Classic Themes (https://github.com/Termix-SSH/Plugin-Classic-Themes) is a full example.
|
||||||
|
|
||||||
## Looking native
|
## Looking native
|
||||||
|
|
||||||
Build UI from `@termix-ssh/plugin-sdk/ui` so it matches Termix and follows the theme. The full list of
|
Build UI from `@termix-ssh/plugin-sdk/ui` so it matches Termix and follows the theme. The full list of
|
||||||
exports with props is `ui.api.txt` in the Termix repo
|
exports with props is `ui.api.txt` in the Termix repo
|
||||||
(https://github.com/Termix-SSH/Termix/blob/main/packages/plugin-sdk/ui.api.txt).
|
(https://github.com/Termix-SSH/Termix/blob/main/packages/plugin-sdk/ui.api.txt).
|
||||||
|
|
||||||
|
- Web pages that break in an iframe: `NativeView` (props `url`, `ignoreCert`, `hidden`, `fallback`) shows the
|
||||||
|
page in a native view in the desktop app. It fills its element, hides while a dialog or menu is open and renders
|
||||||
|
`fallback` in a browser.
|
||||||
- Square: panels, cards and containers have no rounded corners. Small controls may stay rounded.
|
- Square: panels, cards and containers have no rounded corners. Small controls may stay rounded.
|
||||||
- Flat: borders, not shadows. Use theme tokens like `border-border`, `bg-background`,
|
- Flat: borders, not shadows. Use theme tokens like `border-border`, `bg-background`,
|
||||||
`text-muted-foreground`, `text-destructive`. One accent, `text-accent-brand`. Don't add colors.
|
`text-muted-foreground`, `text-destructive`. One accent, `text-accent-brand`. Don't add colors.
|
||||||
@@ -510,6 +643,9 @@ exports with props is `ui.api.txt` in the Termix repo
|
|||||||
`PanePrompt` for small prompts.
|
`PanePrompt` for small prompts.
|
||||||
- Every tab or panel sits in `PanelShell`. Lists use `PanelList` and `ListRow`, add with `AddButton`,
|
- Every tab or panel sits in `PanelShell`. Lists use `PanelList` and `ListRow`, add with `AddButton`,
|
||||||
forms end with `FormFooter`, empty lists show `EmptyState`.
|
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.
|
- Forms: `TextField`, `NumberField`, `SelectField`, `SwitchRow`, `SecretField`, `SettingRow` and friends.
|
||||||
- Host sessions use `ConnectionGate` for the whole connect flow (host keys, TOTP, passphrases, errors).
|
- 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
|
- Mobile: check `useIsMobile()` and give small screens a layout that works. The desktop app always uses the
|
||||||
@@ -578,6 +714,21 @@ closely. Declare ids in `contributes.auth` and register them with `ctx.auth.regi
|
|||||||
`registerSecondFactor` or `registerSshAuthProvider`. Connection protocols go in `contributes.protocols`
|
`registerSecondFactor` or `registerSshAuthProvider`. Connection protocols go in `contributes.protocols`
|
||||||
and `app.registerHostProtocol`. Read https://docs.termix.site/develop/auth before starting one.
|
and `app.registerHostProtocol`. Read https://docs.termix.site/develop/auth before starting one.
|
||||||
|
|
||||||
|
Login rules need `auth:policy`. `ctx.auth.policy.provide({ evaluate, allowRememberDevice })` is asked at
|
||||||
|
every login with the user, the method, the device and every second factor (`ctx.auth.listSecondFactors()`
|
||||||
|
lists them too). `evaluate` returns `allowRememberDevice`, `maxSessionSeconds`, `idleTimeoutSeconds` and
|
||||||
|
`secondFactor` (`"always"` or `"new-device"`). The strictest answer from all plugins wins, and a throw
|
||||||
|
refuses the login. A user who must have a second factor but has none gets a session that can only set one
|
||||||
|
up, and `/users/me` reports `restriction: "second-factor-setup"`.
|
||||||
|
|
||||||
|
Sync is answered by one plugin. `sync:provide` is high risk: `ctx.sync.provide({ handle })` takes over every
|
||||||
|
request under `/sync` (an Express style handler, 404 when nothing provides it), and `ctx.sync.host` hands
|
||||||
|
over the neutral parts core keeps: the replica store (`replica`), the change feed and push applier
|
||||||
|
(`feed`), the linked server list (`links`, one per local user), desktop accounts (`accounts`), sessions
|
||||||
|
(`sessions`), host lookups, plugin mirroring and the proxy settings the desktop app needs. A test passes its
|
||||||
|
own `syncHost` to `createMockCtx`. An API key can ask `POST /users/ws-ticket/api-key` for a one minute
|
||||||
|
ticket that opens plugin sockets as its user, which is how one server opens sessions through another.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
@@ -743,7 +894,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
|
|||||||
## SDK entry points
|
## SDK entry points
|
||||||
|
|
||||||
| Import | What it is |
|
| Import | What it is |
|
||||||
| -------------------------------------------------------------------- | --------------------------------------------------------------------- |
|
| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||||
| `@termix-ssh/plugin-sdk/backend` | `PluginContext` and the backend types |
|
| `@termix-ssh/plugin-sdk/backend` | `PluginContext` and the backend types |
|
||||||
| `@termix-ssh/plugin-sdk/frontend` | `TermixApp`, `TabProps` and the hooks |
|
| `@termix-ssh/plugin-sdk/frontend` | `TermixApp`, `TabProps` and the hooks |
|
||||||
| `@termix-ssh/plugin-sdk/ui` | The UI kit, served by Termix at runtime |
|
| `@termix-ssh/plugin-sdk/ui` | The UI kit, served by Termix at runtime |
|
||||||
@@ -755,6 +906,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
|
|||||||
| `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` |
|
| `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` |
|
||||||
| `@termix-ssh/plugin-sdk/host-commands` | OS detection, package manager and sudo helpers over ssh2 |
|
| `@termix-ssh/plugin-sdk/host-commands` | OS detection, package manager and sudo helpers over ssh2 |
|
||||||
| `@termix-ssh/plugin-sdk/ssh-certs` | `applyCertificateAuth()` for certificate based SSH auth |
|
| `@termix-ssh/plugin-sdk/ssh-certs` | `applyCertificateAuth()` for certificate based SSH auth |
|
||||||
|
| `@termix-ssh/plugin-sdk/ssh-keys` | `generateSshKeyPair()`, `detectSshKeyType()`, `publicKeyFromPrivate()`, `validateSshKeyPair()` |
|
||||||
| `@termix-ssh/plugin-sdk/desktop` | The desktop app bridge, typed. Check it exists before use |
|
| `@termix-ssh/plugin-sdk/desktop` | The desktop app bridge, typed. Check it exists before use |
|
||||||
| `@termix-ssh/plugin-sdk/openapi` | `buildOpenApi()` |
|
| `@termix-ssh/plugin-sdk/openapi` | `buildOpenApi()` |
|
||||||
| `@termix-ssh/plugin-sdk/changelog` | Changelog parsing and checks |
|
| `@termix-ssh/plugin-sdk/changelog` | Changelog parsing and checks |
|
||||||
@@ -764,7 +916,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
|
|||||||
## Capabilities
|
## Capabilities
|
||||||
|
|
||||||
| Capability | Risk | What it allows |
|
| Capability | Risk | What it allows |
|
||||||
| -------------------- | -------- | ------------------------------------------------------- |
|
| ------------------------ | -------- | ------------------------------------------------------------- |
|
||||||
| `credentials:read` | critical | Read stored secrets for hosts the user can reach |
|
| `credentials:read` | critical | Read stored secrets for hosts the user can reach |
|
||||||
| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
|
| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
|
||||||
| `ssh:connect` | high | Run any command on hosts, with the user's access |
|
| `ssh:connect` | high | Run any command on hosts, with the user's access |
|
||||||
@@ -773,9 +925,19 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
|
|||||||
| `users:impersonate` | high | Act as another user, or get a session for them, audited |
|
| `users:impersonate` | high | Act as another user, or get a session for them, audited |
|
||||||
| `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
|
| `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
|
||||||
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
|
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
|
||||||
|
| `auth:policy` | high | Set login rules: idle timeout, session length, 2FA |
|
||||||
|
| `sync:provide` | high | Run sync: answer every /sync request |
|
||||||
| `device:serial` | high | Open serial and USB ports on the server |
|
| `device:serial` | high | Open serial and USB ports on the server |
|
||||||
|
| `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) |
|
| `notify:hub` | high | Receive every alert (the alerts hub only) |
|
||||||
|
| `ui:layout` | high | Replace the whole app layout |
|
||||||
| `hosts:write` | medium | Add and edit hosts |
|
| `hosts:write` | medium | Add and edit hosts |
|
||||||
|
| `hosts:status-provide` | medium | Be the source of host status dots |
|
||||||
|
| `hosts:activity-provide` | medium | Be the source of the recent activity list |
|
||||||
|
| `ssh:transport` | high | Carry SSH connections for hosts it claims over its own stream |
|
||||||
|
| `hosts:lock` | medium | Make hosts it manages read only for everyone else |
|
||||||
|
| `network:probe` | medium | Check that a host answers on a TCP port |
|
||||||
| `credentials:use` | medium | Connect with saved credentials without seeing them |
|
| `credentials:use` | medium | Connect with saved credentials without seeing them |
|
||||||
| `credentials:write` | medium | Save new credentials |
|
| `credentials:write` | medium | Save new credentials |
|
||||||
| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
|
| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
|
||||||
@@ -794,6 +956,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
|
|||||||
| `settings:read-core` | low | Read Termix server settings |
|
| `settings:read-core` | low | Read Termix server settings |
|
||||||
| `plugins:read` | low | See installed plugins |
|
| `plugins:read` | low | See installed plugins |
|
||||||
| `ui:surface` | low | Add tabs, panels and settings |
|
| `ui:surface` | low | Add tabs, panels and settings |
|
||||||
|
| `ui:theme` | low | Add color themes, CSS only |
|
||||||
|
|
||||||
An update that adds a capability waits for an admin to approve it.
|
An update that adds a capability waits for an admin to approve it.
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ import { fileURLToPath } from "node:url";
|
|||||||
import * as esbuild from "esbuild";
|
import * as esbuild from "esbuild";
|
||||||
import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
|
import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
|
||||||
import { staticUrlImports } from "../lib/static-url-imports.mjs";
|
import { staticUrlImports } from "../lib/static-url-imports.mjs";
|
||||||
import { readManifest, resolveEntry, copyDir } from "../lib/plugin-dir.mjs";
|
import {
|
||||||
|
readManifest,
|
||||||
|
resolveEntry,
|
||||||
|
copyDir,
|
||||||
|
isThemeOnly,
|
||||||
|
} from "../lib/plugin-dir.mjs";
|
||||||
import { openapi } from "./openapi.mjs";
|
import { openapi } from "./openapi.mjs";
|
||||||
import { splitByCoreClasses } from "../lib/css-classes.mjs";
|
import { splitByCoreClasses } from "../lib/css-classes.mjs";
|
||||||
|
|
||||||
@@ -232,12 +237,13 @@ export async function build({ cwd }) {
|
|||||||
fs.mkdirSync(outDir, { recursive: true });
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
|
||||||
const backendEntry = resolveEntry(cwd, BACKEND_ENTRIES);
|
const backendEntry = resolveEntry(cwd, BACKEND_ENTRIES);
|
||||||
if (!backendEntry) {
|
if (!backendEntry && !isThemeOnly(manifest)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${pluginId}: no backend entry (looked for ${BACKEND_ENTRIES.join(", ")})`,
|
`${pluginId}: no backend entry (looked for ${BACKEND_ENTRIES.join(", ")})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (backendEntry) {
|
||||||
await esbuild.build({
|
await esbuild.build({
|
||||||
entryPoints: [backendEntry],
|
entryPoints: [backendEntry],
|
||||||
outfile: path.join(outDir, "backend.js"),
|
outfile: path.join(outDir, "backend.js"),
|
||||||
@@ -258,6 +264,14 @@ export async function build({ cwd }) {
|
|||||||
// ESM has no ambient require, so give esbuild's require shim a real one.
|
// ESM has no ambient require, so give esbuild's require shim a real one.
|
||||||
banner: { js: REQUIRE_BANNER },
|
banner: { js: REQUIRE_BANNER },
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Core loads every plugin through a backend entry, so a theme only
|
||||||
|
// plugin gets an empty one.
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(outDir, "backend.js"),
|
||||||
|
"export function activate() {}\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
for (const worker of readWorkerEntries(cwd, pluginId)) {
|
for (const worker of readWorkerEntries(cwd, pluginId)) {
|
||||||
await esbuild.build({
|
await esbuild.build({
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { readManifest } from "../lib/plugin-dir.mjs";
|
import { readManifest } from "../lib/plugin-dir.mjs";
|
||||||
import { collectFiles, createTmxplug } from "../lib/tmxplug.mjs";
|
import {
|
||||||
|
collectFiles,
|
||||||
|
createTmxplug,
|
||||||
|
themeFiles,
|
||||||
|
TMXPLUG_CONTENTS,
|
||||||
|
} from "../lib/tmxplug.mjs";
|
||||||
import { sha256 } from "../lib/signing.mjs";
|
import { sha256 } from "../lib/signing.mjs";
|
||||||
import { validate } from "./validate.mjs";
|
import { validate } from "./validate.mjs";
|
||||||
|
|
||||||
@@ -32,7 +37,12 @@ export async function pack({ cwd, args = [] }) {
|
|||||||
fs.mkdirSync(outDir, { recursive: true });
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
const outfile = path.join(outDir, `${pluginId}-${version}.tmxplug`);
|
const outfile = path.join(outDir, `${pluginId}-${version}.tmxplug`);
|
||||||
|
|
||||||
const archive = createTmxplug(cwd, collectFiles(cwd));
|
const archive = createTmxplug(
|
||||||
|
cwd,
|
||||||
|
collectFiles(cwd, [
|
||||||
|
...new Set([...TMXPLUG_CONTENTS, ...themeFiles(manifest)]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
fs.writeFileSync(outfile, archive);
|
fs.writeFileSync(outfile, archive);
|
||||||
|
|
||||||
console.log(`packed ${path.basename(outfile)}`);
|
console.log(`packed ${path.basename(outfile)}`);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { readManifest } from "../lib/plugin-dir.mjs";
|
import { readManifest, isThemeOnly } from "../lib/plugin-dir.mjs";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The manifest rules live in src/manifest.ts, which the server uses too, so
|
* The manifest rules live in src/manifest.ts, which the server uses too, so
|
||||||
@@ -17,7 +17,7 @@ export async function loadSdkModule(name) {
|
|||||||
|
|
||||||
export async function validate({ cwd }) {
|
export async function validate({ cwd }) {
|
||||||
const raw = readManifest(cwd);
|
const raw = readManifest(cwd);
|
||||||
const { parseManifest } = await loadSdkModule("manifest");
|
const { parseManifest, validateThemeCss } = await loadSdkModule("manifest");
|
||||||
const { errors } = parseManifest(raw);
|
const { errors } = parseManifest(raw);
|
||||||
|
|
||||||
const problems = [...errors];
|
const problems = [...errors];
|
||||||
@@ -25,7 +25,8 @@ export async function validate({ cwd }) {
|
|||||||
// The manifest names files. They have to be there.
|
// The manifest names files. They have to be there.
|
||||||
const referenced = [
|
const referenced = [
|
||||||
raw.backend ?? "dist/backend.js",
|
raw.backend ?? "dist/backend.js",
|
||||||
raw.frontend ?? "dist/frontend.js",
|
// A theme only plugin has no frontend code.
|
||||||
|
...(isThemeOnly(raw) ? [] : [raw.frontend ?? "dist/frontend.js"]),
|
||||||
raw.locales ?? "locales",
|
raw.locales ?? "locales",
|
||||||
];
|
];
|
||||||
for (const rel of referenced) {
|
for (const rel of referenced) {
|
||||||
@@ -34,6 +35,8 @@ export async function validate({ cwd }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
problems.push(...validateThemeFiles(cwd, raw, validateThemeCss));
|
||||||
|
problems.push(...validateLookCapabilities(cwd, raw));
|
||||||
problems.push(
|
problems.push(
|
||||||
...(await validateMigrations(cwd, raw.id ?? path.basename(cwd))),
|
...(await validateMigrations(cwd, raw.id ?? path.basename(cwd))),
|
||||||
);
|
);
|
||||||
@@ -53,6 +56,63 @@ export async function validate({ cwd }) {
|
|||||||
console.log(`ok ${raw.id ?? path.basename(cwd)}`);
|
console.log(`ok ${raw.id ?? path.basename(cwd)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Every declared theme file exists and holds only CSS a theme may hold. */
|
||||||
|
export function validateThemeFiles(cwd, raw, validateThemeCss) {
|
||||||
|
const problems = [];
|
||||||
|
const themes = raw?.contributes?.themes;
|
||||||
|
if (!Array.isArray(themes)) return problems;
|
||||||
|
for (const theme of themes) {
|
||||||
|
if (typeof theme?.css !== "string" || typeof theme?.id !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const file = path.join(cwd, theme.css);
|
||||||
|
if (!fs.existsSync(file)) {
|
||||||
|
problems.push(`theme ${theme.id}: ${theme.css} does not exist`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const error of validateThemeCss(
|
||||||
|
fs.readFileSync(file, "utf8"),
|
||||||
|
theme.id,
|
||||||
|
)) {
|
||||||
|
problems.push(`theme ${theme.id}: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ui:layout and ui:theme must match what the manifest contributes, both
|
||||||
|
* ways. The manifest check already flags a contribution without its
|
||||||
|
* capability; this flags a capability with nothing behind it, and a layout
|
||||||
|
* with no frontend to register it.
|
||||||
|
*/
|
||||||
|
export function validateLookCapabilities(cwd, raw) {
|
||||||
|
const problems = [];
|
||||||
|
const capabilities = Array.isArray(raw?.capabilities) ? raw.capabilities : [];
|
||||||
|
const layouts = raw?.contributes?.layouts;
|
||||||
|
const themes = raw?.contributes?.themes;
|
||||||
|
const hasLayouts = Array.isArray(layouts) && layouts.length > 0;
|
||||||
|
const hasThemes = Array.isArray(themes) && themes.length > 0;
|
||||||
|
if (capabilities.includes("ui:layout") && !hasLayouts) {
|
||||||
|
problems.push('"ui:layout" is declared but contributes.layouts is empty');
|
||||||
|
}
|
||||||
|
if (capabilities.includes("ui:theme") && !hasThemes) {
|
||||||
|
problems.push('"ui:theme" is declared but contributes.themes is empty');
|
||||||
|
}
|
||||||
|
if (hasLayouts) {
|
||||||
|
const entries = ["index.tsx", "index.ts", "index.mjs", "index.js"];
|
||||||
|
const frontend = entries.some((entry) =>
|
||||||
|
fs.existsSync(path.join(cwd, "src", "frontend", entry)),
|
||||||
|
);
|
||||||
|
if (!frontend && raw.frontend === undefined) {
|
||||||
|
problems.push(
|
||||||
|
"contributes.layouts needs a frontend that calls app.registerLayout",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems;
|
||||||
|
}
|
||||||
|
|
||||||
/** Env vars core sets for everyone, so a plugin need not list them. */
|
/** Env vars core sets for everyone, so a plugin need not list them. */
|
||||||
const CORE_ENV = new Set([
|
const CORE_ENV = new Set([
|
||||||
"DATA_DIR",
|
"DATA_DIR",
|
||||||
|
|||||||
@@ -14,6 +14,17 @@ export function readManifest(cwd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The first of these that exists, or null. */
|
/** The first of these that exists, or null. */
|
||||||
|
/** Only themes: CSS files and no code, so no source entry is needed. */
|
||||||
|
export function isThemeOnly(manifest) {
|
||||||
|
const capabilities = manifest.capabilities ?? [];
|
||||||
|
return (
|
||||||
|
capabilities.length > 0 &&
|
||||||
|
capabilities.every((capability) => capability === "ui:theme") &&
|
||||||
|
Array.isArray(manifest.contributes?.themes) &&
|
||||||
|
manifest.contributes.themes.length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveEntry(cwd, candidates) {
|
export function resolveEntry(cwd, candidates) {
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
const full = path.join(cwd, candidate);
|
const full = path.join(cwd, candidate);
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import zlib from "node:zlib";
|
import zlib from "node:zlib";
|
||||||
|
|
||||||
|
/** Theme CSS files a manifest declares, which travel with the plugin too. */
|
||||||
|
export function themeFiles(manifest) {
|
||||||
|
const themes = manifest?.contributes?.themes;
|
||||||
|
if (!Array.isArray(themes)) return [];
|
||||||
|
return themes
|
||||||
|
.map((theme) => theme?.css)
|
||||||
|
.filter((css) => typeof css === "string");
|
||||||
|
}
|
||||||
|
|
||||||
/** What a .tmxplug holds, in the order the spec lists it. */
|
/** What a .tmxplug holds, in the order the spec lists it. */
|
||||||
export const TMXPLUG_CONTENTS = [
|
export const TMXPLUG_CONTENTS = [
|
||||||
"manifest.json",
|
"manifest.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"name": "@termix-ssh/plugin-sdk",
|
||||||
"version": "1.0.11",
|
"version": "1.0.20",
|
||||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -85,6 +85,10 @@
|
|||||||
"types": "./dist/ssh-certs.d.ts",
|
"types": "./dist/ssh-certs.d.ts",
|
||||||
"default": "./dist/ssh-certs.js"
|
"default": "./dist/ssh-certs.js"
|
||||||
},
|
},
|
||||||
|
"./ssh-keys": {
|
||||||
|
"types": "./dist/ssh-keys.d.ts",
|
||||||
|
"default": "./dist/ssh-keys.js"
|
||||||
|
},
|
||||||
"./tsconfig.plugin.json": "./tsconfig.plugin.json",
|
"./tsconfig.plugin.json": "./tsconfig.plugin.json",
|
||||||
"./schema/manifest.schema.json": "./schema/manifest.schema.json",
|
"./schema/manifest.schema.json": "./schema/manifest.schema.json",
|
||||||
"./ui": {
|
"./ui": {
|
||||||
|
|||||||
@@ -82,7 +82,11 @@
|
|||||||
"features": {
|
"features": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"maxItems": 20,
|
"maxItems": 20,
|
||||||
"items": { "type": "string", "minLength": 1, "maxLength": 160 },
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 160
|
||||||
|
},
|
||||||
"description": "Short lines listing what the plugin does, shown on its page."
|
"description": "Short lines listing what the plugin does, shown on its page."
|
||||||
},
|
},
|
||||||
"docs": {
|
"docs": {
|
||||||
@@ -98,11 +102,24 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["name", "description"],
|
"required": ["name", "description"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,63}$" },
|
"name": {
|
||||||
"description": { "type": "string", "minLength": 1, "maxLength": 300 },
|
"type": "string",
|
||||||
"default": { "type": "string" },
|
"pattern": "^[A-Z][A-Z0-9_]{0,63}$"
|
||||||
"required": { "type": "boolean" },
|
},
|
||||||
"secret": { "type": "boolean" }
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 300
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"required": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"secret": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -134,9 +151,13 @@
|
|||||||
"process:spawn",
|
"process:spawn",
|
||||||
"users:write",
|
"users:write",
|
||||||
"auth:provide",
|
"auth:provide",
|
||||||
|
"auth:policy",
|
||||||
|
"sync:provide",
|
||||||
|
"ssh:transport",
|
||||||
"system:tls",
|
"system:tls",
|
||||||
"device:serial",
|
"device:serial",
|
||||||
"hosts:write",
|
"hosts:write",
|
||||||
|
"hosts:export",
|
||||||
"credentials:use",
|
"credentials:use",
|
||||||
"credentials:write",
|
"credentials:write",
|
||||||
"network:outbound",
|
"network:outbound",
|
||||||
@@ -146,7 +167,12 @@
|
|||||||
"events:core",
|
"events:core",
|
||||||
"notify:send",
|
"notify:send",
|
||||||
"audit:read",
|
"audit:read",
|
||||||
|
"network:probe",
|
||||||
|
"hosts:status-provide",
|
||||||
|
"hosts:activity-provide",
|
||||||
|
"hosts:lock",
|
||||||
"desktop:window",
|
"desktop:window",
|
||||||
|
"desktop:local",
|
||||||
"users:impersonate",
|
"users:impersonate",
|
||||||
"db:core-refs",
|
"db:core-refs",
|
||||||
"hosts:read",
|
"hosts:read",
|
||||||
@@ -155,7 +181,11 @@
|
|||||||
"files:own",
|
"files:own",
|
||||||
"secrets:own",
|
"secrets:own",
|
||||||
"settings:read-core",
|
"settings:read-core",
|
||||||
"ui:surface"
|
"ui:surface",
|
||||||
|
"notify:hub",
|
||||||
|
"plugins:read",
|
||||||
|
"ui:layout",
|
||||||
|
"ui:theme"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -944,7 +974,9 @@
|
|||||||
"defaultLevels": {
|
"defaultLevels": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 1,
|
"minItems": 1,
|
||||||
"items": { "enum": ["admin", "user", "folder"] },
|
"items": {
|
||||||
|
"enum": ["admin", "user", "folder"]
|
||||||
|
},
|
||||||
"description": "Levels that may set a default for the field. All three by default."
|
"description": "Levels that may set a default for the field. All three by default."
|
||||||
},
|
},
|
||||||
"personal": {
|
"personal": {
|
||||||
@@ -1127,6 +1159,86 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"pattern": "^[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+$"
|
"pattern": "^[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+$"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"layouts": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Whole-app layouts the frontend registers with app.registerLayout. Needs the ui:layout capability.",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["id", "label"],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z][a-z0-9-]{1,39}$"
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "i18n key in the plugin namespace, or plain text."
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"defaultTheme": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z][a-z0-9-]{1,39}$",
|
||||||
|
"description": "Theme picked with this layout when nobody chose one."
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "The layout a default-look plugin starts with. At most one."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"themes": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Themes: one CSS file each with token rules scoped under .<id>. Needs the ui:theme capability.",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["id", "label", "mode", "css"],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z][a-z0-9-]{1,39}$"
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["light", "dark"]
|
||||||
|
},
|
||||||
|
"css": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": ".css$",
|
||||||
|
"description": "Path of the CSS file inside the plugin."
|
||||||
|
},
|
||||||
|
"swatch": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["background", "panel", "text"],
|
||||||
|
"properties": {
|
||||||
|
"background": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^#[0-9a-fA-F]{3,8}$"
|
||||||
|
},
|
||||||
|
"panel": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^#[0-9a-fA-F]{3,8}$"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^#[0-9a-fA-F]{3,8}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1158,6 +1270,11 @@
|
|||||||
"enum": ["mirror", "local", "server"],
|
"enum": ["mirror", "local", "server"],
|
||||||
"description": "How a desktop linked to a server treats this plugin: mirror follows the server, local is decided on each desktop, server never runs on a linked desktop. Defaults to mirror."
|
"description": "How a desktop linked to a server treats this plugin: mirror follows the server, local is decided on each desktop, server never runs on a linked desktop. Defaults to mirror."
|
||||||
},
|
},
|
||||||
|
"runtime": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["desktop"],
|
||||||
|
"description": "desktop: the plugin only loads on a desktop app's own embedded backend, never on a server. It implies desktop local and may declare desktop:local."
|
||||||
|
},
|
||||||
"nativeDependencies": {
|
"nativeDependencies": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 1,
|
"minItems": 1,
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -47,20 +47,30 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
|||||||
entry("users:impersonate", "high"),
|
entry("users:impersonate", "high"),
|
||||||
entry("db:core-refs", "high"),
|
entry("db:core-refs", "high"),
|
||||||
entry("auth:provide", "high"),
|
entry("auth:provide", "high"),
|
||||||
|
entry("auth:policy", "high"),
|
||||||
|
entry("sync:provide", "high"),
|
||||||
|
entry("ssh:transport", "high"),
|
||||||
entry("device:serial", "high"),
|
entry("device:serial", "high"),
|
||||||
entry("notify:hub", "high"),
|
entry("notify:hub", "high"),
|
||||||
|
entry("ui:layout", "high"),
|
||||||
|
|
||||||
entry("hosts:write", "medium"),
|
entry("hosts:write", "medium"),
|
||||||
|
entry("hosts:export", "high"),
|
||||||
entry("credentials:use", "medium"),
|
entry("credentials:use", "medium"),
|
||||||
entry("credentials:write", "medium"),
|
entry("credentials:write", "medium"),
|
||||||
entry("network:outbound", "medium"),
|
entry("network:outbound", "medium"),
|
||||||
entry("network:serve", "medium"),
|
entry("network:serve", "medium"),
|
||||||
entry("network:broadcast", "medium"),
|
entry("network:broadcast", "medium"),
|
||||||
|
entry("network:probe", "medium"),
|
||||||
|
entry("hosts:status-provide", "medium"),
|
||||||
|
entry("hosts:activity-provide", "medium"),
|
||||||
|
entry("hosts:lock", "medium"),
|
||||||
entry("users:read", "medium"),
|
entry("users:read", "medium"),
|
||||||
entry("events:core", "medium"),
|
entry("events:core", "medium"),
|
||||||
entry("notify:send", "medium"),
|
entry("notify:send", "medium"),
|
||||||
entry("audit:read", "medium"),
|
entry("audit:read", "medium"),
|
||||||
entry("desktop:window", "medium"),
|
entry("desktop:window", "medium"),
|
||||||
|
entry("desktop:local", "high"),
|
||||||
|
|
||||||
entry("hosts:read", "low"),
|
entry("hosts:read", "low"),
|
||||||
entry("db:own", "low"),
|
entry("db:own", "low"),
|
||||||
@@ -70,6 +80,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
|||||||
entry("settings:read-core", "low"),
|
entry("settings:read-core", "low"),
|
||||||
entry("plugins:read", "low"),
|
entry("plugins:read", "low"),
|
||||||
entry("ui:surface", "low"),
|
entry("ui:surface", "low"),
|
||||||
|
entry("ui:theme", "low"),
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const CAPABILITY_IDS: readonly string[] = CAPABILITY_CATALOG.map(
|
export const CAPABILITY_IDS: readonly string[] = CAPABILITY_CATALOG.map(
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ export const LEGACY_TABLE_OWNERS: Readonly<Record<string, string>> = {
|
|||||||
homepage_items: "homepage",
|
homepage_items: "homepage",
|
||||||
homepage_layouts: "homepage",
|
homepage_layouts: "homepage",
|
||||||
dashboard_service_links: "homepage",
|
dashboard_service_links: "homepage",
|
||||||
|
recent_activity: "homepage",
|
||||||
secret_sources: "secret-sources",
|
secret_sources: "secret-sources",
|
||||||
opkssh_tokens: "opkssh",
|
opkssh_tokens: "opkssh",
|
||||||
vault_profiles: "vault",
|
vault_profiles: "vault",
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
* only inside the Termix desktop app; check isElectron before calling it.
|
* only inside the Termix desktop app; check isElectron before calling it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { PluginSshPromptRequest } from "./backend.js";
|
|
||||||
|
|
||||||
export interface ServerConfig {
|
export interface ServerConfig {
|
||||||
serverUrl?: string;
|
serverUrl?: string;
|
||||||
allowInvalidCertificate?: boolean;
|
allowInvalidCertificate?: boolean;
|
||||||
@@ -33,31 +31,21 @@ export interface DialogResult {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LocalDirectoryResult {
|
|
||||||
success: boolean;
|
|
||||||
path: string;
|
|
||||||
parent?: string;
|
|
||||||
entries: LocalFileEntry[];
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocalCollectedFile {
|
|
||||||
path: string;
|
|
||||||
name: string;
|
|
||||||
relativePath: string;
|
|
||||||
size: number;
|
|
||||||
created?: string;
|
|
||||||
modified: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LocalPathMutationResult =
|
|
||||||
| ({ success: true } & Partial<LocalFileEntry>)
|
|
||||||
| { success: false; error?: string };
|
|
||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
getAppVersion: () => Promise<string>;
|
getAppVersion: () => Promise<string>;
|
||||||
setZoomFactor?: (factor: number) => void;
|
setZoomFactor?: (factor: number) => void;
|
||||||
getPlatform: () => Promise<string>;
|
getPlatform: () => Promise<string>;
|
||||||
|
/** Whether the app-drawn title bar shows in this window. */
|
||||||
|
getTitleBarVisible?: () => Promise<boolean>;
|
||||||
|
/** Calls back when the title bar shows or hides; returns an unsubscribe. */
|
||||||
|
onTitleBarVisibleChange?: (
|
||||||
|
callback: (visible: boolean) => void,
|
||||||
|
) => () => void;
|
||||||
|
/** Keeps the window controls the system draws in the colors of the theme. */
|
||||||
|
setTitleBarOverlay?: (colors: {
|
||||||
|
color?: string;
|
||||||
|
symbolColor?: string;
|
||||||
|
}) => Promise<void>;
|
||||||
getSetting?: (key: string) => Promise<string | null | undefined>;
|
getSetting?: (key: string) => Promise<string | null | undefined>;
|
||||||
setSetting?: (key: string, value: string) => Promise<void>;
|
setSetting?: (key: string, value: string) => Promise<void>;
|
||||||
|
|
||||||
@@ -67,60 +55,6 @@ export interface ElectronAPI {
|
|||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
allowInvalidCertificate?: boolean,
|
allowInvalidCertificate?: boolean,
|
||||||
) => Promise<ConnectionTestResult>;
|
) => Promise<ConnectionTestResult>;
|
||||||
answerC2SAuth: (id: string, answer: string | null) => Promise<boolean>;
|
|
||||||
onC2SAuthPrompt: (
|
|
||||||
callback: (prompt: {
|
|
||||||
id: string;
|
|
||||||
closed?: boolean;
|
|
||||||
tunnelName?: string;
|
|
||||||
request?: PluginSshPromptRequest;
|
|
||||||
}) => void,
|
|
||||||
) => () => void;
|
|
||||||
getC2STunnelConfig: () => Promise<unknown[]>;
|
|
||||||
saveC2STunnelConfig: (
|
|
||||||
config: unknown[],
|
|
||||||
) => Promise<{ success: boolean; error?: string }>;
|
|
||||||
checkLocalPortAvailable: (
|
|
||||||
host: string,
|
|
||||||
port: number,
|
|
||||||
) => Promise<{ available: boolean; error?: string }>;
|
|
||||||
getC2STunnelPresetDefaultName: () => Promise<string>;
|
|
||||||
startC2STunnel: (
|
|
||||||
tunnel: unknown,
|
|
||||||
index: number,
|
|
||||||
authToken?: string,
|
|
||||||
) => Promise<{
|
|
||||||
success: boolean;
|
|
||||||
tunnelName?: string;
|
|
||||||
error?: string;
|
|
||||||
code?: string;
|
|
||||||
}>;
|
|
||||||
testC2STunnel: (
|
|
||||||
tunnel: unknown,
|
|
||||||
index: number,
|
|
||||||
authToken?: string,
|
|
||||||
) => Promise<{
|
|
||||||
success: boolean;
|
|
||||||
message?: string;
|
|
||||||
error?: string;
|
|
||||||
code?: string;
|
|
||||||
}>;
|
|
||||||
stopC2STunnel: (
|
|
||||||
tunnelName: string,
|
|
||||||
) => Promise<{ success: boolean; error?: string }>;
|
|
||||||
getC2STunnelStatuses: () => Promise<Record<string, unknown>>;
|
|
||||||
onC2STunnelStatuses?: (
|
|
||||||
callback: (statuses: Record<string, unknown>) => void,
|
|
||||||
) => () => void;
|
|
||||||
startC2SAutoStartTunnels: () => Promise<{
|
|
||||||
success: boolean;
|
|
||||||
started: number;
|
|
||||||
errors: string[];
|
|
||||||
}>;
|
|
||||||
/** Where the linked server relays client tunnel streams, a /plugin-ws/ path. */
|
|
||||||
setC2SRelayPath?: (
|
|
||||||
relayPath: string,
|
|
||||||
) => Promise<{ success: boolean; error?: string }>;
|
|
||||||
onCloseActiveTab?: (callback: () => void) => () => void;
|
onCloseActiveTab?: (callback: () => void) => () => void;
|
||||||
clearSessionCookies: () => Promise<void>;
|
clearSessionCookies: () => Promise<void>;
|
||||||
getSessionCookie: (
|
getSessionCookie: (
|
||||||
@@ -136,62 +70,6 @@ export interface ElectronAPI {
|
|||||||
|
|
||||||
showSaveDialog: (options: DialogOptions) => Promise<DialogResult>;
|
showSaveDialog: (options: DialogOptions) => Promise<DialogResult>;
|
||||||
showOpenDialog: (options: DialogOptions) => Promise<DialogResult>;
|
showOpenDialog: (options: DialogOptions) => Promise<DialogResult>;
|
||||||
getLocalHomeDirectory?: () => Promise<string>;
|
|
||||||
listLocalDirectory?: (path?: string) => Promise<LocalDirectoryResult>;
|
|
||||||
statLocalPaths?: (
|
|
||||||
paths: string[],
|
|
||||||
) => Promise<
|
|
||||||
Array<
|
|
||||||
| (LocalFileEntry & { success: true })
|
|
||||||
| { success: false; path: string; error?: string }
|
|
||||||
>
|
|
||||||
>;
|
|
||||||
collectLocalFiles?: (paths: string[]) => Promise<{
|
|
||||||
success: boolean;
|
|
||||||
files: LocalCollectedFile[];
|
|
||||||
truncated?: boolean;
|
|
||||||
error?: string;
|
|
||||||
}>;
|
|
||||||
createLocalFolder?: (
|
|
||||||
parentPath: string,
|
|
||||||
folderName: string,
|
|
||||||
) => Promise<LocalPathMutationResult>;
|
|
||||||
renameLocalPath?: (
|
|
||||||
path: string,
|
|
||||||
newName: string,
|
|
||||||
) => Promise<LocalPathMutationResult>;
|
|
||||||
trashLocalPath?: (path: string) => Promise<LocalPathMutationResult>;
|
|
||||||
chmodLocalPath?: (
|
|
||||||
path: string,
|
|
||||||
permissions: string,
|
|
||||||
) => Promise<LocalPathMutationResult>;
|
|
||||||
|
|
||||||
openExternalEditor: (fileData: {
|
|
||||||
fileName: string;
|
|
||||||
content: string;
|
|
||||||
encoding?: "utf8" | "base64";
|
|
||||||
editorPath?: string | null;
|
|
||||||
}) => Promise<{
|
|
||||||
success: boolean;
|
|
||||||
editId?: string;
|
|
||||||
path?: string;
|
|
||||||
error?: string;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
closeExternalEditor: (editId: string) => Promise<{
|
|
||||||
success: boolean;
|
|
||||||
error?: string;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
onExternalEditorSaved?: (
|
|
||||||
callback: (payload: {
|
|
||||||
editId: string;
|
|
||||||
content: string;
|
|
||||||
encoding: "utf8";
|
|
||||||
path: string;
|
|
||||||
}) => void,
|
|
||||||
) => () => void;
|
|
||||||
|
|
||||||
onUpdateAvailable: (callback: () => void) => void;
|
onUpdateAvailable: (callback: () => void) => void;
|
||||||
onUpdateDownloaded: (callback: () => void) => void;
|
onUpdateDownloaded: (callback: () => void) => void;
|
||||||
|
|
||||||
@@ -238,159 +116,6 @@ export interface ElectronAPI {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
startLocalTerminal(dimensions: {
|
|
||||||
cols: number;
|
|
||||||
rows: number;
|
|
||||||
shell?: "default" | "wsl";
|
|
||||||
}): Promise<{ sessionId: string; shell: string }>;
|
|
||||||
readyLocalTerminal(sessionId: string): Promise<boolean>;
|
|
||||||
writeLocalTerminal(sessionId: string, data: string): Promise<boolean>;
|
|
||||||
resizeLocalTerminal(
|
|
||||||
sessionId: string,
|
|
||||||
cols: number,
|
|
||||||
rows: number,
|
|
||||||
): Promise<boolean>;
|
|
||||||
closeLocalTerminal(sessionId: string): Promise<boolean>;
|
|
||||||
onLocalTerminalData(
|
|
||||||
sessionId: string,
|
|
||||||
callback: (data: string) => void,
|
|
||||||
): () => void;
|
|
||||||
onLocalTerminalExit(
|
|
||||||
sessionId: string,
|
|
||||||
callback: (exitCode: number) => void,
|
|
||||||
): () => void;
|
|
||||||
|
|
||||||
/** Local disk browsing for the dual-pane file manager (desktop only). */
|
|
||||||
localFs?: {
|
|
||||||
home: () => Promise<LocalFsResult<LocalFsHomeInfo>>;
|
|
||||||
list: (dirPath: string) => Promise<LocalFsResult<LocalDirectoryListing>>;
|
|
||||||
mkdir: (
|
|
||||||
parentPath: string,
|
|
||||||
name: string,
|
|
||||||
) => Promise<LocalFsResult<{ path: string }>>;
|
|
||||||
createFile: (
|
|
||||||
parentPath: string,
|
|
||||||
name: string,
|
|
||||||
) => Promise<LocalFsResult<{ path: string }>>;
|
|
||||||
rename: (
|
|
||||||
oldPath: string,
|
|
||||||
newName: string,
|
|
||||||
) => Promise<LocalFsResult<{ path: string }>>;
|
|
||||||
trash: (paths: string[]) => Promise<LocalFsResult<LocalTrashResult>>;
|
|
||||||
ensureDir: (
|
|
||||||
dirPath: string,
|
|
||||||
rootPath?: string,
|
|
||||||
) => Promise<LocalFsResult<{ path: string }>>;
|
|
||||||
exists: (paths: string[]) => Promise<LocalFsResult<{ existing: string[] }>>;
|
|
||||||
walk: (paths: string[]) => Promise<LocalFsResult<LocalWalkResult>>;
|
|
||||||
reveal: (targetPath: string) => Promise<LocalFsResult<unknown>>;
|
|
||||||
open: (targetPath: string) => Promise<LocalFsResult<unknown>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Streamed local<->remote transfers driven by the main process. */
|
|
||||||
localTransfer?: {
|
|
||||||
/** The /plugin-api/<id> path that serves the streaming routes. */
|
|
||||||
setApiPath?: (apiPath: string) => Promise<LocalFsResult<object>>;
|
|
||||||
upload: (
|
|
||||||
options: LocalUploadRequest,
|
|
||||||
) => Promise<LocalFsResult<{ bytes: number }>>;
|
|
||||||
download: (
|
|
||||||
options: LocalDownloadRequest,
|
|
||||||
) => Promise<LocalFsResult<{ path: string }>>;
|
|
||||||
cancel: (
|
|
||||||
transferId: string,
|
|
||||||
) => Promise<LocalFsResult<{ cancelled: boolean }>>;
|
|
||||||
onProgress: (
|
|
||||||
callback: (payload: LocalTransferProgress) => void,
|
|
||||||
) => () => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LocalFsResult<T> =
|
|
||||||
({ success: true } & T) | { success: false; error: string; code?: string };
|
|
||||||
|
|
||||||
export interface LocalFsHomeInfo {
|
|
||||||
home: string;
|
|
||||||
separator: string;
|
|
||||||
platform: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocalFileEntry {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
type: "file" | "directory" | "link" | "other";
|
|
||||||
created?: string;
|
|
||||||
modified?: string;
|
|
||||||
permissions?: string;
|
|
||||||
owner?: string;
|
|
||||||
group?: string;
|
|
||||||
size: number;
|
|
||||||
modifiedTimestamp?: number;
|
|
||||||
linkTarget?: string;
|
|
||||||
hidden: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocalDirectoryListing {
|
|
||||||
path: string;
|
|
||||||
parent: string | null;
|
|
||||||
entries: LocalFileEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocalTrashResult {
|
|
||||||
trashed: number;
|
|
||||||
failed: Array<{ path: string; error: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocalWalkFile {
|
|
||||||
localPath: string;
|
|
||||||
/** Path relative to the drop root; "/"-separated and includes the root's own name. */
|
|
||||||
relativePath: string;
|
|
||||||
size: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocalWalkResult {
|
|
||||||
files: LocalWalkFile[];
|
|
||||||
emptyDirs: string[];
|
|
||||||
totalBytes: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which Termix backend a transfer talks to. The main process resolves the
|
|
||||||
* actual URL and credentials for the origin; the renderer never supplies them.
|
|
||||||
*/
|
|
||||||
export type LocalTransferOrigin = "local" | "remote";
|
|
||||||
|
|
||||||
export interface LocalUploadRequest {
|
|
||||||
transferId: string;
|
|
||||||
origin: LocalTransferOrigin;
|
|
||||||
fields: Record<string, string>;
|
|
||||||
localPath: string;
|
|
||||||
fileName: string;
|
|
||||||
deviceId?: string;
|
|
||||||
/** The renderer's token for the embedded backend (origin "local"); sent as a Bearer header. */
|
|
||||||
authToken?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocalDownloadRequest {
|
|
||||||
transferId: string;
|
|
||||||
origin: LocalTransferOrigin;
|
|
||||||
body: Record<string, unknown>;
|
|
||||||
destPath: string;
|
|
||||||
/** Selected download folder; destPath must resolve strictly inside it (code EINVAL otherwise). */
|
|
||||||
rootPath: string;
|
|
||||||
expectedSize?: number;
|
|
||||||
/** Replace an existing file at destPath; otherwise the transfer is refused with code EEXIST. */
|
|
||||||
overwrite?: boolean;
|
|
||||||
deviceId?: string;
|
|
||||||
/** The renderer's token for the embedded backend (origin "local"); sent as a Bearer header. */
|
|
||||||
authToken?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocalTransferProgress {
|
|
||||||
transferId: string;
|
|
||||||
transferred: number;
|
|
||||||
total?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -248,6 +248,129 @@ export interface TabsApi extends Pick<
|
|||||||
onReady: (listener: () => void) => Disposer;
|
onReady: (listener: () => void) => Disposer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Layouts (plugin API 1.4)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A whole-app layout. Core renders the chosen one inside its providers and an
|
||||||
|
* error boundary; everything it shows comes from useShell(). Declare it in
|
||||||
|
* manifest contributes.layouts and add the ui:layout capability.
|
||||||
|
*/
|
||||||
|
export interface LayoutContribution {
|
||||||
|
id: string;
|
||||||
|
/** Your own i18n key or plain text, the same as the manifest's label. */
|
||||||
|
label: string;
|
||||||
|
component: ComponentType;
|
||||||
|
/** Shown on phones and narrow windows. The desktop app never uses it. */
|
||||||
|
mobileComponent?: ComponentType;
|
||||||
|
/** Theme to start with when nobody chose one. */
|
||||||
|
defaultTheme?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tab as a layout sees it. Splits are tabs too, with a `split` field. */
|
||||||
|
export interface ShellTabState extends PluginTabRecord {
|
||||||
|
instanceId: string;
|
||||||
|
customLabel?: string;
|
||||||
|
/** Set while the tab sits in a split pane. */
|
||||||
|
parentSplitTabId?: string;
|
||||||
|
openedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a dock region shows and how it is sized, in Normal-size pixels. */
|
||||||
|
export interface ShellDockState {
|
||||||
|
view: string | null;
|
||||||
|
open: boolean;
|
||||||
|
width: number;
|
||||||
|
dragging: boolean;
|
||||||
|
setView: (view: string | null) => void;
|
||||||
|
setWidth: (width: number) => void;
|
||||||
|
/** Starts a drag from the dock's edge, from a mousedown. */
|
||||||
|
startResize: (event: { clientX: number; preventDefault: () => void }) => void;
|
||||||
|
resetWidth: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The live shell: state and actions, nothing drawn. Read with useShell(). */
|
||||||
|
export interface ShellController {
|
||||||
|
tabs: {
|
||||||
|
list: ShellTabState[];
|
||||||
|
/** What a tab bar shows: tabs not sitting in a split. */
|
||||||
|
topLevel: ShellTabState[];
|
||||||
|
activeId: string;
|
||||||
|
/** True once saved tabs were restored after login. */
|
||||||
|
ready: boolean;
|
||||||
|
/** The user turned on tab numbers. */
|
||||||
|
showNumbers: boolean;
|
||||||
|
setActive: (id: string) => void;
|
||||||
|
reorder: (topLevel: ShellTabState[]) => void;
|
||||||
|
close: (id: string) => Promise<void>;
|
||||||
|
rename: (id: string, label: string) => void;
|
||||||
|
/** Reconnects or reloads a session tab. */
|
||||||
|
refresh: (id: string) => void;
|
||||||
|
reconnectAll: () => void;
|
||||||
|
openSingleton: (type: string) => string | undefined;
|
||||||
|
};
|
||||||
|
split: {
|
||||||
|
/** The split shown now, or null. */
|
||||||
|
activeId: string | null;
|
||||||
|
focusedPaneId: string | null;
|
||||||
|
/** Every split with its panes, for a split menu. */
|
||||||
|
summaries: Array<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
full: boolean;
|
||||||
|
panes: Array<{
|
||||||
|
id: string;
|
||||||
|
index: number;
|
||||||
|
tabId: string | null;
|
||||||
|
label: string | null;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
activeFull: boolean;
|
||||||
|
/** Split a tab, apply a preset, unsplit, add to a pane. */
|
||||||
|
handleAction: (action: Record<string, unknown> & { kind: string }) => void;
|
||||||
|
};
|
||||||
|
hosts: {
|
||||||
|
list: PluginHostRecord[];
|
||||||
|
loading: boolean;
|
||||||
|
loaded: boolean;
|
||||||
|
reload: () => Promise<void>;
|
||||||
|
};
|
||||||
|
regions: {
|
||||||
|
left: ShellDockState & { view: string };
|
||||||
|
right: ShellDockState;
|
||||||
|
/** What a rail click does: toggles or switches the left dock. */
|
||||||
|
railClick: (view: string) => void;
|
||||||
|
openInRightDock: (view: string) => void;
|
||||||
|
toggleRightDock: () => void;
|
||||||
|
};
|
||||||
|
palette: { isOpen: boolean; open: () => void; close: () => void };
|
||||||
|
fullscreen: { active: boolean; toggle: () => void };
|
||||||
|
/** The same callbacks tabs and panels get. */
|
||||||
|
actions: ShellApi;
|
||||||
|
env: {
|
||||||
|
isMobile: boolean;
|
||||||
|
isElectron: boolean;
|
||||||
|
user: { username: string; isAdmin: boolean };
|
||||||
|
permissions: string[];
|
||||||
|
branding: {
|
||||||
|
appName: string | null;
|
||||||
|
tagline: string | null;
|
||||||
|
logo: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** Core registries, as hooks a layout calls while rendering. */
|
||||||
|
registries: {
|
||||||
|
useRailItems: () => ReadonlyArray<{ id: string } & Record<string, unknown>>;
|
||||||
|
usePanels: () => ReadonlyArray<{ id: string } & Record<string, unknown>>;
|
||||||
|
useTabTypes: () => ReadonlyArray<{ id: string } & Record<string, unknown>>;
|
||||||
|
useDashboardCards: () => ReadonlyArray<
|
||||||
|
{ id: string } & Record<string, unknown>
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
logout: (options?: { manual?: boolean }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Registrations
|
// Registrations
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -530,8 +653,16 @@ export interface HostBadgeContribution {
|
|||||||
id: string;
|
id: string;
|
||||||
when: (host: PluginHostRecord) => boolean;
|
when: (host: PluginHostRecord) => boolean;
|
||||||
component: ComponentType<{ host: PluginHostRecord }>;
|
component: ComponentType<{ host: PluginHostRecord }>;
|
||||||
|
/** Lower comes first. Ties sort by id. */
|
||||||
|
order?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The dot or stripe that shows a host's status on its row. */
|
||||||
|
export type HostStatusIndicatorContribution = HostBadgeContribution;
|
||||||
|
|
||||||
|
/** A control in the host editor's toolbar. */
|
||||||
|
export type HostEditorToolbarItemContribution = HostBadgeContribution;
|
||||||
|
|
||||||
export interface HostContextMenuItemContribution {
|
export interface HostContextMenuItemContribution {
|
||||||
id: string;
|
id: string;
|
||||||
titleKey: string;
|
titleKey: string;
|
||||||
@@ -683,6 +814,66 @@ export interface SettingsComponentProps {
|
|||||||
running: boolean;
|
running: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a companion starts with. Read from the plugin's own settings. */
|
||||||
|
export interface CompanionDefaults {
|
||||||
|
/** Open by itself the first time a matching tab shows. */
|
||||||
|
open?: boolean;
|
||||||
|
side?: "left" | "right" | "bottom";
|
||||||
|
/** Pixels of width, or height at the bottom. */
|
||||||
|
size?: number;
|
||||||
|
/** Show the edge button that opens and closes it. Default true. */
|
||||||
|
edgeButton?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A panel a tab type can show beside it, for a picker. */
|
||||||
|
export interface CompanionInfo {
|
||||||
|
/** The id registerCompanionLayout's hidden and order lists use. */
|
||||||
|
id: string;
|
||||||
|
titleKey: string;
|
||||||
|
icon?: IconComponent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a host wants the panel bar beside a tab. */
|
||||||
|
export interface CompanionLayout {
|
||||||
|
/** False removes the bar and its panels. */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Where a panel opens until the user moves it. */
|
||||||
|
side?: "left" | "right" | "bottom";
|
||||||
|
/** Companion ids in bar order. */
|
||||||
|
order?: string[];
|
||||||
|
/** Companion ids left out. */
|
||||||
|
hidden?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanionProps {
|
||||||
|
/** The tab it sits beside. */
|
||||||
|
tab: PluginTabRecord;
|
||||||
|
host?: PluginHostRecord;
|
||||||
|
/** The host in the shape the connection APIs take. */
|
||||||
|
sshHost?: Record<string, unknown>;
|
||||||
|
/** The tab's surface handle (see TabHandle), read at call time. */
|
||||||
|
getHandle: () => TabHandle | null;
|
||||||
|
side: "left" | "right" | "bottom";
|
||||||
|
/** False while the tab is in the background or the panel is closed. */
|
||||||
|
isVisible: boolean;
|
||||||
|
close: () => void;
|
||||||
|
setSide: (side: "left" | "right" | "bottom") => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanionOptions {
|
||||||
|
icon?: IconComponent;
|
||||||
|
titleKey?: string;
|
||||||
|
order?: number;
|
||||||
|
/** Tab types it applies to. Any tab with a host when empty. */
|
||||||
|
tabTypes?: string[];
|
||||||
|
when?: (tab: PluginTabRecord, host?: PluginHostRecord) => boolean;
|
||||||
|
/**
|
||||||
|
* A hook returning the starting state. It runs in the plugin's scope, so it
|
||||||
|
* can read the plugin's settings.
|
||||||
|
*/
|
||||||
|
useDefaults?: () => CompanionDefaults;
|
||||||
|
}
|
||||||
|
|
||||||
export type ActionHandler = (...args: never[]) => unknown;
|
export type ActionHandler = (...args: never[]) => unknown;
|
||||||
|
|
||||||
export interface ActionSlotDefinition {
|
export interface ActionSlotDefinition {
|
||||||
@@ -715,10 +906,11 @@ export type OnboardingMode = "full" | "partial" | "rerun";
|
|||||||
* Where a plugin step sits in onboarding. "setup" comes right after the
|
* Where a plugin step sits in onboarding. "setup" comes right after the
|
||||||
* appearance steps, for things that need doing before the feature works
|
* appearance steps, for things that need doing before the feature works
|
||||||
* (like pointing at a guacd server). "explore" (the default) comes after
|
* (like pointing at a guacd server). "explore" (the default) comes after
|
||||||
* that, "security" after the account security step. Onboarding is for
|
* that, "security" after the account security step. "start" comes before
|
||||||
|
* the appearance steps, for choices that shape how the rest looks. Onboarding is for
|
||||||
* choices, not for explaining features.
|
* choices, not for explaining features.
|
||||||
*/
|
*/
|
||||||
export type OnboardingSection = "setup" | "explore" | "security";
|
export type OnboardingSection = "start" | "setup" | "explore" | "security";
|
||||||
|
|
||||||
export interface OnboardingStepProps {
|
export interface OnboardingStepProps {
|
||||||
/**
|
/**
|
||||||
@@ -761,6 +953,16 @@ export interface OnboardingStepContribution {
|
|||||||
permission?: string;
|
permission?: string;
|
||||||
section?: OnboardingSection;
|
section?: OnboardingSection;
|
||||||
order?: number;
|
order?: number;
|
||||||
|
/**
|
||||||
|
* Exact place in the flow, 1 to 99, instead of a section. Core steps sit at
|
||||||
|
* 10 (plugins), 20, 25, 30 (preset), 40 (appearance) and 90 (security).
|
||||||
|
*/
|
||||||
|
position?: number;
|
||||||
|
/**
|
||||||
|
* Older step keys this step took over, like "preset". People who already
|
||||||
|
* saw one of them are not shown this step again.
|
||||||
|
*/
|
||||||
|
replaces?: string[];
|
||||||
when?: (context: {
|
when?: (context: {
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
isDesktop: boolean;
|
isDesktop: boolean;
|
||||||
@@ -926,12 +1128,40 @@ export interface TermixApp extends TermixAppInfo {
|
|||||||
component: ComponentType<TabProps>,
|
component: ComponentType<TabProps>,
|
||||||
options?: TabOptions,
|
options?: TabOptions,
|
||||||
) => Disposer;
|
) => Disposer;
|
||||||
|
/**
|
||||||
|
* A panel that sits beside any host tab the options match (a terminal, a
|
||||||
|
* desktop, ...), bound to that tab's host. The user opens, resizes and moves
|
||||||
|
* it; the tab keeps running underneath.
|
||||||
|
*/
|
||||||
|
registerCompanion: (
|
||||||
|
id: string,
|
||||||
|
component: ComponentType<CompanionProps>,
|
||||||
|
options?: CompanionOptions,
|
||||||
|
) => Disposer;
|
||||||
|
/**
|
||||||
|
* Lets a plugin decide, per host, how the panel bar looks on one tab type.
|
||||||
|
* Read from the host payload, so it must not call hooks.
|
||||||
|
*/
|
||||||
|
registerCompanionLayout: (
|
||||||
|
tabType: string,
|
||||||
|
resolve: (host: PluginHostRecord | undefined) => CompanionLayout,
|
||||||
|
) => Disposer;
|
||||||
|
/** Opens a companion on a tab, or closes it when it is showing. */
|
||||||
|
toggleCompanion: (tab: PluginTabRecord, id?: string) => void;
|
||||||
registerHostEditorSection: (
|
registerHostEditorSection: (
|
||||||
section: HostEditorSectionContribution,
|
section: HostEditorSectionContribution,
|
||||||
) => Disposer;
|
) => Disposer;
|
||||||
registerHostAction: (action: HostActionContribution) => Disposer;
|
registerHostAction: (action: HostActionContribution) => Disposer;
|
||||||
registerHostProtocol: (protocol: HostProtocolContribution) => Disposer;
|
registerHostProtocol: (protocol: HostProtocolContribution) => Disposer;
|
||||||
registerHostBadge: (badge: HostBadgeContribution) => Disposer;
|
registerHostBadge: (badge: HostBadgeContribution) => Disposer;
|
||||||
|
/** Draws a host row's status. A layout renders every registered one. */
|
||||||
|
registerHostStatusIndicator: (
|
||||||
|
indicator: HostStatusIndicatorContribution,
|
||||||
|
) => Disposer;
|
||||||
|
/** Adds a control to the host editor's toolbar. */
|
||||||
|
registerHostEditorToolbarItem: (
|
||||||
|
item: HostEditorToolbarItemContribution,
|
||||||
|
) => Disposer;
|
||||||
registerHostContextMenuItem: (
|
registerHostContextMenuItem: (
|
||||||
item: HostContextMenuItemContribution,
|
item: HostContextMenuItemContribution,
|
||||||
) => Disposer;
|
) => Disposer;
|
||||||
@@ -988,6 +1218,12 @@ export interface TermixApp extends TermixAppInfo {
|
|||||||
registerSshAuthEditor: (editor: SshAuthEditorContribution) => Disposer;
|
registerSshAuthEditor: (editor: SshAuthEditorContribution) => Disposer;
|
||||||
registerLoginMethod: (method: LoginMethodContribution) => Disposer;
|
registerLoginMethod: (method: LoginMethodContribution) => Disposer;
|
||||||
registerSecondFactorUI: (factor: SecondFactorContribution) => Disposer;
|
registerSecondFactorUI: (factor: SecondFactorContribution) => Disposer;
|
||||||
|
/**
|
||||||
|
* A whole-app layout. Declared in manifest contributes.layouts with the
|
||||||
|
* ui:layout capability. Only the chosen layout's plugin is loaded, so
|
||||||
|
* register it in activate. Needs plugin API 1.4.
|
||||||
|
*/
|
||||||
|
registerLayout: (layout: LayoutContribution) => Disposer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Translates a key from the plugin's own locales, for code outside a
|
* Translates a key from the plugin's own locales, for code outside a
|
||||||
@@ -1122,6 +1358,7 @@ export interface PluginHostBridge {
|
|||||||
useHosts: () => { hosts: PluginHostRecord[]; loaded: boolean };
|
useHosts: () => { hosts: PluginHostRecord[]; loaded: boolean };
|
||||||
useCurrentUser: () => CurrentUser | null;
|
useCurrentUser: () => CurrentUser | null;
|
||||||
useTheme: () => { theme: "light" | "dark" };
|
useTheme: () => { theme: "light" | "dark" };
|
||||||
|
useShell: () => ShellController | null;
|
||||||
toast: ToastApi;
|
toast: ToastApi;
|
||||||
getApi: (pluginId: string) => PluginApiClient;
|
getApi: (pluginId: string) => PluginApiClient;
|
||||||
getApiFor: (pluginId: string, origin: unknown) => PluginApiClient;
|
getApiFor: (pluginId: string, origin: unknown) => PluginApiClient;
|
||||||
@@ -1138,6 +1375,7 @@ export interface PluginHostBridge {
|
|||||||
hostProtocols: (host: PluginHostRecord) => string[];
|
hostProtocols: (host: PluginHostRecord) => string[];
|
||||||
useHostStatus: (hostId: number | undefined) => HostStatusInfo | null;
|
useHostStatus: (hostId: number | undefined) => HostStatusInfo | null;
|
||||||
useHostActions: () => HostActionContribution[];
|
useHostActions: () => HostActionContribution[];
|
||||||
|
useCompanions: (tabType: string) => CompanionInfo[];
|
||||||
useActivityTypes: () => string[];
|
useActivityTypes: () => string[];
|
||||||
activityTarget: (type: string) => ActivityTargetInfo | undefined;
|
activityTarget: (type: string) => ActivityTargetInfo | undefined;
|
||||||
useExtensions: (pointId: string) => ExtensionContribution[];
|
useExtensions: (pointId: string) => ExtensionContribution[];
|
||||||
@@ -1372,6 +1610,14 @@ export function useTheme(): { theme: "light" | "dark" } {
|
|||||||
return requireHost().useTheme();
|
return requireHost().useTheme();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The live shell, for a layout. Null outside the signed-in app. Needs plugin
|
||||||
|
* API 1.4.
|
||||||
|
*/
|
||||||
|
export function useShell(): ShellController | null {
|
||||||
|
return requireHost().useShell();
|
||||||
|
}
|
||||||
|
|
||||||
export function useToast(): ToastApi {
|
export function useToast(): ToastApi {
|
||||||
return requireHost().toast;
|
return requireHost().toast;
|
||||||
}
|
}
|
||||||
@@ -1465,28 +1711,49 @@ export function usePluginUiPreferences<
|
|||||||
return bridge.usePluginUiPreferences(bridge.usePluginId()) as never;
|
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
|
* Grid or list, row density and cards per row for one of this plugin's
|
||||||
* Appearance area as `<prefix>viewMode` and `<prefix>density`. Declare those
|
* views, kept in its Appearance area as `<prefix>viewMode`, `<prefix>density`
|
||||||
* keys in contributes.uiPresets so each preset level can pick a default.
|
* 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 = ""): {
|
export function usePluginPanelView(prefix = ""): {
|
||||||
view: "grid" | "list";
|
view: "grid" | "list";
|
||||||
density: "comfortable" | "compact";
|
density: "comfortable" | "compact";
|
||||||
compact: boolean;
|
compact: boolean;
|
||||||
|
columns: PanelGridSize;
|
||||||
setView: (view: "grid" | "list") => void;
|
setView: (view: "grid" | "list") => void;
|
||||||
setDensity: (density: "comfortable" | "compact") => void;
|
setDensity: (density: "comfortable" | "compact") => void;
|
||||||
|
setColumns: (columns: PanelGridSize) => void;
|
||||||
} {
|
} {
|
||||||
const { values, set } = usePluginUiPreferences();
|
const { values, set } = usePluginUiPreferences();
|
||||||
const view = values[`${prefix}viewMode`] === "list" ? "list" : "grid";
|
const view = values[`${prefix}viewMode`] === "list" ? "list" : "grid";
|
||||||
const density =
|
const density =
|
||||||
values[`${prefix}density`] === "compact" ? "compact" : "comfortable";
|
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 {
|
return {
|
||||||
view,
|
view,
|
||||||
density,
|
density,
|
||||||
compact: density === "compact",
|
compact: density === "compact",
|
||||||
|
columns,
|
||||||
setView: (next) => set(`${prefix}viewMode`, next),
|
setView: (next) => set(`${prefix}viewMode`, next),
|
||||||
setDensity: (next) => set(`${prefix}density`, next),
|
setDensity: (next) => set(`${prefix}density`, next),
|
||||||
|
setColumns: (next) => set(`${prefix}columns`, next),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1539,6 +1806,14 @@ export function useHostActions(): HostActionContribution[] {
|
|||||||
return requireHost().useHostActions();
|
return requireHost().useHostActions();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every panel that can open beside a tab of this type, for a settings screen
|
||||||
|
* that lets the user hide or reorder them. Reactive.
|
||||||
|
*/
|
||||||
|
export function useCompanions(tabType: string): CompanionInfo[] {
|
||||||
|
return requireHost().useCompanions(tabType);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recent-activity types every registered tab claims (registerTab's
|
* Recent-activity types every registered tab claims (registerTab's
|
||||||
* activityTypes), core's and plugins'. For a filter that lists what a user
|
* activityTypes), core's and plugins'. For a filter that lists what a user
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
type PluginEnvVar,
|
type PluginEnvVar,
|
||||||
} from "./docs.js";
|
} from "./docs.js";
|
||||||
|
|
||||||
|
export { validateThemeCss } from "./theme-css.js";
|
||||||
export {
|
export {
|
||||||
ENV_NAME_PATTERN,
|
ENV_NAME_PATTERN,
|
||||||
MAX_ENV_DESCRIPTION_LENGTH,
|
MAX_ENV_DESCRIPTION_LENGTH,
|
||||||
@@ -36,7 +37,7 @@ export {
|
|||||||
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
||||||
* version by the server.
|
* version by the server.
|
||||||
*/
|
*/
|
||||||
export const PLUGIN_API_VERSION = "1.3.0";
|
export const PLUGIN_API_VERSION = "1.12.0";
|
||||||
|
|
||||||
/** The API major, the default engine.api for a new plugin. */
|
/** The API major, the default engine.api for a new plugin. */
|
||||||
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
||||||
@@ -426,6 +427,40 @@ export interface PluginAuthContribution {
|
|||||||
keyboardInteractive?: string[];
|
keyboardInteractive?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A layout id or theme id: short, lowercase, used as a CSS class for themes. */
|
||||||
|
export const LOOK_ID_PATTERN = /^[a-z][a-z0-9-]{1,39}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A whole-app layout the frontend registers with app.registerLayout. Needs
|
||||||
|
* the ui:layout capability. Declared here so core can list and choose it
|
||||||
|
* before any plugin code runs.
|
||||||
|
*/
|
||||||
|
export interface PluginLayoutContribution {
|
||||||
|
id: string;
|
||||||
|
/** i18n key in the plugin's namespace, or plain text. */
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
/** Theme picked with this layout when nobody chose one. */
|
||||||
|
defaultTheme?: string;
|
||||||
|
/** The layout a default-look plugin starts with. */
|
||||||
|
default?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A theme: one CSS file of token rules scoped under `.<id>`, applied as a
|
||||||
|
* class on <html>. Needs the ui:theme capability. No code runs for a theme.
|
||||||
|
*/
|
||||||
|
export interface PluginThemeContribution {
|
||||||
|
id: string;
|
||||||
|
/** i18n key in the plugin's namespace, or plain text. */
|
||||||
|
label: string;
|
||||||
|
mode: "light" | "dark";
|
||||||
|
/** Path of the CSS file inside the plugin. */
|
||||||
|
css: string;
|
||||||
|
/** Colors the theme picker previews it with. */
|
||||||
|
swatch?: { background: string; panel: string; text: string };
|
||||||
|
}
|
||||||
|
|
||||||
export interface PluginContributions {
|
export interface PluginContributions {
|
||||||
tabs?: PluginTabContribution[];
|
tabs?: PluginTabContribution[];
|
||||||
panels?: PluginViewContribution[];
|
panels?: PluginViewContribution[];
|
||||||
@@ -487,6 +522,10 @@ export interface PluginContributions {
|
|||||||
* ctx.credentials.resolveHostProtocol.
|
* ctx.credentials.resolveHostProtocol.
|
||||||
*/
|
*/
|
||||||
protocols?: PluginProtocolContribution[];
|
protocols?: PluginProtocolContribution[];
|
||||||
|
/** Whole-app layouts. Needs the ui:layout capability. */
|
||||||
|
layouts?: PluginLayoutContribution[];
|
||||||
|
/** Themes. Needs the ui:theme capability. */
|
||||||
|
themes?: PluginThemeContribution[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A field a protocol login carries besides username and password. */
|
/** A field a protocol login carries besides username and password. */
|
||||||
@@ -649,6 +688,21 @@ export interface PluginManifest {
|
|||||||
nativeDependencies?: string[];
|
nativeDependencies?: string[];
|
||||||
/** How a linked desktop treats this plugin. Defaults to "mirror". */
|
/** How a linked desktop treats this plugin. Defaults to "mirror". */
|
||||||
desktop?: PluginDesktopMode;
|
desktop?: PluginDesktopMode;
|
||||||
|
/**
|
||||||
|
* "desktop": the plugin only loads on a desktop app's own embedded backend,
|
||||||
|
* never on a server. It implies desktop "local" and may declare desktop:local.
|
||||||
|
*/
|
||||||
|
runtime?: PluginRuntime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginRuntime = "desktop";
|
||||||
|
export const PLUGIN_RUNTIMES: readonly PluginRuntime[] = ["desktop"];
|
||||||
|
|
||||||
|
/** The desktop mode a manifest resolves to. */
|
||||||
|
export function resolvePluginDesktopMode(
|
||||||
|
m: Pick<PluginManifest, "desktop" | "runtime">,
|
||||||
|
): PluginDesktopMode {
|
||||||
|
return m.desktop ?? (m.runtime === "desktop" ? "local" : "mirror");
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_BACKEND_ENTRY = "dist/backend.js";
|
export const DEFAULT_BACKEND_ENTRY = "dist/backend.js";
|
||||||
@@ -688,6 +742,7 @@ const ALLOWED_TOP_LEVEL = new Set([
|
|||||||
"platforms",
|
"platforms",
|
||||||
"nativeDependencies",
|
"nativeDependencies",
|
||||||
"desktop",
|
"desktop",
|
||||||
|
"runtime",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ALLOWED_CONTRIBUTES = new Set([
|
const ALLOWED_CONTRIBUTES = new Set([
|
||||||
@@ -708,6 +763,8 @@ const ALLOWED_CONTRIBUTES = new Set([
|
|||||||
"uses",
|
"uses",
|
||||||
"keybindingActions",
|
"keybindingActions",
|
||||||
"protocols",
|
"protocols",
|
||||||
|
"layouts",
|
||||||
|
"themes",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ALLOWED_SETTINGS_FIELD = [
|
const ALLOWED_SETTINGS_FIELD = [
|
||||||
@@ -863,6 +920,21 @@ export function validateManifest(manifest: unknown): string[] {
|
|||||||
`Field "desktop" must be one of: ${PLUGIN_DESKTOP_MODES.join(", ")}`,
|
`Field "desktop" must be one of: ${PLUGIN_DESKTOP_MODES.join(", ")}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
m.runtime !== undefined &&
|
||||||
|
!PLUGIN_RUNTIMES.includes(m.runtime as PluginRuntime)
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
`Field "runtime" must be one of: ${PLUGIN_RUNTIMES.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
m.runtime === "desktop" &&
|
||||||
|
m.desktop !== undefined &&
|
||||||
|
m.desktop !== "local"
|
||||||
|
) {
|
||||||
|
errors.push(`A "desktop" runtime plugin must use desktop "local"`);
|
||||||
|
}
|
||||||
validateDependencyMap(m.dependencies, "dependencies", errors);
|
validateDependencyMap(m.dependencies, "dependencies", errors);
|
||||||
validateDependencyMap(m.optionalDependencies, "optionalDependencies", errors);
|
validateDependencyMap(m.optionalDependencies, "optionalDependencies", errors);
|
||||||
validateProvides(m.provides, errors);
|
validateProvides(m.provides, errors);
|
||||||
@@ -1300,6 +1372,111 @@ function validateContributes(
|
|||||||
validateUiPresets(contributes.uiPresets, errors);
|
validateUiPresets(contributes.uiPresets, errors);
|
||||||
validateKeybindingActions(contributes.keybindingActions, errors);
|
validateKeybindingActions(contributes.keybindingActions, errors);
|
||||||
validateProtocols(contributes.protocols, errors);
|
validateProtocols(contributes.protocols, errors);
|
||||||
|
validateLayouts(contributes.layouts, errors);
|
||||||
|
validateThemes(contributes.themes, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An array of objects with unique look ids; calls `each` for every valid entry. */
|
||||||
|
function validateLookList(
|
||||||
|
value: unknown,
|
||||||
|
field: string,
|
||||||
|
allowed: string[],
|
||||||
|
errors: string[],
|
||||||
|
each: (raw: Record<string, unknown>, where: string) => void,
|
||||||
|
): void {
|
||||||
|
if (value === undefined) return;
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
errors.push(`Field "contributes.${field}" must be an array`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const seen = new Set<string>();
|
||||||
|
value.forEach((raw, index) => {
|
||||||
|
const where = `contributes.${field}[${index}]`;
|
||||||
|
if (!isPlainObject(raw)) {
|
||||||
|
errors.push(`${where} must be an object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rejectUnknown(raw, allowed, where, errors);
|
||||||
|
if (typeof raw.id !== "string" || !LOOK_ID_PATTERN.test(raw.id)) {
|
||||||
|
errors.push(
|
||||||
|
`${where}.id must match ${LOOK_ID_PATTERN} (lowercase letters, digits, dashes)`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (seen.has(raw.id)) errors.push(`${where}.id duplicates "${raw.id}"`);
|
||||||
|
seen.add(raw.id);
|
||||||
|
}
|
||||||
|
requireString(raw.label, `${where}.label`, errors);
|
||||||
|
each(raw, where);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateLayouts(value: unknown, errors: string[]): void {
|
||||||
|
validateLookList(
|
||||||
|
value,
|
||||||
|
"layouts",
|
||||||
|
["id", "label", "description", "defaultTheme", "default"],
|
||||||
|
errors,
|
||||||
|
(raw, where) => {
|
||||||
|
if (raw.description !== undefined)
|
||||||
|
requireString(raw.description, `${where}.description`, errors);
|
||||||
|
if (
|
||||||
|
raw.defaultTheme !== undefined &&
|
||||||
|
(typeof raw.defaultTheme !== "string" ||
|
||||||
|
!LOOK_ID_PATTERN.test(raw.defaultTheme))
|
||||||
|
) {
|
||||||
|
errors.push(`${where}.defaultTheme must be a theme id`);
|
||||||
|
}
|
||||||
|
if (raw.default !== undefined && typeof raw.default !== "boolean") {
|
||||||
|
errors.push(`${where}.default must be a boolean`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
Array.isArray(value) &&
|
||||||
|
value.filter((raw) => isPlainObject(raw) && raw.default === true).length > 1
|
||||||
|
) {
|
||||||
|
errors.push('Only one entry in "contributes.layouts" may set default');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEX_COLOR = /^#[0-9a-fA-F]{3,8}$/;
|
||||||
|
|
||||||
|
function validateThemes(value: unknown, errors: string[]): void {
|
||||||
|
validateLookList(
|
||||||
|
value,
|
||||||
|
"themes",
|
||||||
|
["id", "label", "mode", "css", "swatch"],
|
||||||
|
errors,
|
||||||
|
(raw, where) => {
|
||||||
|
if (raw.mode !== "light" && raw.mode !== "dark") {
|
||||||
|
errors.push(`${where}.mode must be "light" or "dark"`);
|
||||||
|
}
|
||||||
|
if (requireString(raw.css, `${where}.css`, errors)) {
|
||||||
|
requireRelativePath(raw.css, `${where}.css`, errors);
|
||||||
|
if (!raw.css.endsWith(".css")) {
|
||||||
|
errors.push(`${where}.css must be a .css file`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (raw.swatch !== undefined) {
|
||||||
|
if (!isPlainObject(raw.swatch)) {
|
||||||
|
errors.push(`${where}.swatch must be an object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rejectUnknown(
|
||||||
|
raw.swatch,
|
||||||
|
["background", "panel", "text"],
|
||||||
|
`${where}.swatch`,
|
||||||
|
errors,
|
||||||
|
);
|
||||||
|
for (const key of ["background", "panel", "text"]) {
|
||||||
|
const color = raw.swatch[key];
|
||||||
|
if (typeof color !== "string" || !HEX_COLOR.test(color)) {
|
||||||
|
errors.push(`${where}.swatch.${key} must be a hex color`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PROTOCOL_ID_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
|
export const PROTOCOL_ID_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
|
||||||
@@ -2324,6 +2501,23 @@ export function parseManifest(raw: unknown): ParsedManifest {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(manifest.contributes?.layouts?.length ?? 0) > 0 &&
|
||||||
|
!manifest.capabilities.includes("ui:layout")
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
'contributes.layouts needs the "ui:layout" capability in the capabilities array',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(manifest.contributes?.themes?.length ?? 0) > 0 &&
|
||||||
|
!manifest.capabilities.includes("ui:theme")
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
'contributes.themes needs the "ui:theme" capability in the capabilities array',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
for (const secret of manifest.requiresSecret ?? []) {
|
for (const secret of manifest.requiresSecret ?? []) {
|
||||||
if (secret.plugin === manifest.id) {
|
if (secret.plugin === manifest.id) {
|
||||||
errors.push(
|
errors.push(
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* SSH key helpers for plugins that make, check or deploy keys: generate a key
|
||||||
|
* pair, tell what a key is, derive a public key and check a pair belongs
|
||||||
|
* together. Pure functions over ssh2, which the host supplies. Core keeps its
|
||||||
|
* own copy for the connect pipeline.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import ssh2 from "ssh2";
|
||||||
|
|
||||||
|
export type SshKeyKind = "ssh-ed25519" | "ssh-rsa" | "ecdsa-sha2-nistp256";
|
||||||
|
|
||||||
|
export interface GeneratedSshKeyPair {
|
||||||
|
privateKey: string;
|
||||||
|
publicKey: string;
|
||||||
|
keyType: SshKeyKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateSshKeyPairOptions {
|
||||||
|
/** RSA only. 2048 to 8192, default 4096. */
|
||||||
|
bits?: number;
|
||||||
|
passphrase?: string;
|
||||||
|
comment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Makes a new key pair; the public half is one authorized_keys line. */
|
||||||
|
export function generateSshKeyPair(
|
||||||
|
type: SshKeyKind,
|
||||||
|
options: GenerateSshKeyPairOptions = {},
|
||||||
|
): GeneratedSshKeyPair {
|
||||||
|
const algorithm =
|
||||||
|
type === "ssh-rsa" ? "rsa" : type === "ssh-ed25519" ? "ed25519" : "ecdsa";
|
||||||
|
if (
|
||||||
|
type !== "ssh-rsa" &&
|
||||||
|
type !== "ssh-ed25519" &&
|
||||||
|
type !== "ecdsa-sha2-nistp256"
|
||||||
|
) {
|
||||||
|
throw new Error(`Unsupported key type: ${String(type)}`);
|
||||||
|
}
|
||||||
|
const generateOptions: Record<string, unknown> = {
|
||||||
|
comment: options.comment ?? "",
|
||||||
|
};
|
||||||
|
if (options.passphrase) {
|
||||||
|
generateOptions.passphrase = options.passphrase;
|
||||||
|
generateOptions.cipher = "aes256-ctr";
|
||||||
|
}
|
||||||
|
if (type === "ssh-rsa") {
|
||||||
|
const bits = options.bits ?? 4096;
|
||||||
|
if (!Number.isInteger(bits) || bits < 2048 || bits > 8192) {
|
||||||
|
throw new Error("RSA keys need 2048 to 8192 bits");
|
||||||
|
}
|
||||||
|
generateOptions.bits = bits;
|
||||||
|
}
|
||||||
|
if (type === "ecdsa-sha2-nistp256") generateOptions.bits = 256;
|
||||||
|
|
||||||
|
const pair = ssh2.utils.generateKeyPairSync(
|
||||||
|
algorithm as "rsa",
|
||||||
|
generateOptions as never,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
privateKey: pair.private,
|
||||||
|
publicKey: pair.public.trim(),
|
||||||
|
keyType: type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(key: string, passphrase?: string) {
|
||||||
|
const parsed = ssh2.utils.parseKey(key.trim(), passphrase || undefined);
|
||||||
|
const first = Array.isArray(parsed) ? parsed[0] : parsed;
|
||||||
|
if (!first || first instanceof Error) {
|
||||||
|
throw first instanceof Error ? first : new Error("Not a readable SSH key");
|
||||||
|
}
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The key's algorithm, like "ssh-ed25519", or null when it cannot be read. */
|
||||||
|
export function detectSshKeyType(
|
||||||
|
key: string,
|
||||||
|
passphrase?: string,
|
||||||
|
): string | null {
|
||||||
|
try {
|
||||||
|
return parse(key, passphrase).type;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The authorized_keys line for a private key, or null. */
|
||||||
|
export function publicKeyFromPrivate(
|
||||||
|
privateKey: string,
|
||||||
|
passphrase?: string,
|
||||||
|
): string | null {
|
||||||
|
try {
|
||||||
|
const parsed = parse(privateKey, passphrase);
|
||||||
|
return `${parsed.type} ${parsed.getPublicSSH().toString("base64")}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a public key line belongs to the private key. */
|
||||||
|
export function validateSshKeyPair(
|
||||||
|
privateKey: string,
|
||||||
|
publicKey: string,
|
||||||
|
passphrase?: string,
|
||||||
|
): { isValid: boolean; error?: string } {
|
||||||
|
try {
|
||||||
|
const priv = parse(privateKey, passphrase);
|
||||||
|
const pub = parse(publicKey);
|
||||||
|
const same = priv.getPublicSSH().equals(pub.getPublicSSH());
|
||||||
|
return same
|
||||||
|
? { isValid: true }
|
||||||
|
: {
|
||||||
|
isValid: false,
|
||||||
|
error: "The public key does not match the private key",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,10 @@ import {
|
|||||||
PluginCapabilityError,
|
PluginCapabilityError,
|
||||||
} from "./backend.js";
|
} from "./backend.js";
|
||||||
import type {
|
import type {
|
||||||
|
PluginAuditEntry,
|
||||||
|
PluginAuditFilters,
|
||||||
PluginContext,
|
PluginContext,
|
||||||
|
PluginDesktopLocal,
|
||||||
PluginDisposables,
|
PluginDisposables,
|
||||||
PluginLogger,
|
PluginLogger,
|
||||||
} from "./backend.js";
|
} from "./backend.js";
|
||||||
@@ -65,12 +68,19 @@ import type {
|
|||||||
PluginSshKeyCredential,
|
PluginSshKeyCredential,
|
||||||
PluginSshKeyCredentialInput,
|
PluginSshKeyCredentialInput,
|
||||||
} from "./backend.js";
|
} from "./backend.js";
|
||||||
import type { SyncEntityRegistration } from "./backend.js";
|
import type { PluginSyncHost, SyncEntityRegistration } from "./backend.js";
|
||||||
import type { PluginDatabase } from "./backend.js";
|
import type { PluginDatabase } from "./backend.js";
|
||||||
import type {
|
import type {
|
||||||
PluginExternalClientRequest,
|
PluginExternalClientRequest,
|
||||||
PluginProtocolTarget,
|
PluginProtocolTarget,
|
||||||
|
PluginHostProbeTarget,
|
||||||
PluginHostStatusEntry,
|
PluginHostStatusEntry,
|
||||||
|
PluginActivityProvider,
|
||||||
|
PluginHostLockProvider,
|
||||||
|
PluginAuthPolicyProvider,
|
||||||
|
PluginCredentialSummary,
|
||||||
|
PluginHostShareGrant,
|
||||||
|
PluginHostStatusProvider,
|
||||||
PluginNotification,
|
PluginNotification,
|
||||||
PluginNotificationChannel,
|
PluginNotificationChannel,
|
||||||
PluginNotifyHub,
|
PluginNotifyHub,
|
||||||
@@ -94,6 +104,8 @@ export interface FakeScheduledJob {
|
|||||||
|
|
||||||
export interface FakeContextOptions {
|
export interface FakeContextOptions {
|
||||||
pluginId?: string;
|
pluginId?: string;
|
||||||
|
/** What ctx.sync.host answers, for a sync provider's tests. */
|
||||||
|
syncHost?: Partial<PluginSyncHost>;
|
||||||
manifest?: Partial<PluginManifest>;
|
manifest?: Partial<PluginManifest>;
|
||||||
/** Acting user returned by currentActor and used by asUser. */
|
/** Acting user returned by currentActor and used by asUser. */
|
||||||
actor?: string;
|
actor?: string;
|
||||||
@@ -101,6 +113,8 @@ export interface FakeContextOptions {
|
|||||||
settings?: Record<string, unknown>;
|
settings?: Record<string, unknown>;
|
||||||
/** Seed for the core settings ctx.settings.readCore can reach. */
|
/** Seed for the core settings ctx.settings.readCore can reach. */
|
||||||
coreSettings?: Record<string, string>;
|
coreSettings?: Record<string, string>;
|
||||||
|
/** What ctx.files.dataDir() answers. Defaults to /tmp/<pluginId>. */
|
||||||
|
dataDir?: string;
|
||||||
/** What ctx.ssh.connect and withConnection hand back as the client. */
|
/** What ctx.ssh.connect and withConnection hand back as the client. */
|
||||||
sshClient?: unknown;
|
sshClient?: unknown;
|
||||||
/** A real database behind ctx.db, usually createTestDb().database. */
|
/** A real database behind ctx.db, usually createTestDb().database. */
|
||||||
@@ -132,10 +146,22 @@ export interface FakeContextOptions {
|
|||||||
protocolTargets?: Record<string, PluginProtocolTarget>;
|
protocolTargets?: Record<string, PluginProtocolTarget>;
|
||||||
/** What ctx.credentials.listSshKeys answers. */
|
/** What ctx.credentials.listSshKeys answers. */
|
||||||
sshKeyCredentials?: PluginSshKeyCredential[];
|
sshKeyCredentials?: PluginSshKeyCredential[];
|
||||||
|
/** What ctx.credentials.list answers. */
|
||||||
|
credentialSummaries?: PluginCredentialSummary[];
|
||||||
/** What ctx.desktop.available() answers. Defaults to false. */
|
/** What ctx.desktop.available() answers. Defaults to false. */
|
||||||
desktopAvailable?: boolean;
|
desktopAvailable?: boolean;
|
||||||
|
/** What ctx.desktop.local is. Calls throw when this is not given. */
|
||||||
|
desktopLocal?: PluginDesktopLocal;
|
||||||
|
/** The stored audit lines ctx.audit.list and exportBatches serve. */
|
||||||
|
auditEntries?: PluginAuditEntry[];
|
||||||
/** What ctx.hosts.status.get and check answer, by host id. */
|
/** What ctx.hosts.status.get and check answer, by host id. */
|
||||||
hostStatuses?: Record<number, PluginHostStatusEntry>;
|
hostStatuses?: Record<number, PluginHostStatusEntry>;
|
||||||
|
/** What ctx.hosts.probeTargets answers. */
|
||||||
|
probeTargets?: PluginHostProbeTarget[];
|
||||||
|
/** Host ids ctx.hosts.sessionActive answers true for. */
|
||||||
|
activeSessions?: number[];
|
||||||
|
/** What ctx.network.tcpProbe and ctx.ssh.probe answer. Defaults to false. */
|
||||||
|
reachable?: (target: string | number, port?: number) => boolean;
|
||||||
/** The acting user's channels, what ctx.notify.channels answers. */
|
/** The acting user's channels, what ctx.notify.channels answers. */
|
||||||
notificationChannels?: PluginNotificationChannel[];
|
notificationChannels?: PluginNotificationChannel[];
|
||||||
/**
|
/**
|
||||||
@@ -172,6 +198,8 @@ export interface FakeContextOptions {
|
|||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
/** What ctx.auth.countLinkedUsers answers, by provider. Defaults to 0. */
|
/** What ctx.auth.countLinkedUsers answers, by provider. Defaults to 0. */
|
||||||
linkedUsers?: Record<string, number>;
|
linkedUsers?: Record<string, number>;
|
||||||
|
/** Second factors from other plugins that ctx.auth.listSecondFactors adds. */
|
||||||
|
secondFactors?: { id: string; pluginId: string }[];
|
||||||
/** Failures before ctx.auth.loginRateLimit locks a key. Defaults to 5. */
|
/** Failures before ctx.auth.loginRateLimit locks a key. Defaults to 5. */
|
||||||
loginAttemptLimit?: number;
|
loginAttemptLimit?: number;
|
||||||
/** What ctx.users.getLocale answers, by user id. A missing user answers null. */
|
/** What ctx.users.getLocale answers, by user id. A missing user answers null. */
|
||||||
@@ -251,6 +279,8 @@ export interface FakeAuthRegistrations {
|
|||||||
loginFailures: Map<string, number>;
|
loginFailures: Map<string, number>;
|
||||||
/** Every ctx.auth.issueUserToken call, in order. */
|
/** Every ctx.auth.issueUserToken call, in order. */
|
||||||
userTokens: Array<{ userId: string; purpose: string; ttlSeconds?: number }>;
|
userTokens: Array<{ userId: string; purpose: string; ttlSeconds?: number }>;
|
||||||
|
/** Providers passed to ctx.auth.policy.provide that are still registered. */
|
||||||
|
policies: PluginAuthPolicyProvider[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FakePluginContext {
|
export interface FakePluginContext {
|
||||||
@@ -263,6 +293,12 @@ export interface FakePluginContext {
|
|||||||
) => Promise<Record<string, string>>;
|
) => Promise<Record<string, string>>;
|
||||||
/** Everything the plugin registered through ctx.disposables. */
|
/** Everything the plugin registered through ctx.disposables. */
|
||||||
disposals: Array<() => void | Promise<void>>;
|
disposals: Array<() => void | Promise<void>>;
|
||||||
|
/** Providers registered through ctx.hosts.status.provide. */
|
||||||
|
hostStatusProviders: PluginHostStatusProvider[];
|
||||||
|
/** Every provider passed to ctx.hosts.activity.provide that is still registered. */
|
||||||
|
activityProviders: PluginActivityProvider[];
|
||||||
|
/** Every provider passed to ctx.hosts.locks.provide that is still registered. */
|
||||||
|
hostLockProviders: PluginHostLockProvider[];
|
||||||
/** Topics emitted through ctx.events.emit, in order. */
|
/** Topics emitted through ctx.events.emit, in order. */
|
||||||
emitted: Array<{ topic: string; payload: unknown }>;
|
emitted: Array<{ topic: string; payload: unknown }>;
|
||||||
/** Backing store behind ctx.kv. */
|
/** Backing store behind ctx.kv. */
|
||||||
@@ -295,6 +331,8 @@ export interface FakePluginContext {
|
|||||||
permissionLevel: string;
|
permissionLevel: string;
|
||||||
durationHours?: number;
|
durationHours?: number;
|
||||||
}>;
|
}>;
|
||||||
|
/** Every ctx.hosts.setShares call, in order. */
|
||||||
|
hostShareSets: Array<{ hostId: number; grants: PluginHostShareGrant[] }>;
|
||||||
/** Everything registered through ctx.auth. */
|
/** Everything registered through ctx.auth. */
|
||||||
auth: FakeAuthRegistrations;
|
auth: FakeAuthRegistrations;
|
||||||
/** Every ctx.desktop.openIsolatedWindow call, in order. */
|
/** Every ctx.desktop.openIsolatedWindow call, in order. */
|
||||||
@@ -338,6 +376,8 @@ export interface FakePluginContext {
|
|||||||
challenges: Map<string, string>;
|
challenges: Map<string, string>;
|
||||||
renewers: number;
|
renewers: number;
|
||||||
};
|
};
|
||||||
|
/** Calls every ctx.audit.subscribe handler, like a line core just accepted. */
|
||||||
|
emitAudit: (entry: PluginAuditEntry) => void;
|
||||||
/** Every ctx.audit.record entry, in order. */
|
/** Every ctx.audit.record entry, in order. */
|
||||||
audits: Array<{ action: string; success: boolean; [key: string]: unknown }>;
|
audits: Array<{ action: string; success: boolean; [key: string]: unknown }>;
|
||||||
/** Every ctx.hosts.recordActivity call, in order. */
|
/** Every ctx.hosts.recordActivity call, in order. */
|
||||||
@@ -464,7 +504,14 @@ function applyHostInput(
|
|||||||
const {
|
const {
|
||||||
tags,
|
tags,
|
||||||
pluginSettings: _settings,
|
pluginSettings: _settings,
|
||||||
|
pluginEnable: _pluginEnable,
|
||||||
forceKeyboardInteractive: _fki,
|
forceKeyboardInteractive: _fki,
|
||||||
|
password: _password,
|
||||||
|
key: _key,
|
||||||
|
keyPassword: _keyPassword,
|
||||||
|
keyType: _keyType,
|
||||||
|
sudoPassword: _sudoPassword,
|
||||||
|
protocolAuth: _protocolAuth,
|
||||||
...rest
|
...rest
|
||||||
} = input;
|
} = input;
|
||||||
const next: PluginHostRecord = { ...record };
|
const next: PluginHostRecord = { ...record };
|
||||||
@@ -491,6 +538,9 @@ export function createFakeContext(
|
|||||||
const pluginId = options.pluginId ?? "test-plugin";
|
const pluginId = options.pluginId ?? "test-plugin";
|
||||||
const disposals: Array<() => void | Promise<void>> = [];
|
const disposals: Array<() => void | Promise<void>> = [];
|
||||||
const emitted: Array<{ topic: string; payload: unknown }> = [];
|
const emitted: Array<{ topic: string; payload: unknown }> = [];
|
||||||
|
const hostStatusProviders: PluginHostStatusProvider[] = [];
|
||||||
|
const activityProviders: PluginActivityProvider[] = [];
|
||||||
|
const hostLockProviders: PluginHostLockProvider[] = [];
|
||||||
const kv = new Map<string, unknown>();
|
const kv = new Map<string, unknown>();
|
||||||
const tables: PluginTableDefinition[] = [];
|
const tables: PluginTableDefinition[] = [];
|
||||||
const syncEntities: SyncEntityRegistration[] = [];
|
const syncEntities: SyncEntityRegistration[] = [];
|
||||||
@@ -508,6 +558,7 @@ export function createFakeContext(
|
|||||||
}>();
|
}>();
|
||||||
const sshConnections: FakePluginContext["sshConnections"] = [];
|
const sshConnections: FakePluginContext["sshConnections"] = [];
|
||||||
const hostShares: FakePluginContext["hostShares"] = [];
|
const hostShares: FakePluginContext["hostShares"] = [];
|
||||||
|
const hostShareSets: FakePluginContext["hostShareSets"] = [];
|
||||||
const desktopWindows: FakePluginContext["desktopWindows"] = [];
|
const desktopWindows: FakePluginContext["desktopWindows"] = [];
|
||||||
const externalClientLaunches: FakePluginContext["externalClientLaunches"] =
|
const externalClientLaunches: FakePluginContext["externalClientLaunches"] =
|
||||||
[];
|
[];
|
||||||
@@ -531,6 +582,7 @@ export function createFakeContext(
|
|||||||
};
|
};
|
||||||
const secretStore = new Map<string, string>();
|
const secretStore = new Map<string, string>();
|
||||||
const audits: FakePluginContext["audits"] = [];
|
const audits: FakePluginContext["audits"] = [];
|
||||||
|
const auditSubscribers = new Set<(entry: PluginAuditEntry) => void>();
|
||||||
const activities: FakePluginContext["activities"] = [];
|
const activities: FakePluginContext["activities"] = [];
|
||||||
const trackedSessions: number[] = [];
|
const trackedSessions: number[] = [];
|
||||||
const interactions: FakePluginContext["interactions"] = [];
|
const interactions: FakePluginContext["interactions"] = [];
|
||||||
@@ -579,6 +631,7 @@ export function createFakeContext(
|
|||||||
revokedSessions: [],
|
revokedSessions: [],
|
||||||
loginFailures: new Map(),
|
loginFailures: new Map(),
|
||||||
userTokens: [],
|
userTokens: [],
|
||||||
|
policies: [],
|
||||||
};
|
};
|
||||||
const sshClient = options.sshClient ?? {};
|
const sshClient = options.sshClient ?? {};
|
||||||
let actor = options.actor;
|
let actor = options.actor;
|
||||||
@@ -665,7 +718,7 @@ export function createFakeContext(
|
|||||||
files: {
|
files: {
|
||||||
// A fake path; a test that needs a real folder uses the OS temp dir
|
// A fake path; a test that needs a real folder uses the OS temp dir
|
||||||
// itself, not this double.
|
// itself, not this double.
|
||||||
dataDir: async () => `/tmp/${pluginId}`,
|
dataDir: async () => options.dataDir ?? `/tmp/${pluginId}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
db: options.db
|
db: options.db
|
||||||
@@ -705,6 +758,12 @@ export function createFakeContext(
|
|||||||
registerEntity: (entity) => {
|
registerEntity: (entity) => {
|
||||||
syncEntities.push(entity);
|
syncEntities.push(entity);
|
||||||
},
|
},
|
||||||
|
provide: () => () => {},
|
||||||
|
get host() {
|
||||||
|
if (!options.syncHost)
|
||||||
|
throw new Error("Pass syncHost to use ctx.sync.host");
|
||||||
|
return options.syncHost as PluginSyncHost;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
registry: {
|
registry: {
|
||||||
@@ -902,6 +961,27 @@ export function createFakeContext(
|
|||||||
disposables,
|
disposables,
|
||||||
|
|
||||||
hosts: {
|
hosts: {
|
||||||
|
importMany: async (rows) => {
|
||||||
|
const created = rows.map(() => nextHostId++);
|
||||||
|
return {
|
||||||
|
success: created.length,
|
||||||
|
updated: 0,
|
||||||
|
skipped: 0,
|
||||||
|
failed: 0,
|
||||||
|
errors: [],
|
||||||
|
createdHostIds: created,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
importSshConfig: async () => ({
|
||||||
|
success: 0,
|
||||||
|
updated: 0,
|
||||||
|
skipped: 0,
|
||||||
|
failed: 0,
|
||||||
|
errors: [],
|
||||||
|
createdHostIds: [],
|
||||||
|
}),
|
||||||
|
exportMany: async () => ({ hosts: [] }),
|
||||||
|
registerImportNormalizer: () => () => {},
|
||||||
list: async () => [...hostsById.values()],
|
list: async () => [...hostsById.values()],
|
||||||
get: async (hostId) => hostsById.get(hostId) ?? null,
|
get: async (hostId) => hostsById.get(hostId) ?? null,
|
||||||
checkAccess: async (hostId): Promise<PluginHostAccess> => {
|
checkAccess: async (hostId): Promise<PluginHostAccess> => {
|
||||||
@@ -956,6 +1036,10 @@ export function createFakeContext(
|
|||||||
hostShares.push({ hostId, targets, permissionLevel, durationHours });
|
hostShares.push({ hostId, targets, permissionLevel, durationHours });
|
||||||
return { hostId, shared: true };
|
return { hostId, shared: true };
|
||||||
},
|
},
|
||||||
|
setShares: async (hostId, grants) => {
|
||||||
|
hostShareSets.push({ hostId, grants: [...grants] });
|
||||||
|
return { hostId, shared: true };
|
||||||
|
},
|
||||||
listUsers: async () => options.shareableUsers ?? [],
|
listUsers: async () => options.shareableUsers ?? [],
|
||||||
listRoles: async () => options.shareableRoles ?? [],
|
listRoles: async () => options.shareableRoles ?? [],
|
||||||
trackSession: (hostId) => {
|
trackSession: (hostId) => {
|
||||||
@@ -971,7 +1055,42 @@ export function createFakeContext(
|
|||||||
recordActivity: async (hostId, type, hostName) => {
|
recordActivity: async (hostId, type, hostName) => {
|
||||||
activities.push({ hostId, type, hostName });
|
activities.push({ hostId, type, hostName });
|
||||||
},
|
},
|
||||||
|
probeTargets: async (filter) =>
|
||||||
|
(options.probeTargets ?? []).filter(
|
||||||
|
(target) => !filter?.hostIds || filter.hostIds.includes(target.id),
|
||||||
|
),
|
||||||
|
sessionActive: (hostId) =>
|
||||||
|
options.activeSessions?.includes(hostId) ?? false,
|
||||||
|
activity: {
|
||||||
|
provide: (provider) => {
|
||||||
|
activityProviders.push(provider);
|
||||||
|
return () => {
|
||||||
|
const at = activityProviders.indexOf(provider);
|
||||||
|
if (at >= 0) activityProviders.splice(at, 1);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
folders: {
|
||||||
|
ensure: async () => {},
|
||||||
|
removeEmpty: async () => true,
|
||||||
|
},
|
||||||
|
locks: {
|
||||||
|
provide: (provider) => {
|
||||||
|
hostLockProviders.push(provider);
|
||||||
|
return () => {
|
||||||
|
const at = hostLockProviders.indexOf(provider);
|
||||||
|
if (at >= 0) hostLockProviders.splice(at, 1);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
status: {
|
status: {
|
||||||
|
provide: (provider) => {
|
||||||
|
hostStatusProviders.push(provider);
|
||||||
|
return () => {
|
||||||
|
const at = hostStatusProviders.indexOf(provider);
|
||||||
|
if (at >= 0) hostStatusProviders.splice(at, 1);
|
||||||
|
};
|
||||||
|
},
|
||||||
get: async (hostId) => hostStatuses.get(hostId) ?? null,
|
get: async (hostId) => hostStatuses.get(hostId) ?? null,
|
||||||
check: async (hostId) => hostStatuses.get(hostId) ?? null,
|
check: async (hostId) => hostStatuses.get(hostId) ?? null,
|
||||||
reportLogin: (hostId, outcome) => {
|
reportLogin: (hostId, outcome) => {
|
||||||
@@ -996,12 +1115,46 @@ export function createFakeContext(
|
|||||||
},
|
},
|
||||||
|
|
||||||
audit: {
|
audit: {
|
||||||
|
subscribe: (handler) => {
|
||||||
|
auditSubscribers.add(handler);
|
||||||
|
return () => {
|
||||||
|
auditSubscribers.delete(handler);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
list: async (listOptions) => {
|
||||||
|
const matching = filterAuditEntries(
|
||||||
|
options.auditEntries ?? [],
|
||||||
|
listOptions?.filters,
|
||||||
|
).reverse();
|
||||||
|
const offset = listOptions?.offset ?? 0;
|
||||||
|
return {
|
||||||
|
entries: matching.slice(offset, offset + (listOptions?.limit ?? 50)),
|
||||||
|
total: matching.length,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
exportBatches: async function* (filters, batchSize) {
|
||||||
|
const matching = filterAuditEntries(
|
||||||
|
options.auditEntries ?? [],
|
||||||
|
filters,
|
||||||
|
);
|
||||||
|
const size = batchSize ?? 500;
|
||||||
|
for (let at = 0; at < matching.length; at += size) {
|
||||||
|
yield matching.slice(at, at + size);
|
||||||
|
}
|
||||||
|
},
|
||||||
record: async (entry) => {
|
record: async (entry) => {
|
||||||
audits.push({ ...entry });
|
audits.push({ ...entry });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
network: {
|
||||||
|
tcpProbe: async (host, port) => options.reachable?.(host, port) ?? false,
|
||||||
|
},
|
||||||
|
|
||||||
ssh: {
|
ssh: {
|
||||||
|
transports: { provide: () => () => {} },
|
||||||
|
probe: async (hostId, probeOptions) =>
|
||||||
|
options.reachable?.(hostId, probeOptions?.port) ?? false,
|
||||||
connect: async (host, connectOptions) => {
|
connect: async (host, connectOptions) => {
|
||||||
sshConnections.push({ host, options: connectOptions });
|
sshConnections.push({ host, options: connectOptions });
|
||||||
return {
|
return {
|
||||||
@@ -1013,6 +1166,16 @@ export function createFakeContext(
|
|||||||
dispose: () => {},
|
dispose: () => {},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
exec: async (host) => {
|
||||||
|
sshConnections.push({ host });
|
||||||
|
return {
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
code: 0,
|
||||||
|
truncated: false,
|
||||||
|
timedOut: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
withConnection: async (host, connectOptions, fn) => {
|
withConnection: async (host, connectOptions, fn) => {
|
||||||
sshConnections.push({ host, pool: connectOptions.pool });
|
sshConnections.push({ host, pool: connectOptions.pool });
|
||||||
return fn(sshClient as never);
|
return fn(sshClient as never);
|
||||||
@@ -1153,6 +1316,22 @@ export function createFakeContext(
|
|||||||
expiresAt: new Date(Date.now() + ttl * 1000).toISOString(),
|
expiresAt: new Date(Date.now() + ttl * 1000).toISOString(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
policy: {
|
||||||
|
provide: (provider) => {
|
||||||
|
auth.policies.push(provider);
|
||||||
|
return () => {
|
||||||
|
const at = auth.policies.indexOf(provider);
|
||||||
|
if (at >= 0) auth.policies.splice(at, 1);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
listSecondFactors: () => [
|
||||||
|
...auth.secondFactors.map((factor) => ({
|
||||||
|
id: factor.id,
|
||||||
|
pluginId,
|
||||||
|
})),
|
||||||
|
...(options.secondFactors ?? []),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
users: {
|
users: {
|
||||||
@@ -1169,9 +1348,11 @@ export function createFakeContext(
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
available: () => options.desktopAvailable ?? false,
|
available: () => options.desktopAvailable ?? false,
|
||||||
|
local: options.desktopLocal ?? unavailableDesktopLocal(),
|
||||||
},
|
},
|
||||||
|
|
||||||
credentials: {
|
credentials: {
|
||||||
|
list: async () => [...(options.credentialSummaries ?? [])],
|
||||||
listSshKeys: async () => [
|
listSshKeys: async () => [
|
||||||
...(options.sshKeyCredentials ?? []),
|
...(options.sshKeyCredentials ?? []),
|
||||||
...createdSshKeys.map((key) => ({
|
...createdSshKeys.map((key) => ({
|
||||||
@@ -1195,6 +1376,13 @@ export function createFakeContext(
|
|||||||
credentialReads.push({ hostId, protocol });
|
credentialReads.push({ hostId, protocol });
|
||||||
return options.protocolTargets?.[`${hostId}:${protocol}`] ?? null;
|
return options.protocolTargets?.[`${hostId}:${protocol}`] ?? null;
|
||||||
},
|
},
|
||||||
|
publicKey: async (credentialId) =>
|
||||||
|
(options.sshKeyCredentials ?? []).find((k) => k.id === credentialId)
|
||||||
|
?.publicKey ?? null,
|
||||||
|
resolveKeyRefs: async (key, passphrase) => ({
|
||||||
|
key,
|
||||||
|
passphrase: passphrase ?? undefined,
|
||||||
|
}),
|
||||||
registerSecretResolver: (scheme, resolve) => {
|
registerSecretResolver: (scheme, resolve) => {
|
||||||
auth.secretResolvers.set(scheme, resolve);
|
auth.secretResolvers.set(scheme, resolve);
|
||||||
disposals.push(() => {
|
disposals.push(() => {
|
||||||
@@ -1345,6 +1533,9 @@ export function createFakeContext(
|
|||||||
return errors;
|
return errors;
|
||||||
},
|
},
|
||||||
disposals,
|
disposals,
|
||||||
|
hostStatusProviders,
|
||||||
|
activityProviders,
|
||||||
|
hostLockProviders,
|
||||||
emitted,
|
emitted,
|
||||||
kv,
|
kv,
|
||||||
tables,
|
tables,
|
||||||
@@ -1355,6 +1546,7 @@ export function createFakeContext(
|
|||||||
coreSettings,
|
coreSettings,
|
||||||
sshConnections,
|
sshConnections,
|
||||||
hostShares,
|
hostShares,
|
||||||
|
hostShareSets,
|
||||||
auth,
|
auth,
|
||||||
desktopWindows,
|
desktopWindows,
|
||||||
externalClientLaunches,
|
externalClientLaunches,
|
||||||
@@ -1368,6 +1560,9 @@ export function createFakeContext(
|
|||||||
tls,
|
tls,
|
||||||
secretStore,
|
secretStore,
|
||||||
audits,
|
audits,
|
||||||
|
emitAudit: (entry) => {
|
||||||
|
for (const handler of auditSubscribers) handler(entry);
|
||||||
|
},
|
||||||
activities,
|
activities,
|
||||||
trackedSessions,
|
trackedSessions,
|
||||||
interactions,
|
interactions,
|
||||||
@@ -1400,6 +1595,8 @@ export function createFakeContext(
|
|||||||
|
|
||||||
export interface MockContextOptions {
|
export interface MockContextOptions {
|
||||||
pluginId?: string;
|
pluginId?: string;
|
||||||
|
/** What ctx.sync.host answers, for a sync provider's tests. */
|
||||||
|
syncHost?: Partial<PluginSyncHost>;
|
||||||
manifest?: Partial<PluginManifest>;
|
manifest?: Partial<PluginManifest>;
|
||||||
/** Capabilities the plugin is granted. Anything else throws. */
|
/** Capabilities the plugin is granted. Anything else throws. */
|
||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
@@ -1409,6 +1606,8 @@ export interface MockContextOptions {
|
|||||||
settings?: Record<string, unknown>;
|
settings?: Record<string, unknown>;
|
||||||
/** Seed for the core settings readCore can reach. */
|
/** Seed for the core settings readCore can reach. */
|
||||||
coreSettings?: Record<string, string>;
|
coreSettings?: Record<string, string>;
|
||||||
|
/** What ctx.files.dataDir() answers. */
|
||||||
|
dataDir?: string;
|
||||||
/** Acting user returned by currentActor and used by asUser. */
|
/** Acting user returned by currentActor and used by asUser. */
|
||||||
actor?: string;
|
actor?: string;
|
||||||
/** What ctx.ssh.connect and withConnection hand back as the client. */
|
/** What ctx.ssh.connect and withConnection hand back as the client. */
|
||||||
@@ -1432,10 +1631,22 @@ export interface MockContextOptions {
|
|||||||
protocolTargets?: Record<string, PluginProtocolTarget>;
|
protocolTargets?: Record<string, PluginProtocolTarget>;
|
||||||
/** What ctx.credentials.listSshKeys serves. */
|
/** What ctx.credentials.listSshKeys serves. */
|
||||||
sshKeyCredentials?: PluginSshKeyCredential[];
|
sshKeyCredentials?: PluginSshKeyCredential[];
|
||||||
|
/** What ctx.credentials.list serves. */
|
||||||
|
credentialSummaries?: PluginCredentialSummary[];
|
||||||
/** What ctx.desktop.available() answers. */
|
/** What ctx.desktop.available() answers. */
|
||||||
desktopAvailable?: boolean;
|
desktopAvailable?: boolean;
|
||||||
|
/** What ctx.desktop.local is. See FakeContextOptions. */
|
||||||
|
desktopLocal?: PluginDesktopLocal;
|
||||||
|
/** See FakeContextOptions. */
|
||||||
|
auditEntries?: PluginAuditEntry[];
|
||||||
/** What ctx.hosts.status answers. See FakeContextOptions. */
|
/** What ctx.hosts.status answers. See FakeContextOptions. */
|
||||||
hostStatuses?: Record<number, PluginHostStatusEntry>;
|
hostStatuses?: Record<number, PluginHostStatusEntry>;
|
||||||
|
/** What ctx.hosts.probeTargets answers. */
|
||||||
|
probeTargets?: PluginHostProbeTarget[];
|
||||||
|
/** Host ids ctx.hosts.sessionActive answers true for. */
|
||||||
|
activeSessions?: number[];
|
||||||
|
/** What ctx.network.tcpProbe and ctx.ssh.probe answer. */
|
||||||
|
reachable?: (target: string | number, port?: number) => boolean;
|
||||||
/** The acting user's channels, what ctx.notify.channels answers. */
|
/** The acting user's channels, what ctx.notify.channels answers. */
|
||||||
notificationChannels?: PluginNotificationChannel[];
|
notificationChannels?: PluginNotificationChannel[];
|
||||||
/**
|
/**
|
||||||
@@ -1460,6 +1671,8 @@ export interface MockContextOptions {
|
|||||||
/** See FakeContextOptions. */
|
/** See FakeContextOptions. */
|
||||||
loginAttemptLimit?: number;
|
loginAttemptLimit?: number;
|
||||||
/** See FakeContextOptions. */
|
/** See FakeContextOptions. */
|
||||||
|
secondFactors?: FakeContextOptions["secondFactors"];
|
||||||
|
/** See FakeContextOptions. */
|
||||||
locales?: Record<string, string>;
|
locales?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1481,6 +1694,63 @@ export interface MockPluginContext extends FakePluginContext {
|
|||||||
* (users:impersonate on issueUserToken), and users:read on ctx.users. Reading a plugin's own settings is deliberately
|
* (users:impersonate on issueUserToken), and users:read on ctx.users. Reading a plugin's own settings is deliberately
|
||||||
* ungated. As the SDK grows a member, add its gate here in the same shape.
|
* ungated. As the SDK grows a member, add its gate here in the same shape.
|
||||||
*/
|
*/
|
||||||
|
function filterAuditEntries(
|
||||||
|
entries: PluginAuditEntry[],
|
||||||
|
filters: PluginAuditFilters | undefined,
|
||||||
|
): PluginAuditEntry[] {
|
||||||
|
return entries.filter(
|
||||||
|
(entry) =>
|
||||||
|
(filters?.userId === undefined || entry.userId === filters.userId) &&
|
||||||
|
(filters?.action === undefined || entry.action === filters.action) &&
|
||||||
|
(filters?.resourceType === undefined ||
|
||||||
|
entry.resourceType === filters.resourceType) &&
|
||||||
|
(filters?.success === undefined || entry.success === filters.success) &&
|
||||||
|
(filters?.startDate === undefined ||
|
||||||
|
entry.timestamp >= filters.startDate) &&
|
||||||
|
(filters?.endDate === undefined || entry.timestamp <= filters.endDate),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function gateDesktopLocal(
|
||||||
|
target: PluginDesktopLocal,
|
||||||
|
check: () => void,
|
||||||
|
): PluginDesktopLocal {
|
||||||
|
const wrap = (value: unknown): unknown => {
|
||||||
|
if (typeof value === "function") {
|
||||||
|
return (...args: unknown[]) => {
|
||||||
|
check();
|
||||||
|
return (value as (...a: unknown[]) => unknown)(...args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
return new Proxy(value as object, {
|
||||||
|
get: (obj, key) => wrap((obj as Record<string | symbol, unknown>)[key]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
return new Proxy(target, {
|
||||||
|
get: (obj, key) =>
|
||||||
|
key === "available"
|
||||||
|
? obj.available
|
||||||
|
: wrap((obj as unknown as Record<string | symbol, unknown>)[key]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unavailableDesktopLocal(): PluginDesktopLocal {
|
||||||
|
const fail = (): never => {
|
||||||
|
throw new Error("ctx.desktop.local needs the desktopLocal test option");
|
||||||
|
};
|
||||||
|
const deep = (): unknown =>
|
||||||
|
new Proxy(() => fail(), { get: () => deep(), apply: () => fail() });
|
||||||
|
return {
|
||||||
|
available: () => false,
|
||||||
|
info: fail,
|
||||||
|
fs: deep(),
|
||||||
|
pty: deep(),
|
||||||
|
} as unknown as PluginDesktopLocal;
|
||||||
|
}
|
||||||
|
|
||||||
export function createMockCtx(
|
export function createMockCtx(
|
||||||
options: MockContextOptions = {},
|
options: MockContextOptions = {},
|
||||||
): MockPluginContext {
|
): MockPluginContext {
|
||||||
@@ -1489,9 +1759,11 @@ export function createMockCtx(
|
|||||||
|
|
||||||
const base = createFakeContext({
|
const base = createFakeContext({
|
||||||
pluginId: options.pluginId,
|
pluginId: options.pluginId,
|
||||||
|
syncHost: options.syncHost,
|
||||||
actor: options.actor,
|
actor: options.actor,
|
||||||
settings: options.settings,
|
settings: options.settings,
|
||||||
coreSettings: options.coreSettings,
|
coreSettings: options.coreSettings,
|
||||||
|
dataDir: options.dataDir,
|
||||||
sshClient: options.sshClient,
|
sshClient: options.sshClient,
|
||||||
db: options.db,
|
db: options.db,
|
||||||
router: options.router,
|
router: options.router,
|
||||||
@@ -1503,8 +1775,14 @@ export function createMockCtx(
|
|||||||
services: options.services,
|
services: options.services,
|
||||||
protocolTargets: options.protocolTargets,
|
protocolTargets: options.protocolTargets,
|
||||||
sshKeyCredentials: options.sshKeyCredentials,
|
sshKeyCredentials: options.sshKeyCredentials,
|
||||||
|
credentialSummaries: options.credentialSummaries,
|
||||||
desktopAvailable: options.desktopAvailable,
|
desktopAvailable: options.desktopAvailable,
|
||||||
|
desktopLocal: options.desktopLocal,
|
||||||
|
auditEntries: options.auditEntries,
|
||||||
hostStatuses: options.hostStatuses,
|
hostStatuses: options.hostStatuses,
|
||||||
|
probeTargets: options.probeTargets,
|
||||||
|
activeSessions: options.activeSessions,
|
||||||
|
reachable: options.reachable,
|
||||||
notificationChannels: options.notificationChannels,
|
notificationChannels: options.notificationChannels,
|
||||||
fetch: options.fetch,
|
fetch: options.fetch,
|
||||||
process: options.process,
|
process: options.process,
|
||||||
@@ -1515,6 +1793,7 @@ export function createMockCtx(
|
|||||||
baseUrl: options.baseUrl,
|
baseUrl: options.baseUrl,
|
||||||
linkedUsers: options.linkedUsers,
|
linkedUsers: options.linkedUsers,
|
||||||
loginAttemptLimit: options.loginAttemptLimit,
|
loginAttemptLimit: options.loginAttemptLimit,
|
||||||
|
secondFactors: options.secondFactors,
|
||||||
locales: options.locales,
|
locales: options.locales,
|
||||||
manifest: {
|
manifest: {
|
||||||
capabilities: options.capabilities ?? [],
|
capabilities: options.capabilities ?? [],
|
||||||
@@ -1650,6 +1929,23 @@ export function createMockCtx(
|
|||||||
},
|
},
|
||||||
|
|
||||||
hosts: {
|
hosts: {
|
||||||
|
importMany: async (rows, importOptions) => {
|
||||||
|
require("hosts:write");
|
||||||
|
if (importOptions?.credentials?.length) require("credentials:write");
|
||||||
|
return ctx.hosts.importMany(rows, importOptions);
|
||||||
|
},
|
||||||
|
importSshConfig: async (content, importOptions) => {
|
||||||
|
require("hosts:write");
|
||||||
|
return ctx.hosts.importSshConfig(content, importOptions);
|
||||||
|
},
|
||||||
|
exportMany: async (exportOptions) => {
|
||||||
|
require("hosts:export");
|
||||||
|
return ctx.hosts.exportMany(exportOptions);
|
||||||
|
},
|
||||||
|
registerImportNormalizer: (normalizer) => {
|
||||||
|
require("hosts:write");
|
||||||
|
return ctx.hosts.registerImportNormalizer(normalizer);
|
||||||
|
},
|
||||||
list: async () => {
|
list: async () => {
|
||||||
require("hosts:read");
|
require("hosts:read");
|
||||||
return ctx.hosts.list();
|
return ctx.hosts.list();
|
||||||
@@ -1682,6 +1978,10 @@ export function createMockCtx(
|
|||||||
require("hosts:write");
|
require("hosts:write");
|
||||||
return ctx.hosts.share(hostId, targets, permissionLevel, durationHours);
|
return ctx.hosts.share(hostId, targets, permissionLevel, durationHours);
|
||||||
},
|
},
|
||||||
|
setShares: async (hostId, grants) => {
|
||||||
|
require("hosts:write");
|
||||||
|
return ctx.hosts.setShares(hostId, grants);
|
||||||
|
},
|
||||||
listUsers: async () => {
|
listUsers: async () => {
|
||||||
require("hosts:write");
|
require("hosts:write");
|
||||||
return ctx.hosts.listUsers();
|
return ctx.hosts.listUsers();
|
||||||
@@ -1698,7 +1998,42 @@ export function createMockCtx(
|
|||||||
require("hosts:read");
|
require("hosts:read");
|
||||||
return ctx.hosts.recordActivity(hostId, type, hostName);
|
return ctx.hosts.recordActivity(hostId, type, hostName);
|
||||||
},
|
},
|
||||||
|
probeTargets: async (filter) => {
|
||||||
|
require("hosts:read");
|
||||||
|
return ctx.hosts.probeTargets(filter);
|
||||||
|
},
|
||||||
|
sessionActive: (hostId) => {
|
||||||
|
require("hosts:read");
|
||||||
|
return ctx.hosts.sessionActive(hostId);
|
||||||
|
},
|
||||||
|
activity: {
|
||||||
|
provide: (provider) => {
|
||||||
|
require("hosts:activity-provide");
|
||||||
|
return ctx.hosts.activity.provide(provider);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
folders: {
|
||||||
|
ensure: async (name, style) => {
|
||||||
|
require("hosts:write");
|
||||||
|
return ctx.hosts.folders.ensure(name, style);
|
||||||
|
},
|
||||||
|
removeEmpty: async (name) => {
|
||||||
|
require("hosts:write");
|
||||||
|
return ctx.hosts.folders.removeEmpty(name);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
locks: {
|
||||||
|
provide: (provider) => {
|
||||||
|
require("hosts:lock");
|
||||||
|
return ctx.hosts.locks.provide(provider);
|
||||||
|
},
|
||||||
|
},
|
||||||
status: {
|
status: {
|
||||||
|
provide: (provider) => {
|
||||||
|
require("hosts:read");
|
||||||
|
require("hosts:status-provide");
|
||||||
|
return ctx.hosts.status.provide(provider);
|
||||||
|
},
|
||||||
get: async (hostId) => {
|
get: async (hostId) => {
|
||||||
require("hosts:read");
|
require("hosts:read");
|
||||||
return ctx.hosts.status.get(hostId);
|
return ctx.hosts.status.get(hostId);
|
||||||
@@ -1720,11 +2055,26 @@ export function createMockCtx(
|
|||||||
|
|
||||||
ssh: {
|
ssh: {
|
||||||
...ctx.ssh,
|
...ctx.ssh,
|
||||||
|
transports: {
|
||||||
|
provide: (provider) => {
|
||||||
|
require("ssh:transport");
|
||||||
|
return ctx.ssh.transports.provide(provider);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
probe: async (hostId, probeOptions) => {
|
||||||
|
require("ssh:connect");
|
||||||
|
return ctx.ssh.probe(hostId, probeOptions);
|
||||||
|
},
|
||||||
connect: async (host, connectOptions) => {
|
connect: async (host, connectOptions) => {
|
||||||
require("ssh:connect");
|
require("ssh:connect");
|
||||||
require("credentials:use");
|
require("credentials:use");
|
||||||
return ctx.ssh.connect(host, connectOptions);
|
return ctx.ssh.connect(host, connectOptions);
|
||||||
},
|
},
|
||||||
|
exec: async (host, command, execOptions) => {
|
||||||
|
require("ssh:connect");
|
||||||
|
require("credentials:use");
|
||||||
|
return ctx.ssh.exec(host, command, execOptions);
|
||||||
|
},
|
||||||
withConnection: async (host, connectOptions, fn) => {
|
withConnection: async (host, connectOptions, fn) => {
|
||||||
require("ssh:connect");
|
require("ssh:connect");
|
||||||
require("credentials:use");
|
require("credentials:use");
|
||||||
@@ -1811,6 +2161,13 @@ export function createMockCtx(
|
|||||||
require("users:impersonate");
|
require("users:impersonate");
|
||||||
return ctx.auth.issueUserToken(userId, tokenOptions);
|
return ctx.auth.issueUserToken(userId, tokenOptions);
|
||||||
},
|
},
|
||||||
|
policy: {
|
||||||
|
provide: (provider) => {
|
||||||
|
require("auth:policy");
|
||||||
|
return ctx.auth.policy.provide(provider);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
listSecondFactors: () => ctx.auth.listSecondFactors(),
|
||||||
},
|
},
|
||||||
|
|
||||||
users: {
|
users: {
|
||||||
@@ -1820,6 +2177,22 @@ export function createMockCtx(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
audit: {
|
||||||
|
record: (entry) => ctx.audit.record(entry),
|
||||||
|
subscribe: (handler) => {
|
||||||
|
require("audit:read");
|
||||||
|
return ctx.audit.subscribe(handler);
|
||||||
|
},
|
||||||
|
list: async (listOptions) => {
|
||||||
|
require("audit:read");
|
||||||
|
return ctx.audit.list(listOptions);
|
||||||
|
},
|
||||||
|
exportBatches: (filters, batchSize) => {
|
||||||
|
require("audit:read");
|
||||||
|
return ctx.audit.exportBatches(filters, batchSize);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
desktop: {
|
desktop: {
|
||||||
openIsolatedWindow: async (request) => {
|
openIsolatedWindow: async (request) => {
|
||||||
require("desktop:window");
|
require("desktop:window");
|
||||||
@@ -1830,9 +2203,16 @@ export function createMockCtx(
|
|||||||
return ctx.desktop.launchExternalClient(request);
|
return ctx.desktop.launchExternalClient(request);
|
||||||
},
|
},
|
||||||
available: () => ctx.desktop.available(),
|
available: () => ctx.desktop.available(),
|
||||||
|
local: gateDesktopLocal(ctx.desktop.local, () =>
|
||||||
|
require("desktop:local"),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
credentials: {
|
credentials: {
|
||||||
|
list: async () => {
|
||||||
|
require("credentials:use");
|
||||||
|
return ctx.credentials.list();
|
||||||
|
},
|
||||||
listSshKeys: async () => {
|
listSshKeys: async () => {
|
||||||
require("credentials:use");
|
require("credentials:use");
|
||||||
return ctx.credentials.listSshKeys();
|
return ctx.credentials.listSshKeys();
|
||||||
@@ -1851,6 +2231,14 @@ export function createMockCtx(
|
|||||||
}
|
}
|
||||||
return ctx.credentials.resolveHostProtocol(hostId, protocol);
|
return ctx.credentials.resolveHostProtocol(hostId, protocol);
|
||||||
},
|
},
|
||||||
|
publicKey: async (credentialId) => {
|
||||||
|
require("credentials:use");
|
||||||
|
return ctx.credentials.publicKey(credentialId);
|
||||||
|
},
|
||||||
|
resolveKeyRefs: async (key, passphrase) => {
|
||||||
|
require("credentials:read");
|
||||||
|
return ctx.credentials.resolveKeyRefs(key, passphrase);
|
||||||
|
},
|
||||||
registerSecretResolver: (scheme, resolve) => {
|
registerSecretResolver: (scheme, resolve) => {
|
||||||
require("auth:provide");
|
require("auth:provide");
|
||||||
ctx.credentials.registerSecretResolver(scheme, resolve);
|
ctx.credentials.registerSecretResolver(scheme, resolve);
|
||||||
@@ -1877,6 +2265,13 @@ export function createMockCtx(
|
|||||||
return ctx.fetch(url, init);
|
return ctx.fetch(url, init);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
network: {
|
||||||
|
tcpProbe: async (host, port, probeOptions) => {
|
||||||
|
require("network:probe");
|
||||||
|
return ctx.network.tcpProbe(host, port, probeOptions);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
process: {
|
process: {
|
||||||
run: async (file, args, runOptions) => {
|
run: async (file, args, runOptions) => {
|
||||||
require("process:spawn");
|
require("process:spawn");
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ const offline = () =>
|
|||||||
useHostStatus: () => null,
|
useHostStatus: () => null,
|
||||||
useCurrentUser: () => null,
|
useCurrentUser: () => null,
|
||||||
useTheme: () => ({ theme: "dark" }),
|
useTheme: () => ({ theme: "dark" }),
|
||||||
|
useShell: () => null,
|
||||||
toast: { success: noop, error: noop, info: noop, warning: noop },
|
toast: { success: noop, error: noop, info: noop, warning: noop },
|
||||||
getApi: () => ({
|
getApi: () => ({
|
||||||
get: offline,
|
get: offline,
|
||||||
@@ -138,6 +139,7 @@ const offline = () =>
|
|||||||
}),
|
}),
|
||||||
hostProtocols: () => ["ssh"],
|
hostProtocols: () => ["ssh"],
|
||||||
useHostActions: () => [],
|
useHostActions: () => [],
|
||||||
|
useCompanions: () => [],
|
||||||
useActivityTypes: () => [],
|
useActivityTypes: () => [],
|
||||||
activityTarget: () => undefined,
|
activityTarget: () => undefined,
|
||||||
useExtensions: () => [],
|
useExtensions: () => [],
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* Checks a theme's CSS before core serves it.
|
||||||
|
*
|
||||||
|
* A theme is colors and styles only, so its file may hold nothing but rules
|
||||||
|
* whose every selector starts with the theme's own class (`.nord`,
|
||||||
|
* `:root.nord`, `html.nord ...`), optionally inside @media or @supports.
|
||||||
|
* Anything that could load from elsewhere or run code is refused: @import,
|
||||||
|
* url() to another host, other at-rules, expression() and javascript:.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ALLOWED_BLOCK_AT_RULES = new Set(["media", "supports"]);
|
||||||
|
|
||||||
|
function stripComments(css: string): string {
|
||||||
|
return css.replace(/\/\*[\s\S]*?\*\//g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Splits on commas that are not inside parentheses or brackets. */
|
||||||
|
function splitSelectors(prelude: string): string[] {
|
||||||
|
const parts: string[] = [];
|
||||||
|
let depth = 0;
|
||||||
|
let current = "";
|
||||||
|
for (const ch of prelude) {
|
||||||
|
if (ch === "(" || ch === "[") depth += 1;
|
||||||
|
if (ch === ")" || ch === "]") depth -= 1;
|
||||||
|
if (ch === "," && depth === 0) {
|
||||||
|
parts.push(current);
|
||||||
|
current = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
parts.push(current);
|
||||||
|
return parts.map((part) => part.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function withoutStrings(css: string): string {
|
||||||
|
return css.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, '""');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index of the brace closing the one at `open`, skipping strings. */
|
||||||
|
function matchingBrace(css: string, open: number): number {
|
||||||
|
let depth = 0;
|
||||||
|
let quote: string | null = null;
|
||||||
|
for (let i = open; i < css.length; i += 1) {
|
||||||
|
const ch = css[i];
|
||||||
|
if (quote) {
|
||||||
|
if (ch === "\\") i += 1;
|
||||||
|
else if (ch === quote) quote = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"' || ch === "'") quote = ch;
|
||||||
|
else if (ch === "{") depth += 1;
|
||||||
|
else if (ch === "}") {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth === 0) return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkUrls(css: string, errors: string[]): void {
|
||||||
|
const urls = css.matchAll(/url\(\s*(['"]?)([^'")]*)\1\s*\)/gi);
|
||||||
|
for (const match of urls) {
|
||||||
|
const target = match[2].trim();
|
||||||
|
if (target.startsWith("data:")) continue;
|
||||||
|
if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("//")) {
|
||||||
|
errors.push(`url(${target}) points outside the plugin`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkRules(
|
||||||
|
css: string,
|
||||||
|
scope: RegExp,
|
||||||
|
themeId: string,
|
||||||
|
errors: string[],
|
||||||
|
): void {
|
||||||
|
let index = 0;
|
||||||
|
while (index < css.length) {
|
||||||
|
const rest = css.slice(index);
|
||||||
|
if (rest.trim() === "") return;
|
||||||
|
const open = css.indexOf("{", index);
|
||||||
|
const semicolon = css.indexOf(";", index);
|
||||||
|
if (semicolon !== -1 && (open === -1 || semicolon < open)) {
|
||||||
|
const statement = css.slice(index, semicolon).trim();
|
||||||
|
errors.push(
|
||||||
|
statement.startsWith("@")
|
||||||
|
? `${statement.split(/\s/)[0]} is not allowed in a theme`
|
||||||
|
: `Unexpected "${statement}" outside a rule`,
|
||||||
|
);
|
||||||
|
index = semicolon + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (open === -1) {
|
||||||
|
errors.push(`Unexpected "${rest.trim()}" at the end`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const close = matchingBrace(css, open);
|
||||||
|
if (close === -1) {
|
||||||
|
errors.push("A rule is missing its closing brace");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prelude = css.slice(index, open).trim();
|
||||||
|
const body = css.slice(open + 1, close);
|
||||||
|
|
||||||
|
if (prelude.startsWith("@")) {
|
||||||
|
const name = prelude.slice(1).split(/[\s(]/)[0].toLowerCase();
|
||||||
|
if (ALLOWED_BLOCK_AT_RULES.has(name)) {
|
||||||
|
checkRules(body, scope, themeId, errors);
|
||||||
|
} else {
|
||||||
|
errors.push(`@${name} is not allowed in a theme`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const selector of splitSelectors(prelude)) {
|
||||||
|
if (!scope.test(selector)) {
|
||||||
|
errors.push(
|
||||||
|
`Selector "${selector}" must start with .${themeId}, :root.${themeId} or html.${themeId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (withoutStrings(body).includes("{")) {
|
||||||
|
errors.push(`Nested rules are not allowed in "${prelude}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index = close + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every problem with a theme's CSS, empty when it is safe to serve. */
|
||||||
|
export function validateThemeCss(css: string, themeId: string): string[] {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const clean = stripComments(css);
|
||||||
|
if (/@import\b/i.test(clean))
|
||||||
|
errors.push("@import is not allowed in a theme");
|
||||||
|
if (/expression\s*\(/i.test(clean))
|
||||||
|
errors.push("expression() is not allowed in a theme");
|
||||||
|
if (/javascript:/i.test(clean))
|
||||||
|
errors.push("javascript: is not allowed in a theme");
|
||||||
|
if (/-moz-binding|behavior\s*:/i.test(clean))
|
||||||
|
errors.push("Script bindings are not allowed in a theme");
|
||||||
|
checkUrls(clean, errors);
|
||||||
|
const id = escapeRegExp(themeId);
|
||||||
|
const scope = new RegExp(`^(?::root|html)?\\.${id}(?![\\w-])`);
|
||||||
|
checkRules(clean, scope, themeId, errors);
|
||||||
|
return [...new Set(errors)];
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
* Two projects: tests/backend runs in node, tests/frontend in jsdom.
|
* Two projects: tests/backend runs in node, tests/frontend in jsdom.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { createRequire } from "node:module";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
@@ -117,9 +118,41 @@ export function pluginVitestConfig(
|
|||||||
|
|
||||||
const plugins = [wasmBinaryPlugin()];
|
const plugins = [wasmBinaryPlugin()];
|
||||||
|
|
||||||
|
// A local SDK linked into node_modules sits outside the plugin folder, so
|
||||||
|
// Vite must be allowed to read it, and React must come from the plugin.
|
||||||
|
const sdkDir = fs.realpathSync(fileURLToPath(new URL("..", import.meta.url)));
|
||||||
|
const server = { fs: { allow: [pluginDir, sdkDir] } };
|
||||||
|
// dedupe alone is not enough: react-dom or the testing library loaded from
|
||||||
|
// the linked SDK's own node_modules pull in a second React, and hooks then
|
||||||
|
// see no renderer.
|
||||||
|
if (!sdkDir.startsWith(pluginDir)) {
|
||||||
|
const pluginRequire = createRequire(path.join(pluginDir, "package.json"));
|
||||||
|
for (const name of [
|
||||||
|
"react",
|
||||||
|
"react-dom",
|
||||||
|
"@testing-library/react",
|
||||||
|
"react-i18next",
|
||||||
|
"i18next",
|
||||||
|
]) {
|
||||||
|
if (alias[name]) continue;
|
||||||
|
try {
|
||||||
|
alias[name] = path.dirname(
|
||||||
|
pluginRequire.resolve(`${name}/package.json`),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Not installed in the plugin: leave it to normal resolution.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const resolve = {
|
||||||
|
alias,
|
||||||
|
dedupe: ["react", "react-dom", "react-i18next", "i18next"],
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugins,
|
plugins,
|
||||||
resolve: { alias },
|
server,
|
||||||
|
resolve,
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
setupFiles: [setupFile],
|
setupFiles: [setupFile],
|
||||||
@@ -127,7 +160,8 @@ export function pluginVitestConfig(
|
|||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
plugins,
|
plugins,
|
||||||
resolve: { alias },
|
server,
|
||||||
|
resolve,
|
||||||
test: {
|
test: {
|
||||||
name: `${pluginId}:backend`,
|
name: `${pluginId}:backend`,
|
||||||
root: pluginDir,
|
root: pluginDir,
|
||||||
@@ -140,7 +174,8 @@ export function pluginVitestConfig(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
plugins,
|
plugins,
|
||||||
resolve: { alias },
|
server,
|
||||||
|
resolve,
|
||||||
test: {
|
test: {
|
||||||
name: `${pluginId}:frontend`,
|
name: `${pluginId}:frontend`,
|
||||||
root: pluginDir,
|
root: pluginDir,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
|
"preserveSymlinks": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"strict": false,
|
"strict": false,
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
|
ACCENT_PRESET_COLORS (value): { label: string; value: string; }[]
|
||||||
|
APP_VERSION (value): string
|
||||||
|
AccessRecord (type): AccessRecord
|
||||||
ActionSlot (value): ({ slotId, context, className, enabled, renderItem, hideLabels, when, }: ActionSlotProps) => Element
|
ActionSlot (value): ({ slotId, context, className, enabled, renderItem, hideLabels, when, }: ActionSlotProps) => Element
|
||||||
|
ActiveSessionInfo (type): ActiveSessionInfo
|
||||||
AddButton (value): ({ label, compact, className, ...rest }: { label?: string; compact?: boolean; className?: string; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => Element
|
AddButton (value): ({ label, compact, className, ...rest }: { label?: string; compact?: boolean; className?: string; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => Element
|
||||||
|
AdminSection (type): AdminSection
|
||||||
Alert (value): ({ className, variant, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; destructive: string; }; }> & ClassProp) => string>) => Element
|
Alert (value): ({ className, variant, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; destructive: string; }; }> & ClassProp) => string>) => Element
|
||||||
AlertDescription (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
AlertDescription (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||||
AlertDialog (value): ({ ...props }: AlertDialogProps) => Element
|
AlertDialog (value): ({ ...props }: AlertDialogProps) => Element
|
||||||
@@ -14,14 +19,23 @@ AlertDialogPortal (value): ({ ...props }: AlertDialogPortalProps) => Element
|
|||||||
AlertDialogTitle (value): ({ className, ...props }: AlertDialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
|
AlertDialogTitle (value): ({ className, ...props }: AlertDialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
|
||||||
AlertDialogTrigger (value): ({ ...props }: AlertDialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
AlertDialogTrigger (value): ({ ...props }: AlertDialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||||
AlertTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
AlertTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||||
|
ApiKey (type): ApiKey
|
||||||
|
AuditLog (type): AuditLog
|
||||||
|
AuditLogFilters (type): AuditLogFilters
|
||||||
|
AuthEnrollmentSections (value): ({ factorsOnly, }?: { factorsOnly?: boolean; }) => Element
|
||||||
|
AuthOverrideProtocol (type): string
|
||||||
BackButton (value): ({ onClick, label, className, }: { onClick: () => void; label?: string; className?: string; }) => Element
|
BackButton (value): ({ onClick, label, className, }: { onClick: () => void; label?: string; className?: string; }) => Element
|
||||||
Badge (value): ({ className, variant, asChild, ...props }: ClassAttributes<HTMLSpanElement> & HTMLAttributes<HTMLSpanElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; secondary: string; destructive: string; outline: string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element
|
Badge (value): ({ className, variant, asChild, ...props }: ClassAttributes<HTMLSpanElement> & HTMLAttributes<HTMLSpanElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; secondary: string; destructive: string; outline: string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element
|
||||||
BarSeries (value): ({ items, max, className, colorClassName, }: BarSeriesProps) => Element
|
BarSeries (value): ({ items, max, className, colorClassName, }: BarSeriesProps) => Element
|
||||||
BarSeriesItem (type): BarSeriesItem
|
BarSeriesItem (type): BarSeriesItem
|
||||||
BarSeriesProps (type): BarSeriesProps
|
BarSeriesProps (type): BarSeriesProps
|
||||||
|
BrandingSettings (type): BrandingSettings
|
||||||
BrowserSignInDialog (value): ({ isOpen, label, url, code, onContinue, onCancel, onOpenUrl, backgroundColor, }: BrowserSignInDialogProps) => Element
|
BrowserSignInDialog (value): ({ isOpen, label, url, code, onContinue, onCancel, onOpenUrl, backgroundColor, }: BrowserSignInDialogProps) => Element
|
||||||
Button (value): ({ className, variant, size, asChild, ...props }: ClassAttributes<HTMLButtonElement> & ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; outline: string; secondary: string; ghost: string; destructive: string; link: string; }; size: { default: string; xs: string; sm: string; lg: string; icon: string; "icon-xs": string; "icon-sm": string; "icon-lg": string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element
|
Button (value): ({ className, variant, size, asChild, ...props }: ClassAttributes<HTMLButtonElement> & ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; outline: string; secondary: string; ghost: string; destructive: string; link: string; }; size: { default: string; xs: string; sm: string; lg: string; icon: string; "icon-xs": string; "icon-sm": string; "icon-lg": string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element
|
||||||
ButtonProps (type): ButtonProps
|
ButtonProps (type): ButtonProps
|
||||||
|
CORE_HOST_DEFAULTS (value): readonly CoreHostDefault[]
|
||||||
|
CORE_NAMESPACE (value): "core"
|
||||||
|
CREDENTIAL_ROW_FIELD_KEYS (value): (keyof CredentialRowFields)[]
|
||||||
Card (value): ({ className, size, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & { size?: "default" | "sm"; }) => Element
|
Card (value): ({ className, size, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & { size?: "default" | "sm"; }) => Element
|
||||||
CardAction (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
CardAction (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||||
CardContent (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
CardContent (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||||
@@ -33,6 +47,12 @@ CardMasonry (value): ({ columns, stack, className, children, }: { columns: numbe
|
|||||||
CardTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
CardTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||||
Checkbox (value): ({ className, ...props }: CheckboxProps & RefAttributes<HTMLButtonElement>) => Element
|
Checkbox (value): ({ className, ...props }: CheckboxProps & RefAttributes<HTMLButtonElement>) => Element
|
||||||
ColumnCountStepper (value): ({ columns, onChange, }: { columns: number; onChange: (columns: number) => void; }) => Element
|
ColumnCountStepper (value): ({ columns, onChange, }: { columns: number; onChange: (columns: number) => void; }) => Element
|
||||||
|
Command (value): ForwardRefExoticComponent<Omit<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; shouldFilter?: boolean; filter?: CommandFilter; defaultValue?: string; value?: string; onValueChange?: (value: string) => void; loop?: boolean; disablePointerSelection?: boolean; vimBindings?: boolean; } & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
|
||||||
|
CommandEmpty (value): ForwardRefExoticComponent<Omit<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
|
||||||
|
CommandGroup (value): ForwardRefExoticComponent<Omit<Children & Omit<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>>, "value" | "heading"> & { heading?: ReactNode; value?: string; forceMount?: boolean; } & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
|
||||||
|
CommandItem (value): ForwardRefExoticComponent<Omit<Children & Omit<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>>, "disabled" | "value" | "onSelect"> & { disabled?: boolean; onSelect?: (value: string) => void; value?: string; keywords?: string[]; forceMount?: boolean; } & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
|
||||||
|
CommandList (value): ForwardRefExoticComponent<Omit<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; } & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
|
||||||
|
CommandPrimitive (value): ForwardRefExoticComponent<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; shouldFilter?: boolean; filter?: CommandFilter; defaultValue?: string; value?: string; onValueChange?: (value: string) => void; loop?: boolean; disablePointerSelection?: boolean; vimBindings?: boolean; } & RefAttributes<HTMLDivElement>> & { List: ForwardRefExoticComponent<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; } & RefAttributes<HTMLDivElement>>; Item: ForwardRefExoticComponent<Children & Omit<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>>, "disabled" | "value" | "onSelect"> & { disabled?: boolean; onSelect?: (value: string) => void; value?: string; keywords?: string[]; forceMount?: boolean; } & RefAttributes<HTMLDivElement>>; Input: ForwardRefExoticComponent<Omit<Pick<Pick<DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, "key" | keyof InputHTMLAttributes<HTMLInputElement>> & { ref?: Ref<HTMLInputElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof InputHTMLAttributes<HTMLInputElement>>, "type" | "value" | "onChange"> & { value?: string; onValueChange?: (search: string) => void; } & RefAttributes<HTMLInputElement>>; Group: ForwardRefExoticComponent<Children & Omit<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>>, "value" | "heading"> & { heading?: ReactNode; value?: string; forceMount?: boolean; } & RefAttributes<HTMLDivElement>>; Separator: ForwardRefExoticComponent<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { alwaysRender?: boolean; } & RefAttributes<HTMLDivElement>>; Dialog: ForwardRefExoticComponent<DialogProps & Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; shouldFilter?: boolean; filter?: CommandFilter; defaultValue?: string; value?: string; onValueChange?: (value: string) => void; loop?: boolean; disablePointerSelection?: boolean; vimBindings?: boolean; } & { overlayClassName?: string; contentClassName?: string; container?: HTMLElement; } & RefAttributes<HTMLDivElement>>; Empty: ForwardRefExoticComponent<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & RefAttributes<HTMLDivElement>>; Loading: ForwardRefExoticComponent<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { progress?: number; label?: string; } & RefAttributes<HTMLDivElement>>; }
|
||||||
ComponentSlot (value): ({ slotId, props, when, }: { slotId: string; props?: Record<string, unknown>; when?: Record<string, unknown>; }) => Element
|
ComponentSlot (value): ({ slotId, props, when, }: { slotId: string; props?: Record<string, unknown>; when?: Record<string, unknown>; }) => Element
|
||||||
ConfirmOptions (type): ConfirmOptions
|
ConfirmOptions (type): ConfirmOptions
|
||||||
ConnectionGate (value): (props: ConnectionGateProps) => Element
|
ConnectionGate (value): (props: ConnectionGateProps) => Element
|
||||||
@@ -45,9 +65,26 @@ ConnectionScreen (value): ({ status, message, detail, errorDetail, unavailable,
|
|||||||
ConnectionStage (type): ConnectionStage
|
ConnectionStage (type): ConnectionStage
|
||||||
ConnectionStatus (type): ConnectionStatus
|
ConnectionStatus (type): ConnectionStatus
|
||||||
ConnectionUnavailable (type): ConnectionUnavailable
|
ConnectionUnavailable (type): ConnectionUnavailable
|
||||||
|
CreatedApiKey (type): CreatedApiKey
|
||||||
|
Credential (type): Credential
|
||||||
|
CredentialDensity (type): CredentialDensity
|
||||||
|
CredentialPermissionLevel (type): CredentialPermissionLevel
|
||||||
|
CredentialRowFields (type): CredentialRowFields
|
||||||
|
CredentialSidebarFilterState (type): CredentialSidebarFilterState
|
||||||
|
CredentialSidebarPreferences (type): CredentialSidebarPreferences
|
||||||
|
CredentialSortKey (type): CredentialSortKey
|
||||||
|
CredentialTrayTrigger (type): CredentialTrayTrigger
|
||||||
CustomKeybinding (type): CustomKeybinding
|
CustomKeybinding (type): CustomKeybinding
|
||||||
|
DASHBOARD_CARDS (value): DashboardCardConfig[]
|
||||||
|
DEFAULT_FOLDER_COLOR (value): "#f59145"
|
||||||
|
DEFAULT_FOLDER_ICON (value): "folder"
|
||||||
|
DashboardCardId (type): DashboardCardId
|
||||||
DataColumn (type): DataColumn<T>
|
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
|
||||||
|
DesktopUpdateState (type): DesktopUpdateState
|
||||||
Dialog (value): ({ ...props }: DialogProps) => Element
|
Dialog (value): ({ ...props }: DialogProps) => Element
|
||||||
DialogClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
|
DialogClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
|
||||||
DialogContent (value): ({ className, children, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { showCloseButton?: boolean; }) => Element
|
DialogContent (value): ({ className, children, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { showCloseButton?: boolean; }) => Element
|
||||||
@@ -60,6 +97,7 @@ DialogTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<
|
|||||||
DialogTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
DialogTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||||
DocsLink (value): ({ core, page, anchor, href, variant, children, className, }: DocsLinkProps) => Element
|
DocsLink (value): ({ core, page, anchor, href, variant, children, className, }: DocsLinkProps) => Element
|
||||||
DocsLinkProps (type): DocsLinkProps
|
DocsLinkProps (type): DocsLinkProps
|
||||||
|
DocsPage (type): "dashboard" | "users" | "sessions" | "roles" | "branding" | "database" | "manage" | "settings" | "plugins" | "install" | "cli" | "develop" | "home" | "updating" | "betas" | "envVars" | "https" | "reverseProxy" | "desktopSync" | "interface" | "hosts" | "portKnocking" | "hostDefaults" | "credentials" | "sharing" | "account" | "accountSecurity" | "accountData" | "apiKeys" | "commandPalette" | "interfaceSettings" | "appearance" | "keybindings" | "adminGeneral" | "adminDatabase" | "adminSsl" | "auditLog" | "importExport" | "pluginCatalog" | "communityRegistry" | "pluginRegistries" | "hostRegistry"
|
||||||
DropdownMenu (value): ({ ...props }: DropdownMenuProps) => Element
|
DropdownMenu (value): ({ ...props }: DropdownMenuProps) => Element
|
||||||
DropdownMenuCheckboxItem (value): ({ className, children, checked, inset, ...props }: DropdownMenuCheckboxItemProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
|
DropdownMenuCheckboxItem (value): ({ className, children, checked, inset, ...props }: DropdownMenuCheckboxItemProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
|
||||||
DropdownMenuContent (value): ({ className, align, sideOffset, ...props }: DropdownMenuContentProps & RefAttributes<HTMLDivElement>) => Element
|
DropdownMenuContent (value): ({ className, align, sideOffset, ...props }: DropdownMenuContentProps & RefAttributes<HTMLDivElement>) => Element
|
||||||
@@ -78,10 +116,14 @@ DropdownMenuTrigger (value): ({ ...props }: DropdownMenuTriggerProps & RefAttrib
|
|||||||
EditingWidth (type): EditingWidth
|
EditingWidth (type): EditingWidth
|
||||||
EmptyState (value): ({ icon: Icon, title, hint, action, className, }: { icon?: LucideIcon; title: string; hint?: string; action?: ReactNode; className?: string; }) => Element
|
EmptyState (value): ({ icon: Icon, title, hint, action, className, }: { icon?: LucideIcon; title: string; hint?: string; action?: ReactNode; className?: string; }) => Element
|
||||||
FOLDER_COLORS (value): string[]
|
FOLDER_COLORS (value): string[]
|
||||||
|
FONT_SIZES (value): { id: FontSizeId; label: string; }[]
|
||||||
Facts (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element
|
Facts (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element
|
||||||
FakeSwitch (value): ({ defaultChecked, disabled, checked, onChange, }: { defaultChecked?: boolean; disabled?: boolean; checked?: boolean; onChange?: (v: boolean) => void; }) => Element
|
FakeSwitch (value): ({ defaultChecked, disabled, checked, onChange, }: { defaultChecked?: boolean; disabled?: boolean; checked?: boolean; onChange?: (v: boolean) => void; }) => Element
|
||||||
Field (value): ({ label, hint, error, aside, htmlFor, className, children, }: { label: string; hint?: ReactNode; error?: string; aside?: ReactNode; htmlFor?: string; className?: string; children: ReactNode; }) => Element
|
Field (value): ({ label, hint, error, aside, htmlFor, className, children, }: { label: string; hint?: ReactNode; error?: string; aside?: ReactNode; htmlFor?: string; className?: string; children: ReactNode; }) => Element
|
||||||
FieldPair (value): ({ children }: { children: ReactNode; }) => Element
|
FieldPair (value): ({ children }: { children: ReactNode; }) => Element
|
||||||
|
FolderAccessRule (type): FolderAccessRule
|
||||||
|
FolderIconEl (value): ({ icon, className, style, }: { icon?: string; className?: string; style?: CSSProperties; }) => Element
|
||||||
|
FontSizeId (type): FontSizeId
|
||||||
FormFooter (value): ({ onCancel, onSave, saveLabel, cancelLabel, saving, dirty, status, disabled, onDelete, deleteLabel, extra, saveType, form, }: { onCancel?: () => void; onSave?: () => void; saveLabel?: string; cancelLabel?: string; saving?: boolean; dirty?: boolean; status?: ReactNode; disabled?: boolean; onDelete?: () => void; deleteLabel?: string; extra?: ReactNode; saveType?: "button" | "submit"; form?: string; }) => Element
|
FormFooter (value): ({ onCancel, onSave, saveLabel, cancelLabel, saving, dirty, status, disabled, onDelete, deleteLabel, extra, saveType, form, }: { onCancel?: () => void; onSave?: () => void; saveLabel?: string; cancelLabel?: string; saving?: boolean; dirty?: boolean; status?: ReactNode; disabled?: boolean; onDelete?: () => void; deleteLabel?: string; extra?: ReactNode; saveType?: "button" | "submit"; form?: string; }) => Element
|
||||||
FullScreenAppPhase (type): FullScreenAppPhase
|
FullScreenAppPhase (type): FullScreenAppPhase
|
||||||
FullScreenAppWrapper (value): FC<FullScreenAppWrapperProps>
|
FullScreenAppWrapper (value): FC<FullScreenAppWrapperProps>
|
||||||
@@ -89,13 +131,39 @@ GridCardCatalogEntry (type): GridCardCatalogEntry
|
|||||||
GridColSpan (type): GridColSpan
|
GridColSpan (type): GridColSpan
|
||||||
GridColumns (type): GridColumns
|
GridColumns (type): GridColumns
|
||||||
GridLayout (type): GridLayout
|
GridLayout (type): GridLayout
|
||||||
|
GridSize (type): GridSize
|
||||||
GridSlot (type): GridSlot
|
GridSlot (type): GridSlot
|
||||||
GroupHeading (value): ({ title, count, action, className, }: { title: string; count?: number; action?: ReactNode; className?: string; }) => Element
|
GroupHeading (value): ({ title, count, action, className, }: { title: string; count?: number; action?: ReactNode; className?: string; }) => Element
|
||||||
|
HexColorPicker (value): ({ value, onChange, }: { value: string; onChange: (hex: string) => void; }) => Element
|
||||||
|
Host (type): Host
|
||||||
|
HostActionDef (type): HostActionDef
|
||||||
|
HostClickBehavior (type): HostClickBehavior
|
||||||
|
HostData (type): HostData
|
||||||
HostDefaultBadge (value): ({ settingKey }: { settingKey: string; }) => Element
|
HostDefaultBadge (value): ({ settingKey }: { settingKey: string; }) => Element
|
||||||
HostDefaultField (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
|
HostDefaultField (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
|
||||||
|
HostDefaultsChange (type): HostDefaultsChange
|
||||||
|
HostDefaultsContext (value): Context<HostDefaultsContextValue>
|
||||||
|
HostDefaultsContextValue (type): HostDefaultsContextValue
|
||||||
|
HostDefaultsLevel (type): HostDefaultsLevel
|
||||||
|
HostDefaultsTarget (type): HostDefaultsTarget
|
||||||
|
HostDensity (type): HostDensity
|
||||||
HostFeatureFields (value): ({ form, updateForm, }: HostFeatureFieldsProps) => Element
|
HostFeatureFields (value): ({ form, updateForm, }: HostFeatureFieldsProps) => Element
|
||||||
HostFeatureFieldsProps (type): HostFeatureFieldsProps
|
HostFeatureFieldsProps (type): HostFeatureFieldsProps
|
||||||
|
HostFolder (type): HostFolder
|
||||||
|
HostGroupKey (type): HostGroupKey
|
||||||
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
|
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
|
||||||
|
HostLock (type): HostLock
|
||||||
|
HostOnly (value): ({ children }: { children: ReactNode; }) => Element
|
||||||
|
HostPolicy (type): HostPolicy
|
||||||
|
HostProtocolAuthInput (type): HostProtocolAuthInput
|
||||||
|
HostProtocolDef (type): HostProtocolDef
|
||||||
|
HostProtocols (type): HostProtocols
|
||||||
|
HostRowFields (type): HostRowFields
|
||||||
|
HostSidebarFilterState (type): HostSidebarFilterState
|
||||||
|
HostSidebarPreferences (type): HostSidebarPreferences
|
||||||
|
HostTrayTrigger (type): HostTrayTrigger
|
||||||
|
HttpsSettings (type): HttpsSettings
|
||||||
|
IconPicker (value): ({ value, color, onChange, }: { value: string; color?: string; onChange: (icon: string) => void; }) => Element
|
||||||
InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; icon?: ReactNode; status?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; footer?: ReactNode; width?: EditingWidth; onBeforeClose?: () => boolean | Promise<boolean>; scroll?: boolean; bare?: boolean; className?: string; children: ReactNode; }) => ReactPortal
|
InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; icon?: ReactNode; status?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; footer?: ReactNode; width?: EditingWidth; onBeforeClose?: () => boolean | Promise<boolean>; scroll?: boolean; bare?: boolean; className?: string; children: ReactNode; }) => ReactPortal
|
||||||
Input (value): ({ className, type, ...props }: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) => Element
|
Input (value): ({ className, type, ...props }: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) => Element
|
||||||
Kbd (value): ForwardRefExoticComponent<KbdProps & RefAttributes<HTMLElement>>
|
Kbd (value): ForwardRefExoticComponent<KbdProps & RefAttributes<HTMLElement>>
|
||||||
@@ -103,10 +171,13 @@ KbdKey (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): El
|
|||||||
KbdSeparator (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): Element; displayName: string; }
|
KbdSeparator (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): Element; displayName: string; }
|
||||||
KeyCombo (type): KeyCombo
|
KeyCombo (type): KeyCombo
|
||||||
KeybindingAction (type): KeybindingAction
|
KeybindingAction (type): KeybindingAction
|
||||||
|
KeybindingActionDef (type): KeybindingActionDef
|
||||||
KeybindingActionType (type): string
|
KeybindingActionType (type): string
|
||||||
|
LANGUAGES (value): readonly { code: string; label: string; }[]
|
||||||
Label (value): ({ className, ...props }: LabelProps & RefAttributes<HTMLLabelElement>) => Element
|
Label (value): ({ className, ...props }: LabelProps & RefAttributes<HTMLLabelElement>) => Element
|
||||||
LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element
|
LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element
|
||||||
LineChartSeries (type): LineChartSeries
|
LineChartSeries (type): LineChartSeries
|
||||||
|
LinkedAccountInfo (type): LinkedAccountInfo
|
||||||
ListBadge (value): ({ tone, className, children, }: { tone?: "destructive" | "muted" | "brand" | "warning" | "success"; className?: string; children: ReactNode; }) => Element
|
ListBadge (value): ({ tone, className, children, }: { tone?: "destructive" | "muted" | "brand" | "warning" | "success"; className?: string; children: ReactNode; }) => Element
|
||||||
ListRow (value): ({ title, icon, badges, meta, stripe, tone, color, selected, active, dimmed, leading, trailing, actions, onClick, className, children, ...rest }: { title: ReactNode; icon?: ReactNode; badges?: ReactNode; meta?: ReactNode; stripe?: number; tone?: ListRowTone; color?: string; selected?: boolean; active?: boolean; dimmed?: boolean; leading?: ReactNode; trailing?: ReactNode; actions?: ReactNode; onClick?: () => void; className?: string; children?: ReactNode; } & Omit<HTMLAttributes<HTMLDivElement>, "title" | "children" | "onClick">) => Element
|
ListRow (value): ({ title, icon, badges, meta, stripe, tone, color, selected, active, dimmed, leading, trailing, actions, onClick, className, children, ...rest }: { title: ReactNode; icon?: ReactNode; badges?: ReactNode; meta?: ReactNode; stripe?: number; tone?: ListRowTone; color?: string; selected?: boolean; active?: boolean; dimmed?: boolean; leading?: ReactNode; trailing?: ReactNode; actions?: ReactNode; onClick?: () => void; className?: string; children?: ReactNode; } & Omit<HTMLAttributes<HTMLDivElement>, "title" | "children" | "onClick">) => Element
|
||||||
ListRowAction (value): ({ label, tone, className, children, ...rest }: { label: string; tone?: "default" | "destructive" | "brand"; className?: string; children: ReactNode; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => Element
|
ListRowAction (value): ({ label, tone, className, children, ...rest }: { label: string; tone?: "default" | "destructive" | "brand"; className?: string; children: ReactNode; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => Element
|
||||||
@@ -114,18 +185,29 @@ ListRowFolder (value): ({ name, icon, count, open, onToggle, stripe, actions, dr
|
|||||||
ListRowTone (type): ListRowTone
|
ListRowTone (type): ListRowTone
|
||||||
LogContext (type): LogContext
|
LogContext (type): LogContext
|
||||||
LogEntry (type): LogEntry
|
LogEntry (type): LogEntry
|
||||||
|
LookSettings (value): ({ isAdmin }: { isAdmin: boolean; }) => Element
|
||||||
|
MANAGE_REQUEST_EVENT (value): "manage:request"
|
||||||
|
MAX_PANES (value): 8
|
||||||
MFAPromptMode (type): MFAPromptMode
|
MFAPromptMode (type): MFAPromptMode
|
||||||
|
ManageMode (type): ManageMode
|
||||||
|
ManageRequest (type): ManageRequest
|
||||||
ManagerCardError (type): ManagerCardError
|
ManagerCardError (type): ManagerCardError
|
||||||
ManagerCardShell (value): ({ title, icon, loading, error, onRefresh, empty, emptyMessage, children, headerExtra, }: { title: string; icon: ReactNode; loading: boolean; error: ManagerCardError; onRefresh: () => void; empty?: boolean; emptyMessage?: string; children: ReactNode; headerExtra?: ReactNode; }) => Element
|
ManagerCardShell (value): ({ title, icon, loading, error, onRefresh, empty, emptyMessage, children, headerExtra, }: { title: string; icon: ReactNode; loading: boolean; error: ManagerCardError; onRefresh: () => void; empty?: boolean; emptyMessage?: string; children: ReactNode; headerExtra?: ReactNode; }) => Element
|
||||||
ManagerSearch (value): ({ value, onChange, placeholder, count, extra, }: { value: string; onChange: (value: string) => void; placeholder?: string; count?: number; extra?: ReactNode; }) => Element
|
ManagerSearch (value): ({ value, onChange, placeholder, count, extra, }: { value: string; onChange: (value: string) => void; placeholder?: string; count?: number; extra?: ReactNode; }) => Element
|
||||||
Meter (value): ({ percent, className, }: { percent: number; className?: string; }) => Element
|
Meter (value): ({ percent, className, }: { percent: number; className?: string; }) => Element
|
||||||
MetricCard (value): ({ title, icon, action, children, bodyClassName, scroll, scrollMax, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; bodyClassName?: string; scroll?: boolean; scrollMax?: number; }) => Element
|
MetricCard (value): ({ title, icon, action, children, bodyClassName, scroll, scrollMax, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; bodyClassName?: string; scroll?: boolean; scrollMax?: number; }) => Element
|
||||||
MiniStat (value): ({ caption, value, valueClassName, className, }: { caption: ReactNode; value: ReactNode; valueClassName?: string; className?: string; }) => Element
|
MiniStat (value): ({ caption, value, valueClassName, className, }: { caption: ReactNode; value: ReactNode; valueClassName?: string; className?: string; }) => Element
|
||||||
|
NativeView (value): ({ url, ignoreCert, hidden, fallback, className, style, }: NativeViewProps) => Element
|
||||||
|
NativeViewProps (type): NativeViewProps
|
||||||
NumberField (value): ({ label, value, onChange, placeholder, hint, error, min, max, disabled, className, }: { label: string; value: number; onChange: (value: number) => void; placeholder?: string; hint?: ReactNode; error?: string; min?: number; max?: number; disabled?: boolean; className?: string; }) => Element
|
NumberField (value): ({ label, value, onChange, placeholder, hint, error, min, max, disabled, className, }: { label: string; value: number; onChange: (value: number) => void; placeholder?: string; hint?: ReactNode; error?: string; min?: number; max?: number; disabled?: boolean; className?: string; }) => Element
|
||||||
|
OPEN_PLUGINS_EVENT (value): "termix:open-plugins"
|
||||||
|
OpenTabRecord (type): OpenTabRecord
|
||||||
PANEL (value): { readonly body: "p-2.5"; readonly gap: "gap-2"; readonly band: "px-3 py-2"; readonly cardHead: "px-3 py-2.5"; readonly header: "h-12.5"; }
|
PANEL (value): { readonly body: "p-2.5"; readonly gap: "gap-2"; readonly band: "px-3 py-2"; readonly cardHead: "px-3 py-2.5"; readonly header: "h-12.5"; }
|
||||||
|
PRESETS (value): Record<"simple" | "balanced" | "advanced", UiAreaPreferences>
|
||||||
PROMPT_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-border px-3 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
PROMPT_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-border px-3 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||||
PROMPT_DESTRUCTIVE_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 bg-destructive px-3 text-xs font-medium text-white transition-colors hover:bg-destructive/90 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
PROMPT_DESTRUCTIVE_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 bg-destructive px-3 text-xs font-medium text-white transition-colors hover:bg-destructive/90 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||||
PROMPT_PRIMARY_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-accent-brand/40 px-3 text-xs font-medium text-accent-brand transition-colors hover:bg-accent-brand/10 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
PROMPT_PRIMARY_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-accent-brand/40 px-3 text-xs font-medium text-accent-brand transition-colors hover:bg-accent-brand/10 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
PaletteItemDef (type): PaletteItemDef
|
||||||
PanePrompt (value): ({ open, title, titleId, description, icon, tone, actions, onCancel, role, labelledBy, layer, backgroundColor, className, children, }: { open: boolean; title: ReactNode; titleId?: string; description?: ReactNode; icon?: ReactNode; tone?: "default" | "destructive"; actions?: ReactNode; onCancel?: () => void; role?: "dialog" | "alertdialog"; labelledBy?: string; layer?: "connection" | "pane"; backgroundColor?: string; className?: string; children?: ReactNode; }) => Element
|
PanePrompt (value): ({ open, title, titleId, description, icon, tone, actions, onCancel, role, labelledBy, layer, backgroundColor, className, children, }: { open: boolean; title: ReactNode; titleId?: string; description?: ReactNode; icon?: ReactNode; tone?: "default" | "destructive"; actions?: ReactNode; onCancel?: () => void; role?: "dialog" | "alertdialog"; labelledBy?: string; layer?: "connection" | "pane"; backgroundColor?: string; className?: string; children?: ReactNode; }) => Element
|
||||||
PanelDensity (type): PanelDensity
|
PanelDensity (type): PanelDensity
|
||||||
PanelList (value): ({ empty, className, children, }: { empty?: ReactNode; className?: string; children?: ReactNode; }) => Element
|
PanelList (value): ({ empty, className, children, }: { empty?: ReactNode; className?: string; children?: ReactNode; }) => Element
|
||||||
@@ -134,18 +216,34 @@ PanelShell (value): ({ chrome, icon, title, status, leading, actions, toolbar, t
|
|||||||
PanelViewMode (type): PanelViewMode
|
PanelViewMode (type): PanelViewMode
|
||||||
PassphraseDialog (value): ({ isOpen, onSubmit, onCancel, hostInfo, backgroundColor, }: PassphraseDialogProps) => Element
|
PassphraseDialog (value): ({ isOpen, onSubmit, onCancel, hostInfo, backgroundColor, }: PassphraseDialogProps) => Element
|
||||||
PasswordInput (value): ForwardRefExoticComponent<PasswordInputProps & RefAttributes<HTMLInputElement>>
|
PasswordInput (value): ForwardRefExoticComponent<PasswordInputProps & RefAttributes<HTMLInputElement>>
|
||||||
|
PermissionCatalogEntry (type): PermissionCatalogEntry
|
||||||
PluginComponent (value): ({ id, fallback, ...props }: { [prop: string]: unknown; id: string; fallback?: ReactNode; }) => ReactNode
|
PluginComponent (value): ({ id, fallback, ...props }: { [prop: string]: unknown; id: string; fallback?: ReactNode; }) => ReactNode
|
||||||
|
PluginIcon (value): ({ name, ...props }: { name?: string; } & LucideProps) => Element
|
||||||
|
PluginSettingsErrors (type): PluginSettingsErrors
|
||||||
|
PluginSettingsField (type): PluginSettingsField
|
||||||
|
PluginSettingsValidationError (value): typeof PluginSettingsValidationError
|
||||||
|
PluginSummary (type): PluginSummary
|
||||||
PluginViewPlaceholder (value): ({ kind, viewId, compact, }: { kind: ViewKind; viewId: string; compact?: boolean; }) => Element
|
PluginViewPlaceholder (value): ({ kind, viewId, compact, }: { kind: ViewKind; viewId: string; compact?: boolean; }) => Element
|
||||||
Popover (value): ({ ...props }: PopoverProps) => Element
|
Popover (value): ({ ...props }: PopoverProps) => Element
|
||||||
PopoverAnchor (value): ({ ...props }: PopoverAnchorProps & RefAttributes<HTMLDivElement>) => Element
|
PopoverAnchor (value): ({ ...props }: PopoverAnchorProps & RefAttributes<HTMLDivElement>) => Element
|
||||||
PopoverContent (value): ({ className, align, sideOffset, ...props }: PopoverContentProps & RefAttributes<HTMLDivElement>) => Element
|
PopoverContent (value): ({ className, align, sideOffset, ...props }: PopoverContentProps & RefAttributes<HTMLDivElement>) => Element
|
||||||
PopoverTrigger (value): ({ ...props }: PopoverTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
PopoverTrigger (value): ({ ...props }: PopoverTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||||
|
ProxyNode (type): ProxyNode
|
||||||
RadialGauge (value): ({ value, label, caption, size, strokeWidth, className, colorClassName, }: RadialGaugeProps) => Element
|
RadialGauge (value): ({ value, label, caption, size, strokeWidth, className, colorClassName, }: RadialGaugeProps) => Element
|
||||||
RadialGaugeProps (type): RadialGaugeProps
|
RadialGaugeProps (type): RadialGaugeProps
|
||||||
|
RailGroup (type): RailGroup
|
||||||
|
RailItemDef (type): RailItemDef
|
||||||
RecentActivityItem (type): RecentActivityItem
|
RecentActivityItem (type): RecentActivityItem
|
||||||
|
ReleaseInfo (type): ReleaseInfo
|
||||||
Repeater (value): <T>({ label, items, onChange, makeItem, empty, addLabel, hint, renderItem, }: { label: string; items: T[]; onChange: (items: T[]) => void; makeItem: () => T; empty: string; addLabel: string; hint?: ReactNode; renderItem: (item: T, update: (next: T) => void, index: number) => ReactNode; }) => Element
|
Repeater (value): <T>({ label, items, onChange, makeItem, empty, addLabel, hint, renderItem, }: { label: string; items: T[]; onChange: (items: T[]) => void; makeItem: () => T; empty: string; addLabel: string; hint?: ReactNode; renderItem: (item: T, update: (next: T) => void, index: number) => ReactNode; }) => Element
|
||||||
|
ResolvedHostDefault (type): ResolvedHostDefault
|
||||||
RobustClipboardProvider (value): typeof RobustClipboardProvider
|
RobustClipboardProvider (value): typeof RobustClipboardProvider
|
||||||
|
Role (type): Role
|
||||||
SSHAuthDialog (value): ({ isOpen, reason, onSubmit, onCancel, hostInfo, backgroundColor, }: SSHAuthDialogProps) => Element
|
SSHAuthDialog (value): ({ isOpen, reason, onSubmit, onCancel, hostInfo, backgroundColor, }: SSHAuthDialogProps) => Element
|
||||||
|
SSHHost (type): Host
|
||||||
|
SSHHostData (type): HostData
|
||||||
|
SSHHostWithStatus (type): SSHHostWithStatus
|
||||||
|
SSH_AUTH_PROTOCOL (value): "ssh"
|
||||||
ScrollArea (value): ({ className, children, ...props }: ScrollAreaProps & RefAttributes<HTMLDivElement>) => Element
|
ScrollArea (value): ({ className, children, ...props }: ScrollAreaProps & RefAttributes<HTMLDivElement>) => Element
|
||||||
ScrollBar (value): ({ className, orientation, ...props }: ScrollAreaScrollbarProps & RefAttributes<HTMLDivElement>) => Element
|
ScrollBar (value): ({ className, orientation, ...props }: ScrollAreaScrollbarProps & RefAttributes<HTMLDivElement>) => Element
|
||||||
SecretField (value): ({ label, value, onChange, placeholder, hint, error, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; disabled?: boolean; className?: string; }) => Element
|
SecretField (value): ({ label, value, onChange, placeholder, hint, error, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; disabled?: boolean; className?: string; }) => Element
|
||||||
@@ -165,6 +263,9 @@ SelectTrigger (value): ({ className, size, children, ...props }: SelectTriggerPr
|
|||||||
SelectValue (value): ({ ...props }: SelectValueProps & RefAttributes<HTMLSpanElement>) => Element
|
SelectValue (value): ({ ...props }: SelectValueProps & RefAttributes<HTMLSpanElement>) => Element
|
||||||
Separator (value): ({ className, orientation, decorative, ...props }: SeparatorProps & RefAttributes<HTMLDivElement>) => Element
|
Separator (value): ({ className, orientation, decorative, ...props }: SeparatorProps & RefAttributes<HTMLDivElement>) => Element
|
||||||
SettingRow (value): ({ label, badge, description, defaultKey, rowId, children, }: { label: string; badge?: string; description?: ReactNode; defaultKey?: string; rowId?: string; children: ReactNode; }) => Element
|
SettingRow (value): ({ label, badge, description, defaultKey, rowId, children, }: { label: string; badge?: string; description?: ReactNode; defaultKey?: string; rowId?: string; children: ReactNode; }) => Element
|
||||||
|
SettingsFieldRow (value): ({ pluginId, field, values, setValue, running, error, disabled, defaultKey, }: SettingsFieldProps) => Element
|
||||||
|
SharePermissionLevel (type): SharePermissionLevel
|
||||||
|
ShareTarget (type): ShareTarget
|
||||||
Sheet (value): ({ ...props }: DialogProps) => Element
|
Sheet (value): ({ ...props }: DialogProps) => Element
|
||||||
SheetClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
|
SheetClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
|
||||||
SheetContent (value): ({ className, children, side, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { side?: "top" | "right" | "bottom" | "left"; showCloseButton?: boolean; }) => Element
|
SheetContent (value): ({ className, children, side, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { side?: "top" | "right" | "bottom" | "left"; showCloseButton?: boolean; }) => Element
|
||||||
@@ -173,71 +274,360 @@ SheetFooter (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<
|
|||||||
SheetHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
SheetHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||||
SheetTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
|
SheetTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
|
||||||
SheetTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
SheetTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||||
|
ShellConfirmCapture (value): () => null
|
||||||
Skeleton (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
Skeleton (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||||
Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderProps & RefAttributes<HTMLSpanElement>) => Element
|
Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderProps & RefAttributes<HTMLSpanElement>) => Element
|
||||||
|
SortKey (type): SortKey
|
||||||
Sparkline (value): ({ data, domain, className, colorClassName, showLastDot, height, }: SparklineProps) => Element
|
Sparkline (value): ({ data, domain, className, colorClassName, showLastDot, height, }: SparklineProps) => Element
|
||||||
SparklineProps (type): SparklineProps
|
SparklineProps (type): SparklineProps
|
||||||
|
SplitLayoutMenu (value): ({ activeIsSplit, canAddPane, onSplit, onPreset, }: { activeIsSplit: boolean; canAddPane: boolean; onSplit: (edge: "right" | "bottom") => void; onPreset: (id: SplitPresetId) => void; }) => Element
|
||||||
|
SplitOpenTarget (type): SplitOpenTarget
|
||||||
|
SplitSummary (type): SplitSummary
|
||||||
StatRow (value): ({ label, value, mono, className, }: { label: ReactNode; value: ReactNode; mono?: boolean; className?: string; }) => Element
|
StatRow (value): ({ label, value, mono, className, }: { label: ReactNode; value: ReactNode; mono?: boolean; className?: string; }) => Element
|
||||||
|
StatusValue (type): StatusValue
|
||||||
SurfaceKind (type): SurfaceKind
|
SurfaceKind (type): SurfaceKind
|
||||||
SurfaceScope (value): ({ kind, onEditingChange, onClose, className, children, }: { kind?: SurfaceKind; onClose?: () => void; onEditingChange?: (editing: EditingWidth) => void; className?: string; children: ReactNode; }) => Element
|
SurfaceScope (value): ({ kind, onEditingChange, onClose, className, children, }: { kind?: SurfaceKind; onClose?: () => void; onEditingChange?: (editing: EditingWidth) => void; className?: string; children: ReactNode; }) => Element
|
||||||
Switch (value): ({ className, ...props }: SwitchProps & RefAttributes<HTMLButtonElement>) => Element
|
Switch (value): ({ className, ...props }: SwitchProps & RefAttributes<HTMLButtonElement>) => Element
|
||||||
SwitchRow (value): ({ label, description, badge, checked, onChange, disabled, defaultKey, rowId, }: { label: string; description?: ReactNode; badge?: string; checked: boolean; onChange: (value: boolean) => void; disabled?: boolean; defaultKey?: string; rowId?: string; }) => Element
|
SwitchRow (value): ({ label, description, badge, checked, onChange, disabled, defaultKey, rowId, }: { label: string; description?: ReactNode; badge?: string; checked: boolean; onChange: (value: boolean) => void; disabled?: boolean; defaultKey?: string; rowId?: string; }) => Element
|
||||||
TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element
|
TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element
|
||||||
|
Tab (type): Tab
|
||||||
|
TabShellCallbacks (type): TabShellCallbacks
|
||||||
|
TabSplitAction (type): TabSplitAction
|
||||||
|
TabStage (value): ({ className }: { className?: string; }) => Element
|
||||||
TabStrip (value): ({ tabs, activeTab, onTabChange, isActive, variant, trailing, className, }: { tabs: TabStripItem[]; activeTab: string; onTabChange: (id: string) => void; isActive?: (id: string) => boolean; variant?: "secondary" | "primary"; trailing?: ReactNode; className?: string; }) => Element
|
TabStrip (value): ({ tabs, activeTab, onTabChange, isActive, variant, trailing, className, }: { tabs: TabStripItem[]; activeTab: string; onTabChange: (id: string) => void; isActive?: (id: string) => boolean; variant?: "secondary" | "primary"; trailing?: ReactNode; className?: string; }) => Element
|
||||||
TabStripItem (type): TabStripItem
|
TabStripItem (type): TabStripItem
|
||||||
|
TabType (type): TabType
|
||||||
TagInput (value): ({ tags, input, placeholder, onTagsChange, onInputChange, }: { tags: string[]; input: string; placeholder: string; onTagsChange: (tags: string[]) => void; onInputChange: (value: string) => void; }) => Element
|
TagInput (value): ({ tags, input, placeholder, onTagsChange, onInputChange, }: { tags: string[]; input: string; placeholder: string; onTagsChange: (tags: string[]) => void; onInputChange: (value: string) => void; }) => Element
|
||||||
TextAreaField (value): ({ label, value, onChange, placeholder, rows, hint, error, aside, mono, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; rows?: number; hint?: ReactNode; error?: string; aside?: ReactNode; mono?: boolean; disabled?: boolean; className?: string; }) => Element
|
TextAreaField (value): ({ label, value, onChange, placeholder, rows, hint, error, aside, mono, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; rows?: number; hint?: ReactNode; error?: string; aside?: ReactNode; mono?: boolean; disabled?: boolean; className?: string; }) => Element
|
||||||
TextField (value): ({ label, value, onChange, placeholder, hint, error, aside, disabled, mono, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; aside?: ReactNode; disabled?: boolean; mono?: boolean; className?: string; }) => Element
|
TextField (value): ({ label, value, onChange, placeholder, hint, error, aside, disabled, mono, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; aside?: ReactNode; disabled?: boolean; mono?: boolean; className?: string; }) => Element
|
||||||
Textarea (value): ForwardRefExoticComponent<TextareaProps & RefAttributes<HTMLTextAreaElement>>
|
Textarea (value): ForwardRefExoticComponent<TextareaProps & RefAttributes<HTMLTextAreaElement>>
|
||||||
TextareaProps (type): TextareaProps
|
TextareaProps (type): TextareaProps
|
||||||
|
ThemeId (type): string
|
||||||
|
TlsStatus (type): TlsStatus
|
||||||
|
ToolsTab (type): ToolsTab
|
||||||
Tooltip (value): ({ ...props }: TooltipProps) => Element
|
Tooltip (value): ({ ...props }: TooltipProps) => Element
|
||||||
TooltipContent (value): ({ className, sideOffset, children, ...props }: TooltipContentProps & RefAttributes<HTMLDivElement>) => Element
|
TooltipContent (value): ({ className, sideOffset, children, ...props }: TooltipContentProps & RefAttributes<HTMLDivElement>) => Element
|
||||||
TooltipProvider (value): ({ delayDuration, ...props }: TooltipProviderProps) => Element
|
TooltipProvider (value): ({ delayDuration, ...props }: TooltipProviderProps) => Element
|
||||||
TooltipTrigger (value): ({ ...props }: TooltipTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
TooltipTrigger (value): ({ ...props }: TooltipTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||||
|
UI_AREA_KEYS (value): UiAreaKey[]
|
||||||
|
UI_FONTS (value): { id: UiFontId; label: string; family: string; }[]
|
||||||
|
UiAreaKey (type): UiAreaKey
|
||||||
|
UiFontId (type): UiFontId
|
||||||
|
UiPreset (type): UiPreset
|
||||||
|
UpdateChannelState (type): UpdateChannelState
|
||||||
UptimeInfo (type): UptimeInfo
|
UptimeInfo (type): UptimeInfo
|
||||||
UsagePair (value): ({ label, percent, detail, }: { label?: string; percent: number; detail?: string; }) => Element
|
UsagePair (value): ({ label, percent, detail, }: { label?: string; percent: number; detail?: string; }) => Element
|
||||||
|
UserPreferences (type): UserPreferences
|
||||||
|
UserRole (type): UserRole
|
||||||
|
UserSecondFactorSummary (type): UserSecondFactorSummary
|
||||||
|
VersionBadge (value): ({ status, releaseUrl, className, }: VersionBadgeProps) => Element
|
||||||
VersionInfo (type): VersionInfo
|
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
|
WebSocketConnectionTarget (type): WebSocketConnectionTarget
|
||||||
WidgetTitle (value): ({ title, icon, fallback }: WidgetTitleProps) => Element
|
WidgetTitle (value): ({ title, icon, fallback }: WidgetTitleProps) => Element
|
||||||
|
actionOrder (value): (action: HostActionDef) => number
|
||||||
|
activityTarget (value): (type: string) => ActivityTarget
|
||||||
|
adminCreateUser (value): (username: string, password: string) => Promise<Record<string, unknown>>
|
||||||
|
adminCreateUserCredential (value): (targetUserId: string, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||||
|
adminCreateUserHost (value): (targetUserId: string, hostData: HostData) => Promise<Host>
|
||||||
|
adminDeleteUserCredential (value): (targetUserId: string, credentialId: number) => Promise<Record<string, unknown>>
|
||||||
|
adminDeleteUserHost (value): (targetUserId: string, hostId: number) => Promise<Record<string, unknown>>
|
||||||
|
adminExportUserData (value): (userId: string) => Promise<Record<string, unknown>>
|
||||||
|
adminGetHostPassword (value): (targetUserId: string, hostId: number, field?: "key" | "password" | "sudoPassword" | "keyPassword") => Promise<string>
|
||||||
|
adminGetUserCredentials (value): (targetUserId: string) => Promise<Record<string, unknown> | Record<string, unknown>[]>
|
||||||
|
adminGetUserHosts (value): (targetUserId: string) => Promise<Host[]>
|
||||||
|
adminResetUserPassword (value): (userId: string, newPassword: string, confirmDataWipe?: boolean) => Promise<{ message: string; dataWiped?: boolean; }>
|
||||||
|
adminUpdateUserCredential (value): (targetUserId: string, credentialId: number, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||||
|
adminUpdateUserHost (value): (targetUserId: string, hostId: number, hostData: HostData) => Promise<Host>
|
||||||
|
applyAccentColor (value): (colorValue: string) => void
|
||||||
|
applyFontSize (value): (id: FontSizeId) => void
|
||||||
|
applyPresetSideEffects (value): (preset: "simple" | "balanced" | "advanced") => Promise<void>
|
||||||
|
applyUiFont (value): (id: UiFontId) => void
|
||||||
|
assignRoleToUser (value): (userId: string, roleId: number) => Promise<{ success: boolean; }>
|
||||||
|
beginSplitDrag (value): (source: SplitDragSource, x: number, y: number) => void
|
||||||
buildOriginWsUrl (value): ({ origin, localPort, localPath, remotePath, includeJwt, }: { origin: ConnectionOrigin; localPort: number; localPath: string; remotePath: string; includeJwt?: boolean; }) => Promise<WebSocketConnectionTarget>
|
buildOriginWsUrl (value): ({ origin, localPort, localPath, remotePath, includeJwt, }: { origin: ConnectionOrigin; localPort: number; localPath: string; remotePath: string; includeJwt?: boolean; }) => Promise<WebSocketConnectionTarget>
|
||||||
|
bulkUpdateSSHHosts (value): (hostIds: number[], updates: Record<string, unknown>) => Promise<{ updated: number; failed: number; errors: string[]; }>
|
||||||
|
canConfigureUpdates (value): (state: DesktopUpdateState) => state is DesktopUpdateState
|
||||||
|
canJoinSplit (value): (tab: Tab) => boolean
|
||||||
|
changeAppLanguage (value): (language: string) => Promise<string>
|
||||||
|
changePassword (value): (oldPassword: string, newPassword: string) => Promise<any>
|
||||||
|
clearLocalAdaptivePreferences (value): () => void
|
||||||
cn (value): (...inputs: ClassValue[]) => string
|
cn (value): (...inputs: ClassValue[]) => string
|
||||||
copyToClipboard (value): (text: string) => Promise<boolean>
|
copyToClipboard (value): (text: string) => Promise<boolean>
|
||||||
|
createApiKey (value): (name: string, userId: string, expiresAt?: string) => Promise<CreatedApiKey>
|
||||||
|
createCredential (value): (credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||||
createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger
|
createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger
|
||||||
createQuickConnectHost (value): (input: QuickConnectInput) => Host
|
createQuickConnectHost (value): (input: QuickConnectInput) => Host
|
||||||
|
createRole (value): (roleData: { name: string; displayName: string; description?: string; }) => Promise<{ role: Role; }>
|
||||||
|
createSSHHost (value): (hostData: HostData) => Promise<Host>
|
||||||
|
defaultConnectAction (value): (all: HostActionDef[], host: Host) => HostActionDef
|
||||||
|
defaultCredentialRowFields (value): () => CredentialRowFields
|
||||||
|
defaultCredentialSidebarPreferences (value): () => CredentialSidebarPreferences
|
||||||
|
defaultHostRowFields (value): () => HostRowFields
|
||||||
|
defaultHostSidebarPreferences (value): () => HostSidebarPreferences
|
||||||
|
defaultValuesEqual (value): (a: unknown, b: unknown) => boolean
|
||||||
|
deleteAccount (value): (password: string) => Promise<Record<string, unknown>>
|
||||||
|
deleteAllHostsInFolder (value): (folderName: string) => Promise<{ deletedCount: number; }>
|
||||||
|
deleteApiKey (value): (keyId: string) => Promise<{ success: boolean; }>
|
||||||
|
deleteCredential (value): (credentialId: number) => Promise<Record<string, unknown>>
|
||||||
|
deleteOpenTab (value): (instanceId: string) => Promise<void>
|
||||||
|
deleteRole (value): (roleId: number) => Promise<{ success: boolean; }>
|
||||||
|
deleteSSHHost (value): (hostId: number) => Promise<Record<string, unknown>>
|
||||||
|
deleteUser (value): (username: string, successorUserId?: string) => Promise<Record<string, unknown>>
|
||||||
|
desktopUpdates (value): () => DesktopUpdatesBridge
|
||||||
|
docsUrl (value): (page?: "dashboard" | "users" | "sessions" | "roles" | "branding" | "database" | "manage" | "settings" | "plugins" | "install" | "cli" | "develop" | "home" | "updating" | "betas" | "envVars" | "https" | "reverseProxy" | "desktopSync" | "interface" | "hosts" | "portKnocking" | "hostDefaults" | "credentials" | "sharing" | "account" | "accountSecurity" | "accountData" | "apiKeys" | "commandPalette" | "interfaceSettings" | "appearance" | "keybindings" | "adminGeneral" | "adminDatabase" | "adminSsl" | "auditLog" | "importExport" | "pluginCatalog" | "communityRegistry" | "pluginRegistries" | "hostRegistry") => string
|
||||||
|
duplicateCredential (value): (credentialId: number, data: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||||
|
enabledHostProtocols (value): (host: { pluginSettings?: Record<string, Record<string, unknown>>; }, list?: HostProtocolDef[]) => HostProtocolDef[]
|
||||||
|
endSplitDrag (value): (commit: boolean) => void
|
||||||
|
filterPaletteItems (value): (items: PaletteItemDef[], query: string, showWhenEmpty?: boolean) => PaletteItemDef[]
|
||||||
|
findCoreHostDefault (value): (key: string) => CoreHostDefault
|
||||||
findMatchingKeybinding (value): (e: KeyboardEvent, bindings: CustomKeybinding[]) => CustomKeybinding
|
findMatchingKeybinding (value): (e: KeyboardEvent, bindings: CustomKeybinding[]) => CustomKeybinding
|
||||||
|
getActiveSessions (value): () => Promise<ActiveSessionInfo[]>
|
||||||
getAdaptiveResourceBudget (value): (kind: AdaptiveTaskKind) => AdaptiveResourceBudget
|
getAdaptiveResourceBudget (value): (kind: AdaptiveTaskKind) => AdaptiveResourceBudget
|
||||||
|
getApiKeys (value): () => Promise<{ apiKeys: ApiKey[]; }>
|
||||||
|
getAuditLogActions (value): () => Promise<{ actions: string[]; }>
|
||||||
|
getAuditLogs (value): (filters?: AuditLogFilters) => Promise<AuditLogResponse>
|
||||||
getBasePath (value): () => string
|
getBasePath (value): () => string
|
||||||
|
getBranding (value): () => Promise<BrandingSettings>
|
||||||
|
getChannelReleases (value): () => Promise<{ localVersion: string; stable: ReleaseInfo; beta: ReleaseInfo; }>
|
||||||
|
getConnectedRemoteApi (value): () => Promise<AxiosInstance>
|
||||||
|
getCredentialAccess (value): (credentialId: number) => Promise<{ access: AccessRecord[]; }>
|
||||||
|
getCredentialDetails (value): (credentialId: number) => Promise<Record<string, unknown>>
|
||||||
|
getCredentialSidebarPreferences (value): () => Promise<CredentialSidebarPreferences>
|
||||||
|
getCredentials (value): () => Promise<Record<string, unknown> | Record<string, unknown>[]>
|
||||||
getDatabaseHealth (value): () => Promise<Record<string, unknown>>
|
getDatabaseHealth (value): () => Promise<Record<string, unknown>>
|
||||||
|
getDatabaseTransferUrl (value): (operation: "export" | "import", { electron, configuredServerUrl, location }: DatabaseTransferUrlOptions) => string
|
||||||
|
getDefaultConnectionTab (value): (host: Host) => TabType
|
||||||
|
getDeveloperMode (value): () => Promise<DeveloperModeState>
|
||||||
getDeviceId (value): () => string
|
getDeviceId (value): () => string
|
||||||
|
getErrorMessage (value): (error: unknown, fallback?: string) => string
|
||||||
|
getExternalAutoProvision (value): () => Promise<{ enabled: boolean; }>
|
||||||
|
getFolderAccess (value): (folder: string) => Promise<{ rules: FolderAccessRule[]; }>
|
||||||
|
getFolderDefaultsId (value): (name: string) => Promise<number>
|
||||||
|
getHostAccess (value): (hostId: number, syncId?: string) => Promise<{ accessList: AccessRecord[]; isOwner?: boolean; }>
|
||||||
|
getHostAuthOverride (value): (hostId: number, protocol: string, sharedCopySyncId?: string) => Promise<{ protocol: string; credentialId: number; }>
|
||||||
|
getHostDefaultsLevel (value): (target: HostDefaultsTarget) => Promise<HostDefaultsLevelData>
|
||||||
|
getHostEditorSection (value): (id: string) => HostEditorSectionDef
|
||||||
|
getHostPassword (value): (hostId: number, field?: "key" | "password" | "sudoPassword" | "keyPassword") => Promise<string>
|
||||||
|
getHostPolicy (value): () => Promise<HostPolicy>
|
||||||
|
getHostSidebarPreferences (value): () => Promise<HostSidebarPreferences>
|
||||||
|
getHostTags (value): () => Promise<string[]>
|
||||||
|
getHttpsSettings (value): () => Promise<HttpsSettings>
|
||||||
|
getLiveHostStatus (value): (hostId: number) => StatusValue
|
||||||
|
getLogLevel (value): () => Promise<{ level: string; }>
|
||||||
|
getNotificationPrivateEndpoints (value): () => Promise<string[]>
|
||||||
|
getPanel (value): (id: string) => PanelDef
|
||||||
|
getPasswordLoginAllowed (value): () => Promise<{ allowed: boolean; forced?: boolean; }>
|
||||||
|
getPasswordResetAllowed (value): () => Promise<boolean>
|
||||||
|
getPermissionsCatalog (value): () => Promise<{ catalog: PermissionCatalogEntry[]; }>
|
||||||
|
getPluginAdminSettings (value): (pluginId: string) => Promise<PluginSettingsValues>
|
||||||
|
getPluginHostSettings (value): (pluginId: string, hostId: number) => Promise<PluginSettingsValues>
|
||||||
|
getPluginUserSettings (value): (pluginId: string) => Promise<PluginSettingsValues>
|
||||||
|
getPlugins (value): () => Promise<PluginSummary[]>
|
||||||
getPollingEnvironmentMultiplier (value): (connection?: NetworkInformationLike) => number
|
getPollingEnvironmentMultiplier (value): (connection?: NetworkInformationLike) => number
|
||||||
|
getPreferredHostAction (value): (hostId: string, candidates: readonly TabType[], fallback: TabType) => TabType
|
||||||
getRecentActivity (value): (limit?: number) => Promise<RecentActivityItem[]>
|
getRecentActivity (value): (limit?: number) => Promise<RecentActivityItem[]>
|
||||||
|
getRegisteredDashboardCard (value): (id: string) => RegisteredDashboardCard
|
||||||
|
getRegistrationAllowed (value): () => Promise<{ allowed: boolean; }>
|
||||||
|
getRoles (value): () => Promise<{ roles: Role[]; }>
|
||||||
|
getSSHFolders (value): () => Promise<SSHFolder[]>
|
||||||
|
getSSHHosts (value): () => Promise<SSHHostWithStatus[]>
|
||||||
|
getSecondFactorAfterExternalLogin (value): () => Promise<{ enabled: boolean; }>
|
||||||
|
getSessionTimeout (value): () => Promise<{ timeoutHours: number; }>
|
||||||
|
getSessionTimeoutMinutes (value): () => Promise<number>
|
||||||
|
getSessions (value): () => Promise<{ sessions: { id: string; userId: string; username?: string; deviceType: string; deviceInfo: string; createdAt: string; expiresAt: string; lastActiveAt: string; isRevoked?: boolean; isCurrentSession?: boolean; }[]; }>
|
||||||
|
getStatusCheckSettings (value): () => Promise<{ statusCheckInterval: number; }>
|
||||||
|
getStatusClasses (value): (status: boolean | "online" | "offline" | "unknown", scheme: StatusColorScheme, variant: "dot" | "stripe" | "badge", loading?: boolean) => string
|
||||||
|
getTabType (value): (id: string) => TabTypeDef
|
||||||
|
getTlsStatus (value): () => Promise<TlsStatus>
|
||||||
|
getUpdateChannel (value): () => Promise<UpdateChannelState>
|
||||||
getUptime (value): () => Promise<UptimeInfo>
|
getUptime (value): () => Promise<UptimeInfo>
|
||||||
|
getUserInfo (value): () => Promise<UserInfo>
|
||||||
|
getUserList (value): (options?: UserListOptions) => Promise<{ users: UserInfo[]; total?: number; }>
|
||||||
|
getUserPreferences (value): () => Promise<UserPreferences>
|
||||||
|
getUserRoles (value): (userId: string) => Promise<{ roles: UserRole[]; }>
|
||||||
|
getUserSecondFactors (value): (userId: string) => Promise<UserSecondFactorSummary[]>
|
||||||
getVersionInfo (value): (checkRemote?: boolean) => Promise<VersionInfo>
|
getVersionInfo (value): (checkRemote?: boolean) => Promise<VersionInfo>
|
||||||
globalShortcutHandler (value): { current: (e: KeyboardEvent) => void; }
|
globalShortcutHandler (value): { current: (e: KeyboardEvent) => void; }
|
||||||
|
groupRailItems (value): (items: RailItemDef[]) => { group: RailGroup; items: RailItemDef[]; }[]
|
||||||
|
hasVisibleFields (value): (fields: PluginSettingsField[]) => boolean
|
||||||
|
hostActionsFor (value): (all: HostActionDef[], host: Host, kind?: "open" | "connect") => HostActionDef[]
|
||||||
|
hostBadgesFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
|
||||||
|
hostEditorToolbarItemsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
|
||||||
|
hostMenuItemsFor (value): (all: HostContextMenuItemDef[], host: Host) => HostContextMenuItemDef[]
|
||||||
|
hostProtocolFlags (value): (host: { enableSsh?: boolean; pluginSettings?: Record<string, Record<string, unknown>>; }, list?: HostProtocolDef[]) => HostProtocols
|
||||||
|
hostStatusIndicatorsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
|
||||||
hydrateLocalSharedHostAuth (value): <T>(host: T) => Promise<T>
|
hydrateLocalSharedHostAuth (value): <T>(host: T) => Promise<T>
|
||||||
|
invalidateHostsAndStatusCaches (value): () => void
|
||||||
|
invalidateServerStatusCache (value): () => void
|
||||||
|
isCoreRailView (value): (id: string) => boolean
|
||||||
isElectron (value): () => boolean
|
isElectron (value): () => boolean
|
||||||
|
isFieldShown (value): (field: PluginSettingsField, values: Record<string, unknown>) => boolean
|
||||||
isMacPlatform (value): () => boolean
|
isMacPlatform (value): () => boolean
|
||||||
|
isPersistentTabType (value): (type: string) => boolean
|
||||||
|
isPrerelease (value): (version: string) => boolean
|
||||||
|
isSessionTabType (value): (type: string) => boolean
|
||||||
|
isSplitDragging (value): () => boolean
|
||||||
|
isSplitTab (value): (tab: Tab) => tab is SplitTab
|
||||||
isTabJumpHotkey (value): (e: KeyboardEvent) => boolean
|
isTabJumpHotkey (value): (e: KeyboardEvent) => boolean
|
||||||
|
linkExternalToPasswordAccount (value): (externalUserId: string, targetUsername: string) => Promise<{ success: boolean; message: string; }>
|
||||||
linkedServerUrl (value): () => Promise<string>
|
linkedServerUrl (value): () => Promise<string>
|
||||||
|
listHostActions (value): () => HostActionDef[]
|
||||||
|
listHostProtocols (value): () => HostProtocolDef[]
|
||||||
|
loadPaletteGroup (value): (group: PaletteGroupDef) => Promise<PaletteItemDef[]>
|
||||||
|
logoutUser (value): () => Promise<{ success: boolean; message: string; }>
|
||||||
|
makeHostSshSubTabs (value): (t: (key: string, options?: { defaultValue?: string; }) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean) => HostTab[]
|
||||||
|
makeHostTabs (value): (t: (key: string, options?: { defaultValue?: string; }) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean) => HostTab[]
|
||||||
|
makeUserAdmin (value): (userId: string) => Promise<Record<string, unknown>>
|
||||||
|
mapCredentials (value): (res: unknown) => Credential[]
|
||||||
markAdaptiveResourceUsed (value): (kind: AdaptiveTaskKind, action: string, now?: number) => void
|
markAdaptiveResourceUsed (value): (kind: AdaptiveTaskKind, action: string, now?: number) => void
|
||||||
|
markTabSurfaceUsed (value): (type: TabType) => void
|
||||||
|
moveRailItem (value): (items: RailItemDef[], id: string, beforeId: string) => string[]
|
||||||
|
moveSplitDrag (value): (x: number, y: number) => void
|
||||||
|
normalizeLanguageCode (value): (language?: string) => string
|
||||||
|
notifySyncChanged (value): () => void
|
||||||
|
openInSplit (value): (target: SplitOpenTarget, open: () => void) => void
|
||||||
|
paletteEntriesFor (value): (all: PaletteEntryDef[], scope: "global" | "host", host?: Host) => PaletteEntryDef[]
|
||||||
|
parseCustomKeybindings (value): (raw?: string) => CustomKeybinding[]
|
||||||
|
parseServerTime (value): (ts: string) => Date
|
||||||
|
pluginKey (value): { (pluginId: string, key: string): string; (pluginId: string, key: string): string; }
|
||||||
|
pluginName (value): (t: Translator, plugin: { id: string; name: string; }) => string
|
||||||
pluginWsUrl (value): (pluginId: string, path: string, options?: { origin?: ConnectionOrigin; }) => Promise<WebSocketConnectionTarget>
|
pluginWsUrl (value): (pluginId: string, path: string, options?: { origin?: ConnectionOrigin; }) => Promise<WebSocketConnectionTarget>
|
||||||
|
preloadTabSurface (value): (type: TabType) => void
|
||||||
|
previewHostDefaultsLevel (value): (target: HostDefaultsTarget, change: HostDefaultsChange) => Promise<{ changedHosts: number; }>
|
||||||
|
promotableIds (value): () => string[]
|
||||||
|
protocolEnabled (value): (pluginSettings: Record<string, Record<string, unknown>>, protocol: HostProtocolDef) => boolean
|
||||||
|
protocolPort (value): (pluginSettings: Record<string, Record<string, unknown>>, protocol: HostProtocolDef) => number
|
||||||
|
quickConnectTargets (value): (all: HostActionDef[], host: Host) => HostActionDef[]
|
||||||
|
railItemLabel (value): (id: string, t: (key: string) => string) => string
|
||||||
readFromClipboard (value): () => Promise<string>
|
readFromClipboard (value): () => Promise<string>
|
||||||
|
recordHostActionPreference (value): (hostId: string, action: TabType) => void
|
||||||
|
registerLayout (value): (item: RegisteredLayout) => () => void
|
||||||
|
registerTabCloseGuard (value): (tabId: string, guard: Guard) => () => void
|
||||||
|
releaseUrlFrom (value): (info: VersionInfo) => string
|
||||||
|
rem (value): (px: number) => string
|
||||||
|
removeAdminStatus (value): (userId: string) => Promise<Record<string, unknown>>
|
||||||
|
removeRoleFromUser (value): (userId: string, roleId: number) => Promise<{ success: boolean; }>
|
||||||
|
renameCredentialFolder (value): (oldName: string, newName: string) => Promise<Record<string, unknown>>
|
||||||
|
renameFolder (value): (oldName: string, newName: string) => Promise<Record<string, unknown>>
|
||||||
|
reorderCredentials (value): (positions: { id: number; sortOrder: number; }[]) => Promise<{ updated: number; }>
|
||||||
|
reorderFolders (value): (positions: { name: string; sortOrder: number; }[]) => Promise<{ updated: number; }>
|
||||||
|
reorderSSHHosts (value): (positions: { id: number; sortOrder: number; }[]) => Promise<{ updated: number; }>
|
||||||
|
reportBetaFeedbackUrl (value): (termixVersion?: string, betaPlugins?: { id: string; version: string; }[]) => string
|
||||||
|
reportCoreIssueUrl (value): (termixVersion?: string) => string
|
||||||
|
requestFromLegacyEvent (value): (name: string, detail?: unknown) => ManageRequest
|
||||||
|
requestManage (value): (request: ManageRequest) => void
|
||||||
|
resetHostToDefaults (value): (hostIds: number[], reset: { all?: boolean; namespaces?: string[]; keys?: string[]; }) => Promise<void>
|
||||||
|
resetRecentActivity (value): () => Promise<{ message: string; }>
|
||||||
|
resetUserSecondFactors (value): (userId: string) => Promise<void>
|
||||||
resolveConnectionOrigin (value): (host: OriginResolvableHost, options?: { defaultRemote?: boolean; }) => Promise<ConnectionOrigin>
|
resolveConnectionOrigin (value): (host: OriginResolvableHost, options?: { defaultRemote?: boolean; }) => Promise<ConnectionOrigin>
|
||||||
|
resolveHostDefaults (value): (where: { hostId?: number; folder?: string; parentHostId?: number; }, adminTargetUserId?: string) => Promise<Record<string, ResolvedHostDefault>>
|
||||||
|
resolveHostTabType (value): (host: Host, preferredType?: TabType) => TabType
|
||||||
resolveRemoteHostId (value): (syncId: string) => Promise<number>
|
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>
|
||||||
|
revokeHostAccess (value): (hostId: number, accessId: number, syncId?: string) => Promise<{ success: boolean; }>
|
||||||
|
revokeSession (value): (sessionId: string) => Promise<{ success: boolean; message: string; }>
|
||||||
|
rightDockableIds (value): () => string[]
|
||||||
|
roleLabel (value): <T extends string | null | undefined>(t: Translator, displayName: T) => string | T
|
||||||
runAdaptiveBackgroundTask (value): (kind: AdaptiveTaskKind, action: string, task: () => Promise<unknown>, options?: AdaptiveTaskOptions) => boolean
|
runAdaptiveBackgroundTask (value): (kind: AdaptiveTaskKind, action: string, task: () => Promise<unknown>, options?: AdaptiveTaskOptions) => boolean
|
||||||
runAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePollResult>, policy: AdaptivePollingPolicy, options?: { enabled?: () => boolean; visible?: () => boolean; runImmediately?: boolean; random?: () => number; intervalMultiplier?: () => number; onError?: (error: unknown) => void; }) => () => void
|
runAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePollResult>, policy: AdaptivePollingPolicy, options?: { enabled?: () => boolean; visible?: () => boolean; runImmediately?: boolean; random?: () => number; intervalMultiplier?: () => number; onError?: (error: unknown) => void; }) => () => void
|
||||||
|
runHostAction (value): (action: HostActionDef, host: Host, shell: TabShellCallbacks) => void
|
||||||
runVisibleInterval (value): (tick: () => void, intervalMs: number) => () => void
|
runVisibleInterval (value): (tick: () => void, intervalMs: number) => () => void
|
||||||
|
sanitizeCredentialSidebarPreferences (value): (input: unknown) => CredentialSidebarPreferences
|
||||||
|
sanitizeHostSidebarPreferences (value): (input: unknown) => HostSidebarPreferences
|
||||||
|
saveCredentialSidebarPreferences (value): (preferences: CredentialSidebarPreferences) => Promise<void>
|
||||||
|
saveHostDefaultsLevel (value): (target: HostDefaultsTarget, change: HostDefaultsChange) => Promise<{ changedHosts?: number; jobId?: string; }>
|
||||||
|
saveHostSidebarPreferences (value): (preferences: HostSidebarPreferences) => Promise<void>
|
||||||
|
saveHostTags (value): (tags: string[]) => Promise<string[]>
|
||||||
|
saveUserPreferences (value): (prefs: Partial<UserPreferences>) => Promise<void>
|
||||||
|
setAllPluginChannels (value): (channel: PluginChannel) => Promise<{ changed: string[]; }>
|
||||||
|
setDeveloperMode (value): (enabled: boolean) => Promise<void>
|
||||||
|
setHostAuthOverride (value): (hostId: number, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; protocol: string; credentialId: number; }>
|
||||||
|
setNotificationPrivateEndpoints (value): (hosts: string[]) => Promise<string[]>
|
||||||
|
setPluginOptions (value): (pluginId: string, options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel; }) => Promise<{ autoUpdate: boolean; pinnedVersion: string; channel: PluginChannel; }>
|
||||||
|
setUpdateChannel (value): (channel: UpdateChannel) => Promise<UpdateChannelState>
|
||||||
|
shareCredential (value): (credentialId: number, targets: ShareTarget[], permissionLevel: CredentialPermissionLevel, durationHours?: number) => Promise<{ success: boolean; expiresAt: string; }>
|
||||||
|
shareFolder (value): (folder: string, shareData: { targets: ShareTarget[]; permissionLevel: SharePermissionLevel; durationHours?: number; }) => Promise<{ success: boolean; expiresAt: string; hostsShared: number; hostsTotal: number; hostResults: { hostId: number; shared: boolean; reason?: string; }[]; }>
|
||||||
|
shareHost (value): (hostId: number, shareData: { targets: ShareTarget[]; permissionLevel: SharePermissionLevel; durationHours?: number; }, syncId?: string) => Promise<{ success: boolean; expiresAt: string; results: { type: "role" | "user"; id: string | number; accessId: number; created: boolean; }[]; }>
|
||||||
|
shell (value): TabShellCallbacks
|
||||||
|
splitDefaultKey (value): (fullKey: string) => [string, string]
|
||||||
|
sshHostToHost (value): (h: SSHHostWithStatus) => Host
|
||||||
|
tabIcon (value): (type: TabType) => Element
|
||||||
|
tabJumpHotkeyKeys (value): () => string[]
|
||||||
|
takePendingManageRequest (value): () => ManageRequest
|
||||||
|
unlinkExternalFromPasswordAccount (value): (userId: string) => Promise<{ success: boolean; message: string; }>
|
||||||
|
updateCredential (value): (credentialId: number, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||||
|
updateExternalAutoProvision (value): (enabled: boolean) => Promise<Record<string, unknown>>
|
||||||
|
updateFolderMetadata (value): (name: string, color?: string, icon?: string, credentialId?: number, localOnly?: boolean) => Promise<void>
|
||||||
|
updateHostAccess (value): (hostId: number, accessId: number, update: { permissionLevel?: SharePermissionLevel; durationHours?: number; }, syncId?: string) => Promise<{ success: boolean; expiresAt: string; }>
|
||||||
|
updateHttpsSettings (value): (forceHttps: boolean) => Promise<HttpsSettings>
|
||||||
|
updateLogLevel (value): (level: string) => Promise<void>
|
||||||
|
updatePasswordLoginAllowed (value): (allowed: boolean) => Promise<{ allowed: boolean; }>
|
||||||
|
updatePasswordResetAllowed (value): (allowed: boolean) => Promise<{ allowed: boolean; }>
|
||||||
|
updatePluginAdminSettings (value): (pluginId: string, values: PluginSettingsValues) => Promise<PluginSettingsValues>
|
||||||
|
updatePluginHostSettings (value): (pluginId: string, hostId: number, values: PluginSettingsValues) => Promise<PluginSettingsValues>
|
||||||
|
updatePluginUserSettings (value): (pluginId: string, values: PluginSettingsValues) => Promise<PluginSettingsValues>
|
||||||
|
updateRegistrationAllowed (value): (allowed: boolean) => Promise<Record<string, unknown>>
|
||||||
|
updateRole (value): (roleId: number, roleData: { displayName?: string; description?: string; permissions?: string[]; }) => Promise<{ role: Role; }>
|
||||||
|
updateSSHHost (value): (hostId: number, hostData: HostData) => Promise<Host>
|
||||||
|
updateSecondFactorAfterExternalLogin (value): (enabled: boolean) => Promise<Record<string, unknown>>
|
||||||
|
updateSessionTimeout (value): (timeoutHours: number) => Promise<void>
|
||||||
|
updateStatusCheckSettings (value): (settings: { statusCheckInterval: number; }) => Promise<void>
|
||||||
|
uploadTlsCertificate (value): (payload: { certificate: string; privateKey: string; }) => Promise<TlsStatus & { success: boolean; reloadMessage?: string; }>
|
||||||
usageBarColor (value): (percent: number) => string
|
usageBarColor (value): (percent: number) => string
|
||||||
usageColor (value): (percent: number) => string
|
usageColor (value): (percent: number) => string
|
||||||
|
useActionSlot (value): (slotId: string, context?: Record<string, unknown>) => SlotContribution[]
|
||||||
useAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePollResult>, policy: AdaptivePollingPolicy, enabled?: boolean, options?: { runImmediately?: boolean; onError?: (error: unknown) => void; }) => void
|
useAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePollResult>, policy: AdaptivePollingPolicy, enabled?: boolean, options?: { runImmediately?: boolean; onError?: (error: unknown) => void; }) => void
|
||||||
useAppTheme (value): () => ThemeProviderState
|
useAppTheme (value): () => ThemeProviderState
|
||||||
|
useAreaPreferences (value): <A extends UiAreaKey>(area: A) => UiAreaPreferences[A]
|
||||||
|
useBranding (value): () => BrandingContextValue
|
||||||
|
useChooseTheme (value): () => (themeId: string) => void
|
||||||
useConfirm (value): () => (options: ConfirmOptions) => Promise<boolean>
|
useConfirm (value): () => (options: ConfirmOptions) => Promise<boolean>
|
||||||
useConnectionGate (value): () => ConnectionGateController
|
useConnectionGate (value): () => ConnectionGateController
|
||||||
useConnectionLog (value): () => ConnectionLogContextType
|
useConnectionLog (value): () => ConnectionLogContextType
|
||||||
|
useDesktopLinked (value): () => boolean
|
||||||
|
useDesktopUpdate (value): () => DesktopUpdateState
|
||||||
|
useEditorDefaultKeys (value): () => Map<string, EditorDefaultKey>
|
||||||
|
useHostActions (value): () => HostActionDef[]
|
||||||
|
useHostBadges (value): () => HostBadgeDef[]
|
||||||
|
useHostContextMenuItems (value): () => HostContextMenuItemDef[]
|
||||||
|
useHostEditorSections (value): () => HostEditorSectionDef[]
|
||||||
|
useHostEditorToolbarItems (value): () => HostBadgeDef[]
|
||||||
|
useHostProtocols (value): () => HostProtocolDef[]
|
||||||
|
useHostStatus (value): (hostId: number, statusCheckEnabled?: boolean) => StatusValue
|
||||||
|
useHostStatusIndicators (value): () => HostBadgeDef[]
|
||||||
|
useInstalledPlugins (value): () => PluginSummary[]
|
||||||
useIsDefaultsEditor (value): () => boolean
|
useIsDefaultsEditor (value): () => boolean
|
||||||
useIsMobile (value): () => boolean
|
useIsMobile (value): () => boolean
|
||||||
|
useKeybindingActions (value): () => KeybindingActionDef[]
|
||||||
|
useKeybindingDefaults (value): () => KeybindingDefaultDef[]
|
||||||
useOptionalConnectionLog (value): () => ConnectionLogContextType
|
useOptionalConnectionLog (value): () => ConnectionLogContextType
|
||||||
|
usePageVisibleInterval (value): (callback: () => void, intervalMs: number, enabled?: boolean, options?: Options) => void
|
||||||
|
usePaletteEntries (value): () => PaletteEntryDef[]
|
||||||
|
usePaletteGroups (value): () => PaletteGroupDef[]
|
||||||
|
usePanels (value): () => PanelDef[]
|
||||||
|
usePermissions (value): () => PermissionsState
|
||||||
|
usePluginDocsUrl (value): (pluginId: string, page?: string, anchor?: string) => string
|
||||||
|
usePluginHostSections (value): () => PluginSummary[]
|
||||||
|
usePluginStore (value): () => StoreState
|
||||||
|
useRailItems (value): () => RailItemDef[]
|
||||||
|
useRegisteredDashboardCards (value): () => RegisteredDashboardCard[]
|
||||||
|
useServerStatus (value): () => ServerStatusContextType
|
||||||
|
useServerStatusMeta (value): () => { initialLoadComplete: boolean; }
|
||||||
|
useShellController (value): () => ShellController
|
||||||
|
useSplitTargets (value): () => SplitTargetsSnapshot
|
||||||
|
useSshAuthEditors (value): () => SshAuthEditorDef[]
|
||||||
|
useSshAuthProviders (value): () => { providers: SshAuthTypeOption[]; loaded: boolean; find: (type: string) => SshAuthTypeOption; }
|
||||||
|
useStatusColorScheme (value): () => StatusColorScheme
|
||||||
useSurfaceKind (value): () => SurfaceKind
|
useSurfaceKind (value): () => SurfaceKind
|
||||||
|
useTabHandlesVersion (value): () => number
|
||||||
useTabs (value): () => TabContextType
|
useTabs (value): () => TabContextType
|
||||||
useTabsSafe (value): () => TabContextType
|
useTabsSafe (value): () => TabContextType
|
||||||
|
useTheme (value): () => ThemeProviderState
|
||||||
|
useThemeOptions (value): () => ThemeOption[]
|
||||||
|
useUiPreference (value): <A extends UiAreaKey, K extends keyof UiAreaPreferences[A]>(area: A, key: K) => UiAreaPreferences[A][K]
|
||||||
|
useUiPreferencesContext (value): () => UiPreferencesContextValue
|
||||||
|
waitForDefaultsJob (value): (id: string, intervalMs?: number, maxWaitMs?: number) => Promise<number>
|
||||||
|
withProtocolSettings (value): (pluginSettings: Record<string, Record<string, unknown>>, flags: HostProtocols, list?: HostProtocolDef[]) => Record<string, Record<string, unknown>>
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
*
|
*
|
||||||
* getBundledPluginsDir() in src/backend/plugins/paths.ts resolves
|
* getBundledPluginsDir() in src/backend/plugins/paths.ts resolves
|
||||||
* dist/backend/backend/plugins -> dist/plugins, which is where this writes.
|
* dist/backend/backend/plugins -> dist/plugins, which is where this writes.
|
||||||
|
* --profile minimal|standard|full (or TERMIX_PLUGIN_PROFILE, default standard)
|
||||||
|
* picks which tiers of the file are staged.
|
||||||
* Each .tmxplug is downloaded (or read from a path), checked against its
|
* Each .tmxplug is downloaded (or read from a path), checked against its
|
||||||
* pinned sha256 and unpacked.
|
* pinned sha256 and unpacked.
|
||||||
*
|
*
|
||||||
@@ -25,13 +27,16 @@ const {
|
|||||||
localSdkBuildCommand,
|
localSdkBuildCommand,
|
||||||
copyLocalBuild,
|
copyLocalBuild,
|
||||||
buildBundledIndex,
|
buildBundledIndex,
|
||||||
|
resolveProfile,
|
||||||
|
pluginsForProfile,
|
||||||
} = require("./lib/bundled-plugins.cjs");
|
} = require("./lib/bundled-plugins.cjs");
|
||||||
|
|
||||||
const root = path.resolve(__dirname, "..");
|
const root = path.resolve(__dirname, "..");
|
||||||
const destination = path.join(root, "dist", "plugins");
|
const destination = path.join(root, "dist", "plugins");
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const plugins = loadBundledPlugins(root);
|
const profile = resolveProfile();
|
||||||
|
const plugins = pluginsForProfile(loadBundledPlugins(root), profile);
|
||||||
const flag = process.argv.indexOf("--local");
|
const flag = process.argv.indexOf("--local");
|
||||||
const localArg =
|
const localArg =
|
||||||
flag !== -1 ? process.argv[flag + 1] : process.env.TERMIX_LOCAL_PLUGINS;
|
flag !== -1 ? process.argv[flag + 1] : process.env.TERMIX_LOCAL_PLUGINS;
|
||||||
@@ -74,7 +79,7 @@ async function main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Bundled ${plugins.length + extra.length} plugin(s): ${[...pinned, ...extra].join(", ")}`,
|
`Bundled ${plugins.length + extra.length} plugin(s) (${profile}): ${[...pinned, ...extra].join(", ")}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
localSdkBuildCommand,
|
localSdkBuildCommand,
|
||||||
rebuildLocalPlugins,
|
rebuildLocalPlugins,
|
||||||
copyLocalBuild,
|
copyLocalBuild,
|
||||||
|
resolveProfile,
|
||||||
|
pluginsForProfile,
|
||||||
} from "./lib/bundled-plugins.cjs";
|
} from "./lib/bundled-plugins.cjs";
|
||||||
|
|
||||||
const cleanups: Array<() => void> = [];
|
const cleanups: Array<() => void> = [];
|
||||||
@@ -263,10 +265,52 @@ describe("pinsFromIndex", () => {
|
|||||||
source: "tmxplug",
|
source: "tmxplug",
|
||||||
url: "https://example.test/beta-1.1.0.tmxplug",
|
url: "https://example.test/beta-1.1.0.tmxplug",
|
||||||
sha256: "b".repeat(64),
|
sha256: "b".repeat(64),
|
||||||
|
tier: "full",
|
||||||
});
|
});
|
||||||
expect(parseBundledPlugins({ plugins: pins }).problems).toEqual([]);
|
expect(parseBundledPlugins({ plugins: pins }).problems).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the tier of an existing pin", () => {
|
||||||
|
const pins = pinsFromIndex(index, null, [
|
||||||
|
{ id: "alpha", tier: "standard" },
|
||||||
|
]);
|
||||||
|
expect(pins[0].tier).toBe("standard");
|
||||||
|
expect(pins[1].tier).toBe("full");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips releases whose engine.termix excludes the core version", () => {
|
||||||
|
const withEngine = {
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
id: "gamma",
|
||||||
|
versions: [
|
||||||
|
{
|
||||||
|
version: "2.0.0",
|
||||||
|
url: "https://example.test/gamma-2.0.0.tmxplug",
|
||||||
|
sha256: "d".repeat(64),
|
||||||
|
engine: { api: "^1", termix: ">=26.10.6" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "1.0.0",
|
||||||
|
url: "https://example.test/gamma-1.0.0.tmxplug",
|
||||||
|
sha256: "e".repeat(64),
|
||||||
|
engine: { api: "^1", termix: ">=26.10.0" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(pinsFromIndex(withEngine, null, [], "26.10.5")[0].url).toBe(
|
||||||
|
"https://example.test/gamma-1.0.0.tmxplug",
|
||||||
|
);
|
||||||
|
expect(pinsFromIndex(withEngine, null, [], "26.10.6")[0].url).toBe(
|
||||||
|
"https://example.test/gamma-2.0.0.tmxplug",
|
||||||
|
);
|
||||||
|
expect(pinsFromIndex(withEngine, null)[0].url).toBe(
|
||||||
|
"https://example.test/gamma-2.0.0.tmxplug",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps the onboarding defaults of an existing pin", () => {
|
it("keeps the onboarding defaults of an existing pin", () => {
|
||||||
const current = [
|
const current = [
|
||||||
{ id: "beta", onboarding: { recommended: true, consent: false } },
|
{ id: "beta", onboarding: { recommended: true, consent: false } },
|
||||||
@@ -276,6 +320,33 @@ describe("pinsFromIndex", () => {
|
|||||||
expect(pins[0]).not.toHaveProperty("onboarding");
|
expect(pins[0]).not.toHaveProperty("onboarding");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("pins a release kept in gated", () => {
|
||||||
|
const pins = pinsFromIndex(
|
||||||
|
{
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
id: "look",
|
||||||
|
versions: [],
|
||||||
|
gated: [
|
||||||
|
{
|
||||||
|
version: "1.1.0-beta.1",
|
||||||
|
url: "https://example.test/look-1.1.0-beta.1.tmxplug",
|
||||||
|
sha256: "d".repeat(64),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "1.0.0",
|
||||||
|
url: "https://example.test/look-1.0.0.tmxplug",
|
||||||
|
sha256: "e".repeat(64),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(pins[0].url).toBe("https://example.test/look-1.0.0.tmxplug");
|
||||||
|
});
|
||||||
|
|
||||||
it("pins only the ids asked for", () => {
|
it("pins only the ids asked for", () => {
|
||||||
expect(pinsFromIndex(index, ["beta"]).map((pin) => pin.id)).toEqual([
|
expect(pinsFromIndex(index, ["beta"]).map((pin) => pin.id)).toEqual([
|
||||||
"beta",
|
"beta",
|
||||||
@@ -416,3 +487,129 @@ describe("rebuilding local plugins", () => {
|
|||||||
expect(run.sort()).toEqual(["a", "b"]);
|
expect(run.sort()).toEqual(["a", "b"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("default look", () => {
|
||||||
|
const entry = { source: "tmxplug", path: "a", sha256: SHA };
|
||||||
|
|
||||||
|
it("reads the defaultLook flag and refuses anything but a boolean", () => {
|
||||||
|
const { plugins, problems } = parseBundledPlugins({
|
||||||
|
plugins: [
|
||||||
|
{ ...entry, id: "look", defaultLook: true },
|
||||||
|
{ ...entry, id: "plain" },
|
||||||
|
{ ...entry, id: "odd", defaultLook: "yes" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(problems).toEqual(["odd defaultLook must be a boolean"]);
|
||||||
|
expect(
|
||||||
|
plugins.map((p: { id: string; defaultLook: boolean }) => [
|
||||||
|
p.id,
|
||||||
|
p.defaultLook,
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
["look", true],
|
||||||
|
["plain", false],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries it into the bundled index and through a re-pin", () => {
|
||||||
|
expect(
|
||||||
|
buildBundledIndex(
|
||||||
|
[
|
||||||
|
{ id: "look", onboarding: null, defaultLook: true },
|
||||||
|
{ id: "plain", onboarding: null, defaultLook: false },
|
||||||
|
],
|
||||||
|
["look", "plain"],
|
||||||
|
),
|
||||||
|
).toEqual({ version: 1, plugins: { look: { defaultLook: true } } });
|
||||||
|
|
||||||
|
const index = {
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
id: "look",
|
||||||
|
versions: [
|
||||||
|
{ version: "1.0.0", url: "https://x/l.tmxplug", sha256: SHA },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
pinsFromIndex(index, null, [{ id: "look", defaultLook: true }])[0],
|
||||||
|
).toMatchObject({ id: "look", defaultLook: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies a local build's theme files", () => {
|
||||||
|
const repo = tempDir();
|
||||||
|
fs.mkdirSync(path.join(repo, "dist"), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(repo, "themes"), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(repo, "dist", "backend.js"), "export {}");
|
||||||
|
fs.writeFileSync(path.join(repo, "themes", "paper.css"), ".paper{}");
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(repo, "manifest.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
id: "inks",
|
||||||
|
contributes: { themes: [{ id: "paper", css: "themes/paper.css" }] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const out = path.join(tempDir(), "inks");
|
||||||
|
copyLocalBuild(repo, out);
|
||||||
|
expect(fs.existsSync(path.join(out, "themes", "paper.css"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,12 +32,18 @@ const OFFICIAL_IDS = [
|
|||||||
"acme-ssl",
|
"acme-ssl",
|
||||||
"ai",
|
"ai",
|
||||||
"alerts",
|
"alerts",
|
||||||
|
"audit-tools",
|
||||||
"automations",
|
"automations",
|
||||||
|
"classic-layout",
|
||||||
|
"classic-themes",
|
||||||
|
"credential-tools",
|
||||||
"docker",
|
"docker",
|
||||||
"file-manager",
|
"file-manager",
|
||||||
"fleets",
|
"fleets",
|
||||||
"homepage",
|
"homepage",
|
||||||
"host-metrics",
|
"host-metrics",
|
||||||
|
"host-status",
|
||||||
|
"import-export",
|
||||||
"ldap",
|
"ldap",
|
||||||
"network-topology",
|
"network-topology",
|
||||||
"opkssh",
|
"opkssh",
|
||||||
|
|||||||
@@ -19,8 +19,13 @@ const SDK_UI = path.join(ROOT, "src", "ui", "plugin-host", "sdk-ui.ts");
|
|||||||
const TSCONFIG = path.join(ROOT, "tsconfig.app.json");
|
const TSCONFIG = path.join(ROOT, "tsconfig.app.json");
|
||||||
|
|
||||||
const ALLOWED = [
|
const ALLOWED = [
|
||||||
|
"ACCENT_PRESET_COLORS",
|
||||||
|
"APP_VERSION",
|
||||||
|
"AccessRecord",
|
||||||
"ActionSlot",
|
"ActionSlot",
|
||||||
|
"ActiveSessionInfo",
|
||||||
"AddButton",
|
"AddButton",
|
||||||
|
"AdminSection",
|
||||||
"Alert",
|
"Alert",
|
||||||
"AlertDescription",
|
"AlertDescription",
|
||||||
"AlertDialog",
|
"AlertDialog",
|
||||||
@@ -35,14 +40,23 @@ const ALLOWED = [
|
|||||||
"AlertDialogTitle",
|
"AlertDialogTitle",
|
||||||
"AlertDialogTrigger",
|
"AlertDialogTrigger",
|
||||||
"AlertTitle",
|
"AlertTitle",
|
||||||
|
"ApiKey",
|
||||||
|
"AuditLog",
|
||||||
|
"AuditLogFilters",
|
||||||
|
"AuthEnrollmentSections",
|
||||||
|
"AuthOverrideProtocol",
|
||||||
"BackButton",
|
"BackButton",
|
||||||
"Badge",
|
"Badge",
|
||||||
"BarSeries",
|
"BarSeries",
|
||||||
"BarSeriesItem",
|
"BarSeriesItem",
|
||||||
"BarSeriesProps",
|
"BarSeriesProps",
|
||||||
|
"BrandingSettings",
|
||||||
"BrowserSignInDialog",
|
"BrowserSignInDialog",
|
||||||
"Button",
|
"Button",
|
||||||
"ButtonProps",
|
"ButtonProps",
|
||||||
|
"CORE_HOST_DEFAULTS",
|
||||||
|
"CORE_NAMESPACE",
|
||||||
|
"CREDENTIAL_ROW_FIELD_KEYS",
|
||||||
"Card",
|
"Card",
|
||||||
"CardAction",
|
"CardAction",
|
||||||
"CardContent",
|
"CardContent",
|
||||||
@@ -54,6 +68,12 @@ const ALLOWED = [
|
|||||||
"CardTitle",
|
"CardTitle",
|
||||||
"Checkbox",
|
"Checkbox",
|
||||||
"ColumnCountStepper",
|
"ColumnCountStepper",
|
||||||
|
"Command",
|
||||||
|
"CommandEmpty",
|
||||||
|
"CommandGroup",
|
||||||
|
"CommandItem",
|
||||||
|
"CommandList",
|
||||||
|
"CommandPrimitive",
|
||||||
"ComponentSlot",
|
"ComponentSlot",
|
||||||
"ConfirmOptions",
|
"ConfirmOptions",
|
||||||
"ConnectionGate",
|
"ConnectionGate",
|
||||||
@@ -66,9 +86,26 @@ const ALLOWED = [
|
|||||||
"ConnectionStage",
|
"ConnectionStage",
|
||||||
"ConnectionStatus",
|
"ConnectionStatus",
|
||||||
"ConnectionUnavailable",
|
"ConnectionUnavailable",
|
||||||
|
"CreatedApiKey",
|
||||||
|
"Credential",
|
||||||
|
"CredentialDensity",
|
||||||
|
"CredentialPermissionLevel",
|
||||||
|
"CredentialRowFields",
|
||||||
|
"CredentialSidebarFilterState",
|
||||||
|
"CredentialSidebarPreferences",
|
||||||
|
"CredentialSortKey",
|
||||||
|
"CredentialTrayTrigger",
|
||||||
"CustomKeybinding",
|
"CustomKeybinding",
|
||||||
|
"DASHBOARD_CARDS",
|
||||||
|
"DEFAULT_FOLDER_COLOR",
|
||||||
|
"DEFAULT_FOLDER_ICON",
|
||||||
|
"DashboardCardId",
|
||||||
"DataColumn",
|
"DataColumn",
|
||||||
"DataView",
|
"DataView",
|
||||||
|
"DefaultOverrides",
|
||||||
|
"DefaultsOnly",
|
||||||
|
"DesktopUpdateMode",
|
||||||
|
"DesktopUpdateState",
|
||||||
"Dialog",
|
"Dialog",
|
||||||
"DialogClose",
|
"DialogClose",
|
||||||
"DialogContent",
|
"DialogContent",
|
||||||
@@ -81,6 +118,7 @@ const ALLOWED = [
|
|||||||
"DialogTrigger",
|
"DialogTrigger",
|
||||||
"DocsLink",
|
"DocsLink",
|
||||||
"DocsLinkProps",
|
"DocsLinkProps",
|
||||||
|
"DocsPage",
|
||||||
"DropdownMenu",
|
"DropdownMenu",
|
||||||
"DropdownMenuCheckboxItem",
|
"DropdownMenuCheckboxItem",
|
||||||
"DropdownMenuContent",
|
"DropdownMenuContent",
|
||||||
@@ -99,24 +137,54 @@ const ALLOWED = [
|
|||||||
"EditingWidth",
|
"EditingWidth",
|
||||||
"EmptyState",
|
"EmptyState",
|
||||||
"FOLDER_COLORS",
|
"FOLDER_COLORS",
|
||||||
|
"FONT_SIZES",
|
||||||
"Facts",
|
"Facts",
|
||||||
"FakeSwitch",
|
"FakeSwitch",
|
||||||
"Field",
|
"Field",
|
||||||
"FieldPair",
|
"FieldPair",
|
||||||
|
"FolderAccessRule",
|
||||||
|
"FolderIconEl",
|
||||||
|
"FontSizeId",
|
||||||
"FormFooter",
|
"FormFooter",
|
||||||
"FullScreenAppPhase",
|
"FullScreenAppPhase",
|
||||||
"FullScreenAppWrapper",
|
"FullScreenAppWrapper",
|
||||||
"GridCardCatalogEntry",
|
"GridCardCatalogEntry",
|
||||||
"GridColSpan",
|
"GridColSpan",
|
||||||
"GridColumns",
|
"GridColumns",
|
||||||
|
"GridSize",
|
||||||
"GridLayout",
|
"GridLayout",
|
||||||
"GridSlot",
|
"GridSlot",
|
||||||
"GroupHeading",
|
"GroupHeading",
|
||||||
|
"HexColorPicker",
|
||||||
|
"Host",
|
||||||
|
"HostActionDef",
|
||||||
|
"HostClickBehavior",
|
||||||
|
"HostData",
|
||||||
"HostDefaultBadge",
|
"HostDefaultBadge",
|
||||||
"HostDefaultField",
|
"HostDefaultField",
|
||||||
|
"HostDefaultsChange",
|
||||||
|
"HostDefaultsContext",
|
||||||
|
"HostDefaultsContextValue",
|
||||||
|
"HostDefaultsLevel",
|
||||||
|
"HostDefaultsTarget",
|
||||||
|
"HostDensity",
|
||||||
"HostFeatureFields",
|
"HostFeatureFields",
|
||||||
|
"HostLock",
|
||||||
|
"HostPolicy",
|
||||||
|
"HttpsSettings",
|
||||||
"HostFeatureFieldsProps",
|
"HostFeatureFieldsProps",
|
||||||
|
"HostFolder",
|
||||||
|
"HostGroupKey",
|
||||||
"HostKeyVerificationDialog",
|
"HostKeyVerificationDialog",
|
||||||
|
"HostOnly",
|
||||||
|
"HostProtocolAuthInput",
|
||||||
|
"HostProtocolDef",
|
||||||
|
"HostProtocols",
|
||||||
|
"HostRowFields",
|
||||||
|
"HostSidebarFilterState",
|
||||||
|
"HostSidebarPreferences",
|
||||||
|
"HostTrayTrigger",
|
||||||
|
"IconPicker",
|
||||||
"InlineView",
|
"InlineView",
|
||||||
"Input",
|
"Input",
|
||||||
"Kbd",
|
"Kbd",
|
||||||
@@ -124,10 +192,13 @@ const ALLOWED = [
|
|||||||
"KbdSeparator",
|
"KbdSeparator",
|
||||||
"KeyCombo",
|
"KeyCombo",
|
||||||
"KeybindingAction",
|
"KeybindingAction",
|
||||||
|
"KeybindingActionDef",
|
||||||
"KeybindingActionType",
|
"KeybindingActionType",
|
||||||
|
"LANGUAGES",
|
||||||
"Label",
|
"Label",
|
||||||
"LineChart",
|
"LineChart",
|
||||||
"LineChartSeries",
|
"LineChartSeries",
|
||||||
|
"LinkedAccountInfo",
|
||||||
"ListBadge",
|
"ListBadge",
|
||||||
"ListRow",
|
"ListRow",
|
||||||
"ListRowAction",
|
"ListRowAction",
|
||||||
@@ -135,19 +206,29 @@ const ALLOWED = [
|
|||||||
"ListRowTone",
|
"ListRowTone",
|
||||||
"LogContext",
|
"LogContext",
|
||||||
"LogEntry",
|
"LogEntry",
|
||||||
|
"LookSettings",
|
||||||
|
"MANAGE_REQUEST_EVENT",
|
||||||
|
"MAX_PANES",
|
||||||
"MFAPromptMode",
|
"MFAPromptMode",
|
||||||
|
"ManageMode",
|
||||||
|
"ManageRequest",
|
||||||
"ManagerCardError",
|
"ManagerCardError",
|
||||||
"ManagerCardShell",
|
"ManagerCardShell",
|
||||||
"ManagerSearch",
|
"ManagerSearch",
|
||||||
"Meter",
|
"Meter",
|
||||||
"MetricCard",
|
"MetricCard",
|
||||||
"MiniStat",
|
"MiniStat",
|
||||||
|
"NativeView",
|
||||||
|
"NativeViewProps",
|
||||||
"NumberField",
|
"NumberField",
|
||||||
|
"OPEN_PLUGINS_EVENT",
|
||||||
|
"OpenTabRecord",
|
||||||
"PANEL",
|
"PANEL",
|
||||||
"PanePrompt",
|
|
||||||
"PROMPT_BUTTON",
|
"PROMPT_BUTTON",
|
||||||
"PROMPT_DESTRUCTIVE_BUTTON",
|
"PROMPT_DESTRUCTIVE_BUTTON",
|
||||||
"PROMPT_PRIMARY_BUTTON",
|
"PROMPT_PRIMARY_BUTTON",
|
||||||
|
"PaletteItemDef",
|
||||||
|
"PanePrompt",
|
||||||
"PanelDensity",
|
"PanelDensity",
|
||||||
"PanelList",
|
"PanelList",
|
||||||
"PanelSearch",
|
"PanelSearch",
|
||||||
@@ -155,18 +236,35 @@ const ALLOWED = [
|
|||||||
"PanelViewMode",
|
"PanelViewMode",
|
||||||
"PassphraseDialog",
|
"PassphraseDialog",
|
||||||
"PasswordInput",
|
"PasswordInput",
|
||||||
|
"PermissionCatalogEntry",
|
||||||
"PluginComponent",
|
"PluginComponent",
|
||||||
|
"PRESETS",
|
||||||
|
"PluginIcon",
|
||||||
|
"PluginSettingsErrors",
|
||||||
|
"PluginSettingsField",
|
||||||
|
"PluginSettingsValidationError",
|
||||||
|
"PluginSummary",
|
||||||
"PluginViewPlaceholder",
|
"PluginViewPlaceholder",
|
||||||
"Popover",
|
"Popover",
|
||||||
"PopoverAnchor",
|
"PopoverAnchor",
|
||||||
"PopoverContent",
|
"PopoverContent",
|
||||||
"PopoverTrigger",
|
"PopoverTrigger",
|
||||||
|
"ProxyNode",
|
||||||
"RadialGauge",
|
"RadialGauge",
|
||||||
"RadialGaugeProps",
|
"RadialGaugeProps",
|
||||||
|
"RailGroup",
|
||||||
|
"RailItemDef",
|
||||||
"RecentActivityItem",
|
"RecentActivityItem",
|
||||||
|
"ReleaseInfo",
|
||||||
"Repeater",
|
"Repeater",
|
||||||
|
"ResolvedHostDefault",
|
||||||
"RobustClipboardProvider",
|
"RobustClipboardProvider",
|
||||||
|
"Role",
|
||||||
"SSHAuthDialog",
|
"SSHAuthDialog",
|
||||||
|
"SSHHost",
|
||||||
|
"SSHHostData",
|
||||||
|
"SSHHostWithStatus",
|
||||||
|
"SSH_AUTH_PROTOCOL",
|
||||||
"ScrollArea",
|
"ScrollArea",
|
||||||
"ScrollBar",
|
"ScrollBar",
|
||||||
"SecretField",
|
"SecretField",
|
||||||
@@ -186,6 +284,9 @@ const ALLOWED = [
|
|||||||
"SelectValue",
|
"SelectValue",
|
||||||
"Separator",
|
"Separator",
|
||||||
"SettingRow",
|
"SettingRow",
|
||||||
|
"SettingsFieldRow",
|
||||||
|
"SharePermissionLevel",
|
||||||
|
"ShareTarget",
|
||||||
"Sheet",
|
"Sheet",
|
||||||
"SheetClose",
|
"SheetClose",
|
||||||
"SheetContent",
|
"SheetContent",
|
||||||
@@ -194,74 +295,363 @@ const ALLOWED = [
|
|||||||
"SheetHeader",
|
"SheetHeader",
|
||||||
"SheetTitle",
|
"SheetTitle",
|
||||||
"SheetTrigger",
|
"SheetTrigger",
|
||||||
|
"ShellConfirmCapture",
|
||||||
"Skeleton",
|
"Skeleton",
|
||||||
"Slider",
|
"Slider",
|
||||||
|
"SortKey",
|
||||||
"Sparkline",
|
"Sparkline",
|
||||||
"SparklineProps",
|
"SparklineProps",
|
||||||
|
"SplitLayoutMenu",
|
||||||
|
"SplitOpenTarget",
|
||||||
|
"SplitSummary",
|
||||||
"StatRow",
|
"StatRow",
|
||||||
|
"StatusValue",
|
||||||
"SurfaceKind",
|
"SurfaceKind",
|
||||||
"SurfaceScope",
|
"SurfaceScope",
|
||||||
"Switch",
|
"Switch",
|
||||||
"SwitchRow",
|
"SwitchRow",
|
||||||
"TOTPDialog",
|
"TOTPDialog",
|
||||||
|
"Tab",
|
||||||
|
"TabShellCallbacks",
|
||||||
|
"TabSplitAction",
|
||||||
|
"TabStage",
|
||||||
"TabStrip",
|
"TabStrip",
|
||||||
"TabStripItem",
|
"TabStripItem",
|
||||||
|
"TabType",
|
||||||
"TagInput",
|
"TagInput",
|
||||||
"TextAreaField",
|
"TextAreaField",
|
||||||
"TextField",
|
"TextField",
|
||||||
"Textarea",
|
"Textarea",
|
||||||
"TextareaProps",
|
"TextareaProps",
|
||||||
|
"ThemeId",
|
||||||
|
"TlsStatus",
|
||||||
|
"ToolsTab",
|
||||||
"Tooltip",
|
"Tooltip",
|
||||||
"TooltipContent",
|
"TooltipContent",
|
||||||
"TooltipProvider",
|
"TooltipProvider",
|
||||||
"TooltipTrigger",
|
"TooltipTrigger",
|
||||||
|
"UI_AREA_KEYS",
|
||||||
|
"UI_FONTS",
|
||||||
|
"UiAreaKey",
|
||||||
|
"UiFontId",
|
||||||
|
"UiPreset",
|
||||||
|
"UpdateChannelState",
|
||||||
"UptimeInfo",
|
"UptimeInfo",
|
||||||
"UsagePair",
|
"UsagePair",
|
||||||
|
"UserPreferences",
|
||||||
|
"UserRole",
|
||||||
|
"UserSecondFactorSummary",
|
||||||
|
"VersionBadge",
|
||||||
"VersionInfo",
|
"VersionInfo",
|
||||||
"ViewToggle",
|
"ViewToggle",
|
||||||
"WebSocketConnectionTarget",
|
"WebSocketConnectionTarget",
|
||||||
"WidgetTitle",
|
"WidgetTitle",
|
||||||
|
"actionOrder",
|
||||||
|
"activityTarget",
|
||||||
|
"adminCreateUser",
|
||||||
|
"adminCreateUserCredential",
|
||||||
|
"adminCreateUserHost",
|
||||||
|
"adminDeleteUserCredential",
|
||||||
|
"adminDeleteUserHost",
|
||||||
|
"adminExportUserData",
|
||||||
|
"adminGetHostPassword",
|
||||||
|
"adminGetUserCredentials",
|
||||||
|
"adminGetUserHosts",
|
||||||
|
"adminResetUserPassword",
|
||||||
|
"adminUpdateUserCredential",
|
||||||
|
"adminUpdateUserHost",
|
||||||
|
"applyAccentColor",
|
||||||
|
"applyFontSize",
|
||||||
|
"applyPresetSideEffects",
|
||||||
|
"applyUiFont",
|
||||||
|
"assignRoleToUser",
|
||||||
|
"beginSplitDrag",
|
||||||
"buildOriginWsUrl",
|
"buildOriginWsUrl",
|
||||||
|
"bulkUpdateSSHHosts",
|
||||||
|
"canConfigureUpdates",
|
||||||
|
"canJoinSplit",
|
||||||
|
"changeAppLanguage",
|
||||||
|
"changePassword",
|
||||||
|
"clearLocalAdaptivePreferences",
|
||||||
"cn",
|
"cn",
|
||||||
"copyToClipboard",
|
"copyToClipboard",
|
||||||
|
"createApiKey",
|
||||||
|
"createCredential",
|
||||||
"createFrontendLogger",
|
"createFrontendLogger",
|
||||||
"createQuickConnectHost",
|
"createQuickConnectHost",
|
||||||
|
"createRole",
|
||||||
|
"createSSHHost",
|
||||||
|
"defaultConnectAction",
|
||||||
|
"defaultCredentialRowFields",
|
||||||
|
"defaultCredentialSidebarPreferences",
|
||||||
|
"defaultHostRowFields",
|
||||||
|
"defaultHostSidebarPreferences",
|
||||||
|
"defaultValuesEqual",
|
||||||
|
"deleteAccount",
|
||||||
|
"deleteAllHostsInFolder",
|
||||||
|
"deleteApiKey",
|
||||||
|
"deleteCredential",
|
||||||
|
"deleteOpenTab",
|
||||||
|
"deleteRole",
|
||||||
|
"deleteSSHHost",
|
||||||
|
"deleteUser",
|
||||||
|
"desktopUpdates",
|
||||||
|
"docsUrl",
|
||||||
|
"duplicateCredential",
|
||||||
|
"enabledHostProtocols",
|
||||||
|
"endSplitDrag",
|
||||||
|
"filterPaletteItems",
|
||||||
|
"findCoreHostDefault",
|
||||||
"findMatchingKeybinding",
|
"findMatchingKeybinding",
|
||||||
|
"getActiveSessions",
|
||||||
"getAdaptiveResourceBudget",
|
"getAdaptiveResourceBudget",
|
||||||
|
"getApiKeys",
|
||||||
|
"getAuditLogActions",
|
||||||
|
"getAuditLogs",
|
||||||
"getBasePath",
|
"getBasePath",
|
||||||
|
"getBranding",
|
||||||
|
"getChannelReleases",
|
||||||
|
"getConnectedRemoteApi",
|
||||||
|
"getCredentialAccess",
|
||||||
|
"getCredentialDetails",
|
||||||
|
"getCredentialSidebarPreferences",
|
||||||
|
"getCredentials",
|
||||||
"getDatabaseHealth",
|
"getDatabaseHealth",
|
||||||
|
"getDatabaseTransferUrl",
|
||||||
|
"getDefaultConnectionTab",
|
||||||
|
"getDeveloperMode",
|
||||||
"getDeviceId",
|
"getDeviceId",
|
||||||
|
"getErrorMessage",
|
||||||
|
"getExternalAutoProvision",
|
||||||
|
"getFolderAccess",
|
||||||
|
"getFolderDefaultsId",
|
||||||
|
"getHostAccess",
|
||||||
|
"getHostAuthOverride",
|
||||||
|
"getHostDefaultsLevel",
|
||||||
|
"getHostEditorSection",
|
||||||
|
"getHostPassword",
|
||||||
|
"getHostPolicy",
|
||||||
|
"getHostSidebarPreferences",
|
||||||
|
"getHostTags",
|
||||||
|
"getLiveHostStatus",
|
||||||
|
"getHttpsSettings",
|
||||||
|
"getLogLevel",
|
||||||
|
"getNotificationPrivateEndpoints",
|
||||||
|
"getPanel",
|
||||||
|
"getPasswordLoginAllowed",
|
||||||
|
"getPasswordResetAllowed",
|
||||||
|
"getPermissionsCatalog",
|
||||||
|
"getPluginAdminSettings",
|
||||||
|
"getPluginHostSettings",
|
||||||
|
"getPluginUserSettings",
|
||||||
|
"getPlugins",
|
||||||
"getPollingEnvironmentMultiplier",
|
"getPollingEnvironmentMultiplier",
|
||||||
|
"getPreferredHostAction",
|
||||||
"getRecentActivity",
|
"getRecentActivity",
|
||||||
|
"getRegisteredDashboardCard",
|
||||||
|
"getRegistrationAllowed",
|
||||||
|
"getRoles",
|
||||||
|
"getSSHFolders",
|
||||||
|
"getSSHHosts",
|
||||||
|
"getSecondFactorAfterExternalLogin",
|
||||||
|
"getSessionTimeout",
|
||||||
|
"getSessionTimeoutMinutes",
|
||||||
|
"getSessions",
|
||||||
|
"getStatusCheckSettings",
|
||||||
|
"getStatusClasses",
|
||||||
|
"getTabType",
|
||||||
|
"getTlsStatus",
|
||||||
|
"getUpdateChannel",
|
||||||
"getUptime",
|
"getUptime",
|
||||||
|
"getUserInfo",
|
||||||
|
"getUserList",
|
||||||
|
"getUserPreferences",
|
||||||
|
"getUserRoles",
|
||||||
|
"getUserSecondFactors",
|
||||||
"getVersionInfo",
|
"getVersionInfo",
|
||||||
"globalShortcutHandler",
|
"globalShortcutHandler",
|
||||||
|
"groupRailItems",
|
||||||
|
"hasVisibleFields",
|
||||||
|
"hostActionsFor",
|
||||||
|
"hostBadgesFor",
|
||||||
|
"hostEditorToolbarItemsFor",
|
||||||
|
"hostMenuItemsFor",
|
||||||
|
"hostProtocolFlags",
|
||||||
|
"hostStatusIndicatorsFor",
|
||||||
"hydrateLocalSharedHostAuth",
|
"hydrateLocalSharedHostAuth",
|
||||||
|
"invalidateHostsAndStatusCaches",
|
||||||
|
"invalidateServerStatusCache",
|
||||||
|
"isCoreRailView",
|
||||||
"isElectron",
|
"isElectron",
|
||||||
|
"isFieldShown",
|
||||||
"isMacPlatform",
|
"isMacPlatform",
|
||||||
|
"isPersistentTabType",
|
||||||
|
"isPrerelease",
|
||||||
|
"isSessionTabType",
|
||||||
|
"isSplitDragging",
|
||||||
|
"isSplitTab",
|
||||||
"isTabJumpHotkey",
|
"isTabJumpHotkey",
|
||||||
|
"linkExternalToPasswordAccount",
|
||||||
"linkedServerUrl",
|
"linkedServerUrl",
|
||||||
|
"listHostActions",
|
||||||
|
"listHostProtocols",
|
||||||
|
"loadPaletteGroup",
|
||||||
|
"logoutUser",
|
||||||
|
"makeHostSshSubTabs",
|
||||||
|
"makeHostTabs",
|
||||||
|
"makeUserAdmin",
|
||||||
|
"mapCredentials",
|
||||||
"markAdaptiveResourceUsed",
|
"markAdaptiveResourceUsed",
|
||||||
|
"markTabSurfaceUsed",
|
||||||
|
"moveRailItem",
|
||||||
|
"moveSplitDrag",
|
||||||
|
"normalizeLanguageCode",
|
||||||
|
"notifySyncChanged",
|
||||||
|
"openInSplit",
|
||||||
|
"paletteEntriesFor",
|
||||||
|
"parseCustomKeybindings",
|
||||||
|
"parseServerTime",
|
||||||
|
"pluginKey",
|
||||||
|
"pluginName",
|
||||||
"pluginWsUrl",
|
"pluginWsUrl",
|
||||||
|
"preloadTabSurface",
|
||||||
|
"previewHostDefaultsLevel",
|
||||||
|
"promotableIds",
|
||||||
|
"protocolEnabled",
|
||||||
|
"protocolPort",
|
||||||
|
"quickConnectTargets",
|
||||||
|
"railItemLabel",
|
||||||
"readFromClipboard",
|
"readFromClipboard",
|
||||||
|
"recordHostActionPreference",
|
||||||
|
"registerLayout",
|
||||||
|
"registerTabCloseGuard",
|
||||||
|
"releaseUrlFrom",
|
||||||
|
"rem",
|
||||||
|
"removeAdminStatus",
|
||||||
|
"removeRoleFromUser",
|
||||||
|
"renameCredentialFolder",
|
||||||
|
"renameFolder",
|
||||||
|
"reorderCredentials",
|
||||||
|
"reorderFolders",
|
||||||
|
"reorderSSHHosts",
|
||||||
|
"reportBetaFeedbackUrl",
|
||||||
|
"reportCoreIssueUrl",
|
||||||
|
"requestFromLegacyEvent",
|
||||||
|
"requestManage",
|
||||||
|
"resetHostToDefaults",
|
||||||
|
"resetRecentActivity",
|
||||||
|
"resetUserSecondFactors",
|
||||||
"resolveConnectionOrigin",
|
"resolveConnectionOrigin",
|
||||||
|
"resolveHostDefaults",
|
||||||
|
"resolveHostTabType",
|
||||||
"resolveRemoteHostId",
|
"resolveRemoteHostId",
|
||||||
|
"revokeAllUserSessions",
|
||||||
|
"revokeCredentialAccess",
|
||||||
|
"revokeFolderAccess",
|
||||||
|
"revokeHostAccess",
|
||||||
|
"revokeSession",
|
||||||
|
"rightDockableIds",
|
||||||
|
"roleLabel",
|
||||||
"runAdaptiveBackgroundTask",
|
"runAdaptiveBackgroundTask",
|
||||||
"runAdaptivePolling",
|
"runAdaptivePolling",
|
||||||
|
"runHostAction",
|
||||||
"runVisibleInterval",
|
"runVisibleInterval",
|
||||||
|
"sanitizeCredentialSidebarPreferences",
|
||||||
|
"sanitizeHostSidebarPreferences",
|
||||||
|
"saveCredentialSidebarPreferences",
|
||||||
|
"saveHostDefaultsLevel",
|
||||||
|
"saveHostSidebarPreferences",
|
||||||
|
"saveHostTags",
|
||||||
|
"saveUserPreferences",
|
||||||
|
"setAllPluginChannels",
|
||||||
|
"setDeveloperMode",
|
||||||
|
"setHostAuthOverride",
|
||||||
|
"setNotificationPrivateEndpoints",
|
||||||
|
"setPluginOptions",
|
||||||
|
"setUpdateChannel",
|
||||||
|
"shareCredential",
|
||||||
|
"shareFolder",
|
||||||
|
"shareHost",
|
||||||
|
"shell",
|
||||||
|
"splitDefaultKey",
|
||||||
|
"sshHostToHost",
|
||||||
|
"tabIcon",
|
||||||
|
"tabJumpHotkeyKeys",
|
||||||
|
"takePendingManageRequest",
|
||||||
|
"unlinkExternalFromPasswordAccount",
|
||||||
|
"updateCredential",
|
||||||
|
"updateExternalAutoProvision",
|
||||||
|
"updateFolderMetadata",
|
||||||
|
"updateHostAccess",
|
||||||
|
"updateLogLevel",
|
||||||
|
"updatePasswordLoginAllowed",
|
||||||
|
"updatePasswordResetAllowed",
|
||||||
|
"updatePluginAdminSettings",
|
||||||
|
"updatePluginHostSettings",
|
||||||
|
"updatePluginUserSettings",
|
||||||
|
"updateRegistrationAllowed",
|
||||||
|
"updateRole",
|
||||||
|
"updateSSHHost",
|
||||||
|
"updateSecondFactorAfterExternalLogin",
|
||||||
|
"updateSessionTimeout",
|
||||||
|
"updateHttpsSettings",
|
||||||
|
"updateStatusCheckSettings",
|
||||||
|
"uploadTlsCertificate",
|
||||||
"usageBarColor",
|
"usageBarColor",
|
||||||
"usageColor",
|
"usageColor",
|
||||||
|
"useActionSlot",
|
||||||
"useAdaptivePolling",
|
"useAdaptivePolling",
|
||||||
"useAppTheme",
|
"useAppTheme",
|
||||||
|
"useAreaPreferences",
|
||||||
|
"useBranding",
|
||||||
|
"useChooseTheme",
|
||||||
"useConfirm",
|
"useConfirm",
|
||||||
"useConnectionGate",
|
"useConnectionGate",
|
||||||
"useConnectionLog",
|
"useConnectionLog",
|
||||||
|
"useDesktopLinked",
|
||||||
|
"useDesktopUpdate",
|
||||||
|
"useEditorDefaultKeys",
|
||||||
|
"useHostActions",
|
||||||
|
"useHostBadges",
|
||||||
|
"useHostContextMenuItems",
|
||||||
|
"useHostEditorToolbarItems",
|
||||||
|
"useHostStatusIndicators",
|
||||||
|
"useHostEditorSections",
|
||||||
|
"useHostProtocols",
|
||||||
|
"useHostStatus",
|
||||||
|
"useInstalledPlugins",
|
||||||
"useIsDefaultsEditor",
|
"useIsDefaultsEditor",
|
||||||
"useIsMobile",
|
"useIsMobile",
|
||||||
|
"useKeybindingActions",
|
||||||
|
"useKeybindingDefaults",
|
||||||
"useOptionalConnectionLog",
|
"useOptionalConnectionLog",
|
||||||
|
"usePageVisibleInterval",
|
||||||
|
"usePaletteEntries",
|
||||||
|
"usePaletteGroups",
|
||||||
|
"usePanels",
|
||||||
|
"usePermissions",
|
||||||
|
"usePluginDocsUrl",
|
||||||
|
"usePluginHostSections",
|
||||||
|
"usePluginStore",
|
||||||
|
"useRailItems",
|
||||||
|
"useRegisteredDashboardCards",
|
||||||
|
"useServerStatus",
|
||||||
|
"useServerStatusMeta",
|
||||||
|
"useShellController",
|
||||||
|
"useSplitTargets",
|
||||||
|
"useSshAuthEditors",
|
||||||
|
"useSshAuthProviders",
|
||||||
|
"useStatusColorScheme",
|
||||||
"useSurfaceKind",
|
"useSurfaceKind",
|
||||||
|
"useTabHandlesVersion",
|
||||||
"useTabs",
|
"useTabs",
|
||||||
"useTabsSafe",
|
"useTabsSafe",
|
||||||
|
"useTheme",
|
||||||
|
"useThemeOptions",
|
||||||
|
"useUiPreference",
|
||||||
|
"useUiPreferencesContext",
|
||||||
|
"waitForDefaultsJob",
|
||||||
|
"withProtocolSettings",
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Every name a module exports, resolved the way the compiler sees it. */
|
/** Every name a module exports, resolved the way the compiler sees it. */
|
||||||
|
|||||||
@@ -48,7 +48,11 @@ function start() {
|
|||||||
writeFileSync: vi.fn(),
|
writeFileSync: vi.fn(),
|
||||||
},
|
},
|
||||||
fork: () => child,
|
fork: () => child,
|
||||||
app: { isPackaged: true, getVersion: () => "2.9.0" },
|
app: {
|
||||||
|
isPackaged: true,
|
||||||
|
getVersion: () => "2.9.0",
|
||||||
|
getPath: () => "/user-data",
|
||||||
|
},
|
||||||
process: { env: { LOG_LEVEL: "error" } },
|
process: { env: { LOG_LEVEL: "error" } },
|
||||||
isDev: false,
|
isDev: false,
|
||||||
appRoot: "/app",
|
appRoot: "/app",
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { createRequire } from "node:module";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
|
||||||
const { isLaunchableFileName } = require("../electron/external-editor.cjs") as {
|
|
||||||
isLaunchableFileName: (fileName: unknown) => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("Electron external editor", () => {
|
|
||||||
it("refuses remote file names the OS would run instead of open", () => {
|
|
||||||
for (const name of [
|
|
||||||
"payload.exe",
|
|
||||||
"run.BAT",
|
|
||||||
"script.ps1",
|
|
||||||
"a.exe. ",
|
|
||||||
"a.cmd...",
|
|
||||||
"../../evil.vbs",
|
|
||||||
"start.command",
|
|
||||||
"launch.desktop",
|
|
||||||
"install.sh",
|
|
||||||
]) {
|
|
||||||
expect(isLaunchableFileName(name)).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens ordinary text files", () => {
|
|
||||||
for (const name of [
|
|
||||||
"nginx.conf",
|
|
||||||
"README.md",
|
|
||||||
"Dockerfile",
|
|
||||||
".bashrc",
|
|
||||||
"config.json",
|
|
||||||
"index.ts",
|
|
||||||
"",
|
|
||||||
undefined,
|
|
||||||
]) {
|
|
||||||
expect(isLaunchableFileName(name)).toBe(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -39,12 +39,24 @@ describe("Electron security boundary", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("launches only the editor saved from a file dialog", () => {
|
it("saves an external editor only if it was chosen in a file dialog", () => {
|
||||||
expect(main).not.toContain("fileData?.editorPath");
|
|
||||||
expect(main).toContain("readSetting(EXTERNAL_EDITOR_SETTING)");
|
|
||||||
expect(main).toContain("dialogChosenPaths.has(value)");
|
expect(main).toContain("dialogChosenPaths.has(value)");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps local files, the local terminal and the external editor out of Electron", () => {
|
||||||
|
for (const channel of [
|
||||||
|
"open-external-editor",
|
||||||
|
"close-external-editor",
|
||||||
|
"local-terminal-start",
|
||||||
|
"local-fs:",
|
||||||
|
"local-transfer:",
|
||||||
|
"list-local-directory",
|
||||||
|
]) {
|
||||||
|
expect(main).not.toContain(channel);
|
||||||
|
expect(preload).not.toContain(channel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("scopes proxy headers and basic auth to the linked origin", () => {
|
it("scopes proxy headers and basic auth to the linked origin", () => {
|
||||||
expect(main).toContain('"sync-proxy-config"');
|
expect(main).toContain('"sync-proxy-config"');
|
||||||
expect(main).toContain("linkedServer.applyLinkHeaders(");
|
expect(main).toContain("linkedServer.applyLinkHeaders(");
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Builds src/ui/settings/settings-search-index.ts: the i18n keys each core
|
|
||||||
* settings section shows, so the Settings search can match on any label in
|
|
||||||
* a section without mounting it. Run it after changing a settings section.
|
|
||||||
*
|
|
||||||
* --check fails when the committed file is out of date.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fs = require("node:fs");
|
|
||||||
const path = require("node:path");
|
|
||||||
|
|
||||||
const ROOT = path.resolve(__dirname, "..");
|
|
||||||
const SIDEBAR = path.join(ROOT, "src", "ui", "sidebar");
|
|
||||||
const OUT = path.join(
|
|
||||||
ROOT,
|
|
||||||
"src",
|
|
||||||
"ui",
|
|
||||||
"settings",
|
|
||||||
"settings-search-index.ts",
|
|
||||||
);
|
|
||||||
|
|
||||||
function flatKeys(obj, prefix = "") {
|
|
||||||
return Object.entries(obj).flatMap(([key, value]) =>
|
|
||||||
typeof value === "object"
|
|
||||||
? flatKeys(value, `${prefix}${key}.`)
|
|
||||||
: [`${prefix}${key}`],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const EN_KEYS = new Set(
|
|
||||||
flatKeys(require(path.join(ROOT, "src", "ui", "locales", "en.json"))),
|
|
||||||
);
|
|
||||||
|
|
||||||
// t("x", { count }) reads x_one or x_other, so index the plural key.
|
|
||||||
function realKey(key) {
|
|
||||||
return EN_KEYS.has(key) || !EN_KEYS.has(`${key}_other`)
|
|
||||||
? key
|
|
||||||
: `${key}_other`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function keysIn(text) {
|
|
||||||
return [
|
|
||||||
...new Set(
|
|
||||||
[...text.matchAll(/\bt\(\s*"([\w.-]+)"/g)].map((m) => realKey(m[1])),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function read(file) {
|
|
||||||
return fs.readFileSync(path.join(SIDEBAR, file), "utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The body of one exported or local function, by name. */
|
|
||||||
function functionBody(source, name) {
|
|
||||||
const start = source.search(new RegExp(`function ${name}\\b`));
|
|
||||||
if (start < 0) throw new Error(`No function ${name}`);
|
|
||||||
const next = source.slice(start + 1).search(/\n(export )?function \w+/);
|
|
||||||
return next < 0 ? source.slice(start) : source.slice(start, start + 1 + next);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Profile sections, split on their AccordionSection ids. */
|
|
||||||
function profileSections() {
|
|
||||||
const source = read("UserProfilePanel.tsx");
|
|
||||||
const lines = source.split("\n");
|
|
||||||
const starts = [];
|
|
||||||
lines.forEach((line, i) => {
|
|
||||||
if (/^\s{6}<AccordionSection\b/.test(line)) starts.push(i);
|
|
||||||
});
|
|
||||||
const out = {};
|
|
||||||
starts.forEach((start, k) => {
|
|
||||||
const end = k + 1 < starts.length ? starts[k + 1] : lines.length;
|
|
||||||
const chunk = lines.slice(start, end).join("\n");
|
|
||||||
const id = (chunk.match(/\bid="([^"]+)"/) || [])[1];
|
|
||||||
if (id) out[id] = keysIn(chunk);
|
|
||||||
});
|
|
||||||
out.interface = [
|
|
||||||
...new Set([
|
|
||||||
...(out.interface ?? []),
|
|
||||||
...keysIn(read("InterfacePresetSettings.tsx")),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
out.security = [
|
|
||||||
...new Set([
|
|
||||||
...(out.security ?? []),
|
|
||||||
...keysIn(read("AuthEnrollmentSections.tsx")),
|
|
||||||
...keysIn(functionBody(source, "PasswordChangeSection")),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
out["api-keys"] = [
|
|
||||||
...new Set([
|
|
||||||
...(out["api-keys"] ?? []),
|
|
||||||
...keysIn(functionBody(source, "NewApiKeyDialog")),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
out.keybindings = keysIn(
|
|
||||||
fs.readFileSync(
|
|
||||||
path.join(ROOT, "src", "ui", "settings", "KeybindingsSettings.tsx"),
|
|
||||||
"utf8",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function adminSections() {
|
|
||||||
const settings = read("AdminSettingsSections.tsx");
|
|
||||||
const management = read("AdminManagementSections.tsx");
|
|
||||||
return {
|
|
||||||
"admin-general": [
|
|
||||||
...new Set([
|
|
||||||
...keysIn(functionBody(settings, "AdminGeneralSettingsSection")),
|
|
||||||
...keysIn(read("AdminPluginDeveloperMode.tsx")),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
"admin-users": [
|
|
||||||
...new Set([
|
|
||||||
...keysIn(functionBody(management, "AdminUsersSection")),
|
|
||||||
...keysIn(read("AdminUserDialogs.tsx")),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
"admin-sessions": keysIn(functionBody(management, "AdminSessionsSection")),
|
|
||||||
"admin-roles": keysIn(functionBody(management, "AdminRolesSection")),
|
|
||||||
"admin-host-defaults": [
|
|
||||||
...new Set([
|
|
||||||
...keysIn(functionBody(settings, "AdminHostDefaultsSection")),
|
|
||||||
...keysIn(read("AdminHostTags.tsx")),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
"admin-branding": keysIn(read("AdminBrandingSection.tsx")),
|
|
||||||
"admin-database": keysIn(functionBody(settings, "AdminDatabaseSection")),
|
|
||||||
"admin-ssl": keysIn(functionBody(settings, "AdminSSLSection")),
|
|
||||||
"admin-api-keys": keysIn(read("AdminApiKeysSection.tsx")),
|
|
||||||
"admin-audit-log": keysIn(read("AdminAuditLogSection.tsx")),
|
|
||||||
updates: [
|
|
||||||
...new Set(
|
|
||||||
["UpdatesSettings.tsx", "DesktopUpdatesCard.tsx"].flatMap((file) =>
|
|
||||||
keysIn(
|
|
||||||
fs.readFileSync(
|
|
||||||
path.join(ROOT, "src", "ui", "settings", file),
|
|
||||||
"utf8",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
const index = { ...profileSections(), ...adminSections() };
|
|
||||||
const body = Object.entries(index)
|
|
||||||
.sort(([a], [b]) => a.localeCompare(b))
|
|
||||||
.map(
|
|
||||||
([id, keys]) =>
|
|
||||||
` ${JSON.stringify(id)}: [\n${keys
|
|
||||||
.sort()
|
|
||||||
.map((key) => ` ${JSON.stringify(key)},`)
|
|
||||||
.join("\n")}\n ],`,
|
|
||||||
)
|
|
||||||
.join("\n");
|
|
||||||
return `// Generated by scripts/generate-settings-search-index.cjs. Do not edit.\n\n/** The i18n keys each core settings section shows, for search. */\nexport const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {\n${body}\n};\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = render();
|
|
||||||
if (process.argv.includes("--check")) {
|
|
||||||
const current = fs.existsSync(OUT) ? fs.readFileSync(OUT, "utf8") : "";
|
|
||||||
if (current !== next) {
|
|
||||||
console.error(
|
|
||||||
"src/ui/settings/settings-search-index.ts is out of date. Run: node scripts/generate-settings-search-index.cjs",
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fs.writeFileSync(OUT, next);
|
|
||||||
console.log(`Wrote ${path.relative(ROOT, OUT)}`);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { execFileSync } = require("node:child_process");
|
||||||
|
|
||||||
|
function findPackage(start) {
|
||||||
|
let directory = path.dirname(start);
|
||||||
|
while (directory !== path.dirname(directory)) {
|
||||||
|
const manifest = path.join(directory, "package.json");
|
||||||
|
if (fs.existsSync(manifest)) return JSON.parse(fs.readFileSync(manifest));
|
||||||
|
directory = path.dirname(directory);
|
||||||
|
}
|
||||||
|
throw new Error("Could not locate the installed sharp package manifest");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The build runs on x64 but ships arm64 and ia32 too, so npm ci alone
|
||||||
|
// only installs the x64 binary.
|
||||||
|
const sharpPackage = findPackage(require.resolve("sharp"));
|
||||||
|
const packages = [
|
||||||
|
"@img/sharp-win32-arm64",
|
||||||
|
"@img/sharp-win32-ia32",
|
||||||
|
"@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,
|
||||||
|
});
|
||||||
@@ -15,16 +15,29 @@
|
|||||||
* enabledByEnv counts the plugin as recommended when any of those
|
* enabledByEnv counts the plugin as recommended when any of those
|
||||||
* environment variables is set, for a server configured before first boot.
|
* environment variables is set, for a server configured before first boot.
|
||||||
* Plugins without it start unchecked.
|
* Plugins without it start unchecked.
|
||||||
|
*
|
||||||
|
* "tier" says which image profile ships the plugin: "minimal" (only what the
|
||||||
|
* app cannot start without, the default look), "standard" (the default image)
|
||||||
|
* or "full" (everything else). A profile includes its own tier and every
|
||||||
|
* smaller one. A plugin with no tier counts as "full".
|
||||||
|
*
|
||||||
|
* "defaultLook": true marks the plugin whose layout (and that layout's
|
||||||
|
* theme) a fresh install shows. Core finds the default look this way, so it
|
||||||
|
* never names a plugin. Such a plugin always starts enabled and cannot be
|
||||||
|
* uninstalled, only swapped for another look.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const crypto = require("node:crypto");
|
const crypto = require("node:crypto");
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
|
const semver = require("semver");
|
||||||
|
|
||||||
const ID = /^[a-z0-9][a-z0-9-]*$/;
|
const ID = /^[a-z0-9][a-z0-9-]*$/;
|
||||||
const ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
|
const ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
|
||||||
const ONBOARDING_KEYS = new Set(["recommended", "consent", "enabledByEnv"]);
|
const ONBOARDING_KEYS = new Set(["recommended", "consent", "enabledByEnv"]);
|
||||||
const SHA256 = /^[0-9a-f]{64}$/;
|
const SHA256 = /^[0-9a-f]{64}$/;
|
||||||
|
const PROFILES = ["minimal", "standard", "full"];
|
||||||
|
const DEFAULT_PROFILE = "standard";
|
||||||
|
|
||||||
function parseOnboarding(value, id, problems) {
|
function parseOnboarding(value, id, problems) {
|
||||||
if (value === undefined) return null;
|
if (value === undefined) return null;
|
||||||
@@ -110,6 +123,15 @@ function parseBundledPlugins(raw) {
|
|||||||
}
|
}
|
||||||
const before = problems.length;
|
const before = problems.length;
|
||||||
const onboarding = parseOnboarding(entry.onboarding, entry.id, problems);
|
const onboarding = parseOnboarding(entry.onboarding, entry.id, problems);
|
||||||
|
if (
|
||||||
|
entry.defaultLook !== undefined &&
|
||||||
|
typeof entry.defaultLook !== "boolean"
|
||||||
|
) {
|
||||||
|
problems.push(`${entry.id} defaultLook must be a boolean`);
|
||||||
|
}
|
||||||
|
if (entry.tier !== undefined && !PROFILES.includes(entry.tier)) {
|
||||||
|
problems.push(`${entry.id} tier must be one of ${PROFILES.join(", ")}`);
|
||||||
|
}
|
||||||
if (problems.length > before) continue;
|
if (problems.length > before) continue;
|
||||||
plugins.push({
|
plugins.push({
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
@@ -118,6 +140,8 @@ function parseBundledPlugins(raw) {
|
|||||||
path: hasPath ? entry.path : null,
|
path: hasPath ? entry.path : null,
|
||||||
sha256: entry.sha256,
|
sha256: entry.sha256,
|
||||||
onboarding,
|
onboarding,
|
||||||
|
defaultLook: entry.defaultLook === true,
|
||||||
|
tier: entry.tier ?? "full",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
problems.push(`${entry.id} has an unknown source "${entry.source}"`);
|
problems.push(`${entry.id} has an unknown source "${entry.source}"`);
|
||||||
@@ -218,32 +242,76 @@ function buildBundledIndex(plugins, stagedIds) {
|
|||||||
const staged = new Set(stagedIds);
|
const staged = new Set(stagedIds);
|
||||||
const out = {};
|
const out = {};
|
||||||
for (const plugin of plugins) {
|
for (const plugin of plugins) {
|
||||||
if (!staged.has(plugin.id) || !plugin.onboarding) continue;
|
if (!staged.has(plugin.id)) continue;
|
||||||
out[plugin.id] = { onboarding: plugin.onboarding };
|
if (!plugin.onboarding && !plugin.defaultLook) continue;
|
||||||
|
out[plugin.id] = {
|
||||||
|
...(plugin.onboarding ? { onboarding: plugin.onboarding } : {}),
|
||||||
|
...(plugin.defaultLook ? { defaultLook: true } : {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return { version: 1, plugins: out };
|
return { version: 1, plugins: out };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The profile to build: --profile, then TERMIX_PLUGIN_PROFILE, then standard. */
|
||||||
|
function resolveProfile(argv = process.argv, env = process.env) {
|
||||||
|
const flag = argv.indexOf("--profile");
|
||||||
|
const value =
|
||||||
|
(flag !== -1 ? argv[flag + 1] : env.TERMIX_PLUGIN_PROFILE) ||
|
||||||
|
DEFAULT_PROFILE;
|
||||||
|
if (!PROFILES.includes(value)) {
|
||||||
|
throw new Error(
|
||||||
|
`unknown plugin profile "${value}", use ${PROFILES.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The plugins a profile ships: its own tier and every smaller one. */
|
||||||
|
function pluginsForProfile(plugins, profile) {
|
||||||
|
const rank = PROFILES.indexOf(profile);
|
||||||
|
return plugins.filter((plugin) => PROFILES.indexOf(plugin.tier) <= rank);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pins the newest registry version of each wanted plugin. `ids` is the list
|
* Pins the newest registry version of each wanted plugin. `ids` is the list
|
||||||
* to pin, or null for every plugin in the index. `current` is the existing
|
* to pin, or null for every plugin in the index. `current` is the existing
|
||||||
* pin list, so fields the registry does not know (onboarding) 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.
|
||||||
*/
|
*/
|
||||||
function pinsFromIndex(index, ids, current = []) {
|
function pinsFromIndex(index, ids, current = [], coreVersion = null) {
|
||||||
const plugins = Array.isArray(index?.plugins) ? index.plugins : [];
|
const plugins = Array.isArray(index?.plugins) ? index.plugins : [];
|
||||||
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
|
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
|
||||||
const currentById = new Map(current.map((entry) => [entry.id, entry]));
|
const currentById = new Map(current.map((entry) => [entry.id, entry]));
|
||||||
const wanted = ids ?? plugins.map((plugin) => plugin.id);
|
const wanted = ids ?? plugins.map((plugin) => plugin.id);
|
||||||
return [...wanted].sort().map((id) => {
|
return [...wanted].sort().map((id) => {
|
||||||
const latest = byId.get(id)?.versions?.[0];
|
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) throw new Error(`${id} is not in the registry index`);
|
||||||
const onboarding = currentById.get(id)?.onboarding;
|
const onboarding = currentById.get(id)?.onboarding;
|
||||||
|
const defaultLook = currentById.get(id)?.defaultLook === true;
|
||||||
|
const tier = currentById.get(id)?.tier ?? "full";
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
source: "tmxplug",
|
source: "tmxplug",
|
||||||
url: latest.url,
|
url: latest.url,
|
||||||
sha256: latest.sha256,
|
sha256: latest.sha256,
|
||||||
|
tier,
|
||||||
...(onboarding ? { onboarding } : {}),
|
...(onboarding ? { onboarding } : {}),
|
||||||
|
...(defaultLook ? { defaultLook } : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -357,9 +425,26 @@ function rebuildLocalPlugins(dir, ids, run) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Copies a local plugin build into the staging folder. */
|
/** Copies a local plugin build into the staging folder. */
|
||||||
|
/** Theme CSS files the repo's manifest declares, which ship with it too. */
|
||||||
|
function localThemeFiles(repo) {
|
||||||
|
try {
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(repo, "manifest.json"), "utf8"),
|
||||||
|
);
|
||||||
|
const themes = manifest?.contributes?.themes;
|
||||||
|
return Array.isArray(themes)
|
||||||
|
? themes
|
||||||
|
.map((theme) => theme?.css)
|
||||||
|
.filter((css) => typeof css === "string")
|
||||||
|
: [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function copyLocalBuild(repo, destination) {
|
function copyLocalBuild(repo, destination) {
|
||||||
fs.mkdirSync(destination, { recursive: true });
|
fs.mkdirSync(destination, { recursive: true });
|
||||||
for (const entry of LOCAL_FILES) {
|
for (const entry of new Set([...LOCAL_FILES, ...localThemeFiles(repo)])) {
|
||||||
const from = path.join(repo, entry);
|
const from = path.join(repo, entry);
|
||||||
if (fs.existsSync(from)) {
|
if (fs.existsSync(from)) {
|
||||||
fs.cpSync(from, path.join(destination, entry), { recursive: true });
|
fs.cpSync(from, path.join(destination, entry), { recursive: true });
|
||||||
@@ -368,6 +453,9 @@ function copyLocalBuild(repo, destination) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
PROFILES,
|
||||||
|
resolveProfile,
|
||||||
|
pluginsForProfile,
|
||||||
findLocalPluginBuilds,
|
findLocalPluginBuilds,
|
||||||
isLocalBuildStale,
|
isLocalBuildStale,
|
||||||
localSdkBuildCommand,
|
localSdkBuildCommand,
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
const fs = require("node:fs");
|
||||||
|
|
||||||
|
const SK_ED = "sk-ssh-ed25519@openssh.com";
|
||||||
|
const SK_EC = "sk-ecdsa-sha2-nistp256@openssh.com";
|
||||||
|
|
||||||
|
function replaceOnce(source, from, to, label) {
|
||||||
|
const first = source.indexOf(from);
|
||||||
|
if (first === -1 || source.indexOf(from, first + 1) !== -1) {
|
||||||
|
throw new Error(`ssh2 changed (${label}); review the security key patch`);
|
||||||
|
}
|
||||||
|
return source.replace(from, () => to);
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchKeyParserSource(source) {
|
||||||
|
if (source.includes("// termix-sk")) return source;
|
||||||
|
let out = replaceOnce(
|
||||||
|
source,
|
||||||
|
` case 'ecdsa-sha2-nistp521':
|
||||||
|
return true;
|
||||||
|
case 'ssh-ed25519':
|
||||||
|
if (eddsaSupported)
|
||||||
|
return true;`,
|
||||||
|
` case 'ecdsa-sha2-nistp521':
|
||||||
|
return true;
|
||||||
|
case '${SK_EC}': // termix-sk
|
||||||
|
return true;
|
||||||
|
case '${SK_ED}':
|
||||||
|
case 'ssh-ed25519':
|
||||||
|
if (eddsaSupported)
|
||||||
|
return true;`,
|
||||||
|
"key types",
|
||||||
|
);
|
||||||
|
out = replaceOnce(
|
||||||
|
out,
|
||||||
|
` default:
|
||||||
|
return new Error(\`Unsupported OpenSSH public key type: \${baseType}\`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OpenSSH_Public(`,
|
||||||
|
` case '${SK_ED}':
|
||||||
|
case '${SK_EC}': {
|
||||||
|
// Public blob only; the security key signs, so there is no private half.
|
||||||
|
const rest = data.subarray(data._pos || 0);
|
||||||
|
const typeLen = Buffer.byteLength(baseType);
|
||||||
|
pubSSH = Buffer.allocUnsafe(4 + typeLen + rest.length);
|
||||||
|
writeUInt32BE(pubSSH, typeLen, 0);
|
||||||
|
pubSSH.utf8Write(baseType, 4, typeLen);
|
||||||
|
pubSSH.set(rest, 4 + typeLen);
|
||||||
|
algo = baseType === '${SK_ED}' ? null : 'sha256';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return new Error(\`Unsupported OpenSSH public key type: \${baseType}\`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OpenSSH_Public(`,
|
||||||
|
"parseDER",
|
||||||
|
);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchAgentSource(source) {
|
||||||
|
if (source.includes("skTrailer")) return source;
|
||||||
|
return replaceOnce(
|
||||||
|
source,
|
||||||
|
` binaryParser.init(signature, 0);
|
||||||
|
binaryParser.readString(true);
|
||||||
|
signature = binaryParser.readString();
|
||||||
|
binaryParser.clear();`,
|
||||||
|
` binaryParser.init(signature, 0);
|
||||||
|
binaryParser.readString(true);
|
||||||
|
signature = binaryParser.readString();
|
||||||
|
// Security key signatures end with flags and a counter.
|
||||||
|
const skTrailer = signature === undefined
|
||||||
|
? undefined
|
||||||
|
: binaryParser.readRaw();
|
||||||
|
binaryParser.clear();
|
||||||
|
if (skTrailer && skTrailer.length)
|
||||||
|
signature.skTrailer = Buffer.from(skTrailer);`,
|
||||||
|
"agent sign response",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchProtocolSource(source) {
|
||||||
|
if (source.includes("skTrailer")) return source;
|
||||||
|
const start = source.indexOf(" authPK(username, pubKey, keyAlgo, cbSign) {");
|
||||||
|
const end = source.indexOf(" authHostbased(", start);
|
||||||
|
if (start === -1 || end === -1) {
|
||||||
|
throw new Error("ssh2 changed (authPK); review the security key patch");
|
||||||
|
}
|
||||||
|
let region = source.slice(start, end);
|
||||||
|
region = replaceOnce(
|
||||||
|
region,
|
||||||
|
` signature = convertSignature(signature, keyType);`,
|
||||||
|
` const skTrailer = signature && signature.skTrailer;
|
||||||
|
signature = convertSignature(signature, keyType);`,
|
||||||
|
"authPK sign",
|
||||||
|
);
|
||||||
|
region = replaceOnce(
|
||||||
|
region,
|
||||||
|
` + 4 + algoLen + 4 + sigLen
|
||||||
|
);`,
|
||||||
|
` + 4 + algoLen + 4 + sigLen + (skTrailer ? skTrailer.length : 0)
|
||||||
|
);`,
|
||||||
|
"authPK alloc",
|
||||||
|
);
|
||||||
|
region = replaceOnce(
|
||||||
|
region,
|
||||||
|
` writeUInt32BE(packet, 4 + algoLen + 4 + sigLen, p += pubKeyLen);`,
|
||||||
|
` writeUInt32BE(
|
||||||
|
packet,
|
||||||
|
4 + algoLen + 4 + sigLen + (skTrailer ? skTrailer.length : 0),
|
||||||
|
p += pubKeyLen
|
||||||
|
);`,
|
||||||
|
"authPK length",
|
||||||
|
);
|
||||||
|
region = replaceOnce(
|
||||||
|
region,
|
||||||
|
` packet.set(signature, p += 4);`,
|
||||||
|
` packet.set(signature, p += 4);
|
||||||
|
if (skTrailer)
|
||||||
|
packet.set(skTrailer, p + sigLen);`,
|
||||||
|
"authPK copy",
|
||||||
|
);
|
||||||
|
return source.slice(0, start) + region + source.slice(end);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
const targets = [
|
||||||
|
["ssh2/lib/protocol/keyParser.js", patchKeyParserSource],
|
||||||
|
["ssh2/lib/agent.js", patchAgentSource],
|
||||||
|
["ssh2/lib/protocol/Protocol.js", patchProtocolSource],
|
||||||
|
];
|
||||||
|
for (const [id, patch] of targets) {
|
||||||
|
const target = require.resolve(id);
|
||||||
|
const source = fs.readFileSync(target, "utf8");
|
||||||
|
const result = patch(source);
|
||||||
|
if (result !== source) fs.writeFileSync(target, result);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
"[patch-ssh2-sk] Security key public keys and signatures enabled",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
patchKeyParserSource,
|
||||||
|
patchAgentSource,
|
||||||
|
patchProtocolSource,
|
||||||
|
};
|
||||||
@@ -4,7 +4,10 @@
|
|||||||
* re-released.
|
* re-released.
|
||||||
*
|
*
|
||||||
* node scripts/pin-bundled-plugins.cjs refresh the plugins already listed
|
* node scripts/pin-bundled-plugins.cjs refresh the plugins already listed
|
||||||
* node scripts/pin-bundled-plugins.cjs --all list every plugin in the index
|
* node scripts/pin-bundled-plugins.cjs --all also add plugins new to the index (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");
|
const fs = require("node:fs");
|
||||||
@@ -22,6 +25,8 @@ const configPath = path.join(root, "docker", "bundled-plugins.json");
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const all = process.argv.includes("--all");
|
const all = process.argv.includes("--all");
|
||||||
|
const flag = process.argv.indexOf("--core-version");
|
||||||
|
const coreVersion = flag !== -1 ? process.argv[flag + 1] : null;
|
||||||
const response = await fetch(INDEX_URL);
|
const response = await fetch(INDEX_URL);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`registry index download failed with ${response.status}`);
|
throw new Error(`registry index download failed with ${response.status}`);
|
||||||
@@ -30,7 +35,7 @@ async function main() {
|
|||||||
|
|
||||||
const current = loadBundledPlugins(root);
|
const current = loadBundledPlugins(root);
|
||||||
const ids = all ? null : current.map((plugin) => plugin.id);
|
const ids = all ? null : current.map((plugin) => plugin.id);
|
||||||
const plugins = pinsFromIndex(index, ids, current);
|
const plugins = pinsFromIndex(index, ids, current, coreVersion);
|
||||||
fs.writeFileSync(configPath, `${JSON.stringify({ plugins }, null, 2)}\n`);
|
fs.writeFileSync(configPath, `${JSON.stringify({ plugins }, null, 2)}\n`);
|
||||||
console.log(
|
console.log(
|
||||||
`Pinned ${plugins.length} plugin(s) in docker/bundled-plugins.json`,
|
`Pinned ${plugins.length} plugin(s) in docker/bundled-plugins.json`,
|
||||||
|
|||||||
@@ -35,6 +35,42 @@ function fixturePlugin(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("termix-plugin build", () => {
|
describe("termix-plugin build", () => {
|
||||||
|
it("builds a theme only plugin with an empty backend and no source", async () => {
|
||||||
|
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-build-"));
|
||||||
|
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, "manifest.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
id: "theme-fixture",
|
||||||
|
capabilities: ["ui:theme"],
|
||||||
|
contributes: { themes: [{ id: "paper" }] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await build({ cwd: dir });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fs.readFileSync(path.join(dir, "dist", "backend.js"), "utf8"),
|
||||||
|
).toContain("export function activate()");
|
||||||
|
expect(fs.existsSync(path.join(dir, "dist", "frontend.js"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still needs a backend entry once a plugin holds more than themes", async () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-build-"));
|
||||||
|
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, "manifest.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
id: "mixed-fixture",
|
||||||
|
capabilities: ["ui:theme", "ui:layout"],
|
||||||
|
contributes: { themes: [{ id: "paper" }] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(build({ cwd: dir })).rejects.toThrow("no backend entry");
|
||||||
|
});
|
||||||
|
|
||||||
it("marks dist as ESM so Node loads backend.js without reparsing", async () => {
|
it("marks dist as ESM so Node loads backend.js without reparsing", async () => {
|
||||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
const dir = fixturePlugin();
|
const dir = fixturePlugin();
|
||||||
|
|||||||
@@ -142,3 +142,29 @@ describe("termix-plugin pack", () => {
|
|||||||
expect(fs.readdirSync(out)).toEqual([]);
|
expect(fs.readdirSync(out)).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("termix-plugin pack themes", () => {
|
||||||
|
it("ships the CSS files the manifest's themes declare", async () => {
|
||||||
|
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const dir = fixturePlugin({
|
||||||
|
capabilities: ["ui:theme"],
|
||||||
|
contributes: {
|
||||||
|
themes: [
|
||||||
|
{
|
||||||
|
id: "paper",
|
||||||
|
label: "Paper",
|
||||||
|
mode: "light",
|
||||||
|
css: "styles/paper.css",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
writeFile(dir, "styles/paper.css", ".paper { --background: #fff; }\n");
|
||||||
|
writeFile(dir, "styles/draft.css", ".draft {}\n");
|
||||||
|
const file = await pack({ cwd: dir, args: ["--out", tempDir("t-")] });
|
||||||
|
const names = await entries(file);
|
||||||
|
expect(names).toContain("styles/paper.css");
|
||||||
|
expect(names).not.toContain("styles/draft.css");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -49,3 +49,98 @@ describe("termix-plugin validate migrations", () => {
|
|||||||
expect(output).not.toMatch(/migrations\/\w+ is missing/);
|
expect(output).not.toMatch(/migrations\/\w+ is missing/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("termix-plugin validate themes", () => {
|
||||||
|
function themed(css: string | null) {
|
||||||
|
const root = plugin([]);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, "manifest.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
id: "fixture",
|
||||||
|
version: "1.0.0",
|
||||||
|
capabilities: ["ui:theme"],
|
||||||
|
contributes: {
|
||||||
|
themes: [
|
||||||
|
{
|
||||||
|
id: "paper",
|
||||||
|
label: "Paper",
|
||||||
|
mode: "light",
|
||||||
|
css: "themes/paper.css",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (css !== null) {
|
||||||
|
fs.mkdirSync(path.join(root, "themes"));
|
||||||
|
fs.writeFileSync(path.join(root, "themes", "paper.css"), css);
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("reports a missing theme file", async () => {
|
||||||
|
expect(await problems(themed(null))).toContain(
|
||||||
|
"theme paper: themes/paper.css does not exist",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports theme CSS that reaches outside or leaves its class", async () => {
|
||||||
|
const output = await problems(
|
||||||
|
themed('@import "x.css"; body { color: red }'),
|
||||||
|
);
|
||||||
|
expect(output).toContain("theme paper: @import is not allowed in a theme");
|
||||||
|
expect(output).toContain(
|
||||||
|
'theme paper: Selector "body" must start with .paper',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says nothing about CSS that is fine", async () => {
|
||||||
|
const output = await problems(themed(".paper { --background: #fff; }"));
|
||||||
|
expect(output).not.toContain("theme paper");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("termix-plugin validate layouts and capabilities", () => {
|
||||||
|
function withManifest(extra: Record<string, unknown>) {
|
||||||
|
const root = plugin([]);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, "manifest.json"),
|
||||||
|
JSON.stringify({ id: "fixture", version: "1.0.0", ...extra }),
|
||||||
|
);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("flags ui:theme with no themes and ui:layout with no layouts", async () => {
|
||||||
|
const output = await problems(
|
||||||
|
withManifest({ capabilities: ["ui:theme", "ui:layout"] }),
|
||||||
|
);
|
||||||
|
expect(output).toContain(
|
||||||
|
'"ui:theme" is declared but contributes.themes is empty',
|
||||||
|
);
|
||||||
|
expect(output).toContain(
|
||||||
|
'"ui:layout" is declared but contributes.layouts is empty',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a layout plugin with no frontend source", async () => {
|
||||||
|
const output = await problems(
|
||||||
|
withManifest({
|
||||||
|
capabilities: ["ui:layout"],
|
||||||
|
contributes: { layouts: [{ id: "wide", label: "Wide" }] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(output).toContain("contributes.layouts needs a frontend");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a layout plugin that has a frontend entry", async () => {
|
||||||
|
const root = withManifest({
|
||||||
|
capabilities: ["ui:layout"],
|
||||||
|
contributes: { layouts: [{ id: "wide", label: "Wide" }] },
|
||||||
|
});
|
||||||
|
fs.mkdirSync(path.join(root, "src", "frontend"), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(root, "src", "frontend", "index.tsx"), "");
|
||||||
|
const output = await problems(root);
|
||||||
|
expect(output).not.toContain("contributes.layouts needs a frontend");
|
||||||
|
expect(output).not.toContain("is declared but");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -137,9 +137,19 @@ function verifyArtifact(artifact) {
|
|||||||
console.log(`Verified macOS sharp packaging: ${path.basename(artifact)}`);
|
console.log(`Verified macOS sharp packaging: ${path.basename(artifact)}`);
|
||||||
} finally {
|
} finally {
|
||||||
if (mountedAt) {
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ import {
|
|||||||
isSecondFactorAfterExternalLoginEnabled,
|
isSecondFactorAfterExternalLoginEnabled,
|
||||||
} from "./core-auth.js";
|
} from "./core-auth.js";
|
||||||
import { findOrProvisionExternalUser } from "./provisioning.js";
|
import { findOrProvisionExternalUser } from "./provisioning.js";
|
||||||
|
import {
|
||||||
|
SECOND_FACTOR_SETUP,
|
||||||
|
evaluateAuthPolicy,
|
||||||
|
hasAuthPolicies,
|
||||||
|
rememberDeviceAllowed,
|
||||||
|
} from "./policy.js";
|
||||||
import {
|
import {
|
||||||
getLoginMethod,
|
getLoginMethod,
|
||||||
getSecondFactor,
|
getSecondFactor,
|
||||||
@@ -122,12 +128,15 @@ export async function evaluateSecondFactors(
|
|||||||
* governs external methods (SSO, LDAP) alone.
|
* governs external methods (SSO, LDAP) alone.
|
||||||
*/
|
*/
|
||||||
function shouldRunSecondFactors(methodId: string): boolean {
|
function shouldRunSecondFactors(methodId: string): boolean {
|
||||||
ensureCoreLoginProviders();
|
if (!isExternalMethod(methodId)) return true;
|
||||||
const method = getLoginMethod(methodId);
|
|
||||||
if (!method?.external) return true;
|
|
||||||
return isSecondFactorAfterExternalLoginEnabled();
|
return isSecondFactorAfterExternalLoginEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isExternalMethod(methodId: string): boolean {
|
||||||
|
ensureCoreLoginProviders();
|
||||||
|
return !!getLoginMethod(methodId)?.external;
|
||||||
|
}
|
||||||
|
|
||||||
export const TRUST_DEVICE_COOKIE = "termix_trust_device";
|
export const TRUST_DEVICE_COOKIE = "termix_trust_device";
|
||||||
const TRUST_TOKEN_PATTERN = /^[a-f0-9]{64}$/;
|
const TRUST_TOKEN_PATTERN = /^[a-f0-9]{64}$/;
|
||||||
const TRUST_TOKEN_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
const TRUST_TOKEN_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
@@ -294,9 +303,40 @@ export async function runLogin(
|
|||||||
`${context.methodId}_role_shared_credentials`,
|
`${context.methodId}_role_shared_credentials`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const factors = shouldRunSecondFactors(context.methodId)
|
// A login policy may want a factor where core would skip it, so it always
|
||||||
|
// sees the full picture.
|
||||||
|
const runFactors = shouldRunSecondFactors(context.methodId);
|
||||||
|
const factors =
|
||||||
|
runFactors || hasAuthPolicies()
|
||||||
? await evaluateSecondFactors(user.id)
|
? await evaluateSecondFactors(user.id)
|
||||||
: { required: [], blocked: [] };
|
: { required: [], blocked: [] };
|
||||||
|
const trusted =
|
||||||
|
factors.required.length > 0 || hasAuthPolicies()
|
||||||
|
? await isTrustedDevice(req, user.id)
|
||||||
|
: false;
|
||||||
|
const policy = await evaluateAuthPolicy({
|
||||||
|
userId: user.id,
|
||||||
|
methodId: context.methodId,
|
||||||
|
external: isExternalMethod(context.methodId),
|
||||||
|
deviceType,
|
||||||
|
trustedDevice: trusted,
|
||||||
|
mfaSatisfied: !!identity.mfaSatisfied,
|
||||||
|
secondFactors: listSecondFactors().map((factor) => ({
|
||||||
|
id: factor.id,
|
||||||
|
pluginId: factor.pluginId,
|
||||||
|
enrolled: factors.required.some(
|
||||||
|
(required) =>
|
||||||
|
required.id === factor.id && required.pluginId === factor.pluginId,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
if (!runFactors && !policy.secondFactor) {
|
||||||
|
factors.required = [];
|
||||||
|
factors.blocked = [];
|
||||||
|
}
|
||||||
|
const rememberMe = context.rememberMe && policy.allowRememberDevice;
|
||||||
|
const skipByDevice =
|
||||||
|
trusted && policy.allowRememberDevice && policy.secondFactor !== "always";
|
||||||
if (factors.blocked.length > 0) {
|
if (factors.blocked.length > 0) {
|
||||||
authLogger.warn("Login refused: an enrolled second factor is unavailable", {
|
authLogger.warn("Login refused: an enrolled second factor is unavailable", {
|
||||||
operation: "second_factor_unavailable",
|
operation: "second_factor_unavailable",
|
||||||
@@ -324,11 +364,7 @@ export async function runLogin(
|
|||||||
|
|
||||||
const ssoClaims = { externalSession: identity.externalSession ?? null };
|
const ssoClaims = { externalSession: identity.externalSession ?? null };
|
||||||
|
|
||||||
if (
|
if (factors.required.length > 0 && !identity.mfaSatisfied && !skipByDevice) {
|
||||||
factors.required.length > 0 &&
|
|
||||||
!identity.mfaSatisfied &&
|
|
||||||
!(await isTrustedDevice(req, user.id))
|
|
||||||
) {
|
|
||||||
const tempToken = await AuthManager.getInstance().generateJWTToken(
|
const tempToken = await AuthManager.getInstance().generateJWTToken(
|
||||||
user.id,
|
user.id,
|
||||||
{ pendingTOTP: true, expiresIn: "10m" },
|
{ pendingTOTP: true, expiresIn: "10m" },
|
||||||
@@ -337,14 +373,17 @@ export async function runLogin(
|
|||||||
pendingLogins.set(pendingKey(tempToken), {
|
pendingLogins.set(pendingKey(tempToken), {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
methodId: context.methodId,
|
methodId: context.methodId,
|
||||||
rememberMe: context.rememberMe,
|
rememberMe,
|
||||||
|
allowRememberDevice: policy.allowRememberDevice,
|
||||||
|
maxSessionSeconds: policy.maxSessionSeconds,
|
||||||
|
idleTimeoutSeconds: policy.idleTimeoutSeconds,
|
||||||
...ssoClaims,
|
...ssoClaims,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
kind: "second-factor",
|
kind: "second-factor",
|
||||||
tempToken,
|
tempToken,
|
||||||
rememberMe: context.rememberMe,
|
rememberMe,
|
||||||
factors: factors.required.map((factor) => ({
|
factors: factors.required.map((factor) => ({
|
||||||
id: factor.id,
|
id: factor.id,
|
||||||
pluginId: factor.pluginId,
|
pluginId: factor.pluginId,
|
||||||
@@ -353,11 +392,22 @@ export async function runLogin(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Required but nothing set up yet: a session that can only set one up.
|
||||||
|
const needsSetup =
|
||||||
|
!!policy.secondFactor &&
|
||||||
|
factors.required.length === 0 &&
|
||||||
|
!identity.mfaSatisfied &&
|
||||||
|
!skipByDevice;
|
||||||
|
|
||||||
const session = await issueSession(req, user, {
|
const session = await issueSession(req, user, {
|
||||||
methodId: context.methodId,
|
methodId: context.methodId,
|
||||||
rememberMe: context.rememberMe,
|
rememberMe,
|
||||||
rateLimitUsername: context.rateLimitUsername ?? identity.rateLimitUsername,
|
rateLimitUsername: context.rateLimitUsername ?? identity.rateLimitUsername,
|
||||||
longLivedForApps: identity.kind === "external",
|
longLivedForApps:
|
||||||
|
identity.kind === "external" && policy.allowRememberDevice,
|
||||||
|
maxAgeSeconds: policy.maxSessionSeconds,
|
||||||
|
idleTimeoutSeconds: policy.idleTimeoutSeconds,
|
||||||
|
restriction: needsSetup ? SECOND_FACTOR_SETUP : null,
|
||||||
...ssoClaims,
|
...ssoClaims,
|
||||||
});
|
});
|
||||||
return { kind: "session", ...session };
|
return { kind: "session", ...session };
|
||||||
@@ -510,10 +560,15 @@ export async function verifySecondFactorAndRespond(
|
|||||||
}
|
}
|
||||||
loginRateLimiter.resetTOTPAttempts(user.id);
|
loginRateLimiter.resetTOTPAttempts(user.id);
|
||||||
|
|
||||||
|
// A pending entry lost to a restart falls back to the current policy.
|
||||||
|
const allowRemember = lookup.pending
|
||||||
|
? lookup.pending.allowRememberDevice !== false
|
||||||
|
: rememberDeviceAllowed();
|
||||||
const rememberMe =
|
const rememberMe =
|
||||||
typeof req.body?.rememberMe === "boolean"
|
allowRemember &&
|
||||||
|
(typeof req.body?.rememberMe === "boolean"
|
||||||
? req.body.rememberMe
|
? req.body.rememberMe
|
||||||
: (lookup.pending?.rememberMe ?? false);
|
: (lookup.pending?.rememberMe ?? false));
|
||||||
|
|
||||||
let trustToken: string | null = null;
|
let trustToken: string | null = null;
|
||||||
if (rememberMe) {
|
if (rememberMe) {
|
||||||
@@ -545,6 +600,8 @@ export async function verifySecondFactorAndRespond(
|
|||||||
methodId: pending?.methodId ?? "password",
|
methodId: pending?.methodId ?? "password",
|
||||||
rememberMe,
|
rememberMe,
|
||||||
externalSession: pending?.externalSession ?? null,
|
externalSession: pending?.externalSession ?? null,
|
||||||
|
maxAgeSeconds: pending?.maxSessionSeconds ?? null,
|
||||||
|
idleTimeoutSeconds: pending?.idleTimeoutSeconds ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (trustToken && isNativeAppRequest(req)) {
|
if (trustToken && isNativeAppRequest(req)) {
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Login policy from plugins through ctx.auth.policy.provide: idle timeout,
|
||||||
|
* session length, remember me and a required second factor. Core asks every
|
||||||
|
* provider and the strictest answer wins. A provider that throws at login
|
||||||
|
* refuses the login, since skipping a security rule is worse than a failed
|
||||||
|
* sign in. The idle timeout is stored on the session at login. Desktop
|
||||||
|
* loopback, sync link and plugin minted sessions are device credentials, not
|
||||||
|
* logins, so the policy never reaches them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PluginAuthPolicyDecision,
|
||||||
|
PluginAuthPolicyInput,
|
||||||
|
PluginAuthPolicyProvider,
|
||||||
|
} from "@termix-ssh/plugin-sdk/backend";
|
||||||
|
import { authLogger } from "../utils/logger.js";
|
||||||
|
import { LoginMethodError } from "./types.js";
|
||||||
|
|
||||||
|
/** sessions.restriction of a session that may only set up a second factor. */
|
||||||
|
export const SECOND_FACTOR_SETUP = "second-factor-setup";
|
||||||
|
|
||||||
|
interface Entry {
|
||||||
|
pluginId: string;
|
||||||
|
provider: PluginAuthPolicyProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = new Map<symbol, Entry>();
|
||||||
|
|
||||||
|
export function provideAuthPolicy(
|
||||||
|
pluginId: string,
|
||||||
|
provider: PluginAuthPolicyProvider,
|
||||||
|
): () => void {
|
||||||
|
const key = Symbol(pluginId);
|
||||||
|
providers.set(key, { pluginId, provider });
|
||||||
|
return () => {
|
||||||
|
providers.delete(key);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam. */
|
||||||
|
export function clearAuthPolicies(): void {
|
||||||
|
providers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAuthPolicies(): boolean {
|
||||||
|
return providers.size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthPolicy {
|
||||||
|
allowRememberDevice: boolean;
|
||||||
|
maxSessionSeconds: number | null;
|
||||||
|
idleTimeoutSeconds: number | null;
|
||||||
|
secondFactor: "always" | "new-device" | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_POLICY: AuthPolicy = {
|
||||||
|
allowRememberDevice: true,
|
||||||
|
maxSessionSeconds: null,
|
||||||
|
idleTimeoutSeconds: null,
|
||||||
|
secondFactor: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function shortest(current: number | null, next: unknown): number | null {
|
||||||
|
if (typeof next !== "number" || !(next > 0)) return current;
|
||||||
|
const value = Math.floor(next);
|
||||||
|
return current === null ? value : Math.min(current, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function merge(policy: AuthPolicy, next: PluginAuthPolicyDecision): AuthPolicy {
|
||||||
|
let secondFactor = policy.secondFactor;
|
||||||
|
if (next.secondFactor === "always") secondFactor = "always";
|
||||||
|
else if (next.secondFactor === "new-device" && secondFactor !== "always") {
|
||||||
|
secondFactor = "new-device";
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
allowRememberDevice:
|
||||||
|
policy.allowRememberDevice && next.allowRememberDevice !== false,
|
||||||
|
maxSessionSeconds: shortest(
|
||||||
|
policy.maxSessionSeconds,
|
||||||
|
next.maxSessionSeconds,
|
||||||
|
),
|
||||||
|
idleTimeoutSeconds: shortest(
|
||||||
|
policy.idleTimeoutSeconds,
|
||||||
|
next.idleTimeoutSeconds,
|
||||||
|
),
|
||||||
|
secondFactor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function evaluateAuthPolicy(
|
||||||
|
input: PluginAuthPolicyInput,
|
||||||
|
): Promise<AuthPolicy> {
|
||||||
|
let policy = NO_POLICY;
|
||||||
|
for (const entry of providers.values()) {
|
||||||
|
let decision: PluginAuthPolicyDecision;
|
||||||
|
try {
|
||||||
|
decision = (await entry.provider.evaluate(input)) ?? {};
|
||||||
|
} catch (error) {
|
||||||
|
authLogger.warn("Login policy failed, refusing the login", {
|
||||||
|
operation: "auth_policy",
|
||||||
|
pluginId: entry.pluginId,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
throw new LoginMethodError(
|
||||||
|
"Sign in is not available right now. Contact an admin.",
|
||||||
|
503,
|
||||||
|
"auth_policy_unavailable",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
policy = merge(policy, decision);
|
||||||
|
}
|
||||||
|
return policy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the login page should offer remember me. */
|
||||||
|
export function rememberDeviceAllowed(): boolean {
|
||||||
|
for (const entry of providers.values()) {
|
||||||
|
try {
|
||||||
|
if (entry.provider.allowRememberDevice?.() === false) return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -68,6 +68,11 @@ export interface IssueSessionOptions {
|
|||||||
externalSession?: ExternalSessionRef | null;
|
externalSession?: ExternalSessionRef | null;
|
||||||
/** SSO logins keep desktop and mobile apps signed in for 30 days. */
|
/** SSO logins keep desktop and mobile apps signed in for 30 days. */
|
||||||
longLivedForApps?: boolean;
|
longLivedForApps?: boolean;
|
||||||
|
/** Login policy cap on the session length. */
|
||||||
|
maxAgeSeconds?: number | null;
|
||||||
|
/** See SECOND_FACTOR_SETUP. */
|
||||||
|
restriction?: string | null;
|
||||||
|
idleTimeoutSeconds?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IssuedSession {
|
export interface IssuedSession {
|
||||||
@@ -93,6 +98,9 @@ export async function issueSession(
|
|||||||
deviceType: deviceInfo.type,
|
deviceType: deviceInfo.type,
|
||||||
deviceInfo: deviceInfo.deviceInfo,
|
deviceInfo: deviceInfo.deviceInfo,
|
||||||
externalSession: options.externalSession ?? null,
|
externalSession: options.externalSession ?? null,
|
||||||
|
maxAgeSeconds: options.maxAgeSeconds ?? null,
|
||||||
|
restriction: options.restriction ?? null,
|
||||||
|
idleTimeoutSeconds: options.idleTimeoutSeconds ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (options.rateLimitUsername) {
|
if (options.rateLimitUsername) {
|
||||||
@@ -123,11 +131,14 @@ export async function issueSession(
|
|||||||
method: options.methodId,
|
method: options.methodId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const maxAge =
|
let maxAge =
|
||||||
options.longLivedForApps &&
|
options.longLivedForApps &&
|
||||||
(deviceInfo.type === "desktop" || deviceInfo.type === "mobile")
|
(deviceInfo.type === "desktop" || deviceInfo.type === "mobile")
|
||||||
? THIRTY_DAYS_MS
|
? THIRTY_DAYS_MS
|
||||||
: await sessionCookieMaxAge(options.rememberMe);
|
: await sessionCookieMaxAge(options.rememberMe);
|
||||||
|
if (options.maxAgeSeconds) {
|
||||||
|
maxAge = Math.min(maxAge, options.maxAgeSeconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
@@ -142,6 +153,7 @@ export async function issueSession(
|
|||||||
totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor(
|
totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor(
|
||||||
user.id,
|
user.id,
|
||||||
),
|
),
|
||||||
|
...(options.restriction ? { restriction: options.restriction } : {}),
|
||||||
...(isNativeAppRequest(req) ? { token } : {}),
|
...(isNativeAppRequest(req) ? { token } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,5 +35,9 @@ export interface PendingLogin {
|
|||||||
methodId: string;
|
methodId: string;
|
||||||
rememberMe: boolean;
|
rememberMe: boolean;
|
||||||
externalSession?: ExternalSessionRef | null;
|
externalSession?: ExternalSessionRef | null;
|
||||||
|
/** Login policy that applied when the login paused. */
|
||||||
|
allowRememberDevice?: boolean;
|
||||||
|
maxSessionSeconds?: number | null;
|
||||||
|
idleTimeoutSeconds?: number | null;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
@@ -22,12 +22,12 @@ import sshAuthRoutes from "./routes/ssh-auth-routes.js";
|
|||||||
import rbacRoutes from "./routes/rbac.js";
|
import rbacRoutes from "./routes/rbac.js";
|
||||||
import openTabsRoutes from "./routes/open-tabs.js";
|
import openTabsRoutes from "./routes/open-tabs.js";
|
||||||
import userPreferencesRoutes from "./routes/user-preferences.js";
|
import userPreferencesRoutes from "./routes/user-preferences.js";
|
||||||
|
import lookRoutes from "./routes/look.js";
|
||||||
import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
|
import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
|
||||||
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
|
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
|
||||||
import uiPreferencesRoutes from "./routes/ui-preferences.js";
|
import uiPreferencesRoutes from "./routes/ui-preferences.js";
|
||||||
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
|
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
|
||||||
import syncRoutes from "../sync/server/routes.js";
|
import { syncForwarder } from "../sync/provider.js";
|
||||||
import syncLinkRoutes from "../sync/client/routes.js";
|
|
||||||
import { syncChangeWatcher } from "../sync/server/change-watcher.js";
|
import { syncChangeWatcher } from "../sync/server/change-watcher.js";
|
||||||
import dashboardRoutes from "./routes/dashboard-routes.js";
|
import dashboardRoutes from "./routes/dashboard-routes.js";
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
mountPluginLegacyPaths,
|
mountPluginLegacyPaths,
|
||||||
} from "./routes/plugin-api-routes.js";
|
} from "./routes/plugin-api-routes.js";
|
||||||
import { attachPluginWebSockets } from "../plugins/ws.js";
|
import { attachPluginWebSockets } from "../plugins/ws.js";
|
||||||
|
import { registerSshRelay } from "../hosts/ssh-relay.js";
|
||||||
import pluginRoutes from "./routes/plugins.js";
|
import pluginRoutes from "./routes/plugins.js";
|
||||||
import { createPluginAssetsRouter } from "../plugins/assets.js";
|
import { createPluginAssetsRouter } from "../plugins/assets.js";
|
||||||
import { getPluginRuntime } from "../plugins/index.js";
|
import { getPluginRuntime } from "../plugins/index.js";
|
||||||
@@ -72,6 +73,7 @@ import {
|
|||||||
} from "../plugins/user-data.js";
|
} from "../plugins/user-data.js";
|
||||||
import { configureDirectHttps, getTlsConfig } from "../tls/tls-service.js";
|
import { configureDirectHttps, getTlsConfig } from "../tls/tls-service.js";
|
||||||
import { acmeChallengeHandler } from "../tls/acme-challenges.js";
|
import { acmeChallengeHandler } from "../tls/acme-challenges.js";
|
||||||
|
import { httpsPolicyMiddleware } from "../tls/https-policy.js";
|
||||||
import {
|
import {
|
||||||
createCurrentCredentialRepository,
|
createCurrentCredentialRepository,
|
||||||
createCurrentHostRepository,
|
createCurrentHostRepository,
|
||||||
@@ -104,6 +106,7 @@ const __dirname = path.dirname(__filename);
|
|||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.set("trust proxy", "loopback");
|
app.set("trust proxy", "loopback");
|
||||||
|
app.use(httpsPolicyMiddleware);
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
const authenticateJWT = authManager.createAuthMiddleware();
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
@@ -1616,13 +1619,13 @@ app.use("/credentials", credentialsRoutes);
|
|||||||
app.use("/ssh-auth", sshAuthRoutes);
|
app.use("/ssh-auth", sshAuthRoutes);
|
||||||
app.use("/rbac", rbacRoutes);
|
app.use("/rbac", rbacRoutes);
|
||||||
app.use("/open-tabs", openTabsRoutes);
|
app.use("/open-tabs", openTabsRoutes);
|
||||||
|
app.use("/user-preferences/look", lookRoutes);
|
||||||
app.use("/user-preferences", userPreferencesRoutes);
|
app.use("/user-preferences", userPreferencesRoutes);
|
||||||
app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
|
app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
|
||||||
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
|
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
|
||||||
app.use("/ui-preferences", uiPreferencesRoutes);
|
app.use("/ui-preferences", uiPreferencesRoutes);
|
||||||
registerAuditLogRoutes(app, authenticateJWT);
|
registerAuditLogRoutes(app, authenticateJWT);
|
||||||
app.use("/sync", syncRoutes);
|
app.use("/sync", syncForwarder);
|
||||||
app.use("/sync", syncLinkRoutes);
|
|
||||||
app.use("/dashboard", dashboardRoutes);
|
app.use("/dashboard", dashboardRoutes);
|
||||||
app.use("/plugins", pluginRoutes);
|
app.use("/plugins", pluginRoutes);
|
||||||
app.use(
|
app.use(
|
||||||
@@ -1896,6 +1899,7 @@ const httpServer = http.createServer(app);
|
|||||||
// Plugin sockets ride this server at /plugin-ws/<id>/<path>. Anything else is
|
// Plugin sockets ride this server at /plugin-ws/<id>/<path>. Anything else is
|
||||||
// left alone, so core's own upgrade handling is unaffected.
|
// left alone, so core's own upgrade handling is unaffected.
|
||||||
attachPluginWebSockets(httpServer);
|
attachPluginWebSockets(httpServer);
|
||||||
|
registerSshRelay();
|
||||||
|
|
||||||
httpServer.on("error", (err: NodeJS.ErrnoException) => {
|
httpServer.on("error", (err: NodeJS.ErrnoException) => {
|
||||||
if (err.code === "EADDRINUSE") {
|
if (err.code === "EADDRINUSE") {
|
||||||
|
|||||||
@@ -370,17 +370,6 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS recent_activity (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
host_id INTEGER NOT NULL,
|
|
||||||
host_name TEXT,
|
|
||||||
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS host_access (
|
CREATE TABLE IF NOT EXISTS host_access (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
host_id INTEGER NOT NULL,
|
host_id INTEGER NOT NULL,
|
||||||
@@ -985,6 +974,8 @@ const migrateSchema = () => {
|
|||||||
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
|
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
|
||||||
addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT");
|
addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT");
|
||||||
addColumnIfNotExists("user_preferences", "custom_keybindings", "TEXT");
|
addColumnIfNotExists("user_preferences", "custom_keybindings", "TEXT");
|
||||||
|
addColumnIfNotExists("user_preferences", "layout_id", "TEXT");
|
||||||
|
addColumnIfNotExists("user_preferences", "theme_id", "TEXT");
|
||||||
|
|
||||||
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
|
||||||
@@ -1030,6 +1021,8 @@ const migrateSchema = () => {
|
|||||||
}
|
}
|
||||||
enforceUniqueUsernames();
|
enforceUniqueUsernames();
|
||||||
addColumnIfNotExists("sessions", "external_session_ref", "TEXT");
|
addColumnIfNotExists("sessions", "external_session_ref", "TEXT");
|
||||||
|
addColumnIfNotExists("sessions", "restriction", "TEXT");
|
||||||
|
addColumnIfNotExists("sessions", "idle_timeout", "INTEGER");
|
||||||
|
|
||||||
addColumnIfNotExists("ssh_data", "name", "TEXT");
|
addColumnIfNotExists("ssh_data", "name", "TEXT");
|
||||||
addColumnIfNotExists("ssh_data", "folder", "TEXT");
|
addColumnIfNotExists("ssh_data", "folder", "TEXT");
|
||||||
|
|||||||
@@ -98,12 +98,7 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
|
|||||||
columns: "user_id",
|
columns: "user_id",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Per-user history and file manager surfaces.
|
// Per-user open tabs.
|
||||||
{
|
|
||||||
name: "idx_recent_activity_user_ts",
|
|
||||||
table: "recent_activity",
|
|
||||||
columns: "user_id, timestamp",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "idx_user_open_tabs_user_id",
|
name: "idx_user_open_tabs_user_id",
|
||||||
table: "user_open_tabs",
|
table: "user_open_tabs",
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ export const sessions = mysqlTable(
|
|||||||
lastActiveAt: text("last_active_at")
|
lastActiveAt: text("last_active_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||||
|
// Set while the session may only set up a second factor.
|
||||||
|
restriction: text("restriction"),
|
||||||
|
// Seconds without a request before the session ends, from the login policy.
|
||||||
|
idleTimeout: int("idle_timeout"),
|
||||||
},
|
},
|
||||||
// Listing a user's devices, and the startup sweep of expired rows.
|
// Listing a user's devices, and the startup sweep of expired rows.
|
||||||
(table) => [
|
(table) => [
|
||||||
@@ -414,28 +418,6 @@ export const hostDefaults = mysqlTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const recentActivity = mysqlTable(
|
|
||||||
"recent_activity",
|
|
||||||
{
|
|
||||||
id: int("id").autoincrement().primaryKey(),
|
|
||||||
userId: varchar("user_id", { length: 255 })
|
|
||||||
.notNull()
|
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
|
||||||
type: text("type").notNull(),
|
|
||||||
hostId: int("host_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
|
||||||
hostName: text("host_name"),
|
|
||||||
timestamp: varchar("timestamp", { length: 255 })
|
|
||||||
.notNull()
|
|
||||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
|
||||||
},
|
|
||||||
// Always read newest-first for one user, so timestamp follows user_id.
|
|
||||||
(table) => [
|
|
||||||
index("idx_recent_activity_user_ts").on(table.userId, table.timestamp),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
export const hostAccess = mysqlTable(
|
export const hostAccess = mysqlTable(
|
||||||
"host_access",
|
"host_access",
|
||||||
{
|
{
|
||||||
@@ -709,6 +691,8 @@ export const userPreferences = mysqlTable("user_preferences", {
|
|||||||
compactHostView: boolean("compact_host_view"),
|
compactHostView: boolean("compact_host_view"),
|
||||||
statusColorScheme: text("status_color_scheme"),
|
statusColorScheme: text("status_color_scheme"),
|
||||||
customKeybindings: text("custom_keybindings"),
|
customKeybindings: text("custom_keybindings"),
|
||||||
|
layoutId: text("layout_id"),
|
||||||
|
themeId: text("theme_id"),
|
||||||
updatedAt: text("updated_at")
|
updatedAt: text("updated_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ export const sessions = pgTable(
|
|||||||
lastActiveAt: text("last_active_at")
|
lastActiveAt: text("last_active_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
// Set while the session may only set up a second factor.
|
||||||
|
restriction: text("restriction"),
|
||||||
|
// Seconds without a request before the session ends, from the login policy.
|
||||||
|
idleTimeout: integer("idle_timeout"),
|
||||||
},
|
},
|
||||||
// Listing a user's devices, and the startup sweep of expired rows.
|
// Listing a user's devices, and the startup sweep of expired rows.
|
||||||
(table) => [
|
(table) => [
|
||||||
@@ -414,28 +418,6 @@ export const hostDefaults = pgTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const recentActivity = pgTable(
|
|
||||||
"recent_activity",
|
|
||||||
{
|
|
||||||
id: serial("id").primaryKey(),
|
|
||||||
userId: varchar("user_id", { length: 255 })
|
|
||||||
.notNull()
|
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
|
||||||
type: text("type").notNull(),
|
|
||||||
hostId: integer("host_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
|
||||||
hostName: text("host_name"),
|
|
||||||
timestamp: varchar("timestamp", { length: 255 })
|
|
||||||
.notNull()
|
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
|
||||||
},
|
|
||||||
// Always read newest-first for one user, so timestamp follows user_id.
|
|
||||||
(table) => [
|
|
||||||
index("idx_recent_activity_user_ts").on(table.userId, table.timestamp),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
export const hostAccess = pgTable(
|
export const hostAccess = pgTable(
|
||||||
"host_access",
|
"host_access",
|
||||||
{
|
{
|
||||||
@@ -709,6 +691,8 @@ export const userPreferences = pgTable("user_preferences", {
|
|||||||
compactHostView: boolean("compact_host_view"),
|
compactHostView: boolean("compact_host_view"),
|
||||||
statusColorScheme: text("status_color_scheme"),
|
statusColorScheme: text("status_color_scheme"),
|
||||||
customKeybindings: text("custom_keybindings"),
|
customKeybindings: text("custom_keybindings"),
|
||||||
|
layoutId: text("layout_id"),
|
||||||
|
themeId: text("theme_id"),
|
||||||
updatedAt: text("updated_at")
|
updatedAt: text("updated_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ export const sessions = sqliteTable(
|
|||||||
lastActiveAt: text("last_active_at")
|
lastActiveAt: text("last_active_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
// Set while the session may only set up a second factor.
|
||||||
|
restriction: text("restriction", { length: 32 }),
|
||||||
|
// Seconds without a request before the session ends, from the login policy.
|
||||||
|
idleTimeout: integer("idle_timeout"),
|
||||||
},
|
},
|
||||||
// Listing a user's devices, and the startup sweep of expired rows.
|
// Listing a user's devices, and the startup sweep of expired rows.
|
||||||
(table) => [
|
(table) => [
|
||||||
@@ -404,28 +408,6 @@ export const hostDefaults = sqliteTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const recentActivity = sqliteTable(
|
|
||||||
"recent_activity",
|
|
||||||
{
|
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
||||||
userId: text("user_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
|
||||||
type: text("type").notNull(),
|
|
||||||
hostId: integer("host_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
|
||||||
hostName: text("host_name"),
|
|
||||||
timestamp: text("timestamp")
|
|
||||||
.notNull()
|
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
|
||||||
},
|
|
||||||
// Always read newest-first for one user, so timestamp follows user_id.
|
|
||||||
(table) => [
|
|
||||||
index("idx_recent_activity_user_ts").on(table.userId, table.timestamp),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
export const hostAccess = sqliteTable(
|
export const hostAccess = sqliteTable(
|
||||||
"host_access",
|
"host_access",
|
||||||
{
|
{
|
||||||
@@ -703,6 +685,8 @@ export const userPreferences = sqliteTable("user_preferences", {
|
|||||||
compactHostView: integer("compact_host_view", { mode: "boolean" }),
|
compactHostView: integer("compact_host_view", { mode: "boolean" }),
|
||||||
statusColorScheme: text("status_color_scheme"),
|
statusColorScheme: text("status_color_scheme"),
|
||||||
customKeybindings: text("custom_keybindings"),
|
customKeybindings: text("custom_keybindings"),
|
||||||
|
layoutId: text("layout_id"),
|
||||||
|
themeId: text("theme_id"),
|
||||||
updatedAt: text("updated_at")
|
updatedAt: text("updated_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repos
|
|||||||
import { UserAuthRepository } from "./user-auth-repository.js";
|
import { UserAuthRepository } from "./user-auth-repository.js";
|
||||||
import { RbacAccessRepository } from "./rbac-access-repository.js";
|
import { RbacAccessRepository } from "./rbac-access-repository.js";
|
||||||
import { RbacPermissionRepository } from "./rbac-permission-repository.js";
|
import { RbacPermissionRepository } from "./rbac-permission-repository.js";
|
||||||
import { RecentActivityRepository } from "./recent-activity-repository.js";
|
|
||||||
import { RoleRepository } from "./role-repository.js";
|
import { RoleRepository } from "./role-repository.js";
|
||||||
import { SessionRepository } from "./session-repository.js";
|
import { SessionRepository } from "./session-repository.js";
|
||||||
import { CredentialAccessRepository } from "./credential-access-repository.js";
|
import { CredentialAccessRepository } from "./credential-access-repository.js";
|
||||||
@@ -269,13 +268,6 @@ export function createCurrentRbacAccessRepository(): RbacAccessRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCurrentRecentActivityRepository(): RecentActivityRepository {
|
|
||||||
return new RecentActivityRepository(
|
|
||||||
createCurrentRepositoryContext(),
|
|
||||||
createCurrentRepositoryWriteHook("recent_activity_repository_write"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCurrentRoleRepository(): RoleRepository {
|
export function createCurrentRoleRepository(): RoleRepository {
|
||||||
return new RoleRepository(
|
return new RoleRepository(
|
||||||
createCurrentRepositoryContext(),
|
createCurrentRepositoryContext(),
|
||||||
|
|||||||
@@ -236,6 +236,29 @@ export class HostFolderRepository {
|
|||||||
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
|
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Drops the folder rows (and subfolders) only when no host is left in them. */
|
||||||
|
async deleteEmptyFolderRecords(
|
||||||
|
userId: string,
|
||||||
|
folderName: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if ((await this.listHostsInFolder(userId, folderName)).length > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await deleteReturning(
|
||||||
|
this.context,
|
||||||
|
sshFolders,
|
||||||
|
and(
|
||||||
|
eq(sshFolders.userId, userId),
|
||||||
|
or(
|
||||||
|
eq(sshFolders.name, folderName),
|
||||||
|
like(sshFolders.name, `${folderName} / %`),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await this.afterWrite();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async deleteHostsAndFolderRecords(
|
async deleteHostsAndFolderRecords(
|
||||||
userId: string,
|
userId: string,
|
||||||
folderName: string,
|
folderName: string,
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
import { desc, eq, inArray } from "drizzle-orm";
|
|
||||||
import { recentActivity } from "../db/schema.js";
|
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
|
||||||
import { rowsAffected } from "./mutation-result.js";
|
|
||||||
import { insertReturning } from "./returning.js";
|
|
||||||
|
|
||||||
export type RecentActivityRecord = typeof recentActivity.$inferSelect;
|
|
||||||
export type NewRecentActivityRecord = typeof recentActivity.$inferInsert;
|
|
||||||
|
|
||||||
export class RecentActivityRepository {
|
|
||||||
constructor(
|
|
||||||
private readonly context: DatabaseContext,
|
|
||||||
private readonly onWrite?: () => void | Promise<void>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async listByUserId(
|
|
||||||
userId: string,
|
|
||||||
limit: number,
|
|
||||||
): Promise<RecentActivityRecord[]> {
|
|
||||||
return this.context.drizzle
|
|
||||||
.select()
|
|
||||||
.from(recentActivity)
|
|
||||||
.where(eq(recentActivity.userId, userId))
|
|
||||||
.orderBy(desc(recentActivity.timestamp))
|
|
||||||
.limit(limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(
|
|
||||||
activity: NewRecentActivityRecord,
|
|
||||||
): Promise<RecentActivityRecord> {
|
|
||||||
const rows = await insertReturning(this.context, recentActivity, activity);
|
|
||||||
|
|
||||||
await this.afterWrite();
|
|
||||||
return rows[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
async trimUserActivity(userId: string, keepCount: number): Promise<number> {
|
|
||||||
const rows = await this.context.drizzle
|
|
||||||
.select({ id: recentActivity.id })
|
|
||||||
.from(recentActivity)
|
|
||||||
.where(eq(recentActivity.userId, userId))
|
|
||||||
.orderBy(desc(recentActivity.timestamp));
|
|
||||||
|
|
||||||
const idsToDelete = rows
|
|
||||||
.slice(keepCount)
|
|
||||||
.map((row) => row.id)
|
|
||||||
.filter((id) => typeof id === "number");
|
|
||||||
|
|
||||||
if (idsToDelete.length === 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.context.drizzle
|
|
||||||
.delete(recentActivity)
|
|
||||||
.where(inArray(recentActivity.id, idsToDelete));
|
|
||||||
|
|
||||||
if (rowsAffected(result) > 0) {
|
|
||||||
await this.afterWrite();
|
|
||||||
}
|
|
||||||
|
|
||||||
return rowsAffected(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
|
||||||
const result = await this.context.drizzle
|
|
||||||
.delete(recentActivity)
|
|
||||||
.where(eq(recentActivity.userId, userId));
|
|
||||||
|
|
||||||
if (rowsAffected(result) > 0) {
|
|
||||||
await this.afterWrite();
|
|
||||||
}
|
|
||||||
|
|
||||||
return rowsAffected(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteByHostId(hostId: number): Promise<number> {
|
|
||||||
const result = await this.context.drizzle
|
|
||||||
.delete(recentActivity)
|
|
||||||
.where(eq(recentActivity.hostId, hostId));
|
|
||||||
|
|
||||||
if (rowsAffected(result) > 0) {
|
|
||||||
await this.afterWrite();
|
|
||||||
}
|
|
||||||
|
|
||||||
return rowsAffected(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteByHostIds(hostIds: number[]): Promise<number> {
|
|
||||||
if (hostIds.length === 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.context.drizzle
|
|
||||||
.delete(recentActivity)
|
|
||||||
.where(inArray(recentActivity.hostId, hostIds));
|
|
||||||
|
|
||||||
if (rowsAffected(result) > 0) {
|
|
||||||
await this.afterWrite();
|
|
||||||
}
|
|
||||||
|
|
||||||
return rowsAffected(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async afterWrite(): Promise<void> {
|
|
||||||
await this.onWrite?.();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -69,6 +69,14 @@ export class SessionRepository {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async clearRestrictionForUser(userId: string): Promise<void> {
|
||||||
|
await this.context.drizzle
|
||||||
|
.update(sessions)
|
||||||
|
.set({ restriction: null })
|
||||||
|
.where(eq(sessions.userId, userId));
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
async updateToken(
|
async updateToken(
|
||||||
id: string,
|
id: string,
|
||||||
jwtToken: string,
|
jwtToken: string,
|
||||||
|
|||||||
@@ -5,12 +5,6 @@ import {
|
|||||||
createCurrentUserRepository,
|
createCurrentUserRepository,
|
||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
import { apiLogger } from "../../utils/logger.js";
|
import { apiLogger } from "../../utils/logger.js";
|
||||||
import { exportFilename, toCsv, toNdjson } from "../../utils/audit-export.js";
|
|
||||||
import {
|
|
||||||
logAudit,
|
|
||||||
getAuditUsername,
|
|
||||||
getRequestMeta,
|
|
||||||
} from "../../utils/audit-logger.js";
|
|
||||||
|
|
||||||
async function isAdminUser(userId: string | undefined): Promise<boolean> {
|
async function isAdminUser(userId: string | undefined): Promise<boolean> {
|
||||||
if (!userId) return false;
|
if (!userId) return false;
|
||||||
@@ -138,125 +132,4 @@ export function registerAuditLogRoutes(
|
|||||||
.json({ error: "Failed to fetch audit log actions" });
|
.json({ error: "Failed to fetch audit log actions" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /audit-logs/export:
|
|
||||||
* get:
|
|
||||||
* summary: Export audit logs
|
|
||||||
* description: Streams the full filtered result set as CSV or NDJSON. Accepts the same filters as GET /audit-logs. Admin only. The export is itself audited.
|
|
||||||
* tags:
|
|
||||||
* - Audit
|
|
||||||
* parameters:
|
|
||||||
* - in: query
|
|
||||||
* name: format
|
|
||||||
* schema: { type: string, enum: [csv, ndjson], default: csv }
|
|
||||||
* - in: query
|
|
||||||
* name: userId
|
|
||||||
* schema: { type: string }
|
|
||||||
* - in: query
|
|
||||||
* name: action
|
|
||||||
* schema: { type: string }
|
|
||||||
* - in: query
|
|
||||||
* name: resourceType
|
|
||||||
* schema: { type: string }
|
|
||||||
* - in: query
|
|
||||||
* name: success
|
|
||||||
* schema: { type: string, enum: [true, false] }
|
|
||||||
* - in: query
|
|
||||||
* name: startDate
|
|
||||||
* schema: { type: string, format: date-time }
|
|
||||||
* - in: query
|
|
||||||
* name: endDate
|
|
||||||
* schema: { type: string, format: date-time }
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: Audit log file.
|
|
||||||
* 403:
|
|
||||||
* description: Not authorized.
|
|
||||||
* 500:
|
|
||||||
* description: Failed to export audit logs.
|
|
||||||
*/
|
|
||||||
router.get("/audit-logs/export", authenticateJWT, async (req, res) => {
|
|
||||||
const authReq = req as AuthenticatedRequest;
|
|
||||||
try {
|
|
||||||
if (!(await isAdminUser(authReq.userId))) {
|
|
||||||
return res.status(403).json({ error: "Not authorized" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const format = req.query.format === "ndjson" ? "ndjson" : "csv";
|
|
||||||
const { userId, action, resourceType, success, startDate, endDate } =
|
|
||||||
req.query as Record<string, string | undefined>;
|
|
||||||
const filters = {
|
|
||||||
userId,
|
|
||||||
action,
|
|
||||||
resourceType,
|
|
||||||
success:
|
|
||||||
success !== undefined && success !== ""
|
|
||||||
? success === "true"
|
|
||||||
: undefined,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
};
|
|
||||||
|
|
||||||
res.setHeader(
|
|
||||||
"Content-Type",
|
|
||||||
format === "csv" ? "text/csv; charset=utf-8" : "application/x-ndjson",
|
|
||||||
);
|
|
||||||
res.setHeader(
|
|
||||||
"Content-Disposition",
|
|
||||||
`attachment; filename="${exportFilename(format, new Date())}"`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Streamed in batches: an export is unbounded by definition, and the
|
|
||||||
// whole point is to move data out before retention drops it.
|
|
||||||
const BATCH = 500;
|
|
||||||
let offset = 0;
|
|
||||||
let exported = 0;
|
|
||||||
|
|
||||||
for (;;) {
|
|
||||||
const rows = await createCurrentAuditLogRepository().listForExport({
|
|
||||||
filters,
|
|
||||||
limit: BATCH,
|
|
||||||
offset,
|
|
||||||
});
|
|
||||||
if (rows.length === 0) break;
|
|
||||||
|
|
||||||
if (format === "csv") {
|
|
||||||
// Header only on the first batch.
|
|
||||||
const chunk = toCsv(rows);
|
|
||||||
res.write(
|
|
||||||
offset === 0 ? chunk : chunk.slice(chunk.indexOf("\n") + 1),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
res.write(toNdjson(rows));
|
|
||||||
}
|
|
||||||
|
|
||||||
exported += rows.length;
|
|
||||||
offset += rows.length;
|
|
||||||
if (rows.length < BATCH) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.end();
|
|
||||||
|
|
||||||
// Reading the whole trail is itself worth recording.
|
|
||||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
|
||||||
void logAudit({
|
|
||||||
userId: authReq.userId!,
|
|
||||||
username: await getAuditUsername(authReq.userId!),
|
|
||||||
action: "export_audit_logs",
|
|
||||||
resourceType: "audit_log",
|
|
||||||
details: JSON.stringify({ format, exported, filters }),
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
success: true,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
apiLogger.error("Failed to export audit logs", err);
|
|
||||||
if (!res.headersSent) {
|
|
||||||
return res.status(500).json({ error: "Failed to export audit logs" });
|
|
||||||
}
|
|
||||||
res.end();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
} from "../../auth/registry.js";
|
} from "../../auth/registry.js";
|
||||||
import { isTrustedProxyAuthEnabled } from "../../utils/trusted-proxy-auth.js";
|
import { isTrustedProxyAuthEnabled } from "../../utils/trusted-proxy-auth.js";
|
||||||
import { LoginMethodError, type VerifiedIdentity } from "../../auth/types.js";
|
import { LoginMethodError, type VerifiedIdentity } from "../../auth/types.js";
|
||||||
|
import { rememberDeviceAllowed } from "../../auth/policy.js";
|
||||||
|
|
||||||
export interface PublicLoginMethod {
|
export interface PublicLoginMethod {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -243,7 +244,7 @@ export function registerAuthRoutes(router: Router): void {
|
|||||||
* /users/auth/methods:
|
* /users/auth/methods:
|
||||||
* get:
|
* get:
|
||||||
* summary: List login methods
|
* summary: List login methods
|
||||||
* description: Public. The login methods with at least one enabled instance, for the login screen. Carries ids and labels only.
|
* description: Public. The login methods with at least one enabled instance, for the login screen. Carries ids and labels only, plus rememberDevice, false when the login policy turned remember me off.
|
||||||
* tags:
|
* tags:
|
||||||
* - Auth
|
* - Auth
|
||||||
* responses:
|
* responses:
|
||||||
@@ -252,7 +253,10 @@ export function registerAuthRoutes(router: Router): void {
|
|||||||
*/
|
*/
|
||||||
router.get("/auth/methods", async (_req, res) => {
|
router.get("/auth/methods", async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
res.json({ methods: await listPublicLoginMethods() });
|
res.json({
|
||||||
|
methods: await listPublicLoginMethods(),
|
||||||
|
rememberDevice: rememberDeviceAllowed(),
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
authLogger.error("Failed to list login methods", error);
|
authLogger.error("Failed to list login methods", error);
|
||||||
res.status(500).json({ error: "Failed to list login methods" });
|
res.status(500).json({ error: "Failed to list login methods" });
|
||||||
|
|||||||
Loaded 100 of 601 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user