nukebase 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,627 +1,633 @@
1
- // Commands that work with or without a project folder: signing accounts in
2
- // and out, choosing the default, and making or linking projects.
3
-
4
- const fs = require('fs');
5
- const path = require('path');
6
- const { spawnSync } = require('child_process');
7
- const creds = require('./credentials');
8
- const login = require('./login');
9
- const unzip = require('./unzip');
10
- const { findProject, hasConfig, readConfig, writeConfig, newConfig } = require('./project');
11
- const { hostOf, apiFor, makeConfirm, makeAskLine, askHidden } = require('./ui');
12
-
13
- const PROJECT_NAME_RE = /^[a-z][a-z0-9_-]{2,30}$/;
14
-
15
- const cookieFor = (acct) => `uid=${acct.uid}; token=${acct.token}`;
16
-
17
- // Stop the command with a message. Thrown rather than process.exit(): exiting
18
- // while Node is still closing finished HTTP connections crashes it on Windows
19
- // (libuv "UV_HANDLE_CLOSING" assertion). bin/nukebase.js prints it once and
20
- // sets the exit code, and Node exits by itself when the work is done.
21
- function fail(message) {
22
- throw new Error(message);
23
- }
24
-
25
- function describe(acct) {
26
- return `${acct.label || acct.uid} (${acct.uid})`;
27
- }
28
-
29
- // The project in the current folder, if any, without its secrets mattering.
30
- function currentProject() {
31
- const root = findProject(process.cwd());
32
- if (!root) return null;
33
- try { return { root, config: readConfig(root) }; } catch { return null; }
34
- }
35
-
36
- // ---- login ----------------------------------------------------------------
37
-
38
- async function loginCommand(args) {
39
- const askLine = makeAskLine(false);
40
- const here = currentProject();
41
- const api = apiFor(args, here?.config.API || creds.getDefault()?.api);
42
-
43
- // `nukebase login` inside a project whose account isn't signed in asks for
44
- // that account, so you don't have to remember which email it was.
45
- let email = args.get('email') || args.positional.find(a => a.includes('@')) || null;
46
- if (!email && here && here.config.uid && !creds.get(here.config.uid) && String(here.config.account || '').includes('@')) {
47
- email = here.config.account;
48
- }
49
-
50
- let out, method;
51
- if (args.has('password')) {
52
- method = 'password';
53
- const username = args.positional.find(a => !a.includes('@')) || (await askLine('Username: ')).trim();
54
- const pass = await askHidden('Password: ');
55
- if (!username || !pass) fail('Username and password are required.');
56
- out = await login.password(api, username, pass);
57
- } else if (args.has('device')) {
58
- method = 'device';
59
- out = await login.device(api);
60
- } else if (args.has('email-link')) {
61
- method = 'email-link';
62
- if (!email) email = (await askLine('Email: ')).trim().toLowerCase();
63
- if (!email.includes('@')) fail('An email address is required.');
64
- out = await login.emailLink(api, email);
65
- } else if (args.has('browser')) {
66
- method = 'browser';
67
- out = await login.browser(api, { email });
68
- } else {
69
- // Browser and device code together, whichever is approved first, so a
70
- // VM or SSH session can sign in too.
71
- method = 'browser';
72
- out = await login.browserOrDevice(api, { email });
73
- }
74
-
75
- // Signing in again replaces this machine's token for that account; sign the
76
- // old one out on the server rather than leave it valid and forgotten.
77
- const previous = creds.get(out.uid);
78
- creds.put(out.uid, { label: out.label, api, token: out.token, method, created: Date.now(), lastUsed: Date.now() });
79
- if (previous && previous.token && previous.token !== out.token) await revoke(previous);
80
-
81
- console.log(`\n✓ Signed in as ${describe(out)} on ${hostOf(api)}`);
82
- if (email && String(out.label).toLowerCase() !== email.toLowerCase()) {
83
- console.log(` Note: you asked for ${email} but approved as ${out.label}.`);
84
- }
85
- const n = creds.list().length;
86
- if (n > 1) console.log(` ${n} accounts signed in on this machine. List them with: nukebase accounts`);
87
- }
88
-
89
- // ---- logout ---------------------------------------------------------------
90
-
91
- async function revoke(acct) {
92
- try {
93
- const r = await fetch(`${acct.api}/cli/revoke`, {
94
- method: 'POST',
95
- headers: { 'Content-Type': 'application/json', Cookie: cookieFor(acct) },
96
- body: '{}'
97
- });
98
- return r.ok || r.status === 401; // 401: already signed out
99
- } catch {
100
- return false;
101
- }
102
- }
103
-
104
- // `nukebase logout <email|uid>` signs out that account; plain `nukebase
105
- // logout` signs out every account on this machine.
106
- async function logoutCommand(args) {
107
- const target = args.positional[0];
108
- let accounts;
109
- if (target) {
110
- const acct = creds.find(target);
111
- if (!acct) fail(`${target} isn't signed in on this machine. See: nukebase accounts`);
112
- accounts = [acct];
113
- } else {
114
- accounts = creds.list();
115
- if (!accounts.length) { console.log('No accounts are signed in.'); return; }
116
- }
117
- for (const acct of accounts) {
118
- const ok = await revoke(acct);
119
- creds.remove(acct.uid);
120
- console.log(`✓ Signed out ${describe(acct)}${ok ? '' : ` (couldn't reach ${hostOf(acct.api)}; sign it out from the dashboard's Signed-in devices too)`}`);
121
- }
122
- }
123
-
124
- // ---- accounts / use / whoami ----------------------------------------------
125
-
126
- function relative(ms) {
127
- if (!ms) return 'never';
128
- const days = Math.floor((Date.now() - ms) / 86400000);
129
- return days <= 0 ? 'today' : days === 1 ? 'yesterday' : `${days} days ago`;
130
- }
131
-
132
- function accountsCommand() {
133
- const list = creds.list();
134
- if (!list.length) { console.log('No accounts are signed in. Run: nukebase login'); return; }
135
- const w = Math.max(...list.map(a => String(a.label || '').length), 7);
136
- console.log('');
137
- for (const a of list) {
138
- console.log(` ${a.isDefault ? '*' : ' '} ${String(a.label || '').padEnd(w)} ${a.uid} ${hostOf(a.api)} last used ${relative(a.lastUsed)}`);
139
- }
140
- console.log(`\n * default, for commands run outside a project (nukebase use <email> to change)`);
141
- console.log(` Inside a project, the account that owns it is used automatically.\n`);
142
- }
143
-
144
- function useCommand(args) {
145
- const q = args.positional[0];
146
- if (!q) fail('Usage: nukebase use <email|uid>');
147
- const acct = creds.find(q);
148
- if (!acct) fail(`${q} isn't signed in on this machine. Run: nukebase login --email ${q}`);
149
- creds.setDefault(acct.uid);
150
- console.log(`✓ Default account: ${describe(acct)}`);
151
- }
152
-
153
- async function whoamiCommand() {
154
- const here = currentProject();
155
- if (here && here.config.uid) {
156
- const acct = creds.get(here.config.uid);
157
- const name = here.config.project || path.basename(here.root);
158
- if (!acct) {
159
- console.log(`${name} belongs to ${here.config.account || 'uid'} (${here.config.uid}), which isn't signed in on this machine.`);
160
- console.log(`Run: nukebase login${String(here.config.account || '').includes('@') ? ` --email ${here.config.account}` : ''}`);
161
- return;
162
- }
163
- console.log(`${name} belongs to ${describe(acct)} on ${hostOf(here.config.API)}`);
164
- console.log(await check(acct, here.config.API));
165
- return;
166
- }
167
- const acct = creds.getDefault();
168
- if (!acct) { console.log(creds.list().length ? 'No default account. Set one with: nukebase use <email>' : 'Not signed in. Run: nukebase login'); return; }
169
- console.log(`Default account: ${describe(acct)} on ${hostOf(acct.api)}`);
170
- console.log(await check(acct, acct.api));
171
- }
172
-
173
- async function check(acct, api) {
174
- try {
175
- const r = await fetch(`${api}/cli/whoami`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookieFor(acct) }, body: '{}' });
176
- if (r.ok) return '✓ Signed in';
177
- if (r.status === 401) return `✗ This sign-in was revoked. Run: nukebase login${String(acct.label).includes('@') ? ` --email ${acct.label}` : ''}`;
178
- return `? ${hostOf(api)} answered ${r.status}`;
179
- } catch {
180
- return `? Couldn't reach ${hostOf(api)}`;
181
- }
182
- }
183
-
184
- // ---- choosing an account for create / link ----------------------------------
185
-
186
- async function chooseAccount(args, hint, autoYes) {
187
- const q = args.get('as') || hint;
188
- if (q) {
189
- const acct = creds.find(q);
190
- if (!acct) fail(`${q} isn't signed in on this machine. Run: nukebase login --email ${q}`);
191
- return acct;
192
- }
193
- const list = creds.list();
194
- if (!list.length) fail('Not signed in. Run: nukebase login');
195
- if (list.length === 1) return list[0];
196
- // Creating a project is billed to the account, so never guess between several.
197
- if (autoYes || !process.stdin.isTTY) {
198
- fail(`Several accounts are signed in. Say which one, e.g.: nukebase ${args.command} ${args.positional[0] || '<name>'} ${list[0].label}`);
199
- }
200
- console.log('\n Which account?');
201
- list.forEach((a, i) => console.log(` ${i + 1}) ${a.label} (${a.uid})${a.isDefault ? ' default' : ''}`));
202
- const answer = (await makeAskLine(false)('\n Account (number or email): ')).trim();
203
- const picked = /^\d+$/.test(answer) ? list[Number(answer) - 1] : creds.find(answer);
204
- if (!picked) fail('No account chosen.');
205
- return picked;
206
- }
207
-
208
- // ---- plan -----------------------------------------------------------------
209
-
210
- // The account's plan, starting the free plan first when it has none. An
211
- // account whose paid plan was cancelled is left alone: that's a choice for
212
- // the dashboard. Returns the plan name, or null if it couldn't be told.
213
- async function ensurePlan(api, acct) {
214
- const call = async (path) => {
215
- const r = await fetch(`${api}${path}`, {
216
- method: 'POST',
217
- headers: { 'Content-Type': 'application/json', Cookie: cookieFor(acct) },
218
- body: '{}'
219
- });
220
- let data = {};
221
- try { data = await r.json(); } catch {}
222
- return { r, data };
223
- };
224
- let who;
225
- try { who = await call('/cli/whoami'); } catch { return null; }
226
- if (who.r.status === 401) fail(`The sign-in for ${acct.label} is no longer valid. Run: nukebase login --email ${acct.label}`);
227
- if (!who.r.ok) return null;
228
- if (who.data.plan || who.data.canceled) return who.data.plan || null;
229
-
230
- const started = await call('/start-free').catch(() => null);
231
- if (!started || !started.r.ok) return null; // the create below says why
232
- console.log(`\n✓ You're on the free plan: $${started.data.credit || 10} of credit each month, any number of apps at any size, 1 GB storage.`);
233
- console.log(` Upgrade any time at ${api}/`);
234
- return 'free';
235
- }
236
-
237
- // ---- create ---------------------------------------------------------------
238
-
239
- // "256mb", "256", "1gb", "1g", "512 MB" -> megabytes; null when not a size.
240
- function sizeToMb(text) {
241
- const m = /^(\d+(?:\.\d+)?)\s*(m|mb|g|gb)?$/i.exec(String(text).trim());
242
- if (!m) return null;
243
- const n = Number(m[1]);
244
- return /^g/i.test(m[2] || '') ? n * 1024 : n;
245
- }
246
-
247
- // A size the user typed -> tier key. Matches the RAM each tier buys
248
- // (memoryHigh) or the tier's name.
249
- function resolveTier(input, catalog) {
250
- const tiers = catalog.tiers || {};
251
- const key = String(input).toLowerCase();
252
- if (tiers[key]) return key;
253
- const want = sizeToMb(input);
254
- if (want === null) return null;
255
- return (catalog.order || Object.keys(tiers)).find(k => sizeToMb(tiers[k].memoryHigh) === want) || null;
256
- }
257
-
258
- function tierLabel(catalog, key) {
259
- const t = catalog.tiers[key];
260
- const mb = sizeToMb(t.memoryHigh);
261
- const ram = mb >= 1024 ? `${mb / 1024} GB` : `${mb} MB`;
262
- return `${t.name}, ${ram} RAM${t.diskGb ? `, ${t.diskGb} GB disk` : ''}, $${(t.monthlyCents / 100).toFixed(0)}/mo`;
263
- }
264
-
265
- async function loadCatalog(api) {
266
- try {
267
- const r = await fetch(`${api}/tiers`);
268
- if (!r.ok) throw new Error(`HTTP ${r.status}`);
269
- const catalog = await r.json();
270
- catalog.order = catalog.order || Object.keys(catalog.tiers);
271
- catalog.default = catalog.default || catalog.order[0];
272
- return catalog;
273
- } catch (e) {
274
- fail(`Could not reach ${hostOf(api)} (${e.cause?.message || e.message}).`);
275
- }
276
- }
277
-
278
- function sizeList(catalog) {
279
- return catalog.order.map(k => `${catalog.tiers[k].memoryHigh.replace(/M$/, 'mb').replace(/G$/, 'gb').toLowerCase()} (${k})`).join(', ');
280
- }
281
-
282
- // A project's package.json must not be named "nukebase": npm resolves
283
- // `npx nukebase` to the current project when the names match, and a project
284
- // has no executable, so every command inside it fails with "could not
285
- // determine executable to run". Older server templates shipped that name;
286
- // the project's own name (a valid npm name) replaces it. Returns true when
287
- // it changed something.
288
- function namePackage(dir, name) {
289
- const pkgPath = path.join(dir, 'package.json');
290
- try {
291
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
292
- if (pkg.name !== 'nukebase' && pkg.name !== 'nukebase-app') return false;
293
- pkg.name = name;
294
- fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
295
- return true;
296
- } catch { return false; }
297
- }
298
-
299
- // Read a download to the end, showing how much has arrived: one line that
300
- // updates in a terminal, or a line every 10 MB when the output goes to a file
301
- // or an AI agent (where \r updates would pile up). The archives are streamed
302
- // without a length, so it shows MB so far, not a percentage.
303
- async function readWithProgress(res, label) {
304
- const tty = process.stdout.isTTY;
305
- const mb = (n) => (n / 1048576).toFixed(1);
306
- const chunks = [];
307
- let got = 0, lastShown = 0, lastLine = 0;
308
- const show = (final) => {
309
- if (tty) process.stdout.write(`\r ${label}… ${mb(got)} MB${final ? '\n' : ''}`);
310
- else if (final || got - lastLine >= 10 * 1048576) { console.log(` ${label}… ${mb(got)} MB`); lastLine = got; }
311
- };
312
- if (!res.body || typeof res.body.getReader !== 'function') {
313
- const buf = Buffer.from(await res.arrayBuffer());
314
- got = buf.length; show(true);
315
- return buf;
316
- }
317
- const reader = res.body.getReader();
318
- try {
319
- for (;;) {
320
- const { done, value } = await reader.read();
321
- if (done) break;
322
- chunks.push(Buffer.from(value));
323
- got += value.length;
324
- if (Date.now() - lastShown > 250) { lastShown = Date.now(); show(false); }
325
- }
326
- } catch (e) {
327
- // End the half-drawn progress line, so whatever is printed next starts clean.
328
- if (tty && got) process.stdout.write('\n');
329
- throw e;
330
- }
331
- show(true);
332
- return Buffer.concat(chunks);
333
- }
334
-
335
- // The project's files as the server has them, without node_modules (an
336
- // `npm install` follows; servers too old for ?skip send them anyway). One
337
- // retry, since this runs right after a dropped connection.
338
- async function downloadExisting({ acct, api, name }) {
339
- const headers = { 'X-Project': name, Cookie: cookieFor(acct) };
340
- const url = `${api}/download-project?project=${encodeURIComponent(name)}&skip=node_modules`;
341
- let lastErr = null;
342
- for (let attempt = 1; attempt <= 2; attempt++) {
343
- try {
344
- const dl = await fetch(url, { headers });
345
- if (!dl.ok) { lastErr = new Error(`the server answered ${dl.status}`); continue; }
346
- return await readWithProgress(dl, `Downloading ${name}`);
347
- } catch (e) {
348
- lastErr = e;
349
- if (attempt === 1) console.log(' (download interrupted, trying again)');
350
- }
351
- }
352
- fail(`${name} exists but could not be downloaded (${lastErr.cause?.message || lastErr.message}).\n` +
353
- ` In an empty folder, run: nukebase link ${name} then: nukebase pull --yes then: npm install`);
354
- }
355
-
356
- // Create `name` on the server and unpack it into `dest`. When the account
357
- // already has a project by that name: with `reuse`, that project is
358
- // downloaded into `dest` instead (`start`: running it again finishes the
359
- // job rather than making name-2); without, returns { exists: true }. Any
360
- // other failure stops the command.
361
- async function createProject({ acct, api, name, tier, dest, install, reuse = false }) {
362
- console.log(`\n🚀 Creating ${name}...`);
363
- const headers = (extra = {}) => ({ ...extra, 'X-Project': name, Cookie: cookieFor(acct) });
364
-
365
- // The connection dropped while the server was setting up. That is not the
366
- // same as "not created": setup ends with an nginx reload, and a reload
367
- // sends GOAWAY on an open HTTP/2 connection, which Node's fetch reports as
368
- // a failed request after the project already exists. (Older servers reload
369
- // before streaming the ZIP; current ones reload after.) So check, and if it
370
- // is there, fetch its files the other way rather than leaving a project
371
- // behind and trying the next name.
372
- async function recover(err) {
373
- const why = err.cause?.message || err.message;
374
- let probe = null;
375
- try {
376
- probe = await fetch(`${api}/sync`, {
377
- method: 'POST',
378
- headers: headers({ 'Content-Type': 'application/json' }),
379
- body: JSON.stringify({ exclude: [], files: {}, direction: 'pull', symlinks: false })
380
- });
381
- } catch {}
382
- if (!probe || probe.status === 404) {
383
- fail(`Create failed: ${why}. Check the dashboard before retrying; it may have been created.`);
384
- }
385
- if (!probe.ok) fail(`Create failed: ${why} (and the server answered ${probe.status} when asked whether ${name} exists).`);
386
- console.log(` (the connection dropped while the server finished setting up ${name} — it exists, downloading it)`);
387
- return downloadExisting({ acct, api, name });
388
- }
389
-
390
- let res = null, zip = null;
391
- try {
392
- res = await fetch(`${api}/project`, {
393
- method: 'POST',
394
- headers: headers({ 'Content-Type': 'application/json' }),
395
- body: JSON.stringify({ tier })
396
- });
397
- } catch (e) {
398
- zip = await recover(e);
399
- }
400
- if (res && !res.ok) {
401
- let msg = '';
402
- try { msg = (await res.json()).error; } catch {}
403
- const taken = res.status === 400 && /already exists/i.test(msg || '');
404
- if (taken && !reuse) return { exists: true };
405
- if (taken) {
406
- console.log(` ${name} already exists in your account, so downloading it here instead of making a new one.`);
407
- zip = await downloadExisting({ acct, api, name });
408
- } else {
409
- if (res.status === 401) fail(`The sign-in for ${acct.label} is no longer valid. Run: nukebase login --email ${acct.label}`);
410
- // No plan or no credit: fixed on the dashboard.
411
- if (res.status === 403 && /plan|funds|credit|wallet|balance|upgrade/i.test(msg)) {
412
- fail(`${msg}\n Manage your plan at ${api}/`);
413
- }
414
- fail(`Create failed (${res.status}): ${msg || res.statusText}`);
415
- }
416
- }
417
-
418
- // The server finishes setup as the ZIP finishes streaming, so read it all.
419
- // A drop here is the same case as above.
420
- if (!zip) {
421
- try { zip = await readWithProgress(res, `Downloading ${name}`); }
422
- catch (e) { zip = await recover(e); }
423
- }
424
- fs.mkdirSync(dest, { recursive: true });
425
- const count = unzip.extract(zip, dest);
426
-
427
- let base = {};
428
- try { base = readConfig(dest); } catch {}
429
- writeConfig(dest, newConfig({ api, uid: acct.uid, project: name, account: acct.label }, base));
430
- namePackage(dest, name);
431
- console.log(`✓ Downloaded ${count} files into ${path.relative(process.cwd(), dest) || 'this folder'}`);
432
-
433
- if (install && fs.existsSync(path.join(dest, 'package.json'))) {
434
- console.log('\n📦 Installing packages (npm install)…\n');
435
- // One command string under a shell: npm is npm.cmd on Windows, which
436
- // needs a shell, and Node 26 deprecates passing separate args with one.
437
- const r = spawnSync('npm install', [], { cwd: dest, stdio: 'inherit', shell: true });
438
- if (r.status !== 0) console.log(`\n⚠️ npm install failed. Run it yourself in ${dest}.`);
439
- }
440
- return { ok: true, url: `https://${acct.uid}-${name}.${hostOf(api)}` };
441
- }
442
-
443
- async function createCommand(args) {
444
- const autoYes = args.has('yes');
445
- const confirm = makeConfirm(autoYes);
446
- const askLine = makeAskLine(autoYes);
447
-
448
- const name = args.positional[0];
449
- if (!name) fail('Usage: nukebase create <name> [size] [email] e.g. nukebase create demoapp123 256mb you@example.com');
450
- if (!PROJECT_NAME_RE.test(name)) fail('Project names are 3-31 characters: lowercase letters, numbers, - and _, starting with a letter.');
451
-
452
- // After the name, in any order: something with an @ is the account; the
453
- // rest is the size.
454
- const rest = args.positional.slice(1);
455
- const emailArg = rest.find(a => a.includes('@')) || null;
456
- const sizeArg = args.get('tier') || rest.find(a => !a.includes('@')) || null;
457
-
458
- const dest = path.resolve(process.cwd(), name);
459
- if (fs.existsSync(dest) && fs.readdirSync(dest).length) fail(`The folder ${name} already exists and isn't empty.`);
460
-
461
- const acct = await chooseAccount(args, emailArg, autoYes);
462
- const api = apiFor(args, acct.api);
463
-
464
- // Every account starts free: one with no plan is put on the free plan here.
465
- const isFree = (await ensurePlan(api, acct)) === 'free';
466
- const catalog = await loadCatalog(api);
467
-
468
- let tier = sizeArg ? resolveTier(sizeArg, catalog) : null;
469
- if (sizeArg && !tier) fail(`Unknown size "${sizeArg}". One of: ${sizeList(catalog)}`);
470
- if (!tier) {
471
- if (autoYes || !process.stdin.isTTY || isFree) {
472
- tier = catalog.default;
473
- } else {
474
- console.log('\n Sizes:');
475
- catalog.order.forEach((k, i) => console.log(` ${i + 1}) ${tierLabel(catalog, k)}${k === catalog.default ? ' (default)' : ''}`));
476
- const answer = (await askLine(`\n Size [${catalog.default}]: `)).trim();
477
- tier = !answer ? catalog.default : /^\d+$/.test(answer) ? catalog.order[Number(answer) - 1] : resolveTier(answer, catalog);
478
- if (!tier) fail(`Unknown size "${answer}".`);
479
- }
480
- }
481
-
482
- console.log(`\n Project ${name}`);
483
- console.log(` Size ${tierLabel(catalog, tier)}${isFree ? ' (paid from your $10 monthly credit)' : ''}`);
484
- console.log(` Account ${describe(acct)}`);
485
- console.log(` Region ${hostOf(api)}`);
486
- const question = isFree ? '\nCreate it? (y/n) ' : '\nCreate it? It is billed to this account. (y/n) ';
487
- if (!(await confirm(question))) { console.log('Aborted. Nothing was created.'); return; }
488
-
489
- const out = await createProject({ acct, api, name, tier, dest, install: !args.has('no-install') });
490
- if (out.exists) fail(`You already have a project named ${name}. Pick another name, or link to it: nukebase link ${name}`);
491
-
492
- console.log(`\n✅ ${name} is live at ${out.url}`);
493
- console.log(`\n cd ${name}`);
494
- console.log(' nukebase push deploy your changes');
495
- }
496
-
497
- // ---- start ----------------------------------------------------------------
498
- // The one command a new user runs, usually by pasting it into Claude Code:
499
- // `npx nukebase start [name] [size] [email]`. It asks nothing. It signs in
500
- // if needed (one click in the browser), puts a new account on the free
501
- // plan, picks a name (from the folder, else "myapp", adding -2, -3... if
502
- // taken), creates the project at the smallest size unless one is given,
503
- // downloads it into this folder when it's empty (else a subfolder), and
504
- // installs it. An AI agent can run it without any back and forth.
505
- // (Inside a project folder `start` means restart; bin/nukebase.js routes it.)
506
-
507
- // Empty apart from dotfiles (.claude, .git, .vscode): a folder made for this.
508
- function isEmptyFolder(dir) {
509
- try { return fs.readdirSync(dir).every(n => n.startsWith('.')); } catch { return false; }
510
- }
511
-
512
- // A folder name as a project name, or null: "My Recipes" -> "my-recipes".
513
- function nameFromFolder(dir) {
514
- const slug = path.basename(dir).toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^[^a-z]+/, '').replace(/-+$/, '').slice(0, 31);
515
- return PROJECT_NAME_RE.test(slug) ? slug : null;
516
- }
517
-
518
- async function startCommand(args) {
519
- const { parse } = require('./args');
520
- const passFlags = [];
521
- for (const f of ['api', 'region']) if (args.get(f)) passFlags.push(`--${f}`, args.get(f));
522
- for (const f of ['device', 'browser']) if (args.has(f)) passFlags.push(`--${f}`);
523
-
524
- const emailArg = args.positional.find(a => a.includes('@')) || args.get('as');
525
- console.log('\n Welcome to NukeBase.');
526
-
527
- // With several accounts signed in, ask — creating a project bills the
528
- // account, so never guess between them. Only interactively: an agent (no
529
- // terminal) or --yes gets the default, as before.
530
- let acct = emailArg ? creds.find(emailArg) : null;
531
- if (!acct && !emailArg) {
532
- const list = creds.list();
533
- acct = (list.length > 1 && process.stdin.isTTY && !args.has('yes'))
534
- ? await chooseAccount(args, null, false)
535
- : (creds.getDefault() || list[0]);
536
- }
537
- if (!acct) {
538
- console.log(args.has('device')
539
- ? '\n Sign in: open the link below on any device and enter the code.'
540
- : '\n Confirm your Claude session: click Confirm in the browser window that opens,\n or, with no browser on this machine, enter the code shown below on any device.');
541
- await loginCommand(parse(['login', ...(emailArg ? ['--email', emailArg] : []), ...passFlags]));
542
- acct = emailArg ? creds.find(emailArg) : (creds.getDefault() || creds.list()[0]);
543
- if (!acct) fail('Sign-in did not finish. Run: npx nukebase start');
544
- }
545
- const api = apiFor(args, acct.api);
546
- console.log(`\n Signed in as ${describe(acct)}`);
547
-
548
- const isFree = (await ensurePlan(api, acct)) === 'free';
549
- const catalog = await loadCatalog(api);
550
- const sizeArg = args.get('tier') || args.positional.find(a => !a.includes('@') && resolveTier(a, catalog));
551
- const tier = sizeArg ? resolveTier(sizeArg, catalog) : catalog.default;
552
-
553
- const here = process.cwd();
554
- const intoHere = isEmptyFolder(here);
555
- const given = args.positional.find(a => !a.includes('@') && PROJECT_NAME_RE.test(a) && !resolveTier(a, catalog));
556
- const base = given || (intoHere && nameFromFolder(here)) || 'myapp';
557
- // One name. If the account already has a project by it, that project is
558
- // downloaded here rather than a name-2 being created: running start again
559
- // after a hiccup finishes the job.
560
- const name = base;
561
- const dest = intoHere ? here : path.resolve(here, name);
562
- if (!intoHere && fs.existsSync(dest) && fs.readdirSync(dest).length) {
563
- fail(`There is already a folder named ${name} here with files in it. Run this in an empty folder, or choose another name: npx nukebase start <name>`);
564
- }
565
-
566
- console.log(` Size: ${tierLabel(catalog, tier)}${isFree ? ' (paid from your $10 monthly credit)' : ''}`);
567
- const created = await createProject({ acct, api, name, tier, dest, install: !args.has('no-install'), reuse: true });
568
-
569
- // Written for whoever runs this next, person or AI agent.
570
- console.log(`\n✅ Your app "${name}" is live at ${created.url}`);
571
- console.log(` Project folder: ${dest}`);
572
- console.log('\nNext steps (for Claude or you):');
573
- console.log(` 1. Work in ${dest}. Read CLAUDE.md there first: it explains how NukeBase apps work.`);
574
- console.log(' 2. Build what the user describes.');
575
- console.log(' 3. Put it live with: npx nukebase push --yes (run inside the project folder)');
576
- }
577
-
578
- // ---- link -----------------------------------------------------------------
579
-
580
- async function linkCommand(args) {
581
- const autoYes = args.has('yes');
582
- const name = args.positional[0];
583
- if (!name) fail('Usage: nukebase link <project> [email] connects this folder to an existing project');
584
- const acct = await chooseAccount(args, args.positional.slice(1).find(a => a.includes('@')), autoYes);
585
- const api = apiFor(args, acct.api);
586
-
587
- let r;
588
- try {
589
- r = await fetch(`${api}/sync`, {
590
- method: 'POST',
591
- headers: { 'Content-Type': 'application/json', 'X-Project': name, Cookie: cookieFor(acct) },
592
- body: JSON.stringify({ exclude: ['sys', '.env', 'node_modules', 'server'], files: {}, direction: 'pull', symlinks: false })
593
- });
594
- } catch (e) {
595
- fail(`Could not reach ${hostOf(api)} (${e.cause?.message || e.message}).`);
596
- }
597
- if (r.status === 404) fail(`${acct.label} has no project named ${name} on ${hostOf(api)}.`);
598
- if (r.status === 401) fail(`The sign-in for ${acct.label} is no longer valid. Run: nukebase login --email ${acct.label}`);
599
- if (!r.ok) fail(`Could not check the project (${r.status}).`);
600
-
601
- const root = process.cwd();
602
- let base = {};
603
- const existing = hasConfig(root);
604
- if (existing) {
605
- try { base = readConfig(root); } catch {}
606
- if (base.uid && base.uid !== acct.uid && !(await makeConfirm(autoYes)(`This folder belongs to ${base.account || base.uid}. Link it to ${acct.label} instead? (y/n) `))) {
607
- console.log('Aborted.');
608
- return;
609
- }
610
- }
611
- writeConfig(root, newConfig({ api, uid: acct.uid, project: name, account: acct.label }, base));
612
- console.log(`✓ Linked this folder to ${name} (${describe(acct)}) on ${hostOf(api)}`);
613
- console.log(' nukebase pull download its files');
614
- }
615
-
616
- module.exports = {
617
- namePackage,
618
- login: loginCommand,
619
- logout: logoutCommand,
620
- accounts: accountsCommand,
621
- use: useCommand,
622
- whoami: whoamiCommand,
623
- create: createCommand,
624
- start: startCommand,
625
- link: linkCommand,
626
- resolveTier, sizeToMb
627
- };
1
+ // Commands that work with or without a project folder: signing accounts in
2
+ // and out, choosing the default, and making or linking projects.
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { spawnSync } = require('child_process');
7
+ const creds = require('./credentials');
8
+ const login = require('./login');
9
+ const unzip = require('./unzip');
10
+ const { findProject, hasConfig, readConfig, writeConfig, newConfig } = require('./project');
11
+ const { hostOf, apiFor, makeConfirm, makeAskLine, askHidden } = require('./ui');
12
+
13
+ const PROJECT_NAME_RE = /^[a-z][a-z0-9_-]{2,30}$/;
14
+
15
+ const cookieFor = (acct) => `uid=${acct.uid}; token=${acct.token}`;
16
+
17
+ // Stop the command with a message. Thrown rather than process.exit(): exiting
18
+ // while Node is still closing finished HTTP connections crashes it on Windows
19
+ // (libuv "UV_HANDLE_CLOSING" assertion). bin/nukebase.js prints it once and
20
+ // sets the exit code, and Node exits by itself when the work is done.
21
+ function fail(message) {
22
+ throw new Error(message);
23
+ }
24
+
25
+ function describe(acct) {
26
+ return `${acct.label || acct.uid} (${acct.uid})`;
27
+ }
28
+
29
+ // The project in the current folder, if any, without its secrets mattering.
30
+ function currentProject() {
31
+ const root = findProject(process.cwd());
32
+ if (!root) return null;
33
+ try { return { root, config: readConfig(root) }; } catch { return null; }
34
+ }
35
+
36
+ // ---- login ----------------------------------------------------------------
37
+
38
+ async function loginCommand(args) {
39
+ const askLine = makeAskLine(false);
40
+ const here = currentProject();
41
+ const api = apiFor(args, here?.config.API || creds.getDefault()?.api);
42
+
43
+ // `nukebase login` inside a project whose account isn't signed in asks for
44
+ // that account, so you don't have to remember which email it was.
45
+ let email = args.get('email') || args.positional.find(a => a.includes('@')) || null;
46
+ if (!email && here && here.config.uid && !creds.get(here.config.uid) && String(here.config.account || '').includes('@')) {
47
+ email = here.config.account;
48
+ }
49
+
50
+ let out, method;
51
+ if (args.has('password')) {
52
+ method = 'password';
53
+ const username = args.positional.find(a => !a.includes('@')) || (await askLine('Username: ')).trim();
54
+ const pass = await askHidden('Password: ');
55
+ if (!username || !pass) fail('Username and password are required.');
56
+ out = await login.password(api, username, pass);
57
+ } else if (args.has('device')) {
58
+ method = 'device';
59
+ out = await login.device(api);
60
+ } else if (args.has('email-link')) {
61
+ method = 'email-link';
62
+ if (!email) email = (await askLine('Email: ')).trim().toLowerCase();
63
+ if (!email.includes('@')) fail('An email address is required.');
64
+ out = await login.emailLink(api, email);
65
+ } else if (args.has('browser')) {
66
+ method = 'browser';
67
+ out = await login.browser(api, { email });
68
+ } else {
69
+ // Browser and device code together, whichever is approved first, so a
70
+ // VM or SSH session can sign in too.
71
+ method = 'browser';
72
+ out = await login.browserOrDevice(api, { email });
73
+ }
74
+
75
+ // Signing in again replaces this machine's token for that account; sign the
76
+ // old one out on the server rather than leave it valid and forgotten.
77
+ const previous = creds.get(out.uid);
78
+ creds.put(out.uid, { label: out.label, api, token: out.token, method, created: Date.now(), lastUsed: Date.now() });
79
+ if (previous && previous.token && previous.token !== out.token) await revoke(previous);
80
+
81
+ console.log(`\n✓ Signed in as ${describe(out)} on ${hostOf(api)}`);
82
+ if (email && String(out.label).toLowerCase() !== email.toLowerCase()) {
83
+ console.log(` Note: you asked for ${email} but approved as ${out.label}.`);
84
+ }
85
+ const n = creds.list().length;
86
+ if (n > 1) console.log(` ${n} accounts signed in on this machine. List them with: nukebase accounts`);
87
+ }
88
+
89
+ // ---- logout ---------------------------------------------------------------
90
+
91
+ async function revoke(acct) {
92
+ try {
93
+ const r = await fetch(`${acct.api}/cli/revoke`, {
94
+ method: 'POST',
95
+ headers: { 'Content-Type': 'application/json', Cookie: cookieFor(acct) },
96
+ body: '{}'
97
+ });
98
+ return r.ok || r.status === 401; // 401: already signed out
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ // `nukebase logout <email|uid>` signs out that account; `nukebase logout
105
+ // --all` (or plain `logout`, after a y/n at a terminal) signs out every account.
106
+ async function logoutCommand(args) {
107
+ const target = args.positional[0];
108
+ let accounts;
109
+ if (target) {
110
+ const acct = creds.find(target);
111
+ if (!acct) fail(`${target} isn't signed in on this machine. See: nukebase accounts`);
112
+ accounts = [acct];
113
+ } else {
114
+ accounts = creds.list();
115
+ if (!accounts.length) { console.log('No accounts are signed in.'); return; }
116
+ // Bare `logout` signs out everyone: ask first, at a terminal only, so
117
+ // scripts that relied on it keep working. --all (or --yes) skips it.
118
+ if (accounts.length > 1 && process.stdin.isTTY && !args.has('all') && !args.has('yes')) {
119
+ const ok = await makeConfirm(false)(`Sign out all ${accounts.length} accounts on this machine? (y/n) `);
120
+ if (!ok) { console.log('Nothing signed out. One account: nukebase logout <email>'); return; }
121
+ }
122
+ }
123
+ for (const acct of accounts) {
124
+ const ok = await revoke(acct);
125
+ creds.remove(acct.uid);
126
+ console.log(`✓ Signed out ${describe(acct)}${ok ? '' : ` (couldn't reach ${hostOf(acct.api)}; sign it out from the dashboard's Signed-in devices too)`}`);
127
+ }
128
+ }
129
+
130
+ // ---- accounts / use / whoami ----------------------------------------------
131
+
132
+ function relative(ms) {
133
+ if (!ms) return 'never';
134
+ const days = Math.floor((Date.now() - ms) / 86400000);
135
+ return days <= 0 ? 'today' : days === 1 ? 'yesterday' : `${days} days ago`;
136
+ }
137
+
138
+ function accountsCommand() {
139
+ const list = creds.list();
140
+ if (!list.length) { console.log('No accounts are signed in. Run: nukebase login'); return; }
141
+ const w = Math.max(...list.map(a => String(a.label || '').length), 7);
142
+ console.log('');
143
+ for (const a of list) {
144
+ console.log(` ${a.isDefault ? '*' : ' '} ${String(a.label || '').padEnd(w)} ${a.uid} ${hostOf(a.api)} last used ${relative(a.lastUsed)}`);
145
+ }
146
+ console.log(`\n * default, for commands run outside a project (nukebase use <email> to change)`);
147
+ console.log(` Inside a project, the account that owns it is used automatically.\n`);
148
+ }
149
+
150
+ function useCommand(args) {
151
+ const q = args.positional[0];
152
+ if (!q) fail('Usage: nukebase use <email|uid>');
153
+ const acct = creds.find(q);
154
+ if (!acct) fail(`${q} isn't signed in on this machine. Run: nukebase login --email ${q}`);
155
+ creds.setDefault(acct.uid);
156
+ console.log(`✓ Default account: ${describe(acct)}`);
157
+ }
158
+
159
+ async function whoamiCommand() {
160
+ const here = currentProject();
161
+ if (here && here.config.uid) {
162
+ const acct = creds.get(here.config.uid);
163
+ const name = here.config.project || path.basename(here.root);
164
+ if (!acct) {
165
+ console.log(`${name} belongs to ${here.config.account || 'uid'} (${here.config.uid}), which isn't signed in on this machine.`);
166
+ console.log(`Run: nukebase login${String(here.config.account || '').includes('@') ? ` --email ${here.config.account}` : ''}`);
167
+ return;
168
+ }
169
+ console.log(`${name} belongs to ${describe(acct)} on ${hostOf(here.config.API)}`);
170
+ console.log(await check(acct, here.config.API));
171
+ return;
172
+ }
173
+ const acct = creds.getDefault();
174
+ if (!acct) { console.log(creds.list().length ? 'No default account. Set one with: nukebase use <email>' : 'Not signed in. Run: nukebase login'); return; }
175
+ console.log(`Default account: ${describe(acct)} on ${hostOf(acct.api)}`);
176
+ console.log(await check(acct, acct.api));
177
+ }
178
+
179
+ async function check(acct, api) {
180
+ try {
181
+ const r = await fetch(`${api}/cli/whoami`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookieFor(acct) }, body: '{}' });
182
+ if (r.ok) return '✓ Signed in';
183
+ if (r.status === 401) return `✗ This sign-in was revoked. Run: nukebase login${String(acct.label).includes('@') ? ` --email ${acct.label}` : ''}`;
184
+ return `? ${hostOf(api)} answered ${r.status}`;
185
+ } catch {
186
+ return `? Couldn't reach ${hostOf(api)}`;
187
+ }
188
+ }
189
+
190
+ // ---- choosing an account for create / link ----------------------------------
191
+
192
+ async function chooseAccount(args, hint, autoYes) {
193
+ const q = args.get('as') || hint;
194
+ if (q) {
195
+ const acct = creds.find(q);
196
+ if (!acct) fail(`${q} isn't signed in on this machine. Run: nukebase login --email ${q}`);
197
+ return acct;
198
+ }
199
+ const list = creds.list();
200
+ if (!list.length) fail('Not signed in. Run: nukebase login');
201
+ if (list.length === 1) return list[0];
202
+ // Creating a project is billed to the account, so never guess between several.
203
+ if (autoYes || !process.stdin.isTTY) {
204
+ fail(`Several accounts are signed in. Say which one, e.g.: nukebase ${args.command} ${args.positional[0] || '<name>'} ${list[0].label}`);
205
+ }
206
+ console.log('\n Which account?');
207
+ list.forEach((a, i) => console.log(` ${i + 1}) ${a.label} (${a.uid})${a.isDefault ? ' default' : ''}`));
208
+ const answer = (await makeAskLine(false)('\n Account (number or email): ')).trim();
209
+ const picked = /^\d+$/.test(answer) ? list[Number(answer) - 1] : creds.find(answer);
210
+ if (!picked) fail('No account chosen.');
211
+ return picked;
212
+ }
213
+
214
+ // ---- plan -----------------------------------------------------------------
215
+
216
+ // The account's plan, starting the free plan first when it has none. An
217
+ // account whose paid plan was cancelled is left alone: that's a choice for
218
+ // the dashboard. Returns the plan name, or null if it couldn't be told.
219
+ async function ensurePlan(api, acct) {
220
+ const call = async (path) => {
221
+ const r = await fetch(`${api}${path}`, {
222
+ method: 'POST',
223
+ headers: { 'Content-Type': 'application/json', Cookie: cookieFor(acct) },
224
+ body: '{}'
225
+ });
226
+ let data = {};
227
+ try { data = await r.json(); } catch {}
228
+ return { r, data };
229
+ };
230
+ let who;
231
+ try { who = await call('/cli/whoami'); } catch { return null; }
232
+ if (who.r.status === 401) fail(`The sign-in for ${acct.label} is no longer valid. Run: nukebase login --email ${acct.label}`);
233
+ if (!who.r.ok) return null;
234
+ if (who.data.plan || who.data.canceled) return who.data.plan || null;
235
+
236
+ const started = await call('/start-free').catch(() => null);
237
+ if (!started || !started.r.ok) return null; // the create below says why
238
+ console.log(`\n✓ You're on the free plan: $${started.data.credit || 10} of credit each month, any number of apps at any size, 1 GB storage.`);
239
+ console.log(` Upgrade any time at ${api}/`);
240
+ return 'free';
241
+ }
242
+
243
+ // ---- create ---------------------------------------------------------------
244
+
245
+ // "256mb", "256", "1gb", "1g", "512 MB" -> megabytes; null when not a size.
246
+ function sizeToMb(text) {
247
+ const m = /^(\d+(?:\.\d+)?)\s*(m|mb|g|gb)?$/i.exec(String(text).trim());
248
+ if (!m) return null;
249
+ const n = Number(m[1]);
250
+ return /^g/i.test(m[2] || '') ? n * 1024 : n;
251
+ }
252
+
253
+ // A size the user typed -> tier key. Matches the RAM each tier buys
254
+ // (memoryHigh) or the tier's name.
255
+ function resolveTier(input, catalog) {
256
+ const tiers = catalog.tiers || {};
257
+ const key = String(input).toLowerCase();
258
+ if (tiers[key]) return key;
259
+ const want = sizeToMb(input);
260
+ if (want === null) return null;
261
+ return (catalog.order || Object.keys(tiers)).find(k => sizeToMb(tiers[k].memoryHigh) === want) || null;
262
+ }
263
+
264
+ function tierLabel(catalog, key) {
265
+ const t = catalog.tiers[key];
266
+ const mb = sizeToMb(t.memoryHigh);
267
+ const ram = mb >= 1024 ? `${mb / 1024} GB` : `${mb} MB`;
268
+ return `${t.name}, ${ram} RAM${t.diskGb ? `, ${t.diskGb} GB disk` : ''}, $${(t.monthlyCents / 100).toFixed(0)}/mo`;
269
+ }
270
+
271
+ async function loadCatalog(api) {
272
+ try {
273
+ const r = await fetch(`${api}/tiers`);
274
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
275
+ const catalog = await r.json();
276
+ catalog.order = catalog.order || Object.keys(catalog.tiers);
277
+ catalog.default = catalog.default || catalog.order[0];
278
+ return catalog;
279
+ } catch (e) {
280
+ fail(`Could not reach ${hostOf(api)} (${e.cause?.message || e.message}).`);
281
+ }
282
+ }
283
+
284
+ function sizeList(catalog) {
285
+ return catalog.order.map(k => `${catalog.tiers[k].memoryHigh.replace(/M$/, 'mb').replace(/G$/, 'gb').toLowerCase()} (${k})`).join(', ');
286
+ }
287
+
288
+ // A project's package.json must not be named "nukebase": npm resolves
289
+ // `npx nukebase` to the current project when the names match, and a project
290
+ // has no executable, so every command inside it fails with "could not
291
+ // determine executable to run". Older server templates shipped that name;
292
+ // the project's own name (a valid npm name) replaces it. Returns true when
293
+ // it changed something.
294
+ function namePackage(dir, name) {
295
+ const pkgPath = path.join(dir, 'package.json');
296
+ try {
297
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
298
+ if (pkg.name !== 'nukebase' && pkg.name !== 'nukebase-app') return false;
299
+ pkg.name = name;
300
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
301
+ return true;
302
+ } catch { return false; }
303
+ }
304
+
305
+ // Read a download to the end, showing how much has arrived: one line that
306
+ // updates in a terminal, or a line every 10 MB when the output goes to a file
307
+ // or an AI agent (where \r updates would pile up). The archives are streamed
308
+ // without a length, so it shows MB so far, not a percentage.
309
+ async function readWithProgress(res, label) {
310
+ const tty = process.stdout.isTTY;
311
+ const mb = (n) => (n / 1048576).toFixed(1);
312
+ const chunks = [];
313
+ let got = 0, lastShown = 0, lastLine = 0;
314
+ const show = (final) => {
315
+ if (tty) process.stdout.write(`\r ${label}… ${mb(got)} MB${final ? '\n' : ''}`);
316
+ else if (final || got - lastLine >= 10 * 1048576) { console.log(` ${label}… ${mb(got)} MB`); lastLine = got; }
317
+ };
318
+ if (!res.body || typeof res.body.getReader !== 'function') {
319
+ const buf = Buffer.from(await res.arrayBuffer());
320
+ got = buf.length; show(true);
321
+ return buf;
322
+ }
323
+ const reader = res.body.getReader();
324
+ try {
325
+ for (;;) {
326
+ const { done, value } = await reader.read();
327
+ if (done) break;
328
+ chunks.push(Buffer.from(value));
329
+ got += value.length;
330
+ if (Date.now() - lastShown > 250) { lastShown = Date.now(); show(false); }
331
+ }
332
+ } catch (e) {
333
+ // End the half-drawn progress line, so whatever is printed next starts clean.
334
+ if (tty && got) process.stdout.write('\n');
335
+ throw e;
336
+ }
337
+ show(true);
338
+ return Buffer.concat(chunks);
339
+ }
340
+
341
+ // The project's files as the server has them, without node_modules (an
342
+ // `npm install` follows; servers too old for ?skip send them anyway). One
343
+ // retry, since this runs right after a dropped connection.
344
+ async function downloadExisting({ acct, api, name }) {
345
+ const headers = { 'X-Project': name, Cookie: cookieFor(acct) };
346
+ const url = `${api}/download-project?project=${encodeURIComponent(name)}&skip=node_modules`;
347
+ let lastErr = null;
348
+ for (let attempt = 1; attempt <= 2; attempt++) {
349
+ try {
350
+ const dl = await fetch(url, { headers });
351
+ if (!dl.ok) { lastErr = new Error(`the server answered ${dl.status}`); continue; }
352
+ return await readWithProgress(dl, `Downloading ${name}`);
353
+ } catch (e) {
354
+ lastErr = e;
355
+ if (attempt === 1) console.log(' (download interrupted, trying again)');
356
+ }
357
+ }
358
+ fail(`${name} exists but could not be downloaded (${lastErr.cause?.message || lastErr.message}).\n` +
359
+ ` In an empty folder, run: nukebase link ${name} then: nukebase pull --yes then: npm install`);
360
+ }
361
+
362
+ // Create `name` on the server and unpack it into `dest`. When the account
363
+ // already has a project by that name: with `reuse`, that project is
364
+ // downloaded into `dest` instead (`start`: running it again finishes the
365
+ // job rather than making name-2); without, returns { exists: true }. Any
366
+ // other failure stops the command.
367
+ async function createProject({ acct, api, name, tier, dest, install, reuse = false }) {
368
+ console.log(`\n🚀 Creating ${name}...`);
369
+ const headers = (extra = {}) => ({ ...extra, 'X-Project': name, Cookie: cookieFor(acct) });
370
+
371
+ // The connection dropped while the server was setting up. That is not the
372
+ // same as "not created": setup ends with an nginx reload, and a reload
373
+ // sends GOAWAY on an open HTTP/2 connection, which Node's fetch reports as
374
+ // a failed request after the project already exists. (Older servers reload
375
+ // before streaming the ZIP; current ones reload after.) So check, and if it
376
+ // is there, fetch its files the other way rather than leaving a project
377
+ // behind and trying the next name.
378
+ async function recover(err) {
379
+ const why = err.cause?.message || err.message;
380
+ let probe = null;
381
+ try {
382
+ probe = await fetch(`${api}/sync`, {
383
+ method: 'POST',
384
+ headers: headers({ 'Content-Type': 'application/json' }),
385
+ body: JSON.stringify({ exclude: [], files: {}, direction: 'pull', symlinks: false })
386
+ });
387
+ } catch {}
388
+ if (!probe || probe.status === 404) {
389
+ fail(`Create failed: ${why}. Check the dashboard before retrying; it may have been created.`);
390
+ }
391
+ if (!probe.ok) fail(`Create failed: ${why} (and the server answered ${probe.status} when asked whether ${name} exists).`);
392
+ console.log(` (the connection dropped while the server finished setting up ${name} — it exists, downloading it)`);
393
+ return downloadExisting({ acct, api, name });
394
+ }
395
+
396
+ let res = null, zip = null;
397
+ try {
398
+ res = await fetch(`${api}/project`, {
399
+ method: 'POST',
400
+ headers: headers({ 'Content-Type': 'application/json' }),
401
+ body: JSON.stringify({ tier })
402
+ });
403
+ } catch (e) {
404
+ zip = await recover(e);
405
+ }
406
+ if (res && !res.ok) {
407
+ let msg = '';
408
+ try { msg = (await res.json()).error; } catch {}
409
+ const taken = res.status === 400 && /already exists/i.test(msg || '');
410
+ if (taken && !reuse) return { exists: true };
411
+ if (taken) {
412
+ console.log(` ${name} already exists in your account, so downloading it here instead of making a new one.`);
413
+ zip = await downloadExisting({ acct, api, name });
414
+ } else {
415
+ if (res.status === 401) fail(`The sign-in for ${acct.label} is no longer valid. Run: nukebase login --email ${acct.label}`);
416
+ // No plan or no credit: fixed on the dashboard.
417
+ if (res.status === 403 && /plan|funds|credit|wallet|balance|upgrade/i.test(msg)) {
418
+ fail(`${msg}\n Manage your plan at ${api}/`);
419
+ }
420
+ fail(`Create failed (${res.status}): ${msg || res.statusText}`);
421
+ }
422
+ }
423
+
424
+ // The server finishes setup as the ZIP finishes streaming, so read it all.
425
+ // A drop here is the same case as above.
426
+ if (!zip) {
427
+ try { zip = await readWithProgress(res, `Downloading ${name}`); }
428
+ catch (e) { zip = await recover(e); }
429
+ }
430
+ fs.mkdirSync(dest, { recursive: true });
431
+ const count = unzip.extract(zip, dest);
432
+
433
+ let base = {};
434
+ try { base = readConfig(dest); } catch {}
435
+ writeConfig(dest, newConfig({ api, uid: acct.uid, project: name, account: acct.label }, base));
436
+ namePackage(dest, name);
437
+ console.log(`✓ Downloaded ${count} files into ${path.relative(process.cwd(), dest) || 'this folder'}`);
438
+
439
+ if (install && fs.existsSync(path.join(dest, 'package.json'))) {
440
+ console.log('\n📦 Installing packages (npm install)…\n');
441
+ // One command string under a shell: npm is npm.cmd on Windows, which
442
+ // needs a shell, and Node 26 deprecates passing separate args with one.
443
+ const r = spawnSync('npm install', [], { cwd: dest, stdio: 'inherit', shell: true });
444
+ if (r.status !== 0) console.log(`\n⚠️ npm install failed. Run it yourself in ${dest}.`);
445
+ }
446
+ return { ok: true, url: `https://${acct.uid}-${name}.${hostOf(api)}` };
447
+ }
448
+
449
+ async function createCommand(args) {
450
+ const autoYes = args.has('yes');
451
+ const confirm = makeConfirm(autoYes);
452
+ const askLine = makeAskLine(autoYes);
453
+
454
+ const name = args.positional[0];
455
+ if (!name) fail('Usage: nukebase create <name> [size] [email] e.g. nukebase create demoapp123 256mb you@example.com');
456
+ if (!PROJECT_NAME_RE.test(name)) fail('Project names are 3-31 characters: lowercase letters, numbers, - and _, starting with a letter.');
457
+
458
+ // After the name, in any order: something with an @ is the account; the
459
+ // rest is the size.
460
+ const rest = args.positional.slice(1);
461
+ const emailArg = rest.find(a => a.includes('@')) || null;
462
+ const sizeArg = args.get('tier') || rest.find(a => !a.includes('@')) || null;
463
+
464
+ const dest = path.resolve(process.cwd(), name);
465
+ if (fs.existsSync(dest) && fs.readdirSync(dest).length) fail(`The folder ${name} already exists and isn't empty.`);
466
+
467
+ const acct = await chooseAccount(args, emailArg, autoYes);
468
+ const api = apiFor(args, acct.api);
469
+
470
+ // Every account starts free: one with no plan is put on the free plan here.
471
+ const isFree = (await ensurePlan(api, acct)) === 'free';
472
+ const catalog = await loadCatalog(api);
473
+
474
+ let tier = sizeArg ? resolveTier(sizeArg, catalog) : null;
475
+ if (sizeArg && !tier) fail(`Unknown size "${sizeArg}". One of: ${sizeList(catalog)}`);
476
+ if (!tier) {
477
+ if (autoYes || !process.stdin.isTTY || isFree) {
478
+ tier = catalog.default;
479
+ } else {
480
+ console.log('\n Sizes:');
481
+ catalog.order.forEach((k, i) => console.log(` ${i + 1}) ${tierLabel(catalog, k)}${k === catalog.default ? ' (default)' : ''}`));
482
+ const answer = (await askLine(`\n Size [${catalog.default}]: `)).trim();
483
+ tier = !answer ? catalog.default : /^\d+$/.test(answer) ? catalog.order[Number(answer) - 1] : resolveTier(answer, catalog);
484
+ if (!tier) fail(`Unknown size "${answer}".`);
485
+ }
486
+ }
487
+
488
+ console.log(`\n Project ${name}`);
489
+ console.log(` Size ${tierLabel(catalog, tier)}${isFree ? ' (paid from your $10 monthly credit)' : ''}`);
490
+ console.log(` Account ${describe(acct)}`);
491
+ console.log(` Region ${hostOf(api)}`);
492
+ const question = isFree ? '\nCreate it? (y/n) ' : '\nCreate it? It is billed to this account. (y/n) ';
493
+ if (!(await confirm(question))) { console.log('Aborted. Nothing was created.'); return; }
494
+
495
+ const out = await createProject({ acct, api, name, tier, dest, install: !args.has('no-install') });
496
+ if (out.exists) fail(`You already have a project named ${name}. Pick another name, or link to it: nukebase link ${name}`);
497
+
498
+ console.log(`\n✅ ${name} is live at ${out.url}`);
499
+ console.log(`\n cd ${name}`);
500
+ console.log(' nukebase push deploy your changes');
501
+ }
502
+
503
+ // ---- start ----------------------------------------------------------------
504
+ // The one command a new user runs, usually by pasting it into Claude Code:
505
+ // `npx nukebase start [name] [size] [email]`. It asks nothing. It signs in
506
+ // if needed (one click in the browser), puts a new account on the free
507
+ // plan, picks a name (from the folder, else "myapp", adding -2, -3... if
508
+ // taken), creates the project at the smallest size unless one is given,
509
+ // downloads it into this folder when it's empty (else a subfolder), and
510
+ // installs it. An AI agent can run it without any back and forth.
511
+ // (Inside a project folder `start` means restart; bin/nukebase.js routes it.)
512
+
513
+ // Empty apart from dotfiles (.claude, .git, .vscode): a folder made for this.
514
+ function isEmptyFolder(dir) {
515
+ try { return fs.readdirSync(dir).every(n => n.startsWith('.')); } catch { return false; }
516
+ }
517
+
518
+ // A folder name as a project name, or null: "My Recipes" -> "my-recipes".
519
+ function nameFromFolder(dir) {
520
+ const slug = path.basename(dir).toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^[^a-z]+/, '').replace(/-+$/, '').slice(0, 31);
521
+ return PROJECT_NAME_RE.test(slug) ? slug : null;
522
+ }
523
+
524
+ async function startCommand(args) {
525
+ const { parse } = require('./args');
526
+ const passFlags = [];
527
+ for (const f of ['api', 'region']) if (args.get(f)) passFlags.push(`--${f}`, args.get(f));
528
+ for (const f of ['device', 'browser']) if (args.has(f)) passFlags.push(`--${f}`);
529
+
530
+ const emailArg = args.positional.find(a => a.includes('@')) || args.get('as');
531
+ console.log('\n Welcome to NukeBase.');
532
+
533
+ // With several accounts signed in, ask — creating a project bills the
534
+ // account, so never guess between them. Only interactively: an agent (no
535
+ // terminal) or --yes gets the default, as before.
536
+ let acct = emailArg ? creds.find(emailArg) : null;
537
+ if (!acct && !emailArg) {
538
+ const list = creds.list();
539
+ acct = (list.length > 1 && process.stdin.isTTY && !args.has('yes'))
540
+ ? await chooseAccount(args, null, false)
541
+ : (creds.getDefault() || list[0]);
542
+ }
543
+ if (!acct) {
544
+ console.log(args.has('device')
545
+ ? '\n Sign in: open the link below on any device and enter the code.'
546
+ : '\n Confirm your Claude session: click Confirm in the browser window that opens,\n or, with no browser on this machine, enter the code shown below on any device.');
547
+ await loginCommand(parse(['login', ...(emailArg ? ['--email', emailArg] : []), ...passFlags]));
548
+ acct = emailArg ? creds.find(emailArg) : (creds.getDefault() || creds.list()[0]);
549
+ if (!acct) fail('Sign-in did not finish. Run: npx nukebase start');
550
+ }
551
+ const api = apiFor(args, acct.api);
552
+ console.log(`\n Signed in as ${describe(acct)}`);
553
+
554
+ const isFree = (await ensurePlan(api, acct)) === 'free';
555
+ const catalog = await loadCatalog(api);
556
+ const sizeArg = args.get('tier') || args.positional.find(a => !a.includes('@') && resolveTier(a, catalog));
557
+ const tier = sizeArg ? resolveTier(sizeArg, catalog) : catalog.default;
558
+
559
+ const here = process.cwd();
560
+ const intoHere = isEmptyFolder(here);
561
+ const given = args.positional.find(a => !a.includes('@') && PROJECT_NAME_RE.test(a) && !resolveTier(a, catalog));
562
+ const base = given || (intoHere && nameFromFolder(here)) || 'myapp';
563
+ // One name. If the account already has a project by it, that project is
564
+ // downloaded here rather than a name-2 being created: running start again
565
+ // after a hiccup finishes the job.
566
+ const name = base;
567
+ const dest = intoHere ? here : path.resolve(here, name);
568
+ if (!intoHere && fs.existsSync(dest) && fs.readdirSync(dest).length) {
569
+ fail(`There is already a folder named ${name} here with files in it. Run this in an empty folder, or choose another name: npx nukebase start <name>`);
570
+ }
571
+
572
+ console.log(` Size: ${tierLabel(catalog, tier)}${isFree ? ' (paid from your $10 monthly credit)' : ''}`);
573
+ const created = await createProject({ acct, api, name, tier, dest, install: !args.has('no-install'), reuse: true });
574
+
575
+ // Written for whoever runs this next, person or AI agent.
576
+ console.log(`\n✅ Your app "${name}" is live at ${created.url}`);
577
+ console.log(` Project folder: ${dest}`);
578
+ console.log('\nNext steps (for Claude or you):');
579
+ console.log(` 1. Work in ${dest}. Read CLAUDE.md there first: it explains how NukeBase apps work.`);
580
+ console.log(' 2. Build what the user describes.');
581
+ console.log(' 3. Put it live with: npx nukebase push --yes (run inside the project folder)');
582
+ }
583
+
584
+ // ---- link -----------------------------------------------------------------
585
+
586
+ async function linkCommand(args) {
587
+ const autoYes = args.has('yes');
588
+ const name = args.positional[0];
589
+ if (!name) fail('Usage: nukebase link <project> [email] connects this folder to an existing project');
590
+ const acct = await chooseAccount(args, args.positional.slice(1).find(a => a.includes('@')), autoYes);
591
+ const api = apiFor(args, acct.api);
592
+
593
+ let r;
594
+ try {
595
+ r = await fetch(`${api}/sync`, {
596
+ method: 'POST',
597
+ headers: { 'Content-Type': 'application/json', 'X-Project': name, Cookie: cookieFor(acct) },
598
+ body: JSON.stringify({ exclude: ['sys', '.env', 'node_modules', 'server'], files: {}, direction: 'pull', symlinks: false })
599
+ });
600
+ } catch (e) {
601
+ fail(`Could not reach ${hostOf(api)} (${e.cause?.message || e.message}).`);
602
+ }
603
+ if (r.status === 404) fail(`${acct.label} has no project named ${name} on ${hostOf(api)}.`);
604
+ if (r.status === 401) fail(`The sign-in for ${acct.label} is no longer valid. Run: nukebase login --email ${acct.label}`);
605
+ if (!r.ok) fail(`Could not check the project (${r.status}).`);
606
+
607
+ const root = process.cwd();
608
+ let base = {};
609
+ const existing = hasConfig(root);
610
+ if (existing) {
611
+ try { base = readConfig(root); } catch {}
612
+ if (base.uid && base.uid !== acct.uid && !(await makeConfirm(autoYes)(`This folder belongs to ${base.account || base.uid}. Link it to ${acct.label} instead? (y/n) `))) {
613
+ console.log('Aborted.');
614
+ return;
615
+ }
616
+ }
617
+ writeConfig(root, newConfig({ api, uid: acct.uid, project: name, account: acct.label }, base));
618
+ console.log(`✓ Linked this folder to ${name} (${describe(acct)}) on ${hostOf(api)}`);
619
+ console.log(' nukebase pull download its files');
620
+ }
621
+
622
+ module.exports = {
623
+ namePackage,
624
+ login: loginCommand,
625
+ logout: logoutCommand,
626
+ accounts: accountsCommand,
627
+ use: useCommand,
628
+ whoami: whoamiCommand,
629
+ create: createCommand,
630
+ start: startCommand,
631
+ link: linkCommand,
632
+ resolveTier, sizeToMb
633
+ };