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.
- package/lib/project-commands.js +112 -35
- package/lib/storage.js +40 -11
- package/package.json +1 -1
package/lib/project-commands.js
CHANGED
|
@@ -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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
211
|
+
|
|
212
|
+
const measure = () => {
|
|
187
213
|
const now = Date.now();
|
|
188
|
-
if (
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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;
|
|
241
|
+
return new Transform({ transform(chunk, enc, cb) { bytes += chunk.length; cb(null, chunk); } });
|
|
200
242
|
},
|
|
201
|
-
|
|
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
|
-
|
|
252
|
+
clearInterval(timer);
|
|
253
|
+
if (tty) process.stdout.write(line());
|
|
206
254
|
const secs = Math.max((Date.now() - start) / 1000, 0.001);
|
|
207
|
-
|
|
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
|
-
|
|
912
|
-
|
|
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
|
-
|
|
943
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
1173
|
+
console.log(` Cloudflare, ...) add these DNS records${sub ? ` to ${kind.zone}` : ''}:\n`);
|
|
1105
1174
|
console.log(' Type Name Value');
|
|
1106
|
-
console.log(` A @
|
|
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
|
|
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
|
-
|
|
1159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|