dsh-remote 0.7.5 → 0.8.2
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/README.md +47 -16
- package/lib/client.js +356 -162
- package/lib/credential.js +107 -0
- package/lib/errors.js +78 -0
- package/lib/forwards.js +219 -0
- package/lib/hostkey.js +48 -0
- package/lib/ignore.js +123 -0
- package/lib/index.js +1400 -1004
- package/lib/paths.js +169 -0
- package/lib/search.js +127 -0
- package/lib/sshconfig.js +57 -0
- package/lib/sync.js +270 -0
- package/lib/tasks.js +95 -0
- package/lib/update.js +196 -0
- package/package.json +7 -3
package/lib/index.js
CHANGED
|
@@ -1,29 +1,46 @@
|
|
|
1
1
|
// dsh-remote — remote-work assistant for DeepSeek Harness.
|
|
2
2
|
//
|
|
3
3
|
// Host half. Turns "give me a remote host + login" into a usable REMOTE WORKSPACE:
|
|
4
|
-
// • one persistent SSH/SFTP pool per configured remote (password OR private key
|
|
4
|
+
// • one persistent SSH/SFTP pool per configured remote (password OR private key,
|
|
5
|
+
// SSH agent, keyboard-interactive, proxy jump),
|
|
5
6
|
// • a "current remote workspace" — a remote directory the agent treats as the
|
|
6
7
|
// active project root (user@host:/path) — injected into every system prompt,
|
|
7
|
-
// • model tools `
|
|
8
|
-
//
|
|
9
|
-
// • JSON endpoints the client settings page
|
|
10
|
-
//
|
|
8
|
+
// • model tools `rw_*` (info/connect/workspace/list/read/write/edit/append/mkdir/
|
|
9
|
+
// remove/move/stat/exec/search/download/upload/sync/push/forward/disconnect),
|
|
10
|
+
// • JSON endpoints the client settings page + sidebar use over the harness
|
|
11
|
+
// `webServer` (machines / ls / read / write / fs / forwards / task / audit /
|
|
12
|
+
// ssh-config / local-pick / …),
|
|
13
|
+
// • local mirror of the remote workspace (three-way conflict-aware SFTP sync),
|
|
14
|
+
// • optional OS-keychain password storage, command audit log, TOFU host keys.
|
|
11
15
|
//
|
|
12
|
-
// The engine (path guard + shell quoting + ssh pool + exec)
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// presented as such to the model and UI, not a replacement of the local one.
|
|
16
|
+
// The engine (path guard + shell quoting + ssh pool + exec) keeps the proven
|
|
17
|
+
// foundation; `ctx.fs` / the local workspace registry stay untouched — this is a
|
|
18
|
+
// REMOTE workspace presented as such to the model and UI.
|
|
16
19
|
//
|
|
17
20
|
// Plugin Config MUST be a schemastery schema (zod rejects the undefined row config).
|
|
18
21
|
import z from '@deepseek-ai/schemastery'
|
|
19
22
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
20
23
|
import ssh2 from 'ssh2'
|
|
21
|
-
import { createHash } from 'node:crypto'
|
|
22
24
|
import { execFile } from 'node:child_process'
|
|
23
|
-
import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync, renameSync, copyFileSync, utimesSync,
|
|
25
|
+
import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync, renameSync, copyFileSync, utimesSync, appendFileSync, unlinkSync, watch } from 'node:fs'
|
|
24
26
|
import { homedir } from 'node:os'
|
|
25
27
|
import path from 'node:path'
|
|
26
|
-
import
|
|
28
|
+
import iconv from 'iconv-lite'
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
shq, normalizeRemotePath, joinRemotePath, remoteDirname, mkdirRemoteDirs,
|
|
32
|
+
toSftpPath, remotePathBase, truncate, shortHash, relPathUnder,
|
|
33
|
+
} from './paths.js'
|
|
34
|
+
import { blobAlgorithm, keyFingerprint } from './hostkey.js'
|
|
35
|
+
import { compileIgnore, DEFAULT_IGNORE } from './ignore.js'
|
|
36
|
+
import { friendlyMessage } from './errors.js'
|
|
37
|
+
import { importableEntries, sshConfigPath, readSshConfigText } from './sshconfig.js'
|
|
38
|
+
import { saveSecret, getSecret, deleteSecret, platformBackend } from './credential.js'
|
|
39
|
+
import { syncTree, pushTree, loadSyncState, saveSyncState, pushOneFile } from './sync.js'
|
|
40
|
+
import { searchTree } from './search.js'
|
|
41
|
+
import { TaskManager } from './tasks.js'
|
|
42
|
+
import { ForwardManager } from './forwards.js'
|
|
43
|
+
import { selfDir, readVersion, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode } from './update.js'
|
|
27
44
|
|
|
28
45
|
const { Client } = ssh2
|
|
29
46
|
|
|
@@ -31,8 +48,7 @@ export const name = 'dsh-remote'
|
|
|
31
48
|
|
|
32
49
|
// tools + systemPrompt + webServer are required. webServer is INJECTED (not just
|
|
33
50
|
// lazily read) so this plugin activates only after the web server is up — otherwise
|
|
34
|
-
// apply() runs ahead of webServer and the /dsh-remote/* JSON routes never register
|
|
35
|
-
// (silently: the boot shows no error, but status/ls/workspace return the SPA fallback).
|
|
51
|
+
// apply() runs ahead of webServer and the /dsh-remote/* JSON routes never register.
|
|
36
52
|
export const inject = ['tools', 'systemPrompt', 'webServer']
|
|
37
53
|
|
|
38
54
|
export const Config = z.object({
|
|
@@ -63,161 +79,39 @@ export const Config = z.object({
|
|
|
63
79
|
* accept-new); `verify` also rejects hosts never seen before; `off` skips
|
|
64
80
|
* verification entirely (MITM-unsafe, not recommended). */
|
|
65
81
|
hostKeyMode: z.string().default('accept-new'),
|
|
82
|
+
/** Use the OpenSSH agent (SSH_AUTH_SOCK) when no password/key is configured. */
|
|
83
|
+
useAgent: z.boolean().default(false),
|
|
84
|
+
/** Allow keyboard-interactive auth (OTP/MFA chains) using the configured password. */
|
|
85
|
+
keyboardInteractive: z.boolean().default(false),
|
|
86
|
+
/** Jump host / bastion: connect through this machine first. All fields have
|
|
87
|
+
* defaults so this works on schemastery versions without `.optional()`; an
|
|
88
|
+
* empty `host` means "no jump host". */
|
|
89
|
+
proxy: z.object({
|
|
90
|
+
host: z.string().default(''),
|
|
91
|
+
port: z.number().step(1).min(1).max(65535).default(22),
|
|
92
|
+
username: z.string().default(''),
|
|
93
|
+
password: z.string().default(''),
|
|
94
|
+
privateKeyPath: z.string().default(''),
|
|
95
|
+
}),
|
|
96
|
+
/** Auto-push edited mirror files back to the remote (watcher, debounced). Default off. */
|
|
97
|
+
autoPush: z.boolean().default(false),
|
|
98
|
+
/** Append executed commands to the audit log under the harness home. */
|
|
99
|
+
auditLog: z.boolean().default(true),
|
|
100
|
+
/** Text encoding for remote file reads/writes (utf-8 default; gbk etc.). */
|
|
101
|
+
encoding: z.string().default('utf-8'),
|
|
66
102
|
/** Update mode: `manual` (default) only checks when asked; `auto` checks on
|
|
67
103
|
* load and periodically, applying a newer npm release automatically;
|
|
68
|
-
* `off` disables version checks entirely.
|
|
69
|
-
|
|
104
|
+
* `off` disables version checks entirely. (schemastery 3.18 has no .enum —
|
|
105
|
+
* keep string and validate in code.) */
|
|
106
|
+
updateMode: z.string().default('manual'),
|
|
70
107
|
/** How often (ms) auto mode checks the npm registry for a newer release. */
|
|
71
108
|
updateCheckIntervalMs: z.number().step(1).min(60000).default(6 * 3600 * 1000),
|
|
72
109
|
})
|
|
73
110
|
|
|
74
|
-
// ── shell / path helpers (
|
|
75
|
-
|
|
76
|
-
/** Single-quote one shell argument verbatim. */
|
|
77
|
-
function shq(s) {
|
|
78
|
-
return "'" + String(s).replace(/'/g, "'\\''") + "'"
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Normalize a remote path into a clean absolute path, supporting BOTH POSIX
|
|
83
|
-
* (`/home/user`) and Windows (`D:\Code`, `C:/Users/x`, `\\server\share`) forms.
|
|
84
|
-
*
|
|
85
|
-
* - POSIX paths keep `/` separators and a leading `/`.
|
|
86
|
-
* - Windows drive paths keep their `X:` prefix and are returned with `\`
|
|
87
|
-
* separators (the native form on a Windows remote); a leading `/` is NOT
|
|
88
|
-
* added, so `D:\Code` stays `D:\Code` instead of becoming `/D:\Code`.
|
|
89
|
-
* - UNC paths (`\\server\share\…`) keep the `\\server\share` prefix.
|
|
90
|
-
* - `.` / `..` segments are collapsed in both separator styles.
|
|
91
|
-
*/
|
|
92
|
-
function normalizeRemotePath(p) {
|
|
93
|
-
const s = String(p)
|
|
94
|
-
// Windows UNC: \\server\share\…
|
|
95
|
-
const unc = s.match(/^(\\\\[^\\]+(?:\\[^\\]+)?)(?:\\|$)(.*)$/s)
|
|
96
|
-
if (unc) {
|
|
97
|
-
const [prefix, rest] = [unc[1], unc[2]]
|
|
98
|
-
const parts = []
|
|
99
|
-
for (const seg of rest.split(/[\\/]+/)) {
|
|
100
|
-
if (seg === '' || seg === '.') continue
|
|
101
|
-
if (seg === '..') { parts.pop(); continue }
|
|
102
|
-
parts.push(seg)
|
|
103
|
-
}
|
|
104
|
-
return parts.length ? prefix + '\\' + parts.join('\\') : prefix
|
|
105
|
-
}
|
|
106
|
-
// Windows drive: X:\… or X:/…
|
|
107
|
-
const drive = s.match(/^([a-zA-Z]:)(?:[\\/]|$)(.*)$/s)
|
|
108
|
-
if (drive) {
|
|
109
|
-
const [prefix, rest] = [drive[1], drive[2]]
|
|
110
|
-
const parts = []
|
|
111
|
-
for (const seg of rest.split(/[\\/]+/)) {
|
|
112
|
-
if (seg === '' || seg === '.') continue
|
|
113
|
-
if (seg === '..') { parts.pop(); continue }
|
|
114
|
-
parts.push(seg)
|
|
115
|
-
}
|
|
116
|
-
return prefix + '\\' + parts.join('\\')
|
|
117
|
-
}
|
|
118
|
-
// POSIX: /a/b/c
|
|
119
|
-
const parts = []
|
|
120
|
-
for (const seg of s.split('/')) {
|
|
121
|
-
if (seg === '' || seg === '.') continue
|
|
122
|
-
if (seg === '..') {
|
|
123
|
-
parts.pop()
|
|
124
|
-
continue
|
|
125
|
-
}
|
|
126
|
-
parts.push(seg)
|
|
127
|
-
}
|
|
128
|
-
return '/' + parts.join('/')
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** Join a remote dir + entry name, honoring the dir's own separator style. */
|
|
132
|
-
function joinRemotePath(dir, name) {
|
|
133
|
-
const d = String(dir)
|
|
134
|
-
if (!d) return String(name)
|
|
135
|
-
if (d.endsWith('/') || d.endsWith('\\')) return d + String(name)
|
|
136
|
-
return d + (d.includes('\\') ? '\\' : '/') + String(name)
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** Create every level of a remote dir via SFTP (mkdir -p semantics), working
|
|
140
|
-
* for both POSIX (`/a/b`) and Windows (`D:\a\b`) paths. Best effort: an
|
|
141
|
-
* existing or permission-denied level is skipped. */
|
|
142
|
-
async function mkdirRemoteDirs(sftp, dir) {
|
|
143
|
-
const parent = remoteDirname(dir)
|
|
144
|
-
const isWin = parent.includes('\\')
|
|
145
|
-
// Split off a Windows drive prefix (D:) so it is not treated as a segment.
|
|
146
|
-
const drive = isWin ? (parent.match(/^([a-zA-Z]:)[\\/]?(.*)$/s) || null) : null
|
|
147
|
-
const segs = drive ? drive[2].split(/[\\/]/).filter(Boolean) : parent.split(/[\\/]/).filter(Boolean)
|
|
148
|
-
let cur = isWin ? (drive ? drive[1] + '\\' : '') : ''
|
|
149
|
-
// Drive root (D:\) itself, best effort (usually exists).
|
|
150
|
-
if (isWin && cur) { try { await sftp.mkdir(cur) } catch { /* exists */ } }
|
|
151
|
-
for (const s of segs) {
|
|
152
|
-
if (cur) {
|
|
153
|
-
if (!cur.endsWith('\\') && !cur.endsWith('/')) cur += isWin ? '\\' : '/'
|
|
154
|
-
cur += s
|
|
155
|
-
} else {
|
|
156
|
-
cur = (isWin ? s + ':' : '/' + s)
|
|
157
|
-
}
|
|
158
|
-
try { await sftp.mkdir(cur) } catch { /* exists or no perms */ }
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/** Parent dir of a remote absolute path (string-level; POSIX or Windows). */
|
|
163
|
-
function remoteDirname(p) {
|
|
164
|
-
const norm = normalizeRemotePath(p)
|
|
165
|
-
// Windows drive/UNC root: D:\ or \\server\share
|
|
166
|
-
if (norm === '/' || /^[a-zA-Z]:\\?$/.test(norm) || /^\\\\[^\\]+\\[^\\]+$/.test(norm)) {
|
|
167
|
-
// Ensure a drive root ends with a backslash: D: → D:\
|
|
168
|
-
const m = norm.match(/^([a-zA-Z]:)$/)
|
|
169
|
-
return m ? m[1] + '\\' : norm
|
|
170
|
-
}
|
|
171
|
-
const sep = norm.includes('\\') ? '\\' : '/'
|
|
172
|
-
const idx = norm.lastIndexOf(sep)
|
|
173
|
-
if (idx <= 0) {
|
|
174
|
-
// Windows drive with a child (D:\Code) → D:\
|
|
175
|
-
const m = norm.match(/^([a-zA-Z]:)\\/)
|
|
176
|
-
return m ? m[1] + '\\' : sep === '/' && norm.startsWith('/') ? '/' : norm
|
|
177
|
-
}
|
|
178
|
-
// A parent that is a Windows drive root also needs the trailing backslash.
|
|
179
|
-
const parent = norm.slice(0, idx)
|
|
180
|
-
if (sep === '\\' && /^[a-zA-Z]:$/.test(parent)) return parent + '\\'
|
|
181
|
-
return parent
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function truncate(s, max) {
|
|
185
|
-
if (s.length <= max) return s
|
|
186
|
-
return s.slice(0, max) + `\n…[truncated: ${s.length - max} more chars]`
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// ── local mirror of an OS remote workspace ────────────────────────────────
|
|
190
|
-
// Makes a chosen remote workspace a REAL local directory (fs.realpath passes),
|
|
191
|
-
// so the DSH host `workspaceRegistry.create(dir)` accepts it as a native
|
|
192
|
-
// workspace. dsh-remote syncs that local mirror <-> the remote over SFTP.
|
|
193
|
-
|
|
194
|
-
const remotePathBase = (p) => {
|
|
195
|
-
const norm = normalizeRemotePath(p).replace(/[\\/]+$/, '')
|
|
196
|
-
const base = norm.split(/[\\/]/).pop()
|
|
197
|
-
// Windows drive root (D:\) has no meaningful basename
|
|
198
|
-
if (!base || /^[a-zA-Z]:$/.test(base)) return 'workspace'
|
|
199
|
-
return base
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* Convert an internal remote path (POSIX `/a/b` or Windows `D:\Code`) into the
|
|
204
|
-
* path format the SFTP server understands.
|
|
205
|
-
*
|
|
206
|
-
* Windows OpenSSH's sftp-server accepts POSIX-style paths: a drive letter is
|
|
207
|
-
* written `/D:/…` (equivalent to `D:\…`). POSIX paths pass through unchanged.
|
|
208
|
-
* UNC paths are kept as-is (the server resolves `\\server\share` itself).
|
|
209
|
-
*/
|
|
210
|
-
function toSftpPath(p) {
|
|
211
|
-
const norm = normalizeRemotePath(p)
|
|
212
|
-
const m = norm.match(/^([a-zA-Z]):\\(.*)$/s)
|
|
213
|
-
if (m) return '/' + m[1] + ':/' + m[2].replace(/\\/g, '/')
|
|
214
|
-
return norm
|
|
215
|
-
}
|
|
111
|
+
// ── shell / path helpers (pure implementations in lib/paths.js) ───────────
|
|
216
112
|
|
|
217
113
|
/** Harness home: respect `DSH_HOME` when set (the desktop app sets it to its
|
|
218
|
-
* own `userData/harness`), otherwise fall back to `~/.dsh`.
|
|
219
|
-
* data under the same root the harness uses means uninstalling / upgrading the
|
|
220
|
-
* app no longer leaves state behind in the home directory. */
|
|
114
|
+
* own `userData/harness`), otherwise fall back to `~/.dsh`. */
|
|
221
115
|
function dshBase() {
|
|
222
116
|
const env = process.env.DSH_HOME
|
|
223
117
|
if (env && String(env).trim()) return path.resolve(String(env).trim())
|
|
@@ -229,58 +123,13 @@ function remoteWorkspacesRoot() {
|
|
|
229
123
|
return path.join(dshBase(), 'remote-workspaces')
|
|
230
124
|
}
|
|
231
125
|
|
|
232
|
-
/**
|
|
233
|
-
function copyDirSync(from, to) {
|
|
234
|
-
mkdirSync(to, { recursive: true })
|
|
235
|
-
for (const e of readdirSync(from, { withFileTypes: true })) {
|
|
236
|
-
const s = path.join(from, e.name)
|
|
237
|
-
const d = path.join(to, e.name)
|
|
238
|
-
if (e.isDirectory()) copyDirSync(s, d)
|
|
239
|
-
else try { copyFileSync(s, d) } catch { /* skip unreadable */ }
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/** One-time migration of pre-0.6 data (which lived at `~/.dsh/remote-workspaces`
|
|
244
|
-
* regardless of `DSH_HOME`). Moves the legacy dir into the current home so a
|
|
245
|
-
* desktop install keeps its existing machines and mirrors. Idempotent; a
|
|
246
|
-
* rename across devices falls back to a copy. */
|
|
247
|
-
function migrateLegacyData() {
|
|
248
|
-
const legacy = path.join(homedir(), '.dsh', 'remote-workspaces')
|
|
249
|
-
const target = remoteWorkspacesRoot()
|
|
250
|
-
if (legacy === target || !existsSync(legacy) || existsSync(target)) return
|
|
251
|
-
try {
|
|
252
|
-
mkdirSync(path.dirname(target), { recursive: true })
|
|
253
|
-
try {
|
|
254
|
-
renameSync(legacy, target)
|
|
255
|
-
} catch (err) {
|
|
256
|
-
if (err.code !== 'EXDEV') throw err
|
|
257
|
-
copyDirSync(legacy, target)
|
|
258
|
-
}
|
|
259
|
-
} catch {
|
|
260
|
-
// Migration is best-effort: a fresh registry is created on next save.
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/** 32-bit string hash rendered in base36 (short, collision-safe enough). */
|
|
265
|
-
function shortHash(s) {
|
|
266
|
-
let h = 0
|
|
267
|
-
for (let i = 0; i < s.length; i++) {
|
|
268
|
-
h = ((h << 5) - h + s.charCodeAt(i)) | 0
|
|
269
|
-
}
|
|
270
|
-
return (h >>> 0).toString(36)
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/** Stable local root for one remote host's mirrors */
|
|
126
|
+
/** Local mirrors of one remote host. */
|
|
274
127
|
function mirrorRootFor(host, user, port) {
|
|
275
128
|
const tag = [host, user, port].filter(Boolean).join('-').replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
276
129
|
return path.join(remoteWorkspacesRoot(), tag)
|
|
277
130
|
}
|
|
278
131
|
|
|
279
|
-
/** Local mirror dir for a specific remote path (idempotent → returns same dir).
|
|
280
|
-
* Named after the remote directory's basename so the harness workspace label
|
|
281
|
-
* reads cleanly (e.g. .../project). When the plain basename dir is already
|
|
282
|
-
* taken by a DIFFERENT remote path on the same host, a short path-hash suffix
|
|
283
|
-
* is appended so mirrors never collide. */
|
|
132
|
+
/** Local mirror dir for a specific remote path (idempotent → returns same dir). */
|
|
284
133
|
function mirrorDirFor(remotePath, host, user, port) {
|
|
285
134
|
const base = remotePathBase(remotePath)
|
|
286
135
|
const root = mirrorRootFor(host, user, port)
|
|
@@ -293,8 +142,6 @@ function mirrorDirFor(remotePath, host, user, port) {
|
|
|
293
142
|
} catch {
|
|
294
143
|
/* no mirror yet → fall through */
|
|
295
144
|
}
|
|
296
|
-
// Plain dir is free → first mirror for this basename keeps the clean name.
|
|
297
|
-
// It exists but points elsewhere (or is a non-mirror dir) → hashed variant.
|
|
298
145
|
if (!existsSync(plain)) return plain
|
|
299
146
|
return path.join(root, base + '-' + shortHash(norm))
|
|
300
147
|
}
|
|
@@ -310,125 +157,48 @@ function ensureMirror(remotePath, host, user, port) {
|
|
|
310
157
|
return dir
|
|
311
158
|
}
|
|
312
159
|
|
|
313
|
-
/**
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
}
|
|
160
|
+
/** Recursive directory copy (EXDEV fallback for migrateLegacyData). */
|
|
161
|
+
function copyDirSync(from, to) {
|
|
162
|
+
mkdirSync(to, { recursive: true })
|
|
163
|
+
for (const e of readdirSync(from, { withFileTypes: true })) {
|
|
164
|
+
const s = path.join(from, e.name)
|
|
165
|
+
const d = path.join(to, e.name)
|
|
166
|
+
if (e.isDirectory()) copyDirSync(s, d)
|
|
167
|
+
else try { copyFileSync(s, d) } catch { /* skip unreadable */ }
|
|
322
168
|
}
|
|
323
|
-
const workers = []
|
|
324
|
-
for (let w = 0; w < limitN; w++) workers.push(worker())
|
|
325
|
-
await Promise.all(workers)
|
|
326
169
|
}
|
|
327
170
|
|
|
328
|
-
/**
|
|
329
|
-
*
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
)
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
if (name === '.' || name === '..') continue
|
|
341
|
-
const isDir = e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()
|
|
342
|
-
if (!isDir) continue
|
|
343
|
-
if (maxDepth <= 0 || stats.files >= maxFiles) continue
|
|
344
|
-
const rp = joinRemotePath(remoteDir, name)
|
|
345
|
-
const lp = path.join(localDir, name)
|
|
346
|
-
mkdirSync(lp, { recursive: true })
|
|
347
|
-
const sub = await syncTree(sftp, rp, lp, maxDepth - 1, maxFiles - stats.files, maxFileBytes)
|
|
348
|
-
stats.dirs += sub.dirs + 1
|
|
349
|
-
stats.files += sub.files
|
|
350
|
-
stats.skippedUnchanged += sub.skippedUnchanged
|
|
351
|
-
stats.skippedLarge += sub.skippedLarge
|
|
352
|
-
stats.touched.push(...sub.touched)
|
|
353
|
-
if (stats.files >= maxFiles) break
|
|
354
|
-
}
|
|
355
|
-
if (stats.files >= maxFiles) return stats
|
|
356
|
-
|
|
357
|
-
// Files in one bounded-parallel sweep per directory level.
|
|
358
|
-
const fileEntries = entries.filter(
|
|
359
|
-
(e) => !(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()) &&
|
|
360
|
-
String(e.filename) !== '.' && String(e.filename) !== '..',
|
|
361
|
-
)
|
|
362
|
-
await mapLimit(fileEntries, 4, async (e) => {
|
|
363
|
-
if (stats.files >= maxFiles) return
|
|
364
|
-
const name = String(e.filename)
|
|
365
|
-
const rp = joinRemotePath(remoteDir, name)
|
|
366
|
-
const lp = path.join(localDir, name)
|
|
171
|
+
/** One-time migration of pre-0.6 data (~/.dsh/remote-workspaces) into DSH_HOME.
|
|
172
|
+
* Only runs when the harness is on its DEFAULT home: an explicitly-set DSH_HOME
|
|
173
|
+
* pointing elsewhere means ~/.dsh belongs to another installation — migrating
|
|
174
|
+
* would RENAME that other installation's live data out from under it. */
|
|
175
|
+
function migrateLegacyData() {
|
|
176
|
+
const env = process.env.DSH_HOME
|
|
177
|
+
if (env && String(env).trim() && path.resolve(String(env).trim()) !== path.join(homedir(), '.dsh')) return
|
|
178
|
+
const legacy = path.join(homedir(), '.dsh', 'remote-workspaces')
|
|
179
|
+
const target = remoteWorkspacesRoot()
|
|
180
|
+
if (legacy === target || !existsSync(legacy) || existsSync(target)) return
|
|
181
|
+
try {
|
|
182
|
+
mkdirSync(path.dirname(target), { recursive: true })
|
|
367
183
|
try {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
if (unchanged) { stats.skippedUnchanged++; return }
|
|
373
|
-
const buf = await sftp.readFile(rp)
|
|
374
|
-
writeFileSync(lp, buf)
|
|
375
|
-
try { utimesSync(lp, new Date(st.mtime * 1000), new Date(st.mtime * 1000)) } catch {}
|
|
376
|
-
stats.files++
|
|
377
|
-
stats.touched.push(rp)
|
|
378
|
-
} catch {
|
|
379
|
-
/* skip unreadable */
|
|
184
|
+
renameSync(legacy, target)
|
|
185
|
+
} catch (err) {
|
|
186
|
+
if (err.code !== 'EXDEV') throw err
|
|
187
|
+
copyDirSync(legacy, target)
|
|
380
188
|
}
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
/** Recursively upload a local mirror tree → remote SFTP dir. Bounded by files;
|
|
386
|
-
* skips files whose remote copy already matches (same size, remote no older). */
|
|
387
|
-
async function pushTree(sftp, localDir, remoteDir, maxFiles, maxFileBytes) {
|
|
388
|
-
const entries = readdirSync(localDir, { withFileTypes: true }).filter((e) => e.name !== '.dsh-remote-meta.json')
|
|
389
|
-
const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, pushed: [] }
|
|
390
|
-
|
|
391
|
-
for (const e of entries) {
|
|
392
|
-
if (!e.isDirectory()) continue
|
|
393
|
-
if (stats.files >= maxFiles) break
|
|
394
|
-
const rp = joinRemotePath(remoteDir, e.name)
|
|
395
|
-
try { await sftp.mkdir(rp) } catch { /* already exists */ }
|
|
396
|
-
stats.dirs++
|
|
397
|
-
const sub = await pushTree(sftp, path.join(localDir, e.name), rp, maxFiles - stats.files, maxFileBytes)
|
|
398
|
-
stats.files += sub.files
|
|
399
|
-
stats.skippedUnchanged += sub.skippedUnchanged
|
|
400
|
-
stats.skippedLarge += sub.skippedLarge
|
|
401
|
-
stats.pushed.push(...sub.pushed)
|
|
402
|
-
if (stats.files >= maxFiles) break
|
|
189
|
+
} catch {
|
|
190
|
+
// Migration is best-effort: a fresh registry is created on next save.
|
|
403
191
|
}
|
|
404
|
-
if (stats.files >= maxFiles) return stats
|
|
405
|
-
|
|
406
|
-
const fileEntries = entries.filter((e) => !e.isDirectory())
|
|
407
|
-
await mapLimit(fileEntries, 4, async (e) => {
|
|
408
|
-
if (stats.files >= maxFiles) return
|
|
409
|
-
const lp = path.join(localDir, e.name)
|
|
410
|
-
const rp = joinRemotePath(remoteDir, e.name)
|
|
411
|
-
try {
|
|
412
|
-
const lstat = statSync(lp)
|
|
413
|
-
if (maxFileBytes > 0 && lstat.size > maxFileBytes) { stats.skippedLarge++; return }
|
|
414
|
-
let rstat = null
|
|
415
|
-
try { rstat = await sftp.stat(rp) } catch { /* remote file absent → upload */ }
|
|
416
|
-
const unchanged = rstat && rstat.size === lstat.size && rstat.mtime >= Math.floor(lstat.mtimeMs / 1000)
|
|
417
|
-
if (unchanged) { stats.skippedUnchanged++; return }
|
|
418
|
-
const buf = readFileSync(lp)
|
|
419
|
-
await sftp.writeFile(rp, buf)
|
|
420
|
-
stats.files++
|
|
421
|
-
stats.pushed.push(rp)
|
|
422
|
-
} catch {
|
|
423
|
-
/* skip unreadable / unwritable */
|
|
424
|
-
}
|
|
425
|
-
})
|
|
426
|
-
return stats
|
|
427
192
|
}
|
|
428
193
|
|
|
429
194
|
// ── persistent multi-machine registry ─────────────────────────────────────
|
|
430
195
|
const MACHINES_FILE = 'machines.json'
|
|
431
196
|
const machinesFile = () => path.join(remoteWorkspacesRoot(), MACHINES_FILE)
|
|
197
|
+
const secretsDir = () => path.join(remoteWorkspacesRoot(), '.secrets')
|
|
198
|
+
const forwardsFile = () => path.join(remoteWorkspacesRoot(), 'forwards.json')
|
|
199
|
+
const auditFile = () => path.join(remoteWorkspacesRoot(), 'audit.log')
|
|
200
|
+
const ignoreFile = () => path.join(remoteWorkspacesRoot(), '.dsh-remote-ignore')
|
|
201
|
+
|
|
432
202
|
function loadMachines() {
|
|
433
203
|
try {
|
|
434
204
|
const j = JSON.parse(readFileSync(machinesFile(), 'utf8'))
|
|
@@ -442,8 +212,10 @@ function saveMachines(list, currentId) {
|
|
|
442
212
|
}
|
|
443
213
|
function sanitizeMachine(m) {
|
|
444
214
|
if (!m) return m
|
|
445
|
-
const { password, ...rest } = m
|
|
446
|
-
|
|
215
|
+
const { password, proxy, ...rest } = m
|
|
216
|
+
const out = { ...rest, passwordSet: !!(m.password && m.password.length) }
|
|
217
|
+
if (proxy) out.proxy = { ...proxy, password: proxy.password ? '' : undefined, passwordSet: !!(proxy.password && proxy.password.length) }
|
|
218
|
+
return out
|
|
447
219
|
}
|
|
448
220
|
/** Apply a machine's fields onto the live config object (pool + tools read it). */
|
|
449
221
|
function applyMachine(config, m) {
|
|
@@ -455,14 +227,17 @@ function applyMachine(config, m) {
|
|
|
455
227
|
config.privateKeyPath = m.privateKeyPath || ''
|
|
456
228
|
config.passphrase = m.passphrase || ''
|
|
457
229
|
config.workspace = m.workspace || (config.workspace || '')
|
|
230
|
+
if (m.hostKeyMode) config.hostKeyMode = m.hostKeyMode
|
|
231
|
+
if (typeof m.useAgent === 'boolean') config.useAgent = m.useAgent
|
|
232
|
+
if (typeof m.keyboardInteractive === 'boolean') config.keyboardInteractive = m.keyboardInteractive
|
|
233
|
+
config.proxy = m.proxy && m.proxy.host ? m.proxy : undefined
|
|
458
234
|
}
|
|
459
235
|
function machineId() { return 'm-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6) }
|
|
460
236
|
|
|
461
|
-
// ── host-key registry (TOFU
|
|
237
|
+
// ── host-key registry (TOFU) ──────────────────────────────────────────────
|
|
462
238
|
const KNOWN_HOSTS_FILE = 'known_hosts.json'
|
|
463
239
|
const knownHostsFile = () => path.join(remoteWorkspacesRoot(), KNOWN_HOSTS_FILE)
|
|
464
240
|
|
|
465
|
-
/** Read the trusted host-key map { "host:port": { algo, fingerprint } }. */
|
|
466
241
|
function loadKnownHosts() {
|
|
467
242
|
try {
|
|
468
243
|
const j = JSON.parse(readFileSync(knownHostsFile(), 'utf8'))
|
|
@@ -471,40 +246,7 @@ function loadKnownHosts() {
|
|
|
471
246
|
return {}
|
|
472
247
|
}
|
|
473
248
|
|
|
474
|
-
/**
|
|
475
|
-
* (SSH wire format: `uint32 len` + algorithm string + key data). */
|
|
476
|
-
function blobAlgorithm(blob) {
|
|
477
|
-
if (!Buffer.isBuffer(blob) || blob.length < 4) return ''
|
|
478
|
-
try {
|
|
479
|
-
const len = blob.readUInt32BE(0)
|
|
480
|
-
return blob.toString('utf8', 4, 4 + len)
|
|
481
|
-
} catch {
|
|
482
|
-
return ''
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
/** SHA-256 fingerprint (base64) of an ssh2 host-key blob, like `SHA256:…`
|
|
487
|
-
* in a known_hosts file (without the `SHA256:` prefix).
|
|
488
|
-
*
|
|
489
|
-
* ssh2 v1.17 passes hostVerifier the RAW host-key blob Buffer (the SSH
|
|
490
|
-
* wire-format `string(algo) string(keydata)`), NOT the old `{ algo, hash }`
|
|
491
|
-
* object. Fingerprinting `key.hash` on a raw Buffer crashed with "The 'data'
|
|
492
|
-
* argument must be of type string or an instance of Buffer, TypedArray, or
|
|
493
|
-
* DataView. Received undefined" on EVERY connect (v0.6.6 bug) — so the
|
|
494
|
-
* v0.6.1 TOFU guard never actually worked against real ssh2. Accept both
|
|
495
|
-
* shapes defensively. */
|
|
496
|
-
function keyFingerprint(key) {
|
|
497
|
-
const blob = Buffer.isBuffer(key) ? key : (key && key.hash)
|
|
498
|
-
if (!blob) throw new Error('host key missing (hostVerifier received no key blob)')
|
|
499
|
-
return createHash('sha256').update(blob).digest('base64')
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
/** Build an ssh2 `hostVerifier` bound to the current config. Returns
|
|
503
|
-
* `{ mode, verifier, lastError, knownHosts, forgetHost }`. The verifier:
|
|
504
|
-
* - `off` → always accepts;
|
|
505
|
-
* - `accept-new` → records a host's key on first connect, rejects any CHANGE
|
|
506
|
-
* afterwards (classic TOFU / MITM detection);
|
|
507
|
-
* - `verify` → same, plus rejects hosts never recorded before. */
|
|
249
|
+
/** Build an ssh2 `hostVerifier` bound to the current config. */
|
|
508
250
|
function createHostKeyGuard(config) {
|
|
509
251
|
const id = () => `${config.host}:${config.port}`
|
|
510
252
|
const mode = config.hostKeyMode === 'verify' || config.hostKeyMode === 'off'
|
|
@@ -550,106 +292,23 @@ function isHostKeyKnown(host, port) {
|
|
|
550
292
|
return Object.prototype.hasOwnProperty.call(loadKnownHosts(), `${host}:${port}`)
|
|
551
293
|
}
|
|
552
294
|
|
|
553
|
-
// ── SSH pool (key
|
|
554
|
-
|
|
555
|
-
/**
|
|
556
|
-
* Wrap a raw ssh2 SFTP handle so a stuck server can never hang a tool call:
|
|
557
|
-
* every method is bounded by the configured command timeout, and path args
|
|
558
|
-
* are normalized via toSftpPath() so Windows remotes (drive-letter paths)
|
|
559
|
-
* work too.
|
|
560
|
-
*/
|
|
561
|
-
function wrapSftp(client, sftp, config) {
|
|
562
|
-
const withTimeout = (fn) => (...args) =>
|
|
563
|
-
new Promise((r2, j2) => {
|
|
564
|
-
const timer = setTimeout(() => j2(new Error('sftp operation timed out')), config.commandTimeoutMs)
|
|
565
|
-
const done = (e, v) => {
|
|
566
|
-
clearTimeout(timer)
|
|
567
|
-
e ? j2(e) : r2(v)
|
|
568
|
-
}
|
|
569
|
-
try { fn(...args, done) } catch (e) { clearTimeout(timer); j2(e) }
|
|
570
|
-
})
|
|
571
|
-
const P = (p) => toSftpPath(p)
|
|
572
|
-
return {
|
|
573
|
-
readdir: (dir) => withTimeout((d, cb) => sftp.readdir(d, cb))(P(dir)),
|
|
574
|
-
stat: (p) => withTimeout((d, cb) => sftp.stat(d, cb))(P(p)),
|
|
575
|
-
lstat: (p) => withTimeout((d, cb) => sftp.lstat(d, cb))(P(p)),
|
|
576
|
-
mkdir: (dir) => withTimeout((d, cb) => sftp.mkdir(d, cb))(P(dir)),
|
|
577
|
-
readFile: (p) => withTimeout((d, cb) => sftp.readFile(d, cb))(P(p)),
|
|
578
|
-
writeFile: (p, data) => withTimeout((d, data2, cb) => sftp.writeFile(d, data2, cb))(P(p), data),
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
/**
|
|
583
|
-
* Run one exec stream to completion: collects stdout/stderr (bounded), settles
|
|
584
|
-
* on channel close or the configured command timeout. On timeout the remote
|
|
585
|
-
* command gets SIGTERM and the channel is hard-closed so a runaway process
|
|
586
|
-
* cannot keep holding the SSH connection.
|
|
587
|
-
*/
|
|
588
|
-
function runStream(client, stream, config, timeoutMs) {
|
|
589
|
-
return new Promise((resolve, reject) => {
|
|
590
|
-
let stdout = ''
|
|
591
|
-
let stderr = ''
|
|
592
|
-
let settled = false
|
|
593
|
-
let exitCode = null
|
|
594
|
-
let exitSignal = null
|
|
595
|
-
const hardCap = Math.max(config.maxOutputChars * 4, 1024 * 1024)
|
|
596
|
-
const settle = () => {
|
|
597
|
-
if (settled) return
|
|
598
|
-
settled = true
|
|
599
|
-
clearTimeout(timer)
|
|
600
|
-
resolve({
|
|
601
|
-
code: exitCode,
|
|
602
|
-
signal: exitSignal,
|
|
603
|
-
stdout: truncate(stdout, config.maxOutputChars),
|
|
604
|
-
stderr: truncate(stderr, config.maxOutputChars),
|
|
605
|
-
})
|
|
606
|
-
}
|
|
607
|
-
const timer = setTimeout(() => {
|
|
608
|
-
if (settled) return
|
|
609
|
-
exitCode = -1
|
|
610
|
-
exitSignal = 'TIMEOUT'
|
|
611
|
-
// Kill the remote command (SIGTERM) rather than just dropping the
|
|
612
|
-
// channel, so a runaway process cannot keep running and holding
|
|
613
|
-
// the SSH connection after we've given up on its output. The hard
|
|
614
|
-
// close below frees the channel even if the process ignores it.
|
|
615
|
-
try {
|
|
616
|
-
if (typeof stream.signal === 'function') stream.signal('SIGTERM')
|
|
617
|
-
} catch {}
|
|
618
|
-
const hardClose = setTimeout(() => {
|
|
619
|
-
try { stream.close() } catch {}
|
|
620
|
-
}, 800)
|
|
621
|
-
if (typeof hardClose.unref === 'function') hardClose.unref()
|
|
622
|
-
settle()
|
|
623
|
-
}, timeoutMs || config.commandTimeoutMs)
|
|
624
|
-
stream.on('close', (code, signal) => {
|
|
625
|
-
if (settled) return
|
|
626
|
-
exitCode = code
|
|
627
|
-
exitSignal = signal
|
|
628
|
-
settle()
|
|
629
|
-
})
|
|
630
|
-
stream.on('data', (d) => {
|
|
631
|
-
if (stdout.length < hardCap) stdout += d
|
|
632
|
-
})
|
|
633
|
-
stream.stderr.on('data', (d) => {
|
|
634
|
-
if (stderr.length < hardCap) stderr += d
|
|
635
|
-
})
|
|
636
|
-
stream.on('error', (e) => {
|
|
637
|
-
if (settled) return
|
|
638
|
-
settled = true
|
|
639
|
-
clearTimeout(timer)
|
|
640
|
-
reject(new Error('ssh stream error: ' + ((e && e.message) || e)))
|
|
641
|
-
})
|
|
642
|
-
})
|
|
643
|
-
}
|
|
295
|
+
// ── SSH pool (key / password / agent / keyboard-interactive / proxy) ──────
|
|
644
296
|
|
|
645
297
|
class SshPool {
|
|
646
298
|
constructor(config) {
|
|
647
299
|
this.config = config
|
|
648
300
|
this.client = null
|
|
649
301
|
this.connecting = null
|
|
302
|
+
this.proxyPool = null
|
|
650
303
|
// Generational token: bumped on every target change / close so a stale
|
|
651
304
|
// in-flight connect can never hand this pool a connection to an old host.
|
|
652
305
|
this.epoch = 0
|
|
306
|
+
/** Optional async resolver for a machine-stored (keychain) password. */
|
|
307
|
+
this.passwordResolver = null
|
|
308
|
+
/** Optional hook called with the live client after a successful connect. */
|
|
309
|
+
this.onReady = null
|
|
310
|
+
/** Optional hook called when the pool closes. */
|
|
311
|
+
this.onCloseHook = null
|
|
653
312
|
}
|
|
654
313
|
|
|
655
314
|
resolveKeyPath() {
|
|
@@ -659,8 +318,7 @@ class SshPool {
|
|
|
659
318
|
return p
|
|
660
319
|
}
|
|
661
320
|
|
|
662
|
-
|
|
663
|
-
setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace }) {
|
|
321
|
+
setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode }) {
|
|
664
322
|
if (host !== undefined) this.config.host = String(host)
|
|
665
323
|
if (port !== undefined && Number(port)) this.config.port = Number(port)
|
|
666
324
|
if (username !== undefined) this.config.username = String(username)
|
|
@@ -668,6 +326,10 @@ class SshPool {
|
|
|
668
326
|
if (privateKeyPath !== undefined) this.config.privateKeyPath = String(privateKeyPath)
|
|
669
327
|
if (passphrase !== undefined) this.config.passphrase = String(passphrase)
|
|
670
328
|
if (workspace !== undefined) this.config.workspace = String(workspace)
|
|
329
|
+
if (useAgent !== undefined) this.config.useAgent = !!useAgent
|
|
330
|
+
if (keyboardInteractive !== undefined) this.config.keyboardInteractive = !!keyboardInteractive
|
|
331
|
+
if (proxy !== undefined) this.config.proxy = proxy
|
|
332
|
+
if (hostKeyMode !== undefined) this.config.hostKeyMode = String(hostKeyMode)
|
|
671
333
|
this.close()
|
|
672
334
|
return this
|
|
673
335
|
}
|
|
@@ -681,42 +343,49 @@ class SshPool {
|
|
|
681
343
|
const clear = () => {
|
|
682
344
|
if (this.epoch === epoch && this.connecting === pending) this.connecting = null
|
|
683
345
|
}
|
|
684
|
-
// Keep the chain clean in both directions: the raw pending is what callers
|
|
685
|
-
// await (they still see rejections); this side just clears the slot.
|
|
686
346
|
pending.then(clear, clear)
|
|
687
347
|
return pending
|
|
688
348
|
}
|
|
689
349
|
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
const client = this.client
|
|
701
|
-
this.client = null
|
|
702
|
-
const pending = this.connecting
|
|
703
|
-
this.connecting = null
|
|
704
|
-
if (pending && typeof pending.catch === 'function') {
|
|
705
|
-
try { pending.catch(() => {}) } catch {}
|
|
350
|
+
async _doConnect(epoch) {
|
|
351
|
+
const isCurrent = () => this.epoch === epoch
|
|
352
|
+
const guard = createHostKeyGuard(this.config)
|
|
353
|
+
const client = new Client()
|
|
354
|
+
let settled = false
|
|
355
|
+
const fail = (err) => {
|
|
356
|
+
if (settled) return
|
|
357
|
+
settled = true
|
|
358
|
+
if (isCurrent() && this.client === client) this.client = null
|
|
359
|
+
throw guard.lastError ? new Error(guard.lastError) : err
|
|
706
360
|
}
|
|
707
|
-
|
|
708
|
-
|
|
361
|
+
|
|
362
|
+
// Proxy jump: SSH to the bastion first, then tunnel to the target through it.
|
|
363
|
+
let sock = null
|
|
364
|
+
const proxyCfg = this.config.proxy
|
|
365
|
+
if (proxyCfg && proxyCfg.host) {
|
|
366
|
+
try {
|
|
367
|
+
this.proxyPool = new SshPool({
|
|
368
|
+
...this.config,
|
|
369
|
+
host: proxyCfg.host,
|
|
370
|
+
port: Number(proxyCfg.port) || 22,
|
|
371
|
+
username: proxyCfg.username || this.config.username || 'root',
|
|
372
|
+
password: proxyCfg.password || '',
|
|
373
|
+
privateKeyPath: proxyCfg.privateKeyPath || '',
|
|
374
|
+
passphrase: proxyCfg.passphrase || '',
|
|
375
|
+
proxy: undefined,
|
|
376
|
+
})
|
|
377
|
+
const pclient = await this.proxyPool.connect()
|
|
378
|
+
if (!isCurrent()) throw new Error('ssh target changed during proxy connect')
|
|
379
|
+
sock = await new Promise((res, rej) => {
|
|
380
|
+
pclient.forwardOut('127.0.0.1', 0, this.config.host, this.config.port, (e, ch) => (e ? rej(new Error('proxy forward to target failed: ' + ((e && e.message) || e))) : res(ch)))
|
|
381
|
+
})
|
|
382
|
+
} catch (err) {
|
|
383
|
+
return fail(err)
|
|
384
|
+
}
|
|
709
385
|
}
|
|
710
|
-
}
|
|
711
386
|
|
|
712
|
-
_doConnect(epoch) {
|
|
713
387
|
return new Promise((resolve, reject) => {
|
|
714
|
-
const
|
|
715
|
-
let settled = false
|
|
716
|
-
// Fresh host-key guard per connect attempt so lastError is never stale.
|
|
717
|
-
const guard = createHostKeyGuard(this.config)
|
|
718
|
-
const isCurrent = () => this.epoch === epoch
|
|
719
|
-
const fail = (err) => {
|
|
388
|
+
const rejectOnce = (err) => {
|
|
720
389
|
if (settled) return
|
|
721
390
|
settled = true
|
|
722
391
|
if (isCurrent() && this.client === client) this.client = null
|
|
@@ -726,113 +395,244 @@ class SshPool {
|
|
|
726
395
|
if (settled) return
|
|
727
396
|
settled = true
|
|
728
397
|
if (!isCurrent()) {
|
|
729
|
-
// The target changed while we were connecting — never adopt this
|
|
730
|
-
// client, and make sure the caller learns the connect is void.
|
|
731
398
|
try { client.end() } catch {}
|
|
732
399
|
reject(new Error('ssh target changed during connect'))
|
|
733
400
|
return
|
|
734
401
|
}
|
|
735
402
|
this.client = client
|
|
736
403
|
resolve(client)
|
|
404
|
+
if (this.onReady) { try { this.onReady(client) } catch {} }
|
|
737
405
|
})
|
|
738
|
-
client.on('error',
|
|
406
|
+
client.on('error', (e) => rejectOnce(e))
|
|
739
407
|
client.on('close', () => {
|
|
740
408
|
if (isCurrent() && this.client === client) this.client = null
|
|
741
|
-
|
|
409
|
+
rejectOnce(new Error('ssh connection closed'))
|
|
742
410
|
})
|
|
743
411
|
|
|
744
|
-
const
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
if (this.config.password) {
|
|
754
|
-
opts.password = this.config.password
|
|
755
|
-
} else {
|
|
756
|
-
const keyPath = this.resolveKeyPath()
|
|
757
|
-
if (!keyPath) {
|
|
758
|
-
return fail(new Error('no credentials: set a password or a privateKeyPath to connect'))
|
|
412
|
+
const buildOpts = async () => {
|
|
413
|
+
const opts = {
|
|
414
|
+
host: this.config.host,
|
|
415
|
+
port: this.config.port,
|
|
416
|
+
username: this.config.username,
|
|
417
|
+
readyTimeout: this.config.connectTimeoutMs,
|
|
418
|
+
keepaliveInterval: 15000,
|
|
419
|
+
keepaliveCountMax: 3,
|
|
420
|
+
hostVerifier: (key) => guard.verifier(key),
|
|
759
421
|
}
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
422
|
+
if (sock) opts.sock = sock
|
|
423
|
+
if (this.config.useAgent) {
|
|
424
|
+
const sockPath = process.env.SSH_AUTH_SOCK
|
|
425
|
+
if (sockPath) opts.agent = sockPath
|
|
426
|
+
}
|
|
427
|
+
let password = this.config.password || ''
|
|
428
|
+
if (!password && this.passwordResolver) {
|
|
429
|
+
try { password = (await this.passwordResolver()) || '' } catch {}
|
|
430
|
+
}
|
|
431
|
+
if (password) {
|
|
432
|
+
opts.password = password
|
|
433
|
+
opts.tryKeyboard = true
|
|
434
|
+
} else if (this.config.keyboardInteractive && !this.config.privateKeyPath) {
|
|
435
|
+
opts.tryKeyboard = true
|
|
765
436
|
}
|
|
766
|
-
|
|
767
|
-
|
|
437
|
+
if (this.config.privateKeyPath) {
|
|
438
|
+
const keyPath = this.resolveKeyPath()
|
|
439
|
+
if (!keyPath) {
|
|
440
|
+
throw new Error('no credentials: set a password or a privateKeyPath to connect')
|
|
441
|
+
}
|
|
442
|
+
let key
|
|
443
|
+
try {
|
|
444
|
+
key = readFileSync(keyPath)
|
|
445
|
+
} catch (err) {
|
|
446
|
+
throw new Error(`cannot read private key "${keyPath}": ${err && err.message}`)
|
|
447
|
+
}
|
|
448
|
+
opts.privateKey = key
|
|
449
|
+
opts.passphrase = this.config.passphrase || undefined
|
|
450
|
+
} else if (!password && !opts.agent) {
|
|
451
|
+
throw new Error('no credentials: set a password, a privateKeyPath, or enable useAgent to connect')
|
|
452
|
+
}
|
|
453
|
+
return opts
|
|
768
454
|
}
|
|
769
|
-
|
|
455
|
+
|
|
456
|
+
buildOpts().then(
|
|
457
|
+
(opts) => {
|
|
458
|
+
if (opts.tryKeyboard) {
|
|
459
|
+
client.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => {
|
|
460
|
+
finish(prompts.map(() => this.config.password || ''))
|
|
461
|
+
})
|
|
462
|
+
}
|
|
463
|
+
client.connect(opts)
|
|
464
|
+
},
|
|
465
|
+
(err) => rejectOnce(err),
|
|
466
|
+
)
|
|
770
467
|
})
|
|
771
468
|
}
|
|
772
469
|
|
|
773
|
-
/** Run one remote command; resolves { code, signal, stdout, stderr }.
|
|
774
|
-
|
|
470
|
+
/** Run one remote command; resolves { code, signal, stdout, stderr }.
|
|
471
|
+
* Accepts `exec(cmd, timeoutMs)` (legacy) or `exec(cmd, {timeoutMs, pty, env})`. */
|
|
472
|
+
exec(command, timeoutMsOrOpts) {
|
|
473
|
+
const opts = timeoutMsOrOpts && typeof timeoutMsOrOpts === 'object' ? timeoutMsOrOpts : { timeoutMs: timeoutMsOrOpts }
|
|
474
|
+
const timeoutMs = opts.timeoutMs || this.config.commandTimeoutMs
|
|
775
475
|
return this.connect().then(
|
|
776
476
|
(client) =>
|
|
777
477
|
new Promise((resolve, reject) => {
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
(
|
|
791
|
-
|
|
478
|
+
let retried = false
|
|
479
|
+
const runOn = (c) => {
|
|
480
|
+
const execOpts = {}
|
|
481
|
+
if (opts.pty) execOpts.pty = true
|
|
482
|
+
if (opts.env && typeof opts.env === 'object') execOpts.env = opts.env
|
|
483
|
+
c.exec(command, execOpts, (err, stream) => {
|
|
484
|
+
if (err) {
|
|
485
|
+
// Channel-open failure usually means the pooled connection died
|
|
486
|
+
// server-side (idle timeout / network reset) while keepalive
|
|
487
|
+
// hadn't noticed. Drop it and retry ONCE on a fresh connection.
|
|
488
|
+
if (!retried && /channel open failure|open failed/i.test(String((err && err.message) || err))) {
|
|
489
|
+
retried = true
|
|
490
|
+
this.invalidate()
|
|
491
|
+
return this.connect().then(
|
|
492
|
+
(fresh) => runOn(fresh),
|
|
493
|
+
(e2) => reject(new Error('ssh exec failed (reconnect): ' + ((e2 && e2.message) || e2))),
|
|
494
|
+
)
|
|
495
|
+
}
|
|
496
|
+
return reject(new Error('ssh exec failed: ' + ((err && err.message) || err)))
|
|
792
497
|
}
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
498
|
+
let stdout = ''
|
|
499
|
+
let stderr = ''
|
|
500
|
+
let settled = false
|
|
501
|
+
let exitCode = null
|
|
502
|
+
let exitSignal = null
|
|
503
|
+
const hardCap = Math.max(this.config.maxOutputChars * 4, 1024 * 1024)
|
|
504
|
+
const settle = () => {
|
|
505
|
+
if (settled) return
|
|
506
|
+
settled = true
|
|
507
|
+
clearTimeout(timer)
|
|
508
|
+
resolve({
|
|
509
|
+
code: exitCode,
|
|
510
|
+
signal: exitSignal,
|
|
511
|
+
stdout: truncate(stdout, this.config.maxOutputChars),
|
|
512
|
+
stderr: truncate(stderr, this.config.maxOutputChars),
|
|
513
|
+
})
|
|
514
|
+
}
|
|
515
|
+
const timer = setTimeout(() => {
|
|
516
|
+
if (settled) return
|
|
517
|
+
exitCode = -1
|
|
518
|
+
exitSignal = 'TIMEOUT'
|
|
519
|
+
// Kill the remote command (SIGTERM) rather than just dropping the
|
|
520
|
+
// channel, so a runaway process cannot keep running and holding
|
|
521
|
+
// the SSH connection after we've given up on its output.
|
|
522
|
+
try {
|
|
523
|
+
if (typeof stream.signal === 'function') stream.signal('SIGTERM')
|
|
524
|
+
} catch {}
|
|
525
|
+
const hardClose = setTimeout(() => {
|
|
526
|
+
try { stream.close() } catch {}
|
|
527
|
+
}, 800)
|
|
528
|
+
if (typeof hardClose.unref === 'function') hardClose.unref()
|
|
529
|
+
settle()
|
|
530
|
+
}, timeoutMs)
|
|
531
|
+
stream.on('close', (code, signal) => {
|
|
532
|
+
if (settled) return
|
|
533
|
+
exitCode = code
|
|
534
|
+
exitSignal = signal
|
|
535
|
+
settle()
|
|
536
|
+
})
|
|
537
|
+
stream.on('data', (d) => {
|
|
538
|
+
if (stdout.length < hardCap) stdout += d
|
|
539
|
+
})
|
|
540
|
+
stream.stderr.on('data', (d) => {
|
|
541
|
+
if (stderr.length < hardCap) stderr += d
|
|
542
|
+
})
|
|
543
|
+
stream.on('error', (e) => {
|
|
544
|
+
if (settled) return
|
|
545
|
+
settled = true
|
|
546
|
+
clearTimeout(timer)
|
|
547
|
+
reject(new Error('ssh stream error: ' + ((e && e.message) || e)))
|
|
548
|
+
})
|
|
549
|
+
})
|
|
550
|
+
}
|
|
551
|
+
runOn(client)
|
|
797
552
|
}),
|
|
798
553
|
)
|
|
799
554
|
}
|
|
800
555
|
|
|
801
|
-
/** Resolve a promisified SFTP client
|
|
802
|
-
* All path arguments are normalized via toSftpPath() so Windows remotes
|
|
803
|
-
* (drive-letter paths) work too. */
|
|
556
|
+
/** Resolve a promisified SFTP client. All paths normalized via toSftpPath(). */
|
|
804
557
|
sftp() {
|
|
805
558
|
return this.connect().then(
|
|
806
559
|
(client) =>
|
|
807
560
|
new Promise((resolve, reject) => {
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
561
|
+
let retried = false
|
|
562
|
+
const runOn = (c) => {
|
|
563
|
+
c.sftp((err, sftp) => {
|
|
564
|
+
if (err) {
|
|
565
|
+
// Same dead-connection recovery as exec(): a channel open
|
|
566
|
+
// failure means the pooled connection is stale — drop it and
|
|
567
|
+
// retry ONCE on a fresh connection.
|
|
568
|
+
if (!retried && /channel open failure|open failed/i.test(String((err && err.message) || err))) {
|
|
569
|
+
retried = true
|
|
570
|
+
this.invalidate()
|
|
571
|
+
return this.connect().then(
|
|
572
|
+
(fresh) => runOn(fresh),
|
|
573
|
+
(e2) => reject(new Error('ssh sftp failed (reconnect): ' + ((e2 && e2.message) || e2))),
|
|
574
|
+
)
|
|
575
|
+
}
|
|
576
|
+
return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
|
|
823
577
|
}
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
578
|
+
const withTimeout = (fn) => (...args) =>
|
|
579
|
+
new Promise((r2, j2) => {
|
|
580
|
+
const timer = setTimeout(() => j2(new Error('sftp operation timed out')), this.config.commandTimeoutMs)
|
|
581
|
+
const done = (e, v) => {
|
|
582
|
+
clearTimeout(timer)
|
|
583
|
+
e ? j2(e) : r2(v)
|
|
584
|
+
}
|
|
585
|
+
try { fn(...args, done) } catch (e) { clearTimeout(timer); j2(e) }
|
|
586
|
+
})
|
|
587
|
+
const P = (p) => toSftpPath(p)
|
|
588
|
+
resolve({
|
|
589
|
+
readdir: (dir) => withTimeout((d, cb) => sftp.readdir(d, cb))(P(dir)),
|
|
590
|
+
stat: (p) => withTimeout((d, cb) => sftp.stat(d, cb))(P(p)),
|
|
591
|
+
lstat: (p) => withTimeout((d, cb) => sftp.lstat(d, cb))(P(p)),
|
|
592
|
+
mkdir: (dir) => withTimeout((d, cb) => sftp.mkdir(d, cb))(P(dir)),
|
|
593
|
+
rmdir: (dir) => withTimeout((d, cb) => sftp.rmdir(d, cb))(P(dir)),
|
|
594
|
+
unlink: (p) => withTimeout((d, cb) => sftp.unlink(d, cb))(P(p)),
|
|
595
|
+
rename: (p, d) => withTimeout((a, b, cb) => sftp.rename(a, b, cb))(P(p), P(d)),
|
|
596
|
+
realpath: (p) => withTimeout((d, cb) => sftp.realpath(d, cb))(P(p)),
|
|
597
|
+
readFile: (p) => withTimeout((d, cb) => sftp.readFile(d, cb))(P(p)),
|
|
598
|
+
writeFile: (p, data) => withTimeout((d, data2, cb) => sftp.writeFile(d, data2, cb))(P(p), data),
|
|
599
|
+
fastGet: (p, lp) => withTimeout((d, l, cb) => sftp.fastGet(d, l, cb))(P(p), lp),
|
|
600
|
+
fastPut: (p, lp) => withTimeout((d, l, cb) => sftp.fastPut(d, l, cb))(P(p), lp),
|
|
601
|
+
})
|
|
602
|
+
})
|
|
603
|
+
}
|
|
604
|
+
runOn(client)
|
|
828
605
|
}),
|
|
829
606
|
)
|
|
830
607
|
}
|
|
831
608
|
|
|
609
|
+
/**
|
|
610
|
+
* Drop the cached client and force a fresh connection on the next call.
|
|
611
|
+
* Called when a channel open fails (e.g. "Channel open failure: open
|
|
612
|
+
* failed") — the pooled SSH connection is usually dead server-side while
|
|
613
|
+
* keepalive has not yet noticed, and reusing it keeps failing. The epoch
|
|
614
|
+
* bump orphans any in-flight connect; the client is ended so ssh2 frees
|
|
615
|
+
* its sockets.
|
|
616
|
+
*/
|
|
617
|
+
invalidate() {
|
|
618
|
+
this.epoch++
|
|
619
|
+
const client = this.client
|
|
620
|
+
this.client = null
|
|
621
|
+
const pending = this.connecting
|
|
622
|
+
this.connecting = null
|
|
623
|
+
if (pending && typeof pending.catch === 'function') {
|
|
624
|
+
try { pending.catch(() => {}) } catch {}
|
|
625
|
+
}
|
|
626
|
+
if (this.proxyPool) {
|
|
627
|
+
try { this.proxyPool.close() } catch {}
|
|
628
|
+
this.proxyPool = null
|
|
629
|
+
}
|
|
630
|
+
if (client) {
|
|
631
|
+
try { client.end() } catch {}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
832
635
|
close() {
|
|
833
|
-
// Bump the epoch so any in-flight connect is orphaned, drop the pending
|
|
834
|
-
// promise (its rejection becomes nobody's problem — swallow it), and end
|
|
835
|
-
// the live client if there is one.
|
|
836
636
|
this.epoch++
|
|
837
637
|
const client = this.client
|
|
838
638
|
this.client = null
|
|
@@ -841,67 +641,218 @@ class SshPool {
|
|
|
841
641
|
if (pending && typeof pending.catch === 'function') {
|
|
842
642
|
try { pending.catch(() => {}) } catch {}
|
|
843
643
|
}
|
|
644
|
+
if (this.proxyPool) {
|
|
645
|
+
try { this.proxyPool.close() } catch {}
|
|
646
|
+
this.proxyPool = null
|
|
647
|
+
}
|
|
844
648
|
if (client) {
|
|
845
649
|
try {
|
|
846
650
|
client.end()
|
|
847
651
|
} catch {}
|
|
848
652
|
}
|
|
653
|
+
if (this.onCloseHook) {
|
|
654
|
+
try { this.onCloseHook() } catch {}
|
|
655
|
+
}
|
|
849
656
|
}
|
|
850
657
|
}
|
|
851
658
|
|
|
852
|
-
// ──
|
|
659
|
+
// ── encoding helpers ───────────────────────────────────────────────────────
|
|
660
|
+
|
|
661
|
+
function decodeBuf(buf, enc) {
|
|
662
|
+
const e = enc && !/^utf-?8$/i.test(String(enc)) ? String(enc).toLowerCase() : null
|
|
663
|
+
return e ? iconv.decode(buf, e) : buf.toString('utf8')
|
|
664
|
+
}
|
|
665
|
+
function encodeText(s, enc) {
|
|
666
|
+
const e = enc && !/^utf-?8$/i.test(String(enc)) ? String(enc).toLowerCase() : null
|
|
667
|
+
return e ? iconv.encode(String(s), e) : Buffer.from(String(s), 'utf8')
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// ── apply ─────────────────────────────────────────────────────────────────
|
|
853
671
|
|
|
854
672
|
export async function apply(ctx, config) {
|
|
855
673
|
const pool = new SshPool(config)
|
|
856
674
|
ctx.effect(() => () => pool.close(), 'dsh-remote.close')
|
|
857
675
|
|
|
858
|
-
// Bring any pre-0.6 data (~/.dsh/remote-workspaces) into the DSH_HOME-based
|
|
859
|
-
// location so existing machines + mirrors keep working after the move.
|
|
860
676
|
migrateLegacyData()
|
|
861
677
|
|
|
862
678
|
// ── machine registry (multi-host) ─────────────────────────────────────────
|
|
863
679
|
const store = loadMachines()
|
|
864
680
|
const machines = store.list
|
|
865
681
|
const machineIndex = (id) => machines.findIndex((m) => m.id === id)
|
|
866
|
-
|
|
682
|
+
|
|
683
|
+
/** The machine the pool is currently bound to: an ephemeral tool connection
|
|
684
|
+
* (rw_connect save:false) wins, then the stored current, then config default. */
|
|
685
|
+
let ephemeral = null
|
|
686
|
+
const activeMachine = () => {
|
|
687
|
+
if (ephemeral) return ephemeral
|
|
867
688
|
if (store.currentId) {
|
|
868
689
|
const i = machineIndex(store.currentId)
|
|
869
690
|
if (i >= 0) return machines[i]
|
|
870
691
|
}
|
|
871
|
-
// fall back to config-derived default (host set via cordis config)
|
|
872
692
|
if (config.host) return { id: machineId(), name: config.host, host: config.host, port: config.port, username: config.username, password: config.password, privateKeyPath: config.privateKeyPath, passphrase: config.passphrase }
|
|
873
693
|
return null
|
|
874
694
|
}
|
|
875
|
-
const
|
|
695
|
+
const currentMachine = activeMachine
|
|
696
|
+
|
|
697
|
+
/** Resolve a machine's effective password (keychain backend support). */
|
|
698
|
+
const machinePassword = async (m) => {
|
|
699
|
+
if (m && m.password) return m.password
|
|
700
|
+
if (m && m.credentialBackend && m.credentialBackend !== 'plain') {
|
|
701
|
+
const p = await getSecret(m.id, secretsDir())
|
|
702
|
+
if (p) return p
|
|
703
|
+
}
|
|
704
|
+
return ''
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// The pool resolves keychain-stored passwords lazily at connect time.
|
|
708
|
+
pool.passwordResolver = async () => {
|
|
709
|
+
const m = activeMachine()
|
|
710
|
+
return machinePassword(m)
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const applyActiveMachine = async () => {
|
|
714
|
+
const m = activeMachine()
|
|
715
|
+
if (!m || !m.host) return
|
|
716
|
+
const pw = await machinePassword(m)
|
|
717
|
+
applyMachine(config, { ...m, password: pw || m.password || '' })
|
|
718
|
+
pool.setTarget({
|
|
719
|
+
host: config.host, port: config.port, username: config.username,
|
|
720
|
+
password: config.password, privateKeyPath: config.privateKeyPath,
|
|
721
|
+
passphrase: config.passphrase, workspace: config.workspace,
|
|
722
|
+
useAgent: config.useAgent, keyboardInteractive: config.keyboardInteractive,
|
|
723
|
+
proxy: config.proxy, hostKeyMode: config.hostKeyMode,
|
|
724
|
+
})
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const setCurrent = async (id) => {
|
|
876
728
|
const i = machineIndex(id)
|
|
877
729
|
if (i < 0) return false
|
|
878
730
|
store.currentId = id
|
|
731
|
+
ephemeral = null
|
|
879
732
|
saveMachines(machines, id)
|
|
880
|
-
|
|
881
|
-
pool.setTarget({ host: config.host, port: config.port, username: config.username, password: config.password, privateKeyPath: config.privateKeyPath, passphrase: config.passphrase })
|
|
733
|
+
await applyActiveMachine()
|
|
882
734
|
return true
|
|
883
735
|
}
|
|
736
|
+
|
|
884
737
|
// If no stored current, adopt a CLI-provided default as the active machine.
|
|
885
738
|
{
|
|
886
739
|
const cur = currentMachine()
|
|
887
|
-
if (cur && cur.host) applyMachine(config, cur)
|
|
740
|
+
if (cur && cur.host && !store.currentId) applyMachine(config, cur)
|
|
888
741
|
}
|
|
889
742
|
|
|
890
|
-
/**
|
|
891
|
-
*
|
|
743
|
+
/** Persist the active workspace on the machine the pool is actually bound to
|
|
744
|
+
* (fixes the old bug where a tool-connected machine saved its workspace onto
|
|
745
|
+
* the registry's current machine instead). */
|
|
892
746
|
const persistWorkspace = (p) => {
|
|
893
747
|
config.workspace = p
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
if (
|
|
897
|
-
machines[
|
|
748
|
+
const m = activeMachine()
|
|
749
|
+
if (m) {
|
|
750
|
+
if (store.currentId && m.id === store.currentId && machineIndex(m.id) >= 0) {
|
|
751
|
+
const rec = machines[machineIndex(m.id)]
|
|
752
|
+
rec.workspace = p
|
|
753
|
+
rec.recentWorkspaces = [p, ...(rec.recentWorkspaces || []).filter((x) => x !== p)].slice(0, 8)
|
|
898
754
|
saveMachines(machines, store.currentId)
|
|
755
|
+
} else if (ephemeral) {
|
|
756
|
+
ephemeral.workspace = p
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// ── audit log ─────────────────────────────────────────────────────────────
|
|
762
|
+
const audit = (op, cmd, code) => {
|
|
763
|
+
if (!config.auditLog) return
|
|
764
|
+
try {
|
|
765
|
+
const line = [new Date().toISOString(), `${config.username || '?'}@${config.host || '?'}:${config.port}`, op, code == null ? '-' : String(code), String(cmd || '').replace(/\s+/g, ' ').slice(0, 400)].join(' | ') + '\n'
|
|
766
|
+
appendFileSync(auditFile(), line, 'utf8')
|
|
767
|
+
} catch {}
|
|
768
|
+
}
|
|
769
|
+
const readAudit = (limit) => {
|
|
770
|
+
try {
|
|
771
|
+
const text = readFileSync(auditFile(), 'utf8')
|
|
772
|
+
const lines = text.split('\n').filter(Boolean)
|
|
773
|
+
return lines.slice(-Math.max(1, Math.min(Number(limit) || 50, 500)))
|
|
774
|
+
} catch {
|
|
775
|
+
return []
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// ── ignore rules (defaults + user file) ───────────────────────────────────
|
|
780
|
+
const ignoreMatcher = () => {
|
|
781
|
+
try {
|
|
782
|
+
const fromFile = readFileSync(ignoreFile(), 'utf8').split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'))
|
|
783
|
+
return compileIgnore(DEFAULT_IGNORE.concat(fromFile))
|
|
784
|
+
} catch {
|
|
785
|
+
return compileIgnore(DEFAULT_IGNORE)
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// ── task + forward managers ───────────────────────────────────────────────
|
|
790
|
+
const tasks = new TaskManager()
|
|
791
|
+
const forwards = new ForwardManager(pool, { file: forwardsFile() })
|
|
792
|
+
pool.onReady = (client) => forwards.attach(client)
|
|
793
|
+
pool.onCloseHook = () => forwards.detach()
|
|
794
|
+
|
|
795
|
+
// ── auto-push watcher (config.autoPush, default off) ──────────────────────
|
|
796
|
+
// Watches the local mirror; local edits are pushed back to the remote after a
|
|
797
|
+
// 3s debounce, honoring ignore rules + the three-way conflict guard (a remote
|
|
798
|
+
// change is never clobbered — it is recorded as a conflict in the audit log).
|
|
799
|
+
const autoPushWatchers = new Map() // localDir → { watcher, pending:Set, timer }
|
|
800
|
+
const flushAutoPush = async (localDir) => {
|
|
801
|
+
const entry = autoPushWatchers.get(localDir)
|
|
802
|
+
if (!entry || !entry.pending.size) return
|
|
803
|
+
const rels = [...entry.pending]
|
|
804
|
+
entry.pending.clear()
|
|
805
|
+
const ws = wsPath()
|
|
806
|
+
if (!ws || mirrorDirFor(ws, config.host, config.username, config.port) !== localDir) return
|
|
807
|
+
let sftp
|
|
808
|
+
try { sftp = await pool.sftp() } catch { return }
|
|
809
|
+
const matcher = ignoreMatcher()
|
|
810
|
+
const state = loadSyncState(localDir)
|
|
811
|
+
const next = { ...state }
|
|
812
|
+
if (rels.includes('*')) {
|
|
813
|
+
const r = await pushTree(sftp, localDir, ws, { maxFiles: 500, maxFileBytes: config.maxFileBytes, isIgnored: matcher, state: next })
|
|
814
|
+
Object.assign(next, r.nextState)
|
|
815
|
+
for (const c of r.stats.conflicts) audit('auto-push-conflict', `push ${c.path}`, 1)
|
|
816
|
+
} else {
|
|
817
|
+
for (const rel of rels) {
|
|
818
|
+
const r = await pushOneFile(sftp, localDir, ws, rel, { maxFileBytes: config.maxFileBytes, isIgnored: matcher, state: next })
|
|
819
|
+
if (r.status === 'pushed' && r.state) Object.assign(next, r.state)
|
|
820
|
+
else if (r.status === 'conflict') audit('auto-push-conflict', `push ${rel}`, 1)
|
|
899
821
|
}
|
|
900
822
|
}
|
|
823
|
+
saveSyncState(localDir, next)
|
|
901
824
|
}
|
|
825
|
+
const startAutoPush = (localDir) => {
|
|
826
|
+
if (!config.autoPush || autoPushWatchers.has(localDir)) return
|
|
827
|
+
const entry = { pending: new Set(), timer: null, watcher: null }
|
|
828
|
+
const schedule = () => {
|
|
829
|
+
if (entry.timer) clearTimeout(entry.timer)
|
|
830
|
+
entry.timer = setTimeout(() => flushAutoPush(localDir), 3000)
|
|
831
|
+
}
|
|
832
|
+
const onEvent = (eventType, filename) => {
|
|
833
|
+
if (!filename) { entry.pending.add('*'); return schedule() }
|
|
834
|
+
const rel = String(filename).replace(/\\/g, '/')
|
|
835
|
+
if (rel === '.dsh-remote-meta.json' || rel === '.dsh-remote-sync-state.json' || rel.startsWith('.dsh-remote-sync-state.json.tmp')) return
|
|
836
|
+
entry.pending.add(rel)
|
|
837
|
+
schedule()
|
|
838
|
+
}
|
|
839
|
+
try {
|
|
840
|
+
entry.watcher = watch(localDir, { recursive: true }, onEvent)
|
|
841
|
+
} catch {
|
|
842
|
+
try { entry.watcher = watch(localDir, onEvent) } catch { return }
|
|
843
|
+
}
|
|
844
|
+
autoPushWatchers.set(localDir, entry)
|
|
845
|
+
}
|
|
846
|
+
ctx.effect(() => () => {
|
|
847
|
+
for (const e of autoPushWatchers.values()) {
|
|
848
|
+
if (e.timer) clearTimeout(e.timer)
|
|
849
|
+
try { e.watcher && e.watcher.close() } catch {}
|
|
850
|
+
}
|
|
851
|
+
autoPushWatchers.clear()
|
|
852
|
+
}, 'dsh-remote.autopush')
|
|
902
853
|
|
|
903
854
|
const run = async (cmd, opts = {}) => {
|
|
904
|
-
const res = await pool.exec(cmd, opts.timeoutMs)
|
|
855
|
+
const res = await pool.exec(cmd, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {})
|
|
905
856
|
const parts = []
|
|
906
857
|
if (res.stdout) parts.push(res.stdout.replace(/\s+$/, ''))
|
|
907
858
|
if (res.stderr) parts.push('-- stderr --\n' + res.stderr.replace(/\s+$/, ''))
|
|
@@ -912,13 +863,9 @@ export async function apply(ctx, config) {
|
|
|
912
863
|
return text
|
|
913
864
|
}
|
|
914
865
|
|
|
915
|
-
/** Structured
|
|
916
|
-
*
|
|
917
|
-
|
|
918
|
-
* macOS, or Windows (cmd.exe / PowerShell) — with no dependency on a POSIX
|
|
919
|
-
* shell or `ls`. Entry types come straight from SFTP attrs; symlinks are
|
|
920
|
-
* resolved with one bounded lstat (if it fails they degrade to files). */
|
|
921
|
-
const listDirStructured = async (p, timeoutMs) => {
|
|
866
|
+
/** Structured listing: name + type + size + mtime + mode (SFTP protocol-level,
|
|
867
|
+
* works on any remote: POSIX / cmd.exe / PowerShell). */
|
|
868
|
+
const listDirStructured = async (p) => {
|
|
922
869
|
const target = normalizeRemotePath(p || '/')
|
|
923
870
|
let sftp
|
|
924
871
|
try {
|
|
@@ -926,7 +873,7 @@ export async function apply(ctx, config) {
|
|
|
926
873
|
} catch (err) {
|
|
927
874
|
throw new Error('browse failed: ' + ((err && err.message) || err))
|
|
928
875
|
}
|
|
929
|
-
let list
|
|
876
|
+
let list
|
|
930
877
|
try {
|
|
931
878
|
list = await sftp.readdir(target)
|
|
932
879
|
} catch (err) {
|
|
@@ -938,14 +885,22 @@ export async function apply(ctx, config) {
|
|
|
938
885
|
const name = String(e.filename)
|
|
939
886
|
if (name === '.' || name === '..' || !name) continue
|
|
940
887
|
const a = e.attrs || {}
|
|
888
|
+
let type
|
|
941
889
|
if (a.isSymbolicLink && a.isSymbolicLink()) {
|
|
942
|
-
|
|
943
|
-
symIdx.push(items.length
|
|
890
|
+
type = 'symlink'
|
|
891
|
+
symIdx.push(items.length)
|
|
944
892
|
} else if (a.isDirectory && a.isDirectory()) {
|
|
945
|
-
|
|
893
|
+
type = 'dir'
|
|
946
894
|
} else {
|
|
947
|
-
|
|
895
|
+
type = 'file'
|
|
948
896
|
}
|
|
897
|
+
items.push({
|
|
898
|
+
type,
|
|
899
|
+
name,
|
|
900
|
+
size: typeof a.size === 'number' ? a.size : 0,
|
|
901
|
+
mtime: typeof a.mtime === 'number' ? a.mtime : 0,
|
|
902
|
+
mode: typeof a.mode === 'number' ? a.mode.toString(8) : '',
|
|
903
|
+
})
|
|
949
904
|
}
|
|
950
905
|
// Resolve symlink-to-dir vs symlink-to-file (bounded, failure-tolerant).
|
|
951
906
|
if (symIdx.length) {
|
|
@@ -960,9 +915,6 @@ export async function apply(ctx, config) {
|
|
|
960
915
|
return { path: target, items }
|
|
961
916
|
}
|
|
962
917
|
|
|
963
|
-
/** Verify a remote path is an existing directory, via SFTP stat (works on
|
|
964
|
-
* any remote shell: POSIX sh, cmd.exe, PowerShell). Returns false when the
|
|
965
|
-
* path is missing, is a file, or the stat errors. */
|
|
966
918
|
const isRemoteDir = async (p) => {
|
|
967
919
|
const target = normalizeRemotePath(p)
|
|
968
920
|
try {
|
|
@@ -974,9 +926,35 @@ export async function apply(ctx, config) {
|
|
|
974
926
|
}
|
|
975
927
|
}
|
|
976
928
|
|
|
977
|
-
|
|
929
|
+
/** Recursive remote delete (bounded): unlink files bottom-up, then rmdir. */
|
|
930
|
+
const removeRemoteTree = async (sftp, p, maxFiles = 2000) => {
|
|
931
|
+
let removed = 0
|
|
932
|
+
const walk = async (dir) => {
|
|
933
|
+
if (removed >= maxFiles) return
|
|
934
|
+
let entries = []
|
|
935
|
+
try { entries = (await sftp.readdir(dir)) || [] } catch { return }
|
|
936
|
+
for (const e of entries) {
|
|
937
|
+
if (removed >= maxFiles) return
|
|
938
|
+
const name = String(e.filename)
|
|
939
|
+
if (name === '.' || name === '..') continue
|
|
940
|
+
const fp = joinRemotePath(dir, name)
|
|
941
|
+
const isDir = !!(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory())
|
|
942
|
+
if (isDir) await walk(fp)
|
|
943
|
+
else {
|
|
944
|
+
try { await sftp.unlink(fp); removed++ } catch {}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
try { await sftp.rmdir(dir); removed++ } catch { /* already gone or non-empty */ }
|
|
948
|
+
}
|
|
949
|
+
await walk(p)
|
|
950
|
+
return removed
|
|
951
|
+
}
|
|
978
952
|
|
|
953
|
+
// ── remote workspace state ────────────────────────────────────────────────
|
|
979
954
|
const wsPath = () => (config.workspace || '').trim()
|
|
955
|
+
if (config.autoPush && wsPath()) {
|
|
956
|
+
startAutoPush(mirrorDirFor(wsPath(), config.host, config.username, config.port))
|
|
957
|
+
}
|
|
980
958
|
const status = () => ({
|
|
981
959
|
host: config.host,
|
|
982
960
|
port: config.port,
|
|
@@ -985,46 +963,52 @@ export async function apply(ctx, config) {
|
|
|
985
963
|
workspace: wsPath(),
|
|
986
964
|
localMirror: wsPath() ? mirrorDirFor(wsPath(), config.host, config.username, config.port) : '',
|
|
987
965
|
currentId: store.currentId || null,
|
|
966
|
+
activeSource: ephemeral ? 'ephemeral' : (store.currentId ? 'machine' : (config.host ? 'config' : 'none')),
|
|
988
967
|
machines: machines.map(sanitizeMachine),
|
|
989
968
|
hostKeyMode: config.hostKeyMode === 'verify' || config.hostKeyMode === 'off' ? config.hostKeyMode : 'accept-new',
|
|
990
969
|
hostKeyKnown: config.host ? isHostKeyKnown(config.host, config.port) : false,
|
|
970
|
+
forwards: forwards.list(),
|
|
971
|
+
auditEnabled: !!config.auditLog,
|
|
972
|
+
backend: platformBackend(),
|
|
991
973
|
})
|
|
992
974
|
|
|
993
975
|
// ── tools ─────────────────────────────────────────────────────────────────
|
|
994
976
|
|
|
995
|
-
const
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
}
|
|
977
|
+
const textOut = {
|
|
978
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
979
|
+
render: (_a, v) => [{ type: 'text', text: v.text }],
|
|
980
|
+
}
|
|
981
|
+
const okOut = {
|
|
982
|
+
schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, bytes: { type: 'integer' }, text: { type: 'string' } } },
|
|
983
|
+
render: (_a, a) => [{ type: 'text', text: a.text || (a.ok ? 'ok' : 'failed') }],
|
|
984
|
+
}
|
|
999
985
|
|
|
1000
986
|
const tools = [
|
|
1001
987
|
defineTool({
|
|
1002
988
|
name: 'rw_info',
|
|
1003
989
|
description:
|
|
1004
|
-
'Show the remote environment: host/user/port, connection health,
|
|
990
|
+
'Show the remote environment: host/user/port, connection health, current remote workspace path, active port forwards. Call this first to orient, or when an rw_* call fails to check connectivity.',
|
|
1005
991
|
parameters: {},
|
|
1006
|
-
output:
|
|
1007
|
-
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
1008
|
-
render: (_a, v) => [{ type: 'text', text: v.text }],
|
|
1009
|
-
},
|
|
992
|
+
output: textOut,
|
|
1010
993
|
async execute() {
|
|
1011
994
|
const s = status()
|
|
1012
995
|
const lines = [
|
|
1013
|
-
`Remote host: ${s.username || '<user>'}@${s.host || '<host>'}:${s.port}`,
|
|
996
|
+
`Remote host: ${s.username || '<user>'}@${s.host || '<host>'}:${s.port}${s.activeSource !== 'machine' ? ` (source: ${s.activeSource})` : ''}`,
|
|
1014
997
|
`Current remote workspace: ${s.workspace || '(none — call rw_pick_workspace to set one)'}`,
|
|
1015
998
|
`Local mirror: ${s.localMirror || '(none)'}`,
|
|
1016
999
|
`Connected: ${s.connected ? 'yes' : 'no'}`,
|
|
1000
|
+
`Active forwards: ${s.forwards.filter((f) => f.active).length} / ${s.forwards.length}`,
|
|
1001
|
+
`Host key: ${s.hostKeyKnown ? 'trusted' : 'not yet trusted'} (mode=${s.hostKeyMode})`,
|
|
1017
1002
|
'',
|
|
1018
1003
|
]
|
|
1019
1004
|
if (s.host && s.workspace) {
|
|
1020
1005
|
try {
|
|
1021
|
-
|
|
1022
|
-
const res = await pool.exec('echo ok', Math.min(config.commandTimeoutMs, 8000))
|
|
1006
|
+
const res = await pool.exec('echo ok', { timeoutMs: Math.min(config.commandTimeoutMs, 8000) })
|
|
1023
1007
|
if (res.signal === 'TIMEOUT') lines.push('Ping: timeout')
|
|
1024
1008
|
else if (res.code === 0) lines.push('Ping: OK — ' + res.stdout.replace(/\s+/g, ' ').trim())
|
|
1025
1009
|
else lines.push('Ping: FAILED — ' + (res.stderr || res.stdout || `exit ${res.code}`).trim())
|
|
1026
1010
|
} catch (err) {
|
|
1027
|
-
lines.push('Ping: FAILED — ' + (
|
|
1011
|
+
lines.push('Ping: FAILED — ' + friendlyMessage(err, s))
|
|
1028
1012
|
}
|
|
1029
1013
|
} else {
|
|
1030
1014
|
lines.push('No host + workspace configured — call rw_connect with a host to get started.')
|
|
@@ -1036,34 +1020,62 @@ export async function apply(ctx, config) {
|
|
|
1036
1020
|
defineTool({
|
|
1037
1021
|
name: 'rw_connect',
|
|
1038
1022
|
description:
|
|
1039
|
-
'Connect SSH to a remote host for remote workspace work. Provide host (required), user, optional password or privateKeyPath/port. Once connected, call rw_pick_workspace to pick the workspace directory this session should work in.',
|
|
1023
|
+
'Connect SSH to a remote host for remote workspace work. Provide host (required), user, optional password or privateKeyPath/port. Defaults to saving the machine to the registry (save=false keeps it as a temporary connection). Once connected, call rw_pick_workspace to pick the workspace directory this session should work in.',
|
|
1040
1024
|
parameters: {
|
|
1041
1025
|
host: { type: 'string', required: true, description: 'Remote host IP or hostname' },
|
|
1042
1026
|
username: { type: 'string', description: 'SSH user (default from config or root)' },
|
|
1043
1027
|
port: { type: 'integer', description: 'SSH port (default 22)' },
|
|
1044
1028
|
password: { type: 'string', description: 'SSH password (prefer SSH key when possible)' },
|
|
1045
1029
|
privateKeyPath: { type: 'string', description: 'Absolute private-key path' },
|
|
1030
|
+
save: { type: 'boolean', description: 'Save this machine to the registry and make it current (default true)' },
|
|
1046
1031
|
},
|
|
1047
|
-
output:
|
|
1048
|
-
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
1049
|
-
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
1050
|
-
},
|
|
1032
|
+
output: textOut,
|
|
1051
1033
|
async execute(args) {
|
|
1052
1034
|
const host = String(args.host || '').trim()
|
|
1053
1035
|
if (!host) throw new Error('rw_connect: host is required')
|
|
1054
|
-
|
|
1036
|
+
const user = args.username || config.username || 'root'
|
|
1037
|
+
const port = Number(args.port) || undefined
|
|
1038
|
+
const rec = {
|
|
1055
1039
|
host,
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
password: args.password !== undefined ? args.password :
|
|
1059
|
-
privateKeyPath: args.privateKeyPath ||
|
|
1060
|
-
}
|
|
1040
|
+
port: port || 22,
|
|
1041
|
+
username: user,
|
|
1042
|
+
password: args.password !== undefined ? String(args.password) : '',
|
|
1043
|
+
privateKeyPath: args.privateKeyPath || '',
|
|
1044
|
+
}
|
|
1045
|
+
if (args.save !== false) {
|
|
1046
|
+
// Upsert into the registry and make it the current machine so the
|
|
1047
|
+
// settings UI and the tools always agree on who is active.
|
|
1048
|
+
const i = machines.findIndex((m) => m.host === rec.host && m.username === rec.username && Number(m.port) === rec.port)
|
|
1049
|
+
if (i >= 0) {
|
|
1050
|
+
machines[i] = { ...machines[i], ...rec, id: machines[i].id, password: rec.password || machines[i].password || '' }
|
|
1051
|
+
store.currentId = machines[i].id
|
|
1052
|
+
saveMachines(machines, store.currentId)
|
|
1053
|
+
} else {
|
|
1054
|
+
const id = machineId()
|
|
1055
|
+
machines.push({ id, name: host, ...rec })
|
|
1056
|
+
store.currentId = id
|
|
1057
|
+
saveMachines(machines, store.currentId)
|
|
1058
|
+
}
|
|
1059
|
+
ephemeral = null
|
|
1060
|
+
await applyActiveMachine()
|
|
1061
|
+
} else {
|
|
1062
|
+
ephemeral = { id: machineId(), name: host, ...rec }
|
|
1063
|
+
pool.setTarget({
|
|
1064
|
+
host, port: port || 22, username: user,
|
|
1065
|
+
password: rec.password, privateKeyPath: rec.privateKeyPath,
|
|
1066
|
+
workspace: config.workspace,
|
|
1067
|
+
})
|
|
1068
|
+
}
|
|
1061
1069
|
try {
|
|
1062
|
-
const res = await pool.exec('echo ok', 8000)
|
|
1063
|
-
if (res.code !== 0 && !res.stdout)
|
|
1070
|
+
const res = await pool.exec('echo ok', { timeoutMs: 8000 })
|
|
1071
|
+
if (res.code !== 0 && !res.stdout) {
|
|
1072
|
+
audit('connect', `connect ${user}@${host}:${port || 22}`, res.code)
|
|
1073
|
+
return { text: 'connect failed: ' + (res.stderr || 'exit ' + res.code) }
|
|
1074
|
+
}
|
|
1075
|
+
audit('connect', `connect ${user}@${host}:${port || 22}`, 0)
|
|
1064
1076
|
return { text: `Connected to ${host} as ${config.username}.\n\npick a workspace with rw_pick_workspace (path=<abs>).` }
|
|
1065
1077
|
} catch (err) {
|
|
1066
|
-
throw err
|
|
1078
|
+
throw new Error(friendlyMessage(err, { host, port }))
|
|
1067
1079
|
}
|
|
1068
1080
|
},
|
|
1069
1081
|
}),
|
|
@@ -1075,10 +1087,7 @@ export async function apply(ctx, config) {
|
|
|
1075
1087
|
parameters: {
|
|
1076
1088
|
path: { type: 'string', required: true, description: 'Absolute remote directory path, e.g. /home/dev/code/project' },
|
|
1077
1089
|
},
|
|
1078
|
-
output:
|
|
1079
|
-
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
1080
|
-
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
1081
|
-
},
|
|
1090
|
+
output: textOut,
|
|
1082
1091
|
async execute(args) {
|
|
1083
1092
|
const p = normalizeRemotePath(String(args.path || ''))
|
|
1084
1093
|
if (!p || p === '/') throw new Error('rw_pick_workspace: path must be an absolute directory')
|
|
@@ -1086,6 +1095,7 @@ export async function apply(ctx, config) {
|
|
|
1086
1095
|
if (!ok) return { text: `not a directory (or missing) on ${p}` }
|
|
1087
1096
|
persistWorkspace(p)
|
|
1088
1097
|
const local = ensureMirror(p, config.host, config.username, config.port)
|
|
1098
|
+
startAutoPush(local)
|
|
1089
1099
|
return {
|
|
1090
1100
|
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.`,
|
|
1091
1101
|
}
|
|
@@ -1095,15 +1105,15 @@ export async function apply(ctx, config) {
|
|
|
1095
1105
|
defineTool({
|
|
1096
1106
|
name: 'rw_sync',
|
|
1097
1107
|
description:
|
|
1098
|
-
'Download the current remote workspace into its local mirror directory over SFTP (bounded). Makes the remote files visible/editable locally so the DSH native workspace / fs tools can operate on them.',
|
|
1108
|
+
'Download the current remote workspace into its local mirror directory over SFTP (bounded, three-way conflict-aware). Makes the remote files visible/editable locally so the DSH native workspace / fs tools can operate on them. Conflicts (both sides modified) are reported and never overwritten; use force=true to override.',
|
|
1099
1109
|
parameters: {
|
|
1100
1110
|
depth: { type: 'integer', description: 'Max directory depth to mirror (default 5)' },
|
|
1101
1111
|
maxFiles: { type: 'integer', description: 'Max files to download (default 500)' },
|
|
1112
|
+
dryRun: { type: 'boolean', description: 'Compute the plan without downloading (default false)' },
|
|
1113
|
+
force: { type: 'boolean', description: 'Overwrite conflicting files (default false)' },
|
|
1114
|
+
async: { type: 'boolean', description: 'Run in the background and return a task id (default false)' },
|
|
1102
1115
|
},
|
|
1103
|
-
output:
|
|
1104
|
-
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
1105
|
-
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
1106
|
-
},
|
|
1116
|
+
output: textOut,
|
|
1107
1117
|
async execute(args) {
|
|
1108
1118
|
const p = wsPath()
|
|
1109
1119
|
if (!p) throw new Error('rw_sync: no remote workspace set — call rw_pick_workspace first')
|
|
@@ -1111,67 +1121,98 @@ export async function apply(ctx, config) {
|
|
|
1111
1121
|
mkdirSync(local, { recursive: true })
|
|
1112
1122
|
const depth = Math.min(Math.max(Number(args.depth) || 5, 1), 8)
|
|
1113
1123
|
const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 500, 1), 2000)
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1124
|
+
const isIgnored = ignoreMatcher()
|
|
1125
|
+
const body = { depth, maxFiles, dryRun: !!args.dryRun, force: !!args.force, isIgnored }
|
|
1126
|
+
const runSync = async () => {
|
|
1127
|
+
let sftp
|
|
1128
|
+
try {
|
|
1129
|
+
sftp = await pool.sftp()
|
|
1130
|
+
} catch (err) {
|
|
1131
|
+
throw new Error('sftp unavailable: ' + ((err && err.message) || err))
|
|
1132
|
+
}
|
|
1133
|
+
const state = loadSyncState(local)
|
|
1134
|
+
const { stats, nextState } = await syncTree(sftp, p, local, { ...body, state, maxFileBytes: config.maxFileBytes })
|
|
1135
|
+
if (!args.dryRun) saveSyncState(local, nextState)
|
|
1136
|
+
let text = `${args.dryRun ? 'WOULD download' : 'Downloaded'} ${stats.files} file(s) from ${p} → ${local}${stats.files >= maxFiles ? ' (hit download cap)' : ''}.`
|
|
1137
|
+
if (stats.skippedUnchanged) text += ` ${stats.skippedUnchanged} unchanged.`
|
|
1138
|
+
if (stats.skippedLarge) text += ` ${stats.skippedLarge} too large (over ${config.maxFileBytes} bytes).`
|
|
1139
|
+
if (stats.staleRemote) text += ` ${stats.staleRemote} remote entries gone (kept locally; use rw_push to mirror deletions).`
|
|
1140
|
+
if (stats.conflicts.length) {
|
|
1141
|
+
text += `\n⚠ ${stats.conflicts.length} conflict(s), NOT overwritten:`
|
|
1142
|
+
for (const c of stats.conflicts.slice(0, 10)) text += `\n ${c.path} — ${c.reason}`
|
|
1143
|
+
if (stats.conflicts.length > 10) text += `\n … and ${stats.conflicts.length - 10} more`
|
|
1144
|
+
text += '\n(use force=true to override)'
|
|
1145
|
+
}
|
|
1146
|
+
return { text }
|
|
1119
1147
|
}
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
return
|
|
1148
|
+
if (args.async) {
|
|
1149
|
+
const t = tasks.start('sync', `sync ${p}`, runSync)
|
|
1150
|
+
return { text: `sync started in background: taskId=${t.id} (GET /dsh-remote/task?id=${t.id} for progress)` }
|
|
1151
|
+
}
|
|
1152
|
+
return runSync()
|
|
1125
1153
|
},
|
|
1126
1154
|
}),
|
|
1127
1155
|
|
|
1128
1156
|
defineTool({
|
|
1129
1157
|
name: 'rw_push',
|
|
1130
1158
|
description:
|
|
1131
|
-
'Upload the local mirror of the current remote workspace back to the remote host over SFTP (bounded). Use after editing files in the local mirror so the remote reflects your changes (
|
|
1159
|
+
'Upload the local mirror of the current remote workspace back to the remote host over SFTP (bounded, three-way conflict-aware). Use after editing files in the local mirror so the remote reflects your changes. Conflicts (both sides modified) are reported and never overwritten; use force=true to override.',
|
|
1132
1160
|
parameters: {
|
|
1133
1161
|
maxFiles: { type: 'integer', description: 'Max files to upload (default 500)' },
|
|
1162
|
+
dryRun: { type: 'boolean', description: 'Compute the plan without uploading (default false)' },
|
|
1163
|
+
force: { type: 'boolean', description: 'Overwrite conflicting files (default false)' },
|
|
1164
|
+
async: { type: 'boolean', description: 'Run in the background and return a task id (default false)' },
|
|
1134
1165
|
},
|
|
1135
|
-
output:
|
|
1136
|
-
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
1137
|
-
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
1138
|
-
},
|
|
1166
|
+
output: textOut,
|
|
1139
1167
|
async execute(args) {
|
|
1140
1168
|
const p = wsPath()
|
|
1141
1169
|
if (!p) throw new Error('rw_push: no remote workspace set — call rw_pick_workspace first')
|
|
1142
1170
|
const local = mirrorDirFor(p, config.host, config.username, config.port)
|
|
1143
1171
|
if (!existsSync(local)) throw new Error(`rw_push: local mirror does not exist — run rw_sync first (${local})`)
|
|
1144
1172
|
const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 500, 1), 2000)
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1173
|
+
const isIgnored = ignoreMatcher()
|
|
1174
|
+
const body = { maxFiles, dryRun: !!args.dryRun, force: !!args.force, isIgnored }
|
|
1175
|
+
const runPush = async () => {
|
|
1176
|
+
let sftp
|
|
1177
|
+
try {
|
|
1178
|
+
sftp = await pool.sftp()
|
|
1179
|
+
} catch (err) {
|
|
1180
|
+
throw new Error('sftp unavailable: ' + ((err && err.message) || err))
|
|
1181
|
+
}
|
|
1182
|
+
const state = loadSyncState(local)
|
|
1183
|
+
const { stats, nextState } = await pushTree(sftp, local, p, { ...body, state, maxFileBytes: config.maxFileBytes })
|
|
1184
|
+
if (!args.dryRun) saveSyncState(local, nextState)
|
|
1185
|
+
let text = `${args.dryRun ? 'WOULD upload' : 'Uploaded'} ${stats.files} file(s) from ${local} → ${p}.`
|
|
1186
|
+
if (stats.skippedUnchanged) text += ` ${stats.skippedUnchanged} unchanged.`
|
|
1187
|
+
if (stats.skippedLarge) text += ` ${stats.skippedLarge} too large (over ${config.maxFileBytes} bytes).`
|
|
1188
|
+
if (stats.staleLocal) text += ` ${stats.staleLocal} local entries gone remotely (kept remotely; use rw_remove to mirror deletions).`
|
|
1189
|
+
if (stats.conflicts.length) {
|
|
1190
|
+
text += `\n⚠ ${stats.conflicts.length} conflict(s), NOT overwritten:`
|
|
1191
|
+
for (const c of stats.conflicts.slice(0, 10)) text += `\n ${c.path} — ${c.reason}`
|
|
1192
|
+
if (stats.conflicts.length > 10) text += `\n … and ${stats.conflicts.length - 10} more`
|
|
1193
|
+
text += '\n(use force=true to override)'
|
|
1194
|
+
}
|
|
1195
|
+
return { text }
|
|
1150
1196
|
}
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
return
|
|
1197
|
+
if (args.async) {
|
|
1198
|
+
const t = tasks.start('push', `push ${p}`, runPush)
|
|
1199
|
+
return { text: `push started in background: taskId=${t.id} (GET /dsh-remote/task?id=${t.id} for progress)` }
|
|
1200
|
+
}
|
|
1201
|
+
return runPush()
|
|
1156
1202
|
},
|
|
1157
1203
|
}),
|
|
1158
1204
|
|
|
1159
1205
|
defineTool({
|
|
1160
1206
|
name: 'rw_list_dir',
|
|
1161
1207
|
description:
|
|
1162
|
-
'List a remote directory (or a single file) via SSH. Path is absolute; if omitted, lists the current remote workspace.',
|
|
1208
|
+
'List a remote directory (or a single file) via SSH. Path is absolute; if omitted, lists the current remote workspace. Shows type, size, mtime.',
|
|
1163
1209
|
parameters: {
|
|
1164
1210
|
path: { type: 'string', description: 'Absolute remote path (default: current remote workspace)' },
|
|
1165
1211
|
},
|
|
1166
|
-
output:
|
|
1167
|
-
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
1168
|
-
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
1169
|
-
},
|
|
1212
|
+
output: textOut,
|
|
1170
1213
|
async execute(args) {
|
|
1171
1214
|
const p = args.path ? normalizeRemotePath(String(args.path)) : wsPath()
|
|
1172
1215
|
if (!p) throw new Error('rw_list_dir: no path and no remote workspace set')
|
|
1173
|
-
// SFTP readdir listing — works on any remote shell (POSIX, cmd.exe,
|
|
1174
|
-
// PowerShell) unlike `ls -la`.
|
|
1175
1216
|
let list
|
|
1176
1217
|
try {
|
|
1177
1218
|
const sftp = await pool.sftp()
|
|
@@ -1179,173 +1220,347 @@ export async function apply(ctx, config) {
|
|
|
1179
1220
|
} catch (err) {
|
|
1180
1221
|
throw new Error('rw_list_dir: ' + ((err && err.message) || err))
|
|
1181
1222
|
}
|
|
1223
|
+
const fmtMtime = (t) => {
|
|
1224
|
+
if (!t) return '?'
|
|
1225
|
+
const d = new Date(t * 1000)
|
|
1226
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
1227
|
+
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`
|
|
1228
|
+
}
|
|
1182
1229
|
const lines = list
|
|
1183
1230
|
.filter((e) => String(e.filename) !== '.' && String(e.filename) !== '..')
|
|
1184
1231
|
.map((e) => {
|
|
1185
1232
|
const a = e.attrs || {}
|
|
1186
1233
|
const type = a.isDirectory && a.isDirectory() ? 'd' : (a.isSymbolicLink && a.isSymbolicLink() ? 'l' : '-')
|
|
1187
1234
|
const size = typeof a.size === 'number' ? String(a.size) : '?'
|
|
1188
|
-
return `${type} ${size.padStart(10)} ${String(e.filename)}`
|
|
1235
|
+
return `${type} ${size.padStart(10)} ${fmtMtime(a.mtime).padEnd(17)} ${String(e.filename)}`
|
|
1189
1236
|
})
|
|
1190
1237
|
return { text: lines.length ? lines.join('\n') : '(empty directory)' }
|
|
1191
1238
|
},
|
|
1192
1239
|
}),
|
|
1193
1240
|
|
|
1194
1241
|
defineTool({
|
|
1195
|
-
name: '
|
|
1242
|
+
name: 'rw_stat',
|
|
1243
|
+
description:
|
|
1244
|
+
'Show detailed stat of a remote file or directory: type, size, mtime, mode (SFTP attrs). Use to verify a remote path exists or to compare files.',
|
|
1245
|
+
parameters: {
|
|
1246
|
+
path: { type: 'string', required: true, description: 'Absolute remote path' },
|
|
1247
|
+
},
|
|
1248
|
+
output: textOut,
|
|
1249
|
+
async execute(args) {
|
|
1250
|
+
const p = normalizeRemotePath(String(args.path || ''))
|
|
1251
|
+
if (!p) throw new Error('rw_stat: path is required')
|
|
1252
|
+
const sftp = await pool.sftp()
|
|
1253
|
+
let st
|
|
1254
|
+
try {
|
|
1255
|
+
st = await sftp.stat(p)
|
|
1256
|
+
} catch (err) {
|
|
1257
|
+
throw new Error('rw_stat: not found or unreadable: ' + ((err && err.message) || err))
|
|
1258
|
+
}
|
|
1259
|
+
const type = st.isDirectory && st.isDirectory() ? 'directory' : (st.isSymbolicLink && st.isSymbolicLink() ? 'symlink' : 'file')
|
|
1260
|
+
const lines = [
|
|
1261
|
+
`path: ${p}`,
|
|
1262
|
+
`type: ${type}`,
|
|
1263
|
+
`size: ${st.size} bytes`,
|
|
1264
|
+
`mtime: ${new Date(st.mtime * 1000).toISOString()}`,
|
|
1265
|
+
`mode: ${typeof st.mode === 'number' ? st.mode.toString(8) : '?'}`,
|
|
1266
|
+
]
|
|
1267
|
+
return { text: lines.join('\n') }
|
|
1268
|
+
},
|
|
1269
|
+
}),
|
|
1270
|
+
|
|
1271
|
+
defineTool({
|
|
1272
|
+
name: 'rw_read_file',
|
|
1273
|
+
description:
|
|
1274
|
+
'Read a text file on the remote host with line numbers. Supports paging with startLine/endLine and an encoding param (utf-8 default, gbk etc). Path is absolute.',
|
|
1275
|
+
parameters: {
|
|
1276
|
+
path: { type: 'string', required: true, description: 'Absolute remote file path' },
|
|
1277
|
+
startLine: { type: 'integer', description: '1-based first line (default 1)' },
|
|
1278
|
+
endLine: { type: 'integer', description: '1-based last line (inclusive)' },
|
|
1279
|
+
maxLines: { type: 'integer', description: 'Max lines (default 2000)' },
|
|
1280
|
+
encoding: { type: 'string', description: 'Text encoding, e.g. utf-8 (default) or gbk' },
|
|
1281
|
+
},
|
|
1282
|
+
output: textOut,
|
|
1283
|
+
async execute(args) {
|
|
1284
|
+
const p = normalizeRemotePath(String(args.path || ''))
|
|
1285
|
+
if (!p) throw new Error('rw_read_file: path is required')
|
|
1286
|
+
const maxLines = Math.min(Math.max(Number(args.maxLines) || 2000, 1), 10000)
|
|
1287
|
+
let from = Math.max(Number(args.startLine) || 1, 1)
|
|
1288
|
+
let to = Number(args.endLine) || 0
|
|
1289
|
+
if (!to || to - from + 1 > maxLines) to = from + maxLines - 1
|
|
1290
|
+
const sftp = await pool.sftp()
|
|
1291
|
+
let st
|
|
1292
|
+
try { st = await sftp.stat(p) } catch (err) { throw new Error('rw_read_file: ' + ((err && err.message) || err)) }
|
|
1293
|
+
if (config.maxFileBytes > 0 && st.size > config.maxFileBytes) {
|
|
1294
|
+
throw new Error(`rw_read_file: file is ${st.size} bytes (over ${config.maxFileBytes} cap); use rw_download or rw_exec to read it`)
|
|
1295
|
+
}
|
|
1296
|
+
let buf
|
|
1297
|
+
try {
|
|
1298
|
+
buf = await sftp.readFile(p)
|
|
1299
|
+
} catch (err) {
|
|
1300
|
+
throw new Error('rw_read_file: ' + ((err && err.message) || err))
|
|
1301
|
+
}
|
|
1302
|
+
const content = decodeBuf(buf, args.encoding || config.encoding).replace(/\r\n/g, '\n')
|
|
1303
|
+
const allLines = content.split('\n')
|
|
1304
|
+
const page = allLines.slice(from - 1, to)
|
|
1305
|
+
const numbered = page.map((l, i) => `${String(from + i).padStart(6)}\t${l}`).join('\n').replace(/\s+$/, '')
|
|
1306
|
+
let text = numbered === '' ? '(empty or out of range)' : numbered
|
|
1307
|
+
if (!args.endLine) text += '\n(shown up to ' + maxLines + ' lines; use startLine/endLine to page)'
|
|
1308
|
+
return { text }
|
|
1309
|
+
},
|
|
1310
|
+
}),
|
|
1311
|
+
|
|
1312
|
+
defineTool({
|
|
1313
|
+
name: 'rw_write_file',
|
|
1314
|
+
description:
|
|
1315
|
+
'Write text to a file on the remote host (creating parent directories if needed). Path is absolute. Use this to create or overwrite a remote file directly, instead of round-tripping through a local mirror.',
|
|
1316
|
+
parameters: {
|
|
1317
|
+
path: { type: 'string', required: true, description: 'Absolute remote file path' },
|
|
1318
|
+
content: { type: 'string', required: true, description: 'File content to write (overwrites existing file)' },
|
|
1319
|
+
mkdir: { type: 'boolean', description: 'Create missing parent directories (default true)' },
|
|
1320
|
+
encoding: { type: 'string', description: 'Text encoding, e.g. utf-8 (default) or gbk' },
|
|
1321
|
+
},
|
|
1322
|
+
output: okOut,
|
|
1323
|
+
async execute(args) {
|
|
1324
|
+
const p = normalizeRemotePath(String(args.path || ''))
|
|
1325
|
+
if (!p || p === '/') throw new Error('rw_write_file: a file path is required')
|
|
1326
|
+
const content = String(args.content == null ? '' : args.content)
|
|
1327
|
+
const sftp = await pool.sftp()
|
|
1328
|
+
if (args.mkdir !== false) await mkdirRemoteDirs(sftp, remoteDirname(p))
|
|
1329
|
+
const buf = encodeText(content, args.encoding || config.encoding)
|
|
1330
|
+
await sftp.writeFile(p, buf)
|
|
1331
|
+
const bytes = buf.byteLength
|
|
1332
|
+
audit('write_file', `write ${p} (${bytes}B)`, 0)
|
|
1333
|
+
return { ok: true, bytes, text: `wrote ${bytes} bytes to ${p}` }
|
|
1334
|
+
},
|
|
1335
|
+
}),
|
|
1336
|
+
|
|
1337
|
+
defineTool({
|
|
1338
|
+
name: 'rw_edit',
|
|
1339
|
+
description:
|
|
1340
|
+
'Edit a remote text file by replacing literal text (read-modify-write with an mtime optimistic lock: aborts if the file changed on the remote between read and write). Path is absolute.',
|
|
1341
|
+
parameters: {
|
|
1342
|
+
path: { type: 'string', required: true, description: 'Absolute remote file path' },
|
|
1343
|
+
old: { type: 'string', required: true, description: 'Literal text to replace (must appear exactly once unless count is given)' },
|
|
1344
|
+
new: { type: 'string', required: true, description: 'Replacement text' },
|
|
1345
|
+
count: { type: 'integer', description: 'How many occurrences to replace (default: error if the text appears more than once)' },
|
|
1346
|
+
encoding: { type: 'string', description: 'Text encoding, e.g. utf-8 (default) or gbk' },
|
|
1347
|
+
},
|
|
1348
|
+
output: okOut,
|
|
1349
|
+
async execute(args) {
|
|
1350
|
+
const p = normalizeRemotePath(String(args.path || ''))
|
|
1351
|
+
if (!p || p === '/') throw new Error('rw_edit: a file path is required')
|
|
1352
|
+
const oldS = String(args.old ?? '')
|
|
1353
|
+
const newS = String(args.new ?? '')
|
|
1354
|
+
if (oldS === '') throw new Error('rw_edit: old text must not be empty')
|
|
1355
|
+
const sftp = await pool.sftp()
|
|
1356
|
+
const st0 = await sftp.stat(p)
|
|
1357
|
+
const buf = await sftp.readFile(p)
|
|
1358
|
+
const content = decodeBuf(buf, args.encoding || config.encoding)
|
|
1359
|
+
const count = args.count == null ? 0 : Math.max(Number(args.count) || 1, 1)
|
|
1360
|
+
const idxs = []
|
|
1361
|
+
let from = 0
|
|
1362
|
+
let hit
|
|
1363
|
+
while ((hit = content.indexOf(oldS, from)) !== -1) { idxs.push(hit); from = hit + oldS.length }
|
|
1364
|
+
if (!idxs.length) throw new Error(`rw_edit: old text not found in ${p}`)
|
|
1365
|
+
if (count === 0 && idxs.length > 1) {
|
|
1366
|
+
throw new Error(`rw_edit: "old" appears ${idxs.length} times in ${p} — pass count=<n> to pick how many to replace`)
|
|
1367
|
+
}
|
|
1368
|
+
const n = count === 0 ? 1 : Math.min(count, idxs.length)
|
|
1369
|
+
let out = content
|
|
1370
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
1371
|
+
out = out.slice(0, idxs[i]) + newS + out.slice(idxs[i] + oldS.length)
|
|
1372
|
+
}
|
|
1373
|
+
// Optimistic lock: the remote must not have changed since we read it.
|
|
1374
|
+
const st1 = await sftp.stat(p)
|
|
1375
|
+
if (st1.size !== st0.size || st1.mtime !== st0.mtime) {
|
|
1376
|
+
throw new Error(`rw_edit: ${p} changed on the remote while editing (conflict) — re-read and retry`)
|
|
1377
|
+
}
|
|
1378
|
+
await sftp.writeFile(p, encodeText(out, args.encoding || config.encoding))
|
|
1379
|
+
audit('edit', `edit ${p} (${n} occurrence(s))`, 0)
|
|
1380
|
+
return { ok: true, bytes: Buffer.byteLength(out), text: `edited ${p}: replaced ${n} occurrence(s)` }
|
|
1381
|
+
},
|
|
1382
|
+
}),
|
|
1383
|
+
|
|
1384
|
+
defineTool({
|
|
1385
|
+
name: 'rw_append',
|
|
1386
|
+
description:
|
|
1387
|
+
'Append text to a remote file (creates it when missing). Path is absolute.',
|
|
1388
|
+
parameters: {
|
|
1389
|
+
path: { type: 'string', required: true, description: 'Absolute remote file path' },
|
|
1390
|
+
content: { type: 'string', required: true, description: 'Text to append' },
|
|
1391
|
+
encoding: { type: 'string', description: 'Text encoding, e.g. utf-8 (default) or gbk' },
|
|
1392
|
+
},
|
|
1393
|
+
output: okOut,
|
|
1394
|
+
async execute(args) {
|
|
1395
|
+
const p = normalizeRemotePath(String(args.path || ''))
|
|
1396
|
+
if (!p || p === '/') throw new Error('rw_append: a file path is required')
|
|
1397
|
+
const sftp = await pool.sftp()
|
|
1398
|
+
let existing = ''
|
|
1399
|
+
try { existing = decodeBuf(await sftp.readFile(p), args.encoding || config.encoding) } catch { /* new file */ }
|
|
1400
|
+
const content = existing + String(args.content ?? '')
|
|
1401
|
+
await sftp.writeFile(p, encodeText(content, args.encoding || config.encoding))
|
|
1402
|
+
const bytes = Buffer.byteLength(content)
|
|
1403
|
+
audit('append', `append ${p}`, 0)
|
|
1404
|
+
return { ok: true, bytes, text: `appended to ${p} (now ${bytes} bytes)` }
|
|
1405
|
+
},
|
|
1406
|
+
}),
|
|
1407
|
+
|
|
1408
|
+
defineTool({
|
|
1409
|
+
name: 'rw_mkdir',
|
|
1410
|
+
description:
|
|
1411
|
+
'Create a remote directory (mkdir -p semantics, all levels). Path is absolute.',
|
|
1412
|
+
parameters: {
|
|
1413
|
+
path: { type: 'string', required: true, description: 'Absolute remote directory path' },
|
|
1414
|
+
},
|
|
1415
|
+
output: textOut,
|
|
1416
|
+
async execute(args) {
|
|
1417
|
+
const p = normalizeRemotePath(String(args.path || ''))
|
|
1418
|
+
if (!p || p === '/') throw new Error('rw_mkdir: a directory path is required')
|
|
1419
|
+
const sftp = await pool.sftp()
|
|
1420
|
+
await mkdirRemoteDirs(sftp, p)
|
|
1421
|
+
audit('mkdir', `mkdir ${p}`, 0)
|
|
1422
|
+
return { text: `created ${p}` }
|
|
1423
|
+
},
|
|
1424
|
+
}),
|
|
1425
|
+
|
|
1426
|
+
defineTool({
|
|
1427
|
+
name: 'rw_remove',
|
|
1428
|
+
description:
|
|
1429
|
+
'Delete a remote file (or an empty directory). recursive=true removes a directory tree (bounded). Path is absolute. This is destructive — the agent should confirm intent before calling.',
|
|
1430
|
+
parameters: {
|
|
1431
|
+
path: { type: 'string', required: true, description: 'Absolute remote path to delete' },
|
|
1432
|
+
recursive: { type: 'boolean', description: 'Recursively delete a directory (default false)' },
|
|
1433
|
+
},
|
|
1434
|
+
output: okOut,
|
|
1435
|
+
async execute(args) {
|
|
1436
|
+
const p = normalizeRemotePath(String(args.path || ''))
|
|
1437
|
+
if (!p || p === '/') throw new Error('rw_remove: a path is required')
|
|
1438
|
+
const sftp = await pool.sftp()
|
|
1439
|
+
const st = await sftp.stat(p).catch(() => null)
|
|
1440
|
+
if (!st) return { ok: false, text: `not found: ${p}` }
|
|
1441
|
+
if (st.isDirectory && st.isDirectory()) {
|
|
1442
|
+
if (!args.recursive) throw new Error(`rw_remove: ${p} is a directory — pass recursive=true to delete its tree`)
|
|
1443
|
+
const removed = await removeRemoteTree(sftp, p)
|
|
1444
|
+
audit('remove', `remove -r ${p}`, 0)
|
|
1445
|
+
return { ok: true, text: `removed ${removed} entries under ${p}` }
|
|
1446
|
+
}
|
|
1447
|
+
await sftp.unlink(p)
|
|
1448
|
+
audit('remove', `remove ${p}`, 0)
|
|
1449
|
+
return { ok: true, text: `removed ${p}` }
|
|
1450
|
+
},
|
|
1451
|
+
}),
|
|
1452
|
+
|
|
1453
|
+
defineTool({
|
|
1454
|
+
name: 'rw_move',
|
|
1196
1455
|
description:
|
|
1197
|
-
'
|
|
1456
|
+
'Rename or move a remote file/directory (SFTP rename, same filesystem). Paths are absolute.',
|
|
1198
1457
|
parameters: {
|
|
1199
|
-
path: { type: 'string', required: true, description: '
|
|
1200
|
-
|
|
1201
|
-
endLine: { type: 'integer', description: '1-based last line (inclusive)' },
|
|
1202
|
-
maxLines: { type: 'integer', description: 'Max lines (default 2000)' },
|
|
1203
|
-
},
|
|
1204
|
-
output: {
|
|
1205
|
-
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
1206
|
-
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
1458
|
+
path: { type: 'string', required: true, description: 'Current absolute remote path' },
|
|
1459
|
+
dest: { type: 'string', required: true, description: 'Destination absolute remote path' },
|
|
1207
1460
|
},
|
|
1461
|
+
output: textOut,
|
|
1208
1462
|
async execute(args) {
|
|
1209
1463
|
const p = normalizeRemotePath(String(args.path || ''))
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
let buf
|
|
1218
|
-
try {
|
|
1219
|
-
const sftp = await pool.sftp()
|
|
1220
|
-
buf = await sftp.readFile(p)
|
|
1221
|
-
} catch (err) {
|
|
1222
|
-
throw new Error('rw_read_file: ' + ((err && err.message) || err))
|
|
1223
|
-
}
|
|
1224
|
-
const content = buf.toString('utf8').replace(/\r\n/g, '\n')
|
|
1225
|
-
const allLines = content.split('\n')
|
|
1226
|
-
const page = allLines.slice(from - 1, to)
|
|
1227
|
-
const numbered = page.map((l, i) => `${String(from + i).padStart(6)}\t${l}`).join('\n').replace(/\s+$/, '')
|
|
1228
|
-
let text = numbered === '' ? '(empty or out of range)' : numbered
|
|
1229
|
-
if (!args.endLine) text += '\n(shown up to ' + maxLines + ' lines; use startLine/endLine to page)'
|
|
1230
|
-
return { text }
|
|
1464
|
+
const d = normalizeRemotePath(String(args.dest || ''))
|
|
1465
|
+
if (!p || !d || p === '/') throw new Error('rw_move: both path and dest are required')
|
|
1466
|
+
const sftp = await pool.sftp()
|
|
1467
|
+
await mkdirRemoteDirs(sftp, remoteDirname(d))
|
|
1468
|
+
await sftp.rename(p, d)
|
|
1469
|
+
audit('move', `move ${p} → ${d}`, 0)
|
|
1470
|
+
return { text: `moved ${p} → ${d}` }
|
|
1231
1471
|
},
|
|
1232
1472
|
}),
|
|
1233
1473
|
|
|
1234
1474
|
defineTool({
|
|
1235
1475
|
name: 'rw_exec',
|
|
1236
1476
|
description:
|
|
1237
|
-
'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.',
|
|
1477
|
+
'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. pty=true helps interactive commands (sudo prompts, REPLs); env sets environment variables.',
|
|
1238
1478
|
parameters: {
|
|
1239
1479
|
command: { type: 'string', required: true, description: 'Shell command (run on the remote host)' },
|
|
1240
1480
|
cwd: { type: 'string', description: 'Working directory for the command (default: the current remote workspace)' },
|
|
1481
|
+
pty: { type: 'boolean', description: 'Allocate a pseudo-terminal (interactive commands, default false)' },
|
|
1482
|
+
env: { type: 'object', description: 'Extra environment variables (string values)' },
|
|
1241
1483
|
},
|
|
1242
|
-
output:
|
|
1243
|
-
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
1244
|
-
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
1245
|
-
},
|
|
1484
|
+
output: textOut,
|
|
1246
1485
|
async execute(args) {
|
|
1247
1486
|
const cmd = String(args.command || '')
|
|
1248
1487
|
if (!cmd) throw new Error('rw_exec: command is required')
|
|
1249
1488
|
const ws = wsPath()
|
|
1250
1489
|
const cwd = args.cwd ? normalizeRemotePath(String(args.cwd)) : (ws || '')
|
|
1251
|
-
// Windows remotes: no POSIX cd/single-quotes — let the command run as-is
|
|
1252
|
-
// (the user writes cmd/PowerShell). POSIX remotes get `cd … && cmd`.
|
|
1253
1490
|
const full = cwd && !cwd.includes('\\') ? `cd ${shq(cwd)} && ${cmd}` : cmd
|
|
1254
|
-
return { text: await run(full, { timeoutMs: config.commandTimeoutMs }) }
|
|
1255
|
-
},
|
|
1256
|
-
}),
|
|
1257
|
-
|
|
1258
|
-
defineTool({
|
|
1259
|
-
name: 'rw_write_file',
|
|
1260
|
-
description:
|
|
1261
|
-
'Write text to a file on the remote host (creating parent directories if needed). Path is absolute. Use this to create or overwrite a remote file directly, instead of round-tripping through a local mirror.',
|
|
1262
|
-
parameters: {
|
|
1263
|
-
path: { type: 'string', required: true, description: 'Absolute remote file path' },
|
|
1264
|
-
content: { type: 'string', required: true, description: 'File content to write (overwrites existing file)' },
|
|
1265
|
-
mkdir: { type: 'boolean', description: 'Create missing parent directories (default true)' },
|
|
1266
|
-
},
|
|
1267
|
-
output: {
|
|
1268
|
-
schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, bytes: { type: 'integer' }, text: { type: 'string' } } },
|
|
1269
|
-
render: (_a, a) => [{ type: 'text', text: a.text || (a.ok ? 'written' : 'failed') }],
|
|
1270
|
-
},
|
|
1271
|
-
async execute(args) {
|
|
1272
|
-
const p = normalizeRemotePath(String(args.path || ''))
|
|
1273
|
-
if (!p || p === '/') throw new Error('rw_write_file: a file path is required')
|
|
1274
|
-
const content = String(args.content == null ? '' : args.content)
|
|
1275
|
-
let sftp
|
|
1276
1491
|
try {
|
|
1277
|
-
|
|
1492
|
+
const res = await pool.exec(full, { timeoutMs: config.commandTimeoutMs, pty: !!args.pty, env: args.env })
|
|
1493
|
+
audit('exec', cmd, res.code)
|
|
1494
|
+
const parts = []
|
|
1495
|
+
if (res.stdout) parts.push(res.stdout.replace(/\s+$/, ''))
|
|
1496
|
+
if (res.stderr) parts.push('-- stderr --\n' + res.stderr.replace(/\s+$/, ''))
|
|
1497
|
+
if (!parts.length) parts.push('(no output)')
|
|
1498
|
+
let text = parts.join('\n')
|
|
1499
|
+
if (res.signal === 'TIMEOUT') text += `\n[command timed out after ${config.commandTimeoutMs}ms]`
|
|
1500
|
+
else if (res.code !== 0) text += `\n[exit code: ${res.code}]`
|
|
1501
|
+
return { text }
|
|
1278
1502
|
} catch (err) {
|
|
1279
|
-
throw new Error(
|
|
1503
|
+
throw new Error(friendlyMessage(err, { host: config.host, port: config.port }))
|
|
1280
1504
|
}
|
|
1281
|
-
const mkdir = args.mkdir !== false
|
|
1282
|
-
if (mkdir) {
|
|
1283
|
-
// create each level from the root so mkdir -p semantics survive even
|
|
1284
|
-
// when intermediate privileged-parent dirs don't allow create (best effort)
|
|
1285
|
-
await mkdirRemoteDirs(sftp, p)
|
|
1286
|
-
}
|
|
1287
|
-
const buf = Buffer.from(content, 'utf8')
|
|
1288
|
-
await sftp.writeFile(p, buf)
|
|
1289
|
-
const bytes = Buffer.byteLength(content, 'utf8')
|
|
1290
|
-
return { ok: true, bytes, text: `wrote ${bytes} bytes to ${p}` }
|
|
1291
1505
|
},
|
|
1292
1506
|
}),
|
|
1293
1507
|
|
|
1294
1508
|
defineTool({
|
|
1295
1509
|
name: 'rw_search',
|
|
1296
1510
|
description:
|
|
1297
|
-
'Search remote files for a pattern (
|
|
1511
|
+
'Search remote files for a pattern (recursive SFTP walk — works on ANY remote including Windows, honors ignore rules). Returns matching file:line rows; output is capped.',
|
|
1298
1512
|
parameters: {
|
|
1299
1513
|
pattern: { type: 'string', required: true, description: 'Pattern to search for (extended regex)' },
|
|
1300
1514
|
path: { type: 'string', description: 'Directory to search (default: current remote workspace)' },
|
|
1301
|
-
glob: { type: 'string', description: 'Only files whose
|
|
1515
|
+
glob: { type: 'string', description: 'Only files whose NAME matches this glob, e.g. *.ts (optional)' },
|
|
1302
1516
|
ignoreCase: { type: 'boolean', description: 'Case-insensitive search (default true)' },
|
|
1517
|
+
contextLines: { type: 'integer', description: 'Lines of context around each match (default 0)' },
|
|
1518
|
+
maxMatches: { type: 'integer', description: 'Max matches to return (default 500)' },
|
|
1303
1519
|
},
|
|
1304
|
-
output:
|
|
1305
|
-
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
|
|
1306
|
-
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
1307
|
-
},
|
|
1520
|
+
output: textOut,
|
|
1308
1521
|
async execute(args) {
|
|
1309
1522
|
const pattern = String(args.pattern || '')
|
|
1310
1523
|
if (!pattern) throw new Error('rw_search: pattern is required')
|
|
1311
1524
|
const ws = wsPath()
|
|
1312
1525
|
const dir = args.path ? normalizeRemotePath(String(args.path)) : (ws || '')
|
|
1313
1526
|
if (!dir) throw new Error('rw_search: no path and no remote workspace set')
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1527
|
+
let regex
|
|
1528
|
+
try {
|
|
1529
|
+
regex = new RegExp(pattern, args.ignoreCase === false ? '' : 'i')
|
|
1530
|
+
} catch (err) {
|
|
1531
|
+
throw new Error('rw_search: bad pattern: ' + ((err && err.message) || err))
|
|
1318
1532
|
}
|
|
1319
|
-
const
|
|
1320
|
-
const
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1533
|
+
const sftp = await pool.sftp()
|
|
1534
|
+
const maxMatches = Math.min(Math.max(Number(args.maxMatches) || 500, 1), 2000)
|
|
1535
|
+
const matcher = ignoreMatcher()
|
|
1536
|
+
const { matches, scanned, truncated } = await searchTree(sftp, dir, {
|
|
1537
|
+
regex,
|
|
1538
|
+
glob: args.glob,
|
|
1539
|
+
contextLines: Math.min(Math.max(Number(args.contextLines) || 0, 0), 10),
|
|
1540
|
+
maxMatches,
|
|
1541
|
+
maxScanBytes: Math.min(config.maxFileBytes || 1024 * 1024, 1024 * 1024),
|
|
1542
|
+
isIgnored: (name, isDir) => matcher(name, isDir),
|
|
1543
|
+
})
|
|
1544
|
+
if (!matches.length) return { text: `no matches for /${pattern}/ in ${dir} (${scanned} files scanned)` }
|
|
1545
|
+
let text = matches.map((m) => `${m.path}:${m.line}: ${m.text}`).join('\n')
|
|
1546
|
+
text += `\n(${matches.length} match(es), ${scanned} files scanned${truncated ? ', TRUNCATED' : ''})`
|
|
1547
|
+
return { text: truncate(text, config.maxOutputChars) }
|
|
1325
1548
|
},
|
|
1326
1549
|
}),
|
|
1327
1550
|
|
|
1328
1551
|
defineTool({
|
|
1329
1552
|
name: 'rw_download',
|
|
1330
1553
|
description:
|
|
1331
|
-
'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.',
|
|
1554
|
+
'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. Streams to disk (fastGet).',
|
|
1332
1555
|
parameters: {
|
|
1333
1556
|
path: { type: 'string', required: true, description: 'Absolute remote file path' },
|
|
1334
1557
|
localPath: { type: 'string', description: 'Local destination (default: the workspace mirror, preserving the relative path)' },
|
|
1335
1558
|
},
|
|
1336
|
-
output:
|
|
1337
|
-
schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, bytes: { type: 'integer' }, text: { type: 'string' } } },
|
|
1338
|
-
render: (_a, a) => [{ type: 'text', text: a.text || (a.ok ? 'downloaded' : 'failed') }],
|
|
1339
|
-
},
|
|
1559
|
+
output: okOut,
|
|
1340
1560
|
async execute(args) {
|
|
1341
1561
|
const p = normalizeRemotePath(String(args.path || ''))
|
|
1342
1562
|
if (!p || p === '/') throw new Error('rw_download: a remote file path is required')
|
|
1343
|
-
|
|
1344
|
-
try {
|
|
1345
|
-
sftp = await pool.sftp()
|
|
1346
|
-
} catch (err) {
|
|
1347
|
-
throw new Error('rw_download: sftp unavailable: ' + ((err && err.message) || err))
|
|
1348
|
-
}
|
|
1563
|
+
const sftp = await pool.sftp()
|
|
1349
1564
|
let local
|
|
1350
1565
|
if (args.localPath) {
|
|
1351
1566
|
local = path.resolve(String(args.localPath))
|
|
@@ -1357,9 +1572,12 @@ export async function apply(ctx, config) {
|
|
|
1357
1572
|
local = path.join(base, rel)
|
|
1358
1573
|
}
|
|
1359
1574
|
mkdirSync(path.dirname(local), { recursive: true })
|
|
1360
|
-
const
|
|
1361
|
-
|
|
1362
|
-
|
|
1575
|
+
const st = await sftp.stat(p).catch(() => null)
|
|
1576
|
+
if (config.maxFileBytes > 0 && st && st.size > config.maxFileBytes) {
|
|
1577
|
+
throw new Error(`rw_download: file is ${st.size} bytes (over ${config.maxFileBytes} cap)`)
|
|
1578
|
+
}
|
|
1579
|
+
await sftp.fastGet(p, local)
|
|
1580
|
+
const bytes = existsSync(local) ? statSync(local).size : 0
|
|
1363
1581
|
return { ok: true, bytes, text: `downloaded ${bytes} bytes from ${p} → ${local}` }
|
|
1364
1582
|
},
|
|
1365
1583
|
}),
|
|
@@ -1372,41 +1590,69 @@ export async function apply(ctx, config) {
|
|
|
1372
1590
|
localPath: { type: 'string', required: true, description: 'Absolute local file path' },
|
|
1373
1591
|
path: { type: 'string', required: true, description: 'Absolute remote destination path' },
|
|
1374
1592
|
},
|
|
1375
|
-
output:
|
|
1376
|
-
schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, bytes: { type: 'integer' }, text: { type: 'string' } } },
|
|
1377
|
-
render: (_a, a) => [{ type: 'text', text: a.text || (a.ok ? 'uploaded' : 'failed') }],
|
|
1378
|
-
},
|
|
1593
|
+
output: okOut,
|
|
1379
1594
|
async execute(args) {
|
|
1380
1595
|
const rp = normalizeRemotePath(String(args.path || ''))
|
|
1381
1596
|
const lp = String(args.localPath || '')
|
|
1382
1597
|
if (!rp || rp === '/' || !lp) throw new Error('rw_upload: both localPath and a remote path are required')
|
|
1383
1598
|
if (!existsSync(lp)) throw new Error(`rw_upload: local file not found: ${lp}`)
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1599
|
+
const sftp = await pool.sftp()
|
|
1600
|
+
await mkdirRemoteDirs(sftp, remoteDirname(rp))
|
|
1601
|
+
const st = statSync(lp)
|
|
1602
|
+
await sftp.fastPut(rp, lp)
|
|
1603
|
+
audit('upload', `upload ${lp} → ${rp}`, 0)
|
|
1604
|
+
return { ok: true, bytes: st.size, text: `uploaded ${st.size} bytes from ${lp} → ${rp}` }
|
|
1605
|
+
},
|
|
1606
|
+
}),
|
|
1607
|
+
|
|
1608
|
+
defineTool({
|
|
1609
|
+
name: 'rw_forward',
|
|
1610
|
+
description:
|
|
1611
|
+
'Manage SSH port forwards. Direction "local" listens on 127.0.0.1:<listenPort> on THIS machine and forwards connections through SSH to <targetHost>:<targetPort> on the remote. Direction "reverse" asks the REMOTE to listen on 127.0.0.1:<listenPort> and pipes connections back to <targetHost>:<targetPort> on this machine. Call with a listenPort to create+start; with remove=true to delete.',
|
|
1612
|
+
parameters: {
|
|
1613
|
+
listenPort: { type: 'integer', required: true, description: 'Port to listen on (local for direction=local, remote for direction=reverse)' },
|
|
1614
|
+
targetHost: { type: 'string', description: 'Forward target host (default 127.0.0.1)' },
|
|
1615
|
+
targetPort: { type: 'integer', description: 'Forward target port (default: same as listenPort)' },
|
|
1616
|
+
direction: { type: 'string', description: 'local (default) or reverse' },
|
|
1617
|
+
autoStart: { type: 'boolean', description: 'Restart this forward automatically on future connects (default false)' },
|
|
1618
|
+
remove: { type: 'boolean', description: 'Remove an existing forward by listenPort (default false)' },
|
|
1619
|
+
},
|
|
1620
|
+
output: textOut,
|
|
1621
|
+
async execute(args) {
|
|
1622
|
+
const port = Number(args.listenPort)
|
|
1623
|
+
if (!port || port < 1 || port > 65535) throw new Error('rw_forward: a valid listenPort is required')
|
|
1624
|
+
const dir = args.direction === 'reverse' ? 'reverse' : 'local'
|
|
1625
|
+
const existing = forwards.list().find((f) => Number(f.listenPort) === port && f.direction === dir)
|
|
1626
|
+
if (args.remove) {
|
|
1627
|
+
if (!existing) return { text: `no forward on port ${port} to remove` }
|
|
1628
|
+
forwards.remove(existing.id)
|
|
1629
|
+
return { text: `removed ${existing.direction} forward on port ${port}` }
|
|
1389
1630
|
}
|
|
1390
|
-
|
|
1391
|
-
const
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1631
|
+
if (existing && existing.active) return { text: `already active: ${existing.direction} forward 127.0.0.1:${port} → ${existing.targetHost}:${existing.targetPort}` }
|
|
1632
|
+
const d = existing || forwards.define({
|
|
1633
|
+
direction: dir,
|
|
1634
|
+
listenPort: port,
|
|
1635
|
+
targetHost: args.targetHost || '127.0.0.1',
|
|
1636
|
+
targetPort: Number(args.targetPort) || port,
|
|
1637
|
+
autoStart: !!args.autoStart,
|
|
1638
|
+
machineId: store.currentId,
|
|
1639
|
+
})
|
|
1640
|
+
const r = await forwards.start(d)
|
|
1641
|
+
audit('forward', `${d.direction} forward ${port} → ${d.targetHost}:${d.targetPort}`, r.ok ? 0 : 1)
|
|
1642
|
+
if (!r.ok) throw new Error(r.error)
|
|
1643
|
+
return { text: `${d.direction} forward active: 127.0.0.1:${port} → ${d.targetHost}:${d.targetPort} (id=${d.id})` }
|
|
1395
1644
|
},
|
|
1396
1645
|
}),
|
|
1397
1646
|
|
|
1398
1647
|
defineTool({
|
|
1399
1648
|
name: 'rw_disconnect',
|
|
1400
1649
|
description:
|
|
1401
|
-
'Close the current SSH connection to the remote host, releasing the persistent pool. Useful to rotate connections or after a long idle.',
|
|
1650
|
+
'Close the current SSH connection to the remote host, releasing the persistent pool (and stopping all active port forwards). Useful to rotate connections or after a long idle.',
|
|
1402
1651
|
parameters: {},
|
|
1403
|
-
output:
|
|
1404
|
-
schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, text: { type: 'string' } } },
|
|
1405
|
-
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
1406
|
-
},
|
|
1652
|
+
output: okOut,
|
|
1407
1653
|
async execute() {
|
|
1408
1654
|
pool.close()
|
|
1409
|
-
return { ok: true, text: 'disconnected' }
|
|
1655
|
+
return { ok: true, text: 'disconnected (forwards stopped)' }
|
|
1410
1656
|
},
|
|
1411
1657
|
}),
|
|
1412
1658
|
]
|
|
@@ -1422,30 +1668,36 @@ export async function apply(ctx, config) {
|
|
|
1422
1668
|
text: () => {
|
|
1423
1669
|
const w = wsPath()
|
|
1424
1670
|
if (!w || !config.host) return ''
|
|
1671
|
+
const fwd = forwards.list().filter((f) => f.active).map((f) => `${f.direction}:127.0.0.1:${f.listenPort}→${f.targetHost}:${f.targetPort}`)
|
|
1672
|
+
let extra = ''
|
|
1673
|
+
if (fwd.length) extra = `\nActive port forwards: ${fwd.join(', ')}`
|
|
1425
1674
|
return (
|
|
1426
1675
|
'## Remote workspace\n' +
|
|
1427
1676
|
`Current remote workspace: ${config.username}@${config.host}:${w}\n` +
|
|
1428
|
-
'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.'
|
|
1677
|
+
'Use the rw_* tools (rw_list_dir / rw_read_file / rw_write_file / rw_edit / rw_exec / rw_search / rw_sync / rw_push) to inspect and act on files on the remote host. Treat this directory as the working root for this task.' +
|
|
1678
|
+
extra
|
|
1429
1679
|
)
|
|
1430
1680
|
},
|
|
1431
1681
|
})
|
|
1432
1682
|
|
|
1433
|
-
// ── slash
|
|
1683
|
+
// ── slash commands ─────────────────────────────────────────────────────────
|
|
1434
1684
|
const commands = ctx.get('commands')
|
|
1435
1685
|
if (commands !== undefined) {
|
|
1436
1686
|
commands.register({
|
|
1437
1687
|
name: 'remote',
|
|
1438
|
-
description: 'Show the current remote workspace / connection status and how to use remote tools.',
|
|
1688
|
+
description: 'Show the current remote workspace / connection status, active forwards, and how to use remote tools.',
|
|
1439
1689
|
handler: (invocation) => {
|
|
1440
1690
|
const s = status()
|
|
1691
|
+
const fwd = s.forwards.filter((f) => f.active)
|
|
1441
1692
|
return {
|
|
1442
1693
|
kind: 'success',
|
|
1443
1694
|
text:
|
|
1444
|
-
`Remote host: ${s.username}@${s.host || '<none>'} (connected: ${s.connected})\n` +
|
|
1695
|
+
`Remote host: ${s.username}@${s.host || '<none>'} (connected: ${s.connected}, source: ${s.activeSource})\n` +
|
|
1445
1696
|
`Remote workspace: ${s.workspace || '(none)'}\n` +
|
|
1446
1697
|
`Host key: ${s.hostKeyKnown ? 'trusted ✓' : 'not yet trusted'} (mode=${s.hostKeyMode})\n` +
|
|
1447
1698
|
(s.hostKeyKnown ? ` — if the key changed / was mistrusted, run /remote-forget-key\n` : '') +
|
|
1448
|
-
|
|
1699
|
+
(fwd.length ? `Active forwards:\n${fwd.map((f) => ` ${f.direction} 127.0.0.1:${f.listenPort} → ${f.targetHost}:${f.targetPort}`).join('\n')}\n` : '') +
|
|
1700
|
+
`\nUse tools: rw_list_dir / rw_read_file / rw_edit / rw_exec / rw_search / rw_forward.` +
|
|
1449
1701
|
(s.workspace ? `\nCurrently working in ${s.workspace}.` : ''),
|
|
1450
1702
|
}
|
|
1451
1703
|
},
|
|
@@ -1458,6 +1710,17 @@ export async function apply(ctx, config) {
|
|
|
1458
1710
|
return { kind: 'success', text: `forgot host key for ${config.host || '<none>'}:${config.port} — the next connect will re-record it.` }
|
|
1459
1711
|
},
|
|
1460
1712
|
})
|
|
1713
|
+
commands.register({
|
|
1714
|
+
name: 'remote-ignore',
|
|
1715
|
+
description: 'Show the mirror ignore rules file location and the current default patterns (gitignore syntax).',
|
|
1716
|
+
handler: () => {
|
|
1717
|
+
return {
|
|
1718
|
+
kind: 'success',
|
|
1719
|
+
text:
|
|
1720
|
+
`Ignore file: ${ignoreFile()}\n(defaults merged with the file; gitignore syntax, '#' comments)\n\nDefault patterns:\n${DEFAULT_IGNORE.map((p) => ' ' + p).join('\n')}`,
|
|
1721
|
+
}
|
|
1722
|
+
},
|
|
1723
|
+
})
|
|
1461
1724
|
}
|
|
1462
1725
|
|
|
1463
1726
|
// ── JSON endpoints for settings UI ─────────────────────────────────────────
|
|
@@ -1469,171 +1732,6 @@ export async function apply(ctx, config) {
|
|
|
1469
1732
|
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
1470
1733
|
res.end(JSON.stringify(body))
|
|
1471
1734
|
}
|
|
1472
|
-
|
|
1473
|
-
// ── self-update (version check + apply) ────────────────────────────────────
|
|
1474
|
-
// dsh-remote is a zero-build plugin: the host half (lib/index.js) is loaded
|
|
1475
|
-
// straight from the installed package, so "updating" = replacing the package
|
|
1476
|
-
// files with the newer npm tarball. We never mutate node_modules directly
|
|
1477
|
-
// (pnpm owns it); instead we write the fresh files into the same directory
|
|
1478
|
-
// the loader already resolved, which is the profile's node_modules/dsh-remote
|
|
1479
|
-
// (hoisted) or the .pnpm store copy — both are writable for the local user.
|
|
1480
|
-
const NPM_PACKAGE = 'dsh-remote'
|
|
1481
|
-
const REGISTRY_URL = 'https://registry.npmjs.org/' + NPM_PACKAGE + '/latest'
|
|
1482
|
-
/** Absolute directory holding this plugin's installed files (lib/ + package.json). */
|
|
1483
|
-
const selfDir = () => {
|
|
1484
|
-
try {
|
|
1485
|
-
return path.dirname(path.dirname(fileURLToPath(import.meta.url)))
|
|
1486
|
-
} catch {
|
|
1487
|
-
return path.dirname(path.dirname(process.argv[1] || ''))
|
|
1488
|
-
}
|
|
1489
|
-
}
|
|
1490
|
-
const readVersion = () => {
|
|
1491
|
-
try {
|
|
1492
|
-
const pkg = JSON.parse(readFileSync(path.join(selfDir(), 'package.json'), 'utf8'))
|
|
1493
|
-
return String(pkg.version || '0.0.0')
|
|
1494
|
-
} catch {
|
|
1495
|
-
return '0.0.0'
|
|
1496
|
-
}
|
|
1497
|
-
}
|
|
1498
|
-
const currentVersion = readVersion()
|
|
1499
|
-
// A mode persisted via the settings UI (/dsh-remote/update-mode) wins over
|
|
1500
|
-
// the profile-config default, so the user's choice survives restarts without
|
|
1501
|
-
// editing cordis config.
|
|
1502
|
-
const persistedMode = (() => {
|
|
1503
|
-
try {
|
|
1504
|
-
const m = readFileSync(path.join(selfDir(), 'update-mode'), 'utf8').trim()
|
|
1505
|
-
return ['manual', 'auto', 'off'].includes(m) ? m : null
|
|
1506
|
-
} catch {
|
|
1507
|
-
return null
|
|
1508
|
-
}
|
|
1509
|
-
})()
|
|
1510
|
-
if (persistedMode) config.updateMode = persistedMode
|
|
1511
|
-
/** Query the npm registry for the latest release; resolves null on any failure. */
|
|
1512
|
-
const fetchLatestVersion = async (timeoutMs = 8000) => {
|
|
1513
|
-
try {
|
|
1514
|
-
const controller = new AbortController()
|
|
1515
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
1516
|
-
try {
|
|
1517
|
-
const res = await fetch(REGISTRY_URL, { signal: controller.signal, headers: { 'accept': 'application/json' } })
|
|
1518
|
-
if (!res.ok) return null
|
|
1519
|
-
const data = await res.json()
|
|
1520
|
-
return typeof data?.version === 'string' && data.version ? data.version : null
|
|
1521
|
-
} finally {
|
|
1522
|
-
clearTimeout(timer)
|
|
1523
|
-
}
|
|
1524
|
-
} catch {
|
|
1525
|
-
return null
|
|
1526
|
-
}
|
|
1527
|
-
}
|
|
1528
|
-
/** Compare dotted versions; returns true when a > b. */
|
|
1529
|
-
const gtVersion = (a, b) => {
|
|
1530
|
-
const pa = String(a || '').split('.').map((n) => parseInt(n, 10) || 0)
|
|
1531
|
-
const pb = String(b || '').split('.').map((n) => parseInt(n, 10) || 0)
|
|
1532
|
-
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
1533
|
-
const da = pa[i] || 0
|
|
1534
|
-
const db = pb[i] || 0
|
|
1535
|
-
if (da !== db) return da > db
|
|
1536
|
-
}
|
|
1537
|
-
return false
|
|
1538
|
-
}
|
|
1539
|
-
/**
|
|
1540
|
-
* Download the given version's tarball and atomically replace the installed
|
|
1541
|
-
* package files (lib/*.js, cordis.patch.yml, package.json). A stale marker
|
|
1542
|
-
* file (.dsh-remote-updated) is written so the client can tell the user to
|
|
1543
|
-
* reload; the loader picks the new host half up on the next boot.
|
|
1544
|
-
*/
|
|
1545
|
-
const applyUpdate = async (targetVersion) => {
|
|
1546
|
-
const tarballUrl = `https://registry.npmjs.org/${NPM_PACKAGE}/-/${NPM_PACKAGE}-${targetVersion}.tgz`
|
|
1547
|
-
const dir = selfDir()
|
|
1548
|
-
const tmpRoot = path.join(dir, `.dsh-remote-update-${Date.now()}`)
|
|
1549
|
-
const tmpPkg = path.join(tmpRoot, 'package')
|
|
1550
|
-
try {
|
|
1551
|
-
const controller = new AbortController()
|
|
1552
|
-
const timer = setTimeout(() => controller.abort(), 60000)
|
|
1553
|
-
let res
|
|
1554
|
-
try {
|
|
1555
|
-
res = await fetch(tarballUrl, { signal: controller.signal })
|
|
1556
|
-
} finally {
|
|
1557
|
-
clearTimeout(timer)
|
|
1558
|
-
}
|
|
1559
|
-
if (!res.ok) throw new Error(`download failed (HTTP ${res.status})`)
|
|
1560
|
-
const buf = Buffer.from(await res.arrayBuffer())
|
|
1561
|
-
// npm tarballs are gzipped tar; extract the package/ directory.
|
|
1562
|
-
mkdirSync(tmpRoot, { recursive: true })
|
|
1563
|
-
// Minimal tar.gz extractor: we only need package/{lib,package.json,cordis.patch.yml}.
|
|
1564
|
-
// Use node's zlib gunzip + a tiny tar parser for the regular files we ship.
|
|
1565
|
-
const { gunzipSync } = await import('node:zlib')
|
|
1566
|
-
const tar = gunzipSync(buf)
|
|
1567
|
-
const files = parseTar(tar)
|
|
1568
|
-
let extracted = 0
|
|
1569
|
-
for (const f of files) {
|
|
1570
|
-
const rel = f.name.replace(/^package\//, '')
|
|
1571
|
-
if (!rel || rel === 'package/' || rel.endsWith('/')) continue
|
|
1572
|
-
if (rel !== 'package.json' && rel !== 'cordis.patch.yml' && !rel.startsWith('lib/')) continue
|
|
1573
|
-
const dest = path.join(tmpPkg, rel)
|
|
1574
|
-
mkdirSync(path.dirname(dest), { recursive: true })
|
|
1575
|
-
writeFileSync(dest, f.data)
|
|
1576
|
-
extracted++
|
|
1577
|
-
}
|
|
1578
|
-
if (extracted === 0) throw new Error('tarball contained no package files')
|
|
1579
|
-
// Verify the downloaded package.json matches the requested version.
|
|
1580
|
-
const newPkg = JSON.parse(readFileSync(path.join(tmpPkg, 'package.json'), 'utf8'))
|
|
1581
|
-
if (newPkg.version !== targetVersion) throw new Error(`tarball version mismatch: ${newPkg.version}`)
|
|
1582
|
-
// Atomically swap: copy fresh files over the installed ones.
|
|
1583
|
-
const installLib = path.join(dir, 'lib')
|
|
1584
|
-
const tmpLib = path.join(tmpPkg, 'lib')
|
|
1585
|
-
for (const name of ['index.js', 'client.js']) {
|
|
1586
|
-
const src = path.join(tmpLib, name)
|
|
1587
|
-
if (existsSync(src)) copyFileSync(src, path.join(installLib, name))
|
|
1588
|
-
}
|
|
1589
|
-
for (const rel of ['package.json', 'cordis.patch.yml']) {
|
|
1590
|
-
const src = path.join(tmpPkg, rel)
|
|
1591
|
-
if (existsSync(src)) copyFileSync(src, path.join(dir, rel))
|
|
1592
|
-
}
|
|
1593
|
-
writeFileSync(path.join(dir, '.dsh-remote-updated'), String(targetVersion))
|
|
1594
|
-
return { ok: true, from: currentVersion, to: targetVersion }
|
|
1595
|
-
} catch (err) {
|
|
1596
|
-
throw new Error('update failed: ' + ((err && err.message) || err))
|
|
1597
|
-
} finally {
|
|
1598
|
-
try { rmSync(tmpRoot, { recursive: true, force: true }) } catch {}
|
|
1599
|
-
}
|
|
1600
|
-
}
|
|
1601
|
-
/** Minimal POSIX ustar parser (regular files only) for npm tarballs. */
|
|
1602
|
-
function parseTar(buf) {
|
|
1603
|
-
const files = []
|
|
1604
|
-
let off = 0
|
|
1605
|
-
while (off + 512 <= buf.length) {
|
|
1606
|
-
const header = buf.subarray(off, off + 512)
|
|
1607
|
-
const name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/, '')
|
|
1608
|
-
if (!name) break
|
|
1609
|
-
const sizeStr = header.subarray(124, 136).toString('utf8').replace(/\0.*$/, '').trim()
|
|
1610
|
-
const size = parseInt(sizeStr, 8) || 0
|
|
1611
|
-
const type = String.fromCharCode(header[156] || 48)
|
|
1612
|
-
off += 512
|
|
1613
|
-
const data = type === '48' || type === '0' ? Buffer.from(buf.subarray(off, off + size)) : null
|
|
1614
|
-
if (data) files.push({ name, data })
|
|
1615
|
-
off += Math.ceil(size / 512) * 512
|
|
1616
|
-
}
|
|
1617
|
-
return files
|
|
1618
|
-
}
|
|
1619
|
-
// Auto mode: check once on load and on an interval; apply silently when a
|
|
1620
|
-
// newer version exists. Failures are swallowed (never break the plugin).
|
|
1621
|
-
let updateTimer = null
|
|
1622
|
-
if (config.updateMode === 'auto') {
|
|
1623
|
-
const checkAndApply = async () => {
|
|
1624
|
-
const latest = await fetchLatestVersion()
|
|
1625
|
-
if (latest && gtVersion(latest, currentVersion)) {
|
|
1626
|
-
try { await applyUpdate(latest) } catch {}
|
|
1627
|
-
}
|
|
1628
|
-
}
|
|
1629
|
-
void checkAndApply()
|
|
1630
|
-
updateTimer = setInterval(checkAndApply, Math.max(config.updateCheckIntervalMs, 60000))
|
|
1631
|
-
if (typeof updateTimer.unref === 'function') updateTimer.unref()
|
|
1632
|
-
ctx.effect(() => () => { if (updateTimer) clearInterval(updateTimer) }, 'dsh-remote.update-timer')
|
|
1633
|
-
}
|
|
1634
|
-
|
|
1635
|
-
// Cap request bodies (1 MiB) so a malformed client cannot force us to buffer
|
|
1636
|
-
// unbounded memory on a local-only route.
|
|
1637
1735
|
const MAX_BODY_BYTES = 1024 * 1024
|
|
1638
1736
|
const readBody = (req) =>
|
|
1639
1737
|
new Promise((resolve) => {
|
|
@@ -1652,10 +1750,6 @@ export async function apply(ctx, config) {
|
|
|
1652
1750
|
})
|
|
1653
1751
|
|
|
1654
1752
|
// ── 本机目录选择器:DSH directoryPicker 服务优先,缺位/非原生则自持兜底 ──
|
|
1655
|
-
// 背景:web-app 的 `directory-picker` 行(-auto)在桌面启动路径里不物化成
|
|
1656
|
-
// loader 条目,`ctx.get('directoryPicker')` 实测为 null(沙箱 + 产品同路径)。
|
|
1657
|
-
// 因此插件自带原生选择器:macOS osascript / Linux zenity→kdialog,与 DSH
|
|
1658
|
-
// native 后端同一套调用约定(取消 → { cancelled },成功 → { path })。
|
|
1659
1753
|
const PICK_TIMEOUT_MS = 120000
|
|
1660
1754
|
const runPick = (bin, args) =>
|
|
1661
1755
|
new Promise((resolve, reject) => {
|
|
@@ -1663,14 +1757,13 @@ export async function apply(ctx, config) {
|
|
|
1663
1757
|
if (err) {
|
|
1664
1758
|
const code = err.code
|
|
1665
1759
|
const msg = String(stderr || '')
|
|
1666
|
-
// 用户在对话框里点了取消:osascript 退出码 1 + "User canceled"/-128
|
|
1667
1760
|
if (code === 1 && /(?:user canceled|-128)/i.test(msg)) return resolve({ cancelled: true })
|
|
1668
1761
|
if (err.signal || err.killed) return reject(new Error('目录选择已超时,请重试或直接在输入框填本地路径'))
|
|
1669
1762
|
if (code === 'ENOENT') return reject(Object.assign(new Error('未找到目录选择器程序 ' + bin), { code }))
|
|
1670
1763
|
return reject(new Error((msg.trim() || (err && err.message) || '无法打开系统文件夹选择器').split('\n')[0]))
|
|
1671
1764
|
}
|
|
1672
1765
|
const p = String(stdout || '').replace(/[\r\n]+$/, '').trim()
|
|
1673
|
-
resolve(p ? { path: p } : { cancelled: true })
|
|
1766
|
+
resolve(p === 'CANCELED' ? { cancelled: true } : (p ? { path: p } : { cancelled: true }))
|
|
1674
1767
|
})
|
|
1675
1768
|
})
|
|
1676
1769
|
const pickLocalNative = async () => {
|
|
@@ -1686,9 +1779,18 @@ export async function apply(ctx, config) {
|
|
|
1686
1779
|
throw err
|
|
1687
1780
|
}
|
|
1688
1781
|
}
|
|
1782
|
+
if (platform === 'win32') {
|
|
1783
|
+
const script =
|
|
1784
|
+
`Add-Type -AssemblyName System.Windows.Forms;` +
|
|
1785
|
+
`$f = New-Object System.Windows.Forms.FolderBrowserDialog;` +
|
|
1786
|
+
`$f.Description = 'Select Workspace Directory';` +
|
|
1787
|
+
`if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $f.SelectedPath } else { 'CANCELED' }`
|
|
1788
|
+
return runPick('powershell', ['-NoProfile', '-STA', '-Command', script])
|
|
1789
|
+
}
|
|
1689
1790
|
throw new Error('当前系统不支持自动打开目录选择器,请在输入框直接填本地路径')
|
|
1690
1791
|
}
|
|
1691
1792
|
|
|
1793
|
+
// ── route helpers ─────────────────────────────────────────────────────────
|
|
1692
1794
|
const routes = [
|
|
1693
1795
|
{
|
|
1694
1796
|
kind: 'exact',
|
|
@@ -1713,10 +1815,10 @@ export async function apply(ctx, config) {
|
|
|
1713
1815
|
privateKeyPath: payload.privateKeyPath,
|
|
1714
1816
|
workspace: payload.workspace,
|
|
1715
1817
|
})
|
|
1716
|
-
await pool.exec('echo ok', Math.min(config.commandTimeoutMs, 8000))
|
|
1818
|
+
await pool.exec('echo ok', { timeoutMs: Math.min(config.commandTimeoutMs, 8000) })
|
|
1717
1819
|
return sendJson(res, 200, { ok: true, ...status() })
|
|
1718
1820
|
} catch (err) {
|
|
1719
|
-
return sendJson(res, 500, { ok: false, error:
|
|
1821
|
+
return sendJson(res, 500, { ok: false, error: friendlyMessage(err, { host: payload && payload.host, port: payload && payload.port }) })
|
|
1720
1822
|
}
|
|
1721
1823
|
},
|
|
1722
1824
|
},
|
|
@@ -1725,23 +1827,20 @@ export async function apply(ctx, config) {
|
|
|
1725
1827
|
path: '/dsh-remote/ls',
|
|
1726
1828
|
handler: async (req, res) => {
|
|
1727
1829
|
try {
|
|
1728
|
-
// Proper URL parsing so `+` in a path decodes to a literal space
|
|
1729
|
-
// (the old regex missed it).
|
|
1730
1830
|
const q = new URL(req.url, 'http://localhost').searchParams
|
|
1731
1831
|
const p = q.get('path') ? decodeURIComponent(q.get('path')) : wsPath()
|
|
1732
1832
|
const out = await listDirStructured(p || '/')
|
|
1733
|
-
return sendJson(res, 200, { path:
|
|
1833
|
+
return sendJson(res, 200, { path: out.path, items: out.items })
|
|
1734
1834
|
} catch (err) {
|
|
1735
1835
|
return sendJson(res, 500, { error: String((err && err.message) || err) })
|
|
1736
1836
|
}
|
|
1737
1837
|
},
|
|
1738
1838
|
},
|
|
1739
1839
|
{
|
|
1740
|
-
// Read a remote file over SFTP (live
|
|
1741
|
-
//
|
|
1742
|
-
//
|
|
1743
|
-
//
|
|
1744
|
-
// whole file. `maxBytes` caps how much text is returned.
|
|
1840
|
+
// Read a remote file over SFTP (live). Text returns as content (CRLF→LF,
|
|
1841
|
+
// encoding-aware), binary is reported with size+head (base64). A stat
|
|
1842
|
+
// happens first; oversized files are previewed from a streamed head chunk
|
|
1843
|
+
// instead of being fully buffered.
|
|
1745
1844
|
kind: 'exact',
|
|
1746
1845
|
path: '/dsh-remote/read',
|
|
1747
1846
|
handler: async (req, res) => {
|
|
@@ -1749,27 +1848,52 @@ export async function apply(ctx, config) {
|
|
|
1749
1848
|
try {
|
|
1750
1849
|
const q = new URL(req.url, 'http://localhost').searchParams
|
|
1751
1850
|
let p = q.get('path') ? decodeURIComponent(q.get('path')) : ''
|
|
1851
|
+
let encoding = ''
|
|
1852
|
+
let maxBytes = 256 * 1024
|
|
1752
1853
|
if (req.method === 'POST') {
|
|
1753
1854
|
const body = JSON.parse((await readBody(req)) || '{}')
|
|
1754
1855
|
if (!p) p = String(body.path || '')
|
|
1856
|
+
if (body.encoding) encoding = String(body.encoding)
|
|
1857
|
+
if (body.maxBytes) maxBytes = Number(body.maxBytes)
|
|
1755
1858
|
}
|
|
1859
|
+
if (q.get('maxBytes')) maxBytes = Number(q.get('maxBytes'))
|
|
1756
1860
|
if (!p) return sendJson(res, 400, { ok: false, error: 'path is required' })
|
|
1757
1861
|
const target = normalizeRemotePath(p)
|
|
1758
|
-
|
|
1862
|
+
maxBytes = Math.min(Math.max(Number(maxBytes) || 256 * 1024, 1024), 2 * 1024 * 1024)
|
|
1759
1863
|
let sftp
|
|
1760
1864
|
try {
|
|
1761
1865
|
sftp = await pool.sftp()
|
|
1762
1866
|
} catch (err) {
|
|
1763
1867
|
return sendJson(res, 500, { ok: false, error: 'sftp unavailable: ' + ((err && err.message) || err) })
|
|
1764
1868
|
}
|
|
1869
|
+
let st
|
|
1870
|
+
try { st = await sftp.stat(target) } catch (err) {
|
|
1871
|
+
return sendJson(res, 500, { ok: false, error: 'read failed: ' + ((err && err.message) || err) })
|
|
1872
|
+
}
|
|
1873
|
+
if (st.size > maxBytes) {
|
|
1874
|
+
// Head preview for oversized files: fastGet to a temp file, read the
|
|
1875
|
+
// first maxBytes, delete the temp. Never buffers the whole file.
|
|
1876
|
+
const tmp = path.join(dshBase(), '.dsh-remote-preview-' + process.pid)
|
|
1877
|
+
try {
|
|
1878
|
+
await sftp.fastGet(target, tmp)
|
|
1879
|
+
const { open } = await import('node:fs/promises')
|
|
1880
|
+
const fd = await open(tmp, 'r')
|
|
1881
|
+
const head = Buffer.alloc(Math.min(maxBytes, st.size))
|
|
1882
|
+
await fd.read(head, 0, head.length, 0)
|
|
1883
|
+
await fd.close()
|
|
1884
|
+
try { unlinkSync(tmp) } catch {}
|
|
1885
|
+
const content = decodeBuf(head, encoding || config.encoding).replace(/\r\n/g, '\n')
|
|
1886
|
+
return sendJson(res, 200, { ok: true, binary: false, content, truncated: true, size: st.size })
|
|
1887
|
+
} catch (err) {
|
|
1888
|
+
return sendJson(res, 500, { ok: false, error: 'read failed: ' + ((err && err.message) || err) })
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1765
1891
|
let buf
|
|
1766
1892
|
try {
|
|
1767
1893
|
buf = await sftp.readFile(target)
|
|
1768
1894
|
} catch (err) {
|
|
1769
1895
|
return sendJson(res, 500, { ok: false, error: 'read failed: ' + ((err && err.message) || err) })
|
|
1770
1896
|
}
|
|
1771
|
-
// Binary sniff: NUL byte in the first 8KB (or the whole file when
|
|
1772
|
-
// smaller) marks the file binary — matches the sidebar's convention.
|
|
1773
1897
|
const headN = Math.min(buf.length, 8192)
|
|
1774
1898
|
let binary = false
|
|
1775
1899
|
for (let i = 0; i < headN; i++) {
|
|
@@ -1778,7 +1902,7 @@ export async function apply(ctx, config) {
|
|
|
1778
1902
|
if (binary) {
|
|
1779
1903
|
return sendJson(res, 200, { ok: true, binary: true, size: buf.length, head: buf.slice(0, Math.min(buf.length, 4096)).toString('base64') })
|
|
1780
1904
|
}
|
|
1781
|
-
let content = buf.
|
|
1905
|
+
let content = decodeBuf(buf, encoding || config.encoding).replace(/\r\n/g, '\n')
|
|
1782
1906
|
const truncated = content.length > maxBytes
|
|
1783
1907
|
if (truncated) content = content.slice(0, maxBytes) + `\n…[truncated: ${buf.length - maxBytes} more bytes]`
|
|
1784
1908
|
return sendJson(res, 200, { ok: true, binary: false, content, truncated })
|
|
@@ -1787,6 +1911,111 @@ export async function apply(ctx, config) {
|
|
|
1787
1911
|
}
|
|
1788
1912
|
},
|
|
1789
1913
|
},
|
|
1914
|
+
{
|
|
1915
|
+
// Write a remote file (sidebar editor save). `expectedMtime` is an
|
|
1916
|
+
// optimistic lock: a 409 is returned when the remote changed meanwhile.
|
|
1917
|
+
kind: 'exact',
|
|
1918
|
+
path: '/dsh-remote/write',
|
|
1919
|
+
handler: async (req, res) => {
|
|
1920
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
1921
|
+
try {
|
|
1922
|
+
const body = JSON.parse((await readBody(req)) || '{}')
|
|
1923
|
+
const p = normalizeRemotePath(String(body.path || ''))
|
|
1924
|
+
if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path is required' })
|
|
1925
|
+
const sftp = await pool.sftp()
|
|
1926
|
+
let st = null
|
|
1927
|
+
try { st = await sftp.stat(p) } catch { /* new file */ }
|
|
1928
|
+
if (body.expectedMtime != null && st && st.mtime !== Number(body.expectedMtime)) {
|
|
1929
|
+
return sendJson(res, 409, { ok: false, error: `远端文件已变化(mtime ${st.mtime} ≠ ${body.expectedMtime}),已放弃保存——请重新读取后再编辑` })
|
|
1930
|
+
}
|
|
1931
|
+
const buf = encodeText(String(body.content ?? ''), body.encoding || config.encoding)
|
|
1932
|
+
if (!st) await mkdirRemoteDirs(sftp, remoteDirname(p))
|
|
1933
|
+
await sftp.writeFile(p, buf)
|
|
1934
|
+
audit('write', `write ${p}`, 0)
|
|
1935
|
+
const st2 = await sftp.stat(p).catch(() => null)
|
|
1936
|
+
return sendJson(res, 200, { ok: true, bytes: buf.byteLength, mtime: st2 ? st2.mtime : Math.floor(Date.now() / 1000) })
|
|
1937
|
+
} catch (err) {
|
|
1938
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
1939
|
+
}
|
|
1940
|
+
},
|
|
1941
|
+
},
|
|
1942
|
+
{
|
|
1943
|
+
// Generic remote fs ops used by the sidebar context menu + picker:
|
|
1944
|
+
// mkdir / rename / remove (recursive) / write / append.
|
|
1945
|
+
kind: 'exact',
|
|
1946
|
+
path: '/dsh-remote/fs',
|
|
1947
|
+
handler: async (req, res) => {
|
|
1948
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
1949
|
+
try {
|
|
1950
|
+
const body = JSON.parse((await readBody(req)) || '{}')
|
|
1951
|
+
const op = String(body.op || '')
|
|
1952
|
+
const sftp = await pool.sftp()
|
|
1953
|
+
if (op === 'mkdir') {
|
|
1954
|
+
const p = normalizeRemotePath(String(body.path || ''))
|
|
1955
|
+
if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' })
|
|
1956
|
+
await mkdirRemoteDirs(sftp, p)
|
|
1957
|
+
audit('mkdir', `mkdir ${p}`, 0)
|
|
1958
|
+
return sendJson(res, 200, { ok: true })
|
|
1959
|
+
}
|
|
1960
|
+
if (op === 'rename') {
|
|
1961
|
+
const p = normalizeRemotePath(String(body.path || ''))
|
|
1962
|
+
const d = normalizeRemotePath(String(body.dest || ''))
|
|
1963
|
+
if (!p || !d) return sendJson(res, 400, { ok: false, error: 'path and dest required' })
|
|
1964
|
+
await sftp.rename(p, d)
|
|
1965
|
+
audit('move', `move ${p} → ${d}`, 0)
|
|
1966
|
+
return sendJson(res, 200, { ok: true })
|
|
1967
|
+
}
|
|
1968
|
+
if (op === 'remove') {
|
|
1969
|
+
const p = normalizeRemotePath(String(body.path || ''))
|
|
1970
|
+
if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' })
|
|
1971
|
+
const st = await sftp.stat(p).catch(() => null)
|
|
1972
|
+
if (!st) return sendJson(res, 404, { ok: false, error: 'not found' })
|
|
1973
|
+
if (st.isDirectory && st.isDirectory()) {
|
|
1974
|
+
await removeRemoteTree(sftp, p)
|
|
1975
|
+
} else {
|
|
1976
|
+
await sftp.unlink(p)
|
|
1977
|
+
}
|
|
1978
|
+
audit('remove', `remove ${p}`, 0)
|
|
1979
|
+
return sendJson(res, 200, { ok: true })
|
|
1980
|
+
}
|
|
1981
|
+
if (op === 'write' || op === 'append') {
|
|
1982
|
+
const p = normalizeRemotePath(String(body.path || ''))
|
|
1983
|
+
if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' })
|
|
1984
|
+
let content = ''
|
|
1985
|
+
if (op === 'append') {
|
|
1986
|
+
try { content = decodeBuf(await sftp.readFile(p), body.encoding || config.encoding) } catch { /* new */ }
|
|
1987
|
+
}
|
|
1988
|
+
const buf = encodeText(content + String(body.content ?? ''), body.encoding || config.encoding)
|
|
1989
|
+
if (op === 'write' && !(await sftp.stat(p).catch(() => null))) await mkdirRemoteDirs(sftp, remoteDirname(p))
|
|
1990
|
+
await sftp.writeFile(p, buf)
|
|
1991
|
+
audit(op, `${op} ${p}`, 0)
|
|
1992
|
+
return sendJson(res, 200, { ok: true, bytes: buf.byteLength })
|
|
1993
|
+
}
|
|
1994
|
+
if (op === 'download') {
|
|
1995
|
+
// Download a remote file into the current workspace mirror.
|
|
1996
|
+
const p = normalizeRemotePath(String(body.path || ''))
|
|
1997
|
+
const ws = wsPath()
|
|
1998
|
+
if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' })
|
|
1999
|
+
if (!ws) return sendJson(res, 400, { ok: false, error: 'no remote workspace set' })
|
|
2000
|
+
const st = await sftp.stat(p).catch(() => null)
|
|
2001
|
+
if (!st) return sendJson(res, 404, { ok: false, error: 'not found' })
|
|
2002
|
+
if (config.maxFileBytes > 0 && st.size > config.maxFileBytes) {
|
|
2003
|
+
return sendJson(res, 400, { ok: false, error: `file is ${st.size} bytes (over ${config.maxFileBytes} cap)` })
|
|
2004
|
+
}
|
|
2005
|
+
const base = mirrorDirFor(ws, config.host, config.username, config.port)
|
|
2006
|
+
const rel = p.startsWith(ws) ? p.slice(ws.length).replace(/^\/+/, '') : p.slice(1)
|
|
2007
|
+
const local = path.join(base, rel)
|
|
2008
|
+
mkdirSync(path.dirname(local), { recursive: true })
|
|
2009
|
+
await sftp.fastGet(p, local)
|
|
2010
|
+
audit('download', `download ${p}`, 0)
|
|
2011
|
+
return sendJson(res, 200, { ok: true, bytes: st.size, local })
|
|
2012
|
+
}
|
|
2013
|
+
return sendJson(res, 400, { ok: false, error: 'unknown op' })
|
|
2014
|
+
} catch (err) {
|
|
2015
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
2016
|
+
}
|
|
2017
|
+
},
|
|
2018
|
+
},
|
|
1790
2019
|
{
|
|
1791
2020
|
kind: 'exact',
|
|
1792
2021
|
path: '/dsh-remote/workspace',
|
|
@@ -1800,6 +2029,7 @@ export async function apply(ctx, config) {
|
|
|
1800
2029
|
if (!okDir) return sendJson(res, 400, { ok: false, error: `not a directory: ${p}` })
|
|
1801
2030
|
persistWorkspace(p)
|
|
1802
2031
|
const local = ensureMirror(p, config.host, config.username, config.port)
|
|
2032
|
+
startAutoPush(local)
|
|
1803
2033
|
return sendJson(res, 200, { ok: true, workspace: p, localMirror: local, ...status() })
|
|
1804
2034
|
} catch (err) {
|
|
1805
2035
|
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
@@ -1816,12 +2046,11 @@ export async function apply(ctx, config) {
|
|
|
1816
2046
|
const p = normalizeRemotePath(String(payload.path || ''))
|
|
1817
2047
|
if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path must be an absolute directory' })
|
|
1818
2048
|
if (!config.host) return sendJson(res, 400, { ok: false, error: 'no remote host configured/connected — connect first' })
|
|
1819
|
-
// Always verify over SSH (connecting if needed) so an unconnected or
|
|
1820
|
-
// wrong host can never silently mint a mirror for a bogus path.
|
|
1821
2049
|
const okDir = await isRemoteDir(p)
|
|
1822
2050
|
if (!okDir) return sendJson(res, 400, { ok: false, error: `not a directory (or unreachable): ${p}` })
|
|
1823
2051
|
const local = ensureMirror(p, config.host, config.username, config.port)
|
|
1824
2052
|
persistWorkspace(p)
|
|
2053
|
+
startAutoPush(local)
|
|
1825
2054
|
return sendJson(res, 200, { ok: true, path: p, localMirror: local, ...status() })
|
|
1826
2055
|
} catch (err) {
|
|
1827
2056
|
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
@@ -1834,12 +2063,6 @@ export async function apply(ctx, config) {
|
|
|
1834
2063
|
handler: async (req, res) => {
|
|
1835
2064
|
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
1836
2065
|
try {
|
|
1837
|
-
// 1) 优先用 DSH directoryPicker 服务(native 后端)。必须用
|
|
1838
|
-
// ctx.get() 按名取:属性形式 ctx.directoryPicker 会抛 cordis
|
|
1839
|
-
// "cannot get property ... without inject"(该名字未在本插件
|
|
1840
|
-
// inject 里声明),ctx.get() 无此要求、缺失返回 null。
|
|
1841
|
-
// 2) 服务缺位或非 native(实测 web-app 的 -auto 行在桌面启动路径
|
|
1842
|
-
// 不物化后端)→ 自持 osascript/zenity/kdialog 兜底。
|
|
1843
2066
|
let outcome = null
|
|
1844
2067
|
let via = 'service'
|
|
1845
2068
|
const dp = (ctx && typeof ctx.get === 'function') ? (ctx.get('directoryPicker') || null) : null
|
|
@@ -1853,7 +2076,7 @@ export async function apply(ctx, config) {
|
|
|
1853
2076
|
outcome = (typeof p === 'string' && p) ? { path: p } : { cancelled: true }
|
|
1854
2077
|
}
|
|
1855
2078
|
} catch (err) {
|
|
1856
|
-
outcome = null
|
|
2079
|
+
outcome = null
|
|
1857
2080
|
}
|
|
1858
2081
|
}
|
|
1859
2082
|
if (!outcome) {
|
|
@@ -1885,30 +2108,63 @@ export async function apply(ctx, config) {
|
|
|
1885
2108
|
if (action === 'add' || action === 'update') {
|
|
1886
2109
|
const host = String(body.host || '').trim()
|
|
1887
2110
|
if (!host) return sendJson(res, 400, { ok: false, error: 'host required' })
|
|
2111
|
+
const id = body.id || machineId()
|
|
2112
|
+
const i = machineIndex(id)
|
|
2113
|
+
const prev = i >= 0 ? machines[i] : null
|
|
2114
|
+
const credBackend = body.credentialBackend === 'plain' ? 'plain'
|
|
2115
|
+
: (body.encryptPassword ? platformBackend() : (prev && prev.credentialBackend ? prev.credentialBackend : 'plain'))
|
|
1888
2116
|
const rec = {
|
|
1889
|
-
id
|
|
2117
|
+
id,
|
|
1890
2118
|
name: String(body.name || '').trim() || host,
|
|
1891
2119
|
host,
|
|
1892
2120
|
port: Number(body.port) || 22,
|
|
1893
2121
|
username: String(body.username || '').trim() || 'root',
|
|
1894
|
-
password:
|
|
2122
|
+
password: '',
|
|
1895
2123
|
privateKeyPath: String(body.privateKeyPath || '').trim(),
|
|
1896
2124
|
passphrase: body.passphrase || '',
|
|
1897
2125
|
workspace: String(body.workspace || '').trim(),
|
|
2126
|
+
hostKeyMode: body.hostKeyMode || '',
|
|
2127
|
+
useAgent: !!body.useAgent,
|
|
2128
|
+
keyboardInteractive: !!body.keyboardInteractive,
|
|
2129
|
+
proxy: body.proxy && body.proxy.host ? {
|
|
2130
|
+
host: String(body.proxy.host),
|
|
2131
|
+
port: Number(body.proxy.port) || 22,
|
|
2132
|
+
username: String(body.proxy.username || '').trim(),
|
|
2133
|
+
password: String(body.proxy.password || ''),
|
|
2134
|
+
privateKeyPath: String(body.proxy.privateKeyPath || '').trim(),
|
|
2135
|
+
} : undefined,
|
|
2136
|
+
credentialBackend: credBackend,
|
|
2137
|
+
recentWorkspaces: prev && prev.recentWorkspaces ? prev.recentWorkspaces : [],
|
|
2138
|
+
lastConnectedAt: prev && prev.lastConnectedAt ? prev.lastConnectedAt : null,
|
|
2139
|
+
latencyMs: prev && prev.latencyMs ? prev.latencyMs : null,
|
|
2140
|
+
}
|
|
2141
|
+
if (body.password) {
|
|
2142
|
+
if (credBackend !== 'plain') {
|
|
2143
|
+
await saveSecret(id, String(body.password), secretsDir())
|
|
2144
|
+
rec.password = ''
|
|
2145
|
+
} else {
|
|
2146
|
+
rec.password = String(body.password)
|
|
2147
|
+
}
|
|
2148
|
+
} else if (prev && prev.password) {
|
|
2149
|
+
rec.password = prev.password
|
|
1898
2150
|
}
|
|
1899
|
-
const i = machineIndex(rec.id)
|
|
1900
2151
|
if (i >= 0) machines[i] = rec; else machines.push(rec)
|
|
1901
|
-
if (!store.currentId)
|
|
2152
|
+
if (!store.currentId) store.currentId = id
|
|
1902
2153
|
saveMachines(machines, store.currentId)
|
|
1903
|
-
if (store.currentId ===
|
|
2154
|
+
if (store.currentId === id) await applyActiveMachine()
|
|
1904
2155
|
return sendJson(res, 200, { ok: true, machine: sanitizeMachine(rec), machines: machines.map(sanitizeMachine), currentId: store.currentId })
|
|
1905
2156
|
}
|
|
1906
2157
|
if (action === 'delete') {
|
|
1907
2158
|
const i = machineIndex(String(body.id || ''))
|
|
1908
2159
|
if (i < 0) return sendJson(res, 404, { ok: false, error: 'machine not found' })
|
|
2160
|
+
const m = machines[i]
|
|
2161
|
+
if (m.credentialBackend && m.credentialBackend !== 'plain') {
|
|
2162
|
+
await deleteSecret(m.id, secretsDir()).catch(() => {})
|
|
2163
|
+
}
|
|
1909
2164
|
machines.splice(i, 1)
|
|
1910
2165
|
if (store.currentId === body.id) store.currentId = machines[0] ? machines[0].id : null
|
|
1911
2166
|
saveMachines(machines, store.currentId)
|
|
2167
|
+
if (store.currentId) await applyActiveMachine()
|
|
1912
2168
|
return sendJson(res, 200, { ok: true, machines: machines.map(sanitizeMachine), currentId: store.currentId })
|
|
1913
2169
|
}
|
|
1914
2170
|
return sendJson(res, 400, { ok: false, error: 'unknown action' })
|
|
@@ -1934,16 +2190,30 @@ export async function apply(ctx, config) {
|
|
|
1934
2190
|
password: String(body.password || ''),
|
|
1935
2191
|
privateKeyPath: String(body.privateKeyPath || config.privateKeyPath),
|
|
1936
2192
|
passphrase: String(body.passphrase || ''),
|
|
2193
|
+
proxy: body.proxy && body.proxy.host ? {
|
|
2194
|
+
host: String(body.proxy.host),
|
|
2195
|
+
port: Number(body.proxy.port) || 22,
|
|
2196
|
+
username: String(body.proxy.username || ''),
|
|
2197
|
+
password: String(body.proxy.password || ''),
|
|
2198
|
+
privateKeyPath: String(body.proxy.privateKeyPath || ''),
|
|
2199
|
+
} : undefined,
|
|
1937
2200
|
connectTimeoutMs: Math.min(Math.max(Number(body.connectTimeoutMs) || config.connectTimeoutMs, 2000), 30000),
|
|
1938
2201
|
commandTimeoutMs: 10000,
|
|
1939
2202
|
})
|
|
1940
2203
|
const started = Date.now()
|
|
1941
2204
|
await probe.connect()
|
|
1942
|
-
await probe.exec('true', 10000)
|
|
2205
|
+
await probe.exec('true', { timeoutMs: 10000 })
|
|
1943
2206
|
probe.close()
|
|
1944
|
-
|
|
2207
|
+
const latencyMs = Date.now() - started
|
|
2208
|
+
const mi = machines.findIndex((m) => m.host === probe.config.host && m.username === probe.config.username && Number(m.port) === probe.config.port)
|
|
2209
|
+
if (mi >= 0) {
|
|
2210
|
+
machines[mi].lastConnectedAt = new Date().toISOString()
|
|
2211
|
+
machines[mi].latencyMs = latencyMs
|
|
2212
|
+
saveMachines(machines, store.currentId)
|
|
2213
|
+
}
|
|
2214
|
+
return sendJson(res, 200, { ok: true, host: probe.config.host, user: probe.config.username, latencyMs, lastConnectedAt: new Date().toISOString() })
|
|
1945
2215
|
} catch (err) {
|
|
1946
|
-
return sendJson(res, 200, { ok: false, error:
|
|
2216
|
+
return sendJson(res, 200, { ok: false, error: friendlyMessage(err, { host: body && body.host, port: body && body.port }) })
|
|
1947
2217
|
}
|
|
1948
2218
|
},
|
|
1949
2219
|
},
|
|
@@ -1954,7 +2224,7 @@ export async function apply(ctx, config) {
|
|
|
1954
2224
|
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
1955
2225
|
try {
|
|
1956
2226
|
const body = JSON.parse((await readBody(req)) || '{}')
|
|
1957
|
-
const okSet = setCurrent(String(body.id || ''))
|
|
2227
|
+
const okSet = await setCurrent(String(body.id || ''))
|
|
1958
2228
|
if (!okSet) return sendJson(res, 404, { ok: false, error: 'machine not found' })
|
|
1959
2229
|
return sendJson(res, 200, { ok: true, ...status() })
|
|
1960
2230
|
} catch (err) {
|
|
@@ -1967,46 +2237,153 @@ export async function apply(ctx, config) {
|
|
|
1967
2237
|
path: '/dsh-remote/forget-key',
|
|
1968
2238
|
handler: async (req, res) => {
|
|
1969
2239
|
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
1970
|
-
// Drop the trusted host-key record for the CURRENT target so the next
|
|
1971
|
-
// connect re-records it (use after an intentional host reinstall or a
|
|
1972
|
-
// false MITM alarm).
|
|
1973
2240
|
createHostKeyGuard(config).forgetHost()
|
|
1974
2241
|
return sendJson(res, 200, { ok: true, ...status() })
|
|
1975
2242
|
},
|
|
1976
2243
|
},
|
|
2244
|
+
{
|
|
2245
|
+
kind: 'exact',
|
|
2246
|
+
path: '/dsh-remote/forwards',
|
|
2247
|
+
handler: async (req, res) => {
|
|
2248
|
+
if (req.method === 'GET') return sendJson(res, 200, { forwards: forwards.list() })
|
|
2249
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
2250
|
+
try {
|
|
2251
|
+
const body = JSON.parse((await readBody(req)) || '{}')
|
|
2252
|
+
const action = String(body.action || '')
|
|
2253
|
+
if (action === 'define') {
|
|
2254
|
+
const d = forwards.define({
|
|
2255
|
+
direction: body.direction === 'reverse' ? 'reverse' : 'local',
|
|
2256
|
+
listenPort: Number(body.listenPort),
|
|
2257
|
+
targetHost: body.targetHost || '127.0.0.1',
|
|
2258
|
+
targetPort: Number(body.targetPort) || Number(body.listenPort),
|
|
2259
|
+
autoStart: !!body.autoStart,
|
|
2260
|
+
machineId: store.currentId,
|
|
2261
|
+
})
|
|
2262
|
+
return sendJson(res, 200, { ok: true, forward: d, forwards: forwards.list() })
|
|
2263
|
+
}
|
|
2264
|
+
if (action === 'start' || action === 'stop') {
|
|
2265
|
+
const d = forwards.list().find((f) => f.id === body.id)
|
|
2266
|
+
if (!d) return sendJson(res, 404, { ok: false, error: 'forward not found' })
|
|
2267
|
+
if (action === 'start') {
|
|
2268
|
+
const r = await forwards.start(d)
|
|
2269
|
+
if (!r.ok) return sendJson(res, 500, { ok: false, error: r.error })
|
|
2270
|
+
} else {
|
|
2271
|
+
forwards.stop(d.id)
|
|
2272
|
+
}
|
|
2273
|
+
return sendJson(res, 200, { ok: true, forwards: forwards.list() })
|
|
2274
|
+
}
|
|
2275
|
+
if (action === 'remove') {
|
|
2276
|
+
forwards.remove(String(body.id || ''))
|
|
2277
|
+
return sendJson(res, 200, { ok: true, forwards: forwards.list() })
|
|
2278
|
+
}
|
|
2279
|
+
return sendJson(res, 400, { ok: false, error: 'unknown action' })
|
|
2280
|
+
} catch (err) {
|
|
2281
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
2282
|
+
}
|
|
2283
|
+
},
|
|
2284
|
+
},
|
|
2285
|
+
{
|
|
2286
|
+
kind: 'exact',
|
|
2287
|
+
path: '/dsh-remote/task',
|
|
2288
|
+
handler: async (req, res) => {
|
|
2289
|
+
if (req.method === 'GET') {
|
|
2290
|
+
const q = new URL(req.url, 'http://localhost').searchParams
|
|
2291
|
+
const id = q.get('id') || ''
|
|
2292
|
+
const t = tasks.get(id)
|
|
2293
|
+
if (!t) return sendJson(res, 404, { ok: false, error: 'task not found' })
|
|
2294
|
+
return sendJson(res, 200, { ok: true, task: t })
|
|
2295
|
+
}
|
|
2296
|
+
if (req.method === 'POST') {
|
|
2297
|
+
try {
|
|
2298
|
+
const body = JSON.parse((await readBody(req)) || '{}')
|
|
2299
|
+
const id = String(body.id || '')
|
|
2300
|
+
if (body.action === 'cancel') {
|
|
2301
|
+
const ok = tasks.cancel(id)
|
|
2302
|
+
return sendJson(res, 200, { ok, task: tasks.get(id) })
|
|
2303
|
+
}
|
|
2304
|
+
return sendJson(res, 400, { ok: false, error: 'unknown action' })
|
|
2305
|
+
} catch (err) {
|
|
2306
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
2310
|
+
},
|
|
2311
|
+
},
|
|
2312
|
+
{
|
|
2313
|
+
kind: 'exact',
|
|
2314
|
+
path: '/dsh-remote/tasks',
|
|
2315
|
+
handler: async (req, res) => {
|
|
2316
|
+
if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
2317
|
+
return sendJson(res, 200, { ok: true, tasks: tasks.list() })
|
|
2318
|
+
},
|
|
2319
|
+
},
|
|
2320
|
+
{
|
|
2321
|
+
kind: 'exact',
|
|
2322
|
+
path: '/dsh-remote/audit',
|
|
2323
|
+
handler: async (req, res) => {
|
|
2324
|
+
if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
2325
|
+
const q = new URL(req.url, 'http://localhost').searchParams
|
|
2326
|
+
return sendJson(res, 200, { ok: true, auditEnabled: !!config.auditLog, file: auditFile(), lines: readAudit(q.get('limit')) })
|
|
2327
|
+
},
|
|
2328
|
+
},
|
|
2329
|
+
{
|
|
2330
|
+
kind: 'exact',
|
|
2331
|
+
path: '/dsh-remote/ssh-config',
|
|
2332
|
+
handler: async (req, res) => {
|
|
2333
|
+
if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
2334
|
+
try {
|
|
2335
|
+
const text = readSshConfigText()
|
|
2336
|
+
return sendJson(res, 200, { ok: true, file: sshConfigPath(), present: !!text, entries: importableEntries(text) })
|
|
2337
|
+
} catch (err) {
|
|
2338
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
2339
|
+
}
|
|
2340
|
+
},
|
|
2341
|
+
},
|
|
2342
|
+
{
|
|
2343
|
+
kind: 'exact',
|
|
2344
|
+
path: '/dsh-remote/home',
|
|
2345
|
+
handler: async (req, res) => {
|
|
2346
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
2347
|
+
try {
|
|
2348
|
+
const out = await pool.exec('echo ~', { timeoutMs: Math.min(config.commandTimeoutMs, 5000) })
|
|
2349
|
+
const home = String(out.stdout || '').replace(/\s+/g, '').trim()
|
|
2350
|
+
if (!home) return sendJson(res, 200, { ok: true, home: null, hint: 'Windows 远程暂不支持 ~ 解析,请直接输入绝对路径' })
|
|
2351
|
+
return sendJson(res, 200, { ok: true, home })
|
|
2352
|
+
} catch (err) {
|
|
2353
|
+
return sendJson(res, 200, { ok: true, home: null, error: String((err && err.message) || err) })
|
|
2354
|
+
}
|
|
2355
|
+
},
|
|
2356
|
+
},
|
|
1977
2357
|
{
|
|
1978
2358
|
kind: 'exact',
|
|
1979
2359
|
path: '/dsh-remote/update-check',
|
|
1980
2360
|
handler: async (req, res) => {
|
|
1981
2361
|
if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
2362
|
+
const current = readVersion()
|
|
1982
2363
|
const latest = await fetchLatestVersion()
|
|
1983
|
-
if (latest === null) return sendJson(res, 200, { ok: false, current
|
|
1984
|
-
const
|
|
1985
|
-
const updatedMarker = existsSync(path.join(selfDir(), '.dsh-remote-updated'))
|
|
2364
|
+
if (latest === null) return sendJson(res, 200, { ok: false, current, error: '无法连接 npm registry' })
|
|
2365
|
+
const rawMode = readUpdateMode() || config.updateMode || 'manual'
|
|
1986
2366
|
return sendJson(res, 200, {
|
|
1987
2367
|
ok: true,
|
|
1988
|
-
current
|
|
2368
|
+
current,
|
|
1989
2369
|
latest,
|
|
1990
|
-
updateAvailable,
|
|
1991
|
-
updateMode:
|
|
1992
|
-
updatedMarker,
|
|
2370
|
+
updateAvailable: gtVersion(latest, current),
|
|
2371
|
+
updateMode: ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : 'manual',
|
|
2372
|
+
updatedMarker: existsSync(path.join(selfDir(), '.dsh-remote-updated')),
|
|
1993
2373
|
})
|
|
1994
2374
|
},
|
|
1995
2375
|
},
|
|
1996
2376
|
{
|
|
1997
2377
|
kind: 'exact',
|
|
1998
|
-
path: '/dsh-remote/update-
|
|
2378
|
+
path: '/dsh-remote/update-apply',
|
|
1999
2379
|
handler: async (req, res) => {
|
|
2000
2380
|
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
2001
2381
|
try {
|
|
2002
2382
|
const body = JSON.parse((await readBody(req)) || '{}')
|
|
2003
|
-
const
|
|
2004
|
-
if (!
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
const dir = selfDir()
|
|
2008
|
-
try { writeFileSync(path.join(dir, 'update-mode'), mode) } catch (e) { return sendJson(res, 500, { ok: false, error: 'cannot persist mode: ' + ((e && e.message) || e) }) }
|
|
2009
|
-
return sendJson(res, 200, { ok: true, mode })
|
|
2383
|
+
const target = String(body.version || '')
|
|
2384
|
+
if (!target) return sendJson(res, 400, { ok: false, error: 'version is required' })
|
|
2385
|
+
const result = await applyUpdate(target)
|
|
2386
|
+
return sendJson(res, 200, { ok: true, from: readVersion(), ...result })
|
|
2010
2387
|
} catch (err) {
|
|
2011
2388
|
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
2012
2389
|
}
|
|
@@ -2014,15 +2391,15 @@ export async function apply(ctx, config) {
|
|
|
2014
2391
|
},
|
|
2015
2392
|
{
|
|
2016
2393
|
kind: 'exact',
|
|
2017
|
-
path: '/dsh-remote/update-
|
|
2394
|
+
path: '/dsh-remote/update-mode',
|
|
2018
2395
|
handler: async (req, res) => {
|
|
2019
2396
|
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
2020
2397
|
try {
|
|
2021
2398
|
const body = JSON.parse((await readBody(req)) || '{}')
|
|
2022
|
-
const
|
|
2023
|
-
if (!
|
|
2024
|
-
|
|
2025
|
-
return sendJson(res, 200, { ok: true,
|
|
2399
|
+
const mode = String(body.mode || '')
|
|
2400
|
+
if (!['manual', 'auto', 'off'].includes(mode)) return sendJson(res, 400, { ok: false, error: 'mode must be manual | auto | off' })
|
|
2401
|
+
if (!persistUpdateMode(mode)) return sendJson(res, 500, { ok: false, error: 'cannot persist mode' })
|
|
2402
|
+
return sendJson(res, 200, { ok: true, mode })
|
|
2026
2403
|
} catch (err) {
|
|
2027
2404
|
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
2028
2405
|
}
|
|
@@ -2032,4 +2409,23 @@ export async function apply(ctx, config) {
|
|
|
2032
2409
|
|
|
2033
2410
|
const disposers = routes.map((r) => webServer.register(r))
|
|
2034
2411
|
ctx.effect(() => () => disposers.forEach((d) => d && d()), 'dsh-remote.routes')
|
|
2035
|
-
|
|
2412
|
+
|
|
2413
|
+
// ── auto-update: check on load + on an interval; apply silently ───────────
|
|
2414
|
+
// Failures are swallowed (never break the plugin). The persisted override
|
|
2415
|
+
// (settings UI) wins over the profile-config default.
|
|
2416
|
+
const rawMode = readUpdateMode() || config.updateMode || 'manual'
|
|
2417
|
+
const effectiveUpdateMode = ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : 'manual'
|
|
2418
|
+
if (effectiveUpdateMode === 'auto') {
|
|
2419
|
+
const currentVersion = readVersion()
|
|
2420
|
+
const checkAndApply = async () => {
|
|
2421
|
+
const latest = await fetchLatestVersion()
|
|
2422
|
+
if (latest && gtVersion(latest, currentVersion)) {
|
|
2423
|
+
try { await applyUpdate(latest) } catch {}
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
void checkAndApply()
|
|
2427
|
+
const updateTimer = setInterval(checkAndApply, Math.max(config.updateCheckIntervalMs, 60000))
|
|
2428
|
+
if (typeof updateTimer.unref === 'function') updateTimer.unref()
|
|
2429
|
+
ctx.effect(() => () => clearInterval(updateTimer), 'dsh-remote.update-timer')
|
|
2430
|
+
}
|
|
2431
|
+
}
|