shipback-sftp 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mcp.js ADDED
@@ -0,0 +1,240 @@
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
+ resumes: s.resumed || undefined,
157
+ note: s.stoppedEarly ? 'That run was cut off while saving its backups, before it changed any live file. There is nothing to undo.'
158
+ : s.conflicts.length ? 'Some live files were changed after that run. Show the user the conflicts; restore refuses them unless force is true.'
159
+ : s.put.length || s.remove.length ? 'Call restore with this backup and planId to go ahead. The current live files are backed up first.' : 'The site already matches this backup.',
160
+ };
161
+ }),
162
+
163
+ restore: args => withSite(args, async (site, rfs) => {
164
+ if (typeof args.backup !== 'string' || !args.backup) throw new ToolError('Say which backup: "backup" from preview_restore');
165
+ const dry = await runRestore({ remoteRoot: site.remoteRoot, remoteFs: rfs, name: args.backup, dryRun: true });
166
+ 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.');
167
+ const startedAt = new Date().toISOString();
168
+ const s = await runRestore({ remoteRoot: site.remoteRoot, remoteFs: rfs, name: args.backup, force: args.force === true, now: now() });
169
+ await record({ action: 'restore', site: site.name, remoteDir: site.remoteRoot, startedAt, summary: s });
170
+ 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.`);
171
+ const undo = s.undoBackupDir && s.undoBackupDir.split('/').pop();
172
+ return { putBack: cap(s.put), removed: cap(s.remove), undo: undo ? `To undo this restore: preview_restore with backup "${undo}", then restore.` : undefined };
173
+ }),
174
+
175
+ async history(args) {
176
+ return { history: await readHistory(historyFile, { site: args.site || undefined, limit: Math.min(Number(args.limit) || 20, 100) }) };
177
+ },
178
+ };
179
+
180
+ async function callTool(name, args) {
181
+ if (!Object.hasOwn(tools, name)) return { content: [{ type: 'text', text: `Unknown tool "${name}"` }], isError: true };
182
+ try {
183
+ const out = await tools[name](args || {});
184
+ return { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] };
185
+ } catch (e) {
186
+ return { content: [{ type: 'text', text: e.undo ? `${e.message}
187
+ ${e.undo} Call preview_restore to see it.` : e.message }], isError: true };
188
+ }
189
+ }
190
+
191
+ async function handle(msg) {
192
+ const reply = result => ({ jsonrpc: '2.0', id: msg.id, result });
193
+ const fail = (code, message) => ({ jsonrpc: '2.0', id: msg.id ?? null, error: { code, message } });
194
+ if (!msg || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') return fail(-32600, 'Invalid request');
195
+ const isNotification = msg.id === undefined;
196
+ switch (msg.method) {
197
+ case 'initialize': {
198
+ const asked = msg.params && msg.params.protocolVersion;
199
+ return reply({
200
+ protocolVersion: PROTOCOLS.includes(asked) ? asked : PROTOCOLS[0],
201
+ capabilities: { tools: {} },
202
+ serverInfo: { name: 'shipback-sftp', version: VERSION },
203
+ 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).',
204
+ });
205
+ }
206
+ case 'ping': return reply({});
207
+ case 'tools/list': return reply({ tools: TOOLS });
208
+ case 'tools/call': return reply(await callTool(msg.params && msg.params.name, msg.params && msg.params.arguments));
209
+ default:
210
+ if (isNotification) return null; // notifications/initialized and friends need no answer
211
+ return fail(-32601, `Method not found: ${msg.method}`);
212
+ }
213
+ }
214
+
215
+ return { handle, TOOLS };
216
+ }
217
+
218
+ /** Wire createMcp to stdin/stdout, newline-delimited JSON. Logs go to stderr only. */
219
+ function serveStdio(mcp, { input = process.stdin, output = process.stdout } = {}) {
220
+ let buf = '';
221
+ let chain = Promise.resolve();
222
+ input.setEncoding('utf8');
223
+ input.on('data', chunk => {
224
+ buf += chunk;
225
+ let i;
226
+ while ((i = buf.indexOf('\n')) >= 0) {
227
+ const line = buf.slice(0, i).trim(); buf = buf.slice(i + 1);
228
+ if (!line) continue;
229
+ chain = chain.then(async () => {
230
+ let msg;
231
+ try { msg = JSON.parse(line); } catch { output.write(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }) + '\n'); return; }
232
+ const res = await mcp.handle(msg).catch(e => ({ jsonrpc: '2.0', id: msg.id ?? null, error: { code: -32603, message: e.message } }));
233
+ if (res) output.write(JSON.stringify(res) + '\n');
234
+ });
235
+ }
236
+ });
237
+ return new Promise(resolve => input.on('end', () => chain.then(resolve)));
238
+ }
239
+
240
+ module.exports = { createMcp, serveStdio, TOOLS };
package/src/prune.js ADDED
@@ -0,0 +1,105 @@
1
+ 'use strict';
2
+ // Prune: remove _backup-<stamp>/ folders the deploys left on the server.
3
+ //
4
+ // Safety rule — a backup folder is only removed when every file in it still exists
5
+ // byte-for-byte somewhere else on the server: live at the same path under the root, or
6
+ // at the same path inside one of the backups being kept. A folder holding the only copy
7
+ // of anything is refused (listed in summary.refused with the unique paths) unless
8
+ // { force: true } is passed. Nothing is ever removed outside a _backup-* folder.
9
+ //
10
+ // runPrune({ remoteRoot, remoteFs, names?, keep?, dryRun, force, onEvent })
11
+ // names: exact folder names to remove ('_backup-2026-09-24-2352', …)
12
+ // keep: or, keep the newest N backups and remove the rest (stamps sort by name)
13
+ // → { dryRun, backups, targets, pruned: [{ name, files, bytes }], refused: [{ name, unique }], deleted, bytes }
14
+ const crypto = require('node:crypto');
15
+ const { BACKUP_PREFIX } = require('./diff');
16
+ const { withLock } = require('./lock');
17
+ const { BACKUP_INFO } = require('./backup');
18
+
19
+ const sha256 = buf => crypto.createHash('sha256').update(buf).digest('hex');
20
+
21
+ async function listBackups(remoteFs, root) {
22
+ const st = await remoteFs.stat(root);
23
+ if (!st || !st.isDir) throw new Error(`Remote folder not found: ${root}`);
24
+ return (await remoteFs.listDir(root)).filter(e => e.type === 'dir' && e.name.startsWith(BACKUP_PREFIX)).map(e => e.name).sort();
25
+ }
26
+
27
+ /** All directories under dir (absolute paths), deepest last. */
28
+ async function walkDirs(remoteFs, dir) {
29
+ const out = [];
30
+ const walk = async abs => {
31
+ for (const e of await remoteFs.listDir(abs)) if (e.type === 'dir') { const p = abs + '/' + e.name; out.push(p); await walk(p); }
32
+ };
33
+ await walk(dir);
34
+ return out;
35
+ }
36
+
37
+ function chooseTargets(backups, { names, keep }) {
38
+ if (Array.isArray(names) && names.length) {
39
+ const targets = [];
40
+ for (const n of names) {
41
+ if (typeof n !== 'string' || !n.startsWith(BACKUP_PREFIX) || n.includes('/') || n.includes('..')) throw new Error(`Not a backup folder name: "${n}" (must be ${BACKUP_PREFIX}<stamp>)`);
42
+ if (!backups.includes(n)) throw new Error(`No such backup on the server: ${n}`);
43
+ if (!targets.includes(n)) targets.push(n);
44
+ }
45
+ return targets;
46
+ }
47
+ if (Number.isInteger(keep) && keep >= 0) return backups.slice(0, Math.max(0, backups.length - keep)); // sorted ascending ⇒ oldest first
48
+ throw new Error('Say which backups to prune: "names": ["_backup-…"] or "keep": N (newest N stay)');
49
+ }
50
+
51
+ function runPrune(opts) {
52
+ if (opts.dryRun) return pruneNow(opts);
53
+ return withLock({ remoteFs: opts.remoteFs, root: opts.remoteRoot, action: 'prune', onEvent: opts.onEvent }, () => pruneNow(opts));
54
+ }
55
+
56
+ async function pruneNow({ remoteRoot, remoteFs, names, keep, dryRun = false, force = false, onEvent = () => {} }) {
57
+ const root = remoteRoot.replace(/\/$/, '');
58
+ onEvent({ type: 'scan', side: 'remote', dir: root });
59
+ const backups = await listBackups(remoteFs, root);
60
+ const targets = chooseTargets(backups, { names, keep });
61
+ const kept = backups.filter(b => !targets.includes(b));
62
+ const summary = { dryRun, backups, targets, kept, pruned: [], refused: [], deleted: 0, bytes: 0 };
63
+ onEvent({ type: 'prune-plan', backups, targets, kept });
64
+
65
+ const hashCache = new Map();
66
+ const hashOf = async abs => {
67
+ if (hashCache.has(abs)) return hashCache.get(abs);
68
+ let h = null;
69
+ try { h = sha256(await remoteFs.readFile(abs)); } catch (e) { if (e.code !== 'ENOENT' && !/No such file/i.test(e.message)) throw e; }
70
+ hashCache.set(abs, h);
71
+ return h;
72
+ };
73
+
74
+ for (const name of targets) {
75
+ const dir = `${root}/${name}`;
76
+ const files = await remoteFs.listRecursive(dir);
77
+ const unique = [];
78
+ for (const f of files) {
79
+ if (f.path === BACKUP_INFO) continue; // the folder's own record, not a copy of a site file
80
+ const h = await hashOf(`${dir}/${f.path}`);
81
+ let twin = (await hashOf(`${root}/${f.path}`)) === h;
82
+ for (const k of kept) { if (twin) break; twin = (await hashOf(`${root}/${k}/${f.path}`)) === h; }
83
+ if (!twin) unique.push(f.path);
84
+ }
85
+ const bytes = files.reduce((s, f) => s + f.size, 0);
86
+ onEvent({ type: 'prune-check', name, files: files.length, bytes, unique: unique.length });
87
+ if (unique.length && !force) {
88
+ summary.refused.push({ name, unique });
89
+ onEvent({ type: 'refuse', name, unique });
90
+ continue;
91
+ }
92
+ if (!dryRun) {
93
+ for (const f of files) { await remoteFs.remove(`${dir}/${f.path}`); onEvent({ type: 'delete', path: `${name}/${f.path}` }); }
94
+ const dirs = await walkDirs(remoteFs, dir);
95
+ for (const d of dirs.reverse()) { await remoteFs.rmdir(d); onEvent({ type: 'rmdir', path: d.slice(root.length + 1) }); }
96
+ await remoteFs.rmdir(dir); onEvent({ type: 'rmdir', path: name });
97
+ }
98
+ summary.pruned.push({ name, files: files.length, bytes, forced: unique.length > 0 });
99
+ summary.deleted += files.length; summary.bytes += bytes;
100
+ }
101
+ onEvent({ type: 'done', summary });
102
+ return summary;
103
+ }
104
+
105
+ module.exports = { runPrune, listBackups, chooseTargets };
package/src/pull.js ADDED
@@ -0,0 +1,131 @@
1
+ 'use strict';
2
+ // Pull: server → local. Downloads new/changed files into the local folder, backing up anything it
3
+ // overwrites into <localDir>/_backup-<stamp>/. Never writes to the server.
4
+ const fs = require('node:fs/promises');
5
+ const path = require('node:path');
6
+ const crypto = require('node:crypto');
7
+ const { scanLocal, loadIgnore, dropIgnored } = require('./manifest');
8
+ const { MANIFEST_NAME, BACKUP_PREFIX } = require('./diff');
9
+ const { makeStamp } = require('./backup');
10
+
11
+ const isBackupDir = name => name.startsWith(BACKUP_PREFIX);
12
+ const VERIFY_MAX = 8 * 1024 * 1024;
13
+
14
+ async function readRemoteManifest(remoteFs, root) {
15
+ try { return JSON.parse((await remoteFs.readFile(`${root}/${MANIFEST_NAME}`)).toString('utf8')); } catch { return null; }
16
+ }
17
+
18
+ /**
19
+ * planPull({ localFiles, remoteListing, remoteManifest, mirror })
20
+ * → { download: [{ path, size, reason }], verify: [{ path, size, localSha }], skip: [path], extra: [path], delete: [path] }
21
+ */
22
+ function planPull({ localFiles, remoteListing, remoteManifest, mirror = false }) {
23
+ const local = new Map(localFiles.map(f => [f.path, f]));
24
+ const manifest = (remoteManifest && remoteManifest.files) || {};
25
+ const deployedAt = remoteManifest && remoteManifest.deployedAt ? Date.parse(remoteManifest.deployedAt) : NaN;
26
+ const download = [], verify = [], skip = [];
27
+ const remoteSet = new Set();
28
+ for (const r of [...remoteListing].sort((a, b) => a.path.localeCompare(b.path))) {
29
+ if (r.path === MANIFEST_NAME) continue;
30
+ remoteSet.add(r.path);
31
+ const l = local.get(r.path);
32
+ if (!l) { download.push({ path: r.path, size: r.size, reason: 'new' }); continue; }
33
+ if (l.size !== r.size) { download.push({ path: r.path, size: r.size, reason: 'changed' }); continue; }
34
+ const m = manifest[r.path];
35
+ // We deployed this exact content and the server copy hasn't been touched since → unchanged without reading it.
36
+ const trusted = m && m.sha256 === l.sha256 && Number.isFinite(deployedAt) && r.mtime <= deployedAt + 60_000;
37
+ if (trusted) skip.push(r.path); else verify.push({ path: r.path, size: r.size, localSha: l.sha256 });
38
+ }
39
+ const extra = [...local.keys()].filter(p => !remoteSet.has(p)).sort();
40
+ return { download, verify, skip, extra, delete: mirror ? [...extra] : [] };
41
+ }
42
+
43
+ async function removeEmptyDirs(dir, stopAt) {
44
+ // walk up from dir removing empty folders until stopAt
45
+ let cur = dir;
46
+ while (cur.startsWith(stopAt) && cur !== stopAt) {
47
+ try { const ents = await fs.readdir(cur); if (ents.length) break; await fs.rmdir(cur); } catch { break; }
48
+ cur = path.dirname(cur);
49
+ }
50
+ }
51
+
52
+ /**
53
+ * runPull({ localDir, remoteRoot, remoteFs, dryRun, backup, mirror, ignore, now, onEvent })
54
+ * → { dryRun, plan, downloaded, backedUp, backupDir, deleted, bytes }
55
+ */
56
+ async function runPull({ localDir, remoteRoot, remoteFs, dryRun = false, backup = true, mirror = false, ignore, now = new Date(), onEvent = () => {} }) {
57
+ const root = remoteRoot.replace(/\/$/, '');
58
+ const summary = { dryRun, plan: null, downloaded: [], backedUp: [], backupDir: null, deleted: [], bytes: 0 };
59
+ try {
60
+ onEvent({ type: 'scan', side: 'remote', dir: root });
61
+ const rootStat = await remoteFs.stat(root);
62
+ if (!rootStat) throw new Error(`Remote folder not found on server: ${root}`);
63
+ if (!rootStat.isDir) throw new Error(`Remote path is a file, not a folder: ${root}`);
64
+ const fullListing = await remoteFs.listRecursive(root, { skipDir: isBackupDir });
65
+ const remoteManifest = await readRemoteManifest(remoteFs, root);
66
+ onEvent({ type: 'scan', side: 'local', dir: localDir });
67
+ await fs.mkdir(localDir, { recursive: true });
68
+ const localFiles = await scanLocal(localDir, { ignore });
69
+ // ignored paths are hands-off on the server too: never offered as "new", never pulled over local copies
70
+ const remoteListing = dropIgnored(fullListing, await loadIgnore(localDir, ignore));
71
+ const plan = planPull({ localFiles, remoteListing, remoteManifest, mirror });
72
+
73
+ // settle same-size files by reading the server copy (once; content is kept so it isn't fetched twice)
74
+ const fetched = new Map();
75
+ for (const v of plan.verify) {
76
+ if (v.size > VERIFY_MAX) { plan.skip.push(v.path); continue; }
77
+ onEvent({ type: 'verify', path: v.path });
78
+ const data = await remoteFs.readFile(`${root}/${v.path}`);
79
+ if (crypto.createHash('sha256').update(data).digest('hex') === v.localSha) plan.skip.push(v.path);
80
+ else { plan.download.push({ path: v.path, size: v.size, reason: 'changed' }); fetched.set(v.path, data); }
81
+ }
82
+ plan.verify = []; plan.download.sort((a, b) => a.path.localeCompare(b.path)); plan.skip.sort();
83
+ plan.downloadBytes = plan.download.reduce((n, d) => n + d.size, 0);
84
+ summary.plan = plan;
85
+ onEvent({ type: 'plan', plan, localCount: localFiles.length, remoteCount: remoteListing.length, direction: 'pull' });
86
+ if (dryRun) { onEvent({ type: 'done', summary }); return summary; }
87
+
88
+ // 1. local backup of files about to be overwritten or deleted
89
+ const stamp = makeStamp(now);
90
+ const backupDir = path.join(localDir, `${BACKUP_PREFIX}${stamp}`);
91
+ if (backup) {
92
+ const targets = [...plan.download.filter(d => d.reason === 'changed').map(d => d.path), ...plan.delete];
93
+ for (const rel of targets) {
94
+ const src = path.join(localDir, ...rel.split('/'));
95
+ const dest = path.join(backupDir, ...rel.split('/'));
96
+ await fs.mkdir(path.dirname(dest), { recursive: true });
97
+ await fs.copyFile(src, dest);
98
+ summary.backedUp.push(rel);
99
+ onEvent({ type: 'backup', path: rel, to: dest });
100
+ }
101
+ if (summary.backedUp.length) summary.backupDir = backupDir;
102
+ }
103
+
104
+ // 2. download
105
+ let i = 0;
106
+ for (const d of plan.download) {
107
+ const data = fetched.get(d.path) || await remoteFs.readFile(`${root}/${d.path}`);
108
+ const dest = path.join(localDir, ...d.path.split('/'));
109
+ await fs.mkdir(path.dirname(dest), { recursive: true });
110
+ await fs.writeFile(dest, data);
111
+ summary.downloaded.push(d.path); summary.bytes += data.length;
112
+ onEvent({ type: 'download', path: d.path, bytes: data.length, reason: d.reason, done: ++i, total: plan.download.length });
113
+ }
114
+
115
+ // 3. mirror deletes (local)
116
+ for (const rel of plan.delete) {
117
+ const p = path.join(localDir, ...rel.split('/'));
118
+ await fs.rm(p, { force: true });
119
+ await removeEmptyDirs(path.dirname(p), localDir);
120
+ summary.deleted.push(rel);
121
+ onEvent({ type: 'delete', path: rel, side: 'local' });
122
+ }
123
+ onEvent({ type: 'done', summary });
124
+ return summary;
125
+ } catch (err) {
126
+ onEvent({ type: 'error', message: err.message, summary });
127
+ throw err;
128
+ }
129
+ }
130
+
131
+ module.exports = { runPull, planPull };
package/src/queue.js ADDED
@@ -0,0 +1,107 @@
1
+ 'use strict';
2
+ // Drop-box queue: another process (an AI agent with no shell, a script, you) writes
3
+ // queue/<name>.request.json { action: "deploy"|"pull"|"preview", site, localDir?, remoteDir?, dryRun?, mirror?, backup?, note? }
4
+ // { action: "prune", site, remoteDir?, names?: ["_backup-…"], keep?: N, dryRun?, force?, note? }
5
+ // { action: "restore", site, remoteDir?, name?: "_backup-…" | "latest", dryRun?, force?, note? }
6
+ // { action: "backups", site, remoteDir? } (list the backups and what each holds)
7
+ // while the app is running. The watcher runs it and writes
8
+ // queue/<name>.result.json { ok, action, site, startedAt, finishedAt, summary | error }
9
+ // queue/<name>.log.txt the same lines the app's log panel shows
10
+ // and renames the request to <name>.request.done.json so it is never run twice.
11
+ const fs = require('node:fs/promises');
12
+ const path = require('node:path');
13
+
14
+ const QUEUE_SUFFIX = '.request.json';
15
+ const ACTIONS = new Set(['deploy', 'pull', 'preview', 'prune', 'restore', 'backups']);
16
+ const SITE_ACTIONS = ACTIONS;
17
+
18
+
19
+ function fmtEvent(ev) {
20
+ const t = new Date().toLocaleTimeString();
21
+ switch (ev.type) {
22
+ case 'scan': return `${t} scanning ${ev.side}: ${ev.dir}`;
23
+ case 'verify': return `${t} checking ${ev.path}`;
24
+ case 'plan': { const p = ev.plan; const n = (p.upload || p.download || []).length; return `${t} plan: ${n} to ${p.upload ? 'upload' : 'download'}, ${p.skip.length} unchanged, ${p.extra.length} extra, ${p.delete.length} to delete`; }
25
+ case 'mkdir': return `${t} created ${ev.path}`;
26
+ case 'backup': return `${t} backup ${ev.path} -> ${ev.to}`;
27
+ case 'upload': return `${t} upload ${ev.path} ${ev.done}/${ev.total}`;
28
+ case 'download': return `${t} download ${ev.path} ${ev.done}/${ev.total}`;
29
+ case 'delete': return `${t} delete ${ev.path}`;
30
+ case 'rmdir': return `${t} rmdir ${ev.path}`;
31
+ case 'prune-plan': return `${t} backups on server: ${ev.backups.length} — prune ${ev.targets.length} (${ev.targets.join(', ') || 'none'}), keep ${ev.kept.length}`;
32
+ case 'prune-check': return `${t} ${ev.name}: ${ev.files} files, ${ev.bytes} bytes, ${ev.unique} without a copy elsewhere`;
33
+ case 'refuse': return `${t} REFUSED ${ev.name}: only copy of ${ev.unique.slice(0, 5).join(', ')}${ev.unique.length > 5 ? ` and ${ev.unique.length - 5} more` : ''} — pass "force": true to remove anyway`;
34
+ case 'restore-plan': return `${t} restore ${ev.backup}${ev.legacy ? ' (no .backup.json: files only)' : ''}: ${ev.put.length} to put back, ${ev.remove.length} to remove, ${ev.same} already right, ${ev.conflicts} conflicts`;
35
+ case 'restore': return `${t} put back ${ev.path}`;
36
+ case 'conflict': return `${t} CONFLICT ${ev.path}: ${ev.why}`;
37
+ case 'manifest': return `${t} wrote ${ev.path}`;
38
+ case 'done': return `${t} done`;
39
+ case 'error': return `${t} ERROR ${ev.message}${ev.undo ? ` ${ev.undo}` : ''}`;
40
+ default: return `${t} ${ev.type}`;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * createQueueWatcher({ queueDir, runJob, pollMs, log })
46
+ * runJob({ request, name, onEvent }) → summary (throws on failure)
47
+ * → { start(), stop(), queueDir }
48
+ */
49
+ function createQueueWatcher({ queueDir, runJob, pollMs = 1500, log = console.log }) {
50
+ let timer = null, busy = false, stopped = false;
51
+ const retries = new Map(); // name → consecutive parse failures
52
+
53
+ async function scan() {
54
+ if (busy || stopped) return;
55
+ busy = true;
56
+ try {
57
+ let names;
58
+ try { names = (await fs.readdir(queueDir)).filter(n => n.endsWith(QUEUE_SUFFIX)).sort(); } catch { names = []; }
59
+ for (const file of names) {
60
+ if (stopped) break;
61
+ const name = file.slice(0, -QUEUE_SUFFIX.length);
62
+ const reqPath = path.join(queueDir, file);
63
+ let request;
64
+ try { request = JSON.parse(await fs.readFile(reqPath, 'utf8')); }
65
+ catch (e) {
66
+ const n = (retries.get(name) || 0) + 1; retries.set(name, n);
67
+ if (n < 8) continue; // probably still being written — try again next round
68
+ await finish(name, reqPath, { ok: false, error: `request.json is not valid JSON: ${e.message}`, startedAt: new Date().toISOString() }, []);
69
+ retries.delete(name); continue;
70
+ }
71
+ retries.delete(name);
72
+ await runOne(name, reqPath, request);
73
+ }
74
+ } finally { busy = false; }
75
+ }
76
+
77
+ async function runOne(name, reqPath, request) {
78
+ const startedAt = new Date().toISOString();
79
+ const lines = [];
80
+ const onEvent = ev => lines.push(fmtEvent(ev));
81
+ log(`[queue] ${name}: ${request.action} ${request.site || ''}`);
82
+ let result;
83
+ if (!ACTIONS.has(request.action)) result = { ok: false, error: `Unknown action "${request.action}" — use deploy, pull, preview, prune, restore or backups` };
84
+ else if (SITE_ACTIONS.has(request.action) && (!request.site || typeof request.site !== 'string')) result = { ok: false, error: 'Request is missing "site"' };
85
+ else {
86
+ try { const summary = await runJob({ request, name, onEvent }); result = { ok: true, summary }; }
87
+ catch (e) { result = { ok: false, error: e.message }; lines.push(`ERROR ${e.message}`); }
88
+ }
89
+ await finish(name, reqPath, { ...result, action: request.action, site: request.site, note: request.note, startedAt }, lines);
90
+ log(`[queue] ${name}: ${result.ok ? 'ok' : 'FAILED — ' + result.error}`);
91
+ }
92
+
93
+ async function finish(name, reqPath, result, lines) {
94
+ result.finishedAt = new Date().toISOString();
95
+ await fs.writeFile(path.join(queueDir, name + '.log.txt'), lines.join('\n') + '\n');
96
+ await fs.writeFile(path.join(queueDir, name + '.result.json'), JSON.stringify(result, null, 2) + '\n');
97
+ await fs.rename(reqPath, path.join(queueDir, name + '.request.done.json')).catch(() => {});
98
+ }
99
+
100
+ return {
101
+ queueDir,
102
+ async start() { await fs.mkdir(queueDir, { recursive: true }); stopped = false; timer = setInterval(scan, pollMs); scan(); },
103
+ async stop() { stopped = true; if (timer) clearInterval(timer); timer = null; while (busy) await new Promise(r => setTimeout(r, 20)); },
104
+ };
105
+ }
106
+
107
+ module.exports = { createQueueWatcher, QUEUE_SUFFIX, ACTIONS, SITE_ACTIONS };