@linxin666/dsh-ssh 0.3.19 → 0.3.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +10 -4
- package/README.zh.md +10 -4
- package/lib/client.js +252 -38
- package/lib/client.js.map +1 -1
- package/lib/index.js +504 -52
- package/lib/types/client/body-mutations.d.ts +45 -0
- package/lib/types/client/locales.d.ts +9 -1
- package/lib/types/client/panel/TunnelsTab.d.ts +4 -2
- package/lib/types/client/panel-mount-core.d.ts +1 -1
- package/lib/types/client/sidebar-entry-core.d.ts +0 -15
- package/lib/types/engine/connection-pool.d.ts +20 -0
- package/lib/types/engine/proxy-command.d.ts +36 -0
- package/lib/types/index.d.ts +1 -1
- package/lib/types/protocol.d.ts +24 -2
- package/lib/types/ssh-config.d.ts +34 -0
- package/lib/types/store.d.ts +11 -3
- package/lib/types/tools.d.ts +24 -0
- package/package.json +14 -14
- package/src/client/body-mutations.ts +133 -0
- package/src/client/locales.ts +18 -2
- package/src/client/panel/HostFormDialog.tsx +15 -0
- package/src/client/panel/HostsTab.tsx +37 -2
- package/src/client/panel/SshPanel.tsx +6 -2
- package/src/client/panel/TunnelsTab.tsx +50 -10
- package/src/client/panel/panel.module.css +27 -0
- package/src/client/panel-mount-core.ts +21 -13
- package/src/client/sidebar-entry-core.ts +10 -7
- package/src/engine/connection-pool.ts +122 -10
- package/src/engine/proxy-command.ts +248 -0
- package/src/index.ts +1 -1
- package/src/protocol.ts +26 -2
- package/src/routes.ts +1 -1
- package/src/ssh-config.ts +153 -0
- package/src/store.ts +64 -40
- package/src/tools.ts +35 -5
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One `document.body` MutationObserver per page, shared by every family
|
|
3
|
+
* plugin bundle.
|
|
4
|
+
*
|
|
5
|
+
* Family plugins that inject at the DOM level must notice when the shell
|
|
6
|
+
* re-renders around their injected node: the sidebar entry rows
|
|
7
|
+
* (sidebar-entry-core), the center-column panel containers
|
|
8
|
+
* (panel-mount-core), and the aggregate shell's column shims. Each consumer
|
|
9
|
+
* used to install its OWN `MutationObserver` on `document.body` with
|
|
10
|
+
* `{ childList: true, subtree: true }`, so a page carrying N family plugins
|
|
11
|
+
* paid N native observers and N callback invocations for EVERY mutation batch
|
|
12
|
+
* the app produced — chat token streaming alone produces many per second, and
|
|
13
|
+
* the count grows with the number of installed plugins.
|
|
14
|
+
*
|
|
15
|
+
* This module keeps exactly ONE body observer per page. The registry lives on
|
|
16
|
+
* `globalThis` under a `Symbol.for` key, so the per-package generated copies
|
|
17
|
+
* of this file (scripts/sync-shared.mjs) and separately bundled plugins all
|
|
18
|
+
* reach the same hub at runtime instead of one hub per module instance.
|
|
19
|
+
* Record subscribers receive the mutations accumulated since the last flush.
|
|
20
|
+
* Consumers that only re-check their DOM use subscribeBodyInvalidations:
|
|
21
|
+
* when all subscribers use that path, the hub retains no mutation records
|
|
22
|
+
* (or detached subtrees) while a background page's animation frames pause.
|
|
23
|
+
* Both paths run at most once per frame. The last subscriber disconnects the
|
|
24
|
+
* observer, cancels the pending frame and releases its records.
|
|
25
|
+
*
|
|
26
|
+
* Failure policy: without a DOM or `MutationObserver` the subscription is a
|
|
27
|
+
* no-op disposer (the same silence the per-consumer observers had), and when
|
|
28
|
+
* `requestAnimationFrame` is unavailable the flush runs synchronously so a
|
|
29
|
+
* subscriber is never silently dropped. A throwing subscriber cannot stop the
|
|
30
|
+
* others.
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* Subscribe to a coalesced DOM re-check without retaining mutation records.
|
|
34
|
+
* The marked wrapper also works with an older hub, which delivers records
|
|
35
|
+
* that it simply ignores until a page reload picks up the updated hub.
|
|
36
|
+
*/
|
|
37
|
+
export declare function subscribeBodyInvalidations(subscriber: () => void): () => void;
|
|
38
|
+
/**
|
|
39
|
+
* Subscribe to body-level childList mutations.
|
|
40
|
+
* @param subscriber - called at most once per animation frame with the records
|
|
41
|
+
* collected since the previous flush; must be safe to run repeatedly.
|
|
42
|
+
* @returns the disposer removing this subscriber (and the observer when it was
|
|
43
|
+
* the last one).
|
|
44
|
+
*/
|
|
45
|
+
export declare function subscribeBodyMutations(subscriber: (records: MutationRecord[]) => void): () => void;
|
|
@@ -36,6 +36,12 @@ export declare const zh: {
|
|
|
36
36
|
readonly 'hosts.delete': "删除";
|
|
37
37
|
readonly 'hosts.deleteConfirm': "确定删除主机「{alias}」?将同时停止其隧道。";
|
|
38
38
|
readonly 'hosts.imported': "解析 {parsed} 个配置块,新增 {added} 台主机,跳过 {skipped} 个。";
|
|
39
|
+
readonly 'hosts.proxyBadge': "代理";
|
|
40
|
+
readonly 'import.reason.wildcard': "通配符 pattern,需手动配置";
|
|
41
|
+
readonly 'import.reason.existing': "别名已存在";
|
|
42
|
+
readonly 'import.reason.match': "Match 条件块,不支持";
|
|
43
|
+
readonly 'import.reason.invalid': "字段不合法,无法导入";
|
|
44
|
+
readonly 'import.more': "其余 {count} 个未显示";
|
|
39
45
|
readonly 'hosts.testing': "连接测试中…";
|
|
40
46
|
readonly 'hosts.testOk': "连接成功({latency} ms)";
|
|
41
47
|
readonly 'hosts.testFail': "连接失败:{error}";
|
|
@@ -62,7 +68,9 @@ export declare const zh: {
|
|
|
62
68
|
readonly 'form.passwordHint': "明文保存在 $DSH_HOME/dsh-ssh.json(本机私有文件)";
|
|
63
69
|
readonly 'form.passwordRequired': "密码认证需要填写密码。";
|
|
64
70
|
readonly 'form.proxyJump': "跳板机别名";
|
|
65
|
-
readonly 'form.proxyJumpHint': "
|
|
71
|
+
readonly 'form.proxyJumpHint': "逗号分隔的本地主机别名,或 [user@]host[:port] 地址,按顺序连接";
|
|
72
|
+
readonly 'form.proxyCommand': "ProxyCommand";
|
|
73
|
+
readonly 'form.proxyCommandHint': "OpenSSH ProxyCommand:%h 主机、%p 端口、%r 用户、%n 别名;留空或填 none 表示不使用。与跳板机别名互斥。";
|
|
66
74
|
readonly 'form.description': "备注";
|
|
67
75
|
readonly 'form.environment': "环境";
|
|
68
76
|
readonly 'form.environmentHint': "如 development / production";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { SshApi } from '../api.ts';
|
|
2
2
|
import type { TunnelInfo } from '../../protocol.ts';
|
|
3
|
-
/** Live-tunnel polling interval while the tab
|
|
3
|
+
/** Live-tunnel polling interval while the tab and page are visible (ms). */
|
|
4
4
|
export declare const TUNNEL_POLL_MS = 5000;
|
|
5
5
|
/**
|
|
6
6
|
* Return `next` only when the tunnel list changed in a user-visible way
|
|
@@ -12,6 +12,8 @@ export declare function diffTunnels(prev: TunnelInfo[] | null, next: TunnelInfo[
|
|
|
12
12
|
/** Tunnels tab props. */
|
|
13
13
|
export interface TunnelsTabProps {
|
|
14
14
|
api: SshApi;
|
|
15
|
+
/** Pause automatic reads while the owning panel is closed, preserving form state. */
|
|
16
|
+
active?: boolean;
|
|
15
17
|
}
|
|
16
18
|
/** The tunnels tab. */
|
|
17
|
-
export declare function TunnelsTab({ api }: TunnelsTabProps): import("react").JSX.Element;
|
|
19
|
+
export declare function TunnelsTab({ api, active }: TunnelsTabProps): import("react").JSX.Element;
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import { type Root } from 'react-dom/client';
|
|
22
22
|
/** Options for mountCenterPanel; dsh-ssh mount.tsx and dsh-task-board board-mount.tsx are the canonical consumers. */
|
|
23
23
|
export interface CenterPanelMountOptions {
|
|
24
|
-
/** Render the panel
|
|
24
|
+
/** Render the panel tree (first open, remount while open, locale refresh). */
|
|
25
25
|
render: (root: Root) => void;
|
|
26
26
|
/** dataset key of the injected container's view attribute, e.g. `dshSshView` for `data-dsh-ssh-view`. */
|
|
27
27
|
viewDatasetKey: string;
|
|
@@ -1,18 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared sidebar entry injection core.
|
|
3
|
-
*
|
|
4
|
-
* dsh's sidebar shell exposes no slot an external plugin can register into,
|
|
5
|
-
* so the entry row is injected between the shell's New Session button and the
|
|
6
|
-
* workspace browser. The injection self-heals: a MutationObserver watches the
|
|
7
|
-
* sidebar root and re-inserts the row whenever a React re-render displaces it
|
|
8
|
-
* (re-insertion happens in the same frame, before paint, so no flicker).
|
|
9
|
-
*
|
|
10
|
-
* The row is plain DOM (no React tree) so it can never disturb the shell's
|
|
11
|
-
* reconciliation; the view it toggles is a separate root owned by the caller.
|
|
12
|
-
*
|
|
13
|
-
* Packages receive this file as a generated copy via scripts/sync-shared.mjs;
|
|
14
|
-
* edit the shared source and re-run the sync instead of editing a copy.
|
|
15
|
-
*/
|
|
16
1
|
/** Per-package configuration for one sidebar entry row. */
|
|
17
2
|
export interface SidebarEntryOptions {
|
|
18
3
|
/** Full attribute name identifying the injected row (idempotency key), e.g. 'data-dsh-ssh-entry'. */
|
|
@@ -62,10 +62,30 @@ export declare function appendOutput(target: {
|
|
|
62
62
|
text: string;
|
|
63
63
|
truncated: boolean;
|
|
64
64
|
}, chunk: Buffer, maxBytes: number): void;
|
|
65
|
+
/**
|
|
66
|
+
* Parse an OpenSSH jump hop written as an address: `[user@]host[:port]`
|
|
67
|
+
* (an IPv6 literal must be bracketed). Returns undefined when the value is not
|
|
68
|
+
* an address at all.
|
|
69
|
+
*/
|
|
70
|
+
export declare function parseJumpSpec(spec: string): {
|
|
71
|
+
host: string;
|
|
72
|
+
port?: number;
|
|
73
|
+
user?: string;
|
|
74
|
+
} | undefined;
|
|
75
|
+
/**
|
|
76
|
+
* Resolve one ProxyJump spec: an alias configured in this plugin wins, and any
|
|
77
|
+
* other value is read as an OpenSSH address whose credentials come from the
|
|
78
|
+
* target entry (an ad-hoc hop has no stored auth of its own).
|
|
79
|
+
*/
|
|
80
|
+
export declare function resolveHop(engine: PoolEngine, entry: SshHostEntry, spec: string): SshHostEntry;
|
|
65
81
|
/**
|
|
66
82
|
* Build one full jump chain for an entry: hop clients connected through in
|
|
67
83
|
* order, each forwarding a stream to the next destination, ending with the
|
|
68
84
|
* target client. Shared by the pool and standalone shell sessions.
|
|
85
|
+
*
|
|
86
|
+
* A ProxyCommand is the transport that reaches its own host, so the entry's
|
|
87
|
+
* own command seeds the chain when there is no jump chain, and the first hop's
|
|
88
|
+
* command is used when the hop carries one.
|
|
69
89
|
*/
|
|
70
90
|
export declare function connectChain(engine: PoolEngine, entry: SshHostEntry, onKeyboardInteractive?: KeyboardInteractiveHandler): Promise<{
|
|
71
91
|
client: Client;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenSSH-compatible ProxyCommand transport: run the command with the user's
|
|
3
|
+
* shell and expose its stdio as a Duplex that ssh2 accepts as `sock`.
|
|
4
|
+
*
|
|
5
|
+
* The command comes verbatim from the user's own 0600 store file and runs with
|
|
6
|
+
* the DSH host process's privileges — the same trust the `ssh(1)` client gives
|
|
7
|
+
* the identical line in `~/.ssh/config`.
|
|
8
|
+
*/
|
|
9
|
+
import { type ChildProcess } from 'node:child_process';
|
|
10
|
+
import { Duplex } from 'node:stream';
|
|
11
|
+
/** Token source for one host: the values %h / %p / %r / %n expand to. */
|
|
12
|
+
export interface ProxyTarget {
|
|
13
|
+
host: string;
|
|
14
|
+
port: number;
|
|
15
|
+
user: string;
|
|
16
|
+
/** The name the user addresses this host by (OpenSSH's %n). */
|
|
17
|
+
alias: string;
|
|
18
|
+
}
|
|
19
|
+
/** Expand the OpenSSH ProxyCommand tokens; unknown %X sequences stay verbatim. */
|
|
20
|
+
export declare function expandProxyTokens(command: string, target: ProxyTarget): string;
|
|
21
|
+
/** One running proxy command: its transport plus the process behind it. */
|
|
22
|
+
export interface ProxyCommandProcess {
|
|
23
|
+
/** The duplex transport handed to ssh2 as `sock`. */
|
|
24
|
+
stream: Duplex;
|
|
25
|
+
/** The spawned shell; killing the stream kills it (and its process group). */
|
|
26
|
+
child: ChildProcess;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Start one ProxyCommand and bridge its stdio into a Duplex.
|
|
30
|
+
*
|
|
31
|
+
* Failure modes are reported through the stream: a spawn error, or an exit
|
|
32
|
+
* before the SSH handshake finished, destroys it with the exit status and the
|
|
33
|
+
* tail of the command's stderr instead of letting ssh2 wait for its own
|
|
34
|
+
* `readyTimeout`.
|
|
35
|
+
*/
|
|
36
|
+
export declare function startProxyCommand(rawCommand: string, target: ProxyTarget): ProxyCommandProcess;
|
package/lib/types/index.d.ts
CHANGED
|
@@ -39,7 +39,7 @@ export interface Config {
|
|
|
39
39
|
}
|
|
40
40
|
export declare const Config: z<Config>;
|
|
41
41
|
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
42
|
-
export declare const SSH_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-ssh \u63D2\u4EF6\uFF08DSH \u8FDC\u7A0B SSH \u8FD0\u7EF4\uFF09\uFF1A\u4FA7\u8FB9\u680F\u300CSSH\u300D\u5165\u53E3\uFF1B\u5728 dsh-web \u63D2\u4EF6\u5168\u5BB6\u6876\u4ED3\u5E93\uFF08packages/dsh-ssh\uFF09\u7EDF\u4E00\u7EF4\u62A4\u3002\u80FD\u529B\uFF1A\u4E3B\u673A\u914D\u7F6E\u5B58 $DSH_HOME/dsh-ssh.json\uFF08\u9ED8\u8BA4 ~/.dsh\uFF09\uFF08\u53EF\u4ECE ~/.ssh/config \u5BFC\u5165\uFF09\uFF1B\u6301\u4E45\u8FDE\u63A5\u6C60\u590D\u7528\u957F\u8FDE\u63A5\uFF08\u7A7A\u95F2 30 \u5206\u949F\u81EA\u52A8\u65AD\u5F00\uFF09\uFF1Bssh_list \u5217\u51FA\u4E3B\u673A\u3001ssh_exec \u6267\u884C\u8FDC\u7A0B\u547D\u4EE4\u3001ssh_upload/ssh_download \u4F20\u8F93\u6587\u4EF6\u3001ssh_tunnel \u672C\u5730\u7AEF\u53E3\u8F6C\u53D1\uFF08\u8BBF\u95EE\u8FDC\u7A0B\u6570\u636E\u5E93/\u5185\u7F51\u670D\u52A1\uFF09\u3001ssh_cluster \u6309 aliases/environment/tags \u81F3\u5C11\u4E00\u79CD\u975E\u7A7A selector \u7B5B\u9009\u540E\u96C6\u7FA4\u5E76\u53D1\u6267\u884C\uFF1B\u652F\u6301\u5BC6\u94A5/\u5BC6\u7801/ssh-agent \u8BA4\u8BC1\u3001passphrase \u5BC6\u94A5\u4E0E
|
|
42
|
+
export declare const SSH_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-ssh \u63D2\u4EF6\uFF08DSH \u8FDC\u7A0B SSH \u8FD0\u7EF4\uFF09\uFF1A\u4FA7\u8FB9\u680F\u300CSSH\u300D\u5165\u53E3\uFF1B\u5728 dsh-web \u63D2\u4EF6\u5168\u5BB6\u6876\u4ED3\u5E93\uFF08packages/dsh-ssh\uFF09\u7EDF\u4E00\u7EF4\u62A4\u3002\u80FD\u529B\uFF1A\u4E3B\u673A\u914D\u7F6E\u5B58 $DSH_HOME/dsh-ssh.json\uFF08\u9ED8\u8BA4 ~/.dsh\uFF09\uFF08\u53EF\u4ECE ~/.ssh/config \u5BFC\u5165\uFF09\uFF1B\u6301\u4E45\u8FDE\u63A5\u6C60\u590D\u7528\u957F\u8FDE\u63A5\uFF08\u7A7A\u95F2 30 \u5206\u949F\u81EA\u52A8\u65AD\u5F00\uFF09\uFF1Bssh_list \u5217\u51FA\u4E3B\u673A\u3001ssh_exec \u6267\u884C\u8FDC\u7A0B\u547D\u4EE4\u3001ssh_upload/ssh_download \u4F20\u8F93\u6587\u4EF6\u3001ssh_tunnel \u672C\u5730\u7AEF\u53E3\u8F6C\u53D1\uFF08\u8BBF\u95EE\u8FDC\u7A0B\u6570\u636E\u5E93/\u5185\u7F51\u670D\u52A1\uFF09\u3001ssh_cluster \u6309 aliases/environment/tags \u81F3\u5C11\u4E00\u79CD\u975E\u7A7A selector \u7B5B\u9009\u540E\u96C6\u7FA4\u5E76\u53D1\u6267\u884C\uFF1B\u652F\u6301\u5BC6\u94A5/\u5BC6\u7801/ssh-agent \u8BA4\u8BC1\u3001passphrase \u5BC6\u94A5\u3001ProxyJump \u8DF3\u677F\u673A\uFF08\u522B\u540D\u6216 [user@]host[:port] \u5730\u5740\uFF09\u4E0E OpenSSH \u8BED\u4E49\u7684 ProxyCommand\uFF08\u8DF3\u8FC7\u5821\u5792\u673A\u5BA2\u6237\u7AEF\u573A\u666F\uFF0C\u53EA\u80FD\u7531\u7528\u6237\u5728 GUI \u914D\u7F6E\uFF0Cagent \u4E0D\u53EF\u5199\uFF09\uFF1BWeb \u7EC8\u7AEF\u8D70 WebSocket\u3002\u9650\u5236\uFF1A\u4E3B\u673A\u64CD\u4F5C\u7531\u7528\u6237\u5728 GUI \u4E2D\u914D\u7F6E\u540E agent \u65B9\u53EF\u4F7F\u7528\uFF1B\u5BC6\u7801\u4EE5\u660E\u6587\u5B58\u5728\u7528\u6237\u4E3B\u76EE\u5F55\u79C1\u6709\u6587\u4EF6\uFF08\u6743\u9650 0600\uFF09\uFF1B\u547D\u4EE4\u8F93\u51FA\u539F\u6837\u8FD4\u56DE\u3001\u53EF\u80FD\u542B\u654F\u611F\u4FE1\u606F\uFF1B\u65AD\u7EBF\u91CD\u8FDE\u53EF\u80FD\u91CD\u653E\u975E\u5E42\u7B49\u547D\u4EE4\uFF1B\u4F20\u8F93/\u6267\u884C\u6D88\u8017\u771F\u5B9E\u8FDC\u7A0B\u8D44\u6E90\uFF0C\u5148\u786E\u8BA4\u518D\u64CD\u4F5C\u3002\u8DEF\u5F84\u533A\u5206\uFF1A\u672C\u673A\uFF08dsh host\uFF09\u4E0A\u7684\u6587\u4EF6\u4E0E\u547D\u4EE4\u4E00\u5F8B\u7528\u672C\u5730\u5DE5\u5177\uFF08read / write / edit / bash\uFF09\uFF0Cssh_* \u5DE5\u5177\u53EA\u9488\u5BF9\u8FDC\u7A0B\u4E3B\u673A\u4E0A\u7684\u8DEF\u5F84\u3002\u7528\u6237\u63D0\u5230\u300CSSH / \u8FDC\u7A0B\u670D\u52A1\u5668 / \u670D\u52A1\u5668\u64CD\u4F5C / \u8DF3\u677F\u673A / \u96A7\u9053 / \u90E8\u7F72 / \u4E0A\u4F20\u4E0B\u8F7D\u300D\u65F6\u5373\u6307\u672C\u63D2\u4EF6\uFF0C\u8BF7\u636E\u6B64\u534F\u4F5C\u3002";
|
|
43
43
|
/**
|
|
44
44
|
* Mount the SSH engine, routes, tools, and announcement.
|
|
45
45
|
* @param ctx - host plugin context carrying webServer/tools/systemPrompt.
|
package/lib/types/protocol.d.ts
CHANGED
|
@@ -33,6 +33,13 @@ export interface SshHostEntry {
|
|
|
33
33
|
};
|
|
34
34
|
/** Jump chain: local aliases connected through in order (ProxyJump). */
|
|
35
35
|
proxyJump: string[];
|
|
36
|
+
/**
|
|
37
|
+
* OpenSSH-compatible ProxyCommand: a shell command whose stdio becomes the
|
|
38
|
+
* connection transport. Tokens %h (host) / %p (port) / %r (user) / %n (alias)
|
|
39
|
+
* expand before exec and %% is a literal percent; an empty value or 'none'
|
|
40
|
+
* means "no proxy command". Mutually exclusive with a non-empty proxyJump.
|
|
41
|
+
*/
|
|
42
|
+
proxyCommand?: string;
|
|
36
43
|
/** Free-form note. */
|
|
37
44
|
description?: string;
|
|
38
45
|
/** Deployment environment label (development / production / ...). */
|
|
@@ -54,6 +61,8 @@ export interface SshHostSummary {
|
|
|
54
61
|
/** Whether the key path exists on the host machine (key auth only). */
|
|
55
62
|
keyReady: boolean;
|
|
56
63
|
proxyJump: string[];
|
|
64
|
+
/** Configured ProxyCommand, echoed to the browser so the form can edit it. */
|
|
65
|
+
proxyCommand?: string;
|
|
57
66
|
description?: string;
|
|
58
67
|
environment?: string;
|
|
59
68
|
tags: string[];
|
|
@@ -132,18 +141,31 @@ export interface HostPayload {
|
|
|
132
141
|
*/
|
|
133
142
|
auth?: SshHostEntry['auth'];
|
|
134
143
|
proxyJump?: string[];
|
|
144
|
+
/**
|
|
145
|
+
* ProxyCommand to store. An empty string or 'none' clears a stored value;
|
|
146
|
+
* omitting the key leaves it untouched (PATCH semantics).
|
|
147
|
+
*/
|
|
148
|
+
proxyCommand?: string;
|
|
135
149
|
description?: string;
|
|
136
150
|
environment?: string;
|
|
137
151
|
tags?: string[];
|
|
138
152
|
location?: string;
|
|
139
153
|
}
|
|
154
|
+
/** Why one ssh_config Host block was not imported. */
|
|
155
|
+
export type ImportSkipReason = 'wildcard' | 'existing' | 'match' | 'invalid';
|
|
156
|
+
/** One skipped ssh_config block and the reason it could not be imported. */
|
|
157
|
+
export interface ImportSkipBlock {
|
|
158
|
+
/** Host pattern (or '`Match …`' label) that was skipped. */
|
|
159
|
+
name: string;
|
|
160
|
+
reason: ImportSkipReason;
|
|
161
|
+
}
|
|
140
162
|
/** Import outcome from ~/.ssh/config. */
|
|
141
163
|
export interface ImportResult {
|
|
142
164
|
parsed: number;
|
|
143
165
|
added: number;
|
|
144
166
|
skipped: number;
|
|
145
|
-
/**
|
|
146
|
-
|
|
167
|
+
/** Every skipped block with its reason, in scan order and deduplicated by name. */
|
|
168
|
+
skippedBlocks: ImportSkipBlock[];
|
|
147
169
|
}
|
|
148
170
|
/** JSON error body used by every route. */
|
|
149
171
|
export interface ApiErrorBody {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal `~/.ssh/config` reader for the one-shot host import: Host blocks with
|
|
3
|
+
* `Include` expansion and explicit skip reasons. Values stay raw strings; the
|
|
4
|
+
* store maps them onto a HostPayload.
|
|
5
|
+
*
|
|
6
|
+
* Deliberate simplifications (documented in the package README):
|
|
7
|
+
* - `Match` blocks are skipped — their options are conditional and must never
|
|
8
|
+
* merge into the Host block above them.
|
|
9
|
+
* - A multi-pattern `Host a b` line only exposes its first pattern.
|
|
10
|
+
* - Include pathnames accept `~`, globs, and several whitespace-separated
|
|
11
|
+
* pathnames, but not environment variables, `%` tokens, or quoting.
|
|
12
|
+
* - Relative Include pathnames resolve against the directory of the config
|
|
13
|
+
* file the import started from (OpenSSH: `~/.ssh` for a user config).
|
|
14
|
+
* - A file that already contributed lines is never read twice, which doubles
|
|
15
|
+
* as the Include cycle guard.
|
|
16
|
+
*/
|
|
17
|
+
import type { ImportSkipBlock } from './protocol.ts';
|
|
18
|
+
/** One parsed Host block. */
|
|
19
|
+
export interface SshBlock {
|
|
20
|
+
/** Raw Host line value (may hold several whitespace-separated patterns). */
|
|
21
|
+
pattern: string;
|
|
22
|
+
/** Lowercased option key/value pairs; the last occurrence of a key wins. */
|
|
23
|
+
props: Record<string, string>;
|
|
24
|
+
}
|
|
25
|
+
/** One config file read, with every Include expanded in place. */
|
|
26
|
+
export interface SshConfigRead {
|
|
27
|
+
blocks: SshBlock[];
|
|
28
|
+
skipped: ImportSkipBlock[];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Parse one ssh_config file (Include expanded) into Host blocks plus the
|
|
32
|
+
* `Match` blocks that were deliberately skipped.
|
|
33
|
+
*/
|
|
34
|
+
export declare function readSshConfigBlocks(configPath: string): SshConfigRead;
|
package/lib/types/store.d.ts
CHANGED
|
@@ -42,12 +42,15 @@ export declare class HostStore {
|
|
|
42
42
|
delete(alias: string): void;
|
|
43
43
|
/**
|
|
44
44
|
* Import hosts from `~/.ssh/config`: Host blocks with a single non-wildcard
|
|
45
|
-
* pattern
|
|
46
|
-
*
|
|
45
|
+
* pattern become entries (a missing HostName falls back to the pattern
|
|
46
|
+
* itself; key auth via IdentityFile; jump hosts via ProxyJump; ProxyCommand
|
|
47
|
+
* kept verbatim). `Include` files are expanded, and `Match` blocks,
|
|
48
|
+
* wildcard patterns, and aliases that already exist are skipped with a
|
|
49
|
+
* reason instead of silently disappearing.
|
|
47
50
|
* @returns import statistics.
|
|
48
51
|
*/
|
|
49
52
|
importFromSshConfig(): ImportResult;
|
|
50
|
-
private
|
|
53
|
+
private skippedBlocks;
|
|
51
54
|
/**
|
|
52
55
|
* Last parsed store keyed by file identity. list/find ride every acquire
|
|
53
56
|
* and GUI refresh; re-reading and re-parsing the whole file each call is
|
|
@@ -59,5 +62,10 @@ export declare class HostStore {
|
|
|
59
62
|
}
|
|
60
63
|
/** Normalize an agent endpoint for storage: trim, expand `~`, and resolve the SSH_AUTH_SOCK token. */
|
|
61
64
|
export declare function normalizeAgentPath(agentPath: string | undefined): string | undefined;
|
|
65
|
+
/**
|
|
66
|
+
* Normalize a ProxyCommand for storage: trim, and treat an empty value or
|
|
67
|
+
* OpenSSH's `none` keyword as "no proxy command" (an explicit clear).
|
|
68
|
+
*/
|
|
69
|
+
export declare function normalizeProxyCommand(raw: string | undefined): string | undefined;
|
|
62
70
|
/** Expand a leading `~` in a filesystem path. */
|
|
63
71
|
export declare function expandHome(path: string): string;
|
package/lib/types/tools.d.ts
CHANGED
|
@@ -4,6 +4,30 @@
|
|
|
4
4
|
* is immediately operable by any agent, and vice versa.
|
|
5
5
|
*/
|
|
6
6
|
import type { SshEngine } from './engine.ts';
|
|
7
|
+
import type { SshAuthKind, SshHostSummary } from './protocol.ts';
|
|
8
|
+
/**
|
|
9
|
+
* One host row for the agent surface. The ProxyCommand STRING is deliberately
|
|
10
|
+
* projected to a boolean: the command may embed credentials for a bastion
|
|
11
|
+
* client, and the model only needs to know that the host goes through one.
|
|
12
|
+
*/
|
|
13
|
+
export interface AgentHostRow {
|
|
14
|
+
alias: string;
|
|
15
|
+
host: string;
|
|
16
|
+
port: number;
|
|
17
|
+
user: string;
|
|
18
|
+
auth: SshAuthKind;
|
|
19
|
+
keyReady: boolean;
|
|
20
|
+
proxyJump: string[];
|
|
21
|
+
proxyCommandConfigured: boolean;
|
|
22
|
+
description?: string;
|
|
23
|
+
environment?: string;
|
|
24
|
+
tags: string[];
|
|
25
|
+
location?: string;
|
|
26
|
+
createdAt: number;
|
|
27
|
+
updatedAt: number;
|
|
28
|
+
}
|
|
29
|
+
/** Project one summary onto the agent-facing row shape. */
|
|
30
|
+
export declare function toAgentHostRow(host: SshHostSummary): AgentHostRow;
|
|
7
31
|
/** The host-list tool. */
|
|
8
32
|
export declare function sshListTool(engine: SshEngine): import("@deepseek-ai/dsh-tools").ToolDefinition;
|
|
9
33
|
/** The command-execution tool. */
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-ssh",
|
|
3
3
|
"description": "Remote SSH operations for the dsh web GUI: host config store (~/.dsh/dsh-ssh.json, import from ~/.ssh/config), a persistent ssh2 connection pool with jump-host support, exec / PTY web terminal / SFTP transfer / local port-forward tunnels / cluster execution, plus agent tools (ssh_list, ssh_exec, ssh_upload, ssh_download, ssh_tunnel, ssh_cluster). Hot-pluggable — mounted via ~/.dsh/cordis.patch.yml + a profile node_modules symlink, no dsh source changes.",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.21",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "^22.19.0 || >=24.0.0"
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"dsh": {
|
|
28
28
|
"engines": {
|
|
29
|
-
"dsh": ">=0.1.
|
|
29
|
+
"dsh": ">=0.1.5-rc.1"
|
|
30
30
|
},
|
|
31
31
|
"bundle": {
|
|
32
32
|
"patch": "./cordis.patch.yml"
|
|
@@ -52,18 +52,18 @@
|
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@deepseek-ai/cordis": "^4.0.2",
|
|
55
|
-
"@deepseek-ai/dsh-client-connection": "^0.1.
|
|
56
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.
|
|
57
|
-
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.
|
|
58
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.1.
|
|
59
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.
|
|
60
|
-
"@deepseek-ai/dsh-host-webserver": "^0.1.
|
|
61
|
-
"@deepseek-ai/dsh-llm": "^0.1.
|
|
62
|
-
"@deepseek-ai/dsh-scope": "^0.1.
|
|
63
|
-
"@deepseek-ai/dsh-settings": "^0.1.
|
|
64
|
-
"@deepseek-ai/dsh-system-prompt": "^0.1.
|
|
65
|
-
"@deepseek-ai/dsh-timeout": "^0.1.
|
|
66
|
-
"@deepseek-ai/dsh-tools": "^0.1.
|
|
55
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.5-rc.1",
|
|
56
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.5-rc.1",
|
|
57
|
+
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-rc.1",
|
|
58
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.1",
|
|
59
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.1",
|
|
60
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.1",
|
|
61
|
+
"@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
|
|
62
|
+
"@deepseek-ai/dsh-scope": "^0.1.5-rc.1",
|
|
63
|
+
"@deepseek-ai/dsh-settings": "^0.1.5-rc.1",
|
|
64
|
+
"@deepseek-ai/dsh-system-prompt": "^0.1.5-rc.1",
|
|
65
|
+
"@deepseek-ai/dsh-timeout": "^0.1.5-rc.1",
|
|
66
|
+
"@deepseek-ai/dsh-tools": "^0.1.5-rc.1",
|
|
67
67
|
"@types/node": "^22.20.0",
|
|
68
68
|
"@types/react": "~18.3.1",
|
|
69
69
|
"@types/react-dom": "^18.3.5",
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/client/body-mutations.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* One `document.body` MutationObserver per page, shared by every family
|
|
4
|
+
* plugin bundle.
|
|
5
|
+
*
|
|
6
|
+
* Family plugins that inject at the DOM level must notice when the shell
|
|
7
|
+
* re-renders around their injected node: the sidebar entry rows
|
|
8
|
+
* (sidebar-entry-core), the center-column panel containers
|
|
9
|
+
* (panel-mount-core), and the aggregate shell's column shims. Each consumer
|
|
10
|
+
* used to install its OWN `MutationObserver` on `document.body` with
|
|
11
|
+
* `{ childList: true, subtree: true }`, so a page carrying N family plugins
|
|
12
|
+
* paid N native observers and N callback invocations for EVERY mutation batch
|
|
13
|
+
* the app produced — chat token streaming alone produces many per second, and
|
|
14
|
+
* the count grows with the number of installed plugins.
|
|
15
|
+
*
|
|
16
|
+
* This module keeps exactly ONE body observer per page. The registry lives on
|
|
17
|
+
* `globalThis` under a `Symbol.for` key, so the per-package generated copies
|
|
18
|
+
* of this file (scripts/sync-shared.mjs) and separately bundled plugins all
|
|
19
|
+
* reach the same hub at runtime instead of one hub per module instance.
|
|
20
|
+
* Record subscribers receive the mutations accumulated since the last flush.
|
|
21
|
+
* Consumers that only re-check their DOM use subscribeBodyInvalidations:
|
|
22
|
+
* when all subscribers use that path, the hub retains no mutation records
|
|
23
|
+
* (or detached subtrees) while a background page's animation frames pause.
|
|
24
|
+
* Both paths run at most once per frame. The last subscriber disconnects the
|
|
25
|
+
* observer, cancels the pending frame and releases its records.
|
|
26
|
+
*
|
|
27
|
+
* Failure policy: without a DOM or `MutationObserver` the subscription is a
|
|
28
|
+
* no-op disposer (the same silence the per-consumer observers had), and when
|
|
29
|
+
* `requestAnimationFrame` is unavailable the flush runs synchronously so a
|
|
30
|
+
* subscriber is never silently dropped. A throwing subscriber cannot stop the
|
|
31
|
+
* others.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** The page-wide hub: one observer plus its subscribers and pending records. */
|
|
35
|
+
interface BodyMutationHub {
|
|
36
|
+
observer: MutationObserver
|
|
37
|
+
subscribers: Set<(records: MutationRecord[]) => void>
|
|
38
|
+
pending: MutationRecord[]
|
|
39
|
+
scheduled: boolean
|
|
40
|
+
frame?: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Cross-bundle registry key; `Symbol.for` so every module copy agrees. */
|
|
44
|
+
const HUB_KEY = Symbol.for('dsh-web.body-mutation-hub')
|
|
45
|
+
const INVALIDATION_ONLY = Symbol.for('dsh-web.body-mutation-invalidation')
|
|
46
|
+
type Subscriber = ((records: MutationRecord[]) => void) & { [INVALIDATION_ONLY]?: true }
|
|
47
|
+
|
|
48
|
+
function needsRecords(subscribers: Set<(records: MutationRecord[]) => void>): boolean {
|
|
49
|
+
for (const listener of subscribers) {
|
|
50
|
+
if (!(listener as Subscriber)[INVALIDATION_ONLY]) return true
|
|
51
|
+
}
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Subscribe to a coalesced DOM re-check without retaining mutation records.
|
|
57
|
+
* The marked wrapper also works with an older hub, which delivers records
|
|
58
|
+
* that it simply ignores until a page reload picks up the updated hub.
|
|
59
|
+
*/
|
|
60
|
+
export function subscribeBodyInvalidations(subscriber: () => void): () => void {
|
|
61
|
+
const listener: Subscriber = () => { subscriber() }
|
|
62
|
+
listener[INVALIDATION_ONLY] = true
|
|
63
|
+
return subscribeBodyMutations(listener)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Subscribe to body-level childList mutations.
|
|
68
|
+
* @param subscriber - called at most once per animation frame with the records
|
|
69
|
+
* collected since the previous flush; must be safe to run repeatedly.
|
|
70
|
+
* @returns the disposer removing this subscriber (and the observer when it was
|
|
71
|
+
* the last one).
|
|
72
|
+
*/
|
|
73
|
+
export function subscribeBodyMutations(subscriber: (records: MutationRecord[]) => void): () => void {
|
|
74
|
+
if (typeof globalThis === 'undefined' || typeof document === 'undefined') return () => {}
|
|
75
|
+
if (typeof MutationObserver !== 'function') return () => {}
|
|
76
|
+
const registry = globalThis as unknown as Record<symbol, unknown>
|
|
77
|
+
let hub = registry[HUB_KEY] as BodyMutationHub | undefined
|
|
78
|
+
if (hub === undefined) {
|
|
79
|
+
const subscribers = new Set<(records: MutationRecord[]) => void>()
|
|
80
|
+
const created: BodyMutationHub = {
|
|
81
|
+
observer: undefined as unknown as MutationObserver,
|
|
82
|
+
subscribers,
|
|
83
|
+
pending: [],
|
|
84
|
+
scheduled: false,
|
|
85
|
+
}
|
|
86
|
+
const flush = (): void => {
|
|
87
|
+
created.frame = undefined
|
|
88
|
+
created.scheduled = false
|
|
89
|
+
const batch = created.pending
|
|
90
|
+
created.pending = []
|
|
91
|
+
for (const listener of [...subscribers]) {
|
|
92
|
+
if (!subscribers.has(listener)) continue
|
|
93
|
+
try {
|
|
94
|
+
listener(batch)
|
|
95
|
+
} catch {
|
|
96
|
+
// One subscriber must never break the others; the next flush retries it.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const schedule = (): void => {
|
|
101
|
+
if (created.scheduled) return
|
|
102
|
+
created.scheduled = true
|
|
103
|
+
if (typeof requestAnimationFrame === 'function') created.frame = requestAnimationFrame(flush)
|
|
104
|
+
else flush()
|
|
105
|
+
}
|
|
106
|
+
created.observer = new MutationObserver((records) => {
|
|
107
|
+
if (needsRecords(subscribers)) {
|
|
108
|
+
for (const record of records) created.pending.push(record)
|
|
109
|
+
}
|
|
110
|
+
schedule()
|
|
111
|
+
})
|
|
112
|
+
created.observer.observe(document.body ?? document.documentElement, { childList: true, subtree: true })
|
|
113
|
+
registry[HUB_KEY] = created
|
|
114
|
+
hub = created
|
|
115
|
+
}
|
|
116
|
+
const active = hub
|
|
117
|
+
active.subscribers.add(subscriber)
|
|
118
|
+
let subscribed = true
|
|
119
|
+
return () => {
|
|
120
|
+
if (!subscribed) return
|
|
121
|
+
subscribed = false
|
|
122
|
+
active.subscribers.delete(subscriber)
|
|
123
|
+
if (!needsRecords(active.subscribers)) active.pending = []
|
|
124
|
+
if (active.subscribers.size === 0 && registry[HUB_KEY] === active) {
|
|
125
|
+
active.observer.disconnect()
|
|
126
|
+
if (active.frame !== undefined && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(active.frame)
|
|
127
|
+
active.frame = undefined
|
|
128
|
+
active.pending = []
|
|
129
|
+
active.scheduled = false
|
|
130
|
+
delete registry[HUB_KEY]
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
package/src/client/locales.ts
CHANGED
|
@@ -38,6 +38,12 @@ export const zh = {
|
|
|
38
38
|
'hosts.delete': '删除',
|
|
39
39
|
'hosts.deleteConfirm': '确定删除主机「{alias}」?将同时停止其隧道。',
|
|
40
40
|
'hosts.imported': '解析 {parsed} 个配置块,新增 {added} 台主机,跳过 {skipped} 个。',
|
|
41
|
+
'hosts.proxyBadge': '代理',
|
|
42
|
+
'import.reason.wildcard': '通配符 pattern,需手动配置',
|
|
43
|
+
'import.reason.existing': '别名已存在',
|
|
44
|
+
'import.reason.match': 'Match 条件块,不支持',
|
|
45
|
+
'import.reason.invalid': '字段不合法,无法导入',
|
|
46
|
+
'import.more': '其余 {count} 个未显示',
|
|
41
47
|
'hosts.testing': '连接测试中…',
|
|
42
48
|
'hosts.testOk': '连接成功({latency} ms)',
|
|
43
49
|
'hosts.testFail': '连接失败:{error}',
|
|
@@ -65,7 +71,9 @@ export const zh = {
|
|
|
65
71
|
'form.passwordHint': '明文保存在 $DSH_HOME/dsh-ssh.json(本机私有文件)',
|
|
66
72
|
'form.passwordRequired': '密码认证需要填写密码。',
|
|
67
73
|
'form.proxyJump': '跳板机别名',
|
|
68
|
-
'form.proxyJumpHint': '
|
|
74
|
+
'form.proxyJumpHint': '逗号分隔的本地主机别名,或 [user@]host[:port] 地址,按顺序连接',
|
|
75
|
+
'form.proxyCommand': 'ProxyCommand',
|
|
76
|
+
'form.proxyCommandHint': 'OpenSSH ProxyCommand:%h 主机、%p 端口、%r 用户、%n 别名;留空或填 none 表示不使用。与跳板机别名互斥。',
|
|
69
77
|
'form.description': '备注',
|
|
70
78
|
'form.environment': '环境',
|
|
71
79
|
'form.environmentHint': '如 development / production',
|
|
@@ -192,6 +200,12 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
192
200
|
'hosts.delete': 'Delete',
|
|
193
201
|
'hosts.deleteConfirm': 'Delete host "{alias}"? Its tunnels will be stopped.',
|
|
194
202
|
'hosts.imported': 'Parsed {parsed} blocks, added {added} hosts, skipped {skipped}.',
|
|
203
|
+
'hosts.proxyBadge': 'proxy',
|
|
204
|
+
'import.reason.wildcard': 'wildcard pattern — configure it by hand',
|
|
205
|
+
'import.reason.existing': 'alias already configured',
|
|
206
|
+
'import.reason.match': 'Match block — not supported',
|
|
207
|
+
'import.reason.invalid': 'invalid fields — not imported',
|
|
208
|
+
'import.more': '{count} more not shown',
|
|
195
209
|
'hosts.testing': 'Testing connection…',
|
|
196
210
|
'hosts.testOk': 'Connected ({latency} ms)',
|
|
197
211
|
'hosts.testFail': 'Connection failed: {error}',
|
|
@@ -218,7 +232,9 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
218
232
|
'form.passwordHint': 'Stored in plaintext in $DSH_HOME/dsh-ssh.json (private local file)',
|
|
219
233
|
'form.passwordRequired': 'Password auth requires a password.',
|
|
220
234
|
'form.proxyJump': 'Jump host aliases',
|
|
221
|
-
'form.proxyJumpHint': 'Comma-separated local aliases, chained in order',
|
|
235
|
+
'form.proxyJumpHint': 'Comma-separated local aliases or [user@]host[:port] addresses, chained in order',
|
|
236
|
+
'form.proxyCommand': 'ProxyCommand',
|
|
237
|
+
'form.proxyCommandHint': 'OpenSSH ProxyCommand: %h host, %p port, %r user, %n alias; empty or none disables it. Mutually exclusive with the jump chain.',
|
|
222
238
|
'form.description': 'Description',
|
|
223
239
|
'form.environment': 'Environment',
|
|
224
240
|
'form.environmentHint': 'e.g. development / production',
|
|
@@ -31,6 +31,7 @@ interface FormState {
|
|
|
31
31
|
password: string
|
|
32
32
|
agentPath: string
|
|
33
33
|
proxyJump: string
|
|
34
|
+
proxyCommand: string
|
|
34
35
|
description: string
|
|
35
36
|
environment: string
|
|
36
37
|
tags: string
|
|
@@ -55,6 +56,7 @@ function blankOf(editing: SshHostSummary | null | undefined): FormState {
|
|
|
55
56
|
password: '',
|
|
56
57
|
agentPath: '',
|
|
57
58
|
proxyJump: (editing?.proxyJump ?? []).join(', '),
|
|
59
|
+
proxyCommand: editing?.proxyCommand ?? '',
|
|
58
60
|
description: editing?.description ?? '',
|
|
59
61
|
environment: editing?.environment ?? '',
|
|
60
62
|
tags: (editing?.tags ?? []).join(', '),
|
|
@@ -121,6 +123,9 @@ export function HostFormDialog({ api, editing, onClose, onSaved }: HostFormDialo
|
|
|
121
123
|
user,
|
|
122
124
|
auth,
|
|
123
125
|
proxyJump: splitList(form.proxyJump),
|
|
126
|
+
// Always sent (never omitted): an empty value is the explicit clear,
|
|
127
|
+
// because the API cannot express "remove this field" any other way.
|
|
128
|
+
proxyCommand: form.proxyCommand.trim(),
|
|
124
129
|
description: form.description.trim() === '' ? undefined : form.description.trim(),
|
|
125
130
|
environment: form.environment.trim() === '' ? undefined : form.environment.trim(),
|
|
126
131
|
tags: splitList(form.tags),
|
|
@@ -212,6 +217,16 @@ export function HostFormDialog({ api, editing, onClose, onSaved }: HostFormDialo
|
|
|
212
217
|
<input className={css.input} value={form.proxyJump} onChange={event => { set('proxyJump', event.target.value) }} />
|
|
213
218
|
<span className={css.hint}>{tt('form.proxyJumpHint')}</span>
|
|
214
219
|
</label>
|
|
220
|
+
<label className={css.field}>
|
|
221
|
+
<span className={css.fieldLabel}>{tt('form.proxyCommand')}</span>
|
|
222
|
+
<input
|
|
223
|
+
className={css.input}
|
|
224
|
+
value={form.proxyCommand}
|
|
225
|
+
placeholder="corp-vpn proxy %h %p %r"
|
|
226
|
+
onChange={event => { set('proxyCommand', event.target.value) }}
|
|
227
|
+
/>
|
|
228
|
+
<span className={css.hint}>{tt('form.proxyCommandHint')}</span>
|
|
229
|
+
</label>
|
|
215
230
|
<div className={css.formRow}>
|
|
216
231
|
<label className={css.field}>
|
|
217
232
|
<span className={css.fieldLabel}>{tt('form.environment')}</span>
|