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