shipback-sftp 0.0.0-stage → 0.1.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,212 @@
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
+ - **Backup before every overwrite.** The server's copy of each file a deploy replaces is saved into
15
+ `_backup-<time>/` on the server, before the upload starts.
16
+ - **One-command undo.** `--restore` puts those files back and removes the files that deploy added.
17
+ - **It won't undo over a live edit.** If someone changed a file on the server after the deploy, restore
18
+ stops and names the file. Nothing is touched unless you pass `--force`, and even then the edited copy is
19
+ backed up first.
20
+ - **Restores can be undone too.** A restore backs up what it replaces, the same way a deploy does.
21
+ - **Safe for agents.** The MCP server has no switch to delete server files or skip the backup. A deploy
22
+ only runs the exact plan the agent previewed: if a file changed in between, it refuses.
23
+ - **Spots the drift.** `--pull --dry-run` shows what changed on the server since your last deploy, so
24
+ edits made live in cPanel don't get overwritten by accident.
25
+
26
+ No git or SSH shell is needed on the server, just SFTP. It needs Node 18+. The tests run on Windows, macOS
27
+ and Linux with Node 18, 20 and 22 on every push. Passwords are kept in the OS keychain and never
28
+ pass through the model.
29
+
30
+ **It has run in production.** The deploy engine underneath has shipped [aldo.today](https://aldo.today) 235 times
31
+ since September 2026 (2,907 files to a cPanel host). Restore and the MCP server are new in 0.1.
32
+
33
+ ## Quick start
34
+
35
+ ```bash
36
+ git clone https://github.com/aldohushi1-stack/shipback-sftp.git
37
+ cd shipback-sftp
38
+ npm install
39
+ cp sites.example.json sites.json # then edit it
40
+ node bin/cli.js mysite --dry-run # see the plan; asks for the password once and stores it
41
+ node bin/cli.js mysite # deploy
42
+ node bin/cli.js mysite --restore # changed your mind? undo it
43
+ ```
44
+
45
+ `npm link` in the folder installs it as the `shipback` and `shipback-mcp` commands, so `node bin/cli.js mysite`
46
+ becomes `shipback mysite`.
47
+
48
+ `sites.json`, one entry per site:
49
+
50
+ ```json
51
+ {
52
+ "sites": [
53
+ {
54
+ "name": "mysite",
55
+ "host": "server123.example-host.com",
56
+ "port": 22,
57
+ "username": "cpaneluser",
58
+ "remoteRoot": "/home/cpaneluser/public_html",
59
+ "localDir": "/Users/you/sites/mysite",
60
+ "ignore": ["drafts/", "*.psd"]
61
+ }
62
+ ]
63
+ }
64
+ ```
65
+
66
+ Many shared hosts run SFTP on their own port rather than 22 (check *SSH Access* in your control panel),
67
+ and some only accept connections from allow-listed IPs. If a connection times out, check those two
68
+ first. Use `"privateKeyPath"` instead of a password if your host has your key.
69
+
70
+ ## Commands
71
+
72
+ ```bash
73
+ node bin/cli.js <site> --dry-run # what a deploy would upload
74
+ node bin/cli.js <site> # deploy: backup, upload, record
75
+ node bin/cli.js <site> --local <folder> # deploy a different folder than localDir
76
+ node bin/cli.js <site> --pull --dry-run # what changed on the server since the last deploy?
77
+ node bin/cli.js <site> --pull # bring those changes down (local copies backed up first)
78
+ node bin/cli.js <site> --backups # list backups: what each run overwrote, added, deleted
79
+ node bin/cli.js <site> --restore --dry-run # what an undo of the latest deploy would do
80
+ node bin/cli.js <site> --restore [<backup>] # undo it (or name an older _backup-… folder)
81
+ node bin/cli.js <site> --prune --keep 10 # tidy old backups; refuses any holding the only copy of a file
82
+ node bin/cli.js --history [site] # what went up, when, from where
83
+ ```
84
+
85
+ Add `--json` to any command for machine-readable output. A refused restore exits with code 4.
86
+
87
+ `--mirror` (delete server files that aren't in the local folder) and `--no-backup` exist for humans who
88
+ know they want them. The agent tools don't offer them, and the [guard hook](#claude-code-guard-hook)
89
+ blocks Claude from typing them.
90
+
91
+ ## How the undo works
92
+
93
+ Each deploy writes `_backup-YYYY-MM-DD-HHMM/` in the remote root. It holds:
94
+
95
+ - the server's copy of every file the deploy **overwrote** or **deleted**
96
+ - `.backup.json`, a record of what the deploy **added**, and a SHA-256 of every file it left on the server
97
+
98
+ `--restore` reads that record and checks each live file first:
99
+
100
+ | Live file now | Restore does |
101
+ | --- | --- |
102
+ | exactly what the deploy uploaded | puts the old copy back (or removes it, if the deploy added it) |
103
+ | already the old copy | nothing; it counts as already right |
104
+ | different from both | **conflict**: stops, names the file, changes nothing (`--force` overrides) |
105
+
106
+ Before writing anything, restore saves the current live files into a new `_backup-…` folder with its own
107
+ record, so `--restore` on that folder takes you forward again. It also clears the restored paths from
108
+ `.deploy-manifest.json`, so the next deploy compares them by content instead of trusting stale hashes.
109
+
110
+ Backup folders are skipped by deploys and pulls, and are never deleted except by `--prune`.
111
+
112
+ ## Use it from an AI agent (MCP)
113
+
114
+ `bin/mcp.js` is an MCP server over stdio.
115
+
116
+ **Claude Code**
117
+
118
+ ```bash
119
+ claude mcp add shipback-sftp -- node /path/to/shipback-sftp/bin/mcp.js --sites /path/to/sites.json
120
+ ```
121
+
122
+ **Cursor, Claude Desktop, and others** (`mcp.json` / `claude_desktop_config.json`):
123
+
124
+ ```json
125
+ {
126
+ "mcpServers": {
127
+ "shipback-sftp": {
128
+ "command": "node",
129
+ "args": ["/path/to/shipback-sftp/bin/mcp.js", "--sites", "/path/to/sites.json"]
130
+ }
131
+ }
132
+ }
133
+ ```
134
+
135
+ Run any CLI command for a site once first (for example `node bin/cli.js mysite --backups`). That stores
136
+ the password in the OS keychain, where the MCP server reads it. The model never sees it. For CI you can set
137
+ `SFTP_PASSWORD` in the server's environment instead.
138
+
139
+ | Tool | Does |
140
+ | --- | --- |
141
+ | `list_sites` | the configured sites (no passwords) |
142
+ | `preview_deploy` | what would upload; returns a `planId` |
143
+ | `deploy` | uploads that plan; refuses if anything changed since the preview |
144
+ | `check_server_changes` | files edited or added on the live server since the last deploy |
145
+ | `pull` | brings those down into the local folder (local copies backed up) |
146
+ | `list_backups` | backups, newest first, with what each run changed |
147
+ | `preview_restore` | what an undo would put back and remove, plus any conflicts; returns a `planId` |
148
+ | `restore` | runs that undo; refuses on conflicts unless `force` |
149
+ | `history` | recent deploys, pulls and restores |
150
+
151
+ What the tools leave out is deliberate: there is no mirror/delete, no way to skip the backup, and no
152
+ pruning of backups.
153
+
154
+ ## Agents without a shell: the drop-box queue
155
+
156
+ Some agent setups can write files but can't run commands. While the local app is running
157
+ (`npm start`, or `start.bat` on Windows), anything that can write a file can queue a job:
158
+
159
+ ```
160
+ queue/<name>.request.json { "action": "deploy", "site": "mysite", "dryRun": true, "note": "new homepage" }
161
+ queue/<name>.result.json ← written when it finishes, with a .log.txt beside it
162
+ ```
163
+
164
+ Actions: `deploy`, `preview`, `pull`, `restore`, `backups`, `prune`.
165
+
166
+ ## The local app
167
+
168
+ `npm start` opens a small UI on `http://127.0.0.1:4646`: add sites, preview, deploy, pull, drag in
169
+ one-off uploads, browse the server, and see history. It only answers requests from your own machine.
170
+
171
+ ## Claude Code guard hook
172
+
173
+ If Claude also runs the CLI directly, add `bin/claude-guard.js` as a PreToolUse hook. It blocks
174
+ `--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.
176
+
177
+ ```json
178
+ {
179
+ "hooks": {
180
+ "PreToolUse": [
181
+ {
182
+ "matcher": "Bash|PowerShell|Write|Edit|MultiEdit",
183
+ "hooks": [{ "type": "command", "command": "node /path/to/shipback-sftp/bin/claude-guard.js" }]
184
+ }
185
+ ]
186
+ }
187
+ }
188
+ ```
189
+
190
+ ## Tests
191
+
192
+ ```bash
193
+ npm test
194
+ ```
195
+
196
+ The suite uses `node:test` and needs no network. Deploy, restore, the CLI and the MCP server are each
197
+ also exercised end to end against a real SFTP server started inside the test run.
198
+
199
+ ## Limits
200
+
201
+ - It compares by size and SHA-256. The first deploy to a site that already has files reads the server
202
+ copies of any same-size files to compare them.
203
+ - Backups live on the same server as the site. They protect against bad deploys, not against losing the
204
+ hosting account. Keep your host's own backups too.
205
+ - Files over 8 MB with the same size on both sides are assumed unchanged when there is no manifest hash
206
+ to compare.
207
+
208
+ ## License
209
+
210
+ MIT © 2026 Aldo Hushi
211
+
212
+ Runs aldo.today. Made at [BlueprintAU](https://blueprintau.com).
@@ -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,159 @@
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}`);
123
+ };
124
+ const common = { localDir, remoteRoot, remoteFs: rfs, dryRun: !!args.dryRun, backup: args.backup !== false, mirror: !!args.mirror, ignore: site.ignore, onEvent };
125
+ try {
126
+ if (args.backups) {
127
+ const list = await describeBackups({ remoteRoot, remoteFs: rfs });
128
+ if (args.json) console.log(JSON.stringify(list, null, 2));
129
+ 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
+ return;
132
+ }
133
+ if (args.restore) {
134
+ const s = await runRestore({ remoteRoot, remoteFs: rfs, name: args.name, dryRun: !!args.dryRun, force: !!args.force, onEvent });
135
+ if (s.refused) { console.log(`Refused: ${s.conflicts.length} file(s) changed on the server after that run. Nothing was changed. Check them, then add --force to restore anyway (the live copies are backed up first).`); process.exitCode = 4; }
136
+ else if (s.dryRun) console.log('Dry run — nothing was changed.');
137
+ else console.log(`Done: ${s.put.length} put back, ${s.remove.length} removed${s.undoBackupDir ? `. Undo this with --restore ${s.undoBackupDir.split('/').pop()}` : ''}.`);
138
+ if (!s.dryRun) await appendHistory(historyFile, recordFromSummary({ action, site: site.name, trigger: 'cli', remoteDir: remoteRoot, startedAt, summary: s })).catch(() => {});
139
+ if (args.json) console.log(JSON.stringify(s));
140
+ return;
141
+ }
142
+ const s = args.prune ? await runPrune({ remoteRoot, remoteFs: rfs, names: args.names, keep: args.keep, dryRun: !!args.dryRun, force: !!args.force, onEvent })
143
+ : args.pull ? await runPull(common) : await runDeploy(common);
144
+ if (s.dryRun) console.log('Dry run — nothing was changed.');
145
+ else if (args.prune) {
146
+ console.log(`Done: ${s.pruned.length} backup folder(s) removed (${s.deleted} files, ${fmt(s.bytes)})${s.refused.length ? `, ${s.refused.length} refused` : ''}.`);
147
+ await appendHistory(historyFile, recordFromSummary({ action, site: site.name, trigger: 'cli', remoteDir: remoteRoot, startedAt, summary: s })).catch(() => {});
148
+ } else {
149
+ const moved = args.pull ? s.downloaded : s.uploaded;
150
+ 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.`);
151
+ await appendHistory(historyFile, recordFromSummary({ action, site: site.name, trigger: 'cli', localDir, remoteDir: remoteRoot, startedAt, summary: s })).catch(() => {});
152
+ }
153
+ if (args.json) console.log(JSON.stringify(s));
154
+ } catch (e) {
155
+ process.exitCode = 3;
156
+ await appendHistory(historyFile, recordFromSummary({ action, site: site.name, trigger: 'cli', localDir, remoteDir: remoteRoot, startedAt, error: e.message })).catch(() => {});
157
+ }
158
+ finally { await rfs.close(); }
159
+ })();
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,50 @@
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.1.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
+ "LICENSE"
36
+ ],
37
+ "scripts": {
38
+ "start": "node src/server.js",
39
+ "test": "node test/run.js",
40
+ "deploy": "node bin/cli.js",
41
+ "mcp": "node bin/mcp.js"
42
+ },
43
+ "engines": {
44
+ "node": ">=18"
45
+ },
46
+ "dependencies": {
47
+ "@napi-rs/keyring": "^2.1.0",
48
+ "ssh2": "^1.17.0"
49
+ }
50
+ }
@@ -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
+ }
package/src/backup.js ADDED
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+ // Copy the server's current copy of each file into <root>/_backup-<stamp>/<path> before it is overwritten or deleted.
3
+ // A deploy also writes <backup>/.backup.json — what it overwrote, added and deleted, and the sha256 of what it
4
+ // uploaded — so restore.js can undo it and tell when a live file was edited after the deploy.
5
+ const posix = require('node:path').posix;
6
+ const { BACKUP_PREFIX } = require('./diff');
7
+
8
+ const BACKUP_INFO = '.backup.json';
9
+
10
+ const pad = n => String(n).padStart(2, '0');
11
+ function makeStamp(d = new Date()) {
12
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
13
+ }
14
+
15
+ /** First _backup-<stamp> folder name not already on the server (two runs in one minute get -2, -3 …). */
16
+ async function freeBackupDir(remoteFs, root, stamp) {
17
+ root = root.replace(/\/$/, '');
18
+ let dir = `${root}/${BACKUP_PREFIX}${stamp}`;
19
+ for (let n = 2; await remoteFs.stat(dir); n++) dir = `${root}/${BACKUP_PREFIX}${stamp}-${n}`;
20
+ return dir;
21
+ }
22
+
23
+ /** backupFiles({ remoteFs, root, paths, stamp | backupDir, onEvent }) → { backupDir, copied } */
24
+ async function backupFiles({ remoteFs, root, paths, stamp = makeStamp(), backupDir, onEvent = () => {} }) {
25
+ root = root.replace(/\/$/, '');
26
+ backupDir = backupDir || `${root}/${BACKUP_PREFIX}${stamp}`;
27
+ const copied = [];
28
+ const madeDirs = new Set();
29
+ for (const rel of paths) {
30
+ const src = `${root}/${rel}`;
31
+ const st = await remoteFs.stat(src);
32
+ if (!st || st.isDir) continue;
33
+ const dest = `${backupDir}/${rel}`;
34
+ const dir = posix.dirname(dest);
35
+ if (!madeDirs.has(dir)) { await remoteFs.mkdirp(dir); madeDirs.add(dir); }
36
+ const data = await remoteFs.readFile(src);
37
+ await remoteFs.writeFile(dest, data);
38
+ copied.push(rel);
39
+ onEvent({ type: 'backup', path: rel, bytes: data.length, to: dest });
40
+ }
41
+ return { backupDir, copied };
42
+ }
43
+
44
+ /**
45
+ * writeBackupInfo({ remoteFs, backupDir, info })
46
+ * info: { action, at, overwritten: [{ path, sha256 }], added: [{ path, sha256 }], deleted: [{ path }], from? }
47
+ * sha256 = the content the run left at that path, so a later restore can tell if it was edited since.
48
+ */
49
+ async function writeBackupInfo({ remoteFs, backupDir, info }) {
50
+ await remoteFs.mkdirp(backupDir);
51
+ await remoteFs.writeFile(`${backupDir}/${BACKUP_INFO}`, JSON.stringify({ version: 1, tool: 'shipback-sftp', ...info }, null, 1));
52
+ }
53
+
54
+ async function readBackupInfo(remoteFs, backupDir) {
55
+ try {
56
+ const info = JSON.parse((await remoteFs.readFile(`${backupDir}/${BACKUP_INFO}`)).toString('utf8'));
57
+ return info && info.version === 1 ? info : null;
58
+ } catch { return null; }
59
+ }
60
+
61
+ module.exports = { backupFiles, makeStamp, freeBackupDir, writeBackupInfo, readBackupInfo, BACKUP_INFO };
package/src/config.js ADDED
@@ -0,0 +1,44 @@
1
+ 'use strict';
2
+ // sites.json — { sites: [{ name, host, port, username, remoteRoot, localDir, ignore: [] }] }. Never holds passwords.
3
+ const fs = require('node:fs/promises');
4
+ const path = require('node:path');
5
+
6
+ const SITE_FIELDS = ['name', 'host', 'port', 'username', 'remoteRoot', 'localDir', 'ignore', 'privateKeyPath'];
7
+
8
+ function normaliseSite(s) {
9
+ const out = {};
10
+ for (const k of SITE_FIELDS) if (s[k] !== undefined && s[k] !== '') out[k] = s[k];
11
+ out.port = Number(out.port) || 22;
12
+ out.ignore = Array.isArray(out.ignore) ? out.ignore : [];
13
+ for (const k of ['name', 'host', 'username', 'remoteRoot']) if (!out[k] || typeof out[k] !== 'string') throw new Error(`Site is missing "${k}"`);
14
+ if (!/^[\w.-]+$/.test(out.name)) throw new Error('Site name may only use letters, numbers, dots, dashes and underscores');
15
+ out.remoteRoot = out.remoteRoot.replace(/\\/g, '/').replace(/\/$/, '') || '/';
16
+ return out;
17
+ }
18
+
19
+ async function loadSites(file) {
20
+ try {
21
+ const data = JSON.parse(await fs.readFile(file, 'utf8'));
22
+ return { sites: (data.sites || []).map(normaliseSite) };
23
+ } catch (e) {
24
+ if (e.code === 'ENOENT') return { sites: [] };
25
+ throw new Error(`Could not read ${path.basename(file)}: ${e.message}`);
26
+ }
27
+ }
28
+
29
+ async function saveSite(file, site) {
30
+ const clean = normaliseSite(site);
31
+ const cfg = await loadSites(file);
32
+ const i = cfg.sites.findIndex(s => s.name === clean.name);
33
+ if (i >= 0) cfg.sites[i] = clean; else cfg.sites.push(clean);
34
+ await fs.writeFile(file, JSON.stringify(cfg, null, 2) + '\n');
35
+ return clean;
36
+ }
37
+
38
+ async function deleteSite(file, name) {
39
+ const cfg = await loadSites(file);
40
+ cfg.sites = cfg.sites.filter(s => s.name !== name);
41
+ await fs.writeFile(file, JSON.stringify(cfg, null, 2) + '\n');
42
+ }
43
+
44
+ module.exports = { loadSites, saveSite, deleteSite, normaliseSite };