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.
- package/README.en.md +11 -13
- package/README.md +11 -13
- 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 +351 -53
- package/lib/pool.js +14 -1
- package/lib/routes-backup.js +313 -0
- package/lib/update.js +39 -3
- 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
|
@@ -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.
|
|
397
|
-
*
|
|
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(() => {
|
|
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.
|
|
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.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",
|