shipback-sftp 0.1.0 → 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.
package/README.md CHANGED
@@ -11,13 +11,21 @@ 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.
21
29
  - **Safe for agents.** The MCP server has no switch to delete server files or skip the backup. A deploy
22
30
  only runs the exact plan the agent previewed: if a file changed in between, it refuses.
23
31
  - **Spots the drift.** `--pull --dry-run` shows what changed on the server since your last deploy, so
@@ -28,22 +36,24 @@ and Linux with Node 18, 20 and 22 on every push. Passwords are kept in the OS ke
28
36
  pass through the model.
29
37
 
30
38
  **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.
39
+ since September 2026 (2,907 files to a cPanel host). Restore and the MCP server are new since 0.1, and the
40
+ cut-off handling behind the promise is new in 0.2.
32
41
 
33
42
  ## Quick start
34
43
 
35
44
  ```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
45
+ npm install -g shipback-sftp # installs the shipback and shipback-mcp commands
46
+ cd ~/sites # any folder with a sites.json in it (format below)
47
+ shipback mysite --dry-run # see the plan; asks for the password once and stores it
48
+ shipback mysite # deploy
49
+ shipback mysite --restore # changed your mind? undo it
43
50
  ```
44
51
 
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`.
52
+ No global install? `npx -p shipback-sftp shipback mysite --dry-run` works the same way. `--sites <file>`
53
+ points at a `sites.json` in another folder.
54
+
55
+ From source: `git clone https://github.com/aldohushi1-stack/shipback-sftp.git`, `npm install`, then
56
+ `node bin/cli.js` wherever these docs say `shipback`.
47
57
 
48
58
  `sites.json`, one entry per site:
49
59
 
@@ -70,19 +80,20 @@ first. Use `"privateKeyPath"` instead of a password if your host has your key.
70
80
  ## Commands
71
81
 
72
82
  ```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
83
+ shipback <site> --dry-run # what a deploy would upload
84
+ shipback <site> # deploy: backup, upload, record
85
+ shipback <site> --local <folder> # deploy a different folder than localDir
86
+ shipback <site> --pull --dry-run # what changed on the server since the last deploy?
87
+ shipback <site> --pull # bring those changes down (local copies backed up first)
88
+ shipback <site> --backups # list backups: what each run overwrote, added, deleted
89
+ shipback <site> --restore --dry-run # what an undo of the latest deploy would do
90
+ shipback <site> --restore [<backup>] # undo it (or name an older _backup-… folder)
91
+ shipback <site> --prune --keep 10 # tidy old backups; refuses any holding the only copy of a file
92
+ shipback --history [site] # what went up, when, from where
83
93
  ```
84
94
 
85
- Add `--json` to any command for machine-readable output. A refused restore exits with code 4.
95
+ Add `--json` to any command for machine-readable output. A refused restore exits with code 4, and a run turned away
96
+ because another one is writing to the site exits with code 5.
86
97
 
87
98
  `--mirror` (delete server files that aren't in the local folder) and `--no-backup` exist for humans who
88
99
  know they want them. The agent tools don't offer them, and the [guard hook](#claude-code-guard-hook)
@@ -93,7 +104,17 @@ blocks Claude from typing them.
93
104
  Each deploy writes `_backup-YYYY-MM-DD-HHMM/` in the remote root. It holds:
94
105
 
95
106
  - 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
107
+ - `.backup.json`, a record of what the deploy **added**, a SHA-256 of every file it left on the server, and a
108
+ SHA-256 of every copy it saved, so a damaged copy is never put back
109
+
110
+ The record says `saving` until every copy is made. A deploy cut off before then hasn't changed anything, and
111
+ `--restore` says so. New files go up as `.shipback-part-<name>` beside the live file and are renamed over it in one
112
+ step, keeping its permissions. Listings hide part files, and the next deploy or restore clears any that are left.
113
+
114
+ While a deploy, upload, restore or prune writes, it holds `.shipback-lock` in the remote root and refreshes it every
115
+ 30 seconds. The lock is never deployed, pulled or listed. A lock goes stale, and the next run takes it over, when its
116
+ process on this computer has ended or nothing has refreshed it for 2 minutes. So a run that was cut off never leaves
117
+ a site locked for long.
97
118
 
98
119
  `--restore` reads that record and checks each live file first:
99
120
 
@@ -104,19 +125,20 @@ Each deploy writes `_backup-YYYY-MM-DD-HHMM/` in the remote root. It holds:
104
125
  | different from both | **conflict**: stops, names the file, changes nothing (`--force` overrides) |
105
126
 
106
127
  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
128
+ record, so `--restore` on that folder takes you forward again. If a restore is cut off, plain `--restore` sees that
129
+ the latest run is an unfinished restore and finishes it. It also clears the restored paths from
108
130
  `.deploy-manifest.json`, so the next deploy compares them by content instead of trusting stale hashes.
109
131
 
110
132
  Backup folders are skipped by deploys and pulls, and are never deleted except by `--prune`.
111
133
 
112
134
  ## Use it from an AI agent (MCP)
113
135
 
114
- `bin/mcp.js` is an MCP server over stdio.
136
+ `shipback-mcp` (`bin/mcp.js`) is an MCP server over stdio.
115
137
 
116
138
  **Claude Code**
117
139
 
118
140
  ```bash
119
- claude mcp add shipback-sftp -- node /path/to/shipback-sftp/bin/mcp.js --sites /path/to/sites.json
141
+ claude mcp add shipback-sftp -- npx -y -p shipback-sftp shipback-mcp --sites /path/to/sites.json
120
142
  ```
121
143
 
122
144
  **Cursor, Claude Desktop, and others** (`mcp.json` / `claude_desktop_config.json`):
@@ -125,14 +147,18 @@ claude mcp add shipback-sftp -- node /path/to/shipback-sftp/bin/mcp.js --sites /
125
147
  {
126
148
  "mcpServers": {
127
149
  "shipback-sftp": {
128
- "command": "node",
129
- "args": ["/path/to/shipback-sftp/bin/mcp.js", "--sites", "/path/to/sites.json"]
150
+ "command": "npx",
151
+ "args": ["-y", "-p", "shipback-sftp", "shipback-mcp", "--sites", "/path/to/sites.json"]
130
152
  }
131
153
  }
132
154
  }
133
155
  ```
134
156
 
135
- Run any CLI command for a site once first (for example `node bin/cli.js mysite --backups`). That stores
157
+ On Windows, if the client can't start `npx`, use `"command": "cmd"` with `"/c", "npx"` at the front of
158
+ `args`. After a global install, `"command": "shipback-mcp"` works too, and from a clone it's `node` with
159
+ `bin/mcp.js` as the first arg.
160
+
161
+ Run any CLI command for a site once first (for example `shipback mysite --backups`). That stores
136
162
  the password in the OS keychain, where the MCP server reads it. The model never sees it. For CI you can set
137
163
  `SFTP_PASSWORD` in the server's environment instead.
138
164
 
@@ -172,7 +198,8 @@ one-off uploads, browse the server, and see history. It only answers requests fr
172
198
 
173
199
  If Claude also runs the CLI directly, add `bin/claude-guard.js` as a PreToolUse hook. It blocks
174
200
  `--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.
201
+ still run them yourself in a terminal. After `npm install -g shipback-sftp`, the file is in the folder
202
+ `npm root -g` prints, under `shipback-sftp/bin/`.
176
203
 
177
204
  ```json
178
205
  {
@@ -193,7 +220,8 @@ still run them yourself in a terminal.
193
220
  npm test
194
221
  ```
195
222
 
196
- The suite uses `node:test` and needs no network. Deploy, restore, the CLI and the MCP server are each
223
+ The suite uses `node:test` and needs no network. `test/promise.test.js` holds one test per line of the
224
+ [undo promise](UNDO-PROMISE.md). Deploy, restore, the CLI and the MCP server are each
197
225
  also exercised end to end against a real SFTP server started inside the test run.
198
226
 
199
227
  ## Limits
@@ -204,6 +232,8 @@ also exercised end to end against a real SFTP server started inside the test run
204
232
  hosting account. Keep your host's own backups too.
205
233
  - Files over 8 MB with the same size on both sides are assumed unchanged when there is no manifest hash
206
234
  to compare.
235
+ - What undo doesn't cover (databases, `--no-backup` deploys, pruned backups and so on) is listed in
236
+ [the undo promise](UNDO-PROMISE.md#what-it-doesnt-cover).
207
237
 
208
238
  ## License
209
239
 
@@ -0,0 +1,52 @@
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. 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.
45
+ - **Backups made before 0.2.** Those still restore, but they don't carry the checksums behind P4 and P8.
46
+ - **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.
47
+ - **Losing the hosting account.** Backups sit on the same server as the site. Keep your host's own backups too.
48
+
49
+ 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
50
+ MIT licence's "no warranty" still stands.
51
+
52
+ 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.0",
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 } = require('./remote-fs');
9
+ const { withLock } = require('./lock');
8
10
 
9
11
  const isBackupDir = name => name.startsWith(BACKUP_PREFIX);
10
12
 
@@ -34,7 +36,12 @@ async function previewDeploy({ localDir, remoteRoot, remoteFs, mirror = false, i
34
36
  * runDeploy({ localDir, remoteRoot, remoteFs, dryRun, backup, mirror, ignore, now, onEvent })
35
37
  * → { dryRun, plan, uploaded, backedUp, backupDir, deleted, bytes }
36
38
  */
37
- async function runDeploy({ localDir, remoteRoot, remoteFs, dryRun = false, backup = true, mirror = false, ignore, now = new Date(), onEvent = () => {} }) {
39
+ async function runDeploy(opts) {
40
+ if (opts.dryRun) return deployNow(opts);
41
+ return withLock({ remoteFs: opts.remoteFs, root: opts.remoteRoot, action: 'deploy', createRoot: true, onEvent: opts.onEvent }, () => deployNow(opts));
42
+ }
43
+
44
+ async function deployNow({ localDir, remoteRoot, remoteFs, dryRun = false, backup = true, mirror = false, ignore, now = new Date(), onEvent = () => {} }) {
38
45
  const root = remoteRoot.replace(/\/$/, '');
39
46
  const summary = { dryRun, plan: null, uploaded: [], backedUp: [], backupDir: null, deleted: [], bytes: 0 };
40
47
  try {
@@ -44,30 +51,32 @@ async function runDeploy({ localDir, remoteRoot, remoteFs, dryRun = false, backu
44
51
 
45
52
  if (!remoteExists) { await remoteFs.mkdirp(root); onEvent({ type: 'mkdir', path: root }); }
46
53
 
47
- // 1. backup everything about to be overwritten or deleted, plus .backup.json so restore can undo this run
54
+ // 1. backup everything about to be overwritten or deleted, plus .backup.json so restore can undo this run.
55
+ // The record says 'saving' until every copy is made: cut off before that, the run has changed nothing.
48
56
  if (backup && (plan.upload.length || plan.delete.length)) {
49
57
  const backupDir = await freeBackupDir(remoteFs, root, makeStamp(now));
58
+ await writeBackupInfo({ remoteFs, backupDir, info: { action: 'deploy', at: now.toISOString(), status: 'saving', overwritten: [], added: [], deleted: [] } });
50
59
  const targets = [...plan.upload.filter(u => u.reason === 'changed').map(u => u.path), ...plan.delete];
51
60
  const res = await backupFiles({ remoteFs, root, paths: targets, backupDir, onEvent });
52
61
  const copied = new Set(res.copied);
53
62
  const sha = new Map(local.map(f => [f.path, f.sha256]));
54
63
  await writeBackupInfo({ remoteFs, backupDir, info: {
55
64
  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) })),
65
+ 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
66
  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 })),
67
+ deleted: plan.delete.filter(p => copied.has(p)).map(p => ({ path: p, was: res.was.get(p) })),
59
68
  } });
60
69
  summary.backedUp = res.copied;
61
70
  summary.backupDir = backupDir;
62
71
  }
63
72
 
64
- // 2. upload new + changed
73
+ // 2. upload new + changed, each swapped in whole (a dropped line leaves the old file, not half a new one)
65
74
  const madeDirs = new Set([root]);
66
75
  for (const u of plan.upload) {
67
76
  const dest = `${root}/${u.path}`;
68
77
  const dir = posix.dirname(dest);
69
78
  if (!madeDirs.has(dir)) { await remoteFs.mkdirp(dir); madeDirs.add(dir); }
70
- await remoteFs.uploadFile(path.join(localDir, ...u.path.split('/')), dest);
79
+ await writeLive(remoteFs, dest, { localPath: path.join(localDir, ...u.path.split('/')) });
71
80
  summary.uploaded.push(u.path); summary.bytes += u.size;
72
81
  onEvent({ type: 'upload', path: u.path, bytes: u.size, reason: u.reason, done: summary.uploaded.length, total: plan.upload.length });
73
82
  }
@@ -87,13 +96,19 @@ async function runDeploy({ localDir, remoteRoot, remoteFs, dryRun = false, backu
87
96
  onEvent({ type: 'done', summary });
88
97
  return summary;
89
98
  } catch (err) {
90
- onEvent({ type: 'error', message: err.message, summary });
99
+ // a deploy that stopped part way is undone like any other: everything it touched is in its backup record
100
+ 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)}).`;
101
+ onEvent({ type: 'error', message: err.message, undo: err.undo, summary });
91
102
  throw err;
92
103
  }
93
104
  }
94
105
 
95
106
  /** 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 = () => {} }) {
107
+ function uploadFiles(opts) {
108
+ return withLock({ remoteFs: opts.remoteFs, root: opts.remoteDir, action: 'upload', createRoot: true, onEvent: opts.onEvent }, () => uploadNow(opts));
109
+ }
110
+
111
+ async function uploadNow({ files, remoteDir, remoteFs, backup = true, now = new Date(), onEvent = () => {} }) {
97
112
  // files: [{ name, localPath, size }]
98
113
  const root = remoteDir.replace(/\/$/, '');
99
114
  const summary = { uploaded: [], backedUp: [], backupDir: null, bytes: 0 };
@@ -101,20 +116,21 @@ async function uploadFiles({ files, remoteDir, remoteFs, backup = true, now = ne
101
116
  await remoteFs.mkdirp(root);
102
117
  if (backup && files.length) {
103
118
  const backupDir = await freeBackupDir(remoteFs, root, makeStamp(now));
119
+ await writeBackupInfo({ remoteFs, backupDir, info: { action: 'upload', at: now.toISOString(), status: 'saving', overwritten: [], added: [], deleted: [] } });
104
120
  const res = await backupFiles({ remoteFs, root, paths: files.map(f => f.name), backupDir, onEvent });
105
121
  const copied = new Set(res.copied);
106
122
  const shas = new Map();
107
123
  for (const f of files) shas.set(f.name, await sha256File(f.localPath));
108
124
  await writeBackupInfo({ remoteFs, backupDir, info: {
109
125
  action: 'upload', at: now.toISOString(),
110
- overwritten: files.filter(f => copied.has(f.name)).map(f => ({ path: f.name, sha256: shas.get(f.name) })),
126
+ 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
127
  added: files.filter(f => !copied.has(f.name)).map(f => ({ path: f.name, sha256: shas.get(f.name) })),
112
128
  deleted: [],
113
129
  } });
114
130
  summary.backedUp = res.copied; summary.backupDir = backupDir;
115
131
  }
116
132
  for (const f of files) {
117
- await remoteFs.uploadFile(f.localPath, `${root}/${f.name}`);
133
+ await writeLive(remoteFs, `${root}/${f.name}`, { localPath: f.localPath });
118
134
  summary.uploaded.push(f.name); summary.bytes += f.size || 0;
119
135
  onEvent({ type: 'upload', path: f.name, bytes: f.size || 0, reason: 'upload', done: summary.uploaded.length, total: files.length });
120
136
  }
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,25 @@
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
+ // 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.
7
11
  const posix = require('node:path').posix;
8
12
  const fs = require('node:fs');
9
13
  const { Client } = require('ssh2');
10
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
+
11
21
  // ---------------------------------------------------------------- MemoryRemoteFs (tests)
12
22
  class MemoryRemoteFs {
13
- constructor() { this.files = new Map(); this.dirs = new Set(['/']); this.failOn = null; }
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; }
14
25
  _check(p) { if (this.failOn && this.failOn(p)) throw new Error(`Simulated failure on ${p}`); }
15
26
  async stat(p) {
16
27
  p = posix.normalize(p);
@@ -25,6 +36,7 @@ class MemoryRemoteFs {
25
36
  if (!p.startsWith(dir + '/')) continue;
26
37
  const rel = p.slice(dir.length + 1);
27
38
  if (skipDir && rel.split('/').slice(0, -1).some(seg => skipDir(seg))) continue;
39
+ if (isHidden(rel)) continue;
28
40
  out.push({ path: rel, size: f.data.length, mtime: f.mtime });
29
41
  }
30
42
  return out.sort((a, b) => a.path.localeCompare(b.path));
@@ -51,10 +63,16 @@ class MemoryRemoteFs {
51
63
  async writeFile(p, data) {
52
64
  p = posix.normalize(p); this._check(p);
53
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
+ }
54
70
  this.files.set(p, { data: Buffer.from(data), mtime: Date.now() });
55
71
  }
56
72
  async uploadFile(localPath, p) { await this.writeFile(p, await fs.promises.readFile(localPath)); }
57
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; }
58
76
  async remove(p) { p = posix.normalize(p); this._check(p); if (!this.files.delete(p)) throw new Error(`No such file: ${p}`); }
59
77
  async rmdir(p) { this.dirs.delete(posix.normalize(p)); }
60
78
  async close() {}
@@ -67,7 +85,26 @@ const UNREACHABLE_HINT = 'Check the port (many shared hosts run SFTP on their ow
67
85
  + 'current IP in (some make you allow-list it under SSH Access, and home IPs change).';
68
86
 
69
87
  class SftpRemoteFs {
70
- constructor(client, sftp) { this.client = client; this.sftp = sftp; }
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
+ }
71
108
 
72
109
  /** connect({ host, port, username, password | privateKey, readyTimeout }) */
73
110
  static connect(opts) {
@@ -100,10 +137,10 @@ class SftpRemoteFs {
100
137
  });
101
138
  }
102
139
 
103
- _p(fn) { return new Promise((res, rej) => fn((err, val) => err ? rej(err) : res(val))); }
140
+ _p(fn) { return this._guard(new Promise((res, rej) => fn((err, val) => err ? rej(err) : res(val)))); }
104
141
 
105
142
  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() }; }
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 }; }
107
144
  catch (e) { if (e.code === 2 || /No such file/i.test(e.message)) return null; throw e; }
108
145
  }
109
146
  async listRecursive(dir, { skipDir } = {}) {
@@ -114,7 +151,7 @@ class SftpRemoteFs {
114
151
  if (e.filename === '.' || e.filename === '..') continue;
115
152
  const r = rel ? rel + '/' + e.filename : e.filename;
116
153
  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 });
154
+ else if (e.attrs.isFile() && !isHidden(r)) out.push({ path: r, size: e.attrs.size, mtime: e.attrs.mtime * 1000 });
118
155
  }
119
156
  };
120
157
  await walk(dir.replace(/\/$/, ''), '');
@@ -137,25 +174,53 @@ class SftpRemoteFs {
137
174
  }
138
175
  }
139
176
  readFile(p) {
140
- return new Promise((resolve, reject) => {
177
+ return this._guard(new Promise((resolve, reject) => {
141
178
  const chunks = [];
142
179
  this.sftp.createReadStream(p).on('data', c => chunks.push(c)).on('end', () => resolve(Buffer.concat(chunks))).on('error', reject);
143
- });
180
+ }));
144
181
  }
145
182
  writeFile(p, data) {
146
- return new Promise((resolve, reject) => {
183
+ return this._guard(new Promise((resolve, reject) => {
147
184
  const ws = this.sftp.createWriteStream(p);
148
185
  ws.on('error', reject).on('close', resolve);
149
186
  ws.end(Buffer.from(data));
150
- });
187
+ }));
151
188
  }
152
189
  uploadFile(localPath, p) {
153
190
  return this._p(cb => this.sftp.fastPut(localPath, p, { concurrency: 4, chunkSize: 32768 }, cb));
154
191
  }
155
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
+ }
156
213
  remove(p) { return this._p(cb => this.sftp.unlink(p, cb)); }
157
214
  rmdir(p) { return this._p(cb => this.sftp.rmdir(p, cb)); }
158
215
  async close() { try { this.client.end(); } catch { /* ignore */ } }
159
216
  }
160
217
 
161
- module.exports = { MemoryRemoteFs, SftpRemoteFs };
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 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 } = 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,29 +44,55 @@ 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 });
@@ -70,6 +101,7 @@ async function runRestore({ remoteRoot, remoteFs, name, dryRun = false, force =
70
101
  const puts = [];
71
102
  for (const f of (await remoteFs.listRecursive(dir)).filter(f => f.path !== BACKUP_INFO)) {
72
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
73
105
  const now_ = await live(f.path);
74
106
  if (now_ === sha256(data)) { summary.same.push(f.path); continue; }
75
107
  if (now_ === 'dir') { conflict(f.path, 'is a folder on the server now'); continue; } // can't write a file over it, even forced
@@ -97,45 +129,65 @@ async function runRestore({ remoteRoot, remoteFs, name, dryRun = false, force =
97
129
  onEvent({ type: 'done', summary });
98
130
  return summary;
99
131
  }
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;
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' } });
113
157
 
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);
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
+ }
130
175
  }
131
- }
132
176
 
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 */ }
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
+ }
139
191
 
140
192
  onEvent({ type: 'done', summary });
141
193
  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
  },