nukebase 2.3.0 → 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
  ];
@@ -69,7 +70,7 @@ In a project folder (runs as the account that owns the project)
69
70
  update refresh the NukeBase engine files
70
71
  backup [--out <dir>] download the backup set from the server
71
72
  backup --storage also the app's files in file storage (new or
72
- changed ones, straight from the storage)
73
+ changed ones)
73
74
  env push make the hosted environment (process.env) equal
74
75
  to ./.env, then restart the project
75
76
  env list [--show] variable names set on the server
@@ -77,6 +78,13 @@ In a project folder (runs as the account that owns the project)
77
78
  rollback backup [id] undo a push AND restore the backup set as it was
78
79
  then; discards writes since
79
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>
80
88
  pause stop the project (base fee + disk still accrue)
81
89
  restart | resume restart it; also resumes a paused project
82
90
  delete delete it on the server; asks for the name
package/lib/guide.js CHANGED
@@ -103,6 +103,8 @@ Then, inside the project folder:
103
103
  npx nukebase domain email send the app's emails from that domain
104
104
  npx nukebase rollback undo the last push (their data is kept)
105
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)
106
108
  npx nukebase env push send secrets from .env to the server
107
109
  npx nukebase pause / restart stop the app, or start it again
108
110
  npx nukebase help every command
@@ -1521,7 +1521,8 @@ async function push() {
1521
1521
  const { remove, diskWarning } = synced;
1522
1522
  if (diskWarning) console.log('⚠️ ' + diskWarning);
1523
1523
 
1524
- // --include backup on a project with file storage: its files (server/files)
1524
+ // --include backup on a project with file storage: its files (storage/ or
1525
+ // server/files)
1525
1526
  // go straight to the storage, not to the server, and only the ones it lacks
1526
1527
  // or holds at another size. Nothing there is deleted.
1527
1528
  let store = null;
@@ -1539,7 +1540,8 @@ async function push() {
1539
1540
  storageUp = storage.changedLocal(storageLocal, store.files);
1540
1541
  }
1541
1542
  }
1542
- const inFilesDir = (f) => f === storage.FILES_DIR || f.startsWith(storage.FILES_DIR + '/');
1543
+ const filesRel = storage.filesDir(root);
1544
+ const inFilesDir = (f) => f === filesRel || f.startsWith(filesRel + '/');
1543
1545
  const upload = store && store.storage ? synced.upload.filter((f) => !inFilesDir(f)) : synced.upload;
1544
1546
  const storageBytes = storageUp.reduce((n, k) => n + storageLocal[k][0], 0);
1545
1547
 
@@ -1597,7 +1599,7 @@ async function push() {
1597
1599
  console.log(' Run `nukebase backup` first if you meant to restore a copy.');
1598
1600
  }
1599
1601
  if (store && store.storage) {
1600
- console.log(`\n File storage: ${storageUp.length} file(s), ${(storageBytes / 1024 / 1024).toFixed(2)} MB from ${storage.FILES_DIR}`);
1602
+ console.log(`\n File storage: ${storageUp.length} file(s), ${(storageBytes / 1024 / 1024).toFixed(2)} MB from ${storage.filesDir(root)}`);
1601
1603
  console.log(' go straight to the storage (missing or changed ones only; nothing there is deleted).');
1602
1604
  storageUp.slice(0, 10).forEach(f => console.log(' ⇈ ' + f));
1603
1605
  if (storageUp.length > 10) console.log(` ... and ${storageUp.length - 10} more`);
@@ -2254,6 +2256,7 @@ async function rollback() {
2254
2256
  // the working tree.
2255
2257
  async function backup() {
2256
2258
  await connectAndLogin();
2259
+ if (args.get('from')) return backupFrom(args.get('from'));
2257
2260
 
2258
2261
  const dest = args.get('out') ? resolve(args.get('out')) : root;
2259
2262
 
@@ -2337,7 +2340,7 @@ async function backup() {
2337
2340
  }
2338
2341
 
2339
2342
  // File storage: a project whose files live in object storage. Listed always;
2340
- // downloaded with --storage, straight from the storage, new or changed only.
2343
+ // downloaded with --storage, new or changed only.
2341
2344
  let store = null;
2342
2345
  let storageChanged = [];
2343
2346
  let storageBytes = 0;
@@ -2355,7 +2358,7 @@ async function backup() {
2355
2358
  storageBytes = storageChanged.reduce((n, k) => n + store.files[k][0], 0);
2356
2359
  if (!storageChanged.length) console.log(' ✓ Already up to date.');
2357
2360
  else {
2358
- console.log(` ${storageChanged.length} changed, ${(storageBytes / 1024 / 1024).toFixed(2)} MB to download into ${storage.FILES_DIR}`);
2361
+ console.log(` ${storageChanged.length} changed, ${(storageBytes / 1024 / 1024).toFixed(2)} MB to download into ${storage.filesDir(dest)}`);
2359
2362
  storageChanged.slice(0, 10).forEach(f => console.log(' ↓ ' + f));
2360
2363
  if (storageChanged.length > 10) console.log(` ... and ${storageChanged.length - 10} more`);
2361
2364
  }
@@ -2555,6 +2558,182 @@ async function envList() {
2555
2558
  process.exit(0);
2556
2559
  }
2557
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
+
2558
2737
  // bin/nukebase.js has already checked the command name.
2559
2738
  const run = (fn) => fn().catch(err => { console.error('❌', err.message); process.exit(1); });
2560
2739
  if (command === 'push' && args.positional[0] === 'env') {
@@ -2582,6 +2761,8 @@ if (command === 'push' && args.positional[0] === 'env') {
2582
2761
  run(rollback);
2583
2762
  } else if (command === 'backup') {
2584
2763
  run(backup);
2764
+ } else if (command === 'restore' || command === 'backups') {
2765
+ run(restoreCommand);
2585
2766
  } else if (command === 'pause') {
2586
2767
  run(pauseProject);
2587
2768
  } else if (command === 'restart' || command === 'resume') {
package/lib/storage.js CHANGED
@@ -1,9 +1,13 @@
1
1
  // Object storage for `nukebase backup --storage` and `nukebase push --include
2
2
  // backup`. A project with object storage keeps its files (what the app stores
3
- // with setFile) in a bucket instead of server/files. They move straight between
4
- // this machine and the bucket through links the server signs for one file and
5
- // one hour; none of the bytes pass through the project's server. Locally they
6
- // live where a project without storage keeps them: server/files/<path>.
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.
7
11
  //
8
12
  // Every transfer is IPv4: the storage provider stalls parallel uploads over
9
13
  // IPv6 (measured from the server: 13 of 18 stalled, against none over IPv4).
@@ -14,6 +18,12 @@ const { join, dirname, relative, sep } = require('path');
14
18
  const { pipeline } = require('stream/promises');
15
19
 
16
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
+ }
17
27
  const IDLE_MS = 30000;
18
28
  const TRIES = 4;
19
29
  const PARALLEL = 6;
@@ -90,43 +100,55 @@ async function links(API, authHeaders, keys) {
90
100
  return out;
91
101
  }
92
102
 
93
- // Which bucket files differ from the local copy under dest/server/files: by
94
- // size, or by an mtime more than a second apart (a download sets the local
95
- // mtime to the bucket's).
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.
96
107
  function changedRemote(dest, files) {
108
+ const dir = filesDir(dest);
97
109
  return Object.keys(files).filter((key) => {
98
- const local = join(dest, FILES_DIR, ...key.split('/'));
110
+ const local = join(dest, dir, ...key.split('/'));
99
111
  let st;
100
112
  try { st = fs.statSync(local); } catch { return true; }
101
113
  const [size, mtime] = files[key];
102
- return st.size !== size || Math.abs(Math.floor(st.mtimeMs / 1000) - mtime) > 1;
114
+ return st.size !== size || mtime > Math.floor(st.mtimeMs / 1000) + 1;
103
115
  });
104
116
  }
105
117
 
106
- // Download `keys` into dest/server/files. Each file is written to a .part
107
- // beside it and renamed when complete, so an interrupted run leaves no torn
108
- // file. counter() is a pass-through that counts bytes for the progress line.
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.
109
138
  async function download(API, authHeaders, dest, files, keys, counter) {
110
139
  const failed = [];
140
+ const dir = filesDir(dest);
111
141
  for (let i = 0; i < keys.length; i += 500) {
112
142
  const batch = keys.slice(i, i + 500);
113
143
  const urls = await links(API, authHeaders, batch); // an hour each: fetched per batch
114
144
  await pool(batch, PARALLEL, async (key) => {
115
- const target = join(dest, FILES_DIR, ...key.split('/'));
116
- if (!target.startsWith(join(dest, FILES_DIR) + sep)) return;
117
- fs.mkdirSync(dirname(target), { recursive: true });
118
- const part = target + '.part';
145
+ const target = join(dest, dir, ...key.split('/'));
146
+ if (!target.startsWith(join(dest, dir) + sep)) return;
119
147
  try {
120
- await retry(key, async () => {
121
- const r = await request('GET', urls[key]);
122
- if (r.status !== 200) { r.stream.resume(); throw new Error(`HTTP ${r.status}`); }
123
- await pipeline(r.stream, counter(), fs.createWriteStream(part));
124
- });
125
- fs.renameSync(part, target);
148
+ await fetchTo(key, urls[key], target, counter);
126
149
  const mtime = files[key][1];
127
150
  if (mtime) fs.utimesSync(target, mtime, mtime);
128
151
  } catch (e) {
129
- try { fs.unlinkSync(part); } catch {}
130
152
  failed.push(e.message);
131
153
  }
132
154
  });
@@ -134,11 +156,11 @@ async function download(API, authHeaders, dest, files, keys, counter) {
134
156
  return failed;
135
157
  }
136
158
 
137
- // The local files a restore would send: everything under root/server/files
138
- // except names the engine reserves ("$...", its index and spool) and partial
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
139
161
  // downloads, as { key: [size, mtime] }.
140
162
  function localFiles(root) {
141
- const base = join(root, FILES_DIR);
163
+ const base = join(root, filesDir(root));
142
164
  const out = {};
143
165
  const walk = (dir) => {
144
166
  let ents;
@@ -177,11 +199,16 @@ async function postJson(API, authHeaders, path, body) {
177
199
  return res.json();
178
200
  }
179
201
 
180
- // Send each key from root/server/files to the bucket. Returns the failures.
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.
181
206
  async function upload(API, authHeaders, root, local, keys, onBytes) {
182
207
  const failed = [];
208
+ const dir = filesDir(root);
209
+ const sent = [];
183
210
  await pool(keys, 4, async (key) => {
184
- const file = join(root, FILES_DIR, ...key.split('/'));
211
+ const file = join(root, dir, ...key.split('/'));
185
212
  const size = local[key][0];
186
213
  try {
187
214
  const plan = await postJson(API, authHeaders, '/storage-upload', { key, size });
@@ -193,6 +220,7 @@ async function upload(API, authHeaders, root, local, keys, onBytes) {
193
220
  if (r.status !== 200) throw new Error(`HTTP ${r.status}`);
194
221
  });
195
222
  onBytes(size);
223
+ sent.push(key);
196
224
  return;
197
225
  }
198
226
  const fd = fs.openSync(file, 'r');
@@ -211,6 +239,7 @@ async function upload(API, authHeaders, root, local, keys, onBytes) {
211
239
  onBytes(len);
212
240
  });
213
241
  await postJson(API, authHeaders, '/storage-upload-complete', { key, uploadId: plan.uploadId, etags });
242
+ sent.push(key);
214
243
  } catch (e) {
215
244
  await postJson(API, authHeaders, '/storage-upload-complete', { key, uploadId: plan.uploadId, abort: true }).catch(() => {});
216
245
  throw e;
@@ -221,7 +250,16 @@ async function upload(API, authHeaders, root, local, keys, onBytes) {
221
250
  failed.push(`${key}: ${e.message}`);
222
251
  }
223
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
+ }
224
262
  return failed;
225
263
  }
226
264
 
227
- module.exports = { FILES_DIR, manifest, changedRemote, download, localFiles, changedLocal, upload };
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.3.0",
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"