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 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. The default is fine to
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
@@ -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 { upload, remove, diskWarning } = await res.json();
1520
+ const synced = await res.json();
1521
+ const { remove, diskWarning } = synced;
1518
1522
  if (diskWarning) console.log('⚠️ ' + diskWarning);
1519
1523
 
1520
- if (!upload.length && !remove.length) {
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 Nothing to back up — the server has no files under those paths.');
2242
- process.exit(0);
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
- console.log(`\n ${names.length} file(s), ${(totalBytes / 1024 / 1024).toFixed(2)} MB on the server`);
2275
- if (!changed.length) {
2276
- console.log(' ✓ Already up to date.\n');
2277
- process.exit(0);
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
- console.log(` ${changed.length} changed, ${(fetchBytes / 1024 / 1024).toFixed(2)} MB to download`);
2280
- changed.slice(0, 10).forEach(f => console.log(' ↓ ' + f));
2281
- if (changed.length > 10) console.log(` ... and ${changed.length - 10} more`);
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
- const result = await downloadStreams(dest, changed,
2291
- (rel) => (backupSizes[rel] && backupSizes[rel][0]) || 0, fetchBytes || 0);
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
- console.log(`\n\n✅ Backed up ${changed.length} file(s) in ${secs}s`);
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nukebase",
3
- "version": "2.2.3",
3
+ "version": "2.4.0",
4
4
  "description": "NukeBase CLI: create, deploy and manage NukeBase projects",
5
5
  "bin": {
6
6
  "nukebase": "bin/nukebase.js"