From b5072f1a3e150d2929f9004e3d7514824e3950f8 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sun, 13 Sep 2026 10:39:17 +0800 Subject: [PATCH] feat(plugin): add plugin framework workflow --- .gitattributes | 3 + .github/workflows/ci.yml | 3 + .github/workflows/plugin-cli-release.yml | 371 ++ .github/workflows/plugin-dev-host.yml | 39 + .github/workflows/plugin-release-reusable.yml | 218 + .gitignore | 3 + CONTRIBUTING.md | 1 + Cargo.lock | 83 +- README.md | 3 + SECURITY.md | 3 + agents/drivers/duckdb/Cargo.lock | 87 +- apps/desktop/src/App.vue | 83 +- .../components/config/DriverStoreDialog.vue | 8 +- .../connection/ConnectionDialog.vue | 3824 +++++++++-------- .../src/components/layout/AppDialogs.vue | 6 +- .../src/components/layout/AppTabBar.vue | 18 +- .../src/components/layout/AppToolbar.vue | 14 +- .../src/components/layout/ContentArea.vue | 42 + .../layout/QueryResultToolbarActions.vue | 39 +- .../layout/__tests__/AppTabBar.spec.ts | 2 +- .../appContentSurfaces.contract.spec.ts | 2 +- .../plugins/PluginConnectionFields.spec.ts | 113 + .../plugins/PluginConnectionFields.vue | 114 + .../plugins/PluginContributionsPanel.vue | 871 ++++ .../components/plugins/PluginFileManager.vue | 234 + .../plugins/PluginFilesystemTab.vue | 64 + .../src/components/plugins/PluginIcon.spec.ts | 75 + .../src/components/plugins/PluginIcon.vue | 72 + .../plugins/PluginWorkbenchHost.vue | 196 + .../components/plugins/PluginWorkbenchTab.vue | 77 + .../sidebar/SidebarTreeRuntimeHost.vue | 57 +- .../src/components/ui/PasswordInput.vue | 2 + .../__tests__/useTauriEvents.spec.ts | 47 + .../desktop/src/composables/useTauriEvents.ts | 5 + apps/desktop/src/i18n/locales/en.ts | 119 + apps/desktop/src/i18n/locales/es.ts | 110 + apps/desktop/src/i18n/locales/it.ts | 110 + apps/desktop/src/i18n/locales/ja.ts | 110 + apps/desktop/src/i18n/locales/ko.ts | 119 + apps/desktop/src/i18n/locales/pt-BR.ts | 110 + apps/desktop/src/i18n/locales/zh-CN.ts | 110 + apps/desktop/src/i18n/locales/zh-TW.ts | 110 + .../__tests__/app/openTabsPersistence.spec.ts | 58 + .../connectionDialog.deepLinkServices.spec.ts | 2 +- ...onnectionDialogDriverProfileConfig.spec.ts | 8 +- .../plugins/pluginCenterIntegration.spec.ts | 58 + .../src/lib/app/openTabsPersistence.ts | 5 + apps/desktop/src/lib/backend/api.ts | 28 + apps/desktop/src/lib/backend/http.ts | 168 + apps/desktop/src/lib/backend/tauri.ts | 134 + .../lib/connection/connectionOpenTarget.ts | 5 +- .../connection/connectionVisibleDatabases.ts | 2 +- .../lib/database/databaseFeatureSupport.ts | 1 + .../lib/database/databaseNamespaceCreation.ts | 1 + .../lib/database/databasePropertyEditing.ts | 1 + .../src/lib/plugins/frontendPlugin.spec.ts | 330 ++ .../desktop/src/lib/plugins/frontendPlugin.ts | 302 ++ .../src/lib/plugins/pluginCenterNavigation.ts | 4 + apps/desktop/src/lib/plugins/pluginData.ts | 103 + .../src/lib/plugins/pluginFilesystem.spec.ts | 37 + .../src/lib/plugins/pluginFilesystem.ts | 34 + .../src/lib/plugins/pluginHostBridge.spec.ts | 321 ++ .../src/lib/plugins/pluginHostBridge.ts | 457 ++ .../src/lib/plugins/pluginMarketplace.spec.ts | 84 + .../src/lib/plugins/pluginMarketplace.ts | 90 + .../__tests__/settingsPageNavigation.spec.ts | 4 +- .../src/lib/settings/settingsSearch.ts | 3 +- apps/desktop/src/stores/connectionStore.ts | 14 +- apps/desktop/src/stores/queryStore.ts | 103 +- apps/desktop/src/stores/settingsStore.ts | 3 + apps/desktop/src/types/database.ts | 330 +- .../src/types/generated/databaseTypes.ts | 1 + crates/dbx-core/Cargo.toml | 3 + .../assets/database-drivers.manifest.json | 29 + .../examples/plugin_marketplace_smoke.rs | 56 + .../dbx-core/examples/plugin_package_smoke.rs | 160 + .../examples/table_import_live_bench.rs | 4 + crates/dbx-core/src/agent_connection.rs | 4 + crates/dbx-core/src/agent_explain.rs | 11 +- crates/dbx-core/src/agent_tools.rs | 4 + crates/dbx-core/src/cloud_sync.rs | 49 +- crates/dbx-core/src/connection.rs | 96 +- crates/dbx-core/src/connection_secrets.rs | 5 + crates/dbx-core/src/db/redis_driver.rs | 4 + crates/dbx-core/src/models/connection.rs | 39 + crates/dbx-core/src/mq/config.rs | 4 + crates/dbx-core/src/mq/service.rs | 4 + crates/dbx-core/src/nacos/config.rs | 4 + crates/dbx-core/src/nacos/service.rs | 8 + crates/dbx-core/src/plugins.rs | 709 ++- crates/dbx-core/src/plugins/assets.rs | 184 + crates/dbx-core/src/plugins/filesystem.rs | 536 +++ crates/dbx-core/src/plugins/host.rs | 702 +++ crates/dbx-core/src/plugins/installer.rs | 1255 ++++++ crates/dbx-core/src/plugins/manifest.rs | 1667 +++++++ crates/dbx-core/src/plugins/marketplace.rs | 1109 +++++ crates/dbx-core/src/plugins/runtime.rs | 912 ++++ crates/dbx-core/src/production_safety.rs | 4 + crates/dbx-core/src/query.rs | 42 +- crates/dbx-core/src/schema.rs | 10 + crates/dbx-core/src/schema/normalization.rs | 9 +- .../dbx-core/src/sql_dialect/ddl_profile.rs | 2 + crates/dbx-core/src/storage.rs | 103 +- crates/dbx-core/src/table_export.rs | 11 +- crates/dbx-core/src/transfer.rs | 8 + crates/dbx-core/tests/dump_docs_fixture.rs | 4 + .../tests/live_postgres_docs_annotations.rs | 4 + .../tests/live_postgres_docs_snapshot.rs | 4 + .../live_postgres_query_result_export.rs | 4 + .../dbx-core/tests/live_postgres_transfer.rs | 4 + .../tests/live_sqlserver_completion.rs | 4 + .../live_sqlserver_query_result_export.rs | 4 + .../dbx-core/tests/live_sqlserver_transfer.rs | 4 + crates/dbx-core/tests/support/mod.rs | 4 + crates/dbx-web/src/main.rs | 31 + crates/dbx-web/src/routes/connection.rs | 4 + crates/dbx-web/src/routes/plugins.rs | 629 ++- crates/dbx-web/src/routes/transfer.rs | 4 + docs/content/docs/contributing.cn.mdx | 4 + docs/content/docs/contributing.mdx | 4 + docs/content/docs/meta.cn.json | 1 + docs/content/docs/meta.json | 1 + docs/content/docs/plugin-development.cn.mdx | 204 + docs/content/docs/plugin-development.mdx | 204 + docs/content/docs/plugins.cn.mdx | 2 + docs/content/docs/plugins.mdx | 2 + package.json | 7 +- packages/app-tests/queryResultToolbar.test.ts | 12 +- packages/plugin-cli-darwin-arm64/package.json | 17 + packages/plugin-cli-darwin-x64/package.json | 17 + .../plugin-cli-linux-arm64-gnu/package.json | 18 + .../plugin-cli-linux-x64-gnu/package.json | 18 + packages/plugin-cli-win32-arm64/package.json | 17 + packages/plugin-cli-win32-x64/package.json | 17 + packages/plugin-cli/.gitignore | 2 + packages/plugin-cli/README.md | 46 + packages/plugin-cli/bin/dbx-plugin.js | 91 + packages/plugin-cli/package.json | 47 + packages/plugin-cli/scripts/stage-dev.mjs | 17 + packages/plugin-cli/scripts/stage-sdk.mjs | 49 + .../plugin-cli/tests/dev-launcher.test.mjs | 21 + packages/plugin-cli/tests/launcher.test.mjs | 63 + plugins/GETTING_STARTED.zh-CN.md | 443 ++ plugins/README.md | 537 ++- plugins/RELEASING.md | 123 + plugins/SIGNING.md | 33 + plugins/connection-types/plugin.yaml | 29 + plugins/examples/hello-workbench/.gitignore | 2 + plugins/examples/hello-workbench/README.md | 171 + .../hello-workbench/assets/connection.svg | 11 + .../hello-workbench/assets/plugin.svg | 13 + .../hello-workbench/backend/Cargo.lock | 115 + .../hello-workbench/backend/Cargo.toml | 16 + .../hello-workbench/backend/src/main.rs | 195 + .../examples/hello-workbench/manifest.json | 237 + plugins/examples/hello-workbench/package.mjs | 68 + .../hello-workbench/repository-sign.mjs | 83 + .../hello-workbench/repository-smoke.mjs | 79 + plugins/examples/hello-workbench/smoke.mjs | 37 + .../examples/hello-workbench/ui/index.html | 327 ++ plugins/examples/marketplace/README.md | 21 + .../examples/marketplace/catalog.example.json | 75 + plugins/manifest.schema.json | 271 ++ plugins/marketplace.schema.json | 115 + plugins/sdk/cli/.gitignore | 1 + plugins/sdk/cli/Cargo.lock | 692 +++ plugins/sdk/cli/Cargo.toml | 22 + plugins/sdk/cli/README.md | 108 + plugins/sdk/cli/src/dev.rs | 250 ++ plugins/sdk/cli/src/lib.rs | 1942 +++++++++ plugins/sdk/cli/src/main.rs | 6 + plugins/sdk/cli/templates/common/README.md | 34 + .../cli/templates/common/assets/plugin.svg | 10 + .../sdk/cli/templates/common/dbx-plugin.toml | 9 + .../common/github/plugin-release.yml | 18 + plugins/sdk/cli/templates/common/gitignore | 5 + .../sdk/cli/templates/common/manifest.json | 89 + .../sdk/cli/templates/common/ui/index.html | 56 + plugins/sdk/cli/templates/frontend/README.md | 34 + .../cli/templates/frontend/dbx-plugin.toml | 4 + .../frontend/github/plugin-release.yml | 19 + .../sdk/cli/templates/frontend/manifest.json | 41 + .../sdk/cli/templates/frontend/ui/index.html | 55 + plugins/sdk/cli/templates/go/backend/go.mod | 6 + plugins/sdk/cli/templates/go/backend/main.go | 74 + .../sdk/cli/templates/rust/backend/Cargo.toml | 19 + .../cli/templates/rust/backend/src/main.rs | 70 + plugins/sdk/cli/templates/svelte/README.md | 22 + .../sdk/cli/templates/svelte/dbx-plugin.toml | 8 + plugins/sdk/cli/templates/svelte/index.html | 8 + .../sdk/cli/templates/svelte/manifest.json | 33 + plugins/sdk/cli/templates/svelte/package.json | 14 + .../sdk/cli/templates/svelte/src/App.svelte | 44 + plugins/sdk/cli/templates/svelte/src/main.js | 4 + .../sdk/cli/templates/svelte/svelte.config.js | 5 + .../sdk/cli/templates/svelte/vite.config.js | 10 + plugins/sdk/dev-host/.gitignore | 2 + plugins/sdk/dev-host/README.md | 120 + plugins/sdk/dev-host/assets.mjs | 13 + plugins/sdk/dev-host/auto-reload.mjs | 35 + plugins/sdk/dev-host/browser-bridge.mjs | 144 + plugins/sdk/dev-host/build.mjs | 10 + plugins/sdk/dev-host/connections.mjs | 106 + plugins/sdk/dev-host/diagnostics.mjs | 90 + plugins/sdk/dev-host/docs/preview.png | Bin 0 -> 90386 bytes plugins/sdk/dev-host/package-lock.json | 3543 +++++++++++++++ plugins/sdk/dev-host/package.json | 30 + plugins/sdk/dev-host/process-tree.mjs | 32 + plugins/sdk/dev-host/runtime.mjs | 135 + plugins/sdk/dev-host/server.mjs | 543 +++ plugins/sdk/dev-host/sidecar.mjs | 212 + .../sdk/dev-host/tests/auto-reload.test.mjs | 35 + .../dev-host/tests/browser-bridge.test.mjs | 74 + .../sdk/dev-host/tests/diagnostics.test.mjs | 63 + plugins/sdk/dev-host/tests/echo-sidecar.mjs | 23 + plugins/sdk/dev-host/tests/i18n.test.mjs | 31 + plugins/sdk/dev-host/tests/runtime.test.mjs | 83 + plugins/sdk/dev-host/tests/server.test.mjs | 399 ++ plugins/sdk/dev-host/tests/sidecar.test.mjs | 107 + plugins/sdk/dev-host/ui/App.vue | 448 ++ plugins/sdk/dev-host/ui/DebugPanel.vue | 66 + plugins/sdk/dev-host/ui/i18n.js | 118 + plugins/sdk/dev-host/ui/index.html | 12 + plugins/sdk/dev-host/ui/main.js | 4 + plugins/sdk/dev-host/ui/messages.js | 3 + plugins/sdk/dev-host/ui/style.css | 39 + plugins/sdk/go/dbx-plugin-sdk/README.md | 17 + plugins/sdk/go/dbx-plugin-sdk/go.mod | 3 + plugins/sdk/go/dbx-plugin-sdk/sdk.go | 244 ++ plugins/sdk/go/dbx-plugin-sdk/sdk_test.go | 62 + plugins/sdk/packager/.gitignore | 1 + plugins/sdk/packager/Cargo.lock | 621 +++ plugins/sdk/packager/Cargo.toml | 29 + plugins/sdk/packager/README.md | 61 + .../packager/src/bin/dbx-plugin-packager.rs | 3 + plugins/sdk/packager/src/main.rs | 806 ++++ plugins/sdk/rust/dbx-plugin-sdk/.gitignore | 1 + plugins/sdk/rust/dbx-plugin-sdk/Cargo.toml | 12 + plugins/sdk/rust/dbx-plugin-sdk/README.md | 87 + plugins/sdk/rust/dbx-plugin-sdk/src/lib.rs | 432 ++ .../sdk/templates/github/plugin-release.yml | 20 + pnpm-lock.yaml | 33 + scripts/verify-package-install.mjs | 1 + scripts/verify-plugin-cli-package.mjs | 208 + src-tauri/src/commands/connection.rs | 18 +- src-tauri/src/commands/plugins.rs | 477 +- src-tauri/src/lib.rs | 28 + 247 files changed, 33841 insertions(+), 2256 deletions(-) create mode 100644 .github/workflows/plugin-cli-release.yml create mode 100644 .github/workflows/plugin-dev-host.yml create mode 100644 .github/workflows/plugin-release-reusable.yml create mode 100644 apps/desktop/src/components/plugins/PluginConnectionFields.spec.ts create mode 100644 apps/desktop/src/components/plugins/PluginConnectionFields.vue create mode 100644 apps/desktop/src/components/plugins/PluginContributionsPanel.vue create mode 100644 apps/desktop/src/components/plugins/PluginFileManager.vue create mode 100644 apps/desktop/src/components/plugins/PluginFilesystemTab.vue create mode 100644 apps/desktop/src/components/plugins/PluginIcon.spec.ts create mode 100644 apps/desktop/src/components/plugins/PluginIcon.vue create mode 100644 apps/desktop/src/components/plugins/PluginWorkbenchHost.vue create mode 100644 apps/desktop/src/components/plugins/PluginWorkbenchTab.vue create mode 100644 apps/desktop/src/composables/__tests__/useTauriEvents.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/plugins/pluginCenterIntegration.spec.ts create mode 100644 apps/desktop/src/lib/plugins/frontendPlugin.spec.ts create mode 100644 apps/desktop/src/lib/plugins/frontendPlugin.ts create mode 100644 apps/desktop/src/lib/plugins/pluginCenterNavigation.ts create mode 100644 apps/desktop/src/lib/plugins/pluginData.ts create mode 100644 apps/desktop/src/lib/plugins/pluginFilesystem.spec.ts create mode 100644 apps/desktop/src/lib/plugins/pluginFilesystem.ts create mode 100644 apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts create mode 100644 apps/desktop/src/lib/plugins/pluginHostBridge.ts create mode 100644 apps/desktop/src/lib/plugins/pluginMarketplace.spec.ts create mode 100644 apps/desktop/src/lib/plugins/pluginMarketplace.ts create mode 100644 crates/dbx-core/examples/plugin_marketplace_smoke.rs create mode 100644 crates/dbx-core/examples/plugin_package_smoke.rs create mode 100644 crates/dbx-core/src/plugins/assets.rs create mode 100644 crates/dbx-core/src/plugins/filesystem.rs create mode 100644 crates/dbx-core/src/plugins/host.rs create mode 100644 crates/dbx-core/src/plugins/installer.rs create mode 100644 crates/dbx-core/src/plugins/manifest.rs create mode 100644 crates/dbx-core/src/plugins/marketplace.rs create mode 100644 crates/dbx-core/src/plugins/runtime.rs create mode 100644 docs/content/docs/plugin-development.cn.mdx create mode 100644 docs/content/docs/plugin-development.mdx create mode 100644 packages/plugin-cli-darwin-arm64/package.json create mode 100644 packages/plugin-cli-darwin-x64/package.json create mode 100644 packages/plugin-cli-linux-arm64-gnu/package.json create mode 100644 packages/plugin-cli-linux-x64-gnu/package.json create mode 100644 packages/plugin-cli-win32-arm64/package.json create mode 100644 packages/plugin-cli-win32-x64/package.json create mode 100644 packages/plugin-cli/.gitignore create mode 100644 packages/plugin-cli/README.md create mode 100644 packages/plugin-cli/bin/dbx-plugin.js create mode 100644 packages/plugin-cli/package.json create mode 100644 packages/plugin-cli/scripts/stage-dev.mjs create mode 100644 packages/plugin-cli/scripts/stage-sdk.mjs create mode 100644 packages/plugin-cli/tests/dev-launcher.test.mjs create mode 100644 packages/plugin-cli/tests/launcher.test.mjs create mode 100644 plugins/GETTING_STARTED.zh-CN.md create mode 100644 plugins/RELEASING.md create mode 100644 plugins/SIGNING.md create mode 100644 plugins/connection-types/plugin.yaml create mode 100644 plugins/examples/hello-workbench/.gitignore create mode 100644 plugins/examples/hello-workbench/README.md create mode 100644 plugins/examples/hello-workbench/assets/connection.svg create mode 100644 plugins/examples/hello-workbench/assets/plugin.svg create mode 100644 plugins/examples/hello-workbench/backend/Cargo.lock create mode 100644 plugins/examples/hello-workbench/backend/Cargo.toml create mode 100644 plugins/examples/hello-workbench/backend/src/main.rs create mode 100644 plugins/examples/hello-workbench/manifest.json create mode 100644 plugins/examples/hello-workbench/package.mjs create mode 100644 plugins/examples/hello-workbench/repository-sign.mjs create mode 100644 plugins/examples/hello-workbench/repository-smoke.mjs create mode 100644 plugins/examples/hello-workbench/smoke.mjs create mode 100644 plugins/examples/hello-workbench/ui/index.html create mode 100644 plugins/examples/marketplace/README.md create mode 100644 plugins/examples/marketplace/catalog.example.json create mode 100644 plugins/manifest.schema.json create mode 100644 plugins/marketplace.schema.json create mode 100644 plugins/sdk/cli/.gitignore create mode 100644 plugins/sdk/cli/Cargo.lock create mode 100644 plugins/sdk/cli/Cargo.toml create mode 100644 plugins/sdk/cli/README.md create mode 100644 plugins/sdk/cli/src/dev.rs create mode 100644 plugins/sdk/cli/src/lib.rs create mode 100644 plugins/sdk/cli/src/main.rs create mode 100644 plugins/sdk/cli/templates/common/README.md create mode 100644 plugins/sdk/cli/templates/common/assets/plugin.svg create mode 100644 plugins/sdk/cli/templates/common/dbx-plugin.toml create mode 100644 plugins/sdk/cli/templates/common/github/plugin-release.yml create mode 100644 plugins/sdk/cli/templates/common/gitignore create mode 100644 plugins/sdk/cli/templates/common/manifest.json create mode 100644 plugins/sdk/cli/templates/common/ui/index.html create mode 100644 plugins/sdk/cli/templates/frontend/README.md create mode 100644 plugins/sdk/cli/templates/frontend/dbx-plugin.toml create mode 100644 plugins/sdk/cli/templates/frontend/github/plugin-release.yml create mode 100644 plugins/sdk/cli/templates/frontend/manifest.json create mode 100644 plugins/sdk/cli/templates/frontend/ui/index.html create mode 100644 plugins/sdk/cli/templates/go/backend/go.mod create mode 100644 plugins/sdk/cli/templates/go/backend/main.go create mode 100644 plugins/sdk/cli/templates/rust/backend/Cargo.toml create mode 100644 plugins/sdk/cli/templates/rust/backend/src/main.rs create mode 100644 plugins/sdk/cli/templates/svelte/README.md create mode 100644 plugins/sdk/cli/templates/svelte/dbx-plugin.toml create mode 100644 plugins/sdk/cli/templates/svelte/index.html create mode 100644 plugins/sdk/cli/templates/svelte/manifest.json create mode 100644 plugins/sdk/cli/templates/svelte/package.json create mode 100644 plugins/sdk/cli/templates/svelte/src/App.svelte create mode 100644 plugins/sdk/cli/templates/svelte/src/main.js create mode 100644 plugins/sdk/cli/templates/svelte/svelte.config.js create mode 100644 plugins/sdk/cli/templates/svelte/vite.config.js create mode 100644 plugins/sdk/dev-host/.gitignore create mode 100644 plugins/sdk/dev-host/README.md create mode 100644 plugins/sdk/dev-host/assets.mjs create mode 100644 plugins/sdk/dev-host/auto-reload.mjs create mode 100644 plugins/sdk/dev-host/browser-bridge.mjs create mode 100644 plugins/sdk/dev-host/build.mjs create mode 100644 plugins/sdk/dev-host/connections.mjs create mode 100644 plugins/sdk/dev-host/diagnostics.mjs create mode 100644 plugins/sdk/dev-host/docs/preview.png create mode 100644 plugins/sdk/dev-host/package-lock.json create mode 100644 plugins/sdk/dev-host/package.json create mode 100644 plugins/sdk/dev-host/process-tree.mjs create mode 100644 plugins/sdk/dev-host/runtime.mjs create mode 100644 plugins/sdk/dev-host/server.mjs create mode 100644 plugins/sdk/dev-host/sidecar.mjs create mode 100644 plugins/sdk/dev-host/tests/auto-reload.test.mjs create mode 100644 plugins/sdk/dev-host/tests/browser-bridge.test.mjs create mode 100644 plugins/sdk/dev-host/tests/diagnostics.test.mjs create mode 100644 plugins/sdk/dev-host/tests/echo-sidecar.mjs create mode 100644 plugins/sdk/dev-host/tests/i18n.test.mjs create mode 100644 plugins/sdk/dev-host/tests/runtime.test.mjs create mode 100644 plugins/sdk/dev-host/tests/server.test.mjs create mode 100644 plugins/sdk/dev-host/tests/sidecar.test.mjs create mode 100644 plugins/sdk/dev-host/ui/App.vue create mode 100644 plugins/sdk/dev-host/ui/DebugPanel.vue create mode 100644 plugins/sdk/dev-host/ui/i18n.js create mode 100644 plugins/sdk/dev-host/ui/index.html create mode 100644 plugins/sdk/dev-host/ui/main.js create mode 100644 plugins/sdk/dev-host/ui/messages.js create mode 100644 plugins/sdk/dev-host/ui/style.css create mode 100644 plugins/sdk/go/dbx-plugin-sdk/README.md create mode 100644 plugins/sdk/go/dbx-plugin-sdk/go.mod create mode 100644 plugins/sdk/go/dbx-plugin-sdk/sdk.go create mode 100644 plugins/sdk/go/dbx-plugin-sdk/sdk_test.go create mode 100644 plugins/sdk/packager/.gitignore create mode 100644 plugins/sdk/packager/Cargo.lock create mode 100644 plugins/sdk/packager/Cargo.toml create mode 100644 plugins/sdk/packager/README.md create mode 100644 plugins/sdk/packager/src/bin/dbx-plugin-packager.rs create mode 100644 plugins/sdk/packager/src/main.rs create mode 100644 plugins/sdk/rust/dbx-plugin-sdk/.gitignore create mode 100644 plugins/sdk/rust/dbx-plugin-sdk/Cargo.toml create mode 100644 plugins/sdk/rust/dbx-plugin-sdk/README.md create mode 100644 plugins/sdk/rust/dbx-plugin-sdk/src/lib.rs create mode 100644 plugins/sdk/templates/github/plugin-release.yml create mode 100644 scripts/verify-plugin-cli-package.mjs diff --git a/.gitattributes b/.gitattributes index 3555aba67..9992a8f3d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -11,6 +11,9 @@ apps/desktop/tsconfig.json text eol=lf apps/desktop/*.ts text eol=lf src-tauri/windows/nsis/**/*.nsi text eol=lf +# Keep plugin dev-host formatting checks consistent on Windows checkouts. +plugins/sdk/dev-host/** text=auto eol=lf + # Keep tests available for review and CI without counting them as shipped code # in GitHub's repository language breakdown. packages/app-tests/** linguist-vendored diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb14fa4c7..e5eb6367d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,6 +100,9 @@ jobs: - name: Install frontend dependencies run: pnpm install --frozen-lockfile + - name: Install plugin development host dependencies + run: npm ci --prefix plugins/sdk/dev-host + - name: Setup Rust uses: dtolnay/rust-toolchain@1.97.1 diff --git a/.github/workflows/plugin-cli-release.yml b/.github/workflows/plugin-cli-release.yml new file mode 100644 index 000000000..b928ce828 --- /dev/null +++ b/.github/workflows/plugin-cli-release.yml @@ -0,0 +1,371 @@ +name: Plugin CLI Release + +on: + push: + tags: + - "plugin-cli-v*" + workflow_dispatch: + inputs: + version: + description: "Plugin CLI version to publish, for example 0.1.1" + required: true + default: "0.1.1" + npm-tag: + description: "npm distribution tag" + required: true + default: "latest" + type: choice + options: + - latest + - next + +permissions: + attestations: write + contents: write + id-token: write + +concurrency: + group: plugin-cli-release + cancel-in-progress: false + +jobs: + prepare: + name: Validate plugin CLI release + runs-on: ubuntu-24.04 + outputs: + tag: ${{ steps.release.outputs.tag }} + version: ${{ steps.release.outputs.version }} + npm-tag: ${{ steps.release.outputs.npm-tag }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v6 + with: + node-version: 22.13.0 + registry-url: https://registry.npmjs.org + + - uses: dtolnay/rust-toolchain@stable + + - uses: actions/setup-go@v6 + with: + go-version: "1.22.x" + cache: false + + - name: Validate release branch and versions + id: release + env: + INPUT_VERSION: ${{ inputs.version }} + INPUT_NPM_TAG: ${{ inputs.npm-tag }} + run: | + node <<'NODE' + const fs = require("fs"); + + let version; + let tag; + let npmTag; + if (process.env.GITHUB_EVENT_NAME === "push") { + tag = process.env.GITHUB_REF_NAME; + const match = /^plugin-cli-v(.+)$/.exec(tag); + if (!match) throw new Error(`Unexpected plugin CLI tag: ${tag}`); + version = match[1]; + npmTag = version.includes("-") ? "next" : "latest"; + } else { + if (process.env.GITHUB_REF !== "refs/heads/main") { + throw new Error(`Manual plugin CLI releases must run from main, received ${process.env.GITHUB_REF}.`); + } + version = process.env.INPUT_VERSION.trim(); + tag = `plugin-cli-v${version}`; + npmTag = process.env.INPUT_NPM_TAG; + } + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { + throw new Error(`Invalid semantic version: ${version}`); + } + if (version.includes("-") && npmTag === "latest") { + throw new Error(`Prerelease ${version} cannot use the npm latest tag.`); + } + + const packagePaths = [ + "packages/plugin-cli/package.json", + "packages/plugin-cli-darwin-arm64/package.json", + "packages/plugin-cli-darwin-x64/package.json", + "packages/plugin-cli-linux-arm64-gnu/package.json", + "packages/plugin-cli-linux-x64-gnu/package.json", + "packages/plugin-cli-win32-arm64/package.json", + "packages/plugin-cli-win32-x64/package.json", + ]; + for (const path of packagePaths) { + const pkg = JSON.parse(fs.readFileSync(path, "utf8")); + if (pkg.version !== version) { + throw new Error(`${path} has version ${pkg.version}; expected ${version}.`); + } + } + + const cliCargo = fs.readFileSync("plugins/sdk/cli/Cargo.toml", "utf8"); + const cliVersion = cliCargo.match(/^version\s*=\s*"([^"]+)"/m)?.[1]; + if (cliVersion !== version) { + throw new Error(`plugins/sdk/cli/Cargo.toml has version ${cliVersion}; expected ${version}.`); + } + + const launcher = JSON.parse(fs.readFileSync(packagePaths[0], "utf8")); + for (const [dependency, dependencyVersion] of Object.entries(launcher.optionalDependencies ?? {})) { + if (dependencyVersion !== version) { + throw new Error(`${dependency} is pinned to ${dependencyVersion}; expected ${version}.`); + } + } + + fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `tag=${tag}\n`); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `npm-tag=${npmTag}\n`); + NODE + + - name: Check npm token + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + if [ -z "${NODE_AUTH_TOKEN}" ]; then + echo "::error::NPM_TOKEN secret is required to publish @dbx-app/plugin-cli." + exit 1 + fi + + - name: Test Rust CLI + run: cargo test --locked --manifest-path plugins/sdk/cli/Cargo.toml + + - name: Prepare and test plugin development runtime + run: | + npm ci --prefix plugins/sdk/dev-host + npm test --prefix plugins/sdk/dev-host + + - name: Test npm launcher + run: npm --prefix packages/plugin-cli test + + - name: Verify packed npm installation + env: + DBX_PLUGIN_CLI_VERIFY_NATIVE: "1" + run: node scripts/verify-plugin-cli-package.mjs + + - name: Create immutable source tag + if: github.event_name == 'workflow_dispatch' + env: + TAG: ${{ steps.release.outputs.tag }} + run: | + git fetch origin --tags --force + if git rev-parse "refs/tags/${TAG}" >/dev/null 2>&1; then + TAG_SHA="$(git rev-list -n 1 "refs/tags/${TAG}")" + HEAD_SHA="$(git rev-parse HEAD)" + if [ "${TAG_SHA}" != "${HEAD_SHA}" ]; then + echo "::error::${TAG} already points to ${TAG_SHA}, not ${HEAD_SHA}." + exit 1 + fi + echo "${TAG} already points to this commit." + else + git tag "${TAG}" HEAD + git push origin "refs/tags/${TAG}:refs/tags/${TAG}" + fi + + publish-platforms: + name: Publish ${{ matrix.package-name }} + needs: prepare + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15 + target: aarch64-apple-darwin + package-dir: plugin-cli-darwin-arm64 + package-name: "@dbx-app/plugin-cli-darwin-arm64" + binary: dbx-plugin + - runner: macos-15-intel + target: x86_64-apple-darwin + package-dir: plugin-cli-darwin-x64 + package-name: "@dbx-app/plugin-cli-darwin-x64" + binary: dbx-plugin + - runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + package-dir: plugin-cli-linux-arm64-gnu + package-name: "@dbx-app/plugin-cli-linux-arm64-gnu" + binary: dbx-plugin + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + package-dir: plugin-cli-linux-x64-gnu + package-name: "@dbx-app/plugin-cli-linux-x64-gnu" + binary: dbx-plugin + - runner: windows-11-arm + target: aarch64-pc-windows-msvc + package-dir: plugin-cli-win32-arm64 + package-name: "@dbx-app/plugin-cli-win32-arm64" + binary: dbx-plugin.exe + - runner: windows-2025 + target: x86_64-pc-windows-msvc + package-dir: plugin-cli-win32-x64 + package-name: "@dbx-app/plugin-cli-win32-x64" + binary: dbx-plugin.exe + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.prepare.outputs.tag }} + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - uses: actions/setup-python@v6 + if: runner.os == 'Linux' + with: + python-version: "3.x" + + - name: Install Linux compatibility builder + if: runner.os == 'Linux' + run: pip install ziglang==0.14.0 cargo-zigbuild==0.23.0 + + - name: Build precompiled CLI + shell: bash + env: + CARGO_TARGET_DIR: ${{ github.workspace }}/target/plugin-cli + run: | + if [[ "${{ runner.os }}" == "Linux" ]]; then + cargo zigbuild --locked --release --manifest-path plugins/sdk/cli/Cargo.toml --target "${{ matrix.target }}.2.31" + else + cargo build --locked --release --manifest-path plugins/sdk/cli/Cargo.toml --target "${{ matrix.target }}" + fi + + - name: Stage platform package + shell: bash + run: | + mkdir -p "packages/${{ matrix.package-dir }}/bin" + cp "target/plugin-cli/${{ matrix.target }}/release/${{ matrix.binary }}" "packages/${{ matrix.package-dir }}/bin/${{ matrix.binary }}" + if [[ "${{ runner.os }}" != "Windows" ]]; then + chmod +x "packages/${{ matrix.package-dir }}/bin/${{ matrix.binary }}" + fi + + - uses: actions/setup-node@v6 + with: + node-version: 22.13.0 + registry-url: https://registry.npmjs.org + + - name: Publish platform package + shell: bash + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + VERSION: ${{ needs.prepare.outputs.version }} + NPM_TAG: ${{ needs.prepare.outputs.npm-tag }} + run: | + if npm view "${{ matrix.package-name }}@${VERSION}" version >/dev/null 2>&1; then + echo "${{ matrix.package-name }}@${VERSION} already exists; refreshing ${NPM_TAG}." + npm dist-tag add "${{ matrix.package-name }}@${VERSION}" "${NPM_TAG}" + else + npm publish "./packages/${{ matrix.package-dir }}" --access public --provenance --tag "${NPM_TAG}" + fi + + publish-launcher: + name: Publish @dbx-app/plugin-cli + needs: [prepare, publish-platforms] + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.prepare.outputs.tag }} + + - uses: actions/setup-node@v6 + with: + node-version: 22.13.0 + registry-url: https://registry.npmjs.org + + - name: Prepare development runtime build dependencies + run: npm ci --prefix plugins/sdk/dev-host + + - name: Wait for platform packages + env: + VERSION: ${{ needs.prepare.outputs.version }} + run: | + wait_for_package() { + package="$1" + for attempt in $(seq 1 60); do + if npm view "${package}@${VERSION}" version >/dev/null 2>&1; then + return 0 + fi + echo "Waiting for ${package}@${VERSION} (${attempt}/60)." + sleep 10 + done + return 1 + } + + for package in \ + @dbx-app/plugin-cli-darwin-arm64 \ + @dbx-app/plugin-cli-darwin-x64 \ + @dbx-app/plugin-cli-linux-arm64-gnu \ + @dbx-app/plugin-cli-linux-x64-gnu \ + @dbx-app/plugin-cli-win32-arm64 \ + @dbx-app/plugin-cli-win32-x64; do + wait_for_package "${package}" || { + echo "::error::${package}@${VERSION} is unavailable; refusing to publish the launcher." + exit 1 + } + done + + - name: Publish launcher and bundled SDK + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + VERSION: ${{ needs.prepare.outputs.version }} + NPM_TAG: ${{ needs.prepare.outputs.npm-tag }} + run: | + if npm view "@dbx-app/plugin-cli@${VERSION}" version >/dev/null 2>&1; then + echo "@dbx-app/plugin-cli@${VERSION} already exists; refreshing ${NPM_TAG}." + npm dist-tag add "@dbx-app/plugin-cli@${VERSION}" "${NPM_TAG}" + else + npm publish ./packages/plugin-cli --access public --provenance --tag "${NPM_TAG}" + fi + + verify-published-package: + name: Verify clean npm installation + needs: [prepare, publish-launcher] + runs-on: ubuntu-24.04 + steps: + - uses: actions/setup-node@v6 + with: + node-version: 22.13.0 + registry-url: https://registry.npmjs.org + + - uses: dtolnay/rust-toolchain@stable + + - uses: actions/setup-go@v6 + with: + go-version: "1.22.x" + cache: false + + - name: Install from npm and package every template + env: + VERSION: ${{ needs.prepare.outputs.version }} + run: | + set -euo pipefail + work="$(mktemp -d)" + cd "${work}" + npm init -y >/dev/null + + for attempt in $(seq 1 60); do + if npm view "@dbx-app/plugin-cli@${VERSION}" version >/dev/null 2>&1; then + break + fi + if [ "${attempt}" -eq 60 ]; then + echo "::error::@dbx-app/plugin-cli@${VERSION} did not become visible on npm." + exit 1 + fi + sleep 10 + done + + npm install --ignore-scripts --no-audit --no-fund "@dbx-app/plugin-cli@${VERSION}" + ./node_modules/.bin/dbx-plugin --version + + for template in frontend rust go; do + ./node_modules/.bin/dbx-plugin create "${template}-plugin" \ + --template "${template}" \ + --yes \ + --id "com.example.npm-${template}" \ + --name "npm ${template} smoke" \ + --publisher example \ + --description "Published npm package smoke" + ./node_modules/.bin/dbx-plugin package "${template}-plugin" + test "$(find "${template}-plugin/dist" -maxdepth 1 -name '*.dbxp' | wc -l)" -eq 1 + done diff --git a/.github/workflows/plugin-dev-host.yml b/.github/workflows/plugin-dev-host.yml new file mode 100644 index 000000000..4aab18ea1 --- /dev/null +++ b/.github/workflows/plugin-dev-host.yml @@ -0,0 +1,39 @@ +name: Plugin development host +on: + pull_request: + paths: + - '.gitattributes' + - 'plugins/sdk/cli/**' + - 'plugins/sdk/dev-host/**' + - 'packages/plugin-cli/**' + - 'scripts/verify-plugin-cli-package.mjs' + - '.github/workflows/plugin-dev-host.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-15, windows-2022] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + cache: false + - run: npm ci --prefix plugins/sdk/dev-host + - run: npm run lint --prefix plugins/sdk/dev-host + - run: npm run fmt:check --prefix plugins/sdk/dev-host + - run: npm test --prefix plugins/sdk/dev-host + - run: cargo test --locked --manifest-path plugins/sdk/cli/Cargo.toml + - run: npm test --prefix packages/plugin-cli + - run: node scripts/verify-plugin-cli-package.mjs + env: + DBX_PLUGIN_CLI_VERIFY_NATIVE: '1' diff --git a/.github/workflows/plugin-release-reusable.yml b/.github/workflows/plugin-release-reusable.yml new file mode 100644 index 000000000..547c8398f --- /dev/null +++ b/.github/workflows/plugin-release-reusable.yml @@ -0,0 +1,218 @@ +name: Reusable DBX plugin release + +on: + workflow_call: + inputs: + release-tag: + description: Existing GitHub Release tag that receives the plugin assets + required: true + type: string + package-command: + description: Command that builds one unsigned candidate .dbxp and one .artifact.json for DBX_PLUGIN_TARGET + required: true + type: string + working-directory: + description: Plugin repository directory containing the package command + default: . + required: false + type: string + package-path: + description: Glob relative to working-directory for built .dbxp files + default: dist/*.dbxp + required: false + type: string + metadata-path: + description: Glob relative to working-directory for built .artifact.json files + default: dist/*.artifact.json + required: false + type: string + node-version: + default: "22" + required: false + type: string + go-version: + default: "1.22.x" + required: false + type: string + rust-toolchain: + default: stable + required: false + type: string + sdk-ref: + description: DBX tag or commit used only when installing the plugin CLI from source + default: plugin-sdk-v1 + required: false + type: string + plugin-cli-version: + description: Published @dbx-app/plugin-cli version used to package the plugin + default: "0.1.1" + required: false + type: string + install-plugin-cli: + description: Install the dbx-plugin CLI before packaging + default: true + required: false + type: boolean + install-plugin-cli-from-source: + description: Build the CLI from sdk-ref instead of installing its precompiled npm package + default: false + required: false + type: boolean + build-matrix: + description: JSON matrix with runner and DBX target pairs; use one universal entry for platform-independent plugins + default: >- + {"include":[{"runner":"ubuntu-24.04","target":"linux-x64"},{"runner":"ubuntu-24.04-arm","target":"linux-arm64"},{"runner":"windows-2022","target":"windows-x64"},{"runner":"macos-15-intel","target":"darwin-x64"},{"runner":"macos-15","target":"darwin-arm64"}]} + required: false + type: string +permissions: + contents: read + +jobs: + build: + name: Build ${{ matrix.target }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(inputs.build-matrix) }} + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node-version }} + + - uses: actions/setup-go@v5 + with: + go-version: ${{ inputs.go-version }} + + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ inputs.rust-toolchain }} + + - name: Checkout DBX plugin SDK + if: inputs.install-plugin-cli && inputs.install-plugin-cli-from-source + uses: actions/checkout@v4 + with: + repository: t8y2/dbx + ref: ${{ inputs.sdk-ref }} + path: .dbx-plugin-sdk + + - name: Install precompiled DBX plugin CLI + if: inputs.install-plugin-cli && !inputs.install-plugin-cli-from-source + shell: bash + run: npm install --global "@dbx-app/plugin-cli@${{ inputs.plugin-cli-version }}" + + - name: Install DBX plugin CLI from source + if: inputs.install-plugin-cli && inputs.install-plugin-cli-from-source + shell: bash + run: cargo install --locked --path .dbx-plugin-sdk/plugins/sdk/cli + + - name: Build unsigned plugin candidate + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + DBX_PLUGIN_TARGET: ${{ matrix.target }} + DBX_PLUGIN_SDK_ROOT: ${{ inputs.install-plugin-cli-from-source && format('{0}/.dbx-plugin-sdk', github.workspace) || '' }} + run: ${{ inputs.package-command }} + + - name: Upload target artifacts + uses: actions/upload-artifact@v4 + with: + name: dbx-plugin-${{ matrix.target }} + if-no-files-found: error + retention-days: 1 + path: | + ${{ inputs.working-directory }}/${{ inputs.package-path }} + ${{ inputs.working-directory }}/${{ inputs.metadata-path }} + + publish: + name: Publish plugin release assets + needs: build + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - name: Download target artifacts + uses: actions/download-artifact@v4 + with: + pattern: dbx-plugin-* + path: release-assets + merge-multiple: true + + - name: Validate and merge candidate metadata + shell: bash + run: | + node <<'NODE' + const childProcess = require("node:child_process"); + const crypto = require("node:crypto"); + const fs = require("node:fs"); + const path = require("node:path"); + + function filesUnder(root) { + return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const current = path.join(root, entry.name); + return entry.isDirectory() ? filesUnder(current) : [current]; + }); + } + + const files = filesUnder("release-assets"); + const metadataFiles = files.filter((file) => file.endsWith(".artifact.json")); + const packages = new Map(); + for (const file of files.filter((file) => file.endsWith(".dbxp"))) { + const name = path.basename(file); + if (packages.has(name)) throw new Error(`Duplicate package filename ${name}`); + packages.set(name, file); + } + if (metadataFiles.length === 0 || packages.size === 0) throw new Error("Release produced no DBX plugin artifacts"); + + const targets = new Set(); + let pluginIdentity; + const artifacts = metadataFiles.map((file) => { + const artifact = JSON.parse(fs.readFileSync(file, "utf8")); + if (!/^[a-z0-9-]{1,64}$/.test(artifact.target || "")) throw new Error(`Invalid artifact target in ${file}`); + if (!/^[a-fA-F0-9]{64}$/.test(artifact.sha256 || "")) throw new Error(`Invalid artifact SHA-256 in ${file}`); + if (artifact.signingKeyId !== undefined) throw new Error(`Candidate metadata ${file} must not declare signingKeyId`); + if (!Number.isSafeInteger(artifact.size) || artifact.size < 0) throw new Error(`Invalid artifact size in ${file}`); + const packageName = path.basename(new URL(artifact.url, "https://plugins.invalid/releases/").pathname); + const packageFile = packages.get(packageName); + if (!packageFile) throw new Error(`Artifact metadata ${file} references missing package ${packageName}`); + const packageBytes = fs.readFileSync(packageFile); + const packageSha256 = crypto.createHash("sha256").update(packageBytes).digest("hex"); + if (artifact.size !== packageBytes.length) throw new Error(`Artifact metadata ${file} has the wrong package size`); + if (artifact.sha256.toLowerCase() !== packageSha256) throw new Error(`Artifact metadata ${file} has the wrong package SHA-256`); + const entries = childProcess.execFileSync("unzip", ["-Z1", packageFile], { encoding: "utf8" }).split(/\r?\n/); + if (entries.includes("signature.json")) throw new Error(`Candidate package ${packageName} must be unsigned`); + const manifest = JSON.parse(childProcess.execFileSync("unzip", ["-p", packageFile, "manifest.json"], { encoding: "utf8" })); + const identity = { + id: manifest.id, + name: manifest.name, + description: manifest.description || "", + publisher: manifest.publisher, + version: manifest.version, + permissions: [...(manifest.permissions || [])].sort(), + }; + if (!identity.id || !identity.name || !identity.publisher || !identity.version) throw new Error(`Package ${packageName} has incomplete manifest identity`); + if (pluginIdentity && JSON.stringify(pluginIdentity) !== JSON.stringify(identity)) throw new Error(`Package ${packageName} manifest identity differs from other targets`); + pluginIdentity ||= identity; + if (targets.has(artifact.target)) throw new Error(`Duplicate artifact target ${artifact.target}`); + targets.add(artifact.target); + return artifact; + }).sort((left, right) => left.target.localeCompare(right.target)); + + fs.writeFileSync("release-assets/release-candidates.json", `${JSON.stringify({ plugin: pluginIdentity, artifacts }, null, 2)}\n`); + NODE + + - name: Upload assets to GitHub Release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.release-tag }} + run: | + mapfile -d '' packages < <(find release-assets -type f -name '*.dbxp' -print0) + if [ "${#packages[@]}" -eq 0 ]; then + echo "No .dbxp packages were downloaded" >&2 + exit 1 + fi + gh release upload "$RELEASE_TAG" "${packages[@]}" release-assets/release-candidates.json \ + --repo "$GITHUB_REPOSITORY" \ + --clobber diff --git a/.gitignore b/.gitignore index ba7f012d5..9c8303be2 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ plugins/jdbc/build/ plugins/jdbc/bin/main/ plugins/jdbc/bin/test/ plugins/jdbc/dist/ +packages/plugin-cli/sdk-root/ docs/.next/ docs/out/ docs/.source/ @@ -65,6 +66,7 @@ Thumbs.db .env .env.* !.env.example +.dbx-plugin-signing-key.env # Temp tmp/ @@ -81,6 +83,7 @@ review_classified.json # Logs *.log +/.dbx-dev/ # Generated changelog data diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d6bf52449..9340b42ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,6 +65,7 @@ For a real local Java agent test, build the target `shadowJar`, back up and repl | `crates/dbx-web/` | Docker / Web HTTP backend | | `packages/cli/` | `@dbx-app/cli` | | `packages/mcp-server/` | `@dbx-app/mcp-server` | +| `packages/plugin-cli/` | Precompiled `@dbx-app/plugin-cli` launcher and bundled plugin SDKs | | `packages/mongo-shell/` | Private MongoDB editor parsing helpers | | `docs/` | Official documentation site | | `examples/` | Sample configs and automation scripts | diff --git a/Cargo.lock b/Cargo.lock index fea02f857..2ae52b7ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1916,6 +1916,7 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", + "digest 0.10.7", "fiat-crypto 0.2.9", "rustc_version", "subtle", @@ -2092,9 +2093,11 @@ dependencies = [ "csv", "dbx-sqlite-worker", "deadpool-postgres", + "ed25519-dalek 2.2.0", "encoding_rs", "flate2", "font-kit", + "fs2", "futures", "httpdate", "iana-time-zone", @@ -2126,6 +2129,7 @@ dependencies = [ "rust_decimal", "rustls 0.23.43", "rustls-pemfile 2.2.0", + "semver", "serde", "serde_json", "serde_yaml", @@ -2587,19 +2591,43 @@ dependencies = [ "digest 0.11.3", "elliptic-curve", "rfc6979", - "signature", + "signature 3.0.0", "spki 0.8.0-rc.4", "zeroize", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] + [[package]] name = "ed25519" version = "3.0.0-rc.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890" dependencies = [ - "pkcs8", - "signature", + "pkcs8 0.11.0-rc.11", + "signature 3.0.0", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", ] [[package]] @@ -2609,11 +2637,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "053618a4c3d3bc24f188aa660ae75a46eeab74ef07fb415c61431e5e7cd4749b" dependencies = [ "curve25519-dalek 5.0.0-pre.6", - "ed25519", + "ed25519 3.0.0-rc.4", "rand_core 0.10.1", "serde", "sha2 0.11.0", - "signature", + "signature 3.0.0", "subtle", "zeroize", ] @@ -2638,7 +2666,7 @@ dependencies = [ "hybrid-array", "once_cell", "pem-rfc7468 1.0.0", - "pkcs8", + "pkcs8 0.11.0-rc.11", "rand_core 0.10.1", "rustcrypto-ff", "rustcrypto-group", @@ -3035,6 +3063,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -4210,7 +4248,7 @@ dependencies = [ "bcrypt-pbkdf", "crypto-bigint", "ecdsa", - "ed25519-dalek", + "ed25519-dalek 3.0.0-pre.6", "hex", "hmac 0.13.0", "num-bigint-dig", @@ -4222,7 +4260,7 @@ dependencies = [ "sec1", "sha1 0.11.0", "sha2 0.11.0", - "signature", + "signature 3.0.0", "ssh-cipher", "ssh-encoding", "subtle", @@ -6097,6 +6135,16 @@ dependencies = [ "spki 0.8.0-rc.4", ] +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + [[package]] name = "pkcs8" version = "0.11.0-rc.11" @@ -7053,10 +7101,10 @@ dependencies = [ "crypto-primes", "digest 0.11.3", "pkcs1", - "pkcs8", + "pkcs8 0.11.0-rc.11", "rand_core 0.10.1", "sha2 0.11.0", - "signature", + "signature 3.0.0", "spki 0.8.0-rc.4", "zeroize", ] @@ -7121,7 +7169,7 @@ dependencies = [ "der 0.8.1", "digest 0.10.7", "ecdsa", - "ed25519-dalek", + "ed25519-dalek 3.0.0-pre.6", "elliptic-curve", "enum_dispatch", "flate2", @@ -7150,7 +7198,7 @@ dependencies = [ "pbkdf2 0.13.0", "pkcs1", "pkcs5", - "pkcs8", + "pkcs8 0.11.0-rc.11", "polyval 0.7.3", "rand 0.10.2", "rand_core 0.10.1", @@ -7165,7 +7213,7 @@ dependencies = [ "sha2 0.10.9", "sha2 0.11.0", "sha3", - "signature", + "signature 3.0.0", "spki 0.8.0-rc.4", "ssh-encoding", "subtle", @@ -8034,6 +8082,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "signature" version = "3.0.0" diff --git a/README.md b/README.md index cd6f290da..93b42359a 100644 --- a/README.md +++ b/README.md @@ -458,6 +458,8 @@ make docs The official DBX documentation site lives in `docs/`. If you want to improve the website content or documentation pages, edit the files under `docs/` and run `make docs` to preview the site locally. +Plugin authors should start with [Develop and Submit DBX Plugins](https://dbxio.com/en/docs/plugin-development). Plugin source normally stays in its own repository; Marketplace listing pull requests go to [`t8y2/dbx-store`](https://github.com/t8y2/dbx-store), while plugin host, SDK, and CLI changes go to this repository. + For clean, reproducible local database instances, use the versioned Docker Compose recipes under [`deploy/database/`](deploy/database/README.md): ```bash @@ -495,6 +497,7 @@ The installer will be in `src-tauri/target/release/bundle/`. ## Documentation - [Official docs](https://dbxio.com/en/docs/what-is-dbx) — feature guides and tutorials +- [Plugin development](https://dbxio.com/en/docs/plugin-development) — create plugins and submit Marketplace listings to the correct repository - [Database Test Lab](https://dbxio.com/en/docs/database-lab) — local database recipes for development and verification - [Contributing](CONTRIBUTING.md) — how to pick up issues and open PRs - [Web API reference](docs/content/docs/web-api.mdx) — HTTP API for Docker/Web deployments diff --git a/SECURITY.md b/SECURITY.md index 31abe6da3..aba4865b5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -26,5 +26,8 @@ Security-sensitive areas include: - AI provider keys and OpenAI-compatible endpoint configuration. - MCP and CLI access to local DBX connections. - Docker web service authentication and data directory handling. +- Plugin package verification, native sidecar execution, sandboxed plugin UI, host bridge permissions, and plugin connection secrets. + +Plugin connection secrets are stored outside ordinary connection JSON. Unencrypted sync snapshots are always redacted. If encrypted secret sync is enabled with a user-provided passphrase, plugin secrets are included only in the encrypted payload and are restored through the normal secret-store path. Please avoid testing against systems you do not own or have explicit permission to assess. diff --git a/agents/drivers/duckdb/Cargo.lock b/agents/drivers/duckdb/Cargo.lock index 6265365ab..54376b084 100644 --- a/agents/drivers/duckdb/Cargo.lock +++ b/agents/drivers/duckdb/Cargo.lock @@ -1154,6 +1154,7 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", + "digest 0.10.7", "fiat-crypto 0.2.9", "rustc_version", "subtle", @@ -1245,8 +1246,10 @@ dependencies = [ "csv", "dbx-sqlite-worker", "deadpool-postgres", + "ed25519-dalek 2.2.0", "encoding_rs", "flate2", + "fs2", "futures", "httpdate", "iana-time-zone", @@ -1275,6 +1278,7 @@ dependencies = [ "rust_decimal", "rustls 0.23.42", "rustls-pemfile 2.2.0", + "semver", "serde", "serde_json", "serde_yaml", @@ -1551,19 +1555,43 @@ dependencies = [ "digest 0.11.3", "elliptic-curve", "rfc6979", - "signature", + "signature 3.0.0", "spki 0.8.0-rc.4", "zeroize", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] + [[package]] name = "ed25519" version = "3.0.0-rc.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890" dependencies = [ - "pkcs8", - "signature", + "pkcs8 0.11.0-rc.11", + "signature 3.0.0", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", ] [[package]] @@ -1573,11 +1601,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "053618a4c3d3bc24f188aa660ae75a46eeab74ef07fb415c61431e5e7cd4749b" dependencies = [ "curve25519-dalek 5.0.0-pre.6", - "ed25519", + "ed25519 3.0.0-rc.4", "rand_core 0.10.1", "serde", "sha2 0.11.0", - "signature", + "signature 3.0.0", "subtle", "zeroize", ] @@ -1602,7 +1630,7 @@ dependencies = [ "hybrid-array", "once_cell", "pem-rfc7468 1.0.0", - "pkcs8", + "pkcs8 0.11.0-rc.11", "rand_core 0.10.1", "rustcrypto-ff", "rustcrypto-group", @@ -1799,6 +1827,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -2321,7 +2359,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.57.0", + "windows-core 0.62.2", ] [[package]] @@ -2526,7 +2564,7 @@ dependencies = [ "bcrypt-pbkdf", "crypto-bigint", "ecdsa", - "ed25519-dalek", + "ed25519-dalek 3.0.0-pre.6", "hex", "hmac 0.13.0", "num-bigint-dig", @@ -2538,7 +2576,7 @@ dependencies = [ "sec1", "sha1 0.11.0", "sha2 0.11.0", - "signature", + "signature 3.0.0", "ssh-cipher", "ssh-encoding", "subtle", @@ -3729,6 +3767,16 @@ dependencies = [ "spki 0.8.0-rc.4", ] +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + [[package]] name = "pkcs8" version = "0.11.0-rc.11" @@ -4399,10 +4447,10 @@ dependencies = [ "crypto-primes", "digest 0.11.3", "pkcs1", - "pkcs8", + "pkcs8 0.11.0-rc.11", "rand_core 0.10.1", "sha2 0.11.0", - "signature", + "signature 3.0.0", "spki 0.8.0-rc.4", "zeroize", ] @@ -4448,7 +4496,7 @@ dependencies = [ "der 0.8.1", "digest 0.10.7", "ecdsa", - "ed25519-dalek", + "ed25519-dalek 3.0.0-pre.6", "elliptic-curve", "enum_dispatch", "flate2", @@ -4477,7 +4525,7 @@ dependencies = [ "pbkdf2 0.13.0", "pkcs1", "pkcs5", - "pkcs8", + "pkcs8 0.11.0-rc.11", "polyval 0.7.3", "rand 0.10.2", "rand_core 0.10.1", @@ -4492,7 +4540,7 @@ dependencies = [ "sha2 0.10.9", "sha2 0.11.0", "sha3", - "signature", + "signature 3.0.0", "spki 0.8.0-rc.4", "ssh-encoding", "subtle", @@ -5063,6 +5111,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "signature" version = "3.0.0" @@ -5415,7 +5472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 38d52ba73..933b3cf20 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -70,6 +70,7 @@ import { openQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile"; import { rememberExternalSqlFileTarget, resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget"; import { externalSqlFileOpenErrorMessage, externalSqlEditorMaxBytes, isSqlFilePath, readBrowserSqlFile, sqlFileTitleFromPath } from "@/lib/sql/sqlFileOpen"; import type { ConnectionConfig, DatabaseType, ObjectBrowserFilter, ObjectSourceKind, QueryTab, TabOutputView, TreeNode } from "@/types/database"; +import type { PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation"; import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink"; import { parseAiConfigDeepLink, type AiConfigDeepLinkDraft } from "@/lib/ai/aiConfigDeepLink"; import { activeDesktopAiRuns, blockingDesktopAiRunsForQuit } from "@/lib/ai/desktopAiRunRegistry"; @@ -155,6 +156,7 @@ const QueryHistory = defineAsyncComponent(() => import("@/components/editor/Quer const SqlLibraryPanel = defineAsyncComponent(() => import("@/components/layout/SqlLibraryPanel.vue")); const SqlFilePanel = defineAsyncComponent(() => import("@/components/layout/SqlFilePanel.vue")); const DriverStorePage = defineAsyncComponent(() => import("@/components/config/DriverStoreDialog.vue")); +const PluginCenterPage = defineAsyncComponent(() => import("@/components/plugins/PluginContributionsPanel.vue")); const EditorSettingsPage = defineAsyncComponent(() => import("@/components/editor/EditorSettingsDialog.vue")); const UpdateDialog = defineAsyncComponent(() => import("@/components/layout/UpdateDialog.vue")); const CloseActionPromptDialog = defineAsyncComponent(() => import("@/components/layout/CloseActionPromptDialog.vue")); @@ -323,8 +325,13 @@ const showQueryEditorObjectSourceDialog = ref(false); const driverStoreTabOpen = ref(false); const driverStoreActive = ref(false); const driverStoreActiveTab = ref<"agent" | "jdbc" | "storage" | "runtime">("agent"); -const settingsReturnSurface = ref<"query" | "driverStore" | "welcome">("welcome"); +const pluginCenterTabOpen = ref(false); +const pluginCenterActive = ref(false); +const pluginCenterFocus = ref(null); +const connectionPluginProvider = ref(null); +const settingsReturnSurface = ref<"query" | "driverStore" | "pluginCenter" | "welcome">("welcome"); const showDriverStore = computed(() => driverStoreTabOpen.value && driverStoreActive.value); +const showPluginCenter = computed(() => pluginCenterTabOpen.value && pluginCenterActive.value); const showSettingsPage = computed(() => Boolean(settingsPageTabOpen.value && settingsStore.settingsPageActive)); const showQuickOpen = ref(false); const showTabSwitcher = ref(false); @@ -863,7 +870,7 @@ const specialPageTabs = computed(() => ({ driverStoreActive: driverStoreActive.value, driverUpdateCount: toolbarAgentDriverUpdateCount.value, })); -provide(GROUP_TAB_BAR_PORTAL, createGroupTabBarPortal(computed(() => !isDetachedWindowContext && (driverStoreActive.value || settingsStore.settingsPageActive)))); +provide(GROUP_TAB_BAR_PORTAL, createGroupTabBarPortal(computed(() => !isDetachedWindowContext && (driverStoreActive.value || pluginCenterActive.value || settingsStore.settingsPageActive)))); provide(EDITOR_TOOLBAR_ACTIONS, { explainMode, blockDangerousRedisCommands, @@ -976,6 +983,7 @@ const { setupTauriListeners, cleanupTauriListeners } = useTauriEvents({ openDbFilePath, openConnectionDeepLink, openAiConfigDeepLink, + closeActiveSurface, }); const { showCloseActionPrompt, chooseQuit, chooseMinimize, cancelCloseActionPrompt, performCloseAction, setupCloseActionPromptListener, cleanupCloseActionPromptListener } = useCloseActionPrompt({ requestClose: requestAppClose }); useVisibilityChange(); @@ -1005,16 +1013,17 @@ function openSettings(initialTab = "appearance", initialSection?: string) { settingsInitialSection.value = initialSection; settingsNavigationRequestId.value += 1; if (!settingsStore.settingsPageActive) { - settingsReturnSurface.value = showDriverStore.value ? "driverStore" : activeTab.value ? "query" : "welcome"; + settingsReturnSurface.value = showDriverStore.value ? "driverStore" : showPluginCenter.value ? "pluginCenter" : activeTab.value ? "query" : "welcome"; } activateSettingsPage(); } -type MainContentSurface = "query" | "settings" | "driverStore"; +type MainContentSurface = "query" | "settings" | "driverStore" | "pluginCenter"; function activateMainContentSurface(surface: MainContentSurface) { settingsStore.settingsPageActive = surface === "settings"; driverStoreActive.value = surface === "driverStore"; + pluginCenterActive.value = surface === "pluginCenter"; } watch( @@ -1051,6 +1060,10 @@ function closeSettingsPage() { activateMainContentSurface("driverStore"); return; } + if (settingsReturnSurface.value === "pluginCenter" && pluginCenterTabOpen.value) { + activateMainContentSurface("pluginCenter"); + return; + } activateMainContentSurface("query"); } @@ -1076,6 +1089,23 @@ function closeDriverStorePage() { driverStoreActiveTab.value = "agent"; driverStoreFocus.value = null; } + +function openPluginCenterPage(focus?: PluginCenterFocus | null) { + pluginCenterFocus.value = focus ?? null; + pluginCenterTabOpen.value = true; + activateMainContentSurface("pluginCenter"); +} + +function closePluginCenterPage() { + pluginCenterTabOpen.value = false; + pluginCenterFocus.value = null; + activateMainContentSurface("query"); +} + +function openPluginConnectionDialog(pluginId: string, providerId: string) { + connectionPluginProvider.value = { pluginId, providerId }; + showConnectionDialog.value = true; +} const toolbarAgentDriverUpdateCount = computed(() => (updateNotificationsEnabled.value ? agentDriverUpdateCount.value : 0)); const toolbarHasUpdateAvailable = computed(() => updateNotificationsEnabled.value && hasUpdateAvailable.value); const toolbarMcpUpdateAvailable = computed(() => updateNotificationsEnabled.value && mcpUpdateAvailable.value); @@ -2299,6 +2329,7 @@ function setConnectionDialogOpen(value: boolean) { showConnectionDialog.value = value; if (!value) { connectionDialogPrefill.value = null; + connectionPluginProvider.value = null; connectionDialogInitialTab.value = undefined; } } @@ -2407,6 +2438,19 @@ async function openConnectionQuery(connectionId: string) { } return; } + if (initialTarget.kind === "plugin-workbench") { + try { + await queryStore.openPluginConnection(connectionId); + } catch (e: any) { + toast( + t("connection.connectFailed", { + message: translateBackendError(t, e), + }), + 5000, + ); + } + return; + } if (initialTarget.kind === "etcd" || initialTarget.kind === "zookeeper" || initialTarget.kind === "consul") { try { await connectionStore.ensureConnected(connectionId); @@ -3072,6 +3116,18 @@ function handleNativeSelectAll(e: KeyboardEvent) { if (shouldBlockAppNativeSelectAll(e)) e.preventDefault(); } +async function closeActiveSurface() { + if (showSettingsPage.value) { + closeSettingsPage(); + } else if (showPluginCenter.value) { + closePluginCenterPage(); + } else if (showDriverStore.value) { + closeDriverStorePage(); + } else if (queryStore.activeTabId) { + if (await queryStore.clearQueryResults(queryStore.activeTabId)) return; + queryStore.closeTab(queryStore.activeTabId); + } +} async function handleKeydown(e: KeyboardEvent) { if (e.defaultPrevented) return; @@ -3187,14 +3243,7 @@ async function handleKeydown(e: KeyboardEvent) { } if (isCloseTabShortcut(e, shortcuts)) { e.preventDefault(); - if (showSettingsPage.value) { - closeSettingsPage(); - } else if (showDriverStore.value) { - closeDriverStorePage(); - } else if (queryStore.activeTabId) { - if (await queryStore.clearQueryResults(queryStore.activeTabId)) return; - queryStore.closeTab(queryStore.activeTabId); - } + await closeActiveSurface(); return; } if (isSaveShortcut(e, shortcuts) && e.target instanceof Element && isObjectSourceSaveShortcutTarget(e.target)) { @@ -3522,6 +3571,7 @@ onUnmounted(() => { :sql-library-save-feedback-id="sqlLibrarySaveFeedbackId" :show-sql-file-panel="showSqlFilePanel" :show-driver-store="showDriverStore" + :show-plugin-center="showPluginCenter" :show-settings-page="showSettingsPage" :checking-updates="checkingUpdates" :has-update-available="toolbarHasUpdateAvailable" @@ -3545,6 +3595,7 @@ onUnmounted(() => { @open-github="openGitHub" @open-settings="openSettings(toolbarMcpUpdateAvailable ? 'mcp' : 'appearance')" @open-driver-store="openDriverStorePage" + @open-plugin-center="openPluginCenterPage()" @check-updates="checkUpdates()" @open-transfer="dialogs.showTransferDialog.value = true" @open-sql-file="dialogs.showSqlFileDialog.value = true" @@ -3574,6 +3625,8 @@ onUnmounted(() => { ref="appTabBarRef" :driver-store-open="driverStoreTabOpen" :driver-store-active="driverStoreActive" + :plugin-center-open="pluginCenterTabOpen" + :plugin-center-active="pluginCenterActive" :settings-page-open="settingsPageTabOpen" :settings-page-active="settingsStore.settingsPageActive" :agent-driver-update-count="toolbarAgentDriverUpdateCount" @@ -3582,9 +3635,11 @@ onUnmounted(() => { :tab-bar-width="tabBarWidth" :tab-bar-collapsed="tabBarCollapsed" @activate-driver-store="openDriverStorePage" + @activate-plugin-center="openPluginCenterPage(pluginCenterFocus)" @activate-settings-page="activateSettingsPage" @activate-tab="activateQueryTab" @close-driver-store="closeDriverStorePage" + @close-plugin-center="closePluginCenterPage" @close-settings-page="closeSettingsPage" @save-tab="handleSaveTab" @discard-tab-close="handleDiscardPendingTabClose" @@ -3594,6 +3649,7 @@ onUnmounted(() => { @detach-tab="detachTab" > + { -
+
{ (), { @@ -67,12 +67,12 @@ const props = withDefaults( const emit = defineEmits<{ "update-count-change": [count: number]; - "update:activeTab": [tab: "agent" | "jdbc" | "storage" | "runtime"]; + "update:activeTab": [tab: DriverStoreTab]; }>(); const driverStoreTab = computed({ get: () => props.activeTab, - set: (tab: "agent" | "jdbc" | "storage" | "runtime") => emit("update:activeTab", tab), + set: (tab: DriverStoreTab) => emit("update:activeTab", tab), }); // ──────────── Driver store path ──────────── diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index e9a20f752..59ce4b2fc 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -14,7 +14,25 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { HelpTooltip, Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Switch } from "@/components/ui/switch"; -import type { ConnectionConfig, ConnectionTestResult, DatabaseConnectionInfo, DatabaseType, HttpTunnelConfig, IdentifierCase, JdbcDriverInfo, JdbcLocalBundleInfo, JdbcMavenBundleInfo, ProxyTunnelConfig, SshConfigHostEntry, SshTunnelConfig, TransportLayerConfig } from "@/types/database"; +import type { + ConnectionConfig, + ConnectionTestResult, + DatabaseConnectionInfo, + DatabaseType, + HttpTunnelConfig, + IdentifierCase, + InstalledPlugin, + JdbcDriverInfo, + JdbcLocalBundleInfo, + JdbcMavenBundleInfo, + PluginConnectionAction, + PluginFormField, + PluginFormFieldValue, + ProxyTunnelConfig, + SshConfigHostEntry, + SshTunnelConfig, + TransportLayerConfig, +} from "@/types/database"; import { CONNECTION_PICKER_OPTIONS, CONNECTION_PROFILES, CONNECTION_PROFILE_ICONS, type ConnectionPickerOption, type ConnectionProfileCategory, type ConnectionProfileDefinition } from "@/types/generated/connectionProfiles"; import type { InfluxDbExternalConfig, InfluxDbVersion } from "@/types/influxdb"; import type { VictoriaMetricsExternalConfig } from "@/types/victoriametrics"; @@ -33,7 +51,11 @@ import { normalizeRedisKeyTemplates, redisKeyTemplatesToTextarea } from "@/lib/r import { normalizeGlobalConnectTimeoutSecs, normalizeGlobalQueryTimeoutSecs, useSettingsStore } from "@/stores/settingsStore"; import { useToast } from "@/composables/useToast"; import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; +import PluginConnectionFields from "@/components/plugins/PluginConnectionFields.vue"; +import PluginIcon from "@/components/plugins/PluginIcon.vue"; import * as api from "@/lib/backend/api"; +import { buildPluginConnectionConfig, createFrontendPluginRegistry, parsePluginConnectionProviderOptionValue, pluginConnectionActionsForDialog, pluginConnectionFormValues, pluginConnectionProviderIcon, pluginConnectionProviderOptionValue } from "@/lib/plugins/frontendPlugin"; +import type { PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { applyMeilisearchBasePathToExternalConfig, applyParsedConnectionUrl, normalizeMongoConnectionString, parseConnectionUrl } from "@/lib/connection/connectionUrl"; import { DEFAULT_QUERY_TIMEOUT_SECS, MAX_CONNECT_TIMEOUT_SECS, MAX_QUERY_TIMEOUT_SECS } from "@/lib/connection/timeoutLimits"; @@ -167,8 +189,8 @@ import { jdbcProductProfileIdsForCategory, } from "@/lib/database/jdbcProductProfiles"; -type DbOption = ConnectionPickerOption; -type DbCategoryKey = ConnectionProfileCategory; +type DbOption = Omit & { category?: DbCategoryKey; plugin?: boolean; pluginId?: string; pluginIcon?: string }; +type DbCategoryKey = ConnectionProfileCategory | "plugins"; type DbCategory = { key: DbCategoryKey; title: string; options: DbOption[] }; type DialogStep = "select" | "config"; export type ConfigTab = "connection" | "advanced" | "tls" | "transport"; @@ -234,7 +256,7 @@ type LegacyConnectionConfig = ConnectionConfig & LegacyTransportFields; type ConnectionForm = Omit; type ConnectionTestState = ConnectionTestResult & { ok: boolean; scope?: "connection" | "ssh" }; -const { t } = useI18n(); +const { t, locale: appLocale } = useI18n(); const { toast } = useToast(); const settingsStore = useSettingsStore(); const connectionNoteVisibilityDraft = reactive(createConnectionNoteVisibilityDraft(settingsStore.editorSettings.sidebarShowConnectionNotes)); @@ -250,6 +272,7 @@ const isDesktop = isTauriRuntime(); const props = defineProps<{ editConfig?: ConnectionConfig; prefillConfig?: ConnectionDeepLinkDraft | null; + pluginProvider?: PluginCenterFocus | null; initialTab?: ConfigTab; }>(); @@ -305,6 +328,12 @@ const connectionErrorDetail = ref(""); const testResultCopied = ref(false); const connectionErrorCopied = ref(false); const editingId = ref(null); +const installedPlugins = ref([]); +const pluginFormValues = ref>({}); +const pluginLoadError = ref(""); +const runningPluginActionId = ref(null); +type PluginActionStatus = { ok: boolean; message: string }; +const pluginActionStatus = ref(null); const draftTestConnectionId = ref(uuid()); const showVisibleDatabasesDialog = ref(false); const isLoadingVisibleDatabases = ref(false); @@ -1125,6 +1154,9 @@ const driverProfiles: Record = { }; function profileForConfig(config: ConnectionConfig) { + if (config.db_type === "plugin" && config.plugin_id && config.plugin_connection_provider) { + return pluginConnectionProviderOptionValue(config.plugin_id, config.plugin_connection_provider); + } if (config.db_type === "oracle") return "oracle"; if (config.driver_profile && driverProfiles[config.driver_profile]) { if (config.driver_profile === "oceanbase-oracle") return "oceanbase"; @@ -1143,10 +1175,93 @@ function profileForConfig(config: ConnectionConfig) { } function selectedProfile() { + if (form.value.db_type === "plugin") { + const entry = selectedPluginProvider.value; + const providerId = parsePluginConnectionProviderOptionValue(selectedType.value)?.providerId; + return { + type: "plugin" as DatabaseType, + port: form.value.port || 0, + user: form.value.username || "", + label: entry?.contribution.label || form.value.driver_label || providerId || "Plugin", + icon: "plugin", + }; + } const profile = selectedType.value === "gbase" && (form.value.driver_profile === "gbase8a" || form.value.driver_profile === "gbase8s") ? form.value.driver_profile : selectedType.value; return driverProfiles[profile] ?? driverProfiles.mysql; } +const isPluginConnection = computed(() => form.value.db_type === "plugin"); +const pluginConnectionProviders = computed(() => createFrontendPluginRegistry(installedPlugins.value, appLocale.value).listConnectionProviders()); +const selectedPluginProvider = computed(() => pluginProviderEntryForOption(selectedType.value)); +const selectedPluginIcon = computed(() => (selectedPluginProvider.value ? pluginConnectionProviderIcon(selectedPluginProvider.value) : undefined)); +const pluginFooterActions = computed(() => (selectedPluginProvider.value ? pluginConnectionActionsForDialog(selectedPluginProvider.value.contribution, !!editingId.value) : [])); +const pluginFooterBusy = computed(() => isTesting.value || isSaving.value || !!runningPluginActionId.value); + +function pluginProviderEntry(pluginId: string, providerId: string) { + return pluginConnectionProviders.value.find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === providerId) || null; +} + +function pluginProviderEntryForOption(value: string) { + const target = parsePluginConnectionProviderOptionValue(value); + return target ? pluginProviderEntry(target.pluginId, target.providerId) : null; +} + +function applyPluginProvider(value: string, preserveConnectionFields = false, existing?: ConnectionConfig): boolean { + const target = parsePluginConnectionProviderOptionValue(value); + if (!target) return false; + const entry = pluginProviderEntry(target.pluginId, target.providerId); + if (!entry) return false; + + selectedType.value = value; + if (!preserveConnectionFields) { + form.value = { + ...defaultForm(), + name: entry.contribution.label, + db_type: "plugin", + driver_profile: "plugin", + driver_label: entry.contribution.label, + host: "", + port: 0, + username: "", + password: "", + query_timeout_secs: 60, + }; + } else { + form.value.db_type = "plugin"; + form.value.driver_profile = "plugin"; + form.value.driver_label = entry.contribution.label; + } + pluginFormValues.value = pluginConnectionFormValues(entry.contribution, existing); + if (!preserveConnectionFields) { + const nameField = entry.contribution.fields.find((field) => field.binding === "name"); + const defaultName = nameField ? pluginFormValues.value[nameField.key] : undefined; + if (typeof defaultName === "string" && defaultName.trim()) form.value.name = defaultName.trim(); + } + connectionUrlInput.value = ""; + appliedConnectionUrlInput.value = ""; + return true; +} + +async function loadInstalledPlugins() { + pluginLoadError.value = ""; + try { + installedPlugins.value = await api.listPlugins(); + } catch (cause) { + installedPlugins.value = []; + pluginLoadError.value = cause instanceof Error ? cause.message : String(cause); + } +} + +function applyRequestedPluginProvider(target: PluginCenterFocus | null | undefined) { + if (!target?.pluginId || !target.providerId) return false; + const value = pluginConnectionProviderOptionValue(target.pluginId, target.providerId); + if (!applyPluginProvider(value)) return false; + selectedDbCategory.value = "plugins"; + dialogStep.value = "config"; + configTab.value = "connection"; + return true; +} + function mqExtraRecord(config?: Partial): Record { const extra = config?.extra; return extra && typeof extra === "object" && !Array.isArray(extra) ? (extra as Record) : {}; @@ -2622,6 +2737,13 @@ watch( customColorInput.value = config.color || ""; selectedTransportLayerId.value = form.value.transport_layers?.[0]?.id || null; selectedType.value = profile; + if (config.db_type === "plugin") { + selectedDbCategory.value = "plugins"; + const entry = config.plugin_id && config.plugin_connection_provider ? pluginProviderEntry(config.plugin_id, config.plugin_connection_provider) : null; + pluginFormValues.value = entry ? pluginConnectionFormValues(entry.contribution, config) : {}; + } else { + pluginFormValues.value = {}; + } if (profile === "oceanbase") { oceanbaseSubMode.value = oceanbaseMode; } @@ -2785,12 +2907,27 @@ function onDbTypeChange(val: string) { } const category = dbCategoryForOption(val); if (category) selectedDbCategory.value = category; + // Keep in sync with PLUGIN_CONNECTION_PROVIDER_OPTION_PREFIX in lib/plugins/frontendPlugin. + if (val.startsWith("plugin-provider:")) { + onPluginProviderOptionChange(val); + return; + } customDriverName.value = ""; applyProfile(val, !!editingId.value); resetTestState(); resetVisibleSchemasState(); } +function onPluginProviderOptionChange(val: string) { + const pluginTarget = parsePluginConnectionProviderOptionValue(val); + if (pluginTarget) { + const existing = props.editConfig?.db_type === "plugin" && props.editConfig.plugin_id === pluginTarget.pluginId && props.editConfig.plugin_connection_provider === pluginTarget.providerId ? props.editConfig : undefined; + applyPluginProvider(val, !!editingId.value, existing); + } + resetTestState(); + resetVisibleSchemasState(); +} + function selectIgniteConnectionProfile(profile: IgniteConnectionProfile) { if (form.value.db_type === profile) return; const category = dbCategoryForOption(profile); @@ -2904,11 +3041,22 @@ assertCompleteDatabaseCategories( const hiddenPickerOptionTypes = new Set(Object.keys(MERGED_PICKER_OPTION_FOR_TYPE)); const dbCategories = computed(() => { - return dbCategoryDefinitions.map((category) => ({ + const categories: DbCategory[] = dbCategoryDefinitions.map((category) => ({ key: category.key, title: t(category.titleKey), options: dbOptions.filter((option) => category.optionValues.includes(option.value) && !hiddenPickerOptionTypes.has(option.value)), })); + const pluginOptions: DbOption[] = pluginConnectionProviders.value.map((entry) => ({ + value: pluginConnectionProviderOptionValue(entry.plugin.manifest.id, entry.contribution.id), + label: entry.contribution.label, + plugin: true, + pluginId: entry.plugin.manifest.id, + pluginIcon: pluginConnectionProviderIcon(entry), + })); + if (pluginOptions.length) { + categories.push({ key: "plugins", title: t("connection.databaseCategoryPlugins"), options: pluginOptions }); + } + return categories; }); function matchesDbOption(option: DbOption, keyword: string, categoryTitle = "") { @@ -2963,7 +3111,7 @@ function dbCategoryForOption(value: string): DbCategoryKey | undefined { return dbCategories.value.find((category) => category.options.some((option) => option.value === pickerValue))?.key; } -const selectedDbIcon = computed(() => iconTypeMap[selectedType.value] || selectedProfile().icon || selectedType.value); +const selectedDbIcon = computed(() => (isPluginConnection.value ? "plugin" : iconTypeMap[selectedType.value] || selectedProfile().icon || selectedType.value)); function supportsNativeAgentJdbcDriverConfigType(dbType: DatabaseType): boolean { return dbType === "prestosql" || dbType === "bigquery" || dbType === "dameng"; } @@ -3424,6 +3572,70 @@ const testResultMessage = computed(() => { if (!testResult.value.ok) return translateBackendError(t, testResult.value.message); return testResult.value.scope === "ssh" ? t("connection.sshTunnelTestSuccess") : t("connection.testSuccess"); }); +const pluginActionStatusMessage = computed(() => { + if (!pluginActionStatus.value) return ""; + return pluginActionStatus.value.ok ? pluginActionStatus.value.message : translateBackendError(t, pluginActionStatus.value.message); +}); + +function pluginFieldValue(field: PluginFormField): PluginFormFieldValue { + if (field.binding === "name") return form.value.name; + return pluginFormValues.value[field.key] ?? field.default; +} + +function pluginFieldHasValue(field: PluginFormField): boolean { + const value = pluginFieldValue(field); + return typeof value === "string" ? value.trim().length > 0 : value !== undefined; +} + +function pluginActionLabel(action: PluginConnectionAction): string { + if (action.label) return action.label; + if (action.kind === "test") return t("connection.test"); + if (action.kind === "save") return t("connection.save"); + if (action.kind === "save-and-connect") return t("connection.saveAndConnect"); + return action.id; +} + +function pluginActionVariant(action: PluginConnectionAction): PluginConnectionAction["variant"] { + return action.variant || (action.kind === "save" || action.kind === "save-and-connect" ? "default" : "outline"); +} + +function pluginActionIsBusy(action: PluginConnectionAction): boolean { + if (action.kind === "test") return isTesting.value; + if (action.kind === "save" || action.kind === "save-and-connect") return isSaving.value; + return runningPluginActionId.value === action.id; +} + +function pluginActionNeedsValidForm(action: PluginConnectionAction): boolean { + return action.kind !== "custom" || action.requires_valid_form !== false; +} + +function pluginActionDisabled(action: PluginConnectionAction): boolean { + if (pluginFooterBusy.value) return true; + return pluginActionNeedsValidForm(action) && !hasRequiredConnectionTarget.value; +} + +function updatePluginFormValues(values: Record) { + pluginFormValues.value = values; + resetTestState(); +} + +function applyPluginActionFieldValues(values: Record | undefined) { + if (!values) return; + const provider = selectedPluginProvider.value?.contribution; + if (!provider) return; + const nextValues = { ...pluginFormValues.value }; + for (const [key, returnedValue] of Object.entries(values)) { + const field = provider.fields.find((candidate) => candidate.key === key); + if (!field) continue; + const value = returnedValue === null ? undefined : returnedValue; + if (field.binding === "name") { + form.value.name = typeof value === "string" ? value : ""; + } + if (value === undefined) delete nextValues[key]; + else nextValues[key] = value; + } + pluginFormValues.value = nextValues; +} const agentInstallPercent = computed(() => driverInstallProgressPercent(agentInstallProgress.value)); const agentInstallProgressLabel = computed(() => { const progress = agentInstallProgress.value; @@ -3449,6 +3661,10 @@ const connectionLabelSmallClass = `${connectionLabelClass} text-xs`; const connectionLabelTopClass = `${connectionLabelClass} mt-2`; const connectionLabelSmallPaddedClass = `${connectionLabelClass} pt-2 text-xs`; const hasRequiredConnectionTarget = computed(() => { + if (isPluginConnection.value) { + const provider = selectedPluginProvider.value?.contribution; + return !!provider && provider.fields.every((field) => !field.required || pluginFieldHasValue(field)); + } if (form.value.db_type === "mq") { if (mqSystemKind.value === "kafka") return mqKafkaConnectionSource.value === "zookeeper" ? !!mqKafkaZooKeeperServers.value.trim() : !!mqKafkaBootstrapServers.value.trim(); if (mqSystemKind.value === "rocketmq") return !!mqRocketmqNamesrvAddr.value.trim(); @@ -3587,6 +3803,43 @@ async function testConnection() { } } +async function runPluginConnectionAction(action: PluginConnectionAction) { + if (!isPluginConnection.value || pluginFooterBusy.value) return; + pluginActionStatus.value = null; + if (action.kind === "test") { + await testConnection(); + if (testResult.value?.ok && action.close_on_success === true) open.value = false; + return; + } + if (action.kind === "save" || action.kind === "save-and-connect") { + await save({ + connectAfterSave: action.kind === "save-and-connect", + closeOnSuccess: action.close_on_success ?? true, + }); + return; + } + if (!ensureConnectionHostResolvedFromUrl()) return; + runningPluginActionId.value = action.id; + resetTestState(); + try { + const config = connectionConfigForSubmit(editingId.value || draftTestConnectionId.value, "", pluginActionNeedsValidForm(action)); + const result = await api.invokePluginConnectionAction(config, action.id); + applyPluginActionFieldValues(result.fieldValues); + const message = result.message?.trim(); + if (message) pluginActionStatus.value = { ok: true, message }; + if (action.close_on_success === true) { + if (message) toast(message, 3000); + open.value = false; + } + } catch (error) { + const message = mongodbAuthFailureHint(errorMessage(error)); + pluginActionStatus.value = { ok: false, message }; + showConnectionError(message); + } finally { + runningPluginActionId.value = null; + } +} + async function testSshTunnel() { if (isTesting.value || isTestingSshTunnel.value) return; @@ -3810,7 +4063,22 @@ function connectionConfigForSshTunnelTest(id: string): ConnectionConfig { return config; } -function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionConfig { +function connectionConfigForSubmit(id: string, generatedName = "", validatePluginRequired = true): ConnectionConfig { + if (isPluginConnection.value) { + const entry = selectedPluginProvider.value; + if (!entry) throw new Error(pluginLoadError.value || t("connection.pluginProviderUnavailable")); + if (validatePluginRequired) { + const missingField = entry.contribution.fields.find((field) => field.required && !pluginFieldHasValue(field)); + if (missingField) throw new Error(t("connection.pluginRequiredField", { field: missingField.label })); + } + const values = { ...pluginFormValues.value }; + for (const field of entry.contribution.fields) { + if (field.binding === "name") values[field.key] = form.value.name; + } + const existing = props.editConfig?.db_type === "plugin" && props.editConfig.plugin_id === entry.plugin.manifest.id && props.editConfig.plugin_connection_provider === entry.contribution.id ? props.editConfig : undefined; + const config = buildPluginConnectionConfig(entry.plugin.manifest.id, entry.contribution, values, existing); + return { ...formValueForSubmit(), ...config, id, name: form.value.name.trim() || generatedName.trim() || config.name, note: form.value.note?.trim() || undefined }; + } const config = { ...formValueForSubmit(), id } as LegacyConnectionConfig; config.database_info = undefined; config.database = normalizeStoredConnectionDatabase(config.db_type, config.database); @@ -4020,7 +4288,7 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo setGaussdbIdentifierQuoteStyle(config, style); setGaussdbTargetServerType(config, targetServerType); setGaussdbCountQueryDop(config, countQueryDop); - } else if (!isDoltDriverProfile(config.driver_profile)) { + } else if (config.db_type !== "plugin" && !isDoltDriverProfile(config.driver_profile)) { config.external_config = undefined; } if (config.db_type === "mongodb" && !mongoUseUrl.value) { @@ -5142,6 +5410,19 @@ watch( void loadAgentDrivers(); void loadSshConfigHosts(); } + void loadInstalledPlugins().then(() => { + if (!open.value) return; + if (props.editConfig?.db_type === "plugin" && props.editConfig.plugin_id && props.editConfig.plugin_connection_provider) { + const entry = pluginProviderEntry(props.editConfig.plugin_id, props.editConfig.plugin_connection_provider); + if (entry) { + selectedType.value = pluginConnectionProviderOptionValue(props.editConfig.plugin_id, props.editConfig.plugin_connection_provider); + selectedDbCategory.value = "plugins"; + pluginFormValues.value = pluginConnectionFormValues(entry.contribution, props.editConfig); + } + } else if (!props.prefillConfig) { + applyRequestedPluginProvider(props.pluginProvider); + } + }); // Preload database names so the summary count is accurate right away. void nextTick(() => { if (canChooseVisibleDatabases.value && hasVisibleDatabaseFilter.value) { @@ -5163,6 +5444,17 @@ watch( }, ); +watch( + () => props.pluginProvider, + (target) => { + if (!open.value || props.editConfig || props.prefillConfig || !target) return; + if (!applyRequestedPluginProvider(target)) { + void loadInstalledPlugins().then(() => applyRequestedPluginProvider(target)); + } + }, + { deep: true }, +); + watch([() => form.value.db_type, () => form.value.username], () => { if (isOracleSysUser(form.value)) form.value.sysdba = true; }); @@ -5401,7 +5693,7 @@ async function persistConnectionNoteVisibilityDraft() { await persistConnectionNoteVisibilityDraftState(connectionNoteVisibilityDraft, settingsStore.editorSettings.sidebarShowConnectionNotes, (value) => settingsStore.updateEditorSettingsAndPersist({ sidebarShowConnectionNotes: value })); } -async function save() { +async function save(options: { connectAfterSave?: boolean; closeOnSuccess?: boolean } = {}) { if (!ensureConnectionHostResolvedFromUrl()) return; if (isSaving.value) return; if (!hasNacosNamespaceScopeForSave()) { @@ -5409,6 +5701,8 @@ async function save() { await openVisibleNacosNamespacesPicker(); return; } + const connectAfterSave = options.connectAfterSave ?? (!editingId.value && form.value.db_type !== "jdbc"); + const closeOnSuccess = options.closeOnSuccess ?? true; const databaseInfoForSave = visibleTestDatabaseInfo.value ?? visibleSavedDatabaseInfo.value; isSaving.value = true; let connectionSaved = false; @@ -5422,6 +5716,24 @@ async function save() { connectionSaved = true; await persistConnectionNoteVisibilityDraft(); store.stopEditing(); + if (connectAfterSave) { + if (closeOnSuccess) { + open.value = false; + await nextTick(); + } + emit("connectStarted", updated.name); + void store + .connect(updated) + .then(() => { + emit("connectSucceeded", updated.name); + }) + .catch((e: any) => { + const message = String(e?.message || e); + if (message.includes(CONNECTION_ATTEMPT_CANCELLED_MESSAGE)) return; + emit("connectFailed", appendConnectionErrorHints(updated, mongodbAuthFailureHint(message), t)); + }); + return; + } } else { const config = withSavedDatabaseInfo(connectionConfigForSubmit(draftTestConnectionId.value), databaseInfoForSave); await ensureRequiredAgentDriverInstalled(config); @@ -5431,11 +5743,11 @@ async function save() { connectionSaved = true; await persistConnectionNoteVisibilityDraft(); draftTestConnectionId.value = uuid(); - if (config.db_type === "jdbc") { - open.value = false; + if (!connectAfterSave) { + if (closeOnSuccess) open.value = false; return; } - open.value = false; + if (closeOnSuccess) open.value = false; await nextTick(); emit("connectStarted", config.name); void store @@ -5452,7 +5764,7 @@ async function save() { }); return; } - open.value = false; + if (closeOnSuccess) open.value = false; } catch (e: any) { const cause = mongodbAuthFailureHint(String(e?.message || e)); const message = connectionSaved ? t("connection.savedSettingsFailed", { message: cause }) : cause; @@ -5939,7 +6251,8 @@ function openExternalUrl(url: string) { @dblclick="goToConnectionStep(opt.value)" > - + + {{ opt.label }} @@ -5958,7 +6271,8 @@ function openExternalUrl(url: string) { @click="onDbTypeChange(opt.value)" @dblclick="goToConnectionStep(opt.value)" > - + + {{ opt.label }} {{ category.title }} @@ -5974,7 +6288,8 @@ function openExternalUrl(url: string) {
- + + {{ t("connection.selectedDatabase") }}: {{ selectedProfile().label }}
@@ -6105,1616 +6421,75 @@ function openExternalUrl(url: string) {
-
- -
- - -
-
- -
- -
- - - - - -
-
- -