@hyzyn/dsh-tty 0.10.3 → 0.12.0
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.md +55 -2
- package/client.js +1852 -38
- package/lib/index.d.ts +11 -0
- package/lib/index.js +156 -8
- package/lib/index.js.map +1 -1
- package/lib/probe.d.ts +42 -0
- package/lib/probe.js +274 -0
- package/lib/probe.js.map +1 -0
- package/lib/sftp.d.ts +52 -2
- package/lib/sftp.js +197 -12
- package/lib/sftp.js.map +1 -1
- package/package.json +2 -1
package/lib/index.d.ts
CHANGED
|
@@ -93,9 +93,20 @@ export interface Config {
|
|
|
93
93
|
persistence?: 'off' | 'tmux';
|
|
94
94
|
/** 页面(最后一个连接)断开且保活期结束时,是否连 tmux 持久会话一起结束(默认 false = 留存可恢复)。 */
|
|
95
95
|
endOnPageClose?: boolean;
|
|
96
|
+
/** SFTP 传输限制(0 = 不限)。 */
|
|
97
|
+
sftpLimits?: Partial<SftpLimits>;
|
|
96
98
|
/** 内部状态:SSH 持久会话名(远程 tmux 托管,本机 socket 清单看不到,随 settings 留存供新窗口恢复确认)。 */
|
|
97
99
|
persistSessions?: Array<{
|
|
98
100
|
tmuxName: string;
|
|
99
101
|
}>;
|
|
100
102
|
}
|
|
103
|
+
/** SFTP 传输限制(均为 0 = 不限;客户端浏览器侧执行,宿主不做总量闸)。 */
|
|
104
|
+
export interface SftpLimits {
|
|
105
|
+
/** 单文件下载上限(MB)。默认 1024。 */
|
|
106
|
+
maxDownloadMb: number;
|
|
107
|
+
/** 单文件上传上限(MB)。默认 2048。 */
|
|
108
|
+
maxUploadMb: number;
|
|
109
|
+
/** 一次批量/拖拽上传的文件数上限。默认 1000。 */
|
|
110
|
+
maxUploadFiles: number;
|
|
111
|
+
}
|
|
101
112
|
export declare const name: string, inject: string[] | undefined, apply: (ctx: Context, config?: Config | undefined) => void;
|
package/lib/index.js
CHANGED
|
@@ -15,12 +15,15 @@ const HeadlessTerminal = xtermHeadless.Terminal;
|
|
|
15
15
|
import { definePlugin } from '@hyzyn/dsh-kit';
|
|
16
16
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
17
17
|
import { spawnSsh, sshTarget, expandHome } from './ssh.js';
|
|
18
|
+
import { probeSsh } from './probe.js';
|
|
18
19
|
import { buildShellSpawn } from './shell-integration.js';
|
|
19
20
|
import { parseSshConfig } from './ssh-config.js';
|
|
20
21
|
import { parseKnownHosts } from './known-hosts.js';
|
|
21
22
|
import { TunnelManager } from './tunnels.js';
|
|
22
23
|
import { SftpManager } from './sftp.js';
|
|
23
24
|
import { buildTmuxSpawnPlan, ensureTmuxAssets, killTmuxSession, listTmuxSessions, probeTmux, refreshTmuxClient, sanitizePersistName } from './tmux.js';
|
|
25
|
+
/** SFTP 传输限制默认值。 */
|
|
26
|
+
const DEFAULT_SFTP_LIMITS = { maxDownloadMb: 1024, maxUploadMb: 2048, maxUploadFiles: 1000 };
|
|
24
27
|
const SSH_HOST_SCHEMA = z.object({
|
|
25
28
|
name: z.string(),
|
|
26
29
|
host: z.string(),
|
|
@@ -67,6 +70,11 @@ const TTY_SETTINGS_SCHEMA = z.object({
|
|
|
67
70
|
sftpStyle: z.union([z.const('dialog'), z.const('dual')]).default('dialog'),
|
|
68
71
|
persistence: z.union([z.const('off'), z.const('tmux')]).default('off'),
|
|
69
72
|
endOnPageClose: z.boolean().default(false),
|
|
73
|
+
sftpLimits: z.object({
|
|
74
|
+
maxDownloadMb: z.natural().max(1024 * 1024).default(1024),
|
|
75
|
+
maxUploadMb: z.natural().max(1024 * 1024).default(2048),
|
|
76
|
+
maxUploadFiles: z.natural().max(100000).default(1000),
|
|
77
|
+
}).default({ maxDownloadMb: 1024, maxUploadMb: 2048, maxUploadFiles: 1000 }),
|
|
70
78
|
persistSessions: z.array(z.object({ tmuxName: z.string() })).default([]),
|
|
71
79
|
});
|
|
72
80
|
/* ------------------------------------------------------------------ *
|
|
@@ -85,7 +93,7 @@ const BUFFER_CAP = 256 * 1024;
|
|
|
85
93
|
const TERM_RE = /^[A-Za-z0-9_.+-]+$/;
|
|
86
94
|
/** 孤儿会话回收器的扫描间隔。 */
|
|
87
95
|
const REAPER_INTERVAL_MS = 10_000;
|
|
88
|
-
const TTY_GUIDANCE = '本机已安装 dsh-tty 插件(终端面板):Web GUI 侧边栏的「终端」入口可打开交互终端(xterm.js + PTY),可运行任意命令与 TUI 程序(vim/htop 等),支持多标签页与断线自动重连(刷新页面/网络抖动后会话保活并恢复现场);新标签默认在当前会话工作目录打开。标签栏「+」菜单还能开 SSH 标签页(ssh2 原生连接,连接簿在设置卡片维护,支持 agent forwarding 与主机指纹 TOFU 钉扎),像本地终端一样操作远程主机。设置卡片开启「会话持久化(tmux)」后,新开的本地/SSH 标签默认由 tmux server 托管(宿主重启/断线超时后重开即恢复现场),长任务建议在持久化开启时运行。长驻进程(dev server、watch、交互式程序)应引导用户到终端面板里运行,不要在 bash 工具里挂起等待;用户提到「开个终端 / 在终端里跑 / SSH 到某台机器」时引导其打开该面板。agent 侧配套工具:tty_list 列出活跃终端会话(含 SSH 的 target 与实时 cwd),tty_capture 读取近期输出(默认清洗 ANSI;last:true 拿「上一条命令」的输出+退出码),tty_screen 读取当前可见屏幕(可读懂 vim/htop 等 TUI),tty_expect 用正则等待输出中的就绪信号(如 dev server URL、构建完成),tty_send 发送按键,tunnel_list 列出端口转发隧道状态——操作会实时显示在用户终端里。SFTP 文件传输:面板内可对 SSH 连接簿条目(或 SSH
|
|
96
|
+
const TTY_GUIDANCE = '本机已安装 dsh-tty 插件(终端面板):Web GUI 侧边栏的「终端」入口可打开交互终端(xterm.js + PTY),可运行任意命令与 TUI 程序(vim/htop 等),支持多标签页与断线自动重连(刷新页面/网络抖动后会话保活并恢复现场);新标签默认在当前会话工作目录打开。标签栏「+」菜单还能开 SSH 标签页(ssh2 原生连接,连接簿在设置卡片维护,支持 agent forwarding 与主机指纹 TOFU 钉扎),像本地终端一样操作远程主机。设置卡片开启「会话持久化(tmux)」后,新开的本地/SSH 标签默认由 tmux server 托管(宿主重启/断线超时后重开即恢复现场),长任务建议在持久化开启时运行。长驻进程(dev server、watch、交互式程序)应引导用户到终端面板里运行,不要在 bash 工具里挂起等待;用户提到「开个终端 / 在终端里跑 / SSH 到某台机器」时引导其打开该面板。agent 侧配套工具:tty_list 列出活跃终端会话(含 SSH 的 target 与实时 cwd),tty_capture 读取近期输出(默认清洗 ANSI;last:true 拿「上一条命令」的输出+退出码),tty_screen 读取当前可见屏幕(可读懂 vim/htop 等 TUI),tty_expect 用正则等待输出中的就绪信号(如 dev server URL、构建完成),tty_send 发送按键,tunnel_list 列出端口转发隧道状态——操作会实时显示在用户终端里。SFTP 文件传输:面板内可对 SSH 连接簿条目(或 SSH 连接对话框当前填写的信息)打开文件浏览(上传/下载/建目录/重命名/删除),传输期间进度条右侧 ✕ 可取消(半截文件自动清理);agent 配套 sftp_list 列远程目录、sftp_tree 递归看目录结构、sftp_read 读远程文本文件(≤1MB)、sftp_write 写远程文本文件(≤1MB,可追加)、sftp_mkdir 建目录(parents 可逐级补齐)、sftp_rename 重命名/移动、sftp_remove 删除(目录需 recursive),book 参数为连接簿条目名。端口转发:连接簿条目可配本地/远程隧道(如把远程数据库映射到本地端口),宿主自动保活重连,用户提到「转发端口 / 访问远程库」时引导其到终端面板设置卡片配置。推荐流程:tty_send 启动长任务 → tty_expect 等就绪标记 → tty_capture{last:true} 拿结果。';
|
|
89
97
|
/** 本地 PTY 包装成 TermHandle(resize/kill 仍是透传 node-pty 的内部耦合;防御性降级)。 */
|
|
90
98
|
function wrapLocalPty(handle) {
|
|
91
99
|
let resizeWarned = false;
|
|
@@ -143,6 +151,8 @@ class LiveConfig {
|
|
|
143
151
|
endOnPageClose;
|
|
144
152
|
/** SSH 持久会话名(远程 tmux 托管;本机 socket 清单看不到,随 settings 留存)。 */
|
|
145
153
|
persistSessions;
|
|
154
|
+
/** SFTP 传输限制(客户端浏览器侧执行)。 */
|
|
155
|
+
sftpLimits;
|
|
146
156
|
constructor(init) {
|
|
147
157
|
this.shell = init.shell;
|
|
148
158
|
this.term = sanitizeTermValue(init.term, 'xterm-256color');
|
|
@@ -155,6 +165,7 @@ class LiveConfig {
|
|
|
155
165
|
this.tunnels = init.tunnels ?? [];
|
|
156
166
|
this.persistence = init.persistence === 'tmux' ? 'tmux' : 'off';
|
|
157
167
|
this.endOnPageClose = init.endOnPageClose === true;
|
|
168
|
+
this.sftpLimits = sanitizeSftpLimits(init.sftpLimits);
|
|
158
169
|
this.persistSessions = init.persistSessions ?? [];
|
|
159
170
|
}
|
|
160
171
|
/** 合并部分更新;空字符串/undefined 保持原值;sshHosts/hostKeys/tunnels 传数组即整体替换。 */
|
|
@@ -182,6 +193,8 @@ class LiveConfig {
|
|
|
182
193
|
this.persistence = partial.persistence;
|
|
183
194
|
if (typeof partial.endOnPageClose === 'boolean')
|
|
184
195
|
this.endOnPageClose = partial.endOnPageClose;
|
|
196
|
+
if (partial.sftpLimits !== undefined)
|
|
197
|
+
this.sftpLimits = sanitizeSftpLimits({ ...this.sftpLimits, ...partial.sftpLimits });
|
|
185
198
|
if (Array.isArray(partial.persistSessions))
|
|
186
199
|
this.persistSessions = partial.persistSessions;
|
|
187
200
|
}
|
|
@@ -503,6 +516,18 @@ function sanitizeHostKeys(input) {
|
|
|
503
516
|
}
|
|
504
517
|
return out;
|
|
505
518
|
}
|
|
519
|
+
/** 清洗一份 sftpLimits 输入:每项取 0~上限 的整数(0 = 不限),缺省回落默认值。 */
|
|
520
|
+
function sanitizeSftpLimits(input) {
|
|
521
|
+
const num = (value, fallback, max) => {
|
|
522
|
+
const n = Number(value);
|
|
523
|
+
return Number.isInteger(n) && n >= 0 && n <= max ? n : fallback;
|
|
524
|
+
};
|
|
525
|
+
return {
|
|
526
|
+
maxDownloadMb: num(input?.maxDownloadMb, DEFAULT_SFTP_LIMITS.maxDownloadMb, 1024 * 1024),
|
|
527
|
+
maxUploadMb: num(input?.maxUploadMb, DEFAULT_SFTP_LIMITS.maxUploadMb, 1024 * 1024),
|
|
528
|
+
maxUploadFiles: num(input?.maxUploadFiles, DEFAULT_SFTP_LIMITS.maxUploadFiles, 100000),
|
|
529
|
+
};
|
|
530
|
+
}
|
|
506
531
|
/** 严格校验一份 hostKeys 输入(HTTP POST 路径);返回错误信息或清洗后的数组。 */
|
|
507
532
|
function validateHostKeys(input) {
|
|
508
533
|
if (!Array.isArray(input))
|
|
@@ -1556,6 +1581,7 @@ const plugin = definePlugin({
|
|
|
1556
1581
|
tunnels: Array.isArray(config?.tunnels) ? config.tunnels : [],
|
|
1557
1582
|
persistence: config?.persistence === 'tmux' ? 'tmux' : 'off',
|
|
1558
1583
|
endOnPageClose: config?.endOnPageClose === true,
|
|
1584
|
+
sftpLimits: sanitizeSftpLimits(config?.sftpLimits),
|
|
1559
1585
|
persistSessions: sanitizePersistSessions(config?.persistSessions) ?? [],
|
|
1560
1586
|
});
|
|
1561
1587
|
const sessions = new SessionManager(config?.maxSessions ?? DEFAULT_MAX_SESSIONS, () => live.endOnPageClose);
|
|
@@ -1611,6 +1637,7 @@ const plugin = definePlugin({
|
|
|
1611
1637
|
sftpStyle: stateRef.sftpStyle,
|
|
1612
1638
|
persistence: live.persistence,
|
|
1613
1639
|
endOnPageClose: live.endOnPageClose,
|
|
1640
|
+
sftpLimits: live.sftpLimits,
|
|
1614
1641
|
toolsRegistered: stateRef.toolsRegistered,
|
|
1615
1642
|
});
|
|
1616
1643
|
/** 规范化并应用一份配置补丁(settings/updated 事件与 HTTP POST 共用;幂等)。 */
|
|
@@ -1627,6 +1654,7 @@ const plugin = definePlugin({
|
|
|
1627
1654
|
tunnels: sanitizeTunnels(section.tunnels),
|
|
1628
1655
|
persistence: section.persistence === 'tmux' || section.persistence === 'off' ? section.persistence : undefined,
|
|
1629
1656
|
endOnPageClose: typeof section.endOnPageClose === 'boolean' ? section.endOnPageClose : undefined,
|
|
1657
|
+
sftpLimits: typeof section.sftpLimits === 'object' && section.sftpLimits !== null ? section.sftpLimits : undefined,
|
|
1630
1658
|
persistSessions: sanitizePersistSessions(section.persistSessions),
|
|
1631
1659
|
});
|
|
1632
1660
|
// 隧道按最新规格对齐(幂等;sshHosts 变更也会触发,让重连取到新凭证)
|
|
@@ -1645,7 +1673,7 @@ const plugin = definePlugin({
|
|
|
1645
1673
|
/** 校验 HTTP POST 的配置体;返回规范化补丁或错误信息。 */
|
|
1646
1674
|
const normalizePatch = (input) => {
|
|
1647
1675
|
const patch = {};
|
|
1648
|
-
const known = new Set(['enabled', 'announceToAgent', 'maxSessions', 'shell', 'term', 'colorTerm', 'cwd', 'reconnectGraceSec', 'sshHosts', 'hostKeys', 'tunnels', 'shellIntegration', 'sftpStyle', 'persistence', 'endOnPageClose']);
|
|
1676
|
+
const known = new Set(['enabled', 'announceToAgent', 'maxSessions', 'shell', 'term', 'colorTerm', 'cwd', 'reconnectGraceSec', 'sshHosts', 'hostKeys', 'tunnels', 'shellIntegration', 'sftpStyle', 'persistence', 'endOnPageClose', 'sftpLimits']);
|
|
1649
1677
|
for (const key of Object.keys(input)) {
|
|
1650
1678
|
if (!known.has(key))
|
|
1651
1679
|
return { error: '未知配置项: ' + key };
|
|
@@ -1731,6 +1759,22 @@ const plugin = definePlugin({
|
|
|
1731
1759
|
return { error: validated.error };
|
|
1732
1760
|
patch.tunnels = validated.tunnels;
|
|
1733
1761
|
}
|
|
1762
|
+
if (input.sftpLimits !== undefined) {
|
|
1763
|
+
if (typeof input.sftpLimits !== 'object' || input.sftpLimits === null || Array.isArray(input.sftpLimits))
|
|
1764
|
+
return { error: 'sftpLimits 必须是对象' };
|
|
1765
|
+
const raw = input.sftpLimits;
|
|
1766
|
+
const next = {};
|
|
1767
|
+
for (const key of ['maxDownloadMb', 'maxUploadMb', 'maxUploadFiles']) {
|
|
1768
|
+
if (raw[key] === undefined)
|
|
1769
|
+
continue;
|
|
1770
|
+
const value = Number(raw[key]);
|
|
1771
|
+
if (!Number.isInteger(value) || value < 0 || value > (key === 'maxUploadFiles' ? 100000 : 1024 * 1024)) {
|
|
1772
|
+
return { error: key + ' 必须是 0~' + (key === 'maxUploadFiles' ? '100000' : '1048576') + ' 的整数(0 = 不限)' };
|
|
1773
|
+
}
|
|
1774
|
+
next[key] = value;
|
|
1775
|
+
}
|
|
1776
|
+
patch.sftpLimits = next;
|
|
1777
|
+
}
|
|
1734
1778
|
return { patch };
|
|
1735
1779
|
};
|
|
1736
1780
|
// webServer:WS upgrade 路由 + 配置读写路由(/api/dsh-tty/config)
|
|
@@ -1847,6 +1891,72 @@ const plugin = definePlugin({
|
|
|
1847
1891
|
}
|
|
1848
1892
|
},
|
|
1849
1893
|
}));
|
|
1894
|
+
// SSH 连接测试(0.11.0,src/probe.ts):连接簿行「测试」与 SSH 对话框
|
|
1895
|
+
// 「试连」共用。body 携带完整内联 SSH 规格(不引用连接簿——卡片测试
|
|
1896
|
+
// 由客户端先行展开条目),免去服务端按 name 解析;只诊断不建会话。
|
|
1897
|
+
// 连接簿条目测试传 store(新指纹当场 TOFU record);对话框试连不带
|
|
1898
|
+
// store(只比对不落盘,避免给未保存草稿建立钉扎)。
|
|
1899
|
+
disposers.push(webServer.register({
|
|
1900
|
+
kind: 'exact',
|
|
1901
|
+
path: '/api/dsh-tty/probe',
|
|
1902
|
+
handler: async (req, res) => {
|
|
1903
|
+
if (!isLoopbackHttp(req)) {
|
|
1904
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
if (req.method !== 'POST') {
|
|
1908
|
+
writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
|
|
1909
|
+
return;
|
|
1910
|
+
}
|
|
1911
|
+
const body = await readJsonBody(req);
|
|
1912
|
+
if (body === undefined) {
|
|
1913
|
+
writeJson(res, 400, { error: 'invalid JSON body' });
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1916
|
+
const host = typeof body.host === 'string' ? body.host.trim() : '';
|
|
1917
|
+
const username = typeof body.username === 'string' ? body.username.trim() : '';
|
|
1918
|
+
if (host === '' || username === '') {
|
|
1919
|
+
writeJson(res, 200, { ok: false, error: '主机与用户名必填' });
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
let port = 22;
|
|
1923
|
+
if (body.port !== undefined && body.port !== '') {
|
|
1924
|
+
const value = Number(body.port);
|
|
1925
|
+
if (!Number.isInteger(value) || value < 1 || value > 65535) {
|
|
1926
|
+
writeJson(res, 200, { ok: false, error: '端口必须是 1~65535 的整数' });
|
|
1927
|
+
return;
|
|
1928
|
+
}
|
|
1929
|
+
port = value;
|
|
1930
|
+
}
|
|
1931
|
+
const auth = body.auth === 'key' || body.auth === 'password' ? body.auth : 'agent';
|
|
1932
|
+
const spec = { host, port, username, auth };
|
|
1933
|
+
if (auth === 'key') {
|
|
1934
|
+
const keyPath = typeof body.keyPath === 'string' ? body.keyPath.trim() : '';
|
|
1935
|
+
if (keyPath === '') {
|
|
1936
|
+
writeJson(res, 200, { ok: false, error: 'auth=key 需要私钥路径' });
|
|
1937
|
+
return;
|
|
1938
|
+
}
|
|
1939
|
+
spec.keyPath = keyPath;
|
|
1940
|
+
const passphrase = typeof body.passphrase === 'string' ? body.passphrase : '';
|
|
1941
|
+
if (passphrase !== '')
|
|
1942
|
+
spec.passphrase = passphrase;
|
|
1943
|
+
}
|
|
1944
|
+
if (auth === 'password') {
|
|
1945
|
+
const password = typeof body.password === 'string' ? body.password : '';
|
|
1946
|
+
if (password === '') {
|
|
1947
|
+
writeJson(res, 200, { ok: false, error: 'auth=password 需要密码' });
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1950
|
+
spec.password = password;
|
|
1951
|
+
}
|
|
1952
|
+
if (body.agentForward === true)
|
|
1953
|
+
spec.agentForward = true;
|
|
1954
|
+
// bookRecord=true:来自连接簿条目的完整 TOFU(store 记录新指纹);
|
|
1955
|
+
// 缺省(对话框试连)只比对不落盘
|
|
1956
|
+
const result = await probeSsh(spec, body.bookRecord === true ? hostKeyStore : undefined);
|
|
1957
|
+
writeJson(res, 200, { ok: result.auth.ok, result });
|
|
1958
|
+
},
|
|
1959
|
+
}));
|
|
1850
1960
|
// 已安装 shell 候选(设置卡片「Shell 路径」可选可输入):loopback 围栏,只回路径不执行
|
|
1851
1961
|
disposers.push(webServer.register({
|
|
1852
1962
|
kind: 'exact',
|
|
@@ -1997,17 +2107,30 @@ const plugin = definePlugin({
|
|
|
1997
2107
|
writeJson(res, 400, { error: parsed.error ?? '无效的 SSH 连接规格' });
|
|
1998
2108
|
return;
|
|
1999
2109
|
}
|
|
2110
|
+
// 取消上传(0.12.0):客户端 abort → req 'aborted' → 打断 pipeline
|
|
2111
|
+
// 并删掉远端半截文件。不清理的话远端会留一个同名残留文件,用户
|
|
2112
|
+
// 只看得到「已取消」,却有个打不开的文件占着位。
|
|
2113
|
+
const controller = new AbortController();
|
|
2114
|
+
let abortedByClient = false;
|
|
2115
|
+
req.on('aborted', () => {
|
|
2116
|
+
abortedByClient = true;
|
|
2117
|
+
controller.abort();
|
|
2118
|
+
});
|
|
2000
2119
|
try {
|
|
2001
2120
|
const { stream, done } = await sftpManager.openUpload(parsed.spec, target, meta.append === true);
|
|
2002
2121
|
let bytes = 0;
|
|
2003
2122
|
req.on('data', (chunk) => {
|
|
2004
2123
|
bytes += chunk.length;
|
|
2005
2124
|
});
|
|
2006
|
-
await pipeline(req, stream);
|
|
2125
|
+
await pipeline(req, stream, { signal: controller.signal });
|
|
2007
2126
|
await done;
|
|
2008
2127
|
writeJson(res, 200, { ok: true, bytes });
|
|
2009
2128
|
}
|
|
2010
2129
|
catch (error) {
|
|
2130
|
+
if (abortedByClient) {
|
|
2131
|
+
void sftpManager.deleteRemoteQuiet(parsed.spec, target);
|
|
2132
|
+
return;
|
|
2133
|
+
}
|
|
2011
2134
|
const message = error instanceof Error ? error.message : String(error);
|
|
2012
2135
|
if (res.headersSent)
|
|
2013
2136
|
res.destroy();
|
|
@@ -2075,6 +2198,34 @@ const plugin = definePlugin({
|
|
|
2075
2198
|
}
|
|
2076
2199
|
if (sub === '/transfer') {
|
|
2077
2200
|
// up = 本机→远程(上传),down = 远程→本机(下载);目录递归
|
|
2201
|
+
// 0.12.0:任务化(start 返回 jobId,浏览器可轮询进度 / 取消),
|
|
2202
|
+
// 代替原先「一个 HTTP 请求同步跑完、无法打断」的直传。
|
|
2203
|
+
const action = strField('action');
|
|
2204
|
+
const jobId = strField('job');
|
|
2205
|
+
if (action === 'status') {
|
|
2206
|
+
if (jobId === '')
|
|
2207
|
+
throw new Error('job 必填');
|
|
2208
|
+
const job = sftpManager.getTransfer(jobId);
|
|
2209
|
+
if (job === undefined) {
|
|
2210
|
+
writeJson(res, 404, { error: '传输任务不存在或已回收' });
|
|
2211
|
+
return;
|
|
2212
|
+
}
|
|
2213
|
+
writeJson(res, 200, { ok: true, job });
|
|
2214
|
+
return;
|
|
2215
|
+
}
|
|
2216
|
+
if (action === 'cancel') {
|
|
2217
|
+
if (jobId === '')
|
|
2218
|
+
throw new Error('job 必填');
|
|
2219
|
+
const job = sftpManager.cancelTransfer(jobId);
|
|
2220
|
+
if (job === undefined) {
|
|
2221
|
+
writeJson(res, 404, { error: '传输任务不存在或已回收' });
|
|
2222
|
+
return;
|
|
2223
|
+
}
|
|
2224
|
+
writeJson(res, 200, { ok: true, job });
|
|
2225
|
+
return;
|
|
2226
|
+
}
|
|
2227
|
+
if (action !== '' && action !== 'start')
|
|
2228
|
+
throw new Error('action 必须是 start/status/cancel');
|
|
2078
2229
|
const direction = strField('direction');
|
|
2079
2230
|
const localPath = strField('localPath');
|
|
2080
2231
|
const remotePath = strField('remotePath');
|
|
@@ -2087,11 +2238,8 @@ const plugin = definePlugin({
|
|
|
2087
2238
|
writeJson(res, 400, { error: parsed.error ?? '无效的 SSH 连接规格' });
|
|
2088
2239
|
return;
|
|
2089
2240
|
}
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
else
|
|
2093
|
-
await sftpManager.downloadToLocal(parsed.spec, remotePath, localPath);
|
|
2094
|
-
writeJson(res, 200, { ok: true });
|
|
2241
|
+
const job = sftpManager.startTransfer(parsed.spec, direction, localPath, remotePath);
|
|
2242
|
+
writeJson(res, 200, { ok: true, job });
|
|
2095
2243
|
return;
|
|
2096
2244
|
}
|
|
2097
2245
|
writeJson(res, 404, { error: 'not found: ' + sub });
|