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/lib/backup.js DELETED
@@ -1,711 +0,0 @@
1
- // dsh-remote — workspace compressed backup / restore orchestration.
2
- //
3
- // Sits between the pure command builders in lib/archive.js and the callers
4
- // (rw_backup / rw_backup_list / rw_restore tools and the /dsh-remote/backup*
5
- // routes). Everything here takes the SSH pool as an ARGUMENT rather than
6
- // reaching for a module-level singleton, so the whole flow can be exercised
7
- // against an in-memory SFTP double in tests.
8
- //
9
- // Design commitments, each with a reason:
10
- //
11
- // · **A failed run never publishes a partial archive.** archive.js stages the
12
- // file and `mv`s it into place only after tar exits 0 and the stream verifies.
13
- //
14
- // · **A restore is never destructive before the archive proves itself.** The
15
- // extract happens into a staging directory first; a corrupt or truncated
16
- // archive fails there and the target is left byte-for-byte untouched. Only
17
- // then is the old tree swapped aside, and it is removed last.
18
- //
19
- // · **Every archive carries a sidecar.** Recording who/what/when/checksum next
20
- // to the bytes means a restore can say what it is about to overwrite with,
21
- // and a truncated transfer can be detected rather than trusted.
22
- //
23
- // · **The backup directory is never inside its own archive.** When the backup
24
- // dir happens to live under the workspace (a user-chosen path), that subtree
25
- // is added to the excludes; otherwise every run would embed the previous run
26
- // and the archive would grow without bound.
27
- import path from 'node:path'
28
- import { createHash } from 'node:crypto'
29
- import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, rmSync, readdirSync } from 'node:fs'
30
- import { remoteDirname, mkdirRemoteDirs } from './paths.js'
31
- import {
32
- MARK, META_BEGIN_MARK, ARCHIVE_SUFFIX, META_SUFFIX,
33
- buildHomeCommand, buildCreateCommand, buildListCommand, buildVerifyCommand,
34
- buildDeleteCommand, buildRestoreCommand, parseCreateResult, parseListResult,
35
- parseStatus, resolveBackupDir, archiveName, archiveStem, metaNameFor,
36
- isSafeArchiveName, excludeRejection, backupDirUnderRoot, formatBytes,
37
- buildCreateAllCommand, buildRestoreAllCommand, parseRestoredEntries,
38
- ALL_MANIFEST_DIR, ALL_MANIFEST_FILE, ALL_BACKUP_EXCLUDES, expandExcludeDirs,
39
- isUnsafeExclude,
40
- } from './archive.js'
41
-
42
- /** Parse one `ARCH_*` marker without importing the whole marker map. */
43
- const marker = (out, key) => {
44
- const re = new RegExp(`^${key}=(.*)$`, 'gm')
45
- let v = ''
46
- let m
47
- while ((m = re.exec(String(out ?? ''))) !== null) v = m[1].trim()
48
- return v
49
- }
50
-
51
- /** How much of an archive we are willing to move across SFTP. The archive
52
- * itself is uncapped (a backup is a backup); only the optional cross-machine
53
- * copy is bounded, because that is what fills a laptop's disk. */
54
- export function transferCapExceeded(bytes, cap) {
55
- const limit = Number(cap) || 0
56
- if (limit <= 0) return false
57
- return Number(bytes) > limit
58
- }
59
-
60
- /**
61
- * Resolve the remote user's home directory. Cached by the caller (the tool
62
- * resolves it once per call), because a backup is a single round trip anyway.
63
- * @returns {Promise<string>} '' when it cannot be determined.
64
- */
65
- export async function resolveRemoteHome(pool, timeoutMs = 10000) {
66
- try {
67
- const res = await pool.exec(buildHomeCommand(), { timeoutMs })
68
- const home = marker(res && res.stdout, MARK.home)
69
- return home && home.startsWith('/') ? home : ''
70
- } catch {
71
- return ''
72
- }
73
- }
74
-
75
- /**
76
- * Stat a remote path, distinguishing "absent" from "I could not tell".
77
- *
78
- * The distinction matters for naming: treating a TRANSIENT failure as
79
- * "does not exist" would pick an archive name that already exists and overwrite
80
- * a real backup. Only a genuine not-found is reported as absent; anything else
81
- * propagates so the caller fails loudly instead of destroying data.
82
- *
83
- * @returns {Promise<object|null>} the stat, or null when the path genuinely
84
- * does not exist.
85
- * @throws when the failure is anything other than not-found.
86
- */
87
- async function remoteStat(pool, p) {
88
- let sftp
89
- try {
90
- sftp = await pool.sftp()
91
- } catch (err) {
92
- throw new Error('sftp unavailable: ' + String((err && err.message) || err))
93
- }
94
- try {
95
- return await sftp.stat(p)
96
- } catch (err) {
97
- const code = err && (err.code === 2 || err.code === 'ENOENT')
98
- const msg = String((err && err.message) || err)
99
- if (code || /no such file|not found|does not exist|ENOENT/i.test(msg)) return null
100
- throw new Error(`cannot stat ${p}: ${msg}`)
101
- }
102
- }
103
-
104
- /** Whether a remote path exists. See remoteStat for why this is not a plain
105
- * boolean wrapper. */
106
- async function remoteExists(pool, p) {
107
- return (await remoteStat(pool, p)) !== null
108
- }
109
-
110
- /** Read a remote JSON sidecar, or null when absent/unparsable. */
111
- async function readRemoteMeta(pool, metaPath) {
112
- try {
113
- const sftp = await pool.sftp()
114
- const buf = await sftp.readFile(metaPath)
115
- const parsed = JSON.parse(buf.toString('utf8'))
116
- return parsed && typeof parsed === 'object' ? parsed : null
117
- } catch {
118
- return null
119
- }
120
- }
121
-
122
- /** Write a remote JSON sidecar (best effort — a missing sidecar must not fail
123
- * an otherwise successful backup; the listing renders it as "unknown"). */
124
- async function writeRemoteMeta(pool, metaPath, meta) {
125
- try {
126
- const sftp = await pool.sftp()
127
- await mkdirRemoteDirs(sftp, remoteDirname(metaPath))
128
- await sftp.writeFile(metaPath, Buffer.from(`${JSON.stringify(meta, null, 2)}\n`, 'utf8'))
129
- return true
130
- } catch {
131
- return false
132
- }
133
- }
134
-
135
- /**
136
- * Pick an archive filename that does not collide with an existing one.
137
- *
138
- * The timestamp alone is not enough: two backups inside the same second (a
139
- * retry, or the tool and the settings button) would otherwise silently
140
- * overwrite the first. `-2`, `-3` … keeps both.
141
- */
142
- export async function uniqueArchivePath(pool, dir, baseName) {
143
- let candidate = `${dir}/${baseName}`
144
- if (!(await remoteExists(pool, candidate))) return candidate
145
- const stem = baseName.endsWith(ARCHIVE_SUFFIX) ? baseName.slice(0, -ARCHIVE_SUFFIX.length) : baseName
146
- for (let i = 2; i <= 200; i++) {
147
- candidate = `${dir}/${stem}-${i}${ARCHIVE_SUFFIX}`
148
- if (!(await remoteExists(pool, candidate))) return candidate
149
- }
150
- throw new Error('dsh-remote: cannot find a free archive name in ' + dir)
151
- }
152
-
153
- /**
154
- * Create one backup of a remote directory.
155
- *
156
- * @param {object} pool - SSH pool (needs `exec` + `sftp`).
157
- * @param {object} spec
158
- * @param {string} spec.dir - absolute directory to back up (the workspace root).
159
- * @param {string} spec.backupDir - absolute remote backup directory ('' → resolve from HOME).
160
- * @param {string} [spec.label] - user-facing tag embedded in the filename.
161
- * @param {string[]} [spec.excludes] - NORMALIZED patterns (see archive.js).
162
- * @param {object} [spec.meta] - extra fields recorded in the sidecar.
163
- * @param {number} [spec.timeoutMs]
164
- * @returns {Promise<{ok: boolean, error: string, archive: string, name: string, metaPath: string,
165
- * bytes: number, members: number, sha256: string, verified: boolean, meta: object, home: string}>}
166
- */
167
- export async function createBackup(pool, spec = {}) {
168
- const dir = String(spec.dir || '')
169
- const blank = { ok: false, error: '', archive: '', name: '', metaPath: '', bytes: 0, members: 0, sha256: '', verified: false, meta: {}, home: '' }
170
- try {
171
- return await createBackupInner(pool, spec)
172
- } catch (err) {
173
- // A transient failure (a stat we could not complete, a dropped SFTP channel)
174
- // must surface as a failed BACKUP, never as a half-applied one: nothing was
175
- // published because the archive is staged and only renamed on success.
176
- return { ...blank, archive: '', error: String((err && err.message) || err) }
177
- }
178
- }
179
-
180
- async function createBackupInner(pool, spec = {}) {
181
- const dir = String(spec.dir || '')
182
- if (!dir || dir === '/') throw new Error('dsh-remote: a workspace directory is required')
183
- const timeoutMs = Number(spec.timeoutMs) || 0
184
-
185
- let backupDir = String(spec.backupDir || '')
186
- let home = ''
187
- if (!backupDir) {
188
- home = await resolveRemoteHome(pool)
189
- backupDir = resolveBackupDir(home, '')
190
- if (!backupDir) {
191
- return { ok: false, error: 'cannot determine the remote $HOME to place the backup in — set backupDir explicitly', archive: '', name: '', metaPath: '', bytes: 0, members: 0, sha256: '', verified: false, meta: {}, home: '' }
192
- }
193
- }
194
-
195
- // An exclude list that escapes the workspace is refused rather than silently
196
- // used: it would make the archived set impossible to describe.
197
- const excludes = Array.isArray(spec.excludes) ? spec.excludes.slice() : []
198
- const bad = excludeRejection(excludes)
199
- if (bad) return { ok: false, error: bad, archive: '', name: '', metaPath: '', bytes: 0, members: 0, sha256: '', verified: false, meta: {}, home }
200
-
201
- // Never archive a previous backup of the same tree (see the header note).
202
- const selfDir = backupDirUnderRoot(backupDir, dir)
203
- if (selfDir && !excludes.includes(selfDir)) excludes.push(selfDir)
204
-
205
- const baseName = `${archiveStem(dir)}-${archiveName(spec.date ? new Date(spec.date) : new Date(), { label: spec.label })}`
206
- const archive = await uniqueArchivePath(pool, backupDir, baseName)
207
- const name = archive.slice(archive.lastIndexOf('/') + 1)
208
- const metaPath = `${backupDir}/${metaNameFor(name)}`
209
-
210
- const script = buildCreateCommand({ dir, archive, excludes })
211
- let out
212
- try {
213
- out = await pool.exec(script, timeoutMs ? { timeoutMs } : {})
214
- } catch (err) {
215
- return { ok: false, error: String((err && err.message) || err), archive, name, metaPath, bytes: 0, members: 0, sha256: '', verified: false, meta: {}, home }
216
- }
217
- const parsed = parseCreateResult(out && out.stdout)
218
- const stderr = String((out && out.stderr) || '').trim()
219
- if (!parsed.ok) {
220
- // tar's own message is far more actionable than "exit 2", so surface both.
221
- const detail = [parsed.error, stderr].filter(Boolean).join(' — ')
222
- return { ok: false, error: detail || 'backup failed', archive, name, metaPath, bytes: 0, members: 0, sha256: '', verified: false, meta: {}, home }
223
- }
224
-
225
- const meta = {
226
- name,
227
- workspace: dir,
228
- // Recorded because it is part of the FILENAME: a listing that shows the
229
- // label must not have to re-parse the name to get it.
230
- label: String(spec.label || ''),
231
- excludes,
232
- bytes: parsed.bytes,
233
- members: parsed.members,
234
- sha256: parsed.sha256,
235
- verified: parsed.verified,
236
- createdAt: new Date().toISOString(),
237
- ...(spec.meta && typeof spec.meta === 'object' ? spec.meta : {}),
238
- tool: 'dsh-remote',
239
- }
240
- const metaWritten = await writeRemoteMeta(pool, metaPath, meta)
241
- return {
242
- ok: true,
243
- error: '',
244
- archive,
245
- name,
246
- metaPath,
247
- bytes: parsed.bytes,
248
- members: parsed.members,
249
- sha256: parsed.sha256,
250
- verified: parsed.verified,
251
- metaWritten,
252
- meta,
253
- home,
254
- }
255
- }
256
-
257
- /**
258
- * List the backups in one remote directory, newest first.
259
- * @returns {Promise<{exists: boolean, dir: string, entries: Array<object>}>} each
260
- * entry merges the sidecar (when present) with the on-disk truth.
261
- */
262
- export async function listBackups(pool, spec = {}) {
263
- const dir = String(spec.dir || '')
264
- if (!dir) return { exists: false, dir: '', entries: [] }
265
- let out
266
- try {
267
- out = await pool.exec(buildListCommand({ dir }), { timeoutMs: Number(spec.timeoutMs) || 15000 })
268
- } catch (err) {
269
- return { exists: false, dir, entries: [], error: String((err && err.message) || err) }
270
- }
271
- const parsed = parseListResult(out && out.stdout)
272
- const entries = parsed.entries
273
- .filter((e) => isSafeArchiveName(e.name))
274
- .map((e) => ({
275
- name: e.name,
276
- bytes: e.bytes,
277
- mtime: e.mtimeMs ? new Date(e.mtimeMs).toISOString() : '',
278
- // The sidecar may be missing (older archive, failed write) — say so rather
279
- // than inventing values the caller might trust.
280
- workspace: (e.meta && e.meta.workspace) || '',
281
- members: e.meta && Number.isFinite(e.meta.members) ? e.meta.members : null,
282
- sha256: (e.meta && e.meta.sha256) || '',
283
- verified: e.meta ? !!e.meta.verified : null,
284
- label: (e.meta && e.meta.label) || '',
285
- createdAt: (e.meta && e.meta.createdAt) || '',
286
- hasMeta: !!e.meta,
287
- rawMeta: e.meta || null,
288
- }))
289
- .sort((a, b) => String(b.mtime).localeCompare(String(a.mtime)))
290
- return { exists: parsed.exists, dir, entries }
291
- }
292
-
293
- /** Verify one remote archive's integrity + digest. */
294
- export async function verifyBackup(pool, spec = {}) {
295
- const archive = String(spec.archive || '')
296
- const out = await pool.exec(buildVerifyCommand({ archive }), { timeoutMs: Number(spec.timeoutMs) || 60000 })
297
- return parseStatus(out && out.stdout)
298
- }
299
-
300
- /** Delete one remote archive and its sidecar. */
301
- export async function deleteBackup(pool, spec = {}) {
302
- const archive = String(spec.archive || '')
303
- const meta = String(spec.meta || metaNameFor(archive))
304
- const out = await pool.exec(buildDeleteCommand({ archive, meta }), { timeoutMs: Number(spec.timeoutMs) || 20000 })
305
- return parseStatus(out && out.stdout)
306
- }
307
-
308
- /**
309
- * Restore an archive over a target directory.
310
- *
311
- * @param {object} pool
312
- * @param {object} spec
313
- * @param {string} spec.archive - absolute archive path (already on the remote).
314
- * @param {string} spec.target - absolute directory to restore into.
315
- * @param {string} [spec.mode] - `replace` (default, atomic swap) or `merge`.
316
- * @param {number} [spec.timeoutMs]
317
- */
318
- export async function restoreBackup(pool, spec = {}) {
319
- const archive = String(spec.archive || '')
320
- const target = String(spec.target || '')
321
- if (!archive || !target || target === '/') {
322
- return { ok: false, error: 'an archive and a restore target are required', action: '', members: 0 }
323
- }
324
- // Sibling paths keep the swap on the SAME filesystem, which is what makes
325
- // `mv` atomic and therefore makes the rollback path meaningful.
326
- const token = `${process.pid.toString(36)}${Math.random().toString(36).slice(2, 8)}`
327
- const stage = `${target}.dsh-remote-stage-${token}`
328
- const swap = `${target}.dsh-remote-bak-${token}`
329
- const script = buildRestoreCommand({ archive, target, mode: spec.mode, stage, swap })
330
- let out
331
- try {
332
- out = await pool.exec(script, { timeoutMs: Number(spec.timeoutMs) || 0 })
333
- } catch (err) {
334
- return { ok: false, error: String((err && err.message) || err), action: '', members: 0 }
335
- }
336
- const status = parseStatus(out && out.stdout)
337
- const stderr = String((out && out.stderr) || '').trim()
338
- if (!status.ok && !status.error) {
339
- // A non-zero exit with no marker means the script died early (killed,
340
- // unwritable parent) — report the raw stderr rather than a bare failure.
341
- return { ok: false, error: stderr || 'restore failed before it could report a reason', action: '', members: 0, verified: status.verified }
342
- }
343
- return {
344
- ok: status.ok,
345
- error: status.error || (status.ok ? '' : stderr),
346
- action: status.action,
347
- members: status.members,
348
- verified: status.verified,
349
- mode: status.action === 'merged' ? 'merge' : 'replace',
350
- }
351
- }
352
-
353
- /** Local directory holding pulled archives for one machine. */
354
- export function localBackupDirFor(hostRoot) {
355
- return path.join(hostRoot, 'backups')
356
- }
357
-
358
- /** Download one remote archive into a local directory (SFTP). */
359
- export async function pullBackup(pool, spec = {}) {
360
- const archive = String(spec.archive || '')
361
- const localDir = String(spec.localDir || '')
362
- const cap = Number(spec.maxTransferBytes) || 0
363
- if (!archive || !localDir) return { ok: false, error: 'archive and localDir are required' }
364
- const st = await remoteStat(pool, archive)
365
- if (!st) return { ok: false, error: `archive not found on the remote: ${archive}` }
366
- if (transferCapExceeded(st.size, cap)) {
367
- return { ok: false, error: `archive is ${formatBytes(st.size)}, over the ${formatBytes(cap)} transfer cap — raise maxBackupTransferBytes to allow it` }
368
- }
369
- const name = archive.slice(archive.lastIndexOf('/') + 1)
370
- mkdirSync(localDir, { recursive: true })
371
- const localPath = path.join(localDir, name)
372
- try {
373
- const sftp = await pool.sftp()
374
- await sftp.fastGet(archive, localPath)
375
- } catch (err) {
376
- return { ok: false, error: String((err && err.message) || err) }
377
- }
378
- // Pull the sidecar too, so the local copy lists with the same metadata.
379
- let metaPulled = false
380
- const metaRemote = metaNameFor(archive)
381
- try {
382
- const sftp = await pool.sftp()
383
- await sftp.fastGet(metaRemote, path.join(localDir, metaNameFor(name)))
384
- metaPulled = true
385
- } catch { /* sidecar is optional */ }
386
- const bytes = existsSync(localPath) ? statSync(localPath).size : 0
387
- return { ok: true, error: '', localPath, name, bytes, metaPulled }
388
- }
389
-
390
- /** Upload a local archive to a remote directory (SFTP), for restore-from-local. */
391
- export async function pushBackup(pool, spec = {}) {
392
- const localPath = String(spec.localPath || '')
393
- const remoteDir = String(spec.remoteDir || '')
394
- const cap = Number(spec.maxTransferBytes) || 0
395
- if (!localPath || !remoteDir) return { ok: false, error: 'localPath and remoteDir are required' }
396
- if (!existsSync(localPath)) return { ok: false, error: `local archive not found: ${localPath}` }
397
- const st = statSync(localPath)
398
- if (transferCapExceeded(st.size, cap)) {
399
- return { ok: false, error: `archive is ${formatBytes(st.size)}, over the ${formatBytes(cap)} transfer cap — raise maxBackupTransferBytes to allow it` }
400
- }
401
- const name = path.basename(localPath)
402
- if (!isSafeArchiveName(name)) return { ok: false, error: `refusing to upload a suspicious archive name: ${name}` }
403
- const remotePath = `${remoteDir.replace(/\/+$/, '')}/${name}`
404
- try {
405
- const sftp = await pool.sftp()
406
- await mkdirRemoteDirs(sftp, remoteDir)
407
- await sftp.fastPut(localPath, remotePath)
408
- } catch (err) {
409
- return { ok: false, error: String((err && err.message) || err) }
410
- }
411
- return { ok: true, error: '', remotePath, name, bytes: st.size }
412
- }
413
-
414
- /** Read a local JSON sidecar, or null. */
415
- function readLocalMeta(localDir, name) {
416
- try {
417
- const parsed = JSON.parse(readFileSync(path.join(localDir, metaNameFor(name)), 'utf8'))
418
- return parsed && typeof parsed === 'object' ? parsed : null
419
- } catch {
420
- return null
421
- }
422
- }
423
-
424
- /** Write a local JSON sidecar (best effort). */
425
- function writeLocalMeta(localDir, name, meta) {
426
- try {
427
- mkdirSync(localDir, { recursive: true })
428
- writeFileSync(path.join(localDir, metaNameFor(name)), `${JSON.stringify(meta, null, 2)}\n`, 'utf8')
429
- return true
430
- } catch {
431
- return false
432
- }
433
- }
434
-
435
- /** List locally pulled archives, newest first (same shape as listBackups). */
436
- export function listLocalBackups(localDir) {
437
- const entries = []
438
- if (!localDir || !existsSync(localDir)) return { exists: false, dir: localDir || '', entries }
439
- let names = []
440
- try {
441
- names = readdirSync(localDir)
442
- } catch {
443
- return { exists: false, dir: localDir, entries }
444
- }
445
- for (const name of names) {
446
- if (!name.endsWith(ARCHIVE_SUFFIX) || !isSafeArchiveName(name)) continue
447
- let st = null
448
- try { st = statSync(path.join(localDir, name)) } catch { continue }
449
- const meta = readLocalMeta(localDir, name)
450
- entries.push({
451
- name,
452
- bytes: st.size,
453
- mtime: st.mtime.toISOString(),
454
- workspace: (meta && meta.workspace) || '',
455
- members: meta && Number.isFinite(meta.members) ? meta.members : null,
456
- sha256: (meta && meta.sha256) || '',
457
- verified: meta ? !!meta.verified : null,
458
- label: (meta && meta.label) || '',
459
- createdAt: (meta && meta.createdAt) || '',
460
- hasMeta: !!meta,
461
- rawMeta: meta || null,
462
- localPath: path.join(localDir, name),
463
- })
464
- }
465
- entries.sort((a, b) => String(b.mtime).localeCompare(String(a.mtime)))
466
- return { exists: true, dir: localDir, entries }
467
- }
468
-
469
- /** Delete one local archive + sidecar. */
470
- export function deleteLocalBackup(localDir, name) {
471
- if (!isSafeArchiveName(name)) return { ok: false, error: `refusing to delete a suspicious name: ${name}` }
472
- const archive = path.join(localDir, name)
473
- const meta = path.join(localDir, metaNameFor(name))
474
- let removed = false
475
- try {
476
- if (existsSync(archive)) { rmSync(archive, { force: true }); removed = true }
477
- if (existsSync(meta)) rmSync(meta, { force: true })
478
- } catch (err) {
479
- return { ok: false, error: String((err && err.message) || err) }
480
- }
481
- return removed ? { ok: true, error: '' } : { ok: false, error: `local archive not found: ${name}` }
482
- }
483
-
484
- /** Re-verify a LOCAL archive: recompute its digest and compare with the digest
485
- * recorded when the backup was created. That comparison is the transfer-
486
- * fidelity question — a truncated or corrupted pull is exactly what it catches,
487
- * and the local half deliberately has no `tar` dependency to do it. */
488
- export function verifyLocalBackup(localDir, name) {
489
- if (!isSafeArchiveName(name)) return { ok: false, error: `refusing to inspect a suspicious name: ${name}` }
490
- const p = path.join(localDir, name)
491
- if (!existsSync(p)) return { ok: false, error: `local archive not found: ${name}` }
492
- const st = statSync(p)
493
- let sha256 = ''
494
- try {
495
- const h = createHash('sha256')
496
- h.update(readFileSync(p))
497
- sha256 = h.digest('hex')
498
- } catch { sha256 = '' }
499
- const meta = readLocalMeta(localDir, name)
500
- const expected = (meta && meta.sha256) || ''
501
- // `null` (rather than false) when there is nothing to compare against: an
502
- // archive with no sidecar is unverified, which is different from corrupt.
503
- const matches = !expected || !sha256 ? null : expected === sha256
504
- return { ok: true, error: '', bytes: st.size, sha256, expectedSha256: expected, matches, meta }
505
- }
506
-
507
- /** Human summary of a create result (shared by the tool and the route). */
508
- export function describeCreate(result, { dest = 'remote' } = {}) {
509
- if (!result || !result.ok) return `backup failed: ${(result && result.error) || 'unknown error'}`
510
- const where = dest === 'local' ? 'on the local machine'
511
- : dest === 'both' ? 'on both the remote and the local machine'
512
- : 'on the remote'
513
- const bits = [
514
- `created ${result.name} (${formatBytes(result.bytes)}${result.members ? `, ${result.members} entries` : ''}) — stored ${where}`,
515
- ]
516
- if (result.verified) bits.push('verified readable')
517
- if (result.sha256) bits.push(`sha256 ${result.sha256.slice(0, 16)}…`)
518
- return bits.join('; ')
519
- }
520
-
521
- /** Marker used by tests to assert the sidecar name derivation. */
522
- export { META_SUFFIX, ARCHIVE_SUFFIX, META_BEGIN_MARK }
523
-
524
- // ── "all workspaces" backup: ONE archive, ONE restore ───────────────────────
525
- //
526
- // The panel offers exactly two buttons, so this is the whole feature. Everything
527
- // the single-workspace flow learned still applies (stage-then-publish, verify
528
- // before writing, manifest for self-description); what changes is that the
529
- // archive holds MANY unrelated absolute directories and describes itself, so a
530
- // restore does not need the caller to remember which workspace it was for.
531
-
532
- /** Filename stem for an all-workspaces archive: the machine identity plus a
533
- * hash, so archives from different machines never share a stem. */
534
- export function allArchiveStem(machine = {}) {
535
- const who = `${machine.username || 'user'}-${machine.host || 'host'}`
536
- const slug = who.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'machine'
537
- return `workspaces-${slug}-${shortHashLocal(`${machine.username || ''}@${machine.host || ''}:${machine.port || 22}`)}`
538
- }
539
-
540
- /** Local 32-bit base36 hash (kept here so this module has no extra import). */
541
- function shortHashLocal(s) {
542
- let h = 0
543
- for (let i = 0; i < String(s).length; i++) h = ((h << 5) - h + String(s).charCodeAt(i)) | 0
544
- return (h >>> 0).toString(36)
545
- }
546
-
547
- /**
548
- * The archive member for an absolute path: the path with its leading slash
549
- * stripped, because members are added with `-C /`.
550
- *
551
- * It MUST be the full path, not a shortened display form. `-C /` resolves the
552
- * member against the filesystem root, so a shortened member (`allws/w1`) makes
553
- * tar look in `/allws/w1` while the real directory is `/tmp/allws/w1` — every
554
- * run fails with "Cannot stat". (Measured on a real host; the shortening looked
555
- * tidier and was simply wrong.)
556
- */
557
- export function memberForPath(absPath) {
558
- const p = String(absPath || '')
559
- if (!p.startsWith('/') || p === '/') {
560
- throw new Error(`dsh-remote: cannot archive a non-absolute path ${JSON.stringify(absPath)}`)
561
- }
562
- return p.replace(/^\/+/, '')
563
- }
564
-
565
- /** Attach the archive member to each distinct path. Members are unique whenever
566
- * the paths are, which is why no suffixing is needed (and why two different
567
- * `/a/x/proj` and `/b/x/proj` stay distinct: their full paths differ). */
568
- export function assignMembers(paths = []) {
569
- const used = new Set()
570
- const out = []
571
- for (const p of paths) {
572
- const path = String(p || '')
573
- if (!path || path === '/' || used.has(path)) continue
574
- used.add(path)
575
- out.push({ path, member: memberForPath(path) })
576
- }
577
- return out
578
- }
579
-
580
- /**
581
- * Create ONE archive containing every given workspace.
582
- *
583
- * @param {object} pool
584
- * @param {object} spec
585
- * @param {string[]} spec.workspaces - absolute remote directories.
586
- * @param {string} spec.backupDir - absolute remote directory for the archive.
587
- * @param {object} [spec.machine] - {host, username, port} recorded in the manifest.
588
- * @param {string[]} [spec.extraExcludes] - added to the default exclude set.
589
- * @param {string} [spec.label]
590
- * @param {Date} [spec.date]
591
- * @returns {Promise<object>} the create result plus `workspaces` (the manifest).
592
- */
593
- export async function createAllBackup(pool, spec = {}) {
594
- const dirs = [...new Set((spec.workspaces || []).map((p) => String(p || '')).filter((p) => p && p !== '/'))]
595
- if (!dirs.length) {
596
- return { ok: false, error: 'no workspaces to back up — pick a remote workspace first', archive: '', name: '', bytes: 0, members: 0, sha256: '', verified: false, workspaces: [] }
597
- }
598
- const backupDir = String(spec.backupDir || '')
599
- if (!backupDir) {
600
- return { ok: false, error: 'cannot determine the remote backup directory — set backupDir', archive: '', name: '', bytes: 0, members: 0, sha256: '', verified: false, workspaces: [] }
601
- }
602
- const assigned = assignMembers(dirs)
603
- const excludes = [...new Set([...ALL_BACKUP_EXCLUDES, ...expandExcludeDirs([]), ...(spec.extraExcludes || [])])]
604
- // Never archive a previous backup of the same machine.
605
- for (const d of dirs) {
606
- const nested = backupDirUnderRoot(backupDir, d)
607
- if (nested) excludes.push(nested)
608
- }
609
- for (const p of excludes) {
610
- const bad = isUnsafeExclude(p) || p.startsWith('./') || p.startsWith('/')
611
- if (bad) return { ok: false, error: `invalid exclude pattern: ${JSON.stringify(p)}`, archive: '', name: '', bytes: 0, members: 0, sha256: '', verified: false, workspaces: [] }
612
- }
613
-
614
- const baseName = `${allArchiveStem(spec.machine || {})}-${archiveName(spec.date ? new Date(spec.date) : new Date(), { label: spec.label || 'all' })}`
615
- const archive = await uniqueArchivePath(pool, backupDir, baseName)
616
- const name = archive.slice(archive.lastIndexOf('/') + 1)
617
- const metaPath = `${backupDir}/${metaNameFor(name)}`
618
-
619
- const manifest = {
620
- kind: 'dsh-remote/workspaces',
621
- version: 1,
622
- createdAt: new Date().toISOString(),
623
- machine: {
624
- host: String((spec.machine && spec.machine.host) || ''),
625
- username: String((spec.machine && spec.machine.username) || ''),
626
- port: Number((spec.machine && spec.machine.port) || 22),
627
- },
628
- excludes,
629
- workspaces: assigned.map((a) => ({ path: a.path, member: a.member })),
630
- }
631
- // The remote has no JSON parser we can rely on, so the manifest ships BOTH the
632
- // human/JSON form and a TAB-separated member↔path list the restore loop reads.
633
- // Both are passed to the builder, which writes them BEFORE tar runs.
634
- const pathsTsv = assigned.map((a) => `${a.member}\t${a.path}`).join('\n') + '\n'
635
- const withTsv = buildCreateAllCommand({
636
- workspaces: assigned,
637
- archive,
638
- manifestJson: JSON.stringify(manifest, null, 2),
639
- pathsTsv,
640
- excludes,
641
- })
642
-
643
- let out
644
- try {
645
- out = await pool.exec(withTsv, spec.timeoutMs ? { timeoutMs: spec.timeoutMs } : {})
646
- } catch (err) {
647
- return { ok: false, error: String((err && err.message) || err), archive, name, bytes: 0, members: 0, sha256: '', verified: false, workspaces: assigned }
648
- }
649
- const parsed = parseCreateResult(out && out.stdout)
650
- const stderr = String((out && out.stderr) || '').trim()
651
- if (!parsed.ok) {
652
- const detail = [parsed.error, stderr].filter(Boolean).join(' — ')
653
- return { ok: false, error: detail || 'backup failed', archive, name, bytes: 0, members: 0, sha256: '', verified: false, workspaces: assigned }
654
- }
655
- const meta = { name, kind: 'workspaces', ...manifest, bytes: parsed.bytes, members: parsed.members, sha256: parsed.sha256, verified: parsed.verified, tool: 'dsh-remote' }
656
- const metaWritten = await writeRemoteMeta(pool, metaPath, meta)
657
- return {
658
- ok: true,
659
- error: '',
660
- archive,
661
- name,
662
- metaPath,
663
- bytes: parsed.bytes,
664
- members: parsed.members,
665
- sha256: parsed.sha256,
666
- verified: parsed.verified,
667
- metaWritten,
668
- meta,
669
- workspaces: assigned,
670
- }
671
- }
672
-
673
- /** POSIX single-quote (local copy; keeps this module's imports minimal). */
674
- function shqLocal(s) {
675
- return `'${String(s).replace(/'/g, `'\\''`)}'`
676
- }
677
-
678
- /**
679
- * Restore every workspace from an all-workspaces archive.
680
- * @param {object} pool
681
- * @param {object} spec
682
- * @param {string} spec.archive - absolute archive path on the remote.
683
- * @param {string} [spec.fallbackRoot] - where a workspace lands when its original
684
- * absolute path cannot be created (e.g. restoring another machine's archive).
685
- */
686
- export async function restoreAllBackup(pool, spec = {}) {
687
- const archive = String(spec.archive || '')
688
- if (!archive) return { ok: false, error: 'an archive is required', verified: false, entries: [], members: 0 }
689
- const script = buildRestoreAllCommand({ archive, fallbackRoot: String(spec.fallbackRoot || '/tmp') })
690
- let out
691
- try {
692
- out = await pool.exec(script, { timeoutMs: Number(spec.timeoutMs) || 0 })
693
- } catch (err) {
694
- return { ok: false, error: String((err && err.message) || err), verified: false, entries: [], members: 0 }
695
- }
696
- const status = parseStatus(out && out.stdout)
697
- const entries = parseRestoredEntries(out && out.stdout)
698
- const stderr = String((out && out.stderr) || '').trim()
699
- if (!status.ok && !status.error) {
700
- return { ok: false, error: stderr || 'restore failed before it could report a reason', verified: status.verified, entries, members: 0 }
701
- }
702
- return {
703
- ok: status.ok,
704
- // A partial restore still reports success for what moved, so the error text
705
- // must survive alongside `ok` for the caller to show honestly.
706
- error: status.error || (status.ok ? '' : stderr),
707
- verified: status.verified,
708
- entries,
709
- members: status.members || entries.length,
710
- }
711
- }