dsh-remote-workspaces 0.1.1 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-workspaces",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
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,71 @@ 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
+ return
135
+ }
136
+ // Unknown / undetectable: leave the dedupe queue so the NEXT prompt
137
+ // render re-kicks the probe (failed probes are not cached, so a later
138
+ // attempt really does re-probe instead of reusing a stale unknown).
139
+ OS_BACKFILL_QUEUED.delete(hit.anchorPath)
140
+ console.warn(`[dsh-remote-workspaces] os backfill probe failed for ${hit.host} (family unknown); will retry on next render`)
141
+ } catch (error) {
142
+ OS_BACKFILL_QUEUED.delete(hit.anchorPath)
143
+ console.warn(`[dsh-remote-workspaces] os backfill probe errored for ${hit.host}: ${messageOf(error)}`)
144
+ }
145
+ })()
146
+ }
147
+
107
148
  /**
108
149
  * Resolve a raw browse path (home, `~`, `~/x`, relative, or absolute) to an
109
150
  * absolute remote path so the client can navigate "up" past home to `/` and
110
- * display a real path.
151
+ * display a real path. Accepts an optional open SFTP facade to reuse across
152
+ * resolve + listing; otherwise opens its own channel. Resolution goes over
153
+ * SFTP (`realpath`), so it works identically on POSIX and Windows targets and
154
+ * never parses localized shell output.
111
155
  */
112
- async function resolveRemotePath(client, raw) {
156
+ async function resolveRemotePath(client, raw, sharedSftp) {
113
157
  const trimmed = raw === undefined || raw === null ? '' : String(raw).trim()
114
- let sftp
158
+ let owned
159
+ const sftp = async () => {
160
+ if (sharedSftp !== undefined) return sharedSftp
161
+ owned = owned ?? await client.sftp()
162
+ return owned
163
+ }
115
164
  try {
116
165
  if (trimmed === '' || trimmed === '~' || trimmed === '~/') {
117
- sftp = await client.sftp()
118
- return await sftp.realpath('.')
166
+ return await (await sftp()).realpath('.')
119
167
  }
120
168
  if (trimmed.startsWith('~/')) {
121
- sftp = await client.sftp()
122
- const home = String(await sftp.realpath('.')).replace(/\/+$/, '')
169
+ const s = await sftp()
170
+ const home = String(await s.realpath('.')).replace(/\/+$/, '')
123
171
  return `${home}/${trimmed.slice(2)}`
124
172
  }
125
173
  if (!trimmed.startsWith('/')) {
126
- sftp = await client.sftp()
127
- return await sftp.realpath(trimmed)
174
+ return await (await sftp()).realpath(trimmed)
128
175
  }
129
176
  return trimmed
130
177
  } finally {
131
- if (sftp) sftp.end()
178
+ if (owned !== undefined) owned.end()
132
179
  }
133
180
  }
134
181
 
@@ -183,22 +230,35 @@ function remoteWorkspacesService() {
183
230
  },
184
231
 
185
232
  async listRemoteDir(machine, path) {
186
- // `-A` hides `.`/`..`, `-p` appends `/` to directories, `-1` one per line.
233
+ // Pure-SFTP listing (readdir + attrs) the SAME channel the fs backend
234
+ // uses, so Windows targets work: no `ls`, no localized output parsing,
235
+ // no dependency on the remote's default shell.
187
236
  const client = sshClientFor(machine)
188
- let absPath
237
+ let sftp
189
238
  try {
190
- absPath = await resolveRemotePath(client, path)
239
+ sftp = await client.sftp()
240
+ } catch (error) {
241
+ return { ok: false, error: `无法建立 SFTP 连接:${messageOf(error)}` }
242
+ }
243
+ try {
244
+ const absPath = await resolveRemotePath(client, path, sftp)
245
+ let list
246
+ try {
247
+ list = await sftp.readdir(absPath)
248
+ } catch (error) {
249
+ const missing = error && (error.code === 2 || /no such file|not exist|找不到/i.test(String(error.message)))
250
+ return { ok: false, error: missing ? `目录不存在:${absPath}` : `无法读取目录:${messageOf(error)}` }
251
+ }
252
+ const entries = (Array.isArray(list) ? list : [])
253
+ .filter((e) => e && e.filename !== '.' && e.filename !== '..')
254
+ .map((e) => ({ name: e.filename, dir: attrsIsDir(e.attrs) }))
255
+ .sort((a, b) => a.name.localeCompare(b.name))
256
+ return { ok: true, entries, path: absPath }
191
257
  } catch (error) {
192
258
  return { ok: false, error: `无法解析目录:${messageOf(error)}` }
259
+ } finally {
260
+ sftp.end()
193
261
  }
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
262
  },
203
263
 
204
264
  /**
@@ -220,6 +280,16 @@ function remoteWorkspacesService() {
220
280
  const rel = path === undefined || path === '' ? '.' : path
221
281
  const remotePath = await sftp.realpath(rel)
222
282
  const anchorPath = ensureAnchor(machine, remotePath)
283
+ // Probe the remote profile once so prompt/dialect hints can declare
284
+ // the actual shell (PowerShell on Windows remotes). Failure to probe
285
+ // only leaves the hint off — never fails the open.
286
+ let os
287
+ try {
288
+ const profile = await client.profile()
289
+ if (profile !== undefined && (profile.family === 'posix' || profile.family === 'windows')) {
290
+ os = { family: profile.family, os: profile.os, shell: profile.shell }
291
+ }
292
+ } catch { /* os stays undefined */ }
223
293
  registerAnchor({
224
294
  anchorPath,
225
295
  machineId: machine?.id,
@@ -227,6 +297,7 @@ function remoteWorkspacesService() {
227
297
  port: machine?.port ?? null,
228
298
  user: machine?.user ?? null,
229
299
  remotePath,
300
+ ...(os !== undefined ? { os } : {}),
230
301
  })
231
302
  return { ok: true, localDir: anchorPath, remotePath }
232
303
  } catch (error) {
@@ -285,6 +356,8 @@ export function apply(ctx) {
285
356
  if (typeof cwd !== 'string' || cwd === '') return cwd
286
357
  const hit = findByCwd(cwd)
287
358
  if (hit === undefined) return cwd
359
+ // Side effect: backfill the os profile on rows registered before B4.
360
+ kickOsBackfill(cwd)
288
361
  return hit.remoteSubpath === '' ? hit.remotePath : `${hit.remotePath.replace(/\/+$/, '')}/${hit.remoteSubpath}`
289
362
  })
290
363
  })
@@ -303,8 +376,14 @@ export function apply(ctx) {
303
376
  if (typeof cwd !== 'string' || cwd === '') return ''
304
377
  const hit = findByCwd(cwd)
305
378
  if (hit === undefined) return ''
379
+ // Side effect: backfill the os profile on rows registered before B4.
380
+ kickOsBackfill(cwd)
306
381
  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).`
382
+ 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).`
383
+ if (hit.os?.family === 'windows') {
384
+ 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).'
385
+ }
386
+ return base
308
387
  },
309
388
  })
310
389
  })
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
@@ -9,7 +9,7 @@
9
9
  * harness's `bash-sandbox`/`pwsh-sandbox` apply).
10
10
  */
11
11
 
12
- import { SshClient, shellQuote } from './transport.js'
12
+ import { SshClient, shellQuote, PROBE_UNKNOWN_MSG } from './transport.js'
13
13
  import { isRemoteCwd, parseSshUri } from './ssh-uri.js'
14
14
  import { findByCwd } from './registry.js'
15
15
  import { lstatSync } from 'node:fs'
@@ -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,62 @@ 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 === 'unknown') {
251
+ // Never run the POSIX nohup launcher against an undetected host (a
252
+ // Windows/cmd host would die on the `cd '…'` line with Win32 123).
253
+ spawnError = new Error(PROBE_UNKNOWN_MSG)
254
+ proc.status = 'killed'
255
+ return
256
+ }
257
+ if (profile.family === 'windows') {
258
+ // No nohup-style detach exists on Windows remotes, and closing the
259
+ // channel alone does NOT reap the remote tree (verified) - so the
260
+ // job is a long-lived exec stream whose terminate() actively
261
+ // taskkill /T's the remote PID and then closes the channel.
262
+ const ctl = await client.execStream(spec.command, { cwd: path })
263
+ if (proc.status !== 'running') { ctl.terminate(); return }
264
+ streamCtl = ctl
265
+ const merge = () => {
266
+ const outPart = ctl.readOut()
267
+ const errPart = ctl.readErr()
268
+ if (outPart.delta.length > 0) {
269
+ if (buffer.length > 0 && !buffer.endsWith('\n')) buffer += '\n'
270
+ buffer += outPart.delta
271
+ }
272
+ if (errPart.delta.length > 0) {
273
+ if (buffer.length > 0 && !buffer.endsWith('\n')) buffer += '\n'
274
+ buffer += '[stderr]\n' + errPart.delta
275
+ }
276
+ }
277
+ if (spec.signal !== undefined) {
278
+ const onAbort = () => { if (streamCtl !== null) streamCtl.terminate() }
279
+ if (spec.signal.aborted) onAbort()
280
+ else spec.signal.addEventListener('abort', onAbort, { once: true })
281
+ }
282
+ const pump = async () => {
283
+ while (proc.status === 'running') {
284
+ merge()
285
+ await new Promise((r) => setTimeout(r, 150))
286
+ }
287
+ }
288
+ void pump()
289
+ const outcome = await ctl.exit
290
+ merge()
291
+ if (proc.status === 'running') proc.status = 'completed'
292
+ if (outcome.error !== undefined) {
293
+ spawnError = new Error(outcome.error)
294
+ proc.status = 'killed'
295
+ return
296
+ }
297
+ proc.exitCode = typeof outcome.exitCode === 'number' ? outcome.exitCode : null
298
+ return
299
+ }
247
300
  const launched = await client.run(launchScript)
248
301
  if (!launched.ok) {
249
302
  spawnError = new Error((launched.stderr ?? '').trim() || launched.error || 'background spawn failed')
package/src/transport.js CHANGED
@@ -12,6 +12,195 @@ 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; the bare `ver` builtin
117
+ * proves a cmd-default Windows host; a quoted nested `cmd /c "ver"` proves
118
+ * Windows under a PowerShell default (PowerShell itself has no `ver`); a
119
+ * `$PSVersionTable` expression then tells cmd from PowerShell as the default.
120
+ *
121
+ * Quoting matters on real cmd-default Windows hosts: OpenSSH wraps the exec
122
+ * payload for cmd.exe in a way that mangles inner unquoted spaces — `cmd /c
123
+ * ver` arrives as `ver"` and fails, while a bare single token (`ver`) and a
124
+ * quoted inner command (`cmd /c "ver"`) both pass (verified on a real host).
125
+ */
126
+ export async function probeRemoteProfile(client) {
127
+ const uname = await client.run('uname -s')
128
+ if (uname.ok) {
129
+ const os = (uname.stdout ?? '').trim().toLowerCase()
130
+ return { family: 'posix', os: os.startsWith('darwin') ? 'darwin' : 'linux', shell: 'posix' }
131
+ }
132
+ const ver = await client.run('ver')
133
+ if (ver.ok) return { family: 'windows', os: 'windows', shell: 'cmd' }
134
+ const nested = await client.run('cmd /c "ver"')
135
+ if (nested.ok && /windows/i.test(nested.stdout ?? '')) {
136
+ const ps = await client.run('$PSVersionTable.PSVersion.ToString()')
137
+ return ps.ok
138
+ ? { family: 'windows', os: 'windows', shell: 'powershell' }
139
+ : { family: 'windows', os: 'windows', shell: 'cmd' }
140
+ }
141
+ const ps = await client.run('$PSVersionTable.PSVersion.ToString()')
142
+ if (ps.ok) return { family: 'windows', os: 'windows', shell: 'powershell' }
143
+ return { family: 'unknown', os: 'unknown', shell: 'unknown' }
144
+ }
145
+
146
+ /**
147
+ * Error text used whenever a command cannot run because the remote profile
148
+ * probe failed (family 'unknown'). Explicit instead of guessing a dialect:
149
+ * sending a POSIX `cd 'x' || exit 1` script to a Windows/cmd host produces the
150
+ * misleading "文件名、目录名或卷标语法不正确" (Win32 123) failure, and the
151
+ * other way around is equally wrong — so callers refuse loudly.
152
+ */
153
+ export const PROBE_UNKNOWN_MSG =
154
+ 'cannot determine the remote shell type (exec probe failed); command not executed. Check the host side: the account home/profile directory must exist, the OpenSSH DefaultShell must be valid, and the exec channel itself must be usable.'
155
+
156
+ /** Encode a PowerShell script for quote-safe transport through cmd.exe.
157
+ *
158
+ * `powershell -EncodedCommand` would be ideal (no quoting at all), but a
159
+ * nested -EncodedCommand host serializes BOTH progress and error records to
160
+ * stderr as CLIXML soup. A `-Command` host prints plain text, so instead the
161
+ * script rides inside one fixed, quote-free command line: base64 has no
162
+ * `% ! ^ " $ '` and the wrapper has no `$`, so neither cmd nor a PowerShell
163
+ * outer shell can mangle it, and the inner text is decoded + `iex`'d.
164
+ */
165
+ export function psCommandEnvelope(script) {
166
+ const b64 = Buffer.from(script, 'utf16le').toString('base64')
167
+ return `powershell -NoProfile -NonInteractive -Command "& { iex ([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('${b64}'))) }"`
168
+ }
169
+
170
+ /**
171
+ * Build the exec-channel script for one command under a remote profile.
172
+ *
173
+ * POSIX / unknown: unchanged legacy form (`cd 'x' || exit 1` + command).
174
+ *
175
+ * Windows with a PowerShell default shell: the raw script text is executed
176
+ * by the server's default shell directly (cmdlets + aliases like ls/cat/cd
177
+ * make simple commands work), prefixed with a `Set-Location` when a cwd is
178
+ * given and suffixed with the exit-code glue. Windows with a cmd default
179
+ * shell: the same script rides inside a fixed, quote-free
180
+ * `powershell -Command` envelope (base64 + iex), so cmd only ever parses one
181
+ * fixed command line, its exit code is the child's, and stderr stays plain
182
+ * text (no CLIXML records).
183
+ */
184
+ export function buildExecScript(profile, command, cwd) {
185
+ if (profile === undefined) {
186
+ return cwd ? `cd ${shellQuote(cwd)} || exit 1\n${command}` : command
187
+ }
188
+ if (profile.family === 'unknown') {
189
+ // Never guess: a POSIX script against a Windows/cmd host dies on the first
190
+ // line with Win32 123-style errors and never reaches the command.
191
+ throw new Error(PROBE_UNKNOWN_MSG)
192
+ }
193
+ if (profile.family !== 'windows') {
194
+ return cwd ? `cd ${shellQuote(cwd)} || exit 1\n${command}` : command
195
+ }
196
+ const script = [
197
+ ...(cwd ? [`Set-Location -LiteralPath ${psQuote(toWinPath(cwd))}`] : []),
198
+ command,
199
+ PS_EXIT_GLUE,
200
+ ].join('\n')
201
+ return profile.shell === 'cmd' ? psCommandEnvelope(script) : script
202
+ }
203
+
15
204
  /** Bounded output collector: keeps the TAIL of a stream, flags truncation. */
16
205
  class CapCollector {
17
206
  constructor(maxBytes) {
@@ -193,9 +382,18 @@ export class SshClient {
193
382
  * bounded (tail-kept) stdout/stderr, stdin, and an abort signal that closes
194
383
  * the exec channel (SIGHUP on the remote). Resolves with exitCode/signal,
195
384
  * timedOut/aborted first-cause, and `{ text, truncated }` outputs.
385
+ *
386
+ * The command text is executed through the target's default shell. On
387
+ * Windows targets the script is built per detected default shell
388
+ * (`buildExecScript`): raw PowerShell when PowerShell is the default, a
389
+ * quote-safe `powershell -EncodedCommand` wrapper when cmd is.
196
390
  */
197
391
  async execShell(command, { cwd, timeoutMs = 60000, stdoutMaxBytes = 64000, stderrMaxBytes = 64000, stdin, signal } = {}) {
198
- const script = cwd ? `cd ${shellQuote(cwd)} || exit 1\n${command}` : command
392
+ const profile = await this.profile()
393
+ if (profile.family === 'unknown') {
394
+ return { ok: false, error: PROBE_UNKNOWN_MSG }
395
+ }
396
+ const script = buildExecScript(profile, command, cwd)
199
397
  return await new Promise((resolve) => {
200
398
  const conn = new Client()
201
399
  let settled = false
@@ -228,14 +426,20 @@ export class SshClient {
228
426
  s.stderr.on('data', (d) => errc.push(d))
229
427
  s.on('close', (code) => {
230
428
  const aborted = signal !== undefined && signal.aborted
429
+ const stdout = out.output()
430
+ const stderr = errc.output()
431
+ if (profile.family === 'windows') {
432
+ stderr.text = crlfToLf(stripPsProgressClixml(stderr.text))
433
+ stdout.text = crlfToLf(stdout.text)
434
+ }
231
435
  finish({
232
436
  ok: true,
233
437
  exitCode: code,
234
438
  signal: null,
235
439
  timedOut: timedOut && !aborted,
236
440
  aborted: aborted && !timedOut,
237
- stdout: out.output(),
238
- stderr: errc.output(),
441
+ stdout,
442
+ stderr,
239
443
  })
240
444
  })
241
445
  if (stdin === undefined) s.end()
@@ -293,10 +497,178 @@ export class SshClient {
293
497
  })
294
498
  }
295
499
 
500
+ /**
501
+ * Open a LONG-LIVED exec channel for streaming (used by background/start
502
+ * jobs on Windows remotes, where no nohup-style detach exists and closing
503
+ * the channel alone does NOT reliably reap the remote command tree — a kill
504
+ * must actively `taskkill /T` the remote process first; the channel close
505
+ * is only a fallback).
506
+ *
507
+ * On Windows the remote script is prefixed with a self-reporting PID line
508
+ * (`DWSH_PID=<pid>`), which is parsed out of the first output chunk so the
509
+ * controller can tree-kill the exact process. Resolves a controller:
510
+ * { readOut(), readErr() -> { delta, lossy }, exit: Promise<{exitCode}|{error}>, terminate() }
511
+ */
512
+ async execStream(command, { cwd, signal, stdoutMaxBytes = 16 * 1024 * 1024, stderrMaxBytes = 16 * 1024 * 1024 } = {}) {
513
+ const profile = await this.profile()
514
+ if (profile.family === 'unknown') {
515
+ // Same explicit refusal as execShell: no dialect to build the script in.
516
+ return {
517
+ readOut: () => ({ delta: '', lossy: false }),
518
+ readErr: () => ({ delta: '', lossy: false }),
519
+ exit: Promise.resolve({ error: PROBE_UNKNOWN_MSG }),
520
+ terminate() {},
521
+ }
522
+ }
523
+ const windows = profile.family === 'windows'
524
+ const ssh = this
525
+ // The marker runs FIRST inside the same remote shell that owns the exec
526
+ // channel (the raw PowerShell on PS-default hosts, the nested PowerShell
527
+ // inside the -Command envelope on cmd-default hosts), so its $PID is the
528
+ // process whose tree taskkill must reap.
529
+ const markerCommand = windows ? "Write-Output ('DWSH_PID=' + $PID)\n" + command : command
530
+ const script = buildExecScript(profile, markerCommand, cwd)
531
+ return await new Promise((resolve, reject) => {
532
+ const conn = new Client()
533
+ const out = { buf: '', pos: 0, max: stdoutMaxBytes, lossy: false }
534
+ const err = { buf: '', pos: 0, max: stderrMaxBytes, lossy: false }
535
+ let stream
536
+ let settled = false
537
+ let pidResolve
538
+ const pid = new Promise((res) => { pidResolve = res })
539
+ // A stuck launch must not hold terminate() forever.
540
+ const pidTimer = setTimeout(() => pidResolve(null), 8000)
541
+ let exitResolve
542
+ const exit = new Promise((res) => { exitResolve = res })
543
+ const finish = (value) => {
544
+ if (settled) return
545
+ settled = true
546
+ clearTimeout(pidTimer)
547
+ try { conn.end() } catch {}
548
+ exitResolve(value)
549
+ }
550
+ const push = (target) => (chunk) => {
551
+ const piece = String(chunk)
552
+ if (target.buf.length + piece.length > target.max) { target.lossy = true; return }
553
+ target.buf += piece
554
+ }
555
+ const reader = (target) => () => {
556
+ const delta = target.buf.slice(target.pos)
557
+ target.pos = target.buf.length
558
+ return { delta, lossy: target.lossy }
559
+ }
560
+ // Normalize Windows CRLF output to LF at the chunk boundary so a \r\n
561
+ // split across data events still folds (isolated CRs are preserved).
562
+ const outLf = windows ? createCrlfToLf() : null
563
+ const errLf = windows ? createCrlfToLf() : null
564
+ // The PID marker is the FIRST stdout line, but the first data chunk may
565
+ // hold only part of it, so buffer head bytes until the line completes.
566
+ let head = ''
567
+ let markerResolved = false
568
+ const onOut = (chunk) => {
569
+ const text = outLf === null ? String(chunk) : outLf.feed(chunk)
570
+ if (text === '') return
571
+ if (markerResolved) { push(out)(text); return }
572
+ head += text
573
+ if (windows) {
574
+ const m = /^DWSH_PID=(\d+)\r?\n/.exec(head)
575
+ if (m !== null) {
576
+ markerResolved = true
577
+ clearTimeout(pidTimer)
578
+ pidResolve(Number(m[1]))
579
+ const rest = head.slice(m[0].length)
580
+ head = ''
581
+ if (rest.length > 0) push(out)(rest)
582
+ return
583
+ }
584
+ if (head.length > 4096) {
585
+ // Something else produced output first; give up on the marker.
586
+ markerResolved = true
587
+ clearTimeout(pidTimer)
588
+ pidResolve(null)
589
+ push(out)(head)
590
+ head = ''
591
+ }
592
+ return
593
+ }
594
+ markerResolved = true
595
+ clearTimeout(pidTimer)
596
+ pidResolve(null)
597
+ push(out)(head)
598
+ head = ''
599
+ }
600
+ const onErr = (chunk) => {
601
+ const text = errLf === null ? String(chunk) : errLf.feed(chunk)
602
+ if (text !== '') push(err)(text)
603
+ }
604
+ conn.on('ready', () => {
605
+ conn.exec(script, (execError, s) => {
606
+ if (execError) { finish({ error: execError.message }); return }
607
+ stream = s
608
+ s.on('data', onOut)
609
+ s.stderr.on('data', onErr)
610
+ s.on('close', (code) => {
611
+ if (outLf !== null) { const rest = outLf.flush(); if (rest !== '') push(out)(rest) }
612
+ if (errLf !== null) { const rest = errLf.flush(); if (rest !== '') push(err)(rest) }
613
+ finish({ exitCode: code })
614
+ })
615
+ s.end()
616
+ })
617
+ })
618
+ conn.on('error', (connError) => finish({ error: connError.message }))
619
+ try { conn.connect(this.connectConfig()) } catch (connectError) { finish({ error: connectError.message }) }
620
+ resolve({
621
+ readOut: reader(out),
622
+ readErr: reader(err),
623
+ exit,
624
+ terminate() {
625
+ const doClose = () => {
626
+ try { if (stream) stream.close() } catch {}
627
+ try { conn.end() } catch {}
628
+ // Settle explicitly: the remote 'close' event is not guaranteed.
629
+ finish({ exitCode: null })
630
+ }
631
+ pid.then((remotePid) => {
632
+ if (typeof remotePid === 'number' && remotePid > 0) {
633
+ void ssh.execShell(`taskkill /PID ${remotePid} /T /F`, {
634
+ timeoutMs: 15000, stdoutMaxBytes: 4096, stderrMaxBytes: 4096,
635
+ }).catch(() => {}).finally(doClose)
636
+ } else {
637
+ doClose()
638
+ }
639
+ })
640
+ },
641
+ })
642
+ })
643
+ }
644
+
645
+ /**
646
+ * Cached remote execution profile ({ family, os, shell }); probed once per
647
+ * target and reused for the process lifetime. FAILED probes (family
648
+ * 'unknown') are never cached, so the next call re-probes: a transient
649
+ * failure or a host-side fix is picked up without restarting the process.
650
+ */
651
+ async profile() {
652
+ const key = profileCacheKey(this)
653
+ let profile = REMOTE_PROFILE_CACHE.get(key)
654
+ if (profile === undefined) {
655
+ profile = await probeRemoteProfile(this)
656
+ if (profile.family === 'posix' || profile.family === 'windows') {
657
+ REMOTE_PROFILE_CACHE.set(key, profile)
658
+ }
659
+ }
660
+ return profile
661
+ }
662
+
296
663
  async remoteOs() {
297
664
  if (this._os === undefined) {
298
- const res = await this.run('uname -s')
299
- this._os = (res.stdout ?? '').trim().toLowerCase().startsWith('darwin') ? 'darwin' : 'linux'
665
+ const profile = await this.profile()
666
+ if (profile.family === 'posix') this._os = profile.os === 'darwin' ? 'darwin' : 'linux'
667
+ else if (profile.family === 'windows') this._os = 'windows'
668
+ else {
669
+ const res = await this.run('uname -s')
670
+ this._os = (res.stdout ?? '').trim().toLowerCase().startsWith('darwin') ? 'darwin' : 'linux'
671
+ }
300
672
  }
301
673
  return this._os
302
674
  }
@@ -383,12 +755,25 @@ export class SshClient {
383
755
  }
384
756
 
385
757
  /**
386
- * Remote file content hash for post-write verification. Tries GNU
387
- * `sha256sum` then BSD `shasum -a 256`; returns the lowercase hex digest or
758
+ * Remote file content hash for post-write verification. On POSIX targets
759
+ * tries GNU `sha256sum` then BSD `shasum -a 256`; on Windows targets uses
760
+ * PowerShell `Get-FileHash`. Returns the lowercase hex digest or
388
761
  * `undefined` when no such tool exists (verification is then skipped).
389
762
  * Locale-independent: the digest is hex, never localized text.
390
763
  */
391
764
  async sha256(path) {
765
+ const profile = await this.profile()
766
+ if (profile.family === 'windows') {
767
+ const res = await this.execShell(
768
+ `(Get-FileHash -LiteralPath ${psQuote(toWinPath(path))} -Algorithm SHA256).Hash.ToLower()`,
769
+ { timeoutMs: 30000 },
770
+ )
771
+ if (res.exitCode === 0) {
772
+ const hash = (res.stdout?.text ?? '').trim().toLowerCase()
773
+ if (/^[0-9a-f]{64}$/.test(hash)) return hash
774
+ }
775
+ return undefined
776
+ }
392
777
  for (const cmd of [`sha256sum ${shellQuote(path)}`, `shasum -a 256 ${shellQuote(path)}`]) {
393
778
  const res = await this.run(`${cmd} 2>/dev/null`)
394
779
  if (res.ok) {