feat(remote): SSH configurations, SFTP deployment, run targets and remote development (#91)

Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-27 23:12:19 +08:00
1 джерело 8eb4834a48
коміт 025e0019d2
196 files changed
+17920 -3729

No files matched your search

+129 -67
Переглянути файл
@@ -13,13 +13,26 @@ import {
type DbOptions,
type DbProperty,
type DbSsh,
type DbSshAuth,
type SshAuth,
type DbSslMode,
type DbTxMode,
} from "@/platform/ipc";
import { executeAction } from "@/platform/actions";
import { tx, useTx, useTxTemplate } from "@/platform/i18n";
import { sshLabel as configLabel } from "@/platform/remoteSettings";
import { useWorkflowSettings } from "@/platform/workflowSettings";
import { Button, Checkbox, Dialog, IconButton, Input, Select, Tabs, T, type SelectOption } from "@/ui";
import { colorVar, connectRemedy, fileSourceName, newSourceId, parseSourceUrl, sshLabel } from "./model";
import {
colorVar,
connectRemedy,
effectiveSsh,
fileSourceName,
newSourceId,
parseSourceUrl,
sshConfigOf,
sshLabel,
sshTunnelReady,
} from "./model";
import { closeDialog, loadDatabases, loadSchemas, saveSource, useDatabase } from "./store";
import { VENDORS, sourceUrl, vendorById, vendorOf } from "./vendors";
import styles from "./Database.module.css";
@@ -52,7 +65,7 @@ const TX: Array<SelectOption<DbTxMode>> = [
{ value: "manual", label: "Manual" },
];
const SSH_AUTH: Array<SelectOption<DbSshAuth>> = [
const SSH_AUTH: Array<SelectOption<SshAuth>> = [
{ value: "openSsh", label: "OpenSSH config and authentication agent" },
{ value: "password", label: "Password" },
{ value: "keyPair", label: "Key pair" },
@@ -340,6 +353,11 @@ export function SourceDialog({
const set = (patch: Partial<DataSource>) => setS((x) => ({ ...x, ...patch }));
const ssh: DbSsh = { ...blankSsh(), ...s.ssh };
const sshConfigs = useWorkflowSettings((w) => w.ssh.configs);
const sshConfigId = ssh.config?.trim() ?? "";
const sshConfig = sshConfigOf(ssh, sshConfigs);
// The host / user / authentication in effect (the chosen SSH configuration's).
const sshUsed: DbSsh = effectiveSsh(ssh, sshConfigs) ?? ssh;
const setSsh = (patch: Partial<DbSsh>) => setS((x) => ({ ...x, ssh: { ...blankSsh(), ...x.ssh, ...patch } }));
const options: DbOptions = { ...blankOptions(), ...s.options };
const setOptions = (patch: Partial<DbOptions>) =>
@@ -362,7 +380,9 @@ export function SourceDialog({
const name =
s.name?.trim() || (sqlite ? fileSourceName(s.file ?? "") : `${database || s.user || "db"}@${host || "localhost"}`);
const sshOn = !sqlite && !!ssh.enabled;
const valid = sqlite ? !!s.file?.trim() : (urlOnly ? urlValid : !!s.host?.trim()) && (!sshOn || !!ssh.host?.trim());
const valid = sqlite
? !!s.file?.trim()
: (urlOnly ? urlValid : !!s.host?.trim()) && (!sshOn || sshTunnelReady(ssh, sshConfigs));
const sslOn = (s.ssl ?? "prefer") !== "disable";
/** What is saved / tested: a preset's port when the field was left empty. */
@@ -408,7 +428,7 @@ export function SourceDialog({
setTest({ ok: true, text: tx`Succeeded: ${v}` });
} catch (e) {
const text = e instanceof Error ? e.message : String(e);
const trust = e instanceof IpcError && connectRemedy(e.kind, e.message) === "trustHostKey";
const trust = e instanceof IpcError && connectRemedy(e.kind, e.message, { ssh: sshUsed }) === "trustHostKey";
setTest({ ok: false, text, trust });
} finally {
setBusy(false);
@@ -416,7 +436,8 @@ export function SourceDialog({
};
const trustAndRetry = async () => {
await call(commands.dbTrustSshHost(s.id ?? ""));
// A shared SSH configuration records the failed host key under its own id.
await call(sshConfigId ? commands.sshTrustHost(sshConfigId) : commands.dbTrustSshHost(s.id ?? ""));
await runTest();
};
@@ -426,7 +447,7 @@ export function SourceDialog({
await saveSource(
{ ...effective(), properties: properties.filter((p) => p.name?.trim()) },
sqlite ? undefined : password === undefined ? undefined : password || null,
!sshOn || sshSecret === undefined || ssh.auth === "openSsh" ? undefined : sshSecret || null,
!sshOn || sshSecret === undefined || sshUsed.auth === "openSsh" ? undefined : sshSecret || null,
);
closeDialog();
} catch (e) {
@@ -513,7 +534,7 @@ export function SourceDialog({
{sshOn && (
<>
<span />
<span className={styles.formHint}>Reached from the SSH host {sshLabel({ ssh })}.</span>
<span className={styles.formHint}>Reached from the SSH host {sshLabel({ ssh: sshUsed })}.</span>
</>
)}
<label className={styles.formLabel} htmlFor="db-user">
@@ -667,67 +688,105 @@ export function SourceDialog({
</>
);
const sshConfigOptions: SelectOption[] = [
{ value: "", label: "Custom (fields below)" },
...sshConfigs.map((c) => ({ value: c.id, label: c.name.trim() || configLabel(c) || c.id, detail: configLabel(c) })),
...(sshConfigId && !sshConfig ? [{ value: sshConfigId, label: tx`${sshConfigId} (missing)` }] : []),
];
const sshTab = (
<>
<span />
<Checkbox checked={!!ssh.enabled} onChange={(enabled) => setSsh({ enabled })} label="Use SSH tunnel" />
<label className={styles.formLabel} htmlFor="db-ssh-host">
<T>Host:</T>
</label>
<div className={styles.formRow}>
<Input
id="db-ssh-host"
className={styles.fill}
disabled={!sshOn}
value={ssh.host ?? ""}
placeholder={ssh.auth === "openSsh" ? "host or ~/.ssh/config alias" : "bastion.example.com"}
onChange={(e) => setSsh({ host: e.target.value })}
/>
<label className={styles.formLabel} htmlFor="db-ssh-port">
<T>Port:</T>
</label>
<Input
id="db-ssh-port"
className={styles.portInput}
inputMode="numeric"
disabled={!sshOn}
value={ssh.port ?? ""}
placeholder="22"
onChange={(e) => setSsh({ port: parsePort(e.target.value) })}
/>
</div>
<label className={styles.formLabel} htmlFor="db-ssh-user">
<T>User:</T>
</label>
<Input
id="db-ssh-user"
disabled={!sshOn}
value={ssh.user ?? ""}
placeholder={ssh.auth === "openSsh" ? "from ~/.ssh/config" : ""}
onChange={(e) => setSsh({ user: e.target.value })}
/>
<span className={styles.formLabel}>
<T>Authentication:</T>
<T>Configuration:</T>
</span>
<Select
aria-label="SSH authentication type"
aria-label="SSH configuration"
disabled={!sshOn}
value={ssh.auth ?? "openSsh"}
options={SSH_AUTH}
onChange={(auth) => setSsh({ auth })}
value={sshConfigId}
options={sshConfigOptions}
onChange={(config) => setSsh({ config })}
/>
{ssh.auth === "keyPair" && (
<FileField
id="db-ssh-key"
label="Private key:"
value={ssh.keyFile ?? ""}
placeholder="~/.ssh/id_ed25519"
disabled={!sshOn}
title="Private Key File"
onChange={(keyFile) => setSsh({ keyFile })}
/>
{sshConfigId && (
<>
<span />
<span className={styles.formHint}>
{sshConfig ? (
<>{txTemplate(`Connects to ${configLabel(sshConfig) || sshConfig.name}.`)} </>
) : (
<span className={styles.bad}>{txTemplate(`SSH configuration ${sshConfigId} no longer exists.`)} </span>
)}
<button
type="button"
className={styles.linkButton}
onClick={() => void executeAction("file.settings", "tools.sshConfigurations")}
>
<T>Edit SSH Configurations…</T>
</button>
</span>
</>
)}
{ssh.auth === "openSsh" ? (
{!sshConfigId && (
<>
<label className={styles.formLabel} htmlFor="db-ssh-host">
<T>Host:</T>
</label>
<div className={styles.formRow}>
<Input
id="db-ssh-host"
className={styles.fill}
disabled={!sshOn}
value={ssh.host ?? ""}
placeholder={ssh.auth === "openSsh" ? "host or ~/.ssh/config alias" : "bastion.example.com"}
onChange={(e) => setSsh({ host: e.target.value })}
/>
<label className={styles.formLabel} htmlFor="db-ssh-port">
<T>Port:</T>
</label>
<Input
id="db-ssh-port"
className={styles.portInput}
inputMode="numeric"
disabled={!sshOn}
value={ssh.port ?? ""}
placeholder="22"
onChange={(e) => setSsh({ port: parsePort(e.target.value) })}
/>
</div>
<label className={styles.formLabel} htmlFor="db-ssh-user">
<T>User:</T>
</label>
<Input
id="db-ssh-user"
disabled={!sshOn}
value={ssh.user ?? ""}
placeholder={ssh.auth === "openSsh" ? "from ~/.ssh/config" : ""}
onChange={(e) => setSsh({ user: e.target.value })}
/>
<span className={styles.formLabel}>
<T>Authentication:</T>
</span>
<Select
aria-label="SSH authentication type"
disabled={!sshOn}
value={ssh.auth ?? "openSsh"}
options={SSH_AUTH}
onChange={(auth) => setSsh({ auth })}
/>
{ssh.auth === "keyPair" && (
<FileField
id="db-ssh-key"
label="Private key:"
value={ssh.keyFile ?? ""}
placeholder="~/.ssh/id_ed25519"
disabled={!sshOn}
title="Private Key File"
onChange={(keyFile) => setSsh({ keyFile })}
/>
)}
</>
)}
{sshUsed.auth === "openSsh" && !sshConfigId && (
<>
<span />
<span className={styles.formHint}>
@@ -736,10 +795,11 @@ export function SourceDialog({
</T>
</span>
</>
) : (
)}
{sshUsed.auth !== "openSsh" && (
<>
<label className={styles.formLabel} htmlFor="db-ssh-secret">
{tx(ssh.auth === "password" ? "Password:" : "Passphrase:")}
{tx(sshUsed.auth === "password" ? "Password:" : "Passphrase:")}
</label>
<div className={styles.formRow}>
<Input
@@ -751,12 +811,14 @@ export function SourceDialog({
placeholder={initial && sshSecret === undefined ? "<unchanged>" : ""}
onChange={(e) => setSshSecret(e.target.value)}
/>
<Checkbox
checked={!!ssh.saveSecret}
disabled={!sshOn}
onChange={(saveSecret) => setSsh({ saveSecret })}
label="Save password"
/>
{!sshConfigId && (
<Checkbox
checked={!!ssh.saveSecret}
disabled={!sshOn}
onChange={(saveSecret) => setSsh({ saveSecret })}
label="Save password"
/>
)}
</div>
</>
)}
+36 -1
Переглянути файл
@@ -1,5 +1,15 @@
/** Pure helpers of the database tools: clipboard formats, edits, labels. */
import type { DbCellValue, DbColor, DbProperty, DbRowEdit, DbSortKey, DbSslMode, DataSource } from "@/platform/ipc";
import type {
DbCellValue,
DbColor,
DbProperty,
DbRowEdit,
DbSortKey,
DbSsh,
DbSslMode,
DataSource,
} from "@/platform/ipc";
import type { SshConfigValid } from "@/platform/remoteSettings";
import { vendorOf, vendorOfScheme } from "./vendors";
export type Cell = string | null;
@@ -253,6 +263,31 @@ export function sshLabel(source: Pick<DataSource, "ssh">): string {
return `${ssh.user ? `${ssh.user}@` : ""}${ssh.host}${ssh.port ? `:${ssh.port}` : ""}`;
}
/** The SSH configuration a tunnel connects with; undefined for the inline fields or a missing one. */
export function sshConfigOf(
ssh: DbSsh | null | undefined,
configs: readonly SshConfigValid[],
): SshConfigValid | undefined {
const id = ssh?.config?.trim();
return id ? configs.find((c) => c.id === id) : undefined;
}
/** The SSH settings in effect: those of the chosen SSH configuration, else the inline fields. */
export function effectiveSsh(ssh: DbSsh | null | undefined, configs: readonly SshConfigValid[]): DbSsh | null {
if (!ssh) return null;
const c = sshConfigOf(ssh, configs);
return c
? { ...ssh, host: c.host, port: c.port, user: c.user, auth: c.auth, keyFile: c.keyFile, saveSecret: c.saveSecret }
: ssh;
}
/** Whether the SSH tab names a host to tunnel through (always, when the tunnel is off). */
export function sshTunnelReady(ssh: DbSsh | null | undefined, configs: readonly SshConfigValid[]): boolean {
if (!ssh?.enabled) return true;
if (ssh.config?.trim()) return !!sshConfigOf(ssh, configs)?.host.trim();
return !!ssh.host?.trim();
}
/**
* Connection target (backend `split_target`): a data source id for its configured
* database, or `<id>:<database>` for another database of the same server.
+45
Переглянути файл
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import type { DbSsh } from "@/platform/ipc";
import { DEFAULT_SSH_CONFIG, type SshConfigValid } from "@/platform/remoteSettings";
import { connectRemedy, effectiveSsh, sshConfigOf, sshLabel, sshTunnelReady } from "./model";
const configs: SshConfigValid[] = [
{ ...DEFAULT_SSH_CONFIG, id: "dev", name: "Dev", host: "box", port: 2222, user: "me", auth: "password" },
{ ...DEFAULT_SSH_CONFIG, id: "blank", name: "Blank" },
];
const inline: DbSsh = { enabled: true, config: "", host: "bastion", port: null, user: "ops", auth: "openSsh" };
describe("data source SSH configuration", () => {
it("uses the chosen SSH configuration instead of the inline fields", () => {
const ssh = { ...inline, config: "dev" };
expect(sshConfigOf(ssh, configs)?.id).toBe("dev");
const used = effectiveSsh(ssh, configs)!;
expect(sshLabel({ ssh: used })).toBe("me@box:2222");
expect(used.auth).toBe("password");
expect(used.config).toBe("dev");
});
it("keeps the inline fields when no configuration (or a missing one) is chosen", () => {
expect(effectiveSsh(inline, configs)).toBe(inline);
expect(sshConfigOf({ ...inline, config: "gone" }, configs)).toBeUndefined();
expect(effectiveSsh({ ...inline, config: "gone" }, configs)?.host).toBe("bastion");
expect(effectiveSsh(null, configs)).toBeNull();
});
it("needs a host to tunnel through only when the tunnel is on", () => {
expect(sshTunnelReady({ ...inline, enabled: false, host: "" }, configs)).toBe(true);
expect(sshTunnelReady(inline, configs)).toBe(true);
expect(sshTunnelReady({ ...inline, host: " " }, configs)).toBe(false);
expect(sshTunnelReady({ ...inline, host: "", config: "dev" }, configs)).toBe(true);
expect(sshTunnelReady({ ...inline, config: "blank" }, configs)).toBe(false);
expect(sshTunnelReady({ ...inline, config: "gone" }, configs)).toBe(false);
});
it("classifies auth failures by the configuration's authentication", () => {
const msg = "SSH authentication failed for me@box:2222";
expect(connectRemedy("permissionDenied", msg, { ssh: inline })).toBeNull();
expect(connectRemedy("permissionDenied", msg, { ssh: effectiveSsh({ ...inline, config: "dev" }, configs) })).toBe(
"sshSecret",
);
});
});
+6 -3
Переглянути файл
@@ -31,8 +31,9 @@ import {
toolWindowService,
type FileDecoration,
} from "@/workbench/contributions";
import { workflowSettings } from "@/platform/workflowSettings";
import { ask, confirm } from "@/workbench/messages";
import { colorVar, connectRemedy, databaseKey, idleTargets, newSourceId, splitTarget } from "./model";
import { colorVar, connectRemedy, databaseKey, effectiveSsh, idleTargets, newSourceId, splitTarget } from "./model";
import { tableFolder, type DbNode } from "./nodes";
import type { ExportData, ExportFormat } from "./export";
import type { DbObjectRef } from "./objects";
@@ -331,11 +332,13 @@ function askTrustHost(prompt: string): Promise<boolean> {
/** Asks the user what a failed connection needs (password, SSH secret, host key trust); false = give up. */
export async function resolveConnectError(e: unknown, source: DataSource): Promise<boolean> {
if (!(e instanceof IpcError)) return false;
switch (connectRemedy(e.kind, e.message, source)) {
// Prompts show the SSH configuration's host and authentication when the tunnel uses one.
const resolved = { ...source, ssh: effectiveSsh(source.ssh, workflowSettings().ssh.configs) };
switch (connectRemedy(e.kind, e.message, resolved)) {
case "password":
return askPassword(source);
case "sshSecret":
return askSshSecret(source, e.message);
return askSshSecret(resolved, e.message);
case "trustHostKey":
if (!(await askTrustHost(e.message))) return false;
await call(commands.dbTrustSshHost(source.id ?? ""));