mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: plugin docs and env manifest fields, in-app docs links, SDK 1.0.3
This commit is contained in:
1 parent
7fcda3bb01
commit
cfab6f764c
65 files changed
+1665
-294
No files matched your search
@@ -591,6 +591,18 @@ jobs:
|
|||||||
gh release delete "$TAG" --repo ${{ github.repository }} --yes || true
|
gh release delete "$TAG" --repo ${{ github.repository }} --yes || true
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# The docs site reads the env catalog, API spec and download list from the
|
||||||
|
# newest stable release.
|
||||||
|
docs-refresh:
|
||||||
|
needs: [prep, electron-release]
|
||||||
|
if: ${{ always() && needs.prep.outputs.channel == 'Stable' && needs.electron-release.result == 'success' && needs.prep.outputs.mode != 'Dry run' }}
|
||||||
|
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||||
|
steps:
|
||||||
|
- name: Tell the docs site
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
|
||||||
|
run: gh api repos/Termix-SSH/Docs/dispatches -f event_type=core-released
|
||||||
|
|
||||||
cleanup:
|
cleanup:
|
||||||
needs: [prep, merge-to-main, electron-release, cask-commit-back]
|
needs: [prep, merge-to-main, electron-release, cask-commit-back]
|
||||||
if: ${{ always() && needs.prep.outputs.channel == 'Stable' && (needs.prep.outputs.mode == 'Everything' || needs.prep.outputs.mode == 'Skip submit') && needs.merge-to-main.result == 'success' }}
|
if: ${{ always() && needs.prep.outputs.channel == 'Stable' && (needs.prep.outputs.mode == 'Everything' || needs.prep.outputs.mode == 'Skip submit') && needs.merge-to-main.result == 'success' }}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Temp
|
- Temp
|
||||||
|
- Docs links on every settings page, in the Plugins tab, in plugin settings and in the command palette
|
||||||
|
- Plugins can link their own docs and list the environment variables they read, shown on their page in the Plugins tab
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
termix:
|
termix:
|
||||||
image: ghcr.io/lukegus/termix:latest
|
image: ghcr.io/termix-ssh/termix:latest
|
||||||
container_name: termix
|
container_name: termix
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -0,0 +1,460 @@
|
|||||||
|
{
|
||||||
|
"groups": {
|
||||||
|
"server": "Server",
|
||||||
|
"https": "HTTPS",
|
||||||
|
"database": "Database",
|
||||||
|
"secrets": "Secrets",
|
||||||
|
"auth": "Sign in",
|
||||||
|
"proxy": "Proxies and origins",
|
||||||
|
"logging": "Logs and audit",
|
||||||
|
"plugins": "Plugins",
|
||||||
|
"desktop": "Desktop app",
|
||||||
|
"internal": "Internal"
|
||||||
|
},
|
||||||
|
"vars": [
|
||||||
|
{
|
||||||
|
"name": "PORT",
|
||||||
|
"group": "server",
|
||||||
|
"default": "8080",
|
||||||
|
"description": "Port the web UI listens on in the Docker image."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DATA_DIR",
|
||||||
|
"group": "server",
|
||||||
|
"default": "/app/data",
|
||||||
|
"description": "Where Termix keeps its database, keys, plugins and uploads. Mount a volume here. Outside Docker the default is ./db/data."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ALLOW_EMPTY_DATA_DIR",
|
||||||
|
"group": "server",
|
||||||
|
"default": "false",
|
||||||
|
"description": "Start even when DATA_DIR is empty but an older data folder exists elsewhere. Only set this if you really want a fresh install."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PUID",
|
||||||
|
"group": "server",
|
||||||
|
"default": "1000",
|
||||||
|
"docker": true,
|
||||||
|
"description": "User id the container runs as. Match it to the owner of your data volume."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PGID",
|
||||||
|
"group": "server",
|
||||||
|
"default": "1000",
|
||||||
|
"docker": true,
|
||||||
|
"description": "Group id the container runs as."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "BASE_PATH",
|
||||||
|
"group": "server",
|
||||||
|
"description": "Serve Termix under a sub path, like /termix. Set the same path in your reverse proxy."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NODE_ENV",
|
||||||
|
"group": "server",
|
||||||
|
"default": "production",
|
||||||
|
"description": "Set by the image. Leave it alone."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "VERSION",
|
||||||
|
"group": "server",
|
||||||
|
"description": "Set by the image to the Termix version. Leave it alone."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SETTINGS_CACHE_REFRESH_SECONDS",
|
||||||
|
"group": "server",
|
||||||
|
"default": "30",
|
||||||
|
"description": "How often each server instance reloads admin settings from the database. 0 turns the refresh off."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SSH_AUTH_SOCK",
|
||||||
|
"group": "server",
|
||||||
|
"description": "SSH agent socket used for hosts set to agent auth when they do not name their own socket."
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "ENABLE_SSL",
|
||||||
|
"group": "https",
|
||||||
|
"default": "false",
|
||||||
|
"description": "Serve HTTPS. Termix makes a self-signed certificate on first boot unless you give it one."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SSL_PORT",
|
||||||
|
"group": "https",
|
||||||
|
"default": "8443",
|
||||||
|
"description": "Port for HTTPS."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SSL_CERT_PATH",
|
||||||
|
"group": "https",
|
||||||
|
"default": "/app/data/ssl/termix.crt",
|
||||||
|
"description": "Certificate file for HTTPS."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SSL_KEY_PATH",
|
||||||
|
"group": "https",
|
||||||
|
"default": "/app/data/ssl/termix.key",
|
||||||
|
"description": "Private key file for HTTPS."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SSL_DOMAIN",
|
||||||
|
"group": "https",
|
||||||
|
"default": "localhost",
|
||||||
|
"description": "Domain put in the self-signed certificate."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TERMIX_SSL_TERMINATED_BY_NGINX",
|
||||||
|
"group": "https",
|
||||||
|
"internal": true,
|
||||||
|
"description": "Set by the image when nginx serves HTTPS in front of the backend."
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "DATABASE_DIALECT",
|
||||||
|
"group": "database",
|
||||||
|
"default": "sqlite",
|
||||||
|
"description": "sqlite, postgres or mysql."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DATABASE_URL",
|
||||||
|
"group": "database",
|
||||||
|
"description": "Connection URL for postgres or mysql, like postgres://user:pass@db:5432/termix."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DATABASE_POOL_MAX",
|
||||||
|
"group": "database",
|
||||||
|
"default": "10",
|
||||||
|
"description": "Most connections Termix opens to postgres or mysql."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DATABASE_SSL",
|
||||||
|
"group": "database",
|
||||||
|
"description": "TLS for postgres or mysql: require, no-verify or disable."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DB_FILE_ENCRYPTION",
|
||||||
|
"group": "database",
|
||||||
|
"default": "true",
|
||||||
|
"description": "Encrypt the SQLite file at rest. Set false only if something else already encrypts the disk."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DB_FILE_KEY",
|
||||||
|
"group": "database",
|
||||||
|
"secret": true,
|
||||||
|
"description": "Only used to open SQLite files encrypted by very old versions. You almost never need it."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP",
|
||||||
|
"group": "database",
|
||||||
|
"default": "false",
|
||||||
|
"description": "Skip the backup Termix takes of the data folder before an upgrade changes the database."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DATABASE_LAYER_PREUPGRADE_BACKUP_KEEP",
|
||||||
|
"group": "database",
|
||||||
|
"default": "3",
|
||||||
|
"description": "How many of those upgrade backups to keep."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DRIZZLE_MIGRATIONS_DIR",
|
||||||
|
"group": "database",
|
||||||
|
"internal": true,
|
||||||
|
"description": "Where database migrations are read from. Set by the image."
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "JWT_SECRET",
|
||||||
|
"group": "secrets",
|
||||||
|
"secret": true,
|
||||||
|
"description": "Signs sign-in sessions. At least 64 characters. Made for you and saved in DATA_DIR/.env if not set. Also read from JWT_SECRET_FILE."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DATABASE_KEY",
|
||||||
|
"group": "secrets",
|
||||||
|
"secret": true,
|
||||||
|
"description": "Key for the encrypted SQLite file, 64 hex characters. Made for you if not set. Also read from DATABASE_KEY_FILE."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ENCRYPTION_KEY",
|
||||||
|
"group": "secrets",
|
||||||
|
"secret": true,
|
||||||
|
"description": "Wraps the data keys that encrypt passwords and SSH keys, 64 hex characters. Made for you if not set. Also read from ENCRYPTION_KEY_FILE."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "INTERNAL_AUTH_TOKEN",
|
||||||
|
"group": "secrets",
|
||||||
|
"secret": true,
|
||||||
|
"description": "Token the server uses to call itself. Made for you if not set. Also read from INTERNAL_AUTH_TOKEN_FILE."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TERMIX_REQUIRE_EXTERNAL_SECRETS",
|
||||||
|
"group": "secrets",
|
||||||
|
"default": "false",
|
||||||
|
"description": "Refuse to start unless the four secrets above come from env vars or _FILE files, so none are written to disk."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "OIDC_SYSTEM_SECRET",
|
||||||
|
"group": "secrets",
|
||||||
|
"secret": true,
|
||||||
|
"description": "Only read to move data from installs older than 2.5. Never change it on an install that has it."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "WEBAUTHN_SYSTEM_SECRET",
|
||||||
|
"group": "secrets",
|
||||||
|
"secret": true,
|
||||||
|
"description": "Only read to move data from installs older than 2.5. Never change it on an install that has it."
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "ALLOW_PASSWORD_LOGIN",
|
||||||
|
"group": "auth",
|
||||||
|
"description": "true or false. Overrides the admin setting for signing in with a username and password."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ALLOW_REGISTRATION",
|
||||||
|
"group": "auth",
|
||||||
|
"description": "true or false. Overrides the admin setting for creating new accounts."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ALLOW_PASSWORD_RESET",
|
||||||
|
"group": "auth",
|
||||||
|
"description": "true or false. Overrides the admin setting for resetting a password from the sign in page."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "EXTERNAL_ALLOW_REGISTRATION",
|
||||||
|
"group": "auth",
|
||||||
|
"description": "true or false. Overrides the admin setting for creating accounts the first time someone signs in with SSO or LDAP."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "EXTERNAL_FORCE_HTTPS",
|
||||||
|
"group": "auth",
|
||||||
|
"default": "false",
|
||||||
|
"description": "Build sign in callback URLs with https even when the request came in over http. Use it behind a proxy that does not send X-Forwarded-Proto."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "OIDC_ALLOW_REGISTRATION",
|
||||||
|
"group": "auth",
|
||||||
|
"deprecated": "EXTERNAL_ALLOW_REGISTRATION",
|
||||||
|
"description": "Old name for EXTERNAL_ALLOW_REGISTRATION. Still read in 26.10."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "OIDC_FORCE_HTTPS",
|
||||||
|
"group": "auth",
|
||||||
|
"deprecated": "EXTERNAL_FORCE_HTTPS",
|
||||||
|
"description": "Old name for EXTERNAL_FORCE_HTTPS. Still read in 26.10."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "OIDC_CLIENT_ID",
|
||||||
|
"group": "auth",
|
||||||
|
"internal": true,
|
||||||
|
"description": "Read by core only to move a 2.8 OIDC setup into the sso plugin. See the sso plugin for the real variables."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "OIDC_ENV_OVERRIDE",
|
||||||
|
"group": "auth",
|
||||||
|
"internal": true,
|
||||||
|
"description": "Read by core only to move a 2.8 OIDC setup into the sso plugin. See the sso plugin for the real variables."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TRUSTED_PROXY_AUTH_ENABLED",
|
||||||
|
"group": "auth",
|
||||||
|
"default": "false",
|
||||||
|
"description": "Let a proxy like Authelia or Authentik sign people in by sending their username in a header."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TRUSTED_PROXY_AUTH_TRUSTED_PROXIES",
|
||||||
|
"group": "auth",
|
||||||
|
"description": "Comma separated IPs or CIDRs of the proxies allowed to send the headers. Required when trusted proxy login is on."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TRUSTED_PROXY_AUTH_USERNAME_HEADER",
|
||||||
|
"group": "auth",
|
||||||
|
"default": "x-forwarded-username",
|
||||||
|
"description": "Header that holds the username."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TRUSTED_PROXY_AUTH_ROLE_HEADER",
|
||||||
|
"group": "auth",
|
||||||
|
"default": "x-forwarded-role",
|
||||||
|
"description": "Header that holds the user's groups or roles."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TRUSTED_PROXY_AUTH_ROLE_MAP",
|
||||||
|
"group": "auth",
|
||||||
|
"description": "JSON map from proxy roles to Termix roles, like {\"admins\":[\"admin\"],\"staff\":[\"user\"]}. Required when trusted proxy login is on."
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "TRUSTED_PROXIES",
|
||||||
|
"group": "proxy",
|
||||||
|
"docker": true,
|
||||||
|
"description": "Comma separated IPs or CIDRs nginx takes the client IP from (X-Forwarded-For). Set it to your reverse proxy."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CORS_ALLOWED_ORIGINS",
|
||||||
|
"group": "proxy",
|
||||||
|
"description": "Comma separated extra origins allowed to call the API from a browser."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TERMIX_ALLOWED_ORIGINS",
|
||||||
|
"group": "proxy",
|
||||||
|
"description": "Comma separated extra origins allowed to open WebSockets."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "HTTP_PROXY",
|
||||||
|
"group": "proxy",
|
||||||
|
"description": "Proxy for outbound HTTP requests, like registry downloads and webhooks."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "HTTPS_PROXY",
|
||||||
|
"group": "proxy",
|
||||||
|
"description": "Proxy for outbound HTTPS requests."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NO_PROXY",
|
||||||
|
"group": "proxy",
|
||||||
|
"description": "Comma separated hosts that skip the proxy."
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "LOG_LEVEL",
|
||||||
|
"group": "logging",
|
||||||
|
"default": "info",
|
||||||
|
"description": "debug, info, warn or error."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "LOG_TIMESTAMP_FORMAT",
|
||||||
|
"group": "logging",
|
||||||
|
"description": "iso or 24h. Unset uses the local time format."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AUDIT_LOG_RETENTION_DAYS",
|
||||||
|
"group": "logging",
|
||||||
|
"description": "Delete audit entries older than this many days. Unset keeps them until the entry cap."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AUDIT_LOG_MAX_ENTRIES",
|
||||||
|
"group": "logging",
|
||||||
|
"default": "10000",
|
||||||
|
"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",
|
||||||
|
"group": "logging",
|
||||||
|
"deprecated": "the session-recording plugin setting",
|
||||||
|
"description": "Only read once to carry a 2.8 value into the session-recording plugin."
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "TERMIX_PLUGIN_REGISTRY_URL",
|
||||||
|
"group": "plugins",
|
||||||
|
"description": "Use another plugin index instead of the official registry."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TERMIX_PLUGIN_STATS_URL",
|
||||||
|
"group": "plugins",
|
||||||
|
"description": "Where install counts are read from. Defaults to stats.json next to the registry index."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TERMIX_REQUIRE_SIGNED_PLUGINS",
|
||||||
|
"group": "plugins",
|
||||||
|
"default": "false",
|
||||||
|
"description": "Only load plugins signed by a trusted key. Blocks uploads of unsigned files even in developer mode."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TERMIX_BUNDLED_PLUGINS_DIR",
|
||||||
|
"group": "plugins",
|
||||||
|
"description": "Where the plugins shipped with Termix are read from. Set by the image."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TERMIX_PLUGIN_PUBLIC_RATE_LIMIT",
|
||||||
|
"group": "plugins",
|
||||||
|
"default": "120",
|
||||||
|
"description": "Requests per minute one IP may make to a plugin's public routes."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PLUGIN_MAX_KV_KEYS",
|
||||||
|
"group": "plugins",
|
||||||
|
"default": "10000",
|
||||||
|
"description": "Most key value entries one plugin may store."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TERMIX_DEV_RELOAD",
|
||||||
|
"group": "plugins",
|
||||||
|
"internal": true,
|
||||||
|
"description": "Set by npm run dev so plugin rebuilds reload without a restart."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TERMIX_DEV_RUNNER",
|
||||||
|
"group": "plugins",
|
||||||
|
"internal": true,
|
||||||
|
"description": "Set by npm run dev."
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "ENABLE_INSECURE_MODE",
|
||||||
|
"group": "desktop",
|
||||||
|
"default": "false",
|
||||||
|
"description": "Desktop app only. Skip TLS certificate checks for the linked server. Only for testing."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ELECTRON_DISABLE_GPU",
|
||||||
|
"group": "desktop",
|
||||||
|
"description": "Desktop app only. Set to 1 to turn off GPU acceleration if the window is blank or flickers."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TERMIX_LOCAL_SHELL",
|
||||||
|
"group": "desktop",
|
||||||
|
"description": "Desktop app only. Shell the local terminal opens, like /bin/zsh or pwsh.exe."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SHELL",
|
||||||
|
"group": "desktop",
|
||||||
|
"internal": true,
|
||||||
|
"description": "Your login shell, used by the local terminal when TERMIX_LOCAL_SHELL is not set."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ELECTRON_EMBEDDED",
|
||||||
|
"group": "desktop",
|
||||||
|
"internal": true,
|
||||||
|
"description": "Set by the desktop app when it runs its own backend."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TERMIX_DATA_DIR",
|
||||||
|
"group": "desktop",
|
||||||
|
"internal": true,
|
||||||
|
"description": "Set by the desktop app to its data folder."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "XDG_CURRENT_DESKTOP",
|
||||||
|
"group": "desktop",
|
||||||
|
"internal": true,
|
||||||
|
"description": "Read on Linux to pick the password store and window behavior."
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "DESKTOP_SESSION",
|
||||||
|
"group": "desktop",
|
||||||
|
"internal": true,
|
||||||
|
"description": "Read on Linux to pick the password store."
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "VITE_BASE_PATH",
|
||||||
|
"group": "internal",
|
||||||
|
"internal": true,
|
||||||
|
"description": "Base path baked in at build time."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Generated
+2
-35
@@ -142,7 +142,6 @@
|
|||||||
"version": "14.0.1",
|
"version": "14.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz",
|
||||||
"integrity": "sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==",
|
"integrity": "sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/json-schema": "^7.0.15",
|
"@types/json-schema": "^7.0.15",
|
||||||
@@ -159,7 +158,6 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz",
|
||||||
"integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==",
|
"integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
@@ -169,14 +167,12 @@
|
|||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz",
|
||||||
"integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==",
|
"integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@apidevtools/swagger-parser": {
|
"node_modules/@apidevtools/swagger-parser": {
|
||||||
"version": "12.1.0",
|
"version": "12.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-12.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-12.1.0.tgz",
|
||||||
"integrity": "sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==",
|
"integrity": "sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apidevtools/json-schema-ref-parser": "14.0.1",
|
"@apidevtools/json-schema-ref-parser": "14.0.1",
|
||||||
@@ -1093,7 +1089,6 @@
|
|||||||
"version": "8.2.0",
|
"version": "8.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@deadendjs/swagger-jsdoc/-/swagger-jsdoc-8.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@deadendjs/swagger-jsdoc/-/swagger-jsdoc-8.2.0.tgz",
|
||||||
"integrity": "sha512-KwySDId/J0C/dlMGd77H3IN3nL7/HYVTerFp6DBhRnIY7RsbGGz30DBIyAhzhLDdqStW4wQHMP+/3qX8BQ1wTg==",
|
"integrity": "sha512-KwySDId/J0C/dlMGd77H3IN3nL7/HYVTerFp6DBhRnIY7RsbGGz30DBIyAhzhLDdqStW4wQHMP+/3qX8BQ1wTg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apidevtools/swagger-parser": "^12.1.0",
|
"@apidevtools/swagger-parser": "^12.1.0",
|
||||||
@@ -2702,7 +2697,6 @@
|
|||||||
"version": "9.0.0",
|
"version": "9.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
|
||||||
"integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==",
|
"integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==",
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -6236,7 +6230,6 @@
|
|||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/jsonwebtoken": {
|
"node_modules/@types/jsonwebtoken": {
|
||||||
@@ -7001,7 +6994,6 @@
|
|||||||
"version": "8.20.0",
|
"version": "8.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
@@ -7018,7 +7010,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz",
|
||||||
"integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==",
|
"integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"ajv": "^8.5.0"
|
"ajv": "^8.5.0"
|
||||||
@@ -7218,7 +7209,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/argue-cli": {
|
"node_modules/argue-cli": {
|
||||||
@@ -7391,7 +7381,6 @@
|
|||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18 || 20 || >=22"
|
"node": "18 || 20 || >=22"
|
||||||
@@ -7506,7 +7495,6 @@
|
|||||||
"version": "5.0.12",
|
"version": "5.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^4.0.2"
|
"balanced-match": "^4.0.2"
|
||||||
@@ -7748,7 +7736,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
|
||||||
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==",
|
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/callsites": {
|
"node_modules/callsites": {
|
||||||
@@ -7974,7 +7961,6 @@
|
|||||||
"version": "14.0.3",
|
"version": "14.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
|
||||||
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
|
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
@@ -8272,7 +8258,6 @@
|
|||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"path-key": "^3.1.0",
|
"path-key": "^3.1.0",
|
||||||
@@ -9578,7 +9563,6 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-json-stable-stringify": {
|
"node_modules/fast-json-stable-stringify": {
|
||||||
@@ -9606,7 +9590,6 @@
|
|||||||
"version": "3.1.8",
|
"version": "3.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -9775,7 +9758,6 @@
|
|||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||||
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "^7.0.6",
|
"cross-spawn": "^7.0.6",
|
||||||
@@ -10038,7 +10020,6 @@
|
|||||||
"version": "12.0.0",
|
"version": "12.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-12.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-12.0.0.tgz",
|
||||||
"integrity": "sha512-5Qcll1z7IKgHr5g485ePDdHcNQY0k2dtv/bjYy0iuyGxQw2qSOiiXUXJ+AYQpg3HNoUMHqAruX478Jeev7UULw==",
|
"integrity": "sha512-5Qcll1z7IKgHr5g485ePDdHcNQY0k2dtv/bjYy0iuyGxQw2qSOiiXUXJ+AYQpg3HNoUMHqAruX478Jeev7UULw==",
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"foreground-child": "^3.3.1",
|
"foreground-child": "^3.3.1",
|
||||||
@@ -10584,7 +10565,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/istanbul-lib-coverage": {
|
"node_modules/istanbul-lib-coverage": {
|
||||||
@@ -10643,7 +10623,6 @@
|
|||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz",
|
||||||
"integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==",
|
"integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==",
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@isaacs/cliui": "^9.0.0"
|
"@isaacs/cliui": "^9.0.0"
|
||||||
@@ -10700,7 +10679,6 @@
|
|||||||
"version": "4.3.2",
|
"version": "4.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||||
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
|
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -10799,7 +10777,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/json-stable-stringify-without-jsonify": {
|
"node_modules/json-stable-stringify-without-jsonify": {
|
||||||
@@ -11490,7 +11467,6 @@
|
|||||||
"version": "10.2.6",
|
"version": "10.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||||
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
|
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^5.0.8"
|
"brace-expansion": "^5.0.8"
|
||||||
@@ -12004,7 +11980,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0"
|
"license": "BlueOak-1.0.0"
|
||||||
},
|
},
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
@@ -12075,7 +12050,6 @@
|
|||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -12085,7 +12059,6 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
|
||||||
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
|
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lru-cache": "^11.0.0",
|
"lru-cache": "^11.0.0",
|
||||||
@@ -12102,7 +12075,6 @@
|
|||||||
"version": "11.5.3",
|
"version": "11.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
|
||||||
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
|
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "20 || >=22"
|
"node": "20 || >=22"
|
||||||
@@ -12905,7 +12877,6 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -13236,7 +13207,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"shebang-regex": "^3.0.0"
|
"shebang-regex": "^3.0.0"
|
||||||
@@ -13249,7 +13219,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -13338,7 +13307,6 @@
|
|||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
@@ -14805,7 +14773,6 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"isexe": "^2.0.0"
|
"isexe": "^2.0.0"
|
||||||
@@ -14977,7 +14944,6 @@
|
|||||||
"version": "2.0.0-4",
|
"version": "2.0.0-4",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-4.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-4.tgz",
|
||||||
"integrity": "sha512-MoQoNhTFI400tkaeod+X0Vety1KD2L9dUa6pa1CVcyfcATjC/iDxoMLvqZ6U3D8c5KzxBrU2HnJH+PfaXOqI7w==",
|
"integrity": "sha512-MoQoNhTFI400tkaeod+X0Vety1KD2L9dUa6pa1CVcyfcATjC/iDxoMLvqZ6U3D8c5KzxBrU2HnJH+PfaXOqI7w==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
@@ -15049,9 +15015,10 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin-sdk": {
|
"packages/plugin-sdk": {
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"name": "@termix-ssh/plugin-sdk",
|
||||||
"version": "1.0.2",
|
"version": "1.1.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||||
"@tailwindcss/node": "^4.2.4",
|
"@tailwindcss/node": "^4.2.4",
|
||||||
"@tailwindcss/oxide": "^4.2.4",
|
"@tailwindcss/oxide": "^4.2.4",
|
||||||
"esbuild": "^0.28.1",
|
"esbuild": "^0.28.1",
|
||||||
|
|||||||
+2
-2
@@ -18,7 +18,7 @@
|
|||||||
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs",
|
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs",
|
||||||
"prepare": "husky || true",
|
"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/generate-settings-search-index.cjs --check && node scripts/check-test-locations.cjs && node scripts/check-changelog.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/generate-settings-search-index.cjs --check && 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",
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"dev:frontend": "vite",
|
"dev:frontend": "vite",
|
||||||
"build": "npm run build:sdk && vite build && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
|
"build": "npm run build:sdk && vite build && 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": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
|
||||||
"generate:openapi": "tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node ./dist/backend/backend/utils/swagger.js",
|
"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",
|
||||||
"build:win-installer": "npm run build && npm run electron:rebuild && electron-builder --win --publish=never",
|
"build:win-installer": "npm run build && npm run electron:rebuild && electron-builder --win --publish=never",
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.0.3
|
||||||
|
|
||||||
|
Plugin API 1.2.0.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `docs` manifest field: an https link to the plugin's docs. Termix shows it as a Documentation link on the plugin's page and in its settings
|
||||||
|
- `env` manifest field: the environment variables the plugin reads, listed in its docs and on its page
|
||||||
|
- `app.docs` and `useDocsUrl()` for links into the plugin's own docs, and `DocsLink` in `@termix-ssh/plugin-sdk/ui`
|
||||||
|
- `PanelShell` takes a `docs` link and shows it as a book icon in the header
|
||||||
|
- `@termix-ssh/plugin-sdk/openapi` with `buildOpenApi()`, and `termix-plugin openapi`, which writes `dist/openapi.json` from the `@openapi` blocks in `src/backend`. `build` runs it too
|
||||||
|
- `@termix-ssh/plugin-sdk/docs` with `pluginDocsUrl()`, `pluginDocsPage()` and `pluginEnvVars()`
|
||||||
|
- `termix-plugin validate` warns when `docs/index.md` is missing or the source reads an env var the manifest does not list
|
||||||
|
|
||||||
## 1.0.2
|
## 1.0.2
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
`@termix-ssh/plugin-sdk` is what every [Termix](https://github.com/Termix-SSH/Termix) plugin builds against, including the ones that ship with Termix. It has the types for a plugin's backend and frontend, table definitions, the manifest schema, test helpers, and the `termix-plugin` CLI. The easiest way to start is the [Termix Plugin Template](https://github.com/Termix-SSH/Termix-Plugin-Template).
|
`@termix-ssh/plugin-sdk` is what every [Termix](https://github.com/Termix-SSH/Termix) plugin builds against, including the ones that ship with Termix. It has the types for a plugin's backend and frontend, table definitions, the manifest schema, test helpers, and the `termix-plugin` CLI. The easiest way to start is the [Termix Plugin Template](https://github.com/Termix-SSH/Termix-Plugin-Template). The full guide is at [docs.termix.site/develop](https://docs.termix.site/develop).
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
@@ -30,6 +30,7 @@ npm install --save-dev @termix-ssh/plugin-sdk
|
|||||||
npx termix-plugin build # bundle dist/backend.js and dist/frontend.js
|
npx termix-plugin build # bundle dist/backend.js and dist/frontend.js
|
||||||
npx termix-plugin test # run the plugin's Vitest suite
|
npx termix-plugin test # run the plugin's Vitest suite
|
||||||
npx termix-plugin validate # check manifest.json and the files it names
|
npx termix-plugin validate # check manifest.json and the files it names
|
||||||
|
npx termix-plugin openapi # write dist/openapi.json from @openapi comments
|
||||||
npx termix-plugin migrations # write migrations from the table definitions
|
npx termix-plugin migrations # write migrations from the table definitions
|
||||||
npx termix-plugin pack # write <id>-<version>.tmxplug
|
npx termix-plugin pack # write <id>-<version>.tmxplug
|
||||||
npx termix-plugin changelog # print this version's notes from CHANGELOG.md
|
npx termix-plugin changelog # print this version's notes from CHANGELOG.md
|
||||||
@@ -39,7 +40,7 @@ npx termix-plugin keygen --out <dir> # make a new signing key pair
|
|||||||
npx termix-plugin dev --server <url> --key <tmx_...> # install on a server and reinstall on every change
|
npx termix-plugin dev --server <url> --key <tmx_...> # install on a server and reinstall on every change
|
||||||
```
|
```
|
||||||
|
|
||||||
`dev` needs developer mode on (Admin settings, Plugins) and an API key from an
|
`dev` needs developer mode on (Settings, General) and an API key from an
|
||||||
admin account. It installs the plugin from a file, marked unverified, and
|
admin account. It installs the plugin from a file, marked unverified, and
|
||||||
reinstalls it each time you save. `TERMIX_SERVER_URL` and `TERMIX_API_KEY` work
|
reinstalls it each time you save. `TERMIX_SERVER_URL` and `TERMIX_API_KEY` work
|
||||||
in place of the flags. Reload the page to pick up frontend changes.
|
in place of the flags. Reload the page to pick up frontend changes.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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 } from "../lib/plugin-dir.mjs";
|
||||||
|
import { openapi } from "./openapi.mjs";
|
||||||
|
|
||||||
const BACKEND_ENTRIES = [
|
const BACKEND_ENTRIES = [
|
||||||
"src/backend/index.ts",
|
"src/backend/index.ts",
|
||||||
@@ -241,6 +242,7 @@ export async function build({ cwd }) {
|
|||||||
|
|
||||||
copyDir(path.join(cwd, "locales"), path.join(outDir, "locales"));
|
copyDir(path.join(cwd, "locales"), path.join(outDir, "locales"));
|
||||||
copyDir(path.join(cwd, "migrations"), path.join(outDir, "migrations"));
|
copyDir(path.join(cwd, "migrations"), path.join(outDir, "migrations"));
|
||||||
|
await openapi({ cwd, quiet: true });
|
||||||
|
|
||||||
console.log(`built ${pluginId}`);
|
console.log(`built ${pluginId}`);
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,7 @@ export function resolveDevOptions(args, env = process.env) {
|
|||||||
|
|
||||||
const HINTS = {
|
const HINTS = {
|
||||||
DEVELOPER_MODE_OFF:
|
DEVELOPER_MODE_OFF:
|
||||||
"Turn on developer mode in Admin settings, under Plugins, then save again.",
|
"Turn on Plugin developer mode in Settings, General, then save again.",
|
||||||
SIGNED_ONLY:
|
SIGNED_ONLY:
|
||||||
"The server sets TERMIX_REQUIRE_SIGNED_PLUGINS, so it never installs unsigned files.",
|
"The server sets TERMIX_REQUIRE_SIGNED_PLUGINS, so it never installs unsigned files.",
|
||||||
BUNDLED_ID: "That id ships with Termix. Use a different id while developing.",
|
BUNDLED_ID: "That id ships with Termix. Use a different id while developing.",
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { readManifest } from "../lib/plugin-dir.mjs";
|
||||||
|
import { loadSdkModule } from "./validate.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes dist/openapi.json from the @openapi blocks in src/backend. A plugin
|
||||||
|
* with no documented routes gets no file.
|
||||||
|
*/
|
||||||
|
export async function openapi({ cwd, quiet = false }) {
|
||||||
|
const manifest = readManifest(cwd);
|
||||||
|
const pluginId = manifest.id ?? path.basename(cwd);
|
||||||
|
const backendDir = path.join(cwd, "src", "backend");
|
||||||
|
if (!fs.existsSync(backendDir)) return null;
|
||||||
|
|
||||||
|
const { buildOpenApi, hasOpenApiPaths } = await loadSdkModule("openapi");
|
||||||
|
const spec = await buildOpenApi({
|
||||||
|
title: `${manifest.name ?? pluginId} API`,
|
||||||
|
version: manifest.version ?? "0.0.0",
|
||||||
|
description: manifest.description,
|
||||||
|
files: [path.join(backendDir, "**", "*.{ts,tsx,js,mjs}")],
|
||||||
|
});
|
||||||
|
|
||||||
|
const target = path.join(cwd, "dist", "openapi.json");
|
||||||
|
if (!hasOpenApiPaths(spec)) {
|
||||||
|
fs.rmSync(target, { force: true });
|
||||||
|
if (!quiet) console.log(`${pluginId}: no documented routes`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||||
|
fs.writeFileSync(target, `${JSON.stringify(spec, null, 2)}\n`);
|
||||||
|
if (!quiet) {
|
||||||
|
console.log(
|
||||||
|
`wrote dist/openapi.json (${Object.keys(spec.paths).length} paths)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
@@ -41,6 +41,10 @@ export async function validate({ cwd }) {
|
|||||||
problems.push(...validatePackage(cwd, raw));
|
problems.push(...validatePackage(cwd, raw));
|
||||||
problems.push(...(await validateChangelogFile(cwd, raw)));
|
problems.push(...(await validateChangelogFile(cwd, raw)));
|
||||||
|
|
||||||
|
for (const warning of docsWarnings(cwd, raw)) {
|
||||||
|
console.warn(` warn ${warning}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (problems.length > 0) {
|
if (problems.length > 0) {
|
||||||
for (const problem of problems) console.error(` ${problem}`);
|
for (const problem of problems) console.error(` ${problem}`);
|
||||||
throw new Error(`${raw.id ?? path.basename(cwd)}: manifest is not valid.`);
|
throw new Error(`${raw.id ?? path.basename(cwd)}: manifest is not valid.`);
|
||||||
@@ -49,6 +53,69 @@ export async function validate({ cwd }) {
|
|||||||
console.log(`ok ${raw.id ?? path.basename(cwd)}`);
|
console.log(`ok ${raw.id ?? path.basename(cwd)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Env vars core sets for everyone, so a plugin need not list them. */
|
||||||
|
const CORE_ENV = new Set([
|
||||||
|
"DATA_DIR",
|
||||||
|
"NODE_ENV",
|
||||||
|
"PORT",
|
||||||
|
"BASE_PATH",
|
||||||
|
"VERSION",
|
||||||
|
"HOME",
|
||||||
|
"TMPDIR",
|
||||||
|
"TEMP",
|
||||||
|
"TMP",
|
||||||
|
"PATH",
|
||||||
|
"APPDATA",
|
||||||
|
"LOCALAPPDATA",
|
||||||
|
"USERPROFILE",
|
||||||
|
"ELECTRON_EMBEDDED",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ENV_READS = [
|
||||||
|
/process\.env\.([A-Z][A-Z0-9_]*)/g,
|
||||||
|
/process\.env\[\s*["'`]([A-Z][A-Z0-9_]*)["'`]\s*\]/g,
|
||||||
|
/\benv\.([A-Z][A-Z0-9_]*)\b/g,
|
||||||
|
/\benv\[\s*["'`]([A-Z][A-Z0-9_]*)["'`]\s*\]/g,
|
||||||
|
];
|
||||||
|
|
||||||
|
function walkSources(dir, out = []) {
|
||||||
|
if (!fs.existsSync(dir)) return out;
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) walkSources(full, out);
|
||||||
|
else if (/\.(ts|tsx|js|mjs|cjs)$/.test(entry.name)) out.push(full);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Docs gaps that do not block a release: no docs/index.md, or a process.env
|
||||||
|
* read the manifest does not list in env.
|
||||||
|
*/
|
||||||
|
export function docsWarnings(cwd, raw) {
|
||||||
|
const warnings = [];
|
||||||
|
if (!fs.existsSync(path.join(cwd, "docs", "index.md"))) {
|
||||||
|
warnings.push("docs/index.md is missing, so the plugin has no docs page");
|
||||||
|
}
|
||||||
|
const declared = new Set(
|
||||||
|
Array.isArray(raw.env) ? raw.env.map((e) => e?.name) : [],
|
||||||
|
);
|
||||||
|
const missing = new Set();
|
||||||
|
for (const file of walkSources(path.join(cwd, "src"))) {
|
||||||
|
const text = fs.readFileSync(file, "utf8");
|
||||||
|
for (const pattern of ENV_READS) {
|
||||||
|
for (const match of text.matchAll(pattern)) {
|
||||||
|
const name = match[1];
|
||||||
|
if (!declared.has(name) && !CORE_ENV.has(name)) missing.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const name of [...missing].sort()) {
|
||||||
|
warnings.push(`reads ${name} but manifest env does not list it`);
|
||||||
|
}
|
||||||
|
return warnings;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* package.json has to agree with the manifest: the same version, and a real
|
* package.json has to agree with the manifest: the same version, and a real
|
||||||
* SDK range rather than "*", so a plugin built against a newer SDK says so.
|
* SDK range rather than "*", so a plugin built against a newer SDK says so.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { sign, verify, keygen } from "./commands/sign.mjs";
|
|||||||
import { migrations } from "./commands/migrations.mjs";
|
import { migrations } from "./commands/migrations.mjs";
|
||||||
import { dev } from "./commands/dev.mjs";
|
import { dev } from "./commands/dev.mjs";
|
||||||
import { changelog } from "./commands/changelog.mjs";
|
import { changelog } from "./commands/changelog.mjs";
|
||||||
|
import { openapi } from "./commands/openapi.mjs";
|
||||||
|
|
||||||
const COMMANDS = {
|
const COMMANDS = {
|
||||||
build,
|
build,
|
||||||
@@ -32,6 +33,7 @@ const COMMANDS = {
|
|||||||
migrations,
|
migrations,
|
||||||
dev,
|
dev,
|
||||||
changelog,
|
changelog,
|
||||||
|
openapi,
|
||||||
};
|
};
|
||||||
|
|
||||||
const [command, ...args] = process.argv.slice(2);
|
const [command, ...args] = process.argv.slice(2);
|
||||||
@@ -50,6 +52,7 @@ if (!command || command === "--help" || command === "-h") {
|
|||||||
" verify Check a .tmxplug's .sig <file> --key <base64>[,...]",
|
" verify Check a .tmxplug's .sig <file> --key <base64>[,...]",
|
||||||
" keygen Write a new signing key pair [--out dir]",
|
" keygen Write a new signing key pair [--out dir]",
|
||||||
" migrations Generate migrations from the plugin's table definitions",
|
" migrations Generate migrations from the plugin's table definitions",
|
||||||
|
" openapi Write dist/openapi.json from the @openapi blocks in src/backend",
|
||||||
" changelog Print a version's notes from CHANGELOG.md [--version x.y.z]",
|
" changelog Print a version's notes from CHANGELOG.md [--version x.y.z]",
|
||||||
" dev Build, pack and install on a running server, again on every change",
|
" dev Build, pack and install on a running server, again on every change",
|
||||||
" [--server url] [--key tmx_...] [--once]",
|
" [--server url] [--key tmx_...] [--once]",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"name": "@termix-ssh/plugin-sdk",
|
||||||
"version": "1.0.2",
|
"version": "1.0.3",
|
||||||
"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": {
|
||||||
@@ -53,6 +53,14 @@
|
|||||||
"types": "./dist/changelog.d.ts",
|
"types": "./dist/changelog.d.ts",
|
||||||
"default": "./dist/changelog.js"
|
"default": "./dist/changelog.js"
|
||||||
},
|
},
|
||||||
|
"./docs": {
|
||||||
|
"types": "./dist/docs.d.ts",
|
||||||
|
"default": "./dist/docs.js"
|
||||||
|
},
|
||||||
|
"./openapi": {
|
||||||
|
"types": "./dist/openapi.d.ts",
|
||||||
|
"default": "./dist/openapi.js"
|
||||||
|
},
|
||||||
"./settings": {
|
"./settings": {
|
||||||
"types": "./dist/settings.d.ts",
|
"types": "./dist/settings.d.ts",
|
||||||
"default": "./dist/settings.js"
|
"default": "./dist/settings.js"
|
||||||
@@ -102,6 +110,7 @@
|
|||||||
"prepublishOnly": "npm run build"
|
"prepublishOnly": "npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||||
"@tailwindcss/node": "^4.2.4",
|
"@tailwindcss/node": "^4.2.4",
|
||||||
"@tailwindcss/oxide": "^4.2.4",
|
"@tailwindcss/oxide": "^4.2.4",
|
||||||
"esbuild": "^0.28.1",
|
"esbuild": "^0.28.1",
|
||||||
|
|||||||
@@ -85,6 +85,27 @@
|
|||||||
"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": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^https://",
|
||||||
|
"description": "Where the plugin's docs live. Shown as a Documentation link in Termix."
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Environment variables the plugin reads, listed in its docs.",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["name", "description"],
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,63}$" },
|
||||||
|
"description": { "type": "string", "minLength": 1, "maxLength": 300 },
|
||||||
|
"default": { "type": "string" },
|
||||||
|
"required": { "type": "boolean" },
|
||||||
|
"secret": { "type": "boolean" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"engine": {
|
"engine": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Docs links and env var entries from a manifest. Kept apart from manifest.ts
|
||||||
|
* so the browser can use them without pulling in semver.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A docs link, or null for anything that is not an https URL. Core only ever
|
||||||
|
* opens it in a new tab, but a javascript: or file: link should never get there.
|
||||||
|
*/
|
||||||
|
export function pluginDocsUrl(value: unknown): string | null {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
try {
|
||||||
|
const url = new URL(value.trim());
|
||||||
|
if (url.protocol !== "https:") return null;
|
||||||
|
return url.toString().replace(/\/$/, "");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Joins a page and anchor onto a plugin's docs URL. */
|
||||||
|
export function pluginDocsPage(
|
||||||
|
base: string,
|
||||||
|
page?: string,
|
||||||
|
anchor?: string,
|
||||||
|
): string {
|
||||||
|
const clean = (page ?? "").replace(/^\/+|\/+$/g, "");
|
||||||
|
const url = clean ? `${base}/${clean}` : base;
|
||||||
|
return anchor ? `${url}#${anchor.replace(/^#/, "")}` : url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ENV_NAME_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/;
|
||||||
|
export const MAX_ENV_DESCRIPTION_LENGTH = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An environment variable a plugin reads. Only shown in docs and on the
|
||||||
|
* plugin's page, so people know what they can set.
|
||||||
|
*/
|
||||||
|
export interface PluginEnvVar {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
/** The value used when it is not set, as written in a compose file. */
|
||||||
|
default?: string;
|
||||||
|
required?: boolean;
|
||||||
|
/** Holds a password or key. */
|
||||||
|
secret?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The usable env entries of a manifest. Bad ones are dropped. */
|
||||||
|
export function pluginEnvVars(value: unknown): PluginEnvVar[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.filter(
|
||||||
|
(v): v is PluginEnvVar =>
|
||||||
|
typeof v === "object" &&
|
||||||
|
v !== null &&
|
||||||
|
typeof v.name === "string" &&
|
||||||
|
ENV_NAME_PATTERN.test(v.name) &&
|
||||||
|
typeof v.description === "string",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -888,6 +888,16 @@ export interface DesktopApi {
|
|||||||
onRemoteServerChange: (listener: () => void) => Disposer;
|
onRemoteServerChange: (listener: () => void) => Disposer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Links into this plugin's docs, from the manifest's docs field. */
|
||||||
|
export interface DocsApi {
|
||||||
|
/** The docs link, or null when the manifest has none. */
|
||||||
|
readonly url: string | null;
|
||||||
|
/** A page of the docs ("setup"), with an optional anchor. */
|
||||||
|
page: (page?: string, anchor?: string) => string | null;
|
||||||
|
/** Opens a page in a new tab. Does nothing without a docs link. */
|
||||||
|
open: (page?: string, anchor?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TermixAppInfo {
|
export interface TermixAppInfo {
|
||||||
readonly pluginId: string;
|
readonly pluginId: string;
|
||||||
readonly manifest: PluginManifest;
|
readonly manifest: PluginManifest;
|
||||||
@@ -1017,6 +1027,8 @@ export interface TermixApp extends TermixAppInfo {
|
|||||||
confirm: (options: ConfirmRequest) => Promise<boolean>;
|
confirm: (options: ConfirmRequest) => Promise<boolean>;
|
||||||
/** The desktop app, when the frontend runs in it. */
|
/** The desktop app, when the frontend runs in it. */
|
||||||
desktop: DesktopApi;
|
desktop: DesktopApi;
|
||||||
|
/** This plugin's docs. Needs plugin API 1.2. */
|
||||||
|
docs: DocsApi;
|
||||||
/**
|
/**
|
||||||
* Fires after this plugin's settings are saved from the settings screen or
|
* Fires after this plugin's settings are saved from the settings screen or
|
||||||
* the host editor. Disposed automatically.
|
* the host editor. Disposed automatically.
|
||||||
@@ -1091,6 +1103,11 @@ export interface SshAuthTypeInfo {
|
|||||||
*/
|
*/
|
||||||
export interface PluginHostBridge {
|
export interface PluginHostBridge {
|
||||||
usePluginId: () => string;
|
usePluginId: () => string;
|
||||||
|
useDocsUrl: (
|
||||||
|
pluginId: string,
|
||||||
|
page?: string,
|
||||||
|
anchor?: string,
|
||||||
|
) => string | null;
|
||||||
useTranslation: (pluginId: string) => {
|
useTranslation: (pluginId: string) => {
|
||||||
t: TranslateFn;
|
t: TranslateFn;
|
||||||
language: string;
|
language: string;
|
||||||
@@ -1290,6 +1307,15 @@ export function usePluginId(): string {
|
|||||||
return requireHost().usePluginId();
|
return requireHost().usePluginId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A page of this plugin's docs, or null when the manifest has no docs link.
|
||||||
|
* Pass it to PanelShell's docs prop or use DocsLink. Needs plugin API 1.2.
|
||||||
|
*/
|
||||||
|
export function useDocsUrl(page?: string, anchor?: string): string | null {
|
||||||
|
const bridge = requireHost();
|
||||||
|
return bridge.useDocsUrl(bridge.usePluginId(), page, anchor);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Translation bound to this plugin's namespace. Keys the plugin does not
|
* Translation bound to this plugin's namespace. Keys the plugin does not
|
||||||
* define fall back to core's shared strings (common.*, hosts.*).
|
* define fall back to core's shared strings (common.*, hosts.*).
|
||||||
|
|||||||
@@ -12,6 +12,21 @@
|
|||||||
|
|
||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
import { isKnownCapability } from "./capabilities.js";
|
import { isKnownCapability } from "./capabilities.js";
|
||||||
|
import {
|
||||||
|
ENV_NAME_PATTERN,
|
||||||
|
MAX_ENV_DESCRIPTION_LENGTH,
|
||||||
|
pluginDocsUrl,
|
||||||
|
type PluginEnvVar,
|
||||||
|
} from "./docs.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
ENV_NAME_PATTERN,
|
||||||
|
MAX_ENV_DESCRIPTION_LENGTH,
|
||||||
|
pluginDocsPage,
|
||||||
|
pluginDocsUrl,
|
||||||
|
pluginEnvVars,
|
||||||
|
type PluginEnvVar,
|
||||||
|
} from "./docs.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The plugin API this build implements. A minor bump adds to the API and
|
* The plugin API this build implements. A minor bump adds to the API and
|
||||||
@@ -21,7 +36,7 @@ import { isKnownCapability } from "./capabilities.js";
|
|||||||
* "^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.1.0";
|
export const PLUGIN_API_VERSION = "1.2.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(
|
||||||
@@ -601,6 +616,10 @@ export interface PluginManifest {
|
|||||||
video?: string;
|
video?: string;
|
||||||
/** Short lines listing what the plugin does, shown on its page. */
|
/** Short lines listing what the plugin does, shown on its page. */
|
||||||
features?: string[];
|
features?: string[];
|
||||||
|
/** Where the plugin's docs live. https only. */
|
||||||
|
docs?: string;
|
||||||
|
/** Environment variables the plugin reads. */
|
||||||
|
env?: PluginEnvVar[];
|
||||||
engine: PluginEngine;
|
engine: PluginEngine;
|
||||||
/** Catalog capability ids. */
|
/** Catalog capability ids. */
|
||||||
capabilities: string[];
|
capabilities: string[];
|
||||||
@@ -654,6 +673,8 @@ const ALLOWED_TOP_LEVEL = new Set([
|
|||||||
"icon",
|
"icon",
|
||||||
"video",
|
"video",
|
||||||
"features",
|
"features",
|
||||||
|
"docs",
|
||||||
|
"env",
|
||||||
"dependencies",
|
"dependencies",
|
||||||
"optionalDependencies",
|
"optionalDependencies",
|
||||||
"provides",
|
"provides",
|
||||||
@@ -816,6 +837,12 @@ export function validateManifest(manifest: unknown): string[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
validateFeatures(m.features, errors);
|
validateFeatures(m.features, errors);
|
||||||
|
if ("docs" in m && pluginDocsUrl(m.docs) === null) {
|
||||||
|
errors.push(
|
||||||
|
`Field "docs" must be an https URL, got: ${JSON.stringify(m.docs)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
validateEnv(m.env, errors);
|
||||||
for (const field of ["backend", "frontend", "locales"] as const) {
|
for (const field of ["backend", "frontend", "locales"] as const) {
|
||||||
if (field in m && requireString(m[field], field, errors)) {
|
if (field in m && requireString(m[field], field, errors)) {
|
||||||
requireRelativePath(m[field] as string, field, errors);
|
requireRelativePath(m[field] as string, field, errors);
|
||||||
@@ -873,6 +900,54 @@ function validateFeatures(features: unknown, errors: string[]): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateEnv(env: unknown, errors: string[]): void {
|
||||||
|
if (env === undefined) return;
|
||||||
|
if (!Array.isArray(env)) {
|
||||||
|
errors.push('Field "env" must be an array');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const seen = new Set<string>();
|
||||||
|
env.forEach((entry, i) => {
|
||||||
|
const where = `env[${i}]`;
|
||||||
|
if (!isPlainObject(entry)) {
|
||||||
|
errors.push(`Field "${where}" must be an object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rejectUnknown(
|
||||||
|
entry,
|
||||||
|
["name", "description", "default", "required", "secret"],
|
||||||
|
where,
|
||||||
|
errors,
|
||||||
|
);
|
||||||
|
if (typeof entry.name !== "string" || !ENV_NAME_PATTERN.test(entry.name)) {
|
||||||
|
errors.push(
|
||||||
|
`Field "${where}.name" must be an uppercase name like MY_PLUGIN_URL, got: ${JSON.stringify(entry.name)}`,
|
||||||
|
);
|
||||||
|
} else if (seen.has(entry.name)) {
|
||||||
|
errors.push(`Field "${where}.name" repeats ${entry.name}`);
|
||||||
|
} else {
|
||||||
|
seen.add(entry.name);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof entry.description !== "string" ||
|
||||||
|
entry.description.trim().length === 0 ||
|
||||||
|
entry.description.length > MAX_ENV_DESCRIPTION_LENGTH
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
`Field "${where}.description" must be a non-empty string of at most ${MAX_ENV_DESCRIPTION_LENGTH} characters`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ("default" in entry && typeof entry.default !== "string") {
|
||||||
|
errors.push(`Field "${where}.default" must be a string`);
|
||||||
|
}
|
||||||
|
for (const flag of ["required", "secret"] as const) {
|
||||||
|
if (flag in entry && typeof entry[flag] !== "boolean") {
|
||||||
|
errors.push(`Field "${where}.${flag}" must be true or false`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function validateAuthor(author: unknown, errors: string[]): void {
|
function validateAuthor(author: unknown, errors: string[]): void {
|
||||||
if (author === undefined) return;
|
if (author === undefined) return;
|
||||||
if (!isPlainObject(author)) {
|
if (!isPlainObject(author)) {
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Builds an OpenAPI spec from the @openapi JSDoc blocks above routes. Core and
|
||||||
|
* every plugin use this, so the docs site renders them all the same way.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import swaggerJSDoc from "@deadendjs/swagger-jsdoc";
|
||||||
|
|
||||||
|
export interface OpenApiTag {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildOpenApiOptions {
|
||||||
|
title: string;
|
||||||
|
version: string;
|
||||||
|
description?: string;
|
||||||
|
/** Source files or globs holding @openapi blocks. */
|
||||||
|
files: string[];
|
||||||
|
tags?: OpenApiTag[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OpenApiSpec = Record<string, unknown> & {
|
||||||
|
paths?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** How every Termix route is signed in to. */
|
||||||
|
export const TERMIX_SECURITY_SCHEMES = {
|
||||||
|
bearerAuth: {
|
||||||
|
type: "http",
|
||||||
|
scheme: "bearer",
|
||||||
|
bearerFormat: "JWT",
|
||||||
|
description:
|
||||||
|
"Session token sent in the Authorization header as Bearer and the token.",
|
||||||
|
},
|
||||||
|
cookieAuth: {
|
||||||
|
type: "apiKey",
|
||||||
|
in: "cookie",
|
||||||
|
name: "jwt",
|
||||||
|
description:
|
||||||
|
"Session JWT cookie set at sign-in. When present, it takes precedence over the Authorization header.",
|
||||||
|
},
|
||||||
|
apiKeyAuth: {
|
||||||
|
type: "http",
|
||||||
|
scheme: "bearer",
|
||||||
|
bearerFormat: "API key starting with tmx_",
|
||||||
|
description:
|
||||||
|
"API key sent in the Authorization header as Bearer and the key, which starts with tmx_. It acts as the key owner and cannot act as anyone else.",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export async function buildOpenApi(
|
||||||
|
options: BuildOpenApiOptions,
|
||||||
|
): Promise<OpenApiSpec> {
|
||||||
|
const spec = (await swaggerJSDoc({
|
||||||
|
definition: {
|
||||||
|
openapi: "3.0.3",
|
||||||
|
info: {
|
||||||
|
title: options.title,
|
||||||
|
version: options.version,
|
||||||
|
...(options.description ? { description: options.description } : {}),
|
||||||
|
},
|
||||||
|
servers: [{ url: "/", description: "Your Termix server" }],
|
||||||
|
components: {
|
||||||
|
securitySchemes: TERMIX_SECURITY_SCHEMES,
|
||||||
|
schemas: {
|
||||||
|
Error: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
error: { type: "string" },
|
||||||
|
details: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
security: [{ bearerAuth: [] }, { cookieAuth: [] }, { apiKeyAuth: [] }],
|
||||||
|
...(options.tags?.length ? { tags: options.tags } : {}),
|
||||||
|
},
|
||||||
|
apis: options.files.map((f) => f.replace(/\\/g, "/")),
|
||||||
|
failOnErrors: true,
|
||||||
|
} as Parameters<typeof swaggerJSDoc>[0])) as OpenApiSpec;
|
||||||
|
return spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a spec documents any route. */
|
||||||
|
export function hasOpenApiPaths(spec: OpenApiSpec): boolean {
|
||||||
|
return Object.keys(spec.paths ?? {}).length > 0;
|
||||||
|
}
|
||||||
@@ -85,6 +85,7 @@ const offline = () =>
|
|||||||
Promise.reject(new Error("No plugin API in a unit test; mock it"));
|
Promise.reject(new Error("No plugin API in a unit test; mock it"));
|
||||||
(globalThis as Record<string, unknown>).__termixTestPluginHost = {
|
(globalThis as Record<string, unknown>).__termixTestPluginHost = {
|
||||||
usePluginId: () => "test-plugin",
|
usePluginId: () => "test-plugin",
|
||||||
|
useDocsUrl: () => null,
|
||||||
useTranslation: () => ({
|
useTranslation: () => ({
|
||||||
t: (key: string) => key,
|
t: (key: string) => key,
|
||||||
language: "en",
|
language: "en",
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ DialogOverlay (value): ({ className, ...props }: DialogOverlayProps & RefAttribu
|
|||||||
DialogPortal (value): ({ ...props }: DialogPortalProps) => Element
|
DialogPortal (value): ({ ...props }: DialogPortalProps) => Element
|
||||||
DialogTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
|
DialogTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
|
||||||
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
|
||||||
|
DocsLinkProps (type): DocsLinkProps
|
||||||
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
|
||||||
@@ -128,7 +130,7 @@ PanePrompt (value): ({ open, title, titleId, description, icon, tone, actions, o
|
|||||||
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
|
||||||
PanelSearch (value): ({ value, onChange, placeholder, fill, autoFocus, disabled, title, onEnter, inputRef, className, }: { value: string; onChange: (next: string) => void; placeholder?: string; fill?: boolean; autoFocus?: boolean; disabled?: boolean; title?: string; onEnter?: () => void; inputRef?: Ref<HTMLInputElement>; className?: string; }) => Element
|
PanelSearch (value): ({ value, onChange, placeholder, fill, autoFocus, disabled, title, onEnter, inputRef, className, }: { value: string; onChange: (next: string) => void; placeholder?: string; fill?: boolean; autoFocus?: boolean; disabled?: boolean; title?: string; onEnter?: () => void; inputRef?: Ref<HTMLInputElement>; className?: string; }) => Element
|
||||||
PanelShell (value): ({ chrome, icon, title, status, leading, actions, toolbar, tabs, footer, scroll, className, children, }: { chrome?: boolean; icon?: ReactNode; title?: ReactNode; status?: ReactNode; leading?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; tabs?: ReactNode; footer?: ReactNode; scroll?: boolean; className?: string; children: ReactNode; }) => Element
|
PanelShell (value): ({ chrome, icon, title, status, leading, actions, toolbar, tabs, footer, scroll, docs, className, children, }: { chrome?: boolean; icon?: ReactNode; title?: ReactNode; status?: ReactNode; leading?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; tabs?: ReactNode; footer?: ReactNode; scroll?: boolean; docs?: string; className?: string; children: ReactNode; }) => Element
|
||||||
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>>
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Fails when core reads an env var that env.catalog.json does not list. The
|
||||||
|
// docs site builds its env var page from that file.
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, "..");
|
||||||
|
const DIRS = ["src/backend", "electron"];
|
||||||
|
const PATTERNS = [
|
||||||
|
/process\.env\.([A-Z][A-Z0-9_]*)/g,
|
||||||
|
/process\.env\[\s*["'`]([A-Z][A-Z0-9_]*)["'`]\s*\]/g,
|
||||||
|
/\benv\.([A-Z][A-Z0-9_]*)\b/g,
|
||||||
|
/\benv\[\s*["'`]([A-Z][A-Z0-9_]*)["'`]\s*\]/g,
|
||||||
|
/_ENV\s*=\s*["']([A-Z][A-Z0-9_]*)["']/g,
|
||||||
|
];
|
||||||
|
|
||||||
|
function walk(dir, out = []) {
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (entry.name === "node_modules" || entry.name === "tests") continue;
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) walk(full, out);
|
||||||
|
else if (
|
||||||
|
/\.(ts|cjs|mjs|js)$/.test(entry.name) &&
|
||||||
|
!/\.test\./.test(entry.name)
|
||||||
|
)
|
||||||
|
out.push(full);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNames(root = ROOT) {
|
||||||
|
const found = new Map();
|
||||||
|
for (const dir of DIRS) {
|
||||||
|
const abs = path.join(root, dir);
|
||||||
|
if (!fs.existsSync(abs)) continue;
|
||||||
|
for (const file of walk(abs)) {
|
||||||
|
const text = fs.readFileSync(file, "utf8");
|
||||||
|
for (const pattern of PATTERNS) {
|
||||||
|
for (const match of text.matchAll(pattern)) {
|
||||||
|
if (!found.has(match[1]))
|
||||||
|
found.set(match[1], path.relative(root, file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const catalog = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(ROOT, "env.catalog.json"), "utf8"),
|
||||||
|
);
|
||||||
|
const listed = new Set(catalog.vars.map((v) => v.name));
|
||||||
|
const problems = [];
|
||||||
|
for (const v of catalog.vars) {
|
||||||
|
if (!catalog.groups[v.group])
|
||||||
|
problems.push(`${v.name}: unknown group "${v.group}"`);
|
||||||
|
if (!v.description?.trim()) problems.push(`${v.name}: no description`);
|
||||||
|
}
|
||||||
|
for (const [name, file] of readNames()) {
|
||||||
|
if (!listed.has(name))
|
||||||
|
problems.push(
|
||||||
|
`${name} is read in ${file} but missing from env.catalog.json`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (problems.length) {
|
||||||
|
for (const p of problems) console.error(` ${p}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`env catalog ok (${listed.size} vars)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) main();
|
||||||
|
module.exports = { readNames, PATTERNS };
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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 { readNames } = require("./check-env-catalog.cjs") as {
|
||||||
|
readNames: (root: string) => Map<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(), "env-catalog-"));
|
||||||
|
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("readNames", () => {
|
||||||
|
it("finds every way core reads an env var", () => {
|
||||||
|
const dir = fixture({
|
||||||
|
"src/backend/a.ts": [
|
||||||
|
"const a = process.env.FIRST_VAR;",
|
||||||
|
'const b = process.env["SECOND_VAR"];',
|
||||||
|
"const c = env.THIRD_VAR;",
|
||||||
|
'export const X_ENV =\n "FOURTH_VAR";',
|
||||||
|
].join("\n"),
|
||||||
|
"electron/main.cjs": "process.env.DESKTOP_VAR",
|
||||||
|
});
|
||||||
|
expect([...readNames(dir).keys()].sort()).toEqual([
|
||||||
|
"DESKTOP_VAR",
|
||||||
|
"FIRST_VAR",
|
||||||
|
"FOURTH_VAR",
|
||||||
|
"SECOND_VAR",
|
||||||
|
"THIRD_VAR",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips tests", () => {
|
||||||
|
const dir = fixture({
|
||||||
|
"src/backend/tests/a.test.ts": "process.env.ONLY_IN_TESTS",
|
||||||
|
"src/backend/b.test.ts": "process.env.ALSO_TESTS",
|
||||||
|
});
|
||||||
|
expect(readNames(dir).size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -79,6 +79,8 @@ const ALLOWED = [
|
|||||||
"DialogPortal",
|
"DialogPortal",
|
||||||
"DialogTitle",
|
"DialogTitle",
|
||||||
"DialogTrigger",
|
"DialogTrigger",
|
||||||
|
"DocsLink",
|
||||||
|
"DocsLinkProps",
|
||||||
"DropdownMenu",
|
"DropdownMenu",
|
||||||
"DropdownMenuCheckboxItem",
|
"DropdownMenuCheckboxItem",
|
||||||
"DropdownMenuContent",
|
"DropdownMenuContent",
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Writes openapi.json for core from the @openapi blocks in src/backend.
|
||||||
|
// The docs site runs the same builder on each release.
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { buildOpenApi } from "@termix-ssh/plugin-sdk/openapi";
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const pkg = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(root, "package.json"), "utf8"),
|
||||||
|
);
|
||||||
|
const tags = JSON.parse(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(root, "src/backend/utils/openapi-tags.json"),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const out = process.argv[2] ?? path.join(root, "openapi.json");
|
||||||
|
|
||||||
|
const spec = await buildOpenApi({
|
||||||
|
title: "Termix API",
|
||||||
|
version: pkg.version,
|
||||||
|
description: "The core Termix API. Each plugin documents its own routes.",
|
||||||
|
files: [path.join(root, "src/backend/**/!(*.test).ts")],
|
||||||
|
tags,
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync(out, `${JSON.stringify(spec, null, 2)}\n`);
|
||||||
|
console.log(
|
||||||
|
`wrote ${path.relative(root, out)} (${Object.keys(spec.paths ?? {}).length} paths)`,
|
||||||
|
);
|
||||||
@@ -19,7 +19,13 @@ import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
|||||||
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
|
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
|
||||||
import { PluginManageError } from "../../plugins/manage.js";
|
import { PluginManageError } from "../../plugins/manage.js";
|
||||||
import { parsePluginChannel } from "../../plugins/registry-index.js";
|
import { parsePluginChannel } from "../../plugins/registry-index.js";
|
||||||
import { pluginFeatures, youtubeVideoId } from "../../plugins/manifest.js";
|
import {
|
||||||
|
pluginDocsUrl,
|
||||||
|
pluginEnvVars,
|
||||||
|
pluginFeatures,
|
||||||
|
youtubeVideoId,
|
||||||
|
type PluginEnvVar,
|
||||||
|
} from "../../plugins/manifest.js";
|
||||||
import {
|
import {
|
||||||
isPluginChoice,
|
isPluginChoice,
|
||||||
type PluginChoice,
|
type PluginChoice,
|
||||||
@@ -259,6 +265,8 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
let repository: string | undefined;
|
let repository: string | undefined;
|
||||||
let videoId: string | undefined;
|
let videoId: string | undefined;
|
||||||
let features: string[] = [];
|
let features: string[] = [];
|
||||||
|
let docs: string | undefined;
|
||||||
|
let env: PluginEnvVar[] = [];
|
||||||
try {
|
try {
|
||||||
const manifest = JSON.parse(record.manifestJson) as {
|
const manifest = JSON.parse(record.manifestJson) as {
|
||||||
contributes?: unknown;
|
contributes?: unknown;
|
||||||
@@ -269,6 +277,8 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
repository?: unknown;
|
repository?: unknown;
|
||||||
video?: unknown;
|
video?: unknown;
|
||||||
features?: unknown;
|
features?: unknown;
|
||||||
|
docs?: unknown;
|
||||||
|
env?: unknown;
|
||||||
dependencies?: Record<string, string>;
|
dependencies?: Record<string, string>;
|
||||||
optionalDependencies?: Record<string, string>;
|
optionalDependencies?: Record<string, string>;
|
||||||
};
|
};
|
||||||
@@ -292,6 +302,8 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
: undefined;
|
: undefined;
|
||||||
videoId = youtubeVideoId(manifest?.video) ?? undefined;
|
videoId = youtubeVideoId(manifest?.video) ?? undefined;
|
||||||
features = pluginFeatures(manifest?.features);
|
features = pluginFeatures(manifest?.features);
|
||||||
|
docs = pluginDocsUrl(manifest?.docs) ?? undefined;
|
||||||
|
env = pluginEnvVars(manifest?.env);
|
||||||
} catch {
|
} catch {
|
||||||
contributes = null;
|
contributes = null;
|
||||||
}
|
}
|
||||||
@@ -306,6 +318,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
icon,
|
icon,
|
||||||
dependencies,
|
dependencies,
|
||||||
optionalDependencies,
|
optionalDependencies,
|
||||||
|
docs,
|
||||||
...describePluginFrontend(loaded),
|
...describePluginFrontend(loaded),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -324,6 +337,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
repository,
|
repository,
|
||||||
videoId,
|
videoId,
|
||||||
features,
|
features,
|
||||||
|
env,
|
||||||
signedBy: loaded?.signedBy ?? null,
|
signedBy: loaded?.signedBy ?? null,
|
||||||
capabilities,
|
capabilities,
|
||||||
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
|
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ export interface RegistryListing {
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
videoId?: string;
|
videoId?: string;
|
||||||
features: string[];
|
features: string[];
|
||||||
|
docs?: string;
|
||||||
versions: Array<{
|
versions: Array<{
|
||||||
version: string;
|
version: string;
|
||||||
prerelease: boolean;
|
prerelease: boolean;
|
||||||
@@ -290,6 +291,7 @@ export async function listRegistry(
|
|||||||
icon: plugin.icon,
|
icon: plugin.icon,
|
||||||
videoId: plugin.videoId,
|
videoId: plugin.videoId,
|
||||||
features: plugin.features,
|
features: plugin.features,
|
||||||
|
docs: plugin.docs,
|
||||||
versions: channelReleases(plugin, "beta").map((entry) => ({
|
versions: channelReleases(plugin, "beta").map((entry) => ({
|
||||||
version: entry.version,
|
version: entry.version,
|
||||||
prerelease: Boolean(entry.prerelease),
|
prerelease: Boolean(entry.prerelease),
|
||||||
|
|||||||
@@ -19,10 +19,13 @@ export {
|
|||||||
qualifyPermission,
|
qualifyPermission,
|
||||||
youtubeVideoId,
|
youtubeVideoId,
|
||||||
pluginFeatures,
|
pluginFeatures,
|
||||||
|
pluginDocsUrl,
|
||||||
|
pluginEnvVars,
|
||||||
} from "@termix-ssh/plugin-sdk/manifest";
|
} from "@termix-ssh/plugin-sdk/manifest";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
PluginManifest,
|
PluginManifest,
|
||||||
|
PluginEnvVar,
|
||||||
PluginAuthor,
|
PluginAuthor,
|
||||||
PluginEngine,
|
PluginEngine,
|
||||||
PluginContributions,
|
PluginContributions,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
import {
|
import {
|
||||||
|
pluginDocsUrl,
|
||||||
pluginFeatures,
|
pluginFeatures,
|
||||||
SUPPORTED_PLUGIN_API_VERSION,
|
SUPPORTED_PLUGIN_API_VERSION,
|
||||||
youtubeVideoId,
|
youtubeVideoId,
|
||||||
@@ -67,6 +68,8 @@ export interface RegistryPlugin {
|
|||||||
/** Only ever a YouTube video id, never a link. */
|
/** Only ever a YouTube video id, never a link. */
|
||||||
videoId?: string;
|
videoId?: string;
|
||||||
features: string[];
|
features: string[];
|
||||||
|
/** https docs link from the index. */
|
||||||
|
docs?: string;
|
||||||
/** Stable releases, newest first. */
|
/** Stable releases, newest first. */
|
||||||
versions: RegistryVersion[];
|
versions: RegistryVersion[];
|
||||||
/**
|
/**
|
||||||
@@ -161,6 +164,7 @@ export function parseRegistryIndex(raw: unknown): RegistryIndex {
|
|||||||
icon: asString(entry.icon),
|
icon: asString(entry.icon),
|
||||||
videoId: youtubeVideoId(entry.video) ?? undefined,
|
videoId: youtubeVideoId(entry.video) ?? undefined,
|
||||||
features: pluginFeatures(entry.features),
|
features: pluginFeatures(entry.features),
|
||||||
|
docs: pluginDocsUrl(entry.docs) ?? undefined,
|
||||||
versions,
|
versions,
|
||||||
prereleases,
|
prereleases,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ import {
|
|||||||
isTermixCompatible,
|
isTermixCompatible,
|
||||||
youtubeVideoId,
|
youtubeVideoId,
|
||||||
pluginFeatures,
|
pluginFeatures,
|
||||||
|
pluginDocsUrl,
|
||||||
|
pluginEnvVars,
|
||||||
} from "../../plugins/manifest.js";
|
} from "../../plugins/manifest.js";
|
||||||
|
import { pluginDocsPage } from "@termix-ssh/plugin-sdk/docs";
|
||||||
|
|
||||||
function base(overrides: Record<string, unknown> = {}) {
|
function base(overrides: Record<string, unknown> = {}) {
|
||||||
return {
|
return {
|
||||||
@@ -929,3 +932,73 @@ describe("youtubeVideoId", () => {
|
|||||||
expect(youtubeVideoId(undefined)).toBeNull();
|
expect(youtubeVideoId(undefined)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("docs and env fields", () => {
|
||||||
|
it("accepts an https docs link and well formed env entries", () => {
|
||||||
|
expect(
|
||||||
|
validateManifest(
|
||||||
|
base({
|
||||||
|
docs: "https://docs.termix.site/plugins/sample-plugin",
|
||||||
|
env: [
|
||||||
|
{ name: "SAMPLE_URL", description: "Where to connect." },
|
||||||
|
{
|
||||||
|
name: "SAMPLE_TOKEN",
|
||||||
|
description: "Token.",
|
||||||
|
secret: true,
|
||||||
|
required: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a docs link that is not https", () => {
|
||||||
|
const errors = validateManifest(base({ docs: "http://example.com" }));
|
||||||
|
expect(errors.join(" ")).toMatch(/"docs" must be an https URL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses bad env entries", () => {
|
||||||
|
const errors = validateManifest(
|
||||||
|
base({
|
||||||
|
env: [
|
||||||
|
{ name: "lower_case", description: "x" },
|
||||||
|
{ name: "DUP", description: "x" },
|
||||||
|
{ name: "DUP", description: "x" },
|
||||||
|
{ name: "NO_DESC", description: "" },
|
||||||
|
{ name: "EXTRA", description: "x", other: 1 },
|
||||||
|
{ name: "FLAG", description: "x", secret: "yes" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).join(" ");
|
||||||
|
expect(errors).toMatch(/env\[0\]\.name/);
|
||||||
|
expect(errors).toMatch(/env\[2\]\.name" repeats DUP/);
|
||||||
|
expect(errors).toMatch(/env\[3\]\.description/);
|
||||||
|
expect(errors).toMatch(/Unknown field "other" in env\[4\]/);
|
||||||
|
expect(errors).toMatch(/env\[5\]\.secret/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads docs links and env entries leniently", () => {
|
||||||
|
expect(pluginDocsUrl("https://docs.termix.site/plugins/x/")).toBe(
|
||||||
|
"https://docs.termix.site/plugins/x",
|
||||||
|
);
|
||||||
|
expect(pluginDocsUrl("file:///etc/passwd")).toBeNull();
|
||||||
|
expect(pluginDocsUrl(42)).toBeNull();
|
||||||
|
expect(
|
||||||
|
pluginEnvVars([
|
||||||
|
{ name: "OK_VAR", description: "fine" },
|
||||||
|
{ name: "bad", description: "x" },
|
||||||
|
"nope",
|
||||||
|
]),
|
||||||
|
).toEqual([{ name: "OK_VAR", description: "fine" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins pages and anchors onto a docs link", () => {
|
||||||
|
const base = "https://docs.termix.site/plugins/docker";
|
||||||
|
expect(pluginDocsPage(base)).toBe(base);
|
||||||
|
expect(pluginDocsPage(base, "/setup/")).toBe(`${base}/setup`);
|
||||||
|
expect(pluginDocsPage(base, "setup", "#ports")).toBe(`${base}/setup#ports`);
|
||||||
|
expect(pluginDocsPage(base, "", "top")).toBe(`${base}#top`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -155,6 +155,23 @@ describe("parseRegistryIndex", () => {
|
|||||||
expect(index.plugins[1].features).toEqual([]);
|
expect(index.plugins[1].features).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps only https docs links", () => {
|
||||||
|
const index = parseRegistryIndex({
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
id: "docker",
|
||||||
|
docs: "https://docs.termix.site/plugins/docker/",
|
||||||
|
versions: [version()],
|
||||||
|
},
|
||||||
|
{ id: "other", docs: "javascript:alert(1)", versions: [version()] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(index.plugins[0].docs).toBe(
|
||||||
|
"https://docs.termix.site/plugins/docker",
|
||||||
|
);
|
||||||
|
expect(index.plugins[1].docs).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("refuses something that is not an index", () => {
|
it("refuses something that is not an index", () => {
|
||||||
expect(() => parseRegistryIndex({ nope: true })).toThrow(/no plugins/);
|
expect(() => parseRegistryIndex({ nope: true })).toThrow(/no plugins/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
buildOpenApi,
|
||||||
|
hasOpenApiPaths,
|
||||||
|
TERMIX_SECURITY_SCHEMES,
|
||||||
|
} from "@termix-ssh/plugin-sdk/openapi";
|
||||||
|
|
||||||
|
let dir: string | null = null;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
dir = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
function source(files: Record<string, string>): string {
|
||||||
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), "sdk-openapi-"));
|
||||||
|
for (const [name, text] of Object.entries(files)) {
|
||||||
|
fs.writeFileSync(path.join(dir, name), text);
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildOpenApi", () => {
|
||||||
|
it("reads @openapi blocks from TypeScript sources", async () => {
|
||||||
|
const root = source({
|
||||||
|
"routes.ts": [
|
||||||
|
"/**",
|
||||||
|
" * @openapi",
|
||||||
|
" * /plugin-api/sample/items:",
|
||||||
|
" * get:",
|
||||||
|
" * summary: List items",
|
||||||
|
" * responses:",
|
||||||
|
" * 200:",
|
||||||
|
" * description: The items",
|
||||||
|
" */",
|
||||||
|
'router.get("/items", handler);',
|
||||||
|
].join("\n"),
|
||||||
|
});
|
||||||
|
const spec = await buildOpenApi({
|
||||||
|
title: "Sample API",
|
||||||
|
version: "1.2.3",
|
||||||
|
files: [path.join(root, "*.ts")],
|
||||||
|
tags: [{ name: "Sample" }],
|
||||||
|
});
|
||||||
|
expect(hasOpenApiPaths(spec)).toBe(true);
|
||||||
|
expect(Object.keys(spec.paths ?? {})).toEqual(["/plugin-api/sample/items"]);
|
||||||
|
expect(spec).toMatchObject({
|
||||||
|
openapi: "3.0.3",
|
||||||
|
info: { title: "Sample API", version: "1.2.3" },
|
||||||
|
tags: [{ name: "Sample" }],
|
||||||
|
components: { securitySchemes: TERMIX_SECURITY_SCHEMES },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a spec with no paths when nothing is documented", async () => {
|
||||||
|
const root = source({ "index.ts": "export const x = 1;" });
|
||||||
|
const spec = await buildOpenApi({
|
||||||
|
title: "Empty",
|
||||||
|
version: "1.0.0",
|
||||||
|
files: [path.join(root, "*.ts")],
|
||||||
|
});
|
||||||
|
expect(hasOpenApiPaths(spec)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
[
|
||||||
|
{ "name": "Auth", "description": "Sign in, sign out and login methods" },
|
||||||
|
{ "name": "Users", "description": "Accounts, profiles and sessions" },
|
||||||
|
{ "name": "API Keys", "description": "API keys for scripts and the CLI" },
|
||||||
|
{ "name": "Encryption", "description": "Data key setup and unlock" },
|
||||||
|
{ "name": "SSH", "description": "Hosts, folders and connections" },
|
||||||
|
{
|
||||||
|
"name": "SSH Auth",
|
||||||
|
"description": "SSH auth types the server can connect with"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Host Defaults",
|
||||||
|
"description": "Admin, user and folder host defaults"
|
||||||
|
},
|
||||||
|
{ "name": "Host Enrollment", "description": "Adding hosts from a script" },
|
||||||
|
{ "name": "Credentials", "description": "Saved credentials" },
|
||||||
|
{ "name": "RBAC", "description": "Roles and sharing" },
|
||||||
|
{ "name": "Dashboard", "description": "Dashboard stats and recent activity" },
|
||||||
|
{ "name": "Audit", "description": "Audit log search and export" },
|
||||||
|
{ "name": "Plugins", "description": "Installing and managing plugins" },
|
||||||
|
{ "name": "Sync", "description": "Desktop sync with a linked server" },
|
||||||
|
{ "name": "Database", "description": "Database export, import and health" },
|
||||||
|
{ "name": "General", "description": "Server info and settings" },
|
||||||
|
{ "name": "Open Tabs", "description": "Saved open tabs" },
|
||||||
|
{ "name": "User Preferences", "description": "Per-user preferences" },
|
||||||
|
{ "name": "UI Preferences", "description": "Interface layout preferences" },
|
||||||
|
{ "name": "Host Sidebar", "description": "Host sidebar display options" },
|
||||||
|
{
|
||||||
|
"name": "Credential Sidebar",
|
||||||
|
"description": "Credential sidebar display options"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
import swaggerJSDoc from "@deadendjs/swagger-jsdoc";
|
|
||||||
import path from "path";
|
|
||||||
import { fileURLToPath } from "url";
|
|
||||||
import { promises as fs } from "fs";
|
|
||||||
import { systemLogger } from "./logger.js";
|
|
||||||
|
|
||||||
type SwaggerJSDocOptions = Parameters<typeof swaggerJSDoc>[0];
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
|
|
||||||
const swaggerOptions: SwaggerJSDocOptions = {
|
|
||||||
definition: {
|
|
||||||
openapi: "3.0.3",
|
|
||||||
info: {
|
|
||||||
title: "Termix API",
|
|
||||||
version: "0.0.0",
|
|
||||||
description: "Termix Backend API Reference",
|
|
||||||
},
|
|
||||||
servers: [
|
|
||||||
{
|
|
||||||
url: "http://localhost:30001",
|
|
||||||
description: "Main database and authentication server",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
components: {
|
|
||||||
securitySchemes: {
|
|
||||||
bearerAuth: {
|
|
||||||
type: "http",
|
|
||||||
scheme: "bearer",
|
|
||||||
bearerFormat: "JWT",
|
|
||||||
description: "Session JWT sent as Authorization: Bearer <JWT>.",
|
|
||||||
},
|
|
||||||
cookieAuth: {
|
|
||||||
type: "apiKey",
|
|
||||||
in: "cookie",
|
|
||||||
name: "jwt",
|
|
||||||
description:
|
|
||||||
"Session JWT cookie set at sign-in. When present, it takes precedence over the Authorization header.",
|
|
||||||
},
|
|
||||||
apiKeyAuth: {
|
|
||||||
type: "http",
|
|
||||||
scheme: "bearer",
|
|
||||||
bearerFormat: "tmx_<api key>",
|
|
||||||
description:
|
|
||||||
"API key sent as Authorization: Bearer tmx_<api key>. Permissions follow the key owner's account; encrypted data must be unlocked separately. API keys cannot impersonate another user.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
schemas: {
|
|
||||||
Error: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
error: { type: "string" },
|
|
||||||
details: { type: "string" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
security: [
|
|
||||||
{
|
|
||||||
bearerAuth: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
cookieAuth: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
apiKeyAuth: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
tags: [
|
|
||||||
{
|
|
||||||
name: "Credentials",
|
|
||||||
description: "SSH credential management",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "RBAC",
|
|
||||||
description: "Role-based access control for host sharing",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Users",
|
|
||||||
description: "User management and authentication",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Dashboard",
|
|
||||||
description: "Dashboard statistics and activity",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "SSH",
|
|
||||||
description: "SSH host management and configuration",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Host Enrollment",
|
|
||||||
description: "Host enrollment and onboarding",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Open Tabs",
|
|
||||||
description: "Per-user open tab state",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Audit",
|
|
||||||
description: "Audit log querying and export",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "API Keys",
|
|
||||||
description: "API key management",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Sync",
|
|
||||||
description: "Remote sync between desktop and server",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "User Preferences",
|
|
||||||
description: "Per-user application preferences",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "UI Preferences",
|
|
||||||
description: "Interface layout and display preferences",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Host Sidebar",
|
|
||||||
description: "Host sidebar display preferences",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Credential Sidebar",
|
|
||||||
description: "Credential sidebar display preferences",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
apis: [
|
|
||||||
path.join(__dirname, "..", "database", "database.js").replace(/\\/g, "/"),
|
|
||||||
path
|
|
||||||
.join(__dirname, "..", "database", "routes", "*.js")
|
|
||||||
.replace(/\\/g, "/"),
|
|
||||||
path.join(__dirname, "..", "services", "*.js").replace(/\\/g, "/"),
|
|
||||||
path.join(__dirname, "..", "hosts", "*.js").replace(/\\/g, "/"),
|
|
||||||
path.join(__dirname, "..", "hosts", "**", "*.js").replace(/\\/g, "/"),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
async function generateOpenAPISpec() {
|
|
||||||
try {
|
|
||||||
systemLogger.info("Generating OpenAPI specification", {
|
|
||||||
operation: "openapi_generate_start",
|
|
||||||
});
|
|
||||||
|
|
||||||
const swaggerSpec = await swaggerJSDoc(swaggerOptions);
|
|
||||||
|
|
||||||
const outputPath = path.join(
|
|
||||||
__dirname,
|
|
||||||
"..",
|
|
||||||
"..",
|
|
||||||
"..",
|
|
||||||
"..",
|
|
||||||
"openapi.json",
|
|
||||||
);
|
|
||||||
|
|
||||||
await fs.writeFile(
|
|
||||||
outputPath,
|
|
||||||
JSON.stringify(swaggerSpec, null, 2),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
|
|
||||||
systemLogger.success("OpenAPI specification generated", {
|
|
||||||
operation: "openapi_generate_success",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
systemLogger.error("Failed to generate OpenAPI specification", error, {
|
|
||||||
operation: "openapi_generation",
|
|
||||||
});
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
generateOpenAPISpec();
|
|
||||||
|
|
||||||
export { swaggerOptions, generateOpenAPISpec };
|
|
||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
ChangelogRelease,
|
ChangelogRelease,
|
||||||
ReleaseNotes,
|
ReleaseNotes,
|
||||||
} from "@termix-ssh/plugin-sdk/changelog";
|
} from "@termix-ssh/plugin-sdk/changelog";
|
||||||
|
import type { PluginEnvVar } from "@termix-ssh/plugin-sdk/docs";
|
||||||
|
|
||||||
export type { PluginChoice };
|
export type { PluginChoice };
|
||||||
|
|
||||||
@@ -255,6 +256,10 @@ export interface PluginSummary {
|
|||||||
videoId?: string;
|
videoId?: string;
|
||||||
/** What the plugin does, from the manifest. Admins only. */
|
/** What the plugin does, from the manifest. Admins only. */
|
||||||
features?: string[];
|
features?: string[];
|
||||||
|
/** https docs link from the manifest. */
|
||||||
|
docs?: string;
|
||||||
|
/** Environment variables the plugin reads. Admins only. */
|
||||||
|
env?: PluginEnvVar[];
|
||||||
signedBy?: string | null;
|
signedBy?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,6 +299,7 @@ export interface RegistryPluginEntry {
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
videoId?: string;
|
videoId?: string;
|
||||||
features?: string[];
|
features?: string[];
|
||||||
|
docs?: string;
|
||||||
versions: RegistryPluginVersion[];
|
versions: RegistryPluginVersion[];
|
||||||
/** Newest release on the plugin's channel. */
|
/** Newest release on the plugin's channel. */
|
||||||
latestVersion: string | null;
|
latestVersion: string | null;
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
|
import { BookOpen } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { pluginDocsPage } from "@termix-ssh/plugin-sdk/docs";
|
||||||
|
import { docsUrl, type DocsPage } from "@/lib/docs";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { usePluginScope } from "@/plugin-host/scope";
|
||||||
|
import { usePluginStore } from "@/plugin-host/plugin-store";
|
||||||
|
|
||||||
|
/** A plugin's docs link from its manifest, or null when it has none. */
|
||||||
|
export function usePluginDocsUrl(
|
||||||
|
pluginId: string | null | undefined,
|
||||||
|
page?: string,
|
||||||
|
anchor?: string,
|
||||||
|
): string | null {
|
||||||
|
const { records } = usePluginStore();
|
||||||
|
const base = pluginId ? records.get(pluginId)?.summary.docs : undefined;
|
||||||
|
return base ? pluginDocsPage(base, page, anchor) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocsLinkProps {
|
||||||
|
/** A core docs page. */
|
||||||
|
core?: DocsPage;
|
||||||
|
/**
|
||||||
|
* A page of the current plugin's docs, like "setup". Inside a plugin this
|
||||||
|
* resolves against its manifest docs link; "" is its main page.
|
||||||
|
*/
|
||||||
|
page?: string;
|
||||||
|
anchor?: string;
|
||||||
|
/** A full URL, used as is. */
|
||||||
|
href?: string | null;
|
||||||
|
/** "text" is a small inline link, "icon" a square header button. */
|
||||||
|
variant?: "text" | "icon";
|
||||||
|
children?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one way the app links to docs. Renders nothing when there is nowhere
|
||||||
|
* to go, like a community plugin with no docs link.
|
||||||
|
*/
|
||||||
|
export function DocsLink({
|
||||||
|
core,
|
||||||
|
page,
|
||||||
|
anchor,
|
||||||
|
href,
|
||||||
|
variant = "text",
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: DocsLinkProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const pluginId = usePluginScope();
|
||||||
|
const pluginUrl = usePluginDocsUrl(
|
||||||
|
page !== undefined ? pluginId : null,
|
||||||
|
page,
|
||||||
|
anchor,
|
||||||
|
);
|
||||||
|
const url =
|
||||||
|
href ?? (core ? withAnchor(docsUrl(core), anchor) : null) ?? pluginUrl;
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
const label = t("common.docs");
|
||||||
|
if (variant === "icon") {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
className={cn(
|
||||||
|
"flex size-7 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BookOpen className="size-3.5" />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 text-[10px] text-accent-brand hover:underline",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children ?? label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function withAnchor(url: string, anchor?: string): string {
|
||||||
|
if (!anchor) return url;
|
||||||
|
return `${url.split("#")[0]}#${anchor.replace(/^#/, "")}`;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { ArrowLeft, Grid3X3, List, Rows3, Search, X } from "lucide-react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Separator } from "@/components/separator";
|
import { Separator } from "@/components/separator";
|
||||||
|
import { DocsLink } from "@/components/docs-link";
|
||||||
|
|
||||||
/** One spacing rhythm for every panel and tab. */
|
/** One spacing rhythm for every panel and tab. */
|
||||||
export const PANEL = {
|
export const PANEL = {
|
||||||
@@ -30,6 +31,7 @@ export function PanelShell({
|
|||||||
tabs,
|
tabs,
|
||||||
footer,
|
footer,
|
||||||
scroll = true,
|
scroll = true,
|
||||||
|
docs,
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
@@ -49,6 +51,8 @@ export function PanelShell({
|
|||||||
footer?: React.ReactNode;
|
footer?: React.ReactNode;
|
||||||
/** False when the body scrolls itself. */
|
/** False when the body scrolls itself. */
|
||||||
scroll?: boolean;
|
scroll?: boolean;
|
||||||
|
/** Docs URL for this screen, shown as a book icon in the header. */
|
||||||
|
docs?: string | null;
|
||||||
className?: string;
|
className?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
@@ -120,8 +124,11 @@ export function PanelShell({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{actions && (
|
{(actions || docs) && (
|
||||||
<div className="flex shrink-0 items-center gap-1 px-2">{actions}</div>
|
<div className="flex shrink-0 items-center gap-1 px-2">
|
||||||
|
{actions}
|
||||||
|
{docs && <DocsLink href={docs} variant="icon" />}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
{bands}
|
{bands}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import {
|
|||||||
type VersionStatus,
|
type VersionStatus,
|
||||||
} from "./dashboard-core-cards";
|
} from "./dashboard-core-cards";
|
||||||
import { reportCoreIssueUrl } from "@/lib/issue-url";
|
import { reportCoreIssueUrl } from "@/lib/issue-url";
|
||||||
|
import { docsUrl } from "@/lib/docs";
|
||||||
|
|
||||||
export { HostStatusList as HostStatusCard } from "./dashboard-core-cards";
|
export { HostStatusList as HostStatusCard } from "./dashboard-core-cards";
|
||||||
|
|
||||||
@@ -803,7 +804,7 @@ export function DashboardTab({
|
|||||||
href: "https://discord.com/invite/jVQGdvHDrf",
|
href: "https://discord.com/invite/jVQGdvHDrf",
|
||||||
label: t("dashboard.discord"),
|
label: t("dashboard.discord"),
|
||||||
},
|
},
|
||||||
{ href: "https://docs.termix.site/", label: t("dashboard.docs") },
|
{ href: docsUrl(), label: t("dashboard.docs") },
|
||||||
{ href: "https://donate.termix.site/", label: t("dashboard.donate") },
|
{ href: "https://donate.termix.site/", label: t("dashboard.donate") },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -827,6 +828,7 @@ export function DashboardTab({
|
|||||||
title={title}
|
title={title}
|
||||||
status={isDashboardView ? todayLabel : undefined}
|
status={isDashboardView ? todayLabel : undefined}
|
||||||
scroll={false}
|
scroll={false}
|
||||||
|
docs={docsUrl("dashboard")}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
{isDashboardView && !viewPending && (
|
{isDashboardView && !viewPending && (
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"home": "",
|
||||||
|
"install": "install",
|
||||||
|
"updating": "install/updating",
|
||||||
|
"betas": "configure/betas",
|
||||||
|
"envVars": "configure/environment-variables",
|
||||||
|
"https": "configure/https",
|
||||||
|
"database": "configure/database",
|
||||||
|
"reverseProxy": "configure/reverse-proxy",
|
||||||
|
"desktopSync": "configure/desktop-sync",
|
||||||
|
"interface": "guide/interface",
|
||||||
|
"dashboard": "guide/dashboard",
|
||||||
|
"manage": "guide/manage",
|
||||||
|
"hosts": "guide/hosts",
|
||||||
|
"portKnocking": "guide/hosts#port-knocking",
|
||||||
|
"hostDefaults": "guide/host-defaults",
|
||||||
|
"credentials": "guide/credentials",
|
||||||
|
"sharing": "guide/sharing",
|
||||||
|
"account": "guide/account",
|
||||||
|
"accountSecurity": "guide/account#security",
|
||||||
|
"accountData": "guide/account#your-data",
|
||||||
|
"apiKeys": "guide/api-keys",
|
||||||
|
"commandPalette": "guide/command-palette",
|
||||||
|
"settings": "guide/settings",
|
||||||
|
"interfaceSettings": "guide/settings#interface",
|
||||||
|
"appearance": "guide/settings#appearance",
|
||||||
|
"keybindings": "guide/settings#keybindings",
|
||||||
|
"users": "guide/users",
|
||||||
|
"sessions": "guide/users#sessions",
|
||||||
|
"roles": "guide/roles",
|
||||||
|
"adminGeneral": "guide/admin-settings",
|
||||||
|
"branding": "guide/admin-settings#branding",
|
||||||
|
"adminDatabase": "guide/admin-settings#database",
|
||||||
|
"adminSsl": "configure/https",
|
||||||
|
"auditLog": "guide/audit-log",
|
||||||
|
"importExport": "guide/import-export",
|
||||||
|
"plugins": "guide/plugins",
|
||||||
|
"pluginCatalog": "plugins",
|
||||||
|
"cli": "cli",
|
||||||
|
"develop": "develop"
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import DOCS_PAGES from "./docs-pages.json";
|
||||||
|
|
||||||
|
export const DOCS_URL = "https://docs.termix.site";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every core docs page the app links to. The docs site checks each one
|
||||||
|
* exists, so add new links here rather than writing URLs inline.
|
||||||
|
*/
|
||||||
|
export type DocsPage = keyof typeof DOCS_PAGES;
|
||||||
|
|
||||||
|
/** Full URL of a core docs page. */
|
||||||
|
export function docsUrl(page: DocsPage = "home"): string {
|
||||||
|
const path = DOCS_PAGES[page] ?? "";
|
||||||
|
return path ? `${DOCS_URL}/${path}` : `${DOCS_URL}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DOCS_PAGES };
|
||||||
+11
-4
@@ -208,7 +208,8 @@
|
|||||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||||
"toggleSidebar": "Toggle Sidebar",
|
"toggleSidebar": "Toggle Sidebar",
|
||||||
"search": "Search...",
|
"search": "Search...",
|
||||||
"new": "New"
|
"new": "New",
|
||||||
|
"docs": "Docs"
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"stepCounter": "Step {{current}} of {{total}}",
|
"stepCounter": "Step {{current}} of {{total}}",
|
||||||
@@ -252,7 +253,8 @@
|
|||||||
"desktopLocalBody": "Everything is stored here. Nothing to set up.",
|
"desktopLocalBody": "Everything is stored here. Nothing to set up.",
|
||||||
"desktopLinkTitle": "With my Termix server",
|
"desktopLinkTitle": "With my Termix server",
|
||||||
"desktopLinkBody": "Sign in to your server to use the same hosts and settings on every device.",
|
"desktopLinkBody": "Sign in to your server to use the same hosts and settings on every device.",
|
||||||
"desktopLinked": "Linked to {{server}}"
|
"desktopLinked": "Linked to {{server}}",
|
||||||
|
"pluginsDocsLink": "Read about each one"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
@@ -930,7 +932,8 @@
|
|||||||
"select": "Select",
|
"select": "Select",
|
||||||
"toggleWith": "Toggle with",
|
"toggleWith": "Toggle with",
|
||||||
"pluginActions": "More actions",
|
"pluginActions": "More actions",
|
||||||
"noTargetTab": "No terminal tab is open"
|
"noTargetTab": "No terminal tab is open",
|
||||||
|
"documentation": "Documentation"
|
||||||
},
|
},
|
||||||
"splitScreen": {
|
"splitScreen": {
|
||||||
"title": "Split screen",
|
"title": "Split screen",
|
||||||
@@ -2185,7 +2188,11 @@
|
|||||||
"waitingForStable": "Back on stable. Stays on {{version}} until a stable release is newer.",
|
"waitingForStable": "Back on stable. Stays on {{version}} until a stable release is newer.",
|
||||||
"versionLabel": "{{version}} (beta)",
|
"versionLabel": "{{version}} (beta)",
|
||||||
"sendFeedback": "Send beta feedback"
|
"sendFeedback": "Send beta feedback"
|
||||||
}
|
},
|
||||||
|
"documentation": "Documentation",
|
||||||
|
"envVars": "Environment variables",
|
||||||
|
"envRequired": "Required",
|
||||||
|
"envDefault": "Default: {{value}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
type ManageMode,
|
type ManageMode,
|
||||||
type ManageRequest,
|
type ManageRequest,
|
||||||
} from "./manage-requests";
|
} from "./manage-requests";
|
||||||
|
import { docsUrl } from "@/lib/docs";
|
||||||
|
|
||||||
type Editing =
|
type Editing =
|
||||||
| {
|
| {
|
||||||
@@ -440,6 +441,13 @@ export function ManageTab() {
|
|||||||
title={t("nav.manage")}
|
title={t("nav.manage")}
|
||||||
status={status}
|
status={status}
|
||||||
scroll={false}
|
scroll={false}
|
||||||
|
docs={docsUrl(
|
||||||
|
mode === "credentials"
|
||||||
|
? "credentials"
|
||||||
|
: mode === "hosts"
|
||||||
|
? "hosts"
|
||||||
|
: "hostDefaults",
|
||||||
|
)}
|
||||||
leading={
|
leading={
|
||||||
editing ? (
|
editing ? (
|
||||||
<span className="flex h-full md:hidden">
|
<span className="flex h-full md:hidden">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
defaultChoices,
|
defaultChoices,
|
||||||
recommendedChoices,
|
recommendedChoices,
|
||||||
} from "../plugin-choices";
|
} from "../plugin-choices";
|
||||||
|
import { DocsLink } from "@/components/docs-link";
|
||||||
|
|
||||||
const CATEGORY_ORDER = [
|
const CATEGORY_ORDER = [
|
||||||
"Terminal",
|
"Terminal",
|
||||||
@@ -173,7 +174,10 @@ export function PluginsStep({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t("onboarding.pluginsIntro")}
|
{t("onboarding.pluginsIntro")}{" "}
|
||||||
|
<DocsLink core="pluginCatalog" className="text-xs">
|
||||||
|
{t("onboarding.pluginsDocsLink")}
|
||||||
|
</DocsLink>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-start gap-2 border border-border bg-muted/30 p-2.5 text-[11px] leading-snug text-muted-foreground">
|
<div className="flex items-start gap-2 border border-border bg-muted/30 p-2.5 text-[11px] leading-snug text-muted-foreground">
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ import {
|
|||||||
} from "./host-scope";
|
} from "./host-scope";
|
||||||
import { manifestDeclares, type ViewKind } from "./view-ownership";
|
import { manifestDeclares, type ViewKind } from "./view-ownership";
|
||||||
import { pluginKey } from "@/lib/plugin-i18n";
|
import { pluginKey } from "@/lib/plugin-i18n";
|
||||||
|
import { pluginDocsPage, pluginDocsUrl } from "@termix-ssh/plugin-sdk/docs";
|
||||||
import { hasPermission } from "@/hooks/use-permissions";
|
import { hasPermission } from "@/hooks/use-permissions";
|
||||||
import i18n from "@/i18n/i18n";
|
import i18n from "@/i18n/i18n";
|
||||||
|
|
||||||
@@ -83,6 +84,20 @@ export interface PluginAppHandle {
|
|||||||
dispose: () => void;
|
dispose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createDocsApi(manifest: PluginManifest): TermixApp["docs"] {
|
||||||
|
const url = pluginDocsUrl(manifest?.docs);
|
||||||
|
const page = (name?: string, anchor?: string) =>
|
||||||
|
url ? pluginDocsPage(url, name, anchor) : null;
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
page,
|
||||||
|
open(name, anchor) {
|
||||||
|
const target = page(name, anchor);
|
||||||
|
if (target) window.open(target, "_blank", "noopener,noreferrer");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the object a plugin's `activate(app)` receives.
|
* Builds the object a plugin's `activate(app)` receives.
|
||||||
*
|
*
|
||||||
@@ -602,6 +617,8 @@ export function createPluginApp(
|
|||||||
|
|
||||||
confirm: (options) => shell.confirm!(options),
|
confirm: (options) => shell.confirm!(options),
|
||||||
|
|
||||||
|
docs: createDocsApi(manifest),
|
||||||
|
|
||||||
desktop: {
|
desktop: {
|
||||||
available: isElectron(),
|
available: isElectron(),
|
||||||
remoteServerUrl,
|
remoteServerUrl,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
} from "@/api/open-tabs-api";
|
} from "@/api/open-tabs-api";
|
||||||
import { runKeybindingAction } from "@/shell/keybinding-registry";
|
import { runKeybindingAction } from "@/shell/keybinding-registry";
|
||||||
import { usePluginScope } from "./scope";
|
import { usePluginScope } from "./scope";
|
||||||
|
import { usePluginDocsUrl } from "@/components/docs-link";
|
||||||
import { knownPluginIds, usePluginStore } from "./plugin-store";
|
import { knownPluginIds, usePluginStore } from "./plugin-store";
|
||||||
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
|
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
|
||||||
import type { UiPluginPresets } from "@/types/ui-preferences";
|
import type { UiPluginPresets } from "@/types/ui-preferences";
|
||||||
@@ -225,6 +226,10 @@ export const pluginHostBridge: PluginHostBridge = {
|
|||||||
return pluginId;
|
return pluginId;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
useDocsUrl(pluginId, page, anchor) {
|
||||||
|
return usePluginDocsUrl(pluginId, page, anchor);
|
||||||
|
},
|
||||||
|
|
||||||
useTranslation(pluginId) {
|
useTranslation(pluginId) {
|
||||||
const { t, i18n } = useI18nTranslation(pluginId);
|
const { t, i18n } = useI18nTranslation(pluginId);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -289,6 +289,8 @@ export {
|
|||||||
type PanelDensity,
|
type PanelDensity,
|
||||||
type PanelViewMode,
|
type PanelViewMode,
|
||||||
} from "@/components/panel-layout";
|
} from "@/components/panel-layout";
|
||||||
|
// A link to docs. Inside a plugin, page="setup" opens that page of its docs.
|
||||||
|
export { DocsLink, type DocsLinkProps } from "@/components/docs-link";
|
||||||
export {
|
export {
|
||||||
AddButton,
|
AddButton,
|
||||||
FormFooter,
|
FormFooter,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
|
BookOpen,
|
||||||
Bug,
|
Bug,
|
||||||
Check,
|
Check,
|
||||||
Database,
|
Database,
|
||||||
@@ -262,6 +263,40 @@ export function PluginDetail({
|
|||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
|
{plugin.env.length > 0 && (
|
||||||
|
<SectionCard title={t("plugins.manager.envVars")} icon={null}>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{plugin.env.map((v) => (
|
||||||
|
<div
|
||||||
|
key={v.name}
|
||||||
|
className="flex flex-col gap-0.5 px-4 py-2.5"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<code className="font-mono text-xs font-semibold">
|
||||||
|
{v.name}
|
||||||
|
</code>
|
||||||
|
{v.required && (
|
||||||
|
<span className="text-[9px] font-bold uppercase tracking-widest text-accent-brand">
|
||||||
|
{t("plugins.manager.envRequired")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{v.default !== undefined && (
|
||||||
|
<span className="font-mono text-[10px] text-muted-foreground">
|
||||||
|
{t("plugins.manager.envDefault", {
|
||||||
|
value: v.default,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] leading-snug text-muted-foreground">
|
||||||
|
{v.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
|
||||||
<PluginReleaseNotes plugin={plugin} />
|
<PluginReleaseNotes plugin={plugin} />
|
||||||
|
|
||||||
{plugin.installed && (
|
{plugin.installed && (
|
||||||
@@ -419,6 +454,20 @@ export function PluginDetail({
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{plugin.docs && (
|
||||||
|
<a
|
||||||
|
href={plugin.docs}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={LINK_ROW}
|
||||||
|
>
|
||||||
|
<BookOpen className="size-3.5 shrink-0" />
|
||||||
|
<span className="truncate">
|
||||||
|
{t("plugins.manager.documentation")}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
{hasSettings && (
|
{hasSettings && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ import {
|
|||||||
type SortKey,
|
type SortKey,
|
||||||
} from "./plugin-model";
|
} from "./plugin-model";
|
||||||
import { usePluginsManager, type PluginsManager } from "./use-plugins-manager";
|
import { usePluginsManager, type PluginsManager } from "./use-plugins-manager";
|
||||||
|
import { docsUrl } from "@/lib/docs";
|
||||||
|
import { DocsLink } from "@/components/docs-link";
|
||||||
|
|
||||||
type Section = "installed" | "browse" | "updates";
|
type Section = "installed" | "browse" | "updates";
|
||||||
|
|
||||||
@@ -102,6 +104,7 @@ function PluginsTabBody() {
|
|||||||
<PanelShell
|
<PanelShell
|
||||||
icon={<Puzzle className="size-4" />}
|
icon={<Puzzle className="size-4" />}
|
||||||
title={t("nav.plugins")}
|
title={t("nav.plugins")}
|
||||||
|
docs={docsUrl("plugins")}
|
||||||
status={t("plugins.manager.status.summary", {
|
status={t("plugins.manager.status.summary", {
|
||||||
installed: installed.length,
|
installed: installed.length,
|
||||||
running,
|
running,
|
||||||
@@ -550,13 +553,21 @@ function InstalledCard({
|
|||||||
: t("plugins.manager.enable")}
|
: t("plugins.manager.enable")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<DocsLink
|
||||||
|
href={plugin.docs}
|
||||||
|
variant="icon"
|
||||||
|
className="ml-auto size-6"
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
disabled={busy || locked}
|
disabled={busy || locked}
|
||||||
title={t("plugins.manager.uninstall")}
|
title={t("plugins.manager.uninstall")}
|
||||||
aria-label={t("plugins.manager.uninstall")}
|
aria-label={t("plugins.manager.uninstall")}
|
||||||
className="ml-auto text-muted-foreground hover:text-destructive"
|
className={cn(
|
||||||
|
"text-muted-foreground hover:text-destructive",
|
||||||
|
!plugin.docs && "ml-auto",
|
||||||
|
)}
|
||||||
onClick={() => void manager.uninstall(plugin)}
|
onClick={() => void manager.uninstall(plugin)}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-3" />
|
<Trash2 className="size-3" />
|
||||||
@@ -690,6 +701,7 @@ function BrowseCard({
|
|||||||
})
|
})
|
||||||
: t("plugins.manager.noServerAccess")}
|
: t("plugins.manager.noServerAccess")}
|
||||||
</span>
|
</span>
|
||||||
|
<DocsLink href={plugin.docs} variant="icon" className="size-6" />
|
||||||
{plugin.installed ? (
|
{plugin.installed ? (
|
||||||
<span className="flex shrink-0 items-center gap-1 text-[11px] font-medium text-accent-brand">
|
<span className="flex shrink-0 items-center gap-1 text-[11px] font-medium text-accent-brand">
|
||||||
<Check className="size-3" />
|
<Check className="size-3" />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
getCapabilityInfo,
|
getCapabilityInfo,
|
||||||
type CapabilityRisk,
|
type CapabilityRisk,
|
||||||
} from "@termix-ssh/plugin-sdk/capabilities";
|
} from "@termix-ssh/plugin-sdk/capabilities";
|
||||||
|
import type { PluginEnvVar } from "@termix-ssh/plugin-sdk/docs";
|
||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
import {
|
import {
|
||||||
CHANGE_TYPES,
|
CHANGE_TYPES,
|
||||||
@@ -36,6 +37,9 @@ export interface PluginEntry {
|
|||||||
repository?: string;
|
repository?: string;
|
||||||
videoId?: string;
|
videoId?: string;
|
||||||
features: string[];
|
features: string[];
|
||||||
|
/** Docs link: the installed manifest's, else the registry's. */
|
||||||
|
docs?: string;
|
||||||
|
env: PluginEnvVar[];
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
version: string | null;
|
version: string | null;
|
||||||
latestVersion: string | null;
|
latestVersion: string | null;
|
||||||
@@ -124,6 +128,8 @@ export function mergePlugins(
|
|||||||
features: summary.features?.length
|
features: summary.features?.length
|
||||||
? summary.features
|
? summary.features
|
||||||
: (entry?.features ?? []),
|
: (entry?.features ?? []),
|
||||||
|
docs: summary.docs ?? entry?.docs,
|
||||||
|
env: summary.env ?? [],
|
||||||
installed: true,
|
installed: true,
|
||||||
version: summary.version,
|
version: summary.version,
|
||||||
latestVersion: entry?.latestVersion ?? null,
|
latestVersion: entry?.latestVersion ?? null,
|
||||||
@@ -164,6 +170,8 @@ export function mergePlugins(
|
|||||||
repository: entry.repository,
|
repository: entry.repository,
|
||||||
videoId: entry.videoId,
|
videoId: entry.videoId,
|
||||||
features: entry.features ?? [],
|
features: entry.features ?? [],
|
||||||
|
docs: entry.docs,
|
||||||
|
env: [],
|
||||||
installed: false,
|
installed: false,
|
||||||
version: null,
|
version: null,
|
||||||
latestVersion: entry.latestVersion,
|
latestVersion: entry.latestVersion,
|
||||||
@@ -201,6 +209,7 @@ export function uploadEntry(preview: PluginUploadPreview): PluginEntry {
|
|||||||
description: preview.description,
|
description: preview.description,
|
||||||
author: preview.author,
|
author: preview.author,
|
||||||
features: preview.features ?? [],
|
features: preview.features ?? [],
|
||||||
|
env: [],
|
||||||
category: "",
|
category: "",
|
||||||
installed: preview.replaces !== null,
|
installed: preview.replaces !== null,
|
||||||
version: preview.replaces,
|
version: preview.replaces,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { SectionCard } from "@/components/section-card";
|
import { SectionCard } from "@/components/section-card";
|
||||||
|
import { DocsLink } from "@/components/docs-link";
|
||||||
import { PluginIcon } from "@/lib/plugin-icon";
|
import { PluginIcon } from "@/lib/plugin-icon";
|
||||||
import { type PluginSummary } from "@/api/plugins-api";
|
import { type PluginSummary } from "@/api/plugins-api";
|
||||||
import { useInstalledPlugins } from "@/hooks/use-installed-plugins";
|
import { useInstalledPlugins } from "@/hooks/use-installed-plugins";
|
||||||
@@ -142,6 +143,7 @@ function HostPluginSection({
|
|||||||
<SectionCard
|
<SectionCard
|
||||||
title={plugin.name}
|
title={plugin.name}
|
||||||
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
|
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
|
||||||
|
action={<DocsLink href={plugin.docs} />}
|
||||||
>
|
>
|
||||||
{!running && (
|
{!running && (
|
||||||
<div className="my-3 border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
|
<div className="my-3 border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ import {
|
|||||||
type SettingsBand,
|
type SettingsBand,
|
||||||
type SettingsPage,
|
type SettingsPage,
|
||||||
} from "./settings-pages";
|
} from "./settings-pages";
|
||||||
|
import { usePluginDocsUrl } from "@/components/docs-link";
|
||||||
|
import { docsUrl } from "@/lib/docs";
|
||||||
|
|
||||||
export interface SettingsTabProps {
|
export interface SettingsTabProps {
|
||||||
/** The page to open, from the tab's data. */
|
/** The page to open, from the tab's data. */
|
||||||
@@ -195,6 +197,13 @@ export function SettingsTab({
|
|||||||
window.setTimeout(attempt, 60);
|
window.setTimeout(attempt, 60);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pluginDocs = usePluginDocsUrl(page.pluginId);
|
||||||
|
const pageDocs = page.pluginId
|
||||||
|
? pluginDocs
|
||||||
|
: page.docs
|
||||||
|
? docsUrl(page.docs)
|
||||||
|
: null;
|
||||||
|
|
||||||
const pluginFeature = page.pluginId
|
const pluginFeature = page.pluginId
|
||||||
? {
|
? {
|
||||||
user: userFeatures.find((p) => p.id === page.pluginId),
|
user: userFeatures.find((p) => p.id === page.pluginId),
|
||||||
@@ -306,6 +315,7 @@ export function SettingsTab({
|
|||||||
title={t("nav.settings")}
|
title={t("nav.settings")}
|
||||||
status={pageLabel(page)}
|
status={pageLabel(page)}
|
||||||
scroll={false}
|
scroll={false}
|
||||||
|
docs={pageDocs}
|
||||||
actions={
|
actions={
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -21,10 +21,9 @@ import { reportBetaFeedbackUrl } from "@/lib/issue-url";
|
|||||||
import { isElectron } from "@/lib/electron";
|
import { isElectron } from "@/lib/electron";
|
||||||
import { isPrerelease } from "@/plugins/plugin-model";
|
import { isPrerelease } from "@/plugins/plugin-model";
|
||||||
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
|
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
|
||||||
|
import { docsUrl } from "@/lib/docs";
|
||||||
|
|
||||||
const DOCKER_BETA_IMAGE = "ghcr.io/termix-ssh/termix:beta";
|
const DOCKER_BETA_IMAGE = "ghcr.io/termix-ssh/termix:beta";
|
||||||
const BETA_DOCS_URL =
|
|
||||||
"https://docs.termix.site/install/server/docker#beta-builds";
|
|
||||||
|
|
||||||
function onBeta(plugin: PluginSummary): boolean {
|
function onBeta(plugin: PluginSummary): boolean {
|
||||||
return plugin.channel === "beta" || isPrerelease(plugin.version);
|
return plugin.channel === "beta" || isPrerelease(plugin.version);
|
||||||
@@ -196,7 +195,7 @@ export function UpdatesSettings() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<a
|
<a
|
||||||
href={BETA_DOCS_URL}
|
href={docsUrl("betas")}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1 text-accent-brand hover:underline"
|
className="flex items-center gap-1 text-accent-brand hover:underline"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { AdminSection } from "@/types/ui-types";
|
import type { AdminSection } from "@/types/ui-types";
|
||||||
|
import type { DocsPage } from "@/lib/docs";
|
||||||
import { SETTINGS_SEARCH_INDEX } from "./settings-search-index";
|
import { SETTINGS_SEARCH_INDEX } from "./settings-search-index";
|
||||||
|
|
||||||
export type SettingsBand = "you" | "app" | "plugins" | "instance";
|
export type SettingsBand = "you" | "app" | "plugins" | "instance";
|
||||||
@@ -45,6 +46,8 @@ export interface SettingsPage {
|
|||||||
adminSection?: AdminSection;
|
adminSection?: AdminSection;
|
||||||
/** A plugin's settings page. */
|
/** A plugin's settings page. */
|
||||||
pluginId?: string;
|
pluginId?: string;
|
||||||
|
/** The docs page for this settings page. Plugin pages use the plugin's docs. */
|
||||||
|
docs?: DocsPage;
|
||||||
/** i18n keys whose text search matches. */
|
/** i18n keys whose text search matches. */
|
||||||
searchKeys: string[];
|
searchKeys: string[];
|
||||||
}
|
}
|
||||||
@@ -61,6 +64,7 @@ function core(
|
|||||||
export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
||||||
core({
|
core({
|
||||||
id: "account",
|
id: "account",
|
||||||
|
docs: "account",
|
||||||
band: "you",
|
band: "you",
|
||||||
labelKey: "settings.account",
|
labelKey: "settings.account",
|
||||||
icon: User,
|
icon: User,
|
||||||
@@ -68,6 +72,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
|||||||
}),
|
}),
|
||||||
core({
|
core({
|
||||||
id: "security",
|
id: "security",
|
||||||
|
docs: "accountSecurity",
|
||||||
band: "you",
|
band: "you",
|
||||||
labelKey: "settings.security",
|
labelKey: "settings.security",
|
||||||
icon: Shield,
|
icon: Shield,
|
||||||
@@ -76,6 +81,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
|||||||
}),
|
}),
|
||||||
core({
|
core({
|
||||||
id: "api-keys",
|
id: "api-keys",
|
||||||
|
docs: "apiKeys",
|
||||||
band: "you",
|
band: "you",
|
||||||
labelKey: "settings.apiKeys",
|
labelKey: "settings.apiKeys",
|
||||||
icon: KeyRound,
|
icon: KeyRound,
|
||||||
@@ -83,6 +89,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
|||||||
}),
|
}),
|
||||||
core({
|
core({
|
||||||
id: "data",
|
id: "data",
|
||||||
|
docs: "accountData",
|
||||||
band: "you",
|
band: "you",
|
||||||
labelKey: "settings.data",
|
labelKey: "settings.data",
|
||||||
icon: Database,
|
icon: Database,
|
||||||
@@ -90,6 +97,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
|||||||
}),
|
}),
|
||||||
core({
|
core({
|
||||||
id: "interface",
|
id: "interface",
|
||||||
|
docs: "interfaceSettings",
|
||||||
band: "app",
|
band: "app",
|
||||||
labelKey: "settings.interface",
|
labelKey: "settings.interface",
|
||||||
icon: SlidersHorizontal,
|
icon: SlidersHorizontal,
|
||||||
@@ -97,6 +105,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
|||||||
}),
|
}),
|
||||||
core({
|
core({
|
||||||
id: "appearance",
|
id: "appearance",
|
||||||
|
docs: "appearance",
|
||||||
band: "app",
|
band: "app",
|
||||||
labelKey: "settings.appearance",
|
labelKey: "settings.appearance",
|
||||||
icon: Palette,
|
icon: Palette,
|
||||||
@@ -104,6 +113,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
|||||||
}),
|
}),
|
||||||
core({
|
core({
|
||||||
id: "keybindings",
|
id: "keybindings",
|
||||||
|
docs: "keybindings",
|
||||||
band: "app",
|
band: "app",
|
||||||
labelKey: "settings.keybindings",
|
labelKey: "settings.keybindings",
|
||||||
icon: Keyboard,
|
icon: Keyboard,
|
||||||
@@ -111,6 +121,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
|||||||
}),
|
}),
|
||||||
core({
|
core({
|
||||||
id: "admin-general",
|
id: "admin-general",
|
||||||
|
docs: "adminGeneral",
|
||||||
band: "instance",
|
band: "instance",
|
||||||
labelKey: "settings.adminGeneral",
|
labelKey: "settings.adminGeneral",
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
@@ -119,6 +130,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
|||||||
}),
|
}),
|
||||||
core({
|
core({
|
||||||
id: "updates",
|
id: "updates",
|
||||||
|
docs: "updating",
|
||||||
band: "instance",
|
band: "instance",
|
||||||
labelKey: "settings.updates.title",
|
labelKey: "settings.updates.title",
|
||||||
icon: FlaskConical,
|
icon: FlaskConical,
|
||||||
@@ -126,6 +138,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
|||||||
}),
|
}),
|
||||||
core({
|
core({
|
||||||
id: "admin-users",
|
id: "admin-users",
|
||||||
|
docs: "users",
|
||||||
band: "instance",
|
band: "instance",
|
||||||
labelKey: "settings.adminUsers",
|
labelKey: "settings.adminUsers",
|
||||||
icon: Users,
|
icon: Users,
|
||||||
@@ -134,6 +147,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
|||||||
}),
|
}),
|
||||||
core({
|
core({
|
||||||
id: "admin-sessions",
|
id: "admin-sessions",
|
||||||
|
docs: "sessions",
|
||||||
band: "instance",
|
band: "instance",
|
||||||
labelKey: "settings.adminSessions",
|
labelKey: "settings.adminSessions",
|
||||||
icon: Monitor,
|
icon: Monitor,
|
||||||
@@ -142,6 +156,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
|||||||
}),
|
}),
|
||||||
core({
|
core({
|
||||||
id: "admin-roles",
|
id: "admin-roles",
|
||||||
|
docs: "roles",
|
||||||
band: "instance",
|
band: "instance",
|
||||||
labelKey: "settings.adminRoles",
|
labelKey: "settings.adminRoles",
|
||||||
icon: Lock,
|
icon: Lock,
|
||||||
@@ -150,6 +165,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
|||||||
}),
|
}),
|
||||||
core({
|
core({
|
||||||
id: "admin-host-defaults",
|
id: "admin-host-defaults",
|
||||||
|
docs: "hostDefaults",
|
||||||
band: "instance",
|
band: "instance",
|
||||||
labelKey: "settings.adminHostDefaults",
|
labelKey: "settings.adminHostDefaults",
|
||||||
icon: Server,
|
icon: Server,
|
||||||
@@ -158,6 +174,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
|||||||
}),
|
}),
|
||||||
core({
|
core({
|
||||||
id: "admin-branding",
|
id: "admin-branding",
|
||||||
|
docs: "branding",
|
||||||
band: "instance",
|
band: "instance",
|
||||||
labelKey: "settings.adminBranding",
|
labelKey: "settings.adminBranding",
|
||||||
icon: Palette,
|
icon: Palette,
|
||||||
@@ -166,6 +183,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
|||||||
}),
|
}),
|
||||||
core({
|
core({
|
||||||
id: "admin-database",
|
id: "admin-database",
|
||||||
|
docs: "adminDatabase",
|
||||||
band: "instance",
|
band: "instance",
|
||||||
labelKey: "settings.adminDatabase",
|
labelKey: "settings.adminDatabase",
|
||||||
icon: Database,
|
icon: Database,
|
||||||
@@ -174,6 +192,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
|||||||
}),
|
}),
|
||||||
core({
|
core({
|
||||||
id: "admin-ssl",
|
id: "admin-ssl",
|
||||||
|
docs: "adminSsl",
|
||||||
band: "instance",
|
band: "instance",
|
||||||
labelKey: "settings.adminSsl",
|
labelKey: "settings.adminSsl",
|
||||||
icon: Globe,
|
icon: Globe,
|
||||||
@@ -182,6 +201,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
|||||||
}),
|
}),
|
||||||
core({
|
core({
|
||||||
id: "admin-api-keys",
|
id: "admin-api-keys",
|
||||||
|
docs: "apiKeys",
|
||||||
band: "instance",
|
band: "instance",
|
||||||
labelKey: "settings.adminApiKeys",
|
labelKey: "settings.adminApiKeys",
|
||||||
icon: KeyRound,
|
icon: KeyRound,
|
||||||
@@ -190,6 +210,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
|||||||
}),
|
}),
|
||||||
core({
|
core({
|
||||||
id: "admin-audit-log",
|
id: "admin-audit-log",
|
||||||
|
docs: "auditLog",
|
||||||
band: "instance",
|
band: "instance",
|
||||||
labelKey: "settings.adminAuditLog",
|
labelKey: "settings.adminAuditLog",
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
getSavedServerUrls,
|
getSavedServerUrls,
|
||||||
rememberServerUrl,
|
rememberServerUrl,
|
||||||
} from "./saved-servers";
|
} from "./saved-servers";
|
||||||
|
import { DocsLink } from "@/components/docs-link";
|
||||||
|
|
||||||
type Step = "server" | "signin" | "choose" | "finishing";
|
type Step = "server" | "signin" | "choose" | "finishing";
|
||||||
|
|
||||||
@@ -454,7 +455,9 @@ export function LinkServerDialog({
|
|||||||
scroll={!wide}
|
scroll={!wide}
|
||||||
>
|
>
|
||||||
{!wide && (
|
{!wide && (
|
||||||
<p className="text-xs text-muted-foreground">{description}</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{description} {step === "server" && <DocsLink core="desktopSync" />}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
{content}
|
{content}
|
||||||
</InlineView>
|
</InlineView>
|
||||||
@@ -474,7 +477,9 @@ export function LinkServerDialog({
|
|||||||
>
|
>
|
||||||
<DialogHeader className={wide ? "sr-only" : undefined}>
|
<DialogHeader className={wide ? "sr-only" : undefined}>
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
<DialogDescription>{description}</DialogDescription>
|
<DialogDescription>
|
||||||
|
{description} {step === "server" && <DocsLink core="desktopSync" />}
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{content}
|
{content}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
} from "@/components/command";
|
} from "@/components/command";
|
||||||
import {
|
import {
|
||||||
|
BookOpen,
|
||||||
Bug,
|
Bug,
|
||||||
Clock,
|
Clock,
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
@@ -48,6 +49,7 @@ import { reportBetaFeedbackUrl, reportCoreIssueUrl } from "@/lib/issue-url";
|
|||||||
import { isPrerelease } from "@/plugins/plugin-model";
|
import { isPrerelease } from "@/plugins/plugin-model";
|
||||||
import { shell } from "@/plugin-host/shell-bridge";
|
import { shell } from "@/plugin-host/shell-bridge";
|
||||||
import { getLiveHostStatus } from "@/lib/ServerStatusContext";
|
import { getLiveHostStatus } from "@/lib/ServerStatusContext";
|
||||||
|
import { docsUrl } from "@/lib/docs";
|
||||||
|
|
||||||
interface CommandPaletteProps {
|
interface CommandPaletteProps {
|
||||||
/** Opens settings, optionally at one section. */
|
/** Opens settings, optionally at one section. */
|
||||||
@@ -218,6 +220,12 @@ export function CommandPalette({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
|
{
|
||||||
|
id: "link-docs",
|
||||||
|
label: t("commandPalette.documentation"),
|
||||||
|
icon: BookOpen,
|
||||||
|
url: docsUrl(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "link-github",
|
id: "link-github",
|
||||||
label: "GitHub",
|
label: "GitHub",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { toast } from "sonner";
|
|||||||
import { AccordionSection } from "./AdminSettingsShared";
|
import { AccordionSection } from "./AdminSettingsShared";
|
||||||
import type { AdminUser } from "./AdminManagementSections";
|
import type { AdminUser } from "./AdminManagementSections";
|
||||||
import { Select2 } from "@/components/select2";
|
import { Select2 } from "@/components/select2";
|
||||||
|
import { DocsLink } from "@/components/docs-link";
|
||||||
|
|
||||||
type AdminApiKeysSectionProps = {
|
type AdminApiKeysSectionProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -66,24 +67,10 @@ export function AdminApiKeysSection({
|
|||||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||||
<span className="text-[10px] text-muted-foreground">
|
<span className="text-[10px] text-muted-foreground">
|
||||||
{t("admin.apiKeysCount", { count: apiKeys.length })}{" "}
|
{t("admin.apiKeysCount", { count: apiKeys.length })}{" "}
|
||||||
<a
|
<DocsLink core="apiKeys">{t("admin.apiKeysDocsLink")}</DocsLink>
|
||||||
href="https://docs.termix.site/features/api/api-keys"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-accent-brand hover:underline"
|
|
||||||
>
|
|
||||||
{t("admin.apiKeysDocsLink")}
|
|
||||||
</a>
|
|
||||||
<br />
|
<br />
|
||||||
{t("admin.apiKeysCliHint")}{" "}
|
{t("admin.apiKeysCliHint")}{" "}
|
||||||
<a
|
<DocsLink core="cli">{t("admin.apiKeysCliLink")}</DocsLink>
|
||||||
href="https://docs.termix.site/cli"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-accent-brand hover:underline"
|
|
||||||
>
|
|
||||||
{t("admin.apiKeysCliLink")}
|
|
||||||
</a>
|
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
|
|||||||
import { AdminHostTags } from "./AdminHostTags";
|
import { AdminHostTags } from "./AdminHostTags";
|
||||||
import { AdminPluginDeveloperMode } from "./AdminPluginDeveloperMode";
|
import { AdminPluginDeveloperMode } from "./AdminPluginDeveloperMode";
|
||||||
import type { TlsStatus } from "@/api/tls-api";
|
import type { TlsStatus } from "@/api/tls-api";
|
||||||
|
import { DocsLink } from "@/components/docs-link";
|
||||||
|
|
||||||
type GeneralSettingsSectionProps = {
|
type GeneralSettingsSectionProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -423,14 +424,7 @@ export function AdminSSLSection({
|
|||||||
<div className="flex flex-col gap-3 pt-3">
|
<div className="flex flex-col gap-3 pt-3">
|
||||||
<span className="text-[10px] text-muted-foreground">
|
<span className="text-[10px] text-muted-foreground">
|
||||||
{t("admin.sslDescription")}{" "}
|
{t("admin.sslDescription")}{" "}
|
||||||
<a
|
<DocsLink core="https">{t("admin.sslDocsLink")}</DocsLink>
|
||||||
href="https://docs.termix.site/features/networking/ssl"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-accent-brand hover:underline"
|
|
||||||
>
|
|
||||||
{t("admin.sslDocsLink")}
|
|
||||||
</a>
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className="flex flex-col gap-0.5 p-2 border border-border bg-background/50">
|
<div className="flex flex-col gap-0.5 p-2 border border-border bg-background/50">
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import type { AdminSession, AdminUser } from "./AdminManagementSections";
|
|||||||
import { TabStrip } from "./HostManagerTabs";
|
import { TabStrip } from "./HostManagerTabs";
|
||||||
import { BackButton, PanelShell } from "@/components/panel-layout";
|
import { BackButton, PanelShell } from "@/components/panel-layout";
|
||||||
import { useActionSlot } from "@/hooks/use-action-slot";
|
import { useActionSlot } from "@/hooks/use-action-slot";
|
||||||
|
import { docsUrl } from "@/lib/docs";
|
||||||
|
|
||||||
type ApiErrorLike = {
|
type ApiErrorLike = {
|
||||||
response?: {
|
response?: {
|
||||||
@@ -338,6 +339,7 @@ export function AdminUserManagePanel({
|
|||||||
<PanelShell
|
<PanelShell
|
||||||
leading={<BackButton onClick={close} />}
|
leading={<BackButton onClick={close} />}
|
||||||
title={t("admin.manageEditorBack", { username: user.username })}
|
title={t("admin.manageEditorBack", { username: user.username })}
|
||||||
|
docs={docsUrl("users")}
|
||||||
status={
|
status={
|
||||||
editor.kind === "host"
|
editor.kind === "host"
|
||||||
? (editor.host?.name ?? t("manage.newHost"))
|
? (editor.host?.name ?? t("manage.newHost"))
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
import { getUserList, type AccessRecord } from "@/main-axios";
|
import { getUserList, type AccessRecord } from "@/main-axios";
|
||||||
import type { Credential } from "@/types/ui-types";
|
import type { Credential } from "@/types/ui-types";
|
||||||
import { getErrorMessage } from "@/lib/error-message";
|
import { getErrorMessage } from "@/lib/error-message";
|
||||||
|
import { DocsLink } from "@/components/docs-link";
|
||||||
|
|
||||||
const PERMISSION_LEVELS: CredentialPermissionLevel[] = ["use", "manage"];
|
const PERMISSION_LEVELS: CredentialPermissionLevel[] = ["use", "manage"];
|
||||||
|
|
||||||
@@ -268,14 +269,7 @@ export function CredentialShareModal({
|
|||||||
<Users className="size-3.5" />
|
<Users className="size-3.5" />
|
||||||
{t("hosts.sharing.shareWithSection")}
|
{t("hosts.sharing.shareWithSection")}
|
||||||
</div>
|
</div>
|
||||||
<a
|
<DocsLink core="sharing">{t("hosts.docsLink")}</DocsLink>
|
||||||
href="https://docs.termix.site/features/authentication/rbac"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-[10px] text-accent-brand hover:underline shrink-0"
|
|
||||||
>
|
|
||||||
{t("hosts.docsLink")}
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import {
|
import {
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
Check,
|
Check,
|
||||||
ExternalLink,
|
|
||||||
Filter,
|
Filter,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -29,6 +28,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/dropdown-menu";
|
} from "@/components/dropdown-menu";
|
||||||
import type { CredentialSortKey } from "@/types/credential-sidebar-preferences";
|
import type { CredentialSortKey } from "@/types/credential-sidebar-preferences";
|
||||||
|
import { DocsLink } from "@/components/docs-link";
|
||||||
|
|
||||||
export function CredentialsPanel({
|
export function CredentialsPanel({
|
||||||
active: _active = true,
|
active: _active = true,
|
||||||
@@ -269,15 +269,7 @@ export function CredentialsPanel({
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<div className="w-px self-stretch bg-border" />
|
<div className="w-px self-stretch bg-border" />
|
||||||
<a
|
<DocsLink core="credentials" variant="icon" />
|
||||||
href="https://docs.termix.site/features/files-and-hosts/credentials"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
title={t("hosts.docsLink")}
|
|
||||||
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground shrink-0 transition-colors"
|
|
||||||
>
|
|
||||||
<ExternalLink className="size-3.5" />
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center border border-border shrink-0">
|
<div className="flex items-center border border-border shrink-0">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
HostDefaultBadge,
|
HostDefaultBadge,
|
||||||
HostOnly,
|
HostOnly,
|
||||||
} from "@/lib/host-defaults-context";
|
} from "@/lib/host-defaults-context";
|
||||||
|
import { DocsLink } from "@/components/docs-link";
|
||||||
|
|
||||||
type HostEditorSetField = <K extends keyof HostEditorForm>(
|
type HostEditorSetField = <K extends keyof HostEditorForm>(
|
||||||
key: K,
|
key: K,
|
||||||
@@ -402,14 +403,7 @@ export function HostEditorGeneralTab({
|
|||||||
{t("hosts.portKnockingSequence")}
|
{t("hosts.portKnockingSequence")}
|
||||||
</span>
|
</span>
|
||||||
<HostDefaultBadge settingKey="core.portKnockSequence" />
|
<HostDefaultBadge settingKey="core.portKnockSequence" />
|
||||||
<a
|
<DocsLink core="portKnocking">{t("hosts.docsLink")}</DocsLink>
|
||||||
href="https://docs.termix.site/features/networking/port-knocking"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-[10px] text-accent-brand hover:underline"
|
|
||||||
>
|
|
||||||
{t("hosts.docsLink")}
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
} from "@/main-axios";
|
} from "@/main-axios";
|
||||||
import type { Host } from "@/types/ui-types";
|
import type { Host } from "@/types/ui-types";
|
||||||
import { Select2 } from "@/components/select2";
|
import { Select2 } from "@/components/select2";
|
||||||
|
import { DocsLink } from "@/components/docs-link";
|
||||||
|
|
||||||
const PERMISSION_LEVELS: SharePermissionLevel[] = [
|
const PERMISSION_LEVELS: SharePermissionLevel[] = [
|
||||||
"connect",
|
"connect",
|
||||||
@@ -307,14 +308,7 @@ export function HostShareModal({
|
|||||||
<Users className="size-3.5" />
|
<Users className="size-3.5" />
|
||||||
{t("hosts.sharing.shareWithSection")}
|
{t("hosts.sharing.shareWithSection")}
|
||||||
</div>
|
</div>
|
||||||
<a
|
<DocsLink core="sharing">{t("hosts.docsLink")}</DocsLink>
|
||||||
href="https://docs.termix.site/features/authentication/rbac"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-[10px] text-accent-brand hover:underline shrink-0"
|
|
||||||
>
|
|
||||||
{t("hosts.docsLink")}
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ import {
|
|||||||
useFeatureSettings,
|
useFeatureSettings,
|
||||||
type FeatureSectionId,
|
type FeatureSectionId,
|
||||||
} from "@/settings/FeatureSettingsSections";
|
} from "@/settings/FeatureSettingsSections";
|
||||||
|
import { docsUrl } from "@/lib/docs";
|
||||||
|
|
||||||
type UserProfileSection =
|
type UserProfileSection =
|
||||||
| "account"
|
| "account"
|
||||||
@@ -1345,7 +1346,7 @@ export function UserProfilePanel({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href="https://docs.termix.site/cli"
|
href={docsUrl("cli")}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="shrink-0 ml-3 flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest border border-border px-2 py-1.5 hover:bg-muted/40 transition-colors"
|
className="shrink-0 ml-3 flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest border border-border px-2 py-1.5 hover:bg-muted/40 transition-colors"
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { DOCS_PAGES, DOCS_URL, docsUrl } from "@/lib/docs";
|
||||||
|
|
||||||
|
describe("docsUrl", () => {
|
||||||
|
it("builds full links to core docs pages", () => {
|
||||||
|
expect(docsUrl()).toBe(`${DOCS_URL}/`);
|
||||||
|
expect(docsUrl("hosts")).toBe(`${DOCS_URL}/guide/hosts`);
|
||||||
|
expect(docsUrl("portKnocking")).toBe(
|
||||||
|
`${DOCS_URL}/guide/hosts#port-knocking`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps every page a relative path", () => {
|
||||||
|
for (const path of Object.values(DOCS_PAGES)) {
|
||||||
|
expect(path).not.toMatch(/^\/|^https?:/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -108,6 +108,23 @@ describe("mergePlugins", () => {
|
|||||||
expect(mergePlugins([summary()], null)[0].features).toEqual([]);
|
expect(mergePlugins([summary()], null)[0].features).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses the installed docs link and falls back to the registry", () => {
|
||||||
|
const registry = entry({ docs: "https://docs.example.com/registry" });
|
||||||
|
expect(
|
||||||
|
mergePlugins(
|
||||||
|
[summary({ docs: "https://docs.example.com/installed" })],
|
||||||
|
[registry],
|
||||||
|
)[0].docs,
|
||||||
|
).toBe("https://docs.example.com/installed");
|
||||||
|
expect(mergePlugins([summary()], [registry])[0].docs).toBe(
|
||||||
|
"https://docs.example.com/registry",
|
||||||
|
);
|
||||||
|
expect(mergePlugins([summary()], null)[0].docs).toBeUndefined();
|
||||||
|
expect(mergePlugins([], [registry])[0].docs).toBe(
|
||||||
|
"https://docs.example.com/registry",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps installed plugins when the registry is unreachable", () => {
|
it("keeps installed plugins when the registry is unreachable", () => {
|
||||||
const rows = mergePlugins([summary()], null);
|
const rows = mergePlugins([summary()], null);
|
||||||
expect(rows).toHaveLength(1);
|
expect(rows).toHaveLength(1);
|
||||||
|
|||||||
Reference in new issue
Block a user