dsh-winrm 0.3.1 → 0.4.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/lib/index.js CHANGED
@@ -322,6 +322,243 @@ function psWriteChunk(p, b64, append) {
322
322
  ""
323
323
  ].join("\r\n");
324
324
  }
325
+ /**
326
+ * JSON: one bounded, line-numbered window of a remote TEXT file.
327
+ *
328
+ * Decodes UTF-8 (with or without BOM), UTF-16LE and UTF-16BE by BOM, and
329
+ * rejects invalid UTF-8 instead of returning mojibake — a binary file fails
330
+ * loudly rather than as unreadable text. Lines are numbered from 1 over the
331
+ * whole file, `offset` selects the first returned line, and the window stops
332
+ * early once the joined text would exceed `maxBytes` (reported as
333
+ * `truncated`, so the caller knows more lines remain). A line longer than
334
+ * `maxLineLength` is cut to `maxLineLength + 1` code units here; the Node half
335
+ * appends the truncation note.
336
+ *
337
+ * A missing path or a directory emits `{"exists":false}`; anything the reader
338
+ * rejects emits `{"error":…}`. Both keep the exit code at 0 so the caller
339
+ * always receives parseable JSON rather than a PowerShell error record.
340
+ * @param p - remote path.
341
+ * @param offset - 1-based first line to return.
342
+ * @param limit - maximum number of lines to return.
343
+ * @param maxLineLength - maximum characters kept per line.
344
+ * @param maxBytes - maximum UTF-8 bytes of joined line text.
345
+ * @returns the PowerShell fragment.
346
+ */
347
+ function psReadTextWindow(p, offset, limit, maxLineLength, maxBytes) {
348
+ return [
349
+ "$__p = " + sq(p),
350
+ "$__err = $null",
351
+ "$__json = $null",
352
+ "try {",
353
+ " $__i = Get-Item -LiteralPath $__p -ErrorAction SilentlyContinue",
354
+ " if ($null -eq $__i -or $__i.PSIsContainer) {",
355
+ " $__json = [pscustomobject]@{ exists = $false } | ConvertTo-Json -Compress",
356
+ " } else {",
357
+ " $__fs = [System.IO.File]::OpenRead($__p)",
358
+ " try {",
359
+ " $__b0 = New-Object byte[] 3",
360
+ " $__n0 = $__fs.Read($__b0, 0, 3)",
361
+ " $__fs.Position = 0",
362
+ " $__enc = 'utf8'",
363
+ " $__coding = New-Object System.Text.UTF8Encoding($false, $true)",
364
+ " if ($__n0 -ge 2 -and $__b0[0] -eq 255 -and $__b0[1] -eq 254) {",
365
+ " $__enc = 'utf16le'",
366
+ " $__coding = [System.Text.Encoding]::Unicode",
367
+ " } elseif ($__n0 -ge 2 -and $__b0[0] -eq 254 -and $__b0[1] -eq 255) {",
368
+ " $__enc = 'utf16be'",
369
+ " $__coding = [System.Text.Encoding]::BigEndianUnicode",
370
+ " } elseif ($__n0 -ge 3 -and $__b0[0] -eq 239 -and $__b0[1] -eq 187 -and $__b0[2] -eq 191) {",
371
+ " $__enc = 'utf8bom'",
372
+ " }",
373
+ " $__r = New-Object System.IO.StreamReader($__fs, $__coding, $true)",
374
+ " try {",
375
+ " $__total = 0",
376
+ " $__sel = New-Object System.Collections.ArrayList",
377
+ " $__used = 0",
378
+ " $__cut = $false",
379
+ " $__bytes = $false",
380
+ " while ($null -ne ($__line = $__r.ReadLine())) {",
381
+ " $__total++",
382
+ " if ($__cut) { continue }",
383
+ " if ($__total -lt " + String(offset) + ") { continue }",
384
+ " if ($__sel.Count -ge " + String(limit) + ") { $__cut = $true; continue }",
385
+ " $__t = $__line",
386
+ " if ($__t.Length -gt " + String(maxLineLength + 1) + ") { $__t = $__t.Substring(0, " + String(maxLineLength + 1) + ") }",
387
+ " $__b = [System.Text.Encoding]::UTF8.GetByteCount($__t)",
388
+ " if ($__sel.Count -gt 0) { $__b++ }",
389
+ " if ($__used + $__b -gt " + String(maxBytes) + ") { $__cut = $true; $__bytes = $true; continue }",
390
+ " $__used += $__b",
391
+ " [void]$__sel.Add($__t)",
392
+ " }",
393
+ " } finally { $__r.Dispose() }",
394
+ " } finally { $__fs.Dispose() }",
395
+ " $__json = [pscustomobject]@{",
396
+ " exists = $true",
397
+ " size = [long]$__i.Length",
398
+ " encoding = $__enc",
399
+ " totalLines = $__total",
400
+ " lineCount = $__sel.Count",
401
+ " truncated = $__cut",
402
+ " truncatedByBytes = $__bytes",
403
+ " linesText = (($__sel | ForEach-Object { [string]$_ }) -join \"`n\")",
404
+ " } | ConvertTo-Json -Compress -Depth 3",
405
+ " }",
406
+ "} catch { $__err = $_.Exception.GetBaseException().Message }",
407
+ "if ($null -ne $__err) { [pscustomobject]@{ error = $__err } | ConvertTo-Json -Compress }",
408
+ "else { $__json }",
409
+ ""
410
+ ].join("\r\n");
411
+ }
412
+ /**
413
+ * JSON: replace `oldB64` with `newB64` (both UTF-8, base64-carried) in a remote
414
+ * text file, preserving its encoding and writing through the same file.
415
+ *
416
+ * The search is ordinal and never pattern-based, so a replacement cannot be
417
+ * reinterpreted as a regular expression. Without `replaceAll` the edit is
418
+ * refused unless the needle appears exactly once, which is what keeps a stale
419
+ * or too-short needle from rewriting the wrong region. The file is read whole,
420
+ * so this is meant for source files and configuration, not for multi-hundred-
421
+ * megabyte logs.
422
+ *
423
+ * Failures (missing file, absent needle, ambiguous needle, undecodable bytes)
424
+ * are reported as `{"error":…}` with exit code 0 so the caller always receives
425
+ * parseable JSON.
426
+ * @param p - remote path.
427
+ * @param oldB64 - base64 of the UTF-8 text to find.
428
+ * @param newB64 - base64 of the UTF-8 text to substitute.
429
+ * @param replaceAll - replace every occurrence instead of requiring exactly one.
430
+ * @param basisMaxBytes - include the before/after text when the original file is
431
+ * at most this large; `0` omits the basis entirely.
432
+ * @returns the PowerShell fragment.
433
+ */
434
+ function psEditText(p, oldB64, newB64, replaceAll, basisMaxBytes = 0) {
435
+ const branch = replaceAll ? [
436
+ " $__count = [int](($__text.Length - $__text.Replace($__old, [string]::Empty).Length) / $__old.Length)",
437
+ " if ($__count -eq 0) { throw ('oldString was not found in ' + $__p) }",
438
+ " $__out = $__text.Replace($__old, $__new)"
439
+ ] : [
440
+ " $__at = $__text.IndexOf($__old, [System.StringComparison]::Ordinal)",
441
+ " if ($__at -lt 0) { throw ('oldString was not found in ' + $__p) }",
442
+ " if ($__text.IndexOf($__old, $__at + 1, [System.StringComparison]::Ordinal) -ge 0) {",
443
+ " throw ('oldString appears more than once in ' + $__p + '; pass replaceAll=true or include more surrounding context')",
444
+ " }",
445
+ " $__out = $__text.Substring(0, $__at) + $__new + $__text.Substring($__at + $__old.Length)",
446
+ " $__count = 1"
447
+ ];
448
+ const basisGuard = basisMaxBytes > 0 ? `[long]$__i.Length -le ${basisMaxBytes}` : "$false";
449
+ return [
450
+ "$__p = " + sq(p),
451
+ "$__err = $null",
452
+ "$__json = $null",
453
+ "try {",
454
+ " $__i = Get-Item -LiteralPath $__p -ErrorAction SilentlyContinue",
455
+ " if ($null -eq $__i -or $__i.PSIsContainer) { throw ('remote file not found: ' + $__p) }",
456
+ " $__old = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String(" + sq(oldB64) + "))",
457
+ " $__new = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String(" + sq(newB64) + "))",
458
+ " if ($__old.Length -eq 0) { throw " + sq("oldString must be a non-empty string") + " }",
459
+ " $__fs = [System.IO.File]::OpenRead($__p)",
460
+ " try {",
461
+ " $__b0 = New-Object byte[] 3",
462
+ " $__n0 = $__fs.Read($__b0, 0, 3)",
463
+ " $__fs.Position = 0",
464
+ " $__enc = 'utf8'",
465
+ " $__coding = New-Object System.Text.UTF8Encoding($false, $true)",
466
+ " if ($__n0 -ge 2 -and $__b0[0] -eq 255 -and $__b0[1] -eq 254) {",
467
+ " $__enc = 'utf16le'",
468
+ " $__coding = [System.Text.Encoding]::Unicode",
469
+ " } elseif ($__n0 -ge 2 -and $__b0[0] -eq 254 -and $__b0[1] -eq 255) {",
470
+ " $__enc = 'utf16be'",
471
+ " $__coding = [System.Text.Encoding]::BigEndianUnicode",
472
+ " } elseif ($__n0 -ge 3 -and $__b0[0] -eq 239 -and $__b0[1] -eq 187 -and $__b0[2] -eq 191) {",
473
+ " $__enc = 'utf8bom'",
474
+ " }",
475
+ " $__r = New-Object System.IO.StreamReader($__fs, $__coding, $true)",
476
+ " try { $__text = $__r.ReadToEnd() } finally { $__r.Dispose() }",
477
+ " } finally { $__fs.Dispose() }",
478
+ ...branch,
479
+ " if ($__enc -eq \"utf8bom\") { [System.IO.File]::WriteAllText($__p, $__out, (New-Object System.Text.UTF8Encoding($true))) }",
480
+ " elseif ($__enc -eq \"utf16le\") { [System.IO.File]::WriteAllText($__p, $__out, [System.Text.Encoding]::Unicode) }",
481
+ " elseif ($__enc -eq \"utf16be\") { [System.IO.File]::WriteAllText($__p, $__out, [System.Text.Encoding]::BigEndianUnicode) }",
482
+ " else { [System.IO.File]::WriteAllText($__p, $__out, (New-Object System.Text.UTF8Encoding($false))) }",
483
+ " $__basis = $null",
484
+ " if (" + basisGuard + ") {",
485
+ " $__basis = [pscustomobject]@{",
486
+ " before = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($__text))",
487
+ " after = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($__out))",
488
+ " }",
489
+ " }",
490
+ " $__json = [pscustomobject]@{",
491
+ " path = $__p",
492
+ " encoding = $__enc",
493
+ " replacements = $__count",
494
+ " size = [long](Get-Item -LiteralPath $__p).Length",
495
+ " basis = $__basis",
496
+ " } | ConvertTo-Json -Compress -Depth 3",
497
+ "} catch { $__err = $_.Exception.GetBaseException().Message }",
498
+ "if ($null -ne $__err) { [pscustomobject]@{ error = $__err } | ConvertTo-Json -Compress }",
499
+ "else { $__json }",
500
+ ""
501
+ ].join("\r\n");
502
+ }
503
+ /**
504
+ * JSON: the whole remote text file as base64, or a refusal when it is missing
505
+ * or larger than `maxBytes`.
506
+ *
507
+ * The write tool needs the previous text to project a diff card, and the line
508
+ * window reader cannot serve that: it numbers lines and caps each one, so a
509
+ * long line would silently truncate inside the diff basis. The BOM detection
510
+ * repeats {@link psEditText}'s on purpose — both must decode identically, and a
511
+ * shared emitter would hide that requirement behind a helper neither could be
512
+ * reviewed against.
513
+ *
514
+ * Failures are reported as `{"error":…}` with exit code 0, so the caller always
515
+ * receives parseable JSON.
516
+ * @param p - remote path.
517
+ * @param maxBytes - largest file whose text is returned.
518
+ * @returns the PowerShell fragment.
519
+ */
520
+ function psReadTextBasis(p, maxBytes) {
521
+ return [
522
+ "$__p = " + sq(p),
523
+ "$__err = $null",
524
+ "$__json = $null",
525
+ "try {",
526
+ " $__i = Get-Item -LiteralPath $__p -ErrorAction SilentlyContinue",
527
+ " if ($null -eq $__i -or $__i.PSIsContainer) { throw ('remote file not found: ' + $__p) }",
528
+ ` if ([long]$__i.Length -gt ${maxBytes}) {`,
529
+ " $__json = [pscustomobject]@{ tooLarge = $true; encoding = $null; text = $null } | ConvertTo-Json -Compress",
530
+ " } else {",
531
+ " $__fs = [System.IO.File]::OpenRead($__p)",
532
+ " try {",
533
+ " $__b0 = New-Object byte[] 3",
534
+ " $__n0 = $__fs.Read($__b0, 0, 3)",
535
+ " $__fs.Position = 0",
536
+ " $__enc = 'utf8'",
537
+ " $__coding = New-Object System.Text.UTF8Encoding($false, $true)",
538
+ " if ($__n0 -ge 2 -and $__b0[0] -eq 255 -and $__b0[1] -eq 254) {",
539
+ " $__enc = 'utf16le'",
540
+ " $__coding = [System.Text.Encoding]::Unicode",
541
+ " } elseif ($__n0 -ge 2 -and $__b0[0] -eq 254 -and $__b0[1] -eq 255) {",
542
+ " $__enc = 'utf16be'",
543
+ " $__coding = [System.Text.Encoding]::BigEndianUnicode",
544
+ " } elseif ($__n0 -ge 3 -and $__b0[0] -eq 239 -and $__b0[1] -eq 187 -and $__b0[2] -eq 191) {",
545
+ " $__enc = 'utf8bom'",
546
+ " }",
547
+ " $__r = New-Object System.IO.StreamReader($__fs, $__coding, $true)",
548
+ " try { $__text = $__r.ReadToEnd() } finally { $__r.Dispose() }",
549
+ " } finally { $__fs.Dispose() }",
550
+ " $__json = [pscustomobject]@{",
551
+ " tooLarge = $false",
552
+ " encoding = $__enc",
553
+ " text = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($__text))",
554
+ " } | ConvertTo-Json -Compress -Depth 3",
555
+ " }",
556
+ "} catch { $__err = $_.Exception.GetBaseException().Message }",
557
+ "if ($null -ne $__err) { [pscustomobject]@{ error = $__err } | ConvertTo-Json -Compress }",
558
+ "else { $__json }",
559
+ ""
560
+ ].join("\r\n");
561
+ }
325
562
  /** Magic stamped into the frame header that precedes every stream chunk. */
326
563
  const STREAM_FRAME_MAGIC = "DSHZ";
327
564
  /** Remote-side buffer used while draining stdin (or inflating a frame) into the file. */
@@ -1663,14 +1900,125 @@ var ConsoleSession = class {
1663
1900
  this.onExit?.(code, error);
1664
1901
  }
1665
1902
  };
1903
+ /**
1904
+ * Largest file whose text may serve as a diff basis. Above this the card shows
1905
+ * the path and operation without hunks: fetching and projecting a bigger basis
1906
+ * would cost another transfer and a large metadata payload for a summary the
1907
+ * user reads at a glance.
1908
+ */
1909
+ const DIFF_BASIS_MAX_BYTES = 256 * 1024;
1910
+ /** Split text into lines, treating every terminator as a separator. */
1911
+ function toLines(text) {
1912
+ return text === "" ? [] : text.split(/\r?\n/);
1913
+ }
1914
+ /**
1915
+ * Compute the hunks between two texts.
1916
+ * @param path - remote path stamped on every hunk.
1917
+ * @param before - file text before the change.
1918
+ * @param after - file text after the change.
1919
+ * @returns one hunk per contiguous change in file order; empty when equal.
1920
+ */
1921
+ function computeHunkDiffs(path, before, after) {
1922
+ if (before === after) return [];
1923
+ const oldLines = toLines(before);
1924
+ const newLines = toLines(after);
1925
+ let head = 0;
1926
+ while (head < oldLines.length && head < newLines.length && oldLines[head] === newLines[head]) head += 1;
1927
+ let tail = 0;
1928
+ while (tail < oldLines.length - head && tail < newLines.length - head && oldLines[oldLines.length - 1 - tail] === newLines[newLines.length - 1 - tail]) tail += 1;
1929
+ const oldMid = oldLines.slice(head, oldLines.length - tail);
1930
+ const newMid = newLines.slice(head, newLines.length - tail);
1931
+ /** Build one hunk around the changed range `[from, to)` in the middle. */
1932
+ const emit = (from, to) => {
1933
+ const start = Math.max(0, from - 3);
1934
+ const stop = Math.min(oldMid.length, to + 3);
1935
+ return {
1936
+ path,
1937
+ oldText: oldLines.slice(head + start, head + stop).join("\n"),
1938
+ newText: newLines.slice(head + start, head + stop).join("\n")
1939
+ };
1940
+ };
1941
+ if (oldMid.length !== newMid.length) return [{
1942
+ path,
1943
+ oldText: oldLines.slice(Math.max(0, head - 3), oldLines.length - Math.max(0, tail - 3)).join("\n"),
1944
+ newText: newLines.slice(Math.max(0, head - 3), newLines.length - Math.max(0, tail - 3)).join("\n")
1945
+ }];
1946
+ const diffs = [];
1947
+ let index = 0;
1948
+ while (index < oldMid.length) {
1949
+ if (oldMid[index] === newMid[index]) {
1950
+ index += 1;
1951
+ continue;
1952
+ }
1953
+ const from = index;
1954
+ while (index < oldMid.length && oldMid[index] !== newMid[index]) index += 1;
1955
+ diffs.push(emit(from, index));
1956
+ }
1957
+ return diffs;
1958
+ }
1959
+ /**
1960
+ * Compute hunks and drop them when the serialized payload would exceed the cap.
1961
+ * @param path - remote path stamped on every hunk.
1962
+ * @param before - prior text.
1963
+ * @param after - text after the change.
1964
+ * @returns the kept hunks; empty when nothing changed or the payload is too big.
1965
+ */
1966
+ function boundedHunkDiffs(path, before, after) {
1967
+ const diffs = computeHunkDiffs(path, before, after);
1968
+ if (diffs.length === 0) return diffs;
1969
+ return JSON.stringify(diffs).length > 524288 ? [] : diffs;
1970
+ }
1666
1971
  //#endregion
1667
1972
  //#region src/engine.ts
1973
+ /** Longest single line the reader returns before cutting it (characters). */
1974
+ const READ_MAX_LINE_LENGTH = 2e3;
1975
+ /** Lines the reader returns when the caller does not choose a page size. */
1976
+ const READ_DEFAULT_LIMIT = 2e3;
1977
+ /** Most lines the reader will return in one call. */
1978
+ const READ_MAX_LIMIT = 5e3;
1979
+ /** Byte budget for one read window; keeps the envelope well under the transport limit. */
1980
+ const READ_MAX_BYTES = 512 * 1024;
1668
1981
  /** JSON-safe parse of the envelope output into a typed value. */
1669
1982
  function parseJson(text) {
1670
1983
  const trimmed = text.trim();
1671
1984
  if (trimmed === "" || trimmed === "null") return [];
1672
1985
  return JSON.parse(trimmed);
1673
1986
  }
1987
+ /**
1988
+ * Parse one JSON object out of an envelope's stdout.
1989
+ *
1990
+ * The file tools report their own failures inside the JSON (`{"error":…}`)
1991
+ * with exit code 0, so an unparseable body means the fragment never ran;
1992
+ * returning undefined lets the caller say so instead of throwing a
1993
+ * `SyntaxError` with no context.
1994
+ * @param text - envelope stdout.
1995
+ * @returns the parsed object, or undefined when the body is empty or not JSON.
1996
+ */
1997
+ function parseObject(text) {
1998
+ const trimmed = text.trim();
1999
+ if (trimmed === "" || trimmed === "null") return void 0;
2000
+ if (!trimmed.startsWith("{")) return void 0;
2001
+ try {
2002
+ return JSON.parse(trimmed);
2003
+ } catch {
2004
+ return;
2005
+ }
2006
+ }
2007
+ /** Mark an over-long line the reader cut, so the model knows it is incomplete. */
2008
+ function annotateCutLine(text) {
2009
+ return text.length > 2e3 ? `${text.slice(0, READ_MAX_LINE_LENGTH)}… (line truncated to ${READ_MAX_LINE_LENGTH} chars)` : text;
2010
+ }
2011
+ /** Encode text for a remote write, applying the requested byte-order mark. */
2012
+ function encodeText(content, encoding) {
2013
+ const body = Buffer.from(content, "utf8");
2014
+ if (encoding === "utf8bom") return Buffer.concat([Buffer.from([
2015
+ 239,
2016
+ 187,
2017
+ 191
2018
+ ]), body]);
2019
+ if (encoding === "utf16le") return Buffer.concat([Buffer.from([255, 254]), Buffer.from(content, "utf16le")]);
2020
+ return body;
2021
+ }
1674
2022
  /** A tiny per-alias in-flight limiter so GUI bursts do not pile up sockets. */
1675
2023
  var AliasGate = class {
1676
2024
  queues = /* @__PURE__ */ new Map();
@@ -1844,6 +2192,119 @@ var WinRmEngine = class {
1844
2192
  }));
1845
2193
  });
1846
2194
  }
2195
+ /**
2196
+ * Read a bounded, line-numbered window of a remote text file.
2197
+ *
2198
+ * One PowerShell round trip: the remote side decodes the file, counts every
2199
+ * line, and returns only the requested window, so a large log costs one
2200
+ * transfer of the window rather than of the file.
2201
+ * @param alias - configured host.
2202
+ * @param remotePath - path on the target.
2203
+ * @param offset - 1-based first line to return.
2204
+ * @param limit - maximum number of lines to return.
2205
+ * @returns the window plus the file's total line count and encoding.
2206
+ */
2207
+ async readFile(alias, remotePath, offset, limit) {
2208
+ const entry = this.store.find(alias);
2209
+ if (entry === void 0) throw new Error(`alias '${alias}' not found`);
2210
+ return this.gate.run(alias, async () => {
2211
+ const result = await runScript(connOf(entry), psReadTextWindow(remotePath, offset, limit, READ_MAX_LINE_LENGTH, READ_MAX_BYTES), { timeoutMs: 12e4 });
2212
+ const payload = parseObject(result.stdout);
2213
+ if (payload === void 0) throw new Error(result.stderr.trim() || result.stdout.trim() || `failed to read '${remotePath}'`);
2214
+ if (payload.error !== void 0) 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)`);
2215
+ if (payload.exists !== true) throw new Error(`remote file not found: ${remotePath}`);
2216
+ const totalLines = payload.totalLines ?? 0;
2217
+ const lineCount = payload.lineCount ?? 0;
2218
+ if (payload.truncated !== true && offset > totalLines && totalLines > 0) throw new Error(`offset ${offset} is out of range for '${remotePath}' (${totalLines} lines)`);
2219
+ const texts = lineCount === 0 ? [] : (payload.linesText ?? "").split("\n");
2220
+ return {
2221
+ path: remotePath,
2222
+ offset,
2223
+ lines: texts.map((text, index) => ({
2224
+ number: offset + index,
2225
+ text: annotateCutLine(text)
2226
+ })),
2227
+ totalLines,
2228
+ encoding: payload.encoding ?? "utf8",
2229
+ truncatedLines: texts.filter((text) => text.length > READ_MAX_LINE_LENGTH).length,
2230
+ truncatedByBytes: payload.truncatedByBytes === true
2231
+ };
2232
+ });
2233
+ }
2234
+ /**
2235
+ * Write a whole remote text file, creating parent directories as needed.
2236
+ *
2237
+ * Reuses the transfer transport (single streamed stdin when available, the
2238
+ * 48 KiB chunk path otherwise) so a large write does not pay one shell cycle
2239
+ * per chunk.
2240
+ * @param alias - configured host.
2241
+ * @param remotePath - path on the target; overwritten when it exists.
2242
+ * @param content - UTF-8 text to write.
2243
+ * @param encoding - byte-order mark to emit (`utf8`, `utf8bom`, `utf16le`).
2244
+ * @returns the operation kind, byte count, encoding written, and both texts
2245
+ * the diff card compares.
2246
+ */
2247
+ async writeFile(alias, remotePath, content, encoding) {
2248
+ const entry = this.store.find(alias);
2249
+ if (entry === void 0) throw new Error(`alias '${alias}' not found`);
2250
+ const data = encodeText(content, encoding);
2251
+ return this.gate.run(alias, async () => {
2252
+ const conn = connOf(entry);
2253
+ const previous = await remoteFileSize(conn, remotePath);
2254
+ let before = null;
2255
+ if (previous !== null && previous <= 262144) {
2256
+ const payload = parseObject((await runScript(conn, psReadTextBasis(remotePath, DIFF_BASIS_MAX_BYTES), { timeoutMs: 6e4 })).stdout);
2257
+ if (payload !== void 0 && payload.error === void 0 && payload.tooLarge !== true && typeof payload.text === "string") before = Buffer.from(payload.text, "base64").toString("utf8");
2258
+ }
2259
+ let bytes = await uploadStream(conn, remotePath, data);
2260
+ if (bytes === null) bytes = await uploadBuffer(conn, remotePath, data);
2261
+ return {
2262
+ path: remotePath,
2263
+ operation: previous === null ? "create" : "update",
2264
+ bytes,
2265
+ encoding,
2266
+ before,
2267
+ after: content
2268
+ };
2269
+ });
2270
+ }
2271
+ /**
2272
+ * Replace `oldString` with `newString` inside a remote text file.
2273
+ *
2274
+ * The replacement is literal, never a pattern, and the file's existing
2275
+ * encoding is preserved. Without `replaceAll` the edit is refused unless the
2276
+ * needle is unique, so a stale needle cannot rewrite the wrong region.
2277
+ * @param alias - configured host.
2278
+ * @param remotePath - path on the target.
2279
+ * @param oldString - exact text to find.
2280
+ * @param newString - text to substitute.
2281
+ * @param replaceAll - replace every occurrence instead of requiring one.
2282
+ * @returns the replacement count, the file's new size, and both texts the
2283
+ * diff card compares.
2284
+ */
2285
+ async editFile(alias, remotePath, oldString, newString, replaceAll) {
2286
+ const entry = this.store.find(alias);
2287
+ if (entry === void 0) throw new Error(`alias '${alias}' not found`);
2288
+ if (oldString === "") throw new Error("oldString must be a non-empty string");
2289
+ if (oldString === newString) throw new Error("oldString and newString are identical; nothing to change");
2290
+ const oldB64 = Buffer.from(oldString, "utf8").toString("base64");
2291
+ const newB64 = Buffer.from(newString, "utf8").toString("base64");
2292
+ return this.gate.run(alias, async () => {
2293
+ const result = await runScript(connOf(entry), psEditText(remotePath, oldB64, newB64, replaceAll, DIFF_BASIS_MAX_BYTES), { timeoutMs: 12e4 });
2294
+ const payload = parseObject(result.stdout);
2295
+ if (payload === void 0) throw new Error(result.stderr.trim() || result.stdout.trim() || `failed to edit '${remotePath}'`);
2296
+ if (payload.error !== void 0) throw new Error(payload.error);
2297
+ const basis = payload.basis ?? null;
2298
+ return {
2299
+ path: payload.path ?? remotePath,
2300
+ encoding: payload.encoding ?? "utf8",
2301
+ replacements: payload.replacements ?? 0,
2302
+ size: payload.size ?? 0,
2303
+ before: basis === null ? null : Buffer.from(basis.before ?? "", "base64").toString("utf8"),
2304
+ after: basis === null ? null : Buffer.from(basis.after ?? "", "base64").toString("utf8")
2305
+ };
2306
+ });
2307
+ }
1847
2308
  /** Upload one local file to a remote path. Defaults to SMB with WinRM fallback. */
1848
2309
  async upload(alias, localPath, remotePath, onProgress, channel = "auto") {
1849
2310
  const entry = this.store.find(alias);
@@ -3373,6 +3834,368 @@ function winrmClusterTool(engine) {
3373
3834
  }
3374
3835
  });
3375
3836
  }
3837
+ /** Render a read window in the numbered envelope the local read tool uses. */
3838
+ function renderWindow(window) {
3839
+ const lines = window.lines ?? [];
3840
+ const offset = window.offset ?? 1;
3841
+ const total = window.totalLines ?? 0;
3842
+ const last = offset + lines.length - 1;
3843
+ const body = lines.map((line) => `${line.number}: ${line.text}`).join("\n");
3844
+ const encoding = window.encoding !== void 0 && window.encoding !== "utf8" ? `, encoding ${window.encoding}` : "";
3845
+ let footer;
3846
+ if (lines.length === 0) footer = total === 0 ? `(End of file - total 0 lines${encoding})` : `(No lines at offset ${offset}; the file has ${total} lines${encoding}.)`;
3847
+ else if (window.truncatedByBytes === true) footer = `(Showing lines ${offset}-${last} of ${total}${encoding}. The window stopped at the byte budget — continue with offset=${last + 1}.)`;
3848
+ else if (last < total) footer = `(Showing lines ${offset}-${last} of ${total}${encoding}. Continue with offset=${last + 1}.)`;
3849
+ else footer = `(End of file - total ${total} lines${encoding})`;
3850
+ const content = body === "" ? footer : `${body}\n\n${footer}`;
3851
+ return [
3852
+ `<path>${window.path ?? ""}</path>`,
3853
+ "<type>file</type>",
3854
+ "<content>",
3855
+ content,
3856
+ "</content>"
3857
+ ].join("\n");
3858
+ }
3859
+ /** The remote read tool: a line-numbered window of a text file on the target. */
3860
+ function winrmReadTool(engine) {
3861
+ return defineTool({
3862
+ name: "winrm_read",
3863
+ 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. Triggers: read file on Windows server, inspect remote config/log/script.",
3864
+ parameters: {
3865
+ alias: {
3866
+ type: "string",
3867
+ required: true,
3868
+ description: "Host alias from winrm_list."
3869
+ },
3870
+ remotePath: {
3871
+ type: "string",
3872
+ required: true,
3873
+ description: "Path on the remote host, e.g. C:\\temp\\app.log."
3874
+ },
3875
+ offset: {
3876
+ type: "integer",
3877
+ description: "1-based first line to return. Defaults to 1."
3878
+ },
3879
+ limit: {
3880
+ type: "integer",
3881
+ description: `Maximum lines to return (default ${READ_DEFAULT_LIMIT}, max ${READ_MAX_LIMIT}).`
3882
+ }
3883
+ },
3884
+ output: {
3885
+ schema: {
3886
+ type: "object",
3887
+ additionalProperties: false,
3888
+ properties: {
3889
+ ok: {
3890
+ type: "boolean",
3891
+ required: true
3892
+ },
3893
+ path: { type: "string" },
3894
+ offset: { type: "integer" },
3895
+ lines: {
3896
+ type: "array",
3897
+ items: {
3898
+ type: "object",
3899
+ additionalProperties: false,
3900
+ properties: {
3901
+ number: {
3902
+ type: "integer",
3903
+ required: true
3904
+ },
3905
+ text: {
3906
+ type: "string",
3907
+ required: true
3908
+ }
3909
+ }
3910
+ }
3911
+ },
3912
+ totalLines: { type: "integer" },
3913
+ encoding: { type: "string" },
3914
+ truncatedByBytes: { type: "boolean" },
3915
+ truncatedLines: { type: "integer" },
3916
+ error: { type: "string" }
3917
+ }
3918
+ },
3919
+ render: (_args, value) => text(value.ok ? renderWindow(value) : `read failed: ${value.error ?? "unknown error"}`),
3920
+ presentationMeta: (args, value) => value.ok ? {
3921
+ card: "winrm-read",
3922
+ path: value.path ?? args.remotePath,
3923
+ offset: value.offset ?? 1,
3924
+ lines: value.lines ?? [],
3925
+ totalLines: value.totalLines ?? 0,
3926
+ encoding: value.encoding ?? "utf8",
3927
+ truncatedByBytes: value.truncatedByBytes === true,
3928
+ truncatedLines: value.truncatedLines ?? 0,
3929
+ error: ""
3930
+ } : {
3931
+ card: "winrm-read",
3932
+ path: args.remotePath,
3933
+ offset: 0,
3934
+ lines: [],
3935
+ totalLines: 0,
3936
+ encoding: "",
3937
+ truncatedByBytes: false,
3938
+ truncatedLines: 0,
3939
+ error: value.error ?? "unknown error"
3940
+ }
3941
+ },
3942
+ async execute(args) {
3943
+ const offset = Math.max(1, Math.trunc(args.offset ?? 1));
3944
+ const limit = Math.min(READ_MAX_LIMIT, Math.max(1, Math.trunc(args.limit ?? 2e3)));
3945
+ try {
3946
+ return {
3947
+ ok: true,
3948
+ ...await engine.readFile(args.alias, args.remotePath, offset, limit)
3949
+ };
3950
+ } catch (error) {
3951
+ return {
3952
+ ok: false,
3953
+ error: error instanceof Error ? error.message : String(error)
3954
+ };
3955
+ }
3956
+ }
3957
+ });
3958
+ }
3959
+ /** The remote write tool: replace a whole text file on the target. */
3960
+ function winrmWriteTool(engine) {
3961
+ return defineTool({
3962
+ name: "winrm_write",
3963
+ 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. Triggers: write file on Windows server, create remote script/config, overwrite remote file.",
3964
+ parameters: {
3965
+ alias: {
3966
+ type: "string",
3967
+ required: true,
3968
+ description: "Host alias from winrm_list."
3969
+ },
3970
+ remotePath: {
3971
+ type: "string",
3972
+ required: true,
3973
+ description: "Destination path on the remote host, e.g. C:\\temp\\script.ps1 (parent dirs are created)."
3974
+ },
3975
+ content: {
3976
+ type: "string",
3977
+ required: true,
3978
+ description: "Full file content."
3979
+ },
3980
+ encoding: {
3981
+ type: "string",
3982
+ enum: [
3983
+ "utf8",
3984
+ "utf8bom",
3985
+ "utf16le"
3986
+ ],
3987
+ 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."
3988
+ }
3989
+ },
3990
+ output: {
3991
+ schema: {
3992
+ type: "object",
3993
+ additionalProperties: false,
3994
+ properties: {
3995
+ ok: {
3996
+ type: "boolean",
3997
+ required: true
3998
+ },
3999
+ path: { type: "string" },
4000
+ operation: {
4001
+ type: "string",
4002
+ enum: ["create", "update"]
4003
+ },
4004
+ bytes: { type: "integer" },
4005
+ encoding: {
4006
+ type: "string",
4007
+ enum: [
4008
+ "utf8",
4009
+ "utf8bom",
4010
+ "utf16le"
4011
+ ]
4012
+ },
4013
+ diffs: {
4014
+ type: "array",
4015
+ items: {
4016
+ type: "object",
4017
+ additionalProperties: false,
4018
+ properties: {
4019
+ path: {
4020
+ type: "string",
4021
+ required: true
4022
+ },
4023
+ oldText: {
4024
+ type: "string",
4025
+ required: true
4026
+ },
4027
+ newText: {
4028
+ type: "string",
4029
+ required: true
4030
+ }
4031
+ }
4032
+ }
4033
+ },
4034
+ beyondBasis: { type: "boolean" },
4035
+ error: { type: "string" }
4036
+ }
4037
+ },
4038
+ render: (_args, value) => text(value.ok ? [
4039
+ `<path>${value.path ?? ""}</path>`,
4040
+ "<type>file</type>",
4041
+ "<content>",
4042
+ `${value.operation === "update" ? "Updated" : "Created"} file (${value.bytes ?? 0} bytes)`,
4043
+ "</content>"
4044
+ ].join("\n") : `write failed: ${value.error ?? "unknown error"}`),
4045
+ presentationMeta: (args, value) => value.ok ? {
4046
+ card: "winrm-write",
4047
+ path: value.path ?? args.remotePath,
4048
+ operation: value.operation ?? "update",
4049
+ bytes: value.bytes ?? 0,
4050
+ encoding: value.encoding ?? "utf8",
4051
+ diffs: value.diffs ?? [],
4052
+ beyondBasis: value.beyondBasis === true,
4053
+ error: ""
4054
+ } : {
4055
+ card: "winrm-write",
4056
+ path: args.remotePath,
4057
+ operation: "update",
4058
+ bytes: 0,
4059
+ encoding: "",
4060
+ diffs: [],
4061
+ beyondBasis: false,
4062
+ error: value.error ?? "unknown error"
4063
+ }
4064
+ },
4065
+ async execute(args) {
4066
+ try {
4067
+ const outcome = await engine.writeFile(args.alias, args.remotePath, args.content, args.encoding ?? "utf8");
4068
+ const diffs = outcome.before !== null ? boundedHunkDiffs(outcome.path, outcome.before, outcome.after) : outcome.operation === "create" ? boundedHunkDiffs(outcome.path, "", outcome.after) : [];
4069
+ return {
4070
+ ok: true,
4071
+ path: outcome.path,
4072
+ operation: outcome.operation,
4073
+ bytes: outcome.bytes,
4074
+ encoding: outcome.encoding,
4075
+ diffs,
4076
+ beyondBasis: outcome.operation === "update" && outcome.before === null
4077
+ };
4078
+ } catch (error) {
4079
+ return {
4080
+ ok: false,
4081
+ error: error instanceof Error ? error.message : String(error)
4082
+ };
4083
+ }
4084
+ }
4085
+ });
4086
+ }
4087
+ /** The remote edit tool: replace an exact string inside a file on the target. */
4088
+ function winrmEditTool(engine) {
4089
+ return defineTool({
4090
+ name: "winrm_edit",
4091
+ 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. Triggers: edit file on Windows server, patch remote config, change remote script.",
4092
+ parameters: {
4093
+ alias: {
4094
+ type: "string",
4095
+ required: true,
4096
+ description: "Host alias from winrm_list."
4097
+ },
4098
+ remotePath: {
4099
+ type: "string",
4100
+ required: true,
4101
+ description: "Path on the remote host, e.g. C:\\temp\\app.config."
4102
+ },
4103
+ oldString: {
4104
+ type: "string",
4105
+ required: true,
4106
+ description: "Exact text to replace. Must not be empty."
4107
+ },
4108
+ newString: {
4109
+ type: "string",
4110
+ required: true,
4111
+ description: "Replacement text. Must differ from oldString."
4112
+ },
4113
+ replaceAll: {
4114
+ type: "boolean",
4115
+ description: "Replace every occurrence instead of requiring exactly one. Defaults to false."
4116
+ }
4117
+ },
4118
+ output: {
4119
+ schema: {
4120
+ type: "object",
4121
+ additionalProperties: false,
4122
+ properties: {
4123
+ ok: {
4124
+ type: "boolean",
4125
+ required: true
4126
+ },
4127
+ path: { type: "string" },
4128
+ encoding: { type: "string" },
4129
+ replacements: { type: "integer" },
4130
+ size: { type: "integer" },
4131
+ diffs: {
4132
+ type: "array",
4133
+ items: {
4134
+ type: "object",
4135
+ additionalProperties: false,
4136
+ properties: {
4137
+ path: {
4138
+ type: "string",
4139
+ required: true
4140
+ },
4141
+ oldText: {
4142
+ type: "string",
4143
+ required: true
4144
+ },
4145
+ newText: {
4146
+ type: "string",
4147
+ required: true
4148
+ }
4149
+ }
4150
+ }
4151
+ },
4152
+ beyondBasis: { type: "boolean" },
4153
+ error: { type: "string" }
4154
+ }
4155
+ },
4156
+ render: (_args, value) => text(value.ok ? (value.replacements ?? 0) > 1 ? `The file ${value.path ?? ""} has been updated. All ${value.replacements ?? 0} occurrences were successfully replaced.` : `The file ${value.path ?? ""} has been updated successfully.` : `edit failed: ${value.error ?? "unknown error"}`),
4157
+ presentationMeta: (args, value) => value.ok ? {
4158
+ card: "winrm-edit",
4159
+ path: value.path ?? args.remotePath,
4160
+ replacements: value.replacements ?? 0,
4161
+ size: value.size ?? 0,
4162
+ encoding: value.encoding ?? "utf8",
4163
+ diffs: value.diffs ?? [],
4164
+ beyondBasis: value.beyondBasis === true,
4165
+ error: ""
4166
+ } : {
4167
+ card: "winrm-edit",
4168
+ path: args.remotePath,
4169
+ replacements: 0,
4170
+ size: 0,
4171
+ encoding: "",
4172
+ diffs: [],
4173
+ beyondBasis: false,
4174
+ error: value.error ?? "unknown error"
4175
+ }
4176
+ },
4177
+ async execute(args) {
4178
+ try {
4179
+ const outcome = await engine.editFile(args.alias, args.remotePath, args.oldString, args.newString, args.replaceAll ?? false);
4180
+ const diffs = outcome.before !== null && outcome.after !== null ? boundedHunkDiffs(outcome.path, outcome.before, outcome.after) : [];
4181
+ return {
4182
+ ok: true,
4183
+ path: outcome.path,
4184
+ encoding: outcome.encoding,
4185
+ replacements: outcome.replacements,
4186
+ size: outcome.size,
4187
+ diffs,
4188
+ beyondBasis: outcome.before === null
4189
+ };
4190
+ } catch (error) {
4191
+ return {
4192
+ ok: false,
4193
+ error: error instanceof Error ? error.message : String(error)
4194
+ };
4195
+ }
4196
+ }
4197
+ });
4198
+ }
3376
4199
  //#endregion
3377
4200
  //#region src/mount-once.ts
3378
4201
  /**
@@ -3435,7 +4258,7 @@ const DEFAULT_ENABLED = true;
3435
4258
  /** Order of the announcement section within the tool-guidance band. */
3436
4259
  const SECTION_ORDER = 151;
3437
4260
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
3438
- 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 远程 / 服务管理 / 进程管理」时即指本插件,请据此协作。";
4261
+ 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 文件传输(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 远程 / 服务管理 / 进程管理」时即指本插件,请据此协作。";
3439
4262
  /**
3440
4263
  * Mount the WinRM engine, routes, tools, and announcement.
3441
4264
  * @param ctx - host plugin context carrying webServer/tools/systemPrompt.
@@ -3458,6 +4281,9 @@ function applyImpl(ctx, config) {
3458
4281
  winrmExecTool(engine),
3459
4282
  winrmServiceTool(engine),
3460
4283
  winrmProcessTool(engine),
4284
+ winrmReadTool(engine),
4285
+ winrmWriteTool(engine),
4286
+ winrmEditTool(engine),
3461
4287
  winrmUploadTool(engine),
3462
4288
  winrmDownloadTool(engine),
3463
4289
  winrmClusterTool(engine)