From cfab6f764c5b14a6faee5007d79a5845c33a9847 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Thu, 8 Oct 2026 00:34:50 -0500 Subject: [PATCH] feat: plugin docs and env manifest fields, in-app docs links, SDK 1.0.3 --- .github/workflows/release.yml | 12 + CHANGELOG.md | 2 + docker/docker-compose.yml | 2 +- env.catalog.json | 460 ++++++++++++++++++ package-lock.json | 37 +- package.json | 4 +- packages/plugin-sdk/CHANGELOG.md | 14 + packages/plugin-sdk/README.md | 5 +- packages/plugin-sdk/cli/commands/build.mjs | 2 + packages/plugin-sdk/cli/commands/dev.mjs | 2 +- packages/plugin-sdk/cli/commands/openapi.mjs | 39 ++ packages/plugin-sdk/cli/commands/validate.mjs | 67 +++ packages/plugin-sdk/cli/index.mjs | 3 + packages/plugin-sdk/package.json | 11 +- .../plugin-sdk/schema/manifest.schema.json | 21 + packages/plugin-sdk/src/docs.ts | 60 +++ packages/plugin-sdk/src/frontend.ts | 26 + packages/plugin-sdk/src/manifest.ts | 77 ++- packages/plugin-sdk/src/openapi.ts | 87 ++++ packages/plugin-sdk/src/testing/setup.ts | 1 + packages/plugin-sdk/ui.api.txt | 4 +- scripts/check-env-catalog.cjs | 74 +++ scripts/check-env-catalog.test.ts | 56 +++ scripts/check-sdk-ui-exports.cjs | 2 + scripts/generate-openapi.mjs | 32 ++ src/backend/database/routes/plugins.ts | 16 +- src/backend/plugins/manage.ts | 2 + src/backend/plugins/manifest.ts | 3 + src/backend/plugins/registry-index.ts | 4 + src/backend/tests/plugins/manifest.test.ts | 73 +++ .../tests/plugins/registry-index.test.ts | 17 + src/backend/tests/plugins/sdk-openapi.test.ts | 67 +++ src/backend/utils/openapi-tags.json | 32 ++ src/backend/utils/swagger.ts | 176 ------- src/ui/api/plugins-api.ts | 6 + src/ui/components/docs-link.tsx | 99 ++++ src/ui/components/panel-layout.tsx | 11 +- src/ui/dashboard/DashboardTab.tsx | 4 +- src/ui/lib/docs-pages.json | 41 ++ src/ui/lib/docs.ts | 17 + src/ui/locales/en.json | 15 +- src/ui/manage/ManageTab.tsx | 8 + src/ui/onboarding/steps/PluginsStep.tsx | 6 +- src/ui/plugin-host/app.ts | 17 + src/ui/plugin-host/bridge.ts | 5 + src/ui/plugin-host/sdk-ui.ts | 2 + src/ui/plugins/PluginDetail.tsx | 49 ++ src/ui/plugins/PluginsTab.tsx | 14 +- src/ui/plugins/plugin-model.ts | 9 + src/ui/settings/HostPluginSections.tsx | 2 + src/ui/settings/SettingsTab.tsx | 10 + src/ui/settings/UpdatesSettings.tsx | 5 +- src/ui/settings/settings-pages.ts | 21 + src/ui/settings/sync/LinkServerDialog.tsx | 9 +- src/ui/shell/CommandPalette.tsx | 8 + src/ui/sidebar/AdminApiKeysSection.tsx | 19 +- src/ui/sidebar/AdminSettingsSections.tsx | 10 +- src/ui/sidebar/AdminUserManagePanel.tsx | 2 + src/ui/sidebar/CredentialShareModal.tsx | 10 +- src/ui/sidebar/CredentialsPanel.tsx | 12 +- src/ui/sidebar/HostEditorGeneralTab.tsx | 10 +- src/ui/sidebar/HostShareModal.tsx | 10 +- src/ui/sidebar/UserProfilePanel.tsx | 3 +- src/ui/tests/lib/docs.test.ts | 18 + src/ui/tests/plugins/plugin-model.test.ts | 17 + 65 files changed, 1665 insertions(+), 294 deletions(-) create mode 100644 env.catalog.json create mode 100644 packages/plugin-sdk/cli/commands/openapi.mjs create mode 100644 packages/plugin-sdk/src/docs.ts create mode 100644 packages/plugin-sdk/src/openapi.ts create mode 100644 scripts/check-env-catalog.cjs create mode 100644 scripts/check-env-catalog.test.ts create mode 100644 scripts/generate-openapi.mjs create mode 100644 src/backend/tests/plugins/sdk-openapi.test.ts create mode 100644 src/backend/utils/openapi-tags.json delete mode 100644 src/backend/utils/swagger.ts create mode 100644 src/ui/components/docs-link.tsx create mode 100644 src/ui/lib/docs-pages.json create mode 100644 src/ui/lib/docs.ts create mode 100644 src/ui/tests/lib/docs.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 42d5741d2..a0a5bab22 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -591,6 +591,18 @@ jobs: gh release delete "$TAG" --repo ${{ github.repository }} --yes || true 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: 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' }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 84fcc1645..b64a51306 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Added - 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 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index dd671bcd2..601ae1808 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,6 +1,6 @@ services: termix: - image: ghcr.io/lukegus/termix:latest + image: ghcr.io/termix-ssh/termix:latest container_name: termix restart: unless-stopped ports: diff --git a/env.catalog.json b/env.catalog.json new file mode 100644 index 000000000..4b7d0ed28 --- /dev/null +++ b/env.catalog.json @@ -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." + } + ] +} diff --git a/package-lock.json b/package-lock.json index a7f402f21..a1023ee6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -142,7 +142,6 @@ "version": "14.0.1", "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz", "integrity": "sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==", - "dev": true, "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.15", @@ -159,7 +158,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -169,14 +167,12 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", - "dev": true, "license": "MIT" }, "node_modules/@apidevtools/swagger-parser": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-12.1.0.tgz", "integrity": "sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==", - "dev": true, "license": "MIT", "dependencies": { "@apidevtools/json-schema-ref-parser": "14.0.1", @@ -1093,7 +1089,6 @@ "version": "8.2.0", "resolved": "https://registry.npmjs.org/@deadendjs/swagger-jsdoc/-/swagger-jsdoc-8.2.0.tgz", "integrity": "sha512-KwySDId/J0C/dlMGd77H3IN3nL7/HYVTerFp6DBhRnIY7RsbGGz30DBIyAhzhLDdqStW4wQHMP+/3qX8BQ1wTg==", - "dev": true, "license": "MIT", "dependencies": { "@apidevtools/swagger-parser": "^12.1.0", @@ -2702,7 +2697,6 @@ "version": "9.0.0", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=18" @@ -6236,7 +6230,6 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, "license": "MIT" }, "node_modules/@types/jsonwebtoken": { @@ -7001,7 +6994,6 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -7018,7 +7010,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", - "dev": true, "license": "MIT", "peerDependencies": { "ajv": "^8.5.0" @@ -7218,7 +7209,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/argue-cli": { @@ -7391,7 +7381,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, "license": "MIT", "engines": { "node": "18 || 20 || >=22" @@ -7506,7 +7495,6 @@ "version": "5.0.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -7748,7 +7736,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", - "dev": true, "license": "MIT" }, "node_modules/callsites": { @@ -7974,7 +7961,6 @@ "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -8272,7 +8258,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -9578,7 +9563,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -9606,7 +9590,6 @@ "version": "3.1.8", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", - "dev": true, "funding": [ { "type": "github", @@ -9775,7 +9758,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -10038,7 +10020,6 @@ "version": "12.0.0", "resolved": "https://registry.npmjs.org/glob/-/glob-12.0.0.tgz", "integrity": "sha512-5Qcll1z7IKgHr5g485ePDdHcNQY0k2dtv/bjYy0iuyGxQw2qSOiiXUXJ+AYQpg3HNoUMHqAruX478Jeev7UULw==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "foreground-child": "^3.3.1", @@ -10584,7 +10565,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -10643,7 +10623,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^9.0.0" @@ -10700,7 +10679,6 @@ "version": "4.3.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", - "dev": true, "funding": [ { "type": "github", @@ -10799,7 +10777,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -11490,7 +11467,6 @@ "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.8" @@ -12004,7 +11980,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, "license": "BlueOak-1.0.0" }, "node_modules/parent-module": { @@ -12075,7 +12050,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12085,7 +12059,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", @@ -12102,7 +12075,6 @@ "version": "11.5.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -12905,7 +12877,6 @@ "version": "2.0.2", "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==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -13236,7 +13207,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -13249,7 +13219,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -13338,7 +13307,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -14805,7 +14773,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -14977,7 +14944,6 @@ "version": "2.0.0-4", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-4.tgz", "integrity": "sha512-MoQoNhTFI400tkaeod+X0Vety1KD2L9dUa6pa1CVcyfcATjC/iDxoMLvqZ6U3D8c5KzxBrU2HnJH+PfaXOqI7w==", - "dev": true, "license": "ISC", "engines": { "node": ">= 10" @@ -15049,9 +15015,10 @@ }, "packages/plugin-sdk": { "name": "@termix-ssh/plugin-sdk", - "version": "1.0.2", + "version": "1.1.0", "license": "Apache-2.0", "dependencies": { + "@deadendjs/swagger-jsdoc": "^8.1.2", "@tailwindcss/node": "^4.2.4", "@tailwindcss/oxide": "^4.2.4", "esbuild": "^0.28.1", diff --git a/package.json b/package.json index eb95f8827..f08ea8fd9 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs", "prepare": "husky || true", "prebuild": "node scripts/write-electron-build-info.cjs", - "lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/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 .", "type-check": "npm run build:sdk && tsc -b --force", "test": "vitest run", @@ -32,7 +32,7 @@ "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: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", "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", diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index 8daec323e..fa28c31cf 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -1,5 +1,19 @@ # 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 ### Added diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md index 2d8a0c2f5..d8f259a03 100644 --- a/packages/plugin-sdk/README.md +++ b/packages/plugin-sdk/README.md @@ -12,7 +12,7 @@ ## 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).
@@ -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 test # run the plugin's Vitest suite 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 pack # write -.tmxplug npx termix-plugin changelog # print this version's notes from CHANGELOG.md @@ -39,7 +40,7 @@ npx termix-plugin keygen --out # make a new signing key pair npx termix-plugin dev --server --key # 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 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. diff --git a/packages/plugin-sdk/cli/commands/build.mjs b/packages/plugin-sdk/cli/commands/build.mjs index 7a124268f..84de515b9 100644 --- a/packages/plugin-sdk/cli/commands/build.mjs +++ b/packages/plugin-sdk/cli/commands/build.mjs @@ -6,6 +6,7 @@ import * as esbuild from "esbuild"; import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs"; import { staticUrlImports } from "../lib/static-url-imports.mjs"; import { readManifest, resolveEntry, copyDir } from "../lib/plugin-dir.mjs"; +import { openapi } from "./openapi.mjs"; const BACKEND_ENTRIES = [ "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, "migrations"), path.join(outDir, "migrations")); + await openapi({ cwd, quiet: true }); console.log(`built ${pluginId}`); } diff --git a/packages/plugin-sdk/cli/commands/dev.mjs b/packages/plugin-sdk/cli/commands/dev.mjs index fd08ca43f..97bbd9580 100644 --- a/packages/plugin-sdk/cli/commands/dev.mjs +++ b/packages/plugin-sdk/cli/commands/dev.mjs @@ -42,7 +42,7 @@ export function resolveDevOptions(args, env = process.env) { const HINTS = { 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: "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.", diff --git a/packages/plugin-sdk/cli/commands/openapi.mjs b/packages/plugin-sdk/cli/commands/openapi.mjs new file mode 100644 index 000000000..5ac9c0cfb --- /dev/null +++ b/packages/plugin-sdk/cli/commands/openapi.mjs @@ -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; +} diff --git a/packages/plugin-sdk/cli/commands/validate.mjs b/packages/plugin-sdk/cli/commands/validate.mjs index 223156978..c728927c3 100644 --- a/packages/plugin-sdk/cli/commands/validate.mjs +++ b/packages/plugin-sdk/cli/commands/validate.mjs @@ -41,6 +41,10 @@ export async function validate({ cwd }) { problems.push(...validatePackage(cwd, raw)); problems.push(...(await validateChangelogFile(cwd, raw))); + for (const warning of docsWarnings(cwd, raw)) { + console.warn(` warn ${warning}`); + } + if (problems.length > 0) { for (const problem of problems) console.error(` ${problem}`); 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)}`); } +/** 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 * SDK range rather than "*", so a plugin built against a newer SDK says so. diff --git a/packages/plugin-sdk/cli/index.mjs b/packages/plugin-sdk/cli/index.mjs index c5fe1ac6a..9a1286130 100755 --- a/packages/plugin-sdk/cli/index.mjs +++ b/packages/plugin-sdk/cli/index.mjs @@ -15,6 +15,7 @@ import { sign, verify, keygen } from "./commands/sign.mjs"; import { migrations } from "./commands/migrations.mjs"; import { dev } from "./commands/dev.mjs"; import { changelog } from "./commands/changelog.mjs"; +import { openapi } from "./commands/openapi.mjs"; const COMMANDS = { build, @@ -32,6 +33,7 @@ const COMMANDS = { migrations, dev, changelog, + openapi, }; const [command, ...args] = process.argv.slice(2); @@ -50,6 +52,7 @@ if (!command || command === "--help" || command === "-h") { " verify Check a .tmxplug's .sig --key [,...]", " keygen Write a new signing key pair [--out dir]", " 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]", " dev Build, pack and install on a running server, again on every change", " [--server url] [--key tmx_...] [--once]", diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index ea73910b0..d1305bba2 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "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.", "license": "Apache-2.0", "repository": { @@ -53,6 +53,14 @@ "types": "./dist/changelog.d.ts", "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": { "types": "./dist/settings.d.ts", "default": "./dist/settings.js" @@ -102,6 +110,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { + "@deadendjs/swagger-jsdoc": "^8.1.2", "@tailwindcss/node": "^4.2.4", "@tailwindcss/oxide": "^4.2.4", "esbuild": "^0.28.1", diff --git a/packages/plugin-sdk/schema/manifest.schema.json b/packages/plugin-sdk/schema/manifest.schema.json index 74be31590..4ef59dd72 100644 --- a/packages/plugin-sdk/schema/manifest.schema.json +++ b/packages/plugin-sdk/schema/manifest.schema.json @@ -85,6 +85,27 @@ "items": { "type": "string", "minLength": 1, "maxLength": 160 }, "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": { "type": "object", "additionalProperties": false, diff --git a/packages/plugin-sdk/src/docs.ts b/packages/plugin-sdk/src/docs.ts new file mode 100644 index 000000000..8f6ef7d6d --- /dev/null +++ b/packages/plugin-sdk/src/docs.ts @@ -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", + ); +} diff --git a/packages/plugin-sdk/src/frontend.ts b/packages/plugin-sdk/src/frontend.ts index 29bccee19..d7995dccd 100644 --- a/packages/plugin-sdk/src/frontend.ts +++ b/packages/plugin-sdk/src/frontend.ts @@ -888,6 +888,16 @@ export interface DesktopApi { 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 { readonly pluginId: string; readonly manifest: PluginManifest; @@ -1017,6 +1027,8 @@ export interface TermixApp extends TermixAppInfo { confirm: (options: ConfirmRequest) => Promise; /** The desktop app, when the frontend runs in it. */ 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 * the host editor. Disposed automatically. @@ -1091,6 +1103,11 @@ export interface SshAuthTypeInfo { */ export interface PluginHostBridge { usePluginId: () => string; + useDocsUrl: ( + pluginId: string, + page?: string, + anchor?: string, + ) => string | null; useTranslation: (pluginId: string) => { t: TranslateFn; language: string; @@ -1290,6 +1307,15 @@ export function usePluginId(): string { 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 * define fall back to core's shared strings (common.*, hosts.*). diff --git a/packages/plugin-sdk/src/manifest.ts b/packages/plugin-sdk/src/manifest.ts index 4dae63705..8732e8173 100644 --- a/packages/plugin-sdk/src/manifest.ts +++ b/packages/plugin-sdk/src/manifest.ts @@ -12,6 +12,21 @@ import semver from "semver"; 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 @@ -21,7 +36,7 @@ import { isKnownCapability } from "./capabilities.js"; * "^1.2" needs 1.2 or later). engine.termix is checked against the core * 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. */ export const SUPPORTED_PLUGIN_API_VERSION = String( @@ -601,6 +616,10 @@ export interface PluginManifest { video?: string; /** Short lines listing what the plugin does, shown on its page. */ features?: string[]; + /** Where the plugin's docs live. https only. */ + docs?: string; + /** Environment variables the plugin reads. */ + env?: PluginEnvVar[]; engine: PluginEngine; /** Catalog capability ids. */ capabilities: string[]; @@ -654,6 +673,8 @@ const ALLOWED_TOP_LEVEL = new Set([ "icon", "video", "features", + "docs", + "env", "dependencies", "optionalDependencies", "provides", @@ -816,6 +837,12 @@ export function validateManifest(manifest: unknown): string[] { ); } 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) { if (field in m && requireString(m[field], 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(); + 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 { if (author === undefined) return; if (!isPlainObject(author)) { diff --git a/packages/plugin-sdk/src/openapi.ts b/packages/plugin-sdk/src/openapi.ts new file mode 100644 index 000000000..b86b5663d --- /dev/null +++ b/packages/plugin-sdk/src/openapi.ts @@ -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 & { + paths?: Record; +}; + +/** 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 { + 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[0])) as OpenApiSpec; + return spec; +} + +/** Whether a spec documents any route. */ +export function hasOpenApiPaths(spec: OpenApiSpec): boolean { + return Object.keys(spec.paths ?? {}).length > 0; +} diff --git a/packages/plugin-sdk/src/testing/setup.ts b/packages/plugin-sdk/src/testing/setup.ts index 73d5dc260..80fbf1892 100644 --- a/packages/plugin-sdk/src/testing/setup.ts +++ b/packages/plugin-sdk/src/testing/setup.ts @@ -85,6 +85,7 @@ const offline = () => Promise.reject(new Error("No plugin API in a unit test; mock it")); (globalThis as Record).__termixTestPluginHost = { usePluginId: () => "test-plugin", + useDocsUrl: () => null, useTranslation: () => ({ t: (key: string) => key, language: "en", diff --git a/packages/plugin-sdk/ui.api.txt b/packages/plugin-sdk/ui.api.txt index 52eb7c61b..3e3504e19 100644 --- a/packages/plugin-sdk/ui.api.txt +++ b/packages/plugin-sdk/ui.api.txt @@ -58,6 +58,8 @@ DialogOverlay (value): ({ className, ...props }: DialogOverlayProps & RefAttribu DialogPortal (value): ({ ...props }: DialogPortalProps) => Element DialogTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes) => Element DialogTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes) => Element +DocsLink (value): ({ core, page, anchor, href, variant, children, className, }: DocsLinkProps) => Element +DocsLinkProps (type): DocsLinkProps DropdownMenu (value): ({ ...props }: DropdownMenuProps) => Element DropdownMenuCheckboxItem (value): ({ className, children, checked, inset, ...props }: DropdownMenuCheckboxItemProps & RefAttributes & { inset?: boolean; }) => Element DropdownMenuContent (value): ({ className, align, sideOffset, ...props }: DropdownMenuContentProps & RefAttributes) => Element @@ -128,7 +130,7 @@ PanePrompt (value): ({ open, title, titleId, description, icon, tone, actions, o PanelDensity (type): PanelDensity 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; 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 PassphraseDialog (value): ({ isOpen, onSubmit, onCancel, hostInfo, backgroundColor, }: PassphraseDialogProps) => Element PasswordInput (value): ForwardRefExoticComponent> diff --git a/scripts/check-env-catalog.cjs b/scripts/check-env-catalog.cjs new file mode 100644 index 000000000..6d47d7e8a --- /dev/null +++ b/scripts/check-env-catalog.cjs @@ -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 }; diff --git a/scripts/check-env-catalog.test.ts b/scripts/check-env-catalog.test.ts new file mode 100644 index 000000000..85236965d --- /dev/null +++ b/scripts/check-env-catalog.test.ts @@ -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; +}; + +let root: string | null = null; + +afterEach(() => { + if (root) fs.rmSync(root, { recursive: true, force: true }); + root = null; +}); + +function fixture(files: Record): 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); + }); +}); diff --git a/scripts/check-sdk-ui-exports.cjs b/scripts/check-sdk-ui-exports.cjs index 773d52152..c8072663d 100644 --- a/scripts/check-sdk-ui-exports.cjs +++ b/scripts/check-sdk-ui-exports.cjs @@ -79,6 +79,8 @@ const ALLOWED = [ "DialogPortal", "DialogTitle", "DialogTrigger", + "DocsLink", + "DocsLinkProps", "DropdownMenu", "DropdownMenuCheckboxItem", "DropdownMenuContent", diff --git a/scripts/generate-openapi.mjs b/scripts/generate-openapi.mjs new file mode 100644 index 000000000..84e14c51f --- /dev/null +++ b/scripts/generate-openapi.mjs @@ -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)`, +); diff --git a/src/backend/database/routes/plugins.ts b/src/backend/database/routes/plugins.ts index 767b7f9f7..61a7e5d92 100644 --- a/src/backend/database/routes/plugins.ts +++ b/src/backend/database/routes/plugins.ts @@ -19,7 +19,13 @@ import { getPluginPublicHttpRoutes } from "../../plugins/http.js"; import { getPluginPublicWsRoutes } from "../../plugins/ws.js"; import { PluginManageError } from "../../plugins/manage.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 { isPluginChoice, type PluginChoice, @@ -259,6 +265,8 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { let repository: string | undefined; let videoId: string | undefined; let features: string[] = []; + let docs: string | undefined; + let env: PluginEnvVar[] = []; try { const manifest = JSON.parse(record.manifestJson) as { contributes?: unknown; @@ -269,6 +277,8 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { repository?: unknown; video?: unknown; features?: unknown; + docs?: unknown; + env?: unknown; dependencies?: Record; optionalDependencies?: Record; }; @@ -292,6 +302,8 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { : undefined; videoId = youtubeVideoId(manifest?.video) ?? undefined; features = pluginFeatures(manifest?.features); + docs = pluginDocsUrl(manifest?.docs) ?? undefined; + env = pluginEnvVars(manifest?.env); } catch { contributes = null; } @@ -306,6 +318,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { icon, dependencies, optionalDependencies, + docs, ...describePluginFrontend(loaded), }; @@ -324,6 +337,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { repository, videoId, features, + env, signedBy: loaded?.signedBy ?? null, capabilities, grantedCapabilities: grantsByPlugin.get(record.id) ?? [], diff --git a/src/backend/plugins/manage.ts b/src/backend/plugins/manage.ts index 03e106caa..5b606940f 100644 --- a/src/backend/plugins/manage.ts +++ b/src/backend/plugins/manage.ts @@ -222,6 +222,7 @@ export interface RegistryListing { icon?: string; videoId?: string; features: string[]; + docs?: string; versions: Array<{ version: string; prerelease: boolean; @@ -290,6 +291,7 @@ export async function listRegistry( icon: plugin.icon, videoId: plugin.videoId, features: plugin.features, + docs: plugin.docs, versions: channelReleases(plugin, "beta").map((entry) => ({ version: entry.version, prerelease: Boolean(entry.prerelease), diff --git a/src/backend/plugins/manifest.ts b/src/backend/plugins/manifest.ts index be9d53e93..ee76eb848 100644 --- a/src/backend/plugins/manifest.ts +++ b/src/backend/plugins/manifest.ts @@ -19,10 +19,13 @@ export { qualifyPermission, youtubeVideoId, pluginFeatures, + pluginDocsUrl, + pluginEnvVars, } from "@termix-ssh/plugin-sdk/manifest"; export type { PluginManifest, + PluginEnvVar, PluginAuthor, PluginEngine, PluginContributions, diff --git a/src/backend/plugins/registry-index.ts b/src/backend/plugins/registry-index.ts index 5259d05d9..e836c128d 100644 --- a/src/backend/plugins/registry-index.ts +++ b/src/backend/plugins/registry-index.ts @@ -13,6 +13,7 @@ import fs from "node:fs"; import path from "node:path"; import semver from "semver"; import { + pluginDocsUrl, pluginFeatures, SUPPORTED_PLUGIN_API_VERSION, youtubeVideoId, @@ -67,6 +68,8 @@ export interface RegistryPlugin { /** Only ever a YouTube video id, never a link. */ videoId?: string; features: string[]; + /** https docs link from the index. */ + docs?: string; /** Stable releases, newest first. */ versions: RegistryVersion[]; /** @@ -161,6 +164,7 @@ export function parseRegistryIndex(raw: unknown): RegistryIndex { icon: asString(entry.icon), videoId: youtubeVideoId(entry.video) ?? undefined, features: pluginFeatures(entry.features), + docs: pluginDocsUrl(entry.docs) ?? undefined, versions, prereleases, }); diff --git a/src/backend/tests/plugins/manifest.test.ts b/src/backend/tests/plugins/manifest.test.ts index 4e567bc1e..89d95e2ad 100644 --- a/src/backend/tests/plugins/manifest.test.ts +++ b/src/backend/tests/plugins/manifest.test.ts @@ -14,7 +14,10 @@ import { isTermixCompatible, youtubeVideoId, pluginFeatures, + pluginDocsUrl, + pluginEnvVars, } from "../../plugins/manifest.js"; +import { pluginDocsPage } from "@termix-ssh/plugin-sdk/docs"; function base(overrides: Record = {}) { return { @@ -929,3 +932,73 @@ describe("youtubeVideoId", () => { 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`); + }); +}); diff --git a/src/backend/tests/plugins/registry-index.test.ts b/src/backend/tests/plugins/registry-index.test.ts index fe23cf8c8..0efacbdb7 100644 --- a/src/backend/tests/plugins/registry-index.test.ts +++ b/src/backend/tests/plugins/registry-index.test.ts @@ -155,6 +155,23 @@ describe("parseRegistryIndex", () => { 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", () => { expect(() => parseRegistryIndex({ nope: true })).toThrow(/no plugins/); }); diff --git a/src/backend/tests/plugins/sdk-openapi.test.ts b/src/backend/tests/plugins/sdk-openapi.test.ts new file mode 100644 index 000000000..e23c33073 --- /dev/null +++ b/src/backend/tests/plugins/sdk-openapi.test.ts @@ -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 { + 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); + }); +}); diff --git a/src/backend/utils/openapi-tags.json b/src/backend/utils/openapi-tags.json new file mode 100644 index 000000000..eee05e451 --- /dev/null +++ b/src/backend/utils/openapi-tags.json @@ -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" + } +] diff --git a/src/backend/utils/swagger.ts b/src/backend/utils/swagger.ts deleted file mode 100644 index 71bf3d6e7..000000000 --- a/src/backend/utils/swagger.ts +++ /dev/null @@ -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[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 .", - }, - 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_", - description: - "API key sent as Authorization: Bearer tmx_. 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 }; diff --git a/src/ui/api/plugins-api.ts b/src/ui/api/plugins-api.ts index 1372dc4c8..555c3c1a5 100644 --- a/src/ui/api/plugins-api.ts +++ b/src/ui/api/plugins-api.ts @@ -4,6 +4,7 @@ import type { ChangelogRelease, ReleaseNotes, } from "@termix-ssh/plugin-sdk/changelog"; +import type { PluginEnvVar } from "@termix-ssh/plugin-sdk/docs"; export type { PluginChoice }; @@ -255,6 +256,10 @@ export interface PluginSummary { videoId?: string; /** What the plugin does, from the manifest. Admins only. */ features?: string[]; + /** https docs link from the manifest. */ + docs?: string; + /** Environment variables the plugin reads. Admins only. */ + env?: PluginEnvVar[]; signedBy?: string | null; } @@ -294,6 +299,7 @@ export interface RegistryPluginEntry { icon?: string; videoId?: string; features?: string[]; + docs?: string; versions: RegistryPluginVersion[]; /** Newest release on the plugin's channel. */ latestVersion: string | null; diff --git a/src/ui/components/docs-link.tsx b/src/ui/components/docs-link.tsx new file mode 100644 index 000000000..76b418318 --- /dev/null +++ b/src/ui/components/docs-link.tsx @@ -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 ( + + + + ); + } + return ( + + {children ?? label} + + ); +} + +function withAnchor(url: string, anchor?: string): string { + if (!anchor) return url; + return `${url.split("#")[0]}#${anchor.replace(/^#/, "")}`; +} diff --git a/src/ui/components/panel-layout.tsx b/src/ui/components/panel-layout.tsx index 42e27b067..154e9068b 100644 --- a/src/ui/components/panel-layout.tsx +++ b/src/ui/components/panel-layout.tsx @@ -5,6 +5,7 @@ import { ArrowLeft, Grid3X3, List, Rows3, Search, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; import { Separator } from "@/components/separator"; +import { DocsLink } from "@/components/docs-link"; /** One spacing rhythm for every panel and tab. */ export const PANEL = { @@ -30,6 +31,7 @@ export function PanelShell({ tabs, footer, scroll = true, + docs, className, children, }: { @@ -49,6 +51,8 @@ export function PanelShell({ footer?: React.ReactNode; /** False when the body scrolls itself. */ scroll?: boolean; + /** Docs URL for this screen, shown as a book icon in the header. */ + docs?: string | null; className?: string; children: React.ReactNode; }) { @@ -120,8 +124,11 @@ export function PanelShell({ )} - {actions && ( -
{actions}
+ {(actions || docs) && ( +
+ {actions} + {docs && } +
)} {bands} diff --git a/src/ui/dashboard/DashboardTab.tsx b/src/ui/dashboard/DashboardTab.tsx index c0eaea509..f5eb33dcd 100644 --- a/src/ui/dashboard/DashboardTab.tsx +++ b/src/ui/dashboard/DashboardTab.tsx @@ -62,6 +62,7 @@ import { type VersionStatus, } from "./dashboard-core-cards"; import { reportCoreIssueUrl } from "@/lib/issue-url"; +import { docsUrl } from "@/lib/docs"; export { HostStatusList as HostStatusCard } from "./dashboard-core-cards"; @@ -803,7 +804,7 @@ export function DashboardTab({ href: "https://discord.com/invite/jVQGdvHDrf", 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") }, ]; @@ -827,6 +828,7 @@ export function DashboardTab({ title={title} status={isDashboardView ? todayLabel : undefined} scroll={false} + docs={docsUrl("dashboard")} actions={ <> {isDashboardView && !viewPending && ( diff --git a/src/ui/lib/docs-pages.json b/src/ui/lib/docs-pages.json new file mode 100644 index 000000000..b946042ec --- /dev/null +++ b/src/ui/lib/docs-pages.json @@ -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" +} diff --git a/src/ui/lib/docs.ts b/src/ui/lib/docs.ts new file mode 100644 index 000000000..28d456b7c --- /dev/null +++ b/src/ui/lib/docs.ts @@ -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 }; diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index ee8970204..793263629 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -208,7 +208,8 @@ "mobileSidebarDescription": "Displays the mobile sidebar.", "toggleSidebar": "Toggle Sidebar", "search": "Search...", - "new": "New" + "new": "New", + "docs": "Docs" }, "onboarding": { "stepCounter": "Step {{current}} of {{total}}", @@ -252,7 +253,8 @@ "desktopLocalBody": "Everything is stored here. Nothing to set up.", "desktopLinkTitle": "With my Termix server", "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": { "home": "Home", @@ -930,7 +932,8 @@ "select": "Select", "toggleWith": "Toggle with", "pluginActions": "More actions", - "noTargetTab": "No terminal tab is open" + "noTargetTab": "No terminal tab is open", + "documentation": "Documentation" }, "splitScreen": { "title": "Split screen", @@ -2185,7 +2188,11 @@ "waitingForStable": "Back on stable. Stays on {{version}} until a stable release is newer.", "versionLabel": "{{version}} (beta)", "sendFeedback": "Send beta feedback" - } + }, + "documentation": "Documentation", + "envVars": "Environment variables", + "envRequired": "Required", + "envDefault": "Default: {{value}}" } }, "settings": { diff --git a/src/ui/manage/ManageTab.tsx b/src/ui/manage/ManageTab.tsx index cfa069e8f..9df4b3954 100644 --- a/src/ui/manage/ManageTab.tsx +++ b/src/ui/manage/ManageTab.tsx @@ -43,6 +43,7 @@ import { type ManageMode, type ManageRequest, } from "./manage-requests"; +import { docsUrl } from "@/lib/docs"; type Editing = | { @@ -440,6 +441,13 @@ export function ManageTab() { title={t("nav.manage")} status={status} scroll={false} + docs={docsUrl( + mode === "credentials" + ? "credentials" + : mode === "hosts" + ? "hosts" + : "hostDefaults", + )} leading={ editing ? ( diff --git a/src/ui/onboarding/steps/PluginsStep.tsx b/src/ui/onboarding/steps/PluginsStep.tsx index aead48bf0..22435e56b 100644 --- a/src/ui/onboarding/steps/PluginsStep.tsx +++ b/src/ui/onboarding/steps/PluginsStep.tsx @@ -19,6 +19,7 @@ import { defaultChoices, recommendedChoices, } from "../plugin-choices"; +import { DocsLink } from "@/components/docs-link"; const CATEGORY_ORDER = [ "Terminal", @@ -173,7 +174,10 @@ export function PluginsStep({ return (

- {t("onboarding.pluginsIntro")} + {t("onboarding.pluginsIntro")}{" "} + + {t("onboarding.pluginsDocsLink")} +

diff --git a/src/ui/plugin-host/app.ts b/src/ui/plugin-host/app.ts index 6ae583bd6..100b8d560 100644 --- a/src/ui/plugin-host/app.ts +++ b/src/ui/plugin-host/app.ts @@ -72,6 +72,7 @@ import { } from "./host-scope"; import { manifestDeclares, type ViewKind } from "./view-ownership"; import { pluginKey } from "@/lib/plugin-i18n"; +import { pluginDocsPage, pluginDocsUrl } from "@termix-ssh/plugin-sdk/docs"; import { hasPermission } from "@/hooks/use-permissions"; import i18n from "@/i18n/i18n"; @@ -83,6 +84,20 @@ export interface PluginAppHandle { 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. * @@ -602,6 +617,8 @@ export function createPluginApp( confirm: (options) => shell.confirm!(options), + docs: createDocsApi(manifest), + desktop: { available: isElectron(), remoteServerUrl, diff --git a/src/ui/plugin-host/bridge.ts b/src/ui/plugin-host/bridge.ts index adc3e6c97..53cb693fb 100644 --- a/src/ui/plugin-host/bridge.ts +++ b/src/ui/plugin-host/bridge.ts @@ -39,6 +39,7 @@ import { } from "@/api/open-tabs-api"; import { runKeybindingAction } from "@/shell/keybinding-registry"; import { usePluginScope } from "./scope"; +import { usePluginDocsUrl } from "@/components/docs-link"; import { knownPluginIds, usePluginStore } from "./plugin-store"; import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext"; import type { UiPluginPresets } from "@/types/ui-preferences"; @@ -225,6 +226,10 @@ export const pluginHostBridge: PluginHostBridge = { return pluginId; }, + useDocsUrl(pluginId, page, anchor) { + return usePluginDocsUrl(pluginId, page, anchor); + }, + useTranslation(pluginId) { const { t, i18n } = useI18nTranslation(pluginId); return { diff --git a/src/ui/plugin-host/sdk-ui.ts b/src/ui/plugin-host/sdk-ui.ts index b3149908e..78fd103b4 100644 --- a/src/ui/plugin-host/sdk-ui.ts +++ b/src/ui/plugin-host/sdk-ui.ts @@ -289,6 +289,8 @@ export { type PanelDensity, type PanelViewMode, } 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 { AddButton, FormFooter, diff --git a/src/ui/plugins/PluginDetail.tsx b/src/ui/plugins/PluginDetail.tsx index 37e391a2d..294094c72 100644 --- a/src/ui/plugins/PluginDetail.tsx +++ b/src/ui/plugins/PluginDetail.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { + BookOpen, Bug, Check, Database, @@ -262,6 +263,40 @@ export function PluginDetail({
+ {plugin.env.length > 0 && ( + +
+ {plugin.env.map((v) => ( +
+
+ + {v.name} + + {v.required && ( + + {t("plugins.manager.envRequired")} + + )} + {v.default !== undefined && ( + + {t("plugins.manager.envDefault", { + value: v.default, + })} + + )} +
+ + {v.description} + +
+ ))} +
+
+ )} + {plugin.installed && ( @@ -419,6 +454,20 @@ export function PluginDetail({ )} + {plugin.docs && ( + + + + {t("plugins.manager.documentation")} + + + )} + {hasSettings && ( )} +

diff --git a/src/ui/sidebar/CredentialsPanel.tsx b/src/ui/sidebar/CredentialsPanel.tsx index 3d5d019d9..76881a65f 100644 --- a/src/ui/sidebar/CredentialsPanel.tsx +++ b/src/ui/sidebar/CredentialsPanel.tsx @@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next"; import { ArrowUpDown, Check, - ExternalLink, Filter, GripVertical, Plus, @@ -29,6 +28,7 @@ import { DropdownMenuTrigger, } from "@/components/dropdown-menu"; import type { CredentialSortKey } from "@/types/credential-sidebar-preferences"; +import { DocsLink } from "@/components/docs-link"; export function CredentialsPanel({ active: _active = true, @@ -269,15 +269,7 @@ export function CredentialsPanel({

- - - +