dsh-remote 0.8.36 → 0.8.38

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.
@@ -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
@@ -380,8 +380,24 @@ export async function reloadSelf(loader) {
380
380
  if (!entry) return { ok: false, reason: 'own loader entry not found' }
381
381
  try {
382
382
  const cleared = clearSelfModuleCache(loader)
383
+ // ★ 卸载必须走 entry.fiber.dispose()。
384
+ //
385
+ // 原实现找 `entry._dispose` / `entry.dispose`,但 cordis 的 Entry **两个都没有**
386
+ // (0.2.0 实测方法集只有 constructor/disabledOf/evaluate/_patchContext/refresh/
387
+ // update/_commitVolatile/init/_init),所以这里每次都直接返回
388
+ // "entry exposes no dispose hook" —— 热切换从未真正执行过,而
389
+ // scheduleSelfReload 里的 `.catch(() => {})` 把它彻底吞掉,
390
+ // UI 于是永远停在"已落盘但未切换"(用户报的 0.8.35 -> 0.8.36)。
391
+ //
392
+ // 也不能用 entry.refresh():它是 `if (this.fiber) return; await this.init()`,
393
+ // fiber 还活着时直接返回,等于没重启。真正卸载 fiber 的地方在 Entry.update()
394
+ // 里(`this.fiber?.dispose()`),这里照它做。
395
+ //
396
+ // 保留对 _dispose/dispose 的探测:那是宿主可能提供的扩展钩子,有则优先用,
397
+ // 没有就用 fiber.dispose()——两条路都必须能真正卸载,否则应如实报错。
383
398
  if (typeof entry._dispose === 'function') await entry._dispose()
384
399
  else if (typeof entry.dispose === 'function') await entry.dispose()
400
+ else if (entry.fiber && typeof entry.fiber.dispose === 'function') await entry.fiber.dispose()
385
401
  else return { ok: false, reason: 'entry exposes no dispose hook' }
386
402
  await entry.init()
387
403
  return { ok: true, cleared }
@@ -393,18 +409,38 @@ export async function reloadSelf(loader) {
393
409
  /**
394
410
  * Fire-and-forget {@link reloadSelf} after a short delay, so the caller can
395
411
  * finish answering an HTTP request (or an update check) before its own module is
396
- * torn down. Failures are swallowed: a failed swap leaves the running code in
397
- * place, which is the pre-hot-update behaviour, not a crash.
412
+ * torn down. A failed swap leaves the running code in place, which is the
413
+ * pre-hot-update behaviour, not a crash — so we do not throw.
414
+ *
415
+ * We DO record the outcome, though. Silently discarding it is precisely how the
416
+ * "entry exposes no dispose hook" defect stayed invisible: the panel reported a
417
+ * scheduled swap, the swap never ran, and nothing anywhere said so.
418
+ * {@link lastSelfReload} exposes the result for diagnostics (the settings panel
419
+ * and `/dsh-remote/update-check` surface it), and a non-ok result is logged once.
420
+ *
398
421
  * @param loader - the cordis Loader service.
399
422
  * @param delayMs - grace period before the swap (default 300 ms).
400
423
  * @returns true when the swap was scheduled.
401
424
  */
402
425
  export function scheduleSelfReload(loader, delayMs = 300) {
403
- const timer = setTimeout(() => { void reloadSelf(loader).catch(() => {}) }, Math.max(0, delayMs))
426
+ const timer = setTimeout(() => {
427
+ void reloadSelf(loader).then(
428
+ (res) => { lastReloadResult = res; if (res && res.ok === false) console.warn('[dsh-remote] self-update swap failed:', res.reason) },
429
+ (err) => { lastReloadResult = { ok: false, reason: String((err && err.message) || err) }; console.warn('[dsh-remote] self-update swap threw:', lastReloadResult.reason) },
430
+ )
431
+ }, Math.max(0, delayMs))
404
432
  if (typeof timer.unref === 'function') timer.unref()
405
433
  return true
406
434
  }
407
435
 
436
+ /** Result of the most recent {@link scheduleSelfReload} attempt, for diagnostics. */
437
+ let lastReloadResult = null
438
+
439
+ /** @returns the last self-reload outcome (`{ok,reason?}`, or null if never attempted). */
440
+ export function lastSelfReload() {
441
+ return lastReloadResult
442
+ }
443
+
408
444
  /** Write the persisted update-mode override (settings UI). */
409
445
  export function persistUpdateMode(mode) {
410
446
  if (!['manual', 'auto', 'off'].includes(mode)) return false
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-remote",
3
- "version": "0.8.36",
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 20 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, port forwarding, optional side-bar remote file editing, audit log, keychain passwords.",
3
+ "version": "0.8.38",
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",