dsh-remote 0.5.10 → 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/LICENSE +21 -21
- package/README.md +49 -7
- package/README.zh.md +39 -7
- package/cordis.patch.yml +6 -6
- package/lib/client.js +54 -49
- package/lib/index.js +796 -137
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
import z from '@deepseek-ai/schemastery'
|
|
19
19
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
20
20
|
import ssh2 from 'ssh2'
|
|
21
|
-
import {
|
|
21
|
+
import { createHash } from 'node:crypto'
|
|
22
|
+
import { execFile } from 'node:child_process'
|
|
23
|
+
import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync, renameSync, copyFileSync, utimesSync } from 'node:fs'
|
|
22
24
|
import { homedir } from 'node:os'
|
|
23
25
|
import path from 'node:path'
|
|
24
26
|
|
|
@@ -53,6 +55,13 @@ export const Config = z.object({
|
|
|
53
55
|
connectTimeoutMs: z.number().step(1).min(1000).default(15000),
|
|
54
56
|
/** Hard ceiling on collected remote output per call. */
|
|
55
57
|
maxOutputChars: z.number().step(1).min(1024).default(200000),
|
|
58
|
+
/** Skip mirroring files larger than this many bytes (0 = no cap). */
|
|
59
|
+
maxFileBytes: z.number().step(1).min(0).default(52428800),
|
|
60
|
+
/** Host-key policy: `accept-new` (default) records a host's key on first
|
|
61
|
+
* connect and verifies it afterwards (mirrors ssh's StrictHostKeyChecking
|
|
62
|
+
* accept-new); `verify` also rejects hosts never seen before; `off` skips
|
|
63
|
+
* verification entirely (MITM-unsafe, not recommended). */
|
|
64
|
+
hostKeyMode: z.string().default('accept-new'),
|
|
56
65
|
})
|
|
57
66
|
|
|
58
67
|
// ── shell / path helpers (proven in the read tooling) ───────────────────────
|
|
@@ -62,10 +71,46 @@ function shq(s) {
|
|
|
62
71
|
return "'" + String(s).replace(/'/g, "'\\''") + "'"
|
|
63
72
|
}
|
|
64
73
|
|
|
65
|
-
/**
|
|
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
|
+
*/
|
|
66
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
|
|
67
112
|
const parts = []
|
|
68
|
-
for (const seg of
|
|
113
|
+
for (const seg of s.split('/')) {
|
|
69
114
|
if (seg === '' || seg === '.') continue
|
|
70
115
|
if (seg === '..') {
|
|
71
116
|
parts.pop()
|
|
@@ -76,12 +121,57 @@ function normalizeRemotePath(p) {
|
|
|
76
121
|
return '/' + parts.join('/')
|
|
77
122
|
}
|
|
78
123
|
|
|
79
|
-
/**
|
|
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). */
|
|
80
156
|
function remoteDirname(p) {
|
|
81
157
|
const norm = normalizeRemotePath(p)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
85
175
|
}
|
|
86
176
|
|
|
87
177
|
function truncate(s, max) {
|
|
@@ -95,23 +185,111 @@ function truncate(s, max) {
|
|
|
95
185
|
// workspace. dsh-remote syncs that local mirror <-> the remote over SFTP.
|
|
96
186
|
|
|
97
187
|
const remotePathBase = (p) => {
|
|
98
|
-
const norm = normalizeRemotePath(p).replace(
|
|
99
|
-
const base = norm.split(
|
|
100
|
-
|
|
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
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Harness home: respect `DSH_HOME` when set (the desktop app sets it to its
|
|
211
|
+
* own `userData/harness`), otherwise fall back to `~/.dsh`. Keeping plugin
|
|
212
|
+
* data under the same root the harness uses means uninstalling / upgrading the
|
|
213
|
+
* app no longer leaves state behind in the home directory. */
|
|
214
|
+
function dshBase() {
|
|
215
|
+
const env = process.env.DSH_HOME
|
|
216
|
+
if (env && String(env).trim()) return path.resolve(String(env).trim())
|
|
217
|
+
return path.join(homedir(), '.dsh')
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Root holding every remote host's mirrors + the machine registry. */
|
|
221
|
+
function remoteWorkspacesRoot() {
|
|
222
|
+
return path.join(dshBase(), 'remote-workspaces')
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Recursive directory copy (EXDEV fallback for migrateLegacyData). */
|
|
226
|
+
function copyDirSync(from, to) {
|
|
227
|
+
mkdirSync(to, { recursive: true })
|
|
228
|
+
for (const e of readdirSync(from, { withFileTypes: true })) {
|
|
229
|
+
const s = path.join(from, e.name)
|
|
230
|
+
const d = path.join(to, e.name)
|
|
231
|
+
if (e.isDirectory()) copyDirSync(s, d)
|
|
232
|
+
else try { copyFileSync(s, d) } catch { /* skip unreadable */ }
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** One-time migration of pre-0.6 data (which lived at `~/.dsh/remote-workspaces`
|
|
237
|
+
* regardless of `DSH_HOME`). Moves the legacy dir into the current home so a
|
|
238
|
+
* desktop install keeps its existing machines and mirrors. Idempotent; a
|
|
239
|
+
* rename across devices falls back to a copy. */
|
|
240
|
+
function migrateLegacyData() {
|
|
241
|
+
const legacy = path.join(homedir(), '.dsh', 'remote-workspaces')
|
|
242
|
+
const target = remoteWorkspacesRoot()
|
|
243
|
+
if (legacy === target || !existsSync(legacy) || existsSync(target)) return
|
|
244
|
+
try {
|
|
245
|
+
mkdirSync(path.dirname(target), { recursive: true })
|
|
246
|
+
try {
|
|
247
|
+
renameSync(legacy, target)
|
|
248
|
+
} catch (err) {
|
|
249
|
+
if (err.code !== 'EXDEV') throw err
|
|
250
|
+
copyDirSync(legacy, target)
|
|
251
|
+
}
|
|
252
|
+
} catch {
|
|
253
|
+
// Migration is best-effort: a fresh registry is created on next save.
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** 32-bit string hash rendered in base36 (short, collision-safe enough). */
|
|
258
|
+
function shortHash(s) {
|
|
259
|
+
let h = 0
|
|
260
|
+
for (let i = 0; i < s.length; i++) {
|
|
261
|
+
h = ((h << 5) - h + s.charCodeAt(i)) | 0
|
|
262
|
+
}
|
|
263
|
+
return (h >>> 0).toString(36)
|
|
101
264
|
}
|
|
102
265
|
|
|
103
266
|
/** Stable local root for one remote host's mirrors */
|
|
104
267
|
function mirrorRootFor(host, user, port) {
|
|
105
268
|
const tag = [host, user, port].filter(Boolean).join('-').replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
106
|
-
return path.join(
|
|
269
|
+
return path.join(remoteWorkspacesRoot(), tag)
|
|
107
270
|
}
|
|
108
271
|
|
|
109
272
|
/** Local mirror dir for a specific remote path (idempotent → returns same dir).
|
|
110
273
|
* Named after the remote directory's basename so the harness workspace label
|
|
111
|
-
* reads cleanly (e.g. .../project)
|
|
274
|
+
* reads cleanly (e.g. .../project). When the plain basename dir is already
|
|
275
|
+
* taken by a DIFFERENT remote path on the same host, a short path-hash suffix
|
|
276
|
+
* is appended so mirrors never collide. */
|
|
112
277
|
function mirrorDirFor(remotePath, host, user, port) {
|
|
113
278
|
const base = remotePathBase(remotePath)
|
|
114
|
-
|
|
279
|
+
const root = mirrorRootFor(host, user, port)
|
|
280
|
+
const plain = path.join(root, base)
|
|
281
|
+
const norm = normalizeRemotePath(remotePath)
|
|
282
|
+
// A pre-existing mirror for this exact remote origin → reuse it (idempotent).
|
|
283
|
+
try {
|
|
284
|
+
const meta = JSON.parse(readFileSync(path.join(plain, '.dsh-remote-meta.json'), 'utf8'))
|
|
285
|
+
if (meta.remotePath === norm) return plain
|
|
286
|
+
} catch {
|
|
287
|
+
/* no mirror yet → fall through */
|
|
288
|
+
}
|
|
289
|
+
// Plain dir is free → first mirror for this basename keeps the clean name.
|
|
290
|
+
// It exists but points elsewhere (or is a non-mirror dir) → hashed variant.
|
|
291
|
+
if (!existsSync(plain)) return plain
|
|
292
|
+
return path.join(root, base + '-' + shortHash(norm))
|
|
115
293
|
}
|
|
116
294
|
|
|
117
295
|
/** Create the local mirror dir + a meta file describing its remote origin. */
|
|
@@ -120,82 +298,130 @@ function ensureMirror(remotePath, host, user, port) {
|
|
|
120
298
|
mkdirSync(dir, { recursive: true })
|
|
121
299
|
writeFileSync(
|
|
122
300
|
path.join(dir, '.dsh-remote-meta.json'),
|
|
123
|
-
JSON.stringify({ host, port, username: user, remotePath, createdAt: new Date().toISOString() }, null, 2),
|
|
301
|
+
JSON.stringify({ host, port, username: user, remotePath: normalizeRemotePath(remotePath), createdAt: new Date().toISOString() }, null, 2),
|
|
124
302
|
)
|
|
125
303
|
return dir
|
|
126
304
|
}
|
|
127
305
|
|
|
128
|
-
/**
|
|
129
|
-
async function
|
|
306
|
+
/** Run `fn` over `items` with at most `limit` concurrent in-flight calls. */
|
|
307
|
+
async function mapLimit(items, limit, fn) {
|
|
308
|
+
const limitN = Math.max(1, Math.min(Math.floor(limit) || 1, items.length))
|
|
309
|
+
let next = 0
|
|
310
|
+
const worker = async () => {
|
|
311
|
+
while (next < items.length) {
|
|
312
|
+
const i = next++
|
|
313
|
+
try { await fn(items[i], i) } catch { /* per-item errors are the fn's concern */ }
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const workers = []
|
|
317
|
+
for (let w = 0; w < limitN; w++) workers.push(worker())
|
|
318
|
+
await Promise.all(workers)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Recursively sync remote → local mirror. Bounded by depth/files/size;
|
|
322
|
+
* skips files whose size+mtime already match locally. Returns counters. */
|
|
323
|
+
async function syncTree(sftp, remoteDir, localDir, maxDepth, maxFiles, maxFileBytes) {
|
|
130
324
|
const entries = await sftp.readdir(remoteDir).then(
|
|
131
325
|
(list) => list,
|
|
132
326
|
() => [],
|
|
133
327
|
)
|
|
134
|
-
|
|
135
|
-
|
|
328
|
+
const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, touched: [] }
|
|
329
|
+
|
|
330
|
+
// Directories first (sequential, depth-bounded) so every file has a home.
|
|
136
331
|
for (const e of entries) {
|
|
137
332
|
const name = String(e.filename)
|
|
138
333
|
if (name === '.' || name === '..') continue
|
|
139
|
-
const rp = remoteDir === '/' ? '/' + name : remoteDir + '/' + name
|
|
140
|
-
const lp = path.join(localDir, name)
|
|
141
334
|
const isDir = e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()
|
|
142
|
-
if (isDir)
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
335
|
+
if (!isDir) continue
|
|
336
|
+
if (maxDepth <= 0 || stats.files >= maxFiles) continue
|
|
337
|
+
const rp = joinRemotePath(remoteDir, name)
|
|
338
|
+
const lp = path.join(localDir, name)
|
|
339
|
+
mkdirSync(lp, { recursive: true })
|
|
340
|
+
const sub = await syncTree(sftp, rp, lp, maxDepth - 1, maxFiles - stats.files, maxFileBytes)
|
|
341
|
+
stats.dirs += sub.dirs + 1
|
|
342
|
+
stats.files += sub.files
|
|
343
|
+
stats.skippedUnchanged += sub.skippedUnchanged
|
|
344
|
+
stats.skippedLarge += sub.skippedLarge
|
|
345
|
+
stats.touched.push(...sub.touched)
|
|
346
|
+
if (stats.files >= maxFiles) break
|
|
347
|
+
}
|
|
348
|
+
if (stats.files >= maxFiles) return stats
|
|
349
|
+
|
|
350
|
+
// Files in one bounded-parallel sweep per directory level.
|
|
351
|
+
const fileEntries = entries.filter(
|
|
352
|
+
(e) => !(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()) &&
|
|
353
|
+
String(e.filename) !== '.' && String(e.filename) !== '..',
|
|
354
|
+
)
|
|
355
|
+
await mapLimit(fileEntries, 4, async (e) => {
|
|
356
|
+
if (stats.files >= maxFiles) return
|
|
357
|
+
const name = String(e.filename)
|
|
358
|
+
const rp = joinRemotePath(remoteDir, name)
|
|
359
|
+
const lp = path.join(localDir, name)
|
|
151
360
|
try {
|
|
361
|
+
const st = await sftp.stat(rp)
|
|
362
|
+
if (maxFileBytes > 0 && st.size > maxFileBytes) { stats.skippedLarge++; return }
|
|
363
|
+
const lpStat = existsSync(lp) ? statSync(lp) : null
|
|
364
|
+
const unchanged = lpStat && lpStat.size === st.size && Math.floor(lpStat.mtimeMs / 1000) === st.mtime
|
|
365
|
+
if (unchanged) { stats.skippedUnchanged++; return }
|
|
152
366
|
const buf = await sftp.readFile(rp)
|
|
153
367
|
writeFileSync(lp, buf)
|
|
154
|
-
|
|
155
|
-
files++
|
|
368
|
+
try { utimesSync(lp, new Date(st.mtime * 1000), new Date(st.mtime * 1000)) } catch {}
|
|
369
|
+
stats.files++
|
|
370
|
+
stats.touched.push(rp)
|
|
156
371
|
} catch {
|
|
157
372
|
/* skip unreadable */
|
|
158
373
|
}
|
|
159
|
-
}
|
|
160
|
-
return
|
|
374
|
+
})
|
|
375
|
+
return stats
|
|
161
376
|
}
|
|
162
377
|
|
|
163
|
-
/** Recursively upload a local mirror tree → remote SFTP dir. Bounded by files
|
|
164
|
-
|
|
378
|
+
/** Recursively upload a local mirror tree → remote SFTP dir. Bounded by files;
|
|
379
|
+
* skips files whose remote copy already matches (same size, remote no older). */
|
|
380
|
+
async function pushTree(sftp, localDir, remoteDir, maxFiles, maxFileBytes) {
|
|
165
381
|
const entries = readdirSync(localDir, { withFileTypes: true }).filter((e) => e.name !== '.dsh-remote-meta.json')
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const pushed = []
|
|
382
|
+
const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, pushed: [] }
|
|
383
|
+
|
|
169
384
|
for (const e of entries) {
|
|
170
|
-
if (e.isDirectory())
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
385
|
+
if (!e.isDirectory()) continue
|
|
386
|
+
if (stats.files >= maxFiles) break
|
|
387
|
+
const rp = joinRemotePath(remoteDir, e.name)
|
|
388
|
+
try { await sftp.mkdir(rp) } catch { /* already exists */ }
|
|
389
|
+
stats.dirs++
|
|
390
|
+
const sub = await pushTree(sftp, path.join(localDir, e.name), rp, maxFiles - stats.files, maxFileBytes)
|
|
391
|
+
stats.files += sub.files
|
|
392
|
+
stats.skippedUnchanged += sub.skippedUnchanged
|
|
393
|
+
stats.skippedLarge += sub.skippedLarge
|
|
394
|
+
stats.pushed.push(...sub.pushed)
|
|
395
|
+
if (stats.files >= maxFiles) break
|
|
396
|
+
}
|
|
397
|
+
if (stats.files >= maxFiles) return stats
|
|
398
|
+
|
|
399
|
+
const fileEntries = entries.filter((e) => !e.isDirectory())
|
|
400
|
+
await mapLimit(fileEntries, 4, async (e) => {
|
|
401
|
+
if (stats.files >= maxFiles) return
|
|
182
402
|
const lp = path.join(localDir, e.name)
|
|
183
|
-
const rp = remoteDir
|
|
403
|
+
const rp = joinRemotePath(remoteDir, e.name)
|
|
184
404
|
try {
|
|
405
|
+
const lstat = statSync(lp)
|
|
406
|
+
if (maxFileBytes > 0 && lstat.size > maxFileBytes) { stats.skippedLarge++; return }
|
|
407
|
+
let rstat = null
|
|
408
|
+
try { rstat = await sftp.stat(rp) } catch { /* remote file absent → upload */ }
|
|
409
|
+
const unchanged = rstat && rstat.size === lstat.size && rstat.mtime >= Math.floor(lstat.mtimeMs / 1000)
|
|
410
|
+
if (unchanged) { stats.skippedUnchanged++; return }
|
|
185
411
|
const buf = readFileSync(lp)
|
|
186
412
|
await sftp.writeFile(rp, buf)
|
|
187
|
-
|
|
188
|
-
|
|
413
|
+
stats.files++
|
|
414
|
+
stats.pushed.push(rp)
|
|
189
415
|
} catch {
|
|
190
416
|
/* skip unreadable / unwritable */
|
|
191
417
|
}
|
|
192
|
-
}
|
|
193
|
-
return
|
|
418
|
+
})
|
|
419
|
+
return stats
|
|
194
420
|
}
|
|
195
421
|
|
|
196
422
|
// ── persistent multi-machine registry ─────────────────────────────────────
|
|
197
423
|
const MACHINES_FILE = 'machines.json'
|
|
198
|
-
const machinesFile = () => path.join(
|
|
424
|
+
const machinesFile = () => path.join(remoteWorkspacesRoot(), MACHINES_FILE)
|
|
199
425
|
function loadMachines() {
|
|
200
426
|
try {
|
|
201
427
|
const j = JSON.parse(readFileSync(machinesFile(), 'utf8'))
|
|
@@ -225,6 +451,98 @@ function applyMachine(config, m) {
|
|
|
225
451
|
}
|
|
226
452
|
function machineId() { return 'm-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6) }
|
|
227
453
|
|
|
454
|
+
// ── host-key registry (TOFU: trust-on-first-use, like ssh accept-new) ──────
|
|
455
|
+
const KNOWN_HOSTS_FILE = 'known_hosts.json'
|
|
456
|
+
const knownHostsFile = () => path.join(remoteWorkspacesRoot(), KNOWN_HOSTS_FILE)
|
|
457
|
+
|
|
458
|
+
/** Read the trusted host-key map { "host:port": { algo, fingerprint } }. */
|
|
459
|
+
function loadKnownHosts() {
|
|
460
|
+
try {
|
|
461
|
+
const j = JSON.parse(readFileSync(knownHostsFile(), 'utf8'))
|
|
462
|
+
if (j && typeof j === 'object' && !Array.isArray(j)) return j
|
|
463
|
+
} catch {}
|
|
464
|
+
return {}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Extract the host-key algorithm name from a raw SSH host-key blob
|
|
468
|
+
* (SSH wire format: `uint32 len` + algorithm string + key data). */
|
|
469
|
+
function blobAlgorithm(blob) {
|
|
470
|
+
if (!Buffer.isBuffer(blob) || blob.length < 4) return ''
|
|
471
|
+
try {
|
|
472
|
+
const len = blob.readUInt32BE(0)
|
|
473
|
+
return blob.toString('utf8', 4, 4 + len)
|
|
474
|
+
} catch {
|
|
475
|
+
return ''
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** SHA-256 fingerprint (base64) of an ssh2 host-key blob, like `SHA256:…`
|
|
480
|
+
* in a known_hosts file (without the `SHA256:` prefix).
|
|
481
|
+
*
|
|
482
|
+
* ssh2 v1.17 passes hostVerifier the RAW host-key blob Buffer (the SSH
|
|
483
|
+
* wire-format `string(algo) string(keydata)`), NOT the old `{ algo, hash }`
|
|
484
|
+
* object. Fingerprinting `key.hash` on a raw Buffer crashed with "The 'data'
|
|
485
|
+
* argument must be of type string or an instance of Buffer, TypedArray, or
|
|
486
|
+
* DataView. Received undefined" on EVERY connect (v0.6.6 bug) — so the
|
|
487
|
+
* v0.6.1 TOFU guard never actually worked against real ssh2. Accept both
|
|
488
|
+
* shapes defensively. */
|
|
489
|
+
function keyFingerprint(key) {
|
|
490
|
+
const blob = Buffer.isBuffer(key) ? key : (key && key.hash)
|
|
491
|
+
if (!blob) throw new Error('host key missing (hostVerifier received no key blob)')
|
|
492
|
+
return createHash('sha256').update(blob).digest('base64')
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Build an ssh2 `hostVerifier` bound to the current config. Returns
|
|
496
|
+
* `{ mode, verifier, lastError, knownHosts, forgetHost }`. The verifier:
|
|
497
|
+
* - `off` → always accepts;
|
|
498
|
+
* - `accept-new` → records a host's key on first connect, rejects any CHANGE
|
|
499
|
+
* afterwards (classic TOFU / MITM detection);
|
|
500
|
+
* - `verify` → same, plus rejects hosts never recorded before. */
|
|
501
|
+
function createHostKeyGuard(config) {
|
|
502
|
+
const id = () => `${config.host}:${config.port}`
|
|
503
|
+
const mode = config.hostKeyMode === 'verify' || config.hostKeyMode === 'off'
|
|
504
|
+
? config.hostKeyMode
|
|
505
|
+
: 'accept-new'
|
|
506
|
+
const guard = {
|
|
507
|
+
mode,
|
|
508
|
+
lastError: null,
|
|
509
|
+
knownHosts: loadKnownHosts,
|
|
510
|
+
forgetHost() {
|
|
511
|
+
const kh = loadKnownHosts()
|
|
512
|
+
delete kh[id()]
|
|
513
|
+
try { mkdirSync(path.dirname(knownHostsFile()), { recursive: true }) } catch {}
|
|
514
|
+
writeFileSync(knownHostsFile(), JSON.stringify(kh, null, 2))
|
|
515
|
+
},
|
|
516
|
+
verifier(key) {
|
|
517
|
+
if (mode === 'off') return true
|
|
518
|
+
const fp = keyFingerprint(key)
|
|
519
|
+
const kh = loadKnownHosts()
|
|
520
|
+
const stored = kh[id()]
|
|
521
|
+
if (stored) {
|
|
522
|
+
if (stored.fingerprint === fp) return true
|
|
523
|
+
guard.lastError =
|
|
524
|
+
`host key for ${id()} CHANGED (stored ${stored.fingerprint}, received ${fp}) — ` +
|
|
525
|
+
'possible man-in-the-middle; run /remote-forget-key to re-trust if this is expected'
|
|
526
|
+
return false
|
|
527
|
+
}
|
|
528
|
+
if (mode === 'verify') {
|
|
529
|
+
guard.lastError = `unknown host key for ${id()} (hostKeyMode=verify) — trust it first with accept-new`
|
|
530
|
+
return false
|
|
531
|
+
}
|
|
532
|
+
kh[id()] = { algo: blobAlgorithm(key) || (key && key.algo) || 'unknown', fingerprint: fp, firstSeen: new Date().toISOString() }
|
|
533
|
+
try { mkdirSync(path.dirname(knownHostsFile()), { recursive: true }) } catch {}
|
|
534
|
+
writeFileSync(knownHostsFile(), JSON.stringify(kh, null, 2))
|
|
535
|
+
return true
|
|
536
|
+
},
|
|
537
|
+
}
|
|
538
|
+
return guard
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Whether the current target's key has been recorded/trusted before. */
|
|
542
|
+
function isHostKeyKnown(host, port) {
|
|
543
|
+
return Object.prototype.hasOwnProperty.call(loadKnownHosts(), `${host}:${port}`)
|
|
544
|
+
}
|
|
545
|
+
|
|
228
546
|
// ── SSH pool (key OR password) ──────────────────────────────────────────────
|
|
229
547
|
|
|
230
548
|
class SshPool {
|
|
@@ -232,6 +550,9 @@ class SshPool {
|
|
|
232
550
|
this.config = config
|
|
233
551
|
this.client = null
|
|
234
552
|
this.connecting = null
|
|
553
|
+
// Generational token: bumped on every target change / close so a stale
|
|
554
|
+
// in-flight connect can never hand this pool a connection to an old host.
|
|
555
|
+
this.epoch = 0
|
|
235
556
|
}
|
|
236
557
|
|
|
237
558
|
resolveKeyPath() {
|
|
@@ -257,31 +578,47 @@ class SshPool {
|
|
|
257
578
|
connect() {
|
|
258
579
|
if (this.client) return Promise.resolve(this.client)
|
|
259
580
|
if (this.connecting) return this.connecting
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
581
|
+
const epoch = this.epoch
|
|
582
|
+
const pending = this._doConnect(epoch)
|
|
583
|
+
this.connecting = pending
|
|
584
|
+
const clear = () => {
|
|
585
|
+
if (this.epoch === epoch && this.connecting === pending) this.connecting = null
|
|
586
|
+
}
|
|
587
|
+
// Keep the chain clean in both directions: the raw pending is what callers
|
|
588
|
+
// await (they still see rejections); this side just clears the slot.
|
|
589
|
+
pending.then(clear, clear)
|
|
590
|
+
return pending
|
|
264
591
|
}
|
|
265
592
|
|
|
266
|
-
_doConnect() {
|
|
593
|
+
_doConnect(epoch) {
|
|
267
594
|
return new Promise((resolve, reject) => {
|
|
268
595
|
const client = new Client()
|
|
269
596
|
let settled = false
|
|
597
|
+
// Fresh host-key guard per connect attempt so lastError is never stale.
|
|
598
|
+
const guard = createHostKeyGuard(this.config)
|
|
599
|
+
const isCurrent = () => this.epoch === epoch
|
|
270
600
|
const fail = (err) => {
|
|
271
601
|
if (settled) return
|
|
272
602
|
settled = true
|
|
273
|
-
if (this.client === client) this.client = null
|
|
274
|
-
reject(err)
|
|
603
|
+
if (isCurrent() && this.client === client) this.client = null
|
|
604
|
+
reject(guard.lastError ? new Error(guard.lastError) : err)
|
|
275
605
|
}
|
|
276
606
|
client.on('ready', () => {
|
|
277
607
|
if (settled) return
|
|
278
608
|
settled = true
|
|
609
|
+
if (!isCurrent()) {
|
|
610
|
+
// The target changed while we were connecting — never adopt this
|
|
611
|
+
// client, and make sure the caller learns the connect is void.
|
|
612
|
+
try { client.end() } catch {}
|
|
613
|
+
reject(new Error('ssh target changed during connect'))
|
|
614
|
+
return
|
|
615
|
+
}
|
|
279
616
|
this.client = client
|
|
280
617
|
resolve(client)
|
|
281
618
|
})
|
|
282
619
|
client.on('error', fail)
|
|
283
620
|
client.on('close', () => {
|
|
284
|
-
if (this.client === client) this.client = null
|
|
621
|
+
if (isCurrent() && this.client === client) this.client = null
|
|
285
622
|
fail(new Error('ssh connection closed'))
|
|
286
623
|
})
|
|
287
624
|
|
|
@@ -292,6 +629,7 @@ class SshPool {
|
|
|
292
629
|
readyTimeout: this.config.connectTimeoutMs,
|
|
293
630
|
keepaliveInterval: 15000,
|
|
294
631
|
keepaliveCountMax: 3,
|
|
632
|
+
hostVerifier: (key) => guard.verifier(key),
|
|
295
633
|
}
|
|
296
634
|
if (this.config.password) {
|
|
297
635
|
opts.password = this.config.password
|
|
@@ -341,9 +679,17 @@ class SshPool {
|
|
|
341
679
|
if (settled) return
|
|
342
680
|
exitCode = -1
|
|
343
681
|
exitSignal = 'TIMEOUT'
|
|
682
|
+
// Kill the remote command (SIGTERM) rather than just dropping the
|
|
683
|
+
// channel, so a runaway process cannot keep running and holding
|
|
684
|
+
// the SSH connection after we've given up on its output. The hard
|
|
685
|
+
// close below frees the channel even if the process ignores it.
|
|
344
686
|
try {
|
|
345
|
-
stream.
|
|
687
|
+
if (typeof stream.signal === 'function') stream.signal('SIGTERM')
|
|
346
688
|
} catch {}
|
|
689
|
+
const hardClose = setTimeout(() => {
|
|
690
|
+
try { stream.close() } catch {}
|
|
691
|
+
}, 800)
|
|
692
|
+
if (typeof hardClose.unref === 'function') hardClose.unref()
|
|
347
693
|
settle()
|
|
348
694
|
}, timeoutMs || this.config.commandTimeoutMs)
|
|
349
695
|
stream.on('close', (code, signal) => {
|
|
@@ -369,19 +715,33 @@ class SshPool {
|
|
|
369
715
|
)
|
|
370
716
|
}
|
|
371
717
|
|
|
372
|
-
/** 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. */
|
|
373
721
|
sftp() {
|
|
374
722
|
return this.connect().then(
|
|
375
723
|
(client) =>
|
|
376
724
|
new Promise((resolve, reject) => {
|
|
377
725
|
client.sftp((err, sftp) => {
|
|
378
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)
|
|
379
738
|
resolve({
|
|
380
|
-
readdir: (dir) =>
|
|
381
|
-
stat: (p) =>
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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),
|
|
385
745
|
})
|
|
386
746
|
})
|
|
387
747
|
}),
|
|
@@ -389,8 +749,17 @@ class SshPool {
|
|
|
389
749
|
}
|
|
390
750
|
|
|
391
751
|
close() {
|
|
752
|
+
// Bump the epoch so any in-flight connect is orphaned, drop the pending
|
|
753
|
+
// promise (its rejection becomes nobody's problem — swallow it), and end
|
|
754
|
+
// the live client if there is one.
|
|
755
|
+
this.epoch++
|
|
392
756
|
const client = this.client
|
|
393
757
|
this.client = null
|
|
758
|
+
const pending = this.connecting
|
|
759
|
+
this.connecting = null
|
|
760
|
+
if (pending && typeof pending.catch === 'function') {
|
|
761
|
+
try { pending.catch(() => {}) } catch {}
|
|
762
|
+
}
|
|
394
763
|
if (client) {
|
|
395
764
|
try {
|
|
396
765
|
client.end()
|
|
@@ -405,6 +774,10 @@ export async function apply(ctx, config) {
|
|
|
405
774
|
const pool = new SshPool(config)
|
|
406
775
|
ctx.effect(() => () => pool.close(), 'dsh-remote.close')
|
|
407
776
|
|
|
777
|
+
// Bring any pre-0.6 data (~/.dsh/remote-workspaces) into the DSH_HOME-based
|
|
778
|
+
// location so existing machines + mirrors keep working after the move.
|
|
779
|
+
migrateLegacyData()
|
|
780
|
+
|
|
408
781
|
// ── machine registry (multi-host) ─────────────────────────────────────────
|
|
409
782
|
const store = loadMachines()
|
|
410
783
|
const machines = store.list
|
|
@@ -433,6 +806,19 @@ export async function apply(ctx, config) {
|
|
|
433
806
|
if (cur && cur.host) applyMachine(config, cur)
|
|
434
807
|
}
|
|
435
808
|
|
|
809
|
+
/** Set the active remote workspace AND persist it on the current machine so
|
|
810
|
+
* it survives restarts (previously a tool-set workspace reset on reload). */
|
|
811
|
+
const persistWorkspace = (p) => {
|
|
812
|
+
config.workspace = p
|
|
813
|
+
if (store.currentId) {
|
|
814
|
+
const i = machineIndex(store.currentId)
|
|
815
|
+
if (i >= 0) {
|
|
816
|
+
machines[i].workspace = p
|
|
817
|
+
saveMachines(machines, store.currentId)
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
436
822
|
const run = async (cmd, opts = {}) => {
|
|
437
823
|
const res = await pool.exec(cmd, opts.timeoutMs)
|
|
438
824
|
const parts = []
|
|
@@ -445,31 +831,68 @@ export async function apply(ctx, config) {
|
|
|
445
831
|
return text
|
|
446
832
|
}
|
|
447
833
|
|
|
448
|
-
/** Structured-listing of a remote dir: name + usable type.
|
|
449
|
-
*
|
|
450
|
-
*
|
|
834
|
+
/** Structured-listing of a remote dir: name + usable type.
|
|
835
|
+
*
|
|
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). */
|
|
451
840
|
const listDirStructured = async (p, timeoutMs) => {
|
|
452
|
-
const target = p || '/'
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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))
|
|
461
853
|
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
const
|
|
466
|
-
const name = line.slice(idx + 1)
|
|
854
|
+
const items = []
|
|
855
|
+
const symIdx = []
|
|
856
|
+
for (const e of list) {
|
|
857
|
+
const name = String(e.filename)
|
|
467
858
|
if (name === '.' || name === '..' || !name) continue
|
|
468
|
-
|
|
859
|
+
const a = e.attrs || {}
|
|
860
|
+
if (a.isSymbolicLink && a.isSymbolicLink()) {
|
|
861
|
+
items.push({ type: 'symlink', name })
|
|
862
|
+
symIdx.push(items.length - 1)
|
|
863
|
+
} else if (a.isDirectory && a.isDirectory()) {
|
|
864
|
+
items.push({ type: 'dir', name })
|
|
865
|
+
} else {
|
|
866
|
+
items.push({ type: 'file', name })
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
// Resolve symlink-to-dir vs symlink-to-file (bounded, failure-tolerant).
|
|
870
|
+
if (symIdx.length) {
|
|
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
|
+
}))
|
|
469
878
|
}
|
|
470
879
|
return { path: target, items }
|
|
471
880
|
}
|
|
472
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
|
+
|
|
473
896
|
// ── remote workspace state ────────────────────────────────────────────────
|
|
474
897
|
|
|
475
898
|
const wsPath = () => (config.workspace || '').trim()
|
|
@@ -482,6 +905,8 @@ export async function apply(ctx, config) {
|
|
|
482
905
|
localMirror: wsPath() ? mirrorDirFor(wsPath(), config.host, config.username, config.port) : '',
|
|
483
906
|
currentId: store.currentId || null,
|
|
484
907
|
machines: machines.map(sanitizeMachine),
|
|
908
|
+
hostKeyMode: config.hostKeyMode === 'verify' || config.hostKeyMode === 'off' ? config.hostKeyMode : 'accept-new',
|
|
909
|
+
hostKeyKnown: config.host ? isHostKeyKnown(config.host, config.port) : false,
|
|
485
910
|
})
|
|
486
911
|
|
|
487
912
|
// ── tools ─────────────────────────────────────────────────────────────────
|
|
@@ -495,7 +920,7 @@ export async function apply(ctx, config) {
|
|
|
495
920
|
defineTool({
|
|
496
921
|
name: 'rw_info',
|
|
497
922
|
description:
|
|
498
|
-
'Show the remote environment: host/user/port, connection health, and the current remote workspace path. Call this first to orient, or when
|
|
923
|
+
'Show the remote environment: host/user/port, connection health, and the current remote workspace path. Call this first to orient, or when an rw_* call fails to check connectivity.',
|
|
499
924
|
parameters: {},
|
|
500
925
|
output: {
|
|
501
926
|
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
@@ -506,12 +931,14 @@ export async function apply(ctx, config) {
|
|
|
506
931
|
const lines = [
|
|
507
932
|
`Remote host: ${s.username || '<user>'}@${s.host || '<host>'}:${s.port}`,
|
|
508
933
|
`Current remote workspace: ${s.workspace || '(none — call rw_pick_workspace to set one)'}`,
|
|
934
|
+
`Local mirror: ${s.localMirror || '(none)'}`,
|
|
509
935
|
`Connected: ${s.connected ? 'yes' : 'no'}`,
|
|
510
936
|
'',
|
|
511
937
|
]
|
|
512
938
|
if (s.host && s.workspace) {
|
|
513
939
|
try {
|
|
514
|
-
|
|
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))
|
|
515
942
|
if (res.signal === 'TIMEOUT') lines.push('Ping: timeout')
|
|
516
943
|
else if (res.code === 0) lines.push('Ping: OK — ' + res.stdout.replace(/\s+/g, ' ').trim())
|
|
517
944
|
else lines.push('Ping: FAILED — ' + (res.stderr || res.stdout || `exit ${res.code}`).trim())
|
|
@@ -551,9 +978,9 @@ export async function apply(ctx, config) {
|
|
|
551
978
|
privateKeyPath: args.privateKeyPath || undefined,
|
|
552
979
|
})
|
|
553
980
|
try {
|
|
554
|
-
const res = await pool.exec('echo ok
|
|
981
|
+
const res = await pool.exec('echo ok', 8000)
|
|
555
982
|
if (res.code !== 0 && !res.stdout) return { text: 'connect failed: ' + (res.stderr || 'exit ' + res.code) }
|
|
556
|
-
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>).` }
|
|
557
984
|
} catch (err) {
|
|
558
985
|
throw err
|
|
559
986
|
}
|
|
@@ -574,13 +1001,12 @@ export async function apply(ctx, config) {
|
|
|
574
1001
|
async execute(args) {
|
|
575
1002
|
const p = normalizeRemotePath(String(args.path || ''))
|
|
576
1003
|
if (!p || p === '/') throw new Error('rw_pick_workspace: path must be an absolute directory')
|
|
577
|
-
const
|
|
578
|
-
const ok = res.stdout.trim() === 'DIR'
|
|
1004
|
+
const ok = await isRemoteDir(p)
|
|
579
1005
|
if (!ok) return { text: `not a directory (or missing) on ${p}` }
|
|
580
|
-
|
|
1006
|
+
persistWorkspace(p)
|
|
581
1007
|
const local = ensureMirror(p, config.host, config.username, config.port)
|
|
582
1008
|
return {
|
|
583
|
-
text: `Remote workspace set to ${p} on ${config.username}@${config.host}.\nLocal mirror (native workspace path): ${local}\n\nRun rw_sync to download its files into the local mirror.`,
|
|
1009
|
+
text: `Remote workspace set to ${p} on ${config.username}@${config.host} (saved for this machine).\nLocal mirror (native workspace path): ${local}\n\nRun rw_sync to download its files into the local mirror.`,
|
|
584
1010
|
}
|
|
585
1011
|
},
|
|
586
1012
|
}),
|
|
@@ -610,8 +1036,11 @@ export async function apply(ctx, config) {
|
|
|
610
1036
|
} catch (err) {
|
|
611
1037
|
return { text: 'sftp unavailable: ' + ((err && err.message) || err) }
|
|
612
1038
|
}
|
|
613
|
-
const
|
|
614
|
-
|
|
1039
|
+
const r = await syncTree(sftp, p, local, depth, maxFiles, config.maxFileBytes)
|
|
1040
|
+
let text = `Downloaded ${r.files} file(s) from ${p} → ${local}${r.files >= maxFiles ? ' (hit download cap)' : ''}.`
|
|
1041
|
+
if (r.skippedUnchanged) text += ` ${r.skippedUnchanged} unchanged.`
|
|
1042
|
+
if (r.skippedLarge) text += ` ${r.skippedLarge} too large (over ${config.maxFileBytes} bytes).`
|
|
1043
|
+
return { text }
|
|
615
1044
|
},
|
|
616
1045
|
}),
|
|
617
1046
|
|
|
@@ -638,8 +1067,11 @@ export async function apply(ctx, config) {
|
|
|
638
1067
|
} catch (err) {
|
|
639
1068
|
return { text: 'sftp unavailable: ' + ((err && err.message) || err) }
|
|
640
1069
|
}
|
|
641
|
-
const { files } = await pushTree(sftp, local, p,
|
|
642
|
-
|
|
1070
|
+
const { files, skippedUnchanged, skippedLarge, pushed } = await pushTree(sftp, local, p, maxFiles, config.maxFileBytes)
|
|
1071
|
+
let text = `Uploaded ${files} file(s) from ${local} → ${p}.`
|
|
1072
|
+
if (skippedUnchanged) text += ` ${skippedUnchanged} unchanged.`
|
|
1073
|
+
if (skippedLarge) text += ` ${skippedLarge} too large (over ${config.maxFileBytes} bytes).`
|
|
1074
|
+
return { text }
|
|
643
1075
|
},
|
|
644
1076
|
}),
|
|
645
1077
|
|
|
@@ -657,7 +1089,24 @@ export async function apply(ctx, config) {
|
|
|
657
1089
|
async execute(args) {
|
|
658
1090
|
const p = args.path ? normalizeRemotePath(String(args.path)) : wsPath()
|
|
659
1091
|
if (!p) throw new Error('rw_list_dir: no path and no remote workspace set')
|
|
660
|
-
|
|
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)' }
|
|
661
1110
|
},
|
|
662
1111
|
}),
|
|
663
1112
|
|
|
@@ -682,8 +1131,19 @@ export async function apply(ctx, config) {
|
|
|
682
1131
|
let from = Math.max(Number(args.startLine) || 1, 1)
|
|
683
1132
|
let to = Number(args.endLine) || 0
|
|
684
1133
|
if (!to || to - from + 1 > maxLines) to = from + maxLines - 1
|
|
685
|
-
|
|
686
|
-
|
|
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+$/, '')
|
|
687
1147
|
let text = numbered === '' ? '(empty or out of range)' : numbered
|
|
688
1148
|
if (!args.endLine) text += '\n(shown up to ' + maxLines + ' lines; use startLine/endLine to page)'
|
|
689
1149
|
return { text }
|
|
@@ -693,9 +1153,10 @@ export async function apply(ctx, config) {
|
|
|
693
1153
|
defineTool({
|
|
694
1154
|
name: 'rw_exec',
|
|
695
1155
|
description:
|
|
696
|
-
'Run a shell command on the remote host. Use for anything that is not reading a file (build, test, grep, etc). Output is capped.',
|
|
1156
|
+
'Run a shell command on the remote host. Use for anything that is not reading a file (build, test, grep, etc). Output is capped. Runs in the current remote workspace by default; pass cwd to run elsewhere.',
|
|
697
1157
|
parameters: {
|
|
698
1158
|
command: { type: 'string', required: true, description: 'Shell command (run on the remote host)' },
|
|
1159
|
+
cwd: { type: 'string', description: 'Working directory for the command (default: the current remote workspace)' },
|
|
699
1160
|
},
|
|
700
1161
|
output: {
|
|
701
1162
|
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
@@ -704,7 +1165,12 @@ export async function apply(ctx, config) {
|
|
|
704
1165
|
async execute(args) {
|
|
705
1166
|
const cmd = String(args.command || '')
|
|
706
1167
|
if (!cmd) throw new Error('rw_exec: command is required')
|
|
707
|
-
|
|
1168
|
+
const ws = wsPath()
|
|
1169
|
+
const cwd = args.cwd ? normalizeRemotePath(String(args.cwd)) : (ws || '')
|
|
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
|
|
1173
|
+
return { text: await run(full, { timeoutMs: config.commandTimeoutMs }) }
|
|
708
1174
|
},
|
|
709
1175
|
}),
|
|
710
1176
|
|
|
@@ -733,15 +1199,9 @@ export async function apply(ctx, config) {
|
|
|
733
1199
|
}
|
|
734
1200
|
const mkdir = args.mkdir !== false
|
|
735
1201
|
if (mkdir) {
|
|
736
|
-
const parent = remoteDirname(p)
|
|
737
1202
|
// create each level from the root so mkdir -p semantics survive even
|
|
738
1203
|
// when intermediate privileged-parent dirs don't allow create (best effort)
|
|
739
|
-
|
|
740
|
-
let cur = ''
|
|
741
|
-
for (const s of segs) {
|
|
742
|
-
cur += '/' + s
|
|
743
|
-
try { await sftp.mkdir(cur) } catch { /* exists or no perms */ }
|
|
744
|
-
}
|
|
1204
|
+
await mkdirRemoteDirs(sftp, p)
|
|
745
1205
|
}
|
|
746
1206
|
const buf = Buffer.from(content, 'utf8')
|
|
747
1207
|
await sftp.writeFile(p, buf)
|
|
@@ -750,6 +1210,110 @@ export async function apply(ctx, config) {
|
|
|
750
1210
|
},
|
|
751
1211
|
}),
|
|
752
1212
|
|
|
1213
|
+
defineTool({
|
|
1214
|
+
name: 'rw_search',
|
|
1215
|
+
description:
|
|
1216
|
+
'Search remote files for a pattern (grep, recursive, case-insensitive by default). Returns matching file:line rows; output is capped. Use instead of rw_exec grep when you want a bounded, portable search.',
|
|
1217
|
+
parameters: {
|
|
1218
|
+
pattern: { type: 'string', required: true, description: 'Pattern to search for (extended regex)' },
|
|
1219
|
+
path: { type: 'string', description: 'Directory to search (default: current remote workspace)' },
|
|
1220
|
+
glob: { type: 'string', description: 'Only files whose name matches this glob, e.g. *.ts (optional)' },
|
|
1221
|
+
ignoreCase: { type: 'boolean', description: 'Case-insensitive search (default true)' },
|
|
1222
|
+
},
|
|
1223
|
+
output: {
|
|
1224
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
1225
|
+
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
1226
|
+
},
|
|
1227
|
+
async execute(args) {
|
|
1228
|
+
const pattern = String(args.pattern || '')
|
|
1229
|
+
if (!pattern) throw new Error('rw_search: pattern is required')
|
|
1230
|
+
const ws = wsPath()
|
|
1231
|
+
const dir = args.path ? normalizeRemotePath(String(args.path)) : (ws || '')
|
|
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
|
+
}
|
|
1238
|
+
const flags = 'r' + (args.ignoreCase === false ? '' : 'i') + 'nE'
|
|
1239
|
+
const name = args.glob ? ` -name ${shq(String(args.glob))}` : ''
|
|
1240
|
+
// find … -exec grep {} + is POSIX (BSD + GNU): `grep -RInE` alone would
|
|
1241
|
+
// differ on BSD, and GNU-only --include is avoided in favor of find -name.
|
|
1242
|
+
const cmd = `find ${shq(dir)} -type f${name} -exec grep -${flags} -H -- ${shq(pattern)} {} + 2>/dev/null | head -n 2000`
|
|
1243
|
+
return { text: await run(cmd, { timeoutMs: Math.max(config.commandTimeoutMs, 30000) }) }
|
|
1244
|
+
},
|
|
1245
|
+
}),
|
|
1246
|
+
|
|
1247
|
+
defineTool({
|
|
1248
|
+
name: 'rw_download',
|
|
1249
|
+
description:
|
|
1250
|
+
'Download a single remote file over SFTP into the local mirror of the current workspace (or to an explicit local path). Use when you need the actual file content locally, not just its text.',
|
|
1251
|
+
parameters: {
|
|
1252
|
+
path: { type: 'string', required: true, description: 'Absolute remote file path' },
|
|
1253
|
+
localPath: { type: 'string', description: 'Local destination (default: the workspace mirror, preserving the relative path)' },
|
|
1254
|
+
},
|
|
1255
|
+
output: {
|
|
1256
|
+
schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, bytes: { type: 'integer' }, text: { type: 'string' } } },
|
|
1257
|
+
render: (_a, a) => [{ type: 'text', text: a.text || (a.ok ? 'downloaded' : 'failed') }],
|
|
1258
|
+
},
|
|
1259
|
+
async execute(args) {
|
|
1260
|
+
const p = normalizeRemotePath(String(args.path || ''))
|
|
1261
|
+
if (!p || p === '/') throw new Error('rw_download: a remote file path is required')
|
|
1262
|
+
let sftp
|
|
1263
|
+
try {
|
|
1264
|
+
sftp = await pool.sftp()
|
|
1265
|
+
} catch (err) {
|
|
1266
|
+
throw new Error('rw_download: sftp unavailable: ' + ((err && err.message) || err))
|
|
1267
|
+
}
|
|
1268
|
+
let local
|
|
1269
|
+
if (args.localPath) {
|
|
1270
|
+
local = path.resolve(String(args.localPath))
|
|
1271
|
+
} else {
|
|
1272
|
+
const ws = wsPath()
|
|
1273
|
+
if (!ws) throw new Error('rw_download: no remote workspace set — pass localPath explicitly')
|
|
1274
|
+
const base = mirrorDirFor(ws, config.host, config.username, config.port)
|
|
1275
|
+
const rel = p.startsWith(ws) ? p.slice(ws.length).replace(/^\/+/, '') : p.slice(1)
|
|
1276
|
+
local = path.join(base, rel)
|
|
1277
|
+
}
|
|
1278
|
+
mkdirSync(path.dirname(local), { recursive: true })
|
|
1279
|
+
const buf = await sftp.readFile(p)
|
|
1280
|
+
writeFileSync(local, buf)
|
|
1281
|
+
const bytes = buf.byteLength
|
|
1282
|
+
return { ok: true, bytes, text: `downloaded ${bytes} bytes from ${p} → ${local}` }
|
|
1283
|
+
},
|
|
1284
|
+
}),
|
|
1285
|
+
|
|
1286
|
+
defineTool({
|
|
1287
|
+
name: 'rw_upload',
|
|
1288
|
+
description:
|
|
1289
|
+
'Upload a local file over SFTP to a path on the remote host (creating parent directories if needed). Use to push a local file directly, without a full rw_push of the whole mirror.',
|
|
1290
|
+
parameters: {
|
|
1291
|
+
localPath: { type: 'string', required: true, description: 'Absolute local file path' },
|
|
1292
|
+
path: { type: 'string', required: true, description: 'Absolute remote destination path' },
|
|
1293
|
+
},
|
|
1294
|
+
output: {
|
|
1295
|
+
schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, bytes: { type: 'integer' }, text: { type: 'string' } } },
|
|
1296
|
+
render: (_a, a) => [{ type: 'text', text: a.text || (a.ok ? 'uploaded' : 'failed') }],
|
|
1297
|
+
},
|
|
1298
|
+
async execute(args) {
|
|
1299
|
+
const rp = normalizeRemotePath(String(args.path || ''))
|
|
1300
|
+
const lp = String(args.localPath || '')
|
|
1301
|
+
if (!rp || rp === '/' || !lp) throw new Error('rw_upload: both localPath and a remote path are required')
|
|
1302
|
+
if (!existsSync(lp)) throw new Error(`rw_upload: local file not found: ${lp}`)
|
|
1303
|
+
let sftp
|
|
1304
|
+
try {
|
|
1305
|
+
sftp = await pool.sftp()
|
|
1306
|
+
} catch (err) {
|
|
1307
|
+
throw new Error('rw_upload: sftp unavailable: ' + ((err && err.message) || err))
|
|
1308
|
+
}
|
|
1309
|
+
await mkdirRemoteDirs(sftp, rp)
|
|
1310
|
+
const buf = readFileSync(lp)
|
|
1311
|
+
await sftp.writeFile(rp, buf)
|
|
1312
|
+
const bytes = buf.byteLength
|
|
1313
|
+
return { ok: true, bytes, text: `uploaded ${bytes} bytes from ${lp} → ${rp}` }
|
|
1314
|
+
},
|
|
1315
|
+
}),
|
|
1316
|
+
|
|
753
1317
|
defineTool({
|
|
754
1318
|
name: 'rw_disconnect',
|
|
755
1319
|
description:
|
|
@@ -780,7 +1344,7 @@ export async function apply(ctx, config) {
|
|
|
780
1344
|
return (
|
|
781
1345
|
'## Remote workspace\n' +
|
|
782
1346
|
`Current remote workspace: ${config.username}@${config.host}:${w}\n` +
|
|
783
|
-
'Use the
|
|
1347
|
+
'Use the rw_* tools (rw_list_dir / rw_read_file / rw_write_file / rw_exec / rw_search) to inspect and act on files on the remote host. Treat this directory as the working root for this task.'
|
|
784
1348
|
)
|
|
785
1349
|
},
|
|
786
1350
|
})
|
|
@@ -798,11 +1362,21 @@ export async function apply(ctx, config) {
|
|
|
798
1362
|
text:
|
|
799
1363
|
`Remote host: ${s.username}@${s.host || '<none>'} (connected: ${s.connected})\n` +
|
|
800
1364
|
`Remote workspace: ${s.workspace || '(none)'}\n` +
|
|
801
|
-
|
|
1365
|
+
`Host key: ${s.hostKeyKnown ? 'trusted ✓' : 'not yet trusted'} (mode=${s.hostKeyMode})\n` +
|
|
1366
|
+
(s.hostKeyKnown ? ` — if the key changed / was mistrusted, run /remote-forget-key\n` : '') +
|
|
1367
|
+
`\nUse tools: rw_list_dir / rw_read_file / rw_exec / rw_search.` +
|
|
802
1368
|
(s.workspace ? `\nCurrently working in ${s.workspace}.` : ''),
|
|
803
1369
|
}
|
|
804
1370
|
},
|
|
805
1371
|
})
|
|
1372
|
+
commands.register({
|
|
1373
|
+
name: 'remote-forget-key',
|
|
1374
|
+
description: 'Drop the trusted host-key record for the current machine so the next connect re-records it.',
|
|
1375
|
+
handler: () => {
|
|
1376
|
+
createHostKeyGuard(config).forgetHost()
|
|
1377
|
+
return { kind: 'success', text: `forgot host key for ${config.host || '<none>'}:${config.port} — the next connect will re-record it.` }
|
|
1378
|
+
},
|
|
1379
|
+
})
|
|
806
1380
|
}
|
|
807
1381
|
|
|
808
1382
|
// ── JSON endpoints for settings UI ─────────────────────────────────────────
|
|
@@ -814,13 +1388,63 @@ export async function apply(ctx, config) {
|
|
|
814
1388
|
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
815
1389
|
res.end(JSON.stringify(body))
|
|
816
1390
|
}
|
|
1391
|
+
// Cap request bodies (1 MiB) so a malformed client cannot force us to buffer
|
|
1392
|
+
// unbounded memory on a local-only route.
|
|
1393
|
+
const MAX_BODY_BYTES = 1024 * 1024
|
|
817
1394
|
const readBody = (req) =>
|
|
818
1395
|
new Promise((resolve) => {
|
|
819
1396
|
const chunks = []
|
|
820
|
-
|
|
1397
|
+
let total = 0
|
|
1398
|
+
req.on('data', (c) => {
|
|
1399
|
+
total += c.length
|
|
1400
|
+
if (total > MAX_BODY_BYTES) {
|
|
1401
|
+
req.removeAllListeners('data')
|
|
1402
|
+
resolve('{}')
|
|
1403
|
+
return
|
|
1404
|
+
}
|
|
1405
|
+
chunks.push(c)
|
|
1406
|
+
})
|
|
821
1407
|
req.on('end', () => resolve(chunks.join('')))
|
|
822
1408
|
})
|
|
823
1409
|
|
|
1410
|
+
// ── 本机目录选择器:DSH directoryPicker 服务优先,缺位/非原生则自持兜底 ──
|
|
1411
|
+
// 背景:web-app 的 `directory-picker` 行(-auto)在桌面启动路径里不物化成
|
|
1412
|
+
// loader 条目,`ctx.get('directoryPicker')` 实测为 null(沙箱 + 产品同路径)。
|
|
1413
|
+
// 因此插件自带原生选择器:macOS osascript / Linux zenity→kdialog,与 DSH
|
|
1414
|
+
// native 后端同一套调用约定(取消 → { cancelled },成功 → { path })。
|
|
1415
|
+
const PICK_TIMEOUT_MS = 120000
|
|
1416
|
+
const runPick = (bin, args) =>
|
|
1417
|
+
new Promise((resolve, reject) => {
|
|
1418
|
+
execFile(bin, args, { timeout: PICK_TIMEOUT_MS, maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
|
|
1419
|
+
if (err) {
|
|
1420
|
+
const code = err.code
|
|
1421
|
+
const msg = String(stderr || '')
|
|
1422
|
+
// 用户在对话框里点了取消:osascript 退出码 1 + "User canceled"/-128
|
|
1423
|
+
if (code === 1 && /(?:user canceled|-128)/i.test(msg)) return resolve({ cancelled: true })
|
|
1424
|
+
if (err.signal || err.killed) return reject(new Error('目录选择已超时,请重试或直接在输入框填本地路径'))
|
|
1425
|
+
if (code === 'ENOENT') return reject(Object.assign(new Error('未找到目录选择器程序 ' + bin), { code }))
|
|
1426
|
+
return reject(new Error((msg.trim() || (err && err.message) || '无法打开系统文件夹选择器').split('\n')[0]))
|
|
1427
|
+
}
|
|
1428
|
+
const p = String(stdout || '').replace(/[\r\n]+$/, '').trim()
|
|
1429
|
+
resolve(p ? { path: p } : { cancelled: true })
|
|
1430
|
+
})
|
|
1431
|
+
})
|
|
1432
|
+
const pickLocalNative = async () => {
|
|
1433
|
+
const platform = process.platform
|
|
1434
|
+
if (platform === 'darwin') {
|
|
1435
|
+
return runPick('osascript', ['-e', 'set selectedFolder to choose folder with prompt "Select Workspace Directory"', '-e', 'POSIX path of selectedFolder'])
|
|
1436
|
+
}
|
|
1437
|
+
if (platform === 'linux') {
|
|
1438
|
+
try {
|
|
1439
|
+
return await runPick('zenity', ['--file-selection', '--directory', '--title=Select Workspace Directory'])
|
|
1440
|
+
} catch (err) {
|
|
1441
|
+
if (err && err.code === 'ENOENT') return runPick('kdialog', ['--getexistingdirectory', '.', '--title', 'Select Workspace Directory'])
|
|
1442
|
+
throw err
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
throw new Error('当前系统不支持自动打开目录选择器,请在输入框直接填本地路径')
|
|
1446
|
+
}
|
|
1447
|
+
|
|
824
1448
|
const routes = [
|
|
825
1449
|
{
|
|
826
1450
|
kind: 'exact',
|
|
@@ -857,8 +1481,10 @@ export async function apply(ctx, config) {
|
|
|
857
1481
|
path: '/dsh-remote/ls',
|
|
858
1482
|
handler: async (req, res) => {
|
|
859
1483
|
try {
|
|
860
|
-
|
|
861
|
-
|
|
1484
|
+
// Proper URL parsing so `+` in a path decodes to a literal space
|
|
1485
|
+
// (the old regex missed it).
|
|
1486
|
+
const q = new URL(req.url, 'http://localhost').searchParams
|
|
1487
|
+
const p = q.get('path') ? decodeURIComponent(q.get('path')) : wsPath()
|
|
862
1488
|
const out = await listDirStructured(p || '/')
|
|
863
1489
|
return sendJson(res, 200, { path: p, items: out.items })
|
|
864
1490
|
} catch (err) {
|
|
@@ -875,9 +1501,9 @@ export async function apply(ctx, config) {
|
|
|
875
1501
|
const payload = JSON.parse((await readBody(req)) || '{}')
|
|
876
1502
|
const p = normalizeRemotePath(String(payload.path || ''))
|
|
877
1503
|
if (!p || p === '/') return sendJson(res, 400, { error: 'path must be an absolute directory' })
|
|
878
|
-
const
|
|
879
|
-
if (
|
|
880
|
-
|
|
1504
|
+
const okDir = await isRemoteDir(p)
|
|
1505
|
+
if (!okDir) return sendJson(res, 400, { ok: false, error: `not a directory: ${p}` })
|
|
1506
|
+
persistWorkspace(p)
|
|
881
1507
|
const local = ensureMirror(p, config.host, config.username, config.port)
|
|
882
1508
|
return sendJson(res, 200, { ok: true, workspace: p, localMirror: local, ...status() })
|
|
883
1509
|
} catch (err) {
|
|
@@ -895,13 +1521,12 @@ export async function apply(ctx, config) {
|
|
|
895
1521
|
const p = normalizeRemotePath(String(payload.path || ''))
|
|
896
1522
|
if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path must be an absolute directory' })
|
|
897
1523
|
if (!config.host) return sendJson(res, 400, { ok: false, error: 'no remote host configured/connected — connect first' })
|
|
898
|
-
//
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
}
|
|
1524
|
+
// Always verify over SSH (connecting if needed) so an unconnected or
|
|
1525
|
+
// wrong host can never silently mint a mirror for a bogus path.
|
|
1526
|
+
const okDir = await isRemoteDir(p)
|
|
1527
|
+
if (!okDir) return sendJson(res, 400, { ok: false, error: `not a directory (or unreachable): ${p}` })
|
|
903
1528
|
const local = ensureMirror(p, config.host, config.username, config.port)
|
|
904
|
-
|
|
1529
|
+
persistWorkspace(p)
|
|
905
1530
|
return sendJson(res, 200, { ok: true, path: p, localMirror: local, ...status() })
|
|
906
1531
|
} catch (err) {
|
|
907
1532
|
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
@@ -914,16 +1539,38 @@ export async function apply(ctx, config) {
|
|
|
914
1539
|
handler: async (req, res) => {
|
|
915
1540
|
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
916
1541
|
try {
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
1542
|
+
// 1) 优先用 DSH directoryPicker 服务(native 后端)。必须用
|
|
1543
|
+
// ctx.get() 按名取:属性形式 ctx.directoryPicker 会抛 cordis
|
|
1544
|
+
// "cannot get property ... without inject"(该名字未在本插件
|
|
1545
|
+
// inject 里声明),ctx.get() 无此要求、缺失返回 null。
|
|
1546
|
+
// 2) 服务缺位或非 native(实测 web-app 的 -auto 行在桌面启动路径
|
|
1547
|
+
// 不物化后端)→ 自持 osascript/zenity/kdialog 兜底。
|
|
1548
|
+
let outcome = null
|
|
1549
|
+
let via = 'service'
|
|
1550
|
+
const dp = (ctx && typeof ctx.get === 'function') ? (ctx.get('directoryPicker') || null) : null
|
|
1551
|
+
if (dp && typeof dp.capability === 'function') {
|
|
1552
|
+
try {
|
|
1553
|
+
const cap = await Promise.resolve(dp.capability())
|
|
1554
|
+
if (cap && cap.kind === 'native' && typeof cap.pick === 'function') {
|
|
1555
|
+
const pickAbort = new AbortController()
|
|
1556
|
+
const p = await Promise.resolve(cap.pick(pickAbort.signal || null))
|
|
1557
|
+
pickAbort.abort()
|
|
1558
|
+
outcome = (typeof p === 'string' && p) ? { path: p } : { cancelled: true }
|
|
1559
|
+
}
|
|
1560
|
+
} catch (err) {
|
|
1561
|
+
outcome = null // DSH 服务出错 → 落到自持兜底
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
if (!outcome) {
|
|
1565
|
+
via = 'own'
|
|
1566
|
+
try {
|
|
1567
|
+
outcome = await pickLocalNative()
|
|
1568
|
+
} catch (err) {
|
|
1569
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) + ' — 可直接在输入框填本地路径' })
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
if (outcome.cancelled) return sendJson(res, 200, { ok: true, cancelled: true, via })
|
|
1573
|
+
return sendJson(res, 200, { ok: true, path: outcome.path, via })
|
|
927
1574
|
} catch (err) {
|
|
928
1575
|
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
929
1576
|
}
|
|
@@ -1020,6 +1667,18 @@ export async function apply(ctx, config) {
|
|
|
1020
1667
|
}
|
|
1021
1668
|
},
|
|
1022
1669
|
},
|
|
1670
|
+
{
|
|
1671
|
+
kind: 'exact',
|
|
1672
|
+
path: '/dsh-remote/forget-key',
|
|
1673
|
+
handler: async (req, res) => {
|
|
1674
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
1675
|
+
// Drop the trusted host-key record for the CURRENT target so the next
|
|
1676
|
+
// connect re-records it (use after an intentional host reinstall or a
|
|
1677
|
+
// false MITM alarm).
|
|
1678
|
+
createHostKeyGuard(config).forgetHost()
|
|
1679
|
+
return sendJson(res, 200, { ok: true, ...status() })
|
|
1680
|
+
},
|
|
1681
|
+
},
|
|
1023
1682
|
]
|
|
1024
1683
|
|
|
1025
1684
|
const disposers = routes.map((r) => webServer.register(r))
|