feat: copy connection URL from connection card menu (#22)
Add 'Copy connection URL' (with password, fetched from the OS keychain on demand) and 'Copy connection URL (no password)' to the connection card kebab menu. Builds postgresql://, mysql://, sqlite://, redis:// strings with percent-encoded credentials and the PG sslmode appended. Bump to v0.7.10.
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import tauriConf from "../../src-tauri/tauri.conf.json";
|
||||
|
||||
describe("tauri bundle config (v0.7.9)", () => {
|
||||
describe("tauri bundle config (v0.7.10)", () => {
|
||||
it("declares bundled pg_tools resources", () => {
|
||||
expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*");
|
||||
});
|
||||
it("version is 0.7.9", () => {
|
||||
expect(tauriConf.version).toBe("0.7.9");
|
||||
it("version is 0.7.10", () => {
|
||||
expect(tauriConf.version).toBe("0.7.10");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,24 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseConnectionString, looksLikeConnectionString, detectProviderFromHost } from "./connectionString";
|
||||
import { parseConnectionString, looksLikeConnectionString, detectProviderFromHost, buildConnectionUrl } from "./connectionString";
|
||||
import type { Connection } from "./types";
|
||||
|
||||
function makeConn(overrides: Partial<Connection> = {}): Connection {
|
||||
return {
|
||||
id: "c1",
|
||||
name: "Test",
|
||||
db_type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
username: "user",
|
||||
folder_id: null,
|
||||
keychain_ref: null,
|
||||
tag_ids: [],
|
||||
favorite: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("parseConnectionString", () => {
|
||||
it("parses a PostgreSQL URL", () => {
|
||||
@@ -150,3 +169,75 @@ describe("detectProviderFromHost", () => {
|
||||
expect(detectProviderFromHost("prod.example.com")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildConnectionUrl", () => {
|
||||
it("builds a PostgreSQL URL with password and sslmode", () => {
|
||||
const url = buildConnectionUrl(
|
||||
makeConn({ db_type: "postgresql", host: "prod.example.com", port: 5432, username: "alice", database: "app", ssl_mode: "require" }),
|
||||
"s3cret",
|
||||
);
|
||||
expect(url).toBe("postgresql://alice:s3cret@prod.example.com:5432/app?sslmode=require");
|
||||
});
|
||||
|
||||
it("omits password when null (no-password variant)", () => {
|
||||
const url = buildConnectionUrl(
|
||||
makeConn({ db_type: "postgresql", host: "prod.example.com", port: 5432, username: "alice", database: "app" }),
|
||||
null,
|
||||
);
|
||||
expect(url).toBe("postgresql://alice@prod.example.com:5432/app");
|
||||
});
|
||||
|
||||
it("omits sslmode when disable or unset", () => {
|
||||
expect(
|
||||
buildConnectionUrl(makeConn({ ssl_mode: "disable" }), null),
|
||||
).toBe("postgresql://user@localhost:5432");
|
||||
expect(
|
||||
buildConnectionUrl(makeConn({ ssl_mode: undefined }), null),
|
||||
).toBe("postgresql://user@localhost:5432");
|
||||
});
|
||||
|
||||
it("percent-encodes special characters in user/password/database", () => {
|
||||
const url = buildConnectionUrl(
|
||||
makeConn({ username: "a b", database: "my db" }),
|
||||
"p@ss w:rd",
|
||||
);
|
||||
expect(url).toBe("postgresql://a%20b:p%40ss%20w%3Ard@localhost:5432/my%20db");
|
||||
});
|
||||
|
||||
it("builds a MySQL URL", () => {
|
||||
const url = buildConnectionUrl(
|
||||
makeConn({ db_type: "mysql", host: "127.0.0.1", port: 3306, username: "root", database: "app" }),
|
||||
"pw",
|
||||
);
|
||||
expect(url).toBe("mysql://root:pw@127.0.0.1:3306/app");
|
||||
});
|
||||
|
||||
it("builds a Redis URL with password but no username", () => {
|
||||
const url = buildConnectionUrl(
|
||||
makeConn({ db_type: "redis", host: "localhost", port: 6379, username: null, database: "0" }),
|
||||
"pw",
|
||||
);
|
||||
expect(url).toBe("redis://:pw@localhost:6379/0");
|
||||
});
|
||||
|
||||
it("builds a SQLite URL from the host file path", () => {
|
||||
const url = buildConnectionUrl(
|
||||
makeConn({ db_type: "sqlite", host: "/Users/me/data.db", port: null, username: null, database: null }),
|
||||
null,
|
||||
);
|
||||
expect(url).toBe("sqlite:///Users/me/data.db");
|
||||
});
|
||||
|
||||
it("round-trips through parseConnectionString", () => {
|
||||
const conn = makeConn({ db_type: "postgresql", host: "localhost", port: 5432, username: "user", database: "mydb" });
|
||||
const url = buildConnectionUrl(conn, "pass");
|
||||
expect(parseConnectionString(url)).toEqual({
|
||||
db_type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
username: "user",
|
||||
password: "pass",
|
||||
database: "mydb",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DbType } from "./types";
|
||||
import type { Connection, DbType } from "./types";
|
||||
|
||||
export interface ParsedConnectionString {
|
||||
db_type: DbType;
|
||||
@@ -89,3 +89,49 @@ export function detectProviderFromHost(host: string): "supabase" | "neon" | null
|
||||
if (h.endsWith(".neon.tech")) return "neon";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a connection URL string from a saved connection, for copying into
|
||||
* env vars / other tools. `password` is the decrypted keychain value (or
|
||||
* `null` to omit it — e.g. the "no password" copy variant).
|
||||
*
|
||||
* Formats per DB type:
|
||||
* - postgresql: `postgresql://user:pass@host:port/db?sslmode=...`
|
||||
* - mysql: `mysql://user:pass@host:port/db`
|
||||
* - sqlite: `sqlite:///abs/path` (host stores the file path)
|
||||
* - redis: `redis://user:pass@host:port/db`
|
||||
*
|
||||
* Username/password/database are percent-encoded; the ssl_mode is appended as
|
||||
* a query param for PostgreSQL when set to anything other than `disable`.
|
||||
*/
|
||||
export function buildConnectionUrl(conn: Connection, password: string | null): string {
|
||||
const enc = (s: string) => encodeURIComponent(s);
|
||||
const port = conn.port ? `:${conn.port}` : "";
|
||||
const user = conn.username ? enc(conn.username) : "";
|
||||
const auth = password
|
||||
? user
|
||||
? `${user}:${enc(password)}@`
|
||||
: `:${enc(password)}@`
|
||||
: user
|
||||
? `${user}@`
|
||||
: "";
|
||||
const db = conn.database ? `/${enc(conn.database)}` : "";
|
||||
|
||||
switch (conn.db_type) {
|
||||
case "postgresql": {
|
||||
const ssl =
|
||||
conn.ssl_mode && conn.ssl_mode !== "disable"
|
||||
? `?sslmode=${encodeURIComponent(conn.ssl_mode)}`
|
||||
: "";
|
||||
return `postgresql://${auth}${conn.host}${port}${db}${ssl}`;
|
||||
}
|
||||
case "mysql":
|
||||
return `mysql://${auth}${conn.host}${port}${db}`;
|
||||
case "sqlite":
|
||||
// host stores the file path; `sqlite:///abs/path` round-trips through
|
||||
// parseConnectionString (pathname → host).
|
||||
return `sqlite://${conn.host}`;
|
||||
case "redis":
|
||||
return `redis://${auth}${conn.host}${port}${db}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, it, expect } from "vitest";
|
||||
import agents from "../../AGENTS.md?raw";
|
||||
import readme from "../../README.md?raw";
|
||||
|
||||
describe("v0.7.9 docs coverage", () => {
|
||||
describe("v0.7.10 docs coverage", () => {
|
||||
it("AGENTS.md marks inline cell editing complete", () => {
|
||||
expect(agents).toContain("Inline cell editing");
|
||||
expect(agents).toMatch(/Inline cell editing \| ✅/);
|
||||
@@ -24,8 +24,8 @@ describe("v0.7.9 docs coverage", () => {
|
||||
expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
|
||||
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
|
||||
});
|
||||
it("README declares v0.7.9", () => {
|
||||
expect(readme).toContain("0.7.9");
|
||||
it("README declares v0.7.10", () => {
|
||||
expect(readme).toContain("0.7.10");
|
||||
});
|
||||
it("AGENTS.md marks schema CRUD complete", () => {
|
||||
expect(agents).toMatch(/Schema CRUD \| ✅/);
|
||||
@@ -64,9 +64,9 @@ describe("v0.7.9 docs coverage", () => {
|
||||
expect(agents).toMatch(/Cancel long-running queries \| ✅/);
|
||||
expect(agents).toMatch(/Settings export\/import \| ✅/);
|
||||
});
|
||||
it("README links to v0.7.9 assets in both download tables", () => {
|
||||
expect(readme).toContain("releases/download/v0.7.9/");
|
||||
expect(readme).toContain("Gridline_0.7.9_aarch64.dmg");
|
||||
expect(readme).toContain("Gridline-0.7.9-1.x86_64.rpm");
|
||||
it("README links to v0.7.10 assets in both download tables", () => {
|
||||
expect(readme).toContain("releases/download/v0.7.10/");
|
||||
expect(readme).toContain("Gridline_0.7.10_aarch64.dmg");
|
||||
expect(readme).toContain("Gridline-0.7.10-1.x86_64.rpm");
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
|
||||
import pkg from "../../package.json";
|
||||
|
||||
describe("version", () => {
|
||||
it("declares v0.7.9 across the app shell", () => {
|
||||
expect(pkg.version).toBe("0.7.9");
|
||||
it("declares v0.7.10 across the app shell", () => {
|
||||
expect(pkg.version).toBe("0.7.10");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user