From 78520246902404e750352848fe65d8b9a79c33d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Tue, 11 Aug 2026 13:06:38 +0800 Subject: [PATCH] feat(connection): support Consul and Nacos service deep-link prefill --- .../connection/ConnectionDialog.vue | 28 ++- .../connectionDeepLink.services.spec.ts | 164 +++++++++++++++ .../connectionDialog.deepLinkServices.spec.ts | 34 ++++ .../src/lib/connection/connectionDeepLink.ts | 186 ++++++++++++++++-- .../src/lib/connection/connectionUrl.ts | 22 ++- deploy/database/nacos/2.5/recipe.json | 1 + deploy/database/nacos/3.2/recipe.json | 1 + docs/content/docs/cli.cn.mdx | 14 +- docs/content/docs/cli.mdx | 14 +- docs/content/docs/database-lab.cn.mdx | 2 +- docs/content/docs/database-lab.mdx | 2 +- scripts/database-env.mjs | 4 +- scripts/database-env.test.mjs | 30 ++- 13 files changed, 476 insertions(+), 26 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/connection/connectionDeepLink.services.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/connection/connectionDialog.deepLinkServices.spec.ts diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 5288ce2261..77edeaa178 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -33,7 +33,7 @@ import * as api from "@/lib/backend/api"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { applyParsedConnectionUrl, normalizeMongoConnectionString, parseConnectionUrl } from "@/lib/connection/connectionUrl"; import { buildOracleTnsConnectionString, normalizeOracleTnsAdminPath, parseOracleTnsConnectionString } from "@/lib/connection/oracleTnsConnection"; -import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink"; +import { connectionDeepLinkServiceHydrationValue, parseConnectionDeepLink, parseServiceConnectionUrl, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink"; import { connectionUrlPlaceholder as getUrlPlaceholder } from "@/lib/connection/connectionPresentation"; import { parseGaussdbHosts, serializeGaussdbHosts, type GaussdbHostEntry } from "@/lib/connection/gaussdbHosts"; import { h2ConnectionModeForConfig, h2FileJdbcUrlWithPath, h2FilePathFromJdbcUrl, isH2SplitJdbcUrl, type H2ConnectionMode } from "@/lib/database/h2Connection"; @@ -3502,7 +3502,7 @@ function clearEditedConnectionErrorAfterSuccessfulTest() { function applyConnectionUrlToForm(input: string): boolean { try { - const draft = parseConnectionDeepLink(input); + const draft = parseConnectionDeepLink(input) ?? parseServiceConnectionUrl(input); if (draft) { applyConnectionDraftToForm({ ...draft, oneTime: undefined }); resetTestState(); @@ -4687,7 +4687,24 @@ function resetForm() { const submittedOneTimePrefillKey = ref(null); function oneTimePrefillKey(draft: ConnectionDeepLinkDraft) { - return JSON.stringify([draft.name, draft.dbType, draft.driverProfile, draft.driverLabel, draft.host, draft.port, draft.portExplicit, draft.username, draft.password, draft.database, draft.urlParams, draft.ssl, draft.connectionString, draft.oracleConnectionType, draft.useMongoUrl]); + return JSON.stringify([ + draft.name, + draft.dbType, + draft.driverProfile, + draft.driverLabel, + draft.host, + draft.port, + draft.portExplicit, + draft.username, + draft.password, + draft.database, + draft.urlParams, + draft.ssl, + draft.connectionString, + draft.oracleConnectionType, + draft.useMongoUrl, + draft.serviceConfig, + ]); } function submitOneTimePrefill(draft: ConnectionDeepLinkDraft) { @@ -4722,6 +4739,11 @@ function applyConnectionDraftToConfig(config: Omit, draf function applyConnectionDraftToForm(draft: ConnectionDeepLinkDraft) { applyProfile(draft.driverProfile); form.value = applyConnectionDraftToConfig(form.value, draft); + if (draft.serviceConfig?.kind === "consul") { + hydrateConsulFields(connectionDeepLinkServiceHydrationValue(draft.serviceConfig)); + } else if (draft.serviceConfig?.kind === "nacos") { + hydrateNacosFields(connectionDeepLinkServiceHydrationValue(draft.serviceConfig)); + } oracleTnsAdminPath.value = parseOracleTnsConnectionString(form.value.connection_string)?.tnsAdmin || ""; selectedType.value = draft.driverProfile; if (form.value.db_type === "h2") { diff --git a/apps/desktop/src/lib/__tests__/connection/connectionDeepLink.services.spec.ts b/apps/desktop/src/lib/__tests__/connection/connectionDeepLink.services.spec.ts new file mode 100644 index 0000000000..cf6c0eeaf5 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/connection/connectionDeepLink.services.spec.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "vitest"; +import { connectionDeepLinkServiceHydrationValue, parseConnectionDeepLink, parseServiceConnectionUrl } from "@/lib/connection/connectionDeepLink"; +import { connectionProfileForScheme } from "@/lib/connection/connectionUrl"; + +describe("service connection deep links", () => { + it("registers Consul and every Nacos spelling against canonical profiles", () => { + expect(connectionProfileForScheme("consul")).toMatchObject({ type: "consul", profile: "consul", defaultPort: 8500 }); + for (const type of ["nacos-v2", "nacos-v3", "r-nacos", "nacos", "rnacos"]) { + expect(connectionProfileForScheme(type)).toMatchObject({ type: "nacos", profile: "nacos", defaultPort: 8848 }); + } + }); + + it("builds a Consul endpoint and keeps the ACL token in the password field", () => { + const draft = parseConnectionDeepLink("dbx://connection/new?type=consul&host=consul.internal&password=acl-token"); + expect(draft).toMatchObject({ + dbType: "consul", + driverProfile: "consul", + host: "consul.internal", + port: 8500, + password: "acl-token", + serviceConfig: { kind: "consul", serverAddr: "http://consul.internal:8500" }, + }); + }); + + it("maps Nacos versions and aliases to explicit service profiles", () => { + const cases = [ + ["nacos", "v2"], + ["nacos-v2", "v2"], + ["nacos-v3", "v3"], + ["rnacos", "rnacos"], + ["r-nacos", "rnacos"], + ] as const; + for (const [type, profile] of cases) { + const draft = parseConnectionDeepLink(`dbx://connection/new?type=${type}&host=127.0.0.1&user=nacos&password=secret`); + expect(draft?.dbType).toBe("nacos"); + expect(draft?.driverProfile).toBe("nacos"); + expect(draft?.serviceConfig).toMatchObject({ + kind: "nacos", + profile, + serverAddr: "http://127.0.0.1:8848", + auth: { kind: "usernamePassword", username: "nacos", password: "secret" }, + }); + if (profile === "rnacos") expect(draft?.serviceConfig).toMatchObject({ rnacosHistoryEnabled: false }); + } + }); + + it("preserves URL paths and lets top-level host, port and SSL fields override them", () => { + const link = new URL("dbx://connection/new"); + link.searchParams.set("type", "nacos-v3"); + link.searchParams.set("url", "https://nacos:secret@old.example:9443/proxy/nacos"); + link.searchParams.set("host", "[2001:db8::8]"); + link.searchParams.set("port", "18848"); + link.searchParams.set("ssl", "false"); + link.searchParams.set("user", "admin"); + link.searchParams.set("password", "override"); + + const draft = parseConnectionDeepLink(link.toString()); + expect(draft).toMatchObject({ host: "[2001:db8::8]", port: 18848, ssl: false, username: "admin", password: "override" }); + expect(draft?.serviceConfig).toEqual({ + kind: "nacos", + profile: "v3", + serverAddr: "http://[2001:db8::8]:18848/proxy/nacos", + auth: { kind: "usernamePassword", username: "admin", password: "override" }, + }); + }); + + it("accepts service-specific URL schemes and unwrapped IPv6 hosts", () => { + const link = new URL("dbx://connection/new"); + link.searchParams.set("type", "nacos-v3"); + link.searchParams.set("url", "nacos-v3://nacos:secret@old.example:8848/proxy/nacos"); + link.searchParams.set("host", "2001:db8::9"); + const draft = parseConnectionDeepLink(link.toString()); + expect(draft?.serviceConfig).toMatchObject({ + kind: "nacos", + profile: "v3", + serverAddr: "http://[2001:db8::9]:8848/proxy/nacos", + }); + + const inferred = new URL("dbx://connection/new"); + inferred.searchParams.set("url", "consul://127.0.0.1:8500/proxy"); + expect(parseConnectionDeepLink(inferred.toString())?.serviceConfig).toEqual({ + kind: "consul", + serverAddr: "http://127.0.0.1:8500/proxy", + }); + }); + + it("converts raw service URLs into drafts with specialized configuration", () => { + expect(parseServiceConnectionUrl("consul://remote.example:18500/prefix")).toMatchObject({ + dbType: "consul", + host: "remote.example", + port: 18500, + serviceConfig: { kind: "consul", serverAddr: "http://remote.example:18500/prefix" }, + }); + expect(parseServiceConnectionUrl("nacos-v3://nacos:secret@remote.example:18848/nacos")).toMatchObject({ + dbType: "nacos", + username: "nacos", + password: "secret", + serviceConfig: { + kind: "nacos", + profile: "v3", + serverAddr: "http://remote.example:18848/nacos", + auth: { kind: "usernamePassword", username: "nacos", password: "secret" }, + }, + }); + expect(parseServiceConnectionUrl("mysql://root:secret@127.0.0.1:3306/app")).toBeNull(); + }); + + it("accepts missing v and v=1, but rejects unsupported versions", () => { + expect(parseConnectionDeepLink("dbx://connection/new?type=consul&host=127.0.0.1")).not.toBeNull(); + expect(parseConnectionDeepLink("dbx://connection/new?v=1&type=consul&host=127.0.0.1")).not.toBeNull(); + expect(() => parseConnectionDeepLink("dbx://connection/new?v=2&type=consul&host=127.0.0.1")).toThrow(/Unsupported connection deep-link version/); + }); + + it("parses explicit false values and rejects invalid booleans", () => { + expect(parseConnectionDeepLink("dbx://connection/new?type=consul&ssl=false&one_time=false")).toMatchObject({ + ssl: false, + oneTime: false, + serviceConfig: { kind: "consul", serverAddr: "http://127.0.0.1:8500" }, + }); + expect(() => parseConnectionDeepLink("dbx://connection/new?type=consul&ssl=maybe")).toThrow(/Invalid boolean value/); + expect(() => parseConnectionDeepLink("dbx://connection/new?type=consul&one_time=2")).toThrow(/Invalid boolean value/); + }); + + it("maps service drafts to the exact specialized form hydration values", () => { + expect(connectionDeepLinkServiceHydrationValue({ kind: "consul", serverAddr: "https://consul.example/prefix" })).toEqual({ + serverAddr: "https://consul.example/prefix", + }); + expect( + connectionDeepLinkServiceHydrationValue({ + kind: "nacos", + profile: "v3", + serverAddr: "https://nacos.example/prefix", + auth: { kind: "usernamePassword", username: "nacos", password: "secret" }, + }), + ).toEqual({ + implementation: "nacos", + versionMode: "v3", + serverAddr: "https://nacos.example/prefix", + auth: { kind: "usernamePassword", username: "nacos", password: "secret" }, + }); + expect( + connectionDeepLinkServiceHydrationValue({ + kind: "nacos", + profile: "rnacos", + serverAddr: "http://rnacos.example/nacos", + auth: { kind: "none" }, + rnacosHistoryEnabled: false, + }), + ).toEqual({ + implementation: "rnacos", + serverAddr: "http://rnacos.example/nacos", + rnacosHistoryEnabled: false, + auth: { kind: "none" }, + }); + }); + + it("rejects invalid ports, targets and incomplete Nacos credentials", () => { + expect(() => parseConnectionDeepLink("dbx://connection/new?type=consul&port=0")).toThrow(/Invalid connection port/); + expect(() => parseConnectionDeepLink("dbx://connection/new?type=consul&port=70000")).toThrow(/Invalid connection port/); + expect(() => parseConnectionDeepLink("dbx://connection/new?type=nacos-v2&password=secret")).toThrow(/username is required/); + expect(() => parseConnectionDeepLink("dbx://connection/new?type=consul&host=bad%2Fhost")).toThrow(/Invalid service connection host/); + expect(parseConnectionDeepLink("dbx://connection/edit?type=consul")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/connection/connectionDialog.deepLinkServices.spec.ts b/apps/desktop/src/lib/__tests__/connection/connectionDialog.deepLinkServices.spec.ts new file mode 100644 index 0000000000..dc67c37806 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/connection/connectionDialog.deepLinkServices.spec.ts @@ -0,0 +1,34 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const source = readFileSync(new URL("../../../components/connection/ConnectionDialog.vue", import.meta.url), "utf8"); + +describe("ConnectionDialog service deep-link hydration", () => { + it("hydrates specialized refs after generic fields and before one-time submit", () => { + const applyStart = source.indexOf("function applyConnectionDraftToForm"); + const applyEnd = source.indexOf("function applyConnectionPrefill", applyStart); + const applyBody = source.slice(applyStart, applyEnd); + expect(applyBody.indexOf("applyConnectionDraftToConfig")).toBeGreaterThanOrEqual(0); + expect(applyBody.indexOf("hydrateConsulFields")).toBeGreaterThan(applyBody.indexOf("applyConnectionDraftToConfig")); + expect(applyBody.indexOf("hydrateNacosFields")).toBeGreaterThan(applyBody.indexOf("applyConnectionDraftToConfig")); + + const prefillStart = source.indexOf("function applyConnectionPrefill"); + const prefillEnd = source.indexOf("watch(\n open", prefillStart); + const prefillBody = source.slice(prefillStart, prefillEnd); + expect(prefillBody.indexOf("applyConnectionDraftToForm")).toBeLessThan(prefillBody.indexOf("submitOneTimePrefill")); + }); + + it("includes service configuration in one-time submission deduplication", () => { + const keyStart = source.indexOf("function oneTimePrefillKey"); + const keyEnd = source.indexOf("function submitOneTimePrefill", keyStart); + expect(source.slice(keyStart, keyEnd)).toContain("draft.serviceConfig"); + }); + + it("hydrates raw service URLs through the deep-link draft path", () => { + const applyStart = source.indexOf("function applyConnectionUrlToForm"); + const applyEnd = source.indexOf("function hasPendingConnectionUrlInput", applyStart); + const applyBody = source.slice(applyStart, applyEnd); + expect(applyBody).toContain("parseConnectionDeepLink(input) ?? parseServiceConnectionUrl(input)"); + expect(applyBody.indexOf("applyConnectionDraftToForm")).toBeLessThan(applyBody.indexOf("parseConnectionUrl(input")); + }); +}); diff --git a/apps/desktop/src/lib/connection/connectionDeepLink.ts b/apps/desktop/src/lib/connection/connectionDeepLink.ts index 668ad8a19d..377d9c8259 100644 --- a/apps/desktop/src/lib/connection/connectionDeepLink.ts +++ b/apps/desktop/src/lib/connection/connectionDeepLink.ts @@ -1,6 +1,16 @@ import type { DatabaseType } from "@/types/database"; import { connectionProfileForScheme, parseConnectionUrl } from "@/lib/connection/connectionUrl"; +export type ConnectionDeepLinkServiceConfig = + | { kind: "consul"; serverAddr: string } + | { + kind: "nacos"; + profile: "v2" | "v3" | "rnacos"; + serverAddr: string; + auth: { kind: "none" } | { kind: "usernamePassword"; username: string; password: string }; + rnacosHistoryEnabled?: false; + }; + export interface ConnectionDeepLinkDraft { name?: string; dbType: DatabaseType; @@ -18,6 +28,25 @@ export interface ConnectionDeepLinkDraft { oracleConnectionType?: "service_name" | "sid"; useMongoUrl?: boolean; oneTime?: boolean; + serviceConfig?: ConnectionDeepLinkServiceConfig; +} + +export function connectionDeepLinkServiceHydrationValue(config: ConnectionDeepLinkServiceConfig): Record { + if (config.kind === "consul") return { serverAddr: config.serverAddr }; + if (config.profile === "rnacos") { + return { + implementation: "rnacos", + serverAddr: config.serverAddr, + rnacosHistoryEnabled: false, + auth: config.auth, + }; + } + return { + implementation: "nacos", + versionMode: config.profile, + serverAddr: config.serverAddr, + auth: config.auth, + }; } const CONNECTION_DEEP_LINK_TARGET = "connection/new"; @@ -34,16 +63,115 @@ function optionalParam(params: URLSearchParams, ...keys: string[]): string | und return undefined; } -function optionalNumberParam(params: URLSearchParams, ...keys: string[]): number | undefined { +function optionalPortParam(params: URLSearchParams, ...keys: string[]): number | undefined { const value = optionalParam(params, ...keys); if (!value) return undefined; const numberValue = Number(value); - return Number.isFinite(numberValue) && numberValue > 0 ? numberValue : undefined; + if (!Number.isInteger(numberValue) || numberValue < 1 || numberValue > 65535) { + throw new Error(`Invalid connection port: ${value}`); + } + return numberValue; } -function optionalBooleanParam(params: URLSearchParams, ...keys: string[]): boolean { +function optionalBooleanParam(params: URLSearchParams, ...keys: string[]): boolean | undefined { const value = optionalParam(params, ...keys)?.toLowerCase(); - return value === "true" || value === "1" || value === "yes" || value === "on"; + if (value === undefined) return undefined; + if (["true", "1", "yes", "on"].includes(value)) return true; + if (["false", "0", "no", "off"].includes(value)) return false; + throw new Error(`Invalid boolean value: ${value}`); +} + +type ServiceType = "consul" | "nacos-v2" | "nacos-v3" | "r-nacos"; + +function normalizeServiceType(value?: string): ServiceType | undefined { + switch (value?.trim().toLowerCase()) { + case "consul": + return "consul"; + case "nacos": + case "nacos-v2": + return "nacos-v2"; + case "nacos-v3": + return "nacos-v3"; + case "rnacos": + case "r-nacos": + return "r-nacos"; + default: + return undefined; + } +} + +function validateProtocolVersion(params: URLSearchParams) { + const version = optionalParam(params, "v"); + if (version !== undefined && version !== "1") { + throw new Error(`Unsupported connection deep-link version: ${version}`); + } +} + +function validatedServiceHost(value: string): string { + const rawHost = value.trim(); + if (!rawHost) throw new Error("Service connection host is required"); + const unwrappedHost = rawHost.startsWith("[") && rawHost.endsWith("]") ? rawHost.slice(1, -1) : rawHost; + const authorityHost = unwrappedHost.includes(":") ? `[${unwrappedHost}]` : unwrappedHost; + let parsed: URL; + try { + parsed = new URL(`http://${authorityHost}:1`); + } catch { + throw new Error(`Invalid service connection host: ${value}`); + } + if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash || parsed.port !== "1") { + throw new Error(`Invalid service connection host: ${value}`); + } + return parsed.hostname; +} + +function serviceEndpoint(rawConnectionUrl: string | undefined, serviceType: ServiceType, host: string | undefined, port: number, ssl: boolean): string { + let endpoint: URL; + if (rawConnectionUrl) { + try { + endpoint = new URL(rawConnectionUrl); + } catch { + throw new Error("Invalid service connection URL"); + } + const sourceScheme = endpoint.protocol.replace(/:$/, "").toLowerCase(); + const serviceScheme = normalizeServiceType(sourceScheme); + if (endpoint.protocol !== "http:" && endpoint.protocol !== "https:" && serviceScheme !== serviceType) { + throw new Error(`Unsupported service connection URL scheme: ${sourceScheme}`); + } + if (endpoint.search || endpoint.hash) throw new Error("Service connection URL must not contain query parameters or a fragment"); + if (serviceScheme) { + const pathname = endpoint.pathname; + const hostWithPort = endpoint.host; + if (!hostWithPort) throw new Error("Service connection URL must include a host"); + endpoint = new URL(`http://${hostWithPort}`); + endpoint.pathname = pathname; + } + } else { + const endpointHost = validatedServiceHost(host || "127.0.0.1"); + endpoint = new URL(`${ssl ? "https" : "http"}://${endpointHost}:${port}`); + } + + endpoint.username = ""; + endpoint.password = ""; + endpoint.protocol = ssl ? "https:" : "http:"; + if (host) endpoint.hostname = validatedServiceHost(host); + endpoint.port = String(port); + return endpoint.toString().replace(/\/$/, ""); +} + +function serviceConfigForDraft(serviceType: ServiceType | undefined, rawConnectionUrl: string | undefined, host: string | undefined, port: number | undefined, ssl: boolean | undefined, username: string | undefined, password: string | undefined): ConnectionDeepLinkServiceConfig | undefined { + if (!serviceType) return undefined; + const defaultPort = serviceType === "consul" ? 8500 : 8848; + const serverAddr = serviceEndpoint(rawConnectionUrl, serviceType, host, port ?? defaultPort, ssl ?? false); + if (serviceType === "consul") return { kind: "consul", serverAddr }; + if (password && !username) throw new Error("Nacos username is required when a password is provided"); + const auth = username ? { kind: "usernamePassword" as const, username, password: password || "" } : { kind: "none" as const }; + return { + kind: "nacos", + profile: serviceType === "nacos-v3" ? "v3" : serviceType === "r-nacos" ? "rnacos" : "v2", + serverAddr, + auth, + ...(serviceType === "r-nacos" ? { rnacosHistoryEnabled: false as const } : {}), + }; } function draftFromConnectionUrl(value: string, preferredProfile?: string): ConnectionDeepLinkDraft { @@ -79,8 +207,17 @@ export function parseConnectionDeepLink(value: string): ConnectionDeepLinkDraft if (normalizePath(url) !== CONNECTION_DEEP_LINK_TARGET) return null; const params = url.searchParams; - const preferredProfile = optionalParam(params, "type"); + validateProtocolVersion(params); + const preferredProfile = optionalParam(params, "type")?.toLowerCase(); const rawConnectionUrl = optionalParam(params, "url"); + let serviceType = normalizeServiceType(preferredProfile); + if (!serviceType && rawConnectionUrl) { + try { + serviceType = normalizeServiceType(new URL(rawConnectionUrl).protocol.replace(/:$/, "")); + } catch { + // parseConnectionUrl below reports the canonical invalid URL error. + } + } const draft: ConnectionDeepLinkDraft = rawConnectionUrl ? draftFromConnectionUrl(rawConnectionUrl, preferredProfile) : (() => { @@ -96,19 +233,44 @@ export function parseConnectionDeepLink(value: string): ConnectionDeepLinkDraft })(); const oneTime = optionalBooleanParam(params, "one_time"); - const explicitPort = optionalNumberParam(params, "port"); + const explicitPort = optionalPortParam(params, "port"); + const explicitSsl = optionalBooleanParam(params, "ssl"); + const host = optionalParam(params, "host") ?? draft.host; + const port = explicitPort ?? draft.port; + const username = optionalParam(params, "user") ?? draft.username; + const password = optionalParam(params, "password") ?? draft.password; + const ssl = explicitSsl ?? draft.ssl; + const serviceConfig = serviceConfigForDraft(serviceType, rawConnectionUrl, host, port, ssl, username, password); return { ...draft, name: optionalParam(params, "name") ?? draft.name, - host: optionalParam(params, "host") ?? draft.host, - port: explicitPort ?? draft.port, + host, + port, ...((explicitPort !== undefined && draft.dbType === "sqlserver") || draft.portExplicit ? { portExplicit: true } : {}), - username: optionalParam(params, "user") ?? draft.username, - password: optionalParam(params, "password") ?? draft.password, + username, + password, database: optionalParam(params, "database") ?? draft.database, urlParams: optionalParam(params, "url_params") ?? draft.urlParams, - ssl: optionalBooleanParam(params, "ssl") ? true : draft.ssl, - ...(oneTime ? { oneTime: true } : {}), + ssl, + ...(oneTime !== undefined ? { oneTime } : {}), + ...(serviceConfig ? { serviceConfig } : {}), }; } + +export function parseServiceConnectionUrl(value: string): ConnectionDeepLinkDraft | null { + let url: URL; + try { + url = new URL(value); + } catch { + return null; + } + + const serviceType = normalizeServiceType(url.protocol.replace(/:$/, "")); + if (!serviceType) return null; + + const deepLink = new URL("dbx://connection/new"); + deepLink.searchParams.set("type", serviceType); + deepLink.searchParams.set("url", value); + return parseConnectionDeepLink(deepLink.toString()); +} diff --git a/apps/desktop/src/lib/connection/connectionUrl.ts b/apps/desktop/src/lib/connection/connectionUrl.ts index ea5e86bcc8..946793d630 100644 --- a/apps/desktop/src/lib/connection/connectionUrl.ts +++ b/apps/desktop/src/lib/connection/connectionUrl.ts @@ -38,6 +38,12 @@ const SCHEME_PROFILES: Record = { redis: { type: "redis", profile: "redis", label: "Redis", defaultPort: 6379 }, rediss: { type: "redis", profile: "redis", label: "Redis", defaultPort: 6379 }, etcd: { type: "etcd", profile: "etcd", label: "etcd", defaultPort: 2379 }, + consul: { type: "consul", profile: "consul", label: "Consul", defaultPort: 8500 }, + "nacos-v2": { type: "nacos", profile: "nacos", label: "Nacos", defaultPort: 8848 }, + "nacos-v3": { type: "nacos", profile: "nacos", label: "Nacos", defaultPort: 8848 }, + "r-nacos": { type: "nacos", profile: "nacos", label: "r-nacos", defaultPort: 8848 }, + nacos: { type: "nacos", profile: "nacos", label: "Nacos", defaultPort: 8848 }, + rnacos: { type: "nacos", profile: "nacos", label: "r-nacos", defaultPort: 8848 }, zookeeper: { type: "zookeeper", profile: "zookeeper", label: "Apache ZooKeeper", defaultPort: 2181 }, mongodb: { type: "mongodb", profile: "mongodb", label: "MongoDB", defaultPort: 27017 }, "mongodb+srv": { type: "mongodb", profile: "mongodb", label: "MongoDB", defaultPort: 27017 }, @@ -81,6 +87,12 @@ const HTTP_SELECTED_PROFILES: Record = { weaviate: SCHEME_PROFILES.weaviate, chromadb: SCHEME_PROFILES.chromadb, victoriametrics: SCHEME_PROFILES.victoriametrics, + consul: SCHEME_PROFILES.consul, + "nacos-v2": SCHEME_PROFILES["nacos-v2"], + "nacos-v3": SCHEME_PROFILES["nacos-v3"], + "r-nacos": SCHEME_PROFILES["r-nacos"], + nacos: SCHEME_PROFILES.nacos, + rnacos: SCHEME_PROFILES.rnacos, }; function decodeUrlPart(value: string): string { @@ -306,14 +318,16 @@ function isTidbCloudHost(host: string): boolean { } export function connectionProfileForScheme(scheme: string, preferredProfile?: string): ConnectionProfile | undefined { - if ((scheme === "http" || scheme === "https") && preferredProfile) { - return HTTP_SELECTED_PROFILES[preferredProfile]; + const normalizedScheme = scheme.trim().toLowerCase(); + const normalizedPreferredProfile = preferredProfile?.trim().toLowerCase(); + if ((normalizedScheme === "http" || normalizedScheme === "https") && normalizedPreferredProfile) { + return HTTP_SELECTED_PROFILES[normalizedPreferredProfile]; } // Cloudberry uses PostgreSQL URLs, so keep the selected product profile when parsing a pasted URL. - if ((scheme === "postgres" || scheme === "postgresql") && preferredProfile === "cloudberry") { + if ((normalizedScheme === "postgres" || normalizedScheme === "postgresql") && normalizedPreferredProfile === "cloudberry") { return SCHEME_PROFILES.cloudberry; } - return SCHEME_PROFILES[scheme]; + return SCHEME_PROFILES[normalizedScheme]; } function parseJdbcSqlServerUrl(source: string): ParsedConnectionUrl | null { diff --git a/deploy/database/nacos/2.5/recipe.json b/deploy/database/nacos/2.5/recipe.json index f3a88c961c..3145aeda0f 100644 --- a/deploy/database/nacos/2.5/recipe.json +++ b/deploy/database/nacos/2.5/recipe.json @@ -3,6 +3,7 @@ "name": "Nacos", "version": "2.5.2", "displayVersion": "2.5", + "deepLinkType": "nacos-v2", "image": "docker.cnb.cool/znb/images/nacos-server:v2.5.2", "platforms": ["linux/amd64", "linux/arm64"], "service": "database", diff --git a/deploy/database/nacos/3.2/recipe.json b/deploy/database/nacos/3.2/recipe.json index 2cc1516e81..cd700451df 100644 --- a/deploy/database/nacos/3.2/recipe.json +++ b/deploy/database/nacos/3.2/recipe.json @@ -3,6 +3,7 @@ "name": "Nacos", "version": "3.2.3", "displayVersion": "3.2", + "deepLinkType": "nacos-v3", "image": "docker.cnb.cool/znb/images/nacos-server:v3.2.3", "platforms": ["linux/amd64", "linux/arm64"], "service": "database", diff --git a/docs/content/docs/cli.cn.mdx b/docs/content/docs/cli.cn.mdx index 01854d813d..a59e21b15e 100644 --- a/docs/content/docs/cli.cn.mdx +++ b/docs/content/docs/cli.cn.mdx @@ -186,7 +186,7 @@ open 'dbx://connection/new?type=mysql&host=127.0.0.1&port=3306&user=root&passwor | 字段 | 含义 | | ------------ | -------------------------------------------------------------- | -| `type` | 数据库类型,例如 `mysql`、`postgres`、`redis`、`mongodb` | +| `type` | 数据库类型;服务类连接包括 `etcd`、`consul`、`nacos-v2`、`nacos-v3`、`r-nacos` | | `url` | 数据库 DSN,建议 URL encode | | `name` | DBX 中显示的连接名;未传时优先使用 `database`,其次使用 `host` | | `host` | 主机地址 | @@ -204,8 +204,20 @@ open 'dbx://connection/new?type=mysql&host=127.0.0.1&port=3306&user=root&passwor open 'dbx://connection/new?type=redis&host=127.0.0.1&port=6379&user=default&password=secret&database=0&one_time=true' ``` +Nacos 与服务注册中心示例: + +```bash +open 'dbx://connection/new?type=consul&host=127.0.0.1&port=8500&password=acl-token' +open 'dbx://connection/new?type=nacos-v3&host=127.0.0.1&port=8848&user=nacos&password=secret' +open 'dbx://connection/new?type=r-nacos&url=http%3A%2F%2F127.0.0.1%3A8848%2Fnacos&user=admin&password=secret' +``` + +`nacos` 是 `nacos-v2` 的别名,`rnacos` 是 `r-nacos` 的别名。URL 模式会保留服务端点路径,顶层 `host`、`port`、`user`、`password`、`ssl` 会覆盖 `url` 中的对应值。Consul 的 `password` 表示 ACL token;Nacos 传入 `password` 时必须同时传入 `user`。省略 `v` 或使用 `v=1` 表示当前深链协议,其他版本会被拒绝。 + 测试系统级 `dbx://` 唤起前,请先安装并启动一次 DBX 桌面端,让系统注册 URL 协议。macOS 可使用 `open 'dbx://connection/new?...'` 测试。 +深链可能在浏览器或终端历史中暴露数据库密码或 Consul ACL token。不要将携带凭据的链接写入共享日志、Shell 历史、工单或聊天消息。 + ## Codex Codex 可以直接通过 shell 调用 CLI: diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx index 1dd4341ded..2ab3bc5186 100644 --- a/docs/content/docs/cli.mdx +++ b/docs/content/docs/cli.mdx @@ -188,7 +188,7 @@ Supported fields: | Field | Meaning | | ------------ | ---------------------------------------------------------------------------------------- | -| `type` | Database type, such as `mysql`, `postgres`, `redis`, or `mongodb` | +| `type` | Database type; service types include `etcd`, `consul`, `nacos-v2`, `nacos-v3`, `r-nacos` | | `url` | Database DSN; URL encoding is recommended | | `name` | Display name in DBX; when omitted, DBX uses `database`, then `host` | | `host` | Hostname or IP address | @@ -206,8 +206,20 @@ One-time connection example: open 'dbx://connection/new?type=redis&host=127.0.0.1&port=6379&user=default&password=secret&database=0&one_time=true' ``` +Nacos and service-registry examples: + +```bash +open 'dbx://connection/new?type=consul&host=127.0.0.1&port=8500&password=acl-token' +open 'dbx://connection/new?type=nacos-v3&host=127.0.0.1&port=8848&user=nacos&password=secret' +open 'dbx://connection/new?type=r-nacos&url=http%3A%2F%2F127.0.0.1%3A8848%2Fnacos&user=admin&password=secret' +``` + +`nacos` is an alias for `nacos-v2`, and `rnacos` is an alias for `r-nacos`. In URL mode, service endpoint paths are preserved; top-level `host`, `port`, `user`, `password`, and `ssl` fields override values from `url`. For Consul, `password` is the ACL token. Nacos requires `user` when `password` is present. Omitting `v` or using `v=1` selects the current deep-link protocol; other versions are rejected. + Before testing system-level `dbx://` launches, install and open DBX Desktop once so the operating system can register the URL scheme. On macOS, use `open 'dbx://connection/new?...'` to test it. +A deep link may expose a database password or Consul ACL token in browser and terminal history. Do not put credential-bearing links in shared logs, shell history, tickets, or chat messages. + ## Codex Codex can call the CLI directly from shell tools: diff --git a/docs/content/docs/database-lab.cn.mdx b/docs/content/docs/database-lab.cn.mdx index 186bb2a9b6..d38626fd94 100644 --- a/docs/content/docs/database-lab.cn.mdx +++ b/docs/content/docs/database-lab.cn.mdx @@ -97,7 +97,7 @@ make db DB=consul@2.0.2 make db DB=elasticsearch@6.8 ``` -不带参数执行 `make db` 会输出当前全部支持的、可直接复制启动的命令,以及可选参数。传入 `DB=product@version` 后,`db` 会等待 Compose 健康检查通过并输出 DBX 连接字段;对于 DBX 已支持的连接类型,还会输出预填完成的 `dbx://connection/new` 深链。复制该链接到浏览器或终端执行 `open '<链接>'`,即可打开桌面端的新建连接窗口。使用前请先安装并启动一次 DBX 桌面端以注册 `dbx://` 协议。深链可能包含密码,因此不要将其写入共享终端历史、日志或工单。尚未具备 DBX 连接类型的配方会明确显示深链不可用,例如 Consul、Nacos、Kafka、Pulsar 和 r-nacos。`db-verify` 还会在数据库容器中执行配方中的冒烟命令,并校验预期输出;提交数据库相关修复前推荐执行此命令。 +不带参数执行 `make db` 会输出当前全部支持的、可直接复制启动的命令,以及可选参数。传入 `DB=product@version` 后,`db` 会等待 Compose 健康检查通过并输出 DBX 连接字段;对于 DBX 已支持的连接类型,还会输出预填完成的 `dbx://connection/new` 深链。etcd、Consul、Nacos 2、Nacos 3 和 r-nacos 均已支持,其中 Nacos 配方会自动选择正确分流,Nacos 3 使用 API 端口而非独立控制台端口。复制该链接到浏览器或终端执行 `open '<链接>'`,即可打开桌面端的新建连接窗口。使用前请先安装并启动一次 DBX 桌面端以注册 `dbx://` 协议。深链可能包含数据库密码或 Consul ACL token,因此不要将其写入共享终端历史、日志或工单。Kafka、Pulsar 等尚未具备 DBX 连接类型的配方会明确显示深链不可用。`db-verify` 还会在数据库容器中执行配方中的冒烟命令,并校验预期输出;提交数据库相关修复前推荐执行此命令。 ## 命令和变量 diff --git a/docs/content/docs/database-lab.mdx b/docs/content/docs/database-lab.mdx index 0730150bd8..099918d2a7 100644 --- a/docs/content/docs/database-lab.mdx +++ b/docs/content/docs/database-lab.mdx @@ -97,7 +97,7 @@ make db DB=consul@2.0.2 make db DB=elasticsearch@6.8 ``` -`make db` without parameters prints every currently supported copy-and-paste start command, plus optional parameters. With `DB=product@version`, it waits for the Compose health check and prints the DBX connection fields. For database types supported by DBX, it also prints a prefilled `dbx://connection/new` deep link. Copy the link into a browser or run `open ''` to open the new-connection dialog in DBX Desktop. Install and open DBX Desktop once before use so the operating system registers the `dbx://` protocol. The deep link can contain a password, so do not save it in shared terminal history, logs, or tickets. Recipes without a compatible DBX connection type explicitly report that no deep link is available, including Consul, Nacos, Kafka, Pulsar, and r-nacos. `db-verify` also runs the recipe's smoke command inside the database container and checks its expected output. It is the preferred command before reporting a database-specific fix. +`make db` without parameters prints every currently supported copy-and-paste start command, plus optional parameters. With `DB=product@version`, it waits for the Compose health check and prints the DBX connection fields. For database types supported by DBX, it also prints a prefilled `dbx://connection/new` deep link. This includes etcd, Consul, Nacos 2, Nacos 3, and r-nacos; the Nacos recipes select the correct connection profile automatically, and Nacos 3 uses its API port rather than its separate console port. Copy the link into a browser or run `open ''` to open the new-connection dialog in DBX Desktop. Install and open DBX Desktop once before use so the operating system registers the `dbx://` protocol. The link can contain a database password or Consul ACL token, so do not save it in shared terminal history, logs, or tickets. Recipes without a compatible DBX connection type, such as Kafka and Pulsar, explicitly report that no deep link is available. `db-verify` also runs the recipe's smoke command inside the database container and checks its expected output. It is the preferred command before reporting a database-specific fix. ## Commands and variables diff --git a/scripts/database-env.mjs b/scripts/database-env.mjs index e26b29213d..46637ec1f5 100644 --- a/scripts/database-env.mjs +++ b/scripts/database-env.mjs @@ -29,6 +29,7 @@ const DEFAULT_HOST_PORT_RANGES = { }; const DBX_DEEP_LINK_TYPES = { clickhouse: 'clickhouse', + consul: 'consul', elasticsearch: 'elasticsearch', etcd: 'etcd', mariadb: 'mariadb', @@ -37,6 +38,7 @@ const DBX_DEEP_LINK_TYPES = { postgresql: 'postgres', qdrant: 'qdrant', redis: 'redis', + rnacos: 'r-nacos', zookeeper: 'zookeeper', }; @@ -422,7 +424,7 @@ export function expandSmokeCommand(command, recipe, environment = process.env) { } export function dbxConnectionDeepLink(recipe, environment = process.env) { - const type = DBX_DEEP_LINK_TYPES[recipe.database]; + const type = recipe.deepLinkType || DBX_DEEP_LINK_TYPES[recipe.database]; if (!type) return null; const { connection } = recipe; diff --git a/scripts/database-env.test.mjs b/scripts/database-env.test.mjs index 5888a5d3ea..6871cad382 100644 --- a/scripts/database-env.test.mjs +++ b/scripts/database-env.test.mjs @@ -305,7 +305,7 @@ test('generates DBX deep links from effective recipe connection values', () => { assert.equal(params.get('database'), 'dbx'); }); -test('prints a DBX deep link only for compatible connection types', () => { +test('prints DBX deep links for compatible connection types', () => { const repoRoot = join(DEFAULT_RECIPES_ROOT, '..', '..'); const postgres = spawnSync(process.execPath, ['scripts/database-env.mjs', 'info', 'postgresql', '17.4'], { cwd: repoRoot, @@ -326,7 +326,33 @@ test('prints a DBX deep link only for compatible connection types', () => { encoding: 'utf8', }); assert.equal(consul.status, 0, consul.stderr); - assert.match(consul.stdout, /DBX connection link: unavailable \(DBX has no compatible Consul connection type\)/); + assert.match(consul.stdout, /DBX connection link: dbx:\/\/connection\/new\?type=consul&/); +}); + +test('generates canonical service deep-link types from recipes', () => { + const expected = { + 'etcd@3.7': 'etcd', + 'consul@2.0.2': 'consul', + 'nacos@2.5': 'nacos-v2', + 'nacos@3.2': 'nacos-v3', + 'rnacos@0.8': 'r-nacos', + }; + for (const [selector, type] of Object.entries(expected)) { + const recipe = discoverRecipes().find((item) => recipeSelector(item) === selector); + assert.ok(recipe, `missing ${selector}`); + const params = new URL(dbxConnectionDeepLink(recipe)).searchParams; + assert.equal(params.get('type'), type); + assert.equal(params.get('port'), String(recipe.connection.port)); + } +}); + +test('Nacos recipes declare their version-specific deep-link type', () => { + const recipes = discoverRecipes().filter((item) => item.database === 'nacos'); + assert.deepEqual(recipes.map((recipe) => [recipe.displayVersion, recipe.deepLinkType]), [ + ['2.5', 'nacos-v2'], + ['3.2', 'nacos-v3'], + ]); + assert.notEqual(recipes.find((recipe) => recipe.displayVersion === '3.2').connection.port, recipes.find((recipe) => recipe.displayVersion === '3.2').connection.consolePort); }); test('MongoDB commands pass reserved-character passwords as a distinct argument', () => {