From 80944301dde235ec7003ca175619ef80d2336981 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 29 Sep 2026 18:11:59 -0500 Subject: [PATCH] feat: harden plugin api for separate repos, signed updates and generic external login --- docker/Dockerfile | 59 +- electron/local-files.cjs | 42 +- electron/main.cjs | 37 +- electron/preload.js | 9 +- eslint.config.mjs | 15 + knip.json | 33 - package-lock.json | 1085 ++++++++++------- package.json | 39 +- packages/plugin-sdk/cli/commands/build.mjs | 103 ++ packages/plugin-sdk/cli/commands/validate.mjs | 48 +- packages/plugin-sdk/cli/index.mjs | 5 +- packages/plugin-sdk/cli/lib/externals.mjs | 7 +- .../cli/patches/guacamole-common-js-flush.cjs | 20 +- .../cli/patches/xterm-android-ime.cjs | 22 +- packages/plugin-sdk/client.d.ts | 13 + packages/plugin-sdk/package.json | 32 +- .../plugin-sdk/schema/manifest.schema.json | 64 +- packages/plugin-sdk/src/backend.ts | 47 +- packages/plugin-sdk/src/capabilities.ts | 2 + packages/plugin-sdk/src/ddl.ts | 177 ++- packages/plugin-sdk/src/desktop.ts | 394 ++++++ packages/plugin-sdk/src/frontend.ts | 48 +- packages/plugin-sdk/src/manifest.ts | 115 +- packages/plugin-sdk/src/testing.ts | 54 +- packages/plugin-sdk/src/testing/setup.ts | 4 + packages/plugin-sdk/src/vitest-preset.ts | 21 +- packages/plugin-sdk/tsconfig.plugin.json | 5 +- packages/plugin-sdk/ui.api.txt | 185 +++ plugins/acme-ssl/package.json | 17 +- plugins/ai/manifest.json | 6 +- plugins/ai/package.json | 26 +- plugins/ai/tests/backend/gating.test.ts | 2 +- plugins/alerts/package.json | 21 +- plugins/automations/manifest.json | 6 +- plugins/automations/package.json | 23 +- .../tests/backend/activate.test.ts | 2 +- .../tests/backend/triggers.test.ts | 2 +- plugins/docker/manifest.json | 6 +- plugins/docker/package.json | 37 +- plugins/docker/src/frontend/index.tsx | 2 + plugins/file-manager/locales/en.json | 8 +- plugins/file-manager/manifest.json | 17 +- plugins/file-manager/package.json | 50 +- .../file-manager/src/frontend/FileManager.tsx | 40 +- .../src/frontend/LocalFileContextMenu.tsx | 2 +- .../src/frontend/LocalFilePane.tsx | 2 +- .../src/frontend/api/local-transfer-api.ts | 15 +- .../src/frontend/hooks/useLocalTransfers.ts | 2 +- plugins/file-manager/src/frontend/index.tsx | 25 +- .../src/frontend/lib/local-files.ts | 2 +- .../src/frontend/local-transfer-utils.ts | 2 +- .../tests/backend/download-routes.test.ts | 4 +- .../tests/frontend/LocalFilePane.test.tsx | 2 +- .../tests/frontend/activate.test.tsx | 7 +- .../frontend/local-transfer-utils.test.ts | 2 +- plugins/fleets/manifest.json | 3 +- plugins/fleets/package.json | 25 +- .../fleets/tests/backend/repository.test.ts | 4 +- plugins/fleets/tests/backend/service.test.ts | 5 +- plugins/homepage/package.json | 21 +- .../homepage/src/frontend/HomepageCanvas.tsx | 12 +- plugins/homepage/src/frontend/index.tsx | 4 - .../frontend/widgets/QuickConnectWidget.tsx | 13 +- plugins/host-metrics/manifest.json | 10 +- plugins/host-metrics/package.json | 24 +- .../src/frontend/HostEditorStatsTab.tsx | 1 - .../src/frontend/SummaryViews.tsx | 2 +- .../src/frontend/cards/CpuCard.tsx | 7 - plugins/host-metrics/src/frontend/index.tsx | 2 +- .../backend/host-metrics-preferences.test.ts | 6 +- .../tests/frontend/activate.test.tsx | 2 +- plugins/ldap/package.json | 23 +- plugins/network-topology/package.json | 17 +- .../src/frontend/NetworkGraphCard.tsx | 28 +- .../tests/backend/activate.test.ts | 4 +- plugins/opkssh/manifest.json | 3 +- plugins/opkssh/package.json | 24 +- plugins/proxmox/locales/en.json | 3 +- plugins/proxmox/manifest.json | 3 +- plugins/proxmox/package.json | 21 +- .../src/frontend/ProxmoxDiscoverDialog.tsx | 7 +- .../src/frontend/proxmox-import-auth.ts | 26 +- .../backend/proxmox-stats-polling.test.ts | 2 +- plugins/remote-desktop/manifest.json | 11 +- plugins/remote-desktop/package.json | 29 +- .../remote-desktop/patches/guacamole-lite.cjs | 41 +- plugins/remote-desktop/src/backend/routes.ts | 3 +- .../src/backend/token-service.ts | 13 +- .../src/frontend/GuacamoleDisplay.tsx | 4 +- .../src/frontend}/guacamole-common-js.d.ts | 0 .../src/frontend/guacamole-state.ts | 26 + .../tests/backend/macos-vnc-proxy.test.ts | 2 +- .../tests/backend/routes.test.ts | 4 +- .../tests/backend/token-security.test.ts | 2 +- .../tests/backend/token-service.test.ts | 2 + plugins/secret-sources/manifest.json | 3 +- plugins/secret-sources/package.json | 22 +- plugins/serial/manifest.json | 3 +- plugins/serial/package.json | 30 +- plugins/serial/src/frontend/index.tsx | 2 + plugins/session-recording/manifest.json | 6 +- plugins/session-recording/package.json | 28 +- .../tests/backend/repository.test.ts | 6 +- .../tests/backend/service.test.ts | 6 +- plugins/session-sharing/locales/en.json | 6 + plugins/session-sharing/manifest.json | 12 +- plugins/session-sharing/package.json | 31 +- .../session-sharing/src/backend/services.ts | 2 +- .../src/frontend/SharedWithMeSection.tsx | 160 +++ .../session-sharing/src/frontend/index.tsx | 15 +- .../session-sharing/tests/backend/helpers.ts | 2 +- .../tests/backend/security.test.ts | 4 +- .../frontend/SharedWithMeSection.test.tsx | 103 ++ .../tests/frontend/activate.test.tsx | 18 +- plugins/snippets/manifest.json | 10 +- plugins/snippets/package.json | 23 +- .../snippets/tests/backend/activate.test.ts | 6 +- plugins/ssh-terminal/locales/en.json | 34 +- plugins/ssh-terminal/manifest.json | 14 +- plugins/ssh-terminal/package.json | 46 +- plugins/ssh-terminal/src/backend/services.ts | 2 +- plugins/ssh-terminal/src/frontend/index.tsx | 2 + .../src/frontend/look/theme-preview.ts | 30 + .../settings/HostTerminalSettings.tsx | 9 +- .../src/frontend/terminal/Terminal.tsx | 5 +- .../frontend/terminal/TerminalTabContent.tsx | 5 +- .../src/frontend/terminal/TerminalToolbar.tsx | 67 +- .../tests/backend/activate.test.ts | 6 +- .../backend/images/image-storage.test.ts | 2 +- .../tests/backend/memory-agent.test.ts | 4 +- .../terminal/TerminalTabContent.test.tsx | 1 - .../terminal/TerminalToolbar.test.tsx | 39 +- plugins/sso/locales/en.json | 6 + plugins/sso/manifest.json | 8 + plugins/sso/package.json | 20 +- plugins/sso/src/backend/index.ts | 19 +- plugins/sso/tests/backend/helpers.ts | 2 + plugins/sso/tests/backend/sso.test.ts | 25 + plugins/step-ca/manifest.json | 3 +- plugins/step-ca/package.json | 20 +- plugins/tailscale/manifest.json | 3 +- plugins/tailscale/package.json | 22 +- plugins/termix-identity/package.json | 23 +- .../tests/frontend/activate.test.tsx | 22 +- plugins/tmux-monitor/package.json | 22 +- plugins/totp/manifest.json | 3 +- plugins/totp/package.json | 22 +- plugins/tunnels/manifest.json | 7 +- plugins/tunnels/package.json | 24 +- plugins/tunnels/src/frontend/index.tsx | 6 + plugins/vault/manifest.json | 3 +- plugins/vault/package.json | 23 +- plugins/wake-on-lan/package.json | 17 +- .../tests/backend/magic-packet.test.ts | 2 +- .../wake-on-lan/tests/backend/service.test.ts | 3 + plugins/warpgate/package.json | 11 +- plugins/web-endpoint/manifest.json | 2 +- plugins/web-endpoint/package.json | 23 +- plugins/webauthn/manifest.json | 3 +- plugins/webauthn/package.json | 22 +- plugins/workspaces/package.json | 22 +- .../workspaces/tests/backend/activate.test.ts | 2 +- scripts/apply-plugin-patches.cjs | 26 + scripts/build-sdk-host.mjs | 140 +++ scripts/check-core-plugin-ids.cjs | 54 +- scripts/check-core-plugin-ids.test.ts | 31 + scripts/check-plugin-uses.cjs | 153 +++ scripts/check-plugin-uses.test.ts | 70 ++ scripts/check-sdk-ui-exports.cjs | 94 +- scripts/install-native-plugin-deps.cjs | 40 + scripts/run-plugin-tests.cjs | 40 +- src/backend/auth/external-account.ts | 10 + src/backend/auth/login-pipeline.ts | 2 +- src/backend/auth/provisioning.ts | 6 +- src/backend/auth/session-issuer.ts | 3 + src/backend/database/database.ts | 4 +- .../plugin-install-count-repository.ts | 98 -- .../plugin-registry-repository.ts | 129 -- .../database/repositories/user-repository.ts | 2 +- src/backend/database/routes/auth-routes.ts | 16 +- src/backend/database/routes/host.ts | 13 - src/backend/database/routes/open-tabs.ts | 20 +- .../database/routes/ssh-auth-routes.ts | 16 +- .../database/routes/user-admin-routes.ts | 2 + .../routes/user-external-account-routes.ts | 285 +++++ .../routes/user-oidc-account-routes.ts | 318 ----- .../database/routes/user-preferences.ts | 81 -- .../database/routes/user-settings-routes.ts | 19 - src/backend/database/routes/users.ts | 164 +-- src/backend/hosts/credential-username.ts | 24 +- src/backend/hosts/host-resolver.ts | 4 +- src/backend/hosts/live-terminal-sessions.ts | 1 - src/backend/plugins/actor.ts | 12 - src/backend/plugins/assets.ts | 30 +- src/backend/plugins/ctx-hosts.ts | 1 - src/backend/plugins/ctx-process.ts | 30 +- src/backend/plugins/ctx-ssh-auth.ts | 11 +- src/backend/plugins/ctx.ts | 105 +- src/backend/plugins/data.ts | 86 +- src/backend/plugins/http.ts | 47 + src/backend/plugins/index.ts | 36 +- src/backend/plugins/loader.ts | 166 ++- src/backend/plugins/manifest.ts | 3 + src/backend/plugins/migrations.ts | 35 +- src/backend/plugins/registry.ts | 20 +- src/backend/plugins/user-data.ts | 55 +- src/backend/plugins/ws.ts | 26 +- src/backend/sync/client/plugins.ts | 50 +- src/backend/sync/server/routes.ts | 9 +- src/backend/tests/auth/auth-test-helpers.ts | 8 +- .../plugin-install-count-repository.test.ts | 117 -- .../plugin-registry-repository.test.ts | 128 -- .../user-session-repositories.test.ts | 2 +- ...s => user-external-account-routes.test.ts} | 38 +- .../database/routes/user-preferences.test.ts | 21 - .../tests/electron/local-files.test.ts | 15 + .../tests/hosts/credential-username.test.ts | 24 +- .../tests/plugins/capabilities.test.ts | 44 +- .../tests/plugins/ctx-auth-login.test.ts | 19 +- src/backend/tests/plugins/ctx-db.test.ts | 9 +- src/backend/tests/plugins/ctx-desktop.test.ts | 21 +- src/backend/tests/plugins/http.test.ts | 25 + src/backend/tests/plugins/loader.test.ts | 13 - src/backend/tests/plugins/manifest.test.ts | 31 +- .../tests/plugins/permissions-rbac.test.ts | 4 +- .../tests/plugins/secret-contract.test.ts | 2 +- .../tests/plugins/security/actor.test.ts | 8 +- .../tests/plugins/security/data.test.ts | 63 +- .../tests/plugins/service-contract.test.ts | 3 +- .../tests/plugins/signed-plugins-gate.test.ts | 53 +- src/backend/tests/plugins/user-data.test.ts | 34 + .../upgrade/sso-settings-migration.test.ts | 79 ++ .../utils/auth-manager-oidc-logout.test.ts | 10 +- ...est.ts => external-login-callback.test.ts} | 8 +- .../tests/utils/service-listen.test.ts | 146 --- src/backend/tests/utils/ws-auth.test.ts | 60 +- src/backend/upgrade/boot-migrations.ts | 2 + src/backend/upgrade/sso-settings-migration.ts | 59 + src/backend/utils/auth-manager.ts | 7 +- ...callback.ts => external-login-callback.ts} | 2 +- src/backend/utils/logger.ts | 7 - src/backend/utils/service-listen.ts | 84 -- src/backend/utils/swagger.ts | 131 +- src/backend/utils/ws-auth.ts | 41 +- src/types/connection-log.ts | 28 +- src/types/credential-sidebar-preferences.ts | 8 - src/types/electron.d.ts | 384 +----- src/types/index.ts | 84 -- src/types/ui-preferences.ts | 66 +- src/types/ui-types.ts | 23 +- src/ui/AppShell.tsx | 89 +- src/ui/api/auth-methods-api.ts | 2 +- src/ui/api/dashboard-api.ts | 2 +- src/ui/api/external-account-api.ts | 36 + src/ui/api/oidc-account-api.ts | 32 - src/ui/api/open-tabs-api.ts | 23 +- src/ui/api/plugins-api.ts | 17 +- src/ui/api/settings-api.ts | 2 +- src/ui/api/sse-stream.ts | 46 - src/ui/api/user-management-api.ts | 35 +- src/ui/auth/Auth.tsx | 51 +- src/ui/auth/ElectronLoginForm.tsx | 6 +- .../FullScreenAppWrapper.tsx | 2 +- .../connection/connection-status.ts | 38 - src/ui/dashboard/DashboardTab.tsx | 4 +- src/ui/index.css | 85 +- src/ui/lib/apply-ui-preset.ts | 6 - src/ui/lib/frontend-logger.ts | 4 - src/ui/lib/theme.ts | 2 +- src/ui/locales/en.json | 154 +-- src/ui/main-axios.ts | 21 +- src/ui/plugin-host/app.ts | 11 + src/ui/plugin-host/bridge.ts | 14 + src/ui/plugin-host/desktop.ts | 12 + src/ui/plugin-host/sdk-ui.ts | 175 ++- src/ui/plugin-host/shell-bridge.ts | 3 + src/ui/plugin-host/testing-host.tsx | 4 + src/ui/settings/FeatureSettingsSections.tsx | 4 +- src/ui/settings/HostPluginSections.tsx | 4 +- src/ui/settings/settings-fields-util.ts | 7 +- src/ui/shell/TabBar.tsx | 18 - src/ui/shell/TabContext.tsx | 11 - src/ui/shell/shell-layout.ts | 12 +- src/ui/shell/tab-registry.ts | 2 + src/ui/sidebar/AdminSettingsPanel.tsx | 57 +- src/ui/sidebar/AdminSettingsSections.tsx | 32 +- src/ui/sidebar/AdminUserDialogs.tsx | 10 +- src/ui/sidebar/ConnectionsPanel.tsx | 151 +-- src/ui/sidebar/HostCredentialList.tsx | 450 ------- src/ui/sidebar/HostEditor.tsx | 14 +- src/ui/sidebar/HostExportDialog.tsx | 18 +- src/ui/sidebar/HostsPanel.tsx | 8 +- src/ui/sidebar/UserProfilePanel.tsx | 6 +- .../CredentialItem/CredentialItem.tsx | 2 - src/ui/sidebar/host-export-payload.ts | 89 +- src/ui/tests/api/sse-stream.test.ts | 48 - .../tests/plugin-host/view-ownership.test.tsx | 3 +- src/ui/tests/settings/SettingsFields.test.tsx | 12 +- .../tests/sidebar/ConnectionsPanel.test.tsx | 156 +-- .../tests/sidebar/HostCredentialList.test.tsx | 129 -- .../tests/sidebar/HostExportDialog.test.tsx | 31 + .../tests/sidebar/host-export-payload.test.ts | 41 +- src/ui/tests/types/ui-preferences.test.ts | 25 +- src/ui/theme.css | 84 ++ tsconfig.json | 5 +- tsconfig.plugins.backend.json | 28 - tsconfig.plugins.frontend.json | 16 - tsconfig.plugins.json | 12 + tsconfig.sdk-ui.json | 12 + vite.config.ts | 25 - 310 files changed, 6577 insertions(+), 4927 deletions(-) rename scripts/patch-guacamole-common-js.cjs => packages/plugin-sdk/cli/patches/guacamole-common-js-flush.cjs (71%) rename scripts/patch-xterm-android-ime.cjs => packages/plugin-sdk/cli/patches/xterm-android-ime.cjs (91%) create mode 100644 packages/plugin-sdk/client.d.ts create mode 100644 packages/plugin-sdk/src/desktop.ts create mode 100644 packages/plugin-sdk/ui.api.txt rename scripts/patch-guacamole-lite.cjs => plugins/remote-desktop/patches/guacamole-lite.cjs (94%) rename {src/types => plugins/remote-desktop/src/frontend}/guacamole-common-js.d.ts (100%) create mode 100644 plugins/remote-desktop/src/frontend/guacamole-state.ts create mode 100644 plugins/session-sharing/src/frontend/SharedWithMeSection.tsx create mode 100644 plugins/session-sharing/tests/frontend/SharedWithMeSection.test.tsx create mode 100644 plugins/ssh-terminal/src/frontend/look/theme-preview.ts create mode 100644 scripts/apply-plugin-patches.cjs create mode 100644 scripts/build-sdk-host.mjs create mode 100644 scripts/check-plugin-uses.cjs create mode 100644 scripts/check-plugin-uses.test.ts create mode 100644 scripts/install-native-plugin-deps.cjs create mode 100644 src/backend/auth/external-account.ts delete mode 100644 src/backend/database/repositories/plugin-install-count-repository.ts delete mode 100644 src/backend/database/repositories/plugin-registry-repository.ts create mode 100644 src/backend/database/routes/user-external-account-routes.ts delete mode 100644 src/backend/database/routes/user-oidc-account-routes.ts delete mode 100644 src/backend/tests/database/repositories/plugin-install-count-repository.test.ts delete mode 100644 src/backend/tests/database/repositories/plugin-registry-repository.test.ts rename src/backend/tests/database/routes/{user-oidc-account-routes.test.ts => user-external-account-routes.test.ts} (82%) delete mode 100644 src/backend/tests/database/routes/user-preferences.test.ts create mode 100644 src/backend/tests/upgrade/sso-settings-migration.test.ts rename src/backend/tests/utils/{oidc-desktop-callback.test.ts => external-login-callback.test.ts} (65%) delete mode 100644 src/backend/tests/utils/service-listen.test.ts create mode 100644 src/backend/upgrade/sso-settings-migration.ts rename src/backend/utils/{oidc-desktop-callback.ts => external-login-callback.ts} (88%) delete mode 100644 src/backend/utils/service-listen.ts create mode 100644 src/ui/api/external-account-api.ts delete mode 100644 src/ui/api/oidc-account-api.ts delete mode 100644 src/ui/api/sse-stream.ts rename src/ui/{features => components}/FullScreenAppWrapper.tsx (98%) delete mode 100644 src/ui/sidebar/HostCredentialList.tsx delete mode 100644 src/ui/tests/api/sse-stream.test.ts delete mode 100644 src/ui/tests/sidebar/HostCredentialList.test.tsx create mode 100644 src/ui/theme.css delete mode 100644 tsconfig.plugins.backend.json delete mode 100644 tsconfig.plugins.frontend.json create mode 100644 tsconfig.plugins.json create mode 100644 tsconfig.sdk-ui.json diff --git a/docker/Dockerfile b/docker/Dockerfile index 42fc073f0..03b9df7a4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,3 +1,12 @@ +# Stage 0: every plugin's package.json and manifest.json and nothing else. +# npm ci needs each workspace package.json in place or it silently skips it, +# and a glob COPY flattens paths, so the tree is trimmed here instead. The +# deps layer below stays cached until one of these files changes. +FROM node:26-slim AS workspace-manifests +WORKDIR /src +COPY plugins ./plugins +RUN find plugins -mindepth 2 -maxdepth 2 ! -name package.json ! -name manifest.json -exec rm -rf {} + + # Stage 1: Install dependencies FROM node:26-slim AS deps WORKDIR /app @@ -5,51 +14,14 @@ WORKDIR /app RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* COPY package*.json ./ -# Every workspace package needs its package.json present before npm ci, or -# npm ci silently skips linking it and its dependencies never get installed -# (a glob COPY like plugins/*/package.json flattens into one file, so each -# plugin needs its own line). COPY packages/plugin-sdk/package.json ./packages/plugin-sdk/package.json -COPY plugins/acme-ssl/package.json ./plugins/acme-ssl/package.json -COPY plugins/ai/package.json ./plugins/ai/package.json -COPY plugins/alerts/package.json ./plugins/alerts/package.json -COPY plugins/automations/package.json ./plugins/automations/package.json -COPY plugins/docker/package.json ./plugins/docker/package.json -COPY plugins/file-manager/package.json ./plugins/file-manager/package.json -COPY plugins/fleets/package.json ./plugins/fleets/package.json -COPY plugins/homepage/package.json ./plugins/homepage/package.json -COPY plugins/host-metrics/package.json ./plugins/host-metrics/package.json -COPY plugins/ldap/package.json ./plugins/ldap/package.json -COPY plugins/network-topology/package.json ./plugins/network-topology/package.json -COPY plugins/opkssh/package.json ./plugins/opkssh/package.json -COPY plugins/proxmox/package.json ./plugins/proxmox/package.json -COPY plugins/remote-desktop/package.json ./plugins/remote-desktop/package.json -COPY plugins/secret-sources/package.json ./plugins/secret-sources/package.json -COPY plugins/serial/package.json ./plugins/serial/package.json -COPY plugins/session-recording/package.json ./plugins/session-recording/package.json -COPY plugins/session-sharing/package.json ./plugins/session-sharing/package.json -COPY plugins/snippets/package.json ./plugins/snippets/package.json -COPY plugins/ssh-terminal/package.json ./plugins/ssh-terminal/package.json -COPY plugins/sso/package.json ./plugins/sso/package.json -COPY plugins/step-ca/package.json ./plugins/step-ca/package.json -COPY plugins/tailscale/package.json ./plugins/tailscale/package.json -COPY plugins/termix-identity/package.json ./plugins/termix-identity/package.json -COPY plugins/tmux-monitor/package.json ./plugins/tmux-monitor/package.json -COPY plugins/totp/package.json ./plugins/totp/package.json -COPY plugins/tunnels/package.json ./plugins/tunnels/package.json -COPY plugins/vault/package.json ./plugins/vault/package.json -COPY plugins/wake-on-lan/package.json ./plugins/wake-on-lan/package.json -COPY plugins/warpgate/package.json ./plugins/warpgate/package.json -COPY plugins/web-endpoint/package.json ./plugins/web-endpoint/package.json -COPY plugins/webauthn/package.json ./plugins/webauthn/package.json -COPY plugins/workspaces/package.json ./plugins/workspaces/package.json +COPY --from=workspace-manifests /src/plugins ./plugins COPY .npmrc ./ COPY vendor ./vendor -COPY scripts/patch-guacamole-lite.cjs scripts/patch-ssh2-agent.cjs ./scripts/ +COPY scripts/patch-ssh2-agent.cjs ./scripts/ RUN npm ci --ignore-scripts && \ - node scripts/patch-guacamole-lite.cjs && \ node scripts/patch-ssh2-agent.cjs && \ npm cache clean --force @@ -110,10 +82,15 @@ COPY packages/plugin-sdk/package.json ./packages/plugin-sdk/package.json COPY .npmrc ./ COPY vendor ./vendor -COPY scripts/patch-guacamole-lite.cjs scripts/patch-ssh2-agent.cjs ./scripts/ +COPY scripts/patch-ssh2-agent.cjs scripts/install-native-plugin-deps.cjs ./scripts/ +# Plugin manifests only, for their nativeDependencies. Outside plugins/ so npm +# ci does not treat them as workspaces: everything else a plugin uses is +# already inside its bundle. +COPY --from=workspace-manifests /src/plugins ./plugin-manifests RUN npm ci --omit=dev --ignore-scripts && \ - node scripts/patch-guacamole-lite.cjs && \ + node scripts/install-native-plugin-deps.cjs plugin-manifests && \ + rm -rf plugin-manifests && \ node scripts/patch-ssh2-agent.cjs && \ rm -rf node_modules/better-sqlite3/prebuilds && \ npm run build-release --prefix node_modules/better-sqlite3 && \ diff --git a/electron/local-files.cjs b/electron/local-files.cjs index 9b4c47a4e..487127134 100644 --- a/electron/local-files.cjs +++ b/electron/local-files.cjs @@ -35,6 +35,7 @@ const IPC = { DOWNLOAD: "local-transfer:download", CANCEL: "local-transfer:cancel", PROGRESS: "local-transfer:progress", + SET_API: "local-transfer:set-api", }; const READ_CHUNK_BYTES = 1024 * 1024; @@ -325,10 +326,19 @@ const DEVICE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/; // A compact JWT (base64url segments joined by dots); anything else is refused. const AUTH_TOKEN_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/; -// The file-manager plugin serves both routes under /plugin-api/file-manager -// on the main backend. -const FILE_MANAGER_API = "/plugin-api/file-manager"; -const DEFAULT_LOCAL_FILE_MANAGER_BASE = `http://localhost:30001${FILE_MANAGER_API}`; +// The plugin that serves the two streaming routes tells us its /plugin-api/ +// path, so nothing here names it. Only a /plugin-api/ path is accepted, +// which keeps every transfer on the backend's plugin routes. +const API_PATH_PATTERN = /^\/plugin-api\/[a-z0-9][a-z0-9-]*$/; +const LOCAL_BACKEND = "http://localhost:30001"; +let registeredApiPath = null; + +function setTransferApiPath(apiPath) { + if (typeof apiPath !== "string" || !API_PATH_PATTERN.test(apiPath)) { + throw new Error("Invalid transfer API path"); + } + registeredApiPath = apiPath; +} function normalizeHttpBase(candidate, what) { let parsed; @@ -346,10 +356,13 @@ function normalizeHttpBase(candidate, what) { // Resolves where a transfer may go. Only the two file-manager streaming // routes are reachable, and only on the embedded backend or the linked // server; credentials come from the main process, not the caller. -function createTargetResolver({ - localBaseUrl = DEFAULT_LOCAL_FILE_MANAGER_BASE, - getLinkedServer, -}) { +function createTargetResolver({ localBaseUrl, getLinkedServer, apiPath }) { + const resolveApiPath = () => { + const resolved = apiPath ?? registeredApiPath; + if (!resolved) + throw new Error("Local file transfers are not available yet"); + return resolved; + }; return function resolveTransferTarget({ origin, route, @@ -389,7 +402,10 @@ function createTargetResolver({ headers.Authorization = `Bearer ${authToken}`; } return { - url: `${normalizeHttpBase(localBaseUrl, "Local backend URL")}${routePath}`, + url: `${normalizeHttpBase( + localBaseUrl ?? `${LOCAL_BACKEND}${resolveApiPath()}`, + "Local backend URL", + )}${routePath}`, headers, }; } @@ -402,7 +418,7 @@ function createTargetResolver({ } const base = normalizeHttpBase(linked.serverUrl, "Linked server URL"); if (linked.token) headers.Authorization = `Bearer ${linked.token}`; - return { url: `${base}${FILE_MANAGER_API}${routePath}`, headers }; + return { url: `${base}${resolveApiPath()}${routePath}`, headers }; } throw new Error(`Unknown transfer origin: ${String(origin)}`); @@ -890,6 +906,11 @@ function createLocalFileHandlers({ }; return { + [IPC.SET_API]: wrap(async (_event, apiPath) => { + setTransferApiPath(apiPath); + return {}; + }), + [IPC.HOME]: wrap(async () => ({ home: os.homedir(), separator: path.sep, @@ -1028,6 +1049,7 @@ module.exports = { // exported for tests / reuse createLocalFileHandlers, createTargetResolver, + setTransferApiPath, publishDownload, defaultPublishFs, assertWithinRoot, diff --git a/electron/main.cjs b/electron/main.cjs index 50eeff407..c5d2f8a59 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -630,7 +630,14 @@ const isolatedWindows = createIsolatedWindows({ // ctx.desktop.openIsolatedWindow is the one caller today. const BACKEND_REQUEST_HANDLERS = { "open-isolated-window": (payload) => isolatedWindows.open(payload), - "launch-native-rdp": (payload) => launchNativeRdp(payload), + // The OS client for a protocol. Only RDP has one wired up. + "launch-external-client": (payload) => + payload?.protocol === "rdp" + ? launchNativeRdp(payload) + : { + success: false, + error: `No external client for ${payload?.protocol ?? "this protocol"}`, + }, "sync-proxy-config": (payload) => { linkedServer.setLinkedServer(payload); return { success: true }; @@ -1590,9 +1597,9 @@ ipcMain.handle("get-embedded-server-status", () => { }; }); -// OIDC System Browser Authentication (RFC 8252) +// External login through the system browser (RFC 8252) ipcMain.handle( - "oidc-system-browser-auth", + "external-browser-login", async (_event, authUrl, callbackPort) => { const http = require("http"); @@ -1666,7 +1673,7 @@ ipcMain.handle( // Timeout after 5 minutes timeout = setTimeout( () => { - fail(new Error("OIDC authentication timed out")); + fail(new Error("External login timed out")); }, 5 * 60 * 1000, ); @@ -1868,8 +1875,28 @@ function getC2SRemoteBaseUrl() { return serverUrl.replace(/\/$/, ""); } +// The socket path on the linked server that relays C2S streams. The plugin +// that owns the relay tells us where it is, so nothing here names it. +let c2sRelayPath = null; +const C2S_RELAY_PATH_PATTERN = + /^\/plugin-ws\/[a-z0-9][a-z0-9-]*\/[A-Za-z0-9/_-]+$/; + +ipcMain.handle("set-c2s-relay-path", (_event, relayPath) => { + if ( + typeof relayPath !== "string" || + !C2S_RELAY_PATH_PATTERN.test(relayPath) + ) { + return { success: false, error: "Invalid relay path" }; + } + c2sRelayPath = relayPath; + return { success: true }; +}); + function getC2SRelayUrl() { - const relayHttpUrl = `${getC2SRemoteBaseUrl()}/plugin-ws/tunnels/c2s/stream`; + if (!c2sRelayPath) { + throw new Error("Client tunnels are not available yet. Try again shortly."); + } + const relayHttpUrl = `${getC2SRemoteBaseUrl()}${c2sRelayPath}`; return relayHttpUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:"); } diff --git a/electron/preload.js b/electron/preload.js index 58f34eedf..7ded60d62 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -53,6 +53,8 @@ contextBridge.exposeInMainWorld("electronAPI", { }, startC2SAutoStartTunnels: () => ipcRenderer.invoke("start-c2s-autostart-tunnels"), + setC2SRelayPath: (relayPath) => + ipcRenderer.invoke("set-c2s-relay-path", relayPath), onCloseActiveTab: (callback) => { const listener = () => callback(); @@ -72,8 +74,11 @@ contextBridge.exposeInMainWorld("electronAPI", { timeoutMs, ), + externalBrowserLogin: (authUrl, callbackPort) => + ipcRenderer.invoke("external-browser-login", authUrl, callbackPort), + // 2.8 name, read by older server login pages. Remove in 3.0.0. oidcSystemBrowserAuth: (authUrl, callbackPort) => - ipcRenderer.invoke("oidc-system-browser-auth", authUrl, callbackPort), + ipcRenderer.invoke("external-browser-login", authUrl, callbackPort), openExternalEditor: (fileData) => ipcRenderer.invoke("open-external-editor", fileData), @@ -151,6 +156,8 @@ contextBridge.exposeInMainWorld("electronAPI", { open: (targetPath) => ipcRenderer.invoke("local-fs:open", targetPath), }, localTransfer: { + setApiPath: (apiPath) => + ipcRenderer.invoke("local-transfer:set-api", apiPath), upload: (options) => ipcRenderer.invoke("local-transfer:upload", options), download: (options) => ipcRenderer.invoke("local-transfer:download", options), diff --git a/eslint.config.mjs b/eslint.config.mjs index 4c5e68ad5..f404fee54 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -43,6 +43,21 @@ const pluginBoundary = { ExportNamedDeclaration: (node) => check(node, node.source?.value), ExportAllDeclaration: (node) => check(node, node.source.value), ImportExpression: (node) => check(node, node.source.value), + // vi.mock("@/x") and require("../../x") name a module too. + CallExpression: (node) => { + const callee = node.callee; + const named = + (callee.type === "Identifier" && callee.name === "require") || + (callee.type === "MemberExpression" && + callee.object.type === "Identifier" && + callee.object.name === "vi" && + callee.property.type === "Identifier" && + ["mock", "doMock", "importActual", "unmock"].includes( + callee.property.name, + )); + const first = node.arguments[0]; + if (named && first?.type === "Literal") check(node, first.value); + }, }; }, }, diff --git a/knip.json b/knip.json index a96e560d5..4e7318445 100644 --- a/knip.json +++ b/knip.json @@ -23,46 +23,28 @@ ], "ignoreBinaries": ["powershell"], "ignoreDependencies": [ - "@codemirror/autocomplete", - "@codemirror/commands", - "@codemirror/search", "@codemirror/theme-one-dark", "@codemirror/view", "@electron/notarize", - "@monaco-editor/react", - "@radix-ui/react-accordion", "@radix-ui/react-alert-dialog", "@radix-ui/react-checkbox", - "@radix-ui/react-dialog", - "@radix-ui/react-dropdown-menu", "@radix-ui/react-label", "@radix-ui/react-popover", - "@radix-ui/react-progress", "@radix-ui/react-scroll-area", "@radix-ui/react-select", - "@radix-ui/react-separator", "@radix-ui/react-slider", "@radix-ui/react-slot", "@radix-ui/react-switch", "@radix-ui/react-tabs", - "@radix-ui/react-tooltip", "@types/better-sqlite3", "@types/cookie-parser", "@types/cors", "@types/express", - "@types/guacamole-common-js", - "@types/js-yaml", "@types/jsonwebtoken", "@types/multer", "@types/ssh2", - "@uiw/codemirror-extensions-langs", - "@uiw/codemirror-theme-github", "@uiw/react-codemirror", "@xterm/addon-clipboard", - "@xterm/addon-fit", - "@xterm/addon-unicode11", - "@xterm/addon-web-links", - "@xterm/xterm", "axios", "bcryptjs", "better-sqlite3", @@ -73,30 +55,15 @@ "cmdk", "cookie-parser", "cors", - "cytoscape", "drizzle-orm", "express", - "guacamole-lite", - "guacamole-common-js", - "js-yaml", "jsonwebtoken", - "jszip", "multer", "nanoid", "i18next-browser-languagedetector", "lucide-react", "motion", "radix-ui", - "react-cytoscapejs", - "react-h5-audio-player", - "react-hook-form", - "react-icons", - "react-markdown", - "react-pdf", - "react-photo-view", - "react-syntax-highlighter", - "react-xtermjs", - "remark-gfm", "socks", "ssh2", "undici", diff --git a/package-lock.json b/package-lock.json index f0de70ba5..0ab056fe4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "plugins/*" ], "dependencies": { - "@anthropic-ai/sdk": "^0.120.0", "@tanstack/react-virtual": "^3.14.13", "@types/compression": "^1.8.1", "@types/semver": "^7.8.0", @@ -28,9 +27,7 @@ "dotenv": "^17.4.2", "drizzle-orm": "^0.45.2", "express": "^5.2.1", - "guacamole-lite": "^1.2.0", "jsonwebtoken": "^9.0.3", - "jszip": "^3.10.2", "motion": "^13.1.1", "multer": "^2.3.0", "mysql2": "^3.23.4", @@ -46,9 +43,6 @@ "ws": "^8.21.3" }, "devDependencies": { - "@codemirror/autocomplete": "^6.20.3", - "@codemirror/commands": "^6.11.1", - "@codemirror/search": "^6.7.2", "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.43.12", "@commitlint/cli": "^21.2.2", @@ -61,24 +55,17 @@ "@fontsource/fira-code": "^5.3.0", "@fontsource/jetbrains-mono": "^5.3.0", "@fontsource/source-code-pro": "^5.3.0", - "@monaco-editor/react": "^4.7.0", "@peculiar/x509": "^2.1.0", - "@radix-ui/react-accordion": "^1.2.20", "@radix-ui/react-alert-dialog": "^1.1.23", "@radix-ui/react-checkbox": "^1.3.11", - "@radix-ui/react-dialog": "^1.1.23", - "@radix-ui/react-dropdown-menu": "^2.1.24", "@radix-ui/react-label": "^2.1.15", "@radix-ui/react-popover": "^1.1.23", - "@radix-ui/react-progress": "^1.1.16", "@radix-ui/react-scroll-area": "^1.2.18", "@radix-ui/react-select": "^2.3.7", - "@radix-ui/react-separator": "^1.1.15", "@radix-ui/react-slider": "^1.4.7", "@radix-ui/react-slot": "^1.3.3", "@radix-ui/react-switch": "^1.3.7", "@radix-ui/react-tabs": "^1.1.21", - "@radix-ui/react-tooltip": "^1.2.16", "@tailwindcss/vite": "^4.3.3", "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^7.0.1", @@ -88,7 +75,6 @@ "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", - "@types/guacamole-common-js": "^1.5.5", "@types/jsonwebtoken": "^9.0.10", "@types/multer": "^2.2.0", "@types/node": "^26.2.0", @@ -98,18 +84,11 @@ "@types/ssh2": "^1.15.6", "@types/supertest": "^7.2.1", "@types/ws": "^8.18.1", - "@uiw/codemirror-extensions-langs": "^4.25.11", - "@uiw/codemirror-theme-github": "^4.25.11", "@uiw/react-codemirror": "^4.25.11", "@vitejs/plugin-react": "^6.1.1", "@vitest/coverage-v8": "^4.1.11", "@vitest/ui": "^4.1.11", "@xterm/addon-clipboard": "^0.2.0", - "@xterm/addon-fit": "^0.11.0", - "@xterm/addon-search": "^0.16.0", - "@xterm/addon-unicode11": "^0.9.0", - "@xterm/addon-web-links": "^0.12.0", - "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -122,7 +101,6 @@ "eslint-plugin-react-refresh": "^0.5.7", "eslint-plugin-unused-imports": "^4.4.1", "globals": "^17.11.0", - "guacamole-common-js": "^1.5.0", "husky": "^9.1.7", "i18next": "^26.4.2", "i18next-browser-languagedetector": "^8.2.1", @@ -134,17 +112,8 @@ "radix-ui": "^1.6.7", "react": "^19.2.8", "react-dom": "^19.2.8", - "react-h5-audio-player": "^3.10.2", - "react-hook-form": "^7.84.0", "react-i18next": "^17.0.14", - "react-icons": "^5.7.0", - "react-markdown": "^10.1.0", - "react-pdf": "^10.5.0", - "react-photo-view": "^1.2.7", - "react-syntax-highlighter": "^16.1.1", - "react-xtermjs": "^1.0.10", "reflect-metadata": "^0.2.2", - "remark-gfm": "^4.0.1", "sonner": "^2.0.8", "supertest": "^7.3.0", "tailwind-merge": "^3.5.0", @@ -613,7 +582,6 @@ "version": "6.20.3", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", @@ -626,7 +594,6 @@ "version": "6.11.1", "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz", "integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", @@ -639,7 +606,6 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz", "integrity": "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", @@ -650,7 +616,6 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", @@ -664,7 +629,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/@codemirror/lang-go/-/lang-go-6.0.1.tgz", "integrity": "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", @@ -678,7 +642,6 @@ "version": "6.4.11", "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", @@ -696,7 +659,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/@codemirror/lang-java/-/lang-java-6.0.2.tgz", "integrity": "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", @@ -707,7 +669,6 @@ "version": "6.2.5", "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", @@ -723,7 +684,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/@codemirror/lang-jinja/-/lang-jinja-6.0.1.tgz", "integrity": "sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", @@ -740,7 +700,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", @@ -751,7 +710,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/@codemirror/lang-less/-/lang-less-6.0.2.tgz", "integrity": "sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/lang-css": "^6.2.0", @@ -765,7 +723,6 @@ "version": "6.3.2", "resolved": "https://registry.npmjs.org/@codemirror/lang-liquid/-/lang-liquid-6.3.2.tgz", "integrity": "sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", @@ -782,7 +739,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz", "integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.7.1", @@ -798,7 +754,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.2.tgz", "integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/lang-html": "^6.0.0", @@ -812,7 +767,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.3.2", @@ -826,7 +780,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz", "integrity": "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", @@ -837,7 +790,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/@codemirror/lang-sass/-/lang-sass-6.0.2.tgz", "integrity": "sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/lang-css": "^6.2.0", @@ -851,7 +803,6 @@ "version": "6.10.0", "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz", "integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", @@ -866,7 +817,6 @@ "version": "0.1.3", "resolved": "https://registry.npmjs.org/@codemirror/lang-vue/-/lang-vue-0.1.3.tgz", "integrity": "sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/lang-html": "^6.0.0", @@ -881,7 +831,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/@codemirror/lang-wast/-/lang-wast-6.0.2.tgz", "integrity": "sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", @@ -894,7 +843,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", "integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", @@ -909,7 +857,6 @@ "version": "6.1.3", "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz", "integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", @@ -925,7 +872,6 @@ "version": "6.12.3", "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", @@ -940,7 +886,6 @@ "version": "6.5.3", "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.3.tgz", "integrity": "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0" @@ -950,7 +895,6 @@ "version": "6.9.6", "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.6.tgz", "integrity": "sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", @@ -962,7 +906,6 @@ "version": "6.7.2", "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz", "integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", @@ -974,7 +917,6 @@ "version": "6.7.1", "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", - "dev": true, "license": "MIT", "dependencies": { "@marijn/find-cluster-break": "^1.0.0" @@ -984,7 +926,6 @@ "version": "6.1.3", "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", @@ -997,7 +938,6 @@ "version": "6.43.12", "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.12.tgz", "integrity": "sha512-Nv0vxQ19NAqvB/c2pFzjIzFlzzJl7jmdtNkwOwGbn0Ks9mFAzibvumz7cQem5cRsFA2cEw2fg+uHZGbcHupLQQ==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/state": "^6.7.0", @@ -2599,7 +2539,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/@iconify/react/-/react-5.2.1.tgz", "integrity": "sha512-37GDR3fYDZmnmUn9RagyaX+zca24jfVOMY8E1IXTqJuE8pxNtN51KWPQe3VODOWvuUurq7q9uUu3CFrpqj5Iqg==", - "dev": true, "license": "MIT", "dependencies": { "@iconify/types": "^2.0.0" @@ -2615,7 +2554,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", - "dev": true, "license": "MIT" }, "node_modules/@img/colour": { @@ -3144,7 +3082,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -3155,7 +3092,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -3166,7 +3102,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -3176,14 +3111,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -3295,14 +3228,12 @@ "version": "1.5.2", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", - "dev": true, "license": "MIT" }, "node_modules/@lezer/cpp": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.5.tgz", "integrity": "sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw==", - "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -3314,7 +3245,6 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.3.tgz", "integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==", - "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -3326,7 +3256,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz", "integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==", - "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -3338,7 +3267,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", - "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.3.0" @@ -3348,7 +3276,6 @@ "version": "1.3.13", "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", - "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -3360,7 +3287,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.3.tgz", "integrity": "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==", - "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -3372,7 +3298,6 @@ "version": "1.5.4", "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", - "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -3384,7 +3309,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", - "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -3396,7 +3320,6 @@ "version": "1.4.10", "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", - "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.0.0" @@ -3406,7 +3329,6 @@ "version": "1.6.3", "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.3.tgz", "integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==", - "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.5.0", @@ -3417,7 +3339,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.5.tgz", "integrity": "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==", - "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -3429,7 +3350,6 @@ "version": "1.1.18", "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz", "integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==", - "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -3441,7 +3361,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.2.tgz", "integrity": "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==", - "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -3453,7 +3372,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@lezer/sass/-/sass-1.1.0.tgz", "integrity": "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==", - "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -3465,7 +3383,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", "integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==", - "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -3477,7 +3394,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", - "dev": true, "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -3544,14 +3460,12 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", - "dev": true, "license": "MIT" }, "node_modules/@monaco-editor/loader": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", - "dev": true, "license": "MIT", "dependencies": { "state-local": "^1.0.6" @@ -3561,7 +3475,6 @@ "version": "4.7.0", "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", - "dev": true, "license": "MIT", "dependencies": { "@monaco-editor/loader": "^1.5.0" @@ -3576,7 +3489,6 @@ "version": "0.1.100", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz", "integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==", - "dev": true, "license": "MIT", "optional": true, "workspaces": [ @@ -3610,7 +3522,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3631,7 +3542,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3652,7 +3562,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3673,7 +3582,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3694,7 +3602,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3715,7 +3622,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3736,7 +3642,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3757,7 +3662,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3778,7 +3682,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3799,7 +3702,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3820,7 +3722,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5782,7 +5683,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/@replit/codemirror-lang-nix/-/codemirror-lang-nix-6.0.1.tgz", "integrity": "sha512-lvzjoYn9nfJzBD5qdm3Ut6G3+Or2wEacYIDJ49h9+19WSChVnxv4ojf+rNmQ78ncuxIt/bfbMvDLMeMP0xze6g==", - "dev": true, "license": "MIT", "peerDependencies": { "@codemirror/autocomplete": "^6.0.0", @@ -5798,7 +5698,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/@replit/codemirror-lang-solidity/-/codemirror-lang-solidity-6.0.2.tgz", "integrity": "sha512-/dpTVH338KFV6SaDYYSadkB4bI/0B0QRF/bkt1XS3t3QtyR49mn6+2k0OUQhvt2ZSO7kt10J+OPilRAtgbmX0w==", - "dev": true, "license": "MIT", "dependencies": { "@lezer/highlight": "^1.2.0" @@ -5811,7 +5710,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/@replit/codemirror-lang-svelte/-/codemirror-lang-svelte-6.0.0.tgz", "integrity": "sha512-U2OqqgMM6jKelL0GNWbAmqlu1S078zZNoBqlJBW+retTc5M4Mha6/Y2cf4SVg6ddgloJvmcSpt4hHrVoM4ePRA==", - "dev": true, "license": "MIT", "peerDependencies": { "@codemirror/autocomplete": "^6.0.0", @@ -6719,7 +6617,6 @@ "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", @@ -6735,7 +6632,6 @@ "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 20" @@ -6762,7 +6658,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6779,7 +6674,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6796,7 +6690,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6813,7 +6706,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6830,7 +6722,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6847,7 +6738,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6864,7 +6754,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6881,7 +6770,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6898,7 +6786,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6923,7 +6810,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -6940,7 +6826,6 @@ }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { "version": "1.11.1", - "dev": true, "inBundle": true, "license": "MIT", "optional": true, @@ -6951,7 +6836,6 @@ }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { "version": "1.11.1", - "dev": true, "inBundle": true, "license": "MIT", "optional": true, @@ -6961,7 +6845,6 @@ }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { "version": "1.2.2", - "dev": true, "inBundle": true, "license": "MIT", "optional": true, @@ -6971,7 +6854,6 @@ }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", - "dev": true, "inBundle": true, "license": "MIT", "optional": true, @@ -6989,7 +6871,6 @@ }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { "version": "0.10.2", - "dev": true, "inBundle": true, "license": "MIT", "optional": true, @@ -6999,7 +6880,6 @@ }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { "version": "2.8.1", - "dev": true, "inBundle": true, "license": "0BSD", "optional": true @@ -7011,7 +6891,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7028,7 +6907,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7412,7 +7290,6 @@ "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", - "dev": true, "license": "MIT", "dependencies": { "@types/ms": "*" @@ -7436,14 +7313,12 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, "license": "MIT" }, "node_modules/@types/estree-jsx": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "*" @@ -7482,18 +7357,10 @@ "@types/node": "*" } }, - "node_modules/@types/guacamole-common-js": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@types/guacamole-common-js/-/guacamole-common-js-1.5.5.tgz", - "integrity": "sha512-dqDYo/PhbOXFGSph23rFDRZRzXdKPXy/nsTkovFMb6P3iGrd0qGB5r5BXHmX5Cr/LK7L1TK9nYrTMbtPkhdXyg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/unist": "*" @@ -7505,13 +7372,6 @@ "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", "license": "MIT" }, - "node_modules/@types/js-yaml": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", - "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -7530,21 +7390,10 @@ "@types/node": "*" } }, - "node_modules/@types/ldapjs": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/ldapjs/-/ldapjs-3.0.6.tgz", - "integrity": "sha512-E2Tn1ltJDYBsidOT9QG4engaQeQzRQ9aYNxVmjCkD33F7cIeLPgrRDXAYs0O35mK2YDU20c/+ZkNjeAPRGLM0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "dev": true, "license": "MIT", "dependencies": { "@types/unist": "*" @@ -7561,7 +7410,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "dev": true, "license": "MIT" }, "node_modules/@types/multer": { @@ -7583,16 +7431,6 @@ "undici-types": "~8.3.0" } }, - "node_modules/@types/nodemailer": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.2.tgz", - "integrity": "sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/pg": { "version": "8.23.1", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", @@ -7609,19 +7447,8 @@ "version": "1.26.6", "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", - "dev": true, "license": "MIT" }, - "node_modules/@types/qrcode": { - "version": "1.5.6", - "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", - "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", @@ -7679,16 +7506,6 @@ "@types/node": "*" } }, - "node_modules/@types/speakeasy": { - "version": "2.0.10", - "resolved": "https://registry.npmjs.org/@types/speakeasy/-/speakeasy-2.0.10.tgz", - "integrity": "sha512-QVRlDW5r4yl7p7xkNIbAIC/JtyOcClDIIdKfuG7PWdDT1MmyhtXSANsildohy0K+Lmvf/9RUtLbNLMacvrVwxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/ssh2": { "version": "1.15.6", "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.6.tgz", @@ -7744,7 +7561,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "dev": true, "license": "MIT" }, "node_modules/@types/ws": { @@ -7991,7 +7807,6 @@ "version": "4.25.11", "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.11.tgz", "integrity": "sha512-otyFa+n9IOYtEjaKOxPedHkj15fTPUF21wdR9pv0GpZPfuGl27cvmcv6+tognbRu9VvEcsHKE+ESoszeo3KfTw==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", @@ -8019,7 +7834,6 @@ "version": "4.25.11", "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-langs/-/codemirror-extensions-langs-4.25.11.tgz", "integrity": "sha512-RJ6MQTGInT+HBnzEs6PoUmPBgLFbWH3e9ZiF0+H1bdWJXrqN0Kfkiopumz1OCcbWFJ8aVJCD6KPK9mXV/LQ4yA==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/lang-cpp": "^6.0.0", @@ -8056,44 +7870,10 @@ "@codemirror/language": ">=6.0.0" } }, - "node_modules/@uiw/codemirror-theme-github": { - "version": "4.25.11", - "resolved": "https://registry.npmjs.org/@uiw/codemirror-theme-github/-/codemirror-theme-github-4.25.11.tgz", - "integrity": "sha512-3s0LK3gX2mvGI996z3G0tEHZqshbeJRly+QMRsKGAI1Tfr1V475bfaW9NvnoDdINuQHt+RB+ri6q57+WWF8d+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@uiw/codemirror-themes": "4.25.11" - }, - "funding": { - "url": "https://jaywcjlove.github.io/#/sponsor" - } - }, - "node_modules/@uiw/codemirror-themes": { - "version": "4.25.11", - "resolved": "https://registry.npmjs.org/@uiw/codemirror-themes/-/codemirror-themes-4.25.11.tgz", - "integrity": "sha512-SBNCOgRsCtewGNocRbmjbCkltGXlFcPJsvhxQ351VynQjnWUiPbUrFcEU/haQ3HanROdAAjWXZJPk5bMBxl2jw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0" - }, - "funding": { - "url": "https://jaywcjlove.github.io/#/sponsor" - }, - "peerDependencies": { - "@codemirror/language": ">=6.0.0", - "@codemirror/state": ">=6.0.0", - "@codemirror/view": ">=6.0.0" - } - }, "node_modules/@uiw/react-codemirror": { "version": "4.25.11", "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.11.tgz", "integrity": "sha512-DYVFAKLX+F/4JS9N/7xexh+TICrlncwkX9HKKInrP1bwO0tSfc3k0GB6oawTYhelVKh20cX3TuRx+NJSkVXuMw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.6", @@ -8120,7 +7900,6 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", - "dev": true, "license": "ISC" }, "node_modules/@vitejs/plugin-react": { @@ -8343,7 +8122,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0.tgz", "integrity": "sha512-Dl31BCtBhLaUEECUbEiVcCLvLBbaeGYdT7NofB8OJkGTD3MWgBsaLjXvfGAD4tQNHhm6mbKyYkR7XD8kiZsdNg==", - "dev": true, "license": "MIT", "dependencies": { "js-base64": "^3.7.5" @@ -8359,21 +8137,18 @@ "version": "0.16.0", "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz", "integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==", - "dev": true, "license": "MIT" }, "node_modules/@xterm/addon-unicode11": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz", "integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==", - "dev": true, "license": "MIT" }, "node_modules/@xterm/addon-web-links": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", - "dev": true, "license": "MIT" }, "node_modules/@xterm/xterm": { @@ -8952,7 +8727,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", - "dev": true, "license": "MIT", "funding": { "type": "github", @@ -9370,7 +9144,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "dev": true, "license": "MIT", "funding": { "type": "github", @@ -9403,7 +9176,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", - "dev": true, "license": "MIT", "funding": { "type": "github", @@ -9414,7 +9186,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", - "dev": true, "license": "MIT", "funding": { "type": "github", @@ -9425,7 +9196,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "dev": true, "license": "MIT", "funding": { "type": "github", @@ -9436,7 +9206,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", - "dev": true, "license": "MIT", "funding": { "type": "github", @@ -9548,7 +9317,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -9584,7 +9352,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", @@ -9600,7 +9367,6 @@ "version": "0.5.0", "resolved": "https://registry.npmjs.org/codemirror-lang-mermaid/-/codemirror-lang-mermaid-0.5.0.tgz", "integrity": "sha512-Taw/2gPCyNArQJCxIP/HSUif+3zrvD+6Ugt7KJZ2dUKou/8r3ZhcfG8krNTZfV2iu8AuGnymKuo7bLPFyqsh/A==", - "dev": true, "license": "MIT", "dependencies": { "@codemirror/language": "^6.9.0", @@ -9642,7 +9408,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "dev": true, "license": "MIT", "funding": { "type": "github", @@ -10108,7 +9873,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", - "dev": true, "license": "MIT" }, "node_modules/cross-spawn": { @@ -10214,7 +9978,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", - "dev": true, "license": "MIT", "dependencies": { "character-entities": "^2.0.0" @@ -10262,7 +10025,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -10298,7 +10060,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "dev": true, "license": "MIT", "dependencies": { "dequal": "^2.0.0" @@ -10824,7 +10585,6 @@ "version": "5.24.2", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz", "integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -11228,7 +10988,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", - "dev": true, "license": "MIT", "funding": { "type": "opencollective", @@ -11334,7 +11093,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true, "license": "MIT" }, "node_modules/extsprintf": { @@ -11401,7 +11159,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", - "dev": true, "license": "MIT", "dependencies": { "format": "^0.2.0" @@ -11626,7 +11383,6 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", - "dev": true, "engines": { "node": ">=0.4.x" } @@ -11912,14 +11668,12 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/guacamole-common-js": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/guacamole-common-js/-/guacamole-common-js-1.5.0.tgz", "integrity": "sha512-zxztif3GGhKbg1RgOqwmqot8kXgv2HmHFg1EvWwd4q7UfEKvBcYZ0f+7G8HzvU+FUxF0Psqm9Kl5vCbgfrRgJg==", - "dev": true, "license": "Apache 2.0" }, "node_modules/guacamole-lite": { @@ -11988,7 +11742,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", - "dev": true, "license": "MIT", "dependencies": { "@types/hast": "^3.0.0" @@ -12002,7 +11755,6 @@ "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", @@ -12030,7 +11782,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", - "dev": true, "license": "MIT", "dependencies": { "@types/hast": "^3.0.0" @@ -12044,7 +11795,6 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", - "dev": true, "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -12079,7 +11829,6 @@ "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": "*" @@ -12089,7 +11838,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", - "dev": true, "license": "CC0-1.0" }, "node_modules/hosted-git-info": { @@ -12159,7 +11907,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", - "dev": true, "license": "MIT", "funding": { "type": "opencollective", @@ -12368,7 +12115,6 @@ "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", - "dev": true, "license": "MIT" }, "node_modules/ip-address": { @@ -12393,7 +12139,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", - "dev": true, "license": "MIT", "funding": { "type": "github", @@ -12404,7 +12149,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", - "dev": true, "license": "MIT", "dependencies": { "is-alphabetical": "^2.0.0", @@ -12426,7 +12170,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", - "dev": true, "license": "MIT", "funding": { "type": "github", @@ -12469,7 +12212,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", - "dev": true, "license": "MIT", "funding": { "type": "github", @@ -12480,7 +12222,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -12624,7 +12365,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -12643,7 +12383,6 @@ "version": "3.7.8", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/js-tokens": { @@ -12942,7 +12681,6 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -12975,7 +12713,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12996,7 +12733,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -13017,7 +12753,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -13038,7 +12773,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -13059,7 +12793,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -13080,7 +12813,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -13101,7 +12833,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -13122,7 +12853,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -13143,7 +12873,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -13164,7 +12893,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -13185,7 +12913,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -13322,7 +13049,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", - "dev": true, "license": "MIT", "funding": { "type": "github", @@ -13355,7 +13081,6 @@ "version": "1.20.0", "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==", - "dev": true, "license": "MIT", "dependencies": { "fault": "^1.0.0", @@ -13395,7 +13120,6 @@ "version": "1.33.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.33.0.tgz", "integrity": "sha512-MTRwMy0ZlL8Ur/vOAiJ9XGHE+kFPC7brq6MxAm0GiGXEBj0qy0jA/pG4N675oSzciO/UCdX8T+5yUQdmDeTLxg==", - "dev": true, "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -13415,7 +13139,6 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -13437,7 +13160,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz", "integrity": "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1" @@ -13463,7 +13185,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-2.0.0.tgz", "integrity": "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/wojtekmaj/make-event-props?sponsor=1" @@ -13473,7 +13194,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", - "dev": true, "license": "MIT", "funding": { "type": "github", @@ -13493,7 +13213,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", - "dev": true, "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -13510,7 +13229,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -13523,7 +13241,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", - "dev": true, "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -13548,7 +13265,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", - "dev": true, "license": "MIT", "dependencies": { "mdast-util-from-markdown": "^2.0.0", @@ -13568,7 +13284,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -13586,7 +13301,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -13604,7 +13318,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", - "dev": true, "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -13620,7 +13333,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", - "dev": true, "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -13638,7 +13350,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -13655,7 +13366,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree-jsx": "^1.0.0", @@ -13674,7 +13384,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree-jsx": "^1.0.0", @@ -13699,7 +13408,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree-jsx": "^1.0.0", @@ -13718,7 +13426,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", - "dev": true, "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -13733,7 +13440,6 @@ "version": "13.2.1", "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", - "dev": true, "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -13755,7 +13461,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", - "dev": true, "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -13777,7 +13482,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", - "dev": true, "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0" @@ -13819,7 +13523,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-2.0.0.tgz", "integrity": "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/wojtekmaj/merge-refs?sponsor=1" @@ -13847,7 +13550,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", - "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -13883,7 +13585,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", - "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -13918,7 +13619,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", - "dev": true, "license": "MIT", "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", @@ -13939,7 +13639,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", - "dev": true, "license": "MIT", "dependencies": { "micromark-util-character": "^2.0.0", @@ -13956,7 +13655,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", - "dev": true, "license": "MIT", "dependencies": { "devlop": "^1.0.0", @@ -13977,7 +13675,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", - "dev": true, "license": "MIT", "dependencies": { "devlop": "^1.0.0", @@ -13996,7 +13693,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", - "dev": true, "license": "MIT", "dependencies": { "devlop": "^1.0.0", @@ -14014,7 +13710,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", - "dev": true, "license": "MIT", "dependencies": { "micromark-util-types": "^2.0.0" @@ -14028,7 +13723,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", - "dev": true, "license": "MIT", "dependencies": { "devlop": "^1.0.0", @@ -14046,7 +13740,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", - "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -14068,7 +13761,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", - "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -14091,7 +13783,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", - "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -14112,7 +13803,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", - "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -14135,7 +13825,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", - "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -14158,7 +13847,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -14179,7 +13867,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", - "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -14199,7 +13886,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", - "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -14221,7 +13907,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", - "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -14242,7 +13927,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", - "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -14262,7 +13946,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", - "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -14285,7 +13968,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", - "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -14302,7 +13984,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", - "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -14319,7 +14000,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", - "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -14339,7 +14019,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", - "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -14359,7 +14038,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", - "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -14381,7 +14059,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", - "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -14404,7 +14081,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -14421,7 +14097,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", - "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -15050,7 +14725,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", - "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^2.0.0", @@ -15070,7 +14744,6 @@ "version": "2.0.11", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", - "dev": true, "license": "MIT" }, "node_modules/parse-json": { @@ -15204,7 +14877,6 @@ "version": "5.4.296", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=20.16.0 || >=22.3.0" @@ -15572,7 +15244,6 @@ "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -15658,7 +15329,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", - "dev": true, "license": "MIT", "funding": { "type": "github", @@ -16038,7 +15708,6 @@ "version": "3.10.2", "resolved": "https://registry.npmjs.org/react-h5-audio-player/-/react-h5-audio-player-3.10.2.tgz", "integrity": "sha512-2RlP1eF9V0KPZgBzaYM/hwIE1KxKV+E84gOkpaf0+3X1r56USO+V7AuCVa2FSTeWkQnrB03Osay99hR9FbSefQ==", - "dev": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.10.2", @@ -16052,23 +15721,6 @@ "react-dom": "^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/react-hook-form": { - "version": "7.85.0", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.85.0.tgz", - "integrity": "sha512-U2MTriFXnclmV4rOE20p2DcRFv5WEg3FIcBFOKcOLFHDVvGIMPvLTkTWefUsonmlaVy23khVDxDWym6uJVGOzw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/react-hook-form" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17 || ^18 || ^19" - } - }, "node_modules/react-i18next": { "version": "17.0.14", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.14.tgz", @@ -16101,7 +15753,6 @@ "version": "5.7.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz", "integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==", - "dev": true, "license": "MIT", "peerDependencies": { "react": "*" @@ -16117,7 +15768,6 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -16145,7 +15795,6 @@ "version": "10.5.0", "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.5.0.tgz", "integrity": "sha512-zGKb6Ph5AYn2OfO4TS8Gg0Go3eW5K/TKy0+0FTm8EwFNbpgbuaWBZoJdYkJt+cRBCkizE9dYvqtMtHRj522Q4w==", - "dev": true, "license": "MIT", "dependencies": { "clsx": "^2.0.0", @@ -16175,7 +15824,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/react-photo-view/-/react-photo-view-1.2.7.tgz", "integrity": "sha512-MfOWVPxuibncRLaycZUNxqYU8D9IA+rbGDDaq6GM8RIoGJal592hEJoRAyRSI7ZxyyJNJTLMUWWL3UIXHJJOpw==", - "dev": true, "license": "Apache-2.0", "peerDependencies": { "react": ">=16.8.0", @@ -16258,7 +15906,6 @@ "version": "16.1.1", "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", "integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.4", @@ -16358,7 +16005,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==", - "dev": true, "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -16375,7 +16021,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", - "dev": true, "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -16394,7 +16039,6 @@ "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", - "dev": true, "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -16411,7 +16055,6 @@ "version": "11.1.2", "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", - "dev": true, "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -16429,7 +16072,6 @@ "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", - "dev": true, "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -17031,7 +16673,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -17052,7 +16693,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "dev": true, "license": "MIT", "funding": { "type": "github", @@ -17143,7 +16783,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", - "dev": true, "license": "MIT" }, "node_modules/statuses": { @@ -17234,7 +16873,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", - "dev": true, "license": "MIT", "dependencies": { "character-entities-html4": "^2.0.0", @@ -17278,14 +16916,12 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", - "dev": true, "license": "MIT" }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", - "dev": true, "license": "MIT", "dependencies": { "style-to-object": "1.0.14" @@ -17295,7 +16931,6 @@ "version": "1.0.14", "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", - "dev": true, "license": "MIT", "dependencies": { "inline-style-parser": "0.2.7" @@ -17391,7 +17026,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", - "dev": true, "license": "MIT", "funding": { "type": "github", @@ -17402,14 +17036,12 @@ "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", - "dev": true, "license": "MIT" }, "node_modules/tapable": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -17479,7 +17111,6 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "dev": true, "license": "MIT" }, "node_modules/tinybench": { @@ -17625,7 +17256,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "dev": true, "license": "MIT", "funding": { "type": "github", @@ -17636,7 +17266,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", - "dev": true, "license": "MIT", "funding": { "type": "github", @@ -17832,7 +17461,6 @@ "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", - "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -17852,7 +17480,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", - "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" @@ -17866,7 +17493,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", - "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" @@ -17880,7 +17506,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" @@ -17894,7 +17519,6 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", - "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -17910,7 +17534,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -18143,7 +17766,6 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", - "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -18158,7 +17780,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", - "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -18617,7 +18238,6 @@ "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", - "dev": true, "license": "MIT" }, "node_modules/w3c-xmlserializer": { @@ -18637,7 +18257,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.0.0" @@ -18900,7 +18519,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "dev": true, "license": "MIT", "funding": { "type": "github", @@ -18912,8 +18530,11 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@tailwindcss/node": "^4.2.4", + "@tailwindcss/oxide": "^4.2.4", "esbuild": "^0.28.1", "semver": "^7.6.3", + "tailwindcss": "^4.2.4", "tar": "^7.5.22" }, "bin": { @@ -18923,13 +18544,21 @@ "node": ">=22" }, "peerDependencies": { + "@testing-library/react": "^16.3.3", "better-sqlite3": "*", "drizzle-orm": "*", + "i18next": "^26.4.2", "react": "*", + "react-dom": "^19.2.8", + "react-i18next": "^17.0.14", + "sonner": "^2.0.8", "ssh2": "*", "vitest": "*" }, "peerDependenciesMeta": { + "@testing-library/react": { + "optional": true + }, "better-sqlite3": { "optional": true }, @@ -18954,78 +18583,289 @@ "acme-client": "^5.4.0" }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8" } }, "plugins/ai": { "name": "@termix-plugin/ai", "version": "1.0.0", + "dependencies": { + "@anthropic-ai/sdk": "^0.120.0", + "lucide-react": "^1.33.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8", + "ssh2": "^1.17.0" } }, "plugins/alerts": { "name": "@termix-plugin/alerts", "version": "1.0.0", "dependencies": { + "lucide-react": "^1.33.0", "nodemailer": "^10.0.10" }, "devDependencies": { - "@termix/plugin-sdk": "*", - "@types/nodemailer": "^8.0.2" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8" } }, "plugins/automations": { "name": "@termix-plugin/automations", "version": "1.0.0", + "dependencies": { + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8", + "ssh2": "^1.17.0" } }, "plugins/docker": { "name": "@termix-plugin/docker", "version": "1.0.0", + "dependencies": { + "@xterm/addon-clipboard": "^0.2.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", + "lucide-react": "^1.33.0", + "react-xtermjs": "^1.0.10" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8", + "ssh2": "^1.17.0", + "ws": "^8.21.3" } }, "plugins/file-manager": { "name": "@termix-plugin/file-manager", "version": "1.0.0", + "dependencies": { + "@codemirror/autocomplete": "^6.20.3", + "@codemirror/commands": "^6.11.1", + "@codemirror/search": "^6.7.2", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.43.12", + "@monaco-editor/react": "^4.7.0", + "@tanstack/react-virtual": "^3.14.13", + "@uiw/codemirror-extensions-langs": "^4.25.11", + "@uiw/react-codemirror": "^4.25.11", + "busboy": "^1.6.0", + "jszip": "^3.10.2", + "lucide-react": "^1.33.0", + "motion": "^13.1.1", + "pdfjs-dist": "5.4.296", + "react-h5-audio-player": "^3.10.2", + "react-icons": "^5.7.0", + "react-markdown": "^10.1.0", + "react-pdf": "^10.5.0", + "react-photo-view": "^1.2.7", + "react-syntax-highlighter": "^16.1.1", + "remark-gfm": "^4.0.1" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "axios": "^1.19.0", + "cookie-parser": "^1.4.7", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "i18next": "^26.4.2", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-i18next": "^17.0.14", + "sonner": "^2.0.8", + "ssh2": "^1.17.0" } }, "plugins/fleets": { "name": "@termix-plugin/fleets", "version": "1.0.0", + "dependencies": { + "clsx": "^2.1.1", + "jszip": "^3.10.2", + "lucide-react": "^1.33.0", + "tailwind-merge": "^3.5.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "multer": "^2.3.0", + "react": "^19.2.8", + "sonner": "^2.0.8", + "ssh2": "^1.17.0" } }, "plugins/homepage": { "name": "@termix-plugin/homepage", "version": "1.0.0", + "dependencies": { + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8" } }, "plugins/host-metrics": { "name": "@termix-plugin/host-metrics", "version": "1.0.0", + "dependencies": { + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8", + "ssh2": "^1.17.0" } }, "plugins/ldap": { "name": "@termix-plugin/ldap", "version": "1.0.0", "dependencies": { - "ldapjs": "^3.0.7" + "ldapjs": "^3.0.7", + "lucide-react": "^1.33.0" }, "devDependencies": { - "@termix/plugin-sdk": "*", - "@types/ldapjs": "^3.0.6" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8" } }, "plugins/network-topology": { @@ -19033,59 +18873,177 @@ "version": "1.0.0", "dependencies": { "cytoscape": "^3.34.3", + "lucide-react": "^1.33.0", "react-cytoscapejs": "^2.0.0" }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8" } }, "plugins/opkssh": { "name": "@termix-plugin/opkssh", "version": "1.0.0", "dependencies": { - "js-yaml": "^5.2.3" + "js-yaml": "^5.2.3", + "lucide-react": "^1.33.0" }, "devDependencies": { - "@termix/plugin-sdk": "*", - "@types/js-yaml": "^4.0.9" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "axios": "^1.19.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8" } }, "plugins/proxmox": { "name": "@termix-plugin/proxmox", "version": "1.0.0", + "dependencies": { + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8", + "ssh2": "^1.17.0" } }, "plugins/remote-desktop": { "name": "@termix-plugin/remote-desktop", "version": "1.0.0", + "dependencies": { + "guacamole-common-js": "^1.5.0", + "guacamole-lite": "^1.2.0", + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "express": "^5.2.1", + "react": "^19.2.8", + "react-i18next": "^17.0.14", + "sonner": "^2.0.8", + "ssh2": "^1.17.0" } }, "plugins/secret-sources": { "name": "@termix-plugin/secret-sources", "version": "1.0.0", + "dependencies": { + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8" } }, "plugins/serial": { "name": "@termix-plugin/serial", "version": "1.0.0", "dependencies": { + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", + "lucide-react": "^1.33.0", + "react-xtermjs": "^1.0.10", "serialport": "^13.0.0" }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "react": "^19.2.8", + "ws": "^8.21.3" } }, "plugins/session-recording": { "name": "@termix-plugin/session-recording", "version": "1.0.0", + "dependencies": { + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", + "guacamole-common-js": "^1.5.0", + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8" } }, "plugins/session-sharing": { @@ -19094,25 +19052,93 @@ "dependencies": { "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", + "lucide-react": "^1.33.0", "react-xtermjs": "^1.0.10", "redis": "^6.2.1" }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8", + "ws": "^8.21.3" } }, "plugins/snippets": { "name": "@termix-plugin/snippets", "version": "1.0.0", + "dependencies": { + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8", + "ssh2": "^1.17.0" } }, "plugins/ssh-terminal": { "name": "@termix-plugin/ssh-terminal", "version": "1.0.0", + "dependencies": { + "@xterm/addon-clipboard": "^0.2.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-unicode11": "^0.9.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", + "lucide-react": "^1.33.0", + "react-xtermjs": "^1.0.10" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "multer": "^2.3.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "sharp": "^0.35.4", + "sonner": "^2.0.8", + "ssh2": "^1.17.0", + "ws": "^8.21.3" } }, "plugins/sso": { @@ -19120,89 +19146,268 @@ "version": "1.0.0", "dependencies": { "jose": "^6.2.12", + "lucide-react": "^1.33.0", "undici": "^8.10.2" }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8" } }, "plugins/step-ca": { "name": "@termix-plugin/step-ca", "version": "1.0.0", "dependencies": { + "lucide-react": "^1.33.0", "redis": "^6.2.1" }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8" } }, "plugins/tailscale": { "name": "@termix-plugin/tailscale", "version": "1.0.0", + "dependencies": { + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8", + "ssh2": "^1.17.0" } }, "plugins/termix-identity": { "name": "@termix-plugin/termix-identity", "version": "1.0.0", + "dependencies": { + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8", + "ssh2": "^1.17.0" } }, "plugins/tmux-monitor": { "name": "@termix-plugin/tmux-monitor", "version": "1.0.0", + "dependencies": { + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "ssh2": "^1.17.0" } }, "plugins/totp": { "name": "@termix-plugin/totp", "version": "1.0.0", "dependencies": { + "lucide-react": "^1.33.0", "qrcode": "^1.5.4", "speakeasy": "^2.0.0" }, "devDependencies": { - "@termix/plugin-sdk": "*", - "@types/qrcode": "^1.5.6", - "@types/speakeasy": "^2.0.10" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8" } }, "plugins/tunnels": { "name": "@termix-plugin/tunnels", "version": "1.0.0", + "dependencies": { + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8", + "ssh2": "^1.17.0", + "ws": "^8.21.3" } }, "plugins/vault": { "name": "@termix-plugin/vault", "version": "1.0.0", + "dependencies": { + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8", + "ssh2": "^1.17.0" } }, "plugins/wake-on-lan": { "name": "@termix-plugin/wake-on-lan", "version": "1.0.0", + "dependencies": { + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "express": "^5.2.1", + "sonner": "^2.0.8" } }, "plugins/warpgate": { "name": "@termix-plugin/warpgate", "version": "1.0.0", "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0" } }, "plugins/web-endpoint": { "name": "@termix-plugin/web-endpoint", "version": "1.0.0", + "dependencies": { + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "axios": "^1.19.0", + "express": "^5.2.1", + "i18next": "^26.4.2", + "react": "^19.2.8", + "sonner": "^2.0.8" } }, "plugins/webauthn": { @@ -19210,17 +19415,51 @@ "version": "1.0.0", "dependencies": { "@simplewebauthn/browser": "^13.3.0", - "@simplewebauthn/server": "^13.3.2" + "@simplewebauthn/server": "^13.3.2", + "lucide-react": "^1.33.0" }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8" } }, "plugins/workspaces": { "name": "@termix-plugin/workspaces", "version": "1.0.0", + "dependencies": { + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8" } } } diff --git a/package.json b/package.json index 28d4e4ec6..e6767dcf7 100644 --- a/package.json +++ b/package.json @@ -16,12 +16,12 @@ "scripts": { "format": "prettier --write .", "format:check": "prettier --check .", - "postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-guacamole-lite.cjs && node scripts/patch-guacamole-common-js.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-xterm-android-ime.cjs && node scripts/patch-ssh2-agent.cjs", + "postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs && node scripts/apply-plugin-patches.cjs", "prepare": "husky || true", "prebuild": "node scripts/write-electron-build-info.cjs", - "lint": "node scripts/generate-dialect-schema.cjs --check && npm run plugin:validate-manifest && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-plugin-locales.cjs && node scripts/check-test-locations.cjs", + "lint": "node scripts/generate-dialect-schema.cjs --check && npm run plugin:validate-manifest && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-plugin-locales.cjs && node scripts/check-plugin-uses.cjs && node scripts/check-test-locations.cjs", "lint:fix": "eslint --fix .", - "type-check": "tsc -b --force", + "type-check": "npm run build:sdk && tsc -b --force", "test": "vitest run", "verify:dialect": "tsx scripts/verify-dialects.mjs", "test:watch": "vitest", @@ -49,13 +49,12 @@ "schema:check": "node scripts/generate-dialect-schema.cjs --check", "schema:migrations": "drizzle-kit generate --config=drizzle.config.sqlite.ts && drizzle-kit generate --config=drizzle.config.pg.ts && drizzle-kit generate --config=drizzle.config.mysql.ts", "plugin:validate-manifest": "node scripts/validate-plugin-manifest.cjs", - "build:sdk": "npm run build --workspace @termix/plugin-sdk", + "build:sdk": "npm run build --workspace @termix/plugin-sdk && node scripts/build-sdk-host.mjs", "build:plugins": "node scripts/build-plugins.cjs", "test:plugins": "node scripts/run-plugin-tests.cjs", "test:all": "npm run test && npm run test:plugins" }, "dependencies": { - "@anthropic-ai/sdk": "^0.120.0", "@tanstack/react-virtual": "^3.14.13", "@types/compression": "^1.8.1", "@types/semver": "^7.8.0", @@ -70,9 +69,7 @@ "dotenv": "^17.4.2", "drizzle-orm": "^0.45.2", "express": "^5.2.1", - "guacamole-lite": "^1.2.0", "jsonwebtoken": "^9.0.3", - "jszip": "^3.10.2", "motion": "^13.1.1", "multer": "^2.3.0", "mysql2": "^3.23.4", @@ -88,9 +85,6 @@ "ws": "^8.21.3" }, "devDependencies": { - "@codemirror/autocomplete": "^6.20.3", - "@codemirror/commands": "^6.11.1", - "@codemirror/search": "^6.7.2", "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.43.12", "@commitlint/cli": "^21.2.2", @@ -103,24 +97,17 @@ "@fontsource/fira-code": "^5.3.0", "@fontsource/jetbrains-mono": "^5.3.0", "@fontsource/source-code-pro": "^5.3.0", - "@monaco-editor/react": "^4.7.0", "@peculiar/x509": "^2.1.0", - "@radix-ui/react-accordion": "^1.2.20", "@radix-ui/react-alert-dialog": "^1.1.23", "@radix-ui/react-checkbox": "^1.3.11", - "@radix-ui/react-dialog": "^1.1.23", - "@radix-ui/react-dropdown-menu": "^2.1.24", "@radix-ui/react-label": "^2.1.15", "@radix-ui/react-popover": "^1.1.23", - "@radix-ui/react-progress": "^1.1.16", "@radix-ui/react-scroll-area": "^1.2.18", "@radix-ui/react-select": "^2.3.7", - "@radix-ui/react-separator": "^1.1.15", "@radix-ui/react-slider": "^1.4.7", "@radix-ui/react-slot": "^1.3.3", "@radix-ui/react-switch": "^1.3.7", "@radix-ui/react-tabs": "^1.1.21", - "@radix-ui/react-tooltip": "^1.2.16", "@tailwindcss/vite": "^4.3.3", "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^7.0.1", @@ -130,7 +117,6 @@ "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", - "@types/guacamole-common-js": "^1.5.5", "@types/jsonwebtoken": "^9.0.10", "@types/multer": "^2.2.0", "@types/node": "^26.2.0", @@ -140,18 +126,11 @@ "@types/ssh2": "^1.15.6", "@types/supertest": "^7.2.1", "@types/ws": "^8.18.1", - "@uiw/codemirror-extensions-langs": "^4.25.11", - "@uiw/codemirror-theme-github": "^4.25.11", "@uiw/react-codemirror": "^4.25.11", "@vitejs/plugin-react": "^6.1.1", "@vitest/coverage-v8": "^4.1.11", "@vitest/ui": "^4.1.11", "@xterm/addon-clipboard": "^0.2.0", - "@xterm/addon-fit": "^0.11.0", - "@xterm/addon-search": "^0.16.0", - "@xterm/addon-unicode11": "^0.9.0", - "@xterm/addon-web-links": "^0.12.0", - "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -164,7 +143,6 @@ "eslint-plugin-react-refresh": "^0.5.7", "eslint-plugin-unused-imports": "^4.4.1", "globals": "^17.11.0", - "guacamole-common-js": "^1.5.0", "husky": "^9.1.7", "i18next": "^26.4.2", "i18next-browser-languagedetector": "^8.2.1", @@ -176,17 +154,8 @@ "radix-ui": "^1.6.7", "react": "^19.2.8", "react-dom": "^19.2.8", - "react-h5-audio-player": "^3.10.2", - "react-hook-form": "^7.84.0", "react-i18next": "^17.0.14", - "react-icons": "^5.7.0", - "react-markdown": "^10.1.0", - "react-pdf": "^10.5.0", - "react-photo-view": "^1.2.7", - "react-syntax-highlighter": "^16.1.1", - "react-xtermjs": "^1.0.10", "reflect-metadata": "^0.2.2", - "remark-gfm": "^4.0.1", "sonner": "^2.0.8", "supertest": "^7.3.0", "tailwind-merge": "^3.5.0", diff --git a/packages/plugin-sdk/cli/commands/build.mjs b/packages/plugin-sdk/cli/commands/build.mjs index f2eebbc74..3c70bf0bf 100644 --- a/packages/plugin-sdk/cli/commands/build.mjs +++ b/packages/plugin-sdk/cli/commands/build.mjs @@ -1,5 +1,7 @@ import fs from "node:fs"; import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; import * as esbuild from "esbuild"; import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs"; import { staticUrlImports } from "../lib/static-url-imports.mjs"; @@ -48,11 +50,110 @@ export function externalRequireInterop(externals) { }; } +const SDK_PATCHES = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "patches", +); + +/** + * Patches a dependency before it is bundled, so the fix ships inside the + * plugin rather than depending on whoever ran npm install. + * + * package.json "termix": { "patches": ["xterm-android-ime"] } names patches + * the SDK ships for libraries several plugins share. Any .cjs file in the + * plugin's own patches/ folder runs too. Every patch must be safe to run + * twice. + */ +export function applyPatches(cwd, pluginId) { + const pkgPath = path.join(cwd, "package.json"); + const pkg = fs.existsSync(pkgPath) + ? JSON.parse(fs.readFileSync(pkgPath, "utf8")) + : {}; + const scripts = []; + for (const name of pkg.termix?.patches ?? []) { + const file = path.join(SDK_PATCHES, `${name}.cjs`); + if (!/^[a-z0-9-]+$/.test(name) || !fs.existsSync(file)) { + throw new Error(`${pluginId}: the SDK has no patch named "${name}"`); + } + scripts.push(file); + } + const own = path.join(cwd, "patches"); + if (fs.existsSync(own)) { + for (const file of fs.readdirSync(own).sort()) { + if (file.endsWith(".cjs")) scripts.push(path.join(own, file)); + } + } + for (const file of scripts) { + const result = spawnSync(process.execPath, [file], { + cwd, + env: { ...process.env, TERMIX_PATCH_ROOT: cwd }, + stdio: "inherit", + }); + if (result.status !== 0) { + throw new Error(`${pluginId}: patch ${path.basename(file)} failed`); + } + } +} + +const THEME_CSS = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "dist", + "host", + "theme.css", +); + +/** + * Compiles the Tailwind classes the plugin's frontend uses into + * dist/frontend.css, against the theme core ships in the SDK. Core only + * compiles its own classes, so a class no core file happens to use would + * otherwise have no CSS at all. Appended to whatever CSS the bundle already + * produced (a library's stylesheet the plugin imports). + */ +export async function buildTailwind(cwd, outDir) { + const { compile, optimize } = await import("@tailwindcss/node"); + const { Scanner } = await import("@tailwindcss/oxide"); + if (!fs.existsSync(THEME_CSS)) { + throw new Error("The SDK theme is missing. Run: npm run build:sdk"); + } + const input = [ + '@import "tailwindcss/theme.css" theme(reference);', + `@import ${JSON.stringify(THEME_CSS.replaceAll("\\", "/"))};`, + '@import "tailwindcss/utilities.css" layer(utilities);', + ].join("\n"); + const compiler = await compile(input, { + base: path.dirname(THEME_CSS), + onDependency: () => {}, + }); + const scanner = new Scanner({ + sources: [ + { + base: path.join(cwd, "src", "frontend"), + pattern: "**/*", + negated: false, + }, + ], + }); + // Flattened and minified the way core's own CSS is, so it does not rely + // on native CSS nesting. + const css = optimize(compiler.build(scanner.scan()), { + minify: true, + }).code.trim(); + if (!css) return; + const target = path.join(outDir, "frontend.css"); + const existing = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : ""; + fs.writeFileSync(target, `${existing}${existing ? "\n" : ""}${css}\n`); +} + export async function build({ cwd }) { const manifest = readManifest(cwd); const pluginId = manifest.id ?? path.basename(cwd); const outDir = path.join(cwd, "dist"); + applyPatches(cwd, pluginId); + fs.rmSync(outDir, { recursive: true, force: true }); fs.mkdirSync(outDir, { recursive: true }); @@ -123,6 +224,8 @@ export async function build({ cwd }) { }); } + if (frontendEntry) await buildTailwind(cwd, outDir); + copyDir(path.join(cwd, "locales"), path.join(outDir, "locales")); copyDir(path.join(cwd, "migrations"), path.join(outDir, "migrations")); diff --git a/packages/plugin-sdk/cli/commands/validate.mjs b/packages/plugin-sdk/cli/commands/validate.mjs index a1b86454e..60591114c 100644 --- a/packages/plugin-sdk/cli/commands/validate.mjs +++ b/packages/plugin-sdk/cli/commands/validate.mjs @@ -3,7 +3,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { readManifest } from "../lib/plugin-dir.mjs"; import { LEGACY_TABLE_OWNERS as LEGACY_TABLES } from "../../dist/db.js"; -import { findUnownedTableWrites } from "../../dist/ddl.js"; +import { collectOwnedIndexes, findUnownedTableWrites } from "../../dist/ddl.js"; /** * The manifest rules live in src/manifest.ts, which the server uses too, so @@ -39,6 +39,7 @@ export async function validate({ cwd }) { problems.push(...validateMigrations(cwd, raw.id ?? path.basename(cwd))); problems.push(...validateNativeDependencies(cwd, raw)); + problems.push(...validatePackage(cwd, raw)); if (problems.length > 0) { for (const problem of problems) console.error(` ${problem}`); @@ -48,6 +49,36 @@ export async function validate({ cwd }) { console.log(`ok ${raw.id ?? path.basename(cwd)}`); } +/** + * 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. + */ +function validatePackage(cwd, raw) { + const problems = []; + const pkgPath = path.join(cwd, "package.json"); + if (!fs.existsSync(pkgPath)) return problems; + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); + + if (pkg.version && raw.version && pkg.version !== raw.version) { + problems.push( + `package.json version ${pkg.version} does not match manifest version ${raw.version}`, + ); + } + + const sdkRange = + pkg.peerDependencies?.["@termix/plugin-sdk"] ?? + pkg.dependencies?.["@termix/plugin-sdk"] ?? + pkg.devDependencies?.["@termix/plugin-sdk"]; + if (!sdkRange) { + problems.push("package.json does not depend on @termix/plugin-sdk"); + } else if (sdkRange === "*" || sdkRange === "latest") { + problems.push( + `@termix/plugin-sdk is "${sdkRange}"; pin a range such as "^1.0.0"`, + ); + } + return problems; +} + /** * A native dependency has to actually be a real dependency of the plugin, or * the build's external declaration points at nothing node_modules can @@ -106,16 +137,25 @@ function validateMigrations(cwd, pluginId) { .filter((file) => file.endsWith(".sql")) .sort(); byDialect.set(dialect, files); + const sqls = files.map((file) => + fs.readFileSync(path.join(dir, file), "utf8"), + ); + const indexes = collectOwnedIndexes(pluginId, sqls, legacy); - for (const file of files) { + for (const [index, file] of files.entries()) { if (!/^\d{4}_[a-z0-9_]+\.sql$/.test(file)) { problems.push( `migrations/${dialect}/${file} must be named NNNN_name.sql, lower snake_case`, ); } - const sql = fs.readFileSync(path.join(dir, file), "utf8"); - for (const problem of findUnownedTableWrites(pluginId, sql, legacy)) { + const sql = sqls[index]; + for (const problem of findUnownedTableWrites( + pluginId, + sql, + legacy, + indexes, + )) { problems.push(`migrations/${dialect}/${file} ${problem}`); } } diff --git a/packages/plugin-sdk/cli/index.mjs b/packages/plugin-sdk/cli/index.mjs index 5b6eb95df..122f192be 100644 --- a/packages/plugin-sdk/cli/index.mjs +++ b/packages/plugin-sdk/cli/index.mjs @@ -6,7 +6,8 @@ */ import process from "node:process"; -import { build } from "./commands/build.mjs"; +import { applyPatches, build } from "./commands/build.mjs"; +import { readManifest } from "./lib/plugin-dir.mjs"; import { validate } from "./commands/validate.mjs"; import { test } from "./commands/test.mjs"; import { pack } from "./commands/pack.mjs"; @@ -15,6 +16,7 @@ import { migrations } from "./commands/migrations.mjs"; const COMMANDS = { build, + patch: async ({ cwd }) => applyPatches(cwd, readManifest(cwd).id), validate, test, pack, @@ -32,6 +34,7 @@ if (!command || command === "--help" || command === "-h") { "Usage: termix-plugin ", "", " build Bundle the plugin into dist/", + " patch Apply the plugin's dependency patches (build does this too)", " validate Check manifest.json and the files it names", " test Run the plugin's vitest suite", " pack Write -.tmxplug of the built plugin [--out dir]", diff --git a/packages/plugin-sdk/cli/lib/externals.mjs b/packages/plugin-sdk/cli/lib/externals.mjs index cb2fd3127..a9a0e8231 100644 --- a/packages/plugin-sdk/cli/lib/externals.mjs +++ b/packages/plugin-sdk/cli/lib/externals.mjs @@ -6,6 +6,10 @@ * a second ssh2 would mean a second set of native bindings. A plugin may * depend on anything outside these lists, and that does get bundled. * + * A plugin lists the ones it uses as peerDependencies. Only add a package here + * when core itself ships it; a library one plugin needs belongs in that + * plugin's own dependencies, so it can update without a core release. + * * Node builtins are external automatically through platform: "node". */ @@ -18,9 +22,6 @@ export const BACKEND_EXTERNALS = [ "multer", "cookie-parser", "axios", - "jszip", - "guacamole-lite", - "@anthropic-ai/sdk", "drizzle-orm", "drizzle-orm/*", // Native image processing, loaded on first use by the terminal's image upload. diff --git a/scripts/patch-guacamole-common-js.cjs b/packages/plugin-sdk/cli/patches/guacamole-common-js-flush.cjs similarity index 71% rename from scripts/patch-guacamole-common-js.cjs rename to packages/plugin-sdk/cli/patches/guacamole-common-js-flush.cjs index 039071de4..a088d80a3 100644 --- a/scripts/patch-guacamole-common-js.cjs +++ b/packages/plugin-sdk/cli/patches/guacamole-common-js-flush.cjs @@ -1,12 +1,20 @@ const fs = require("fs"); const path = require("path"); -const packageRoot = path.join( - __dirname, - "..", - "node_modules", - "guacamole-common-js", -); +// Run by `termix-plugin build` with TERMIX_PATCH_ROOT set to the plugin, so +// the package resolves the way the plugin's bundle will resolve it. +function packageDir(name) { + const root = process.env.TERMIX_PATCH_ROOT || process.cwd(); + // Walked by hand: a package whose exports map hides package.json cannot be + // found with require.resolve. + for (let dir = path.resolve(root); ; dir = path.dirname(dir)) { + const candidate = path.join(dir, "node_modules", name); + if (fs.existsSync(path.join(candidate, "package.json"))) return candidate; + if (path.dirname(dir) === dir) return path.join(root, "node_modules", name); + } +} + +const packageRoot = path.join(packageDir("guacamole-common-js")); const bundlePaths = [ path.join(packageRoot, "dist", "esm", "guacamole-common.js"), diff --git a/scripts/patch-xterm-android-ime.cjs b/packages/plugin-sdk/cli/patches/xterm-android-ime.cjs similarity index 91% rename from scripts/patch-xterm-android-ime.cjs rename to packages/plugin-sdk/cli/patches/xterm-android-ime.cjs index c527748d0..6da9b685b 100644 --- a/scripts/patch-xterm-android-ime.cjs +++ b/packages/plugin-sdk/cli/patches/xterm-android-ime.cjs @@ -1,14 +1,20 @@ const fs = require("node:fs"); const path = require("node:path"); -const xtermDir = path.join( - __dirname, - "..", - "node_modules", - "@xterm", - "xterm", - "lib", -); +// Run by `termix-plugin build` with TERMIX_PATCH_ROOT set to the plugin, so +// the package resolves the way the plugin's bundle will resolve it. +function packageDir(name) { + const root = process.env.TERMIX_PATCH_ROOT || process.cwd(); + // Walked by hand: a package whose exports map hides package.json cannot be + // found with require.resolve. + for (let dir = path.resolve(root); ; dir = path.dirname(dir)) { + const candidate = path.join(dir, "node_modules", name); + if (fs.existsSync(path.join(candidate, "package.json"))) return candidate; + if (path.dirname(dir) === dir) return path.join(root, "node_modules", name); + } +} + +const xtermDir = path.join(packageDir("@xterm/xterm"), "lib"); // Backport the textarea-shrink fix from gmuxapp/xterm.js@6a011cf while // xtermjs/xterm.js#3600 remains unresolved upstream. Android IMEs can restart diff --git a/packages/plugin-sdk/client.d.ts b/packages/plugin-sdk/client.d.ts new file mode 100644 index 000000000..708260053 --- /dev/null +++ b/packages/plugin-sdk/client.d.ts @@ -0,0 +1,13 @@ +/** + * Import forms `termix-plugin build` understands beyond plain modules. Picked + * up through tsconfig.plugin.json. + */ + +/** A script copied next to the bundle; the import is its URL. */ +declare module "*?url" { + const url: string; + export default url; +} + +/** A stylesheet imported for its side effect; the build emits frontend.css. */ +declare module "*.css"; diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 1e4f6ca24..dd2695db1 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -37,6 +37,10 @@ "types": "./dist/frontend.d.ts", "default": "./dist/frontend.js" }, + "./desktop": { + "types": "./dist/desktop.d.ts", + "default": "./dist/desktop.js" + }, "./manifest": { "types": "./dist/manifest.d.ts", "default": "./dist/manifest.js" @@ -70,29 +74,46 @@ "default": "./dist/ssh-certs.js" }, "./tsconfig.plugin.json": "./tsconfig.plugin.json", - "./schema/manifest.schema.json": "./schema/manifest.schema.json" + "./schema/manifest.schema.json": "./schema/manifest.schema.json", + "./ui": { + "types": "./dist/host/ui.d.ts", + "default": "./dist/host/ui.js" + }, + "./testing-host": { + "default": "./dist/host/testing-host.js" + }, + "./theme.css": "./dist/host/theme.css" }, "files": [ "dist", "cli", "schema", "tsconfig.plugin.json", - "README.md" + "README.md", + "client.d.ts" ], "scripts": { "build": "tsc -p tsconfig.json", "prepublishOnly": "npm run build" }, "dependencies": { + "@tailwindcss/node": "^4.2.4", + "@tailwindcss/oxide": "^4.2.4", "esbuild": "^0.28.1", "semver": "^7.6.3", + "tailwindcss": "^4.2.4", "tar": "^7.5.22" }, "peerDependencies": { - "drizzle-orm": "*", - "ssh2": "*", + "@testing-library/react": "^16.3.3", "better-sqlite3": "*", + "drizzle-orm": "*", + "i18next": "^26.4.2", "react": "*", + "react-dom": "^19.2.8", + "react-i18next": "^17.0.14", + "sonner": "^2.0.8", + "ssh2": "*", "vitest": "*" }, "peerDependenciesMeta": { @@ -110,6 +131,9 @@ }, "vitest": { "optional": true + }, + "@testing-library/react": { + "optional": true } }, "publishConfig": { diff --git a/packages/plugin-sdk/schema/manifest.schema.json b/packages/plugin-sdk/schema/manifest.schema.json index e9cd6dd35..b52e51967 100644 --- a/packages/plugin-sdk/schema/manifest.schema.json +++ b/packages/plugin-sdk/schema/manifest.schema.json @@ -82,12 +82,12 @@ "termix": { "type": "string", "minLength": 1, - "description": "Display string only. Never enforced." + "description": "Semver range of Termix releases the plugin runs on, e.g. \">=2.9.0\"." }, "api": { "type": "string", - "pattern": "^[0-9]+$", - "description": "Plugin SDK major version. The real compatibility gate." + "minLength": 1, + "description": "Semver range of the plugin API it needs, e.g. \"1\" or \"^1.2\"." } } }, @@ -115,6 +115,8 @@ "notify:send", "audit:read", "desktop:window", + "users:impersonate", + "db:core-refs", "hosts:read", "db:own", "kv:own", @@ -290,7 +292,9 @@ "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,31}$", - "not": { "enum": ["ssh"] } + "not": { + "enum": ["ssh"] + } }, "credentialFields": { "type": "array", @@ -311,7 +315,9 @@ ] } }, - "secret": { "type": "boolean" } + "secret": { + "type": "boolean" + } } } }, @@ -323,7 +329,9 @@ "hostLoginFallback": { "type": "array", "uniqueItems": true, - "items": { "enum": ["username", "password"] } + "items": { + "enum": ["username", "password"] + } } } } @@ -352,17 +360,28 @@ "type": "object", "propertyNames": { "pattern": "^[a-zA-Z][a-zA-Z0-9]{0,31}$", - "not": { "const": "type" } + "not": { + "const": "type" + } }, "additionalProperties": { "type": "object", "additionalProperties": false, "required": ["type"], "properties": { - "type": { "enum": ["string", "boolean"] }, - "required": { "type": "boolean" }, - "pattern": { "type": "string" }, - "maxLength": { "type": "integer", "minimum": 1 }, + "type": { + "enum": ["string", "boolean"] + }, + "required": { + "type": "boolean" + }, + "pattern": { + "type": "string" + }, + "maxLength": { + "type": "integer", + "minimum": 1 + }, "syncEntity": { "type": "string", "pattern": "^[a-zA-Z][a-zA-Z0-9]*$" @@ -894,6 +913,15 @@ "ownerOnly": { "type": "boolean", "description": "Only the host's owner may change it." + }, + "secretKeys": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + }, + "description": "JSON fields only: keys inside the stored object that hold secrets." } } } @@ -962,7 +990,9 @@ "type": "string", "pattern": "^(/[A-Za-z0-9_.-]+)+$" }, - "status": { "enum": [307, 308] } + "status": { + "enum": [307, 308] + } } } } @@ -1041,6 +1071,16 @@ } } } + }, + "uses": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "description": "Other plugins' action, slot and extension point ids this plugin calls, fills or extends.", + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+$" + } } } }, diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index 4ebe8d072..a69a520cb 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -96,8 +96,8 @@ export interface PluginFiles { * to query through `db`. Both require db:own. * * Scoped by name: every table carries the p__ prefix, and `refs` exposes - * users, ssh_data, roles and user_roles read-only so a plugin can join against - * them without being able to write them. In-process code could reach around + * users, ssh_data, roles and user_roles (behind db:core-refs) so a plugin can + * join against them. In-process code could reach around * all of this. The capability, the prefix, lint and review are the contract, * not a sandbox. */ @@ -106,7 +106,10 @@ export interface PluginDatabase { define: (definition: PluginTableDefinition) => Promise; /** Drizzle handle, scoped to this plugin's tables. */ client: () => Promise; - /** Read-only references to the core tables a plugin may point at. */ + /** + * Core tables a plugin may join against: users, hosts, roles, userRoles. + * Needs db:core-refs. Treat them as read-only. + */ refs: () => Promise; /** * Flushes writes to disk. Call it after every write: on SQLite the database @@ -235,6 +238,11 @@ export interface PluginSync { registerEntity: (entity: SyncEntityRegistration) => void; } +/** + * A plain key/value hand-off between plugins. `provide` and `revoke` only take + * keys under the plugin's own id (`.something`); anyone may + * `consume`. + */ export interface PluginRegistry { provide: (key: string, value: T) => void; consume: (key: string) => T | undefined; @@ -252,7 +260,11 @@ export interface PluginServices { implementation: T, options?: { name?: string }, ) => void; - /** `provider` picks a named provider; omitted, the unnamed one. */ + /** + * The service must be listed in the manifest's `requires` (or be one this + * plugin provides). `provider` picks a named provider; omitted, the unnamed + * one. A `userId` other than the acting user needs users:impersonate. + */ get: ( service: string, options?: { userId?: string; provider?: string }, @@ -1340,6 +1352,12 @@ export interface PluginLoginInstance { * route ("oidc", "github", "google", "ldap"). Leave unset otherwise. */ type?: string; + /** + * Redirect methods: the login page starts this instance without a click, + * for a server that signs everyone in through one provider. The first + * instance that asks wins. + */ + autoStart?: boolean; } export interface PluginLoginMethod { @@ -1468,7 +1486,13 @@ export interface PluginOpenIsolatedWindowRequest { * Electron only: rejects when the server is not running embedded in the * desktop app. Needs desktop:window. */ -export interface PluginNativeRdpRequest { +/** + * A host to open in the operating system's own client for a protocol. The + * desktop app supports "rdp" (mstsc on Windows) today; any other protocol is + * refused. + */ +export interface PluginExternalClientRequest { + protocol: string; host: string; port?: number; username?: string; @@ -1480,12 +1504,11 @@ export interface PluginDesktop { request: PluginOpenIsolatedWindowRequest, ) => Promise<{ success: true }>; /** - * Opens the operating system's own RDP client (mstsc on Windows) for a - * host. The password is never passed; the client asks for it. Windows - * desktop app only. + * Opens the operating system's own client for a protocol. The password is + * never passed; the client asks for it. Desktop app only. */ - launchNativeRdp: ( - request: PluginNativeRdpRequest, + launchExternalClient: ( + request: PluginExternalClientRequest, ) => Promise<{ success: boolean; error?: string }>; /** Whether the server runs embedded in the desktop app, so the calls above can work. */ available: () => boolean; @@ -1942,8 +1965,8 @@ export interface PluginContext { * no request behind it. Always audited, and every core API inside still * applies that user's RBAC. * - * This is the only way a plugin can name a user. Nothing else trusts a user - * id that came from plugin code. + * Needs users:impersonate. That and a `userId` option on services.get or + * secrets.getShared are the only ways a plugin can name a user. */ asUser: (userId: string, fn: () => Promise | T) => Promise; diff --git a/packages/plugin-sdk/src/capabilities.ts b/packages/plugin-sdk/src/capabilities.ts index 0ee21b2b2..662278056 100644 --- a/packages/plugin-sdk/src/capabilities.ts +++ b/packages/plugin-sdk/src/capabilities.ts @@ -44,6 +44,8 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [ entry("ssh:connect", "high"), entry("process:spawn", "high"), entry("users:write", "high"), + entry("users:impersonate", "high"), + entry("db:core-refs", "high"), entry("auth:provide", "high"), entry("device:serial", "high"), entry("notify:hub", "high"), diff --git a/packages/plugin-sdk/src/ddl.ts b/packages/plugin-sdk/src/ddl.ts index 18247a7f2..a188d051c 100644 --- a/packages/plugin-sdk/src/ddl.ts +++ b/packages/plugin-sdk/src/ddl.ts @@ -429,7 +429,35 @@ const REFUSED_STATEMENTS = new Set([ /** What a migration may CREATE, ALTER or DROP. Anything else is refused. */ const TABLE_OBJECTS = new Set(["TABLE", "VIEW"]); -const HARMLESS_OBJECTS = new Set(["SEQUENCE", "TYPE"]); +/** Allowed, but only under the plugin's own prefix. */ +const PREFIXED_OBJECTS = new Set(["SEQUENCE", "TYPE"]); + +/** + * Functions that read files, change server settings or run a string as SQL. + * A SELECT calling one of them could do anything, so it is refused. + */ +const REFUSED_FUNCTIONS = new Set([ + "SET_CONFIG", + "PG_READ_FILE", + "PG_READ_BINARY_FILE", + "PG_LS_DIR", + "PG_STAT_FILE", + "PG_FILE_WRITE", + "LO_IMPORT", + "LO_EXPORT", + "LO_FROM_BYTEA", + "DBLINK", + "DBLINK_EXEC", + "QUERY_TO_XML", + "QUERY_TO_XML_AND_XMLSCHEMA", + "PG_TERMINATE_BACKEND", + "PG_CANCEL_BACKEND", + "PG_RELOAD_CONF", + "LOAD_FILE", + "LOAD_EXTENSION", + "SYS_EXEC", + "SYS_EVAL", +]); const CREATE_MODIFIERS = [ "OR", @@ -460,9 +488,27 @@ export function findUnownedTableWrites( pluginId: string, sql: string, ownedLegacy: ReadonlySet = new Set(), + ownedIndexes: ReadonlySet = new Set(), ): string[] { const prefix = tablePrefix(pluginId); const problems: string[] = []; + const indexes = new Set([ + ...ownedIndexes, + ...collectOwnedIndexes(pluginId, [sql], ownedLegacy), + ]); + + // The splitter and tokenizer follow standard quoting only. MySQL backslash + // escapes, E'' strings and Postgres dollar quoting would let a string end + // somewhere else for the engine than for this check, so none are allowed. + if (sql.includes("\\")) { + problems.push("backslashes are not allowed in a plugin migration"); + } + if (/\$[A-Za-z_0-9]*\$/.test(sql)) { + problems.push( + "dollar-quoted strings are not allowed in a plugin migration", + ); + } + if (problems.length > 0) return problems; for (const statement of splitStatements(sql)) { const tokens = tokenize(statement); @@ -507,16 +553,47 @@ export function findUnownedTableWrites( continue; } + if (first !== "CREATE" && find("CASCADE") > 0) { + problems.push("CASCADE is not allowed in a plugin migration"); + continue; + } + if (first === "CREATE" || first === "ALTER" || first === "DROP") { let at = skip(1, CREATE_MODIFIERS); const object = upper(at); if (object === "INDEX") { // The index name is free; the table after ON is what it writes to. const on = find("ON", at); - if (on > 0) table(skip(on + 1, ["ONLY"])); + if (on > 0) { + table(skip(on + 1, ["ONLY"])); + } else if (first === "CREATE") { + problems.push("CREATE INDEX without ON is not allowed"); + } else { + // DROP INDEX name: only an index this plugin created. + const nameAt = skip(at + 1, ["IF", "EXISTS", "CONCURRENTLY"]); + const name = tokens[nameAt]; + if ( + !name || + name.kind === "punct" || + tokens[nameAt + 1]?.value === "." || + (!name.value.startsWith(prefix) && !indexes.has(name.value)) + ) { + problems.push( + `${first} INDEX "${name?.value ?? ""}" is not an index this plugin created`, + ); + } + } + continue; + } + if (PREFIXED_OBJECTS.has(object)) { + const name = tokens[skip(at + 1, ["IF", "NOT", "EXISTS"])]; + if (!name || !name.value.startsWith(prefix)) { + problems.push( + `${first} ${object} "${name?.value ?? ""}" is not prefixed "${prefix}"`, + ); + } continue; } - if (HARMLESS_OBJECTS.has(object)) continue; if (!TABLE_OBJECTS.has(object)) { problems.push( `${first} ${object || tokens[at]?.value || ""} is not allowed in a plugin migration`.trim(), @@ -565,6 +642,14 @@ export function findUnownedTableWrites( } if (first === "UPDATE") { + // MySQL writes every table before SET: UPDATE a JOIN b ... SET b.x = 1. + const set = find("SET"); + const end = set > 0 ? set : tokens.length; + for (let at = 1; at < end; at++) { + if (upper(at) === "JOIN" || tokens[at].value === ",") { + table(at + 1); + } + } table( skip(1, [ "OR", @@ -582,12 +667,51 @@ export function findUnownedTableWrites( if (first === "DELETE") { const from = find("FROM"); - if (from > 0) table(skip(from + 1, ["ONLY"])); - else problems.push("DELETE without FROM is not allowed"); + if (from < 0) { + problems.push("DELETE without FROM is not allowed"); + continue; + } + // MySQL: DELETE a, b FROM ... deletes from a and b. + let at = skip(1, ["LOW_PRIORITY", "QUICK", "IGNORE"]); + if (at < from) { + while (at < from) { + at = table(at); + if (tokens[at]?.value !== ",") break; + at++; + } + continue; + } + at = table(skip(from + 1, ["ONLY"])); + // DELETE FROM a, b USING ... deletes from both. + while (tokens[at]?.value === ",") at = table(at + 1); continue; } - if (first !== "SELECT" && first !== "COMMENT") { + if (first === "SELECT") { + // SELECT ... INTO creates a table on Postgres and writes a file on + // MySQL. + if (find("INTO") > 0) { + problems.push("SELECT INTO is not allowed in a plugin migration"); + continue; + } + for (let at = 0; at < tokens.length; at++) { + if (REFUSED_FUNCTIONS.has(upper(at)) && tokens[at + 1]?.value === "(") { + problems.push( + `${tokens[at].value}() is not allowed in a plugin migration`, + ); + } + } + // setval can break a core table's ids, so only the plugin's own. + for (const match of statement.matchAll(/setval\s*\(\s*([^,]*)/gi)) { + const target = /'([^']*)'/.exec(match[1])?.[1]; + if (!target || !target.startsWith(prefix)) { + problems.push("setval() may only target this plugin's own sequences"); + } + } + continue; + } + + if (first !== "COMMENT") { problems.push( `"${tokens[0].value}" statements are not allowed in a plugin migration`, ); @@ -596,3 +720,44 @@ export function findUnownedTableWrites( return problems; } + +/** + * Names of the indexes a plugin's migrations create on its own tables, so a + * later DROP INDEX with no ON clause can be checked against them. + */ +export function collectOwnedIndexes( + pluginId: string, + sqls: readonly string[], + ownedLegacy: ReadonlySet = new Set(), +): Set { + const prefix = tablePrefix(pluginId); + const owned = new Set(); + for (const sql of sqls) { + for (const statement of splitStatements(sql)) { + const tokens = tokenize(statement); + const upper = (at: number) => + tokens[at]?.kind === "word" ? tokens[at].value.toUpperCase() : ""; + if (upper(0) !== "CREATE") continue; + let at = 1; + while (CREATE_MODIFIERS.includes(upper(at))) at++; + if (upper(at) !== "INDEX") continue; + at++; + while (["IF", "NOT", "EXISTS"].includes(upper(at))) at++; + const name = tokens[at]; + let on = at; + while (on < tokens.length && upper(on) !== "ON") on++; + if (upper(on + 1) === "ONLY") on++; + const target = tokens[on + 1]; + if ( + name && + name.kind !== "punct" && + target && + tokens[on + 2]?.value !== "." && + (target.value.startsWith(prefix) || ownedLegacy.has(target.value)) + ) { + owned.add(name.value); + } + } + } + return owned; +} diff --git a/packages/plugin-sdk/src/desktop.ts b/packages/plugin-sdk/src/desktop.ts new file mode 100644 index 000000000..f970407a0 --- /dev/null +++ b/packages/plugin-sdk/src/desktop.ts @@ -0,0 +1,394 @@ +/** + * The desktop app's bridge (window.electronAPI), typed for plugins. It exists + * only inside the Termix desktop app; check isElectron before calling it. + */ + +export interface ServerConfig { + serverUrl?: string; + allowInvalidCertificate?: boolean; + [key: string]: unknown; +} + +export interface ConnectionTestResult { + success: boolean; + error?: string; + [key: string]: unknown; +} + +export interface DialogOptions { + title?: string; + defaultPath?: string; + buttonLabel?: string; + filters?: Array<{ name: string; extensions: string[] }>; + properties?: string[]; + [key: string]: unknown; +} + +export interface DialogResult { + canceled: boolean; + filePath?: string; + filePaths?: string[]; + [key: string]: unknown; +} + +export interface LocalDirectoryResult { + success: boolean; + path: string; + parent?: string; + entries: LocalFileEntry[]; + error?: string; +} + +export interface LocalCollectedFile { + path: string; + name: string; + relativePath: string; + size: number; + created?: string; + modified: string; +} + +export type LocalPathMutationResult = + | ({ success: true } & Partial) + | { success: false; error?: string }; + +export interface ElectronAPI { + getAppVersion: () => Promise; + setZoomFactor?: (factor: number) => void; + getPlatform: () => Promise; + getSetting?: (key: string) => Promise; + setSetting?: (key: string, value: string) => Promise; + + getServerConfig: () => Promise; + saveServerConfig: (config: ServerConfig) => Promise<{ success: boolean }>; + testServerConnection: ( + serverUrl: string, + allowInvalidCertificate?: boolean, + ) => Promise; + getC2STunnelConfig: () => Promise; + saveC2STunnelConfig: ( + config: unknown[], + ) => Promise<{ success: boolean; error?: string }>; + checkLocalPortAvailable: ( + host: string, + port: number, + ) => Promise<{ available: boolean; error?: string }>; + getC2STunnelPresetDefaultName: () => Promise; + startC2STunnel: ( + tunnel: unknown, + index: number, + authToken?: string, + ) => Promise<{ + success: boolean; + tunnelName?: string; + error?: string; + code?: string; + }>; + testC2STunnel: ( + tunnel: unknown, + index: number, + authToken?: string, + ) => Promise<{ + success: boolean; + message?: string; + error?: string; + code?: string; + }>; + stopC2STunnel: ( + tunnelName: string, + ) => Promise<{ success: boolean; error?: string }>; + getC2STunnelStatuses: () => Promise>; + onC2STunnelStatuses?: ( + callback: (statuses: Record) => void, + ) => () => void; + startC2SAutoStartTunnels: () => Promise<{ + success: boolean; + started: number; + errors: string[]; + }>; + /** Where the linked server relays client tunnel streams, a /plugin-ws/ path. */ + setC2SRelayPath?: ( + relayPath: string, + ) => Promise<{ success: boolean; error?: string }>; + onCloseActiveTab?: (callback: () => void) => () => void; + clearSessionCookies: () => Promise; + getSessionCookie: ( + name: string, + targetUrl?: string, + ) => Promise; + waitForSessionCookie: ( + name: string, + targetUrl?: string, + previousValue?: string | null, + timeoutMs?: number, + ) => Promise<{ success: boolean; value?: string; error?: string }>; + + showSaveDialog: (options: DialogOptions) => Promise; + showOpenDialog: (options: DialogOptions) => Promise; + getLocalHomeDirectory?: () => Promise; + listLocalDirectory?: (path?: string) => Promise; + statLocalPaths?: ( + paths: string[], + ) => Promise< + Array< + | (LocalFileEntry & { success: true }) + | { success: false; path: string; error?: string } + > + >; + collectLocalFiles?: (paths: string[]) => Promise<{ + success: boolean; + files: LocalCollectedFile[]; + truncated?: boolean; + error?: string; + }>; + createLocalFolder?: ( + parentPath: string, + folderName: string, + ) => Promise; + renameLocalPath?: ( + path: string, + newName: string, + ) => Promise; + trashLocalPath?: (path: string) => Promise; + chmodLocalPath?: ( + path: string, + permissions: string, + ) => Promise; + + openExternalEditor: (fileData: { + fileName: string; + content: string; + encoding?: "utf8" | "base64"; + editorPath?: string | null; + }) => Promise<{ + success: boolean; + editId?: string; + path?: string; + error?: string; + }>; + + closeExternalEditor: (editId: string) => Promise<{ + success: boolean; + error?: string; + }>; + + onExternalEditorSaved?: ( + callback: (payload: { + editId: string; + content: string; + encoding: "utf8"; + path: string; + }) => void, + ) => () => void; + + onUpdateAvailable: (callback: () => void) => void; + onUpdateDownloaded: (callback: () => void) => void; + + removeAllListeners: (channel: string) => void; + isElectron: boolean; + isDev: boolean; + + invoke: (channel: string, ...args: unknown[]) => Promise; + + createTempFile: (fileData: { + fileName: string; + content: string; + encoding?: "base64" | "utf8"; + }) => Promise<{ + success: boolean; + tempId?: string; + path?: string; + error?: string; + }>; + + createTempFolder: (folderData: { + folderName: string; + files: Array<{ + relativePath: string; + content: string; + encoding?: "base64" | "utf8"; + }>; + }) => Promise<{ + success: boolean; + tempId?: string; + path?: string; + error?: string; + }>; + + startDragToDesktop: (dragData: { + tempId: string; + fileName: string; + }) => Promise<{ + success: boolean; + error?: string; + }>; + + cleanupTempFile: (tempId: string) => Promise<{ + success: boolean; + error?: string; + }>; + + startLocalTerminal(dimensions: { + cols: number; + rows: number; + shell?: "default" | "wsl"; + }): Promise<{ sessionId: string; shell: string }>; + readyLocalTerminal(sessionId: string): Promise; + writeLocalTerminal(sessionId: string, data: string): Promise; + resizeLocalTerminal( + sessionId: string, + cols: number, + rows: number, + ): Promise; + closeLocalTerminal(sessionId: string): Promise; + onLocalTerminalData( + sessionId: string, + callback: (data: string) => void, + ): () => void; + onLocalTerminalExit( + sessionId: string, + callback: (exitCode: number) => void, + ): () => void; + + /** Local disk browsing for the dual-pane file manager (desktop only). */ + localFs?: { + home: () => Promise>; + list: (dirPath: string) => Promise>; + mkdir: ( + parentPath: string, + name: string, + ) => Promise>; + createFile: ( + parentPath: string, + name: string, + ) => Promise>; + rename: ( + oldPath: string, + newName: string, + ) => Promise>; + trash: (paths: string[]) => Promise>; + ensureDir: ( + dirPath: string, + rootPath?: string, + ) => Promise>; + exists: (paths: string[]) => Promise>; + walk: (paths: string[]) => Promise>; + reveal: (targetPath: string) => Promise>; + open: (targetPath: string) => Promise>; + }; + + /** Streamed local<->remote transfers driven by the main process. */ + localTransfer?: { + /** The /plugin-api/ path that serves the streaming routes. */ + setApiPath?: (apiPath: string) => Promise>; + upload: ( + options: LocalUploadRequest, + ) => Promise>; + download: ( + options: LocalDownloadRequest, + ) => Promise>; + cancel: ( + transferId: string, + ) => Promise>; + onProgress: ( + callback: (payload: LocalTransferProgress) => void, + ) => () => void; + }; +} + +export type LocalFsResult = + ({ success: true } & T) | { success: false; error: string; code?: string }; + +export interface LocalFsHomeInfo { + home: string; + separator: string; + platform: string; +} + +export interface LocalFileEntry { + name: string; + path: string; + type: "file" | "directory" | "link" | "other"; + created?: string; + modified?: string; + permissions?: string; + owner?: string; + group?: string; + size: number; + modifiedTimestamp?: number; + linkTarget?: string; + hidden: boolean; +} + +export interface LocalDirectoryListing { + path: string; + parent: string | null; + entries: LocalFileEntry[]; +} + +export interface LocalTrashResult { + trashed: number; + failed: Array<{ path: string; error: string }>; +} + +export interface LocalWalkFile { + localPath: string; + /** Path relative to the drop root; "/"-separated and includes the root's own name. */ + relativePath: string; + size: number; +} + +export interface LocalWalkResult { + files: LocalWalkFile[]; + emptyDirs: string[]; + totalBytes: number; +} + +/** + * Which Termix backend a transfer talks to. The main process resolves the + * actual URL and credentials for the origin; the renderer never supplies them. + */ +export type LocalTransferOrigin = "local" | "remote"; + +export interface LocalUploadRequest { + transferId: string; + origin: LocalTransferOrigin; + fields: Record; + localPath: string; + fileName: string; + deviceId?: string; + /** The renderer's token for the embedded backend (origin "local"); sent as a Bearer header. */ + authToken?: string; +} + +export interface LocalDownloadRequest { + transferId: string; + origin: LocalTransferOrigin; + body: Record; + destPath: string; + /** Selected download folder; destPath must resolve strictly inside it (code EINVAL otherwise). */ + rootPath: string; + expectedSize?: number; + /** Replace an existing file at destPath; otherwise the transfer is refused with code EEXIST. */ + overwrite?: boolean; + deviceId?: string; + /** The renderer's token for the embedded backend (origin "local"); sent as a Bearer header. */ + authToken?: string; +} + +export interface LocalTransferProgress { + transferId: string; + transferred: number; + total?: number; +} + +declare global { + interface Window { + electronAPI: ElectronAPI; + IS_ELECTRON: boolean; + electronClipboard?: { + writeText(text: string): Promise; + readText(): Promise; + }; + } +} diff --git a/packages/plugin-sdk/src/frontend.ts b/packages/plugin-sdk/src/frontend.ts index b20a9bf29..377d5c13a 100644 --- a/packages/plugin-sdk/src/frontend.ts +++ b/packages/plugin-sdk/src/frontend.ts @@ -155,9 +155,6 @@ export interface PluginTabRecord { instanceId?: string; /** A backend session a session tab should reattach to. */ restoredSessionId?: string | null; - /** A live shared session this tab joins instead of connecting its own. */ - joinSharedSessionId?: string | null; - joinShareId?: string | null; [key: string]: unknown; } @@ -192,6 +189,11 @@ export interface ShellApi { options?: OpenTabOptions, ) => void; openSingletonTab: (type: string, options?: OpenTabOptions) => void; + /** + * Connects to a host the way clicking it in the host list does: its + * default connection, or `type` when given. + */ + connectHost: (host: PluginHostRecord, type?: string) => void; closeTab: (tabId: string) => void; renameTab: (tabId: string, label: string) => void; /** Saves a quick-connect tab's host as a real host. Absent in some shells. */ @@ -212,7 +214,7 @@ export interface ShellApi { export interface TabsApi extends Pick< ShellApi, - "openTab" | "openSingletonTab" | "closeTab" | "openRailView" + "openTab" | "openSingletonTab" | "connectHost" | "closeTab" | "openRailView" > { /** The current tabs and split layout, or null before the shell mounts. */ getLayout: () => ShellLayout | null; @@ -1085,6 +1087,15 @@ export interface PluginCoreApi { listHosts: (pluginId: string) => Promise; /** The user's stored credentials, without their secrets. */ listCredentials: () => Promise; + /** Tells the shell hosts were added or changed, so lists reload. */ + notifyHostsChanged: () => void; + /** How the user wants host status shown: the brand accent, or green/red. */ + getHostStatusColorScheme: () => "accent" | "status"; + /** + * The token the desktop app's embedded backend accepts, for work the + * desktop main process does on the renderer's behalf. Null in a browser. + */ + getLocalAuthToken: () => string | null; } /** A stored credential as a picker needs it. Secrets never leave core. */ @@ -1114,8 +1125,17 @@ export interface HostStatusInfo { let host: PluginHostBridge | null = null; -/** Called once by core. Not part of the plugin API. */ +/** + * Called once by core, before any plugin loads. Not part of the plugin API: + * once set it cannot be swapped, so a plugin cannot replace the bridge every + * other plugin talks through. + */ export function __setPluginHost(bridge: PluginHostBridge | null): void { + if (host && bridge !== host) { + throw new Error( + "@termix/plugin-sdk/frontend: the plugin host is already set", + ); + } host = bridge; } @@ -1211,6 +1231,21 @@ export function logActivity( return requireHost().core.logActivity(type, hostId, hostName); } +/** Tells the shell hosts were added or changed, so its lists reload. */ +export function notifyHostsChanged(): void { + requireHost().core.notifyHostsChanged(); +} + +/** How the user wants host status shown: the brand accent, or green/red. */ +export function getHostStatusColorScheme(): "accent" | "status" { + return requireHost().core.getHostStatusColorScheme(); +} + +/** The desktop app's local backend token, or null in a browser. */ +export function getLocalAuthToken(): string | null { + return requireHost().core.getLocalAuthToken(); +} + export function getHostPassword( hostId: number, field: "password" | "sudoPassword", @@ -1583,3 +1618,6 @@ export function useConnectionRetry({ export type { PluginManifest } from "./manifest.js"; export type { ReactNode }; + +// The desktop bridge types, and the window.electronAPI global they declare. +export type * from "./desktop.js"; diff --git a/packages/plugin-sdk/src/manifest.ts b/packages/plugin-sdk/src/manifest.ts index c28a6e8a9..44bc09512 100644 --- a/packages/plugin-sdk/src/manifest.ts +++ b/packages/plugin-sdk/src/manifest.ts @@ -14,10 +14,40 @@ import semver from "semver"; import { isKnownCapability } from "./capabilities.js"; /** - * The SDK major version this build implements. engine.api is the real - * compatibility gate; engine.termix is a display string and is never enforced. + * The plugin API this build implements. A minor bump adds to the API and + * never removes; a major bump may break plugins. + * + * engine.api is a semver range checked against this ("1" means any 1.x, + * "^1.2" needs 1.2 or later). engine.termix is checked against the core + * version by the server. */ -export const SUPPORTED_PLUGIN_API_VERSION = "1"; +export const PLUGIN_API_VERSION = "1.0.0"; + +/** The API major, the default engine.api for a new plugin. */ +export const SUPPORTED_PLUGIN_API_VERSION = String( + semver.major(PLUGIN_API_VERSION), +); + +/** Whether this build's plugin API satisfies a manifest's engine.api. */ +export function isApiCompatible(range: string): boolean { + return ( + semver.validRange(range) !== null && + semver.satisfies(PLUGIN_API_VERSION, range) + ); +} + +/** + * Whether a core version satisfies a manifest's engine.termix. A prerelease + * core (2.9.0-beta.1) counts as that release. + */ +export function isTermixCompatible( + range: string, + coreVersion: string | null, +): boolean { + if (!coreVersion || !semver.valid(semver.coerce(coreVersion))) return true; + if (semver.validRange(range) === null) return false; + return semver.satisfies(semver.coerce(coreVersion)!.version, range); +} export const PLUGIN_CATEGORIES = [ "Terminal", @@ -59,14 +89,12 @@ export const RESERVED_PLUGIN_IDS: readonly string[] = [ /** A preset key is a plain property name, never a prototype one. */ const PRESET_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/; const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/; -const API_VERSION_PATTERN = /^[0-9]+$/; /** Service names are dotted, e.g. "ssh.transport". */ const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/; const SECRET_KEY_PATTERN = /^[a-z0-9-]+$/; /** Action ids name a frontend function, so segments may be camelCase. */ const ACTION_ID_PATTERN = /^[a-z0-9-]+(\.[a-zA-Z0-9-]+)+$/; const HANDLER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/; -const PERMISSION_PATTERN = /^[a-z0-9-]+(\.[a-z0-9_-]+)+$/; /** * A plugin-relative permission name. Unlike a full id one segment is fine, * and underscores are allowed in the first segment too ("manage_providers"). @@ -98,9 +126,12 @@ export interface PluginAuthor { } export interface PluginEngine { - /** Display string only, e.g. ">=2.9.0". Never enforced. */ + /** Semver range of Termix releases the plugin runs on, e.g. ">=2.9.0". */ termix: string; - /** SDK major version as a string. The real gate. */ + /** + * Semver range of the plugin API it needs, e.g. "1" or "^1.2". Use the + * lowest minor whose API you call, so older cores refuse it cleanly. + */ api: string; } @@ -257,6 +288,11 @@ export interface PluginSettingsField { shareRead?: "connect" | "view" | "edit" | "manage"; /** Host scope only: only the host's owner may change it. */ ownerOnly?: boolean; + /** + * JSON fields only: keys inside the stored object that hold secrets, such + * as a gateway password. Core clears them from exports like any secret. + */ + secretKeys?: string[]; /** * Stored and validated like any field, but not drawn by the generic * settings form, because the plugin edits it in its own UI (a host editor @@ -344,6 +380,13 @@ export interface PluginContributions { * off and a desktop can list them in its sync settings. */ syncEntities?: string[]; + /** + * Other plugins' action, slot and extension point ids this plugin calls, + * fills or extends. Declaration only: nothing breaks when the owner is + * missing, but the coupling is written down where tools and people can + * see it. + */ + uses?: string[]; /** * Keybinding actions this plugin adds to Appearance > Keybindings, with * the parameters a saved binding carries. Core validates saved bindings @@ -562,6 +605,7 @@ const ALLOWED_CONTRIBUTES = new Set([ "uiPresets", "auth", "syncEntities", + "uses", "keybindingActions", "protocols", ]); @@ -584,6 +628,7 @@ const ALLOWED_SETTINGS_FIELD = [ "defaultFrom", "shareRead", "ownerOnly", + "secretKeys", ]; const SHARE_LEVELS = ["connect", "view", "edit", "manage"]; @@ -738,9 +783,20 @@ function validateEngine(engine: unknown, errors: string[]): void { } rejectUnknown(engine, ["termix", "api"], '"engine"', errors); requireString(engine.termix, "engine.termix", errors); - if (typeof engine.api !== "string" || !API_VERSION_PATTERN.test(engine.api)) { + if ( + typeof engine.termix === "string" && + semver.validRange(engine.termix) === null + ) { errors.push( - `Field "engine.api" must be an integer string, got: ${JSON.stringify(engine.api)}`, + `Field "engine.termix" must be a semver range, got: ${JSON.stringify(engine.termix)}`, + ); + } + if ( + typeof engine.api !== "string" || + semver.validRange(engine.api) === null + ) { + errors.push( + `Field "engine.api" must be a semver range such as "1" or "^1.2", got: ${JSON.stringify(engine.api)}`, ); } } @@ -895,6 +951,28 @@ function validateSyncEntities(value: unknown, errors: string[]) { } } +function validateUses(value: unknown, errors: string[]) { + const where = "contributes.uses"; + if (!Array.isArray(value) || value.length === 0) { + errors.push(`${where} must be a non-empty array`); + return; + } + const seen = new Set(); + for (const id of value) { + if ( + typeof id !== "string" || + !/^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/.test(id) + ) { + errors.push( + `${where} entries must be dotted ids such as "terminal.open"`, + ); + continue; + } + if (seen.has(id)) errors.push(`${where} duplicates "${id}"`); + seen.add(id); + } +} + function validateNameList(value: unknown, where: string, errors: string[]) { if (!Array.isArray(value) || value.length === 0) { errors.push(`${where} must be a non-empty array`); @@ -1025,6 +1103,9 @@ function validateContributes( if (contributes.syncEntities !== undefined) { validateSyncEntities(contributes.syncEntities, errors); } + if (contributes.uses !== undefined) { + validateUses(contributes.uses, errors); + } validatePermissions(contributes.permissions, pluginId, errors); validateActions(contributes.actions, errors); validateActionSlots(contributes.actionSlots, errors); @@ -1534,6 +1615,18 @@ function validateSettings(settings: unknown, errors: string[]): void { if ("ownerOnly" in raw && typeof raw.ownerOnly !== "boolean") { errors.push(`${fieldAt}.ownerOnly must be a boolean`); } + if ("secretKeys" in raw) { + const keys = raw.secretKeys; + if (raw.type !== "json") { + errors.push(`${fieldAt}.secretKeys is only valid on json fields`); + } else if ( + !Array.isArray(keys) || + keys.length === 0 || + keys.some((key) => typeof key !== "string" || key.length === 0) + ) { + errors.push(`${fieldAt}.secretKeys must be a non-empty string array`); + } + } }); } for (const scope of ["admin", "user"] as const) { @@ -1957,10 +2050,10 @@ export function parseManifest(raw: unknown): ParsedManifest { const manifest = raw as PluginManifest; - if (manifest.engine.api !== SUPPORTED_PLUGIN_API_VERSION) { + if (!isApiCompatible(manifest.engine.api)) { return { errors: [ - `Plugin targets SDK API version ${manifest.engine.api}, but this build implements ${SUPPORTED_PLUGIN_API_VERSION}`, + `Plugin needs plugin API ${manifest.engine.api}, but this build implements ${PLUGIN_API_VERSION}`, ], }; } diff --git a/packages/plugin-sdk/src/testing.ts b/packages/plugin-sdk/src/testing.ts index 93edc751a..9ff711ff2 100644 --- a/packages/plugin-sdk/src/testing.ts +++ b/packages/plugin-sdk/src/testing.ts @@ -68,7 +68,7 @@ import type { import type { SyncEntityRegistration } from "./backend.js"; import type { PluginDatabase } from "./backend.js"; import type { - PluginNativeRdpRequest, + PluginExternalClientRequest, PluginProtocolTarget, PluginHostStatusEntry, PluginNotification, @@ -297,8 +297,8 @@ export interface FakePluginContext { title?: string; ignoreCert?: boolean; }>; - /** Every ctx.desktop.launchNativeRdp call, in order. */ - nativeRdpLaunches: PluginNativeRdpRequest[]; + /** Every ctx.desktop.launchExternalClient call, in order. */ + externalClientLaunches: PluginExternalClientRequest[]; /** Every ctx.credentials.resolveHostProtocol call, in order. */ credentialReads: Array<{ hostId: number; protocol: string }>; /** Every ctx.credentials.createSshKey call, with the id it answered. */ @@ -361,6 +361,11 @@ export interface FakePluginContext { runScheduled: () => Promise; /** Changes the acting user, as core's request middleware would. */ setActor: (userId: string | undefined) => void; + /** + * Runs `fn` as `userId`, the way a request from that user would. For test + * code standing in for a caller; unlike ctx.asUser it needs no capability. + */ + actAs: (userId: string, fn: () => Promise | T) => Promise; /** Provided or seeded services, by name or "#". */ services: Map; } @@ -497,7 +502,8 @@ export function createFakeContext( const sshConnections: FakePluginContext["sshConnections"] = []; const hostShares: FakePluginContext["hostShares"] = []; const desktopWindows: FakePluginContext["desktopWindows"] = []; - const nativeRdpLaunches: FakePluginContext["nativeRdpLaunches"] = []; + const externalClientLaunches: FakePluginContext["externalClientLaunches"] = + []; const credentialReads: FakePluginContext["credentialReads"] = []; const createdSshKeys: FakePluginContext["createdSshKeys"] = []; const notifications: FakePluginContext["notifications"] = []; @@ -695,10 +701,12 @@ export function createFakeContext( registry: { provide: (key, value) => { + assertOwnRegistryKey(pluginId, key); registryProviders.set(key, value); }, consume: (key) => registryProviders.get(key) as never, revoke: (key, value) => { + assertOwnRegistryKey(pluginId, key); if (!registryProviders.has(key)) return false; if (value !== undefined && registryProviders.get(key) !== value) { return false; @@ -1128,8 +1136,8 @@ export function createFakeContext( desktopWindows.push(request); return { success: true }; }, - launchNativeRdp: async (request) => { - nativeRdpLaunches.push(request); + launchExternalClient: async (request) => { + externalClientLaunches.push(request); return { success: true }; }, available: () => options.desktopAvailable ?? false, @@ -1309,7 +1317,7 @@ export function createFakeContext( hostShares, auth, desktopWindows, - nativeRdpLaunches, + externalClientLaunches, credentialReads, createdSshKeys, notifications, @@ -1337,6 +1345,15 @@ export function createFakeContext( setActor: (userId) => { actor = userId; }, + actAs: async (userId, fn) => { + const previous = actor; + actor = userId; + try { + return await fn(); + } finally { + actor = previous; + } + }, services, }; } @@ -1477,6 +1494,11 @@ export function createMockCtx( const gatedCtx: PluginContext = { ...ctx, + asUser: async (userId, fn) => { + require("users:impersonate"); + return ctx.asUser(userId, fn); + }, + db: { define: async (definition) => { require("db:own"); @@ -1487,7 +1509,7 @@ export function createMockCtx( return guardedDb.client(); }, refs: async () => { - require("db:own"); + require("db:core-refs"); return guardedDb.refs(); }, persist: async (persistOptions) => { @@ -1745,9 +1767,9 @@ export function createMockCtx( require("desktop:window"); return ctx.desktop.openIsolatedWindow(request); }, - launchNativeRdp: async (request) => { + launchExternalClient: async (request) => { require("desktop:window"); - return ctx.desktop.launchNativeRdp(request); + return ctx.desktop.launchExternalClient(request); }, available: () => ctx.desktop.available(), }, @@ -1882,7 +1904,7 @@ export interface TestDbOptions { * Runs after the core stub tables exist and before the plugin's migrations, * e.g. to create the legacy table an adoption migration renames. */ - before?: (sqlite: TestSqlite) => void | Promise; + before?: (sqlite: TestSqlite) => unknown; /** Skip applying the migrations, to apply them by hand later. */ skipMigrations?: boolean; } @@ -2046,6 +2068,8 @@ export interface RenderWithAppOptions { ready?: boolean; /** Stands in for app.api and usePluginApi(), e.g. a stub of the routes. */ api?: import("./frontend.js").PluginApiClient; + /** What app.desktop.remoteServerUrl() answers. Read on every call. */ + remoteServerUrl?: () => string | null | Promise; } /** A call a plugin made on the shell, recorded instead of performed. */ @@ -2150,3 +2174,11 @@ export async function renderWithApp( const host = (await import(/* @vite-ignore */ TEST_HOST)) as PluginTestHost; return host.renderPlugin(plugin, options); } + +function assertOwnRegistryKey(pluginId: string, key: string): void { + if (typeof key !== "string" || !key.startsWith(`${pluginId}.`)) { + throw new Error( + `Plugin ${pluginId} may only use registry keys under "${pluginId}.", not "${key}"`, + ); + } +} diff --git a/packages/plugin-sdk/src/testing/setup.ts b/packages/plugin-sdk/src/testing/setup.ts index 5cb11dc78..19c0d25ca 100644 --- a/packages/plugin-sdk/src/testing/setup.ts +++ b/packages/plugin-sdk/src/testing/setup.ts @@ -98,6 +98,7 @@ const offline = () => useTabs: () => ({ openTab: noop, openSingletonTab: noop, + connectHost: noop, closeTab: noop, getLayout: () => null, applyLayout: async () => ({ skipped: [] }), @@ -142,6 +143,9 @@ const offline = () => setClientPreference: () => {}, listHosts: async () => [], listCredentials: async () => [], + notifyHostsChanged: () => {}, + getHostStatusColorScheme: () => "accent", + getLocalAuthToken: () => null, }, }; diff --git a/packages/plugin-sdk/src/vitest-preset.ts b/packages/plugin-sdk/src/vitest-preset.ts index 72b25b65a..c3361958d 100644 --- a/packages/plugin-sdk/src/vitest-preset.ts +++ b/packages/plugin-sdk/src/vitest-preset.ts @@ -14,12 +14,11 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; /** - * Walks up from a plugin directory to the repo that holds src/ui. + * Walks up from a plugin directory to a Termix checkout, if it is in one. * - * Plugin frontends and their tests still reach core through the "@/" alias - * (the legacy debt A7 and D1 remove), so the preset has to resolve it. A - * plugin developed outside this repo has no src/ui to point at, which is - * exactly right: the alias stops resolving and the import fails loudly. + * Inside the repo the SDK's browser entries and the test host resolve to + * core's source, so a test exercises the real registries. Outside it they + * resolve to the builds the SDK ships in dist/host. */ function findRepoRoot(startDir: string): string | null { let dir = startDir; @@ -83,7 +82,13 @@ export function pluginVitestConfig( "@/types": path.join(repoRoot, "src", "types"), "@": path.join(repoRoot, "src", "ui"), } - : {}), + : { + // Outside a Termix checkout: the UI kit resolves through the + // package, and the test host is the copy the SDK ships. + "@termix/plugin-host/testing": fileURLToPath( + new URL("./host/testing-host.js", import.meta.url), + ), + }), ...options.alias, }; @@ -112,6 +117,10 @@ export function pluginVitestConfig( environment: "jsdom", globals: true, setupFiles: [setupFile], + // Run the SDK through Vite rather than plain Node, so its import + // of the test host sees the alias above and the plugin and the + // host share one copy of @termix/plugin-sdk/frontend. + server: { deps: { inline: [/@termix\/plugin-sdk/] } }, include: ["tests/frontend/**/*.test.{ts,tsx}"], }, }, diff --git a/packages/plugin-sdk/tsconfig.plugin.json b/packages/plugin-sdk/tsconfig.plugin.json index 5e193ae86..911890125 100644 --- a/packages/plugin-sdk/tsconfig.plugin.json +++ b/packages/plugin-sdk/tsconfig.plugin.json @@ -5,6 +5,8 @@ "module": "esnext", "moduleResolution": "bundler", "jsx": "react-jsx", + "allowImportingTsExtensions": true, + "types": ["node", "vitest/globals"], "skipLibCheck": true, "verbatimModuleSyntax": true, "moduleDetection": "force", @@ -24,5 +26,6 @@ "noPropertyAccessFromIndexSignature": false, "allowUnusedLabels": true, "allowUnreachableCode": true - } + }, + "files": ["./client.d.ts"] } diff --git a/packages/plugin-sdk/ui.api.txt b/packages/plugin-sdk/ui.api.txt new file mode 100644 index 000000000..0b5faec39 --- /dev/null +++ b/packages/plugin-sdk/ui.api.txt @@ -0,0 +1,185 @@ +ActionSlot (value): ({ slotId, context, className, enabled, renderItem, hideLabels, when, }: ActionSlotProps) => Element +Alert (value): ({ className, variant, ...props }: ClassAttributes & HTMLAttributes & VariantProps<(props?: ConfigVariants<{ variant: { default: string; destructive: string; }; }> & ClassProp) => string>) => Element +AlertDescription (value): ({ className, ...props }: DetailedHTMLProps, HTMLDivElement>) => Element +AlertDialog (value): ({ ...props }: AlertDialogProps) => Element +AlertDialogAction (value): ({ className, ...props }: AlertDialogActionProps & RefAttributes) => Element +AlertDialogCancel (value): ({ className, ...props }: AlertDialogCancelProps & RefAttributes) => Element +AlertDialogContent (value): ({ className, ...props }: AlertDialogContentProps & RefAttributes) => Element +AlertDialogDescription (value): ({ className, ...props }: AlertDialogDescriptionProps & RefAttributes) => Element +AlertDialogFooter (value): ({ className, ...props }: DetailedHTMLProps, HTMLDivElement>) => Element +AlertDialogHeader (value): ({ className, ...props }: DetailedHTMLProps, HTMLDivElement>) => Element +AlertDialogOverlay (value): ({ className, ...props }: AlertDialogOverlayProps & RefAttributes) => Element +AlertDialogPortal (value): ({ ...props }: AlertDialogPortalProps) => Element +AlertDialogTitle (value): ({ className, ...props }: AlertDialogTitleProps & RefAttributes) => Element +AlertDialogTrigger (value): ({ ...props }: AlertDialogTriggerProps & RefAttributes) => Element +AlertTitle (value): ({ className, ...props }: DetailedHTMLProps, HTMLDivElement>) => Element +Badge (value): ({ className, variant, asChild, ...props }: ClassAttributes & HTMLAttributes & VariantProps<(props?: ConfigVariants<{ variant: { default: string; secondary: string; destructive: string; outline: string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element +BarSeries (value): ({ items, max, className, colorClassName, }: BarSeriesProps) => Element +BarSeriesItem (type): BarSeriesItem +BarSeriesProps (type): BarSeriesProps +BrowserSignInDialog (value): ({ isOpen, label, url, code, onContinue, onCancel, onOpenUrl, backgroundColor, }: BrowserSignInDialogProps) => Element +Button (value): ({ className, variant, size, asChild, ...props }: ClassAttributes & ButtonHTMLAttributes & VariantProps<(props?: ConfigVariants<{ variant: { default: string; outline: string; secondary: string; ghost: string; destructive: string; link: string; }; size: { default: string; xs: string; sm: string; lg: string; icon: string; "icon-xs": string; "icon-sm": string; "icon-lg": string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element +ButtonProps (type): ButtonProps +Card (value): ({ className, size, ...props }: ClassAttributes & HTMLAttributes & { size?: "default" | "sm"; }) => Element +CardAction (value): ({ className, ...props }: DetailedHTMLProps, HTMLDivElement>) => Element +CardContent (value): ({ className, ...props }: DetailedHTMLProps, HTMLDivElement>) => Element +CardDescription (value): ({ className, ...props }: DetailedHTMLProps, HTMLDivElement>) => Element +CardFooter (value): ({ className, ...props }: DetailedHTMLProps, HTMLDivElement>) => Element +CardGridCanvas (value): ({ slots, columns, editMode, renderCard, cardCatalog, onChange, cardLabel, className, }: CardGridCanvasProps) => Element +CardHeader (value): ({ className, ...props }: DetailedHTMLProps, HTMLDivElement>) => Element +CardTitle (value): ({ className, ...props }: DetailedHTMLProps, HTMLDivElement>) => Element +Checkbox (value): ({ className, ...props }: CheckboxProps & RefAttributes) => Element +ColumnCountStepper (value): ({ columns, onChange, }: { columns: number; onChange: (columns: number) => void; }) => Element +ComponentSlot (value): ({ slotId, props, when, }: { slotId: string; props?: Record; when?: Record; }) => Element +ConnectionLogPanel (value): ({ isConnecting, isConnected, hasConnectionError, position, className, }: ConnectionLogPanelProps) => Element +ConnectionLogProvider (value): ({ children, }: { children: ReactNode; }) => Element +ConnectionOrigin (type): ConnectionOrigin +ConnectionScreen (value): ({ status, message, backgroundColor, attempt, maxAttempts, nextRetryInMs, onManualRetry, retryLabel, disconnectedMessage, extraActions, logPosition, emptyState, className, }: ConnectionScreenProps) => Element +ConnectionStage (type): ConnectionStage +ConnectionStatus (type): ConnectionStatus +CustomKeybinding (type): CustomKeybinding +Dialog (value): ({ ...props }: DialogProps) => Element +DialogClose (value): ({ ...props }: DialogCloseProps & RefAttributes) => Element +DialogContent (value): ({ className, children, showCloseButton, ...props }: DialogContentProps & RefAttributes & { showCloseButton?: boolean; }) => Element +DialogDescription (value): ({ className, ...props }: DialogDescriptionProps & RefAttributes) => Element +DialogFooter (value): ({ className, showCloseButton, children, ...props }: ClassAttributes & HTMLAttributes & { showCloseButton?: boolean; }) => Element +DialogHeader (value): ({ className, ...props }: DetailedHTMLProps, HTMLDivElement>) => Element +DialogOverlay (value): ({ className, ...props }: DialogOverlayProps & RefAttributes) => Element +DialogPortal (value): ({ ...props }: DialogPortalProps) => Element +DialogTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes) => Element +DialogTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes) => Element +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 +DropdownMenuGroup (value): ({ ...props }: DropdownMenuGroupProps & RefAttributes) => Element +DropdownMenuItem (value): ({ className, inset, variant, ...props }: DropdownMenuItemProps & RefAttributes & { inset?: boolean; variant?: "default" | "destructive"; }) => Element +DropdownMenuLabel (value): ({ className, inset, ...props }: DropdownMenuLabelProps & RefAttributes & { inset?: boolean; }) => Element +DropdownMenuPortal (value): ({ ...props }: DropdownMenuPortalProps) => Element +DropdownMenuRadioGroup (value): ({ ...props }: DropdownMenuRadioGroupProps & RefAttributes) => Element +DropdownMenuRadioItem (value): ({ className, children, inset, ...props }: DropdownMenuRadioItemProps & RefAttributes & { inset?: boolean; }) => Element +DropdownMenuSeparator (value): ({ className, ...props }: DropdownMenuSeparatorProps & RefAttributes) => Element +DropdownMenuShortcut (value): ({ className, ...props }: DetailedHTMLProps, HTMLSpanElement>) => Element +DropdownMenuSub (value): ({ ...props }: DropdownMenuSubProps) => Element +DropdownMenuSubContent (value): ({ className, sideOffset, alignOffset, ...props }: DropdownMenuSubContentProps & RefAttributes) => Element +DropdownMenuSubTrigger (value): ({ className, inset, children, ...props }: DropdownMenuSubTriggerProps & RefAttributes & { inset?: boolean; }) => Element +DropdownMenuTrigger (value): ({ ...props }: DropdownMenuTriggerProps & RefAttributes) => Element +EmptyState (value): ({ icon: Icon, title, hint, action, className, }: { icon?: LucideIcon; title: string; hint?: string; action?: ReactNode; className?: string; }) => Element +FOLDER_COLORS (value): string[] +FakeSwitch (value): ({ defaultChecked, disabled, checked, onChange, }: { defaultChecked?: boolean; disabled?: boolean; checked?: boolean; onChange?: (v: boolean) => void; }) => Element +FullScreenAppPhase (type): FullScreenAppPhase +FullScreenAppWrapper (value): FC +GridCardCatalogEntry (type): GridCardCatalogEntry +GridColSpan (type): GridColSpan +GridLayout (type): GridLayout +GridSlot (type): GridSlot +HostFeatureFields (value): ({ form, updateForm, }: HostFeatureFieldsProps) => Element +HostFeatureFieldsProps (type): HostFeatureFieldsProps +HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, algorithm, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element +Input (value): ({ className, type, ...props }: DetailedHTMLProps, HTMLInputElement>) => Element +Kbd (value): ForwardRefExoticComponent> +KbdKey (value): { ({ className, ...props }: HTMLAttributes): Element; displayName: string; } +KbdSeparator (value): { ({ className, ...props }: HTMLAttributes): Element; displayName: string; } +KeyCombo (type): KeyCombo +KeybindingAction (type): KeybindingAction +KeybindingActionType (type): string +Label (value): ({ className, ...props }: LabelProps & RefAttributes) => Element +LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element +LineChartSeries (type): LineChartSeries +LogContext (type): LogContext +LogEntry (type): LogEntry +MFAPromptMode (type): MFAPromptMode +ManagerCardError (type): ManagerCardError +ManagerCardShell (value): ({ title, icon, loading, error, onRefresh, empty, emptyMessage, children, headerExtra, }: { title: string; icon: ReactNode; loading: boolean; error: ManagerCardError; onRefresh: () => void; empty?: boolean; emptyMessage?: string; children: ReactNode; headerExtra?: ReactNode; }) => Element +ManagerSearch (value): ({ value, onChange, placeholder, count, extra, }: { value: string; onChange: (value: string) => void; placeholder?: string; count?: number; extra?: ReactNode; }) => Element +MetricCard (value): ({ title, icon, action, children, bodyClassName, scroll, scrollMax, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; bodyClassName?: string; scroll?: boolean; scrollMax?: number; }) => Element +MiniStat (value): ({ caption, value, valueClassName, className, }: { caption: ReactNode; value: ReactNode; valueClassName?: string; className?: string; }) => Element +PassphraseDialog (value): ({ isOpen, onSubmit, onCancel, hostInfo, backgroundColor, }: PassphraseDialogProps) => Element +PasswordInput (value): ForwardRefExoticComponent> +PluginComponent (value): ({ id, fallback, ...props }: { [prop: string]: unknown; id: string; fallback?: ReactNode; }) => ReactNode +PluginViewPlaceholder (value): ({ kind, viewId, compact, }: { kind: ViewKind; viewId: string; compact?: boolean; }) => Element +Popover (value): ({ ...props }: PopoverProps) => Element +PopoverAnchor (value): ({ ...props }: PopoverAnchorProps & RefAttributes) => Element +PopoverContent (value): ({ className, align, sideOffset, ...props }: PopoverContentProps & RefAttributes) => Element +PopoverTrigger (value): ({ ...props }: PopoverTriggerProps & RefAttributes) => Element +RadialGauge (value): ({ value, label, caption, size, strokeWidth, className, colorClassName, }: RadialGaugeProps) => Element +RadialGaugeProps (type): RadialGaugeProps +RecentActivityItem (type): RecentActivityItem +RobustClipboardProvider (value): typeof RobustClipboardProvider +SSHAuthDialog (value): ({ isOpen, reason, onSubmit, onCancel, hostInfo, backgroundColor, }: SSHAuthDialogProps) => Element +ScrollArea (value): ({ className, children, ...props }: ScrollAreaProps & RefAttributes) => Element +ScrollBar (value): ({ className, orientation, ...props }: ScrollAreaScrollbarProps & RefAttributes) => Element +SectionCard (value): ({ title, icon, action, children, className, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; className?: string; }) => Element +Select (value): ({ ...props }: SelectSharedProps & { value?: string; defaultValue?: string; onValueChange?(value: string): void; }) => Element +Select2 (value): ({ className, children, value, defaultValue, onChange, disabled, placeholder, align, contentClassName, name, id, required, "aria-label": ariaLabel, ...props }: Select2Props) => Element +SelectContent (value): ({ className, children, position, ...props }: SelectContentProps & RefAttributes) => Element +SelectGroup (value): ({ ...props }: SelectGroupProps & RefAttributes) => Element +SelectItem (value): ({ className, children, ...props }: SelectItemProps & RefAttributes) => Element +SelectLabel (value): ({ className, ...props }: SelectLabelProps & RefAttributes) => Element +SelectScrollDownButton (value): ({ className, ...props }: SelectScrollDownButtonProps & RefAttributes) => Element +SelectScrollUpButton (value): ({ className, ...props }: SelectScrollUpButtonProps & RefAttributes) => Element +SelectSeparator (value): ({ className, ...props }: SelectSeparatorProps & RefAttributes) => Element +SelectTrigger (value): ({ className, size, children, ...props }: SelectTriggerProps & RefAttributes & { size?: "default" | "sm"; }) => Element +SelectValue (value): ({ ...props }: SelectValueProps & RefAttributes) => Element +Separator (value): ({ className, orientation, decorative, ...props }: SeparatorProps & RefAttributes) => Element +SettingRow (value): ({ label, badge, description, children, }: { label: string; badge?: string; description?: ReactNode; children: ReactNode; }) => Element +Sheet (value): ({ ...props }: DialogProps) => Element +SheetClose (value): ({ ...props }: DialogCloseProps & RefAttributes) => Element +SheetContent (value): ({ className, children, side, showCloseButton, ...props }: DialogContentProps & RefAttributes & { side?: "top" | "right" | "bottom" | "left"; showCloseButton?: boolean; }) => Element +SheetDescription (value): ({ className, ...props }: DialogDescriptionProps & RefAttributes) => Element +SheetFooter (value): ({ className, ...props }: DetailedHTMLProps, HTMLDivElement>) => Element +SheetHeader (value): ({ className, ...props }: DetailedHTMLProps, HTMLDivElement>) => Element +SheetTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes) => Element +SheetTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes) => Element +Skeleton (value): ({ className, ...props }: DetailedHTMLProps, HTMLDivElement>) => Element +Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderProps & RefAttributes) => Element +Sparkline (value): ({ data, domain, className, colorClassName, showLastDot, height, }: SparklineProps) => Element +SparklineProps (type): SparklineProps +StatRow (value): ({ label, value, mono, className, }: { label: ReactNode; value: ReactNode; mono?: boolean; className?: string; }) => Element +Switch (value): ({ className, ...props }: SwitchProps & RefAttributes) => Element +TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element +Textarea (value): ForwardRefExoticComponent> +TextareaProps (type): TextareaProps +Tooltip (value): ({ ...props }: TooltipProps) => Element +TooltipContent (value): ({ className, sideOffset, children, ...props }: TooltipContentProps & RefAttributes) => Element +TooltipProvider (value): ({ delayDuration, ...props }: TooltipProviderProps) => Element +TooltipTrigger (value): ({ ...props }: TooltipTriggerProps & RefAttributes) => Element +UptimeInfo (type): UptimeInfo +VersionInfo (type): VersionInfo +WebSocketConnectionTarget (type): WebSocketConnectionTarget +WidgetTitle (value): ({ title, icon, fallback }: WidgetTitleProps) => Element +buildOriginWsUrl (value): ({ origin, localPort, localPath, remotePath, includeJwt, }: { origin: ConnectionOrigin; localPort: number; localPath: string; remotePath: string; includeJwt?: boolean; }) => Promise +cn (value): (...inputs: ClassValue[]) => string +copyToClipboard (value): (text: string) => Promise +createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger +createQuickConnectHost (value): (input: QuickConnectInput) => Host +findMatchingKeybinding (value): (e: KeyboardEvent, bindings: CustomKeybinding[]) => CustomKeybinding +getAdaptiveResourceBudget (value): (kind: AdaptiveTaskKind) => AdaptiveResourceBudget +getBasePath (value): () => string +getDatabaseHealth (value): () => Promise> +getDeviceId (value): () => string +getPollingEnvironmentMultiplier (value): (connection?: NetworkInformationLike) => number +getRecentActivity (value): (limit?: number) => Promise +getUptime (value): () => Promise +getVersionInfo (value): (checkRemote?: boolean) => Promise +globalShortcutHandler (value): { current: (e: KeyboardEvent) => void; } +hydrateLocalSharedHostAuth (value): (host: T) => Promise +isElectron (value): () => boolean +isMacPlatform (value): () => boolean +isTabJumpHotkey (value): (e: KeyboardEvent) => boolean +linkedServerUrl (value): () => Promise +markAdaptiveResourceUsed (value): (kind: AdaptiveTaskKind, action: string, now?: number) => void +pluginWsUrl (value): (pluginId: string, path: string, options?: { origin?: ConnectionOrigin; }) => Promise +readFromClipboard (value): () => Promise +resolveConnectionOrigin (value): (host: OriginResolvableHost, options?: { defaultRemote?: boolean; }) => Promise +resolveRemoteHostId (value): (syncId: string) => Promise +runAdaptiveBackgroundTask (value): (kind: AdaptiveTaskKind, action: string, task: () => Promise, options?: AdaptiveTaskOptions) => boolean +runAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise, policy: AdaptivePollingPolicy, options?: { enabled?: () => boolean; visible?: () => boolean; runImmediately?: boolean; random?: () => number; intervalMultiplier?: () => number; onError?: (error: unknown) => void; }) => () => void +runVisibleInterval (value): (tick: () => void, intervalMs: number) => () => void +useAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise, policy: AdaptivePollingPolicy, enabled?: boolean, options?: { runImmediately?: boolean; onError?: (error: unknown) => void; }) => void +useAppTheme (value): () => ThemeProviderState +useConfirmation (value): () => { isOpen: boolean; options: ConfirmationOptions; confirm: (opts: ConfirmationOptions, callback: () => void) => void; handleConfirm: () => void; handleCancel: () => void; confirmWithToast: (opts: string | ConfirmationOptions, callback?: () => void, variantOrConfirmLabel?: string, cancelLabel?: string, toastOptions?: ToastConfirmOptions) => Promise; } +useConnectionLog (value): () => ConnectionLogContextType +useIsMobile (value): () => boolean +useOptionalConnectionLog (value): () => ConnectionLogContextType +useTabs (value): () => TabContextType +useTabsSafe (value): () => TabContextType diff --git a/plugins/acme-ssl/package.json b/plugins/acme-ssl/package.json index 0c318d05f..636f11e92 100644 --- a/plugins/acme-ssl/package.json +++ b/plugins/acme-ssl/package.json @@ -6,13 +6,26 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, "dependencies": { "acme-client": "^5.4.0" }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8" } } diff --git a/plugins/ai/manifest.json b/plugins/ai/manifest.json index dd86b399f..0375022b8 100644 --- a/plugins/ai/manifest.json +++ b/plugins/ai/manifest.json @@ -24,7 +24,8 @@ "network:outbound", "notify:send", "network:serve", - "ui:surface" + "ui:surface", + "users:impersonate" ], "optionalDependencies": { "workspaces": "^1.0.0", @@ -185,7 +186,8 @@ } ] } - } + }, + "uses": ["terminal.sidePanel", "terminal.toolbar"] }, "locales": "locales", "platforms": ["linux", "win32", "darwin"] diff --git a/plugins/ai/package.json b/plugins/ai/package.json index 51bb1be59..0ea2606b0 100644 --- a/plugins/ai/package.json +++ b/plugins/ai/package.json @@ -6,10 +6,32 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, + "dependencies": { + "@anthropic-ai/sdk": "^0.120.0", + "lucide-react": "^1.33.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8", + "ssh2": "^1.17.0" } } diff --git a/plugins/ai/tests/backend/gating.test.ts b/plugins/ai/tests/backend/gating.test.ts index a7c5d4df5..26c971090 100644 --- a/plugins/ai/tests/backend/gating.test.ts +++ b/plugins/ai/tests/backend/gating.test.ts @@ -113,7 +113,7 @@ describe("createAiGate", () => { it("passes an opted-in user through with their access attached", async () => { const next = vi.fn(); - const req: { aiAccess?: unknown } = {}; + const req: Parameters>[0] = {}; await createAiGate( settings({ globallyEnabled: true }, { enabled: true }), () => "user-1", diff --git a/plugins/alerts/package.json b/plugins/alerts/package.json index fcbc1f303..8ee908026 100644 --- a/plugins/alerts/package.json +++ b/plugins/alerts/package.json @@ -6,14 +6,29 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, "dependencies": { + "lucide-react": "^1.33.0", "nodemailer": "^10.0.10" }, "devDependencies": { - "@termix/plugin-sdk": "*", - "@types/nodemailer": "^8.0.2" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8" } } diff --git a/plugins/automations/manifest.json b/plugins/automations/manifest.json index 4fc967228..cd18b6b5d 100644 --- a/plugins/automations/manifest.json +++ b/plugins/automations/manifest.json @@ -24,7 +24,8 @@ "network:outbound", "network:serve", "settings:read-core", - "ui:surface" + "ui:surface", + "users:impersonate" ], "optionalDependencies": { "fleets": "^1.0.0", @@ -119,7 +120,8 @@ ], "http": { "legacyPaths": ["/automations/webhook"] - } + }, + "uses": ["alerts.openChannels"] }, "locales": "locales", "platforms": ["linux", "win32", "darwin"], diff --git a/plugins/automations/package.json b/plugins/automations/package.json index 72ccaf1cd..5ad39445a 100644 --- a/plugins/automations/package.json +++ b/plugins/automations/package.json @@ -6,10 +6,29 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, + "dependencies": { + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8", + "ssh2": "^1.17.0" } } diff --git a/plugins/automations/tests/backend/activate.test.ts b/plugins/automations/tests/backend/activate.test.ts index 7f84f0251..4b4ea31b6 100644 --- a/plugins/automations/tests/backend/activate.test.ts +++ b/plugins/automations/tests/backend/activate.test.ts @@ -61,7 +61,7 @@ describe("automations activate", () => { capabilities: manifest.capabilities.filter((c) => c !== "notify:send"), }); await expect( - withoutNotify.ctx.notify.send([1], { title: "t", body: "b" }), + withoutNotify.ctx.notify.send({ title: "t", body: "b" } as never), ).rejects.toBeInstanceOf(PluginCapabilityError); }); diff --git a/plugins/automations/tests/backend/triggers.test.ts b/plugins/automations/tests/backend/triggers.test.ts index 0ba52b3dc..68ce34fe9 100644 --- a/plugins/automations/tests/backend/triggers.test.ts +++ b/plugins/automations/tests/backend/triggers.test.ts @@ -99,7 +99,7 @@ describe("onMetrics", () => { }); expect(run).toHaveBeenCalledTimes(1); - expect(run.mock.calls[0][0]).toMatchObject({ + expect((run.mock.calls[0] as unknown[])[0]).toMatchObject({ triggerType: "metric_threshold", triggerHostId: 7, }); diff --git a/plugins/docker/manifest.json b/plugins/docker/manifest.json index b06dd7dc1..f2a37c77b 100644 --- a/plugins/docker/manifest.json +++ b/plugins/docker/manifest.json @@ -20,7 +20,8 @@ "ssh:connect", "network:serve", "ui:surface", - "events:core" + "events:core", + "users:impersonate" ], "provides": [ { @@ -93,7 +94,8 @@ "viewMode": "detail", "containerLayout": "table" } - } + }, + "uses": ["homepage.widgets", "terminal.resolveTheme"] }, "locales": "locales", "platforms": ["linux", "win32", "darwin"] diff --git a/plugins/docker/package.json b/plugins/docker/package.json index 0bd521029..bdbb083c7 100644 --- a/plugins/docker/package.json +++ b/plugins/docker/package.json @@ -6,10 +6,43 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, + "dependencies": { + "@xterm/addon-clipboard": "^0.2.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", + "lucide-react": "^1.33.0", + "react-xtermjs": "^1.0.10" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8", + "ssh2": "^1.17.0", + "ws": "^8.21.3" + }, + "termix": { + "patches": [ + "xterm-android-ime" + ] + }, + "overrides": { + "react-xtermjs": { + "@xterm/xterm": "$@xterm/xterm" + } } } diff --git a/plugins/docker/src/frontend/index.tsx b/plugins/docker/src/frontend/index.tsx index 588833656..2ff1f30e5 100644 --- a/plugins/docker/src/frontend/index.tsx +++ b/plugins/docker/src/frontend/index.tsx @@ -1,3 +1,5 @@ +// The terminal surfaces need xterm's own stylesheet. +import "@xterm/xterm/css/xterm.css"; import type { ComponentType } from "react"; import { Box } from "lucide-react"; import type { diff --git a/plugins/file-manager/locales/en.json b/plugins/file-manager/locales/en.json index de40398c6..5840acb52 100644 --- a/plugins/file-manager/locales/en.json +++ b/plugins/file-manager/locales/en.json @@ -1,7 +1,8 @@ { "nav": { "files": "Files", - "sftp": "SFTP" + "sftp": "SFTP", + "openFileManager": "Open File Manager" }, "permissions": { "use": { @@ -520,6 +521,9 @@ "transferError": "Transfer failed", "transferFailedRetryHint": "Partial data was kept on the destination. Retry will resume when the connection is back.", "transferPartialHint": "Could not transfer: {{paths}}", - "transferSuccess": "Transfer complete" + "transferSuccess": "Transfer complete", + "metricsHopSourceRead": "Source → server: {{throughput}}", + "metricsHopDestSftpWrite": "Server → dest (SFTP): {{throughput}}", + "metricsHopDestLocalWrite": "Server → dest (local): {{throughput}}" } } diff --git a/plugins/file-manager/manifest.json b/plugins/file-manager/manifest.json index 18e972bb1..da8640807 100644 --- a/plugins/file-manager/manifest.json +++ b/plugins/file-manager/manifest.json @@ -23,7 +23,8 @@ "network:serve", "ui:surface", "events:core", - "kv:own" + "kv:own", + "users:impersonate" ], "provides": [ { @@ -108,7 +109,19 @@ "key": "enableFileManager", "labelKey": "hostCapability.label", "editorTab": "plugins" - } + }, + "uiPresets": { + "simple": { + "viewMode": "grid" + }, + "balanced": { + "viewMode": "grid" + }, + "advanced": { + "viewMode": "list" + } + }, + "uses": ["homepage.widgets", "host-metrics.disk", "terminal.open"] }, "locales": "locales", "platforms": ["linux", "win32", "darwin"] diff --git a/plugins/file-manager/package.json b/plugins/file-manager/package.json index 9de4baf22..ff03ff847 100644 --- a/plugins/file-manager/package.json +++ b/plugins/file-manager/package.json @@ -6,10 +6,56 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, + "dependencies": { + "@codemirror/autocomplete": "^6.20.3", + "@codemirror/commands": "^6.11.1", + "@codemirror/search": "^6.7.2", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.43.12", + "@monaco-editor/react": "^4.7.0", + "@tanstack/react-virtual": "^3.14.13", + "@uiw/codemirror-extensions-langs": "^4.25.11", + "@uiw/react-codemirror": "^4.25.11", + "busboy": "^1.6.0", + "jszip": "^3.10.2", + "lucide-react": "^1.33.0", + "motion": "^13.1.1", + "pdfjs-dist": "5.4.296", + "react-h5-audio-player": "^3.10.2", + "react-icons": "^5.7.0", + "react-markdown": "^10.1.0", + "react-pdf": "^10.5.0", + "react-photo-view": "^1.2.7", + "react-syntax-highlighter": "^16.1.1", + "remark-gfm": "^4.0.1" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "axios": "^1.19.0", + "cookie-parser": "^1.4.7", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "i18next": "^26.4.2", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-i18next": "^17.0.14", + "sonner": "^2.0.8", + "ssh2": "^1.17.0" } } diff --git a/plugins/file-manager/src/frontend/FileManager.tsx b/plugins/file-manager/src/frontend/FileManager.tsx index d84ffb3fc..df7612ab6 100644 --- a/plugins/file-manager/src/frontend/FileManager.tsx +++ b/plugins/file-manager/src/frontend/FileManager.tsx @@ -46,6 +46,7 @@ import { } from "@termix/plugin-sdk/ui"; import { ConnectionLogPanel } from "@termix/plugin-sdk/ui"; import { + usePluginUiPreferences, invokeAction, useConnectionRetry, logActivity, @@ -177,25 +178,32 @@ function FileManagerContent({ const [isReconnecting, setIsReconnecting] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [lastRefreshTime, setLastRefreshTime] = useState(0); - const [viewMode, setViewMode] = useState<"grid" | "list">(() => { - const saved = localStorage.getItem("fileManagerViewMode"); - return saved === "grid" || saved === "list" ? saved : "grid"; - }); + // An interface preset sets this too, so it is a plugin UI preference. + const { values: fmPrefs, set: setFmPref } = usePluginUiPreferences<{ + viewMode: "grid" | "list"; + }>(); + const viewMode: "grid" | "list" = + fmPrefs.viewMode === "list" ? "list" : "grid"; + const setViewMode = useCallback( + (next: "grid" | "list") => setFmPref("viewMode", next), + [setFmPref], + ); + useEffect(() => { + // Before 2.9 the view mode lived in this browser's localStorage. + try { + const legacy = localStorage.getItem("fileManagerViewMode"); + if (legacy === null) return; + localStorage.removeItem("fileManagerViewMode"); + if (legacy === "grid" || legacy === "list") setFmPref("viewMode", legacy); + } catch { + // Storage can be unavailable. + } + }, []); const [density, setDensity] = useState<"comfortable" | "compact">(() => localStorage.getItem("fileManagerDensity") === "compact" ? "compact" : "comfortable", ); - // Picking an interface preset seeds this key from another part of the app. - useEffect(() => { - const handler = () => { - const saved = localStorage.getItem("fileManagerViewMode"); - if (saved === "grid" || saved === "list") setViewMode(saved); - }; - window.addEventListener("fileManagerViewModeChanged", handler); - return () => - window.removeEventListener("fileManagerViewModeChanged", handler); - }, []); const [sortBy, setSortBy] = useState<"name" | "modified" | "size">(() => { const saved = localStorage.getItem("fileManagerSortBy"); return saved === "name" || saved === "modified" || saved === "size" @@ -3328,10 +3336,6 @@ function FileManagerContent({ } }, [currentHost?.id]); - useEffect(() => { - localStorage.setItem("fileManagerViewMode", viewMode); - }, [viewMode]); - useEffect(() => { localStorage.setItem("fileManagerDensity", density); }, [density]); diff --git a/plugins/file-manager/src/frontend/LocalFileContextMenu.tsx b/plugins/file-manager/src/frontend/LocalFileContextMenu.tsx index 0d7402344..2cd8b7288 100644 --- a/plugins/file-manager/src/frontend/LocalFileContextMenu.tsx +++ b/plugins/file-manager/src/frontend/LocalFileContextMenu.tsx @@ -15,7 +15,7 @@ import { } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Kbd, KbdKey, KbdSeparator } from "@termix/plugin-sdk/ui"; -import type { LocalFileEntry } from "@termix/plugin-sdk/ui"; +import type { LocalFileEntry } from "@termix/plugin-sdk/frontend"; const VIEWPORT_PADDING = 16; diff --git a/plugins/file-manager/src/frontend/LocalFilePane.tsx b/plugins/file-manager/src/frontend/LocalFilePane.tsx index 4b19e238f..a0fbee422 100644 --- a/plugins/file-manager/src/frontend/LocalFilePane.tsx +++ b/plugins/file-manager/src/frontend/LocalFilePane.tsx @@ -28,7 +28,7 @@ import { } from "lucide-react"; import { cn } from "@termix/plugin-sdk/ui"; import { Button, Input } from "@termix/plugin-sdk/ui"; -import type { LocalFileEntry } from "@termix/plugin-sdk/ui"; +import type { LocalFileEntry } from "@termix/plugin-sdk/frontend"; import { createLocalFile, createLocalFolder, diff --git a/plugins/file-manager/src/frontend/api/local-transfer-api.ts b/plugins/file-manager/src/frontend/api/local-transfer-api.ts index da14b9122..3a7e7d534 100644 --- a/plugins/file-manager/src/frontend/api/local-transfer-api.ts +++ b/plugins/file-manager/src/frontend/api/local-transfer-api.ts @@ -9,10 +9,11 @@ // configured Remote Sync server) and the main process resolves the rest, so a // compromised renderer cannot turn the transfer bridge into an HTTP client. -import type { - LocalTransferOrigin, - LocalTransferProgress, -} from "@termix/plugin-sdk/ui"; +import { + getLocalAuthToken, + type LocalTransferOrigin, + type LocalTransferProgress, +} from "@termix/plugin-sdk/frontend"; import { getSessionOrigin } from "./client"; import { getDeviceId } from "@termix/plugin-sdk/ui"; @@ -67,11 +68,7 @@ function transferOriginFor(sessionId: string): LocalTransferOrigin { */ function localAuthTokenFor(origin: LocalTransferOrigin): string | undefined { if (origin !== "local") return undefined; - try { - return localStorage.getItem("jwt") || undefined; - } catch { - return undefined; - } + return getLocalAuthToken() ?? undefined; } export function createLocalTransferId(prefix: string): string { diff --git a/plugins/file-manager/src/frontend/hooks/useLocalTransfers.ts b/plugins/file-manager/src/frontend/hooks/useLocalTransfers.ts index 1fa856d56..942c4fa1d 100644 --- a/plugins/file-manager/src/frontend/hooks/useLocalTransfers.ts +++ b/plugins/file-manager/src/frontend/hooks/useLocalTransfers.ts @@ -3,7 +3,7 @@ import { createElement } from "react"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import type { FileItem } from "../host-types"; -import type { LocalWalkResult } from "@termix/plugin-sdk/ui"; +import type { LocalWalkResult } from "@termix/plugin-sdk/frontend"; import { useSettings } from "@termix/plugin-sdk/frontend"; import { cancelLocalTransfer, diff --git a/plugins/file-manager/src/frontend/index.tsx b/plugins/file-manager/src/frontend/index.tsx index fa34ce924..2cc998dae 100644 --- a/plugins/file-manager/src/frontend/index.tsx +++ b/plugins/file-manager/src/frontend/index.tsx @@ -1,6 +1,6 @@ import { fileManagerHostSetting } from "./host-settings"; import type { ComponentType } from "react"; -import { FolderSearch, ArrowLeftRight } from "lucide-react"; +import { FolderOpen, FolderSearch, ArrowLeftRight } from "lucide-react"; import { invokeAction, type PluginHostRecord, @@ -70,8 +70,14 @@ function openEditorAction( app.tabs.openTab(host, "files", { data: { initialFilePath: filePath } }); } +/** Where this plugin serves the streaming routes the desktop app calls. */ +const TRANSFER_API_PATH = "/plugin-api/file-manager"; + export function activate(app: TermixApp): void { setFileManagerApp(app); + if (typeof window !== "undefined") { + void window.electronAPI?.localTransfer?.setApiPath?.(TRANSFER_API_PATH); + } app.onDispose(() => setFileManagerApp(null)); app.registerTab("files", FilesTab as unknown as ComponentType, { icon: FolderSearch, @@ -135,8 +141,21 @@ export function activate(app: TermixApp): void { const openHost = ((host: PluginHostRecord | null, path?: string) => openHostAction(app, host, path)) as never; app.registerAction("files.openHost", openHost); - // The shell's "open in file manager" tab button asks for this. - app.registerAction("host.openFiles", openHost); + // "Open File Manager" in the tab bar's menu, for a terminal tab. The + // terminal answers with its working directory and opens the files tab. + app.registerAction("file-manager.openFromTab", ((handle: unknown) => + ( + handle as { openFileManager?: () => void } | null + )?.openFileManager?.()) as never); + app.registerSlotContribution("tab.menu", { + actionId: "file-manager.openFromTab", + titleKey: "nav.openFileManager", + icon: FolderOpen, + kind: "button", + when: (context) => + typeof (context.handle as { openFileManager?: unknown } | null) + ?.openFileManager === "function", + }); app.registerAction("files.openEditor", (( host: PluginHostRecord | null, filePath: string, diff --git a/plugins/file-manager/src/frontend/lib/local-files.ts b/plugins/file-manager/src/frontend/lib/local-files.ts index 5a3be31c3..df639e7ad 100644 --- a/plugins/file-manager/src/frontend/lib/local-files.ts +++ b/plugins/file-manager/src/frontend/lib/local-files.ts @@ -8,7 +8,7 @@ import type { LocalFsResult, LocalTrashResult, LocalWalkResult, -} from "@termix/plugin-sdk/ui"; +} from "@termix/plugin-sdk/frontend"; import { isElectron } from "@termix/plugin-sdk/ui"; export function isLocalFileBrowserAvailable(): boolean { diff --git a/plugins/file-manager/src/frontend/local-transfer-utils.ts b/plugins/file-manager/src/frontend/local-transfer-utils.ts index ced4c5f7d..6f78a9b58 100644 --- a/plugins/file-manager/src/frontend/local-transfer-utils.ts +++ b/plugins/file-manager/src/frontend/local-transfer-utils.ts @@ -1,7 +1,7 @@ // Pure helpers shared by the dual-pane (local <-> remote) file manager code. // Kept free of React/DOM so they can be unit tested directly. -import type { LocalFileEntry } from "@termix/plugin-sdk/ui"; +import type { LocalFileEntry } from "@termix/plugin-sdk/frontend"; /** Custom MIME type carried by drags that originate in the local pane. */ export const LOCAL_FILES_DRAG_MIME = "application/x-termix-local-files"; diff --git a/plugins/file-manager/tests/backend/download-routes.test.ts b/plugins/file-manager/tests/backend/download-routes.test.ts index b934cc733..dfa88555f 100644 --- a/plugins/file-manager/tests/backend/download-routes.test.ts +++ b/plugins/file-manager/tests/backend/download-routes.test.ts @@ -12,7 +12,9 @@ function setup(remote: object, owned = true) { (req: Request, res: Response) => Promise >(); registerFileDownloadRoutes( - { post: (path, handler) => routes.set(path, handler) } as Express, + { + post: (path, handler) => routes.set(path, handler), + } as unknown as Express, { ctx: { currentActor: () => "user", diff --git a/plugins/file-manager/tests/frontend/LocalFilePane.test.tsx b/plugins/file-manager/tests/frontend/LocalFilePane.test.tsx index 0894af42e..c15419888 100644 --- a/plugins/file-manager/tests/frontend/LocalFilePane.test.tsx +++ b/plugins/file-manager/tests/frontend/LocalFilePane.test.tsx @@ -6,7 +6,7 @@ import { LOCAL_FILES_DRAG_MIME, REMOTE_FILES_DRAG_MIME, } from "../../src/frontend/local-transfer-utils"; -import type { LocalFileEntry } from "@termix/plugin-sdk/ui"; +import type { LocalFileEntry } from "@termix/plugin-sdk/frontend"; // jsdom has no layout, so render every row instead of a virtual window. vi.mock("@tanstack/react-virtual", () => ({ diff --git a/plugins/file-manager/tests/frontend/activate.test.tsx b/plugins/file-manager/tests/frontend/activate.test.tsx index b8fde657f..049cd670a 100644 --- a/plugins/file-manager/tests/frontend/activate.test.tsx +++ b/plugins/file-manager/tests/frontend/activate.test.tsx @@ -57,12 +57,15 @@ describe(`${manifest.id} activate`, () => { expect(rendered.registered.hostEditorSections()).toEqual([]); }); - it("registers its open actions, including the shell's host.openFiles", async () => { + it("registers its open actions and the tab menu entry", async () => { rendered = await renderWithApp(plugin, { manifest, locales }); expect(rendered.registered.actions().sort()).toEqual([ + "file-manager.openFromTab", "files.openEditor", "files.openHost", - "host.openFiles", + ]); + expect(rendered.registered.slot("tab.menu")).toEqual([ + "file-manager.openFromTab", ]); }); diff --git a/plugins/file-manager/tests/frontend/local-transfer-utils.test.ts b/plugins/file-manager/tests/frontend/local-transfer-utils.test.ts index f47770a2f..2ec2c29ad 100644 --- a/plugins/file-manager/tests/frontend/local-transfer-utils.test.ts +++ b/plugins/file-manager/tests/frontend/local-transfer-utils.test.ts @@ -25,7 +25,7 @@ import { serializeLocalFilesDragPayload, sortLocalEntries, } from "../../src/frontend/local-transfer-utils"; -import type { LocalFileEntry } from "@termix/plugin-sdk/ui"; +import type { LocalFileEntry } from "@termix/plugin-sdk/frontend"; describe("drag payloads", () => { it("round-trips local file payloads", () => { diff --git a/plugins/fleets/manifest.json b/plugins/fleets/manifest.json index 328a1891b..5d0ded88f 100644 --- a/plugins/fleets/manifest.json +++ b/plugins/fleets/manifest.json @@ -70,6 +70,7 @@ "descriptionKey": "permissions.execute.description", "defaultRoles": ["user"] } - ] + ], + "uses": ["snippets.extractInputs"] } } diff --git a/plugins/fleets/package.json b/plugins/fleets/package.json index 30add508c..32b03b5a3 100644 --- a/plugins/fleets/package.json +++ b/plugins/fleets/package.json @@ -6,10 +6,31 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, + "dependencies": { + "clsx": "^2.1.1", + "jszip": "^3.10.2", + "lucide-react": "^1.33.0", + "tailwind-merge": "^3.5.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "multer": "^2.3.0", + "react": "^19.2.8", + "sonner": "^2.0.8", + "ssh2": "^1.17.0" } } diff --git a/plugins/fleets/tests/backend/repository.test.ts b/plugins/fleets/tests/backend/repository.test.ts index 980d16135..cb74526fd 100644 --- a/plugins/fleets/tests/backend/repository.test.ts +++ b/plugins/fleets/tests/backend/repository.test.ts @@ -20,7 +20,7 @@ afterEach(() => { }); function fakeHosts(hosts: PluginHostSummary[]): PluginHosts { - return { + return ({ list: async () => hosts, get: async (hostId) => hosts.find((h) => h.id === hostId) ?? null, checkAccess: async (hostId) => { @@ -37,7 +37,7 @@ function fakeHosts(hosts: PluginHostSummary[]): PluginHosts { share: async (hostId) => ({ hostId, shared: true }), listUsers: async () => [], listRoles: async () => [], - }; + }); } async function setup(hosts: PluginHostSummary[]) { diff --git a/plugins/fleets/tests/backend/service.test.ts b/plugins/fleets/tests/backend/service.test.ts index 3af44340f..0dbe1163a 100644 --- a/plugins/fleets/tests/backend/service.test.ts +++ b/plugins/fleets/tests/backend/service.test.ts @@ -19,15 +19,14 @@ describe("fleets.access", () => { const service = server.mock.services.get("fleets.access") as FleetsService; await expect( - server.mock.ctx.asUser("user-1", () => service.create({ name: "prod" })), + server.mock.actAs("user-1", () => service.create({ name: "prod" })), ).rejects.toThrow("fleets.manage"); }); it("adds only hosts the caller can see", async () => { server = await startServer(); const service = server.mock.services.get("fleets.access") as FleetsService; - const run = (fn: () => Promise) => - server!.mock.ctx.asUser("user-1", fn); + const run = (fn: () => Promise) => server!.mock.actAs("user-1", fn); const fleet = await run(() => service.create({ name: "prod" })); await run(() => service.addMember(fleet.id, 10)); diff --git a/plugins/homepage/package.json b/plugins/homepage/package.json index 4a43a1612..5df858d22 100644 --- a/plugins/homepage/package.json +++ b/plugins/homepage/package.json @@ -6,10 +6,27 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, + "dependencies": { + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8" } } diff --git a/plugins/homepage/src/frontend/HomepageCanvas.tsx b/plugins/homepage/src/frontend/HomepageCanvas.tsx index 23e48bcec..5638f57b6 100644 --- a/plugins/homepage/src/frontend/HomepageCanvas.tsx +++ b/plugins/homepage/src/frontend/HomepageCanvas.tsx @@ -333,7 +333,9 @@ export function HomepageCanvas({ scheduleSave(next); return next; }); - } catch {} + } catch { + // A bad layout write is dropped; the canvas keeps what it shows. + } }, [scheduleSave], ); @@ -376,7 +378,9 @@ export function HomepageCanvas({ scheduleSave(next); return next; }); - } catch {} + } catch { + // A bad layout write is dropped; the canvas keeps what it shows. + } }, [scheduleSave], ); @@ -403,7 +407,9 @@ export function HomepageCanvas({ setWidgets((prev) => prev.map((w) => (w.id === id ? { ...w, title, config } : w)), ); - } catch {} + } catch { + // A bad layout write is dropped; the canvas keeps what it shows. + } }, [], ); diff --git a/plugins/homepage/src/frontend/index.tsx b/plugins/homepage/src/frontend/index.tsx index 59de3d4c0..cc9fd5db5 100644 --- a/plugins/homepage/src/frontend/index.tsx +++ b/plugins/homepage/src/frontend/index.tsx @@ -93,10 +93,6 @@ export function activate(app: TermixApp): void { component: HomepagePreviewCard, }); - app.declareActionSlot({ - id: "dashboard.secondaryView", - accepts: ["component"], - }); app.registerSlotContribution("dashboard.secondaryView", { actionId: "homepage", titleKey: "nav.homepage", diff --git a/plugins/homepage/src/frontend/widgets/QuickConnectWidget.tsx b/plugins/homepage/src/frontend/widgets/QuickConnectWidget.tsx index f2f8d80e0..45f7d53eb 100644 --- a/plugins/homepage/src/frontend/widgets/QuickConnectWidget.tsx +++ b/plugins/homepage/src/frontend/widgets/QuickConnectWidget.tsx @@ -4,7 +4,7 @@ import { useHostActions, useHostStatus, useHosts, - type PluginHostRecord, + useTabs, } from "@termix/plugin-sdk/frontend"; import { registerWidget } from "./WidgetRegistry"; @@ -18,14 +18,6 @@ import { GRID_SIZE } from "../types.js"; import { quickConnectTargets } from "../quick-connect-targets.js"; import { WidgetTitle } from "@termix/plugin-sdk/ui"; -function openTab(host: PluginHostRecord, type: QuickConnectType) { - window.dispatchEvent( - new CustomEvent("termix:open-tab", { - detail: { hostId: String(host.id), type }, - }), - ); -} - function StatusDot({ hostId }: { hostId: number }) { const status = useHostStatus(hostId); const cls = @@ -43,6 +35,7 @@ function QuickConnectWidget({ }: WidgetComponentProps) { const { t } = useTranslation(); const { hosts: allHosts, loaded } = useHosts(); + const tabs = useTabs(); const targets = quickConnectTargets(useHostActions()); const hosts = @@ -104,7 +97,7 @@ function QuickConnectWidget({ onMouseDown={(e) => e.stopPropagation()} onClick={(e) => { e.stopPropagation(); - openTab(host, target.type); + tabs.connectHost(host, target.type); }} className="p-1.5 text-muted-foreground hover:text-accent-brand hover:bg-accent-brand/10 transition-colors border border-transparent hover:border-accent-brand/20" > diff --git a/plugins/host-metrics/manifest.json b/plugins/host-metrics/manifest.json index 3de1c0be7..2e1592d36 100644 --- a/plugins/host-metrics/manifest.json +++ b/plugins/host-metrics/manifest.json @@ -22,7 +22,8 @@ "db:own", "network:serve", "ui:surface", - "events:core" + "events:core", + "users:impersonate" ], "provides": [ { @@ -153,7 +154,12 @@ "advanced": { "columns": 4 } - } + }, + "uses": [ + "homepage.hostMetrics", + "homepage.widgets", + "terminal.toolbarStatus" + ] }, "locales": "locales", "platforms": ["linux", "win32", "darwin"] diff --git a/plugins/host-metrics/package.json b/plugins/host-metrics/package.json index 44c8106c8..b14dba5d4 100644 --- a/plugins/host-metrics/package.json +++ b/plugins/host-metrics/package.json @@ -6,10 +6,30 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, + "dependencies": { + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8", + "ssh2": "^1.17.0" } } diff --git a/plugins/host-metrics/src/frontend/HostEditorStatsTab.tsx b/plugins/host-metrics/src/frontend/HostEditorStatsTab.tsx index 2a586a4ce..4131df13b 100644 --- a/plugins/host-metrics/src/frontend/HostEditorStatsTab.tsx +++ b/plugins/host-metrics/src/frontend/HostEditorStatsTab.tsx @@ -26,7 +26,6 @@ type PluginSettingsBag = Record>; */ export function HostStatsTab({ form, - setField, updateForm, host, }: HostEditorSectionProps) { diff --git a/plugins/host-metrics/src/frontend/SummaryViews.tsx b/plugins/host-metrics/src/frontend/SummaryViews.tsx index 4cc0f8d3c..cc017f5d6 100644 --- a/plugins/host-metrics/src/frontend/SummaryViews.tsx +++ b/plugins/host-metrics/src/frontend/SummaryViews.tsx @@ -27,7 +27,7 @@ function RowBar({ label, value }: { label: string; value: number }) { ); } -/** CPU, RAM and disk bars in a dashboard host row ("dashboard.hostMetrics"). */ +/** CPU, RAM and disk bars in a dashboard host row ("dashboard.hostRow"). */ export function createDashboardHostMetrics(store: MetricsSummaryStore) { return function DashboardHostMetrics(props: Record) { const { t } = useTranslation(); diff --git a/plugins/host-metrics/src/frontend/cards/CpuCard.tsx b/plugins/host-metrics/src/frontend/cards/CpuCard.tsx index 784b70dd6..4eaf8fe5d 100644 --- a/plugins/host-metrics/src/frontend/cards/CpuCard.tsx +++ b/plugins/host-metrics/src/frontend/cards/CpuCard.tsx @@ -13,13 +13,6 @@ import { useTranslation } from "@termix/plugin-sdk/frontend"; import { type MetricsHistoryRow, useHostMetricsApi } from "../host-metrics-api"; import { CardTimeTabs, type HistoryTab } from "./CardTimeTabs"; -function computeChartData(rows: MetricsHistoryRow[]) { - return { - data: rows.map((r) => r.cpu_percent), - timestamps: rows.map((r) => r.ts), - }; -} - export function CpuCard({ metrics, history, diff --git a/plugins/host-metrics/src/frontend/index.tsx b/plugins/host-metrics/src/frontend/index.tsx index 31f07b632..361ea878a 100644 --- a/plugins/host-metrics/src/frontend/index.tsx +++ b/plugins/host-metrics/src/frontend/index.tsx @@ -111,7 +111,7 @@ export async function activate(app: TermixApp): Promise { }); // The usage bars in the dashboard's host status card. - app.registerSlotContribution("dashboard.hostMetrics", { + app.registerSlotContribution("dashboard.hostRow", { actionId: "host-metrics.dashboardHost", titleKey: "nav.hostMetrics", kind: "component", diff --git a/plugins/host-metrics/tests/backend/host-metrics-preferences.test.ts b/plugins/host-metrics/tests/backend/host-metrics-preferences.test.ts index ab0e1c5c3..dac0f24e9 100644 --- a/plugins/host-metrics/tests/backend/host-metrics-preferences.test.ts +++ b/plugins/host-metrics/tests/backend/host-metrics-preferences.test.ts @@ -32,8 +32,10 @@ describe("deriveEnabledWidgets", () => { it("output is always a subset of the 10 known WidgetTypes", () => { const slots = METRIC_CARD_IDS.map((id) => ({ id })).concat([ - { id: "user_manager" } as { id: (typeof METRIC_CARD_IDS)[number] }, - { id: "bogus" } as { id: (typeof METRIC_CARD_IDS)[number] }, + { id: "user_manager" } as unknown as { + id: (typeof METRIC_CARD_IDS)[number]; + }, + { id: "bogus" } as unknown as { id: (typeof METRIC_CARD_IDS)[number] }, ]); const out = deriveEnabledWidgets(slots); expect(out).toEqual(METRIC_CARD_IDS); diff --git a/plugins/host-metrics/tests/frontend/activate.test.tsx b/plugins/host-metrics/tests/frontend/activate.test.tsx index 3fe29be30..c8ffa9049 100644 --- a/plugins/host-metrics/tests/frontend/activate.test.tsx +++ b/plugins/host-metrics/tests/frontend/activate.test.tsx @@ -40,7 +40,7 @@ describe(`${manifest.id} activate`, () => { it("fills core's metric slots and offers disk usage to other plugins", async () => { rendered = await renderWithApp(plugin, { manifest, locales }); - expect(rendered.registered.slot("dashboard.hostMetrics")).toEqual([ + expect(rendered.registered.slot("dashboard.hostRow")).toEqual([ "host-metrics.dashboardHost", ]); expect(rendered.registered.slot("homepage.hostMetrics")).toEqual([ diff --git a/plugins/ldap/package.json b/plugins/ldap/package.json index 03b6a1128..70fda63d3 100644 --- a/plugins/ldap/package.json +++ b/plugins/ldap/package.json @@ -6,14 +6,29 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, "dependencies": { - "ldapjs": "^3.0.7" + "ldapjs": "^3.0.7", + "lucide-react": "^1.33.0" }, "devDependencies": { - "@termix/plugin-sdk": "*", - "@types/ldapjs": "^3.0.6" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8" } } diff --git a/plugins/network-topology/package.json b/plugins/network-topology/package.json index 8f76fea6f..7b582db90 100644 --- a/plugins/network-topology/package.json +++ b/plugins/network-topology/package.json @@ -6,14 +6,27 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, "dependencies": { "cytoscape": "^3.34.3", + "lucide-react": "^1.33.0", "react-cytoscapejs": "^2.0.0" }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8" } } diff --git a/plugins/network-topology/src/frontend/NetworkGraphCard.tsx b/plugins/network-topology/src/frontend/NetworkGraphCard.tsx index ee6695727..d98a610e5 100644 --- a/plugins/network-topology/src/frontend/NetworkGraphCard.tsx +++ b/plugins/network-topology/src/frontend/NetworkGraphCard.tsx @@ -31,6 +31,8 @@ import { useTranslation, usePluginApi, useHosts, + useTabs, + getHostStatusColorScheme, useHostActions, type PluginHostRecord, } from "@termix/plugin-sdk/frontend"; @@ -101,24 +103,6 @@ interface NetworkGraphCardProps { type NetworkElement = NetworkTopologyNode | NetworkTopologyEdge; -type StatusColorScheme = "accent" | "status"; - -/** Reads the sidebar preferences cache directly, same as core's status dots do. */ -function readStatusColorScheme(): StatusColorScheme { - try { - const raw = localStorage.getItem("hostSidebarPreferences"); - if (!raw) return "accent"; - const parsed = JSON.parse(raw) as { - display?: { statusColorScheme?: string }; - }; - return parsed?.display?.statusColorScheme === "status" - ? "status" - : "accent"; - } catch { - return "accent"; - } -} - function resolveCssVar(varName: string, fallback: string): string { const raw = getComputedStyle(document.documentElement) .getPropertyValue(varName) @@ -151,7 +135,7 @@ function buildNodeSvg( ): string { const isOnline = status === "online"; const isOffline = status === "offline"; - const useRealColors = readStatusColorScheme() === "status"; + const useRealColors = getHostStatusColorScheme() === "status"; let statusColor: string; if (isOnline) { statusColor = useRealColors @@ -583,10 +567,10 @@ export function NetworkGraphCard({ const hideMenu = () => setContextMenu((p) => ({ ...p, visible: false })); + const shellTabs = useTabs(); const fireOpen = (hostId: string, type?: string) => { - window.dispatchEvent( - new CustomEvent("termix:open-tab", { detail: { hostId, type } }), - ); + const host = liveHosts.find((candidate) => String(candidate.id) === hostId); + if (host) shellTabs.connectHost(host, type); }; const handleContextAction = (action: string) => { diff --git a/plugins/network-topology/tests/backend/activate.test.ts b/plugins/network-topology/tests/backend/activate.test.ts index 5bf026089..4dedc8c51 100644 --- a/plugins/network-topology/tests/backend/activate.test.ts +++ b/plugins/network-topology/tests/backend/activate.test.ts @@ -38,7 +38,7 @@ describe("network-topology activate", () => { const service = server.mock.services.get("network-topology.graph") as { get: () => Promise; }; - const result = await server.mock.ctx.asUser("user-1", () => service.get()); + const result = await server.mock.actAs("user-1", () => service.get()); expect(result).toEqual({ nodes: [{ data: { id: "1" } }], edges: [] }); }); @@ -47,7 +47,7 @@ describe("network-topology activate", () => { const service = server.mock.services.get("network-topology.graph") as { get: () => Promise; }; - const result = await server.mock.ctx.asUser("user-1", () => service.get()); + const result = await server.mock.actAs("user-1", () => service.get()); expect(result).toBeNull(); }); diff --git a/plugins/opkssh/manifest.json b/plugins/opkssh/manifest.json index 60fca372c..f5c1c076b 100644 --- a/plugins/opkssh/manifest.json +++ b/plugins/opkssh/manifest.json @@ -52,7 +52,8 @@ "to": "/callback" } ] - } + }, + "uses": ["terminal.overlay"] }, "locales": "locales", "platforms": ["linux", "win32", "darwin"] diff --git a/plugins/opkssh/package.json b/plugins/opkssh/package.json index 8ec88c708..d9f4a65f7 100644 --- a/plugins/opkssh/package.json +++ b/plugins/opkssh/package.json @@ -6,14 +6,30 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, "dependencies": { - "js-yaml": "^5.2.3" + "js-yaml": "^5.2.3", + "lucide-react": "^1.33.0" }, "devDependencies": { - "@termix/plugin-sdk": "*", - "@types/js-yaml": "^4.0.9" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "axios": "^1.19.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8" } } diff --git a/plugins/proxmox/locales/en.json b/plugins/proxmox/locales/en.json index e3258666c..4827300f6 100644 --- a/plugins/proxmox/locales/en.json +++ b/plugins/proxmox/locales/en.json @@ -88,7 +88,8 @@ "proxmoxLastSyncSummary": "{{created}} created, {{updated}} updated, {{markedMissing}} missing, {{skipped}} skipped", "proxmoxLastSyncStatus": { "error": "Failed", - "pending": "Pending" + "pending": "Pending", + "success": "Success" }, "proxmoxDiscoverAction": "Discover & import Proxmox guests", "proxmoxImportTitle": "Import from Proxmox", diff --git a/plugins/proxmox/manifest.json b/plugins/proxmox/manifest.json index afd8cc7ff..ba996ffa2 100644 --- a/plugins/proxmox/manifest.json +++ b/plugins/proxmox/manifest.json @@ -21,7 +21,8 @@ "ssh:connect", "db:own", "network:serve", - "ui:surface" + "ui:surface", + "users:impersonate" ], "contributes": { "tabs": [ diff --git a/plugins/proxmox/package.json b/plugins/proxmox/package.json index 9edbe17c2..d3a7d9e01 100644 --- a/plugins/proxmox/package.json +++ b/plugins/proxmox/package.json @@ -6,10 +6,27 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, + "dependencies": { + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8", + "ssh2": "^1.17.0" } } diff --git a/plugins/proxmox/src/frontend/ProxmoxDiscoverDialog.tsx b/plugins/proxmox/src/frontend/ProxmoxDiscoverDialog.tsx index 895bfb3e2..5af77d32d 100644 --- a/plugins/proxmox/src/frontend/ProxmoxDiscoverDialog.tsx +++ b/plugins/proxmox/src/frontend/ProxmoxDiscoverDialog.tsx @@ -1,5 +1,8 @@ import { useRef, useState } from "react"; -import { useTranslation } from "@termix/plugin-sdk/frontend"; +import { + notifyHostsChanged, + useTranslation, +} from "@termix/plugin-sdk/frontend"; import { Server, RefreshCw, CheckSquare, Square, Download } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@termix/plugin-sdk/ui"; @@ -201,7 +204,7 @@ export function ProxmoxDiscoverDialog({ if (toImport.length) { const updated = await listHosts(); onHostsChanged(updated); - window.dispatchEvent(new CustomEvent("termix:hosts-changed")); + notifyHostsChanged(); } const msg = [ diff --git a/plugins/proxmox/src/frontend/proxmox-import-auth.ts b/plugins/proxmox/src/frontend/proxmox-import-auth.ts index 77293ca18..e48ca7ef3 100644 --- a/plugins/proxmox/src/frontend/proxmox-import-auth.ts +++ b/plugins/proxmox/src/frontend/proxmox-import-auth.ts @@ -1,12 +1,4 @@ const SECRET_BACKED_AUTH_TYPES = new Set(["password", "key"]); -const SECRETLESS_AUTH_TYPES = new Set([ - "none", - "agent", - "opkssh", - "stepca", - "tailscale", - "vault", -]); export type ProxmoxImportAuth = { authType: string; @@ -18,8 +10,13 @@ export function resolveProxmoxImportAuth( defaultAuthType: string | undefined, credentialId: number | null | undefined, ): ProxmoxImportAuth { - // An explicit secretless auth choice (none/opkssh/tailscale/vault) wins. - if (defaultAuthType && SECRETLESS_AUTH_TYPES.has(defaultAuthType)) { + // Any auth type that needs no stored secret (none, agent, or one a plugin + // adds) wins, the same rule the backend copy applies. + if ( + defaultAuthType && + defaultAuthType !== "credential" && + !SECRET_BACKED_AUTH_TYPES.has(defaultAuthType) + ) { return { authType: defaultAuthType }; } @@ -35,14 +32,5 @@ export function resolveProxmoxImportAuth( }; } - // Explicit non secret-backed special type without a credential. - if ( - defaultAuthType && - defaultAuthType !== "credential" && - !SECRET_BACKED_AUTH_TYPES.has(defaultAuthType) - ) { - return { authType: defaultAuthType }; - } - return { authType: "none" }; } diff --git a/plugins/proxmox/tests/backend/proxmox-stats-polling.test.ts b/plugins/proxmox/tests/backend/proxmox-stats-polling.test.ts index e703d3cdd..fdbff72f7 100644 --- a/plugins/proxmox/tests/backend/proxmox-stats-polling.test.ts +++ b/plugins/proxmox/tests/backend/proxmox-stats-polling.test.ts @@ -86,7 +86,7 @@ describe("ProxmoxPollingManager", () => { }; const manager = new ProxmoxPollingManager({ fetchHostById, - withSshConnection, + withSshConnection: withSshConnection as never, historyRepository, }); return { manager, fetchHostById, withSshConnection, historyRepository }; diff --git a/plugins/remote-desktop/manifest.json b/plugins/remote-desktop/manifest.json index 73efaf10a..c4401a3cd 100644 --- a/plugins/remote-desktop/manifest.json +++ b/plugins/remote-desktop/manifest.json @@ -7,7 +7,7 @@ "name": "Termix" }, "license": "MIT", - "repository": "https://github.com/Termix-SSH/Plugin-RemoteDesktop", + "repository": "https://github.com/Termix-SSH/Termix", "category": "Terminal", "icon": "Monitor", "engine": { @@ -21,7 +21,8 @@ "ssh:connect", "network:serve", "desktop:window", - "ui:surface" + "ui:surface", + "users:impersonate" ], "optionalDependencies": { "session-recording": "^1.0.0", @@ -442,7 +443,8 @@ "labelKey": "settings.host.guacamoleConfig.label", "default": {}, "hidden": true, - "shareRead": "edit" + "shareRead": "edit", + "secretKeys": ["gateway-password"] }, { "key": "enableToolbar", @@ -454,7 +456,8 @@ } ] } - } + }, + "uses": ["session.remoteDisplay", "wakeOnLan.macAddress"] }, "locales": "locales", "platforms": ["linux", "win32", "darwin"] diff --git a/plugins/remote-desktop/package.json b/plugins/remote-desktop/package.json index 3891aa377..2f0604f47 100644 --- a/plugins/remote-desktop/package.json +++ b/plugins/remote-desktop/package.json @@ -6,10 +6,35 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, + "dependencies": { + "guacamole-common-js": "^1.5.0", + "guacamole-lite": "^1.2.0", + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "express": "^5.2.1", + "react": "^19.2.8", + "react-i18next": "^17.0.14", + "sonner": "^2.0.8", + "ssh2": "^1.17.0" + }, + "termix": { + "patches": [ + "guacamole-common-js-flush" + ] } } diff --git a/scripts/patch-guacamole-lite.cjs b/plugins/remote-desktop/patches/guacamole-lite.cjs similarity index 94% rename from scripts/patch-guacamole-lite.cjs rename to plugins/remote-desktop/patches/guacamole-lite.cjs index 28f152f11..598191ab3 100644 --- a/scripts/patch-guacamole-lite.cjs +++ b/plugins/remote-desktop/patches/guacamole-lite.cjs @@ -1,38 +1,31 @@ const fs = require("fs"); const path = require("path"); +// Run by `termix-plugin build` with TERMIX_PATCH_ROOT set to the plugin, so +// the package resolves the way the plugin's bundle will resolve it. +function packageDir(name) { + const root = process.env.TERMIX_PATCH_ROOT || process.cwd(); + // Walked by hand: a package whose exports map hides package.json cannot be + // found with require.resolve. + for (let dir = path.resolve(root); ; dir = path.dirname(dir)) { + const candidate = path.join(dir, "node_modules", name); + if (fs.existsSync(path.join(candidate, "package.json"))) return candidate; + if (path.dirname(dir) === dir) return path.join(root, "node_modules", name); + } +} + const guacdClientPath = path.join( - __dirname, - "..", - "node_modules", - "guacamole-lite", + packageDir("guacamole-lite"), "lib", "GuacdClient.js", ); -const cryptPath = path.join( - __dirname, - "..", - "node_modules", - "guacamole-lite", - "lib", - "Crypt.js", -); +const cryptPath = path.join(packageDir("guacamole-lite"), "lib", "Crypt.js"); const clientConnectionPath = path.join( - __dirname, - "..", - "node_modules", - "guacamole-lite", + packageDir("guacamole-lite"), "lib", "ClientConnection.js", ); -const serverPath = path.join( - __dirname, - "..", - "node_modules", - "guacamole-lite", - "lib", - "Server.js", -); +const serverPath = path.join(packageDir("guacamole-lite"), "lib", "Server.js"); if ( !fs.existsSync(guacdClientPath) || diff --git a/plugins/remote-desktop/src/backend/routes.ts b/plugins/remote-desktop/src/backend/routes.ts index 34907fd9a..9a130a99e 100644 --- a/plugins/remote-desktop/src/backend/routes.ts +++ b/plugins/remote-desktop/src/backend/routes.ts @@ -636,7 +636,8 @@ export function registerRoutes(router: Router, deps: RouteDeps): void { return res.status(400).json({ error: "Missing host" }); } try { - const result = await ctx.desktop.launchNativeRdp({ + const result = await ctx.desktop.launchExternalClient({ + protocol: "rdp", host: body.host, port: Number(body.port) || DEFAULT_PORT.rdp, username: typeof body.username === "string" ? body.username : undefined, diff --git a/plugins/remote-desktop/src/backend/token-service.ts b/plugins/remote-desktop/src/backend/token-service.ts index 27bfd82e2..ea007cc5d 100644 --- a/plugins/remote-desktop/src/backend/token-service.ts +++ b/plugins/remote-desktop/src/backend/token-service.ts @@ -117,17 +117,8 @@ export class GuacamoleTokenService { } } - const jwtSecret = process.env.JWT_SECRET; - if (jwtSecret) { - return crypto - .createHash("sha256") - .update(jwtSecret + "_guacamole") - .digest(); - } - - this.log.warn("No persistent encryption key found, generating random key", { - operation: "guac_key_generation", - }); + // A token lives for seconds between issue and connect, so a fresh key + // per boot is enough and nothing is derived from core's own secrets. return crypto.randomBytes(KEY_LENGTH); } diff --git a/plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx b/plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx index 719b69c7c..3b36e777b 100644 --- a/plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx +++ b/plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx @@ -15,13 +15,13 @@ import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts"; import { buildOriginWsUrl, getBasePath, - guacStateToStage, isElectron, resolveConnectionOrigin, type ConnectionOrigin, type ConnectionStage, } from "@termix/plugin-sdk/ui"; import { errorMessage } from "./host-remote"; +import { guacStateToStage } from "./guacamole-state.ts"; import { isPasteShortcut, pasteTextToRemote } from "./guacamole-clipboard.ts"; import { getGuacamoleDisplaySize } from "./guacamole-display-size.ts"; import { bindPointerInput } from "./guacamole-pointer.ts"; @@ -88,8 +88,6 @@ interface GuacamoleDisplayProps { onZoomChange?: (zoom: number) => void; } -const isDev = import.meta.env.DEV; - export const GuacamoleDisplay = forwardRef< GuacamoleDisplayHandle, GuacamoleDisplayProps diff --git a/src/types/guacamole-common-js.d.ts b/plugins/remote-desktop/src/frontend/guacamole-common-js.d.ts similarity index 100% rename from src/types/guacamole-common-js.d.ts rename to plugins/remote-desktop/src/frontend/guacamole-common-js.d.ts diff --git a/plugins/remote-desktop/src/frontend/guacamole-state.ts b/plugins/remote-desktop/src/frontend/guacamole-state.ts new file mode 100644 index 000000000..bf8efd923 --- /dev/null +++ b/plugins/remote-desktop/src/frontend/guacamole-state.ts @@ -0,0 +1,26 @@ +import type { ConnectionStage } from "@termix/plugin-sdk/ui"; + +// Guacamole client states, per guacamole-common-js Guacamole.Client#STATE_*. +const GUAC_STATE_IDLE = 0; +const GUAC_STATE_CONNECTING = 1; +const GUAC_STATE_WAITING = 2; +const GUAC_STATE_CONNECTED = 3; +const GUAC_STATE_DISCONNECTING = 4; +const GUAC_STATE_DISCONNECTED = 5; + +export function guacStateToStage(state: number): ConnectionStage { + switch (state) { + case GUAC_STATE_CONNECTING: + return "guac_connecting"; + case GUAC_STATE_WAITING: + return "guac_handshake"; + case GUAC_STATE_CONNECTED: + return "guac_ready"; + case GUAC_STATE_DISCONNECTING: + case GUAC_STATE_DISCONNECTED: + return "guac_disconnected"; + case GUAC_STATE_IDLE: + default: + return "guac_connecting"; + } +} diff --git a/plugins/remote-desktop/tests/backend/macos-vnc-proxy.test.ts b/plugins/remote-desktop/tests/backend/macos-vnc-proxy.test.ts index b38ba2071..6e097a6f0 100644 --- a/plugins/remote-desktop/tests/backend/macos-vnc-proxy.test.ts +++ b/plugins/remote-desktop/tests/backend/macos-vnc-proxy.test.ts @@ -43,7 +43,7 @@ describe("createMacosVncCompatibilityProxy", () => { socket.write(Buffer.from([5, 30, 33])); socket.write(Buffer.from([36, 2, 35])); socket.once("data", (selection) => { - selectedSecurityType = selection[0]; + selectedSecurityType = Number(selection[0]); socket.write("desktop-data"); }); }); diff --git a/plugins/remote-desktop/tests/backend/routes.test.ts b/plugins/remote-desktop/tests/backend/routes.test.ts index cb33bd5cb..2a5112f0c 100644 --- a/plugins/remote-desktop/tests/backend/routes.test.ts +++ b/plugins/remote-desktop/tests/backend/routes.test.ts @@ -17,8 +17,8 @@ import { GuacamoleTokenService } from "../../src/backend/token-service.js"; const manifest = manifestJson as unknown as PluginManifest; -// The token key comes from JWT_SECRET; without one each service picks its own. -process.env.JWT_SECRET = "remote-desktop-test-secret"; +// Without a fixed key each token service picks its own. +process.env.GUACAMOLE_ENCRYPTION_KEY = "ab".repeat(32); function target( overrides: Partial = {}, diff --git a/plugins/remote-desktop/tests/backend/token-security.test.ts b/plugins/remote-desktop/tests/backend/token-security.test.ts index b19cafdba..75e86bde2 100644 --- a/plugins/remote-desktop/tests/backend/token-security.test.ts +++ b/plugins/remote-desktop/tests/backend/token-security.test.ts @@ -10,7 +10,7 @@ import { isServerOwnedSetting, } from "../../src/backend/token-service.js"; -process.env.JWT_SECRET = "remote-desktop-test-secret"; +process.env.GUACAMOLE_ENCRYPTION_KEY = "ab".repeat(32); function decode(token: string) { return JSON.parse(Buffer.from(token, "base64").toString("utf8")) as { diff --git a/plugins/remote-desktop/tests/backend/token-service.test.ts b/plugins/remote-desktop/tests/backend/token-service.test.ts index 0a0e0c1fb..ffb6d3511 100644 --- a/plugins/remote-desktop/tests/backend/token-service.test.ts +++ b/plugins/remote-desktop/tests/backend/token-service.test.ts @@ -59,8 +59,10 @@ describe("GuacamoleTokenService", () => { const termixMeta = { termixConnectId: "connect-1", hostId: 7, + hostName: "windows", ownerUserId: "user-1", protocol: "rdp" as const, + tabInstanceId: null, }; const token = tokenService.createRdpToken( "windows.example.test", diff --git a/plugins/secret-sources/manifest.json b/plugins/secret-sources/manifest.json index 05f55ea4f..0ed2eef67 100644 --- a/plugins/secret-sources/manifest.json +++ b/plugins/secret-sources/manifest.json @@ -20,7 +20,8 @@ "network:outbound", "network:serve", "auth:provide", - "ui:surface" + "ui:surface", + "users:impersonate" ], "contributes": { "auth": { diff --git a/plugins/secret-sources/package.json b/plugins/secret-sources/package.json index 2b4548984..11dfff5d7 100644 --- a/plugins/secret-sources/package.json +++ b/plugins/secret-sources/package.json @@ -6,10 +6,28 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, + "dependencies": { + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8" } } diff --git a/plugins/serial/manifest.json b/plugins/serial/manifest.json index c3319d320..bca945f96 100644 --- a/plugins/serial/manifest.json +++ b/plugins/serial/manifest.json @@ -33,7 +33,8 @@ "titleKey": "nav.serial", "icon": "Usb" } - ] + ], + "uses": ["terminal.resolveTheme"] }, "desktop": "local" } diff --git a/plugins/serial/package.json b/plugins/serial/package.json index 8b98f203e..314fec69f 100644 --- a/plugins/serial/package.json +++ b/plugins/serial/package.json @@ -6,13 +6,39 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, "dependencies": { + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", + "lucide-react": "^1.33.0", + "react-xtermjs": "^1.0.10", "serialport": "^13.0.0" }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "react": "^19.2.8", + "ws": "^8.21.3" + }, + "termix": { + "patches": [ + "xterm-android-ime" + ] + }, + "overrides": { + "react-xtermjs": { + "@xterm/xterm": "$@xterm/xterm" + } } } diff --git a/plugins/serial/src/frontend/index.tsx b/plugins/serial/src/frontend/index.tsx index 16f8cfe63..fa4daf74c 100644 --- a/plugins/serial/src/frontend/index.tsx +++ b/plugins/serial/src/frontend/index.tsx @@ -1,3 +1,5 @@ +// The terminal surfaces need xterm's own stylesheet. +import "@xterm/xterm/css/xterm.css"; import type { Ref } from "react"; import { Usb } from "lucide-react"; import type { diff --git a/plugins/session-recording/manifest.json b/plugins/session-recording/manifest.json index 1d21574e9..bf19a807f 100644 --- a/plugins/session-recording/manifest.json +++ b/plugins/session-recording/manifest.json @@ -20,7 +20,8 @@ "files:own", "network:serve", "ui:surface", - "events:core" + "events:core", + "db:core-refs" ], "provides": [ { @@ -71,7 +72,8 @@ "key": "enableSessionRecording", "labelKey": "hosts.enableSessionRecording", "editorTab": "general" - } + }, + "uses": ["terminal.toolbarStatus"] }, "locales": "locales", "platforms": ["linux", "win32", "darwin"] diff --git a/plugins/session-recording/package.json b/plugins/session-recording/package.json index 9ce8dfeef..ce4fb7e10 100644 --- a/plugins/session-recording/package.json +++ b/plugins/session-recording/package.json @@ -6,10 +6,34 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, + "dependencies": { + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", + "guacamole-common-js": "^1.5.0", + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8" + }, + "termix": { + "patches": [ + "xterm-android-ime", + "guacamole-common-js-flush" + ] } } diff --git a/plugins/session-recording/tests/backend/repository.test.ts b/plugins/session-recording/tests/backend/repository.test.ts index c825cd50e..59a0cc435 100644 --- a/plugins/session-recording/tests/backend/repository.test.ts +++ b/plugins/session-recording/tests/backend/repository.test.ts @@ -50,7 +50,11 @@ async function setup() { .run("user-1", "alice"); db.sqlite.prepare("INSERT INTO ssh_data (id) VALUES (?)").run(7); const table = await db.database.define(sessionRecordings); - return createSessionRecordingRepository(db.database, table, fakeHosts); + return createSessionRecordingRepository( + db.database, + table, + fakeHosts as never, + ); } describe("session recording repository", () => { diff --git a/plugins/session-recording/tests/backend/service.test.ts b/plugins/session-recording/tests/backend/service.test.ts index f76a9087b..5f3b03004 100644 --- a/plugins/session-recording/tests/backend/service.test.ts +++ b/plugins/session-recording/tests/backend/service.test.ts @@ -59,7 +59,11 @@ async function setup(): Promise<{ trackSession: () => () => {}, recordActivity: async () => {}, }; - const repo = createSessionRecordingRepository(db.database, table, hosts); + const repo = createSessionRecordingRepository( + db.database, + table, + hosts as never, + ); const mock = createMockCtx({ pluginId: manifest.id, diff --git a/plugins/session-sharing/locales/en.json b/plugins/session-sharing/locales/en.json index 540573925..462266bb6 100644 --- a/plugins/session-sharing/locales/en.json +++ b/plugins/session-sharing/locales/en.json @@ -168,5 +168,11 @@ "custom": "Custom" } } + }, + "connections": { + "sectionSharedWithMe": "Shared with me", + "sharedBy": "Shared by {{username}}", + "join": "Join", + "sharedSessionLabel": "{{hostName}} (shared)" } } diff --git a/plugins/session-sharing/manifest.json b/plugins/session-sharing/manifest.json index 231c57a4d..226051a90 100644 --- a/plugins/session-sharing/manifest.json +++ b/plugins/session-sharing/manifest.json @@ -14,7 +14,14 @@ "termix": ">=2.9.0", "api": "1" }, - "capabilities": ["hosts:read", "db:own", "network:serve", "ui:surface"], + "capabilities": [ + "hosts:read", + "db:own", + "network:serve", + "ui:surface", + "users:impersonate", + "db:core-refs" + ], "dependencies": { "ssh-terminal": "^1.0.0" }, @@ -96,7 +103,8 @@ } ] } - } + }, + "uses": ["session.remoteDisplay.token", "terminal.open"] }, "locales": "locales", "platforms": ["linux", "win32", "darwin"] diff --git a/plugins/session-sharing/package.json b/plugins/session-sharing/package.json index d3aab6b3e..8f6971a04 100644 --- a/plugins/session-sharing/package.json +++ b/plugins/session-sharing/package.json @@ -6,16 +6,43 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, "dependencies": { "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", + "lucide-react": "^1.33.0", "react-xtermjs": "^1.0.10", "redis": "^6.2.1" }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8", + "ws": "^8.21.3" + }, + "termix": { + "patches": [ + "xterm-android-ime" + ] + }, + "overrides": { + "react-xtermjs": { + "@xterm/xterm": "$@xterm/xterm" + } } } diff --git a/plugins/session-sharing/src/backend/services.ts b/plugins/session-sharing/src/backend/services.ts index 6d379404c..ed3ad333b 100644 --- a/plugins/session-sharing/src/backend/services.ts +++ b/plugins/session-sharing/src/backend/services.ts @@ -38,7 +38,7 @@ export interface SessionGuestsV1 { recordJoin: (shareId: string) => Promise; } -export const SESSION_GUESTS_KEY = "sessions.sharing.guests"; +export const SESSION_GUESTS_KEY = "session-sharing.guests"; function toRef(share: ShareRecord): SharedSessionRef { return { diff --git a/plugins/session-sharing/src/frontend/SharedWithMeSection.tsx b/plugins/session-sharing/src/frontend/SharedWithMeSection.tsx new file mode 100644 index 000000000..d04b63d8f --- /dev/null +++ b/plugins/session-sharing/src/frontend/SharedWithMeSection.tsx @@ -0,0 +1,160 @@ +import { useCallback, useEffect, useState } from "react"; +import { SquareTerminal } from "lucide-react"; +import { + invokeAction, + useHosts, + usePermission, + useTranslation, + type PluginHostRecord, +} from "@termix/plugin-sdk/frontend"; +import { Badge, Button } from "@termix/plugin-sdk/ui"; +import { getSharedWithMe } from "./api"; + +const POLL_MS = 15_000; + +interface SharedSession { + sessionId: string; + hostId: number; + hostName: string; + isConnected: boolean; + sharedByUsername: string | null; + permissionLevel: string | null; + shareId: string | null; +} + +function isSharedSession(value: unknown): value is SharedSession { + const row = value as Partial | null; + return ( + !!row && + typeof row.sessionId === "string" && + typeof row.hostName === "string" + ); +} + +/** + * The active connections list's "Shared with me" section: live sessions other + * users shared with this one, each joinable in a terminal tab. + */ +export function SharedWithMeSection({ + search = "", + openTabData = [], +}: { + search?: string; + openTabData?: Array>; +}) { + const { t } = useTranslation(); + const { hosts } = useHosts(); + const allowed = usePermission("use"); + const [sessions, setSessions] = useState([]); + + const load = useCallback(() => { + if (!allowed) return; + getSharedWithMe() + .then((rows) => setSessions(rows.filter(isSharedSession))) + .catch(() => setSessions([])); + }, [allowed]); + + useEffect(() => { + load(); + const timer = window.setInterval(load, POLL_MS); + return () => window.clearInterval(timer); + }, [load]); + + const joined = new Set( + openTabData + .map((data) => data.joinSharedSessionId) + .filter((id): id is string => typeof id === "string"), + ); + const q = search.trim().toLowerCase(); + const visible = sessions.filter( + (session) => + !joined.has(session.sessionId) && + (!q || + session.hostName.toLowerCase().includes(q) || + (session.sharedByUsername ?? "").toLowerCase().includes(q)), + ); + if (!allowed || visible.length === 0) return null; + + const join = (session: SharedSession) => { + if (!session.shareId) return; + const host: PluginHostRecord = hosts.find( + (candidate) => String(candidate.id) === String(session.hostId), + ) ?? { + id: String(session.hostId), + name: session.hostName, + ip: "", + port: 0, + username: "", + }; + void invokeAction("terminal.open", host, { + joinSharedSessionId: session.sessionId, + joinShareId: session.shareId, + label: t("connections.sharedSessionLabel", { + hostName: session.hostName, + }), + }); + }; + + return ( +
+
+ + {t("connections.sectionSharedWithMe")} + + + {visible.length} + +
+ {visible.map((session) => { + const readWrite = session.permissionLevel === "read-write"; + return ( +
+
+ +
+
+
+ + + {session.hostName} + + + {readWrite + ? t("sessionSharing.permissionLevel.readWrite") + : t("sessionSharing.permissionLevel.readOnly")} + +
+ + {t("connections.sharedBy", { + username: session.sharedByUsername ?? "?", + })} + +
+ +
+ ); + })} +
+ ); +} diff --git a/plugins/session-sharing/src/frontend/index.tsx b/plugins/session-sharing/src/frontend/index.tsx index b567288a3..da3715229 100644 --- a/plugins/session-sharing/src/frontend/index.tsx +++ b/plugins/session-sharing/src/frontend/index.tsx @@ -1,3 +1,5 @@ +// The terminal surfaces need xterm's own stylesheet. +import "@xterm/xterm/css/xterm.css"; import { useEffect, type ComponentType } from "react"; import { Presentation, Share2 } from "lucide-react"; import { toast } from "sonner"; @@ -9,7 +11,8 @@ import { type TabProps, type TermixApp, } from "@termix/plugin-sdk/frontend"; -import { bindApi, getSharedWithMe, listCollabRooms } from "./api"; +import { bindApi, listCollabRooms } from "./api"; +import { SharedWithMeSection } from "./SharedWithMeSection"; import CollabGuestView from "./CollabGuestView"; import { CollabPanel } from "./CollabPanel"; import { CollabRoomTab } from "./CollabRoomTab"; @@ -24,7 +27,6 @@ import { REMOTE_DISPLAY_SLOT } from "./shared"; const COLLAB = "collab"; const SHARE_ACTION = "session-sharing.share"; -const SHARED_WITH_ME_ACTION = "sessions.sharedWithMe"; const SEEN_ROOMS_KEY = "termix:collab-rooms-seen"; const INVITE_POLL_MS = 60_000; @@ -237,9 +239,12 @@ export function activate(app: TermixApp): void { >, }); - // Core's active connections list asks for sessions shared with the user. - app.registerAction(SHARED_WITH_ME_ACTION, () => getSharedWithMe(), { - permission: "use", + // The "Shared with me" section of the active connections list. + app.registerSlotContribution("connections.sections", { + actionId: "session-sharing.sharedWithMe", + titleKey: "connections.sectionSharedWithMe", + kind: "component", + component: SharedWithMeSection as ComponentType>, }); } diff --git a/plugins/session-sharing/tests/backend/helpers.ts b/plugins/session-sharing/tests/backend/helpers.ts index 711987252..85f7c88b8 100644 --- a/plugins/session-sharing/tests/backend/helpers.ts +++ b/plugins/session-sharing/tests/backend/helpers.ts @@ -141,7 +141,7 @@ export async function startServer( live, sharing: mock.services.get("sessions.sharing") as SessionSharingV1, guests: mock.ctx.registry.consume( - "sessions.sharing.guests", + "session-sharing.guests", )!, async request(method, path, { user = "alice", body: given } = {}) { const body = method === "GET" ? undefined : given; diff --git a/plugins/session-sharing/tests/backend/security.test.ts b/plugins/session-sharing/tests/backend/security.test.ts index e335fb541..be07427b3 100644 --- a/plugins/session-sharing/tests/backend/security.test.ts +++ b/plugins/session-sharing/tests/backend/security.test.ts @@ -416,7 +416,9 @@ describe("room guest links", () => { roomGuestToken: "guess", clientIp: "9.9.9.9", }); - if (!result.ok && result.reason === "Too many requests") refused = true; + if (result.ok === false && result.reason === "Too many requests") { + refused = true; + } } expect(refused).toBe(true); }); diff --git a/plugins/session-sharing/tests/frontend/SharedWithMeSection.test.tsx b/plugins/session-sharing/tests/frontend/SharedWithMeSection.test.tsx new file mode 100644 index 000000000..051730e6e --- /dev/null +++ b/plugins/session-sharing/tests/frontend/SharedWithMeSection.test.tsx @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; + +const api = vi.hoisted(() => ({ + getSharedWithMe: vi.fn(async () => [] as unknown[]), +})); +const sdk = vi.hoisted(() => ({ + invokeAction: vi.fn(async () => undefined), + allowed: true, +})); + +vi.mock("../../src/frontend/api", () => api); +vi.mock("@termix/plugin-sdk/frontend", async (importActual) => ({ + ...(await importActual()), + invokeAction: sdk.invokeAction, + usePermission: () => sdk.allowed, + useHosts: () => ({ hosts: [], loaded: true }), + useTranslation: () => ({ + t: (key: string, options?: Record) => + options ? `${key}:${JSON.stringify(options)}` : key, + language: "en", + }), +})); + +import { SharedWithMeSection } from "../../src/frontend/SharedWithMeSection"; + +function shared(overrides: Record = {}) { + return { + sessionId: "sess-1", + hostId: 5, + hostName: "prod-db", + isConnected: true, + sharedByUsername: "alice", + permissionLevel: "read-only", + shareId: "share-1", + ...overrides, + }; +} + +beforeEach(() => { + api.getSharedWithMe.mockReset(); + sdk.invokeAction.mockClear(); + sdk.allowed = true; +}); + +afterEach(() => cleanup()); + +describe("shared with me section", () => { + it("lists sessions shared with the user", async () => { + api.getSharedWithMe.mockResolvedValue([shared()]); + render(); + + expect(await screen.findByText("prod-db")).toBeTruthy(); + expect( + screen.getByText('connections.sharedBy:{"username":"alice"}'), + ).toBeTruthy(); + expect( + screen.getByText("sessionSharing.permissionLevel.readOnly"), + ).toBeTruthy(); + }); + + it("hides a session the user already joined in a tab", async () => { + api.getSharedWithMe.mockResolvedValue([shared()]); + render( + , + ); + await vi.waitFor(() => expect(api.getSharedWithMe).toHaveBeenCalled()); + expect(screen.queryByText("prod-db")).toBeNull(); + }); + + it("filters by the connections search", async () => { + api.getSharedWithMe.mockResolvedValue([ + shared(), + shared({ sessionId: "sess-2", hostName: "web", sharedByUsername: "bob" }), + ]); + render(); + expect(await screen.findByText("web")).toBeTruthy(); + expect(screen.queryByText("prod-db")).toBeNull(); + }); + + it("joins through the terminal's open action", async () => { + api.getSharedWithMe.mockResolvedValue([shared()]); + render(); + fireEvent.click(await screen.findByText("connections.join")); + + expect(sdk.invokeAction).toHaveBeenCalledWith( + "terminal.open", + expect.objectContaining({ id: "5", name: "prod-db" }), + expect.objectContaining({ + joinSharedSessionId: "sess-1", + joinShareId: "share-1", + }), + ); + }); + + it("renders nothing for a user without the use permission", async () => { + sdk.allowed = false; + api.getSharedWithMe.mockResolvedValue([shared()]); + const { container } = render(); + expect(container.textContent).toBe(""); + expect(api.getSharedWithMe).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/session-sharing/tests/frontend/activate.test.tsx b/plugins/session-sharing/tests/frontend/activate.test.tsx index 948fc3a46..c5cd8df5a 100644 --- a/plugins/session-sharing/tests/frontend/activate.test.tsx +++ b/plugins/session-sharing/tests/frontend/activate.test.tsx @@ -69,7 +69,9 @@ describe("session-sharing activate", () => { expect(rendered.registered.slot("shell.overlay")).toHaveLength(2); expect(rendered.registered.actions().sort()).toEqual([ "session-sharing.share", - "sessions.sharedWithMe", + ]); + expect(rendered.registered.slot("connections.sections")).toEqual([ + "session-sharing.sharedWithMe", ]); }); @@ -167,20 +169,6 @@ describe("session-sharing activate", () => { ); }); - it("answers sessions shared with the user through the api", async () => { - const shared = [{ sessionId: "s", isOwnSession: false }]; - rendered = await renderWithApp(plugin, { - manifest, - locales, - api: stubApi({ - get: vi.fn(async () => ({ data: shared })) as never, - }), - }); - await expect(invokeAction("sessions.sharedWithMe")).resolves.toEqual( - shared, - ); - }); - it("removes everything it registered on deactivate", async () => { const app = await renderWithApp(plugin, { manifest, diff --git a/plugins/snippets/manifest.json b/plugins/snippets/manifest.json index 17a0e2a05..5c4d42978 100644 --- a/plugins/snippets/manifest.json +++ b/plugins/snippets/manifest.json @@ -21,7 +21,8 @@ "credentials:use", "hosts:write", "ui:surface", - "events:core" + "events:core", + "db:core-refs" ], "provides": [ { @@ -140,6 +141,11 @@ ], "http": { "adminImpersonation": true - } + }, + "uses": [ + "host-metrics.toolbar", + "terminal.sendToActive", + "terminal.sendToSession" + ] } } diff --git a/plugins/snippets/package.json b/plugins/snippets/package.json index fd2493cb3..57452fa3d 100644 --- a/plugins/snippets/package.json +++ b/plugins/snippets/package.json @@ -6,10 +6,29 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, + "dependencies": { + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8", + "ssh2": "^1.17.0" } } diff --git a/plugins/snippets/tests/backend/activate.test.ts b/plugins/snippets/tests/backend/activate.test.ts index d0290f64a..1562280fc 100644 --- a/plugins/snippets/tests/backend/activate.test.ts +++ b/plugins/snippets/tests/backend/activate.test.ts @@ -45,12 +45,12 @@ describe("snippets activate", () => { const service = server.mock.services.get("snippets.access") as { list: () => Promise; }; - const listed = await server.mock.ctx.asUser("user-1", () => service.list()); + const listed = await server.mock.actAs("user-1", () => service.list()); expect(listed).toEqual([ expect.objectContaining({ name: "Deploy", content: "echo deploy" }), ]); - const otherUsersList = await server.mock.ctx.asUser("user-2", () => + const otherUsersList = await server.mock.actAs("user-2", () => service.list(), ); expect(otherUsersList).toEqual([]); @@ -64,7 +64,7 @@ describe("snippets activate", () => { remove: (id: number) => Promise; }; const asUser = (fn: () => Promise) => - server!.mock.ctx.asUser("user-1", fn); + server!.mock.actAs("user-1", fn); await expect( asUser(() => service.create({ name: "x", content: "ls" })), diff --git a/plugins/ssh-terminal/locales/en.json b/plugins/ssh-terminal/locales/en.json index 1130f99aa..8a7bd3d57 100644 --- a/plugins/ssh-terminal/locales/en.json +++ b/plugins/ssh-terminal/locales/en.json @@ -513,7 +513,39 @@ "useSSHTitleDesc": "Update the tab title from the shell's window title instead of the host name", "useSSHTitleLabel": "Use SSH Window Title", "useUserDefaults": "Use user defaults", - "useUserTerminalDefaultsDesc": "Keep this host in step with the terminal defaults in your profile." + "useUserTerminalDefaultsDesc": "Keep this host in step with the terminal defaults in your profile.", + "enableTunnel": "Enable Tunnel", + "enableFileManager": "Enable File Manager", + "enableDocker": "Enable Docker", + "startupSnippet": "Startup Snippet", + "quickActions": "Quick Actions", + "proxmoxLastSyncStatus": { + "success": "Success" + }, + "proxmoxResultSkippedNoIp": "{{count}} skipped (no IP found)", + "proxmoxStatsUrlCopied": "Proxmox Stats URL copied to clipboard", + "rdpUrlCopied": "RDP URL copied", + "vncUrlCopied": "VNC URL copied", + "filterAuthStepca": "Step CA", + "guac": { + "noCredential": "No credential (direct credentials below)", + "authTypeDirect": "Direct", + "authTypeCredential": "Credential", + "authTypeNone": "None", + "recordingPath": "Recording Path", + "recordingName": "Recording Name", + "saveHostFirst": "Save the host first.", + "sharingOptionsAfterSave": "Sharing options are available after the host has been saved.", + "permissionLevel": "Permission Level", + "typeHeader": "Type", + "targetHeader": "Target", + "permissionHeader": "Permission", + "cancelBtn": "Cancel", + "savingBtn": "Saving...", + "updateHostBtn": "Update Host", + "addHostBtn": "Add Host" + }, + "filterAuthVault": "Vault" }, "nav": { "history": "History", diff --git a/plugins/ssh-terminal/manifest.json b/plugins/ssh-terminal/manifest.json index d66b2dbb0..28915beb8 100644 --- a/plugins/ssh-terminal/manifest.json +++ b/plugins/ssh-terminal/manifest.json @@ -797,7 +797,19 @@ } } } - ] + ], + "uiPresets": { + "simple": { + "toolbarDensity": "icon" + }, + "balanced": { + "toolbarDensity": "labeled" + }, + "advanced": { + "toolbarDensity": "expanded" + } + }, + "uses": ["files.openEditor", "files.openHost", "snippets.startupCommand"] }, "locales": "locales", "platforms": ["linux", "win32", "darwin"] diff --git a/plugins/ssh-terminal/package.json b/plugins/ssh-terminal/package.json index f79e483db..2064cb0b4 100644 --- a/plugins/ssh-terminal/package.json +++ b/plugins/ssh-terminal/package.json @@ -6,10 +6,52 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, + "dependencies": { + "@xterm/addon-clipboard": "^0.2.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-unicode11": "^0.9.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", + "lucide-react": "^1.33.0", + "react-xtermjs": "^1.0.10" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "multer": "^2.3.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "sharp": "^0.35.4", + "sonner": "^2.0.8", + "ssh2": "^1.17.0", + "ws": "^8.21.3" + }, + "termix": { + "patches": [ + "xterm-android-ime" + ] + }, + "overrides": { + "react-xtermjs": { + "@xterm/xterm": "$@xterm/xterm" + } } } diff --git a/plugins/ssh-terminal/src/backend/services.ts b/plugins/ssh-terminal/src/backend/services.ts index 16b82c94b..f65e3cf3b 100644 --- a/plugins/ssh-terminal/src/backend/services.ts +++ b/plugins/ssh-terminal/src/backend/services.ts @@ -116,7 +116,7 @@ export interface SessionGuestsV1 { recordJoin: (shareId: string) => Promise; } -export const SESSION_GUESTS_KEY = "sessions.sharing.guests"; +export const SESSION_GUESTS_KEY = "session-sharing.guests"; export interface RecordingSink { /** Appends one batch; the first batch starts with the asciicast header. */ diff --git a/plugins/ssh-terminal/src/frontend/index.tsx b/plugins/ssh-terminal/src/frontend/index.tsx index 598694bc6..ca19fb7ed 100644 --- a/plugins/ssh-terminal/src/frontend/index.tsx +++ b/plugins/ssh-terminal/src/frontend/index.tsx @@ -1,3 +1,5 @@ +// The terminal surfaces need xterm's own stylesheet. +import "@xterm/xterm/css/xterm.css"; import { lazy, Suspense, type ComponentType } from "react"; import { Braces, diff --git a/plugins/ssh-terminal/src/frontend/look/theme-preview.ts b/plugins/ssh-terminal/src/frontend/look/theme-preview.ts new file mode 100644 index 000000000..72c1e2e05 --- /dev/null +++ b/plugins/ssh-terminal/src/frontend/look/theme-preview.ts @@ -0,0 +1,30 @@ +import { useSyncExternalStore } from "react"; + +/** + * The terminal theme the host editor is previewing, so an open terminal for + * that host can show it before the host is saved. Null when nothing is being + * previewed. + */ +let preview: string | null = null; +const listeners = new Set<() => void>(); + +export function setThemePreview(theme: string | null): void { + if (theme === preview) return; + preview = theme; + for (const listener of listeners) listener(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function useThemePreview(): string | null { + return useSyncExternalStore( + subscribe, + () => preview, + () => preview, + ); +} diff --git a/plugins/ssh-terminal/src/frontend/settings/HostTerminalSettings.tsx b/plugins/ssh-terminal/src/frontend/settings/HostTerminalSettings.tsx index 6f5a20b11..6a200c949 100644 --- a/plugins/ssh-terminal/src/frontend/settings/HostTerminalSettings.tsx +++ b/plugins/ssh-terminal/src/frontend/settings/HostTerminalSettings.tsx @@ -1,4 +1,5 @@ -import { useMemo, useState } from "react"; +import { useMemo, useState, useEffect } from "react"; +import { setThemePreview } from "../look/theme-preview"; import { Info, Palette, X, Zap } from "lucide-react"; import { toast } from "sonner"; import { @@ -13,7 +14,6 @@ import { TooltipContent, TooltipProvider, TooltipTrigger, - useTabsSafe, } from "@termix/plugin-sdk/ui"; import { useSettings, @@ -69,7 +69,8 @@ export function HostTerminalSettings({ const { t } = useTranslation(); const client = useTerminalClientSettings(); const userSettings = useSettings("user"); - const { setPreviewTerminalTheme } = useTabsSafe(); + // The preview ends when the editor section goes away. + useEffect(() => () => setThemePreview(null), []); const stored = ((editorForm?.pluginSettings as HostPluginSettings)?.[ PLUGIN_ID @@ -222,7 +223,7 @@ export function HostTerminalSettings({ onChange={(e) => { const newTheme = e.target.value; setField("theme", newTheme); - setPreviewTerminalTheme(newTheme); + setThemePreview(newTheme); if (newTheme === "custom" && !form.customThemeColors) { setField("customThemeColors", { ...TERMINAL_THEMES.termixDark.colors, diff --git a/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx b/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx index f1f1e3efb..ef3e38f53 100644 --- a/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx +++ b/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx @@ -119,6 +119,7 @@ import { isElectron, } from "@termix/plugin-sdk/ui"; import { + notifyHostsChanged, useTranslation, invokeAction, usePluginApi, @@ -1558,9 +1559,7 @@ const TerminalInner = forwardRef( onClick: () => { void enableHostAutoTmux(api, hostConfig.id as number) .then(() => { - window.dispatchEvent( - new CustomEvent("termix:hosts-changed"), - ); + notifyHostsChanged(); toast.success( t("terminal.autoTmuxEnabled", { host: hostLabel }), ); diff --git a/plugins/ssh-terminal/src/frontend/terminal/TerminalTabContent.tsx b/plugins/ssh-terminal/src/frontend/terminal/TerminalTabContent.tsx index c51139a4a..33bc7c2c7 100644 --- a/plugins/ssh-terminal/src/frontend/terminal/TerminalTabContent.tsx +++ b/plugins/ssh-terminal/src/frontend/terminal/TerminalTabContent.tsx @@ -1,7 +1,8 @@ import { lazy, Suspense } from "react"; +import { useThemePreview } from "../look/theme-preview"; import { TerminalSquare } from "lucide-react"; import type { TerminalHandle, TerminalHostConfig } from "./Terminal"; -import { useIsMobile, useTabsSafe } from "@termix/plugin-sdk/ui"; +import { useIsMobile } from "@termix/plugin-sdk/ui"; import { useTranslation, invokeAction, @@ -63,7 +64,7 @@ export function TerminalTabContent({ const tab = tabRecord as unknown as TerminalTabRecord; const host = hostRecord as unknown as Host | undefined; const { t } = useTranslation(); - const { previewTerminalTheme } = useTabsSafe(); + const previewTerminalTheme = useThemePreview(); const isMobile = useIsMobile(); const useSshTitle = readHostTerminalSettings( hostTerminalValues(host), diff --git a/plugins/ssh-terminal/src/frontend/terminal/TerminalToolbar.tsx b/plugins/ssh-terminal/src/frontend/terminal/TerminalToolbar.tsx index 790eebcf4..fd78055e6 100644 --- a/plugins/ssh-terminal/src/frontend/terminal/TerminalToolbar.tsx +++ b/plugins/ssh-terminal/src/frontend/terminal/TerminalToolbar.tsx @@ -48,6 +48,7 @@ import { useIsMobile, } from "@termix/plugin-sdk/ui"; import { + usePluginUiPreferences, useHostActions, useTabs, useTranslation, @@ -57,8 +58,6 @@ import { } from "@termix/plugin-sdk/frontend"; type SelectedToolbarDensity = ToolbarDensity; -const DENSITY_STORAGE_KEY = "termix-terminal-toolbar-density"; - /** Plugins contribute toolbar buttons here. Declared by the ssh-terminal plugin. */ const TOOLBAR_SLOT_ID = TERMINAL_TOOLBAR_SLOT; @@ -69,16 +68,12 @@ const DENSITY_OPTIONS: { value: ToolbarDensity }[] = [ ]; const DENSITIES = DENSITY_OPTIONS.map((option) => option.value); -function readStoredDensity(): ToolbarDensity { - if (typeof window === "undefined") return "labeled"; - try { - const stored = window.localStorage.getItem(DENSITY_STORAGE_KEY); - return DENSITIES.includes(stored as ToolbarDensity) - ? (stored as ToolbarDensity) - : "labeled"; - } catch { - return "labeled"; - } +const LEGACY_DENSITY_KEY = "termix-terminal-toolbar-density"; + +function asDensity(value: unknown): ToolbarDensity { + return DENSITIES.includes(value as ToolbarDensity) + ? (value as ToolbarDensity) + : "labeled"; } const CONTROL = @@ -151,8 +146,28 @@ export const TerminalToolbar: React.FC = ({ const settings = useMemo(() => readToolbarSettings(host), [host]); const { anchor } = settings; const rememberDensity = settings.density === null; + // The remembered density is this plugin's UI preference, so an interface + // preset sets it too. + const { values: uiPrefs, set: setUiPref } = usePluginUiPreferences<{ + toolbarDensity: ToolbarDensity; + }>(); + const preferredDensity = asDensity(uiPrefs.toolbarDensity); + useEffect(() => { + // Before 2.9 the density lived in this browser's localStorage. + try { + const legacy = window.localStorage.getItem(LEGACY_DENSITY_KEY); + if (legacy === null) return; + window.localStorage.removeItem(LEGACY_DENSITY_KEY); + if (DENSITIES.includes(legacy as ToolbarDensity)) { + setUiPref("toolbarDensity", legacy as ToolbarDensity); + } + } catch { + // Storage can be unavailable in hardened browser contexts. + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const [density, setDensity] = useState( - () => settings.density ?? readStoredDensity(), + () => settings.density ?? preferredDensity, ); const [responsiveDensity, setResponsiveDensity] = useState(() => density); @@ -217,26 +232,12 @@ export const TerminalToolbar: React.FC = ({ setPosition(next); }, [anchor]); + // A preset (or another tab) changing the saved density applies here too. useEffect(() => { if (!rememberDensity) return; if (desktopViewportReady !== true || isMobile !== false) return; - try { - window.localStorage.setItem(DENSITY_STORAGE_KEY, density); - } catch { - // Storage can be unavailable in hardened browser contexts. - } - }, [density, desktopViewportReady, isMobile, rememberDensity]); - - // Picking an interface preset seeds this key, so pick the new value up - // without needing the tab to remount. - useEffect(() => { - if (!rememberDensity) return; - if (desktopViewportReady !== true || isMobile !== false) return; - const handler = () => setDensity(readStoredDensity()); - window.addEventListener("terminalToolbarDensityChanged", handler); - return () => - window.removeEventListener("terminalToolbarDensityChanged", handler); - }, [desktopViewportReady, isMobile, rememberDensity]); + setDensity(preferredDensity); + }, [preferredDensity, desktopViewportReady, isMobile, rememberDensity]); useEffect(() => { if (desktopViewportReady !== true || isMobile !== false) return; const measurement = measurementRef.current; @@ -375,6 +376,7 @@ export const TerminalToolbar: React.FC = ({ const setAndStoreDensity = (next: ToolbarDensity) => { setDensity(next); + if (rememberDensity) setUiPref("toolbarDensity", next); }; const reportImageResult = ( action: () => void | Promise, @@ -692,7 +694,10 @@ export const TerminalToolbar: React.FC = ({ }} > {collapsed ? ( -
+
{expandButton} {grabButton()}
diff --git a/plugins/ssh-terminal/tests/backend/activate.test.ts b/plugins/ssh-terminal/tests/backend/activate.test.ts index dd6ce1ea3..6fe042878 100644 --- a/plugins/ssh-terminal/tests/backend/activate.test.ts +++ b/plugins/ssh-terminal/tests/backend/activate.test.ts @@ -48,9 +48,7 @@ describe("ssh-terminal activate", () => { const history = server.mock.services.get( "terminal.history", ) as TerminalHistoryV1; - const listed = await server.mock.ctx.asUser("user-1", () => - history.list(1, 10), - ); + const listed = await server.mock.actAs("user-1", () => history.list(1, 10)); expect(listed.map((entry) => entry.command)).toEqual(["df -h"]); }); @@ -59,7 +57,7 @@ describe("ssh-terminal activate", () => { const live = server.mock.services.get( "sessions.live#ssh", ) as LiveSessionsV1; - const other = await server.mock.ctx.asUser("user-2", async () => + const other = await server.mock.actAs("user-2", async () => live.listForUser("user-1"), ); expect(other).toEqual([]); diff --git a/plugins/ssh-terminal/tests/backend/images/image-storage.test.ts b/plugins/ssh-terminal/tests/backend/images/image-storage.test.ts index bdcee683d..318459ff4 100644 --- a/plugins/ssh-terminal/tests/backend/images/image-storage.test.ts +++ b/plugins/ssh-terminal/tests/backend/images/image-storage.test.ts @@ -264,7 +264,7 @@ describe("storeImageViaSftp", () => { stream.destroyed = true; }; streams.push(stream); - stream.end = (data: Buffer) => { + (stream as { end: unknown }).end = (data: Buffer) => { if (behavior.stallWrite) return; queueMicrotask(() => { if (behavior.writeError) { diff --git a/plugins/ssh-terminal/tests/backend/memory-agent.test.ts b/plugins/ssh-terminal/tests/backend/memory-agent.test.ts index cda511174..ebf7049af 100644 --- a/plugins/ssh-terminal/tests/backend/memory-agent.test.ts +++ b/plugins/ssh-terminal/tests/backend/memory-agent.test.ts @@ -45,7 +45,7 @@ describe("MemoryAgent", () => { expect((parsed as ParsedKey).verify(data, signature)).toBe(true); client.destroy(); - stream.destroy(); + (stream as unknown as { destroy: () => void }).destroy(); }); }); @@ -98,7 +98,7 @@ describe("MemoryAgent unsupported extension framing", () => { expect(replies[1][0]).toBe(12); // The following identities request succeeds. expect(replies[1].readUInt32BE(1)).toBe(1); } finally { - stream.destroy(); + (stream as unknown as { destroy: () => void }).destroy(); } }, ); diff --git a/plugins/ssh-terminal/tests/frontend/terminal/TerminalTabContent.test.tsx b/plugins/ssh-terminal/tests/frontend/terminal/TerminalTabContent.test.tsx index 3f4d67368..9972e0397 100644 --- a/plugins/ssh-terminal/tests/frontend/terminal/TerminalTabContent.test.tsx +++ b/plugins/ssh-terminal/tests/frontend/terminal/TerminalTabContent.test.tsx @@ -23,7 +23,6 @@ vi.mock( vi.mock("@termix/plugin-sdk/ui", () => ({ useIsMobile: () => false, - useTabsSafe: () => ({ previewTerminalTheme: null }), })); import { TerminalTabContent } from "../../../src/frontend/terminal/TerminalTabContent"; diff --git a/plugins/ssh-terminal/tests/frontend/terminal/TerminalToolbar.test.tsx b/plugins/ssh-terminal/tests/frontend/terminal/TerminalToolbar.test.tsx index d2b120443..91a8fbe76 100644 --- a/plugins/ssh-terminal/tests/frontend/terminal/TerminalToolbar.test.tsx +++ b/plugins/ssh-terminal/tests/frontend/terminal/TerminalToolbar.test.tsx @@ -56,8 +56,22 @@ vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({ slotId === "terminal.toolbar" ? slots.toolbar : [], useHostActions: () => slots.hostActions, useTabs: () => slots.tabs, + usePluginUiPreferences: () => ({ + values: uiPrefs.values, + set: uiPrefs.set, + }), })); +const uiPrefs = vi.hoisted(() => { + const values: Record = {}; + return { + values, + set: vi.fn((key: string, value: unknown) => { + values[key] = value; + }), + }; +}); + vi.mock("@termix/plugin-sdk/ui", async (importOriginal) => ({ ...(await importOriginal>()), useIsMobile: () => mobileApi.isMobile, @@ -111,6 +125,8 @@ function renderToolbar( // jsdom lacks the pointer APIs Radix Select needs to open its popup. beforeEach(() => { + for (const key of Object.keys(uiPrefs.values)) delete uiPrefs.values[key]; + uiPrefs.set.mockClear(); Element.prototype.hasPointerCapture ??= () => false; Element.prototype.setPointerCapture ??= () => {}; Element.prototype.releasePointerCapture ??= () => {}; @@ -406,9 +422,7 @@ describe("TerminalToolbar Phase 1", () => { screen.getByRole("option", { name: "Expanded with metrics" }), ); await waitFor(() => - expect(localStorage.getItem("termix-terminal-toolbar-density")).toBe( - "expanded", - ), + expect(uiPrefs.set).toHaveBeenCalledWith("toolbarDensity", "expanded"), ); }); @@ -561,7 +575,7 @@ describe("TerminalToolbar Phase 1", () => { }); expect(collapsedMover).toBeVisible(); expect( - container.querySelector(".terminal-toolbar-collapsed-shell"), + container.querySelector("[data-toolbar-collapsed]"), ).toBeInTheDocument(); collapsedMover.focus(); expect(toolbar).toHaveClass("focus-within:opacity-100"); @@ -1018,12 +1032,21 @@ describe("TerminalToolbar Phase 1", () => { }); it("uses the host display mode without overwriting the saved one", () => { - localStorage.setItem("termix-terminal-toolbar-density", "labeled"); + uiPrefs.values.toolbarDensity = "labeled"; + uiPrefs.set.mockClear(); renderToolbar({ host: withSettings({ terminalToolbarDisplay: "icon" }) }); expect(screen.queryByText("Upload image")).toBeNull(); - expect(localStorage.getItem("termix-terminal-toolbar-density")).toBe( - "labeled", - ); + expect(uiPrefs.set).not.toHaveBeenCalled(); + }); + + it("moves a density saved in this browser into the plugin preferences", () => { + localStorage.setItem("termix-terminal-toolbar-density", "icon"); + uiPrefs.set.mockClear(); + renderToolbar(); + expect(uiPrefs.set).toHaveBeenCalledWith("toolbarDensity", "icon"); + expect( + localStorage.getItem("termix-terminal-toolbar-density"), + ).toBeNull(); }); it("stays fully visible when fading is off", () => { diff --git a/plugins/sso/locales/en.json b/plugins/sso/locales/en.json index 0cfe5658d..9e2735ede 100644 --- a/plugins/sso/locales/en.json +++ b/plugins/sso/locales/en.json @@ -55,5 +55,11 @@ "adminGroupDesc": "Users in this group are granted admin. Leave empty to disable group sync.", "caCert": "Custom CA Certificate", "caCertDesc": "Optional. PEM-encoded CA certificate for OIDC providers using a private or self-signed CA. Leave empty to use the system trust store." + }, + "settings": { + "silentLoginDefault": { + "label": "Sign in with SSO automatically", + "description": "Send visitors straight to the first SSO provider instead of showing the login form. The OIDC_SILENT_LOGIN_DEFAULT environment variable overrides this." + } } } diff --git a/plugins/sso/manifest.json b/plugins/sso/manifest.json index dbb9a2881..dc0eb7c5d 100644 --- a/plugins/sso/manifest.json +++ b/plugins/sso/manifest.json @@ -41,6 +41,14 @@ "type": "custom", "component": "providers", "permission": "manage" + }, + { + "key": "silentLoginDefault", + "type": "boolean", + "labelKey": "settings.silentLoginDefault.label", + "descriptionKey": "settings.silentLoginDefault.description", + "default": false, + "permission": "manage" } ] }, diff --git a/plugins/sso/package.json b/plugins/sso/package.json index 44a47ca50..d80b5bdd2 100644 --- a/plugins/sso/package.json +++ b/plugins/sso/package.json @@ -6,14 +6,30 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, "dependencies": { "jose": "^6.2.12", + "lucide-react": "^1.33.0", "undici": "^8.10.2" }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8" } } diff --git a/plugins/sso/src/backend/index.ts b/plugins/sso/src/backend/index.ts index 20a1ca322..04c2c45b9 100644 --- a/plugins/sso/src/backend/index.ts +++ b/plugins/sso/src/backend/index.ts @@ -5,6 +5,16 @@ import { createProviderStore } from "./providers.js"; import { createSsoLogin, METHOD_ID } from "./login.js"; import { PUBLIC_PATHS, registerSsoRoutes } from "./routes.js"; +/** + * Whether the login page starts the first provider without a click. The + * OIDC_SILENT_LOGIN_DEFAULT environment variable pins it either way. + */ +export async function silentLoginDefault(ctx: PluginContext): Promise { + const pinned = process.env.OIDC_SILENT_LOGIN_DEFAULT; + if (pinned !== undefined) return pinned.trim().toLowerCase() === "true"; + return (await ctx.settings.get("silentLoginDefault")) === true; +} + export async function activate(ctx: PluginContext) { const table = await ctx.db.define(providers); const store = createProviderStore(ctx, table); @@ -17,11 +27,14 @@ export async function activate(ctx: PluginContext) { icon: "key-round", kind: "redirect", external: true, - describe: async () => - (await store.listInstances()).map((instance) => ({ + describe: async () => { + const autoStart = await silentLoginDefault(ctx); + return (await store.listInstances()).map((instance, index) => ({ ...instance, enabled: true, - })), + ...(autoStart && index === 0 ? { autoStart: true } : {}), + })); + }, start: login.start, callback: login.callback, }); diff --git a/plugins/sso/tests/backend/helpers.ts b/plugins/sso/tests/backend/helpers.ts index 5b9c7fa73..1aca58fd1 100644 --- a/plugins/sso/tests/backend/helpers.ts +++ b/plugins/sso/tests/backend/helpers.ts @@ -57,6 +57,7 @@ export async function startServer( permissions?: string[]; linkedUsers?: Record; before?: (sqlite: TestDb["sqlite"]) => void; + settings?: Record; } = {}, ): Promise { const db = await createTestDb(pluginDir, { before: options.before }); @@ -74,6 +75,7 @@ export async function startServer( permissions: options.permissions, linkedUsers: options.linkedUsers, baseUrl: "https://termix.test", + settings: options.settings, }); const { activate } = await import("../../src/backend/index.js"); diff --git a/plugins/sso/tests/backend/sso.test.ts b/plugins/sso/tests/backend/sso.test.ts index 9e16fa408..4fea1b50b 100644 --- a/plugins/sso/tests/backend/sso.test.ts +++ b/plugins/sso/tests/backend/sso.test.ts @@ -185,6 +185,31 @@ describe("the login method", () => { ]); }); + it("marks the first provider to start on its own with silent sign-in on", async () => { + const s = await startLegacy({ settings: { silentLoginDefault: true } }); + expect(await s.mock.auth.loginMethods[0].describe!()).toEqual([ + { + id: "3", + label: "Keycloak", + type: "oidc", + enabled: true, + autoStart: true, + }, + ]); + }); + + it("lets OIDC_SILENT_LOGIN_DEFAULT override the setting", async () => { + const s = await startLegacy({ settings: { silentLoginDefault: true } }); + vi.stubEnv("OIDC_SILENT_LOGIN_DEFAULT", "false"); + const [instance] = await s.mock.auth.loginMethods[0].describe!(); + expect(instance.autoStart).toBeUndefined(); + vi.stubEnv("OIDC_SILENT_LOGIN_DEFAULT", "true"); + s.mock.settings.clear(); + expect((await s.mock.auth.loginMethods[0].describe!())[0].autoStart).toBe( + true, + ); + }); + it("falls back to the provider configured through the environment", async () => { server = await startServer(); vi.stubEnv("OIDC_CLIENT_ID", "env-client"); diff --git a/plugins/step-ca/manifest.json b/plugins/step-ca/manifest.json index 26c3277e3..26f888767 100644 --- a/plugins/step-ca/manifest.json +++ b/plugins/step-ca/manifest.json @@ -78,7 +78,8 @@ "to": "/callback" } ] - } + }, + "uses": ["terminal.overlay"] }, "locales": "locales", "platforms": ["linux", "win32", "darwin"] diff --git a/plugins/step-ca/package.json b/plugins/step-ca/package.json index 5139c3c69..28828bd1f 100644 --- a/plugins/step-ca/package.json +++ b/plugins/step-ca/package.json @@ -6,13 +6,29 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, "dependencies": { + "lucide-react": "^1.33.0", "redis": "^6.2.1" }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8" } } diff --git a/plugins/tailscale/manifest.json b/plugins/tailscale/manifest.json index 371e801e9..a14dcbf3b 100644 --- a/plugins/tailscale/manifest.json +++ b/plugins/tailscale/manifest.json @@ -73,7 +73,8 @@ "group": "settings.group.devices" } ] - } + }, + "uses": ["host-metrics.managers", "terminal.overlay"] }, "locales": "locales", "platforms": ["linux", "win32", "darwin"] diff --git a/plugins/tailscale/package.json b/plugins/tailscale/package.json index e27a7bb53..dbdf408a6 100644 --- a/plugins/tailscale/package.json +++ b/plugins/tailscale/package.json @@ -6,10 +6,28 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, + "dependencies": { + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8", + "ssh2": "^1.17.0" } } diff --git a/plugins/termix-identity/package.json b/plugins/termix-identity/package.json index 7b28dcc38..525715d19 100644 --- a/plugins/termix-identity/package.json +++ b/plugins/termix-identity/package.json @@ -6,10 +6,29 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, + "dependencies": { + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8", + "ssh2": "^1.17.0" } } diff --git a/plugins/termix-identity/tests/frontend/activate.test.tsx b/plugins/termix-identity/tests/frontend/activate.test.tsx index adcdf09ba..ad8b2a18c 100644 --- a/plugins/termix-identity/tests/frontend/activate.test.tsx +++ b/plugins/termix-identity/tests/frontend/activate.test.tsx @@ -13,10 +13,6 @@ import locales from "../../locales/en.json"; const manifest = manifestJson as unknown as PluginManifest; const linked = vi.hoisted(() => ({ serverUrl: null as string | null })); -vi.mock("@/lib/linked-server", () => ({ - getLinkedSession: async () => - linked.serverUrl ? { serverUrl: linked.serverUrl, token: "t" } : null, -})); let rendered: RenderedPluginApp | null = null; @@ -76,7 +72,11 @@ function stubApi(options: { identity?: boolean; linked?: number[] } = {}) { describe(`${manifest.id} activate`, () => { it("registers the rail item, panel, tab and credential badge", async () => { - rendered = await renderWithApp(plugin, { manifest, locales }); + rendered = await renderWithApp(plugin, { + manifest, + locales, + remoteServerUrl: () => linked.serverUrl, + }); expect(rendered.registered.railItems()).toEqual([ expect.objectContaining({ id: "termix-id", @@ -106,7 +106,11 @@ describe(`${manifest.id} activate`, () => { isElectron: true, invoke: vi.fn(async () => null), }; - rendered = await renderWithApp(plugin, { manifest, locales }); + rendered = await renderWithApp(plugin, { + manifest, + locales, + remoteServerUrl: () => linked.serverUrl, + }); await waitFor(() => expect(rendered!.registered.railItems()).toEqual([ expect.objectContaining({ id: "termix-id", hidden: true }), @@ -120,7 +124,11 @@ describe(`${manifest.id} activate`, () => { isElectron: true, invoke: vi.fn(async () => null), }; - rendered = await renderWithApp(plugin, { manifest, locales }); + rendered = await renderWithApp(plugin, { + manifest, + locales, + remoteServerUrl: () => linked.serverUrl, + }); await waitFor(() => expect(rendered!.registered.railItems()[0]?.hidden).toBe(true), ); diff --git a/plugins/tmux-monitor/package.json b/plugins/tmux-monitor/package.json index a63b6a5ac..a64c022b8 100644 --- a/plugins/tmux-monitor/package.json +++ b/plugins/tmux-monitor/package.json @@ -6,10 +6,28 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, + "dependencies": { + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "ssh2": "^1.17.0" } } diff --git a/plugins/totp/manifest.json b/plugins/totp/manifest.json index b4e439764..8487c1c62 100644 --- a/plugins/totp/manifest.json +++ b/plugins/totp/manifest.json @@ -19,7 +19,8 @@ "secrets:own", "network:serve", "auth:provide", - "ui:surface" + "ui:surface", + "db:core-refs" ], "contributes": { "auth": { diff --git a/plugins/totp/package.json b/plugins/totp/package.json index 189d27b5c..5616bfde9 100644 --- a/plugins/totp/package.json +++ b/plugins/totp/package.json @@ -6,16 +6,30 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, "dependencies": { + "lucide-react": "^1.33.0", "qrcode": "^1.5.4", "speakeasy": "^2.0.0" }, "devDependencies": { - "@termix/plugin-sdk": "*", - "@types/qrcode": "^1.5.6", - "@types/speakeasy": "^2.0.10" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8" } } diff --git a/plugins/tunnels/manifest.json b/plugins/tunnels/manifest.json index 8ec03dc10..36e91534a 100644 --- a/plugins/tunnels/manifest.json +++ b/plugins/tunnels/manifest.json @@ -20,7 +20,9 @@ "ssh:connect", "db:own", "network:serve", - "ui:surface" + "ui:surface", + "users:impersonate", + "db:core-refs" ], "provides": [ { @@ -86,7 +88,8 @@ "key": "enableTunnel", "labelKey": "hosts.tabTunnels", "editorTab": "general" - } + }, + "uses": ["homepage.widgets"] }, "locales": "locales", "platforms": ["linux", "win32", "darwin"] diff --git a/plugins/tunnels/package.json b/plugins/tunnels/package.json index bf0a9df6e..1be544b2b 100644 --- a/plugins/tunnels/package.json +++ b/plugins/tunnels/package.json @@ -6,10 +6,30 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, + "dependencies": { + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8", + "ssh2": "^1.17.0", + "ws": "^8.21.3" } } diff --git a/plugins/tunnels/src/frontend/index.tsx b/plugins/tunnels/src/frontend/index.tsx index 66b2565e6..ddcc4d8cf 100644 --- a/plugins/tunnels/src/frontend/index.tsx +++ b/plugins/tunnels/src/frontend/index.tsx @@ -20,7 +20,13 @@ function TunnelTabView({ host }: TabProps) { return ; } +/** This plugin's C2S relay socket, which the desktop app dials on the linked server. */ +const C2S_RELAY_PATH = "/plugin-ws/tunnels/c2s/stream"; + export function activate(app: TermixApp): void { + if (typeof window !== "undefined" && window.electronAPI?.setC2SRelayPath) { + void window.electronAPI.setC2SRelayPath(C2S_RELAY_PATH); + } // apiFor("remote") hands back app.api itself outside the desktop app. const remote = app.apiFor("remote"); setTunnelsApi(app.api, { diff --git a/plugins/vault/manifest.json b/plugins/vault/manifest.json index 5dc56d2b2..05d51701f 100644 --- a/plugins/vault/manifest.json +++ b/plugins/vault/manifest.json @@ -75,7 +75,8 @@ } ] }, - "syncEntities": ["vaultProfiles"] + "syncEntities": ["vaultProfiles"], + "uses": ["terminal.overlay"] }, "locales": "locales", "platforms": ["linux", "win32", "darwin"] diff --git a/plugins/vault/package.json b/plugins/vault/package.json index e1e6c68d5..dba545cb5 100644 --- a/plugins/vault/package.json +++ b/plugins/vault/package.json @@ -6,10 +6,29 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, + "dependencies": { + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8", + "ssh2": "^1.17.0" } } diff --git a/plugins/wake-on-lan/package.json b/plugins/wake-on-lan/package.json index f54195fbf..565ddb111 100644 --- a/plugins/wake-on-lan/package.json +++ b/plugins/wake-on-lan/package.json @@ -6,10 +6,23 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, + "dependencies": { + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "express": "^5.2.1", + "sonner": "^2.0.8" } } diff --git a/plugins/wake-on-lan/tests/backend/magic-packet.test.ts b/plugins/wake-on-lan/tests/backend/magic-packet.test.ts index d86167d4e..aec9e304f 100644 --- a/plugins/wake-on-lan/tests/backend/magic-packet.test.ts +++ b/plugins/wake-on-lan/tests/backend/magic-packet.test.ts @@ -72,7 +72,7 @@ describe("sendMagicPacket", () => { beforeEach(() => { vi.clearAllMocks(); - mockSocket = (dgram.createSocket as ReturnType)(); + mockSocket = (dgram.createSocket as unknown as () => typeof mockSocket)(); (mockSocket.bind as ReturnType).mockImplementation( (cb: () => void) => cb(), ); diff --git a/plugins/wake-on-lan/tests/backend/service.test.ts b/plugins/wake-on-lan/tests/backend/service.test.ts index 55ef67138..3e9708d9f 100644 --- a/plugins/wake-on-lan/tests/backend/service.test.ts +++ b/plugins/wake-on-lan/tests/backend/service.test.ts @@ -17,6 +17,9 @@ const HOST = { name: "server", ip: "10.0.0.5", port: 22, + username: "root", + tags: "", + folder: "", authType: "password", }; diff --git a/plugins/warpgate/package.json b/plugins/warpgate/package.json index 315f076a3..593a4892b 100644 --- a/plugins/warpgate/package.json +++ b/plugins/warpgate/package.json @@ -6,10 +6,17 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0" } } diff --git a/plugins/web-endpoint/manifest.json b/plugins/web-endpoint/manifest.json index 5fea709e7..3d94ecf22 100644 --- a/plugins/web-endpoint/manifest.json +++ b/plugins/web-endpoint/manifest.json @@ -7,7 +7,7 @@ "name": "Termix" }, "license": "MIT", - "repository": "https://github.com/Termix-SSH/Plugin-WebEndpoint", + "repository": "https://github.com/Termix-SSH/Termix", "category": "Networking", "icon": "Globe", "engine": { diff --git a/plugins/web-endpoint/package.json b/plugins/web-endpoint/package.json index c5fb93f01..075c91f32 100644 --- a/plugins/web-endpoint/package.json +++ b/plugins/web-endpoint/package.json @@ -6,10 +6,29 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, + "dependencies": { + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "axios": "^1.19.0", + "express": "^5.2.1", + "i18next": "^26.4.2", + "react": "^19.2.8", + "sonner": "^2.0.8" } } diff --git a/plugins/webauthn/manifest.json b/plugins/webauthn/manifest.json index 503b30265..50c8ce46e 100644 --- a/plugins/webauthn/manifest.json +++ b/plugins/webauthn/manifest.json @@ -19,7 +19,8 @@ "network:serve", "auth:provide", "settings:read-core", - "ui:surface" + "ui:surface", + "db:core-refs" ], "contributes": { "auth": { diff --git a/plugins/webauthn/package.json b/plugins/webauthn/package.json index 4c1346de1..b00f937d0 100644 --- a/plugins/webauthn/package.json +++ b/plugins/webauthn/package.json @@ -6,14 +6,30 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, "dependencies": { "@simplewebauthn/browser": "^13.3.0", - "@simplewebauthn/server": "^13.3.2" + "@simplewebauthn/server": "^13.3.2", + "lucide-react": "^1.33.0" }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8" } } diff --git a/plugins/workspaces/package.json b/plugins/workspaces/package.json index 5d4e62544..eea2df669 100644 --- a/plugins/workspaces/package.json +++ b/plugins/workspaces/package.json @@ -6,10 +6,28 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -p .", "validate": "termix-plugin validate" }, + "dependencies": { + "lucide-react": "^1.33.0" + }, "devDependencies": { - "@termix/plugin-sdk": "*" + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "better-sqlite3": "^13.0.3", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8" } } diff --git a/plugins/workspaces/tests/backend/activate.test.ts b/plugins/workspaces/tests/backend/activate.test.ts index 94d1ff684..9a11482a7 100644 --- a/plugins/workspaces/tests/backend/activate.test.ts +++ b/plugins/workspaces/tests/backend/activate.test.ts @@ -40,7 +40,7 @@ describe("workspaces activate", () => { const service = server.mock.services.get("workspaces.saved") as { list: () => Promise; }; - const listed = await server.mock.ctx.asUser("user-1", () => service.list()); + const listed = await server.mock.actAs("user-1", () => service.list()); expect(listed).toEqual([ { id: expect.any(Number), name: "Prod", isDefault: false }, ]); diff --git a/scripts/apply-plugin-patches.cjs b/scripts/apply-plugin-patches.cjs new file mode 100644 index 000000000..eb7cdb7c6 --- /dev/null +++ b/scripts/apply-plugin-patches.cjs @@ -0,0 +1,26 @@ +/** + * Applies every workspace plugin's dependency patches after npm install, so + * the Vite dev server serves the same patched libraries a build bundles. + */ + +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const root = path.resolve(__dirname, ".."); +const cli = path.join(root, "packages", "plugin-sdk", "cli", "index.mjs"); +const plugins = path.join(root, "plugins"); + +for (const id of fs.readdirSync(plugins)) { + const dir = path.join(plugins, id); + const pkgPath = path.join(dir, "package.json"); + if (!fs.existsSync(pkgPath)) continue; + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); + if (!pkg.termix?.patches?.length && !fs.existsSync(path.join(dir, "patches"))) + continue; + const result = spawnSync(process.execPath, [cli, "patch"], { + cwd: dir, + stdio: "inherit", + }); + if (result.status !== 0) process.exit(result.status ?? 1); +} diff --git a/scripts/build-sdk-host.mjs b/scripts/build-sdk-host.mjs new file mode 100644 index 000000000..a1e4a752b --- /dev/null +++ b/scripts/build-sdk-host.mjs @@ -0,0 +1,140 @@ +/** + * Builds the two pieces of the plugin SDK that come from core: + * + * dist/host/ui.js @termix/plugin-sdk/ui, the shell's UI kit + * dist/host/testing-host.js what renderWithApp() renders plugins into + * + * Inside Termix the page's import map points @termix/plugin-sdk/ui at the + * shell's own copy, so ui.js is never loaded there. It exists so a plugin in + * its own repo can typecheck and run its tests without a Termix checkout. + * + * One esbuild run with code splitting, so both entries share one copy of the + * registries: a test host with its own registries would never see what the + * plugin's components registered. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import * as esbuild from "esbuild"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const outDir = path.join(root, "packages", "plugin-sdk", "dist", "host"); + +// Shared with the plugin, so never a second copy. Same list the page's import +// map provides, plus the test library the host renders with. +const EXTERNALS = [ + "react", + "react-dom", + "react/jsx-runtime", + "react-dom/client", + "i18next", + "react-i18next", + "sonner", + "@termix/plugin-sdk/frontend", + "@testing-library/react", +]; + +fs.rmSync(outDir, { recursive: true, force: true }); + +await esbuild.build({ + entryPoints: { + ui: path.join(root, "src/ui/plugin-host/sdk-ui.ts"), + "testing-host": path.join(root, "src/ui/plugin-host/testing-host.tsx"), + }, + outdir: outDir, + bundle: true, + splitting: true, + format: "esm", + platform: "browser", + target: "es2022", + jsx: "automatic", + logLevel: "warning", + external: EXTERNALS, + alias: { + "@/types": path.join(root, "src/types"), + "@": path.join(root, "src/ui"), + }, + loader: { + ".css": "empty", + ".svg": "dataurl", + ".png": "dataurl", + ".woff": "empty", + ".woff2": "empty", + ".ttf": "empty", + }, + // import.meta.glob is Vite's; outside Termix the host ships English only, + // so the translated locale files it would list are simply none. + banner: { js: "const __termixNoGlob = () => ({});" }, + define: { + "import.meta.glob": "__termixNoGlob", + "import.meta.env.DEV": "false", + "import.meta.env.PROD": "true", + "import.meta.env.MODE": '"production"', + "import.meta.env.BASE_URL": '"/"', + "import.meta.env": + '{"DEV":false,"PROD":true,"MODE":"production","SSR":false,"BASE_URL":"/"}', + }, +}); + +// Types for @termix/plugin-sdk/ui: the declarations tsc emits for sdk-ui.ts +// and everything it reaches, with core's "@/" paths made relative. +const typesDir = path.join(outDir, "types"); +const tsc = spawnSync( + process.execPath, + [ + path.join(root, "node_modules", "typescript", "bin", "tsc"), + "-p", + path.join(root, "tsconfig.sdk-ui.json"), + "--outDir", + typesDir, + ], + { cwd: root, stdio: "inherit" }, +); +if (tsc.status !== 0) process.exit(tsc.status ?? 1); +// tsc follows @termix/plugin-sdk/frontend into the SDK's source; plugins get +// those types from the package itself. +fs.rmSync(path.join(typesDir, "packages"), { recursive: true, force: true }); + +const srcTypes = path.join(typesDir, "src"); +function rewrite(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const file = path.join(dir, entry.name); + if (entry.isDirectory()) { + rewrite(file); + continue; + } + if (!entry.name.endsWith(".d.ts")) continue; + const text = fs + .readFileSync(file, "utf8") + .replace( + /(["'])@\/(types(?=[/"'])|)([^"']*)\1/g, + (_match, quote, types, rest) => { + const target = types + ? path.join(srcTypes, "types", rest.replace(/^\//, "")) + : path.join(srcTypes, "ui", rest); + let relative = path + .relative(path.dirname(file), target) + .replaceAll("\\", "/") + .replace(/\.tsx?$/, ""); + if (!relative.startsWith(".")) relative = `./${relative}`; + return `${quote}${relative}${quote}`; + }, + ); + fs.writeFileSync(file, text); + } +} +rewrite(srcTypes); +fs.writeFileSync( + path.join(outDir, "ui.d.ts"), + 'export * from "./types/src/ui/plugin-host/sdk-ui.js";\n', +); + +// The design tokens termix-plugin build compiles a plugin's classes against. +fs.copyFileSync( + path.join(root, "src/ui/theme.css"), + path.join(outDir, "theme.css"), +); + +console.log(`Built the SDK host modules into ${path.relative(root, outDir)}`); diff --git a/scripts/check-core-plugin-ids.cjs b/scripts/check-core-plugin-ids.cjs index 43ddf45f4..625b44f30 100644 --- a/scripts/check-core-plugin-ids.cjs +++ b/scripts/check-core-plugin-ids.cjs @@ -10,7 +10,13 @@ * through the registries instead. Regex literals are read too, so * /^\/plugin-api\/docker/ counts the same as "/plugin-api/docker". * - * Tests and locales are exempt, and so is src/backend/upgrade/: the one-time + * Ids come from every plugin manifest and from docker/bundled-plugins.json, so + * the check still means something once plugins live in their own repos. An + * action, slot or extension point id a plugin owns ("terminal.open") counts + * too, whatever its prefix. electron/ and vite.config.ts are read as well. + * + * src/backend/tests, src/ui/tests and src/ui/locales are exempt, and so is + * src/backend/upgrade/: the one-time * 2.8 to 2.9 data moves have to name the plugin each piece of data moves to, * the same way LEGACY_TABLE_OWNERS in the SDK names the plugin that adopts * each legacy table. @@ -23,7 +29,7 @@ const fs = require("node:fs"); const path = require("node:path"); -const EXEMPT_DIRS = new Set(["tests", "locales"]); +const { owners } = require("./check-plugin-uses.cjs"); const SSH_TERMS = new Set(["totp"]); function paths(root) { @@ -33,12 +39,20 @@ function paths(root) { src, ui: path.join(src, "ui"), plugins: path.join(root, "plugins"), - exempt: [path.join(src, "backend", "upgrade")], + bundled: path.join(root, "docker", "bundled-plugins.json"), + extra: [path.join(root, "electron"), path.join(root, "vite.config.ts")], + exempt: [ + path.join(src, "backend", "upgrade"), + path.join(src, "backend", "tests"), + path.join(src, "ui", "tests"), + path.join(src, "ui", "locales"), + ], sshConnect: path.join(src, "backend", "hosts", "connect"), }; } function manifests(dir) { + if (!fs.existsSync(dir)) return []; return fs .readdirSync(dir, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) @@ -47,9 +61,15 @@ function manifests(dir) { .map((file) => JSON.parse(fs.readFileSync(file, "utf8"))); } -function names(pluginsDir) { +function names(pluginsDir, bundledFile) { const ids = new Set(); const views = new Set(); + if (bundledFile && fs.existsSync(bundledFile)) { + for (const entry of JSON.parse(fs.readFileSync(bundledFile, "utf8")) + .plugins ?? []) { + if (typeof entry?.id === "string") ids.add(entry.id); + } + } for (const manifest of manifests(pluginsDir)) { ids.add(manifest.id); const contributes = manifest.contributes ?? {}; @@ -65,11 +85,15 @@ function names(pluginsDir) { } function walk(dir, exempt, out) { - if (exempt.includes(dir)) return out; + if (exempt.includes(dir) || !fs.existsSync(dir)) return out; + if (fs.statSync(dir).isFile()) { + out.push(dir); + return out; + } for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { - if (EXEMPT_DIRS.has(entry.name)) continue; + if (entry.name === "node_modules") continue; walk(full, exempt, out); } else if (/\.(tsx?|mjs|cjs|jsx?)$/.test(entry.name)) { out.push(full); @@ -107,16 +131,25 @@ function regexLiterals(source) { } function scan(root = path.resolve(__dirname, "..")) { - const { src, ui, plugins, exempt, sshConnect } = paths(root); - const { ids, views } = names(plugins); + const { src, ui, plugins, bundled, extra, exempt, sshConnect } = paths(root); + const { ids, views } = names(plugins, bundled); + const owned = fs.existsSync(plugins) ? owners(plugins) : new Map(); const found = {}; const add = (file, what) => { const key = path.relative(root, file).replaceAll("\\", "/"); (found[key] ??= new Set()).add(what); }; - for (const file of walk(src, exempt, [])) { - const source = fs.readFileSync(file, "utf8"); + const files = walk(src, exempt, []); + for (const target of extra) walk(target, exempt, files); + for (const file of files) { + // A line marked "plugin-id-ok" names a plugin on purpose, such as a key + // an older release stored; the marker has to say why. + const source = fs + .readFileSync(file, "utf8") + .split("\n") + .filter((line) => !/plugin-id-ok: \S/.test(line)) + .join("\n"); const inShell = file.startsWith(ui + path.sep); const inConnect = file.startsWith(sshConnect + path.sep); for (const match of source.matchAll( @@ -141,6 +174,7 @@ function scan(root = path.resolve(__dirname, "..")) { } const prefix = /^([a-z][a-z0-9-]*)\.[a-zA-Z]/.exec(text); if (prefix && ids.has(prefix[1])) add(file, text); + if (owned.has(text)) add(file, `${text} (owned by ${owned.get(text)})`); } for (const pattern of regexLiterals(source)) { for (const route of pattern.matchAll( diff --git a/scripts/check-core-plugin-ids.test.ts b/scripts/check-core-plugin-ids.test.ts index c642c2a93..5b818e4d1 100644 --- a/scripts/check-core-plugin-ids.test.ts +++ b/scripts/check-core-plugin-ids.test.ts @@ -81,3 +81,34 @@ describe("scan", () => { }); }); }); + +describe("scan beyond src with plugins elsewhere", () => { + it("knows the bundled ids even when plugins/ is empty", () => { + const dir = fixture({ + "docker/bundled-plugins.json": JSON.stringify({ + plugins: [{ id: "tunnels", source: "workspace" }], + }), + "src/ui/x.ts": 'const route = "/plugin-ws/tunnels/c2s";\n', + }); + fs.rmSync(path.join(dir, "plugins"), { recursive: true, force: true }); + expect(scan(dir)["src/ui/x.ts"]).toContain("/plugin-ws/tunnels"); + }); + + it("reads electron and catches an action a plugin owns", () => { + const dir = fixture({ + "plugins/docker/src/frontend/index.tsx": + 'app.registerAction("terminal.open", fn);\n', + "electron/main.cjs": 'invoke("terminal.open");\n', + }); + expect(scan(dir)["electron/main.cjs"]).toEqual([ + "terminal.open (owned by docker)", + ]); + }); + + it("skips a line marked plugin-id-ok with a reason", () => { + const dir = fixture({ + "src/types/a.ts": 'const legacy = "docker"; // plugin-id-ok: 2.8 key\n', + }); + expect(scan(dir)).toEqual({}); + }); +}); diff --git a/scripts/check-plugin-uses.cjs b/scripts/check-plugin-uses.cjs new file mode 100644 index 000000000..0d3e88ab2 --- /dev/null +++ b/scripts/check-plugin-uses.cjs @@ -0,0 +1,153 @@ +#!/usr/bin/env node +/** + * A plugin that calls another plugin's action, fills its slot or extends its + * extension point has to say so in manifest.json contributes.uses. Nothing + * else records that coupling: the ids are plain strings, so without it a + * plugin moved to its own repo silently loses a feature when the other one is + * renamed, and nobody can tell what depends on what. + * + * Owners are read from the code: a plugin owns the actions it registers, the + * slots it renders (ActionSlot/ComponentSlot/useSlotContributions/ + * declareActionSlot) and the extension points it reads (useExtensions). An id + * nobody owns is core's and needs no declaration. + * + * --list prints what each plugin uses from which owner. + */ + +const fs = require("node:fs"); +const path = require("node:path"); + +const ROOT = path.resolve(__dirname, ".."); +const PLUGINS = path.join(ROOT, "plugins"); + +// Each pattern captures a string literal or a constant's name; constants are +// resolved against the plugin's own `const NAME = "id"` declarations. +const ID = String.raw`(?:"([^"]+)"|([A-Z][A-Z0-9_]*))`; +const OWNS = [ + new RegExp(String.raw`registerAction\(\s*${ID}`, "g"), + new RegExp(String.raw`declareActionSlot\(\s*\{\s*id:\s*${ID}`, "g"), + new RegExp( + String.raw`<(?:ActionSlot|ComponentSlot)[^>]*?slotId=\{?${ID}`, + "g", + ), + new RegExp(String.raw`useSlotContributions\(\s*${ID}`, "g"), + new RegExp(String.raw`useExtensions(?:<[^>]*>)?\(\s*${ID}`, "g"), + new RegExp(String.raw`getExtension\(\s*${ID}`, "g"), +]; +const USES = [ + new RegExp(String.raw`invokeAction\(\s*${ID}`, "g"), + new RegExp(String.raw`registerSlotContribution\(\s*${ID}`, "g"), + new RegExp(String.raw`registerExtension\(\s*${ID}`, "g"), + new RegExp( + String.raw`<(?:ActionSlot|ComponentSlot)[^>]*?slotId=\{?${ID}`, + "g", + ), + new RegExp(String.raw`useSlotContributions\(\s*${ID}`, "g"), +]; + +function sourceOf(dir) { + let text = ""; + const walk = (current) => { + if (!fs.existsSync(current)) return; + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const file = path.join(current, entry.name); + if (entry.isDirectory()) walk(file); + else if (/\.(ts|tsx)$/.test(entry.name)) { + text += fs.readFileSync(file, "utf8"); + } + } + }; + walk(path.join(dir, "src")); + return text; +} + +function constants(text) { + const map = new Map(); + for (const match of text.matchAll( + /const ([A-Z][A-Z0-9_]*)\s*=\s*"([^"]+)"/g, + )) { + map.set(match[1], match[2]); + } + return map; +} + +function matches(patterns, text) { + const names = constants(text); + const found = new Set(); + for (const pattern of patterns) { + for (const match of text.matchAll(pattern)) { + const id = match[1] ?? names.get(match[2]); + if (id) found.add(id); + } + } + return found; +} + +function collect(pluginsDir = PLUGINS) { + const plugins = fs + .readdirSync(pluginsDir, { withFileTypes: true }) + .filter((entry) => + fs.existsSync(path.join(pluginsDir, entry.name, "manifest.json")), + ) + .map((entry) => { + const dir = path.join(pluginsDir, entry.name); + const manifest = JSON.parse( + fs.readFileSync(path.join(dir, "manifest.json"), "utf8"), + ); + const source = sourceOf(dir); + return { + id: manifest.id, + declared: new Set(manifest.contributes?.uses ?? []), + owns: matches(OWNS, source), + uses: matches(USES, source), + }; + }); + + const owner = new Map(); + for (const plugin of plugins) { + for (const id of plugin.owns) if (!owner.has(id)) owner.set(id, plugin.id); + } + + const problems = []; + const usage = {}; + for (const plugin of plugins) { + for (const id of plugin.uses) { + const by = owner.get(id); + if (!by || by === plugin.id || plugin.owns.has(id)) continue; + (usage[plugin.id] ??= []).push(`${id} (${by})`); + if (!plugin.declared.has(id)) { + problems.push( + `${plugin.id} uses "${id}" from the ${by} plugin; add it to contributes.uses in plugins/${plugin.id}/manifest.json`, + ); + } + } + for (const id of plugin.declared) { + if (!plugin.uses.has(id)) { + problems.push( + `${plugin.id} declares "${id}" in contributes.uses but never uses it`, + ); + } + } + } + return { problems, usage, owner }; +} + +function main() { + const { problems, usage } = collect(); + if (process.argv.includes("--list")) { + console.log(JSON.stringify(usage, null, 2)); + return; + } + if (problems.length === 0) return; + for (const problem of problems) console.error(problem); + process.exit(1); +} + +if (require.main === module) main(); + +/** Every action, slot and extension point id a plugin owns, by owner. */ +function owners(pluginsDir = PLUGINS) { + return collect(pluginsDir).owner; +} + +module.exports = { collect, owners }; diff --git a/scripts/check-plugin-uses.test.ts b/scripts/check-plugin-uses.test.ts new file mode 100644 index 000000000..5fcc45cc6 --- /dev/null +++ b/scripts/check-plugin-uses.test.ts @@ -0,0 +1,70 @@ +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 { collect } = require("./check-plugin-uses.cjs") as { + collect: (dir: string) => { problems: string[]; owner: Map }; +}; + +let root: string | null = null; + +afterEach(() => { + if (root) fs.rmSync(root, { recursive: true, force: true }); + root = null; +}); + +function plugins( + specs: Record, +): string { + root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-uses-")); + for (const [id, spec] of Object.entries(specs)) { + const dir = path.join(root, id); + fs.mkdirSync(path.join(dir, "src"), { recursive: true }); + fs.writeFileSync( + path.join(dir, "manifest.json"), + JSON.stringify({ id, contributes: spec.uses ? { uses: spec.uses } : {} }), + ); + fs.writeFileSync(path.join(dir, "src", "index.tsx"), spec.source); + } + return root; +} + +describe("check-plugin-uses", () => { + it("finds owners through string literals and constants", () => { + const dir = plugins({ + terminal: { + source: + 'const SLOT = "terminal.toolbar";\napp.declareActionSlot({ id: SLOT });\napp.registerAction("terminal.open", f);\n', + }, + }); + const { owner } = collect(dir); + expect(owner.get("terminal.open")).toBe("terminal"); + expect(owner.get("terminal.toolbar")).toBe("terminal"); + }); + + it("requires another plugin's id in contributes.uses", () => { + const dir = plugins({ + terminal: { source: 'app.registerAction("terminal.open", f);\n' }, + files: { source: 'invokeAction("terminal.open", host);\n' }, + }); + expect(collect(dir).problems).toEqual([ + expect.stringContaining('files uses "terminal.open" from the terminal'), + ]); + }); + + it("passes a declared use and flags a declared one that is unused", () => { + const dir = plugins({ + terminal: { source: 'app.registerAction("terminal.open", f);\n' }, + files: { + source: 'invokeAction("terminal.open", host);\n', + uses: ["terminal.open", "terminal.gone"], + }, + }); + expect(collect(dir).problems).toEqual([ + expect.stringContaining('declares "terminal.gone"'), + ]); + }); +}); diff --git a/scripts/check-sdk-ui-exports.cjs b/scripts/check-sdk-ui-exports.cjs index abd55f247..476aa13f9 100644 --- a/scripts/check-sdk-ui-exports.cjs +++ b/scripts/check-sdk-ui-exports.cjs @@ -106,16 +106,6 @@ const ALLOWED = [ "Label", "LineChart", "LineChartSeries", - "LocalCollectedFile", - "LocalDirectoryListing", - "LocalFileEntry", - "LocalFsHomeInfo", - "LocalFsResult", - "LocalTransferOrigin", - "LocalTransferProgress", - "LocalTrashResult", - "LocalWalkFile", - "LocalWalkResult", "LogContext", "LogEntry", "MFAPromptMode", @@ -178,9 +168,7 @@ const ALLOWED = [ "VersionInfo", "WebSocketConnectionTarget", "WidgetTitle", - "badgeVariants", "buildOriginWsUrl", - "buttonVariants", "cn", "copyToClipboard", "createFrontendLogger", @@ -195,12 +183,9 @@ const ALLOWED = [ "getUptime", "getVersionInfo", "globalShortcutHandler", - "guacStateToStage", - "guacStateToStatus", "hydrateLocalSharedHostAuth", "isElectron", "isMacPlatform", - "isQuickConnectHost", "isTabJumpHotkey", "linkedServerUrl", "markAdaptiveResourceUsed", @@ -243,6 +228,53 @@ function exportedNames(file = SDK_UI, tsconfig = TSCONFIG) { .sort(); } +const SNAPSHOT = path.join(ROOT, "packages", "plugin-sdk", "ui.api.txt"); + +/** + * One line per export with its type as the compiler prints it. Committed as + * packages/plugin-sdk/ui.api.txt, so a changed prop or signature shows up as + * a diff instead of reaching plugins unnoticed. + */ +function exportSignatures(file = SDK_UI, tsconfig = TSCONFIG) { + const ts = require("typescript"); + const parsed = ts.getParsedCommandLineOfConfigFile( + tsconfig, + {}, + { ...ts.sys, onUnRecoverableConfigFileDiagnostic: () => {} }, + ); + const program = ts.createProgram([file], { + ...(parsed?.options ?? {}), + noEmit: true, + }); + const checker = program.getTypeChecker(); + const source = program.getSourceFile(file); + const symbol = source && checker.getSymbolAtLocation(source); + const flags = + ts.TypeFormatFlags.NoTruncation | + ts.TypeFormatFlags.WriteArrowStyleSignature; + return checker + .getExportsOfModule(symbol) + .map((entry) => { + const target = + entry.flags & ts.SymbolFlags.Alias + ? checker.getAliasedSymbol(entry) + : entry; + const isValue = !!(target.flags & ts.SymbolFlags.Value); + const declaration = target.declarations?.[0] ?? source; + const type = isValue + ? checker.getTypeOfSymbolAtLocation(target, declaration) + : checker.getDeclaredTypeOfSymbol(target); + const text = checker + .typeToString(type, undefined, flags) + .replace(/import\("[^"]*"\)\./g, "") + .replace(/\s+/g, " "); + return `${entry.getName()} (${isValue ? "value" : "type"}): ${text}`; + }) + .sort() + .join("\n") + .concat("\n"); +} + /** What the module adds to, and drops from, the allowlist. */ function compare(names, allowed = ALLOWED) { const allowedSet = new Set(allowed); @@ -254,6 +286,36 @@ function compare(names, allowed = ALLOWED) { } function main() { + const fs = require("node:fs"); + if (/^export (type )?\*/m.test(fs.readFileSync(SDK_UI, "utf8"))) { + console.error( + "sdk-ui.ts uses export *; name each export so nothing becomes public API by accident.", + ); + process.exit(1); + } + if (process.argv.includes("--update-snapshot")) { + fs.writeFileSync(SNAPSHOT, exportSignatures()); + console.log(`Wrote ${path.relative(ROOT, SNAPSHOT)}`); + return; + } + const saved = fs.existsSync(SNAPSHOT) + ? fs.readFileSync(SNAPSHOT, "utf8") + : ""; + const current = exportSignatures(); + if (current !== saved) { + const before = new Set(saved.split("\n")); + const after = new Set(current.split("\n")); + console.error( + "@termix/plugin-sdk/ui changed shape. If that is deliberate, run: node scripts/check-sdk-ui-exports.cjs --update-snapshot", + ); + for (const line of saved.split("\n")) { + if (line && !after.has(line)) console.error(` - ${line.slice(0, 200)}`); + } + for (const line of current.split("\n")) { + if (line && !before.has(line)) console.error(` + ${line.slice(0, 200)}`); + } + process.exitCode = 1; + } const names = exportedNames(); if (process.argv.includes("--list")) { console.log(names.join("\n")); @@ -278,4 +340,4 @@ function main() { if (require.main === module) main(); -module.exports = { ALLOWED, compare, exportedNames }; +module.exports = { ALLOWED, compare, exportedNames, exportSignatures }; diff --git a/scripts/install-native-plugin-deps.cjs b/scripts/install-native-plugin-deps.cjs new file mode 100644 index 000000000..7e880bc60 --- /dev/null +++ b/scripts/install-native-plugin-deps.cjs @@ -0,0 +1,40 @@ +/** + * Installs the native dependencies bundled plugins declare. esbuild leaves a + * nativeDependencies package out of the plugin bundle, so the server's own + * node_modules has to have it. Run in the production image after npm ci. + */ + +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const root = path.resolve(__dirname, ".."); +const plugins = path.resolve(root, process.argv[2] ?? "plugins"); +const wanted = new Map(); + +for (const id of fs.existsSync(plugins) ? fs.readdirSync(plugins) : []) { + const manifestPath = path.join(plugins, id, "manifest.json"); + const pkgPath = path.join(plugins, id, "package.json"); + if (!fs.existsSync(manifestPath) || !fs.existsSync(pkgPath)) continue; + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); + for (const name of manifest.nativeDependencies ?? []) { + const range = pkg.dependencies?.[name]; + if (!range) { + console.error(`${id}: native dependency ${name} has no version`); + process.exit(1); + } + wanted.set(name, range); + } +} + +if (wanted.size === 0) process.exit(0); + +const specs = [...wanted].map(([name, range]) => `${name}@${range}`); +console.log(`Installing native plugin dependencies: ${specs.join(" ")}`); +const result = spawnSync( + "npm", + ["install", "--no-save", "--omit=dev", "--no-audit", "--no-fund", ...specs], + { cwd: root, stdio: "inherit", shell: process.platform === "win32" }, +); +process.exit(result.status ?? 1); diff --git a/scripts/run-plugin-tests.cjs b/scripts/run-plugin-tests.cjs index 21b91597f..7588c27aa 100644 --- a/scripts/run-plugin-tests.cjs +++ b/scripts/run-plugin-tests.cjs @@ -23,6 +23,24 @@ const pluginIds = fs .map((entry) => entry.name); const failed = []; +const skipped = []; +let ran = 0; + +// A vitest fork that dies mid-run (seen on Windows under memory pressure) +// fails the suite without any test failing. That is retried once; a real +// test failure is not. +const WORKER_CRASH = /Worker exited unexpectedly|Worker forks emitted error/; + +function runSuite(pluginDir) { + const result = spawnSync(process.execPath, [cli, "test"], { + cwd: pluginDir, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); + process.stdout.write(result.stdout ?? ""); + process.stderr.write(result.stderr ?? ""); + return result; +} for (const id of pluginIds) { const pluginDir = path.join(source, id); @@ -40,18 +58,28 @@ for (const id of pluginIds) { ) ); }); - if (!hasTests) continue; + if (!hasTests) { + skipped.push(id); + continue; + } - const result = spawnSync(process.execPath, [cli, "test"], { - cwd: pluginDir, - stdio: "inherit", - }); + ran++; + let result = runSuite(pluginDir); + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`; + if (result.status !== 0 && WORKER_CRASH.test(output)) { + console.warn(`\n${id}: a test worker crashed, running the suite again`); + result = runSuite(pluginDir); + } if (result.status !== 0) failed.push(id); } +if (skipped.length > 0) { + console.warn(`\nNo tests found for: ${skipped.join(", ")}`); +} + if (failed.length > 0) { console.error(`\nPlugin tests failed: ${failed.join(", ")}`); process.exit(1); } -console.log(`\nPlugin tests passed for ${pluginIds.length} plugin(s).`); +console.log(`\nPlugin tests passed for ${ran} plugin(s).`); diff --git a/src/backend/auth/external-account.ts b/src/backend/auth/external-account.ts new file mode 100644 index 000000000..0795e0618 --- /dev/null +++ b/src/backend/auth/external-account.ts @@ -0,0 +1,10 @@ +/** + * Whether a user signs in through an external login (SSO, LDAP or any login + * plugin) rather than only a password. Read from the is_oidc column, which + * keeps its 2.8 name until 3.0.0 replaces it with user_external_identities. + */ +export function isExternalAccount(user: { + isOidc?: boolean | number | null; +}): boolean { + return !!user.isOidc; +} diff --git a/src/backend/auth/login-pipeline.ts b/src/backend/auth/login-pipeline.ts index cfbbfeccb..c3e5a3655 100644 --- a/src/backend/auth/login-pipeline.ts +++ b/src/backend/auth/login-pipeline.ts @@ -177,7 +177,7 @@ async function unlockUser( if (identity.kind === "external" || user.isOidc) { try { - await authManager.authenticateOIDCUser(user.id, deviceType); + await authManager.authenticateExternalUser(user.id, deviceType); } catch (error) { authLogger.error("Failed to set up external user encryption", error, { operation: "external_user_encryption_setup_failed", diff --git a/src/backend/auth/provisioning.ts b/src/backend/auth/provisioning.ts index d6677da2c..e25420767 100644 --- a/src/backend/auth/provisioning.ts +++ b/src/backend/auth/provisioning.ts @@ -167,7 +167,9 @@ async function findLinkedUser( // not scoped to a provider, so only an SSO account that has no link yet, // from the same provider when both say which, can be claimed this way. if (identity.legacyIdentifier) { - const legacy = await users.findByOidcIdentifier(identity.legacyIdentifier); + const legacy = await users.findByExternalIdentifier( + identity.legacyIdentifier, + ); if (legacy && (await mayClaimLegacyAccount(legacy, identity))) { await identities.linkIdentity({ userId: legacy.id, @@ -258,7 +260,7 @@ async function createUser( deviceType === "desktop" || deviceType === "mobile" ? THIRTY_DAYS_MS : ONE_DAY_MS; - await AuthManager.getInstance().registerOIDCUser(id, sessionDurationMs); + await AuthManager.getInstance().registerExternalUser(id, sessionDurationMs); } catch (encryptionError) { await users.delete(id); authLogger.error( diff --git a/src/backend/auth/session-issuer.ts b/src/backend/auth/session-issuer.ts index 2190e8093..be0b8fda9 100644 --- a/src/backend/auth/session-issuer.ts +++ b/src/backend/auth/session-issuer.ts @@ -4,6 +4,7 @@ * they all behave the same way. */ +import { isExternalAccount } from "./external-account.js"; import type { Request, Response } from "express"; import { AuthManager } from "../utils/auth-manager.js"; import { authLogger } from "../utils/logger.js"; @@ -139,6 +140,8 @@ export async function issueSession( is_admin: !!user.isAdmin, username: user.username, userId: user.id, + is_external: isExternalAccount(user), + // 2.8 name, kept until 3.0.0. is_oidc: !!user.isOidc, // Any second factor; the name is what 2.8 clients read. totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor( diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 34d63136c..e3ab85d6d 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -625,7 +625,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => { if (!DataCrypto.getUserDataKey(userId)) { if (isOidcUser) { - const oidcUnlocked = await authManager.authenticateOIDCUser( + const oidcUnlocked = await authManager.authenticateExternalUser( userId, deviceInfo.type, ); @@ -1058,7 +1058,7 @@ app.post( if (!DataCrypto.getUserDataKey(userId)) { if (isOidcUser) { - const oidcUnlocked = await authManager.authenticateOIDCUser( + const oidcUnlocked = await authManager.authenticateExternalUser( userId, deviceInfo.type, ); diff --git a/src/backend/database/repositories/plugin-install-count-repository.ts b/src/backend/database/repositories/plugin-install-count-repository.ts deleted file mode 100644 index d97394f1f..000000000 --- a/src/backend/database/repositories/plugin-install-count-repository.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { and, eq } from "drizzle-orm"; -import { pluginInstallCounts } from "../db/schema.js"; -import type { DatabaseContext } from "./database-context.js"; -import { rowsAffected } from "./mutation-result.js"; -import { insertReturning, updateReturning } from "./returning.js"; - -export type PluginInstallCountRecord = typeof pluginInstallCounts.$inferSelect; - -export interface PluginInstallCountUpsertInput { - pluginId: string; - registryId: string; - count: number; - source?: string; -} - -export class PluginInstallCountRepository { - constructor( - private readonly context: DatabaseContext, - private readonly onWrite?: () => void | Promise, - ) {} - - async listByPlugin(pluginId: string): Promise { - return this.context.drizzle - .select() - .from(pluginInstallCounts) - .where(eq(pluginInstallCounts.pluginId, pluginId)); - } - - async findByPluginAndRegistry( - pluginId: string, - registryId: string, - ): Promise { - const rows = await this.context.drizzle - .select() - .from(pluginInstallCounts) - .where( - and( - eq(pluginInstallCounts.pluginId, pluginId), - eq(pluginInstallCounts.registryId, registryId), - ), - ) - .limit(1); - - return rows[0] ?? null; - } - - async upsert( - input: PluginInstallCountUpsertInput, - now = new Date().toISOString(), - ): Promise { - const existing = await this.findByPluginAndRegistry( - input.pluginId, - input.registryId, - ); - - if (existing) { - const [updated] = await updateReturning( - this.context, - pluginInstallCounts, - { - count: input.count, - source: input.source ?? existing.source, - updatedAt: now, - }, - eq(pluginInstallCounts.id, existing.id), - ); - - await this.afterWrite(); - return updated; - } - - const [created] = await insertReturning(this.context, pluginInstallCounts, { - pluginId: input.pluginId, - registryId: input.registryId, - count: input.count, - source: input.source ?? "aggregate-telemetry", - updatedAt: now, - }); - - await this.afterWrite(); - return created; - } - - async deleteByPlugin(pluginId: string): Promise { - const result = await this.context.drizzle - .delete(pluginInstallCounts) - .where(eq(pluginInstallCounts.pluginId, pluginId)); - - if (rowsAffected(result) > 0) { - await this.afterWrite(); - } - return rowsAffected(result); - } - - private async afterWrite(): Promise { - await this.onWrite?.(); - } -} diff --git a/src/backend/database/repositories/plugin-registry-repository.ts b/src/backend/database/repositories/plugin-registry-repository.ts deleted file mode 100644 index dd5cc5d76..000000000 --- a/src/backend/database/repositories/plugin-registry-repository.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { eq } from "drizzle-orm"; -import { pluginRegistries } from "../db/schema.js"; -import type { DatabaseContext } from "./database-context.js"; -import { rowsAffected } from "./mutation-result.js"; -import { - deleteReturning, - insertReturning, - updateReturning, -} from "./returning.js"; - -export type PluginRegistryRecord = typeof pluginRegistries.$inferSelect; - -export interface PluginRegistryCreateInput { - id: string; - name: string; - url: string; - kind?: string; - enabled?: boolean; - signingKey?: string | null; -} - -export interface PluginRegistryUpdateInput { - name?: string; - url?: string; - kind?: string; - enabled?: boolean; - signingKey?: string | null; - lastCheckedAt?: string | null; - lastIndexHash?: string | null; -} - -export class PluginRegistryRepository { - constructor( - private readonly context: DatabaseContext, - private readonly onWrite?: () => void | Promise, - ) {} - - async listAll(): Promise { - return this.context.drizzle.select().from(pluginRegistries); - } - - async findById(registryId: string): Promise { - const rows = await this.context.drizzle - .select() - .from(pluginRegistries) - .where(eq(pluginRegistries.id, registryId)) - .limit(1); - return rows[0] ?? null; - } - - async create( - input: PluginRegistryCreateInput, - ): Promise { - const [created] = await insertReturning(this.context, pluginRegistries, { - id: input.id, - name: input.name, - url: input.url, - kind: input.kind ?? "community", - enabled: input.enabled ?? true, - signingKey: input.signingKey ?? null, - }); - - await this.afterWrite(); - return created; - } - - async update( - registryId: string, - input: PluginRegistryUpdateInput, - ): Promise { - const existing = await this.findById(registryId); - if (!existing) return null; - - const [updated] = await updateReturning( - this.context, - pluginRegistries, - { - name: input.name ?? existing.name, - url: input.url ?? existing.url, - kind: input.kind ?? existing.kind, - enabled: input.enabled ?? existing.enabled, - signingKey: - input.signingKey === undefined - ? existing.signingKey - : input.signingKey, - lastCheckedAt: - input.lastCheckedAt === undefined - ? existing.lastCheckedAt - : input.lastCheckedAt, - lastIndexHash: - input.lastIndexHash === undefined - ? existing.lastIndexHash - : input.lastIndexHash, - }, - eq(pluginRegistries.id, registryId), - ); - - await this.afterWrite(); - return updated ?? null; - } - - async delete(registryId: string): Promise { - const deleted = await deleteReturning( - this.context, - pluginRegistries, - eq(pluginRegistries.id, registryId), - ); - - if (deleted.length > 0) { - await this.afterWrite(); - return true; - } - return false; - } - - async deleteAll(): Promise { - const all = await this.listAll(); - const result = await this.context.drizzle.delete(pluginRegistries); - - if (rowsAffected(result) > 0) { - await this.afterWrite(); - } - return all.length; - } - - private async afterWrite(): Promise { - await this.onWrite?.(); - } -} diff --git a/src/backend/database/repositories/user-repository.ts b/src/backend/database/repositories/user-repository.ts index b341d231a..0bc7b0de0 100644 --- a/src/backend/database/repositories/user-repository.ts +++ b/src/backend/database/repositories/user-repository.ts @@ -79,7 +79,7 @@ export class UserRepository { return rows[0] ?? null; } - async findByOidcIdentifier( + async findByExternalIdentifier( oidcIdentifier: string, ): Promise { const rows = await this.context.drizzle diff --git a/src/backend/database/routes/auth-routes.ts b/src/backend/database/routes/auth-routes.ts index 5d101754d..9260f351d 100644 --- a/src/backend/database/routes/auth-routes.ts +++ b/src/backend/database/routes/auth-routes.ts @@ -7,7 +7,7 @@ import type { Request, Response, Router } from "express"; import { authLogger } from "../../utils/logger.js"; -import { isOidcTokenCallback } from "../../utils/oidc-desktop-callback.js"; +import { isExternalTokenCallback } from "../../utils/external-login-callback.js"; import { ensureCoreLoginProviders } from "../../auth/core-auth.js"; import { readPendingLogin, @@ -32,7 +32,7 @@ export interface PublicLoginMethod { kind: "redirect" | "form"; labelKey: string; icon?: string; - instances: Array<{ id: string; label: string }>; + instances: Array<{ id: string; label: string; autoStart?: boolean }>; } async function enabledInstances(method: LoginMethod) { @@ -93,12 +93,18 @@ export async function listPublicLoginMethods(): Promise { ensureCoreLoginProviders(); const methods: PublicLoginMethod[] = []; for (const method of listLoginMethods()) { - let instances: Array<{ id: string; label: string }> = []; + let instances: PublicLoginMethod["instances"] = []; if (method.describe) { try { instances = (await method.describe()) .filter((instance) => instance.enabled) - .map((instance) => ({ id: instance.id, label: instance.label })); + .map((instance) => ({ + id: instance.id, + label: instance.label, + ...(instance.autoStart && method.kind === "redirect" + ? { autoStart: true } + : {}), + })); } catch (error) { authLogger.warn("Login method could not describe itself", { operation: "login_method_describe", @@ -178,7 +184,7 @@ export async function handleRedirectCallback( res, identity, { methodId: method.id, rememberMe: !!identity.rememberMe }, - isOidcTokenCallback, + isExternalTokenCallback, ); } diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index c80a29798..767d3e435 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -2303,19 +2303,6 @@ registerHostNetworkRoutes(router, { export default router; -/** - * A config field arriving as a JSON string (an import, or a client that - * stringified it) must still reach storage as an object. Malformed input - * becomes null rather than throwing inside a host save. - */ -function safeParseJson(raw: string): unknown { - try { - return JSON.parse(raw); - } catch { - return null; - } -} - /** Seconds between status checks, or null to follow the global setting. */ function normalizeStatusInterval(value: unknown): number | null { const seconds = Number(value); diff --git a/src/backend/database/routes/open-tabs.ts b/src/backend/database/routes/open-tabs.ts index 8232f3503..a58ff228f 100644 --- a/src/backend/database/routes/open-tabs.ts +++ b/src/backend/database/routes/open-tabs.ts @@ -283,9 +283,8 @@ router.get( * get: * summary: Get all active backend sessions for the current user * description: > - * Returns the live terminal sessions the caller owns. Sessions other - * users shared with the caller come from the session sharing plugin. - * Used by the Active Connections panel and tab restore logic. + * Returns the live terminal sessions the caller owns. Used by the + * Active Connections panel and tab restore logic. * tags: * - Open Tabs * responses: @@ -310,17 +309,6 @@ router.get( * type: boolean * createdAt: * type: number - * isOwnSession: - * type: boolean - * sharedByUsername: - * type: string - * nullable: true - * permissionLevel: - * type: string - * nullable: true - * shareId: - * type: string - * nullable: true */ router.get( "/active-sessions", @@ -336,10 +324,6 @@ router.get( tabInstanceId: s.tabInstanceId, isConnected: s.isConnected, createdAt: s.createdAt, - isOwnSession: true, - sharedByUsername: null as string | null, - permissionLevel: null as string | null, - shareId: null as string | null, })); return res.json(result); diff --git a/src/backend/database/routes/ssh-auth-routes.ts b/src/backend/database/routes/ssh-auth-routes.ts index e598c74a4..d1ad78788 100644 --- a/src/backend/database/routes/ssh-auth-routes.ts +++ b/src/backend/database/routes/ssh-auth-routes.ts @@ -25,13 +25,25 @@ export interface SshAuthProviderSummary { missingPlugin?: { id: string; name: string }; } +/** + * A plugin names its keys relative to its own locales, so they are sent + * qualified with its namespace. Core never has to hold a plugin's strings. + */ +function qualifyKey( + pluginId: string, + key: string | undefined, +): string | undefined { + if (!key || pluginId === "core" || key.includes(":")) return key; + return `${pluginId}:${key}`; +} + function summarizeSshAuthProviders(): SshAuthProviderSummary[] { ensureCoreSshAuthProviders(); const summaries: SshAuthProviderSummary[] = listSshAuthProviders().map( (provider) => ({ type: provider.type, - labelKey: provider.labelKey, - descriptionKey: provider.descriptionKey, + labelKey: qualifyKey(provider.pluginId, provider.labelKey) ?? "", + descriptionKey: qualifyKey(provider.pluginId, provider.descriptionKey), pluginId: provider.pluginId, fields: provider.fields ?? [], credentialType: !!provider.credentialType, diff --git a/src/backend/database/routes/user-admin-routes.ts b/src/backend/database/routes/user-admin-routes.ts index cf723d4a4..366d89a6e 100644 --- a/src/backend/database/routes/user-admin-routes.ts +++ b/src/backend/database/routes/user-admin-routes.ts @@ -1,3 +1,4 @@ +import { isExternalAccount } from "../../auth/external-account.js"; import type { AuthenticatedRequest } from "../../../types/index.js"; import type { RequestHandler, Router } from "express"; import { authLogger } from "../../utils/logger.js"; @@ -121,6 +122,7 @@ export function registerUserAdminRoutes( userId: u.id, username: u.username, is_admin: u.isAdmin, + is_external: isExternalAccount(u), is_oidc: u.isOidc, password_hash: u.passwordHash ? "set" : null, // Management-only details stay admin-eyes-only; regular users hit diff --git a/src/backend/database/routes/user-external-account-routes.ts b/src/backend/database/routes/user-external-account-routes.ts new file mode 100644 index 000000000..40e6c17f8 --- /dev/null +++ b/src/backend/database/routes/user-external-account-routes.ts @@ -0,0 +1,285 @@ +import { getErrorMessage } from "../../utils/error-message.js"; +import type { AuthenticatedRequest } from "../../../types/index.js"; +import type { Request, RequestHandler, Response, Router } from "express"; +import { AuthManager } from "../../utils/auth-manager.js"; +import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; +import { authLogger } from "../../utils/logger.js"; +import { + createCurrentUserRepository, + createCurrentUserAuthRepository, +} from "../repositories/factory.js"; +import { deleteUserAndRelatedData } from "./delete-user-data.js"; +import { isExternalAccount } from "../../auth/external-account.js"; + +type UserExternalAccountRoutesDeps = { + authenticateJWT: RequestHandler; + authManager: AuthManager; +}; + +function isNonEmptyString(val: unknown): val is string { + return typeof val === "string" && val.trim().length > 0; +} + +/** + * Merging an account that only signs in externally (SSO, LDAP or any login + * plugin) into a password account, and taking the external sign-in away + * again. The identities themselves live in user_external_identities. + */ +export function registerUserExternalAccountRoutes( + router: Router, + { authenticateJWT, authManager }: UserExternalAccountRoutesDeps, +): void { + /** + * @openapi + * /users/link-external-to-password: + * post: + * summary: Merge an external account into a password account + * description: Moves an external-only account's sign-in identities onto a password account and deletes the external-only account (admin only). The 2.8 path /users/link-oidc-to-password and its oidcUserId field are accepted too. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * externalUserId: + * type: string + * targetUsername: + * type: string + * responses: + * 200: + * description: Accounts linked successfully. + * 400: + * description: Invalid request or incompatible accounts. + * 403: + * description: Admin access required. + * 404: + * description: User not found. + * 500: + * description: Failed to link accounts. + */ + const link = async (req: Request, res: Response) => { + const adminUserId = (req as AuthenticatedRequest).userId; + const externalUserId = req.body?.externalUserId ?? req.body?.oidcUserId; + const targetUsername = req.body?.targetUsername; + + if ( + !isNonEmptyString(externalUserId) || + !isNonEmptyString(targetUsername) + ) { + return res.status(400).json({ + error: "External user ID and target username are required", + }); + } + + try { + const userRepository = createCurrentUserRepository(); + const identities = createCurrentUserAuthRepository(); + const adminUser = await userRepository.findById(adminUserId); + if (!adminUser?.isAdmin) { + return res.status(403).json({ error: "Admin access required" }); + } + + const externalUser = await userRepository.findById(externalUserId); + if (!externalUser) { + return res.status(404).json({ error: "External user not found" }); + } + if (!isExternalAccount(externalUser)) { + return res + .status(400) + .json({ error: "Source user does not sign in externally" }); + } + + const targetUser = await userRepository.findByUsername(targetUsername); + if (!targetUser) { + return res + .status(404) + .json({ error: "Target password user not found" }); + } + if (isExternalAccount(targetUser) || !targetUser.passwordHash) { + return res.status(400).json({ + error: "Target user must be a password-based account", + }); + } + if ((await identities.listIdentitiesForUser(targetUser.id)).length > 0) { + return res.status(400).json({ + error: "Target user already has an external sign-in", + }); + } + + authLogger.info("Linking an external account to a password account", { + operation: "link_external_to_password", + externalUserId, + targetUserId: targetUser.id, + adminUserId, + }); + + await userRepository.update(targetUser.id, { + isOidc: true, + oidcIdentifier: externalUser.oidcIdentifier, + }); + await identities.moveIdentities(externalUserId, targetUser.id); + + await authManager.revokeAllUserSessions(externalUserId); + authManager.logoutUser(externalUserId); + await deleteUserAndRelatedData(externalUserId); + + try { + await DatabaseSaveTrigger.forceSave("link_external_explicit_save"); + } catch (saveError) { + authLogger.error( + "Failed to persist account linking to disk", + saveError, + { + operation: "link_external_save_failed", + externalUserId, + targetUserId: targetUser.id, + }, + ); + } + + authLogger.success( + `External account ${externalUser.username} linked to password account ${targetUser.username}`, + { + operation: "link_external_to_password_success", + externalUserId, + targetUserId: targetUser.id, + adminUserId, + }, + ); + + res.json({ + success: true, + message: `${externalUser.username} has been linked to ${targetUser.username}. The password account can now sign in both ways.`, + }); + } catch (err) { + authLogger.error("Failed to link an external account", err, { + operation: "link_external_to_password_failed", + externalUserId, + targetUsername, + adminUserId, + }); + res.status(500).json({ + error: "Failed to link accounts", + details: getErrorMessage(err), + }); + } + }; + + /** + * @openapi + * /users/unlink-external-from-password: + * post: + * summary: Remove external sign-in from a password account + * description: Removes every external sign-in identity from an account that also has a password (admin only). The 2.8 path /users/unlink-oidc-from-password is accepted too. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * userId: + * type: string + * responses: + * 200: + * description: External sign-in removed. + * 400: + * description: Invalid request, or the user has no external sign-in or no password. + * 403: + * description: Admin privileges required. + * 404: + * description: User not found. + * 500: + * description: Failed to unlink. + */ + const unlink = async (req: Request, res: Response) => { + const adminUserId = (req as AuthenticatedRequest).userId; + const { userId } = req.body ?? {}; + + if (!userId) { + return res.status(400).json({ error: "User ID is required" }); + } + + try { + const userRepository = createCurrentUserRepository(); + const adminUser = await userRepository.findById(adminUserId); + if (!adminUser?.isAdmin) { + authLogger.warn("Non-admin attempted to remove an external sign-in", { + operation: "unlink_external_unauthorized", + adminUserId, + targetUserId: userId, + }); + return res.status(403).json({ error: "Admin privileges required" }); + } + + const targetUser = await userRepository.findById(userId); + if (!targetUser) { + return res.status(404).json({ error: "User not found" }); + } + if (!isExternalAccount(targetUser)) { + return res + .status(400) + .json({ error: "User does not have an external sign-in" }); + } + if (!targetUser.passwordHash) { + return res.status(400).json({ + error: + "Cannot remove the external sign-in from a user without a password. This would leave the user unable to sign in.", + }); + } + + await userRepository.update(targetUser.id, { + isOidc: false, + oidcIdentifier: null, + }); + await createCurrentUserAuthRepository().unlinkIdentitiesForUser( + targetUser.id, + ); + + try { + await DatabaseSaveTrigger.forceSave("unlink_external_explicit_save"); + } catch (saveError) { + authLogger.error( + "Failed to save database after removing an external sign-in", + saveError, + { + operation: "unlink_external_save_failed", + targetUserId: targetUser.id, + }, + ); + } + + authLogger.success("External sign-in removed from password account", { + operation: "unlink_external_from_password_success", + targetUserId: targetUser.id, + adminUserId, + }); + + res.json({ + success: true, + message: `External sign-in has been removed from ${targetUser.username}. The user can now only sign in with a password.`, + }); + } catch (err) { + authLogger.error("Failed to remove an external sign-in", err, { + operation: "unlink_external_from_password_failed", + targetUserId: userId, + adminUserId, + }); + res.status(500).json({ + error: "Failed to unlink", + details: getErrorMessage(err), + }); + } + }; + + router.post("/link-external-to-password", authenticateJWT, link); + router.post("/unlink-external-from-password", authenticateJWT, unlink); + // 2.8 paths, kept until 3.0.0. + router.post("/link-oidc-to-password", authenticateJWT, link); + router.post("/unlink-oidc-from-password", authenticateJWT, unlink); +} diff --git a/src/backend/database/routes/user-oidc-account-routes.ts b/src/backend/database/routes/user-oidc-account-routes.ts deleted file mode 100644 index cce1bcccf..000000000 --- a/src/backend/database/routes/user-oidc-account-routes.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { getErrorMessage } from "../../utils/error-message.js"; -import type { AuthenticatedRequest } from "../../../types/index.js"; -import type { RequestHandler, Router } from "express"; -import { AuthManager } from "../../utils/auth-manager.js"; -import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; -import { authLogger } from "../../utils/logger.js"; -import { - createCurrentUserRepository, - createCurrentUserAuthRepository, -} from "../repositories/factory.js"; -import { deleteUserAndRelatedData } from "./delete-user-data.js"; - -type UserOidcAccountRoutesDeps = { - authenticateJWT: RequestHandler; - authManager: AuthManager; -}; - -function isNonEmptyString(val: unknown): val is string { - return typeof val === "string" && val.trim().length > 0; -} - -export function registerUserOidcAccountRoutes( - router: Router, - { authenticateJWT, authManager }: UserOidcAccountRoutesDeps, -): void { - /** - * @openapi - * /users/link-oidc-to-password: - * post: - * summary: Link OIDC user to password account - * description: Merges an OIDC-only account into a password-based account (admin only). - * tags: - * - Users - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * oidcUserId: - * type: string - * targetUsername: - * type: string - * responses: - * 200: - * description: Accounts linked successfully. - * 400: - * description: Invalid request or incompatible accounts. - * 403: - * description: Admin access required. - * 404: - * description: User not found. - * 500: - * description: Failed to link accounts. - */ - router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => { - const adminUserId = (req as AuthenticatedRequest).userId; - const { oidcUserId, targetUsername } = req.body; - - if (!isNonEmptyString(oidcUserId) || !isNonEmptyString(targetUsername)) { - return res.status(400).json({ - error: "OIDC user ID and target username are required", - }); - } - - try { - const userRepository = createCurrentUserRepository(); - const adminUser = await userRepository.findById(adminUserId); - if (!adminUser?.isAdmin) { - return res.status(403).json({ error: "Admin access required" }); - } - - const oidcUser = await userRepository.findById(oidcUserId); - if (!oidcUser) { - return res.status(404).json({ error: "OIDC user not found" }); - } - - if (!oidcUser.isOidc) { - return res.status(400).json({ - error: "Source user is not an OIDC user", - }); - } - - const targetUser = await userRepository.findByUsername(targetUsername); - if (!targetUser) { - return res - .status(404) - .json({ error: "Target password user not found" }); - } - - if (targetUser.isOidc || !targetUser.passwordHash) { - return res.status(400).json({ - error: "Target user must be a password-based account", - }); - } - - if (targetUser.clientId && targetUser.oidcIdentifier) { - return res.status(400).json({ - error: "Target user already has OIDC authentication configured", - }); - } - - authLogger.info("Linking OIDC user to password account", { - operation: "link_oidc_to_password", - oidcUserId, - oidcUsername: oidcUser.username, - targetUserId: targetUser.id, - targetUsername: targetUser.username, - adminUserId, - }); - - await userRepository.update(targetUser.id, { - isOidc: true, - oidcIdentifier: oidcUser.oidcIdentifier, - clientId: oidcUser.clientId, - clientSecret: oidcUser.clientSecret, - issuerUrl: oidcUser.issuerUrl, - authorizationUrl: oidcUser.authorizationUrl, - tokenUrl: oidcUser.tokenUrl, - identifierPath: oidcUser.identifierPath, - namePath: oidcUser.namePath, - scopes: oidcUser.scopes || "openid email profile", - }); - - // Identities hang off the user row and would go with it. - await createCurrentUserAuthRepository().moveIdentities( - oidcUserId, - targetUser.id, - ); - - await authManager.revokeAllUserSessions(oidcUserId); - authManager.logoutUser(oidcUserId); - - await deleteUserAndRelatedData(oidcUserId); - - try { - await DatabaseSaveTrigger.forceSave("link_oidc_explicit_save"); - } catch (saveError) { - authLogger.error( - "Failed to persist account linking to disk", - saveError, - { - operation: "link_oidc_save_failed", - oidcUserId, - targetUserId: targetUser.id, - }, - ); - } - - authLogger.success( - `OIDC user ${oidcUser.username} linked to password account ${targetUser.username}`, - { - operation: "link_oidc_to_password_success", - oidcUserId, - oidcUsername: oidcUser.username, - targetUserId: targetUser.id, - targetUsername: targetUser.username, - adminUserId, - }, - ); - - res.json({ - success: true, - message: `OIDC user ${oidcUser.username} has been linked to ${targetUser.username}. The password account can now use both password and OIDC login.`, - }); - } catch (err) { - authLogger.error("Failed to link OIDC user to password account", err, { - operation: "link_oidc_to_password_failed", - oidcUserId, - targetUsername, - adminUserId, - }); - res.status(500).json({ - error: "Failed to link accounts", - details: getErrorMessage(err), - }); - } - }); - - /** - * @openapi - * /users/unlink-oidc-from-password: - * post: - * summary: Unlink OIDC from password account - * description: Removes OIDC authentication from a dual-auth account (admin only). - * tags: - * - Users - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * userId: - * type: string - * responses: - * 200: - * description: OIDC unlinked successfully. - * 400: - * description: Invalid request or user doesn't have OIDC. - * 403: - * description: Admin privileges required. - * 404: - * description: User not found. - * 500: - * description: Failed to unlink OIDC. - */ - router.post( - "/unlink-oidc-from-password", - authenticateJWT, - async (req, res) => { - const adminUserId = (req as AuthenticatedRequest).userId; - const { userId } = req.body; - - if (!userId) { - return res.status(400).json({ - error: "User ID is required", - }); - } - - try { - const userRepository = createCurrentUserRepository(); - const adminUser = await userRepository.findById(adminUserId); - - if (!adminUser?.isAdmin) { - authLogger.warn("Non-admin attempted to unlink OIDC from password", { - operation: "unlink_oidc_unauthorized", - adminUserId, - targetUserId: userId, - }); - return res.status(403).json({ - error: "Admin privileges required", - }); - } - - const targetUser = await userRepository.findById(userId); - if (!targetUser) { - return res.status(404).json({ - error: "User not found", - }); - } - - if (!targetUser.isOidc) { - return res.status(400).json({ - error: "User does not have OIDC authentication enabled", - }); - } - - if (!targetUser.passwordHash || targetUser.passwordHash === "") { - return res.status(400).json({ - error: - "Cannot unlink OIDC from a user without password authentication. This would leave the user unable to login.", - }); - } - - authLogger.info("Unlinking OIDC from password account", { - operation: "unlink_oidc_from_password_start", - targetUserId: targetUser.id, - targetUsername: targetUser.username, - adminUserId, - }); - - await userRepository.update(targetUser.id, { - isOidc: false, - oidcIdentifier: null, - clientId: "", - clientSecret: "", - issuerUrl: "", - authorizationUrl: "", - tokenUrl: "", - identifierPath: "", - namePath: "", - scopes: "openid email profile", - }); - await createCurrentUserAuthRepository().unlinkIdentitiesForUser( - targetUser.id, - ); - - try { - await DatabaseSaveTrigger.forceSave("unlink_oidc_explicit_save"); - } catch (saveError) { - authLogger.error( - "Failed to save database after unlinking OIDC", - saveError, - { - operation: "unlink_oidc_save_failed", - targetUserId: targetUser.id, - }, - ); - } - - authLogger.success("OIDC unlinked from password account successfully", { - operation: "unlink_oidc_from_password_success", - targetUserId: targetUser.id, - targetUsername: targetUser.username, - adminUserId, - }); - - res.json({ - success: true, - message: `OIDC authentication has been removed from ${targetUser.username}. User can now only login with password.`, - }); - } catch (err) { - authLogger.error("Failed to unlink OIDC from password account", err, { - operation: "unlink_oidc_from_password_failed", - targetUserId: userId, - adminUserId, - }); - res.status(500).json({ - error: "Failed to unlink OIDC", - details: getErrorMessage(err), - }); - } - }, - ); -} diff --git a/src/backend/database/routes/user-preferences.ts b/src/backend/database/routes/user-preferences.ts index e79cd90ac..6cd16af89 100644 --- a/src/backend/database/routes/user-preferences.ts +++ b/src/backend/database/routes/user-preferences.ts @@ -20,7 +20,6 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({ accentColor: row?.accentColor ?? null, language: row?.language ?? null, storageMode: row?.storageMode ?? "cloud", - commandAutocomplete: row?.commandAutocomplete ?? null, commandPaletteEnabled: row?.commandPaletteEnabled ?? null, showHostTags: row?.showHostTags ?? null, hostTrayOnClick: row?.hostTrayOnClick ?? null, @@ -32,23 +31,9 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({ hiddenRailTabs: row?.hiddenRailTabs ?? null, compactHostView: row?.compactHostView ?? null, statusColorScheme: row?.statusColorScheme ?? null, - customThemes: row?.customThemes ?? null, customKeybindings: row?.customKeybindings ?? null, - terminalDefaults: row?.terminalDefaults ?? null, }); -const connectionDefaultFields = ["terminalDefaults"] as const; - -export function validateDefaultsJson(value: string): boolean { - if (value.length > 32_768) return false; - try { - const parsed = JSON.parse(value); - return !!parsed && typeof parsed === "object" && !Array.isArray(parsed); - } catch { - return false; - } -} - /** * @openapi * /user-preferences: @@ -82,9 +67,6 @@ export function validateDefaultsJson(value: string): boolean { * storageMode: * type: string * nullable: true - * commandAutocomplete: - * type: boolean - * nullable: true * commandPaletteEnabled: * type: boolean * nullable: true @@ -118,10 +100,6 @@ export function validateDefaultsJson(value: string): boolean { * statusColorScheme: * type: string * nullable: true - * customThemes: - * type: string - * nullable: true - * description: JSON-encoded array of the user's saved global custom terminal themes. * customKeybindings: * type: string * nullable: true @@ -170,8 +148,6 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { * type: string * storageMode: * type: string - * commandAutocomplete: - * type: boolean * commandPaletteEnabled: * type: boolean * pinAppRail: @@ -186,9 +162,6 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { * type: boolean * hiddenRailTabs: * type: string - * customThemes: - * type: string - * description: JSON-encoded array of the user's saved global custom terminal themes. * customKeybindings: * type: string * description: JSON-encoded array of the user's custom keybindings. Each action is checked against the shell's own types and the parameters a plugin declares for its type. @@ -205,7 +178,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => { accentColor, language, storageMode, - commandAutocomplete, commandPaletteEnabled, pinAppRail, expandAppRailOnHover, @@ -213,9 +185,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => { disableUpdateCheck, confirmTabClose, hiddenRailTabs, - customThemes, customKeybindings, - terminalDefaults, } = req.body as { reopenTabsOnLogin?: boolean; theme?: string | null; @@ -223,7 +193,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => { accentColor?: string | null; language?: string | null; storageMode?: string | null; - commandAutocomplete?: boolean | null; commandPaletteEnabled?: boolean | null; pinAppRail?: boolean | null; expandAppRailOnHover?: boolean | null; @@ -231,9 +200,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => { disableUpdateCheck?: boolean | null; confirmTabClose?: boolean | null; hiddenRailTabs?: string | null; - customThemes?: string | null; customKeybindings?: string | null; - terminalDefaults?: string | null; }; // showHostTags, hostTrayOnClick, compactHostView, statusColorScheme are no // longer writable here -- they moved to /host-sidebar/preferences as of the @@ -262,55 +229,13 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => { language, storageMode, hiddenRailTabs, - customThemes, customKeybindings, - terminalDefaults, })) { if (value !== undefined && value !== null && typeof value !== "string") { return res.status(400).json({ error: `${key} must be a string` }); } } - const connectionDefaults = { - terminalDefaults, - }; - for (const key of connectionDefaultFields) { - const value = connectionDefaults[key]; - if (value !== undefined && value !== null && !validateDefaultsJson(value)) { - return res.status(400).json({ - error: `${key} must be a JSON-encoded object of at most 32 KiB`, - }); - } - } - - if (customThemes !== undefined && customThemes !== null) { - let parsedThemes: unknown; - try { - parsedThemes = JSON.parse(customThemes); - } catch { - return res - .status(400) - .json({ error: "customThemes must be a JSON-encoded array" }); - } - if (!Array.isArray(parsedThemes) || parsedThemes.length > 100) { - return res.status(400).json({ - error: "customThemes must be a JSON array of at most 100 themes", - }); - } - const isValidTheme = (entry: unknown): boolean => - !!entry && - typeof entry === "object" && - typeof (entry as { id?: unknown }).id === "string" && - typeof (entry as { name?: unknown }).name === "string" && - !!(entry as { colors?: unknown }).colors && - typeof (entry as { colors?: unknown }).colors === "object"; - if (!parsedThemes.every(isValidTheme)) { - return res.status(400).json({ - error: "Each custom theme must have an id, name, and colors object", - }); - } - } - if (customKeybindings !== undefined && customKeybindings !== null) { let parsedKeybindings: unknown; try { @@ -334,7 +259,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => { } const boolFields: Record = { - commandAutocomplete, commandPaletteEnabled, pinAppRail, expandAppRailOnHover, @@ -354,8 +278,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => { if (language !== undefined) updates.language = language; if (storageMode !== undefined) updates.storageMode = storageMode; if (hiddenRailTabs !== undefined) updates.hiddenRailTabs = hiddenRailTabs; - if (commandAutocomplete !== undefined) - updates.commandAutocomplete = commandAutocomplete; if (commandPaletteEnabled !== undefined) updates.commandPaletteEnabled = commandPaletteEnabled; if (pinAppRail !== undefined) updates.pinAppRail = pinAppRail; @@ -366,11 +288,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => { if (disableUpdateCheck !== undefined) updates.disableUpdateCheck = disableUpdateCheck; if (confirmTabClose !== undefined) updates.confirmTabClose = confirmTabClose; - if (customThemes !== undefined) updates.customThemes = customThemes; if (customKeybindings !== undefined) updates.customKeybindings = customKeybindings; - if (terminalDefaults !== undefined) - updates.terminalDefaults = terminalDefaults; if (Object.keys(updates).length === 1) { return res.status(400).json({ error: "No preferences provided" }); diff --git a/src/backend/database/routes/user-settings-routes.ts b/src/backend/database/routes/user-settings-routes.ts index ed393b635..106068d72 100644 --- a/src/backend/database/routes/user-settings-routes.ts +++ b/src/backend/database/routes/user-settings-routes.ts @@ -341,25 +341,6 @@ export function registerUserSettingsRoutes( } }); - /** - * @openapi - * /users/session-sharing-enabled: - * get: - * summary: Get session sharing globally enabled setting - * description: Returns whether live session sharing (terminal and remote desktop share links and in-app joins) is allowed instance-wide. Overrides every per-host toggle when false. - * tags: - * - Users - * responses: - * 200: - * description: Session sharing enabled status. - * content: - * application/json: - * schema: - * type: object - * properties: - * enabled: - * type: boolean - */ /** * @openapi * /users/audit-forwarding: diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index b302bf563..939a0add9 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -1,5 +1,10 @@ +import { isExternalAccount } from "../../auth/external-account.js"; import type { AuthenticatedRequest } from "../../../types/index.js"; -import express, { type Request, type Response } from "express"; +import express, { + type Request, + type RequestHandler, + type Response, +} from "express"; import bcrypt from "bcryptjs"; import { nanoid } from "nanoid"; import { authLogger } from "../../utils/logger.js"; @@ -21,7 +26,7 @@ import { registerBrandingRoutes } from "./branding-routes.js"; import { registerUserSettingsRoutes } from "./user-settings-routes.js"; import { registerTlsRoutes } from "./tls-routes.js"; import { registerUserSessionRoutes } from "./user-session-routes.js"; -import { registerUserOidcAccountRoutes } from "./user-oidc-account-routes.js"; +import { registerUserExternalAccountRoutes } from "./user-external-account-routes.js"; import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js"; import { registerUserAdminRoutes } from "./user-admin-routes.js"; import { registerUserDataAccessRoutes } from "./user-data-access-routes.js"; @@ -90,12 +95,6 @@ function isPasswordResetAllowed(): boolean { } } -function getOidcSilentLoginDefaultFromEnv(): boolean | undefined { - const envVal = process.env.OIDC_SILENT_LOGIN_DEFAULT; - if (envVal === undefined) return undefined; - return envVal.trim().toLowerCase() === "true"; -} - async function findCurrentUser(userId: string): Promise { return createCurrentUserRepository().findById(userId); } @@ -601,8 +600,8 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => { } const hasPassword = user.passwordHash && user.passwordHash.trim() !== ""; - const hasOidc = user.isOidc && user.oidcIdentifier; - const isDualAuth = hasPassword && hasOidc; + const isDualAuth = + hasPassword && isExternalAccount(user) && !!user.oidcIdentifier; const showDonationModal = shouldShowDonationModal( user.registeredAt, @@ -613,6 +612,8 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => { userId: user.id, username: user.username, is_admin: !!user.isAdmin, + is_external: isExternalAccount(user), + // 2.8 name, kept until 3.0.0. is_oidc: !!user.isOidc, is_dual_auth: isDualAuth, // Any second factor; the name is what 2.8 clients read. @@ -940,17 +941,17 @@ router.patch("/registration-allowed", authenticateJWT, async (req, res) => { /** * @openapi - * /users/oidc-auto-provision: + * /users/external-auto-provision: * get: - * summary: Get OIDC auto-provision status - * description: Whether a new user account is created automatically on first SSO sign-in. + * summary: Get the external account auto-create setting + * description: Whether a new account is created the first time someone signs in through an external login (SSO, LDAP or any login plugin). The 2.8 path /users/oidc-auto-provision is accepted too. * tags: * - Users * responses: * 200: - * description: OIDC auto-provision status. + * description: Auto-create setting. */ -router.get("/oidc-auto-provision", async (_req, res) => { +const getExternalAutoProvision: RequestHandler = async (_req, res) => { try { res.json({ enabled: await createCurrentSettingsRepository().getBoolean( @@ -959,19 +960,19 @@ router.get("/oidc-auto-provision", async (_req, res) => { ), }); } catch (err) { - authLogger.error("Failed to get OIDC auto-provision setting", err); + authLogger.error("Failed to get external auto-provision setting", err); res .status(500) - .json({ error: "Failed to get OIDC auto-provision setting" }); + .json({ error: "Failed to get external auto-provision setting" }); } -}); +}; /** * @openapi - * /users/oidc-auto-provision: + * /users/external-auto-provision: * patch: - * summary: Set OIDC auto-provision status - * description: Enables or disables automatic account creation on first SSO sign-in. + * summary: Set the external account auto-create setting + * description: Enables or disables creating an account on first external sign-in. The 2.8 path /users/oidc-auto-provision is accepted too. * tags: * - Users * requestBody: @@ -985,15 +986,15 @@ router.get("/oidc-auto-provision", async (_req, res) => { * type: boolean * responses: * 200: - * description: OIDC auto-provision status updated. + * description: Setting updated. * 400: * description: Invalid value for enabled. * 403: * description: Not authorized. * 500: - * description: Failed to set OIDC auto-provision setting. + * description: Failed to save the setting. */ -router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => { +const setExternalAutoProvision: RequestHandler = async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const user = await requireCurrentAdmin(userId); @@ -1010,10 +1011,20 @@ router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => { ); res.json({ enabled }); } catch (err) { - authLogger.error("Failed to set OIDC auto-provision", err); - res.status(500).json({ error: "Failed to set OIDC auto-provision" }); + authLogger.error("Failed to set external auto-provision", err); + res.status(500).json({ error: "Failed to set external auto-provision" }); } -}); +}; + +router.get("/external-auto-provision", getExternalAutoProvision); +router.patch( + "/external-auto-provision", + authenticateJWT, + setExternalAutoProvision, +); +// 2.8 paths, kept until 3.0.0. +router.get("/oidc-auto-provision", getExternalAutoProvision); +router.patch("/oidc-auto-provision", authenticateJWT, setExternalAutoProvision); /** * @openapi @@ -1104,103 +1115,6 @@ router.patch( }, ); -/** - * @openapi - * /users/oidc-silent-login-default: - * get: - * summary: Get OIDC silent login default setting - * description: Returns whether silent OIDC login is enabled as the default behavior. Can be pinned via the OIDC_SILENT_LOGIN_DEFAULT env var. - * tags: - * - Users - * responses: - * 200: - * description: Silent login default setting. - * 500: - * description: Failed to get setting. - */ -router.get("/oidc-silent-login-default", async (_req, res) => { - try { - const envVal = getOidcSilentLoginDefaultFromEnv(); - if (envVal !== undefined) { - res.json({ enabled: envVal, locked: true }); - return; - } - res.json({ - enabled: await createCurrentSettingsRepository().getBoolean( - "oidc_silent_login_default", - false, - ), - locked: false, - }); - } catch (err) { - authLogger.error("Failed to get OIDC silent login default", err); - res.status(500).json({ error: "Failed to get OIDC silent login default" }); - } -}); - -/** - * @openapi - * /users/oidc-silent-login-default: - * patch: - * summary: Set OIDC silent login default setting - * description: Enables or disables silent OIDC login as the default behavior on the login page. - * tags: - * - Users - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * enabled: - * type: boolean - * responses: - * 200: - * description: Setting updated. - * 400: - * description: Invalid value. - * 403: - * description: Not authorized. - * 409: - * description: Setting is pinned by the OIDC_SILENT_LOGIN_DEFAULT env var. - * 500: - * description: Failed to update setting. - */ -router.patch( - "/oidc-silent-login-default", - authenticateJWT, - async (req, res) => { - const userId = (req as AuthenticatedRequest).userId; - try { - const user = await requireCurrentAdmin(userId); - if (!user) { - return res.status(403).json({ error: "Not authorized" }); - } - if (getOidcSilentLoginDefaultFromEnv() !== undefined) { - return res.status(409).json({ - error: - "OIDC silent login default is set via the OIDC_SILENT_LOGIN_DEFAULT env var and cannot be changed here", - }); - } - const { enabled } = req.body; - if (typeof enabled !== "boolean") { - return res.status(400).json({ error: "Invalid value for enabled" }); - } - await createCurrentSettingsRepository().set( - "oidc_silent_login_default", - enabled ? "true" : "false", - ); - res.json({ enabled }); - } catch (err) { - authLogger.error("Failed to set OIDC silent login default", err); - res - .status(500) - .json({ error: "Failed to set OIDC silent login default" }); - } - }, -); - /** * @openapi * /users/password-login-allowed: @@ -1668,7 +1582,7 @@ registerUserSessionRoutes(router, { authManager, }); -registerUserOidcAccountRoutes(router, { +registerUserExternalAccountRoutes(router, { authenticateJWT, authManager, }); diff --git a/src/backend/hosts/credential-username.ts b/src/backend/hosts/credential-username.ts index de00ef934..d03ad704d 100644 --- a/src/backend/hosts/credential-username.ts +++ b/src/backend/hosts/credential-username.ts @@ -34,16 +34,20 @@ export function pickResolvedPassword( return undefined; } +const USERNAME_PLACEHOLDER = + /\$(?:external\.username|oidc\.preferred_username)/g; + /** - * Expands the `$oidc.preferred_username` placeholder in an SSH username to the - * connecting user's OIDC identifier. Returns the username unchanged if it does - * not contain the placeholder or the user has no OIDC identifier. + * Expands `$external.username` (or its 2.8 spelling `$oidc.preferred_username`) + * in an SSH username to the name the connecting user signed in with through + * SSO or LDAP. Returns the username unchanged if it has no placeholder or the + * user has no external sign-in. */ -export async function expandOidcUsername( +export async function expandExternalUsername( username: string | undefined, userId: string, ): Promise { - if (!username || !username.includes("$oidc.preferred_username")) { + if (!username || !new RegExp(USERNAME_PLACEHOLDER.source).test(username)) { return username; } @@ -51,10 +55,10 @@ export async function expandOidcUsername( const { createCurrentUserRepository } = await import("../database/repositories/factory.js"); const user = await createCurrentUserRepository().findById(userId); - let oidcIdentifier = user?.oidcIdentifier; - if (!oidcIdentifier) return username; + let externalName = user?.oidcIdentifier; + if (!externalName) return username; - const match = /^ldap:(\d+):(.+)$/.exec(oidcIdentifier); + const match = /^ldap:(\d+):(.+)$/.exec(externalName); if (match) { // Only strip the prefix for a real LDAP identity, to prevent spoofing // through an SSO subject that happens to look like one. @@ -69,11 +73,11 @@ export async function expandOidcUsername( identity.subject === match[2], ) ) { - oidcIdentifier = match[2]; + externalName = match[2]; } } - return username.replace(/\$oidc\.preferred_username/g, oidcIdentifier); + return username.replace(USERNAME_PLACEHOLDER, () => externalName); } catch { return username; } diff --git a/src/backend/hosts/host-resolver.ts b/src/backend/hosts/host-resolver.ts index 207abd0c3..7c9befb0f 100644 --- a/src/backend/hosts/host-resolver.ts +++ b/src/backend/hosts/host-resolver.ts @@ -13,7 +13,7 @@ import { resolveRecipientSharedHostAuthentication } from "../utils/shared-host-a import { pickResolvedPassword, pickResolvedUsername, - expandOidcUsername, + expandExternalUsername, } from "./credential-username.js"; import type { SSHHost } from "../../types/index.js"; @@ -210,7 +210,7 @@ export async function resolveHostById( } } - host.username = await expandOidcUsername( + host.username = await expandExternalUsername( host.username as string | undefined, ownerEquivalent ? ownerId : userId, ); diff --git a/src/backend/hosts/live-terminal-sessions.ts b/src/backend/hosts/live-terminal-sessions.ts index 145a6acd9..5bab178e9 100644 --- a/src/backend/hosts/live-terminal-sessions.ts +++ b/src/backend/hosts/live-terminal-sessions.ts @@ -22,7 +22,6 @@ export interface LiveSessionInfo { createdAt: number; lastDetachedAt: number | null; tabInstanceId: string | null; - tmuxSessionName: string | null; cols: number; rows: number; } diff --git a/src/backend/plugins/actor.ts b/src/backend/plugins/actor.ts index 99eca5118..f0a49b57b 100644 --- a/src/backend/plugins/actor.ts +++ b/src/backend/plugins/actor.ts @@ -46,15 +46,3 @@ export function getActorSessionId(): string | undefined { export function getActor(): string | undefined { return storage.getStore()?.userId; } - -class PluginActorError extends Error { - readonly code = "EPLUGINACTOR"; - - constructor(pluginId: string, operation: string) { - super( - `Plugin "${pluginId}" called ${operation} with no acting user. ` + - `Wrap background work in ctx.asUser(userId, fn).`, - ); - this.name = "PluginActorError"; - } -} diff --git a/src/backend/plugins/assets.ts b/src/backend/plugins/assets.ts index af05b73f4..928f6b7a1 100644 --- a/src/backend/plugins/assets.ts +++ b/src/backend/plugins/assets.ts @@ -93,12 +93,7 @@ export function describePluginFrontend( const entry = frontendEntry(plugin); if (!entry || !fileExists(entry.file)) return { ...none, locales }; - const stat = fs.statSync(entry.file); - const assetVersion = crypto - .createHash("sha256") - .update(`${plugin.manifest.version}:${stat.size}:${stat.mtimeMs}`) - .digest("hex") - .slice(0, 12); + const assetVersion = contentVersion(entry.file, plugin.manifest.version); return { frontend: true, @@ -108,6 +103,29 @@ export function describePluginFrontend( }; } +const versionCache = new Map(); + +/** + * A hash of the bundle's bytes, not its mtime: assets are cached as immutable + * for a year, and a rebuilt .tmxplug has a fixed mtime, so a same-size rebuild + * would otherwise keep serving the stale copy. + */ +function contentVersion(file: string, version: string): string { + const stat = fs.statSync(file); + // ctime, unlike mtime, cannot be set by tar, so an unpack always changes it. + const stamp = `${version}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`; + const cached = versionCache.get(file); + if (cached?.stamp === stamp) return cached.version; + const hashed = crypto + .createHash("sha256") + .update(version) + .update(fs.readFileSync(file)) + .digest("hex") + .slice(0, 12); + versionCache.set(file, { stamp, version: hashed }); + return hashed; +} + /** * Maps a request path under /plugin-assets// to a file, or null. * diff --git a/src/backend/plugins/ctx-hosts.ts b/src/backend/plugins/ctx-hosts.ts index 4896c0c30..f0a95d3d0 100644 --- a/src/backend/plugins/ctx-hosts.ts +++ b/src/backend/plugins/ctx-hosts.ts @@ -81,7 +81,6 @@ export const HOST_SECRET_FIELDS = [ "passphrase", "sudoPassword", "socks5Password", - "vaultToken", ] as const; /** A copy of a host with every secret field removed, nested ones included. */ diff --git a/src/backend/plugins/ctx-process.ts b/src/backend/plugins/ctx-process.ts index b2792a5c4..34b71d5c6 100644 --- a/src/backend/plugins/ctx-process.ts +++ b/src/backend/plugins/ctx-process.ts @@ -51,6 +51,34 @@ async function sha256Of(file: string): Promise { } } +/** + * Downloads through the SSRF guard, following https redirects by hand + * because the guard refuses automatic ones (release assets redirect to a + * CDN). + */ +async function guardedDownload( + url: URL | string, + init: RequestInit, +): Promise { + const { safeOutboundFetch } = await import("../utils/safe-outbound-fetch.js"); + let current = new URL(url.toString()); + for (let hop = 0; hop < 5; hop++) { + const response = await safeOutboundFetch(current.toString(), { + ...init, + redirect: "manual", + }); + const location = response.headers.get("location"); + if (response.status < 300 || response.status >= 400 || !location) { + return response; + } + current = new URL(location, current); + if (current.protocol !== "https:") { + throw new Error("Binary downloads must stay on https"); + } + } + throw new Error("Too many redirects"); +} + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -60,7 +88,7 @@ export function createPluginProcess(deps: Deps): PluginProcess { const declared = deps.manifest.capabilities; const binDir = deps.binDir ?? (() => path.join(getPluginDataDir(pluginId), "bin")); - const doFetch = deps.fetch ?? fetch; + const doFetch = deps.fetch ?? (guardedDownload as typeof fetch); const audited = async ( action: string, diff --git a/src/backend/plugins/ctx-ssh-auth.ts b/src/backend/plugins/ctx-ssh-auth.ts index 1d9f6785a..b08c5eac8 100644 --- a/src/backend/plugins/ctx-ssh-auth.ts +++ b/src/backend/plugins/ctx-ssh-auth.ts @@ -791,11 +791,12 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth { await granted(); if (!match.sub && !match.sid) return 0; const { AuthManager } = await import("../utils/auth-manager.js"); - const revoked = await AuthManager.getInstance().revokeSessionsByOidc({ - ssoProviderId: match.providerId ?? null, - sub: match.sub ?? null, - sid: match.sid ?? null, - }); + const revoked = + await AuthManager.getInstance().revokeSessionsByExternalSession({ + ssoProviderId: match.providerId ?? null, + sub: match.sub ?? null, + sid: match.sid ?? null, + }); await audit( "auth_sessions_revoked", `${revoked} session(s) for provider ${match.providerId ?? "none"}`, diff --git a/src/backend/plugins/ctx.ts b/src/backend/plugins/ctx.ts index e88edea86..3c27f9af4 100644 --- a/src/backend/plugins/ctx.ts +++ b/src/backend/plugins/ctx.ts @@ -47,7 +47,7 @@ import { type PluginContext, type PluginModule, type PluginOpenIsolatedWindowRequest, - type PluginNativeRdpRequest, + type PluginExternalClientRequest, } from "@termix/plugin-sdk/backend"; import type { PluginTableDefinition } from "@termix/plugin-sdk/db"; import * as syncRegistry from "./sync-registry.js"; @@ -191,14 +191,17 @@ export function createPluginContext( await import("../database/repositories/factory.js"); return createCurrentPluginSettingsRepository(); }; - // A user named by the plugin instead of the ambient actor. Honoured, like - // ctx.asUser, and audited the same way. + // A user named by the plugin instead of the ambient actor. The same thing + // as ctx.asUser, so it needs the same capability and gets the same audit. const namedUser = ( userId: string | undefined, via: string, ): string | undefined => { if (!userId) return getActor(); if (userId !== getActor()) { + if (!declared.includes("users:impersonate")) { + throw capabilityRefused(pluginId, "users:impersonate"); + } void writeAudit( manifest, { @@ -342,11 +345,11 @@ export function createPluginContext( const dbRefs = guarded( manifest, - "db:own", + "db:core-refs", async () => { // Read-only by convention, not by engine: a plugin holding these can - // write through them. The capability and the audit line are the record - // that it did. See the header of this file. + // write through them, which is why the capability is rated high. The + // audit line is the record that it asked. const schema = await import("../database/db/schema.js"); return { users: schema.users, @@ -380,25 +383,25 @@ export function createPluginContext( }, ); - const desktopLaunchNativeRdp = guarded( + const desktopLaunchExternalClient = guarded( manifest, "desktop:window", - async (request: PluginNativeRdpRequest) => { + async (request: PluginExternalClientRequest) => { const { isElectronIpcAvailable, requestFromElectronMain } = await import("../utils/electron-ipc-bridge.js"); if (!isElectronIpcAvailable()) { throw new Error( - `Plugin ${pluginId} tried to open the native RDP client outside the desktop app`, + `Plugin ${pluginId} tried to open an external client outside the desktop app`, ); } return requestFromElectronMain<{ success: boolean; error?: string }>( - "launch-native-rdp", + "launch-external-client", request, ); }, { - action: "desktop_launch_native_rdp", - details: () => "opened the native RDP client", + action: "desktop_launch_external_client", + details: () => "opened an external client", }, ); @@ -455,7 +458,26 @@ export function createPluginContext( `Declare the events:core capability to listen to core topics.`, ); } - const unsubscribe = pluginEvents.on(topic, listener); + // A throwing listener counts against the plugin's error budget. + const counted: typeof listener = (payload) => { + try { + const result = listener(payload) as unknown; + if ( + result && + typeof (result as Promise).then === "function" + ) { + return (result as Promise).catch((error) => { + void reportRuntimeError(pluginId, error); + throw error; + }); + } + return result as never; + } catch (error) { + void reportRuntimeError(pluginId, error); + throw error; + } + }; + const unsubscribe = pluginEvents.on(topic, counted); handle.bag.add(unsubscribe, `event listener for "${topic}"`); return unsubscribe; }, @@ -502,7 +524,10 @@ export function createPluginContext( }, registry: { + // Keys are namespaced by plugin id, so one plugin cannot replace or + // remove what another provides. Anyone may consume. provide: (key, value) => { + assertOwnRegistryKey(pluginId, key); registry.provide(key, value); handle.bag.add( () => void registry.revoke(key, value), @@ -510,7 +535,10 @@ export function createPluginContext( ); }, consume: (key) => registry.consume(key), - revoke: (key, value) => registry.revoke(key, value), + revoke: (key, value) => { + assertOwnRegistryKey(pluginId, key); + return registry.revoke(key, value); + }, }, services: { @@ -548,8 +576,18 @@ export function createPluginContext( ); }, - get: (service, options) => - serviceRegistry.createServiceHandle( + get: (service, options) => { + // Declared, like getShared: a plugin reaches only the services its + // manifest names in requires, or its own. + const known = + manifest.requires?.some((entry) => entry.service === service) || + manifest.provides?.some((entry) => entry.service === service); + if (!known) { + throw new Error( + `Plugin ${pluginId} cannot use service "${service}": it is not declared in the manifest's requires array`, + ); + } + return serviceRegistry.createServiceHandle( service, pluginId, { @@ -562,7 +600,8 @@ export function createPluginContext( audit: (entry) => writeServiceAudit(entry), }, options?.provider ?? "", - ), + ); + }, providers: (service) => serviceRegistry.listProviderNames(service), }, @@ -709,10 +748,20 @@ export function createPluginContext( if (!declared.includes("network:serve")) { throw capabilityRefused(pluginId, "network:serve"); } + // Only a signed-in caller's socket counts against the error budget, + // so an anonymous peer cannot switch the plugin off. + const counted: typeof wsHandler = async (connection) => { + try { + return await wsHandler(connection); + } catch (error) { + if (connection.userId) void reportRuntimeError(pluginId, error); + throw error; + } + }; const dispose = registerPluginWsRoute( pluginId, path, - wsHandler, + counted, declared, options, ); @@ -840,13 +889,14 @@ export function createPluginContext( hosts: createPluginHosts({ manifest, bag: handle.bag, audit: auditCall }), - schedule: createPluginSchedule(handle.bag, (message, error) => + schedule: createPluginSchedule(handle.bag, (message, error) => { pluginLogger.error( message, error instanceof Error ? error : new Error(String(error)), logContext, - ), - ), + ); + void reportRuntimeError(pluginId, error); + }), ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }), notify: createPluginNotify({ manifest, @@ -864,7 +914,7 @@ export function createPluginContext( desktop: { openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request), - launchNativeRdp: (request) => desktopLaunchNativeRdp(request), + launchExternalClient: (request) => desktopLaunchExternalClient(request), available: () => isElectronIpcAvailable(), }, @@ -909,6 +959,9 @@ export function createPluginContext( `Plugin ${pluginId} called ctx.asUser without a user id`, ); } + if (!declared.includes("users:impersonate")) { + throw capabilityRefused(pluginId, "users:impersonate"); + } await writeAudit( manifest, { @@ -942,6 +995,14 @@ async function reportRuntimeError( } } +function assertOwnRegistryKey(pluginId: string, key: string): void { + if (typeof key !== "string" || !key.startsWith(`${pluginId}.`)) { + throw new Error( + `Plugin ${pluginId} may only use registry keys under "${pluginId}.", not "${key}"`, + ); + } +} + function assertKvKey(key: string): void { if (typeof key !== "string" || key.length === 0) { throw new Error("ctx.kv keys must be non-empty strings"); diff --git a/src/backend/plugins/data.ts b/src/backend/plugins/data.ts index e5f9e367c..b8f101bb7 100644 --- a/src/backend/plugins/data.ts +++ b/src/backend/plugins/data.ts @@ -30,14 +30,19 @@ export function registerTable( definition: PluginTableDefinition, ): unknown { const definitions = registered.get(pluginId) ?? []; - const existing = definitions.find((entry) => entry.name === definition.name); - if (existing) { - // Re-registering on re-activation is normal: the module is cached, so - // activate runs again against the same definitions. - return getTable(pluginId, definition.name); + const index = definitions.findIndex( + (entry) => entry.name === definition.name, + ); + if (index >= 0) { + // Re-registering on re-activation is normal. The same definition keeps + // its table object; a changed one (an updated plugin) is rebuilt. + if (JSON.stringify(definitions[index]) === JSON.stringify(definition)) { + return getTable(pluginId, definition.name); + } + definitions[index] = definition; + } else { + definitions.push(definition); } - - definitions.push(definition); registered.set(pluginId, definitions); const table = buildTable(pluginId, definition); @@ -86,12 +91,65 @@ async function execute(statements: string[]): Promise { } } +/** + * Postgres runs DDL inside a transaction, so one migration file applies + * whole or not at all. Through drizzle's transaction because a pool would + * spread a raw BEGIN and the statements over different connections. + */ +async function executeInTransaction(statements: string[]): Promise { + const { getDb } = await import("../database/db/index.js"); + const db = getDb() as unknown as { + transaction: ( + fn: (tx: { + execute: (query: unknown) => Promise; + }) => Promise, + ) => Promise; + }; + await db.transaction(async (tx) => { + for (const statement of statements) { + await tx.execute(sql.raw(statement)); + } + }); +} + +/** + * SQLite has one connection, so a raw BEGIN covers the statements and the + * migration record together. + */ +async function sqliteTransaction(fn: () => Promise): Promise { + const { runStatement } = + await import("../utils/crypto-migration/raw-rows.js"); + await runStatement(sql.raw("BEGIN")); + try { + await fn(); + await runStatement(sql.raw("COMMIT")); + } catch (error) { + await runStatement(sql.raw("ROLLBACK")).catch(() => {}); + throw error; + } +} + +async function executeNamingFailure(statements: string[]): Promise { + const { runStatement } = + await import("../utils/crypto-migration/raw-rows.js"); + for (const [index, statement] of statements.entries()) { + try { + await runStatement(sql.raw(statement)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `statement ${index + 1} of ${statements.length} failed (earlier ones are already applied on MySQL): ${message}`, + ); + } + } +} + /** * Builds the runner that applies migrations against the live database. * - * No transaction on any engine: MySQL commits implicitly on DDL, and each - * migration is recorded once it has run, so a retry after a failure starts - * at the migration that failed. + * SQLite and Postgres apply each migration file atomically. MySQL commits + * implicitly on DDL, so there a failed file can leave earlier statements + * applied; the error names the statement so it can be fixed by hand. */ async function createMigrationRunner( dialect: DatabaseDialect = resolveDatabaseDialect(), @@ -109,7 +167,13 @@ async function createMigrationRunner( checksum: row.checksum, })); }, - execute, + execute: + dialect === "postgres" + ? executeInTransaction + : dialect === "mysql" + ? executeNamingFailure + : execute, + transaction: dialect === "sqlite" ? sqliteTransaction : undefined, record: async (pluginId, migration) => { await repository.record(pluginId, migration.id, migration.checksum); }, diff --git a/src/backend/plugins/http.ts b/src/backend/plugins/http.ts index c12e716c1..ee5f2eeca 100644 --- a/src/backend/plugins/http.ts +++ b/src/backend/plugins/http.ts @@ -38,6 +38,43 @@ const DEFAULT_BODY_LIMIT = "2mb"; /** Routers by plugin id, read by the dispatcher in plugin-api-routes.ts. */ const routers = new Map(); +/** + * Requests per minute one address may make to one plugin's public routes. + * They run without a login, so they are the cheap way to hammer a plugin. + */ +function publicRateLimit(): number { + const parsed = Number(process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 120; +} +const PUBLIC_RATE_WINDOW_MS = 60_000; +const publicHits = new Map(); + +/** True when this request is over the public route limit. */ +export function overPublicRateLimit( + pluginId: string, + ip: string, + now = Date.now(), +): boolean { + if (publicHits.size > 10_000) { + for (const [hitKey, entry] of publicHits) { + if (entry.resetAt <= now) publicHits.delete(hitKey); + } + } + const hitKey = `${pluginId}|${ip}`; + const entry = publicHits.get(hitKey); + if (!entry || entry.resetAt <= now) { + publicHits.set(hitKey, { count: 1, resetAt: now + PUBLIC_RATE_WINDOW_MS }); + return false; + } + entry.count++; + return entry.count > publicRateLimit(); +} + +/** Test seam. */ +export function resetPublicRateLimits(): void { + publicHits.clear(); +} + /** Declared public paths by plugin id, for the admin plugin details. */ const publicRoutes = new Map(); @@ -151,6 +188,11 @@ export function createPluginRouter({ reportError, }: CreatePluginRouterArgs): Router { const pluginId = manifest.id; + if (routers.has(pluginId)) { + throw new Error( + `Plugin ${pluginId} already has a router; call ctx.http.router() once`, + ); + } const declaredPublic = (options?.public ?? []).map((path) => normalizePublicPath(path), ); @@ -209,6 +251,11 @@ export function createPluginRouter({ outer.use((req: Request, res: Response, next: NextFunction) => { if (isPublicRequest(req, publicPaths)) { + const ip = req.ip || req.socket?.remoteAddress || "unknown"; + if (overPublicRateLimit(pluginId, ip)) { + res.status(429).json({ error: "Too many requests" }); + return; + } pluginLogger.info(`Unauthenticated request to ${pluginId}${req.path}`, { operation: "plugin_http_public_request", }); diff --git a/src/backend/plugins/index.ts b/src/backend/plugins/index.ts index 1a08e2e3f..05cee58c0 100644 --- a/src/backend/plugins/index.ts +++ b/src/backend/plugins/index.ts @@ -29,7 +29,9 @@ let loader: PluginLoader | null = null; export function getPluginRuntime(): { loader: PluginLoader } { if (!loader) { - loader = new PluginLoader(); + loader = new PluginLoader({ + onFailed: (plugin) => void handlePluginFailed(plugin), + }); // The 503-while-disabled answer comes from here rather than from the // router being torn down, so a request that arrives mid-disable gets a // truthful status instead of a 404. @@ -91,6 +93,29 @@ export function getPluginRuntime(): { loader: PluginLoader } { return { loader }; } +/** + * A plugin that tripped the error budget is off until an admin retries it, so + * it has to look off everywhere: no routes, greyed permissions, and a failed + * row that survives a restart. + */ +async function handlePluginFailed(plugin: LoadedPlugin): Promise { + unregisterPluginHttp(plugin.id); + try { + if (plugin.manifest.contributes?.permissions?.length) { + const { markPluginPermissionsDisabled } = + await import("../utils/permission-catalog.js"); + markPluginPermissionsDisabled(plugin.id); + } + await persistRuntimeState([plugin]); + } catch (error) { + pluginLogger.error( + `Could not record that plugin ${plugin.id} failed`, + error instanceof Error ? error : new Error(String(error)), + { operation: "plugin_runtime" }, + ); + } +} + /** * Gives every discovered plugin a row, and refreshes the stored manifest. * @@ -533,13 +558,10 @@ export async function installPluginArtifact( file: string, ): Promise { const { loader: pluginLoader } = getPluginRuntime(); - const bundledIds = new Set( - pluginLoader - .list() - .filter((plugin) => plugin.source === "bundled") - .map((plugin) => plugin.id), + const plugin = await pluginLoader.loadArtifact( + file, + pluginLoader.bundledIds(), ); - const plugin = await pluginLoader.loadArtifact(file, bundledIds); await seedPlugins([plugin]); await syncCapabilityGrants([plugin]); return plugin; diff --git a/src/backend/plugins/loader.ts b/src/backend/plugins/loader.ts index c9d38834e..e8d668711 100644 --- a/src/backend/plugins/loader.ts +++ b/src/backend/plugins/loader.ts @@ -18,7 +18,8 @@ import path from "node:path"; import semver from "semver"; import * as tar from "tar"; import { pluginLogger } from "../utils/logger.js"; -import { parseManifest } from "./manifest.js"; +import { isTermixCompatible, parseManifest } from "./manifest.js"; +import { getLocalVersion } from "../utils/app-version.js"; import type { PluginManifest } from "@termix/plugin-sdk/manifest"; import { getBundledPluginsDir, @@ -67,6 +68,13 @@ export interface LoadedPlugin { lastError: string | null; /** Timestamps of recent runtime errors, trimmed to the window. */ errorTimestamps: number[]; + /** + * The .tmxplug this copy was unpacked from. Set for user plugins and for a + * signed update that replaced a bundled plugin. + */ + artifact?: string; + /** Key id that signed the artifact, when a trusted key signed it. */ + signedBy?: string; } export interface PluginLoaderOptions { @@ -78,6 +86,8 @@ export interface PluginLoaderOptions { export class PluginLoader { private readonly plugins = new Map(); + /** Version of each bundled plugin as shipped, the floor for any update. */ + private readonly bundledVersions = new Map(); /** Activation order, so shutdown can run it backwards. */ private activationOrder: string[] = []; @@ -91,6 +101,16 @@ export class PluginLoader { return this.plugins.get(pluginId); } + /** Ids of the plugins that ship with this install, loaded or replaced. */ + bundledIds(): Set { + return new Set([ + ...this.bundledVersions.keys(), + ...this.list() + .filter((plugin) => plugin.source === "bundled") + .map((plugin) => plugin.id), + ]); + } + /** Drops a stopped plugin from the list, so a new copy can load in its place. */ forget(pluginId: string): void { const plugin = this.plugins.get(pluginId); @@ -122,6 +142,12 @@ export class PluginLoader { ); } + if (!isTermixCompatible(manifest.engine.termix, getLocalVersion())) { + throw new Error( + `Plugin ${manifest.id} needs Termix ${manifest.engine.termix}, but this is ${getLocalVersion()}`, + ); + } + if (path.basename(dir) !== manifest.id) { throw new Error( `Plugin directory "${path.basename(dir)}" does not match manifest id "${manifest.id}"`, @@ -141,6 +167,10 @@ export class PluginLoader { ); } + if (this.plugins.has(manifest.id)) { + throw new Error(`another plugin already uses the id "${manifest.id}"`); + } + const overlap = [...this.plugins.values()].find((other) => prefixesOverlap(other.id, manifest.id), ); @@ -175,16 +205,16 @@ export class PluginLoader { } /** - * Scans both roots. A user-installed plugin can never shadow a bundled one: - * the collision is rejected with an error rather than silently skipped, so - * dropping a folder with a bundled plugin's id into the data directory is visibly - * refused instead of quietly ignored. + * Scans both roots. A user plugin folder can never shadow a bundled one: the + * collision is rejected with an error rather than silently skipped. * - * The check is on the directory name, which load() then requires to equal - * the manifest id, and it covers bundled folders that failed to load too. + * The one way to replace a bundled plugin is a .tmxplug signed by a trusted + * key with a higher version (see loadArtifact). That is how one official + * plugin ships an update without a core release. */ async loadAll(): Promise { this.plugins.clear(); + this.bundledVersions.clear(); const loaded: LoadedPlugin[] = []; // Every bundled folder name, loaded or not: a bundled plugin that fails // to load must not leave its id free for a user plugin to take. @@ -216,13 +246,16 @@ export class PluginLoader { const dir = path.join(root, entry.name); try { if (isArtifact) { - loaded.push(await this.loadArtifact(dir, bundledIds)); + const plugin = await this.loadArtifact(dir, bundledIds); + const index = loaded.findIndex((other) => other.id === plugin.id); + if (index >= 0) loaded[index] = plugin; + else loaded.push(plugin); continue; } if (source === "user" && bundledIds.has(entry.name)) { throw new Error( - `a bundled plugin already uses the id "${entry.name}"; a plugin in the data directory cannot replace it`, + `a bundled plugin already uses the id "${entry.name}"; only a signed .tmxplug with a higher version can replace it`, ); } @@ -236,6 +269,9 @@ export class PluginLoader { } const plugin = await this.load(dir, source); + if (source === "bundled") { + this.bundledVersions.set(plugin.id, plugin.manifest.version); + } loaded.push(plugin); } catch (error) { pluginLogger.error( @@ -255,6 +291,10 @@ export class PluginLoader { * against a pinned key; with TERMIX_REQUIRE_SIGNED_PLUGINS=true it must * also exist. The archive is unpacked fresh on every load, so the file * stays the only source of truth. + * + * An artifact with a bundled plugin's id is an update to it. It replaces + * the bundled copy only when a trusted key signed it and its version is + * higher; if it then fails to load the bundled copy stays. */ async loadArtifact( file: string, @@ -262,6 +302,7 @@ export class PluginLoader { ): Promise { const buffer = await fs.promises.readFile(file); const sigFile = `${file}.sig`; + let signedBy: string | undefined; if (fs.existsSync(sigFile)) { const sha256 = crypto.createHash("sha256").update(buffer).digest("hex"); @@ -273,6 +314,7 @@ export class PluginLoader { if (result.ok === false) { throw new Error(`signature check failed: ${result.reason}`); } + signedBy = result.keyId; } else if (requireSignedPlugins()) { throw new Error( `TERMIX_REQUIRE_SIGNED_PLUGINS is on and ${path.basename(file)} has no .sig next to it`, @@ -301,22 +343,47 @@ export class PluginLoader { await fs.promises.readFile(getPluginManifestPath(staging), "utf8"), ); const id = typeof raw?.id === "string" ? raw.id : ""; - if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) { + if (!PLUGIN_ID_PATTERN.test(id)) { throw new Error(`${path.basename(file)} has no valid manifest id`); } - if (bundledIds.has(id)) { - throw new Error( - `a bundled plugin already uses the id "${id}"; a plugin in the data directory cannot replace it`, + + const isUpdate = bundledIds.has(id); + const replaced = isUpdate ? this.plugins.get(id) : undefined; + if (isUpdate) { + assertBundledUpdate( + id, + raw?.version, + signedBy, + this.bundledVersions.get(id) ?? replaced?.manifest.version, ); - } - if (this.plugins.has(id)) { + } else if (this.plugins.has(id)) { throw new Error(`another plugin already uses the id "${id}"`); } + if (replaced && replaced.state !== "loaded") { + throw new Error(`plugin ${id} is running; stop it before updating`); + } const target = path.join(unpackedRoot, id); await fs.promises.rm(target, { recursive: true, force: true }); await fs.promises.rename(staging, target); - return await this.load(target, "user"); + + if (replaced) this.plugins.delete(id); + try { + // A signed update keeps the bundled trust it replaces. + const plugin = await this.load(target, isUpdate ? "bundled" : "user"); + plugin.artifact = file; + plugin.signedBy = signedBy; + if (replaced) { + pluginLogger.info( + `Plugin ${id} updated from ${replaced.manifest.version} to ${plugin.manifest.version}`, + { operation: "plugin_load" }, + ); + } + return plugin; + } catch (error) { + if (replaced) this.plugins.set(id, replaced); + throw error; + } } finally { await fs.promises.rm(staging, { recursive: true, force: true }); } @@ -427,9 +494,12 @@ export class PluginLoader { plugin.state = "activating"; plugin.lastError = null; - const entry = pathToFileUrlString( - getPluginBackendEntry(plugin.dir, plugin.manifest), - ); + const entryPath = getPluginBackendEntry(plugin.dir, plugin.manifest); + // Node caches ESM by URL, so a plugin replaced without a restart would + // otherwise keep running the old code. + const entry = `${pathToFileUrlString(entryPath)}?v=${encodeURIComponent( + `${plugin.manifest.version}-${fileStamp(entryPath)}`, + )}`; try { const imported = (await import(entry)) as { @@ -639,27 +709,12 @@ export class PluginLoader { this.options.onFailed?.(plugin); } - /** - * Wraps a plugin callback so a throw is counted rather than escaping into - * whichever core call site invoked it. - */ - wrap( - pluginId: string, - fn: (...args: Args) => Result | Promise, - ): (...args: Args) => Promise { - return async (...args: Args) => { - try { - return await fn(...args); - } catch (error) { - await this.reportError(pluginId, error); - return undefined; - } - }; - } - /** Clears the error budget and starts the plugin again. */ async retry(pluginId: string): Promise { const plugin = this.requirePlugin(pluginId); + if (plugin.state === "activating") { + throw new Error(`Plugin ${pluginId} is still activating`); + } plugin.errorTimestamps = []; plugin.lastError = null; plugin.state = "loaded"; @@ -709,6 +764,43 @@ function withTimeout( }); } +const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; + +/** + * Throws unless an artifact may replace the bundled plugin with the same id: + * a trusted key signed it and its version is strictly higher, so an old + * signed build can never be replayed as a downgrade. + */ +export function assertBundledUpdate( + id: string, + version: unknown, + signedBy: string | undefined, + bundledVersion: string | undefined, +): void { + if (!signedBy) { + throw new Error( + `a bundled plugin already uses the id "${id}"; only a signed update can replace it`, + ); + } + if (typeof version !== "string" || !semver.valid(version)) { + throw new Error(`update for "${id}" has no valid version`); + } + if (bundledVersion && !semver.gt(version, bundledVersion)) { + throw new Error( + `update for "${id}" is ${version}, which is not newer than the bundled ${bundledVersion}`, + ); + } +} + +function fileStamp(filePath: string): string { + try { + const stat = fs.statSync(filePath); + return `${stat.size}-${Math.round(stat.mtimeMs)}`; + } catch { + return "0"; + } +} + function pathToFileUrlString(filePath: string): string { const resolved = path.resolve(filePath).replace(/\\/g, "/"); return `file://${resolved.startsWith("/") ? "" : "/"}${resolved}`; diff --git a/src/backend/plugins/manifest.ts b/src/backend/plugins/manifest.ts index ca97b80d2..7b099e694 100644 --- a/src/backend/plugins/manifest.ts +++ b/src/backend/plugins/manifest.ts @@ -11,6 +11,9 @@ export { validateManifest, parseManifest, SUPPORTED_PLUGIN_API_VERSION, + PLUGIN_API_VERSION, + isApiCompatible, + isTermixCompatible, SYSTEM_ROLE_NAMES, RESERVED_PERMISSION_PREFIXES, qualifyPermission, diff --git a/src/backend/plugins/migrations.ts b/src/backend/plugins/migrations.ts index 51f2dddee..120035662 100644 --- a/src/backend/plugins/migrations.ts +++ b/src/backend/plugins/migrations.ts @@ -20,6 +20,7 @@ import { pluginLogger } from "../utils/logger.js"; import type { DatabaseDialect } from "../database/db/dialect.js"; import { LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db"; import { + collectOwnedIndexes, findUnownedTableWrites, splitStatements, } from "@termix/plugin-sdk/ddl"; @@ -28,9 +29,6 @@ import { // migration exactly the way this runner does. export { LEGACY_TABLE_OWNERS, splitStatements }; -/** Dialect directory names, matching DatabaseDialect exactly. */ -const DIALECTS: readonly DatabaseDialect[] = ["sqlite", "postgres", "mysql"]; - const FILE_PATTERN = /^(\d{4})_([a-z0-9_]+)\.sql$/; export interface PluginMigration { @@ -120,7 +118,7 @@ export function readMigrations( export function assertOwnedTables( pluginId: string, sql: string, - options: { bundled?: boolean } = {}, + options: { bundled?: boolean; ownedIndexes?: ReadonlySet } = {}, ): void { const legacy = new Set( options.bundled @@ -129,16 +127,19 @@ export function assertOwnedTables( .map(([table]) => table) : [], ); - const problems = findUnownedTableWrites(pluginId, sql, legacy).map( - (problem) => { - // Name the owner when the table is another plugin's legacy table. - const table = /writes to "([^"]+)"/.exec(problem)?.[1]; - const owner = table ? LEGACY_TABLE_OWNERS[table] : undefined; - return owner && owner !== pluginId - ? `may not touch "${table}": it belongs to the "${owner}" plugin` - : problem; - }, - ); + const problems = findUnownedTableWrites( + pluginId, + sql, + legacy, + options.ownedIndexes, + ).map((problem) => { + // Name the owner when the table is another plugin's legacy table. + const table = /writes to "([^"]+)"/.exec(problem)?.[1]; + const owner = table ? LEGACY_TABLE_OWNERS[table] : undefined; + return owner && owner !== pluginId + ? `may not touch "${table}": it belongs to the "${owner}" plugin` + : problem; + }); if (problems.length > 0) { throw new Error( `Plugin "${pluginId}" migration refused: ${problems.join("; ")}`, @@ -197,9 +198,13 @@ export async function applyPluginMigrations( if (pending.length === 0) return []; + const ownedIndexes = collectOwnedIndexes( + pluginId, + migrations.map((migration) => migration.sql), + ); const run = async () => { for (const migration of pending) { - assertOwnedTables(pluginId, migration.sql, options); + assertOwnedTables(pluginId, migration.sql, { ...options, ownedIndexes }); const statements = splitStatements(migration.sql); await runner.execute(statements); await runner.record(pluginId, migration); diff --git a/src/backend/plugins/registry.ts b/src/backend/plugins/registry.ts index fc5f65840..050f57511 100644 --- a/src/backend/plugins/registry.ts +++ b/src/backend/plugins/registry.ts @@ -1,19 +1,11 @@ /** - * A small main-thread service registry. + * A small in-process key/value registry for live objects plugins hand each + * other (a guest-link resolver, a host import normalizer). Services with + * versions and permissions go through service-registry.ts instead. * - * This exists so a module can depend on a capability without depending on - * whichever module happens to provide it. Two uses today: - * - * - core registers the SSH connection pool as "ssh.transport" at boot, so - * everything that needs a pooled connection resolves a provider rather - * than importing the pool directly. - * - the session-sharing plugin publishes "sessions.sharing.guests", guest - * link resolution for the terminal, keyed by a token rather than a user. - * - * Deliberately NOT reachable from a worker plugin's ctx. Values here are live - * objects -- pools, session managers, sockets -- and handing one across the - * postMessage boundary is exactly what that boundary exists to prevent. Only - * in-process first-party plugins get a reference to this. + * ctx.registry only lets a plugin provide or revoke keys under its own id + * (".something"); anyone may consume. Core also reads a few + * well-known per-plugin keys, such as ".hostImportNormalizer". * * `consume` returns undefined rather than throwing when nothing is registered. * A provider can disappear when its plugin is disabled, and callers are diff --git a/src/backend/plugins/user-data.ts b/src/backend/plugins/user-data.ts index 691f651f1..e08af2db9 100644 --- a/src/backend/plugins/user-data.ts +++ b/src/backend/plugins/user-data.ts @@ -23,6 +23,10 @@ export interface UserOwnedTable { /** The legacy core name the table adopted, for older export files. */ legacyName?: string; userColumn: string; + /** Other refUser columns, such as a grantee or the user who shared. */ + otherUserColumns: string[]; + /** refHost columns. */ + hostColumns: string[]; /** Columns that travel, as SQL names. The primary key and secrets do not. */ columns: string[]; definition: PluginTableDefinition; @@ -39,6 +43,13 @@ export function listUserOwnedTables(): UserOwnedTable[] { table: prefixedTableName(pluginId, definition.name), legacyName: definition.adopts, userColumn: columnName(user[0], user[1]), + otherUserColumns: entries + .filter(([, column]) => column.type === "refUser") + .slice(1) + .map(([property, column]) => columnName(property, column)), + hostColumns: entries + .filter(([, column]) => column.type === "refHost") + .map(([property, column]) => columnName(property, column)), columns: entries .filter( ([, column]) => @@ -130,10 +141,40 @@ export interface PluginRowsImport { errors: string[]; } +/** + * Whether a row from an import file may be written as `userId`. The file is + * user input, so a reference to anyone else's user or host is not trusted: + * other user columns must point back at the row's own owner, and host columns + * at a host the importing user owns. + */ +async function rowReferencesAllowed( + owned: UserOwnedTable, + source: Row, + userId: string, +): Promise { + const owner = source[owned.userColumn]; + for (const column of owned.otherUserColumns) { + const value = source[column]; + if (value !== null && value !== undefined && value !== owner) return false; + } + for (const column of owned.hostColumns) { + const value = source[column]; + if (value === null || value === undefined) continue; + const rows = await selectRows( + sql`SELECT 1 FROM ${sql.identifier("ssh_data")} WHERE ${sql.identifier( + "id", + )} = ${value} AND ${sql.identifier("user_id")} = ${userId} LIMIT 1`, + ); + if (rows.length === 0) return false; + } + return true; +} + /** * Copies plugin rows from an import file into this server as `userId`. A row - * matching one the user already has is skipped. Files from before 2.9 carry - * the legacy table names, which are read too. + * matching one the user already has is skipped, and so is one that points at + * another user or at a host the importer does not own. Files from before 2.9 + * carry the legacy table names, which are read too. */ export async function importUserPluginRows( importDb: SqliteLike, @@ -157,10 +198,18 @@ export async function importUserPluginRows( const columns = owned.columns.filter( (column) => column === owned.userColumn || source[column] !== undefined, ); + const owner = source[owned.userColumn]; const values = columns.map((column) => - column === owned.userColumn ? userId : source[column], + column === owned.userColumn || + (owned.otherUserColumns.includes(column) && source[column] === owner) + ? userId + : source[column], ); try { + if (!(await rowReferencesAllowed(owned, source, userId))) { + summary.skipped++; + continue; + } const existing = await selectRows( sql`SELECT 1 FROM ${sql.identifier(owned.table)} WHERE ${sql.join( columns.map((column, index) => diff --git a/src/backend/plugins/ws.ts b/src/backend/plugins/ws.ts index 7dde054c6..29e4f00dc 100644 --- a/src/backend/plugins/ws.ts +++ b/src/backend/plugins/ws.ts @@ -49,8 +49,13 @@ interface Route { declared: readonly string[]; handler?: PluginWebSocketHandler; rawHandler?: RawUpgradeHandler; + /** Sockets handed to rawHandler, destroyed when the route goes away. */ + rawSockets?: Set; } +/** Largest frame a plugin socket accepts. ws defaults to 100MB. */ +const MAX_PAYLOAD_BYTES = 64 * 1024 * 1024; + /** Keyed by ":". */ const routes = new Map(); @@ -177,6 +182,11 @@ async function handlePluginUpgrade( if (route.rawHandler) { const rawHandler = route.rawHandler; + const tracked = route.rawSockets; + if (tracked) { + tracked.add(socket); + socket.once("close", () => tracked.delete(socket)); + } asCaller(() => rawHandler(request, socket, head, userId)); return true; } @@ -270,7 +280,10 @@ export function registerPluginWsRoute( handler, }); if (options.public) auditPublicSocket(pluginId, normalized); - servers.set(routeKey, new WebSocketServer({ noServer: true })); + servers.set( + routeKey, + new WebSocketServer({ noServer: true, maxPayload: MAX_PAYLOAD_BYTES }), + ); pluginLogger.info(`Mounted /plugin-ws/${pluginId}${normalized}`, { operation: "plugin_ws_mount", @@ -301,6 +314,7 @@ export function registerPluginWsUpgrade( options, declared, rawHandler: handler, + rawSockets: new Set(), }); if (options.public) auditPublicSocket(pluginId, normalized); @@ -312,8 +326,18 @@ export function registerPluginWsUpgrade( } function disposeRoute(routeKey: string, pluginId: string, path: string): void { + const route = routes.get(routeKey); routes.delete(routeKey); + for (const socket of route?.rawSockets ?? []) { + try { + socket.destroy(); + } catch { + // Already gone. + } + } + route?.rawSockets?.clear(); + const server = servers.get(routeKey); if (server) { servers.delete(routeKey); diff --git a/src/backend/sync/client/plugins.ts b/src/backend/sync/client/plugins.ts index 204658959..8a77cc6b9 100644 --- a/src/backend/sync/client/plugins.ts +++ b/src/backend/sync/client/plugins.ts @@ -32,6 +32,8 @@ export interface RemotePlugin { name: string; version: string; source: "bundled" | "user"; + /** The server can send a signed .tmxplug for it. */ + artifact?: boolean; enabled: boolean; active: boolean; desktop: PluginDesktopMode; @@ -52,6 +54,8 @@ async function isEnabledLocally(pluginId: string): Promise { return record?.state === "enabled"; } +const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; + async function download(link: SyncLink, plugin: RemotePlugin): Promise { const response = await remoteFetch( link, @@ -83,6 +87,9 @@ async function matchGrants(plugin: RemotePlugin): Promise { const repository = createCurrentPluginPermissionGrantRepository(); const local = getPluginRuntime().loader.get(plugin.id); if (!local || local.source !== "user") return; + // Grants are only copied for code a trusted key signed. Anything else the + // desktop's own admin has to review, as with any plugin dropped on disk. + if (!local.signedBy) return; const declared = new Set(local.manifest.capabilities); const wanted = new Set(plugin.granted.filter((cap) => declared.has(cap))); const existing = await repository.listByPlugin(plugin.id); @@ -117,7 +124,25 @@ async function alertOlderDesktop(plugin: RemotePlugin): Promise { }); } +async function alertUnsigned(plugin: RemotePlugin): Promise { + const key = `unsigned:${plugin.id}@${plugin.version}`; + if (alertedVersions.has(key)) return; + alertedVersions.add(key); + await sendCoreAlert({ + title: "Review a feature from your server", + body: `${plugin.name} (${plugin.version}) was copied from your server but is not signed, so it stays off here until you turn it on.`, + severity: "warning", + category: "termix.sync.unsigned_plugin", + dedupeKey: `sync:${key}`, + audience: "admins", + }); +} + async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise { + // The id becomes a file name below, so it must be a real plugin id. + if (typeof plugin.id !== "string" || !PLUGIN_ID_PATTERN.test(plugin.id)) { + throw new Error("The server sent a plugin with an invalid id"); + } const { loader } = getPluginRuntime(); let local = loader.get(plugin.id); const mode = local?.manifest.desktop ?? plugin.desktop ?? "mirror"; @@ -131,7 +156,20 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise { if (mode !== "mirror") return; if (plugin.source === "bundled") { - if (!local || semver.gt(plugin.version, local.manifest.version)) { + const newer = !local || semver.gt(plugin.version, local.manifest.version); + if (newer && plugin.artifact && local?.state !== "activating") { + // A signed update of a bundled plugin. The loader checks the signature + // and that the version really is newer. + const wasEnabled = await isEnabledLocally(plugin.id); + if (local) await unloadPlugin(plugin.id); + try { + local = await installPluginArtifact(await download(link, plugin)); + } catch (error) { + await alertOlderDesktop(plugin); + throw error; + } + if (wasEnabled) await setPluginEnabled(plugin.id, true); + } else if (newer) { await alertOlderDesktop(plugin); } if (!local) return; @@ -150,6 +188,16 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise { } } await matchGrants(plugin); + // Unsigned code is only ever switched off from here, never on. + if (!local.signedBy) { + const enabledHere = await isEnabledLocally(plugin.id); + if (!plugin.enabled && enabledHere) { + await setPluginEnabled(plugin.id, false); + } else if (plugin.enabled && !enabledHere) { + await alertUnsigned(plugin); + } + return; + } } if (!local) return; diff --git a/src/backend/sync/server/routes.ts b/src/backend/sync/server/routes.ts index 2577021ed..a6cfa5c0e 100644 --- a/src/backend/sync/server/routes.ts +++ b/src/backend/sync/server/routes.ts @@ -493,6 +493,8 @@ router.get( name: plugin.manifest.name, version: plugin.manifest.version, source: plugin.source, + // A bundled plugin updated from a signed .tmxplug can be mirrored. + artifact: !!plugin.artifact, enabled: records.get(plugin.id)?.state === "enabled", active: plugin.state === "active", desktop: plugin.manifest.desktop ?? "mirror", @@ -517,7 +519,7 @@ router.get( * /sync/v2/plugins/{id}/package: * get: * summary: Download an installed plugin so a linked desktop can install it - * description: Only for plugins installed on this server, not bundled ones. Sends the original .tmxplug when there is one, with its signature in the X-Termix-Signature header, otherwise a fresh archive of the plugin folder. + * description: Only for plugins installed on this server, or bundled ones replaced by a signed update. Sends the original .tmxplug when there is one, with its signature in the X-Termix-Signature header, otherwise a fresh archive of the plugin folder. * tags: * - Sync * parameters: @@ -541,11 +543,10 @@ router.get( const plugin = (await listPluginsForDesktop()).find( (candidate) => candidate.id === pluginId, ); - if (!plugin || plugin.source !== "user") { + if (!plugin || (plugin.source !== "user" && !plugin.artifact)) { return res.status(404).json({ error: "Plugin not found" }); } - const { getPluginsDir } = await import("../../plugins/paths.js"); - const artifact = path.join(getPluginsDir(), `${pluginId}.tmxplug`); + const artifact = plugin.artifact ?? ""; res.setHeader("Content-Type", "application/gzip"); if (fs.existsSync(artifact)) { const sig = `${artifact}.sig`; diff --git a/src/backend/tests/auth/auth-test-helpers.ts b/src/backend/tests/auth/auth-test-helpers.ts index 8681d7757..b56bcf3b0 100644 --- a/src/backend/tests/auth/auth-test-helpers.ts +++ b/src/backend/tests/auth/auth-test-helpers.ts @@ -43,7 +43,7 @@ export function fakeFactory(state: AuthState) { findById: async (id: string) => state.users.get(id) ?? null, findByUsername: async (username: string) => [...state.users.values()].find((u) => u.username === username) ?? null, - findByOidcIdentifier: async (identifier: string) => + findByExternalIdentifier: async (identifier: string) => [...state.users.values()].find( (u) => u.oidcIdentifier === identifier, ) ?? null, @@ -206,10 +206,10 @@ export function fakeAuthManager(state: AuthState) { return userId ? { userId, pendingTOTP: true } : null; }), authenticateUser: vi.fn(async () => true), - authenticateOIDCUser: vi.fn(async () => true), - revokeSessionsByOidc: vi.fn(async () => 1), + authenticateExternalUser: vi.fn(async () => true), + revokeSessionsByExternalSession: vi.fn(async () => 1), unlockWithSystemKey: vi.fn(async () => true), - registerOIDCUser: vi.fn(async () => {}), + registerExternalUser: vi.fn(async () => {}), isTrustedDevice: vi.fn(async (userId: string, fingerprint: string) => state.trusted.has(`${userId}:${fingerprint}`), ), diff --git a/src/backend/tests/database/repositories/plugin-install-count-repository.test.ts b/src/backend/tests/database/repositories/plugin-install-count-repository.test.ts deleted file mode 100644 index 77b1d9bca..000000000 --- a/src/backend/tests/database/repositories/plugin-install-count-repository.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { TestSqliteDatabase } from "./test-support.js"; -import { PluginInstallCountRepository } from "../../../database/repositories/plugin-install-count-repository.js"; - -describe("PluginInstallCountRepository", () => { - let adapter: TestSqliteDatabase | null = null; - - afterEach(async () => { - if (adapter) { - await adapter.close(); - adapter = null; - } - }); - - async function createRepository( - onWrite?: () => void | Promise, - ): Promise { - adapter = new TestSqliteDatabase(); - const context = await adapter.connect(); - return new PluginInstallCountRepository(context, onWrite); - } - - it("inserts a count on first upsert", async () => { - let writes = 0; - const repo = await createRepository(() => { - writes += 1; - }); - - const created = await repo.upsert({ - pluginId: "plugin-a", - registryId: "registry-official", - count: 42, - source: "github-releases", - }); - - expect(created).toMatchObject({ - pluginId: "plugin-a", - registryId: "registry-official", - count: 42, - source: "github-releases", - }); - expect(writes).toBe(1); - }); - - it("updates the existing row on a repeat upsert for the same plugin/registry pair", async () => { - let writes = 0; - const repo = await createRepository(() => { - writes += 1; - }); - - const first = await repo.upsert({ - pluginId: "plugin-a", - registryId: "registry-official", - count: 10, - }); - - const second = await repo.upsert({ - pluginId: "plugin-a", - registryId: "registry-official", - count: 25, - }); - - expect(second.id).toBe(first.id); - expect(second.count).toBe(25); - expect(writes).toBe(2); - - const rows = await repo.listByPlugin("plugin-a"); - expect(rows).toHaveLength(1); - }); - - it("keeps counts from different registries for the same plugin separate", async () => { - const repo = await createRepository(); - - await repo.upsert({ - pluginId: "plugin-a", - registryId: "registry-official", - count: 10, - }); - await repo.upsert({ - pluginId: "plugin-a", - registryId: "registry-community", - count: 5, - }); - - const rows = await repo.listByPlugin("plugin-a"); - expect(rows.map((r) => r.registryId).sort()).toEqual([ - "registry-community", - "registry-official", - ]); - - expect( - await repo.findByPluginAndRegistry("plugin-a", "registry-official"), - ).toMatchObject({ count: 10 }); - expect( - await repo.findByPluginAndRegistry("plugin-a", "missing-registry"), - ).toBeNull(); - }); - - it("deletes all counts for a plugin", async () => { - const repo = await createRepository(); - - await repo.upsert({ - pluginId: "plugin-a", - registryId: "registry-official", - count: 10, - }); - await repo.upsert({ - pluginId: "plugin-a", - registryId: "registry-community", - count: 5, - }); - - expect(await repo.deleteByPlugin("plugin-a")).toBe(2); - expect(await repo.listByPlugin("plugin-a")).toEqual([]); - expect(await repo.deleteByPlugin("plugin-a")).toBe(0); - }); -}); diff --git a/src/backend/tests/database/repositories/plugin-registry-repository.test.ts b/src/backend/tests/database/repositories/plugin-registry-repository.test.ts deleted file mode 100644 index ae1abbf70..000000000 --- a/src/backend/tests/database/repositories/plugin-registry-repository.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { TestSqliteDatabase } from "./test-support.js"; -import { PluginRegistryRepository } from "../../../database/repositories/plugin-registry-repository.js"; - -describe("PluginRegistryRepository", () => { - let adapter: TestSqliteDatabase | null = null; - - afterEach(async () => { - if (adapter) { - await adapter.close(); - adapter = null; - } - }); - - async function createRepository( - onWrite?: () => void | Promise, - ): Promise { - adapter = new TestSqliteDatabase(); - const context = await adapter.connect(); - return new PluginRegistryRepository(context, onWrite); - } - - it("creates, finds, and lists registries", async () => { - const repo = await createRepository(); - - await repo.create({ - id: "registry-official", - name: "Official Registry", - url: "https://plugins.termix.site/index.json", - kind: "official", - }); - await repo.create({ - id: "registry-custom", - name: "Custom Registry", - url: "https://example.com/index.json", - }); - - const found = await repo.findById("registry-official"); - expect(found).toMatchObject({ - id: "registry-official", - name: "Official Registry", - kind: "official", - enabled: true, - }); - - const custom = await repo.findById("registry-custom"); - expect(custom).toMatchObject({ kind: "community", enabled: true }); - - const all = await repo.listAll(); - expect(all.map((r) => r.id).sort()).toEqual([ - "registry-custom", - "registry-official", - ]); - expect(await repo.findById("missing")).toBeNull(); - }); - - it("updates only the provided fields", async () => { - let writes = 0; - const repo = await createRepository(() => { - writes += 1; - }); - - await repo.create({ - id: "registry-custom", - name: "Custom Registry", - url: "https://example.com/index.json", - }); - expect(writes).toBe(1); - - const updated = await repo.update("registry-custom", { - enabled: false, - lastCheckedAt: "2026-01-01T00:00:00.000Z", - lastIndexHash: "abc123", - }); - - expect(updated).toMatchObject({ - id: "registry-custom", - name: "Custom Registry", - enabled: false, - lastCheckedAt: "2026-01-01T00:00:00.000Z", - lastIndexHash: "abc123", - }); - expect(writes).toBe(2); - - expect(await repo.update("missing", { enabled: false })).toBeNull(); - expect(writes).toBe(2); - }); - - it("deletes a registry and reports whether a row was removed", async () => { - let writes = 0; - const repo = await createRepository(() => { - writes += 1; - }); - - await repo.create({ - id: "registry-custom", - name: "Custom Registry", - url: "https://example.com/index.json", - }); - expect(writes).toBe(1); - - expect(await repo.delete("missing")).toBe(false); - expect(writes).toBe(1); - - expect(await repo.delete("registry-custom")).toBe(true); - expect(writes).toBe(2); - expect(await repo.findById("registry-custom")).toBeNull(); - }); - - it("deletes all registries and returns the count removed", async () => { - const repo = await createRepository(); - - await repo.create({ - id: "registry-a", - name: "A", - url: "https://a.example.com/index.json", - }); - await repo.create({ - id: "registry-b", - name: "B", - url: "https://b.example.com/index.json", - }); - - expect(await repo.deleteAll()).toBe(2); - expect(await repo.listAll()).toEqual([]); - expect(await repo.deleteAll()).toBe(0); - }); -}); diff --git a/src/backend/tests/database/repositories/user-session-repositories.test.ts b/src/backend/tests/database/repositories/user-session-repositories.test.ts index f834c3884..c8c2d11f1 100644 --- a/src/backend/tests/database/repositories/user-session-repositories.test.ts +++ b/src/backend/tests/database/repositories/user-session-repositories.test.ts @@ -67,7 +67,7 @@ describe("UserRepository and SessionRepository", () => { oidcIdentifier: "oidc:admin", }); expect(updated?.oidcIdentifier).toBe("oidc:admin"); - expect((await repo.users.findByOidcIdentifier("oidc:admin"))?.id).toBe( + expect((await repo.users.findByExternalIdentifier("oidc:admin"))?.id).toBe( "user-1", ); diff --git a/src/backend/tests/database/routes/user-oidc-account-routes.test.ts b/src/backend/tests/database/routes/user-external-account-routes.test.ts similarity index 82% rename from src/backend/tests/database/routes/user-oidc-account-routes.test.ts rename to src/backend/tests/database/routes/user-external-account-routes.test.ts index 606960a30..19f9fad77 100644 --- a/src/backend/tests/database/routes/user-oidc-account-routes.test.ts +++ b/src/backend/tests/database/routes/user-external-account-routes.test.ts @@ -40,6 +40,8 @@ vi.mock("../../../database/repositories/factory.js", () => ({ }, }), createCurrentUserAuthRepository: () => ({ + listIdentitiesForUser: async (userId: string) => + h.identities.filter((row) => row.userId === userId), moveIdentities: async (from: string, to: string) => { for (const row of h.identities) if (row.userId === from) row.userId = to; }, @@ -68,8 +70,8 @@ vi.mock("../../../utils/logger.js", () => { return { authLogger: log }; }); -const { registerUserOidcAccountRoutes } = - await import("../../../database/routes/user-oidc-account-routes.js"); +const { registerUserExternalAccountRoutes } = + await import("../../../database/routes/user-external-account-routes.js"); let server: http.Server; let base: string; @@ -114,7 +116,7 @@ beforeEach(async () => { const router = express.Router(); router.use(express.json()); - registerUserOidcAccountRoutes(router, { + registerUserExternalAccountRoutes(router, { authenticateJWT: (req, _res, next) => { (req as unknown as { userId: string }).userId = req.header("x-user") ?? ""; @@ -164,6 +166,24 @@ describe("linking an external account to a password account", () => { expect(h.deleted).toEqual(["sso"]); }); + it("takes the new path and field name", async () => { + const response = await post("/link-external-to-password", { + externalUserId: "sso", + targetUsername: "alice", + }); + expect(response.status).toBe(200); + expect(h.identities[0].userId).toBe("local"); + }); + + it("refuses a target that already signs in externally", async () => { + h.identities.push({ userId: "local", providerId: "ldap:4", subject: "x" }); + const response = await post("/link-external-to-password", { + externalUserId: "sso", + targetUsername: "alice", + }); + expect(response.status).toBe(400); + }); + it("is for admins only", async () => { const response = await post( "/link-oidc-to-password", @@ -200,6 +220,18 @@ describe("unlinking", () => { expect(h.identities).toEqual([]); }); + it("takes the new path", async () => { + await post("/link-external-to-password", { + externalUserId: "sso", + targetUsername: "alice", + }); + const response = await post("/unlink-external-from-password", { + userId: "local", + }); + expect(response.status).toBe(200); + expect(h.identities).toEqual([]); + }); + it("refuses to leave an account with no way in", async () => { const response = await post("/unlink-oidc-from-password", { userId: "sso", diff --git a/src/backend/tests/database/routes/user-preferences.test.ts b/src/backend/tests/database/routes/user-preferences.test.ts deleted file mode 100644 index 1ee4d38db..000000000 --- a/src/backend/tests/database/routes/user-preferences.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { validateDefaultsJson } from "../../../database/routes/user-preferences.js"; - -describe("user connection defaults validation", () => { - it("accepts JSON objects", () => { - expect(validateDefaultsJson('{"fontSize":16}')).toBe(true); - expect(validateDefaultsJson("{}")).toBe(true); - }); - - it("rejects malformed JSON and non-object values", () => { - expect(validateDefaultsJson("{")).toBe(false); - expect(validateDefaultsJson("null")).toBe(false); - expect(validateDefaultsJson("[]")).toBe(false); - }); - - it("rejects payloads larger than 32 KiB", () => { - expect( - validateDefaultsJson(JSON.stringify({ value: "x".repeat(32_768) })), - ).toBe(false); - }); -}); diff --git a/src/backend/tests/electron/local-files.test.ts b/src/backend/tests/electron/local-files.test.ts index 0887b7c93..5999ad547 100644 --- a/src/backend/tests/electron/local-files.test.ts +++ b/src/backend/tests/electron/local-files.test.ts @@ -165,6 +165,7 @@ function startBackend( describe("local-files transfer target resolution", () => { const resolve = localFiles.createTargetResolver({ localBaseUrl: "http://127.0.0.1:30001/plugin-api/file-manager", + apiPath: "/plugin-api/file-manager", getLinkedServer: () => ({ serverUrl: "https://termix.example.com/", token: "remote-jwt", @@ -1149,3 +1150,17 @@ describe("local-files upload boundary", () => { expect(result.error).toMatch(/Unknown transfer origin/); }); }); + +describe("local-files transfer API path", () => { + it("accepts only a /plugin-api/ path from the plugin", () => { + const set = ( + localFiles as unknown as { + setTransferApiPath: (path: unknown) => void; + } + ).setTransferApiPath; + expect(() => set("/plugin-api/file-manager")).not.toThrow(); + for (const bad of ["/users", "/plugin-api/../users", "https://x", 3]) { + expect(() => set(bad)).toThrow(/Invalid transfer API path/); + } + }); +}); diff --git a/src/backend/tests/hosts/credential-username.test.ts b/src/backend/tests/hosts/credential-username.test.ts index ac408bd96..82acfa4ad 100644 --- a/src/backend/tests/hosts/credential-username.test.ts +++ b/src/backend/tests/hosts/credential-username.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { pickResolvedUsername, pickResolvedPassword, - expandOidcUsername, + expandExternalUsername, } from "../../hosts/credential-username.js"; describe("pickResolvedUsername", () => { @@ -52,14 +52,14 @@ describe("pickResolvedPassword", () => { }); }); -describe("expandOidcUsername", () => { +describe("expandExternalUsername", () => { beforeEach(() => { vi.resetModules(); }); it("returns the username unchanged when it has no placeholder", async () => { - expect(await expandOidcUsername("alice", "user-1")).toBe("alice"); - expect(await expandOidcUsername(undefined, "user-1")).toBeUndefined(); + expect(await expandExternalUsername("alice", "user-1")).toBe("alice"); + expect(await expandExternalUsername(undefined, "user-1")).toBeUndefined(); }); it("expands the placeholder with the user's OIDC identifier", async () => { @@ -69,9 +69,13 @@ describe("expandOidcUsername", () => { }), })); - const { expandOidcUsername: expand } = + const { expandExternalUsername: expand } = await import("../../hosts/credential-username.js"); expect(await expand("$oidc.preferred_username", "user-1")).toBe("jdoe"); + expect(await expand("$external.username", "user-1")).toBe("jdoe"); + expect( + await expand("$external.username-$oidc.preferred_username", "user-1"), + ).toBe("jdoe-jdoe"); }); it("leaves the placeholder as-is when the user has no OIDC identifier", async () => { @@ -81,7 +85,7 @@ describe("expandOidcUsername", () => { }), })); - const { expandOidcUsername: expand } = + const { expandExternalUsername: expand } = await import("../../hosts/credential-username.js"); expect(await expand("$oidc.preferred_username", "user-1")).toBe( "$oidc.preferred_username", @@ -95,7 +99,7 @@ describe("expandOidcUsername", () => { }, })); - const { expandOidcUsername: expand } = + const { expandExternalUsername: expand } = await import("../../hosts/credential-username.js"); expect(await expand("$oidc.preferred_username", "user-1")).toBe( "$oidc.preferred_username", @@ -117,7 +121,7 @@ describe("expandOidcUsername", () => { }), })); - const { expandOidcUsername: expand } = + const { expandExternalUsername: expand } = await import("../../hosts/credential-username.js"); expect(await expand("$oidc.preferred_username", "user-1")).toBe("jdoe"); }); @@ -137,7 +141,7 @@ describe("expandOidcUsername", () => { }), })); - const { expandOidcUsername: expand } = + const { expandExternalUsername: expand } = await import("../../hosts/credential-username.js"); expect(await expand("$oidc.preferred_username", "user-1")).toBe( "ldap:1:admin", @@ -159,7 +163,7 @@ describe("expandOidcUsername", () => { }), })); - const { expandOidcUsername: expand } = + const { expandExternalUsername: expand } = await import("../../hosts/credential-username.js"); expect(await expand("$oidc.preferred_username", "user-1")).toBe( "ldap:1:admin", diff --git a/src/backend/tests/plugins/capabilities.test.ts b/src/backend/tests/plugins/capabilities.test.ts index 580abadf9..c6fe6d86c 100644 --- a/src/backend/tests/plugins/capabilities.test.ts +++ b/src/backend/tests/plugins/capabilities.test.ts @@ -149,7 +149,7 @@ describe("capability gate", () => { describe("ctx.asUser", () => { it("makes the named user the actor and audits the switch", async () => { grants.set("sample-plugin", ["kv:own"]); - const ctx = context(["kv:own"]); + const ctx = context(["kv:own", "users:impersonate"]); let seen: string | undefined; await ctx.asUser("user-7", async () => { @@ -169,7 +169,7 @@ describe("ctx.asUser", () => { }); it("restores the previous actor afterwards", async () => { - const ctx = context([]); + const ctx = context(["users:impersonate"]); await runAsActor("outer-user", "request", async () => { await ctx.asUser("inner-user", async () => { @@ -186,6 +186,46 @@ describe("ctx.asUser", () => { /without a user id/, ); }); + + it("refuses without users:impersonate", async () => { + const ctx = context(["kv:own"]); + + await expect(ctx.asUser("user-7", async () => {})).rejects.toThrow( + /users:impersonate/, + ); + }); +}); + +describe("ctx.registry namespacing", () => { + it("lets a plugin provide and revoke its own keys", () => { + const ctx = context([]); + const value = { ok: true }; + + ctx.registry.provide("sample-plugin.thing", value); + expect(ctx.registry.consume("sample-plugin.thing")).toBe(value); + expect(ctx.registry.revoke("sample-plugin.thing", value)).toBe(true); + }); + + it("refuses a key outside the plugin's namespace", () => { + const ctx = context([]); + + expect(() => ctx.registry.provide("other.thing", {})).toThrow( + /registry keys under "sample-plugin\."/, + ); + expect(() => ctx.registry.revoke("other.thing")).toThrow( + /registry keys under/, + ); + }); +}); + +describe("ctx.services.get", () => { + it("refuses a service the manifest does not require", () => { + const ctx = context([]); + + expect(() => ctx.services.get("someone.else")).toThrow( + /not declared in the manifest's requires/, + ); + }); }); describe("ctx.events namespacing", () => { diff --git a/src/backend/tests/plugins/ctx-auth-login.test.ts b/src/backend/tests/plugins/ctx-auth-login.test.ts index 1a46fa176..6269cff57 100644 --- a/src/backend/tests/plugins/ctx-auth-login.test.ts +++ b/src/backend/tests/plugins/ctx-auth-login.test.ts @@ -140,7 +140,7 @@ function setup() { kind: "redirect", external: true, describe: async () => [ - { id: "3", label: "Corp", enabled: true, type: "oidc" }, + { id: "3", label: "Corp", enabled: true, type: "oidc", autoStart: true }, ], start: async () => ({ redirectUrl: "https://idp.example/auth" }), callback: async () => ({ @@ -161,7 +161,9 @@ function setup() { labelKey: "signIn", kind: "form", external: true, - describe: async () => [{ id: "4", label: "Directory", enabled: true }], + describe: async () => [ + { id: "4", label: "Directory", enabled: true, autoStart: true }, + ], verify: async (request) => { if (request.body.password !== "hunter2") { await auth.loginRateLimit.recordFailure( @@ -303,7 +305,7 @@ describe("the other ctx.auth helpers", () => { it("revokes sessions by logout claims and audits it", async () => { const { auth } = setup(); expect(await auth.revokeSessions({ providerId: 3, sid: "sid-1" })).toBe(1); - expect(h.manager.revokeSessionsByOidc).toHaveBeenCalledWith({ + expect(h.manager.revokeSessionsByExternalSession).toHaveBeenCalledWith({ ssoProviderId: 3, sub: null, sid: "sid-1", @@ -352,6 +354,17 @@ describe("a disabled plugin", () => { ]); }); + it("passes autoStart through for redirect methods only", async () => { + setup(); + const methods = await listPublicLoginMethods(); + expect(methods.find((m) => m.id === "corp-sso")?.instances).toEqual([ + { id: "3", label: "Corp", autoStart: true }, + ]); + expect(methods.find((m) => m.id === "corp-dir")?.instances).toEqual([ + { id: "4", label: "Directory" }, + ]); + }); + it("hides its buttons and leaves no route that signs anyone in", async () => { const { bag } = setup(); await bag.disposeAll(); diff --git a/src/backend/tests/plugins/ctx-db.test.ts b/src/backend/tests/plugins/ctx-db.test.ts index 3feaf4b60..8586c4e3b 100644 --- a/src/backend/tests/plugins/ctx-db.test.ts +++ b/src/backend/tests/plugins/ctx-db.test.ts @@ -190,10 +190,17 @@ describe("ctx.db capability", () => { }); }); - it("hands back read-only refs to the core tables a plugin may join", async () => { + it("refuses core refs with only db:own", async () => { grants.set("demo", ["db:own"]); const { ctx } = contextFor("demo", ["db:own"]); + await expect(ctx.db.refs()).rejects.toThrow(/db:core-refs/); + }); + + it("hands back refs to the core tables a plugin may join", async () => { + grants.set("demo", ["db:core-refs"]); + const { ctx } = contextFor("demo", ["db:core-refs"]); + const refs = (await ctx.db.refs()) as Record; expect(Object.keys(refs).sort()).toEqual([ diff --git a/src/backend/tests/plugins/ctx-desktop.test.ts b/src/backend/tests/plugins/ctx-desktop.test.ts index f1b6513b1..3b2c27bf1 100644 --- a/src/backend/tests/plugins/ctx-desktop.test.ts +++ b/src/backend/tests/plugins/ctx-desktop.test.ts @@ -122,19 +122,24 @@ describe("ctx.desktop.openIsolatedWindow", () => { }); }); -describe("ctx.desktop.launchNativeRdp", () => { - const request = { host: "win.example.test", port: 3389, username: "bob" }; +describe("ctx.desktop.launchExternalClient", () => { + const request = { + protocol: "rdp", + host: "win.example.test", + port: 3389, + username: "bob", + }; it("refuses without desktop:window", async () => { grants.set("demo", []); const { ctx } = contextFor("demo", []); - await expect(ctx.desktop.launchNativeRdp(request)).rejects.toThrow( + await expect(ctx.desktop.launchExternalClient(request)).rejects.toThrow( /desktop:window/, ); expect(bridge.requestFromElectronMain).not.toHaveBeenCalled(); expect(auditEntries.at(-1)).toMatchObject({ - action: "plugin_desktop_launch_native_rdp", + action: "plugin_desktop_launch_external_client", success: false, }); }); @@ -144,7 +149,7 @@ describe("ctx.desktop.launchNativeRdp", () => { const { ctx } = contextFor("demo", ["desktop:window"]); expect(ctx.desktop.available()).toBe(false); - await expect(ctx.desktop.launchNativeRdp(request)).rejects.toThrow( + await expect(ctx.desktop.launchExternalClient(request)).rejects.toThrow( /desktop app/, ); }); @@ -155,15 +160,15 @@ describe("ctx.desktop.launchNativeRdp", () => { const { ctx } = contextFor("demo", ["desktop:window"]); expect(ctx.desktop.available()).toBe(true); - await expect(ctx.desktop.launchNativeRdp(request)).resolves.toEqual({ + await expect(ctx.desktop.launchExternalClient(request)).resolves.toEqual({ success: true, }); expect(bridge.requestFromElectronMain).toHaveBeenCalledWith( - "launch-native-rdp", + "launch-external-client", request, ); expect(auditEntries.at(-1)).toMatchObject({ - action: "plugin_desktop_launch_native_rdp", + action: "plugin_desktop_launch_external_client", success: true, }); }); diff --git a/src/backend/tests/plugins/http.test.ts b/src/backend/tests/plugins/http.test.ts index eb3029b95..d38a7dfd2 100644 --- a/src/backend/tests/plugins/http.test.ts +++ b/src/backend/tests/plugins/http.test.ts @@ -401,3 +401,28 @@ describe("ctx.http streaming", () => { expect(rest).toContain("data: done"); }); }); + +describe("public route rate limit", () => { + afterEach(() => { + http.resetPublicRateLimits(); + delete process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT; + }); + + it("allows up to the limit per address and plugin, then refuses", () => { + process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT = "3"; + const now = 1_000; + const hits = [1, 2, 3, 4].map(() => + http.overPublicRateLimit("demo", "1.2.3.4", now), + ); + expect(hits).toEqual([false, false, false, true]); + expect(http.overPublicRateLimit("demo", "5.6.7.8", now)).toBe(false); + expect(http.overPublicRateLimit("other", "1.2.3.4", now)).toBe(false); + }); + + it("starts a new window after a minute", () => { + process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT = "1"; + expect(http.overPublicRateLimit("demo", "ip", 0)).toBe(false); + expect(http.overPublicRateLimit("demo", "ip", 10)).toBe(true); + expect(http.overPublicRateLimit("demo", "ip", 60_001)).toBe(false); + }); +}); diff --git a/src/backend/tests/plugins/loader.test.ts b/src/backend/tests/plugins/loader.test.ts index d5210f143..f4ace73df 100644 --- a/src/backend/tests/plugins/loader.test.ts +++ b/src/backend/tests/plugins/loader.test.ts @@ -472,19 +472,6 @@ describe("PluginLoader error budget", () => { expect(loader.get("sample-plugin")?.state).toBe("active"); }); - it("wrap() counts a throwing callback instead of letting it escape", async () => { - const loader = await failing(2); - - const wrapped = loader.wrap("sample-plugin", () => { - throw new Error("callback failed"); - }); - - await expect(wrapped()).resolves.toBeUndefined(); - await wrapped(); - - expect(loader.get("sample-plugin")?.state).toBe("failed"); - }); - it("retry clears the budget and starts the plugin again", async () => { const loader = await failing(1); diff --git a/src/backend/tests/plugins/manifest.test.ts b/src/backend/tests/plugins/manifest.test.ts index 30e5e8750..2f295b82a 100644 --- a/src/backend/tests/plugins/manifest.test.ts +++ b/src/backend/tests/plugins/manifest.test.ts @@ -11,6 +11,7 @@ import { parseManifest, validateManifest, SUPPORTED_PLUGIN_API_VERSION, + isTermixCompatible, } from "../../plugins/manifest.js"; function base(overrides: Record = {}) { @@ -91,7 +92,35 @@ describe("manifest v2 validation", () => { ); expect(manifest).toBeUndefined(); - expect(errors.join()).toMatch(/SDK API version/); + expect(errors.join()).toMatch(/needs plugin API 2/); + }); + + it("accepts an api range this build satisfies", () => { + for (const api of ["1", "^1.0.0", ">=1.0.0 <2", "1.x"]) { + expect( + parseManifest(base({ engine: { termix: ">=2.9.0", api } })).errors, + ).toEqual([]); + } + }); + + it("refuses an api range needing a newer minor", () => { + const { errors } = parseManifest( + base({ engine: { termix: ">=2.9.0", api: "^1.99" } }), + ); + expect(errors.join()).toMatch(/needs plugin API \^1\.99/); + }); + + it("refuses an engine that is not a semver range", () => { + expect( + validateManifest(base({ engine: { termix: "soon", api: "one" } })).join(), + ).toMatch(/engine.termix.*semver[\s\S]*engine.api.*semver/); + }); + + it("checks engine.termix against the core version", () => { + expect(isTermixCompatible(">=2.9.0", "2.9.0")).toBe(true); + expect(isTermixCompatible(">=2.9.0", "2.9.0-beta.3")).toBe(true); + expect(isTermixCompatible(">=3.0.0", "2.9.1")).toBe(false); + expect(isTermixCompatible(">=3.0.0", null)).toBe(true); }); it("fills in the entry point defaults", () => { diff --git a/src/backend/tests/plugins/permissions-rbac.test.ts b/src/backend/tests/plugins/permissions-rbac.test.ts index efbd781fd..4e0b7c7c9 100644 --- a/src/backend/tests/plugins/permissions-rbac.test.ts +++ b/src/backend/tests/plugins/permissions-rbac.test.ts @@ -7,6 +7,7 @@ * group was declared twice. */ +import type { PluginLoader } from "../../plugins/loader.js"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import fs from "node:fs"; import path from "node:path"; @@ -113,7 +114,6 @@ vi.mock("../../database/repositories/factory.js", () => ({ }), })); -const { PluginLoader } = await import("../../plugins/loader.js"); const { createFixturePlugin } = await import("./fixture-plugin.js"); const { getPermissionCatalog, @@ -433,7 +433,7 @@ describe("shipped manifests keep their permission ids", () => { describe("role defaults apply exactly once", () => { let fixture: Fixture; - let loader: InstanceType; + let loader: PluginLoader; beforeEach(() => { state.roles.set("user", { id: 2, permissions: ["hosts.*"] }); diff --git a/src/backend/tests/plugins/secret-contract.test.ts b/src/backend/tests/plugins/secret-contract.test.ts index e195a9ce9..ac31eaad3 100644 --- a/src/backend/tests/plugins/secret-contract.test.ts +++ b/src/backend/tests/plugins/secret-contract.test.ts @@ -128,7 +128,7 @@ function consumerFixture( ): Fixture { return createFixturePlugin({ id: CONSUMER_ID, - capabilities: ["kv:own"], + capabilities: ["kv:own", "users:impersonate"], backendSource: CONSUMER_SOURCE, manifestOverrides: { category: "Infrastructure", requiresSecret }, }); diff --git a/src/backend/tests/plugins/security/actor.test.ts b/src/backend/tests/plugins/security/actor.test.ts index 8644fe33e..5edfc49db 100644 --- a/src/backend/tests/plugins/security/actor.test.ts +++ b/src/backend/tests/plugins/security/actor.test.ts @@ -81,7 +81,13 @@ function contextFor(pluginId: string, extra: Record = {}) { license: "MIT", category: "Productivity", engine: { termix: ">=2.9.0", api: "1" }, - capabilities: ["hosts:read", "ssh:connect", "credentials:use"], + capabilities: [ + "hosts:read", + "ssh:connect", + "credentials:use", + "users:impersonate", + ], + requires: [{ service: "sample.echo", versionRange: "^1.0.0" }], ...extra, } as never, handle, diff --git a/src/backend/tests/plugins/security/data.test.ts b/src/backend/tests/plugins/security/data.test.ts index da47adbe1..f9037d55b 100644 --- a/src/backend/tests/plugins/security/data.test.ts +++ b/src/backend/tests/plugins/security/data.test.ts @@ -11,7 +11,10 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { findUnownedTableWrites } from "@termix/plugin-sdk/ddl"; +import { + collectOwnedIndexes, + findUnownedTableWrites, +} from "@termix/plugin-sdk/ddl"; import { LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db"; const state = vi.hoisted(() => ({ @@ -74,6 +77,40 @@ describe("the migration checker", () => { ["a truncate", "TRUNCATE TABLE users"], ["an unlogged table", "CREATE UNLOGGED TABLE users (id int)"], ["a CTE write", "WITH x AS (SELECT 1) DELETE FROM users"], + [ + "a Postgres dollar-quoted string hiding a statement", + "SELECT $$'$$; UPDATE users SET is_admin = true; SELECT $$'$$", + ], + [ + "a MySQL backslash escape hiding a statement", + "SELECT 'a\\''; UPDATE users SET is_admin = 1; SELECT ''", + ], + ["dropping a core index by name", "DROP INDEX idx_sessions_user_id"], + ["altering a core index by name", "ALTER INDEX users_pkey RENAME TO x"], + ["dropping a core sequence", "DROP SEQUENCE users_id_seq"], + ["a cascade", "DROP TABLE p_foo_a CASCADE"], + [ + "a MySQL multi-table update", + "UPDATE p_foo_a JOIN users ON 1 = 1 SET users.is_admin = 1", + ], + ["a MySQL comma update", "UPDATE p_foo_a, users SET users.is_admin = 1"], + [ + "a MySQL multi-table delete", + "DELETE p_foo_a, users FROM p_foo_a JOIN users", + ], + [ + "a delete with USING", + "DELETE FROM p_foo_a, users USING p_foo_a JOIN users", + ], + ["SELECT INTO", "SELECT * INTO users_copy FROM users"], + ["writing a file", "SELECT 1 INTO OUTFILE '/tmp/x'"], + ["reading a server file", "SELECT pg_read_file('/etc/passwd')"], + ["changing a server setting", "SELECT set_config('x', 'y', false)"], + [ + "running a string as SQL", + "SELECT query_to_xml('DELETE FROM users', true, true, '')", + ], + ["setval on a core sequence", "SELECT setval('users_id_seq', 1)"], ])("refuses %s", (_label, sql) => { expect(findUnownedTableWrites("foo", sql)).not.toEqual([]); }); @@ -90,10 +127,34 @@ describe("the migration checker", () => { "a quoted name in a string", "INSERT INTO p_foo_a (x) VALUES ('DROP TABLE users')", ], + [ + "dropping an index it created in the same file", + "CREATE INDEX idx_foo_a ON p_foo_a (id); DROP INDEX idx_foo_a", + ], + ["dropping a prefixed index", "DROP INDEX IF EXISTS p_foo_a_idx"], + [ + "setval on its own sequence", + "SELECT setval(pg_get_serial_sequence('p_foo_a', 'id'), 1)", + ], + ["its own sequence", "CREATE SEQUENCE IF NOT EXISTS p_foo_seq"], ])("allows %s", (_label, sql) => { expect(findUnownedTableWrites("foo", sql)).toEqual([]); }); + it("allows dropping an index an earlier migration created", () => { + const owned = collectOwnedIndexes("foo", [ + "CREATE INDEX idx_foo_a ON p_foo_a (id)", + "CREATE INDEX idx_core ON users (id)", + ]); + expect([...owned]).toEqual(["idx_foo_a"]); + expect( + findUnownedTableWrites("foo", "DROP INDEX idx_foo_a", new Set(), owned), + ).toEqual([]); + expect( + findUnownedTableWrites("foo", "DROP INDEX idx_core", new Set(), owned), + ).not.toEqual([]); + }); + it("passes every migration a bundled plugin ships", () => { const plugins = path.resolve( path.dirname(fileURLToPath(import.meta.url)), diff --git a/src/backend/tests/plugins/service-contract.test.ts b/src/backend/tests/plugins/service-contract.test.ts index dab977b64..4cd7a19d5 100644 --- a/src/backend/tests/plugins/service-contract.test.ts +++ b/src/backend/tests/plugins/service-contract.test.ts @@ -121,7 +121,7 @@ function consumerFixture( ): Fixture { return createFixturePlugin({ id: "docker", - capabilities: ["kv:own"], + capabilities: ["kv:own", "users:impersonate"], backendSource: CONSUMER_SOURCE, manifestOverrides: { category: "Infrastructure", requires }, }); @@ -219,6 +219,7 @@ describe("plugin service contracts", () => { it("activates anyway when an unsatisfied requirement is optional", async () => { consumer = consumerFixture([ + { service: SERVICE, versionRange: "^1.2.0", optional: true }, { service: "testplugin.absent", versionRange: "^1.0.0", optional: true }, ]); diff --git a/src/backend/tests/plugins/signed-plugins-gate.test.ts b/src/backend/tests/plugins/signed-plugins-gate.test.ts index f22f52f0a..0ba3fb3da 100644 --- a/src/backend/tests/plugins/signed-plugins-gate.test.ts +++ b/src/backend/tests/plugins/signed-plugins-gate.test.ts @@ -69,10 +69,11 @@ async function writeArtifact( dir: string, id: string, sig: "valid" | "wrong-key" | "none", + version = "1.0.0", ): Promise { const build = tempRoot("termix-build-"); - createFixturePlugin({ id, root: build }); - const file = path.join(dir, `${id}-1.0.0.tmxplug`); + createFixturePlugin({ id, root: build, manifestOverrides: { version } }); + const file = path.join(dir, `${id}-${version}.tmxplug`); await tar.c({ gzip: true, file, cwd: path.join(build, id) }, [ "manifest.json", "backend", @@ -146,13 +147,57 @@ describe("signed plugins gate on", () => { expect(await loadedIds()).toEqual(["bundled-one", "signed-one"]); }); - it("refuses a signed archive that takes a bundled plugin's id", async () => { + it("refuses a signed archive of a bundled id that is not newer", async () => { const { userDir } = setup(); process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "true"; await writeArtifact(userDir, "bundled-one", "valid"); const loader = new PluginLoader(); const loaded = await loader.loadAll(); expect(loaded.map((plugin) => plugin.id)).toEqual(["bundled-one"]); - expect(loader.get("bundled-one")?.source).toBe("bundled"); + expect(loader.get("bundled-one")?.artifact).toBeUndefined(); + }); +}); + +describe("updating a bundled plugin", () => { + it("loads a signed, newer archive in place of the bundled copy", async () => { + const { userDir } = setup(); + const file = await writeArtifact(userDir, "bundled-one", "valid", "1.0.1"); + const loader = new PluginLoader(); + const loaded = await loader.loadAll(); + + expect(loaded.map((plugin) => plugin.id)).toEqual(["bundled-one"]); + const plugin = loader.get("bundled-one"); + expect(plugin?.manifest.version).toBe("1.0.1"); + expect(plugin?.source).toBe("bundled"); + expect(plugin?.artifact).toBe(file); + expect(plugin?.signedBy).toBe("test"); + }); + + it("keeps the bundled copy for an unsigned newer archive", async () => { + const { userDir } = setup(); + await writeArtifact(userDir, "bundled-one", "none", "2.0.0"); + const loader = new PluginLoader(); + await loader.loadAll(); + expect(loader.get("bundled-one")?.manifest.version).toBe("1.0.0"); + }); + + it("keeps the bundled copy for a signed older archive", async () => { + const { userDir } = setup(); + await writeArtifact(userDir, "bundled-one", "valid", "0.9.0"); + const loader = new PluginLoader(); + await loader.loadAll(); + expect(loader.get("bundled-one")?.manifest.version).toBe("1.0.0"); + }); + + it("refuses a downgrade after the bundled copy was unloaded", async () => { + const { userDir } = setup(); + const loader = new PluginLoader(); + await loader.loadAll(); + loader.forget("bundled-one"); + const file = await writeArtifact(userDir, "bundled-one", "valid", "0.5.0"); + + await expect( + loader.loadArtifact(file, loader.bundledIds()), + ).rejects.toThrow(/not newer than the bundled 1\.0\.0/); }); }); diff --git a/src/backend/tests/plugins/user-data.test.ts b/src/backend/tests/plugins/user-data.test.ts index cc87e048f..4270ab164 100644 --- a/src/backend/tests/plugins/user-data.test.ts +++ b/src/backend/tests/plugins/user-data.test.ts @@ -11,6 +11,7 @@ import { defineTable, encryptedText, id, + refHost, refUser, text, varchar, @@ -134,4 +135,37 @@ describe("plugin user data", () => { .get(), ).toEqual({ user_id: "u1", note: "x" }); }); + + it("skips imported rows that point at another user or host", async () => { + sqlite.exec("DROP TABLE ssh_data"); + sqlite.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY, user_id TEXT)"); + sqlite.exec( + "INSERT INTO ssh_data (id, user_id) VALUES (1, 'u2'), (2, 'u1')", + ); + const grants = defineTable("grant", { + id: id(), + ownerId: refUser(), + granteeId: refUser(), + hostId: refHost(), + }); + for (const statement of createTableSql("sqlite", "fixture", grants)) { + sqlite.exec(statement); + } + registerTable("fixture", grants); + + const file = new Database(":memory:"); + file.exec( + "CREATE TABLE p_fixture_grant (id INTEGER PRIMARY KEY, owner_id TEXT, grantee_id TEXT, host_id INTEGER)", + ); + file.exec(`INSERT INTO p_fixture_grant (owner_id, grantee_id, host_id) VALUES + ('old', 'old', 1), ('old', 'u1', 1), ('old', 'old', 2)`); + + const result = await importUserPluginRows(file, "u2"); + expect(result).toMatchObject({ imported: 1, skipped: 2 }); + expect( + sqlite + .prepare("SELECT owner_id, grantee_id, host_id FROM p_fixture_grant") + .all(), + ).toEqual([{ owner_id: "u2", grantee_id: "u2", host_id: 1 }]); + }); }); diff --git a/src/backend/tests/upgrade/sso-settings-migration.test.ts b/src/backend/tests/upgrade/sso-settings-migration.test.ts new file mode 100644 index 000000000..82518ffe8 --- /dev/null +++ b/src/backend/tests/upgrade/sso-settings-migration.test.ts @@ -0,0 +1,79 @@ +/** + * The silent sign-in default moving from core settings into the sso plugin's + * admin settings. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + core: new Map(), + plugin: new Map(), + pluginInstalled: true, +})); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentPluginRepository: () => ({ + findById: async (id: string) => + state.pluginInstalled && id === "sso" ? { id } : null, + }), + createCurrentSettingsRepository: () => ({ + get: async (key: string) => state.core.get(key) ?? null, + delete: async (key: string) => { + state.core.delete(key); + }, + }), + createCurrentPluginSettingsRepository: () => ({ + get: async ( + _pluginId: string, + _scope: string, + _scopeId: string | null, + key: string, + ) => (state.plugin.has(key) ? { value: state.plugin.get(key) } : null), + set: async ( + _pluginId: string, + _scope: string, + _scopeId: string | null, + key: string, + value: string | null, + ) => { + state.plugin.set(key, value); + }, + }), +})); + +vi.mock("../../utils/logger.js", () => ({ + databaseLogger: { info: vi.fn(), warn: vi.fn() }, +})); + +import { runSsoSettingsMigration } from "../../upgrade/sso-settings-migration.js"; + +beforeEach(() => { + state.core = new Map(); + state.plugin = new Map(); + state.pluginInstalled = true; +}); + +describe("runSsoSettingsMigration", () => { + it("moves the core value into the sso admin setting", async () => { + state.core.set("oidc_silent_login_default", "true"); + expect(await runSsoSettingsMigration()).toEqual({ moved: true }); + expect(state.plugin.get("silentLoginDefault")).toBe("true"); + expect(state.core.has("oidc_silent_login_default")).toBe(false); + }); + + it("keeps a value already in plugin settings", async () => { + state.core.set("oidc_silent_login_default", "true"); + state.plugin.set("silentLoginDefault", "false"); + await runSsoSettingsMigration(); + expect(state.plugin.get("silentLoginDefault")).toBe("false"); + expect(state.core.has("oidc_silent_login_default")).toBe(false); + }); + + it("does nothing twice or without the plugin", async () => { + expect(await runSsoSettingsMigration()).toEqual({ moved: false }); + state.core.set("oidc_silent_login_default", "true"); + state.pluginInstalled = false; + expect(await runSsoSettingsMigration()).toEqual({ moved: false }); + expect(state.core.get("oidc_silent_login_default")).toBe("true"); + }); +}); diff --git a/src/backend/tests/utils/auth-manager-oidc-logout.test.ts b/src/backend/tests/utils/auth-manager-oidc-logout.test.ts index 007e280ea..c3d6eff56 100644 --- a/src/backend/tests/utils/auth-manager-oidc-logout.test.ts +++ b/src/backend/tests/utils/auth-manager-oidc-logout.test.ts @@ -99,7 +99,7 @@ function sessionIds(): string[] { .map((row) => String(row.id)); } -describe("AuthManager.revokeSessionsByOidc", () => { +describe("AuthManager.revokeSessionsByExternalSession", () => { beforeAll(() => { mocks.sqlite!.exec(` CREATE TABLE sessions ( @@ -147,7 +147,7 @@ describe("AuthManager.revokeSessionsByOidc", () => { }); await expect( - authManager.revokeSessionsByOidc({ + authManager.revokeSessionsByExternalSession({ ssoProviderId: 7, sub: "subject-1", sid: "session-1", @@ -181,7 +181,7 @@ describe("AuthManager.revokeSessionsByOidc", () => { }); await expect( - authManager.revokeSessionsByOidc({ + authManager.revokeSessionsByExternalSession({ ssoProviderId: 7, sub: "subject-1", }), @@ -192,7 +192,7 @@ describe("AuthManager.revokeSessionsByOidc", () => { it("does not persist when no session matches", async () => { await expect( - authManager.revokeSessionsByOidc({ + authManager.revokeSessionsByExternalSession({ ssoProviderId: 7, sid: "missing", }), @@ -212,7 +212,7 @@ describe("AuthManager.revokeSessionsByOidc", () => { mocks.saveDatabase.mockRejectedValueOnce(new Error("disk full")); await expect( - authManager.revokeSessionsByOidc({ + authManager.revokeSessionsByExternalSession({ ssoProviderId: 7, sid: "session-1", }), diff --git a/src/backend/tests/utils/oidc-desktop-callback.test.ts b/src/backend/tests/utils/external-login-callback.test.ts similarity index 65% rename from src/backend/tests/utils/oidc-desktop-callback.test.ts rename to src/backend/tests/utils/external-login-callback.test.ts index 8a4697602..cf5c03edd 100644 --- a/src/backend/tests/utils/oidc-desktop-callback.test.ts +++ b/src/backend/tests/utils/external-login-callback.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from "vitest"; -import { isOidcTokenCallback } from "../../utils/oidc-desktop-callback.js"; +import { isExternalTokenCallback } from "../../utils/external-login-callback.js"; -describe("isOidcTokenCallback", () => { +describe("isExternalTokenCallback", () => { it.each([ "http://localhost:17850/oidc-callback", "http://127.0.0.1:17850/oidc-callback", "termix-mobile://oidc-callback", ])("recognizes app callback %s", (url) => { - expect(isOidcTokenCallback(url)).toBe(true); + expect(isExternalTokenCallback(url)).toBe(true); }); it.each([ @@ -15,6 +15,6 @@ describe("isOidcTokenCallback", () => { "http://example.com:17850/oidc-callback", "http://localhost:17850/other", ])("rejects non-app callback %s", (url) => { - expect(isOidcTokenCallback(url)).toBe(false); + expect(isExternalTokenCallback(url)).toBe(false); }); }); diff --git a/src/backend/tests/utils/service-listen.test.ts b/src/backend/tests/utils/service-listen.test.ts deleted file mode 100644 index d5b99b5af..000000000 --- a/src/backend/tests/utils/service-listen.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { readdirSync, readFileSync, statSync } from "node:fs"; -import path from "node:path"; -import express from "express"; -import http from "http"; -import type { AddressInfo } from "net"; -import { listenOnServicePort } from "../../utils/service-listen.js"; -import { Logger } from "../../utils/logger.js"; - -function testLogger() { - const logger = new Logger("TEST", "🧪", "#000000"); - return { - logger, - error: vi.spyOn(logger, "error").mockImplementation(() => {}), - }; -} - -function occupyPort(): Promise<{ port: number; release: () => void }> { - return new Promise((resolve) => { - const blocker = http.createServer(); - blocker.listen(0, "127.0.0.1", () => { - resolve({ - port: (blocker.address() as AddressInfo).port, - release: () => blocker.close(), - }); - }); - }); -} - -const servers: http.Server[] = []; - -afterEach(() => { - while (servers.length) servers.pop()?.close(); - vi.restoreAllMocks(); -}); - -describe("listenOnServicePort", () => { - it("starts the service and reports listening on a free port", async () => { - const { logger, error } = testLogger(); - const onListening = vi.fn(); - - const server = listenOnServicePort({ - app: express(), - port: 0, - logger, - serviceName: "test", - onListening, - }); - servers.push(server); - - await vi.waitFor(() => expect(onListening).toHaveBeenCalledOnce()); - expect(server.listening).toBe(true); - expect(error).not.toHaveBeenCalled(); - }); - - // The bug this helper exists for: express's app.listen(port, host, cb) - // registers cb as the server's error handler too, so a port conflict - // invoked the "service started" callback and emitted nothing at all. - it("does not report the service as started when the port is taken", async () => { - const { port, release } = await occupyPort(); - const { logger } = testLogger(); - const onListening = vi.fn(); - const exit = vi - .spyOn(process, "exit") - .mockImplementation(() => undefined as never); - - const server = listenOnServicePort({ - app: express(), - port, - logger, - serviceName: "test", - onListening, - }); - servers.push(server); - - await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1)); - expect(onListening).not.toHaveBeenCalled(); - release(); - }); - - it("names the conflicting port so the desktop can surface it", async () => { - const { port, release } = await occupyPort(); - const { logger, error } = testLogger(); - vi.spyOn(process, "exit").mockImplementation(() => undefined as never); - - servers.push( - listenOnServicePort({ - app: express(), - port, - logger, - serviceName: "metrics", - }), - ); - - await vi.waitFor(() => expect(error).toHaveBeenCalled()); - const [message, , context] = error.mock.calls[0]; - expect(message).toContain(String(port)); - expect(context).toMatchObject({ - operation: "service_port_conflict", - port, - service: "metrics", - }); - release(); - }); - - it("leaves any other listen error to the process-level handlers", async () => { - const { logger } = testLogger(); - const exit = vi - .spyOn(process, "exit") - .mockImplementation(() => undefined as never); - const server = listenOnServicePort({ - app: express(), - port: 0, - logger, - serviceName: "test", - }); - servers.push(server); - - const notPortConflict = Object.assign(new Error("boom"), { code: "EPERM" }); - expect(() => server.emit("error", notPortConflict)).toThrow("boom"); - expect(exit).not.toHaveBeenCalled(); - }); -}); - -// express's app.listen() swallows a bind failure by design (see -// service-listen.ts), so no service may reach for it again. -describe("backend services", () => { - function sourceFiles(dir: string): string[] { - return readdirSync(dir).flatMap((entry) => { - const full = path.join(dir, entry); - if (statSync(full).isDirectory()) { - return entry === "tests" ? [] : sourceFiles(full); - } - return full.endsWith(".ts") ? [full] : []; - }); - } - - it("never bind a port with express's app.listen()", () => { - const offenders = sourceFiles(path.resolve("src/backend")) - // service-listen.ts names the call only to document why it is banned. - .filter((file) => !file.endsWith(path.join("utils", "service-listen.ts"))) - .filter((file) => /\bapp\.listen\(/.test(readFileSync(file, "utf8"))); - - expect(offenders).toEqual([]); - }); -}); diff --git a/src/backend/tests/utils/ws-auth.test.ts b/src/backend/tests/utils/ws-auth.test.ts index b19cb8ae7..79159efcc 100644 --- a/src/backend/tests/utils/ws-auth.test.ts +++ b/src/backend/tests/utils/ws-auth.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import type { IncomingMessage } from "http"; -import { extractWebSocketToken } from "../../utils/ws-auth.js"; +import { + extractWebSocketToken, + isCookieOriginAllowed, +} from "../../utils/ws-auth.js"; function request(headers: Record): IncomingMessage { return { headers } as IncomingMessage; @@ -26,3 +29,58 @@ describe("extractWebSocketToken", () => { ).toBe("cookie-token"); }); }); + +describe("the jwt cookie on a socket", () => { + it("counts when the page is this server", () => { + expect( + extractWebSocketToken( + request({ + cookie: "jwt=cookie-token", + host: "termix.example.com", + origin: "https://termix.example.com", + }), + ), + ).toBe("cookie-token"); + }); + + it("is ignored when another site opened the socket", () => { + expect( + extractWebSocketToken( + request({ + cookie: "jwt=cookie-token", + host: "termix.example.com", + origin: "https://evil.example.com", + }), + ), + ).toBeUndefined(); + }); + + it("still takes an explicit token from another origin", () => { + expect( + extractWebSocketToken( + request({ + cookie: "jwt=cookie-token", + host: "termix.example.com", + origin: "https://evil.example.com", + "sec-websocket-protocol": "termix.jwt.protocol-token", + }), + ), + ).toBe("protocol-token"); + }); + + it("allows the dev proxy, which rewrites Host to loopback", () => { + expect( + isCookieOriginAllowed( + request({ host: "127.0.0.1:30001", origin: "http://localhost:5173" }), + ), + ).toBe(true); + }); + + it("allows the desktop app, which is not a web origin", () => { + expect( + isCookieOriginAllowed( + request({ host: "termix.example.com", origin: "file://" }), + ), + ).toBe(true); + }); +}); diff --git a/src/backend/upgrade/boot-migrations.ts b/src/backend/upgrade/boot-migrations.ts index a762a5a80..573664643 100644 --- a/src/backend/upgrade/boot-migrations.ts +++ b/src/backend/upgrade/boot-migrations.ts @@ -34,6 +34,7 @@ import { runSecretSourcesTokenMigration } from "./secret-sources-token-migration import { runTotpMigration } from "./totp-migration.js"; import { runNotificationChannelMigration } from "./notification-channel-migration.js"; import { runTermixIdentityCaMigration } from "./termix-identity-ca-migration.js"; +import { runSsoSettingsMigration } from "./sso-settings-migration.js"; const MIGRATIONS: Array<[string, () => Promise]> = [ ["runTailscaleSettingsMigration", runTailscaleSettingsMigration], @@ -64,6 +65,7 @@ const MIGRATIONS: Array<[string, () => Promise]> = [ ["runTotpMigration", runTotpMigration], ["runNotificationChannelMigration", runNotificationChannelMigration], ["runTermixIdentityCaMigration", runTermixIdentityCaMigration], + ["runSsoSettingsMigration", runSsoSettingsMigration], ]; export async function runPluginDataMigrations(): Promise { diff --git a/src/backend/upgrade/sso-settings-migration.ts b/src/backend/upgrade/sso-settings-migration.ts new file mode 100644 index 000000000..35b6c6b2e --- /dev/null +++ b/src/backend/upgrade/sso-settings-migration.ts @@ -0,0 +1,59 @@ +/** + * Moves the silent sign-in default out of core settings and into the sso + * plugin's admin settings. Core used to own the toggle and read it on the + * login page; the sso plugin now marks its own login instance to start on + * its own. + * + * Idempotent: a value already in plugin settings wins and the core copy is + * dropped. + */ + +import { databaseLogger } from "../utils/logger.js"; +import { + createCurrentPluginRepository, + createCurrentPluginSettingsRepository, + createCurrentSettingsRepository, +} from "../database/repositories/factory.js"; + +const PLUGIN_ID = "sso"; +const LEGACY_KEY = "oidc_silent_login_default"; +const FIELD = "silentLoginDefault"; + +export interface SsoSettingsMigrationResult { + moved: boolean; +} + +export async function runSsoSettingsMigration(): Promise { + try { + const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID); + if (!plugin) return { moved: false }; + + const settings = createCurrentSettingsRepository(); + const legacy = await settings.get(LEGACY_KEY); + if (legacy === null || legacy === "") return { moved: false }; + + const pluginSettings = createCurrentPluginSettingsRepository(); + const existing = await pluginSettings.get(PLUGIN_ID, "admin", null, FIELD); + if (!existing || existing.value === null) { + await pluginSettings.set( + PLUGIN_ID, + "admin", + null, + FIELD, + JSON.stringify(legacy === "true"), + false, + ); + } + await settings.delete(LEGACY_KEY); + databaseLogger.info("Moved the silent sign-in default into sso settings", { + operation: "sso_settings_migration", + }); + return { moved: true }; + } catch (error) { + databaseLogger.warn("SSO settings migration failed", { + operation: "sso_settings_migration", + error: error instanceof Error ? error.message : String(error), + }); + return { moved: false }; + } +} diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index b1ec5f2b5..ef564da60 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -137,7 +137,7 @@ class AuthManager { } } - async registerOIDCUser( + async registerExternalUser( userId: string, _sessionDurationMs?: number, ): Promise { @@ -154,7 +154,7 @@ class AuthManager { return false; } - async authenticateOIDCUser( + async authenticateExternalUser( userId: string, _deviceType?: DeviceType, ): Promise { @@ -505,7 +505,7 @@ class AuthManager { } } - async revokeSessionsByOidc(params: { + async revokeSessionsByExternalSession(params: { ssoProviderId?: number | null; sub?: string | null; sid?: string | null; @@ -529,7 +529,6 @@ class AuthManager { if (matched.length === 0) return 0; const matchedIds = matched.map((s) => s.id); - const affectedUsers = new Set(matched.map((s) => s.userId)); await db.delete(sessions).where(inArray(sessions.id, matchedIds)); diff --git a/src/backend/utils/oidc-desktop-callback.ts b/src/backend/utils/external-login-callback.ts similarity index 88% rename from src/backend/utils/oidc-desktop-callback.ts rename to src/backend/utils/external-login-callback.ts index b9e7c67ac..28b190d00 100644 --- a/src/backend/utils/oidc-desktop-callback.ts +++ b/src/backend/utils/external-login-callback.ts @@ -5,7 +5,7 @@ const CALLBACK_PATH = "/oidc-callback"; * listener or the mobile app's scheme, which get the token in the URL * because they cannot read a cookie. */ -export function isOidcTokenCallback(value: string): boolean { +export function isExternalTokenCallback(value: string): boolean { if (value.startsWith("termix-mobile:")) return true; try { diff --git a/src/backend/utils/logger.ts b/src/backend/utils/logger.ts index d8faa2b06..f4cc99247 100644 --- a/src/backend/utils/logger.ts +++ b/src/backend/utils/logger.ts @@ -34,7 +34,6 @@ export interface LogContext { operation?: string; userId?: string; hostId?: number; - tunnelName?: string; sessionId?: string; requestId?: string; duration?: number; @@ -139,8 +138,6 @@ export class Logger { contextParts.push(`user:${sanitizedContext.userId}`); if (sanitizedContext.hostId) contextParts.push(`host:${sanitizedContext.hostId}`); - if (sanitizedContext.tunnelName) - contextParts.push(`tunnel:${sanitizedContext.tunnelName}`); if (sanitizedContext.sessionId) contextParts.push(`session:${sanitizedContext.sessionId}`); if (sanitizedContext.requestId) @@ -255,10 +252,6 @@ export class Logger { this.info(`SSH: ${message}`, { ...context, operation: "ssh" }); } - tunnel(message: string, context?: LogContext): void { - this.info(`TUNNEL: ${message}`, { ...context, operation: "tunnel" }); - } - file(message: string, context?: LogContext): void { this.info(`FILE: ${message}`, { ...context, operation: "file" }); } diff --git a/src/backend/utils/service-listen.ts b/src/backend/utils/service-listen.ts deleted file mode 100644 index bf24110cb..000000000 --- a/src/backend/utils/service-listen.ts +++ /dev/null @@ -1,84 +0,0 @@ -import http from "http"; -import type { RequestListener } from "http"; -import type { Logger } from "./logger.js"; - -interface ServiceListenOptions { - app: RequestListener; - port: number; - logger: Logger; - /** Used only to identify the service in the conflict log. */ - serviceName: string; - host?: string; - onListening?: () => void; -} - -/** - * Starts one of the backend's fixed-port services. - * - * Express's `app.listen(port, host, callback)` registers that callback as - * the server's `error` handler as well as its `listening` handler: - * - * if (typeof args[args.length - 1] === 'function') { - * var done = args[args.length - 1] = once(args[args.length - 1]) - * server.once('error', done) - * } - * - * so a port conflict called the service's "started" callback with an - * EADDRINUSE error as its argument and emitted nothing. Since none of the - * services read that argument, a failed bind was indistinguishable from a - * successful one: the service logged that it had started, ran its - * initialisation, and served nothing, with no error anywhere in the logs. - * - * Building the server here keeps `listening` and `error` separate. A port - * conflict is treated the way database.ts already treats one on the main - * HTTP port -- reported with the port that was taken, then exit -- rather - * than leaving the backend up with one feature silently missing. Exiting - * also gives the desktop app the classified failure it already knows how - * to surface, so the user is told which port to free. - */ -/** - * Reports a port conflict on an already-built service server and stops the - * backend, for services that must own their `http.Server` (the tunnel - * service attaches a WebSocket upgrade handler to it, for example) and so - * cannot go through {@link listenOnServicePort}. - */ -function attachServicePortConflictHandler( - server: http.Server, - port: number, - logger: Logger, - serviceName: string, -): void { - server.on("error", (err: NodeJS.ErrnoException) => { - if (err.code === "EADDRINUSE") { - logger.error( - `Port ${port} is already in use. Kill the existing process and retry.`, - err, - { - operation: "service_port_conflict", - port, - service: serviceName, - }, - ); - // process.exit() does not return, so nothing below runs in - // production; the explicit return keeps that obvious and keeps the - // rethrow below reachable only for genuinely unexpected errors. - process.exit(1); - return; - } - throw err; - }); -} - -export function listenOnServicePort({ - app, - port, - logger, - serviceName, - host = "127.0.0.1", - onListening, -}: ServiceListenOptions): http.Server { - const server = http.createServer(app); - attachServicePortConflictHandler(server, port, logger, serviceName); - server.listen(port, host, onListening); - return server; -} diff --git a/src/backend/utils/swagger.ts b/src/backend/utils/swagger.ts index 38ae8cc66..97f52bead 100644 --- a/src/backend/utils/swagger.ts +++ b/src/backend/utils/swagger.ts @@ -8,6 +8,42 @@ type SwaggerJSDocOptions = Parameters[0]; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +// dist/backend/backend/utils -> the repository. +const REPO_ROOT = path.join(__dirname, "..", "..", "..", ".."); + +/** + * A tag per plugin, named and described by its manifest, so the reference + * groups a plugin's routes without core naming any plugin. + */ +async function pluginTags(): Promise<{ name: string; description: string }[]> { + const pluginsDir = path.join(REPO_ROOT, "plugins"); + const tags: { name: string; description: string }[] = []; + let entries: string[] = []; + try { + entries = await fs.readdir(pluginsDir); + } catch { + return tags; + } + for (const entry of entries.sort()) { + try { + const manifest = JSON.parse( + await fs.readFile( + path.join(pluginsDir, entry, "manifest.json"), + "utf8", + ), + ) as { name?: string; description?: string }; + if (manifest.name) { + tags.push({ + name: manifest.name, + description: manifest.description ?? "", + }); + } + } catch { + // Not a plugin folder. + } + } + return tags; +} const swaggerOptions: SwaggerJSDocOptions = { definition: { @@ -47,30 +83,14 @@ const swaggerOptions: SwaggerJSDocOptions = { }, ], tags: [ - { - name: "AI", - description: "AI assistant providers, conversations and proposals", - }, - { - name: "Alerts", - description: "System alerts and notifications management", - }, { name: "Credentials", description: "SSH credential management", }, - { - name: "Network Topology", - description: "Network topology visualization and management", - }, { name: "RBAC", description: "Role-based access control for host sharing", }, - { - name: "Terminal", - description: "Terminal command history", - }, { name: "Users", description: "User management and authentication", @@ -79,22 +99,6 @@ const swaggerOptions: SwaggerJSDocOptions = { name: "Dashboard", description: "Dashboard statistics and activity", }, - { - name: "Docker", - description: "Docker container management", - }, - { - name: "SSH Tunnels", - description: "SSH tunnel connection management", - }, - { - name: "Host Metrics", - description: "Host status monitoring, metrics collection, and managers", - }, - { - name: "File Manager", - description: "SSH file management operations", - }, { name: "SSH", description: "SSH host management and configuration", @@ -103,50 +107,10 @@ const swaggerOptions: SwaggerJSDocOptions = { name: "Host Enrollment", description: "Host enrollment and onboarding", }, - { - name: "Fleets", - description: "Fleet grouping, membership, and inventory", - }, - { - name: "Workspaces", - description: "Saved tab and split layouts", - }, { name: "Open Tabs", description: "Per-user open tab state", }, - { - name: "Automations", - description: "Scheduled and triggered automations", - }, - { - name: "Guacamole", - description: "Remote desktop sessions", - }, - { - name: "Proxmox", - description: "Proxmox host integration", - }, - { - name: "Proxmox Stats", - description: "Proxmox node and VM statistics", - }, - { - name: "Session Sharing", - description: "Share live sessions by link or with another user", - }, - { - name: "Collab", - description: "Collaboration rooms that present a live session", - }, - { - name: "Session Logs", - description: "Session recording and playback", - }, - { - name: "Homepage", - description: "Homepage service links and layout", - }, { name: "Audit", description: "Audit log querying and export", @@ -155,26 +119,10 @@ const swaggerOptions: SwaggerJSDocOptions = { name: "API Keys", description: "API key management", }, - { - name: "Vault", - description: "HashiCorp Vault SSH signing profiles", - }, - { - name: "Termix ID", - description: "Built-in SSH certificate authority", - }, - { - name: "Tailscale", - description: "Tailscale network integration", - }, { name: "Sync", description: "Remote sync between desktop and server", }, - { - name: "Tunnel Presets", - description: "Saved tunnel configurations", - }, { name: "User Preferences", description: "Per-user application preferences", @@ -201,6 +149,11 @@ const swaggerOptions: SwaggerJSDocOptions = { path.join(__dirname, "..", "services", "*.js").replace(/\\/g, "/"), path.join(__dirname, "..", "hosts", "*.js").replace(/\\/g, "/"), path.join(__dirname, "..", "hosts", "**", "*.js").replace(/\\/g, "/"), + // Plugin routes document themselves. Read from source, because a plugin's + // bundle drops comments. + path + .join(REPO_ROOT, "plugins", "*", "src", "backend", "**", "*.ts") + .replace(/\\/g, "/"), ], }; @@ -210,6 +163,8 @@ async function generateOpenAPISpec() { operation: "openapi_generate_start", }); + const definition = swaggerOptions.definition as { tags?: unknown[] }; + definition.tags = [...(definition.tags ?? []), ...(await pluginTags())]; const swaggerSpec = await swaggerJSDoc(swaggerOptions); const outputPath = path.join( diff --git a/src/backend/utils/ws-auth.ts b/src/backend/utils/ws-auth.ts index ae2314c77..a67ec9ff5 100644 --- a/src/backend/utils/ws-auth.ts +++ b/src/backend/utils/ws-auth.ts @@ -2,11 +2,50 @@ import type { IncomingMessage } from "http"; const JWT_PROTOCOL_PREFIX = "termix.jwt."; +/** + * A browser attaches the jwt cookie to a socket opened from any page on the + * same site, including a sibling subdomain. The cookie only counts when the + * page that opened the socket is this server. A non-web origin (the desktop + * app) or no Origin at all is not a browser page on another host. + */ +export function isCookieOriginAllowed(req: IncomingMessage): boolean { + const origin = req.headers.origin; + if (!origin) return true; + let parsed: URL; + try { + parsed = new URL(origin); + } catch { + return false; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return true; + const hosts = [req.headers.host, req.headers["x-forwarded-host"]] + .flatMap((value) => String(value ?? "").split(",")) + .map((value) => value.trim().toLowerCase()) + .filter(Boolean); + if (hosts.includes(parsed.host.toLowerCase())) return true; + // The Vite dev proxy rewrites Host, so loopback to loopback is allowed. + if ( + isLoopback(parsed.hostname) && + hosts.some((host) => isLoopback(host.replace(/:\d+$/, ""))) + ) { + return true; + } + return (process.env.TERMIX_ALLOWED_ORIGINS ?? "") + .split(",") + .map((value) => value.trim().toLowerCase()) + .includes(parsed.origin.toLowerCase()); +} + +function isLoopback(hostname: string): boolean { + const host = hostname.replace(/^\[|\]$/g, "").toLowerCase(); + return host === "localhost" || host === "::1" || host.startsWith("127."); +} + export function extractWebSocketToken( req: IncomingMessage, ): string | undefined { const cookieHeader = req.headers.cookie; - if (cookieHeader) { + if (cookieHeader && isCookieOriginAllowed(req)) { const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/); if (match) return decodeURIComponent(match[1]); } diff --git a/src/types/connection-log.ts b/src/types/connection-log.ts index 7e1a1578e..414f98b5b 100644 --- a/src/types/connection-log.ts +++ b/src/types/connection-log.ts @@ -1,3 +1,7 @@ +/** + * Where a connection is. Core's SSH pipeline uses the named stages; a plugin + * may log its own (such as "guac_ready") and they pass through unchanged. + */ export type ConnectionStage = | "dns" | "tcp" @@ -9,29 +13,7 @@ export type ConnectionStage = | "proxy" | "jump" | "validation" - | "docker_connecting" - | "docker_auth" - | "docker_session" - | "docker_ready" - | "stats_connecting" - | "stats_totp" - | "stats_polling" - | "stats_heartbeat" - | "tunnel_connecting" - | "tunnel_source" - | "tunnel_endpoint" - | "tunnel_forwarding" - | "tunnel_retry" - | "tunnel_connected" - | "sftp_connecting" - | "sftp_auth" - | "sftp_connected" - | "guac_token" - | "guac_guacd" - | "guac_connecting" - | "guac_handshake" - | "guac_ready" - | "guac_disconnected"; + | (string & {}); export type LogEntry = { id: string; diff --git a/src/types/credential-sidebar-preferences.ts b/src/types/credential-sidebar-preferences.ts index 735c49f6d..55e33a302 100644 --- a/src/types/credential-sidebar-preferences.ts +++ b/src/types/credential-sidebar-preferences.ts @@ -85,14 +85,6 @@ function sanitizeStringArray(input: unknown): string[] { return input.filter((v): v is string => typeof v === "string"); } -function sanitizeEnumArray( - input: unknown, - allowed: readonly T[], -): T[] { - if (!Array.isArray(input)) return []; - return input.filter((v): v is T => allowed.includes(v as T)); -} - export function sanitizeCredentialSidebarPreferences( input: unknown, ): CredentialSidebarPreferences { diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 4b5a5e20c..1eedf93ad 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -1,383 +1 @@ -interface ServerConfig { - serverUrl?: string; - allowInvalidCertificate?: boolean; - [key: string]: unknown; -} - -interface ConnectionTestResult { - success: boolean; - error?: string; - [key: string]: unknown; -} - -interface DialogOptions { - title?: string; - defaultPath?: string; - buttonLabel?: string; - filters?: Array<{ name: string; extensions: string[] }>; - properties?: string[]; - [key: string]: unknown; -} - -interface DialogResult { - canceled: boolean; - filePath?: string; - filePaths?: string[]; - [key: string]: unknown; -} - -export interface LocalDirectoryResult { - success: boolean; - path: string; - parent?: string; - entries: LocalFileEntry[]; - error?: string; -} - -export interface LocalCollectedFile { - path: string; - name: string; - relativePath: string; - size: number; - created?: string; - modified: string; -} - -export type LocalPathMutationResult = - | ({ success: true } & Partial) - | { success: false; error?: string }; - -export interface ElectronAPI { - getAppVersion: () => Promise; - setZoomFactor?: (factor: number) => void; - getPlatform: () => Promise; - getSetting?: (key: string) => Promise; - setSetting?: (key: string, value: string) => Promise; - - getServerConfig: () => Promise; - saveServerConfig: (config: ServerConfig) => Promise<{ success: boolean }>; - testServerConnection: ( - serverUrl: string, - allowInvalidCertificate?: boolean, - ) => Promise; - getC2STunnelConfig: () => Promise; - saveC2STunnelConfig: ( - config: unknown[], - ) => Promise<{ success: boolean; error?: string }>; - checkLocalPortAvailable: ( - host: string, - port: number, - ) => Promise<{ available: boolean; error?: string }>; - getC2STunnelPresetDefaultName: () => Promise; - startC2STunnel: ( - tunnel: unknown, - index: number, - authToken?: string, - ) => Promise<{ - success: boolean; - tunnelName?: string; - error?: string; - code?: string; - }>; - testC2STunnel: ( - tunnel: unknown, - index: number, - authToken?: string, - ) => Promise<{ - success: boolean; - message?: string; - error?: string; - code?: string; - }>; - stopC2STunnel: ( - tunnelName: string, - ) => Promise<{ success: boolean; error?: string }>; - getC2STunnelStatuses: () => Promise>; - onC2STunnelStatuses?: ( - callback: (statuses: Record) => void, - ) => () => void; - startC2SAutoStartTunnels: () => Promise<{ - success: boolean; - started: number; - errors: string[]; - }>; - onCloseActiveTab?: (callback: () => void) => () => void; - clearSessionCookies: () => Promise; - getSessionCookie: ( - name: string, - targetUrl?: string, - ) => Promise; - waitForSessionCookie: ( - name: string, - targetUrl?: string, - previousValue?: string | null, - timeoutMs?: number, - ) => Promise<{ success: boolean; value?: string; error?: string }>; - - showSaveDialog: (options: DialogOptions) => Promise; - showOpenDialog: (options: DialogOptions) => Promise; - getLocalHomeDirectory?: () => Promise; - listLocalDirectory?: (path?: string) => Promise; - statLocalPaths?: ( - paths: string[], - ) => Promise< - Array< - | (LocalFileEntry & { success: true }) - | { success: false; path: string; error?: string } - > - >; - collectLocalFiles?: (paths: string[]) => Promise<{ - success: boolean; - files: LocalCollectedFile[]; - truncated?: boolean; - error?: string; - }>; - createLocalFolder?: ( - parentPath: string, - folderName: string, - ) => Promise; - renameLocalPath?: ( - path: string, - newName: string, - ) => Promise; - trashLocalPath?: (path: string) => Promise; - chmodLocalPath?: ( - path: string, - permissions: string, - ) => Promise; - - openExternalEditor: (fileData: { - fileName: string; - content: string; - encoding?: "utf8" | "base64"; - editorPath?: string | null; - }) => Promise<{ - success: boolean; - editId?: string; - path?: string; - error?: string; - }>; - - closeExternalEditor: (editId: string) => Promise<{ - success: boolean; - error?: string; - }>; - - onExternalEditorSaved?: ( - callback: (payload: { - editId: string; - content: string; - encoding: "utf8"; - path: string; - }) => void, - ) => () => void; - - onUpdateAvailable: (callback: () => void) => void; - onUpdateDownloaded: (callback: () => void) => void; - - removeAllListeners: (channel: string) => void; - isElectron: boolean; - isDev: boolean; - - invoke: (channel: string, ...args: unknown[]) => Promise; - - createTempFile: (fileData: { - fileName: string; - content: string; - encoding?: "base64" | "utf8"; - }) => Promise<{ - success: boolean; - tempId?: string; - path?: string; - error?: string; - }>; - - createTempFolder: (folderData: { - folderName: string; - files: Array<{ - relativePath: string; - content: string; - encoding?: "base64" | "utf8"; - }>; - }) => Promise<{ - success: boolean; - tempId?: string; - path?: string; - error?: string; - }>; - - startDragToDesktop: (dragData: { - tempId: string; - fileName: string; - }) => Promise<{ - success: boolean; - error?: string; - }>; - - cleanupTempFile: (tempId: string) => Promise<{ - success: boolean; - error?: string; - }>; - - startLocalTerminal(dimensions: { - cols: number; - rows: number; - shell?: "default" | "wsl"; - }): Promise<{ sessionId: string; shell: string }>; - readyLocalTerminal(sessionId: string): Promise; - writeLocalTerminal(sessionId: string, data: string): Promise; - resizeLocalTerminal( - sessionId: string, - cols: number, - rows: number, - ): Promise; - closeLocalTerminal(sessionId: string): Promise; - onLocalTerminalData( - sessionId: string, - callback: (data: string) => void, - ): () => void; - onLocalTerminalExit( - sessionId: string, - callback: (exitCode: number) => void, - ): () => void; - - /** Local disk browsing for the dual-pane file manager (desktop only). */ - localFs?: { - home: () => Promise>; - list: (dirPath: string) => Promise>; - mkdir: ( - parentPath: string, - name: string, - ) => Promise>; - createFile: ( - parentPath: string, - name: string, - ) => Promise>; - rename: ( - oldPath: string, - newName: string, - ) => Promise>; - trash: (paths: string[]) => Promise>; - ensureDir: ( - dirPath: string, - rootPath?: string, - ) => Promise>; - exists: (paths: string[]) => Promise>; - walk: (paths: string[]) => Promise>; - reveal: (targetPath: string) => Promise>; - open: (targetPath: string) => Promise>; - }; - - /** Streamed local<->remote transfers driven by the main process. */ - localTransfer?: { - upload: ( - options: LocalUploadRequest, - ) => Promise>; - download: ( - options: LocalDownloadRequest, - ) => Promise>; - cancel: ( - transferId: string, - ) => Promise>; - onProgress: ( - callback: (payload: LocalTransferProgress) => void, - ) => () => void; - }; -} - -export type LocalFsResult = - ({ success: true } & T) | { success: false; error: string; code?: string }; - -export interface LocalFsHomeInfo { - home: string; - separator: string; - platform: string; -} - -export interface LocalFileEntry { - name: string; - path: string; - type: "file" | "directory" | "link" | "other"; - created?: string; - modified?: string; - permissions?: string; - owner?: string; - group?: string; - size: number; - modifiedTimestamp?: number; - linkTarget?: string; - hidden: boolean; -} - -export interface LocalDirectoryListing { - path: string; - parent: string | null; - entries: LocalFileEntry[]; -} - -export interface LocalTrashResult { - trashed: number; - failed: Array<{ path: string; error: string }>; -} - -export interface LocalWalkFile { - localPath: string; - /** Path relative to the drop root; "/"-separated and includes the root's own name. */ - relativePath: string; - size: number; -} - -export interface LocalWalkResult { - files: LocalWalkFile[]; - emptyDirs: string[]; - totalBytes: number; -} - -/** - * Which Termix backend a transfer talks to. The main process resolves the - * actual URL and credentials for the origin; the renderer never supplies them. - */ -export type LocalTransferOrigin = "local" | "remote"; - -export interface LocalUploadRequest { - transferId: string; - origin: LocalTransferOrigin; - fields: Record; - localPath: string; - fileName: string; - deviceId?: string; - /** The renderer's token for the embedded backend (origin "local"); sent as a Bearer header. */ - authToken?: string; -} - -export interface LocalDownloadRequest { - transferId: string; - origin: LocalTransferOrigin; - body: Record; - destPath: string; - /** Selected download folder; destPath must resolve strictly inside it (code EINVAL otherwise). */ - rootPath: string; - expectedSize?: number; - /** Replace an existing file at destPath; otherwise the transfer is refused with code EEXIST. */ - overwrite?: boolean; - deviceId?: string; - /** The renderer's token for the embedded backend (origin "local"); sent as a Bearer header. */ - authToken?: string; -} - -export interface LocalTransferProgress { - transferId: string; - transferred: number; - total?: number; -} - -declare global { - interface Window { - electronAPI: ElectronAPI; - IS_ELECTRON: boolean; - electronClipboard?: { - writeText(text: string): Promise; - readText(): Promise; - }; - } -} +export type * from "@termix/plugin-sdk/desktop"; diff --git a/src/types/index.ts b/src/types/index.ts index baa84a1c8..02228bb95 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -32,48 +32,6 @@ export interface HostProtocolAuthInput { fields?: Record; } -export type WebEndpointAccess = "direct" | "tunnel"; -export type WebEndpointRender = "external" | "embedded"; - -/** One web UI a host serves, declared in the host's settings. */ -export interface WebEndpoint { - /** - * Stable identifier. Must NOT be derived from the port: it keys both the - * tunnel name and the tab identity, so editing a port has to leave a live - * tunnel findable under the same name. - */ - id: string; - label: string; - scheme: "http" | "https"; - port: number; - /** Defaults to "/". Normalized at the storage boundary, never here. */ - path?: string; - access: WebEndpointAccess; - render: WebEndpointRender; - /** - * Direct endpoints only. Allows an invalid TLS certificate for this - * endpoint's exact origin. A no-op for tunnel access, whose host component - * is loopback and therefore already exempt. - */ - ignoreCert?: boolean; - /** - * Tunnel endpoints only. Where the backend binds the forward, exactly as - * the server tunnels feature exposes it. Defaults to 127.0.0.1, reachable - * only from the machine running the backend. A web deployment runs the - * backend on a server, so reaching the forward from a browser needs an - * address that machine answers on -- which also exposes the target's web UI - * to anyone who can reach the port, with no login in front of it. - */ - bindHost?: string; - /** - * Tunnel endpoints only. Which port the forward listens on, as the server - * tunnels feature's Source Port does. Left unset the kernel picks a free - * one, which is fine when backend and browser share a machine -- but a - * container can only publish ports it knows in advance. - */ - localPort?: number; -} - export interface JumpHost { hostId: number; } @@ -306,29 +264,6 @@ export interface CredentialBackend { updatedAt: string; } -// ============================================================================ -// TUNNEL TYPES -// ============================================================================ - -export type TunnelScope = "s2s" | "c2s"; -export type TunnelMode = "local" | "remote" | "dynamic"; - -// ============================================================================ -// FILE MANAGER TYPES -// ============================================================================ - -export interface Tab { - id: string | number; - title: string; - fileName: string; - content: string; - isSSH?: boolean; - sshSessionId?: string; - filePath?: string; - loading?: boolean; - dirty?: boolean; -} - // ============================================================================ // TERMINAL CONFIGURATION TYPES // ============================================================================ @@ -368,7 +303,6 @@ export interface TerminalRefHandle { subscribeOutput?: (listener: (data: string) => void) => () => void; notifyResize?: () => void; refresh?: () => void; - openFileManager?: () => void; } // ============================================================================ @@ -427,21 +361,3 @@ export interface CacheEntry { timestamp: number; expiresAt: number; } - -// ============================================================================ -// DATABASE EXPORT/IMPORT TYPES -// ============================================================================ - -export interface ExportSummary { - sshHostsImported: number; - sshCredentialsImported: number; - pluginItemsImported: number; - credentialUsageImported: number; - settingsImported: number; - skippedItems: number; - errors: string[]; -} - -// ============================================================================ -// DOCKER TYPES -// ============================================================================ diff --git a/src/types/ui-preferences.ts b/src/types/ui-preferences.ts index bf0373bd8..673a3544d 100644 --- a/src/types/ui-preferences.ts +++ b/src/types/ui-preferences.ts @@ -17,7 +17,7 @@ */ /** 2: the docker and host metrics areas moved to their plugins. */ -export const UI_PREFERENCES_VERSION = 2; +export const UI_PREFERENCES_VERSION = 3; /** Bump when onboarding gains steps existing users should be shown again. */ export const UI_ONBOARDING_VERSION = 2; @@ -30,16 +30,12 @@ export type UiAreaKey = | "credentialList" | "rail" | "dashboard" - | "terminal" - | "fileManager" | "hostEditor"; export type UiDensity = "comfortable" | "compact"; export type UiTrayTrigger = "always" | "hover" | "click" | "actionsOnly"; export type UiRowActions = "essential" | "full"; export type UiEmptyStateVerbosity = "minimal" | "guided"; -export type UiToolbarDensity = "icon" | "labeled" | "expanded"; -export type UiFileViewMode = "grid" | "list"; export type UiHostEditorMode = "simple" | "full"; export interface UiChromePreferences { @@ -75,15 +71,6 @@ export interface UiDashboardPreferences { enabledCards: string[]; } -export interface UiTerminalPreferences { - toolbarDensity: UiToolbarDensity; -} - -export interface UiFileManagerPreferences { - viewMode: UiFileViewMode; - showHiddenFiles: boolean; -} - export interface UiHostEditorPreferences { mode: UiHostEditorMode; } @@ -94,8 +81,6 @@ export interface UiAreaPreferences { credentialList: UiCredentialListPreferences; rail: UiRailPreferences; dashboard: UiDashboardPreferences; - terminal: UiTerminalPreferences; - fileManager: UiFileManagerPreferences; hostEditor: UiHostEditorPreferences; } @@ -149,10 +134,8 @@ const BALANCED_DASHBOARD_CARDS = [ "host_status", "recent_activity", ]; -// Advanced adds service links but leaves network_graph and homepage_preview -// off: both are wide, and enabling them by default pushes the dashboard past -// the edge of the screen. They stay available in the Add card tray. -const ADVANCED_DASHBOARD_CARDS = [...BALANCED_DASHBOARD_CARDS, "service_links"]; +// Only core's own cards: a plugin's cards are added from the Add card tray. +const ADVANCED_DASHBOARD_CARDS = [...BALANCED_DASHBOARD_CARDS]; export const PRESETS: Record, UiAreaPreferences> = { simple: { @@ -181,8 +164,6 @@ export const PRESETS: Record, UiAreaPreferences> = { "host_status", ], }, - terminal: { toolbarDensity: "icon" }, - fileManager: { viewMode: "grid", showHiddenFiles: false }, hostEditor: { mode: "simple" }, }, balanced: { @@ -202,9 +183,6 @@ export const PRESETS: Record, UiAreaPreferences> = { credentialList: { density: "comfortable", showTags: true }, rail: { hiddenTabs: [] }, dashboard: { enabledCards: BALANCED_DASHBOARD_CARDS }, - terminal: { toolbarDensity: "labeled" }, - // FileManager.tsx has always defaulted to grid when nothing is stored. - fileManager: { viewMode: "grid", showHiddenFiles: false }, // 3 is defaultLayoutFromWidgets's own default, i.e. today's behavior. hostEditor: { mode: "full" }, }, @@ -225,9 +203,6 @@ export const PRESETS: Record, UiAreaPreferences> = { credentialList: { density: "compact", showTags: true }, rail: { hiddenTabs: [] }, dashboard: { enabledCards: ADVANCED_DASHBOARD_CARDS }, - terminal: { toolbarDensity: "expanded" }, - // List packs more files and metadata per screen than the grid. - fileManager: { viewMode: "list", showHiddenFiles: true }, hostEditor: { mode: "full" }, }, }; @@ -273,16 +248,6 @@ const AREA_SPECS: { dashboard: { enabledCards: { kind: "stringArray" }, }, - terminal: { - toolbarDensity: { - kind: "enum", - values: ["icon", "labeled", "expanded"], - }, - }, - fileManager: { - viewMode: { kind: "enum", values: ["grid", "list"] }, - showHiddenFiles: { kind: "bool" }, - }, hostEditor: { mode: { kind: "enum", values: ["simple", "full"] }, }, @@ -349,11 +314,21 @@ function sanitizePluginArea(input: unknown): Record { return out; } -/** Version 1 stored the docker and host metrics areas under core names. */ -const LEGACY_PLUGIN_AREAS: Record = { - docker: "plugin:docker", - hostMetrics: "plugin:host-metrics", -}; +/** + * Areas that moved into a plugin: before `version` they were stored under + * core's name. Version 1 had docker and host metrics, version 2 the terminal + * and the file manager. + */ +const MOVED_PLUGIN_AREAS: Array<{ + before: number; + legacy: string; + area: UiPluginAreaKey; +}> = [ + { before: 2, legacy: "docker", area: "plugin:docker" }, // plugin-id-ok: 2.8 key + { before: 2, legacy: "hostMetrics", area: "plugin:host-metrics" }, + { before: 3, legacy: "terminal", area: "plugin:ssh-terminal" }, + { before: 3, legacy: "fileManager", area: "plugin:file-manager" }, +]; export function sanitizeUiOverrides( input: unknown, @@ -362,9 +337,10 @@ export function sanitizeUiOverrides( const out: Record> = {}; if (!input || typeof input !== "object") return out as UiOverrides; - if (version < 2) { + const moves = MOVED_PLUGIN_AREAS.filter((move) => version < move.before); + if (moves.length > 0) { const upgraded = { ...(input as Record) }; - for (const [legacy, area] of Object.entries(LEGACY_PLUGIN_AREAS)) { + for (const { legacy, area } of moves) { if (legacy in upgraded) { upgraded[area] = upgraded[legacy]; delete upgraded[legacy]; diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index 970f8294b..15a8982b5 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -166,12 +166,6 @@ export type Tab = { host?: Host; openedAt: number; restoredSessionId?: string | null; - /** Set when this tab joins someone else's live shared session instead of connecting/attaching its own. */ - joinSharedSessionId?: string | null; - joinShareId?: string | null; - initialFilePath?: string; - /** Directory to open a Files tab into, distinct from initialFilePath (a specific file to open in an editor window). */ - initialPath?: string; /** Payload owned by the tab's plugin, e.g. which fleet or endpoint it shows. */ data?: Record; /** Present only on a split-screen container tab. Pane tab ids reference live child tabs. */ @@ -189,7 +183,6 @@ export type Tab = { notifyResize?: () => void; refresh?: () => void; getApplicationCursorKeysMode?: () => boolean; - openFileManager?: () => void; focus?: () => void; } | null>; }; @@ -281,9 +274,9 @@ export type WorkspaceTabSnapshot = { hostNameSnapshot?: string | null; label: string; customLabel?: string; + /** Read from payloads saved before tabs carried `data`. */ initialFilePath?: string; initialPath?: string; - /** Read from payloads saved before tabs carried `data`. */ fleetId?: number; /** The tab's plugin payload. */ data?: Record; @@ -323,17 +316,3 @@ export type WorkspacePayload = { right: WorkspaceDockState; }; }; - -const FOLDER_ICONS = [ - "folder", - "server", - "cloud", - "database", - "box", - "network", - "copy", - "settings", - "cpu", - "globe", -] as const; -type FolderIconId = (typeof FOLDER_ICONS)[number]; diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index 657d1ed3d..e7f02c4c4 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -230,7 +230,6 @@ import { } from "@/shell/tab-registry"; import { runHostAction } from "@/sidebar/host-contributions"; import { getPanel, usePanels } from "@/shell/panel-registry"; -import { invokeAction } from "@/shell/action-registry"; import { usePluginStore } from "@/plugin-host/plugin-store"; import { notifyShellReady, @@ -1142,19 +1141,6 @@ export function AppShell({ ); }, [allHosts]); - // Let HostManager trigger tab opens via custom event - useEffect(() => { - const handle = (e: Event) => { - const { hostId, type } = ( - e as CustomEvent<{ hostId: string; type?: TabType }> - ).detail; - const host = allHosts.find((h) => h.id === hostId); - if (host) connectHost(host, type); - }; - window.addEventListener("termix:open-tab", handle); - return () => window.removeEventListener("termix:open-tab", handle); - }, [allHosts]); - function buildWorkspacePayload(): WorkspacePayload { return buildLayoutPayload({ tabs, @@ -1205,13 +1191,16 @@ export function AppShell({ } if (target.kind === "host") { - const newTabId = openTab(target.host, snapshot.type, { - instanceId: createId(), - restoredSessionId: null, - savedLabel: snapshot.customLabel ?? snapshot.label, - initialFilePath: snapshot.initialFilePath, - initialPath: snapshot.initialPath, - }); + const newTabId = openTab( + target.host, + snapshot.type, + { + instanceId: createId(), + restoredSessionId: null, + savedLabel: snapshot.customLabel ?? snapshot.label, + }, + { data: snapshotData(snapshot) }, + ); slotIdToNewTabId.set(snapshot.slotId, newTabId); } } @@ -1474,10 +1463,6 @@ export function AppShell({ instanceId: string; restoredSessionId: string | null; savedLabel?: string; - initialFilePath?: string; - initialPath?: string; - joinSharedSessionId?: string | null; - joinShareId?: string | null; }, options?: { data?: Record; @@ -1506,10 +1491,6 @@ export function AppShell({ let finalLabel = host.name; const savedLabel = restore?.savedLabel; - const initialFilePath = restore?.initialFilePath; - const initialPath = restore?.initialPath; - const joinSharedSessionId = restore?.joinSharedSessionId ?? null; - const joinShareId = restore?.joinShareId ?? null; // A saved label that doesn't match the bare host name or the auto-numbered pattern is a custom label const isCustomLabel = savedLabel != null && @@ -1531,10 +1512,7 @@ export function AppShell({ openedAt, terminalRef: ref, restoredSessionId: restore?.restoredSessionId ?? null, - joinSharedSessionId, - joinShareId, - initialFilePath, - initialPath, + data: options?.data, }, ]; } @@ -1568,10 +1546,6 @@ export function AppShell({ openedAt, terminalRef: ref, restoredSessionId: restore?.restoredSessionId ?? null, - joinSharedSessionId, - joinShareId, - initialFilePath, - initialPath, data: options?.data, }, ]; @@ -1690,7 +1664,6 @@ export function AppShell({ const id = type; const singletonLabels: Partial> = { "host-manager": t("nav.hostManager"), - sftp: t("nav.sftp"), }; // A plugin tab names itself; promoted rail panels reuse the rail's own // label so the two stay in sync. @@ -1701,7 +1674,7 @@ export function AppShell({ setTabs((prev) => { const existing = prev.find((t) => t.id === id); if (existing) { - // --- tmux-monitor --- refocusing with a host preselects it + // Refocusing a singleton with a host or data passes it on. if (!host && data === undefined) return prev; return prev.map((t) => t.id === id @@ -1721,7 +1694,7 @@ export function AppShell({ type, label, openedAt: Date.now(), - ...(host ? { host } : {}), // --- tmux-monitor --- + ...(host ? { host } : {}), ...(data !== undefined ? { data } : {}), }, ]; @@ -2537,6 +2510,7 @@ export function AppShell({ }, openSingletonTab: (type, options) => openSingletonTab(type, undefined, undefined, options?.data), + connectHost: (host, type) => connectHost(host, type as TabType), closeTab: (tabId) => closeTab(tabId), renameTab: (tabId, label) => renameTab(tabId, label), @@ -2697,35 +2671,6 @@ export function AppShell({ }} onRenameTab={renameTab} onReorderTabs={setTabs} - onJoinSharedSession={(session) => { - if (!session.shareId) return; - const existingHost = allHosts.find( - (h) => h.id === String(session.hostId), - ); - const host: Host = existingHost ?? { - id: String(session.hostId), - name: session.hostName, - username: "", - ip: "", - port: 0, - folder: "", - online: false, - cpu: null, - ram: null, - lastAccess: new Date().toISOString(), - authType: "none", - enableSsh: false, - sshPort: 22, - }; - void invokeAction("terminal.open", host, { - joinSharedSessionId: session.sessionId, - joinShareId: session.shareId, - label: t("connections.sharedSessionLabel", { - hostName: session.hostName, - }), - }); - if (isMobile) setSidebarOpen(false); - }} />
)} @@ -3037,12 +2982,6 @@ export function AppShell({ onReorderTabs={reorderTopLevelTabs} onSplitAction={handleTabSplitAction} onRenameTab={renameTab} - onOpenFileManager={(tabId) => { - const targetTab = tabs.find((t) => t.id === tabId); - if (targetTab?.host) { - void invokeAction("host.openFiles", targetTab.host); - } - }} isAppFullscreen={isAppFullscreen} onToggleAppFullscreen={toggleAppFullscreen} rightDockOpen={rightRailView !== null} diff --git a/src/ui/api/auth-methods-api.ts b/src/ui/api/auth-methods-api.ts index 11c129346..d1d888179 100644 --- a/src/ui/api/auth-methods-api.ts +++ b/src/ui/api/auth-methods-api.ts @@ -11,7 +11,7 @@ export interface PublicLoginMethod { kind: "redirect" | "form"; labelKey: string; icon?: string; - instances: Array<{ id: string; label: string }>; + instances: Array<{ id: string; label: string; autoStart?: boolean }>; } export interface SecondFactorRef { diff --git a/src/ui/api/dashboard-api.ts b/src/ui/api/dashboard-api.ts index 5b618148c..35899372a 100644 --- a/src/ui/api/dashboard-api.ts +++ b/src/ui/api/dashboard-api.ts @@ -12,7 +12,7 @@ export interface UptimeInfo { export interface RecentActivityItem { id: number; userId: string; - /** Core records file_manager and tunnel; plugin tabs record their own. */ + /** The activity type; each plugin records its own. */ type: string; hostId: number; hostName: string; diff --git a/src/ui/api/external-account-api.ts b/src/ui/api/external-account-api.ts new file mode 100644 index 000000000..646775be2 --- /dev/null +++ b/src/ui/api/external-account-api.ts @@ -0,0 +1,36 @@ +import { authApi, handleApiError } from "@/main-axios"; + +// EXTERNAL ACCOUNT LINKING +// ============================================================================ + +export async function linkExternalToPasswordAccount( + externalUserId: string, + targetUsername: string, +): Promise<{ success: boolean; message: string }> { + try { + const response = await authApi.post("/users/link-external-to-password", { + externalUserId, + targetUsername, + }); + return response.data; + } catch (error) { + throw handleApiError(error, "link external account to password account"); + } +} + +export async function unlinkExternalFromPasswordAccount( + userId: string, +): Promise<{ success: boolean; message: string }> { + try { + const response = await authApi.post( + "/users/unlink-external-from-password", + { userId }, + ); + return response.data; + } catch (error) { + throw handleApiError( + error, + "unlink external sign-in from password account", + ); + } +} diff --git a/src/ui/api/oidc-account-api.ts b/src/ui/api/oidc-account-api.ts deleted file mode 100644 index 238636258..000000000 --- a/src/ui/api/oidc-account-api.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { authApi, handleApiError } from "@/main-axios"; - -// OIDC ACCOUNT LINKING -// ============================================================================ - -export async function linkOIDCToPasswordAccount( - oidcUserId: string, - targetUsername: string, -): Promise<{ success: boolean; message: string }> { - try { - const response = await authApi.post("/users/link-oidc-to-password", { - oidcUserId, - targetUsername, - }); - return response.data; - } catch (error) { - throw handleApiError(error, "link OIDC account to password account"); - } -} - -export async function unlinkOIDCFromPasswordAccount( - userId: string, -): Promise<{ success: boolean; message: string }> { - try { - const response = await authApi.post("/users/unlink-oidc-from-password", { - userId, - }); - return response.data; - } catch (error) { - throw handleApiError(error, "unlink OIDC from password account"); - } -} diff --git a/src/ui/api/open-tabs-api.ts b/src/ui/api/open-tabs-api.ts index ddc8b7ef8..e1757bd27 100644 --- a/src/ui/api/open-tabs-api.ts +++ b/src/ui/api/open-tabs-api.ts @@ -1,9 +1,6 @@ import { authApi } from "@/main-axios"; import { createTtlRequestCache } from "@/lib/ttl-request-cache"; import type { CustomKeybinding } from "@/types/keybindings"; -import { invokeAction, isActionRegistered } from "@/shell/action-registry"; - -const SHARED_WITH_ME_ACTION = "sessions.sharedWithMe"; // OPEN TABS API // ============================================================================ @@ -45,10 +42,6 @@ export interface ActiveSessionInfo { tabInstanceId: string | null; isConnected: boolean; createdAt: number; - isOwnSession: boolean; - sharedByUsername: string | null; - permissionLevel: string | null; - shareId: string | null; } // Negative IDs identify remote-only shared hosts, absent from the local DB. @@ -86,21 +79,11 @@ export async function addOpenTab(tab: OpenTabUpsertPayload): Promise { await authApi.post("/open-tabs", tab); } -/** - * The caller's live sessions, plus sessions other users shared with them, - * which the session sharing plugin answers through an action (none while it - * is off). - */ +/** The caller's own live sessions. */ export async function getActiveSessions(): Promise { return activeSessionsCache.get(async () => { - const [response, shared] = await Promise.all([ - authApi.get("/open-tabs/active-sessions"), - isActionRegistered(SHARED_WITH_ME_ACTION) - ? invokeAction(SHARED_WITH_ME_ACTION).catch(() => []) - : Promise.resolve([]), - ]); - const own = Array.isArray(response.data) ? response.data : []; - return Array.isArray(shared) ? [...own, ...shared] : own; + const response = await authApi.get("/open-tabs/active-sessions"); + return Array.isArray(response.data) ? response.data : []; }); } diff --git a/src/ui/api/plugins-api.ts b/src/ui/api/plugins-api.ts index 1969a58c1..a6e5a9c5b 100644 --- a/src/ui/api/plugins-api.ts +++ b/src/ui/api/plugins-api.ts @@ -78,19 +78,10 @@ export interface PluginContributions { uiPresets?: PluginUiPresets; } -/** A secret as it arrives from the server. The value never leaves the server. */ -export interface RedactedSecret { - set: boolean; -} - -export function isRedactedSecret(value: unknown): value is RedactedSecret { - return ( - typeof value === "object" && - value !== null && - !Array.isArray(value) && - typeof (value as RedactedSecret).set === "boolean" - ); -} +export { + isRedactedSecret, + type RedactedSecret, +} from "@termix/plugin-sdk/settings"; export type PluginSettingsValues = Record; diff --git a/src/ui/api/settings-api.ts b/src/ui/api/settings-api.ts index 06106e2be..b01393d7a 100644 --- a/src/ui/api/settings-api.ts +++ b/src/ui/api/settings-api.ts @@ -76,7 +76,7 @@ export async function updateAnalyticsEnabled( } // ============================================================================ -// TERMINAL IMAGE STORAGE SETTINGS +// BRANDING SETTINGS // ============================================================================ export interface BrandingSettings { diff --git a/src/ui/api/sse-stream.ts b/src/ui/api/sse-stream.ts deleted file mode 100644 index d438aa27c..000000000 --- a/src/ui/api/sse-stream.ts +++ /dev/null @@ -1,46 +0,0 @@ -export interface ServerSentEvent { - event: string; - data: string; -} - -export async function streamServerSentEvents( - url: string, - init: RequestInit, - onEvent: (event: ServerSentEvent) => void, -): Promise { - const response = await fetch(url, init); - if (!response.ok) { - throw new Error(`SSE request failed with status ${response.status}`); - } - if (!response.body) throw new Error("SSE response has no body"); - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - let eventName = "message"; - let data: string[] = []; - - const dispatch = () => { - if (data.length > 0) onEvent({ event: eventName, data: data.join("\n") }); - eventName = "message"; - data = []; - }; - - while (true) { - const { done, value } = await reader.read(); - buffer += decoder.decode(value, { stream: !done }); - const lines = buffer.split(/\r?\n/); - buffer = done ? "" : (lines.pop() ?? ""); - - for (const line of lines) { - if (line === "") dispatch(); - else if (line.startsWith("event:")) eventName = line.slice(6).trimStart(); - else if (line.startsWith("data:")) data.push(line.slice(5).trimStart()); - } - - if (done) { - dispatch(); - return; - } - } -} diff --git a/src/ui/api/user-management-api.ts b/src/ui/api/user-management-api.ts index 205ac18ca..b0f97cadb 100644 --- a/src/ui/api/user-management-api.ts +++ b/src/ui/api/user-management-api.ts @@ -221,20 +221,22 @@ export async function updateRegistrationAllowed( } } -export async function getOidcAutoProvision(): Promise<{ enabled: boolean }> { +export async function getExternalAutoProvision(): Promise<{ + enabled: boolean; +}> { try { - const response = await authApi.get("/users/oidc-auto-provision"); + const response = await authApi.get("/users/external-auto-provision"); return response.data; } catch (error) { handleApiError(error, "check OIDC auto-provision status"); } } -export async function updateOidcAutoProvision( +export async function updateExternalAutoProvision( enabled: boolean, ): Promise> { try { - const response = await authApi.patch("/users/oidc-auto-provision", { + const response = await authApi.patch("/users/external-auto-provision", { enabled, }); return response.data; @@ -270,31 +272,6 @@ export async function updateSecondFactorAfterExternalLogin( } } -export async function getOidcSilentLoginDefault(): Promise<{ - enabled: boolean; - locked?: boolean; -}> { - try { - const response = await authApi.get("/users/oidc-silent-login-default"); - return response.data; - } catch (error) { - handleApiError(error, "get OIDC silent login default"); - } -} - -export async function updateOidcSilentLoginDefault( - enabled: boolean, -): Promise<{ enabled: boolean }> { - try { - const response = await authApi.patch("/users/oidc-silent-login-default", { - enabled, - }); - return response.data; - } catch (error) { - handleApiError(error, "update OIDC silent login default"); - } -} - export async function updatePasswordLoginAllowed( allowed: boolean, ): Promise<{ allowed: boolean }> { diff --git a/src/ui/auth/Auth.tsx b/src/ui/auth/Auth.tsx index 6624c604f..ada612a5e 100644 --- a/src/ui/auth/Auth.tsx +++ b/src/ui/auth/Auth.tsx @@ -27,7 +27,6 @@ import { completePasswordReset, isElectron, getCurrentToken, - getOidcSilentLoginDefault, requestDesktopAutoSession, requestTrustedProxyLogin, } from "@/main-axios"; @@ -343,9 +342,6 @@ export function Auth({ onLogin }: AuthProps) { ); const silentSigninHandledRef = useRef(false); const proxySigninHandledRef = useRef(false); - const [oidcSilentLoginDefault, setOidcSilentLoginDefault] = useState(false); - const [oidcSilentLoginDefaultLoaded, setOidcSilentLoginDefaultLoaded] = - useState(false); const [firstUser, setFirstUser] = useState(false); const [dbConnectionFailed, setDbConnectionFailed] = useState(false); const [dbHealthChecking, setDbHealthChecking] = useState( @@ -401,7 +397,7 @@ export function Auth({ onLogin }: AuthProps) { useEffect(() => { if (!isInElectronWebView()) return; - const handleOIDCSystemBrowserResult = (event: MessageEvent) => { + const handleExternalBrowserResult = (event: MessageEvent) => { if (event.source !== window.parent) return; if (!event.data || typeof event.data !== "object") return; if (event.data.type !== "OIDC_SYSTEM_BROWSER_AUTH_RESULT") return; @@ -418,15 +414,14 @@ export function Auth({ onLogin }: AuthProps) { toast.error(event.data.error || t("errors.failedOidcLogin")); }; - window.addEventListener("message", handleOIDCSystemBrowserResult); + window.addEventListener("message", handleExternalBrowserResult); return () => - window.removeEventListener("message", handleOIDCSystemBrowserResult); + window.removeEventListener("message", handleExternalBrowserResult); }, [t]); useEffect(() => { if (localDesktopAuth) { setAuthMethodsLoaded(true); - setOidcSilentLoginDefaultLoaded(true); return; } getRegistrationAllowed() @@ -444,10 +439,6 @@ export function Auth({ onLogin }: AuthProps) { .then((methods) => setAuthMethods(methods)) .catch(() => setAuthMethods([])) .finally(() => setAuthMethodsLoaded(true)); - getOidcSilentLoginDefault() - .then((res) => setOidcSilentLoginDefault(res.enabled)) - .catch(() => {}) - .finally(() => setOidcSilentLoginDefaultLoaded(true)); }, [localDesktopAuth]); useEffect(() => { @@ -997,7 +988,7 @@ export function Auth({ onLogin }: AuthProps) { const electronAPI = ( window as unknown as { electronAPI?: { - oidcSystemBrowserAuth?: ( + externalBrowserLogin?: ( authUrl: string, port: number, ) => Promise<{ @@ -1011,14 +1002,14 @@ export function Auth({ onLogin }: AuthProps) { }; } ).electronAPI; - if (electronAPI?.oidcSystemBrowserAuth) { + if (electronAPI?.externalBrowserLogin) { const authUrl = await startLoginRedirect(methodId, { instanceId, rememberMe, desktopCallbackPort: callbackPort, }); if (!authUrl) throw new Error(t("errors.invalidAuthUrl")); - const result = await electronAPI.oidcSystemBrowserAuth( + const result = await electronAPI.externalBrowserLogin( authUrl, callbackPort, ); @@ -1093,10 +1084,18 @@ export function Auth({ onLogin }: AuthProps) { useEffect(() => { if (!authMethodsLoaded || silentSigninHandledRef.current) return; - if (!oidcSilentLoginDefaultLoaded) return; + // A login method may ask to start without a click (the sso plugin's + // silent sign-in setting); ?silent_signin asks for it once. + const autoStart = externalMethods + .filter((method) => method.kind === "redirect") + .flatMap((method) => + method.instances + .filter((instance) => instance.autoStart) + .map((instance) => ({ method, instance })), + )[0]; const urlTriggered = shouldTriggerSilentSignin(window.location.search); - if (!urlTriggered && !oidcSilentLoginDefault) return; + if (!urlTriggered && !autoStart) return; if (urlTriggered) { const nextSearch = removeSilentSigninFromSearch(window.location.search); @@ -1112,22 +1111,20 @@ export function Auth({ onLogin }: AuthProps) { const redirectMethod = externalMethods.find( (method) => method.kind === "redirect" && method.instances.length > 0, ); - if (redirectMethod && !isElectron()) { - void startRedirect(redirectMethod.id, redirectMethod.instances[0].id); + const target = + autoStart ?? + (redirectMethod + ? { method: redirectMethod, instance: redirectMethod.instances[0] } + : null); + if (target && !isElectron()) { + void startRedirect(target.method.id, target.instance.id); return; } if (urlTriggered) { toast.info(t("errors.silentSigninOidcUnavailable")); } - }, [ - startRedirect, - authMethodsLoaded, - externalMethods, - t, - oidcSilentLoginDefault, - oidcSilentLoginDefaultLoaded, - ]); + }, [startRedirect, authMethodsLoaded, externalMethods, t]); // Electron, non-iframed: wait for the auto-session probe before rendering // anything, so a standalone desktop install never flashes a login form diff --git a/src/ui/auth/ElectronLoginForm.tsx b/src/ui/auth/ElectronLoginForm.tsx index 46adaf229..973342a4c 100644 --- a/src/ui/auth/ElectronLoginForm.tsx +++ b/src/ui/auth/ElectronLoginForm.tsx @@ -121,7 +121,7 @@ export function ElectronLoginForm({ const electronAPI = ( window as unknown as { electronAPI?: { - oidcSystemBrowserAuth?: ( + externalBrowserLogin?: ( url: string, port: number, ) => Promise<{ @@ -135,14 +135,14 @@ export function ElectronLoginForm({ }; } ).electronAPI; - if (!electronAPI?.oidcSystemBrowserAuth) { + if (!electronAPI?.externalBrowserLogin) { sendResultToIframe({ success: false, error: t("errors.failedOidcLogin"), }); return; } - const result = await electronAPI.oidcSystemBrowserAuth( + const result = await electronAPI.externalBrowserLogin( authUrl, callbackPort, ); diff --git a/src/ui/features/FullScreenAppWrapper.tsx b/src/ui/components/FullScreenAppWrapper.tsx similarity index 98% rename from src/ui/features/FullScreenAppWrapper.tsx rename to src/ui/components/FullScreenAppWrapper.tsx index 2f0fae277..2db4a967f 100644 --- a/src/ui/features/FullScreenAppWrapper.tsx +++ b/src/ui/components/FullScreenAppWrapper.tsx @@ -48,7 +48,7 @@ export const FullScreenAppWrapper: React.FC = ({ }, []); useEffect(() => { - // Popped-out windows (terminal/tunnel/file-manager/etc.) share the main + // Popped-out plugin windows share the main // window's session rather than owning their own login -- there's no // login form to fall back to here. On a cold Electron launch this // window's renderer can start before the embedded backend has finished diff --git a/src/ui/components/connection/connection-status.ts b/src/ui/components/connection/connection-status.ts index 53a855a76..8a8ee3d83 100644 --- a/src/ui/components/connection/connection-status.ts +++ b/src/ui/components/connection/connection-status.ts @@ -1,40 +1,2 @@ -import type { ConnectionStage } from "@/types/connection-log.ts"; - export type ConnectionStatus = "connecting" | "connected" | "error" | "disconnected"; - -// Guacamole client states, per guacamole-common-js Guacamole.Client#STATE_*. -const GUAC_STATE_IDLE = 0; -const GUAC_STATE_CONNECTING = 1; -const GUAC_STATE_WAITING = 2; -const GUAC_STATE_CONNECTED = 3; -const GUAC_STATE_DISCONNECTING = 4; -const GUAC_STATE_DISCONNECTED = 5; - -export function guacStateToStage(state: number): ConnectionStage { - switch (state) { - case GUAC_STATE_CONNECTING: - return "guac_connecting"; - case GUAC_STATE_WAITING: - return "guac_handshake"; - case GUAC_STATE_CONNECTED: - return "guac_ready"; - case GUAC_STATE_DISCONNECTING: - case GUAC_STATE_DISCONNECTED: - return "guac_disconnected"; - case GUAC_STATE_IDLE: - default: - return "guac_connecting"; - } -} - -export function guacStateToStatus(state: number): ConnectionStatus { - switch (state) { - case GUAC_STATE_CONNECTED: - return "connected"; - case GUAC_STATE_DISCONNECTED: - return "error"; - default: - return "connecting"; - } -} diff --git a/src/ui/dashboard/DashboardTab.tsx b/src/ui/dashboard/DashboardTab.tsx index 7c00f6985..77e96d850 100644 --- a/src/ui/dashboard/DashboardTab.tsx +++ b/src/ui/dashboard/DashboardTab.tsx @@ -484,9 +484,9 @@ export function HostStatusCard({
- {/* Plugins such as host metrics show live usage here. */} + {/* Plugins add live details to a host row here. */} { + // An activate that outlived its timeout, or ran on after a disable, still + // registers things. Nothing will dispose them later, so undo them now. + if (disposed) { + try { + disposer(); + } catch { + // A failing disposer must not throw into the plugin's activate. + } + return () => {}; + } bag.push(disposer); return () => { const index = bag.indexOf(disposer); @@ -526,6 +536,7 @@ export function createPluginApp( ...tabsApi, openTab: shell.openTab as TermixApp["tabs"]["openTab"], openSingletonTab: shell.openSingletonTab, + connectHost: shell.connectHost as TermixApp["tabs"]["connectHost"], closeTab: shell.closeTab, onChange: (listener) => track(tabsApi.onChange(listener)), onReady: (listener) => track(tabsApi.onReady(listener)), diff --git a/src/ui/plugin-host/bridge.ts b/src/ui/plugin-host/bridge.ts index 1aa800d78..d6b4a7681 100644 --- a/src/ui/plugin-host/bridge.ts +++ b/src/ui/plugin-host/bridge.ts @@ -1,4 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; +import { isElectron } from "@/lib/electron"; +import { readStatusColorScheme } from "@/hooks/use-status-color-scheme"; import { enabledHostProtocols } from "@/sidebar/host-protocols"; import { useHostActions } from "@/sidebar/host-contributions"; import { tabTypeForActivity, useTabTypes } from "@/shell/tab-registry"; @@ -430,6 +432,18 @@ export const pluginHostBridge: PluginHostBridge = { (await getSSHHosts({ includeStatus: false })).map((host) => toPluginHostRecord(host, pluginId ?? null), ), + notifyHostsChanged: () => { + window.dispatchEvent(new CustomEvent("termix:hosts-changed")); + }, + getHostStatusColorScheme: () => readStatusColorScheme(), + getLocalAuthToken: () => { + if (!isElectron()) return null; + try { + return localStorage.getItem("jwt"); + } catch { + return null; + } + }, listCredentials: async () => { const raw = await getCredentials(); const list = Array.isArray(raw) diff --git a/src/ui/plugin-host/desktop.ts b/src/ui/plugin-host/desktop.ts index 4755f4955..3df8ea1c5 100644 --- a/src/ui/plugin-host/desktop.ts +++ b/src/ui/plugin-host/desktop.ts @@ -3,7 +3,19 @@ import { getLinkedSession } from "@/lib/linked-server"; import { SYNC_CHANGED_EVENT } from "@/lib/sync-events"; +type RemoteServerSource = () => string | null | Promise; + +let testSource: RemoteServerSource | null = null; + +/** renderWithApp's stand-in for the linked server. */ +export function setRemoteServerUrlForTesting( + source: RemoteServerSource | null, +): void { + testSource = source; +} + export async function remoteServerUrl(): Promise { + if (testSource) return (await testSource()) ?? null; return (await getLinkedSession())?.serverUrl ?? null; } diff --git a/src/ui/plugin-host/sdk-ui.ts b/src/ui/plugin-host/sdk-ui.ts index 2b181ddc9..868cb8ee0 100644 --- a/src/ui/plugin-host/sdk-ui.ts +++ b/src/ui/plugin-host/sdk-ui.ts @@ -12,44 +12,127 @@ */ // Primitives -export * from "@/components/alert"; -export * from "@/components/alert-dialog"; -export * from "@/components/badge"; -export * from "@/components/button"; -export * from "@/components/card"; -export * from "@/components/checkbox"; -export * from "@/components/dialog"; -export * from "@/components/dropdown-menu"; -export * from "@/components/input"; -export * from "@/components/label"; -export * from "@/components/password-input"; -export * from "@/components/popover"; -export * from "@/components/scroll-area"; -export * from "@/components/select"; -export * from "@/components/select2"; -export * from "@/components/separator"; -export * from "@/components/skeleton"; -export * from "@/components/slider"; -export * from "@/components/switch"; -export * from "@/components/textarea"; -export * from "@/components/tooltip"; +export { Alert, AlertDescription, AlertTitle } from "@/components/alert"; +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/alert-dialog"; +export { Badge } from "@/components/badge"; +export { Button, type ButtonProps } from "@/components/button"; +export { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/card"; +export { Checkbox } from "@/components/checkbox"; +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} from "@/components/dialog"; +export { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/dropdown-menu"; +export { Input } from "@/components/input"; +export { Label } from "@/components/label"; +export { PasswordInput } from "@/components/password-input"; +export { + Popover, + PopoverAnchor, + PopoverContent, + PopoverTrigger, +} from "@/components/popover"; +export { ScrollArea, ScrollBar } from "@/components/scroll-area"; +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} from "@/components/select"; +export { Select2 } from "@/components/select2"; +export { Separator } from "@/components/separator"; +export { Skeleton } from "@/components/skeleton"; +export { Slider } from "@/components/slider"; +export { Switch } from "@/components/switch"; +export { Textarea, type TextareaProps } from "@/components/textarea"; +export { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/tooltip"; // Composites -export * from "@/components/section-card"; -export * from "@/components/metric-card"; -export * from "@/components/charts"; +export { FakeSwitch, SectionCard, SettingRow } from "@/components/section-card"; +export { MetricCard } from "@/components/metric-card"; +export { + BarSeries, + MiniStat, + RadialGauge, + Sparkline, + StatRow, + type BarSeriesItem, + type BarSeriesProps, + type RadialGaugeProps, + type SparklineProps, +} from "@/components/charts"; export { LineChart, type LineChartSeries } from "@/components/charts/LineChart"; export { CardGridCanvas, ColumnCountStepper, } from "@/components/card-grid/CardGridCanvas"; -export type * from "@/components/card-grid/types"; +export { + type GridCardCatalogEntry, + type GridColSpan, + type GridLayout, + type GridSlot, +} from "@/components/card-grid/types"; export { ConnectionScreen } from "@/components/connection/ConnectionScreen"; -export * from "@/components/connection/connection-status"; +export { type ConnectionStatus } from "@/components/connection/connection-status"; export { FullScreenAppWrapper, type FullScreenAppPhase, -} from "@/features/FullScreenAppWrapper"; +} from "@/components/FullScreenAppWrapper"; // Connection surfaces export { @@ -87,7 +170,16 @@ export { runAdaptivePolling, getPollingEnvironmentMultiplier, } from "@/lib/adaptive-polling"; -export * from "@/components/sheet"; +export { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/sheet"; // The frame and filter box every host manager card uses. export { ManagerCardShell, @@ -116,7 +208,12 @@ export { useTheme as useAppTheme } from "@/components/theme-provider"; export { findMatchingKeybinding } from "@/lib/keybinding-match"; export { globalShortcutHandler } from "@/lib/global-shortcut-handler"; export { isMacPlatform, isTabJumpHotkey } from "@/lib/tab-jump-hotkey"; -export type * from "@/types/keybindings"; +export { + type CustomKeybinding, + type KeyCombo, + type KeybindingAction, + type KeybindingActionType, +} from "@/types/keybindings"; // Connection helpers: which backend a host's session dials, and the pieces // the desktop app needs to reach it. @@ -170,22 +267,4 @@ export { export { getDeviceId } from "@/lib/device-id"; // A host that is never saved, for connecting to an address straight away. -export { - createQuickConnectHost, - isQuickConnectHost, -} from "@/sidebar/quick-connect-host"; - -// The desktop app's local file bridge (window.electronAPI.localFs and -// localTransfer), for plugins that browse or transfer local files. -export type { - LocalCollectedFile, - LocalDirectoryListing, - LocalFileEntry, - LocalFsHomeInfo, - LocalFsResult, - LocalTransferOrigin, - LocalTransferProgress, - LocalTrashResult, - LocalWalkFile, - LocalWalkResult, -} from "@/types/electron"; +export { createQuickConnectHost } from "@/sidebar/quick-connect-host"; diff --git a/src/ui/plugin-host/shell-bridge.ts b/src/ui/plugin-host/shell-bridge.ts index 91e423cbe..b5678efab 100644 --- a/src/ui/plugin-host/shell-bridge.ts +++ b/src/ui/plugin-host/shell-bridge.ts @@ -107,6 +107,8 @@ export function shellHost(id: string | number | undefined): Host | undefined { export const shell: TabShellCallbacks = { openTab: (host, ...rest) => callbacks?.openTab(withShellHost(host), ...rest), openSingletonTab: (...args) => callbacks?.openSingletonTab(...args), + connectHost: (host, ...rest) => + callbacks?.connectHost(withShellHost(host), ...rest), closeTab: (...args) => callbacks?.closeTab(...args), renameTab: (...args) => callbacks?.renameTab(...args), openRailView: (...args) => callbacks?.openRailView(...args), @@ -119,6 +121,7 @@ export const shell: TabShellCallbacks = { export const tabsApi = { openTab: shell.openTab, openSingletonTab: shell.openSingletonTab, + connectHost: shell.connectHost, closeTab: shell.closeTab, openRailView: shell.openRailView, getLayout: () => layoutProvider?.getLayout() ?? null, diff --git a/src/ui/plugin-host/testing-host.tsx b/src/ui/plugin-host/testing-host.tsx index ba511988f..1ac73791f 100644 --- a/src/ui/plugin-host/testing-host.tsx +++ b/src/ui/plugin-host/testing-host.tsx @@ -60,6 +60,7 @@ import { ActionSlot, ComponentSlot } from "@/shell/ActionSlot"; import type { Host, Tab } from "@/types/ui-types"; import { createPluginApp } from "./app"; import { installPluginHostBridge, setPluginApiForTesting } from "./bridge"; +import { setRemoteServerUrlForTesting } from "./desktop"; import { setPluginSummaries, setFrontendState } from "./plugin-store"; import { notifyShellReady, @@ -81,6 +82,7 @@ function recordingShell(calls: ShellCall[]): TabShellCallbacks { return { openTab: record("openTab"), openSingletonTab: record("openSingletonTab"), + connectHost: record("connectHost"), closeTab: record("closeTab"), renameTab: record("renameTab"), openRailView: record("openRailView"), @@ -187,6 +189,7 @@ export async function renderPlugin( pluginId, (options.api as Parameters[1]) ?? null, ); + setRemoteServerUrlForTesting(options.remoteServerUrl ?? null); const handle = createPluginApp(pluginId, manifest, summary.contributes, { guest: options.guest, }); @@ -407,6 +410,7 @@ export async function renderPlugin( handle.dispose(); setFrontendState(pluginId, "inactive"); setPluginApiForTesting(pluginId, null); + setRemoteServerUrlForTesting(null); } }, }; diff --git a/src/ui/settings/FeatureSettingsSections.tsx b/src/ui/settings/FeatureSettingsSections.tsx index 2d7279fe5..428ae42c0 100644 --- a/src/ui/settings/FeatureSettingsSections.tsx +++ b/src/ui/settings/FeatureSettingsSections.tsx @@ -25,7 +25,7 @@ import { type PluginSummary, } from "@/api/plugins-api"; import { SettingsFieldRow } from "./SettingsFields"; -import { hasVisibleFields, isFieldActive } from "./settings-fields-util"; +import { hasVisibleFields, isFieldShown } from "./settings-fields-util"; export type FeatureSettingsScope = "admin" | "user"; @@ -187,7 +187,7 @@ function FeatureSettingsForm({ const rows = (group: PluginSettingsField[]) => group - .filter((field) => isFieldActive(field, values)) + .filter((field) => isFieldShown(field, values)) .map((field) => ( >; @@ -202,7 +202,7 @@ function HostPluginSection({ {enabled && host.fields - .filter((field) => isFieldActive(field, shown)) + .filter((field) => isFieldShown(field, shown)) .map((field) => ( , ): boolean { - if (field.hidden) return false; - if (!field.requires) return true; - return values[field.requires] === true; + return !field.hidden && isFieldActive(field, values); } /** Whether a plugin has any field the generic form would ever draw. */ diff --git a/src/ui/shell/TabBar.tsx b/src/ui/shell/TabBar.tsx index 884d20030..6a4abb017 100644 --- a/src/ui/shell/TabBar.tsx +++ b/src/ui/shell/TabBar.tsx @@ -26,7 +26,6 @@ import { Ungroup, Maximize2, Minimize2, - FolderOpen, PanelRight, } from "lucide-react"; import { tabIcon } from "@/shell/tabUtils"; @@ -71,7 +70,6 @@ export function TabBar({ onReorderTabs, onSplitAction, onRenameTab, - onOpenFileManager, isAppFullscreen, onToggleAppFullscreen, rightDockOpen, @@ -90,7 +88,6 @@ export function TabBar({ onReorderTabs: (tabs: Tab[]) => void; onSplitAction: (action: TabSplitAction) => void; onRenameTab?: (tabId: string, newLabel: string) => void; - onOpenFileManager?: (tabId: string) => void; isAppFullscreen: boolean; onToggleAppFullscreen: () => void; rightDockOpen?: boolean; @@ -712,21 +709,6 @@ export function TabBar({ {t("nav.refreshTab")} )} - {typeof ctxTab.terminalRef?.current?.openFileManager === - "function" && - ctxTab.host && - onOpenFileManager && ( - - )} {/* Plugins add entries here, invoked with the tab's surface handle. */} void; updateTab: (tabId: number, updates: Partial>) => void; - previewTerminalTheme: string | null; - setPreviewTerminalTheme: (theme: string | null) => void; } const TabContext = createContext(undefined); @@ -62,8 +60,6 @@ const NOOP_TABS: TabContextType = { reorderTabs: () => {}, updateHostConfig: () => {}, updateTab: () => {}, - previewTerminalTheme: null, - setPreviewTerminalTheme: () => {}, }; export function useTabsSafe(): TabContextType { @@ -86,9 +82,6 @@ export function TabProvider({ children }: TabProviderProps) { ]); const [currentTab, setCurrentTab] = useState(1); const [allSplitScreenTab, setAllSplitScreenTab] = useState([]); - const [previewTerminalTheme, setPreviewTerminalTheme] = useState< - string | null - >(null); const [initialMaxId] = useState(2); const nextTabId = useRef(initialMaxId); @@ -372,8 +365,6 @@ export function TabProvider({ children }: TabProviderProps) { reorderTabs, updateHostConfig, updateTab, - previewTerminalTheme, - setPreviewTerminalTheme, }), [ tabs, @@ -386,8 +377,6 @@ export function TabProvider({ children }: TabProviderProps) { reorderTabs, updateHostConfig, updateTab, - previewTerminalTheme, - setPreviewTerminalTheme, ], ); diff --git a/src/ui/shell/shell-layout.ts b/src/ui/shell/shell-layout.ts index 2213c72e2..611eefed5 100644 --- a/src/ui/shell/shell-layout.ts +++ b/src/ui/shell/shell-layout.ts @@ -66,8 +66,6 @@ export function buildLayoutTabSnapshots( hostNameSnapshot: tab.host?.name ?? null, label: tab.label, customLabel: tab.customLabel, - initialFilePath: tab.initialFilePath, - initialPath: tab.initialPath, data: tab.data, })); @@ -82,9 +80,13 @@ export function snapshotData( snapshot: WorkspaceTabSnapshot, ): Record | undefined { if (snapshot.data) return snapshot.data; - return snapshot.fleetId !== undefined - ? { fleetId: snapshot.fleetId } - : undefined; + // Payloads saved before tabs carried `data` kept these as fields of their own. + const legacy: Record = {}; + if (snapshot.fleetId !== undefined) legacy.fleetId = snapshot.fleetId; + if (snapshot.initialFilePath) + legacy.initialFilePath = snapshot.initialFilePath; + if (snapshot.initialPath) legacy.initialPath = snapshot.initialPath; + return Object.keys(legacy).length > 0 ? legacy : undefined; } /** diff --git a/src/ui/shell/tab-registry.ts b/src/ui/shell/tab-registry.ts index 7de98dc69..e2239bbd9 100644 --- a/src/ui/shell/tab-registry.ts +++ b/src/ui/shell/tab-registry.ts @@ -22,6 +22,8 @@ export interface TabShellCallbacks { type: string, options?: { label?: string; data?: Record }, ) => void; + /** Connects the way clicking the host in the host list does. */ + connectHost: (host: Host, type?: string) => void; closeTab: (tabId: string) => void; renameTab: (tabId: string, label: string) => void; diff --git a/src/ui/sidebar/AdminSettingsPanel.tsx b/src/ui/sidebar/AdminSettingsPanel.tsx index 6655c76ce..a6ef66501 100644 --- a/src/ui/sidebar/AdminSettingsPanel.tsx +++ b/src/ui/sidebar/AdminSettingsPanel.tsx @@ -29,12 +29,10 @@ import { updateStatusCheckSettings, getLogLevel, updateLogLevel, - getOidcAutoProvision, - updateOidcAutoProvision, + getExternalAutoProvision, + updateExternalAutoProvision, getSecondFactorAfterExternalLogin, updateSecondFactorAfterExternalLogin, - getOidcSilentLoginDefault, - updateOidcSilentLoginDefault, isElectron, getUserRoles, } from "@/main-axios"; @@ -128,25 +126,19 @@ export function AdminSettingsPanel({ const [logLevel, setLogLevel] = useState("info"); const [analyticsEnabled, setAnalyticsEnabled] = useState(true); const [analyticsLocked, setAnalyticsLocked] = useState(false); - const [oidcSilentLoginDefaultLocked, setOidcSilentLoginDefaultLocked] = - useState(false); const [notificationPrivateEndpoints, setNotificationPrivateEndpoints] = useState([]); const [hostDefaults, setHostDefaults] = useState({}); - // Terminal image storage state. localDir stays a draft: the API never - // returns the configured backend path, so it is only sent when changed. - const [brandingSettings, setBrandingSettings] = useState(null); const [brandingSaving, setBrandingSaving] = useState(false); const { applyBranding } = useBranding(); // External login state - const [oidcAutoProvision, setOidcAutoProvision] = useState(false); + const [externalAutoProvision, setExternalAutoProvision] = useState(false); const [secondFactorAfterExternalLogin, setSecondFactorAfterExternalLogin] = useState(false); - const [oidcSilentLoginDefault, setOidcSilentLoginDefault] = useState(false); // Create user dialog const [createUserOpen, setCreateUserOpen] = useState(false); @@ -258,7 +250,7 @@ export function AdminSettingsPanel({ id: user.userId, username: user.username, isAdmin: user.is_admin, - isOidc: user.is_oidc, + isOidc: user.is_external ?? user.is_oidc, passwordHash: user.password_hash, dataUnlocked: user.data_unlocked, secondFactorEnabled: user.second_factor_enabled, @@ -298,7 +290,6 @@ export function AdminSettingsPanel({ level, oidcProv, secondFactorExternal, - oidcSilent, analytics, notificationEndpoints, branding, @@ -309,9 +300,8 @@ export function AdminSettingsPanel({ getSessionTimeout(), getStatusCheckSettings(), getLogLevel(), - getOidcAutoProvision(), + getExternalAutoProvision(), getSecondFactorAfterExternalLogin(), - getOidcSilentLoginDefault(), getAnalyticsEnabled(), getNotificationPrivateEndpoints(), getBranding(), @@ -326,13 +316,9 @@ export function AdminSettingsPanel({ ); } if (oidcProv.status === "fulfilled") - setOidcAutoProvision(oidcProv.value.enabled); + setExternalAutoProvision(oidcProv.value.enabled); if (secondFactorExternal.status === "fulfilled") setSecondFactorAfterExternalLogin(secondFactorExternal.value.enabled); - if (oidcSilent.status === "fulfilled") { - setOidcSilentLoginDefault(oidcSilent.value.enabled); - setOidcSilentLoginDefaultLocked(oidcSilent.value.locked ?? false); - } if (pwReset.status === "fulfilled") setAllowPasswordReset(pwReset.value); if (timeout.status === "fulfilled") setSessionTimeout(String(timeout.value.timeoutHours)); @@ -405,14 +391,14 @@ export function AdminSettingsPanel({ } } - async function handleToggleOidcAutoProvision() { - const newVal = !oidcAutoProvision; - setOidcAutoProvision(newVal); + async function handleToggleExternalAutoProvision() { + const newVal = !externalAutoProvision; + setExternalAutoProvision(newVal); try { - await updateOidcAutoProvision(newVal); + await updateExternalAutoProvision(newVal); } catch { - setOidcAutoProvision(!newVal); - toast.error(t("admin.updateOidcAutoProvisionFailed")); + setExternalAutoProvision(!newVal); + toast.error(t("admin.updateExternalAutoProvisionFailed")); } } @@ -427,18 +413,6 @@ export function AdminSettingsPanel({ } } - async function handleToggleOidcSilentLoginDefault() { - if (oidcSilentLoginDefaultLocked) return; - const newVal = !oidcSilentLoginDefault; - setOidcSilentLoginDefault(newVal); - try { - await updateOidcSilentLoginDefault(newVal); - } catch { - setOidcSilentLoginDefault(!newVal); - toast.error(t("admin.updateOidcSilentLoginDefaultFailed")); - } - } - async function handleTogglePasswordReset() { const newVal = !allowPasswordReset; setAllowPasswordReset(newVal); @@ -839,15 +813,12 @@ export function AdminSettingsPanel({ allowPasswordLogin={allowPasswordLogin} passwordLoginForced={passwordLoginForced && !allowPasswordLogin} handleTogglePasswordLogin={handleTogglePasswordLogin} - oidcAutoProvision={oidcAutoProvision} - handleToggleOidcAutoProvision={handleToggleOidcAutoProvision} + externalAutoProvision={externalAutoProvision} + handleToggleExternalAutoProvision={handleToggleExternalAutoProvision} secondFactorAfterExternalLogin={secondFactorAfterExternalLogin} handleToggleSecondFactorAfterExternalLogin={ handleToggleSecondFactorAfterExternalLogin } - oidcSilentLoginDefault={oidcSilentLoginDefault} - oidcSilentLoginDefaultLocked={oidcSilentLoginDefaultLocked} - handleToggleOidcSilentLoginDefault={handleToggleOidcSilentLoginDefault} allowPasswordReset={allowPasswordReset} handleTogglePasswordReset={handleTogglePasswordReset} sessionTimeout={sessionTimeout} diff --git a/src/ui/sidebar/AdminSettingsSections.tsx b/src/ui/sidebar/AdminSettingsSections.tsx index 5e4ada669..28e72ab4c 100644 --- a/src/ui/sidebar/AdminSettingsSections.tsx +++ b/src/ui/sidebar/AdminSettingsSections.tsx @@ -22,13 +22,10 @@ type GeneralSettingsSectionProps = { allowPasswordLogin: boolean; passwordLoginForced?: boolean; handleTogglePasswordLogin: () => void; - oidcAutoProvision: boolean; - handleToggleOidcAutoProvision: () => void; + externalAutoProvision: boolean; + handleToggleExternalAutoProvision: () => void; secondFactorAfterExternalLogin: boolean; handleToggleSecondFactorAfterExternalLogin: () => void; - oidcSilentLoginDefault: boolean; - oidcSilentLoginDefaultLocked: boolean; - handleToggleOidcSilentLoginDefault: () => void; allowPasswordReset: boolean; handleTogglePasswordReset: () => void; sessionTimeout: string; @@ -54,13 +51,10 @@ export function AdminGeneralSettingsSection({ allowPasswordLogin, passwordLoginForced, handleTogglePasswordLogin, - oidcAutoProvision, - handleToggleOidcAutoProvision, + externalAutoProvision, + handleToggleExternalAutoProvision, secondFactorAfterExternalLogin, handleToggleSecondFactorAfterExternalLogin, - oidcSilentLoginDefault, - oidcSilentLoginDefaultLocked, - handleToggleOidcSilentLoginDefault, allowPasswordReset, handleTogglePasswordReset, sessionTimeout, @@ -147,8 +141,8 @@ export function AdminGeneralSettingsSection({ description={t("admin.oidcAutoProvisionDesc")} > - - - prev.filter((u) => u.id !== linkAccountTarget.id)); } else { const oidcUser = users.find( @@ -508,7 +508,7 @@ export function AdminLinkAccountDialog({ toast.error(t("admin.linkAccountOidcNotFound")); return; } - await linkOIDCToPasswordAccount( + await linkExternalToPasswordAccount( oidcUser.id, linkAccountTarget.username, ); diff --git a/src/ui/sidebar/ConnectionsPanel.tsx b/src/ui/sidebar/ConnectionsPanel.tsx index 2c7e2bd79..90cc1bb18 100644 --- a/src/ui/sidebar/ConnectionsPanel.tsx +++ b/src/ui/sidebar/ConnectionsPanel.tsx @@ -1,15 +1,8 @@ import { getTabType, isPersistentTabType } from "@/shell/tab-registry"; +import { ComponentSlot } from "@/shell/ActionSlot"; import { useState, useEffect, useRef, useCallback } from "react"; import { useTranslation } from "react-i18next"; -import { - ExternalLink, - Plug, - Search, - X, - Pencil, - Check, - SquareTerminal, -} from "lucide-react"; +import { ExternalLink, Plug, Search, X, Pencil, Check } from "lucide-react"; import { getActiveSessions, deleteOpenTab, @@ -20,7 +13,6 @@ import { tabIcon } from "@/shell/tabUtils"; import { getSessionTimeoutMinutes } from "@/api/open-tabs-api"; import type { Tab, TabType } from "@/types/ui-types"; import { Badge } from "@/components/badge"; -import { Button } from "@/components/button"; import { Input } from "@/components/input"; import { Tooltip, @@ -64,11 +56,7 @@ function sessionsUnchanged( a.hostName !== b.hostName || a.tabInstanceId !== b.tabInstanceId || a.isConnected !== b.isConnected || - a.createdAt !== b.createdAt || - a.isOwnSession !== b.isOwnSession || - a.sharedByUsername !== b.sharedByUsername || - a.permissionLevel !== b.permissionLevel || - a.shareId !== b.shareId + a.createdAt !== b.createdAt ) { return false; } @@ -296,7 +284,6 @@ export function ConnectionsPanel({ onForgetBackground, onRenameTab, onReorderTabs, - onJoinSharedSession, }: { tabs: Tab[]; activeTabId: string; @@ -311,7 +298,6 @@ export function ConnectionsPanel({ onForgetBackground: (recordId: string) => void; onRenameTab?: (tabId: string, newLabel: string) => void; onReorderTabs?: (tabs: Tab[]) => void; - onJoinSharedSession?: (session: ActiveSessionInfo) => void; }) { const { t } = useTranslation(); const [now, setNow] = useState(Date.now()); @@ -360,22 +346,6 @@ export function ConnectionsPanel({ }) : backgroundTabs; - const joinedInstanceIds = new Set( - tabs - .map((t) => (t.joinSharedSessionId ? t.instanceId : null)) - .filter(Boolean), - ); - const sharedWithMe = activeSessions.filter( - (s) => s.isOwnSession === false && !joinedInstanceIds.has(s.sessionId), - ); - const filteredSharedWithMe = q - ? sharedWithMe.filter( - (s) => - s.hostName.toLowerCase().includes(q) || - (s.sharedByUsername ?? "").toLowerCase().includes(q), - ) - : sharedWithMe; - // Duration labels only need minute-level freshness; 1s ticks re-render the whole panel. usePageVisibleInterval(() => setNow(Date.now()), 15_000); @@ -462,26 +432,36 @@ export function ConnectionsPanel({ activeSessions.map((s) => [s.tabInstanceId, s]), ); - const hasAnything = - openTabs.length > 0 || backgroundTabs.length > 0 || sharedWithMe.length > 0; + const hasAnything = openTabs.length > 0 || backgroundTabs.length > 0; const hasResults = - filteredOpenTabs.length > 0 || - filteredBackgroundTabs.length > 0 || - filteredSharedWithMe.length > 0; + filteredOpenTabs.length > 0 || filteredBackgroundTabs.length > 0; + + const pluginSections = ( + tab.data ?? {}), + }} + /> + ); if (!hasAnything) { return ( -
-
- -
-
- - {t("connections.noConnections")} - - - {t("connections.noConnectionsDesc")} - +
+ {pluginSections} +
+
+ +
+
+ + {t("connections.noConnections")} + + + {t("connections.noConnectionsDesc")} + +
); @@ -629,77 +609,8 @@ export function ConnectionsPanel({
)} - {filteredSharedWithMe.length > 0 && ( -
0 || filteredBackgroundTabs.length > 0 ? "mt-2" : ""}`} - > - - {filteredSharedWithMe.map((session) => ( - onJoinSharedSession?.(session)} - /> - ))} -
- )} -
- ); -} - -function SharedWithMeRow({ - session, - onJoin, -}: { - session: ActiveSessionInfo; - onJoin: () => void; -}) { - const { t } = useTranslation(); - const isReadWrite = session.permissionLevel === "read-write"; - - return ( -
-
- -
-
-
- - - {session.hostName} - - - {isReadWrite - ? t("sessionSharing.permissionLevel.readWrite") - : t("sessionSharing.permissionLevel.readOnly")} - -
- - {t("connections.sharedBy", { - username: session.sharedByUsername ?? "?", - })} - -
- + {/* Sections a plugin adds, such as sessions others shared with you. */} + {pluginSections}
); } diff --git a/src/ui/sidebar/HostCredentialList.tsx b/src/ui/sidebar/HostCredentialList.tsx deleted file mode 100644 index 387879176..000000000 --- a/src/ui/sidebar/HostCredentialList.tsx +++ /dev/null @@ -1,450 +0,0 @@ -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { - ChevronRight, - Copy, - FolderOpen, - KeyRound, - Loader2, - Pencil, - Plus, - Trash2, - Upload, - X, -} from "lucide-react"; -import { toast } from "sonner"; - -import { Button } from "@/components/button"; -import { getCredentialDetails } from "@/main-axios"; -import { copyToClipboard } from "@/lib/clipboard"; -import { ComponentSlot } from "@/shell/ActionSlot"; -import type { Host, Credential } from "@/types/ui-types"; - -type CredentialWithCertificate = Credential & { certPublicKey?: string }; -type ConfirmDialog = { - message: string; - onConfirm: () => void; -}; - -function CredentialItem({ - cred, - usedByHosts, - stripeIndex, - onDeploy, - onEdit, - onDelete, -}: { - cred: Credential; - usedByHosts: Host[]; - stripeIndex: number; - onDeploy: () => void; - onEdit: () => void; - onDelete: () => void; -}) { - const { t } = useTranslation(); - const isKey = cred.type === "key"; - const visibleHosts = usedByHosts.slice(0, 2); - const remainingHosts = Math.max(0, usedByHosts.length - visibleHosts.length); - const usedByTitle = usedByHosts - .map((host) => host.name || host.ip) - .join(", "); - - return ( -
- {/* Type stripe */} -
- -
- {/* Name row */} -
- - {cred.name} - - - {isKey ? "KEY" : "PWD"} - - -
- - {/* Username row */} - {cred.username && ( - - {cred.username} - - )} - - {usedByHosts.length > 0 && ( -
- {visibleHosts.map((host) => { - const label = host.name || host.ip; - return ( - - {label} - - ); - })} - {remainingHosts > 0 && ( - - +{remainingHosts} - - )} -
- )} - - {/* Tag pills */} - {cred.tags && cred.tags.length > 0 && ( -
- {cred.tags.slice(0, 4).map((tag) => ( - - {tag} - - ))} - {cred.tags.length > 4 && ( - - +{cred.tags.length - 4} - - )} -
- )} - - {/* Action tray — slides open on hover */} -
-
- {isKey && ( - <> - - - - )} - - -
-
-
-
- ); -} - -function CredentialFolderItem({ - folder, - creds, - allHosts, - stripeOffset, - editingFolderName, - editingFolderValue, - onEditingFolderNameChange, - onEditingFolderValueChange, - onRenameFolder, - onDeploy, - onEdit, - onDelete, -}: { - folder: string; - creds: Credential[]; - allHosts: Host[]; - stripeOffset: number; - editingFolderName: string | null; - editingFolderValue: string; - onEditingFolderNameChange: (name: string | null) => void; - onEditingFolderValueChange: (value: string) => void; - onRenameFolder: (folder: string, newName: string) => Promise; - onDeploy: (cred: Credential) => void; - onEdit: (cred: Credential) => void; - onDelete: (cred: Credential) => void; -}) { - const [open, setOpen] = useState(false); - - return ( -
- - - ) : ( - <> - - {folder} - - - {creds.length} - - {folder !== "Uncategorized" && ( - { - e.stopPropagation(); - onEditingFolderNameChange(folder); - onEditingFolderValueChange(folder); - }} - > - - - )} - - )} - - {open && ( -
- {creds.map((cred, i) => { - const usedByHosts = allHosts.filter( - (h) => h.credentialId === cred.id, - ); - return ( - onDeploy(cred)} - onEdit={() => onEdit(cred)} - onDelete={() => onDelete(cred)} - /> - ); - })} -
- )} -
- ); -} - -export function HostCredentialList({ - credentialFolders, - filteredCredentials, - credentialsLoading, - allHosts, - editingFolderName, - editingFolderValue, - onEditingFolderNameChange, - onEditingFolderValueChange, - onRenameFolder, - onDeployCredential, - onEditCredential, - onDeleteCredential, - onAddCredential, - onConfirmDialogChange, -}: { - credentialFolders: string[]; - filteredCredentials: Credential[]; - credentialsLoading: boolean; - allHosts: Host[]; - editingFolderName: string | null; - editingFolderValue: string; - onEditingFolderNameChange: (name: string | null) => void; - onEditingFolderValueChange: (value: string) => void; - onRenameFolder: (folder: string, newName: string) => Promise; - onDeployCredential: (cred: Credential) => void; - onEditCredential: (cred: Credential) => void; - onDeleteCredential: (cred: Credential) => Promise; - onAddCredential: () => void; - onConfirmDialogChange: (dialog: ConfirmDialog) => void; -}) { - const { t } = useTranslation(); - - async function handleDelete(cred: Credential) { - onConfirmDialogChange({ - message: t("hosts.deleteCredentialConfirm", { name: cred.name }), - onConfirm: async () => { - try { - await onDeleteCredential(cred); - toast.success(t("hosts.deletedCredential", { name: cred.name })); - } catch { - toast.error(t("hosts.failedToDeleteCredential2")); - } - }, - }); - } - - async function handleEdit(cred: Credential) { - try { - const full = await getCredentialDetails(Number(cred.id)); - onEditCredential({ - ...cred, - value: ( - full as CredentialWithCertificate & { - hasKey?: boolean; - hasKeyPassword?: boolean; - } - ).hasKey - ? "existing_key" - : (( - full as CredentialWithCertificate & { - password?: string; - } - ).password ?? ""), - password: - (full as CredentialWithCertificate & { password?: string }) - .password ?? "", - passphrase: ( - full as CredentialWithCertificate & { - hasKeyPassword?: boolean; - } - ).hasKeyPassword - ? "existing_key_password" - : "", - publicKey: - (full as CredentialWithCertificate).certPublicKey ?? cred.publicKey, - }); - } catch { - onEditCredential(cred); - } - } - - let globalStripe = 0; - - return ( -
-
- {credentialFolders.map((folder) => { - const creds = filteredCredentials.filter( - (c) => (c.folder || "Uncategorized") === folder, - ); - if (creds.length === 0) return null; - const offset = globalStripe; - globalStripe += 1 + creds.length; - return ( - - ); - })} - {credentialsLoading && ( -
- {[60, 45, 55, 40].map((w, i) => ( -
-
-
-
- ))} -
- - {t("hosts.loadingCredentials")} -
-
- )} - {!credentialsLoading && filteredCredentials.length === 0 && ( -
- - - {t("hosts.noCredentialsFound")} - - -
- )} -
-
- ); -} diff --git a/src/ui/sidebar/HostEditor.tsx b/src/ui/sidebar/HostEditor.tsx index 5eefe05be..e0c8b1e93 100644 --- a/src/ui/sidebar/HostEditor.tsx +++ b/src/ui/sidebar/HostEditor.tsx @@ -21,7 +21,6 @@ import { import { getHostDefaults } from "@/api/settings-api"; import type { Host } from "@/types/ui-types"; import type { SSHHost } from "@/types"; -import { useTabsSafe } from "@/shell/TabContext"; import { updatePluginHostSettings } from "@/api/plugins-api"; import { applyHostDraft, @@ -93,7 +92,6 @@ export function HostEditor({ }) { const { t } = useTranslation(); const hostSettingPlugins = usePluginHostSections(); - const { setPreviewTerminalTheme } = useTabsSafe(); const [form, setForm] = useState(() => applyHostDraft(createHostEditorForm(host), host ? undefined : draft), ); @@ -124,7 +122,7 @@ export function HostEditor({ useState(null); useEffect(() => { getUserInfo() - .then((info) => setIsOidcUser(info.is_oidc)) + .then((info) => setIsOidcUser(info.is_external ?? info.is_oidc)) .catch(() => {}); }, []); @@ -196,7 +194,6 @@ export function HostEditor({ ); toast.success(host ? t("hosts.hostUpdated") : t("hosts.hostCreated")); - setPreviewTerminalTheme(null); onSave(saved); } catch { toast.error(t("hosts.failedToSave")); @@ -1104,12 +1101,11 @@ export function HostEditor({ {!readOnly && ( )}
diff --git a/src/ui/sidebar/HostExportDialog.tsx b/src/ui/sidebar/HostExportDialog.tsx index 43336dd73..1fc26ca49 100644 --- a/src/ui/sidebar/HostExportDialog.tsx +++ b/src/ui/sidebar/HostExportDialog.tsx @@ -1,4 +1,5 @@ import { getErrorMessage } from "../lib/error-message.js"; +import { usePluginStore } from "@/plugin-host/plugin-store"; import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Download, Search } from "lucide-react"; @@ -17,6 +18,7 @@ import { exportAllSSHHosts, type SSHHostWithStatus } from "@/main-axios"; import { isFolder } from "@/sidebar/SidebarTree"; import { buildExportPayload, + pluginSecretKeys, hostKey, maskSecrets, type ExportPayload, @@ -135,6 +137,15 @@ export function HostExportDialog({ }; }, [open, withCredentials, t]); + const { records: pluginRecords } = usePluginStore(); + const pluginSecrets = useMemo( + () => + pluginSecretKeys( + [...pluginRecords.values()].map((record) => record.summary), + ), + [pluginRecords], + ); + const payload = useMemo(() => { if (!raw) return null; return buildExportPayload( @@ -142,12 +153,13 @@ export function HostExportDialog({ scope === "all" ? null : selectedKeys, groups, withCredentials, + pluginSecrets, ); - }, [raw, scope, selectedKeys, groups, withCredentials]); + }, [raw, scope, selectedKeys, groups, withCredentials, pluginSecrets]); const preview = useMemo(() => { if (!payload) return ""; - const masked = maskSecrets(payload); + const masked = maskSecrets(payload, pluginSecrets); if (masked.hosts.length <= PREVIEW_HOST_LIMIT) { return JSON.stringify(masked, null, 2); } @@ -163,7 +175,7 @@ export function HostExportDialog({ count: payload.hosts.length - PREVIEW_HOST_LIMIT, }) ); - }, [payload, t]); + }, [payload, pluginSecrets, t]); function toggleGroup(group: FieldGroup) { setGroups((prev) => { diff --git a/src/ui/sidebar/HostsPanel.tsx b/src/ui/sidebar/HostsPanel.tsx index 5500ece91..ce61f5871 100644 --- a/src/ui/sidebar/HostsPanel.tsx +++ b/src/ui/sidebar/HostsPanel.tsx @@ -291,10 +291,10 @@ export function HostsPanel({ ? t("hosts.filterOnline") : t("hosts.filterOffline"); if (key === "protocol") return group.toUpperCase(); - if (key === "auth") - return t( - `hosts.filterAuth${group.charAt(0).toUpperCase() + group.slice(1)}`, - ); + if (key === "auth") { + const labelKey = sshAuthProviders.find(group)?.labelKey; + return labelKey ? t(labelKey, { defaultValue: group }) : group; + } return group; } diff --git a/src/ui/sidebar/UserProfilePanel.tsx b/src/ui/sidebar/UserProfilePanel.tsx index 184f5c7df..4aebfa812 100644 --- a/src/ui/sidebar/UserProfilePanel.tsx +++ b/src/ui/sidebar/UserProfilePanel.tsx @@ -647,7 +647,7 @@ export function UserProfilePanel({ getUserInfo() .then((info) => { setUserId(info.userId); - setIsOidc(info.is_oidc ?? false); + setIsOidc(info.is_external ?? info.is_oidc ?? false); setIsDualAuth(info.is_dual_auth ?? false); const linked = info.linked ?? null; setLinkedAccount(linked); @@ -661,7 +661,7 @@ export function UserProfilePanel({ ); if (info.is_dual_auth) { setAuthMethod(t("newUi.sidebar.userProfile.authMethodDual")); - } else if (info.is_oidc) { + } else if (info.is_external ?? info.is_oidc) { setAuthMethod(t("newUi.sidebar.userProfile.authMethodOidc")); } else { setAuthMethod(t("newUi.sidebar.userProfile.authMethodLocal")); @@ -764,8 +764,6 @@ export function UserProfilePanel({ // and back silently reset them. "dashboardTab.slots", "dashboardTab.mainWidthPct", - "termix-terminal-toolbar-density", - "fileManagerViewMode", ]; const snap: Record = { __theme: theme }; for (const key of SNAPSHOT_KEYS) snap[key] = localStorage.getItem(key); diff --git a/src/ui/sidebar/credential-tree/CredentialItem/CredentialItem.tsx b/src/ui/sidebar/credential-tree/CredentialItem/CredentialItem.tsx index 5be6aff66..067b9863b 100644 --- a/src/ui/sidebar/credential-tree/CredentialItem/CredentialItem.tsx +++ b/src/ui/sidebar/credential-tree/CredentialItem/CredentialItem.tsx @@ -43,10 +43,8 @@ const CREDENTIAL_ITEM_DENSITY_TOKENS = { export function CredentialItem({ cred, usedByCount = 0, - query = "", stripeIndex = 0, isMenuOpen = false, - onMenuOpenChange, isTrayOpen = false, onTrayOpenChange, onDragStart, diff --git a/src/ui/sidebar/host-export-payload.ts b/src/ui/sidebar/host-export-payload.ts index 21400c344..ac92f7802 100644 --- a/src/ui/sidebar/host-export-payload.ts +++ b/src/ui/sidebar/host-export-payload.ts @@ -65,29 +65,42 @@ const CREDENTIAL_KEYS = [ const TUPLE_KEYS = ["name", "ip", "port", "username", "connectionType"]; -const NESTED_SECRETS: { container: string; field: string }[] = [ - { container: "guacamoleConfig", field: "gateway-password" }, -]; +/** + * Keys inside a plugin's JSON host settings that hold secrets, per plugin and + * field, from each manifest's `secretKeys`. + */ +export type PluginSecretKeys = Record>; + +/** Reads the secret keys the plugins declare in their host settings. */ +export function pluginSecretKeys( + summaries: Array<{ + id: string; + contributes?: { + settings?: { + host?: { fields?: Array<{ key: string; secretKeys?: string[] }> }; + }; + } | null; + }>, +): PluginSecretKeys { + const result: PluginSecretKeys = {}; + for (const summary of summaries) { + for (const field of summary.contributes?.settings?.host?.fields ?? []) { + if (!field.secretKeys?.length) continue; + (result[summary.id] ??= {})[field.key] = field.secretKeys; + } + } + return result; +} const NESTED_SECRET_ARRAYS: { container: string; field: string }[] = [ { container: "socks5ProxyChain", field: "password" }, ]; -function nestedSecret( - host: Record, - container: string, - field: string, -): Record | null { - const blob = host[container]; - if (!blob || typeof blob !== "object") return null; - const record = blob as Record; - return field in record ? record : null; -} - -/** Plugin host settings can carry the same nested secrets as the host. */ -function mapPluginSettings( +/** Rewrites each declared secret inside a plugin's JSON host settings. */ +function mapPluginSecrets( pluginSettings: unknown, - apply: (values: Record) => void, + secrets: PluginSecretKeys, + replace: (value: unknown) => unknown, ): unknown { if (!pluginSettings || typeof pluginSettings !== "object") { return pluginSettings; @@ -97,7 +110,15 @@ function mapPluginSettings( pluginSettings as Record, )) { const copy = { ...(values as Record) }; - apply(copy); + for (const [field, keys] of Object.entries(secrets[pluginId] ?? {})) { + const blob = copy[field]; + if (!blob || typeof blob !== "object") continue; + const record = { ...(blob as Record) }; + for (const key of keys) { + if (key in record) record[key] = replace(record[key]); + } + copy[field] = record; + } perPlugin[pluginId] = copy; } return perPlugin; @@ -112,6 +133,7 @@ export function buildExportPayload( selected: Set | null, groups: Set, withCredentials: boolean, + pluginSecrets: PluginSecretKeys = {}, ): ExportPayload { const allowed = new Set([ "exportId", @@ -130,16 +152,10 @@ export function buildExportPayload( if (allowed.has(key)) shaped[key] = value; } if (!withCredentials) { - const clearNested = (target: Record) => { - for (const { container, field } of NESTED_SECRETS) { - const record = nestedSecret(target, container, field); - if (record) target[container] = { ...record, [field]: null }; - } - }; - clearNested(shaped); - shaped.pluginSettings = mapPluginSettings( + shaped.pluginSettings = mapPluginSecrets( shaped.pluginSettings, - clearNested, + pluginSecrets, + () => null, ); for (const { container, field } of NESTED_SECRET_ARRAYS) { const arr = shaped[container]; @@ -169,7 +185,10 @@ export function buildExportPayload( return result; } -export function maskSecrets(payload: ExportPayload): ExportPayload { +export function maskSecrets( + payload: ExportPayload, + pluginSecrets: PluginSecretKeys = {}, +): ExportPayload { return { ...payload, hosts: payload.hosts.map((host) => { @@ -183,18 +202,10 @@ export function maskSecrets(payload: ExportPayload): ExportPayload { masked[key] = ""; } } - const maskNested = (target: Record) => { - for (const { container, field } of NESTED_SECRETS) { - const record = nestedSecret(target, container, field); - if (record && record[field]) { - target[container] = { ...record, [field]: "" }; - } - } - }; - maskNested(masked); - masked.pluginSettings = mapPluginSettings( + masked.pluginSettings = mapPluginSecrets( masked.pluginSettings, - maskNested, + pluginSecrets, + (value) => (value ? "" : value), ); for (const { container, field } of NESTED_SECRET_ARRAYS) { const arr = masked[container]; diff --git a/src/ui/tests/api/sse-stream.test.ts b/src/ui/tests/api/sse-stream.test.ts deleted file mode 100644 index 4b398d804..000000000 --- a/src/ui/tests/api/sse-stream.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { streamServerSentEvents } from "@/api/sse-stream"; - -describe("streamServerSentEvents", () => { - it("sends the supplied authentication options and parses split events", async () => { - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode("event: stat")); - controller.enqueue( - new TextEncoder().encode( - 'uses\ndata: {"tunnel":{"status":"connected"}}\n\n', - ), - ); - controller.close(); - }, - }); - const fetchMock = vi - .spyOn(globalThis, "fetch") - .mockResolvedValue(new Response(stream, { status: 200 })); - const onEvent = vi.fn(); - const headers = new Headers({ Authorization: "Bearer token" }); - - await streamServerSentEvents( - "http://localhost/stream", - { credentials: "include", headers }, - onEvent, - ); - - expect(fetchMock).toHaveBeenCalledWith("http://localhost/stream", { - credentials: "include", - headers, - }); - expect(onEvent).toHaveBeenCalledWith({ - event: "statuses", - data: '{"tunnel":{"status":"connected"}}', - }); - }); - - it("rejects an unauthorized stream", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(null, { status: 401 }), - ); - - await expect( - streamServerSentEvents("http://localhost/stream", {}, vi.fn()), - ).rejects.toThrow("status 401"); - }); -}); diff --git a/src/ui/tests/plugin-host/view-ownership.test.tsx b/src/ui/tests/plugin-host/view-ownership.test.tsx index 604337141..02be8ee9a 100644 --- a/src/ui/tests/plugin-host/view-ownership.test.tsx +++ b/src/ui/tests/plugin-host/view-ownership.test.tsx @@ -29,8 +29,7 @@ const shell = { openSingletonTab: () => {}, closeTab: () => {}, renameTab: () => {}, - openFileInEditor: () => {}, - openFileManager: () => {}, + connectHost: () => {}, openRailView: () => {}, closeRailView: () => {}, }; diff --git a/src/ui/tests/settings/SettingsFields.test.tsx b/src/ui/tests/settings/SettingsFields.test.tsx index a93e7f696..493c274e5 100644 --- a/src/ui/tests/settings/SettingsFields.test.tsx +++ b/src/ui/tests/settings/SettingsFields.test.tsx @@ -10,7 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import type { PluginSettingsField } from "@/api/plugins-api"; import { SettingsFieldRow } from "@/settings/SettingsFields"; -import { isFieldActive } from "@/settings/settings-fields-util"; +import { isFieldShown } from "@/settings/settings-fields-util"; import { registerSettingsComponent, resetSettingsComponents, @@ -272,7 +272,7 @@ describe("errors", () => { }); }); -describe("isFieldActive", () => { +describe("isFieldShown", () => { const gated: PluginSettingsField = { key: "socketPath", type: "string", @@ -281,16 +281,16 @@ describe("isFieldActive", () => { }; it("is inactive while the named boolean is off", () => { - expect(isFieldActive(gated, { enableDocker: false })).toBe(false); - expect(isFieldActive(gated, {})).toBe(false); + expect(isFieldShown(gated, { enableDocker: false })).toBe(false); + expect(isFieldShown(gated, {})).toBe(false); }); it("is active once the named boolean is on", () => { - expect(isFieldActive(gated, { enableDocker: true })).toBe(true); + expect(isFieldShown(gated, { enableDocker: true })).toBe(true); }); it("is always active without a requires", () => { - expect(isFieldActive({ key: "a", type: "string", labelKey: "k" }, {})).toBe( + expect(isFieldShown({ key: "a", type: "string", labelKey: "k" }, {})).toBe( true, ); }); diff --git a/src/ui/tests/sidebar/ConnectionsPanel.test.tsx b/src/ui/tests/sidebar/ConnectionsPanel.test.tsx index 529b313fc..7f4ca1960 100644 --- a/src/ui/tests/sidebar/ConnectionsPanel.test.tsx +++ b/src/ui/tests/sidebar/ConnectionsPanel.test.tsx @@ -1,11 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { - render, - screen, - waitFor, - cleanup, - fireEvent, -} from "@testing-library/react"; +import { render, screen, waitFor, cleanup } from "@testing-library/react"; import type { ActiveSessionInfo } from "@/api/open-tabs-api"; const mainAxios = vi.hoisted(() => ({ @@ -23,136 +17,62 @@ vi.mock("react-i18next", () => ({ })); import { ConnectionsPanel } from "../../sidebar/ConnectionsPanel"; - -function sharedSession( - overrides: Partial = {}, -): ActiveSessionInfo { - return { - sessionId: "sess-shared-1", - hostId: 5, - hostName: "prod-db", - tabInstanceId: null, - isConnected: true, - createdAt: Date.now(), - isOwnSession: false, - sharedByUsername: "alice", - permissionLevel: "read-only", - shareId: "share-1", - ...overrides, - }; -} +import { + registerSlotContribution, + resetActionRegistry, +} from "../../shell/action-registry"; beforeEach(() => { mainAxios.getActiveSessions.mockReset(); mainAxios.getActiveSessions.mockResolvedValue([]); mainAxios.deleteOpenTab.mockReset(); + resetActionRegistry(); }); afterEach(() => { cleanup(); }); -describe("ConnectionsPanel - shared with me", () => { - it("renders a shared-with-me row for sessions the current user does not own", async () => { - mainAxios.getActiveSessions.mockResolvedValue([sharedSession()]); +function renderPanel() { + return render( + {}} + onCloseTab={() => {}} + onReopenTab={() => {}} + onForgetBackground={() => {}} + />, + ); +} - render( - {}} - onCloseTab={() => {}} - onReopenTab={() => {}} - onForgetBackground={() => {}} - />, - ); +describe("ConnectionsPanel - plugin sections", () => { + it("renders what plugins add to connections.sections, even with no tabs", async () => { + const Section = vi.fn(({ search }: { search?: string }) => ( +
plugin section {JSON.stringify(search)}
+ )); + registerSlotContribution("connections.sections", { + actionId: "demo.section", + titleKey: "demo.section", + kind: "component", + component: Section as never, + }); + + renderPanel(); await waitFor(() => { - expect(screen.getByText("connections.sectionSharedWithMe")).toBeTruthy(); + expect(screen.getByText('plugin section ""')).toBeTruthy(); }); - expect(screen.getByText("prod-db")).toBeTruthy(); - expect( - screen.getByText('connections.sharedBy:{"username":"alice"}'), - ).toBeTruthy(); + expect(screen.getByText("connections.noConnections")).toBeTruthy(); }); - it("does not show own sessions in the shared-with-me section", async () => { - mainAxios.getActiveSessions.mockResolvedValue([ - sharedSession({ - isOwnSession: true, - sharedByUsername: null, - shareId: null, - }), - ]); - - render( - {}} - onCloseTab={() => {}} - onReopenTab={() => {}} - onForgetBackground={() => {}} - />, - ); - + it("renders only the empty state without plugin sections", async () => { + renderPanel(); await waitFor(() => { expect(mainAxios.getActiveSessions).toHaveBeenCalled(); }); - expect(screen.queryByText("connections.sectionSharedWithMe")).toBeNull(); - }); - - it("dispatches onJoinSharedSession with the session when Join is clicked", async () => { - const session = sharedSession(); - mainAxios.getActiveSessions.mockResolvedValue([session]); - const onJoinSharedSession = vi.fn(); - - render( - {}} - onCloseTab={() => {}} - onReopenTab={() => {}} - onForgetBackground={() => {}} - onJoinSharedSession={onJoinSharedSession} - />, - ); - - const joinButton = await screen.findByText("connections.join"); - fireEvent.click(joinButton); - - expect(onJoinSharedSession).toHaveBeenCalledWith(session); - }); - - it("shows a read-write badge for read-write shared sessions", async () => { - mainAxios.getActiveSessions.mockResolvedValue([ - sharedSession({ permissionLevel: "read-write" }), - ]); - - render( - {}} - onCloseTab={() => {}} - onReopenTab={() => {}} - onForgetBackground={() => {}} - />, - ); - - await waitFor(() => { - expect( - screen.getByText("sessionSharing.permissionLevel.readWrite"), - ).toBeTruthy(); - }); + expect(screen.getByText("connections.noConnections")).toBeTruthy(); }); }); diff --git a/src/ui/tests/sidebar/HostCredentialList.test.tsx b/src/ui/tests/sidebar/HostCredentialList.test.tsx deleted file mode 100644 index 12e13dd7e..000000000 --- a/src/ui/tests/sidebar/HostCredentialList.test.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; - -import { HostCredentialList } from "@/sidebar/HostCredentialList"; -import type { Credential, Host } from "@/types/ui-types"; - -vi.mock("@/main-axios", () => ({ - getCredentialDetails: vi.fn(), -})); - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (key: string) => key }), -})); - -vi.mock("sonner", () => ({ - toast: { success: vi.fn(), error: vi.fn() }, -})); - -function makeCredential(overrides: Partial = {}): Credential { - return { - id: "1", - name: "Deploy Key", - username: "ubuntu", - type: "key", - folder: "Shared", - tags: [], - ...overrides, - }; -} - -function makeHost(overrides: Partial): Host { - return { - id: "host-1", - name: "prod-api", - username: "ubuntu", - ip: "10.0.0.1", - port: 22, - folder: "", - online: false, - cpu: null, - ram: null, - lastAccess: "", - authType: "credential", - enableSsh: true, - sshPort: 22, - ...overrides, - }; -} - -async function renderOpenedList({ - credentials = [makeCredential()], - hosts = [], -}: { - credentials?: Credential[]; - hosts?: Host[]; -} = {}) { - render( - , - ); - - await userEvent.click(screen.getByText("Shared")); -} - -describe("HostCredentialList credential usage", () => { - it("shows the SSH host using a credential", async () => { - await renderOpenedList({ - hosts: [makeHost({ id: "host-1", name: "prod-api", credentialId: "1" })], - }); - - expect(screen.getByText("prod-api")).toBeTruthy(); - }); - - it("shows two host names and a compact remaining count", async () => { - await renderOpenedList({ - hosts: [ - makeHost({ id: "host-1", name: "prod-api", credentialId: "1" }), - makeHost({ id: "host-2", name: "jump-box", credentialId: "1" }), - makeHost({ id: "host-3", name: "worker-01", credentialId: "1" }), - ], - }); - - expect(screen.getByText("prod-api")).toBeTruthy(); - expect(screen.getByText("jump-box")).toBeTruthy(); - expect(screen.getByText("+1")).toBeTruthy(); - expect(screen.queryByText("worker-01")).toBeNull(); - }); - - it("does not show host chips when no SSH host uses the credential", async () => { - await renderOpenedList(); - - expect(screen.queryByLabelText(/^Used by /)).toBeNull(); - }); - - it("does not count non-SSH protocol credential ids", async () => { - await renderOpenedList({ - hosts: [ - makeHost({ - id: "host-1", - name: "rdp-only", - authType: "password", - credentialId: undefined, - protocolAuth: { - rdp: { authType: "credential", credentialId: 1 }, - }, - enableSsh: false, - }), - ], - }); - - expect(screen.queryByText("rdp-only")).toBeNull(); - expect(screen.queryByLabelText(/^Used by /)).toBeNull(); - }); -}); diff --git a/src/ui/tests/sidebar/HostExportDialog.test.tsx b/src/ui/tests/sidebar/HostExportDialog.test.tsx index 477219f6e..36916de34 100644 --- a/src/ui/tests/sidebar/HostExportDialog.test.tsx +++ b/src/ui/tests/sidebar/HostExportDialog.test.tsx @@ -14,6 +14,37 @@ const mainAxios = vi.hoisted(() => ({ vi.mock("@/main-axios", () => mainAxios); +// The remote-desktop plugin declares its gateway password as a secret key. +vi.mock("@/plugin-host/plugin-store", () => ({ + usePluginStore: () => ({ + loaded: true, + settled: true, + records: new Map([ + [ + "remote-desktop", + { + frontend: "active", + summary: { + id: "remote-desktop", + contributes: { + settings: { + host: { + fields: [ + { + key: "guacamoleConfig", + secretKeys: ["gateway-password"], + }, + ], + }, + }, + }, + }, + }, + ], + ]), + }), +})); + const i18n = vi.hoisted(() => ({ t: (key: string, opts?: Record) => opts ? `${key}:${JSON.stringify(opts)}` : key, diff --git a/src/ui/tests/sidebar/host-export-payload.test.ts b/src/ui/tests/sidebar/host-export-payload.test.ts index 8b70af424..094df47f8 100644 --- a/src/ui/tests/sidebar/host-export-payload.test.ts +++ b/src/ui/tests/sidebar/host-export-payload.test.ts @@ -6,8 +6,14 @@ import { SECRET_KEYS, type ExportPayload, type FieldGroup, + pluginSecretKeys, } from "../../sidebar/host-export-payload"; +// What the remote-desktop manifest declares for its guacamoleConfig field. +const RDP_SECRETS = { + "remote-desktop": { guacamoleConfig: ["gateway-password"] }, +}; + const ALL_GROUPS = new Set([ "connection", "notes", @@ -208,7 +214,13 @@ describe("buildExportPayload", () => { }); it("nulls the guacamole gateway password when credentials are excluded", () => { - const out = buildExportPayload(rdpRaw(), null, ALL_GROUPS, false); + const out = buildExportPayload( + rdpRaw(), + null, + ALL_GROUPS, + false, + RDP_SECRETS, + ); const config = gatewayConfig(out.hosts[0]); expect(config["gateway-password"]).toBeNull(); expect(config["gateway-hostname"]).toBe("gw.example.com"); @@ -222,7 +234,7 @@ describe("buildExportPayload", () => { it("does not mutate the source payload when nulling nested secrets", () => { const raw = rdpRaw(); - buildExportPayload(raw, null, ALL_GROUPS, false); + buildExportPayload(raw, null, ALL_GROUPS, false, RDP_SECRETS); const config = gatewayConfig(raw.hosts[0]); expect(config["gateway-password"]).toBe("gw-secret"); }); @@ -270,7 +282,7 @@ describe("maskSecrets", () => { }); it("masks the guacamole gateway password", () => { - const out = maskSecrets(rdpRaw()); + const out = maskSecrets(rdpRaw(), RDP_SECRETS); const config = gatewayConfig(out.hosts[0]); expect(config["gateway-password"]).toBe(""); expect(config["gateway-hostname"]).toBe("gw.example.com"); @@ -286,3 +298,26 @@ describe("maskSecrets", () => { expect(JSON.stringify(out)).not.toContain("proxy-secret-2"); }); }); + +describe("pluginSecretKeys", () => { + it("reads secretKeys off each plugin's host fields", () => { + expect( + pluginSecretKeys([ + { + id: "remote-desktop", + contributes: { + settings: { + host: { + fields: [ + { key: "guacamoleConfig", secretKeys: ["gateway-password"] }, + { key: "enableRdp" }, + ], + }, + }, + }, + }, + { id: "docker", contributes: null }, + ]), + ).toEqual(RDP_SECRETS); + }); +}); diff --git a/src/ui/tests/types/ui-preferences.test.ts b/src/ui/tests/types/ui-preferences.test.ts index 2a13e7e5c..ff0c8609a 100644 --- a/src/ui/tests/types/ui-preferences.test.ts +++ b/src/ui/tests/types/ui-preferences.test.ts @@ -150,8 +150,6 @@ describe("PRESETS.balanced", () => { rowActions: "full", }); expect(PRESETS.balanced.rail.hiddenTabs).toEqual([]); - expect(PRESETS.balanced.terminal.toolbarDensity).toBe("labeled"); - expect(PRESETS.balanced.fileManager.viewMode).toBe("grid"); expect(PRESETS.balanced.hostEditor.mode).toBe("full"); }); @@ -220,3 +218,26 @@ describe("plugin areas", () => { expect(overrides).toEqual({ "plugin:docker": { ok: "card" } }); }); }); + +describe("areas that moved into plugins", () => { + it("maps version 2 terminal and file manager overrides to their plugins", () => { + expect( + sanitizeUiOverrides( + { + terminal: { toolbarDensity: "expanded" }, + fileManager: { viewMode: "list" }, + }, + 2, + ), + ).toEqual({ + "plugin:ssh-terminal": { toolbarDensity: "expanded" }, + "plugin:file-manager": { viewMode: "list" }, + }); + }); + + it("drops the old names from a current payload", () => { + expect( + sanitizeUiOverrides({ terminal: { toolbarDensity: "icon" } }), + ).toEqual({}); + }); +}); diff --git a/src/ui/theme.css b/src/ui/theme.css new file mode 100644 index 000000000..50d76484d --- /dev/null +++ b/src/ui/theme.css @@ -0,0 +1,84 @@ +/* + * The design tokens plugins build against: custom variants, the Tailwind + * theme and shared utilities. Core imports it, and the SDK ships a copy that + * `termix-plugin build` compiles a plugin's own classes against, so a plugin + * class means the same thing wherever it is compiled. Values only; anything + * that renders on its own belongs in index.css. + */ + +@custom-variant dark (&:is(.dark *)); +@custom-variant dracula (&:is(.dracula *)); +@custom-variant catppuccin (&:is(.catppuccin *)); +@custom-variant nord (&:is(.nord *)); +@custom-variant solarized (&:is(.solarized *)); +@custom-variant tokyo-night (&:is(.tokyo-night *)); +@custom-variant one-dark (&:is(.one-dark *)); +@custom-variant gruvbox (&:is(.gruvbox *)); + +@theme inline { + --font-heading: var(--font-mono); + --font-mono: var(--font-ui, "JetBrains Mono Variable", monospace); + --color-accent-brand: var(--accent-brand); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --color-surface: var(--surface); + --color-surface-dim: var(--surface-dim); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +@utility scrollbar-none { + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } +} + +@utility thin-scrollbar { + scrollbar-width: thin; + scrollbar-color: oklch(1 0 0 / 15%) transparent; + &::-webkit-scrollbar { + width: 4px; + } + &::-webkit-scrollbar-track { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background: oklch(1 0 0 / 15%); + border-radius: 2px; + } +} diff --git a/tsconfig.json b/tsconfig.json index 26eb55a44..2b6f42bf6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,10 +8,7 @@ "path": "./tsconfig.node.json" }, { - "path": "./tsconfig.plugins.backend.json" - }, - { - "path": "./tsconfig.plugins.frontend.json" + "path": "./tsconfig.plugins.json" } ], "compilerOptions": { diff --git a/tsconfig.plugins.backend.json b/tsconfig.plugins.backend.json deleted file mode 100644 index 7abd45ae7..000000000 --- a/tsconfig.plugins.backend.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.plugins.backend.tsbuildinfo", - "target": "ES2023", - "lib": ["ES2023"], - "module": "nodenext", - "moduleResolution": "nodenext", - "skipLibCheck": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "esModuleInterop": true, - "noEmit": true, - "strict": false, - "noUnusedLocals": false, - "noUnusedParameters": false, - "noImplicitReturns": false, - "noFallthroughCasesInSwitch": false, - "noUncheckedSideEffectImports": false, - "noImplicitAny": false, - "noImplicitThis": false, - "noUncheckedIndexedAccess": false, - "exactOptionalPropertyTypes": false, - "noPropertyAccessFromIndexSignature": false, - "allowUnusedLabels": true, - "allowUnreachableCode": true - }, - "include": ["plugins/*/src/backend/**/*.ts"] -} diff --git a/tsconfig.plugins.frontend.json b/tsconfig.plugins.frontend.json deleted file mode 100644 index e7a7ea9fa..000000000 --- a/tsconfig.plugins.frontend.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "./tsconfig.app.json", - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.plugins.frontend.tsbuildinfo", - "types": ["vitest/globals"] - }, - "include": [ - "src/vite-env.d.ts", - "src/types/electron.d.ts", - "src/types/guacamole-common-js.d.ts", - "plugins/*/src/frontend/**/*.ts", - "plugins/*/src/frontend/**/*.tsx", - "plugins/*/tests/frontend/**/*.ts", - "plugins/*/tests/frontend/**/*.tsx" - ] -} diff --git a/tsconfig.plugins.json b/tsconfig.plugins.json new file mode 100644 index 000000000..16ac677b6 --- /dev/null +++ b/tsconfig.plugins.json @@ -0,0 +1,12 @@ +{ + "extends": "./packages/plugin-sdk/tsconfig.plugin.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.plugins.tsbuildinfo" + }, + "include": [ + "plugins/*/src/**/*.ts", + "plugins/*/src/**/*.tsx", + "plugins/*/tests/**/*.ts", + "plugins/*/tests/**/*.tsx" + ] +} diff --git a/tsconfig.sdk-ui.json b/tsconfig.sdk-ui.json new file mode 100644 index 000000000..100ba0deb --- /dev/null +++ b/tsconfig.sdk-ui.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.app.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "composite": false, + "incremental": false + }, + "files": ["src/vite-env.d.ts", "src/ui/plugin-host/sdk-ui.ts"], + "include": [] +} diff --git a/vite.config.ts b/vite.config.ts index 456d2cedd..03076e2cd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -46,37 +46,12 @@ const manualChunkGroups: Record = { "tailwind-merge", "class-variance-authority", ], - monaco: ["@monaco-editor/react", "monaco-editor"], - "terminal-vendor": [ - "@xterm/addon-clipboard", - "@xterm/addon-fit", - "@xterm/addon-unicode11", - "@xterm/addon-web-links", - "@xterm/xterm", - "react-xtermjs", - ], codemirror: [ "@uiw/react-codemirror", "@codemirror/view", "@codemirror/state", "@codemirror/language", - "@codemirror/commands", - "@codemirror/search", - "@codemirror/autocomplete", "@codemirror/theme-one-dark", - "@uiw/codemirror-extensions-langs", - "@uiw/codemirror-theme-github", - ], - "remote-desktop-vendor": ["guacamole-common-js"], - "graph-vendor": ["cytoscape", "react-cytoscapejs"], - "file-preview-vendor": [ - "react-pdf", - "pdfjs-dist", - "react-photo-view", - "react-h5-audio-player", - "react-markdown", - "react-syntax-highlighter", - "remark-gfm", ], };