shipback-sftp 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aldo Hushi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,242 @@
1
- # Temporary Holding Version
1
+ # Shipback SFTP
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![test](https://github.com/aldohushi1-stack/shipback-sftp/actions/workflows/test.yml/badge.svg)](https://github.com/aldohushi1-stack/shipback-sftp/actions/workflows/test.yml)
4
+
5
+ **Let the agent deploy. Keep the undo.**
6
+
7
+ It's late, and the agent says "Deployed ✓". Then the client's contact form is gone. The agent overwrote a
8
+ file someone had fixed by hand in cPanel last week, and shared hosting has no `git revert`.
9
+
10
+ Shipback deploys a folder to any SFTP host: cPanel, Plesk, DirectAdmin, a plain VPS. Every deploy
11
+ can be undone with one command. You can run it yourself, script it, or hand it to Claude Code, Cursor or
12
+ any MCP client.
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
+
18
+ - **Backup before every overwrite.** The server's copy of each file a deploy replaces is saved into
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.
22
+ - **One-command undo.** `--restore` puts those files back and removes the files that deploy added.
23
+ - **It won't undo over a live edit.** If someone changed a file on the server after the deploy, restore
24
+ stops and names the file. Nothing is touched unless you pass `--force`, and even then the edited copy is
25
+ backed up first.
26
+ - **Restores can be undone too.** A restore backs up what it replaces, the same way a deploy does.
27
+ - **One run at a time.** A deploy or restore holds a lock on the site while it writes. A second one is turned away
28
+ with nothing changed, so an agent and a person can't deploy over each other.
29
+ - **Safe for agents.** The MCP server has no switch to delete server files or skip the backup. A deploy
30
+ only runs the exact plan the agent previewed: if a file changed in between, it refuses.
31
+ - **Spots the drift.** `--pull --dry-run` shows what changed on the server since your last deploy, so
32
+ edits made live in cPanel don't get overwritten by accident.
33
+
34
+ No git or SSH shell is needed on the server, just SFTP. It needs Node 18+. The tests run on Windows, macOS
35
+ and Linux with Node 18, 20 and 22 on every push. Passwords are kept in the OS keychain and never
36
+ pass through the model.
37
+
38
+ **It has run in production.** The deploy engine underneath has shipped [aldo.today](https://aldo.today) 235 times
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.
41
+
42
+ ## Quick start
43
+
44
+ ```bash
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
50
+ ```
51
+
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`.
57
+
58
+ `sites.json`, one entry per site:
59
+
60
+ ```json
61
+ {
62
+ "sites": [
63
+ {
64
+ "name": "mysite",
65
+ "host": "server123.example-host.com",
66
+ "port": 22,
67
+ "username": "cpaneluser",
68
+ "remoteRoot": "/home/cpaneluser/public_html",
69
+ "localDir": "/Users/you/sites/mysite",
70
+ "ignore": ["drafts/", "*.psd"]
71
+ }
72
+ ]
73
+ }
74
+ ```
75
+
76
+ Many shared hosts run SFTP on their own port rather than 22 (check *SSH Access* in your control panel),
77
+ and some only accept connections from allow-listed IPs. If a connection times out, check those two
78
+ first. Use `"privateKeyPath"` instead of a password if your host has your key.
79
+
80
+ ## Commands
81
+
82
+ ```bash
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
93
+ ```
94
+
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.
97
+
98
+ `--mirror` (delete server files that aren't in the local folder) and `--no-backup` exist for humans who
99
+ know they want them. The agent tools don't offer them, and the [guard hook](#claude-code-guard-hook)
100
+ blocks Claude from typing them.
101
+
102
+ ## How the undo works
103
+
104
+ Each deploy writes `_backup-YYYY-MM-DD-HHMM/` in the remote root. It holds:
105
+
106
+ - the server's copy of every file the deploy **overwrote** or **deleted**
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.
118
+
119
+ `--restore` reads that record and checks each live file first:
120
+
121
+ | Live file now | Restore does |
122
+ | --- | --- |
123
+ | exactly what the deploy uploaded | puts the old copy back (or removes it, if the deploy added it) |
124
+ | already the old copy | nothing; it counts as already right |
125
+ | different from both | **conflict**: stops, names the file, changes nothing (`--force` overrides) |
126
+
127
+ Before writing anything, restore saves the current live files into a new `_backup-…` folder with its own
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
130
+ `.deploy-manifest.json`, so the next deploy compares them by content instead of trusting stale hashes.
131
+
132
+ Backup folders are skipped by deploys and pulls, and are never deleted except by `--prune`.
133
+
134
+ ## Use it from an AI agent (MCP)
135
+
136
+ `shipback-mcp` (`bin/mcp.js`) is an MCP server over stdio.
137
+
138
+ **Claude Code**
139
+
140
+ ```bash
141
+ claude mcp add shipback-sftp -- npx -y -p shipback-sftp shipback-mcp --sites /path/to/sites.json
142
+ ```
143
+
144
+ **Cursor, Claude Desktop, and others** (`mcp.json` / `claude_desktop_config.json`):
145
+
146
+ ```json
147
+ {
148
+ "mcpServers": {
149
+ "shipback-sftp": {
150
+ "command": "npx",
151
+ "args": ["-y", "-p", "shipback-sftp", "shipback-mcp", "--sites", "/path/to/sites.json"]
152
+ }
153
+ }
154
+ }
155
+ ```
156
+
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
162
+ the password in the OS keychain, where the MCP server reads it. The model never sees it. For CI you can set
163
+ `SFTP_PASSWORD` in the server's environment instead.
164
+
165
+ | Tool | Does |
166
+ | --- | --- |
167
+ | `list_sites` | the configured sites (no passwords) |
168
+ | `preview_deploy` | what would upload; returns a `planId` |
169
+ | `deploy` | uploads that plan; refuses if anything changed since the preview |
170
+ | `check_server_changes` | files edited or added on the live server since the last deploy |
171
+ | `pull` | brings those down into the local folder (local copies backed up) |
172
+ | `list_backups` | backups, newest first, with what each run changed |
173
+ | `preview_restore` | what an undo would put back and remove, plus any conflicts; returns a `planId` |
174
+ | `restore` | runs that undo; refuses on conflicts unless `force` |
175
+ | `history` | recent deploys, pulls and restores |
176
+
177
+ What the tools leave out is deliberate: there is no mirror/delete, no way to skip the backup, and no
178
+ pruning of backups.
179
+
180
+ ## Agents without a shell: the drop-box queue
181
+
182
+ Some agent setups can write files but can't run commands. While the local app is running
183
+ (`npm start`, or `start.bat` on Windows), anything that can write a file can queue a job:
184
+
185
+ ```
186
+ queue/<name>.request.json { "action": "deploy", "site": "mysite", "dryRun": true, "note": "new homepage" }
187
+ queue/<name>.result.json ← written when it finishes, with a .log.txt beside it
188
+ ```
189
+
190
+ Actions: `deploy`, `preview`, `pull`, `restore`, `backups`, `prune`.
191
+
192
+ ## The local app
193
+
194
+ `npm start` opens a small UI on `http://127.0.0.1:4646`: add sites, preview, deploy, pull, drag in
195
+ one-off uploads, browse the server, and see history. It only answers requests from your own machine.
196
+
197
+ ## Claude Code guard hook
198
+
199
+ If Claude also runs the CLI directly, add `bin/claude-guard.js` as a PreToolUse hook. It blocks
200
+ `--mirror` and `--no-backup` in shell commands, queue files and API calls, and tells Claude why. You can
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/`.
203
+
204
+ ```json
205
+ {
206
+ "hooks": {
207
+ "PreToolUse": [
208
+ {
209
+ "matcher": "Bash|PowerShell|Write|Edit|MultiEdit",
210
+ "hooks": [{ "type": "command", "command": "node /path/to/shipback-sftp/bin/claude-guard.js" }]
211
+ }
212
+ ]
213
+ }
214
+ }
215
+ ```
216
+
217
+ ## Tests
218
+
219
+ ```bash
220
+ npm test
221
+ ```
222
+
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
225
+ also exercised end to end against a real SFTP server started inside the test run.
226
+
227
+ ## Limits
228
+
229
+ - It compares by size and SHA-256. The first deploy to a site that already has files reads the server
230
+ copies of any same-size files to compare them.
231
+ - Backups live on the same server as the site. They protect against bad deploys, not against losing the
232
+ hosting account. Keep your host's own backups too.
233
+ - Files over 8 MB with the same size on both sides are assumed unchanged when there is no manifest hash
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).
237
+
238
+ ## License
239
+
240
+ MIT © 2026 Aldo Hushi
241
+
242
+ Runs aldo.today. Made at [BlueprintAU](https://blueprintau.com).
@@ -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
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Claude Code PreToolUse hook: stops Claude deploying with --mirror (deletes server files that
4
+ // only live there: uploads, form data, .well-known) or --no-backup (overwrites with no copy),
5
+ // whether by CLI flag, queue request file, or the local server's API.
6
+ // Register it in ~/.claude/settings.json (see README). Exit 2 = blocked; stderr goes back to Claude.
7
+ // Hooks only gate Claude's tool calls: you can still run either flag yourself in a terminal.
8
+
9
+ // The flag inside the same simple command as the CLI (so `grep -- --mirror bin/cli.js` passes).
10
+ const CLI_FLAG = /(?:cli\.js|npm\s+(?:run\s+)?deploy|\b(?:sftp-deploy|shipback)(?:\.cmd)?\s)[^\n;&|]*?\s--(mirror|no-backup)(?=$|[\s'";&|)])/i;
11
+ const MIRROR_ON = /\bmirror["'\\]*\s*:\s*true\b/i;
12
+ const BACKUP_OFF = /\bbackup["'\\]*\s*:\s*false\b/i;
13
+ const QUEUE_OR_API = /\.request\.json|localhost|127\.0\.0\.1/i;
14
+ const REQUEST_FILE = /\.request\.json$/i;
15
+
16
+ function reason(what) {
17
+ return `shipback guard: ${what} is blocked for Claude. --mirror / "mirror": true deletes server files ` +
18
+ `that aren't in the local folder; --no-backup / "backup": false overwrites with no copy. Deploy without it. ` +
19
+ `If the user explicitly wants this, they run the command themselves in a terminal.`;
20
+ }
21
+
22
+ function jsonOption(text) {
23
+ if (MIRROR_ON.test(text)) return '"mirror": true';
24
+ if (BACKUP_OFF.test(text)) return '"backup": false';
25
+ return null;
26
+ }
27
+
28
+ // Returns the block reason, or null to let the call through.
29
+ function check(input) {
30
+ const tool = input && input.tool_name;
31
+ const ti = (input && input.tool_input) || {};
32
+ if (tool === 'Bash' || tool === 'PowerShell') {
33
+ const cmd = String(ti.command || '');
34
+ const flag = cmd.match(CLI_FLAG);
35
+ if (flag) return reason(`--${flag[1]}`);
36
+ const opt = QUEUE_OR_API.test(cmd) && jsonOption(cmd);
37
+ return opt ? reason(opt) : null;
38
+ }
39
+ if (tool === 'Write' || tool === 'Edit' || tool === 'MultiEdit') {
40
+ if (!REQUEST_FILE.test(String(ti.file_path || ''))) return null;
41
+ const text = [ti.content, ti.new_string, ...(ti.edits || []).map(e => e && e.new_string)].filter(Boolean).join('\n');
42
+ const opt = jsonOption(text);
43
+ return opt ? reason(opt) : null;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ if (require.main === module) {
49
+ let raw = '';
50
+ process.stdin.setEncoding('utf8');
51
+ process.stdin.on('data', d => { raw += d; });
52
+ process.stdin.on('end', () => {
53
+ let input;
54
+ try { input = JSON.parse(raw); } catch { process.exit(0); }
55
+ const why = check(input);
56
+ if (why) { process.stderr.write(why + '\n'); process.exit(2); }
57
+ process.exit(0);
58
+ });
59
+ }
60
+
61
+ module.exports = { check };
package/bin/cli.js ADDED
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Command-line deploy / pull: node bin/cli.js <site> [--pull] [--local DIR] [--remote DIR] [--dry-run] [--no-backup] [--mirror] [--password PW]
4
+ // node bin/cli.js <site> --prune NAME[,NAME…] | --keep N [--force] [--dry-run] (remove _backup-* folders on the server)
5
+ // node bin/cli.js <site> --restore [NAME|latest] [--force] [--dry-run] (undo a deploy from its _backup-* folder)
6
+ // node bin/cli.js <site> --backups (list the backups on the server)
7
+ // node bin/cli.js --history [site]
8
+ const path = require('node:path');
9
+ const readline = require('node:readline');
10
+ const { loadSites } = require('../src/config');
11
+ const { createCredentialStore } = require('../src/credentials');
12
+ const { SftpRemoteFs } = require('../src/remote-fs');
13
+ const { runDeploy } = require('../src/deploy');
14
+ const { runPull } = require('../src/pull');
15
+ const { runPrune } = require('../src/prune');
16
+ const { runRestore, describeBackups } = require('../src/restore');
17
+ const { appendHistory, readHistory, recordFromSummary } = require('../src/history');
18
+
19
+ function parseArgs(argv) {
20
+ const out = { _: [] };
21
+ for (let i = 0; i < argv.length; i++) {
22
+ const a = argv[i];
23
+ if (a === '--dry-run') out.dryRun = true;
24
+ else if (a === '--pull') out.pull = true;
25
+ else if (a === '--history') out.history = true;
26
+ else if (a === '--json') out.json = true;
27
+ else if (a === '--no-backup') out.backup = false;
28
+ else if (a === '--mirror') out.mirror = true;
29
+ else if (a === '--prune') { out.prune = true; const v = argv[i + 1]; if (v && !v.startsWith('--')) { out.names = v.split(',').map(x => x.trim()).filter(Boolean); i++; } }
30
+ else if (a === '--keep') { out.prune = true; out.keep = Number(argv[++i]); }
31
+ else if (a === '--force') out.force = true;
32
+ else if (a === '--restore') { out.restore = true; const v = argv[i + 1]; if (v && !v.startsWith('--')) { out.name = v; i++; } }
33
+ else if (a === '--backups') out.backups = true;
34
+ else if (a === '--local') out.local = argv[++i];
35
+ else if (a === '--remote') out.remote = argv[++i];
36
+ else if (a === '--password') out.password = argv[++i];
37
+ else if (a === '--sites') out.sites = argv[++i];
38
+ else if (a === '-h' || a === '--help') out.help = true;
39
+ else out._.push(a);
40
+ }
41
+ return out;
42
+ }
43
+
44
+ function ask(q, hidden) {
45
+ return new Promise(resolve => {
46
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
47
+ if (hidden) { rl._writeToOutput = s => { if (/\n/.test(s)) rl.output.write('\n'); }; process.stdout.write(q); }
48
+ rl.question(hidden ? '' : q, ans => { rl.close(); resolve(ans); });
49
+ });
50
+ }
51
+
52
+ (async () => {
53
+ const args = parseArgs(process.argv.slice(2));
54
+ const sitesFile = args.sites || path.join(process.cwd(), 'sites.json');
55
+ const historyFile = path.join(path.dirname(sitesFile), 'deploys.jsonl');
56
+ if (args.history) {
57
+ const rows = await readHistory(historyFile, { site: args._[0], limit: 20 });
58
+ if (args.json) { console.log(JSON.stringify(rows, null, 2)); return; }
59
+ if (!rows.length) { console.log('No deploys recorded yet.'); return; }
60
+ for (const r of rows) {
61
+ const what = r.action === 'pull' ? `${(r.downloaded || []).length} downloaded` : `${(r.uploaded || []).length} uploaded`;
62
+ console.log(`${new Date(r.at).toLocaleString([], { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })} ${r.ok ? 'ok ' : 'FAILED'} ${r.action.padEnd(7)} ${r.site.padEnd(14)} ${what}${r.deleted && r.deleted.length ? `, ${r.deleted.length} deleted` : ''}${r.backupDir ? ' backup: ' + r.backupDir : ''} [${r.trigger}]${r.error ? ' ' + r.error : ''}`);
63
+ }
64
+ return;
65
+ }
66
+ if (args.help || !args._[0]) {
67
+ console.log('Usage: node bin/cli.js <site> [--pull] [--local DIR] [--remote DIR] [--dry-run] [--no-backup] [--mirror] [--password PW] [--sites sites.json]');
68
+ console.log(' node bin/cli.js <site> --prune NAME[,NAME…] | --keep N [--force] [--dry-run] remove _backup-* folders (only when every file has a copy elsewhere)');
69
+ console.log(' node bin/cli.js <site> --restore [NAME|latest] [--force] [--dry-run] undo a deploy (refuses if a live file was edited since, unless --force)');
70
+ console.log(' node bin/cli.js <site> --backups [--json] list the _backup-* folders and what each holds');
71
+ console.log(' node bin/cli.js --history [site] [--json]');
72
+ process.exit(args.help ? 0 : 1);
73
+ }
74
+ const { sites } = await loadSites(sitesFile);
75
+ const site = sites.find(s => s.name === args._[0]);
76
+ if (!site) { console.error(`Unknown site "${args._[0]}". Known: ${sites.map(s => s.name).join(', ') || '(none)'}`); process.exit(1); }
77
+ const localDir = args.local || site.localDir;
78
+ const remoteRoot = args.remote || site.remoteRoot;
79
+ if (!localDir && !args.prune && !args.restore && !args.backups) { console.error('No local folder: pass --local DIR or set localDir on the site'); process.exit(1); }
80
+
81
+ // Open the OS keychain only when a password has to come from it (or be saved to it): a --password or
82
+ // SFTP_PASSWORD run, like CI, never touches the vault.
83
+ const needsStore = !site.privateKeyPath && args.password == null && !process.env.SFTP_PASSWORD;
84
+ const { store, note } = needsStore ? createCredentialStore() : {};
85
+ let password = args.password ?? process.env.SFTP_PASSWORD ?? (site.privateKeyPath ? '' : await store.get(site.name));
86
+ if (password == null) { password = await ask(`Password for ${site.username}@${site.host}: `, true); }
87
+
88
+ const opts = { host: site.host, port: site.port, username: site.username };
89
+ if (site.privateKeyPath) opts.privateKey = require('node:fs').readFileSync(site.privateKeyPath); else opts.password = password;
90
+ process.stdout.write(`Connecting to ${site.host}… `);
91
+ let rfs;
92
+ try { rfs = await SftpRemoteFs.connect(opts); } catch (e) { console.error('\n' + e.message); process.exit(2); }
93
+ console.log('ok');
94
+ if (needsStore && (await store.get(site.name)) == null) { await store.set(site.name, password); console.log(note); }
95
+
96
+ const fmt = n => n < 1024 ? n + ' B' : n < 1048576 ? (n / 1024).toFixed(1) + ' KB' : (n / 1048576).toFixed(2) + ' MB';
97
+ const action = args.backups ? 'backups' : args.restore ? 'restore' : args.prune ? 'prune' : args.pull ? 'pull' : 'deploy';
98
+ const startedAt = new Date().toISOString();
99
+ const onEvent = ev => {
100
+ if (ev.type === 'plan') {
101
+ const list = ev.plan.upload || ev.plan.download, verb = ev.plan.upload ? 'upload' : 'download';
102
+ console.log(args.pull ? `\n${site.host}:${remoteRoot} → ${localDir}` : `\n${localDir} → ${site.host}:${remoteRoot}`);
103
+ for (const u of list) console.log(` ${u.reason === 'new' ? '+' : '~'} ${u.path} (${fmt(u.size)})`);
104
+ for (const d of ev.plan.delete) console.log(` - ${d}`);
105
+ console.log(` ${list.length} to ${verb} (${fmt(ev.plan.uploadBytes || ev.plan.downloadBytes || 0)}), ${ev.plan.skip.length} unchanged, ${ev.plan.extra.length} ${args.pull ? 'only local' : 'only on server'}${ev.plan.delete.length ? `, ${ev.plan.delete.length} to delete` : ''}\n`);
106
+ } else if (ev.type === 'backup') console.log(` backup ${ev.path}`);
107
+ else if (ev.type === 'upload' || ev.type === 'download') console.log(` ${ev.type.padEnd(8)}${ev.path} ${ev.done}/${ev.total}`);
108
+ else if (ev.type === 'delete') console.log(` delete ${ev.path}`);
109
+ else if (ev.type === 'rmdir') console.log(` rmdir ${ev.path}`);
110
+ else if (ev.type === 'prune-plan') console.log(`\n${site.host}:${remoteRoot} backups: ${ev.backups.join(', ') || '(none)'}\n prune: ${ev.targets.join(', ') || '(none)'}\n keep: ${ev.kept.join(', ') || '(none)'}\n`);
111
+ else if (ev.type === 'prune-check') console.log(` ${ev.name}: ${ev.files} files (${fmt(ev.bytes)}), ${ev.unique} without a copy elsewhere`);
112
+ else if (ev.type === 'refuse') console.log(` REFUSED ${ev.name}: only copy of ${ev.unique.join(', ')} — add --force to remove anyway`);
113
+ else if (ev.type === 'restore-plan') {
114
+ console.log(`
115
+ ${site.host}:${remoteRoot} restore from ${ev.backup}${ev.legacy ? ' (older backup, no .backup.json: puts its files back, removes nothing, cannot check for edits)' : ''}`);
116
+ for (const p of ev.put) console.log(` < ${p}`);
117
+ for (const p of ev.remove) console.log(` - ${p}`);
118
+ console.log(` ${ev.put.length} to put back, ${ev.remove.length} to remove, ${ev.same} already right${ev.conflicts ? `, ${ev.conflicts} CONFLICTS` : ''}
119
+ `);
120
+ } else if (ev.type === 'conflict') console.log(` CONFLICT ${ev.path}: ${ev.why}`);
121
+ else if (ev.type === 'restore') console.log(` restore ${ev.path}`);
122
+ else if (ev.type === 'error') console.error(` ERROR ${ev.message}${ev.undo ? `
123
+ ${ev.undo}` : ''}`);
124
+ };
125
+ const common = { localDir, remoteRoot, remoteFs: rfs, dryRun: !!args.dryRun, backup: args.backup !== false, mirror: !!args.mirror, ignore: site.ignore, onEvent };
126
+ try {
127
+ if (args.backups) {
128
+ const list = await describeBackups({ remoteRoot, remoteFs: rfs });
129
+ if (args.json) console.log(JSON.stringify(list, null, 2));
130
+ else if (!list.length) console.log('No backups on the server.');
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]' : ''}`}`);
132
+ return;
133
+ }
134
+ if (args.restore) {
135
+ const s = await runRestore({ remoteRoot, remoteFs: rfs, name: args.name, dryRun: !!args.dryRun, force: !!args.force, onEvent });
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; }
139
+ else if (s.dryRun) console.log('Dry run — nothing was changed.');
140
+ else console.log(`Done: ${s.put.length} put back, ${s.remove.length} removed${s.undoBackupDir ? `. Undo this with --restore ${s.undoBackupDir.split('/').pop()}` : ''}.`);
141
+ if (!s.dryRun) await appendHistory(historyFile, recordFromSummary({ action, site: site.name, trigger: 'cli', remoteDir: remoteRoot, startedAt, summary: s })).catch(() => {});
142
+ if (args.json) console.log(JSON.stringify(s));
143
+ return;
144
+ }
145
+ const s = args.prune ? await runPrune({ remoteRoot, remoteFs: rfs, names: args.names, keep: args.keep, dryRun: !!args.dryRun, force: !!args.force, onEvent })
146
+ : args.pull ? await runPull(common) : await runDeploy(common);
147
+ if (s.dryRun) console.log('Dry run — nothing was changed.');
148
+ else if (args.prune) {
149
+ console.log(`Done: ${s.pruned.length} backup folder(s) removed (${s.deleted} files, ${fmt(s.bytes)})${s.refused.length ? `, ${s.refused.length} refused` : ''}.`);
150
+ await appendHistory(historyFile, recordFromSummary({ action, site: site.name, trigger: 'cli', remoteDir: remoteRoot, startedAt, summary: s })).catch(() => {});
151
+ } else {
152
+ const moved = args.pull ? s.downloaded : s.uploaded;
153
+ console.log(`Done: ${moved.length} ${args.pull ? 'downloaded' : 'uploaded'} (${fmt(s.bytes)}), ${s.backedUp.length} backed up${s.backupDir ? ' to ' + s.backupDir : ''}, ${s.deleted.length} deleted.`);
154
+ await appendHistory(historyFile, recordFromSummary({ action, site: site.name, trigger: 'cli', localDir, remoteDir: remoteRoot, startedAt, summary: s })).catch(() => {});
155
+ }
156
+ if (args.json) console.log(JSON.stringify(s));
157
+ } catch (e) {
158
+ process.exitCode = e.code === 'ELOCKED' ? 5 : 3;
159
+ await appendHistory(historyFile, recordFromSummary({ action, site: site.name, trigger: 'cli', localDir, remoteDir: remoteRoot, startedAt, error: e.message })).catch(() => {});
160
+ }
161
+ finally { await rfs.close(); }
162
+ })();
package/bin/mcp.js ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // MCP server over stdio: shipback-mcp [--sites path/to/sites.json]
4
+ // (or set SHIPBACK_SITES). See README → "Use it from an AI agent".
5
+ const path = require('node:path');
6
+ const { createMcp, serveStdio } = require('../src/mcp');
7
+
8
+ const i = process.argv.indexOf('--sites');
9
+ const sitesFile = i > 0 && process.argv[i + 1] ? path.resolve(process.argv[i + 1]) : undefined;
10
+ serveStdio(createMcp({ sitesFile })).then(() => process.exit(0));
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "shipback-sftp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
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
+ "keywords": [
6
+ "sftp",
7
+ "deploy",
8
+ "cpanel",
9
+ "shared-hosting",
10
+ "backup",
11
+ "rollback",
12
+ "mcp",
13
+ "mcp-server",
14
+ "claude-code",
15
+ "ai-agents"
16
+ ],
17
+ "author": "Aldo Hushi",
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/aldohushi1-stack/shipback-sftp.git"
22
+ },
23
+ "main": "src/deploy.js",
24
+ "bin": {
25
+ "shipback": "bin/cli.js",
26
+ "shipback-mcp": "bin/mcp.js"
27
+ },
28
+ "files": [
29
+ "bin/",
30
+ "src/",
31
+ "ui/",
32
+ "start.bat",
33
+ "sites.example.json",
34
+ "README.md",
35
+ "UNDO-PROMISE.md",
36
+ "LICENSE"
37
+ ],
38
+ "scripts": {
39
+ "start": "node src/server.js",
40
+ "test": "node test/run.js",
41
+ "deploy": "node bin/cli.js",
42
+ "mcp": "node bin/mcp.js"
43
+ },
44
+ "engines": {
45
+ "node": ">=18"
46
+ },
47
+ "dependencies": {
48
+ "@napi-rs/keyring": "^2.1.0",
49
+ "ssh2": "^1.17.0"
50
+ }
51
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "sites": [
3
+ {
4
+ "name": "mysite",
5
+ "host": "server123.example-host.com",
6
+ "port": 22,
7
+ "username": "cpaneluser",
8
+ "remoteRoot": "/home/cpaneluser/public_html",
9
+ "localDir": "/Users/you/sites/mysite",
10
+ "ignore": ["drafts/", "*.psd"]
11
+ }
12
+ ]
13
+ }