nukebase 2.2.2 → 2.2.3

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.
@@ -1,2487 +1,2499 @@
1
- // Commands that act on the project in the current folder: push, pull, update,
2
- // rollback, backup, env, pause/restart/resume/delete, domain, logs, download and
3
- // regiontransfer. Loaded by bin/nukebase.js, which runs the command named in
4
- // process.argv when this module is required.
5
- const { existsSync, readdirSync, statSync, lstatSync, readlinkSync, symlinkSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, rmdirSync } = require('fs');
6
- const readline = require('readline');
7
- const crypto = require('crypto');
8
- const http = require('http');
9
- const https = require('https');
10
- const { tmpdir } = require('os');
11
- const { join, dirname, relative, resolve, isAbsolute, sep } = require('path');
12
- const { Transform, PassThrough } = require('stream');
13
- const { pipeline } = require('stream/promises');
14
- const tar = require('tar');
15
- const createClient = require('./sdk');
16
- const creds = require('./credentials');
17
- const loginFlows = require('./login');
18
- const { findProject, readConfig, writeConfig, restoreSettings, migrateLegacy } = require('./project');
19
- const { regionApi, hostOf } = require('./ui');
20
-
21
- const args = require('./args').parse(process.argv.slice(2));
22
- const command = args.command;
23
- // --yes / -y makes push/pull non-interactive: auto-confirm the file diff and,
24
- // after pushing, tail logs only until the server settles (then exit) — so an AI
25
- // agent or CI job never blocks on the y/n prompt or the live log stream.
26
- const AUTO_YES = args.has('yes');
27
- const BIN = require.resolve('../bin/nukebase.js');
28
-
29
- const root = findProject(process.cwd());
30
- if (!root) {
31
- console.error('Not inside a NukeBase project (no package.json with a "nukebase" section here or above).');
32
- console.error(' nukebase create <name> make a new one');
33
- console.error(' nukebase link <name> connect this folder to an existing one');
34
- process.exit(1);
35
- }
36
- let config = readConfig(root);
37
- let { project, exclude, API } = config;
38
- let uid = null, token = null, accountLabel = null;
39
-
40
- // `--region <name>` / `--api <url>` point THIS invocation at another region
41
- // without touching package.json. The account is the same everywhere (auth
42
- // replicates), the project is not — so this is how a command reaches a copy
43
- // of the project elsewhere: `nukebase delete --region usw3` after a transfer,
44
- // or the transfer itself driving the target. A region's API is its name as
45
- // the first host label of the configured one (usw4.nukebase.com →
46
- // usw3.nukebase.com); --api is the escape hatch for anything else.
47
- const ELSEWHERE = !!(args.get('api') || args.get('region'));
48
- if (args.get('api')) API = args.get('api').replace(/\/$/, '');
49
- else if (args.get('region')) API = regionApi(API, args.get('region'));
50
-
51
- // `--as <email|uid>` runs this invocation as another signed-in account. Only
52
- // for reaching a copy of the project elsewhere (--region/--api) — the region
53
- // transfer drives the target that way when the project changes accounts — so a
54
- // typo can never push this folder into the wrong account in its own region.
55
- const AS = args.get('as');
56
-
57
- // ---------------------------------------------------------------------------
58
- // The backup set
59
- // ---------------------------------------------------------------------------
60
- // package.json carries two path lists. They are INDEPENDENT — each does
61
- // exactly one job, and neither implies the other:
62
- //
63
- // exclude what push and pull skip. Nothing else reads it.
64
- // backup what `nukebase backup` copies down and what a deploy snapshots.
65
- // Nothing about sync.
66
- //
67
- // They overlap in a default project — server/data is in both — but that is two
68
- // separate decisions that happen to agree, not one implying the other. Keeping
69
- // them independent is what lets you take a path out of `exclude` and push it
70
- // deliberately, without also changing what gets backed up.
71
- //
72
- // Why exclude ships with the data paths: sync moves whole trees in both
73
- // directions, and treating the running app's own state as ordinary project code
74
- // was destructive twice over —
75
- //
76
- // DELETE /sync reports every server file absent from the client as
77
- // `remove`, so a push from a fresh git clone listed the live
78
- // database, the backups and the file store as deletions.
79
- //
80
- // OVERWRITE worse, because it took no mistake at all. `pull` downloaded
81
- // server/data, so your machine held a stale copy, and the next push
82
- // saw it differ and uploaded it back OVER the live database. Pull on
83
- // Monday, push on Friday, production is back to Monday.
84
- //
85
- // The backup default matches the engine's own DATA_DIR / BACKUP_DIR / FILES_DIR
86
- // in database.js. FILES_DIR is env-overridable there, but nothing in the hosted
87
- // stack sets it (systemd-run passes only SOCKET and DOMAIN), so the default is
88
- // correct on the platform; the override exists for anyone whose layout differs.
89
- const DEFAULT_BACKUP_PATHS = ['server/data', 'server/files'];
90
- const backupPaths = Array.isArray(config.backup) && config.backup.length
91
- ? config.backup
92
- : DEFAULT_BACKUP_PATHS;
93
-
94
- // `nukebase push --include backup` — a one-off opt-in that carries the backup
95
- // set UP, for seeding a new project or restoring a copy taken with
96
- // `nukebase backup`. Off by default: a deploy has no business rewriting the
97
- // database, and this is the only way to ask it to.
98
- //
99
- // Two tokens rather than --include-backup, so it reads the same as
100
- // `nukebase rollback backup` — the word "backup" always names the same set.
101
- const INCLUDE_BACKUP = args.get('include') === 'backup';
102
-
103
- // `.env` is never part of a code push or pull, whatever package.json says.
104
- // It is the input to `nukebase env push`, which carries it to a root-owned file
105
- // OUTSIDE the project on the server. An ordinary push copying it into the
106
- // project tree would put secrets where the ZIP download and every restore
107
- // point can see them, and a pull would overwrite the local file with whatever
108
- // the server had. Hard-wired here, not left to the exclude list, so projects
109
- // created before the template listed it are covered too.
110
- const ENV_FILE = '.env';
111
- const syncExclude = (exclude || []).includes(ENV_FILE) ? (exclude || []) : [...(exclude || []), ENV_FILE];
112
-
113
- // What THIS machine's file walk skips. Identical to `syncExclude`, except that
114
- // --include backup lifts the backup paths so the local copies are picked up.
115
- //
116
- // The server is still sent `syncExclude` with the backup paths INTACT, and that
117
- // asymmetry is deliberate. /sync derives its delete list from files it can see
118
- // on the server but not on the client, so un-excluding the data paths there
119
- // too would make a restore that is missing one shard delete the live one. With
120
- // the server still excluding them: nothing under those paths can ever be
121
- // deleted, while every local file still uploads (it compares against "absent",
122
- // so it always differs).
123
- const localExclude = INCLUDE_BACKUP
124
- ? syncExclude.filter(e => !backupPaths.includes(e))
125
- : syncExclude;
126
-
127
- // ---- Parallel streams (push, pull, backup) ----------------------------------
128
- //
129
- // One TCP connection is often capped far below the line speed — measured on a
130
- // 500 Mbps home line to ovh4: ~100-120 Mbps per connection with the server
131
- // idle, 520 Mbps with four at once. So a large transfer is split into STREAMS
132
- // groups of roughly equal size that move concurrently.
133
- //
134
- // package.json "streams": 4 (default, 1-8)
135
- // --streams <n> override for one command; --streams 1 is the old behaviour
136
- //
137
- // Small transfers (under PARALLEL_MIN_BYTES and PARALLEL_MIN_FILES) stay one
138
- // stream: a typical code push gains nothing from splitting.
139
- const STREAMS = (() => {
140
- let n = args.get('streams') ? parseInt(args.get('streams'), 10) : parseInt(config.streams, 10);
141
- if (!Number.isFinite(n)) n = 4;
142
- return Math.min(Math.max(n, 1), 8);
143
- })();
144
- const PARALLEL_MIN_BYTES = 32 * 1024 * 1024;
145
- const PARALLEL_MIN_FILES = 200;
146
-
147
- function wantParallel(fileCount, bytes) {
148
- return STREAMS > 1 && fileCount > 1 && (bytes >= PARALLEL_MIN_BYTES || fileCount >= PARALLEL_MIN_FILES);
149
- }
150
-
151
- // Split `files` into up to n groups of roughly equal bytes: biggest first,
152
- // each into the lightest group. The 512 per file is a tar header, so a pile of
153
- // tiny files still spreads out instead of all landing in one group.
154
- function splitBySize(files, sizeOf, n) {
155
- if (n <= 1 || files.length <= 1) return [files];
156
- const groups = Array.from({ length: Math.min(n, files.length) }, () => ({ files: [], bytes: 0 }));
157
- const sorted = [...files].sort((a, b) => sizeOf(b) - sizeOf(a));
158
- for (const file of sorted) {
159
- let lightest = groups[0];
160
- for (const g of groups) if (g.bytes < lightest.bytes) lightest = g;
161
- lightest.files.push(file);
162
- lightest.bytes += sizeOf(file) + 512;
163
- }
164
- return groups.map(g => g.files).filter(l => l.length);
165
- }
166
-
167
- // One progress line shared by every stream of a transfer. counter() gives each
168
- // stream its own pass-through that adds to the shared total.
169
- function progressMeter(totalBytes) {
170
- let bytes = 0;
171
- let last = 0;
172
- const start = Date.now();
173
- const mb = (n) => (n / 1024 / 1024).toFixed(2);
174
- const draw = (force) => {
175
- const now = Date.now();
176
- if (!force && now - last < 100) return;
177
- last = now;
178
- const secs = (now - start) / 1000;
179
- const speed = secs > 0 ? (bytes / 1024 / 1024) / secs : 0;
180
- const remaining = Math.max(0, totalBytes - bytes);
181
- const eta = speed > 0 ? formatEta((remaining / 1024 / 1024) / speed) : '--:--';
182
- const of = totalBytes > 0 ? ` of ${mb(totalBytes).padStart(7)}` : '';
183
- process.stdout.write(`\r ${mb(bytes).padStart(7)}${of} MB || ${speed.toFixed(2).padStart(6)} MB/s || ETA ${String(eta).padStart(5)} `);
184
- };
185
- return {
186
- counter() {
187
- return new Transform({ transform(chunk, enc, cb) { bytes += chunk.length; draw(false); cb(null, chunk); } });
188
- },
189
- bytes: () => bytes,
190
- done() {
191
- draw(true);
192
- const secs = Math.max((Date.now() - start) / 1000, 0.001);
193
- console.log(`\n ✓ ${mb(bytes)} MB in ${secs.toFixed(1)}s (avg: ${(bytes / 1024 / 1024 / secs).toFixed(2)} MB/s)`);
194
- }
195
- };
196
- }
197
-
198
- // Ask the server to get a parallel push ready: take this deploy's restore
199
- // point for every file up front (concurrent /upload requests cannot each
200
- // rewrite it) and hand the project to the tenant so the per-stream extractors
201
- // can write. Returns how many streams to use: STREAMS if the server agreed,
202
- // 1 for a server that predates /upload-prepare (404) or declines (the master
203
- // project) — which is then exactly the old single-stream push.
204
- async function prepareParallelUpload(deployId, files) {
205
- try {
206
- const res = await fetch(`${API}/upload-prepare`, {
207
- method: 'POST',
208
- headers: authHeaders({
209
- 'Content-Type': 'application/json',
210
- 'X-Deploy-Id': deployId,
211
- 'X-Backup-Paths': JSON.stringify(backupPaths),
212
- ...(INCLUDE_BACKUP ? { 'X-Include-Backup': '1' } : {})
213
- }),
214
- body: JSON.stringify({ files })
215
- });
216
- if (!res.ok) return 1;
217
- const body = await res.json().catch(() => ({}));
218
- return body && body.parallel === true ? STREAMS : 1;
219
- } catch (e) {
220
- return 1;
221
- }
222
- }
223
-
224
- // Download `files` into `dest` over one or several /download streams, each
225
- // extracted as it arrives. Returns { bytes, stopped }.
226
- // Ctrl-C once: every stream stops after the file it is writing; twice: quit.
227
- async function downloadStreams(dest, files, sizeOf, totalBytes) {
228
- const groups = wantParallel(files.length, totalBytes)
229
- ? splitBySize(files, sizeOf, STREAMS)
230
- : [files];
231
- if (groups.length > 1) console.log(` (${groups.length} parallel streams)`);
232
-
233
- const meter = progressMeter(totalBytes);
234
- let stopRequested = false;
235
- const active = []; // { stream, inFlight }
236
-
237
- const onSigint = () => {
238
- if (stopRequested) {
239
- console.log('\n⚠ Forcing quit. Files currently being written may be partial.');
240
- process.exit(130);
241
- }
242
- stopRequested = true;
243
- console.log('\n⏹ Stop requested — finishing the files in progress. Press Ctrl-C again to force quit.');
244
- for (const a of active) if (!a.inFlight) a.stream.destroy();
245
- };
246
- process.on('SIGINT', onSigint);
247
-
248
- try {
249
- await Promise.all(groups.map(async (group) => {
250
- const res = await postJsonStream(`${API}/download`,
251
- authHeaders({ 'Content-Type': 'application/json' }),
252
- JSON.stringify({ files: group }));
253
- if (!res.ok) throw new Error(`Download failed: ${res.status} ${await res.text()}`);
254
- const slot = { stream: res.stream, inFlight: false };
255
- active.push(slot);
256
- // Same containment rule the server applies on /upload: a corrupted or
257
- // hostile archive must not write outside `dest` or plant a link out of it.
258
- const extractor = tar.x({ cwd: dest, filter: (p, e) => tarEntryAllowed(dest, p, e) });
259
- extractor.on('entry', (entry) => {
260
- slot.inFlight = true;
261
- entry.on('end', () => {
262
- slot.inFlight = false;
263
- if (stopRequested) res.stream.destroy();
264
- });
265
- });
266
- try {
267
- await pipeline(res.stream, meter.counter(), extractor);
268
- } catch (err) {
269
- if (!stopRequested) throw err;
270
- }
271
- }));
272
- } finally {
273
- process.off('SIGINT', onSigint);
274
- }
275
- meter.done();
276
- return { bytes: meter.bytes(), stopped: stopRequested };
277
- }
278
-
279
- const dbEndpoint = API.replace(/^https:/, 'wss:').replace(/^http:/, 'ws:').replace(/\/$/, '');
280
- let db = null;
281
-
282
- function die(...lines) {
283
- console.error(`❌ ${lines[0]}`);
284
- for (const l of lines.slice(1)) console.error(` ${l}`);
285
- process.exit(1);
286
- }
287
-
288
- const loginHint = (label) => String(label || '').includes('@') ? `nukebase login --email ${label}` : 'nukebase login --password';
289
-
290
- // Work out which account this command runs as, first match wins:
291
- // NUKEBASE_TOKEN (+ NUKEBASE_UID) a build token, for CI
292
- // --as <email|uid> another signed-in account (elsewhere only)
293
- // the project's uid the normal case
294
- // The token comes from ~/.nukebase/credentials.json, never from the project.
295
- async function connectAndLogin() {
296
- // Projects from the old CLI keep their sign-in in sys/config.json. Move it
297
- // out first. A region without CLI sign-in yet can't take it, and then the
298
- // old token is used as before.
299
- const migrated = await migrateLegacy(root, config);
300
- config = migrated.config;
301
-
302
- if (migrated.legacy) {
303
- uid = config.uid; token = config.token;
304
- accountLabel = config.email || config.username || config.uid;
305
- if (config.username && config.password) {
306
- const r = await fetch(`${API}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: config.username, password: config.password }) })
307
- .then(r => r.json()).catch(() => ({}));
308
- if (r.status !== 'Success') die('Login failed.');
309
- uid = r.uid; token = r.token;
310
- }
311
- if (!token) die(`${hostOf(API)} doesn't support CLI sign-in yet, and this project has no saved sign-in.`);
312
- } else if (process.env.NUKEBASE_TOKEN) {
313
- uid = process.env.NUKEBASE_UID || config.uid;
314
- token = process.env.NUKEBASE_TOKEN;
315
- accountLabel = `build token (${uid})`;
316
- if (!uid) die('NUKEBASE_TOKEN is set but NUKEBASE_UID is not.');
317
- } else if (AS) {
318
- const acct = creds.find(AS);
319
- if (!acct) die(`${AS} isn't signed in on this machine.`, `Run: ${loginHint(AS)}`);
320
- if (acct.uid !== config.uid && !ELSEWHERE) {
321
- die(`This project belongs to ${config.account || config.uid}, not ${acct.label}.`,
322
- '--as another account only works with --region or --api, to reach a copy of the project elsewhere.');
323
- }
324
- ({ uid, token } = acct);
325
- accountLabel = acct.label || acct.uid;
326
- } else {
327
- if (!config.uid) die('The "nukebase" section of package.json has no "uid", so this folder has no owner.', 'Run: nukebase link <project>');
328
- const acct = creds.get(config.uid);
329
- if (!acct) {
330
- die(`This project belongs to ${config.account || 'uid'} (${config.uid}), which isn't signed in on this machine.`,
331
- `Run: ${loginHint(config.account)}`);
332
- }
333
- ({ uid, token } = acct);
334
- accountLabel = acct.label || acct.uid;
335
- }
336
- if (!process.env.NUKEBASE_TOKEN) creds.touch(uid);
337
-
338
- console.log(`as ${accountLabel}${String(accountLabel).includes(uid) ? '' : ` (${uid})`} → ${project} on ${hostOf(API)}`);
339
- db = await createClient(dbEndpoint, { uid, token });
340
- }
341
-
342
- // A 401 means this machine's token was signed out (`nukebase logout`, or
343
- // Signed-in devices on the dashboard). Say how to sign back in.
344
- function authExpired() {
345
- if (process.env.NUKEBASE_TOKEN) die('NUKEBASE_TOKEN was revoked. Create a new build token on the dashboard.');
346
- die(`The sign-in for ${accountLabel} (${uid}) is no longer valid. It was signed out or revoked.`, `Run: ${loginHint(accountLabel)}`);
347
- }
348
-
349
- // Deploy ids are ISO timestamps with the separators stripped, so they are safe
350
- // as a single path segment and sort chronologically as plain strings. Must match
351
- // the server's own format (see newDeployId in server/app.js).
352
- function newDeployId() {
353
- return new Date().toISOString().replace(/[:.]/g, '-');
354
- }
355
-
356
- // Auth headers from the current (possibly just-refreshed) uid/token.
357
- function authHeaders(extra = {}) {
358
- return {
359
- ...extra,
360
- 'X-project': project,
361
- 'Cookie': `uid=${uid}; token=${token}`
362
- };
363
- }
364
-
365
- // Symlinks ARE part of the sync contract — but only where this machine can
366
- // actually create one.
367
- //
368
- // Windows refuses symlink creation unless Developer Mode is on or the process
369
- // is elevated (SeCreateSymbolicLinkPrivilege), so probe once instead of
370
- // guessing from process.platform: a Windows box WITH Developer Mode gets full
371
- // fidelity, and one without degrades to the old file-only behaviour rather than
372
- // failing a pull halfway through extraction.
373
- //
374
- // This answer is sent to the server, which then lists links only if we said yes.
375
- // Both sides must agree on whether a link exists, or every sync sees a
376
- // difference it cannot resolve and re-transfers it forever.
377
- const SYMLINKS = (() => {
378
- const probe = join(tmpdir(), `nukebase-symlink-probe-${process.pid}-${Date.now()}`);
379
- try {
380
- symlinkSync('probe-target', probe);
381
- return true;
382
- } catch (e) {
383
- return false;
384
- } finally {
385
- try { unlinkSync(probe); } catch {}
386
- }
387
- })();
388
-
389
- // A link may only point INSIDE the project, so the project stays self-contained
390
- // and means the same thing wherever it is unpacked. Checked lexically rather
391
- // than with realpath because node_modules/.bin links are routinely dangling at
392
- // extract time (tar can create the link before its target), and because lexical
393
- // resolution has no TOCTOU window. Mirrors linkTargetInside() in server/app.js.
394
- function linkTargetInside(root, linkAbs, target) {
395
- if (typeof target !== 'string' || !target) return false;
396
- if (isAbsolute(target) || /^[a-zA-Z]:/.test(target)) return false;
397
- const resolved = resolve(dirname(linkAbs), target);
398
- return resolved === root || resolved.startsWith(root + sep);
399
- }
400
-
401
- // Manifest entries are [size, mtime] for a regular file and ["L", target] for a
402
- // symlink. Size is always a number, so the string sentinel cannot collide.
403
- const isLinkEntry = (e) => Array.isArray(e) && e[0] === 'L';
404
-
405
- // withFileTypes gives lstat semantics, so isSymbolicLink() describes the entry
406
- // itself rather than its target. readlink reads that target as a STRING and
407
- // never opens it, so a link is described without ever being followed.
408
- function getFiles(dir, root = dir) {
409
- if (!existsSync(dir)) return {};
410
- return Object.fromEntries(
411
- readdirSync(dir, { withFileTypes: true }).flatMap(ent => {
412
- const path = join(dir, ent.name);
413
- const rel = relative(root, path).replace(/\\/g, '/');
414
- if (localExclude.includes(ent.name) || localExclude.includes(rel)) return [];
415
- // Saved sessions for --as (a token for another account) never leave
416
- // this machine, whatever the exclude list says.
417
- if (rel.startsWith('sys/.session-')) return [];
418
- if (ent.isSymbolicLink()) {
419
- if (!SYMLINKS) return [];
420
- let target;
421
- try { target = readlinkSync(path); } catch (e) { return []; }
422
- if (!linkTargetInside(root, path, target)) return [];
423
- // Normalise to forward slashes so a link written on Windows and one
424
- // written on Linux compare equal instead of re-syncing on every run.
425
- return [[rel, ['L', target.replace(/\\/g, '/')]]];
426
- }
427
- if (ent.isDirectory()) return Object.entries(getFiles(path, root));
428
- if (!ent.isFile()) return [];
429
- const s = lstatSync(path);
430
- return [[rel, [s.size, Math.floor(s.mtimeMs / 1000)]]];
431
- })
432
- );
433
- }
434
-
435
- // Shared by the push and pull tar streams: the same containment rule the server
436
- // applies on /upload, so a malicious or corrupted archive cannot write outside
437
- // the project or plant a link pointing out of it on a developer's machine.
438
- function tarEntryAllowed(root, entryPath, entry) {
439
- const resolved = resolve(root, entryPath);
440
- if (resolved !== root && !resolved.startsWith(root + sep)) return false;
441
- if (entry && entry.type === 'SymbolicLink') {
442
- if (!SYMLINKS) return false;
443
- return linkTargetInside(root, resolved, entry.linkpath);
444
- }
445
- if (entry && entry.type === 'Link') return false;
446
- return true;
447
- }
448
-
449
- // Confirmation for the one command that destroys data. Requires typing an exact
450
- // word rather than "y", so it cannot be cleared by a reflex keystroke — and
451
- // deliberately does NOT honour --yes: an unattended agent or CI job has no
452
- // business discarding a production database without a human present.
453
- function confirmTyped(expected, question) {
454
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
455
- return new Promise(resolve => {
456
- let answered = false;
457
- rl.question(question, answer => {
458
- answered = true;
459
- rl.close();
460
- resolve(answer.trim() === expected);
461
- });
462
- rl.on('close', () => { if (!answered) resolve(false); });
463
- });
464
- }
465
-
466
- function confirm(question) {
467
- // Non-interactive (--yes): skip the prompt entirely.
468
- if (AUTO_YES) {
469
- console.log(question + 'y ▶ auto-confirmed (--yes)');
470
- return Promise.resolve(true);
471
- }
472
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
473
- return new Promise(resolve => {
474
- let answered = false;
475
- rl.question(question, answer => {
476
- answered = true;
477
- rl.close();
478
- resolve(answer.trim().toLowerCase() === 'y');
479
- });
480
- // If stdin hits EOF with no answer (piped / no terminal), abort instead of
481
- // hanging forever. Pass --yes to auto-confirm in that situation.
482
- rl.on('close', () => { if (!answered) resolve(false); });
483
- });
484
- }
485
-
486
- function cleanEmptyDirs(filePath, stopAt) {
487
- let dir = dirname(filePath);
488
- while (dir !== stopAt) {
489
- try {
490
- if (readdirSync(dir).length === 0) {
491
- rmdirSync(dir);
492
- dir = dirname(dir);
493
- } else break;
494
- } catch { break; }
495
- }
496
- }
497
-
498
- function formatEta(seconds) {
499
- if (!seconds || !isFinite(seconds) || seconds < 0) return '--:--';
500
-
501
- const mins = Math.floor(seconds / 60);
502
- const secs = Math.floor(seconds % 60);
503
-
504
- if (mins >= 60) {
505
- const hours = Math.floor(mins / 60);
506
- const remainMins = mins % 60;
507
- return `${hours}:${remainMins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
508
- }
509
-
510
- return `${mins}:${secs.toString().padStart(2, '0')}`;
511
- }
512
-
513
- function makeClickable(line) {
514
- const match = line.match(/(\/[^:\s]+\.js):(\d+)(?::(\d+))?/);
515
-
516
- if (!match) return line;
517
-
518
- const [fullMatch, filePath, lineNum, colNum] = match;
519
-
520
- const localCwd = process.cwd();
521
- const localFilePath = join(localCwd, filePath);
522
-
523
- const vscodeUrl = colNum
524
- ? `vscode://file/${localFilePath.replace(/\\/g, '/')}:${lineNum}:${colNum}`
525
- : `vscode://file/${localFilePath.replace(/\\/g, '/')}:${lineNum}`;
526
-
527
- const coloredLine = line.replace(
528
- fullMatch,
529
- `\x1b[36m${fullMatch}\x1b[0m`
530
- );
531
-
532
- return `\x1b]8;;${vscodeUrl}\x1b\\${coloredLine}\x1b]8;;\x1b\\`;
533
- }
534
-
535
- function watchLogs() {
536
- console.log('\n📡 Watching live logs... (Press Ctrl+C to stop)\n');
537
- db.getSub({ path: ["users", uid, "projects", project, "logs", "logline"] }, (data) => {
538
- if (data.data) {
539
- const logText = typeof data.data === 'string' ? data.data : data.data.message || JSON.stringify(data.data);
540
- console.log(makeClickable(logText));
541
- }
542
- });
543
- }
544
-
545
- // Bounded log tail for --yes mode: stream logs until the server goes quiet (no
546
- // new line for idleMs) or a hard cap (maxMs) is hit, then resolve so the caller
547
- // can exit. Lets an AI agent watch the restart output and terminate on its own.
548
- function tailLogsUntilSettled({ idleMs = 4000, maxMs = 15000 } = {}) {
549
- return new Promise(resolve => {
550
- console.log('\n📡 Tailing logs until the server settles...\n');
551
- let idleTimer = null;
552
- let finished = false;
553
- const finish = () => {
554
- if (finished) return;
555
- finished = true;
556
- if (idleTimer) clearTimeout(idleTimer);
557
- clearTimeout(hardTimer);
558
- resolve();
559
- };
560
- const bump = () => {
561
- if (idleTimer) clearTimeout(idleTimer);
562
- idleTimer = setTimeout(finish, idleMs);
563
- };
564
- const hardTimer = setTimeout(finish, maxMs);
565
- db.getSub({ path: ["users", uid, "projects", project, "logs", "logline"] }, (data) => {
566
- if (data.data) {
567
- const logText = typeof data.data === 'string' ? data.data : data.data.message || JSON.stringify(data.data);
568
- console.log(makeClickable(logText));
569
- }
570
- bump();
571
- });
572
- bump(); // start the idle countdown even if no logs arrive
573
- });
574
- }
575
-
576
- function postStream(urlString, headers, stream) {
577
- return new Promise((resolve, reject) => {
578
- const url = new URL(urlString);
579
- const transport = url.protocol === 'http:' ? http : https;
580
- const req = transport.request({
581
- protocol: url.protocol,
582
- hostname: url.hostname,
583
- port: url.port || (url.protocol === 'http:' ? 80 : 443),
584
- path: `${url.pathname}${url.search}`,
585
- method: 'POST',
586
- highWaterMark: 1 * 1024 * 1024,
587
- headers: {
588
- ...cleanHeaders(headers),
589
- 'Connection': 'close'
590
- }
591
- }, (res) => {
592
- let body = '';
593
- res.setEncoding('utf8');
594
- res.on('data', chunk => { body += chunk; });
595
- res.on('end', () => {
596
- resolve({
597
- ok: res.statusCode >= 200 && res.statusCode < 300,
598
- status: res.statusCode,
599
- statusText: res.statusMessage || '',
600
- text: async () => body
601
- });
602
- });
603
- });
604
-
605
- req.on('error', reject);
606
- pipeline(stream, req).catch(err => {
607
- req.destroy(err);
608
- reject(err);
609
- });
610
- });
611
- }
612
-
613
- function postJsonStream(urlString, headers, body) {
614
- return new Promise((resolve, reject) => {
615
- const url = new URL(urlString);
616
- const transport = url.protocol === 'http:' ? http : https;
617
- const reqBody = Buffer.from(body);
618
- const req = transport.request({
619
- protocol: url.protocol,
620
- hostname: url.hostname,
621
- port: url.port || (url.protocol === 'http:' ? 80 : 443),
622
- path: `${url.pathname}${url.search}`,
623
- method: 'POST',
624
- headers: {
625
- ...cleanHeaders(headers),
626
- 'Content-Length': String(reqBody.length),
627
- 'Connection': 'close'
628
- }
629
- }, (res) => {
630
- resolve({
631
- ok: res.statusCode >= 200 && res.statusCode < 300,
632
- status: res.statusCode,
633
- statusText: res.statusMessage || '',
634
- headers: res.headers,
635
- stream: res,
636
- text: () => new Promise((resolveText, rejectText) => {
637
- let text = '';
638
- res.setEncoding('utf8');
639
- res.on('data', chunk => { text += chunk; });
640
- res.on('end', () => resolveText(text));
641
- res.on('error', rejectText);
642
- })
643
- });
644
- });
645
-
646
- req.on('error', reject);
647
- req.end(reqBody);
648
- });
649
- }
650
-
651
- function cleanHeaders(headers) {
652
- return Object.fromEntries(
653
- Object.entries(headers).filter(([, value]) => value !== undefined && value !== null)
654
- );
655
- }
656
-
657
- // `nukebase push` onto a region that has no project of this name yet.
658
- //
659
- // Creates it through the same /project the dashboard uses — template files,
660
- // nginx block, unit started, project node with a tier — then returns so the
661
- // push proceeds and replaces the template with the local files, package.json
662
- // and its settings included. The mail key the new region's easy-login needs
663
- // is in no file: the platform hands it to the process at every start.
664
- //
665
- // Size comes from --tier <name>, else a prompt, else the server's default.
666
- // The server enforces plan and wallet for that region and its error is
667
- // printed verbatim — a project cannot be created where there is no credit.
668
- async function offerCreateProject() {
669
- const host = (() => { try { return new URL(API).host; } catch (e) { return API; } })();
670
- console.log(`\n📦 Project "${project}" does not exist on ${host}.`);
671
-
672
- let catalog = null;
673
- try {
674
- const r = await fetch(`${API}/tiers`);
675
- if (r.ok) catalog = await r.json();
676
- } catch (e) {}
677
- const order = catalog?.order || [];
678
- const tiers = catalog?.tiers || {};
679
- const fmt = k => `${tiers[k].name} — ${tiers[k].memoryHigh}B RAM — $${(tiers[k].monthlyCents / 100).toFixed(0)}/mo`;
680
-
681
- if (!(await confirm(`Create it on ${host}? (y/n) `))) {
682
- console.log('Aborted. Nothing was created.');
683
- return false;
684
- }
685
-
686
- // --tier takes a tier name or its RAM, like `create`: --tier 256mb.
687
- let tier = args.get('tier');
688
- if (tier && order.length) {
689
- const resolved = require('./account-commands').resolveTier(tier, catalog);
690
- if (!resolved) {
691
- console.error(`❌ Unknown --tier "${tier}". One of: ${order.join(', ')}`);
692
- return false;
693
- }
694
- tier = resolved;
695
- }
696
- if (!tier && order.length) {
697
- const def = catalog.default || order[0];
698
- if (AUTO_YES) {
699
- tier = def;
700
- console.log(` Size: ${fmt(tier)} ▶ default (--yes; pass --tier <name> to choose)`);
701
- } else {
702
- console.log('\n Sizes:');
703
- order.forEach((k, i) => console.log(` ${i + 1}) ${fmt(k)}${k === def ? ' (default)' : ''}`));
704
- const answer = (await askLine(` Size [${def}]: `)).trim().toLowerCase();
705
- if (answer === '') tier = def;
706
- else if (/^\d+$/.test(answer) && order[Number(answer) - 1]) tier = order[Number(answer) - 1];
707
- else if (tiers[answer]) tier = answer;
708
- else { console.error(`❌ Unknown size "${answer}".`); return false; }
709
- }
710
- }
711
-
712
- console.log(`\n🚀 Creating "${project}" on ${host}${tier ? ` (${tiers[tier]?.name || tier})` : ''}...`);
713
- let res;
714
- try {
715
- res = await fetch(`${API}/project`, {
716
- method: 'POST',
717
- headers: authHeaders({ 'Content-Type': 'application/json' }),
718
- body: JSON.stringify(tier ? { tier } : {})
719
- });
720
- } catch (e) {
721
- // undici reports every transport failure as "fetch failed" and hides the
722
- // reason in .cause. Setup takes a while (template copy, nginx reload,
723
- // unit start, then a ZIP streamed back) and the connection can drop
724
- // partway — after the project already exists server-side. So say what
725
- // happened, then check rather than assume.
726
- console.error('❌ Create failed:', e.cause?.message || e.message);
727
- return await createdAnyway();
728
- }
729
- if (!res.ok) {
730
- let msg = '';
731
- try { const j = await res.json(); msg = j.error || JSON.stringify(j); } catch (e) { msg = await res.text().catch(() => ''); }
732
- console.error(`❌ Create failed (${res.status}): ${msg}`);
733
- return false;
734
- }
735
- // The response is the template ZIP the dashboard would download. This
736
- // machine already has the project, so drain and drop it — but the server
737
- // only finishes setup (unit start, registration) as the stream completes,
738
- // so it must be read to the end rather than abandoned.
739
- try {
740
- await res.arrayBuffer();
741
- } catch (e) {
742
- console.error('⚠️ Connection dropped while the server finished setup:', e.cause?.message || e.message);
743
- return await createdAnyway();
744
- }
745
- console.log(`✓ Created. The template it started with is replaced by this push.`);
746
- return true;
747
-
748
- async function createdAnyway() {
749
- const r = await fetch(`${API}/sync`, {
750
- method: 'POST',
751
- headers: authHeaders({ 'Content-Type': 'application/json' }),
752
- body: JSON.stringify({ exclude: syncExclude, files: {}, direction: 'push', symlinks: SYMLINKS })
753
- }).catch(() => null);
754
- if (r && r.ok) {
755
- console.log('✓ The project exists on the server anyway — continuing with the push.');
756
- return true;
757
- }
758
- console.error(' The project was not created. Run the push again to retry.');
759
- return false;
760
- }
761
- }
762
-
763
- // One line of free text from the terminal. Empty string under --yes, or when
764
- // stdin has no terminal, so callers fall back to their default.
765
- function askLine(question) {
766
- if (AUTO_YES || !process.stdin.isTTY) return Promise.resolve('');
767
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
768
- return new Promise(resolve => {
769
- let answered = false;
770
- rl.question(question, answer => { answered = true; rl.close(); resolve(answer); });
771
- rl.on('close', () => { if (!answered) resolve(''); });
772
- });
773
- }
774
-
775
- // ---------------------------------------------------------------------------
776
- // Project lifecycle — the dashboard's card buttons, from the terminal.
777
- // ---------------------------------------------------------------------------
778
- // Every one of these calls the same endpoint the dashboard does, with the same
779
- // body, so behaviour cannot drift between the two. They all address the project
780
- // in package.json.
781
-
782
- // POST to a project endpoint; resolves the parsed JSON, exits with the server's
783
- // message on failure. A 401 says how to sign back in.
784
- // `verify`, when given, is asked whether the operation actually happened
785
- // after a TRANSPORT failure — the connection dying is not the same as the
786
- // server not doing the work. The domain step is the standing example: issuing
787
- // the certificate ends with an nginx reload, and a reloading nginx sends
788
- // GOAWAY on every open HTTP/2 connection, this one included. The browser
789
- // retries silently; here we check the result instead.
790
- async function projectPost(path, body, { label, verify } = {}) {
791
- const send = () => fetch(`${API}${path}`, {
792
- method: 'POST',
793
- headers: authHeaders({ 'Content-Type': 'application/json' }),
794
- body: JSON.stringify(body || {})
795
- });
796
- let res;
797
- try {
798
- res = await send();
799
- if (res.status === 401) authExpired();
800
- } catch (e) {
801
- const why = e.cause?.message || e.message;
802
- if (verify) {
803
- const ok = await verify(why);
804
- if (ok) return ok;
805
- }
806
- console.error(`❌ ${label || path} failed:`, why);
807
- process.exit(1);
808
- }
809
- let data = {};
810
- try { data = await res.json(); } catch (e) {}
811
- if (!res.ok) {
812
- console.error(`❌ ${label || path} failed (${res.status}): ${data.error || JSON.stringify(data)}`);
813
- process.exit(1);
814
- }
815
- return data;
816
- }
817
-
818
- // nukebase pause — stop the unit. Base fee and disk keep accruing (that is
819
- // the published rule); CPU, memory and traffic stop.
820
- async function pauseProject() {
821
- await connectAndLogin();
822
- const r = await projectPost('/pause', {}, { label: 'pause' });
823
- console.log(`⏸ ${r.message || `Paused ${project}`}`);
824
- process.exit(0);
825
- }
826
-
827
- // nukebase restart — bounce the unit. Also how a paused project resumes.
828
- async function restartProject() {
829
- await connectAndLogin();
830
- await projectPost('/restart', {}, { label: command });
831
- console.log(command === 'restart' ? `🔄 Restarting ${project}...` : `▶️ Resuming ${project}...`);
832
- if (AUTO_YES) { await tailLogsUntilSettled(); process.exit(0); }
833
- watchLogs();
834
- }
835
-
836
- // nukebase delete — the project, its files, its unit, its nginx block and
837
- // its custom domain. Irreversible, so the name must be typed in full; --yes
838
- // does NOT skip this one.
839
- async function deleteProject() {
840
- await connectAndLogin();
841
- const host = (() => { try { return new URL(API).host; } catch (e) { return API; } })();
842
- console.log(`\n⚠️ This deletes "${project}" on ${host}: files, database, backups,`);
843
- console.log(' restore points and any custom domain. There is no undo.');
844
- console.log(' Your local copy is not touched.\n');
845
- if (!(await confirmTyped(project, `Type the project name (${project}) to confirm: `))) {
846
- console.log('Aborted. Nothing was deleted.');
847
- process.exit(1);
848
- }
849
- await projectPost('/delete-project', {}, { label: 'delete' });
850
- console.log(`🗑️ Deleted ${project}.`);
851
- process.exit(0);
852
- }
853
-
854
- // nukebase domain guided: DNS records, wait, connect
855
- // nukebase domain <name> [--email you@x] one shot, for scripts (no waiting)
856
- // nukebase domain remove disconnect it
857
- // nukebase domain email [remove] send the app's emails from it
858
- //
859
- // The guided flow is the dashboard's modal in the terminal: it prints the two
860
- // A records and the ownership TXT (nukebase.<domain> = nukebase-uid=<uid>,
861
- // which the server requires on a domain's first connection so no other
862
- // account can claim it) to create, polls DNS until they are in place (Enter to check now,
863
- // Ctrl+C to give up and come back later), then asks the server to verify and
864
- // issue the certificate — the slow part, up to a minute at Let's Encrypt.
865
- // --email is the cert contact; it defaults to the account email in
866
- // package.json.
867
- async function domainCommand() {
868
- await connectAndLogin();
869
- const sub = args.positional[0] || null;
870
-
871
- if (sub === 'remove') {
872
- const cur = await currentDomain();
873
- if (!cur) { console.log(`🌐 ${project} has no custom domain to remove.`); process.exit(0); }
874
- if (!(await confirm(`Disconnect ${cur} from ${project}? The certificate is deleted too. (y/n) `))) {
875
- console.log('Aborted.'); process.exit(1);
876
- }
877
- await projectPost('/remove-domain', {}, { label: 'domain remove' });
878
- console.log('✓ Custom domain removed. The project is still served at its nukebase.com address.');
879
- process.exit(0);
880
- }
881
-
882
- if (sub === 'email') {
883
- await emailDomainCommand(args.positional[1] || null);
884
- process.exit(0);
885
- }
886
-
887
- const email = args.get('email') || (String(accountLabel).includes('@') ? accountLabel : null);
888
-
889
- // One shot: name given, no hand-holding. Scripts and people who already
890
- // set DNS use this; the server still verifies before it issues anything.
891
- // `--wait` adds the DNS poll from the guided flow first — what the region
892
- // transfer uses, since it knows the name but the records still have to move.
893
- if (sub) {
894
- if (args.has('wait')) {
895
- const ip = await regionIp();
896
- const needTxt = (await currentDomain()) !== sub; // already ours: proven when connected
897
- printDnsRecords(ip, needTxt);
898
- await waitForDns(sub, ip, needTxt);
899
- }
900
- await connectDomain(sub, email);
901
- if (args.has('email-domain')) await emailDomainCommand(null);
902
- process.exit(0);
903
- }
904
-
905
- // Guided.
906
- const cur = await currentDomain();
907
- if (cur) {
908
- console.log(`🌐 ${project} is served at https://${cur}`);
909
- console.log(' nukebase domain <name> to replace it, nukebase domain remove to disconnect.');
910
- process.exit(0);
911
- }
912
-
913
- if (!process.stdin.isTTY || AUTO_YES) {
914
- console.log('Usage: nukebase domain <name> [--email you@example.com] | nukebase domain remove');
915
- process.exit(1);
916
- }
917
-
918
- const ip = await regionIp();
919
-
920
- console.log(`\n🌐 Connect a custom domain to ${project}\n`);
921
- let domain = '';
922
- while (!domain) {
923
- domain = (await askLine(' Domain (e.g. example.com): ')).trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/^www\./, '');
924
- if (!domain) { console.log(' Aborted.'); process.exit(1); }
925
- if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) { console.log(` "${domain}" does not look like a domain.`); domain = ''; }
926
- }
927
-
928
- printDnsRecords(ip, true);
929
- await waitForDns(domain, ip, true);
930
-
931
- // Step 3 — the server does the real verification and issues the cert.
932
- await connectDomain(domain, email);
933
-
934
- // Optional: the app's emails from this domain too.
935
- if (args.has('email-domain') || await confirm(`\n✉️ Send this app's emails from ${domain} too (instead of nukebase.com)? (y/n) `)) {
936
- await emailDomainCommand(null);
937
- } else {
938
- console.log(' Later: nukebase domain email');
939
- }
940
- process.exit(0);
941
-
942
- async function currentDomain() {
943
- const r = await db.get(["users", uid, "projects", project, "customDomain"]).catch(() => null);
944
- return r && r.data ? r.data : null;
945
- }
946
- }
947
-
948
- // nukebase domain email [--root | --sub] [--wait]
949
- // nukebase domain email remove
950
- //
951
- // Send the app's emails from its custom domain. The setup is only the
952
- // domain and its DNS: the address, sender name and Reply-To are chosen per
953
- // email in code (sendEmail's `from` / `replyTo`). The sending domain is either the
954
- // custom domain itself (--root) or mail.<domain> (--sub; safest when the
955
- // domain already has email). Prints the two DNS records to add, then waits
956
- // for the mail provider to verify them (Enter to check now, Ctrl+C to leave
957
- // it: the server keeps checking every 10 minutes for a week). Run again to
958
- // see the status, change the Reply-To, or switch between --root and --sub.
959
- async function emailDomainCommand(action) {
960
- if (action === 'remove') {
961
- if (!(await confirm(`Stop sending ${project}'s emails from its domain? They go back to nukebase.com. (y/n) `))) {
962
- console.log('Aborted.'); process.exit(1);
963
- }
964
- await projectPost('/remove-email-domain', {}, { label: 'email remove' });
965
- console.log('✓ Email sending domain removed. Emails are sent from nukebase.com again.');
966
- return;
967
- }
968
-
969
- const check = await projectPost('/email-domain-check', {}, { label: 'email' });
970
- const domain = check.domain;
971
- const current = check.current;
972
- const wantRoot = args.has('root') ? true : args.has('sub') ? false : null;
973
- const replyTo = args.get('reply-to');
974
- const fromArg = args.get('from');
975
- const changing = wantRoot !== null && (!current || current.root !== wantRoot);
976
-
977
- let rec = current;
978
- if (!current || changing || replyTo !== undefined || fromArg !== undefined) {
979
- // --from / --reply-to are only carried by a region transfer, for setups
980
- // made when those were part of it.
981
- const from = fromArg;
982
- let root = wantRoot;
983
- if (root === null) root = current ? current.root : null;
984
- if (root === null) {
985
- const suggestRoot = check.suggested === 'root';
986
- console.log(`\n✉️ Send ${project}'s emails from ${domain}\n`);
987
- console.log(` 1) anything@${domain}`);
988
- console.log(` ${check.hasEmail ? 'This domain already has email set up' + (check.spf ? ' (you will update its SPF record)' : '') + '.' : 'Recommended: this domain has no email set up yet.'}`);
989
- console.log(` 2) anything@${domain}, sent via mail.${domain}`);
990
- console.log(' Safest if you already use email on this domain: its records are not touched.\n');
991
- if (process.stdin.isTTY && !AUTO_YES) {
992
- const a = (await askLine(` Choose 1 or 2 [${suggestRoot ? 1 : 2}]: `)).trim();
993
- root = a === '1' ? true : a === '2' ? false : suggestRoot;
994
- } else {
995
- root = suggestRoot;
996
- }
997
- }
998
- const reply = replyTo;
999
- const body = { root };
1000
- if (typeof from === 'string' && from) body.from = from;
1001
- if (typeof reply === 'string') body.replyTo = reply;
1002
- rec = (await projectPost('/email-domain', body, { label: 'email' })).emailDomain;
1003
- }
1004
-
1005
- printEmailRecords(rec);
1006
- if (rec.status === 'verified') return;
1007
- if (!args.has('wait') && (!process.stdin.isTTY || AUTO_YES)) {
1008
- console.log(' The server checks every 10 minutes. See the status with: nukebase domain email');
1009
- return;
1010
- }
1011
- await waitForEmailDomain();
1012
- }
1013
-
1014
- function printEmailRecords(rec) {
1015
- const ok = rec.status === 'verified';
1016
- const fromDomain = rec.fromDomain || (rec.root ? rec.name : rec.name.replace(/^mail\./, ''));
1017
- console.log(`\n Sending domain: ${rec.name} ${ok ? '✓ verified' : '… waiting for DNS'}`);
1018
- console.log(` Sends from: any address @${fromDomain} — your code picks it: sendEmail({ from: "Your Name <support>" })`);
1019
- if (ok) { console.log(''); return; }
1020
- console.log('\n At your registrar, add these DNS records:\n');
1021
- for (const r of rec.records || []) {
1022
- console.log(` ${r.valid ? '✓' : '…'} TXT ${r.host}`);
1023
- if (r.replace) console.log(' REPLACE your existing SPF record (only one is allowed) with:');
1024
- console.log(` ${r.value}\n`);
1025
- }
1026
- }
1027
-
1028
- async function waitForEmailDomain() {
1029
- console.log(' Waiting for verification. Enter to check now, Ctrl+C to stop (the server keeps checking).\n');
1030
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1031
- let poke = null;
1032
- rl.on('line', () => { if (poke) poke(); });
1033
- const wait = (ms) => new Promise(res => { poke = res; setTimeout(res, ms); });
1034
- try {
1035
- for (;;) {
1036
- const r = await projectPost('/email-domain-status', {}, { label: 'email status' });
1037
- const rec = r.emailDomain;
1038
- if (!rec) { console.log('\n The sending domain is gone. Run: nukebase domain email'); return; }
1039
- const marks = (rec.records || []).map(x => `${x.valid ? '✓' : '…'} ${x.kind.toUpperCase()}`).join(' ');
1040
- process.stdout.write(`\r ${marks} `);
1041
- if (rec.status === 'verified') {
1042
- console.log(`\n\n✓ Verified. ${project}'s emails now go out from @${rec.fromDomain || rec.name}.`);
1043
- return;
1044
- }
1045
- await wait(20000);
1046
- }
1047
- } finally {
1048
- rl.close();
1049
- }
1050
- }
1051
-
1052
- async function connectDomain(domain, email) {
1053
- console.log(`🔐 Step 3 — verifying DNS from the server and issuing SSL for ${domain} (up to a minute)...`);
1054
- const r = await projectPost('/custom-domain', email ? { domain, email } : { domain }, {
1055
- label: 'domain',
1056
- // The connection dropped (nginx reload, see projectPost). The server
1057
- // records the domain on the project as its LAST step, so poll for that:
1058
- // present means the certificate was issued and nginx is serving it.
1059
- verify: async (why) => {
1060
- // Quiet: the drop is an implementation detail (nginx reloading), not
1061
- // something the user can act on. The wait reads as part of step 3.
1062
- for (let i = 0; i < 30; i++) {
1063
- const cur = await db.get(["users", uid, "projects", project, "customDomain"]).catch(() => null);
1064
- if (cur && cur.data === domain) return { domain, verified: true };
1065
- await new Promise(r => setTimeout(r, 3000));
1066
- }
1067
- return null;
1068
- }
1069
- });
1070
- console.log(`\n✓ https://${r.domain} is live.`);
1071
- if (r.wwwNote) console.log(` ${r.wwwNote}`);
1072
- else if (r.verified) console.log(' www redirects to the apex if its A record also points here.');
1073
- }
1074
-
1075
- // This region's public IP, from /region-info. null when it cannot be read;
1076
- // the DNS wait then accepts any A record rather than a specific one.
1077
- async function regionIp() {
1078
- try {
1079
- const r = await fetch(`${API}/region-info`, { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: '{}' });
1080
- if (r.ok) return (await r.json()).serverIp || null;
1081
- } catch (e) {}
1082
- return null;
1083
- }
1084
-
1085
- // The ownership record the server checks on a domain's first connection.
1086
- const domainTxtValue = () => `nukebase-uid=${uid}`;
1087
-
1088
- function printDnsRecords(ip, withTxt) {
1089
- console.log('\n Step 1 — point it here. At your registrar (GoDaddy, Namecheap,');
1090
- console.log(' Cloudflare, ...) add these DNS records:\n');
1091
- console.log(' Type Name Value');
1092
- console.log(` A @ ${ip || "(this region's IP — see the dashboard)"}`);
1093
- console.log(` A www ${ip || '(same)'}`);
1094
- if (withTxt) {
1095
- console.log(` TXT nukebase ${domainTxtValue()}`);
1096
- console.log('\n The TXT record proves the domain is yours, so no other account can');
1097
- console.log(' connect it. One on your main domain covers its subdomains too.');
1098
- }
1099
- console.log('\n If you use Cloudflare, set the records to "DNS only" (grey cloud)');
1100
- console.log(' so the certificate can be issued.\n');
1101
- }
1102
-
1103
- // Step 2 — wait for DNS. Poll quietly; Enter forces a check; Ctrl+C leaves
1104
- // the records in place and the command can be re-run later. Resolves once
1105
- // the apex points here; says so if www does not yet.
1106
- //
1107
- // Ask public resolvers directly, not the machine's. Two reasons: the OS
1108
- // resolver caches, so a record changed a minute ago can look unchanged here
1109
- // long after the server sees it — and on some Windows setups Node's own
1110
- // resolver cannot reach the system DNS server at all (ECONNREFUSED on every
1111
- // name). getaddrinfo is the fallback for a machine that blocks 1.1.1.1.
1112
- async function waitForDns(domain, ip, needTxt) {
1113
- console.log(' Step 2 — waiting for DNS. This can take a few minutes.');
1114
- console.log(' Press Enter to check now, Ctrl+C to stop and come back later.\n');
1115
- const dns = require('dns').promises;
1116
- const pub = new dns.Resolver(); pub.setServers(['1.1.1.1', '8.8.8.8']);
1117
- const lookup = async (name) => {
1118
- try { return await pub.resolve4(name); } catch (e) {}
1119
- try { return (await dns.lookup(name, { all: true, family: 4 })).map(a => a.address); } catch (e) { return []; }
1120
- };
1121
- // Same rule as the server: nukebase.<domain>, then each parent.
1122
- const want = domainTxtValue().toLowerCase();
1123
- const txtFound = async () => {
1124
- const labels = domain.split('.');
1125
- for (let i = 0; i <= labels.length - 2; i++) {
1126
- let recs;
1127
- try { recs = await pub.resolveTxt('nukebase.' + labels.slice(i).join('.')); } catch (e) { continue; }
1128
- if (recs.some(chunks => chunks.join('').trim().toLowerCase() === want)) return true;
1129
- }
1130
- return false;
1131
- };
1132
- let apexOk = false, wwwOk = false, txtOk = !needTxt;
1133
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1134
- let poke = null;
1135
- rl.on('line', () => { if (poke) poke(); });
1136
- const wait = (ms) => new Promise(res => { poke = res; setTimeout(res, ms); });
1137
- try {
1138
- for (;;) {
1139
- const [apex, www, txt] = await Promise.all([lookup(domain), lookup(`www.${domain}`), needTxt ? txtFound() : true]);
1140
- apexOk = ip ? apex.includes(ip) : apex.length > 0;
1141
- wwwOk = ip ? www.includes(ip) : www.length > 0;
1142
- txtOk = txt;
1143
- const mark = ok => ok ? '✓' : '…';
1144
- 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)'}` : ''} `);
1145
- if (apexOk && txtOk) break;
1146
- await wait(15000);
1147
- }
1148
- } finally {
1149
- rl.close();
1150
- }
1151
- console.log('\n');
1152
- if (!wwwOk) {
1153
- console.log(' www is not pointing here yet — connecting the apex only. Re-run');
1154
- console.log(' `nukebase domain ' + domain + '` later to add the www redirect.\n');
1155
- }
1156
- }
1157
-
1158
- // nukebase logs [--follow] the recent journal for the unit; --follow keeps
1159
- // streaming new lines (Ctrl+C to stop), the same feed push tails.
1160
- async function logsCommand() {
1161
- await connectAndLogin();
1162
- const r = await projectPost('/logs', {}, { label: 'logs' });
1163
- const lines = Array.isArray(r.lines) ? r.lines : [];
1164
- if (r.truncated) console.log(' (older output truncated)');
1165
- for (const l of lines) console.log(makeClickable(typeof l === 'string' ? l : JSON.stringify(l)));
1166
- if (args.has('follow')) { watchLogs(); return; }
1167
- process.exit(0);
1168
- }
1169
-
1170
- // nukebase download [--out <file.zip>] the project ZIP the dashboard's
1171
- // "Download ZIP" gives: a byte-for-byte snapshot of the server's folder,
1172
- // node_modules and sys/ included. Streams to disk rather than buffering.
1173
- async function downloadCommand() {
1174
- await connectAndLogin();
1175
- const out = args.get('out') || join(process.cwd(), `${project}.zip`);
1176
- let res;
1177
- try {
1178
- res = await fetch(`${API}/download-project?project=${encodeURIComponent(project)}`, {
1179
- headers: authHeaders()
1180
- });
1181
- } catch (e) {
1182
- console.error('❌ download failed:', e.cause?.message || e.message);
1183
- process.exit(1);
1184
- }
1185
- if (!res.ok) {
1186
- let msg = ''; try { msg = (await res.json()).error; } catch (e) {}
1187
- console.error(`❌ download failed (${res.status}): ${msg || ''}`);
1188
- process.exit(1);
1189
- }
1190
- const { pipeline } = require('stream/promises');
1191
- const { Readable } = require('stream');
1192
- const fsMod = require('fs');
1193
- let received = 0;
1194
- const meter = new (require('stream').Transform)({
1195
- transform(chunk, enc, cb) { received += chunk.length; process.stdout.write(`\r📥 ${(received / 1048576).toFixed(1)} MB`); cb(null, chunk); }
1196
- });
1197
- await pipeline(Readable.fromWeb(res.body), meter, fsMod.createWriteStream(out));
1198
- console.log(`\n✓ Saved ${out}`);
1199
- process.exit(0);
1200
- }
1201
-
1202
- // ---------------------------------------------------------------------------
1203
- // nukebase regiontransfer [region]
1204
- // ---------------------------------------------------------------------------
1205
- // Move this project to another region, step by step, by driving the commands
1206
- // above in the right order. No new server logic: each step is something you
1207
- // could type yourself, and the transfer prints each one as it runs it so a
1208
- // failure part-way leaves you knowing exactly where you are.
1209
- //
1210
- // 1 create the project on the target and push the code (source still up)
1211
- // 2 pause the source (downtime starts)
1212
- // 3 backup the source's data down to this machine
1213
- // 4 push the backup set up to the target (downtime ends)
1214
- // 5 copy the hosted environment variables
1215
- // 6 custom domain, if there is one — DNS has to move to the target's IP
1216
- // 7 point package.json at the target
1217
- //
1218
- // The source is left PAUSED, never deleted: verify the target, then
1219
- // `nukebase delete --region <source>` when satisfied. Base fee and disk keep
1220
- // accruing on a paused project, so do not forget.
1221
- async function regionTransfer() {
1222
- await connectAndLogin();
1223
-
1224
- // -- where from, where to
1225
- let info;
1226
- try {
1227
- const r = await fetch(`${API}/region-info`, { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: '{}' });
1228
- info = r.ok ? await r.json() : null;
1229
- } catch (e) {}
1230
- if (!info) { console.error('❌ Could not read region info from the server.'); process.exit(1); }
1231
- const source = info.thisRegion;
1232
- const others = (info.allRegions || []).filter(r => r !== source);
1233
-
1234
- // A region name, a list number, a hostname or a URL are all accepted:
1235
- // "usw4", "2", "usw4.nukebase.com", "https://usw4.nukebase.com". The name is
1236
- // the first host label. A region the source does not list (its region
1237
- // config can lag a newly built box) is fine so long as it answers.
1238
- const toRegion = (a) => {
1239
- a = String(a || '').trim().toLowerCase();
1240
- if (!a) return null;
1241
- if (/^\d+$/.test(a)) return others[Number(a) - 1] || null;
1242
- a = a.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
1243
- return a.split('.')[0] || null;
1244
- };
1245
- let target = args.positional[0] ? toRegion(args.positional[0]) : null;
1246
- if (!target) {
1247
- if (AUTO_YES || !process.stdin.isTTY) {
1248
- console.log(`Usage: nukebase regiontransfer <region> (this project is in ${source}${others.length ? '; known: ' + others.join(', ') : ''})`);
1249
- process.exit(1);
1250
- }
1251
- console.log(`\n🌍 Move "${project}" out of ${source}.${others.length ? ' Regions:' : ''}\n`);
1252
- others.forEach((r, i) => console.log(` ${i + 1}) ${r}`));
1253
- target = toRegion(await askLine(`\n Move to${others.length ? ' (number or name)' : ''}: `));
1254
- if (!target) { console.log(' Aborted.'); process.exit(1); }
1255
- }
1256
- if (target === source) { console.error(`❌ "${project}" is already in ${source}.`); process.exit(1); }
1257
- const targetApi = (info.regionUrls && info.regionUrls[target]) ? info.regionUrls[target].replace(/\/$/, '') : regionApi(API, target);
1258
- const targetHost = new URL(targetApi).host;
1259
-
1260
- // Does it exist? /tiers is public, so a reply proves a NukeBase region is
1261
- // listening there before anything is paused.
1262
- try {
1263
- const r = await fetch(`${targetApi}/tiers`);
1264
- if (!r.ok) throw new Error(`HTTP ${r.status}`);
1265
- if (!others.includes(target)) console.log(` (${target} is not in ${source}'s region list, but ${targetHost} answers — continuing)`);
1266
- } catch (e) {
1267
- console.error(`❌ No NukeBase region at ${targetHost} (${e.cause?.message || e.message}).`);
1268
- process.exit(1);
1269
- }
1270
-
1271
- // Whose account on the target? The same one when the target recognises
1272
- // this uid and token; otherwise — or when `--to <email>` says so — another
1273
- // account, signed in on this machine like any other (~/.nukebase). Every
1274
- // target step then runs `--as <email>`, so the source steps keep this
1275
- // identity and package.json is only rewritten at the very end, when the
1276
- // folder changes hands.
1277
- let toEmail = args.get('to') && args.get('to').includes('@') ? args.get('to').trim().toLowerCase() : null;
1278
- let targetUid = uid, targetToken = token;
1279
- if (!toEmail) {
1280
- let recognised = false;
1281
- try {
1282
- const r = await fetch(`${targetApi}/region-info`, { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: '{}' });
1283
- recognised = r.ok;
1284
- if (!r.ok && r.status !== 401) throw new Error(`HTTP ${r.status}`);
1285
- } catch (e) {
1286
- if (e.message && e.message.startsWith('HTTP')) { console.error(`❌ ${targetHost} rejected the account check (${e.message}).`); process.exit(1); }
1287
- }
1288
- if (!recognised) {
1289
- console.log(`\n ${targetHost} does not recognise this account (uid ${uid}) — the account has`);
1290
- console.log(` not replicated there, or the project is moving to a different account.`);
1291
- if (AUTO_YES || !process.stdin.isTTY) {
1292
- console.error(`❌ Pass --to <email> to say which account owns it on ${target}.`);
1293
- process.exit(1);
1294
- }
1295
- const suggested = String(accountLabel).includes('@') ? accountLabel : '';
1296
- toEmail = (await askLine(` Email of the account on ${target} [${suggested}]: `)).trim().toLowerCase() || suggested;
1297
- if (!toEmail || !toEmail.includes('@')) { console.log(' Aborted.'); process.exit(1); }
1298
- }
1299
- }
1300
- if (toEmail) {
1301
- // Sign that account in now, so a typo or a dead inbox surfaces before
1302
- // anything is paused. It is saved like any login, so the child steps find it.
1303
- let acct = creds.find(toEmail);
1304
- if (acct) {
1305
- const r = await fetch(`${targetApi}/cli/whoami`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: `uid=${acct.uid}; token=${acct.token}` }, body: '{}' }).catch(() => null);
1306
- if (!r || !r.ok) acct = null;
1307
- }
1308
- if (!acct) {
1309
- console.log(`\n🔑 Signing in to ${targetHost} as ${toEmail}...`);
1310
- try {
1311
- const out = await loginFlows.browserOrDevice(targetApi, { email: toEmail });
1312
- creds.put(out.uid, { label: out.label, api: targetApi, token: out.token, method: 'browser', created: Date.now(), lastUsed: Date.now() });
1313
- acct = creds.get(out.uid);
1314
- } catch (e) {
1315
- die(e.message);
1316
- }
1317
- }
1318
- targetUid = acct.uid; targetToken = acct.token;
1319
- if (targetUid === uid) { console.log(` (same account on both sides: uid ${uid})`); toEmail = null; }
1320
- else console.log(` ✓ ${acct.label} on ${target} is uid ${targetUid}; ${source}'s copy stays under uid ${uid}.`);
1321
- }
1322
- const asArgs = toEmail ? ['--as', targetUid] : [];
1323
- const targetHeaders = (extra = {}) => ({ ...extra, 'X-project': project, 'Cookie': `uid=${targetUid}; token=${targetToken}` });
1324
-
1325
- // -- what moves
1326
- const node = (await db.get(["users", uid, "projects", project]).catch(() => null))?.data || {};
1327
- const tier = node.tier || null;
1328
- const customDomain = typeof node.customDomain === 'string' ? node.customDomain : null;
1329
- const running = node.stats?.active !== false;
1330
- const hosted = await fetchHostedEnv({ optional: true });
1331
- const envNames = hosted ? Object.keys(hosted).filter(k => k !== 'SOCKET' && k !== 'DOMAIN') : [];
1332
-
1333
- console.log(`\n📋 Plan\n`);
1334
- console.log(` Project ${project}`);
1335
- console.log(` From ${source} (${new URL(API).host})`);
1336
- console.log(` To ${target} (${targetHost})${toEmail ? ` as ${toEmail}` : ''}`);
1337
- console.log(` Size ${tier || 'default'}`);
1338
- console.log(` Env variables ${hosted === null ? 'none (' + source + ' predates hosted environments)' : envNames.length ? envNames.length + ': ' + envNames.join(', ') : 'none'}`);
1339
- console.log(` Custom domain ${customDomain || 'none'}`);
1340
- console.log(`\n The source is paused while its data is copied, and stays paused`);
1341
- console.log(` afterwards — it is not deleted. Delete it yourself once the target`);
1342
- console.log(` checks out: nukebase delete --region ${source}`);
1343
- if (customDomain) {
1344
- console.log(`\n ${customDomain} keeps working on ${source} until you move its DNS to`);
1345
- console.log(` ${target}; step 6 walks through that.`);
1346
- }
1347
- if (!(await confirm('\nContinue? (y/n) '))) { console.log('Aborted. Nothing changed.'); process.exit(0); }
1348
-
1349
- // Each step is the CLI run again as a child, so the output is exactly what
1350
- // the command prints on its own and a failure stops the sequence with the
1351
- // remaining steps listed as commands to run by hand.
1352
- const { spawnSync } = require('child_process');
1353
- const steps = [];
1354
- const step = (title, args) => steps.push({ title, args });
1355
- step('1/7 Create the project on the target and push the code',
1356
- ['push', '--yes', '--region', target, ...asArgs, ...(tier ? ['--tier', tier] : [])]);
1357
- step('2/7 Pause the source', running ? ['pause'] : null);
1358
- step('3/7 Back up the source\'s data to this machine', ['backup', '--yes']);
1359
- step('4/7 Push the backup set to the target', ['push', '--include', 'backup', '--yes', '--region', target, ...asArgs]);
1360
-
1361
- for (let i = 0; i < steps.length; i++) {
1362
- const { title, args } = steps[i];
1363
- console.log(`\n${'─'.repeat(66)}\n ${title}\n${'─'.repeat(66)}`);
1364
- if (!args) { console.log(' (already paused — skipped)'); continue; }
1365
- console.log(` $ nukebase ${args.join(' ')}\n`);
1366
- const r = spawnSync(process.execPath, [BIN, ...args], { stdio: 'inherit', cwd: root });
1367
- if (r.status !== 0) {
1368
- console.error(`\n❌ Step ${title.split(' ')[0]} failed. Nothing after it was run.`);
1369
- console.error(' Fix the cause and run the remaining steps by hand:');
1370
- for (const rest of steps.slice(i)) if (rest.args) console.error(` nukebase ${rest.args.join(' ')}`);
1371
- console.error(` nukebase env push --region ${target}${asArgs.length ? ' ' + asArgs.join(' ') : ''} (after writing the variables to .env)`);
1372
- if (customDomain) console.error(` nukebase domain ${customDomain} --region ${target} --wait${asArgs.length ? ' ' + asArgs.join(' ') : ''}`);
1373
- console.error(` then set "api" in the "nukebase" section of package.json to ${targetApi}`);
1374
- process.exit(1);
1375
- }
1376
- }
1377
-
1378
- // 5 — environment. Straight from the source's hosted copy to the target's,
1379
- // never through a local .env (which may not exist, or may differ).
1380
- console.log(`\n${'─'.repeat(66)}\n 5/7 Copy the hosted environment\n${'─'.repeat(66)}`);
1381
- if (hosted === null) {
1382
- console.log(` (${source} has no hosted environment to copy — skipped)`);
1383
- } else if (!envNames.length) {
1384
- console.log(' (no variables — skipped)');
1385
- } else {
1386
- const env = Object.fromEntries(envNames.map(k => [k, hosted[k]]));
1387
- const r = await fetch(`${targetApi}/update-env`, {
1388
- method: 'POST',
1389
- headers: targetHeaders({ 'Content-Type': 'application/json' }),
1390
- body: JSON.stringify({ env })
1391
- }).catch(e => ({ ok: false, status: 0, text: async () => e.cause?.message || e.message }));
1392
- if (!r.ok) {
1393
- console.error(`❌ Could not write the environment on ${target}: ${r.status} ${await r.text()}`);
1394
- console.error(` Write the ${envNames.length} variable(s) to .env and run: nukebase env push --region ${target}${asArgs.length ? ' ' + asArgs.join(' ') : ''}`);
1395
- process.exit(1);
1396
- }
1397
- console.log(` ✓ ${envNames.length} variable(s) set on ${target}; the project restarts with them.`);
1398
- }
1399
-
1400
- // 6 — custom domain. Optional here: it needs a registrar change, and the
1401
- // source keeps serving it meanwhile, so it can be finished later.
1402
- console.log(`\n${'─'.repeat(66)}\n 6/7 Custom domain\n${'─'.repeat(66)}`);
1403
- if (!customDomain) {
1404
- console.log(' (none — skipped)');
1405
- } else if (await confirm(` Move ${customDomain} to ${target} now? You will need to change its DNS. (y/n) `)) {
1406
- const r = spawnSync(process.execPath, [BIN, 'domain', customDomain, '--region', target, '--wait', ...asArgs], { stdio: 'inherit', cwd: root });
1407
- if (r.status !== 0) {
1408
- console.error(`\n⚠️ The domain was not connected on ${target}. It still works on ${source}.`);
1409
- console.error(` Finish later with: nukebase domain ${customDomain} --region ${target} --wait${asArgs.length ? ' ' + asArgs.join(' ') : ''}`);
1410
- } else if (node.emailDomain && node.emailDomain.name) {
1411
- // Same sending domain on the target: the mail provider already has it
1412
- // verified (its DNS records do not change), so the target adopts it.
1413
- const em = node.emailDomain;
1414
- const emArgs = ['domain', 'email', em.root ? '--root' : '--sub', '--region', target, '--yes', ...asArgs];
1415
- if (em.replyTo) emArgs.push('--reply-to', em.replyTo);
1416
- if (em.fromLocal) emArgs.push('--from', em.fromLocal);
1417
- const e = spawnSync(process.execPath, [BIN, ...emArgs], { stdio: 'inherit', cwd: root });
1418
- if (e.status !== 0) console.error(` Email sending: finish later with: nukebase domain email --region ${target}${asArgs.length ? ' ' + asArgs.join(' ') : ''}`);
1419
- }
1420
- } else {
1421
- console.log(` Skipped. ${customDomain} stays on ${source} for now. When ready:`);
1422
- console.log(` nukebase domain ${customDomain} --region ${target} --wait${asArgs.length ? ' ' + asArgs.join(' ') : ''}`);
1423
- }
1424
-
1425
- // 7 — this folder now belongs to the target.
1426
- console.log(`\n${'─'.repeat(66)}\n 7/7 Point this project at ${target}\n${'─'.repeat(66)}`);
1427
- const fresh = readConfig(root);
1428
- fresh.API = targetApi;
1429
- if (toEmail) {
1430
- // The folder changes hands: it now names the target account as owner.
1431
- // Both accounts stay signed in on this machine, so
1432
- // `nukebase delete --region <source> --as <old account>` still works.
1433
- fresh.uid = targetUid;
1434
- fresh.account = creds.get(targetUid)?.label || toEmail;
1435
- }
1436
- writeConfig(root, fresh);
1437
- console.log(` ✓ package.json "nukebase" api → ${targetApi}${toEmail ? `, account → ${fresh.account}` : ''}`);
1438
-
1439
- const oldAs = toEmail ? ` --as ${accountLabel}` : '';
1440
- console.log(`\n✅ "${project}" is running in ${target}: https://${targetUid}-${project}.${targetHost}`);
1441
- console.log(`\n Next:`);
1442
- console.log(` • check the app there`);
1443
- console.log(` • the ${source} copy is paused and still billing its base fee and disk;`);
1444
- console.log(` when satisfied: nukebase delete --region ${source}${oldAs}`);
1445
- process.exit(0);
1446
- }
1447
-
1448
- // `node --check` every server-side JavaScript file about to be uploaded.
1449
- // Returns [{ file, message }] for the ones that do not parse.
1450
- //
1451
- // Only code Node will run on the server: anything under a `public/` folder is
1452
- // browser code (ES modules — `import`/`export` in a .js file, which Node would
1453
- // wrongly reject in a CommonJS project) and anything in node_modules is not
1454
- // ours to check. Uses the same Node that is running this CLI.
1455
- function syntaxCheck(files) {
1456
- const { spawnSync } = require('child_process');
1457
- const bad = [];
1458
- for (const rel of files) {
1459
- if (!/\.(c|m)?js$/.test(rel)) continue;
1460
- const parts = rel.split('/');
1461
- if (parts.includes('public') || parts.includes('node_modules')) continue;
1462
- const abs = join(root, rel);
1463
- if (!existsSync(abs)) continue;
1464
- const r = spawnSync(process.execPath, ['--check', abs], { encoding: 'utf8' });
1465
- if (r.status !== 0) {
1466
- const msg = (r.stderr || r.stdout || 'does not parse').split(root + sep).join('').trim()
1467
- .split('\n').filter(l => !/^\s+at /.test(l) && !/^Node\.js v/.test(l)).join('\n').trim();
1468
- bad.push({ file: rel, message: msg });
1469
- }
1470
- }
1471
- return bad;
1472
- }
1473
-
1474
- async function push() {
1475
- await connectAndLogin();
1476
-
1477
- // One id for this whole push, sent on /upload, /remove and /restart so the
1478
- // server files all three into ONE deploy snapshot. Without it the server
1479
- // falls back to correlating by time, which works but is less precise.
1480
- const deployId = newDeployId();
1481
-
1482
- console.log('🔍 Checking for changes...');
1483
-
1484
- const localFiles = getFiles(root);
1485
-
1486
- const doSync = () => fetch(`${API}/sync`, {
1487
- method: 'POST',
1488
- headers: authHeaders({ 'Content-Type': 'application/json' }),
1489
- body: JSON.stringify({ exclude: syncExclude, files: localFiles, direction: 'push', symlinks: SYMLINKS })
1490
- });
1491
-
1492
- let res = await doSync();
1493
- if (res.status === 401) authExpired();
1494
-
1495
- // The project is not on this server. That is the normal state when the API
1496
- // in package.json was just pointed at a different region — the account
1497
- // is the same everywhere, the project is not — so offer to create it here
1498
- // and carry on, rather than failing with a bare 404.
1499
- if (res.status === 404) {
1500
- if (!(await offerCreateProject())) process.exit(1);
1501
- res = await doSync();
1502
- }
1503
-
1504
- if (!res.ok) {
1505
- console.error('❌ Sync failed:', res.status, await res.text());
1506
- process.exit(1);
1507
- }
1508
-
1509
- const { upload, remove } = await res.json();
1510
-
1511
- if (!upload.length && !remove.length) {
1512
- console.log('✓ Already up to date');
1513
- if (AUTO_YES) process.exit(0);
1514
- watchLogs();
1515
- return;
1516
- }
1517
-
1518
- // Interactive: tail live logs during and after the push (Ctrl+C to stop).
1519
- // Non-interactive (--yes): defer to the bounded tail after the push completes.
1520
- if (!AUTO_YES) watchLogs();
1521
-
1522
- // Links carry no content, and their entry[0] is the "L" sentinel rather than a
1523
- // number — including one would turn the running total into a string.
1524
- const totalSize = upload.reduce((sum, file) => {
1525
- const entry = localFiles[file];
1526
- if (!entry || isLinkEntry(entry)) return sum;
1527
- return sum + (entry[0] || 0);
1528
- }, 0);
1529
-
1530
- if (upload.length) {
1531
- console.log(`\n📤 Uploading ${upload.length} files (${(totalSize / 1024 / 1024).toFixed(2)} MB):`);
1532
- upload.slice(0, 10).forEach(f => console.log(' + ' + f));
1533
- if (upload.length > 10) console.log(` ... and ${upload.length - 10} more`);
1534
- }
1535
-
1536
- if (remove.length) {
1537
- console.log(`\n🗑️ Removing ${remove.length} files (after upload completes):`);
1538
- remove.slice(0, 10).forEach(f => console.log(' - ' + f));
1539
- if (remove.length > 10) console.log(` ... and ${remove.length - 10} more`);
1540
- }
1541
-
1542
- if (INCLUDE_BACKUP) {
1543
- // Which of the uploads are actually backup-set files. Shown separately
1544
- // because they are the ones that overwrite live state rather than code.
1545
- const backupUploads = upload.filter(f =>
1546
- backupPaths.some(p => f === p || f.startsWith(p + '/')));
1547
-
1548
- console.log('\n' + '─'.repeat(66));
1549
- console.log(' ⚠️ --include backup: this OVERWRITES the server\'s live data');
1550
- console.log('─'.repeat(66));
1551
- if (backupUploads.length) {
1552
- console.log(`\n ${backupUploads.length} backup-set file(s) will replace what is on the server:`);
1553
- backupUploads.slice(0, 10).forEach(f => console.log(' ⇈ ' + f));
1554
- if (backupUploads.length > 10) console.log(` ... and ${backupUploads.length - 10} more`);
1555
- console.log('\n Whatever the live app has written since is replaced by these.');
1556
- console.log(' Nothing under those paths is DELETED — the server keeps files');
1557
- console.log(' your copy does not have.');
1558
- console.log('\n The project is stopped before extraction, so the running');
1559
- console.log(' process cannot flush its in-memory database over the upload.');
1560
- } else {
1561
- console.log('\n No backup-set files found locally — nothing extra to send.');
1562
- console.log(' Run `nukebase backup` first if you meant to restore a copy.');
1563
- }
1564
- console.log('');
1565
- }
1566
-
1567
- // Refuse to ship server code that does not even parse. A syntax error in
1568
- // server/app.js does not fail the push — it fails the NEXT START of the
1569
- // server, after the files are already live: the app is down until someone
1570
- // notices, and on the hosted master the deploy guard has to roll it back.
1571
- // Checking here costs a second and stops the whole class before it leaves
1572
- // this machine.
1573
- if (!args.has('no-check')) {
1574
- const bad = syntaxCheck(upload);
1575
- if (bad.length) {
1576
- console.error(`\n❌ Not pushed: ${bad.length} file(s) do not parse:\n`);
1577
- for (const b of bad) console.error(`── ${b.file}\n${b.message}\n`);
1578
- console.error('Fix them and push again (or pass --no-check to push anyway).');
1579
- process.exit(1);
1580
- }
1581
- }
1582
-
1583
- console.log(`\n📊 Total: ${upload.length} to upload, ${remove.length} to remove`);
1584
- const ok = await confirm(`Continue? (y/n) `);
1585
- if (!ok) {
1586
- console.log('Aborted.');
1587
- process.exit(0);
1588
- }
1589
-
1590
- if (upload.length) {
1591
- const sizeOf = (f) => {
1592
- const entry = localFiles[f];
1593
- return (!entry || isLinkEntry(entry)) ? 0 : (entry[0] || 0);
1594
- };
1595
- // Always prepare when the server supports it, even for one stream: a
1596
- // prepared push is unpacked by a process running as the project's own
1597
- // user, with the restore point already taken. Parallel only when it pays
1598
- // AND the server agreed. A server that declines (the master) or predates
1599
- // /upload-prepare gets one ordinary stream, exactly as before.
1600
- const prepared = await prepareParallelUpload(deployId, upload);
1601
- const streams = wantParallel(upload.length, totalSize) ? prepared : 1;
1602
- const groups = splitBySize(upload, sizeOf, streams);
1603
- console.log(`\n Streaming upload${groups.length > 1 ? ` (${groups.length} parallel streams)` : ''}...`);
1604
-
1605
- const meter = progressMeter(totalSize);
1606
- const results = await Promise.all(groups.map(async (group) => {
1607
- // follow:false is node-tar's default, but state it explicitly — it is the
1608
- // difference between archiving a link and archiving whatever it points at.
1609
- const tarStream = tar.c({ cwd: root, portable: true, follow: false }, group);
1610
- const body = tarStream.pipe(meter.counter());
1611
- try {
1612
- // X-Include-Backup tells the server to stop the unit BEFORE extracting.
1613
- // Without that, /restart's SIGTERM makes the dying process flush its
1614
- // in-memory tree over the database we just uploaded, and the new
1615
- // process boots from that — the upload silently does nothing.
1616
- const res = await postStream(`${API}/upload`, authHeaders({
1617
- 'X-Deploy-Id': deployId,
1618
- 'X-Backup-Paths': JSON.stringify(backupPaths),
1619
- ...(INCLUDE_BACKUP ? { 'X-Include-Backup': '1' } : {})
1620
- }), body);
1621
- return { ok: res.ok, status: res.status, text: res.ok ? '' : await res.text() };
1622
- } catch (err) {
1623
- return { ok: false, status: 0, text: err.message };
1624
- }
1625
- }));
1626
- meter.done();
1627
-
1628
- const failed = results.filter(r => !r.ok);
1629
- if (failed.length) {
1630
- for (const r of failed) console.error('\n❌ Upload failed:', r.status || '', r.text);
1631
- if (results.length > 1) {
1632
- console.error(` ${failed.length} of ${results.length} streams failed. Files that arrived are kept — run the push again to send the rest.`);
1633
- }
1634
- // --include backup stopped the project server-side; don't leave it down.
1635
- if (INCLUDE_BACKUP) {
1636
- console.error(' Restarting the project (it was stopped for --include backup)...');
1637
- await fetch(`${API}/restart`, { method: 'POST', headers: authHeaders() }).catch(() => {});
1638
- }
1639
- process.exit(1);
1640
- }
1641
- }
1642
-
1643
- if (remove.length) {
1644
- const removeRes = await fetch(`${API}/remove`, {
1645
- method: 'POST',
1646
- headers: authHeaders({ 'Content-Type': 'application/json', 'X-Deploy-Id': deployId, 'X-Backup-Paths': JSON.stringify(backupPaths) }),
1647
- body: JSON.stringify(remove)
1648
- });
1649
-
1650
- if (!removeRes.ok) {
1651
- console.error('❌ Remove failed:', removeRes.status, await removeRes.text());
1652
- // --include backup stopped the project server-side before extracting, and
1653
- // the /restart below is what starts it again. Bailing out here would leave
1654
- // a tenant's app down over a failed file deletion, so restart first.
1655
- if (INCLUDE_BACKUP) {
1656
- console.error(' Restarting the project (it was stopped for --include backup)...');
1657
- await fetch(`${API}/restart`, { method: 'POST', headers: authHeaders() }).catch(() => {});
1658
- }
1659
- process.exit(1);
1660
- }
1661
- }
1662
-
1663
- console.log('\n🔄 Restarting server...');
1664
-
1665
- // Restart also seals the deploy snapshot server-side, which is what makes it
1666
- // eligible for rollback.
1667
- const restartRes = await fetch(`${API}/restart`, {
1668
- method: 'POST',
1669
- headers: authHeaders({ 'X-Deploy-Id': deployId })
1670
- });
1671
-
1672
- // The files are on the server either way, and the restore point is sealed
1673
- // before the server decides whether it may start. A refusal (cancelled plan,
1674
- // empty balance, spending cap) is not a failed upload — but the app is NOT
1675
- // running, so say so instead of printing success and tailing a silent log.
1676
- if (!restartRes.ok) {
1677
- let why = `HTTP ${restartRes.status}`;
1678
- try { why = (await restartRes.json()).error || why; } catch {}
1679
- console.log(`\n✅ Uploaded (${upload.length} uploaded, ${remove.length} removed) — deploy ${deployId}`);
1680
- console.error(`⚠️ The project was not started: ${why}`);
1681
- process.exit(1);
1682
- }
1683
-
1684
- console.log(`\n✅ Pushed! (${upload.length} uploaded, ${remove.length} removed)`);
1685
- console.log(` Deploy ${deployId} — run 'nukebase rollback' to undo this push`);
1686
-
1687
- // Non-interactive: tail logs only until the server settles, then exit so the
1688
- // AI agent / CI job returns on its own. Interactive keeps the live tail above.
1689
- if (AUTO_YES) {
1690
- await tailLogsUntilSettled();
1691
- console.log('\n✅ Deploy complete.');
1692
- process.exit(0);
1693
- }
1694
- }
1695
-
1696
- async function pull() {
1697
- await connectAndLogin();
1698
-
1699
- console.log('🔍 Checking for changes...');
1700
-
1701
- const doSync = () => fetch(`${API}/sync`, {
1702
- method: 'POST',
1703
- headers: authHeaders({ 'Content-Type': 'application/json' }),
1704
- body: JSON.stringify({ exclude: syncExclude, files: getFiles(root), direction: 'pull', symlinks: SYMLINKS })
1705
- });
1706
-
1707
- let res = await doSync();
1708
- if (res.status === 401) authExpired();
1709
-
1710
- if (!res.ok) {
1711
- console.error('❌ Sync failed:', res.status, await res.text());
1712
- process.exit(1);
1713
- }
1714
-
1715
- const { download, remove, totalSize } = await res.json();
1716
-
1717
- if (!download.length && !remove.length) {
1718
- console.log('✓ Already up to date');
1719
- return;
1720
- }
1721
-
1722
- if (download.length) {
1723
- console.log(`\n📥 Downloading ${download.length} files (${(totalSize / 1024 / 1024).toFixed(2)} MB):`);
1724
- download.slice(0, 10).forEach(f => console.log(' + ' + f));
1725
- if (download.length > 10) console.log(` ... and ${download.length - 10} more`);
1726
- }
1727
-
1728
- if (remove.length) {
1729
- console.log(`\n🗑️ Removing ${remove.length} files (after download completes):`);
1730
- remove.slice(0, 10).forEach(f => console.log(' - ' + f));
1731
- if (remove.length > 10) console.log(` ... and ${remove.length - 10} more`);
1732
- }
1733
-
1734
- console.log(`\n📊 Total: ${download.length} to download, ${remove.length} to remove`);
1735
- const ok = await confirm(`Continue? (y/n) `);
1736
- if (!ok) {
1737
- console.log('Aborted.');
1738
- process.exit(0);
1739
- }
1740
-
1741
- if (download.length) {
1742
- console.log('\n⬇️ Streaming download...');
1743
-
1744
- const pullSizes = {};
1745
- const result = await downloadStreams(root, download, (f) => pullSizes[f] || 0, totalSize);
1746
-
1747
- // The server's package.json carries whichever settings were last pushed
1748
- // to it — from another machine, or with --region another region's copy.
1749
- // This folder's own settings are what the developer meant; put them back
1750
- // (also after a stop, since the file may already have landed).
1751
- if (download.includes('package.json')) restoreSettings(root, config);
1752
-
1753
- if (result.stopped) {
1754
- console.log('\n🛑 Pull stopped cleanly. Local file deletes were skipped. Re-run `nukebase pull` to resume.');
1755
- return;
1756
- }
1757
- }
1758
-
1759
- for (const file of remove) {
1760
- // Same containment rule as extraction: a path from the server never
1761
- // reaches outside the project.
1762
- if (!tarEntryAllowed(root, file)) { console.warn(` ! skipped ${file} (outside the project)`); continue; }
1763
- const fullPath = join(root, file);
1764
- // lstat rather than existsSync: existsSync follows links, so a broken
1765
- // symlink looks absent and would never get cleaned up. unlink removes the
1766
- // link itself and never touches what it points at.
1767
- let stat = null;
1768
- try { stat = lstatSync(fullPath); } catch (e) { stat = null; }
1769
- if (stat) {
1770
- unlinkSync(fullPath);
1771
- cleanEmptyDirs(fullPath, root);
1772
- }
1773
- }
1774
-
1775
- console.log(`\n✅ Pulled! (${download.length} downloaded, ${remove.length} removed)`);
1776
- }
1777
-
1778
-
1779
- // Work out what package.json needs so it matches this engine release: the
1780
- // dependency versions it was built against, plus the engine version itself.
1781
- // Dependencies are add/overwrite only — names the engine doesn't ship are
1782
- // untouched, and nothing is ever deleted.
1783
- function planPackageChanges(manifest, updateExclude) {
1784
- const pkgPath = join(root, 'package.json');
1785
- const empty = (note) => ({ pkgPath, pkg: null, changes: [], versionChanged: false, note });
1786
-
1787
- if (updateExclude.includes('package.json')) return empty('skipped (updateExclude)');
1788
- if (!existsSync(pkgPath)) return empty('no package.json found');
1789
-
1790
- let pkg;
1791
- try {
1792
- pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
1793
- } catch (e) {
1794
- return empty(`could not parse package.json (${e.message}) — skipping`);
1795
- }
1796
-
1797
- const local = pkg.dependencies || {};
1798
- const changes = [];
1799
- for (const [name, want] of Object.entries(manifest.dependencies || {})) {
1800
- if (local[name] === want) continue;
1801
- changes.push({ name, from: local[name] || null, to: want });
1802
- }
1803
-
1804
- // The project template ships "version" as the engine version, so it tracks
1805
- // the engine rather than the developer's app — keep it in step.
1806
- const versionFrom = typeof pkg.version === 'string' ? pkg.version : null;
1807
- const versionTo = manifest.version;
1808
-
1809
- return {
1810
- pkgPath, pkg, changes,
1811
- versionFrom, versionTo,
1812
- versionChanged: versionFrom !== versionTo,
1813
- note: null
1814
- };
1815
- }
1816
-
1817
- function hasPackageWork(plan) {
1818
- return plan.changes.length > 0 || plan.versionChanged;
1819
- }
1820
-
1821
- // Rewrite package.json with the pinned versions. Everything else in the file —
1822
- // name, scripts, devDependencies, your own deps — is preserved as-is.
1823
- function applyPackageChanges(plan) {
1824
- const merged = { ...(plan.pkg.dependencies || {}) };
1825
- for (const c of plan.changes) merged[c.name] = c.to;
1826
-
1827
- // npm keeps dependencies alphabetically sorted and rewrites them that way on
1828
- // any `npm install`; matching it here means repeat updates produce a stable
1829
- // file instead of reshuffling the diff each time.
1830
- plan.pkg.dependencies = Object.fromEntries(
1831
- Object.keys(merged).sort((a, b) => a.localeCompare(b)).map(k => [k, merged[k]])
1832
- );
1833
- plan.pkg.version = plan.versionTo;
1834
-
1835
- writeFileSync(plan.pkgPath, JSON.stringify(plan.pkg, null, 2) + '\n');
1836
- }
1837
-
1838
- // Refresh the NukeBase engine files — database.js, the browser SDK, the CLI
1839
- // itself, and the auth extensions — from the server's canonical copies. These
1840
- // belong to the platform rather than to your application, so this behaves like
1841
- // `pull`: it shows what differs and overwrites on confirm. Anything listed in
1842
- // config.updateExclude is never touched, so a customized extension survives
1843
- // every future update.
1844
- async function update() {
1845
- // The master project's extensions legitimately differ from the tenant
1846
- // template (it mounts mail-magic.js and runs a newer auth stack), so updating
1847
- // here would replace them with the template's copies and break its auth.
1848
- if (existsSync(join(root, 'server', 'region.config.json'))) {
1849
- console.error('❌ Refusing to update the master project (server/region.config.json present).');
1850
- process.exit(1);
1851
- }
1852
-
1853
- await connectAndLogin();
1854
-
1855
- console.log('🔍 Checking engine files...');
1856
-
1857
- const doFetch = () => fetch(`${API}/engine-manifest`, {
1858
- method: 'POST',
1859
- headers: authHeaders({ 'Content-Type': 'application/json' }),
1860
- body: '{}'
1861
- });
1862
-
1863
- let res = await doFetch();
1864
- if (res.status === 401) authExpired();
1865
- if (!res.ok) {
1866
- console.error('❌ Engine manifest fetch failed:', res.status, await res.text());
1867
- process.exit(1);
1868
- }
1869
-
1870
- const manifest = await res.json();
1871
- const updateExclude = Array.isArray(config.updateExclude) ? config.updateExclude : [];
1872
- // Optional positional filter: `nukebase update server/database.js`
1873
- const only = args.positional;
1874
-
1875
- const rows = manifest.files.map(file => {
1876
- const localPath = join(root, file.path);
1877
- const exists = existsSync(localPath);
1878
- const localHash = exists
1879
- ? crypto.createHash('sha256').update(readFileSync(localPath)).digest('hex')
1880
- : null;
1881
-
1882
- let state;
1883
- if (updateExclude.includes(file.path)) state = 'protected';
1884
- else if (only.length && !only.includes(file.path)) state = 'filtered';
1885
- else if (!exists) state = 'new';
1886
- else if (localHash === file.sha256) state = 'current';
1887
- else state = 'outdated';
1888
-
1889
- return { ...file, localPath, state };
1890
- });
1891
-
1892
- const LABELS = {
1893
- outdated: 'outdated',
1894
- new: 'new',
1895
- current: 'up to date',
1896
- protected: 'protected (updateExclude)',
1897
- filtered: 'not selected'
1898
- };
1899
-
1900
- const toWrite = rows.filter(r => r.state === 'outdated' || r.state === 'new');
1901
-
1902
- // The engine files and their dependencies are a matched set — this version of
1903
- // database.js was tested against these exact package versions — so the engine
1904
- // pins them rather than reporting drift. Only names the engine actually ships
1905
- // are touched: your own dependencies are left alone, and nothing is ever
1906
- // removed (without a baseline we can't tell a dropped engine dep from one you
1907
- // added yourself, so we never guess).
1908
- const pkgPlan = planPackageChanges(manifest, updateExclude);
1909
-
1910
- const header = pkgPlan.versionFrom && pkgPlan.versionFrom !== manifest.version
1911
- ? `NukeBase engine ${pkgPlan.versionFrom} → ${manifest.version}`
1912
- : `NukeBase engine ${manifest.version}`;
1913
- console.log(`\n📦 ${header}\n`);
1914
-
1915
- const width = Math.max(...rows.map(r => r.path.length));
1916
- for (const r of rows) {
1917
- const mark = (r.state === 'outdated' || r.state === 'new') ? '→' : ' ';
1918
- console.log(` ${mark} ${r.path.padEnd(width)} ${LABELS[r.state]}`);
1919
- }
1920
-
1921
- // Always report package.json status — "nothing to change" is a real result
1922
- // and silence here just looks like the check never ran.
1923
- console.log('\n📦 package.json\n');
1924
- if (pkgPlan.note) {
1925
- console.log(` ${pkgPlan.note}`);
1926
- } else {
1927
- const labelWidth = Math.max(7, ...pkgPlan.changes.map(c => c.name.length));
1928
- if (pkgPlan.versionChanged) {
1929
- console.log(` → ${'version'.padEnd(labelWidth)} ${pkgPlan.versionFrom || 'unset'} → ${pkgPlan.versionTo}`);
1930
- } else {
1931
- console.log(` ${'version'.padEnd(labelWidth)} ${pkgPlan.versionTo} (in sync)`);
1932
- }
1933
- for (const c of pkgPlan.changes) {
1934
- console.log(` → ${c.name.padEnd(labelWidth)} ${c.from ? c.from + ' → ' + c.to : 'missing → ' + c.to}`);
1935
- }
1936
- const total = Object.keys(manifest.dependencies || {}).length;
1937
- if (!pkgPlan.changes.length) {
1938
- console.log(` all ${total} engine dependencies already in sync`);
1939
- } else {
1940
- console.log(`\n ${pkgPlan.changes.length} of ${total} engine dependencies to pin (your own are untouched)`);
1941
- }
1942
- }
1943
-
1944
- if (!toWrite.length && !hasPackageWork(pkgPlan)) {
1945
- if (!updateExclude.includes('package.json') && require('./account-commands').namePackage(root, project)) {
1946
- console.log(`\n✓ package.json name -> "${project}" (was "nukebase"; that name broke npx nukebase in this folder)`);
1947
- }
1948
- console.log('\n✓ Engine is already up to date');
1949
- process.exit(0);
1950
- }
1951
-
1952
- const outdated = toWrite.filter(r => r.state === 'outdated').length;
1953
- const added = toWrite.filter(r => r.state === 'new').length;
1954
- const pkgCount = pkgPlan.changes.length + (pkgPlan.versionChanged ? 1 : 0);
1955
- console.log(`\n📊 Total: ${outdated} to update, ${added} new, ${pkgCount} package.json change(s)`);
1956
- console.log(' Local edits to these files will be overwritten — add a path to');
1957
- console.log(' "updateExclude" in the "nukebase" section of package.json to protect it permanently');
1958
- console.log(' ("package.json" there opts out of dependency pinning).');
1959
-
1960
- const ok = await confirm(`Continue? (y/n) `);
1961
- if (!ok) {
1962
- console.log('Aborted.');
1963
- process.exit(0);
1964
- }
1965
-
1966
- console.log('');
1967
- for (const r of toWrite) {
1968
- mkdirSync(dirname(r.localPath), { recursive: true });
1969
- writeFileSync(r.localPath, r.content);
1970
- console.log(` ✓ ${r.path}`);
1971
- }
1972
-
1973
- if (hasPackageWork(pkgPlan)) {
1974
- applyPackageChanges(pkgPlan);
1975
- console.log(` ✓ package.json (${pkgCount} change(s))`);
1976
- }
1977
- // Projects from older templates are named "nukebase", which breaks
1978
- // `npx nukebase` inside them (see namePackage). Repaired here as well, so
1979
- // one `nukebase update` fixes an existing project.
1980
- if (!updateExclude.includes('package.json') && require('./account-commands').namePackage(root, project)) {
1981
- console.log(` ✓ package.json name -> "${project}" (was "nukebase"; that name broke npx nukebase in this folder)`);
1982
- }
1983
-
1984
- console.log(`\n✅ Updated ${toWrite.length} file(s) to ${manifest.version}`);
1985
- if (pkgPlan.changes.length) console.log(' → run: npm install');
1986
- console.log(" → run 'nukebase push' to deploy the updated engine");
1987
- process.exit(0);
1988
- }
1989
-
1990
- // ---------------------------------------------------------------------------
1991
- // rollback — undo a push
1992
- // ---------------------------------------------------------------------------
1993
- // The server snapshots every file a push is about to overwrite or delete,
1994
- // BEFORE it touches them, and seals the snapshot on restart. This restores one.
1995
- //
1996
- // What it undoes is the PUSH, not the clock: files the push replaced go back,
1997
- // files it deleted come back, files it added are removed, and anything the push
1998
- // never touched is left exactly as it is. So a rollback keeps whatever your
1999
- // users wrote in the meantime.
2000
- //
2001
- // Three forms:
2002
- //
2003
- // nukebase rollback [id] restore code. Live data is kept. The common
2004
- // case by a wide margin.
2005
- // nukebase rollback backup [id] restore code AND the backup set as it was at
2006
- // that deploy — discarding every write since.
2007
- // nukebase rollback list the restore points.
2008
- //
2009
- // `backup` means the same set of paths here as it does in `nukebase backup`:
2010
- // whatever the project declared, or the default. That is the reason it is a
2011
- // SUBCOMMAND and not a flag. A flag is one keystroke from the safe command and
2012
- // reads like "be more thorough", so people reach for it out of caution and
2013
- // silently discard a day of writes. Typing the word "backup" is a decision.
2014
- async function rollback() {
2015
- await connectAndLogin();
2016
-
2017
- // `list` and `backup` are reserved: a snapshot can never be addressed by
2018
- // those names. Ids are generated timestamps, so nothing real collides — but
2019
- // it is deliberate rather than accidental, so a stray argument can't silently
2020
- // turn a listing into a restore.
2021
- const RESERVED = new Set(['list', 'backup']);
2022
- const first = args.positional[0];
2023
- const sub = first && RESERVED.has(first) ? first : null;
2024
-
2025
- const wantList = sub === 'list' || args.has('list') || command === 'restorepoints' || command === 'restore-points';
2026
- const withBackup = sub === 'backup';
2027
-
2028
- // First bare argument that is not the subcommand is an explicit snapshot id.
2029
- const explicitId = args.positional[sub ? 1 : 0];
2030
-
2031
- const listRes = await fetch(`${API}/deploys`, {
2032
- method: 'POST',
2033
- headers: authHeaders({ 'Content-Type': 'application/json' }),
2034
- body: '{}'
2035
- });
2036
- if (!listRes.ok) {
2037
- console.error('❌ Could not list deploys:', listRes.status, await listRes.text());
2038
- process.exit(1);
2039
- }
2040
- const { deploys } = await listRes.json();
2041
- const restorable = deploys.filter(d => d.state === 'closed');
2042
-
2043
- if (wantList) {
2044
- if (!deploys.length) { console.log('No restore points yet — they are created by `nukebase push`.'); process.exit(0); }
2045
- // "Restore points", not "deploys": the list answers "what can I go back
2046
- // to?", and it contains entries created by rollbacks as well as by pushes.
2047
- console.log(`\nRestore points for ${project}:\n`);
2048
- for (const d of deploys) {
2049
- const when = d.startedAt ? new Date(d.startedAt).toISOString().replace('T', ' ').slice(0, 19) : '?';
2050
- const mb = (d.bytes / 1024 / 1024).toFixed(1);
2051
- const origin = d.reason === 'pre-rollback' ? 'saved before a rollback' : 'deploy';
2052
- const tags = [
2053
- d.state === 'open' ? 'in progress' : null,
2054
- // Whether `rollback backup <id>` will work on this one.
2055
- d.hasData ? 'backup set included' : 'code only'
2056
- ].filter(Boolean);
2057
- console.log(` ${d.id}`);
2058
- console.log(` ${when} ${origin}`);
2059
- console.log(` +${d.added} added ~${d.preserved} preserved ${mb} MB [${tags.join(', ')}]`);
2060
- }
2061
- console.log('\n nukebase rollback <id> restore code, keep live data');
2062
- console.log(' nukebase rollback backup <id> restore code and the backup set\n');
2063
- process.exit(0);
2064
- }
2065
-
2066
- if (!restorable.length) {
2067
- console.error('❌ No completed deploy snapshots to roll back to.');
2068
- process.exit(1);
2069
- }
2070
-
2071
- const target = explicitId
2072
- ? restorable.find(d => d.id === explicitId)
2073
- : restorable[0];
2074
-
2075
- if (!target) {
2076
- console.error(`❌ No such restore point: ${explicitId}`);
2077
- console.error(" Run 'nukebase rollback list' to see what is available.");
2078
- process.exit(1);
2079
- }
2080
-
2081
- if (withBackup && !target.hasData) {
2082
- console.error(`❌ Restore point ${target.id} has no copy of the backup set.`);
2083
- console.error(' Roll back code only with: nukebase rollback ' + target.id);
2084
- process.exit(1);
2085
- }
2086
-
2087
- const when = target.startedAt
2088
- ? new Date(target.startedAt).toISOString().replace('T', ' ').slice(0, 19)
2089
- : 'unknown time';
2090
- const ageHours = target.startedAt ? (Date.now() - target.startedAt) / 3600000 : null;
2091
- const age = ageHours === null ? 'unknown age'
2092
- : ageHours < 1 ? `${Math.round(ageHours * 60)} minutes ago`
2093
- : ageHours < 48 ? `${ageHours.toFixed(1)} hours ago`
2094
- : `${(ageHours / 24).toFixed(1)} days ago`;
2095
-
2096
- console.log(`\n⏪ Rolling back ${project} to ${target.id}`);
2097
- console.log(` taken ${when} (${age})`);
2098
- console.log(` ${target.preserved} file(s) restored, ${target.added} file(s) added by that push removed`);
2099
-
2100
- if (withBackup) {
2101
- // The destructive form gets a materially different prompt, not the same
2102
- // y/n with more text above it. What is being thrown away is stated in the
2103
- // units that matter — time — because "restores the backup set" does not
2104
- // read as "deletes everything since Tuesday" until you spell it out.
2105
- console.log('\n' + '─'.repeat(66));
2106
- console.log(' ⚠️ THIS DISCARDS DATA. Read before continuing.');
2107
- console.log('─'.repeat(66));
2108
- console.log('\n These paths will be replaced with the copy taken at that deploy:');
2109
- for (const p of backupPaths) console.log(` ${p}`);
2110
- console.log(`\n Everything written to them in the last ${age.replace(' ago', '')} is lost.`);
2111
- console.log(' That includes every signup, order and upload since then.');
2112
- console.log('\n This is not undone by rolling forward again — the writes are gone.');
2113
- console.log(' If you only need to fix broken code, press Ctrl+C and run:');
2114
- console.log(` nukebase rollback ${target.id}`);
2115
- console.log('\n Take a copy first if you have not: nukebase backup');
2116
- console.log('');
2117
-
2118
- const typed = await confirmTyped(project,
2119
- ` Type the project name (${project}) to confirm: `);
2120
- if (!typed) { console.log('Aborted.'); process.exit(0); }
2121
- } else {
2122
- console.log('\n Your live data is kept — only the files that push touched are');
2123
- console.log(' restored. Anything it left alone stays exactly as it is.');
2124
- console.log(`\n To also restore the backup set as it was at that deploy:`);
2125
- console.log(` nukebase rollback backup ${target.id}`);
2126
- console.log('\n The project will be stopped, restored, and restarted.');
2127
-
2128
- const ok = await confirm('Continue? (y/n) ');
2129
- if (!ok) { console.log('Aborted.'); process.exit(0); }
2130
- }
2131
-
2132
- const res = await fetch(`${API}/rollback`, {
2133
- method: 'POST',
2134
- headers: authHeaders({ 'Content-Type': 'application/json' }),
2135
- body: JSON.stringify({ deployId: target.id, withBackup })
2136
- });
2137
-
2138
- if (!res.ok) {
2139
- console.error('\n❌ Rollback failed:', res.status, await res.text());
2140
- process.exit(1);
2141
- }
2142
-
2143
- const result = await res.json();
2144
- console.log(`\n✅ Rolled back to ${target.id}`);
2145
- console.log(` ${result.restored} restored, ${result.deleted} removed` +
2146
- (withBackup ? `, ${result.backupRestored} backup file(s)` : ''));
2147
- if (result.failures && result.failures.length) {
2148
- console.log(` ⚠️ ${result.failures.length} file(s) could not be restored:`);
2149
- result.failures.slice(0, 10).forEach(f => console.log(' ! ' + f));
2150
- }
2151
- if (result.undoId) {
2152
- console.log(`\n The state you just replaced was saved as ${result.undoId}`);
2153
- console.log(` Undo this rollback with: nukebase rollback ${result.undoId}`);
2154
- }
2155
-
2156
- if (AUTO_YES) { await tailLogsUntilSettled(); process.exit(0); }
2157
- watchLogs();
2158
- }
2159
-
2160
- // ---------------------------------------------------------------------------
2161
- // backup — copy the server's state down to this machine
2162
- // ---------------------------------------------------------------------------
2163
- // The counterpart to `pull`. Pull brings down CODE; backup brings down the
2164
- // things the running app owns — its database, its file store — which sync
2165
- // deliberately never touches.
2166
- //
2167
- // Read-only: it downloads and writes locally, and cannot alter the server. That
2168
- // is the point. Everything else in this file is a destructive operation wrapped
2169
- // in guard rails; this one needs none, so it is safe to run on a schedule or out
2170
- // of habit before a risky deploy.
2171
- //
2172
- // Incremental. It diffs against what is already in the destination and fetches
2173
- // only what changed, so running it often is cheap even when the file store is
2174
- // large.
2175
- //
2176
- // Lands INSIDE the project by default, at the same relative paths the server
2177
- // uses — so server/data ends up at ./server/data. That replaces the old dance of
2178
- // deleting server/data from `exclude`, running pull, and putting it back.
2179
- //
2180
- // Safe to land there precisely because those paths are in `exclude`: push and
2181
- // pull ignore them, so a local copy of production sits inert until you ask for
2182
- // it with `nukebase push --include backup`. It also means you can run the app
2183
- // locally against real data without moving anything.
2184
- //
2185
- // --out <dir> writes elsewhere instead, for an archival copy you do not want in
2186
- // the working tree.
2187
- async function backup() {
2188
- await connectAndLogin();
2189
-
2190
- const dest = args.get('out') ? resolve(args.get('out')) : root;
2191
-
2192
- console.log(`\n💾 Backing up ${project}`);
2193
- console.log(' paths: ' + backupPaths.join(', '));
2194
- console.log(` into: ${dest}`);
2195
-
2196
- const doList = () => fetch(`${API}/backup-manifest`, {
2197
- method: 'POST',
2198
- headers: authHeaders({ 'Content-Type': 'application/json' }),
2199
- body: JSON.stringify({ paths: backupPaths })
2200
- });
2201
-
2202
- let listRes = await doList();
2203
- if (listRes.status === 401) authExpired();
2204
- let changed, fetchBytes;
2205
- let backupSizes = {}; // rel -> [size, mtime], when the server lists sizes
2206
- if (listRes.status === 404) {
2207
- // A master that predates /backup-manifest. It still has /sync and
2208
- // /download (pull is older than backup), so ask /sync for a pull listing
2209
- // with the backup paths taken OUT of the exclude list and an empty local
2210
- // manifest — every server file then counts as "to download" — and keep
2211
- // only the ones under the backup paths. No size/mtime comparison this
2212
- // way, so everything is fetched; that is fine for a one-off copy.
2213
- console.log(' (this region has no backup endpoint — using the sync listing instead)');
2214
- const legacyExclude = syncExclude.filter(e => !backupPaths.includes(e));
2215
- const doSync = () => fetch(`${API}/sync`, {
2216
- method: 'POST',
2217
- headers: authHeaders({ 'Content-Type': 'application/json' }),
2218
- body: JSON.stringify({ exclude: legacyExclude, files: {}, direction: 'pull', symlinks: false })
2219
- });
2220
- let syncRes = await doSync();
2221
- if (!syncRes.ok) {
2222
- console.error('❌ Could not list files:', syncRes.status, await syncRes.text());
2223
- process.exit(1);
2224
- }
2225
- const { download } = await syncRes.json();
2226
- changed = (download || []).filter(rel => backupPaths.some(p => rel === p || rel.startsWith(p + '/')));
2227
- fetchBytes = 0;
2228
- if (!changed.length) {
2229
- console.log('\n Nothing to back up — the server has no files under those paths.');
2230
- process.exit(0);
2231
- }
2232
- console.log(`\n ${changed.length} file(s) to download`);
2233
- changed.slice(0, 10).forEach(f => console.log(' ↓ ' + f));
2234
- if (changed.length > 10) console.log(` ... and ${changed.length - 10} more`);
2235
- } else {
2236
- if (!listRes.ok) {
2237
- console.error('❌ Could not list backup files:', listRes.status, await listRes.text());
2238
- process.exit(1);
2239
- }
2240
-
2241
- const { files: serverFiles } = await listRes.json();
2242
- backupSizes = serverFiles || {};
2243
- const names = Object.keys(serverFiles);
2244
- if (!names.length) {
2245
- console.log('\n Nothing to back up — the server has no files under those paths.');
2246
- process.exit(0);
2247
- }
2248
-
2249
- // Same size+mtime comparison sync uses. mtime survives the tar round-trip
2250
- // closely enough that a 1s tolerance avoids re-fetching unchanged files.
2251
- changed = names.filter(rel => {
2252
- const local = join(dest, rel);
2253
- if (!existsSync(local)) return true;
2254
- const s = lstatSync(local);
2255
- const [size, mtime] = serverFiles[rel];
2256
- return s.size !== size || Math.abs(Math.floor(s.mtimeMs / 1000) - mtime) > 1;
2257
- });
2258
-
2259
- const totalBytes = names.reduce((n, rel) => n + (serverFiles[rel][0] || 0), 0);
2260
- fetchBytes = changed.reduce((n, rel) => n + (serverFiles[rel][0] || 0), 0);
2261
-
2262
- console.log(`\n ${names.length} file(s), ${(totalBytes / 1024 / 1024).toFixed(2)} MB on the server`);
2263
- if (!changed.length) {
2264
- console.log(' ✓ Already up to date.\n');
2265
- process.exit(0);
2266
- }
2267
- console.log(` ${changed.length} changed, ${(fetchBytes / 1024 / 1024).toFixed(2)} MB to download`);
2268
- changed.slice(0, 10).forEach(f => console.log(' ↓ ' + f));
2269
- if (changed.length > 10) console.log(` ... and ${changed.length - 10} more`);
2270
- }
2271
-
2272
- const ok = await confirm('\nContinue? (y/n) ');
2273
- if (!ok) { console.log('Aborted.'); process.exit(0); }
2274
-
2275
- mkdirSync(dest, { recursive: true });
2276
-
2277
- const started = Date.now();
2278
- const result = await downloadStreams(dest, changed,
2279
- (rel) => (backupSizes[rel] && backupSizes[rel][0]) || 0, fetchBytes || 0);
2280
-
2281
- const secs = ((Date.now() - started) / 1000).toFixed(1);
2282
- console.log(`\n\n✅ Backed up ${changed.length} file(s) in ${secs}s`);
2283
- console.log(` ${dest}`);
2284
- if (dest === root) {
2285
- console.log('\n These paths are in `exclude`, so push and pull will ignore them.');
2286
- console.log(' To send this copy back up: nukebase push --include backup\n');
2287
- } else {
2288
- console.log('');
2289
- }
2290
- process.exit(0);
2291
- }
2292
-
2293
- // ---------------------------------------------------------------------------
2294
- // env — the project's hosted environment (process.env)
2295
- // ---------------------------------------------------------------------------
2296
- // A hosted project's process is started by the platform, so its environment
2297
- // has to come from the platform too. The developer keeps a .env in the project
2298
- // root — the same file `node --env-file=.env server/database.js` loads locally
2299
- // — and `nukebase env push` makes the hosted environment equal to it. The
2300
- // server stores it outside the project, root-owned, and hands it to the process
2301
- // through systemd, so no sync, ZIP download or restore point ever contains it.
2302
- //
2303
- // Whole-set semantics, like push: the local file is the truth. Removing a line
2304
- // locally and pushing removes it on the server. Names are shown in the diff;
2305
- // values never are.
2306
-
2307
- // Parse a dotenv file into { NAME: value }. The common dialect: blank lines and
2308
- // # comments skipped, an optional `export ` prefix, NAME=VALUE split at the
2309
- // first "=", surrounding single or double quotes stripped, and inside double
2310
- // quotes \n, \" and \\ unescaped. An unquoted value ends at a " #" comment.
2311
- // Nothing is expanded — a $ is a literal dollar sign, as it is on the server.
2312
- function parseDotenv(text) {
2313
- const env = {};
2314
- for (const rawLine of String(text).split(/\r?\n/)) {
2315
- const line = rawLine.trim();
2316
- if (!line || line.startsWith('#')) continue;
2317
- const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
2318
- if (!m) continue;
2319
- let value = m[2];
2320
- const q = value[0];
2321
- if (value.length >= 2 && (q === '"' || q === "'") && value[value.length - 1] === q) {
2322
- value = value.slice(1, -1);
2323
- if (q === '"') value = value.replace(/\\(n|"|\\)/g, (_, c) => (c === 'n' ? '\n' : c));
2324
- } else {
2325
- const hash = value.search(/\s#/);
2326
- if (hash !== -1) value = value.slice(0, hash);
2327
- value = value.trim();
2328
- }
2329
- env[m[1]] = value;
2330
- }
2331
- return env;
2332
- }
2333
-
2334
- function readLocalEnv() {
2335
- const file = join(root, ENV_FILE);
2336
- if (!existsSync(file)) {
2337
- console.error(`❌ No ${ENV_FILE} in ${root}`);
2338
- console.error(` Create one with NAME=value lines, then run 'nukebase env push'.`);
2339
- process.exit(1);
2340
- }
2341
- return parseDotenv(readFileSync(file, 'utf8'));
2342
- }
2343
-
2344
- // The hosted environment, re-authenticating once on a stale session like the
2345
- // other commands do.
2346
- // `optional`: resolve null instead of exiting when the region has no /env at
2347
- // all (a master that predates hosted environments answers 404 from uWS, not
2348
- // a JSON error) — the transfer treats that as "nothing to copy".
2349
- async function fetchHostedEnv({ optional = false } = {}) {
2350
- const doFetch = () => fetch(`${API}/env`, {
2351
- method: 'POST',
2352
- headers: authHeaders({ 'Content-Type': 'application/json' }),
2353
- body: '{}'
2354
- });
2355
- let res = await doFetch();
2356
- if (res.status === 401) authExpired();
2357
- if (optional && res.status === 404) return null;
2358
- if (!res.ok) {
2359
- console.error('❌ Could not read the hosted environment:', res.status, await res.text());
2360
- process.exit(1);
2361
- }
2362
- const data = await res.json();
2363
- return (data && data.env) || {};
2364
- }
2365
-
2366
- async function envPush() {
2367
- // Read the file before connecting, so a missing one fails before any login.
2368
- const local = readLocalEnv();
2369
- await connectAndLogin();
2370
-
2371
- console.log(`🔍 Comparing ${ENV_FILE} with the hosted environment...`);
2372
- const hosted = await fetchHostedEnv();
2373
-
2374
- const added = [], changed = [], removed = [];
2375
- let same = 0;
2376
- for (const k of Object.keys(local).sort()) {
2377
- if (!Object.hasOwn(hosted, k)) added.push(k);
2378
- else if (hosted[k] !== local[k]) changed.push(k);
2379
- else same++;
2380
- }
2381
- for (const k of Object.keys(hosted).sort()) if (!Object.hasOwn(local, k)) removed.push(k);
2382
-
2383
- if (!added.length && !changed.length && !removed.length) {
2384
- console.log(`✓ Hosted environment already matches ${ENV_FILE} (${same} variable${same === 1 ? '' : 's'})`);
2385
- process.exit(0);
2386
- }
2387
-
2388
- // Names only. Values are secrets, and this goes to a terminal and possibly a
2389
- // CI log.
2390
- if (added.length) { console.log(`\n➕ Adding ${added.length}:`); added.forEach(k => console.log(' + ' + k)); }
2391
- if (changed.length) { console.log(`\n✏️ Changing ${changed.length}:`); changed.forEach(k => console.log(' ~ ' + k)); }
2392
- if (removed.length) {
2393
- console.log(`\n🗑️ Removing ${removed.length} (not in your ${ENV_FILE}):`);
2394
- removed.forEach(k => console.log(' - ' + k));
2395
- }
2396
- if (same) console.log(`\n ${same} unchanged`);
2397
-
2398
- console.log(`\n📊 Total: ${added.length} to add, ${changed.length} to change, ${removed.length} to remove`);
2399
- console.log(' The project restarts to pick up the new environment.');
2400
- const ok = await confirm('Continue? (y/n) ');
2401
- if (!ok) { console.log('Aborted.'); process.exit(0); }
2402
-
2403
- const res = await fetch(`${API}/update-env`, {
2404
- method: 'POST',
2405
- headers: authHeaders({ 'Content-Type': 'application/json' }),
2406
- body: JSON.stringify({ env: local })
2407
- });
2408
- if (!res.ok) {
2409
- console.error('\n❌ Update failed:', res.status, await res.text());
2410
- process.exit(1);
2411
- }
2412
- const result = await res.json();
2413
- const n = (result.keys || []).length;
2414
- console.log(`\n✅ Hosted environment updated: ${n} variable${n === 1 ? '' : 's'}`);
2415
- if (result.restarted) {
2416
- await tailLogsUntilSettled();
2417
- } else {
2418
- console.log(' The project is not running; the new environment applies when it next starts.');
2419
- }
2420
- process.exit(0);
2421
- }
2422
-
2423
- async function envList() {
2424
- const show = args.has('show');
2425
- await connectAndLogin();
2426
- const hosted = await fetchHostedEnv();
2427
- const names = Object.keys(hosted).sort();
2428
-
2429
- if (!names.length) {
2430
- console.log(`\nNo variables set for ${project}.`);
2431
- console.log(`Create a ${ENV_FILE} in the project root and run 'nukebase env push'.\n`);
2432
- process.exit(0);
2433
- }
2434
-
2435
- console.log(`\nHosted environment for ${project}:\n`);
2436
- const width = Math.max(...names.map(n => n.length));
2437
- for (const name of names) {
2438
- console.log(` ${name.padEnd(width)} ${show ? hosted[name] : '••••••••'}`);
2439
- }
2440
- console.log(`\n${names.length} variable${names.length === 1 ? '' : 's'}.` + (show ? '' : ' Add --show to print values.') + '\n');
2441
- process.exit(0);
2442
- }
2443
-
2444
- // bin/nukebase.js has already checked the command name.
2445
- const run = (fn) => fn().catch(err => { console.error('❌', err.message); process.exit(1); });
2446
- if (command === 'push' && args.positional[0] === 'env') {
2447
- // `nukebase push env` reads as naturally as `nukebase env push`; both work.
2448
- run(envPush);
2449
- } else if (command === 'push') {
2450
- run(push);
2451
- } else if (command === 'env') {
2452
- const sub = args.positional[0];
2453
- if (sub === 'push') run(envPush);
2454
- else if (sub === 'list') run(envList);
2455
- else {
2456
- console.log('Usage: nukebase env <push|list>');
2457
- console.log(' env push make the hosted environment equal to ./.env, then restart');
2458
- console.log(' env list [--show] variable names currently set on the server');
2459
- process.exit(1);
2460
- }
2461
- } else if (command === 'pull') {
2462
- // Exit once done: the log socket would otherwise keep Node alive.
2463
- pull().then(() => process.exit(0), err => { console.error('❌', err.message); process.exit(1); });
2464
- } else if (command === 'update') {
2465
- run(update);
2466
- } else if (command === 'rollback' || command === 'restorepoints' || command === 'restore-points') {
2467
- // restorepoints: the same listing as `rollback list`, under the dashboard's name.
2468
- run(rollback);
2469
- } else if (command === 'backup') {
2470
- run(backup);
2471
- } else if (command === 'pause') {
2472
- run(pauseProject);
2473
- } else if (command === 'restart' || command === 'resume') {
2474
- // One endpoint: /restart launches a stopped unit and bounces a running one,
2475
- // so resume is the same call under the name the user reaches for.
2476
- run(restartProject);
2477
- } else if (command === 'delete') {
2478
- run(deleteProject);
2479
- } else if (command === 'domain') {
2480
- run(domainCommand);
2481
- } else if (command === 'logs') {
2482
- run(logsCommand);
2483
- } else if (command === 'download') {
2484
- run(downloadCommand);
2485
- } else if (command === 'regiontransfer' || command === 'region-transfer') {
2486
- run(regionTransfer);
2487
- }
1
+ // Commands that act on the project in the current folder: push, pull, update,
2
+ // rollback, backup, env, pause/restart/resume/delete, domain, logs, download and
3
+ // regiontransfer. Loaded by bin/nukebase.js, which runs the command named in
4
+ // process.argv when this module is required.
5
+ const { existsSync, readdirSync, statSync, lstatSync, readlinkSync, symlinkSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, rmdirSync } = require('fs');
6
+ const readline = require('readline');
7
+ const crypto = require('crypto');
8
+ const http = require('http');
9
+ const https = require('https');
10
+ const { tmpdir } = require('os');
11
+ const { join, dirname, relative, resolve, isAbsolute, sep } = require('path');
12
+ const { Transform, PassThrough } = require('stream');
13
+ const { pipeline } = require('stream/promises');
14
+ const tar = require('tar');
15
+ const createClient = require('./sdk');
16
+ const creds = require('./credentials');
17
+ const loginFlows = require('./login');
18
+ const { findProject, readConfig, writeConfig, restoreSettings, migrateLegacy } = require('./project');
19
+ const { regionApi, hostOf } = require('./ui');
20
+
21
+ const args = require('./args').parse(process.argv.slice(2));
22
+ const command = args.command;
23
+ // --yes / -y makes push/pull non-interactive: auto-confirm the file diff and,
24
+ // after pushing, tail logs only until the server settles (then exit) — so an AI
25
+ // agent or CI job never blocks on the y/n prompt or the live log stream.
26
+ const AUTO_YES = args.has('yes');
27
+ const BIN = require.resolve('../bin/nukebase.js');
28
+
29
+ const root = findProject(process.cwd());
30
+ if (!root) {
31
+ console.error('Not inside a NukeBase project (no package.json with a "nukebase" section here or above).');
32
+ console.error(' nukebase create <name> make a new one');
33
+ console.error(' nukebase link <name> connect this folder to an existing one');
34
+ process.exit(1);
35
+ }
36
+ let config = readConfig(root);
37
+ let { project, exclude, API } = config;
38
+ let uid = null, token = null, accountLabel = null;
39
+
40
+ // `--region <name>` / `--api <url>` point THIS invocation at another region
41
+ // without touching package.json. The account is the same everywhere (auth
42
+ // replicates), the project is not — so this is how a command reaches a copy
43
+ // of the project elsewhere: `nukebase delete --region usw3` after a transfer,
44
+ // or the transfer itself driving the target. A region's API is its name as
45
+ // the first host label of the configured one (usw4.nukebase.com →
46
+ // usw3.nukebase.com); --api is the escape hatch for anything else.
47
+ const ELSEWHERE = !!(args.get('api') || args.get('region'));
48
+ if (args.get('api')) API = args.get('api').replace(/\/$/, '');
49
+ else if (args.get('region')) API = regionApi(API, args.get('region'));
50
+
51
+ // `--as <email|uid>` runs this invocation as another signed-in account. Only
52
+ // for reaching a copy of the project elsewhere (--region/--api) — the region
53
+ // transfer drives the target that way when the project changes accounts — so a
54
+ // typo can never push this folder into the wrong account in its own region.
55
+ const AS = args.get('as');
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // The backup set
59
+ // ---------------------------------------------------------------------------
60
+ // package.json carries two path lists. They are INDEPENDENT — each does
61
+ // exactly one job, and neither implies the other:
62
+ //
63
+ // exclude what push and pull skip. Nothing else reads it.
64
+ // backup what `nukebase backup` copies down and what a deploy snapshots.
65
+ // Nothing about sync.
66
+ //
67
+ // They overlap in a default project — server/data is in both — but that is two
68
+ // separate decisions that happen to agree, not one implying the other. Keeping
69
+ // them independent is what lets you take a path out of `exclude` and push it
70
+ // deliberately, without also changing what gets backed up.
71
+ //
72
+ // Why exclude ships with the data paths: sync moves whole trees in both
73
+ // directions, and treating the running app's own state as ordinary project code
74
+ // was destructive twice over —
75
+ //
76
+ // DELETE /sync reports every server file absent from the client as
77
+ // `remove`, so a push from a fresh git clone listed the live
78
+ // database, the backups and the file store as deletions.
79
+ //
80
+ // OVERWRITE worse, because it took no mistake at all. `pull` downloaded
81
+ // server/data, so your machine held a stale copy, and the next push
82
+ // saw it differ and uploaded it back OVER the live database. Pull on
83
+ // Monday, push on Friday, production is back to Monday.
84
+ //
85
+ // The backup default matches the engine's own DATA_DIR / BACKUP_DIR / FILES_DIR
86
+ // in database.js. FILES_DIR is env-overridable there, but nothing in the hosted
87
+ // stack sets it (systemd-run passes only SOCKET and DOMAIN), so the default is
88
+ // correct on the platform; the override exists for anyone whose layout differs.
89
+ const DEFAULT_BACKUP_PATHS = ['server/data', 'server/files'];
90
+ const backupPaths = Array.isArray(config.backup) && config.backup.length
91
+ ? config.backup
92
+ : DEFAULT_BACKUP_PATHS;
93
+
94
+ // `nukebase push --include backup` — a one-off opt-in that carries the backup
95
+ // set UP, for seeding a new project or restoring a copy taken with
96
+ // `nukebase backup`. Off by default: a deploy has no business rewriting the
97
+ // database, and this is the only way to ask it to.
98
+ //
99
+ // Two tokens rather than --include-backup, so it reads the same as
100
+ // `nukebase rollback backup` — the word "backup" always names the same set.
101
+ const INCLUDE_BACKUP = args.get('include') === 'backup';
102
+
103
+ // `.env` is never part of a code push or pull, whatever package.json says.
104
+ // It is the input to `nukebase env push`, which carries it to a root-owned file
105
+ // OUTSIDE the project on the server. An ordinary push copying it into the
106
+ // project tree would put secrets where the ZIP download and every restore
107
+ // point can see them, and a pull would overwrite the local file with whatever
108
+ // the server had. Hard-wired here, not left to the exclude list, so projects
109
+ // created before the template listed it are covered too.
110
+ const ENV_FILE = '.env';
111
+ const syncExclude = (exclude || []).includes(ENV_FILE) ? (exclude || []) : [...(exclude || []), ENV_FILE];
112
+
113
+ // What THIS machine's file walk skips. Identical to `syncExclude`, except that
114
+ // --include backup lifts the backup paths so the local copies are picked up.
115
+ //
116
+ // The server is still sent `syncExclude` with the backup paths INTACT, and that
117
+ // asymmetry is deliberate. /sync derives its delete list from files it can see
118
+ // on the server but not on the client, so un-excluding the data paths there
119
+ // too would make a restore that is missing one shard delete the live one. With
120
+ // the server still excluding them: nothing under those paths can ever be
121
+ // deleted, while every local file still uploads (it compares against "absent",
122
+ // so it always differs).
123
+ const localExclude = INCLUDE_BACKUP
124
+ ? syncExclude.filter(e => !backupPaths.includes(e))
125
+ : syncExclude;
126
+
127
+ // ---- Parallel streams (push, pull, backup) ----------------------------------
128
+ //
129
+ // One TCP connection is often capped far below the line speed — measured on a
130
+ // 500 Mbps home line to ovh4: ~100-120 Mbps per connection with the server
131
+ // idle, 520 Mbps with four at once. So a large transfer is split into STREAMS
132
+ // groups of roughly equal size that move concurrently.
133
+ //
134
+ // package.json "streams": 4 (default, 1-8)
135
+ // --streams <n> override for one command; --streams 1 is the old behaviour
136
+ //
137
+ // Small transfers (under PARALLEL_MIN_BYTES and PARALLEL_MIN_FILES) stay one
138
+ // stream: a typical code push gains nothing from splitting.
139
+ const STREAMS = (() => {
140
+ let n = args.get('streams') ? parseInt(args.get('streams'), 10) : parseInt(config.streams, 10);
141
+ if (!Number.isFinite(n)) n = 4;
142
+ return Math.min(Math.max(n, 1), 8);
143
+ })();
144
+ const PARALLEL_MIN_BYTES = 32 * 1024 * 1024;
145
+ const PARALLEL_MIN_FILES = 200;
146
+
147
+ function wantParallel(fileCount, bytes) {
148
+ return STREAMS > 1 && fileCount > 1 && (bytes >= PARALLEL_MIN_BYTES || fileCount >= PARALLEL_MIN_FILES);
149
+ }
150
+
151
+ // Split `files` into up to n groups of roughly equal bytes: biggest first,
152
+ // each into the lightest group. The 512 per file is a tar header, so a pile of
153
+ // tiny files still spreads out instead of all landing in one group.
154
+ function splitBySize(files, sizeOf, n) {
155
+ if (n <= 1 || files.length <= 1) return [files];
156
+ const groups = Array.from({ length: Math.min(n, files.length) }, () => ({ files: [], bytes: 0 }));
157
+ const sorted = [...files].sort((a, b) => sizeOf(b) - sizeOf(a));
158
+ for (const file of sorted) {
159
+ let lightest = groups[0];
160
+ for (const g of groups) if (g.bytes < lightest.bytes) lightest = g;
161
+ lightest.files.push(file);
162
+ lightest.bytes += sizeOf(file) + 512;
163
+ }
164
+ return groups.map(g => g.files).filter(l => l.length);
165
+ }
166
+
167
+ // One progress line shared by every stream of a transfer. counter() gives each
168
+ // stream its own pass-through that adds to the shared total.
169
+ function progressMeter(totalBytes) {
170
+ let bytes = 0;
171
+ let last = 0;
172
+ const start = Date.now();
173
+ const mb = (n) => (n / 1024 / 1024).toFixed(2);
174
+ const draw = (force) => {
175
+ const now = Date.now();
176
+ if (!force && now - last < 100) return;
177
+ last = now;
178
+ const secs = (now - start) / 1000;
179
+ const speed = secs > 0 ? (bytes / 1024 / 1024) / secs : 0;
180
+ const remaining = Math.max(0, totalBytes - bytes);
181
+ const eta = speed > 0 ? formatEta((remaining / 1024 / 1024) / speed) : '--:--';
182
+ const of = totalBytes > 0 ? ` of ${mb(totalBytes).padStart(7)}` : '';
183
+ process.stdout.write(`\r ${mb(bytes).padStart(7)}${of} MB || ${speed.toFixed(2).padStart(6)} MB/s || ETA ${String(eta).padStart(5)} `);
184
+ };
185
+ return {
186
+ counter() {
187
+ return new Transform({ transform(chunk, enc, cb) { bytes += chunk.length; draw(false); cb(null, chunk); } });
188
+ },
189
+ bytes: () => bytes,
190
+ done() {
191
+ draw(true);
192
+ const secs = Math.max((Date.now() - start) / 1000, 0.001);
193
+ console.log(`\n ✓ ${mb(bytes)} MB in ${secs.toFixed(1)}s (avg: ${(bytes / 1024 / 1024 / secs).toFixed(2)} MB/s)`);
194
+ }
195
+ };
196
+ }
197
+
198
+ // Ask the server to get a parallel push ready: take this deploy's restore
199
+ // point for every file up front (concurrent /upload requests cannot each
200
+ // rewrite it) and hand the project to the tenant so the per-stream extractors
201
+ // can write. Returns how many streams to use: STREAMS if the server agreed,
202
+ // 1 for a server that predates /upload-prepare (404) or declines (the master
203
+ // project) — which is then exactly the old single-stream push.
204
+ async function prepareParallelUpload(deployId, files) {
205
+ try {
206
+ const res = await fetch(`${API}/upload-prepare`, {
207
+ method: 'POST',
208
+ headers: authHeaders({
209
+ 'Content-Type': 'application/json',
210
+ 'X-Deploy-Id': deployId,
211
+ 'X-Backup-Paths': JSON.stringify(backupPaths),
212
+ ...(INCLUDE_BACKUP ? { 'X-Include-Backup': '1' } : {})
213
+ }),
214
+ body: JSON.stringify({ files })
215
+ });
216
+ if (!res.ok) return 1;
217
+ const body = await res.json().catch(() => ({}));
218
+ return body && body.parallel === true ? STREAMS : 1;
219
+ } catch (e) {
220
+ return 1;
221
+ }
222
+ }
223
+
224
+ // Download `files` into `dest` over one or several /download streams, each
225
+ // extracted as it arrives. Returns { bytes, stopped }.
226
+ // Ctrl-C once: every stream stops after the file it is writing; twice: quit.
227
+ async function downloadStreams(dest, files, sizeOf, totalBytes) {
228
+ const groups = wantParallel(files.length, totalBytes)
229
+ ? splitBySize(files, sizeOf, STREAMS)
230
+ : [files];
231
+ if (groups.length > 1) console.log(` (${groups.length} parallel streams)`);
232
+
233
+ const meter = progressMeter(totalBytes);
234
+ let stopRequested = false;
235
+ const active = []; // { stream, inFlight }
236
+
237
+ const onSigint = () => {
238
+ if (stopRequested) {
239
+ console.log('\n⚠ Forcing quit. Files currently being written may be partial.');
240
+ process.exit(130);
241
+ }
242
+ stopRequested = true;
243
+ console.log('\n⏹ Stop requested — finishing the files in progress. Press Ctrl-C again to force quit.');
244
+ for (const a of active) if (!a.inFlight) a.stream.destroy();
245
+ };
246
+ process.on('SIGINT', onSigint);
247
+
248
+ try {
249
+ await Promise.all(groups.map(async (group) => {
250
+ const res = await postJsonStream(`${API}/download`,
251
+ authHeaders({ 'Content-Type': 'application/json' }),
252
+ JSON.stringify({ files: group }));
253
+ if (!res.ok) throw new Error(`Download failed: ${res.status} ${await res.text()}`);
254
+ const slot = { stream: res.stream, inFlight: false };
255
+ active.push(slot);
256
+ // Same containment rule the server applies on /upload: a corrupted or
257
+ // hostile archive must not write outside `dest` or plant a link out of it.
258
+ const extractor = tar.x({ cwd: dest, filter: (p, e) => tarEntryAllowed(dest, p, e) });
259
+ extractor.on('entry', (entry) => {
260
+ slot.inFlight = true;
261
+ entry.on('end', () => {
262
+ slot.inFlight = false;
263
+ if (stopRequested) res.stream.destroy();
264
+ });
265
+ });
266
+ try {
267
+ await pipeline(res.stream, meter.counter(), extractor);
268
+ } catch (err) {
269
+ if (!stopRequested) throw err;
270
+ }
271
+ }));
272
+ } finally {
273
+ process.off('SIGINT', onSigint);
274
+ }
275
+ meter.done();
276
+ return { bytes: meter.bytes(), stopped: stopRequested };
277
+ }
278
+
279
+ const dbEndpoint = API.replace(/^https:/, 'wss:').replace(/^http:/, 'ws:').replace(/\/$/, '');
280
+ let db = null;
281
+
282
+ function die(...lines) {
283
+ console.error(`❌ ${lines[0]}`);
284
+ for (const l of lines.slice(1)) console.error(` ${l}`);
285
+ process.exit(1);
286
+ }
287
+
288
+ const loginHint = (label) => String(label || '').includes('@') ? `nukebase login --email ${label}` : 'nukebase login --password';
289
+
290
+ // Work out which account this command runs as, first match wins:
291
+ // NUKEBASE_TOKEN (+ NUKEBASE_UID) a build token, for CI
292
+ // --as <email|uid> another signed-in account (elsewhere only)
293
+ // the project's uid the normal case
294
+ // The token comes from ~/.nukebase/credentials.json, never from the project.
295
+ async function connectAndLogin() {
296
+ // Projects from the old CLI keep their sign-in in sys/config.json. Move it
297
+ // out first. A region without CLI sign-in yet can't take it, and then the
298
+ // old token is used as before.
299
+ const migrated = await migrateLegacy(root, config);
300
+ config = migrated.config;
301
+
302
+ if (migrated.legacy) {
303
+ uid = config.uid; token = config.token;
304
+ accountLabel = config.email || config.username || config.uid;
305
+ if (config.username && config.password) {
306
+ const r = await fetch(`${API}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: config.username, password: config.password }) })
307
+ .then(r => r.json()).catch(() => ({}));
308
+ if (r.status !== 'Success') die('Login failed.');
309
+ uid = r.uid; token = r.token;
310
+ }
311
+ if (!token) die(`${hostOf(API)} doesn't support CLI sign-in yet, and this project has no saved sign-in.`);
312
+ } else if (process.env.NUKEBASE_TOKEN) {
313
+ uid = process.env.NUKEBASE_UID || config.uid;
314
+ token = process.env.NUKEBASE_TOKEN;
315
+ accountLabel = `build token (${uid})`;
316
+ if (!uid) die('NUKEBASE_TOKEN is set but NUKEBASE_UID is not.');
317
+ } else if (AS) {
318
+ const acct = creds.find(AS);
319
+ if (!acct) die(`${AS} isn't signed in on this machine.`, `Run: ${loginHint(AS)}`);
320
+ if (acct.uid !== config.uid && !ELSEWHERE) {
321
+ die(`This project belongs to ${config.account || config.uid}, not ${acct.label}.`,
322
+ '--as another account only works with --region or --api, to reach a copy of the project elsewhere.');
323
+ }
324
+ ({ uid, token } = acct);
325
+ accountLabel = acct.label || acct.uid;
326
+ } else {
327
+ if (!config.uid) die('The "nukebase" section of package.json has no "uid", so this folder has no owner.', 'Run: nukebase link <project>');
328
+ const acct = creds.get(config.uid);
329
+ if (!acct) {
330
+ die(`This project belongs to ${config.account || 'uid'} (${config.uid}), which isn't signed in on this machine.`,
331
+ `Run: ${loginHint(config.account)}`);
332
+ }
333
+ ({ uid, token } = acct);
334
+ accountLabel = acct.label || acct.uid;
335
+ }
336
+ if (!process.env.NUKEBASE_TOKEN) creds.touch(uid);
337
+
338
+ console.log(`as ${accountLabel}${String(accountLabel).includes(uid) ? '' : ` (${uid})`} → ${project} on ${hostOf(API)}`);
339
+ db = await createClient(dbEndpoint, { uid, token });
340
+ }
341
+
342
+ // A 401 means this machine's token was signed out (`nukebase logout`, or
343
+ // Signed-in devices on the dashboard). Say how to sign back in.
344
+ function authExpired() {
345
+ if (process.env.NUKEBASE_TOKEN) die('NUKEBASE_TOKEN was revoked. Create a new build token on the dashboard.');
346
+ die(`The sign-in for ${accountLabel} (${uid}) is no longer valid. It was signed out or revoked.`, `Run: ${loginHint(accountLabel)}`);
347
+ }
348
+
349
+ // Deploy ids are ISO timestamps with the separators stripped, so they are safe
350
+ // as a single path segment and sort chronologically as plain strings. Must match
351
+ // the server's own format (see newDeployId in server/app.js).
352
+ function newDeployId() {
353
+ return new Date().toISOString().replace(/[:.]/g, '-');
354
+ }
355
+
356
+ // Auth headers from the current (possibly just-refreshed) uid/token.
357
+ function authHeaders(extra = {}) {
358
+ return {
359
+ ...extra,
360
+ 'X-project': project,
361
+ 'Cookie': `uid=${uid}; token=${token}`
362
+ };
363
+ }
364
+
365
+ // Symlinks ARE part of the sync contract — but only where this machine can
366
+ // actually create one.
367
+ //
368
+ // Windows refuses symlink creation unless Developer Mode is on or the process
369
+ // is elevated (SeCreateSymbolicLinkPrivilege), so probe once instead of
370
+ // guessing from process.platform: a Windows box WITH Developer Mode gets full
371
+ // fidelity, and one without degrades to the old file-only behaviour rather than
372
+ // failing a pull halfway through extraction.
373
+ //
374
+ // This answer is sent to the server, which then lists links only if we said yes.
375
+ // Both sides must agree on whether a link exists, or every sync sees a
376
+ // difference it cannot resolve and re-transfers it forever.
377
+ const SYMLINKS = (() => {
378
+ const probe = join(tmpdir(), `nukebase-symlink-probe-${process.pid}-${Date.now()}`);
379
+ try {
380
+ symlinkSync('probe-target', probe);
381
+ return true;
382
+ } catch (e) {
383
+ return false;
384
+ } finally {
385
+ try { unlinkSync(probe); } catch {}
386
+ }
387
+ })();
388
+
389
+ // A link may only point INSIDE the project, so the project stays self-contained
390
+ // and means the same thing wherever it is unpacked. Checked lexically rather
391
+ // than with realpath because node_modules/.bin links are routinely dangling at
392
+ // extract time (tar can create the link before its target), and because lexical
393
+ // resolution has no TOCTOU window. Mirrors linkTargetInside() in server/app.js.
394
+ function linkTargetInside(root, linkAbs, target) {
395
+ if (typeof target !== 'string' || !target) return false;
396
+ if (isAbsolute(target) || /^[a-zA-Z]:/.test(target)) return false;
397
+ const resolved = resolve(dirname(linkAbs), target);
398
+ return resolved === root || resolved.startsWith(root + sep);
399
+ }
400
+
401
+ // Manifest entries are [size, mtime] for a regular file and ["L", target] for a
402
+ // symlink. Size is always a number, so the string sentinel cannot collide.
403
+ const isLinkEntry = (e) => Array.isArray(e) && e[0] === 'L';
404
+
405
+ // withFileTypes gives lstat semantics, so isSymbolicLink() describes the entry
406
+ // itself rather than its target. readlink reads that target as a STRING and
407
+ // never opens it, so a link is described without ever being followed.
408
+ function getFiles(dir, root = dir) {
409
+ if (!existsSync(dir)) return {};
410
+ return Object.fromEntries(
411
+ readdirSync(dir, { withFileTypes: true }).flatMap(ent => {
412
+ const path = join(dir, ent.name);
413
+ const rel = relative(root, path).replace(/\\/g, '/');
414
+ if (localExclude.includes(ent.name) || localExclude.includes(rel)) return [];
415
+ // Saved sessions for --as (a token for another account) never leave
416
+ // this machine, whatever the exclude list says.
417
+ if (rel.startsWith('sys/.session-')) return [];
418
+ if (ent.isSymbolicLink()) {
419
+ if (!SYMLINKS) return [];
420
+ let target;
421
+ try { target = readlinkSync(path); } catch (e) { return []; }
422
+ if (!linkTargetInside(root, path, target)) return [];
423
+ // Normalise to forward slashes so a link written on Windows and one
424
+ // written on Linux compare equal instead of re-syncing on every run.
425
+ return [[rel, ['L', target.replace(/\\/g, '/')]]];
426
+ }
427
+ if (ent.isDirectory()) return Object.entries(getFiles(path, root));
428
+ if (!ent.isFile()) return [];
429
+ const s = lstatSync(path);
430
+ return [[rel, [s.size, Math.floor(s.mtimeMs / 1000)]]];
431
+ })
432
+ );
433
+ }
434
+
435
+ // Shared by the push and pull tar streams: the same containment rule the server
436
+ // applies on /upload, so a malicious or corrupted archive cannot write outside
437
+ // the project or plant a link pointing out of it on a developer's machine.
438
+ function tarEntryAllowed(root, entryPath, entry) {
439
+ const resolved = resolve(root, entryPath);
440
+ if (resolved !== root && !resolved.startsWith(root + sep)) return false;
441
+ if (entry && entry.type === 'SymbolicLink') {
442
+ if (!SYMLINKS) return false;
443
+ return linkTargetInside(root, resolved, entry.linkpath);
444
+ }
445
+ if (entry && entry.type === 'Link') return false;
446
+ return true;
447
+ }
448
+
449
+ // Confirmation for the one command that destroys data. Requires typing an exact
450
+ // word rather than "y", so it cannot be cleared by a reflex keystroke — and
451
+ // deliberately does NOT honour --yes: an unattended agent or CI job has no
452
+ // business discarding a production database without a human present.
453
+ function confirmTyped(expected, question) {
454
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
455
+ return new Promise(resolve => {
456
+ let answered = false;
457
+ rl.question(question, answer => {
458
+ answered = true;
459
+ rl.close();
460
+ resolve(answer.trim() === expected);
461
+ });
462
+ rl.on('close', () => { if (!answered) resolve(false); });
463
+ });
464
+ }
465
+
466
+ function confirm(question) {
467
+ // Non-interactive (--yes): skip the prompt entirely.
468
+ if (AUTO_YES) {
469
+ console.log(question + 'y ▶ auto-confirmed (--yes)');
470
+ return Promise.resolve(true);
471
+ }
472
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
473
+ return new Promise(resolve => {
474
+ let answered = false;
475
+ rl.question(question, answer => {
476
+ answered = true;
477
+ rl.close();
478
+ resolve(answer.trim().toLowerCase() === 'y');
479
+ });
480
+ // If stdin hits EOF with no answer (piped / no terminal), abort instead of
481
+ // hanging forever. Pass --yes to auto-confirm in that situation.
482
+ rl.on('close', () => { if (!answered) resolve(false); });
483
+ });
484
+ }
485
+
486
+ function cleanEmptyDirs(filePath, stopAt) {
487
+ let dir = dirname(filePath);
488
+ while (dir !== stopAt) {
489
+ try {
490
+ if (readdirSync(dir).length === 0) {
491
+ rmdirSync(dir);
492
+ dir = dirname(dir);
493
+ } else break;
494
+ } catch { break; }
495
+ }
496
+ }
497
+
498
+ function formatEta(seconds) {
499
+ if (!seconds || !isFinite(seconds) || seconds < 0) return '--:--';
500
+
501
+ const mins = Math.floor(seconds / 60);
502
+ const secs = Math.floor(seconds % 60);
503
+
504
+ if (mins >= 60) {
505
+ const hours = Math.floor(mins / 60);
506
+ const remainMins = mins % 60;
507
+ return `${hours}:${remainMins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
508
+ }
509
+
510
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
511
+ }
512
+
513
+ function makeClickable(line) {
514
+ const match = line.match(/(\/[^:\s]+\.js):(\d+)(?::(\d+))?/);
515
+
516
+ if (!match) return line;
517
+
518
+ const [fullMatch, filePath, lineNum, colNum] = match;
519
+
520
+ const localCwd = process.cwd();
521
+ const localFilePath = join(localCwd, filePath);
522
+
523
+ const vscodeUrl = colNum
524
+ ? `vscode://file/${localFilePath.replace(/\\/g, '/')}:${lineNum}:${colNum}`
525
+ : `vscode://file/${localFilePath.replace(/\\/g, '/')}:${lineNum}`;
526
+
527
+ const coloredLine = line.replace(
528
+ fullMatch,
529
+ `\x1b[36m${fullMatch}\x1b[0m`
530
+ );
531
+
532
+ return `\x1b]8;;${vscodeUrl}\x1b\\${coloredLine}\x1b]8;;\x1b\\`;
533
+ }
534
+
535
+ function watchLogs() {
536
+ console.log('\n📡 Watching live logs... (Press Ctrl+C to stop)\n');
537
+ db.getSub({ path: ["users", uid, "projects", project, "logs", "logline"] }, (data) => {
538
+ if (data.data) {
539
+ const logText = typeof data.data === 'string' ? data.data : data.data.message || JSON.stringify(data.data);
540
+ console.log(makeClickable(logText));
541
+ }
542
+ });
543
+ }
544
+
545
+ // Bounded log tail for --yes mode: stream logs until the server goes quiet (no
546
+ // new line for idleMs) or a hard cap (maxMs) is hit, then resolve so the caller
547
+ // can exit. Lets an AI agent watch the restart output and terminate on its own.
548
+ function tailLogsUntilSettled({ idleMs = 4000, maxMs = 15000 } = {}) {
549
+ return new Promise(resolve => {
550
+ console.log('\n📡 Tailing logs until the server settles...\n');
551
+ let idleTimer = null;
552
+ let finished = false;
553
+ const finish = () => {
554
+ if (finished) return;
555
+ finished = true;
556
+ if (idleTimer) clearTimeout(idleTimer);
557
+ clearTimeout(hardTimer);
558
+ resolve();
559
+ };
560
+ const bump = () => {
561
+ if (idleTimer) clearTimeout(idleTimer);
562
+ idleTimer = setTimeout(finish, idleMs);
563
+ };
564
+ const hardTimer = setTimeout(finish, maxMs);
565
+ db.getSub({ path: ["users", uid, "projects", project, "logs", "logline"] }, (data) => {
566
+ if (data.data) {
567
+ const logText = typeof data.data === 'string' ? data.data : data.data.message || JSON.stringify(data.data);
568
+ console.log(makeClickable(logText));
569
+ }
570
+ bump();
571
+ });
572
+ bump(); // start the idle countdown even if no logs arrive
573
+ });
574
+ }
575
+
576
+ function postStream(urlString, headers, stream) {
577
+ return new Promise((resolve, reject) => {
578
+ const url = new URL(urlString);
579
+ const transport = url.protocol === 'http:' ? http : https;
580
+ const req = transport.request({
581
+ protocol: url.protocol,
582
+ hostname: url.hostname,
583
+ port: url.port || (url.protocol === 'http:' ? 80 : 443),
584
+ path: `${url.pathname}${url.search}`,
585
+ method: 'POST',
586
+ highWaterMark: 1 * 1024 * 1024,
587
+ headers: {
588
+ ...cleanHeaders(headers),
589
+ 'Connection': 'close'
590
+ }
591
+ }, (res) => {
592
+ let body = '';
593
+ res.setEncoding('utf8');
594
+ res.on('data', chunk => { body += chunk; });
595
+ res.on('end', () => {
596
+ resolve({
597
+ ok: res.statusCode >= 200 && res.statusCode < 300,
598
+ status: res.statusCode,
599
+ statusText: res.statusMessage || '',
600
+ text: async () => body
601
+ });
602
+ });
603
+ });
604
+
605
+ req.on('error', reject);
606
+ pipeline(stream, req).catch(err => {
607
+ req.destroy(err);
608
+ reject(err);
609
+ });
610
+ });
611
+ }
612
+
613
+ function postJsonStream(urlString, headers, body) {
614
+ return new Promise((resolve, reject) => {
615
+ const url = new URL(urlString);
616
+ const transport = url.protocol === 'http:' ? http : https;
617
+ const reqBody = Buffer.from(body);
618
+ const req = transport.request({
619
+ protocol: url.protocol,
620
+ hostname: url.hostname,
621
+ port: url.port || (url.protocol === 'http:' ? 80 : 443),
622
+ path: `${url.pathname}${url.search}`,
623
+ method: 'POST',
624
+ headers: {
625
+ ...cleanHeaders(headers),
626
+ 'Content-Length': String(reqBody.length),
627
+ 'Connection': 'close'
628
+ }
629
+ }, (res) => {
630
+ resolve({
631
+ ok: res.statusCode >= 200 && res.statusCode < 300,
632
+ status: res.statusCode,
633
+ statusText: res.statusMessage || '',
634
+ headers: res.headers,
635
+ stream: res,
636
+ text: () => new Promise((resolveText, rejectText) => {
637
+ let text = '';
638
+ res.setEncoding('utf8');
639
+ res.on('data', chunk => { text += chunk; });
640
+ res.on('end', () => resolveText(text));
641
+ res.on('error', rejectText);
642
+ })
643
+ });
644
+ });
645
+
646
+ req.on('error', reject);
647
+ req.end(reqBody);
648
+ });
649
+ }
650
+
651
+ function cleanHeaders(headers) {
652
+ return Object.fromEntries(
653
+ Object.entries(headers).filter(([, value]) => value !== undefined && value !== null)
654
+ );
655
+ }
656
+
657
+ // `nukebase push` onto a region that has no project of this name yet.
658
+ //
659
+ // Creates it through the same /project the dashboard uses — template files,
660
+ // nginx block, unit started, project node with a tier — then returns so the
661
+ // push proceeds and replaces the template with the local files, package.json
662
+ // and its settings included. The mail key the new region's easy-login needs
663
+ // is in no file: the platform hands it to the process at every start.
664
+ //
665
+ // Size comes from --tier <name>, else a prompt, else the server's default.
666
+ // The server enforces plan and wallet for that region and its error is
667
+ // printed verbatim — a project cannot be created where there is no credit.
668
+ async function offerCreateProject() {
669
+ const host = (() => { try { return new URL(API).host; } catch (e) { return API; } })();
670
+ console.log(`\n📦 Project "${project}" does not exist on ${host}.`);
671
+
672
+ let catalog = null;
673
+ try {
674
+ const r = await fetch(`${API}/tiers`);
675
+ if (r.ok) catalog = await r.json();
676
+ } catch (e) {}
677
+ const order = catalog?.order || [];
678
+ const tiers = catalog?.tiers || {};
679
+ const fmt = k => `${tiers[k].name} — ${tiers[k].memoryHigh}B RAM${tiers[k].diskGb ? ` · ${tiers[k].diskGb} GB disk` : ''} — $${(tiers[k].monthlyCents / 100).toFixed(0)}/mo`;
680
+
681
+ if (!(await confirm(`Create it on ${host}? (y/n) `))) {
682
+ console.log('Aborted. Nothing was created.');
683
+ return false;
684
+ }
685
+
686
+ // --tier takes a tier name or its RAM, like `create`: --tier 256mb.
687
+ let tier = args.get('tier');
688
+ if (tier && order.length) {
689
+ const resolved = require('./account-commands').resolveTier(tier, catalog);
690
+ if (!resolved) {
691
+ console.error(`❌ Unknown --tier "${tier}". One of: ${order.join(', ')}`);
692
+ return false;
693
+ }
694
+ tier = resolved;
695
+ }
696
+ if (!tier && order.length) {
697
+ const def = catalog.default || order[0];
698
+ if (AUTO_YES) {
699
+ tier = def;
700
+ console.log(` Size: ${fmt(tier)} ▶ default (--yes; pass --tier <name> to choose)`);
701
+ } else {
702
+ console.log('\n Sizes:');
703
+ order.forEach((k, i) => console.log(` ${i + 1}) ${fmt(k)}${k === def ? ' (default)' : ''}`));
704
+ const answer = (await askLine(` Size [${def}]: `)).trim().toLowerCase();
705
+ if (answer === '') tier = def;
706
+ else if (/^\d+$/.test(answer) && order[Number(answer) - 1]) tier = order[Number(answer) - 1];
707
+ else if (tiers[answer]) tier = answer;
708
+ else { console.error(`❌ Unknown size "${answer}".`); return false; }
709
+ }
710
+ }
711
+
712
+ console.log(`\n🚀 Creating "${project}" on ${host}${tier ? ` (${tiers[tier]?.name || tier})` : ''}...`);
713
+ let res;
714
+ try {
715
+ res = await fetch(`${API}/project`, {
716
+ method: 'POST',
717
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
718
+ body: JSON.stringify(tier ? { tier } : {})
719
+ });
720
+ } catch (e) {
721
+ // undici reports every transport failure as "fetch failed" and hides the
722
+ // reason in .cause. Setup takes a while (template copy, nginx reload,
723
+ // unit start, then a ZIP streamed back) and the connection can drop
724
+ // partway — after the project already exists server-side. So say what
725
+ // happened, then check rather than assume.
726
+ console.error('❌ Create failed:', e.cause?.message || e.message);
727
+ return await createdAnyway();
728
+ }
729
+ if (!res.ok) {
730
+ let msg = '';
731
+ try { const j = await res.json(); msg = j.error || JSON.stringify(j); } catch (e) { msg = await res.text().catch(() => ''); }
732
+ console.error(`❌ Create failed (${res.status}): ${msg}`);
733
+ return false;
734
+ }
735
+ // The response is the template ZIP the dashboard would download. This
736
+ // machine already has the project, so drain and drop it — but the server
737
+ // only finishes setup (unit start, registration) as the stream completes,
738
+ // so it must be read to the end rather than abandoned.
739
+ try {
740
+ await res.arrayBuffer();
741
+ } catch (e) {
742
+ console.error('⚠️ Connection dropped while the server finished setup:', e.cause?.message || e.message);
743
+ return await createdAnyway();
744
+ }
745
+ console.log(`✓ Created. The template it started with is replaced by this push.`);
746
+ return true;
747
+
748
+ async function createdAnyway() {
749
+ const r = await fetch(`${API}/sync`, {
750
+ method: 'POST',
751
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
752
+ body: JSON.stringify({ exclude: syncExclude, files: {}, direction: 'push', symlinks: SYMLINKS })
753
+ }).catch(() => null);
754
+ if (r && r.ok) {
755
+ console.log('✓ The project exists on the server anyway — continuing with the push.');
756
+ return true;
757
+ }
758
+ console.error(' The project was not created. Run the push again to retry.');
759
+ return false;
760
+ }
761
+ }
762
+
763
+ // One line of free text from the terminal. Empty string under --yes, or when
764
+ // stdin has no terminal, so callers fall back to their default.
765
+ function askLine(question) {
766
+ if (AUTO_YES || !process.stdin.isTTY) return Promise.resolve('');
767
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
768
+ return new Promise(resolve => {
769
+ let answered = false;
770
+ rl.question(question, answer => { answered = true; rl.close(); resolve(answer); });
771
+ rl.on('close', () => { if (!answered) resolve(''); });
772
+ });
773
+ }
774
+
775
+ // ---------------------------------------------------------------------------
776
+ // Project lifecycle — the dashboard's card buttons, from the terminal.
777
+ // ---------------------------------------------------------------------------
778
+ // Every one of these calls the same endpoint the dashboard does, with the same
779
+ // body, so behaviour cannot drift between the two. They all address the project
780
+ // in package.json.
781
+
782
+ // POST to a project endpoint; resolves the parsed JSON, exits with the server's
783
+ // message on failure. A 401 says how to sign back in.
784
+ // `verify`, when given, is asked whether the operation actually happened
785
+ // after a TRANSPORT failure — the connection dying is not the same as the
786
+ // server not doing the work. The domain step is the standing example: issuing
787
+ // the certificate ends with an nginx reload, and a reloading nginx sends
788
+ // GOAWAY on every open HTTP/2 connection, this one included. The browser
789
+ // retries silently; here we check the result instead.
790
+ async function projectPost(path, body, { label, verify } = {}) {
791
+ const send = () => fetch(`${API}${path}`, {
792
+ method: 'POST',
793
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
794
+ body: JSON.stringify(body || {})
795
+ });
796
+ let res;
797
+ try {
798
+ res = await send();
799
+ if (res.status === 401) authExpired();
800
+ } catch (e) {
801
+ const why = e.cause?.message || e.message;
802
+ if (verify) {
803
+ const ok = await verify(why);
804
+ if (ok) return ok;
805
+ }
806
+ console.error(`❌ ${label || path} failed:`, why);
807
+ process.exit(1);
808
+ }
809
+ let data = {};
810
+ try { data = await res.json(); } catch (e) {}
811
+ if (!res.ok) {
812
+ console.error(`❌ ${label || path} failed (${res.status}): ${data.error || JSON.stringify(data)}`);
813
+ process.exit(1);
814
+ }
815
+ return data;
816
+ }
817
+
818
+ // nukebase pause — stop the unit. Base fee and disk keep accruing (that is
819
+ // the published rule); CPU, memory and traffic stop.
820
+ async function pauseProject() {
821
+ await connectAndLogin();
822
+ const r = await projectPost('/pause', {}, { label: 'pause' });
823
+ console.log(`⏸ ${r.message || `Paused ${project}`}`);
824
+ process.exit(0);
825
+ }
826
+
827
+ // nukebase restart — bounce the unit. Also how a paused project resumes.
828
+ async function restartProject() {
829
+ await connectAndLogin();
830
+ await projectPost('/restart', {}, { label: command });
831
+ console.log(command === 'restart' ? `🔄 Restarting ${project}...` : `▶️ Resuming ${project}...`);
832
+ if (AUTO_YES) { await tailLogsUntilSettled(); process.exit(0); }
833
+ watchLogs();
834
+ }
835
+
836
+ // nukebase delete — the project, its files, its unit, its nginx block and
837
+ // its custom domain. Irreversible, so the name must be typed in full; --yes
838
+ // does NOT skip this one.
839
+ async function deleteProject() {
840
+ await connectAndLogin();
841
+ const host = (() => { try { return new URL(API).host; } catch (e) { return API; } })();
842
+ console.log(`\n⚠️ This deletes "${project}" on ${host}: files, database, backups,`);
843
+ console.log(' restore points and any custom domain. There is no undo.');
844
+ console.log(' Your local copy is not touched.\n');
845
+ if (!(await confirmTyped(project, `Type the project name (${project}) to confirm: `))) {
846
+ console.log('Aborted. Nothing was deleted.');
847
+ process.exit(1);
848
+ }
849
+ await projectPost('/delete-project', {}, { label: 'delete' });
850
+ console.log(`🗑️ Deleted ${project}.`);
851
+ process.exit(0);
852
+ }
853
+
854
+ // nukebase domain guided: DNS records, wait, connect
855
+ // nukebase domain <name> [--email you@x] one shot, for scripts (no waiting)
856
+ // nukebase domain remove disconnect it
857
+ // nukebase domain email [remove] send the app's emails from it
858
+ //
859
+ // The guided flow is the dashboard's modal in the terminal: it prints the two
860
+ // A records and the ownership TXT (nukebase.<domain> = nukebase-uid=<uid>,
861
+ // which the server requires on a domain's first connection so no other
862
+ // account can claim it) to create, polls DNS until they are in place (Enter to check now,
863
+ // Ctrl+C to give up and come back later), then asks the server to verify and
864
+ // issue the certificate — the slow part, up to a minute at Let's Encrypt.
865
+ // --email is the cert contact; it defaults to the account email in
866
+ // package.json.
867
+ async function domainCommand() {
868
+ await connectAndLogin();
869
+ const sub = args.positional[0] || null;
870
+
871
+ if (sub === 'remove') {
872
+ const cur = await currentDomain();
873
+ if (!cur) { console.log(`🌐 ${project} has no custom domain to remove.`); process.exit(0); }
874
+ if (!(await confirm(`Disconnect ${cur} from ${project}? The certificate is deleted too. (y/n) `))) {
875
+ console.log('Aborted.'); process.exit(1);
876
+ }
877
+ await projectPost('/remove-domain', {}, { label: 'domain remove' });
878
+ console.log('✓ Custom domain removed. The project is still served at its nukebase.com address.');
879
+ process.exit(0);
880
+ }
881
+
882
+ if (sub === 'email') {
883
+ await emailDomainCommand(args.positional[1] || null);
884
+ process.exit(0);
885
+ }
886
+
887
+ const email = args.get('email') || (String(accountLabel).includes('@') ? accountLabel : null);
888
+
889
+ // One shot: name given, no hand-holding. Scripts and people who already
890
+ // set DNS use this; the server still verifies before it issues anything.
891
+ // `--wait` adds the DNS poll from the guided flow first — what the region
892
+ // transfer uses, since it knows the name but the records still have to move.
893
+ if (sub) {
894
+ if (args.has('wait')) {
895
+ const ip = await regionIp();
896
+ const needTxt = (await currentDomain()) !== sub; // already ours: proven when connected
897
+ printDnsRecords(ip, needTxt);
898
+ await waitForDns(sub, ip, needTxt);
899
+ }
900
+ await connectDomain(sub, email);
901
+ if (args.has('email-domain')) await emailDomainCommand(null);
902
+ process.exit(0);
903
+ }
904
+
905
+ // Guided.
906
+ const cur = await currentDomain();
907
+ if (cur) {
908
+ console.log(`🌐 ${project} is served at https://${cur}`);
909
+ console.log(' nukebase domain <name> to replace it, nukebase domain remove to disconnect.');
910
+ process.exit(0);
911
+ }
912
+
913
+ if (!process.stdin.isTTY || AUTO_YES) {
914
+ console.log('Usage: nukebase domain <name> [--email you@example.com] | nukebase domain remove');
915
+ process.exit(1);
916
+ }
917
+
918
+ const ip = await regionIp();
919
+
920
+ console.log(`\n🌐 Connect a custom domain to ${project}\n`);
921
+ let domain = '';
922
+ while (!domain) {
923
+ domain = (await askLine(' Domain (e.g. example.com): ')).trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/^www\./, '');
924
+ if (!domain) { console.log(' Aborted.'); process.exit(1); }
925
+ if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) { console.log(` "${domain}" does not look like a domain.`); domain = ''; }
926
+ }
927
+
928
+ printDnsRecords(ip, true);
929
+ await waitForDns(domain, ip, true);
930
+
931
+ // Step 3 — the server does the real verification and issues the cert.
932
+ await connectDomain(domain, email);
933
+
934
+ // Optional: the app's emails from this domain too.
935
+ if (args.has('email-domain') || await confirm(`\n✉️ Send this app's emails from ${domain} too (instead of nukebase.com)? (y/n) `)) {
936
+ await emailDomainCommand(null);
937
+ } else {
938
+ console.log(' Later: nukebase domain email');
939
+ }
940
+ process.exit(0);
941
+
942
+ async function currentDomain() {
943
+ const r = await db.get(["users", uid, "projects", project, "customDomain"]).catch(() => null);
944
+ return r && r.data ? r.data : null;
945
+ }
946
+ }
947
+
948
+ // nukebase domain email [--root | --sub] [--wait]
949
+ // nukebase domain email remove
950
+ //
951
+ // Send the app's emails from its custom domain. The setup is only the
952
+ // domain and its DNS: the address, sender name and Reply-To are chosen per
953
+ // email in code (sendEmail's `from` / `replyTo`). The sending domain is either the
954
+ // custom domain itself (--root) or mail.<domain> (--sub; safest when the
955
+ // domain already has email). Prints the two DNS records to add, then waits
956
+ // for the mail provider to verify them (Enter to check now, Ctrl+C to leave
957
+ // it: the server keeps checking every 10 minutes for a week). Run again to
958
+ // see the status, change the Reply-To, or switch between --root and --sub.
959
+ async function emailDomainCommand(action) {
960
+ if (action === 'remove') {
961
+ if (!(await confirm(`Stop sending ${project}'s emails from its domain? They go back to nukebase.com. (y/n) `))) {
962
+ console.log('Aborted.'); process.exit(1);
963
+ }
964
+ await projectPost('/remove-email-domain', {}, { label: 'email remove' });
965
+ console.log('✓ Email sending domain removed. Emails are sent from nukebase.com again.');
966
+ return;
967
+ }
968
+
969
+ const check = await projectPost('/email-domain-check', {}, { label: 'email' });
970
+ const domain = check.domain;
971
+ const current = check.current;
972
+ const wantRoot = args.has('root') ? true : args.has('sub') ? false : null;
973
+ const replyTo = args.get('reply-to');
974
+ const fromArg = args.get('from');
975
+ const changing = wantRoot !== null && (!current || current.root !== wantRoot);
976
+
977
+ let rec = current;
978
+ if (!current || changing || replyTo !== undefined || fromArg !== undefined) {
979
+ // --from / --reply-to are only carried by a region transfer, for setups
980
+ // made when those were part of it.
981
+ const from = fromArg;
982
+ let root = wantRoot;
983
+ if (root === null) root = current ? current.root : null;
984
+ if (root === null) {
985
+ const suggestRoot = check.suggested === 'root';
986
+ console.log(`\n✉️ Send ${project}'s emails from ${domain}\n`);
987
+ console.log(` 1) anything@${domain}`);
988
+ console.log(` ${check.hasEmail ? 'This domain already has email set up' + (check.spf ? ' (you will update its SPF record)' : '') + '.' : 'Recommended: this domain has no email set up yet.'}`);
989
+ console.log(` 2) anything@${domain}, sent via mail.${domain}`);
990
+ console.log(' Safest if you already use email on this domain: its records are not touched.\n');
991
+ if (process.stdin.isTTY && !AUTO_YES) {
992
+ const a = (await askLine(` Choose 1 or 2 [${suggestRoot ? 1 : 2}]: `)).trim();
993
+ root = a === '1' ? true : a === '2' ? false : suggestRoot;
994
+ } else {
995
+ root = suggestRoot;
996
+ }
997
+ }
998
+ const reply = replyTo;
999
+ const body = { root };
1000
+ if (typeof from === 'string' && from) body.from = from;
1001
+ if (typeof reply === 'string') body.replyTo = reply;
1002
+ rec = (await projectPost('/email-domain', body, { label: 'email' })).emailDomain;
1003
+ }
1004
+
1005
+ printEmailRecords(rec);
1006
+ if (rec.status === 'verified') return;
1007
+ if (!args.has('wait') && (!process.stdin.isTTY || AUTO_YES)) {
1008
+ console.log(' The server checks every 10 minutes. See the status with: nukebase domain email');
1009
+ return;
1010
+ }
1011
+ await waitForEmailDomain();
1012
+ }
1013
+
1014
+ function printEmailRecords(rec) {
1015
+ const ok = rec.status === 'verified';
1016
+ const fromDomain = rec.fromDomain || (rec.root ? rec.name : rec.name.replace(/^mail\./, ''));
1017
+ console.log(`\n Sending domain: ${rec.name} ${ok ? '✓ verified' : '… waiting for DNS'}`);
1018
+ console.log(` Sends from: any address @${fromDomain} — your code picks it: sendEmail({ from: "Your Name <support>" })`);
1019
+ if (ok) { console.log(''); return; }
1020
+ console.log('\n At your registrar, add these DNS records:\n');
1021
+ for (const r of rec.records || []) {
1022
+ console.log(` ${r.valid ? '✓' : '…'} TXT ${r.host}`);
1023
+ if (r.replace) console.log(' REPLACE your existing SPF record (only one is allowed) with:');
1024
+ console.log(` ${r.value}\n`);
1025
+ }
1026
+ }
1027
+
1028
+ async function waitForEmailDomain() {
1029
+ console.log(' Waiting for verification. Enter to check now, Ctrl+C to stop (the server keeps checking).\n');
1030
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1031
+ let poke = null;
1032
+ rl.on('line', () => { if (poke) poke(); });
1033
+ const wait = (ms) => new Promise(res => { poke = res; setTimeout(res, ms); });
1034
+ try {
1035
+ for (;;) {
1036
+ const r = await projectPost('/email-domain-status', {}, { label: 'email status' });
1037
+ const rec = r.emailDomain;
1038
+ if (!rec) { console.log('\n The sending domain is gone. Run: nukebase domain email'); return; }
1039
+ const marks = (rec.records || []).map(x => `${x.valid ? '✓' : '…'} ${x.kind.toUpperCase()}`).join(' ');
1040
+ process.stdout.write(`\r ${marks} `);
1041
+ if (rec.status === 'verified') {
1042
+ console.log(`\n\n✓ Verified. ${project}'s emails now go out from @${rec.fromDomain || rec.name}.`);
1043
+ return;
1044
+ }
1045
+ await wait(20000);
1046
+ }
1047
+ } finally {
1048
+ rl.close();
1049
+ }
1050
+ }
1051
+
1052
+ async function connectDomain(domain, email) {
1053
+ console.log(`🔐 Step 3 — verifying DNS from the server and issuing SSL for ${domain} (up to a minute)...`);
1054
+ const r = await projectPost('/custom-domain', email ? { domain, email } : { domain }, {
1055
+ label: 'domain',
1056
+ // The connection dropped (nginx reload, see projectPost). The server
1057
+ // records the domain on the project as its LAST step, so poll for that:
1058
+ // present means the certificate was issued and nginx is serving it.
1059
+ verify: async (why) => {
1060
+ // Quiet: the drop is an implementation detail (nginx reloading), not
1061
+ // something the user can act on. The wait reads as part of step 3.
1062
+ for (let i = 0; i < 30; i++) {
1063
+ const cur = await db.get(["users", uid, "projects", project, "customDomain"]).catch(() => null);
1064
+ if (cur && cur.data === domain) return { domain, verified: true };
1065
+ await new Promise(r => setTimeout(r, 3000));
1066
+ }
1067
+ return null;
1068
+ }
1069
+ });
1070
+ console.log(`\n✓ https://${r.domain} is live.`);
1071
+ if (r.wwwNote) console.log(` ${r.wwwNote}`);
1072
+ else if (r.verified) console.log(' www redirects to the apex if its A record also points here.');
1073
+ }
1074
+
1075
+ // This region's public IP, from /region-info. null when it cannot be read;
1076
+ // the DNS wait then accepts any A record rather than a specific one.
1077
+ async function regionIp() {
1078
+ try {
1079
+ const r = await fetch(`${API}/region-info`, { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: '{}' });
1080
+ if (r.ok) return (await r.json()).serverIp || null;
1081
+ } catch (e) {}
1082
+ return null;
1083
+ }
1084
+
1085
+ // The ownership record the server checks on a domain's first connection.
1086
+ const domainTxtValue = () => `nukebase-uid=${uid}`;
1087
+
1088
+ function printDnsRecords(ip, withTxt) {
1089
+ console.log('\n Step 1 — point it here. At your registrar (GoDaddy, Namecheap,');
1090
+ console.log(' Cloudflare, ...) add these DNS records:\n');
1091
+ console.log(' Type Name Value');
1092
+ console.log(` A @ ${ip || "(this region's IP — see the dashboard)"}`);
1093
+ console.log(` A www ${ip || '(same)'}`);
1094
+ if (withTxt) {
1095
+ console.log(` TXT nukebase ${domainTxtValue()}`);
1096
+ console.log('\n The TXT record proves the domain is yours, so no other account can');
1097
+ console.log(' connect it. One on your main domain covers its subdomains too.');
1098
+ }
1099
+ console.log('\n If you use Cloudflare, set the records to "DNS only" (grey cloud)');
1100
+ console.log(' so the certificate can be issued.\n');
1101
+ }
1102
+
1103
+ // Step 2 — wait for DNS. Poll quietly; Enter forces a check; Ctrl+C leaves
1104
+ // the records in place and the command can be re-run later. Resolves once
1105
+ // the apex points here; says so if www does not yet.
1106
+ //
1107
+ // Ask public resolvers directly, not the machine's. Two reasons: the OS
1108
+ // resolver caches, so a record changed a minute ago can look unchanged here
1109
+ // long after the server sees it — and on some Windows setups Node's own
1110
+ // resolver cannot reach the system DNS server at all (ECONNREFUSED on every
1111
+ // name). getaddrinfo is the fallback for a machine that blocks 1.1.1.1.
1112
+ async function waitForDns(domain, ip, needTxt) {
1113
+ console.log(' Step 2 — waiting for DNS. This can take a few minutes.');
1114
+ console.log(' Press Enter to check now, Ctrl+C to stop and come back later.\n');
1115
+ const dns = require('dns').promises;
1116
+ const pub = new dns.Resolver(); pub.setServers(['1.1.1.1', '8.8.8.8']);
1117
+ const lookup = async (name) => {
1118
+ try { return await pub.resolve4(name); } catch (e) {}
1119
+ try { return (await dns.lookup(name, { all: true, family: 4 })).map(a => a.address); } catch (e) { return []; }
1120
+ };
1121
+ // Same rule as the server: nukebase.<domain>, then each parent.
1122
+ const want = domainTxtValue().toLowerCase();
1123
+ const txtFound = async () => {
1124
+ const labels = domain.split('.');
1125
+ for (let i = 0; i <= labels.length - 2; i++) {
1126
+ let recs;
1127
+ try { recs = await pub.resolveTxt('nukebase.' + labels.slice(i).join('.')); } catch (e) { continue; }
1128
+ if (recs.some(chunks => chunks.join('').trim().toLowerCase() === want)) return true;
1129
+ }
1130
+ return false;
1131
+ };
1132
+ let apexOk = false, wwwOk = false, txtOk = !needTxt;
1133
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1134
+ let poke = null;
1135
+ rl.on('line', () => { if (poke) poke(); });
1136
+ const wait = (ms) => new Promise(res => { poke = res; setTimeout(res, ms); });
1137
+ try {
1138
+ for (;;) {
1139
+ const [apex, www, txt] = await Promise.all([lookup(domain), lookup(`www.${domain}`), needTxt ? txtFound() : true]);
1140
+ apexOk = ip ? apex.includes(ip) : apex.length > 0;
1141
+ wwwOk = ip ? www.includes(ip) : www.length > 0;
1142
+ txtOk = txt;
1143
+ const mark = ok => ok ? '✓' : '…';
1144
+ 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)'}` : ''} `);
1145
+ if (apexOk && txtOk) break;
1146
+ await wait(15000);
1147
+ }
1148
+ } finally {
1149
+ rl.close();
1150
+ }
1151
+ console.log('\n');
1152
+ if (!wwwOk) {
1153
+ console.log(' www is not pointing here yet — connecting the apex only. Re-run');
1154
+ console.log(' `nukebase domain ' + domain + '` later to add the www redirect.\n');
1155
+ }
1156
+ }
1157
+
1158
+ // nukebase logs [--follow] the recent journal for the unit; --follow keeps
1159
+ // streaming new lines (Ctrl+C to stop), the same feed push tails.
1160
+ async function logsCommand() {
1161
+ await connectAndLogin();
1162
+ const r = await projectPost('/logs', {}, { label: 'logs' });
1163
+ const lines = Array.isArray(r.lines) ? r.lines : [];
1164
+ if (r.truncated) console.log(' (older output truncated)');
1165
+ for (const l of lines) console.log(makeClickable(typeof l === 'string' ? l : JSON.stringify(l)));
1166
+ if (args.has('follow')) { watchLogs(); return; }
1167
+ process.exit(0);
1168
+ }
1169
+
1170
+ // nukebase download [--out <file.zip>] the project ZIP the dashboard's
1171
+ // "Download ZIP" gives: a byte-for-byte snapshot of the server's folder,
1172
+ // node_modules and sys/ included. Streams to disk rather than buffering.
1173
+ async function downloadCommand() {
1174
+ await connectAndLogin();
1175
+ const out = args.get('out') || join(process.cwd(), `${project}.zip`);
1176
+ let res;
1177
+ try {
1178
+ res = await fetch(`${API}/download-project?project=${encodeURIComponent(project)}`, {
1179
+ headers: authHeaders()
1180
+ });
1181
+ } catch (e) {
1182
+ console.error('❌ download failed:', e.cause?.message || e.message);
1183
+ process.exit(1);
1184
+ }
1185
+ if (!res.ok) {
1186
+ let msg = ''; try { msg = (await res.json()).error; } catch (e) {}
1187
+ console.error(`❌ download failed (${res.status}): ${msg || ''}`);
1188
+ process.exit(1);
1189
+ }
1190
+ const { pipeline } = require('stream/promises');
1191
+ const { Readable } = require('stream');
1192
+ const fsMod = require('fs');
1193
+ let received = 0;
1194
+ const meter = new (require('stream').Transform)({
1195
+ transform(chunk, enc, cb) { received += chunk.length; process.stdout.write(`\r📥 ${(received / 1048576).toFixed(1)} MB`); cb(null, chunk); }
1196
+ });
1197
+ await pipeline(Readable.fromWeb(res.body), meter, fsMod.createWriteStream(out));
1198
+ console.log(`\n✓ Saved ${out}`);
1199
+ process.exit(0);
1200
+ }
1201
+
1202
+ // ---------------------------------------------------------------------------
1203
+ // nukebase regiontransfer [region]
1204
+ // ---------------------------------------------------------------------------
1205
+ // Move this project to another region, step by step, by driving the commands
1206
+ // above in the right order. No new server logic: each step is something you
1207
+ // could type yourself, and the transfer prints each one as it runs it so a
1208
+ // failure part-way leaves you knowing exactly where you are.
1209
+ //
1210
+ // 1 create the project on the target and push the code (source still up)
1211
+ // 2 pause the source (downtime starts)
1212
+ // 3 backup the source's data down to this machine
1213
+ // 4 push the backup set up to the target (downtime ends)
1214
+ // 5 copy the hosted environment variables
1215
+ // 6 custom domain, if there is one — DNS has to move to the target's IP
1216
+ // 7 point package.json at the target
1217
+ //
1218
+ // The source is left PAUSED, never deleted: verify the target, then
1219
+ // `nukebase delete --region <source>` when satisfied. Base fee and disk keep
1220
+ // accruing on a paused project, so do not forget.
1221
+ async function regionTransfer() {
1222
+ await connectAndLogin();
1223
+
1224
+ // -- where from, where to
1225
+ let info;
1226
+ try {
1227
+ const r = await fetch(`${API}/region-info`, { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: '{}' });
1228
+ info = r.ok ? await r.json() : null;
1229
+ } catch (e) {}
1230
+ if (!info) { console.error('❌ Could not read region info from the server.'); process.exit(1); }
1231
+ const source = info.thisRegion;
1232
+ const others = (info.allRegions || []).filter(r => r !== source);
1233
+
1234
+ // A region name, a list number, a hostname or a URL are all accepted:
1235
+ // "usw4", "2", "usw4.nukebase.com", "https://usw4.nukebase.com". The name is
1236
+ // the first host label. A region the source does not list (its region
1237
+ // config can lag a newly built box) is fine so long as it answers.
1238
+ const toRegion = (a) => {
1239
+ a = String(a || '').trim().toLowerCase();
1240
+ if (!a) return null;
1241
+ if (/^\d+$/.test(a)) return others[Number(a) - 1] || null;
1242
+ a = a.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
1243
+ return a.split('.')[0] || null;
1244
+ };
1245
+ let target = args.positional[0] ? toRegion(args.positional[0]) : null;
1246
+ if (!target) {
1247
+ if (AUTO_YES || !process.stdin.isTTY) {
1248
+ console.log(`Usage: nukebase regiontransfer <region> (this project is in ${source}${others.length ? '; known: ' + others.join(', ') : ''})`);
1249
+ process.exit(1);
1250
+ }
1251
+ console.log(`\n🌍 Move "${project}" out of ${source}.${others.length ? ' Regions:' : ''}\n`);
1252
+ others.forEach((r, i) => console.log(` ${i + 1}) ${r}`));
1253
+ target = toRegion(await askLine(`\n Move to${others.length ? ' (number or name)' : ''}: `));
1254
+ if (!target) { console.log(' Aborted.'); process.exit(1); }
1255
+ }
1256
+ if (target === source) { console.error(`❌ "${project}" is already in ${source}.`); process.exit(1); }
1257
+ const targetApi = (info.regionUrls && info.regionUrls[target]) ? info.regionUrls[target].replace(/\/$/, '') : regionApi(API, target);
1258
+ const targetHost = new URL(targetApi).host;
1259
+
1260
+ // Does it exist? /tiers is public, so a reply proves a NukeBase region is
1261
+ // listening there before anything is paused.
1262
+ try {
1263
+ const r = await fetch(`${targetApi}/tiers`);
1264
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
1265
+ if (!others.includes(target)) console.log(` (${target} is not in ${source}'s region list, but ${targetHost} answers — continuing)`);
1266
+ } catch (e) {
1267
+ console.error(`❌ No NukeBase region at ${targetHost} (${e.cause?.message || e.message}).`);
1268
+ process.exit(1);
1269
+ }
1270
+
1271
+ // Whose account on the target? The same one when the target recognises
1272
+ // this uid and token; otherwise — or when `--to <email>` says so — another
1273
+ // account, signed in on this machine like any other (~/.nukebase). Every
1274
+ // target step then runs `--as <email>`, so the source steps keep this
1275
+ // identity and package.json is only rewritten at the very end, when the
1276
+ // folder changes hands.
1277
+ let toEmail = args.get('to') && args.get('to').includes('@') ? args.get('to').trim().toLowerCase() : null;
1278
+ let targetUid = uid, targetToken = token;
1279
+ if (!toEmail) {
1280
+ let recognised = false;
1281
+ try {
1282
+ const r = await fetch(`${targetApi}/region-info`, { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: '{}' });
1283
+ recognised = r.ok;
1284
+ if (!r.ok && r.status !== 401) throw new Error(`HTTP ${r.status}`);
1285
+ } catch (e) {
1286
+ if (e.message && e.message.startsWith('HTTP')) { console.error(`❌ ${targetHost} rejected the account check (${e.message}).`); process.exit(1); }
1287
+ }
1288
+ if (!recognised) {
1289
+ console.log(`\n ${targetHost} does not recognise this account (uid ${uid}) — the account has`);
1290
+ console.log(` not replicated there, or the project is moving to a different account.`);
1291
+ if (AUTO_YES || !process.stdin.isTTY) {
1292
+ console.error(`❌ Pass --to <email> to say which account owns it on ${target}.`);
1293
+ process.exit(1);
1294
+ }
1295
+ const suggested = String(accountLabel).includes('@') ? accountLabel : '';
1296
+ toEmail = (await askLine(` Email of the account on ${target} [${suggested}]: `)).trim().toLowerCase() || suggested;
1297
+ if (!toEmail || !toEmail.includes('@')) { console.log(' Aborted.'); process.exit(1); }
1298
+ }
1299
+ }
1300
+ if (toEmail) {
1301
+ // Sign that account in now, so a typo or a dead inbox surfaces before
1302
+ // anything is paused. It is saved like any login, so the child steps find it.
1303
+ let acct = creds.find(toEmail);
1304
+ if (acct) {
1305
+ const r = await fetch(`${targetApi}/cli/whoami`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: `uid=${acct.uid}; token=${acct.token}` }, body: '{}' }).catch(() => null);
1306
+ if (!r || !r.ok) acct = null;
1307
+ }
1308
+ if (!acct) {
1309
+ console.log(`\n🔑 Signing in to ${targetHost} as ${toEmail}...`);
1310
+ try {
1311
+ const out = await loginFlows.browserOrDevice(targetApi, { email: toEmail });
1312
+ creds.put(out.uid, { label: out.label, api: targetApi, token: out.token, method: 'browser', created: Date.now(), lastUsed: Date.now() });
1313
+ acct = creds.get(out.uid);
1314
+ } catch (e) {
1315
+ die(e.message);
1316
+ }
1317
+ }
1318
+ targetUid = acct.uid; targetToken = acct.token;
1319
+ if (targetUid === uid) { console.log(` (same account on both sides: uid ${uid})`); toEmail = null; }
1320
+ else console.log(` ✓ ${acct.label} on ${target} is uid ${targetUid}; ${source}'s copy stays under uid ${uid}.`);
1321
+ }
1322
+ const asArgs = toEmail ? ['--as', targetUid] : [];
1323
+ const targetHeaders = (extra = {}) => ({ ...extra, 'X-project': project, 'Cookie': `uid=${targetUid}; token=${targetToken}` });
1324
+
1325
+ // -- what moves
1326
+ const node = (await db.get(["users", uid, "projects", project]).catch(() => null))?.data || {};
1327
+ const tier = node.tier || null;
1328
+ const customDomain = typeof node.customDomain === 'string' ? node.customDomain : null;
1329
+ const running = node.stats?.active !== false;
1330
+ const hosted = await fetchHostedEnv({ optional: true });
1331
+ const envNames = hosted ? Object.keys(hosted).filter(k => k !== 'SOCKET' && k !== 'DOMAIN') : [];
1332
+
1333
+ console.log(`\n📋 Plan\n`);
1334
+ console.log(` Project ${project}`);
1335
+ console.log(` From ${source} (${new URL(API).host})`);
1336
+ console.log(` To ${target} (${targetHost})${toEmail ? ` as ${toEmail}` : ''}`);
1337
+ console.log(` Size ${tier || 'default'}`);
1338
+ console.log(` Env variables ${hosted === null ? 'none (' + source + ' predates hosted environments)' : envNames.length ? envNames.length + ': ' + envNames.join(', ') : 'none'}`);
1339
+ console.log(` Custom domain ${customDomain || 'none'}`);
1340
+ console.log(`\n The source is paused while its data is copied, and stays paused`);
1341
+ console.log(` afterwards — it is not deleted. Delete it yourself once the target`);
1342
+ console.log(` checks out: nukebase delete --region ${source}`);
1343
+ if (customDomain) {
1344
+ console.log(`\n ${customDomain} keeps working on ${source} until you move its DNS to`);
1345
+ console.log(` ${target}; step 6 walks through that.`);
1346
+ }
1347
+ if (!(await confirm('\nContinue? (y/n) '))) { console.log('Aborted. Nothing changed.'); process.exit(0); }
1348
+
1349
+ // Each step is the CLI run again as a child, so the output is exactly what
1350
+ // the command prints on its own and a failure stops the sequence with the
1351
+ // remaining steps listed as commands to run by hand.
1352
+ const { spawnSync } = require('child_process');
1353
+ const steps = [];
1354
+ const step = (title, args) => steps.push({ title, args });
1355
+ step('1/7 Create the project on the target and push the code',
1356
+ ['push', '--yes', '--region', target, ...asArgs, ...(tier ? ['--tier', tier] : [])]);
1357
+ step('2/7 Pause the source', running ? ['pause'] : null);
1358
+ step('3/7 Back up the source\'s data to this machine', ['backup', '--yes']);
1359
+ step('4/7 Push the backup set to the target', ['push', '--include', 'backup', '--yes', '--region', target, ...asArgs]);
1360
+
1361
+ for (let i = 0; i < steps.length; i++) {
1362
+ const { title, args } = steps[i];
1363
+ console.log(`\n${'─'.repeat(66)}\n ${title}\n${'─'.repeat(66)}`);
1364
+ if (!args) { console.log(' (already paused — skipped)'); continue; }
1365
+ console.log(` $ nukebase ${args.join(' ')}\n`);
1366
+ const r = spawnSync(process.execPath, [BIN, ...args], { stdio: 'inherit', cwd: root });
1367
+ if (r.status !== 0) {
1368
+ console.error(`\n❌ Step ${title.split(' ')[0]} failed. Nothing after it was run.`);
1369
+ console.error(' Fix the cause and run the remaining steps by hand:');
1370
+ for (const rest of steps.slice(i)) if (rest.args) console.error(` nukebase ${rest.args.join(' ')}`);
1371
+ console.error(` nukebase env push --region ${target}${asArgs.length ? ' ' + asArgs.join(' ') : ''} (after writing the variables to .env)`);
1372
+ if (customDomain) console.error(` nukebase domain ${customDomain} --region ${target} --wait${asArgs.length ? ' ' + asArgs.join(' ') : ''}`);
1373
+ console.error(` then set "api" in the "nukebase" section of package.json to ${targetApi}`);
1374
+ process.exit(1);
1375
+ }
1376
+ }
1377
+
1378
+ // 5 — environment. Straight from the source's hosted copy to the target's,
1379
+ // never through a local .env (which may not exist, or may differ).
1380
+ console.log(`\n${'─'.repeat(66)}\n 5/7 Copy the hosted environment\n${'─'.repeat(66)}`);
1381
+ if (hosted === null) {
1382
+ console.log(` (${source} has no hosted environment to copy — skipped)`);
1383
+ } else if (!envNames.length) {
1384
+ console.log(' (no variables — skipped)');
1385
+ } else {
1386
+ const env = Object.fromEntries(envNames.map(k => [k, hosted[k]]));
1387
+ const r = await fetch(`${targetApi}/update-env`, {
1388
+ method: 'POST',
1389
+ headers: targetHeaders({ 'Content-Type': 'application/json' }),
1390
+ body: JSON.stringify({ env })
1391
+ }).catch(e => ({ ok: false, status: 0, text: async () => e.cause?.message || e.message }));
1392
+ if (!r.ok) {
1393
+ console.error(`❌ Could not write the environment on ${target}: ${r.status} ${await r.text()}`);
1394
+ console.error(` Write the ${envNames.length} variable(s) to .env and run: nukebase env push --region ${target}${asArgs.length ? ' ' + asArgs.join(' ') : ''}`);
1395
+ process.exit(1);
1396
+ }
1397
+ console.log(` ✓ ${envNames.length} variable(s) set on ${target}; the project restarts with them.`);
1398
+ }
1399
+
1400
+ // 6 — custom domain. Optional here: it needs a registrar change, and the
1401
+ // source keeps serving it meanwhile, so it can be finished later.
1402
+ console.log(`\n${'─'.repeat(66)}\n 6/7 Custom domain\n${'─'.repeat(66)}`);
1403
+ if (!customDomain) {
1404
+ console.log(' (none — skipped)');
1405
+ } else if (await confirm(` Move ${customDomain} to ${target} now? You will need to change its DNS. (y/n) `)) {
1406
+ const r = spawnSync(process.execPath, [BIN, 'domain', customDomain, '--region', target, '--wait', ...asArgs], { stdio: 'inherit', cwd: root });
1407
+ if (r.status !== 0) {
1408
+ console.error(`\n⚠️ The domain was not connected on ${target}. It still works on ${source}.`);
1409
+ console.error(` Finish later with: nukebase domain ${customDomain} --region ${target} --wait${asArgs.length ? ' ' + asArgs.join(' ') : ''}`);
1410
+ } else if (node.emailDomain && node.emailDomain.name) {
1411
+ // Same sending domain on the target: the mail provider already has it
1412
+ // verified (its DNS records do not change), so the target adopts it.
1413
+ const em = node.emailDomain;
1414
+ const emArgs = ['domain', 'email', em.root ? '--root' : '--sub', '--region', target, '--yes', ...asArgs];
1415
+ if (em.replyTo) emArgs.push('--reply-to', em.replyTo);
1416
+ if (em.fromLocal) emArgs.push('--from', em.fromLocal);
1417
+ const e = spawnSync(process.execPath, [BIN, ...emArgs], { stdio: 'inherit', cwd: root });
1418
+ if (e.status !== 0) console.error(` Email sending: finish later with: nukebase domain email --region ${target}${asArgs.length ? ' ' + asArgs.join(' ') : ''}`);
1419
+ }
1420
+ } else {
1421
+ console.log(` Skipped. ${customDomain} stays on ${source} for now. When ready:`);
1422
+ console.log(` nukebase domain ${customDomain} --region ${target} --wait${asArgs.length ? ' ' + asArgs.join(' ') : ''}`);
1423
+ }
1424
+
1425
+ // 7 — this folder now belongs to the target.
1426
+ console.log(`\n${'─'.repeat(66)}\n 7/7 Point this project at ${target}\n${'─'.repeat(66)}`);
1427
+ const fresh = readConfig(root);
1428
+ fresh.API = targetApi;
1429
+ if (toEmail) {
1430
+ // The folder changes hands: it now names the target account as owner.
1431
+ // Both accounts stay signed in on this machine, so
1432
+ // `nukebase delete --region <source> --as <old account>` still works.
1433
+ fresh.uid = targetUid;
1434
+ fresh.account = creds.get(targetUid)?.label || toEmail;
1435
+ }
1436
+ writeConfig(root, fresh);
1437
+ console.log(` ✓ package.json "nukebase" api → ${targetApi}${toEmail ? `, account → ${fresh.account}` : ''}`);
1438
+
1439
+ const oldAs = toEmail ? ` --as ${accountLabel}` : '';
1440
+ console.log(`\n✅ "${project}" is running in ${target}: https://${targetUid}-${project}.${targetHost}`);
1441
+ console.log(`\n Next:`);
1442
+ console.log(` • check the app there`);
1443
+ console.log(` • the ${source} copy is paused and still billing its base fee and disk;`);
1444
+ console.log(` when satisfied: nukebase delete --region ${source}${oldAs}`);
1445
+ process.exit(0);
1446
+ }
1447
+
1448
+ // `node --check` every server-side JavaScript file about to be uploaded.
1449
+ // Returns [{ file, message }] for the ones that do not parse.
1450
+ //
1451
+ // Only code Node will run on the server: anything under a `public/` folder is
1452
+ // browser code (ES modules — `import`/`export` in a .js file, which Node would
1453
+ // wrongly reject in a CommonJS project) and anything in node_modules is not
1454
+ // ours to check. Uses the same Node that is running this CLI.
1455
+ function syntaxCheck(files) {
1456
+ const { spawnSync } = require('child_process');
1457
+ const bad = [];
1458
+ for (const rel of files) {
1459
+ if (!/\.(c|m)?js$/.test(rel)) continue;
1460
+ const parts = rel.split('/');
1461
+ if (parts.includes('public') || parts.includes('node_modules')) continue;
1462
+ const abs = join(root, rel);
1463
+ if (!existsSync(abs)) continue;
1464
+ const r = spawnSync(process.execPath, ['--check', abs], { encoding: 'utf8' });
1465
+ if (r.status !== 0) {
1466
+ const msg = (r.stderr || r.stdout || 'does not parse').split(root + sep).join('').trim()
1467
+ .split('\n').filter(l => !/^\s+at /.test(l) && !/^Node\.js v/.test(l)).join('\n').trim();
1468
+ bad.push({ file: rel, message: msg });
1469
+ }
1470
+ }
1471
+ return bad;
1472
+ }
1473
+
1474
+ async function push() {
1475
+ await connectAndLogin();
1476
+
1477
+ // One id for this whole push, sent on /upload, /remove and /restart so the
1478
+ // server files all three into ONE deploy snapshot. Without it the server
1479
+ // falls back to correlating by time, which works but is less precise.
1480
+ const deployId = newDeployId();
1481
+
1482
+ console.log('🔍 Checking for changes...');
1483
+
1484
+ const localFiles = getFiles(root);
1485
+
1486
+ const doSync = () => fetch(`${API}/sync`, {
1487
+ method: 'POST',
1488
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
1489
+ body: JSON.stringify({ exclude: syncExclude, files: localFiles, direction: 'push', symlinks: SYMLINKS })
1490
+ });
1491
+
1492
+ let res = await doSync();
1493
+ if (res.status === 401) authExpired();
1494
+
1495
+ // The project is not on this server. That is the normal state when the API
1496
+ // in package.json was just pointed at a different region — the account
1497
+ // is the same everywhere, the project is not — so offer to create it here
1498
+ // and carry on, rather than failing with a bare 404.
1499
+ if (res.status === 404) {
1500
+ if (!(await offerCreateProject())) process.exit(1);
1501
+ res = await doSync();
1502
+ }
1503
+
1504
+ // 507: the project is at its size's disk line, or the account at its push
1505
+ // cut-off. The body says which, in plain text. A push that only removes
1506
+ // files is never refused.
1507
+ if (res.status === 507) {
1508
+ console.error('❌ ' + (await res.text()).trim());
1509
+ process.exit(1);
1510
+ }
1511
+
1512
+ if (!res.ok) {
1513
+ console.error('❌ Sync failed:', res.status, await res.text());
1514
+ process.exit(1);
1515
+ }
1516
+
1517
+ const { upload, remove, diskWarning } = await res.json();
1518
+ if (diskWarning) console.log('⚠️ ' + diskWarning);
1519
+
1520
+ if (!upload.length && !remove.length) {
1521
+ console.log('✓ Already up to date');
1522
+ if (AUTO_YES) process.exit(0);
1523
+ watchLogs();
1524
+ return;
1525
+ }
1526
+
1527
+ // Interactive: tail live logs during and after the push (Ctrl+C to stop).
1528
+ // Non-interactive (--yes): defer to the bounded tail after the push completes.
1529
+ if (!AUTO_YES) watchLogs();
1530
+
1531
+ // Links carry no content, and their entry[0] is the "L" sentinel rather than a
1532
+ // number — including one would turn the running total into a string.
1533
+ const totalSize = upload.reduce((sum, file) => {
1534
+ const entry = localFiles[file];
1535
+ if (!entry || isLinkEntry(entry)) return sum;
1536
+ return sum + (entry[0] || 0);
1537
+ }, 0);
1538
+
1539
+ if (upload.length) {
1540
+ console.log(`\n📤 Uploading ${upload.length} files (${(totalSize / 1024 / 1024).toFixed(2)} MB):`);
1541
+ upload.slice(0, 10).forEach(f => console.log(' + ' + f));
1542
+ if (upload.length > 10) console.log(` ... and ${upload.length - 10} more`);
1543
+ }
1544
+
1545
+ if (remove.length) {
1546
+ console.log(`\n🗑️ Removing ${remove.length} files (after upload completes):`);
1547
+ remove.slice(0, 10).forEach(f => console.log(' - ' + f));
1548
+ if (remove.length > 10) console.log(` ... and ${remove.length - 10} more`);
1549
+ }
1550
+
1551
+ if (INCLUDE_BACKUP) {
1552
+ // Which of the uploads are actually backup-set files. Shown separately
1553
+ // because they are the ones that overwrite live state rather than code.
1554
+ const backupUploads = upload.filter(f =>
1555
+ backupPaths.some(p => f === p || f.startsWith(p + '/')));
1556
+
1557
+ console.log('\n' + '─'.repeat(66));
1558
+ console.log(' ⚠️ --include backup: this OVERWRITES the server\'s live data');
1559
+ console.log('─'.repeat(66));
1560
+ if (backupUploads.length) {
1561
+ console.log(`\n ${backupUploads.length} backup-set file(s) will replace what is on the server:`);
1562
+ backupUploads.slice(0, 10).forEach(f => console.log(' ⇈ ' + f));
1563
+ if (backupUploads.length > 10) console.log(` ... and ${backupUploads.length - 10} more`);
1564
+ console.log('\n Whatever the live app has written since is replaced by these.');
1565
+ console.log(' Nothing under those paths is DELETED — the server keeps files');
1566
+ console.log(' your copy does not have.');
1567
+ console.log('\n The project is stopped before extraction, so the running');
1568
+ console.log(' process cannot flush its in-memory database over the upload.');
1569
+ } else {
1570
+ console.log('\n No backup-set files found locally — nothing extra to send.');
1571
+ console.log(' Run `nukebase backup` first if you meant to restore a copy.');
1572
+ }
1573
+ console.log('');
1574
+ }
1575
+
1576
+ // Refuse to ship server code that does not even parse. A syntax error in
1577
+ // server/app.js does not fail the push — it fails the NEXT START of the
1578
+ // server, after the files are already live: the app is down until someone
1579
+ // notices, and on the hosted master the deploy guard has to roll it back.
1580
+ // Checking here costs a second and stops the whole class before it leaves
1581
+ // this machine.
1582
+ if (!args.has('no-check')) {
1583
+ const bad = syntaxCheck(upload);
1584
+ if (bad.length) {
1585
+ console.error(`\n❌ Not pushed: ${bad.length} file(s) do not parse:\n`);
1586
+ for (const b of bad) console.error(`── ${b.file}\n${b.message}\n`);
1587
+ console.error('Fix them and push again (or pass --no-check to push anyway).');
1588
+ process.exit(1);
1589
+ }
1590
+ }
1591
+
1592
+ console.log(`\n📊 Total: ${upload.length} to upload, ${remove.length} to remove`);
1593
+ const ok = await confirm(`Continue? (y/n) `);
1594
+ if (!ok) {
1595
+ console.log('Aborted.');
1596
+ process.exit(0);
1597
+ }
1598
+
1599
+ if (upload.length) {
1600
+ const sizeOf = (f) => {
1601
+ const entry = localFiles[f];
1602
+ return (!entry || isLinkEntry(entry)) ? 0 : (entry[0] || 0);
1603
+ };
1604
+ // Always prepare when the server supports it, even for one stream: a
1605
+ // prepared push is unpacked by a process running as the project's own
1606
+ // user, with the restore point already taken. Parallel only when it pays
1607
+ // AND the server agreed. A server that declines (the master) or predates
1608
+ // /upload-prepare gets one ordinary stream, exactly as before.
1609
+ const prepared = await prepareParallelUpload(deployId, upload);
1610
+ const streams = wantParallel(upload.length, totalSize) ? prepared : 1;
1611
+ const groups = splitBySize(upload, sizeOf, streams);
1612
+ console.log(`\n Streaming upload${groups.length > 1 ? ` (${groups.length} parallel streams)` : ''}...`);
1613
+
1614
+ const meter = progressMeter(totalSize);
1615
+ const results = await Promise.all(groups.map(async (group) => {
1616
+ // follow:false is node-tar's default, but state it explicitly — it is the
1617
+ // difference between archiving a link and archiving whatever it points at.
1618
+ const tarStream = tar.c({ cwd: root, portable: true, follow: false }, group);
1619
+ const body = tarStream.pipe(meter.counter());
1620
+ try {
1621
+ // X-Include-Backup tells the server to stop the unit BEFORE extracting.
1622
+ // Without that, /restart's SIGTERM makes the dying process flush its
1623
+ // in-memory tree over the database we just uploaded, and the new
1624
+ // process boots from that — the upload silently does nothing.
1625
+ const res = await postStream(`${API}/upload`, authHeaders({
1626
+ 'X-Deploy-Id': deployId,
1627
+ 'X-Backup-Paths': JSON.stringify(backupPaths),
1628
+ ...(INCLUDE_BACKUP ? { 'X-Include-Backup': '1' } : {})
1629
+ }), body);
1630
+ return { ok: res.ok, status: res.status, text: res.ok ? '' : await res.text() };
1631
+ } catch (err) {
1632
+ return { ok: false, status: 0, text: err.message };
1633
+ }
1634
+ }));
1635
+ meter.done();
1636
+
1637
+ const failed = results.filter(r => !r.ok);
1638
+ if (failed.length) {
1639
+ for (const r of failed) {
1640
+ if (r.status === 507) console.error('\n❌ ' + String(r.text).trim());
1641
+ else console.error('\n❌ Upload failed:', r.status || '', r.text);
1642
+ }
1643
+ if (results.length > 1) {
1644
+ console.error(` ${failed.length} of ${results.length} streams failed. Files that arrived are kept — run the push again to send the rest.`);
1645
+ }
1646
+ // --include backup stopped the project server-side; don't leave it down.
1647
+ if (INCLUDE_BACKUP) {
1648
+ console.error(' Restarting the project (it was stopped for --include backup)...');
1649
+ await fetch(`${API}/restart`, { method: 'POST', headers: authHeaders() }).catch(() => {});
1650
+ }
1651
+ process.exit(1);
1652
+ }
1653
+ }
1654
+
1655
+ if (remove.length) {
1656
+ const removeRes = await fetch(`${API}/remove`, {
1657
+ method: 'POST',
1658
+ headers: authHeaders({ 'Content-Type': 'application/json', 'X-Deploy-Id': deployId, 'X-Backup-Paths': JSON.stringify(backupPaths) }),
1659
+ body: JSON.stringify(remove)
1660
+ });
1661
+
1662
+ if (!removeRes.ok) {
1663
+ console.error('❌ Remove failed:', removeRes.status, await removeRes.text());
1664
+ // --include backup stopped the project server-side before extracting, and
1665
+ // the /restart below is what starts it again. Bailing out here would leave
1666
+ // a tenant's app down over a failed file deletion, so restart first.
1667
+ if (INCLUDE_BACKUP) {
1668
+ console.error(' Restarting the project (it was stopped for --include backup)...');
1669
+ await fetch(`${API}/restart`, { method: 'POST', headers: authHeaders() }).catch(() => {});
1670
+ }
1671
+ process.exit(1);
1672
+ }
1673
+ }
1674
+
1675
+ console.log('\n🔄 Restarting server...');
1676
+
1677
+ // Restart also seals the deploy snapshot server-side, which is what makes it
1678
+ // eligible for rollback.
1679
+ const restartRes = await fetch(`${API}/restart`, {
1680
+ method: 'POST',
1681
+ headers: authHeaders({ 'X-Deploy-Id': deployId })
1682
+ });
1683
+
1684
+ // The files are on the server either way, and the restore point is sealed
1685
+ // before the server decides whether it may start. A refusal (cancelled plan,
1686
+ // empty balance, spending cap) is not a failed upload — but the app is NOT
1687
+ // running, so say so instead of printing success and tailing a silent log.
1688
+ if (!restartRes.ok) {
1689
+ let why = `HTTP ${restartRes.status}`;
1690
+ try { why = (await restartRes.json()).error || why; } catch {}
1691
+ console.log(`\n✅ Uploaded (${upload.length} uploaded, ${remove.length} removed) — deploy ${deployId}`);
1692
+ console.error(`⚠️ The project was not started: ${why}`);
1693
+ process.exit(1);
1694
+ }
1695
+
1696
+ console.log(`\n✅ Pushed! (${upload.length} uploaded, ${remove.length} removed)`);
1697
+ console.log(` Deploy ${deployId} — run 'nukebase rollback' to undo this push`);
1698
+
1699
+ // Non-interactive: tail logs only until the server settles, then exit so the
1700
+ // AI agent / CI job returns on its own. Interactive keeps the live tail above.
1701
+ if (AUTO_YES) {
1702
+ await tailLogsUntilSettled();
1703
+ console.log('\n✅ Deploy complete.');
1704
+ process.exit(0);
1705
+ }
1706
+ }
1707
+
1708
+ async function pull() {
1709
+ await connectAndLogin();
1710
+
1711
+ console.log('🔍 Checking for changes...');
1712
+
1713
+ const doSync = () => fetch(`${API}/sync`, {
1714
+ method: 'POST',
1715
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
1716
+ body: JSON.stringify({ exclude: syncExclude, files: getFiles(root), direction: 'pull', symlinks: SYMLINKS })
1717
+ });
1718
+
1719
+ let res = await doSync();
1720
+ if (res.status === 401) authExpired();
1721
+
1722
+ if (!res.ok) {
1723
+ console.error('❌ Sync failed:', res.status, await res.text());
1724
+ process.exit(1);
1725
+ }
1726
+
1727
+ const { download, remove, totalSize } = await res.json();
1728
+
1729
+ if (!download.length && !remove.length) {
1730
+ console.log('✓ Already up to date');
1731
+ return;
1732
+ }
1733
+
1734
+ if (download.length) {
1735
+ console.log(`\n📥 Downloading ${download.length} files (${(totalSize / 1024 / 1024).toFixed(2)} MB):`);
1736
+ download.slice(0, 10).forEach(f => console.log(' + ' + f));
1737
+ if (download.length > 10) console.log(` ... and ${download.length - 10} more`);
1738
+ }
1739
+
1740
+ if (remove.length) {
1741
+ console.log(`\n🗑️ Removing ${remove.length} files (after download completes):`);
1742
+ remove.slice(0, 10).forEach(f => console.log(' - ' + f));
1743
+ if (remove.length > 10) console.log(` ... and ${remove.length - 10} more`);
1744
+ }
1745
+
1746
+ console.log(`\n📊 Total: ${download.length} to download, ${remove.length} to remove`);
1747
+ const ok = await confirm(`Continue? (y/n) `);
1748
+ if (!ok) {
1749
+ console.log('Aborted.');
1750
+ process.exit(0);
1751
+ }
1752
+
1753
+ if (download.length) {
1754
+ console.log('\n⬇️ Streaming download...');
1755
+
1756
+ const pullSizes = {};
1757
+ const result = await downloadStreams(root, download, (f) => pullSizes[f] || 0, totalSize);
1758
+
1759
+ // The server's package.json carries whichever settings were last pushed
1760
+ // to it — from another machine, or with --region another region's copy.
1761
+ // This folder's own settings are what the developer meant; put them back
1762
+ // (also after a stop, since the file may already have landed).
1763
+ if (download.includes('package.json')) restoreSettings(root, config);
1764
+
1765
+ if (result.stopped) {
1766
+ console.log('\n🛑 Pull stopped cleanly. Local file deletes were skipped. Re-run `nukebase pull` to resume.');
1767
+ return;
1768
+ }
1769
+ }
1770
+
1771
+ for (const file of remove) {
1772
+ // Same containment rule as extraction: a path from the server never
1773
+ // reaches outside the project.
1774
+ if (!tarEntryAllowed(root, file)) { console.warn(` ! skipped ${file} (outside the project)`); continue; }
1775
+ const fullPath = join(root, file);
1776
+ // lstat rather than existsSync: existsSync follows links, so a broken
1777
+ // symlink looks absent and would never get cleaned up. unlink removes the
1778
+ // link itself and never touches what it points at.
1779
+ let stat = null;
1780
+ try { stat = lstatSync(fullPath); } catch (e) { stat = null; }
1781
+ if (stat) {
1782
+ unlinkSync(fullPath);
1783
+ cleanEmptyDirs(fullPath, root);
1784
+ }
1785
+ }
1786
+
1787
+ console.log(`\n✅ Pulled! (${download.length} downloaded, ${remove.length} removed)`);
1788
+ }
1789
+
1790
+
1791
+ // Work out what package.json needs so it matches this engine release: the
1792
+ // dependency versions it was built against, plus the engine version itself.
1793
+ // Dependencies are add/overwrite only — names the engine doesn't ship are
1794
+ // untouched, and nothing is ever deleted.
1795
+ function planPackageChanges(manifest, updateExclude) {
1796
+ const pkgPath = join(root, 'package.json');
1797
+ const empty = (note) => ({ pkgPath, pkg: null, changes: [], versionChanged: false, note });
1798
+
1799
+ if (updateExclude.includes('package.json')) return empty('skipped (updateExclude)');
1800
+ if (!existsSync(pkgPath)) return empty('no package.json found');
1801
+
1802
+ let pkg;
1803
+ try {
1804
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
1805
+ } catch (e) {
1806
+ return empty(`could not parse package.json (${e.message}) — skipping`);
1807
+ }
1808
+
1809
+ const local = pkg.dependencies || {};
1810
+ const changes = [];
1811
+ for (const [name, want] of Object.entries(manifest.dependencies || {})) {
1812
+ if (local[name] === want) continue;
1813
+ changes.push({ name, from: local[name] || null, to: want });
1814
+ }
1815
+
1816
+ // The project template ships "version" as the engine version, so it tracks
1817
+ // the engine rather than the developer's app — keep it in step.
1818
+ const versionFrom = typeof pkg.version === 'string' ? pkg.version : null;
1819
+ const versionTo = manifest.version;
1820
+
1821
+ return {
1822
+ pkgPath, pkg, changes,
1823
+ versionFrom, versionTo,
1824
+ versionChanged: versionFrom !== versionTo,
1825
+ note: null
1826
+ };
1827
+ }
1828
+
1829
+ function hasPackageWork(plan) {
1830
+ return plan.changes.length > 0 || plan.versionChanged;
1831
+ }
1832
+
1833
+ // Rewrite package.json with the pinned versions. Everything else in the file —
1834
+ // name, scripts, devDependencies, your own deps — is preserved as-is.
1835
+ function applyPackageChanges(plan) {
1836
+ const merged = { ...(plan.pkg.dependencies || {}) };
1837
+ for (const c of plan.changes) merged[c.name] = c.to;
1838
+
1839
+ // npm keeps dependencies alphabetically sorted and rewrites them that way on
1840
+ // any `npm install`; matching it here means repeat updates produce a stable
1841
+ // file instead of reshuffling the diff each time.
1842
+ plan.pkg.dependencies = Object.fromEntries(
1843
+ Object.keys(merged).sort((a, b) => a.localeCompare(b)).map(k => [k, merged[k]])
1844
+ );
1845
+ plan.pkg.version = plan.versionTo;
1846
+
1847
+ writeFileSync(plan.pkgPath, JSON.stringify(plan.pkg, null, 2) + '\n');
1848
+ }
1849
+
1850
+ // Refresh the NukeBase engine files — database.js, the browser SDK, the CLI
1851
+ // itself, and the auth extensions — from the server's canonical copies. These
1852
+ // belong to the platform rather than to your application, so this behaves like
1853
+ // `pull`: it shows what differs and overwrites on confirm. Anything listed in
1854
+ // config.updateExclude is never touched, so a customized extension survives
1855
+ // every future update.
1856
+ async function update() {
1857
+ // The master project's extensions legitimately differ from the tenant
1858
+ // template (it mounts mail-magic.js and runs a newer auth stack), so updating
1859
+ // here would replace them with the template's copies and break its auth.
1860
+ if (existsSync(join(root, 'server', 'region.config.json'))) {
1861
+ console.error('❌ Refusing to update the master project (server/region.config.json present).');
1862
+ process.exit(1);
1863
+ }
1864
+
1865
+ await connectAndLogin();
1866
+
1867
+ console.log('🔍 Checking engine files...');
1868
+
1869
+ const doFetch = () => fetch(`${API}/engine-manifest`, {
1870
+ method: 'POST',
1871
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
1872
+ body: '{}'
1873
+ });
1874
+
1875
+ let res = await doFetch();
1876
+ if (res.status === 401) authExpired();
1877
+ if (!res.ok) {
1878
+ console.error('❌ Engine manifest fetch failed:', res.status, await res.text());
1879
+ process.exit(1);
1880
+ }
1881
+
1882
+ const manifest = await res.json();
1883
+ const updateExclude = Array.isArray(config.updateExclude) ? config.updateExclude : [];
1884
+ // Optional positional filter: `nukebase update server/database.js`
1885
+ const only = args.positional;
1886
+
1887
+ const rows = manifest.files.map(file => {
1888
+ const localPath = join(root, file.path);
1889
+ const exists = existsSync(localPath);
1890
+ const localHash = exists
1891
+ ? crypto.createHash('sha256').update(readFileSync(localPath)).digest('hex')
1892
+ : null;
1893
+
1894
+ let state;
1895
+ if (updateExclude.includes(file.path)) state = 'protected';
1896
+ else if (only.length && !only.includes(file.path)) state = 'filtered';
1897
+ else if (!exists) state = 'new';
1898
+ else if (localHash === file.sha256) state = 'current';
1899
+ else state = 'outdated';
1900
+
1901
+ return { ...file, localPath, state };
1902
+ });
1903
+
1904
+ const LABELS = {
1905
+ outdated: 'outdated',
1906
+ new: 'new',
1907
+ current: 'up to date',
1908
+ protected: 'protected (updateExclude)',
1909
+ filtered: 'not selected'
1910
+ };
1911
+
1912
+ const toWrite = rows.filter(r => r.state === 'outdated' || r.state === 'new');
1913
+
1914
+ // The engine files and their dependencies are a matched set — this version of
1915
+ // database.js was tested against these exact package versions — so the engine
1916
+ // pins them rather than reporting drift. Only names the engine actually ships
1917
+ // are touched: your own dependencies are left alone, and nothing is ever
1918
+ // removed (without a baseline we can't tell a dropped engine dep from one you
1919
+ // added yourself, so we never guess).
1920
+ const pkgPlan = planPackageChanges(manifest, updateExclude);
1921
+
1922
+ const header = pkgPlan.versionFrom && pkgPlan.versionFrom !== manifest.version
1923
+ ? `NukeBase engine ${pkgPlan.versionFrom} → ${manifest.version}`
1924
+ : `NukeBase engine ${manifest.version}`;
1925
+ console.log(`\n📦 ${header}\n`);
1926
+
1927
+ const width = Math.max(...rows.map(r => r.path.length));
1928
+ for (const r of rows) {
1929
+ const mark = (r.state === 'outdated' || r.state === 'new') ? '→' : ' ';
1930
+ console.log(` ${mark} ${r.path.padEnd(width)} ${LABELS[r.state]}`);
1931
+ }
1932
+
1933
+ // Always report package.json status — "nothing to change" is a real result
1934
+ // and silence here just looks like the check never ran.
1935
+ console.log('\n📦 package.json\n');
1936
+ if (pkgPlan.note) {
1937
+ console.log(` ${pkgPlan.note}`);
1938
+ } else {
1939
+ const labelWidth = Math.max(7, ...pkgPlan.changes.map(c => c.name.length));
1940
+ if (pkgPlan.versionChanged) {
1941
+ console.log(` → ${'version'.padEnd(labelWidth)} ${pkgPlan.versionFrom || 'unset'} → ${pkgPlan.versionTo}`);
1942
+ } else {
1943
+ console.log(` ${'version'.padEnd(labelWidth)} ${pkgPlan.versionTo} (in sync)`);
1944
+ }
1945
+ for (const c of pkgPlan.changes) {
1946
+ console.log(` → ${c.name.padEnd(labelWidth)} ${c.from ? c.from + ' → ' + c.to : 'missing → ' + c.to}`);
1947
+ }
1948
+ const total = Object.keys(manifest.dependencies || {}).length;
1949
+ if (!pkgPlan.changes.length) {
1950
+ console.log(` all ${total} engine dependencies already in sync`);
1951
+ } else {
1952
+ console.log(`\n ${pkgPlan.changes.length} of ${total} engine dependencies to pin (your own are untouched)`);
1953
+ }
1954
+ }
1955
+
1956
+ if (!toWrite.length && !hasPackageWork(pkgPlan)) {
1957
+ if (!updateExclude.includes('package.json') && require('./account-commands').namePackage(root, project)) {
1958
+ console.log(`\n✓ package.json name -> "${project}" (was "nukebase"; that name broke npx nukebase in this folder)`);
1959
+ }
1960
+ console.log('\n✓ Engine is already up to date');
1961
+ process.exit(0);
1962
+ }
1963
+
1964
+ const outdated = toWrite.filter(r => r.state === 'outdated').length;
1965
+ const added = toWrite.filter(r => r.state === 'new').length;
1966
+ const pkgCount = pkgPlan.changes.length + (pkgPlan.versionChanged ? 1 : 0);
1967
+ console.log(`\n📊 Total: ${outdated} to update, ${added} new, ${pkgCount} package.json change(s)`);
1968
+ console.log(' Local edits to these files will be overwritten — add a path to');
1969
+ console.log(' "updateExclude" in the "nukebase" section of package.json to protect it permanently');
1970
+ console.log(' ("package.json" there opts out of dependency pinning).');
1971
+
1972
+ const ok = await confirm(`Continue? (y/n) `);
1973
+ if (!ok) {
1974
+ console.log('Aborted.');
1975
+ process.exit(0);
1976
+ }
1977
+
1978
+ console.log('');
1979
+ for (const r of toWrite) {
1980
+ mkdirSync(dirname(r.localPath), { recursive: true });
1981
+ writeFileSync(r.localPath, r.content);
1982
+ console.log(` ✓ ${r.path}`);
1983
+ }
1984
+
1985
+ if (hasPackageWork(pkgPlan)) {
1986
+ applyPackageChanges(pkgPlan);
1987
+ console.log(` ✓ package.json (${pkgCount} change(s))`);
1988
+ }
1989
+ // Projects from older templates are named "nukebase", which breaks
1990
+ // `npx nukebase` inside them (see namePackage). Repaired here as well, so
1991
+ // one `nukebase update` fixes an existing project.
1992
+ if (!updateExclude.includes('package.json') && require('./account-commands').namePackage(root, project)) {
1993
+ console.log(` ✓ package.json name -> "${project}" (was "nukebase"; that name broke npx nukebase in this folder)`);
1994
+ }
1995
+
1996
+ console.log(`\n✅ Updated ${toWrite.length} file(s) to ${manifest.version}`);
1997
+ if (pkgPlan.changes.length) console.log(' → run: npm install');
1998
+ console.log(" → run 'nukebase push' to deploy the updated engine");
1999
+ process.exit(0);
2000
+ }
2001
+
2002
+ // ---------------------------------------------------------------------------
2003
+ // rollback — undo a push
2004
+ // ---------------------------------------------------------------------------
2005
+ // The server snapshots every file a push is about to overwrite or delete,
2006
+ // BEFORE it touches them, and seals the snapshot on restart. This restores one.
2007
+ //
2008
+ // What it undoes is the PUSH, not the clock: files the push replaced go back,
2009
+ // files it deleted come back, files it added are removed, and anything the push
2010
+ // never touched is left exactly as it is. So a rollback keeps whatever your
2011
+ // users wrote in the meantime.
2012
+ //
2013
+ // Three forms:
2014
+ //
2015
+ // nukebase rollback [id] restore code. Live data is kept. The common
2016
+ // case by a wide margin.
2017
+ // nukebase rollback backup [id] restore code AND the backup set as it was at
2018
+ // that deploy — discarding every write since.
2019
+ // nukebase rollback list the restore points.
2020
+ //
2021
+ // `backup` means the same set of paths here as it does in `nukebase backup`:
2022
+ // whatever the project declared, or the default. That is the reason it is a
2023
+ // SUBCOMMAND and not a flag. A flag is one keystroke from the safe command and
2024
+ // reads like "be more thorough", so people reach for it out of caution and
2025
+ // silently discard a day of writes. Typing the word "backup" is a decision.
2026
+ async function rollback() {
2027
+ await connectAndLogin();
2028
+
2029
+ // `list` and `backup` are reserved: a snapshot can never be addressed by
2030
+ // those names. Ids are generated timestamps, so nothing real collides — but
2031
+ // it is deliberate rather than accidental, so a stray argument can't silently
2032
+ // turn a listing into a restore.
2033
+ const RESERVED = new Set(['list', 'backup']);
2034
+ const first = args.positional[0];
2035
+ const sub = first && RESERVED.has(first) ? first : null;
2036
+
2037
+ const wantList = sub === 'list' || args.has('list') || command === 'restorepoints' || command === 'restore-points';
2038
+ const withBackup = sub === 'backup';
2039
+
2040
+ // First bare argument that is not the subcommand is an explicit snapshot id.
2041
+ const explicitId = args.positional[sub ? 1 : 0];
2042
+
2043
+ const listRes = await fetch(`${API}/deploys`, {
2044
+ method: 'POST',
2045
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
2046
+ body: '{}'
2047
+ });
2048
+ if (!listRes.ok) {
2049
+ console.error('❌ Could not list deploys:', listRes.status, await listRes.text());
2050
+ process.exit(1);
2051
+ }
2052
+ const { deploys } = await listRes.json();
2053
+ const restorable = deploys.filter(d => d.state === 'closed');
2054
+
2055
+ if (wantList) {
2056
+ if (!deploys.length) { console.log('No restore points yet — they are created by `nukebase push`.'); process.exit(0); }
2057
+ // "Restore points", not "deploys": the list answers "what can I go back
2058
+ // to?", and it contains entries created by rollbacks as well as by pushes.
2059
+ console.log(`\nRestore points for ${project}:\n`);
2060
+ for (const d of deploys) {
2061
+ const when = d.startedAt ? new Date(d.startedAt).toISOString().replace('T', ' ').slice(0, 19) : '?';
2062
+ const mb = (d.bytes / 1024 / 1024).toFixed(1);
2063
+ const origin = d.reason === 'pre-rollback' ? 'saved before a rollback' : 'deploy';
2064
+ const tags = [
2065
+ d.state === 'open' ? 'in progress' : null,
2066
+ // Whether `rollback backup <id>` will work on this one.
2067
+ d.hasData ? 'backup set included' : 'code only'
2068
+ ].filter(Boolean);
2069
+ console.log(` ${d.id}`);
2070
+ console.log(` ${when} ${origin}`);
2071
+ console.log(` +${d.added} added ~${d.preserved} preserved ${mb} MB [${tags.join(', ')}]`);
2072
+ }
2073
+ console.log('\n nukebase rollback <id> restore code, keep live data');
2074
+ console.log(' nukebase rollback backup <id> restore code and the backup set\n');
2075
+ process.exit(0);
2076
+ }
2077
+
2078
+ if (!restorable.length) {
2079
+ console.error('❌ No completed deploy snapshots to roll back to.');
2080
+ process.exit(1);
2081
+ }
2082
+
2083
+ const target = explicitId
2084
+ ? restorable.find(d => d.id === explicitId)
2085
+ : restorable[0];
2086
+
2087
+ if (!target) {
2088
+ console.error(`❌ No such restore point: ${explicitId}`);
2089
+ console.error(" Run 'nukebase rollback list' to see what is available.");
2090
+ process.exit(1);
2091
+ }
2092
+
2093
+ if (withBackup && !target.hasData) {
2094
+ console.error(`❌ Restore point ${target.id} has no copy of the backup set.`);
2095
+ console.error(' Roll back code only with: nukebase rollback ' + target.id);
2096
+ process.exit(1);
2097
+ }
2098
+
2099
+ const when = target.startedAt
2100
+ ? new Date(target.startedAt).toISOString().replace('T', ' ').slice(0, 19)
2101
+ : 'unknown time';
2102
+ const ageHours = target.startedAt ? (Date.now() - target.startedAt) / 3600000 : null;
2103
+ const age = ageHours === null ? 'unknown age'
2104
+ : ageHours < 1 ? `${Math.round(ageHours * 60)} minutes ago`
2105
+ : ageHours < 48 ? `${ageHours.toFixed(1)} hours ago`
2106
+ : `${(ageHours / 24).toFixed(1)} days ago`;
2107
+
2108
+ console.log(`\n⏪ Rolling back ${project} to ${target.id}`);
2109
+ console.log(` taken ${when} (${age})`);
2110
+ console.log(` ${target.preserved} file(s) restored, ${target.added} file(s) added by that push removed`);
2111
+
2112
+ if (withBackup) {
2113
+ // The destructive form gets a materially different prompt, not the same
2114
+ // y/n with more text above it. What is being thrown away is stated in the
2115
+ // units that matter — time — because "restores the backup set" does not
2116
+ // read as "deletes everything since Tuesday" until you spell it out.
2117
+ console.log('\n' + '─'.repeat(66));
2118
+ console.log(' ⚠️ THIS DISCARDS DATA. Read before continuing.');
2119
+ console.log('─'.repeat(66));
2120
+ console.log('\n These paths will be replaced with the copy taken at that deploy:');
2121
+ for (const p of backupPaths) console.log(` ${p}`);
2122
+ console.log(`\n Everything written to them in the last ${age.replace(' ago', '')} is lost.`);
2123
+ console.log(' That includes every signup, order and upload since then.');
2124
+ console.log('\n This is not undone by rolling forward again — the writes are gone.');
2125
+ console.log(' If you only need to fix broken code, press Ctrl+C and run:');
2126
+ console.log(` nukebase rollback ${target.id}`);
2127
+ console.log('\n Take a copy first if you have not: nukebase backup');
2128
+ console.log('');
2129
+
2130
+ const typed = await confirmTyped(project,
2131
+ ` Type the project name (${project}) to confirm: `);
2132
+ if (!typed) { console.log('Aborted.'); process.exit(0); }
2133
+ } else {
2134
+ console.log('\n Your live data is kept — only the files that push touched are');
2135
+ console.log(' restored. Anything it left alone stays exactly as it is.');
2136
+ console.log(`\n To also restore the backup set as it was at that deploy:`);
2137
+ console.log(` nukebase rollback backup ${target.id}`);
2138
+ console.log('\n The project will be stopped, restored, and restarted.');
2139
+
2140
+ const ok = await confirm('Continue? (y/n) ');
2141
+ if (!ok) { console.log('Aborted.'); process.exit(0); }
2142
+ }
2143
+
2144
+ const res = await fetch(`${API}/rollback`, {
2145
+ method: 'POST',
2146
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
2147
+ body: JSON.stringify({ deployId: target.id, withBackup })
2148
+ });
2149
+
2150
+ if (!res.ok) {
2151
+ console.error('\n❌ Rollback failed:', res.status, await res.text());
2152
+ process.exit(1);
2153
+ }
2154
+
2155
+ const result = await res.json();
2156
+ console.log(`\n✅ Rolled back to ${target.id}`);
2157
+ console.log(` ${result.restored} restored, ${result.deleted} removed` +
2158
+ (withBackup ? `, ${result.backupRestored} backup file(s)` : ''));
2159
+ if (result.failures && result.failures.length) {
2160
+ console.log(` ⚠️ ${result.failures.length} file(s) could not be restored:`);
2161
+ result.failures.slice(0, 10).forEach(f => console.log(' ! ' + f));
2162
+ }
2163
+ if (result.undoId) {
2164
+ console.log(`\n The state you just replaced was saved as ${result.undoId}`);
2165
+ console.log(` Undo this rollback with: nukebase rollback ${result.undoId}`);
2166
+ }
2167
+
2168
+ if (AUTO_YES) { await tailLogsUntilSettled(); process.exit(0); }
2169
+ watchLogs();
2170
+ }
2171
+
2172
+ // ---------------------------------------------------------------------------
2173
+ // backup — copy the server's state down to this machine
2174
+ // ---------------------------------------------------------------------------
2175
+ // The counterpart to `pull`. Pull brings down CODE; backup brings down the
2176
+ // things the running app owns — its database, its file store — which sync
2177
+ // deliberately never touches.
2178
+ //
2179
+ // Read-only: it downloads and writes locally, and cannot alter the server. That
2180
+ // is the point. Everything else in this file is a destructive operation wrapped
2181
+ // in guard rails; this one needs none, so it is safe to run on a schedule or out
2182
+ // of habit before a risky deploy.
2183
+ //
2184
+ // Incremental. It diffs against what is already in the destination and fetches
2185
+ // only what changed, so running it often is cheap even when the file store is
2186
+ // large.
2187
+ //
2188
+ // Lands INSIDE the project by default, at the same relative paths the server
2189
+ // uses — so server/data ends up at ./server/data. That replaces the old dance of
2190
+ // deleting server/data from `exclude`, running pull, and putting it back.
2191
+ //
2192
+ // Safe to land there precisely because those paths are in `exclude`: push and
2193
+ // pull ignore them, so a local copy of production sits inert until you ask for
2194
+ // it with `nukebase push --include backup`. It also means you can run the app
2195
+ // locally against real data without moving anything.
2196
+ //
2197
+ // --out <dir> writes elsewhere instead, for an archival copy you do not want in
2198
+ // the working tree.
2199
+ async function backup() {
2200
+ await connectAndLogin();
2201
+
2202
+ const dest = args.get('out') ? resolve(args.get('out')) : root;
2203
+
2204
+ console.log(`\n💾 Backing up ${project}`);
2205
+ console.log(' paths: ' + backupPaths.join(', '));
2206
+ console.log(` into: ${dest}`);
2207
+
2208
+ const doList = () => fetch(`${API}/backup-manifest`, {
2209
+ method: 'POST',
2210
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
2211
+ body: JSON.stringify({ paths: backupPaths })
2212
+ });
2213
+
2214
+ let listRes = await doList();
2215
+ if (listRes.status === 401) authExpired();
2216
+ let changed, fetchBytes;
2217
+ let backupSizes = {}; // rel -> [size, mtime], when the server lists sizes
2218
+ if (listRes.status === 404) {
2219
+ // A master that predates /backup-manifest. It still has /sync and
2220
+ // /download (pull is older than backup), so ask /sync for a pull listing
2221
+ // with the backup paths taken OUT of the exclude list and an empty local
2222
+ // manifest — every server file then counts as "to download" — and keep
2223
+ // only the ones under the backup paths. No size/mtime comparison this
2224
+ // way, so everything is fetched; that is fine for a one-off copy.
2225
+ console.log(' (this region has no backup endpoint — using the sync listing instead)');
2226
+ const legacyExclude = syncExclude.filter(e => !backupPaths.includes(e));
2227
+ const doSync = () => fetch(`${API}/sync`, {
2228
+ method: 'POST',
2229
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
2230
+ body: JSON.stringify({ exclude: legacyExclude, files: {}, direction: 'pull', symlinks: false })
2231
+ });
2232
+ let syncRes = await doSync();
2233
+ if (!syncRes.ok) {
2234
+ console.error('❌ Could not list files:', syncRes.status, await syncRes.text());
2235
+ process.exit(1);
2236
+ }
2237
+ const { download } = await syncRes.json();
2238
+ changed = (download || []).filter(rel => backupPaths.some(p => rel === p || rel.startsWith(p + '/')));
2239
+ fetchBytes = 0;
2240
+ if (!changed.length) {
2241
+ console.log('\n Nothing to back up — the server has no files under those paths.');
2242
+ process.exit(0);
2243
+ }
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
+ } else {
2248
+ if (!listRes.ok) {
2249
+ console.error('❌ Could not list backup files:', listRes.status, await listRes.text());
2250
+ process.exit(1);
2251
+ }
2252
+
2253
+ const { files: serverFiles } = await listRes.json();
2254
+ backupSizes = serverFiles || {};
2255
+ 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
+
2261
+ // Same size+mtime comparison sync uses. mtime survives the tar round-trip
2262
+ // closely enough that a 1s tolerance avoids re-fetching unchanged files.
2263
+ changed = names.filter(rel => {
2264
+ const local = join(dest, rel);
2265
+ if (!existsSync(local)) return true;
2266
+ const s = lstatSync(local);
2267
+ const [size, mtime] = serverFiles[rel];
2268
+ return s.size !== size || Math.abs(Math.floor(s.mtimeMs / 1000) - mtime) > 1;
2269
+ });
2270
+
2271
+ const totalBytes = names.reduce((n, rel) => n + (serverFiles[rel][0] || 0), 0);
2272
+ fetchBytes = changed.reduce((n, rel) => n + (serverFiles[rel][0] || 0), 0);
2273
+
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);
2278
+ }
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`);
2282
+ }
2283
+
2284
+ const ok = await confirm('\nContinue? (y/n) ');
2285
+ if (!ok) { console.log('Aborted.'); process.exit(0); }
2286
+
2287
+ mkdirSync(dest, { recursive: true });
2288
+
2289
+ const started = Date.now();
2290
+ const result = await downloadStreams(dest, changed,
2291
+ (rel) => (backupSizes[rel] && backupSizes[rel][0]) || 0, fetchBytes || 0);
2292
+
2293
+ const secs = ((Date.now() - started) / 1000).toFixed(1);
2294
+ console.log(`\n\n✅ Backed up ${changed.length} file(s) in ${secs}s`);
2295
+ console.log(` ${dest}`);
2296
+ if (dest === root) {
2297
+ console.log('\n These paths are in `exclude`, so push and pull will ignore them.');
2298
+ console.log(' To send this copy back up: nukebase push --include backup\n');
2299
+ } else {
2300
+ console.log('');
2301
+ }
2302
+ process.exit(0);
2303
+ }
2304
+
2305
+ // ---------------------------------------------------------------------------
2306
+ // env — the project's hosted environment (process.env)
2307
+ // ---------------------------------------------------------------------------
2308
+ // A hosted project's process is started by the platform, so its environment
2309
+ // has to come from the platform too. The developer keeps a .env in the project
2310
+ // root — the same file `node --env-file=.env server/database.js` loads locally
2311
+ // — and `nukebase env push` makes the hosted environment equal to it. The
2312
+ // server stores it outside the project, root-owned, and hands it to the process
2313
+ // through systemd, so no sync, ZIP download or restore point ever contains it.
2314
+ //
2315
+ // Whole-set semantics, like push: the local file is the truth. Removing a line
2316
+ // locally and pushing removes it on the server. Names are shown in the diff;
2317
+ // values never are.
2318
+
2319
+ // Parse a dotenv file into { NAME: value }. The common dialect: blank lines and
2320
+ // # comments skipped, an optional `export ` prefix, NAME=VALUE split at the
2321
+ // first "=", surrounding single or double quotes stripped, and inside double
2322
+ // quotes \n, \" and \\ unescaped. An unquoted value ends at a " #" comment.
2323
+ // Nothing is expanded — a $ is a literal dollar sign, as it is on the server.
2324
+ function parseDotenv(text) {
2325
+ const env = {};
2326
+ for (const rawLine of String(text).split(/\r?\n/)) {
2327
+ const line = rawLine.trim();
2328
+ if (!line || line.startsWith('#')) continue;
2329
+ const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
2330
+ if (!m) continue;
2331
+ let value = m[2];
2332
+ const q = value[0];
2333
+ if (value.length >= 2 && (q === '"' || q === "'") && value[value.length - 1] === q) {
2334
+ value = value.slice(1, -1);
2335
+ if (q === '"') value = value.replace(/\\(n|"|\\)/g, (_, c) => (c === 'n' ? '\n' : c));
2336
+ } else {
2337
+ const hash = value.search(/\s#/);
2338
+ if (hash !== -1) value = value.slice(0, hash);
2339
+ value = value.trim();
2340
+ }
2341
+ env[m[1]] = value;
2342
+ }
2343
+ return env;
2344
+ }
2345
+
2346
+ function readLocalEnv() {
2347
+ const file = join(root, ENV_FILE);
2348
+ if (!existsSync(file)) {
2349
+ console.error(`❌ No ${ENV_FILE} in ${root}`);
2350
+ console.error(` Create one with NAME=value lines, then run 'nukebase env push'.`);
2351
+ process.exit(1);
2352
+ }
2353
+ return parseDotenv(readFileSync(file, 'utf8'));
2354
+ }
2355
+
2356
+ // The hosted environment, re-authenticating once on a stale session like the
2357
+ // other commands do.
2358
+ // `optional`: resolve null instead of exiting when the region has no /env at
2359
+ // all (a master that predates hosted environments answers 404 from uWS, not
2360
+ // a JSON error) — the transfer treats that as "nothing to copy".
2361
+ async function fetchHostedEnv({ optional = false } = {}) {
2362
+ const doFetch = () => fetch(`${API}/env`, {
2363
+ method: 'POST',
2364
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
2365
+ body: '{}'
2366
+ });
2367
+ let res = await doFetch();
2368
+ if (res.status === 401) authExpired();
2369
+ if (optional && res.status === 404) return null;
2370
+ if (!res.ok) {
2371
+ console.error('❌ Could not read the hosted environment:', res.status, await res.text());
2372
+ process.exit(1);
2373
+ }
2374
+ const data = await res.json();
2375
+ return (data && data.env) || {};
2376
+ }
2377
+
2378
+ async function envPush() {
2379
+ // Read the file before connecting, so a missing one fails before any login.
2380
+ const local = readLocalEnv();
2381
+ await connectAndLogin();
2382
+
2383
+ console.log(`🔍 Comparing ${ENV_FILE} with the hosted environment...`);
2384
+ const hosted = await fetchHostedEnv();
2385
+
2386
+ const added = [], changed = [], removed = [];
2387
+ let same = 0;
2388
+ for (const k of Object.keys(local).sort()) {
2389
+ if (!Object.hasOwn(hosted, k)) added.push(k);
2390
+ else if (hosted[k] !== local[k]) changed.push(k);
2391
+ else same++;
2392
+ }
2393
+ for (const k of Object.keys(hosted).sort()) if (!Object.hasOwn(local, k)) removed.push(k);
2394
+
2395
+ if (!added.length && !changed.length && !removed.length) {
2396
+ console.log(`✓ Hosted environment already matches ${ENV_FILE} (${same} variable${same === 1 ? '' : 's'})`);
2397
+ process.exit(0);
2398
+ }
2399
+
2400
+ // Names only. Values are secrets, and this goes to a terminal and possibly a
2401
+ // CI log.
2402
+ if (added.length) { console.log(`\n➕ Adding ${added.length}:`); added.forEach(k => console.log(' + ' + k)); }
2403
+ if (changed.length) { console.log(`\n✏️ Changing ${changed.length}:`); changed.forEach(k => console.log(' ~ ' + k)); }
2404
+ if (removed.length) {
2405
+ console.log(`\n🗑️ Removing ${removed.length} (not in your ${ENV_FILE}):`);
2406
+ removed.forEach(k => console.log(' - ' + k));
2407
+ }
2408
+ if (same) console.log(`\n ${same} unchanged`);
2409
+
2410
+ console.log(`\n📊 Total: ${added.length} to add, ${changed.length} to change, ${removed.length} to remove`);
2411
+ console.log(' The project restarts to pick up the new environment.');
2412
+ const ok = await confirm('Continue? (y/n) ');
2413
+ if (!ok) { console.log('Aborted.'); process.exit(0); }
2414
+
2415
+ const res = await fetch(`${API}/update-env`, {
2416
+ method: 'POST',
2417
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
2418
+ body: JSON.stringify({ env: local })
2419
+ });
2420
+ if (!res.ok) {
2421
+ console.error('\n❌ Update failed:', res.status, await res.text());
2422
+ process.exit(1);
2423
+ }
2424
+ const result = await res.json();
2425
+ const n = (result.keys || []).length;
2426
+ console.log(`\n✅ Hosted environment updated: ${n} variable${n === 1 ? '' : 's'}`);
2427
+ if (result.restarted) {
2428
+ await tailLogsUntilSettled();
2429
+ } else {
2430
+ console.log(' The project is not running; the new environment applies when it next starts.');
2431
+ }
2432
+ process.exit(0);
2433
+ }
2434
+
2435
+ async function envList() {
2436
+ const show = args.has('show');
2437
+ await connectAndLogin();
2438
+ const hosted = await fetchHostedEnv();
2439
+ const names = Object.keys(hosted).sort();
2440
+
2441
+ if (!names.length) {
2442
+ console.log(`\nNo variables set for ${project}.`);
2443
+ console.log(`Create a ${ENV_FILE} in the project root and run 'nukebase env push'.\n`);
2444
+ process.exit(0);
2445
+ }
2446
+
2447
+ console.log(`\nHosted environment for ${project}:\n`);
2448
+ const width = Math.max(...names.map(n => n.length));
2449
+ for (const name of names) {
2450
+ console.log(` ${name.padEnd(width)} ${show ? hosted[name] : '••••••••'}`);
2451
+ }
2452
+ console.log(`\n${names.length} variable${names.length === 1 ? '' : 's'}.` + (show ? '' : ' Add --show to print values.') + '\n');
2453
+ process.exit(0);
2454
+ }
2455
+
2456
+ // bin/nukebase.js has already checked the command name.
2457
+ const run = (fn) => fn().catch(err => { console.error('❌', err.message); process.exit(1); });
2458
+ if (command === 'push' && args.positional[0] === 'env') {
2459
+ // `nukebase push env` reads as naturally as `nukebase env push`; both work.
2460
+ run(envPush);
2461
+ } else if (command === 'push') {
2462
+ run(push);
2463
+ } else if (command === 'env') {
2464
+ const sub = args.positional[0];
2465
+ if (sub === 'push') run(envPush);
2466
+ else if (sub === 'list') run(envList);
2467
+ else {
2468
+ console.log('Usage: nukebase env <push|list>');
2469
+ console.log(' env push make the hosted environment equal to ./.env, then restart');
2470
+ console.log(' env list [--show] variable names currently set on the server');
2471
+ process.exit(1);
2472
+ }
2473
+ } else if (command === 'pull') {
2474
+ // Exit once done: the log socket would otherwise keep Node alive.
2475
+ pull().then(() => process.exit(0), err => { console.error('❌', err.message); process.exit(1); });
2476
+ } else if (command === 'update') {
2477
+ run(update);
2478
+ } else if (command === 'rollback' || command === 'restorepoints' || command === 'restore-points') {
2479
+ // restorepoints: the same listing as `rollback list`, under the dashboard's name.
2480
+ run(rollback);
2481
+ } else if (command === 'backup') {
2482
+ run(backup);
2483
+ } else if (command === 'pause') {
2484
+ run(pauseProject);
2485
+ } else if (command === 'restart' || command === 'resume') {
2486
+ // One endpoint: /restart launches a stopped unit and bounces a running one,
2487
+ // so resume is the same call under the name the user reaches for.
2488
+ run(restartProject);
2489
+ } else if (command === 'delete') {
2490
+ run(deleteProject);
2491
+ } else if (command === 'domain') {
2492
+ run(domainCommand);
2493
+ } else if (command === 'logs') {
2494
+ run(logsCommand);
2495
+ } else if (command === 'download') {
2496
+ run(downloadCommand);
2497
+ } else if (command === 'regiontransfer' || command === 'region-transfer') {
2498
+ run(regionTransfer);
2499
+ }