dsh-winrm 0.3.1 → 0.4.1

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/src/tools.ts CHANGED
@@ -6,7 +6,8 @@
6
6
 
7
7
  import { defineTool } from '@deepseek-ai/dsh-tools'
8
8
  import type { ContentBlock } from '@deepseek-ai/dsh-llm'
9
- import type { WinRmEngine } from './engine.ts'
9
+ import { boundedHunkDiffs, type RemoteFileDiff } from './diff.ts'
10
+ import { READ_DEFAULT_LIMIT, READ_MAX_LIMIT, type WinRmEngine } from './engine.ts'
10
11
  import type { ClusterResult, ExecResult, WinHostSummary } from './protocol.ts'
11
12
 
12
13
  /** One text content block (the only render shape these tools emit). */
@@ -272,7 +273,7 @@ export function winrmUploadTool(engine: WinRmEngine) {
272
273
  alias: { type: 'string', required: true, description: 'Host alias from winrm_list.' },
273
274
  localPath: { type: 'string', required: true, description: 'Absolute local file path on this machine.' },
274
275
  remotePath: { type: 'string', required: true, description: 'Destination path on the remote host, e.g. C:\\temp\\file.txt (parent dirs are created).' },
275
- channel: { type: 'string', enum: ['auto', 'smb', 'winrm'], description: 'Transfer channel: smb (admin share), winrm (streamed over the WinRS stdin channel), or auto (default, SMB first with WinRM fallback).' },
276
+ channel: { type: 'string', enum: ['auto', 'smb', 'winrm'], description: 'Transfer channel: auto (default) uses the SMB admin share when TCP 445 answers and otherwise WinRM; smb fails outright when 445 is closed; winrm always streams over the WinRS stdin channel.' },
276
277
  },
277
278
  output: {
278
279
  schema: {
@@ -305,13 +306,13 @@ export function winrmUploadTool(engine: WinRmEngine) {
305
306
  export function winrmDownloadTool(engine: WinRmEngine) {
306
307
  return defineTool({
307
308
  name: 'winrm_download',
308
- description: 'Download a remote FILE from a configured Windows host to a local path on this machine. The bytes come over the SMB admin share when it is reachable, otherwise they are streamed over WinRM as parallel byte ranges (the 48 KiB base64 chunk path is kept only as a fallback). Directory download is not supported — download files individually. ' +
309
+ description: 'Download a remote FILE from a configured Windows host to a local path on this machine. The bytes come over the SMB admin share when it is reachable, otherwise they are streamed over WinRM as parallel byte ranges (the 48 KiB base64 chunk path is kept only as a fallback). Large files are fine: the WinRM path runs for as long as bytes keep arriving and only fails once a range stalls. Directory download is not supported — download files individually. ' +
309
310
  'Triggers: download file from Windows server, fetch remote log/artifact.',
310
311
  parameters: {
311
312
  alias: { type: 'string', required: true, description: 'Host alias from winrm_list.' },
312
313
  remotePath: { type: 'string', required: true, description: 'Remote file path, e.g. C:\\temp\\file.txt.' },
313
314
  localPath: { type: 'string', required: true, description: 'Absolute destination path on this machine.' },
314
- channel: { type: 'string', enum: ['auto', 'smb', 'winrm'], description: 'Transfer channel: smb (admin share), winrm (streamed over parallel WinRS ranges), or auto (default, SMB first with WinRM fallback).' },
315
+ channel: { type: 'string', enum: ['auto', 'smb', 'winrm'], description: 'Transfer channel: auto (default) uses the SMB admin share when TCP 445 answers and otherwise WinRM; smb fails outright when 445 is closed; winrm always streams over parallel WinRS byte ranges.' },
315
316
  },
316
317
  output: {
317
318
  schema: {
@@ -386,3 +387,301 @@ export function winrmClusterTool(engine: WinRmEngine) {
386
387
  },
387
388
  })
388
389
  }
390
+
391
+ /** Render a read window in the numbered envelope the local read tool uses. */
392
+ function renderWindow(window: {
393
+ path?: string
394
+ offset?: number
395
+ lines?: Array<{ number: number; text: string }>
396
+ totalLines?: number
397
+ encoding?: string
398
+ truncatedByBytes?: boolean
399
+ }): string {
400
+ const lines = window.lines ?? []
401
+ const offset = window.offset ?? 1
402
+ const total = window.totalLines ?? 0
403
+ const last = offset + lines.length - 1
404
+ const body = lines.map(line => `${line.number}: ${line.text}`).join('\n')
405
+ // Only a non-UTF-8 file needs saying: that is when a later write would
406
+ // otherwise replace the encoding without the caller noticing.
407
+ const encoding = window.encoding !== undefined && window.encoding !== 'utf8'
408
+ ? `, encoding ${window.encoding}`
409
+ : ''
410
+ let footer: string
411
+ if (lines.length === 0) {
412
+ footer = total === 0
413
+ ? `(End of file - total 0 lines${encoding})`
414
+ : `(No lines at offset ${offset}; the file has ${total} lines${encoding}.)`
415
+ } else if (window.truncatedByBytes === true) {
416
+ footer = `(Showing lines ${offset}-${last} of ${total}${encoding}. The window stopped at the byte budget — continue with offset=${last + 1}.)`
417
+ } else if (last < total) {
418
+ footer = `(Showing lines ${offset}-${last} of ${total}${encoding}. Continue with offset=${last + 1}.)`
419
+ } else {
420
+ footer = `(End of file - total ${total} lines${encoding})`
421
+ }
422
+ const content = body === '' ? footer : `${body}\n\n${footer}`
423
+ return [`<path>${window.path ?? ''}</path>`, '<type>file</type>', '<content>', content, '</content>'].join('\n')
424
+ }
425
+
426
+ /** The remote read tool: a line-numbered window of a text file on the target. */
427
+ export function winrmReadTool(engine: WinRmEngine) {
428
+ return defineTool({
429
+ name: 'winrm_read',
430
+ description: 'Read a text file ON A REMOTE Windows host and return its content line-numbered. The path is on the Windows host, not this machine — use `read` for local files. Decodes UTF-8 (with or without BOM) and UTF-16 with a BOM, and refuses binary files instead of returning mojibake. Page through large files with offset and limit. ' +
431
+ 'Triggers: read file on Windows server, inspect remote config/log/script.',
432
+ parameters: {
433
+ alias: { type: 'string', required: true, description: 'Host alias from winrm_list.' },
434
+ remotePath: { type: 'string', required: true, description: 'Path on the remote host, e.g. C:\\temp\\app.log.' },
435
+ offset: { type: 'integer', description: '1-based first line to return. Defaults to 1.' },
436
+ limit: { type: 'integer', description: `Maximum lines to return (default ${READ_DEFAULT_LIMIT}, max ${READ_MAX_LIMIT}).` },
437
+ },
438
+ output: {
439
+ schema: {
440
+ type: 'object',
441
+ additionalProperties: false,
442
+ properties: {
443
+ ok: { type: 'boolean', required: true },
444
+ path: { type: 'string' },
445
+ offset: { type: 'integer' },
446
+ lines: {
447
+ type: 'array',
448
+ items: {
449
+ type: 'object',
450
+ additionalProperties: false,
451
+ properties: {
452
+ number: { type: 'integer', required: true },
453
+ text: { type: 'string', required: true },
454
+ },
455
+ },
456
+ },
457
+ totalLines: { type: 'integer' },
458
+ encoding: { type: 'string' },
459
+ truncatedByBytes: { type: 'boolean' },
460
+ truncatedLines: { type: 'integer' },
461
+ error: { type: 'string' },
462
+ },
463
+ },
464
+ render: (_args, value: { ok: boolean; path?: string; offset?: number; lines?: Array<{ number: number; text: string }>; totalLines?: number; encoding?: string; truncatedByBytes?: boolean; error?: string }) => text(value.ok
465
+ ? renderWindow(value)
466
+ : `read failed: ${value.error ?? 'unknown error'}`),
467
+ // The GUI draws a read card from this; the model-facing text above stays
468
+ // the fallback for a client without the card.
469
+ presentationMeta: (args, value: { ok: boolean; path?: string; offset?: number; lines?: Array<{ number: number; text: string }>; totalLines?: number; encoding?: string; truncatedByBytes?: boolean; truncatedLines?: number; error?: string }) => (value.ok
470
+ ? {
471
+ card: 'winrm-read',
472
+ path: value.path ?? args.remotePath,
473
+ offset: value.offset ?? 1,
474
+ lines: value.lines ?? [],
475
+ totalLines: value.totalLines ?? 0,
476
+ encoding: value.encoding ?? 'utf8',
477
+ truncatedByBytes: value.truncatedByBytes === true,
478
+ truncatedLines: value.truncatedLines ?? 0,
479
+ // Every arm carries the same keys: the metadata is typed against a
480
+ // JSON index signature, where an arm-specific optional key would
481
+ // widen to `undefined` and stop satisfying it.
482
+ error: '',
483
+ }
484
+ : {
485
+ card: 'winrm-read',
486
+ path: args.remotePath,
487
+ offset: 0,
488
+ lines: [],
489
+ totalLines: 0,
490
+ encoding: '',
491
+ truncatedByBytes: false,
492
+ truncatedLines: 0,
493
+ error: value.error ?? 'unknown error',
494
+ }),
495
+ },
496
+ async execute(args) {
497
+ const offset = Math.max(1, Math.trunc(args.offset ?? 1))
498
+ const limit = Math.min(READ_MAX_LIMIT, Math.max(1, Math.trunc(args.limit ?? READ_DEFAULT_LIMIT)))
499
+ try {
500
+ const window = await engine.readFile(args.alias, args.remotePath, offset, limit)
501
+ return { ok: true, ...window }
502
+ } catch (error) {
503
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
504
+ }
505
+ },
506
+ })
507
+ }
508
+
509
+ /** The remote write tool: replace a whole text file on the target. */
510
+ export function winrmWriteTool(engine: WinRmEngine) {
511
+ return defineTool({
512
+ name: 'winrm_write',
513
+ description: 'Write a text file ON A REMOTE Windows host, creating parent directories and overwriting any existing file. The path is on the Windows host, not this machine — use `write` for local files, and prefer winrm_edit when only part of an existing file changes. Line endings are sent verbatim, so include \\r\\n for a Windows-native file. The encoding is whatever `encoding` asks for — rewriting an existing utf16le or utf8bom file without setting it converts the file to plain UTF-8; `winrm_read` names a non-UTF-8 file in its footer when you need to match one. ' +
514
+ 'Triggers: write file on Windows server, create remote script/config, overwrite remote file.',
515
+ parameters: {
516
+ alias: { type: 'string', required: true, description: 'Host alias from winrm_list.' },
517
+ remotePath: { type: 'string', required: true, description: 'Destination path on the remote host, e.g. C:\\temp\\script.ps1 (parent dirs are created).' },
518
+ content: { type: 'string', required: true, description: 'Full file content.' },
519
+ encoding: { type: 'string', enum: ['utf8', 'utf8bom', 'utf16le'], description: 'Byte-order mark to emit: utf8 (default, no BOM), utf8bom, or utf16le (PowerShell 5.1 reads a BOM-less script as ANSI, so non-ASCII script text needs a BOM). Does not preserve the existing file\'s encoding — pass it explicitly when overwriting a UTF-16 or BOM-carrying file.' },
520
+ },
521
+ output: {
522
+ schema: {
523
+ type: 'object',
524
+ additionalProperties: false,
525
+ properties: {
526
+ ok: { type: 'boolean', required: true },
527
+ path: { type: 'string' },
528
+ operation: { type: 'string', enum: ['create', 'update'] },
529
+ bytes: { type: 'integer' },
530
+ encoding: { type: 'string', enum: ['utf8', 'utf8bom', 'utf16le'] },
531
+ diffs: {
532
+ type: 'array',
533
+ items: {
534
+ type: 'object',
535
+ additionalProperties: false,
536
+ properties: {
537
+ path: { type: 'string', required: true },
538
+ oldText: { type: 'string', required: true },
539
+ newText: { type: 'string', required: true },
540
+ },
541
+ },
542
+ },
543
+ beyondBasis: { type: 'boolean' },
544
+ error: { type: 'string' },
545
+ },
546
+ },
547
+ render: (_args, value: { ok: boolean; path?: string; operation?: string; bytes?: number; error?: string }) => text(value.ok
548
+ ? [`<path>${value.path ?? ''}</path>`, '<type>file</type>', '<content>', `${value.operation === 'update' ? 'Updated' : 'Created'} file (${value.bytes ?? 0} bytes)`, '</content>'].join('\n')
549
+ : `write failed: ${value.error ?? 'unknown error'}`),
550
+ presentationMeta: (args, value: { ok: boolean; path?: string; operation?: 'create' | 'update'; bytes?: number; encoding?: string; diffs?: RemoteFileDiff[]; beyondBasis?: boolean; error?: string }) => (value.ok
551
+ ? {
552
+ card: 'winrm-write',
553
+ path: value.path ?? args.remotePath,
554
+ operation: value.operation ?? 'update',
555
+ bytes: value.bytes ?? 0,
556
+ encoding: value.encoding ?? 'utf8',
557
+ diffs: value.diffs ?? [],
558
+ beyondBasis: value.beyondBasis === true,
559
+ error: '',
560
+ }
561
+ : {
562
+ card: 'winrm-write',
563
+ path: args.remotePath,
564
+ operation: 'update' as const,
565
+ bytes: 0,
566
+ encoding: '',
567
+ diffs: [],
568
+ beyondBasis: false,
569
+ error: value.error ?? 'unknown error',
570
+ }),
571
+ },
572
+ async execute(args) {
573
+ try {
574
+ const outcome = await engine.writeFile(args.alias, args.remotePath, args.content, args.encoding ?? 'utf8')
575
+ // A create compares against nothing, so its whole content is one
576
+ // insertion hunk; an update whose prior text was past the basis cap
577
+ // reports the path without hunks rather than a misleading full rewrite.
578
+ const diffs = outcome.before !== null
579
+ ? boundedHunkDiffs(outcome.path, outcome.before, outcome.after)
580
+ : outcome.operation === 'create'
581
+ ? boundedHunkDiffs(outcome.path, '', outcome.after)
582
+ : []
583
+ return {
584
+ ok: true,
585
+ path: outcome.path,
586
+ operation: outcome.operation,
587
+ bytes: outcome.bytes,
588
+ encoding: outcome.encoding,
589
+ diffs,
590
+ beyondBasis: outcome.operation === 'update' && outcome.before === null,
591
+ }
592
+ } catch (error) {
593
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
594
+ }
595
+ },
596
+ })
597
+ }
598
+
599
+ /** The remote edit tool: replace an exact string inside a file on the target. */
600
+ export function winrmEditTool(engine: WinRmEngine) {
601
+ return defineTool({
602
+ name: 'winrm_edit',
603
+ description: 'Replace an exact string in a text file ON A REMOTE Windows host, preserving the file encoding. The path is on the Windows host, not this machine — use `edit` for local files. `oldString` must match the file byte for byte, including indentation and line endings, and must be unique unless `replaceAll` is set; read the file with winrm_read first. ' +
604
+ 'Triggers: edit file on Windows server, patch remote config, change remote script.',
605
+ parameters: {
606
+ alias: { type: 'string', required: true, description: 'Host alias from winrm_list.' },
607
+ remotePath: { type: 'string', required: true, description: 'Path on the remote host, e.g. C:\\temp\\app.config.' },
608
+ oldString: { type: 'string', required: true, description: 'Exact text to replace. Must not be empty.' },
609
+ newString: { type: 'string', required: true, description: 'Replacement text. Must differ from oldString.' },
610
+ replaceAll: { type: 'boolean', description: 'Replace every occurrence instead of requiring exactly one. Defaults to false.' },
611
+ },
612
+ output: {
613
+ schema: {
614
+ type: 'object',
615
+ additionalProperties: false,
616
+ properties: {
617
+ ok: { type: 'boolean', required: true },
618
+ path: { type: 'string' },
619
+ encoding: { type: 'string' },
620
+ replacements: { type: 'integer' },
621
+ size: { type: 'integer' },
622
+ diffs: {
623
+ type: 'array',
624
+ items: {
625
+ type: 'object',
626
+ additionalProperties: false,
627
+ properties: {
628
+ path: { type: 'string', required: true },
629
+ oldText: { type: 'string', required: true },
630
+ newText: { type: 'string', required: true },
631
+ },
632
+ },
633
+ },
634
+ beyondBasis: { type: 'boolean' },
635
+ error: { type: 'string' },
636
+ },
637
+ },
638
+ render: (_args, value: { ok: boolean; path?: string; replacements?: number; size?: number; error?: string }) => text(value.ok
639
+ ? ((value.replacements ?? 0) > 1
640
+ ? `The file ${value.path ?? ''} has been updated. All ${value.replacements ?? 0} occurrences were successfully replaced.`
641
+ : `The file ${value.path ?? ''} has been updated successfully.`)
642
+ : `edit failed: ${value.error ?? 'unknown error'}`),
643
+ presentationMeta: (args, value: { ok: boolean; path?: string; replacements?: number; size?: number; encoding?: string; diffs?: RemoteFileDiff[]; beyondBasis?: boolean; error?: string }) => (value.ok
644
+ ? {
645
+ card: 'winrm-edit',
646
+ path: value.path ?? args.remotePath,
647
+ replacements: value.replacements ?? 0,
648
+ size: value.size ?? 0,
649
+ encoding: value.encoding ?? 'utf8',
650
+ diffs: value.diffs ?? [],
651
+ beyondBasis: value.beyondBasis === true,
652
+ error: '',
653
+ }
654
+ : {
655
+ card: 'winrm-edit',
656
+ path: args.remotePath,
657
+ replacements: 0,
658
+ size: 0,
659
+ encoding: '',
660
+ diffs: [],
661
+ beyondBasis: false,
662
+ error: value.error ?? 'unknown error',
663
+ }),
664
+ },
665
+ async execute(args) {
666
+ try {
667
+ const outcome = await engine.editFile(args.alias, args.remotePath, args.oldString, args.newString, args.replaceAll ?? false)
668
+ // The edit fragment returns both texts only when the file was inside the
669
+ // basis cap; otherwise the card reports the path and the count alone.
670
+ const diffs = outcome.before !== null && outcome.after !== null
671
+ ? boundedHunkDiffs(outcome.path, outcome.before, outcome.after)
672
+ : []
673
+ return {
674
+ ok: true,
675
+ path: outcome.path,
676
+ encoding: outcome.encoding,
677
+ replacements: outcome.replacements,
678
+ size: outcome.size,
679
+ diffs,
680
+ beyondBasis: outcome.before === null,
681
+ }
682
+ } catch (error) {
683
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
684
+ }
685
+ },
686
+ })
687
+ }
package/README.zh.md DELETED
@@ -1,126 +0,0 @@
1
- # dsh-winrm — Windows 远程管理插件(WinRM / PowerShell Remoting)
2
-
3
- 仿照 [dsh-ssh](https://github.com/zhu1090093659/dsh-web-ui/tree/main/packages/dsh-ssh) 开发的 DSH 插件:用 Windows 原生的 **WinRM / PowerShell Remoting** 协议远程管理 Windows 服务器,**目标机不需要装 OpenSSH**。
4
-
5
- > **来源**:本项目是 [andyfan1094/dsh-winrm](https://github.com/andyfan1094/dsh-winrm)(Apache-2.0)的分支,把后端的 WinRM 实现从 Python + pywinrm 换成原生 Node.js 客户端([winrm-client](https://github.com/shide1989/winrm-client)),因此**本机不再需要 Python**。GUI、Agent 工具与功能范围沿用上游设计。
6
-
7
- ## 功能
8
-
9
- | 面 | 说明 |
10
- | --- | --- |
11
- | GUI 侧边栏「Windows」入口 | 居中面板:**主机** / **控制台** / **服务** / **进程** / **传输** 五个页签 |
12
- | Agent 工具 | `winrm_list` `winrm_exec` `winrm_service` `winrm_process` `winrm_upload` `winrm_download` `winrm_cluster` |
13
- | PowerShell 控制台 | WebSocket 命令会话(每条命令通过原生 Node WinRM 客户端执行,输出实时返回) |
14
- | 服务管理 | 列出 / 启动 / 停止 / 重启 / 改启动类型(自动/手动/禁用) |
15
- | 进程管理 | 列出(CPU/内存/路径)/ 按 PID 结束 |
16
- | 文件传输 | WinRM 流式传输(上传单 shell 单流 / 下载并行区间,**不依赖 SMB**,任意路径可传);上传自动建目录 |
17
- | 集群 | 一条命令并发跑多台主机(按 aliases / environment / tags 过滤) |
18
-
19
- ## 认证与传输
20
-
21
- - 使用原生 Node.js WinRM 客户端([winrm-client](https://github.com/shide1989/winrm-client)),按传输自动选择认证:HTTPS 优先 Basic、HTTP 优先 NTLM,两者均自动回退(HTTP Basic 仅限受信内网,公网必须使用 HTTPS)
22
- - 本地账户可写 `Administrator`;域账户可写 `DOMAIN\\user` 或 `user@domain`
23
- - 本机无需 Python —— WinRM 走纯 Node 依赖(winrm-client),随插件一起安装
24
- - 传输:HTTP(5985) 或 HTTPS(5986);HTTPS 可勾选「接受自签名证书」
25
- - 中文输出不乱码:所有命令走 **UTF-8 base64 信封**(`Out-String -Stream` 包装 + 三条带标签的信封行),绕过 WinRM 传输的代码页问题
26
- - 命令结果的 `stderr` 已自动剥离 PowerShell 的 CLIXML 宿主机记录;真实的进程级 stderr(如 `[Console]::Error`)原样保留
27
- - **脚本走 WinRS stdin,不走命令行**:WinRS 把命令行交给 `cmd.exe`,上限约 8191 字符,而 `-EncodedCommand` 会把脚本膨胀 2.67 倍 —— 命令行长脚本此前会直接报 `The command line is too long.`。现在命令行是**常量**,脚本按 UTF-8 字节数分块送到 stdin,实测 200 KB 脚本正常
28
-
29
- ## 目标机准备(一次性)
30
-
31
- 在要管理的 Windows 机器上,**以管理员身份**运行:
32
-
33
- ```powershell
34
- powershell -ExecutionPolicy Bypass -File .\scripts\enable-winrm.ps1
35
- ```
36
-
37
- 脚本自动:启用 WinRM 服务与 5985 监听 → 开放 Basic/Negotiate 认证 → 允许 HTTP 明文(内网)→ WinRS 内存上限 512MB → 放行防火墙 → 打印本机 IP。
38
-
39
- > ⚠️ **安全**:HTTP + Basic 是明文,仅限受信内网;公网请配置 HTTPS + 自签名证书,并在插件里勾选「接受自签名证书」。密码明文存于本机 `~/.dsh/dsh-winrm.json`(0600 权限,仅当前用户可读),插件界面永不回显。
40
-
41
- ## 截图
42
-
43
- ![dsh-winrm 截图](docs/screenshots/winrm-panel.png)
44
-
45
-
46
- ## 安装
47
-
48
- > 需要 DeepSeek Harness **0.1.7-alpha.2 及以后**(含 `0.1.7-rc.1`、`0.2.0-rc.*`、`0.2.x`)。插件的 `peerDependencies` 声明为 `^0.1.7-alpha.2 || ^0.2.0-rc.1`,DSH 启动时会据此校验:0.1.7 与 0.2.x 各版本可加载,`0.3.0` 起会被拒绝(确需冒险可用 `dsh plugin allow-version` 逐版本放行)。
49
-
50
- 从 [Releases](https://github.com/MoRanYue/dsh-winrm/releases) 下载最新的 `dsh-winrm-*.tgz`,加入 profile:
51
-
52
- ```powershell
53
- dsh plugin --profile web add D:\downloads\dsh-winrm-0.3.0.tgz
54
- ```
55
-
56
- 也可以直接从 npm 安装:
57
-
58
- ```powershell
59
- dsh plugin --profile web add dsh-winrm
60
- ```
61
-
62
- 本地开发可用 profile 链接安装:
63
-
64
- ```bash
65
- dsh plugin --profile web add link:D:\项目\dsh-winrm
66
- ```
67
-
68
- 安装后 **重启 dsh web**(退出再启动 `dsh web`)使插件生效。侧边栏出现「Windows」入口;对话中可直接用 `winrm_list` 等工具。
69
-
70
- ### 从源码构建
71
-
72
- ```bash
73
- cd D:\项目\dsh-winrm
74
- pnpm install
75
- npm run build # tsc 声明 + tsdown 宿主/客户端打包 + postbuild 包装
76
- ```
77
-
78
- ## 使用示例(agent 工具)
79
-
80
- ```
81
- winrm_list # 列出已配置主机
82
- winrm_exec alias=web1 command="Get-Service | Select -First 5 | Format-Table"
83
- winrm_service alias=web1 name=W3SVC action=restart
84
- winrm_process alias=web1 action=list
85
- winrm_process alias=web1 id=1234 action=kill
86
- winrm_upload alias=web1 localPath=D:\a.zip remotePath=C:\temp\a.zip
87
- winrm_download alias=web1 remotePath=C:\logs\app.log localPath=D:\app.log
88
- winrm_cluster command="Get-Date" tags=prod
89
- ```
90
-
91
- ## 架构
92
-
93
- ```
94
- src/
95
- index.ts Cordis 插件入口(webServer/tools/systemPrompt 挂载 + 设置面板 + 公告)
96
- engine.ts WinRmEngine 门面:exec / services / processes / ls / upload / download / console / cluster / test
97
- store.ts ~/.dsh/dsh-winrm.json 主机存储(原子写,0600)
98
- protocol.ts 宿主↔浏览器 wire 类型
99
- powershell.ts PS 片段构造器 + stdin 信封(outer/child 包装 / 服务 / 进程 / 目录 / 流式收发脚本)
100
- routes.ts /api/dsh-winrm 路由族 + 控制台 WebSocket(loopback 围栏)
101
- tools.ts 7 个 winrm_* agent 工具
102
- engine/client.ts winrm-client 传输层:进程内认证(NTLM/Basic)、UTF-8 信封、流式上传/下载 + 分块回退
103
- engine/wsman.ts 手写 WSMan Send/Receive 信封(原始字节走 stdin,不做二次 base64;512000 信封上限)
104
- engine/console.ts 流式 PowerShell 控制台会话
105
- client/ 浏览器半:侧边栏入口 + 居中面板(5 页签)
106
- scripts/
107
- enable-winrm.ps1 目标机一键启用 WinRM
108
- postbuild.mjs 客户端产物 __ModuleLoader__ 包装
109
- ```
110
-
111
- 传输依赖原生 Node.js 的 [winrm-client](https://github.com/shide1989/winrm-client)(NTLM/Basic,进程内认证);密码只在宿主进程内使用,不出现在任何子进程命令行参数中。
112
-
113
- ## 已知限制
114
-
115
- - 控制台是命令会话,不保持 PowerShell 变量和当前目录状态;每条命令独立执行
116
- - 上传走**单条 stdin 流**:一个 shell、一个命令,每 256KB 一个 `Send`,可压缩内容自动按块 gzip(实测 1.7MB JSON 线上仅 91KB)。旧的 48KB 分块路径保留为回退,仅在流式通道不可用时启用
117
- - 下载走**并行区间流**:默认最多 4 个 worker,各自一个 shell 读同一文件的绝对字节区间,远端 gzip 后回传,本地按 offset 落位(实测 2MiB 约 10-12 秒,16MiB 约 21-23 秒,可压缩的 16MiB 约 6 秒;旧的 48KB 分块路径 2MiB 需 129 秒,保留为回退)。服务端单次 `Receive` 最多回 128KB,与信封大小无关,所以下载是往返次数受限而非带宽受限
118
- - `channel=auto`(默认)先探测目标机 445 端口:`net use` 自身没有短超时,SMB 不可用时要等约 60 秒才失败,此前这段等待发生在每次上传之前。现在 3 秒内判定并直接走 WinRM
119
- - 下载失败不留残file:已写入的数据会被丢弃、目标文件被删除;若失败发生在任何字节到达之前(路径不存在、目标是目录、主机不可达),已存在的本地文件保持原样不动(目标文件在首次写入时才创建)
120
- - 单命令默认 60s 超时,可传 `timeoutMs`
121
- - 目标机需已启用 WinRM(见上);HTTP 明文 Basic 不应用于公网
122
- - 面板关闭再打开会回到「主机」页签:面板是 `main` 插槽的占用者,切回对话时卸载,页签状态不跨开关保留
123
-
124
- ---
125
-
126
- English documentation: see [README.md](README.md).