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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-workspaces",
3
- "version": "0.1.1",
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 sftp
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
- sftp = await client.sftp()
118
- return await sftp.realpath('.')
157
+ return await (await sftp()).realpath('.')
119
158
  }
120
159
  if (trimmed.startsWith('~/')) {
121
- sftp = await client.sftp()
122
- const home = String(await sftp.realpath('.')).replace(/\/+$/, '')
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
- sftp = await client.sftp()
127
- return await sftp.realpath(trimmed)
165
+ return await (await sftp()).realpath(trimmed)
128
166
  }
129
167
  return trimmed
130
168
  } finally {
131
- if (sftp) sftp.end()
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
- // `-A` hides `.`/`..`, `-p` appends `/` to directories, `-1` one per line.
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 absPath
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
- return `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).`
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
- export function registerAnchor({ anchorPath, machineId, host, port, user, remotePath }) {
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
- anchors[anchorPath] = { machineId, host, port, user, remotePath, registeredAt: new Date().toISOString() }
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 command = ['rg', ...argv].map(shellQuote).join(' ')
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
- return { matches: parseGrepMatches(stdout).slice(0, GREP_MAX_MATCHES) }
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
- return { paths: parseGlobPaths(stdout).slice(0, GLOB_MAX_RESULTS) }
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 (pid !== null) void client.run(`kill ${pid} 2>/dev/null || true`)
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 script = cwd ? `cd ${shellQuote(cwd)} || exit 1\n${command}` : command
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: out.output(),
238
- stderr: errc.output(),
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 res = await this.run('uname -s')
299
- this._os = (res.stdout ?? '').trim().toLowerCase().startsWith('darwin') ? 'darwin' : 'linux'
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. Tries GNU
387
- * `sha256sum` then BSD `shasum -a 256`; returns the lowercase hex digest or
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) {