nukebase 2.2.3 → 2.4.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/README.md +1 -1
- package/bin/nukebase.js +10 -0
- package/lib/guide.js +6 -2
- package/lib/project-commands.js +308 -25
- package/lib/storage.js +265 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ Push and pull carry it, so a fresh clone is already linked.
|
|
|
41
41
|
```
|
|
42
42
|
nukebase create <name> [size] [email] sizes: 128mb, 256mb, 512mb, 1gb
|
|
43
43
|
nukebase link <name> [email] connect a folder to an existing project
|
|
44
|
-
nukebase push | pull | logs | rollback | backup | env push | domain | ...
|
|
44
|
+
nukebase push | pull | logs | rollback | backup | restore | env push | domain | ...
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
Run `nukebase help` for every command.
|
package/bin/nukebase.js
CHANGED
|
@@ -24,6 +24,7 @@ if (!(args.has('version') && !args.command)) {
|
|
|
24
24
|
const ACCOUNT_COMMANDS = ['login', 'logout', 'accounts', 'use', 'whoami', 'create', 'link'];
|
|
25
25
|
const PROJECT_COMMANDS = [
|
|
26
26
|
'push', 'pull', 'update', 'rollback', 'restorepoints', 'restore-points', 'backup', 'env',
|
|
27
|
+
'restore', 'backups',
|
|
27
28
|
'pause', 'restart', 'resume', 'delete', 'domain', 'logs', 'download',
|
|
28
29
|
'regiontransfer', 'region-transfer'
|
|
29
30
|
];
|
|
@@ -68,6 +69,8 @@ In a project folder (runs as the account that owns the project)
|
|
|
68
69
|
pull download code
|
|
69
70
|
update refresh the NukeBase engine files
|
|
70
71
|
backup [--out <dir>] download the backup set from the server
|
|
72
|
+
backup --storage also the app's files in file storage (new or
|
|
73
|
+
changed ones)
|
|
71
74
|
env push make the hosted environment (process.env) equal
|
|
72
75
|
to ./.env, then restart the project
|
|
73
76
|
env list [--show] variable names set on the server
|
|
@@ -75,6 +78,13 @@ In a project folder (runs as the account that owns the project)
|
|
|
75
78
|
rollback backup [id] undo a push AND restore the backup set as it was
|
|
76
79
|
then; discards writes since
|
|
77
80
|
rollback list show restore points (also: restorepoints)
|
|
81
|
+
restore list backups in file storage: database and code,
|
|
82
|
+
every 4 hours and after each push, 30 days
|
|
83
|
+
(also: backups)
|
|
84
|
+
restore <id> put one back (--code-only / --data-only);
|
|
85
|
+
what it replaces becomes a restore point
|
|
86
|
+
backup --from <id> download one: its database into ./server/data,
|
|
87
|
+
or everything with --out <dir>
|
|
78
88
|
pause stop the project (base fee + disk still accrue)
|
|
79
89
|
restart | resume restart it; also resumes a paused project
|
|
80
90
|
delete delete it on the server; asks for the name
|
package/lib/guide.js
CHANGED
|
@@ -66,9 +66,11 @@ What it can do (features)
|
|
|
66
66
|
Safety net Every deploy can be undone, and the data can be
|
|
67
67
|
backed up to their computer at any time.
|
|
68
68
|
Secrets API keys for other services, kept out of the code.
|
|
69
|
-
Sizes 128 MB, 256 MB, 512 MB or 1 GB
|
|
69
|
+
Sizes 128 MB, 256 MB, 512 MB or 1 GB of memory, holding 25,
|
|
70
|
+
50, 100 or 200 GB of disk. The default is fine to
|
|
70
71
|
start; it can be changed at any time from the
|
|
71
|
-
dashboard
|
|
72
|
+
dashboard, and a project moves up a size by itself
|
|
73
|
+
when its disk is 90% full.
|
|
72
74
|
|
|
73
75
|
Good fits: booking and scheduling, member or client portals, sign-up lists
|
|
74
76
|
and waitlists, small shops and catalogs, dashboards and tools for a business,
|
|
@@ -101,6 +103,8 @@ Then, inside the project folder:
|
|
|
101
103
|
npx nukebase domain email send the app's emails from that domain
|
|
102
104
|
npx nukebase rollback undo the last push (their data is kept)
|
|
103
105
|
npx nukebase backup copy the app's data to this computer
|
|
106
|
+
npx nukebase restore list the app's backups (database + code, 30 days)
|
|
107
|
+
(add --storage for the files it stores)
|
|
104
108
|
npx nukebase env push send secrets from .env to the server
|
|
105
109
|
npx nukebase pause / restart stop the app, or start it again
|
|
106
110
|
npx nukebase help every command
|
package/lib/project-commands.js
CHANGED
|
@@ -13,6 +13,7 @@ const { Transform, PassThrough } = require('stream');
|
|
|
13
13
|
const { pipeline } = require('stream/promises');
|
|
14
14
|
const tar = require('tar');
|
|
15
15
|
const createClient = require('./sdk');
|
|
16
|
+
const storage = require('./storage');
|
|
16
17
|
const creds = require('./credentials');
|
|
17
18
|
const loginFlows = require('./login');
|
|
18
19
|
const { findProject, readConfig, writeConfig, restoreSettings, migrateLegacy } = require('./project');
|
|
@@ -186,6 +187,8 @@ function progressMeter(totalBytes) {
|
|
|
186
187
|
counter() {
|
|
187
188
|
return new Transform({ transform(chunk, enc, cb) { bytes += chunk.length; draw(false); cb(null, chunk); } });
|
|
188
189
|
},
|
|
190
|
+
// Bytes moved without a stream to count them (storage uploads).
|
|
191
|
+
add(n) { bytes += n; draw(false); },
|
|
189
192
|
bytes: () => bytes,
|
|
190
193
|
done() {
|
|
191
194
|
draw(true);
|
|
@@ -1514,10 +1517,35 @@ async function push() {
|
|
|
1514
1517
|
process.exit(1);
|
|
1515
1518
|
}
|
|
1516
1519
|
|
|
1517
|
-
const
|
|
1520
|
+
const synced = await res.json();
|
|
1521
|
+
const { remove, diskWarning } = synced;
|
|
1518
1522
|
if (diskWarning) console.log('⚠️ ' + diskWarning);
|
|
1519
1523
|
|
|
1520
|
-
|
|
1524
|
+
// --include backup on a project with file storage: its files (storage/ or
|
|
1525
|
+
// server/files)
|
|
1526
|
+
// go straight to the storage, not to the server, and only the ones it lacks
|
|
1527
|
+
// or holds at another size. Nothing there is deleted.
|
|
1528
|
+
let store = null;
|
|
1529
|
+
let storageLocal = {};
|
|
1530
|
+
let storageUp = [];
|
|
1531
|
+
if (INCLUDE_BACKUP) {
|
|
1532
|
+
try {
|
|
1533
|
+
store = await storage.manifest(API, authHeaders);
|
|
1534
|
+
} catch (e) {
|
|
1535
|
+
console.error('❌ ' + e.message);
|
|
1536
|
+
process.exit(1);
|
|
1537
|
+
}
|
|
1538
|
+
if (store && store.storage) {
|
|
1539
|
+
storageLocal = storage.localFiles(root);
|
|
1540
|
+
storageUp = storage.changedLocal(storageLocal, store.files);
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
const filesRel = storage.filesDir(root);
|
|
1544
|
+
const inFilesDir = (f) => f === filesRel || f.startsWith(filesRel + '/');
|
|
1545
|
+
const upload = store && store.storage ? synced.upload.filter((f) => !inFilesDir(f)) : synced.upload;
|
|
1546
|
+
const storageBytes = storageUp.reduce((n, k) => n + storageLocal[k][0], 0);
|
|
1547
|
+
|
|
1548
|
+
if (!upload.length && !remove.length && !storageUp.length) {
|
|
1521
1549
|
console.log('✓ Already up to date');
|
|
1522
1550
|
if (AUTO_YES) process.exit(0);
|
|
1523
1551
|
watchLogs();
|
|
@@ -1566,10 +1594,16 @@ async function push() {
|
|
|
1566
1594
|
console.log(' your copy does not have.');
|
|
1567
1595
|
console.log('\n The project is stopped before extraction, so the running');
|
|
1568
1596
|
console.log(' process cannot flush its in-memory database over the upload.');
|
|
1569
|
-
} else {
|
|
1597
|
+
} else if (!storageUp.length) {
|
|
1570
1598
|
console.log('\n No backup-set files found locally — nothing extra to send.');
|
|
1571
1599
|
console.log(' Run `nukebase backup` first if you meant to restore a copy.');
|
|
1572
1600
|
}
|
|
1601
|
+
if (store && store.storage) {
|
|
1602
|
+
console.log(`\n File storage: ${storageUp.length} file(s), ${(storageBytes / 1024 / 1024).toFixed(2)} MB from ${storage.filesDir(root)}`);
|
|
1603
|
+
console.log(' go straight to the storage (missing or changed ones only; nothing there is deleted).');
|
|
1604
|
+
storageUp.slice(0, 10).forEach(f => console.log(' ⇈ ' + f));
|
|
1605
|
+
if (storageUp.length > 10) console.log(` ... and ${storageUp.length - 10} more`);
|
|
1606
|
+
}
|
|
1573
1607
|
console.log('');
|
|
1574
1608
|
}
|
|
1575
1609
|
|
|
@@ -1589,13 +1623,28 @@ async function push() {
|
|
|
1589
1623
|
}
|
|
1590
1624
|
}
|
|
1591
1625
|
|
|
1592
|
-
console.log(`\n📊 Total: ${upload.length} to upload, ${remove.length} to remove`
|
|
1626
|
+
console.log(`\n📊 Total: ${upload.length} to upload, ${remove.length} to remove` +
|
|
1627
|
+
(storageUp.length ? `, ${storageUp.length} to file storage` : ''));
|
|
1593
1628
|
const ok = await confirm(`Continue? (y/n) `);
|
|
1594
1629
|
if (!ok) {
|
|
1595
1630
|
console.log('Aborted.');
|
|
1596
1631
|
process.exit(0);
|
|
1597
1632
|
}
|
|
1598
1633
|
|
|
1634
|
+
// Storage first, while the app still runs: it cannot see the new files until
|
|
1635
|
+
// its index is rebuilt at the restart below, so nothing is half-visible.
|
|
1636
|
+
let storageFailed = [];
|
|
1637
|
+
if (storageUp.length) {
|
|
1638
|
+
console.log(`\n File storage (${storageUp.length} file(s)):`);
|
|
1639
|
+
const meter = progressMeter(storageBytes);
|
|
1640
|
+
storageFailed = await storage.upload(API, authHeaders, root, storageLocal, storageUp, (n) => meter.add(n));
|
|
1641
|
+
meter.done();
|
|
1642
|
+
if (storageFailed.length) {
|
|
1643
|
+
console.error(`\n❌ ${storageFailed.length} file(s) did not reach the storage; push again to send them:`);
|
|
1644
|
+
storageFailed.slice(0, 10).forEach(f => console.error(' ' + f));
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1599
1648
|
if (upload.length) {
|
|
1600
1649
|
const sizeOf = (f) => {
|
|
1601
1650
|
const entry = localFiles[f];
|
|
@@ -1672,6 +1721,13 @@ async function push() {
|
|
|
1672
1721
|
}
|
|
1673
1722
|
}
|
|
1674
1723
|
|
|
1724
|
+
// Files went to the storage: the app's file index is rebuilt from the
|
|
1725
|
+
// storage when it starts.
|
|
1726
|
+
if (storageUp.length > storageFailed.length) {
|
|
1727
|
+
const r = await fetch(`${API}/storage-reindex`, { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: '{}' });
|
|
1728
|
+
if (!r.ok) console.error('⚠️ Could not reset the file index:', r.status, await r.text());
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1675
1731
|
console.log('\n🔄 Restarting server...');
|
|
1676
1732
|
|
|
1677
1733
|
// Restart also seals the deploy snapshot server-side, which is what makes it
|
|
@@ -1693,15 +1749,17 @@ async function push() {
|
|
|
1693
1749
|
process.exit(1);
|
|
1694
1750
|
}
|
|
1695
1751
|
|
|
1696
|
-
console.log(`\n✅ Pushed! (${upload.length} uploaded, ${remove.length} removed
|
|
1752
|
+
console.log(`\n✅ Pushed! (${upload.length} uploaded, ${remove.length} removed` +
|
|
1753
|
+
(storageUp.length ? `, ${storageUp.length - storageFailed.length} to file storage` : '') + ')');
|
|
1697
1754
|
console.log(` Deploy ${deployId} — run 'nukebase rollback' to undo this push`);
|
|
1755
|
+
if (storageFailed.length) process.exitCode = 1;
|
|
1698
1756
|
|
|
1699
1757
|
// Non-interactive: tail logs only until the server settles, then exit so the
|
|
1700
1758
|
// AI agent / CI job returns on its own. Interactive keeps the live tail above.
|
|
1701
1759
|
if (AUTO_YES) {
|
|
1702
1760
|
await tailLogsUntilSettled();
|
|
1703
1761
|
console.log('\n✅ Deploy complete.');
|
|
1704
|
-
process.exit(0);
|
|
1762
|
+
process.exit(storageFailed.length ? 1 : 0);
|
|
1705
1763
|
}
|
|
1706
1764
|
}
|
|
1707
1765
|
|
|
@@ -2198,6 +2256,7 @@ async function rollback() {
|
|
|
2198
2256
|
// the working tree.
|
|
2199
2257
|
async function backup() {
|
|
2200
2258
|
await connectAndLogin();
|
|
2259
|
+
if (args.get('from')) return backupFrom(args.get('from'));
|
|
2201
2260
|
|
|
2202
2261
|
const dest = args.get('out') ? resolve(args.get('out')) : root;
|
|
2203
2262
|
|
|
@@ -2238,12 +2297,12 @@ async function backup() {
|
|
|
2238
2297
|
changed = (download || []).filter(rel => backupPaths.some(p => rel === p || rel.startsWith(p + '/')));
|
|
2239
2298
|
fetchBytes = 0;
|
|
2240
2299
|
if (!changed.length) {
|
|
2241
|
-
console.log('\n
|
|
2242
|
-
|
|
2300
|
+
console.log('\n The server has no files under those paths.');
|
|
2301
|
+
} else {
|
|
2302
|
+
console.log(`\n ${changed.length} file(s) to download`);
|
|
2303
|
+
changed.slice(0, 10).forEach(f => console.log(' ↓ ' + f));
|
|
2304
|
+
if (changed.length > 10) console.log(` ... and ${changed.length - 10} more`);
|
|
2243
2305
|
}
|
|
2244
|
-
console.log(`\n ${changed.length} file(s) to download`);
|
|
2245
|
-
changed.slice(0, 10).forEach(f => console.log(' ↓ ' + f));
|
|
2246
|
-
if (changed.length > 10) console.log(` ... and ${changed.length - 10} more`);
|
|
2247
2306
|
} else {
|
|
2248
2307
|
if (!listRes.ok) {
|
|
2249
2308
|
console.error('❌ Could not list backup files:', listRes.status, await listRes.text());
|
|
@@ -2253,10 +2312,6 @@ async function backup() {
|
|
|
2253
2312
|
const { files: serverFiles } = await listRes.json();
|
|
2254
2313
|
backupSizes = serverFiles || {};
|
|
2255
2314
|
const names = Object.keys(serverFiles);
|
|
2256
|
-
if (!names.length) {
|
|
2257
|
-
console.log('\n Nothing to back up — the server has no files under those paths.');
|
|
2258
|
-
process.exit(0);
|
|
2259
|
-
}
|
|
2260
2315
|
|
|
2261
2316
|
// Same size+mtime comparison sync uses. mtime survives the tar round-trip
|
|
2262
2317
|
// closely enough that a 1s tolerance avoids re-fetching unchanged files.
|
|
@@ -2271,14 +2326,50 @@ async function backup() {
|
|
|
2271
2326
|
const totalBytes = names.reduce((n, rel) => n + (serverFiles[rel][0] || 0), 0);
|
|
2272
2327
|
fetchBytes = changed.reduce((n, rel) => n + (serverFiles[rel][0] || 0), 0);
|
|
2273
2328
|
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2329
|
+
if (!names.length) {
|
|
2330
|
+
console.log('\n The server has no files under those paths.');
|
|
2331
|
+
} else {
|
|
2332
|
+
console.log(`\n ${names.length} file(s), ${(totalBytes / 1024 / 1024).toFixed(2)} MB on the server`);
|
|
2333
|
+
if (!changed.length) console.log(' ✓ Already up to date.');
|
|
2334
|
+
else {
|
|
2335
|
+
console.log(` ${changed.length} changed, ${(fetchBytes / 1024 / 1024).toFixed(2)} MB to download`);
|
|
2336
|
+
changed.slice(0, 10).forEach(f => console.log(' ↓ ' + f));
|
|
2337
|
+
if (changed.length > 10) console.log(` ... and ${changed.length - 10} more`);
|
|
2338
|
+
}
|
|
2278
2339
|
}
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
// File storage: a project whose files live in object storage. Listed always;
|
|
2343
|
+
// downloaded with --storage, new or changed only.
|
|
2344
|
+
let store = null;
|
|
2345
|
+
let storageChanged = [];
|
|
2346
|
+
let storageBytes = 0;
|
|
2347
|
+
try {
|
|
2348
|
+
store = await storage.manifest(API, authHeaders);
|
|
2349
|
+
} catch (e) {
|
|
2350
|
+
console.error('⚠️ ' + e.message);
|
|
2351
|
+
}
|
|
2352
|
+
if (store && store.storage) {
|
|
2353
|
+
const keys = Object.keys(store.files);
|
|
2354
|
+
const total = keys.reduce((n, k) => n + store.files[k][0], 0);
|
|
2355
|
+
console.log(`\n File storage: ${keys.length} file(s), ${(total / 1024 / 1024).toFixed(2)} MB`);
|
|
2356
|
+
if (args.has('storage')) {
|
|
2357
|
+
storageChanged = storage.changedRemote(dest, store.files);
|
|
2358
|
+
storageBytes = storageChanged.reduce((n, k) => n + store.files[k][0], 0);
|
|
2359
|
+
if (!storageChanged.length) console.log(' ✓ Already up to date.');
|
|
2360
|
+
else {
|
|
2361
|
+
console.log(` ${storageChanged.length} changed, ${(storageBytes / 1024 / 1024).toFixed(2)} MB to download into ${storage.filesDir(dest)}`);
|
|
2362
|
+
storageChanged.slice(0, 10).forEach(f => console.log(' ↓ ' + f));
|
|
2363
|
+
if (storageChanged.length > 10) console.log(` ... and ${storageChanged.length - 10} more`);
|
|
2364
|
+
}
|
|
2365
|
+
} else if (keys.length) {
|
|
2366
|
+
console.log(' Not downloaded. Add --storage to copy them too (only new or changed files).');
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
if (!changed.length && !storageChanged.length) {
|
|
2371
|
+
console.log('\n Nothing to download.\n');
|
|
2372
|
+
process.exit(0);
|
|
2282
2373
|
}
|
|
2283
2374
|
|
|
2284
2375
|
const ok = await confirm('\nContinue? (y/n) ');
|
|
@@ -2287,11 +2378,25 @@ async function backup() {
|
|
|
2287
2378
|
mkdirSync(dest, { recursive: true });
|
|
2288
2379
|
|
|
2289
2380
|
const started = Date.now();
|
|
2290
|
-
|
|
2291
|
-
|
|
2381
|
+
if (changed.length) {
|
|
2382
|
+
await downloadStreams(dest, changed,
|
|
2383
|
+
(rel) => (backupSizes[rel] && backupSizes[rel][0]) || 0, fetchBytes || 0);
|
|
2384
|
+
}
|
|
2385
|
+
let storageFailed = [];
|
|
2386
|
+
if (storageChanged.length) {
|
|
2387
|
+
console.log(`\n File storage (${storageChanged.length} file(s)):`);
|
|
2388
|
+
const meter = progressMeter(storageBytes);
|
|
2389
|
+
storageFailed = await storage.download(API, authHeaders, dest, store.files, storageChanged, () => meter.counter());
|
|
2390
|
+
meter.done();
|
|
2391
|
+
}
|
|
2292
2392
|
|
|
2293
2393
|
const secs = ((Date.now() - started) / 1000).toFixed(1);
|
|
2294
|
-
|
|
2394
|
+
if (storageFailed.length) {
|
|
2395
|
+
console.error(`\n❌ ${storageFailed.length} storage file(s) failed; run the backup again to fetch them:`);
|
|
2396
|
+
storageFailed.slice(0, 10).forEach(f => console.error(' ' + f));
|
|
2397
|
+
process.exit(1);
|
|
2398
|
+
}
|
|
2399
|
+
console.log(`\n\n✅ Backed up ${changed.length + storageChanged.length} file(s) in ${secs}s`);
|
|
2295
2400
|
console.log(` ${dest}`);
|
|
2296
2401
|
if (dest === root) {
|
|
2297
2402
|
console.log('\n These paths are in `exclude`, so push and pull will ignore them.');
|
|
@@ -2453,6 +2558,182 @@ async function envList() {
|
|
|
2453
2558
|
process.exit(0);
|
|
2454
2559
|
}
|
|
2455
2560
|
|
|
2561
|
+
// ---------------------------------------------------------------------------
|
|
2562
|
+
// Backups in file storage: restore, restore list, backup --from
|
|
2563
|
+
// ---------------------------------------------------------------------------
|
|
2564
|
+
// A project with file storage backs itself up into its own storage: its
|
|
2565
|
+
// database and code, every few hours and after each push, kept 30 days (the
|
|
2566
|
+
// engine does it, 1.12+). `restore list` shows them; `restore <id>` puts one
|
|
2567
|
+
// back on the server: the code at once, the database as the app starts
|
|
2568
|
+
// again. What it replaces is saved as a restore point first, so `nukebase
|
|
2569
|
+
// rollback backup <that id>` undoes it. `backup --from <id>` downloads one
|
|
2570
|
+
// instead.
|
|
2571
|
+
|
|
2572
|
+
function ageOf(at) {
|
|
2573
|
+
const h = (Date.now() - at) / 3600000;
|
|
2574
|
+
return h < 1 ? `${Math.max(1, Math.round(h * 60))} minutes ago` : h < 48 ? `${h.toFixed(1)} hours ago` : `${(h / 24).toFixed(1)} days ago`;
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
async function listBackups() {
|
|
2578
|
+
const res = await fetch(`${API}/backups`, {
|
|
2579
|
+
method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: '{}'
|
|
2580
|
+
});
|
|
2581
|
+
if (res.status === 401) authExpired();
|
|
2582
|
+
if (res.status === 404) {
|
|
2583
|
+
console.error('❌ This region does not keep backups in file storage yet.');
|
|
2584
|
+
process.exit(1);
|
|
2585
|
+
}
|
|
2586
|
+
if (!res.ok) {
|
|
2587
|
+
console.error('❌ Could not list backups:', res.status, await res.text());
|
|
2588
|
+
process.exit(1);
|
|
2589
|
+
}
|
|
2590
|
+
return res.json();
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
async function restoreCommand() {
|
|
2594
|
+
await connectAndLogin();
|
|
2595
|
+
const first = args.positional[0];
|
|
2596
|
+
const { storage: has, backups, late } = await listBackups();
|
|
2597
|
+
if (!has) {
|
|
2598
|
+
console.log(`\n${project} keeps its files on the server's disk, so it has no backups in file storage.`);
|
|
2599
|
+
console.log(' Its daily backups are on the server (server/backup); restore points: nukebase rollback list\n');
|
|
2600
|
+
process.exit(0);
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
if (!first || first === 'list' || command === 'backups') {
|
|
2604
|
+
if (!backups.length) {
|
|
2605
|
+
console.log(`\nNo backups of ${project} yet. They are made a few minutes after each start`);
|
|
2606
|
+
console.log('and every 4 hours (engine 1.12+: nukebase update, then push).\n');
|
|
2607
|
+
process.exit(0);
|
|
2608
|
+
}
|
|
2609
|
+
console.log(`\nBackups of ${project} (kept 30 days):\n`);
|
|
2610
|
+
for (const b of backups) {
|
|
2611
|
+
console.log(` ${b.id} ${new Date(b.at).toISOString().replace('T', ' ').slice(0, 19)} ${ageOf(b.at)}`);
|
|
2612
|
+
}
|
|
2613
|
+
if (late) console.log('\n ⚠️ No backup for over 9 hours: check the logs (nukebase logs).');
|
|
2614
|
+
console.log('\n nukebase restore <id> the database and the code as they were then');
|
|
2615
|
+
console.log(' nukebase restore <id> --code-only only the code (keeps live data)');
|
|
2616
|
+
console.log(' nukebase restore <id> --data-only only the database');
|
|
2617
|
+
console.log(' nukebase backup --from <id> download it instead (--out <dir> for everything)\n');
|
|
2618
|
+
process.exit(0);
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
const target = backups.find((b) => b.id === first);
|
|
2622
|
+
if (!target) {
|
|
2623
|
+
console.error(`❌ No such backup: ${first}`);
|
|
2624
|
+
console.error(" Run 'nukebase restore list' to see them.");
|
|
2625
|
+
process.exit(1);
|
|
2626
|
+
}
|
|
2627
|
+
const code = !args.has('data-only');
|
|
2628
|
+
const data = !args.has('code-only');
|
|
2629
|
+
if (!code && !data) {
|
|
2630
|
+
console.error('❌ --code-only and --data-only together leave nothing to restore.');
|
|
2631
|
+
process.exit(1);
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
console.log(`\n⏪ Restoring ${project} to backup ${target.id}`);
|
|
2635
|
+
console.log(` taken ${new Date(target.at).toISOString().replace('T', ' ').slice(0, 19)} (${ageOf(target.at)})`);
|
|
2636
|
+
console.log(` ${[data && 'the database', code && 'the code'].filter(Boolean).join(' and ')}; the project restarts`);
|
|
2637
|
+
console.log(' What is replaced is saved as a restore point first, so it can be undone.');
|
|
2638
|
+
if (data) {
|
|
2639
|
+
console.log('\n' + '─'.repeat(66));
|
|
2640
|
+
console.log(' ⚠️ THIS DISCARDS DATA. Read before continuing.');
|
|
2641
|
+
console.log('─'.repeat(66));
|
|
2642
|
+
console.log(`\n Every write of the last ${ageOf(target.at).replace(' ago', '')} is lost from the live app`);
|
|
2643
|
+
console.log(" (kept only in that restore point). The app's stored files are not");
|
|
2644
|
+
console.log(' changed. If you only need the code back, press Ctrl+C and run:');
|
|
2645
|
+
console.log(` nukebase restore ${target.id} --code-only\n`);
|
|
2646
|
+
const typed = await confirmTyped(project, ` Type the project name (${project}) to confirm: `);
|
|
2647
|
+
if (!typed) { console.log('Aborted.'); process.exit(0); }
|
|
2648
|
+
} else {
|
|
2649
|
+
const ok = await confirm('\nContinue? (y/n) ');
|
|
2650
|
+
if (!ok) { console.log('Aborted.'); process.exit(0); }
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
const res = await fetch(`${API}/backup-restore`, {
|
|
2654
|
+
method: 'POST',
|
|
2655
|
+
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
|
2656
|
+
body: JSON.stringify({ id: target.id, data, code, confirm: project })
|
|
2657
|
+
});
|
|
2658
|
+
if (!res.ok) {
|
|
2659
|
+
console.error('\n❌ Restore failed:', res.status, await res.text());
|
|
2660
|
+
process.exit(1);
|
|
2661
|
+
}
|
|
2662
|
+
const result = await res.json();
|
|
2663
|
+
console.log(`\n✅ Restored backup ${target.id}`);
|
|
2664
|
+
if (code) console.log(` ${result.codeFiles} code file(s) put back`);
|
|
2665
|
+
if (data) console.log(result.database ? ' the database is restored as the app starts (see the logs below)' : ' ⚠️ the database could not be marked for restore');
|
|
2666
|
+
if (result.failures && result.failures.length) {
|
|
2667
|
+
console.log(` ⚠️ ${result.failures.length} file(s) could not be restored:`);
|
|
2668
|
+
result.failures.slice(0, 10).forEach((f) => console.log(' ! ' + f));
|
|
2669
|
+
}
|
|
2670
|
+
if (result.undoId) {
|
|
2671
|
+
console.log(`\n What it replaced is restore point ${result.undoId}`);
|
|
2672
|
+
console.log(` Undo with: nukebase rollback backup ${result.undoId}`);
|
|
2673
|
+
}
|
|
2674
|
+
if (AUTO_YES) { await tailLogsUntilSettled(); process.exit(0); }
|
|
2675
|
+
watchLogs();
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
// backup --from <id>: the backup's database into ./server/data, as a plain
|
|
2679
|
+
// backup does; with --out <dir>, everything it holds (code too) into that
|
|
2680
|
+
// folder, on links the server signs (served by the server).
|
|
2681
|
+
async function backupFrom(id) {
|
|
2682
|
+
const res = await fetch(`${API}/backup-links`, {
|
|
2683
|
+
method: 'POST',
|
|
2684
|
+
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
|
2685
|
+
body: JSON.stringify({ id })
|
|
2686
|
+
});
|
|
2687
|
+
if (res.status === 401) authExpired();
|
|
2688
|
+
if (!res.ok) {
|
|
2689
|
+
console.error(`❌ Could not read backup ${id}:`, res.status, await res.text());
|
|
2690
|
+
if (res.status === 404) console.error(" Run 'nukebase restore list' to see the backups.");
|
|
2691
|
+
process.exit(1);
|
|
2692
|
+
}
|
|
2693
|
+
const snap = await res.json();
|
|
2694
|
+
const out = args.get('out') ? resolve(args.get('out')) : null;
|
|
2695
|
+
const entries = Object.entries(snap.files).filter(([rel]) => out || rel.startsWith('server/data/'));
|
|
2696
|
+
const total = entries.reduce((n, [, f]) => n + (f.size || 0), 0);
|
|
2697
|
+
console.log(`\n💾 Backup ${snap.id} of ${project} (${ageOf(snap.at)})`);
|
|
2698
|
+
console.log(` ${entries.length} file(s), ${(total / 1024 / 1024).toFixed(2)} MB` + (out ? `, into ${out}` : ', its database, into ./server/data'));
|
|
2699
|
+
if (!out && existsSync(join(root, 'server', 'data'))) {
|
|
2700
|
+
console.log(' Your local ./server/data is moved to ./server/backup/before-' + snap.id);
|
|
2701
|
+
}
|
|
2702
|
+
if (!entries.length) { console.log('\n Nothing to download.\n'); process.exit(0); }
|
|
2703
|
+
const ok = await confirm('\nContinue? (y/n) ');
|
|
2704
|
+
if (!ok) { console.log('Aborted.'); process.exit(0); }
|
|
2705
|
+
|
|
2706
|
+
// The database goes to a folder in server/backup (excluded from push) and
|
|
2707
|
+
// replaces ./server/data only once all of it is here.
|
|
2708
|
+
const fsm = require('fs');
|
|
2709
|
+
const staging = out || join(root, 'server', 'backup', 'from-' + snap.id);
|
|
2710
|
+
const target = (rel) => out ? join(out, ...rel.split('/')) : join(staging, ...rel.slice('server/data/'.length).split('/'));
|
|
2711
|
+
if (!out) fsm.rmSync(staging, { recursive: true, force: true });
|
|
2712
|
+
const meter = progressMeter(total);
|
|
2713
|
+
const failed = [];
|
|
2714
|
+
await storage.pool(entries, 4, async ([rel, f]) => {
|
|
2715
|
+
const file = target(rel);
|
|
2716
|
+
if (!resolve(file).startsWith(resolve(staging) + sep)) { failed.push(`${rel}: outside the folder`); return; }
|
|
2717
|
+
try { await storage.fetchTo(rel, f.url, file, () => meter.counter()); } catch (e) { failed.push(`${rel}: ${e.message}`); }
|
|
2718
|
+
});
|
|
2719
|
+
meter.done();
|
|
2720
|
+
if (failed.length) {
|
|
2721
|
+
console.error(`\n❌ ${failed.length} file(s) failed; nothing was replaced:`);
|
|
2722
|
+
failed.slice(0, 10).forEach((f) => console.error(' ' + f));
|
|
2723
|
+
process.exit(1);
|
|
2724
|
+
}
|
|
2725
|
+
if (!out) {
|
|
2726
|
+
const live = join(root, 'server', 'data');
|
|
2727
|
+
const aside = join(root, 'server', 'backup', 'before-' + snap.id);
|
|
2728
|
+
fsm.rmSync(aside, { recursive: true, force: true });
|
|
2729
|
+
if (existsSync(live)) fsm.renameSync(live, aside);
|
|
2730
|
+
fsm.renameSync(staging, live);
|
|
2731
|
+
}
|
|
2732
|
+
console.log(`\n✅ Backup ${snap.id} downloaded` + (out ? ` into ${out}` : ' into ./server/data'));
|
|
2733
|
+
if (!out) console.log(' To send it back up: nukebase push --include backup\n');
|
|
2734
|
+
process.exit(0);
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2456
2737
|
// bin/nukebase.js has already checked the command name.
|
|
2457
2738
|
const run = (fn) => fn().catch(err => { console.error('❌', err.message); process.exit(1); });
|
|
2458
2739
|
if (command === 'push' && args.positional[0] === 'env') {
|
|
@@ -2480,6 +2761,8 @@ if (command === 'push' && args.positional[0] === 'env') {
|
|
|
2480
2761
|
run(rollback);
|
|
2481
2762
|
} else if (command === 'backup') {
|
|
2482
2763
|
run(backup);
|
|
2764
|
+
} else if (command === 'restore' || command === 'backups') {
|
|
2765
|
+
run(restoreCommand);
|
|
2483
2766
|
} else if (command === 'pause') {
|
|
2484
2767
|
run(pauseProject);
|
|
2485
2768
|
} else if (command === 'restart' || command === 'resume') {
|
package/lib/storage.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// Object storage for `nukebase backup --storage` and `nukebase push --include
|
|
2
|
+
// backup`. A project with object storage keeps its files (what the app stores
|
|
3
|
+
// with setFile) in a bucket instead of server/files. Uploads go straight from
|
|
4
|
+
// this machine to the bucket through links the server signs for one file and
|
|
5
|
+
// one hour. Downloads come through the server, on links it signs the same way:
|
|
6
|
+
// the storage lets only the project's own key read what the app wrote, and
|
|
7
|
+
// that key works only from the server. Locally they
|
|
8
|
+
// live where a project without storage keeps them: storage/<path> in projects
|
|
9
|
+
// made from the engine 1.12 template (marked by storage/$nukebase),
|
|
10
|
+
// server/files/<path> before.
|
|
11
|
+
//
|
|
12
|
+
// Every transfer is IPv4: the storage provider stalls parallel uploads over
|
|
13
|
+
// IPv6 (measured from the server: 13 of 18 stalled, against none over IPv4).
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const https = require('https');
|
|
16
|
+
const http = require('http');
|
|
17
|
+
const { join, dirname, relative, sep } = require('path');
|
|
18
|
+
const { pipeline } = require('stream/promises');
|
|
19
|
+
|
|
20
|
+
const FILES_DIR = 'server/files';
|
|
21
|
+
// The project's file folder, relative to root: the engine and the server pick
|
|
22
|
+
// the same way.
|
|
23
|
+
function filesDir(root) {
|
|
24
|
+
try { if (fs.statSync(join(root, 'storage', '$nukebase')).isFile()) return 'storage'; } catch {}
|
|
25
|
+
return FILES_DIR;
|
|
26
|
+
}
|
|
27
|
+
const IDLE_MS = 30000;
|
|
28
|
+
const TRIES = 4;
|
|
29
|
+
const PARALLEL = 6;
|
|
30
|
+
|
|
31
|
+
// { storage: false } from a server without storage for this project, null from
|
|
32
|
+
// a server that predates it; otherwise { storage: true, files: { key: [size, mtime] } }.
|
|
33
|
+
async function manifest(API, authHeaders) {
|
|
34
|
+
const files = {};
|
|
35
|
+
let after = null;
|
|
36
|
+
for (;;) {
|
|
37
|
+
const res = await fetch(`${API}/storage-manifest`, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
|
40
|
+
body: JSON.stringify(after ? { after } : {})
|
|
41
|
+
});
|
|
42
|
+
if (res.status === 404) return null;
|
|
43
|
+
if (!res.ok) throw new Error(`Could not list the storage files: ${res.status} ${await res.text()}`);
|
|
44
|
+
const page = await res.json();
|
|
45
|
+
if (!page.storage) return { storage: false, files: {} };
|
|
46
|
+
Object.assign(files, page.files || {});
|
|
47
|
+
if (!page.next) return { storage: true, files };
|
|
48
|
+
after = page.next;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// One request to the bucket, IPv4, failing on IDLE_MS without progress.
|
|
53
|
+
// Resolves { status, headers, stream } once headers arrive.
|
|
54
|
+
function request(method, url, body) {
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
const u = new URL(url);
|
|
57
|
+
const mod = u.protocol === 'http:' ? http : https;
|
|
58
|
+
const req = mod.request({
|
|
59
|
+
method, hostname: u.hostname, port: u.port || undefined, path: u.pathname + u.search, family: 4,
|
|
60
|
+
headers: body ? { 'content-length': body.length } : {}
|
|
61
|
+
}, (resp) => {
|
|
62
|
+
resp.setTimeout(IDLE_MS, () => resp.destroy(new Error('the storage stopped sending')));
|
|
63
|
+
resolve({ status: resp.statusCode, headers: resp.headers, stream: resp });
|
|
64
|
+
});
|
|
65
|
+
req.setTimeout(IDLE_MS, () => req.destroy(new Error('the storage stopped answering')));
|
|
66
|
+
req.on('error', reject);
|
|
67
|
+
req.end(body || undefined);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function retry(what, fn) {
|
|
72
|
+
let last;
|
|
73
|
+
for (let i = 0; i < TRIES; i++) {
|
|
74
|
+
try { return await fn(); } catch (e) { last = e; }
|
|
75
|
+
await new Promise((r) => setTimeout(r, 500 * (i + 1)));
|
|
76
|
+
}
|
|
77
|
+
throw new Error(`${what}: ${last.message}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Runs fn over items, `n` at a time.
|
|
81
|
+
async function pool(items, n, fn) {
|
|
82
|
+
let i = 0;
|
|
83
|
+
await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => {
|
|
84
|
+
while (i < items.length) await fn(items[i++]);
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Signed links for many keys, 500 per request.
|
|
89
|
+
async function links(API, authHeaders, keys) {
|
|
90
|
+
const out = {};
|
|
91
|
+
for (let i = 0; i < keys.length; i += 500) {
|
|
92
|
+
const res = await fetch(`${API}/storage-links`, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
|
95
|
+
body: JSON.stringify({ keys: keys.slice(i, i + 500) })
|
|
96
|
+
});
|
|
97
|
+
if (!res.ok) throw new Error(`Could not get download links: ${res.status} ${await res.text()}`);
|
|
98
|
+
Object.assign(out, (await res.json()).links);
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Which bucket files differ from the local copy in the file folder: by size,
|
|
104
|
+
// or by being newer in the bucket than here by more than a second. A download
|
|
105
|
+
// sets the local mtime to the bucket's, and an upload sets it to when the
|
|
106
|
+
// bucket got it, so neither is fetched again.
|
|
107
|
+
function changedRemote(dest, files) {
|
|
108
|
+
const dir = filesDir(dest);
|
|
109
|
+
return Object.keys(files).filter((key) => {
|
|
110
|
+
const local = join(dest, dir, ...key.split('/'));
|
|
111
|
+
let st;
|
|
112
|
+
try { st = fs.statSync(local); } catch { return true; }
|
|
113
|
+
const [size, mtime] = files[key];
|
|
114
|
+
return st.size !== size || mtime > Math.floor(st.mtimeMs / 1000) + 1;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// One URL into `target`, through a .part renamed when complete, so an
|
|
119
|
+
// interrupted run leaves no torn file.
|
|
120
|
+
async function fetchTo(what, url, target, counter) {
|
|
121
|
+
fs.mkdirSync(dirname(target), { recursive: true });
|
|
122
|
+
const part = target + '.part';
|
|
123
|
+
try {
|
|
124
|
+
await retry(what, async () => {
|
|
125
|
+
const r = await request('GET', url);
|
|
126
|
+
if (r.status !== 200) { r.stream.resume(); throw new Error(`HTTP ${r.status}`); }
|
|
127
|
+
await pipeline(r.stream, counter(), fs.createWriteStream(part));
|
|
128
|
+
});
|
|
129
|
+
fs.renameSync(part, target);
|
|
130
|
+
} catch (e) {
|
|
131
|
+
try { fs.unlinkSync(part); } catch {}
|
|
132
|
+
throw e;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Download `keys` into the file folder under dest. counter() is a
|
|
137
|
+
// pass-through that counts bytes for the progress line.
|
|
138
|
+
async function download(API, authHeaders, dest, files, keys, counter) {
|
|
139
|
+
const failed = [];
|
|
140
|
+
const dir = filesDir(dest);
|
|
141
|
+
for (let i = 0; i < keys.length; i += 500) {
|
|
142
|
+
const batch = keys.slice(i, i + 500);
|
|
143
|
+
const urls = await links(API, authHeaders, batch); // an hour each: fetched per batch
|
|
144
|
+
await pool(batch, PARALLEL, async (key) => {
|
|
145
|
+
const target = join(dest, dir, ...key.split('/'));
|
|
146
|
+
if (!target.startsWith(join(dest, dir) + sep)) return;
|
|
147
|
+
try {
|
|
148
|
+
await fetchTo(key, urls[key], target, counter);
|
|
149
|
+
const mtime = files[key][1];
|
|
150
|
+
if (mtime) fs.utimesSync(target, mtime, mtime);
|
|
151
|
+
} catch (e) {
|
|
152
|
+
failed.push(e.message);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return failed;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The local files a restore would send: everything in the file folder except
|
|
160
|
+
// names the engine reserves ("$...", its index and spool) and partial
|
|
161
|
+
// downloads, as { key: [size, mtime] }.
|
|
162
|
+
function localFiles(root) {
|
|
163
|
+
const base = join(root, filesDir(root));
|
|
164
|
+
const out = {};
|
|
165
|
+
const walk = (dir) => {
|
|
166
|
+
let ents;
|
|
167
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
168
|
+
for (const e of ents) {
|
|
169
|
+
if (e.name.startsWith('$') || e.name.endsWith('.part') || e.name.endsWith('.tmp')) continue;
|
|
170
|
+
const full = join(dir, e.name);
|
|
171
|
+
if (e.isDirectory()) walk(full);
|
|
172
|
+
else if (e.isFile()) {
|
|
173
|
+
const st = fs.statSync(full);
|
|
174
|
+
out[relative(base, full).split(sep).join('/')] = [st.size, Math.floor(st.mtimeMs / 1000)];
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
walk(base);
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// What a restore uploads: local files the bucket lacks or holds at another
|
|
183
|
+
// size, or holds older than the local copy. Nothing is ever deleted.
|
|
184
|
+
function changedLocal(local, remote) {
|
|
185
|
+
return Object.keys(local).filter((key) => {
|
|
186
|
+
const r = remote[key];
|
|
187
|
+
if (!r) return true;
|
|
188
|
+
return r[0] !== local[key][0] || local[key][1] > r[1] + 1;
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function postJson(API, authHeaders, path, body) {
|
|
193
|
+
const res = await fetch(`${API}${path}`, {
|
|
194
|
+
method: 'POST',
|
|
195
|
+
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
|
196
|
+
body: JSON.stringify(body)
|
|
197
|
+
});
|
|
198
|
+
if (!res.ok) throw new Error(`${path}: ${res.status} ${await res.text()}`);
|
|
199
|
+
return res.json();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Send each key from the file folder to the bucket. Returns the failures.
|
|
203
|
+
// Each file sent then takes the bucket's date for it as its mtime, the way a
|
|
204
|
+
// download does, so neither `backup --storage` nor the next push moves it
|
|
205
|
+
// again.
|
|
206
|
+
async function upload(API, authHeaders, root, local, keys, onBytes) {
|
|
207
|
+
const failed = [];
|
|
208
|
+
const dir = filesDir(root);
|
|
209
|
+
const sent = [];
|
|
210
|
+
await pool(keys, 4, async (key) => {
|
|
211
|
+
const file = join(root, dir, ...key.split('/'));
|
|
212
|
+
const size = local[key][0];
|
|
213
|
+
try {
|
|
214
|
+
const plan = await postJson(API, authHeaders, '/storage-upload', { key, size });
|
|
215
|
+
if (plan.url) {
|
|
216
|
+
const body = fs.readFileSync(file);
|
|
217
|
+
await retry(key, async () => {
|
|
218
|
+
const r = await request('PUT', plan.url, body);
|
|
219
|
+
r.stream.resume();
|
|
220
|
+
if (r.status !== 200) throw new Error(`HTTP ${r.status}`);
|
|
221
|
+
});
|
|
222
|
+
onBytes(size);
|
|
223
|
+
sent.push(key);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const fd = fs.openSync(file, 'r');
|
|
227
|
+
try {
|
|
228
|
+
const etags = new Array(plan.parts.length);
|
|
229
|
+
await pool(plan.parts.map((url, i) => ({ url, i })), 4, async ({ url, i }) => {
|
|
230
|
+
const len = Math.min(plan.partSize, size - i * plan.partSize);
|
|
231
|
+
const buf = Buffer.alloc(len);
|
|
232
|
+
fs.readSync(fd, buf, 0, len, i * plan.partSize);
|
|
233
|
+
await retry(`${key} part ${i + 1}`, async () => {
|
|
234
|
+
const r = await request('PUT', url, buf);
|
|
235
|
+
r.stream.resume();
|
|
236
|
+
if (r.status !== 200 || !r.headers.etag) throw new Error(`HTTP ${r.status}`);
|
|
237
|
+
etags[i] = r.headers.etag;
|
|
238
|
+
});
|
|
239
|
+
onBytes(len);
|
|
240
|
+
});
|
|
241
|
+
await postJson(API, authHeaders, '/storage-upload-complete', { key, uploadId: plan.uploadId, etags });
|
|
242
|
+
sent.push(key);
|
|
243
|
+
} catch (e) {
|
|
244
|
+
await postJson(API, authHeaders, '/storage-upload-complete', { key, uploadId: plan.uploadId, abort: true }).catch(() => {});
|
|
245
|
+
throw e;
|
|
246
|
+
} finally {
|
|
247
|
+
fs.closeSync(fd);
|
|
248
|
+
}
|
|
249
|
+
} catch (e) {
|
|
250
|
+
failed.push(`${key}: ${e.message}`);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
if (sent.length) {
|
|
254
|
+
try {
|
|
255
|
+
const after = await manifest(API, authHeaders);
|
|
256
|
+
for (const key of sent) {
|
|
257
|
+
const r = after && after.files[key];
|
|
258
|
+
if (r && r[1]) fs.utimesSync(join(root, dir, ...key.split('/')), r[1], r[1]);
|
|
259
|
+
}
|
|
260
|
+
} catch {} // only the next run's comparison suffers
|
|
261
|
+
}
|
|
262
|
+
return failed;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
module.exports = { FILES_DIR, filesDir, manifest, changedRemote, download, localFiles, changedLocal, upload, fetchTo, pool };
|