nukebase 2.5.0 → 2.5.2

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.
@@ -177,34 +177,83 @@ function splitBySize(files, sizeOf, n) {
177
177
  }
178
178
 
179
179
  // One progress line shared by every stream of a transfer. counter() gives each
180
- // stream its own pass-through that adds to the shared total.
180
+ // stream its own pass-through that adds to the shared total; add(n) counts
181
+ // bytes moved without a stream (storage uploads; negative takes back a failed
182
+ // attempt).
183
+ //
184
+ // The speed is the rate over the last few seconds, not the average since the
185
+ // start, and it is redrawn on a timer rather than when bytes arrive:
186
+ // - An upload's bytes are counted as they enter the request. The first
187
+ // several MB land in Node's and the OS's send buffers almost at once
188
+ // (measured ~10 MB on Windows), so a whole-run average opened far above
189
+ // the line speed and took a long time to fall. The window starts only once
190
+ // the buffers have filled (SETTLE_MS after the first byte); from then on
191
+ // the counted bytes move at the real pace.
192
+ // - Redrawing only on data froze the line during a stall, then jumped.
193
+ // Live redraws go to a terminal only (in a log or an AI agent they pile up);
194
+ // the final line, with the whole-run average, is always printed.
181
195
  function progressMeter(totalBytes) {
182
- let bytes = 0;
183
- let last = 0;
196
+ const TICK_MS = 250; // redraw interval
197
+ const SETTLE_MS = 1000; // after the first byte: buffers filling, not measured
198
+ const WINDOW_MS = 5000; // speed = bytes over (at most) this long
199
+ // Shortest span worth showing a speed for. Bytes leave in steps of a few MB
200
+ // (the OS frees send buffer in bursts), so a shorter span can catch a gap
201
+ // between steps and read 0.
202
+ const MIN_SPAN_MS = 2000;
203
+ const SMOOTH = 0.3; // per-tick weight of the newest reading
204
+ const tty = !!process.stdout.isTTY;
184
205
  const start = Date.now();
206
+ let bytes = 0;
207
+ let firstAt = 0;
208
+ let speed = null; // bytes per second, smoothed; null until measured
209
+ const samples = []; // [time, bytes], from SETTLE_MS after the first byte
185
210
  const mb = (n) => (n / 1024 / 1024).toFixed(2);
186
- const draw = (force) => {
211
+
212
+ const measure = () => {
187
213
  const now = Date.now();
188
- if (!force && now - last < 100) return;
189
- last = now;
190
- const secs = (now - start) / 1000;
191
- const speed = secs > 0 ? (bytes / 1024 / 1024) / secs : 0;
192
- const remaining = Math.max(0, totalBytes - bytes);
193
- const eta = speed > 0 ? formatEta((remaining / 1024 / 1024) / speed) : '--:--';
214
+ if (bytes > 0 && !firstAt) firstAt = now;
215
+ if (!firstAt || now - firstAt < SETTLE_MS) return;
216
+ // All of an upload counted: the tail is still in the send buffers, going
217
+ // out at the same pace. Hold the speed rather than let it decay to 0.
218
+ if (totalBytes > 0 && bytes >= totalBytes) return;
219
+ samples.push([now, bytes]);
220
+ while (samples.length > 2 && now - samples[1][0] >= WINDOW_MS) samples.shift();
221
+ const [t0, b0] = samples[0];
222
+ if (now - t0 < MIN_SPAN_MS) return;
223
+ const rate = Math.max(0, (bytes - b0) / ((now - t0) / 1000));
224
+ speed = speed === null ? rate : speed + SMOOTH * (rate - speed);
225
+ };
226
+ const line = () => {
227
+ const shown = totalBytes > 0 ? Math.min(bytes, totalBytes) : bytes;
194
228
  const of = totalBytes > 0 ? ` of ${mb(totalBytes).padStart(7)}` : '';
195
- process.stdout.write(`\r ${mb(bytes).padStart(7)}${of} MB || ${speed.toFixed(2).padStart(6)} MB/s || ETA ${String(eta).padStart(5)} `);
229
+ const rate = speed === null ? ' -- MB/s' : `${(speed / 1024 / 1024).toFixed(2).padStart(6)} MB/s (${Math.round(speed * 8 / 1e6)} Mbps)`;
230
+ const remaining = Math.max(0, totalBytes - bytes);
231
+ const eta = totalBytes > 0 && remaining === 0 ? 'finishing'
232
+ : speed > 0 && totalBytes > 0 ? formatEta(remaining / speed) : '--:--';
233
+ return `\r ${mb(shown).padStart(7)}${of} MB || ${rate} || ETA ${String(eta).padStart(5)} `;
196
234
  };
235
+ const tick = () => { measure(); if (tty) process.stdout.write(line()); };
236
+ const timer = setInterval(tick, TICK_MS);
237
+ if (timer.unref) timer.unref();
238
+
197
239
  return {
198
240
  counter() {
199
- return new Transform({ transform(chunk, enc, cb) { bytes += chunk.length; draw(false); cb(null, chunk); } });
241
+ return new Transform({ transform(chunk, enc, cb) { bytes += chunk.length; cb(null, chunk); } });
200
242
  },
201
- // Bytes moved without a stream to count them (storage uploads).
202
- add(n) { bytes += n; draw(false); },
243
+ add(n) { bytes = Math.max(0, bytes + n); },
203
244
  bytes: () => bytes,
245
+ // For a transfer that failed: stop redrawing and end the line, so the
246
+ // error printed next is not drawn over. done() is for one that finished.
247
+ stop() {
248
+ clearInterval(timer);
249
+ if (tty && firstAt) process.stdout.write('\n');
250
+ },
204
251
  done() {
205
- draw(true);
252
+ clearInterval(timer);
253
+ if (tty) process.stdout.write(line());
206
254
  const secs = Math.max((Date.now() - start) / 1000, 0.001);
207
- console.log(`\n ✓ ${mb(bytes)} MB in ${secs.toFixed(1)}s (avg: ${(bytes / 1024 / 1024 / secs).toFixed(2)} MB/s)`);
255
+ const avg = bytes / secs;
256
+ console.log(`${tty ? '\n' : ''} ✓ ${mb(bytes)} MB in ${secs.toFixed(1)}s (avg: ${(avg / 1024 / 1024).toFixed(2)} MB/s, ${Math.round(avg * 8 / 1e6)} Mbps)`);
208
257
  }
209
258
  };
210
259
  }
@@ -283,6 +332,9 @@ async function downloadStreams(dest, files, sizeOf, totalBytes) {
283
332
  if (!stopRequested) throw err;
284
333
  }
285
334
  }));
335
+ } catch (err) {
336
+ meter.stop();
337
+ throw err;
286
338
  } finally {
287
339
  process.off('SIGINT', onSigint);
288
340
  }
@@ -908,8 +960,9 @@ async function domainCommand() {
908
960
  if (args.has('wait')) {
909
961
  const ip = await regionIp();
910
962
  const needTxt = (await currentDomain()) !== sub; // already ours: proven when connected
911
- printDnsRecords(ip, needTxt);
912
- await waitForDns(sub, ip, needTxt);
963
+ const kind = domainKind(sub);
964
+ printDnsRecords(ip, needTxt, kind);
965
+ await waitForDns(sub, ip, needTxt, kind);
913
966
  }
914
967
  await connectDomain(sub, email);
915
968
  if (args.has('email-domain')) await emailDomainCommand(null);
@@ -939,8 +992,9 @@ async function domainCommand() {
939
992
  if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) { console.log(` "${domain}" does not look like a domain.`); domain = ''; }
940
993
  }
941
994
 
942
- printDnsRecords(ip, true);
943
- await waitForDns(domain, ip, true);
995
+ const kind = domainKind(domain);
996
+ printDnsRecords(ip, true, kind);
997
+ await waitForDns(domain, ip, true, kind);
944
998
 
945
999
  // Step 3 — the server does the real verification and issues the cert.
946
1000
  await connectDomain(domain, email);
@@ -1083,7 +1137,7 @@ async function connectDomain(domain, email) {
1083
1137
  });
1084
1138
  console.log(`\n✓ https://${r.domain} is live.`);
1085
1139
  if (r.wwwNote) console.log(` ${r.wwwNote}`);
1086
- else if (r.verified) console.log(' www redirects to the apex if its A record also points here.');
1140
+ else if (r.verified) console.log(' www redirects to the main domain.');
1087
1141
  }
1088
1142
 
1089
1143
  // This region's public IP, from /region-info. null when it cannot be read;
@@ -1099,31 +1153,47 @@ async function regionIp() {
1099
1153
  // The ownership record the server checks on a domain's first connection.
1100
1154
  const domainTxtValue = () => `nukebase-uid=${uid}`;
1101
1155
 
1102
- function printDnsRecords(ip, withTxt) {
1156
+ // A domain of its own (example.com) or a subdomain (app.example.com), and
1157
+ // what its record is called (@, or "app"). By the dots: two labels is a
1158
+ // domain, three or more a subdomain, except under a two-part ending
1159
+ // (example.co.uk is a domain). Only for the instructions; the server decides
1160
+ // at connect. Same rule as the dashboard's domainKind.
1161
+ const TWO_PART_ENDING = /^(co|com|net|org|gov|edu|ac|ltd|plc|sch)\.[a-z]{2}$/;
1162
+ function domainKind(domain) {
1163
+ const labels = domain.split('.');
1164
+ const base = labels.length >= 3 && TWO_PART_ENDING.test(labels.slice(-2).join('.')) ? 3 : 2;
1165
+ if (labels.length <= base) return { subdomain: false, zone: domain, name: '@' };
1166
+ return { subdomain: true, zone: labels.slice(-base).join('.'), name: labels.slice(0, -base).join('.') };
1167
+ }
1168
+
1169
+ function printDnsRecords(ip, withTxt, kind) {
1170
+ const sub = kind && kind.subdomain;
1171
+ const pad = (s) => s.padEnd(10);
1103
1172
  console.log('\n Step 1 — point it here. At your registrar (GoDaddy, Namecheap,');
1104
- console.log(' Cloudflare, ...) add these DNS records:\n');
1173
+ console.log(` Cloudflare, ...) add these DNS records${sub ? ` to ${kind.zone}` : ''}:\n`);
1105
1174
  console.log(' Type Name Value');
1106
- console.log(` A @ ${ip || "(this region's IP — see the dashboard)"}`);
1107
- console.log(` A www ${ip || '(same)'}`);
1175
+ console.log(` A ${pad(sub ? kind.name : '@')} ${ip || "(this region's IP — see the dashboard)"}`);
1176
+ if (!sub) console.log(` A www ${ip || '(same)'}`);
1108
1177
  if (withTxt) {
1109
1178
  console.log(` TXT nukebase ${domainTxtValue()}`);
1110
1179
  console.log('\n The TXT record proves the domain is yours, so no other account can');
1111
1180
  console.log(' connect it. One on your main domain covers its subdomains too.');
1112
1181
  }
1182
+ if (sub) console.log(`\n ${kind.name}.${kind.zone} is a subdomain, so no www record is needed.`);
1113
1183
  console.log('\n If you use Cloudflare, set the records to "DNS only" (grey cloud)');
1114
1184
  console.log(' so the certificate can be issued.\n');
1115
1185
  }
1116
1186
 
1117
1187
  // Step 2 — wait for DNS. Poll quietly; Enter forces a check; Ctrl+C leaves
1118
1188
  // the records in place and the command can be re-run later. Resolves once
1119
- // the apex points here; says so if www does not yet.
1189
+ // the apex and www point here (only the apex for a subdomain).
1120
1190
  //
1121
1191
  // Ask public resolvers directly, not the machine's. Two reasons: the OS
1122
1192
  // resolver caches, so a record changed a minute ago can look unchanged here
1123
1193
  // long after the server sees it — and on some Windows setups Node's own
1124
1194
  // resolver cannot reach the system DNS server at all (ECONNREFUSED on every
1125
1195
  // name). getaddrinfo is the fallback for a machine that blocks 1.1.1.1.
1126
- async function waitForDns(domain, ip, needTxt) {
1196
+ async function waitForDns(domain, ip, needTxt, kind) {
1127
1197
  console.log(' Step 2 — waiting for DNS. This can take a few minutes.');
1128
1198
  console.log(' Press Enter to check now, Ctrl+C to stop and come back later.\n');
1129
1199
  const dns = require('dns').promises;
@@ -1144,6 +1214,12 @@ async function waitForDns(domain, ip, needTxt) {
1144
1214
  return false;
1145
1215
  };
1146
1216
  let apexOk = false, wwwOk = false, txtOk = !needTxt;
1217
+ // The server connects a domain of its own with www or not at all (since
1218
+ // 2026-11); only a subdomain (no NS records of its own) goes without it.
1219
+ // By the dots, not "resolveNs says ENODATA": a subdomain whose record is
1220
+ // not added yet is NXDOMAIN, which read as a domain of its own and waited
1221
+ // for www.
1222
+ const subdomain = (kind || domainKind(domain)).subdomain;
1147
1223
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1148
1224
  let poke = null;
1149
1225
  rl.on('line', () => { if (poke) poke(); });
@@ -1155,18 +1231,15 @@ async function waitForDns(domain, ip, needTxt) {
1155
1231
  wwwOk = ip ? www.includes(ip) : www.length > 0;
1156
1232
  txtOk = txt;
1157
1233
  const mark = ok => ok ? '✓' : '…';
1158
- process.stdout.write(`\r ${mark(apexOk)} ${domain} ${apex.length ? '→ ' + apex.join(', ') : '(no record yet)'} ${mark(wwwOk)} www ${www.length ? '→ ' + www.join(', ') : '(no record yet)'}${needTxt ? ` ${mark(txtOk)} TXT ${txtOk ? 'found' : '(not yet)'}` : ''} `);
1159
- if (apexOk && txtOk) break;
1234
+ const wwwPart = subdomain ? '' : ` ${mark(wwwOk)} www ${www.length ? '→ ' + www.join(', ') : '(no record yet)'}`;
1235
+ process.stdout.write(`\r ${mark(apexOk)} ${domain} ${apex.length ? '→ ' + apex.join(', ') : '(no record yet)'}${wwwPart}${needTxt ? ` ${mark(txtOk)} TXT ${txtOk ? 'found' : '(not yet)'}` : ''} `);
1236
+ if (apexOk && txtOk && (wwwOk || subdomain)) break;
1160
1237
  await wait(15000);
1161
1238
  }
1162
1239
  } finally {
1163
1240
  rl.close();
1164
1241
  }
1165
1242
  console.log('\n');
1166
- if (!wwwOk) {
1167
- console.log(' www is not pointing here yet — connecting the apex only. Re-run');
1168
- console.log(' `nukebase domain ' + domain + '` later to add the www redirect.\n');
1169
- }
1170
1243
  }
1171
1244
 
1172
1245
  // nukebase logs [--follow] the recent journal for the unit; --follow keeps
@@ -1649,7 +1722,9 @@ async function push() {
1649
1722
  if (storageUp.length) {
1650
1723
  console.log(`\n File storage (${storageUp.length} file(s)):`);
1651
1724
  const meter = progressMeter(storageBytes);
1652
- storageFailed = await storage.upload(API, authHeaders, root, storageLocal, storageUp, (n) => meter.add(n));
1725
+ try {
1726
+ storageFailed = await storage.upload(API, authHeaders, root, storageLocal, storageUp, (n) => meter.add(n));
1727
+ } catch (e) { meter.stop(); throw e; }
1653
1728
  meter.done();
1654
1729
  if (storageFailed.length) {
1655
1730
  console.error(`\n❌ ${storageFailed.length} file(s) did not reach the storage; push again to send them:`);
@@ -2415,7 +2490,9 @@ async function backup() {
2415
2490
  if (storageChanged.length) {
2416
2491
  console.log(`\n File storage (${storageChanged.length} file(s)):`);
2417
2492
  const meter = progressMeter(storageBytes);
2418
- storageFailed = await storage.download(API, authHeaders, dest, store.files, storageChanged, () => meter.counter());
2493
+ try {
2494
+ storageFailed = await storage.download(API, authHeaders, dest, store.files, storageChanged, () => meter.counter());
2495
+ } catch (e) { meter.stop(); throw e; }
2419
2496
  meter.done();
2420
2497
  }
2421
2498
 
package/lib/storage.js CHANGED
@@ -50,8 +50,12 @@ async function manifest(API, authHeaders) {
50
50
  }
51
51
 
52
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) {
53
+ // Resolves { status, headers, stream } once headers arrive. With onSent, the
54
+ // body goes out in SLICE pieces and onSent(n) is called as each one is handed
55
+ // to the network, so the progress line moves while a part uploads instead of
56
+ // jumping by a whole part (up to 16 MB) when it finishes.
57
+ const SLICE = 256 * 1024;
58
+ function request(method, url, body, onSent) {
55
59
  return new Promise((resolve, reject) => {
56
60
  const u = new URL(url);
57
61
  const mod = u.protocol === 'http:' ? http : https;
@@ -64,10 +68,37 @@ function request(method, url, body) {
64
68
  });
65
69
  req.setTimeout(IDLE_MS, () => req.destroy(new Error('the storage stopped answering')));
66
70
  req.on('error', reject);
67
- req.end(body || undefined);
71
+ if (!body || !onSent) { req.end(body || undefined); return; }
72
+ let off = 0;
73
+ const writeMore = () => {
74
+ while (off < body.length) {
75
+ const end = Math.min(off + SLICE, body.length);
76
+ const n = end - off;
77
+ const more = req.write(body.subarray(off, end), (err) => { if (!err) onSent(n); });
78
+ off = end;
79
+ if (!more) { req.once('drain', writeMore); return; }
80
+ }
81
+ req.end();
82
+ };
83
+ writeMore();
68
84
  });
69
85
  }
70
86
 
87
+ // One upload attempt whose bytes are reported as they go out. If the attempt
88
+ // fails, what it reported is taken back, so a retried part is not counted
89
+ // twice and the line cannot pass the total.
90
+ async function counted(onBytes, attempt) {
91
+ let n = 0;
92
+ let over = false; // a write callback landing after the failure adds nothing
93
+ try {
94
+ return await attempt((k) => { if (over) return; n += k; onBytes(k); });
95
+ } catch (e) {
96
+ over = true;
97
+ if (n) onBytes(-n);
98
+ throw e;
99
+ }
100
+ }
101
+
71
102
  async function retry(what, fn) {
72
103
  let last;
73
104
  for (let i = 0; i < TRIES; i++) {
@@ -214,12 +245,11 @@ async function upload(API, authHeaders, root, local, keys, onBytes) {
214
245
  const plan = await postJson(API, authHeaders, '/storage-upload', { key, size });
215
246
  if (plan.url) {
216
247
  const body = fs.readFileSync(file);
217
- await retry(key, async () => {
218
- const r = await request('PUT', plan.url, body);
248
+ await retry(key, () => counted(onBytes, async (onSent) => {
249
+ const r = await request('PUT', plan.url, body, onSent);
219
250
  r.stream.resume();
220
251
  if (r.status !== 200) throw new Error(`HTTP ${r.status}`);
221
- });
222
- onBytes(size);
252
+ }));
223
253
  sent.push(key);
224
254
  return;
225
255
  }
@@ -230,13 +260,12 @@ async function upload(API, authHeaders, root, local, keys, onBytes) {
230
260
  const len = Math.min(plan.partSize, size - i * plan.partSize);
231
261
  const buf = Buffer.alloc(len);
232
262
  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);
263
+ await retry(`${key} part ${i + 1}`, () => counted(onBytes, async (onSent) => {
264
+ const r = await request('PUT', url, buf, onSent);
235
265
  r.stream.resume();
236
266
  if (r.status !== 200 || !r.headers.etag) throw new Error(`HTTP ${r.status}`);
237
267
  etags[i] = r.headers.etag;
238
- });
239
- onBytes(len);
268
+ }));
240
269
  });
241
270
  await postJson(API, authHeaders, '/storage-upload-complete', { key, uploadId: plan.uploadId, etags });
242
271
  sent.push(key);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nukebase",
3
- "version": "2.5.0",
3
+ "version": "2.5.2",
4
4
  "description": "NukeBase CLI: create, deploy and manage NukeBase projects",
5
5
  "bin": {
6
6
  "nukebase": "bin/nukebase.js"