shipback-sftp 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,226 @@
1
+ 'use strict';
2
+ // RemoteFs: the small set of server operations the deploy needs.
3
+ // stat(p) → { size, mtime(ms), isDir } | null
4
+ // listRecursive(dir, { skipDir }) → [{ path (posix, relative to dir), size, mtime(ms) }] (files only)
5
+ // mkdirp(p) · readFile(p) → Buffer · writeFile(p, Buffer) · uploadFile(localPath, p)
6
+ // rename(a, b) · replace(tmp, dest) · remove(p) · rmdir(p) · close()
7
+ //
8
+ // Live files are never written in place: the new copy goes to partPath(dest) and replace() swaps it in, so a
9
+ // dropped connection leaves the old file whole, never half a file. The part name keeps the extension (a stray
10
+ // part of config.php is still run as PHP, not served as source) and listRecursive hides part files.
11
+ const posix = require('node:path').posix;
12
+ const fs = require('node:fs');
13
+ const { Client } = require('ssh2');
14
+
15
+ const PART_PREFIX = '.shipback-part-';
16
+ const LOCK_NAME = '.shipback-lock';
17
+ const partPath = p => posix.join(posix.dirname(p), PART_PREFIX + posix.basename(p));
18
+ const isPart = rel => posix.basename(rel).startsWith(PART_PREFIX);
19
+ const isHidden = rel => isPart(rel) || rel === LOCK_NAME; // Shipback's own working files: never deployed, pulled or listed
20
+
21
+ // ---------------------------------------------------------------- MemoryRemoteFs (tests)
22
+ class MemoryRemoteFs {
23
+ // failOn(p): throw before touching p · tornOn(p): write half of p, then throw (a dropped connection)
24
+ constructor() { this.files = new Map(); this.dirs = new Set(['/']); this.failOn = null; this.tornOn = null; }
25
+ _check(p) { if (this.failOn && this.failOn(p)) throw new Error(`Simulated failure on ${p}`); }
26
+ async stat(p) {
27
+ p = posix.normalize(p);
28
+ if (this.files.has(p)) { const f = this.files.get(p); return { size: f.data.length, mtime: f.mtime, isDir: false }; }
29
+ if (this.dirs.has(p)) return { size: 0, mtime: 0, isDir: true };
30
+ return null;
31
+ }
32
+ async listRecursive(dir, { skipDir } = {}) {
33
+ dir = posix.normalize(dir).replace(/\/$/, '');
34
+ const out = [];
35
+ for (const [p, f] of this.files) {
36
+ if (!p.startsWith(dir + '/')) continue;
37
+ const rel = p.slice(dir.length + 1);
38
+ if (skipDir && rel.split('/').slice(0, -1).some(seg => skipDir(seg))) continue;
39
+ if (isHidden(rel)) continue;
40
+ out.push({ path: rel, size: f.data.length, mtime: f.mtime });
41
+ }
42
+ return out.sort((a, b) => a.path.localeCompare(b.path));
43
+ }
44
+ async listDir(dir) {
45
+ dir = posix.normalize(dir).replace(/\/$/, '') || '/';
46
+ const prefix = dir === '/' ? '/' : dir + '/';
47
+ const out = new Map();
48
+ for (const d of this.dirs) if (d.startsWith(prefix) && d !== dir && !d.slice(prefix.length).includes('/')) out.set(d.slice(prefix.length), { name: d.slice(prefix.length), type: 'dir', size: 0, mtime: 0 });
49
+ for (const [p, f] of this.files) if (p.startsWith(prefix) && !p.slice(prefix.length).includes('/')) out.set(p.slice(prefix.length), { name: p.slice(prefix.length), type: 'file', size: f.data.length, mtime: f.mtime });
50
+ return [...out.values()].sort((a, b) => a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1);
51
+ }
52
+ async mkdirp(p) {
53
+ p = posix.normalize(p);
54
+ const parts = p.split('/').filter(Boolean);
55
+ let cur = '';
56
+ for (const part of parts) { cur += '/' + part; if (this.files.has(cur)) throw new Error(`Not a directory: ${cur}`); this.dirs.add(cur); }
57
+ }
58
+ async readFile(p) {
59
+ p = posix.normalize(p); this._check(p);
60
+ const f = this.files.get(p); if (!f) { const e = new Error(`No such file: ${p}`); e.code = 'ENOENT'; throw e; }
61
+ return Buffer.from(f.data);
62
+ }
63
+ async writeFile(p, data) {
64
+ p = posix.normalize(p); this._check(p);
65
+ if (!this.dirs.has(posix.dirname(p))) { const e = new Error(`No such directory: ${posix.dirname(p)}`); e.code = 'ENOENT'; throw e; }
66
+ if (this.tornOn && this.tornOn(p)) {
67
+ this.files.set(p, { data: Buffer.from(data).subarray(0, Math.floor(data.length / 2)), mtime: Date.now() });
68
+ throw new Error(`Simulated connection drop while writing ${p}`);
69
+ }
70
+ this.files.set(p, { data: Buffer.from(data), mtime: Date.now() });
71
+ }
72
+ async uploadFile(localPath, p) { await this.writeFile(p, await fs.promises.readFile(localPath)); }
73
+ async rename(a, b) { a = posix.normalize(a); b = posix.normalize(b); const f = this.files.get(a); if (!f) throw new Error(`No such file: ${a}`); this.files.delete(a); this.files.set(b, f); }
74
+ async replace(tmp, dest) { this._check(posix.normalize(dest)); await this.rename(tmp, dest); }
75
+ async createExclusive(p, data) { p = posix.normalize(p); if (this.files.has(p) || this.dirs.has(p)) return false; await this.writeFile(p, data); return true; }
76
+ async remove(p) { p = posix.normalize(p); this._check(p); if (!this.files.delete(p)) throw new Error(`No such file: ${p}`); }
77
+ async rmdir(p) { this.dirs.delete(posix.normalize(p)); }
78
+ async close() {}
79
+ dump() { return { dirs: [...this.dirs].sort(), files: [...this.files].map(([p, f]) => [p, f.data.toString('base64')]).sort() }; }
80
+ }
81
+
82
+ // ---------------------------------------------------------------- SftpRemoteFs (ssh2)
83
+ // Many shared hosts firewall SSH: a wrong port or an unlisted IP is dropped silently, so ssh2 only says "timed out".
84
+ const UNREACHABLE_HINT = 'Check the port (many shared hosts run SFTP on their own port, not 22) and that your host lets your '
85
+ + 'current IP in (some make you allow-list it under SSH Access, and home IPs change).';
86
+
87
+ class SftpRemoteFs {
88
+ constructor(client, sftp) {
89
+ this.client = client; this.sftp = sftp;
90
+ // ssh2 drops pending SFTP callbacks when the line goes: without this a cut-off upload never settles and the
91
+ // CLI exits 0 with nothing said. Every operation fails with this error instead.
92
+ this.lost = null; this.pending = new Set();
93
+ const fail = why => {
94
+ if (this.lost) return;
95
+ this.lost = new Error(`Lost the connection to the server${why ? ` (${why})` : ''}`);
96
+ for (const reject of this.pending) reject(this.lost);
97
+ this.pending.clear();
98
+ };
99
+ client.on('close', () => fail()); client.on('error', e => fail(e.message));
100
+ if (sftp && sftp.on) sftp.on('close', () => fail());
101
+ }
102
+ _guard(op) {
103
+ if (this.lost) return Promise.reject(this.lost);
104
+ let reject;
105
+ const cut = new Promise((_, r) => { reject = r; this.pending.add(r); });
106
+ return Promise.race([op, cut]).finally(() => this.pending.delete(reject));
107
+ }
108
+
109
+ /** connect({ host, port, username, password | privateKey, readyTimeout }) */
110
+ static connect(opts) {
111
+ return new Promise((resolve, reject) => {
112
+ const client = new Client();
113
+ const cfg = {
114
+ host: opts.host, port: opts.port || 22, username: opts.username,
115
+ readyTimeout: opts.readyTimeout || 20000, keepaliveInterval: 15000,
116
+ };
117
+ if (opts.privateKey) { cfg.privateKey = opts.privateKey; if (opts.passphrase) cfg.passphrase = opts.passphrase; }
118
+ else cfg.password = opts.password;
119
+ let settled = false;
120
+ client.on('ready', () => {
121
+ client.sftp((err, sftp) => {
122
+ if (settled) return; settled = true;
123
+ if (err) { client.end(); return reject(err); }
124
+ resolve(new SftpRemoteFs(client, sftp));
125
+ });
126
+ });
127
+ client.on('error', err => {
128
+ if (settled) return; settled = true;
129
+ const msg = /authentication/i.test(err.message) ? (opts.privateKey
130
+ ? `Authentication failed for ${opts.username}@${opts.host} — the server rejected the key file. Authorize its public key in cPanel → Security → SSH Access → Manage SSH Keys, or clear "Private key file" in Edit site to log in with the password`
131
+ : `Authentication failed for ${opts.username}@${opts.host} (check username/password)`)
132
+ : /ECONNREFUSED|ENOTFOUND|EAI_AGAIN|Timed out/i.test(err.message) ? `Cannot reach ${opts.host}:${cfg.port} — ${err.message}. ${UNREACHABLE_HINT}`
133
+ : err.message;
134
+ reject(new Error(msg));
135
+ });
136
+ client.connect(cfg);
137
+ });
138
+ }
139
+
140
+ _p(fn) { return this._guard(new Promise((res, rej) => fn((err, val) => err ? rej(err) : res(val)))); }
141
+
142
+ async stat(p) {
143
+ try { const a = await this._p(cb => this.sftp.stat(p, cb)); return { size: a.size, mtime: a.mtime * 1000, isDir: a.isDirectory(), mode: a.mode }; }
144
+ catch (e) { if (e.code === 2 || /No such file/i.test(e.message)) return null; throw e; }
145
+ }
146
+ async listRecursive(dir, { skipDir } = {}) {
147
+ const out = [];
148
+ const walk = async (abs, rel) => {
149
+ const entries = await this._p(cb => this.sftp.readdir(abs, cb));
150
+ for (const e of entries) {
151
+ if (e.filename === '.' || e.filename === '..') continue;
152
+ const r = rel ? rel + '/' + e.filename : e.filename;
153
+ if (e.attrs.isDirectory()) { if (!(skipDir && skipDir(e.filename))) await walk(abs + '/' + e.filename, r); }
154
+ else if (e.attrs.isFile() && !isHidden(r)) out.push({ path: r, size: e.attrs.size, mtime: e.attrs.mtime * 1000 });
155
+ }
156
+ };
157
+ await walk(dir.replace(/\/$/, ''), '');
158
+ return out.sort((a, b) => a.path.localeCompare(b.path));
159
+ }
160
+ async listDir(dir) {
161
+ const entries = await this._p(cb => this.sftp.readdir(dir.replace(/\/$/, '') || '/', cb));
162
+ return entries.filter(e => e.filename !== '.' && e.filename !== '..')
163
+ .map(e => ({ name: e.filename, type: e.attrs.isDirectory() ? 'dir' : e.attrs.isFile() ? 'file' : 'other', size: e.attrs.size, mtime: e.attrs.mtime * 1000 }))
164
+ .sort((a, b) => a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1);
165
+ }
166
+ async mkdirp(p) {
167
+ const parts = posix.normalize(p).split('/').filter(Boolean);
168
+ let cur = '';
169
+ for (const part of parts) {
170
+ cur += '/' + part;
171
+ const st = await this.stat(cur);
172
+ if (st) { if (!st.isDir) throw new Error(`Not a directory on server: ${cur}`); continue; }
173
+ await this._p(cb => this.sftp.mkdir(cur, cb));
174
+ }
175
+ }
176
+ readFile(p) {
177
+ return this._guard(new Promise((resolve, reject) => {
178
+ const chunks = [];
179
+ this.sftp.createReadStream(p).on('data', c => chunks.push(c)).on('end', () => resolve(Buffer.concat(chunks))).on('error', reject);
180
+ }));
181
+ }
182
+ writeFile(p, data) {
183
+ return this._guard(new Promise((resolve, reject) => {
184
+ const ws = this.sftp.createWriteStream(p);
185
+ ws.on('error', reject).on('close', resolve);
186
+ ws.end(Buffer.from(data));
187
+ }));
188
+ }
189
+ uploadFile(localPath, p) {
190
+ return this._p(cb => this.sftp.fastPut(localPath, p, { concurrency: 4, chunkSize: 32768 }, cb));
191
+ }
192
+ rename(a, b) { return this._p(cb => this.sftp.rename(a, b, cb)); }
193
+ /** Create p only if nothing is there yet (O_EXCL): true if created, false if it already exists. */
194
+ async createExclusive(p, data) {
195
+ let handle;
196
+ try { handle = await this._p(cb => this.sftp.open(p, 'wx', cb)); }
197
+ catch (e) { if (await this.stat(p)) return false; throw e; }
198
+ try { const buf = Buffer.from(data); await this._p(cb => this.sftp.write(handle, buf, 0, buf.length, 0, cb)); }
199
+ finally { await this._p(cb => this.sftp.close(handle, cb)).catch(() => {}); }
200
+ return true;
201
+ }
202
+ /** Swap a fully written part file in over dest, keeping dest's permissions (suEXEC hosts 500 on 0666 PHP). */
203
+ async replace(tmp, dest) {
204
+ const st = await this.stat(dest);
205
+ if (st && st.mode !== undefined) await this._p(cb => this.sftp.chmod(tmp, st.mode & 0o7777, cb));
206
+ // OpenSSH (cPanel, Plesk, DirectAdmin, any VPS) has had posix-rename since 2008: one atomic step
207
+ try { return await this._p(cb => this.sftp.ext_openssh_rename(tmp, dest, cb)); }
208
+ catch (e) { if (!/does not support/i.test(e.message)) throw e; }
209
+ if (!st) return this.rename(tmp, dest);
210
+ try { return await this.rename(tmp, dest); } // some servers replace on a plain rename
211
+ catch { await this.remove(dest); return this.rename(tmp, dest); } // restore.js copes if the line drops between these two
212
+ }
213
+ remove(p) { return this._p(cb => this.sftp.unlink(p, cb)); }
214
+ rmdir(p) { return this._p(cb => this.sftp.rmdir(p, cb)); }
215
+ async close() { try { this.client.end(); } catch { /* ignore */ } }
216
+ }
217
+
218
+ /** Write a live file the safe way: to its part file first, then swapped in whole. Give { data } or { localPath }. */
219
+ async function writeLive(remoteFs, dest, { data, localPath }) {
220
+ const part = partPath(dest);
221
+ if (localPath !== undefined) await remoteFs.uploadFile(localPath, part);
222
+ else await remoteFs.writeFile(part, data);
223
+ await remoteFs.replace(part, dest);
224
+ }
225
+
226
+ module.exports = { PART_PREFIX, LOCK_NAME, partPath, isPart, isHidden, writeLive, MemoryRemoteFs, SftpRemoteFs };
package/src/restore.js ADDED
@@ -0,0 +1,196 @@
1
+ 'use strict';
2
+ // Restore: put a _backup-<stamp>/ folder back — the undo for a deploy.
3
+ //
4
+ // A deploy's backup folder holds the server copies of every file it overwrote or deleted, and
5
+ // .backup.json saying which files it added and what it uploaded (sha256). Restore:
6
+ // · checks first that each live file is still what that run left there (or is already the old copy).
7
+ // A file edited on the server since is a conflict, and then nothing is changed unless { force: true }
8
+ // · checks each saved copy against the sha256 recorded when it was saved: a damaged copy is never put back
9
+ // · copes with a run that stopped part way: files it never reached are already right, and a file caught
10
+ // between "old removed" and "new swapped in" (servers without posix-rename) is found in its part file
11
+ // · backs up everything it is about to change into a new _backup-<stamp>/ with its own .backup.json,
12
+ // so a restore can itself be restored
13
+ // · writes each backed-up file back to its live path and removes the files that run added
14
+ // · drops the touched paths from .deploy-manifest.json, so the next deploy compares them by content
15
+ // A backup with no .backup.json (made by an older version) restores its files only: summary.legacy = true,
16
+ // added files stay, and there is nothing to check edits against.
17
+ //
18
+ // runRestore({ remoteRoot, remoteFs, name, dryRun, force, now, onEvent })
19
+ // name: the backup folder ('_backup-2026-10-18-1400') or 'latest' / omitted for the newest
20
+ // → { dryRun, backup, action, legacy, put, remove, same, conflicts: [{ path, why }], refused, undoBackupDir }
21
+ const crypto = require('node:crypto');
22
+ const posix = require('node:path').posix;
23
+ const { BACKUP_PREFIX, MANIFEST_NAME } = require('./diff');
24
+ const { backupFiles, makeStamp, freeBackupDir, writeBackupInfo, readBackupInfo, BACKUP_INFO } = require('./backup');
25
+ const { listBackups } = require('./prune');
26
+ const { partPath, writeLive } = require('./remote-fs');
27
+ const { withLock } = require('./lock');
28
+
29
+ const sha256 = buf => crypto.createHash('sha256').update(buf).digest('hex');
30
+
31
+ function chooseBackup(backups, name) {
32
+ if (!backups.length) throw new Error('There are no _backup-* folders on the server to restore from');
33
+ if (name === undefined || name === null || name === '' || name === 'latest') return backups[backups.length - 1];
34
+ if (typeof name !== 'string' || !name.startsWith(BACKUP_PREFIX) || name.includes('/') || name.includes('\\') || name.includes('..')) throw new Error(`Not a backup folder name: "${name}" (must be ${BACKUP_PREFIX}<stamp>, or "latest")`);
35
+ if (!backups.includes(name)) throw new Error(`No such backup on the server: ${name}`);
36
+ return name;
37
+ }
38
+
39
+ /** Backups newest first, each with what it holds: [{ name, action, at, overwritten, added, deleted, files, legacy }] */
40
+ async function describeBackups({ remoteRoot, remoteFs }) {
41
+ const root = remoteRoot.replace(/\/$/, '');
42
+ const out = [];
43
+ for (const name of (await listBackups(remoteFs, root)).reverse()) {
44
+ const info = await readBackupInfo(remoteFs, `${root}/${name}`);
45
+ const files = (await remoteFs.listRecursive(`${root}/${name}`)).filter(f => f.path !== BACKUP_INFO).length;
46
+ out.push(info
47
+ ? { name, action: info.action, at: info.at, from: info.from, overwritten: info.overwritten.length, added: info.added.length, deleted: info.deleted.length, files, legacy: false, status: info.status }
48
+ : { name, files, legacy: true });
49
+ }
50
+ return out;
51
+ }
52
+
53
+ function runRestore(opts) {
54
+ if (opts.dryRun) return restoreNow(opts);
55
+ return withLock({ remoteFs: opts.remoteFs, root: opts.remoteRoot, action: 'restore', onEvent: opts.onEvent }, () => restoreNow(opts));
56
+ }
57
+
58
+ async function restoreNow({ remoteRoot, remoteFs, name, dryRun = false, force = false, now = new Date(), onEvent = () => {} }) {
59
+ const root = remoteRoot.replace(/\/$/, '');
60
+ onEvent({ type: 'scan', side: 'remote', dir: root });
61
+ let target = chooseBackup(await listBackups(remoteFs, root), name);
62
+ let info = await readBackupInfo(remoteFs, `${root}/${target}`);
63
+ let resumed = null;
64
+ // the latest run is a restore that was cut off: running it again finishes that restore
65
+ const latest = name === undefined || name === null || name === '' || name === 'latest';
66
+ if (latest && info && info.action === 'restore' && info.status && info.from) {
67
+ resumed = target; target = chooseBackup(await listBackups(remoteFs, root), info.from);
68
+ info = await readBackupInfo(remoteFs, `${root}/${target}`);
69
+ }
70
+ const dir = `${root}/${target}`;
71
+ const summary = { dryRun, backup: target, action: info ? info.action : undefined, legacy: !info, resumed, stoppedEarly: false, put: [], remove: [], same: [], conflicts: [], refused: false, undoBackupDir: null };
72
+ if (info && info.status === 'saving') {
73
+ // that run was cut off while it was still making its backup copies, before it changed a single live file
74
+ summary.stoppedEarly = true;
75
+ onEvent({ type: 'restore-plan', backup: target, put: [], remove: [], same: 0, conflicts: 0, legacy: false, stoppedEarly: true });
76
+ onEvent({ type: 'done', summary });
77
+ return summary;
78
+ }
79
+
80
+ const left = new Map(); // path → sha256 the run left there (overwritten + added)
81
+ const deleted = new Set(); // paths the run deleted
82
+ const was = new Map(); // path → sha256 of the copy saved in the backup folder (records from 0.2 on)
83
+ if (info) {
84
+ for (const e of [...info.overwritten, ...info.added]) left.set(e.path, e.sha256);
85
+ for (const e of info.deleted) deleted.add(e.path);
86
+ for (const e of [...info.overwritten, ...info.deleted]) if (e.was) was.set(e.path, e.was);
87
+ }
88
+ const live = async p => {
89
+ const st = await remoteFs.stat(`${root}/${p}`);
90
+ if (!st) {
91
+ // the line dropped mid-swap: the run's whole copy is still in the part file
92
+ const part = partPath(`${root}/${p}`);
93
+ if (left.has(p) && await remoteFs.stat(part) && sha256(await remoteFs.readFile(part)) === left.get(p)) return left.get(p);
94
+ return null;
95
+ }
96
+ return st.isDir ? 'dir' : sha256(await remoteFs.readFile(`${root}/${p}`));
97
+ };
98
+ const conflict = (p, why) => summary.conflicts.push({ path: p, why });
99
+
100
+ // 1. what goes back: every file in the backup folder
101
+ const puts = [];
102
+ for (const f of (await remoteFs.listRecursive(dir)).filter(f => f.path !== BACKUP_INFO)) {
103
+ const data = await remoteFs.readFile(`${dir}/${f.path}`);
104
+ if (was.has(f.path) && sha256(data) !== was.get(f.path)) { conflict(f.path, 'its copy in the backup folder is damaged (it no longer matches what was saved)'); continue; } // never put back, even forced
105
+ const now_ = await live(f.path);
106
+ if (now_ === sha256(data)) { summary.same.push(f.path); continue; }
107
+ if (now_ === 'dir') { conflict(f.path, 'is a folder on the server now'); continue; } // can't write a file over it, even forced
108
+ puts.push({ path: f.path, data });
109
+ if (left.has(f.path) && now_ !== left.get(f.path)) conflict(f.path, now_ === null ? 'deleted on the server after that run' : 'edited on the server after that run');
110
+ else if (deleted.has(f.path) && now_ !== null) conflict(f.path, 'put back on the server after that run deleted it');
111
+ }
112
+ // 2. what comes off: the files that run added
113
+ const removes = [];
114
+ if (info) for (const e of info.added) {
115
+ const now_ = await live(e.path);
116
+ if (now_ === null) { summary.same.push(e.path); continue; }
117
+ if (now_ === 'dir') { conflict(e.path, 'is a folder on the server now'); continue; }
118
+ removes.push(e.path);
119
+ if (now_ !== e.sha256) conflict(e.path, 'edited on the server after that run added it');
120
+ }
121
+ summary.put = puts.map(p => p.path).sort();
122
+ summary.remove = removes.sort();
123
+ summary.same.sort();
124
+ onEvent({ type: 'restore-plan', backup: target, put: summary.put, remove: summary.remove, same: summary.same.length, conflicts: summary.conflicts.length, legacy: summary.legacy });
125
+ for (const c of summary.conflicts) onEvent({ type: 'conflict', ...c });
126
+
127
+ if (summary.conflicts.length && !force) {
128
+ summary.refused = true;
129
+ onEvent({ type: 'done', summary });
130
+ return summary;
131
+ }
132
+ // part files a stopped run left behind (hidden from listings, but they hold the run's bytes)
133
+ const sweepParts = async () => {
134
+ for (const p of new Set([...left.keys(), ...deleted])) {
135
+ const part = partPath(`${root}/${p}`);
136
+ if (await remoteFs.stat(part)) await remoteFs.remove(part).catch(() => {});
137
+ }
138
+ };
139
+ if (dryRun) { onEvent({ type: 'done', summary }); return summary; }
140
+ if (!puts.length && !removes.length) { await sweepParts(); onEvent({ type: 'done', summary }); return summary; }
141
+ try {
142
+ // 3. back up what is live now, so this restore can be undone too. Its record says 'saving', then 'running'
143
+ // while files are written, then nothing: cut off part way, running restore again finishes the job.
144
+ const undoDir = await freeBackupDir(remoteFs, root, makeStamp(now));
145
+ summary.undoBackupDir = undoDir;
146
+ const head = { action: 'restore', from: target, at: now.toISOString() };
147
+ await writeBackupInfo({ remoteFs, backupDir: undoDir, info: { ...head, status: 'saving', overwritten: [], added: [], deleted: [] } });
148
+ const res = await backupFiles({ remoteFs, root, paths: [...summary.put, ...summary.remove], backupDir: undoDir, onEvent });
149
+ const copied = new Set(res.copied);
150
+ const record = {
151
+ ...head,
152
+ overwritten: puts.filter(p => copied.has(p.path)).map(p => ({ path: p.path, sha256: sha256(p.data), was: res.was.get(p.path) })),
153
+ added: puts.filter(p => !copied.has(p.path)).map(p => ({ path: p.path, sha256: sha256(p.data) })),
154
+ deleted: removes.filter(p => copied.has(p)).map(p => ({ path: p, was: res.was.get(p) })),
155
+ };
156
+ await writeBackupInfo({ remoteFs, backupDir: undoDir, info: { ...record, status: 'running' } });
157
+
158
+ // 4. put the old copies back, take the added files off
159
+ const madeDirs = new Set();
160
+ for (const p of puts) {
161
+ const dest = `${root}/${p.path}`;
162
+ const d = posix.dirname(dest);
163
+ if (!madeDirs.has(d)) { await remoteFs.mkdirp(d); madeDirs.add(d); }
164
+ await writeLive(remoteFs, dest, { data: p.data });
165
+ onEvent({ type: 'restore', path: p.path, bytes: p.data.length });
166
+ }
167
+ for (const p of removes) { await remoteFs.remove(`${root}/${p}`); onEvent({ type: 'delete', path: p }); }
168
+ // folders the deploy made just for added files go too, once empty
169
+ for (const d of [...new Set(removes.map(p => posix.dirname(p)).filter(d => d !== '.'))].sort((a, b) => b.length - a.length)) {
170
+ for (let cur = d; cur !== '.'; cur = posix.dirname(cur)) {
171
+ const abs = `${root}/${cur}`;
172
+ if (!(await remoteFs.stat(abs)) || (await remoteFs.listDir(abs)).length) break;
173
+ await remoteFs.rmdir(abs);
174
+ }
175
+ }
176
+
177
+ await sweepParts();
178
+
179
+ // 5. the manifest no longer describes these paths
180
+ try {
181
+ const m = JSON.parse((await remoteFs.readFile(`${root}/${MANIFEST_NAME}`)).toString('utf8'));
182
+ for (const p of [...summary.put, ...summary.remove]) delete m.files[p];
183
+ await remoteFs.writeFile(`${root}/${MANIFEST_NAME}`, JSON.stringify(m, null, 1));
184
+ } catch { /* no manifest: nothing to correct */ }
185
+ await writeBackupInfo({ remoteFs, backupDir: undoDir, info: record });
186
+ } catch (err) {
187
+ err.undo = 'The restore stopped part way. Run the same restore again: it finishes the job.';
188
+ onEvent({ type: 'error', message: err.message, undo: err.undo, summary });
189
+ throw err;
190
+ }
191
+
192
+ onEvent({ type: 'done', summary });
193
+ return summary;
194
+ }
195
+
196
+ module.exports = { runRestore, describeBackups, chooseBackup };