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.
- package/lib/client.js +49 -44
- package/lib/index.js +228 -83
- 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
|
|
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
|
-
|
|
288
|
-
const
|
|
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 === '/' ?
|
|
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(
|
|
322
|
-
const kids = list.filter((it) => it.dir).slice(0, 40).map((it) => (base === '/' ? '/' + it.name : base +
|
|
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
|
-
//
|
|
357
|
-
//
|
|
358
|
-
// (
|
|
359
|
-
return React.createElement('div', {
|
|
360
|
-
style: {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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: {
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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(
|
|
109
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) =>
|
|
629
|
-
stat: (p) =>
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
*
|
|
725
|
-
*
|
|
726
|
-
*
|
|
727
|
-
*
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
items.push({ type: 'symlink', name
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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
|
-
|
|
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
|
|
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}.\
|
|
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
|
|
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
|
-
//
|
|
968
|
-
//
|
|
969
|
-
|
|
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
|
-
//
|
|
995
|
-
//
|
|
996
|
-
|
|
997
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1360
|
-
if (
|
|
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
|
|
1382
|
-
if (
|
|
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.
|
|
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>",
|