dsh-remote-workspaces 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/client.js +8 -0
- package/src/index.js +93 -23
- package/src/registry.js +18 -4
- package/src/search.js +72 -8
- package/src/shell-exec.js +42 -1
- package/src/transport.js +348 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-workspaces",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Open folders on remote hosts over SSH as DeepSeek Harness workspaces — a non-invasive DSH bundle.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"cordis.patch.yml"
|
|
22
22
|
],
|
|
23
23
|
"scripts": {
|
|
24
|
-
"test": "node test/unit-anchor-path.mjs && node test/unit-secrets-encryption.mjs && node test/unit-fs.mjs && node test/unit-shell.mjs && node test/unit-routing.mjs && node test/unit-remote-policy.mjs && node test/unit-search-registration.mjs",
|
|
24
|
+
"test": "node test/unit-anchor-path.mjs && node test/unit-secrets-encryption.mjs && node test/unit-fs.mjs && node test/unit-shell.mjs && node test/unit-routing.mjs && node test/unit-remote-policy.mjs && node test/unit-search-registration.mjs && node test/unit-win-remote.mjs && node test/unit-registry-os.mjs",
|
|
25
25
|
"test:integration": "node test/integration-remote.mjs"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
package/src/client.js
CHANGED
|
@@ -809,6 +809,14 @@ window.__ModuleLoader__.load({
|
|
|
809
809
|
'.dsh-rw-btn button:disabled{opacity:.5;cursor:not-allowed}',
|
|
810
810
|
'.dsh-rw-btn button:focus-visible{outline:2px solid #6e56cf;outline-offset:2px}',
|
|
811
811
|
'.dsh-rw-btn input:focus,.dsh-rw-btn select:focus{box-shadow:0 0 0 1px #6e56cf}',
|
|
812
|
+
// The host chooser sits inside the always-dark modal (modalBg #1f1f21,
|
|
813
|
+
// text #e8e8e8), but a native <select> renders its option popup on the
|
|
814
|
+
// OS theme. Without dark color-scheme the light inherited text lands on
|
|
815
|
+
// a light popup and the host list is unreadable. Force the popup dark,
|
|
816
|
+
// and give the options an explicit light-on-dark fallback.
|
|
817
|
+
'.dsh-rw-btn select{color-scheme:dark}',
|
|
818
|
+
'.dsh-rw-btn select option{color:#e8e8e8;background:#1f1f21}',
|
|
819
|
+
'.dsh-rw-btn select option:checked{background:#6e56cf;color:#fff}',
|
|
812
820
|
].join('\n')
|
|
813
821
|
document.head.appendChild(styleEl)
|
|
814
822
|
}
|
package/src/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { loadMachines, sanitizeMachine, upsertMachine, removeMachine, machineByI
|
|
|
4
4
|
import { ensureAnchor } from './anchor.js'
|
|
5
5
|
import { RoutingFileSystem } from './routing-fs.js'
|
|
6
6
|
import { SshShellExecutor } from './shell-exec.js'
|
|
7
|
-
import { registerAnchor, unregisterAnchor, findByCwd } from './registry.js'
|
|
7
|
+
import { registerAnchor, unregisterAnchor, findByCwd, updateAnchorOs } from './registry.js'
|
|
8
8
|
import { applySearchTools } from './search.js'
|
|
9
9
|
|
|
10
10
|
export { parseSshConfig, expandTilde } from './ssh-config.js'
|
|
@@ -93,6 +93,13 @@ function messageOf(error) {
|
|
|
93
93
|
return error instanceof Error ? error.message : String(error)
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/** Directory flag from an SFTP attrs record (protocol-level, locale-free). */
|
|
97
|
+
function attrsIsDir(st) {
|
|
98
|
+
if (st && typeof st.isDirectory === 'function') return st.isDirectory()
|
|
99
|
+
if (st && typeof st.mode === 'number') return (st.mode & 0o170000) === 0o040000
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
|
|
96
103
|
/**
|
|
97
104
|
* Build an `SshClient` for a remote host, preferring a saved machine's
|
|
98
105
|
* credentials (password/identityFile/passphrase) and falling back to
|
|
@@ -104,31 +111,62 @@ function clientForRemote(host, user, port) {
|
|
|
104
111
|
return machine ? sshClientFor(machine) : clientForHost(host)
|
|
105
112
|
}
|
|
106
113
|
|
|
114
|
+
const OS_BACKFILL_QUEUED = new Set()
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Lazily backfill the `os` profile on an anchor row that predates B4 (kicked
|
|
118
|
+
* once per anchor per process, fire-and-forget). Prompt hints re-read the
|
|
119
|
+
* registry on every render, so the next prompt after the probe sees the OS.
|
|
120
|
+
*/
|
|
121
|
+
function kickOsBackfill(cwd) {
|
|
122
|
+
if (typeof cwd !== 'string' || cwd === '') return
|
|
123
|
+
let hit
|
|
124
|
+
try { hit = findByCwd(cwd) } catch { return }
|
|
125
|
+
if (hit === undefined || (hit.os && hit.os.family !== undefined)) return
|
|
126
|
+
if (OS_BACKFILL_QUEUED.has(hit.anchorPath)) return
|
|
127
|
+
OS_BACKFILL_QUEUED.add(hit.anchorPath)
|
|
128
|
+
void (async () => {
|
|
129
|
+
try {
|
|
130
|
+
const client = clientForRemote(hit.host, hit.user, hit.port)
|
|
131
|
+
const profile = await client.profile()
|
|
132
|
+
if (profile !== undefined && (profile.family === 'posix' || profile.family === 'windows')) {
|
|
133
|
+
updateAnchorOs(hit.anchorPath, { family: profile.family, os: profile.os, shell: profile.shell })
|
|
134
|
+
}
|
|
135
|
+
} catch { /* keep the row without os */ }
|
|
136
|
+
})()
|
|
137
|
+
}
|
|
138
|
+
|
|
107
139
|
/**
|
|
108
140
|
* Resolve a raw browse path (home, `~`, `~/x`, relative, or absolute) to an
|
|
109
141
|
* absolute remote path so the client can navigate "up" past home to `/` and
|
|
110
|
-
* display a real path.
|
|
142
|
+
* display a real path. Accepts an optional open SFTP facade to reuse across
|
|
143
|
+
* resolve + listing; otherwise opens its own channel. Resolution goes over
|
|
144
|
+
* SFTP (`realpath`), so it works identically on POSIX and Windows targets and
|
|
145
|
+
* never parses localized shell output.
|
|
111
146
|
*/
|
|
112
|
-
async function resolveRemotePath(client, raw) {
|
|
147
|
+
async function resolveRemotePath(client, raw, sharedSftp) {
|
|
113
148
|
const trimmed = raw === undefined || raw === null ? '' : String(raw).trim()
|
|
114
|
-
let
|
|
149
|
+
let owned
|
|
150
|
+
const sftp = async () => {
|
|
151
|
+
if (sharedSftp !== undefined) return sharedSftp
|
|
152
|
+
owned = owned ?? await client.sftp()
|
|
153
|
+
return owned
|
|
154
|
+
}
|
|
115
155
|
try {
|
|
116
156
|
if (trimmed === '' || trimmed === '~' || trimmed === '~/') {
|
|
117
|
-
|
|
118
|
-
return await sftp.realpath('.')
|
|
157
|
+
return await (await sftp()).realpath('.')
|
|
119
158
|
}
|
|
120
159
|
if (trimmed.startsWith('~/')) {
|
|
121
|
-
|
|
122
|
-
const home = String(await
|
|
160
|
+
const s = await sftp()
|
|
161
|
+
const home = String(await s.realpath('.')).replace(/\/+$/, '')
|
|
123
162
|
return `${home}/${trimmed.slice(2)}`
|
|
124
163
|
}
|
|
125
164
|
if (!trimmed.startsWith('/')) {
|
|
126
|
-
|
|
127
|
-
return await sftp.realpath(trimmed)
|
|
165
|
+
return await (await sftp()).realpath(trimmed)
|
|
128
166
|
}
|
|
129
167
|
return trimmed
|
|
130
168
|
} finally {
|
|
131
|
-
if (
|
|
169
|
+
if (owned !== undefined) owned.end()
|
|
132
170
|
}
|
|
133
171
|
}
|
|
134
172
|
|
|
@@ -183,22 +221,35 @@ function remoteWorkspacesService() {
|
|
|
183
221
|
},
|
|
184
222
|
|
|
185
223
|
async listRemoteDir(machine, path) {
|
|
186
|
-
//
|
|
224
|
+
// Pure-SFTP listing (readdir + attrs) — the SAME channel the fs backend
|
|
225
|
+
// uses, so Windows targets work: no `ls`, no localized output parsing,
|
|
226
|
+
// no dependency on the remote's default shell.
|
|
187
227
|
const client = sshClientFor(machine)
|
|
188
|
-
let
|
|
228
|
+
let sftp
|
|
229
|
+
try {
|
|
230
|
+
sftp = await client.sftp()
|
|
231
|
+
} catch (error) {
|
|
232
|
+
return { ok: false, error: `无法建立 SFTP 连接:${messageOf(error)}` }
|
|
233
|
+
}
|
|
189
234
|
try {
|
|
190
|
-
absPath = await resolveRemotePath(client, path)
|
|
235
|
+
const absPath = await resolveRemotePath(client, path, sftp)
|
|
236
|
+
let list
|
|
237
|
+
try {
|
|
238
|
+
list = await sftp.readdir(absPath)
|
|
239
|
+
} catch (error) {
|
|
240
|
+
const missing = error && (error.code === 2 || /no such file|not exist|找不到/i.test(String(error.message)))
|
|
241
|
+
return { ok: false, error: missing ? `目录不存在:${absPath}` : `无法读取目录:${messageOf(error)}` }
|
|
242
|
+
}
|
|
243
|
+
const entries = (Array.isArray(list) ? list : [])
|
|
244
|
+
.filter((e) => e && e.filename !== '.' && e.filename !== '..')
|
|
245
|
+
.map((e) => ({ name: e.filename, dir: attrsIsDir(e.attrs) }))
|
|
246
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
247
|
+
return { ok: true, entries, path: absPath }
|
|
191
248
|
} catch (error) {
|
|
192
249
|
return { ok: false, error: `无法解析目录:${messageOf(error)}` }
|
|
250
|
+
} finally {
|
|
251
|
+
sftp.end()
|
|
193
252
|
}
|
|
194
|
-
const command = absPath === '' ? 'ls -1Ap' : `ls -1Ap ${shellQuote(absPath)}`
|
|
195
|
-
const res = await client.run(command)
|
|
196
|
-
if (!res.ok) return { ok: false, error: (res.stderr ?? '').trim() || res.error || '列出目录失败' }
|
|
197
|
-
const entries = res.stdout
|
|
198
|
-
.split('\n')
|
|
199
|
-
.filter((name) => name !== '')
|
|
200
|
-
.map((name) => ({ name: name.replace(/\/$/, ''), dir: name.endsWith('/') }))
|
|
201
|
-
return { ok: true, entries, path: absPath }
|
|
202
253
|
},
|
|
203
254
|
|
|
204
255
|
/**
|
|
@@ -220,6 +271,16 @@ function remoteWorkspacesService() {
|
|
|
220
271
|
const rel = path === undefined || path === '' ? '.' : path
|
|
221
272
|
const remotePath = await sftp.realpath(rel)
|
|
222
273
|
const anchorPath = ensureAnchor(machine, remotePath)
|
|
274
|
+
// Probe the remote profile once so prompt/dialect hints can declare
|
|
275
|
+
// the actual shell (PowerShell on Windows remotes). Failure to probe
|
|
276
|
+
// only leaves the hint off — never fails the open.
|
|
277
|
+
let os
|
|
278
|
+
try {
|
|
279
|
+
const profile = await client.profile()
|
|
280
|
+
if (profile !== undefined && (profile.family === 'posix' || profile.family === 'windows')) {
|
|
281
|
+
os = { family: profile.family, os: profile.os, shell: profile.shell }
|
|
282
|
+
}
|
|
283
|
+
} catch { /* os stays undefined */ }
|
|
223
284
|
registerAnchor({
|
|
224
285
|
anchorPath,
|
|
225
286
|
machineId: machine?.id,
|
|
@@ -227,6 +288,7 @@ function remoteWorkspacesService() {
|
|
|
227
288
|
port: machine?.port ?? null,
|
|
228
289
|
user: machine?.user ?? null,
|
|
229
290
|
remotePath,
|
|
291
|
+
...(os !== undefined ? { os } : {}),
|
|
230
292
|
})
|
|
231
293
|
return { ok: true, localDir: anchorPath, remotePath }
|
|
232
294
|
} catch (error) {
|
|
@@ -285,6 +347,8 @@ export function apply(ctx) {
|
|
|
285
347
|
if (typeof cwd !== 'string' || cwd === '') return cwd
|
|
286
348
|
const hit = findByCwd(cwd)
|
|
287
349
|
if (hit === undefined) return cwd
|
|
350
|
+
// Side effect: backfill the os profile on rows registered before B4.
|
|
351
|
+
kickOsBackfill(cwd)
|
|
288
352
|
return hit.remoteSubpath === '' ? hit.remotePath : `${hit.remotePath.replace(/\/+$/, '')}/${hit.remoteSubpath}`
|
|
289
353
|
})
|
|
290
354
|
})
|
|
@@ -303,8 +367,14 @@ export function apply(ctx) {
|
|
|
303
367
|
if (typeof cwd !== 'string' || cwd === '') return ''
|
|
304
368
|
const hit = findByCwd(cwd)
|
|
305
369
|
if (hit === undefined) return ''
|
|
370
|
+
// Side effect: backfill the os profile on rows registered before B4.
|
|
371
|
+
kickOsBackfill(cwd)
|
|
306
372
|
const host = hit.user ? `${hit.user}@${hit.host}` : hit.host
|
|
307
|
-
|
|
373
|
+
const base = `Remote workspace over SSH (${host}): file/search tools and shell commands run on the remote host; use relative paths (they route to the remote automatically).`
|
|
374
|
+
if (hit.os?.family === 'windows') {
|
|
375
|
+
return base + ' The remote shell is PowerShell — write PowerShell syntax (built-in aliases like ls/cat/cd/pwd work; bash-only syntax such as && or 2>/dev/null does not).'
|
|
376
|
+
}
|
|
377
|
+
return base
|
|
308
378
|
},
|
|
309
379
|
})
|
|
310
380
|
})
|
package/src/registry.js
CHANGED
|
@@ -34,14 +34,28 @@ function saveAnchors(anchors) {
|
|
|
34
34
|
writeFileSync(anchorsPath(), JSON.stringify(anchors, null, 2) + '\n', 'utf8')
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
/** Record an anchor (idempotent by anchorPath). Returns the stored record.
|
|
38
|
-
|
|
37
|
+
/** Record an anchor (idempotent by anchorPath). Returns the stored record.
|
|
38
|
+
* `os` (optional) is the probed remote profile `{ family, os, shell }` used
|
|
39
|
+
* by prompt/dialect hints; older rows without it are backfilled lazily. */
|
|
40
|
+
export function registerAnchor({ anchorPath, machineId, host, port, user, remotePath, os }) {
|
|
39
41
|
const anchors = loadAnchors()
|
|
40
|
-
|
|
42
|
+
const rec = { machineId, host, port, user, remotePath, registeredAt: new Date().toISOString() }
|
|
43
|
+
if (os !== undefined && os !== null) rec.os = os
|
|
44
|
+
anchors[anchorPath] = rec
|
|
41
45
|
saveAnchors(anchors)
|
|
42
46
|
return anchors[anchorPath]
|
|
43
47
|
}
|
|
44
48
|
|
|
49
|
+
/** Backfill (or replace) the probed remote profile on an existing anchor row. */
|
|
50
|
+
export function updateAnchorOs(anchorPath, os) {
|
|
51
|
+
const anchors = loadAnchors()
|
|
52
|
+
const rec = anchors[anchorPath]
|
|
53
|
+
if (rec === undefined) return undefined
|
|
54
|
+
rec.os = os
|
|
55
|
+
saveAnchors(anchors)
|
|
56
|
+
return rec
|
|
57
|
+
}
|
|
58
|
+
|
|
45
59
|
/** Remove one anchor by its local path. */
|
|
46
60
|
export function unregisterAnchor(anchorPath) {
|
|
47
61
|
const anchors = loadAnchors()
|
|
@@ -73,4 +87,4 @@ export function findByCwd(cwd) {
|
|
|
73
87
|
return { ...best, remoteSubpath: rel === '' ? '' : rel.split(sep).join('/') }
|
|
74
88
|
}
|
|
75
89
|
|
|
76
|
-
export default { loadAnchors, registerAnchor, unregisterAnchor, findByCwd, remoteWorkspacesRoot }
|
|
90
|
+
export default { loadAnchors, registerAnchor, unregisterAnchor, updateAnchorOs, findByCwd, remoteWorkspacesRoot }
|
package/src/search.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { posix } from 'node:path'
|
|
14
|
-
import { shellQuote } from './transport.js'
|
|
14
|
+
import { shellQuote, psQuote, toWinPath, profileCacheKey } from './transport.js'
|
|
15
15
|
import { findByCwd } from './registry.js'
|
|
16
16
|
|
|
17
17
|
const GREP_MAX_MATCHES = 250
|
|
@@ -21,6 +21,9 @@ const SEARCH_TIMEOUT_MS = 30_000
|
|
|
21
21
|
const STDERR_MAX_BYTES = 64 * 1024
|
|
22
22
|
const GRACE_MS = 3_000
|
|
23
23
|
|
|
24
|
+
/** One-time rg-presence verdict per Windows target (process lifetime). */
|
|
25
|
+
const RG_PRESENCE_CACHE = new Map()
|
|
26
|
+
|
|
24
27
|
// ---------------------------------------------------------------------------
|
|
25
28
|
// ripgrep argv (mirrors @deepseek-ai/dsh-tool-fs-search)
|
|
26
29
|
// ---------------------------------------------------------------------------
|
|
@@ -108,11 +111,57 @@ async function localRg(deps, argv, cwd, signal) {
|
|
|
108
111
|
if (outcome.exitCode !== 0 && outcome.exitCode !== 1) {
|
|
109
112
|
throw new Error(`grep/glob: rg failed (exit ${outcome.exitCode}): ${stderr?.text ?? ''}`)
|
|
110
113
|
}
|
|
111
|
-
return stdout?.text ?? ''
|
|
114
|
+
return { stdout: stdout?.text ?? '', windows: false }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Build the remote `rg` command line for one profile. POSIX keeps the legacy
|
|
119
|
+
* single-quoted form. Windows runs rg.exe inside the per-shell PowerShell
|
|
120
|
+
* script, so EVERY token is PowerShell-single-quoted (patterns with spaces or
|
|
121
|
+
* apostrophes stay one argument) and the search root — the token after `--`,
|
|
122
|
+
* which arrives in the canonical `/X:/…` SFTP form — is converted with
|
|
123
|
+
* `toWinPath` because native rg.exe does not understand a leading-slash drive
|
|
124
|
+
* path. `--path-separator=/` forces forward-slash paths in rg's JSON/file
|
|
125
|
+
* output, matching every other path the plugin surfaces (native rg.exe would
|
|
126
|
+
* otherwise print `src\file.js`). `--regexp`/`--glob` values are pattern text
|
|
127
|
+
* and pass through as-is.
|
|
128
|
+
*/
|
|
129
|
+
export function remoteRgCommand(profile, argv) {
|
|
130
|
+
if (profile !== undefined && profile.family === 'windows') {
|
|
131
|
+
const parts = [psQuote('--path-separator=/')]
|
|
132
|
+
let isRoot = false
|
|
133
|
+
for (const token of argv) {
|
|
134
|
+
parts.push(isRoot ? psQuote(toWinPath(token)) : psQuote(token))
|
|
135
|
+
isRoot = token === '--'
|
|
136
|
+
}
|
|
137
|
+
return `rg ${parts.join(' ')}`
|
|
138
|
+
}
|
|
139
|
+
return ['rg', ...argv].map(shellQuote).join(' ')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Verify ripgrep exists on a Windows target once per process. A missing `rg`
|
|
144
|
+
* must NOT surface as the glue's exit 1 (which the caller reads as "no
|
|
145
|
+
* matches"), so it is detected up front and reported with an install hint.
|
|
146
|
+
*/
|
|
147
|
+
async function ensureRemoteRg(client) {
|
|
148
|
+
const key = profileCacheKey(client)
|
|
149
|
+
let present = RG_PRESENCE_CACHE.get(key)
|
|
150
|
+
if (present === undefined) {
|
|
151
|
+
const probe = await client.run('rg --version')
|
|
152
|
+
present = probe.ok
|
|
153
|
+
RG_PRESENCE_CACHE.set(key, present)
|
|
154
|
+
}
|
|
155
|
+
if (!present) {
|
|
156
|
+
throw new Error('grep/glob: ripgrep was not found on the remote Windows host. Install it first (e.g. winget install BurntSushi.ripgrep.MSVC or scoop install ripgrep) and retry')
|
|
157
|
+
}
|
|
112
158
|
}
|
|
113
159
|
|
|
114
160
|
async function remoteRg(client, argv, remoteCwd, signal) {
|
|
115
|
-
const
|
|
161
|
+
const profile = await client.profile()
|
|
162
|
+
const windows = profile.family === 'windows'
|
|
163
|
+
if (windows) await ensureRemoteRg(client)
|
|
164
|
+
const command = remoteRgCommand(profile, argv)
|
|
116
165
|
const result = await client.execShell(command, {
|
|
117
166
|
cwd: remoteCwd,
|
|
118
167
|
timeoutMs: SEARCH_TIMEOUT_MS,
|
|
@@ -124,7 +173,16 @@ async function remoteRg(client, argv, remoteCwd, signal) {
|
|
|
124
173
|
if (result.exitCode !== 0 && result.exitCode !== 1) {
|
|
125
174
|
throw new Error(`grep/glob: remote rg failed (exit ${result.exitCode}): ${result.stderr.text}`)
|
|
126
175
|
}
|
|
127
|
-
return result.stdout.text
|
|
176
|
+
return { stdout: result.stdout.text, windows }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* rg.exe ignores `--path-separator` for the JSON match records it emits, so
|
|
181
|
+
* grep paths come back with native backslashes on Windows remotes. Normalize
|
|
182
|
+
* to forward slashes so every surfaced path matches the canonical form.
|
|
183
|
+
*/
|
|
184
|
+
function normalizeWindowsPath(value) {
|
|
185
|
+
return String(value).replace(/\\/g, '/')
|
|
128
186
|
}
|
|
129
187
|
|
|
130
188
|
// ---------------------------------------------------------------------------
|
|
@@ -216,8 +274,13 @@ export function createSearchTools(deps) {
|
|
|
216
274
|
...(args.path !== undefined ? { path: args.path } : {}),
|
|
217
275
|
...(args.include !== undefined ? { include: args.include } : {}),
|
|
218
276
|
}
|
|
219
|
-
const stdout = await runRg(exec, grepArgv, input)
|
|
220
|
-
|
|
277
|
+
const { stdout, windows } = await runRg(exec, grepArgv, input)
|
|
278
|
+
const matches = parseGrepMatches(stdout).slice(0, GREP_MAX_MATCHES)
|
|
279
|
+
return {
|
|
280
|
+
matches: windows
|
|
281
|
+
? matches.map((m) => ({ ...m, path: normalizeWindowsPath(m.path) }))
|
|
282
|
+
: matches,
|
|
283
|
+
}
|
|
221
284
|
},
|
|
222
285
|
}
|
|
223
286
|
|
|
@@ -248,8 +311,9 @@ export function createSearchTools(deps) {
|
|
|
248
311
|
},
|
|
249
312
|
async execute(args, exec) {
|
|
250
313
|
const input = { pattern: args.pattern, ...(args.path !== undefined ? { path: args.path } : {}) }
|
|
251
|
-
const stdout = await runRg(exec, globArgv, input)
|
|
252
|
-
|
|
314
|
+
const { stdout, windows } = await runRg(exec, globArgv, input)
|
|
315
|
+
const paths = parseGlobPaths(stdout).slice(0, GLOB_MAX_RESULTS)
|
|
316
|
+
return { paths: windows ? paths.map(normalizeWindowsPath) : paths }
|
|
253
317
|
},
|
|
254
318
|
}
|
|
255
319
|
|
package/src/shell-exec.js
CHANGED
|
@@ -226,6 +226,7 @@ export class SshShellExecutor {
|
|
|
226
226
|
let buffer = ''
|
|
227
227
|
let spawnError
|
|
228
228
|
let pollTimer
|
|
229
|
+
let streamCtl = null
|
|
229
230
|
|
|
230
231
|
const proc = {
|
|
231
232
|
status: 'running',
|
|
@@ -240,10 +241,50 @@ export class SshShellExecutor {
|
|
|
240
241
|
kill() {
|
|
241
242
|
if (proc.status !== 'running') return false
|
|
242
243
|
proc.status = 'killed'
|
|
243
|
-
if (
|
|
244
|
+
if (streamCtl !== null) streamCtl.terminate()
|
|
245
|
+
else if (pid !== null) void client.run(`kill ${pid} 2>/dev/null || true`)
|
|
244
246
|
return true
|
|
245
247
|
},
|
|
246
248
|
done: (async () => {
|
|
249
|
+
const profile = await client.profile()
|
|
250
|
+
if (profile.family === 'windows') {
|
|
251
|
+
// No nohup-style detach exists on Windows remotes, and closing the
|
|
252
|
+
// channel alone does NOT reap the remote tree (verified) - so the
|
|
253
|
+
// job is a long-lived exec stream whose terminate() actively
|
|
254
|
+
// taskkill /T's the remote PID and then closes the channel.
|
|
255
|
+
const ctl = await client.execStream(spec.command, { cwd: path })
|
|
256
|
+
if (proc.status !== 'running') { ctl.terminate(); return }
|
|
257
|
+
streamCtl = ctl
|
|
258
|
+
const merge = () => {
|
|
259
|
+
const outPart = ctl.readOut()
|
|
260
|
+
const errPart = ctl.readErr()
|
|
261
|
+
if (outPart.delta.length > 0) {
|
|
262
|
+
if (buffer.length > 0 && !buffer.endsWith('\n')) buffer += '\n'
|
|
263
|
+
buffer += outPart.delta
|
|
264
|
+
}
|
|
265
|
+
if (errPart.delta.length > 0) {
|
|
266
|
+
if (buffer.length > 0 && !buffer.endsWith('\n')) buffer += '\n'
|
|
267
|
+
buffer += '[stderr]\n' + errPart.delta
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (spec.signal !== undefined) {
|
|
271
|
+
const onAbort = () => { if (streamCtl !== null) streamCtl.terminate() }
|
|
272
|
+
if (spec.signal.aborted) onAbort()
|
|
273
|
+
else spec.signal.addEventListener('abort', onAbort, { once: true })
|
|
274
|
+
}
|
|
275
|
+
const pump = async () => {
|
|
276
|
+
while (proc.status === 'running') {
|
|
277
|
+
merge()
|
|
278
|
+
await new Promise((r) => setTimeout(r, 150))
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
void pump()
|
|
282
|
+
const outcome = await ctl.exit
|
|
283
|
+
merge()
|
|
284
|
+
if (proc.status === 'running') proc.status = 'completed'
|
|
285
|
+
proc.exitCode = typeof outcome.exitCode === 'number' ? outcome.exitCode : null
|
|
286
|
+
return
|
|
287
|
+
}
|
|
247
288
|
const launched = await client.run(launchScript)
|
|
248
289
|
if (!launched.ok) {
|
|
249
290
|
spawnError = new Error((launched.stderr ?? '').trim() || launched.error || 'background spawn failed')
|
package/src/transport.js
CHANGED
|
@@ -12,6 +12,167 @@ export function shellQuote(value) {
|
|
|
12
12
|
return `'${String(value).replace(/'/g, `'\\''`)}'`
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Convert a canonical REMOTE path — as the SFTP `realpath` reports it, which
|
|
17
|
+
* is POSIX-style even on Windows hosts (`/X:/work/demo`) — into the form a
|
|
18
|
+
* Windows shell accepts (`X:/work/demo`). PowerShell understands forward
|
|
19
|
+
* slashes, so only a leading `/X:` drive prefix is stripped; everything else
|
|
20
|
+
* passes through untouched.
|
|
21
|
+
*/
|
|
22
|
+
export function toWinPath(value) {
|
|
23
|
+
const s = String(value ?? '')
|
|
24
|
+
return /^\/[A-Za-z]:/.test(s) ? s.slice(1) : s
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** PowerShell single-quote escaping for a path/string literal. */
|
|
28
|
+
export function psQuote(value) {
|
|
29
|
+
return `'${String(value).replace(/'/g, "''")}'`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Strip PowerShell progress records from a stderr capture. When a nested
|
|
34
|
+
* powershell is launched by a Windows OpenSSH exec channel (e.g. the cmd
|
|
35
|
+
* default-shell wrapper), the engine's first-run module-analysis warm-up emits
|
|
36
|
+
* a `#< CLIXML …</Objs>` progress blob to stderr on every fresh process —
|
|
37
|
+
* real cmdlet errors still arrive as plain text, so removing only CLIXML
|
|
38
|
+
* blocks keeps genuine diagnostics intact.
|
|
39
|
+
*/
|
|
40
|
+
export function stripPsProgressClixml(text) {
|
|
41
|
+
const lines = String(text ?? '').split('\n')
|
|
42
|
+
const out = []
|
|
43
|
+
let inBlob = false
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
if (!inBlob && /#< CLIXML/i.test(line)) {
|
|
46
|
+
inBlob = !/<\/Objs>/i.test(line)
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
if (inBlob) {
|
|
50
|
+
if (/<\/Objs>/i.test(line)) { inBlob = false; continue }
|
|
51
|
+
// XML record lines are dropped; a non-XML text line ends a truncated
|
|
52
|
+
// blob so genuine diagnostics that follow it are preserved.
|
|
53
|
+
if (!/^\s*</.test(line) && line.trim() !== '') { inBlob = false; out.push(line); continue }
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
out.push(line)
|
|
57
|
+
}
|
|
58
|
+
return out.join('\n')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whole-text CRLF→LF (Windows remote output normalization). */
|
|
62
|
+
export function crlfToLf(text) {
|
|
63
|
+
return String(text ?? '').replace(/\r\n/g, '\n')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Streaming CRLF→LF normalizer that carries a trailing `\r` across chunk
|
|
68
|
+
* boundaries (a `\r\n` pair may be split between two data events). `feed`
|
|
69
|
+
* returns the normalized fragment; call `flush()` at stream end for a lone
|
|
70
|
+
* trailing `\r` (isolated CRs are preserved, not folded).
|
|
71
|
+
*/
|
|
72
|
+
export function createCrlfToLf() {
|
|
73
|
+
let carry = ''
|
|
74
|
+
return {
|
|
75
|
+
feed(text) {
|
|
76
|
+
const s = String(text ?? '')
|
|
77
|
+
let out = carry + s
|
|
78
|
+
carry = ''
|
|
79
|
+
out = out.replace(/\r\n/g, '\n')
|
|
80
|
+
if (out.endsWith('\r')) {
|
|
81
|
+
carry = '\r'
|
|
82
|
+
out = out.slice(0, -1)
|
|
83
|
+
}
|
|
84
|
+
return out
|
|
85
|
+
},
|
|
86
|
+
flush() {
|
|
87
|
+
const rest = carry
|
|
88
|
+
carry = ''
|
|
89
|
+
return rest
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Exit-code glue appended to every Windows remote script so the channel's
|
|
96
|
+
* exit status mirrors what a POSIX shell reports: the last command's result.
|
|
97
|
+
* `exit N` inside the user's own command terminates first and wins.
|
|
98
|
+
*/
|
|
99
|
+
const PS_EXIT_GLUE = 'if ($?) { exit $LASTEXITCODE } else { exit 1 }'
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Remote execution profiles, probed once per target and cached for the
|
|
103
|
+
* process lifetime:
|
|
104
|
+
* { family: 'posix', os: 'linux'|'darwin', shell: 'posix' }
|
|
105
|
+
* { family: 'windows', os: 'windows', shell: 'powershell'|'cmd' }
|
|
106
|
+
* { family: 'unknown', … } — callers fall back to POSIX behaviour.
|
|
107
|
+
*/
|
|
108
|
+
const REMOTE_PROFILE_CACHE = new Map()
|
|
109
|
+
|
|
110
|
+
export function profileCacheKey({ host, user, port }) {
|
|
111
|
+
return `${user ?? ''}@${host ?? ''}:${port ?? 22}`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Detect a target's OS family and default exec shell without parsing
|
|
116
|
+
* localized output: `uname -s` proves a POSIX shell; `cmd /c ver` proves
|
|
117
|
+
* Windows (it runs from cmd, PowerShell and Git Bash defaults alike); a
|
|
118
|
+
* `$PSVersionTable` expression then tells cmd from PowerShell as the default.
|
|
119
|
+
*/
|
|
120
|
+
export async function probeRemoteProfile(client) {
|
|
121
|
+
const uname = await client.run('uname -s')
|
|
122
|
+
if (uname.ok) {
|
|
123
|
+
const os = (uname.stdout ?? '').trim().toLowerCase()
|
|
124
|
+
return { family: 'posix', os: os.startsWith('darwin') ? 'darwin' : 'linux', shell: 'posix' }
|
|
125
|
+
}
|
|
126
|
+
const ver = await client.run('cmd /c ver')
|
|
127
|
+
if (ver.ok && /windows/i.test(ver.stdout ?? '')) {
|
|
128
|
+
const ps = await client.run('$PSVersionTable.PSVersion.ToString()')
|
|
129
|
+
return ps.ok
|
|
130
|
+
? { family: 'windows', os: 'windows', shell: 'powershell' }
|
|
131
|
+
: { family: 'windows', os: 'windows', shell: 'cmd' }
|
|
132
|
+
}
|
|
133
|
+
return { family: 'unknown', os: 'unknown', shell: 'unknown' }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Encode a PowerShell script for quote-safe transport through cmd.exe.
|
|
137
|
+
*
|
|
138
|
+
* `powershell -EncodedCommand` would be ideal (no quoting at all), but a
|
|
139
|
+
* nested -EncodedCommand host serializes BOTH progress and error records to
|
|
140
|
+
* stderr as CLIXML soup. A `-Command` host prints plain text, so instead the
|
|
141
|
+
* script rides inside one fixed, quote-free command line: base64 has no
|
|
142
|
+
* `% ! ^ " $ '` and the wrapper has no `$`, so neither cmd nor a PowerShell
|
|
143
|
+
* outer shell can mangle it, and the inner text is decoded + `iex`'d.
|
|
144
|
+
*/
|
|
145
|
+
export function psCommandEnvelope(script) {
|
|
146
|
+
const b64 = Buffer.from(script, 'utf16le').toString('base64')
|
|
147
|
+
return `powershell -NoProfile -NonInteractive -Command "& { iex ([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('${b64}'))) }"`
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Build the exec-channel script for one command under a remote profile.
|
|
152
|
+
*
|
|
153
|
+
* POSIX / unknown: unchanged legacy form (`cd 'x' || exit 1` + command).
|
|
154
|
+
*
|
|
155
|
+
* Windows with a PowerShell default shell: the raw script text is executed
|
|
156
|
+
* by the server's default shell directly (cmdlets + aliases like ls/cat/cd
|
|
157
|
+
* make simple commands work), prefixed with a `Set-Location` when a cwd is
|
|
158
|
+
* given and suffixed with the exit-code glue. Windows with a cmd default
|
|
159
|
+
* shell: the same script rides inside a fixed, quote-free
|
|
160
|
+
* `powershell -Command` envelope (base64 + iex), so cmd only ever parses one
|
|
161
|
+
* fixed command line, its exit code is the child's, and stderr stays plain
|
|
162
|
+
* text (no CLIXML records).
|
|
163
|
+
*/
|
|
164
|
+
export function buildExecScript(profile, command, cwd) {
|
|
165
|
+
if (profile === undefined || profile.family !== 'windows') {
|
|
166
|
+
return cwd ? `cd ${shellQuote(cwd)} || exit 1\n${command}` : command
|
|
167
|
+
}
|
|
168
|
+
const script = [
|
|
169
|
+
...(cwd ? [`Set-Location -LiteralPath ${psQuote(toWinPath(cwd))}`] : []),
|
|
170
|
+
command,
|
|
171
|
+
PS_EXIT_GLUE,
|
|
172
|
+
].join('\n')
|
|
173
|
+
return profile.shell === 'cmd' ? psCommandEnvelope(script) : script
|
|
174
|
+
}
|
|
175
|
+
|
|
15
176
|
/** Bounded output collector: keeps the TAIL of a stream, flags truncation. */
|
|
16
177
|
class CapCollector {
|
|
17
178
|
constructor(maxBytes) {
|
|
@@ -193,9 +354,15 @@ export class SshClient {
|
|
|
193
354
|
* bounded (tail-kept) stdout/stderr, stdin, and an abort signal that closes
|
|
194
355
|
* the exec channel (SIGHUP on the remote). Resolves with exitCode/signal,
|
|
195
356
|
* timedOut/aborted first-cause, and `{ text, truncated }` outputs.
|
|
357
|
+
*
|
|
358
|
+
* The command text is executed through the target's default shell. On
|
|
359
|
+
* Windows targets the script is built per detected default shell
|
|
360
|
+
* (`buildExecScript`): raw PowerShell when PowerShell is the default, a
|
|
361
|
+
* quote-safe `powershell -EncodedCommand` wrapper when cmd is.
|
|
196
362
|
*/
|
|
197
363
|
async execShell(command, { cwd, timeoutMs = 60000, stdoutMaxBytes = 64000, stderrMaxBytes = 64000, stdin, signal } = {}) {
|
|
198
|
-
const
|
|
364
|
+
const profile = await this.profile()
|
|
365
|
+
const script = buildExecScript(profile, command, cwd)
|
|
199
366
|
return await new Promise((resolve) => {
|
|
200
367
|
const conn = new Client()
|
|
201
368
|
let settled = false
|
|
@@ -228,14 +395,20 @@ export class SshClient {
|
|
|
228
395
|
s.stderr.on('data', (d) => errc.push(d))
|
|
229
396
|
s.on('close', (code) => {
|
|
230
397
|
const aborted = signal !== undefined && signal.aborted
|
|
398
|
+
const stdout = out.output()
|
|
399
|
+
const stderr = errc.output()
|
|
400
|
+
if (profile.family === 'windows') {
|
|
401
|
+
stderr.text = crlfToLf(stripPsProgressClixml(stderr.text))
|
|
402
|
+
stdout.text = crlfToLf(stdout.text)
|
|
403
|
+
}
|
|
231
404
|
finish({
|
|
232
405
|
ok: true,
|
|
233
406
|
exitCode: code,
|
|
234
407
|
signal: null,
|
|
235
408
|
timedOut: timedOut && !aborted,
|
|
236
409
|
aborted: aborted && !timedOut,
|
|
237
|
-
stdout
|
|
238
|
-
stderr
|
|
410
|
+
stdout,
|
|
411
|
+
stderr,
|
|
239
412
|
})
|
|
240
413
|
})
|
|
241
414
|
if (stdin === undefined) s.end()
|
|
@@ -293,10 +466,165 @@ export class SshClient {
|
|
|
293
466
|
})
|
|
294
467
|
}
|
|
295
468
|
|
|
469
|
+
/**
|
|
470
|
+
* Open a LONG-LIVED exec channel for streaming (used by background/start
|
|
471
|
+
* jobs on Windows remotes, where no nohup-style detach exists and closing
|
|
472
|
+
* the channel alone does NOT reliably reap the remote command tree — a kill
|
|
473
|
+
* must actively `taskkill /T` the remote process first; the channel close
|
|
474
|
+
* is only a fallback).
|
|
475
|
+
*
|
|
476
|
+
* On Windows the remote script is prefixed with a self-reporting PID line
|
|
477
|
+
* (`DWSH_PID=<pid>`), which is parsed out of the first output chunk so the
|
|
478
|
+
* controller can tree-kill the exact process. Resolves a controller:
|
|
479
|
+
* { readOut(), readErr() -> { delta, lossy }, exit: Promise<{exitCode}|{error}>, terminate() }
|
|
480
|
+
*/
|
|
481
|
+
async execStream(command, { cwd, signal, stdoutMaxBytes = 16 * 1024 * 1024, stderrMaxBytes = 16 * 1024 * 1024 } = {}) {
|
|
482
|
+
const profile = await this.profile()
|
|
483
|
+
const windows = profile.family === 'windows'
|
|
484
|
+
const ssh = this
|
|
485
|
+
// The marker runs FIRST inside the same remote shell that owns the exec
|
|
486
|
+
// channel (the raw PowerShell on PS-default hosts, the nested PowerShell
|
|
487
|
+
// inside the -Command envelope on cmd-default hosts), so its $PID is the
|
|
488
|
+
// process whose tree taskkill must reap.
|
|
489
|
+
const markerCommand = windows ? "Write-Output ('DWSH_PID=' + $PID)\n" + command : command
|
|
490
|
+
const script = buildExecScript(profile, markerCommand, cwd)
|
|
491
|
+
return await new Promise((resolve, reject) => {
|
|
492
|
+
const conn = new Client()
|
|
493
|
+
const out = { buf: '', pos: 0, max: stdoutMaxBytes, lossy: false }
|
|
494
|
+
const err = { buf: '', pos: 0, max: stderrMaxBytes, lossy: false }
|
|
495
|
+
let stream
|
|
496
|
+
let settled = false
|
|
497
|
+
let pidResolve
|
|
498
|
+
const pid = new Promise((res) => { pidResolve = res })
|
|
499
|
+
// A stuck launch must not hold terminate() forever.
|
|
500
|
+
const pidTimer = setTimeout(() => pidResolve(null), 8000)
|
|
501
|
+
let exitResolve
|
|
502
|
+
const exit = new Promise((res) => { exitResolve = res })
|
|
503
|
+
const finish = (value) => {
|
|
504
|
+
if (settled) return
|
|
505
|
+
settled = true
|
|
506
|
+
clearTimeout(pidTimer)
|
|
507
|
+
try { conn.end() } catch {}
|
|
508
|
+
exitResolve(value)
|
|
509
|
+
}
|
|
510
|
+
const push = (target) => (chunk) => {
|
|
511
|
+
const piece = String(chunk)
|
|
512
|
+
if (target.buf.length + piece.length > target.max) { target.lossy = true; return }
|
|
513
|
+
target.buf += piece
|
|
514
|
+
}
|
|
515
|
+
const reader = (target) => () => {
|
|
516
|
+
const delta = target.buf.slice(target.pos)
|
|
517
|
+
target.pos = target.buf.length
|
|
518
|
+
return { delta, lossy: target.lossy }
|
|
519
|
+
}
|
|
520
|
+
// Normalize Windows CRLF output to LF at the chunk boundary so a \r\n
|
|
521
|
+
// split across data events still folds (isolated CRs are preserved).
|
|
522
|
+
const outLf = windows ? createCrlfToLf() : null
|
|
523
|
+
const errLf = windows ? createCrlfToLf() : null
|
|
524
|
+
// The PID marker is the FIRST stdout line, but the first data chunk may
|
|
525
|
+
// hold only part of it, so buffer head bytes until the line completes.
|
|
526
|
+
let head = ''
|
|
527
|
+
let markerResolved = false
|
|
528
|
+
const onOut = (chunk) => {
|
|
529
|
+
const text = outLf === null ? String(chunk) : outLf.feed(chunk)
|
|
530
|
+
if (text === '') return
|
|
531
|
+
if (markerResolved) { push(out)(text); return }
|
|
532
|
+
head += text
|
|
533
|
+
if (windows) {
|
|
534
|
+
const m = /^DWSH_PID=(\d+)\r?\n/.exec(head)
|
|
535
|
+
if (m !== null) {
|
|
536
|
+
markerResolved = true
|
|
537
|
+
clearTimeout(pidTimer)
|
|
538
|
+
pidResolve(Number(m[1]))
|
|
539
|
+
const rest = head.slice(m[0].length)
|
|
540
|
+
head = ''
|
|
541
|
+
if (rest.length > 0) push(out)(rest)
|
|
542
|
+
return
|
|
543
|
+
}
|
|
544
|
+
if (head.length > 4096) {
|
|
545
|
+
// Something else produced output first; give up on the marker.
|
|
546
|
+
markerResolved = true
|
|
547
|
+
clearTimeout(pidTimer)
|
|
548
|
+
pidResolve(null)
|
|
549
|
+
push(out)(head)
|
|
550
|
+
head = ''
|
|
551
|
+
}
|
|
552
|
+
return
|
|
553
|
+
}
|
|
554
|
+
markerResolved = true
|
|
555
|
+
clearTimeout(pidTimer)
|
|
556
|
+
pidResolve(null)
|
|
557
|
+
push(out)(head)
|
|
558
|
+
head = ''
|
|
559
|
+
}
|
|
560
|
+
const onErr = (chunk) => {
|
|
561
|
+
const text = errLf === null ? String(chunk) : errLf.feed(chunk)
|
|
562
|
+
if (text !== '') push(err)(text)
|
|
563
|
+
}
|
|
564
|
+
conn.on('ready', () => {
|
|
565
|
+
conn.exec(script, (execError, s) => {
|
|
566
|
+
if (execError) { finish({ error: execError.message }); return }
|
|
567
|
+
stream = s
|
|
568
|
+
s.on('data', onOut)
|
|
569
|
+
s.stderr.on('data', onErr)
|
|
570
|
+
s.on('close', (code) => {
|
|
571
|
+
if (outLf !== null) { const rest = outLf.flush(); if (rest !== '') push(out)(rest) }
|
|
572
|
+
if (errLf !== null) { const rest = errLf.flush(); if (rest !== '') push(err)(rest) }
|
|
573
|
+
finish({ exitCode: code })
|
|
574
|
+
})
|
|
575
|
+
s.end()
|
|
576
|
+
})
|
|
577
|
+
})
|
|
578
|
+
conn.on('error', (connError) => finish({ error: connError.message }))
|
|
579
|
+
try { conn.connect(this.connectConfig()) } catch (connectError) { finish({ error: connectError.message }) }
|
|
580
|
+
resolve({
|
|
581
|
+
readOut: reader(out),
|
|
582
|
+
readErr: reader(err),
|
|
583
|
+
exit,
|
|
584
|
+
terminate() {
|
|
585
|
+
const doClose = () => {
|
|
586
|
+
try { if (stream) stream.close() } catch {}
|
|
587
|
+
try { conn.end() } catch {}
|
|
588
|
+
// Settle explicitly: the remote 'close' event is not guaranteed.
|
|
589
|
+
finish({ exitCode: null })
|
|
590
|
+
}
|
|
591
|
+
pid.then((remotePid) => {
|
|
592
|
+
if (typeof remotePid === 'number' && remotePid > 0) {
|
|
593
|
+
void ssh.execShell(`taskkill /PID ${remotePid} /T /F`, {
|
|
594
|
+
timeoutMs: 15000, stdoutMaxBytes: 4096, stderrMaxBytes: 4096,
|
|
595
|
+
}).catch(() => {}).finally(doClose)
|
|
596
|
+
} else {
|
|
597
|
+
doClose()
|
|
598
|
+
}
|
|
599
|
+
})
|
|
600
|
+
},
|
|
601
|
+
})
|
|
602
|
+
})
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Cached remote execution profile ({ family, os, shell }); probed once per
|
|
607
|
+
* target and reused for the process lifetime.
|
|
608
|
+
*/
|
|
609
|
+
async profile() {
|
|
610
|
+
const key = profileCacheKey(this)
|
|
611
|
+
let profile = REMOTE_PROFILE_CACHE.get(key)
|
|
612
|
+
if (profile === undefined) {
|
|
613
|
+
profile = await probeRemoteProfile(this)
|
|
614
|
+
REMOTE_PROFILE_CACHE.set(key, profile)
|
|
615
|
+
}
|
|
616
|
+
return profile
|
|
617
|
+
}
|
|
618
|
+
|
|
296
619
|
async remoteOs() {
|
|
297
620
|
if (this._os === undefined) {
|
|
298
|
-
const
|
|
299
|
-
|
|
621
|
+
const profile = await this.profile()
|
|
622
|
+
if (profile.family === 'posix') this._os = profile.os === 'darwin' ? 'darwin' : 'linux'
|
|
623
|
+
else if (profile.family === 'windows') this._os = 'windows'
|
|
624
|
+
else {
|
|
625
|
+
const res = await this.run('uname -s')
|
|
626
|
+
this._os = (res.stdout ?? '').trim().toLowerCase().startsWith('darwin') ? 'darwin' : 'linux'
|
|
627
|
+
}
|
|
300
628
|
}
|
|
301
629
|
return this._os
|
|
302
630
|
}
|
|
@@ -383,12 +711,25 @@ export class SshClient {
|
|
|
383
711
|
}
|
|
384
712
|
|
|
385
713
|
/**
|
|
386
|
-
* Remote file content hash for post-write verification.
|
|
387
|
-
* `sha256sum` then BSD `shasum -a 256`;
|
|
714
|
+
* Remote file content hash for post-write verification. On POSIX targets
|
|
715
|
+
* tries GNU `sha256sum` then BSD `shasum -a 256`; on Windows targets uses
|
|
716
|
+
* PowerShell `Get-FileHash`. Returns the lowercase hex digest or
|
|
388
717
|
* `undefined` when no such tool exists (verification is then skipped).
|
|
389
718
|
* Locale-independent: the digest is hex, never localized text.
|
|
390
719
|
*/
|
|
391
720
|
async sha256(path) {
|
|
721
|
+
const profile = await this.profile()
|
|
722
|
+
if (profile.family === 'windows') {
|
|
723
|
+
const res = await this.execShell(
|
|
724
|
+
`(Get-FileHash -LiteralPath ${psQuote(toWinPath(path))} -Algorithm SHA256).Hash.ToLower()`,
|
|
725
|
+
{ timeoutMs: 30000 },
|
|
726
|
+
)
|
|
727
|
+
if (res.exitCode === 0) {
|
|
728
|
+
const hash = (res.stdout?.text ?? '').trim().toLowerCase()
|
|
729
|
+
if (/^[0-9a-f]{64}$/.test(hash)) return hash
|
|
730
|
+
}
|
|
731
|
+
return undefined
|
|
732
|
+
}
|
|
392
733
|
for (const cmd of [`sha256sum ${shellQuote(path)}`, `shasum -a 256 ${shellQuote(path)}`]) {
|
|
393
734
|
const res = await this.run(`${cmd} 2>/dev/null`)
|
|
394
735
|
if (res.ok) {
|