dsh-remote 0.6.7 → 0.6.9

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.
Files changed (3) hide show
  1. package/lib/client.js +49 -44
  2. package/lib/index.js +228 -83
  3. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -273,7 +273,8 @@ window.__ModuleLoader__.load({
273
273
  if (busy || loading) return
274
274
  const last = levels && levels.length ? levels[levels.length - 1] : null
275
275
  const base = last && last.path ? last.path : ''
276
- const next = base === '/' ? '/' + name : (base ? base : '') + '/' + name
276
+ const sep = base.includes('\\') ? '\\' : '/'
277
+ const next = base === '/' ? '/' + name : (base ? base + sep + name : name)
277
278
  loadLevels(machineId, next, (levels ? levels.length : 0))
278
279
  }
279
280
 
@@ -284,8 +285,11 @@ window.__ModuleLoader__.load({
284
285
  if (!id || !raw) { setSuggest([]); setSuggestOpen(false); return }
285
286
  const t = String(raw || '').trim()
286
287
  if (!t) { setSuggest([]); setSuggestOpen(false); return }
287
- const slash = t.lastIndexOf('/')
288
- const parent = slash <= 0 ? '/' : t.slice(0, slash)
288
+ // Windows drive path (D:\...) uses backslashes; POSIX uses '/'.
289
+ const isWin = t.includes('\\')
290
+ const sep = isWin ? '\\' : '/'
291
+ const slash = t.lastIndexOf(sep)
292
+ const parent = slash <= 0 ? (isWin ? t.match(/^[a-zA-Z]:\\?/) ? t : '' : '/') : t.slice(0, slash)
289
293
  const lastSeg = slash < 0 ? t : t.slice(slash + 1)
290
294
  api('POST', '/dsh-remote/current', { id }).catch(() => {})
291
295
  .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(parent || '/')))
@@ -295,7 +299,7 @@ window.__ModuleLoader__.load({
295
299
  : parseLs(res && res.text)
296
300
  const base = parent === '/' ? '/' : parent
297
301
  const matches = list.filter((it) => it.name.toLowerCase().startsWith(String(lastSeg).toLowerCase()))
298
- .slice(0, 40).map((it) => (base === '/' ? '/' + it.name : base + '/' + it.name))
302
+ .slice(0, 40).map((it) => (base === '/' || !base ? sep + it.name : base + sep + it.name))
299
303
  setSuggest(matches)
300
304
  setSuggestOpen(!!matches.length)
301
305
  }).catch(() => { setSuggest([]); setSuggestOpen(false) })
@@ -312,14 +316,15 @@ window.__ModuleLoader__.load({
312
316
  const continueSuggest = (dir) => {
313
317
  if (!machineId || !dir) { setSuggest([]); setSuggestOpen(false); return }
314
318
  setSuggestOpen(false)
319
+ const sep = String(dir).includes('\\') ? '\\' : '/'
315
320
  api('POST', '/dsh-remote/current', { id: machineId }).catch(() => {})
316
- .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(String(dir).replace(/\/+$/, '') || '/')))
321
+ .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(String(dir).replace(/[\\/]+$/, '') || '/')))
317
322
  .then((res) => {
318
323
  const list = Array.isArray(res && res.items)
319
324
  ? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir' }))
320
325
  : parseLs(res && res.text)
321
- const base = String(dir).replace(/\/+$/, '') === '' ? '/' : String(dir).replace(/\/+$/, '')
322
- const kids = list.filter((it) => it.dir).slice(0, 40).map((it) => (base === '/' ? '/' + it.name : base + '/' + it.name))
326
+ const base = String(dir).replace(/[\\/]+$/, '') === '' ? '/' : String(dir).replace(/[\\/]+$/, '')
327
+ const kids = list.filter((it) => it.dir).slice(0, 40).map((it) => (base === '/' ? '/' + it.name : base + sep + it.name))
323
328
  setSuggest(kids)
324
329
  setSuggestOpen(!!kids.length)
325
330
  }).catch(() => { setSuggest([]); setSuggestOpen(false) })
@@ -353,34 +358,29 @@ window.__ModuleLoader__.load({
353
358
  }
354
359
  const last = levels[levels.length - 1]
355
360
  const entries = last.all || []
356
- // Floating overlay: pinned to viewport, covers the dialog background so
357
- // it never stretches the surrounding layout. The picker panel is a fixed
358
- // (height-capped) box whose directory list scrolls internally.
359
- return React.createElement('div', {
360
- style: { position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 12 },
361
- onClick: () => setPopOpen(false),
362
- },
363
- React.createElement('div', { style: { background: v('--dsw-alias-bg-overlay', '#1e1e1e'), border: '1px solid ' + T.borderStrong, borderRadius: 10, boxShadow: '0 10px 40px rgba(0,0,0,0.5)', width: 'min(560px, 94vw)', minWidth: 320, display: 'flex', flexDirection: 'column', maxHeight: 'min(440px, 82vh)', overflow: 'hidden' }, onClick: (e) => e.stopPropagation() },
364
- React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '8px 12px', borderBottom: '1px solid ' + T.border } },
365
- React.createElement('button', { style: { ...buttonS, padding: '3px 10px' }, onClick: () => setLevels((p) => p && p.length > 1 ? p.slice(0, p.length - 1) : p), disabled: levels.length <= 1 || loading }, '回上一级 ▴'),
366
- React.createElement('div', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, breadcrumb(machineId, last.path, (p) => setLevels((prev) => { const cut = (prev || []).findIndex((lv) => lv.path === p); return cut >= 0 ? prev.slice(0, cut + 1) : prev }))),
367
- React.createElement('button', { style: { ...buttonS, padding: '3px 10px' }, onClick: () => setPopOpen(false) }, '关闭 ✕'),
368
- ),
369
- React.createElement('div', { style: { overflowY: 'auto', overflowX: 'hidden' } },
370
- loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12 } }, '加载中…')
371
- : (entries.length ? entries.slice(0, 400).map((it, i) => React.createElement('div', {
372
- key: i, title: (it.dir ? '进入 ' : '文件: ') + it.name,
373
- onClick: it.dir ? () => enterDir(it.name) : undefined,
374
- style: { padding: '7px 12px', cursor: it.dir ? 'pointer' : 'default', color: it.dir ? T.ok : T.label, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'flex', gap: 8, alignItems: 'center', fontSize: 13, borderBottom: '1px solid ' + T.border },
375
- },
376
- React.createElement('span', null, it.dir ? '📁' : '📄'),
377
- React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, it.name),
378
- )) : React.createElement('div', { style: { opacity: 0.6, padding: 12 } }, '(空目录)')),
379
- ),
380
- React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', padding: '8px 12px', borderTop: '1px solid ' + T.border } },
381
- React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, '所选: ' + last.path),
382
- React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => acceptBrowserPick(last.path) }, '选用此路径'),
383
- ),
361
+ // Inline panel anchored inside the dialog (NOT a full-viewport overlay):
362
+ // a height-capped box whose directory list scrolls internally, so the
363
+ // dialog's own confirm row ("设为远程工作区") stays visible below it.
364
+ return React.createElement('div', { style: { border: '1px solid ' + T.borderStrong, borderRadius: 10, background: v('--dsw-alias-bg-overlay', '#1e1e1e'), boxShadow: '0 8px 32px rgba(0,0,0,0.4)', display: 'flex', flexDirection: 'column', maxHeight: 'min(300px, 46vh)', overflow: 'hidden' } },
365
+ React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '6px 10px', borderBottom: '1px solid ' + T.border } },
366
+ React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => setLevels((p) => p && p.length > 1 ? p.slice(0, p.length - 1) : p), disabled: levels.length <= 1 || loading }, '回上一级 ▴'),
367
+ React.createElement('div', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, breadcrumb(machineId, last.path, (p) => setLevels((prev) => { const cut = (prev || []).findIndex((lv) => lv.path === p); return cut >= 0 ? prev.slice(0, cut + 1) : prev }))),
368
+ React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => setPopOpen(false) }, '关闭 ✕'),
369
+ ),
370
+ React.createElement('div', { style: { overflowY: 'auto', overflowX: 'hidden' } },
371
+ loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12 } }, '加载中…')
372
+ : (entries.length ? entries.slice(0, 400).map((it, i) => React.createElement('div', {
373
+ key: i, title: (it.dir ? '进入 ' : '文件: ') + it.name,
374
+ onClick: it.dir ? () => enterDir(it.name) : undefined,
375
+ style: { padding: '6px 10px', cursor: it.dir ? 'pointer' : 'default', color: it.dir ? T.ok : T.label, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'flex', gap: 8, alignItems: 'center', fontSize: 13, borderBottom: '1px solid ' + T.border },
376
+ },
377
+ React.createElement('span', null, it.dir ? '📁' : '📄'),
378
+ React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, it.name),
379
+ )) : React.createElement('div', { style: { opacity: 0.6, padding: 12 } }, '(空目录)')),
380
+ ),
381
+ React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', padding: '6px 10px', borderTop: '1px solid ' + T.border } },
382
+ React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, '所选: ' + last.path),
383
+ React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => { acceptBrowserPick(last.path); setPopOpen(false) } }, '选用此路径'),
384
384
  ),
385
385
  )
386
386
  }
@@ -391,7 +391,7 @@ window.__ModuleLoader__.load({
391
391
  // renders identically in the narrow sidebar and in the conversation and
392
392
  // is never squeezed into a cramped in-place column.
393
393
  return React.createElement('div', { style: { position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }, onClick: () => { if (!busy) onCancel() } },
394
- React.createElement('div', { style: { background: v('--dsw-alias-bg-layer-1', '#18181b'), border: '1px solid ' + T.borderStrong, borderRadius: 12, boxShadow: '0 12px 48px rgba(0,0,0,0.5)', width: 'min(600px, 94vw)', padding: 16, boxSizing: 'border-box' }, onClick: (e) => e.stopPropagation() },
394
+ React.createElement('div', { style: { background: v('--dsw-alias-bg-layer-1', '#18181b'), border: '1px solid ' + T.borderStrong, borderRadius: 12, boxShadow: '0 12px 48px rgba(0,0,0,0.5)', width: 'min(600px, 94vw)', maxHeight: 'min(620px, 90vh)', display: 'flex', flexDirection: 'column', padding: 16, boxSizing: 'border-box' }, onClick: (e) => e.stopPropagation() },
395
395
  React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 } },
396
396
  React.createElement('div', null, '选择工作目录'),
397
397
  React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => { if (!busy) onCancel() }, disabled: busy }, '关闭 ✕'),
@@ -410,7 +410,7 @@ window.__ModuleLoader__.load({
410
410
  React.createElement('button', { style: { ...buttonS, alignSelf: 'flex-start' }, onClick: chooseLocal, disabled: loading }, loading ? '打开中…' : '打开系统文件夹选择器'),
411
411
  err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
412
412
  )
413
- : React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
413
+ : React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, minHeight: 0, overflowY: 'auto' } },
414
414
  React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
415
415
  React.createElement('label', { style: { fontSize: 12, opacity: 0.8, whiteSpace: 'nowrap' } }, '远程机器:'),
416
416
  React.createElement('div', { ref: ddRef, style: { position: 'relative', display: 'inline-block' } },
@@ -427,23 +427,28 @@ window.__ModuleLoader__.load({
427
427
  ),
428
428
  ),
429
429
  // 路径输入框(带自动补全)+ 打开浏览弹层按钮
430
- React.createElement('div', { ref: suggestRef, style: { position: 'relative', display: 'flex', gap: 6 } },
431
- React.createElement('input', { value: path, onChange: (e) => onPathChange(e.target.value), onFocus: () => loadSuggestions(path), placeholder: (machineId ? '输入远程路径(自动补全)' : '先选远程机器'), disabled: !machineId, style: { ...inputS, flex: 1, minWidth: 120 } }),
432
- React.createElement('button', { style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: () => { if (machineId) setPopOpen(true) }, disabled: !machineId }, '浏览…'),
433
- // 自动补全下拉
430
+ // suggestRef 外层仍用于「点击外部关闭」判定;输入行与补全列表
431
+ // 是两级 div —— 补全列表以流式(非绝对定位)展开,确认按钮被
432
+ // 推到下方而不会被遮挡(issue #4「有子目录时选择框遮挡确定菜单」)。
433
+ React.createElement('div', { ref: suggestRef, style: { position: 'relative' } },
434
+ React.createElement('div', { style: { display: 'flex', gap: 6 } },
435
+ React.createElement('input', { value: path, onChange: (e) => onPathChange(e.target.value), onFocus: () => loadSuggestions(path), placeholder: (machineId ? '输入远程路径(自动补全)' : '先选远程机器'), disabled: !machineId, style: { ...inputS, flex: 1, minWidth: 120 } }),
436
+ React.createElement('button', { style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: () => { if (machineId) setPopOpen(true) }, disabled: !machineId }, '浏览…'),
437
+ ),
438
+ // 自动补全下拉(流式展开,参与文档流,不覆盖确认按钮)
434
439
  (suggestOpen && suggest.length)
435
- ? React.createElement('div', { style: { position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 50, background: v('--dsw-alias-bg-overlay', '#1e1e1e'), border: '1px solid ' + T.borderStrong, borderRadius: 8, marginTop: 4, maxHeight: 200, overflowY: 'auto', boxShadow: '0 6px 24px rgba(0,0,0,0.25)' } },
440
+ ? React.createElement('div', { style: { marginTop: 6, background: v('--dsw-alias-bg-overlay', '#1e1e1e'), border: '1px solid ' + T.borderStrong, borderRadius: 8, maxHeight: 200, overflowY: 'auto', boxShadow: '0 6px 24px rgba(0,0,0,0.25)' } },
436
441
  suggest.map((s, i) => React.createElement('div', { key: s + i, onMouseDown: () => selectSuggestion(s), style: { padding: '6px 10px', cursor: 'pointer', fontSize: 12, fontFamily: 'monospace', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, s)),
437
442
  )
438
443
  : null,
439
444
  ),
445
+ // 目录浏览面板(内联,锚定在对话框内;确认按钮保持可见)
446
+ popOpen ? renderDirPopup() : null,
440
447
  err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
441
448
  React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' } },
442
449
  React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, path ? '所选: ' + path : ''),
443
450
  React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => commitPath(path), disabled: busy || !machineId || !path.trim() }, busy ? '镜像中…' : '设为远程工作区'),
444
451
  ),
445
- // 悬浮浏览弹层:选中的路径回填到输入框(不直接提交)
446
- popOpen ? renderDirPopup() : null,
447
452
  ),
448
453
  (tab === 'local') ? React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end' } }, React.createElement('button', { style: { background: 'transparent' }, onClick: onCancel }, '取消')) : null,
449
454
  ),
package/lib/index.js CHANGED
@@ -71,10 +71,46 @@ function shq(s) {
71
71
  return "'" + String(s).replace(/'/g, "'\\''") + "'"
72
72
  }
73
73
 
74
- /** Collapse `.`/`..`/duplicate slashes into a clean absolute remote path. */
74
+ /**
75
+ * Normalize a remote path into a clean absolute path, supporting BOTH POSIX
76
+ * (`/home/user`) and Windows (`D:\Code`, `C:/Users/x`, `\\server\share`) forms.
77
+ *
78
+ * - POSIX paths keep `/` separators and a leading `/`.
79
+ * - Windows drive paths keep their `X:` prefix and are returned with `\`
80
+ * separators (the native form on a Windows remote); a leading `/` is NOT
81
+ * added, so `D:\Code` stays `D:\Code` instead of becoming `/D:\Code`.
82
+ * - UNC paths (`\\server\share\…`) keep the `\\server\share` prefix.
83
+ * - `.` / `..` segments are collapsed in both separator styles.
84
+ */
75
85
  function normalizeRemotePath(p) {
86
+ const s = String(p)
87
+ // Windows UNC: \\server\share\…
88
+ const unc = s.match(/^(\\\\[^\\]+(?:\\[^\\]+)?)(?:\\|$)(.*)$/s)
89
+ if (unc) {
90
+ const [prefix, rest] = [unc[1], unc[2]]
91
+ const parts = []
92
+ for (const seg of rest.split(/[\\/]+/)) {
93
+ if (seg === '' || seg === '.') continue
94
+ if (seg === '..') { parts.pop(); continue }
95
+ parts.push(seg)
96
+ }
97
+ return parts.length ? prefix + '\\' + parts.join('\\') : prefix
98
+ }
99
+ // Windows drive: X:\… or X:/…
100
+ const drive = s.match(/^([a-zA-Z]:)(?:[\\/]|$)(.*)$/s)
101
+ if (drive) {
102
+ const [prefix, rest] = [drive[1], drive[2]]
103
+ const parts = []
104
+ for (const seg of rest.split(/[\\/]+/)) {
105
+ if (seg === '' || seg === '.') continue
106
+ if (seg === '..') { parts.pop(); continue }
107
+ parts.push(seg)
108
+ }
109
+ return prefix + '\\' + parts.join('\\')
110
+ }
111
+ // POSIX: /a/b/c
76
112
  const parts = []
77
- for (const seg of String(p).split('/')) {
113
+ for (const seg of s.split('/')) {
78
114
  if (seg === '' || seg === '.') continue
79
115
  if (seg === '..') {
80
116
  parts.pop()
@@ -85,12 +121,57 @@ function normalizeRemotePath(p) {
85
121
  return '/' + parts.join('/')
86
122
  }
87
123
 
88
- /** Parent dir of a remote absolute path (string-level). */
124
+ /** Join a remote dir + entry name, honoring the dir's own separator style. */
125
+ function joinRemotePath(dir, name) {
126
+ const d = String(dir)
127
+ if (!d) return String(name)
128
+ if (d.endsWith('/') || d.endsWith('\\')) return d + String(name)
129
+ return d + (d.includes('\\') ? '\\' : '/') + String(name)
130
+ }
131
+
132
+ /** Create every level of a remote dir via SFTP (mkdir -p semantics), working
133
+ * for both POSIX (`/a/b`) and Windows (`D:\a\b`) paths. Best effort: an
134
+ * existing or permission-denied level is skipped. */
135
+ async function mkdirRemoteDirs(sftp, dir) {
136
+ const parent = remoteDirname(dir)
137
+ const isWin = parent.includes('\\')
138
+ // Split off a Windows drive prefix (D:) so it is not treated as a segment.
139
+ const drive = isWin ? (parent.match(/^([a-zA-Z]:)[\\/]?(.*)$/s) || null) : null
140
+ const segs = drive ? drive[2].split(/[\\/]/).filter(Boolean) : parent.split(/[\\/]/).filter(Boolean)
141
+ let cur = isWin ? (drive ? drive[1] + '\\' : '') : ''
142
+ // Drive root (D:\) itself, best effort (usually exists).
143
+ if (isWin && cur) { try { await sftp.mkdir(cur) } catch { /* exists */ } }
144
+ for (const s of segs) {
145
+ if (cur) {
146
+ if (!cur.endsWith('\\') && !cur.endsWith('/')) cur += isWin ? '\\' : '/'
147
+ cur += s
148
+ } else {
149
+ cur = (isWin ? s + ':' : '/' + s)
150
+ }
151
+ try { await sftp.mkdir(cur) } catch { /* exists or no perms */ }
152
+ }
153
+ }
154
+
155
+ /** Parent dir of a remote absolute path (string-level; POSIX or Windows). */
89
156
  function remoteDirname(p) {
90
157
  const norm = normalizeRemotePath(p)
91
- if (norm === '/') return '/'
92
- const idx = norm.lastIndexOf('/')
93
- return idx <= 0 ? '/' : norm.slice(0, idx)
158
+ // Windows drive/UNC root: D:\ or \\server\share
159
+ if (norm === '/' || /^[a-zA-Z]:\\?$/.test(norm) || /^\\\\[^\\]+\\[^\\]+$/.test(norm)) {
160
+ // Ensure a drive root ends with a backslash: D: → D:\
161
+ const m = norm.match(/^([a-zA-Z]:)$/)
162
+ return m ? m[1] + '\\' : norm
163
+ }
164
+ const sep = norm.includes('\\') ? '\\' : '/'
165
+ const idx = norm.lastIndexOf(sep)
166
+ if (idx <= 0) {
167
+ // Windows drive with a child (D:\Code) → D:\
168
+ const m = norm.match(/^([a-zA-Z]:)\\/)
169
+ return m ? m[1] + '\\' : sep === '/' && norm.startsWith('/') ? '/' : norm
170
+ }
171
+ // A parent that is a Windows drive root also needs the trailing backslash.
172
+ const parent = norm.slice(0, idx)
173
+ if (sep === '\\' && /^[a-zA-Z]:$/.test(parent)) return parent + '\\'
174
+ return parent
94
175
  }
95
176
 
96
177
  function truncate(s, max) {
@@ -104,9 +185,26 @@ function truncate(s, max) {
104
185
  // workspace. dsh-remote syncs that local mirror <-> the remote over SFTP.
105
186
 
106
187
  const remotePathBase = (p) => {
107
- const norm = normalizeRemotePath(p).replace(/\/+$/, '')
108
- const base = norm.split('/').pop()
109
- return base || 'workspace'
188
+ const norm = normalizeRemotePath(p).replace(/[\\/]+$/, '')
189
+ const base = norm.split(/[\\/]/).pop()
190
+ // Windows drive root (D:\) has no meaningful basename
191
+ if (!base || /^[a-zA-Z]:$/.test(base)) return 'workspace'
192
+ return base
193
+ }
194
+
195
+ /**
196
+ * Convert an internal remote path (POSIX `/a/b` or Windows `D:\Code`) into the
197
+ * path format the SFTP server understands.
198
+ *
199
+ * Windows OpenSSH's sftp-server accepts POSIX-style paths: a drive letter is
200
+ * written `/D:/…` (equivalent to `D:\…`). POSIX paths pass through unchanged.
201
+ * UNC paths are kept as-is (the server resolves `\\server\share` itself).
202
+ */
203
+ function toSftpPath(p) {
204
+ const norm = normalizeRemotePath(p)
205
+ const m = norm.match(/^([a-zA-Z]):\\(.*)$/s)
206
+ if (m) return '/' + m[1] + ':/' + m[2].replace(/\\/g, '/')
207
+ return norm
110
208
  }
111
209
 
112
210
  /** Harness home: respect `DSH_HOME` when set (the desktop app sets it to its
@@ -236,7 +334,7 @@ async function syncTree(sftp, remoteDir, localDir, maxDepth, maxFiles, maxFileBy
236
334
  const isDir = e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()
237
335
  if (!isDir) continue
238
336
  if (maxDepth <= 0 || stats.files >= maxFiles) continue
239
- const rp = remoteDir === '/' ? '/' + name : remoteDir + '/' + name
337
+ const rp = joinRemotePath(remoteDir, name)
240
338
  const lp = path.join(localDir, name)
241
339
  mkdirSync(lp, { recursive: true })
242
340
  const sub = await syncTree(sftp, rp, lp, maxDepth - 1, maxFiles - stats.files, maxFileBytes)
@@ -257,7 +355,7 @@ async function syncTree(sftp, remoteDir, localDir, maxDepth, maxFiles, maxFileBy
257
355
  await mapLimit(fileEntries, 4, async (e) => {
258
356
  if (stats.files >= maxFiles) return
259
357
  const name = String(e.filename)
260
- const rp = remoteDir === '/' ? '/' + name : remoteDir + '/' + name
358
+ const rp = joinRemotePath(remoteDir, name)
261
359
  const lp = path.join(localDir, name)
262
360
  try {
263
361
  const st = await sftp.stat(rp)
@@ -286,7 +384,7 @@ async function pushTree(sftp, localDir, remoteDir, maxFiles, maxFileBytes) {
286
384
  for (const e of entries) {
287
385
  if (!e.isDirectory()) continue
288
386
  if (stats.files >= maxFiles) break
289
- const rp = remoteDir === '/' ? '/' + e.name : remoteDir + '/' + e.name
387
+ const rp = joinRemotePath(remoteDir, e.name)
290
388
  try { await sftp.mkdir(rp) } catch { /* already exists */ }
291
389
  stats.dirs++
292
390
  const sub = await pushTree(sftp, path.join(localDir, e.name), rp, maxFiles - stats.files, maxFileBytes)
@@ -302,7 +400,7 @@ async function pushTree(sftp, localDir, remoteDir, maxFiles, maxFileBytes) {
302
400
  await mapLimit(fileEntries, 4, async (e) => {
303
401
  if (stats.files >= maxFiles) return
304
402
  const lp = path.join(localDir, e.name)
305
- const rp = remoteDir === '/' ? '/' + e.name : remoteDir + '/' + e.name
403
+ const rp = joinRemotePath(remoteDir, e.name)
306
404
  try {
307
405
  const lstat = statSync(lp)
308
406
  if (maxFileBytes > 0 && lstat.size > maxFileBytes) { stats.skippedLarge++; return }
@@ -617,19 +715,33 @@ class SshPool {
617
715
  )
618
716
  }
619
717
 
620
- /** Resolve a promisified SFTP client for binary file transfers (sync). */
718
+ /** Resolve a promisified SFTP client for binary file transfers (sync).
719
+ * All path arguments are normalized via toSftpPath() so Windows remotes
720
+ * (drive-letter paths) work too. */
621
721
  sftp() {
622
722
  return this.connect().then(
623
723
  (client) =>
624
724
  new Promise((resolve, reject) => {
625
725
  client.sftp((err, sftp) => {
626
726
  if (err) return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
727
+ // Wrap a raw SFTP method so a stuck server can never hang a tool call.
728
+ const withTimeout = (fn) => (...args) =>
729
+ new Promise((r2, j2) => {
730
+ const timer = setTimeout(() => j2(new Error('sftp operation timed out')), this.config.commandTimeoutMs)
731
+ const done = (e, v) => {
732
+ clearTimeout(timer)
733
+ e ? j2(e) : r2(v)
734
+ }
735
+ try { fn(...args, done) } catch (e) { clearTimeout(timer); j2(e) }
736
+ })
737
+ const P = (p) => toSftpPath(p)
627
738
  resolve({
628
- readdir: (dir) => new Promise((r2, j2) => sftp.readdir(dir, (e, list) => (e ? j2(e) : r2(list)))),
629
- stat: (p) => new Promise((r2, j2) => sftp.stat(p, (e, st) => (e ? j2(e) : r2(st)))),
630
- mkdir: (dir) => new Promise((r2, j2) => sftp.mkdir(dir, (e) => (e ? j2(e) : r2()) )),
631
- readFile: (p) => new Promise((r2, j2) => sftp.readFile(p, (e, buf) => (e ? j2(e) : r2(buf)))),
632
- writeFile: (p, data) => new Promise((r2, j2) => sftp.writeFile(p, data, (e) => (e ? j2(e) : r2()))),
739
+ readdir: (dir) => withTimeout((d, cb) => sftp.readdir(d, cb))(P(dir)),
740
+ stat: (p) => withTimeout((d, cb) => sftp.stat(d, cb))(P(p)),
741
+ lstat: (p) => withTimeout((d, cb) => sftp.lstat(d, cb))(P(p)),
742
+ mkdir: (dir) => withTimeout((d, cb) => sftp.mkdir(d, cb))(P(dir)),
743
+ readFile: (p) => withTimeout((d, cb) => sftp.readFile(d, cb))(P(p)),
744
+ writeFile: (p, data) => withTimeout((d, data2, cb) => sftp.writeFile(d, data2, cb))(P(p), data),
633
745
  })
634
746
  })
635
747
  }),
@@ -721,53 +833,66 @@ export async function apply(ctx, config) {
721
833
 
722
834
  /** Structured-listing of a remote dir: name + usable type.
723
835
  *
724
- * Implementation note — this deliberately uses `ls -1A -F` instead of a
725
- * `for f in .[!.]* *` loop or `find`:
726
- * • `-F` marks real directories with a trailing `/` and symlinks with `@`;
727
- * on Linux GNU ls classifies from readdir `d_type` WITHOUT stat, so the
728
- * listing can never hang on a stuck FUSE/network mount the way
729
- * `[ -d "$f" ]` / `find -type` do (those stat every entry and block in
730
- * an uninterruptible D-state that even SIGTERM can't clear).
731
- * • there is no shell glob, so zsh's `nomatch` can't abort the listing on
732
- * directories that have no dotfiles (the old `.[!.]*` glob did).
733
- * Symlinked entries (e.g. `/bin@`) get ONE bounded follow-up stat — only
734
- * symlinks are touched, never the stuck mount — to decide dir vs file. */
836
+ * Uses SFTP readdir (protocol-level), so it works on ANY remote — Linux,
837
+ * macOS, or Windows (cmd.exe / PowerShell) — with no dependency on a POSIX
838
+ * shell or `ls`. Entry types come straight from SFTP attrs; symlinks are
839
+ * resolved with one bounded lstat (if it fails they degrade to files). */
735
840
  const listDirStructured = async (p, timeoutMs) => {
736
- const target = p || '/'
737
- const to = timeoutMs || config.commandTimeoutMs
738
- const res = await pool.exec(`ls -1A -F ${shq(target)}`, to)
739
- if (res.code !== 0 && res.stderr) {
740
- throw new Error('ls failed: ' + (res.stderr || '').trim())
841
+ const target = normalizeRemotePath(p || '/')
842
+ let sftp
843
+ try {
844
+ sftp = await pool.sftp()
845
+ } catch (err) {
846
+ throw new Error('browse failed: ' + ((err && err.message) || err))
847
+ }
848
+ let list = []
849
+ try {
850
+ list = await sftp.readdir(target)
851
+ } catch (err) {
852
+ throw new Error('browse failed: ' + ((err && err.message) || err))
741
853
  }
742
854
  const items = []
743
855
  const symIdx = []
744
- for (const line of String(res.stdout || '').split('\n')) {
745
- if (!line) continue
746
- const c = line[line.length - 1]
747
- if (c === '/') items.push({ type: 'dir', name: line.slice(0, -1) })
748
- else if (c === '@') {
749
- items.push({ type: 'symlink', name: line.slice(0, -1) })
856
+ for (const e of list) {
857
+ const name = String(e.filename)
858
+ if (name === '.' || name === '..' || !name) continue
859
+ const a = e.attrs || {}
860
+ if (a.isSymbolicLink && a.isSymbolicLink()) {
861
+ items.push({ type: 'symlink', name })
750
862
  symIdx.push(items.length - 1)
863
+ } else if (a.isDirectory && a.isDirectory()) {
864
+ items.push({ type: 'dir', name })
751
865
  } else {
752
- // regular file, executable (`*`), socket (`=`), fifo (`|`) …
753
- items.push({ type: 'file', name: c === '*' || c === '=' || c === '|' ? line.slice(0, -1) : line })
866
+ items.push({ type: 'file', name })
754
867
  }
755
868
  }
756
- // Resolve symlink-to-dir vs symlink-to-file. Bounded and failure-tolerant:
757
- // if the stat is slow it times out and symlinks just degrade to files
758
- // (non-enterable) instead of failing the whole browse.
869
+ // Resolve symlink-to-dir vs symlink-to-file (bounded, failure-tolerant).
759
870
  if (symIdx.length) {
760
- const cmd = 'for f in ' + symIdx.map((i) => shq(items[i].name)).join(' ') +
761
- '; do if [ -d "$f" ]; then echo d; else echo f; fi; done'
762
- const r2 = await pool.exec(cmd, Math.min(to, 5000))
763
- const kinds = String(r2.stdout || '').split('\n')
764
- for (let i = 0; i < symIdx.length; i++) {
765
- items[symIdx[i]].type = (kinds[i] || '').trim() === 'd' ? 'dir' : 'file'
766
- }
871
+ await Promise.all(symIdx.map(async (i) => {
872
+ const full = joinRemotePath(target, items[i].name)
873
+ try {
874
+ const st = await sftp.lstat(full)
875
+ items[i].type = st && st.isDirectory && st.isDirectory() ? 'dir' : 'file'
876
+ } catch { /* degrade to file */ }
877
+ }))
767
878
  }
768
879
  return { path: target, items }
769
880
  }
770
881
 
882
+ /** Verify a remote path is an existing directory, via SFTP stat (works on
883
+ * any remote shell: POSIX sh, cmd.exe, PowerShell). Returns false when the
884
+ * path is missing, is a file, or the stat errors. */
885
+ const isRemoteDir = async (p) => {
886
+ const target = normalizeRemotePath(p)
887
+ try {
888
+ const sftp = await pool.sftp()
889
+ const st = await sftp.stat(target)
890
+ return !!(st && st.isDirectory && st.isDirectory())
891
+ } catch {
892
+ return false
893
+ }
894
+ }
895
+
771
896
  // ── remote workspace state ────────────────────────────────────────────────
772
897
 
773
898
  const wsPath = () => (config.workspace || '').trim()
@@ -812,7 +937,8 @@ export async function apply(ctx, config) {
812
937
  ]
813
938
  if (s.host && s.workspace) {
814
939
  try {
815
- const res = await pool.exec('echo ok; hostname; pwd', Math.min(config.commandTimeoutMs, 8000))
940
+ // `echo ok` only — the `;`-joined probes broke on cmd.exe remotes.
941
+ const res = await pool.exec('echo ok', Math.min(config.commandTimeoutMs, 8000))
816
942
  if (res.signal === 'TIMEOUT') lines.push('Ping: timeout')
817
943
  else if (res.code === 0) lines.push('Ping: OK — ' + res.stdout.replace(/\s+/g, ' ').trim())
818
944
  else lines.push('Ping: FAILED — ' + (res.stderr || res.stdout || `exit ${res.code}`).trim())
@@ -852,9 +978,9 @@ export async function apply(ctx, config) {
852
978
  privateKeyPath: args.privateKeyPath || undefined,
853
979
  })
854
980
  try {
855
- const res = await pool.exec('echo ok; hostname', 8000)
981
+ const res = await pool.exec('echo ok', 8000)
856
982
  if (res.code !== 0 && !res.stdout) return { text: 'connect failed: ' + (res.stderr || 'exit ' + res.code) }
857
- return { text: `Connected to ${host} as ${config.username}.\nhostname: ${res.stdout.replace(/\s+/g, ' ').trim()}\n\npick a workspace with rw_pick_workspace (path=<abs>).` }
983
+ return { text: `Connected to ${host} as ${config.username}.\n\npick a workspace with rw_pick_workspace (path=<abs>).` }
858
984
  } catch (err) {
859
985
  throw err
860
986
  }
@@ -875,8 +1001,7 @@ export async function apply(ctx, config) {
875
1001
  async execute(args) {
876
1002
  const p = normalizeRemotePath(String(args.path || ''))
877
1003
  if (!p || p === '/') throw new Error('rw_pick_workspace: path must be an absolute directory')
878
- const res = await pool.exec(`if [ -d ${shq(p)} ]; then echo DIR; else echo NOTDIR; fi`)
879
- const ok = res.stdout.trim() === 'DIR'
1004
+ const ok = await isRemoteDir(p)
880
1005
  if (!ok) return { text: `not a directory (or missing) on ${p}` }
881
1006
  persistWorkspace(p)
882
1007
  const local = ensureMirror(p, config.host, config.username, config.port)
@@ -964,9 +1089,24 @@ export async function apply(ctx, config) {
964
1089
  async execute(args) {
965
1090
  const p = args.path ? normalizeRemotePath(String(args.path)) : wsPath()
966
1091
  if (!p) throw new Error('rw_list_dir: no path and no remote workspace set')
967
- // `ls -la` (no --color / -N): piped output never carries color on either
968
- // GNU or BSD ls, so the GNU-only flags would break macOS/BSD remotes.
969
- return { text: await run(`ls -la ${shq(p)}`) }
1092
+ // SFTP readdir listing — works on any remote shell (POSIX, cmd.exe,
1093
+ // PowerShell) unlike `ls -la`.
1094
+ let list
1095
+ try {
1096
+ const sftp = await pool.sftp()
1097
+ list = await sftp.readdir(p)
1098
+ } catch (err) {
1099
+ throw new Error('rw_list_dir: ' + ((err && err.message) || err))
1100
+ }
1101
+ const lines = list
1102
+ .filter((e) => String(e.filename) !== '.' && String(e.filename) !== '..')
1103
+ .map((e) => {
1104
+ const a = e.attrs || {}
1105
+ const type = a.isDirectory && a.isDirectory() ? 'd' : (a.isSymbolicLink && a.isSymbolicLink() ? 'l' : '-')
1106
+ const size = typeof a.size === 'number' ? String(a.size) : '?'
1107
+ return `${type} ${size.padStart(10)} ${String(e.filename)}`
1108
+ })
1109
+ return { text: lines.length ? lines.join('\n') : '(empty directory)' }
970
1110
  },
971
1111
  }),
972
1112
 
@@ -991,10 +1131,19 @@ export async function apply(ctx, config) {
991
1131
  let from = Math.max(Number(args.startLine) || 1, 1)
992
1132
  let to = Number(args.endLine) || 0
993
1133
  if (!to || to - from + 1 > maxLines) to = from + maxLines - 1
994
- // Absolute paths start with '/', so the GNU-only `--` terminator is
995
- // dropped to keep BSD/macOS sed (which rejects `--`) working.
996
- const raw = await run(`sed -n '${from},${to}p' ${shq(p)}`, { timeoutMs: config.commandTimeoutMs })
997
- const numbered = raw.split('\n').map((l, i) => `${String(from + i).padStart(6)}\t${l}`).join('\n').replace(/\s+$/, '')
1134
+ // SFTP readFile is used instead of `sed -n` so Windows remotes (cmd.exe
1135
+ // / PowerShell, no POSIX sed) work too.
1136
+ let buf
1137
+ try {
1138
+ const sftp = await pool.sftp()
1139
+ buf = await sftp.readFile(p)
1140
+ } catch (err) {
1141
+ throw new Error('rw_read_file: ' + ((err && err.message) || err))
1142
+ }
1143
+ const content = buf.toString('utf8').replace(/\r\n/g, '\n')
1144
+ const allLines = content.split('\n')
1145
+ const page = allLines.slice(from - 1, to)
1146
+ const numbered = page.map((l, i) => `${String(from + i).padStart(6)}\t${l}`).join('\n').replace(/\s+$/, '')
998
1147
  let text = numbered === '' ? '(empty or out of range)' : numbered
999
1148
  if (!args.endLine) text += '\n(shown up to ' + maxLines + ' lines; use startLine/endLine to page)'
1000
1149
  return { text }
@@ -1018,7 +1167,9 @@ export async function apply(ctx, config) {
1018
1167
  if (!cmd) throw new Error('rw_exec: command is required')
1019
1168
  const ws = wsPath()
1020
1169
  const cwd = args.cwd ? normalizeRemotePath(String(args.cwd)) : (ws || '')
1021
- const full = cwd ? `cd ${shq(cwd)} && ${cmd}` : cmd
1170
+ // Windows remotes: no POSIX cd/single-quotes — let the command run as-is
1171
+ // (the user writes cmd/PowerShell). POSIX remotes get `cd … && cmd`.
1172
+ const full = cwd && !cwd.includes('\\') ? `cd ${shq(cwd)} && ${cmd}` : cmd
1022
1173
  return { text: await run(full, { timeoutMs: config.commandTimeoutMs }) }
1023
1174
  },
1024
1175
  }),
@@ -1048,15 +1199,9 @@ export async function apply(ctx, config) {
1048
1199
  }
1049
1200
  const mkdir = args.mkdir !== false
1050
1201
  if (mkdir) {
1051
- const parent = remoteDirname(p)
1052
1202
  // create each level from the root so mkdir -p semantics survive even
1053
1203
  // when intermediate privileged-parent dirs don't allow create (best effort)
1054
- const segs = parent.split('/').filter(Boolean)
1055
- let cur = ''
1056
- for (const s of segs) {
1057
- cur += '/' + s
1058
- try { await sftp.mkdir(cur) } catch { /* exists or no perms */ }
1059
- }
1204
+ await mkdirRemoteDirs(sftp, p)
1060
1205
  }
1061
1206
  const buf = Buffer.from(content, 'utf8')
1062
1207
  await sftp.writeFile(p, buf)
@@ -1085,6 +1230,11 @@ export async function apply(ctx, config) {
1085
1230
  const ws = wsPath()
1086
1231
  const dir = args.path ? normalizeRemotePath(String(args.path)) : (ws || '')
1087
1232
  if (!dir) throw new Error('rw_search: no path and no remote workspace set')
1233
+ if (dir.includes('\\')) {
1234
+ // Windows remotes have no POSIX find/grep; the search would need a
1235
+ // recursive SFTP walk. Give an actionable hint instead of a broken cmd.
1236
+ return { text: `rw_search does not support Windows-remote paths yet (${dir}). Use rw_exec with a PowerShell one-liner, e.g.: powershell -NoProfile -Command "Get-ChildItem -Recurse -File | Select-String -Pattern '${pattern}'"` }
1237
+ }
1088
1238
  const flags = 'r' + (args.ignoreCase === false ? '' : 'i') + 'nE'
1089
1239
  const name = args.glob ? ` -name ${shq(String(args.glob))}` : ''
1090
1240
  // find … -exec grep {} + is POSIX (BSD + GNU): `grep -RInE` alone would
@@ -1156,12 +1306,7 @@ export async function apply(ctx, config) {
1156
1306
  } catch (err) {
1157
1307
  throw new Error('rw_upload: sftp unavailable: ' + ((err && err.message) || err))
1158
1308
  }
1159
- const segs = remoteDirname(rp).split('/').filter(Boolean)
1160
- let cur = ''
1161
- for (const s of segs) {
1162
- cur += '/' + s
1163
- try { await sftp.mkdir(cur) } catch { /* exists or no perms */ }
1164
- }
1309
+ await mkdirRemoteDirs(sftp, rp)
1165
1310
  const buf = readFileSync(lp)
1166
1311
  await sftp.writeFile(rp, buf)
1167
1312
  const bytes = buf.byteLength
@@ -1356,8 +1501,8 @@ export async function apply(ctx, config) {
1356
1501
  const payload = JSON.parse((await readBody(req)) || '{}')
1357
1502
  const p = normalizeRemotePath(String(payload.path || ''))
1358
1503
  if (!p || p === '/') return sendJson(res, 400, { error: 'path must be an absolute directory' })
1359
- const r = await pool.exec(`if [ -d ${shq(p)} ]; then echo DIR; else echo NOTDIR; fi`)
1360
- if (r.stdout.trim() !== 'DIR') return sendJson(res, 400, { ok: false, error: `not a directory: ${p}` })
1504
+ const okDir = await isRemoteDir(p)
1505
+ if (!okDir) return sendJson(res, 400, { ok: false, error: `not a directory: ${p}` })
1361
1506
  persistWorkspace(p)
1362
1507
  const local = ensureMirror(p, config.host, config.username, config.port)
1363
1508
  return sendJson(res, 200, { ok: true, workspace: p, localMirror: local, ...status() })
@@ -1378,8 +1523,8 @@ export async function apply(ctx, config) {
1378
1523
  if (!config.host) return sendJson(res, 400, { ok: false, error: 'no remote host configured/connected — connect first' })
1379
1524
  // Always verify over SSH (connecting if needed) so an unconnected or
1380
1525
  // wrong host can never silently mint a mirror for a bogus path.
1381
- const r = await pool.exec(`if [ -d ${shq(p)} ]; then echo DIR; else echo NOTDIR; fi`)
1382
- if (r.stdout.trim() !== 'DIR') return sendJson(res, 400, { ok: false, error: `not a directory (or unreachable): ${p}` })
1526
+ const okDir = await isRemoteDir(p)
1527
+ if (!okDir) return sendJson(res, 400, { ok: false, error: `not a directory (or unreachable): ${p}` })
1383
1528
  const local = ensureMirror(p, config.host, config.username, config.port)
1384
1529
  persistWorkspace(p)
1385
1530
  return sendJson(res, 200, { ok: true, path: p, localMirror: local, ...status() })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote",
3
- "version": "0.6.7",
3
+ "version": "0.6.9",
4
4
  "description": "Remote-work assistant for DeepSeek Harness: connect SSH (password or key), pick a remote workspace, operate on it with rw_pick_workspace / rw_list_dir / rw_read_file / rw_exec / rw_sync tools, and mirror it to a real local directory (SFTP) so the DSH native workspace can adopt it.",
5
5
  "license": "MIT",
6
6
  "author": "flymysql <flyphp@outlook.com>",