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
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Password storage. Windows → Credential Manager (Generic Credentials) via @napi-rs/keyring.
|
|
3
|
+
// If the OS vault is unavailable, fall back to memory for this session only and say so.
|
|
4
|
+
const SERVICE = 'shipback-sftp';
|
|
5
|
+
|
|
6
|
+
class MemoryStore {
|
|
7
|
+
constructor() { this.map = new Map(); this.kind = 'memory'; }
|
|
8
|
+
async get(account) { return this.map.has(account) ? this.map.get(account) : null; }
|
|
9
|
+
async set(account, password) { this.map.set(account, password); }
|
|
10
|
+
async delete(account) { this.map.delete(account); }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
class KeyringStore {
|
|
14
|
+
constructor(Entry) { this.Entry = Entry; this.kind = 'keyring'; }
|
|
15
|
+
async get(account) { try { return new this.Entry(SERVICE, account).getPassword() ?? null; } catch { return null; } }
|
|
16
|
+
async set(account, password) { new this.Entry(SERVICE, account).setPassword(password); }
|
|
17
|
+
async delete(account) { try { new this.Entry(SERVICE, account).deletePassword(); } catch { /* not there */ } }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Pick the best available store. Returns { store, note } */
|
|
21
|
+
function createCredentialStore() {
|
|
22
|
+
try {
|
|
23
|
+
const { Entry } = require('@napi-rs/keyring');
|
|
24
|
+
// probe: the vault must be readable, otherwise fall back
|
|
25
|
+
new Entry(SERVICE, '__probe__').getPassword();
|
|
26
|
+
return { store: new KeyringStore(Entry), note: process.platform === 'win32' ? 'Passwords are kept in Windows Credential Manager.' : 'Passwords are kept in the OS keychain.' };
|
|
27
|
+
} catch (e) {
|
|
28
|
+
return { store: new MemoryStore(), note: `OS credential vault unavailable (${e.message.split('\n')[0]}). Passwords are kept in memory for this session only.` };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { MemoryStore, KeyringStore, createCredentialStore, SERVICE };
|
package/src/deploy.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Deploy orchestrator: scan → plan → backup → upload → (delete) → manifest.
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const posix = path.posix;
|
|
5
|
+
const { scanLocal, loadIgnore, dropIgnored, sha256File } = require('./manifest');
|
|
6
|
+
const { plan: makePlan, resolveVerify, MANIFEST_NAME, BACKUP_PREFIX } = require('./diff');
|
|
7
|
+
const { backupFiles, makeStamp, freeBackupDir, writeBackupInfo } = require('./backup');
|
|
8
|
+
|
|
9
|
+
const isBackupDir = name => name.startsWith(BACKUP_PREFIX);
|
|
10
|
+
|
|
11
|
+
async function readRemoteManifest(remoteFs, root) {
|
|
12
|
+
try { return JSON.parse((await remoteFs.readFile(`${root}/${MANIFEST_NAME}`)).toString('utf8')); }
|
|
13
|
+
catch { return null; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Preview only: scan both sides and return the plan. */
|
|
17
|
+
async function previewDeploy({ localDir, remoteRoot, remoteFs, mirror = false, ignore, onEvent = () => {} }) {
|
|
18
|
+
const root = remoteRoot.replace(/\/$/, '');
|
|
19
|
+
onEvent({ type: 'scan', side: 'local', dir: localDir });
|
|
20
|
+
const local = await scanLocal(localDir, { ignore });
|
|
21
|
+
onEvent({ type: 'scan', side: 'remote', dir: root });
|
|
22
|
+
const rootStat = await remoteFs.stat(root);
|
|
23
|
+
if (rootStat && !rootStat.isDir) throw new Error(`Remote path is a file, not a folder: ${root}`);
|
|
24
|
+
// ignored paths are hands-off on the server too: never counted as extra, never mirror-deleted
|
|
25
|
+
const remoteListing = rootStat ? dropIgnored(await remoteFs.listRecursive(root, { skipDir: isBackupDir }), await loadIgnore(localDir, ignore)) : [];
|
|
26
|
+
const remoteManifest = rootStat ? await readRemoteManifest(remoteFs, root) : null;
|
|
27
|
+
const plan = makePlan({ local, remoteManifest, remoteListing, mirror });
|
|
28
|
+
if (plan.verify.length) await resolveVerify(plan, { readFile: p => remoteFs.readFile(`${root}/${p}`), onEvent });
|
|
29
|
+
onEvent({ type: 'plan', plan, localCount: local.length, remoteCount: remoteListing.length, remoteExists: !!rootStat });
|
|
30
|
+
return { local, plan, remoteExists: !!rootStat };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* runDeploy({ localDir, remoteRoot, remoteFs, dryRun, backup, mirror, ignore, now, onEvent })
|
|
35
|
+
* → { dryRun, plan, uploaded, backedUp, backupDir, deleted, bytes }
|
|
36
|
+
*/
|
|
37
|
+
async function runDeploy({ localDir, remoteRoot, remoteFs, dryRun = false, backup = true, mirror = false, ignore, now = new Date(), onEvent = () => {} }) {
|
|
38
|
+
const root = remoteRoot.replace(/\/$/, '');
|
|
39
|
+
const summary = { dryRun, plan: null, uploaded: [], backedUp: [], backupDir: null, deleted: [], bytes: 0 };
|
|
40
|
+
try {
|
|
41
|
+
const { local, plan, remoteExists } = await previewDeploy({ localDir, remoteRoot: root, remoteFs, mirror, ignore, onEvent });
|
|
42
|
+
summary.plan = plan;
|
|
43
|
+
if (dryRun) { onEvent({ type: 'done', summary }); return summary; }
|
|
44
|
+
|
|
45
|
+
if (!remoteExists) { await remoteFs.mkdirp(root); onEvent({ type: 'mkdir', path: root }); }
|
|
46
|
+
|
|
47
|
+
// 1. backup everything about to be overwritten or deleted, plus .backup.json so restore can undo this run
|
|
48
|
+
if (backup && (plan.upload.length || plan.delete.length)) {
|
|
49
|
+
const backupDir = await freeBackupDir(remoteFs, root, makeStamp(now));
|
|
50
|
+
const targets = [...plan.upload.filter(u => u.reason === 'changed').map(u => u.path), ...plan.delete];
|
|
51
|
+
const res = await backupFiles({ remoteFs, root, paths: targets, backupDir, onEvent });
|
|
52
|
+
const copied = new Set(res.copied);
|
|
53
|
+
const sha = new Map(local.map(f => [f.path, f.sha256]));
|
|
54
|
+
await writeBackupInfo({ remoteFs, backupDir, info: {
|
|
55
|
+
action: 'deploy', at: now.toISOString(),
|
|
56
|
+
overwritten: plan.upload.filter(u => copied.has(u.path)).map(u => ({ path: u.path, sha256: sha.get(u.path) })),
|
|
57
|
+
added: plan.upload.filter(u => !copied.has(u.path)).map(u => ({ path: u.path, sha256: sha.get(u.path) })),
|
|
58
|
+
deleted: plan.delete.filter(p => copied.has(p)).map(p => ({ path: p })),
|
|
59
|
+
} });
|
|
60
|
+
summary.backedUp = res.copied;
|
|
61
|
+
summary.backupDir = backupDir;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 2. upload new + changed
|
|
65
|
+
const madeDirs = new Set([root]);
|
|
66
|
+
for (const u of plan.upload) {
|
|
67
|
+
const dest = `${root}/${u.path}`;
|
|
68
|
+
const dir = posix.dirname(dest);
|
|
69
|
+
if (!madeDirs.has(dir)) { await remoteFs.mkdirp(dir); madeDirs.add(dir); }
|
|
70
|
+
await remoteFs.uploadFile(path.join(localDir, ...u.path.split('/')), dest);
|
|
71
|
+
summary.uploaded.push(u.path); summary.bytes += u.size;
|
|
72
|
+
onEvent({ type: 'upload', path: u.path, bytes: u.size, reason: u.reason, done: summary.uploaded.length, total: plan.upload.length });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 3. mirror deletes
|
|
76
|
+
for (const p of plan.delete) {
|
|
77
|
+
await remoteFs.remove(`${root}/${p}`);
|
|
78
|
+
summary.deleted.push(p);
|
|
79
|
+
onEvent({ type: 'delete', path: p });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 4. manifest for next time
|
|
83
|
+
const files = {};
|
|
84
|
+
for (const f of local) files[f.path] = { sha256: f.sha256, size: f.size, mtime: f.mtime };
|
|
85
|
+
await remoteFs.writeFile(`${root}/${MANIFEST_NAME}`, JSON.stringify({ version: 1, deployedAt: new Date().toISOString(), files }, null, 1));
|
|
86
|
+
onEvent({ type: 'manifest', path: MANIFEST_NAME, count: local.length });
|
|
87
|
+
onEvent({ type: 'done', summary });
|
|
88
|
+
return summary;
|
|
89
|
+
} catch (err) {
|
|
90
|
+
onEvent({ type: 'error', message: err.message, summary });
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Ad-hoc upload of named local files into a remote dir (with backup of anything overwritten). */
|
|
96
|
+
async function uploadFiles({ files, remoteDir, remoteFs, backup = true, now = new Date(), onEvent = () => {} }) {
|
|
97
|
+
// files: [{ name, localPath, size }]
|
|
98
|
+
const root = remoteDir.replace(/\/$/, '');
|
|
99
|
+
const summary = { uploaded: [], backedUp: [], backupDir: null, bytes: 0 };
|
|
100
|
+
try {
|
|
101
|
+
await remoteFs.mkdirp(root);
|
|
102
|
+
if (backup && files.length) {
|
|
103
|
+
const backupDir = await freeBackupDir(remoteFs, root, makeStamp(now));
|
|
104
|
+
const res = await backupFiles({ remoteFs, root, paths: files.map(f => f.name), backupDir, onEvent });
|
|
105
|
+
const copied = new Set(res.copied);
|
|
106
|
+
const shas = new Map();
|
|
107
|
+
for (const f of files) shas.set(f.name, await sha256File(f.localPath));
|
|
108
|
+
await writeBackupInfo({ remoteFs, backupDir, info: {
|
|
109
|
+
action: 'upload', at: now.toISOString(),
|
|
110
|
+
overwritten: files.filter(f => copied.has(f.name)).map(f => ({ path: f.name, sha256: shas.get(f.name) })),
|
|
111
|
+
added: files.filter(f => !copied.has(f.name)).map(f => ({ path: f.name, sha256: shas.get(f.name) })),
|
|
112
|
+
deleted: [],
|
|
113
|
+
} });
|
|
114
|
+
summary.backedUp = res.copied; summary.backupDir = backupDir;
|
|
115
|
+
}
|
|
116
|
+
for (const f of files) {
|
|
117
|
+
await remoteFs.uploadFile(f.localPath, `${root}/${f.name}`);
|
|
118
|
+
summary.uploaded.push(f.name); summary.bytes += f.size || 0;
|
|
119
|
+
onEvent({ type: 'upload', path: f.name, bytes: f.size || 0, reason: 'upload', done: summary.uploaded.length, total: files.length });
|
|
120
|
+
}
|
|
121
|
+
onEvent({ type: 'done', summary });
|
|
122
|
+
return summary;
|
|
123
|
+
} catch (err) {
|
|
124
|
+
onEvent({ type: 'error', message: err.message, summary });
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { runDeploy, previewDeploy, uploadFiles, MANIFEST_NAME };
|
package/src/diff.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Compare a local manifest against the server and decide what to upload / skip / delete.
|
|
3
|
+
const MANIFEST_NAME = '.deploy-manifest.json';
|
|
4
|
+
const BACKUP_PREFIX = '_backup-';
|
|
5
|
+
|
|
6
|
+
function isProtectedRemote(p) {
|
|
7
|
+
return p === MANIFEST_NAME || p.startsWith(BACKUP_PREFIX) || p.includes('/' + BACKUP_PREFIX);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* plan({ local, remoteManifest, remoteListing, mirror })
|
|
12
|
+
* local: [{ path, size, mtime, sha256 }]
|
|
13
|
+
* remoteManifest: { files: { path: { sha256 } } } | null (written by the last deploy)
|
|
14
|
+
* remoteListing: [{ path, size, mtime }] (files actually on the server)
|
|
15
|
+
* → { upload: [{ path, size, reason }], skip: [path], verify: [{ path, size, sha256 }], extra: [path], delete: [path], uploadBytes }
|
|
16
|
+
* `verify` = same size on both sides but no manifest hash to compare; resolveVerify() settles those.
|
|
17
|
+
*/
|
|
18
|
+
function plan({ local, remoteManifest, remoteListing, mirror = false }) {
|
|
19
|
+
const remote = new Map(remoteListing.map(f => [f.path, f]));
|
|
20
|
+
const manifest = (remoteManifest && remoteManifest.files) || {};
|
|
21
|
+
const upload = [], skip = [], verify = [];
|
|
22
|
+
const localSet = new Set();
|
|
23
|
+
for (const f of [...local].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
24
|
+
localSet.add(f.path);
|
|
25
|
+
const r = remote.get(f.path);
|
|
26
|
+
if (!r) { upload.push({ path: f.path, size: f.size, reason: 'new' }); continue; }
|
|
27
|
+
const m = manifest[f.path];
|
|
28
|
+
if (m && m.sha256 === f.sha256 && r.size === f.size) {
|
|
29
|
+
skip.push(f.path); // exactly what we deployed last time
|
|
30
|
+
} else if (r.size !== f.size) {
|
|
31
|
+
upload.push({ path: f.path, size: f.size, reason: 'changed' });
|
|
32
|
+
} else {
|
|
33
|
+
// same size but no manifest hash to trust (never deployed by us, or the folder was pulled since): read the server copy
|
|
34
|
+
verify.push({ path: f.path, size: f.size, sha256: f.sha256 });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const extra = [...remote.keys()].filter(p => !localSet.has(p) && !isProtectedRemote(p)).sort();
|
|
38
|
+
return finalise({ upload, skip, verify, extra, delete: mirror ? [...extra] : [] });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function finalise(p) {
|
|
42
|
+
p.upload.sort((a, b) => a.path.localeCompare(b.path));
|
|
43
|
+
p.skip.sort();
|
|
44
|
+
p.uploadBytes = p.upload.reduce((n, u) => n + u.size, 0);
|
|
45
|
+
return p;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve the `verify` list by hashing the server copies. Files above `maxBytes` are assumed
|
|
50
|
+
* unchanged (same size, too big to be worth a download). Mutates and returns the plan.
|
|
51
|
+
*/
|
|
52
|
+
async function resolveVerify(plan, { readFile, maxBytes = 8 * 1024 * 1024, onEvent = () => {} }) {
|
|
53
|
+
const crypto = require('node:crypto');
|
|
54
|
+
for (const v of plan.verify) {
|
|
55
|
+
if (v.size > maxBytes) { plan.skip.push(v.path); continue; }
|
|
56
|
+
onEvent({ type: 'verify', path: v.path });
|
|
57
|
+
const remoteHash = crypto.createHash('sha256').update(await readFile(v.path)).digest('hex');
|
|
58
|
+
if (remoteHash === v.sha256) plan.skip.push(v.path);
|
|
59
|
+
else plan.upload.push({ path: v.path, size: v.size, reason: 'changed' });
|
|
60
|
+
}
|
|
61
|
+
plan.verify = [];
|
|
62
|
+
return finalise(plan);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { plan, resolveVerify, MANIFEST_NAME, BACKUP_PREFIX, isProtectedRemote };
|
package/src/history.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// deploys.jsonl — one line per deploy / upload / pull, so "what went up and when" is answerable later.
|
|
3
|
+
const fs = require('node:fs/promises');
|
|
4
|
+
|
|
5
|
+
async function appendHistory(file, record) {
|
|
6
|
+
await fs.appendFile(file, JSON.stringify(record) + '\n');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function readHistory(file, { site, limit = 50 } = {}) {
|
|
10
|
+
let text;
|
|
11
|
+
try { text = await fs.readFile(file, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return []; throw e; }
|
|
12
|
+
const out = [];
|
|
13
|
+
for (const line of text.split('\n')) {
|
|
14
|
+
if (!line.trim()) continue;
|
|
15
|
+
try { const r = JSON.parse(line); if (!site || r.site === site) out.push(r); } catch { /* skip corrupt line */ }
|
|
16
|
+
}
|
|
17
|
+
return out.reverse().slice(0, limit);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Build a compact history record from a run summary (deploy / upload / pull). */
|
|
21
|
+
function recordFromSummary({ action, site, trigger = 'ui', localDir, remoteDir, startedAt, summary = {}, error }) {
|
|
22
|
+
const plan = summary.plan || {};
|
|
23
|
+
return {
|
|
24
|
+
at: new Date().toISOString(), startedAt, action, site, trigger, ok: !error, error: error || undefined,
|
|
25
|
+
localDir, remoteDir, dryRun: !!summary.dryRun,
|
|
26
|
+
uploaded: summary.uploaded || undefined, downloaded: summary.downloaded || undefined, deleted: summary.deleted || undefined,
|
|
27
|
+
pruned: summary.pruned ? summary.pruned.map(p => p.name) : undefined, refused: Array.isArray(summary.refused) ? summary.refused.map(r => r.name) : undefined,
|
|
28
|
+
restoredFrom: action === 'restore' ? summary.backup : undefined,
|
|
29
|
+
restored: action === 'restore' ? summary.put : undefined, removed: action === 'restore' ? summary.remove : undefined,
|
|
30
|
+
refusedConflicts: summary.conflicts && summary.refused ? summary.conflicts.length : undefined,
|
|
31
|
+
backedUp: summary.backedUp ? summary.backedUp.length : undefined, backupDir: summary.backupDir || summary.undoBackupDir || undefined,
|
|
32
|
+
bytes: summary.bytes || 0,
|
|
33
|
+
unchanged: plan.skip ? plan.skip.length : undefined, extra: plan.extra ? plan.extra.length : undefined,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { appendHistory, readHistory, recordFromSummary };
|
package/src/manifest.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Local folder scan → manifest [{ path, size, mtime, sha256 }], with gitignore-style ignore rules.
|
|
3
|
+
const fs = require('node:fs/promises');
|
|
4
|
+
const { createReadStream } = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const crypto = require('node:crypto');
|
|
7
|
+
|
|
8
|
+
const DEFAULT_IGNORE = ['.git/', 'node_modules/', 'Thumbs.db', '.DS_Store', 'desktop.ini', '*.tmp', '.deployignore', '_backup-*/'];
|
|
9
|
+
const IGNORE_FILE = '.deployignore';
|
|
10
|
+
|
|
11
|
+
function parseIgnoreFile(text) {
|
|
12
|
+
return String(text || '').split(/\r?\n/).map(l => l.trim()).filter(l => l && !l.startsWith('#'));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function globToRegex(glob) {
|
|
16
|
+
let re = '';
|
|
17
|
+
for (const ch of glob) {
|
|
18
|
+
if (ch === '*') re += '[^/]*';
|
|
19
|
+
else if (ch === '?') re += '[^/]';
|
|
20
|
+
else re += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
21
|
+
}
|
|
22
|
+
return re;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** makeMatcher(globs) → (relPosixPath, isDir) => boolean */
|
|
26
|
+
function makeMatcher(globs) {
|
|
27
|
+
const rules = globs.map(g => {
|
|
28
|
+
let dirOnly = false, pattern = g;
|
|
29
|
+
if (pattern.endsWith('/')) { dirOnly = true; pattern = pattern.slice(0, -1); }
|
|
30
|
+
const anchored = pattern.includes('/');
|
|
31
|
+
if (anchored && pattern.startsWith('/')) pattern = pattern.slice(1);
|
|
32
|
+
const body = globToRegex(pattern);
|
|
33
|
+
// anchored patterns match from the root; bare names match at any depth
|
|
34
|
+
const re = anchored ? new RegExp('^' + body + '(/|$)') : new RegExp('(^|/)' + body + '(/|$)');
|
|
35
|
+
return { re, dirOnly };
|
|
36
|
+
});
|
|
37
|
+
return (rel, isDir) => {
|
|
38
|
+
for (const r of rules) {
|
|
39
|
+
const m = r.re.exec(rel);
|
|
40
|
+
if (!m) continue;
|
|
41
|
+
if (!r.dirOnly) return true;
|
|
42
|
+
// dir-only rule: matches if the matched segment is a directory (i.e. something follows it, or the entry itself is a dir)
|
|
43
|
+
const matchedToEnd = m[0].length === rel.length - m.index && !m[0].endsWith('/');
|
|
44
|
+
if (!matchedToEnd || isDir) return true;
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sha256File(p) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const h = crypto.createHash('sha256');
|
|
53
|
+
createReadStream(p).on('data', d => h.update(d)).on('end', () => resolve(h.digest('hex'))).on('error', reject);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* loadIgnore(dir, ignore) → (relPosixPath, isDir) => boolean
|
|
59
|
+
* The one set of ignore rules for a site: built-in defaults + the site's "ignore" list + the local .deployignore.
|
|
60
|
+
* Used for the local scan AND to filter the server listing, so an ignored path is hands-off in both directions
|
|
61
|
+
* (never uploaded, never pulled, never mirror-deleted).
|
|
62
|
+
*/
|
|
63
|
+
async function loadIgnore(dir, ignore = []) {
|
|
64
|
+
let globs = [...DEFAULT_IGNORE, ...(ignore || [])];
|
|
65
|
+
try { globs = globs.concat(parseIgnoreFile(await fs.readFile(path.join(dir, IGNORE_FILE), 'utf8'))); } catch { /* none */ }
|
|
66
|
+
return makeMatcher(globs);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** dropIgnored(remoteListing, ignored) → the listing without ignored files ([{ path, ... }], paths relative to the root) */
|
|
70
|
+
function dropIgnored(listing, ignored) {
|
|
71
|
+
return listing.filter(f => !ignored(f.path, false));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** scanLocal(dir, { ignore }) → sorted manifest */
|
|
75
|
+
async function scanLocal(dir, opts = {}) {
|
|
76
|
+
let st;
|
|
77
|
+
try { st = await fs.stat(dir); } catch (e) { throw new Error(`Local folder not found: ${dir}`); }
|
|
78
|
+
if (!st.isDirectory()) throw new Error(`Not a folder: ${dir}`);
|
|
79
|
+
const ignored = await loadIgnore(dir, opts.ignore);
|
|
80
|
+
const out = [];
|
|
81
|
+
async function walk(abs, rel) {
|
|
82
|
+
const entries = await fs.readdir(abs, { withFileTypes: true });
|
|
83
|
+
for (const e of entries) {
|
|
84
|
+
const r = rel ? rel + '/' + e.name : e.name;
|
|
85
|
+
if (e.isDirectory()) { if (!ignored(r, true)) await walk(path.join(abs, e.name), r); continue; }
|
|
86
|
+
if (!e.isFile()) continue;
|
|
87
|
+
if (ignored(r, false)) continue;
|
|
88
|
+
const full = path.join(abs, e.name);
|
|
89
|
+
const s = await fs.stat(full);
|
|
90
|
+
out.push({ path: r, size: s.size, mtime: Math.floor(s.mtimeMs), sha256: await sha256File(full) });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
await walk(dir, '');
|
|
94
|
+
out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { scanLocal, loadIgnore, dropIgnored, makeMatcher, parseIgnoreFile, DEFAULT_IGNORE, IGNORE_FILE, sha256File };
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// MCP server (stdio, JSON-RPC 2.0, one message per line) so an AI agent can deploy, check and undo.
|
|
3
|
+
//
|
|
4
|
+
// Safety is in what is NOT here: no mirror (deleting server files), no backup switch, no pruning of
|
|
5
|
+
// backups. Every deploy is backed up, every deploy can be restored. On top of that:
|
|
6
|
+
// · deploy and restore need the planId from preview_deploy / preview_restore. If anything changed
|
|
7
|
+
// in between (a local edit, someone editing the live site), the ids differ and nothing runs.
|
|
8
|
+
// · restore refuses when a live file was edited after the run being undone, unless force is passed.
|
|
9
|
+
// · passwords never pass through the model: they come from the OS keychain (stored by the CLI or
|
|
10
|
+
// the app the first time) or SFTP_PASSWORD in the server's environment.
|
|
11
|
+
const crypto = require('node:crypto');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const fs = require('node:fs/promises');
|
|
14
|
+
const { loadSites } = require('./config');
|
|
15
|
+
const { createCredentialStore } = require('./credentials');
|
|
16
|
+
const { SftpRemoteFs } = require('./remote-fs');
|
|
17
|
+
const { previewDeploy, runDeploy } = require('./deploy');
|
|
18
|
+
const { runPull } = require('./pull');
|
|
19
|
+
const { runRestore, describeBackups } = require('./restore');
|
|
20
|
+
const { appendHistory, readHistory, recordFromSummary } = require('./history');
|
|
21
|
+
|
|
22
|
+
const VERSION = require('../package.json').version;
|
|
23
|
+
const PROTOCOLS = ['2025-06-18', '2025-03-26', '2024-11-05'];
|
|
24
|
+
const LIST_MAX = 200;
|
|
25
|
+
|
|
26
|
+
const hash = v => crypto.createHash('sha256').update(JSON.stringify(v)).digest('hex').slice(0, 16);
|
|
27
|
+
const cap = list => list.length > LIST_MAX ? [...list.slice(0, LIST_MAX), `… and ${list.length - LIST_MAX} more`] : list;
|
|
28
|
+
|
|
29
|
+
const SITE = { site: { type: 'string', description: 'Site name from sites.json (see list_sites)' } };
|
|
30
|
+
const LOCAL = { localDir: { type: 'string', description: "Local folder to deploy from / compare with. Defaults to the site's localDir." } };
|
|
31
|
+
const TOOLS = [
|
|
32
|
+
{ name: 'list_sites', description: 'List the configured sites: name, host, remote root and default local folder. No passwords.',
|
|
33
|
+
inputSchema: { type: 'object', properties: {} } },
|
|
34
|
+
{ name: 'preview_deploy', description: 'Show what a deploy would upload (new and changed files). Changes nothing. Returns a planId that deploy needs.',
|
|
35
|
+
inputSchema: { type: 'object', properties: { ...SITE, ...LOCAL }, required: ['site'] } },
|
|
36
|
+
{ name: 'deploy', description: 'Upload the previewed plan. Every file it overwrites is backed up on the server first, and the whole deploy can be undone with restore. Never deletes server files. Refuses if the plan changed since preview_deploy.',
|
|
37
|
+
inputSchema: { type: 'object', properties: { ...SITE, ...LOCAL, planId: { type: 'string', description: 'planId from preview_deploy' } }, required: ['site', 'planId'] } },
|
|
38
|
+
{ name: 'check_server_changes', description: "Before editing a site's local files: list files that are different or only on the server (someone edited the live site). Changes nothing.",
|
|
39
|
+
inputSchema: { type: 'object', properties: { ...SITE, ...LOCAL }, required: ['site'] } },
|
|
40
|
+
{ name: 'pull', description: 'Download the server-side changes into the local folder, so local is the truth before editing. Local files it overwrites are backed up in <localDir>/_backup-<stamp>/. Never deletes local files.',
|
|
41
|
+
inputSchema: { type: 'object', properties: { ...SITE, ...LOCAL }, required: ['site'] } },
|
|
42
|
+
{ name: 'list_backups', description: 'List the backups on the server, newest first, with what each run overwrote, added and deleted.',
|
|
43
|
+
inputSchema: { type: 'object', properties: { ...SITE }, required: ['site'] } },
|
|
44
|
+
{ name: 'preview_restore', description: 'Show what restoring a backup would put back and remove, and any conflicts (live files edited after that run). Changes nothing. Returns a planId that restore needs.',
|
|
45
|
+
inputSchema: { type: 'object', properties: { ...SITE, backup: { type: 'string', description: 'Backup folder name, e.g. _backup-2026-10-18-1400. Default: the newest.' } }, required: ['site'] } },
|
|
46
|
+
{ name: 'restore', description: 'Undo a deploy: put the backed-up files back and remove the files that run added. Backs up the current live files first, so the restore can itself be restored. Refuses on conflicts unless force is true; only pass force when the user has seen the conflicts and said to go ahead.',
|
|
47
|
+
inputSchema: { type: 'object', properties: { ...SITE, backup: { type: 'string', description: 'Backup folder name from preview_restore' }, planId: { type: 'string', description: 'planId from preview_restore' }, force: { type: 'boolean', description: 'Restore over files edited on the server since that run (they are backed up first)' } }, required: ['site', 'backup', 'planId'] } },
|
|
48
|
+
{ name: 'history', description: 'Recent deploys, pulls and restores from this machine.',
|
|
49
|
+
inputSchema: { type: 'object', properties: { site: { type: 'string' }, limit: { type: 'number' } } } },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
class ToolError extends Error {}
|
|
53
|
+
|
|
54
|
+
/** createMcp({ sitesFile, historyFile, credentials, connect, env, now }) → { handle(message) → response | null } */
|
|
55
|
+
function createMcp(opts = {}) {
|
|
56
|
+
const sitesFile = opts.sitesFile || process.env.SHIPBACK_SITES || path.join(process.cwd(), 'sites.json');
|
|
57
|
+
const historyFile = opts.historyFile || path.join(path.dirname(sitesFile), 'deploys.jsonl');
|
|
58
|
+
const env = opts.env || process.env;
|
|
59
|
+
const now = opts.now || (() => new Date());
|
|
60
|
+
let credentials = opts.credentials;
|
|
61
|
+
const store = () => credentials || (credentials = createCredentialStore().store);
|
|
62
|
+
|
|
63
|
+
async function getSite(name) {
|
|
64
|
+
if (typeof name !== 'string' || !name) throw new ToolError('Say which site: "site" is required (see list_sites)');
|
|
65
|
+
const { sites } = await loadSites(sitesFile);
|
|
66
|
+
const s = sites.find(x => x.name === name);
|
|
67
|
+
if (!s) throw new ToolError(`Unknown site "${name}". Known: ${sites.map(x => x.name).join(', ') || '(none — add one to ' + sitesFile + ')'}`);
|
|
68
|
+
return s;
|
|
69
|
+
}
|
|
70
|
+
async function connect(site) {
|
|
71
|
+
if (opts.connect) return opts.connect(site);
|
|
72
|
+
const o = { host: site.host, port: site.port, username: site.username };
|
|
73
|
+
if (site.privateKeyPath) o.privateKey = await fs.readFile(site.privateKeyPath);
|
|
74
|
+
else {
|
|
75
|
+
o.password = env.SFTP_PASSWORD ?? await store().get(site.name);
|
|
76
|
+
if (o.password == null) throw new ToolError(`No stored password for "${site.name}". Ask the user to run this once in a terminal, which stores it in the OS keychain: shipback ${site.name} --backups (or node bin/cli.js ${site.name} --backups from a clone)`);
|
|
77
|
+
}
|
|
78
|
+
return SftpRemoteFs.connect(o);
|
|
79
|
+
}
|
|
80
|
+
async function withSite(args, fn) {
|
|
81
|
+
const site = await getSite(args.site);
|
|
82
|
+
const rfs = await connect(site);
|
|
83
|
+
try { return await fn(site, rfs); } finally { await rfs.close(); }
|
|
84
|
+
}
|
|
85
|
+
const record = rec => appendHistory(historyFile, recordFromSummary({ trigger: 'mcp', ...rec })).catch(() => {});
|
|
86
|
+
const localOf = (site, args) => {
|
|
87
|
+
const d = args.localDir || site.localDir;
|
|
88
|
+
if (!d) throw new ToolError(`No local folder: pass "localDir" or set localDir for "${site.name}" in sites.json`);
|
|
89
|
+
return d;
|
|
90
|
+
};
|
|
91
|
+
const deployPlanId = (site, localDir, local, plan) => {
|
|
92
|
+
const sha = new Map(local.map(f => [f.path, f.sha256]));
|
|
93
|
+
return hash({ site: site.name, localDir, root: site.remoteRoot, upload: plan.upload.map(u => [u.path, u.reason, sha.get(u.path)]) });
|
|
94
|
+
};
|
|
95
|
+
const restorePlanId = (site, s) => hash({ site: site.name, root: site.remoteRoot, backup: s.backup, put: s.put, remove: s.remove, conflicts: s.conflicts });
|
|
96
|
+
|
|
97
|
+
const tools = {
|
|
98
|
+
async list_sites() {
|
|
99
|
+
const { sites } = await loadSites(sitesFile);
|
|
100
|
+
return { sitesFile, sites: sites.map(s => ({ name: s.name, host: s.host, port: s.port, remoteRoot: s.remoteRoot, localDir: s.localDir })) };
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
preview_deploy: args => withSite(args, async (site, rfs) => {
|
|
104
|
+
const localDir = localOf(site, args);
|
|
105
|
+
const { local, plan } = await previewDeploy({ localDir, remoteRoot: site.remoteRoot, remoteFs: rfs, ignore: site.ignore });
|
|
106
|
+
return {
|
|
107
|
+
planId: deployPlanId(site, localDir, local, plan), site: site.name, from: localDir, to: `${site.host}:${site.remoteRoot}`,
|
|
108
|
+
upload: cap(plan.upload.map(u => `${u.reason === 'new' ? '+' : '~'} ${u.path}`)), uploadBytes: plan.uploadBytes,
|
|
109
|
+
unchanged: plan.skip.length, onlyOnServer: plan.extra.length,
|
|
110
|
+
note: plan.upload.length ? `Files marked ~ will be backed up on the server before they are replaced. Nothing is deleted. Call deploy with this planId to go ahead.` : 'Nothing to upload.',
|
|
111
|
+
};
|
|
112
|
+
}),
|
|
113
|
+
|
|
114
|
+
deploy: args => withSite(args, async (site, rfs) => {
|
|
115
|
+
const localDir = localOf(site, args);
|
|
116
|
+
const { local, plan } = await previewDeploy({ localDir, remoteRoot: site.remoteRoot, remoteFs: rfs, ignore: site.ignore });
|
|
117
|
+
if (deployPlanId(site, localDir, local, plan) !== args.planId) throw new ToolError('The plan changed since preview_deploy (a local file or the server changed). Nothing was uploaded. Call preview_deploy again and check the new plan.');
|
|
118
|
+
if (!plan.upload.length) return { uploaded: [], note: 'Nothing to upload.' };
|
|
119
|
+
const startedAt = new Date().toISOString();
|
|
120
|
+
try {
|
|
121
|
+
const s = await runDeploy({ localDir, remoteRoot: site.remoteRoot, remoteFs: rfs, backup: true, mirror: false, ignore: site.ignore, now: now() });
|
|
122
|
+
await record({ action: 'deploy', site: site.name, localDir, remoteDir: site.remoteRoot, startedAt, summary: s });
|
|
123
|
+
const backup = s.backupDir && s.backupDir.split('/').pop();
|
|
124
|
+
return { uploaded: cap(s.uploaded), bytes: s.bytes, backedUp: cap(s.backedUp), backup, undo: backup ? `To undo this deploy: preview_restore with backup "${backup}", then restore.` : undefined };
|
|
125
|
+
} catch (e) { await record({ action: 'deploy', site: site.name, localDir, remoteDir: site.remoteRoot, startedAt, error: e.message }); throw e; }
|
|
126
|
+
}),
|
|
127
|
+
|
|
128
|
+
check_server_changes: args => withSite(args, async (site, rfs) => {
|
|
129
|
+
const localDir = localOf(site, args);
|
|
130
|
+
const s = await runPull({ localDir, remoteRoot: site.remoteRoot, remoteFs: rfs, dryRun: true, ignore: site.ignore });
|
|
131
|
+
const changed = s.plan.download;
|
|
132
|
+
return {
|
|
133
|
+
site: site.name, serverAhead: changed.length > 0,
|
|
134
|
+
differentOnServer: cap(changed.filter(d => d.reason === 'changed').map(d => d.path)),
|
|
135
|
+
onlyOnServer: cap(changed.filter(d => d.reason === 'new').map(d => d.path)),
|
|
136
|
+
onlyLocal: s.plan.extra.length, same: s.plan.skip.length,
|
|
137
|
+
note: changed.length ? 'The server has files the local folder does not match. If those are live edits to keep, call pull before editing.' : 'Local folder matches the server.',
|
|
138
|
+
};
|
|
139
|
+
}),
|
|
140
|
+
|
|
141
|
+
pull: args => withSite(args, async (site, rfs) => {
|
|
142
|
+
const localDir = localOf(site, args);
|
|
143
|
+
const startedAt = new Date().toISOString();
|
|
144
|
+
const s = await runPull({ localDir, remoteRoot: site.remoteRoot, remoteFs: rfs, backup: true, mirror: false, ignore: site.ignore, now: now() });
|
|
145
|
+
await record({ action: 'pull', site: site.name, localDir, remoteDir: site.remoteRoot, startedAt, summary: s });
|
|
146
|
+
return { downloaded: cap(s.downloaded), bytes: s.bytes, localBackup: s.backupDir || undefined };
|
|
147
|
+
}),
|
|
148
|
+
|
|
149
|
+
list_backups: args => withSite(args, async (site, rfs) => ({ site: site.name, backups: await describeBackups({ remoteRoot: site.remoteRoot, remoteFs: rfs }) })),
|
|
150
|
+
|
|
151
|
+
preview_restore: args => withSite(args, async (site, rfs) => {
|
|
152
|
+
const s = await runRestore({ remoteRoot: site.remoteRoot, remoteFs: rfs, name: args.backup, dryRun: true });
|
|
153
|
+
return {
|
|
154
|
+
planId: restorePlanId(site, s), site: site.name, backup: s.backup, undoes: s.action, legacy: s.legacy || undefined,
|
|
155
|
+
putBack: cap(s.put), remove: cap(s.remove), alreadyRight: s.same.length, conflicts: s.conflicts,
|
|
156
|
+
note: s.conflicts.length ? 'Some live files were changed after that run. Show the user the conflicts; restore refuses them unless force is true.'
|
|
157
|
+
: s.put.length || s.remove.length ? 'Call restore with this backup and planId to go ahead. The current live files are backed up first.' : 'The site already matches this backup.',
|
|
158
|
+
};
|
|
159
|
+
}),
|
|
160
|
+
|
|
161
|
+
restore: args => withSite(args, async (site, rfs) => {
|
|
162
|
+
if (typeof args.backup !== 'string' || !args.backup) throw new ToolError('Say which backup: "backup" from preview_restore');
|
|
163
|
+
const dry = await runRestore({ remoteRoot: site.remoteRoot, remoteFs: rfs, name: args.backup, dryRun: true });
|
|
164
|
+
if (restorePlanId(site, dry) !== args.planId) throw new ToolError('The restore plan changed since preview_restore (the live site changed). Nothing was restored. Call preview_restore again.');
|
|
165
|
+
const startedAt = new Date().toISOString();
|
|
166
|
+
const s = await runRestore({ remoteRoot: site.remoteRoot, remoteFs: rfs, name: args.backup, force: args.force === true, now: now() });
|
|
167
|
+
await record({ action: 'restore', site: site.name, remoteDir: site.remoteRoot, startedAt, summary: s });
|
|
168
|
+
if (s.refused) throw new ToolError(`Refused: ${s.conflicts.length} live file(s) changed after that run, so nothing was restored: ${s.conflicts.map(c => `${c.path} (${c.why})`).join('; ')}. Only pass force: true if the user says so.`);
|
|
169
|
+
const undo = s.undoBackupDir && s.undoBackupDir.split('/').pop();
|
|
170
|
+
return { putBack: cap(s.put), removed: cap(s.remove), undo: undo ? `To undo this restore: preview_restore with backup "${undo}", then restore.` : undefined };
|
|
171
|
+
}),
|
|
172
|
+
|
|
173
|
+
async history(args) {
|
|
174
|
+
return { history: await readHistory(historyFile, { site: args.site || undefined, limit: Math.min(Number(args.limit) || 20, 100) }) };
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
async function callTool(name, args) {
|
|
179
|
+
if (!Object.hasOwn(tools, name)) return { content: [{ type: 'text', text: `Unknown tool "${name}"` }], isError: true };
|
|
180
|
+
try {
|
|
181
|
+
const out = await tools[name](args || {});
|
|
182
|
+
return { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] };
|
|
183
|
+
} catch (e) {
|
|
184
|
+
return { content: [{ type: 'text', text: e.message }], isError: true };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function handle(msg) {
|
|
189
|
+
const reply = result => ({ jsonrpc: '2.0', id: msg.id, result });
|
|
190
|
+
const fail = (code, message) => ({ jsonrpc: '2.0', id: msg.id ?? null, error: { code, message } });
|
|
191
|
+
if (!msg || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') return fail(-32600, 'Invalid request');
|
|
192
|
+
const isNotification = msg.id === undefined;
|
|
193
|
+
switch (msg.method) {
|
|
194
|
+
case 'initialize': {
|
|
195
|
+
const asked = msg.params && msg.params.protocolVersion;
|
|
196
|
+
return reply({
|
|
197
|
+
protocolVersion: PROTOCOLS.includes(asked) ? asked : PROTOCOLS[0],
|
|
198
|
+
capabilities: { tools: {} },
|
|
199
|
+
serverInfo: { name: 'shipback-sftp', version: VERSION },
|
|
200
|
+
instructions: 'Deploy to shared hosting over SFTP. preview_deploy then deploy; every deploy is backed up and can be undone with preview_restore then restore. Before editing a site, check_server_changes (and pull if the server is ahead).',
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
case 'ping': return reply({});
|
|
204
|
+
case 'tools/list': return reply({ tools: TOOLS });
|
|
205
|
+
case 'tools/call': return reply(await callTool(msg.params && msg.params.name, msg.params && msg.params.arguments));
|
|
206
|
+
default:
|
|
207
|
+
if (isNotification) return null; // notifications/initialized and friends need no answer
|
|
208
|
+
return fail(-32601, `Method not found: ${msg.method}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return { handle, TOOLS };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Wire createMcp to stdin/stdout, newline-delimited JSON. Logs go to stderr only. */
|
|
216
|
+
function serveStdio(mcp, { input = process.stdin, output = process.stdout } = {}) {
|
|
217
|
+
let buf = '';
|
|
218
|
+
let chain = Promise.resolve();
|
|
219
|
+
input.setEncoding('utf8');
|
|
220
|
+
input.on('data', chunk => {
|
|
221
|
+
buf += chunk;
|
|
222
|
+
let i;
|
|
223
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
224
|
+
const line = buf.slice(0, i).trim(); buf = buf.slice(i + 1);
|
|
225
|
+
if (!line) continue;
|
|
226
|
+
chain = chain.then(async () => {
|
|
227
|
+
let msg;
|
|
228
|
+
try { msg = JSON.parse(line); } catch { output.write(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }) + '\n'); return; }
|
|
229
|
+
const res = await mcp.handle(msg).catch(e => ({ jsonrpc: '2.0', id: msg.id ?? null, error: { code: -32603, message: e.message } }));
|
|
230
|
+
if (res) output.write(JSON.stringify(res) + '\n');
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
return new Promise(resolve => input.on('end', () => chain.then(resolve)));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
module.exports = { createMcp, serveStdio, TOOLS };
|