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