dsh-plugin 1.3.3 → 1.3.5
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.en.md +8 -0
- package/README.md +8 -0
- package/client/client.js +337 -314
- package/client/client.js.map +1 -1
- package/lib/http/routes.js +64 -11
- package/lib/services/install/npm-check.js +2 -1
- package/lib/services/install/task-queue.js +29 -15
- package/lib/services/profile/profile.js +4 -3
- package/lib/types/services/install/install-types.d.ts +2 -2
- package/lib/types/services/install/task-queue.d.ts +3 -3
- package/package.json +1 -1
- package/src/client/components/PluginHubSection.tsx +2 -0
- package/src/client/components/views/CustomInstallView.tsx +35 -14
- package/src/client/locales.ts +8 -6
- package/src/client/logic/renderMarkdown.ts +9 -5
- package/src/client/styles/CustomInstallView.module.css +25 -0
- package/src/server/http/routes.ts +65 -11
- package/src/server/services/install/install-types.ts +2 -2
- package/src/server/services/install/npm-check.ts +2 -1
- package/src/server/services/install/task-queue.ts +36 -20
- package/src/server/services/profile/profile.ts +4 -3
package/lib/http/routes.js
CHANGED
|
@@ -20,6 +20,61 @@ import { preflightTarget } from '../services/install/preflight.js';
|
|
|
20
20
|
import { isDshPlugin, isEntryLoaded } from '../services/loader.js';
|
|
21
21
|
import { loadSettings, saveSettings, resetSettings } from '../services/settings.js';
|
|
22
22
|
import { appendLog, clearLog, readLog, logFilePath, defaultLogFilePath, customLogFile } from '../services/log.js';
|
|
23
|
+
/**
|
|
24
|
+
* 跨平台宿主重启脚本(以 `node -e` 运行,独立于宿主进程)。
|
|
25
|
+
* 取代原先的 `/bin/sh -c`:Windows 无 /bin/sh,且 lsof/nohup 仅 POSIX 存在。
|
|
26
|
+
* 由 `spawn(process.execPath, ['-e', SCRIPT, port], { detached, stdio:'ignore' })` 孵化,
|
|
27
|
+
* 宿主进程被 kill 后仍能完成「停旧 → 等端口释放 → 拉起新 dsh web」。
|
|
28
|
+
*/
|
|
29
|
+
const HOST_RESTART_SCRIPT = String.raw `const C = require('node:child_process')
|
|
30
|
+
const F = require('node:fs')
|
|
31
|
+
const P = require('node:path')
|
|
32
|
+
const O = require('node:os')
|
|
33
|
+
const win = process.platform === 'win32'
|
|
34
|
+
const port = Number(process.argv[1])
|
|
35
|
+
|
|
36
|
+
function findPids() {
|
|
37
|
+
if (win) {
|
|
38
|
+
const out = C.spawnSync('netstat', ['-ano', '-p', 'tcp'], { encoding: 'utf8' })
|
|
39
|
+
const found = []
|
|
40
|
+
for (const line of String(out.stdout || '').split(/\r?\n/)) {
|
|
41
|
+
if (!/LISTENING/i.test(line)) continue
|
|
42
|
+
const parts = line.trim().split(/\s+/)
|
|
43
|
+
const addr = parts[1] || ''
|
|
44
|
+
if (!addr.endsWith(':' + port)) continue
|
|
45
|
+
const pid = parts[parts.length - 1]
|
|
46
|
+
if (pid && /\d+/.test(pid)) found.push(pid)
|
|
47
|
+
}
|
|
48
|
+
return found
|
|
49
|
+
}
|
|
50
|
+
const out = C.spawnSync('lsof', ['-ti', 'tcp:' + port, '-sTCP:LISTEN'], { encoding: 'utf8' })
|
|
51
|
+
return String(out.stdout || '').split(/\s+/).filter(Boolean)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const pid of findPids()) {
|
|
55
|
+
try {
|
|
56
|
+
if (win) C.spawnSync('taskkill', ['/pid', pid, '/t', '/f'])
|
|
57
|
+
else process.kill(Number(pid), 'SIGTERM')
|
|
58
|
+
} catch {}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let tries = 0
|
|
62
|
+
;(function waitForStop() {
|
|
63
|
+
if (findPids().length === 0 || ++tries > 20) return startHost()
|
|
64
|
+
setTimeout(waitForStop, 250)
|
|
65
|
+
})()
|
|
66
|
+
|
|
67
|
+
function startHost() {
|
|
68
|
+
const dir = P.join(O.homedir(), '.dsh', 'logs')
|
|
69
|
+
F.mkdirSync(dir, { recursive: true })
|
|
70
|
+
const fd = F.openSync(P.join(dir, 'dsh-web-' + port + '.log'), 'a')
|
|
71
|
+
const child = C.spawn('dsh', ['web', '--port', String(port)], {
|
|
72
|
+
detached: true,
|
|
73
|
+
shell: win,
|
|
74
|
+
stdio: ['ignore', fd, fd],
|
|
75
|
+
})
|
|
76
|
+
child.unref()
|
|
77
|
+
}`;
|
|
23
78
|
const PROFILE_RE = /^[A-Za-z0-9_-]+$/;
|
|
24
79
|
const BODY_LIMIT_BYTES = 4 * 1024;
|
|
25
80
|
const COMMAND_TIMEOUT_MS = 5 * 60 * 1000;
|
|
@@ -672,7 +727,9 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
|
|
|
672
727
|
}
|
|
673
728
|
// Kick off the CLI in the background; the client polls /status for progress
|
|
674
729
|
const task = startPluginMutation({
|
|
675
|
-
|
|
730
|
+
// mode=update(已安装目标的覆盖更新)以 update 动作入队:
|
|
731
|
+
// 日志按 update 类别落盘(update.start/done/fail),实际 CLI 执行仍是 add <target>@latest
|
|
732
|
+
action: mode === 'update' ? 'update' : 'add',
|
|
676
733
|
profile,
|
|
677
734
|
target,
|
|
678
735
|
// npm 安装时 target 是包名:displayTarget 固定展示仓库名,待重启行/前端恢复不受通道变化影响
|
|
@@ -804,16 +861,12 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
|
|
|
804
861
|
const host = request.headers.host ?? '';
|
|
805
862
|
const portMatch = host.match(/:(\d+)$/);
|
|
806
863
|
const port = portMatch ? Number(portMatch[1]) : 7923;
|
|
807
|
-
// detached
|
|
808
|
-
// 即使当前宿主(即本插件所在进程)被 kill
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
'mkdir -p "$HOME/.dsh/logs"',
|
|
814
|
-
`nohup dsh web --port ${port} >>"$HOME/.dsh/logs/dsh-web-${port}.log" 2>&1 &`,
|
|
815
|
-
].join('\n');
|
|
816
|
-
spawn('/bin/sh', ['-c', script], { detached: true, stdio: 'ignore' }).unref();
|
|
864
|
+
// detached 子进程完成「停旧进程 → 等端口释放 → 后台拉起 dsh web」,
|
|
865
|
+
// 即使当前宿主(即本插件所在进程)被 kill,重启仍会继续执行。
|
|
866
|
+
// 不用 /bin/sh:Windows 无此路径,且 lsof/nohup 等仅 POSIX 存在,
|
|
867
|
+
// 故孵化 node(process.execPath) 跑跨平台脚本 —— Windows 用
|
|
868
|
+
// netstat + taskkill + shell:true 解析 dsh.cmd,POSIX 用 lsof + SIGTERM。
|
|
869
|
+
spawn(process.execPath, ['-e', HOST_RESTART_SCRIPT, String(port)], { detached: true, stdio: 'ignore' }).unref();
|
|
817
870
|
sendJson(response, 200, { ok: true, port });
|
|
818
871
|
},
|
|
819
872
|
}),
|
|
@@ -21,7 +21,8 @@ export const NPM_MIN_VERSION = [11, 6, 0];
|
|
|
21
21
|
/** 读本机 npm 版本(major.minor.patch);npm 不在 PATH 或执行失败/超时时返回 null(不妄下结论)。 */
|
|
22
22
|
export function npmVersionOf(env) {
|
|
23
23
|
try {
|
|
24
|
-
|
|
24
|
+
// Windows 下 npm 是 npm.cmd:spawn 直接 exec 会拒绝,须 shell:true 解析(与 task-queue 的 useShell 一致)
|
|
25
|
+
const res = spawnSync('npm', ['-v'], { env, encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32' });
|
|
25
26
|
if (res.error !== undefined || res.status !== 0)
|
|
26
27
|
return null;
|
|
27
28
|
const m = /(\d+)\.(\d+)\.(\d+)/.exec(res.stdout ?? '');
|
|
@@ -142,6 +142,14 @@ function stopChild(child) {
|
|
|
142
142
|
}
|
|
143
143
|
child.kill('SIGKILL');
|
|
144
144
|
}
|
|
145
|
+
/** 系统日志类别:安装/卸载/更新三分,针对性地判断「哪一步不兼容」。 */
|
|
146
|
+
function mutationCategory(action) {
|
|
147
|
+
return action === 'remove' ? 'uninstall' : action === 'update' ? 'update' : 'install';
|
|
148
|
+
}
|
|
149
|
+
/** 系统日志动作词:开始安装/开始卸载/开始更新。 */
|
|
150
|
+
function mutationVerb(action) {
|
|
151
|
+
return action === 'remove' ? '开始卸载' : action === 'update' ? '开始更新' : '开始安装';
|
|
152
|
+
}
|
|
145
153
|
/**
|
|
146
154
|
* Enqueue a plugin mutation and return its task. Tasks run strictly serially:
|
|
147
155
|
* the queue worker starts the next one only after the previous finishes.
|
|
@@ -161,14 +169,14 @@ export function startPluginMutation(options) {
|
|
|
161
169
|
});
|
|
162
170
|
}
|
|
163
171
|
queue.push({ task, options: { ...options } });
|
|
164
|
-
//
|
|
165
|
-
const category = options.action
|
|
172
|
+
// 系统日志:记录任务入队(安装/卸载/更新开始)
|
|
173
|
+
const category = mutationCategory(options.action);
|
|
166
174
|
appendLog(options.profile, {
|
|
167
175
|
at: Date.now(),
|
|
168
176
|
level: 'info',
|
|
169
177
|
category,
|
|
170
178
|
event: `${category}.start`,
|
|
171
|
-
message: `${options.action
|
|
179
|
+
message: `${mutationVerb(options.action)} ${options.displayTarget ?? options.target}`,
|
|
172
180
|
});
|
|
173
181
|
pumpQueue();
|
|
174
182
|
return task;
|
|
@@ -184,16 +192,17 @@ function pumpQueue() {
|
|
|
184
192
|
task.status = 'running';
|
|
185
193
|
void runPluginMutation({ ...options, task }).finally(() => {
|
|
186
194
|
runningChildren.delete(task.id);
|
|
187
|
-
//
|
|
195
|
+
// 系统日志:任务终态(成功/失败/取消),按动作归类安装/卸载/更新日志
|
|
188
196
|
const show = task.displayTarget ?? task.target;
|
|
189
|
-
const category = task.action
|
|
197
|
+
const category = mutationCategory(task.action);
|
|
198
|
+
const verb = (v) => task.action === 'remove' ? `卸载${v}` : task.action === 'update' ? `更新${v}` : `安装${v}`;
|
|
190
199
|
if (task.status === 'done') {
|
|
191
200
|
appendLog(options.profile, {
|
|
192
201
|
at: Date.now(),
|
|
193
202
|
level: 'success',
|
|
194
203
|
category,
|
|
195
204
|
event: `${category}.done`,
|
|
196
|
-
message: `${
|
|
205
|
+
message: `${verb('成功')} ${show}${task.needsRestart ? '(待重启生效)' : ''}`,
|
|
197
206
|
});
|
|
198
207
|
}
|
|
199
208
|
else if (task.status === 'failed') {
|
|
@@ -202,7 +211,7 @@ function pumpQueue() {
|
|
|
202
211
|
level: 'error',
|
|
203
212
|
category,
|
|
204
213
|
event: `${category}.fail`,
|
|
205
|
-
message: `${
|
|
214
|
+
message: `${verb('失败')} ${show}`,
|
|
206
215
|
});
|
|
207
216
|
}
|
|
208
217
|
else if (task.status === 'cancelled') {
|
|
@@ -236,17 +245,21 @@ function spawnMutation(options) {
|
|
|
236
245
|
// 更新已安装的 npm 包:显式指定 @latest。pnpm add <pkg>(无版本)对已存在依赖
|
|
237
246
|
// (尤其精确 spec 如 "1.3.0")是幂等的 —— 输出 "Already up to date" 且不改 package.json,
|
|
238
247
|
// 导致「更新成功」但实际仍停留在旧版本;git 地址与全局安装不受此影响。
|
|
239
|
-
|
|
248
|
+
// update 动作实际执行 `add <target>@latest`(pnpm 对已存在依赖带显式版本即原位覆盖升级),
|
|
249
|
+
// CLI 动词仍透传 add —— 不执行官方 update 动词,避免与既有覆盖重装机制分叉。
|
|
250
|
+
const isUpdate = action === 'update' || updateNpm === true;
|
|
251
|
+
const cliAction = action === 'update' ? 'add' : action;
|
|
252
|
+
const npmUpdateTarget = isUpdate && !isGlobalNpm && githubRepoOf(target) === null
|
|
240
253
|
? `${target}@latest`
|
|
241
254
|
: target;
|
|
242
255
|
const args = isGlobalNpm
|
|
243
256
|
? ['install', '-g', ...(globalNpm ?? [])]
|
|
244
|
-
: [...invocation.prefixArgs, 'plugin', '--profile', profile,
|
|
257
|
+
: [...invocation.prefixArgs, 'plugin', '--profile', profile, cliAction, npmUpdateTarget];
|
|
245
258
|
// 记录实际执行的命令(npm 通道显示包名,git 通道显示 HTTPS 源),
|
|
246
259
|
// 失败提 Issue 时作为「已尝试的安装方式」随附
|
|
247
260
|
const executed = isGlobalNpm
|
|
248
261
|
? `npm install -g ${(globalNpm ?? []).join(' ')}`
|
|
249
|
-
: `dsh plugin --profile ${profile} ${
|
|
262
|
+
: `dsh plugin --profile ${profile} ${cliAction} ${npmUpdateTarget}`;
|
|
250
263
|
if (task && !task.attempts.includes(executed)) {
|
|
251
264
|
task.attempts.push(executed);
|
|
252
265
|
}
|
|
@@ -413,10 +426,10 @@ function spawnMutation(options) {
|
|
|
413
426
|
addPendingRestart(displayTarget ?? target, 'uninstall');
|
|
414
427
|
}
|
|
415
428
|
else {
|
|
416
|
-
//
|
|
429
|
+
// 安装/更新成功(或卸载但无 loader 引用)→ 登记待重启:
|
|
417
430
|
// 插件要宿主重启才会挂载/卸载干净,重启前一直提醒;展示目标用 displayTarget(owner/repo)
|
|
418
431
|
task.needsRestart = true;
|
|
419
|
-
addPendingRestart(displayTarget ?? target, action === '
|
|
432
|
+
addPendingRestart(displayTarget ?? target, action === 'remove' ? 'uninstall' : 'install');
|
|
420
433
|
}
|
|
421
434
|
}
|
|
422
435
|
pushLine(task, result.timedOut ? '[timed out]' : `[exit ${result.exitCode ?? '?'}]`);
|
|
@@ -482,7 +495,7 @@ function ignoredBuildKeys(output) {
|
|
|
482
495
|
* 前端据此展示「本机 npm 版本过低」的准确原因 —— 避免该报错被外层 ERR_PNPM_PREPARE_PACKAGE
|
|
483
496
|
* 误判成插件打包/分发问题、误导用户提 Issue。检测逻辑在独立的 npm-check.ts(本机 npm 环境职责)。 */
|
|
484
497
|
function annotateNpmTooLow(result, options) {
|
|
485
|
-
if (options.action
|
|
498
|
+
if (options.action === 'remove' || result.exitCode === 0 || result.timedOut)
|
|
486
499
|
return result;
|
|
487
500
|
const line = npmTooLowMarker(`${result.stderr}\n${result.stdout}`, options.env);
|
|
488
501
|
if (line === null)
|
|
@@ -504,7 +517,8 @@ export async function runPluginMutation(options) {
|
|
|
504
517
|
let result = await spawnMutation(options);
|
|
505
518
|
// allowBuilds 放行 / 装后入口校验都是 profile 专属(pnpm 装进 profile 的场景);
|
|
506
519
|
// 全局 npm 安装直接走 npm,无 pnpm allowBuilds 拦截、也不进 profile,跳过这两类恢复
|
|
507
|
-
|
|
520
|
+
// 安装与更新(同为 add 通道执行)共享同一套恢复路径;卸载不适用
|
|
521
|
+
if (options.action !== 'remove' && result.exitCode !== 0 && !result.timedOut && (options.globalNpm ?? []).length === 0) {
|
|
508
522
|
const output = `${result.stderr}\n${result.stdout}`;
|
|
509
523
|
// git 源:pnpm 打印的 key@url 提示
|
|
510
524
|
if (output.includes('ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED')) {
|
|
@@ -544,7 +558,7 @@ export async function runPluginMutation(options) {
|
|
|
544
558
|
// pnpm 安装本身成功,但目标包入口文件缺失(git 分发缺构建产物)→ 判为失败:
|
|
545
559
|
// 撤销刚登记的待重启,避免用户重启时宿主加载残缺包直接崩溃。
|
|
546
560
|
// 全局 npm 安装不进 profile(profile deps 里没有这些包),跳过入口校验
|
|
547
|
-
if (options.action
|
|
561
|
+
if (options.action !== 'remove' && result.exitCode === 0 && !result.timedOut && (options.globalNpm ?? []).length === 0) {
|
|
548
562
|
const verified = verifyInstalledEntry(options.profile, options.target);
|
|
549
563
|
if (verified.name !== null && verified.missing !== null) {
|
|
550
564
|
clearPendingRestart(options.displayTarget ?? options.target);
|
|
@@ -27,16 +27,17 @@ export function githubTarget(repo) {
|
|
|
27
27
|
return `git+https://github.com/${repo}.git`;
|
|
28
28
|
}
|
|
29
29
|
/**
|
|
30
|
-
* DSH 命令行前缀(官方唯一形式 `dsh plugin --profile <name> add <target>`)—— 提取其中的安装目标;
|
|
30
|
+
* DSH 命令行前缀(官方唯一形式 `dsh plugin --profile <name> <add|update> <target>`)—— 提取其中的安装目标;
|
|
31
31
|
* 非命令输入原样返回。兼容「直接粘贴完整命令」(防用户输错):--profile 必填且只有长选项
|
|
32
32
|
* (官方 CLI 无 -p 简写,省略即报错),支持 `--profile=<name>` 等号形式,命令大小写不敏感。
|
|
33
|
+
* update = 更新已安装目标到最新;remove 不在此列(卸载走 /uninstall 路由,按已装包名直卸)。
|
|
33
34
|
* 与客户端 CustomInstallView 口径一致。
|
|
34
35
|
*/
|
|
35
|
-
const DSH_PLUGIN_CMD_RE = /^dsh\s+plugin\s+--profile(?:\s+|=)\S+\s+add\s+(.+)$/i;
|
|
36
|
+
const DSH_PLUGIN_CMD_RE = /^dsh\s+plugin\s+--profile(?:\s+|=)\S+\s+(add|update)\s+(.+)$/i;
|
|
36
37
|
export function installTargetOf(value) {
|
|
37
38
|
const input = typeof value === 'string' ? value.trim() : '';
|
|
38
39
|
const match = DSH_PLUGIN_CMD_RE.exec(input);
|
|
39
|
-
return match !== null ? match[
|
|
40
|
+
return match !== null ? match[2].trim() : input;
|
|
40
41
|
}
|
|
41
42
|
/**
|
|
42
43
|
* npm 全局安装命令(官方 README 的 `npm install -g <pkgs>`,如 dsh-tui 的安装命令)——
|
|
@@ -25,7 +25,7 @@ export interface InstallTask {
|
|
|
25
25
|
attempts: string[];
|
|
26
26
|
/** 全局 npm 安装(官方 `npm install -g <pkgs>`):非空时任务执行全局 npm 安装,不进任何 profile */
|
|
27
27
|
globalNpm?: string[];
|
|
28
|
-
action: 'add' | 'remove';
|
|
28
|
+
action: 'add' | 'remove' | 'update';
|
|
29
29
|
/** 队列语义:pending 排队中 / running 执行中 / done / failed / cancelled(用户取消) */
|
|
30
30
|
status: 'pending' | 'running' | 'done' | 'failed' | 'cancelled';
|
|
31
31
|
timedOut: boolean;
|
|
@@ -48,7 +48,7 @@ export interface Invocation {
|
|
|
48
48
|
export interface QueueItem {
|
|
49
49
|
task: InstallTask;
|
|
50
50
|
options: {
|
|
51
|
-
action: 'add' | 'remove';
|
|
51
|
+
action: 'add' | 'remove' | 'update';
|
|
52
52
|
profile: string;
|
|
53
53
|
target: string;
|
|
54
54
|
timeoutMs?: number;
|
|
@@ -5,7 +5,7 @@ export declare function getTask(id: number): InstallTask | undefined;
|
|
|
5
5
|
export interface ActiveTaskInfo {
|
|
6
6
|
id: number;
|
|
7
7
|
target: string;
|
|
8
|
-
action: 'add' | 'remove';
|
|
8
|
+
action: 'add' | 'remove' | 'update';
|
|
9
9
|
status: 'pending' | 'running';
|
|
10
10
|
progress: number;
|
|
11
11
|
lines: string[];
|
|
@@ -37,7 +37,7 @@ export declare function cancelTask(id: number): boolean;
|
|
|
37
37
|
* Progress is visible through `getTask(id)` / `activeTask()` until done.
|
|
38
38
|
*/
|
|
39
39
|
export declare function startPluginMutation(options: {
|
|
40
|
-
action: 'add' | 'remove';
|
|
40
|
+
action: 'add' | 'remove' | 'update';
|
|
41
41
|
profile: string;
|
|
42
42
|
target: string;
|
|
43
43
|
timeoutMs?: number;
|
|
@@ -73,7 +73,7 @@ export declare function verifyInstalledEntry(profile: string, target: string): {
|
|
|
73
73
|
* Other failures fall through to the single-spawn result.
|
|
74
74
|
*/
|
|
75
75
|
export declare function runPluginMutation(options: {
|
|
76
|
-
action: 'add' | 'remove';
|
|
76
|
+
action: 'add' | 'remove' | 'update';
|
|
77
77
|
profile: string;
|
|
78
78
|
target: string;
|
|
79
79
|
timeoutMs?: number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.5",
|
|
4
4
|
"description": "A community plugin marketplace for DeepSeek Harness, built to the official plugin spec — browse, search and install 4000+ human-curated community plugins without leaving the app. · DeepSeek Harness 社区插件市场(遵循官方开发规范):4000+ 人工精选社区插件,每日更新。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -427,6 +427,8 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
427
427
|
enableGit: hubSettings.enableGitInstall,
|
|
428
428
|
enableDsh: hubSettings.enableDshInstall,
|
|
429
429
|
onOpenSettings: () => setView('settings'),
|
|
430
|
+
// 当前 profile:一键插入的 Hub 更新命令用它拼 --profile
|
|
431
|
+
profile: env?.profile ?? 'web',
|
|
430
432
|
onInstallCustom: (raw, opts) => {
|
|
431
433
|
const target = raw.trim()
|
|
432
434
|
if (!target) return
|
|
@@ -26,7 +26,8 @@ import { CloseIcon, HelpIcon } from '../ui/icons.tsx'
|
|
|
26
26
|
/* —— 自定义安装输入预检 ——
|
|
27
27
|
* 提交前校验「输入是否符合该通道格式」,识别不了的输入不发请求,就地提示推荐格式。
|
|
28
28
|
* 三个通道严格分开:npm 框认裸包名或完整 npm/pnpm 安装命令(含全局 `npm install -g`),
|
|
29
|
-
* git 框只认 GitHub 地址,命令框只认官方 `dsh plugin --profile <name> add <target
|
|
29
|
+
* git 框只认 GitHub 地址,命令框只认官方 `dsh plugin --profile <name> <add|update> <target>`
|
|
30
|
+
* (--profile 必填,无 -p 简写;update = 更新已安装目标到最新)。
|
|
30
31
|
* 这里只做格式校验,真正归一化/安装由服务端完成。 */
|
|
31
32
|
|
|
32
33
|
/** GitHub 地址(带前缀形态):github:、https://github.com/、git+https://github.com/、git@github.com: */
|
|
@@ -36,10 +37,12 @@ const GITHUB_BARE_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/
|
|
|
36
37
|
/** npm 包名(含 @scope/pkg) */
|
|
37
38
|
const NPM_PACKAGE_RE = /^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/
|
|
38
39
|
|
|
39
|
-
/** DSH 命令行前缀(官方唯一形式 `dsh plugin --profile <name> add <target>`)——
|
|
40
|
+
/** DSH 命令行前缀(官方唯一形式 `dsh plugin --profile <name> <add|update> <target>`)—— 提取其中的动作与目标。
|
|
40
41
|
* 官方 CLI(apps/cli/src/args.ts,commander):--profile 为必填长选项(无 -p 简写,省略即报错),
|
|
41
|
-
* 支持 `--profile=<name>`
|
|
42
|
-
|
|
42
|
+
* 支持 `--profile=<name>` 等号形式;命令大小写不敏感。update = 已安装目标覆盖更新到最新;
|
|
43
|
+
* remove 不走这里(卸载由「已安装」列表的卸载按钮执行,命令框不认卸载动词)。
|
|
44
|
+
* 与 server 端 installTargetOf 口径一致。 */
|
|
45
|
+
const DSH_PLUGIN_CMD_RE = /^dsh\s+plugin\s+--profile(?:\s+|=)\S+\s+(add|update)\s+(.+)$/i
|
|
43
46
|
|
|
44
47
|
/** npm 全局安装命令(官方 README 格式,如 `npm install -g @deepseek-ai/dsh @deepseek-harness-tui/dsh-tui`)——
|
|
45
48
|
* 提取 -g 之后的包列表;任一包名非法返回 null(交给格式错误提示)。与 server 端 globalNpmPackagesOf 口径一致。 */
|
|
@@ -49,10 +52,12 @@ const NPM_GLOBAL_CMD_RE = /^npm\s+(?:install|i)\s+(?:-g|--global)\s+(.+)$/i
|
|
|
49
52
|
* 提取命令后的包列表;包列表在 splitPackages 里逐个校验。 */
|
|
50
53
|
const NPM_INSTALL_CMD_RE = /^(?:npm\s+(?:install|i)|pnpm\s+(?:add|i|install))\s+(.+)$/i
|
|
51
54
|
|
|
52
|
-
|
|
55
|
+
/** 解析 DSH 命令:返回 { action, target };非官方命令返回 null(交给格式错误提示)。 */
|
|
56
|
+
function parseDshCommand(raw: string): { action: 'add' | 'update'; target: string } | null {
|
|
53
57
|
const input = raw.trim()
|
|
54
58
|
const match = DSH_PLUGIN_CMD_RE.exec(input)
|
|
55
|
-
|
|
59
|
+
if (match === null) return null
|
|
60
|
+
return { action: match[1].toLowerCase() as 'add' | 'update', target: match[2].trim() }
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
/** 空格分隔的包列表:逐个按 npm 包名语法校验 + 拒绝以 - 开头的 token(npm 会把 --xxx 当参数而非包名),
|
|
@@ -96,10 +101,6 @@ function isGitHubInput(raw: string): boolean {
|
|
|
96
101
|
return GITHUB_URL_RE.test(input) || GITHUB_BARE_RE.test(input)
|
|
97
102
|
}
|
|
98
103
|
|
|
99
|
-
function isDshCommand(raw: string): boolean {
|
|
100
|
-
return DSH_PLUGIN_CMD_RE.test(raw.trim())
|
|
101
|
-
}
|
|
102
|
-
|
|
103
104
|
/** 卡片标题旁的「帮助」弹窗:三块卡片各自的标题与格式清单语言包 key。 */
|
|
104
105
|
const HELP_CARDS = {
|
|
105
106
|
npm: { titleKey: 'npmInstallLabel', contentKey: 'npmInstallHelp' },
|
|
@@ -109,7 +110,7 @@ const HELP_CARDS = {
|
|
|
109
110
|
|
|
110
111
|
type HelpTarget = keyof typeof HELP_CARDS
|
|
111
112
|
|
|
112
|
-
export function CustomInstallView({ t, onInstallCustom, enableNpm, enableGit, enableDsh, onOpenSettings }: {
|
|
113
|
+
export function CustomInstallView({ t, onInstallCustom, enableNpm, enableGit, enableDsh, onOpenSettings, profile }: {
|
|
113
114
|
t: Translate
|
|
114
115
|
/** 命令行安装:输入 npm 包名 / npm、pnpm 安装命令 / GitHub 地址 / dsh plugin 命令即装
|
|
115
116
|
* (custom 源,受安全设置开关控制);opts.globalNpm 表示官方 npm 全局安装命令(npm install -g),附带解析出的包列表 */
|
|
@@ -120,6 +121,8 @@ export function CustomInstallView({ t, onInstallCustom, enableNpm, enableGit, en
|
|
|
120
121
|
enableDsh: boolean
|
|
121
122
|
/** 引导去「设置 → 安全信任」打开被关闭的通道开关 */
|
|
122
123
|
onOpenSettings: () => void
|
|
124
|
+
/** 当前 dsh profile 名(宿主 /env 快照):一键插入的更新命令用它拼 --profile */
|
|
125
|
+
profile: string
|
|
123
126
|
}) {
|
|
124
127
|
/** 自定义安装:NPM 包输入与格式错误('' = 无错误) */
|
|
125
128
|
const [npmQuery, setNpmQuery] = useState('')
|
|
@@ -170,11 +173,19 @@ export function CustomInstallView({ t, onInstallCustom, enableNpm, enableGit, en
|
|
|
170
173
|
const submitCmd = () => {
|
|
171
174
|
const raw = cmdQuery.trim()
|
|
172
175
|
if (!raw) return
|
|
173
|
-
|
|
174
|
-
|
|
176
|
+
const parsed = parseDshCommand(raw)
|
|
177
|
+
if (parsed === null) { setCmdError(t('dshCmdInvalid')); return }
|
|
178
|
+
// add / update 统一走安装流程:目标已安装时弹窗自动转「更新」覆盖重装(服务端 mode=update 放行),
|
|
179
|
+
// 未安装时即为普通安装 —— 粘贴 `dsh plugin --profile web update dsh-plugin` 这类命令即可更新到最新
|
|
180
|
+
onInstallCustom(parsed.target)
|
|
175
181
|
setCmdQuery('')
|
|
176
182
|
setCmdError('')
|
|
177
183
|
}
|
|
184
|
+
/** 一键插入 DSH Plugin Hub 自身的更新命令:按当前 profile 拼官方命令填进输入框,用户确认后提交 */
|
|
185
|
+
const insertHubUpdate = () => {
|
|
186
|
+
setCmdQuery(`dsh plugin --profile ${profile} update dsh-plugin`)
|
|
187
|
+
setCmdError('')
|
|
188
|
+
}
|
|
178
189
|
|
|
179
190
|
return h('div', { className: styles.root },
|
|
180
191
|
// 页面说明:独立一级导航,交代「装完去哪看」
|
|
@@ -265,7 +276,8 @@ export function CustomInstallView({ t, onInstallCustom, enableNpm, enableGit, en
|
|
|
265
276
|
gitError ? h('div', { className: styles.installError }, gitError) : null,
|
|
266
277
|
],
|
|
267
278
|
),
|
|
268
|
-
// 命令卡片:粘贴官方 dsh plugin
|
|
279
|
+
// 命令卡片:粘贴官方 dsh plugin 命令即装/即更新 —— 只认完整命令
|
|
280
|
+
// (--profile 必填,可含 = 等号,动词 add/update,剥成裸目标走安装流程)
|
|
269
281
|
h('div', { className: enableDsh ? styles.installCard : `${styles.installCard} ${styles.installCardDisabled}` },
|
|
270
282
|
h('div', { className: styles.installCardHead },
|
|
271
283
|
h('span', { className: styles.installLabel }, t('dshCmdLabel')),
|
|
@@ -276,6 +288,15 @@ export function CustomInstallView({ t, onInstallCustom, enableNpm, enableGit, en
|
|
|
276
288
|
'aria-label': t('installHelp'),
|
|
277
289
|
onClick: () => setHelpFor('cmd'),
|
|
278
290
|
}, h(HelpIcon)),
|
|
291
|
+
// 一键插入 Hub 自身的更新命令:按当前 profile 拼 `dsh plugin --profile <p> update dsh-plugin`
|
|
292
|
+
// 填入输入框(命令卡片被安全开关禁用时不显示,插入后也提交不了)
|
|
293
|
+
enableDsh
|
|
294
|
+
? h('button', {
|
|
295
|
+
type: 'button',
|
|
296
|
+
className: styles.installInsertBtn,
|
|
297
|
+
onClick: insertHubUpdate,
|
|
298
|
+
}, t('dshCmdInsertHubUpdate'))
|
|
299
|
+
: null,
|
|
279
300
|
),
|
|
280
301
|
!enableDsh
|
|
281
302
|
? channelOffRow
|
package/src/client/locales.ts
CHANGED
|
@@ -56,13 +56,14 @@ export const zh = {
|
|
|
56
56
|
gitInstallInvalid: '请输入 GitHub 仓库地址,例如:owner/repo 或 https://github.com/owner/repo',
|
|
57
57
|
dshCmdLabel: 'DeepSeek Harness 命令',
|
|
58
58
|
dshCmdPlaceholder: '粘贴官方 dsh plugin 命令',
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
dshCmdInsertHubUpdate: '插入更新命令',
|
|
60
|
+
dshCmdExample: '例如:dsh plugin --profile web add github:owner/repo,或 dsh plugin --profile web update dsh-plugin',
|
|
61
|
+
dshCmdInvalid: '请输入官方 dsh plugin 命令(--profile 必填,支持 add / update)。例如:dsh plugin --profile web update dsh-plugin',
|
|
61
62
|
installHelp: '帮助',
|
|
62
63
|
helpModalTitle: '支持的命令格式',
|
|
63
64
|
npmInstallHelp: 'lodash、@scope/pkg\t直接输入包名\nnpm install lodash\nnpm i lodash\npnpm add @scope/pkg\npnpm i @scope/pkg\nnpm install -g @scope/cli\t全局安装 CLI 工具,不进「已安装」列表',
|
|
64
65
|
gitInstallHelp: 'owner/repo\nhttps://github.com/owner/repo\ngithub:owner/repo\ngit+https://github.com/owner/repo.git\ngit@github.com:owner/repo.git',
|
|
65
|
-
dshCmdHelp: 'dsh plugin --profile web add <目标>\ndsh plugin --profile=web add <目标>\n\t--profile 必填,官方 CLI 无 -p 简写;<目标> 可为 npm 包名或 github:owner/repo',
|
|
66
|
+
dshCmdHelp: 'dsh plugin --profile web add <目标>\t安装\ndsh plugin --profile web update <目标>\t更新已安装目标到最新版本\ndsh plugin --profile=web add <目标>\ndsh plugin --profile=web update <目标>\n\t--profile 必填,官方 CLI 无 -p 简写;<目标> 可为 npm 包名或 github:owner/repo;卸载请用「已安装」列表',
|
|
66
67
|
globalNpmHint: '将全局安装 CLI 工具(npm install -g),不会出现在「已安装」列表,无需重启宿主',
|
|
67
68
|
installCliBtn: '安装',
|
|
68
69
|
// 安装通道关闭(安全信任开关关掉后,对应卡片禁用并引导去设置打开)
|
|
@@ -381,13 +382,14 @@ export const en = {
|
|
|
381
382
|
gitInstallInvalid: 'Enter a GitHub repository address, e.g. owner/repo or https://github.com/owner/repo',
|
|
382
383
|
dshCmdLabel: 'DeepSeek Harness command',
|
|
383
384
|
dshCmdPlaceholder: 'Paste an official dsh plugin command',
|
|
384
|
-
|
|
385
|
-
|
|
385
|
+
dshCmdInsertHubUpdate: 'Insert update command',
|
|
386
|
+
dshCmdExample: 'e.g. dsh plugin --profile web add github:owner/repo, or dsh plugin --profile web update dsh-plugin',
|
|
387
|
+
dshCmdInvalid: 'Paste an official dsh plugin command (--profile required, add / update), e.g. dsh plugin --profile web update dsh-plugin',
|
|
386
388
|
installHelp: 'Help',
|
|
387
389
|
helpModalTitle: 'Supported command formats',
|
|
388
390
|
npmInstallHelp: 'lodash or @scope/pkg\tjust type the package name\nnpm install lodash\nnpm i lodash\npnpm add @scope/pkg\npnpm i @scope/pkg\nnpm install -g @scope/cli\tinstalls the CLI globally, not shown in Installed',
|
|
389
391
|
gitInstallHelp: 'owner/repo\nhttps://github.com/owner/repo\ngithub:owner/repo\ngit+https://github.com/owner/repo.git\ngit@github.com:owner/repo.git',
|
|
390
|
-
dshCmdHelp: 'dsh plugin --profile web add <target>\ndsh plugin --profile=web add <target>\n\t--profile is required and there is no -p shorthand; <target> can be an npm package or github:owner/repo',
|
|
392
|
+
dshCmdHelp: 'dsh plugin --profile web add <target>\tinstall\ndsh plugin --profile web update <target>\tupdate an installed target to the latest version\ndsh plugin --profile=web add <target>\ndsh plugin --profile=web update <target>\n\t--profile is required and there is no -p shorthand; <target> can be an npm package or github:owner/repo; uninstall from the Installed list',
|
|
391
393
|
globalNpmHint: 'This installs CLI tools globally (npm install -g). They won\'t appear in Installed and no host restart is needed.',
|
|
392
394
|
installCliBtn: 'Install',
|
|
393
395
|
// Install channel disabled (security-trust toggle off → card disabled, guides to settings)
|
|
@@ -33,11 +33,15 @@ function inline(s: string): string {
|
|
|
33
33
|
return `\u0000${codeSpans.length - 1}\u0000`
|
|
34
34
|
})
|
|
35
35
|
// 图片 :仅放行 http(s),用于展示反馈群二维码等;limited by CSS
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
// 文件名为 dsh-plugin-user-group-qr- 前缀的视为用户反馈群二维码,固定 250×250 不撑破弹窗。
|
|
37
|
+
out = out.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (m, alt, url) => {
|
|
38
|
+
const src = String(url)
|
|
39
|
+
if (!/^https?:\/\//i.test(src) || /[\u0000]/.test(src)) return m
|
|
40
|
+
if (/\/dsh-plugin-user-group-qr-[^/]*$/i.test(src)) {
|
|
41
|
+
return `<img src="${src}" alt="${String(alt)}" width="250" height="250" loading="lazy">`
|
|
42
|
+
}
|
|
43
|
+
return `<img src="${src}" alt="${String(alt)}" loading="lazy">`
|
|
44
|
+
})
|
|
41
45
|
// 链接 [text](url):仅放行 http(s),新窗口打开
|
|
42
46
|
out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, text, url) =>
|
|
43
47
|
/^https?:\/\//i.test(String(url)) && !/[\u0000]/.test(String(url))
|
|
@@ -84,6 +84,31 @@
|
|
|
84
84
|
border-color: var(--hub-brand-border-soft);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/* 命令卡片标题旁的「一键插入更新命令」:右对齐小号描边按钮,弱化但可点,hover 提亮品牌色 */
|
|
88
|
+
.installInsertBtn {
|
|
89
|
+
flex-shrink: 0;
|
|
90
|
+
margin-left: auto;
|
|
91
|
+
font-family: inherit;
|
|
92
|
+
font-size: 11px;
|
|
93
|
+
line-height: 16px;
|
|
94
|
+
font-weight: 500;
|
|
95
|
+
color: var(--hub-text-secondary);
|
|
96
|
+
background: transparent;
|
|
97
|
+
border: 1px solid var(--hub-border-2);
|
|
98
|
+
border-radius: 6px;
|
|
99
|
+
padding: 2px 9px;
|
|
100
|
+
cursor: pointer;
|
|
101
|
+
white-space: nowrap;
|
|
102
|
+
user-select: none;
|
|
103
|
+
transition: color 120ms ease, border-color 120ms ease, background 120ms ease;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.installInsertBtn:hover {
|
|
107
|
+
color: var(--hub-brand);
|
|
108
|
+
border-color: var(--hub-brand-border-soft);
|
|
109
|
+
background: var(--hub-brand-tint);
|
|
110
|
+
}
|
|
111
|
+
|
|
87
112
|
/* 文本框上方的示例:灰字小字,告诉用户该通道的推荐写法 */
|
|
88
113
|
.installExample {
|
|
89
114
|
font-size: 11px;
|
|
@@ -22,6 +22,62 @@ import { isDshPlugin, isEntryLoaded } from '../services/loader.ts'
|
|
|
22
22
|
import { loadSettings, saveSettings, resetSettings, type HubSettings } from '../services/settings.ts'
|
|
23
23
|
import { appendLog, clearLog, readLog, logFilePath, defaultLogFilePath, customLogFile } from '../services/log.ts'
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* 跨平台宿主重启脚本(以 `node -e` 运行,独立于宿主进程)。
|
|
27
|
+
* 取代原先的 `/bin/sh -c`:Windows 无 /bin/sh,且 lsof/nohup 仅 POSIX 存在。
|
|
28
|
+
* 由 `spawn(process.execPath, ['-e', SCRIPT, port], { detached, stdio:'ignore' })` 孵化,
|
|
29
|
+
* 宿主进程被 kill 后仍能完成「停旧 → 等端口释放 → 拉起新 dsh web」。
|
|
30
|
+
*/
|
|
31
|
+
const HOST_RESTART_SCRIPT = String.raw`const C = require('node:child_process')
|
|
32
|
+
const F = require('node:fs')
|
|
33
|
+
const P = require('node:path')
|
|
34
|
+
const O = require('node:os')
|
|
35
|
+
const win = process.platform === 'win32'
|
|
36
|
+
const port = Number(process.argv[1])
|
|
37
|
+
|
|
38
|
+
function findPids() {
|
|
39
|
+
if (win) {
|
|
40
|
+
const out = C.spawnSync('netstat', ['-ano', '-p', 'tcp'], { encoding: 'utf8' })
|
|
41
|
+
const found = []
|
|
42
|
+
for (const line of String(out.stdout || '').split(/\r?\n/)) {
|
|
43
|
+
if (!/LISTENING/i.test(line)) continue
|
|
44
|
+
const parts = line.trim().split(/\s+/)
|
|
45
|
+
const addr = parts[1] || ''
|
|
46
|
+
if (!addr.endsWith(':' + port)) continue
|
|
47
|
+
const pid = parts[parts.length - 1]
|
|
48
|
+
if (pid && /\d+/.test(pid)) found.push(pid)
|
|
49
|
+
}
|
|
50
|
+
return found
|
|
51
|
+
}
|
|
52
|
+
const out = C.spawnSync('lsof', ['-ti', 'tcp:' + port, '-sTCP:LISTEN'], { encoding: 'utf8' })
|
|
53
|
+
return String(out.stdout || '').split(/\s+/).filter(Boolean)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const pid of findPids()) {
|
|
57
|
+
try {
|
|
58
|
+
if (win) C.spawnSync('taskkill', ['/pid', pid, '/t', '/f'])
|
|
59
|
+
else process.kill(Number(pid), 'SIGTERM')
|
|
60
|
+
} catch {}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let tries = 0
|
|
64
|
+
;(function waitForStop() {
|
|
65
|
+
if (findPids().length === 0 || ++tries > 20) return startHost()
|
|
66
|
+
setTimeout(waitForStop, 250)
|
|
67
|
+
})()
|
|
68
|
+
|
|
69
|
+
function startHost() {
|
|
70
|
+
const dir = P.join(O.homedir(), '.dsh', 'logs')
|
|
71
|
+
F.mkdirSync(dir, { recursive: true })
|
|
72
|
+
const fd = F.openSync(P.join(dir, 'dsh-web-' + port + '.log'), 'a')
|
|
73
|
+
const child = C.spawn('dsh', ['web', '--port', String(port)], {
|
|
74
|
+
detached: true,
|
|
75
|
+
shell: win,
|
|
76
|
+
stdio: ['ignore', fd, fd],
|
|
77
|
+
})
|
|
78
|
+
child.unref()
|
|
79
|
+
}`
|
|
80
|
+
|
|
25
81
|
export interface WebRoute {
|
|
26
82
|
kind: 'exact'
|
|
27
83
|
path: string
|
|
@@ -669,7 +725,9 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
|
|
|
669
725
|
}
|
|
670
726
|
// Kick off the CLI in the background; the client polls /status for progress
|
|
671
727
|
const task = startPluginMutation({
|
|
672
|
-
|
|
728
|
+
// mode=update(已安装目标的覆盖更新)以 update 动作入队:
|
|
729
|
+
// 日志按 update 类别落盘(update.start/done/fail),实际 CLI 执行仍是 add <target>@latest
|
|
730
|
+
action: mode === 'update' ? 'update' : 'add',
|
|
673
731
|
profile,
|
|
674
732
|
target,
|
|
675
733
|
// npm 安装时 target 是包名:displayTarget 固定展示仓库名,待重启行/前端恢复不受通道变化影响
|
|
@@ -792,16 +850,12 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
|
|
|
792
850
|
const host = request.headers.host ?? ''
|
|
793
851
|
const portMatch = host.match(/:(\d+)$/)
|
|
794
852
|
const port = portMatch ? Number(portMatch[1]) : 7923
|
|
795
|
-
// detached
|
|
796
|
-
// 即使当前宿主(即本插件所在进程)被 kill
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
'mkdir -p "$HOME/.dsh/logs"',
|
|
802
|
-
`nohup dsh web --port ${port} >>"$HOME/.dsh/logs/dsh-web-${port}.log" 2>&1 &`,
|
|
803
|
-
].join('\n')
|
|
804
|
-
spawn('/bin/sh', ['-c', script], { detached: true, stdio: 'ignore' }).unref()
|
|
853
|
+
// detached 子进程完成「停旧进程 → 等端口释放 → 后台拉起 dsh web」,
|
|
854
|
+
// 即使当前宿主(即本插件所在进程)被 kill,重启仍会继续执行。
|
|
855
|
+
// 不用 /bin/sh:Windows 无此路径,且 lsof/nohup 等仅 POSIX 存在,
|
|
856
|
+
// 故孵化 node(process.execPath) 跑跨平台脚本 —— Windows 用
|
|
857
|
+
// netstat + taskkill + shell:true 解析 dsh.cmd,POSIX 用 lsof + SIGTERM。
|
|
858
|
+
spawn(process.execPath, ['-e', HOST_RESTART_SCRIPT, String(port)], { detached: true, stdio: 'ignore' }).unref()
|
|
805
859
|
sendJson(response, 200, { ok: true, port })
|
|
806
860
|
},
|
|
807
861
|
}),
|