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/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
|
-
}
|