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/README.en.md +132 -0
- package/README.md +20 -7
- package/docs/screenshots/winrm-panel.png +0 -0
- package/lib/client.js +597 -73
- package/lib/index.js +898 -26
- package/lib/types/client/locales.d.ts +44 -0
- package/lib/types/client/tool/card-model.d.ts +71 -0
- package/lib/types/client/tool/cards.d.ts +9 -0
- package/lib/types/diff.d.ts +62 -0
- package/lib/types/engine/client.d.ts +30 -4
- package/lib/types/engine.d.ts +51 -1
- package/lib/types/index.d.ts +4 -3
- package/lib/types/powershell.d.ts +64 -0
- package/lib/types/protocol.d.ts +54 -0
- package/lib/types/tools.d.ts +7 -1
- package/package.json +8 -4
- package/scripts/verify-live.mts +116 -0
- package/src/client/index.ts +17 -0
- package/src/client/locales.ts +44 -0
- package/src/client/tool/card-model.ts +151 -0
- package/src/client/tool/cards.module.css +128 -0
- package/src/client/tool/cards.tsx +335 -0
- package/src/diff.ts +128 -0
- package/src/engine/client.ts +66 -16
- package/src/engine.ts +222 -5
- package/src/index.ts +10 -3
- package/src/powershell.ts +253 -0
- package/src/protocol.ts +59 -0
- package/src/smb.ts +4 -1
- package/src/tools.ts +303 -4
- package/README.zh.md +0 -126
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. */
|
|
@@ -1070,6 +1307,41 @@ const STREAM_GZIP_MIN_RATIO = .85;
|
|
|
1070
1307
|
/** Per-Send HTTP budget; a single slice is small, so this only bounds a stalled connection. */
|
|
1071
1308
|
const STREAM_SEND_TIMEOUT_MS = 12e4;
|
|
1072
1309
|
/**
|
|
1310
|
+
* How long a streaming transfer may deliver nothing before it is treated as
|
|
1311
|
+
* stalled.
|
|
1312
|
+
*
|
|
1313
|
+
* A wall-clock cap on the whole transfer cannot work here: this transport is
|
|
1314
|
+
* latency-bound and measured at ~0.73 MiB/s against a real target, so a 700 MiB
|
|
1315
|
+
* file legitimately needs about sixteen minutes and any smaller fixed cap fails
|
|
1316
|
+
* a transfer that is making steady progress. What a transfer actually needs
|
|
1317
|
+
* bounding against is a *stall* — a range that has stopped producing bytes —
|
|
1318
|
+
* which is what this idle window measures.
|
|
1319
|
+
*/
|
|
1320
|
+
const STREAM_IDLE_TIMEOUT_MS = 18e4;
|
|
1321
|
+
/**
|
|
1322
|
+
* Floor used only to size the runaway backstop, never as a throughput target.
|
|
1323
|
+
* Deliberately pessimistic (64 KiB/s): it must not fail a slow link, only a
|
|
1324
|
+
* transfer that is somehow still "busy" hours after it should have finished.
|
|
1325
|
+
*/
|
|
1326
|
+
const STREAM_MIN_BYTES_PER_SEC = 65536;
|
|
1327
|
+
/**
|
|
1328
|
+
* Budget for one streaming transfer of `bytes` bytes.
|
|
1329
|
+
*
|
|
1330
|
+
* The idle window is the effective limit; the hard window exists so a wedged
|
|
1331
|
+
* transfer cannot stay alive forever, and it scales with the payload because
|
|
1332
|
+
* the payload is what sets the legitimate duration.
|
|
1333
|
+
* @param bytes - total bytes the transfer must move.
|
|
1334
|
+
* @param idleMs - how long the transfer may go without delivering anything.
|
|
1335
|
+
* @returns the idle window and the absolute backstop, both in milliseconds.
|
|
1336
|
+
*/
|
|
1337
|
+
function transferBudget(bytes, idleMs = STREAM_IDLE_TIMEOUT_MS) {
|
|
1338
|
+
const optimisticMs = Math.ceil(Math.max(0, bytes) / STREAM_MIN_BYTES_PER_SEC) * 1e3;
|
|
1339
|
+
return {
|
|
1340
|
+
idleMs,
|
|
1341
|
+
hardMs: Math.max(idleMs * 4, optimisticMs + idleMs)
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
/**
|
|
1073
1345
|
* One streaming transfer over a single shell: create → run the receiver →
|
|
1074
1346
|
* push the receiver script, the header frame and every data frame down stdin →
|
|
1075
1347
|
* receive the envelope → delete the shell.
|
|
@@ -1081,13 +1353,13 @@ const STREAM_SEND_TIMEOUT_MS = 12e4;
|
|
|
1081
1353
|
* @param remotePath - destination path on the target.
|
|
1082
1354
|
* @param data - the file contents to send.
|
|
1083
1355
|
* @param onProgress - called with cumulative bytes after each frame.
|
|
1084
|
-
* @param
|
|
1356
|
+
* @param budget - idle window and absolute backstop for the transfer.
|
|
1085
1357
|
* @param authMethod - the auth scheme for this attempt.
|
|
1086
1358
|
* @param sendHttp - the resolved library send function.
|
|
1087
1359
|
* @returns the number of file bytes the receiver acknowledged writing.
|
|
1088
1360
|
* @throws WinrmTimedOut when the deadline passes; other errors are transport failures.
|
|
1089
1361
|
*/
|
|
1090
|
-
async function streamOnce(conn, remotePath, data, onProgress,
|
|
1362
|
+
async function streamOnce(conn, remotePath, data, onProgress, budget, authMethod, sendHttp) {
|
|
1091
1363
|
const base = baseParams(conn, authMethod);
|
|
1092
1364
|
const target = {
|
|
1093
1365
|
host: conn.host,
|
|
@@ -1099,7 +1371,9 @@ async function streamOnce(conn, remotePath, data, onProgress, timeoutMs, authMet
|
|
|
1099
1371
|
useHttps: conn.useHttps ?? false,
|
|
1100
1372
|
rejectUnauthorized: conn.rejectUnauthorized ?? true
|
|
1101
1373
|
};
|
|
1102
|
-
const hardDeadline = Date.now() +
|
|
1374
|
+
const hardDeadline = Date.now() + budget.hardMs + SETUP_GRACE_MS;
|
|
1375
|
+
let lastProgressAt = Date.now();
|
|
1376
|
+
const stalled = () => Date.now() - lastProgressAt >= budget.idleMs;
|
|
1103
1377
|
const state = {
|
|
1104
1378
|
shellId: void 0,
|
|
1105
1379
|
timedOut: false,
|
|
@@ -1128,7 +1402,7 @@ async function streamOnce(conn, remotePath, data, onProgress, timeoutMs, authMet
|
|
|
1128
1402
|
let slice = STREAM_CHUNK_BYTES;
|
|
1129
1403
|
let ceiling = STREAM_CHUNK_BYTES;
|
|
1130
1404
|
while (offset < total) {
|
|
1131
|
-
if (state.settled ||
|
|
1405
|
+
if (state.settled || stalled()) throw new WinrmTimedOut();
|
|
1132
1406
|
const take = Math.min(slice, total - offset);
|
|
1133
1407
|
const chunk = data.subarray(offset, offset + take);
|
|
1134
1408
|
const gz = gzipSync(chunk, { level: 6 });
|
|
@@ -1145,6 +1419,7 @@ async function streamOnce(conn, remotePath, data, onProgress, timeoutMs, authMet
|
|
|
1145
1419
|
throw error;
|
|
1146
1420
|
}
|
|
1147
1421
|
offset += take;
|
|
1422
|
+
lastProgressAt = Date.now();
|
|
1148
1423
|
onProgress?.(offset, total);
|
|
1149
1424
|
if (slice < ceiling) slice = Math.min(ceiling, slice * 2);
|
|
1150
1425
|
}
|
|
@@ -1153,15 +1428,15 @@ async function streamOnce(conn, remotePath, data, onProgress, timeoutMs, authMet
|
|
|
1153
1428
|
shellId,
|
|
1154
1429
|
commandId
|
|
1155
1430
|
};
|
|
1156
|
-
const commandDeadline = Date.now() + timeoutMs;
|
|
1157
1431
|
let stdout = "";
|
|
1158
1432
|
let stderr = "";
|
|
1159
1433
|
for (;;) {
|
|
1160
|
-
if (state.settled ||
|
|
1434
|
+
if (state.settled || stalled()) throw new WinrmTimedOut();
|
|
1161
1435
|
const chunk = await Command.doReceiveOutputNonBlocking(receive);
|
|
1162
1436
|
stdout += chunk.output;
|
|
1163
1437
|
stderr += chunk.stderr;
|
|
1164
1438
|
if (chunk.isComplete) break;
|
|
1439
|
+
if (chunk.output !== "" || chunk.stderr !== "") lastProgressAt = Date.now();
|
|
1165
1440
|
}
|
|
1166
1441
|
const parsed = parseEnvelope(stdout);
|
|
1167
1442
|
if (parsed === null) throw new Error("streaming upload did not report completion: " + (stripClixml(stderr) || stdout).trim());
|
|
@@ -1206,16 +1481,17 @@ async function streamOnce(conn, remotePath, data, onProgress, timeoutMs, authMet
|
|
|
1206
1481
|
* @param remotePath - destination path on the target.
|
|
1207
1482
|
* @param data - the file contents to send.
|
|
1208
1483
|
* @param onProgress - called with cumulative bytes after each frame.
|
|
1209
|
-
* @param
|
|
1484
|
+
* @param idleMs - how long the transfer may go without moving a slice.
|
|
1210
1485
|
* @returns bytes written, or null when the streaming path is unavailable.
|
|
1211
1486
|
*/
|
|
1212
|
-
async function uploadStream(conn, remotePath, data, onProgress,
|
|
1487
|
+
async function uploadStream(conn, remotePath, data, onProgress, idleMs = STREAM_IDLE_TIMEOUT_MS) {
|
|
1213
1488
|
const sendHttp = loadSendHttp();
|
|
1214
1489
|
if (sendHttp === null) return null;
|
|
1490
|
+
const budget = transferBudget(data.length, idleMs);
|
|
1215
1491
|
const candidates = conn.useHttps ? ["basic", "ntlm"] : ["ntlm", "basic"];
|
|
1216
1492
|
let lastError;
|
|
1217
1493
|
for (const authMethod of candidates) try {
|
|
1218
|
-
return await streamOnce(conn, remotePath, data, onProgress,
|
|
1494
|
+
return await streamOnce(conn, remotePath, data, onProgress, budget, authMethod, sendHttp);
|
|
1219
1495
|
} catch (error) {
|
|
1220
1496
|
if (error instanceof WinrmTimedOut) throw error;
|
|
1221
1497
|
lastError = error;
|
|
@@ -1235,12 +1511,12 @@ const DOWNLOAD_RECEIVE_TIMEOUT_MS = 12e4;
|
|
|
1235
1511
|
* @param start - first byte offset of this worker's range.
|
|
1236
1512
|
* @param length - bytes this worker must deliver.
|
|
1237
1513
|
* @param onData - receives each decompressed piece together with its file offset.
|
|
1238
|
-
* @param
|
|
1514
|
+
* @param budget - idle window and absolute backstop for this worker's range.
|
|
1239
1515
|
* @param authMethod - the auth scheme for this attempt.
|
|
1240
1516
|
* @param sendHttp - the resolved library HTTP function.
|
|
1241
1517
|
* @returns the number of file bytes this worker delivered.
|
|
1242
1518
|
*/
|
|
1243
|
-
async function downloadWorkerOnce(conn, remotePath, start, length, onData,
|
|
1519
|
+
async function downloadWorkerOnce(conn, remotePath, start, length, onData, budget, authMethod, sendHttp) {
|
|
1244
1520
|
const base = baseParams(conn, authMethod);
|
|
1245
1521
|
const target = {
|
|
1246
1522
|
host: conn.host,
|
|
@@ -1252,7 +1528,9 @@ async function downloadWorkerOnce(conn, remotePath, start, length, onData, timeo
|
|
|
1252
1528
|
useHttps: conn.useHttps ?? false,
|
|
1253
1529
|
rejectUnauthorized: conn.rejectUnauthorized ?? true
|
|
1254
1530
|
};
|
|
1255
|
-
const hardDeadline = Date.now() +
|
|
1531
|
+
const hardDeadline = Date.now() + budget.hardMs + SETUP_GRACE_MS;
|
|
1532
|
+
let lastDataAt = Date.now();
|
|
1533
|
+
const stalled = () => Date.now() - lastDataAt >= budget.idleMs;
|
|
1256
1534
|
const state = {
|
|
1257
1535
|
shellId: void 0,
|
|
1258
1536
|
timedOut: false,
|
|
@@ -1287,7 +1565,7 @@ async function downloadWorkerOnce(conn, remotePath, start, length, onData, timeo
|
|
|
1287
1565
|
let stdoutEnded = false;
|
|
1288
1566
|
let exitCode;
|
|
1289
1567
|
while (!stdoutEnded) {
|
|
1290
|
-
if (state.settled ||
|
|
1568
|
+
if (state.settled || stalled()) throw new WinrmTimedOut();
|
|
1291
1569
|
const result = await receiveOutput(target, shellId, commandId, sendHttp, DOWNLOAD_RECEIVE_TIMEOUT_MS);
|
|
1292
1570
|
let sawData = false;
|
|
1293
1571
|
for (const stream of result.streams) {
|
|
@@ -1301,6 +1579,7 @@ async function downloadWorkerOnce(conn, remotePath, start, length, onData, timeo
|
|
|
1301
1579
|
if (stream.end) stdoutEnded = true;
|
|
1302
1580
|
} else stderr += stream.data.toString("utf8");
|
|
1303
1581
|
}
|
|
1582
|
+
if (sawData) lastDataAt = Date.now();
|
|
1304
1583
|
if (inflateError !== void 0) throw inflateError;
|
|
1305
1584
|
if (result.exitCode !== void 0) exitCode = result.exitCode;
|
|
1306
1585
|
if (!sawData && exitCode !== void 0) break;
|
|
@@ -1349,15 +1628,16 @@ async function downloadWorkerOnce(conn, remotePath, start, length, onData, timeo
|
|
|
1349
1628
|
* @param remotePath - file to read.
|
|
1350
1629
|
* @param onData - receives each decompressed piece with its absolute file offset.
|
|
1351
1630
|
* @param onProgress - receives cumulative bytes delivered and the total size.
|
|
1352
|
-
* @param
|
|
1631
|
+
* @param idleMs - how long a worker may go without delivering bytes.
|
|
1353
1632
|
* @returns the number of bytes delivered, or null when streaming is unavailable.
|
|
1354
1633
|
*/
|
|
1355
|
-
async function downloadStream(conn, remotePath, onData, onProgress,
|
|
1634
|
+
async function downloadStream(conn, remotePath, onData, onProgress, idleMs = STREAM_IDLE_TIMEOUT_MS) {
|
|
1356
1635
|
const sendHttp = loadSendHttp();
|
|
1357
1636
|
if (sendHttp === null) return null;
|
|
1358
1637
|
const total = await remoteFileSize(conn, remotePath);
|
|
1359
1638
|
if (total === null) throw new Error(`remote file not found: ${remotePath}`);
|
|
1360
1639
|
if (total === 0) return 0;
|
|
1640
|
+
const budget = transferBudget(total, idleMs);
|
|
1361
1641
|
const workers = Math.max(1, Math.min(DOWNLOAD_MAX_WORKERS, Math.ceil(total / DOWNLOAD_MIN_RANGE_BYTES)));
|
|
1362
1642
|
const rangeSize = Math.ceil(total / workers);
|
|
1363
1643
|
let transferred = 0;
|
|
@@ -1381,7 +1661,7 @@ async function downloadStream(conn, remotePath, onData, onProgress, timeoutMs =
|
|
|
1381
1661
|
start,
|
|
1382
1662
|
length: Math.min(rangeSize, total - start)
|
|
1383
1663
|
});
|
|
1384
|
-
await Promise.all(ranges.map((range) => downloadWorkerOnce(conn, remotePath, range.start, range.length, report,
|
|
1664
|
+
await Promise.all(ranges.map((range) => downloadWorkerOnce(conn, remotePath, range.start, range.length, report, budget, authMethod, sendHttp)));
|
|
1385
1665
|
return transferred;
|
|
1386
1666
|
} catch (error) {
|
|
1387
1667
|
if (error instanceof WinrmTimedOut) throw error;
|
|
@@ -1544,7 +1824,7 @@ async function smbReachable(host) {
|
|
|
1544
1824
|
}
|
|
1545
1825
|
/** Error thrown when SMB was requested or probed and the target is not listening. */
|
|
1546
1826
|
function smbUnavailable(host) {
|
|
1547
|
-
return /* @__PURE__ */ new Error("SMB is not reachable on " + host + ":445 (the admin share needs TCP 445 open and the Server service running)");
|
|
1827
|
+
return /* @__PURE__ */ new Error("SMB is not reachable on " + host + ":445 (the admin share needs TCP 445 open and the Server service running); retry with channel \"winrm\" to transfer over WinRM instead");
|
|
1548
1828
|
}
|
|
1549
1829
|
/**
|
|
1550
1830
|
* Copy one local file to a remote Windows host over SMB, verifying the
|
|
@@ -1663,14 +1943,125 @@ var ConsoleSession = class {
|
|
|
1663
1943
|
this.onExit?.(code, error);
|
|
1664
1944
|
}
|
|
1665
1945
|
};
|
|
1946
|
+
/**
|
|
1947
|
+
* Largest file whose text may serve as a diff basis. Above this the card shows
|
|
1948
|
+
* the path and operation without hunks: fetching and projecting a bigger basis
|
|
1949
|
+
* would cost another transfer and a large metadata payload for a summary the
|
|
1950
|
+
* user reads at a glance.
|
|
1951
|
+
*/
|
|
1952
|
+
const DIFF_BASIS_MAX_BYTES = 256 * 1024;
|
|
1953
|
+
/** Split text into lines, treating every terminator as a separator. */
|
|
1954
|
+
function toLines(text) {
|
|
1955
|
+
return text === "" ? [] : text.split(/\r?\n/);
|
|
1956
|
+
}
|
|
1957
|
+
/**
|
|
1958
|
+
* Compute the hunks between two texts.
|
|
1959
|
+
* @param path - remote path stamped on every hunk.
|
|
1960
|
+
* @param before - file text before the change.
|
|
1961
|
+
* @param after - file text after the change.
|
|
1962
|
+
* @returns one hunk per contiguous change in file order; empty when equal.
|
|
1963
|
+
*/
|
|
1964
|
+
function computeHunkDiffs(path, before, after) {
|
|
1965
|
+
if (before === after) return [];
|
|
1966
|
+
const oldLines = toLines(before);
|
|
1967
|
+
const newLines = toLines(after);
|
|
1968
|
+
let head = 0;
|
|
1969
|
+
while (head < oldLines.length && head < newLines.length && oldLines[head] === newLines[head]) head += 1;
|
|
1970
|
+
let tail = 0;
|
|
1971
|
+
while (tail < oldLines.length - head && tail < newLines.length - head && oldLines[oldLines.length - 1 - tail] === newLines[newLines.length - 1 - tail]) tail += 1;
|
|
1972
|
+
const oldMid = oldLines.slice(head, oldLines.length - tail);
|
|
1973
|
+
const newMid = newLines.slice(head, newLines.length - tail);
|
|
1974
|
+
/** Build one hunk around the changed range `[from, to)` in the middle. */
|
|
1975
|
+
const emit = (from, to) => {
|
|
1976
|
+
const start = Math.max(0, from - 3);
|
|
1977
|
+
const stop = Math.min(oldMid.length, to + 3);
|
|
1978
|
+
return {
|
|
1979
|
+
path,
|
|
1980
|
+
oldText: oldLines.slice(head + start, head + stop).join("\n"),
|
|
1981
|
+
newText: newLines.slice(head + start, head + stop).join("\n")
|
|
1982
|
+
};
|
|
1983
|
+
};
|
|
1984
|
+
if (oldMid.length !== newMid.length) return [{
|
|
1985
|
+
path,
|
|
1986
|
+
oldText: oldLines.slice(Math.max(0, head - 3), oldLines.length - Math.max(0, tail - 3)).join("\n"),
|
|
1987
|
+
newText: newLines.slice(Math.max(0, head - 3), newLines.length - Math.max(0, tail - 3)).join("\n")
|
|
1988
|
+
}];
|
|
1989
|
+
const diffs = [];
|
|
1990
|
+
let index = 0;
|
|
1991
|
+
while (index < oldMid.length) {
|
|
1992
|
+
if (oldMid[index] === newMid[index]) {
|
|
1993
|
+
index += 1;
|
|
1994
|
+
continue;
|
|
1995
|
+
}
|
|
1996
|
+
const from = index;
|
|
1997
|
+
while (index < oldMid.length && oldMid[index] !== newMid[index]) index += 1;
|
|
1998
|
+
diffs.push(emit(from, index));
|
|
1999
|
+
}
|
|
2000
|
+
return diffs;
|
|
2001
|
+
}
|
|
2002
|
+
/**
|
|
2003
|
+
* Compute hunks and drop them when the serialized payload would exceed the cap.
|
|
2004
|
+
* @param path - remote path stamped on every hunk.
|
|
2005
|
+
* @param before - prior text.
|
|
2006
|
+
* @param after - text after the change.
|
|
2007
|
+
* @returns the kept hunks; empty when nothing changed or the payload is too big.
|
|
2008
|
+
*/
|
|
2009
|
+
function boundedHunkDiffs(path, before, after) {
|
|
2010
|
+
const diffs = computeHunkDiffs(path, before, after);
|
|
2011
|
+
if (diffs.length === 0) return diffs;
|
|
2012
|
+
return JSON.stringify(diffs).length > 524288 ? [] : diffs;
|
|
2013
|
+
}
|
|
1666
2014
|
//#endregion
|
|
1667
2015
|
//#region src/engine.ts
|
|
2016
|
+
/** Longest single line the reader returns before cutting it (characters). */
|
|
2017
|
+
const READ_MAX_LINE_LENGTH = 2e3;
|
|
2018
|
+
/** Lines the reader returns when the caller does not choose a page size. */
|
|
2019
|
+
const READ_DEFAULT_LIMIT = 2e3;
|
|
2020
|
+
/** Most lines the reader will return in one call. */
|
|
2021
|
+
const READ_MAX_LIMIT = 5e3;
|
|
2022
|
+
/** Byte budget for one read window; keeps the envelope well under the transport limit. */
|
|
2023
|
+
const READ_MAX_BYTES = 512 * 1024;
|
|
1668
2024
|
/** JSON-safe parse of the envelope output into a typed value. */
|
|
1669
2025
|
function parseJson(text) {
|
|
1670
2026
|
const trimmed = text.trim();
|
|
1671
2027
|
if (trimmed === "" || trimmed === "null") return [];
|
|
1672
2028
|
return JSON.parse(trimmed);
|
|
1673
2029
|
}
|
|
2030
|
+
/**
|
|
2031
|
+
* Parse one JSON object out of an envelope's stdout.
|
|
2032
|
+
*
|
|
2033
|
+
* The file tools report their own failures inside the JSON (`{"error":…}`)
|
|
2034
|
+
* with exit code 0, so an unparseable body means the fragment never ran;
|
|
2035
|
+
* returning undefined lets the caller say so instead of throwing a
|
|
2036
|
+
* `SyntaxError` with no context.
|
|
2037
|
+
* @param text - envelope stdout.
|
|
2038
|
+
* @returns the parsed object, or undefined when the body is empty or not JSON.
|
|
2039
|
+
*/
|
|
2040
|
+
function parseObject(text) {
|
|
2041
|
+
const trimmed = text.trim();
|
|
2042
|
+
if (trimmed === "" || trimmed === "null") return void 0;
|
|
2043
|
+
if (!trimmed.startsWith("{")) return void 0;
|
|
2044
|
+
try {
|
|
2045
|
+
return JSON.parse(trimmed);
|
|
2046
|
+
} catch {
|
|
2047
|
+
return;
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
/** Mark an over-long line the reader cut, so the model knows it is incomplete. */
|
|
2051
|
+
function annotateCutLine(text) {
|
|
2052
|
+
return text.length > 2e3 ? `${text.slice(0, READ_MAX_LINE_LENGTH)}… (line truncated to ${READ_MAX_LINE_LENGTH} chars)` : text;
|
|
2053
|
+
}
|
|
2054
|
+
/** Encode text for a remote write, applying the requested byte-order mark. */
|
|
2055
|
+
function encodeText(content, encoding) {
|
|
2056
|
+
const body = Buffer.from(content, "utf8");
|
|
2057
|
+
if (encoding === "utf8bom") return Buffer.concat([Buffer.from([
|
|
2058
|
+
239,
|
|
2059
|
+
187,
|
|
2060
|
+
191
|
|
2061
|
+
]), body]);
|
|
2062
|
+
if (encoding === "utf16le") return Buffer.concat([Buffer.from([255, 254]), Buffer.from(content, "utf16le")]);
|
|
2063
|
+
return body;
|
|
2064
|
+
}
|
|
1674
2065
|
/** A tiny per-alias in-flight limiter so GUI bursts do not pile up sockets. */
|
|
1675
2066
|
var AliasGate = class {
|
|
1676
2067
|
queues = /* @__PURE__ */ new Map();
|
|
@@ -1844,6 +2235,119 @@ var WinRmEngine = class {
|
|
|
1844
2235
|
}));
|
|
1845
2236
|
});
|
|
1846
2237
|
}
|
|
2238
|
+
/**
|
|
2239
|
+
* Read a bounded, line-numbered window of a remote text file.
|
|
2240
|
+
*
|
|
2241
|
+
* One PowerShell round trip: the remote side decodes the file, counts every
|
|
2242
|
+
* line, and returns only the requested window, so a large log costs one
|
|
2243
|
+
* transfer of the window rather than of the file.
|
|
2244
|
+
* @param alias - configured host.
|
|
2245
|
+
* @param remotePath - path on the target.
|
|
2246
|
+
* @param offset - 1-based first line to return.
|
|
2247
|
+
* @param limit - maximum number of lines to return.
|
|
2248
|
+
* @returns the window plus the file's total line count and encoding.
|
|
2249
|
+
*/
|
|
2250
|
+
async readFile(alias, remotePath, offset, limit) {
|
|
2251
|
+
const entry = this.store.find(alias);
|
|
2252
|
+
if (entry === void 0) throw new Error(`alias '${alias}' not found`);
|
|
2253
|
+
return this.gate.run(alias, async () => {
|
|
2254
|
+
const result = await runScript(connOf(entry), psReadTextWindow(remotePath, offset, limit, READ_MAX_LINE_LENGTH, READ_MAX_BYTES), { timeoutMs: 12e4 });
|
|
2255
|
+
const payload = parseObject(result.stdout);
|
|
2256
|
+
if (payload === void 0) throw new Error(result.stderr.trim() || result.stdout.trim() || `failed to read '${remotePath}'`);
|
|
2257
|
+
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)`);
|
|
2258
|
+
if (payload.exists !== true) throw new Error(`remote file not found: ${remotePath}`);
|
|
2259
|
+
const totalLines = payload.totalLines ?? 0;
|
|
2260
|
+
const lineCount = payload.lineCount ?? 0;
|
|
2261
|
+
if (payload.truncated !== true && offset > totalLines && totalLines > 0) throw new Error(`offset ${offset} is out of range for '${remotePath}' (${totalLines} lines)`);
|
|
2262
|
+
const texts = lineCount === 0 ? [] : (payload.linesText ?? "").split("\n");
|
|
2263
|
+
return {
|
|
2264
|
+
path: remotePath,
|
|
2265
|
+
offset,
|
|
2266
|
+
lines: texts.map((text, index) => ({
|
|
2267
|
+
number: offset + index,
|
|
2268
|
+
text: annotateCutLine(text)
|
|
2269
|
+
})),
|
|
2270
|
+
totalLines,
|
|
2271
|
+
encoding: payload.encoding ?? "utf8",
|
|
2272
|
+
truncatedLines: texts.filter((text) => text.length > READ_MAX_LINE_LENGTH).length,
|
|
2273
|
+
truncatedByBytes: payload.truncatedByBytes === true
|
|
2274
|
+
};
|
|
2275
|
+
});
|
|
2276
|
+
}
|
|
2277
|
+
/**
|
|
2278
|
+
* Write a whole remote text file, creating parent directories as needed.
|
|
2279
|
+
*
|
|
2280
|
+
* Reuses the transfer transport (single streamed stdin when available, the
|
|
2281
|
+
* 48 KiB chunk path otherwise) so a large write does not pay one shell cycle
|
|
2282
|
+
* per chunk.
|
|
2283
|
+
* @param alias - configured host.
|
|
2284
|
+
* @param remotePath - path on the target; overwritten when it exists.
|
|
2285
|
+
* @param content - UTF-8 text to write.
|
|
2286
|
+
* @param encoding - byte-order mark to emit (`utf8`, `utf8bom`, `utf16le`).
|
|
2287
|
+
* @returns the operation kind, byte count, encoding written, and both texts
|
|
2288
|
+
* the diff card compares.
|
|
2289
|
+
*/
|
|
2290
|
+
async writeFile(alias, remotePath, content, encoding) {
|
|
2291
|
+
const entry = this.store.find(alias);
|
|
2292
|
+
if (entry === void 0) throw new Error(`alias '${alias}' not found`);
|
|
2293
|
+
const data = encodeText(content, encoding);
|
|
2294
|
+
return this.gate.run(alias, async () => {
|
|
2295
|
+
const conn = connOf(entry);
|
|
2296
|
+
const previous = await remoteFileSize(conn, remotePath);
|
|
2297
|
+
let before = null;
|
|
2298
|
+
if (previous !== null && previous <= 262144) {
|
|
2299
|
+
const payload = parseObject((await runScript(conn, psReadTextBasis(remotePath, DIFF_BASIS_MAX_BYTES), { timeoutMs: 6e4 })).stdout);
|
|
2300
|
+
if (payload !== void 0 && payload.error === void 0 && payload.tooLarge !== true && typeof payload.text === "string") before = Buffer.from(payload.text, "base64").toString("utf8");
|
|
2301
|
+
}
|
|
2302
|
+
let bytes = await uploadStream(conn, remotePath, data);
|
|
2303
|
+
if (bytes === null) bytes = await uploadBuffer(conn, remotePath, data);
|
|
2304
|
+
return {
|
|
2305
|
+
path: remotePath,
|
|
2306
|
+
operation: previous === null ? "create" : "update",
|
|
2307
|
+
bytes,
|
|
2308
|
+
encoding,
|
|
2309
|
+
before,
|
|
2310
|
+
after: content
|
|
2311
|
+
};
|
|
2312
|
+
});
|
|
2313
|
+
}
|
|
2314
|
+
/**
|
|
2315
|
+
* Replace `oldString` with `newString` inside a remote text file.
|
|
2316
|
+
*
|
|
2317
|
+
* The replacement is literal, never a pattern, and the file's existing
|
|
2318
|
+
* encoding is preserved. Without `replaceAll` the edit is refused unless the
|
|
2319
|
+
* needle is unique, so a stale needle cannot rewrite the wrong region.
|
|
2320
|
+
* @param alias - configured host.
|
|
2321
|
+
* @param remotePath - path on the target.
|
|
2322
|
+
* @param oldString - exact text to find.
|
|
2323
|
+
* @param newString - text to substitute.
|
|
2324
|
+
* @param replaceAll - replace every occurrence instead of requiring one.
|
|
2325
|
+
* @returns the replacement count, the file's new size, and both texts the
|
|
2326
|
+
* diff card compares.
|
|
2327
|
+
*/
|
|
2328
|
+
async editFile(alias, remotePath, oldString, newString, replaceAll) {
|
|
2329
|
+
const entry = this.store.find(alias);
|
|
2330
|
+
if (entry === void 0) throw new Error(`alias '${alias}' not found`);
|
|
2331
|
+
if (oldString === "") throw new Error("oldString must be a non-empty string");
|
|
2332
|
+
if (oldString === newString) throw new Error("oldString and newString are identical; nothing to change");
|
|
2333
|
+
const oldB64 = Buffer.from(oldString, "utf8").toString("base64");
|
|
2334
|
+
const newB64 = Buffer.from(newString, "utf8").toString("base64");
|
|
2335
|
+
return this.gate.run(alias, async () => {
|
|
2336
|
+
const result = await runScript(connOf(entry), psEditText(remotePath, oldB64, newB64, replaceAll, DIFF_BASIS_MAX_BYTES), { timeoutMs: 12e4 });
|
|
2337
|
+
const payload = parseObject(result.stdout);
|
|
2338
|
+
if (payload === void 0) throw new Error(result.stderr.trim() || result.stdout.trim() || `failed to edit '${remotePath}'`);
|
|
2339
|
+
if (payload.error !== void 0) throw new Error(payload.error);
|
|
2340
|
+
const basis = payload.basis ?? null;
|
|
2341
|
+
return {
|
|
2342
|
+
path: payload.path ?? remotePath,
|
|
2343
|
+
encoding: payload.encoding ?? "utf8",
|
|
2344
|
+
replacements: payload.replacements ?? 0,
|
|
2345
|
+
size: payload.size ?? 0,
|
|
2346
|
+
before: basis === null ? null : Buffer.from(basis.before ?? "", "base64").toString("utf8"),
|
|
2347
|
+
after: basis === null ? null : Buffer.from(basis.after ?? "", "base64").toString("utf8")
|
|
2348
|
+
};
|
|
2349
|
+
});
|
|
2350
|
+
}
|
|
1847
2351
|
/** Upload one local file to a remote path. Defaults to SMB with WinRM fallback. */
|
|
1848
2352
|
async upload(alias, localPath, remotePath, onProgress, channel = "auto") {
|
|
1849
2353
|
const entry = this.store.find(alias);
|
|
@@ -1969,16 +2473,19 @@ var WinRmEngine = class {
|
|
|
1969
2473
|
percent: total > 0 ? Math.round(transferred / total * 1e3) / 10 : 0
|
|
1970
2474
|
});
|
|
1971
2475
|
};
|
|
2476
|
+
let delivered = 0;
|
|
1972
2477
|
try {
|
|
1973
2478
|
const conn = connOf(entry);
|
|
1974
|
-
|
|
2479
|
+
const streamed = await downloadStream(conn, remotePath, put, report);
|
|
2480
|
+
if (streamed === null) {
|
|
1975
2481
|
let offset = 0;
|
|
1976
2482
|
await downloadChunks(conn, remotePath, (b64) => {
|
|
1977
2483
|
const buffer = Buffer.from(b64, "base64");
|
|
1978
2484
|
put(offset, buffer);
|
|
1979
2485
|
offset += buffer.length;
|
|
1980
2486
|
}, report);
|
|
1981
|
-
|
|
2487
|
+
delivered = bytes;
|
|
2488
|
+
} else delivered = streamed;
|
|
1982
2489
|
await queue;
|
|
1983
2490
|
if (sink === null) sink = await open(localPath, "w", 384);
|
|
1984
2491
|
completed = true;
|
|
@@ -1990,12 +2497,12 @@ var WinRmEngine = class {
|
|
|
1990
2497
|
onProgress?.({
|
|
1991
2498
|
phase: "done",
|
|
1992
2499
|
file: remotePath,
|
|
1993
|
-
transferred:
|
|
1994
|
-
total:
|
|
2500
|
+
transferred: delivered,
|
|
2501
|
+
total: delivered,
|
|
1995
2502
|
percent: 100
|
|
1996
2503
|
});
|
|
1997
2504
|
return {
|
|
1998
|
-
bytes,
|
|
2505
|
+
bytes: delivered,
|
|
1999
2506
|
channel: "winrm"
|
|
2000
2507
|
};
|
|
2001
2508
|
});
|
|
@@ -3195,7 +3702,7 @@ function winrmUploadTool(engine) {
|
|
|
3195
3702
|
"smb",
|
|
3196
3703
|
"winrm"
|
|
3197
3704
|
],
|
|
3198
|
-
description: "Transfer channel:
|
|
3705
|
+
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."
|
|
3199
3706
|
}
|
|
3200
3707
|
},
|
|
3201
3708
|
output: {
|
|
@@ -3239,7 +3746,7 @@ function winrmUploadTool(engine) {
|
|
|
3239
3746
|
function winrmDownloadTool(engine) {
|
|
3240
3747
|
return defineTool({
|
|
3241
3748
|
name: "winrm_download",
|
|
3242
|
-
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. Triggers: download file from Windows server, fetch remote log/artifact.",
|
|
3749
|
+
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. Triggers: download file from Windows server, fetch remote log/artifact.",
|
|
3243
3750
|
parameters: {
|
|
3244
3751
|
alias: {
|
|
3245
3752
|
type: "string",
|
|
@@ -3263,7 +3770,7 @@ function winrmDownloadTool(engine) {
|
|
|
3263
3770
|
"smb",
|
|
3264
3771
|
"winrm"
|
|
3265
3772
|
],
|
|
3266
|
-
description: "Transfer channel:
|
|
3773
|
+
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."
|
|
3267
3774
|
}
|
|
3268
3775
|
},
|
|
3269
3776
|
output: {
|
|
@@ -3373,6 +3880,368 @@ function winrmClusterTool(engine) {
|
|
|
3373
3880
|
}
|
|
3374
3881
|
});
|
|
3375
3882
|
}
|
|
3883
|
+
/** Render a read window in the numbered envelope the local read tool uses. */
|
|
3884
|
+
function renderWindow(window) {
|
|
3885
|
+
const lines = window.lines ?? [];
|
|
3886
|
+
const offset = window.offset ?? 1;
|
|
3887
|
+
const total = window.totalLines ?? 0;
|
|
3888
|
+
const last = offset + lines.length - 1;
|
|
3889
|
+
const body = lines.map((line) => `${line.number}: ${line.text}`).join("\n");
|
|
3890
|
+
const encoding = window.encoding !== void 0 && window.encoding !== "utf8" ? `, encoding ${window.encoding}` : "";
|
|
3891
|
+
let footer;
|
|
3892
|
+
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}.)`;
|
|
3893
|
+
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}.)`;
|
|
3894
|
+
else if (last < total) footer = `(Showing lines ${offset}-${last} of ${total}${encoding}. Continue with offset=${last + 1}.)`;
|
|
3895
|
+
else footer = `(End of file - total ${total} lines${encoding})`;
|
|
3896
|
+
const content = body === "" ? footer : `${body}\n\n${footer}`;
|
|
3897
|
+
return [
|
|
3898
|
+
`<path>${window.path ?? ""}</path>`,
|
|
3899
|
+
"<type>file</type>",
|
|
3900
|
+
"<content>",
|
|
3901
|
+
content,
|
|
3902
|
+
"</content>"
|
|
3903
|
+
].join("\n");
|
|
3904
|
+
}
|
|
3905
|
+
/** The remote read tool: a line-numbered window of a text file on the target. */
|
|
3906
|
+
function winrmReadTool(engine) {
|
|
3907
|
+
return defineTool({
|
|
3908
|
+
name: "winrm_read",
|
|
3909
|
+
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.",
|
|
3910
|
+
parameters: {
|
|
3911
|
+
alias: {
|
|
3912
|
+
type: "string",
|
|
3913
|
+
required: true,
|
|
3914
|
+
description: "Host alias from winrm_list."
|
|
3915
|
+
},
|
|
3916
|
+
remotePath: {
|
|
3917
|
+
type: "string",
|
|
3918
|
+
required: true,
|
|
3919
|
+
description: "Path on the remote host, e.g. C:\\temp\\app.log."
|
|
3920
|
+
},
|
|
3921
|
+
offset: {
|
|
3922
|
+
type: "integer",
|
|
3923
|
+
description: "1-based first line to return. Defaults to 1."
|
|
3924
|
+
},
|
|
3925
|
+
limit: {
|
|
3926
|
+
type: "integer",
|
|
3927
|
+
description: `Maximum lines to return (default ${READ_DEFAULT_LIMIT}, max ${READ_MAX_LIMIT}).`
|
|
3928
|
+
}
|
|
3929
|
+
},
|
|
3930
|
+
output: {
|
|
3931
|
+
schema: {
|
|
3932
|
+
type: "object",
|
|
3933
|
+
additionalProperties: false,
|
|
3934
|
+
properties: {
|
|
3935
|
+
ok: {
|
|
3936
|
+
type: "boolean",
|
|
3937
|
+
required: true
|
|
3938
|
+
},
|
|
3939
|
+
path: { type: "string" },
|
|
3940
|
+
offset: { type: "integer" },
|
|
3941
|
+
lines: {
|
|
3942
|
+
type: "array",
|
|
3943
|
+
items: {
|
|
3944
|
+
type: "object",
|
|
3945
|
+
additionalProperties: false,
|
|
3946
|
+
properties: {
|
|
3947
|
+
number: {
|
|
3948
|
+
type: "integer",
|
|
3949
|
+
required: true
|
|
3950
|
+
},
|
|
3951
|
+
text: {
|
|
3952
|
+
type: "string",
|
|
3953
|
+
required: true
|
|
3954
|
+
}
|
|
3955
|
+
}
|
|
3956
|
+
}
|
|
3957
|
+
},
|
|
3958
|
+
totalLines: { type: "integer" },
|
|
3959
|
+
encoding: { type: "string" },
|
|
3960
|
+
truncatedByBytes: { type: "boolean" },
|
|
3961
|
+
truncatedLines: { type: "integer" },
|
|
3962
|
+
error: { type: "string" }
|
|
3963
|
+
}
|
|
3964
|
+
},
|
|
3965
|
+
render: (_args, value) => text(value.ok ? renderWindow(value) : `read failed: ${value.error ?? "unknown error"}`),
|
|
3966
|
+
presentationMeta: (args, value) => value.ok ? {
|
|
3967
|
+
card: "winrm-read",
|
|
3968
|
+
path: value.path ?? args.remotePath,
|
|
3969
|
+
offset: value.offset ?? 1,
|
|
3970
|
+
lines: value.lines ?? [],
|
|
3971
|
+
totalLines: value.totalLines ?? 0,
|
|
3972
|
+
encoding: value.encoding ?? "utf8",
|
|
3973
|
+
truncatedByBytes: value.truncatedByBytes === true,
|
|
3974
|
+
truncatedLines: value.truncatedLines ?? 0,
|
|
3975
|
+
error: ""
|
|
3976
|
+
} : {
|
|
3977
|
+
card: "winrm-read",
|
|
3978
|
+
path: args.remotePath,
|
|
3979
|
+
offset: 0,
|
|
3980
|
+
lines: [],
|
|
3981
|
+
totalLines: 0,
|
|
3982
|
+
encoding: "",
|
|
3983
|
+
truncatedByBytes: false,
|
|
3984
|
+
truncatedLines: 0,
|
|
3985
|
+
error: value.error ?? "unknown error"
|
|
3986
|
+
}
|
|
3987
|
+
},
|
|
3988
|
+
async execute(args) {
|
|
3989
|
+
const offset = Math.max(1, Math.trunc(args.offset ?? 1));
|
|
3990
|
+
const limit = Math.min(READ_MAX_LIMIT, Math.max(1, Math.trunc(args.limit ?? 2e3)));
|
|
3991
|
+
try {
|
|
3992
|
+
return {
|
|
3993
|
+
ok: true,
|
|
3994
|
+
...await engine.readFile(args.alias, args.remotePath, offset, limit)
|
|
3995
|
+
};
|
|
3996
|
+
} catch (error) {
|
|
3997
|
+
return {
|
|
3998
|
+
ok: false,
|
|
3999
|
+
error: error instanceof Error ? error.message : String(error)
|
|
4000
|
+
};
|
|
4001
|
+
}
|
|
4002
|
+
}
|
|
4003
|
+
});
|
|
4004
|
+
}
|
|
4005
|
+
/** The remote write tool: replace a whole text file on the target. */
|
|
4006
|
+
function winrmWriteTool(engine) {
|
|
4007
|
+
return defineTool({
|
|
4008
|
+
name: "winrm_write",
|
|
4009
|
+
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.",
|
|
4010
|
+
parameters: {
|
|
4011
|
+
alias: {
|
|
4012
|
+
type: "string",
|
|
4013
|
+
required: true,
|
|
4014
|
+
description: "Host alias from winrm_list."
|
|
4015
|
+
},
|
|
4016
|
+
remotePath: {
|
|
4017
|
+
type: "string",
|
|
4018
|
+
required: true,
|
|
4019
|
+
description: "Destination path on the remote host, e.g. C:\\temp\\script.ps1 (parent dirs are created)."
|
|
4020
|
+
},
|
|
4021
|
+
content: {
|
|
4022
|
+
type: "string",
|
|
4023
|
+
required: true,
|
|
4024
|
+
description: "Full file content."
|
|
4025
|
+
},
|
|
4026
|
+
encoding: {
|
|
4027
|
+
type: "string",
|
|
4028
|
+
enum: [
|
|
4029
|
+
"utf8",
|
|
4030
|
+
"utf8bom",
|
|
4031
|
+
"utf16le"
|
|
4032
|
+
],
|
|
4033
|
+
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."
|
|
4034
|
+
}
|
|
4035
|
+
},
|
|
4036
|
+
output: {
|
|
4037
|
+
schema: {
|
|
4038
|
+
type: "object",
|
|
4039
|
+
additionalProperties: false,
|
|
4040
|
+
properties: {
|
|
4041
|
+
ok: {
|
|
4042
|
+
type: "boolean",
|
|
4043
|
+
required: true
|
|
4044
|
+
},
|
|
4045
|
+
path: { type: "string" },
|
|
4046
|
+
operation: {
|
|
4047
|
+
type: "string",
|
|
4048
|
+
enum: ["create", "update"]
|
|
4049
|
+
},
|
|
4050
|
+
bytes: { type: "integer" },
|
|
4051
|
+
encoding: {
|
|
4052
|
+
type: "string",
|
|
4053
|
+
enum: [
|
|
4054
|
+
"utf8",
|
|
4055
|
+
"utf8bom",
|
|
4056
|
+
"utf16le"
|
|
4057
|
+
]
|
|
4058
|
+
},
|
|
4059
|
+
diffs: {
|
|
4060
|
+
type: "array",
|
|
4061
|
+
items: {
|
|
4062
|
+
type: "object",
|
|
4063
|
+
additionalProperties: false,
|
|
4064
|
+
properties: {
|
|
4065
|
+
path: {
|
|
4066
|
+
type: "string",
|
|
4067
|
+
required: true
|
|
4068
|
+
},
|
|
4069
|
+
oldText: {
|
|
4070
|
+
type: "string",
|
|
4071
|
+
required: true
|
|
4072
|
+
},
|
|
4073
|
+
newText: {
|
|
4074
|
+
type: "string",
|
|
4075
|
+
required: true
|
|
4076
|
+
}
|
|
4077
|
+
}
|
|
4078
|
+
}
|
|
4079
|
+
},
|
|
4080
|
+
beyondBasis: { type: "boolean" },
|
|
4081
|
+
error: { type: "string" }
|
|
4082
|
+
}
|
|
4083
|
+
},
|
|
4084
|
+
render: (_args, value) => text(value.ok ? [
|
|
4085
|
+
`<path>${value.path ?? ""}</path>`,
|
|
4086
|
+
"<type>file</type>",
|
|
4087
|
+
"<content>",
|
|
4088
|
+
`${value.operation === "update" ? "Updated" : "Created"} file (${value.bytes ?? 0} bytes)`,
|
|
4089
|
+
"</content>"
|
|
4090
|
+
].join("\n") : `write failed: ${value.error ?? "unknown error"}`),
|
|
4091
|
+
presentationMeta: (args, value) => value.ok ? {
|
|
4092
|
+
card: "winrm-write",
|
|
4093
|
+
path: value.path ?? args.remotePath,
|
|
4094
|
+
operation: value.operation ?? "update",
|
|
4095
|
+
bytes: value.bytes ?? 0,
|
|
4096
|
+
encoding: value.encoding ?? "utf8",
|
|
4097
|
+
diffs: value.diffs ?? [],
|
|
4098
|
+
beyondBasis: value.beyondBasis === true,
|
|
4099
|
+
error: ""
|
|
4100
|
+
} : {
|
|
4101
|
+
card: "winrm-write",
|
|
4102
|
+
path: args.remotePath,
|
|
4103
|
+
operation: "update",
|
|
4104
|
+
bytes: 0,
|
|
4105
|
+
encoding: "",
|
|
4106
|
+
diffs: [],
|
|
4107
|
+
beyondBasis: false,
|
|
4108
|
+
error: value.error ?? "unknown error"
|
|
4109
|
+
}
|
|
4110
|
+
},
|
|
4111
|
+
async execute(args) {
|
|
4112
|
+
try {
|
|
4113
|
+
const outcome = await engine.writeFile(args.alias, args.remotePath, args.content, args.encoding ?? "utf8");
|
|
4114
|
+
const diffs = outcome.before !== null ? boundedHunkDiffs(outcome.path, outcome.before, outcome.after) : outcome.operation === "create" ? boundedHunkDiffs(outcome.path, "", outcome.after) : [];
|
|
4115
|
+
return {
|
|
4116
|
+
ok: true,
|
|
4117
|
+
path: outcome.path,
|
|
4118
|
+
operation: outcome.operation,
|
|
4119
|
+
bytes: outcome.bytes,
|
|
4120
|
+
encoding: outcome.encoding,
|
|
4121
|
+
diffs,
|
|
4122
|
+
beyondBasis: outcome.operation === "update" && outcome.before === null
|
|
4123
|
+
};
|
|
4124
|
+
} catch (error) {
|
|
4125
|
+
return {
|
|
4126
|
+
ok: false,
|
|
4127
|
+
error: error instanceof Error ? error.message : String(error)
|
|
4128
|
+
};
|
|
4129
|
+
}
|
|
4130
|
+
}
|
|
4131
|
+
});
|
|
4132
|
+
}
|
|
4133
|
+
/** The remote edit tool: replace an exact string inside a file on the target. */
|
|
4134
|
+
function winrmEditTool(engine) {
|
|
4135
|
+
return defineTool({
|
|
4136
|
+
name: "winrm_edit",
|
|
4137
|
+
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.",
|
|
4138
|
+
parameters: {
|
|
4139
|
+
alias: {
|
|
4140
|
+
type: "string",
|
|
4141
|
+
required: true,
|
|
4142
|
+
description: "Host alias from winrm_list."
|
|
4143
|
+
},
|
|
4144
|
+
remotePath: {
|
|
4145
|
+
type: "string",
|
|
4146
|
+
required: true,
|
|
4147
|
+
description: "Path on the remote host, e.g. C:\\temp\\app.config."
|
|
4148
|
+
},
|
|
4149
|
+
oldString: {
|
|
4150
|
+
type: "string",
|
|
4151
|
+
required: true,
|
|
4152
|
+
description: "Exact text to replace. Must not be empty."
|
|
4153
|
+
},
|
|
4154
|
+
newString: {
|
|
4155
|
+
type: "string",
|
|
4156
|
+
required: true,
|
|
4157
|
+
description: "Replacement text. Must differ from oldString."
|
|
4158
|
+
},
|
|
4159
|
+
replaceAll: {
|
|
4160
|
+
type: "boolean",
|
|
4161
|
+
description: "Replace every occurrence instead of requiring exactly one. Defaults to false."
|
|
4162
|
+
}
|
|
4163
|
+
},
|
|
4164
|
+
output: {
|
|
4165
|
+
schema: {
|
|
4166
|
+
type: "object",
|
|
4167
|
+
additionalProperties: false,
|
|
4168
|
+
properties: {
|
|
4169
|
+
ok: {
|
|
4170
|
+
type: "boolean",
|
|
4171
|
+
required: true
|
|
4172
|
+
},
|
|
4173
|
+
path: { type: "string" },
|
|
4174
|
+
encoding: { type: "string" },
|
|
4175
|
+
replacements: { type: "integer" },
|
|
4176
|
+
size: { type: "integer" },
|
|
4177
|
+
diffs: {
|
|
4178
|
+
type: "array",
|
|
4179
|
+
items: {
|
|
4180
|
+
type: "object",
|
|
4181
|
+
additionalProperties: false,
|
|
4182
|
+
properties: {
|
|
4183
|
+
path: {
|
|
4184
|
+
type: "string",
|
|
4185
|
+
required: true
|
|
4186
|
+
},
|
|
4187
|
+
oldText: {
|
|
4188
|
+
type: "string",
|
|
4189
|
+
required: true
|
|
4190
|
+
},
|
|
4191
|
+
newText: {
|
|
4192
|
+
type: "string",
|
|
4193
|
+
required: true
|
|
4194
|
+
}
|
|
4195
|
+
}
|
|
4196
|
+
}
|
|
4197
|
+
},
|
|
4198
|
+
beyondBasis: { type: "boolean" },
|
|
4199
|
+
error: { type: "string" }
|
|
4200
|
+
}
|
|
4201
|
+
},
|
|
4202
|
+
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"}`),
|
|
4203
|
+
presentationMeta: (args, value) => value.ok ? {
|
|
4204
|
+
card: "winrm-edit",
|
|
4205
|
+
path: value.path ?? args.remotePath,
|
|
4206
|
+
replacements: value.replacements ?? 0,
|
|
4207
|
+
size: value.size ?? 0,
|
|
4208
|
+
encoding: value.encoding ?? "utf8",
|
|
4209
|
+
diffs: value.diffs ?? [],
|
|
4210
|
+
beyondBasis: value.beyondBasis === true,
|
|
4211
|
+
error: ""
|
|
4212
|
+
} : {
|
|
4213
|
+
card: "winrm-edit",
|
|
4214
|
+
path: args.remotePath,
|
|
4215
|
+
replacements: 0,
|
|
4216
|
+
size: 0,
|
|
4217
|
+
encoding: "",
|
|
4218
|
+
diffs: [],
|
|
4219
|
+
beyondBasis: false,
|
|
4220
|
+
error: value.error ?? "unknown error"
|
|
4221
|
+
}
|
|
4222
|
+
},
|
|
4223
|
+
async execute(args) {
|
|
4224
|
+
try {
|
|
4225
|
+
const outcome = await engine.editFile(args.alias, args.remotePath, args.oldString, args.newString, args.replaceAll ?? false);
|
|
4226
|
+
const diffs = outcome.before !== null && outcome.after !== null ? boundedHunkDiffs(outcome.path, outcome.before, outcome.after) : [];
|
|
4227
|
+
return {
|
|
4228
|
+
ok: true,
|
|
4229
|
+
path: outcome.path,
|
|
4230
|
+
encoding: outcome.encoding,
|
|
4231
|
+
replacements: outcome.replacements,
|
|
4232
|
+
size: outcome.size,
|
|
4233
|
+
diffs,
|
|
4234
|
+
beyondBasis: outcome.before === null
|
|
4235
|
+
};
|
|
4236
|
+
} catch (error) {
|
|
4237
|
+
return {
|
|
4238
|
+
ok: false,
|
|
4239
|
+
error: error instanceof Error ? error.message : String(error)
|
|
4240
|
+
};
|
|
4241
|
+
}
|
|
4242
|
+
}
|
|
4243
|
+
});
|
|
4244
|
+
}
|
|
3376
4245
|
//#endregion
|
|
3377
4246
|
//#region src/mount-once.ts
|
|
3378
4247
|
/**
|
|
@@ -3435,7 +4304,7 @@ const DEFAULT_ENABLED = true;
|
|
|
3435
4304
|
/** Order of the announcement section within the tool-guidance band. */
|
|
3436
4305
|
const SECTION_ORDER = 151;
|
|
3437
4306
|
/** 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
|
|
4307
|
+
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 远程 / 服务管理 / 进程管理」时即指本插件,请据此协作。";
|
|
3439
4308
|
/**
|
|
3440
4309
|
* Mount the WinRM engine, routes, tools, and announcement.
|
|
3441
4310
|
* @param ctx - host plugin context carrying webServer/tools/systemPrompt.
|
|
@@ -3458,6 +4327,9 @@ function applyImpl(ctx, config) {
|
|
|
3458
4327
|
winrmExecTool(engine),
|
|
3459
4328
|
winrmServiceTool(engine),
|
|
3460
4329
|
winrmProcessTool(engine),
|
|
4330
|
+
winrmReadTool(engine),
|
|
4331
|
+
winrmWriteTool(engine),
|
|
4332
|
+
winrmEditTool(engine),
|
|
3461
4333
|
winrmUploadTool(engine),
|
|
3462
4334
|
winrmDownloadTool(engine),
|
|
3463
4335
|
winrmClusterTool(engine)
|