From 296129798932e3904242a73a2b8bca16d80713b6 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Fri, 25 Sep 2026 07:24:46 -0500 Subject: [PATCH] chore: lock the plugin boundary and remove 2.9.0 leftovers --- docker/nginx-https.conf | 73 +-- docker/nginx.conf | 73 +-- drizzle/sqlite/0045_public_menace.sql | 7 +- electron/local-files.cjs | 12 +- electron/main.cjs | 2 +- eslint.config.mjs | 108 ++--- knip.json | 210 ++++---- package.json | 6 +- packages/plugin-sdk/ARCHITECTURE.md | 371 ++++++++------- packages/plugin-sdk/FINISH-LIST.md | 29 ++ packages/plugin-sdk/cli/commands/build.mjs | 22 +- packages/plugin-sdk/cli/lib/externals.mjs | 2 - .../cli/lib/legacy-core-imports.mjs | 68 --- .../cli/lib/legacy-core-specifier.mjs | 62 --- .../plugin-sdk/schema/manifest.schema.json | 20 + packages/plugin-sdk/src/backend.ts | 33 +- packages/plugin-sdk/src/frontend.ts | 32 ++ packages/plugin-sdk/src/manifest.ts | 60 ++- packages/plugin-sdk/src/testing.ts | 4 +- packages/plugin-sdk/src/testing/setup.ts | 3 + packages/plugin-sdk/src/vitest-preset.ts | 2 + plugins/acme-ssl/package.json | 2 +- plugins/ai/package.json | 2 +- plugins/automations/package.json | 2 +- plugins/automations/tsconfig.json | 6 - plugins/docker/package.json | 2 +- plugins/docker/src/backend/connect.ts | 1 + plugins/docker/src/backend/console.ts | 1 + plugins/docker/src/backend/services.ts | 4 +- plugins/file-manager/package.json | 2 +- .../file-manager/src/backend/host-identity.ts | 119 +++++ plugins/file-manager/src/backend/index.ts | 100 ++-- .../src/backend/operation-routes.ts | 14 +- plugins/file-manager/src/backend/service.ts | 6 +- .../file-manager/src/frontend/FileManager.tsx | 22 +- .../src/frontend/FileManagerContextMenu.tsx | 4 +- .../src/frontend/FileManagerDialogs.tsx | 2 +- .../src/frontend/FileManagerGrid.tsx | 4 +- .../src/frontend/FileManagerSidebar.tsx | 6 +- .../src/frontend/FileManagerToolbar.tsx | 2 +- .../src/frontend/LocalFileContextMenu.tsx | 6 +- .../src/frontend/LocalFilePane.tsx | 10 +- .../src/frontend/SftpTransferTab.tsx | 8 +- .../src/frontend/TransferMonitor.tsx | 4 +- .../file-manager/src/frontend/api/client.ts | 121 +++++ .../src/frontend/api/file-manager-data-api.ts | 20 +- .../file-manager/src/frontend/api/hosts.ts | 6 + .../src/frontend/api/local-transfer-api.ts | 6 +- .../frontend/api/ssh-file-operations-api.ts | 94 +--- .../src/frontend/api/transfer-api.ts | 384 +++++++++++++++ .../src/frontend/components/AudioPreview.tsx | 2 +- .../components/ColumnResizeHandle.tsx | 2 +- .../components/ColumnVisibilityMenu.tsx | 2 +- .../src/frontend/components/DiffViewer.tsx | 2 +- .../src/frontend/components/DiffWindow.tsx | 2 +- .../components/DownloadProgressToast.tsx | 2 +- .../frontend/components/DraggableWindow.tsx | 2 +- .../src/frontend/components/FileViewer.tsx | 4 +- .../src/frontend/components/FileWindow.tsx | 2 +- .../src/frontend/components/FolderTree.tsx | 2 +- .../frontend/components/PaneResizeHandle.tsx | 2 +- .../frontend/components/TerminalWindow.tsx | 2 +- .../components/TransferProgressBar.tsx | 2 +- .../components/TransferProgressToast.tsx | 2 +- .../components/TransferToHostDialog.tsx | 6 +- .../src/frontend/file-manager-types.ts | 4 +- .../src/frontend/file-preview-preload.ts | 2 +- .../frontend/homepage/FileManagerWidget.tsx | 2 +- .../src/frontend/hooks/useDragToDesktop.ts | 2 +- .../frontend/hooks/useDragToSystemDesktop.ts | 2 +- .../src/frontend/hooks/useLocalTransfers.ts | 6 +- .../file-manager/src/frontend/host-types.ts | 57 +++ plugins/file-manager/src/frontend/index.tsx | 13 +- .../src/frontend/lib/error-message.ts | 14 + .../lib/file-content-request-cache.ts | 4 +- .../frontend/lib/file-list-request-cache.ts | 4 +- .../src/frontend}/lib/http-error.ts | 0 .../src/frontend}/lib/keyed-request-cache.ts | 0 .../src/frontend}/lib/local-files.ts | 4 +- .../src/frontend/local-transfer-utils.ts | 2 +- .../src/frontend/optimistic-file-list.ts | 2 +- .../src/frontend/transferMetricsFormat.ts | 2 +- .../src/frontend/transferProgressMonitor.tsx | 4 +- .../tests/frontend/LocalFilePane.test.tsx | 2 +- .../tests/frontend/SftpTransferTab.test.tsx | 7 +- .../tests/frontend/activate.test.tsx | 3 +- .../api/ssh-file-operations-api.test.ts | 20 +- .../frontend}/lib/keyed-request-cache.test.ts | 2 +- .../frontend/local-transfer-utils.test.ts | 2 +- .../frontend/optimistic-file-list.test.ts | 2 +- .../frontend/transfer-progress-toast.test.ts | 2 +- plugins/file-manager/tsconfig.json | 6 - plugins/fleets/package.json | 2 +- plugins/fleets/tsconfig.json | 6 - plugins/homepage/package.json | 2 +- plugins/homepage/src/frontend/HomepageApp.tsx | 12 - plugins/homepage/src/frontend/registry.ts | 77 --- plugins/host-metrics/package.json | 2 +- plugins/ldap/package.json | 2 +- plugins/network-topology/package.json | 2 +- plugins/network-topology/tsconfig.json | 6 - plugins/opkssh/manifest.json | 8 + plugins/opkssh/package.json | 2 +- .../opkssh/tests/backend/legacy-urls.test.ts | 19 + plugins/proxmox/package.json | 2 +- plugins/proxmox/src/backend/routes.ts | 57 +++ .../proxmox/src/frontend/HostProxmoxTab.tsx | 35 +- .../src/frontend/ProxmoxDiscoverDialog.tsx | 36 +- plugins/proxmox/src/frontend/index.tsx | 12 +- plugins/proxmox/src/frontend/proxmox-api.ts | 117 +++++ plugins/proxmox/src/frontend/types.ts | 18 + plugins/proxmox/tsconfig.json | 6 - plugins/remote-desktop/package.json | 2 +- plugins/secret-sources/package.json | 2 +- plugins/secret-sources/src/frontend/index.tsx | 4 +- .../tests/frontend/activate.test.tsx | 11 +- plugins/serial/package.json | 2 +- plugins/session-recording/package.json | 2 +- plugins/session-sharing/package.json | 2 +- plugins/snippets/locales/en.json | 25 +- plugins/snippets/package.json | 2 +- .../src/frontend/AdminUserSnippets.tsx | 237 +++++++++ plugins/snippets/src/frontend/index.tsx | 48 +- .../frontend/admin-user-snippets.test.tsx | 85 ++++ plugins/ssh-terminal/package.json | 2 +- .../src/backend/terminal-socket.ts | 1 + plugins/sso/manifest.json | 18 + plugins/sso/package.json | 2 +- plugins/sso/tests/backend/legacy-urls.test.ts | 35 ++ plugins/step-ca/manifest.json | 8 + plugins/step-ca/package.json | 2 +- .../step-ca/tests/backend/legacy-urls.test.ts | 19 + plugins/tailscale/package.json | 2 +- plugins/tailscale/src/backend/routes.ts | 21 +- .../src/backend/ssh-auth-provider.ts | 2 + .../src/frontend/TailscaleCheckDialog.tsx | 2 +- .../src/frontend/TailscaleDevicesPanel.tsx | 10 +- .../src/frontend/TailscaleDevicesStatus.tsx | 2 +- plugins/tailscale/src/frontend/index.tsx | 5 +- .../tailscale/src/frontend/tailscale-api.ts | 51 +- .../frontend/TailscaleDevicesPanel.test.tsx | 3 +- .../tests/frontend/tailscale-api.test.ts | 42 +- plugins/tailscale/tsconfig.json | 6 - plugins/termix-identity/manifest.json | 11 +- plugins/termix-identity/package.json | 2 +- .../tests/backend/legacy-urls.test.ts | 20 + plugins/tmux-monitor/package.json | 2 +- plugins/tmux-monitor/src/backend/routes.ts | 2 +- plugins/totp/package.json | 2 +- plugins/tunnels/locales/en.json | 3 +- plugins/tunnels/package.json | 2 +- plugins/tunnels/src/backend/c2s-relay.ts | 1 + plugins/tunnels/src/backend/manager.ts | 1 + .../src/frontend/ActiveTunnelsCounter.tsx | 42 ++ plugins/tunnels/src/frontend/index.tsx | 19 +- .../tunnels/tests/frontend/activate.test.tsx | 4 +- plugins/vault/manifest.json | 8 + plugins/vault/package.json | 2 +- .../vault/tests/backend/legacy-urls.test.ts | 19 + plugins/wake-on-lan/package.json | 2 +- plugins/warpgate/package.json | 2 +- plugins/web-endpoint/package.json | 2 +- .../src/frontend/HostEditorWebUiSection.tsx | 4 +- .../src/frontend/WebEndpointTab.tsx | 8 +- plugins/web-endpoint/src/frontend/index.tsx | 2 +- .../src/frontend/web-endpoint-api.ts | 17 +- .../tests/frontend/WebEndpointTab.test.tsx | 10 +- .../tests/frontend/activate.test.tsx | 9 +- .../tests/frontend/web-endpoint-api.test.ts | 14 +- plugins/web-endpoint/tsconfig.json | 6 - plugins/webauthn/package.json | 2 +- plugins/workspaces/package.json | 2 +- scripts/check-core-plugin-ids.cjs | 143 ++++++ scripts/check-importmap-csp.cjs | 2 +- scripts/check-plugin-boundaries.cjs | 138 ------ scripts/check-shell-plugin-ids.cjs | 121 ----- scripts/lib/plugin-import-map.mjs | 77 +-- scripts/plugin-boundary-allowlist.json | 62 --- scripts/shell-plugin-id-allowlist.json | 9 - scripts/vite-plugin-termix-plugins.mjs | 16 +- src/backend/auth/builtin-login-methods.ts | 2 +- src/backend/auth/core-auth.ts | 4 +- src/backend/auth/login-pipeline.ts | 9 +- src/backend/auth/provisioning.ts | 6 +- src/backend/auth/session-issuer.ts | 4 +- src/backend/boot.ts | 2 +- src/backend/database/database.ts | 232 +-------- src/backend/database/db/index.ts | 50 +- .../database/db/performance-indexes.ts | 4 - src/backend/database/db/schema.mysql.ts | 22 +- src/backend/database/db/schema.pg.ts | 22 +- src/backend/database/db/schema.ts | 22 +- src/backend/database/repositories/factory.ts | 24 - .../rbac-permission-repository.ts | 4 +- .../repositories/user-auth-repository.ts | 4 +- .../database/routes/auth-compat-routes.ts | 114 ++--- .../database/routes/dashboard-routes.ts | 4 +- .../database/routes/delete-user-data.ts | 19 +- .../database/routes/host-compat-routes.ts | 101 ---- .../database/routes/host-folder-routes.ts | 5 +- .../database/routes/host-normalizers.ts | 2 +- .../database/routes/host-plugin-settings.ts | 2 +- src/backend/database/routes/host.ts | 20 +- src/backend/database/routes/open-tabs.ts | 14 +- .../database/routes/plugin-api-routes.ts | 39 +- .../database/routes/ssh-auth-routes.ts | 6 +- src/backend/database/sync-timestamp.ts | 2 +- .../hosts/connect/auth-provider-registry.ts | 7 +- .../hosts/connect/build-connect-config.ts | 104 +--- .../hosts/connect/builtin-providers.ts | 17 +- src/backend/hosts/connect/connect-host.ts | 6 +- src/backend/hosts/connect/core-providers.ts | 5 - .../hosts/connect/keyboard-interactive.ts | 6 +- .../hosts/connect/secret-resolver-registry.ts | 2 +- src/backend/hosts/connect/transport.ts | 4 +- src/backend/hosts/connect/types.ts | 37 +- src/backend/hosts/connection-log.ts | 15 - src/backend/hosts/delete-host.ts | 5 +- src/backend/hosts/external-secrets.ts | 12 +- src/backend/hosts/host-resolver.ts | 27 -- src/backend/hosts/jump-host-chain.ts | 4 +- src/backend/hosts/live-terminal-sessions.ts | 2 +- src/backend/hosts/ssh-client-factory.ts | 45 -- src/backend/hosts/ssh-dns.ts | 2 +- src/backend/hosts/transfer-paths.ts | 151 ------ src/backend/plugins/actor.ts | 12 +- src/backend/plugins/conflicts.ts | 5 - src/backend/plugins/ctx-ssh-auth.ts | 31 +- src/backend/plugins/data.ts | 29 +- src/backend/plugins/host-view.ts | 56 --- src/backend/plugins/http.ts | 4 - src/backend/plugins/index.ts | 3 +- src/backend/plugins/loader.ts | 8 +- src/backend/plugins/manifest.ts | 13 - src/backend/plugins/migrations.ts | 10 +- src/backend/plugins/paths.ts | 4 - src/backend/plugins/permissions.ts | 2 - src/backend/plugins/secret-registry.ts | 2 +- src/backend/plugins/sync-registry.ts | 8 - src/backend/plugins/table-builder.ts | 2 - src/backend/plugins/user-data.ts | 195 ++++++++ src/backend/plugins/ws.ts | 4 +- src/backend/services/recent-activity.ts | 19 +- src/backend/starter.ts | 2 +- .../routes/auth-compat-routes.test.ts | 42 +- .../routes/host-compat-routes.test.ts | 116 ----- .../routes/plugin-legacy-paths.test.ts | 50 +- .../tests/database/routes/plugins.test.ts | 2 +- .../tests/electron/local-files.test.ts | 26 +- .../tests/hosts/connect/pipeline.test.ts | 49 +- src/backend/tests/plugins/built-harness.ts | 2 +- src/backend/tests/plugins/manifest.test.ts | 33 ++ .../tests/plugins/permissions-rbac.test.ts | 4 +- src/backend/tests/plugins/user-data.test.ts | 137 ++++++ .../acme-ssl-settings-migration.test.ts | 2 +- .../ai-settings-migration.test.ts | 2 +- .../docker-settings-migration.test.ts | 2 +- .../external-identity-migration.test.ts | 13 +- .../file-manager-settings-migration.test.ts | 2 +- .../host-metrics-settings-migration.test.ts | 2 +- .../ldap-provider-migration.test.ts | 2 +- .../opkssh-config-migration.test.ts | 2 +- .../proxmox-settings-migration.test.ts | 2 +- .../remote-desktop-settings-migration.test.ts | 2 +- .../secret-sources-token-migration.test.ts | 2 +- ...ssion-recording-settings-migration.test.ts | 2 +- ...session-sharing-settings-migration.test.ts | 2 +- .../ssh-terminal-settings-migration.test.ts | 2 +- .../step-ca-settings-migration.test.ts | 2 +- .../tailscale-settings-migration.test.ts | 2 +- .../termix-identity-ca-migration.test.ts | 2 +- .../tmux-monitor-settings-migration.test.ts | 2 +- .../{utils => upgrade}/totp-migration.test.ts | 3 +- .../tunnels-settings-migration.test.ts | 2 +- .../vault-settings-migration.test.ts | 2 +- .../wake-on-lan-settings-migration.test.ts | 2 +- .../warpgate-settings-migration.test.ts | 2 +- .../web-endpoint-settings-migration.test.ts | 2 +- src/backend/tests/utils/analytics.test.ts | 1 - .../utils/audit-retention-migration.test.ts | 62 +-- src/backend/tls/certificate.ts | 4 +- src/backend/tls/tls-service.ts | 2 +- .../acme-ssl-settings-migration.ts | 6 +- .../ai-settings-migration.ts | 15 +- .../{plugins => upgrade}/boot-migrations.ts | 42 +- .../docker-settings-migration.ts | 6 +- .../external-identity-migration.ts | 4 +- .../file-manager-settings-migration.ts | 9 +- .../host-metrics-settings-migration.ts | 6 +- .../ldap-provider-migration.ts | 9 +- .../opkssh-config-migration.ts | 4 +- .../plugin-data-moves.ts | 0 .../proxmox-settings-migration.ts | 6 +- .../remote-desktop-settings-migration.ts | 11 +- .../secret-sources-token-migration.ts | 15 +- .../session-recording-settings-migration.ts | 9 +- .../session-sharing-settings-migration.ts | 6 +- .../ssh-terminal-settings-migration.ts | 11 +- .../step-ca-settings-migration.ts | 6 +- .../tailscale-settings-migration.ts | 6 +- .../termix-identity-ca-migration.ts | 13 +- .../tmux-monitor-settings-migration.ts | 6 +- .../totp-migration.ts | 13 +- .../tunnels-settings-migration.ts | 6 +- .../vault-settings-migration.ts | 6 +- .../wake-on-lan-settings-migration.ts | 6 +- .../warpgate-settings-migration.ts | 9 +- .../web-endpoint-settings-migration.ts | 6 +- src/backend/utils/analytics.ts | 22 +- .../utils/audit-retention-migration.ts | 83 +--- .../utils/crypto-migration/dek-migration.ts | 6 +- src/backend/utils/data-dir-guard.ts | 4 +- .../utils/database-layer-preupgrade-backup.ts | 2 +- src/backend/utils/known-permissions.ts | 2 +- src/backend/utils/logger.ts | 3 - src/backend/utils/permission-manager.ts | 2 +- src/backend/utils/proxy-agent.ts | 2 +- src/backend/utils/proxy-helper.ts | 4 +- src/backend/utils/request-origin.ts | 2 +- src/backend/utils/service-listen.ts | 2 +- .../utils/shared-host-auth-resolver.ts | 2 +- src/backend/utils/socks5-helper.ts | 7 +- src/backend/utils/ssh-algorithms.ts | 46 -- src/backend/utils/ssh-key-utils.ts | 2 +- src/backend/utils/swagger.ts | 1 - src/backend/utils/user-data-export.ts | 99 +--- src/backend/utils/ws-message.ts | 2 +- src/types/auth-protocols.ts | 2 +- src/types/connection-log.ts | 4 - src/types/homepage-types.ts | 393 --------------- src/types/index.ts | 373 +-------------- src/types/proxmox.ts | 124 ----- src/types/ui-preferences.ts | 82 +--- src/types/ui-types.ts | 55 +-- src/ui/AppShell.tsx | 2 +- src/ui/api/admin-user-data-api.ts | 60 --- src/ui/api/auth-methods-api.ts | 2 + src/ui/api/snippets-api.ts | 42 -- src/ui/api/ssh-host-management-api.ts | 92 +--- src/ui/components/folder-style.tsx | 8 +- src/ui/dashboard/DashboardTab.tsx | 36 +- .../dashboard/panels/alerts/AlertManager.tsx | 6 +- src/ui/hooks/use-permissions.ts | 2 +- src/ui/hooks/use-snippet-runner.tsx | 6 +- src/ui/hooks/useSshAuthProviders.ts | 1 + src/ui/lib/SimpleLoader.tsx | 63 --- src/ui/lib/apply-ui-preset.ts | 31 +- src/ui/lib/frontend-logger.ts | 11 +- src/ui/lib/hosts-request-cache.ts | 4 - src/ui/lib/plugin-transport.ts | 19 +- src/ui/lib/remote-server-api.ts | 2 +- src/ui/lib/snippet-provider.ts | 22 + src/ui/lib/snippet-variables.ts | 5 - src/ui/lib/splitDragging.ts | 5 - src/ui/locales/en.json | 23 +- src/ui/main-axios.ts | 449 +----------------- src/ui/plugin-host/app.ts | 1 + src/ui/plugin-host/auth-registry.ts | 7 - src/ui/plugin-host/bridge.ts | 27 +- src/ui/plugin-host/component-registry.ts | 10 - .../plugin-host/homepage-widget-registry.ts | 2 - src/ui/plugin-host/loader.ts | 2 +- src/ui/plugin-host/plugin-store.ts | 2 +- src/ui/plugin-host/scope.tsx | 2 +- src/ui/plugin-host/sdk-ui.ts | 37 ++ src/ui/plugin-host/shell-bridge.ts | 4 - src/ui/plugin-host/testing-host.tsx | 11 +- src/ui/settings/settings-components.ts | 6 - src/ui/shell/CommandPalette.tsx | 11 +- src/ui/shell/action-registry.ts | 13 - src/ui/shell/panel-registry.ts | 1 - src/ui/shell/shell-layout.ts | 2 +- src/ui/shell/tab-registry.ts | 4 +- src/ui/sidebar/AdminSettingsPanel.tsx | 2 +- src/ui/sidebar/AdminUserManagePanel.tsx | 251 +--------- src/ui/sidebar/CredentialEditorView.tsx | 4 +- src/ui/sidebar/HostEditor.tsx | 14 +- src/ui/sidebar/InterfacePresetSettings.tsx | 8 +- src/ui/sidebar/KeybindingsDialog.tsx | 5 +- src/ui/sidebar/QuickConnectPanel.tsx | 73 ++- src/ui/sidebar/UserProfilePanel.tsx | 2 +- src/ui/sidebar/branding-logo-file.ts | 2 +- .../CredentialItem/CredentialItem.tsx | 8 - src/ui/sidebar/credential-tree/index.ts | 2 +- .../sidebar/credential-tree/visible-rows.ts | 2 +- src/ui/sidebar/host-contributions.ts | 2 + src/ui/sidebar/host-export-payload.ts | 2 +- src/ui/sidebar/quick-connect-host.ts | 5 +- src/ui/sidebar/rail-items.ts | 8 +- src/ui/sidebar/tree/HostItem/HostItem.tsx | 6 +- src/ui/sidebar/tree/index.ts | 3 +- src/ui/tests/lib/apply-ui-preset.test.ts | 24 +- .../sidebar/AdminUserManagePanel.test.tsx | 64 +-- .../tests/sidebar/quick-connect-host.test.ts | 15 + src/ui/tests/types/ui-preferences.test.ts | 23 +- tsconfig.json | 2 +- ...gins.json => tsconfig.plugins.backend.json | 2 +- vite.config.ts | 7 +- vitest.config.ts | 40 +- 399 files changed, 3964 insertions(+), 5627 deletions(-) delete mode 100644 packages/plugin-sdk/cli/lib/legacy-core-imports.mjs delete mode 100644 packages/plugin-sdk/cli/lib/legacy-core-specifier.mjs create mode 100644 plugins/file-manager/src/backend/host-identity.ts create mode 100644 plugins/file-manager/src/frontend/api/client.ts create mode 100644 plugins/file-manager/src/frontend/api/hosts.ts create mode 100644 plugins/file-manager/src/frontend/api/transfer-api.ts rename src/ui/components/folder.tsx => plugins/file-manager/src/frontend/components/FolderTree.tsx (99%) create mode 100644 plugins/file-manager/src/frontend/host-types.ts create mode 100644 plugins/file-manager/src/frontend/lib/error-message.ts rename {src/ui => plugins/file-manager/src/frontend}/lib/http-error.ts (100%) rename {src/ui => plugins/file-manager/src/frontend}/lib/keyed-request-cache.ts (100%) rename {src/ui => plugins/file-manager/src/frontend}/lib/local-files.ts (97%) rename {src/ui/tests => plugins/file-manager/tests/frontend}/lib/keyed-request-cache.test.ts (96%) delete mode 100644 plugins/homepage/src/frontend/HomepageApp.tsx delete mode 100644 plugins/homepage/src/frontend/registry.ts create mode 100644 plugins/opkssh/tests/backend/legacy-urls.test.ts create mode 100644 plugins/proxmox/src/frontend/proxmox-api.ts create mode 100644 plugins/snippets/src/frontend/AdminUserSnippets.tsx create mode 100644 plugins/snippets/tests/frontend/admin-user-snippets.test.tsx create mode 100644 plugins/sso/tests/backend/legacy-urls.test.ts create mode 100644 plugins/step-ca/tests/backend/legacy-urls.test.ts create mode 100644 plugins/termix-identity/tests/backend/legacy-urls.test.ts create mode 100644 plugins/tunnels/src/frontend/ActiveTunnelsCounter.tsx create mode 100644 plugins/vault/tests/backend/legacy-urls.test.ts create mode 100644 scripts/check-core-plugin-ids.cjs delete mode 100644 scripts/check-plugin-boundaries.cjs delete mode 100644 scripts/check-shell-plugin-ids.cjs delete mode 100644 scripts/plugin-boundary-allowlist.json delete mode 100644 scripts/shell-plugin-id-allowlist.json delete mode 100644 src/backend/database/routes/host-compat-routes.ts delete mode 100644 src/backend/hosts/connection-log.ts delete mode 100644 src/backend/hosts/ssh-client-factory.ts delete mode 100644 src/backend/hosts/transfer-paths.ts delete mode 100644 src/backend/plugins/host-view.ts create mode 100644 src/backend/plugins/user-data.ts delete mode 100644 src/backend/tests/database/routes/host-compat-routes.test.ts create mode 100644 src/backend/tests/plugins/user-data.test.ts rename src/backend/tests/{utils => upgrade}/acme-ssl-settings-migration.test.ts (97%) rename src/backend/tests/{utils => upgrade}/ai-settings-migration.test.ts (99%) rename src/backend/tests/{utils => upgrade}/docker-settings-migration.test.ts (98%) rename src/backend/tests/{utils/crypto-migration => upgrade}/external-identity-migration.test.ts (90%) rename src/backend/tests/{utils => upgrade}/file-manager-settings-migration.test.ts (98%) rename src/backend/tests/{utils => upgrade}/host-metrics-settings-migration.test.ts (98%) rename src/backend/tests/{utils => upgrade}/ldap-provider-migration.test.ts (98%) rename src/backend/tests/{utils => upgrade}/opkssh-config-migration.test.ts (96%) rename src/backend/tests/{utils => upgrade}/proxmox-settings-migration.test.ts (98%) rename src/backend/tests/{utils => upgrade}/remote-desktop-settings-migration.test.ts (98%) rename src/backend/tests/{utils => upgrade}/secret-sources-token-migration.test.ts (98%) rename src/backend/tests/{utils => upgrade}/session-recording-settings-migration.test.ts (98%) rename src/backend/tests/{utils => upgrade}/session-sharing-settings-migration.test.ts (98%) rename src/backend/tests/{utils => upgrade}/ssh-terminal-settings-migration.test.ts (98%) rename src/backend/tests/{utils => upgrade}/step-ca-settings-migration.test.ts (97%) rename src/backend/tests/{utils => upgrade}/tailscale-settings-migration.test.ts (98%) rename src/backend/tests/{utils => upgrade}/termix-identity-ca-migration.test.ts (97%) rename src/backend/tests/{utils => upgrade}/tmux-monitor-settings-migration.test.ts (97%) rename src/backend/tests/{utils => upgrade}/totp-migration.test.ts (98%) rename src/backend/tests/{utils => upgrade}/tunnels-settings-migration.test.ts (98%) rename src/backend/tests/{utils => upgrade}/vault-settings-migration.test.ts (97%) rename src/backend/tests/{utils => upgrade}/wake-on-lan-settings-migration.test.ts (98%) rename src/backend/tests/{utils => upgrade}/warpgate-settings-migration.test.ts (98%) rename src/backend/tests/{utils => upgrade}/web-endpoint-settings-migration.test.ts (98%) rename src/backend/{utils/crypto-migration => upgrade}/acme-ssl-settings-migration.ts (95%) rename src/backend/{utils/crypto-migration => upgrade}/ai-settings-migration.ts (94%) rename src/backend/{plugins => upgrade}/boot-migrations.ts (50%) rename src/backend/{utils/crypto-migration => upgrade}/docker-settings-migration.ts (95%) rename src/backend/{utils/crypto-migration => upgrade}/external-identity-migration.ts (96%) rename src/backend/{utils/crypto-migration => upgrade}/file-manager-settings-migration.ts (92%) rename src/backend/{utils/crypto-migration => upgrade}/host-metrics-settings-migration.ts (97%) rename src/backend/{utils/crypto-migration => upgrade}/ldap-provider-migration.ts (96%) rename src/backend/{utils/crypto-migration => upgrade}/opkssh-config-migration.ts (96%) rename src/backend/{utils/crypto-migration => upgrade}/plugin-data-moves.ts (100%) rename src/backend/{utils/crypto-migration => upgrade}/proxmox-settings-migration.ts (94%) rename src/backend/{utils/crypto-migration => upgrade}/remote-desktop-settings-migration.ts (97%) rename src/backend/{utils/crypto-migration => upgrade}/secret-sources-token-migration.ts (89%) rename src/backend/{utils/crypto-migration => upgrade}/session-recording-settings-migration.ts (91%) rename src/backend/{utils/crypto-migration => upgrade}/session-sharing-settings-migration.ts (94%) rename src/backend/{utils/crypto-migration => upgrade}/ssh-terminal-settings-migration.ts (96%) rename src/backend/{utils/crypto-migration => upgrade}/step-ca-settings-migration.ts (94%) rename src/backend/{utils/crypto-migration => upgrade}/tailscale-settings-migration.ts (95%) rename src/backend/{utils/crypto-migration => upgrade}/termix-identity-ca-migration.ts (87%) rename src/backend/{utils/crypto-migration => upgrade}/tmux-monitor-settings-migration.ts (91%) rename src/backend/{utils/crypto-migration => upgrade}/totp-migration.ts (94%) rename src/backend/{utils/crypto-migration => upgrade}/tunnels-settings-migration.ts (93%) rename src/backend/{utils/crypto-migration => upgrade}/vault-settings-migration.ts (96%) rename src/backend/{utils/crypto-migration => upgrade}/wake-on-lan-settings-migration.ts (95%) rename src/backend/{utils/crypto-migration => upgrade}/warpgate-settings-migration.ts (94%) rename src/backend/{utils/crypto-migration => upgrade}/web-endpoint-settings-migration.ts (92%) delete mode 100644 src/types/homepage-types.ts delete mode 100644 src/types/proxmox.ts delete mode 100644 src/ui/api/snippets-api.ts delete mode 100644 src/ui/lib/SimpleLoader.tsx create mode 100644 src/ui/lib/snippet-provider.ts rename tsconfig.plugins.json => tsconfig.plugins.backend.json (90%) diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index 275c1330d..538e72e04 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -174,7 +174,7 @@ http { root /app/html; index index.html index.htm; expires off; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-q2DmZRcdiLWe4+k149nTr+u9AGPLVk+Bx0aWPFnz5bU='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-gQLhWUEDVfLtBiv83VAckuSAVmhxOpOHbNpFbODgPN0='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; add_header X-Frame-Options "DENY" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; @@ -271,24 +271,6 @@ http { proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } - location ~ ^/snippets(/.*)?$ { - proxy_pass http://127.0.0.1:30001; - proxy_http_version 1.1; - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; - } - - location = /vault/oidc/callback { - proxy_pass http://127.0.0.1:30001; - proxy_http_version 1.1; - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; - } - location ~ ^/sync(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; @@ -298,15 +280,6 @@ http { proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } - location ^~ /termix-id/u/ { - proxy_pass http://127.0.0.1:30001; - proxy_http_version 1.1; - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - location ~ ^/audit-logs(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; @@ -336,8 +309,29 @@ http { proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } - # Automation webhooks lived at /automations/webhook/ before - # 2.9.0 and external systems still call that URL. + # 2.8 URLs that something outside Termix still calls: the Vault OIDC + # callback, the Termix ID resolver and automation webhooks. The + # backend redirects each to the plugin that serves it now (see + # contributes.http in each plugin's manifest). New plugins never add + # a block here. + location = /vault/oidc/callback { + proxy_pass http://127.0.0.1:30001; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; + } + + location ^~ /termix-id/u/ { + proxy_pass http://127.0.0.1:30001; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location ^~ /automations/webhook/ { rewrite ^/automations/webhook/(.*)$ /plugin-api/automations/webhook/$1 last; } @@ -455,25 +449,6 @@ http { proxy_buffering off; } - location ~ ^/db(/.*)?$ { - client_max_body_size 5G; - client_body_timeout 300s; - - proxy_pass http://127.0.0.1:30001; - proxy_http_version 1.1; - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; - - proxy_connect_timeout 60s; - proxy_send_timeout 300s; - proxy_read_timeout 300s; - - proxy_request_buffering off; - proxy_buffering off; - } - location ~ ^/encryption(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; diff --git a/docker/nginx.conf b/docker/nginx.conf index 641ebf9c2..b80029950 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -144,7 +144,7 @@ http { root /app/html; index index.html index.htm; expires off; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-q2DmZRcdiLWe4+k149nTr+u9AGPLVk+Bx0aWPFnz5bU='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-gQLhWUEDVfLtBiv83VAckuSAVmhxOpOHbNpFbODgPN0='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; add_header X-Frame-Options "DENY" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; @@ -240,24 +240,6 @@ http { proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } - location ~ ^/snippets(/.*)?$ { - proxy_pass http://127.0.0.1:30001; - proxy_http_version 1.1; - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; - } - - location = /vault/oidc/callback { - proxy_pass http://127.0.0.1:30001; - proxy_http_version 1.1; - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; - } - location ~ ^/sync(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; @@ -267,15 +249,6 @@ http { proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } - location ^~ /termix-id/u/ { - proxy_pass http://127.0.0.1:30001; - proxy_http_version 1.1; - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - location ~ ^/audit-logs(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; @@ -305,8 +278,29 @@ http { proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } - # Automation webhooks lived at /automations/webhook/ before - # 2.9.0 and external systems still call that URL. + # 2.8 URLs that something outside Termix still calls: the Vault OIDC + # callback, the Termix ID resolver and automation webhooks. The + # backend redirects each to the plugin that serves it now (see + # contributes.http in each plugin's manifest). New plugins never add + # a block here. + location = /vault/oidc/callback { + proxy_pass http://127.0.0.1:30001; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; + } + + location ^~ /termix-id/u/ { + proxy_pass http://127.0.0.1:30001; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location ^~ /automations/webhook/ { rewrite ^/automations/webhook/(.*)$ /plugin-api/automations/webhook/$1 last; } @@ -424,25 +418,6 @@ http { proxy_buffering off; } - location ~ ^/db(/.*)?$ { - client_max_body_size 5G; - client_body_timeout 300s; - - proxy_pass http://127.0.0.1:30001; - proxy_http_version 1.1; - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; - - proxy_connect_timeout 60s; - proxy_send_timeout 300s; - proxy_read_timeout 300s; - - proxy_request_buffering off; - proxy_buffering off; - } - location ~ ^/encryption(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; diff --git a/drizzle/sqlite/0045_public_menace.sql b/drizzle/sqlite/0045_public_menace.sql index 15dcd3965..71ded1bc6 100644 --- a/drizzle/sqlite/0045_public_menace.sql +++ b/drizzle/sqlite/0045_public_menace.sql @@ -1,3 +1,4 @@ -DROP TABLE `dashboard_service_links`;--> statement-breakpoint -DROP TABLE `homepage_items`;--> statement-breakpoint -DROP TABLE `homepage_layouts`; \ No newline at end of file +-- dashboard_service_links, homepage_items and homepage_layouts moved to the +-- homepage plugin, which adopts them by rename at activation. Dropping them +-- here would delete every row before the plugin's migration runs. +SELECT 1; diff --git a/electron/local-files.cjs b/electron/local-files.cjs index 637db56c3..6bbe37b9e 100644 --- a/electron/local-files.cjs +++ b/electron/local-files.cjs @@ -317,16 +317,18 @@ function writeToRequest(request, chunk) { } const TRANSFER_ROUTES = Object.freeze({ - uploadFileStream: "/ssh/uploadFileStream", - downloadFileStream: "/ssh/downloadFileStream", + uploadFileStream: "/uploadFileStream", + downloadFileStream: "/downloadFileStream", }); 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_-]+$/; -const DEFAULT_LOCAL_FILE_MANAGER_BASE = - "http://localhost:30004/ssh/file_manager"; +// 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}`; function normalizeHttpBase(candidate, what) { let parsed; @@ -408,7 +410,7 @@ function createTargetResolver({ const jwt = typeof getRemoteSyncJwt === "function" ? getRemoteSyncJwt() : null; if (jwt) headers.Authorization = `Bearer ${jwt}`; - return { url: `${base}/ssh/file_manager${routePath}`, headers }; + return { url: `${base}${FILE_MANAGER_API}${routePath}`, headers }; } throw new Error(`Unknown transfer origin: ${String(origin)}`); diff --git a/electron/main.cjs b/electron/main.cjs index 1f3c7e32a..4e83ed704 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1050,7 +1050,7 @@ function getBackendPidFilePath() { // Manager) rather than through the normal quit flow, will-quit never fires // and stopBackendServer() never runs -- the forked backend child is a // genuinely separate OS process on Windows/mac/Linux, so it keeps running -// and holding every port the backend binds (30001, 30004). Every subsequent +// and holding the port the backend binds (30001). Every subsequent // launch's own backend then fails outright // with EADDRINUSE and the app is stuck until something manually kills the // orphan. Reap any such leftover process, identified by PID file, before diff --git a/eslint.config.mjs b/eslint.config.mjs index 597d4111f..4c5e68ad5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,6 +5,49 @@ import reactRefresh from "eslint-plugin-react-refresh"; import unusedImports from "eslint-plugin-unused-imports"; import tseslint from "typescript-eslint"; import { globalIgnores } from "eslint/config"; +import path from "node:path"; + +// A plugin may import @termix/plugin-sdk, npm packages and its own files. +// A relative path that climbs out of plugins// reaches core or another +// plugin, and "@/..." is core's alias. +const pluginBoundary = { + rules: { + "stay-inside": { + meta: { + type: "problem", + messages: { + core: "A plugin reaches core only through @termix/plugin-sdk. Use an SDK API, or add one.", + outside: + "A plugin cannot import files outside its own folder. Use the SDK, ctx.services, events, actions or slots.", + }, + }, + create(context) { + const file = context.filename.replace(/\\/g, "/"); + const root = /^(.*\/plugins\/[^/]+)\//.exec(file)?.[1]; + const check = (node, source) => { + if (typeof source !== "string") return; + if (source === "@" || source.startsWith("@/")) { + context.report({ node, messageId: "core" }); + return; + } + if (!root || !source.startsWith(".")) return; + const target = path.posix.normalize( + path.posix.join(path.posix.dirname(file), source), + ); + if (target !== root && !target.startsWith(`${root}/`)) { + context.report({ node, messageId: "outside" }); + } + }; + return { + ImportDeclaration: (node) => check(node, node.source.value), + ExportNamedDeclaration: (node) => check(node, node.source?.value), + ExportAllDeclaration: (node) => check(node, node.source.value), + ImportExpression: (node) => check(node, node.source.value), + }; + }, + }, + }, +}; export default tseslint.config([ globalIgnores([ @@ -108,17 +151,10 @@ export default tseslint.config([ // Core must not reach into a plugin. A plugin can be disabled, upgraded // or removed, so an import from core turns "disabled" into a broken // build rather than a missing feature. Core talks to plugins through the - // runtime in src/backend/plugins/ and the dispatchers in - // database/routes/*-dispatch.ts, both of which handle absence. - // - // Tests are exempt: they import a plugin's modules on purpose to test - // them, and until A2 gives each plugin its own suite they live under - // src/*/tests/plugins/. + // runtime in src/backend/plugins/, which handles absence. Core tests only + // use fixture plugins. files: ["src/**/*.{ts,tsx}"], - ignores: ["src/**/tests/**"], rules: { - // An error for the backend, which has dispatchers to register through - // and no remaining offenders. "no-restricted-imports": [ "error", { @@ -126,7 +162,7 @@ export default tseslint.config([ { group: ["**/plugins/*/src/backend/**"], message: - "Core must not import a plugin backend. Register a dispatcher or an SDK service instead, so disabling the plugin degrades cleanly.", + "Core must not import a plugin backend. Go through the plugin runtime, so disabling the plugin degrades cleanly.", }, ], }, @@ -139,7 +175,6 @@ export default tseslint.config([ // also carries the backend rule for src/ui files, because flat config // replaces a rule's options rather than merging them. files: ["src/ui/**/*.{ts,tsx}", "src/main.tsx"], - ignores: ["src/ui/tests/**"], rules: { "no-restricted-imports": [ "error", @@ -156,53 +191,12 @@ export default tseslint.config([ }, }, { - // The other direction. A plugin is supposed to talk to core only through - // @termix/plugin-sdk, which is the contract D1 finishes enforcing. - // - // Only the backend is fenced today. Plugin backends reach core by - // relative path (../../../src/backend/...) and plugin frontends use the - // "@/" alias for shared UI, both of which are the "legacy core imports" - // debt recorded in packages/plugin-sdk/ARCHITECTURE.md: the SDK does not - // yet expose the SSH pool, host resolution, repositories or the - // component library. A7 moves the frontends onto the app object and D1 - // moves the backends onto SDK APIs, and this rule tightens to match. - // - // What it already catches: a plugin backend importing the frontend tree, - // which is not debt in any direction, just a mistake. - files: ["plugins/*/src/backend/**/*.{ts,mjs}"], + // The other direction. A plugin reaches core only through + // @termix/plugin-sdk, and never reaches into another plugin's source. + files: ["plugins/*/src/**/*.{ts,tsx,mjs}", "plugins/*/tests/**/*.{ts,tsx}"], + plugins: { "termix-plugins": pluginBoundary }, rules: { - "no-restricted-imports": [ - "error", - { - patterns: [ - { - group: ["@/*", "**/src/ui/**"], - message: - "A plugin backend cannot import frontend code. Import from @termix/plugin-sdk, or use a relative path within the plugin.", - }, - ], - }, - ], - // A second rule name rather than a second pattern, because flat config - // replaces a rule's options rather than merging them: one rule cannot - // carry both severities, and folding this into the error above would - // fail the build on debt that has nowhere to go yet. - // - // scripts/plugin-boundary-allowlist.json holds today's offenders and - // scripts/check-plugin-boundaries.cjs fails on a new one. D1 empties - // the list and this becomes an error. - "@typescript-eslint/no-restricted-imports": [ - "warn", - { - patterns: [ - { - group: ["**/src/backend/**", "**/src/types/**"], - message: - "A plugin should reach core through @termix/plugin-sdk. This relative import is legacy debt tracked in scripts/plugin-boundary-allowlist.json.", - }, - ], - }, - ], + "termix-plugins/stay-inside": "error", }, }, ]); diff --git a/knip.json b/knip.json index 1709411b8..a96e560d5 100644 --- a/knip.json +++ b/knip.json @@ -1,93 +1,123 @@ { "$schema": "https://unpkg.com/knip@latest/schema.json", - "entry": [ - "src/backend/starter.ts", - "src/backend/swagger.ts", - "scripts/**/*.cjs" - ], - "project": ["src/**/*.{ts,tsx}", "electron/**/*.cjs", "scripts/**/*.cjs"], - "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", - "body-parser", - "chalk", - "class-variance-authority", - "clsx", - "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", - "husky", - "sharp", - "sonner", - "tailwind-merge" - ] + "workspaces": { + ".": { + "entry": [ + "src/main.tsx", + "src/backend/starter.ts", + "src/backend/utils/swagger.ts", + "src/ui/plugin-host/sdk-ui.ts", + "src/ui/plugin-host/testing-host.tsx", + "src/**/tests/**/*.{ts,tsx}", + "scripts/**/*.{cjs,mjs}", + "electron/main.cjs", + "electron/preload.js", + "vitest.config.ts", + "vitest.setup.ts", + "vite.config.ts" + ], + "project": [ + "src/**/*.{ts,tsx}", + "electron/**/*.cjs", + "scripts/**/*.{cjs,mjs}" + ], + "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", + "body-parser", + "chalk", + "class-variance-authority", + "clsx", + "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", + "husky", + "sharp", + "sonner", + "tailwind-merge" + ] + }, + "packages/plugin-sdk": { + "entry": ["src/*.ts", "src/testing/*.ts", "cli/**/*.mjs"], + "project": ["src/**/*.ts", "cli/**/*.mjs"] + }, + "plugins/*": { + "entry": [ + "src/backend/index.ts", + "src/frontend/index.{ts,tsx}", + "tests/**/*.{ts,tsx}", + "vitest.config.ts" + ], + "project": ["src/**/*.{ts,tsx}", "tests/**/*.{ts,tsx}"] + } + } } diff --git a/package.json b/package.json index 73085ab91..96cad6787 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "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", "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-plugin-boundaries.cjs && node scripts/check-importmap-csp.cjs && node scripts/check-shell-plugin-ids.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", "lint:fix": "eslint --fix .", "type-check": "tsc -b --force", "test": "vitest run", @@ -31,8 +31,8 @@ "build": "npm run build:sdk && vite build && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins", "build:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins", "dev:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins && node ./dist/backend/backend/starter.js", - "dev:docker": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker build -f docker/Dockerfile -t termix:dev --no-cache . && docker run -d --name termix-dev -p 3000:3000 -p 8080:8080 -p 30001-30006:30001-30006 -v \"%cd%\\db\\data:/app/data\" termix:dev", - "dev:docker:restart": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker run -d --name termix-dev -p 8080:8080 -p 30001-30006:30001-30006 -v \"%cd%\\db\\data:/app/data\" termix:dev", + "dev:docker": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker build -f docker/Dockerfile -t termix:dev --no-cache . && docker run -d --name termix-dev -p 3000:3000 -p 8080:8080 -p 30001:30001 -v \"%cd%\\db\\data:/app/data\" termix:dev", + "dev:docker:restart": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker run -d --name termix-dev -p 8080:8080 -p 30001:30001 -v \"%cd%\\db\\data:/app/data\" termix:dev", "generate:openapi": "tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node ./dist/backend/backend/utils/swagger.js", "preview": "vite preview", "electron:dev": "concurrently \"npm run dev\" \"powershell -c \\\"Start-Sleep -Seconds 5\\\" && electron .\"", diff --git a/packages/plugin-sdk/ARCHITECTURE.md b/packages/plugin-sdk/ARCHITECTURE.md index 177621c8b..828370774 100644 --- a/packages/plugin-sdk/ARCHITECTURE.md +++ b/packages/plugin-sdk/ARCHITECTURE.md @@ -43,8 +43,16 @@ dependencies. Entry points: Plugins never import from `src/` or `@/`, and core never imports from `plugins/`. Anything a plugin needs from core becomes a typed, documented SDK -API. Enforced by lint, with the exceptions recorded under -[legacy core imports](#legacy-core-imports-the-debt-d1-removes). +API. Enforced by lint, with no exceptions: + +- `eslint.config.mjs` runs a local rule, `termix-plugins/stay-inside`, on + every file under `plugins/*/src` and `plugins/*/tests`. It refuses `@/...` + and any relative import that resolves outside `plugins//`, which covers + core (`src/`) and every other plugin. +- The same config refuses a `src/` file importing `plugins/*/src` or + `plugins/*/dist`. +- `scripts/check-core-plugin-ids.cjs` (see [UI](#8-ui)) refuses core naming a + plugin at all. The SDK holds the contract: types, the catalog, and pure helpers. The ctx implementation lives in core (`src/backend/plugins/ctx.ts`) and is injected at @@ -78,7 +86,7 @@ can never be confused with colon-separated capabilities. `contributes.permissions`, and core registers each as `.`. The plugin never writes the prefix, so it cannot claim a core group or another plugin's namespace: `parseManifest` refuses a name starting with `hosts`, -`snippets`, `credentials`, `admin` or the plugin's own id, and the runtime +`credentials`, `admin` or the plugin's own id, and the runtime re-checks against every namespace already in the catalog, because the validator cannot know which other plugins exist. @@ -184,6 +192,34 @@ Automations replaced them and A3 drops them. A core table needs all three artifacts in lockstep: the `schema.ts` declaration, the `index.ts` DDL, and the regenerated `schema.pg.ts`/`schema.mysql.ts` plus drizzle migrations. +**What stays in core.** After 2.9.0, `schema.ts`, the SQLite DDL in +`db/index.ts`, `performance-indexes.ts`, the repository factory and the +drizzle migrations hold only these 38 tables. Anything else is a plugin's, +under `p__`. + +| Area | Tables | +| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Accounts | `users`, `sessions`, `trusted_devices`, `api_keys`, `user_external_identities`, `user_second_factors` | +| Preferences | `settings`, `user_preferences`, `ui_preferences`, `host_sidebar_preferences`, `credential_sidebar_preferences`, `user_open_tabs` | +| Hosts | `ssh_data`, `ssh_folders`, `recent_activity`, `dismissed_alerts` | +| Credentials | `ssh_credentials`, `ssh_credential_usage` | +| Sharing and RBAC | `roles`, `user_roles`, `host_access`, `folder_access`, `credential_access`, `shared_host_secrets`, `shared_host_auth_overrides`, `shared_credential_secrets`, `rbac_known_permissions`, `rbac_applied_defaults` | +| Audit and alerts | `audit_logs`, `notification_channels` | +| Sync | `sync_tombstones` | +| Plugin runtime | `plugins`, `plugin_permission_grants`, `plugin_registries`, `plugin_install_counts`, `plugin_storage`, `plugin_settings`, `plugin_migrations` | + +A few core tables still carry columns a plugin reads through its own host or +user settings after the upgrade (the moved `ssh_data` feature columns, the +2.8 OIDC columns on `users`, `sessions.oidc_sub`/`oidc_sid`). They are +dropped in 3.0.0; see [Moving a host column into a plugin](#moving-a-host-column-into-a-plugin). + +**Exports and imports follow the plugins.** The per-user SQLite export and +the JSON user export include every row the user owns in a plugin table: any +table a running plugin declared with a `refUser()` column +(`src/backend/plugins/user-data.ts`). Encrypted columns stay behind, since +they are sealed with this server's keys. An import reads each table by its +`p_` name, or by the legacy name it adopted for a file written before 2.9. + **Sync entities are registered, not hardcoded.** `ctx.sync.registerEntity` adds an entity to remote sync; core registers its own the same way in `database/routes/sync-entities.ts`. The wire names are unchanged, because @@ -272,6 +308,26 @@ handler (noted for D2). Every route connection now also carries `clientIp`, `requestOrigin` and `isDataUnlocked()`, the data-key check a long-lived socket repeats per message so an expired session stops being served. +**Old URLs outside the namespace.** A few 2.8 URLs are called by something +outside Termix: identity provider callbacks, the Vault OIDC callback, the +Termix ID resolver, back-channel logout. A plugin lists them in +`contributes.http.legacyRedirects` as `{ from, to, status? }`. Core redirects +a request at `from` (or under it) to `/plugin-api/` with the rest of +the path and the query kept, 307 by default or 308 for a permanent move. +`from` may be any absolute path outside the plugin framework's own prefixes, +and core routes win a clash. It is a redirect rather than a forward because a +cookie the plugin set is scoped to its own path. `contributes.http.legacyPaths` +is the other form, for URLs already under `//` (automation +webhooks), which the plugin's router serves in place. 2.8 clients' login +routes (`/users//authorize`, `/users//login`) stay in core as +generic aliases of the login pipeline. + +The nginx configs keep one small group of blocks for those public 2.8 URLs +(`/vault/oidc/callback`, `/termix-id/u/`, `/automations/webhook/`); the rest +already fall under core's `/users` and `/host` blocks. A new plugin never +needs an nginx change: everything it serves is under `/plugin-api/`, +`/plugin-ws/` or `/plugin-assets/`. + Socket and ssh2 event listeners fire outside the upgrade's async context, so a plugin that calls privileged ctx members from them binds them with Node's `AsyncResource.bind` while the actor is still set; the terminal does this for @@ -371,14 +427,26 @@ allowlist (`CORE_SETTINGS_ALLOWLIST`) rather than the whole settings table. A plugin frontend is a built ESM bundle, `dist/frontend.js`, exporting `activate(app)` and optionally `deactivate()`. The shell has no plugin ids and no imports from `plugins/`; everything a plugin shows goes through the `app` -object. `scripts/check-shell-plugin-ids.cjs` (run by lint) fails on a quoted -plugin id in `src/ui` outside tests, apart from the entries in -`scripts/shell-plugin-id-allowlist.json`, each with a reason (host data like -the `docker` runtime name, which Phase B moves). **B14** removed the `rdp`, -`vnc` and `telnet` entries: the shell learns those protocols from -`app.registerHostProtocol`. **B15** removed both `docker` entries: the Hosts -panel's Docker filter and the bulk Docker toggles went with the column, and -the host editor no longer carries a Proxmox default that spelled `docker`. +object. `scripts/check-core-plugin-ids.cjs` (run by lint) scans all of `src/` +and fails on any plugin id core spells: a bare literal (`"docker"`), a plugin +route (`"/plugin-api/docker/..."`), or an action, slot or permission id that +starts with one (`"docker.open"`). The shell (`src/ui`) also may not spell a +view a plugin owns (a tab, panel or dashboard card id). There is no +allowlist. Three things are exempt, by rule rather than by entry: + +- tests and locales; +- `src/backend/upgrade/`, the one-time 2.8 to 2.9 data moves, which have to + name the plugin each piece of data moves to, the same way + `LEGACY_TABLE_OWNERS` names the plugin that adopts each legacy table; +- `"totp"` inside `src/backend/hosts/connect/`, where it is the SSH + keyboard-interactive one-time-code prompt kind, not the login plugin. + +Where core needs something from whichever plugin provides it, core names the +contract and a plugin fills it: a slot (`dashboard.counters`, +`admin.userTabs`), an action (`host.openFiles`, `snippet.list`, +`snippet.resolveForTerminal`) or a component id (`credentials.secretHint`, +`credentials.secretManager`). With no provider, the slot renders nothing and +the action answers `undefined`. #### The loader @@ -425,8 +493,8 @@ the full pass does not load the same bundle twice. See Auth below. #### Shared modules and the import map A bundle keeps `react`, `react-dom`, `react-dom/client`, `react/jsx-runtime`, -`i18next`, `react-i18next`, `sonner`, `@termix/plugin-sdk/frontend`, -`@termix/plugin-sdk/ui` and `@termix/legacy-core/*` as bare imports. Core's +`i18next`, `react-i18next`, `sonner`, `@termix/plugin-sdk/frontend` and +`@termix/plugin-sdk/ui` as bare imports. Core's Vite build (`scripts/vite-plugin-termix-plugins.mjs`) emits one entry per shared module, writes a stable unhashed shim for each at `dist/shared/.js`, and injects an import map into `index.html` mapping @@ -434,8 +502,7 @@ the bare names to those shims. In dev the map points at Vite's virtual modules instead. The map is inline, so both nginx configs allow it by hash in `script-src`; `scripts/check-importmap-csp.cjs` (run by lint) fails when the hash is out of date and `--write` updates it. The map changes only when the -list of shared modules does, which includes the legacy-core modules plugins -import. +list of shared modules does. Electron loads `index.html` from `file://`. The map uses relative URLs, and plugin bundles come from the embedded backend @@ -482,6 +549,22 @@ boundary and Suspense. | `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces; `tabs.openRailView` (**B12**) opens a rail view | | `guest`, `info`, `onDispose` | Guest mode flag, plugin info, extra cleanup | | `desktop` | `available`, `remoteServerUrl()` and `onRemoteServerChange`: whether the frontend runs in the desktop app and which remote server it syncs with (**C6**) | +| `registerComponent` | A component other plugins and core render by id with `PluginComponent` | +| `onSettingsChanged` | Called when one of the plugin's settings changes | + +A few contribution fields core reads generically: + +| Field | Effect | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `RailItemContribution.simplePreset` | The item stays visible in the Simple interface preset, which hides every other plugin rail item | +| `HostActionContribution.quickConnect` | The action is offered as a button in Quick Connect, opening its `tabType` for an address that is never saved | +| `PluginSshAuthProvider.quickConnect` | (backend) The auth type works for an unsaved host, so Quick Connect lists it and renders the plugin's `SshAuthEditor` for its fields | +| `SshAuthTypeInfo.quickConnect` | What `useSshAuthTypes()` reports for the above | + +Core calls outside a component (`@termix/plugin-sdk/frontend`): `logActivity`, +`getHostPassword`, `patchOpenTab`, `getCustomKeybindings`, `setHostAutoTmux`, +`getClientPreference`, `listHosts` (every host the user can see, fetched now) +and `listCredentials` (the user's credentials without secrets, for a picker). Hooks: `useTranslation` (the plugin's namespace), `usePermission` (a short name resolves to `.`), `useSettings`, `useHost`, `useHosts`, @@ -494,7 +577,21 @@ to list manager cards plugins added to `host-metrics.managers` next to its own). The SDK has no runtime dependencies: the hooks delegate to a host bridge core installs. -Slots core owns: `terminal.toolbar`, `terminal.toolbarStatus`, +Slots and actions core names for plugins to fill: `dashboard.counters` +(component slot in the dashboard's counters card; tunnels shows its active +tunnel count there), `admin.userTabs` (component slot, one tab per +contribution in the admin "manage user" panel, rendered with `{ user }` and +replaced by core's locked notice while the user's data is locked; snippets +adds its tab), the `host.openFiles` action (the tab bar's file manager +button), the `snippet.list` and `snippet.resolveForTerminal` actions (core's +snippet pickers and the command palette's run flow) and the +`credentials.secretHint` / `credentials.secretManager` components (the +credential and host editors' secret reference UI). **D1** added these in +place of `tunnels.statuses`, `tunnels.open`, `files.openHost`, +`snippets.resolveForTerminal` and the `secret-sources.*` component ids, which +core used to call by the plugin's name. + +Other slots: `terminal.toolbar`, `terminal.toolbarStatus`, `terminal.sidePanel`, `terminal.overlay` (declared by ssh-terminal; **B9** renamed `terminal.dock` to `terminal.sidePanel` and added the status slot, where host-metrics puts its CPU, memory and disk bars; **C3** gave overlay @@ -526,10 +623,7 @@ invoked with `{ hostId, sessionId, protocol, tabInstanceId }`. **B3**) is host-metrics's own: tailscale contributes its manager card there instead of host-metrics knowing tailscale exists, and host-metrics works with or without tailscale enabled. Cross-plugin frontend calls go through actions: -`automations.list`, `fleets.list`, `session.remoteDisplay.token`. Core calls -plugin actions the same way when a core view shows a plugin's data: -`files.openHost`, and **B7**'s `tunnels.statuses` and `tunnels.open` behind the -dashboard's active tunnel counter, which reads as zero while tunnels is off. +`automations.list`, `fleets.list`, `session.remoteDisplay.token`. **B16** added `host-metrics.disk` (the file manager's disk usage bar) and `snippets.get` / `snippets.execute` (host-metrics's quick actions); each answers undefined while its plugin is off. @@ -628,6 +722,16 @@ calls a plugin makes for the signed-in user onto the typed host bridge, as `@termix/plugin-sdk/frontend` functions: `logActivity`, `getHostPassword`, `patchOpenTab`, `getCustomKeybindings`, `setHostAutoTmux` and `getClientPreference`. +**D1** added what the last plugins reaching into core used: `Kbd`, +`KbdKey` and `KbdSeparator`; `ConnectionLogPanel` and the `LogEntry` +type; `createFrontendLogger(name)`, a console logger in core's format; the +adaptive resource budget (`getAdaptiveResourceBudget`, +`markAdaptiveResourceUsed`, `runAdaptiveBackgroundTask`) so a plugin's +background work shares one budget with the shell; `getDeviceId`; +`createQuickConnectHost` and `isQuickConnectHost`; and the desktop app's +local file bridge types (`LocalFileEntry`, `LocalDirectoryListing`, +`LocalWalkResult`, `LocalTransferProgress` and the rest), for a plugin that +drives `window.electronAPI.localFs` itself. **B16** added `LineChart`, `useAdaptivePolling`, `useAreaPreferences` (the Appearance density and chart options), and the homepage widget pieces `WidgetTitle` and `runVisibleInterval` (moved to `src/ui/lib/` in **B19**, @@ -685,8 +789,13 @@ where Crowdin writes them. Only `en.json` is edited by hand. #### Type checking -`tsconfig.plugins.frontend.json` checks every plugin frontend and its tests -with bundler resolution, and is part of `npm run type-check`. +`npm run type-check` covers every plugin through two root configs: +`tsconfig.plugins.frontend.json` (plugin frontends and their tests, bundler +resolution; it extends the app config because `@termix/plugin-sdk/ui` is +core's own `sdk-ui.ts`) and `tsconfig.plugins.backend.json` (plugin backends, +`nodenext`, the SDK's built `.d.ts`). Neither gives a plugin a path into +core; the `@/` alias the frontend config inherits exists for `sdk-ui.ts` +itself, and lint refuses it in plugin code. ### 9. Cross-plugin use @@ -1296,8 +1405,8 @@ Every SSH connection, core's and plugins', goes through decryption, shared-host overrides and shared agent, and `op://` external secret references. 2. `buildConnectConfig` builds the ssh2 config: the defaults for the - connection's purpose (terminal, file-manager, tmux, metrics, fleet, docker, - ...), the host key verifier, then the auth provider's `prepare`. + connection's profile (terminal, session, stream, forward, background or + jump), the host key verifier, then the auth provider's `prepare`. 3. `openSshTransport` does port knocking, the Cloudflare Access tunnel, the jump host chain (every hop through steps 1 and 2) or SOCKS5. 4. `connectHost` connects with a keyboard-interactive handler: a prompt channel @@ -1867,48 +1976,51 @@ deciding, not the mechanism. ## The ctx surface Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`. +Every member is listed; the types in `@termix/plugin-sdk/backend` are the +authority for signatures. -| Member | Capability | Status | -| ------------------------------------------------ | -------------------------------------------------- | ----------------------------------- | -| `ctx.pluginId`, `ctx.manifest` | none | **A1** | -| `ctx.log.*` | none | **A1** | -| `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** | -| `ctx.kv.get/set/delete/list` | `kv:own` | **A1** | -| `ctx.files.dataDir` | `files:own` | **B13** | -| `ctx.registry.*` | none | **A1** | -| `ctx.services.provide` / `.get` / `.providers` | per-service RBAC | **A1**, named **B12** | -| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** | -| `ctx.secrets.get` / `.set` / `.delete` | `secrets:own` | **B18** | -| `ctx.secrets.seal` / `.unseal` | `secrets:own` | **C1** | -| `ctx.disposables.add` | none | **A1** | -| `ctx.asUser(userId, fn)` | none, always audited | **A1** | -| `ctx.currentActor()` | none | **A1** | -| `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** | -| `ctx.db.persist` / `.dialect` | `db:own` (persist only) | **A9**, lazy **B16** | -| `ctx.sync.registerEntity` | none | **A3** | -| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** | -| `ctx.http.baseUrl(req)` | none | **C2** | -| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** | -| `ctx.capabilities.has` / `.require` | the capability itself | **B11** | -| `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4**, **B5**, **B18** | -| `ctx.hosts.status.*` | `hosts:read` | **B16** | -| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** | -| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** | -| `ctx.notify.channels` / `.send` | `notify:send` | **B17** | -| `ctx.auth.*` | `auth:provide` | **A8** | -| `ctx.auth.completeRedirectLogin` and the rest | `auth:provide` | **C2** | -| `ctx.desktop.openIsolatedWindow` | `desktop:window` | **B8** | -| `ctx.desktop.launchNativeRdp` / `.available` | `desktop:window` (launch only) | **B14** | -| `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** | -| `ctx.credentials.registerSecretResolver` | `auth:provide` | **B20** | -| `ctx.credentials.listSshKeys` | `credentials:use` | **C6** | -| `ctx.credentials.createSshKey` | `credentials:write` | **C6** | -| `ctx.audit.record` | none, the actor is the runtime's | **B9**, request **D0** | -| `ctx.schedule.every` / `.after` | none | **B16** | -| `ctx.fetch` | `network:outbound` | **B17**, signal **B18**, tls **C4** | -| `ctx.process.run` / `.ensureBinary` | `process:spawn` (+ `network:outbound` to download) | **C3** | -| `ctx.auth.registerKeyboardInteractiveHandler` | `auth:provide` | **C3** | -| `ctx.system.*` | `system:tls` | **C7** | +| Member | Capability | What it does | +| -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `pluginId`, `manifest` | none | The plugin's id and parsed manifest | +| `log.debug/info/warn/error` | none | Lines in core's log, tagged with the plugin | +| `events.emit/on` | `events:core` for core topics | Topics under `plugin..*`, or core's (`user.deleted`, `user.data_wiped`, host events) | +| `kv.get/set/delete/list` | `kv:own` | Small key/value state | +| `files.dataDir()` | `files:own` | A per-plugin folder under `DATA_DIR` | +| `db.define/client/refs/persist/dialect` | `db:own` (not `dialect`) | The plugin's own tables through Drizzle; `persist` flushes SQLite to disk | +| `sync.registerEntity/recordTombstone` | none | Adds a table to remote sync, and records a delete for it | +| `registry.provide/consume/revoke` | none | The low-level named registry services sit on | +| `services.provide/get/providers` | per-service RBAC | Typed, versioned cross-plugin services, checked per call | +| `secrets.get/set/delete/seal/unseal` | `secrets:own` | The plugin's own encrypted values | +| `secrets.offer/withdraw/getShared` | per-secret RBAC | Secrets one plugin lends another | +| `http.router(options)`, `http.baseUrl(req)` | `network:serve` (router) | The router mounted at `/plugin-api//`, and the public origin for callback URLs | +| `ws.route/upgrade` | `network:serve` | Sockets at `/plugin-ws//` | +| `rbac.has/hasFor/require` | own permissions (require) | The acting user's role permissions; `require` is route middleware | +| `settings.get/set/getUser/setUser/getHost/setHost/listHostValues/getAll/onChange/onValidate` | `hosts:read` (listHostValues) | The plugin's admin, user and host settings from its manifest | +| `settings.readCore(key)` | `settings:read-core` | One of the core keys in `CORE_SETTINGS_ALLOWLIST` | +| `disposables.add` | none | Extra cleanup on deactivate | +| `capabilities.has/require` | the named capability | A generic check for privileged code no ctx member wraps | +| `hosts.list/get/checkAccess` | `hosts:read` | Hosts the acting user can see, without secrets | +| `hosts.create/update/delete/listOwned/share/listUsers/listRoles` | `hosts:write` | Host changes and sharing on the user's behalf | +| `hosts.trackSession/recordActivity` | `hosts:read` | The online indicator and a recent activity entry | +| `hosts.status.get/check/reportLogin/registerPort` | `hosts:read` | Core's reachability status | +| `ssh.connect/withConnection/jumpChain` | `ssh:connect`, `credentials:use` | Connections through core's one connect pipeline | +| `ssh.resolveHost/prepare/openTransport/classifyKeyboardInteractive/autoResponses/startInteraction/cancelInteraction` | `ssh:connect`, `credentials:use` | The lower level, for a transport with its own prompt flow | +| `ssh.poolKey/dropPooled/requiresSecret/supportsBackground` | `ssh:connect` | Pool housekeeping and questions for an auth type's provider | +| `auth.registerLoginMethod/registerSecondFactor/recordEnrollment/removeEnrollment` | `auth:provide` | Login methods and second factors | +| `auth.completeRedirectLogin/revokeSessions/loginRateLimit/countLinkedUsers` | `auth:provide` | What a login method needs from core's session and provisioning code | +| `auth.registerSshAuthProvider/registerKeyboardInteractiveHandler` | `auth:provide` | SSH auth types and keyboard-interactive handlers | +| `credentials.listSshKeys` / `.createSshKey` | `credentials:use` / `credentials:write` | The user's saved SSH keys | +| `credentials.resolveHostProtocol` | `credentials:read` | A host's RDP, VNC or Telnet login | +| `credentials.registerSecretResolver` | `auth:provide` | Resolves external secret references in host and credential fields | +| `desktop.openIsolatedWindow/launchNativeRdp/available` | `desktop:window` (not `available`) | Electron windows and the native RDP client | +| `audit.record` | none | An audit line under the plugin's own action name | +| `schedule.every/after` | none | Timers that stop on deactivate and never overlap | +| `notify.channels/send` | `notify:send` | Core's notification channels, as the acting user | +| `fetch(url, init)` | `network:outbound` | Outbound HTTP through core's SSRF guard; `allowPrivateHosts` hosts also go through the configured proxy | +| `process.run/ensureBinary` | `process:spawn` (+ `network:outbound` to download) | Programs on the server | +| `system.tlsStatus/writeTlsCertificate/reloadTls/publishHttpChallenge/registerTlsRenewer` | `system:tls` | The server certificate | +| `asUser(userId, fn)` | none, always audited | Background work as a user | +| `currentActor()` | none | The acting user for the current call | **B11** added `ctx.capabilities.has(capability)` / `.require(capability)`, a generic check for a capability no other ctx member wraps. Unlike every other @@ -1948,14 +2060,14 @@ connect pipeline instead of importing ssh2 helpers from core: has no auth code of its own left. - `requiresSecret(authType)` and `supportsBackground(authType)` ask the provider. -- **B6** added `"file-manager"` and `"file-transfer"` to `PluginSshPurpose` - (core's `SshConnectPurpose` already had them; the SDK type had lagged), - since the file-manager plugin's interactive connect route and its dedicated - transfer sessions both need their own keepalive/timeout defaults rather than - falling back to the generic `"plugin"` purpose. -- **B10** added `"tmux"` to `PluginSshPurpose` the same way (core's - `SshConnectPurpose` already had it), for the tmux-monitor plugin's pooled - connection and the `tmux.sessions` service it provides to the terminal. +- `purpose` is a free label (`"terminal"`, `"docker"`) that auth providers + see in `connectOptions`. **D1** moved the keepalive, timeout and + environment defaults onto `profile`: `"terminal"` (an interactive shell), + `"session"` (a long-lived browsing session such as the file manager or + Docker), `"stream"` (a long transfer or console), `"forward"` (port + forwarding) or `"background"`, the default, for short exec work. Core's own + jump chain uses a `"jump"` profile plugins cannot pick. Core no longer keeps + a list of purposes, so it names no plugin. - **B9** added what an interactive transport needs, for the terminal: `resolveHost(hostId, { syncId? })` (the host with secrets, by sync id first, audited), the `"terminal"` purpose, `interactive` and `hostKeySocket` on @@ -2349,7 +2461,8 @@ keeps working unchanged. what Docker and electron-builder package. `npm run build`, `build:backend` and `dev:backend` all call it. -TypeScript only type-checks (`tsconfig.plugins.json`, `noEmit`); esbuild does +TypeScript only type-checks (`tsconfig.plugins.backend.json` and +`tsconfig.plugins.frontend.json`, `noEmit`); esbuild does every emit. Nothing is written next to a source file. ### Host-provided packages @@ -2365,9 +2478,8 @@ and that is bundled into its output. the terminal's image upload loads it on first use). **Frontend**: `react`, `react-dom`, `react-dom/client`, `react/jsx-runtime`, -`i18next`, `react-i18next`, `sonner`, `@termix/plugin-sdk/frontend`, -`@termix/plugin-sdk/ui` and `@termix/legacy-core/*`, all resolved through the -import map (see UI). Everything else, including `lucide-react`, `axios`, +`i18next`, `react-i18next`, `sonner`, `@termix/plugin-sdk/frontend` and +`@termix/plugin-sdk/ui`, all resolved through the import map (see UI). Everything else, including `lucide-react`, `axios`, `cytoscape` and `guacamole-common-js`, is bundled into the plugin that uses it. @@ -2673,10 +2785,10 @@ install (workspaces leaves out its `last_session` row). 2. Register everything in `activate(app)`: rail item (with `permission` when there is one), panel, tabs, host editor sections, actions and slot contributions. Use `app.onDispose` for timers. -3. Remove the shell's references: imports from the feature, hardcoded ids and - switch cases, and the matching entries in - `scripts/shell-plugin-id-allowlist.json`. `node scripts/check-shell-plugin-ids.cjs` - must pass. +3. Remove core's references: imports from the feature, hardcoded ids and + switch cases. Where core still needs the feature, name a slot, action or + component id in core's own vocabulary and let the plugin fill it. + `node scripts/check-core-plugin-ids.cjs` must pass. 4. The rail item appears in Appearance > Sidebar > Navigation on its own because it is registered; confirm it there. 5. No hardcoded text (a relative time is a translated string), no em dashes, @@ -2715,14 +2827,7 @@ install (workspaces leaves out its `last_session` row). 4. `cd plugins/ && npx vitest run` while working; `npm run test:plugins` at the end. -### 14. Boundary allowlist - -`node scripts/check-plugin-boundaries.cjs --write`, then -`git diff scripts/plugin-boundary-allowlist.json`: the plugin's entries are -gone and **no other line was added**. Update the counts in the table under -[Legacy core imports](#legacy-core-imports-the-debt-d1-removes). - -### 15. Build, test and check by hand +### 14. Build, test and check by hand ```bash npm run build:sdk @@ -2745,91 +2850,3 @@ Then, with the app running: views show the "needs the plugin" placeholder while off and come back after. 5. On Postgres or MySQL if available: `npm run verify:dialect`, then boot. 6. Update this document for anything the step added to the contract. - ---- - -## Legacy core imports: the debt D1 removes - -The bundled plugins predate the SDK, apart from workspaces (A9), snippets -(B2), remote-desktop (B14), docker (B15), host-metrics (B16), automations -(B17), ai (B18), homepage (B19), totp and webauthn (C1), sso and ldap (C2), -opkssh and warpgate (C3), step-ca (C4), vault (C5), termix-identity (C6), -acme-ssl (C7), -which import nothing from core. The others still reach core by relative -path (`../../../../src/backend/...`), which an esbuild plugin, -`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle -and rewrites to the compiled output path (`../../../backend/backend/...`, or -`../../../backend/types/...` for shared types). The rewrite is output-relative, -so a source file at any nesting collapses to the same prefix. D1 deletes that -file. - -Frontends do the same with `@/`: in the browser build, `@/x` and -`../src/ui/x` become the bare `@termix/legacy-core/ui/x` (or -`@termix/legacy-core/types/x`), which core's Vite build exposes as a shared -module so plugin and shell use one instance of each core module. The CLI -refuses the rewrite outside a Termix checkout. Every such import is counted -in the `plugin-frontend-to-core` direction below. - -This is expected and temporary. The SDK does not yet expose what they need: -the SSH connection pool, host resolution, the repository layer, the shared -component library. D1 removes the debt once A3 through A8 have given them -supported APIs to move onto. - -What the lint fence enforces today, in `eslint.config.mjs`: - -| Direction | Severity | Offenders | Emptied by | -| ------------------------------------------------ | --------- | --------- | ---------- | -| Core importing a plugin backend | **Error** | 0 | - | -| A plugin backend importing frontend code or `@/` | **Error** | 0 | - | -| The shell importing plugin code | **Error** | 0 | - | -| A plugin frontend importing core through `@/` | Warning | 51 files | D1 | -| A plugin importing core by relative path | Warning | 3 files | D1 | -| A plugin importing another plugin's source | Warning | 0 files | - | - -A warning does not fail a build, so the counts are held by -`scripts/check-plugin-boundaries.cjs`, run by `npm run lint`. Every offender is -listed in `scripts/plugin-boundary-allowlist.json`: a new one fails, and so -does an entry that stopped being an offender, so the list shrinks as each step -lands rather than rotting. `--write` regenerates it. D1 empties it and the two -plugin-side warnings become errors. - -The two plugin-backend directions use different rule names -(`no-restricted-imports` and `@typescript-eslint/no-restricted-imports`) -because flat config replaces a rule's options rather than merging them, so one -rule cannot carry both severities on the same files. - -Known specifics: - -- **B17** removed the last plugin-to-plugin import: ai's proposal executor - and automation read tools call automations through the `automations.access` - service, and automations is an optional dependency of ai rather than a hard - one. -- **B16** moved host-metrics onto the SDK: it imports nothing from core or - automations. Automations reaches it through the `host-metrics.viewers` - service and its `plugin.host-metrics.*` events, and core reaches it through - the `dashboard.hostMetrics` and `homepage.hostMetrics` slots and the - `host-metrics.disk` action. Core's `hosts/metrics-shared/` is gone: ai, - automations, proxmox and tailscale use `@termix/plugin-sdk/host-commands`. -- Core feature servers that are not plugins yet still own ports: dashboard - 30006 and homepage 30012. Each keeps its nginx block until its own Phase B - step. No plugin owns a port any more (A4). **B6** moved file-manager off - port 30004 onto `/plugin-api/file-manager/`, **B7** moved tunnels off port - 30003 onto `/plugin-api/tunnels/` and `/plugin-ws/tunnels/c2s/stream`, - **B10** moved tmux monitoring off port 30010, and **B11** moved serial off - port 30011 onto `/plugin-ws/serial/console`. -- **B9** moved the terminal, its session manager, the local terminal and the - command history panel into ssh-terminal, which imports nothing from core. - The file manager's terminal window renders the `terminal.view` component - instead of importing the core terminal. -- Host data columns (`enableFileManager` and so on) stay in core types until - Phase B moves them. Only UI branches on them left the shell. **B15** moved - docker's `enableDocker` and `docker_config` (now `containerRuntime`) into - its host settings. **B14** moved - remote desktop's (`enableRdp`, `enableVnc`, `enableTelnet`, the three ports, - `rdpSecurity`, `rdpIgnoreCert`, `guacamoleConfig`) into its host settings; - the protocol logins stay core host fields. -- **B14** moved remote desktop onto the SDK: it imports nothing from core, - owns no module state, and its guacamole-lite server is built in `activate` - with its own signal handlers removed (guacamole-lite installs SIGTERM and - SIGINT handlers in its constructor). guacd is always an external service, - so it does not use `ctx.process`; its address is an admin setting. diff --git a/packages/plugin-sdk/FINISH-LIST.md b/packages/plugin-sdk/FINISH-LIST.md index dcac0a734..6dc1f908f 100644 --- a/packages/plugin-sdk/FINISH-LIST.md +++ b/packages/plugin-sdk/FINISH-LIST.md @@ -154,3 +154,32 @@ with a real install, a real device or a repo outside this one. copied in without a restart. - A password user who signs in for the first time after the upgrade keeps their TOTP (the copy runs once their keys unlock). +- D1 changes to check by hand: Quick Connect lists Tailscale next to the + core auth types and connects with it, and its "Connect to Files" button + comes from the file manager; the admin "manage user" panel's Snippets tab + (add, edit, delete a snippet for another user); the dashboard's active + tunnel counter and its click; the tab bar's file manager button; the + secret reference hint and manager in the host and credential editors; + Proxmox guest discovery and import (now its own `/plugin-api/proxmox/import` + route); the Tailscale device list behind an outbound proxy and against a + LAN Headscale; dragging a local file into a host in the desktop app (its + transfer URL moved off the dead port 30004); a per-user database export and + import that carries file manager bookmarks; the Simple preset hiding plugin + rail items except Snippets. + +## Left for later + +- 3.0.0: drop the migrated 2.8 settings rows (`guac_url`, `step_ca_url`, + `global_metrics_interval` and the rest). The upgrade moves leave them in + place so a downgrade to 2.8 still works; nothing reads them in 2.9. +- Plugin repo split: knip still reports about 150 unused exports inside + plugins (constants exported for readability, `tables.ts` exports the CLI + reads, test-only helpers). Prune them per plugin when it moves to its own + repo; core and the SDK are clean. +- Plugin OpenAPI docs: plugins carry `@openapi` comments but + `src/backend/utils/swagger.ts` only scans core routes, so + `npm run generate:openapi` leaves every `/plugin-api/` route out. +- Three backend tests sit next to their source, against the test layout + rule: `src/backend/hosts/host-session-status.test.ts`, + `jump-host-proxy.test.ts` and `ssh-keepalive.test.ts`. Move them under + `src/backend/tests/hosts/`. diff --git a/packages/plugin-sdk/cli/commands/build.mjs b/packages/plugin-sdk/cli/commands/build.mjs index 8d4a992a4..33f79270f 100644 --- a/packages/plugin-sdk/cli/commands/build.mjs +++ b/packages/plugin-sdk/cli/commands/build.mjs @@ -2,7 +2,6 @@ import fs from "node:fs"; import path from "node:path"; import * as esbuild from "esbuild"; import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs"; -import { legacyCoreImports } from "../lib/legacy-core-imports.mjs"; import { staticUrlImports } from "../lib/static-url-imports.mjs"; import { readManifest, resolveEntry, copyDir } from "../lib/plugin-dir.mjs"; @@ -18,17 +17,6 @@ const FRONTEND_ENTRIES = [ "src/frontend/index.js", ]; -/** Whether the plugin sits in a Termix checkout, i.e. is a bundled plugin. */ -function insideTermixCheckout(cwd) { - let dir = path.resolve(cwd); - for (;;) { - if (fs.existsSync(path.join(dir, "src", "ui", "plugin-host"))) return true; - const parent = path.dirname(dir); - if (parent === dir) return false; - dir = parent; - } -} - export async function build({ cwd }) { const manifest = readManifest(cwd); const pluginId = manifest.id ?? path.basename(cwd); @@ -59,7 +47,6 @@ export async function build({ cwd }) { // node_modules import instead, resolved at runtime like a host-provided // package. external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])], - plugins: [legacyCoreImports({ pluginId, platform: "node" })], }); const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES); @@ -75,14 +62,7 @@ export async function build({ cwd }) { sourcemap: true, logLevel: "warning", external: FRONTEND_EXTERNALS, - plugins: [ - staticUrlImports({ outDir }), - legacyCoreImports({ - pluginId, - platform: "browser", - insideTermix: insideTermixCheckout(cwd), - }), - ], + plugins: [staticUrlImports({ outDir })], }); } diff --git a/packages/plugin-sdk/cli/lib/externals.mjs b/packages/plugin-sdk/cli/lib/externals.mjs index 5c2f27bbc..cb2fd3127 100644 --- a/packages/plugin-sdk/cli/lib/externals.mjs +++ b/packages/plugin-sdk/cli/lib/externals.mjs @@ -42,6 +42,4 @@ export const FRONTEND_EXTERNALS = [ "sonner", "@termix/plugin-sdk/frontend", "@termix/plugin-sdk/ui", - // Legacy: the shell's own modules, see legacy-core-imports.mjs. D1 removes. - "@termix/legacy-core/*", ]; diff --git a/packages/plugin-sdk/cli/lib/legacy-core-imports.mjs b/packages/plugin-sdk/cli/lib/legacy-core-imports.mjs deleted file mode 100644 index e3b6051b3..000000000 --- a/packages/plugin-sdk/cli/lib/legacy-core-imports.mjs +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Legacy core imports: the debt D1 removes. - * - * Plugin code predates the SDK and still reaches core, and in one case a - * sibling plugin, by relative path. Those specifiers must stay out of the - * bundle and be rewritten to where the built server puts the target. - * - * The rewrite is output-relative, not source-relative. Every plugin bundles - * to plugins//dist/backend.js, which sits at the same depth the old - * per-file output did, so one prefix covers a source file at any nesting. - * - * Delete this file in D1, once every plugin reaches core through the SDK. - */ - -import { legacyCoreSpecifier } from "./legacy-core-specifier.mjs"; - -// "../../../src/backend/" or "../../../../src/backend/", any depth. -const CORE_BACKEND = /^(?:\.\.\/)+src\/backend\//; -// Shared types. Mostly erased at compile, but automations imports a real -// value (AUTOMATION_DEFINITION_VERSION), so this has to resolve at runtime. -const CORE_TYPES = /^(?:\.\.\/)+src\/types\//; -const CORE_UI = /^(?:\.\.\/)+src\/ui\//; -/** dist/plugins//dist/backend.js -> dist/backend/... */ -const COMPILED_CORE = "../../../backend/backend/"; -const COMPILED_TYPES = "../../../backend/types/"; - -const CORE_ALIAS = /^@\//; - -export function legacyCoreImports({ pluginId, platform, insideTermix = true }) { - return { - name: "legacy-core-imports", - setup(build) { - // The shell's own modules, reached as "@/..." or by relative path. - // Bundling them would give the plugin a second copy of the shell's - // contexts and API client, so each becomes a bare specifier the page's - // import map resolves to the shell's own module. - if (platform === "browser") { - const toLegacy = (args) => { - const specifier = legacyCoreSpecifier(args.path); - if (!specifier) return null; - if (!insideTermix) { - return { - errors: [ - { - text: `${pluginId}: "${args.path}" reaches Termix core. Only plugins built inside a Termix checkout may; use @termix/plugin-sdk instead.`, - }, - ], - }; - } - return { path: specifier, external: true }; - }; - build.onResolve({ filter: CORE_ALIAS }, toLegacy); - build.onResolve({ filter: CORE_UI }, toLegacy); - build.onResolve({ filter: CORE_TYPES }, toLegacy); - } - - build.onResolve({ filter: CORE_BACKEND }, (args) => ({ - path: args.path.replace(CORE_BACKEND, COMPILED_CORE), - external: true, - })); - - build.onResolve({ filter: CORE_TYPES }, (args) => ({ - path: args.path.replace(CORE_TYPES, COMPILED_TYPES), - external: true, - })); - }, - }; -} diff --git a/packages/plugin-sdk/cli/lib/legacy-core-specifier.mjs b/packages/plugin-sdk/cli/lib/legacy-core-specifier.mjs deleted file mode 100644 index 051274c29..000000000 --- a/packages/plugin-sdk/cli/lib/legacy-core-specifier.mjs +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Legacy core modules a bundled plugin frontend still imports. - * - * Bundled plugin frontends predate the SDK and import the shell's own modules - * as "@/..." (or, in a couple of places, by relative path into src/). Bundling - * those would give the plugin a second copy of the shell's contexts and API - * client, so the CLI rewrites each one to a bare specifier under - * "@termix/legacy-core/" and core's build exposes exactly that module through - * the import map. Both sides use this one normalizer so they cannot disagree. - * - * Only plugins built inside a Termix checkout may do this. D1 deletes it. - */ - -export const LEGACY_CORE_PREFIX = "@termix/legacy-core/"; - -const EXTENSION = /\.(tsx|ts|jsx|js|mjs)$/; - -function clean(rest) { - return rest.replace(EXTENSION, "").replace(/\/+$/, ""); -} - -/** - * "@/components/button.tsx" -> "@termix/legacy-core/ui/components/button" - * "@/types" -> "@termix/legacy-core/types" - * "@/types/connection-log" -> "@termix/legacy-core/types/connection-log" - * "../../../src/ui/x.tsx" -> "@termix/legacy-core/ui/x" - * Anything else -> null - */ -export function legacyCoreSpecifier(specifier) { - if (specifier.startsWith("@/")) { - const rest = clean(specifier.slice(2)); - if (rest === "types" || rest.startsWith("types/")) { - return LEGACY_CORE_PREFIX + rest; - } - return `${LEGACY_CORE_PREFIX}ui/${rest}`; - } - const relative = /^(?:\.\.\/)+src\/(ui|types)(\/.*)?$/.exec(specifier); - if (relative) { - return LEGACY_CORE_PREFIX + clean(`${relative[1]}${relative[2] ?? ""}`); - } - return null; -} - -/** - * Every value import in a source file that names a legacy core module. - * Type-only imports are skipped: they are erased and never reach the map. - */ -export function findLegacyCoreImports(source) { - const found = new Set(); - const patterns = [ - /^\s*(?:import|export)\s+(?!type\s)[^'";]*?\sfrom\s+["']([^"']+)["']/gm, - /^\s*import\s+["']([^"']+)["']/gm, - /import\(\s*["']([^"']+)["']\s*\)/g, - ]; - for (const pattern of patterns) { - for (const match of source.matchAll(pattern)) { - const normalized = legacyCoreSpecifier(match[1]); - if (normalized) found.add(normalized); - } - } - return found; -} diff --git a/packages/plugin-sdk/schema/manifest.schema.json b/packages/plugin-sdk/schema/manifest.schema.json index 99d6822c7..fe41d523c 100644 --- a/packages/plugin-sdk/schema/manifest.schema.json +++ b/packages/plugin-sdk/schema/manifest.schema.json @@ -825,6 +825,26 @@ "type": "string", "pattern": "^/[a-z][a-z0-9-]*(/[A-Za-z0-9_.-]+)+$" } + }, + "legacyRedirects": { + "type": "array", + "description": "Old URLs outside the plugin's namespace, redirected to a path in its router with the rest of the path and the query kept.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["from", "to"], + "properties": { + "from": { + "type": "string", + "pattern": "^(/[A-Za-z0-9_.-]+)+$" + }, + "to": { + "type": "string", + "pattern": "^(/[A-Za-z0-9_.-]+)+$" + }, + "status": { "enum": [307, 308] } + } + } } } }, diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index 0366dd2bd..d7fdf41e3 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -737,19 +737,17 @@ export interface PluginSshHost { } /** Picks keepalive and timeout defaults. "plugin" is the generic one. */ -export type PluginSshPurpose = - | "plugin" - | "docker" - | "docker-console" - | "metrics" - | "proxmox" - | "fleet" - | "remote-desktop" - | "tunnel" - | "file-manager" - | "file-transfer" - | "terminal" - | "tmux"; +/** What a connection is for, as a free label. Auth providers see it. */ +export type PluginSshPurpose = string; + +/** + * Keepalive, timeout and environment defaults for a kind of connection. + * terminal: an interactive shell. session: a long-lived browsing session. + * stream: a long transfer or console. forward: port forwarding. background + * (the default): short exec work such as polling. + */ +export type PluginSshProfile = + "terminal" | "session" | "stream" | "forward" | "background"; export type PluginSshAuthOutcome = | { status: "ready" } @@ -868,6 +866,8 @@ export interface PluginSshPromptChannel { export interface PluginSshConnectOptions { purpose?: PluginSshPurpose; + /** Keepalive and timeout defaults. Defaults to "background". */ + profile?: PluginSshProfile; /** Overall time to reach "ready". Defaults to 30s. */ timeoutMs?: number; /** Without one, the stored password answers password prompts. */ @@ -956,6 +956,8 @@ export interface PluginSsh { host: PluginSshHost, options: { purpose?: PluginSshPurpose; + /** Keepalive and timeout defaults. Defaults to "background". */ + profile?: PluginSshProfile; client: unknown; /** Server-side host id when it differs from host.id. */ serverHostId?: number; @@ -1079,6 +1081,11 @@ export interface PluginSshAuthProvider { requiresSecret?: boolean; /** Can connect unattended. Defaults to true. */ supportsBackground?: boolean; + /** + * Works for a host that was never saved (nothing in host settings, no host + * id to key a cache on), so Quick Connect offers it. Default false. + */ + quickConnect?: boolean; /** Interaction name its outcomes use, for startInteraction routing. */ interaction?: string; connectOptions?: ( diff --git a/packages/plugin-sdk/src/frontend.ts b/packages/plugin-sdk/src/frontend.ts index 826b3b147..f96553956 100644 --- a/packages/plugin-sdk/src/frontend.ts +++ b/packages/plugin-sdk/src/frontend.ts @@ -128,6 +128,11 @@ export interface RailItemContribution { kind?: "panel" | "tab"; /** Users may hide it from Appearance > Sidebar > Navigation. Default true. */ hideable?: boolean; + /** + * Stays visible in the Simple interface preset, which hides every other + * plugin rail item. For the one or two things a new user reaches for. + */ + simplePreset?: boolean; /** Can also open as a full-width tab. */ promotable?: boolean; /** Can open in the right dock. */ @@ -302,6 +307,11 @@ export interface HostActionContribution { copyUrlView?: string; /** Where a host overview (the dashboard's host status list) sends a click. */ overview?: boolean; + /** + * Offered as a button in Quick Connect, next to the default connect button, + * for an address that is never saved. Opens tabType. + */ + quickConnect?: boolean; /** * Position in the host row. Core: Files 20, Tunnel 40, Tmux 70. Connect * actions default to 100 and sit after a separator. @@ -709,6 +719,8 @@ export interface SshAuthTypeInfo { credentialType: boolean; /** Can connect unattended, for polling. */ supportsBackground: boolean; + /** Offered in Quick Connect, for a host that is never saved. */ + quickConnect: boolean; } /** @@ -825,6 +837,18 @@ export interface PluginCoreApi { setHostAutoTmux: (hostId: number, autoTmux: boolean) => Promise; /** A browser-side UI preference (a cookie, or the desktop app's store). */ getClientPreference: (name: string) => string | undefined; + /** Every host the user can see, fetched now rather than from the shell's cache. */ + listHosts: () => Promise; + /** The user's stored credentials, without their secrets. */ + listCredentials: () => Promise; +} + +/** A stored credential as a picker needs it. Secrets never leave core. */ +export interface PluginCredentialSummary { + id: number; + name: string; + username?: string; + authType?: string; } /** What a recent-activity entry opens and how it is labelled. */ @@ -972,6 +996,14 @@ export function getClientPreference(name: string): string | undefined { return requireHost().core.getClientPreference(name); } +export function listHosts(): Promise { + return requireHost().core.listHosts(); +} + +export function listCredentials(): Promise { + return requireHost().core.listCredentials(); +} + export function usePluginApi(): PluginApiClient { const bridge = requireHost(); return bridge.getApi(bridge.usePluginId()); diff --git a/packages/plugin-sdk/src/manifest.ts b/packages/plugin-sdk/src/manifest.ts index 2ee2a90b3..2503de298 100644 --- a/packages/plugin-sdk/src/manifest.ts +++ b/packages/plugin-sdk/src/manifest.ts @@ -62,7 +62,6 @@ export type SystemRoleName = (typeof SYSTEM_ROLE_NAMES)[number]; */ export const RESERVED_PERMISSION_PREFIXES = [ "hosts", - "snippets", "credentials", "admin", ] as const; @@ -324,6 +323,23 @@ export interface PluginHttpContribution { * a clash. */ legacyPaths?: string[]; + /** + * Old URLs outside the plugin's namespace that something outside Termix + * still sends people to (an identity provider's callback, a published + * resolver URL). A request at `from`, or under it, is redirected to `to` + * under /plugin-api// with the rest of the path and the query kept. + * Core routes win a clash. + */ + legacyRedirects?: PluginLegacyRedirect[]; +} + +export interface PluginLegacyRedirect { + /** An absolute path, e.g. "/users/oidc/callback". */ + from: string; + /** A path in this plugin's router, e.g. "/callback". */ + to: string; + /** 307 (the default) or 308 for a permanent move. */ + status?: 307 | 308; } export interface PluginManifest { @@ -869,6 +885,40 @@ function validateUiPresets(value: unknown, errors: string[]): void { } const LEGACY_PATH_PATTERN = /^\/[a-z][a-z0-9-]*(\/[A-Za-z0-9_.-]+)+$/; +const REDIRECT_PATH_PATTERN = /^(\/[A-Za-z0-9_.-]+)+$/; +const RESERVED_PREFIXES = ["/plugin-api", "/plugin-ws", "/plugin-assets"]; + +function validateLegacyRedirects(value: unknown, errors: string[]): void { + if (value === undefined) return; + if (!Array.isArray(value)) { + errors.push('Field "contributes.http.legacyRedirects" must be an array'); + return; + } + value.forEach((entry, index) => { + const at = `contributes.http.legacyRedirects[${index}]`; + if (!isPlainObject(entry)) { + errors.push(`${at} must be an object`); + return; + } + rejectUnknown(entry, ["from", "to", "status"], at, errors); + const { from, to, status } = entry; + if (typeof from !== "string" || !REDIRECT_PATH_PATTERN.test(from)) { + errors.push(`${at}.from must be an absolute path`); + } else if ( + RESERVED_PREFIXES.some( + (prefix) => from === prefix || from.startsWith(`${prefix}/`), + ) + ) { + errors.push(`${at}.from cannot be under ${from.split("/")[1]}`); + } + if (typeof to !== "string" || !REDIRECT_PATH_PATTERN.test(to)) { + errors.push(`${at}.to must be a path in the plugin's router`); + } + if (status !== undefined && status !== 307 && status !== 308) { + errors.push(`${at}.status must be 307 or 308`); + } + }); +} function validateHttpContribution( value: unknown, @@ -880,7 +930,13 @@ function validateHttpContribution( errors.push('Field "contributes.http" must be an object'); return; } - rejectUnknown(value, ["legacyPaths"], "contributes.http", errors); + rejectUnknown( + value, + ["legacyPaths", "legacyRedirects"], + "contributes.http", + errors, + ); + validateLegacyRedirects(value.legacyRedirects, errors); const paths = value.legacyPaths; if (paths === undefined) return; if (!Array.isArray(paths)) { diff --git a/packages/plugin-sdk/src/testing.ts b/packages/plugin-sdk/src/testing.ts index d14f4bdc4..ef59f73ff 100644 --- a/packages/plugin-sdk/src/testing.ts +++ b/packages/plugin-sdk/src/testing.ts @@ -14,7 +14,7 @@ import type { PluginDisposables, PluginLogger, } from "./backend.js"; -import type { TermixApp } from "./frontend.js"; +import type { HostActionContribution, TermixApp } from "./frontend.js"; import type { PluginManifest } from "./manifest.js"; import type { PluginTableDefinition } from "./db.js"; import type { @@ -1967,6 +1967,8 @@ export interface RenderedPluginApp { tabs: () => string[]; panels: () => string[]; hostActions: () => Array<{ id: string; tabType?: string }>; + /** This plugin's host actions that apply to `host`, as the shell sees them. */ + hostActionsFor: (host: unknown) => HostActionContribution[]; hostProtocols: () => string[]; hostEditorSections: () => string[]; dashboardCards: () => string[]; diff --git a/packages/plugin-sdk/src/testing/setup.ts b/packages/plugin-sdk/src/testing/setup.ts index f048aceab..d1410d2df 100644 --- a/packages/plugin-sdk/src/testing/setup.ts +++ b/packages/plugin-sdk/src/testing/setup.ts @@ -112,6 +112,7 @@ const offline = () => pluginId: "core", credentialType: type === "password" || type === "key", supportsBackground: type !== "none", + quickConnect: true, })), loaded: true, }), @@ -138,6 +139,8 @@ const offline = () => getCustomKeybindings: async () => [], setHostAutoTmux: async () => {}, getClientPreference: () => undefined, + listHosts: async () => [], + listCredentials: async () => [], }, }; diff --git a/packages/plugin-sdk/src/vitest-preset.ts b/packages/plugin-sdk/src/vitest-preset.ts index 7c535d67c..cc25592d9 100644 --- a/packages/plugin-sdk/src/vitest-preset.ts +++ b/packages/plugin-sdk/src/vitest-preset.ts @@ -75,6 +75,8 @@ export function pluginVitestConfig( "plugin-host", "testing-host.tsx", ), + // Core's own modules behind sdk-ui and the test host import each + // other this way. Plugin code may not (eslint refuses it). "@/types": path.join(repoRoot, "src", "types"), "@": path.join(repoRoot, "src", "ui"), } diff --git a/plugins/acme-ssl/package.json b/plugins/acme-ssl/package.json index 8bcbbc1ec..0c318d05f 100644 --- a/plugins/acme-ssl/package.json +++ b/plugins/acme-ssl/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "validate": "termix-plugin validate" }, "dependencies": { diff --git a/plugins/ai/package.json b/plugins/ai/package.json index 6b767c4ec..51bb1be59 100644 --- a/plugins/ai/package.json +++ b/plugins/ai/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "validate": "termix-plugin validate" }, "devDependencies": { diff --git a/plugins/automations/package.json b/plugins/automations/package.json index 3c3c66a31..72ccaf1cd 100644 --- a/plugins/automations/package.json +++ b/plugins/automations/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "validate": "termix-plugin validate" }, "devDependencies": { diff --git a/plugins/automations/tsconfig.json b/plugins/automations/tsconfig.json index 1d3e42542..7b35314c3 100644 --- a/plugins/automations/tsconfig.json +++ b/plugins/automations/tsconfig.json @@ -1,10 +1,4 @@ { "extends": "@termix/plugin-sdk/tsconfig.plugin.json", - "compilerOptions": { - "paths": { - "@/types/*": ["../../src/types/*"], - "@/*": ["../../src/ui/*"] - } - }, "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"] } diff --git a/plugins/docker/package.json b/plugins/docker/package.json index 990ed702c..0bd521029 100644 --- a/plugins/docker/package.json +++ b/plugins/docker/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "validate": "termix-plugin validate" }, "devDependencies": { diff --git a/plugins/docker/src/backend/connect.ts b/plugins/docker/src/backend/connect.ts index 89a582af1..bacbad75c 100644 --- a/plugins/docker/src/backend/connect.ts +++ b/plugins/docker/src/backend/connect.ts @@ -250,6 +250,7 @@ export async function startConnect( ctx.ssh .connect(host, { purpose: "docker", + profile: "session", timeoutMs: CONNECT_TIMEOUT_MS, prompt: { ask }, }) diff --git a/plugins/docker/src/backend/console.ts b/plugins/docker/src/backend/console.ts index 76ec2f4fb..61b6ff579 100644 --- a/plugins/docker/src/backend/console.ts +++ b/plugins/docker/src/backend/console.ts @@ -200,6 +200,7 @@ export function registerConsole(ctx: PluginContext, log: DockerLogger): void { const { client, dispose } = await ctx.ssh.connect(host, { purpose: "docker-console", + profile: "stream", timeoutMs: 65_000, }); if (ws.readyState !== WebSocket.OPEN) { diff --git a/plugins/docker/src/backend/services.ts b/plugins/docker/src/backend/services.ts index e1e4c8aeb..5b6dd266d 100644 --- a/plugins/docker/src/backend/services.ts +++ b/plugins/docker/src/backend/services.ts @@ -92,7 +92,7 @@ export function registerServices(ctx: PluginContext, log: DockerLogger): void { const { runtime } = await readDockerHostSettings(ctx, hostId); const output = await ctx.ssh.withConnection( hostId, - { pool: POOL, purpose: "docker" }, + { pool: POOL, purpose: "docker", profile: "session" }, (client) => execOnClient(client, command(runtime)), ); return handle(output); @@ -161,7 +161,7 @@ export function registerServices(ctx: PluginContext, log: DockerLogger): void { const { runtime } = await readDockerHostSettings(ctx, watch.hostId); const output = await ctx.ssh.withConnection( host, - { pool: POOL, purpose: "docker" }, + { pool: POOL, purpose: "docker", profile: "session" }, (client) => execOnClient( client, diff --git a/plugins/file-manager/package.json b/plugins/file-manager/package.json index 9a18881bd..9de4baf22 100644 --- a/plugins/file-manager/package.json +++ b/plugins/file-manager/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "validate": "termix-plugin validate" }, "devDependencies": { diff --git a/plugins/file-manager/src/backend/host-identity.ts b/plugins/file-manager/src/backend/host-identity.ts new file mode 100644 index 000000000..4afde5e46 --- /dev/null +++ b/plugins/file-manager/src/backend/host-identity.ts @@ -0,0 +1,119 @@ +import type { PluginLogger } from "@termix/plugin-sdk/backend"; + +export type ConnectionStage = + | "dns" + | "tcp" + | "handshake" + | "auth" + | "connected" + | "connection" + | "error" + | "proxy" + | "jump" + | "sftp_connecting" + | "sftp_auth" + | "sftp_connected"; + +export type LogEntry = { + id: string; + timestamp: Date; + type: "info" | "success" | "warning" | "error"; + stage: ConnectionStage; + message: string; + details?: Record | string; +}; + +export function createConnectionLog( + type: LogEntry["type"], + stage: ConnectionStage, + message: string, + details?: Record, +): Omit { + return { type, stage, message, details }; +} + +function normalizeHostAddress(value: unknown): string { + if (typeof value !== "string") return ""; + return value + .replace(/^\[|\]$/g, "") + .trim() + .toLowerCase(); +} + +/** + * Whether a numeric host id resolved to a different machine than the client + * meant. Ids from the desktop app and a sync server drift apart, and the + * resolved row supplies the credentials, so a mismatch must be refused. + */ +export function hostAddressMismatch( + clientAddress: unknown, + resolvedAddress: unknown, +): boolean { + const resolved = normalizeHostAddress(resolvedAddress); + if (!resolved) return false; + return resolved !== normalizeHostAddress(clientAddress); +} + +export function resolveServerHostId( + clientHostId: number | null, + resolvedHost: { id?: unknown } | null, +): number | null { + return typeof resolvedHost?.id === "number" ? resolvedHost.id : clientHostId; +} + +export class HostAddressMismatchError extends Error { + constructor() { + super( + "Host mismatch: this server resolved the selected host to a different machine, so the connection was refused. " + + "The host ids on this device and on the sync server have drifted apart. " + + 'Set the connection origin to "This device" for this host, or re-run a full sync, then try again.', + ); + this.name = "HostAddressMismatchError"; + } +} + +export class HostNotOnThisServerError extends Error { + constructor() { + super( + "This host does not exist on the sync server, so the connection was refused. " + + 'Run a sync so the server knows about it, or set the connection origin to "This device" for this host.', + ); + this.name = "HostNotOnThisServerError"; + } +} + +type Meta = Record; + +export interface FileLogger { + info: (message: string, meta?: Meta) => void; + success: (message: string, meta?: Meta) => void; + warn: (message: string, meta?: Meta) => void; + error: (message: string, error?: unknown, meta?: Meta) => void; +} + +function withMeta(message: string, meta?: Meta): string { + if (!meta || Object.keys(meta).length === 0) return message; + try { + return `${message} ${JSON.stringify(meta)}`; + } catch { + return message; + } +} + +/** ctx.log takes a plain message; this keeps the structured fields readable. */ +export function createFileLogger(log: PluginLogger): FileLogger { + return { + info: (message, meta) => log.info(withMeta(message, meta)), + success: (message, meta) => log.info(withMeta(message, meta)), + warn: (message, meta) => log.warn(withMeta(message, meta)), + error: (message, error, meta) => { + if (error instanceof Error) { + log.error(withMeta(message, meta), error); + } else if (error && typeof error === "object" && !meta) { + log.error(withMeta(message, error as Meta)); + } else { + log.error(withMeta(message, meta)); + } + }, + }; +} diff --git a/plugins/file-manager/src/backend/index.ts b/plugins/file-manager/src/backend/index.ts index ca334614b..566367134 100644 --- a/plugins/file-manager/src/backend/index.ts +++ b/plugins/file-manager/src/backend/index.ts @@ -1,9 +1,5 @@ -// Core imports below point at TypeScript source so tsc can type-check them. -// The plugin build rewrites the prefix to the compiled output path; see -// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs. import express, { type Router } from "express"; import cookieParser from "cookie-parser"; -import axios from "axios"; import { Client as SSHClient } from "ssh2"; import type { PluginContext, PluginSshHost } from "@termix/plugin-sdk/backend"; import { getErrorMessage } from "./error-message.js"; @@ -15,30 +11,17 @@ import { } from "./transfer-tuning.js"; const TRANSFER_PROFILES_KEY = "transfer-profiles"; -import { AuthManager } from "../../../../src/backend/utils/auth-manager.js"; -import { logger } from "../../../../src/backend/utils/logger.js"; -import { - logAudit, - getAuditUsername, - getRequestMeta, -} from "../../../../src/backend/utils/audit-logger.js"; -import { createCurrentHostResolutionRepository } from "../../../../src/backend/database/repositories/factory.js"; -import { - resolveHostById, - resolveHostBySyncId, -} from "../../../../src/backend/hosts/host-resolver.js"; -import { createConnectionLog } from "../../../../src/backend/hosts/connection-log.js"; -import { JumpHostChainError } from "../../../../src/backend/hosts/jump-host-chain.js"; import { + createConnectionLog, + createFileLogger, hostAddressMismatch, HostAddressMismatchError, HostNotOnThisServerError, resolveServerHostId, -} from "../../../../src/backend/hosts/host-identity.js"; -import type { - LogEntry, - ConnectionStage, -} from "../../../../src/types/connection-log.js"; + type ConnectionStage, + type FileLogger, + type LogEntry, +} from "./host-identity.js"; import { ChannelOpenSerializer, execChannel, @@ -66,7 +49,17 @@ import { import { createFilesService } from "./service.js"; import { tables } from "./tables.js"; -const fileLogger = logger; +let fileLogger: FileLogger = createFileLogger(console); + +// Shape of a resolved host as this route reads it; core fills more fields +// than PluginSshHost names. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type LooseHost = PluginSshHost & Record; + +// Core's jump chain failure, matched by name so no core class is imported. +function isJumpHostChainError(error: unknown): boolean { + return error instanceof Error && error.name === "JumpHostChainError"; +} /** Cosmetic only: whether the proxy step is worth a connection-log line. */ function hasSocks5Config(host: PluginSshHost): boolean { @@ -115,6 +108,7 @@ function assertResolvedHost( } export async function activate(ctx: PluginContext) { + fileLogger = createFileLogger(ctx.log); setPluginSsh(ctx.ssh); setPluginCtx(ctx); ctx.disposables.add(() => setPluginSsh(null)); @@ -276,7 +270,7 @@ export async function activate(ctx: PluginContext) { throw new Error("Cannot open transfer connection: unknown host"); } - const host = await resolveHostById(hostId, userId); + const host = await ctx.asUser(userId, () => ctx.ssh.resolveHost(hostId)); if (!host) { throw new Error("Host not found for transfer connection"); } @@ -304,6 +298,7 @@ export async function activate(ctx: PluginContext) { host as unknown as PluginSshHost, { purpose: "file-transfer", + profile: "stream", timeoutMs: 60000, overrides: { tcpKeepAliveInitialDelay: 5000 }, }, @@ -600,9 +595,9 @@ export async function activate(ctx: PluginContext) { let resolvedSocks5ProxyChain = socks5ProxyChain; const resolveFrom = async () => { - const resolvedHost = hostSyncId - ? await resolveHostBySyncId(hostSyncId, userId) - : await resolveHostById(hostId, userId); + const resolvedHost = (await ctx.ssh.resolveHost(hostId, { + syncId: hostSyncId, + })) as LooseHost | null; assertResolvedHost(ip, hostSyncId, resolvedHost, hostId, userId); if (!resolvedHost) return; resolvedIp = resolvedHost.ip; @@ -690,7 +685,9 @@ export async function activate(ctx: PluginContext) { let serverHostId = hostId; if (hostSyncId && userId) { - const resolvedHost = await resolveHostBySyncId(hostSyncId, userId); + const resolvedHost = await ctx.ssh.resolveHost(hostId, { + syncId: hostSyncId, + }); assertResolvedHost(ip, hostSyncId, resolvedHost, hostId, userId); serverHostId = resolveServerHostId(hostId, resolvedHost) ?? hostId; } @@ -744,6 +741,7 @@ export async function activate(ctx: PluginContext) { const ssh = pluginSsh(); const prepared = await ssh.prepare(connectTarget, { purpose: "file-manager", + profile: "session", client, serverHostId, log: (level, message) => @@ -850,17 +848,13 @@ export async function activate(ctx: PluginContext) { scheduleSessionCleanup(sessionId); if (userId) { - const { ipAddress, userAgent } = getRequestMeta(req); void (async () => { - await logAudit({ - userId, - username: await getAuditUsername(userId), + await ctx.audit.record({ + request: req, action: "file_manager_connect", resourceType: "host", resourceId: serverHostId ? String(serverHostId) : undefined, resourceName: `${username}@${ip}:${port}`, - ipAddress, - userAgent, success: true, }); })(); @@ -1169,17 +1163,14 @@ export async function activate(ctx: PluginContext) { hostId, }); const stage = - error instanceof JumpHostChainError || jumpHostCount > 0 - ? "jump" - : "proxy"; + isJumpHostChainError(error) || jumpHostCount > 0 ? "jump" : "proxy"; connectionLogs.push( createConnectionLog("error", stage, getErrorMessage(error)), ); return res.status(500).json({ - error: - error instanceof JumpHostChainError - ? `Failed to connect through jump hosts: ${error.message}` - : getErrorMessage(error), + error: isJumpHostChainError(error) + ? `Failed to connect through jump hosts: ${error.message}` + : getErrorMessage(error), connectionLogs, }); } @@ -1195,24 +1186,13 @@ export async function activate(ctx: PluginContext) { port: number, ): Promise { try { - const host = await createCurrentHostResolutionRepository().findHostById( - hostId, - userId, - ); - const hostName = - host?.userId === userId && host.name - ? host.name - : `${username}@${ip}:${port}`; - - const authManager = AuthManager.getInstance(); - await axios.post( - "http://localhost:30006/activity/log", - { type: "file_manager", hostId, hostName }, - { - headers: { - Authorization: `Bearer ${await authManager.generateJWTToken(userId)}`, - }, - }, + const host = await ctx.hosts.get(hostId); + await ctx.asUser(userId, () => + ctx.hosts.recordActivity( + hostId, + "file_manager", + host?.name || `${username}@${ip}:${port}`, + ), ); } catch (error) { fileLogger.warn("Failed to log file manager activity", { diff --git a/plugins/file-manager/src/backend/operation-routes.ts b/plugins/file-manager/src/backend/operation-routes.ts index a28993e92..e87994061 100644 --- a/plugins/file-manager/src/backend/operation-routes.ts +++ b/plugins/file-manager/src/backend/operation-routes.ts @@ -15,7 +15,6 @@ import { permanentlyDeleteTrashItem, restoreTrashItem, } from "./trash-service.js"; -import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js"; type FileOperationRoutesDeps = { ctx: PluginContext; @@ -29,10 +28,8 @@ export function registerFileOperationRoutes( app: Express, { ctx, sshSessions, verifySessionOwnership }: FileOperationRoutesDeps, ): void { - // NOTE: trash retention is an install-wide admin setting, not a per-host - // access check, so it stays on PermissionManager.isAdmin rather than - // ctx.hosts.checkAccess (see rule 5 scope: host resolution/access only). - const permissionManager = PermissionManager.getInstance(); + // Trash retention is an install-wide admin setting, not a per-host check. + const canManageRetention = () => ctx.rbac.has("admin.settings.manage"); const getTrashRetentionDays = async (): Promise => { try { const value = Number(await ctx.settings.get(TRASH_RETENTION_DAYS_KEY)); @@ -68,9 +65,7 @@ export function registerFileOperationRoutes( res.json({ items: await listTrash(await getSessionSftp(session), retentionDays), retentionDays, - canManageRetention: await permissionManager.isAdmin( - ctx.currentActor()!, - ), + canManageRetention: await canManageRetention(), }); } catch (error) { ctx.log.error("Failed to list trash", error as Error); @@ -121,8 +116,7 @@ export function registerFileOperationRoutes( }); app.put("/trash-retention", async (req, res) => { - const userId = ctx.currentActor()!; - if (!(await permissionManager.isAdmin(userId))) { + if (!(await canManageRetention())) { return res.status(403).json({ error: "Admin access required" }); } const retentionDays = Number(req.body?.retentionDays); diff --git a/plugins/file-manager/src/backend/service.ts b/plugins/file-manager/src/backend/service.ts index 4b3ca9360..bc10cd527 100644 --- a/plugins/file-manager/src/backend/service.ts +++ b/plugins/file-manager/src/backend/service.ts @@ -40,7 +40,11 @@ async function withServiceSftp( ): Promise { return ctx.ssh.withConnection( hostId, - { pool: "file-manager-service", purpose: "file-manager" }, + { + pool: "file-manager-service", + purpose: "file-manager", + profile: "session", + }, (client) => new Promise((resolve, reject) => { client.sftp(async (err, sftp) => { diff --git a/plugins/file-manager/src/frontend/FileManager.tsx b/plugins/file-manager/src/frontend/FileManager.tsx index 0ba509d28..d2eaed209 100644 --- a/plugins/file-manager/src/frontend/FileManager.tsx +++ b/plugins/file-manager/src/frontend/FileManager.tsx @@ -1,5 +1,5 @@ import { fileManagerHostSetting } from "./host-settings"; -import { getErrorMessage } from "@/lib/error-message.js"; +import { getErrorMessage } from "./lib/error-message"; /* eslint-disable react-hooks/exhaustive-deps */ import React, { useState, @@ -8,8 +8,8 @@ import React, { useCallback, useMemo, } from "react"; -import { asHttpError } from "@/lib/http-error"; -import { cn } from "@/lib/utils.ts"; +import { asHttpError } from "./lib/http-error"; +import { cn } from "@termix/plugin-sdk/ui"; import { FileManagerGrid } from "./FileManagerGrid.tsx"; import { FileManagerSidebar, type SidebarItem } from "./FileManagerSidebar.tsx"; import { FileManagerContextMenu } from "./FileManagerContextMenu.tsx"; @@ -24,33 +24,33 @@ import { DownloadProgressToast } from "./components/DownloadProgressToast.tsx"; import { DiffWindow } from "./components/DiffWindow.tsx"; import { useDragToDesktop } from "./hooks/useDragToDesktop"; import { useDragToSystemDesktop } from "./hooks/useDragToSystemDesktop"; -import { useConfirmation } from "@/hooks/use-confirmation.ts"; +import { useConfirmation } from "@termix/plugin-sdk/ui"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import { FileManagerDialogs } from "./FileManagerDialogs.tsx"; -import { PassphraseDialog } from "@/ssh/dialogs/PassphraseDialog.tsx"; +import { PassphraseDialog } from "@termix/plugin-sdk/ui"; import { FileManagerToolbar } from "./FileManagerToolbar.tsx"; import { LocalFilePane } from "./LocalFilePane.tsx"; import { useLocalTransfers } from "./hooks/useLocalTransfers.ts"; import { usePaneWidth } from "./hooks/usePaneWidth.ts"; import { PaneResizeHandle } from "./components/PaneResizeHandle.tsx"; -import { isLocalFileBrowserAvailable } from "@/lib/local-files.ts"; +import { isLocalFileBrowserAvailable } from "./lib/local-files"; import { TransferToHostDialog } from "./components/TransferToHostDialog.tsx"; import { FileManagerTrashDialog } from "./FileManagerTrashDialog.tsx"; import { TerminalWindow } from "./components/TerminalWindow.tsx"; -import type { SSHHost, FileItem } from "@/types/index"; +import type { SSHHost, FileItem } from "./host-types"; import { ConnectionLogProvider, useConnectionLog, ConnectionScreen, } from "@termix/plugin-sdk/ui"; -import { ConnectionLogPanel } from "@/components/connection/ConnectionLogPanel.tsx"; +import { ConnectionLogPanel } from "@termix/plugin-sdk/ui"; import { invokeAction, useConnectionRetry, logActivity, } from "@termix/plugin-sdk/frontend"; -import { copyToClipboard } from "@/lib/clipboard.ts"; +import { copyToClipboard } from "@termix/plugin-sdk/ui"; import { listSSHFiles, readSSHFile, @@ -85,13 +85,13 @@ import { transferToHost, addTransferRecent, type TransferMethodPreference, -} from "@/main-axios.ts"; +} from "./api/transfer-api"; import type { DiskFilesystem, HostDiskInfo } from "./disk-info"; import { getAdaptiveResourceBudget, markAdaptiveResourceUsed, runAdaptiveBackgroundTask, -} from "@/lib/adaptive-resource-budget"; +} from "@termix/plugin-sdk/ui"; import { shouldPrefetchFileContent } from "./lib/file-content-request-cache"; import { markFilePreviewUsed, diff --git a/plugins/file-manager/src/frontend/FileManagerContextMenu.tsx b/plugins/file-manager/src/frontend/FileManagerContextMenu.tsx index 1c7946a37..f4301261a 100644 --- a/plugins/file-manager/src/frontend/FileManagerContextMenu.tsx +++ b/plugins/file-manager/src/frontend/FileManagerContextMenu.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { cn } from "@/lib/utils.ts"; +import { cn } from "@termix/plugin-sdk/ui"; import { Download, Edit3, @@ -22,7 +22,7 @@ import { Link, } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { Kbd, KbdKey, KbdSeparator } from "@/components/kbd.tsx"; +import { Kbd, KbdKey, KbdSeparator } from "@termix/plugin-sdk/ui"; const VIEWPORT_PADDING = 16; diff --git a/plugins/file-manager/src/frontend/FileManagerDialogs.tsx b/plugins/file-manager/src/frontend/FileManagerDialogs.tsx index 3898c4f5e..4b05c2f83 100644 --- a/plugins/file-manager/src/frontend/FileManagerDialogs.tsx +++ b/plugins/file-manager/src/frontend/FileManagerDialogs.tsx @@ -1,4 +1,4 @@ -import type { FileItem, SSHHost } from "@/types/index"; +import type { FileItem, SSHHost } from "./host-types"; import { TOTPDialog, SSHAuthDialog, diff --git a/plugins/file-manager/src/frontend/FileManagerGrid.tsx b/plugins/file-manager/src/frontend/FileManagerGrid.tsx index 7e63bce4f..6d63f83d7 100644 --- a/plugins/file-manager/src/frontend/FileManagerGrid.tsx +++ b/plugins/file-manager/src/frontend/FileManagerGrid.tsx @@ -9,7 +9,7 @@ import React, { } from "react"; import { createPortal } from "react-dom"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { cn } from "@/lib/utils.ts"; +import { cn } from "@termix/plugin-sdk/ui"; import { Folder, File, @@ -30,7 +30,7 @@ import { GitCompare, } from "lucide-react"; import { useTranslation } from "react-i18next"; -import type { FileItem } from "@/types/index"; +import type { FileItem } from "./host-types"; import type { CreateIntent } from "./file-manager-types.ts"; import { formatFileSize } from "./file-manager-utils.ts"; import { diff --git a/plugins/file-manager/src/frontend/FileManagerSidebar.tsx b/plugins/file-manager/src/frontend/FileManagerSidebar.tsx index eb0b0e45a..3144be86c 100644 --- a/plugins/file-manager/src/frontend/FileManagerSidebar.tsx +++ b/plugins/file-manager/src/frontend/FileManagerSidebar.tsx @@ -5,7 +5,7 @@ import React, { useCallback, useMemo, } from "react"; -import { cn } from "@/lib/utils.ts"; +import { cn } from "@termix/plugin-sdk/ui"; import { Star, Clock, @@ -17,7 +17,7 @@ import { Check, } from "lucide-react"; import { useTranslation } from "react-i18next"; -import type { SSHHost } from "@/types"; +import type { SSHHost } from "./host-types"; import { getRecentFiles, getPinnedFiles, @@ -29,7 +29,7 @@ import { import { listSSHFiles } from "./api/ssh-file-operations-api"; import type { DiskFilesystem } from "./disk-info"; import { toast } from "sonner"; -import FolderTree from "@/components/folder.tsx"; +import FolderTree from "./components/FolderTree"; import { DropdownMenu, DropdownMenuTrigger, diff --git a/plugins/file-manager/src/frontend/FileManagerToolbar.tsx b/plugins/file-manager/src/frontend/FileManagerToolbar.tsx index 73f06191d..46ed52212 100644 --- a/plugins/file-manager/src/frontend/FileManagerToolbar.tsx +++ b/plugins/file-manager/src/frontend/FileManagerToolbar.tsx @@ -30,7 +30,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@termix/plugin-sdk/ui"; -import type { FileItem } from "@/types/index"; +import type { FileItem } from "./host-types"; type SortBy = "name" | "modified" | "size"; type SortOrder = "asc" | "desc"; diff --git a/plugins/file-manager/src/frontend/LocalFileContextMenu.tsx b/plugins/file-manager/src/frontend/LocalFileContextMenu.tsx index 87f36ab60..0d7402344 100644 --- a/plugins/file-manager/src/frontend/LocalFileContextMenu.tsx +++ b/plugins/file-manager/src/frontend/LocalFileContextMenu.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { cn } from "@/lib/utils.ts"; +import { cn } from "@termix/plugin-sdk/ui"; import { Clipboard, Edit3, @@ -14,8 +14,8 @@ import { Upload, } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { Kbd, KbdKey, KbdSeparator } from "@/components/kbd.tsx"; -import type { LocalFileEntry } from "@/types/electron"; +import { Kbd, KbdKey, KbdSeparator } from "@termix/plugin-sdk/ui"; +import type { LocalFileEntry } from "@termix/plugin-sdk/ui"; const VIEWPORT_PADDING = 16; diff --git a/plugins/file-manager/src/frontend/LocalFilePane.tsx b/plugins/file-manager/src/frontend/LocalFilePane.tsx index daf5468b1..1ab2766b0 100644 --- a/plugins/file-manager/src/frontend/LocalFilePane.tsx +++ b/plugins/file-manager/src/frontend/LocalFilePane.tsx @@ -26,9 +26,9 @@ import { RefreshCw, X, } from "lucide-react"; -import { cn } from "@/lib/utils.ts"; +import { cn } from "@termix/plugin-sdk/ui"; import { Button, Input } from "@termix/plugin-sdk/ui"; -import type { LocalFileEntry } from "@/types/electron"; +import type { LocalFileEntry } from "@termix/plugin-sdk/ui"; import { createLocalFile, createLocalFolder, @@ -38,9 +38,9 @@ import { renameLocalEntry, revealLocalPath, trashLocalPaths, -} from "@/lib/local-files.ts"; -import { copyToClipboard } from "@/lib/clipboard.ts"; -import { useConfirmation } from "@/hooks/use-confirmation.ts"; +} from "./lib/local-files"; +import { copyToClipboard } from "@termix/plugin-sdk/ui"; +import { useConfirmation } from "@termix/plugin-sdk/ui"; import { LocalFileContextMenu } from "./LocalFileContextMenu.tsx"; import { useResizableColumns, diff --git a/plugins/file-manager/src/frontend/SftpTransferTab.tsx b/plugins/file-manager/src/frontend/SftpTransferTab.tsx index ac4516888..2b7c4b9a5 100644 --- a/plugins/file-manager/src/frontend/SftpTransferTab.tsx +++ b/plugins/file-manager/src/frontend/SftpTransferTab.tsx @@ -38,10 +38,10 @@ import { } from "@termix/plugin-sdk/ui"; import { addTransferRecent, - getSSHHosts, transferToHost, type TransferProgressResponse, -} from "@/main-axios"; +} from "./api/transfer-api"; +import { getSSHHosts } from "./api/hosts"; import { browseSSHDirectory, changeSSHPermissions, @@ -51,7 +51,7 @@ import { renameSSHItem, type HostConnectionState, } from "./api/ssh-file-operations-api"; -import type { SSHHost } from "@/types"; +import type { SSHHost } from "./host-types"; import { PermissionsDialog } from "./components/PermissionsDialog"; import { formatFileSize } from "./file-manager-utils"; import { beginTransferProgressMonitoring } from "./transferProgressMonitor"; @@ -581,7 +581,7 @@ export function SftpTransferTab() { useEffect(() => { setHostsLoading(true); - getSSHHosts({ includeStatus: false }) + getSSHHosts() .then((data) => setHosts( data.filter( diff --git a/plugins/file-manager/src/frontend/TransferMonitor.tsx b/plugins/file-manager/src/frontend/TransferMonitor.tsx index 6dfa568b3..38fd15b69 100644 --- a/plugins/file-manager/src/frontend/TransferMonitor.tsx +++ b/plugins/file-manager/src/frontend/TransferMonitor.tsx @@ -1,6 +1,6 @@ import type { TranslateFn } from "@termix/plugin-sdk/frontend"; import type { TFunction } from "i18next"; -import { getTransferStatus, listActiveTransfers } from "@/main-axios.ts"; +import { getTransferStatus, listActiveTransfers } from "./api/transfer-api"; import { createFormatTransferMetrics } from "./transferMetricsFormat.ts"; import { beginTransferProgressMonitoring, @@ -12,7 +12,7 @@ import { getPendingTransferIds, isTransferNotified, } from "./transferNotificationStore.ts"; -import { runAdaptivePolling } from "@/lib/adaptive-polling.ts"; +import { runAdaptivePolling } from "@termix/plugin-sdk/ui"; const POLL_INTERVAL_MS = 2000; diff --git a/plugins/file-manager/src/frontend/api/client.ts b/plugins/file-manager/src/frontend/api/client.ts new file mode 100644 index 000000000..c3a5ec946 --- /dev/null +++ b/plugins/file-manager/src/frontend/api/client.ts @@ -0,0 +1,121 @@ +import type { TermixApp } from "@termix/plugin-sdk/frontend"; + +// Response bodies are typed at each call site's return type. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Body = any; + +interface ClientResponse { + data: T; + status: number; + headers: Record; +} + +/** The plugin client is axios underneath; these are the parts used here. */ +export interface FileManagerClient { + get(url: string, config?: unknown): Promise>; + delete(url: string, config?: unknown): Promise>; + post( + url: string, + body?: unknown, + config?: unknown, + ): Promise>; + put( + url: string, + body?: unknown, + config?: unknown, + ): Promise>; + patch( + url: string, + body?: unknown, + config?: unknown, + ): Promise>; + postForm( + url: string, + body?: unknown, + config?: unknown, + ): Promise>; +} + +let current: TermixApp | null = null; + +/** Set in activate, cleared on deactivate. */ +export function setFileManagerApp(app: TermixApp | null): void { + current = app; +} + +function app(): TermixApp { + if (!current) throw new Error("The file manager plugin is not active"); + return current; +} + +export function fileManagerApi(): FileManagerClient { + return app().api as unknown as FileManagerClient; +} + +// A live SSH session (keyed by sessionId) remembers which backend holds it, +// so every later call for that session reaches the same server. +const sessionOrigins = new Map(); + +export function setSessionOrigin( + sessionId: string, + origin: "local" | "remote", +): void { + sessionOrigins.set(sessionId, origin); +} + +export function clearSessionOrigin(sessionId: string): void { + sessionOrigins.delete(sessionId); +} + +export function getSessionOrigin(sessionId: string): "local" | "remote" { + return sessionOrigins.get(sessionId) === "remote" ? "remote" : "local"; +} + +export function getFileManagerApiForSession( + sessionId: string, +): FileManagerClient { + return app().apiFor( + getSessionOrigin(sessionId), + ) as unknown as FileManagerClient; +} + +export function fileManagerApiFor( + origin: "local" | "remote" | undefined, +): FileManagerClient { + return app().apiFor(origin ?? "local") as unknown as FileManagerClient; +} + +class ApiError extends Error { + constructor( + message: string, + public status?: number, + public code?: string, + ) { + super(message); + this.name = "ApiError"; + } +} + +interface AxiosLikeError { + message?: string; + response?: { + status?: number; + data?: { message?: string; error?: string; code?: string }; + }; +} + +/** Turns an axios failure into an Error carrying the server's message. */ +export function handleApiError(error: unknown, operation: string): never { + const e = error as AxiosLikeError; + if (e?.response) { + const status = e.response.status; + const data = e.response.data ?? {}; + const message = + data.message || data.error || e.message || `Failed to ${operation}`; + const apiError = new ApiError(message, status, data.code || data.error); + (apiError as ApiError & { response?: unknown }).response = e.response; + throw apiError; + } + if (error instanceof Error) throw error; + throw new ApiError(`Failed to ${operation}`); +} diff --git a/plugins/file-manager/src/frontend/api/file-manager-data-api.ts b/plugins/file-manager/src/frontend/api/file-manager-data-api.ts index 0b75526db..76e5f26d5 100644 --- a/plugins/file-manager/src/frontend/api/file-manager-data-api.ts +++ b/plugins/file-manager/src/frontend/api/file-manager-data-api.ts @@ -1,4 +1,4 @@ -import { fileManagerApi, handleApiError } from "@/main-axios"; +import { fileManagerApi, handleApiError } from "./client"; /** Row shape shared by the recent / pinned / shortcut list endpoints. */ export interface FileManagerEntry { @@ -16,7 +16,7 @@ export async function getRecentFiles( hostId: number, ): Promise { try { - const response = await fileManagerApi.get("/recent", { + const response = await fileManagerApi().get("/recent", { params: { hostId }, }); return response.data; @@ -32,7 +32,7 @@ export async function addRecentFile( name?: string, ): Promise> { try { - const response = await fileManagerApi.post("/recent", { + const response = await fileManagerApi().post("/recent", { hostId, path, name, @@ -49,7 +49,7 @@ export async function removeRecentFile( path: string, ): Promise> { try { - const response = await fileManagerApi.delete("/recent", { + const response = await fileManagerApi().delete("/recent", { data: { hostId, path }, }); return response.data; @@ -63,7 +63,7 @@ export async function getPinnedFiles( hostId: number, ): Promise { try { - const response = await fileManagerApi.get("/pinned", { + const response = await fileManagerApi().get("/pinned", { params: { hostId }, }); return response.data; @@ -79,7 +79,7 @@ export async function addPinnedFile( name?: string, ): Promise> { try { - const response = await fileManagerApi.post("/pinned", { + const response = await fileManagerApi().post("/pinned", { hostId, path, name, @@ -96,7 +96,7 @@ export async function removePinnedFile( path: string, ): Promise> { try { - const response = await fileManagerApi.delete("/pinned", { + const response = await fileManagerApi().delete("/pinned", { data: { hostId, path }, }); return response.data; @@ -110,7 +110,7 @@ export async function getFolderShortcuts( hostId: number, ): Promise { try { - const response = await fileManagerApi.get("/shortcuts", { + const response = await fileManagerApi().get("/shortcuts", { params: { hostId }, }); return response.data; @@ -126,7 +126,7 @@ export async function addFolderShortcut( name?: string, ): Promise> { try { - const response = await fileManagerApi.post("/shortcuts", { + const response = await fileManagerApi().post("/shortcuts", { hostId, path, name, @@ -143,7 +143,7 @@ export async function removeFolderShortcut( path: string, ): Promise> { try { - const response = await fileManagerApi.delete("/shortcuts", { + const response = await fileManagerApi().delete("/shortcuts", { data: { hostId, path }, }); return response.data; diff --git a/plugins/file-manager/src/frontend/api/hosts.ts b/plugins/file-manager/src/frontend/api/hosts.ts new file mode 100644 index 000000000..4f3a15250 --- /dev/null +++ b/plugins/file-manager/src/frontend/api/hosts.ts @@ -0,0 +1,6 @@ +import { listHosts } from "@termix/plugin-sdk/frontend"; +import type { SSHHost } from "../host-types"; + +export async function getSSHHosts(): Promise { + return (await listHosts()) as unknown as SSHHost[]; +} 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 a7dcac5e0..da14b9122 100644 --- a/plugins/file-manager/src/frontend/api/local-transfer-api.ts +++ b/plugins/file-manager/src/frontend/api/local-transfer-api.ts @@ -12,9 +12,9 @@ import type { LocalTransferOrigin, LocalTransferProgress, -} from "@/types/electron"; -import { getSessionOrigin } from "@/main-axios"; -import { getDeviceId } from "@/lib/device-id"; +} from "@termix/plugin-sdk/ui"; +import { getSessionOrigin } from "./client"; +import { getDeviceId } from "@termix/plugin-sdk/ui"; export interface LocalTransferProgressEvent { transferred: number; diff --git a/plugins/file-manager/src/frontend/api/ssh-file-operations-api.ts b/plugins/file-manager/src/frontend/api/ssh-file-operations-api.ts index 453e88bb1..5557b49d6 100644 --- a/plugins/file-manager/src/frontend/api/ssh-file-operations-api.ts +++ b/plugins/file-manager/src/frontend/api/ssh-file-operations-api.ts @@ -1,16 +1,17 @@ -import { getErrorMessage } from "@/lib/error-message.js"; +import { getErrorMessage } from "../lib/error-message"; import axios from "axios"; -import { asHttpError } from "@/lib/http-error"; +import { asHttpError } from "../lib/http-error"; import { - authApi, handleApiError, getFileManagerApiForSession, setSessionOrigin, clearSessionOrigin, -} from "@/main-axios"; -import { resolveConnectionOrigin } from "@/lib/connection-origin"; -import { fileLogger } from "@/lib/frontend-logger"; -import type { FileItem, SSHHost } from "@/types/index"; +} from "./client"; +import { + createFrontendLogger, + resolveConnectionOrigin, +} from "@termix/plugin-sdk/ui"; +import type { FileItem, SSHHost } from "../host-types"; import { getCachedFileList } from "../lib/file-list-request-cache"; import { getCachedFileContent, @@ -18,6 +19,8 @@ import { type FileContentResult, } from "../lib/file-content-request-cache"; +const fileLogger = createFrontendLogger("FILE"); + type ApiConnectionLog = { type: "info" | "success" | "warning" | "error"; stage: string; @@ -193,83 +196,6 @@ export async function verifySSHBrowserSignIn( } } -/** - * @openapi - * /ssh/quick-connect: - * post: - * summary: Create a temporary SSH connection without saving to database - * description: Returns a temporary host configuration for immediate use - * tags: - * - SSH - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: - * - ip - * - port - * - username - * - authType - * properties: - * ip: - * type: string - * description: SSH server IP or hostname - * port: - * type: number - * description: SSH server port - * username: - * type: string - * description: SSH username - * authType: - * type: string - * enum: [password, key, credential] - * description: Authentication method - * password: - * type: string - * description: Password (required if authType is password) - * key: - * type: string - * description: SSH private key (required if authType is key) - * keyPassword: - * type: string - * description: SSH key password (optional) - * keyType: - * type: string - * description: SSH key type - * credentialId: - * type: number - * description: Credential ID (required if authType is credential) - * overrideCredentialUsername: - * type: boolean - * description: Use provided username instead of credential username - * responses: - * 200: - * description: Temporary host configuration created successfully - * content: - * application/json: - * schema: - * type: object - * description: SSHHost object - * 400: - * description: Invalid request data - * 401: - * description: Unauthorized - * 500: - * description: Server error - */ -export async function quickConnect( - data: Record, -): Promise { - try { - const response = await authApi.post("/host/quick-connect", data); - return response.data; - } catch (error) { - throw handleApiError(error, "quick connect"); - } -} - export async function getSSHStatus( sessionId: string, ): Promise<{ connected: boolean }> { diff --git a/plugins/file-manager/src/frontend/api/transfer-api.ts b/plugins/file-manager/src/frontend/api/transfer-api.ts new file mode 100644 index 000000000..0cd4d0d95 --- /dev/null +++ b/plugins/file-manager/src/frontend/api/transfer-api.ts @@ -0,0 +1,384 @@ +import { createFrontendLogger } from "@termix/plugin-sdk/ui"; +import { fileManagerApi, handleApiError } from "./client"; + +const fileLogger = createFrontendLogger("FILE"); + +// ============================================================================ +// HOST-TO-HOST TRANSFER +// ============================================================================ + +export type TransferMethodPreference = "auto" | "tar" | "item_sftp"; + +export interface TransferScanSummary { + fileCount: number; + totalBytes: number; + largestFileBytes: number; + incompressibleRatio: number; +} + +export interface TransferMethodPreview { + methodPreference: TransferMethodPreference; + resolvedMethod: "tar" | "item_sftp"; + reasonKey: string; + sourcePlatform: "unix" | "windows"; + destPlatform: "unix" | "windows"; + sourceHasTar: boolean; + destHasTar: boolean; + summary: TransferScanSummary; +} + +export async function getTransferMethodPreview( + sourceSessionId: string, + sourcePaths: string[], + destSessionId: string, + destPath: string, + methodPreference?: TransferMethodPreference, +): Promise { + try { + const response = await fileManagerApi().post("/transferMethodPreview", { + sourceSessionId, + sourcePaths, + destSessionId, + destPath, + methodPreference: methodPreference ?? "auto", + }); + return response.data; + } catch (error) { + handleApiError(error, "preview transfer method"); + throw error; + } +} + +export interface TransferHopMetrics { + id: string; + mbPerSec?: number; +} + +export interface TransferTimings { + prepareDestMs?: number; + compressMs?: number; + transferMs?: number; + extractMs?: number; + verifyMs?: number; + directBenchmarkMs?: number; + relayBenchmarkMs?: number; + sourceDeleteMs?: number; + totalMs?: number; + transferBytes?: number; + endToEndMbPerSec?: number; + hops?: TransferHopMetrics[]; +} + +export function getTransferProgressPercent( + status: TransferProgressResponse, +): number | undefined { + if ( + status.bytesTransferred !== undefined && + status.totalBytes !== undefined && + status.totalBytes > 0 + ) { + return Math.min( + 100, + Math.round((status.bytesTransferred / status.totalBytes) * 100), + ); + } + if ( + status.itemsCompleted !== undefined && + status.totalItems !== undefined && + status.totalItems > 0 + ) { + return Math.min( + 100, + Math.round((status.itemsCompleted / status.totalItems) * 100), + ); + } + return undefined; +} + +export function formatTransferMbPerSec( + mbPerSec?: number, + _bytes?: number, + _ms?: number, +): string { + if (mbPerSec === undefined || mbPerSec <= 0) return ""; + if (mbPerSec < 1) return `${(mbPerSec * 1024).toFixed(0)} KB/s`; + return `${mbPerSec.toFixed(1)} MB/s`; +} + +export function formatDurationMs(ms?: number): string { + if (ms === undefined) return ""; + if (ms < 1000) return `${ms}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; + const minutes = Math.floor(ms / 60000); + const seconds = Math.round((ms % 60000) / 1000); + return `${minutes}m ${seconds}s`; +} + +interface TransferProgressTracker { + update(status: TransferProgressResponse): { + rate: number | undefined; + stalled: boolean; + }; +} + +export function createTransferProgressTracker(): TransferProgressTracker { + let lastBytes: number | undefined; + let lastTime: number | undefined; + let lastRate: number | undefined; + let lastActivityTime: number | undefined; + const STALL_THRESHOLD_MS = 5000; + + return { + update(status) { + const now = Date.now(); + const bytes = status.bytesTransferred; + + if ( + bytes !== undefined && + lastBytes !== undefined && + lastTime !== undefined + ) { + const deltaBytes = bytes - lastBytes; + const deltaMs = now - lastTime; + if (deltaMs > 0 && deltaBytes >= 0) { + lastRate = (deltaBytes / deltaMs / 1024 / 1024) * 1000; + if (deltaBytes > 0) lastActivityTime = now; + } + } else if (bytes !== undefined) { + lastActivityTime = now; + } + + lastBytes = bytes; + lastTime = now; + + const stalled = + lastActivityTime !== undefined && + now - lastActivityTime > STALL_THRESHOLD_MS && + status.status === "running" && + status.phase === "transferring"; + + return { rate: lastRate, stalled }; + }, + }; +} + +export interface TransferProgressResponse { + transferId: string; + status: "running" | "success" | "partial" | "error" | "cancelled"; + phase: + | "compressing" + | "transferring" + | "benchmarking" + | "verifying" + | "extracting" + | "reconnecting"; + bytesTransferred?: number; + totalBytes?: number; + itemsCompleted?: number; + totalItems?: number; + failedPaths?: string[]; + message?: string; + method?: "stream" | "tar" | "item_sftp" | "direct_rsync"; + sourcePaths?: string[]; + destPath?: string; + sourceSessionId?: string; + destSessionId?: string; + startedAt?: number; + timings?: TransferTimings; + sourceDeleted?: boolean; + moveRequested?: boolean; + partialDestRemaining?: boolean; + cleanupCompleted?: boolean; + retryable?: boolean; + integrityVerified?: boolean; + parallelSegmentCount?: number; +} + +export async function transferToHost( + sourceSessionId: string, + sourcePaths: string[], + destSessionId: string, + destPath: string, + move?: boolean, + methodPreference?: TransferMethodPreference, + parallelSegmentCount?: number, +): Promise<{ transferId: string }> { + try { + fileLogger.info("Starting host transfer", { + operation: "host_transfer", + sourceSessionId, + destSessionId, + sourcePaths, + destPath, + move, + methodPreference, + parallelSegmentCount, + }); + + const response = await fileManagerApi().post("/transferToHost", { + sourceSessionId, + sourcePaths, + destSessionId, + destPath, + move, + methodPreference: methodPreference ?? "auto", + parallelSegmentCount, + }); + + return response.data; + } catch (error) { + fileLogger.error("Failed to start host transfer", error, { + operation: "host_transfer", + sourceSessionId, + destSessionId, + sourcePaths, + }); + handleApiError(error, "transfer to host"); + throw error; + } +} + +export async function getTransferStatus( + transferId: string, +): Promise { + try { + const response = await fileManagerApi().get( + `/transferStatus/${transferId}`, + ); + return response.data; + } catch (error) { + handleApiError(error, "get transfer status"); + throw error; + } +} + +export async function listActiveTransfers(): Promise<{ + transfers: TransferProgressResponse[]; +}> { + try { + const response = await fileManagerApi().get("/activeTransfers"); + return response.data; + } catch (error) { + handleApiError(error, "list active transfers"); + throw error; + } +} + +export async function cancelTransferToHost(transferId: string): Promise { + try { + await fileManagerApi().post(`/transferCancel/${transferId}`); + } catch (error) { + fileLogger.warn("Transfer cancel request failed (non-fatal)", { + operation: "host_transfer", + transferId, + error, + }); + } +} + +export async function cleanupCancelledTransfer( + transferId: string, +): Promise<{ removedPaths: string[]; failedPaths: string[] }> { + try { + const response = await fileManagerApi().post( + `/transferCleanup/${transferId}`, + ); + return response.data; + } catch (error) { + handleApiError(error, "clean up cancelled transfer"); + throw error; + } +} + +export async function retryTransferToHost( + transferId: string, +): Promise<{ ok: boolean; transferId: string }> { + try { + const response = await fileManagerApi().post( + `/transferRetry/${transferId}`, + ); + return response.data; + } catch (error) { + handleApiError(error, "retry transfer"); + throw error; + } +} + +export async function pollTransferUntilComplete( + transferId: string, + onProgress?: (status: TransferProgressResponse) => void, + intervalMs = 500, +): Promise { + return new Promise((resolve, reject) => { + const poll = async () => { + try { + const status = await getTransferStatus(transferId); + onProgress?.(status); + + if ( + status.status === "success" || + status.status === "partial" || + status.status === "error" || + status.status === "cancelled" + ) { + resolve(status); + return; + } + + setTimeout(poll, intervalMs); + } catch (err) { + reject(err); + } + }; + + void poll(); + }); +} + +// ============================================================================ +// FILE MANAGER DATA +// ============================================================================ + +export interface TransferDestination { + id: number; + userId: string; + sourceHostId: number; + destHostId: number; + destPath: string; + destPathLabel?: string; + lastUsed?: string; +} + +export async function getTransferRecent( + sourceHostId: number, +): Promise { + try { + const response = await fileManagerApi().get("/transfer-recent", { + params: { sourceHostId }, + }); + return response.data; + } catch (error) { + handleApiError(error, "get transfer recent destinations"); + throw error; + } +} + +export async function addTransferRecent( + sourceHostId: number, + destHostId: number, + destPath: string, + destPathLabel?: string, +): Promise> { + try { + const response = await fileManagerApi().post("/transfer-recent", { + sourceHostId, + destHostId, + destPath, + destPathLabel, + }); + return response.data; + } catch (error) { + handleApiError(error, "add transfer recent destination"); + throw error; + } +} diff --git a/plugins/file-manager/src/frontend/components/AudioPreview.tsx b/plugins/file-manager/src/frontend/components/AudioPreview.tsx index 0b3b95036..28110071b 100644 --- a/plugins/file-manager/src/frontend/components/AudioPreview.tsx +++ b/plugins/file-manager/src/frontend/components/AudioPreview.tsx @@ -2,7 +2,7 @@ import React from "react"; import AudioPlayer from "react-h5-audio-player"; import "react-h5-audio-player/lib/styles.css"; import { Music } from "lucide-react"; -import { cn } from "@/lib/utils.ts"; +import { cn } from "@termix/plugin-sdk/ui"; import { useTranslation } from "react-i18next"; interface FileItem { diff --git a/plugins/file-manager/src/frontend/components/ColumnResizeHandle.tsx b/plugins/file-manager/src/frontend/components/ColumnResizeHandle.tsx index fdae7a68f..5b93b9516 100644 --- a/plugins/file-manager/src/frontend/components/ColumnResizeHandle.tsx +++ b/plugins/file-manager/src/frontend/components/ColumnResizeHandle.tsx @@ -1,5 +1,5 @@ import type React from "react"; -import { cn } from "@/lib/utils.ts"; +import { cn } from "@termix/plugin-sdk/ui"; interface ColumnResizeHandleProps { onMouseDown: (event: React.MouseEvent) => void; diff --git a/plugins/file-manager/src/frontend/components/ColumnVisibilityMenu.tsx b/plugins/file-manager/src/frontend/components/ColumnVisibilityMenu.tsx index 2936e8981..bbe006bbe 100644 --- a/plugins/file-manager/src/frontend/components/ColumnVisibilityMenu.tsx +++ b/plugins/file-manager/src/frontend/components/ColumnVisibilityMenu.tsx @@ -1,7 +1,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { Check } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { cn } from "@/lib/utils.ts"; +import { cn } from "@termix/plugin-sdk/ui"; import type { ResizableColumns } from "../hooks/useResizableColumns.ts"; interface ColumnVisibilityMenuProps { diff --git a/plugins/file-manager/src/frontend/components/DiffViewer.tsx b/plugins/file-manager/src/frontend/components/DiffViewer.tsx index 5b1979078..c8c16d213 100644 --- a/plugins/file-manager/src/frontend/components/DiffViewer.tsx +++ b/plugins/file-manager/src/frontend/components/DiffViewer.tsx @@ -18,7 +18,7 @@ import { getSSHStatus, connectSSH, } from "../api/ssh-file-operations-api"; -import type { FileItem, SSHHost } from "@/types"; +import type { FileItem, SSHHost } from "../host-types"; interface DiffViewerProps { file1: FileItem; diff --git a/plugins/file-manager/src/frontend/components/DiffWindow.tsx b/plugins/file-manager/src/frontend/components/DiffWindow.tsx index dfc358793..a91756d52 100644 --- a/plugins/file-manager/src/frontend/components/DiffWindow.tsx +++ b/plugins/file-manager/src/frontend/components/DiffWindow.tsx @@ -3,7 +3,7 @@ import { DraggableWindow } from "./DraggableWindow.tsx"; import { DiffViewer } from "./DiffViewer.tsx"; import { useWindowManager } from "./WindowManager.tsx"; import { useTranslation } from "react-i18next"; -import type { FileItem, SSHHost } from "@/types/index"; +import type { FileItem, SSHHost } from "../host-types"; interface DiffWindowProps { windowId: string; diff --git a/plugins/file-manager/src/frontend/components/DownloadProgressToast.tsx b/plugins/file-manager/src/frontend/components/DownloadProgressToast.tsx index 7c6927e23..747f951d0 100644 --- a/plugins/file-manager/src/frontend/components/DownloadProgressToast.tsx +++ b/plugins/file-manager/src/frontend/components/DownloadProgressToast.tsx @@ -1,4 +1,4 @@ -import { formatTransferMbPerSec } from "@/main-axios.ts"; +import { formatTransferMbPerSec } from "../api/transfer-api"; import { useTranslation } from "react-i18next"; import { ArrowDownToLine } from "lucide-react"; import { TransferProgressBar } from "./TransferProgressBar"; diff --git a/plugins/file-manager/src/frontend/components/DraggableWindow.tsx b/plugins/file-manager/src/frontend/components/DraggableWindow.tsx index c835f784b..96824fb90 100644 --- a/plugins/file-manager/src/frontend/components/DraggableWindow.tsx +++ b/plugins/file-manager/src/frontend/components/DraggableWindow.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef, useCallback, useEffect } from "react"; -import { cn } from "@/lib/utils.ts"; +import { cn } from "@termix/plugin-sdk/ui"; import { Minus, X, Maximize2, Minimize2 } from "lucide-react"; import { useTranslation } from "react-i18next"; diff --git a/plugins/file-manager/src/frontend/components/FileViewer.tsx b/plugins/file-manager/src/frontend/components/FileViewer.tsx index 449be13b8..3bc4339cb 100644 --- a/plugins/file-manager/src/frontend/components/FileViewer.tsx +++ b/plugins/file-manager/src/frontend/components/FileViewer.tsx @@ -1,6 +1,6 @@ /* eslint-disable react-hooks/exhaustive-deps */ import React, { Suspense, lazy, useState, useEffect, useRef } from "react"; -import { cn } from "@/lib/utils.ts"; +import { cn } from "@termix/plugin-sdk/ui"; import { useTranslation } from "react-i18next"; import { FileText, @@ -43,7 +43,7 @@ import { SiDocker, } from "react-icons/si"; import { Button } from "@termix/plugin-sdk/ui"; -import { Kbd, KbdKey } from "@/components/kbd.tsx"; +import { Kbd, KbdKey } from "@termix/plugin-sdk/ui"; import type { CodeEditorHandle } from "./CodeEditor.tsx"; import { loadAudioPreview, diff --git a/plugins/file-manager/src/frontend/components/FileWindow.tsx b/plugins/file-manager/src/frontend/components/FileWindow.tsx index 1535a581c..a0cfab6a8 100644 --- a/plugins/file-manager/src/frontend/components/FileWindow.tsx +++ b/plugins/file-manager/src/frontend/components/FileWindow.tsx @@ -11,7 +11,7 @@ import { connectSSH, } from "../api/ssh-file-operations-api"; import { toast } from "sonner"; -import type { SSHHost } from "@/types/index"; +import type { SSHHost } from "../host-types"; import { useTranslation } from "react-i18next"; interface FileItem { diff --git a/src/ui/components/folder.tsx b/plugins/file-manager/src/frontend/components/FolderTree.tsx similarity index 99% rename from src/ui/components/folder.tsx rename to plugins/file-manager/src/frontend/components/FolderTree.tsx index 7ca7d4a40..9e6122f59 100644 --- a/src/ui/components/folder.tsx +++ b/plugins/file-manager/src/frontend/components/FolderTree.tsx @@ -20,7 +20,7 @@ import { File, type LucideIcon, } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { cn } from "@termix/plugin-sdk/ui"; const animationVariants: Variants = { rootInitial: { opacity: 0, y: 20 }, diff --git a/plugins/file-manager/src/frontend/components/PaneResizeHandle.tsx b/plugins/file-manager/src/frontend/components/PaneResizeHandle.tsx index 76f080486..a00592f4f 100644 --- a/plugins/file-manager/src/frontend/components/PaneResizeHandle.tsx +++ b/plugins/file-manager/src/frontend/components/PaneResizeHandle.tsx @@ -1,5 +1,5 @@ import type React from "react"; -import { cn } from "@/lib/utils.ts"; +import { cn } from "@termix/plugin-sdk/ui"; interface PaneResizeHandleProps { label: string; diff --git a/plugins/file-manager/src/frontend/components/TerminalWindow.tsx b/plugins/file-manager/src/frontend/components/TerminalWindow.tsx index 80258b267..e748ff5a2 100644 --- a/plugins/file-manager/src/frontend/components/TerminalWindow.tsx +++ b/plugins/file-manager/src/frontend/components/TerminalWindow.tsx @@ -4,7 +4,7 @@ import type { TabHandle as TerminalHandle } from "@termix/plugin-sdk/frontend"; import { PluginComponent } from "@termix/plugin-sdk/ui"; import { useWindowManager } from "./WindowManager.tsx"; import { useTranslation } from "react-i18next"; -import type { SSHHost } from "@/types/index.ts"; +import type { SSHHost } from "../host-types"; import { ExternalLink } from "lucide-react"; interface TerminalWindowProps { diff --git a/plugins/file-manager/src/frontend/components/TransferProgressBar.tsx b/plugins/file-manager/src/frontend/components/TransferProgressBar.tsx index 505dee413..362b3200b 100644 --- a/plugins/file-manager/src/frontend/components/TransferProgressBar.tsx +++ b/plugins/file-manager/src/frontend/components/TransferProgressBar.tsx @@ -1,6 +1,6 @@ import type { CSSProperties } from "react"; -import { cn } from "@/lib/utils"; +import { cn } from "@termix/plugin-sdk/ui"; export function TransferProgressBar({ value, diff --git a/plugins/file-manager/src/frontend/components/TransferProgressToast.tsx b/plugins/file-manager/src/frontend/components/TransferProgressToast.tsx index ac7747bd2..9b6746bde 100644 --- a/plugins/file-manager/src/frontend/components/TransferProgressToast.tsx +++ b/plugins/file-manager/src/frontend/components/TransferProgressToast.tsx @@ -3,7 +3,7 @@ import { formatTransferMbPerSec, getTransferProgressPercent, type TransferProgressResponse, -} from "@/main-axios.ts"; +} from "../api/transfer-api"; import { useTranslation } from "react-i18next"; import { Archive, diff --git a/plugins/file-manager/src/frontend/components/TransferToHostDialog.tsx b/plugins/file-manager/src/frontend/components/TransferToHostDialog.tsx index 12c444e97..77e3218cd 100644 --- a/plugins/file-manager/src/frontend/components/TransferToHostDialog.tsx +++ b/plugins/file-manager/src/frontend/components/TransferToHostDialog.tsx @@ -31,13 +31,13 @@ import { Trash2, } from "lucide-react"; import { - getSSHHosts, getTransferRecent, getTransferMethodPreview, type TransferDestination, type TransferMethodPreference, type TransferMethodPreview, -} from "@/main-axios.ts"; +} from "../api/transfer-api"; +import { getSSHHosts } from "../api/hosts"; import { getSSHStatus, browseSSHDirectory, @@ -49,7 +49,7 @@ import { addFolderShortcut, removeFolderShortcut, } from "../api/file-manager-data-api"; -import type { SSHHost } from "@/types"; +import type { SSHHost } from "../host-types"; interface FileItem { name: string; diff --git a/plugins/file-manager/src/frontend/file-manager-types.ts b/plugins/file-manager/src/frontend/file-manager-types.ts index c2b1f7327..bec697f42 100644 --- a/plugins/file-manager/src/frontend/file-manager-types.ts +++ b/plugins/file-manager/src/frontend/file-manager-types.ts @@ -1,5 +1,5 @@ -import type { FileItem, SSHHost } from "@/types/index"; -import type { LogEntry } from "@/types/connection-log.ts"; +import type { FileItem, SSHHost } from "./host-types"; +import type { LogEntry } from "@termix/plugin-sdk/ui"; export interface FileManagerProps { initialHost?: SSHHost | null; diff --git a/plugins/file-manager/src/frontend/file-preview-preload.ts b/plugins/file-manager/src/frontend/file-preview-preload.ts index e62491c5d..d5e0e2e48 100644 --- a/plugins/file-manager/src/frontend/file-preview-preload.ts +++ b/plugins/file-manager/src/frontend/file-preview-preload.ts @@ -1,7 +1,7 @@ import { markAdaptiveResourceUsed, runAdaptiveBackgroundTask, -} from "@/lib/adaptive-resource-budget"; +} from "@termix/plugin-sdk/ui"; export type FilePreviewKind = | "image" diff --git a/plugins/file-manager/src/frontend/homepage/FileManagerWidget.tsx b/plugins/file-manager/src/frontend/homepage/FileManagerWidget.tsx index 8df121137..579985f48 100644 --- a/plugins/file-manager/src/frontend/homepage/FileManagerWidget.tsx +++ b/plugins/file-manager/src/frontend/homepage/FileManagerWidget.tsx @@ -6,7 +6,7 @@ import type { FileManagerWidgetConfig, WidgetComponentProps, } from "./homepage.js"; -import type { SSHHost } from "@/types/index"; +import type { SSHHost } from "../host-types"; import { FileManager } from "../FileManager"; export function FileManagerWidget({ diff --git a/plugins/file-manager/src/frontend/hooks/useDragToDesktop.ts b/plugins/file-manager/src/frontend/hooks/useDragToDesktop.ts index 54692c457..83a4d9522 100644 --- a/plugins/file-manager/src/frontend/hooks/useDragToDesktop.ts +++ b/plugins/file-manager/src/frontend/hooks/useDragToDesktop.ts @@ -1,7 +1,7 @@ import { useState, useCallback } from "react"; import { toast } from "sonner"; import { downloadSSHFile } from "../api/ssh-file-operations-api"; -import type { FileItem, SSHHost } from "@/types/index.js"; +import type { FileItem, SSHHost } from "../host-types"; interface DragToDesktopState { isDragging: boolean; diff --git a/plugins/file-manager/src/frontend/hooks/useDragToSystemDesktop.ts b/plugins/file-manager/src/frontend/hooks/useDragToSystemDesktop.ts index 8820f1cd5..3b3a9b351 100644 --- a/plugins/file-manager/src/frontend/hooks/useDragToSystemDesktop.ts +++ b/plugins/file-manager/src/frontend/hooks/useDragToSystemDesktop.ts @@ -2,7 +2,7 @@ import { useState, useCallback, useRef } from "react"; import { toast } from "sonner"; import { downloadSSHFile } from "../api/ssh-file-operations-api"; -import type { FileItem, SSHHost } from "@/types/index.js"; +import type { FileItem, SSHHost } from "../host-types"; interface DragToSystemState { isDragging: boolean; diff --git a/plugins/file-manager/src/frontend/hooks/useLocalTransfers.ts b/plugins/file-manager/src/frontend/hooks/useLocalTransfers.ts index 3171eb7ad..4e67cac32 100644 --- a/plugins/file-manager/src/frontend/hooks/useLocalTransfers.ts +++ b/plugins/file-manager/src/frontend/hooks/useLocalTransfers.ts @@ -2,8 +2,8 @@ import { useCallback, useRef } from "react"; import { createElement } from "react"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; -import type { FileItem } from "@/types/index"; -import type { LocalWalkResult } from "@/types/electron"; +import type { FileItem } from "../host-types"; +import type { LocalWalkResult } from "@termix/plugin-sdk/ui"; import { cancelLocalTransfer, createLocalTransferId, @@ -16,7 +16,7 @@ import { getLocalHome, localPathsExist, walkLocalPaths, -} from "@/lib/local-files.ts"; +} from "../lib/local-files"; import { UnsafeLocalNameError, buildLocalDestination, diff --git a/plugins/file-manager/src/frontend/host-types.ts b/plugins/file-manager/src/frontend/host-types.ts new file mode 100644 index 000000000..74f891c80 --- /dev/null +++ b/plugins/file-manager/src/frontend/host-types.ts @@ -0,0 +1,57 @@ +/** A host as the shell hands it to the file manager. */ +export interface SSHHost { + id: number; + name: string; + ip: string; + port: number; + username: string; + folder: string; + tags: string[]; + pin: boolean; + authType: string; + password?: string; + key?: string; + keyPassword?: string; + keyType?: string; + sudoPassword?: string; + forceKeyboardInteractive?: boolean; + credentialId?: number; + overrideCredentialUsername?: boolean; + userId?: string; + jumpHosts?: Array<{ hostId: number }>; + terminalConfig?: Record; + notes?: string; + useSocks5?: boolean; + socks5Host?: string; + socks5Port?: number; + socks5Username?: string; + socks5Password?: string; + socks5ProxyChain?: unknown[]; + connectionType?: string; + enableSsh?: boolean; + syncId?: string | null; + createdAt: string; + updatedAt: string; + connectionOrigin?: "local" | "remote" | null; + instanceId?: string; + isShared?: boolean; + permissionLevel?: "connect" | "view" | "edit" | "manage"; + /** Enabled plugins' host-scope settings, keyed by plugin id. */ + pluginSettings?: Record>; +} + +export interface FileItem { + name: string; + path: string; + isPinned?: boolean; + type: "file" | "directory" | "link"; + sshSessionId?: string; + size?: number; + modified?: string; + modifiedTimestamp?: number; + permissions?: string; + owner?: string; + group?: string; + linkTarget?: string; + executable?: boolean; +} diff --git a/plugins/file-manager/src/frontend/index.tsx b/plugins/file-manager/src/frontend/index.tsx index fa0213523..4cd2c77f3 100644 --- a/plugins/file-manager/src/frontend/index.tsx +++ b/plugins/file-manager/src/frontend/index.tsx @@ -15,6 +15,7 @@ import { SftpTransferTab } from "./SftpTransferTab.tsx"; import { FileManagerWidget } from "./homepage/FileManagerWidget.tsx"; import { FileManagerWidgetEditForm } from "./homepage/FileManagerWidgetEditForm.tsx"; import { startTransferMonitor } from "./TransferMonitor.tsx"; +import { setFileManagerApp } from "./api/client"; function FilesTab({ tab, host, sshHost, isVisible }: TabProps) { const data = tab.data as @@ -66,6 +67,8 @@ function openEditorAction( } export function activate(app: TermixApp): void { + setFileManagerApp(app); + app.onDispose(() => setFileManagerApp(null)); app.registerTab("files", FilesTab as unknown as ComponentType, { icon: FolderSearch, titleKey: "nav.files", @@ -104,6 +107,7 @@ export function activate(app: TermixApp): void { order: 20, tabType: "files", copyUrlView: "file-manager", + quickConnect: true, when: (host) => !!host.enableSsh && fileManagerHostSetting(host, "enableFileManager", true), @@ -122,10 +126,11 @@ export function activate(app: TermixApp): void { editFormComponent: FileManagerWidgetEditForm as never, }); - app.registerAction("files.openHost", (( - host: PluginHostRecord | null, - path?: string, - ) => openHostAction(app, host, path)) as never); + 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); app.registerAction("files.openEditor", (( host: PluginHostRecord | null, filePath: string, diff --git a/plugins/file-manager/src/frontend/lib/error-message.ts b/plugins/file-manager/src/frontend/lib/error-message.ts new file mode 100644 index 000000000..393de3d64 --- /dev/null +++ b/plugins/file-manager/src/frontend/lib/error-message.ts @@ -0,0 +1,14 @@ +/** + * Extract a stable, human-readable message from an unknown thrown value. + * + * A thrown value is not guaranteed to be an `Error` — libraries may throw + * strings or plain objects, which carry no useful `.message`. When that + * happens the caller gets `fallback` instead, so error paths never surface a + * raw `[object Object]` in logs or UI. + */ +export function getErrorMessage( + error: unknown, + fallback = "Unknown error", +): string { + return error instanceof Error ? error.message : fallback; +} diff --git a/plugins/file-manager/src/frontend/lib/file-content-request-cache.ts b/plugins/file-manager/src/frontend/lib/file-content-request-cache.ts index e17c88022..0937cd2d4 100644 --- a/plugins/file-manager/src/frontend/lib/file-content-request-cache.ts +++ b/plugins/file-manager/src/frontend/lib/file-content-request-cache.ts @@ -1,5 +1,5 @@ -import type { FileItem } from "@/types/index"; -import { createKeyedRequestCache } from "@/lib/keyed-request-cache"; +import type { FileItem } from "../host-types"; +import { createKeyedRequestCache } from "./keyed-request-cache"; export interface FileContentResult { content: string; diff --git a/plugins/file-manager/src/frontend/lib/file-list-request-cache.ts b/plugins/file-manager/src/frontend/lib/file-list-request-cache.ts index 85905f678..711b832e9 100644 --- a/plugins/file-manager/src/frontend/lib/file-list-request-cache.ts +++ b/plugins/file-manager/src/frontend/lib/file-list-request-cache.ts @@ -1,5 +1,5 @@ -import type { FileItem } from "@/types/index"; -import { createKeyedRequestCache } from "@/lib/keyed-request-cache"; +import type { FileItem } from "../host-types"; +import { createKeyedRequestCache } from "./keyed-request-cache"; export interface FileListResult { files: FileItem[]; diff --git a/src/ui/lib/http-error.ts b/plugins/file-manager/src/frontend/lib/http-error.ts similarity index 100% rename from src/ui/lib/http-error.ts rename to plugins/file-manager/src/frontend/lib/http-error.ts diff --git a/src/ui/lib/keyed-request-cache.ts b/plugins/file-manager/src/frontend/lib/keyed-request-cache.ts similarity index 100% rename from src/ui/lib/keyed-request-cache.ts rename to plugins/file-manager/src/frontend/lib/keyed-request-cache.ts diff --git a/src/ui/lib/local-files.ts b/plugins/file-manager/src/frontend/lib/local-files.ts similarity index 97% rename from src/ui/lib/local-files.ts rename to plugins/file-manager/src/frontend/lib/local-files.ts index 8c68bfc2b..5a3be31c3 100644 --- a/src/ui/lib/local-files.ts +++ b/plugins/file-manager/src/frontend/lib/local-files.ts @@ -8,8 +8,8 @@ import type { LocalFsResult, LocalTrashResult, LocalWalkResult, -} from "@/types/electron"; -import { isElectron } from "./electron"; +} from "@termix/plugin-sdk/ui"; +import { isElectron } from "@termix/plugin-sdk/ui"; export function isLocalFileBrowserAvailable(): boolean { if (!isElectron()) return false; diff --git a/plugins/file-manager/src/frontend/local-transfer-utils.ts b/plugins/file-manager/src/frontend/local-transfer-utils.ts index 5388f7b88..75624a45d 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 "@/types/electron"; +import type { LocalFileEntry } from "@termix/plugin-sdk/ui"; /** 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/src/frontend/optimistic-file-list.ts b/plugins/file-manager/src/frontend/optimistic-file-list.ts index 404206246..d85a02f1b 100644 --- a/plugins/file-manager/src/frontend/optimistic-file-list.ts +++ b/plugins/file-manager/src/frontend/optimistic-file-list.ts @@ -1,4 +1,4 @@ -import type { FileItem } from "@/types/index"; +import type { FileItem } from "./host-types"; export function childPath(parent: string, name: string): string { return parent === "/" ? `/${name}` : `${parent.replace(/\/$/, "")}/${name}`; diff --git a/plugins/file-manager/src/frontend/transferMetricsFormat.ts b/plugins/file-manager/src/frontend/transferMetricsFormat.ts index fadad3efc..d9ff1dba1 100644 --- a/plugins/file-manager/src/frontend/transferMetricsFormat.ts +++ b/plugins/file-manager/src/frontend/transferMetricsFormat.ts @@ -3,7 +3,7 @@ import { formatDurationMs, formatTransferMbPerSec, type TransferTimings, -} from "@/main-axios.ts"; +} from "./api/transfer-api"; export function createFormatTransferMetrics(t: TFunction) { return (timings?: TransferTimings): string => { diff --git a/plugins/file-manager/src/frontend/transferProgressMonitor.tsx b/plugins/file-manager/src/frontend/transferProgressMonitor.tsx index 6edc8e945..036deea06 100644 --- a/plugins/file-manager/src/frontend/transferProgressMonitor.tsx +++ b/plugins/file-manager/src/frontend/transferProgressMonitor.tsx @@ -1,4 +1,4 @@ -import { getErrorMessage } from "@/lib/error-message.js"; +import { getErrorMessage } from "./lib/error-message"; import { toast } from "sonner"; import type { TFunction } from "i18next"; import { @@ -9,7 +9,7 @@ import { createTransferProgressTracker, type TransferProgressResponse, type TransferTimings, -} from "@/main-axios.ts"; +} from "./api/transfer-api"; import { TransferProgressToast } from "./components/TransferProgressToast.tsx"; import { markTransferNotified, diff --git a/plugins/file-manager/tests/frontend/LocalFilePane.test.tsx b/plugins/file-manager/tests/frontend/LocalFilePane.test.tsx index 353782eb0..0894af42e 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 "@/types/electron"; +import type { LocalFileEntry } from "@termix/plugin-sdk/ui"; // 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/SftpTransferTab.test.tsx b/plugins/file-manager/tests/frontend/SftpTransferTab.test.tsx index 6165233e5..53ca7522c 100644 --- a/plugins/file-manager/tests/frontend/SftpTransferTab.test.tsx +++ b/plugins/file-manager/tests/frontend/SftpTransferTab.test.tsx @@ -19,13 +19,16 @@ const api = vi.hoisted(() => ({ beginTransferProgressMonitoring: vi.fn(), })); -vi.mock("@/main-axios", () => ({ +vi.mock("../../src/frontend/api/transfer-api", () => ({ addTransferRecent: api.addTransferRecent, - getSSHHosts: api.getSSHHosts, getTransferProgressPercent: api.getTransferProgressPercent, transferToHost: api.transferToHost, })); +vi.mock("../../src/frontend/api/hosts", () => ({ + getSSHHosts: api.getSSHHosts, +})); + vi.mock("../../src/frontend/api/ssh-file-operations-api", () => ({ browseSSHDirectory: api.browseSSHDirectory, changeSSHPermissions: api.changeSSHPermissions, diff --git a/plugins/file-manager/tests/frontend/activate.test.tsx b/plugins/file-manager/tests/frontend/activate.test.tsx index 510b66f00..b8fde657f 100644 --- a/plugins/file-manager/tests/frontend/activate.test.tsx +++ b/plugins/file-manager/tests/frontend/activate.test.tsx @@ -57,11 +57,12 @@ describe(`${manifest.id} activate`, () => { expect(rendered.registered.hostEditorSections()).toEqual([]); }); - it("registers files.openHost and files.openEditor actions", async () => { + it("registers its open actions, including the shell's host.openFiles", async () => { rendered = await renderWithApp(plugin, { manifest, locales }); expect(rendered.registered.actions().sort()).toEqual([ "files.openEditor", "files.openHost", + "host.openFiles", ]); }); diff --git a/plugins/file-manager/tests/frontend/api/ssh-file-operations-api.test.ts b/plugins/file-manager/tests/frontend/api/ssh-file-operations-api.test.ts index 9a42cccf6..3fe272c12 100644 --- a/plugins/file-manager/tests/frontend/api/ssh-file-operations-api.test.ts +++ b/plugins/file-manager/tests/frontend/api/ssh-file-operations-api.test.ts @@ -4,9 +4,8 @@ const fileManagerApiMock = vi.hoisted(() => ({ post: vi.fn(async () => ({ data: { complete: false } })), })); -vi.mock("@/main-axios", () => ({ - authApi: { defaults: {} }, - fileManagerApi: { defaults: {} }, +vi.mock("../../../src/frontend/api/client", () => ({ + fileManagerApi: () => ({ defaults: {} }), getFileManagerApiForSession: () => fileManagerApiMock, handleApiError: (error: unknown) => { throw error; @@ -14,19 +13,20 @@ vi.mock("@/main-axios", () => ({ setSessionOrigin: vi.fn(), clearSessionOrigin: vi.fn(), })); -vi.mock("@/lib/connection-origin", () => ({ +vi.mock("@termix/plugin-sdk/ui", async (importOriginal) => ({ + ...(await importOriginal()), resolveConnectionOrigin: vi.fn(), -})); -vi.mock("@/lib/frontend-logger", () => ({ - fileLogger: { + createFrontendLogger: () => ({ info: vi.fn(), success: vi.fn(), - }, + warn: vi.fn(), + error: vi.fn(), + }), })); -vi.mock("@/lib/file-list-request-cache", () => ({ +vi.mock("../../../src/frontend/lib/file-list-request-cache", () => ({ getCachedFileList: vi.fn(), })); -vi.mock("@/lib/file-content-request-cache", () => ({ +vi.mock("../../../src/frontend/lib/file-content-request-cache", () => ({ getCachedFileContent: vi.fn(), invalidateCachedFileContent: vi.fn(), })); diff --git a/src/ui/tests/lib/keyed-request-cache.test.ts b/plugins/file-manager/tests/frontend/lib/keyed-request-cache.test.ts similarity index 96% rename from src/ui/tests/lib/keyed-request-cache.test.ts rename to plugins/file-manager/tests/frontend/lib/keyed-request-cache.test.ts index f922e74a6..fc25574d5 100644 --- a/src/ui/tests/lib/keyed-request-cache.test.ts +++ b/plugins/file-manager/tests/frontend/lib/keyed-request-cache.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { createKeyedRequestCache } from "../../lib/keyed-request-cache"; +import { createKeyedRequestCache } from "../../../src/frontend/lib/keyed-request-cache"; afterEach(() => vi.useRealTimers()); 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 793168316..13bf587c2 100644 --- a/plugins/file-manager/tests/frontend/local-transfer-utils.test.ts +++ b/plugins/file-manager/tests/frontend/local-transfer-utils.test.ts @@ -26,7 +26,7 @@ import { serializeLocalFilesDragPayload, sortLocalEntries, } from "../../src/frontend/local-transfer-utils"; -import type { LocalFileEntry } from "@/types/electron"; +import type { LocalFileEntry } from "@termix/plugin-sdk/ui"; describe("drag payloads", () => { it("round-trips local file payloads", () => { diff --git a/plugins/file-manager/tests/frontend/optimistic-file-list.test.ts b/plugins/file-manager/tests/frontend/optimistic-file-list.test.ts index a5fa3f2dc..0dea07313 100644 --- a/plugins/file-manager/tests/frontend/optimistic-file-list.test.ts +++ b/plugins/file-manager/tests/frontend/optimistic-file-list.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { FileItem } from "@/types/index"; +import type { FileItem } from "../../src/frontend/host-types"; import { addOptimisticItem, childPath, diff --git a/plugins/file-manager/tests/frontend/transfer-progress-toast.test.ts b/plugins/file-manager/tests/frontend/transfer-progress-toast.test.ts index 354f7ac4d..f97f36639 100644 --- a/plugins/file-manager/tests/frontend/transfer-progress-toast.test.ts +++ b/plugins/file-manager/tests/frontend/transfer-progress-toast.test.ts @@ -7,7 +7,7 @@ vi.mock("sonner", () => ({ }, })); -vi.mock("@/main-axios.ts", () => ({})); +vi.mock("../../src/frontend/api/transfer-api", () => ({})); vi.mock("../../src/frontend/transferNotificationStore.ts", () => ({ markTransferNotified: vi.fn(), })); diff --git a/plugins/file-manager/tsconfig.json b/plugins/file-manager/tsconfig.json index 1d3e42542..7b35314c3 100644 --- a/plugins/file-manager/tsconfig.json +++ b/plugins/file-manager/tsconfig.json @@ -1,10 +1,4 @@ { "extends": "@termix/plugin-sdk/tsconfig.plugin.json", - "compilerOptions": { - "paths": { - "@/types/*": ["../../src/types/*"], - "@/*": ["../../src/ui/*"] - } - }, "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"] } diff --git a/plugins/fleets/package.json b/plugins/fleets/package.json index 441d6590e..30add508c 100644 --- a/plugins/fleets/package.json +++ b/plugins/fleets/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "validate": "termix-plugin validate" }, "devDependencies": { diff --git a/plugins/fleets/tsconfig.json b/plugins/fleets/tsconfig.json index 1d3e42542..7b35314c3 100644 --- a/plugins/fleets/tsconfig.json +++ b/plugins/fleets/tsconfig.json @@ -1,10 +1,4 @@ { "extends": "@termix/plugin-sdk/tsconfig.plugin.json", - "compilerOptions": { - "paths": { - "@/types/*": ["../../src/types/*"], - "@/*": ["../../src/ui/*"] - } - }, "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"] } diff --git a/plugins/homepage/package.json b/plugins/homepage/package.json index cdd889c5c..4a43a1612 100644 --- a/plugins/homepage/package.json +++ b/plugins/homepage/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "validate": "termix-plugin validate" }, "devDependencies": { diff --git a/plugins/homepage/src/frontend/HomepageApp.tsx b/plugins/homepage/src/frontend/HomepageApp.tsx deleted file mode 100644 index dc6aafb1a..000000000 --- a/plugins/homepage/src/frontend/HomepageApp.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from "react"; -import { HomepageCanvas } from "./HomepageCanvas"; - -const HomepageApp: React.FC = () => { - return ( -
- -
- ); -}; - -export default HomepageApp; diff --git a/plugins/homepage/src/frontend/registry.ts b/plugins/homepage/src/frontend/registry.ts deleted file mode 100644 index 6325943d1..000000000 --- a/plugins/homepage/src/frontend/registry.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { useSyncExternalStore } from "react"; - -/** - * A keyed, reactive list of contributions. - * - * Widgets register on import, so anything rendered from a registry has to - * re-render when it changes. The snapshot array is cached until the next - * change because useSyncExternalStore compares by identity and loops forever - * on a fresh array. - */ -export interface Registry { - /** Adds or replaces by id. The disposer only removes this exact entry. */ - register: (item: T) => () => void; - unregister: (id: string) => void; - get: (id: string) => T | undefined; - list: () => T[]; - subscribe: (listener: () => void) => () => void; - /** React hook over list(). */ - useList: () => T[]; - reset: () => void; -} - -export function createRegistry( - sort?: (a: T, b: T) => number, -): Registry { - const items = new Map(); - const listeners = new Set<() => void>(); - let snapshot: T[] | null = null; - - const emit = () => { - snapshot = null; - for (const listener of listeners) listener(); - }; - - const list = () => { - if (!snapshot) { - snapshot = [...items.values()]; - if (sort) snapshot.sort(sort); - } - return snapshot; - }; - - const subscribe = (listener: () => void) => { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - }; - - return { - register(item) { - items.set(item.id, item); - emit(); - return () => { - if (items.get(item.id) === item) { - items.delete(item.id); - emit(); - } - }; - }, - unregister(id) { - if (items.delete(id)) emit(); - }, - get(id) { - return items.get(id); - }, - list, - subscribe, - useList() { - return useSyncExternalStore(subscribe, list, list); - }, - reset() { - items.clear(); - emit(); - }, - }; -} diff --git a/plugins/host-metrics/package.json b/plugins/host-metrics/package.json index 70d648946..44c8106c8 100644 --- a/plugins/host-metrics/package.json +++ b/plugins/host-metrics/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "validate": "termix-plugin validate" }, "devDependencies": { diff --git a/plugins/ldap/package.json b/plugins/ldap/package.json index 3a958ff0b..03b6a1128 100644 --- a/plugins/ldap/package.json +++ b/plugins/ldap/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "validate": "termix-plugin validate" }, "dependencies": { diff --git a/plugins/network-topology/package.json b/plugins/network-topology/package.json index d4e7516d2..8f76fea6f 100644 --- a/plugins/network-topology/package.json +++ b/plugins/network-topology/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "validate": "termix-plugin validate" }, "dependencies": { diff --git a/plugins/network-topology/tsconfig.json b/plugins/network-topology/tsconfig.json index 1d3e42542..7b35314c3 100644 --- a/plugins/network-topology/tsconfig.json +++ b/plugins/network-topology/tsconfig.json @@ -1,10 +1,4 @@ { "extends": "@termix/plugin-sdk/tsconfig.plugin.json", - "compilerOptions": { - "paths": { - "@/types/*": ["../../src/types/*"], - "@/*": ["../../src/ui/*"] - } - }, "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"] } diff --git a/plugins/opkssh/manifest.json b/plugins/opkssh/manifest.json index ce88ce586..b2e5347a2 100644 --- a/plugins/opkssh/manifest.json +++ b/plugins/opkssh/manifest.json @@ -44,6 +44,14 @@ "default": false } ] + }, + "http": { + "legacyRedirects": [ + { + "from": "/host/opkssh-callback", + "to": "/callback" + } + ] } }, "locales": "locales", diff --git a/plugins/opkssh/package.json b/plugins/opkssh/package.json index e6f2581f8..8ec88c708 100644 --- a/plugins/opkssh/package.json +++ b/plugins/opkssh/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "validate": "termix-plugin validate" }, "dependencies": { diff --git a/plugins/opkssh/tests/backend/legacy-urls.test.ts b/plugins/opkssh/tests/backend/legacy-urls.test.ts new file mode 100644 index 000000000..0c5ece68d --- /dev/null +++ b/plugins/opkssh/tests/backend/legacy-urls.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { validateManifest } from "@termix/plugin-sdk/manifest"; +import manifest from "../../manifest.json"; + +// Old URLs something outside Termix still calls keep reaching the plugin. +const redirects = manifest.contributes.http.legacyRedirects as unknown[]; + +describe("legacy URLs", () => { + it("declares them in a manifest that validates", () => { + expect(validateManifest(manifest)).toEqual([]); + }); + + it("redirects /host/opkssh-callback, which OPKSSH identity providers were set up with before 2.9", () => { + expect(redirects).toContainEqual({ + from: "/host/opkssh-callback", + to: "/callback", + }); + }); +}); diff --git a/plugins/proxmox/package.json b/plugins/proxmox/package.json index 4da4f136f..9edbe17c2 100644 --- a/plugins/proxmox/package.json +++ b/plugins/proxmox/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "validate": "termix-plugin validate" }, "devDependencies": { diff --git a/plugins/proxmox/src/backend/routes.ts b/plugins/proxmox/src/backend/routes.ts index 9074c1036..3b5b7cfd6 100644 --- a/plugins/proxmox/src/backend/routes.ts +++ b/plugins/proxmox/src/backend/routes.ts @@ -1004,6 +1004,63 @@ router.post("/discover", async (req, res) => { } }); +/** + * @openapi + * /plugin-api/proxmox/import: + * post: + * summary: Import discovered Proxmox guests as hosts + * description: > + * Creates one host per entry, owned by the caller, and stores each + * entry's proxmox host settings (the guest's source) on the new host. + * tags: + * - Proxmox + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - hosts + * properties: + * hosts: + * type: array + * items: + * type: object + * responses: + * 200: + * description: How many hosts were created and which failed. + * 400: + * description: No hosts given. + */ +router.post("/import", async (req, res) => { + const ctx = pluginCtx(); + const { hosts } = req.body as { hosts?: unknown }; + if (!Array.isArray(hosts) || hosts.length === 0) { + return res.status(400).json({ error: "No hosts to import" }); + } + const result = { success: 0, failed: 0, errors: [] as string[] }; + for (const entry of hosts as Array>) { + const { pluginSettings, ...input } = entry; + try { + const created = await ctx.hosts.create( + input as unknown as PluginHostCreateInput, + ); + const own = (pluginSettings as Record | undefined)?.[ + ctx.pluginId + ] as Record | undefined; + for (const [key, value] of Object.entries(own ?? {})) { + await ctx.settings.setHost(Number(created.id), key, value); + } + result.success++; + } catch (err: unknown) { + result.failed++; + result.errors.push(`${String(input.name)}: ${getErrorMessage(err)}`); + } + } + return res.json(result); +}); + /** Called from activate(). Mounts this router at /proxmox via the shared * dispatcher, and starts the background auto-sync scan (a 60s interval plus * a one-off 30s-delayed startup run, both unref'd so they never keep the diff --git a/plugins/proxmox/src/frontend/HostProxmoxTab.tsx b/plugins/proxmox/src/frontend/HostProxmoxTab.tsx index 16e7588f6..1b20da4c0 100644 --- a/plugins/proxmox/src/frontend/HostProxmoxTab.tsx +++ b/plugins/proxmox/src/frontend/HostProxmoxTab.tsx @@ -1,10 +1,14 @@ import { useEffect, useState } from "react"; import { useSshAuthTypes, useTranslation } from "@termix/plugin-sdk/frontend"; import { Server } from "lucide-react"; -import { Input } from "@/components/input"; -import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card"; -import { getCredentials } from "@/main-axios"; -import { Select2 } from "@/components/select2"; +import { + FakeSwitch, + Input, + SectionCard, + Select2, + SettingRow, +} from "@termix/plugin-sdk/ui"; +import { listCredentials } from "@termix/plugin-sdk/frontend"; import type { ProxmoxHostConfig } from "./types"; interface HostProxmoxForm { @@ -28,19 +32,16 @@ export function HostProxmoxTab({ >([]); useEffect(() => { - getCredentials() - .then((res: unknown) => { - const raw = - (res as { credentials?: unknown })?.credentials ?? res ?? []; - const list = (Array.isArray(raw) ? raw : []).map( - (c: Record) => ({ - id: c.id as number, - name: c.name as string, - username: (c.username as string | null) ?? null, - }), - ); - setCredentials(list); - }) + listCredentials() + .then((list) => + setCredentials( + list.map((c) => ({ + id: c.id, + name: c.name, + username: c.username ?? null, + })), + ), + ) .catch(() => {}); }, []); diff --git a/plugins/proxmox/src/frontend/ProxmoxDiscoverDialog.tsx b/plugins/proxmox/src/frontend/ProxmoxDiscoverDialog.tsx index d51f36220..14c25fda8 100644 --- a/plugins/proxmox/src/frontend/ProxmoxDiscoverDialog.tsx +++ b/plugins/proxmox/src/frontend/ProxmoxDiscoverDialog.tsx @@ -1,31 +1,26 @@ import { useRef, useState } from "react"; -import type { HostData } from "@/types/index"; import { useTranslation } from "@termix/plugin-sdk/frontend"; import { Server, RefreshCw, CheckSquare, Square, Download } from "lucide-react"; import { toast } from "sonner"; -import { Button } from "@/components/button"; +import { Button } from "@termix/plugin-sdk/ui"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, -} from "@/components/dialog"; -import { Select2 } from "@/components/select2"; -import { - discoverProxmoxGuestsStream, - bulkImportSSHHosts, - getSSHHosts, -} from "@/main-axios"; -import type { SSHHostWithStatus } from "@/main-axios"; -import type { ProxmoxGuest } from "@/types/proxmox"; +} from "@termix/plugin-sdk/ui"; +import { Select2 } from "@termix/plugin-sdk/ui"; +import { discoverProxmoxGuestsStream, importProxmoxHosts } from "./proxmox-api"; +import { listHosts, type PluginHostRecord } from "@termix/plugin-sdk/frontend"; +import type { ProxmoxGuest } from "./types"; import { resolveProxmoxImportAuth } from "./proxmox-import-auth"; interface ProxmoxDiscoverDialogProps { open: boolean; onClose: () => void; - hosts: SSHHostWithStatus[]; - onHostsChanged: (hosts: SSHHostWithStatus[]) => void; + hosts: PluginHostRecord[]; + onHostsChanged: (hosts: PluginHostRecord[]) => void; /** Pre-select a specific host and skip the host picker */ preselectedHostId?: number; /** Credential to use for imported hosts */ @@ -69,7 +64,11 @@ export function ProxmoxDiscoverDialog({ // When opened from the dropdown (no preselectedHostId), only show Proxmox-enabled hosts const sshHosts = hosts.filter( (h) => - !("isFolder" in h) && h.pluginSettings?.proxmox?.enableProxmox === true, + !("isFolder" in h) && + ( + h.pluginSettings as + Record | undefined> | undefined + )?.proxmox?.enableProxmox === true, ); // The Proxmox host the discovery runs against — imported guests are grouped @@ -189,11 +188,11 @@ export function ProxmoxDiscoverDialog({ })); const result = toImport.length - ? await bulkImportSSHHosts(toImport as unknown as HostData[], false) - : { success: 0, updated: 0, skipped: 0, failed: 0 }; + ? await importProxmoxHosts(toImport) + : { success: 0, failed: 0, errors: [] }; if (toImport.length) { - const updated = await getSSHHosts(); + const updated = await listHosts(); onHostsChanged(updated); window.dispatchEvent(new CustomEvent("termix:hosts-changed")); } @@ -202,9 +201,6 @@ export function ProxmoxDiscoverDialog({ result.success ? t("hosts.proxmoxResultImported", { count: result.success }) : null, - result.updated - ? t("hosts.proxmoxResultUpdated", { count: result.updated }) - : null, result.failed ? t("hosts.proxmoxResultFailed", { count: result.failed }) : null, diff --git a/plugins/proxmox/src/frontend/index.tsx b/plugins/proxmox/src/frontend/index.tsx index d6eb6e6f4..640cebd41 100644 --- a/plugins/proxmox/src/frontend/index.tsx +++ b/plugins/proxmox/src/frontend/index.tsx @@ -9,13 +9,13 @@ import { type TermixApp, } from "@termix/plugin-sdk/frontend"; import { ComponentSlot, DropdownMenuItem } from "@termix/plugin-sdk/ui"; -import type { SSHHostWithStatus } from "@/main-axios"; +import type { PluginHostRecord } from "@termix/plugin-sdk/frontend"; +import { setProxmoxApp } from "./proxmox-api"; import { ProxmoxDiscoverDialog } from "./ProxmoxDiscoverDialog"; import { HostProxmoxTab } from "./HostProxmoxTab"; import { ProxmoxStatsTab } from "./stats/ProxmoxStatsTab"; import ProxmoxStatsApp from "./stats/ProxmoxStatsApp"; import { HostProxmoxStatsTab } from "./stats/HostProxmoxStatsTab"; -import type { SSHHost } from "@/types"; interface DiscoverRequest { hostId?: number; @@ -79,7 +79,7 @@ function ProxmoxHostSection({ form, updateForm }: HostEditorSectionProps) { function ProxmoxStatsTabView({ sshHost, label, isVisible }: TabProps) { return ( setProxmoxApp(null)); // The dialog lives in the hosts panel; the menu item and host action open // it through this, created per activation. const openers = new Set(); @@ -104,8 +106,8 @@ export function activate(app: TermixApp): void { hosts, onHostsChanged, }: { - hosts: SSHHostWithStatus[]; - onHostsChanged: (hosts: SSHHostWithStatus[]) => void; + hosts: PluginHostRecord[]; + onHostsChanged: (hosts: PluginHostRecord[]) => void; }) { const [request, setRequest] = useState(null); useEffect(() => { diff --git a/plugins/proxmox/src/frontend/proxmox-api.ts b/plugins/proxmox/src/frontend/proxmox-api.ts new file mode 100644 index 000000000..4ed557aa6 --- /dev/null +++ b/plugins/proxmox/src/frontend/proxmox-api.ts @@ -0,0 +1,117 @@ +import type { TermixApp } from "@termix/plugin-sdk/frontend"; +import type { ProxmoxDiscoverResult } from "./types"; + +let current: TermixApp | null = null; + +/** Set in activate, cleared on deactivate. */ +export function setProxmoxApp(app: TermixApp | null): void { + current = app; +} + +function app(): TermixApp { + if (!current) throw new Error("The Proxmox plugin is not active"); + return current; +} + +/** + * Runs a guest discovery over the server-sent event stream and reports + * progress. Returns a function that stops listening. + */ +export function discoverProxmoxGuestsStream( + hostId: number, + handlers: { + onProgress?: (done: number, total: number) => void; + onResult: (result: ProxmoxDiscoverResult) => void; + onError: (message: string) => void; + }, +): () => void { + const controller = new AbortController(); + let settled = false; + const finish = () => { + settled = true; + controller.abort(); + }; + + const handleEvent = (event: string, data: string) => { + if (event === "progress") { + try { + const parsed = JSON.parse(data); + handlers.onProgress?.(parsed.done, parsed.total); + } catch { + // ignore malformed progress frames + } + } else if (event === "result") { + finish(); + try { + handlers.onResult(JSON.parse(data)); + } catch { + handlers.onError("Failed to parse discovery result"); + } + } else if (event === "fail") { + finish(); + let message = "Discovery failed"; + try { + message = JSON.parse(data).message || message; + } catch { + // keep default message + } + handlers.onError(message); + } + }; + + void (async () => { + try { + const response = await app().fetch( + `/discover/stream?hostId=${encodeURIComponent(String(hostId))}`, + { signal: controller.signal }, + ); + if (!response.ok || !response.body) throw new Error("stream failed"); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let split = buffer.indexOf("\n\n"); + while (split !== -1) { + const frame = buffer.slice(0, split); + buffer = buffer.slice(split + 2); + let event = "message"; + const data: string[] = []; + for (const line of frame.split("\n")) { + if (line.startsWith("event:")) event = line.slice(6).trim(); + else if (line.startsWith("data:")) data.push(line.slice(5).trim()); + } + if (data.length) handleEvent(event, data.join("\n")); + split = buffer.indexOf("\n\n"); + } + } + if (!settled) { + settled = true; + handlers.onError("Discovery connection lost"); + } + } catch { + if (settled) return; + settled = true; + handlers.onError("Discovery connection lost"); + } + })(); + + return finish; +} + +export interface ProxmoxImportResult { + success: number; + failed: number; + errors: string[]; +} + +export async function importProxmoxHosts( + hosts: Record[], +): Promise { + const response = await app().api.post("/import", { + hosts, + }); + return response.data; +} diff --git a/plugins/proxmox/src/frontend/types.ts b/plugins/proxmox/src/frontend/types.ts index 207f27b3f..806002571 100644 --- a/plugins/proxmox/src/frontend/types.ts +++ b/plugins/proxmox/src/frontend/types.ts @@ -133,3 +133,21 @@ export interface ProxmoxHostConfig { errors: string[]; }; } + +export interface ProxmoxGuest { + name: string; + vmid: number; + type: "qemu" | "lxc"; + node: string; + status: string; + ip: string | null; + connectionType: "ssh" | "rdp"; + enableDocker: boolean; +} + +export interface ProxmoxDiscoverResult { + guests: ProxmoxGuest[]; + credentialId: number | null; + defaultCredentialId: number | null; + jumpHosts?: unknown[] | null; +} diff --git a/plugins/proxmox/tsconfig.json b/plugins/proxmox/tsconfig.json index 1d3e42542..7b35314c3 100644 --- a/plugins/proxmox/tsconfig.json +++ b/plugins/proxmox/tsconfig.json @@ -1,10 +1,4 @@ { "extends": "@termix/plugin-sdk/tsconfig.plugin.json", - "compilerOptions": { - "paths": { - "@/types/*": ["../../src/types/*"], - "@/*": ["../../src/ui/*"] - } - }, "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"] } diff --git a/plugins/remote-desktop/package.json b/plugins/remote-desktop/package.json index 67a60bb55..3891aa377 100644 --- a/plugins/remote-desktop/package.json +++ b/plugins/remote-desktop/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "validate": "termix-plugin validate" }, "devDependencies": { diff --git a/plugins/secret-sources/package.json b/plugins/secret-sources/package.json index 99bed961a..2b4548984 100644 --- a/plugins/secret-sources/package.json +++ b/plugins/secret-sources/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "validate": "termix-plugin validate" }, "devDependencies": { diff --git a/plugins/secret-sources/src/frontend/index.tsx b/plugins/secret-sources/src/frontend/index.tsx index 638a05f24..c22106bff 100644 --- a/plugins/secret-sources/src/frontend/index.tsx +++ b/plugins/secret-sources/src/frontend/index.tsx @@ -3,6 +3,6 @@ import { SecretSourceManager } from "./SecretSourceManager"; import { SecretReferenceHint } from "./SecretReferenceHint"; export function activate(app: TermixApp): void { - app.registerComponent("secret-sources.hint", SecretReferenceHint); - app.registerComponent("secret-sources.manager", SecretSourceManager); + app.registerComponent("credentials.secretHint", SecretReferenceHint); + app.registerComponent("credentials.secretManager", SecretSourceManager); } diff --git a/plugins/secret-sources/tests/frontend/activate.test.tsx b/plugins/secret-sources/tests/frontend/activate.test.tsx index 8ab2626c4..a2b9d87f6 100644 --- a/plugins/secret-sources/tests/frontend/activate.test.tsx +++ b/plugins/secret-sources/tests/frontend/activate.test.tsx @@ -24,7 +24,7 @@ describe(`${manifest.id} activate`, () => { rendered = await renderWithApp(plugin, { manifest, locales }); render( fallback} />, @@ -41,7 +41,9 @@ describe(`${manifest.id} activate`, () => { } as never, }); const onClose = vi.fn(); - render(); + render( + , + ); expect(await screen.findByText(locales.title)).toBeTruthy(); }); @@ -49,7 +51,10 @@ describe(`${manifest.id} activate`, () => { const app = await renderWithApp(plugin, { manifest, locales }); await app.deactivate(); render( - gone} />, + gone} + />, ); expect(await screen.findByText("gone")).toBeTruthy(); }); diff --git a/plugins/serial/package.json b/plugins/serial/package.json index 158b66e73..8b98f203e 100644 --- a/plugins/serial/package.json +++ b/plugins/serial/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "validate": "termix-plugin validate" }, "dependencies": { diff --git a/plugins/session-recording/package.json b/plugins/session-recording/package.json index 7c8635224..9ce8dfeef 100644 --- a/plugins/session-recording/package.json +++ b/plugins/session-recording/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "validate": "termix-plugin validate" }, "devDependencies": { diff --git a/plugins/session-sharing/package.json b/plugins/session-sharing/package.json index 9e56cf4b1..d3aab6b3e 100644 --- a/plugins/session-sharing/package.json +++ b/plugins/session-sharing/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "validate": "termix-plugin validate" }, "dependencies": { diff --git a/plugins/snippets/locales/en.json b/plugins/snippets/locales/en.json index da5ef0032..85f1d2e36 100644 --- a/plugins/snippets/locales/en.json +++ b/plugins/snippets/locales/en.json @@ -48,7 +48,6 @@ "shareTitle": "Shared", "importExport": "Import / Export", "importFailed": "Failed to import snippets", - "nameLabel": "Name", "namePlaceholder": "Snippet name", "descriptionLabel": "Description", @@ -61,20 +60,17 @@ "commandPlaceholder": "Command to run, e.g. systemctl status $HOST", "noteLabel": "Note", "notePlaceholder": "Text to paste into the terminal", - "createSnippetTitle": "Create snippet", "createSnippetDescription": "Save a command or note you can run or paste into a terminal.", "createSnippetButton": "Create snippet", "createSuccess": "Snippet created", "createFailed": "Failed to create snippet", - "editSnippetTitle": "Edit snippet", "editSnippetDescription": "Change this snippet's name, content or folder.", "saveSnippetButton": "Save snippet", "updateSuccess": "Snippet updated", "updateFailed": "Failed to update snippet", "deleteFailed": "Failed to delete snippet", - "createFolderTitle": "Create folder", "createFolderButton": "Create folder", "folderCreateSuccess": "Folder created", @@ -88,11 +84,26 @@ "folderIconLabel": "Icon", "folderDeleteSuccess": "Folder deleted", "folderDeleteFailed": "Failed to delete folder", - "confirmRunMessage": "Run \"{{name}}\"?", "confirmRunButton": "Run", "runSuccess": "Ran \"{{name}}\" on {{count}} terminal(s)", "pasteSuccess": "Pasted \"{{name}}\" into {{count}} terminal(s)", - - "foldersCollapsedLabel": "Collapse folders by default" + "foldersCollapsedLabel": "Collapse folders by default", + "admin": { + "tabTitle": "Snippets", + "count": "{{count}} snippets", + "add": "Add Snippet", + "namePlaceholder": "Snippet name", + "contentPlaceholder": "Command content", + "folderPlaceholder": "Folder (optional)", + "empty": "This user has no snippets", + "deleteConfirm": "Delete snippet \"{{name}}\" belonging to {{username}}?", + "deleted": "Snippet deleted", + "deleteFailed": "Failed to delete snippet", + "requiredFields": "Snippet name and content are required", + "saved": "Snippet saved", + "saveFailed": "Failed to save snippet", + "saving": "Saving...", + "save": "Save" + } } diff --git a/plugins/snippets/package.json b/plugins/snippets/package.json index 8c6f95478..fd2493cb3 100644 --- a/plugins/snippets/package.json +++ b/plugins/snippets/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "termix-plugin build", "test": "termix-plugin test", - "typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json", + "typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json", "validate": "termix-plugin validate" }, "devDependencies": { diff --git a/plugins/snippets/src/frontend/AdminUserSnippets.tsx b/plugins/snippets/src/frontend/AdminUserSnippets.tsx new file mode 100644 index 000000000..76418bbd7 --- /dev/null +++ b/plugins/snippets/src/frontend/AdminUserSnippets.tsx @@ -0,0 +1,237 @@ +import { useCallback, useEffect, useState } from "react"; +import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { + usePluginApi, + useTranslation, + type PluginApiClient, +} from "@termix/plugin-sdk/frontend"; +import { Button, Input, useConfirmation } from "@termix/plugin-sdk/ui"; + +// Core's admin routes act on another user's data when this header names them. +export const ADMIN_TARGET_USER_HEADER = "X-Admin-Target-User"; + +export function adminOptions(targetUserId: string) { + return { headers: { [ADMIN_TARGET_USER_HEADER]: targetUserId } }; +} + +interface ManagedSnippet { + id: number; + name: string; + content: string; + folder?: string | null; +} + +export function mapSnippets(res: unknown): ManagedSnippet[] { + const list = Array.isArray(res) + ? res + : ((res as { snippets?: unknown[] })?.snippets ?? []); + return (list as Record[]).map((s) => ({ + id: Number(s.id), + name: String(s.name ?? ""), + content: String(s.content ?? ""), + folder: (s.folder as string | null) ?? null, + })); +} + +function errorMessage(error: unknown, fallback: string): string { + return ( + (error as { response?: { data?: { error?: string } } })?.response?.data + ?.error || fallback + ); +} + +async function load(api: PluginApiClient, userId: string) { + return mapSnippets((await api.get("/", adminOptions(userId))).data); +} + +/** The Snippets tab of the admin "manage user" panel. */ +export function AdminUserSnippets({ + user, +}: { + user: { id: string; username: string }; +}) { + const { t } = useTranslation(); + const api = usePluginApi(); + const { confirmWithToast } = useConfirmation(); + const [snippets, setSnippets] = useState([]); + const [loading, setLoading] = useState(true); + const [editing, setEditing] = useState(null); + const [form, setForm] = useState({ name: "", content: "", folder: "" }); + const [saving, setSaving] = useState(false); + + const reload = useCallback(() => { + setLoading(true); + load(api, user.id) + .then(setSnippets) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [api, user.id]); + + useEffect(() => { + reload(); + }, [reload]); + + async function save() { + if (!form.name.trim() || !form.content.trim()) { + toast.error(t("admin.requiredFields")); + return; + } + setSaving(true); + try { + const payload = { + name: form.name.trim(), + content: form.content, + folder: form.folder.trim() || null, + }; + if (editing && editing !== "new") { + await api.put(`/${editing.id}`, payload, adminOptions(user.id)); + } else { + await api.post("/", payload, adminOptions(user.id)); + } + setEditing(null); + reload(); + toast.success(t("admin.saved")); + } catch (error) { + toast.error(errorMessage(error, t("admin.saveFailed"))); + } finally { + setSaving(false); + } + } + + function remove(snippet: ManagedSnippet) { + confirmWithToast( + t("admin.deleteConfirm", { name: snippet.name, username: user.username }), + async () => { + try { + await api.delete(`/${snippet.id}`, adminOptions(user.id)); + setSnippets((prev) => prev.filter((s) => s.id !== snippet.id)); + toast.success(t("admin.deleted")); + } catch (error) { + toast.error(errorMessage(error, t("admin.deleteFailed"))); + } + }, + ); + } + + return ( +
+
+ + {t("admin.count", { count: snippets.length })} + +
+ + +
+
+ {editing && ( +
+ setForm((p) => ({ ...p, name: e.target.value }))} + /> +