shipback-sftp 0.1.0 → 0.2.1

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.md CHANGED
@@ -11,13 +11,23 @@ Shipback deploys a folder to any SFTP host: cPanel, Plesk, DirectAdmin, a plain
11
11
  can be undone with one command. You can run it yourself, script it, or hand it to Claude Code, Cursor or
12
12
  any MCP client.
13
13
 
14
+ **[The undo promise](UNDO-PROMISE.md):** one command puts the site back byte for byte, even after a deploy that
15
+ was cut off halfway. It never overwrites a live edit. Each of the thirteen lines in the promise has its own test, and
16
+ if undo ever breaks one of them, that's the first bug I fix.
17
+
14
18
  - **Backup before every overwrite.** The server's copy of each file a deploy replaces is saved into
15
19
  `_backup-<time>/` on the server, before the upload starts.
20
+ - **Never half a file.** Each file goes up beside the live one and is swapped in whole, so a dropped connection
21
+ leaves the old file, not a broken one.
16
22
  - **One-command undo.** `--restore` puts those files back and removes the files that deploy added.
17
23
  - **It won't undo over a live edit.** If someone changed a file on the server after the deploy, restore
18
24
  stops and names the file. Nothing is touched unless you pass `--force`, and even then the edited copy is
19
25
  backed up first.
20
26
  - **Restores can be undone too.** A restore backs up what it replaces, the same way a deploy does.
27
+ - **One run at a time.** A deploy or restore holds a lock on the site while it writes. A second one is turned away
28
+ with nothing changed, so an agent and a person can't deploy over each other.
29
+ - **Stays inside the site.** Deploy and undo won't write through a symlink or over a special file on the server,
30
+ so a symlinked folder can't carry a write somewhere else.
21
31
  - **Safe for agents.** The MCP server has no switch to delete server files or skip the backup. A deploy
22
32
  only runs the exact plan the agent previewed: if a file changed in between, it refuses.
23
33
  - **Spots the drift.** `--pull --dry-run` shows what changed on the server since your last deploy, so
@@ -28,22 +38,24 @@ and Linux with Node 18, 20 and 22 on every push. Passwords are kept in the OS ke
28
38
  pass through the model.
29
39
 
30
40
  **It has run in production.** The deploy engine underneath has shipped [aldo.today](https://aldo.today) 235 times
31
- since September 2026 (2,907 files to a cPanel host). Restore and the MCP server are new in 0.1.
41
+ since September 2026 (2,907 files to a cPanel host). Restore and the MCP server are new since 0.1, and the
42
+ cut-off handling behind the promise is new in 0.2.
32
43
 
33
44
  ## Quick start
34
45
 
35
46
  ```bash
36
- git clone https://github.com/aldohushi1-stack/shipback-sftp.git
37
- cd shipback-sftp
38
- npm install
39
- cp sites.example.json sites.json # then edit it
40
- node bin/cli.js mysite --dry-run # see the plan; asks for the password once and stores it
41
- node bin/cli.js mysite # deploy
42
- node bin/cli.js mysite --restore # changed your mind? undo it
47
+ npm install -g shipback-sftp # installs the shipback and shipback-mcp commands
48
+ cd ~/sites # any folder with a sites.json in it (format below)
49
+ shipback mysite --dry-run # see the plan; asks for the password once and stores it
50
+ shipback mysite # deploy
51
+ shipback mysite --restore # changed your mind? undo it
43
52
  ```
44
53
 
45
- `npm link` in the folder installs it as the `shipback` and `shipback-mcp` commands, so `node bin/cli.js mysite`
46
- becomes `shipback mysite`.
54
+ No global install? `npx -p shipback-sftp shipback mysite --dry-run` works the same way. `--sites <file>`
55
+ points at a `sites.json` in another folder.
56
+
57
+ From source: `git clone https://github.com/aldohushi1-stack/shipback-sftp.git`, `npm install`, then
58
+ `node bin/cli.js` wherever these docs say `shipback`.
47
59
 
48
60
  `sites.json`, one entry per site:
49
61
 
@@ -70,19 +82,20 @@ first. Use `"privateKeyPath"` instead of a password if your host has your key.
70
82
  ## Commands
71
83
 
72
84
  ```bash
73
- node bin/cli.js <site> --dry-run # what a deploy would upload
74
- node bin/cli.js <site> # deploy: backup, upload, record
75
- node bin/cli.js <site> --local <folder> # deploy a different folder than localDir
76
- node bin/cli.js <site> --pull --dry-run # what changed on the server since the last deploy?
77
- node bin/cli.js <site> --pull # bring those changes down (local copies backed up first)
78
- node bin/cli.js <site> --backups # list backups: what each run overwrote, added, deleted
79
- node bin/cli.js <site> --restore --dry-run # what an undo of the latest deploy would do
80
- node bin/cli.js <site> --restore [<backup>] # undo it (or name an older _backup-… folder)
81
- node bin/cli.js <site> --prune --keep 10 # tidy old backups; refuses any holding the only copy of a file
82
- node bin/cli.js --history [site] # what went up, when, from where
85
+ shipback <site> --dry-run # what a deploy would upload
86
+ shipback <site> # deploy: backup, upload, record
87
+ shipback <site> --local <folder> # deploy a different folder than localDir
88
+ shipback <site> --pull --dry-run # what changed on the server since the last deploy?
89
+ shipback <site> --pull # bring those changes down (local copies backed up first)
90
+ shipback <site> --backups # list backups: what each run overwrote, added, deleted
91
+ shipback <site> --restore --dry-run # what an undo of the latest deploy would do
92
+ shipback <site> --restore [<backup>] # undo it (or name an older _backup-… folder)
93
+ shipback <site> --prune --keep 10 # tidy old backups; refuses any holding the only copy of a file
94
+ shipback --history [site] # what went up, when, from where
83
95
  ```
84
96
 
85
- Add `--json` to any command for machine-readable output. A refused restore exits with code 4.
97
+ Add `--json` to any command for machine-readable output. A refused restore exits with code 4, and a run turned away
98
+ because another one is writing to the site exits with code 5.
86
99
 
87
100
  `--mirror` (delete server files that aren't in the local folder) and `--no-backup` exist for humans who
88
101
  know they want them. The agent tools don't offer them, and the [guard hook](#claude-code-guard-hook)
@@ -93,7 +106,18 @@ blocks Claude from typing them.
93
106
  Each deploy writes `_backup-YYYY-MM-DD-HHMM/` in the remote root. It holds:
94
107
 
95
108
  - the server's copy of every file the deploy **overwrote** or **deleted**
96
- - `.backup.json`, a record of what the deploy **added**, and a SHA-256 of every file it left on the server
109
+ - `.backup.json`, a record of what the deploy **added**, a SHA-256 of every file it left on the server, and a
110
+ SHA-256 of every copy it saved, so a damaged copy is never put back
111
+
112
+ The record says `saving` until every copy is made. A deploy cut off before then hasn't changed anything, and
113
+ `--restore` says so. New files go up as `.shipback-part-<name>` beside the live file and are renamed over it in one
114
+ step, keeping its permissions. Listings hide part files, and the next deploy or restore clears any that are left.
115
+
116
+ While a deploy, upload, restore or prune writes, it holds `.shipback-lock` in the remote root and refreshes it every
117
+ 30 seconds. The lock is never deployed, pulled or listed. A lock goes stale, and the next run takes it over, when its
118
+ process on this computer has ended or nothing has refreshed it for 2 minutes. If the system has reused that process
119
+ ID, the lock just waits out the 2 minutes; a run that's still going is never mistaken for a finished one. So a run that was cut off never leaves
120
+ a site locked for long.
97
121
 
98
122
  `--restore` reads that record and checks each live file first:
99
123
 
@@ -104,19 +128,20 @@ Each deploy writes `_backup-YYYY-MM-DD-HHMM/` in the remote root. It holds:
104
128
  | different from both | **conflict**: stops, names the file, changes nothing (`--force` overrides) |
105
129
 
106
130
  Before writing anything, restore saves the current live files into a new `_backup-…` folder with its own
107
- record, so `--restore` on that folder takes you forward again. It also clears the restored paths from
131
+ record, so `--restore` on that folder takes you forward again. If a restore is cut off, plain `--restore` sees that
132
+ the latest run is an unfinished restore and finishes it. It also clears the restored paths from
108
133
  `.deploy-manifest.json`, so the next deploy compares them by content instead of trusting stale hashes.
109
134
 
110
135
  Backup folders are skipped by deploys and pulls, and are never deleted except by `--prune`.
111
136
 
112
137
  ## Use it from an AI agent (MCP)
113
138
 
114
- `bin/mcp.js` is an MCP server over stdio.
139
+ `shipback-mcp` (`bin/mcp.js`) is an MCP server over stdio.
115
140
 
116
141
  **Claude Code**
117
142
 
118
143
  ```bash
119
- claude mcp add shipback-sftp -- node /path/to/shipback-sftp/bin/mcp.js --sites /path/to/sites.json
144
+ claude mcp add shipback-sftp -- npx -y -p shipback-sftp shipback-mcp --sites /path/to/sites.json
120
145
  ```
121
146
 
122
147
  **Cursor, Claude Desktop, and others** (`mcp.json` / `claude_desktop_config.json`):
@@ -125,14 +150,18 @@ claude mcp add shipback-sftp -- node /path/to/shipback-sftp/bin/mcp.js --sites /
125
150
  {
126
151
  "mcpServers": {
127
152
  "shipback-sftp": {
128
- "command": "node",
129
- "args": ["/path/to/shipback-sftp/bin/mcp.js", "--sites", "/path/to/sites.json"]
153
+ "command": "npx",
154
+ "args": ["-y", "-p", "shipback-sftp", "shipback-mcp", "--sites", "/path/to/sites.json"]
130
155
  }
131
156
  }
132
157
  }
133
158
  ```
134
159
 
135
- Run any CLI command for a site once first (for example `node bin/cli.js mysite --backups`). That stores
160
+ On Windows, if the client can't start `npx`, use `"command": "cmd"` with `"/c", "npx"` at the front of
161
+ `args`. After a global install, `"command": "shipback-mcp"` works too, and from a clone it's `node` with
162
+ `bin/mcp.js` as the first arg.
163
+
164
+ Run any CLI command for a site once first (for example `shipback mysite --backups`). That stores
136
165
  the password in the OS keychain, where the MCP server reads it. The model never sees it. For CI you can set
137
166
  `SFTP_PASSWORD` in the server's environment instead.
138
167
 
@@ -172,7 +201,8 @@ one-off uploads, browse the server, and see history. It only answers requests fr
172
201
 
173
202
  If Claude also runs the CLI directly, add `bin/claude-guard.js` as a PreToolUse hook. It blocks
174
203
  `--mirror` and `--no-backup` in shell commands, queue files and API calls, and tells Claude why. You can
175
- still run them yourself in a terminal.
204
+ still run them yourself in a terminal. After `npm install -g shipback-sftp`, the file is in the folder
205
+ `npm root -g` prints, under `shipback-sftp/bin/`.
176
206
 
177
207
  ```json
178
208
  {
@@ -193,7 +223,8 @@ still run them yourself in a terminal.
193
223
  npm test
194
224
  ```
195
225
 
196
- The suite uses `node:test` and needs no network. Deploy, restore, the CLI and the MCP server are each
226
+ The suite uses `node:test` and needs no network. `test/promise.test.js` holds one test per line of the
227
+ [undo promise](UNDO-PROMISE.md). Deploy, restore, the CLI and the MCP server are each
197
228
  also exercised end to end against a real SFTP server started inside the test run.
198
229
 
199
230
  ## Limits
@@ -204,6 +235,8 @@ also exercised end to end against a real SFTP server started inside the test run
204
235
  hosting account. Keep your host's own backups too.
205
236
  - Files over 8 MB with the same size on both sides are assumed unchanged when there is no manifest hash
206
237
  to compare.
238
+ - What undo doesn't cover (databases, `--no-backup` deploys, pruned backups and so on) is listed in
239
+ [the undo promise](UNDO-PROMISE.md#what-it-doesnt-cover).
207
240
 
208
241
  ## License
209
242
 
@@ -0,0 +1,53 @@
1
+ # The undo promise
2
+
3
+ Any deploy Shipback makes can be undone with one command:
4
+
5
+ ```bash
6
+ shipback mysite --restore
7
+ ```
8
+
9
+ Agents get the same thing as `preview_restore` then `restore`. Here is exactly what it does. Each line has a test with
10
+ the same number in [`test/promise.test.js`](test/promise.test.js). Those tests run on every push, on Windows, macOS
11
+ and Linux, and compare every byte on the server, not a sample.
12
+
13
+ | | When you undo a deploy |
14
+ | --- | --- |
15
+ | **P1** | Every file it replaced or deleted comes back byte for byte. Every file it added is removed, along with any folder it created for them. Nothing else on the server is touched: uploads, form data and `.well-known` stay as they are. |
16
+ | **P2** | If the deploy was cut off part way (Wi-Fi dropped, laptop lid shut), the live site never had half a file on it, and the same one command still undoes it. No `--force`. |
17
+ | **P3** | That holds on servers that can't swap a file in one step too. |
18
+ | **P4** | If it was cut off while it was still saving backups, it hadn't changed anything yet. Undo says so and doesn't guess. |
19
+ | **P5** | If the backup can't be saved (a full disk, say), nothing goes up. |
20
+ | **P6** | If the undo itself is cut off, run it again and it finishes. |
21
+ | **P7** | If someone changed one of those files on the server after the deploy, undo stops, names the file and changes nothing at all. You decide what happens next. |
22
+ | **P8** | A backup copy that's been damaged since is never written back, even with `--force`. |
23
+ | **P9** | The undo can be undone. |
24
+ | **P10** | All of this works through a real SFTP server, and replaced files keep their permissions. |
25
+ | **P11** | A dropped connection fails the deploy out loud and tells you how to undo it. It never stops in silence. |
26
+ | **P12** | Only one deploy or undo writes to a site at a time. A second one is turned away without changing anything, so two runs can never get mixed into one backup. A lock left behind by a run that was cut off clears itself. |
27
+ | **P13** | Undo never touches anything outside the site folder, even if a backup record has been tampered with to say it should, and neither undo nor deploy writes through a symlink or over a special file on the server. A record that can't be read is refused, never guessed at. |
28
+
29
+ These have also been run on a real cPanel host, including cutting the connection halfway through a deploy and running
30
+ two deploys at once.
31
+
32
+ ## If it doesn't
33
+
34
+ If undo ever fails to do one of those things, [open an "Undo didn't work" issue](https://github.com/aldohushi1-stack/shipback-sftp/issues/new?template=undo_failed.yml).
35
+
36
+ - It's the first thing I fix, ahead of everything else.
37
+ - I'll help you get your files back by hand. Everything the deploy replaced is still in its `_backup-…` folder on your server.
38
+ - The fix ships with a test built from your case, so it can't happen again.
39
+
40
+ ## What it doesn't cover
41
+
42
+ - **Deploys made with `--no-backup`.** They have nothing to undo from. The agent tools can't make them.
43
+ - **Backups that are gone.** Undo needs that deploy's `_backup-…` folder. `--prune` will remove old ones if you ask, and so can you or your host.
44
+ - **Anything outside the site folder:** databases, server settings, cron jobs, email. The site folder itself may be a
45
+ symlink (cPanel's `www` → `public_html` is normal), and Shipback follows that one. It won't follow any symlink inside it.
46
+ - **Backups made before 0.2.** Those still restore, but they don't carry the checksums behind P4 and P8.
47
+ - **File dates and owners.** Restored files get the current time. A file brought back after a deploy deleted it gets the server's default permissions.
48
+ - **Losing the hosting account.** Backups sit on the same server as the site. Keep your host's own backups too.
49
+
50
+ This is a promise about what the software does and how I'll treat it when it doesn't. It isn't insurance, and the
51
+ MIT licence's "no warranty" still stands.
52
+
53
+ Aldo Hushi, 20 October 2026
package/bin/cli.js CHANGED
@@ -119,7 +119,8 @@ ${site.host}:${remoteRoot} restore from ${ev.backup}${ev.legacy ? ' (older bac
119
119
  `);
120
120
  } else if (ev.type === 'conflict') console.log(` CONFLICT ${ev.path}: ${ev.why}`);
121
121
  else if (ev.type === 'restore') console.log(` restore ${ev.path}`);
122
- else if (ev.type === 'error') console.error(` ERROR ${ev.message}`);
122
+ else if (ev.type === 'error') console.error(` ERROR ${ev.message}${ev.undo ? `
123
+ ${ev.undo}` : ''}`);
123
124
  };
124
125
  const common = { localDir, remoteRoot, remoteFs: rfs, dryRun: !!args.dryRun, backup: args.backup !== false, mirror: !!args.mirror, ignore: site.ignore, onEvent };
125
126
  try {
@@ -127,12 +128,14 @@ ${site.host}:${remoteRoot} restore from ${ev.backup}${ev.legacy ? ' (older bac
127
128
  const list = await describeBackups({ remoteRoot, remoteFs: rfs });
128
129
  if (args.json) console.log(JSON.stringify(list, null, 2));
129
130
  else if (!list.length) console.log('No backups on the server.');
130
- else for (const b of list) console.log(` ${b.name.padEnd(28)} ${b.legacy ? `${b.files} files (older backup, no record)` : `${b.action.padEnd(8)} ${b.overwritten} overwritten, ${b.added} added, ${b.deleted} deleted${b.from ? ' (undo of ' + b.from + ')' : ''}`}`);
131
+ else for (const b of list) console.log(` ${b.name.padEnd(28)} ${b.legacy ? `${b.files} files (older backup, no record)` : `${b.action.padEnd(8)} ${b.overwritten} overwritten, ${b.added} added, ${b.deleted} deleted${b.from ? ' (undo of ' + b.from + ')' : ''}${b.status === 'saving' ? ' [cut off before changing anything]' : b.status ? ' [cut off part way]' : ''}`}`);
131
132
  return;
132
133
  }
133
134
  if (args.restore) {
134
135
  const s = await runRestore({ remoteRoot, remoteFs: rfs, name: args.name, dryRun: !!args.dryRun, force: !!args.force, onEvent });
135
- if (s.refused) { console.log(`Refused: ${s.conflicts.length} file(s) changed on the server after that run. Nothing was changed. Check them, then add --force to restore anyway (the live copies are backed up first).`); process.exitCode = 4; }
136
+ if (s.resumed) console.log(`(${s.resumed} is a restore that was cut off, so this finishes it: restoring ${s.backup})`);
137
+ if (s.stoppedEarly) console.log(`${s.backup} was cut off while it was still saving backups, before it changed any live file. Nothing to undo.`);
138
+ else if (s.refused) { console.log(`Refused: ${s.conflicts.length} file(s) changed on the server after that run. Nothing was changed. Check them, then add --force to restore anyway (the live copies are backed up first).`); process.exitCode = 4; }
136
139
  else if (s.dryRun) console.log('Dry run — nothing was changed.');
137
140
  else console.log(`Done: ${s.put.length} put back, ${s.remove.length} removed${s.undoBackupDir ? `. Undo this with --restore ${s.undoBackupDir.split('/').pop()}` : ''}.`);
138
141
  if (!s.dryRun) await appendHistory(historyFile, recordFromSummary({ action, site: site.name, trigger: 'cli', remoteDir: remoteRoot, startedAt, summary: s })).catch(() => {});
@@ -152,7 +155,7 @@ ${site.host}:${remoteRoot} restore from ${ev.backup}${ev.legacy ? ' (older bac
152
155
  }
153
156
  if (args.json) console.log(JSON.stringify(s));
154
157
  } catch (e) {
155
- process.exitCode = 3;
158
+ process.exitCode = e.code === 'ELOCKED' ? 5 : 3;
156
159
  await appendHistory(historyFile, recordFromSummary({ action, site: site.name, trigger: 'cli', localDir, remoteDir: remoteRoot, startedAt, error: e.message })).catch(() => {});
157
160
  }
158
161
  finally { await rfs.close(); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shipback-sftp",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "SFTP deploys to shared hosting that you, a script or an AI agent can undo: backup before every overwrite, one-command restore that won't clobber live edits, and an MCP server.",
5
5
  "keywords": [
6
6
  "sftp",
@@ -32,6 +32,7 @@
32
32
  "start.bat",
33
33
  "sites.example.json",
34
34
  "README.md",
35
+ "UNDO-PROMISE.md",
35
36
  "LICENSE"
36
37
  ],
37
38
  "scripts": {
package/src/backup.js CHANGED
@@ -2,8 +2,10 @@
2
2
  // Copy the server's current copy of each file into <root>/_backup-<stamp>/<path> before it is overwritten or deleted.
3
3
  // A deploy also writes <backup>/.backup.json — what it overwrote, added and deleted, and the sha256 of what it
4
4
  // uploaded — so restore.js can undo it and tell when a live file was edited after the deploy.
5
+ const crypto = require('node:crypto');
5
6
  const posix = require('node:path').posix;
6
- const { BACKUP_PREFIX } = require('./diff');
7
+ const { BACKUP_PREFIX, MANIFEST_NAME } = require('./diff');
8
+ const { writeLive, isHidden } = require('./remote-fs');
7
9
 
8
10
  const BACKUP_INFO = '.backup.json';
9
11
 
@@ -20,11 +22,17 @@ async function freeBackupDir(remoteFs, root, stamp) {
20
22
  return dir;
21
23
  }
22
24
 
23
- /** backupFiles({ remoteFs, root, paths, stamp | backupDir, onEvent }) → { backupDir, copied } */
25
+ const sha256 = buf => crypto.createHash('sha256').update(buf).digest('hex');
26
+
27
+ /**
28
+ * backupFiles({ remoteFs, root, paths, stamp | backupDir, onEvent }) → { backupDir, copied, was }
29
+ * was: Map path → sha256 of the copy saved, so restore can check the copy is still intact before using it
30
+ */
24
31
  async function backupFiles({ remoteFs, root, paths, stamp = makeStamp(), backupDir, onEvent = () => {} }) {
25
32
  root = root.replace(/\/$/, '');
26
33
  backupDir = backupDir || `${root}/${BACKUP_PREFIX}${stamp}`;
27
34
  const copied = [];
35
+ const was = new Map();
28
36
  const madeDirs = new Set();
29
37
  for (const rel of paths) {
30
38
  const src = `${root}/${rel}`;
@@ -36,26 +44,51 @@ async function backupFiles({ remoteFs, root, paths, stamp = makeStamp(), backupD
36
44
  const data = await remoteFs.readFile(src);
37
45
  await remoteFs.writeFile(dest, data);
38
46
  copied.push(rel);
47
+ was.set(rel, sha256(data));
39
48
  onEvent({ type: 'backup', path: rel, bytes: data.length, to: dest });
40
49
  }
41
- return { backupDir, copied };
50
+ return { backupDir, copied, was };
42
51
  }
43
52
 
44
53
  /**
45
54
  * writeBackupInfo({ remoteFs, backupDir, info })
46
- * info: { action, at, overwritten: [{ path, sha256 }], added: [{ path, sha256 }], deleted: [{ path }], from? }
55
+ * info: { action, at, overwritten: [{ path, sha256, was }], added: [{ path, sha256 }], deleted: [{ path, was }], from? }
47
56
  * sha256 = the content the run left at that path, so a later restore can tell if it was edited since.
57
+ * was = the sha256 of the copy saved in the backup folder (from 0.2; older records have none).
58
+ * status (from 0.2): 'saving' while the copies are being made, so a run cut off then is known to have changed
59
+ * nothing; a restore also says 'running' while it writes, so cutting it off can be finished by running it again.
60
+ * No status = the record is whole. It is swapped in whole too, never left half written.
48
61
  */
49
62
  async function writeBackupInfo({ remoteFs, backupDir, info }) {
50
63
  await remoteFs.mkdirp(backupDir);
51
- await remoteFs.writeFile(`${backupDir}/${BACKUP_INFO}`, JSON.stringify({ version: 1, tool: 'shipback-sftp', ...info }, null, 1));
64
+ await writeLive(remoteFs, `${backupDir}/${BACKUP_INFO}`, { data: Buffer.from(JSON.stringify({ version: 1, tool: 'shipback-sftp', ...info }, null, 1)) });
65
+ }
66
+
67
+ /** A path a record may name: relative, inside the site, and not one of Shipback's own files. */
68
+ function siteRelative(p) {
69
+ if (typeof p !== 'string' || !p || p.startsWith('/') || p.includes('\\') || p.includes('\0') || /^[A-Za-z]:/.test(p)) return false;
70
+ const segs = p.split('/');
71
+ return segs.every(s => s && s !== '.' && s !== '..' && !s.startsWith(BACKUP_PREFIX)) && p !== MANIFEST_NAME && !isHidden(p);
52
72
  }
53
73
 
74
+ /**
75
+ * The run's record, or null if the folder has none (a backup made before .backup.json existed).
76
+ * A record that can't be read, or that names any path outside the site, throws: restore never guesses from it,
77
+ * and never falls back to putting files back unchecked.
78
+ */
54
79
  async function readBackupInfo(remoteFs, backupDir) {
55
- try {
56
- const info = JSON.parse((await remoteFs.readFile(`${backupDir}/${BACKUP_INFO}`)).toString('utf8'));
57
- return info && info.version === 1 ? info : null;
58
- } catch { return null; }
80
+ if (!(await remoteFs.stat(`${backupDir}/${BACKUP_INFO}`))) return null;
81
+ let info;
82
+ try { info = JSON.parse((await remoteFs.readFile(`${backupDir}/${BACKUP_INFO}`)).toString('utf8')); }
83
+ catch (e) { throw new Error(`${posix.basename(backupDir)}/${BACKUP_INFO} is damaged (${e.message.split('\n')[0]}), so it can't be restored safely`); }
84
+ if (!info || info.version !== 1) throw new Error(`${posix.basename(backupDir)}/${BACKUP_INFO} is not a record this version understands`);
85
+ for (const key of ['overwritten', 'added', 'deleted']) {
86
+ if (!Array.isArray(info[key])) throw new Error(`${posix.basename(backupDir)}/${BACKUP_INFO} is damaged (no "${key}" list), so it can't be restored safely`);
87
+ for (const e of info[key]) {
88
+ if (!e || !siteRelative(e.path)) throw new Error(`${posix.basename(backupDir)}/${BACKUP_INFO} names a path outside the site (${JSON.stringify(e && e.path)}). Nothing was changed`);
89
+ }
90
+ }
91
+ return info;
59
92
  }
60
93
 
61
- module.exports = { backupFiles, makeStamp, freeBackupDir, writeBackupInfo, readBackupInfo, BACKUP_INFO };
94
+ module.exports = { backupFiles, makeStamp, freeBackupDir, writeBackupInfo, readBackupInfo, siteRelative, BACKUP_INFO };
package/src/deploy.js CHANGED
@@ -5,6 +5,8 @@ const posix = path.posix;
5
5
  const { scanLocal, loadIgnore, dropIgnored, sha256File } = require('./manifest');
6
6
  const { plan: makePlan, resolveVerify, MANIFEST_NAME, BACKUP_PREFIX } = require('./diff');
7
7
  const { backupFiles, makeStamp, freeBackupDir, writeBackupInfo } = require('./backup');
8
+ const { writeLive, outsideReason } = require('./remote-fs');
9
+ const { withLock } = require('./lock');
8
10
 
9
11
  const isBackupDir = name => name.startsWith(BACKUP_PREFIX);
10
12
 
@@ -13,6 +15,15 @@ async function readRemoteManifest(remoteFs, root) {
13
15
  catch { return null; }
14
16
  }
15
17
 
18
+ /** Throw before anything changes if any path would write through a symlink or over a special file. */
19
+ async function refuseOutside(remoteFs, root, paths) {
20
+ const folders = new Map();
21
+ for (const p of paths) {
22
+ const why = await outsideReason(remoteFs, root, p, folders);
23
+ if (why) throw new Error(`Refused: ${why}. Nothing was changed. Ignore that path in sites.json or .deployignore, or replace the symlink with a real folder.`);
24
+ }
25
+ }
26
+
16
27
  /** Preview only: scan both sides and return the plan. */
17
28
  async function previewDeploy({ localDir, remoteRoot, remoteFs, mirror = false, ignore, onEvent = () => {} }) {
18
29
  const root = remoteRoot.replace(/\/$/, '');
@@ -25,6 +36,7 @@ async function previewDeploy({ localDir, remoteRoot, remoteFs, mirror = false, i
25
36
  const remoteListing = rootStat ? dropIgnored(await remoteFs.listRecursive(root, { skipDir: isBackupDir }), await loadIgnore(localDir, ignore)) : [];
26
37
  const remoteManifest = rootStat ? await readRemoteManifest(remoteFs, root) : null;
27
38
  const plan = makePlan({ local, remoteManifest, remoteListing, mirror });
39
+ if (rootStat) await refuseOutside(remoteFs, root, [...plan.upload.map(u => u.path), ...plan.delete]);
28
40
  if (plan.verify.length) await resolveVerify(plan, { readFile: p => remoteFs.readFile(`${root}/${p}`), onEvent });
29
41
  onEvent({ type: 'plan', plan, localCount: local.length, remoteCount: remoteListing.length, remoteExists: !!rootStat });
30
42
  return { local, plan, remoteExists: !!rootStat };
@@ -34,7 +46,12 @@ async function previewDeploy({ localDir, remoteRoot, remoteFs, mirror = false, i
34
46
  * runDeploy({ localDir, remoteRoot, remoteFs, dryRun, backup, mirror, ignore, now, onEvent })
35
47
  * → { dryRun, plan, uploaded, backedUp, backupDir, deleted, bytes }
36
48
  */
37
- async function runDeploy({ localDir, remoteRoot, remoteFs, dryRun = false, backup = true, mirror = false, ignore, now = new Date(), onEvent = () => {} }) {
49
+ async function runDeploy(opts) {
50
+ if (opts.dryRun) return deployNow(opts);
51
+ return withLock({ remoteFs: opts.remoteFs, root: opts.remoteRoot, action: 'deploy', createRoot: true, onEvent: opts.onEvent }, () => deployNow(opts));
52
+ }
53
+
54
+ async function deployNow({ localDir, remoteRoot, remoteFs, dryRun = false, backup = true, mirror = false, ignore, now = new Date(), onEvent = () => {} }) {
38
55
  const root = remoteRoot.replace(/\/$/, '');
39
56
  const summary = { dryRun, plan: null, uploaded: [], backedUp: [], backupDir: null, deleted: [], bytes: 0 };
40
57
  try {
@@ -44,30 +61,32 @@ async function runDeploy({ localDir, remoteRoot, remoteFs, dryRun = false, backu
44
61
 
45
62
  if (!remoteExists) { await remoteFs.mkdirp(root); onEvent({ type: 'mkdir', path: root }); }
46
63
 
47
- // 1. backup everything about to be overwritten or deleted, plus .backup.json so restore can undo this run
64
+ // 1. backup everything about to be overwritten or deleted, plus .backup.json so restore can undo this run.
65
+ // The record says 'saving' until every copy is made: cut off before that, the run has changed nothing.
48
66
  if (backup && (plan.upload.length || plan.delete.length)) {
49
67
  const backupDir = await freeBackupDir(remoteFs, root, makeStamp(now));
68
+ await writeBackupInfo({ remoteFs, backupDir, info: { action: 'deploy', at: now.toISOString(), status: 'saving', overwritten: [], added: [], deleted: [] } });
50
69
  const targets = [...plan.upload.filter(u => u.reason === 'changed').map(u => u.path), ...plan.delete];
51
70
  const res = await backupFiles({ remoteFs, root, paths: targets, backupDir, onEvent });
52
71
  const copied = new Set(res.copied);
53
72
  const sha = new Map(local.map(f => [f.path, f.sha256]));
54
73
  await writeBackupInfo({ remoteFs, backupDir, info: {
55
74
  action: 'deploy', at: now.toISOString(),
56
- overwritten: plan.upload.filter(u => copied.has(u.path)).map(u => ({ path: u.path, sha256: sha.get(u.path) })),
75
+ overwritten: plan.upload.filter(u => copied.has(u.path)).map(u => ({ path: u.path, sha256: sha.get(u.path), was: res.was.get(u.path) })),
57
76
  added: plan.upload.filter(u => !copied.has(u.path)).map(u => ({ path: u.path, sha256: sha.get(u.path) })),
58
- deleted: plan.delete.filter(p => copied.has(p)).map(p => ({ path: p })),
77
+ deleted: plan.delete.filter(p => copied.has(p)).map(p => ({ path: p, was: res.was.get(p) })),
59
78
  } });
60
79
  summary.backedUp = res.copied;
61
80
  summary.backupDir = backupDir;
62
81
  }
63
82
 
64
- // 2. upload new + changed
83
+ // 2. upload new + changed, each swapped in whole (a dropped line leaves the old file, not half a new one)
65
84
  const madeDirs = new Set([root]);
66
85
  for (const u of plan.upload) {
67
86
  const dest = `${root}/${u.path}`;
68
87
  const dir = posix.dirname(dest);
69
88
  if (!madeDirs.has(dir)) { await remoteFs.mkdirp(dir); madeDirs.add(dir); }
70
- await remoteFs.uploadFile(path.join(localDir, ...u.path.split('/')), dest);
89
+ await writeLive(remoteFs, dest, { localPath: path.join(localDir, ...u.path.split('/')) });
71
90
  summary.uploaded.push(u.path); summary.bytes += u.size;
72
91
  onEvent({ type: 'upload', path: u.path, bytes: u.size, reason: u.reason, done: summary.uploaded.length, total: plan.upload.length });
73
92
  }
@@ -87,34 +106,42 @@ async function runDeploy({ localDir, remoteRoot, remoteFs, dryRun = false, backu
87
106
  onEvent({ type: 'done', summary });
88
107
  return summary;
89
108
  } catch (err) {
90
- onEvent({ type: 'error', message: err.message, summary });
109
+ // a deploy that stopped part way is undone like any other: everything it touched is in its backup record
110
+ if (summary.backupDir && summary.uploaded.length) err.undo = `${summary.uploaded.length} of ${summary.plan.upload.length} files went up before it stopped. --restore undoes them (${posix.basename(summary.backupDir)}).`;
111
+ onEvent({ type: 'error', message: err.message, undo: err.undo, summary });
91
112
  throw err;
92
113
  }
93
114
  }
94
115
 
95
116
  /** Ad-hoc upload of named local files into a remote dir (with backup of anything overwritten). */
96
- async function uploadFiles({ files, remoteDir, remoteFs, backup = true, now = new Date(), onEvent = () => {} }) {
117
+ function uploadFiles(opts) {
118
+ return withLock({ remoteFs: opts.remoteFs, root: opts.remoteDir, action: 'upload', createRoot: true, onEvent: opts.onEvent }, () => uploadNow(opts));
119
+ }
120
+
121
+ async function uploadNow({ files, remoteDir, remoteFs, backup = true, now = new Date(), onEvent = () => {} }) {
97
122
  // files: [{ name, localPath, size }]
98
123
  const root = remoteDir.replace(/\/$/, '');
99
124
  const summary = { uploaded: [], backedUp: [], backupDir: null, bytes: 0 };
100
125
  try {
101
126
  await remoteFs.mkdirp(root);
127
+ await refuseOutside(remoteFs, root, files.map(f => f.name));
102
128
  if (backup && files.length) {
103
129
  const backupDir = await freeBackupDir(remoteFs, root, makeStamp(now));
130
+ await writeBackupInfo({ remoteFs, backupDir, info: { action: 'upload', at: now.toISOString(), status: 'saving', overwritten: [], added: [], deleted: [] } });
104
131
  const res = await backupFiles({ remoteFs, root, paths: files.map(f => f.name), backupDir, onEvent });
105
132
  const copied = new Set(res.copied);
106
133
  const shas = new Map();
107
134
  for (const f of files) shas.set(f.name, await sha256File(f.localPath));
108
135
  await writeBackupInfo({ remoteFs, backupDir, info: {
109
136
  action: 'upload', at: now.toISOString(),
110
- overwritten: files.filter(f => copied.has(f.name)).map(f => ({ path: f.name, sha256: shas.get(f.name) })),
137
+ overwritten: files.filter(f => copied.has(f.name)).map(f => ({ path: f.name, sha256: shas.get(f.name), was: res.was.get(f.name) })),
111
138
  added: files.filter(f => !copied.has(f.name)).map(f => ({ path: f.name, sha256: shas.get(f.name) })),
112
139
  deleted: [],
113
140
  } });
114
141
  summary.backedUp = res.copied; summary.backupDir = backupDir;
115
142
  }
116
143
  for (const f of files) {
117
- await remoteFs.uploadFile(f.localPath, `${root}/${f.name}`);
144
+ await writeLive(remoteFs, `${root}/${f.name}`, { localPath: f.localPath });
118
145
  summary.uploaded.push(f.name); summary.bytes += f.size || 0;
119
146
  onEvent({ type: 'upload', path: f.name, bytes: f.size || 0, reason: 'upload', done: summary.uploaded.length, total: files.length });
120
147
  }
package/src/lock.js ADDED
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+ // One run at a time per site. Deploy, upload, restore and prune hold <root>/.shipback-lock while they write: two
3
+ // at once would interleave their backups, and an undo could then pick up half of each. A second run is refused
4
+ // and told who holds the lock.
5
+ //
6
+ // A run that is cut off can't take its lock away, so a lock counts as stale (and is taken over) when:
7
+ // · it was taken on this computer by a process that has ended, or by this process for a run that is over, or
8
+ // · nobody has refreshed it for STALE_MS (the holder rewrites it every HEARTBEAT_MS while it runs)
9
+ //
10
+ // withLock({ remoteFs, root, action, createRoot, onEvent }, fn) → fn's result, the lock held throughout.
11
+ // Only a deploy or upload creates a missing root; a restore or prune there has nothing to protect and fails by itself.
12
+ const crypto = require('node:crypto');
13
+ const os = require('node:os');
14
+ const { LOCK_NAME } = require('./remote-fs');
15
+
16
+ const HEARTBEAT_MS = 30000;
17
+ const STALE_MS = 120000;
18
+ const held = new Set(); // tokens this process holds right now
19
+
20
+ // the lock sits in the web root, so it carries a hash of the computer's name, not the name
21
+ const machine = () => crypto.createHash('sha256').update(os.hostname()).digest('hex').slice(0, 12);
22
+
23
+ function pidAlive(pid) {
24
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
25
+ }
26
+
27
+ async function readLock(remoteFs, file) {
28
+ try { return JSON.parse((await remoteFs.readFile(file)).toString('utf8')); } catch { return null; }
29
+ }
30
+
31
+ /** Why a lock can be taken over, or null if its run may still be going. */
32
+ function staleReason(lock, mtime, now) {
33
+ if (lock && lock.machine === machine() && Number.isInteger(lock.pid)) {
34
+ if (lock.pid === process.pid ? !held.has(lock.token) : !pidAlive(lock.pid)) return 'its run on this computer has ended';
35
+ }
36
+ if (now - mtime > STALE_MS) return `nothing has refreshed it for ${Math.round((now - mtime) / 60000)} min`;
37
+ return null;
38
+ }
39
+
40
+ class LockedError extends Error {
41
+ constructor(lock) {
42
+ const who = lock && lock.machine === machine() ? 'on this computer' : 'from another computer';
43
+ super(`Another ${lock && lock.action || 'run'} is writing to this site right now (started ${lock && lock.at || 'just now'}, ${who}). `
44
+ + 'Nothing was changed. Try again when it finishes. A lock left by a run that was cut off clears itself within 2 minutes.');
45
+ this.code = 'ELOCKED';
46
+ }
47
+ }
48
+
49
+ async function acquire({ remoteFs, root, action, now = Date.now, onEvent = () => {} }) {
50
+ const file = `${root}/${LOCK_NAME}`;
51
+ const token = crypto.randomBytes(8).toString('hex');
52
+ const body = () => Buffer.from(JSON.stringify({ tool: 'shipback-sftp', action, token, pid: process.pid, machine: machine(), at: new Date(now()).toISOString() }));
53
+ for (let attempt = 0; attempt < 3; attempt++) {
54
+ if (await remoteFs.createExclusive(file, body())) {
55
+ held.add(token);
56
+ let beat = Promise.resolve();
57
+ const timer = setInterval(() => { beat = beat.then(() => remoteFs.writeFile(file, body())).catch(() => {}); }, HEARTBEAT_MS);
58
+ if (timer.unref) timer.unref();
59
+ return async () => {
60
+ clearInterval(timer);
61
+ held.delete(token);
62
+ await beat;
63
+ const cur = await readLock(remoteFs, file);
64
+ if (cur && cur.token === token) await remoteFs.remove(file).catch(() => {});
65
+ };
66
+ }
67
+ const st = await remoteFs.stat(file);
68
+ if (!st) continue; // released between the two calls
69
+ const lock = await readLock(remoteFs, file);
70
+ const why = staleReason(lock, st.mtime, now());
71
+ if (!why) throw new LockedError(lock);
72
+ onEvent({ type: 'stale-lock', action: lock && lock.action, at: lock && lock.at, why });
73
+ await remoteFs.remove(file).catch(() => {});
74
+ }
75
+ throw new LockedError(await readLock(remoteFs, file));
76
+ }
77
+
78
+ async function withLock({ remoteFs, root, action, createRoot = false, now, onEvent = () => {} }, fn) {
79
+ root = root.replace(/\/$/, '');
80
+ let release = null;
81
+ try {
82
+ const exists = await remoteFs.stat(root);
83
+ if (exists || createRoot) {
84
+ if (!exists) await remoteFs.mkdirp(root);
85
+ release = await acquire({ remoteFs, root, action, now, onEvent });
86
+ }
87
+ } catch (err) { onEvent({ type: 'error', message: err.message, code: err.code }); throw err; }
88
+ try { return await fn(); }
89
+ finally { if (release) await release().catch(() => {}); } // a dropped connection can't release: the lock then goes stale
90
+ }
91
+
92
+ module.exports = { withLock, LockedError, STALE_MS, HEARTBEAT_MS };
package/src/mcp.js CHANGED
@@ -153,7 +153,9 @@ function createMcp(opts = {}) {
153
153
  return {
154
154
  planId: restorePlanId(site, s), site: site.name, backup: s.backup, undoes: s.action, legacy: s.legacy || undefined,
155
155
  putBack: cap(s.put), remove: cap(s.remove), alreadyRight: s.same.length, conflicts: s.conflicts,
156
- note: s.conflicts.length ? 'Some live files were changed after that run. Show the user the conflicts; restore refuses them unless force is true.'
156
+ resumes: s.resumed || undefined,
157
+ note: s.stoppedEarly ? 'That run was cut off while saving its backups, before it changed any live file. There is nothing to undo.'
158
+ : s.conflicts.length ? 'Some live files were changed after that run. Show the user the conflicts; restore refuses them unless force is true.'
157
159
  : s.put.length || s.remove.length ? 'Call restore with this backup and planId to go ahead. The current live files are backed up first.' : 'The site already matches this backup.',
158
160
  };
159
161
  }),
@@ -181,7 +183,8 @@ function createMcp(opts = {}) {
181
183
  const out = await tools[name](args || {});
182
184
  return { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] };
183
185
  } catch (e) {
184
- return { content: [{ type: 'text', text: e.message }], isError: true };
186
+ return { content: [{ type: 'text', text: e.undo ? `${e.message}
187
+ ${e.undo} Call preview_restore to see it.` : e.message }], isError: true };
185
188
  }
186
189
  }
187
190
 
package/src/prune.js CHANGED
@@ -13,6 +13,7 @@
13
13
  // → { dryRun, backups, targets, pruned: [{ name, files, bytes }], refused: [{ name, unique }], deleted, bytes }
14
14
  const crypto = require('node:crypto');
15
15
  const { BACKUP_PREFIX } = require('./diff');
16
+ const { withLock } = require('./lock');
16
17
  const { BACKUP_INFO } = require('./backup');
17
18
 
18
19
  const sha256 = buf => crypto.createHash('sha256').update(buf).digest('hex');
@@ -47,7 +48,12 @@ function chooseTargets(backups, { names, keep }) {
47
48
  throw new Error('Say which backups to prune: "names": ["_backup-…"] or "keep": N (newest N stay)');
48
49
  }
49
50
 
50
- async function runPrune({ remoteRoot, remoteFs, names, keep, dryRun = false, force = false, onEvent = () => {} }) {
51
+ function runPrune(opts) {
52
+ if (opts.dryRun) return pruneNow(opts);
53
+ return withLock({ remoteFs: opts.remoteFs, root: opts.remoteRoot, action: 'prune', onEvent: opts.onEvent }, () => pruneNow(opts));
54
+ }
55
+
56
+ async function pruneNow({ remoteRoot, remoteFs, names, keep, dryRun = false, force = false, onEvent = () => {} }) {
51
57
  const root = remoteRoot.replace(/\/$/, '');
52
58
  onEvent({ type: 'scan', side: 'remote', dir: root });
53
59
  const backups = await listBackups(remoteFs, root);
package/src/queue.js CHANGED
@@ -36,7 +36,7 @@ function fmtEvent(ev) {
36
36
  case 'conflict': return `${t} CONFLICT ${ev.path}: ${ev.why}`;
37
37
  case 'manifest': return `${t} wrote ${ev.path}`;
38
38
  case 'done': return `${t} done`;
39
- case 'error': return `${t} ERROR ${ev.message}`;
39
+ case 'error': return `${t} ERROR ${ev.message}${ev.undo ? ` ${ev.undo}` : ''}`;
40
40
  default: return `${t} ${ev.type}`;
41
41
  }
42
42
  }
package/src/remote-fs.js CHANGED
@@ -3,14 +3,34 @@
3
3
  // stat(p) → { size, mtime(ms), isDir } | null
4
4
  // listRecursive(dir, { skipDir }) → [{ path (posix, relative to dir), size, mtime(ms) }] (files only)
5
5
  // mkdirp(p) · readFile(p) → Buffer · writeFile(p, Buffer) · uploadFile(localPath, p)
6
- // rename(a, b) · remove(p) · rmdir(p) · close()
6
+ // lstat(p) → { isDir, isFile, isLink } | null (does not follow symlinks)
7
+ // rename(a, b) · replace(tmp, dest) · remove(p) · rmdir(p) · close()
8
+ //
9
+ // Live files are never written in place: the new copy goes to partPath(dest) and replace() swaps it in, so a
10
+ // dropped connection leaves the old file whole, never half a file. The part name keeps the extension (a stray
11
+ // part of config.php is still run as PHP, not served as source) and listRecursive hides part files.
7
12
  const posix = require('node:path').posix;
8
13
  const fs = require('node:fs');
9
14
  const { Client } = require('ssh2');
10
15
 
16
+ const PART_PREFIX = '.shipback-part-';
17
+ const LOCK_NAME = '.shipback-lock';
18
+ const partPath = p => posix.join(posix.dirname(p), PART_PREFIX + posix.basename(p));
19
+ const isPart = rel => posix.basename(rel).startsWith(PART_PREFIX);
20
+ const isHidden = rel => isPart(rel) || rel === LOCK_NAME; // Shipback's own working files: never deployed, pulled or listed
21
+
11
22
  // ---------------------------------------------------------------- MemoryRemoteFs (tests)
12
23
  class MemoryRemoteFs {
13
- constructor() { this.files = new Map(); this.dirs = new Set(['/']); this.failOn = null; }
24
+ // failOn(p): throw before touching p · tornOn(p): write half of p, then throw (a dropped connection)
25
+ // odd: path → 'link' | 'fifo', for paths a test says are a symlink or a special file
26
+ constructor() { this.files = new Map(); this.dirs = new Set(['/']); this.failOn = null; this.tornOn = null; this.odd = new Map(); }
27
+ async lstat(p) {
28
+ p = posix.normalize(p);
29
+ const odd = this.odd.get(p);
30
+ if (odd) return { isDir: false, isFile: false, isLink: odd === 'link' };
31
+ const st = await this.stat(p);
32
+ return st && { isDir: st.isDir, isFile: !st.isDir, isLink: false };
33
+ }
14
34
  _check(p) { if (this.failOn && this.failOn(p)) throw new Error(`Simulated failure on ${p}`); }
15
35
  async stat(p) {
16
36
  p = posix.normalize(p);
@@ -25,6 +45,7 @@ class MemoryRemoteFs {
25
45
  if (!p.startsWith(dir + '/')) continue;
26
46
  const rel = p.slice(dir.length + 1);
27
47
  if (skipDir && rel.split('/').slice(0, -1).some(seg => skipDir(seg))) continue;
48
+ if (isHidden(rel)) continue;
28
49
  out.push({ path: rel, size: f.data.length, mtime: f.mtime });
29
50
  }
30
51
  return out.sort((a, b) => a.path.localeCompare(b.path));
@@ -51,10 +72,16 @@ class MemoryRemoteFs {
51
72
  async writeFile(p, data) {
52
73
  p = posix.normalize(p); this._check(p);
53
74
  if (!this.dirs.has(posix.dirname(p))) { const e = new Error(`No such directory: ${posix.dirname(p)}`); e.code = 'ENOENT'; throw e; }
75
+ if (this.tornOn && this.tornOn(p)) {
76
+ this.files.set(p, { data: Buffer.from(data).subarray(0, Math.floor(data.length / 2)), mtime: Date.now() });
77
+ throw new Error(`Simulated connection drop while writing ${p}`);
78
+ }
54
79
  this.files.set(p, { data: Buffer.from(data), mtime: Date.now() });
55
80
  }
56
81
  async uploadFile(localPath, p) { await this.writeFile(p, await fs.promises.readFile(localPath)); }
57
82
  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); }
83
+ async replace(tmp, dest) { this._check(posix.normalize(dest)); await this.rename(tmp, dest); }
84
+ 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; }
58
85
  async remove(p) { p = posix.normalize(p); this._check(p); if (!this.files.delete(p)) throw new Error(`No such file: ${p}`); }
59
86
  async rmdir(p) { this.dirs.delete(posix.normalize(p)); }
60
87
  async close() {}
@@ -67,7 +94,26 @@ const UNREACHABLE_HINT = 'Check the port (many shared hosts run SFTP on their ow
67
94
  + 'current IP in (some make you allow-list it under SSH Access, and home IPs change).';
68
95
 
69
96
  class SftpRemoteFs {
70
- constructor(client, sftp) { this.client = client; this.sftp = sftp; }
97
+ constructor(client, sftp) {
98
+ this.client = client; this.sftp = sftp;
99
+ // ssh2 drops pending SFTP callbacks when the line goes: without this a cut-off upload never settles and the
100
+ // CLI exits 0 with nothing said. Every operation fails with this error instead.
101
+ this.lost = null; this.pending = new Set();
102
+ const fail = why => {
103
+ if (this.lost) return;
104
+ this.lost = new Error(`Lost the connection to the server${why ? ` (${why})` : ''}`);
105
+ for (const reject of this.pending) reject(this.lost);
106
+ this.pending.clear();
107
+ };
108
+ client.on('close', () => fail()); client.on('error', e => fail(e.message));
109
+ if (sftp && sftp.on) sftp.on('close', () => fail());
110
+ }
111
+ _guard(op) {
112
+ if (this.lost) return Promise.reject(this.lost);
113
+ let reject;
114
+ const cut = new Promise((_, r) => { reject = r; this.pending.add(r); });
115
+ return Promise.race([op, cut]).finally(() => this.pending.delete(reject));
116
+ }
71
117
 
72
118
  /** connect({ host, port, username, password | privateKey, readyTimeout }) */
73
119
  static connect(opts) {
@@ -100,10 +146,14 @@ class SftpRemoteFs {
100
146
  });
101
147
  }
102
148
 
103
- _p(fn) { return new Promise((res, rej) => fn((err, val) => err ? rej(err) : res(val))); }
149
+ _p(fn) { return this._guard(new Promise((res, rej) => fn((err, val) => err ? rej(err) : res(val)))); }
104
150
 
105
151
  async stat(p) {
106
- try { const a = await this._p(cb => this.sftp.stat(p, cb)); return { size: a.size, mtime: a.mtime * 1000, isDir: a.isDirectory() }; }
152
+ 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 }; }
153
+ catch (e) { if (e.code === 2 || /No such file/i.test(e.message)) return null; throw e; }
154
+ }
155
+ async lstat(p) {
156
+ try { const a = await this._p(cb => this.sftp.lstat(p, cb)); return { isDir: a.isDirectory(), isFile: a.isFile(), isLink: a.isSymbolicLink() }; }
107
157
  catch (e) { if (e.code === 2 || /No such file/i.test(e.message)) return null; throw e; }
108
158
  }
109
159
  async listRecursive(dir, { skipDir } = {}) {
@@ -114,7 +164,7 @@ class SftpRemoteFs {
114
164
  if (e.filename === '.' || e.filename === '..') continue;
115
165
  const r = rel ? rel + '/' + e.filename : e.filename;
116
166
  if (e.attrs.isDirectory()) { if (!(skipDir && skipDir(e.filename))) await walk(abs + '/' + e.filename, r); }
117
- else if (e.attrs.isFile()) out.push({ path: r, size: e.attrs.size, mtime: e.attrs.mtime * 1000 });
167
+ else if (e.attrs.isFile() && !isHidden(r)) out.push({ path: r, size: e.attrs.size, mtime: e.attrs.mtime * 1000 });
118
168
  }
119
169
  };
120
170
  await walk(dir.replace(/\/$/, ''), '');
@@ -137,25 +187,79 @@ class SftpRemoteFs {
137
187
  }
138
188
  }
139
189
  readFile(p) {
140
- return new Promise((resolve, reject) => {
190
+ return this._guard(new Promise((resolve, reject) => {
141
191
  const chunks = [];
142
192
  this.sftp.createReadStream(p).on('data', c => chunks.push(c)).on('end', () => resolve(Buffer.concat(chunks))).on('error', reject);
143
- });
193
+ }));
144
194
  }
145
195
  writeFile(p, data) {
146
- return new Promise((resolve, reject) => {
196
+ return this._guard(new Promise((resolve, reject) => {
147
197
  const ws = this.sftp.createWriteStream(p);
148
198
  ws.on('error', reject).on('close', resolve);
149
199
  ws.end(Buffer.from(data));
150
- });
200
+ }));
151
201
  }
152
202
  uploadFile(localPath, p) {
153
203
  return this._p(cb => this.sftp.fastPut(localPath, p, { concurrency: 4, chunkSize: 32768 }, cb));
154
204
  }
155
205
  rename(a, b) { return this._p(cb => this.sftp.rename(a, b, cb)); }
206
+ /** Create p only if nothing is there yet (O_EXCL): true if created, false if it already exists. */
207
+ async createExclusive(p, data) {
208
+ let handle;
209
+ try { handle = await this._p(cb => this.sftp.open(p, 'wx', cb)); }
210
+ catch (e) { if (await this.stat(p)) return false; throw e; }
211
+ try { const buf = Buffer.from(data); await this._p(cb => this.sftp.write(handle, buf, 0, buf.length, 0, cb)); }
212
+ finally { await this._p(cb => this.sftp.close(handle, cb)).catch(() => {}); }
213
+ return true;
214
+ }
215
+ /** Swap a fully written part file in over dest, keeping dest's permissions (suEXEC hosts 500 on 0666 PHP). */
216
+ async replace(tmp, dest) {
217
+ const st = await this.stat(dest);
218
+ if (st && st.mode !== undefined) await this._p(cb => this.sftp.chmod(tmp, st.mode & 0o7777, cb));
219
+ // OpenSSH (cPanel, Plesk, DirectAdmin, any VPS) has had posix-rename since 2008: one atomic step
220
+ try { return await this._p(cb => this.sftp.ext_openssh_rename(tmp, dest, cb)); }
221
+ catch (e) { if (!/does not support/i.test(e.message)) throw e; }
222
+ if (!st) return this.rename(tmp, dest);
223
+ try { return await this.rename(tmp, dest); } // some servers replace on a plain rename
224
+ catch { await this.remove(dest); return this.rename(tmp, dest); } // restore.js copes if the line drops between these two
225
+ }
156
226
  remove(p) { return this._p(cb => this.sftp.unlink(p, cb)); }
157
227
  rmdir(p) { return this._p(cb => this.sftp.rmdir(p, cb)); }
158
228
  async close() { try { this.client.end(); } catch { /* ignore */ } }
159
229
  }
160
230
 
161
- module.exports = { MemoryRemoteFs, SftpRemoteFs };
231
+ /**
232
+ * Why writing or removing <root>/<rel> would not stay inside the site, or null if it's safe: every folder between
233
+ * the root and the file must be a real folder (a symlinked one would carry the write somewhere else), and the file
234
+ * itself a plain file or not there yet. The root itself may be a symlink (cPanel's www → public_html is normal).
235
+ * folders: a Map shared across one run, so each folder is looked at once.
236
+ */
237
+ async function outsideReason(remoteFs, root, rel, folders = new Map()) {
238
+ const segs = rel.split('/');
239
+ let cur = root.replace(/\/$/, '');
240
+ for (let i = 0; i < segs.length; i++) {
241
+ cur += '/' + segs[i];
242
+ const shown = segs.slice(0, i + 1).join('/');
243
+ const last = i === segs.length - 1;
244
+ if (!last && folders.has(cur)) { const why = folders.get(cur); if (why === 'missing') return null; if (why) return why; continue; }
245
+ const st = await remoteFs.lstat(cur);
246
+ if (!st) { if (!last) folders.set(cur, 'missing'); return null; } // nothing there yet: what gets made is a real folder
247
+ let why = null;
248
+ if (st.isLink) why = `${shown} is a symlink on the server, so writing there could reach outside the site`;
249
+ else if (!last && !st.isDir) why = `${shown} is not a folder on the server`;
250
+ else if (last && !st.isFile && !st.isDir) why = `${shown} is a special file on the server (not a plain file)`;
251
+ if (!last) folders.set(cur, why);
252
+ if (why) return why;
253
+ }
254
+ return null;
255
+ }
256
+
257
+ /** Write a live file the safe way: to its part file first, then swapped in whole. Give { data } or { localPath }. */
258
+ async function writeLive(remoteFs, dest, { data, localPath }) {
259
+ const part = partPath(dest);
260
+ if (localPath !== undefined) await remoteFs.uploadFile(localPath, part);
261
+ else await remoteFs.writeFile(part, data);
262
+ await remoteFs.replace(part, dest);
263
+ }
264
+
265
+ module.exports = { PART_PREFIX, LOCK_NAME, partPath, isPart, isHidden, writeLive, outsideReason, MemoryRemoteFs, SftpRemoteFs };
package/src/restore.js CHANGED
@@ -5,6 +5,9 @@
5
5
  // .backup.json saying which files it added and what it uploaded (sha256). Restore:
6
6
  // · checks first that each live file is still what that run left there (or is already the old copy).
7
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
8
11
  // · backs up everything it is about to change into a new _backup-<stamp>/ with its own .backup.json,
9
12
  // so a restore can itself be restored
10
13
  // · writes each backed-up file back to its live path and removes the files that run added
@@ -20,6 +23,8 @@ const posix = require('node:path').posix;
20
23
  const { BACKUP_PREFIX, MANIFEST_NAME } = require('./diff');
21
24
  const { backupFiles, makeStamp, freeBackupDir, writeBackupInfo, readBackupInfo, BACKUP_INFO } = require('./backup');
22
25
  const { listBackups } = require('./prune');
26
+ const { partPath, writeLive, outsideReason } = require('./remote-fs');
27
+ const { withLock } = require('./lock');
23
28
 
24
29
  const sha256 = buf => crypto.createHash('sha256').update(buf).digest('hex');
25
30
 
@@ -39,37 +44,73 @@ async function describeBackups({ remoteRoot, remoteFs }) {
39
44
  const info = await readBackupInfo(remoteFs, `${root}/${name}`);
40
45
  const files = (await remoteFs.listRecursive(`${root}/${name}`)).filter(f => f.path !== BACKUP_INFO).length;
41
46
  out.push(info
42
- ? { 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 }
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 }
43
48
  : { name, files, legacy: true });
44
49
  }
45
50
  return out;
46
51
  }
47
52
 
48
- async function runRestore({ remoteRoot, remoteFs, name, dryRun = false, force = false, now = new Date(), onEvent = () => {} }) {
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 = () => {} }) {
49
59
  const root = remoteRoot.replace(/\/$/, '');
50
60
  onEvent({ type: 'scan', side: 'remote', dir: root });
51
- const target = chooseBackup(await listBackups(remoteFs, root), name);
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
+ }
52
70
  const dir = `${root}/${target}`;
53
- const info = await readBackupInfo(remoteFs, dir);
54
- const summary = { dryRun, backup: target, action: info ? info.action : undefined, legacy: !info, put: [], remove: [], same: [], conflicts: [], refused: false, undoBackupDir: null };
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
+ }
55
79
 
56
80
  const left = new Map(); // path → sha256 the run left there (overwritten + added)
57
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)
58
83
  if (info) {
59
84
  for (const e of [...info.overwritten, ...info.added]) left.set(e.path, e.sha256);
60
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);
61
87
  }
62
88
  const live = async p => {
63
89
  const st = await remoteFs.stat(`${root}/${p}`);
64
- if (!st) return null;
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
+ }
65
96
  return st.isDir ? 'dir' : sha256(await remoteFs.readFile(`${root}/${p}`));
66
97
  };
67
98
  const conflict = (p, why) => summary.conflicts.push({ path: p, why });
68
99
 
100
+ // never through a symlink or over a special file, even forced: that could reach outside the site
101
+ const folders = new Map(), unsafe = new Set();
102
+ const outside = async p => {
103
+ const why = await outsideReason(remoteFs, root, p, folders);
104
+ if (why) { unsafe.add(p); conflict(p, why); }
105
+ return !!why;
106
+ };
107
+
69
108
  // 1. what goes back: every file in the backup folder
70
109
  const puts = [];
71
110
  for (const f of (await remoteFs.listRecursive(dir)).filter(f => f.path !== BACKUP_INFO)) {
111
+ if (await outside(f.path)) continue;
72
112
  const data = await remoteFs.readFile(`${dir}/${f.path}`);
113
+ 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
73
114
  const now_ = await live(f.path);
74
115
  if (now_ === sha256(data)) { summary.same.push(f.path); continue; }
75
116
  if (now_ === 'dir') { conflict(f.path, 'is a folder on the server now'); continue; } // can't write a file over it, even forced
@@ -80,6 +121,7 @@ async function runRestore({ remoteRoot, remoteFs, name, dryRun = false, force =
80
121
  // 2. what comes off: the files that run added
81
122
  const removes = [];
82
123
  if (info) for (const e of info.added) {
124
+ if (await outside(e.path)) continue;
83
125
  const now_ = await live(e.path);
84
126
  if (now_ === null) { summary.same.push(e.path); continue; }
85
127
  if (now_ === 'dir') { conflict(e.path, 'is a folder on the server now'); continue; }
@@ -97,45 +139,66 @@ async function runRestore({ remoteRoot, remoteFs, name, dryRun = false, force =
97
139
  onEvent({ type: 'done', summary });
98
140
  return summary;
99
141
  }
100
- if (dryRun || (!puts.length && !removes.length)) { onEvent({ type: 'done', summary }); return summary; }
101
-
102
- // 3. back up what is live now, so this restore can be undone too
103
- const undoDir = await freeBackupDir(remoteFs, root, makeStamp(now));
104
- const res = await backupFiles({ remoteFs, root, paths: [...summary.put, ...summary.remove], backupDir: undoDir, onEvent });
105
- const copied = new Set(res.copied);
106
- await writeBackupInfo({ remoteFs, backupDir: undoDir, info: {
107
- action: 'restore', from: target, at: now.toISOString(),
108
- overwritten: puts.filter(p => copied.has(p.path)).map(p => ({ path: p.path, sha256: sha256(p.data) })),
109
- added: puts.filter(p => !copied.has(p.path)).map(p => ({ path: p.path, sha256: sha256(p.data) })),
110
- deleted: removes.filter(p => copied.has(p)).map(p => ({ path: p })),
111
- } });
112
- summary.undoBackupDir = undoDir;
142
+ // part files a stopped run left behind (hidden from listings, but they hold the run's bytes)
143
+ const sweepParts = async () => {
144
+ for (const p of new Set([...left.keys(), ...deleted])) {
145
+ if (unsafe.has(p)) continue;
146
+ const part = partPath(`${root}/${p}`);
147
+ if (await remoteFs.stat(part)) await remoteFs.remove(part).catch(() => {});
148
+ }
149
+ };
150
+ if (dryRun) { onEvent({ type: 'done', summary }); return summary; }
151
+ if (!puts.length && !removes.length) { await sweepParts(); onEvent({ type: 'done', summary }); return summary; }
152
+ try {
153
+ // 3. back up what is live now, so this restore can be undone too. Its record says 'saving', then 'running'
154
+ // while files are written, then nothing: cut off part way, running restore again finishes the job.
155
+ const undoDir = await freeBackupDir(remoteFs, root, makeStamp(now));
156
+ summary.undoBackupDir = undoDir;
157
+ const head = { action: 'restore', from: target, at: now.toISOString() };
158
+ await writeBackupInfo({ remoteFs, backupDir: undoDir, info: { ...head, status: 'saving', overwritten: [], added: [], deleted: [] } });
159
+ const res = await backupFiles({ remoteFs, root, paths: [...summary.put, ...summary.remove], backupDir: undoDir, onEvent });
160
+ const copied = new Set(res.copied);
161
+ const record = {
162
+ ...head,
163
+ overwritten: puts.filter(p => copied.has(p.path)).map(p => ({ path: p.path, sha256: sha256(p.data), was: res.was.get(p.path) })),
164
+ added: puts.filter(p => !copied.has(p.path)).map(p => ({ path: p.path, sha256: sha256(p.data) })),
165
+ deleted: removes.filter(p => copied.has(p)).map(p => ({ path: p, was: res.was.get(p) })),
166
+ };
167
+ await writeBackupInfo({ remoteFs, backupDir: undoDir, info: { ...record, status: 'running' } });
113
168
 
114
- // 4. put the old copies back, take the added files off
115
- const madeDirs = new Set();
116
- for (const p of puts) {
117
- const dest = `${root}/${p.path}`;
118
- const d = posix.dirname(dest);
119
- if (!madeDirs.has(d)) { await remoteFs.mkdirp(d); madeDirs.add(d); }
120
- await remoteFs.writeFile(dest, p.data);
121
- onEvent({ type: 'restore', path: p.path, bytes: p.data.length });
122
- }
123
- for (const p of removes) { await remoteFs.remove(`${root}/${p}`); onEvent({ type: 'delete', path: p }); }
124
- // folders the deploy made just for added files go too, once empty
125
- for (const d of [...new Set(removes.map(p => posix.dirname(p)).filter(d => d !== '.'))].sort((a, b) => b.length - a.length)) {
126
- for (let cur = d; cur !== '.'; cur = posix.dirname(cur)) {
127
- const abs = `${root}/${cur}`;
128
- if (!(await remoteFs.stat(abs)) || (await remoteFs.listDir(abs)).length) break;
129
- await remoteFs.rmdir(abs);
169
+ // 4. put the old copies back, take the added files off
170
+ const madeDirs = new Set();
171
+ for (const p of puts) {
172
+ const dest = `${root}/${p.path}`;
173
+ const d = posix.dirname(dest);
174
+ if (!madeDirs.has(d)) { await remoteFs.mkdirp(d); madeDirs.add(d); }
175
+ await writeLive(remoteFs, dest, { data: p.data });
176
+ onEvent({ type: 'restore', path: p.path, bytes: p.data.length });
177
+ }
178
+ for (const p of removes) { await remoteFs.remove(`${root}/${p}`); onEvent({ type: 'delete', path: p }); }
179
+ // folders the deploy made just for added files go too, once empty
180
+ for (const d of [...new Set(removes.map(p => posix.dirname(p)).filter(d => d !== '.'))].sort((a, b) => b.length - a.length)) {
181
+ for (let cur = d; cur !== '.'; cur = posix.dirname(cur)) {
182
+ const abs = `${root}/${cur}`;
183
+ if (!(await remoteFs.stat(abs)) || (await remoteFs.listDir(abs)).length) break;
184
+ await remoteFs.rmdir(abs);
185
+ }
130
186
  }
131
- }
132
187
 
133
- // 5. the manifest no longer describes these paths
134
- try {
135
- const m = JSON.parse((await remoteFs.readFile(`${root}/${MANIFEST_NAME}`)).toString('utf8'));
136
- for (const p of [...summary.put, ...summary.remove]) delete m.files[p];
137
- await remoteFs.writeFile(`${root}/${MANIFEST_NAME}`, JSON.stringify(m, null, 1));
138
- } catch { /* no manifest: nothing to correct */ }
188
+ await sweepParts();
189
+
190
+ // 5. the manifest no longer describes these paths
191
+ try {
192
+ const m = JSON.parse((await remoteFs.readFile(`${root}/${MANIFEST_NAME}`)).toString('utf8'));
193
+ for (const p of [...summary.put, ...summary.remove]) delete m.files[p];
194
+ await remoteFs.writeFile(`${root}/${MANIFEST_NAME}`, JSON.stringify(m, null, 1));
195
+ } catch { /* no manifest: nothing to correct */ }
196
+ await writeBackupInfo({ remoteFs, backupDir: undoDir, info: record });
197
+ } catch (err) {
198
+ err.undo = 'The restore stopped part way. Run the same restore again: it finishes the job.';
199
+ onEvent({ type: 'error', message: err.message, undo: err.undo, summary });
200
+ throw err;
201
+ }
139
202
 
140
203
  onEvent({ type: 'done', summary });
141
204
  return summary;
package/src/server.js CHANGED
@@ -68,6 +68,15 @@ async function createApp(opts = {}) {
68
68
  for (const l of job.listeners) l(ev);
69
69
  if (ev.type === 'done' || ev.type === 'error') job.done = true;
70
70
  }
71
+ // Runs a UI job, holding back its last event ('done' / 'error') until the history line is written:
72
+ // the UI reloads history on 'done', so the run has to be on file by then.
73
+ function track(job, run, rec) {
74
+ let last = null;
75
+ const onEvent = ev => { if (ev.type === 'done' || ev.type === 'error') last = ev; else emit(job, ev); };
76
+ return run(onEvent)
77
+ .then(summary => rec(summary), err => rec(last && last.summary, err))
78
+ .finally(() => { if (last) emit(job, last); });
79
+ }
71
80
 
72
81
  async function getSite(name) {
73
82
  const { sites } = await loadSites(sitesFile);
@@ -125,9 +134,9 @@ async function createApp(opts = {}) {
125
134
  const job = newJob('deploy');
126
135
  const args = { localDir: body.localDir || site.localDir, remoteRoot: body.remoteDir || site.remoteRoot };
127
136
  const startedAt = new Date().toISOString();
128
- runDeploy({ ...args, remoteFs: rfs, dryRun: !!body.dryRun, backup: body.backup !== false, mirror: !!body.mirror, ignore: site.ignore, now: now(), onEvent: ev => emit(job, ev) })
129
- .then(summary => { if (!summary.dryRun) record({ action: 'deploy', site: site.name, trigger: 'ui', localDir: args.localDir, remoteDir: args.remoteRoot, startedAt, summary }); },
130
- err => record({ action: 'deploy', site: site.name, trigger: 'ui', localDir: args.localDir, remoteDir: args.remoteRoot, startedAt, summary: job.events.find(e => e.type === 'error')?.summary, error: err.message }))
137
+ track(job, onEvent => runDeploy({ ...args, remoteFs: rfs, dryRun: !!body.dryRun, backup: body.backup !== false, mirror: !!body.mirror, ignore: site.ignore, now: now(), onEvent }),
138
+ (summary, err) => err ? record({ action: 'deploy', site: site.name, trigger: 'ui', localDir: args.localDir, remoteDir: args.remoteRoot, startedAt, summary, error: err.message })
139
+ : !summary.dryRun && record({ action: 'deploy', site: site.name, trigger: 'ui', localDir: args.localDir, remoteDir: args.remoteRoot, startedAt, summary }))
131
140
  .finally(() => rfs.close());
132
141
  return { jobId: job.id };
133
142
  },
@@ -139,9 +148,9 @@ async function createApp(opts = {}) {
139
148
  const args = { localDir: body.localDir || site.localDir, remoteRoot: body.remoteDir || site.remoteRoot };
140
149
  if (!args.localDir) throw new HttpError(400, 'No local folder given');
141
150
  const startedAt = new Date().toISOString();
142
- runPull({ ...args, remoteFs: rfs, dryRun: !!body.dryRun, backup: body.backup !== false, mirror: !!body.mirror, ignore: site.ignore, now: now(), onEvent: ev => emit(job, ev) })
143
- .then(summary => { if (!summary.dryRun) record({ action: 'pull', site: site.name, trigger: 'ui', localDir: args.localDir, remoteDir: args.remoteRoot, startedAt, summary }); },
144
- err => record({ action: 'pull', site: site.name, trigger: 'ui', localDir: args.localDir, remoteDir: args.remoteRoot, startedAt, error: err.message }))
151
+ track(job, onEvent => runPull({ ...args, remoteFs: rfs, dryRun: !!body.dryRun, backup: body.backup !== false, mirror: !!body.mirror, ignore: site.ignore, now: now(), onEvent }),
152
+ (summary, err) => err ? record({ action: 'pull', site: site.name, trigger: 'ui', localDir: args.localDir, remoteDir: args.remoteRoot, startedAt, error: err.message })
153
+ : !summary.dryRun && record({ action: 'pull', site: site.name, trigger: 'ui', localDir: args.localDir, remoteDir: args.remoteRoot, startedAt, summary }))
145
154
  .finally(() => rfs.close());
146
155
  return { jobId: job.id };
147
156
  },
@@ -165,9 +174,8 @@ async function createApp(opts = {}) {
165
174
  const rfs = await connectSite(site);
166
175
  const job = newJob('upload');
167
176
  const startedAt = new Date().toISOString();
168
- uploadFiles({ files, remoteDir, remoteFs: rfs, backup: form.get('backup') !== 'false', now: now(), onEvent: ev => emit(job, ev) })
169
- .then(summary => record({ action: 'upload', site: site.name, trigger: 'ui', remoteDir, startedAt, summary }),
170
- err => record({ action: 'upload', site: site.name, trigger: 'ui', remoteDir, startedAt, error: err.message }))
177
+ track(job, onEvent => uploadFiles({ files, remoteDir, remoteFs: rfs, backup: form.get('backup') !== 'false', now: now(), onEvent }),
178
+ (summary, err) => record({ action: 'upload', site: site.name, trigger: 'ui', remoteDir, startedAt, ...(err ? { error: err.message } : { summary }) }))
171
179
  .finally(async () => { await rfs.close(); await fs.rm(tmp, { recursive: true, force: true }); });
172
180
  return { jobId: job.id, files: files.map(f => f.name) };
173
181
  },