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 +60 -30
- package/UNDO-PROMISE.md +52 -0
- package/bin/cli.js +7 -4
- package/package.json +2 -1
- package/src/backup.js +43 -10
- package/src/deploy.js +26 -10
- package/src/lock.js +92 -0
- package/src/mcp.js +5 -2
- package/src/prune.js +7 -1
- package/src/queue.js +1 -1
- package/src/remote-fs.js +76 -11
- package/src/restore.js +94 -42
- package/src/server.js +17 -9
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
|
|
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
|
-
|
|
37
|
-
cd
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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**,
|
|
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.
|
|
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 --
|
|
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": "
|
|
129
|
-
"args": ["
|
|
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
|
-
|
|
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.
|
|
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
|
|
package/UNDO-PROMISE.md
ADDED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
54
|
-
|
|
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)
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
169
|
-
|
|
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
|
},
|