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/backup.js ADDED
@@ -0,0 +1,94 @@
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 crypto = require('node:crypto');
6
+ const posix = require('node:path').posix;
7
+ const { BACKUP_PREFIX, MANIFEST_NAME } = require('./diff');
8
+ const { writeLive, isHidden } = require('./remote-fs');
9
+
10
+ const BACKUP_INFO = '.backup.json';
11
+
12
+ const pad = n => String(n).padStart(2, '0');
13
+ function makeStamp(d = new Date()) {
14
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
15
+ }
16
+
17
+ /** First _backup-<stamp> folder name not already on the server (two runs in one minute get -2, -3 …). */
18
+ async function freeBackupDir(remoteFs, root, stamp) {
19
+ root = root.replace(/\/$/, '');
20
+ let dir = `${root}/${BACKUP_PREFIX}${stamp}`;
21
+ for (let n = 2; await remoteFs.stat(dir); n++) dir = `${root}/${BACKUP_PREFIX}${stamp}-${n}`;
22
+ return dir;
23
+ }
24
+
25
+ const sha256 = buf => crypto.createHash('sha256').update(buf).digest('hex');
26
+
27
+ /**
28
+ * backupFiles({ remoteFs, root, paths, stamp | backupDir, onEvent }) → { backupDir, copied, was }
29
+ * was: Map path → sha256 of the copy saved, so restore can check the copy is still intact before using it
30
+ */
31
+ async function backupFiles({ remoteFs, root, paths, stamp = makeStamp(), backupDir, onEvent = () => {} }) {
32
+ root = root.replace(/\/$/, '');
33
+ backupDir = backupDir || `${root}/${BACKUP_PREFIX}${stamp}`;
34
+ const copied = [];
35
+ const was = new Map();
36
+ const madeDirs = new Set();
37
+ for (const rel of paths) {
38
+ const src = `${root}/${rel}`;
39
+ const st = await remoteFs.stat(src);
40
+ if (!st || st.isDir) continue;
41
+ const dest = `${backupDir}/${rel}`;
42
+ const dir = posix.dirname(dest);
43
+ if (!madeDirs.has(dir)) { await remoteFs.mkdirp(dir); madeDirs.add(dir); }
44
+ const data = await remoteFs.readFile(src);
45
+ await remoteFs.writeFile(dest, data);
46
+ copied.push(rel);
47
+ was.set(rel, sha256(data));
48
+ onEvent({ type: 'backup', path: rel, bytes: data.length, to: dest });
49
+ }
50
+ return { backupDir, copied, was };
51
+ }
52
+
53
+ /**
54
+ * writeBackupInfo({ remoteFs, backupDir, info })
55
+ * info: { action, at, overwritten: [{ path, sha256, was }], added: [{ path, sha256 }], deleted: [{ path, was }], from? }
56
+ * sha256 = the content the run left at that path, so a later restore can tell if it was edited since.
57
+ * was = the sha256 of the copy saved in the backup folder (from 0.2; older records have none).
58
+ * status (from 0.2): 'saving' while the copies are being made, so a run cut off then is known to have changed
59
+ * nothing; a restore also says 'running' while it writes, so cutting it off can be finished by running it again.
60
+ * No status = the record is whole. It is swapped in whole too, never left half written.
61
+ */
62
+ async function writeBackupInfo({ remoteFs, backupDir, info }) {
63
+ await remoteFs.mkdirp(backupDir);
64
+ await writeLive(remoteFs, `${backupDir}/${BACKUP_INFO}`, { data: Buffer.from(JSON.stringify({ version: 1, tool: 'shipback-sftp', ...info }, null, 1)) });
65
+ }
66
+
67
+ /** A path a record may name: relative, inside the site, and not one of Shipback's own files. */
68
+ function siteRelative(p) {
69
+ if (typeof p !== 'string' || !p || p.startsWith('/') || p.includes('\\') || p.includes('\0') || /^[A-Za-z]:/.test(p)) return false;
70
+ const segs = p.split('/');
71
+ return segs.every(s => s && s !== '.' && s !== '..' && !s.startsWith(BACKUP_PREFIX)) && p !== MANIFEST_NAME && !isHidden(p);
72
+ }
73
+
74
+ /**
75
+ * The run's record, or null if the folder has none (a backup made before .backup.json existed).
76
+ * A record that can't be read, or that names any path outside the site, throws: restore never guesses from it,
77
+ * and never falls back to putting files back unchecked.
78
+ */
79
+ async function readBackupInfo(remoteFs, backupDir) {
80
+ if (!(await remoteFs.stat(`${backupDir}/${BACKUP_INFO}`))) return null;
81
+ let info;
82
+ try { info = JSON.parse((await remoteFs.readFile(`${backupDir}/${BACKUP_INFO}`)).toString('utf8')); }
83
+ catch (e) { throw new Error(`${posix.basename(backupDir)}/${BACKUP_INFO} is damaged (${e.message.split('\n')[0]}), so it can't be restored safely`); }
84
+ if (!info || info.version !== 1) throw new Error(`${posix.basename(backupDir)}/${BACKUP_INFO} is not a record this version understands`);
85
+ for (const key of ['overwritten', 'added', 'deleted']) {
86
+ if (!Array.isArray(info[key])) throw new Error(`${posix.basename(backupDir)}/${BACKUP_INFO} is damaged (no "${key}" list), so it can't be restored safely`);
87
+ for (const e of info[key]) {
88
+ if (!e || !siteRelative(e.path)) throw new Error(`${posix.basename(backupDir)}/${BACKUP_INFO} names a path outside the site (${JSON.stringify(e && e.path)}). Nothing was changed`);
89
+ }
90
+ }
91
+ return info;
92
+ }
93
+
94
+ module.exports = { backupFiles, makeStamp, freeBackupDir, writeBackupInfo, readBackupInfo, siteRelative, 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 };
@@ -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,145 @@
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
+ const { writeLive } = require('./remote-fs');
9
+ const { withLock } = require('./lock');
10
+
11
+ const isBackupDir = name => name.startsWith(BACKUP_PREFIX);
12
+
13
+ async function readRemoteManifest(remoteFs, root) {
14
+ try { return JSON.parse((await remoteFs.readFile(`${root}/${MANIFEST_NAME}`)).toString('utf8')); }
15
+ catch { return null; }
16
+ }
17
+
18
+ /** Preview only: scan both sides and return the plan. */
19
+ async function previewDeploy({ localDir, remoteRoot, remoteFs, mirror = false, ignore, onEvent = () => {} }) {
20
+ const root = remoteRoot.replace(/\/$/, '');
21
+ onEvent({ type: 'scan', side: 'local', dir: localDir });
22
+ const local = await scanLocal(localDir, { ignore });
23
+ onEvent({ type: 'scan', side: 'remote', dir: root });
24
+ const rootStat = await remoteFs.stat(root);
25
+ if (rootStat && !rootStat.isDir) throw new Error(`Remote path is a file, not a folder: ${root}`);
26
+ // ignored paths are hands-off on the server too: never counted as extra, never mirror-deleted
27
+ const remoteListing = rootStat ? dropIgnored(await remoteFs.listRecursive(root, { skipDir: isBackupDir }), await loadIgnore(localDir, ignore)) : [];
28
+ const remoteManifest = rootStat ? await readRemoteManifest(remoteFs, root) : null;
29
+ const plan = makePlan({ local, remoteManifest, remoteListing, mirror });
30
+ if (plan.verify.length) await resolveVerify(plan, { readFile: p => remoteFs.readFile(`${root}/${p}`), onEvent });
31
+ onEvent({ type: 'plan', plan, localCount: local.length, remoteCount: remoteListing.length, remoteExists: !!rootStat });
32
+ return { local, plan, remoteExists: !!rootStat };
33
+ }
34
+
35
+ /**
36
+ * runDeploy({ localDir, remoteRoot, remoteFs, dryRun, backup, mirror, ignore, now, onEvent })
37
+ * → { dryRun, plan, uploaded, backedUp, backupDir, deleted, bytes }
38
+ */
39
+ async function runDeploy(opts) {
40
+ if (opts.dryRun) return deployNow(opts);
41
+ return withLock({ remoteFs: opts.remoteFs, root: opts.remoteRoot, action: 'deploy', createRoot: true, onEvent: opts.onEvent }, () => deployNow(opts));
42
+ }
43
+
44
+ async function deployNow({ localDir, remoteRoot, remoteFs, dryRun = false, backup = true, mirror = false, ignore, now = new Date(), onEvent = () => {} }) {
45
+ const root = remoteRoot.replace(/\/$/, '');
46
+ const summary = { dryRun, plan: null, uploaded: [], backedUp: [], backupDir: null, deleted: [], bytes: 0 };
47
+ try {
48
+ const { local, plan, remoteExists } = await previewDeploy({ localDir, remoteRoot: root, remoteFs, mirror, ignore, onEvent });
49
+ summary.plan = plan;
50
+ if (dryRun) { onEvent({ type: 'done', summary }); return summary; }
51
+
52
+ if (!remoteExists) { await remoteFs.mkdirp(root); onEvent({ type: 'mkdir', path: root }); }
53
+
54
+ // 1. backup everything about to be overwritten or deleted, plus .backup.json so restore can undo this run.
55
+ // The record says 'saving' until every copy is made: cut off before that, the run has changed nothing.
56
+ if (backup && (plan.upload.length || plan.delete.length)) {
57
+ const backupDir = await freeBackupDir(remoteFs, root, makeStamp(now));
58
+ await writeBackupInfo({ remoteFs, backupDir, info: { action: 'deploy', at: now.toISOString(), status: 'saving', overwritten: [], added: [], deleted: [] } });
59
+ const targets = [...plan.upload.filter(u => u.reason === 'changed').map(u => u.path), ...plan.delete];
60
+ const res = await backupFiles({ remoteFs, root, paths: targets, backupDir, onEvent });
61
+ const copied = new Set(res.copied);
62
+ const sha = new Map(local.map(f => [f.path, f.sha256]));
63
+ await writeBackupInfo({ remoteFs, backupDir, info: {
64
+ action: 'deploy', at: now.toISOString(),
65
+ overwritten: plan.upload.filter(u => copied.has(u.path)).map(u => ({ path: u.path, sha256: sha.get(u.path), was: res.was.get(u.path) })),
66
+ added: plan.upload.filter(u => !copied.has(u.path)).map(u => ({ path: u.path, sha256: sha.get(u.path) })),
67
+ deleted: plan.delete.filter(p => copied.has(p)).map(p => ({ path: p, was: res.was.get(p) })),
68
+ } });
69
+ summary.backedUp = res.copied;
70
+ summary.backupDir = backupDir;
71
+ }
72
+
73
+ // 2. upload new + changed, each swapped in whole (a dropped line leaves the old file, not half a new one)
74
+ const madeDirs = new Set([root]);
75
+ for (const u of plan.upload) {
76
+ const dest = `${root}/${u.path}`;
77
+ const dir = posix.dirname(dest);
78
+ if (!madeDirs.has(dir)) { await remoteFs.mkdirp(dir); madeDirs.add(dir); }
79
+ await writeLive(remoteFs, dest, { localPath: path.join(localDir, ...u.path.split('/')) });
80
+ summary.uploaded.push(u.path); summary.bytes += u.size;
81
+ onEvent({ type: 'upload', path: u.path, bytes: u.size, reason: u.reason, done: summary.uploaded.length, total: plan.upload.length });
82
+ }
83
+
84
+ // 3. mirror deletes
85
+ for (const p of plan.delete) {
86
+ await remoteFs.remove(`${root}/${p}`);
87
+ summary.deleted.push(p);
88
+ onEvent({ type: 'delete', path: p });
89
+ }
90
+
91
+ // 4. manifest for next time
92
+ const files = {};
93
+ for (const f of local) files[f.path] = { sha256: f.sha256, size: f.size, mtime: f.mtime };
94
+ await remoteFs.writeFile(`${root}/${MANIFEST_NAME}`, JSON.stringify({ version: 1, deployedAt: new Date().toISOString(), files }, null, 1));
95
+ onEvent({ type: 'manifest', path: MANIFEST_NAME, count: local.length });
96
+ onEvent({ type: 'done', summary });
97
+ return summary;
98
+ } catch (err) {
99
+ // a deploy that stopped part way is undone like any other: everything it touched is in its backup record
100
+ if (summary.backupDir && summary.uploaded.length) err.undo = `${summary.uploaded.length} of ${summary.plan.upload.length} files went up before it stopped. --restore undoes them (${posix.basename(summary.backupDir)}).`;
101
+ onEvent({ type: 'error', message: err.message, undo: err.undo, summary });
102
+ throw err;
103
+ }
104
+ }
105
+
106
+ /** Ad-hoc upload of named local files into a remote dir (with backup of anything overwritten). */
107
+ function uploadFiles(opts) {
108
+ return withLock({ remoteFs: opts.remoteFs, root: opts.remoteDir, action: 'upload', createRoot: true, onEvent: opts.onEvent }, () => uploadNow(opts));
109
+ }
110
+
111
+ async function uploadNow({ files, remoteDir, remoteFs, backup = true, now = new Date(), onEvent = () => {} }) {
112
+ // files: [{ name, localPath, size }]
113
+ const root = remoteDir.replace(/\/$/, '');
114
+ const summary = { uploaded: [], backedUp: [], backupDir: null, bytes: 0 };
115
+ try {
116
+ await remoteFs.mkdirp(root);
117
+ if (backup && files.length) {
118
+ const backupDir = await freeBackupDir(remoteFs, root, makeStamp(now));
119
+ await writeBackupInfo({ remoteFs, backupDir, info: { action: 'upload', at: now.toISOString(), status: 'saving', overwritten: [], added: [], deleted: [] } });
120
+ const res = await backupFiles({ remoteFs, root, paths: files.map(f => f.name), backupDir, onEvent });
121
+ const copied = new Set(res.copied);
122
+ const shas = new Map();
123
+ for (const f of files) shas.set(f.name, await sha256File(f.localPath));
124
+ await writeBackupInfo({ remoteFs, backupDir, info: {
125
+ action: 'upload', at: now.toISOString(),
126
+ overwritten: files.filter(f => copied.has(f.name)).map(f => ({ path: f.name, sha256: shas.get(f.name), was: res.was.get(f.name) })),
127
+ added: files.filter(f => !copied.has(f.name)).map(f => ({ path: f.name, sha256: shas.get(f.name) })),
128
+ deleted: [],
129
+ } });
130
+ summary.backedUp = res.copied; summary.backupDir = backupDir;
131
+ }
132
+ for (const f of files) {
133
+ await writeLive(remoteFs, `${root}/${f.name}`, { localPath: f.localPath });
134
+ summary.uploaded.push(f.name); summary.bytes += f.size || 0;
135
+ onEvent({ type: 'upload', path: f.name, bytes: f.size || 0, reason: 'upload', done: summary.uploaded.length, total: files.length });
136
+ }
137
+ onEvent({ type: 'done', summary });
138
+ return summary;
139
+ } catch (err) {
140
+ onEvent({ type: 'error', message: err.message, summary });
141
+ throw err;
142
+ }
143
+ }
144
+
145
+ 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/lock.js ADDED
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+ // One run at a time per site. Deploy, upload, restore and prune hold <root>/.shipback-lock while they write: two
3
+ // at once would interleave their backups, and an undo could then pick up half of each. A second run is refused
4
+ // and told who holds the lock.
5
+ //
6
+ // A run that is cut off can't take its lock away, so a lock counts as stale (and is taken over) when:
7
+ // · it was taken on this computer by a process that has ended, or by this process for a run that is over, or
8
+ // · nobody has refreshed it for STALE_MS (the holder rewrites it every HEARTBEAT_MS while it runs)
9
+ //
10
+ // withLock({ remoteFs, root, action, createRoot, onEvent }, fn) → fn's result, the lock held throughout.
11
+ // Only a deploy or upload creates a missing root; a restore or prune there has nothing to protect and fails by itself.
12
+ const crypto = require('node:crypto');
13
+ const os = require('node:os');
14
+ const { LOCK_NAME } = require('./remote-fs');
15
+
16
+ const HEARTBEAT_MS = 30000;
17
+ const STALE_MS = 120000;
18
+ const held = new Set(); // tokens this process holds right now
19
+
20
+ // the lock sits in the web root, so it carries a hash of the computer's name, not the name
21
+ const machine = () => crypto.createHash('sha256').update(os.hostname()).digest('hex').slice(0, 12);
22
+
23
+ function pidAlive(pid) {
24
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
25
+ }
26
+
27
+ async function readLock(remoteFs, file) {
28
+ try { return JSON.parse((await remoteFs.readFile(file)).toString('utf8')); } catch { return null; }
29
+ }
30
+
31
+ /** Why a lock can be taken over, or null if its run may still be going. */
32
+ function staleReason(lock, mtime, now) {
33
+ if (lock && lock.machine === machine() && Number.isInteger(lock.pid)) {
34
+ if (lock.pid === process.pid ? !held.has(lock.token) : !pidAlive(lock.pid)) return 'its run on this computer has ended';
35
+ }
36
+ if (now - mtime > STALE_MS) return `nothing has refreshed it for ${Math.round((now - mtime) / 60000)} min`;
37
+ return null;
38
+ }
39
+
40
+ class LockedError extends Error {
41
+ constructor(lock) {
42
+ const who = lock && lock.machine === machine() ? 'on this computer' : 'from another computer';
43
+ super(`Another ${lock && lock.action || 'run'} is writing to this site right now (started ${lock && lock.at || 'just now'}, ${who}). `
44
+ + 'Nothing was changed. Try again when it finishes. A lock left by a run that was cut off clears itself within 2 minutes.');
45
+ this.code = 'ELOCKED';
46
+ }
47
+ }
48
+
49
+ async function acquire({ remoteFs, root, action, now = Date.now, onEvent = () => {} }) {
50
+ const file = `${root}/${LOCK_NAME}`;
51
+ const token = crypto.randomBytes(8).toString('hex');
52
+ const body = () => Buffer.from(JSON.stringify({ tool: 'shipback-sftp', action, token, pid: process.pid, machine: machine(), at: new Date(now()).toISOString() }));
53
+ for (let attempt = 0; attempt < 3; attempt++) {
54
+ if (await remoteFs.createExclusive(file, body())) {
55
+ held.add(token);
56
+ let beat = Promise.resolve();
57
+ const timer = setInterval(() => { beat = beat.then(() => remoteFs.writeFile(file, body())).catch(() => {}); }, HEARTBEAT_MS);
58
+ if (timer.unref) timer.unref();
59
+ return async () => {
60
+ clearInterval(timer);
61
+ held.delete(token);
62
+ await beat;
63
+ const cur = await readLock(remoteFs, file);
64
+ if (cur && cur.token === token) await remoteFs.remove(file).catch(() => {});
65
+ };
66
+ }
67
+ const st = await remoteFs.stat(file);
68
+ if (!st) continue; // released between the two calls
69
+ const lock = await readLock(remoteFs, file);
70
+ const why = staleReason(lock, st.mtime, now());
71
+ if (!why) throw new LockedError(lock);
72
+ onEvent({ type: 'stale-lock', action: lock && lock.action, at: lock && lock.at, why });
73
+ await remoteFs.remove(file).catch(() => {});
74
+ }
75
+ throw new LockedError(await readLock(remoteFs, file));
76
+ }
77
+
78
+ async function withLock({ remoteFs, root, action, createRoot = false, now, onEvent = () => {} }, fn) {
79
+ root = root.replace(/\/$/, '');
80
+ let release = null;
81
+ try {
82
+ const exists = await remoteFs.stat(root);
83
+ if (exists || createRoot) {
84
+ if (!exists) await remoteFs.mkdirp(root);
85
+ release = await acquire({ remoteFs, root, action, now, onEvent });
86
+ }
87
+ } catch (err) { onEvent({ type: 'error', message: err.message, code: err.code }); throw err; }
88
+ try { return await fn(); }
89
+ finally { if (release) await release().catch(() => {}); } // a dropped connection can't release: the lock then goes stale
90
+ }
91
+
92
+ module.exports = { withLock, LockedError, STALE_MS, HEARTBEAT_MS };
@@ -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 };