nukebase 2.2.3 → 2.3.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/bin/nukebase.js CHANGED
@@ -68,6 +68,8 @@ In a project folder (runs as the account that owns the project)
68
68
  pull download code
69
69
  update refresh the NukeBase engine files
70
70
  backup [--out <dir>] download the backup set from the server
71
+ backup --storage also the app's files in file storage (new or
72
+ changed ones, straight from the storage)
71
73
  env push make the hosted environment (process.env) equal
72
74
  to ./.env, then restart the project
73
75
  env list [--show] variable names set on the server
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,
@@ -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,33 @@ 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 (server/files)
1525
+ // go straight to the storage, not to the server, and only the ones it lacks
1526
+ // or holds at another size. Nothing there is deleted.
1527
+ let store = null;
1528
+ let storageLocal = {};
1529
+ let storageUp = [];
1530
+ if (INCLUDE_BACKUP) {
1531
+ try {
1532
+ store = await storage.manifest(API, authHeaders);
1533
+ } catch (e) {
1534
+ console.error('❌ ' + e.message);
1535
+ process.exit(1);
1536
+ }
1537
+ if (store && store.storage) {
1538
+ storageLocal = storage.localFiles(root);
1539
+ storageUp = storage.changedLocal(storageLocal, store.files);
1540
+ }
1541
+ }
1542
+ const inFilesDir = (f) => f === storage.FILES_DIR || f.startsWith(storage.FILES_DIR + '/');
1543
+ const upload = store && store.storage ? synced.upload.filter((f) => !inFilesDir(f)) : synced.upload;
1544
+ const storageBytes = storageUp.reduce((n, k) => n + storageLocal[k][0], 0);
1545
+
1546
+ if (!upload.length && !remove.length && !storageUp.length) {
1521
1547
  console.log('✓ Already up to date');
1522
1548
  if (AUTO_YES) process.exit(0);
1523
1549
  watchLogs();
@@ -1566,10 +1592,16 @@ async function push() {
1566
1592
  console.log(' your copy does not have.');
1567
1593
  console.log('\n The project is stopped before extraction, so the running');
1568
1594
  console.log(' process cannot flush its in-memory database over the upload.');
1569
- } else {
1595
+ } else if (!storageUp.length) {
1570
1596
  console.log('\n No backup-set files found locally — nothing extra to send.');
1571
1597
  console.log(' Run `nukebase backup` first if you meant to restore a copy.');
1572
1598
  }
1599
+ if (store && store.storage) {
1600
+ console.log(`\n File storage: ${storageUp.length} file(s), ${(storageBytes / 1024 / 1024).toFixed(2)} MB from ${storage.FILES_DIR}`);
1601
+ console.log(' go straight to the storage (missing or changed ones only; nothing there is deleted).');
1602
+ storageUp.slice(0, 10).forEach(f => console.log(' ⇈ ' + f));
1603
+ if (storageUp.length > 10) console.log(` ... and ${storageUp.length - 10} more`);
1604
+ }
1573
1605
  console.log('');
1574
1606
  }
1575
1607
 
@@ -1589,13 +1621,28 @@ async function push() {
1589
1621
  }
1590
1622
  }
1591
1623
 
1592
- console.log(`\n📊 Total: ${upload.length} to upload, ${remove.length} to remove`);
1624
+ console.log(`\n📊 Total: ${upload.length} to upload, ${remove.length} to remove` +
1625
+ (storageUp.length ? `, ${storageUp.length} to file storage` : ''));
1593
1626
  const ok = await confirm(`Continue? (y/n) `);
1594
1627
  if (!ok) {
1595
1628
  console.log('Aborted.');
1596
1629
  process.exit(0);
1597
1630
  }
1598
1631
 
1632
+ // Storage first, while the app still runs: it cannot see the new files until
1633
+ // its index is rebuilt at the restart below, so nothing is half-visible.
1634
+ let storageFailed = [];
1635
+ if (storageUp.length) {
1636
+ console.log(`\n File storage (${storageUp.length} file(s)):`);
1637
+ const meter = progressMeter(storageBytes);
1638
+ storageFailed = await storage.upload(API, authHeaders, root, storageLocal, storageUp, (n) => meter.add(n));
1639
+ meter.done();
1640
+ if (storageFailed.length) {
1641
+ console.error(`\n❌ ${storageFailed.length} file(s) did not reach the storage; push again to send them:`);
1642
+ storageFailed.slice(0, 10).forEach(f => console.error(' ' + f));
1643
+ }
1644
+ }
1645
+
1599
1646
  if (upload.length) {
1600
1647
  const sizeOf = (f) => {
1601
1648
  const entry = localFiles[f];
@@ -1672,6 +1719,13 @@ async function push() {
1672
1719
  }
1673
1720
  }
1674
1721
 
1722
+ // Files went to the storage: the app's file index is rebuilt from the
1723
+ // storage when it starts.
1724
+ if (storageUp.length > storageFailed.length) {
1725
+ const r = await fetch(`${API}/storage-reindex`, { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: '{}' });
1726
+ if (!r.ok) console.error('⚠️ Could not reset the file index:', r.status, await r.text());
1727
+ }
1728
+
1675
1729
  console.log('\n🔄 Restarting server...');
1676
1730
 
1677
1731
  // Restart also seals the deploy snapshot server-side, which is what makes it
@@ -1693,15 +1747,17 @@ async function push() {
1693
1747
  process.exit(1);
1694
1748
  }
1695
1749
 
1696
- console.log(`\n✅ Pushed! (${upload.length} uploaded, ${remove.length} removed)`);
1750
+ console.log(`\n✅ Pushed! (${upload.length} uploaded, ${remove.length} removed` +
1751
+ (storageUp.length ? `, ${storageUp.length - storageFailed.length} to file storage` : '') + ')');
1697
1752
  console.log(` Deploy ${deployId} — run 'nukebase rollback' to undo this push`);
1753
+ if (storageFailed.length) process.exitCode = 1;
1698
1754
 
1699
1755
  // Non-interactive: tail logs only until the server settles, then exit so the
1700
1756
  // AI agent / CI job returns on its own. Interactive keeps the live tail above.
1701
1757
  if (AUTO_YES) {
1702
1758
  await tailLogsUntilSettled();
1703
1759
  console.log('\n✅ Deploy complete.');
1704
- process.exit(0);
1760
+ process.exit(storageFailed.length ? 1 : 0);
1705
1761
  }
1706
1762
  }
1707
1763
 
@@ -2238,12 +2294,12 @@ async function backup() {
2238
2294
  changed = (download || []).filter(rel => backupPaths.some(p => rel === p || rel.startsWith(p + '/')));
2239
2295
  fetchBytes = 0;
2240
2296
  if (!changed.length) {
2241
- console.log('\n Nothing to back up — the server has no files under those paths.');
2242
- process.exit(0);
2297
+ console.log('\n The server has no files under those paths.');
2298
+ } else {
2299
+ console.log(`\n ${changed.length} file(s) to download`);
2300
+ changed.slice(0, 10).forEach(f => console.log(' ↓ ' + f));
2301
+ if (changed.length > 10) console.log(` ... and ${changed.length - 10} more`);
2243
2302
  }
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
2303
  } else {
2248
2304
  if (!listRes.ok) {
2249
2305
  console.error('❌ Could not list backup files:', listRes.status, await listRes.text());
@@ -2253,10 +2309,6 @@ async function backup() {
2253
2309
  const { files: serverFiles } = await listRes.json();
2254
2310
  backupSizes = serverFiles || {};
2255
2311
  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
2312
 
2261
2313
  // Same size+mtime comparison sync uses. mtime survives the tar round-trip
2262
2314
  // closely enough that a 1s tolerance avoids re-fetching unchanged files.
@@ -2271,14 +2323,50 @@ async function backup() {
2271
2323
  const totalBytes = names.reduce((n, rel) => n + (serverFiles[rel][0] || 0), 0);
2272
2324
  fetchBytes = changed.reduce((n, rel) => n + (serverFiles[rel][0] || 0), 0);
2273
2325
 
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);
2326
+ if (!names.length) {
2327
+ console.log('\n The server has no files under those paths.');
2328
+ } else {
2329
+ console.log(`\n ${names.length} file(s), ${(totalBytes / 1024 / 1024).toFixed(2)} MB on the server`);
2330
+ if (!changed.length) console.log(' ✓ Already up to date.');
2331
+ else {
2332
+ console.log(` ${changed.length} changed, ${(fetchBytes / 1024 / 1024).toFixed(2)} MB to download`);
2333
+ changed.slice(0, 10).forEach(f => console.log(' ↓ ' + f));
2334
+ if (changed.length > 10) console.log(` ... and ${changed.length - 10} more`);
2335
+ }
2336
+ }
2337
+ }
2338
+
2339
+ // File storage: a project whose files live in object storage. Listed always;
2340
+ // downloaded with --storage, straight from the storage, new or changed only.
2341
+ let store = null;
2342
+ let storageChanged = [];
2343
+ let storageBytes = 0;
2344
+ try {
2345
+ store = await storage.manifest(API, authHeaders);
2346
+ } catch (e) {
2347
+ console.error('⚠️ ' + e.message);
2348
+ }
2349
+ if (store && store.storage) {
2350
+ const keys = Object.keys(store.files);
2351
+ const total = keys.reduce((n, k) => n + store.files[k][0], 0);
2352
+ console.log(`\n File storage: ${keys.length} file(s), ${(total / 1024 / 1024).toFixed(2)} MB`);
2353
+ if (args.has('storage')) {
2354
+ storageChanged = storage.changedRemote(dest, store.files);
2355
+ storageBytes = storageChanged.reduce((n, k) => n + store.files[k][0], 0);
2356
+ if (!storageChanged.length) console.log(' ✓ Already up to date.');
2357
+ else {
2358
+ console.log(` ${storageChanged.length} changed, ${(storageBytes / 1024 / 1024).toFixed(2)} MB to download into ${storage.FILES_DIR}`);
2359
+ storageChanged.slice(0, 10).forEach(f => console.log(' ↓ ' + f));
2360
+ if (storageChanged.length > 10) console.log(` ... and ${storageChanged.length - 10} more`);
2361
+ }
2362
+ } else if (keys.length) {
2363
+ console.log(' Not downloaded. Add --storage to copy them too (only new or changed files).');
2278
2364
  }
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`);
2365
+ }
2366
+
2367
+ if (!changed.length && !storageChanged.length) {
2368
+ console.log('\n Nothing to download.\n');
2369
+ process.exit(0);
2282
2370
  }
2283
2371
 
2284
2372
  const ok = await confirm('\nContinue? (y/n) ');
@@ -2287,11 +2375,25 @@ async function backup() {
2287
2375
  mkdirSync(dest, { recursive: true });
2288
2376
 
2289
2377
  const started = Date.now();
2290
- const result = await downloadStreams(dest, changed,
2291
- (rel) => (backupSizes[rel] && backupSizes[rel][0]) || 0, fetchBytes || 0);
2378
+ if (changed.length) {
2379
+ await downloadStreams(dest, changed,
2380
+ (rel) => (backupSizes[rel] && backupSizes[rel][0]) || 0, fetchBytes || 0);
2381
+ }
2382
+ let storageFailed = [];
2383
+ if (storageChanged.length) {
2384
+ console.log(`\n File storage (${storageChanged.length} file(s)):`);
2385
+ const meter = progressMeter(storageBytes);
2386
+ storageFailed = await storage.download(API, authHeaders, dest, store.files, storageChanged, () => meter.counter());
2387
+ meter.done();
2388
+ }
2292
2389
 
2293
2390
  const secs = ((Date.now() - started) / 1000).toFixed(1);
2294
- console.log(`\n\n✅ Backed up ${changed.length} file(s) in ${secs}s`);
2391
+ if (storageFailed.length) {
2392
+ console.error(`\n❌ ${storageFailed.length} storage file(s) failed; run the backup again to fetch them:`);
2393
+ storageFailed.slice(0, 10).forEach(f => console.error(' ' + f));
2394
+ process.exit(1);
2395
+ }
2396
+ console.log(`\n\n✅ Backed up ${changed.length + storageChanged.length} file(s) in ${secs}s`);
2295
2397
  console.log(` ${dest}`);
2296
2398
  if (dest === root) {
2297
2399
  console.log('\n These paths are in `exclude`, so push and pull will ignore them.');
package/lib/storage.js ADDED
@@ -0,0 +1,227 @@
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. 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>.
7
+ //
8
+ // Every transfer is IPv4: the storage provider stalls parallel uploads over
9
+ // IPv6 (measured from the server: 13 of 18 stalled, against none over IPv4).
10
+ const fs = require('fs');
11
+ const https = require('https');
12
+ const http = require('http');
13
+ const { join, dirname, relative, sep } = require('path');
14
+ const { pipeline } = require('stream/promises');
15
+
16
+ const FILES_DIR = 'server/files';
17
+ const IDLE_MS = 30000;
18
+ const TRIES = 4;
19
+ const PARALLEL = 6;
20
+
21
+ // { storage: false } from a server without storage for this project, null from
22
+ // a server that predates it; otherwise { storage: true, files: { key: [size, mtime] } }.
23
+ async function manifest(API, authHeaders) {
24
+ const files = {};
25
+ let after = null;
26
+ for (;;) {
27
+ const res = await fetch(`${API}/storage-manifest`, {
28
+ method: 'POST',
29
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
30
+ body: JSON.stringify(after ? { after } : {})
31
+ });
32
+ if (res.status === 404) return null;
33
+ if (!res.ok) throw new Error(`Could not list the storage files: ${res.status} ${await res.text()}`);
34
+ const page = await res.json();
35
+ if (!page.storage) return { storage: false, files: {} };
36
+ Object.assign(files, page.files || {});
37
+ if (!page.next) return { storage: true, files };
38
+ after = page.next;
39
+ }
40
+ }
41
+
42
+ // One request to the bucket, IPv4, failing on IDLE_MS without progress.
43
+ // Resolves { status, headers, stream } once headers arrive.
44
+ function request(method, url, body) {
45
+ return new Promise((resolve, reject) => {
46
+ const u = new URL(url);
47
+ const mod = u.protocol === 'http:' ? http : https;
48
+ const req = mod.request({
49
+ method, hostname: u.hostname, port: u.port || undefined, path: u.pathname + u.search, family: 4,
50
+ headers: body ? { 'content-length': body.length } : {}
51
+ }, (resp) => {
52
+ resp.setTimeout(IDLE_MS, () => resp.destroy(new Error('the storage stopped sending')));
53
+ resolve({ status: resp.statusCode, headers: resp.headers, stream: resp });
54
+ });
55
+ req.setTimeout(IDLE_MS, () => req.destroy(new Error('the storage stopped answering')));
56
+ req.on('error', reject);
57
+ req.end(body || undefined);
58
+ });
59
+ }
60
+
61
+ async function retry(what, fn) {
62
+ let last;
63
+ for (let i = 0; i < TRIES; i++) {
64
+ try { return await fn(); } catch (e) { last = e; }
65
+ await new Promise((r) => setTimeout(r, 500 * (i + 1)));
66
+ }
67
+ throw new Error(`${what}: ${last.message}`);
68
+ }
69
+
70
+ // Runs fn over items, `n` at a time.
71
+ async function pool(items, n, fn) {
72
+ let i = 0;
73
+ await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => {
74
+ while (i < items.length) await fn(items[i++]);
75
+ }));
76
+ }
77
+
78
+ // Signed links for many keys, 500 per request.
79
+ async function links(API, authHeaders, keys) {
80
+ const out = {};
81
+ for (let i = 0; i < keys.length; i += 500) {
82
+ const res = await fetch(`${API}/storage-links`, {
83
+ method: 'POST',
84
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
85
+ body: JSON.stringify({ keys: keys.slice(i, i + 500) })
86
+ });
87
+ if (!res.ok) throw new Error(`Could not get download links: ${res.status} ${await res.text()}`);
88
+ Object.assign(out, (await res.json()).links);
89
+ }
90
+ return out;
91
+ }
92
+
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).
96
+ function changedRemote(dest, files) {
97
+ return Object.keys(files).filter((key) => {
98
+ const local = join(dest, FILES_DIR, ...key.split('/'));
99
+ let st;
100
+ try { st = fs.statSync(local); } catch { return true; }
101
+ const [size, mtime] = files[key];
102
+ return st.size !== size || Math.abs(Math.floor(st.mtimeMs / 1000) - mtime) > 1;
103
+ });
104
+ }
105
+
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.
109
+ async function download(API, authHeaders, dest, files, keys, counter) {
110
+ const failed = [];
111
+ for (let i = 0; i < keys.length; i += 500) {
112
+ const batch = keys.slice(i, i + 500);
113
+ const urls = await links(API, authHeaders, batch); // an hour each: fetched per batch
114
+ 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';
119
+ 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);
126
+ const mtime = files[key][1];
127
+ if (mtime) fs.utimesSync(target, mtime, mtime);
128
+ } catch (e) {
129
+ try { fs.unlinkSync(part); } catch {}
130
+ failed.push(e.message);
131
+ }
132
+ });
133
+ }
134
+ return failed;
135
+ }
136
+
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
139
+ // downloads, as { key: [size, mtime] }.
140
+ function localFiles(root) {
141
+ const base = join(root, FILES_DIR);
142
+ const out = {};
143
+ const walk = (dir) => {
144
+ let ents;
145
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
146
+ for (const e of ents) {
147
+ if (e.name.startsWith('$') || e.name.endsWith('.part') || e.name.endsWith('.tmp')) continue;
148
+ const full = join(dir, e.name);
149
+ if (e.isDirectory()) walk(full);
150
+ else if (e.isFile()) {
151
+ const st = fs.statSync(full);
152
+ out[relative(base, full).split(sep).join('/')] = [st.size, Math.floor(st.mtimeMs / 1000)];
153
+ }
154
+ }
155
+ };
156
+ walk(base);
157
+ return out;
158
+ }
159
+
160
+ // What a restore uploads: local files the bucket lacks or holds at another
161
+ // size, or holds older than the local copy. Nothing is ever deleted.
162
+ function changedLocal(local, remote) {
163
+ return Object.keys(local).filter((key) => {
164
+ const r = remote[key];
165
+ if (!r) return true;
166
+ return r[0] !== local[key][0] || local[key][1] > r[1] + 1;
167
+ });
168
+ }
169
+
170
+ async function postJson(API, authHeaders, path, body) {
171
+ const res = await fetch(`${API}${path}`, {
172
+ method: 'POST',
173
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
174
+ body: JSON.stringify(body)
175
+ });
176
+ if (!res.ok) throw new Error(`${path}: ${res.status} ${await res.text()}`);
177
+ return res.json();
178
+ }
179
+
180
+ // Send each key from root/server/files to the bucket. Returns the failures.
181
+ async function upload(API, authHeaders, root, local, keys, onBytes) {
182
+ const failed = [];
183
+ await pool(keys, 4, async (key) => {
184
+ const file = join(root, FILES_DIR, ...key.split('/'));
185
+ const size = local[key][0];
186
+ try {
187
+ const plan = await postJson(API, authHeaders, '/storage-upload', { key, size });
188
+ if (plan.url) {
189
+ const body = fs.readFileSync(file);
190
+ await retry(key, async () => {
191
+ const r = await request('PUT', plan.url, body);
192
+ r.stream.resume();
193
+ if (r.status !== 200) throw new Error(`HTTP ${r.status}`);
194
+ });
195
+ onBytes(size);
196
+ return;
197
+ }
198
+ const fd = fs.openSync(file, 'r');
199
+ try {
200
+ const etags = new Array(plan.parts.length);
201
+ await pool(plan.parts.map((url, i) => ({ url, i })), 4, async ({ url, i }) => {
202
+ const len = Math.min(plan.partSize, size - i * plan.partSize);
203
+ const buf = Buffer.alloc(len);
204
+ fs.readSync(fd, buf, 0, len, i * plan.partSize);
205
+ await retry(`${key} part ${i + 1}`, async () => {
206
+ const r = await request('PUT', url, buf);
207
+ r.stream.resume();
208
+ if (r.status !== 200 || !r.headers.etag) throw new Error(`HTTP ${r.status}`);
209
+ etags[i] = r.headers.etag;
210
+ });
211
+ onBytes(len);
212
+ });
213
+ await postJson(API, authHeaders, '/storage-upload-complete', { key, uploadId: plan.uploadId, etags });
214
+ } catch (e) {
215
+ await postJson(API, authHeaders, '/storage-upload-complete', { key, uploadId: plan.uploadId, abort: true }).catch(() => {});
216
+ throw e;
217
+ } finally {
218
+ fs.closeSync(fd);
219
+ }
220
+ } catch (e) {
221
+ failed.push(`${key}: ${e.message}`);
222
+ }
223
+ });
224
+ return failed;
225
+ }
226
+
227
+ module.exports = { FILES_DIR, manifest, changedRemote, download, localFiles, changedLocal, upload };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nukebase",
3
- "version": "2.2.3",
3
+ "version": "2.3.0",
4
4
  "description": "NukeBase CLI: create, deploy and manage NukeBase projects",
5
5
  "bin": {
6
6
  "nukebase": "bin/nukebase.js"