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 +21 -0
- package/README.md +211 -2
- package/bin/claude-guard.js +61 -0
- package/bin/cli.js +159 -0
- package/bin/mcp.js +10 -0
- package/package.json +48 -4
- package/sites.example.json +13 -0
- package/src/backup.js +61 -0
- package/src/config.js +44 -0
- package/src/credentials.js +32 -0
- package/src/deploy.js +129 -0
- package/src/diff.js +65 -0
- package/src/history.js +37 -0
- package/src/manifest.js +98 -0
- package/src/mcp.js +237 -0
- package/src/prune.js +99 -0
- package/src/pull.js +131 -0
- package/src/queue.js +107 -0
- package/src/remote-fs.js +161 -0
- package/src/restore.js +144 -0
- package/src/server.js +267 -0
- package/start.bat +13 -0
- package/ui/index.html +462 -0
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
|
-
#
|
|
1
|
+
# Shipback SFTP
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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 };
|