dsh-remote 0.8.40 → 0.8.41
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 +2 -6
- package/README.md +2 -6
- package/lib/client.js +0 -183
- package/lib/index.js +0 -274
- package/lib/paths.js +10 -2
- package/lib/web-attach.js +12 -0
- package/package.json +2 -2
- package/lib/archive.js +0 -1005
- package/lib/backup.js +0 -711
- package/lib/routes-backup.js +0 -283
package/lib/routes-backup.js
DELETED
|
@@ -1,283 +0,0 @@
|
|
|
1
|
-
// Settings-panel routes for workspace 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: the machine/session-scoped JSON API behind Settings → 工作区备份. The
|
|
9
|
-
// panel deliberately exposes only two actions (create / restore) plus
|
|
10
|
-
// list-verify-delete housekeeping; the model-facing equivalents are the
|
|
11
|
-
// rw_backup / rw_backup_list / rw_restore tools, which share lib/backup.js.
|
|
12
|
-
import {
|
|
13
|
-
listBackups, verifyBackup, deleteBackup, pullBackup, listLocalBackups,
|
|
14
|
-
deleteLocalBackup, verifyLocalBackup, resolveRemoteHome, describeCreate,
|
|
15
|
-
createAllBackup, restoreAllBackup,
|
|
16
|
-
} from './backup.js'
|
|
17
|
-
import { resolveBackupDir, isSafeArchiveName, DEFAULT_MAX_TRANSFER_BYTES } from './archive.js'
|
|
18
|
-
|
|
19
|
-
/** Default cap on a cross-machine archive copy; the archive itself is uncapped. */
|
|
20
|
-
export { DEFAULT_MAX_TRANSFER_BYTES }
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* @param {object} deps
|
|
24
|
-
* @param {Function} deps.sendJson - (res, status, body)
|
|
25
|
-
* @param {Function} deps.readBody - (req) => Promise<string>
|
|
26
|
-
* @param {object} deps.config - the resolved plugin config
|
|
27
|
-
* @param {Function} deps.audit - (op, detail, code, target)
|
|
28
|
-
* @param {Function} deps.resolveRequestBinding - (req, body) => binding (see routes-fs)
|
|
29
|
-
* @param {Function} deps.resolveMachineBinding - (machineId) => binding for an explicit machine
|
|
30
|
-
* @param {Function} deps.localRootFor - (binding) => local directory for pulled archives
|
|
31
|
-
* @param {Function} deps.workspacesFor - (binding) => string[] of REMOTE workspace paths
|
|
32
|
-
* @returns {Array<object>} route definitions
|
|
33
|
-
*/
|
|
34
|
-
export function createBackupRoutes({
|
|
35
|
-
sendJson, readBody, config, audit, resolveRequestBinding, resolveMachineBinding, localRootFor,
|
|
36
|
-
workspacesFor,
|
|
37
|
-
}) {
|
|
38
|
-
const maxTransfer = Number(config.maxBackupTransferBytes) > 0
|
|
39
|
-
? Number(config.maxBackupTransferBytes)
|
|
40
|
-
: DEFAULT_MAX_TRANSFER_BYTES
|
|
41
|
-
|
|
42
|
-
/** The remote backup directory for a binding (config override → $HOME/.dsh-remote/backups). */
|
|
43
|
-
const backupDirFor = async (b) => {
|
|
44
|
-
const override = String(config.backupDir || '').trim()
|
|
45
|
-
if (override) return resolveBackupDir('', override)
|
|
46
|
-
// Resolved remotely so a Windows/posix home is reported by the host that
|
|
47
|
-
// actually owns it, never guessed from the local machine.
|
|
48
|
-
const home = await resolveRemoteHome(b.pool)
|
|
49
|
-
return resolveBackupDir(home, '')
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const bindOrReply = async (req, res, body = {}) => {
|
|
53
|
-
try {
|
|
54
|
-
// An explicit machineId targets that machine (the settings page lets the
|
|
55
|
-
// operator act on a machine that is not the current one); otherwise the
|
|
56
|
-
// session-scoped binding applies, with the active machine as the documented
|
|
57
|
-
// fallback for the machine-scoped settings view.
|
|
58
|
-
if (body && body.machineId && typeof resolveMachineBinding === 'function') {
|
|
59
|
-
return await resolveMachineBinding(String(body.machineId))
|
|
60
|
-
}
|
|
61
|
-
return await resolveRequestBinding(req, body)
|
|
62
|
-
} catch (err) {
|
|
63
|
-
sendJson(res, (err && err.httpStatus) || 500, { ok: false, error: String((err && err.message) || err) })
|
|
64
|
-
return null
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Everything the panel needs to render: remote archives + local copies. */
|
|
69
|
-
const collect = async (b) => {
|
|
70
|
-
let dir = ''
|
|
71
|
-
let remote = { exists: false, dir: '', entries: [], error: '' }
|
|
72
|
-
try {
|
|
73
|
-
dir = await backupDirFor(b)
|
|
74
|
-
if (dir) remote = await listBackups(b.pool, { dir })
|
|
75
|
-
} catch (err) {
|
|
76
|
-
remote = { exists: false, dir, entries: [], error: String((err && err.message) || err) }
|
|
77
|
-
}
|
|
78
|
-
const localDir = localRootFor(b)
|
|
79
|
-
const local = listLocalBackups(localDir)
|
|
80
|
-
// Which remote workspaces this backup WOULD include — shown before the user
|
|
81
|
-
// clicks, so "back up all my workspaces" is never a surprise.
|
|
82
|
-
let covers = []
|
|
83
|
-
try {
|
|
84
|
-
covers = typeof workspacesFor === 'function' ? (workspacesFor(b) || []) : (b.ws ? [b.ws] : [])
|
|
85
|
-
} catch { covers = b.ws ? [b.ws] : [] }
|
|
86
|
-
return {
|
|
87
|
-
workspace: b.ws || '',
|
|
88
|
-
covers,
|
|
89
|
-
host: b.host,
|
|
90
|
-
username: b.username,
|
|
91
|
-
port: b.port,
|
|
92
|
-
backupDir: dir,
|
|
93
|
-
exists: !!remote.exists,
|
|
94
|
-
error: remote.error || '',
|
|
95
|
-
entries: (remote.entries || []).map((e) => ({ ...e, source: 'remote' })),
|
|
96
|
-
local: {
|
|
97
|
-
dir: localDir,
|
|
98
|
-
exists: !!local.exists,
|
|
99
|
-
entries: local.entries.map((e) => ({
|
|
100
|
-
...e,
|
|
101
|
-
source: 'local',
|
|
102
|
-
// The local digest is recomputed lazily on verify; reporting the
|
|
103
|
-
// recorded one here would look like verification that never ran.
|
|
104
|
-
})),
|
|
105
|
-
},
|
|
106
|
-
maxTransferBytes: maxTransfer,
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return [
|
|
111
|
-
{
|
|
112
|
-
kind: 'exact',
|
|
113
|
-
path: '/dsh-remote/backup',
|
|
114
|
-
handler: async (req, res) => {
|
|
115
|
-
// ── list ────────────────────────────────────────────────────────────
|
|
116
|
-
if (req.method === 'GET') {
|
|
117
|
-
const q = new URL(req.url, 'http://localhost').searchParams
|
|
118
|
-
const hint = {
|
|
119
|
-
sessionId: q.get('sessionId') ? decodeURIComponent(q.get('sessionId')) : '',
|
|
120
|
-
local: q.get('local') ? decodeURIComponent(q.get('local')) : '',
|
|
121
|
-
machineId: q.get('machineId') ? decodeURIComponent(q.get('machineId')) : '',
|
|
122
|
-
}
|
|
123
|
-
const b = await bindOrReply(req, res, hint)
|
|
124
|
-
if (!b) return
|
|
125
|
-
try {
|
|
126
|
-
return sendJson(res, 200, { ok: true, ...(await collect(b)) })
|
|
127
|
-
} catch (err) {
|
|
128
|
-
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
132
|
-
|
|
133
|
-
// ── actions ─────────────────────────────────────────────────────────
|
|
134
|
-
let body = {}
|
|
135
|
-
try {
|
|
136
|
-
body = JSON.parse((await readBody(req)) || '{}')
|
|
137
|
-
} catch {
|
|
138
|
-
return sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' })
|
|
139
|
-
}
|
|
140
|
-
const action = String(body.action || '')
|
|
141
|
-
const b = await bindOrReply(req, res, body)
|
|
142
|
-
if (!b) return
|
|
143
|
-
|
|
144
|
-
try {
|
|
145
|
-
if (action === 'create') {
|
|
146
|
-
// ONE button: archive every remote workspace the sidebar knows about
|
|
147
|
-
// for this machine. There is no per-workspace choice, no exclude box
|
|
148
|
-
// and no destination picker on purpose — the default exclude set
|
|
149
|
-
// (dependency/build trees) is the only thing worth deciding, and it
|
|
150
|
-
// lives in code where it is reviewable.
|
|
151
|
-
const backupDir = await backupDirFor(b)
|
|
152
|
-
if (!backupDir) return sendJson(res, 400, { ok: false, error: '无法确定远端 $HOME,请配置 backupDir' })
|
|
153
|
-
let covers = []
|
|
154
|
-
try { covers = (typeof workspacesFor === 'function' ? workspacesFor(b) : []) || [] } catch { covers = [] }
|
|
155
|
-
if (!covers.length && b.ws) covers = [b.ws]
|
|
156
|
-
if (!covers.length) {
|
|
157
|
-
return sendJson(res, 400, { ok: false, error: '没有可备份的远程工作区——先在侧边栏里选一个远程工作区' })
|
|
158
|
-
}
|
|
159
|
-
const result = await createAllBackup(b.pool, {
|
|
160
|
-
workspaces: covers,
|
|
161
|
-
backupDir,
|
|
162
|
-
machine: { host: b.host, username: b.username, port: b.port },
|
|
163
|
-
label: 'all',
|
|
164
|
-
})
|
|
165
|
-
if (!result.ok) {
|
|
166
|
-
audit('backup', `backup ${covers.length} workspace(s) failed: ${result.error}`, 1, b)
|
|
167
|
-
return sendJson(res, 500, { ok: false, error: result.error })
|
|
168
|
-
}
|
|
169
|
-
audit('backup', `backup ${covers.length} workspace(s) → ${result.archive}`, 0, b)
|
|
170
|
-
return sendJson(res, 200, {
|
|
171
|
-
ok: true,
|
|
172
|
-
created: {
|
|
173
|
-
name: result.name,
|
|
174
|
-
archive: result.archive,
|
|
175
|
-
bytes: result.bytes,
|
|
176
|
-
members: result.members,
|
|
177
|
-
sha256: result.sha256,
|
|
178
|
-
verified: result.verified,
|
|
179
|
-
workspaces: covers,
|
|
180
|
-
},
|
|
181
|
-
summary: describeCreate(result, { dest: 'remote' }),
|
|
182
|
-
...(await collect(b)),
|
|
183
|
-
})
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
if (action === 'restore') {
|
|
187
|
-
// Restoring is destructive by nature, so the UI must confirm it and
|
|
188
|
-
// the route enforces that the confirmation is explicit.
|
|
189
|
-
if (body.confirm !== true) return sendJson(res, 400, { ok: false, error: '恢复需要显式确认(confirm: true)' })
|
|
190
|
-
const file = String(body.file || '')
|
|
191
|
-
if (!isSafeArchiveName(file)) return sendJson(res, 400, { ok: false, error: '无效的备份文件名' })
|
|
192
|
-
const dir = await backupDirFor(b)
|
|
193
|
-
if (!dir) return sendJson(res, 400, { ok: false, error: '无法确定远端备份目录' })
|
|
194
|
-
const archive = `${dir.replace(/\/+$/, '')}/${file}`
|
|
195
|
-
const r = await restoreAllBackup(b.pool, { archive, fallbackRoot: '/tmp' })
|
|
196
|
-
audit('restore', `restore ${file} (${r.members} workspace(s))`, r.ok ? 0 : 1, b)
|
|
197
|
-
if (!r.ok) return sendJson(res, 500, { ok: false, error: r.error })
|
|
198
|
-
return sendJson(res, 200, {
|
|
199
|
-
ok: true,
|
|
200
|
-
restored: { file, targets: r.entries, count: r.members },
|
|
201
|
-
text: `已恢复 ${r.members} 个工作区:${(r.entries || []).join('、')}`,
|
|
202
|
-
// A partial restore reports what moved AND what failed.
|
|
203
|
-
warning: r.error || '',
|
|
204
|
-
...(await collect(b)),
|
|
205
|
-
})
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
if (action === 'delete') {
|
|
209
|
-
const file = String(body.file || '')
|
|
210
|
-
if (!isSafeArchiveName(file)) return sendJson(res, 400, { ok: false, error: '无效的备份文件名' })
|
|
211
|
-
const where = body.where === 'local' ? 'local' : 'remote'
|
|
212
|
-
let result
|
|
213
|
-
if (where === 'local') {
|
|
214
|
-
result = deleteLocalBackup(localRootFor(b), file)
|
|
215
|
-
} else {
|
|
216
|
-
result = await deleteBackup(b.pool, { archive: `${(await backupDirFor(b)).replace(/\/+$/, '')}/${file}` })
|
|
217
|
-
}
|
|
218
|
-
audit('backup-delete', `delete ${where} backup ${file}`, result.ok ? 0 : 1, b)
|
|
219
|
-
if (!result.ok) return sendJson(res, 500, { ok: false, error: result.error })
|
|
220
|
-
return sendJson(res, 200, { ok: true, ...(await collect(b)) })
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
if (action === 'verify') {
|
|
224
|
-
const file = String(body.file || '')
|
|
225
|
-
if (!isSafeArchiveName(file)) return sendJson(res, 400, { ok: false, error: '无效的备份文件名' })
|
|
226
|
-
const where = body.where === 'local' ? 'local' : 'remote'
|
|
227
|
-
const result = where === 'local'
|
|
228
|
-
? verifyLocalBackup(localRootFor(b), file)
|
|
229
|
-
: await verifyBackup(b.pool, { archive: `${(await backupDirFor(b)).replace(/\/+$/, '')}/${file}` })
|
|
230
|
-
return sendJson(res, 200, {
|
|
231
|
-
ok: !!result.ok,
|
|
232
|
-
verified: where === 'local' ? result.matches !== false : !!result.verified,
|
|
233
|
-
bytes: result.bytes,
|
|
234
|
-
sha256: result.sha256,
|
|
235
|
-
members: result.members,
|
|
236
|
-
expectedSha256: result.expectedSha256,
|
|
237
|
-
matches: result.matches ?? null,
|
|
238
|
-
error: result.error || '',
|
|
239
|
-
})
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
if (action === 'download') {
|
|
243
|
-
const file = String(body.file || '')
|
|
244
|
-
if (!isSafeArchiveName(file)) return sendJson(res, 400, { ok: false, error: '无效的备份文件名' })
|
|
245
|
-
const pulled = await pullBackup(b.pool, {
|
|
246
|
-
archive: `${(await backupDirFor(b)).replace(/\/+$/, '')}/${file}`,
|
|
247
|
-
localDir: localRootFor(b),
|
|
248
|
-
maxTransferBytes: maxTransfer,
|
|
249
|
-
})
|
|
250
|
-
if (!pulled.ok) return sendJson(res, 500, { ok: false, error: pulled.error })
|
|
251
|
-
audit('backup-download', `pull ${file} → ${pulled.localPath}`, 0, b)
|
|
252
|
-
return sendJson(res, 200, {
|
|
253
|
-
ok: true,
|
|
254
|
-
localPath: pulled.localPath,
|
|
255
|
-
bytes: pulled.bytes,
|
|
256
|
-
text: `已下载到本机:${pulled.localPath}(${formatBytes(pulled.bytes)})`,
|
|
257
|
-
...(await collect(b)),
|
|
258
|
-
})
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
if (action === 'upload') {
|
|
262
|
-
// Push a locally held archive back to the remote (the inverse of
|
|
263
|
-
// download), which is the prerequisite for restoring a local copy
|
|
264
|
-
// on a fresh machine.
|
|
265
|
-
const file = String(body.file || '')
|
|
266
|
-
if (!isSafeArchiveName(file)) return sendJson(res, 400, { ok: false, error: '无效的备份文件名' })
|
|
267
|
-
const localDir = localRootFor(b)
|
|
268
|
-
const dir = await backupDirFor(b)
|
|
269
|
-
if (!dir) return sendJson(res, 400, { ok: false, error: '无法确定远端备份目录' })
|
|
270
|
-
const up = await pushBackup(b.pool, { localPath: `${localDir.replace(/\/+$/, '')}/${file}`, remoteDir: dir, maxTransferBytes: maxTransfer })
|
|
271
|
-
if (!up.ok) return sendJson(res, 500, { ok: false, error: up.error })
|
|
272
|
-
audit('backup-upload', `push ${file} → ${up.remotePath}`, 0, b)
|
|
273
|
-
return sendJson(res, 200, { ok: true, remotePath: up.remotePath, bytes: up.bytes, ...(await collect(b)) })
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
return sendJson(res, 400, { ok: false, error: `unknown action: ${action || '(none)'}` })
|
|
277
|
-
} catch (err) {
|
|
278
|
-
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
279
|
-
}
|
|
280
|
-
},
|
|
281
|
-
},
|
|
282
|
-
]
|
|
283
|
-
}
|