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/engine.ts CHANGED
@@ -6,12 +6,25 @@
6
6
  * per plugin apply.
7
7
  */
8
8
 
9
- import type { ClusterResult, ExecResult, ProcessInfo, RemoteDirEntry, ServiceInfo, TestResult, TransferProgress, WinHostSummary } from './protocol.ts'
9
+ import type { ClusterResult, ExecResult, ProcessInfo, RemoteDirEntry, RemoteEditOutcome, RemoteTextEncoding, RemoteTextWindow, RemoteWriteOutcome, ServiceInfo, TestResult, TransferProgress, WinHostSummary } from './protocol.ts'
10
10
  import type { HostStore } from './store.ts'
11
- import { connOf, downloadChunks, downloadStream, runScript, testConnection, uploadBuffer, uploadStream } from './engine/client.ts'
11
+ import { connOf, downloadChunks, downloadStream, remoteFileSize, runScript, testConnection, uploadBuffer, uploadStream } from './engine/client.ts'
12
12
  import { downloadSmb, smbReachable, smbUnavailable, uploadSmb, type TransferChannel } from './smb.ts'
13
13
  import { ConsoleSession } from './engine/console.ts'
14
- import { psKillProcess, psListDir, psListProcesses, psListServices, psServiceAction } from './powershell.ts'
14
+ import { DIFF_BASIS_MAX_BYTES } from './diff.ts'
15
+ import { psEditText, psKillProcess, psListDir, psListProcesses, psListServices, psReadTextBasis, psReadTextWindow, psServiceAction } from './powershell.ts'
16
+
17
+ /** Longest single line the reader returns before cutting it (characters). */
18
+ export const READ_MAX_LINE_LENGTH = 2_000
19
+
20
+ /** Lines the reader returns when the caller does not choose a page size. */
21
+ export const READ_DEFAULT_LIMIT = 2_000
22
+
23
+ /** Most lines the reader will return in one call. */
24
+ export const READ_MAX_LIMIT = 5_000
25
+
26
+ /** Byte budget for one read window; keeps the envelope well under the transport limit. */
27
+ export const READ_MAX_BYTES = 512 * 1_024
15
28
 
16
29
  /** JSON-safe parse of the envelope output into a typed value. */
17
30
  function parseJson<T>(text: string): T {
@@ -20,6 +33,42 @@ function parseJson<T>(text: string): T {
20
33
  return JSON.parse(trimmed) as T
21
34
  }
22
35
 
36
+ /**
37
+ * Parse one JSON object out of an envelope's stdout.
38
+ *
39
+ * The file tools report their own failures inside the JSON (`{"error":…}`)
40
+ * with exit code 0, so an unparseable body means the fragment never ran;
41
+ * returning undefined lets the caller say so instead of throwing a
42
+ * `SyntaxError` with no context.
43
+ * @param text - envelope stdout.
44
+ * @returns the parsed object, or undefined when the body is empty or not JSON.
45
+ */
46
+ function parseObject<T>(text: string): T | undefined {
47
+ const trimmed = text.trim()
48
+ if (trimmed === '' || trimmed === 'null') return undefined
49
+ if (!trimmed.startsWith('{')) return undefined
50
+ try {
51
+ return JSON.parse(trimmed) as T
52
+ } catch {
53
+ return undefined
54
+ }
55
+ }
56
+
57
+ /** Mark an over-long line the reader cut, so the model knows it is incomplete. */
58
+ function annotateCutLine(text: string): string {
59
+ return text.length > READ_MAX_LINE_LENGTH
60
+ ? `${text.slice(0, READ_MAX_LINE_LENGTH)}… (line truncated to ${READ_MAX_LINE_LENGTH} chars)`
61
+ : text
62
+ }
63
+
64
+ /** Encode text for a remote write, applying the requested byte-order mark. */
65
+ function encodeText(content: string, encoding: RemoteTextEncoding): Buffer {
66
+ const body = Buffer.from(content, 'utf8')
67
+ if (encoding === 'utf8bom') return Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), body])
68
+ if (encoding === 'utf16le') return Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(content, 'utf16le')])
69
+ return body
70
+ }
71
+
23
72
  /** A tiny per-alias in-flight limiter so GUI bursts do not pile up sockets. */
24
73
  class AliasGate {
25
74
  private queues = new Map<string, Promise<void>>()
@@ -233,6 +282,167 @@ export class WinRmEngine {
233
282
  })
234
283
  }
235
284
 
285
+ // ------------------------------------------------------------------ text
286
+
287
+ /**
288
+ * Read a bounded, line-numbered window of a remote text file.
289
+ *
290
+ * One PowerShell round trip: the remote side decodes the file, counts every
291
+ * line, and returns only the requested window, so a large log costs one
292
+ * transfer of the window rather than of the file.
293
+ * @param alias - configured host.
294
+ * @param remotePath - path on the target.
295
+ * @param offset - 1-based first line to return.
296
+ * @param limit - maximum number of lines to return.
297
+ * @returns the window plus the file's total line count and encoding.
298
+ */
299
+ async readFile(alias: string, remotePath: string, offset: number, limit: number): Promise<RemoteTextWindow> {
300
+ const entry = this.store.find(alias)
301
+ if (entry === undefined) throw new Error(`alias '${alias}' not found`)
302
+ return this.gate.run(alias, async () => {
303
+ const result = await runScript(
304
+ connOf(entry),
305
+ psReadTextWindow(remotePath, offset, limit, READ_MAX_LINE_LENGTH, READ_MAX_BYTES),
306
+ { timeoutMs: 120_000 },
307
+ )
308
+ const payload = parseObject<{
309
+ exists?: boolean
310
+ error?: string
311
+ size?: number
312
+ encoding?: string
313
+ totalLines?: number
314
+ lineCount?: number
315
+ truncated?: boolean
316
+ truncatedByBytes?: boolean
317
+ linesText?: string
318
+ }>(result.stdout)
319
+ if (payload === undefined) {
320
+ throw new Error(result.stderr.trim() || result.stdout.trim() || `failed to read '${remotePath}'`)
321
+ }
322
+ if (payload.error !== undefined) {
323
+ throw new Error(`${remotePath}: ${payload.error} (the reader decodes UTF-8 and UTF-16 with a BOM; a binary file has no line structure to show)`)
324
+ }
325
+ if (payload.exists !== true) throw new Error(`remote file not found: ${remotePath}`)
326
+ const totalLines = payload.totalLines ?? 0
327
+ const lineCount = payload.lineCount ?? 0
328
+ // `truncated` also covers the byte budget, in which case the offset may
329
+ // legitimately point past what this window reached.
330
+ if (payload.truncated !== true && offset > totalLines && totalLines > 0) {
331
+ throw new Error(`offset ${offset} is out of range for '${remotePath}' (${totalLines} lines)`)
332
+ }
333
+ const texts = lineCount === 0 ? [] : (payload.linesText ?? '').split('\n')
334
+ return {
335
+ path: remotePath,
336
+ offset,
337
+ lines: texts.map((text, index) => ({ number: offset + index, text: annotateCutLine(text) })),
338
+ totalLines,
339
+ encoding: payload.encoding ?? 'utf8',
340
+ // Counted before annotation so the card can say how many lines it cut
341
+ // without the client pattern-matching the annotation suffix.
342
+ truncatedLines: texts.filter(text => text.length > READ_MAX_LINE_LENGTH).length,
343
+ truncatedByBytes: payload.truncatedByBytes === true,
344
+ }
345
+ })
346
+ }
347
+
348
+ /**
349
+ * Write a whole remote text file, creating parent directories as needed.
350
+ *
351
+ * Reuses the transfer transport (single streamed stdin when available, the
352
+ * 48 KiB chunk path otherwise) so a large write does not pay one shell cycle
353
+ * per chunk.
354
+ * @param alias - configured host.
355
+ * @param remotePath - path on the target; overwritten when it exists.
356
+ * @param content - UTF-8 text to write.
357
+ * @param encoding - byte-order mark to emit (`utf8`, `utf8bom`, `utf16le`).
358
+ * @returns the operation kind, byte count, encoding written, and both texts
359
+ * the diff card compares.
360
+ */
361
+ async writeFile(alias: string, remotePath: string, content: string, encoding: RemoteTextEncoding): Promise<RemoteWriteOutcome> {
362
+ const entry = this.store.find(alias)
363
+ if (entry === undefined) throw new Error(`alias '${alias}' not found`)
364
+ const data = encodeText(content, encoding)
365
+ return this.gate.run(alias, async () => {
366
+ const conn = connOf(entry)
367
+ const previous = await remoteFileSize(conn, remotePath)
368
+ // The diff card compares the previous text with the new one, so the prior
369
+ // content is read before overwriting it. That costs one extra round trip,
370
+ // which is skipped for a create and for a file past the basis cap rather
371
+ // than trimmed to fit — a partial basis would make every line look edited.
372
+ let before: string | null = null
373
+ if (previous !== null && previous <= DIFF_BASIS_MAX_BYTES) {
374
+ const basis = await runScript(conn, psReadTextBasis(remotePath, DIFF_BASIS_MAX_BYTES), { timeoutMs: 60_000 })
375
+ const payload = parseObject<{ error?: string; tooLarge?: boolean; text?: string | null }>(basis.stdout)
376
+ if (payload !== undefined && payload.error === undefined && payload.tooLarge !== true && typeof payload.text === 'string') {
377
+ before = Buffer.from(payload.text, 'base64').toString('utf8')
378
+ }
379
+ }
380
+ let bytes = await uploadStream(conn, remotePath, data)
381
+ if (bytes === null) bytes = await uploadBuffer(conn, remotePath, data)
382
+ return {
383
+ path: remotePath,
384
+ operation: previous === null ? 'create' : 'update',
385
+ bytes,
386
+ encoding,
387
+ before,
388
+ after: content,
389
+ }
390
+ })
391
+ }
392
+
393
+ /**
394
+ * Replace `oldString` with `newString` inside a remote text file.
395
+ *
396
+ * The replacement is literal, never a pattern, and the file's existing
397
+ * encoding is preserved. Without `replaceAll` the edit is refused unless the
398
+ * needle is unique, so a stale needle cannot rewrite the wrong region.
399
+ * @param alias - configured host.
400
+ * @param remotePath - path on the target.
401
+ * @param oldString - exact text to find.
402
+ * @param newString - text to substitute.
403
+ * @param replaceAll - replace every occurrence instead of requiring one.
404
+ * @returns the replacement count, the file's new size, and both texts the
405
+ * diff card compares.
406
+ */
407
+ async editFile(
408
+ alias: string,
409
+ remotePath: string,
410
+ oldString: string,
411
+ newString: string,
412
+ replaceAll: boolean,
413
+ ): Promise<RemoteEditOutcome> {
414
+ const entry = this.store.find(alias)
415
+ if (entry === undefined) throw new Error(`alias '${alias}' not found`)
416
+ if (oldString === '') throw new Error('oldString must be a non-empty string')
417
+ if (oldString === newString) throw new Error('oldString and newString are identical; nothing to change')
418
+ const oldB64 = Buffer.from(oldString, 'utf8').toString('base64')
419
+ const newB64 = Buffer.from(newString, 'utf8').toString('base64')
420
+ return this.gate.run(alias, async () => {
421
+ const result = await runScript(connOf(entry), psEditText(remotePath, oldB64, newB64, replaceAll, DIFF_BASIS_MAX_BYTES), { timeoutMs: 120_000 })
422
+ const payload = parseObject<{
423
+ error?: string
424
+ path?: string
425
+ encoding?: string
426
+ replacements?: number
427
+ size?: number
428
+ basis?: { before?: string; after?: string } | null
429
+ }>(result.stdout)
430
+ if (payload === undefined) {
431
+ throw new Error(result.stderr.trim() || result.stdout.trim() || `failed to edit '${remotePath}'`)
432
+ }
433
+ if (payload.error !== undefined) throw new Error(payload.error)
434
+ const basis = payload.basis ?? null
435
+ return {
436
+ path: payload.path ?? remotePath,
437
+ encoding: payload.encoding ?? 'utf8',
438
+ replacements: payload.replacements ?? 0,
439
+ size: payload.size ?? 0,
440
+ before: basis === null ? null : Buffer.from(basis.before ?? '', 'base64').toString('utf8'),
441
+ after: basis === null ? null : Buffer.from(basis.after ?? '', 'base64').toString('utf8'),
442
+ }
443
+ })
444
+ }
445
+
236
446
  // -------------------------------------------------------------- transfer
237
447
 
238
448
  /** Upload one local file to a remote path. Defaults to SMB with WinRM fallback. */
@@ -326,6 +536,7 @@ export class WinRmEngine {
326
536
  const report = (transferred: number, total: number): void => {
327
537
  onProgress?.({ phase: 'transferring', file: remotePath, transferred, total, percent: total > 0 ? Math.round((transferred / total) * 1000) / 10 : 0 })
328
538
  }
539
+ let delivered = 0
329
540
  try {
330
541
  const conn = connOf(entry)
331
542
  const streamed = await downloadStream(conn, remotePath, put, report)
@@ -341,6 +552,12 @@ export class WinRmEngine {
341
552
  },
342
553
  report,
343
554
  )
555
+ delivered = bytes
556
+ } else {
557
+ // `bytes` counts every positional write, and a retry re-sends the
558
+ // ranges from their start — so it would report more than the file
559
+ // holds. The successful attempt's own total is the file size.
560
+ delivered = streamed
344
561
  }
345
562
  await queue
346
563
  // A zero-byte remote file produces no writes, so the sink is still
@@ -360,8 +577,8 @@ export class WinRmEngine {
360
577
  // already truncated that file to nothing.
361
578
  if (!completed && sink !== null) await rm(localPath, { force: true }).catch(() => undefined)
362
579
  }
363
- onProgress?.({ phase: 'done', file: remotePath, transferred: bytes, total: bytes, percent: 100 })
364
- return { bytes, channel: 'winrm' }
580
+ onProgress?.({ phase: 'done', file: remotePath, transferred: delivered, total: delivered, percent: 100 })
581
+ return { bytes: delivered, channel: 'winrm' }
365
582
  })
366
583
  }
367
584
 
package/src/index.ts CHANGED
@@ -3,8 +3,9 @@
3
3
  * execution with a UTF-8 envelope, streaming console, service/process
4
4
  * management, streamed file transfer, cluster), the /api/dsh-winrm
5
5
  * route family plus the console WebSocket upgrade, the agent tools
6
- * (winrm_list, winrm_exec, winrm_service, winrm_process, winrm_upload,
7
- * winrm_download, winrm_cluster), and a system-prompt announcement. The
6
+ * (winrm_list, winrm_exec, winrm_service, winrm_process, winrm_read,
7
+ * winrm_write, winrm_edit, winrm_upload, winrm_download, winrm_cluster), and
8
+ * a system-prompt announcement. The
8
9
  * browser half (./client) renders the host manager and operations panel.
9
10
  * Everything rides official NPM SDK packages — no dsh source changes.
10
11
  *
@@ -27,11 +28,14 @@ import { HostStore } from './store.ts'
27
28
  import {
28
29
  winrmClusterTool,
29
30
  winrmDownloadTool,
31
+ winrmEditTool,
30
32
  winrmExecTool,
31
33
  winrmListTool,
32
34
  winrmProcessTool,
35
+ winrmReadTool,
33
36
  winrmServiceTool,
34
37
  winrmUploadTool,
38
+ winrmWriteTool,
35
39
  } from './tools.ts'
36
40
  import { mountOnce } from './mount-once.ts'
37
41
 
@@ -64,7 +68,7 @@ const DEFAULT_ENABLED = true
64
68
  const SECTION_ORDER = 151
65
69
 
66
70
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
67
- export const WINRM_GUIDANCE = '本机已安装 dsh-winrm 插件(DSH 远程 Windows 运维,WinRM/PowerShell Remoting):侧边栏「Windows」入口;仿照 dsh-ssh 插件开发。能力:主机配置存 ~/.dsh/dsh-winrm.json(GUI 配置后 agent 方可使用);winrm_list 列出主机、winrm_exec 执行 PowerShell 命令(UTF-8 信封,中文不乱码)、winrm_service 服务管理(start/stop/restart/启动类型)、winrm_process 进程管理(list/kill)、winrm_upload/winrm_download 文件传输(SMB 管理共享可达时优先,否则走 WinRM 流式传输,无 SMB 依赖,大文件经 SHA256 校验)、winrm_cluster 集群并发执行;Web 控制台走 WebSocket。认证:通过原生 Node.js WinRM 客户端(winrm-client)的 NTLM/Negotiate 与 Basic 实现,兼容本地账户与域账户;传输支持 http(5985)/https(5986)。前提:目标机需启用 WinRM(scripts/enable-winrm.ps1),本机无需 Python —— WinRM 走纯 Node 依赖。HTTP 仅建议受信内网使用;密码以明文存在用户主目录私有文件(权限 0600);命令输出可能含敏感信息;传输/执行消耗真实远程资源,先确认再操作。用户提到「Windows 服务器 / WinRM / PowerShell 远程 / 服务管理 / 进程管理」时即指本插件,请据此协作。'
71
+ export const WINRM_GUIDANCE = '本机已安装 dsh-winrm 插件(DSH 远程 Windows 运维,WinRM/PowerShell Remoting):侧边栏「Windows」入口;仿照 dsh-ssh 插件开发。能力:主机配置存 ~/.dsh/dsh-winrm.json(GUI 配置后 agent 方可使用);winrm_list 列出主机、winrm_exec 执行 PowerShell 命令(UTF-8 信封,中文不乱码)、winrm_service 服务管理(start/stop/restart/启动类型)、winrm_process 进程管理(list/kill)、winrm_read/winrm_write/winrm_edit 远程文本文件读写(按行分页、编码感知、精确串替换,作用于远端主机而非本机,本机文件仍用 read/write/edit)、winrm_upload/winrm_download 文件传输(channel=auto 时若目标 445 可达走 SMB 管理共享,否则走 WinRM 流式传输;channel=smb 在 445 关闭时直接报错、需改用 winrm;大文件可下载,流式通道按字节持续推进、仅在字节断流时才判超时、完成后经 SHA256 校验)、winrm_cluster 集群并发执行;Web 控制台走 WebSocket。认证:通过原生 Node.js WinRM 客户端(winrm-client)的 NTLM/Negotiate 与 Basic 实现,兼容本地账户与域账户;传输支持 http(5985)/https(5986)。前提:目标机需启用 WinRM(scripts/enable-winrm.ps1),本机无需 Python —— WinRM 走纯 Node 依赖。HTTP 仅建议受信内网使用;密码以明文存在用户主目录私有文件(权限 0600);命令输出可能含敏感信息;传输/执行消耗真实远程资源,先确认再操作。用户提到「Windows 服务器 / WinRM / PowerShell 远程 / 服务管理 / 进程管理」时即指本插件,请据此协作。'
68
72
 
69
73
  /**
70
74
  * Mount the WinRM engine, routes, tools, and announcement.
@@ -88,6 +92,9 @@ function applyImpl(ctx: Context, config?: Config): void {
88
92
  winrmExecTool(engine),
89
93
  winrmServiceTool(engine),
90
94
  winrmProcessTool(engine),
95
+ winrmReadTool(engine),
96
+ winrmWriteTool(engine),
97
+ winrmEditTool(engine),
91
98
  winrmUploadTool(engine),
92
99
  winrmDownloadTool(engine),
93
100
  winrmClusterTool(engine),
package/src/powershell.ts CHANGED
@@ -343,6 +343,259 @@ export function psWriteChunk(p: string, b64: string, append: boolean): string {
343
343
  ].join('\r\n')
344
344
  }
345
345
 
346
+ /**
347
+ * JSON: one bounded, line-numbered window of a remote TEXT file.
348
+ *
349
+ * Decodes UTF-8 (with or without BOM), UTF-16LE and UTF-16BE by BOM, and
350
+ * rejects invalid UTF-8 instead of returning mojibake — a binary file fails
351
+ * loudly rather than as unreadable text. Lines are numbered from 1 over the
352
+ * whole file, `offset` selects the first returned line, and the window stops
353
+ * early once the joined text would exceed `maxBytes` (reported as
354
+ * `truncated`, so the caller knows more lines remain). A line longer than
355
+ * `maxLineLength` is cut to `maxLineLength + 1` code units here; the Node half
356
+ * appends the truncation note.
357
+ *
358
+ * A missing path or a directory emits `{"exists":false}`; anything the reader
359
+ * rejects emits `{"error":…}`. Both keep the exit code at 0 so the caller
360
+ * always receives parseable JSON rather than a PowerShell error record.
361
+ * @param p - remote path.
362
+ * @param offset - 1-based first line to return.
363
+ * @param limit - maximum number of lines to return.
364
+ * @param maxLineLength - maximum characters kept per line.
365
+ * @param maxBytes - maximum UTF-8 bytes of joined line text.
366
+ * @returns the PowerShell fragment.
367
+ */
368
+ export function psReadTextWindow(
369
+ p: string,
370
+ offset: number,
371
+ limit: number,
372
+ maxLineLength: number,
373
+ maxBytes: number,
374
+ ): string {
375
+ return [
376
+ '$__p = ' + sq(p),
377
+ '$__err = $null',
378
+ '$__json = $null',
379
+ 'try {',
380
+ ' $__i = Get-Item -LiteralPath $__p -ErrorAction SilentlyContinue',
381
+ ' if ($null -eq $__i -or $__i.PSIsContainer) {',
382
+ ' $__json = [pscustomobject]@{ exists = $false } | ConvertTo-Json -Compress',
383
+ ' } else {',
384
+ ' $__fs = [System.IO.File]::OpenRead($__p)',
385
+ ' try {',
386
+ ' $__b0 = New-Object byte[] 3',
387
+ ' $__n0 = $__fs.Read($__b0, 0, 3)',
388
+ ' $__fs.Position = 0',
389
+ " $__enc = 'utf8'",
390
+ ' $__coding = New-Object System.Text.UTF8Encoding($false, $true)',
391
+ ' if ($__n0 -ge 2 -and $__b0[0] -eq 255 -and $__b0[1] -eq 254) {',
392
+ " $__enc = 'utf16le'",
393
+ ' $__coding = [System.Text.Encoding]::Unicode',
394
+ ' } elseif ($__n0 -ge 2 -and $__b0[0] -eq 254 -and $__b0[1] -eq 255) {',
395
+ " $__enc = 'utf16be'",
396
+ ' $__coding = [System.Text.Encoding]::BigEndianUnicode',
397
+ ' } elseif ($__n0 -ge 3 -and $__b0[0] -eq 239 -and $__b0[1] -eq 187 -and $__b0[2] -eq 191) {',
398
+ " $__enc = 'utf8bom'",
399
+ ' }',
400
+ ' $__r = New-Object System.IO.StreamReader($__fs, $__coding, $true)',
401
+ ' try {',
402
+ ' $__total = 0',
403
+ ' $__sel = New-Object System.Collections.ArrayList',
404
+ ' $__used = 0',
405
+ ' $__cut = $false',
406
+ ' $__bytes = $false',
407
+ ' while ($null -ne ($__line = $__r.ReadLine())) {',
408
+ ' $__total++',
409
+ ' if ($__cut) { continue }',
410
+ ' if ($__total -lt ' + String(offset) + ') { continue }',
411
+ ' if ($__sel.Count -ge ' + String(limit) + ') { $__cut = $true; continue }',
412
+ ' $__t = $__line',
413
+ ' if ($__t.Length -gt ' + String(maxLineLength + 1) + ') { $__t = $__t.Substring(0, ' + String(maxLineLength + 1) + ') }',
414
+ ' $__b = [System.Text.Encoding]::UTF8.GetByteCount($__t)',
415
+ ' if ($__sel.Count -gt 0) { $__b++ }',
416
+ ' if ($__used + $__b -gt ' + String(maxBytes) + ') { $__cut = $true; $__bytes = $true; continue }',
417
+ ' $__used += $__b',
418
+ ' [void]$__sel.Add($__t)',
419
+ ' }',
420
+ ' } finally { $__r.Dispose() }',
421
+ ' } finally { $__fs.Dispose() }',
422
+ ' $__json = [pscustomobject]@{',
423
+ ' exists = $true',
424
+ ' size = [long]$__i.Length',
425
+ ' encoding = $__enc',
426
+ ' totalLines = $__total',
427
+ ' lineCount = $__sel.Count',
428
+ ' truncated = $__cut',
429
+ ' truncatedByBytes = $__bytes',
430
+ ' linesText = (($__sel | ForEach-Object { [string]$_ }) -join "`n")',
431
+ ' } | ConvertTo-Json -Compress -Depth 3',
432
+ ' }',
433
+ '} catch { $__err = $_.Exception.GetBaseException().Message }',
434
+ 'if ($null -ne $__err) { [pscustomobject]@{ error = $__err } | ConvertTo-Json -Compress }',
435
+ 'else { $__json }',
436
+ '',
437
+ ].join('\r\n')
438
+ }
439
+
440
+ /**
441
+ * JSON: replace `oldB64` with `newB64` (both UTF-8, base64-carried) in a remote
442
+ * text file, preserving its encoding and writing through the same file.
443
+ *
444
+ * The search is ordinal and never pattern-based, so a replacement cannot be
445
+ * reinterpreted as a regular expression. Without `replaceAll` the edit is
446
+ * refused unless the needle appears exactly once, which is what keeps a stale
447
+ * or too-short needle from rewriting the wrong region. The file is read whole,
448
+ * so this is meant for source files and configuration, not for multi-hundred-
449
+ * megabyte logs.
450
+ *
451
+ * Failures (missing file, absent needle, ambiguous needle, undecodable bytes)
452
+ * are reported as `{"error":…}` with exit code 0 so the caller always receives
453
+ * parseable JSON.
454
+ * @param p - remote path.
455
+ * @param oldB64 - base64 of the UTF-8 text to find.
456
+ * @param newB64 - base64 of the UTF-8 text to substitute.
457
+ * @param replaceAll - replace every occurrence instead of requiring exactly one.
458
+ * @param basisMaxBytes - include the before/after text when the original file is
459
+ * at most this large; `0` omits the basis entirely.
460
+ * @returns the PowerShell fragment.
461
+ */
462
+ export function psEditText(p: string, oldB64: string, newB64: string, replaceAll: boolean, basisMaxBytes = 0): string {
463
+ // Only the selected mode is emitted. The two bodies fail differently, and
464
+ // shipping the unused one would leave a message in the script that can never
465
+ // fire — which reads like a live rule to anyone auditing the wire.
466
+ const branch = replaceAll
467
+ ? [
468
+ ' $__count = [int](($__text.Length - $__text.Replace($__old, [string]::Empty).Length) / $__old.Length)',
469
+ " if ($__count -eq 0) { throw ('oldString was not found in ' + $__p) }",
470
+ ' $__out = $__text.Replace($__old, $__new)',
471
+ ]
472
+ : [
473
+ ' $__at = $__text.IndexOf($__old, [System.StringComparison]::Ordinal)',
474
+ " if ($__at -lt 0) { throw ('oldString was not found in ' + $__p) }",
475
+ ' if ($__text.IndexOf($__old, $__at + 1, [System.StringComparison]::Ordinal) -ge 0) {',
476
+ " throw ('oldString appears more than once in ' + $__p + '; pass replaceAll=true or include more surrounding context')",
477
+ ' }',
478
+ ' $__out = $__text.Substring(0, $__at) + $__new + $__text.Substring($__at + $__old.Length)',
479
+ ' $__count = 1',
480
+ ]
481
+ // A zero cap means the caller does not want a diff basis; emit a literal
482
+ // `if ($false)` rather than a comparison that would still read a large file.
483
+ const basisGuard = basisMaxBytes > 0 ? `[long]$__i.Length -le ${basisMaxBytes}` : '$false'
484
+ return [
485
+ '$__p = ' + sq(p),
486
+ '$__err = $null',
487
+ '$__json = $null',
488
+ 'try {',
489
+ ' $__i = Get-Item -LiteralPath $__p -ErrorAction SilentlyContinue',
490
+ " if ($null -eq $__i -or $__i.PSIsContainer) { throw ('remote file not found: ' + $__p) }",
491
+ ' $__old = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String(' + sq(oldB64) + '))',
492
+ ' $__new = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String(' + sq(newB64) + '))',
493
+ ' if ($__old.Length -eq 0) { throw ' + sq('oldString must be a non-empty string') + ' }',
494
+ ' $__fs = [System.IO.File]::OpenRead($__p)',
495
+ ' try {',
496
+ ' $__b0 = New-Object byte[] 3',
497
+ ' $__n0 = $__fs.Read($__b0, 0, 3)',
498
+ ' $__fs.Position = 0',
499
+ " $__enc = 'utf8'",
500
+ ' $__coding = New-Object System.Text.UTF8Encoding($false, $true)',
501
+ ' if ($__n0 -ge 2 -and $__b0[0] -eq 255 -and $__b0[1] -eq 254) {',
502
+ " $__enc = 'utf16le'",
503
+ ' $__coding = [System.Text.Encoding]::Unicode',
504
+ ' } elseif ($__n0 -ge 2 -and $__b0[0] -eq 254 -and $__b0[1] -eq 255) {',
505
+ " $__enc = 'utf16be'",
506
+ ' $__coding = [System.Text.Encoding]::BigEndianUnicode',
507
+ ' } elseif ($__n0 -ge 3 -and $__b0[0] -eq 239 -and $__b0[1] -eq 187 -and $__b0[2] -eq 191) {',
508
+ " $__enc = 'utf8bom'",
509
+ ' }',
510
+ ' $__r = New-Object System.IO.StreamReader($__fs, $__coding, $true)',
511
+ ' try { $__text = $__r.ReadToEnd() } finally { $__r.Dispose() }',
512
+ ' } finally { $__fs.Dispose() }',
513
+ ...branch,
514
+ ' if ($__enc -eq "utf8bom") { [System.IO.File]::WriteAllText($__p, $__out, (New-Object System.Text.UTF8Encoding($true))) }',
515
+ ' elseif ($__enc -eq "utf16le") { [System.IO.File]::WriteAllText($__p, $__out, [System.Text.Encoding]::Unicode) }',
516
+ ' elseif ($__enc -eq "utf16be") { [System.IO.File]::WriteAllText($__p, $__out, [System.Text.Encoding]::BigEndianUnicode) }',
517
+ ' else { [System.IO.File]::WriteAllText($__p, $__out, (New-Object System.Text.UTF8Encoding($false))) }',
518
+ ' $__basis = $null',
519
+ ' if (' + basisGuard + ') {',
520
+ ' $__basis = [pscustomobject]@{',
521
+ ' before = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($__text))',
522
+ ' after = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($__out))',
523
+ ' }',
524
+ ' }',
525
+ ' $__json = [pscustomobject]@{',
526
+ ' path = $__p',
527
+ ' encoding = $__enc',
528
+ ' replacements = $__count',
529
+ ' size = [long](Get-Item -LiteralPath $__p).Length',
530
+ ' basis = $__basis',
531
+ ' } | ConvertTo-Json -Compress -Depth 3',
532
+ '} catch { $__err = $_.Exception.GetBaseException().Message }',
533
+ 'if ($null -ne $__err) { [pscustomobject]@{ error = $__err } | ConvertTo-Json -Compress }',
534
+ 'else { $__json }',
535
+ '',
536
+ ].join('\r\n')
537
+ }
538
+
539
+ /**
540
+ * JSON: the whole remote text file as base64, or a refusal when it is missing
541
+ * or larger than `maxBytes`.
542
+ *
543
+ * The write tool needs the previous text to project a diff card, and the line
544
+ * window reader cannot serve that: it numbers lines and caps each one, so a
545
+ * long line would silently truncate inside the diff basis. The BOM detection
546
+ * repeats {@link psEditText}'s on purpose — both must decode identically, and a
547
+ * shared emitter would hide that requirement behind a helper neither could be
548
+ * reviewed against.
549
+ *
550
+ * Failures are reported as `{"error":…}` with exit code 0, so the caller always
551
+ * receives parseable JSON.
552
+ * @param p - remote path.
553
+ * @param maxBytes - largest file whose text is returned.
554
+ * @returns the PowerShell fragment.
555
+ */
556
+ export function psReadTextBasis(p: string, maxBytes: number): string {
557
+ return [
558
+ '$__p = ' + sq(p),
559
+ '$__err = $null',
560
+ '$__json = $null',
561
+ 'try {',
562
+ ' $__i = Get-Item -LiteralPath $__p -ErrorAction SilentlyContinue',
563
+ " if ($null -eq $__i -or $__i.PSIsContainer) { throw ('remote file not found: ' + $__p) }",
564
+ ` if ([long]$__i.Length -gt ${maxBytes}) {`,
565
+ ' $__json = [pscustomobject]@{ tooLarge = $true; encoding = $null; text = $null } | ConvertTo-Json -Compress',
566
+ ' } else {',
567
+ ' $__fs = [System.IO.File]::OpenRead($__p)',
568
+ ' try {',
569
+ ' $__b0 = New-Object byte[] 3',
570
+ ' $__n0 = $__fs.Read($__b0, 0, 3)',
571
+ ' $__fs.Position = 0',
572
+ " $__enc = 'utf8'",
573
+ ' $__coding = New-Object System.Text.UTF8Encoding($false, $true)',
574
+ ' if ($__n0 -ge 2 -and $__b0[0] -eq 255 -and $__b0[1] -eq 254) {',
575
+ " $__enc = 'utf16le'",
576
+ ' $__coding = [System.Text.Encoding]::Unicode',
577
+ ' } elseif ($__n0 -ge 2 -and $__b0[0] -eq 254 -and $__b0[1] -eq 255) {',
578
+ " $__enc = 'utf16be'",
579
+ ' $__coding = [System.Text.Encoding]::BigEndianUnicode',
580
+ ' } elseif ($__n0 -ge 3 -and $__b0[0] -eq 239 -and $__b0[1] -eq 187 -and $__b0[2] -eq 191) {',
581
+ " $__enc = 'utf8bom'",
582
+ ' }',
583
+ ' $__r = New-Object System.IO.StreamReader($__fs, $__coding, $true)',
584
+ ' try { $__text = $__r.ReadToEnd() } finally { $__r.Dispose() }',
585
+ ' } finally { $__fs.Dispose() }',
586
+ ' $__json = [pscustomobject]@{',
587
+ ' tooLarge = $false',
588
+ ' encoding = $__enc',
589
+ ' text = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($__text))',
590
+ ' } | ConvertTo-Json -Compress -Depth 3',
591
+ ' }',
592
+ '} catch { $__err = $_.Exception.GetBaseException().Message }',
593
+ 'if ($null -ne $__err) { [pscustomobject]@{ error = $__err } | ConvertTo-Json -Compress }',
594
+ 'else { $__json }',
595
+ '',
596
+ ].join('\r\n')
597
+ }
598
+
346
599
  /** Magic stamped into the frame header that precedes every stream chunk. */
347
600
  export const STREAM_FRAME_MAGIC = 'DSHZ'
348
601
 
package/src/protocol.ts CHANGED
@@ -96,6 +96,65 @@ export interface RemoteDirEntry {
96
96
  mtimeMs: number
97
97
  }
98
98
 
99
+ /** One numbered line of a remote text file. */
100
+ export interface RemoteTextLine {
101
+ /** 1-based line number within the remote file. */
102
+ number: number
103
+ /** Line text without its terminator; over-long lines are cut and annotated. */
104
+ text: string
105
+ }
106
+
107
+ /** Result of reading a bounded window of a remote text file. */
108
+ export interface RemoteTextWindow {
109
+ path: string
110
+ /** 1-based line number of the first returned line. */
111
+ offset: number
112
+ lines: RemoteTextLine[]
113
+ /** Lines in the whole file, so the caller can page. */
114
+ totalLines: number
115
+ /** Detected encoding: `utf8`, `utf8bom`, `utf16le` or `utf16be`. */
116
+ encoding: string
117
+ /** True when the window stopped at the byte budget instead of at `limit`. */
118
+ truncatedByBytes: boolean
119
+ /** Lines whose text was cut at the per-line cap and annotated in `lines`. */
120
+ truncatedLines: number
121
+ }
122
+
123
+ /** Text encodings the writer can emit. */
124
+ export type RemoteTextEncoding = 'utf8' | 'utf8bom' | 'utf16le'
125
+
126
+ /** Result of writing a remote text file. */
127
+ export interface RemoteWriteOutcome {
128
+ path: string
129
+ /** `create` when the path was absent, `update` when it was overwritten. */
130
+ operation: 'create' | 'update'
131
+ bytes: number
132
+ encoding: RemoteTextEncoding
133
+ /**
134
+ * Previous file text, or `null` for a create and for a file too large to
135
+ * serve as a diff basis. Only the presentation layer reads it.
136
+ */
137
+ before: string | null
138
+ /** Text after the write, on the same basis as {@link RemoteWriteOutcome.before}. */
139
+ after: string
140
+ }
141
+
142
+ /** Result of an in-place remote text edit. */
143
+ export interface RemoteEditOutcome {
144
+ path: string
145
+ /** Encoding the file already had, preserved across the edit. */
146
+ encoding: string
147
+ replacements: number
148
+ size: number
149
+ /**
150
+ * File text before the edit, or `null` when the file was too large to serve
151
+ * as a diff basis. Only the presentation layer reads it.
152
+ */
153
+ before: string | null
154
+ /** Text after the edit, on the same basis as {@link RemoteEditOutcome.before}. */
155
+ after: string | null
156
+ }
157
+
99
158
  /** One server entry in a cluster run. */
100
159
  export interface ClusterResult {
101
160
  alias: string
package/src/smb.ts CHANGED
@@ -134,7 +134,10 @@ export async function smbReachable(host: string): Promise<boolean> {
134
134
 
135
135
  /** Error thrown when SMB was requested or probed and the target is not listening. */
136
136
  export function smbUnavailable(host: string): Error {
137
- return new Error('SMB is not reachable on ' + host + ':445 (the admin share needs TCP 445 open and the Server service running)')
137
+ return new Error(
138
+ 'SMB is not reachable on ' + host + ':445 (the admin share needs TCP 445 open and the Server service running); '
139
+ + 'retry with channel "winrm" to transfer over WinRM instead',
140
+ )
138
141
  }
139
142
 
140
143
  /**