dsh-remote 0.8.37 → 0.8.39
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.en.md +6 -2
- package/README.md +6 -2
- package/lib/archive.js +680 -0
- package/lib/backup.js +519 -0
- package/lib/client.js +220 -0
- package/lib/errors.js +9 -0
- package/lib/index.js +346 -52
- package/lib/pool.js +14 -1
- package/lib/routes-backup.js +313 -0
- package/lib/update.js +9 -0
- package/package.json +2 -2
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// Settings-panel routes for workspace compressed backup / restore.
|
|
2
|
+
//
|
|
3
|
+
// Kept in its own module (like lib/routes-fs.js) so the route surface stays
|
|
4
|
+
// readable and the whole thing is unit-testable with an injected binding — the
|
|
5
|
+
// production wiring in lib/index.js supplies the real SSH pool, audit log and
|
|
6
|
+
// configuration.
|
|
7
|
+
//
|
|
8
|
+
// Scope: this is the machine/session-scoped JSON API behind Settings → 备份.
|
|
9
|
+
// The model-facing equivalents are the rw_backup / rw_backup_list / rw_restore
|
|
10
|
+
// tools, which resolve their own session binding and share the same lib/backup.js
|
|
11
|
+
// implementation — one behaviour, two entrances.
|
|
12
|
+
import {
|
|
13
|
+
createBackup, listBackups, verifyBackup, deleteBackup, restoreBackup,
|
|
14
|
+
pullBackup, pushBackup, listLocalBackups, deleteLocalBackup, verifyLocalBackup,
|
|
15
|
+
resolveRemoteHome,
|
|
16
|
+
} from './backup.js'
|
|
17
|
+
import { parseExcludes, resolveBackupDir, isSafeArchiveName, formatBytes, DEFAULT_MAX_TRANSFER_BYTES } from './archive.js'
|
|
18
|
+
import { describeCreate } from './backup.js'
|
|
19
|
+
|
|
20
|
+
/** Default cap on a cross-machine archive copy; the archive itself is uncapped. */
|
|
21
|
+
export { DEFAULT_MAX_TRANSFER_BYTES }
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {object} deps
|
|
25
|
+
* @param {Function} deps.sendJson - (res, status, body)
|
|
26
|
+
* @param {Function} deps.readBody - (req) => Promise<string>
|
|
27
|
+
* @param {object} deps.config - the resolved plugin config
|
|
28
|
+
* @param {Function} deps.audit - (op, detail, code, target)
|
|
29
|
+
* @param {Function} deps.resolveRequestBinding - (req, body) => binding (see routes-fs)
|
|
30
|
+
* @param {Function} deps.resolveMachineBinding - (machineId) => binding for an explicit machine
|
|
31
|
+
* @param {Function} deps.localRootFor - (binding) => local directory for pulled archives
|
|
32
|
+
* @returns {Array<object>} route definitions
|
|
33
|
+
*/
|
|
34
|
+
export function createBackupRoutes({
|
|
35
|
+
sendJson, readBody, config, audit, resolveRequestBinding, resolveMachineBinding, localRootFor,
|
|
36
|
+
}) {
|
|
37
|
+
const maxTransfer = Number(config.maxBackupTransferBytes) > 0
|
|
38
|
+
? Number(config.maxBackupTransferBytes)
|
|
39
|
+
: DEFAULT_MAX_TRANSFER_BYTES
|
|
40
|
+
|
|
41
|
+
/** The remote backup directory for a binding (config override → $HOME/.dsh-remote/backups). */
|
|
42
|
+
const backupDirFor = async (b) => {
|
|
43
|
+
const override = String(config.backupDir || '').trim()
|
|
44
|
+
if (override) return resolveBackupDir('', override)
|
|
45
|
+
// Resolved remotely so a Windows/posix home is reported by the host that
|
|
46
|
+
// actually owns it, never guessed from the local machine.
|
|
47
|
+
const home = await resolveRemoteHome(b.pool)
|
|
48
|
+
return resolveBackupDir(home, '')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const bindOrReply = async (req, res, body = {}) => {
|
|
52
|
+
try {
|
|
53
|
+
// An explicit machineId targets that machine (the settings page lets the
|
|
54
|
+
// operator act on a machine that is not the current one); otherwise the
|
|
55
|
+
// session-scoped binding applies, with the active machine as the documented
|
|
56
|
+
// fallback for the machine-scoped settings view.
|
|
57
|
+
if (body && body.machineId && typeof resolveMachineBinding === 'function') {
|
|
58
|
+
return await resolveMachineBinding(String(body.machineId))
|
|
59
|
+
}
|
|
60
|
+
return await resolveRequestBinding(req, body)
|
|
61
|
+
} catch (err) {
|
|
62
|
+
sendJson(res, (err && err.httpStatus) || 500, { ok: false, error: String((err && err.message) || err) })
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Everything the panel needs to render: remote archives + local copies. */
|
|
68
|
+
const collect = async (b) => {
|
|
69
|
+
let dir = ''
|
|
70
|
+
let remote = { exists: false, dir: '', entries: [], error: '' }
|
|
71
|
+
try {
|
|
72
|
+
dir = await backupDirFor(b)
|
|
73
|
+
if (dir) remote = await listBackups(b.pool, { dir })
|
|
74
|
+
} catch (err) {
|
|
75
|
+
remote = { exists: false, dir, entries: [], error: String((err && err.message) || err) }
|
|
76
|
+
}
|
|
77
|
+
const localDir = localRootFor(b)
|
|
78
|
+
const local = listLocalBackups(localDir)
|
|
79
|
+
return {
|
|
80
|
+
workspace: b.ws || '',
|
|
81
|
+
host: b.host,
|
|
82
|
+
username: b.username,
|
|
83
|
+
port: b.port,
|
|
84
|
+
backupDir: dir,
|
|
85
|
+
exists: !!remote.exists,
|
|
86
|
+
error: remote.error || '',
|
|
87
|
+
entries: (remote.entries || []).map((e) => ({ ...e, source: 'remote' })),
|
|
88
|
+
local: {
|
|
89
|
+
dir: localDir,
|
|
90
|
+
exists: !!local.exists,
|
|
91
|
+
entries: local.entries.map((e) => ({
|
|
92
|
+
...e,
|
|
93
|
+
source: 'local',
|
|
94
|
+
// The local digest is recomputed lazily on verify; reporting the
|
|
95
|
+
// recorded one here would look like verification that never ran.
|
|
96
|
+
})),
|
|
97
|
+
},
|
|
98
|
+
maxTransferBytes: maxTransfer,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return [
|
|
103
|
+
{
|
|
104
|
+
kind: 'exact',
|
|
105
|
+
path: '/dsh-remote/backup',
|
|
106
|
+
handler: async (req, res) => {
|
|
107
|
+
// ── list ────────────────────────────────────────────────────────────
|
|
108
|
+
if (req.method === 'GET') {
|
|
109
|
+
const q = new URL(req.url, 'http://localhost').searchParams
|
|
110
|
+
const hint = {
|
|
111
|
+
sessionId: q.get('sessionId') ? decodeURIComponent(q.get('sessionId')) : '',
|
|
112
|
+
local: q.get('local') ? decodeURIComponent(q.get('local')) : '',
|
|
113
|
+
machineId: q.get('machineId') ? decodeURIComponent(q.get('machineId')) : '',
|
|
114
|
+
}
|
|
115
|
+
const b = await bindOrReply(req, res, hint)
|
|
116
|
+
if (!b) return
|
|
117
|
+
try {
|
|
118
|
+
return sendJson(res, 200, { ok: true, ...(await collect(b)) })
|
|
119
|
+
} catch (err) {
|
|
120
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
124
|
+
|
|
125
|
+
// ── actions ─────────────────────────────────────────────────────────
|
|
126
|
+
let body = {}
|
|
127
|
+
try {
|
|
128
|
+
body = JSON.parse((await readBody(req)) || '{}')
|
|
129
|
+
} catch {
|
|
130
|
+
return sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' })
|
|
131
|
+
}
|
|
132
|
+
const action = String(body.action || '')
|
|
133
|
+
const b = await bindOrReply(req, res, body)
|
|
134
|
+
if (!b) return
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
if (action === 'create') {
|
|
138
|
+
const parsed = parseExcludes(body.excludes)
|
|
139
|
+
// The UI may target a subdirectory of the workspace; it must stay
|
|
140
|
+
// inside it, otherwise "backup" could archive the whole home dir.
|
|
141
|
+
let dir = b.ws || ''
|
|
142
|
+
const requested = String(body.path || '').trim()
|
|
143
|
+
if (requested && b.ws && requested !== b.ws
|
|
144
|
+
&& !requested.startsWith(b.ws.replace(/\/+$/, '') + '/')) {
|
|
145
|
+
return sendJson(res, 400, { ok: false, error: '只允许备份当前工作区目录(或其子目录)' })
|
|
146
|
+
}
|
|
147
|
+
if (requested) dir = requested
|
|
148
|
+
if (!dir) return sendJson(res, 400, { ok: false, error: '没有远程工作区可备份——先选择一个远程工作区' })
|
|
149
|
+
const dest = ['remote', 'local', 'both'].includes(String(body.dest)) ? String(body.dest) : 'remote'
|
|
150
|
+
const backupDir = await backupDirFor(b)
|
|
151
|
+
if (!backupDir) return sendJson(res, 400, { ok: false, error: '无法确定远端 $HOME,请配置 backupDir' })
|
|
152
|
+
const result = await createBackup(b.pool, {
|
|
153
|
+
dir,
|
|
154
|
+
backupDir,
|
|
155
|
+
label: String(body.label || 'manual'),
|
|
156
|
+
excludes: parsed.patterns,
|
|
157
|
+
meta: { username: b.username, host: b.host, port: b.port },
|
|
158
|
+
})
|
|
159
|
+
if (!result.ok) {
|
|
160
|
+
audit('backup', `backup ${dir} failed: ${result.error}`, 1, b)
|
|
161
|
+
return sendJson(res, 500, { ok: false, error: result.error, droppedExcludes: parsed.dropped })
|
|
162
|
+
}
|
|
163
|
+
audit('backup', `backup ${dir} → ${result.archive}`, 0, b)
|
|
164
|
+
let pulled = null
|
|
165
|
+
let pullError = ''
|
|
166
|
+
if (dest === 'local' || dest === 'both') {
|
|
167
|
+
pulled = await pullBackup(b.pool, { archive: result.archive, localDir: localRootFor(b), maxTransferBytes: maxTransfer })
|
|
168
|
+
if (!pulled.ok) pullError = pulled.error
|
|
169
|
+
else audit('backup', `pull ${result.name} → ${pulled.localPath}`, 0, b)
|
|
170
|
+
}
|
|
171
|
+
// Local-only means the remote copy is a staging step: drop it so the
|
|
172
|
+
// "dest: local" promise is honoured (and say so if removal failed).
|
|
173
|
+
let remoteRemoved = null
|
|
174
|
+
if (dest === 'local' && result.ok) {
|
|
175
|
+
const del = await deleteBackup(b.pool, { archive: result.archive })
|
|
176
|
+
remoteRemoved = !!del.ok
|
|
177
|
+
if (del.ok) audit('backup', `removed remote staging copy ${result.name}`, 0, b)
|
|
178
|
+
}
|
|
179
|
+
return sendJson(res, 200, {
|
|
180
|
+
ok: true,
|
|
181
|
+
created: {
|
|
182
|
+
name: result.name,
|
|
183
|
+
archive: result.archive,
|
|
184
|
+
bytes: result.bytes,
|
|
185
|
+
members: result.members,
|
|
186
|
+
sha256: result.sha256,
|
|
187
|
+
verified: result.verified,
|
|
188
|
+
dest,
|
|
189
|
+
},
|
|
190
|
+
summary: describeCreate(result, { dest }),
|
|
191
|
+
localPath: pulled && pulled.ok ? pulled.localPath : '',
|
|
192
|
+
pullError,
|
|
193
|
+
remoteRemoved,
|
|
194
|
+
droppedExcludes: parsed.dropped,
|
|
195
|
+
...(await collect(b)),
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (action === 'restore') {
|
|
200
|
+
// Restoring is destructive by nature, so the UI must confirm it and
|
|
201
|
+
// the route enforces that the confirmation is explicit.
|
|
202
|
+
if (body.confirm !== true) return sendJson(res, 400, { ok: false, error: '恢复需要显式确认(confirm: true)' })
|
|
203
|
+
const file = String(body.file || '')
|
|
204
|
+
if (!isSafeArchiveName(file)) return sendJson(res, 400, { ok: false, error: '无效的备份文件名' })
|
|
205
|
+
const mode = body.mode === 'merge' ? 'merge' : 'replace'
|
|
206
|
+
const target = String(body.target || '').trim() || b.ws || ''
|
|
207
|
+
if (!target || target === '/') return sendJson(res, 400, { ok: false, error: '没有恢复目标目录' })
|
|
208
|
+
const where = body.where === 'local' ? 'local' : 'remote'
|
|
209
|
+
let archive = ''
|
|
210
|
+
let uploaded = null
|
|
211
|
+
if (where === 'local') {
|
|
212
|
+
// A pulled copy is restored by pushing it back first: the extract
|
|
213
|
+
// itself must happen where the workspace lives.
|
|
214
|
+
const localDir = localRootFor(b)
|
|
215
|
+
const up = await pushBackup(b.pool, {
|
|
216
|
+
localPath: `${localDir.replace(/\/+$/, '')}/${file}`,
|
|
217
|
+
remoteDir: await backupDirFor(b),
|
|
218
|
+
maxTransferBytes: maxTransfer,
|
|
219
|
+
})
|
|
220
|
+
if (!up.ok) return sendJson(res, 500, { ok: false, error: up.error })
|
|
221
|
+
uploaded = up.remotePath
|
|
222
|
+
archive = up.remotePath
|
|
223
|
+
} else {
|
|
224
|
+
archive = `${(await backupDirFor(b)).replace(/\/+$/, '')}/${file}`
|
|
225
|
+
}
|
|
226
|
+
const r = await restoreBackup(b.pool, { archive, target, mode })
|
|
227
|
+
audit('restore', `${mode} restore ${file} → ${target}`, r.ok ? 0 : 1, b)
|
|
228
|
+
if (!r.ok) return sendJson(res, 500, { ok: false, error: r.error, mode, target, uploaded })
|
|
229
|
+
return sendJson(res, 200, {
|
|
230
|
+
ok: true,
|
|
231
|
+
restored: { file, mode: r.mode, target, members: r.members, from: where },
|
|
232
|
+
uploaded,
|
|
233
|
+
text: `已${r.mode === 'merge' ? '合并' : '替换'}恢复 ${file} → ${target}(${r.members} 个文件)`,
|
|
234
|
+
...(await collect(b)),
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (action === 'delete') {
|
|
239
|
+
const file = String(body.file || '')
|
|
240
|
+
if (!isSafeArchiveName(file)) return sendJson(res, 400, { ok: false, error: '无效的备份文件名' })
|
|
241
|
+
const where = body.where === 'local' ? 'local' : 'remote'
|
|
242
|
+
let result
|
|
243
|
+
if (where === 'local') {
|
|
244
|
+
result = deleteLocalBackup(localRootFor(b), file)
|
|
245
|
+
} else {
|
|
246
|
+
result = await deleteBackup(b.pool, { archive: `${(await backupDirFor(b)).replace(/\/+$/, '')}/${file}` })
|
|
247
|
+
}
|
|
248
|
+
audit('backup-delete', `delete ${where} backup ${file}`, result.ok ? 0 : 1, b)
|
|
249
|
+
if (!result.ok) return sendJson(res, 500, { ok: false, error: result.error })
|
|
250
|
+
return sendJson(res, 200, { ok: true, ...(await collect(b)) })
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (action === 'verify') {
|
|
254
|
+
const file = String(body.file || '')
|
|
255
|
+
if (!isSafeArchiveName(file)) return sendJson(res, 400, { ok: false, error: '无效的备份文件名' })
|
|
256
|
+
const where = body.where === 'local' ? 'local' : 'remote'
|
|
257
|
+
const result = where === 'local'
|
|
258
|
+
? verifyLocalBackup(localRootFor(b), file)
|
|
259
|
+
: await verifyBackup(b.pool, { archive: `${(await backupDirFor(b)).replace(/\/+$/, '')}/${file}` })
|
|
260
|
+
return sendJson(res, 200, {
|
|
261
|
+
ok: !!result.ok,
|
|
262
|
+
verified: where === 'local' ? result.matches !== false : !!result.verified,
|
|
263
|
+
bytes: result.bytes,
|
|
264
|
+
sha256: result.sha256,
|
|
265
|
+
members: result.members,
|
|
266
|
+
expectedSha256: result.expectedSha256,
|
|
267
|
+
matches: result.matches ?? null,
|
|
268
|
+
error: result.error || '',
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (action === 'download') {
|
|
273
|
+
const file = String(body.file || '')
|
|
274
|
+
if (!isSafeArchiveName(file)) return sendJson(res, 400, { ok: false, error: '无效的备份文件名' })
|
|
275
|
+
const pulled = await pullBackup(b.pool, {
|
|
276
|
+
archive: `${(await backupDirFor(b)).replace(/\/+$/, '')}/${file}`,
|
|
277
|
+
localDir: localRootFor(b),
|
|
278
|
+
maxTransferBytes: maxTransfer,
|
|
279
|
+
})
|
|
280
|
+
if (!pulled.ok) return sendJson(res, 500, { ok: false, error: pulled.error })
|
|
281
|
+
audit('backup-download', `pull ${file} → ${pulled.localPath}`, 0, b)
|
|
282
|
+
return sendJson(res, 200, {
|
|
283
|
+
ok: true,
|
|
284
|
+
localPath: pulled.localPath,
|
|
285
|
+
bytes: pulled.bytes,
|
|
286
|
+
text: `已下载到本机:${pulled.localPath}(${formatBytes(pulled.bytes)})`,
|
|
287
|
+
...(await collect(b)),
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (action === 'upload') {
|
|
292
|
+
// Push a locally held archive back to the remote (the inverse of
|
|
293
|
+
// download), which is the prerequisite for restoring a local copy
|
|
294
|
+
// on a fresh machine.
|
|
295
|
+
const file = String(body.file || '')
|
|
296
|
+
if (!isSafeArchiveName(file)) return sendJson(res, 400, { ok: false, error: '无效的备份文件名' })
|
|
297
|
+
const localDir = localRootFor(b)
|
|
298
|
+
const dir = await backupDirFor(b)
|
|
299
|
+
if (!dir) return sendJson(res, 400, { ok: false, error: '无法确定远端备份目录' })
|
|
300
|
+
const up = await pushBackup(b.pool, { localPath: `${localDir.replace(/\/+$/, '')}/${file}`, remoteDir: dir, maxTransferBytes: maxTransfer })
|
|
301
|
+
if (!up.ok) return sendJson(res, 500, { ok: false, error: up.error })
|
|
302
|
+
audit('backup-upload', `push ${file} → ${up.remotePath}`, 0, b)
|
|
303
|
+
return sendJson(res, 200, { ok: true, remotePath: up.remotePath, bytes: up.bytes, ...(await collect(b)) })
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return sendJson(res, 400, { ok: false, error: `unknown action: ${action || '(none)'}` })
|
|
307
|
+
} catch (err) {
|
|
308
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
]
|
|
313
|
+
}
|
package/lib/update.js
CHANGED
|
@@ -395,10 +395,19 @@ export async function reloadSelf(loader) {
|
|
|
395
395
|
//
|
|
396
396
|
// 保留对 _dispose/dispose 的探测:那是宿主可能提供的扩展钩子,有则优先用,
|
|
397
397
|
// 没有就用 fiber.dispose()——两条路都必须能真正卸载,否则应如实报错。
|
|
398
|
+
// DSH 0.2 的 loader 在 fiber 被卸载时把 entry.options.disabled 写成 true
|
|
399
|
+
// 并写回配置树。客户端扫描会跳过禁用行,于是热切换之后宿主能起来、
|
|
400
|
+
// 设置页的 client 不再下发。这里记下卸载前的状态:只有 loader 在
|
|
401
|
+
// dispose 期间新打上的禁用才清掉。用户本来就禁用的行不要重新打开。
|
|
402
|
+
const disabledBefore = entry.options?.disabled === true
|
|
398
403
|
if (typeof entry._dispose === 'function') await entry._dispose()
|
|
399
404
|
else if (typeof entry.dispose === 'function') await entry.dispose()
|
|
400
405
|
else if (entry.fiber && typeof entry.fiber.dispose === 'function') await entry.fiber.dispose()
|
|
401
406
|
else return { ok: false, reason: 'entry exposes no dispose hook' }
|
|
407
|
+
if (!disabledBefore && entry.options?.disabled === true) {
|
|
408
|
+
delete entry.options.disabled
|
|
409
|
+
entry.parent?.tree?.write?.()
|
|
410
|
+
}
|
|
402
411
|
await entry.init()
|
|
403
412
|
return { ok: true, cleared }
|
|
404
413
|
} catch (err) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote",
|
|
3
|
-
"version": "0.8.
|
|
4
|
-
"description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive/proxy jump), pick a remote workspace, operate on it with
|
|
3
|
+
"version": "0.8.39",
|
|
4
|
+
"description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive/proxy jump), pick a remote workspace, operate on it with 25 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, workspace backup/restore, port forwarding, optional side-bar remote file editing, audit log, keychain passwords.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "flymysql <flyphp@outlook.com>",
|
|
7
7
|
"readme": "README.md",
|