From 5796186a1c490e6b454cf776b0f035acff18f40f Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 22 Sep 2026 15:09:50 -0500 Subject: [PATCH] feat: make each plugin an npm workspace built by a termix-plugin CLI --- .gitignore | 9 +- crowdin.yml | 2 + eslint.config.mjs | 26 ++- package-lock.json | 175 +++++++++++++---- package.json | 16 +- packages/plugin-sdk/ARCHITECTURE.md | 140 ++++++++++++-- packages/plugin-sdk/cli/commands/build.mjs | 69 +++++++ packages/plugin-sdk/cli/commands/pack.mjs | 33 ++++ packages/plugin-sdk/cli/commands/test.mjs | 33 ++++ packages/plugin-sdk/cli/commands/validate.mjs | 44 +++++ packages/plugin-sdk/cli/index.mjs | 43 +++++ packages/plugin-sdk/cli/lib/externals.mjs | 48 +++++ .../cli/lib/legacy-core-imports.mjs | 61 ++++++ packages/plugin-sdk/cli/lib/plugin-dir.mjs | 29 +++ packages/plugin-sdk/package.json | 18 +- packages/plugin-sdk/src/testing.ts | 155 ++++++++++++++- packages/plugin-sdk/src/testing/setup.ts | 68 +++++++ packages/plugin-sdk/src/vitest-preset.ts | 92 +++++++++ packages/plugin-sdk/tsconfig.json | 3 +- packages/plugin-sdk/tsconfig.plugin.json | 28 +++ plugins/ai/CHANGELOG.md | 5 + plugins/ai/README.md | 22 +++ plugins/ai/backend/index.mjs | 87 --------- plugins/ai/locales/en.json | 1 + plugins/ai/manifest.json | 2 - plugins/ai/package.json | 15 ++ plugins/ai/{ => src}/backend/context.ts | 0 plugins/ai/{ => src}/backend/egress.ts | 4 +- plugins/ai/{ => src}/backend/engine.ts | 2 +- plugins/ai/{ => src}/backend/gating.ts | 4 +- plugins/ai/src/backend/index.ts | 32 ++++ .../{ => src}/backend/providers/anthropic.ts | 0 .../ai/{ => src}/backend/providers/gemini.ts | 0 .../ai/{ => src}/backend/providers/http.ts | 4 +- .../ai/{ => src}/backend/providers/ollama.ts | 0 .../ai/{ => src}/backend/providers/openai.ts | 0 .../{ => src}/backend/providers/registry.ts | 0 .../ai/{ => src}/backend/providers/types.ts | 0 plugins/ai/{ => src}/backend/redaction.ts | 0 plugins/ai/{ => src}/backend/routes.ts | 18 +- plugins/ai/{ => src}/backend/tools/catalog.ts | 0 .../backend/tools/command-allowlist.ts | 0 .../ai/{ => src}/backend/tools/executor.ts | 12 +- .../{ => src}/backend/tools/propose-tools.ts | 0 .../ai/{ => src}/backend/tools/read-tools.ts | 2 +- plugins/ai/{ => src}/backend/tools/types.ts | 0 plugins/ai/{ => src}/frontend/index.mjs | 0 .../ai/tests/backend}/context.test.ts | 2 +- .../ai/tests/backend}/egress.test.ts | 2 +- .../ai/tests/backend}/engine.test.ts | 9 +- .../ai/tests/backend}/gating.test.ts | 4 +- .../backend}/providers/provider-http.test.ts | 8 +- .../backend}/providers/providers.test.ts | 14 +- .../ai/tests/backend}/redaction.test.ts | 6 +- .../backend}/tools/command-allowlist.test.ts | 2 +- .../backend}/tools/proposal-executor.test.ts | 19 +- .../tests/backend}/tools/tool-catalog.test.ts | 2 +- .../frontend}/AiProviderSettings.test.tsx | 0 .../ai/tests/frontend}/labels.test.ts | 2 +- plugins/ai/tsconfig.json | 10 + plugins/ai/vitest.config.ts | 3 + plugins/automations/CHANGELOG.md | 5 + plugins/automations/README.md | 22 +++ plugins/automations/backend/index.mjs | 76 -------- plugins/automations/locales/en.json | 1 + plugins/automations/manifest.json | 2 - plugins/automations/package.json | 15 ++ .../{ => src}/backend/actions/host-targets.ts | 10 +- .../{ => src}/backend/actions/index.ts | 18 +- .../{ => src}/backend/actions/types.ts | 2 +- .../{ => src}/backend/conditions.ts | 5 +- plugins/automations/{ => src}/backend/cron.ts | 0 .../{ => src}/backend/docker-watcher.ts | 16 +- .../automations/{ => src}/backend/engine.ts | 12 +- .../{ => src}/backend/headless-viewer.ts | 2 +- plugins/automations/{ => src}/backend/http.ts | 4 +- plugins/automations/src/backend/index.ts | 19 ++ .../automations/{ => src}/backend/notify.ts | 2 +- .../automations/{ => src}/backend/routes.ts | 20 +- .../{ => src}/backend/scheduler.ts | 8 +- .../automations/{ => src}/backend/template.ts | 0 .../automations/{ => src}/backend/triggers.ts | 13 +- .../{ => src}/frontend/AutomationsPanel.tsx | 0 .../{ => src}/frontend/automations-api.ts | 0 .../frontend/automations/AutomationEditor.tsx | 0 .../automations/HostSelectorField.tsx | 0 .../frontend/automations/StepBlock.tsx | 0 .../frontend/automations/TriggerCard.tsx | 0 .../frontend/automations/editor-types.ts | 0 .../automations/{ => src}/frontend/index.mjs | 0 .../tests/backend}/conditions.test.ts | 2 +- .../automations/tests/backend}/cron.test.ts | 2 +- .../tests/backend}/docker-watcher.test.ts | 2 +- .../automations/tests/backend}/engine.test.ts | 11 +- .../tests/backend}/headless-viewer.test.ts | 5 +- .../automations/tests/backend}/notify.test.ts | 4 +- .../automations/tests/backend}/routes.test.ts | 30 +-- .../tests/backend}/template.test.ts | 2 +- .../tests/backend}/triggers.test.ts | 9 +- .../frontend}/automations-editor.test.ts | 6 +- plugins/automations/tsconfig.json | 10 + plugins/automations/vitest.config.ts | 3 + plugins/docker/CHANGELOG.md | 5 + plugins/docker/README.md | 22 +++ plugins/docker/backend/index.mjs | 143 -------------- plugins/docker/locales/en.json | 1 + plugins/docker/manifest.json | 2 - plugins/docker/package.json | 15 ++ plugins/docker/{ => src}/backend/console.ts | 28 +-- .../{ => src}/backend/container-routes.ts | 8 +- .../{ => src}/backend/container-runtime.ts | 0 plugins/docker/src/backend/index.ts | 88 +++++++++ plugins/docker/{ => src}/backend/routes.ts | 42 ++-- .../{ => src}/backend/session-manager.ts | 6 +- .../docker/{ => src}/frontend/DockerApp.tsx | 0 .../{ => src}/frontend/DockerManager.tsx | 0 .../frontend/components/ConsoleTerminal.tsx | 0 .../frontend/components/ContainerCard.tsx | 0 .../frontend/components/ContainerDetail.tsx | 0 .../frontend/components/ContainerList.tsx | 0 .../frontend/components/ContainerStats.tsx | 0 .../frontend/components/ContainerTable.tsx | 0 .../frontend/components/LogViewer.tsx | 0 plugins/docker/{ => src}/frontend/index.mjs | 2 +- plugins/docker/tsconfig.json | 10 + plugins/docker/vitest.config.ts | 3 + plugins/fleets/CHANGELOG.md | 5 + plugins/fleets/README.md | 22 +++ plugins/fleets/backend/index.mjs | 63 ------ plugins/fleets/locales/en.json | 1 + plugins/fleets/manifest.json | 2 - plugins/fleets/package.json | 15 ++ plugins/fleets/src/backend/index.ts | 11 ++ plugins/fleets/{ => src}/backend/routes.ts | 36 ++-- .../{ => src}/frontend/FleetInventoryTab.tsx | 0 .../fleets/{ => src}/frontend/FleetsPanel.tsx | 0 plugins/fleets/{ => src}/frontend/index.mjs | 0 .../repositories/fleet-repository.test.ts | 4 +- .../fleets/tests/backend}/routes.test.ts | 77 ++++---- plugins/fleets/tsconfig.json | 10 + plugins/fleets/vitest.config.ts | 3 + plugins/host-metrics/CHANGELOG.md | 5 + plugins/host-metrics/README.md | 22 +++ plugins/host-metrics/backend/index.mjs | 66 ------- plugins/host-metrics/locales/en.json | 1 + plugins/host-metrics/manifest.json | 5 +- plugins/host-metrics/package.json | 15 ++ .../{ => src}/backend/automation-bridge.ts | 7 +- .../host-metrics/{ => src}/backend/helpers.ts | 2 +- .../{ => src}/backend/history-routes.ts | 8 +- plugins/host-metrics/src/backend/index.ts | 20 ++ .../{ => src}/backend/managers/cron.ts | 4 +- .../{ => src}/backend/managers/firewall.ts | 6 +- .../{ => src}/backend/managers/health.ts | 10 +- .../{ => src}/backend/managers/index.ts | 2 +- .../{ => src}/backend/managers/logs.ts | 6 +- .../{ => src}/backend/managers/packages.ts | 10 +- .../{ => src}/backend/managers/processes.ts | 6 +- .../backend/managers/route-helpers.ts | 10 +- .../{ => src}/backend/managers/services.ts | 6 +- .../backend/managers/simple-reads.ts | 2 +- .../{ => src}/backend/managers/ssl.ts | 8 +- .../{ => src}/backend/managers/tailscale.ts | 6 +- .../{ => src}/backend/managers/types.ts | 2 +- .../{ => src}/backend/managers/users.ts | 6 +- .../{ => src}/backend/managers/wireguard.ts | 4 +- .../{ => src}/backend/preferences-routes.ts | 10 +- .../host-metrics/{ => src}/backend/routes.ts | 83 ++++---- .../{ => src}/backend/sessions.ts | 2 +- .../{ => src}/backend/settings-routes.ts | 4 +- .../{ => src}/backend/viewer-routes.ts | 8 +- .../backend/widgets/cpu-collector.ts | 2 +- .../backend/widgets/disk-collector.ts | 2 +- .../backend/widgets/firewall-collector.ts | 4 +- .../backend/widgets/gpu-collector.ts | 2 +- .../backend/widgets/login-stats-collector.ts | 2 +- .../backend/widgets/memory-collector.ts | 2 +- .../backend/widgets/network-collector.ts | 2 +- .../backend/widgets/ports-collector.ts | 4 +- .../backend/widgets/processes-collector.ts | 2 +- .../backend/widgets/system-collector.ts | 2 +- .../backend/widgets/temperature-collector.ts | 2 +- .../backend/widgets/uptime-collector.ts | 2 +- .../{ => src}/frontend/HostMetricsApp.tsx | 0 .../{ => src}/frontend/HostMetricsTab.tsx | 0 .../{ => src}/frontend/cards/CardTimeTabs.tsx | 0 .../{ => src}/frontend/cards/CpuCard.tsx | 0 .../{ => src}/frontend/cards/DiskCard.tsx | 0 .../frontend/cards/FilesystemPicker.tsx | 0 .../{ => src}/frontend/cards/FirewallCard.tsx | 0 .../{ => src}/frontend/cards/GpuCard.tsx | 0 .../frontend/cards/LoginStatsCard.tsx | 0 .../{ => src}/frontend/cards/MemoryCard.tsx | 0 .../{ => src}/frontend/cards/NetworkCard.tsx | 0 .../{ => src}/frontend/cards/PortsCard.tsx | 0 .../frontend/cards/ProcessesCard.tsx | 0 .../{ => src}/frontend/cards/SystemCard.tsx | 0 .../frontend/cards/TemperatureCard.tsx | 0 .../{ => src}/frontend/cards/UptimeCard.tsx | 0 .../{ => src}/frontend/cards/gpu-history.ts | 0 .../{ => src}/frontend/cards/index.tsx | 0 .../cards/managers/CronManagerCard.tsx | 0 .../cards/managers/FirewallManagerCard.tsx | 0 .../cards/managers/HealthCheckCard.tsx | 0 .../frontend/cards/managers/LogViewerCard.tsx | 0 .../cards/managers/ManagerCardShell.tsx | 0 .../cards/managers/ManagerToolbar.tsx | 0 .../cards/managers/PackageManagerCard.tsx | 0 .../cards/managers/ProcessInspectorCard.tsx | 0 .../cards/managers/ServiceManagerCard.tsx | 0 .../cards/managers/SimpleManagerCards.tsx | 0 .../cards/managers/SslManagerCard.tsx | 0 .../cards/managers/TailscaleManagerCard.tsx | 0 .../cards/managers/UserManagerCard.tsx | 0 .../cards/managers/WireGuardManagerCard.tsx | 0 .../frontend/cards/managers/useManagerData.ts | 0 .../hooks/useHostMetricsPreferences.ts | 0 .../host-metrics/{ => src}/frontend/index.mjs | 0 .../{ => src}/frontend/metrics-change-key.ts | 0 .../frontend/temperature-preference.ts | 0 .../tests/backend}/helpers.test.ts | 4 +- .../backend}/host-metrics-preferences.test.ts | 2 +- .../backend}/login-alert-dispatch.test.ts | 5 +- .../backend}/login-alert-route-order.test.ts | 5 +- .../tests/backend}/managers/managers.test.ts | 26 +-- .../metrics-shared/common-utils.test.ts | 2 +- .../metrics-shared/exec-elevated.test.ts | 11 +- .../metrics-shared/host-status.test.ts | 2 +- .../backend}/metrics-shared/state.test.ts | 2 +- .../host-metrics-history-repository.test.ts | 4 +- ...host-metrics-preference-repository.test.ts | 4 +- .../tests/backend}/status-poll-auth.test.ts | 5 +- .../backend}/widgets/cpu-collector.test.ts | 2 +- .../backend}/widgets/disk-collector.test.ts | 2 +- .../backend}/widgets/gpu-collector.test.ts | 2 +- .../widgets/network-collector.test.ts | 2 +- .../widgets/temperature-collector.test.ts | 2 +- .../tests/frontend}/HostMetricsTab.test.tsx | 23 +-- .../tests/frontend}/cards/GpuCard.test.tsx | 2 +- .../tests/frontend}/cards/gpu-history.test.ts | 2 +- .../frontend}/host-metrics-status-api.test.ts | 2 +- .../frontend}/metrics-change-key.test.ts | 2 +- .../frontend}/temperature-preference.test.ts | 2 +- plugins/host-metrics/tsconfig.json | 10 + plugins/host-metrics/vitest.config.ts | 3 + plugins/network-topology/CHANGELOG.md | 5 + plugins/network-topology/README.md | 22 +++ plugins/network-topology/backend/index.mjs | 60 ------ plugins/network-topology/locales/en.json | 1 + plugins/network-topology/manifest.json | 2 - plugins/network-topology/package.json | 15 ++ plugins/network-topology/src/backend/index.ts | 14 ++ .../{ => src}/backend/routes.ts | 10 +- .../{ => src}/frontend/NetworkGraphCard.tsx | 0 .../{ => src}/frontend/index.mjs | 0 .../network-topology-repository.test.ts | 4 +- .../tests/backend}/routes.test.ts | 22 ++- plugins/network-topology/tsconfig.json | 10 + plugins/network-topology/vitest.config.ts | 3 + plugins/proxmox/CHANGELOG.md | 5 + plugins/proxmox/README.md | 22 +++ plugins/proxmox/backend/index.mjs | 63 ------ plugins/proxmox/locales/en.json | 1 + plugins/proxmox/manifest.json | 2 - plugins/proxmox/package.json | 15 ++ plugins/proxmox/src/backend/index.ts | 11 ++ .../{ => src}/backend/proxmox-import-auth.ts | 0 .../{ => src}/backend/proxmox-jump-hosts.ts | 0 plugins/proxmox/{ => src}/backend/routes.ts | 26 +-- .../frontend/ProxmoxDiscoverDialog.tsx | 0 plugins/proxmox/{ => src}/frontend/index.mjs | 2 +- .../{ => src}/frontend/proxmox-import-auth.ts | 0 .../backend}/proxmox-import-auth.test.ts | 2 +- .../tests/backend}/proxmox-jump-hosts.test.ts | 2 +- .../proxmox-stats-columns-migration.test.ts | 15 +- .../backend}/proxmox-stats-polling.test.ts | 13 +- .../tests/backend}/proxmox-sudo.test.ts | 12 +- .../proxmox/cluster-health-collector.test.ts | 11 +- .../proxmox/collect-proxmox-stats.test.ts | 19 +- .../backend}/proxmox/guests-collector.test.ts | 19 +- .../proxmox/node-network-collector.test.ts | 11 +- .../proxmox/node-status-collector.test.ts | 19 +- .../proxmox/storage-collector.test.ts | 19 +- .../proxmox-node-history-repository.test.ts | 4 +- .../frontend}/proxmox-import-auth.test.ts | 2 +- plugins/proxmox/tsconfig.json | 10 + plugins/proxmox/vitest.config.ts | 3 + plugins/remote-desktop/CHANGELOG.md | 5 + plugins/remote-desktop/backend/index.mjs | 74 ------- plugins/remote-desktop/locales/en.json | 1 + plugins/remote-desktop/manifest.json | 2 - plugins/remote-desktop/package.json | 15 ++ .../{ => src}/backend/drive-settings.ts | 0 .../{ => src}/backend/guacamole-server.ts | 6 +- plugins/remote-desktop/src/backend/index.ts | 33 ++++ .../{ => src}/backend/jump-tunnel-endpoint.ts | 0 .../{ => src}/backend/macos-vnc-proxy.ts | 0 .../{ => src}/backend/rdp-settings.ts | 0 .../{ => src}/backend/recording-settings.ts | 0 .../{ => src}/backend/routes.ts | 22 +-- .../{ => src}/backend/token-service.ts | 2 +- .../{ => src}/frontend/GuacamoleApp.tsx | 0 .../{ => src}/frontend/GuacamoleDisplay.tsx | 0 .../frontend/GuacamoleFileBrowser.tsx | 0 .../{ => src}/frontend/GuacamoleToolbar.tsx | 0 .../{ => src}/frontend/guacamole-api.ts | 0 .../{ => src}/frontend/guacamole-clipboard.ts | 0 .../frontend/guacamole-display-size.ts | 0 .../{ => src}/frontend/guacamole-file-drop.ts | 0 .../frontend/guacamole-filesystem.ts | 0 .../{ => src}/frontend/guacamole-meta-key.ts | 0 .../{ => src}/frontend/guacamole-pointer.ts | 0 .../{ => src}/frontend/guacamole-scroll.ts | 0 .../frontend/guacamole-session-id.ts | 0 .../frontend/guacamole-websocket-url.ts | 0 .../{ => src}/frontend/guacamole-zoom.ts | 0 .../{ => src}/frontend/index.mjs | 0 .../frontend/rdp-credential-prompt.ts | 0 .../tests/backend}/drive-settings.test.ts | 2 +- .../backend}/jump-tunnel-endpoint.test.ts | 2 +- .../tests/backend}/macos-vnc-proxy.test.ts | 2 +- .../tests/backend}/rdp-settings.test.ts | 2 +- .../tests/backend}/recording-settings.test.ts | 2 +- .../tests/backend}/token-delivery.test.ts | 10 +- .../tests/backend}/token-service.test.ts | 4 +- .../frontend/GuacamoleToolbar.test.tsx | 4 +- .../frontend/guacamole-api.test.ts | 2 +- .../frontend/guacamole-clipboard.test.ts | 2 +- .../frontend/guacamole-display-size.test.ts | 2 +- .../frontend/guacamole-file-drop.test.ts | 2 +- .../frontend/guacamole-filesystem.test.ts | 2 +- .../frontend/guacamole-meta-key.test.ts | 5 +- .../frontend/guacamole-pointer.test.ts | 2 +- .../frontend/guacamole-scroll.test.ts | 2 +- .../frontend/guacamole-session-id.test.ts | 6 +- .../frontend/guacamole-websocket-url.test.ts | 2 +- .../frontend/guacamole-zoom.test.ts | 5 +- .../tests/frontend}/native-rdp.test.ts | 0 .../frontend/rdp-credential-prompt.test.ts | 2 +- plugins/remote-desktop/tsconfig.json | 10 + plugins/remote-desktop/vitest.config.ts | 3 + plugins/ssh-terminal/CHANGELOG.md | 5 + plugins/ssh-terminal/backend/index.mjs | 83 -------- plugins/ssh-terminal/locales/en.json | 1 + plugins/ssh-terminal/manifest.json | 2 - plugins/ssh-terminal/package.json | 15 ++ plugins/ssh-terminal/src/backend/index.ts | 21 ++ .../ssh-terminal/{ => src}/frontend/index.mjs | 0 plugins/ssh-terminal/tsconfig.json | 10 + plugins/ssh-terminal/vitest.config.ts | 3 + plugins/tailscale/CHANGELOG.md | 5 + plugins/tailscale/README.md | 22 +++ plugins/tailscale/backend/index.mjs | 69 ------- plugins/tailscale/locales/en.json | 1 + plugins/tailscale/manifest.json | 2 - plugins/tailscale/package.json | 15 ++ plugins/tailscale/src/backend/index.ts | 11 ++ plugins/tailscale/{ => src}/backend/routes.ts | 12 +- .../frontend/AdminTailscaleSection.tsx | 0 .../frontend/TailscaleCheckDialog.tsx | 0 .../frontend/TailscaleDevicesPanel.tsx | 2 +- .../tailscale/{ => src}/frontend/index.mjs | 0 .../{ => src}/frontend/tailscale-api.ts | 0 .../tests/backend}/tailscale-check.test.ts | 2 +- .../frontend}/AdminTailscaleSection.test.tsx | 2 +- .../frontend}/TailscaleDevicesPanel.test.tsx | 4 +- .../tests/frontend}/tailscale-api.test.ts | 2 +- plugins/tailscale/tsconfig.json | 10 + plugins/tailscale/vitest.config.ts | 3 + plugins/web-endpoint/CHANGELOG.md | 5 + plugins/web-endpoint/backend/index.mjs | 75 -------- plugins/web-endpoint/locales/en.json | 1 + plugins/web-endpoint/manifest.json | 2 - plugins/web-endpoint/package.json | 15 ++ plugins/web-endpoint/src/backend/index.ts | 11 ++ .../web-endpoint/{ => src}/backend/routes.ts | 18 +- .../{ => src}/frontend/WebEndpointTab.tsx | 0 .../web-endpoint/{ => src}/frontend/index.mjs | 0 .../{ => src}/frontend/web-endpoint-api.ts | 0 .../{ => src}/frontend/web-endpoint-url.ts | 0 .../frontend/web-endpoint-validation.ts | 0 .../host-web-endpoint-enumeration.test.ts | 10 +- .../tests/backend}/host-web-endpoints.test.ts | 2 +- .../tests/backend}/routes.test.ts | 41 ++-- .../web-endpoint-tunnel-manager.test.ts | 17 +- .../backend}/web-endpoint-window.test.ts | 0 .../frontend/WebEndpointTab.test.tsx | 61 ++++-- .../frontend/web-endpoint-api.test.ts | 23 ++- .../frontend/web-endpoint-url.test.ts | 2 +- .../frontend/web-endpoint-validation.test.ts | 4 +- plugins/web-endpoint/tsconfig.json | 10 + plugins/web-endpoint/vitest.config.ts | 3 + plugins/workspaces/CHANGELOG.md | 5 + plugins/workspaces/README.md | 22 +++ plugins/workspaces/backend/index.mjs | 58 ------ plugins/workspaces/locales/en.json | 1 + plugins/workspaces/manifest.json | 2 - plugins/workspaces/package.json | 15 ++ plugins/workspaces/src/backend/index.ts | 11 ++ .../workspaces/{ => src}/backend/routes.ts | 12 +- .../{ => src}/frontend/WorkspacesPanel.tsx | 0 .../workspaces/{ => src}/frontend/index.mjs | 0 .../{ => src}/frontend/workspaceUtils.ts | 0 .../{ => src}/frontend/workspaces-api.ts | 0 .../repositories/workspace-repository.test.ts | 4 +- .../workspaces/tests/backend}/routes.test.ts | 22 ++- .../tests/frontend}/workspaceUtils.test.ts | 2 +- .../tests/frontend}/workspaces-api.test.ts | 2 +- plugins/workspaces/tsconfig.json | 10 + plugins/workspaces/vitest.config.ts | 3 + scripts/build-plugins.cjs | 58 ++++++ scripts/check-plugin-boundaries.cjs | 120 ++++++++++++ scripts/copy-bundled-plugins.cjs | 180 ------------------ scripts/plugin-boundary-allowlist.json | 98 ++++++++++ scripts/run-plugin-tests.cjs | 57 ++++++ scripts/validate-plugin-manifest.cjs | 8 +- .../database/routes/guacamole-dispatch.ts | 4 +- .../database/routes/host-web-endpoints.ts | 2 +- src/backend/hosts/ssh-client-factory.ts | 2 +- src/backend/plugins/paths.ts | 11 +- .../database/repositories/test-support.ts | 6 +- .../tests/plugins/bundled-plugin.test.ts | 14 +- src/backend/tests/plugins/events.test.ts | 2 +- src/main.tsx | 6 +- src/types/ui-preferences.ts | 2 +- src/ui/AppShell.tsx | 18 +- src/ui/api/settings-api.ts | 2 +- src/ui/dashboard/DashboardTab.tsx | 2 +- src/ui/features/ai/useMentions.ts | 2 +- src/ui/features/collab/CollabGuestView.tsx | 2 +- src/ui/features/collab/CollabRoomTab.tsx | 4 +- .../homepage/widgets/DockerWidget.tsx | 2 +- .../session-sharing/SharedSessionView.tsx | 2 +- src/ui/features/terminal/Terminal.tsx | 2 +- src/ui/main-axios.ts | 4 +- src/ui/shell/tabUtils.tsx | 40 ++-- src/ui/sidebar/AdminSettingsPanel.tsx | 4 +- src/ui/sidebar/HostEditor.tsx | 2 +- src/ui/sidebar/HostEditorWebUiSection.tsx | 4 +- src/ui/sidebar/HostsPanel.tsx | 2 +- src/ui/sidebar/quick-connect-host.ts | 2 +- src/ui/sidebar/tree/HostItem/HostItem.tsx | 2 +- .../SharedSessionView.test.tsx | 2 +- src/ui/tests/sidebar/rail-items.test.ts | 2 +- tsconfig.json | 3 + tsconfig.plugins.json | 21 +- vitest.config.ts | 5 +- vitest.setup.ts | 60 +----- 448 files changed, 3087 insertions(+), 2003 deletions(-) create mode 100644 packages/plugin-sdk/cli/commands/build.mjs create mode 100644 packages/plugin-sdk/cli/commands/pack.mjs create mode 100644 packages/plugin-sdk/cli/commands/test.mjs create mode 100644 packages/plugin-sdk/cli/commands/validate.mjs create mode 100644 packages/plugin-sdk/cli/index.mjs create mode 100644 packages/plugin-sdk/cli/lib/externals.mjs create mode 100644 packages/plugin-sdk/cli/lib/legacy-core-imports.mjs create mode 100644 packages/plugin-sdk/cli/lib/plugin-dir.mjs create mode 100644 packages/plugin-sdk/src/testing/setup.ts create mode 100644 packages/plugin-sdk/src/vitest-preset.ts create mode 100644 packages/plugin-sdk/tsconfig.plugin.json create mode 100644 plugins/ai/CHANGELOG.md create mode 100644 plugins/ai/README.md delete mode 100644 plugins/ai/backend/index.mjs create mode 100644 plugins/ai/locales/en.json create mode 100644 plugins/ai/package.json rename plugins/ai/{ => src}/backend/context.ts (100%) rename plugins/ai/{ => src}/backend/egress.ts (94%) rename plugins/ai/{ => src}/backend/engine.ts (98%) rename plugins/ai/{ => src}/backend/gating.ts (93%) create mode 100644 plugins/ai/src/backend/index.ts rename plugins/ai/{ => src}/backend/providers/anthropic.ts (100%) rename plugins/ai/{ => src}/backend/providers/gemini.ts (100%) rename plugins/ai/{ => src}/backend/providers/http.ts (96%) rename plugins/ai/{ => src}/backend/providers/ollama.ts (100%) rename plugins/ai/{ => src}/backend/providers/openai.ts (100%) rename plugins/ai/{ => src}/backend/providers/registry.ts (100%) rename plugins/ai/{ => src}/backend/providers/types.ts (100%) rename plugins/ai/{ => src}/backend/redaction.ts (100%) rename plugins/ai/{ => src}/backend/routes.ts (98%) rename plugins/ai/{ => src}/backend/tools/catalog.ts (100%) rename plugins/ai/{ => src}/backend/tools/command-allowlist.ts (100%) rename plugins/ai/{ => src}/backend/tools/executor.ts (95%) rename plugins/ai/{ => src}/backend/tools/propose-tools.ts (100%) rename plugins/ai/{ => src}/backend/tools/read-tools.ts (99%) rename plugins/ai/{ => src}/backend/tools/types.ts (100%) rename plugins/ai/{ => src}/frontend/index.mjs (100%) rename {src/backend/tests/plugins/ai => plugins/ai/tests/backend}/context.test.ts (95%) rename {src/backend/tests/plugins/ai => plugins/ai/tests/backend}/egress.test.ts (98%) rename {src/backend/tests/plugins/ai => plugins/ai/tests/backend}/engine.test.ts (94%) rename {src/backend/tests/plugins/ai => plugins/ai/tests/backend}/gating.test.ts (96%) rename {src/backend/tests/plugins/ai => plugins/ai/tests/backend}/providers/provider-http.test.ts (83%) rename {src/backend/tests/plugins/ai => plugins/ai/tests/backend}/providers/providers.test.ts (94%) rename {src/backend/tests/plugins/ai => plugins/ai/tests/backend}/redaction.test.ts (95%) rename {src/backend/tests/plugins/ai => plugins/ai/tests/backend}/tools/command-allowlist.test.ts (96%) rename {src/backend/tests/plugins/ai => plugins/ai/tests/backend}/tools/proposal-executor.test.ts (93%) rename {src/backend/tests/plugins/ai => plugins/ai/tests/backend}/tools/tool-catalog.test.ts (98%) rename {src/ui/tests/features/ai => plugins/ai/tests/frontend}/AiProviderSettings.test.tsx (100%) rename {src/ui/tests/features/ai => plugins/ai/tests/frontend}/labels.test.ts (96%) create mode 100644 plugins/ai/tsconfig.json create mode 100644 plugins/ai/vitest.config.ts create mode 100644 plugins/automations/CHANGELOG.md create mode 100644 plugins/automations/README.md delete mode 100644 plugins/automations/backend/index.mjs create mode 100644 plugins/automations/locales/en.json create mode 100644 plugins/automations/package.json rename plugins/automations/{ => src}/backend/actions/host-targets.ts (84%) rename plugins/automations/{ => src}/backend/actions/index.ts (94%) rename plugins/automations/{ => src}/backend/actions/types.ts (96%) rename plugins/automations/{ => src}/backend/conditions.ts (98%) rename plugins/automations/{ => src}/backend/cron.ts (100%) rename plugins/automations/{ => src}/backend/docker-watcher.ts (91%) rename plugins/automations/{ => src}/backend/engine.ts (96%) rename plugins/automations/{ => src}/backend/headless-viewer.ts (97%) rename plugins/automations/{ => src}/backend/http.ts (91%) create mode 100644 plugins/automations/src/backend/index.ts rename plugins/automations/{ => src}/backend/notify.ts (98%) rename plugins/automations/{ => src}/backend/routes.ts (96%) rename plugins/automations/{ => src}/backend/scheduler.ts (95%) rename plugins/automations/{ => src}/backend/template.ts (100%) rename plugins/automations/{ => src}/backend/triggers.ts (96%) rename plugins/automations/{ => src}/frontend/AutomationsPanel.tsx (100%) rename plugins/automations/{ => src}/frontend/automations-api.ts (100%) rename plugins/automations/{ => src}/frontend/automations/AutomationEditor.tsx (100%) rename plugins/automations/{ => src}/frontend/automations/HostSelectorField.tsx (100%) rename plugins/automations/{ => src}/frontend/automations/StepBlock.tsx (100%) rename plugins/automations/{ => src}/frontend/automations/TriggerCard.tsx (100%) rename plugins/automations/{ => src}/frontend/automations/editor-types.ts (100%) rename plugins/automations/{ => src}/frontend/index.mjs (100%) rename {src/backend/tests/plugins/automations => plugins/automations/tests/backend}/conditions.test.ts (98%) rename {src/backend/tests/plugins/automations => plugins/automations/tests/backend}/cron.test.ts (98%) rename {src/backend/tests/plugins/automations => plugins/automations/tests/backend}/docker-watcher.test.ts (98%) rename {src/backend/tests/plugins/automations => plugins/automations/tests/backend}/engine.test.ts (98%) rename {src/backend/tests/plugins/automations => plugins/automations/tests/backend}/headless-viewer.test.ts (96%) rename {src/backend/tests/plugins/automations => plugins/automations/tests/backend}/notify.test.ts (89%) rename {src/backend/tests/plugins/automations => plugins/automations/tests/backend}/routes.test.ts (94%) rename {src/backend/tests/plugins/automations => plugins/automations/tests/backend}/template.test.ts (97%) rename {src/backend/tests/plugins/automations => plugins/automations/tests/backend}/triggers.test.ts (97%) rename {src/ui/tests/plugins/automations => plugins/automations/tests/frontend}/automations-editor.test.ts (85%) create mode 100644 plugins/automations/tsconfig.json create mode 100644 plugins/automations/vitest.config.ts create mode 100644 plugins/docker/CHANGELOG.md create mode 100644 plugins/docker/README.md delete mode 100644 plugins/docker/backend/index.mjs create mode 100644 plugins/docker/locales/en.json create mode 100644 plugins/docker/package.json rename plugins/docker/{ => src}/backend/console.ts (96%) rename plugins/docker/{ => src}/backend/container-routes.ts (99%) rename plugins/docker/{ => src}/backend/container-runtime.ts (100%) create mode 100644 plugins/docker/src/backend/index.ts rename plugins/docker/{ => src}/backend/routes.ts (96%) rename plugins/docker/{ => src}/backend/session-manager.ts (95%) rename plugins/docker/{ => src}/frontend/DockerApp.tsx (100%) rename plugins/docker/{ => src}/frontend/DockerManager.tsx (100%) rename plugins/docker/{ => src}/frontend/components/ConsoleTerminal.tsx (100%) rename plugins/docker/{ => src}/frontend/components/ContainerCard.tsx (100%) rename plugins/docker/{ => src}/frontend/components/ContainerDetail.tsx (100%) rename plugins/docker/{ => src}/frontend/components/ContainerList.tsx (100%) rename plugins/docker/{ => src}/frontend/components/ContainerStats.tsx (100%) rename plugins/docker/{ => src}/frontend/components/ContainerTable.tsx (100%) rename plugins/docker/{ => src}/frontend/components/LogViewer.tsx (100%) rename plugins/docker/{ => src}/frontend/index.mjs (96%) create mode 100644 plugins/docker/tsconfig.json create mode 100644 plugins/docker/vitest.config.ts create mode 100644 plugins/fleets/CHANGELOG.md create mode 100644 plugins/fleets/README.md delete mode 100644 plugins/fleets/backend/index.mjs create mode 100644 plugins/fleets/locales/en.json create mode 100644 plugins/fleets/package.json create mode 100644 plugins/fleets/src/backend/index.ts rename plugins/fleets/{ => src}/backend/routes.ts (96%) rename plugins/fleets/{ => src}/frontend/FleetInventoryTab.tsx (100%) rename plugins/fleets/{ => src}/frontend/FleetsPanel.tsx (100%) rename plugins/fleets/{ => src}/frontend/index.mjs (100%) rename {src/backend/tests/database => plugins/fleets/tests/backend}/repositories/fleet-repository.test.ts (94%) rename {src/backend/tests/plugins/fleets => plugins/fleets/tests/backend}/routes.test.ts (86%) create mode 100644 plugins/fleets/tsconfig.json create mode 100644 plugins/fleets/vitest.config.ts create mode 100644 plugins/host-metrics/CHANGELOG.md create mode 100644 plugins/host-metrics/README.md delete mode 100644 plugins/host-metrics/backend/index.mjs create mode 100644 plugins/host-metrics/locales/en.json create mode 100644 plugins/host-metrics/package.json rename plugins/host-metrics/{ => src}/backend/automation-bridge.ts (90%) rename plugins/host-metrics/{ => src}/backend/helpers.ts (94%) rename plugins/host-metrics/{ => src}/backend/history-routes.ts (93%) create mode 100644 plugins/host-metrics/src/backend/index.ts rename plugins/host-metrics/{ => src}/backend/managers/cron.ts (95%) rename plugins/host-metrics/{ => src}/backend/managers/firewall.ts (93%) rename plugins/host-metrics/{ => src}/backend/managers/health.ts (93%) rename plugins/host-metrics/{ => src}/backend/managers/index.ts (95%) rename plugins/host-metrics/{ => src}/backend/managers/logs.ts (92%) rename plugins/host-metrics/{ => src}/backend/managers/packages.ts (82%) rename plugins/host-metrics/{ => src}/backend/managers/processes.ts (93%) rename plugins/host-metrics/{ => src}/backend/managers/route-helpers.ts (80%) rename plugins/host-metrics/{ => src}/backend/managers/services.ts (93%) rename plugins/host-metrics/{ => src}/backend/managers/simple-reads.ts (97%) rename plugins/host-metrics/{ => src}/backend/managers/ssl.ts (96%) rename plugins/host-metrics/{ => src}/backend/managers/tailscale.ts (93%) rename plugins/host-metrics/{ => src}/backend/managers/types.ts (89%) rename plugins/host-metrics/{ => src}/backend/managers/users.ts (94%) rename plugins/host-metrics/{ => src}/backend/managers/wireguard.ts (97%) rename plugins/host-metrics/{ => src}/backend/preferences-routes.ts (95%) rename plugins/host-metrics/{ => src}/backend/routes.ts (96%) rename plugins/host-metrics/{ => src}/backend/sessions.ts (96%) rename plugins/host-metrics/{ => src}/backend/settings-routes.ts (97%) rename plugins/host-metrics/{ => src}/backend/viewer-routes.ts (96%) rename plugins/host-metrics/{ => src}/backend/widgets/cpu-collector.ts (98%) rename plugins/host-metrics/{ => src}/backend/widgets/disk-collector.ts (99%) rename plugins/host-metrics/{ => src}/backend/widgets/firewall-collector.ts (97%) rename plugins/host-metrics/{ => src}/backend/widgets/gpu-collector.ts (98%) rename plugins/host-metrics/{ => src}/backend/widgets/login-stats-collector.ts (97%) rename plugins/host-metrics/{ => src}/backend/widgets/memory-collector.ts (98%) rename plugins/host-metrics/{ => src}/backend/widgets/network-collector.ts (99%) rename plugins/host-metrics/{ => src}/backend/widgets/ports-collector.ts (96%) rename plugins/host-metrics/{ => src}/backend/widgets/processes-collector.ts (95%) rename plugins/host-metrics/{ => src}/backend/widgets/system-collector.ts (96%) rename plugins/host-metrics/{ => src}/backend/widgets/temperature-collector.ts (97%) rename plugins/host-metrics/{ => src}/backend/widgets/uptime-collector.ts (97%) rename plugins/host-metrics/{ => src}/frontend/HostMetricsApp.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/HostMetricsTab.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/cards/CardTimeTabs.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/cards/CpuCard.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/cards/DiskCard.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/cards/FilesystemPicker.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/cards/FirewallCard.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/cards/GpuCard.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/cards/LoginStatsCard.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/cards/MemoryCard.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/cards/NetworkCard.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/cards/PortsCard.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/cards/ProcessesCard.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/cards/SystemCard.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/cards/TemperatureCard.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/cards/UptimeCard.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/cards/gpu-history.ts (100%) rename plugins/host-metrics/{ => src}/frontend/cards/index.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/cards/managers/CronManagerCard.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/cards/managers/FirewallManagerCard.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/cards/managers/HealthCheckCard.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/cards/managers/LogViewerCard.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/cards/managers/ManagerCardShell.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/cards/managers/ManagerToolbar.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/cards/managers/PackageManagerCard.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/cards/managers/ProcessInspectorCard.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/cards/managers/ServiceManagerCard.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/cards/managers/SimpleManagerCards.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/cards/managers/SslManagerCard.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/cards/managers/TailscaleManagerCard.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/cards/managers/UserManagerCard.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/cards/managers/WireGuardManagerCard.tsx (100%) rename plugins/host-metrics/{ => src}/frontend/cards/managers/useManagerData.ts (100%) rename plugins/host-metrics/{ => src}/frontend/hooks/useHostMetricsPreferences.ts (100%) rename plugins/host-metrics/{ => src}/frontend/index.mjs (100%) rename plugins/host-metrics/{ => src}/frontend/metrics-change-key.ts (100%) rename plugins/host-metrics/{ => src}/frontend/temperature-preference.ts (100%) rename {src/backend/tests/plugins/host-metrics => plugins/host-metrics/tests/backend}/helpers.test.ts (96%) rename {src/backend/tests/hosts => plugins/host-metrics/tests/backend}/host-metrics-preferences.test.ts (98%) rename {src/backend/tests/plugins/host-metrics => plugins/host-metrics/tests/backend}/login-alert-dispatch.test.ts (96%) rename {src/backend/tests/plugins/host-metrics => plugins/host-metrics/tests/backend}/login-alert-route-order.test.ts (90%) rename {src/backend/tests/plugins/host-metrics => plugins/host-metrics/tests/backend}/managers/managers.test.ts (93%) rename {src/backend/tests/hosts => plugins/host-metrics/tests/backend}/metrics-shared/common-utils.test.ts (94%) rename {src/backend/tests/hosts => plugins/host-metrics/tests/backend}/metrics-shared/exec-elevated.test.ts (94%) rename {src/backend/tests/hosts => plugins/host-metrics/tests/backend}/metrics-shared/host-status.test.ts (94%) rename {src/backend/tests/hosts => plugins/host-metrics/tests/backend}/metrics-shared/state.test.ts (99%) rename {src/backend/tests/database => plugins/host-metrics/tests/backend}/repositories/host-metrics-history-repository.test.ts (90%) rename {src/backend/tests/database => plugins/host-metrics/tests/backend}/repositories/host-metrics-preference-repository.test.ts (93%) rename {src/backend/tests/plugins/host-metrics => plugins/host-metrics/tests/backend}/status-poll-auth.test.ts (92%) rename {src/backend/tests/plugins/host-metrics => plugins/host-metrics/tests/backend}/widgets/cpu-collector.test.ts (94%) rename {src/backend/tests/plugins/host-metrics => plugins/host-metrics/tests/backend}/widgets/disk-collector.test.ts (99%) rename {src/backend/tests/plugins/host-metrics => plugins/host-metrics/tests/backend}/widgets/gpu-collector.test.ts (98%) rename {src/backend/tests/plugins/host-metrics => plugins/host-metrics/tests/backend}/widgets/network-collector.test.ts (97%) rename {src/backend/tests/plugins/host-metrics => plugins/host-metrics/tests/backend}/widgets/temperature-collector.test.ts (92%) rename {src/ui/tests/plugins/host-metrics => plugins/host-metrics/tests/frontend}/HostMetricsTab.test.tsx (87%) rename {src/ui/tests/plugins/host-metrics => plugins/host-metrics/tests/frontend}/cards/GpuCard.test.tsx (97%) rename {src/ui/tests/plugins/host-metrics => plugins/host-metrics/tests/frontend}/cards/gpu-history.test.ts (94%) rename {src/ui/tests/api => plugins/host-metrics/tests/frontend}/host-metrics-status-api.test.ts (98%) rename {src/ui/tests/plugins/host-metrics => plugins/host-metrics/tests/frontend}/metrics-change-key.test.ts (94%) rename {src/ui/tests/plugins/host-metrics => plugins/host-metrics/tests/frontend}/temperature-preference.test.ts (90%) create mode 100644 plugins/host-metrics/tsconfig.json create mode 100644 plugins/host-metrics/vitest.config.ts create mode 100644 plugins/network-topology/CHANGELOG.md create mode 100644 plugins/network-topology/README.md delete mode 100644 plugins/network-topology/backend/index.mjs create mode 100644 plugins/network-topology/locales/en.json create mode 100644 plugins/network-topology/package.json create mode 100644 plugins/network-topology/src/backend/index.ts rename plugins/network-topology/{ => src}/backend/routes.ts (94%) rename plugins/network-topology/{ => src}/frontend/NetworkGraphCard.tsx (100%) rename plugins/network-topology/{ => src}/frontend/index.mjs (100%) rename {src/backend/tests/database => plugins/network-topology/tests/backend}/repositories/network-topology-repository.test.ts (90%) rename {src/backend/tests/plugins/network-topology => plugins/network-topology/tests/backend}/routes.test.ts (87%) create mode 100644 plugins/network-topology/tsconfig.json create mode 100644 plugins/network-topology/vitest.config.ts create mode 100644 plugins/proxmox/CHANGELOG.md create mode 100644 plugins/proxmox/README.md delete mode 100644 plugins/proxmox/backend/index.mjs create mode 100644 plugins/proxmox/locales/en.json create mode 100644 plugins/proxmox/package.json create mode 100644 plugins/proxmox/src/backend/index.ts rename plugins/proxmox/{ => src}/backend/proxmox-import-auth.ts (100%) rename plugins/proxmox/{ => src}/backend/proxmox-jump-hosts.ts (100%) rename plugins/proxmox/{ => src}/backend/routes.ts (97%) rename plugins/proxmox/{ => src}/frontend/ProxmoxDiscoverDialog.tsx (100%) rename plugins/proxmox/{ => src}/frontend/index.mjs (95%) rename plugins/proxmox/{ => src}/frontend/proxmox-import-auth.ts (100%) rename {src/backend/tests/plugins/proxmox => plugins/proxmox/tests/backend}/proxmox-import-auth.test.ts (94%) rename {src/backend/tests/plugins/proxmox => plugins/proxmox/tests/backend}/proxmox-jump-hosts.test.ts (91%) rename {src/backend/tests/database/db => plugins/proxmox/tests/backend}/proxmox-stats-columns-migration.test.ts (90%) rename {src/backend/tests/hosts/metrics => plugins/proxmox/tests/backend}/proxmox-stats-polling.test.ts (93%) rename {src/backend/tests/plugins/proxmox => plugins/proxmox/tests/backend}/proxmox-sudo.test.ts (82%) rename {src/backend/tests/hosts/metrics => plugins/proxmox/tests/backend}/proxmox/cluster-health-collector.test.ts (87%) rename {src/backend/tests/hosts/metrics => plugins/proxmox/tests/backend}/proxmox/collect-proxmox-stats.test.ts (83%) rename {src/backend/tests/hosts/metrics => plugins/proxmox/tests/backend}/proxmox/guests-collector.test.ts (89%) rename {src/backend/tests/hosts/metrics => plugins/proxmox/tests/backend}/proxmox/node-network-collector.test.ts (87%) rename {src/backend/tests/hosts/metrics => plugins/proxmox/tests/backend}/proxmox/node-status-collector.test.ts (84%) rename {src/backend/tests/hosts/metrics => plugins/proxmox/tests/backend}/proxmox/storage-collector.test.ts (81%) rename {src/backend/tests/database => plugins/proxmox/tests/backend}/repositories/proxmox-node-history-repository.test.ts (90%) rename {src/ui/tests/plugins/proxmox => plugins/proxmox/tests/frontend}/proxmox-import-auth.test.ts (94%) create mode 100644 plugins/proxmox/tsconfig.json create mode 100644 plugins/proxmox/vitest.config.ts create mode 100644 plugins/remote-desktop/CHANGELOG.md delete mode 100644 plugins/remote-desktop/backend/index.mjs create mode 100644 plugins/remote-desktop/locales/en.json create mode 100644 plugins/remote-desktop/package.json rename plugins/remote-desktop/{ => src}/backend/drive-settings.ts (100%) rename plugins/remote-desktop/{ => src}/backend/guacamole-server.ts (97%) create mode 100644 plugins/remote-desktop/src/backend/index.ts rename plugins/remote-desktop/{ => src}/backend/jump-tunnel-endpoint.ts (100%) rename plugins/remote-desktop/{ => src}/backend/macos-vnc-proxy.ts (100%) rename plugins/remote-desktop/{ => src}/backend/rdp-settings.ts (100%) rename plugins/remote-desktop/{ => src}/backend/recording-settings.ts (100%) rename plugins/remote-desktop/{ => src}/backend/routes.ts (96%) rename plugins/remote-desktop/{ => src}/backend/token-service.ts (98%) rename plugins/remote-desktop/{ => src}/frontend/GuacamoleApp.tsx (100%) rename plugins/remote-desktop/{ => src}/frontend/GuacamoleDisplay.tsx (100%) rename plugins/remote-desktop/{ => src}/frontend/GuacamoleFileBrowser.tsx (100%) rename plugins/remote-desktop/{ => src}/frontend/GuacamoleToolbar.tsx (100%) rename plugins/remote-desktop/{ => src}/frontend/guacamole-api.ts (100%) rename plugins/remote-desktop/{ => src}/frontend/guacamole-clipboard.ts (100%) rename plugins/remote-desktop/{ => src}/frontend/guacamole-display-size.ts (100%) rename plugins/remote-desktop/{ => src}/frontend/guacamole-file-drop.ts (100%) rename plugins/remote-desktop/{ => src}/frontend/guacamole-filesystem.ts (100%) rename plugins/remote-desktop/{ => src}/frontend/guacamole-meta-key.ts (100%) rename plugins/remote-desktop/{ => src}/frontend/guacamole-pointer.ts (100%) rename plugins/remote-desktop/{ => src}/frontend/guacamole-scroll.ts (100%) rename plugins/remote-desktop/{ => src}/frontend/guacamole-session-id.ts (100%) rename plugins/remote-desktop/{ => src}/frontend/guacamole-websocket-url.ts (100%) rename plugins/remote-desktop/{ => src}/frontend/guacamole-zoom.ts (100%) rename plugins/remote-desktop/{ => src}/frontend/index.mjs (100%) rename plugins/remote-desktop/{ => src}/frontend/rdp-credential-prompt.ts (100%) rename {src/backend/tests/plugins/remote-desktop => plugins/remote-desktop/tests/backend}/drive-settings.test.ts (91%) rename {src/backend/tests/plugins/remote-desktop => plugins/remote-desktop/tests/backend}/jump-tunnel-endpoint.test.ts (86%) rename {src/backend/tests/plugins/remote-desktop => plugins/remote-desktop/tests/backend}/macos-vnc-proxy.test.ts (97%) rename {src/backend/tests/plugins/remote-desktop => plugins/remote-desktop/tests/backend}/rdp-settings.test.ts (97%) rename {src/backend/tests/plugins/remote-desktop => plugins/remote-desktop/tests/backend}/recording-settings.test.ts (94%) rename {src/backend/tests/plugins/remote-desktop => plugins/remote-desktop/tests/backend}/token-delivery.test.ts (92%) rename {src/backend/tests/plugins/remote-desktop => plugins/remote-desktop/tests/backend}/token-service.test.ts (95%) rename plugins/remote-desktop/{ => tests}/frontend/GuacamoleToolbar.test.tsx (96%) rename plugins/remote-desktop/{ => tests}/frontend/guacamole-api.test.ts (99%) rename plugins/remote-desktop/{ => tests}/frontend/guacamole-clipboard.test.ts (96%) rename plugins/remote-desktop/{ => tests}/frontend/guacamole-display-size.test.ts (97%) rename plugins/remote-desktop/{ => tests}/frontend/guacamole-file-drop.test.ts (94%) rename plugins/remote-desktop/{ => tests}/frontend/guacamole-filesystem.test.ts (89%) rename plugins/remote-desktop/{ => tests}/frontend/guacamole-meta-key.test.ts (93%) rename plugins/remote-desktop/{ => tests}/frontend/guacamole-pointer.test.ts (97%) rename plugins/remote-desktop/{ => tests}/frontend/guacamole-scroll.test.ts (97%) rename plugins/remote-desktop/{ => tests}/frontend/guacamole-session-id.test.ts (89%) rename plugins/remote-desktop/{ => tests}/frontend/guacamole-websocket-url.test.ts (95%) rename plugins/remote-desktop/{ => tests}/frontend/guacamole-zoom.test.ts (80%) rename {src/ui/tests/electron => plugins/remote-desktop/tests/frontend}/native-rdp.test.ts (100%) rename plugins/remote-desktop/{ => tests}/frontend/rdp-credential-prompt.test.ts (90%) create mode 100644 plugins/remote-desktop/tsconfig.json create mode 100644 plugins/remote-desktop/vitest.config.ts create mode 100644 plugins/ssh-terminal/CHANGELOG.md delete mode 100644 plugins/ssh-terminal/backend/index.mjs create mode 100644 plugins/ssh-terminal/locales/en.json create mode 100644 plugins/ssh-terminal/package.json create mode 100644 plugins/ssh-terminal/src/backend/index.ts rename plugins/ssh-terminal/{ => src}/frontend/index.mjs (100%) create mode 100644 plugins/ssh-terminal/tsconfig.json create mode 100644 plugins/ssh-terminal/vitest.config.ts create mode 100644 plugins/tailscale/CHANGELOG.md create mode 100644 plugins/tailscale/README.md delete mode 100644 plugins/tailscale/backend/index.mjs create mode 100644 plugins/tailscale/locales/en.json create mode 100644 plugins/tailscale/package.json create mode 100644 plugins/tailscale/src/backend/index.ts rename plugins/tailscale/{ => src}/backend/routes.ts (90%) rename plugins/tailscale/{ => src}/frontend/AdminTailscaleSection.tsx (100%) rename plugins/tailscale/{ => src}/frontend/TailscaleCheckDialog.tsx (100%) rename plugins/tailscale/{ => src}/frontend/TailscaleDevicesPanel.tsx (98%) rename plugins/tailscale/{ => src}/frontend/index.mjs (100%) rename plugins/tailscale/{ => src}/frontend/tailscale-api.ts (100%) rename {src/backend/tests/hosts => plugins/tailscale/tests/backend}/tailscale-check.test.ts (97%) rename {src/ui/tests/plugins/tailscale => plugins/tailscale/tests/frontend}/AdminTailscaleSection.test.tsx (94%) rename {src/ui/tests/plugins/tailscale => plugins/tailscale/tests/frontend}/TailscaleDevicesPanel.test.tsx (92%) rename {src/ui/tests/plugins/tailscale => plugins/tailscale/tests/frontend}/tailscale-api.test.ts (93%) create mode 100644 plugins/tailscale/tsconfig.json create mode 100644 plugins/tailscale/vitest.config.ts create mode 100644 plugins/web-endpoint/CHANGELOG.md delete mode 100644 plugins/web-endpoint/backend/index.mjs create mode 100644 plugins/web-endpoint/locales/en.json create mode 100644 plugins/web-endpoint/package.json create mode 100644 plugins/web-endpoint/src/backend/index.ts rename plugins/web-endpoint/{ => src}/backend/routes.ts (94%) rename plugins/web-endpoint/{ => src}/frontend/WebEndpointTab.tsx (100%) rename plugins/web-endpoint/{ => src}/frontend/index.mjs (100%) rename plugins/web-endpoint/{ => src}/frontend/web-endpoint-api.ts (100%) rename plugins/web-endpoint/{ => src}/frontend/web-endpoint-url.ts (100%) rename plugins/web-endpoint/{ => src}/frontend/web-endpoint-validation.ts (100%) rename {src/backend/tests/database => plugins/web-endpoint/tests/backend}/host-web-endpoint-enumeration.test.ts (92%) rename {src/backend/tests/database => plugins/web-endpoint/tests/backend}/host-web-endpoints.test.ts (99%) rename {src/backend/tests/plugins/web-endpoint => plugins/web-endpoint/tests/backend}/routes.test.ts (88%) rename {src/backend/tests/hosts => plugins/web-endpoint/tests/backend}/web-endpoint-tunnel-manager.test.ts (90%) rename {src/backend/tests/electron => plugins/web-endpoint/tests/backend}/web-endpoint-window.test.ts (100%) rename plugins/web-endpoint/{ => tests}/frontend/WebEndpointTab.test.tsx (88%) rename plugins/web-endpoint/{ => tests}/frontend/web-endpoint-api.test.ts (87%) rename plugins/web-endpoint/{ => tests}/frontend/web-endpoint-url.test.ts (99%) rename plugins/web-endpoint/{ => tests}/frontend/web-endpoint-validation.test.ts (96%) create mode 100644 plugins/web-endpoint/tsconfig.json create mode 100644 plugins/web-endpoint/vitest.config.ts create mode 100644 plugins/workspaces/CHANGELOG.md create mode 100644 plugins/workspaces/README.md delete mode 100644 plugins/workspaces/backend/index.mjs create mode 100644 plugins/workspaces/locales/en.json create mode 100644 plugins/workspaces/package.json create mode 100644 plugins/workspaces/src/backend/index.ts rename plugins/workspaces/{ => src}/backend/routes.ts (97%) rename plugins/workspaces/{ => src}/frontend/WorkspacesPanel.tsx (100%) rename plugins/workspaces/{ => src}/frontend/index.mjs (100%) rename plugins/workspaces/{ => src}/frontend/workspaceUtils.ts (100%) rename plugins/workspaces/{ => src}/frontend/workspaces-api.ts (100%) rename {src/backend/tests/database => plugins/workspaces/tests/backend}/repositories/workspace-repository.test.ts (96%) rename {src/backend/tests/plugins/workspaces => plugins/workspaces/tests/backend}/routes.test.ts (96%) rename {src/ui/tests/plugins/workspaces => plugins/workspaces/tests/frontend}/workspaceUtils.test.ts (99%) rename {src/ui/tests/plugins/workspaces => plugins/workspaces/tests/frontend}/workspaces-api.test.ts (98%) create mode 100644 plugins/workspaces/tsconfig.json create mode 100644 plugins/workspaces/vitest.config.ts create mode 100644 scripts/build-plugins.cjs create mode 100644 scripts/check-plugin-boundaries.cjs delete mode 100644 scripts/copy-bundled-plugins.cjs create mode 100644 scripts/plugin-boundary-allowlist.json create mode 100644 scripts/run-plugin-tests.cjs diff --git a/.gitignore b/.gitignore index 7b94e7a4a..4f15e17dd 100644 --- a/.gitignore +++ b/.gitignore @@ -13,12 +13,9 @@ dist-ssr coverage *.local -# tsc -p tsconfig.plugins.json emits plugin backend .js next to its .ts -# source. It also stray-emits sibling .js next to every core .ts the plugin -# imports for type-checking (src/types, src/backend/utils, repositories...); -# scripts/copy-bundled-plugins.cjs removes those after every compile, this -# is a safety net for running tsc -p tsconfig.plugins.json directly. -/plugins/*/backend/**/*.js +# Plugin build output. termix-plugin build writes it, the packaged server +# gets its copy under dist/plugins. +/plugins/*/dist/ /src/**/*.js .vscode/* diff --git a/crowdin.yml b/crowdin.yml index 765747aa2..2dbe8904b 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -4,3 +4,5 @@ api_token: "env:CROWDIN_API_TOKEN" files: - source: /src/ui/locales/en.json translation: /src/ui/locales/translated/%locale_with_underscore%.json + - source: /plugins/*/locales/en.json + translation: /plugins/**/locales/translated/%locale_with_underscore%.json diff --git a/eslint.config.mjs b/eslint.config.mjs index 9b2278b25..a21c9c646 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -119,7 +119,7 @@ export default tseslint.config([ { patterns: [ { - group: ["**/plugins/*/backend/**"], + 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.", }, @@ -141,7 +141,7 @@ export default tseslint.config([ { patterns: [ { - group: ["**/plugins/*/frontend/**"], + group: ["**/plugins/*/src/frontend/**"], message: "The shell should not import plugin components directly. A7 replaces these with registrations through the app object.", }, @@ -164,7 +164,7 @@ export default tseslint.config([ // // What it already catches: a plugin backend importing the frontend tree, // which is not debt in any direction, just a mistake. - files: ["plugins/**/backend/**/*.{ts,mjs}"], + files: ["plugins/*/src/backend/**/*.{ts,mjs}"], rules: { "no-restricted-imports": [ "error", @@ -178,6 +178,26 @@ export default tseslint.config([ ], }, ], + // 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.", + }, + ], + }, + ], }, }, ]); diff --git a/package-lock.json b/package-lock.json index f9ea0bc4d..c8b0c00eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "2.9.0", "hasInstallScript": true, "workspaces": [ - "packages/*" + "packages/*", + "plugins/*" ], "dependencies": { "@anthropic-ai/sdk": "^0.120.0", @@ -1907,7 +1908,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1924,7 +1924,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1941,7 +1940,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1958,7 +1956,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1975,7 +1972,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1992,7 +1988,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2009,7 +2004,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2026,7 +2020,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2043,7 +2036,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2060,7 +2052,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2077,7 +2068,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2094,7 +2084,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2111,7 +2100,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2128,7 +2116,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2145,7 +2132,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2162,7 +2148,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2179,7 +2164,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2196,7 +2180,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2213,7 +2196,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2230,7 +2212,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2247,7 +2228,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2264,7 +2244,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2281,7 +2260,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2298,7 +2276,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2315,7 +2292,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2332,7 +2308,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3166,7 +3141,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dev": true, "license": "ISC", "dependencies": { "minipass": "^7.0.4" @@ -7005,6 +6979,54 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@termix-plugin/ai": { + "resolved": "plugins/ai", + "link": true + }, + "node_modules/@termix-plugin/automations": { + "resolved": "plugins/automations", + "link": true + }, + "node_modules/@termix-plugin/docker": { + "resolved": "plugins/docker", + "link": true + }, + "node_modules/@termix-plugin/fleets": { + "resolved": "plugins/fleets", + "link": true + }, + "node_modules/@termix-plugin/host-metrics": { + "resolved": "plugins/host-metrics", + "link": true + }, + "node_modules/@termix-plugin/network-topology": { + "resolved": "plugins/network-topology", + "link": true + }, + "node_modules/@termix-plugin/proxmox": { + "resolved": "plugins/proxmox", + "link": true + }, + "node_modules/@termix-plugin/remote-desktop": { + "resolved": "plugins/remote-desktop", + "link": true + }, + "node_modules/@termix-plugin/ssh-terminal": { + "resolved": "plugins/ssh-terminal", + "link": true + }, + "node_modules/@termix-plugin/tailscale": { + "resolved": "plugins/tailscale", + "link": true + }, + "node_modules/@termix-plugin/web-endpoint": { + "resolved": "plugins/web-endpoint", + "link": true + }, + "node_modules/@termix-plugin/workspaces": { + "resolved": "plugins/workspaces", + "link": true + }, "node_modules/@termix/plugin-sdk": { "resolved": "packages/plugin-sdk", "link": true @@ -9148,7 +9170,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=18" @@ -10618,7 +10639,6 @@ "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -14155,7 +14175,6 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" @@ -14165,7 +14184,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "dev": true, "license": "MIT", "dependencies": { "minipass": "^7.1.2" @@ -16998,7 +17016,6 @@ "version": "7.5.22", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -17015,7 +17032,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=18" @@ -18489,7 +18505,96 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "semver": "^7.6.3" + "esbuild": "^0.28.1", + "semver": "^7.6.3", + "tar": "^7.5.22" + }, + "bin": { + "termix-plugin": "cli/index.mjs" + } + }, + "plugins/ai": { + "name": "@termix-plugin/ai", + "version": "1.0.0", + "devDependencies": { + "@termix/plugin-sdk": "*" + } + }, + "plugins/automations": { + "name": "@termix-plugin/automations", + "version": "1.0.0", + "devDependencies": { + "@termix/plugin-sdk": "*" + } + }, + "plugins/docker": { + "name": "@termix-plugin/docker", + "version": "1.0.0", + "devDependencies": { + "@termix/plugin-sdk": "*" + } + }, + "plugins/fleets": { + "name": "@termix-plugin/fleets", + "version": "1.0.0", + "devDependencies": { + "@termix/plugin-sdk": "*" + } + }, + "plugins/host-metrics": { + "name": "@termix-plugin/host-metrics", + "version": "1.0.0", + "devDependencies": { + "@termix/plugin-sdk": "*" + } + }, + "plugins/network-topology": { + "name": "@termix-plugin/network-topology", + "version": "1.0.0", + "devDependencies": { + "@termix/plugin-sdk": "*" + } + }, + "plugins/proxmox": { + "name": "@termix-plugin/proxmox", + "version": "1.0.0", + "devDependencies": { + "@termix/plugin-sdk": "*" + } + }, + "plugins/remote-desktop": { + "name": "@termix-plugin/remote-desktop", + "version": "1.0.0", + "devDependencies": { + "@termix/plugin-sdk": "*" + } + }, + "plugins/ssh-terminal": { + "name": "@termix-plugin/ssh-terminal", + "version": "1.0.0", + "devDependencies": { + "@termix/plugin-sdk": "*" + } + }, + "plugins/tailscale": { + "name": "@termix-plugin/tailscale", + "version": "1.0.0", + "devDependencies": { + "@termix/plugin-sdk": "*" + } + }, + "plugins/web-endpoint": { + "name": "@termix-plugin/web-endpoint", + "version": "1.0.0", + "devDependencies": { + "@termix/plugin-sdk": "*" + } + }, + "plugins/workspaces": { + "name": "@termix-plugin/workspaces", + "version": "1.0.0", + "devDependencies": { + "@termix/plugin-sdk": "*" } } } diff --git a/package.json b/package.json index 073f8d115..cd3b8baee 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,8 @@ "name": "termix", "private": true, "workspaces": [ - "packages/*" + "packages/*", + "plugins/*" ], "version": "2.9.0", "description": "Self-hosted SSH and remote desktop management.", @@ -18,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 .", + "lint": "node scripts/generate-dialect-schema.cjs --check && npm run plugin:validate-manifest && eslint . && node scripts/check-plugin-boundaries.cjs", "lint:fix": "eslint --fix .", "type-check": "tsc -b --force", "test": "vitest run", @@ -27,9 +28,9 @@ "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", "dev": "vite", - "build": "npm run build:sdk && vite build && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node scripts/copy-bundled-plugins.cjs", - "build:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node scripts/copy-bundled-plugins.cjs", - "dev:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node scripts/copy-bundled-plugins.cjs && node ./dist/backend/backend/starter.js", + "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", "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", @@ -48,7 +49,10 @@ "schema:check": "node scripts/generate-dialect-schema.cjs --check", "schema:migrations": "drizzle-kit generate --config=drizzle.config.sqlite.ts && drizzle-kit generate --config=drizzle.config.pg.ts && drizzle-kit generate --config=drizzle.config.mysql.ts", "plugin:validate-manifest": "node scripts/validate-plugin-manifest.cjs", - "build:sdk": "npm run build --workspace @termix/plugin-sdk" + "build:sdk": "npm run build --workspace @termix/plugin-sdk", + "build:plugins": "node scripts/build-plugins.cjs", + "test:plugins": "node scripts/run-plugin-tests.cjs", + "test:all": "npm run test && npm run test:plugins" }, "dependencies": { "@anthropic-ai/sdk": "^0.120.0", diff --git a/packages/plugin-sdk/ARCHITECTURE.md b/packages/plugin-sdk/ARCHITECTURE.md index 9f5b9124e..0831f09b8 100644 --- a/packages/plugin-sdk/ARCHITECTURE.md +++ b/packages/plugin-sdk/ARCHITECTURE.md @@ -126,12 +126,30 @@ is disposed automatically on deactivate. See [Lifecycle rules](#lifecycle-rules) ### 11. Tests -Each plugin has `plugins//tests/backend` and `plugins//tests/frontend`, -run by its own vitest config built on the SDK preset (**A2**). Core tests -(`src/backend/tests`, `src/ui/tests`) never test plugin code; they may test the -plugin runtime with fixture plugins. +Each plugin has `plugins//tests/backend` (node) and +`plugins//tests/frontend` (jsdom), run by its own vitest config: -Until A2, plugin tests still live under `src/*/tests/plugins/`. +```ts +// plugins//vitest.config.ts +import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset"; + +export default pluginVitestConfig(import.meta.url); +``` + +The preset supplies both projects, the shared setup file +(`@termix/plugin-sdk/testing/setup`, which core's `vitest.setup.ts` re-exports +so there is one copy) and the `@/` alias the frontends still use. + +`npm run test` runs core only, `npm run test:plugins` runs every plugin and +`npm run test:all` runs both. Core tests (`src/backend/tests`, `src/ui/tests`) +never test plugin code; they may test the plugin runtime with fixture plugins, +which is why `src/backend/tests/plugins/` still holds the loader, manifest, +capability, registry, service, secret and shutdown suites. + +`@termix/plugin-sdk/testing` provides `createMockCtx()`, which enforces +capabilities the way the runtime does, `createFakeContext()` for tests that do +not care about the gates, and `renderWithApp()` for frontend tests (**A7** +finishes it). ### 12. Auth @@ -146,6 +164,24 @@ Official plugins live in `plugins//` as npm workspace packages in this monorepo for all of 2.9.0, and get split into their own repos later. Every plugin is enabled by default in 2.9.0; there is no install UI until 3.0.0. +The layout is fixed, and the directory name must equal the manifest `id`: + +``` +plugins// + manifest.json id, capabilities, contributions + package.json @termix-plugin/, private, scripts call the CLI + tsconfig.json extends @termix/plugin-sdk/tsconfig.plugin.json + vitest.config.ts one line, the SDK preset + src/backend/index.ts exports activate(ctx) and deactivate() + src/frontend/index.tsx exports activate(app) and deactivate() (A7) + locales/en.json English strings; Crowdin translates the rest + migrations/{sqlite,pg,mysql}/ owned tables (A3) + tests/backend/ vitest, node + tests/frontend/ vitest, jsdom + README.md CHANGELOG.md + dist/ build output, gitignored +``` + ### 14. Upgrades must be lossless Every move of data, settings or host columns into a plugin comes with a @@ -431,35 +467,99 @@ until one is, this document does not pretend otherwise. --- +## Building a plugin + +`termix-plugin`, the CLI the SDK ships, is the only build a plugin needs. It +runs from the plugin's own directory, so a plugin that moves to its own repo +keeps working unchanged. + +| Command | What it does | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `termix-plugin build` | esbuild bundles `src/backend/index.ts` to `dist/backend.js` (ESM, node22) and `src/frontend/index.tsx` to `dist/frontend.js` (ESM, browser), then copies `locales/` and `migrations/` into `dist/`. Imported CSS lands in `dist/frontend.css`. | +| `termix-plugin validate` | Runs the SDK's `parseManifest` and checks the files the manifest names exist. | +| `termix-plugin test` | Runs the plugin's vitest suite. | +| `termix-plugin pack` | Writes a `.tgz` of the manifest, `dist/`, locales, migrations and README. **D3** adds signing. | + +`npm run build:plugins` builds every plugin and stages the result in +`dist/plugins//`, which is what `getBundledPluginsDir()` resolves to and +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 +every emit. Nothing is written next to a source file. + +### Host-provided packages + +These ship with the Termix server and are never bundled into a plugin, because +a second copy of express or React is a bug, and a second `ssh2` means a second +set of native bindings. A plugin may depend on anything outside these lists, +and that is bundled into its output. + +**Backend** (plus every node builtin): `@termix/plugin-sdk`, `express`, `ssh2`, +`ws`, `multer`, `cookie-parser`, `axios`, `jszip`, `guacamole-lite`, +`@anthropic-ai/sdk`, `drizzle-orm`. + +**Frontend**: `react`, `react-dom`, `react/jsx-runtime`, `@termix/plugin-sdk`, +`i18next`, `react-i18next`, and the UI libraries core provides today +(`lucide-react`, `sonner`, `axios`, `cytoscape`, `react-cytoscapejs`, +`guacamole-common-js`, `react-xtermjs`, `@xterm/*`). **A7** serves these +through the SDK `ui` entry. + +The lists live in `packages/plugin-sdk/cli/lib/externals.mjs`. + +--- + ## Legacy core imports: the debt D1 removes The twelve bundled plugins predate the SDK. They still reach core by relative -path (`../../../src/backend/...`), which a build-time string rewrite in -`scripts/copy-bundled-plugins.cjs` patches to the compiled output path. +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. 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: +What the lint fence enforces today, in `eslint.config.mjs`: -- Core must not import a plugin backend. **Error**, no offenders. -- A plugin backend must not import frontend code or use the `@/` alias. - **Error**, no offenders. -- The shell must not import plugin components. **Warning** with 21 offenders, - which is exactly the work A7 does. It becomes an error then. -- Plugin backends importing core by relative path is not yet flagged, because - there is nowhere for them to go. +| 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 components | Warning | 16 files | A7 | +| A plugin importing core by relative path | Warning | 71 files | D1 | +| A plugin importing another plugin's source | Warning | 3 files | B18 | + +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: -- **TODO(B18):** `plugins/ai/backend/tools/executor.ts` imports - `../../../automations/backend/routes.js` directly. It is declared as a hard - `dependencies` entry so the loader starts automations first and marks `ai` - blocked if automations is disabled. In B18 `ai` calls automations through an +- **TODO(B18):** `plugins/ai/src/backend/tools/executor.ts` imports + `automations`' `routes.js` directly. It is declared as a hard `dependencies` + entry so the loader starts automations first and marks `ai` blocked if + automations is disabled. In B18 `ai` calls automations through an `automations` service as an `optionalDependency` and the direct import goes - away. This is the only cross-plugin import in the repo. + away. +- **TODO(B18):** `plugins/host-metrics/src/backend/` reaches `automations` + twice, for a `MetricsSnapshot` type and a fire-and-forget + `import("...headless-viewer.js")` that registers a viewer bridge. Both + degrade silently when automations is absent, so host-metrics declares it as + an `optionalDependency` (added in A2; it previously declared nothing, and + the loader had no reason to order the two). - Eight plugins keep their original core URLs through the dispatchers in `src/backend/database/routes/*-dispatch.ts` rather than `/plugin-api`. A4 moves them. diff --git a/packages/plugin-sdk/cli/commands/build.mjs b/packages/plugin-sdk/cli/commands/build.mjs new file mode 100644 index 000000000..9a29c7f8e --- /dev/null +++ b/packages/plugin-sdk/cli/commands/build.mjs @@ -0,0 +1,69 @@ +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 { readManifest, resolveEntry, copyDir } from "../lib/plugin-dir.mjs"; + +const BACKEND_ENTRIES = [ + "src/backend/index.ts", + "src/backend/index.mjs", + "src/backend/index.js", +]; +const FRONTEND_ENTRIES = [ + "src/frontend/index.tsx", + "src/frontend/index.ts", + "src/frontend/index.mjs", + "src/frontend/index.js", +]; + +export async function build({ cwd }) { + const manifest = readManifest(cwd); + const pluginId = manifest.id ?? path.basename(cwd); + const outDir = path.join(cwd, "dist"); + + fs.rmSync(outDir, { recursive: true, force: true }); + fs.mkdirSync(outDir, { recursive: true }); + + const backendEntry = resolveEntry(cwd, BACKEND_ENTRIES); + if (!backendEntry) { + throw new Error( + `${pluginId}: no backend entry (looked for ${BACKEND_ENTRIES.join(", ")})`, + ); + } + + await esbuild.build({ + entryPoints: [backendEntry], + outfile: path.join(outDir, "backend.js"), + bundle: true, + format: "esm", + platform: "node", + target: "node22", + sourcemap: true, + logLevel: "warning", + external: BACKEND_EXTERNALS, + plugins: [legacyCoreImports({ pluginId, platform: "node" })], + }); + + const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES); + if (frontendEntry) { + await esbuild.build({ + entryPoints: [frontendEntry], + outfile: path.join(outDir, "frontend.js"), + bundle: true, + format: "esm", + platform: "browser", + target: "es2022", + jsx: "automatic", + sourcemap: true, + logLevel: "warning", + external: FRONTEND_EXTERNALS, + plugins: [legacyCoreImports({ pluginId, platform: "browser" })], + }); + } + + copyDir(path.join(cwd, "locales"), path.join(outDir, "locales")); + copyDir(path.join(cwd, "migrations"), path.join(outDir, "migrations")); + + console.log(`built ${pluginId}`); +} diff --git a/packages/plugin-sdk/cli/commands/pack.mjs b/packages/plugin-sdk/cli/commands/pack.mjs new file mode 100644 index 000000000..8f6cc52ff --- /dev/null +++ b/packages/plugin-sdk/cli/commands/pack.mjs @@ -0,0 +1,33 @@ +import fs from "node:fs"; +import path from "node:path"; +import { create } from "tar"; +import { readManifest } from "../lib/plugin-dir.mjs"; + +/** Everything a server needs to run the plugin, and nothing else. D3 signs it. */ +const CONTENTS = [ + "manifest.json", + "dist", + "locales", + "migrations", + "README.md", + "CHANGELOG.md", +]; + +export async function pack({ cwd }) { + const manifest = readManifest(cwd); + const pluginId = manifest.id ?? path.basename(cwd); + const version = manifest.version ?? "0.0.0"; + + if (!fs.existsSync(path.join(cwd, "dist", "backend.js"))) { + throw new Error(`${pluginId}: nothing built. Run termix-plugin build.`); + } + + const files = CONTENTS.filter((entry) => + fs.existsSync(path.join(cwd, entry)), + ); + const outfile = path.join(cwd, `${pluginId}-${version}.tgz`); + + await create({ gzip: true, cwd, file: outfile }, files); + + console.log(`packed ${path.basename(outfile)}`); +} diff --git a/packages/plugin-sdk/cli/commands/test.mjs b/packages/plugin-sdk/cli/commands/test.mjs new file mode 100644 index 000000000..14b9f496d --- /dev/null +++ b/packages/plugin-sdk/cli/commands/test.mjs @@ -0,0 +1,33 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { createRequire } from "node:module"; + +/** + * Runs vitest through its own entry rather than the npx shim, so no shell is + * involved and paths with spaces work the same on every platform. + */ +export async function test({ cwd, args }) { + const require = createRequire(import.meta.url); + + let vitestBin; + try { + // vitest.mjs is the package bin, not an exported subpath, so resolve the + // manifest and walk to it. + const manifest = require.resolve("vitest/package.json", { paths: [cwd] }); + vitestBin = path.join(path.dirname(manifest), "vitest.mjs"); + } catch { + throw new Error( + "vitest is not installed. Run npm install at the repo root.", + ); + } + + const result = spawnSync( + process.execPath, + [vitestBin, "run", "--config", "vitest.config.ts", ...args], + { cwd, stdio: "inherit" }, + ); + + if (result.status !== 0) { + throw new Error(`vitest exited with ${result.status}`); + } +} diff --git a/packages/plugin-sdk/cli/commands/validate.mjs b/packages/plugin-sdk/cli/commands/validate.mjs new file mode 100644 index 000000000..dc32fb4fd --- /dev/null +++ b/packages/plugin-sdk/cli/commands/validate.mjs @@ -0,0 +1,44 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { readManifest } from "../lib/plugin-dir.mjs"; + +/** + * The manifest rules live in src/manifest.ts, which the server uses too, so + * there is one implementation rather than a copy here that drifts. + */ +async function loadParseManifest() { + const entry = new URL("../../dist/manifest.js", import.meta.url); + if (!fs.existsSync(fileURLToPath(entry))) { + throw new Error("The plugin SDK is not built yet. Run: npm run build:sdk"); + } + const mod = await import(entry.href); + return mod.parseManifest; +} + +export async function validate({ cwd }) { + const raw = readManifest(cwd); + const parseManifest = await loadParseManifest(); + const { errors } = parseManifest(raw); + + const problems = [...errors]; + + // The manifest names files. They have to be there. + const referenced = [ + raw.backend ?? "dist/backend.js", + raw.frontend ?? "dist/frontend.js", + raw.locales ?? "locales", + ]; + for (const rel of referenced) { + if (!fs.existsSync(path.join(cwd, rel))) { + problems.push(`manifest references ${rel}, which does not exist`); + } + } + + if (problems.length > 0) { + for (const problem of problems) console.error(` ${problem}`); + throw new Error(`${raw.id ?? path.basename(cwd)}: manifest is not valid.`); + } + + console.log(`ok ${raw.id ?? path.basename(cwd)}`); +} diff --git a/packages/plugin-sdk/cli/index.mjs b/packages/plugin-sdk/cli/index.mjs new file mode 100644 index 000000000..6b8366860 --- /dev/null +++ b/packages/plugin-sdk/cli/index.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node +/** + * termix-plugin: build, validate, test and pack a Termix plugin. + * + * Run from a plugin directory (npm run build inside plugins// does). + */ + +import process from "node:process"; +import { build } from "./commands/build.mjs"; +import { validate } from "./commands/validate.mjs"; +import { test } from "./commands/test.mjs"; +import { pack } from "./commands/pack.mjs"; + +const COMMANDS = { build, validate, test, pack }; + +const [command, ...args] = process.argv.slice(2); + +if (!command || command === "--help" || command === "-h") { + console.log( + [ + "Usage: termix-plugin ", + "", + " build Bundle the plugin into dist/", + " validate Check manifest.json and the files it names", + " test Run the plugin's vitest suite", + " pack Write a .tgz of the built plugin", + ].join("\n"), + ); + process.exit(command ? 0 : 1); +} + +const run = COMMANDS[command]; +if (!run) { + console.error(`Unknown command: ${command}`); + process.exit(1); +} + +try { + await run({ cwd: process.cwd(), args }); +} catch (error) { + console.error(error?.message ?? error); + process.exit(1); +} diff --git a/packages/plugin-sdk/cli/lib/externals.mjs b/packages/plugin-sdk/cli/lib/externals.mjs new file mode 100644 index 000000000..563ac2aaa --- /dev/null +++ b/packages/plugin-sdk/cli/lib/externals.mjs @@ -0,0 +1,48 @@ +/** + * Host-provided packages. + * + * These ship with the Termix server and are never bundled into a plugin: + * bundling them would give each plugin its own copy of express or React, and + * a second ssh2 would mean a second set of native bindings. A plugin may + * depend on anything outside these lists, and that does get bundled. + * + * Node builtins are external automatically through platform: "node". + */ + +export const BACKEND_EXTERNALS = [ + "@termix/plugin-sdk", + "@termix/plugin-sdk/*", + "express", + "ssh2", + "ws", + "multer", + "cookie-parser", + "axios", + "jszip", + "guacamole-lite", + "@anthropic-ai/sdk", + "drizzle-orm", + "drizzle-orm/*", +]; + +export const FRONTEND_EXTERNALS = [ + "react", + "react-dom", + "react/jsx-runtime", + "react-dom/client", + "@termix/plugin-sdk", + "@termix/plugin-sdk/*", + "i18next", + "react-i18next", + // Core-provided UI libraries. A7 serves these through the SDK ui entry. + "lucide-react", + "sonner", + "axios", + "cytoscape", + "react-cytoscapejs", + "guacamole-common-js", + "react-xtermjs", + "@xterm/*", + // Legacy: the shell's own modules, still reached by alias. A7 removes. + "@/*", +]; diff --git a/packages/plugin-sdk/cli/lib/legacy-core-imports.mjs b/packages/plugin-sdk/cli/lib/legacy-core-imports.mjs new file mode 100644 index 000000000..802d9b490 --- /dev/null +++ b/packages/plugin-sdk/cli/lib/legacy-core-imports.mjs @@ -0,0 +1,61 @@ +/** + * 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. + */ + +// "../../../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\//; +// "../../..//backend/" -- only plugins/ai/.../executor.ts today. +// esbuild filters are Go RE2, which has no lookahead, so "src" is excluded in +// the callback rather than the pattern. +const CROSS_PLUGIN = /^(?:\.\.\/)+([a-z0-9-]+)\/(?:src\/)?backend\//; + +/** dist/plugins//dist/backend.js -> dist/backend/... */ +const COMPILED_CORE = "../../../backend/backend/"; +const COMPILED_TYPES = "../../../backend/types/"; + +export function legacyCoreImports({ pluginId, platform }) { + return { + name: "legacy-core-imports", + setup(build) { + 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, + })); + + // The shell's own modules. Only plugins/docker's frontend entry reaches + // core this way; every other frontend uses the "@/" alias, which is + // externalized by name. A7 removes both. + if (platform === "browser") { + build.onResolve({ filter: CORE_UI }, (args) => ({ + path: args.path, + external: true, + })); + } + + build.onResolve({ filter: CROSS_PLUGIN }, (args) => { + const [, other] = CROSS_PLUGIN.exec(args.path); + if (other === "src" || other === pluginId) return null; + return { path: `../../${other}/dist/backend.js`, external: true }; + }); + }, + }; +} diff --git a/packages/plugin-sdk/cli/lib/plugin-dir.mjs b/packages/plugin-sdk/cli/lib/plugin-dir.mjs new file mode 100644 index 000000000..427543c8f --- /dev/null +++ b/packages/plugin-sdk/cli/lib/plugin-dir.mjs @@ -0,0 +1,29 @@ +import fs from "node:fs"; +import path from "node:path"; + +export function readManifest(cwd) { + const manifestPath = path.join(cwd, "manifest.json"); + if (!fs.existsSync(manifestPath)) { + throw new Error(`No manifest.json in ${cwd}`); + } + try { + return JSON.parse(fs.readFileSync(manifestPath, "utf8")); + } catch (error) { + throw new Error(`manifest.json is not valid JSON: ${error.message}`); + } +} + +/** The first of these that exists, or null. */ +export function resolveEntry(cwd, candidates) { + for (const candidate of candidates) { + const full = path.join(cwd, candidate); + if (fs.existsSync(full)) return full; + } + return null; +} + +export function copyDir(from, to) { + if (!fs.existsSync(from)) return false; + fs.cpSync(from, to, { recursive: true }); + return true; +} diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 38b1e09be..f1631ea33 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -4,6 +4,9 @@ "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "license": "MIT", "type": "module", + "bin": { + "termix-plugin": "./cli/index.mjs" + }, "exports": { "./backend": { "types": "./dist/backend.d.ts", @@ -25,17 +28,30 @@ "types": "./dist/testing.d.ts", "default": "./dist/testing.js" }, + "./testing/setup": { + "types": "./dist/testing/setup.d.ts", + "default": "./dist/testing/setup.js" + }, + "./vitest-preset": { + "types": "./dist/vitest-preset.d.ts", + "default": "./dist/vitest-preset.js" + }, + "./tsconfig.plugin.json": "./tsconfig.plugin.json", "./schema/manifest.schema.json": "./schema/manifest.schema.json" }, "files": [ "dist", + "cli", "schema", + "tsconfig.plugin.json", "ARCHITECTURE.md" ], "scripts": { "build": "tsc -p tsconfig.json" }, "dependencies": { - "semver": "^7.6.3" + "esbuild": "^0.28.1", + "semver": "^7.6.3", + "tar": "^7.5.22" } } diff --git a/packages/plugin-sdk/src/testing.ts b/packages/plugin-sdk/src/testing.ts index 47f883f22..25de63938 100644 --- a/packages/plugin-sdk/src/testing.ts +++ b/packages/plugin-sdk/src/testing.ts @@ -1,16 +1,20 @@ /** * Test helpers for plugin authors. * - * A2 adds the shared vitest preset each plugin's config extends. For now this - * exposes the one thing a plugin test needs before that lands: a context - * double that satisfies the PluginContext contract without a running server. + * createMockCtx is the one to reach for: it enforces capabilities the way the + * real runtime does. createFakeContext stays for tests that only need a + * context-shaped object and do not care about the gates. + * + * The vitest config these run under comes from @termix/plugin-sdk/vitest-preset. */ +import { PluginCapabilityError } from "./backend.js"; import type { PluginContext, PluginDisposables, PluginLogger, } from "./backend.js"; +import type { TermixApp } from "./frontend.js"; import type { PluginManifest } from "./manifest.js"; export interface FakeContextOptions { @@ -137,3 +141,148 @@ export function createFakeContext( return { ctx, disposals, emitted, kv }; } + +export interface MockContextOptions { + pluginId?: string; + manifest?: Partial; + /** Capabilities the plugin is granted. Anything else throws. */ + capabilities?: string[]; + /** Seed for ctx.kv. */ + kv?: Record; + /** Plugin settings, readable through settings.get. A5 wires the real one. */ + settings?: Record; + /** Acting user returned by currentActor and used by asUser. */ + actor?: string; +} + +export interface MockPluginContext extends FakePluginContext { + /** Settings backing store, so a test can assert what a plugin wrote. */ + settings: Map; + /** Capability ids checked during the test, in order, denied ones included. */ + checked: string[]; +} + +/** + * A context double that gates on capabilities the way src/backend/plugins/ctx.ts + * does, so a test can prove a plugin fails closed without a running server. + * + * Two gates exist on today's ctx surface: kv:own on every ctx.kv call, and + * events:core on emitting a topic outside the plugin's own namespace. As the + * SDK grows a member, add its gate here in the same shape. + */ +export function createMockCtx( + options: MockContextOptions = {}, +): MockPluginContext { + const granted = new Set(options.capabilities ?? []); + const checked: string[] = []; + const settings = new Map( + Object.entries(options.settings ?? {}), + ); + + const base = createFakeContext({ + pluginId: options.pluginId, + actor: options.actor, + manifest: { + capabilities: options.capabilities ?? [], + ...options.manifest, + }, + }); + + const { ctx, kv } = base; + const pluginId = ctx.pluginId; + + for (const [key, value] of Object.entries(options.kv ?? {})) { + kv.set(key, value); + } + + const require = (capability: string) => { + checked.push(capability); + if (!granted.has(capability)) { + throw new PluginCapabilityError(pluginId, capability); + } + }; + + const guardedKv = ctx.kv; + const gatedCtx: PluginContext = { + ...ctx, + + kv: { + get: async (key) => { + require("kv:own"); + return guardedKv.get(key); + }, + set: async (key, value) => { + require("kv:own"); + return guardedKv.set(key, value); + }, + delete: async (key) => { + require("kv:own"); + return guardedKv.delete(key); + }, + list: async () => { + require("kv:own"); + return guardedKv.list(); + }, + }, + + events: { + emit: (topic, payload) => { + if ( + !topic.startsWith(`plugin.${pluginId}.`) && + !granted.has("events:core") + ) { + throw new Error( + `Plugin ${pluginId} may only emit topics under "plugin.${pluginId}.". ` + + `Declare the events:core capability to emit core topics.`, + ); + } + ctx.events.emit(topic, payload); + }, + on: (topic, listener) => ctx.events.on(topic, listener), + }, + }; + + return { ...base, ctx: gatedCtx, settings, checked }; +} + +/** + * A TermixApp double for frontend tests. + * + * A7 builds the real registration surface; until then this records what a + * plugin's frontend entry registered, which is all a test can assert about a + * loader that does not exist yet. Deliberately free of any render library so + * a plugin only needs testing-library if its own tests render something. + */ +export interface FakeTermixApp { + app: TermixApp; + /** Cleanups the plugin handed to app.onDispose. */ + disposals: Array<() => void>; +} + +export function renderWithApp( + options: { pluginId?: string; manifest?: Partial } = {}, +): FakeTermixApp { + const pluginId = options.pluginId ?? "test-plugin"; + const disposals: Array<() => void> = []; + + const manifest = { + id: pluginId, + name: pluginId, + version: "1.0.0", + description: "", + author: { name: "test" }, + license: "MIT", + category: "Productivity", + engine: { termix: ">=2.9.0", api: "1" }, + capabilities: [], + ...options.manifest, + } as PluginManifest; + + const app: TermixApp = { + pluginId, + manifest, + onDispose: (dispose) => disposals.push(dispose), + }; + + return { app, disposals }; +} diff --git a/packages/plugin-sdk/src/testing/setup.ts b/packages/plugin-sdk/src/testing/setup.ts new file mode 100644 index 000000000..5570255aa --- /dev/null +++ b/packages/plugin-sdk/src/testing/setup.ts @@ -0,0 +1,68 @@ +/** + * Shared vitest setup for core and for every plugin suite. + * + * The root vitest.setup.ts re-exports this so there is one copy of the + * polyfills rather than two that drift apart. + */ + +import { afterEach, vi } from "vitest"; + +if (typeof window !== "undefined" && !window.matchMedia) { + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })); +} + +/** + * jsdom in this project ships without a `localStorage` global, and Node only + * provides one when started with --localstorage-file (which persists to disk + * and is shared across test files -- both wrong for tests). Suites that touch + * storage therefore threw `Cannot read properties of undefined`, taking out + * every test in the file rather than just the ones that used it. + * + * A per-process in-memory implementation is enough: it satisfies the Storage + * interface the app uses and keeps nothing on disk. + */ +if (typeof globalThis.localStorage === "undefined") { + const createMemoryStorage = (): Storage => { + const store = new Map(); + return { + get length() { + return store.size; + }, + key: (index: number) => Array.from(store.keys())[index] ?? null, + getItem: (key: string) => (store.has(key) ? store.get(key)! : null), + setItem: (key: string, value: string) => { + store.set(key, String(value)); + }, + removeItem: (key: string) => { + store.delete(key); + }, + clear: () => { + store.clear(); + }, + } as Storage; + }; + + Object.defineProperty(globalThis, "localStorage", { + value: createMemoryStorage(), + configurable: true, + writable: true, + }); + Object.defineProperty(globalThis, "sessionStorage", { + value: createMemoryStorage(), + configurable: true, + writable: true, + }); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); diff --git a/packages/plugin-sdk/src/vitest-preset.ts b/packages/plugin-sdk/src/vitest-preset.ts new file mode 100644 index 000000000..b9e0aee70 --- /dev/null +++ b/packages/plugin-sdk/src/vitest-preset.ts @@ -0,0 +1,92 @@ +/** + * The vitest config every plugin uses. + * + * A plugin's vitest.config.ts is one line: + * + * import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset"; + * export default pluginVitestConfig(import.meta.url); + * + * Two projects, matching the layout in ARCHITECTURE.md: tests/backend runs in + * node, tests/frontend in jsdom. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Walks up from a plugin directory to the repo that holds src/ui. + * + * Plugin frontends and their tests still reach core through the "@/" alias + * (the legacy debt A7 and D1 remove), so the preset has to resolve it. A + * plugin developed outside this repo has no src/ui to point at, which is + * exactly right: the alias stops resolving and the import fails loudly. + */ +function findRepoRoot(startDir: string): string | null { + let dir = startDir; + for (;;) { + if (fs.existsSync(path.join(dir, "src", "ui"))) return dir; + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +export interface PluginVitestOptions { + /** Extra aliases merged over the defaults. */ + alias?: Record; +} + +export function pluginVitestConfig( + configUrl: string, + options: PluginVitestOptions = {}, +) { + const pluginDir = path.dirname(fileURLToPath(configUrl)); + const pluginId = path.basename(pluginDir); + const setupFile = fileURLToPath( + new URL("./testing/setup.js", import.meta.url), + ); + + const repoRoot = findRepoRoot(pluginDir); + const alias: Record = { + ...(repoRoot + ? { + "@/types": path.join(repoRoot, "src", "types"), + "@": path.join(repoRoot, "src", "ui"), + } + : {}), + ...options.alias, + }; + + return { + resolve: { alias }, + test: { + globals: true, + setupFiles: [setupFile], + projects: [ + { + resolve: { alias }, + test: { + name: `${pluginId}:backend`, + root: pluginDir, + environment: "node", + globals: true, + setupFiles: [setupFile], + include: ["tests/backend/**/*.test.ts"], + }, + }, + { + resolve: { alias }, + test: { + name: `${pluginId}:frontend`, + root: pluginDir, + environment: "jsdom", + globals: true, + setupFiles: [setupFile], + include: ["tests/frontend/**/*.test.{ts,tsx}"], + }, + }, + ], + }, + }; +} diff --git a/packages/plugin-sdk/tsconfig.json b/packages/plugin-sdk/tsconfig.json index 88ab83069..73e9f979c 100644 --- a/packages/plugin-sdk/tsconfig.json +++ b/packages/plugin-sdk/tsconfig.json @@ -2,7 +2,8 @@ "compilerOptions": { "tsBuildInfoFile": "../../node_modules/.tmp/tsconfig.plugin-sdk.tsbuildinfo", "target": "ES2023", - "lib": ["ES2023"], + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "types": ["node"], "module": "nodenext", "moduleResolution": "nodenext", "skipLibCheck": true, diff --git a/packages/plugin-sdk/tsconfig.plugin.json b/packages/plugin-sdk/tsconfig.plugin.json new file mode 100644 index 000000000..b08922b9e --- /dev/null +++ b/packages/plugin-sdk/tsconfig.plugin.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "nodenext", + "moduleResolution": "nodenext", + "jsx": "react-jsx", + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "esModuleInterop": true, + "resolveJsonModule": true, + "noEmit": true, + "strict": false, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": false, + "noFallthroughCasesInSwitch": false, + "noUncheckedSideEffectImports": false, + "noImplicitAny": false, + "noImplicitThis": false, + "noUncheckedIndexedAccess": false, + "exactOptionalPropertyTypes": false, + "noPropertyAccessFromIndexSignature": false, + "allowUnusedLabels": true, + "allowUnreachableCode": true + } +} diff --git a/plugins/ai/CHANGELOG.md b/plugins/ai/CHANGELOG.md new file mode 100644 index 000000000..6a1bc3a37 --- /dev/null +++ b/plugins/ai/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 1.0.0 + +Bundled with Termix 2.9.0. diff --git a/plugins/ai/README.md b/plugins/ai/README.md new file mode 100644 index 000000000..2e7eb4ec1 --- /dev/null +++ b/plugins/ai/README.md @@ -0,0 +1,22 @@ +# AI Assistant + +An AI assistant that can read your hosts, snippets, fleets, alerts and automations, and propose changes for you to approve. + +Bundled with Termix and enabled by default. + +## Layout + + manifest.json id, capabilities and what this plugin contributes + src/backend/ activate(ctx) / deactivate(), plus the routes + src/frontend/ the UI it registers + locales/en.json its strings (English only; the rest are translated) + tests/backend/ vitest, node + tests/frontend/ vitest, jsdom + +## Commands + + npm run build bundle into dist/ + npm run test run this plugin's tests + npm run typecheck + +See packages/plugin-sdk/ARCHITECTURE.md for the contract this follows. diff --git a/plugins/ai/backend/index.mjs b/plugins/ai/backend/index.mjs deleted file mode 100644 index da04e1d01..000000000 --- a/plugins/ai/backend/index.mjs +++ /dev/null @@ -1,87 +0,0 @@ -/** - * AI Assistant - first-party, in-process plugin. - * - * This plugin runs on the main thread rather than in a worker for the same - * reason ssh-terminal, docker and host-metrics do (see - * src/backend/plugins/first-party.ts): it writes directly across hosts, - * snippets, fleets and automations through their repositories, - * runs approved commands over the shared SSH pool via resolveHostById and - * withConnection, and streams its chat replies as server-sent events. None - * of that fits the worker ctx -- ctx.hosts is a 13-field read-only view with - * no write methods and no snippets/fleets/automations surface at all, - * and ctx.http.route replies with a single postMessage value rather than a - * stream. - * - * Unlike ssh-terminal/docker/host-metrics, this plugin does not own a live - * transport (no WebSocket server, no long-lived connection that must outlive - * the main server's route table), so it does not get its own port. Instead - * it registers its Express router with the always-mounted /ai dispatcher in - * src/backend/database/routes/ai-dispatch.ts, the same "register a router, - * a small dispatcher forwards to it" shape /plugin-api uses for worker - * plugins, just scoped to this one path so /ai/* keeps its existing routes - * instead of moving under /plugin-api/ai/. - * - * routes.ts is what used to be src/backend/ai/index.ts, moved here with its - * sibling modules (context.ts, egress.ts, engine.ts, gating.ts, redaction.ts, - * providers/, tools/) and rewritten from `export default router` into - * startAiService()/stopAiService(), called from activate()/deactivate() - * below. - */ - -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -/** - * Loads a module by its path relative to this file's own directory. - * - * The plugin ships beside dist/backend, so in a built server the compiled - * .js for the moved AI files (routes.js, ...) sits right next to this .mjs, - * and core modules resolve via ../../../backend/backend/... from there - * (dist/plugins/ai/backend -> dist/backend/backend). Under vitest/dev nothing - * imports this plugin directly (the loader loads in-process plugins with a - * plain dynamic import, no tsx loader involved), so the only layout that - * needs to resolve here is the built one. - */ -async function loadRelative(relativePath) { - const here = path.dirname(fileURLToPath(import.meta.url)); - const target = path.join(here, `${relativePath}.js`); - return import(new URL(`file://${target.replace(/\\/g, "/")}`).href); -} - -let routesModule = null; - -export async function activate(ctx) { - routesModule = await loadRelative("routes"); - routesModule.startAiService(); - - // Offered by reference, not copied: this hands over a resolver, so the key - // stays ours. Rotating or deleting the provider takes effect on the next - // read by any borrower, and deactivate() below withdraws the offer entirely. - // The resolve is per-user because the keys are stored per-user and encrypted - // under that user's data key, so a borrowed read returns the acting user's - // own default key, never someone else's. - ctx.secrets.offer("api-key", async (userId) => { - const { createCurrentAiRepository } = await loadRelative( - "../../../backend/backend/database/repositories/factory", - ); - const repository = createCurrentAiRepository(); - const providers = await repository.listProviders(userId); - const preferred = providers.find((provider) => provider.apiKeyPrefix); - if (!preferred) return null; - - const withSecret = await repository.findProviderWithSecret( - preferred.id, - userId, - ); - return withSecret?.apiKey ?? null; - }); - - ctx.log.info("AI assistant router registered at /ai"); -} - -export async function deactivate() { - if (routesModule) { - routesModule.stopAiService(); - routesModule = null; - } -} diff --git a/plugins/ai/locales/en.json b/plugins/ai/locales/en.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/plugins/ai/locales/en.json @@ -0,0 +1 @@ +{} diff --git a/plugins/ai/manifest.json b/plugins/ai/manifest.json index cb57cfee1..665938960 100644 --- a/plugins/ai/manifest.json +++ b/plugins/ai/manifest.json @@ -69,8 +69,6 @@ } } }, - "backend": "backend/index.mjs", - "frontend": "frontend/index.mjs", "locales": "locales", "platforms": ["linux", "win32", "darwin"] } diff --git a/plugins/ai/package.json b/plugins/ai/package.json new file mode 100644 index 000000000..6d822dca9 --- /dev/null +++ b/plugins/ai/package.json @@ -0,0 +1,15 @@ +{ + "name": "@termix-plugin/ai", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "termix-plugin build", + "test": "termix-plugin test", + "typecheck": "tsc -p tsconfig.json", + "validate": "termix-plugin validate" + }, + "devDependencies": { + "@termix/plugin-sdk": "*" + } +} diff --git a/plugins/ai/backend/context.ts b/plugins/ai/src/backend/context.ts similarity index 100% rename from plugins/ai/backend/context.ts rename to plugins/ai/src/backend/context.ts diff --git a/plugins/ai/backend/egress.ts b/plugins/ai/src/backend/egress.ts similarity index 94% rename from plugins/ai/backend/egress.ts rename to plugins/ai/src/backend/egress.ts index fbdcb7d79..b410ece36 100644 --- a/plugins/ai/backend/egress.ts +++ b/plugins/ai/src/backend/egress.ts @@ -1,6 +1,6 @@ import { isIP } from "net"; -import { isBlockedAddress } from "../../../src/backend/utils/safe-outbound-fetch.js"; -import { createCurrentSettingsRepository } from "../../../src/backend/database/repositories/factory.js"; +import { isBlockedAddress } from "../../../../src/backend/utils/safe-outbound-fetch.js"; +import { createCurrentSettingsRepository } from "../../../../src/backend/database/repositories/factory.js"; /** * Where the assistant is allowed to send requests. diff --git a/plugins/ai/backend/engine.ts b/plugins/ai/src/backend/engine.ts similarity index 98% rename from plugins/ai/backend/engine.ts rename to plugins/ai/src/backend/engine.ts index 4b184e08f..fd0419ea1 100644 --- a/plugins/ai/backend/engine.ts +++ b/plugins/ai/src/backend/engine.ts @@ -1,4 +1,4 @@ -import { getErrorMessage } from "../../../src/backend/utils/error-message.js"; +import { getErrorMessage } from "../../../../src/backend/utils/error-message.js"; import { getAdapter } from "./providers/registry.js"; import type { ChatMessage, diff --git a/plugins/ai/backend/gating.ts b/plugins/ai/src/backend/gating.ts similarity index 93% rename from plugins/ai/backend/gating.ts rename to plugins/ai/src/backend/gating.ts index 77ec27e8e..0967519ea 100644 --- a/plugins/ai/backend/gating.ts +++ b/plugins/ai/src/backend/gating.ts @@ -1,9 +1,9 @@ import type { NextFunction, Response } from "express"; -import type { AuthenticatedRequest } from "../../../src/types/index.js"; +import type { AuthenticatedRequest } from "../../../../src/types/index.js"; import { createCurrentSettingsRepository, createCurrentUserPreferenceRepository, -} from "../../../src/backend/database/repositories/factory.js"; +} from "../../../../src/backend/database/repositories/factory.js"; /** * Three gates, all checked on the server. diff --git a/plugins/ai/src/backend/index.ts b/plugins/ai/src/backend/index.ts new file mode 100644 index 000000000..bdf5d7efe --- /dev/null +++ b/plugins/ai/src/backend/index.ts @@ -0,0 +1,32 @@ +import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { startAiService, stopAiService } from "./routes.js"; +import { createCurrentAiRepository } from "../../../../src/backend/database/repositories/factory.js"; + +export async function activate(ctx: PluginContext) { + startAiService(); + + // Offered by reference, not copied: this hands over a resolver, so the key + // stays ours. Rotating or deleting the provider takes effect on the next + // read by any borrower, and deactivate() below withdraws the offer entirely. + // The resolve is per-user because the keys are stored per-user and encrypted + // under that user's data key, so a borrowed read returns the acting user's + // own default key, never someone else's. + ctx.secrets.offer("api-key", async (userId: string) => { + const repository = createCurrentAiRepository(); + const providers = await repository.listProviders(userId); + const preferred = providers.find((provider) => provider.apiKeyPrefix); + if (!preferred) return null; + + const withSecret = await repository.findProviderWithSecret( + preferred.id, + userId, + ); + return withSecret?.apiKey ?? null; + }); + + ctx.log.info("AI assistant router registered at /ai"); +} + +export async function deactivate() { + stopAiService(); +} diff --git a/plugins/ai/backend/providers/anthropic.ts b/plugins/ai/src/backend/providers/anthropic.ts similarity index 100% rename from plugins/ai/backend/providers/anthropic.ts rename to plugins/ai/src/backend/providers/anthropic.ts diff --git a/plugins/ai/backend/providers/gemini.ts b/plugins/ai/src/backend/providers/gemini.ts similarity index 100% rename from plugins/ai/backend/providers/gemini.ts rename to plugins/ai/src/backend/providers/gemini.ts diff --git a/plugins/ai/backend/providers/http.ts b/plugins/ai/src/backend/providers/http.ts similarity index 96% rename from plugins/ai/backend/providers/http.ts rename to plugins/ai/src/backend/providers/http.ts index 758813b76..e29454d50 100644 --- a/plugins/ai/backend/providers/http.ts +++ b/plugins/ai/src/backend/providers/http.ts @@ -1,5 +1,5 @@ -import { fetchWithProxy } from "../../../../src/backend/utils/proxy-agent.js"; -import { safeOutboundFetch } from "../../../../src/backend/utils/safe-outbound-fetch.js"; +import { fetchWithProxy } from "../../../../../src/backend/utils/proxy-agent.js"; +import { safeOutboundFetch } from "../../../../../src/backend/utils/safe-outbound-fetch.js"; import { evaluateEgress, readPrivateAllowlist } from "../egress.js"; import { AiProviderError } from "./types.js"; diff --git a/plugins/ai/backend/providers/ollama.ts b/plugins/ai/src/backend/providers/ollama.ts similarity index 100% rename from plugins/ai/backend/providers/ollama.ts rename to plugins/ai/src/backend/providers/ollama.ts diff --git a/plugins/ai/backend/providers/openai.ts b/plugins/ai/src/backend/providers/openai.ts similarity index 100% rename from plugins/ai/backend/providers/openai.ts rename to plugins/ai/src/backend/providers/openai.ts diff --git a/plugins/ai/backend/providers/registry.ts b/plugins/ai/src/backend/providers/registry.ts similarity index 100% rename from plugins/ai/backend/providers/registry.ts rename to plugins/ai/src/backend/providers/registry.ts diff --git a/plugins/ai/backend/providers/types.ts b/plugins/ai/src/backend/providers/types.ts similarity index 100% rename from plugins/ai/backend/providers/types.ts rename to plugins/ai/src/backend/providers/types.ts diff --git a/plugins/ai/backend/redaction.ts b/plugins/ai/src/backend/redaction.ts similarity index 100% rename from plugins/ai/backend/redaction.ts rename to plugins/ai/src/backend/redaction.ts diff --git a/plugins/ai/backend/routes.ts b/plugins/ai/src/backend/routes.ts similarity index 98% rename from plugins/ai/backend/routes.ts rename to plugins/ai/src/backend/routes.ts index 3c551b2de..b88a772f3 100644 --- a/plugins/ai/backend/routes.ts +++ b/plugins/ai/src/backend/routes.ts @@ -1,25 +1,25 @@ -import { getErrorMessage } from "../../../src/backend/utils/error-message.js"; +import { getErrorMessage } from "../../../../src/backend/utils/error-message.js"; import express from "express"; import { registerAiRouter, unregisterAiRouter, -} from "../../../src/backend/database/routes/ai-dispatch.js"; -import type { AuthenticatedRequest } from "../../../src/types/index.js"; -import { PermissionManager } from "../../../src/backend/utils/permission-manager.js"; -import { AuthManager } from "../../../src/backend/utils/auth-manager.js"; -import { databaseLogger } from "../../../src/backend/utils/logger.js"; +} from "../../../../src/backend/database/routes/ai-dispatch.js"; +import type { AuthenticatedRequest } from "../../../../src/types/index.js"; +import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js"; +import { AuthManager } from "../../../../src/backend/utils/auth-manager.js"; +import { databaseLogger } from "../../../../src/backend/utils/logger.js"; import { getAuditUsername, getRequestMeta, logAudit, -} from "../../../src/backend/utils/audit-logger.js"; +} from "../../../../src/backend/utils/audit-logger.js"; import { createCurrentAiRepository, createCurrentHostRepository, createCurrentSettingsRepository, createCurrentUserRepository, -} from "../../../src/backend/database/repositories/factory.js"; -import type { UserRecord } from "../../../src/backend/database/repositories/user-repository.js"; +} from "../../../../src/backend/database/repositories/factory.js"; +import type { UserRecord } from "../../../../src/backend/database/repositories/user-repository.js"; import { buildSystemPrompt } from "./context.js"; import { AI_PRIVATE_ALLOWLIST_KEY, parseAllowlist } from "./egress.js"; import { runAgent } from "./engine.js"; diff --git a/plugins/ai/backend/tools/catalog.ts b/plugins/ai/src/backend/tools/catalog.ts similarity index 100% rename from plugins/ai/backend/tools/catalog.ts rename to plugins/ai/src/backend/tools/catalog.ts diff --git a/plugins/ai/backend/tools/command-allowlist.ts b/plugins/ai/src/backend/tools/command-allowlist.ts similarity index 100% rename from plugins/ai/backend/tools/command-allowlist.ts rename to plugins/ai/src/backend/tools/command-allowlist.ts diff --git a/plugins/ai/backend/tools/executor.ts b/plugins/ai/src/backend/tools/executor.ts similarity index 95% rename from plugins/ai/backend/tools/executor.ts rename to plugins/ai/src/backend/tools/executor.ts index ff8a89473..a4bb867e6 100644 --- a/plugins/ai/backend/tools/executor.ts +++ b/plugins/ai/src/backend/tools/executor.ts @@ -3,15 +3,15 @@ import { createCurrentFleetRepository, createCurrentHostRepository, createCurrentSnippetRepository, -} from "../../../../src/backend/database/repositories/factory.js"; -import { validateDefinition } from "../../../automations/backend/routes.js"; -import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js"; -import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; +} from "../../../../../src/backend/database/repositories/factory.js"; +import { validateDefinition } from "../../../../automations/src/backend/routes.js"; +import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js"; +import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js"; import { createFleetSshFactory, getFleetPoolKey, -} from "../../../../src/backend/hosts/ssh-client-factory.js"; -import { withConnection } from "../../../../src/backend/hosts/ssh-connection-pool.js"; +} from "../../../../../src/backend/hosts/ssh-client-factory.js"; +import { withConnection } from "../../../../../src/backend/hosts/ssh-connection-pool.js"; import { getTool } from "./catalog.js"; /** Approved commands get a bounded window rather than hanging the request. */ diff --git a/plugins/ai/backend/tools/propose-tools.ts b/plugins/ai/src/backend/tools/propose-tools.ts similarity index 100% rename from plugins/ai/backend/tools/propose-tools.ts rename to plugins/ai/src/backend/tools/propose-tools.ts diff --git a/plugins/ai/backend/tools/read-tools.ts b/plugins/ai/src/backend/tools/read-tools.ts similarity index 99% rename from plugins/ai/backend/tools/read-tools.ts rename to plugins/ai/src/backend/tools/read-tools.ts index 235a4ff53..4cfbddba8 100644 --- a/plugins/ai/backend/tools/read-tools.ts +++ b/plugins/ai/src/backend/tools/read-tools.ts @@ -8,7 +8,7 @@ import { createCurrentNetworkTopologyRepository, createCurrentSnippetRepository, createCurrentWorkspaceRepository, -} from "../../../../src/backend/database/repositories/factory.js"; +} from "../../../../../src/backend/database/repositories/factory.js"; import { num, objectSchema, type AiTool } from "./types.js"; /** diff --git a/plugins/ai/backend/tools/types.ts b/plugins/ai/src/backend/tools/types.ts similarity index 100% rename from plugins/ai/backend/tools/types.ts rename to plugins/ai/src/backend/tools/types.ts diff --git a/plugins/ai/frontend/index.mjs b/plugins/ai/src/frontend/index.mjs similarity index 100% rename from plugins/ai/frontend/index.mjs rename to plugins/ai/src/frontend/index.mjs diff --git a/src/backend/tests/plugins/ai/context.test.ts b/plugins/ai/tests/backend/context.test.ts similarity index 95% rename from src/backend/tests/plugins/ai/context.test.ts rename to plugins/ai/tests/backend/context.test.ts index c10bd409c..6a6d587be 100644 --- a/src/backend/tests/plugins/ai/context.test.ts +++ b/plugins/ai/tests/backend/context.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildSystemPrompt } from "../../../../../plugins/ai/backend/context.js"; +import { buildSystemPrompt } from "../../src/backend/context.js"; const BASE = { hostCount: 3, allowReadOnlyCommands: false }; diff --git a/src/backend/tests/plugins/ai/egress.test.ts b/plugins/ai/tests/backend/egress.test.ts similarity index 98% rename from src/backend/tests/plugins/ai/egress.test.ts rename to plugins/ai/tests/backend/egress.test.ts index e5cfdecb6..9d4ab5cd6 100644 --- a/src/backend/tests/plugins/ai/egress.test.ts +++ b/plugins/ai/tests/backend/egress.test.ts @@ -4,7 +4,7 @@ import { evaluateEgress, isPrivateDestination, parseAllowlist, -} from "../../../../../plugins/ai/backend/egress.js"; +} from "../../src/backend/egress.js"; /** * The rule that lets a self-hosted Ollama work without turning the backend diff --git a/src/backend/tests/plugins/ai/engine.test.ts b/plugins/ai/tests/backend/engine.test.ts similarity index 94% rename from src/backend/tests/plugins/ai/engine.test.ts rename to plugins/ai/tests/backend/engine.test.ts index 213bfd5ea..13a32ad7b 100644 --- a/src/backend/tests/plugins/ai/engine.test.ts +++ b/plugins/ai/tests/backend/engine.test.ts @@ -1,14 +1,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { ChatChunk } from "../../../../../plugins/ai/backend/providers/types.js"; +import type { ChatChunk } from "../../src/backend/providers/types.js"; const streamChat = vi.fn(); const handler = vi.fn(); -vi.mock("../../../../../plugins/ai/backend/providers/registry.js", () => ({ +vi.mock("../../src/backend/providers/registry.js", () => ({ getAdapter: () => ({ streamChat, listModels: async () => [] }), })); -vi.mock("../../../../../plugins/ai/backend/tools/catalog.js", () => ({ +vi.mock("../../src/backend/tools/catalog.js", () => ({ getTool: (name: string) => name === "list_hosts" ? { @@ -24,8 +24,7 @@ vi.mock("../../../../../plugins/ai/backend/tools/catalog.js", () => ({ ], })); -const { runAgent } = - await import("../../../../../plugins/ai/backend/engine.js"); +const { runAgent } = await import("../../src/backend/engine.js"); function chunks(...values: ChatChunk[]) { return (async function* () { diff --git a/src/backend/tests/plugins/ai/gating.test.ts b/plugins/ai/tests/backend/gating.test.ts similarity index 96% rename from src/backend/tests/plugins/ai/gating.test.ts rename to plugins/ai/tests/backend/gating.test.ts index 74f7e7948..e1939af96 100644 --- a/src/backend/tests/plugins/ai/gating.test.ts +++ b/plugins/ai/tests/backend/gating.test.ts @@ -3,13 +3,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const settingsRepository = { getBoolean: vi.fn() }; const userPreferenceRepository = { findByUserId: vi.fn() }; -vi.mock("../../../database/repositories/factory.js", () => ({ +vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({ createCurrentSettingsRepository: () => settingsRepository, createCurrentUserPreferenceRepository: () => userPreferenceRepository, })); const { isAiGloballyEnabled, resolveAiAccess } = - await import("../../../../../plugins/ai/backend/gating.js"); + await import("../../src/backend/gating.js"); describe("AI gating", () => { beforeEach(() => { diff --git a/src/backend/tests/plugins/ai/providers/provider-http.test.ts b/plugins/ai/tests/backend/providers/provider-http.test.ts similarity index 83% rename from src/backend/tests/plugins/ai/providers/provider-http.test.ts rename to plugins/ai/tests/backend/providers/provider-http.test.ts index e86d815a0..1fd571536 100644 --- a/src/backend/tests/plugins/ai/providers/provider-http.test.ts +++ b/plugins/ai/tests/backend/providers/provider-http.test.ts @@ -7,20 +7,20 @@ const readPrivateAllowlist = vi.fn(); const evaluateEgress = vi.fn(); const globalFetch = vi.fn(); -vi.mock("../../../../utils/proxy-agent.js", () => ({ +vi.mock("../../../../../src/backend/utils/proxy-agent.js", () => ({ fetchWithProxy, getFetchDispatcher, })); -vi.mock("../../../../utils/safe-outbound-fetch.js", () => ({ +vi.mock("../../../../../src/backend/utils/safe-outbound-fetch.js", () => ({ safeOutboundFetch, })); -vi.mock("../../../../../../plugins/ai/backend/egress.js", () => ({ +vi.mock("../../../src/backend/egress.js", () => ({ readPrivateAllowlist, evaluateEgress, })); const { providerFetch } = - await import("../../../../../../plugins/ai/backend/providers/http.js"); + await import("../../../src/backend/providers/http.js"); describe("providerFetch", () => { beforeEach(() => { diff --git a/src/backend/tests/plugins/ai/providers/providers.test.ts b/plugins/ai/tests/backend/providers/providers.test.ts similarity index 94% rename from src/backend/tests/plugins/ai/providers/providers.test.ts rename to plugins/ai/tests/backend/providers/providers.test.ts index 6275ec021..4a982f712 100644 --- a/src/backend/tests/plugins/ai/providers/providers.test.ts +++ b/plugins/ai/tests/backend/providers/providers.test.ts @@ -1,21 +1,21 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { ChatChunk } from "../../../../../../plugins/ai/backend/providers/types.js"; +import type { ChatChunk } from "../../../src/backend/providers/types.js"; const providerFetch = vi.fn(); -vi.mock("../../../../../../plugins/ai/backend/providers/http.js", async () => { +vi.mock("../../../src/backend/providers/http.js", async () => { const actual = await vi.importActual< - typeof import("../../../../../../plugins/ai/backend/providers/http.js") - >("../../../../../../plugins/ai/backend/providers/http.js"); + typeof import("../../../src/backend/providers/http.js") + >("../../../src/backend/providers/http.js"); return { ...actual, providerFetch }; }); const { openAiAdapter } = - await import("../../../../../../plugins/ai/backend/providers/openai.js"); + await import("../../../src/backend/providers/openai.js"); const { ollamaAdapter } = - await import("../../../../../../plugins/ai/backend/providers/ollama.js"); + await import("../../../src/backend/providers/ollama.js"); const { geminiAdapter } = - await import("../../../../../../plugins/ai/backend/providers/gemini.js"); + await import("../../../src/backend/providers/gemini.js"); /** Builds a Response whose body streams the given text chunks. */ function streamingResponse(lines: string[]): Response { diff --git a/src/backend/tests/plugins/ai/redaction.test.ts b/plugins/ai/tests/backend/redaction.test.ts similarity index 95% rename from src/backend/tests/plugins/ai/redaction.test.ts rename to plugins/ai/tests/backend/redaction.test.ts index 858939a78..c8d4ef8da 100644 --- a/src/backend/tests/plugins/ai/redaction.test.ts +++ b/plugins/ai/tests/backend/redaction.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - REDACTED, - redact, - redactString, -} from "../../../../../plugins/ai/backend/redaction.js"; +import { REDACTED, redact, redactString } from "../../src/backend/redaction.js"; describe("redact", () => { it("drops secret-named fields at any depth", () => { diff --git a/src/backend/tests/plugins/ai/tools/command-allowlist.test.ts b/plugins/ai/tests/backend/tools/command-allowlist.test.ts similarity index 96% rename from src/backend/tests/plugins/ai/tools/command-allowlist.test.ts rename to plugins/ai/tests/backend/tools/command-allowlist.test.ts index 6b1d97037..4b0adff03 100644 --- a/src/backend/tests/plugins/ai/tools/command-allowlist.test.ts +++ b/plugins/ai/tests/backend/tools/command-allowlist.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isReadOnlyCommand } from "../../../../../../plugins/ai/backend/tools/command-allowlist.js"; +import { isReadOnlyCommand } from "../../../src/backend/tools/command-allowlist.js"; /** * These commands can run without a per-command approval click, so the parser diff --git a/src/backend/tests/plugins/ai/tools/proposal-executor.test.ts b/plugins/ai/tests/backend/tools/proposal-executor.test.ts similarity index 93% rename from src/backend/tests/plugins/ai/tools/proposal-executor.test.ts rename to plugins/ai/tests/backend/tools/proposal-executor.test.ts index 3c6ba6131..3e2aad620 100644 --- a/src/backend/tests/plugins/ai/tools/proposal-executor.test.ts +++ b/plugins/ai/tests/backend/tools/proposal-executor.test.ts @@ -15,7 +15,7 @@ const snippetRepository = { const fleetRepository = { create: vi.fn(), addMember: vi.fn() }; const automationRepository = { create: vi.fn() }; -vi.mock("../../../../database/repositories/factory.js", () => ({ +vi.mock("../../../../../src/backend/database/repositories/factory.js", () => ({ createCurrentHostRepository: () => hostRepository, createCurrentSnippetRepository: () => snippetRepository, createCurrentFleetRepository: () => fleetRepository, @@ -23,19 +23,22 @@ vi.mock("../../../../database/repositories/factory.js", () => ({ })); const resolveHostById = vi.fn(); -vi.mock("../../../../hosts/host-resolver.js", () => ({ +vi.mock("../../../../../src/backend/hosts/host-resolver.js", () => ({ resolveHostById: (...args: unknown[]) => resolveHostById(...args), })); const execCommand = vi.fn(); -vi.mock("../../../../hosts/metrics-shared/common-utils.js", () => ({ - execCommand: (...args: unknown[]) => execCommand(...args), -})); -vi.mock("../../../../hosts/ssh-client-factory.js", () => ({ +vi.mock( + "../../../../../src/backend/hosts/metrics-shared/common-utils.js", + () => ({ + execCommand: (...args: unknown[]) => execCommand(...args), + }), +); +vi.mock("../../../../../src/backend/hosts/ssh-client-factory.js", () => ({ createFleetSshFactory: () => () => ({}), getFleetPoolKey: () => "pool", })); -vi.mock("../../../../hosts/ssh-connection-pool.js", () => ({ +vi.mock("../../../../../src/backend/hosts/ssh-connection-pool.js", () => ({ withConnection: async ( _key: string, _factory: unknown, @@ -44,7 +47,7 @@ vi.mock("../../../../hosts/ssh-connection-pool.js", () => ({ })); const { applyProposal } = - await import("../../../../../../plugins/ai/backend/tools/executor.js"); + await import("../../../src/backend/tools/executor.js"); describe("applyProposal", () => { beforeEach(() => { diff --git a/src/backend/tests/plugins/ai/tools/tool-catalog.test.ts b/plugins/ai/tests/backend/tools/tool-catalog.test.ts similarity index 98% rename from src/backend/tests/plugins/ai/tools/tool-catalog.test.ts rename to plugins/ai/tests/backend/tools/tool-catalog.test.ts index c34cb285e..db6dbac3b 100644 --- a/src/backend/tests/plugins/ai/tools/tool-catalog.test.ts +++ b/plugins/ai/tests/backend/tools/tool-catalog.test.ts @@ -5,7 +5,7 @@ import { getTool, listToolNames, toolDefinitions, -} from "../../../../../../plugins/ai/backend/tools/catalog.js"; +} from "../../../src/backend/tools/catalog.js"; /** * The security regression test for the whole feature. diff --git a/src/ui/tests/features/ai/AiProviderSettings.test.tsx b/plugins/ai/tests/frontend/AiProviderSettings.test.tsx similarity index 100% rename from src/ui/tests/features/ai/AiProviderSettings.test.tsx rename to plugins/ai/tests/frontend/AiProviderSettings.test.tsx diff --git a/src/ui/tests/features/ai/labels.test.ts b/plugins/ai/tests/frontend/labels.test.ts similarity index 96% rename from src/ui/tests/features/ai/labels.test.ts rename to plugins/ai/tests/frontend/labels.test.ts index d88828c09..f16bc00cd 100644 --- a/src/ui/tests/features/ai/labels.test.ts +++ b/plugins/ai/tests/frontend/labels.test.ts @@ -5,7 +5,7 @@ import { mentionLabel, toolLabel, } from "@/features/ai/labels"; -import { AI_TOOLS } from "../../../../../plugins/ai/backend/tools/catalog"; +import { AI_TOOLS } from "../../src/backend/tools/catalog.js"; describe("humanize", () => { it("splits camelCase into words", () => { diff --git a/plugins/ai/tsconfig.json b/plugins/ai/tsconfig.json new file mode 100644 index 000000000..1d3e42542 --- /dev/null +++ b/plugins/ai/tsconfig.json @@ -0,0 +1,10 @@ +{ + "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/ai/vitest.config.ts b/plugins/ai/vitest.config.ts new file mode 100644 index 000000000..31ba004fc --- /dev/null +++ b/plugins/ai/vitest.config.ts @@ -0,0 +1,3 @@ +import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset"; + +export default pluginVitestConfig(import.meta.url); diff --git a/plugins/automations/CHANGELOG.md b/plugins/automations/CHANGELOG.md new file mode 100644 index 000000000..6a1bc3a37 --- /dev/null +++ b/plugins/automations/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 1.0.0 + +Bundled with Termix 2.9.0. diff --git a/plugins/automations/README.md b/plugins/automations/README.md new file mode 100644 index 000000000..a2e969fd1 --- /dev/null +++ b/plugins/automations/README.md @@ -0,0 +1,22 @@ +# Automations + +Trigger-and-action automation engine: schedules, metric/status/docker-event triggers, and multi-step actions across managed hosts. + +Bundled with Termix and enabled by default. + +## Layout + + manifest.json id, capabilities and what this plugin contributes + src/backend/ activate(ctx) / deactivate(), plus the routes + src/frontend/ the UI it registers + locales/en.json its strings (English only; the rest are translated) + tests/backend/ vitest, node + tests/frontend/ vitest, jsdom + +## Commands + + npm run build bundle into dist/ + npm run test run this plugin's tests + npm run typecheck + +See packages/plugin-sdk/ARCHITECTURE.md for the contract this follows. diff --git a/plugins/automations/backend/index.mjs b/plugins/automations/backend/index.mjs deleted file mode 100644 index 6435c2780..000000000 --- a/plugins/automations/backend/index.mjs +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Automations - first-party, in-process plugin. - * - * This plugin runs on the main thread rather than in a worker for the same - * reason ai and fleets do (see src/backend/plugins/first-party.ts): its - * engine and scheduler write across hosts, snippets, fleets, and - * notification-channel repositories through the shared SSH pool via - * resolveHostById and withConnection, and its scheduler needs direct - * repository access plus event-bus subscription that the worker ctx's - * read-only ctx.hosts and single-postMessage ctx.http.route cannot support. - * - * Like fleets, this plugin does not own a live transport (no WebSocket - * server, no dedicated port), so it registers its Express router with the - * always-mounted /automations dispatcher in - * src/backend/database/routes/automation-dispatch.ts instead of getting its - * own port. - * - * routes.ts is what used to be src/backend/database/routes/automations.ts, - * moved here and rewritten from `export default router` into - * startAutomationsService()/stopAutomationsService(), called from - * activate()/deactivate() below. scheduler.ts owns the one setInterval the - * feature has always used for due schedules, dwell rechecks, docker-event - * polling and history pruning -- there is no ctx.schedule API for an - * in-process plugin, so it starts and stops the same way it always has. The - * automations/automationTriggerState/automationSchedules/automationRuns/ - * automationRunSteps/automationChannels tables and their repository stay in - * core (src/backend/database/db/schema.ts, - * src/backend/database/repositories/automation-repository.ts) -- this plugin - * system has no owned-table migration mechanism of its own, and these tables - * carry FKs into core's hosts/users tables. - */ - -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -/** - * Loads a module by its path relative to this file's own directory. - * - * The plugin ships beside dist/backend, so in a built server the compiled - * .js for routes.js/scheduler.js sits right next to this .mjs, and core - * modules resolve via ../../../src/backend/... from there. Under - * vitest/dev nothing imports this plugin directly (the loader loads - * in-process plugins with a plain dynamic import, no tsx loader involved), - * so the only layout that needs to resolve here is the built one. - */ -async function loadRelative(relativePath) { - const here = path.dirname(fileURLToPath(import.meta.url)); - const target = path.join(here, `${relativePath}.js`); - return import(new URL(`file://${target.replace(/\\/g, "/")}`).href); -} - -let routesModule = null; -let schedulerModule = null; - -export async function activate(ctx) { - routesModule = await loadRelative("routes"); - routesModule.startAutomationsService(); - - schedulerModule = await loadRelative("scheduler"); - schedulerModule.startAutomationScheduler(); - - ctx.log.info( - "Automations router registered at /automations; scheduler started", - ); -} - -export async function deactivate() { - if (schedulerModule) { - schedulerModule.stopAutomationScheduler(); - schedulerModule = null; - } - if (routesModule) { - routesModule.stopAutomationsService(); - routesModule = null; - } -} diff --git a/plugins/automations/locales/en.json b/plugins/automations/locales/en.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/plugins/automations/locales/en.json @@ -0,0 +1 @@ +{} diff --git a/plugins/automations/manifest.json b/plugins/automations/manifest.json index 866ac6a90..e376a1d7a 100644 --- a/plugins/automations/manifest.json +++ b/plugins/automations/manifest.json @@ -25,8 +25,6 @@ "network:outbound", "ui:surface" ], - "backend": "backend/index.mjs", - "frontend": "frontend/index.mjs", "locales": "locales", "platforms": ["linux", "win32", "darwin"] } diff --git a/plugins/automations/package.json b/plugins/automations/package.json new file mode 100644 index 000000000..7872dcbbb --- /dev/null +++ b/plugins/automations/package.json @@ -0,0 +1,15 @@ +{ + "name": "@termix-plugin/automations", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "termix-plugin build", + "test": "termix-plugin test", + "typecheck": "tsc -p tsconfig.json", + "validate": "termix-plugin validate" + }, + "devDependencies": { + "@termix/plugin-sdk": "*" + } +} diff --git a/plugins/automations/backend/actions/host-targets.ts b/plugins/automations/src/backend/actions/host-targets.ts similarity index 84% rename from plugins/automations/backend/actions/host-targets.ts rename to plugins/automations/src/backend/actions/host-targets.ts index d855b09be..95cdc832b 100644 --- a/plugins/automations/backend/actions/host-targets.ts +++ b/plugins/automations/src/backend/actions/host-targets.ts @@ -1,7 +1,7 @@ -import type { HostSelector } from "../../../../src/types/automations.js"; -import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js"; -import { createCurrentFleetRepository } from "../../../../src/backend/database/repositories/factory.js"; -import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js"; +import type { HostSelector } from "../../../../../src/types/automations.js"; +import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js"; +import { createCurrentFleetRepository } from "../../../../../src/backend/database/repositories/factory.js"; +import { PermissionManager } from "../../../../../src/backend/utils/permission-manager.js"; import type { StepExecutionContext } from "./types.js"; /** A host the caller is allowed to act on. `host` is always resolved. */ @@ -75,7 +75,7 @@ async function fleetHostIds( async function allAccessibleHostIds(userId: string): Promise { try { const { createCurrentHostRepository } = - await import("../../../../src/backend/database/repositories/factory.js"); + await import("../../../../../src/backend/database/repositories/factory.js"); const hosts = await createCurrentHostRepository().listByUserId(userId); const ids = hosts.map((host) => host.id); const allowed = diff --git a/plugins/automations/backend/actions/index.ts b/plugins/automations/src/backend/actions/index.ts similarity index 94% rename from plugins/automations/backend/actions/index.ts rename to plugins/automations/src/backend/actions/index.ts index 5a3f54101..bebd1e974 100644 --- a/plugins/automations/backend/actions/index.ts +++ b/plugins/automations/src/backend/actions/index.ts @@ -1,22 +1,22 @@ import { DEFAULT_STEP_TIMEOUT_MS, type Step, -} from "../../../../src/types/automations.js"; -import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; +} from "../../../../../src/types/automations.js"; +import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js"; import { execElevated, shellSingleQuote, -} from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; +} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; import { createFleetSshFactory, getFleetPoolKey, -} from "../../../../src/backend/hosts/ssh-client-factory.js"; -import { withConnection } from "../../../../src/backend/hosts/ssh-connection-pool.js"; -import { resolveSnippetCommand } from "../../../../src/backend/database/routes/snippets-execution.js"; +} from "../../../../../src/backend/hosts/ssh-client-factory.js"; +import { withConnection } from "../../../../../src/backend/hosts/ssh-connection-pool.js"; +import { resolveSnippetCommand } from "../../../../../src/backend/database/routes/snippets-execution.js"; import { createCurrentNotificationChannelRepository, createCurrentSnippetRepository, -} from "../../../../src/backend/database/repositories/factory.js"; +} from "../../../../../src/backend/database/repositories/factory.js"; import { sendAutomationNotification } from "../notify.js"; import { automationFetch } from "../http.js"; import { renderRecord, renderTemplate } from "../template.js"; @@ -248,7 +248,7 @@ async function runTunnel( try { const manager = - await import("../../../../src/backend/hosts/tunnel/manager.js"); + await import("../../../../../src/backend/hosts/tunnel/manager.js"); const config = manager.tunnelConfigs?.get(name); if (!config) return fail(`Tunnel "${name}" is not configured`); @@ -284,7 +284,7 @@ async function runWol( try { const { sendWakeOnLan, isValidMac } = - await import("../../../../src/backend/utils/wake-on-lan.js"); + await import("../../../../../src/backend/utils/wake-on-lan.js"); if (!isValidMac(mac)) return fail(`Invalid MAC address: ${mac}`); await sendWakeOnLan(mac); return ok(`Sent magic packet to ${target.name}`); diff --git a/plugins/automations/backend/actions/types.ts b/plugins/automations/src/backend/actions/types.ts similarity index 96% rename from plugins/automations/backend/actions/types.ts rename to plugins/automations/src/backend/actions/types.ts index a0ad32669..8454272db 100644 --- a/plugins/automations/backend/actions/types.ts +++ b/plugins/automations/src/backend/actions/types.ts @@ -1,4 +1,4 @@ -import type { Step } from "../../../../src/types/automations.js"; +import type { Step } from "../../../../../src/types/automations.js"; import type { TemplateContext } from "../template.js"; /** What every executor returns. `output` is what later steps can read. */ diff --git a/plugins/automations/backend/conditions.ts b/plugins/automations/src/backend/conditions.ts similarity index 98% rename from plugins/automations/backend/conditions.ts rename to plugins/automations/src/backend/conditions.ts index 9e291ae76..eadd45a0a 100644 --- a/plugins/automations/backend/conditions.ts +++ b/plugins/automations/src/backend/conditions.ts @@ -1,4 +1,7 @@ -import type { MetricPath, Operator } from "../../../src/types/automations.js"; +import type { + MetricPath, + Operator, +} from "../../../../src/types/automations.js"; /** * Operator evaluation and metric extraction. diff --git a/plugins/automations/backend/cron.ts b/plugins/automations/src/backend/cron.ts similarity index 100% rename from plugins/automations/backend/cron.ts rename to plugins/automations/src/backend/cron.ts diff --git a/plugins/automations/backend/docker-watcher.ts b/plugins/automations/src/backend/docker-watcher.ts similarity index 91% rename from plugins/automations/backend/docker-watcher.ts rename to plugins/automations/src/backend/docker-watcher.ts index 1e93d3e33..719e60f02 100644 --- a/plugins/automations/backend/docker-watcher.ts +++ b/plugins/automations/src/backend/docker-watcher.ts @@ -1,14 +1,14 @@ -import type { AutomationDefinition } from "../../../src/types/automations.js"; -import { createCurrentAutomationRepository } from "../../../src/backend/database/repositories/factory.js"; -import { resolveHostById } from "../../../src/backend/hosts/host-resolver.js"; -import { DataCrypto } from "../../../src/backend/utils/data-crypto.js"; -import { execCommand } from "../../../src/backend/hosts/metrics-shared/common-utils.js"; +import type { AutomationDefinition } from "../../../../src/types/automations.js"; +import { createCurrentAutomationRepository } from "../../../../src/backend/database/repositories/factory.js"; +import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js"; +import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js"; +import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; import { createFleetSshFactory, getFleetPoolKey, -} from "../../../src/backend/hosts/ssh-client-factory.js"; -import { withConnection } from "../../../src/backend/hosts/ssh-connection-pool.js"; -import { statsLogger } from "../../../src/backend/utils/logger.js"; +} from "../../../../src/backend/hosts/ssh-client-factory.js"; +import { withConnection } from "../../../../src/backend/hosts/ssh-connection-pool.js"; +import { statsLogger } from "../../../../src/backend/utils/logger.js"; import { onDockerEvent } from "./triggers.js"; /** diff --git a/plugins/automations/backend/engine.ts b/plugins/automations/src/backend/engine.ts similarity index 96% rename from plugins/automations/backend/engine.ts rename to plugins/automations/src/backend/engine.ts index 925d5db24..60b5454db 100644 --- a/plugins/automations/backend/engine.ts +++ b/plugins/automations/src/backend/engine.ts @@ -2,16 +2,16 @@ import type { AutomationDefinition, RunStatus, Step, -} from "../../../src/types/automations.js"; +} from "../../../../src/types/automations.js"; import { DEFAULT_MAX_RUN_SECONDS, MAX_AUTOMATION_DEPTH, MAX_STEP_OUTPUT_BYTES, -} from "../../../src/types/automations.js"; -import { createCurrentAutomationRepository } from "../../../src/backend/database/repositories/factory.js"; -import { statsLogger } from "../../../src/backend/utils/logger.js"; -import { resolveHostById } from "../../../src/backend/hosts/host-resolver.js"; -import { notifyAutomationInternalEvent } from "../../../src/backend/hosts/automation-events.js"; +} from "../../../../src/types/automations.js"; +import { createCurrentAutomationRepository } from "../../../../src/backend/database/repositories/factory.js"; +import { statsLogger } from "../../../../src/backend/utils/logger.js"; +import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js"; +import { notifyAutomationInternalEvent } from "../../../../src/backend/hosts/automation-events.js"; import { executeStep } from "./actions/index.js"; import type { StepExecutionContext, StepResult } from "./actions/types.js"; import { compare } from "./conditions.js"; diff --git a/plugins/automations/backend/headless-viewer.ts b/plugins/automations/src/backend/headless-viewer.ts similarity index 97% rename from plugins/automations/backend/headless-viewer.ts rename to plugins/automations/src/backend/headless-viewer.ts index 502d7abc1..8049c2568 100644 --- a/plugins/automations/backend/headless-viewer.ts +++ b/plugins/automations/src/backend/headless-viewer.ts @@ -1,4 +1,4 @@ -import { statsLogger } from "../../../src/backend/utils/logger.js"; +import { statsLogger } from "../../../../src/backend/utils/logger.js"; import { listAutomationWatchedHosts } from "./triggers.js"; /** diff --git a/plugins/automations/backend/http.ts b/plugins/automations/src/backend/http.ts similarity index 91% rename from plugins/automations/backend/http.ts rename to plugins/automations/src/backend/http.ts index ed2a6df4b..2e15afd33 100644 --- a/plugins/automations/backend/http.ts +++ b/plugins/automations/src/backend/http.ts @@ -1,5 +1,5 @@ -import { safeOutboundFetch } from "../../../src/backend/utils/safe-outbound-fetch.js"; -import { readNotificationPrivateAllowlist } from "../../../src/backend/utils/notification-egress.js"; +import { safeOutboundFetch } from "../../../../src/backend/utils/safe-outbound-fetch.js"; +import { readNotificationPrivateAllowlist } from "../../../../src/backend/utils/notification-egress.js"; /** * Outbound HTTP for automation steps and notification channels. diff --git a/plugins/automations/src/backend/index.ts b/plugins/automations/src/backend/index.ts new file mode 100644 index 000000000..d650c68fe --- /dev/null +++ b/plugins/automations/src/backend/index.ts @@ -0,0 +1,19 @@ +import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { startAutomationsService, stopAutomationsService } from "./routes.js"; +import { + startAutomationScheduler, + stopAutomationScheduler, +} from "./scheduler.js"; + +export async function activate(ctx: PluginContext) { + startAutomationsService(); + startAutomationScheduler(); + ctx.log.info( + "Automations router registered at /automations; scheduler started", + ); +} + +export async function deactivate() { + stopAutomationScheduler(); + stopAutomationsService(); +} diff --git a/plugins/automations/backend/notify.ts b/plugins/automations/src/backend/notify.ts similarity index 98% rename from plugins/automations/backend/notify.ts rename to plugins/automations/src/backend/notify.ts index 2df127966..25d0e2c61 100644 --- a/plugins/automations/backend/notify.ts +++ b/plugins/automations/src/backend/notify.ts @@ -1,4 +1,4 @@ -import { statsLogger } from "../../../src/backend/utils/logger.js"; +import { statsLogger } from "../../../../src/backend/utils/logger.js"; import { automationFetch } from "./http.js"; import type { TemplateContext } from "./template.js"; diff --git a/plugins/automations/backend/routes.ts b/plugins/automations/src/backend/routes.ts similarity index 96% rename from plugins/automations/backend/routes.ts rename to plugins/automations/src/backend/routes.ts index 09515657a..8fb43277b 100644 --- a/plugins/automations/backend/routes.ts +++ b/plugins/automations/src/backend/routes.ts @@ -1,28 +1,28 @@ import crypto from "node:crypto"; import express, { type Request, type Response } from "express"; -import type { AuthenticatedRequest } from "../../../src/types/index.js"; +import type { AuthenticatedRequest } from "../../../../src/types/index.js"; import type { AutomationDefinition, Step, Trigger, -} from "../../../src/types/automations.js"; -import { AUTOMATION_DEFINITION_VERSION } from "../../../src/types/automations.js"; -import { PermissionManager } from "../../../src/backend/utils/permission-manager.js"; -import { AuthManager } from "../../../src/backend/utils/auth-manager.js"; -import { databaseLogger } from "../../../src/backend/utils/logger.js"; +} from "../../../../src/types/automations.js"; +import { AUTOMATION_DEFINITION_VERSION } from "../../../../src/types/automations.js"; +import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js"; +import { AuthManager } from "../../../../src/backend/utils/auth-manager.js"; +import { databaseLogger } from "../../../../src/backend/utils/logger.js"; import { getAuditUsername, getRequestMeta, logAudit, -} from "../../../src/backend/utils/audit-logger.js"; -import { createCurrentAutomationRepository } from "../../../src/backend/database/repositories/factory.js"; -import type { AutomationRow } from "../../../src/backend/database/repositories/automation-repository.js"; +} from "../../../../src/backend/utils/audit-logger.js"; +import { createCurrentAutomationRepository } from "../../../../src/backend/database/repositories/factory.js"; +import type { AutomationRow } from "../../../../src/backend/database/repositories/automation-repository.js"; import { AutomationEngine } from "./engine.js"; import { computeNextDueAt, isValidCron, isValidTimezone } from "./cron.js"; import { registerAutomationsRouter, unregisterAutomationsRouter, -} from "../../../src/backend/database/routes/automation-dispatch.js"; +} from "../../../../src/backend/database/routes/automation-dispatch.js"; export const router = express.Router(); diff --git a/plugins/automations/backend/scheduler.ts b/plugins/automations/src/backend/scheduler.ts similarity index 95% rename from plugins/automations/backend/scheduler.ts rename to plugins/automations/src/backend/scheduler.ts index 640e24f18..a5ffa821e 100644 --- a/plugins/automations/backend/scheduler.ts +++ b/plugins/automations/src/backend/scheduler.ts @@ -1,7 +1,7 @@ -import type { AutomationDefinition } from "../../../src/types/automations.js"; -import { createCurrentAutomationRepository } from "../../../src/backend/database/repositories/factory.js"; -import { DataCrypto } from "../../../src/backend/utils/data-crypto.js"; -import { statsLogger } from "../../../src/backend/utils/logger.js"; +import type { AutomationDefinition } from "../../../../src/types/automations.js"; +import { createCurrentAutomationRepository } from "../../../../src/backend/database/repositories/factory.js"; +import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js"; +import { statsLogger } from "../../../../src/backend/utils/logger.js"; import { computeNextDueAt } from "./cron.js"; import { hasDwelled, isCoolingDown } from "./conditions.js"; import { pollDockerEvents } from "./docker-watcher.js"; diff --git a/plugins/automations/backend/template.ts b/plugins/automations/src/backend/template.ts similarity index 100% rename from plugins/automations/backend/template.ts rename to plugins/automations/src/backend/template.ts diff --git a/plugins/automations/backend/triggers.ts b/plugins/automations/src/backend/triggers.ts similarity index 96% rename from plugins/automations/backend/triggers.ts rename to plugins/automations/src/backend/triggers.ts index 44638ad31..4294758bf 100644 --- a/plugins/automations/backend/triggers.ts +++ b/plugins/automations/src/backend/triggers.ts @@ -2,10 +2,10 @@ import type { AutomationDefinition, HostSelector, Trigger, -} from "../../../src/types/automations.js"; -import { createCurrentAutomationRepository } from "../../../src/backend/database/repositories/factory.js"; -import type { AutomationEngineRow } from "../../../src/backend/database/repositories/automation-repository.js"; -import { statsLogger } from "../../../src/backend/utils/logger.js"; +} from "../../../../src/types/automations.js"; +import { createCurrentAutomationRepository } from "../../../../src/backend/database/repositories/factory.js"; +import type { AutomationEngineRow } from "../../../../src/backend/database/repositories/automation-repository.js"; +import { statsLogger } from "../../../../src/backend/utils/logger.js"; import { compare, extractMetricValue, @@ -16,7 +16,10 @@ import { type MetricsSnapshot, } from "./conditions.js"; import { AutomationEngine } from "./engine.js"; -import { pluginEvents, TOPICS } from "../../../src/backend/plugins/events.js"; +import { + pluginEvents, + TOPICS, +} from "../../../../src/backend/plugins/events.js"; /** * Matches events against automation triggers and decides what fires. diff --git a/plugins/automations/frontend/AutomationsPanel.tsx b/plugins/automations/src/frontend/AutomationsPanel.tsx similarity index 100% rename from plugins/automations/frontend/AutomationsPanel.tsx rename to plugins/automations/src/frontend/AutomationsPanel.tsx diff --git a/plugins/automations/frontend/automations-api.ts b/plugins/automations/src/frontend/automations-api.ts similarity index 100% rename from plugins/automations/frontend/automations-api.ts rename to plugins/automations/src/frontend/automations-api.ts diff --git a/plugins/automations/frontend/automations/AutomationEditor.tsx b/plugins/automations/src/frontend/automations/AutomationEditor.tsx similarity index 100% rename from plugins/automations/frontend/automations/AutomationEditor.tsx rename to plugins/automations/src/frontend/automations/AutomationEditor.tsx diff --git a/plugins/automations/frontend/automations/HostSelectorField.tsx b/plugins/automations/src/frontend/automations/HostSelectorField.tsx similarity index 100% rename from plugins/automations/frontend/automations/HostSelectorField.tsx rename to plugins/automations/src/frontend/automations/HostSelectorField.tsx diff --git a/plugins/automations/frontend/automations/StepBlock.tsx b/plugins/automations/src/frontend/automations/StepBlock.tsx similarity index 100% rename from plugins/automations/frontend/automations/StepBlock.tsx rename to plugins/automations/src/frontend/automations/StepBlock.tsx diff --git a/plugins/automations/frontend/automations/TriggerCard.tsx b/plugins/automations/src/frontend/automations/TriggerCard.tsx similarity index 100% rename from plugins/automations/frontend/automations/TriggerCard.tsx rename to plugins/automations/src/frontend/automations/TriggerCard.tsx diff --git a/plugins/automations/frontend/automations/editor-types.ts b/plugins/automations/src/frontend/automations/editor-types.ts similarity index 100% rename from plugins/automations/frontend/automations/editor-types.ts rename to plugins/automations/src/frontend/automations/editor-types.ts diff --git a/plugins/automations/frontend/index.mjs b/plugins/automations/src/frontend/index.mjs similarity index 100% rename from plugins/automations/frontend/index.mjs rename to plugins/automations/src/frontend/index.mjs diff --git a/src/backend/tests/plugins/automations/conditions.test.ts b/plugins/automations/tests/backend/conditions.test.ts similarity index 98% rename from src/backend/tests/plugins/automations/conditions.test.ts rename to plugins/automations/tests/backend/conditions.test.ts index 9a467b794..a3bc8632a 100644 --- a/src/backend/tests/plugins/automations/conditions.test.ts +++ b/plugins/automations/tests/backend/conditions.test.ts @@ -7,7 +7,7 @@ import { metricStateKey, severityForValue, type MetricsSnapshot, -} from "../../../../../plugins/automations/backend/conditions.js"; +} from "../../src/backend/conditions.js"; const metrics: MetricsSnapshot = { cpu: { percent: 42.5, load: [1.5, 1.2, 0.9] }, diff --git a/src/backend/tests/plugins/automations/cron.test.ts b/plugins/automations/tests/backend/cron.test.ts similarity index 98% rename from src/backend/tests/plugins/automations/cron.test.ts rename to plugins/automations/tests/backend/cron.test.ts index 89c5c934c..318600bc5 100644 --- a/src/backend/tests/plugins/automations/cron.test.ts +++ b/plugins/automations/tests/backend/cron.test.ts @@ -5,7 +5,7 @@ import { isValidTimezone, nextCronRun, parseCron, -} from "../../../../../plugins/automations/backend/cron.js"; +} from "../../src/backend/cron.js"; describe("parseCron", () => { it("rejects anything that is not five fields", () => { diff --git a/src/backend/tests/plugins/automations/docker-watcher.test.ts b/plugins/automations/tests/backend/docker-watcher.test.ts similarity index 98% rename from src/backend/tests/plugins/automations/docker-watcher.test.ts rename to plugins/automations/tests/backend/docker-watcher.test.ts index 3bcd4208b..75ca1357a 100644 --- a/src/backend/tests/plugins/automations/docker-watcher.test.ts +++ b/plugins/automations/tests/backend/docker-watcher.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { diffContainerStates, parseContainerStates, -} from "../../../../../plugins/automations/backend/docker-watcher.js"; +} from "../../src/backend/docker-watcher.js"; /** * The polling side needs SSH, so what is tested here is the pure part: turning diff --git a/src/backend/tests/plugins/automations/engine.test.ts b/plugins/automations/tests/backend/engine.test.ts similarity index 98% rename from src/backend/tests/plugins/automations/engine.test.ts rename to plugins/automations/tests/backend/engine.test.ts index ff097c346..26fb04bc9 100644 --- a/src/backend/tests/plugins/automations/engine.test.ts +++ b/plugins/automations/tests/backend/engine.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AutomationDefinition, Step, -} from "../../../../types/automations.js"; +} from "../../../../src/types/automations.js"; /** * The engine reaches the database through the repository factory and the @@ -52,21 +52,20 @@ const repository = { const resolveHostById = vi.fn(); -vi.mock("../../../hosts/host-resolver.js", () => ({ +vi.mock("../../../../src/backend/hosts/host-resolver.js", () => ({ resolveHostById: (...args: unknown[]) => resolveHostById(...args), })); -vi.mock("../../../database/repositories/factory.js", () => ({ +vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({ createCurrentAutomationRepository: () => repository, })); const executeStep = vi.fn(); -vi.mock("../../../../../plugins/automations/backend/actions/index.js", () => ({ +vi.mock("../../src/backend/actions/index.js", () => ({ executeStep: (...args: unknown[]) => executeStep(...args), })); -const { AutomationEngine } = - await import("../../../../../plugins/automations/backend/engine.js"); +const { AutomationEngine } = await import("../../src/backend/engine.js"); function defineAutomation( steps: Step[], diff --git a/src/backend/tests/plugins/automations/headless-viewer.test.ts b/plugins/automations/tests/backend/headless-viewer.test.ts similarity index 96% rename from src/backend/tests/plugins/automations/headless-viewer.test.ts rename to plugins/automations/tests/backend/headless-viewer.test.ts index 90a34fedd..8faac6d4f 100644 --- a/src/backend/tests/plugins/automations/headless-viewer.test.ts +++ b/plugins/automations/tests/backend/headless-viewer.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const listAutomationWatchedHosts = vi.fn(); -vi.mock("../../../../../plugins/automations/backend/triggers.js", () => ({ +vi.mock("../../src/backend/triggers.js", () => ({ listAutomationWatchedHosts: () => listAutomationWatchedHosts(), })); @@ -10,8 +10,7 @@ const { reconcileHeadlessViewers, releaseHeadlessViewers, setViewerRegistry, -} = - await import("../../../../../plugins/automations/backend/headless-viewer.js"); +} = await import("../../src/backend/headless-viewer.js"); const registry = { registerViewer: vi.fn(), diff --git a/src/backend/tests/plugins/automations/notify.test.ts b/plugins/automations/tests/backend/notify.test.ts similarity index 89% rename from src/backend/tests/plugins/automations/notify.test.ts rename to plugins/automations/tests/backend/notify.test.ts index c38cdb0e4..4c12930c3 100644 --- a/src/backend/tests/plugins/automations/notify.test.ts +++ b/plugins/automations/tests/backend/notify.test.ts @@ -2,12 +2,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const automationFetch = vi.fn(); -vi.mock("../../../../../plugins/automations/backend/http.js", () => ({ +vi.mock("../../src/backend/http.js", () => ({ automationFetch: (...args: unknown[]) => automationFetch(...args), })); const { sendAutomationNotification } = - await import("../../../../../plugins/automations/backend/notify.js"); + await import("../../src/backend/notify.js"); beforeEach(() => { automationFetch.mockReset(); diff --git a/src/backend/tests/plugins/automations/routes.test.ts b/plugins/automations/tests/backend/routes.test.ts similarity index 94% rename from src/backend/tests/plugins/automations/routes.test.ts rename to plugins/automations/tests/backend/routes.test.ts index 01f09cbe5..c9c8db69f 100644 --- a/src/backend/tests/plugins/automations/routes.test.ts +++ b/plugins/automations/tests/backend/routes.test.ts @@ -1,7 +1,7 @@ import crypto from "node:crypto"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Request, Response, Router } from "express"; -import type { AutomationDefinition } from "../../../../types/automations.js"; +import type { AutomationDefinition } from "../../../../src/types/automations.js"; /** * Route-level behaviour: validation, ownership and webhook token handling. The repository and engine are mocked; what matters here is what @@ -14,9 +14,9 @@ const state = vi.hoisted(() => ({ nextId: 1, })); -vi.mock("../../../database/db/index.js", () => ({ db: {} })); +vi.mock("../../../../src/backend/database/db/index.js", () => ({ db: {} })); -vi.mock("../../../utils/logger.js", () => ({ +vi.mock("../../../../src/backend/utils/logger.js", () => ({ databaseLogger: { error: vi.fn(), warn: vi.fn(), @@ -39,21 +39,24 @@ const repository = vi.hoisted(() => ({ deleteSchedule: vi.fn(), })); -vi.mock("../../../database/repositories/factory.js", () => ({ +vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({ createCurrentAutomationRepository: () => repository, })); const run = vi.hoisted(() => vi.fn()); -vi.mock("../../../../../plugins/automations/backend/engine.js", () => ({ +vi.mock("../../src/backend/engine.js", () => ({ AutomationEngine: { getInstance: () => ({ run }) }, })); -vi.mock("../../../database/routes/automation-dispatch.js", () => ({ - registerAutomationsRouter: vi.fn(), - unregisterAutomationsRouter: vi.fn(), -})); +vi.mock( + "../../../../src/backend/database/routes/automation-dispatch.js", + () => ({ + registerAutomationsRouter: vi.fn(), + unregisterAutomationsRouter: vi.fn(), + }), +); -vi.mock("../../../utils/permission-manager.js", () => ({ +vi.mock("../../../../src/backend/utils/permission-manager.js", () => ({ PermissionManager: { getInstance: () => ({ requirePermission: @@ -63,7 +66,7 @@ vi.mock("../../../utils/permission-manager.js", () => ({ }, })); -vi.mock("../../../utils/auth-manager.js", () => ({ +vi.mock("../../../../src/backend/utils/auth-manager.js", () => ({ AuthManager: { getInstance: () => ({ createAuthMiddleware: @@ -79,14 +82,13 @@ vi.mock("../../../utils/auth-manager.js", () => ({ }, })); -vi.mock("../../../utils/audit-logger.js", () => ({ +vi.mock("../../../../src/backend/utils/audit-logger.js", () => ({ logAudit: vi.fn(async () => undefined), getAuditUsername: vi.fn(async () => "alice"), getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }), })); -const { router } = - await import("../../../../../plugins/automations/backend/routes.js"); +const { router } = await import("../../src/backend/routes.js"); /** Runs the whole middleware chain for the route, not just its handler. */ async function invoke( diff --git a/src/backend/tests/plugins/automations/template.test.ts b/plugins/automations/tests/backend/template.test.ts similarity index 97% rename from src/backend/tests/plugins/automations/template.test.ts rename to plugins/automations/tests/backend/template.test.ts index 08605ec5a..25192f707 100644 --- a/src/backend/tests/plugins/automations/template.test.ts +++ b/plugins/automations/tests/backend/template.test.ts @@ -4,7 +4,7 @@ import { redactSecrets, renderRecord, renderTemplate, -} from "../../../../../plugins/automations/backend/template.js"; +} from "../../src/backend/template.js"; const context = { host: { id: 7, name: "Zeus", ip: "10.0.0.5", username: "root", port: 22 }, diff --git a/src/backend/tests/plugins/automations/triggers.test.ts b/plugins/automations/tests/backend/triggers.test.ts similarity index 97% rename from src/backend/tests/plugins/automations/triggers.test.ts rename to plugins/automations/tests/backend/triggers.test.ts index 25470d460..5b69f7495 100644 --- a/src/backend/tests/plugins/automations/triggers.test.ts +++ b/plugins/automations/tests/backend/triggers.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AutomationDefinition, Trigger, -} from "../../../../types/automations.js"; +} from "../../../../src/types/automations.js"; /** * Trigger matching, dwell windows and cooldowns. The repository and the engine @@ -42,17 +42,16 @@ const repository = { }), }; -vi.mock("../../../database/repositories/factory.js", () => ({ +vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({ createCurrentAutomationRepository: () => repository, })); const run = vi.fn(async () => ({ runId: 1, status: "success" as const })); -vi.mock("../../../../../plugins/automations/backend/engine.js", () => ({ +vi.mock("../../src/backend/engine.js", () => ({ AutomationEngine: { getInstance: () => ({ run }) }, })); -const triggers = - await import("../../../../../plugins/automations/backend/triggers.js"); +const triggers = await import("../../src/backend/triggers.js"); function addAutomation(trigger: Trigger, overrides: Partial = {}) { const definition: AutomationDefinition = { version: 1, trigger, steps: [] }; diff --git a/src/ui/tests/plugins/automations/automations-editor.test.ts b/plugins/automations/tests/frontend/automations-editor.test.ts similarity index 85% rename from src/ui/tests/plugins/automations/automations-editor.test.ts rename to plugins/automations/tests/frontend/automations-editor.test.ts index 7e97b6ea5..bb79aa644 100644 --- a/src/ui/tests/plugins/automations/automations-editor.test.ts +++ b/plugins/automations/tests/frontend/automations-editor.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { defaultTrigger } from "../../../../../plugins/automations/frontend/automations/TriggerCard"; -import { emptyDraft } from "../../../../../plugins/automations/frontend/automations/AutomationEditor"; -import { newStepId } from "../../../../../plugins/automations/frontend/automations/editor-types"; +import { defaultTrigger } from "../../src/frontend/automations/TriggerCard"; +import { emptyDraft } from "../../src/frontend/automations/AutomationEditor"; +import { newStepId } from "../../src/frontend/automations/editor-types"; import { AUTOMATION_DEFINITION_VERSION } from "@/types/automations"; describe("emptyDraft", () => { diff --git a/plugins/automations/tsconfig.json b/plugins/automations/tsconfig.json new file mode 100644 index 000000000..1d3e42542 --- /dev/null +++ b/plugins/automations/tsconfig.json @@ -0,0 +1,10 @@ +{ + "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/automations/vitest.config.ts b/plugins/automations/vitest.config.ts new file mode 100644 index 000000000..31ba004fc --- /dev/null +++ b/plugins/automations/vitest.config.ts @@ -0,0 +1,3 @@ +import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset"; + +export default pluginVitestConfig(import.meta.url); diff --git a/plugins/docker/CHANGELOG.md b/plugins/docker/CHANGELOG.md new file mode 100644 index 000000000..6a1bc3a37 --- /dev/null +++ b/plugins/docker/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 1.0.0 + +Bundled with Termix 2.9.0. diff --git a/plugins/docker/README.md b/plugins/docker/README.md new file mode 100644 index 000000000..02ce4bc7d --- /dev/null +++ b/plugins/docker/README.md @@ -0,0 +1,22 @@ +# Docker + +Container management over SSH: list, inspect, start, stop and view logs for Docker or Podman containers, plus an interactive container console. + +Bundled with Termix and enabled by default. + +## Layout + + manifest.json id, capabilities and what this plugin contributes + src/backend/ activate(ctx) / deactivate(), plus the routes + src/frontend/ the UI it registers + locales/en.json its strings (English only; the rest are translated) + tests/backend/ vitest, node + tests/frontend/ vitest, jsdom + +## Commands + + npm run build bundle into dist/ + npm run test run this plugin's tests + npm run typecheck + +See packages/plugin-sdk/ARCHITECTURE.md for the contract this follows. diff --git a/plugins/docker/backend/index.mjs b/plugins/docker/backend/index.mjs deleted file mode 100644 index 5187c896c..000000000 --- a/plugins/docker/backend/index.mjs +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Docker - first-party, in-process plugin. - * - * This plugin runs on the main thread rather than in a worker for the same - * reason ssh-terminal does (see src/backend/plugins/first-party.ts): its - * container-console WebSocket server holds long-lived ssh2.Client + PTY - * streams, its session-manager reuses a live ssh2.Client across many REST - * calls, and its SSH connect flow implements TOTP/Warpgate as a genuinely - * stateful multi-request handshake. None of that can cross the - * structured-clone postMessage boundary a worker plugin talks over. - * - * Unlike ssh-terminal, Docker's backend has no consumers outside itself, so - * the whole implementation moved here rather than staying in core with only - * a lifecycle wrapper. This file is what used to be - * src/backend/hosts/docker/index.ts, rewritten from a module-scope - * SIGINT/SIGTERM + listenOnServicePort() call into activate()/deactivate(). - * - * The moved files (routes.ts, container-routes.ts, container-runtime.ts, - * session-manager.ts, console.ts) are real TypeScript, compiled by - * scripts/copy-bundled-plugins.cjs (see that script for why plugins/ needs - * its own compile step) rather than hand-written like ssh-terminal's .mjs - * wrapper. This file itself stays plain JS since it is a thin entry point - * with no types worth checking. - */ - -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { createServer } from "node:http"; - -/** - * Loads a module by its path relative to this file's own directory. - * - * The plugin ships beside dist/backend, so in a built server the compiled - * .js for the moved docker files (routes.js, session-manager.js, ...) sits - * right next to this .mjs, and core modules resolve via - * ../../../backend/backend/... from there (dist/plugins/docker/backend -> - * dist/backend/backend). Under vitest/dev nothing imports this plugin - * directly (src/backend/plugins/loader.ts loads in-process plugins with a - * plain dynamic import, no tsx loader involved), so the only layout that - * needs to resolve here is the built one. - */ -async function loadRelative(relativePath) { - const here = path.dirname(fileURLToPath(import.meta.url)); - const target = path.join(here, `${relativePath}.js`); - return import(new URL(`file://${target.replace(/\\/g, "/")}`).href); -} - -let httpServer = null; -let consoleModule = null; -let authManagerInstance = null; - -export async function activate(ctx) { - const [ - express, - cookieParser, - { createCorsMiddleware }, - { createCompressionMiddleware }, - { logger }, - { AuthManager }, - { registerDockerContainerRoutes }, - sessionManager, - { DOCKER_TIMESTAMP_RE, getRequestUserId, registerDockerSshRoutes }, - ] = await Promise.all([ - import("express").then((m) => m.default), - import("cookie-parser").then((m) => m.default), - loadRelative("../../../backend/backend/utils/cors-config"), - loadRelative("../../../backend/backend/utils/compression-config"), - loadRelative("../../../backend/backend/utils/logger"), - loadRelative("../../../backend/backend/utils/auth-manager"), - loadRelative("container-routes"), - loadRelative("session-manager"), - loadRelative("routes"), - ]); - - const app = express(); - app.set("trust proxy", "loopback"); - - app.use(createCompressionMiddleware()); - app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"])); - - app.use(cookieParser()); - authManagerInstance = AuthManager.getInstance(); - app.use(authManagerInstance.createAuthMiddleware()); - app.use(express.json({ limit: "100mb" })); - app.use(express.urlencoded({ limit: "100mb", extended: true })); - app.use((_req, res, next) => { - res.setHeader("Cache-Control", "no-store"); - next(); - }); - - registerDockerSshRoutes(app); - registerDockerContainerRoutes(app, { - sshSessions: sessionManager.sshSessions, - pendingTOTPSessions: sessionManager.pendingTOTPSessions, - getRequestUserId, - executeDockerCommand: sessionManager.executeDockerCommand, - dockerTimestampPattern: DOCKER_TIMESTAMP_RE, - }); - - const port = 30007; - const server = createServer(app); - - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(port, "127.0.0.1", () => { - server.off("error", reject); - resolve(); - }); - }); - - httpServer = server; - - try { - await authManagerInstance.initialize(); - } catch (err) { - logger.error("Failed to initialize Docker backend", err, { - operation: "startup", - }); - } - - // Importing console.js no longer binds anything: the server is started - // here so disabling the plugin frees port 30009 and re-enabling can take - // it again, even though the module stays in the ESM cache. - consoleModule = await loadRelative("console"); - await consoleModule.startConsoleServer(); - - ctx.log.info(`Docker plugin listening on ${port} (console on 30009)`); -} - -export async function deactivate() { - if (consoleModule?.closeConsoleServer) { - await consoleModule.closeConsoleServer(); - } - consoleModule = null; - - if (httpServer) { - const server = httpServer; - httpServer = null; - await new Promise((resolve) => server.close(() => resolve())); - } - - authManagerInstance = null; -} diff --git a/plugins/docker/locales/en.json b/plugins/docker/locales/en.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/plugins/docker/locales/en.json @@ -0,0 +1 @@ +{} diff --git a/plugins/docker/manifest.json b/plugins/docker/manifest.json index bdbb102aa..531b5c29e 100644 --- a/plugins/docker/manifest.json +++ b/plugins/docker/manifest.json @@ -37,8 +37,6 @@ "editorTab": "docker" } }, - "backend": "backend/index.mjs", - "frontend": "frontend/index.mjs", "locales": "locales", "platforms": ["linux", "win32", "darwin"] } diff --git a/plugins/docker/package.json b/plugins/docker/package.json new file mode 100644 index 000000000..cae12e5b8 --- /dev/null +++ b/plugins/docker/package.json @@ -0,0 +1,15 @@ +{ + "name": "@termix-plugin/docker", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "termix-plugin build", + "test": "termix-plugin test", + "typecheck": "tsc -p tsconfig.json", + "validate": "termix-plugin validate" + }, + "devDependencies": { + "@termix/plugin-sdk": "*" + } +} diff --git a/plugins/docker/backend/console.ts b/plugins/docker/src/backend/console.ts similarity index 96% rename from plugins/docker/backend/console.ts rename to plugins/docker/src/backend/console.ts index 001136364..f3458c762 100644 --- a/plugins/docker/backend/console.ts +++ b/plugins/docker/src/backend/console.ts @@ -1,35 +1,35 @@ // Core imports below point at TypeScript source so tsc can type-check them. -// scripts/copy-bundled-plugins.cjs rewrites the prefix to the compiled -// output path after tsc -p tsconfig.plugins.json runs -- see that script. -import { getErrorMessage } from "../../../src/backend/utils/error-message.js"; +// The plugin build rewrites the prefix to the compiled output path; see +// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs. +import { getErrorMessage } from "../../../../src/backend/utils/error-message.js"; import { StringDecoder } from "string_decoder"; import { Client as SSHClient } from "ssh2"; -import { SSH_ALGORITHMS } from "../../../src/backend/utils/ssh-algorithms.js"; +import { SSH_ALGORITHMS } from "../../../../src/backend/utils/ssh-algorithms.js"; import { WebSocketServer, WebSocket } from "ws"; -import { AuthManager } from "../../../src/backend/utils/auth-manager.js"; -import { createCurrentHostResolutionRepository } from "../../../src/backend/database/repositories/factory.js"; -import { systemLogger } from "../../../src/backend/utils/logger.js"; -import type { SSHHost } from "../../../src/types/index.js"; -import { applyAgentAuth } from "../../../src/backend/hosts/terminal-auth-helpers.js"; +import { AuthManager } from "../../../../src/backend/utils/auth-manager.js"; +import { createCurrentHostResolutionRepository } from "../../../../src/backend/database/repositories/factory.js"; +import { systemLogger } from "../../../../src/backend/utils/logger.js"; +import type { SSHHost } from "../../../../src/types/index.js"; +import { applyAgentAuth } from "../../../../src/backend/hosts/terminal-auth-helpers.js"; import { containerCommand, getContainerRuntimeConfig, type ContainerRuntime, } from "./container-runtime.js"; -import { resolveSshConnectConfigHost } from "../../../src/backend/hosts/ssh-dns.js"; +import { resolveSshConnectConfigHost } from "../../../../src/backend/hosts/ssh-dns.js"; import { hostAddressMismatch, HOST_ADDRESS_MISMATCH_MESSAGE, HOST_NOT_ON_THIS_SERVER_MESSAGE, -} from "../../../src/backend/hosts/host-identity.js"; -import { extractWebSocketToken } from "../../../src/backend/utils/ws-auth.js"; +} from "../../../../src/backend/hosts/host-identity.js"; +import { extractWebSocketToken } from "../../../../src/backend/utils/ws-auth.js"; import { asObject, asString, MAX_WS_MESSAGE_BYTES, parseWsMessage, toTerminalDimension, -} from "../../../src/backend/utils/ws-message.js"; +} from "../../../../src/backend/utils/ws-message.js"; const sshLogger = systemLogger; @@ -450,7 +450,7 @@ async function onConsoleConnection( try { // Resolve host with credentials server-side const { resolveHostById, resolveHostBySyncId } = - await import("../../../src/backend/hosts/host-resolver.js"); + await import("../../../../src/backend/hosts/host-resolver.js"); // syncId names the host on both sides of a sync pair; the numeric // id only names it in the database the client is displaying. const hostSyncId = hostConfig?.syncId; diff --git a/plugins/docker/backend/container-routes.ts b/plugins/docker/src/backend/container-routes.ts similarity index 99% rename from plugins/docker/backend/container-routes.ts rename to plugins/docker/src/backend/container-routes.ts index bdd2f37bf..6bcacf926 100644 --- a/plugins/docker/backend/container-routes.ts +++ b/plugins/docker/src/backend/container-routes.ts @@ -1,9 +1,9 @@ // Core imports below point at TypeScript source so tsc can type-check them. -// scripts/copy-bundled-plugins.cjs rewrites the prefix to the compiled -// output path after tsc -p tsconfig.plugins.json runs -- see that script. -import { getErrorMessage } from "../../../src/backend/utils/error-message.js"; +// The plugin build rewrites the prefix to the compiled output path; see +// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs. +import { getErrorMessage } from "../../../../src/backend/utils/error-message.js"; import type express from "express"; -import { logger } from "../../../src/backend/utils/logger.js"; +import { logger } from "../../../../src/backend/utils/logger.js"; import { containerCommand, type ContainerRuntime, diff --git a/plugins/docker/backend/container-runtime.ts b/plugins/docker/src/backend/container-runtime.ts similarity index 100% rename from plugins/docker/backend/container-runtime.ts rename to plugins/docker/src/backend/container-runtime.ts diff --git a/plugins/docker/src/backend/index.ts b/plugins/docker/src/backend/index.ts new file mode 100644 index 000000000..301c041a9 --- /dev/null +++ b/plugins/docker/src/backend/index.ts @@ -0,0 +1,88 @@ +import { createServer, type Server } from "node:http"; +import express from "express"; +import cookieParser from "cookie-parser"; +import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { createCorsMiddleware } from "../../../../src/backend/utils/cors-config.js"; +import { createCompressionMiddleware } from "../../../../src/backend/utils/compression-config.js"; +import { logger } from "../../../../src/backend/utils/logger.js"; +import { AuthManager } from "../../../../src/backend/utils/auth-manager.js"; +import { registerDockerContainerRoutes } from "./container-routes.js"; +import { + sshSessions, + pendingTOTPSessions, + executeDockerCommand, +} from "./session-manager.js"; +import { + DOCKER_TIMESTAMP_RE, + getRequestUserId, + registerDockerSshRoutes, +} from "./routes.js"; +import { startConsoleServer, closeConsoleServer } from "./console.js"; + +const PORT = 30007; + +let httpServer: Server | null = null; +let authManagerInstance: ReturnType | null = + null; + +export async function activate(ctx: PluginContext) { + const app = express(); + app.set("trust proxy", "loopback"); + app.use(createCompressionMiddleware()); + app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"])); + app.use(cookieParser()); + + authManagerInstance = AuthManager.getInstance(); + app.use(authManagerInstance.createAuthMiddleware()); + app.use(express.json({ limit: "100mb" })); + app.use(express.urlencoded({ limit: "100mb", extended: true })); + app.use((_req, res, next) => { + res.setHeader("Cache-Control", "no-store"); + next(); + }); + + registerDockerSshRoutes(app); + registerDockerContainerRoutes(app, { + sshSessions, + pendingTOTPSessions, + getRequestUserId, + executeDockerCommand, + dockerTimestampPattern: DOCKER_TIMESTAMP_RE, + }); + + const server = createServer(app); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(PORT, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + httpServer = server; + + try { + await authManagerInstance.initialize(); + } catch (err) { + logger.error("Failed to initialize Docker backend", err, { + operation: "startup", + }); + } + + // The console server is started here rather than on import so disabling the + // plugin frees port 30009 and re-enabling can take it again. + await startConsoleServer(); + + ctx.log.info(`Docker plugin listening on ${PORT} (console on 30009)`); +} + +export async function deactivate() { + await closeConsoleServer(); + + if (httpServer) { + const server = httpServer; + httpServer = null; + await new Promise((resolve) => server.close(() => resolve())); + } + + authManagerInstance = null; +} diff --git a/plugins/docker/backend/routes.ts b/plugins/docker/src/backend/routes.ts similarity index 96% rename from plugins/docker/backend/routes.ts rename to plugins/docker/src/backend/routes.ts index 80786ab4c..d9860a15f 100644 --- a/plugins/docker/backend/routes.ts +++ b/plugins/docker/src/backend/routes.ts @@ -1,45 +1,45 @@ // Core imports below point at TypeScript source so tsc can type-check them. -// scripts/copy-bundled-plugins.cjs rewrites the prefix to the compiled -// output path after tsc -p tsconfig.plugins.json runs -- see that script. -import { getErrorMessage } from "../../../src/backend/utils/error-message.js"; -import { usesIssuedCertificate } from "../../../src/backend/hosts/issued-certificate-auth.js"; +// The plugin build rewrites the prefix to the compiled output path; see +// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs. +import { getErrorMessage } from "../../../../src/backend/utils/error-message.js"; +import { usesIssuedCertificate } from "../../../../src/backend/hosts/issued-certificate-auth.js"; import express from "express"; import axios from "axios"; import { Client as SSHClient } from "ssh2"; -import { logger } from "../../../src/backend/utils/logger.js"; +import { logger } from "../../../../src/backend/utils/logger.js"; import { logAudit, getAuditUsername, getRequestMeta, -} from "../../../src/backend/utils/audit-logger.js"; -import { createCurrentHostRepository } from "../../../src/backend/database/repositories/factory.js"; -import { createJumpHostChain } from "../../../src/backend/hosts/jump-host-chain.js"; -import { resolveHostById } from "../../../src/backend/hosts/host-resolver.js"; -import { createConnectionLog } from "../../../src/backend/hosts/connection-log.js"; -import { DataCrypto } from "../../../src/backend/utils/data-crypto.js"; -import { AuthManager } from "../../../src/backend/utils/auth-manager.js"; +} from "../../../../src/backend/utils/audit-logger.js"; +import { createCurrentHostRepository } from "../../../../src/backend/database/repositories/factory.js"; +import { createJumpHostChain } from "../../../../src/backend/hosts/jump-host-chain.js"; +import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js"; +import { createConnectionLog } from "../../../../src/backend/hosts/connection-log.js"; +import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js"; +import { AuthManager } from "../../../../src/backend/utils/auth-manager.js"; import type { AuthenticatedRequest, ProxyNode, SSHHost, -} from "../../../src/types/index.js"; +} from "../../../../src/types/index.js"; import { createSocks5Connection, type SOCKS5Config, -} from "../../../src/backend/utils/socks5-helper.js"; +} from "../../../../src/backend/utils/socks5-helper.js"; import type { LogEntry, ConnectionStage, -} from "../../../src/types/connection-log.js"; -import { SSHHostKeyVerifier } from "../../../src/backend/hosts/host-key-verifier.js"; -import { preparePrivateKeyForSSH2 } from "../../../src/backend/utils/ssh-key-utils.js"; -import { applyAgentAuth } from "../../../src/backend/hosts/terminal-auth-helpers.js"; +} from "../../../../src/types/connection-log.js"; +import { SSHHostKeyVerifier } from "../../../../src/backend/hosts/host-key-verifier.js"; +import { preparePrivateKeyForSSH2 } from "../../../../src/backend/utils/ssh-key-utils.js"; +import { applyAgentAuth } from "../../../../src/backend/hosts/terminal-auth-helpers.js"; import { containerCommand, getContainerRuntimeConfig, getRuntimeLabel, } from "./container-runtime.js"; -import { resolveSshConnectConfigHost } from "../../../src/backend/hosts/ssh-dns.js"; +import { resolveSshConnectConfigHost } from "../../../../src/backend/hosts/ssh-dns.js"; import { type SSHSession, sshSessions, @@ -307,7 +307,7 @@ export function registerDockerSshRoutes(app: express.Express): void { } else if (usesIssuedCertificate(resolvedCredentials.authType)) { try { const { getOPKSSHToken } = - await import("../../../src/backend/hosts/opkssh-auth.js"); + await import("../../../../src/backend/hosts/opkssh-auth.js"); const token = await getOPKSSHToken(userId, hostId); if (!token) { @@ -327,7 +327,7 @@ export function registerDockerSshRoutes(app: express.Express): void { } const { setupOPKSSHCertAuth } = - await import("../../../src/backend/hosts/opkssh-cert-auth.js"); + await import("../../../../src/backend/hosts/opkssh-cert-auth.js"); await setupOPKSSHCertAuth( config as import("ssh2").ConnectConfig, client, diff --git a/plugins/docker/backend/session-manager.ts b/plugins/docker/src/backend/session-manager.ts similarity index 95% rename from plugins/docker/backend/session-manager.ts rename to plugins/docker/src/backend/session-manager.ts index bcc58d51b..3b1b93931 100644 --- a/plugins/docker/backend/session-manager.ts +++ b/plugins/docker/src/backend/session-manager.ts @@ -1,8 +1,8 @@ // Core imports below point at TypeScript source so tsc can type-check them. -// scripts/copy-bundled-plugins.cjs rewrites the prefix to the compiled -// output path after tsc -p tsconfig.plugins.json runs -- see that script. +// The plugin build rewrites the prefix to the compiled output path; see +// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs. import { Client as SSHClient } from "ssh2"; -import { logger } from "../../../src/backend/utils/logger.js"; +import { logger } from "../../../../src/backend/utils/logger.js"; import type { ContainerRuntime } from "./container-runtime.js"; const sshLogger = logger; diff --git a/plugins/docker/frontend/DockerApp.tsx b/plugins/docker/src/frontend/DockerApp.tsx similarity index 100% rename from plugins/docker/frontend/DockerApp.tsx rename to plugins/docker/src/frontend/DockerApp.tsx diff --git a/plugins/docker/frontend/DockerManager.tsx b/plugins/docker/src/frontend/DockerManager.tsx similarity index 100% rename from plugins/docker/frontend/DockerManager.tsx rename to plugins/docker/src/frontend/DockerManager.tsx diff --git a/plugins/docker/frontend/components/ConsoleTerminal.tsx b/plugins/docker/src/frontend/components/ConsoleTerminal.tsx similarity index 100% rename from plugins/docker/frontend/components/ConsoleTerminal.tsx rename to plugins/docker/src/frontend/components/ConsoleTerminal.tsx diff --git a/plugins/docker/frontend/components/ContainerCard.tsx b/plugins/docker/src/frontend/components/ContainerCard.tsx similarity index 100% rename from plugins/docker/frontend/components/ContainerCard.tsx rename to plugins/docker/src/frontend/components/ContainerCard.tsx diff --git a/plugins/docker/frontend/components/ContainerDetail.tsx b/plugins/docker/src/frontend/components/ContainerDetail.tsx similarity index 100% rename from plugins/docker/frontend/components/ContainerDetail.tsx rename to plugins/docker/src/frontend/components/ContainerDetail.tsx diff --git a/plugins/docker/frontend/components/ContainerList.tsx b/plugins/docker/src/frontend/components/ContainerList.tsx similarity index 100% rename from plugins/docker/frontend/components/ContainerList.tsx rename to plugins/docker/src/frontend/components/ContainerList.tsx diff --git a/plugins/docker/frontend/components/ContainerStats.tsx b/plugins/docker/src/frontend/components/ContainerStats.tsx similarity index 100% rename from plugins/docker/frontend/components/ContainerStats.tsx rename to plugins/docker/src/frontend/components/ContainerStats.tsx diff --git a/plugins/docker/frontend/components/ContainerTable.tsx b/plugins/docker/src/frontend/components/ContainerTable.tsx similarity index 100% rename from plugins/docker/frontend/components/ContainerTable.tsx rename to plugins/docker/src/frontend/components/ContainerTable.tsx diff --git a/plugins/docker/frontend/components/LogViewer.tsx b/plugins/docker/src/frontend/components/LogViewer.tsx similarity index 100% rename from plugins/docker/frontend/components/LogViewer.tsx rename to plugins/docker/src/frontend/components/LogViewer.tsx diff --git a/plugins/docker/frontend/index.mjs b/plugins/docker/src/frontend/index.mjs similarity index 96% rename from plugins/docker/frontend/index.mjs rename to plugins/docker/src/frontend/index.mjs index ba6761f93..08529b956 100644 --- a/plugins/docker/frontend/index.mjs +++ b/plugins/docker/src/frontend/index.mjs @@ -46,7 +46,7 @@ export async function register({ if (registerHostEditorTab) { const { HostDockerTab } = - await import("../../../src/ui/sidebar/HostEditorFeatureTabs.tsx"); + await import("../../../../src/ui/sidebar/HostEditorFeatureTabs.tsx"); registerHostEditorTab({ id: hostEditorTabId, labelKey: "hosts.tabDocker", diff --git a/plugins/docker/tsconfig.json b/plugins/docker/tsconfig.json new file mode 100644 index 000000000..1d3e42542 --- /dev/null +++ b/plugins/docker/tsconfig.json @@ -0,0 +1,10 @@ +{ + "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/vitest.config.ts b/plugins/docker/vitest.config.ts new file mode 100644 index 000000000..31ba004fc --- /dev/null +++ b/plugins/docker/vitest.config.ts @@ -0,0 +1,3 @@ +import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset"; + +export default pluginVitestConfig(import.meta.url); diff --git a/plugins/fleets/CHANGELOG.md b/plugins/fleets/CHANGELOG.md new file mode 100644 index 000000000..6a1bc3a37 --- /dev/null +++ b/plugins/fleets/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 1.0.0 + +Bundled with Termix 2.9.0. diff --git a/plugins/fleets/README.md b/plugins/fleets/README.md new file mode 100644 index 000000000..362fafdac --- /dev/null +++ b/plugins/fleets/README.md @@ -0,0 +1,22 @@ +# Fleets + +Group hosts into fleets by static membership or tag rules, and run commands, package actions, and file transfers across every member at once. + +Bundled with Termix and enabled by default. + +## Layout + + manifest.json id, capabilities and what this plugin contributes + src/backend/ activate(ctx) / deactivate(), plus the routes + src/frontend/ the UI it registers + locales/en.json its strings (English only; the rest are translated) + tests/backend/ vitest, node + tests/frontend/ vitest, jsdom + +## Commands + + npm run build bundle into dist/ + npm run test run this plugin's tests + npm run typecheck + +See packages/plugin-sdk/ARCHITECTURE.md for the contract this follows. diff --git a/plugins/fleets/backend/index.mjs b/plugins/fleets/backend/index.mjs deleted file mode 100644 index b7096068b..000000000 --- a/plugins/fleets/backend/index.mjs +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Fleets - first-party, in-process plugin. - * - * This plugin runs on the main thread rather than in a worker for the same - * reason proxmox and ai do (see src/backend/plugins/first-party.ts): its - * execute/transfer routes resolve and connect to many hosts concurrently over - * the shared SSH pool via resolveHostById and withConnection, read/write - * files over SFTP, and stream a zip archive back for pull requests. None of - * that fits the worker ctx -- ctx.hosts is a 13-field read-only view with no - * fleets surface at all, and ctx.http.route replies with a single postMessage - * value rather than a stream. - * - * Like proxmox, this plugin does not own a live transport (no WebSocket - * server, no dedicated port), so it registers its Express router with the - * always-mounted /fleets dispatcher in - * src/backend/database/routes/fleet-dispatch.ts instead of getting its own - * port. - * - * routes.ts is what used to be src/backend/database/routes/fleet-routes.ts, - * moved here and rewritten from `export default router` into - * startFleetsService()/stopFleetsService(), called from - * activate()/deactivate() below. The fleets/fleetMembers/fleetInventory - * tables and their repositories stay in core (src/backend/database/db/schema.ts, - * src/backend/database/repositories/fleet-repository.ts, - * fleet-inventory-repository.ts) -- this plugin system has no owned-table - * migration mechanism of its own, and these tables carry FKs into core's - * hosts/users tables. - */ - -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -/** - * Loads a module by its path relative to this file's own directory. - * - * The plugin ships beside dist/backend, so in a built server the compiled - * .js for routes.js sits right next to this .mjs, and core modules resolve - * via ../../../backend/backend/... from there (dist/plugins/fleets/backend - * -> dist/backend/backend). Under vitest/dev nothing imports this plugin - * directly (the loader loads in-process plugins with a plain dynamic import, - * no tsx loader involved), so the only layout that needs to resolve here is - * the built one. - */ -async function loadRelative(relativePath) { - const here = path.dirname(fileURLToPath(import.meta.url)); - const target = path.join(here, `${relativePath}.js`); - return import(new URL(`file://${target.replace(/\\/g, "/")}`).href); -} - -let routesModule = null; - -export async function activate(ctx) { - routesModule = await loadRelative("routes"); - routesModule.startFleetsService(); - ctx.log.info("Fleets router registered at /fleets"); -} - -export async function deactivate() { - if (routesModule) { - routesModule.stopFleetsService(); - routesModule = null; - } -} diff --git a/plugins/fleets/locales/en.json b/plugins/fleets/locales/en.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/plugins/fleets/locales/en.json @@ -0,0 +1 @@ +{} diff --git a/plugins/fleets/manifest.json b/plugins/fleets/manifest.json index d982f174a..d8f942128 100644 --- a/plugins/fleets/manifest.json +++ b/plugins/fleets/manifest.json @@ -21,8 +21,6 @@ "db:own", "ui:surface" ], - "backend": "backend/index.mjs", - "frontend": "frontend/index.mjs", "locales": "locales", "platforms": ["linux", "win32", "darwin"] } diff --git a/plugins/fleets/package.json b/plugins/fleets/package.json new file mode 100644 index 000000000..802606ca4 --- /dev/null +++ b/plugins/fleets/package.json @@ -0,0 +1,15 @@ +{ + "name": "@termix-plugin/fleets", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "termix-plugin build", + "test": "termix-plugin test", + "typecheck": "tsc -p tsconfig.json", + "validate": "termix-plugin validate" + }, + "devDependencies": { + "@termix/plugin-sdk": "*" + } +} diff --git a/plugins/fleets/src/backend/index.ts b/plugins/fleets/src/backend/index.ts new file mode 100644 index 000000000..60877b918 --- /dev/null +++ b/plugins/fleets/src/backend/index.ts @@ -0,0 +1,11 @@ +import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { startFleetsService, stopFleetsService } from "./routes.js"; + +export async function activate(ctx: PluginContext) { + startFleetsService(); + ctx.log.info("Fleets router registered at /fleets"); +} + +export async function deactivate() { + stopFleetsService(); +} diff --git a/plugins/fleets/backend/routes.ts b/plugins/fleets/src/backend/routes.ts similarity index 96% rename from plugins/fleets/backend/routes.ts rename to plugins/fleets/src/backend/routes.ts index a7890fb24..c9b58bd04 100644 --- a/plugins/fleets/backend/routes.ts +++ b/plugins/fleets/src/backend/routes.ts @@ -1,5 +1,5 @@ -import { getErrorMessage } from "../../../src/backend/utils/error-message.js"; -import type { AuthenticatedRequest } from "../../../src/types/index.js"; +import { getErrorMessage } from "../../../../src/backend/utils/error-message.js"; +import type { AuthenticatedRequest } from "../../../../src/types/index.js"; import express, { type Request, type Response } from "express"; import multer from "multer"; import JSZip from "jszip"; @@ -7,43 +7,43 @@ import type { Client, SFTPWrapper } from "ssh2"; import { authLogger, databaseLogger, -} from "../../../src/backend/utils/logger.js"; -import { AuthManager } from "../../../src/backend/utils/auth-manager.js"; +} from "../../../../src/backend/utils/logger.js"; +import { AuthManager } from "../../../../src/backend/utils/auth-manager.js"; import { PermissionManager, type HostAction, -} from "../../../src/backend/utils/permission-manager.js"; +} from "../../../../src/backend/utils/permission-manager.js"; import { isSharePermissionLevel, expiryFromDuration, parseShareTargets, -} from "../../../src/backend/database/routes/rbac.js"; +} from "../../../../src/backend/database/routes/rbac.js"; import { createCurrentFleetRepository, createCurrentFleetInventoryRepository, createCurrentRbacAccessRepository, createCurrentRoleRepository, createCurrentUserRepository, -} from "../../../src/backend/database/repositories/factory.js"; -import { resolveHostById } from "../../../src/backend/hosts/host-resolver.js"; +} from "../../../../src/backend/database/repositories/factory.js"; +import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js"; import { getFleetPoolKey, createFleetSshFactory, -} from "../../../src/backend/hosts/ssh-client-factory.js"; -import { withConnection } from "../../../src/backend/hosts/ssh-connection-pool.js"; -import { execCommand } from "../../../src/backend/hosts/metrics-shared/common-utils.js"; -import { detectPlatform } from "../../../src/backend/hosts/metrics-shared/platform.js"; +} from "../../../../src/backend/hosts/ssh-client-factory.js"; +import { withConnection } from "../../../../src/backend/hosts/ssh-connection-pool.js"; +import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; +import { detectPlatform } from "../../../../src/backend/hosts/metrics-shared/platform.js"; import { execElevated, ElevationError, -} from "../../../src/backend/hosts/metrics-shared/exec-elevated.js"; -import { buildPackageActionCommand } from "../../../src/backend/hosts/metrics-shared/package-commands.js"; -import { isValidPackageName } from "../../../src/backend/hosts/metrics-shared/validation.js"; -import { resolveSnippetCommand } from "../../../src/backend/database/routes/snippets-execution.js"; +} from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; +import { buildPackageActionCommand } from "../../../../src/backend/hosts/metrics-shared/package-commands.js"; +import { isValidPackageName } from "../../../../src/backend/hosts/metrics-shared/validation.js"; +import { resolveSnippetCommand } from "../../../../src/backend/database/routes/snippets-execution.js"; import { registerFleetsRouter, unregisterFleetsRouter, -} from "../../../src/backend/database/routes/fleet-dispatch.js"; +} from "../../../../src/backend/database/routes/fleet-dispatch.js"; export const router = express.Router(); @@ -629,7 +629,7 @@ router.post( const expiresAt = expiryFromDuration(durationHours); const rbacAccessRepository = createCurrentRbacAccessRepository(); const { SharedHostSecretsManager } = - await import("../../../src/backend/utils/shared-host-secrets-manager.js"); + await import("../../../../src/backend/utils/shared-host-secrets-manager.js"); const secretsManager = SharedHostSecretsManager.getInstance(); const hostResults: Array<{ diff --git a/plugins/fleets/frontend/FleetInventoryTab.tsx b/plugins/fleets/src/frontend/FleetInventoryTab.tsx similarity index 100% rename from plugins/fleets/frontend/FleetInventoryTab.tsx rename to plugins/fleets/src/frontend/FleetInventoryTab.tsx diff --git a/plugins/fleets/frontend/FleetsPanel.tsx b/plugins/fleets/src/frontend/FleetsPanel.tsx similarity index 100% rename from plugins/fleets/frontend/FleetsPanel.tsx rename to plugins/fleets/src/frontend/FleetsPanel.tsx diff --git a/plugins/fleets/frontend/index.mjs b/plugins/fleets/src/frontend/index.mjs similarity index 100% rename from plugins/fleets/frontend/index.mjs rename to plugins/fleets/src/frontend/index.mjs diff --git a/src/backend/tests/database/repositories/fleet-repository.test.ts b/plugins/fleets/tests/backend/repositories/fleet-repository.test.ts similarity index 94% rename from src/backend/tests/database/repositories/fleet-repository.test.ts rename to plugins/fleets/tests/backend/repositories/fleet-repository.test.ts index 03c372544..d0880f628 100644 --- a/src/backend/tests/database/repositories/fleet-repository.test.ts +++ b/plugins/fleets/tests/backend/repositories/fleet-repository.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; -import { TestSqliteDatabase } from "./test-support.js"; -import { FleetRepository } from "../../../database/repositories/fleet-repository.js"; +import { TestSqliteDatabase } from "../../../../../src/backend/tests/database/repositories/test-support.js"; +import { FleetRepository } from "../../../../../src/backend/database/repositories/fleet-repository.js"; describe("FleetRepository", () => { let adapter: TestSqliteDatabase | null = null; diff --git a/src/backend/tests/plugins/fleets/routes.test.ts b/plugins/fleets/tests/backend/routes.test.ts similarity index 86% rename from src/backend/tests/plugins/fleets/routes.test.ts rename to plugins/fleets/tests/backend/routes.test.ts index 9ea6db47f..0cdb02f66 100644 --- a/src/backend/tests/plugins/fleets/routes.test.ts +++ b/plugins/fleets/tests/backend/routes.test.ts @@ -9,9 +9,9 @@ const state = vi.hoisted(() => ({ hosts: new Map>(), })); -vi.mock("../../../database/db/index.js", () => ({ db: {} })); +vi.mock("../../../../src/backend/database/db/index.js", () => ({ db: {} })); -vi.mock("../../../utils/logger.js", () => ({ +vi.mock("../../../../src/backend/utils/logger.js", () => ({ authLogger: { error: vi.fn(), warn: vi.fn(), @@ -26,7 +26,7 @@ vi.mock("../../../utils/logger.js", () => ({ }, })); -vi.mock("../../../utils/auth-manager.js", () => ({ +vi.mock("../../../../src/backend/utils/auth-manager.js", () => ({ AuthManager: { getInstance: () => ({ createAuthMiddleware: @@ -42,7 +42,7 @@ vi.mock("../../../utils/auth-manager.js", () => ({ }, })); -vi.mock("../../../utils/permission-manager.js", () => ({ +vi.mock("../../../../src/backend/utils/permission-manager.js", () => ({ PermissionManager: { getInstance: () => ({ canAccessHost: async (userId: string, hostId: number, level: string) => { @@ -56,16 +56,16 @@ vi.mock("../../../utils/permission-manager.js", () => ({ }, })); -vi.mock("../../../hosts/host-resolver.js", () => ({ +vi.mock("../../../../src/backend/hosts/host-resolver.js", () => ({ resolveHostById: async (hostId: number) => state.hosts.get(hostId) ?? null, })); -vi.mock("../../../hosts/ssh-client-factory.js", () => ({ +vi.mock("../../../../src/backend/hosts/ssh-client-factory.js", () => ({ getFleetPoolKey: () => "pool-key", createFleetSshFactory: () => async () => ({}), })); -vi.mock("../../../hosts/ssh-connection-pool.js", () => ({ +vi.mock("../../../../src/backend/hosts/ssh-connection-pool.js", () => ({ withConnection: async ( _key: string, _factory: unknown, @@ -73,48 +73,57 @@ vi.mock("../../../hosts/ssh-connection-pool.js", () => ({ ) => fn({}), })); -vi.mock("../../../hosts/metrics-shared/common-utils.js", () => ({ +vi.mock("../../../../src/backend/hosts/metrics-shared/common-utils.js", () => ({ execCommand: vi.fn(async () => ({ stdout: "ok", stderr: "", code: 0 })), })); -vi.mock("../../../hosts/metrics-shared/platform.js", () => ({ +vi.mock("../../../../src/backend/hosts/metrics-shared/platform.js", () => ({ detectPlatform: vi.fn(async () => ({ pkg: "apt", osPrettyName: "Debian" })), })); -vi.mock("../../../hosts/metrics-shared/exec-elevated.js", async () => { - class ElevationError extends Error { - code: string; - constructor(code: string, message: string) { - super(message); - this.code = code; +vi.mock( + "../../../../src/backend/hosts/metrics-shared/exec-elevated.js", + async () => { + class ElevationError extends Error { + code: string; + constructor(code: string, message: string) { + super(message); + this.code = code; + } } - } - return { - execElevated: vi.fn(), - ElevationError, - }; -}); + return { + execElevated: vi.fn(), + ElevationError, + }; + }, +); -vi.mock("../../../hosts/metrics-shared/package-commands.js", () => ({ - buildPackageActionCommand: vi.fn(() => "apt-get install -y foo"), -})); +vi.mock( + "../../../../src/backend/hosts/metrics-shared/package-commands.js", + () => ({ + buildPackageActionCommand: vi.fn(() => "apt-get install -y foo"), + }), +); -vi.mock("../../../hosts/metrics-shared/validation.js", () => ({ +vi.mock("../../../../src/backend/hosts/metrics-shared/validation.js", () => ({ isValidPackageName: (v: unknown) => typeof v === "string" && v.length > 0, })); -vi.mock("../../../database/routes/snippets-execution.js", () => ({ - resolveSnippetCommand: (command: string) => command, -})); +vi.mock( + "../../../../src/backend/database/routes/snippets-execution.js", + () => ({ + resolveSnippetCommand: (command: string) => command, + }), +); -vi.mock("../../../database/routes/rbac.js", () => ({ +vi.mock("../../../../src/backend/database/routes/rbac.js", () => ({ isSharePermissionLevel: (v: unknown) => ["connect", "view", "edit", "manage"].includes(v as string), expiryFromDuration: () => null, parseShareTargets: () => null, })); -vi.mock("../../../database/repositories/factory.js", () => ({ +vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({ createCurrentFleetRepository: () => ({ findById: async (userId: string, fleetId: number) => { const fleet = state.fleets.get(fleetId); @@ -135,13 +144,13 @@ vi.mock("../../../database/repositories/factory.js", () => ({ createCurrentUserRepository: () => ({}), })); -vi.mock("../../../database/routes/fleet-dispatch.js", () => ({ +vi.mock("../../../../src/backend/database/routes/fleet-dispatch.js", () => ({ registerFleetsRouter: vi.fn(), unregisterFleetsRouter: vi.fn(), })); const { router, parseInventoryProbe, buildRemoveCommand } = - await import("../../../../../plugins/fleets/backend/routes.js"); + await import("../../src/backend/routes.js"); function findLayer(method: string, path: string) { const stack = (router as unknown as Router).stack as Array<{ @@ -322,7 +331,7 @@ describe("POST /:id/packages", () => { it("surfaces an ElevationError as a per-host error, not a request failure", async () => { const { execElevated, ElevationError } = - await import("../../../hosts/metrics-shared/exec-elevated.js"); + await import("../../../../src/backend/hosts/metrics-shared/exec-elevated.js"); (execElevated as ReturnType).mockRejectedValue( new ElevationError("SUDO_REQUIRED", "sudo password required"), ); @@ -349,7 +358,7 @@ describe("POST /:id/packages", () => { }); const { execElevated } = - await import("../../../hosts/metrics-shared/exec-elevated.js"); + await import("../../../../src/backend/hosts/metrics-shared/exec-elevated.js"); (execElevated as ReturnType).mockResolvedValue({ code: 0, stdout: "done", diff --git a/plugins/fleets/tsconfig.json b/plugins/fleets/tsconfig.json new file mode 100644 index 000000000..1d3e42542 --- /dev/null +++ b/plugins/fleets/tsconfig.json @@ -0,0 +1,10 @@ +{ + "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/vitest.config.ts b/plugins/fleets/vitest.config.ts new file mode 100644 index 000000000..31ba004fc --- /dev/null +++ b/plugins/fleets/vitest.config.ts @@ -0,0 +1,3 @@ +import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset"; + +export default pluginVitestConfig(import.meta.url); diff --git a/plugins/host-metrics/CHANGELOG.md b/plugins/host-metrics/CHANGELOG.md new file mode 100644 index 000000000..6a1bc3a37 --- /dev/null +++ b/plugins/host-metrics/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 1.0.0 + +Bundled with Termix 2.9.0. diff --git a/plugins/host-metrics/README.md b/plugins/host-metrics/README.md new file mode 100644 index 000000000..02316101e --- /dev/null +++ b/plugins/host-metrics/README.md @@ -0,0 +1,22 @@ +# Host Metrics + +Live CPU, memory, disk, network and process metrics, plus host management (services, packages, firewall, cron, users, SSL, logs, WireGuard, Tailscale). + +Bundled with Termix and enabled by default. + +## Layout + + manifest.json id, capabilities and what this plugin contributes + src/backend/ activate(ctx) / deactivate(), plus the routes + src/frontend/ the UI it registers + locales/en.json its strings (English only; the rest are translated) + tests/backend/ vitest, node + tests/frontend/ vitest, jsdom + +## Commands + + npm run build bundle into dist/ + npm run test run this plugin's tests + npm run typecheck + +See packages/plugin-sdk/ARCHITECTURE.md for the contract this follows. diff --git a/plugins/host-metrics/backend/index.mjs b/plugins/host-metrics/backend/index.mjs deleted file mode 100644 index 327331f43..000000000 --- a/plugins/host-metrics/backend/index.mjs +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Host Metrics - first-party, in-process plugin. - * - * This plugin runs on the main thread rather than in a worker for the same - * reason ssh-terminal and docker do (see src/backend/plugins/first-party.ts): - * its polling manager holds long-lived ssh2.Client connections across a - * whole fleet of hosts, reused between polling ticks, and its connect flow - * implements the same jump-host/SOCKS5/TOTP/Warpgate/OPKSSH/Vault - * keyboard-interactive handshakes the terminal does. None of that can cross - * the structured-clone postMessage boundary a worker plugin talks over, and - * the worker ctx.ssh primitive (connect + single exec-and-collect) has no - * hook for a session that outlives one request. - * - * routes.ts is what used to be src/backend/hosts/metrics/index.ts, moved - * here and rewritten from a module-scope SIGINT/SIGTERM + listenOnServicePort - * call into startHostMetricsService()/shutdown(), called from - * activate()/deactivate() below. - * - * Proxmox-related code (metrics/proxmox/*, proxmox-stats-*) stays in core at - * src/backend/hosts/metrics/ rather than moving here -- it is planned as its - * own future plugin, so this move does not touch it. - */ - -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -/** - * Loads a module by its path relative to this file's own directory. - * - * The plugin ships beside dist/backend, so in a built server the compiled - * .js for the moved host-metrics files (routes.js, ...) sits right next to - * this .mjs, and core modules resolve via ../../../backend/backend/... from - * there (dist/plugins/host-metrics/backend -> dist/backend/backend). Under - * vitest/dev nothing imports this plugin directly (the loader loads - * in-process plugins with a plain dynamic import, no tsx loader involved), - * so the only layout that needs to resolve here is the built one. - */ -async function loadRelative(relativePath) { - const here = path.dirname(fileURLToPath(import.meta.url)); - const target = path.join(here, `${relativePath}.js`); - return import(new URL(`file://${target.replace(/\\/g, "/")}`).href); -} - -let routesModule = null; -let httpServer = null; - -export async function activate(ctx) { - routesModule = await loadRelative("routes"); - - httpServer = routesModule.startHostMetricsService(); - - ctx.log.info(`Host Metrics listening on ${routesModule.PORT ?? 30005}`); -} - -export async function deactivate() { - if (routesModule) { - routesModule.shutdown(); - routesModule = null; - } - - if (httpServer) { - const server = httpServer; - httpServer = null; - await new Promise((resolve) => server.close(() => resolve())); - } -} diff --git a/plugins/host-metrics/locales/en.json b/plugins/host-metrics/locales/en.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/plugins/host-metrics/locales/en.json @@ -0,0 +1 @@ +{} diff --git a/plugins/host-metrics/manifest.json b/plugins/host-metrics/manifest.json index 688f0bc54..4dcae9f5b 100644 --- a/plugins/host-metrics/manifest.json +++ b/plugins/host-metrics/manifest.json @@ -23,6 +23,9 @@ "events:core", "ui:surface" ], + "optionalDependencies": { + "automations": "^1.0.0" + }, "contributes": { "tabs": [ { @@ -33,8 +36,6 @@ } ] }, - "backend": "backend/index.mjs", - "frontend": "frontend/index.mjs", "locales": "locales", "platforms": ["linux", "win32", "darwin"] } diff --git a/plugins/host-metrics/package.json b/plugins/host-metrics/package.json new file mode 100644 index 000000000..814f5857a --- /dev/null +++ b/plugins/host-metrics/package.json @@ -0,0 +1,15 @@ +{ + "name": "@termix-plugin/host-metrics", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "termix-plugin build", + "test": "termix-plugin test", + "typecheck": "tsc -p tsconfig.json", + "validate": "termix-plugin validate" + }, + "devDependencies": { + "@termix/plugin-sdk": "*" + } +} diff --git a/plugins/host-metrics/backend/automation-bridge.ts b/plugins/host-metrics/src/backend/automation-bridge.ts similarity index 90% rename from plugins/host-metrics/backend/automation-bridge.ts rename to plugins/host-metrics/src/backend/automation-bridge.ts index 9a49c05f3..37bf5417e 100644 --- a/plugins/host-metrics/backend/automation-bridge.ts +++ b/plugins/host-metrics/src/backend/automation-bridge.ts @@ -11,8 +11,11 @@ * `hosts/automation-events.ts` -- that one is shared across features. */ -import { pluginEvents, TOPICS } from "../../../src/backend/plugins/events.js"; -import type { MetricsSnapshot } from "../../automations/backend/conditions.js"; +import { + pluginEvents, + TOPICS, +} from "../../../../src/backend/plugins/events.js"; +import type { MetricsSnapshot } from "../../../automations/src/backend/conditions.js"; export interface HostMetricsPayload { hostId: number; diff --git a/plugins/host-metrics/backend/helpers.ts b/plugins/host-metrics/src/backend/helpers.ts similarity index 94% rename from plugins/host-metrics/backend/helpers.ts rename to plugins/host-metrics/src/backend/helpers.ts index 48ac7f77a..8dc9bb4da 100644 --- a/plugins/host-metrics/backend/helpers.ts +++ b/plugins/host-metrics/src/backend/helpers.ts @@ -1,5 +1,5 @@ import type { Client } from "ssh2"; -import { usesIssuedCertificate } from "../../../src/backend/hosts/issued-certificate-auth.js"; +import { usesIssuedCertificate } from "../../../../src/backend/hosts/issued-certificate-auth.js"; export type StatsCapableHost = { connectionType?: string; diff --git a/plugins/host-metrics/backend/history-routes.ts b/plugins/host-metrics/src/backend/history-routes.ts similarity index 93% rename from plugins/host-metrics/backend/history-routes.ts rename to plugins/host-metrics/src/backend/history-routes.ts index 58e66211b..f832e81f3 100644 --- a/plugins/host-metrics/backend/history-routes.ts +++ b/plugins/host-metrics/src/backend/history-routes.ts @@ -1,8 +1,8 @@ import type { Express, RequestHandler } from "express"; -import type { AuthenticatedRequest } from "../../../src/types/index.js"; -import { createCurrentHostMetricsHistoryRepository } from "../../../src/backend/database/repositories/factory.js"; -import { statsLogger } from "../../../src/backend/utils/logger.js"; -import type { HostAction } from "../../../src/backend/utils/permission-manager.js"; +import type { AuthenticatedRequest } from "../../../../src/types/index.js"; +import { createCurrentHostMetricsHistoryRepository } from "../../../../src/backend/database/repositories/factory.js"; +import { statsLogger } from "../../../../src/backend/utils/logger.js"; +import type { HostAction } from "../../../../src/backend/utils/permission-manager.js"; type HistoryRoutesDeps = { validateHostId: RequestHandler; diff --git a/plugins/host-metrics/src/backend/index.ts b/plugins/host-metrics/src/backend/index.ts new file mode 100644 index 000000000..ebf353714 --- /dev/null +++ b/plugins/host-metrics/src/backend/index.ts @@ -0,0 +1,20 @@ +import type { Server } from "node:http"; +import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { PORT, shutdown, startHostMetricsService } from "./routes.js"; + +let httpServer: Server | null = null; + +export async function activate(ctx: PluginContext) { + httpServer = startHostMetricsService(); + ctx.log.info(`Host Metrics listening on ${PORT ?? 30005}`); +} + +export async function deactivate() { + shutdown(); + + if (httpServer) { + const server = httpServer; + httpServer = null; + await new Promise((resolve) => server.close(() => resolve())); + } +} diff --git a/plugins/host-metrics/backend/managers/cron.ts b/plugins/host-metrics/src/backend/managers/cron.ts similarity index 95% rename from plugins/host-metrics/backend/managers/cron.ts rename to plugins/host-metrics/src/backend/managers/cron.ts index 362e7f596..501c294aa 100644 --- a/plugins/host-metrics/backend/managers/cron.ts +++ b/plugins/host-metrics/src/backend/managers/cron.ts @@ -1,7 +1,7 @@ import type { Express } from "express"; -import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; +import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js"; import { managerHandler, ManagerInputError } from "./route-helpers.js"; -import { shellSingleQuote } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; +import { shellSingleQuote } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; import type { ManagerRoutesDeps } from "./types.js"; export interface CronEntry { diff --git a/plugins/host-metrics/backend/managers/firewall.ts b/plugins/host-metrics/src/backend/managers/firewall.ts similarity index 93% rename from plugins/host-metrics/backend/managers/firewall.ts rename to plugins/host-metrics/src/backend/managers/firewall.ts index afeeb1461..76f0bc92f 100644 --- a/plugins/host-metrics/backend/managers/firewall.ts +++ b/plugins/host-metrics/src/backend/managers/firewall.ts @@ -1,6 +1,6 @@ import type { Express } from "express"; import { collectFirewallMetrics } from "../widgets/firewall-collector.js"; -import { execElevated } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; +import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; import { managerHandler, ManagerInputError } from "./route-helpers.js"; import { isValidPort, @@ -8,8 +8,8 @@ import { isValidFirewallTarget, type IpProtocol, type FirewallTarget, -} from "../../../../src/backend/hosts/metrics-shared/validation.js"; -import { detectPlatform } from "../../../../src/backend/hosts/metrics-shared/platform.js"; +} from "../../../../../src/backend/hosts/metrics-shared/validation.js"; +import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js"; import type { ManagerRoutesDeps } from "./types.js"; export interface FirewallRuleSpec { diff --git a/plugins/host-metrics/backend/managers/health.ts b/plugins/host-metrics/src/backend/managers/health.ts similarity index 93% rename from plugins/host-metrics/backend/managers/health.ts rename to plugins/host-metrics/src/backend/managers/health.ts index a1e19efd7..457eef4e6 100644 --- a/plugins/host-metrics/backend/managers/health.ts +++ b/plugins/host-metrics/src/backend/managers/health.ts @@ -1,11 +1,11 @@ import type { Express } from "express"; import type { Client } from "ssh2"; -import type { AuthenticatedRequest } from "../../../../src/types/index.js"; -import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; -import { createCurrentHostHealthRepository } from "../../../../src/backend/database/repositories/factory.js"; +import type { AuthenticatedRequest } from "../../../../../src/types/index.js"; +import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js"; +import { createCurrentHostHealthRepository } from "../../../../../src/backend/database/repositories/factory.js"; import { managerHandler, ManagerInputError } from "./route-helpers.js"; -import { shellSingleQuote } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; -import { isValidPort } from "../../../../src/backend/hosts/metrics-shared/validation.js"; +import { shellSingleQuote } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; +import { isValidPort } from "../../../../../src/backend/hosts/metrics-shared/validation.js"; import type { ManagerRoutesDeps } from "./types.js"; import { notifyAutomationHealthCheck } from "../automation-bridge.js"; diff --git a/plugins/host-metrics/backend/managers/index.ts b/plugins/host-metrics/src/backend/managers/index.ts similarity index 95% rename from plugins/host-metrics/backend/managers/index.ts rename to plugins/host-metrics/src/backend/managers/index.ts index da5c6d1e8..f9147e1d5 100644 --- a/plugins/host-metrics/backend/managers/index.ts +++ b/plugins/host-metrics/src/backend/managers/index.ts @@ -1,5 +1,5 @@ import type { Express } from "express"; -import { detectPlatform } from "../../../../src/backend/hosts/metrics-shared/platform.js"; +import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js"; import { managerHandler } from "./route-helpers.js"; import type { ManagerRoutesDeps } from "./types.js"; import { registerServiceRoutes } from "./services.js"; diff --git a/plugins/host-metrics/backend/managers/logs.ts b/plugins/host-metrics/src/backend/managers/logs.ts similarity index 92% rename from plugins/host-metrics/backend/managers/logs.ts rename to plugins/host-metrics/src/backend/managers/logs.ts index 9da3c8264..9e5f2a022 100644 --- a/plugins/host-metrics/backend/managers/logs.ts +++ b/plugins/host-metrics/src/backend/managers/logs.ts @@ -1,14 +1,14 @@ import type { Express } from "express"; -import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; +import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js"; import { execElevated, shellSingleQuote, -} from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; +} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; import { managerHandler, ManagerInputError } from "./route-helpers.js"; import { isAllowedPath, isValidSystemdUnit, -} from "../../../../src/backend/hosts/metrics-shared/validation.js"; +} from "../../../../../src/backend/hosts/metrics-shared/validation.js"; import type { ManagerRoutesDeps } from "./types.js"; /** Directories from which arbitrary log files may be tailed. */ diff --git a/plugins/host-metrics/backend/managers/packages.ts b/plugins/host-metrics/src/backend/managers/packages.ts similarity index 82% rename from plugins/host-metrics/backend/managers/packages.ts rename to plugins/host-metrics/src/backend/managers/packages.ts index 1a1c7d71c..0ddf54af4 100644 --- a/plugins/host-metrics/backend/managers/packages.ts +++ b/plugins/host-metrics/src/backend/managers/packages.ts @@ -1,15 +1,15 @@ import type { Express } from "express"; -import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; -import { execElevated } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; +import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js"; +import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; import { managerHandler, ManagerInputError } from "./route-helpers.js"; -import { isValidPackageName } from "../../../../src/backend/hosts/metrics-shared/validation.js"; -import { detectPlatform } from "../../../../src/backend/hosts/metrics-shared/platform.js"; +import { isValidPackageName } from "../../../../../src/backend/hosts/metrics-shared/validation.js"; +import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js"; import { buildListUpgradableCommand, buildPackageActionCommand, parseUpgradable, type PackageAction, -} from "../../../../src/backend/hosts/metrics-shared/package-commands.js"; +} from "../../../../../src/backend/hosts/metrics-shared/package-commands.js"; import type { ManagerRoutesDeps } from "./types.js"; export function registerPackageRoutes( diff --git a/plugins/host-metrics/backend/managers/processes.ts b/plugins/host-metrics/src/backend/managers/processes.ts similarity index 93% rename from plugins/host-metrics/backend/managers/processes.ts rename to plugins/host-metrics/src/backend/managers/processes.ts index 6fc6df337..918c0afe7 100644 --- a/plugins/host-metrics/backend/managers/processes.ts +++ b/plugins/host-metrics/src/backend/managers/processes.ts @@ -1,12 +1,12 @@ import type { Express } from "express"; -import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; -import { execElevated } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; +import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js"; +import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; import { managerHandler, ManagerInputError } from "./route-helpers.js"; import { isValidPid, isValidSignal, type Signal, -} from "../../../../src/backend/hosts/metrics-shared/validation.js"; +} from "../../../../../src/backend/hosts/metrics-shared/validation.js"; import type { ManagerRoutesDeps } from "./types.js"; export interface ProcessRow { diff --git a/plugins/host-metrics/backend/managers/route-helpers.ts b/plugins/host-metrics/src/backend/managers/route-helpers.ts similarity index 80% rename from plugins/host-metrics/backend/managers/route-helpers.ts rename to plugins/host-metrics/src/backend/managers/route-helpers.ts index 3677d4a80..a6aad43b9 100644 --- a/plugins/host-metrics/backend/managers/route-helpers.ts +++ b/plugins/host-metrics/src/backend/managers/route-helpers.ts @@ -1,11 +1,11 @@ -import { getErrorMessage } from "../../../../src/backend/utils/error-message.js"; +import { getErrorMessage } from "../../../../../src/backend/utils/error-message.js"; import type { Request, Response } from "express"; import type { Client } from "ssh2"; -import type { AuthenticatedRequest } from "../../../../src/types/index.js"; -import { statsLogger } from "../../../../src/backend/utils/logger.js"; -import { ElevationError } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; +import type { AuthenticatedRequest } from "../../../../../src/types/index.js"; +import { statsLogger } from "../../../../../src/backend/utils/logger.js"; +import { ElevationError } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; import type { ManagerHost, RunOnHost } from "./types.js"; -import type { HostAction } from "../../../../src/backend/utils/permission-manager.js"; +import type { HostAction } from "../../../../../src/backend/utils/permission-manager.js"; export class AccessDeniedError extends Error { constructor(message = "No access to this host") { diff --git a/plugins/host-metrics/backend/managers/services.ts b/plugins/host-metrics/src/backend/managers/services.ts similarity index 93% rename from plugins/host-metrics/backend/managers/services.ts rename to plugins/host-metrics/src/backend/managers/services.ts index 6502a39f4..367dfe5cc 100644 --- a/plugins/host-metrics/backend/managers/services.ts +++ b/plugins/host-metrics/src/backend/managers/services.ts @@ -1,12 +1,12 @@ import type { Express } from "express"; -import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; -import { execElevated } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; +import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js"; +import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; import { managerHandler, ManagerInputError } from "./route-helpers.js"; import { isValidSystemdUnit, isValidServiceAction, type ServiceAction, -} from "../../../../src/backend/hosts/metrics-shared/validation.js"; +} from "../../../../../src/backend/hosts/metrics-shared/validation.js"; import type { ManagerRoutesDeps } from "./types.js"; export interface SystemdService { diff --git a/plugins/host-metrics/backend/managers/simple-reads.ts b/plugins/host-metrics/src/backend/managers/simple-reads.ts similarity index 97% rename from plugins/host-metrics/backend/managers/simple-reads.ts rename to plugins/host-metrics/src/backend/managers/simple-reads.ts index f74e1b0c2..dcf9db75c 100644 --- a/plugins/host-metrics/backend/managers/simple-reads.ts +++ b/plugins/host-metrics/src/backend/managers/simple-reads.ts @@ -1,5 +1,5 @@ import type { Express } from "express"; -import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; +import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js"; import { managerHandler } from "./route-helpers.js"; import type { ManagerRoutesDeps } from "./types.js"; diff --git a/plugins/host-metrics/backend/managers/ssl.ts b/plugins/host-metrics/src/backend/managers/ssl.ts similarity index 96% rename from plugins/host-metrics/backend/managers/ssl.ts rename to plugins/host-metrics/src/backend/managers/ssl.ts index 6927f0a92..c251b884d 100644 --- a/plugins/host-metrics/backend/managers/ssl.ts +++ b/plugins/host-metrics/src/backend/managers/ssl.ts @@ -1,16 +1,16 @@ import type { Express } from "express"; -import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; +import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js"; import { execElevated, shellSingleQuote, -} from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; +} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; import { managerHandler, ManagerInputError } from "./route-helpers.js"; import { isValidDomain, isValidDnsProvider, isAllowedPath, -} from "../../../../src/backend/hosts/metrics-shared/validation.js"; -import { detectPlatform } from "../../../../src/backend/hosts/metrics-shared/platform.js"; +} from "../../../../../src/backend/hosts/metrics-shared/validation.js"; +import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js"; import type { ManagerRoutesDeps } from "./types.js"; export type AcmeClient = "certbot" | "acme.sh"; diff --git a/plugins/host-metrics/backend/managers/tailscale.ts b/plugins/host-metrics/src/backend/managers/tailscale.ts similarity index 93% rename from plugins/host-metrics/backend/managers/tailscale.ts rename to plugins/host-metrics/src/backend/managers/tailscale.ts index b7166c55a..0104c1e22 100644 --- a/plugins/host-metrics/backend/managers/tailscale.ts +++ b/plugins/host-metrics/src/backend/managers/tailscale.ts @@ -1,9 +1,9 @@ import type { Express } from "express"; -import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; -import { execElevated } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; +import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js"; +import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; import { managerHandler, ManagerInputError } from "./route-helpers.js"; import type { ManagerRoutesDeps } from "./types.js"; -import { isValidTailscaleAction } from "../../../../src/backend/hosts/metrics-shared/validation.js"; +import { isValidTailscaleAction } from "../../../../../src/backend/hosts/metrics-shared/validation.js"; export interface TailscalePeer { hostname: string; diff --git a/plugins/host-metrics/backend/managers/types.ts b/plugins/host-metrics/src/backend/managers/types.ts similarity index 89% rename from plugins/host-metrics/backend/managers/types.ts rename to plugins/host-metrics/src/backend/managers/types.ts index 486b36824..1169d06d8 100644 --- a/plugins/host-metrics/backend/managers/types.ts +++ b/plugins/host-metrics/src/backend/managers/types.ts @@ -1,6 +1,6 @@ import type { Client } from "ssh2"; import type { RequestHandler } from "express"; -import type { HostAction } from "../../../../src/backend/utils/permission-manager.js"; +import type { HostAction } from "../../../../../src/backend/utils/permission-manager.js"; /** Minimal host shape managers need (includes the decrypted sudo password). */ export interface ManagerHost { diff --git a/plugins/host-metrics/backend/managers/users.ts b/plugins/host-metrics/src/backend/managers/users.ts similarity index 94% rename from plugins/host-metrics/backend/managers/users.ts rename to plugins/host-metrics/src/backend/managers/users.ts index 8df13a739..34ae6ef06 100644 --- a/plugins/host-metrics/backend/managers/users.ts +++ b/plugins/host-metrics/src/backend/managers/users.ts @@ -1,11 +1,11 @@ import type { Express } from "express"; -import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; -import { execElevated } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; +import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js"; +import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; import { managerHandler, ManagerInputError } from "./route-helpers.js"; import { isValidUsername, isValidGroupName, -} from "../../../../src/backend/hosts/metrics-shared/validation.js"; +} from "../../../../../src/backend/hosts/metrics-shared/validation.js"; import type { ManagerRoutesDeps } from "./types.js"; export interface SystemUser { diff --git a/plugins/host-metrics/backend/managers/wireguard.ts b/plugins/host-metrics/src/backend/managers/wireguard.ts similarity index 97% rename from plugins/host-metrics/backend/managers/wireguard.ts rename to plugins/host-metrics/src/backend/managers/wireguard.ts index dc199712e..15b9d754b 100644 --- a/plugins/host-metrics/backend/managers/wireguard.ts +++ b/plugins/host-metrics/src/backend/managers/wireguard.ts @@ -1,11 +1,11 @@ import type { Express } from "express"; -import { execElevated } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; +import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; import { managerHandler, ManagerInputError } from "./route-helpers.js"; import type { ManagerRoutesDeps } from "./types.js"; import { isValidWireGuardInterface, isValidWireGuardAction, -} from "../../../../src/backend/hosts/metrics-shared/validation.js"; +} from "../../../../../src/backend/hosts/metrics-shared/validation.js"; export interface WireGuardPeer { publicKey: string; diff --git a/plugins/host-metrics/backend/preferences-routes.ts b/plugins/host-metrics/src/backend/preferences-routes.ts similarity index 95% rename from plugins/host-metrics/backend/preferences-routes.ts rename to plugins/host-metrics/src/backend/preferences-routes.ts index 9bb9ef6f1..b0fd17d47 100644 --- a/plugins/host-metrics/backend/preferences-routes.ts +++ b/plugins/host-metrics/src/backend/preferences-routes.ts @@ -1,13 +1,13 @@ import type { Express, RequestHandler } from "express"; -import type { AuthenticatedRequest } from "../../../src/types/index.js"; -import { createCurrentHostMetricsPreferenceRepository } from "../../../src/backend/database/repositories/factory.js"; -import { statsLogger } from "../../../src/backend/utils/logger.js"; +import type { AuthenticatedRequest } from "../../../../src/types/index.js"; +import { createCurrentHostMetricsPreferenceRepository } from "../../../../src/backend/database/repositories/factory.js"; +import { statsLogger } from "../../../../src/backend/utils/logger.js"; import { deriveEnabledWidgets, defaultLayoutFromWidgets, type HostMetricsLayout, -} from "../../../src/types/host-metrics.js"; -import type { HostAction } from "../../../src/backend/utils/permission-manager.js"; +} from "../../../../src/types/host-metrics.js"; +import type { HostAction } from "../../../../src/backend/utils/permission-manager.js"; interface PrefStatsConfig { enabledWidgets?: string[]; diff --git a/plugins/host-metrics/backend/routes.ts b/plugins/host-metrics/src/backend/routes.ts similarity index 96% rename from plugins/host-metrics/backend/routes.ts rename to plugins/host-metrics/src/backend/routes.ts index 298d1f6d1..5c0efa025 100644 --- a/plugins/host-metrics/backend/routes.ts +++ b/plugins/host-metrics/src/backend/routes.ts @@ -1,33 +1,33 @@ -import { getErrorMessage } from "../../../src/backend/utils/error-message.js"; -import { usesIssuedCertificate } from "../../../src/backend/hosts/issued-certificate-auth.js"; +import { getErrorMessage } from "../../../../src/backend/utils/error-message.js"; +import { usesIssuedCertificate } from "../../../../src/backend/hosts/issued-certificate-auth.js"; import express from "express"; import net from "net"; -import { createCorsMiddleware } from "../../../src/backend/utils/cors-config.js"; -import { createCompressionMiddleware } from "../../../src/backend/utils/compression-config.js"; +import { createCorsMiddleware } from "../../../../src/backend/utils/cors-config.js"; +import { createCompressionMiddleware } from "../../../../src/backend/utils/compression-config.js"; import cookieParser from "cookie-parser"; import { Client, type ConnectConfig } from "ssh2"; -import { SSH_ALGORITHMS } from "../../../src/backend/utils/ssh-algorithms.js"; +import { SSH_ALGORITHMS } from "../../../../src/backend/utils/ssh-algorithms.js"; import { pickResolvedPassword, pickResolvedUsername, -} from "../../../src/backend/hosts/credential-username.js"; +} from "../../../../src/backend/hosts/credential-username.js"; import { getCurrentSettingValue, createCurrentHostMetricsHistoryRepository, createCurrentHostResolutionRepository, -} from "../../../src/backend/database/repositories/factory.js"; -import { statsLogger } from "../../../src/backend/utils/logger.js"; -import { DataCrypto } from "../../../src/backend/utils/data-crypto.js"; -import { AuthManager } from "../../../src/backend/utils/auth-manager.js"; -import { PermissionManager } from "../../../src/backend/utils/permission-manager.js"; +} from "../../../../src/backend/database/repositories/factory.js"; +import { statsLogger } from "../../../../src/backend/utils/logger.js"; +import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js"; +import { AuthManager } from "../../../../src/backend/utils/auth-manager.js"; +import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js"; import type { AuthenticatedRequest, ProxyNode, -} from "../../../src/types/index.js"; +} from "../../../../src/types/index.js"; import type { LogEntry, ConnectionStage, -} from "../../../src/types/connection-log.js"; +} from "../../../../src/types/connection-log.js"; import { collectCpuMetrics, clearCpuSampleCache, @@ -44,7 +44,7 @@ import { import { collectUptimeMetrics } from "./widgets/uptime-collector.js"; import { collectProcessesMetrics } from "./widgets/processes-collector.js"; import { collectSystemMetrics } from "./widgets/system-collector.js"; -import { detectPlatform } from "../../../src/backend/hosts/metrics-shared/common-utils.js"; +import { detectPlatform } from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; import { collectLoginStats } from "./widgets/login-stats-collector.js"; import { collectPortsMetrics } from "./widgets/ports-collector.js"; import { collectFirewallMetrics } from "./widgets/firewall-collector.js"; @@ -53,33 +53,36 @@ import { collectGpuMetrics, type GpuMetrics } from "./widgets/gpu-collector.js"; import { createSocks5Connection, type SOCKS5Config, -} from "../../../src/backend/utils/socks5-helper.js"; -import { preparePrivateKeyForSSH2 } from "../../../src/backend/utils/ssh-key-utils.js"; -import { SSHHostKeyVerifier } from "../../../src/backend/hosts/host-key-verifier.js"; +} from "../../../../src/backend/utils/socks5-helper.js"; +import { preparePrivateKeyForSSH2 } from "../../../../src/backend/utils/ssh-key-utils.js"; +import { SSHHostKeyVerifier } from "../../../../src/backend/hosts/host-key-verifier.js"; import { connectionPool, withConnection, -} from "../../../src/backend/hosts/ssh-connection-pool.js"; +} from "../../../../src/backend/hosts/ssh-connection-pool.js"; import { registerHostMetricsSettingsRoutes } from "./settings-routes.js"; import { registerHostMetricsViewerRoutes } from "./viewer-routes.js"; import { registerHostMetricsPreferencesRoutes } from "./preferences-routes.js"; import { registerHostMetricsHistoryRoutes } from "./history-routes.js"; -import { registerProxmoxStatsRoutes } from "../../../src/backend/hosts/metrics/proxmox-stats-routes.js"; -import { registerProxmoxStatsHistoryRoutes } from "../../../src/backend/hosts/metrics/proxmox-stats-history-routes.js"; -import { ProxmoxPollingManager } from "../../../src/backend/hosts/metrics/proxmox-stats-polling.js"; -import type { HostSessionStatusPayload } from "../../../src/backend/hosts/host-session-status.js"; -import { pluginEvents, TOPICS } from "../../../src/backend/plugins/events.js"; +import { registerProxmoxStatsRoutes } from "../../../../src/backend/hosts/metrics/proxmox-stats-routes.js"; +import { registerProxmoxStatsHistoryRoutes } from "../../../../src/backend/hosts/metrics/proxmox-stats-history-routes.js"; +import { ProxmoxPollingManager } from "../../../../src/backend/hosts/metrics/proxmox-stats-polling.js"; +import type { HostSessionStatusPayload } from "../../../../src/backend/hosts/host-session-status.js"; +import { + pluginEvents, + TOPICS, +} from "../../../../src/backend/plugins/events.js"; import { notifyAutomationMetrics, notifyAutomationStatus, } from "./automation-bridge.js"; -import { notifyAutomationInternalEvent } from "../../../src/backend/hosts/automation-events.js"; +import { notifyAutomationInternalEvent } from "../../../../src/backend/hosts/automation-events.js"; import { registerManagerRoutes } from "./managers/index.js"; -import { resolveSshConnectConfigHost } from "../../../src/backend/hosts/ssh-dns.js"; +import { resolveSshConnectConfigHost } from "../../../../src/backend/hosts/ssh-dns.js"; import { AccessDeniedError } from "./managers/route-helpers.js"; import type { ManagerHost } from "./managers/types.js"; -import { createJumpHostChain } from "../../../src/backend/hosts/jump-host-chain.js"; -import { resolveHostById } from "../../../src/backend/hosts/host-resolver.js"; +import { createJumpHostChain } from "../../../../src/backend/hosts/jump-host-chain.js"; +import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js"; import { isTcpPingEnabled, parseStatusHostIds, @@ -91,8 +94,8 @@ import { isHostKeyVerificationError, statusAfterAuthentication, statusAfterReachabilityCheck, -} from "../../../src/backend/hosts/metrics-shared/host-status.js"; -import { createConnectionLog } from "../../../src/backend/hosts/connection-log.js"; +} from "../../../../src/backend/hosts/metrics-shared/host-status.js"; +import { createConnectionLog } from "../../../../src/backend/hosts/connection-log.js"; import { cleanupMetricsSession, getSessionKey, @@ -112,8 +115,8 @@ import { pollingBackoff, requestQueue, statusPollLimiter, -} from "../../../src/backend/hosts/metrics-shared/state.js"; -import { listenOnServicePort } from "../../../src/backend/utils/service-listen.js"; +} from "../../../../src/backend/hosts/metrics-shared/state.js"; +import { listenOnServicePort } from "../../../../src/backend/utils/service-listen.js"; const authManager = AuthManager.getInstance(); const permissionManager = PermissionManager.getInstance(); @@ -1097,7 +1100,7 @@ app.post("/internal/login-alert", async (req, res) => { return res.status(403).json({ error: "Forbidden" }); } const systemCrypto = ( - await import("../../../src/backend/utils/system-crypto.js") + await import("../../../../src/backend/utils/system-crypto.js") ).SystemCrypto; const expectedToken = await systemCrypto.getInstance().getInternalAuthToken(); const token = req.headers["x-internal-auth"]; @@ -1242,7 +1245,7 @@ async function resolveHostCredentials( if (isSharedHost) { const { SharedHostSecretsManager } = - await import("../../../src/backend/utils/shared-host-secrets-manager.js"); + await import("../../../../src/backend/utils/shared-host-secrets-manager.js"); const sharedCred = await SharedHostSecretsManager.getInstance().getSecretForUser( host.id as number, @@ -1510,7 +1513,7 @@ function createSshFactory(host: SSHHostWithCredentials): () => Promise { // Set up OPKSSH cert auth if needed (requires client instance) if (usesIssuedCertificate(host.authType) && host.userId) { const { getOPKSSHToken } = - await import("../../../src/backend/hosts/opkssh-auth.js"); + await import("../../../../src/backend/hosts/opkssh-auth.js"); const token = await getOPKSSHToken(host.userId, host.id); if (!token) { throw new Error( @@ -1518,11 +1521,11 @@ function createSshFactory(host: SSHHostWithCredentials): () => Promise { ); } const { setupOPKSSHCertAuth } = - await import("../../../src/backend/hosts/opkssh-cert-auth.js"); + await import("../../../../src/backend/hosts/opkssh-cert-auth.js"); await setupOPKSSHCertAuth(config, client, token, host.username); } else if (host.authType === "vault") { const { setupVaultSshSignerAuth } = - await import("../../../src/backend/hosts/vault-ssh-connect.js"); + await import("../../../../src/backend/hosts/vault-ssh-connect.js"); await setupVaultSshSignerAuth(config, client, host); } @@ -2443,7 +2446,7 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => { if (usesIssuedCertificate(host.authType) && host.userId) { const { getOPKSSHToken } = - await import("../../../src/backend/hosts/opkssh-auth.js"); + await import("../../../../src/backend/hosts/opkssh-auth.js"); const token = await getOPKSSHToken(host.userId, host.id); if (!token) { connectionLogs.push( @@ -2460,11 +2463,11 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => { }); } const { setupOPKSSHCertAuth } = - await import("../../../src/backend/hosts/opkssh-cert-auth.js"); + await import("../../../../src/backend/hosts/opkssh-cert-auth.js"); await setupOPKSSHCertAuth(config, client, token, host.username); } else if (host.authType === "vault") { const { setupVaultSshSignerAuth } = - await import("../../../src/backend/hosts/vault-ssh-connect.js"); + await import("../../../../src/backend/hosts/vault-ssh-connect.js"); await setupVaultSshSignerAuth(config, client, host); } @@ -3033,7 +3036,7 @@ app.post("/metrics/connect-totp", async (req, res) => { // Lets automations keep metrics flowing for hosts they watch, through the same // viewer refcount the UI uses rather than around it. -import("../../automations/backend/headless-viewer.js") +import("../../../automations/src/backend/headless-viewer.js") .then(({ setViewerRegistry }) => { setViewerRegistry({ registerViewer: (hostId, sessionId, userId) => diff --git a/plugins/host-metrics/backend/sessions.ts b/plugins/host-metrics/src/backend/sessions.ts similarity index 96% rename from plugins/host-metrics/backend/sessions.ts rename to plugins/host-metrics/src/backend/sessions.ts index 6c9809a77..87a5f4b01 100644 --- a/plugins/host-metrics/backend/sessions.ts +++ b/plugins/host-metrics/src/backend/sessions.ts @@ -1,5 +1,5 @@ import type { Client, ConnectConfig } from "ssh2"; -import { statsLogger } from "../../../src/backend/utils/logger.js"; +import { statsLogger } from "../../../../src/backend/utils/logger.js"; export interface MetricsSession { client: Client; diff --git a/plugins/host-metrics/backend/settings-routes.ts b/plugins/host-metrics/src/backend/settings-routes.ts similarity index 97% rename from plugins/host-metrics/backend/settings-routes.ts rename to plugins/host-metrics/src/backend/settings-routes.ts index 7bf58c488..1907886ee 100644 --- a/plugins/host-metrics/backend/settings-routes.ts +++ b/plugins/host-metrics/src/backend/settings-routes.ts @@ -1,6 +1,6 @@ import type { Express, RequestHandler } from "express"; -import { statsLogger } from "../../../src/backend/utils/logger.js"; -import { createCurrentSettingsRepository } from "../../../src/backend/database/repositories/factory.js"; +import { statsLogger } from "../../../../src/backend/utils/logger.js"; +import { createCurrentSettingsRepository } from "../../../../src/backend/database/repositories/factory.js"; type HostMetricsSettingsConfig = { statusCheckInterval: number; diff --git a/plugins/host-metrics/backend/viewer-routes.ts b/plugins/host-metrics/src/backend/viewer-routes.ts similarity index 96% rename from plugins/host-metrics/backend/viewer-routes.ts rename to plugins/host-metrics/src/backend/viewer-routes.ts index f0e49e5b6..cf96ce8ab 100644 --- a/plugins/host-metrics/backend/viewer-routes.ts +++ b/plugins/host-metrics/src/backend/viewer-routes.ts @@ -1,8 +1,8 @@ -import { getErrorMessage } from "../../../src/backend/utils/error-message.js"; +import { getErrorMessage } from "../../../../src/backend/utils/error-message.js"; import type { Express } from "express"; -import type { AuthenticatedRequest } from "../../../src/types/index.js"; -import { statsLogger } from "../../../src/backend/utils/logger.js"; -import { DataCrypto } from "../../../src/backend/utils/data-crypto.js"; +import type { AuthenticatedRequest } from "../../../../src/types/index.js"; +import { statsLogger } from "../../../../src/backend/utils/logger.js"; +import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js"; type ViewerStatsConfig = { metricsEnabled: boolean; diff --git a/plugins/host-metrics/backend/widgets/cpu-collector.ts b/plugins/host-metrics/src/backend/widgets/cpu-collector.ts similarity index 98% rename from plugins/host-metrics/backend/widgets/cpu-collector.ts rename to plugins/host-metrics/src/backend/widgets/cpu-collector.ts index f8948880b..1bc999f45 100644 --- a/plugins/host-metrics/backend/widgets/cpu-collector.ts +++ b/plugins/host-metrics/src/backend/widgets/cpu-collector.ts @@ -4,7 +4,7 @@ import { execPowerShell, toFixedNum, type HostPlatform, -} from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; +} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js"; /** * CPU percent is derived from the delta against the previous poll's diff --git a/plugins/host-metrics/backend/widgets/disk-collector.ts b/plugins/host-metrics/src/backend/widgets/disk-collector.ts similarity index 99% rename from plugins/host-metrics/backend/widgets/disk-collector.ts rename to plugins/host-metrics/src/backend/widgets/disk-collector.ts index bcaee108e..1e5d72562 100644 --- a/plugins/host-metrics/backend/widgets/disk-collector.ts +++ b/plugins/host-metrics/src/backend/widgets/disk-collector.ts @@ -4,7 +4,7 @@ import { execPowerShell, toFixedNum, type HostPlatform, -} from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; +} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js"; const PSEUDO_FS_RE = /^(tmpfs|devtmpfs|overlay|udev|none|shm)$/; diff --git a/plugins/host-metrics/backend/widgets/firewall-collector.ts b/plugins/host-metrics/src/backend/widgets/firewall-collector.ts similarity index 97% rename from plugins/host-metrics/backend/widgets/firewall-collector.ts rename to plugins/host-metrics/src/backend/widgets/firewall-collector.ts index 657db71f3..0ed13ffa2 100644 --- a/plugins/host-metrics/backend/widgets/firewall-collector.ts +++ b/plugins/host-metrics/src/backend/widgets/firewall-collector.ts @@ -1,10 +1,10 @@ import type { Client } from "ssh2"; -import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; +import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js"; import type { FirewallMetrics, FirewallChain, FirewallRule, -} from "../../../../src/types/stats-widgets.js"; +} from "../../../../../src/types/stats-widgets.js"; function parseIptablesRule(line: string): FirewallRule | null { if (!line.startsWith("-A ")) return null; diff --git a/plugins/host-metrics/backend/widgets/gpu-collector.ts b/plugins/host-metrics/src/backend/widgets/gpu-collector.ts similarity index 98% rename from plugins/host-metrics/backend/widgets/gpu-collector.ts rename to plugins/host-metrics/src/backend/widgets/gpu-collector.ts index 0fcd475ea..9e6b6daa7 100644 --- a/plugins/host-metrics/backend/widgets/gpu-collector.ts +++ b/plugins/host-metrics/src/backend/widgets/gpu-collector.ts @@ -2,7 +2,7 @@ import type { Client } from "ssh2"; import { execCommand, toFixedNum, -} from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; +} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js"; export interface GpuDevice { index: number; diff --git a/plugins/host-metrics/backend/widgets/login-stats-collector.ts b/plugins/host-metrics/src/backend/widgets/login-stats-collector.ts similarity index 97% rename from plugins/host-metrics/backend/widgets/login-stats-collector.ts rename to plugins/host-metrics/src/backend/widgets/login-stats-collector.ts index e6bb3b98f..afc992169 100644 --- a/plugins/host-metrics/backend/widgets/login-stats-collector.ts +++ b/plugins/host-metrics/src/backend/widgets/login-stats-collector.ts @@ -1,5 +1,5 @@ import type { Client } from "ssh2"; -import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; +import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js"; export interface LoginRecord { user: string; diff --git a/plugins/host-metrics/backend/widgets/memory-collector.ts b/plugins/host-metrics/src/backend/widgets/memory-collector.ts similarity index 98% rename from plugins/host-metrics/backend/widgets/memory-collector.ts rename to plugins/host-metrics/src/backend/widgets/memory-collector.ts index dfa91fc56..c34d98e03 100644 --- a/plugins/host-metrics/backend/widgets/memory-collector.ts +++ b/plugins/host-metrics/src/backend/widgets/memory-collector.ts @@ -5,7 +5,7 @@ import { toFixedNum, kibToGiB, type HostPlatform, -} from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; +} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js"; async function collectDarwinMemoryMetrics(client: Client): Promise<{ percent: number | null; diff --git a/plugins/host-metrics/backend/widgets/network-collector.ts b/plugins/host-metrics/src/backend/widgets/network-collector.ts similarity index 99% rename from plugins/host-metrics/backend/widgets/network-collector.ts rename to plugins/host-metrics/src/backend/widgets/network-collector.ts index ae43d051d..d4ef36435 100644 --- a/plugins/host-metrics/backend/widgets/network-collector.ts +++ b/plugins/host-metrics/src/backend/widgets/network-collector.ts @@ -3,7 +3,7 @@ import { execCommand, execPowerShell, type HostPlatform, -} from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; +} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js"; export interface NetworkCounters { rx: string; diff --git a/plugins/host-metrics/backend/widgets/ports-collector.ts b/plugins/host-metrics/src/backend/widgets/ports-collector.ts similarity index 96% rename from plugins/host-metrics/backend/widgets/ports-collector.ts rename to plugins/host-metrics/src/backend/widgets/ports-collector.ts index 743084a47..a467bfc3a 100644 --- a/plugins/host-metrics/backend/widgets/ports-collector.ts +++ b/plugins/host-metrics/src/backend/widgets/ports-collector.ts @@ -1,9 +1,9 @@ import type { Client } from "ssh2"; -import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; +import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js"; import type { PortsMetrics, ListeningPort, -} from "../../../../src/types/stats-widgets.js"; +} from "../../../../../src/types/stats-widgets.js"; function parseSsOutput(output: string): ListeningPort[] { const ports: ListeningPort[] = []; diff --git a/plugins/host-metrics/backend/widgets/processes-collector.ts b/plugins/host-metrics/src/backend/widgets/processes-collector.ts similarity index 95% rename from plugins/host-metrics/backend/widgets/processes-collector.ts rename to plugins/host-metrics/src/backend/widgets/processes-collector.ts index 33980edc9..89d8f1dc2 100644 --- a/plugins/host-metrics/backend/widgets/processes-collector.ts +++ b/plugins/host-metrics/src/backend/widgets/processes-collector.ts @@ -1,5 +1,5 @@ import type { Client } from "ssh2"; -import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; +import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js"; export async function collectProcessesMetrics(client: Client): Promise<{ total: number | null; diff --git a/plugins/host-metrics/backend/widgets/system-collector.ts b/plugins/host-metrics/src/backend/widgets/system-collector.ts similarity index 96% rename from plugins/host-metrics/backend/widgets/system-collector.ts rename to plugins/host-metrics/src/backend/widgets/system-collector.ts index cb269da1e..ff5677bfc 100644 --- a/plugins/host-metrics/backend/widgets/system-collector.ts +++ b/plugins/host-metrics/src/backend/widgets/system-collector.ts @@ -3,7 +3,7 @@ import { execCommand, execPowerShell, type HostPlatform, -} from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; +} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js"; async function collectWindowsSystemMetrics(client: Client): Promise<{ hostname: string | null; diff --git a/plugins/host-metrics/backend/widgets/temperature-collector.ts b/plugins/host-metrics/src/backend/widgets/temperature-collector.ts similarity index 97% rename from plugins/host-metrics/backend/widgets/temperature-collector.ts rename to plugins/host-metrics/src/backend/widgets/temperature-collector.ts index 4ef51a584..2adc09f8f 100644 --- a/plugins/host-metrics/backend/widgets/temperature-collector.ts +++ b/plugins/host-metrics/src/backend/widgets/temperature-collector.ts @@ -2,7 +2,7 @@ import type { Client } from "ssh2"; import { execCommand, toFixedNum, -} from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; +} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js"; export interface TemperatureSensor { label: string; diff --git a/plugins/host-metrics/backend/widgets/uptime-collector.ts b/plugins/host-metrics/src/backend/widgets/uptime-collector.ts similarity index 97% rename from plugins/host-metrics/backend/widgets/uptime-collector.ts rename to plugins/host-metrics/src/backend/widgets/uptime-collector.ts index 0c585e6ab..23b4a4439 100644 --- a/plugins/host-metrics/backend/widgets/uptime-collector.ts +++ b/plugins/host-metrics/src/backend/widgets/uptime-collector.ts @@ -3,7 +3,7 @@ import { execCommand, execPowerShell, type HostPlatform, -} from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; +} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js"; function formatUptime(uptimeSeconds: number): string { const days = Math.floor(uptimeSeconds / 86400); diff --git a/plugins/host-metrics/frontend/HostMetricsApp.tsx b/plugins/host-metrics/src/frontend/HostMetricsApp.tsx similarity index 100% rename from plugins/host-metrics/frontend/HostMetricsApp.tsx rename to plugins/host-metrics/src/frontend/HostMetricsApp.tsx diff --git a/plugins/host-metrics/frontend/HostMetricsTab.tsx b/plugins/host-metrics/src/frontend/HostMetricsTab.tsx similarity index 100% rename from plugins/host-metrics/frontend/HostMetricsTab.tsx rename to plugins/host-metrics/src/frontend/HostMetricsTab.tsx diff --git a/plugins/host-metrics/frontend/cards/CardTimeTabs.tsx b/plugins/host-metrics/src/frontend/cards/CardTimeTabs.tsx similarity index 100% rename from plugins/host-metrics/frontend/cards/CardTimeTabs.tsx rename to plugins/host-metrics/src/frontend/cards/CardTimeTabs.tsx diff --git a/plugins/host-metrics/frontend/cards/CpuCard.tsx b/plugins/host-metrics/src/frontend/cards/CpuCard.tsx similarity index 100% rename from plugins/host-metrics/frontend/cards/CpuCard.tsx rename to plugins/host-metrics/src/frontend/cards/CpuCard.tsx diff --git a/plugins/host-metrics/frontend/cards/DiskCard.tsx b/plugins/host-metrics/src/frontend/cards/DiskCard.tsx similarity index 100% rename from plugins/host-metrics/frontend/cards/DiskCard.tsx rename to plugins/host-metrics/src/frontend/cards/DiskCard.tsx diff --git a/plugins/host-metrics/frontend/cards/FilesystemPicker.tsx b/plugins/host-metrics/src/frontend/cards/FilesystemPicker.tsx similarity index 100% rename from plugins/host-metrics/frontend/cards/FilesystemPicker.tsx rename to plugins/host-metrics/src/frontend/cards/FilesystemPicker.tsx diff --git a/plugins/host-metrics/frontend/cards/FirewallCard.tsx b/plugins/host-metrics/src/frontend/cards/FirewallCard.tsx similarity index 100% rename from plugins/host-metrics/frontend/cards/FirewallCard.tsx rename to plugins/host-metrics/src/frontend/cards/FirewallCard.tsx diff --git a/plugins/host-metrics/frontend/cards/GpuCard.tsx b/plugins/host-metrics/src/frontend/cards/GpuCard.tsx similarity index 100% rename from plugins/host-metrics/frontend/cards/GpuCard.tsx rename to plugins/host-metrics/src/frontend/cards/GpuCard.tsx diff --git a/plugins/host-metrics/frontend/cards/LoginStatsCard.tsx b/plugins/host-metrics/src/frontend/cards/LoginStatsCard.tsx similarity index 100% rename from plugins/host-metrics/frontend/cards/LoginStatsCard.tsx rename to plugins/host-metrics/src/frontend/cards/LoginStatsCard.tsx diff --git a/plugins/host-metrics/frontend/cards/MemoryCard.tsx b/plugins/host-metrics/src/frontend/cards/MemoryCard.tsx similarity index 100% rename from plugins/host-metrics/frontend/cards/MemoryCard.tsx rename to plugins/host-metrics/src/frontend/cards/MemoryCard.tsx diff --git a/plugins/host-metrics/frontend/cards/NetworkCard.tsx b/plugins/host-metrics/src/frontend/cards/NetworkCard.tsx similarity index 100% rename from plugins/host-metrics/frontend/cards/NetworkCard.tsx rename to plugins/host-metrics/src/frontend/cards/NetworkCard.tsx diff --git a/plugins/host-metrics/frontend/cards/PortsCard.tsx b/plugins/host-metrics/src/frontend/cards/PortsCard.tsx similarity index 100% rename from plugins/host-metrics/frontend/cards/PortsCard.tsx rename to plugins/host-metrics/src/frontend/cards/PortsCard.tsx diff --git a/plugins/host-metrics/frontend/cards/ProcessesCard.tsx b/plugins/host-metrics/src/frontend/cards/ProcessesCard.tsx similarity index 100% rename from plugins/host-metrics/frontend/cards/ProcessesCard.tsx rename to plugins/host-metrics/src/frontend/cards/ProcessesCard.tsx diff --git a/plugins/host-metrics/frontend/cards/SystemCard.tsx b/plugins/host-metrics/src/frontend/cards/SystemCard.tsx similarity index 100% rename from plugins/host-metrics/frontend/cards/SystemCard.tsx rename to plugins/host-metrics/src/frontend/cards/SystemCard.tsx diff --git a/plugins/host-metrics/frontend/cards/TemperatureCard.tsx b/plugins/host-metrics/src/frontend/cards/TemperatureCard.tsx similarity index 100% rename from plugins/host-metrics/frontend/cards/TemperatureCard.tsx rename to plugins/host-metrics/src/frontend/cards/TemperatureCard.tsx diff --git a/plugins/host-metrics/frontend/cards/UptimeCard.tsx b/plugins/host-metrics/src/frontend/cards/UptimeCard.tsx similarity index 100% rename from plugins/host-metrics/frontend/cards/UptimeCard.tsx rename to plugins/host-metrics/src/frontend/cards/UptimeCard.tsx diff --git a/plugins/host-metrics/frontend/cards/gpu-history.ts b/plugins/host-metrics/src/frontend/cards/gpu-history.ts similarity index 100% rename from plugins/host-metrics/frontend/cards/gpu-history.ts rename to plugins/host-metrics/src/frontend/cards/gpu-history.ts diff --git a/plugins/host-metrics/frontend/cards/index.tsx b/plugins/host-metrics/src/frontend/cards/index.tsx similarity index 100% rename from plugins/host-metrics/frontend/cards/index.tsx rename to plugins/host-metrics/src/frontend/cards/index.tsx diff --git a/plugins/host-metrics/frontend/cards/managers/CronManagerCard.tsx b/plugins/host-metrics/src/frontend/cards/managers/CronManagerCard.tsx similarity index 100% rename from plugins/host-metrics/frontend/cards/managers/CronManagerCard.tsx rename to plugins/host-metrics/src/frontend/cards/managers/CronManagerCard.tsx diff --git a/plugins/host-metrics/frontend/cards/managers/FirewallManagerCard.tsx b/plugins/host-metrics/src/frontend/cards/managers/FirewallManagerCard.tsx similarity index 100% rename from plugins/host-metrics/frontend/cards/managers/FirewallManagerCard.tsx rename to plugins/host-metrics/src/frontend/cards/managers/FirewallManagerCard.tsx diff --git a/plugins/host-metrics/frontend/cards/managers/HealthCheckCard.tsx b/plugins/host-metrics/src/frontend/cards/managers/HealthCheckCard.tsx similarity index 100% rename from plugins/host-metrics/frontend/cards/managers/HealthCheckCard.tsx rename to plugins/host-metrics/src/frontend/cards/managers/HealthCheckCard.tsx diff --git a/plugins/host-metrics/frontend/cards/managers/LogViewerCard.tsx b/plugins/host-metrics/src/frontend/cards/managers/LogViewerCard.tsx similarity index 100% rename from plugins/host-metrics/frontend/cards/managers/LogViewerCard.tsx rename to plugins/host-metrics/src/frontend/cards/managers/LogViewerCard.tsx diff --git a/plugins/host-metrics/frontend/cards/managers/ManagerCardShell.tsx b/plugins/host-metrics/src/frontend/cards/managers/ManagerCardShell.tsx similarity index 100% rename from plugins/host-metrics/frontend/cards/managers/ManagerCardShell.tsx rename to plugins/host-metrics/src/frontend/cards/managers/ManagerCardShell.tsx diff --git a/plugins/host-metrics/frontend/cards/managers/ManagerToolbar.tsx b/plugins/host-metrics/src/frontend/cards/managers/ManagerToolbar.tsx similarity index 100% rename from plugins/host-metrics/frontend/cards/managers/ManagerToolbar.tsx rename to plugins/host-metrics/src/frontend/cards/managers/ManagerToolbar.tsx diff --git a/plugins/host-metrics/frontend/cards/managers/PackageManagerCard.tsx b/plugins/host-metrics/src/frontend/cards/managers/PackageManagerCard.tsx similarity index 100% rename from plugins/host-metrics/frontend/cards/managers/PackageManagerCard.tsx rename to plugins/host-metrics/src/frontend/cards/managers/PackageManagerCard.tsx diff --git a/plugins/host-metrics/frontend/cards/managers/ProcessInspectorCard.tsx b/plugins/host-metrics/src/frontend/cards/managers/ProcessInspectorCard.tsx similarity index 100% rename from plugins/host-metrics/frontend/cards/managers/ProcessInspectorCard.tsx rename to plugins/host-metrics/src/frontend/cards/managers/ProcessInspectorCard.tsx diff --git a/plugins/host-metrics/frontend/cards/managers/ServiceManagerCard.tsx b/plugins/host-metrics/src/frontend/cards/managers/ServiceManagerCard.tsx similarity index 100% rename from plugins/host-metrics/frontend/cards/managers/ServiceManagerCard.tsx rename to plugins/host-metrics/src/frontend/cards/managers/ServiceManagerCard.tsx diff --git a/plugins/host-metrics/frontend/cards/managers/SimpleManagerCards.tsx b/plugins/host-metrics/src/frontend/cards/managers/SimpleManagerCards.tsx similarity index 100% rename from plugins/host-metrics/frontend/cards/managers/SimpleManagerCards.tsx rename to plugins/host-metrics/src/frontend/cards/managers/SimpleManagerCards.tsx diff --git a/plugins/host-metrics/frontend/cards/managers/SslManagerCard.tsx b/plugins/host-metrics/src/frontend/cards/managers/SslManagerCard.tsx similarity index 100% rename from plugins/host-metrics/frontend/cards/managers/SslManagerCard.tsx rename to plugins/host-metrics/src/frontend/cards/managers/SslManagerCard.tsx diff --git a/plugins/host-metrics/frontend/cards/managers/TailscaleManagerCard.tsx b/plugins/host-metrics/src/frontend/cards/managers/TailscaleManagerCard.tsx similarity index 100% rename from plugins/host-metrics/frontend/cards/managers/TailscaleManagerCard.tsx rename to plugins/host-metrics/src/frontend/cards/managers/TailscaleManagerCard.tsx diff --git a/plugins/host-metrics/frontend/cards/managers/UserManagerCard.tsx b/plugins/host-metrics/src/frontend/cards/managers/UserManagerCard.tsx similarity index 100% rename from plugins/host-metrics/frontend/cards/managers/UserManagerCard.tsx rename to plugins/host-metrics/src/frontend/cards/managers/UserManagerCard.tsx diff --git a/plugins/host-metrics/frontend/cards/managers/WireGuardManagerCard.tsx b/plugins/host-metrics/src/frontend/cards/managers/WireGuardManagerCard.tsx similarity index 100% rename from plugins/host-metrics/frontend/cards/managers/WireGuardManagerCard.tsx rename to plugins/host-metrics/src/frontend/cards/managers/WireGuardManagerCard.tsx diff --git a/plugins/host-metrics/frontend/cards/managers/useManagerData.ts b/plugins/host-metrics/src/frontend/cards/managers/useManagerData.ts similarity index 100% rename from plugins/host-metrics/frontend/cards/managers/useManagerData.ts rename to plugins/host-metrics/src/frontend/cards/managers/useManagerData.ts diff --git a/plugins/host-metrics/frontend/hooks/useHostMetricsPreferences.ts b/plugins/host-metrics/src/frontend/hooks/useHostMetricsPreferences.ts similarity index 100% rename from plugins/host-metrics/frontend/hooks/useHostMetricsPreferences.ts rename to plugins/host-metrics/src/frontend/hooks/useHostMetricsPreferences.ts diff --git a/plugins/host-metrics/frontend/index.mjs b/plugins/host-metrics/src/frontend/index.mjs similarity index 100% rename from plugins/host-metrics/frontend/index.mjs rename to plugins/host-metrics/src/frontend/index.mjs diff --git a/plugins/host-metrics/frontend/metrics-change-key.ts b/plugins/host-metrics/src/frontend/metrics-change-key.ts similarity index 100% rename from plugins/host-metrics/frontend/metrics-change-key.ts rename to plugins/host-metrics/src/frontend/metrics-change-key.ts diff --git a/plugins/host-metrics/frontend/temperature-preference.ts b/plugins/host-metrics/src/frontend/temperature-preference.ts similarity index 100% rename from plugins/host-metrics/frontend/temperature-preference.ts rename to plugins/host-metrics/src/frontend/temperature-preference.ts diff --git a/src/backend/tests/plugins/host-metrics/helpers.test.ts b/plugins/host-metrics/tests/backend/helpers.test.ts similarity index 96% rename from src/backend/tests/plugins/host-metrics/helpers.test.ts rename to plugins/host-metrics/tests/backend/helpers.test.ts index ec181d5ad..a8c1dcf87 100644 --- a/src/backend/tests/plugins/host-metrics/helpers.test.ts +++ b/plugins/host-metrics/tests/backend/helpers.test.ts @@ -5,8 +5,8 @@ import { isTcpPingEnabled, parseStatusHostIds, tcpPingThroughJumpHost, -} from "../../../../../plugins/host-metrics/backend/helpers.js"; -import { createConnectionLog } from "../../../hosts/connection-log.js"; +} from "../../src/backend/helpers.js"; +import { createConnectionLog } from "../../../../src/backend/hosts/connection-log.js"; describe("supportsMetrics", () => { it("supports plain ssh hosts", () => { diff --git a/src/backend/tests/hosts/host-metrics-preferences.test.ts b/plugins/host-metrics/tests/backend/host-metrics-preferences.test.ts similarity index 98% rename from src/backend/tests/hosts/host-metrics-preferences.test.ts rename to plugins/host-metrics/tests/backend/host-metrics-preferences.test.ts index 47fbc70ce..c46aff27c 100644 --- a/src/backend/tests/hosts/host-metrics-preferences.test.ts +++ b/plugins/host-metrics/tests/backend/host-metrics-preferences.test.ts @@ -5,7 +5,7 @@ import { isMetricCardId, isManagerCardId, METRIC_CARD_IDS, -} from "../../../types/host-metrics.js"; +} from "../../../../src/types/host-metrics.js"; describe("deriveEnabledWidgets", () => { it("returns only metric ids, in canonical order, deduped", () => { diff --git a/src/backend/tests/plugins/host-metrics/login-alert-dispatch.test.ts b/plugins/host-metrics/tests/backend/login-alert-dispatch.test.ts similarity index 96% rename from src/backend/tests/plugins/host-metrics/login-alert-dispatch.test.ts rename to plugins/host-metrics/tests/backend/login-alert-dispatch.test.ts index e860512e4..b548777ea 100644 --- a/src/backend/tests/plugins/host-metrics/login-alert-dispatch.test.ts +++ b/plugins/host-metrics/tests/backend/login-alert-dispatch.test.ts @@ -5,10 +5,7 @@ import { beforeEach, expect, it, vi } from "vitest"; // Load the production route without starting metrics polling or SSH services. const source = readFileSync( - new URL( - "../../../../../plugins/host-metrics/backend/routes.ts", - import.meta.url, - ), + new URL("../../src/backend/routes.ts", import.meta.url), "utf8", ); const ast = ts.createSourceFile( diff --git a/src/backend/tests/plugins/host-metrics/login-alert-route-order.test.ts b/plugins/host-metrics/tests/backend/login-alert-route-order.test.ts similarity index 90% rename from src/backend/tests/plugins/host-metrics/login-alert-route-order.test.ts rename to plugins/host-metrics/tests/backend/login-alert-route-order.test.ts index 34068bc44..8725e8588 100644 --- a/src/backend/tests/plugins/host-metrics/login-alert-route-order.test.ts +++ b/plugins/host-metrics/tests/backend/login-alert-route-order.test.ts @@ -11,10 +11,7 @@ import path from "path"; describe("metrics service /internal/login-alert route order", () => { it("is registered before the global auth middleware", () => { const source = fs.readFileSync( - path.resolve( - __dirname, - "../../../../../plugins/host-metrics/backend/routes.ts", - ), + path.resolve(__dirname, "../../src/backend/routes.ts"), "utf8", ); diff --git a/src/backend/tests/plugins/host-metrics/managers/managers.test.ts b/plugins/host-metrics/tests/backend/managers/managers.test.ts similarity index 93% rename from src/backend/tests/plugins/host-metrics/managers/managers.test.ts rename to plugins/host-metrics/tests/backend/managers/managers.test.ts index ad108aa1d..6a9d97118 100644 --- a/src/backend/tests/plugins/host-metrics/managers/managers.test.ts +++ b/plugins/host-metrics/tests/backend/managers/managers.test.ts @@ -2,8 +2,8 @@ import { describe, it, expect } from "vitest"; import { buildSudoCommand, shellSingleQuote, -} from "../../../../hosts/metrics-shared/exec-elevated.js"; -import { parsePlatformProbe } from "../../../../hosts/metrics-shared/platform.js"; +} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; +import { parsePlatformProbe } from "../../../../../src/backend/hosts/metrics-shared/platform.js"; import { isValidSystemdUnit, isValidPid, @@ -15,53 +15,53 @@ import { isValidSignal, isValidServiceAction, isAllowedPath, -} from "../../../../hosts/metrics-shared/validation.js"; +} from "../../../../../src/backend/hosts/metrics-shared/validation.js"; import { parseServiceList, buildServiceActionCommand, -} from "../../../../../../plugins/host-metrics/backend/managers/services.js"; +} from "../../../src/backend/managers/services.js"; import { parseProcessList, buildKillCommand, -} from "../../../../../../plugins/host-metrics/backend/managers/processes.js"; +} from "../../../src/backend/managers/processes.js"; import { parseDfMounts, parseTopMemory, -} from "../../../../../../plugins/host-metrics/backend/managers/simple-reads.js"; +} from "../../../src/backend/managers/simple-reads.js"; import { parseCrontab, serializeCrontab, isValidCronSchedule, buildApplyCrontabCommand, -} from "../../../../../../plugins/host-metrics/backend/managers/cron.js"; +} from "../../../src/backend/managers/cron.js"; import { buildPackageActionCommand, parseUpgradable, buildListUpgradableCommand, -} from "../../../../hosts/metrics-shared/package-commands.js"; +} from "../../../../../src/backend/hosts/metrics-shared/package-commands.js"; import { buildIssueCommand, buildRenewCommand, buildRevokeCommand, isValidCertName, parseCertbotCertificates, -} from "../../../../../../plugins/host-metrics/backend/managers/ssl.js"; +} from "../../../src/backend/managers/ssl.js"; import { buildIptablesRuleCommand, buildNftRuleCommand, -} from "../../../../../../plugins/host-metrics/backend/managers/firewall.js"; +} from "../../../src/backend/managers/firewall.js"; import { parsePasswd, parseSudoers, -} from "../../../../../../plugins/host-metrics/backend/managers/users.js"; +} from "../../../src/backend/managers/users.js"; import { buildHealthCheckCommand, parseHealthResult, -} from "../../../../../../plugins/host-metrics/backend/managers/health.js"; +} from "../../../src/backend/managers/health.js"; import { buildTailCommand, clampLines, -} from "../../../../../../plugins/host-metrics/backend/managers/logs.js"; +} from "../../../src/backend/managers/logs.js"; describe("exec-elevated", () => { it("single-quotes and escapes for the shell", () => { diff --git a/src/backend/tests/hosts/metrics-shared/common-utils.test.ts b/plugins/host-metrics/tests/backend/metrics-shared/common-utils.test.ts similarity index 94% rename from src/backend/tests/hosts/metrics-shared/common-utils.test.ts rename to plugins/host-metrics/tests/backend/metrics-shared/common-utils.test.ts index 07747753a..bf05a9ecb 100644 --- a/src/backend/tests/hosts/metrics-shared/common-utils.test.ts +++ b/plugins/host-metrics/tests/backend/metrics-shared/common-utils.test.ts @@ -3,7 +3,7 @@ import { toFixedNum, kibToGiB, encodePowerShellCommand, -} from "../../../hosts/metrics-shared/common-utils.js"; +} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js"; describe("toFixedNum", () => { it("rounds to the requested digit count", () => { diff --git a/src/backend/tests/hosts/metrics-shared/exec-elevated.test.ts b/plugins/host-metrics/tests/backend/metrics-shared/exec-elevated.test.ts similarity index 94% rename from src/backend/tests/hosts/metrics-shared/exec-elevated.test.ts rename to plugins/host-metrics/tests/backend/metrics-shared/exec-elevated.test.ts index ee97a3ae4..b0e1d5c80 100644 --- a/src/backend/tests/hosts/metrics-shared/exec-elevated.test.ts +++ b/plugins/host-metrics/tests/backend/metrics-shared/exec-elevated.test.ts @@ -1,14 +1,17 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const execCommand = vi.fn(); -vi.mock("../../../hosts/metrics-shared/common-utils.js", () => ({ - execCommand: (...args: unknown[]) => execCommand(...args), -})); +vi.mock( + "../../../../../src/backend/hosts/metrics-shared/common-utils.js", + () => ({ + execCommand: (...args: unknown[]) => execCommand(...args), + }), +); import { execElevated, ElevationError, -} from "../../../hosts/metrics-shared/exec-elevated.js"; +} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; import type { Client } from "ssh2"; const fakeClient = {} as Client; diff --git a/src/backend/tests/hosts/metrics-shared/host-status.test.ts b/plugins/host-metrics/tests/backend/metrics-shared/host-status.test.ts similarity index 94% rename from src/backend/tests/hosts/metrics-shared/host-status.test.ts rename to plugins/host-metrics/tests/backend/metrics-shared/host-status.test.ts index 0f04ff099..20658fcbc 100644 --- a/src/backend/tests/hosts/metrics-shared/host-status.test.ts +++ b/plugins/host-metrics/tests/backend/metrics-shared/host-status.test.ts @@ -3,7 +3,7 @@ import { isHostKeyVerificationError, statusAfterAuthentication, statusAfterReachabilityCheck, -} from "../../../hosts/metrics-shared/host-status.js"; +} from "../../../../../src/backend/hosts/metrics-shared/host-status.js"; describe("isHostKeyVerificationError", () => { it.each([ diff --git a/src/backend/tests/hosts/metrics-shared/state.test.ts b/plugins/host-metrics/tests/backend/metrics-shared/state.test.ts similarity index 99% rename from src/backend/tests/hosts/metrics-shared/state.test.ts rename to plugins/host-metrics/tests/backend/metrics-shared/state.test.ts index 6c3be064e..17b92bb0f 100644 --- a/src/backend/tests/hosts/metrics-shared/state.test.ts +++ b/plugins/host-metrics/tests/backend/metrics-shared/state.test.ts @@ -5,7 +5,7 @@ import { ConcurrentLimiter, HostPollCache, metricsConcurrencyFor, -} from "../../../hosts/metrics-shared/state.js"; +} from "../../../../../src/backend/hosts/metrics-shared/state.js"; describe("auth failure tracking", () => { it("permanently pauses polling after a host key mismatch", () => { diff --git a/src/backend/tests/database/repositories/host-metrics-history-repository.test.ts b/plugins/host-metrics/tests/backend/repositories/host-metrics-history-repository.test.ts similarity index 90% rename from src/backend/tests/database/repositories/host-metrics-history-repository.test.ts rename to plugins/host-metrics/tests/backend/repositories/host-metrics-history-repository.test.ts index 3fb64e676..b9441effa 100644 --- a/src/backend/tests/database/repositories/host-metrics-history-repository.test.ts +++ b/plugins/host-metrics/tests/backend/repositories/host-metrics-history-repository.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; -import { TestSqliteDatabase } from "./test-support.js"; -import { HostMetricsHistoryRepository } from "../../../database/repositories/host-metrics-history-repository.js"; +import { TestSqliteDatabase } from "../../../../../src/backend/tests/database/repositories/test-support.js"; +import { HostMetricsHistoryRepository } from "../../../../../src/backend/database/repositories/host-metrics-history-repository.js"; describe("HostMetricsHistoryRepository", () => { let adapter: TestSqliteDatabase | null = null; diff --git a/src/backend/tests/database/repositories/host-metrics-preference-repository.test.ts b/plugins/host-metrics/tests/backend/repositories/host-metrics-preference-repository.test.ts similarity index 93% rename from src/backend/tests/database/repositories/host-metrics-preference-repository.test.ts rename to plugins/host-metrics/tests/backend/repositories/host-metrics-preference-repository.test.ts index e45cb6e08..e8c2b4476 100644 --- a/src/backend/tests/database/repositories/host-metrics-preference-repository.test.ts +++ b/plugins/host-metrics/tests/backend/repositories/host-metrics-preference-repository.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; -import { TestSqliteDatabase } from "./test-support.js"; -import { HostMetricsPreferenceRepository } from "../../../database/repositories/host-metrics-preference-repository.js"; +import { TestSqliteDatabase } from "../../../../../src/backend/tests/database/repositories/test-support.js"; +import { HostMetricsPreferenceRepository } from "../../../../../src/backend/database/repositories/host-metrics-preference-repository.js"; describe("HostMetricsPreferenceRepository", () => { let adapter: TestSqliteDatabase | null = null; diff --git a/src/backend/tests/plugins/host-metrics/status-poll-auth.test.ts b/plugins/host-metrics/tests/backend/status-poll-auth.test.ts similarity index 92% rename from src/backend/tests/plugins/host-metrics/status-poll-auth.test.ts rename to plugins/host-metrics/tests/backend/status-poll-auth.test.ts index 4cd6f63f3..85e5ed667 100644 --- a/src/backend/tests/plugins/host-metrics/status-poll-auth.test.ts +++ b/plugins/host-metrics/tests/backend/status-poll-auth.test.ts @@ -13,10 +13,7 @@ import path from "path"; describe("metrics service status polling", () => { it("never authenticates over SSH from pollHostStatus, on the first poll or any later one", () => { const source = fs.readFileSync( - path.resolve( - __dirname, - "../../../../../plugins/host-metrics/backend/routes.ts", - ), + path.resolve(__dirname, "../../src/backend/routes.ts"), "utf8", ); diff --git a/src/backend/tests/plugins/host-metrics/widgets/cpu-collector.test.ts b/plugins/host-metrics/tests/backend/widgets/cpu-collector.test.ts similarity index 94% rename from src/backend/tests/plugins/host-metrics/widgets/cpu-collector.test.ts rename to plugins/host-metrics/tests/backend/widgets/cpu-collector.test.ts index 7e9ce42ea..f6d16dc6f 100644 --- a/src/backend/tests/plugins/host-metrics/widgets/cpu-collector.test.ts +++ b/plugins/host-metrics/tests/backend/widgets/cpu-collector.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { parseCpuLine, parseDarwinCpuUsageLine, -} from "../../../../../../plugins/host-metrics/backend/widgets/cpu-collector.js"; +} from "../../../src/backend/widgets/cpu-collector.js"; describe("parseCpuLine", () => { it("parses a standard /proc/stat cpu line", () => { diff --git a/src/backend/tests/plugins/host-metrics/widgets/disk-collector.test.ts b/plugins/host-metrics/tests/backend/widgets/disk-collector.test.ts similarity index 99% rename from src/backend/tests/plugins/host-metrics/widgets/disk-collector.test.ts rename to plugins/host-metrics/tests/backend/widgets/disk-collector.test.ts index 8780e0660..c43625e9f 100644 --- a/src/backend/tests/plugins/host-metrics/widgets/disk-collector.test.ts +++ b/plugins/host-metrics/tests/backend/widgets/disk-collector.test.ts @@ -9,7 +9,7 @@ import { parseWindowsDiskJson, parseDarwinMountTypes, parseDarwinDfRows, -} from "../../../../../../plugins/host-metrics/backend/widgets/disk-collector.js"; +} from "../../../src/backend/widgets/disk-collector.js"; describe("parseDfLines", () => { it("parses df -T -P output into rows", () => { diff --git a/src/backend/tests/plugins/host-metrics/widgets/gpu-collector.test.ts b/plugins/host-metrics/tests/backend/widgets/gpu-collector.test.ts similarity index 98% rename from src/backend/tests/plugins/host-metrics/widgets/gpu-collector.test.ts rename to plugins/host-metrics/tests/backend/widgets/gpu-collector.test.ts index 1f7a94faf..72e665793 100644 --- a/src/backend/tests/plugins/host-metrics/widgets/gpu-collector.test.ts +++ b/plugins/host-metrics/tests/backend/widgets/gpu-collector.test.ts @@ -3,7 +3,7 @@ import { parseNvidiaSmiGpus, parseNvidiaSmiOutput, parseNvidiaSmiProcesses, -} from "../../../../../../plugins/host-metrics/backend/widgets/gpu-collector.js"; +} from "../../../src/backend/widgets/gpu-collector.js"; const RTX_UUID = "GPU-3b8f2c1a-5d4e-7f60-8a9b-0c1d2e3f4a5b"; const H100_UUID = "GPU-9a8b7c6d-5e4f-3a2b-1c0d-e9f8a7b6c5d4"; diff --git a/src/backend/tests/plugins/host-metrics/widgets/network-collector.test.ts b/plugins/host-metrics/tests/backend/widgets/network-collector.test.ts similarity index 97% rename from src/backend/tests/plugins/host-metrics/widgets/network-collector.test.ts rename to plugins/host-metrics/tests/backend/widgets/network-collector.test.ts index e84b6f9f9..6699eed22 100644 --- a/src/backend/tests/plugins/host-metrics/widgets/network-collector.test.ts +++ b/plugins/host-metrics/tests/backend/widgets/network-collector.test.ts @@ -5,7 +5,7 @@ import { parseDarwinIfconfig, parseDarwinNetstat, parseWindowsAdapterJson, -} from "../../../../../../plugins/host-metrics/backend/widgets/network-collector.js"; +} from "../../../src/backend/widgets/network-collector.js"; const PROC_NET = `Inter-| Receive | Transmit face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed diff --git a/src/backend/tests/plugins/host-metrics/widgets/temperature-collector.test.ts b/plugins/host-metrics/tests/backend/widgets/temperature-collector.test.ts similarity index 92% rename from src/backend/tests/plugins/host-metrics/widgets/temperature-collector.test.ts rename to plugins/host-metrics/tests/backend/widgets/temperature-collector.test.ts index 9bd2fa963..cf2589ae2 100644 --- a/src/backend/tests/plugins/host-metrics/widgets/temperature-collector.test.ts +++ b/plugins/host-metrics/tests/backend/widgets/temperature-collector.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { parseSensorsOutput, parseSysfsThermalOutput, -} from "../../../../../../plugins/host-metrics/backend/widgets/temperature-collector.js"; +} from "../../../src/backend/widgets/temperature-collector.js"; describe("temperature collectors", () => { it("parses sysfs thermal zone output", () => { diff --git a/src/ui/tests/plugins/host-metrics/HostMetricsTab.test.tsx b/plugins/host-metrics/tests/frontend/HostMetricsTab.test.tsx similarity index 87% rename from src/ui/tests/plugins/host-metrics/HostMetricsTab.test.tsx rename to plugins/host-metrics/tests/frontend/HostMetricsTab.test.tsx index 9d015c2f8..fefc079bd 100644 --- a/src/ui/tests/plugins/host-metrics/HostMetricsTab.test.tsx +++ b/plugins/host-metrics/tests/frontend/HostMetricsTab.test.tsx @@ -55,21 +55,18 @@ vi.mock("@/hooks/use-confirmation.ts", () => ({ useConfirmation: () => ({ confirmWithToast: vi.fn() }), })); -vi.mock( - "../../../../../plugins/host-metrics/frontend/hooks/useHostMetricsPreferences.ts", - () => ({ - useHostMetricsPreferences: () => ({ - layout: { - slots: [{ id: "gpu", order: 0, colSpan: 2, height: null }], - columns: 3, - }, - setLayout: vi.fn(), - loaded: true, - }), +vi.mock("../../src/frontend/hooks/useHostMetricsPreferences.ts", () => ({ + useHostMetricsPreferences: () => ({ + layout: { + slots: [{ id: "gpu", order: 0, colSpan: 2, height: null }], + columns: 3, + }, + setLayout: vi.fn(), + loaded: true, }), -); +})); -import { HostMetricsTab } from "../../../../../plugins/host-metrics/frontend/HostMetricsTab"; +import { HostMetricsTab } from "../../src/frontend/HostMetricsTab"; beforeAll(() => { globalThis.ResizeObserver ??= class { diff --git a/src/ui/tests/plugins/host-metrics/cards/GpuCard.test.tsx b/plugins/host-metrics/tests/frontend/cards/GpuCard.test.tsx similarity index 97% rename from src/ui/tests/plugins/host-metrics/cards/GpuCard.test.tsx rename to plugins/host-metrics/tests/frontend/cards/GpuCard.test.tsx index 3e18881f0..8ba0c3caa 100644 --- a/src/ui/tests/plugins/host-metrics/cards/GpuCard.test.tsx +++ b/plugins/host-metrics/tests/frontend/cards/GpuCard.test.tsx @@ -8,7 +8,7 @@ vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key }), })); -import { GpuCard } from "../../../../../../plugins/host-metrics/frontend/cards/GpuCard"; +import { GpuCard } from "../../../src/frontend/cards/GpuCard"; const GPU_METRICS: GpuMetrics = { source: "nvidia-smi", diff --git a/src/ui/tests/plugins/host-metrics/cards/gpu-history.test.ts b/plugins/host-metrics/tests/frontend/cards/gpu-history.test.ts similarity index 94% rename from src/ui/tests/plugins/host-metrics/cards/gpu-history.test.ts rename to plugins/host-metrics/tests/frontend/cards/gpu-history.test.ts index a197aadca..b17991374 100644 --- a/src/ui/tests/plugins/host-metrics/cards/gpu-history.test.ts +++ b/plugins/host-metrics/tests/frontend/cards/gpu-history.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { GpuDevice } from "@/types/stats-widgets"; -import { appendGpuHistories } from "../../../../../../plugins/host-metrics/frontend/cards/gpu-history"; +import { appendGpuHistories } from "../../../src/frontend/cards/gpu-history"; function gpu( index: number, diff --git a/src/ui/tests/api/host-metrics-status-api.test.ts b/plugins/host-metrics/tests/frontend/host-metrics-status-api.test.ts similarity index 98% rename from src/ui/tests/api/host-metrics-status-api.test.ts rename to plugins/host-metrics/tests/frontend/host-metrics-status-api.test.ts index 9e0ef6fbf..4c407dc0f 100644 --- a/src/ui/tests/api/host-metrics-status-api.test.ts +++ b/plugins/host-metrics/tests/frontend/host-metrics-status-api.test.ts @@ -25,7 +25,7 @@ import { getAllServerStatuses, getServerMetricsById, sendMetricsHeartbeat, -} from "../../api/host-metrics-status-api"; +} from "../../../../src/ui/api/host-metrics-status-api"; beforeEach(() => { vi.clearAllMocks(); diff --git a/src/ui/tests/plugins/host-metrics/metrics-change-key.test.ts b/plugins/host-metrics/tests/frontend/metrics-change-key.test.ts similarity index 94% rename from src/ui/tests/plugins/host-metrics/metrics-change-key.test.ts rename to plugins/host-metrics/tests/frontend/metrics-change-key.test.ts index b264ca912..c17032be3 100644 --- a/src/ui/tests/plugins/host-metrics/metrics-change-key.test.ts +++ b/plugins/host-metrics/tests/frontend/metrics-change-key.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { ServerMetrics } from "@/main-axios"; import type { GpuMetrics } from "@/types/stats-widgets"; -import { metricsChangeKey } from "../../../../../plugins/host-metrics/frontend/metrics-change-key"; +import { metricsChangeKey } from "../../src/frontend/metrics-change-key"; function poll(gpu: GpuMetrics): ServerMetrics { return { diff --git a/src/ui/tests/plugins/host-metrics/temperature-preference.test.ts b/plugins/host-metrics/tests/frontend/temperature-preference.test.ts similarity index 90% rename from src/ui/tests/plugins/host-metrics/temperature-preference.test.ts rename to plugins/host-metrics/tests/frontend/temperature-preference.test.ts index 4f404d9f9..7b0040660 100644 --- a/src/ui/tests/plugins/host-metrics/temperature-preference.test.ts +++ b/plugins/host-metrics/tests/frontend/temperature-preference.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { selectTemperatureSensor, temperaturePreferenceKey, -} from "../../../../../plugins/host-metrics/frontend/temperature-preference"; +} from "../../src/frontend/temperature-preference"; const sensors = [ { label: "k10temp: Tctl", celsius: 55 }, diff --git a/plugins/host-metrics/tsconfig.json b/plugins/host-metrics/tsconfig.json new file mode 100644 index 000000000..1d3e42542 --- /dev/null +++ b/plugins/host-metrics/tsconfig.json @@ -0,0 +1,10 @@ +{ + "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/host-metrics/vitest.config.ts b/plugins/host-metrics/vitest.config.ts new file mode 100644 index 000000000..31ba004fc --- /dev/null +++ b/plugins/host-metrics/vitest.config.ts @@ -0,0 +1,3 @@ +import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset"; + +export default pluginVitestConfig(import.meta.url); diff --git a/plugins/network-topology/CHANGELOG.md b/plugins/network-topology/CHANGELOG.md new file mode 100644 index 000000000..6a1bc3a37 --- /dev/null +++ b/plugins/network-topology/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 1.0.0 + +Bundled with Termix 2.9.0. diff --git a/plugins/network-topology/README.md b/plugins/network-topology/README.md new file mode 100644 index 000000000..0d6b8819a --- /dev/null +++ b/plugins/network-topology/README.md @@ -0,0 +1,22 @@ +# Network Topology + +Visualize hosts and their connections as an interactive graph, saved per user. + +Bundled with Termix and enabled by default. + +## Layout + + manifest.json id, capabilities and what this plugin contributes + src/backend/ activate(ctx) / deactivate(), plus the routes + src/frontend/ the UI it registers + locales/en.json its strings (English only; the rest are translated) + tests/backend/ vitest, node + tests/frontend/ vitest, jsdom + +## Commands + + npm run build bundle into dist/ + npm run test run this plugin's tests + npm run typecheck + +See packages/plugin-sdk/ARCHITECTURE.md for the contract this follows. diff --git a/plugins/network-topology/backend/index.mjs b/plugins/network-topology/backend/index.mjs deleted file mode 100644 index d9aaa8da8..000000000 --- a/plugins/network-topology/backend/index.mjs +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Network Topology - first-party, in-process plugin. - * - * Unlike the other first-party plugins (see src/backend/plugins/first-party.ts), - * this one does not own a transport or touch the shared SSH pool -- its two - * routes are plain per-user JSON CRUD against a single repository. It runs - * in-process anyway to keep its existing /network-topology URL and its - * existing dedicated table (network_topology, keyed by userId) rather than - * moving onto /plugin-api/network-topology/* and the generic key-value - * ctx.storage the worker tier would require, matching how fleets/proxmox/ - * automations were migrated in place rather than reworked around the worker - * ctx boundary. - * - * Like fleets and proxmox, this plugin does not get its own port -- it - * registers its Express router with the always-mounted /network-topology - * dispatcher in src/backend/database/routes/network-topology-dispatch.ts. - * - * routes.ts is what used to be - * src/backend/database/routes/network-topology.ts, moved here and rewritten - * from `export default router` into - * startNetworkTopologyService()/stopNetworkTopologyService(), called from - * activate()/deactivate() below. The network_topology table and its - * repository stay in core (src/backend/database/db/schema.ts, - * src/backend/database/repositories/network-topology-repository.ts) -- this - * plugin system has no owned-table migration mechanism of its own, and the - * table carries an FK into core's users table. - */ - -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -/** - * Loads a module by its path relative to this file's own directory. - * - * The plugin ships beside dist/backend, so in a built server the compiled - * .js for routes.js sits right next to this .mjs. Under vitest/dev nothing - * imports this plugin directly (the loader loads in-process plugins with a - * plain dynamic import, no tsx loader involved), so the only layout that - * needs to resolve here is the built one. - */ -async function loadRelative(relativePath) { - const here = path.dirname(fileURLToPath(import.meta.url)); - const target = path.join(here, `${relativePath}.js`); - return import(new URL(`file://${target.replace(/\\/g, "/")}`).href); -} - -let routesModule = null; - -export async function activate(ctx) { - routesModule = await loadRelative("routes"); - routesModule.startNetworkTopologyService(); - ctx.log.info("Network Topology router registered at /network-topology"); -} - -export async function deactivate() { - if (routesModule) { - routesModule.stopNetworkTopologyService(); - routesModule = null; - } -} diff --git a/plugins/network-topology/locales/en.json b/plugins/network-topology/locales/en.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/plugins/network-topology/locales/en.json @@ -0,0 +1 @@ +{} diff --git a/plugins/network-topology/manifest.json b/plugins/network-topology/manifest.json index 0b1781704..7239a0e6d 100644 --- a/plugins/network-topology/manifest.json +++ b/plugins/network-topology/manifest.json @@ -25,8 +25,6 @@ } ] }, - "backend": "backend/index.mjs", - "frontend": "frontend/index.mjs", "locales": "locales", "platforms": ["linux", "win32", "darwin"] } diff --git a/plugins/network-topology/package.json b/plugins/network-topology/package.json new file mode 100644 index 000000000..07ca37682 --- /dev/null +++ b/plugins/network-topology/package.json @@ -0,0 +1,15 @@ +{ + "name": "@termix-plugin/network-topology", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "termix-plugin build", + "test": "termix-plugin test", + "typecheck": "tsc -p tsconfig.json", + "validate": "termix-plugin validate" + }, + "devDependencies": { + "@termix/plugin-sdk": "*" + } +} diff --git a/plugins/network-topology/src/backend/index.ts b/plugins/network-topology/src/backend/index.ts new file mode 100644 index 000000000..4d1fa907d --- /dev/null +++ b/plugins/network-topology/src/backend/index.ts @@ -0,0 +1,14 @@ +import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { + startNetworkTopologyService, + stopNetworkTopologyService, +} from "./routes.js"; + +export async function activate(ctx: PluginContext) { + startNetworkTopologyService(); + ctx.log.info("Network Topology router registered at /network-topology"); +} + +export async function deactivate() { + stopNetworkTopologyService(); +} diff --git a/plugins/network-topology/backend/routes.ts b/plugins/network-topology/src/backend/routes.ts similarity index 94% rename from plugins/network-topology/backend/routes.ts rename to plugins/network-topology/src/backend/routes.ts index e4ddbb94a..4399ea75f 100644 --- a/plugins/network-topology/backend/routes.ts +++ b/plugins/network-topology/src/backend/routes.ts @@ -1,12 +1,12 @@ import express from "express"; -import { AuthManager } from "../../../src/backend/utils/auth-manager.js"; -import type { AuthenticatedRequest } from "../../../src/types/index.js"; -import { databaseLogger } from "../../../src/backend/utils/logger.js"; -import { createCurrentNetworkTopologyRepository } from "../../../src/backend/database/repositories/factory.js"; +import { AuthManager } from "../../../../src/backend/utils/auth-manager.js"; +import type { AuthenticatedRequest } from "../../../../src/types/index.js"; +import { databaseLogger } from "../../../../src/backend/utils/logger.js"; +import { createCurrentNetworkTopologyRepository } from "../../../../src/backend/database/repositories/factory.js"; import { registerNetworkTopologyRouter, unregisterNetworkTopologyRouter, -} from "../../../src/backend/database/routes/network-topology-dispatch.js"; +} from "../../../../src/backend/database/routes/network-topology-dispatch.js"; export const router = express.Router(); diff --git a/plugins/network-topology/frontend/NetworkGraphCard.tsx b/plugins/network-topology/src/frontend/NetworkGraphCard.tsx similarity index 100% rename from plugins/network-topology/frontend/NetworkGraphCard.tsx rename to plugins/network-topology/src/frontend/NetworkGraphCard.tsx diff --git a/plugins/network-topology/frontend/index.mjs b/plugins/network-topology/src/frontend/index.mjs similarity index 100% rename from plugins/network-topology/frontend/index.mjs rename to plugins/network-topology/src/frontend/index.mjs diff --git a/src/backend/tests/database/repositories/network-topology-repository.test.ts b/plugins/network-topology/tests/backend/repositories/network-topology-repository.test.ts similarity index 90% rename from src/backend/tests/database/repositories/network-topology-repository.test.ts rename to plugins/network-topology/tests/backend/repositories/network-topology-repository.test.ts index 985336ee4..1a605fe2c 100644 --- a/src/backend/tests/database/repositories/network-topology-repository.test.ts +++ b/plugins/network-topology/tests/backend/repositories/network-topology-repository.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; -import { TestSqliteDatabase } from "./test-support.js"; -import { NetworkTopologyRepository } from "../../../database/repositories/network-topology-repository.js"; +import { TestSqliteDatabase } from "../../../../../src/backend/tests/database/repositories/test-support.js"; +import { NetworkTopologyRepository } from "../../../../../src/backend/database/repositories/network-topology-repository.js"; describe("NetworkTopologyRepository", () => { let adapter: TestSqliteDatabase | null = null; diff --git a/src/backend/tests/plugins/network-topology/routes.test.ts b/plugins/network-topology/tests/backend/routes.test.ts similarity index 87% rename from src/backend/tests/plugins/network-topology/routes.test.ts rename to plugins/network-topology/tests/backend/routes.test.ts index 1c390354f..0c3ae6b86 100644 --- a/src/backend/tests/plugins/network-topology/routes.test.ts +++ b/plugins/network-topology/tests/backend/routes.test.ts @@ -6,9 +6,9 @@ const state = vi.hoisted(() => ({ records: new Map(), })); -vi.mock("../../../database/db/index.js", () => ({ db: {} })); +vi.mock("../../../../src/backend/database/db/index.js", () => ({ db: {} })); -vi.mock("../../../utils/logger.js", () => ({ +vi.mock("../../../../src/backend/utils/logger.js", () => ({ databaseLogger: { error: vi.fn(), warn: vi.fn(), @@ -17,7 +17,7 @@ vi.mock("../../../utils/logger.js", () => ({ }, })); -vi.mock("../../../utils/auth-manager.js", () => ({ +vi.mock("../../../../src/backend/utils/auth-manager.js", () => ({ AuthManager: { getInstance: () => ({ createAuthMiddleware: @@ -30,7 +30,7 @@ vi.mock("../../../utils/auth-manager.js", () => ({ }, })); -vi.mock("../../../database/repositories/factory.js", () => ({ +vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({ createCurrentNetworkTopologyRepository: () => ({ findByUserId: async (userId: string) => state.records.get(userId) ?? null, upsertForUser: async (userId: string, topology: string) => { @@ -39,13 +39,15 @@ vi.mock("../../../database/repositories/factory.js", () => ({ }), })); -vi.mock("../../../database/routes/network-topology-dispatch.js", () => ({ - registerNetworkTopologyRouter: vi.fn(), - unregisterNetworkTopologyRouter: vi.fn(), -})); +vi.mock( + "../../../../src/backend/database/routes/network-topology-dispatch.js", + () => ({ + registerNetworkTopologyRouter: vi.fn(), + unregisterNetworkTopologyRouter: vi.fn(), + }), +); -const { router } = - await import("../../../../../plugins/network-topology/backend/routes.js"); +const { router } = await import("../../src/backend/routes.js"); function findLayer(method: string, path: string) { const stack = (router as unknown as Router).stack as Array<{ diff --git a/plugins/network-topology/tsconfig.json b/plugins/network-topology/tsconfig.json new file mode 100644 index 000000000..1d3e42542 --- /dev/null +++ b/plugins/network-topology/tsconfig.json @@ -0,0 +1,10 @@ +{ + "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/network-topology/vitest.config.ts b/plugins/network-topology/vitest.config.ts new file mode 100644 index 000000000..31ba004fc --- /dev/null +++ b/plugins/network-topology/vitest.config.ts @@ -0,0 +1,3 @@ +import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset"; + +export default pluginVitestConfig(import.meta.url); diff --git a/plugins/proxmox/CHANGELOG.md b/plugins/proxmox/CHANGELOG.md new file mode 100644 index 000000000..6a1bc3a37 --- /dev/null +++ b/plugins/proxmox/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 1.0.0 + +Bundled with Termix 2.9.0. diff --git a/plugins/proxmox/README.md b/plugins/proxmox/README.md new file mode 100644 index 000000000..466f90322 --- /dev/null +++ b/plugins/proxmox/README.md @@ -0,0 +1,22 @@ +# Proxmox + +Discover Proxmox VE guests over an existing SSH host, import them as Termix hosts, and keep them in sync. + +Bundled with Termix and enabled by default. + +## Layout + + manifest.json id, capabilities and what this plugin contributes + src/backend/ activate(ctx) / deactivate(), plus the routes + src/frontend/ the UI it registers + locales/en.json its strings (English only; the rest are translated) + tests/backend/ vitest, node + tests/frontend/ vitest, jsdom + +## Commands + + npm run build bundle into dist/ + npm run test run this plugin's tests + npm run typecheck + +See packages/plugin-sdk/ARCHITECTURE.md for the contract this follows. diff --git a/plugins/proxmox/backend/index.mjs b/plugins/proxmox/backend/index.mjs deleted file mode 100644 index 2a6f5eab1..000000000 --- a/plugins/proxmox/backend/index.mjs +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Proxmox discovery/import/sync - first-party, in-process plugin. - * - * This plugin runs on the main thread rather than in a worker for the same - * reason ai does (see src/backend/plugins/first-party.ts): it writes - * directly across hosts through the host repository, resolves and connects - * to hosts over the shared SSH pool via resolveHostById and - * createJumpHostChain, verifies host keys via SSHHostKeyVerifier, and - * streams its discovery progress as server-sent events. None of that fits - * the worker ctx -- ctx.hosts is a 13-field read-only view with no write - * methods, and ctx.http.route replies with a single postMessage value - * rather than a stream. - * - * Like ai, this plugin does not own a live transport (no WebSocket server, - * no dedicated port), so it registers its Express router with the - * always-mounted /proxmox dispatcher in - * src/backend/database/routes/proxmox-dispatch.ts instead of getting its - * own port. - * - * routes.ts is what used to be src/backend/database/routes/proxmox.ts, - * moved here with its sibling modules (proxmox-import-auth.ts, - * proxmox-jump-hosts.ts) and rewritten from `export default router` into - * startProxmoxService()/stopProxmoxService(), called from - * activate()/deactivate() below. Those two functions also start and stop - * the background auto-sync interval that used to run at module scope in - * proxmox.ts -- keeping it there would mean it fires even when this plugin - * is disabled and can never be torn down. - */ - -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -/** - * Loads a module by its path relative to this file's own directory. - * - * The plugin ships beside dist/backend, so in a built server the compiled - * .js for the moved Proxmox files (routes.js, ...) sits right next to this - * .mjs, and core modules resolve via ../../../backend/backend/... from - * there (dist/plugins/proxmox/backend -> dist/backend/backend). Under - * vitest/dev nothing imports this plugin directly (the loader loads - * in-process plugins with a plain dynamic import, no tsx loader involved), - * so the only layout that needs to resolve here is the built one. - */ -async function loadRelative(relativePath) { - const here = path.dirname(fileURLToPath(import.meta.url)); - const target = path.join(here, `${relativePath}.js`); - return import(new URL(`file://${target.replace(/\\/g, "/")}`).href); -} - -let routesModule = null; - -export async function activate(ctx) { - routesModule = await loadRelative("routes"); - routesModule.startProxmoxService(); - ctx.log.info("Proxmox router registered at /proxmox"); -} - -export async function deactivate() { - if (routesModule) { - routesModule.stopProxmoxService(); - routesModule = null; - } -} diff --git a/plugins/proxmox/locales/en.json b/plugins/proxmox/locales/en.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/plugins/proxmox/locales/en.json @@ -0,0 +1 @@ +{} diff --git a/plugins/proxmox/manifest.json b/plugins/proxmox/manifest.json index 4b9ab0b06..44db166cb 100644 --- a/plugins/proxmox/manifest.json +++ b/plugins/proxmox/manifest.json @@ -29,8 +29,6 @@ "editorTab": "proxmox" } }, - "backend": "backend/index.mjs", - "frontend": "frontend/index.mjs", "locales": "locales", "platforms": ["linux", "win32", "darwin"] } diff --git a/plugins/proxmox/package.json b/plugins/proxmox/package.json new file mode 100644 index 000000000..c07e923d8 --- /dev/null +++ b/plugins/proxmox/package.json @@ -0,0 +1,15 @@ +{ + "name": "@termix-plugin/proxmox", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "termix-plugin build", + "test": "termix-plugin test", + "typecheck": "tsc -p tsconfig.json", + "validate": "termix-plugin validate" + }, + "devDependencies": { + "@termix/plugin-sdk": "*" + } +} diff --git a/plugins/proxmox/src/backend/index.ts b/plugins/proxmox/src/backend/index.ts new file mode 100644 index 000000000..9b735d1c8 --- /dev/null +++ b/plugins/proxmox/src/backend/index.ts @@ -0,0 +1,11 @@ +import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { startProxmoxService, stopProxmoxService } from "./routes.js"; + +export async function activate(ctx: PluginContext) { + startProxmoxService(); + ctx.log.info("Proxmox router registered at /proxmox"); +} + +export async function deactivate() { + stopProxmoxService(); +} diff --git a/plugins/proxmox/backend/proxmox-import-auth.ts b/plugins/proxmox/src/backend/proxmox-import-auth.ts similarity index 100% rename from plugins/proxmox/backend/proxmox-import-auth.ts rename to plugins/proxmox/src/backend/proxmox-import-auth.ts diff --git a/plugins/proxmox/backend/proxmox-jump-hosts.ts b/plugins/proxmox/src/backend/proxmox-jump-hosts.ts similarity index 100% rename from plugins/proxmox/backend/proxmox-jump-hosts.ts rename to plugins/proxmox/src/backend/proxmox-jump-hosts.ts diff --git a/plugins/proxmox/backend/routes.ts b/plugins/proxmox/src/backend/routes.ts similarity index 97% rename from plugins/proxmox/backend/routes.ts rename to plugins/proxmox/src/backend/routes.ts index 0eaca6cdb..56538e127 100644 --- a/plugins/proxmox/backend/routes.ts +++ b/plugins/proxmox/src/backend/routes.ts @@ -1,28 +1,28 @@ -import { getErrorMessage } from "../../../src/backend/utils/error-message.js"; +import { getErrorMessage } from "../../../../src/backend/utils/error-message.js"; import express from "express"; import { Client as SSHClient } from "ssh2"; -import { logger } from "../../../src/backend/utils/logger.js"; -import { DataCrypto } from "../../../src/backend/utils/data-crypto.js"; -import { createCurrentHostRepository } from "../../../src/backend/database/repositories/factory.js"; -import { AuthManager } from "../../../src/backend/utils/auth-manager.js"; +import { logger } from "../../../../src/backend/utils/logger.js"; +import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js"; +import { createCurrentHostRepository } from "../../../../src/backend/database/repositories/factory.js"; +import { AuthManager } from "../../../../src/backend/utils/auth-manager.js"; import { type AuthenticatedRequest, type SSHHost, -} from "../../../src/types/index.js"; -import { SSHHostKeyVerifier } from "../../../src/backend/hosts/host-key-verifier.js"; -import { resolveHostById } from "../../../src/backend/hosts/host-resolver.js"; -import { createJumpHostChain } from "../../../src/backend/hosts/jump-host-chain.js"; +} from "../../../../src/types/index.js"; +import { SSHHostKeyVerifier } from "../../../../src/backend/hosts/host-key-verifier.js"; +import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js"; +import { createJumpHostChain } from "../../../../src/backend/hosts/jump-host-chain.js"; import { resolveProxmoxImportAuth } from "./proxmox-import-auth.js"; import { parseProxmoxJumpHosts, serializeProxmoxJumpHosts, } from "./proxmox-jump-hosts.js"; -import { isSafeNodeName } from "../../../src/backend/hosts/proxmox-shared.js"; -import { execElevated } from "../../../src/backend/hosts/metrics-shared/exec-elevated.js"; +import { isSafeNodeName } from "../../../../src/backend/hosts/proxmox-shared.js"; +import { execElevated } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; import { registerProxmoxRouter, unregisterProxmoxRouter, -} from "../../../src/backend/database/routes/proxmox-dispatch.js"; +} from "../../../../src/backend/database/routes/proxmox-dispatch.js"; const router = express.Router(); const proxmoxLogger = logger; @@ -350,7 +350,7 @@ async function discoverProxmoxGuestsForHost( sshConfig.passphrase = resolvedCredentials.keyPassword; } else if (authType === "agent") { const { applyAgentAuth } = - await import("../../../src/backend/hosts/terminal-auth-helpers.js"); + await import("../../../../src/backend/hosts/terminal-auth-helpers.js"); const result = await applyAgentAuth( sshConfig, host.terminalConfig as unknown as Record | undefined, diff --git a/plugins/proxmox/frontend/ProxmoxDiscoverDialog.tsx b/plugins/proxmox/src/frontend/ProxmoxDiscoverDialog.tsx similarity index 100% rename from plugins/proxmox/frontend/ProxmoxDiscoverDialog.tsx rename to plugins/proxmox/src/frontend/ProxmoxDiscoverDialog.tsx diff --git a/plugins/proxmox/frontend/index.mjs b/plugins/proxmox/src/frontend/index.mjs similarity index 95% rename from plugins/proxmox/frontend/index.mjs rename to plugins/proxmox/src/frontend/index.mjs index d04b5a248..96fb45be4 100644 --- a/plugins/proxmox/frontend/index.mjs +++ b/plugins/proxmox/src/frontend/index.mjs @@ -29,7 +29,7 @@ export const hostEditorTabId = "proxmox"; export async function register({ registerHostEditorTab, icons }) { if (registerHostEditorTab) { const { HostProxmoxTab } = - await import("../../../src/ui/sidebar/HostEditorFeatureTabs.tsx"); + await import("../../../../src/ui/sidebar/HostEditorFeatureTabs.tsx"); registerHostEditorTab({ id: hostEditorTabId, labelKey: "hosts.tabProxmox", diff --git a/plugins/proxmox/frontend/proxmox-import-auth.ts b/plugins/proxmox/src/frontend/proxmox-import-auth.ts similarity index 100% rename from plugins/proxmox/frontend/proxmox-import-auth.ts rename to plugins/proxmox/src/frontend/proxmox-import-auth.ts diff --git a/src/backend/tests/plugins/proxmox/proxmox-import-auth.test.ts b/plugins/proxmox/tests/backend/proxmox-import-auth.test.ts similarity index 94% rename from src/backend/tests/plugins/proxmox/proxmox-import-auth.test.ts rename to plugins/proxmox/tests/backend/proxmox-import-auth.test.ts index bb61fc345..70bf71b93 100644 --- a/src/backend/tests/plugins/proxmox/proxmox-import-auth.test.ts +++ b/plugins/proxmox/tests/backend/proxmox-import-auth.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { resolveProxmoxImportAuth } from "../../../../../plugins/proxmox/backend/proxmox-import-auth.js"; +import { resolveProxmoxImportAuth } from "../../src/backend/proxmox-import-auth.js"; // The frontend carries its own copy of this decision in // plugins/proxmox/frontend/proxmox-import-auth.ts. The two drifting apart is diff --git a/src/backend/tests/plugins/proxmox/proxmox-jump-hosts.test.ts b/plugins/proxmox/tests/backend/proxmox-jump-hosts.test.ts similarity index 91% rename from src/backend/tests/plugins/proxmox/proxmox-jump-hosts.test.ts rename to plugins/proxmox/tests/backend/proxmox-jump-hosts.test.ts index 7322a3ad7..083a4ef25 100644 --- a/src/backend/tests/plugins/proxmox/proxmox-jump-hosts.test.ts +++ b/plugins/proxmox/tests/backend/proxmox-jump-hosts.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { parseProxmoxJumpHosts, serializeProxmoxJumpHosts, -} from "../../../../../plugins/proxmox/backend/proxmox-jump-hosts.js"; +} from "../../src/backend/proxmox-jump-hosts.js"; describe("Proxmox jump-host persistence", () => { const jumpHosts = [{ hostId: 7 }, { hostId: 9 }]; diff --git a/src/backend/tests/database/db/proxmox-stats-columns-migration.test.ts b/plugins/proxmox/tests/backend/proxmox-stats-columns-migration.test.ts similarity index 90% rename from src/backend/tests/database/db/proxmox-stats-columns-migration.test.ts rename to plugins/proxmox/tests/backend/proxmox-stats-columns-migration.test.ts index efdd1fcd9..71358d6fd 100644 --- a/src/backend/tests/database/db/proxmox-stats-columns-migration.test.ts +++ b/plugins/proxmox/tests/backend/proxmox-stats-columns-migration.test.ts @@ -65,13 +65,17 @@ describe("proxmox stats columns migration", () => { it("adds enable_proxmox_stats (default 0) and proxmox_stats_config (nullable) columns", async () => { writePreUpgradeDatabase(); - const db = await import("../../../database/db/index.js"); + const db = await import("../../../../src/backend/database/db/index.js"); await db.initializeDatabase(); const sqlite = db.getSqlite(); const columns = sqlite .prepare("PRAGMA table_info(ssh_data)") - .all() as Array<{ name: string; notnull: number; dflt_value: string | null }>; + .all() as Array<{ + name: string; + notnull: number; + dflt_value: string | null; + }>; const enableCol = columns.find((c) => c.name === "enable_proxmox_stats"); expect(enableCol).toBeDefined(); @@ -85,7 +89,10 @@ describe("proxmox stats columns migration", () => { .prepare( "SELECT enable_proxmox_stats, proxmox_stats_config FROM ssh_data WHERE id = 1", ) - .get() as { enable_proxmox_stats: number; proxmox_stats_config: string | null }; + .get() as { + enable_proxmox_stats: number; + proxmox_stats_config: string | null; + }; // Pre-existing rows default to disabled, independent of enable_proxmox. expect(row.enable_proxmox_stats).toBe(0); @@ -95,7 +102,7 @@ describe("proxmox stats columns migration", () => { it("creates the proxmox_node_history and proxmox_stats_preferences tables", async () => { writePreUpgradeDatabase(); - const db = await import("../../../database/db/index.js"); + const db = await import("../../../../src/backend/database/db/index.js"); await db.initializeDatabase(); const sqlite = db.getSqlite(); diff --git a/src/backend/tests/hosts/metrics/proxmox-stats-polling.test.ts b/plugins/proxmox/tests/backend/proxmox-stats-polling.test.ts similarity index 93% rename from src/backend/tests/hosts/metrics/proxmox-stats-polling.test.ts rename to plugins/proxmox/tests/backend/proxmox-stats-polling.test.ts index 3dc9a3d7d..3a5a35e69 100644 --- a/src/backend/tests/hosts/metrics/proxmox-stats-polling.test.ts +++ b/plugins/proxmox/tests/backend/proxmox-stats-polling.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; const historyCreate = vi.fn(); const historyPrune = vi.fn(); -vi.mock("../../../database/repositories/factory.js", () => ({ +vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({ getCurrentSettingValue: () => null, createCurrentProxmoxNodeHistoryRepository: () => ({ create: historyCreate, @@ -11,14 +11,17 @@ vi.mock("../../../database/repositories/factory.js", () => ({ })); const collectProxmoxStats = vi.fn(); -vi.mock("../../../hosts/metrics/proxmox/collect-proxmox-stats.js", () => ({ - collectProxmoxStats: (...args: unknown[]) => collectProxmoxStats(...args), -})); +vi.mock( + "../../../../src/backend/hosts/metrics/proxmox/collect-proxmox-stats.js", + () => ({ + collectProxmoxStats: (...args: unknown[]) => collectProxmoxStats(...args), + }), +); import { ProxmoxPollingManager, parseProxmoxStatsConfig, -} from "../../../hosts/metrics/proxmox-stats-polling.js"; +} from "../../../../src/backend/hosts/metrics/proxmox-stats-polling.js"; import type { Client } from "ssh2"; interface TestHost { diff --git a/src/backend/tests/plugins/proxmox/proxmox-sudo.test.ts b/plugins/proxmox/tests/backend/proxmox-sudo.test.ts similarity index 82% rename from src/backend/tests/plugins/proxmox/proxmox-sudo.test.ts rename to plugins/proxmox/tests/backend/proxmox-sudo.test.ts index f3309ec0a..5a4d44694 100644 --- a/src/backend/tests/plugins/proxmox/proxmox-sudo.test.ts +++ b/plugins/proxmox/tests/backend/proxmox-sudo.test.ts @@ -2,12 +2,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Client } from "ssh2"; const execElevated = vi.fn(); -vi.mock("../../../hosts/metrics-shared/exec-elevated.js", () => ({ - execElevated: (...args: unknown[]) => execElevated(...args), -})); +vi.mock( + "../../../../src/backend/hosts/metrics-shared/exec-elevated.js", + () => ({ + execElevated: (...args: unknown[]) => execElevated(...args), + }), +); -const { execPveshCommand } = - await import("../../../../../plugins/proxmox/backend/routes.js"); +const { execPveshCommand } = await import("../../src/backend/routes.js"); const client = {} as Client; diff --git a/src/backend/tests/hosts/metrics/proxmox/cluster-health-collector.test.ts b/plugins/proxmox/tests/backend/proxmox/cluster-health-collector.test.ts similarity index 87% rename from src/backend/tests/hosts/metrics/proxmox/cluster-health-collector.test.ts rename to plugins/proxmox/tests/backend/proxmox/cluster-health-collector.test.ts index 117a2eeaa..8a20f98af 100644 --- a/src/backend/tests/hosts/metrics/proxmox/cluster-health-collector.test.ts +++ b/plugins/proxmox/tests/backend/proxmox/cluster-health-collector.test.ts @@ -1,11 +1,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const execCommand = vi.fn(); -vi.mock("../../../../hosts/metrics-shared/common-utils.js", () => ({ - execCommand: (...args: unknown[]) => execCommand(...args), -})); +vi.mock( + "../../../../../src/backend/hosts/metrics-shared/common-utils.js", + () => ({ + execCommand: (...args: unknown[]) => execCommand(...args), + }), +); -import { collectProxmoxClusterHealth } from "../../../../hosts/metrics/proxmox/cluster-health-collector.js"; +import { collectProxmoxClusterHealth } from "../../../../../src/backend/hosts/metrics/proxmox/cluster-health-collector.js"; import type { Client } from "ssh2"; const fakeClient = {} as Client; diff --git a/src/backend/tests/hosts/metrics/proxmox/collect-proxmox-stats.test.ts b/plugins/proxmox/tests/backend/proxmox/collect-proxmox-stats.test.ts similarity index 83% rename from src/backend/tests/hosts/metrics/proxmox/collect-proxmox-stats.test.ts rename to plugins/proxmox/tests/backend/proxmox/collect-proxmox-stats.test.ts index 731a5ffcf..d4a504e4b 100644 --- a/src/backend/tests/hosts/metrics/proxmox/collect-proxmox-stats.test.ts +++ b/plugins/proxmox/tests/backend/proxmox/collect-proxmox-stats.test.ts @@ -1,15 +1,18 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const execCommand = vi.fn(); -vi.mock("../../../../hosts/metrics-shared/common-utils.js", () => ({ - execCommand: (...args: unknown[]) => execCommand(...args), - toFixedNum: (n: number | null | undefined, digits = 2) => { - if (typeof n !== "number" || !Number.isFinite(n)) return null; - return Number(n.toFixed(digits)); - }, -})); +vi.mock( + "../../../../../src/backend/hosts/metrics-shared/common-utils.js", + () => ({ + execCommand: (...args: unknown[]) => execCommand(...args), + toFixedNum: (n: number | null | undefined, digits = 2) => { + if (typeof n !== "number" || !Number.isFinite(n)) return null; + return Number(n.toFixed(digits)); + }, + }), +); -import { collectProxmoxStats } from "../../../../hosts/metrics/proxmox/collect-proxmox-stats.js"; +import { collectProxmoxStats } from "../../../../../src/backend/hosts/metrics/proxmox/collect-proxmox-stats.js"; import type { Client } from "ssh2"; const fakeClient = {} as Client; diff --git a/src/backend/tests/hosts/metrics/proxmox/guests-collector.test.ts b/plugins/proxmox/tests/backend/proxmox/guests-collector.test.ts similarity index 89% rename from src/backend/tests/hosts/metrics/proxmox/guests-collector.test.ts rename to plugins/proxmox/tests/backend/proxmox/guests-collector.test.ts index 3fb62d94c..4bd5a7863 100644 --- a/src/backend/tests/hosts/metrics/proxmox/guests-collector.test.ts +++ b/plugins/proxmox/tests/backend/proxmox/guests-collector.test.ts @@ -1,15 +1,18 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const execCommand = vi.fn(); -vi.mock("../../../../hosts/metrics-shared/common-utils.js", () => ({ - execCommand: (...args: unknown[]) => execCommand(...args), - toFixedNum: (n: number | null | undefined, digits = 2) => { - if (typeof n !== "number" || !Number.isFinite(n)) return null; - return Number(n.toFixed(digits)); - }, -})); +vi.mock( + "../../../../../src/backend/hosts/metrics-shared/common-utils.js", + () => ({ + execCommand: (...args: unknown[]) => execCommand(...args), + toFixedNum: (n: number | null | undefined, digits = 2) => { + if (typeof n !== "number" || !Number.isFinite(n)) return null; + return Number(n.toFixed(digits)); + }, + }), +); -import { collectProxmoxGuestsSummary } from "../../../../hosts/metrics/proxmox/guests-collector.js"; +import { collectProxmoxGuestsSummary } from "../../../../../src/backend/hosts/metrics/proxmox/guests-collector.js"; import type { Client } from "ssh2"; const fakeClient = {} as Client; diff --git a/src/backend/tests/hosts/metrics/proxmox/node-network-collector.test.ts b/plugins/proxmox/tests/backend/proxmox/node-network-collector.test.ts similarity index 87% rename from src/backend/tests/hosts/metrics/proxmox/node-network-collector.test.ts rename to plugins/proxmox/tests/backend/proxmox/node-network-collector.test.ts index 6884d0329..0cb76ec2b 100644 --- a/src/backend/tests/hosts/metrics/proxmox/node-network-collector.test.ts +++ b/plugins/proxmox/tests/backend/proxmox/node-network-collector.test.ts @@ -1,11 +1,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const execCommand = vi.fn(); -vi.mock("../../../../hosts/metrics-shared/common-utils.js", () => ({ - execCommand: (...args: unknown[]) => execCommand(...args), -})); +vi.mock( + "../../../../../src/backend/hosts/metrics-shared/common-utils.js", + () => ({ + execCommand: (...args: unknown[]) => execCommand(...args), + }), +); -import { collectProxmoxNodeNetwork } from "../../../../hosts/metrics/proxmox/node-network-collector.js"; +import { collectProxmoxNodeNetwork } from "../../../../../src/backend/hosts/metrics/proxmox/node-network-collector.js"; import type { Client } from "ssh2"; const fakeClient = {} as Client; diff --git a/src/backend/tests/hosts/metrics/proxmox/node-status-collector.test.ts b/plugins/proxmox/tests/backend/proxmox/node-status-collector.test.ts similarity index 84% rename from src/backend/tests/hosts/metrics/proxmox/node-status-collector.test.ts rename to plugins/proxmox/tests/backend/proxmox/node-status-collector.test.ts index 3909f673b..e10fed76a 100644 --- a/src/backend/tests/hosts/metrics/proxmox/node-status-collector.test.ts +++ b/plugins/proxmox/tests/backend/proxmox/node-status-collector.test.ts @@ -1,15 +1,18 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const execCommand = vi.fn(); -vi.mock("../../../../hosts/metrics-shared/common-utils.js", () => ({ - execCommand: (...args: unknown[]) => execCommand(...args), - toFixedNum: (n: number | null | undefined, digits = 2) => { - if (typeof n !== "number" || !Number.isFinite(n)) return null; - return Number(n.toFixed(digits)); - }, -})); +vi.mock( + "../../../../../src/backend/hosts/metrics-shared/common-utils.js", + () => ({ + execCommand: (...args: unknown[]) => execCommand(...args), + toFixedNum: (n: number | null | undefined, digits = 2) => { + if (typeof n !== "number" || !Number.isFinite(n)) return null; + return Number(n.toFixed(digits)); + }, + }), +); -import { collectProxmoxNodeStatus } from "../../../../hosts/metrics/proxmox/node-status-collector.js"; +import { collectProxmoxNodeStatus } from "../../../../../src/backend/hosts/metrics/proxmox/node-status-collector.js"; import type { Client } from "ssh2"; const fakeClient = {} as Client; diff --git a/src/backend/tests/hosts/metrics/proxmox/storage-collector.test.ts b/plugins/proxmox/tests/backend/proxmox/storage-collector.test.ts similarity index 81% rename from src/backend/tests/hosts/metrics/proxmox/storage-collector.test.ts rename to plugins/proxmox/tests/backend/proxmox/storage-collector.test.ts index c721f7e1a..99da77767 100644 --- a/src/backend/tests/hosts/metrics/proxmox/storage-collector.test.ts +++ b/plugins/proxmox/tests/backend/proxmox/storage-collector.test.ts @@ -1,15 +1,18 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const execCommand = vi.fn(); -vi.mock("../../../../hosts/metrics-shared/common-utils.js", () => ({ - execCommand: (...args: unknown[]) => execCommand(...args), - toFixedNum: (n: number | null | undefined, digits = 2) => { - if (typeof n !== "number" || !Number.isFinite(n)) return null; - return Number(n.toFixed(digits)); - }, -})); +vi.mock( + "../../../../../src/backend/hosts/metrics-shared/common-utils.js", + () => ({ + execCommand: (...args: unknown[]) => execCommand(...args), + toFixedNum: (n: number | null | undefined, digits = 2) => { + if (typeof n !== "number" || !Number.isFinite(n)) return null; + return Number(n.toFixed(digits)); + }, + }), +); -import { collectProxmoxStorage } from "../../../../hosts/metrics/proxmox/storage-collector.js"; +import { collectProxmoxStorage } from "../../../../../src/backend/hosts/metrics/proxmox/storage-collector.js"; import type { Client } from "ssh2"; const fakeClient = {} as Client; diff --git a/src/backend/tests/database/repositories/proxmox-node-history-repository.test.ts b/plugins/proxmox/tests/backend/repositories/proxmox-node-history-repository.test.ts similarity index 90% rename from src/backend/tests/database/repositories/proxmox-node-history-repository.test.ts rename to plugins/proxmox/tests/backend/repositories/proxmox-node-history-repository.test.ts index 6e67701b5..db298c592 100644 --- a/src/backend/tests/database/repositories/proxmox-node-history-repository.test.ts +++ b/plugins/proxmox/tests/backend/repositories/proxmox-node-history-repository.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; -import { TestSqliteDatabase } from "./test-support.js"; -import { ProxmoxNodeHistoryRepository } from "../../../database/repositories/proxmox-node-history-repository.js"; +import { TestSqliteDatabase } from "../../../../../src/backend/tests/database/repositories/test-support.js"; +import { ProxmoxNodeHistoryRepository } from "../../../../../src/backend/database/repositories/proxmox-node-history-repository.js"; describe("ProxmoxNodeHistoryRepository", () => { let adapter: TestSqliteDatabase | null = null; diff --git a/src/ui/tests/plugins/proxmox/proxmox-import-auth.test.ts b/plugins/proxmox/tests/frontend/proxmox-import-auth.test.ts similarity index 94% rename from src/ui/tests/plugins/proxmox/proxmox-import-auth.test.ts rename to plugins/proxmox/tests/frontend/proxmox-import-auth.test.ts index c9b8e4f23..3120cc95b 100644 --- a/src/ui/tests/plugins/proxmox/proxmox-import-auth.test.ts +++ b/plugins/proxmox/tests/frontend/proxmox-import-auth.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { resolveProxmoxImportAuth } from "../../../../../plugins/proxmox/frontend/proxmox-import-auth"; +import { resolveProxmoxImportAuth } from "../../src/frontend/proxmox-import-auth"; describe("resolveProxmoxImportAuth", () => { it("inherits an available credential even with the default 'password' authType", () => { diff --git a/plugins/proxmox/tsconfig.json b/plugins/proxmox/tsconfig.json new file mode 100644 index 000000000..1d3e42542 --- /dev/null +++ b/plugins/proxmox/tsconfig.json @@ -0,0 +1,10 @@ +{ + "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/proxmox/vitest.config.ts b/plugins/proxmox/vitest.config.ts new file mode 100644 index 000000000..31ba004fc --- /dev/null +++ b/plugins/proxmox/vitest.config.ts @@ -0,0 +1,3 @@ +import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset"; + +export default pluginVitestConfig(import.meta.url); diff --git a/plugins/remote-desktop/CHANGELOG.md b/plugins/remote-desktop/CHANGELOG.md new file mode 100644 index 000000000..6a1bc3a37 --- /dev/null +++ b/plugins/remote-desktop/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 1.0.0 + +Bundled with Termix 2.9.0. diff --git a/plugins/remote-desktop/backend/index.mjs b/plugins/remote-desktop/backend/index.mjs deleted file mode 100644 index 22c42d061..000000000 --- a/plugins/remote-desktop/backend/index.mjs +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Remote Desktop (RDP/VNC/Telnet) - first-party, in-process plugin. - * - * Runs on the main thread rather than in a worker for the same class of - * reason as ssh-terminal/docker (see src/backend/plugins/first-party.ts): it - * owns a WebSocket server (guacamole-lite) on port 30008, and its - * /connect-host/:hostId route does multi-step credential/tunnel resolution - * (jump-host SSH tunnels, a macOS VNC compatibility proxy) involving raw - * net.Socket plumbing the worker's structured-clone postMessage boundary - * cannot carry. - * - * guacd itself is not a process this plugin spawns: it is a separate - * external service (a sibling Docker container by default, or any - * network-reachable guacd) reached over plain TCP. See guacd-config.ts's - * resolveGuacdOptions(). There is nothing to supervise here - guacd's - * availability is handled as an external dependency with graceful - * degradation, same as before this plugin existed. - * - * Its backend logic is real TypeScript physically relocated here (routes.ts, - * guacamole-server.ts, token-service.ts, etc.), compiled by - * scripts/copy-bundled-plugins.cjs like docker's plugin backend, rather than - * kept as a thin wrapper over code left in core -- see that script's own - * comment for why. The one piece that stays in core is - * src/backend/database/routes/guacamole-dispatch.ts, since it is what - * database.ts mounts permanently at /guacamole regardless of whether this - * plugin is installed. - */ - -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -async function loadRelative(relativePath) { - const here = path.dirname(fileURLToPath(import.meta.url)); - const target = path.join(here, `${relativePath}.js`); - return import(new URL(`file://${target.replace(/\\/g, "/")}`).href); -} - -let guacServerModule = null; -let dispatchModule = null; - -export async function activate(ctx) { - const [server, routes, dispatch] = await Promise.all([ - loadRelative("guacamole-server"), - loadRelative("routes"), - loadRelative("../../../backend/backend/database/routes/guacamole-dispatch"), - ]); - - guacServerModule = server; - dispatchModule = dispatch; - - await server.startGuacamoleService(); - dispatch.registerGuacamoleBridge({ - router: routes.router, - restart: server.restartGuacServer, - createJoinToken: (guacamoleConnectionId, readOnly) => - server.tokenService.createJoinToken(guacamoleConnectionId, readOnly), - getSessionInfo: server.getGuacSessionInfo, - }); - - ctx.log.info( - `Remote Desktop listening on ${server.GUAC_WS_PORT ?? 30008}`, - ); -} - -export async function deactivate() { - if (dispatchModule) { - dispatchModule.unregisterGuacamoleBridge(); - dispatchModule = null; - } - if (guacServerModule) { - await guacServerModule.stopGuacamoleService(); - guacServerModule = null; - } -} diff --git a/plugins/remote-desktop/locales/en.json b/plugins/remote-desktop/locales/en.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/plugins/remote-desktop/locales/en.json @@ -0,0 +1 @@ +{} diff --git a/plugins/remote-desktop/manifest.json b/plugins/remote-desktop/manifest.json index d24366971..5031ff812 100644 --- a/plugins/remote-desktop/manifest.json +++ b/plugins/remote-desktop/manifest.json @@ -61,8 +61,6 @@ } ] }, - "backend": "backend/index.mjs", - "frontend": "frontend/index.mjs", "locales": "locales", "platforms": ["linux", "win32", "darwin"] } diff --git a/plugins/remote-desktop/package.json b/plugins/remote-desktop/package.json new file mode 100644 index 000000000..2aa009d82 --- /dev/null +++ b/plugins/remote-desktop/package.json @@ -0,0 +1,15 @@ +{ + "name": "@termix-plugin/remote-desktop", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "termix-plugin build", + "test": "termix-plugin test", + "typecheck": "tsc -p tsconfig.json", + "validate": "termix-plugin validate" + }, + "devDependencies": { + "@termix/plugin-sdk": "*" + } +} diff --git a/plugins/remote-desktop/backend/drive-settings.ts b/plugins/remote-desktop/src/backend/drive-settings.ts similarity index 100% rename from plugins/remote-desktop/backend/drive-settings.ts rename to plugins/remote-desktop/src/backend/drive-settings.ts diff --git a/plugins/remote-desktop/backend/guacamole-server.ts b/plugins/remote-desktop/src/backend/guacamole-server.ts similarity index 97% rename from plugins/remote-desktop/backend/guacamole-server.ts rename to plugins/remote-desktop/src/backend/guacamole-server.ts index 22f8de8ac..956e6f5e6 100644 --- a/plugins/remote-desktop/backend/guacamole-server.ts +++ b/plugins/remote-desktop/src/backend/guacamole-server.ts @@ -1,5 +1,5 @@ import GuacamoleLite from "guacamole-lite"; -import { guacLogger } from "../../../src/backend/utils/logger.js"; +import { guacLogger } from "../../../../src/backend/utils/logger.js"; import { GuacamoleTokenService, type GuacamoleRecordingMetadata, @@ -7,8 +7,8 @@ import { import { createCurrentSessionRecordingRepository, getCurrentSettingValue, -} from "../../../src/backend/database/repositories/factory.js"; -import { resolveGuacdOptions } from "../../../src/backend/utils/guacd-config.js"; +} from "../../../../src/backend/database/repositories/factory.js"; +import { resolveGuacdOptions } from "../../../../src/backend/utils/guacd-config.js"; import fs from "fs"; import path from "path"; diff --git a/plugins/remote-desktop/src/backend/index.ts b/plugins/remote-desktop/src/backend/index.ts new file mode 100644 index 000000000..d20d1f8a9 --- /dev/null +++ b/plugins/remote-desktop/src/backend/index.ts @@ -0,0 +1,33 @@ +import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { + GUAC_WS_PORT, + getGuacSessionInfo, + restartGuacServer, + startGuacamoleService, + stopGuacamoleService, + tokenService, +} from "./guacamole-server.js"; +import { router } from "./routes.js"; +import { + registerGuacamoleBridge, + unregisterGuacamoleBridge, +} from "../../../../src/backend/database/routes/guacamole-dispatch.js"; + +export async function activate(ctx: PluginContext) { + await startGuacamoleService(); + + registerGuacamoleBridge({ + router, + restart: restartGuacServer, + createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) => + tokenService.createJoinToken(guacamoleConnectionId, readOnly), + getSessionInfo: getGuacSessionInfo, + }); + + ctx.log.info(`Remote Desktop listening on ${GUAC_WS_PORT ?? 30008}`); +} + +export async function deactivate() { + unregisterGuacamoleBridge(); + await stopGuacamoleService(); +} diff --git a/plugins/remote-desktop/backend/jump-tunnel-endpoint.ts b/plugins/remote-desktop/src/backend/jump-tunnel-endpoint.ts similarity index 100% rename from plugins/remote-desktop/backend/jump-tunnel-endpoint.ts rename to plugins/remote-desktop/src/backend/jump-tunnel-endpoint.ts diff --git a/plugins/remote-desktop/backend/macos-vnc-proxy.ts b/plugins/remote-desktop/src/backend/macos-vnc-proxy.ts similarity index 100% rename from plugins/remote-desktop/backend/macos-vnc-proxy.ts rename to plugins/remote-desktop/src/backend/macos-vnc-proxy.ts diff --git a/plugins/remote-desktop/backend/rdp-settings.ts b/plugins/remote-desktop/src/backend/rdp-settings.ts similarity index 100% rename from plugins/remote-desktop/backend/rdp-settings.ts rename to plugins/remote-desktop/src/backend/rdp-settings.ts diff --git a/plugins/remote-desktop/backend/recording-settings.ts b/plugins/remote-desktop/src/backend/recording-settings.ts similarity index 100% rename from plugins/remote-desktop/backend/recording-settings.ts rename to plugins/remote-desktop/src/backend/recording-settings.ts diff --git a/plugins/remote-desktop/backend/routes.ts b/plugins/remote-desktop/src/backend/routes.ts similarity index 96% rename from plugins/remote-desktop/backend/routes.ts rename to plugins/remote-desktop/src/backend/routes.ts index 515735295..0629fcf02 100644 --- a/plugins/remote-desktop/backend/routes.ts +++ b/plugins/remote-desktop/src/backend/routes.ts @@ -1,32 +1,32 @@ -import { getErrorMessage } from "../../../src/backend/utils/error-message.js"; +import { getErrorMessage } from "../../../../src/backend/utils/error-message.js"; import express from "express"; import { GuacamoleTokenService } from "./token-service.js"; import { withRecordingSettings } from "./recording-settings.js"; import { withDriveSettings } from "./drive-settings.js"; -import { guacLogger } from "../../../src/backend/utils/logger.js"; -import { AuthManager } from "../../../src/backend/utils/auth-manager.js"; -import { PermissionManager } from "../../../src/backend/utils/permission-manager.js"; +import { guacLogger } from "../../../../src/backend/utils/logger.js"; +import { AuthManager } from "../../../../src/backend/utils/auth-manager.js"; +import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js"; import { resolveRecipientSharedHostAuthentication, type RecipientSharedHostAuthResolution, -} from "../../../src/backend/utils/shared-host-auth-resolver.js"; -import type { AuthOverrideProtocol } from "../../../src/types/auth-protocols.js"; +} from "../../../../src/backend/utils/shared-host-auth-resolver.js"; +import type { AuthOverrideProtocol } from "../../../../src/types/auth-protocols.js"; import net from "net"; import crypto from "crypto"; import path from "path"; -import type { AuthenticatedRequest } from "../../../src/types/index.js"; +import type { AuthenticatedRequest } from "../../../../src/types/index.js"; import { createCurrentHostResolutionRepository, createCurrentSettingsRepository, -} from "../../../src/backend/database/repositories/factory.js"; -import { resolveGuacdOptions } from "../../../src/backend/utils/guacd-config.js"; -import { createJumpHostChain } from "../../../src/backend/hosts/jump-host-chain.js"; +} from "../../../../src/backend/database/repositories/factory.js"; +import { resolveGuacdOptions } from "../../../../src/backend/utils/guacd-config.js"; +import { createJumpHostChain } from "../../../../src/backend/hosts/jump-host-chain.js"; import { getGuacSessionByConnectId } from "./guacamole-server.js"; import { logAudit, getAuditUsername, getRequestMeta, -} from "../../../src/backend/utils/audit-logger.js"; +} from "../../../../src/backend/utils/audit-logger.js"; import { resolveJumpTunnelEndpoint } from "./jump-tunnel-endpoint.js"; import { buildRdpSettings, diff --git a/plugins/remote-desktop/backend/token-service.ts b/plugins/remote-desktop/src/backend/token-service.ts similarity index 98% rename from plugins/remote-desktop/backend/token-service.ts rename to plugins/remote-desktop/src/backend/token-service.ts index 8ad4a8d2d..fd6d00e35 100644 --- a/plugins/remote-desktop/backend/token-service.ts +++ b/plugins/remote-desktop/src/backend/token-service.ts @@ -1,5 +1,5 @@ import crypto from "crypto"; -import { guacLogger } from "../../../src/backend/utils/logger.js"; +import { guacLogger } from "../../../../src/backend/utils/logger.js"; export interface GuacamoleConnectionSettings { type?: "rdp" | "vnc" | "telnet"; diff --git a/plugins/remote-desktop/frontend/GuacamoleApp.tsx b/plugins/remote-desktop/src/frontend/GuacamoleApp.tsx similarity index 100% rename from plugins/remote-desktop/frontend/GuacamoleApp.tsx rename to plugins/remote-desktop/src/frontend/GuacamoleApp.tsx diff --git a/plugins/remote-desktop/frontend/GuacamoleDisplay.tsx b/plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx similarity index 100% rename from plugins/remote-desktop/frontend/GuacamoleDisplay.tsx rename to plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx diff --git a/plugins/remote-desktop/frontend/GuacamoleFileBrowser.tsx b/plugins/remote-desktop/src/frontend/GuacamoleFileBrowser.tsx similarity index 100% rename from plugins/remote-desktop/frontend/GuacamoleFileBrowser.tsx rename to plugins/remote-desktop/src/frontend/GuacamoleFileBrowser.tsx diff --git a/plugins/remote-desktop/frontend/GuacamoleToolbar.tsx b/plugins/remote-desktop/src/frontend/GuacamoleToolbar.tsx similarity index 100% rename from plugins/remote-desktop/frontend/GuacamoleToolbar.tsx rename to plugins/remote-desktop/src/frontend/GuacamoleToolbar.tsx diff --git a/plugins/remote-desktop/frontend/guacamole-api.ts b/plugins/remote-desktop/src/frontend/guacamole-api.ts similarity index 100% rename from plugins/remote-desktop/frontend/guacamole-api.ts rename to plugins/remote-desktop/src/frontend/guacamole-api.ts diff --git a/plugins/remote-desktop/frontend/guacamole-clipboard.ts b/plugins/remote-desktop/src/frontend/guacamole-clipboard.ts similarity index 100% rename from plugins/remote-desktop/frontend/guacamole-clipboard.ts rename to plugins/remote-desktop/src/frontend/guacamole-clipboard.ts diff --git a/plugins/remote-desktop/frontend/guacamole-display-size.ts b/plugins/remote-desktop/src/frontend/guacamole-display-size.ts similarity index 100% rename from plugins/remote-desktop/frontend/guacamole-display-size.ts rename to plugins/remote-desktop/src/frontend/guacamole-display-size.ts diff --git a/plugins/remote-desktop/frontend/guacamole-file-drop.ts b/plugins/remote-desktop/src/frontend/guacamole-file-drop.ts similarity index 100% rename from plugins/remote-desktop/frontend/guacamole-file-drop.ts rename to plugins/remote-desktop/src/frontend/guacamole-file-drop.ts diff --git a/plugins/remote-desktop/frontend/guacamole-filesystem.ts b/plugins/remote-desktop/src/frontend/guacamole-filesystem.ts similarity index 100% rename from plugins/remote-desktop/frontend/guacamole-filesystem.ts rename to plugins/remote-desktop/src/frontend/guacamole-filesystem.ts diff --git a/plugins/remote-desktop/frontend/guacamole-meta-key.ts b/plugins/remote-desktop/src/frontend/guacamole-meta-key.ts similarity index 100% rename from plugins/remote-desktop/frontend/guacamole-meta-key.ts rename to plugins/remote-desktop/src/frontend/guacamole-meta-key.ts diff --git a/plugins/remote-desktop/frontend/guacamole-pointer.ts b/plugins/remote-desktop/src/frontend/guacamole-pointer.ts similarity index 100% rename from plugins/remote-desktop/frontend/guacamole-pointer.ts rename to plugins/remote-desktop/src/frontend/guacamole-pointer.ts diff --git a/plugins/remote-desktop/frontend/guacamole-scroll.ts b/plugins/remote-desktop/src/frontend/guacamole-scroll.ts similarity index 100% rename from plugins/remote-desktop/frontend/guacamole-scroll.ts rename to plugins/remote-desktop/src/frontend/guacamole-scroll.ts diff --git a/plugins/remote-desktop/frontend/guacamole-session-id.ts b/plugins/remote-desktop/src/frontend/guacamole-session-id.ts similarity index 100% rename from plugins/remote-desktop/frontend/guacamole-session-id.ts rename to plugins/remote-desktop/src/frontend/guacamole-session-id.ts diff --git a/plugins/remote-desktop/frontend/guacamole-websocket-url.ts b/plugins/remote-desktop/src/frontend/guacamole-websocket-url.ts similarity index 100% rename from plugins/remote-desktop/frontend/guacamole-websocket-url.ts rename to plugins/remote-desktop/src/frontend/guacamole-websocket-url.ts diff --git a/plugins/remote-desktop/frontend/guacamole-zoom.ts b/plugins/remote-desktop/src/frontend/guacamole-zoom.ts similarity index 100% rename from plugins/remote-desktop/frontend/guacamole-zoom.ts rename to plugins/remote-desktop/src/frontend/guacamole-zoom.ts diff --git a/plugins/remote-desktop/frontend/index.mjs b/plugins/remote-desktop/src/frontend/index.mjs similarity index 100% rename from plugins/remote-desktop/frontend/index.mjs rename to plugins/remote-desktop/src/frontend/index.mjs diff --git a/plugins/remote-desktop/frontend/rdp-credential-prompt.ts b/plugins/remote-desktop/src/frontend/rdp-credential-prompt.ts similarity index 100% rename from plugins/remote-desktop/frontend/rdp-credential-prompt.ts rename to plugins/remote-desktop/src/frontend/rdp-credential-prompt.ts diff --git a/src/backend/tests/plugins/remote-desktop/drive-settings.test.ts b/plugins/remote-desktop/tests/backend/drive-settings.test.ts similarity index 91% rename from src/backend/tests/plugins/remote-desktop/drive-settings.test.ts rename to plugins/remote-desktop/tests/backend/drive-settings.test.ts index 0a8069d65..68a5a3fdc 100644 --- a/src/backend/tests/plugins/remote-desktop/drive-settings.test.ts +++ b/plugins/remote-desktop/tests/backend/drive-settings.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { withDriveSettings } from "../../../../../plugins/remote-desktop/backend/drive-settings.js"; +import { withDriveSettings } from "../../src/backend/drive-settings.js"; describe("withDriveSettings", () => { it("gives each user a folder under GUACD_DRIVE_PATH and creates it", () => { diff --git a/src/backend/tests/plugins/remote-desktop/jump-tunnel-endpoint.test.ts b/plugins/remote-desktop/tests/backend/jump-tunnel-endpoint.test.ts similarity index 86% rename from src/backend/tests/plugins/remote-desktop/jump-tunnel-endpoint.test.ts rename to plugins/remote-desktop/tests/backend/jump-tunnel-endpoint.test.ts index b4528d6f4..af017f402 100644 --- a/src/backend/tests/plugins/remote-desktop/jump-tunnel-endpoint.test.ts +++ b/plugins/remote-desktop/tests/backend/jump-tunnel-endpoint.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { resolveJumpTunnelEndpoint } from "../../../../../plugins/remote-desktop/backend/jump-tunnel-endpoint.js"; +import { resolveJumpTunnelEndpoint } from "../../src/backend/jump-tunnel-endpoint.js"; describe("resolveJumpTunnelEndpoint", () => { it("keeps an in-process guacd tunnel on loopback", () => { diff --git a/src/backend/tests/plugins/remote-desktop/macos-vnc-proxy.test.ts b/plugins/remote-desktop/tests/backend/macos-vnc-proxy.test.ts similarity index 97% rename from src/backend/tests/plugins/remote-desktop/macos-vnc-proxy.test.ts rename to plugins/remote-desktop/tests/backend/macos-vnc-proxy.test.ts index 7c02d25e3..b38ba2071 100644 --- a/src/backend/tests/plugins/remote-desktop/macos-vnc-proxy.test.ts +++ b/plugins/remote-desktop/tests/backend/macos-vnc-proxy.test.ts @@ -1,6 +1,6 @@ import net from "net"; import { afterEach, describe, expect, it } from "vitest"; -import { createMacosVncCompatibilityProxy } from "../../../../../plugins/remote-desktop/backend/macos-vnc-proxy.js"; +import { createMacosVncCompatibilityProxy } from "../../src/backend/macos-vnc-proxy.js"; const closers: Array<() => void> = []; diff --git a/src/backend/tests/plugins/remote-desktop/rdp-settings.test.ts b/plugins/remote-desktop/tests/backend/rdp-settings.test.ts similarity index 97% rename from src/backend/tests/plugins/remote-desktop/rdp-settings.test.ts rename to plugins/remote-desktop/tests/backend/rdp-settings.test.ts index c53892bd4..10d94d61a 100644 --- a/src/backend/tests/plugins/remote-desktop/rdp-settings.test.ts +++ b/plugins/remote-desktop/tests/backend/rdp-settings.test.ts @@ -3,7 +3,7 @@ import { buildRdpSettings, resolveRdpAuthTypeForConnect, resolveRdpDomain, -} from "../../../../../plugins/remote-desktop/backend/rdp-settings.js"; +} from "../../src/backend/rdp-settings.js"; describe("buildRdpSettings", () => { it("keeps saved RDP settings authoritative over stale advanced config", () => { diff --git a/src/backend/tests/plugins/remote-desktop/recording-settings.test.ts b/plugins/remote-desktop/tests/backend/recording-settings.test.ts similarity index 94% rename from src/backend/tests/plugins/remote-desktop/recording-settings.test.ts rename to plugins/remote-desktop/tests/backend/recording-settings.test.ts index 948f150f9..8504e5f75 100644 --- a/src/backend/tests/plugins/remote-desktop/recording-settings.test.ts +++ b/plugins/remote-desktop/tests/backend/recording-settings.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { withRecordingSettings } from "../../../../../plugins/remote-desktop/backend/recording-settings.js"; +import { withRecordingSettings } from "../../src/backend/recording-settings.js"; const PATH = "/app/data/session_recordings/guacamole"; const NAME = "b7e6c0f2-0000-4000-8000-000000000000.guac"; diff --git a/src/backend/tests/plugins/remote-desktop/token-delivery.test.ts b/plugins/remote-desktop/tests/backend/token-delivery.test.ts similarity index 92% rename from src/backend/tests/plugins/remote-desktop/token-delivery.test.ts rename to plugins/remote-desktop/tests/backend/token-delivery.test.ts index b7829249b..afa75a428 100644 --- a/src/backend/tests/plugins/remote-desktop/token-delivery.test.ts +++ b/plugins/remote-desktop/tests/backend/token-delivery.test.ts @@ -5,10 +5,7 @@ import { expect, it, vi } from "vitest"; it("returns the minted token without waiting for a connection that needs that token", async () => { const source = readFileSync( - new URL( - "../../../../../plugins/remote-desktop/backend/routes.ts", - import.meta.url, - ), + new URL("../../src/backend/routes.ts", import.meta.url), "utf8", ); const ast = ts.createSourceFile( @@ -69,10 +66,7 @@ it("returns the minted token without waiting for a connection that needs that to it("only reveals the live session ID to its owner", () => { const source = readFileSync( - new URL( - "../../../../../plugins/remote-desktop/backend/guacamole-server.ts", - import.meta.url, - ), + new URL("../../src/backend/guacamole-server.ts", import.meta.url), "utf8", ); const ast = ts.createSourceFile( diff --git a/src/backend/tests/plugins/remote-desktop/token-service.test.ts b/plugins/remote-desktop/tests/backend/token-service.test.ts similarity index 95% rename from src/backend/tests/plugins/remote-desktop/token-service.test.ts rename to plugins/remote-desktop/tests/backend/token-service.test.ts index 998c5cd09..9646a6898 100644 --- a/src/backend/tests/plugins/remote-desktop/token-service.test.ts +++ b/plugins/remote-desktop/tests/backend/token-service.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -vi.mock("../../../../../src/backend/utils/logger.js", () => ({ +vi.mock("../../../../src/backend/utils/logger.js", () => ({ guacLogger: { debug: vi.fn(), info: vi.fn(), @@ -11,7 +11,7 @@ vi.mock("../../../../../src/backend/utils/logger.js", () => ({ })); const { GuacamoleTokenService } = - await import("../../../../../plugins/remote-desktop/backend/token-service.js"); + await import("../../src/backend/token-service.js"); describe("GuacamoleTokenService", () => { const tokenService = GuacamoleTokenService.getInstance(); diff --git a/plugins/remote-desktop/frontend/GuacamoleToolbar.test.tsx b/plugins/remote-desktop/tests/frontend/GuacamoleToolbar.test.tsx similarity index 96% rename from plugins/remote-desktop/frontend/GuacamoleToolbar.test.tsx rename to plugins/remote-desktop/tests/frontend/GuacamoleToolbar.test.tsx index a9eed57ad..3fc72f32b 100644 --- a/plugins/remote-desktop/frontend/GuacamoleToolbar.test.tsx +++ b/plugins/remote-desktop/tests/frontend/GuacamoleToolbar.test.tsx @@ -1,8 +1,8 @@ import React from "react"; import { fireEvent, render } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; -import { GuacamoleToolbar } from "./GuacamoleToolbar.js"; -import type { GuacamoleDisplayHandle } from "./GuacamoleDisplay.js"; +import { GuacamoleToolbar } from "../../src/frontend/GuacamoleToolbar.js"; +import type { GuacamoleDisplayHandle } from "../../src/frontend/GuacamoleDisplay.js"; vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key }), diff --git a/plugins/remote-desktop/frontend/guacamole-api.test.ts b/plugins/remote-desktop/tests/frontend/guacamole-api.test.ts similarity index 99% rename from plugins/remote-desktop/frontend/guacamole-api.test.ts rename to plugins/remote-desktop/tests/frontend/guacamole-api.test.ts index a55a6585d..a1535607c 100644 --- a/plugins/remote-desktop/frontend/guacamole-api.test.ts +++ b/plugins/remote-desktop/tests/frontend/guacamole-api.test.ts @@ -25,7 +25,7 @@ import { getGuacdStatus, getGuacamoleConnectionId, getGuacamoleTokenFromHost, -} from "./guacamole-api"; +} from "../../src/frontend/guacamole-api"; beforeEach(() => { authApiMock.get.mockClear(); diff --git a/plugins/remote-desktop/frontend/guacamole-clipboard.test.ts b/plugins/remote-desktop/tests/frontend/guacamole-clipboard.test.ts similarity index 96% rename from plugins/remote-desktop/frontend/guacamole-clipboard.test.ts rename to plugins/remote-desktop/tests/frontend/guacamole-clipboard.test.ts index b6e4f690f..2c298bfff 100644 --- a/plugins/remote-desktop/frontend/guacamole-clipboard.test.ts +++ b/plugins/remote-desktop/tests/frontend/guacamole-clipboard.test.ts @@ -3,7 +3,7 @@ import { isPasteShortcut, pasteTextToRemote, type GuacamoleClipboardClient, -} from "./guacamole-clipboard.js"; +} from "../../src/frontend/guacamole-clipboard.js"; describe("Guacamole clipboard paste", () => { it("recognizes Ctrl+V and Command+V without intercepting Alt+V", () => { diff --git a/plugins/remote-desktop/frontend/guacamole-display-size.test.ts b/plugins/remote-desktop/tests/frontend/guacamole-display-size.test.ts similarity index 97% rename from plugins/remote-desktop/frontend/guacamole-display-size.test.ts rename to plugins/remote-desktop/tests/frontend/guacamole-display-size.test.ts index 4d4ee897e..0a1338fbb 100644 --- a/plugins/remote-desktop/frontend/guacamole-display-size.test.ts +++ b/plugins/remote-desktop/tests/frontend/guacamole-display-size.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { getGuacamoleDisplaySize, readConfiguredDimension, -} from "./guacamole-display-size"; +} from "../../src/frontend/guacamole-display-size"; describe("getGuacamoleDisplaySize", () => { it("requests native pixels and matching DPI for HiDPI RDP", () => { diff --git a/plugins/remote-desktop/frontend/guacamole-file-drop.test.ts b/plugins/remote-desktop/tests/frontend/guacamole-file-drop.test.ts similarity index 94% rename from plugins/remote-desktop/frontend/guacamole-file-drop.test.ts rename to plugins/remote-desktop/tests/frontend/guacamole-file-drop.test.ts index 6d40a966f..cf407025d 100644 --- a/plugins/remote-desktop/frontend/guacamole-file-drop.test.ts +++ b/plugins/remote-desktop/tests/frontend/guacamole-file-drop.test.ts @@ -3,7 +3,7 @@ import { canUploadToRdpDrive, getFileDropDisposition, hasDraggedFiles, -} from "./guacamole-file-drop.ts"; +} from "../../src/frontend/guacamole-file-drop.ts"; describe("Guacamole file drop", () => { it("recognizes external file drags", () => { diff --git a/plugins/remote-desktop/frontend/guacamole-filesystem.test.ts b/plugins/remote-desktop/tests/frontend/guacamole-filesystem.test.ts similarity index 89% rename from plugins/remote-desktop/frontend/guacamole-filesystem.test.ts rename to plugins/remote-desktop/tests/frontend/guacamole-filesystem.test.ts index 55d61effe..ce7c535af 100644 --- a/plugins/remote-desktop/frontend/guacamole-filesystem.test.ts +++ b/plugins/remote-desktop/tests/frontend/guacamole-filesystem.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { describeUploadError } from "./guacamole-filesystem"; +import { describeUploadError } from "../../src/frontend/guacamole-filesystem"; const t = (key: string) => `#${key}`; diff --git a/plugins/remote-desktop/frontend/guacamole-meta-key.test.ts b/plugins/remote-desktop/tests/frontend/guacamole-meta-key.test.ts similarity index 93% rename from plugins/remote-desktop/frontend/guacamole-meta-key.test.ts rename to plugins/remote-desktop/tests/frontend/guacamole-meta-key.test.ts index 72a8e2f1e..7fa6d15ff 100644 --- a/plugins/remote-desktop/frontend/guacamole-meta-key.test.ts +++ b/plugins/remote-desktop/tests/frontend/guacamole-meta-key.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { detectMetaKeyFamily, metaKeyLabels } from "./guacamole-meta-key.js"; +import { + detectMetaKeyFamily, + metaKeyLabels, +} from "../../src/frontend/guacamole-meta-key.js"; describe("guacamole toolbar meta key labels", () => { it("keeps Windows labels for RDP regardless of the local device", () => { diff --git a/plugins/remote-desktop/frontend/guacamole-pointer.test.ts b/plugins/remote-desktop/tests/frontend/guacamole-pointer.test.ts similarity index 97% rename from plugins/remote-desktop/frontend/guacamole-pointer.test.ts rename to plugins/remote-desktop/tests/frontend/guacamole-pointer.test.ts index 96effd4e5..dcb7c54d5 100644 --- a/plugins/remote-desktop/frontend/guacamole-pointer.test.ts +++ b/plugins/remote-desktop/tests/frontend/guacamole-pointer.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { bindPointerInput } from "./guacamole-pointer.js"; +import { bindPointerInput } from "../../src/frontend/guacamole-pointer.js"; function listenedEvents(): { element: HTMLElement; diff --git a/plugins/remote-desktop/frontend/guacamole-scroll.test.ts b/plugins/remote-desktop/tests/frontend/guacamole-scroll.test.ts similarity index 97% rename from plugins/remote-desktop/frontend/guacamole-scroll.test.ts rename to plugins/remote-desktop/tests/frontend/guacamole-scroll.test.ts index 15634717f..8c5665151 100644 --- a/plugins/remote-desktop/frontend/guacamole-scroll.test.ts +++ b/plugins/remote-desktop/tests/frontend/guacamole-scroll.test.ts @@ -6,7 +6,7 @@ import { MAX_SCROLL_CLICKS_PER_FLUSH, SCROLL_FLUSH_INTERVAL_MS, takeScrollClicks, -} from "./guacamole-scroll.js"; +} from "../../src/frontend/guacamole-scroll.js"; describe("guacamole scroll coalescing", () => { it("caps a click flood so a laggy tunnel cannot queue a backlog", () => { diff --git a/plugins/remote-desktop/frontend/guacamole-session-id.test.ts b/plugins/remote-desktop/tests/frontend/guacamole-session-id.test.ts similarity index 89% rename from plugins/remote-desktop/frontend/guacamole-session-id.test.ts rename to plugins/remote-desktop/tests/frontend/guacamole-session-id.test.ts index 248382ab3..27cc872cd 100644 --- a/plugins/remote-desktop/frontend/guacamole-session-id.test.ts +++ b/plugins/remote-desktop/tests/frontend/guacamole-session-id.test.ts @@ -1,7 +1,9 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; const fetchId = vi.hoisted(() => vi.fn()); -vi.mock("./guacamole-api", () => ({ getGuacamoleConnectionId: fetchId })); -import { watchGuacamoleConnectionId } from "./guacamole-session-id"; +vi.mock("../../src/frontend/guacamole-api", () => ({ + getGuacamoleConnectionId: fetchId, +})); +import { watchGuacamoleConnectionId } from "../../src/frontend/guacamole-session-id"; beforeEach(() => { vi.useFakeTimers(); fetchId.mockReset(); diff --git a/plugins/remote-desktop/frontend/guacamole-websocket-url.test.ts b/plugins/remote-desktop/tests/frontend/guacamole-websocket-url.test.ts similarity index 95% rename from plugins/remote-desktop/frontend/guacamole-websocket-url.test.ts rename to plugins/remote-desktop/tests/frontend/guacamole-websocket-url.test.ts index 17e07de82..c2b267fe6 100644 --- a/plugins/remote-desktop/frontend/guacamole-websocket-url.test.ts +++ b/plugins/remote-desktop/tests/frontend/guacamole-websocket-url.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.js"; +import { buildGuacamoleWebSocketBaseUrl } from "../../src/frontend/guacamole-websocket-url.js"; const httpsLocation = { protocol: "https:", diff --git a/plugins/remote-desktop/frontend/guacamole-zoom.test.ts b/plugins/remote-desktop/tests/frontend/guacamole-zoom.test.ts similarity index 80% rename from plugins/remote-desktop/frontend/guacamole-zoom.test.ts rename to plugins/remote-desktop/tests/frontend/guacamole-zoom.test.ts index 15ad65386..413d15e0a 100644 --- a/plugins/remote-desktop/frontend/guacamole-zoom.test.ts +++ b/plugins/remote-desktop/tests/frontend/guacamole-zoom.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { clampGuacamoleZoom, stepGuacamoleZoom } from "./guacamole-zoom.js"; +import { + clampGuacamoleZoom, + stepGuacamoleZoom, +} from "../../src/frontend/guacamole-zoom.js"; describe("guacamole zoom", () => { it("steps in predictable quarter increments", () => { diff --git a/src/ui/tests/electron/native-rdp.test.ts b/plugins/remote-desktop/tests/frontend/native-rdp.test.ts similarity index 100% rename from src/ui/tests/electron/native-rdp.test.ts rename to plugins/remote-desktop/tests/frontend/native-rdp.test.ts diff --git a/plugins/remote-desktop/frontend/rdp-credential-prompt.test.ts b/plugins/remote-desktop/tests/frontend/rdp-credential-prompt.test.ts similarity index 90% rename from plugins/remote-desktop/frontend/rdp-credential-prompt.test.ts rename to plugins/remote-desktop/tests/frontend/rdp-credential-prompt.test.ts index f9afa67dd..6170dcdf1 100644 --- a/plugins/remote-desktop/frontend/rdp-credential-prompt.test.ts +++ b/plugins/remote-desktop/tests/frontend/rdp-credential-prompt.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { needsRdpCredentialPrompt } from "./rdp-credential-prompt"; +import { needsRdpCredentialPrompt } from "../../src/frontend/rdp-credential-prompt"; describe("needsRdpCredentialPrompt", () => { it("prompts recipients when RDP has no saved authentication", () => { diff --git a/plugins/remote-desktop/tsconfig.json b/plugins/remote-desktop/tsconfig.json new file mode 100644 index 000000000..1d3e42542 --- /dev/null +++ b/plugins/remote-desktop/tsconfig.json @@ -0,0 +1,10 @@ +{ + "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/vitest.config.ts b/plugins/remote-desktop/vitest.config.ts new file mode 100644 index 000000000..31ba004fc --- /dev/null +++ b/plugins/remote-desktop/vitest.config.ts @@ -0,0 +1,3 @@ +import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset"; + +export default pluginVitestConfig(import.meta.url); diff --git a/plugins/ssh-terminal/CHANGELOG.md b/plugins/ssh-terminal/CHANGELOG.md new file mode 100644 index 000000000..6a1bc3a37 --- /dev/null +++ b/plugins/ssh-terminal/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 1.0.0 + +Bundled with Termix 2.9.0. diff --git a/plugins/ssh-terminal/backend/index.mjs b/plugins/ssh-terminal/backend/index.mjs deleted file mode 100644 index 0d98156bd..000000000 --- a/plugins/ssh-terminal/backend/index.mjs +++ /dev/null @@ -1,83 +0,0 @@ -/** - * SSH Terminal - first-party, in-process plugin. - * - * This plugin runs on the main thread rather than in a worker, because it IS - * the SSH transport rather than a consumer of one: it owns a WebSocket server - * and long-lived ssh2 clients, and neither a live socket nor an ssh2 Client can - * cross the structured-clone postMessage boundary a worker plugin talks over. - * See src/backend/plugins/first-party.ts for why that tier is a hardcoded - * allowlist and not something a manifest can ask for. - * - * The terminal's implementation stays in core (src/backend/hosts/terminal/) - * rather than being copied in here. Two reasons: - * - session-manager has six consumers outside the terminal (terminal routes, - * open-tabs, user-image-storage, collab, and session-sharing twice), so it - * is shared infrastructure, not plugin-private code. - * - the terminal needs plaintext host credentials to connect, which ctx.hosts - * deliberately never returns (PluginHostView is a 13-field allowlist that - * excludes password, key and passphrase). - * - * What this plugin owns is the lifecycle: enabling it starts the WS server, - * disabling it closes live sessions and frees the port. - */ - -import { createRequire } from "node:module"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -/** - * Resolves the compiled core terminal module. - * - * The plugin ships beside dist/backend, so in a built server the module is at - * dist/backend/backend/hosts/terminal/index.js. Under vitest and dev the - * plugin is loaded from the repo instead, where the source .ts is the thing - * that exists and tsx is already handling it. - */ -async function loadTerminalModule() { - const here = path.dirname(fileURLToPath(import.meta.url)); - const require = createRequire(import.meta.url); - - const candidates = [ - // Built: plugins/ssh-terminal/backend -> dist/backend/backend/... - path.resolve(here, "../../../backend/backend/hosts/terminal/index.js"), - // Repo layout, compiled output present. - path.resolve(here, "../../../dist/backend/backend/hosts/terminal/index.js"), - // Repo layout, running from source under tsx. - path.resolve(here, "../../../src/backend/hosts/terminal/index.ts"), - ]; - - for (const candidate of candidates) { - try { - require.resolve(candidate); - } catch { - continue; - } - return import(new URL(`file://${candidate.replace(/\\/g, "/")}`).href); - } - - throw new Error( - `ssh-terminal could not locate the terminal module. Looked in:\n ${candidates.join("\n ")}`, - ); -} - -let terminal = null; - -export async function activate(ctx) { - terminal = await loadTerminalModule(); - - await terminal.startTerminalServer(); - - // Published so collab and session-sharing can reach live sessions without - // importing a plugin. Revoked automatically when this plugin is disabled. - ctx.registry.provide("terminal.sessions", terminal.sessionManager ?? null); - - ctx.log.info( - `SSH terminal listening on ${terminal.TERMINAL_WS_PORT ?? 30002}`, - ); -} - -export async function deactivate() { - if (!terminal) return; - await terminal.stopTerminalServer(); - terminal = null; -} diff --git a/plugins/ssh-terminal/locales/en.json b/plugins/ssh-terminal/locales/en.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/plugins/ssh-terminal/locales/en.json @@ -0,0 +1 @@ +{} diff --git a/plugins/ssh-terminal/manifest.json b/plugins/ssh-terminal/manifest.json index 10808c036..67bae89ca 100644 --- a/plugins/ssh-terminal/manifest.json +++ b/plugins/ssh-terminal/manifest.json @@ -37,8 +37,6 @@ } ] }, - "backend": "backend/index.mjs", - "frontend": "frontend/index.mjs", "locales": "locales", "platforms": ["linux", "win32", "darwin"] } diff --git a/plugins/ssh-terminal/package.json b/plugins/ssh-terminal/package.json new file mode 100644 index 000000000..fef642ed4 --- /dev/null +++ b/plugins/ssh-terminal/package.json @@ -0,0 +1,15 @@ +{ + "name": "@termix-plugin/ssh-terminal", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "termix-plugin build", + "test": "termix-plugin test", + "typecheck": "tsc -p tsconfig.json", + "validate": "termix-plugin validate" + }, + "devDependencies": { + "@termix/plugin-sdk": "*" + } +} diff --git a/plugins/ssh-terminal/src/backend/index.ts b/plugins/ssh-terminal/src/backend/index.ts new file mode 100644 index 000000000..08f1d7bb6 --- /dev/null +++ b/plugins/ssh-terminal/src/backend/index.ts @@ -0,0 +1,21 @@ +import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { + TERMINAL_WS_PORT, + sessionManager, + startTerminalServer, + stopTerminalServer, +} from "../../../../src/backend/hosts/terminal/index.js"; + +export async function activate(ctx: PluginContext) { + await startTerminalServer(); + + // Published so collab and session-sharing can reach live sessions without + // importing a plugin. Revoked automatically when this plugin is disabled. + ctx.registry.provide("terminal.sessions", sessionManager ?? null); + + ctx.log.info(`SSH terminal listening on ${TERMINAL_WS_PORT ?? 30002}`); +} + +export async function deactivate() { + await stopTerminalServer(); +} diff --git a/plugins/ssh-terminal/frontend/index.mjs b/plugins/ssh-terminal/src/frontend/index.mjs similarity index 100% rename from plugins/ssh-terminal/frontend/index.mjs rename to plugins/ssh-terminal/src/frontend/index.mjs diff --git a/plugins/ssh-terminal/tsconfig.json b/plugins/ssh-terminal/tsconfig.json new file mode 100644 index 000000000..1d3e42542 --- /dev/null +++ b/plugins/ssh-terminal/tsconfig.json @@ -0,0 +1,10 @@ +{ + "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/ssh-terminal/vitest.config.ts b/plugins/ssh-terminal/vitest.config.ts new file mode 100644 index 000000000..31ba004fc --- /dev/null +++ b/plugins/ssh-terminal/vitest.config.ts @@ -0,0 +1,3 @@ +import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset"; + +export default pluginVitestConfig(import.meta.url); diff --git a/plugins/tailscale/CHANGELOG.md b/plugins/tailscale/CHANGELOG.md new file mode 100644 index 000000000..6a1bc3a37 --- /dev/null +++ b/plugins/tailscale/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 1.0.0 + +Bundled with Termix 2.9.0. diff --git a/plugins/tailscale/README.md b/plugins/tailscale/README.md new file mode 100644 index 000000000..1af0b5482 --- /dev/null +++ b/plugins/tailscale/README.md @@ -0,0 +1,22 @@ +# Tailscale + +Browse and connect to tailnet devices, manage the Tailscale API key, and show the Tailscale SSH re-authentication dialog. + +Bundled with Termix and enabled by default. + +## Layout + + manifest.json id, capabilities and what this plugin contributes + src/backend/ activate(ctx) / deactivate(), plus the routes + src/frontend/ the UI it registers + locales/en.json its strings (English only; the rest are translated) + tests/backend/ vitest, node + tests/frontend/ vitest, jsdom + +## Commands + + npm run build bundle into dist/ + npm run test run this plugin's tests + npm run typecheck + +See packages/plugin-sdk/ARCHITECTURE.md for the contract this follows. diff --git a/plugins/tailscale/backend/index.mjs b/plugins/tailscale/backend/index.mjs deleted file mode 100644 index 7155515fa..000000000 --- a/plugins/tailscale/backend/index.mjs +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Tailscale - first-party, in-process plugin. - * - * This plugin runs on the main thread rather than in a worker for the same - * reason fleets and proxmox do (see src/backend/plugins/first-party.ts): it - * does not own a live transport of its own (no WebSocket server, no - * dedicated port), so it registers its Express router with the - * always-mounted /tailscale dispatcher in - * src/backend/database/routes/tailscale-dispatch.ts instead of getting its - * own port. - * - * routes.ts is what used to be src/backend/database/routes/tailscale-routes.ts, - * moved here and rewritten from a direct app.use() call into - * startTailscaleService()/stopTailscaleService(), called from - * activate()/deactivate() below. - * - * tailscale-check.ts (SSH re-auth banner parsing) stays in core, at - * src/backend/hosts/tailscale-check.ts, and is NOT part of this plugin. - * It is imported directly by src/backend/hosts/terminal/index.ts (owned by - * ssh-terminal), which type-checks under tsconfig.node.json's rootDir of - * src/ -- a plugin .ts file physically outside that root cannot be a static - * import target for core code (see scripts/copy-bundled-plugins.cjs's own - * comment: plugin backends import core, never the reverse, anywhere in this - * codebase). Moving the file into plugins/tailscale/backend/ would require - * either widening tsconfig.node.json's rootDir for every other plugin's - * dist path-rewriting, or switching terminal/index.ts to a dynamic import - * inside its banner handler -- both bigger changes than this plugin - * warrants. The banner/ready wiring and retry state already live in - * ssh-terminal's connection handler regardless, so this stays exactly where - * that coupling already lives. - * - * The /users/tailscale-settings GET/PATCH routes stay in core - * (src/backend/database/routes/user-settings-routes.ts) -- they are general - * user-settings infrastructure, not Tailscale-specific plumbing, and this - * plugin's frontend calls them the same way core code does. - */ - -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -/** - * Loads a module by its path relative to this file's own directory. - * - * The plugin ships beside dist/backend, so in a built server the compiled - * .js for routes.js sits right next to this .mjs. Under vitest/dev nothing - * imports this plugin directly (the loader loads in-process plugins with a - * plain dynamic import, no tsx loader involved), so the only layout that - * needs to resolve here is the built one. - */ -async function loadRelative(relativePath) { - const here = path.dirname(fileURLToPath(import.meta.url)); - const target = path.join(here, `${relativePath}.js`); - return import(new URL(`file://${target.replace(/\\/g, "/")}`).href); -} - -let routesModule = null; - -export async function activate(ctx) { - routesModule = await loadRelative("routes"); - routesModule.startTailscaleService(); - ctx.log.info("Tailscale router registered at /tailscale"); -} - -export async function deactivate() { - if (routesModule) { - routesModule.stopTailscaleService(); - routesModule = null; - } -} diff --git a/plugins/tailscale/locales/en.json b/plugins/tailscale/locales/en.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/plugins/tailscale/locales/en.json @@ -0,0 +1 @@ +{} diff --git a/plugins/tailscale/manifest.json b/plugins/tailscale/manifest.json index 714d3c646..1c6c83be5 100644 --- a/plugins/tailscale/manifest.json +++ b/plugins/tailscale/manifest.json @@ -21,8 +21,6 @@ "permissions": ["tailscale.devices.view"] } }, - "backend": "backend/index.mjs", - "frontend": "frontend/index.mjs", "locales": "locales", "platforms": ["linux", "win32", "darwin"] } diff --git a/plugins/tailscale/package.json b/plugins/tailscale/package.json new file mode 100644 index 000000000..9e87cb087 --- /dev/null +++ b/plugins/tailscale/package.json @@ -0,0 +1,15 @@ +{ + "name": "@termix-plugin/tailscale", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "termix-plugin build", + "test": "termix-plugin test", + "typecheck": "tsc -p tsconfig.json", + "validate": "termix-plugin validate" + }, + "devDependencies": { + "@termix/plugin-sdk": "*" + } +} diff --git a/plugins/tailscale/src/backend/index.ts b/plugins/tailscale/src/backend/index.ts new file mode 100644 index 000000000..4f7c5424a --- /dev/null +++ b/plugins/tailscale/src/backend/index.ts @@ -0,0 +1,11 @@ +import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { startTailscaleService, stopTailscaleService } from "./routes.js"; + +export async function activate(ctx: PluginContext) { + startTailscaleService(); + ctx.log.info("Tailscale router registered at /tailscale"); +} + +export async function deactivate() { + stopTailscaleService(); +} diff --git a/plugins/tailscale/backend/routes.ts b/plugins/tailscale/src/backend/routes.ts similarity index 90% rename from plugins/tailscale/backend/routes.ts rename to plugins/tailscale/src/backend/routes.ts index 20ffa4651..df45ac6bf 100644 --- a/plugins/tailscale/backend/routes.ts +++ b/plugins/tailscale/src/backend/routes.ts @@ -4,15 +4,15 @@ // direct reference to the app instance, the same way fleets does. import { Router } from "express"; -import { AuthManager } from "../../../src/backend/utils/auth-manager.js"; -import { PermissionManager } from "../../../src/backend/utils/permission-manager.js"; -import { apiLogger } from "../../../src/backend/utils/logger.js"; -import { fetchWithProxy } from "../../../src/backend/utils/proxy-agent.js"; -import { createCurrentSettingsRepository } from "../../../src/backend/database/repositories/factory.js"; +import { AuthManager } from "../../../../src/backend/utils/auth-manager.js"; +import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js"; +import { apiLogger } from "../../../../src/backend/utils/logger.js"; +import { fetchWithProxy } from "../../../../src/backend/utils/proxy-agent.js"; +import { createCurrentSettingsRepository } from "../../../../src/backend/database/repositories/factory.js"; import { registerTailscaleRouter, unregisterTailscaleRouter, -} from "../../../src/backend/database/routes/tailscale-dispatch.js"; +} from "../../../../src/backend/database/routes/tailscale-dispatch.js"; interface TailscaleDevice { id: string; diff --git a/plugins/tailscale/frontend/AdminTailscaleSection.tsx b/plugins/tailscale/src/frontend/AdminTailscaleSection.tsx similarity index 100% rename from plugins/tailscale/frontend/AdminTailscaleSection.tsx rename to plugins/tailscale/src/frontend/AdminTailscaleSection.tsx diff --git a/plugins/tailscale/frontend/TailscaleCheckDialog.tsx b/plugins/tailscale/src/frontend/TailscaleCheckDialog.tsx similarity index 100% rename from plugins/tailscale/frontend/TailscaleCheckDialog.tsx rename to plugins/tailscale/src/frontend/TailscaleCheckDialog.tsx diff --git a/plugins/tailscale/frontend/TailscaleDevicesPanel.tsx b/plugins/tailscale/src/frontend/TailscaleDevicesPanel.tsx similarity index 98% rename from plugins/tailscale/frontend/TailscaleDevicesPanel.tsx rename to plugins/tailscale/src/frontend/TailscaleDevicesPanel.tsx index 389db1310..06ffcf371 100644 --- a/plugins/tailscale/frontend/TailscaleDevicesPanel.tsx +++ b/plugins/tailscale/src/frontend/TailscaleDevicesPanel.tsx @@ -5,7 +5,7 @@ import { Button } from "@/components/button"; import { Input } from "@/components/input"; import type { Host } from "@/types/ui-types"; import { getTailscaleDevices } from "./tailscale-api"; -import { createQuickConnectHost } from "../../../src/ui/sidebar/quick-connect-host"; +import { createQuickConnectHost } from "../../../../src/ui/sidebar/quick-connect-host"; interface TailscaleDevice { id: string; diff --git a/plugins/tailscale/frontend/index.mjs b/plugins/tailscale/src/frontend/index.mjs similarity index 100% rename from plugins/tailscale/frontend/index.mjs rename to plugins/tailscale/src/frontend/index.mjs diff --git a/plugins/tailscale/frontend/tailscale-api.ts b/plugins/tailscale/src/frontend/tailscale-api.ts similarity index 100% rename from plugins/tailscale/frontend/tailscale-api.ts rename to plugins/tailscale/src/frontend/tailscale-api.ts diff --git a/src/backend/tests/hosts/tailscale-check.test.ts b/plugins/tailscale/tests/backend/tailscale-check.test.ts similarity index 97% rename from src/backend/tests/hosts/tailscale-check.test.ts rename to plugins/tailscale/tests/backend/tailscale-check.test.ts index 730582633..445f697c0 100644 --- a/src/backend/tests/hosts/tailscale-check.test.ts +++ b/plugins/tailscale/tests/backend/tailscale-check.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { parseTailscaleCheckBanner, isTailscaleCheckCompleteBanner, -} from "../../hosts/tailscale-check.js"; +} from "../../../../src/backend/hosts/tailscale-check.js"; describe("parseTailscaleCheckBanner", () => { it("extracts the login URL from a real check-mode banner", () => { diff --git a/src/ui/tests/plugins/tailscale/AdminTailscaleSection.test.tsx b/plugins/tailscale/tests/frontend/AdminTailscaleSection.test.tsx similarity index 94% rename from src/ui/tests/plugins/tailscale/AdminTailscaleSection.test.tsx rename to plugins/tailscale/tests/frontend/AdminTailscaleSection.test.tsx index 64440b039..7ecbc0e08 100644 --- a/src/ui/tests/plugins/tailscale/AdminTailscaleSection.test.tsx +++ b/plugins/tailscale/tests/frontend/AdminTailscaleSection.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { AdminTailscaleSection } from "../../../../../plugins/tailscale/frontend/AdminTailscaleSection"; +import { AdminTailscaleSection } from "../../src/frontend/AdminTailscaleSection"; vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key }), diff --git a/src/ui/tests/plugins/tailscale/TailscaleDevicesPanel.test.tsx b/plugins/tailscale/tests/frontend/TailscaleDevicesPanel.test.tsx similarity index 92% rename from src/ui/tests/plugins/tailscale/TailscaleDevicesPanel.test.tsx rename to plugins/tailscale/tests/frontend/TailscaleDevicesPanel.test.tsx index fcec0904c..fbe626dfd 100644 --- a/src/ui/tests/plugins/tailscale/TailscaleDevicesPanel.test.tsx +++ b/plugins/tailscale/tests/frontend/TailscaleDevicesPanel.test.tsx @@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event"; const getTailscaleDevicesMock = vi.hoisted(() => vi.fn()); -vi.mock("../../../../../plugins/tailscale/frontend/tailscale-api", () => ({ +vi.mock("../../src/frontend/tailscale-api", () => ({ getTailscaleDevices: getTailscaleDevicesMock, })); @@ -12,7 +12,7 @@ vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key }), })); -import { TailscaleDevicesPanel } from "../../../../../plugins/tailscale/frontend/TailscaleDevicesPanel"; +import { TailscaleDevicesPanel } from "../../src/frontend/TailscaleDevicesPanel"; beforeEach(() => { vi.clearAllMocks(); diff --git a/src/ui/tests/plugins/tailscale/tailscale-api.test.ts b/plugins/tailscale/tests/frontend/tailscale-api.test.ts similarity index 93% rename from src/ui/tests/plugins/tailscale/tailscale-api.test.ts rename to plugins/tailscale/tests/frontend/tailscale-api.test.ts index 334b969da..6e1c4dfbe 100644 --- a/src/ui/tests/plugins/tailscale/tailscale-api.test.ts +++ b/plugins/tailscale/tests/frontend/tailscale-api.test.ts @@ -9,7 +9,7 @@ vi.mock("@/main-axios", () => ({ handleApiError: handleApiErrorMock, })); -import { getTailscaleDevices } from "../../../../../plugins/tailscale/frontend/tailscale-api"; +import { getTailscaleDevices } from "../../src/frontend/tailscale-api"; beforeEach(() => { vi.clearAllMocks(); diff --git a/plugins/tailscale/tsconfig.json b/plugins/tailscale/tsconfig.json new file mode 100644 index 000000000..1d3e42542 --- /dev/null +++ b/plugins/tailscale/tsconfig.json @@ -0,0 +1,10 @@ +{ + "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/tailscale/vitest.config.ts b/plugins/tailscale/vitest.config.ts new file mode 100644 index 000000000..31ba004fc --- /dev/null +++ b/plugins/tailscale/vitest.config.ts @@ -0,0 +1,3 @@ +import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset"; + +export default pluginVitestConfig(import.meta.url); diff --git a/plugins/web-endpoint/CHANGELOG.md b/plugins/web-endpoint/CHANGELOG.md new file mode 100644 index 000000000..6a1bc3a37 --- /dev/null +++ b/plugins/web-endpoint/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 1.0.0 + +Bundled with Termix 2.9.0. diff --git a/plugins/web-endpoint/backend/index.mjs b/plugins/web-endpoint/backend/index.mjs deleted file mode 100644 index 227c20e6c..000000000 --- a/plugins/web-endpoint/backend/index.mjs +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Web Endpoint - first-party, in-process plugin. - * - * This plugin runs on the main thread because its one route is a real - * transport consumer: it polls the tunnel manager's live maps - * (activeTunnelRuntimes, connectionStatus, tunnelConnecting), needs the - * plaintext host credentials ctx.hosts deliberately withholds (PluginHostView - * is a 13-field allowlist excluding password, key and passphrase), and hands a - * live ssh2.Client to forwardOut to probe the target. None of that survives - * the structured-clone postMessage boundary a worker plugin talks over. - * - * Unlike every other dispatcher-backed plugin, this one does not register with - * a dispatcher in database.ts: its route is served by the tunnel service on - * port 30003, so the dispatcher lives at - * src/backend/hosts/tunnel/web-endpoint-dispatch.ts instead. Both that service - * and the plugin runtime load into the same backend process, so the - * dispatcher's module-level slot is shared between them. Keeping the URL - * (/ssh/tunnel/web-endpoint/open) byte-identical is also why no nginx change - * was needed -- the generic /ssh/tunnel/ location block already covers it. - * - * Three pieces stay in core rather than moving here: - * - * - The tunnel manager itself (src/backend/hosts/tunnel/manager.ts). It - * serves the whole server-tunnels feature, and the reserved "web:" name - * prefix that exempts these tunnels from the retry machinery is part of - * its own disconnect path. - * - * - host-web-endpoints.ts (the webUiConfig normalizer). Despite its name it - * has no router: it is imported by host.ts, host-bulk-routes.ts and - * host-normalizers.ts to sanitize the column on every host save and list. - * Disabling this plugin must not stop that validation, and core cannot - * import plugin code in any case. - * - * - The Electron half (electron/web-endpoint-window.cjs and the two - * ipcMain handlers in main.cjs). BrowserWindow and session are - * main-process-only, the renderer is what invokes those channels, and the - * main process loads no plugin code at all today. - * - * The enableWebUi/webUiConfig columns stay on core's ssh_data table, declared - * here through contributes.hostCapability -- this plugin system has no - * owned-table migration mechanism of its own. - */ - -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -/** - * Loads a module by its path relative to this file's own directory. - * - * The plugin ships beside dist/backend, so in a built server the compiled .js - * for routes.js sits right next to this .mjs. Under vitest/dev nothing imports - * this plugin directly (the loader loads in-process plugins with a plain - * dynamic import, no tsx loader involved), so the only layout that needs to - * resolve here is the built one. - */ -async function loadRelative(relativePath) { - const here = path.dirname(fileURLToPath(import.meta.url)); - const target = path.join(here, `${relativePath}.js`); - return import(new URL(`file://${target.replace(/\\/g, "/")}`).href); -} - -let routesModule = null; - -export async function activate(ctx) { - routesModule = await loadRelative("routes"); - routesModule.startWebEndpointService(); - ctx.log.info("Web Endpoint router registered at /ssh/tunnel/web-endpoint"); -} - -export async function deactivate() { - if (routesModule) { - routesModule.stopWebEndpointService(); - routesModule = null; - } -} diff --git a/plugins/web-endpoint/locales/en.json b/plugins/web-endpoint/locales/en.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/plugins/web-endpoint/locales/en.json @@ -0,0 +1 @@ +{} diff --git a/plugins/web-endpoint/manifest.json b/plugins/web-endpoint/manifest.json index 042b2d58c..4335d1ca3 100644 --- a/plugins/web-endpoint/manifest.json +++ b/plugins/web-endpoint/manifest.json @@ -35,8 +35,6 @@ "editorTab": "general" } }, - "backend": "backend/index.mjs", - "frontend": "frontend/index.mjs", "locales": "locales", "platforms": ["linux", "win32", "darwin"] } diff --git a/plugins/web-endpoint/package.json b/plugins/web-endpoint/package.json new file mode 100644 index 000000000..06facf8d5 --- /dev/null +++ b/plugins/web-endpoint/package.json @@ -0,0 +1,15 @@ +{ + "name": "@termix-plugin/web-endpoint", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "termix-plugin build", + "test": "termix-plugin test", + "typecheck": "tsc -p tsconfig.json", + "validate": "termix-plugin validate" + }, + "devDependencies": { + "@termix/plugin-sdk": "*" + } +} diff --git a/plugins/web-endpoint/src/backend/index.ts b/plugins/web-endpoint/src/backend/index.ts new file mode 100644 index 000000000..e0812ff9f --- /dev/null +++ b/plugins/web-endpoint/src/backend/index.ts @@ -0,0 +1,11 @@ +import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { startWebEndpointService, stopWebEndpointService } from "./routes.js"; + +export async function activate(ctx: PluginContext) { + startWebEndpointService(); + ctx.log.info("Web Endpoint router registered at /ssh/tunnel/web-endpoint"); +} + +export async function deactivate() { + stopWebEndpointService(); +} diff --git a/plugins/web-endpoint/backend/routes.ts b/plugins/web-endpoint/src/backend/routes.ts similarity index 94% rename from plugins/web-endpoint/backend/routes.ts rename to plugins/web-endpoint/src/backend/routes.ts index 1cb9009ae..02c206abd 100644 --- a/plugins/web-endpoint/backend/routes.ts +++ b/plugins/web-endpoint/src/backend/routes.ts @@ -1,22 +1,22 @@ import express from "express"; import { Client } from "ssh2"; -import { AuthManager } from "../../../src/backend/utils/auth-manager.js"; -import { tunnelLogger } from "../../../src/backend/utils/logger.js"; -import { parseWebUiConfig } from "../../../src/backend/database/routes/host-web-endpoints.js"; +import { AuthManager } from "../../../../src/backend/utils/auth-manager.js"; +import { tunnelLogger } from "../../../../src/backend/utils/logger.js"; +import { parseWebUiConfig } from "../../../../src/backend/database/routes/host-web-endpoints.js"; import { activeTunnelRuntimes, cleanupTunnelResources, connectSSHTunnel, connectionStatus, tunnelConnecting, -} from "../../../src/backend/hosts/tunnel/manager.js"; -import { forwardOut } from "../../../src/backend/hosts/tunnel/ssh-primitives.js"; -import { buildWebEndpointTunnelName } from "../../../src/backend/hosts/tunnel/utils.js"; +} from "../../../../src/backend/hosts/tunnel/manager.js"; +import { forwardOut } from "../../../../src/backend/hosts/tunnel/ssh-primitives.js"; +import { buildWebEndpointTunnelName } from "../../../../src/backend/hosts/tunnel/utils.js"; import { registerWebEndpointRouter, unregisterWebEndpointRouter, -} from "../../../src/backend/hosts/tunnel/web-endpoint-dispatch.js"; -import type { TunnelConfig, WebEndpoint } from "../../../src/types/index.js"; +} from "../../../../src/backend/hosts/tunnel/web-endpoint-dispatch.js"; +import type { TunnelConfig, WebEndpoint } from "../../../../src/types/index.js"; /** Matches the spec's ten minutes. */ const WEB_ENDPOINT_IDLE_TIMEOUT_MS = 10 * 60 * 1000; @@ -151,7 +151,7 @@ export async function handleWebEndpointOpen( // a web deployment. const { resolveHostById } = - await import("../../../src/backend/hosts/host-resolver.js"); + await import("../../../../src/backend/hosts/host-resolver.js"); const host = await resolveHostById(hostId, userId); if (!host) { return res.status(403).json({ error: "Host not found or access denied" }); diff --git a/plugins/web-endpoint/frontend/WebEndpointTab.tsx b/plugins/web-endpoint/src/frontend/WebEndpointTab.tsx similarity index 100% rename from plugins/web-endpoint/frontend/WebEndpointTab.tsx rename to plugins/web-endpoint/src/frontend/WebEndpointTab.tsx diff --git a/plugins/web-endpoint/frontend/index.mjs b/plugins/web-endpoint/src/frontend/index.mjs similarity index 100% rename from plugins/web-endpoint/frontend/index.mjs rename to plugins/web-endpoint/src/frontend/index.mjs diff --git a/plugins/web-endpoint/frontend/web-endpoint-api.ts b/plugins/web-endpoint/src/frontend/web-endpoint-api.ts similarity index 100% rename from plugins/web-endpoint/frontend/web-endpoint-api.ts rename to plugins/web-endpoint/src/frontend/web-endpoint-api.ts diff --git a/plugins/web-endpoint/frontend/web-endpoint-url.ts b/plugins/web-endpoint/src/frontend/web-endpoint-url.ts similarity index 100% rename from plugins/web-endpoint/frontend/web-endpoint-url.ts rename to plugins/web-endpoint/src/frontend/web-endpoint-url.ts diff --git a/plugins/web-endpoint/frontend/web-endpoint-validation.ts b/plugins/web-endpoint/src/frontend/web-endpoint-validation.ts similarity index 100% rename from plugins/web-endpoint/frontend/web-endpoint-validation.ts rename to plugins/web-endpoint/src/frontend/web-endpoint-validation.ts diff --git a/src/backend/tests/database/host-web-endpoint-enumeration.test.ts b/plugins/web-endpoint/tests/backend/host-web-endpoint-enumeration.test.ts similarity index 92% rename from src/backend/tests/database/host-web-endpoint-enumeration.test.ts rename to plugins/web-endpoint/tests/backend/host-web-endpoint-enumeration.test.ts index fae5f1d4e..42aa2d58c 100644 --- a/src/backend/tests/database/host-web-endpoint-enumeration.test.ts +++ b/plugins/web-endpoint/tests/backend/host-web-endpoint-enumeration.test.ts @@ -1,5 +1,6 @@ import { readFileSync } from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; /** @@ -18,8 +19,15 @@ import { describe, expect, it } from "vitest"; * CORRECTLY, only that the file knows it exists. Behavioural coverage lives in * the route tests. */ +// Repo-root relative, resolved from this file rather than cwd: the plugin +// suite runs with the plugin directory as cwd. +const REPO_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../..", +); + function source(relative: string): string { - return readFileSync(path.resolve(relative), "utf8"); + return readFileSync(path.resolve(REPO_ROOT, relative), "utf8"); } const ENUMERATION_POINTS = [ diff --git a/src/backend/tests/database/host-web-endpoints.test.ts b/plugins/web-endpoint/tests/backend/host-web-endpoints.test.ts similarity index 99% rename from src/backend/tests/database/host-web-endpoints.test.ts rename to plugins/web-endpoint/tests/backend/host-web-endpoints.test.ts index 5c826478a..ead89d7f6 100644 --- a/src/backend/tests/database/host-web-endpoints.test.ts +++ b/plugins/web-endpoint/tests/backend/host-web-endpoints.test.ts @@ -4,7 +4,7 @@ import { normalizeWebEndpoints, parseWebUiConfig, serializeWebUiConfig, -} from "../../database/routes/host-web-endpoints.js"; +} from "../../../../src/backend/database/routes/host-web-endpoints.js"; function valid(overrides: Record = {}) { return { diff --git a/src/backend/tests/plugins/web-endpoint/routes.test.ts b/plugins/web-endpoint/tests/backend/routes.test.ts similarity index 88% rename from src/backend/tests/plugins/web-endpoint/routes.test.ts rename to plugins/web-endpoint/tests/backend/routes.test.ts index a7902b7e6..283f81f35 100644 --- a/src/backend/tests/plugins/web-endpoint/routes.test.ts +++ b/plugins/web-endpoint/tests/backend/routes.test.ts @@ -3,7 +3,9 @@ import { readFile } from "node:fs/promises"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const resolveHostById = vi.hoisted(() => vi.fn()); -vi.mock("../../../hosts/host-resolver.js", () => ({ resolveHostById })); +vi.mock("../../../../src/backend/hosts/host-resolver.js", () => ({ + resolveHostById, +})); const manager = vi.hoisted(() => ({ activeTunnelRuntimes: new Map(), @@ -12,12 +14,14 @@ const manager = vi.hoisted(() => ({ connectSSHTunnel: vi.fn(), cleanupTunnelResources: vi.fn(async () => undefined), })); -vi.mock("../../../hosts/tunnel/manager.js", () => manager); +vi.mock("../../../../src/backend/hosts/tunnel/manager.js", () => manager); const forwardOut = vi.hoisted(() => vi.fn(async () => ({ end: vi.fn() }))); -vi.mock("../../../hosts/tunnel/ssh-primitives.js", () => ({ forwardOut })); +vi.mock("../../../../src/backend/hosts/tunnel/ssh-primitives.js", () => ({ + forwardOut, +})); -vi.mock("../../../utils/auth-manager.js", () => ({ +vi.mock("../../../../src/backend/utils/auth-manager.js", () => ({ AuthManager: { getInstance: () => ({ createAuthMiddleware: () => vi.fn() }) }, })); @@ -103,7 +107,7 @@ describe("POST /tunnel/web-endpoint/open", () => { it("refuses a host the user cannot resolve, without opening anything", async () => { resolveHostById.mockResolvedValue(null); const { handleWebEndpointOpen } = - await import("../../../../../plugins/web-endpoint/backend/routes.js"); + await import("../../src/backend/routes.js"); const res = response(); await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res); @@ -116,7 +120,7 @@ describe("POST /tunnel/web-endpoint/open", () => { // stored enable_web_ui is still 0. The UI reads as off in that state. resolveHostById.mockResolvedValue(host({ enableWebUi: false })); const { handleWebEndpointOpen } = - await import("../../../../../plugins/web-endpoint/backend/routes.js"); + await import("../../src/backend/routes.js"); const res = response(); await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res); @@ -126,7 +130,7 @@ describe("POST /tunnel/web-endpoint/open", () => { it("refuses an endpoint id that matches nothing", async () => { const { handleWebEndpointOpen } = - await import("../../../../../plugins/web-endpoint/backend/routes.js"); + await import("../../src/backend/routes.js"); const res = response(); await handleWebEndpointOpen( request({ hostId: 7, endpointId: "nope" }), @@ -138,7 +142,7 @@ describe("POST /tunnel/web-endpoint/open", () => { it("returns the real bound port", async () => { tunnelConnectsOn(41234); const { handleWebEndpointOpen } = - await import("../../../../../plugins/web-endpoint/backend/routes.js"); + await import("../../src/backend/routes.js"); const res = response(); await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res); @@ -152,7 +156,7 @@ describe("POST /tunnel/web-endpoint/open", () => { // select a different strategy and the forward would never bind locally. tunnelConnectsOn(41234); const { handleWebEndpointOpen } = - await import("../../../../../plugins/web-endpoint/backend/routes.js"); + await import("../../src/backend/routes.js"); await handleWebEndpointOpen( request({ hostId: 7, endpointId: "e1" }), response(), @@ -171,7 +175,7 @@ describe("POST /tunnel/web-endpoint/open", () => { it("binds loopback by default and the endpoint's choice when given", async () => { tunnelConnectsOn(41234); const { handleWebEndpointOpen } = - await import("../../../../../plugins/web-endpoint/backend/routes.js"); + await import("../../src/backend/routes.js"); await handleWebEndpointOpen( request({ hostId: 7, endpointId: "e1" }), response(), @@ -200,7 +204,7 @@ describe("POST /tunnel/web-endpoint/open", () => { it("reuses a live tunnel instead of opening a second one", async () => { tunnelConnectsOn(41234); const { handleWebEndpointOpen } = - await import("../../../../../plugins/web-endpoint/backend/routes.js"); + await import("../../src/backend/routes.js"); const first = response(); await handleWebEndpointOpen( request({ hostId: 7, endpointId: "e1" }), @@ -219,7 +223,7 @@ describe("POST /tunnel/web-endpoint/open", () => { it("reopens when the endpoint's target changed under a live tunnel", async () => { tunnelConnectsOn(41234); const { handleWebEndpointOpen } = - await import("../../../../../plugins/web-endpoint/backend/routes.js"); + await import("../../src/backend/routes.js"); await handleWebEndpointOpen( request({ hostId: 7, endpointId: "e1" }), response(), @@ -251,7 +255,7 @@ describe("POST /tunnel/web-endpoint/open", () => { new Error("Channel open failure: connect failed"), ); const { handleWebEndpointOpen } = - await import("../../../../../plugins/web-endpoint/backend/routes.js"); + await import("../../src/backend/routes.js"); const res = response(); await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res); @@ -276,7 +280,7 @@ describe("POST /tunnel/web-endpoint/open", () => { }, ); const { handleWebEndpointOpen } = - await import("../../../../../plugins/web-endpoint/backend/routes.js"); + await import("../../src/backend/routes.js"); const res = response(); await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res); @@ -286,7 +290,7 @@ describe("POST /tunnel/web-endpoint/open", () => { it("rejects a malformed request before touching the database", async () => { const { handleWebEndpointOpen } = - await import("../../../../../plugins/web-endpoint/backend/routes.js"); + await import("../../src/backend/routes.js"); for (const body of [ {}, { hostId: "7", endpointId: "e1" }, @@ -315,10 +319,9 @@ describe("POST /tunnel/web-endpoint/open", () => { */ describe("route registration", () => { it("registers /open on the router the dispatcher forwards to", async () => { - const routes = - await import("../../../../../plugins/web-endpoint/backend/routes.js"); + const routes = await import("../../src/backend/routes.js"); const dispatch = - await import("../../../hosts/tunnel/web-endpoint-dispatch.js"); + await import("../../../../src/backend/hosts/tunnel/web-endpoint-dispatch.js"); const registered: unknown[] = []; vi.spyOn(dispatch, "registerWebEndpointRouter").mockImplementation( (router) => { @@ -341,7 +344,7 @@ describe("route registration", () => { it("is mounted so the full client path resolves", async () => { const source = await readFile( - new URL("../../../hosts/tunnel/index.ts", import.meta.url), + new URL("../../../../src/backend/hosts/tunnel/index.ts", import.meta.url), "utf8", ); diff --git a/src/backend/tests/hosts/web-endpoint-tunnel-manager.test.ts b/plugins/web-endpoint/tests/backend/web-endpoint-tunnel-manager.test.ts similarity index 90% rename from src/backend/tests/hosts/web-endpoint-tunnel-manager.test.ts rename to plugins/web-endpoint/tests/backend/web-endpoint-tunnel-manager.test.ts index 9f15f5836..d68e2fb29 100644 --- a/src/backend/tests/hosts/web-endpoint-tunnel-manager.test.ts +++ b/plugins/web-endpoint/tests/backend/web-endpoint-tunnel-manager.test.ts @@ -20,7 +20,10 @@ const sshPrimitives = vi.hoisted(() => ({ pipeTunnelStreams: vi.fn(), handleSocks5Connect: vi.fn(), })); -vi.mock("../../hosts/tunnel/ssh-primitives.js", () => sshPrimitives); +vi.mock( + "../../../../src/backend/hosts/tunnel/ssh-primitives.js", + () => sshPrimitives, +); function tunnelConfig(overrides: Record = {}) { return { @@ -44,7 +47,8 @@ afterEach(() => { describe("establishDirectTunnel records the port it actually bound", () => { it("reports the kernel-assigned port, never the requested 0", async () => { - const manager = await import("../../hosts/tunnel/manager.js"); + const manager = + await import("../../../../src/backend/hosts/tunnel/manager.js"); await manager.establishDirectTunnel(fakeClient(), tunnelConfig()); const runtime = manager.activeTunnelRuntimes.get("web:7:e1"); @@ -68,7 +72,8 @@ describe("establishDirectTunnel records the port it actually bound", () => { }); it("honours a fixed source port when one is asked for", async () => { - const manager = await import("../../hosts/tunnel/manager.js"); + const manager = + await import("../../../../src/backend/hosts/tunnel/manager.js"); // Borrow a free port, release it, then ask for it explicitly. const probe = net.createServer(); const wanted = await new Promise((resolve) => { @@ -94,7 +99,8 @@ describe("idle close", () => { it("removes the runtime entry, not just the listener", async () => { // Real timers deliberately: establishDirectTunnel awaits real socket I/O, // and mixing fake timers with that is a known route to a hanging test. - const manager = await import("../../hosts/tunnel/manager.js"); + const manager = + await import("../../../../src/backend/hosts/tunnel/manager.js"); await manager.establishDirectTunnel( fakeClient(), tunnelConfig({ name: "web:7:idle", idleTimeoutMs: 60 }), @@ -115,7 +121,8 @@ describe("idle close", () => { it("leaves a tunnel with no idle timeout running", async () => { // Without this, an idle-close bug that fires unconditionally would still // pass the test above. - const manager = await import("../../hosts/tunnel/manager.js"); + const manager = + await import("../../../../src/backend/hosts/tunnel/manager.js"); await manager.establishDirectTunnel( fakeClient(), tunnelConfig({ name: "web:7:forever" }), diff --git a/src/backend/tests/electron/web-endpoint-window.test.ts b/plugins/web-endpoint/tests/backend/web-endpoint-window.test.ts similarity index 100% rename from src/backend/tests/electron/web-endpoint-window.test.ts rename to plugins/web-endpoint/tests/backend/web-endpoint-window.test.ts diff --git a/plugins/web-endpoint/frontend/WebEndpointTab.test.tsx b/plugins/web-endpoint/tests/frontend/WebEndpointTab.test.tsx similarity index 88% rename from plugins/web-endpoint/frontend/WebEndpointTab.test.tsx rename to plugins/web-endpoint/tests/frontend/WebEndpointTab.test.tsx index 1ab50be8a..2b57df513 100644 --- a/plugins/web-endpoint/frontend/WebEndpointTab.test.tsx +++ b/plugins/web-endpoint/tests/frontend/WebEndpointTab.test.tsx @@ -13,11 +13,14 @@ import type { Host } from "@/types/ui-types"; const openWebEndpointTunnel = vi.hoisted(() => vi.fn()); const allowInvalidCertificateForOrigin = vi.hoisted(() => vi.fn()); -vi.mock("./web-endpoint-api", async (importOriginal) => { +vi.mock("../../src/frontend/web-endpoint-api", async (importOriginal) => { // requireNumericHostId is left as the real implementation -- the "no saved // host" case exercises the actual validation, not a re-implementation of it // that could drift. - const actual = await importOriginal(); + const actual = + await importOriginal< + typeof import("../../src/frontend/web-endpoint-api") + >(); return { ...actual, openWebEndpointTunnel: (...args: unknown[]) => @@ -94,7 +97,8 @@ describe("WebEndpointTab", () => { it("refuses unsupported environments before creating a tunnel or iframe", async () => { delete (HTMLIFrameElement.prototype as { credentialless?: boolean }) .credentialless; - const { WebEndpointTab } = await import("./WebEndpointTab"); + const { WebEndpointTab } = + await import("../../src/frontend/WebEndpointTab"); render( { expect(document.querySelector("iframe")).toBeNull(); }); it("isolates cookie state and denies parent access, popups and top navigation", async () => { - const { WebEndpointTab } = await import("./WebEndpointTab"); + const { WebEndpointTab } = + await import("../../src/frontend/WebEndpointTab"); render(); const frame = (await screen.findByTitle("Proxmox")) as HTMLIFrameElement; expect( @@ -118,7 +123,8 @@ describe("WebEndpointTab", () => { }); it("renders an iframe at the direct URL", async () => { - const { WebEndpointTab } = await import("./WebEndpointTab"); + const { WebEndpointTab } = + await import("../../src/frontend/WebEndpointTab"); render(); await waitFor(() => expect(screen.getByTitle("Proxmox")).toHaveAttribute( @@ -130,7 +136,8 @@ describe("WebEndpointTab", () => { it("opens the tunnel through the numeric host id and frames the loopback URL", async () => { openWebEndpointTunnel.mockResolvedValue(41234); - const { WebEndpointTab } = await import("./WebEndpointTab"); + const { WebEndpointTab } = + await import("../../src/frontend/WebEndpointTab"); render( { }); it("registers the certificate allowance for a direct endpoint with ignoreCert", async () => { - const { WebEndpointTab } = await import("./WebEndpointTab"); + const { WebEndpointTab } = + await import("../../src/frontend/WebEndpointTab"); render( { openWebEndpointTunnel .mockResolvedValueOnce(41234) .mockResolvedValueOnce(51234); - const { WebEndpointTab } = await import("./WebEndpointTab"); + const { WebEndpointTab } = + await import("../../src/frontend/WebEndpointTab"); render( { */ it("remounts the frame on reload even when the resolved URL is unchanged", async () => { openWebEndpointTunnel.mockResolvedValue(41234); - const { WebEndpointTab } = await import("./WebEndpointTab"); + const { WebEndpointTab } = + await import("../../src/frontend/WebEndpointTab"); render( { openWebEndpointTunnel.mockRejectedValue( new Error("Timed out reaching the endpoint port"), ); - const { WebEndpointTab } = await import("./WebEndpointTab"); + const { WebEndpointTab } = + await import("../../src/frontend/WebEndpointTab"); render( { }); it("shows a plain message when the endpoint id matches nothing on the host", async () => { - const { WebEndpointTab } = await import("./WebEndpointTab"); + const { WebEndpointTab } = + await import("../../src/frontend/WebEndpointTab"); render(); await waitFor(() => expect(screen.getByText("webEndpoint.notFound")).toBeInTheDocument(), @@ -251,7 +263,8 @@ describe("WebEndpointTab", () => { }); it("explains that a tunnel needs a saved host, instead of calling the route", async () => { - const { WebEndpointTab } = await import("./WebEndpointTab"); + const { WebEndpointTab } = + await import("../../src/frontend/WebEndpointTab"); render( { it("resolves a direct endpoint normally on the same quick-connect host", async () => { // The positive case: requireNumericHostId must be consulted only on the // tunnel branch, or direct endpoints would break on unsaved hosts too. - const { WebEndpointTab } = await import("./WebEndpointTab"); + const { WebEndpointTab } = + await import("../../src/frontend/WebEndpointTab"); render( { // session cookie. electron.isElectron.mockReturnValue(false); openWebEndpointTunnel.mockResolvedValue(41234); - const { WebEndpointTab } = await import("./WebEndpointTab"); + const { WebEndpointTab } = + await import("../../src/frontend/WebEndpointTab"); render( { it("refuses a loopback-bound tunnel in a browser instead of framing a dead port", async () => { electron.isElectron.mockReturnValue(false); - const { WebEndpointTab } = await import("./WebEndpointTab"); + const { WebEndpointTab } = + await import("../../src/frontend/WebEndpointTab"); render( { it("refuses when the page host has no separate loopback spelling", async () => { electron.isElectron.mockReturnValue(false); pageHostname = "termix.example.com"; - const { WebEndpointTab } = await import("./WebEndpointTab"); + const { WebEndpointTab } = + await import("../../src/frontend/WebEndpointTab"); render( { it("refuses a direct endpoint on the same host that serves Termix", async () => { electron.isElectron.mockReturnValue(false); pageHostname = "termix.example"; - const { WebEndpointTab } = await import("./WebEndpointTab"); + const { WebEndpointTab } = + await import("../../src/frontend/WebEndpointTab"); render( { it("refuses a direct endpoint on a sub domain of the page host", async () => { electron.isElectron.mockReturnValue(false); pageHostname = "termix.example.com"; - const { WebEndpointTab } = await import("./WebEndpointTab"); + const { WebEndpointTab } = + await import("../../src/frontend/WebEndpointTab"); render( { it("still frames a direct same-host endpoint on the desktop, whose jar has no jwt", async () => { electron.isElectron.mockReturnValue(true); pageHostname = "termix.example"; - const { WebEndpointTab } = await import("./WebEndpointTab"); + const { WebEndpointTab } = + await import("../../src/frontend/WebEndpointTab"); render( { it("still frames a DIRECT endpoint on a hostname that refuses tunnels", async () => { electron.isElectron.mockReturnValue(false); pageHostname = "termix.example.com"; - const { WebEndpointTab } = await import("./WebEndpointTab"); + const { WebEndpointTab } = + await import("../../src/frontend/WebEndpointTab"); render(); await waitFor(() => expect(screen.getByTitle("Proxmox")).toHaveAttribute( diff --git a/plugins/web-endpoint/frontend/web-endpoint-api.test.ts b/plugins/web-endpoint/tests/frontend/web-endpoint-api.test.ts similarity index 87% rename from plugins/web-endpoint/frontend/web-endpoint-api.test.ts rename to plugins/web-endpoint/tests/frontend/web-endpoint-api.test.ts index a352be07d..747f3f9f7 100644 --- a/plugins/web-endpoint/frontend/web-endpoint-api.test.ts +++ b/plugins/web-endpoint/tests/frontend/web-endpoint-api.test.ts @@ -59,7 +59,8 @@ describe("openWebEndpointTunnel", () => { // resolve to /ssh/ssh/... and 404 on every call. Asserted on the captured // runtime argument rather than by scanning source, so quoting style and // indirection cannot fool it. - const { openWebEndpointTunnel } = await import("./web-endpoint-api"); + const { openWebEndpointTunnel } = + await import("../../src/frontend/web-endpoint-api"); await openWebEndpointTunnel(7, "e1"); expect(tunnelPost.mock.calls[0][0]).not.toMatch(/^\/ssh\//); @@ -70,7 +71,8 @@ describe("openWebEndpointTunnel", () => { }); it("returns the port", async () => { - const { openWebEndpointTunnel } = await import("./web-endpoint-api"); + const { openWebEndpointTunnel } = + await import("../../src/frontend/web-endpoint-api"); await expect(openWebEndpointTunnel(7, "e1")).resolves.toBe(41234); }); @@ -87,7 +89,7 @@ describe("openWebEndpointTunnel", () => { }); tunnelPost.mockRejectedValue(axiosError); const { openWebEndpointTunnel, WebEndpointTunnelError } = - await import("./web-endpoint-api"); + await import("../../src/frontend/web-endpoint-api"); await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow( /Timed out reaching the endpoint port/, @@ -106,20 +108,23 @@ describe("openWebEndpointTunnel", () => { response: { status: 500, data: { error: { nested: true } } }, }), ); - const { openWebEndpointTunnel } = await import("./web-endpoint-api"); + const { openWebEndpointTunnel } = + await import("../../src/frontend/web-endpoint-api"); await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow(/generic:/); }); it("treats a missing port as a failure rather than returning undefined", async () => { tunnelPost.mockResolvedValue({ data: {} }); - const { openWebEndpointTunnel } = await import("./web-endpoint-api"); + const { openWebEndpointTunnel } = + await import("../../src/frontend/web-endpoint-api"); await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow(/no port/); }); }); describe("requireNumericHostId", () => { it("accepts a saved host id and rejects a quick-connect one", async () => { - const { requireNumericHostId } = await import("./web-endpoint-api"); + const { requireNumericHostId } = + await import("../../src/frontend/web-endpoint-api"); expect(requireNumericHostId("7")).toBe(7); for (const bad of ["quick-connect-1", "", "0", "-3", "abc"]) { expect(() => requireNumericHostId(bad)).toThrow(/saved host/); @@ -134,7 +139,8 @@ describe("requireNumericHostId", () => { */ describe("openWebEndpointExternally", () => { it("refuses shared-cookie browser windows before opening a tunnel", async () => { - const { openWebEndpointExternally } = await import("./web-endpoint-api"); + const { openWebEndpointExternally } = + await import("../../src/frontend/web-endpoint-api"); await expect(openWebEndpointExternally(host, endpoint())).rejects.toThrow( /desktop app/, ); @@ -148,7 +154,8 @@ describe("openWebEndpointExternally", () => { configurable: true, value: { invoke }, }); - const { openWebEndpointExternally } = await import("./web-endpoint-api"); + const { openWebEndpointExternally } = + await import("../../src/frontend/web-endpoint-api"); await openWebEndpointExternally(host, endpoint({ ignoreCert: true })); expect(invoke).toHaveBeenCalledWith("open-isolated-web-endpoint", { url: "https://127.0.0.1:41234/", diff --git a/plugins/web-endpoint/frontend/web-endpoint-url.test.ts b/plugins/web-endpoint/tests/frontend/web-endpoint-url.test.ts similarity index 99% rename from plugins/web-endpoint/frontend/web-endpoint-url.test.ts rename to plugins/web-endpoint/tests/frontend/web-endpoint-url.test.ts index 87e3dc6bb..b58a680a7 100644 --- a/plugins/web-endpoint/frontend/web-endpoint-url.test.ts +++ b/plugins/web-endpoint/tests/frontend/web-endpoint-url.test.ts @@ -5,7 +5,7 @@ import { separatedTunnelHost, sharesCookieSiteWithPage, webEndpointRefusalReason, -} from "./web-endpoint-url"; +} from "../../src/frontend/web-endpoint-url"; import type { WebEndpoint } from "@/types/index"; function endpoint(overrides: Partial = {}): WebEndpoint { diff --git a/plugins/web-endpoint/frontend/web-endpoint-validation.test.ts b/plugins/web-endpoint/tests/frontend/web-endpoint-validation.test.ts similarity index 96% rename from plugins/web-endpoint/frontend/web-endpoint-validation.test.ts rename to plugins/web-endpoint/tests/frontend/web-endpoint-validation.test.ts index 905cf77cd..3bff5ce14 100644 --- a/plugins/web-endpoint/frontend/web-endpoint-validation.test.ts +++ b/plugins/web-endpoint/tests/frontend/web-endpoint-validation.test.ts @@ -5,8 +5,8 @@ import { isWebEndpointPortValid, webEndpointErrorKey, webEndpointRowError, -} from "./web-endpoint-validation"; -import { normalizeWebEndpoints } from "../../../src/backend/database/routes/host-web-endpoints.js"; +} from "../../src/frontend/web-endpoint-validation"; +import { normalizeWebEndpoints } from "../../../../src/backend/database/routes/host-web-endpoints.js"; import type { WebEndpoint } from "@/types/index"; function endpoint(overrides: Partial = {}): WebEndpoint { diff --git a/plugins/web-endpoint/tsconfig.json b/plugins/web-endpoint/tsconfig.json new file mode 100644 index 000000000..1d3e42542 --- /dev/null +++ b/plugins/web-endpoint/tsconfig.json @@ -0,0 +1,10 @@ +{ + "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/web-endpoint/vitest.config.ts b/plugins/web-endpoint/vitest.config.ts new file mode 100644 index 000000000..31ba004fc --- /dev/null +++ b/plugins/web-endpoint/vitest.config.ts @@ -0,0 +1,3 @@ +import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset"; + +export default pluginVitestConfig(import.meta.url); diff --git a/plugins/workspaces/CHANGELOG.md b/plugins/workspaces/CHANGELOG.md new file mode 100644 index 000000000..6a1bc3a37 --- /dev/null +++ b/plugins/workspaces/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 1.0.0 + +Bundled with Termix 2.9.0. diff --git a/plugins/workspaces/README.md b/plugins/workspaces/README.md new file mode 100644 index 000000000..46ec7f3b7 --- /dev/null +++ b/plugins/workspaces/README.md @@ -0,0 +1,22 @@ +# Workspaces + +Save and restore named tab/split layouts per user. + +Bundled with Termix and enabled by default. + +## Layout + + manifest.json id, capabilities and what this plugin contributes + src/backend/ activate(ctx) / deactivate(), plus the routes + src/frontend/ the UI it registers + locales/en.json its strings (English only; the rest are translated) + tests/backend/ vitest, node + tests/frontend/ vitest, jsdom + +## Commands + + npm run build bundle into dist/ + npm run test run this plugin's tests + npm run typecheck + +See packages/plugin-sdk/ARCHITECTURE.md for the contract this follows. diff --git a/plugins/workspaces/backend/index.mjs b/plugins/workspaces/backend/index.mjs deleted file mode 100644 index 63ab2a5b7..000000000 --- a/plugins/workspaces/backend/index.mjs +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Workspaces - first-party, in-process plugin. - * - * Unlike the other first-party plugins (see src/backend/plugins/first-party.ts), - * this one does not own a transport or touch the shared SSH pool -- its routes - * are plain per-user JSON CRUD against a single repository. It runs in-process - * anyway to keep its existing /workspaces URL and its existing dedicated table - * (user_workspaces, keyed by userId) rather than moving onto - * /plugin-api/workspaces/* and the generic key-value ctx.storage the worker - * tier would require, matching how network-topology was migrated in place - * rather than reworked around the worker ctx boundary. - * - * Like network-topology, this plugin does not get its own port -- it - * registers its Express router with the always-mounted /workspaces - * dispatcher in src/backend/database/routes/workspace-dispatch.ts. - * - * routes.ts is what used to be src/backend/database/routes/workspaces.ts, - * moved here and rewritten from `export default router` into - * startWorkspacesService()/stopWorkspacesService(), called from - * activate()/deactivate() below. The user_workspaces table and its - * repository stay in core (src/backend/database/db/schema.ts, - * src/backend/database/repositories/workspace-repository.ts) -- this - * plugin system has no owned-table migration mechanism of its own, and the - * table carries an FK into core's users table. - */ - -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -/** - * Loads a module by its path relative to this file's own directory. - * - * The plugin ships beside dist/backend, so in a built server the compiled - * .js for routes.js sits right next to this .mjs. Under vitest/dev nothing - * imports this plugin directly (the loader loads in-process plugins with a - * plain dynamic import, no tsx loader involved), so the only layout that - * needs to resolve here is the built one. - */ -async function loadRelative(relativePath) { - const here = path.dirname(fileURLToPath(import.meta.url)); - const target = path.join(here, `${relativePath}.js`); - return import(new URL(`file://${target.replace(/\\/g, "/")}`).href); -} - -let routesModule = null; - -export async function activate(ctx) { - routesModule = await loadRelative("routes"); - routesModule.startWorkspacesService(); - ctx.log.info("Workspaces router registered at /workspaces"); -} - -export async function deactivate() { - if (routesModule) { - routesModule.stopWorkspacesService(); - routesModule = null; - } -} diff --git a/plugins/workspaces/locales/en.json b/plugins/workspaces/locales/en.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/plugins/workspaces/locales/en.json @@ -0,0 +1 @@ +{} diff --git a/plugins/workspaces/manifest.json b/plugins/workspaces/manifest.json index 42630c160..e2a6b94d7 100644 --- a/plugins/workspaces/manifest.json +++ b/plugins/workspaces/manifest.json @@ -15,8 +15,6 @@ "api": "1" }, "capabilities": ["db:own", "ui:surface"], - "backend": "backend/index.mjs", - "frontend": "frontend/index.mjs", "locales": "locales", "platforms": ["linux", "win32", "darwin"] } diff --git a/plugins/workspaces/package.json b/plugins/workspaces/package.json new file mode 100644 index 000000000..78681f432 --- /dev/null +++ b/plugins/workspaces/package.json @@ -0,0 +1,15 @@ +{ + "name": "@termix-plugin/workspaces", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "termix-plugin build", + "test": "termix-plugin test", + "typecheck": "tsc -p tsconfig.json", + "validate": "termix-plugin validate" + }, + "devDependencies": { + "@termix/plugin-sdk": "*" + } +} diff --git a/plugins/workspaces/src/backend/index.ts b/plugins/workspaces/src/backend/index.ts new file mode 100644 index 000000000..ae61b0f64 --- /dev/null +++ b/plugins/workspaces/src/backend/index.ts @@ -0,0 +1,11 @@ +import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { startWorkspacesService, stopWorkspacesService } from "./routes.js"; + +export async function activate(ctx: PluginContext) { + startWorkspacesService(); + ctx.log.info("Workspaces router registered at /workspaces"); +} + +export async function deactivate() { + stopWorkspacesService(); +} diff --git a/plugins/workspaces/backend/routes.ts b/plugins/workspaces/src/backend/routes.ts similarity index 97% rename from plugins/workspaces/backend/routes.ts rename to plugins/workspaces/src/backend/routes.ts index 2b26e511e..15e431a2d 100644 --- a/plugins/workspaces/backend/routes.ts +++ b/plugins/workspaces/src/backend/routes.ts @@ -1,13 +1,13 @@ -import type { AuthenticatedRequest } from "../../../src/types/index.js"; +import type { AuthenticatedRequest } from "../../../../src/types/index.js"; import express, { type Request, type Response } from "express"; -import { databaseLogger } from "../../../src/backend/utils/logger.js"; -import { AuthManager } from "../../../src/backend/utils/auth-manager.js"; -import { createCurrentWorkspaceRepository } from "../../../src/backend/database/repositories/factory.js"; -import type { WorkspaceRecord } from "../../../src/backend/database/repositories/workspace-repository.js"; +import { databaseLogger } from "../../../../src/backend/utils/logger.js"; +import { AuthManager } from "../../../../src/backend/utils/auth-manager.js"; +import { createCurrentWorkspaceRepository } from "../../../../src/backend/database/repositories/factory.js"; +import type { WorkspaceRecord } from "../../../../src/backend/database/repositories/workspace-repository.js"; import { registerWorkspacesRouter, unregisterWorkspacesRouter, -} from "../../../src/backend/database/routes/workspace-dispatch.js"; +} from "../../../../src/backend/database/routes/workspace-dispatch.js"; export const router = express.Router(); diff --git a/plugins/workspaces/frontend/WorkspacesPanel.tsx b/plugins/workspaces/src/frontend/WorkspacesPanel.tsx similarity index 100% rename from plugins/workspaces/frontend/WorkspacesPanel.tsx rename to plugins/workspaces/src/frontend/WorkspacesPanel.tsx diff --git a/plugins/workspaces/frontend/index.mjs b/plugins/workspaces/src/frontend/index.mjs similarity index 100% rename from plugins/workspaces/frontend/index.mjs rename to plugins/workspaces/src/frontend/index.mjs diff --git a/plugins/workspaces/frontend/workspaceUtils.ts b/plugins/workspaces/src/frontend/workspaceUtils.ts similarity index 100% rename from plugins/workspaces/frontend/workspaceUtils.ts rename to plugins/workspaces/src/frontend/workspaceUtils.ts diff --git a/plugins/workspaces/frontend/workspaces-api.ts b/plugins/workspaces/src/frontend/workspaces-api.ts similarity index 100% rename from plugins/workspaces/frontend/workspaces-api.ts rename to plugins/workspaces/src/frontend/workspaces-api.ts diff --git a/src/backend/tests/database/repositories/workspace-repository.test.ts b/plugins/workspaces/tests/backend/repositories/workspace-repository.test.ts similarity index 96% rename from src/backend/tests/database/repositories/workspace-repository.test.ts rename to plugins/workspaces/tests/backend/repositories/workspace-repository.test.ts index e334e1474..1403e464a 100644 --- a/src/backend/tests/database/repositories/workspace-repository.test.ts +++ b/plugins/workspaces/tests/backend/repositories/workspace-repository.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; -import { TestSqliteDatabase } from "./test-support.js"; -import { WorkspaceRepository } from "../../../database/repositories/workspace-repository.js"; +import { TestSqliteDatabase } from "../../../../../src/backend/tests/database/repositories/test-support.js"; +import { WorkspaceRepository } from "../../../../../src/backend/database/repositories/workspace-repository.js"; describe("WorkspaceRepository", () => { let adapter: TestSqliteDatabase | null = null; diff --git a/src/backend/tests/plugins/workspaces/routes.test.ts b/plugins/workspaces/tests/backend/routes.test.ts similarity index 96% rename from src/backend/tests/plugins/workspaces/routes.test.ts rename to plugins/workspaces/tests/backend/routes.test.ts index dacd2c629..0eef1f48f 100644 --- a/src/backend/tests/plugins/workspaces/routes.test.ts +++ b/plugins/workspaces/tests/backend/routes.test.ts @@ -22,9 +22,9 @@ const state = vi.hoisted(() => ({ nextId: 1, })); -vi.mock("../../../database/db/index.js", () => ({ db: {} })); +vi.mock("../../../../src/backend/database/db/index.js", () => ({ db: {} })); -vi.mock("../../../utils/logger.js", () => ({ +vi.mock("../../../../src/backend/utils/logger.js", () => ({ databaseLogger: { error: vi.fn(), warn: vi.fn(), @@ -33,7 +33,7 @@ vi.mock("../../../utils/logger.js", () => ({ }, })); -vi.mock("../../../utils/auth-manager.js", () => ({ +vi.mock("../../../../src/backend/utils/auth-manager.js", () => ({ AuthManager: { getInstance: () => ({ createAuthMiddleware: @@ -54,7 +54,7 @@ function findByIdForUser(userId: string, id: number): WorkspaceRow | null { return row && row.userId === userId ? row : null; } -vi.mock("../../../database/repositories/factory.js", () => ({ +vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({ createCurrentWorkspaceRepository: () => ({ listByUser: async (userId: string) => [...state.workspaces.values()].filter((w) => w.userId === userId), @@ -161,13 +161,15 @@ vi.mock("../../../database/repositories/factory.js", () => ({ }), })); -vi.mock("../../../database/routes/workspace-dispatch.js", () => ({ - registerWorkspacesRouter: vi.fn(), - unregisterWorkspacesRouter: vi.fn(), -})); +vi.mock( + "../../../../src/backend/database/routes/workspace-dispatch.js", + () => ({ + registerWorkspacesRouter: vi.fn(), + unregisterWorkspacesRouter: vi.fn(), + }), +); -const { router } = - await import("../../../../../plugins/workspaces/backend/routes.js"); +const { router } = await import("../../src/backend/routes.js"); function findLayer(method: string, path: string) { const stack = (router as unknown as Router).stack as Array<{ diff --git a/src/ui/tests/plugins/workspaces/workspaceUtils.test.ts b/plugins/workspaces/tests/frontend/workspaceUtils.test.ts similarity index 99% rename from src/ui/tests/plugins/workspaces/workspaceUtils.test.ts rename to plugins/workspaces/tests/frontend/workspaceUtils.test.ts index 00b8c8afc..31abaa094 100644 --- a/src/ui/tests/plugins/workspaces/workspaceUtils.test.ts +++ b/plugins/workspaces/tests/frontend/workspaceUtils.test.ts @@ -4,7 +4,7 @@ import { buildWorkspaceTabSnapshots, remapSlotIds, resolveWorkspaceTabTarget, -} from "../../../../../plugins/workspaces/frontend/workspaceUtils"; +} from "../../src/frontend/workspaceUtils"; import type { Host, Tab, WorkspaceTabSnapshot } from "@/types/ui-types"; afterEach(() => vi.unstubAllGlobals()); diff --git a/src/ui/tests/plugins/workspaces/workspaces-api.test.ts b/plugins/workspaces/tests/frontend/workspaces-api.test.ts similarity index 98% rename from src/ui/tests/plugins/workspaces/workspaces-api.test.ts rename to plugins/workspaces/tests/frontend/workspaces-api.test.ts index 9be63eca2..61b3ea2c4 100644 --- a/src/ui/tests/plugins/workspaces/workspaces-api.test.ts +++ b/plugins/workspaces/tests/frontend/workspaces-api.test.ts @@ -24,7 +24,7 @@ import { applyWorkspaceServer, getLastSessionWorkspace, saveLastSessionWorkspace, -} from "../../../../../plugins/workspaces/frontend/workspaces-api"; +} from "../../src/frontend/workspaces-api"; import type { WorkspacePayload } from "@/types/ui-types"; beforeEach(() => { diff --git a/plugins/workspaces/tsconfig.json b/plugins/workspaces/tsconfig.json new file mode 100644 index 000000000..1d3e42542 --- /dev/null +++ b/plugins/workspaces/tsconfig.json @@ -0,0 +1,10 @@ +{ + "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/workspaces/vitest.config.ts b/plugins/workspaces/vitest.config.ts new file mode 100644 index 000000000..31ba004fc --- /dev/null +++ b/plugins/workspaces/vitest.config.ts @@ -0,0 +1,3 @@ +import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset"; + +export default pluginVitestConfig(import.meta.url); diff --git a/scripts/build-plugins.cjs b/scripts/build-plugins.cjs new file mode 100644 index 000000000..55c19bb62 --- /dev/null +++ b/scripts/build-plugins.cjs @@ -0,0 +1,58 @@ +/** + * Builds every plugin workspace, then stages the result in dist/plugins. + * + * getBundledPluginsDir() in src/backend/plugins/paths.ts resolves + * dist/backend/backend/plugins -> dist/plugins, which is where this writes. + * Only what a server needs at runtime is copied: the manifest, the built + * bundles, locales and migrations. Source and tests stay out of the image. + */ + +const fs = require("node:fs"); +const path = require("node:path"); +const { execFileSync } = require("node:child_process"); + +const root = path.resolve(__dirname, ".."); +const source = path.join(root, "plugins"); +const destination = path.join(root, "dist", "plugins"); +const cli = path.join(root, "packages", "plugin-sdk", "cli", "index.mjs"); + +const SHIPPED = ["manifest.json", "dist", "locales", "migrations", "README.md"]; + +if (!fs.existsSync(source)) { + console.log("No plugins/ directory, nothing to bundle."); + process.exit(0); +} + +const pluginIds = fs + .readdirSync(source, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .filter((entry) => + fs.existsSync(path.join(source, entry.name, "manifest.json")), + ) + .map((entry) => entry.name); + +fs.rmSync(destination, { recursive: true, force: true }); + +for (const id of pluginIds) { + const pluginDir = path.join(source, id); + + execFileSync(process.execPath, [cli, "build"], { + cwd: pluginDir, + stdio: "inherit", + }); + + const built = path.join(pluginDir, "dist", "backend.js"); + if (!fs.existsSync(built)) { + throw new Error(`${id} produced no dist/backend.js`); + } + + const outDir = path.join(destination, id); + fs.mkdirSync(outDir, { recursive: true }); + for (const entry of SHIPPED) { + const from = path.join(pluginDir, entry); + if (!fs.existsSync(from)) continue; + fs.cpSync(from, path.join(outDir, entry), { recursive: true }); + } +} + +console.log(`Bundled ${pluginIds.length} plugin(s): ${pluginIds.join(", ")}`); diff --git a/scripts/check-plugin-boundaries.cjs b/scripts/check-plugin-boundaries.cjs new file mode 100644 index 000000000..0ffbffc0d --- /dev/null +++ b/scripts/check-plugin-boundaries.cjs @@ -0,0 +1,120 @@ +/** + * Counts the import-boundary debt between core and plugins, and fails when it + * grows. + * + * The eslint fence marks these directions as warnings because there is nowhere + * for them to go yet (see packages/plugin-sdk/ARCHITECTURE.md). A warning does + * not fail a build, so this script holds the line instead: every offender is + * listed in plugin-boundary-allowlist.json, a new one fails, and an entry that + * stopped being an offender fails too, so the list shrinks as B steps land + * rather than rotting. D1 empties it and the eslint rules become errors. + */ + +const fs = require("node:fs"); +const path = require("node:path"); + +const root = path.resolve(__dirname, ".."); +const allowlistPath = path.join( + root, + "scripts", + "plugin-boundary-allowlist.json", +); +const norm = (p) => p.split(path.sep).join("/"); + +const DIRECTIONS = { + // The shell importing plugin components. A7 empties this. + "ui-to-plugin": { + roots: ["src/ui"], + skip: (rel) => rel.includes("/tests/"), + pattern: /["'][^"']*\/plugins\/[a-z0-9-]+\/src\/(frontend|backend)\//, + }, + // A plugin backend reaching core by relative path. D1 empties this. + "plugin-to-core": { + roots: ["plugins"], + only: (rel) => /^plugins\/[a-z0-9-]+\/src\//.test(rel), + pattern: /["'](?:\.\.\/)+src\/(backend|types)\//, + }, + // One plugin importing another's source. B18 empties this. + "plugin-to-plugin": { + roots: ["plugins"], + only: (rel) => /^plugins\/[a-z0-9-]+\/src\//.test(rel), + pattern: /["'](?:\.\.\/)+(?!src\/)[a-z0-9-]+\/src\/(backend|frontend)\//, + }, +}; + +function walk(dir, out = []) { + if (!fs.existsSync(dir)) return out; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === "dist") continue; + walk(full, out); + } else if (/\.(ts|tsx|mjs)$/.test(entry.name)) { + out.push(full); + } + } + return out; +} + +function offendersFor(name, config) { + const found = new Set(); + for (const base of config.roots) { + for (const file of walk(path.join(root, base))) { + const rel = norm(path.relative(root, file)); + if (config.only && !config.only(rel)) continue; + if (config.skip && config.skip(rel)) continue; + const source = fs.readFileSync(file, "utf8"); + if (config.pattern.test(source)) found.add(rel); + } + } + return [...found].sort(); +} + +const allowlist = fs.existsSync(allowlistPath) + ? JSON.parse(fs.readFileSync(allowlistPath, "utf8")) + : {}; + +if (process.argv.includes("--write")) { + const next = {}; + for (const [name, config] of Object.entries(DIRECTIONS)) { + next[name] = offendersFor(name, config); + } + fs.writeFileSync(allowlistPath, JSON.stringify(next, null, 2) + "\n"); + console.log("Wrote the allowlist from the current tree."); + process.exit(0); +} + +let failed = false; + +for (const [name, config] of Object.entries(DIRECTIONS)) { + const offenders = offendersFor(name, config); + const allowed = new Set(allowlist[name] ?? []); + + const added = offenders.filter((file) => !allowed.has(file)); + const stale = [...allowed].filter((file) => !offenders.includes(file)); + + if (added.length > 0) { + failed = true; + console.error(`\n${name}: ${added.length} new violation(s):`); + for (const file of added) console.error(` ${file}`); + } + + if (stale.length > 0) { + failed = true; + console.error( + `\n${name}: ${stale.length} allowlist entry(ies) no longer violate. Remove them:`, + ); + for (const file of stale) console.error(` ${file}`); + } + + if (added.length === 0 && stale.length === 0) { + console.log(`${name}: ${offenders.length} remaining (allowlisted)`); + } +} + +if (failed) { + console.error( + "\nRun `node scripts/check-plugin-boundaries.cjs --write` if the change is intentional.", + ); + process.exit(1); +} diff --git a/scripts/copy-bundled-plugins.cjs b/scripts/copy-bundled-plugins.cjs deleted file mode 100644 index 34b239b06..000000000 --- a/scripts/copy-bundled-plugins.cjs +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Compiles plugin backend TypeScript, then copies plugins/ into dist/plugins - * so bundled first-party plugins ship with a built server. - * - * Most plugin backend entries are hand-written .mjs plus a manifest.json -- - * tsc only emits .ts, so without a copy step the plugins directory simply - * would not exist in dist and the loader would find nothing. - * - * docker, host-metrics and remote-desktop are the exception: their backends - * are real TypeScript, physically relocated from src/backend/hosts/docker/, - * src/backend/hosts/metrics/ and src/backend/hosts/guacamole/ rather than - * kept as hand-written JS, because each is thousands of lines of typed - * SSH/session (or, for remote-desktop, guacd protocol) logic that is not - * worth hand-transpiling. tsconfig.plugins.json compiles each plugin backend's - * TypeScript to a sibling .js next to its source. Their imports into core - * (e.g. "../../../src/backend/utils/logger.js") are written relative to the - * TypeScript SOURCE tree so tsc can type-check them, since plugins/ is not - * under tsconfig.node.json's rootDir and cannot be added to it without - * changing every existing dist/backend/backend/... path in the codebase. - * That means the emitted .js still points at src/backend/, which does not - * exist in a built server -- only its compiled counterpart at - * dist/backend/backend/ does. rewriteCoreImports() below corrects that one - * prefix after compilation, for every plugins/*\/backend directory tsc - * compiled, before the directory is copied into dist/. - * - * getBundledPluginsDir() in src/backend/plugins/paths.ts resolves - * dist/backend/backend/plugins -> dist/plugins, which is where this writes. - */ - -const fs = require("node:fs"); -const path = require("node:path"); -const { execSync } = require("node:child_process"); - -const root = path.resolve(__dirname, ".."); -const source = path.join(root, "plugins"); -const destination = path.join(root, "dist", "plugins"); - -if (!fs.existsSync(source)) { - console.log("No plugins/ directory, nothing to bundle."); - process.exit(0); -} - -const SRC_BACKEND_PREFIX = "../../../src/backend/"; -const COMPILED_BACKEND_PREFIX = "../../../backend/backend/"; - -function compilePluginBackends() { - execSync("npx tsc -p tsconfig.plugins.json", { - cwd: root, - stdio: "inherit", - }); -} - -/** - * Rewrites the compiled plugin JS's imports into core from the source-tree - * path tsc needed to resolve them, to the compiled-output path they need to - * resolve at runtime. See the module comment above for why this exists. - */ -function rewriteCoreImports(dir) { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const entryPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - rewriteCoreImports(entryPath); - continue; - } - if (!entry.name.endsWith(".js")) continue; - - const original = fs.readFileSync(entryPath, "utf8"); - if (!original.includes(SRC_BACKEND_PREFIX)) continue; - - const rewritten = original - .split(SRC_BACKEND_PREFIX) - .join(COMPILED_BACKEND_PREFIX); - fs.writeFileSync(entryPath, rewritten); - } -} - -/** - * tsconfig.plugins.json has rootDir/outDir "." so the docker plugin's .js - * lands beside its own .ts, but tsc also pulls every file the plugin - * imports into the same program for type-checking -- core utils, repository - * factories, src/types -- and stray-emits a sibling .js next to each of - * those .ts files too, even though none of that code is actually compiled - * by this pass (only referenced for types/signatures). Those stray files - * are not part of the normal build and must not leak into dist/ or the - * working tree, so anything under src/ that is a .js with no git history - * and a same-named .ts sibling gets removed after every compile. - */ -function isGitTracked(filePath) { - try { - execSync(`git ls-files --error-unmatch "${filePath}"`, { - cwd: root, - stdio: "ignore", - }); - return true; - } catch { - return false; - } -} - -function removeStrayEmits(dir) { - if (!fs.existsSync(dir)) return; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const entryPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - removeStrayEmits(entryPath); - continue; - } - if (!entry.name.endsWith(".js")) continue; - const tsSibling = entryPath.slice(0, -".js".length) + ".ts"; - // Never touch a tracked file, even if some future .ts/.js pair - // legitimately coexists -- only remove what is both untracked and has a - // .ts sibling, which is what a stray tsc emit always looks like. - if (fs.existsSync(tsSibling) && !isGitTracked(entryPath)) { - fs.rmSync(entryPath); - } - } -} - -/** - * Every plugin directory with a backend/ that tsc actually compiled (i.e. it - * has a .ts file in it, not just a hand-written index.mjs). Scanned rather - * than hardcoded so a future plugin only needs a tsconfig.plugins.json entry, - * not a second call site here. - */ -function compiledPluginBackendDirs() { - return fs - .readdirSync(source, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(source, entry.name, "backend")) - .filter( - (dir) => - fs.existsSync(dir) && - fs.readdirSync(dir).some((name) => name.endsWith(".ts")), - ); -} - -/** - * Deletes every .js file tsc just emitted directly under a plugin's own - * backend/ directory (recursively), leaving its .ts siblings untouched. - * - * Without this, the compiled .js sits next to the .ts in plugins/ after every - * build, shadowing the source when vitest/tsx resolve an import -- and since - * those .js files were rewritten for the dist/ layout (see rewriteCoreImports), - * their relative paths are wrong at the plugins/ depth, so anything importing - * them from a test fails with "Cannot find module". .gitignore already - * expects plugins/*\/backend/**\/*.js to never be tracked; this is what keeps - * the working tree matching that after a local build, the same way dist/ is - * expected to hold the only copy of compiled plugin output. - */ -function removeCompiledPluginEmits(dir) { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const entryPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - removeCompiledPluginEmits(entryPath); - continue; - } - if (entry.name.endsWith(".js")) fs.rmSync(entryPath); - } -} - -compilePluginBackends(); -const compiledDirs = compiledPluginBackendDirs(); -for (const dir of compiledDirs) { - rewriteCoreImports(dir); -} -removeStrayEmits(path.join(root, "src")); - -fs.rmSync(destination, { recursive: true, force: true }); -fs.cpSync(source, destination, { recursive: true }); - -for (const dir of compiledDirs) { - removeCompiledPluginEmits(dir); -} - -const bundled = fs - .readdirSync(destination, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name); - -console.log(`Bundled ${bundled.length} plugin(s): ${bundled.join(", ")}`); diff --git a/scripts/plugin-boundary-allowlist.json b/scripts/plugin-boundary-allowlist.json new file mode 100644 index 000000000..d9b065172 --- /dev/null +++ b/scripts/plugin-boundary-allowlist.json @@ -0,0 +1,98 @@ +{ + "ui-to-plugin": [ + "src/ui/AppShell.tsx", + "src/ui/dashboard/DashboardTab.tsx", + "src/ui/features/ai/useMentions.ts", + "src/ui/features/collab/CollabGuestView.tsx", + "src/ui/features/collab/CollabRoomTab.tsx", + "src/ui/features/homepage/widgets/DockerWidget.tsx", + "src/ui/features/session-sharing/SharedSessionView.tsx", + "src/ui/features/terminal/Terminal.tsx", + "src/ui/main-axios.ts", + "src/ui/shell/tabUtils.tsx", + "src/ui/sidebar/AdminSettingsPanel.tsx", + "src/ui/sidebar/HostEditor.tsx", + "src/ui/sidebar/HostEditorWebUiSection.tsx", + "src/ui/sidebar/HostsPanel.tsx", + "src/ui/sidebar/quick-connect-host.ts", + "src/ui/sidebar/tree/HostItem/HostItem.tsx" + ], + "plugin-to-core": [ + "plugins/ai/src/backend/egress.ts", + "plugins/ai/src/backend/engine.ts", + "plugins/ai/src/backend/gating.ts", + "plugins/ai/src/backend/index.ts", + "plugins/ai/src/backend/providers/http.ts", + "plugins/ai/src/backend/routes.ts", + "plugins/ai/src/backend/tools/executor.ts", + "plugins/ai/src/backend/tools/read-tools.ts", + "plugins/automations/src/backend/actions/host-targets.ts", + "plugins/automations/src/backend/actions/index.ts", + "plugins/automations/src/backend/actions/types.ts", + "plugins/automations/src/backend/conditions.ts", + "plugins/automations/src/backend/docker-watcher.ts", + "plugins/automations/src/backend/engine.ts", + "plugins/automations/src/backend/headless-viewer.ts", + "plugins/automations/src/backend/http.ts", + "plugins/automations/src/backend/notify.ts", + "plugins/automations/src/backend/routes.ts", + "plugins/automations/src/backend/scheduler.ts", + "plugins/automations/src/backend/triggers.ts", + "plugins/docker/src/backend/console.ts", + "plugins/docker/src/backend/container-routes.ts", + "plugins/docker/src/backend/index.ts", + "plugins/docker/src/backend/routes.ts", + "plugins/docker/src/backend/session-manager.ts", + "plugins/fleets/src/backend/routes.ts", + "plugins/host-metrics/src/backend/automation-bridge.ts", + "plugins/host-metrics/src/backend/helpers.ts", + "plugins/host-metrics/src/backend/history-routes.ts", + "plugins/host-metrics/src/backend/managers/cron.ts", + "plugins/host-metrics/src/backend/managers/firewall.ts", + "plugins/host-metrics/src/backend/managers/health.ts", + "plugins/host-metrics/src/backend/managers/index.ts", + "plugins/host-metrics/src/backend/managers/logs.ts", + "plugins/host-metrics/src/backend/managers/packages.ts", + "plugins/host-metrics/src/backend/managers/processes.ts", + "plugins/host-metrics/src/backend/managers/route-helpers.ts", + "plugins/host-metrics/src/backend/managers/services.ts", + "plugins/host-metrics/src/backend/managers/simple-reads.ts", + "plugins/host-metrics/src/backend/managers/ssl.ts", + "plugins/host-metrics/src/backend/managers/tailscale.ts", + "plugins/host-metrics/src/backend/managers/types.ts", + "plugins/host-metrics/src/backend/managers/users.ts", + "plugins/host-metrics/src/backend/managers/wireguard.ts", + "plugins/host-metrics/src/backend/preferences-routes.ts", + "plugins/host-metrics/src/backend/routes.ts", + "plugins/host-metrics/src/backend/sessions.ts", + "plugins/host-metrics/src/backend/settings-routes.ts", + "plugins/host-metrics/src/backend/viewer-routes.ts", + "plugins/host-metrics/src/backend/widgets/cpu-collector.ts", + "plugins/host-metrics/src/backend/widgets/disk-collector.ts", + "plugins/host-metrics/src/backend/widgets/firewall-collector.ts", + "plugins/host-metrics/src/backend/widgets/gpu-collector.ts", + "plugins/host-metrics/src/backend/widgets/login-stats-collector.ts", + "plugins/host-metrics/src/backend/widgets/memory-collector.ts", + "plugins/host-metrics/src/backend/widgets/network-collector.ts", + "plugins/host-metrics/src/backend/widgets/ports-collector.ts", + "plugins/host-metrics/src/backend/widgets/processes-collector.ts", + "plugins/host-metrics/src/backend/widgets/system-collector.ts", + "plugins/host-metrics/src/backend/widgets/temperature-collector.ts", + "plugins/host-metrics/src/backend/widgets/uptime-collector.ts", + "plugins/network-topology/src/backend/routes.ts", + "plugins/proxmox/src/backend/routes.ts", + "plugins/remote-desktop/src/backend/guacamole-server.ts", + "plugins/remote-desktop/src/backend/index.ts", + "plugins/remote-desktop/src/backend/routes.ts", + "plugins/remote-desktop/src/backend/token-service.ts", + "plugins/ssh-terminal/src/backend/index.ts", + "plugins/tailscale/src/backend/routes.ts", + "plugins/web-endpoint/src/backend/routes.ts", + "plugins/workspaces/src/backend/routes.ts" + ], + "plugin-to-plugin": [ + "plugins/ai/src/backend/tools/executor.ts", + "plugins/host-metrics/src/backend/automation-bridge.ts", + "plugins/host-metrics/src/backend/routes.ts" + ] +} diff --git a/scripts/run-plugin-tests.cjs b/scripts/run-plugin-tests.cjs new file mode 100644 index 000000000..21b91597f --- /dev/null +++ b/scripts/run-plugin-tests.cjs @@ -0,0 +1,57 @@ +/** + * Runs every plugin's own vitest suite. + * + * Core's `npm run test` covers src/ only. A plugin's tests live beside it and + * run under its own config, so a plugin that moves out of this repo keeps a + * working suite. + */ + +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const root = path.resolve(__dirname, ".."); +const source = path.join(root, "plugins"); +const cli = path.join(root, "packages", "plugin-sdk", "cli", "index.mjs"); + +const pluginIds = fs + .readdirSync(source, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .filter((entry) => + fs.existsSync(path.join(source, entry.name, "vitest.config.ts")), + ) + .map((entry) => entry.name); + +const failed = []; + +for (const id of pluginIds) { + const pluginDir = path.join(source, id); + + const hasTests = ["tests/backend", "tests/frontend"].some((dir) => { + const full = path.join(pluginDir, dir); + return ( + fs.existsSync(full) && + fs + .readdirSync(full, { recursive: true }) + .some( + (name) => + String(name).endsWith(".test.ts") || + String(name).endsWith(".test.tsx"), + ) + ); + }); + if (!hasTests) continue; + + const result = spawnSync(process.execPath, [cli, "test"], { + cwd: pluginDir, + stdio: "inherit", + }); + if (result.status !== 0) failed.push(id); +} + +if (failed.length > 0) { + console.error(`\nPlugin tests failed: ${failed.join(", ")}`); + process.exit(1); +} + +console.log(`\nPlugin tests passed for ${pluginIds.length} plugin(s).`); diff --git a/scripts/validate-plugin-manifest.cjs b/scripts/validate-plugin-manifest.cjs index 226395a18..11f625ccb 100644 --- a/scripts/validate-plugin-manifest.cjs +++ b/scripts/validate-plugin-manifest.cjs @@ -14,7 +14,13 @@ const path = require("node:path"); const { pathToFileURL } = require("node:url"); const root = path.resolve(__dirname, ".."); -const sdkEntry = path.join(root, "packages", "plugin-sdk", "dist", "manifest.js"); +const sdkEntry = path.join( + root, + "packages", + "plugin-sdk", + "dist", + "manifest.js", +); async function loadValidator() { if (!fs.existsSync(sdkEntry)) { diff --git a/src/backend/database/routes/guacamole-dispatch.ts b/src/backend/database/routes/guacamole-dispatch.ts index e7ed1b7b9..09ec8186c 100644 --- a/src/backend/database/routes/guacamole-dispatch.ts +++ b/src/backend/database/routes/guacamole-dispatch.ts @@ -6,8 +6,8 @@ // It also brokers the small surface collab, session-sharing and the admin // settings route need from the plugin (join tokens, live-session lookups, a // server restart hook). Those core modules cannot import the plugin's -// TypeScript directly -- plugins/ is compiled by tsconfig.plugins.json as a -// one-directional read of src/backend/ (see copy-bundled-plugins.cjs), and a +// TypeScript directly -- plugins/ is type-checked by tsconfig.plugins.json as +// a one-directional read of src/backend/, and a // core file under tsconfig.node.json's rootDir cannot statically import // something outside it. So the plugin registers this bridge once on // activate, and clears it on deactivate; a disabled/uninstalled plugin falls diff --git a/src/backend/database/routes/host-web-endpoints.ts b/src/backend/database/routes/host-web-endpoints.ts index cb0297f9e..16e6c18a3 100644 --- a/src/backend/database/routes/host-web-endpoints.ts +++ b/src/backend/database/routes/host-web-endpoints.ts @@ -146,7 +146,7 @@ function normalizeEndpoint(raw: unknown): WebEndpoint | null { * Drops any endpoint it refuses rather than rejecting the whole host -- one * bad row must not make a host unsaveable or unlistable. The editor is * responsible for telling the user before that happens - * (plugins/web-endpoint/frontend/web-endpoint-validation.ts). + * (plugins/web-endpoint/src/frontend/web-endpoint-validation.ts). */ export function normalizeWebEndpoints(raw: unknown): WebEndpoint[] { if (!Array.isArray(raw)) return []; diff --git a/src/backend/hosts/ssh-client-factory.ts b/src/backend/hosts/ssh-client-factory.ts index 0441166ea..60eccef75 100644 --- a/src/backend/hosts/ssh-client-factory.ts +++ b/src/backend/hosts/ssh-client-factory.ts @@ -15,7 +15,7 @@ import { resolveSshConnectConfigHost } from "./ssh-dns.js"; * Non-interactive SSH connection helpers shared by fleet execution. * * Mirrors buildSshConfig/createSshFactory/getPoolKey in - * plugins/host-metrics/backend/routes.ts, + * plugins/host-metrics/src/backend/routes.ts, * minus the keyboard-interactive TOTP prompt handling (metrics/terminal-only) * and OPKSSH/Vault cert setup (not supported as fleet auth for v1 - those flows * need an interactive browser step that has no place in a batch fleet run). diff --git a/src/backend/plugins/paths.ts b/src/backend/plugins/paths.ts index 7e07920e2..bc9016e90 100644 --- a/src/backend/plugins/paths.ts +++ b/src/backend/plugins/paths.ts @@ -51,20 +51,11 @@ export function getPluginDir(pluginId: string): string { return path.join(getPluginsDir(), pluginId); } -/** - * The backend entry, from the manifest's own `backend` field. - * - * Bundled plugins still ship a hand-written backend/index.mjs, so that stays - * the fallback until A2 gives every plugin a real build. - */ +/** The backend entry, from the manifest's own `backend` field. */ export function getPluginBackendEntry( pluginDir: string, manifest?: Pick, ): string { - const legacy = path.join(pluginDir, "backend", "index.mjs"); - if (!manifest?.backend || manifest.backend === DEFAULT_BACKEND_ENTRY) { - if (fs.existsSync(legacy)) return legacy; - } return path.join(pluginDir, manifest?.backend ?? DEFAULT_BACKEND_ENTRY); } diff --git a/src/backend/tests/database/repositories/test-support.ts b/src/backend/tests/database/repositories/test-support.ts index b92d67315..dc8891d87 100644 --- a/src/backend/tests/database/repositories/test-support.ts +++ b/src/backend/tests/database/repositories/test-support.ts @@ -10,6 +10,7 @@ import { } from "drizzle-orm"; import fs from "fs"; import path from "path"; +import { fileURLToPath } from "node:url"; import * as schema from "../../../database/db/schema.js"; import type { DatabaseContext } from "../../../database/repositories/database-context.js"; import type { DatabaseDialect } from "../../../database/db/dialect.js"; @@ -448,7 +449,10 @@ let cachedSqliteSchema: string | null = null; function sqliteSchemaSql(): string { if (cachedSqliteSchema) return cachedSqliteSchema; - const dir = path.resolve(process.cwd(), "drizzle", "sqlite"); + // Resolved from this file, not cwd: plugin suites run with their own + // directory as cwd and would not find drizzle/ otherwise. + const here = path.dirname(fileURLToPath(import.meta.url)); + const dir = path.resolve(here, "../../../../..", "drizzle", "sqlite"); const files = fs .readdirSync(dir) .filter((name) => name.endsWith(".sql")) diff --git a/src/backend/tests/plugins/bundled-plugin.test.ts b/src/backend/tests/plugins/bundled-plugin.test.ts index 2f3f930c7..73677f390 100644 --- a/src/backend/tests/plugins/bundled-plugin.test.ts +++ b/src/backend/tests/plugins/bundled-plugin.test.ts @@ -90,7 +90,7 @@ describe("declared dependencies", () => { } }); - // plugins/ai/backend/tools/executor.ts imports automations' routes + // plugins/ai/src/backend/tools/executor.ts imports automations' routes // directly, so the loader has to start automations first. B18 replaces the // import with a service call and this becomes optional. it("records ai's dependency on automations", () => { @@ -111,7 +111,7 @@ describe("plugins that own a port", () => { (id) => { if (!ids.includes(id)) return; - const entry = path.join(BUNDLED_DIR, id, "backend", "index.mjs"); + const entry = path.join(BUNDLED_DIR, id, "src", "backend", "index.ts"); const source = fs.readFileSync(entry, "utf8"); const moduleScope = source.slice( 0, @@ -129,8 +129,14 @@ describe("plugins that own a port", () => { // Core owns shutdown: gracefulShutdown calls shutdownPlugins, which runs // every plugin's deactivate. A plugin calling process.exit() itself // skipped the rest of that sequence. - for (const file of ["index.mjs", "console.ts"]) { - const candidate = path.join(BUNDLED_DIR, "docker", "backend", file); + for (const file of ["index.ts", "console.ts"]) { + const candidate = path.join( + BUNDLED_DIR, + "docker", + "src", + "backend", + file, + ); if (!fs.existsSync(candidate)) continue; // Comments are stripped first: the rule is worth explaining in prose diff --git a/src/backend/tests/plugins/events.test.ts b/src/backend/tests/plugins/events.test.ts index ab8558b8a..f86b79503 100644 --- a/src/backend/tests/plugins/events.test.ts +++ b/src/backend/tests/plugins/events.test.ts @@ -137,7 +137,7 @@ describe("hosts publishers route through the bus", () => { it("notifyAutomationStatus and notifyAutomationMetrics emit their topics", async () => { const { notifyAutomationStatus, notifyAutomationMetrics } = - await import("../../../../plugins/host-metrics/backend/automation-bridge.js"); + await import("../../../../plugins/host-metrics/src/backend/automation-bridge.js"); const status = vi.fn(); const metrics = vi.fn(); diff --git a/src/main.tsx b/src/main.tsx index d8271c43a..1c399da89 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -44,7 +44,7 @@ const TunnelApp = lazy(() => import("@/features/tunnel/TunnelApp").then((m) => ({ default: m.default })), ); const HostMetricsApp = lazy(() => - import("../plugins/host-metrics/frontend/HostMetricsApp").then((m) => ({ + import("../plugins/host-metrics/src/frontend/HostMetricsApp").then((m) => ({ default: m.default, })), ); @@ -54,12 +54,12 @@ const ProxmoxStatsApp = lazy(() => })), ); const DockerApp = lazy(() => - import("../plugins/docker/frontend/DockerApp").then((m) => ({ + import("../plugins/docker/src/frontend/DockerApp").then((m) => ({ default: m.default, })), ); const GuacamoleApp = lazy(() => - import("../plugins/remote-desktop/frontend/GuacamoleApp").then((m) => ({ + import("../plugins/remote-desktop/src/frontend/GuacamoleApp").then((m) => ({ default: m.default, })), ); diff --git a/src/types/ui-preferences.ts b/src/types/ui-preferences.ts index 7244d8f78..38c4506b1 100644 --- a/src/types/ui-preferences.ts +++ b/src/types/ui-preferences.ts @@ -181,7 +181,7 @@ const BALANCED_DASHBOARD_CARDS = [ // the edge of the screen. They stay available in the Add card tray. const ADVANCED_DASHBOARD_CARDS = [...BALANCED_DASHBOARD_CARDS, "service_links"]; -/** Host metrics card ids, mirroring CARD_DEFINITIONS in plugins/host-metrics/frontend/cards. */ +/** Host metrics card ids, mirroring CARD_DEFINITIONS in plugins/host-metrics/src/frontend/cards. */ const SIMPLE_HOST_METRICS_CARDS = ["cpu", "memory", "disk"]; const BALANCED_HOST_METRICS_CARDS = [ "cpu", diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index 90a2bc460..c5c47b816 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -92,24 +92,26 @@ const MacrosPanel = lazy(() => import("@/sidebar/MacrosPanel").then((m) => ({ default: m.MacrosPanel })), ); const FleetsPanel = lazy(() => - import("../../plugins/fleets/frontend/FleetsPanel").then((m) => ({ + import("../../plugins/fleets/src/frontend/FleetsPanel").then((m) => ({ default: m.FleetsPanel, })), ); const TailscaleDevicesPanel = lazy(() => - import("../../plugins/tailscale/frontend/TailscaleDevicesPanel").then( + import("../../plugins/tailscale/src/frontend/TailscaleDevicesPanel").then( (m) => ({ default: m.TailscaleDevicesPanel }), ), ); const WorkspacesPanel = lazy(() => - import("../../plugins/workspaces/frontend/WorkspacesPanel").then((m) => ({ + import("../../plugins/workspaces/src/frontend/WorkspacesPanel").then((m) => ({ default: m.WorkspacesPanel, })), ); const AutomationsPanel = lazy(() => - import("../../plugins/automations/frontend/AutomationsPanel").then((m) => ({ - default: m.AutomationsPanel, - })), + import("../../plugins/automations/src/frontend/AutomationsPanel").then( + (m) => ({ + default: m.AutomationsPanel, + }), + ), ); const AiPanel = lazy(() => import("@/features/ai/AiPanel").then((m) => ({ @@ -197,12 +199,12 @@ import { listWorkspaces, applyWorkspaceServer, saveLastSessionWorkspace, -} from "../../plugins/workspaces/frontend/workspaces-api"; +} from "../../plugins/workspaces/src/frontend/workspaces-api"; import { buildWorkspacePayload as buildWorkspacePayloadUtil, remapSlotIds, resolveWorkspaceTabTarget, -} from "../../plugins/workspaces/frontend/workspaceUtils"; +} from "../../plugins/workspaces/src/frontend/workspaceUtils"; import { DonationReminderModal } from "@/user/DonationReminderModal.tsx"; import { RemoteSyncBanner } from "@/components/RemoteSyncBanner.tsx"; import { MigrationNoticeDialog } from "@/components/MigrationNoticeDialog.tsx"; diff --git a/src/ui/api/settings-api.ts b/src/ui/api/settings-api.ts index 3cf5ea663..3c580ce73 100644 --- a/src/ui/api/settings-api.ts +++ b/src/ui/api/settings-api.ts @@ -128,7 +128,7 @@ export async function updateSessionTimeout( } // Tailscale settings/device API wrappers moved to -// plugins/tailscale/frontend/tailscale-api.ts. +// plugins/tailscale/src/frontend/tailscale-api.ts. // ============================================================================ // GUACAMOLE SETTINGS diff --git a/src/ui/dashboard/DashboardTab.tsx b/src/ui/dashboard/DashboardTab.tsx index 31fcfc3b7..96eeb8163 100644 --- a/src/ui/dashboard/DashboardTab.tsx +++ b/src/ui/dashboard/DashboardTab.tsx @@ -50,7 +50,7 @@ import { } from "@/main-axios"; import type { RecentActivityItem, ServiceLink } from "@/main-axios"; import { useTranslation } from "react-i18next"; -import { NetworkGraphCard } from "../../../plugins/network-topology/frontend/NetworkGraphCard"; +import { NetworkGraphCard } from "../../../plugins/network-topology/src/frontend/NetworkGraphCard"; import { HomepagePreviewCard } from "@/dashboard/cards/HomepagePreviewCard"; import { HomepageCanvas } from "@/features/homepage/HomepageCanvas"; diff --git a/src/ui/features/ai/useMentions.ts b/src/ui/features/ai/useMentions.ts index 1596b3883..8f2a74c8e 100644 --- a/src/ui/features/ai/useMentions.ts +++ b/src/ui/features/ai/useMentions.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { getSSHHosts } from "@/api/ssh-host-management-api"; import { getSnippets } from "@/api/snippets-api"; -import { listAutomations } from "../../../../plugins/automations/frontend/automations-api"; +import { listAutomations } from "../../../../plugins/automations/src/frontend/automations-api"; /** * Backs the @-mention picker in the composer. diff --git a/src/ui/features/collab/CollabGuestView.tsx b/src/ui/features/collab/CollabGuestView.tsx index ef69555f9..bafc6f976 100644 --- a/src/ui/features/collab/CollabGuestView.tsx +++ b/src/ui/features/collab/CollabGuestView.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { AlertCircle, Eye, Presentation, Users } from "lucide-react"; -import { GuacamoleDisplay } from "../../../../plugins/remote-desktop/frontend/GuacamoleDisplay.tsx"; +import { GuacamoleDisplay } from "../../../../plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx"; import { GuestTerminalView, type SessionParticipantInfo, diff --git a/src/ui/features/collab/CollabRoomTab.tsx b/src/ui/features/collab/CollabRoomTab.tsx index 19a5b9088..0c19ad88b 100644 --- a/src/ui/features/collab/CollabRoomTab.tsx +++ b/src/ui/features/collab/CollabRoomTab.tsx @@ -33,8 +33,8 @@ import { Input } from "@/components/input"; import { CollabMembersSidebar } from "./CollabMembersSidebar"; import { Terminal } from "@/features/terminal/Terminal"; import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext"; -import { GuacamoleDisplay } from "../../../../plugins/remote-desktop/frontend/GuacamoleDisplay.tsx"; -import { getGuacamoleTokenFromHost } from "../../../../plugins/remote-desktop/frontend/guacamole-api"; +import { GuacamoleDisplay } from "../../../../plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx"; +import { getGuacamoleTokenFromHost } from "../../../../plugins/remote-desktop/src/frontend/guacamole-api"; import { getSSHHosts, getUserList, type SSHHostWithStatus } from "@/main-axios"; import { getRoles } from "@/api/rbac-api"; import type { Role } from "@/main-axios"; diff --git a/src/ui/features/homepage/widgets/DockerWidget.tsx b/src/ui/features/homepage/widgets/DockerWidget.tsx index 9e30ca7b0..68348fe87 100644 --- a/src/ui/features/homepage/widgets/DockerWidget.tsx +++ b/src/ui/features/homepage/widgets/DockerWidget.tsx @@ -9,7 +9,7 @@ import type { import { GRID_SIZE } from "@/types/homepage-types"; import { getSSHHosts } from "@/api/ssh-host-management-api"; import type { SSHHostWithStatus } from "@/main-axios"; -import { DockerManager } from "../../../../../plugins/docker/frontend/DockerManager"; +import { DockerManager } from "../../../../../plugins/docker/src/frontend/DockerManager"; import type { SSHHost } from "@/types/index"; import { WidgetTitle } from "./WidgetTitle"; diff --git a/src/ui/features/session-sharing/SharedSessionView.tsx b/src/ui/features/session-sharing/SharedSessionView.tsx index 8099c2961..126d0c426 100644 --- a/src/ui/features/session-sharing/SharedSessionView.tsx +++ b/src/ui/features/session-sharing/SharedSessionView.tsx @@ -11,7 +11,7 @@ import { import { SimpleLoader } from "@/lib/SimpleLoader.tsx"; import { getBasePath } from "@/lib/base-path"; import { isElectron } from "@/lib/electron"; -import { GuacamoleDisplay } from "../../../../plugins/remote-desktop/frontend/GuacamoleDisplay.tsx"; +import { GuacamoleDisplay } from "../../../../plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx"; const PING_INTERVAL_MS = 30000; diff --git a/src/ui/features/terminal/Terminal.tsx b/src/ui/features/terminal/Terminal.tsx index a583c4f8b..ff129b7d2 100644 --- a/src/ui/features/terminal/Terminal.tsx +++ b/src/ui/features/terminal/Terminal.tsx @@ -46,7 +46,7 @@ import { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog.tsx"; import { PassphraseDialog } from "@/ssh/dialogs/PassphraseDialog.tsx"; import { WarpgateDialog } from "@/ssh/dialogs/WarpgateDialog.tsx"; import { OPKSSHDialog } from "@/ssh/dialogs/OPKSSHDialog.tsx"; -import { TailscaleCheckDialog } from "../../../../plugins/tailscale/frontend/TailscaleCheckDialog.tsx"; +import { TailscaleCheckDialog } from "../../../../plugins/tailscale/src/frontend/TailscaleCheckDialog.tsx"; import { HostKeyVerificationDialog } from "@/ssh/dialogs/HostKeyVerificationDialog.tsx"; import { TmuxSessionPicker } from "@/ssh/dialogs/TmuxSessionPicker.tsx"; import { diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 3f203ce27..7f119008d 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -2191,13 +2191,13 @@ export { export type { GuacamoleTokenRequest, GuacamoleTokenResponse, -} from "../../plugins/remote-desktop/frontend/guacamole-api"; +} from "../../plugins/remote-desktop/src/frontend/guacamole-api"; export { getGuacamoleDpi, getGuacamoleToken, getGuacamoleTokenFromHost, getGuacdStatus, -} from "../../plugins/remote-desktop/frontend/guacamole-api"; +} from "../../plugins/remote-desktop/src/frontend/guacamole-api"; // ============================================================================ // RBAC MANAGEMENT diff --git a/src/ui/shell/tabUtils.tsx b/src/ui/shell/tabUtils.tsx index a03986efe..83c19d33e 100644 --- a/src/ui/shell/tabUtils.tsx +++ b/src/ui/shell/tabUtils.tsx @@ -38,7 +38,7 @@ import type { TerminalHandle, TerminalHostConfig, } from "@/features/terminal/Terminal"; -import type { GuacamoleAppHandle } from "../../../plugins/remote-desktop/frontend/GuacamoleApp"; +import type { GuacamoleAppHandle } from "../../../plugins/remote-desktop/src/frontend/GuacamoleApp"; import { useIsMobile } from "@/hooks/use-mobile"; import type { Tab, TabType, Host } from "@/types/ui-types"; import { @@ -84,19 +84,23 @@ const loadFileManager = () => })); const FileManager = lazy(loadFileManager); const loadDockerManager = () => - import("../../../plugins/docker/frontend/DockerManager").then((m) => ({ + import("../../../plugins/docker/src/frontend/DockerManager").then((m) => ({ default: m.DockerManager, })); const DockerManager = lazy(loadDockerManager); const loadWebEndpointTab = () => - import("../../../plugins/web-endpoint/frontend/WebEndpointTab").then((m) => ({ - default: m.WebEndpointTab, - })); + import("../../../plugins/web-endpoint/src/frontend/WebEndpointTab").then( + (m) => ({ + default: m.WebEndpointTab, + }), + ); const WebEndpointTab = lazy(loadWebEndpointTab); const loadHostMetricsTab = () => - import("../../../plugins/host-metrics/frontend/HostMetricsTab").then((m) => ({ - default: m.HostMetricsTab, - })); + import("../../../plugins/host-metrics/src/frontend/HostMetricsTab").then( + (m) => ({ + default: m.HostMetricsTab, + }), + ); const HostMetricsTab = lazy(loadHostMetricsTab); const loadProxmoxStatsTab = () => import("@/features/proxmox-stats/ProxmoxStatsTab").then((m) => ({ @@ -109,9 +113,11 @@ const loadTmuxMonitor = () => })); const TmuxMonitor = lazy(loadTmuxMonitor); const loadGuacamoleApp = () => - import("../../../plugins/remote-desktop/frontend/GuacamoleApp").then((m) => ({ - default: m.default, - })); + import("../../../plugins/remote-desktop/src/frontend/GuacamoleApp").then( + (m) => ({ + default: m.default, + }), + ); const GuacamoleApp = lazy(loadGuacamoleApp); const DashboardTab = lazy(() => import("@/dashboard/DashboardTab").then((m) => ({ @@ -134,7 +140,7 @@ const SftpTransferTab = lazy(() => })), ); const NetworkGraphCard = lazy(() => - import("../../../plugins/network-topology/frontend/NetworkGraphCard").then( + import("../../../plugins/network-topology/src/frontend/NetworkGraphCard").then( (m) => ({ default: m.NetworkGraphCard, }), @@ -146,9 +152,11 @@ const Serial = lazy(() => })), ); const FleetInventoryTab = lazy(() => - import("../../../plugins/fleets/frontend/FleetInventoryTab").then((m) => ({ - default: m.FleetInventoryTab, - })), + import("../../../plugins/fleets/src/frontend/FleetInventoryTab").then( + (m) => ({ + default: m.FleetInventoryTab, + }), + ), ); // Rail panels promoted to full tabs. @@ -173,7 +181,7 @@ const SshToolsPanel = lazy(() => import("@/sidebar/SshToolsPanel").then((m) => ({ default: m.SshToolsPanel })), ); const AutomationsPanel = lazy(() => - import("../../../plugins/automations/frontend/AutomationsPanel").then( + import("../../../plugins/automations/src/frontend/AutomationsPanel").then( (m) => ({ default: m.AutomationsPanel, }), diff --git a/src/ui/sidebar/AdminSettingsPanel.tsx b/src/ui/sidebar/AdminSettingsPanel.tsx index ce2dd3e03..dcbca2c5e 100644 --- a/src/ui/sidebar/AdminSettingsPanel.tsx +++ b/src/ui/sidebar/AdminSettingsPanel.tsx @@ -67,8 +67,8 @@ import { import { getTailscaleSettings, updateTailscaleSettings, -} from "../../../plugins/tailscale/frontend/tailscale-api"; -import { AdminTailscaleSection } from "../../../plugins/tailscale/frontend/AdminTailscaleSection"; +} from "../../../plugins/tailscale/src/frontend/tailscale-api"; +import { AdminTailscaleSection } from "../../../plugins/tailscale/src/frontend/AdminTailscaleSection"; import { getSessionSharingGloballyEnabled, updateSessionSharingGloballyEnabled, diff --git a/src/ui/sidebar/HostEditor.tsx b/src/ui/sidebar/HostEditor.tsx index adfe51d10..c81f334e2 100644 --- a/src/ui/sidebar/HostEditor.tsx +++ b/src/ui/sidebar/HostEditor.tsx @@ -57,7 +57,7 @@ import { adminCreateUserCredential, } from "@/main-axios"; import { getHostDefaults } from "@/api/settings-api"; -import { getTailscaleDevices } from "../../../plugins/tailscale/frontend/tailscale-api"; +import { getTailscaleDevices } from "../../../plugins/tailscale/src/frontend/tailscale-api"; import { getUserPreferences, saveUserPreferences, diff --git a/src/ui/sidebar/HostEditorWebUiSection.tsx b/src/ui/sidebar/HostEditorWebUiSection.tsx index 1fbcb64b6..f0b2f3d40 100644 --- a/src/ui/sidebar/HostEditorWebUiSection.tsx +++ b/src/ui/sidebar/HostEditorWebUiSection.tsx @@ -4,7 +4,7 @@ import { isElectron } from "@/lib/electron"; import { webEndpointRefusalReason, type WebEndpointRefusalReason, -} from "../../../plugins/web-endpoint/frontend/web-endpoint-url"; +} from "../../../plugins/web-endpoint/src/frontend/web-endpoint-url"; import { Button } from "@/components/button"; import { Input } from "@/components/input"; import { Checkbox } from "@/components/checkbox"; @@ -28,7 +28,7 @@ import { isWebEndpointPortValid, webEndpointErrorKey, webEndpointRowError, -} from "../../../plugins/web-endpoint/frontend/web-endpoint-validation"; +} from "../../../plugins/web-endpoint/src/frontend/web-endpoint-validation"; /** * crypto.randomUUID is undefined outside a secure context, so a plain-http web diff --git a/src/ui/sidebar/HostsPanel.tsx b/src/ui/sidebar/HostsPanel.tsx index 19a27021f..82cb41353 100644 --- a/src/ui/sidebar/HostsPanel.tsx +++ b/src/ui/sidebar/HostsPanel.tsx @@ -28,7 +28,7 @@ import { HostManager } from "@/sidebar/HostManager"; import { HostShareModal } from "@/sidebar/HostShareModal"; import { HostExportDialog } from "@/sidebar/HostExportDialog"; import { CustomizeSidebarPanel } from "@/sidebar/CustomizeSidebarPanel"; -import { ProxmoxDiscoverDialog } from "../../../plugins/proxmox/frontend/ProxmoxDiscoverDialog"; +import { ProxmoxDiscoverDialog } from "../../../plugins/proxmox/src/frontend/ProxmoxDiscoverDialog"; import { Button } from "@/components/button"; import { DropdownMenu, diff --git a/src/ui/sidebar/quick-connect-host.ts b/src/ui/sidebar/quick-connect-host.ts index a39fd23ab..4c57062eb 100644 --- a/src/ui/sidebar/quick-connect-host.ts +++ b/src/ui/sidebar/quick-connect-host.ts @@ -1,6 +1,6 @@ import type { SSHHostData } from "@/types"; import type { Host } from "@/types/ui-types"; -import type { GuacamoleQuickHost } from "../../../plugins/remote-desktop/frontend/GuacamoleApp"; +import type { GuacamoleQuickHost } from "../../../plugins/remote-desktop/src/frontend/GuacamoleApp"; export type QuickConnectProtocol = "ssh" | "rdp" | "vnc"; diff --git a/src/ui/sidebar/tree/HostItem/HostItem.tsx b/src/ui/sidebar/tree/HostItem/HostItem.tsx index bc9e4230d..b5d708142 100644 --- a/src/ui/sidebar/tree/HostItem/HostItem.tsx +++ b/src/ui/sidebar/tree/HostItem/HostItem.tsx @@ -87,7 +87,7 @@ import { recordHostActionPreference, } from "@/lib/local-adaptive-preferences"; import type { WebEndpoint } from "@/types/index"; -import { openWebEndpointExternally } from "../../../../../plugins/web-endpoint/frontend/web-endpoint-api"; +import { openWebEndpointExternally } from "../../../../../plugins/web-endpoint/src/frontend/web-endpoint-api"; import { isTabTypeAvailable } from "@/shell/pluginLoader"; export function statusCheckEnabled(host: Host): boolean { diff --git a/src/ui/tests/features/session-sharing/SharedSessionView.test.tsx b/src/ui/tests/features/session-sharing/SharedSessionView.test.tsx index a394fa27c..95973f739 100644 --- a/src/ui/tests/features/session-sharing/SharedSessionView.test.tsx +++ b/src/ui/tests/features/session-sharing/SharedSessionView.test.tsx @@ -39,7 +39,7 @@ vi.mock("@xterm/addon-fit", () => ({ })); vi.mock( - "../../../../../plugins/remote-desktop/frontend/GuacamoleDisplay.tsx", + "../../../../../plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx", () => ({ GuacamoleDisplay: () =>
, }), diff --git a/src/ui/tests/sidebar/rail-items.test.ts b/src/ui/tests/sidebar/rail-items.test.ts index ed626a2dc..49a9b8689 100644 --- a/src/ui/tests/sidebar/rail-items.test.ts +++ b/src/ui/tests/sidebar/rail-items.test.ts @@ -8,7 +8,7 @@ import { railItemLabel, visibleRailItems, } from "@/sidebar/rail-items"; -import { WORKSPACE_CAPTURABLE_TYPES } from "../../../../plugins/workspaces/frontend/workspaceUtils"; +import { WORKSPACE_CAPTURABLE_TYPES } from "../../../../plugins/workspaces/src/frontend/workspaceUtils"; import type { TabType } from "@/types/ui-types"; import en from "@/locales/en.json"; diff --git a/tsconfig.json b/tsconfig.json index 20b681814..2b6f42bf6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,9 @@ }, { "path": "./tsconfig.node.json" + }, + { + "path": "./tsconfig.plugins.json" } ], "compilerOptions": { diff --git a/tsconfig.plugins.json b/tsconfig.plugins.json index 761c6b99c..38a6118fe 100644 --- a/tsconfig.plugins.json +++ b/tsconfig.plugins.json @@ -4,14 +4,12 @@ "target": "ES2023", "lib": ["ES2023"], "module": "nodenext", - "skipLibCheck": true, "moduleResolution": "nodenext", + "skipLibCheck": true, "verbatimModuleSyntax": true, "moduleDetection": "force", "esModuleInterop": true, - "noEmit": false, - "rootDir": ".", - "outDir": ".", + "noEmit": true, "strict": false, "noUnusedLocals": false, "noUnusedParameters": false, @@ -26,18 +24,5 @@ "allowUnusedLabels": true, "allowUnreachableCode": true }, - "include": [ - "plugins/docker/backend/**/*.ts", - "plugins/host-metrics/backend/**/*.ts", - "plugins/ai/backend/**/*.ts", - "plugins/proxmox/backend/**/*.ts", - "plugins/remote-desktop/backend/**/*.ts", - "plugins/fleets/backend/**/*.ts", - "plugins/automations/backend/**/*.ts", - "plugins/network-topology/backend/**/*.ts", - "plugins/workspaces/backend/**/*.ts", - "plugins/web-endpoint/backend/**/*.ts", - "plugins/tailscale/backend/**/*.ts" - ], - "exclude": ["src/**/*.test.ts"] + "include": ["plugins/*/src/backend/**/*.ts"] } diff --git a/vitest.config.ts b/vitest.config.ts index ff59eb129..7fa55c2c3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -47,10 +47,7 @@ export default defineConfig({ test: { name: "frontend", environment: "jsdom", - include: [ - "src/ui/**/*.test.{ts,tsx}", - "plugins/**/frontend/**/*.test.{ts,tsx}", - ], + include: ["src/ui/**/*.test.{ts,tsx}"], }, }, { diff --git a/vitest.setup.ts b/vitest.setup.ts index 3e073212a..fe27dc3b0 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1,61 +1,7 @@ -import { afterEach, vi } from "vitest"; - -if (typeof window !== "undefined" && !window.matchMedia) { - window.matchMedia = vi.fn().mockImplementation((query: string) => ({ - matches: false, - media: query, - onchange: null, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - addListener: vi.fn(), - removeListener: vi.fn(), - dispatchEvent: vi.fn(), - })); -} - /** - * jsdom in this project ships without a `localStorage` global, and Node only - * provides one when started with --localstorage-file (which persists to disk - * and is shared across test files -- both wrong for tests). Suites that touch - * storage therefore threw `Cannot read properties of undefined`, taking out - * every test in the file rather than just the ones that used it. + * Core's vitest setup. * - * A per-process in-memory implementation is enough: it satisfies the Storage - * interface the app uses and keeps nothing on disk. + * The polyfills live in the SDK so core and every plugin suite share one copy. */ -if (typeof globalThis.localStorage === "undefined") { - const createMemoryStorage = (): Storage => { - const store = new Map(); - return { - get length() { - return store.size; - }, - key: (index: number) => Array.from(store.keys())[index] ?? null, - getItem: (key: string) => (store.has(key) ? store.get(key)! : null), - setItem: (key: string, value: string) => { - store.set(key, String(value)); - }, - removeItem: (key: string) => { - store.delete(key); - }, - clear: () => { - store.clear(); - }, - } as Storage; - }; - Object.defineProperty(globalThis, "localStorage", { - value: createMemoryStorage(), - configurable: true, - writable: true, - }); - Object.defineProperty(globalThis, "sessionStorage", { - value: createMemoryStorage(), - configurable: true, - writable: true, - }); -} - -afterEach(() => { - vi.restoreAllMocks(); -}); +import "@termix/plugin-sdk/testing/setup";