nukebase 2.0.2 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/nukebase.js CHANGED
@@ -97,7 +97,9 @@ if (args.has('version') && !command) {
97
97
  } else if (ACCOUNT_COMMANDS.includes(command) || command === 'setup') {
98
98
  Promise.resolve()
99
99
  .then(() => require('../lib/account-commands')[command === 'setup' ? 'start' : command](args))
100
- .then(() => process.exit(0), err => { console.error('❌', err.message); process.exit(1); });
100
+ // No process.exit(): see fail() in lib/account-commands.js. Nothing these
101
+ // commands start outlives them, so Node exits as soon as they finish.
102
+ .catch(err => { console.error('❌', err.message); process.exitCode = 1; });
101
103
  } else if (PROJECT_COMMANDS.includes(command)) {
102
104
  require('../lib/project-commands');
103
105
  } else {
@@ -14,9 +14,12 @@ const PROJECT_NAME_RE = /^[a-z][a-z0-9_-]{2,30}$/;
14
14
 
15
15
  const cookieFor = (acct) => `uid=${acct.uid}; token=${acct.token}`;
16
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.
17
21
  function fail(message) {
18
- console.error(`❌ ${message}`);
19
- process.exit(1);
22
+ throw new Error(message);
20
23
  }
21
24
 
22
25
  function describe(acct) {
@@ -197,6 +200,35 @@ async function chooseAccount(args, hint, autoYes) {
197
200
  return picked;
198
201
  }
199
202
 
203
+ // ---- plan -----------------------------------------------------------------
204
+
205
+ // The account's plan, starting the free plan first when it has none. An
206
+ // account whose paid plan was cancelled is left alone: that's a choice for
207
+ // the dashboard. Returns the plan name, or null if it couldn't be told.
208
+ async function ensurePlan(api, acct) {
209
+ const call = async (path) => {
210
+ const r = await fetch(`${api}${path}`, {
211
+ method: 'POST',
212
+ headers: { 'Content-Type': 'application/json', Cookie: cookieFor(acct) },
213
+ body: '{}'
214
+ });
215
+ let data = {};
216
+ try { data = await r.json(); } catch {}
217
+ return { r, data };
218
+ };
219
+ let who;
220
+ try { who = await call('/cli/whoami'); } catch { return null; }
221
+ if (who.r.status === 401) fail(`The sign-in for ${acct.label} is no longer valid. Run: nukebase login --email ${acct.label}`);
222
+ if (!who.r.ok) return null;
223
+ if (who.data.plan || who.data.canceled) return who.data.plan || null;
224
+
225
+ const started = await call('/start-free').catch(() => null);
226
+ if (!started || !started.r.ok) return null; // the create below says why
227
+ console.log(`\n✓ You're on the free plan: $${started.data.credit || 10} of credit each month, 1 app (any size), 1 GB storage.`);
228
+ console.log(` Upgrade any time at ${api}/`);
229
+ return 'free';
230
+ }
231
+
200
232
  // ---- create ---------------------------------------------------------------
201
233
 
202
234
  // "256mb", "256", "1gb", "1g", "512 MB" -> megabytes; null when not a size.
@@ -225,61 +257,27 @@ function tierLabel(catalog, key) {
225
257
  return `${t.name}, ${ram} RAM, $${(t.monthlyCents / 100).toFixed(0)}/mo`;
226
258
  }
227
259
 
228
- async function createCommand(args) {
229
- const autoYes = args.has('yes');
230
- const confirm = makeConfirm(autoYes);
231
- const askLine = makeAskLine(autoYes);
232
-
233
- const name = args.positional[0];
234
- if (!name) fail('Usage: nukebase create <name> [size] [email] e.g. nukebase create demoapp123 256mb you@example.com');
235
- if (!PROJECT_NAME_RE.test(name)) fail('Project names are 3-31 characters: lowercase letters, numbers, - and _, starting with a letter.');
236
-
237
- // After the name, in any order: something with an @ is the account; the
238
- // rest is the size.
239
- const rest = args.positional.slice(1);
240
- const emailArg = rest.find(a => a.includes('@')) || null;
241
- const sizeArg = args.get('tier') || rest.find(a => !a.includes('@')) || null;
242
-
243
- const dest = path.resolve(process.cwd(), name);
244
- if (fs.existsSync(dest) && fs.readdirSync(dest).length) fail(`The folder ${name} already exists and isn't empty.`);
245
-
246
- const acct = await chooseAccount(args, emailArg, autoYes);
247
- const api = apiFor(args, acct.api);
248
- const host = hostOf(api);
249
-
250
- let catalog;
260
+ async function loadCatalog(api) {
251
261
  try {
252
262
  const r = await fetch(`${api}/tiers`);
253
263
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
254
- catalog = await r.json();
264
+ const catalog = await r.json();
265
+ catalog.order = catalog.order || Object.keys(catalog.tiers);
266
+ catalog.default = catalog.default || catalog.order[0];
267
+ return catalog;
255
268
  } catch (e) {
256
- fail(`Could not reach ${host} (${e.cause?.message || e.message}).`);
257
- }
258
- const order = catalog.order || Object.keys(catalog.tiers);
259
- const def = catalog.default || order[0];
260
-
261
- let tier = sizeArg ? resolveTier(sizeArg, catalog) : null;
262
- if (sizeArg && !tier) {
263
- fail(`Unknown size "${sizeArg}". One of: ${order.map(k => `${catalog.tiers[k].memoryHigh.replace(/M$/, 'mb').replace(/G$/, 'gb').toLowerCase()} (${k})`).join(', ')}`);
264
- }
265
- if (!tier) {
266
- if (autoYes || !process.stdin.isTTY) {
267
- tier = def;
268
- } else {
269
- console.log('\n Sizes:');
270
- order.forEach((k, i) => console.log(` ${i + 1}) ${tierLabel(catalog, k)}${k === def ? ' (default)' : ''}`));
271
- const answer = (await askLine(`\n Size [${def}]: `)).trim();
272
- tier = !answer ? def : /^\d+$/.test(answer) ? order[Number(answer) - 1] : resolveTier(answer, catalog);
273
- if (!tier) fail(`Unknown size "${answer}".`);
274
- }
269
+ fail(`Could not reach ${hostOf(api)} (${e.cause?.message || e.message}).`);
275
270
  }
271
+ }
276
272
 
277
- console.log(`\n Project ${name}`);
278
- console.log(` Size ${tierLabel(catalog, tier)}`);
279
- console.log(` Account ${describe(acct)}`);
280
- console.log(` Region ${host}`);
281
- if (!(await confirm('\nCreate it? It is billed to this account. (y/n) '))) { console.log('Aborted. Nothing was created.'); return; }
273
+ function sizeList(catalog) {
274
+ return catalog.order.map(k => `${catalog.tiers[k].memoryHigh.replace(/M$/, 'mb').replace(/G$/, 'gb').toLowerCase()} (${k})`).join(', ');
275
+ }
282
276
 
277
+ // Create `name` on the server and unpack it into `dest`. Returns
278
+ // { exists: true } when the account already has a project by that name, so
279
+ // `start` can try the next one; any other failure stops the command.
280
+ async function createProject({ acct, api, name, tier, dest, install }) {
283
281
  console.log(`\n🚀 Creating ${name}...`);
284
282
  let res;
285
283
  try {
@@ -294,10 +292,11 @@ async function createCommand(args) {
294
292
  if (!res.ok) {
295
293
  let msg = '';
296
294
  try { msg = (await res.json()).error; } catch {}
295
+ if (res.status === 400 && /already exists/i.test(msg || '')) return { exists: true };
297
296
  if (res.status === 401) fail(`The sign-in for ${acct.label} is no longer valid. Run: nukebase login --email ${acct.label}`);
298
- // No plan, or no credit: both are fixed on the dashboard.
299
- if (res.status === 403 && /plan|funds|credit|wallet|balance/i.test(msg)) {
300
- fail(`${msg}\n Choose a plan or add credit at ${api}/`);
297
+ // No plan, no credit, or the free plan's one project: all fixed on the dashboard.
298
+ if (res.status === 403 && /plan|funds|credit|wallet|balance|upgrade/i.test(msg)) {
299
+ fail(`${msg}\n Manage your plan at ${api}/`);
301
300
  }
302
301
  fail(`Create failed (${res.status}): ${msg || res.statusText}`);
303
302
  }
@@ -310,55 +309,139 @@ async function createCommand(args) {
310
309
  let base = {};
311
310
  try { base = readConfig(dest); } catch {}
312
311
  writeConfig(dest, newConfig({ api, uid: acct.uid, project: name, account: acct.label }, base));
313
- console.log(`✓ Downloaded ${count} files into ./${name}`);
312
+ console.log(`✓ Downloaded ${count} files into ${path.relative(process.cwd(), dest) || 'this folder'}`);
314
313
 
315
- if (!args.has('no-install') && fs.existsSync(path.join(dest, 'package.json'))) {
314
+ if (install && fs.existsSync(path.join(dest, 'package.json'))) {
316
315
  console.log('\n📦 npm install\n');
317
316
  const r = spawnSync('npm', ['install'], { cwd: dest, stdio: 'inherit', shell: process.platform === 'win32' });
318
- if (r.status !== 0) console.log(`\n⚠️ npm install failed. Run it yourself in ./${name}.`);
317
+ if (r.status !== 0) console.log(`\n⚠️ npm install failed. Run it yourself in ${dest}.`);
319
318
  }
319
+ return { ok: true, url: `https://${acct.uid}-${name}.${hostOf(api)}` };
320
+ }
320
321
 
321
- console.log(`\n✅ ${name} is live at https://${acct.uid}-${name}.${host}`);
322
+ async function createCommand(args) {
323
+ const autoYes = args.has('yes');
324
+ const confirm = makeConfirm(autoYes);
325
+ const askLine = makeAskLine(autoYes);
326
+
327
+ const name = args.positional[0];
328
+ if (!name) fail('Usage: nukebase create <name> [size] [email] e.g. nukebase create demoapp123 256mb you@example.com');
329
+ if (!PROJECT_NAME_RE.test(name)) fail('Project names are 3-31 characters: lowercase letters, numbers, - and _, starting with a letter.');
330
+
331
+ // After the name, in any order: something with an @ is the account; the
332
+ // rest is the size.
333
+ const rest = args.positional.slice(1);
334
+ const emailArg = rest.find(a => a.includes('@')) || null;
335
+ const sizeArg = args.get('tier') || rest.find(a => !a.includes('@')) || null;
336
+
337
+ const dest = path.resolve(process.cwd(), name);
338
+ if (fs.existsSync(dest) && fs.readdirSync(dest).length) fail(`The folder ${name} already exists and isn't empty.`);
339
+
340
+ const acct = await chooseAccount(args, emailArg, autoYes);
341
+ const api = apiFor(args, acct.api);
342
+
343
+ // Every account starts free: one with no plan is put on the free plan here.
344
+ const isFree = (await ensurePlan(api, acct)) === 'free';
345
+ const catalog = await loadCatalog(api);
346
+
347
+ let tier = sizeArg ? resolveTier(sizeArg, catalog) : null;
348
+ if (sizeArg && !tier) fail(`Unknown size "${sizeArg}". One of: ${sizeList(catalog)}`);
349
+ if (!tier) {
350
+ if (autoYes || !process.stdin.isTTY || isFree) {
351
+ tier = catalog.default;
352
+ } else {
353
+ console.log('\n Sizes:');
354
+ catalog.order.forEach((k, i) => console.log(` ${i + 1}) ${tierLabel(catalog, k)}${k === catalog.default ? ' (default)' : ''}`));
355
+ const answer = (await askLine(`\n Size [${catalog.default}]: `)).trim();
356
+ tier = !answer ? catalog.default : /^\d+$/.test(answer) ? catalog.order[Number(answer) - 1] : resolveTier(answer, catalog);
357
+ if (!tier) fail(`Unknown size "${answer}".`);
358
+ }
359
+ }
360
+
361
+ console.log(`\n Project ${name}`);
362
+ console.log(` Size ${tierLabel(catalog, tier)}${isFree ? ' (paid from your $10 monthly credit)' : ''}`);
363
+ console.log(` Account ${describe(acct)}`);
364
+ console.log(` Region ${hostOf(api)}`);
365
+ const question = isFree ? '\nCreate it? (y/n) ' : '\nCreate it? It is billed to this account. (y/n) ';
366
+ if (!(await confirm(question))) { console.log('Aborted. Nothing was created.'); return; }
367
+
368
+ const out = await createProject({ acct, api, name, tier, dest, install: !args.has('no-install') });
369
+ if (out.exists) fail(`You already have a project named ${name}. Pick another name, or link to it: nukebase link ${name}`);
370
+
371
+ console.log(`\n✅ ${name} is live at ${out.url}`);
322
372
  console.log(`\n cd ${name}`);
323
373
  console.log(' nukebase push deploy your changes');
324
374
  }
325
375
 
326
376
  // ---- start ----------------------------------------------------------------
327
- // The one command a new user runs after signing up: sign in if needed, then
328
- // create a project and download it. `nukebase start [name] [size] [email]`.
377
+ // The one command a new user runs, usually by pasting it into Claude Code:
378
+ // `npx nukebase start [name] [size] [email]`. It asks nothing. It signs in
379
+ // if needed (one click in the browser), puts a new account on the free
380
+ // plan, picks a name (from the folder, else "myapp", adding -2, -3... if
381
+ // taken), creates the project at the smallest size unless one is given,
382
+ // downloads it into this folder when it's empty (else a subfolder), and
383
+ // installs it. An AI agent can run it without any back and forth.
329
384
  // (Inside a project folder `start` means restart; bin/nukebase.js routes it.)
330
385
 
386
+ // Empty apart from dotfiles (.claude, .git, .vscode): a folder made for this.
387
+ function isEmptyFolder(dir) {
388
+ try { return fs.readdirSync(dir).every(n => n.startsWith('.')); } catch { return false; }
389
+ }
390
+
391
+ // A folder name as a project name, or null: "My Recipes" -> "my-recipes".
392
+ function nameFromFolder(dir) {
393
+ const slug = path.basename(dir).toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^[^a-z]+/, '').replace(/-+$/, '').slice(0, 31);
394
+ return PROJECT_NAME_RE.test(slug) ? slug : null;
395
+ }
396
+
331
397
  async function startCommand(args) {
332
- const autoYes = args.has('yes');
333
398
  const { parse } = require('./args');
334
399
  const passFlags = [];
335
400
  for (const f of ['api', 'region']) if (args.get(f)) passFlags.push(`--${f}`, args.get(f));
336
- if (autoYes) passFlags.push('--yes');
337
- if (args.has('no-install')) passFlags.push('--no-install');
338
401
 
402
+ const emailArg = args.positional.find(a => a.includes('@')) || args.get('as');
339
403
  console.log('\n Welcome to NukeBase.');
340
- const emailArg = args.positional.find(a => a.includes('@'));
341
- if (!creds.list().length || (emailArg && !creds.find(emailArg))) {
342
- console.log('\n Step 1 of 2: sign in. Click Allow in the browser window that opens.');
404
+
405
+ let acct = emailArg ? creds.find(emailArg) : (creds.getDefault() || creds.list()[0]);
406
+ if (!acct) {
407
+ console.log('\n Confirm your Claude session: click Confirm in the browser window that opens.');
343
408
  await loginCommand(parse(['login', ...(emailArg ? ['--email', emailArg] : []), ...passFlags]));
409
+ acct = emailArg ? creds.find(emailArg) : (creds.getDefault() || creds.list()[0]);
410
+ if (!acct) fail('Sign-in did not finish. Run: npx nukebase start');
344
411
  }
345
-
346
- console.log(`\n ${creds.list().length ? 'Step 2 of 2: ' : ''}create your project`);
347
- let name = args.positional.find(a => !a.includes('@') && PROJECT_NAME_RE.test(a));
348
- if (!name) {
349
- if (autoYes || !process.stdin.isTTY) fail('Give the project a name, e.g.: nukebase start myapp');
350
- for (;;) {
351
- name = (await makeAskLine(false)('\n Project name (lowercase, e.g. myapp): ')).trim().toLowerCase();
352
- if (!name) fail('No name given.');
353
- if (PROJECT_NAME_RE.test(name)) break;
354
- console.log(' 3-31 characters: lowercase letters, numbers, - and _, starting with a letter.');
355
- }
412
+ const api = apiFor(args, acct.api);
413
+ console.log(`\n Signed in as ${describe(acct)}`);
414
+
415
+ const isFree = (await ensurePlan(api, acct)) === 'free';
416
+ const catalog = await loadCatalog(api);
417
+ const sizeArg = args.get('tier') || args.positional.find(a => !a.includes('@') && resolveTier(a, catalog));
418
+ const tier = sizeArg ? resolveTier(sizeArg, catalog) : catalog.default;
419
+
420
+ const here = process.cwd();
421
+ const intoHere = isEmptyFolder(here);
422
+ const given = args.positional.find(a => !a.includes('@') && PROJECT_NAME_RE.test(a) && !resolveTier(a, catalog));
423
+ const base = given || (intoHere && nameFromFolder(here)) || 'myapp';
424
+ const candidates = given ? [given] : [base, ...Array.from({ length: 8 }, (_, i) => `${base}-${i + 2}`.slice(0, 31))];
425
+
426
+ console.log(` Size: ${tierLabel(catalog, tier)}${isFree ? ' (paid from your $10 monthly credit)' : ''}`);
427
+ let created = null, name = null, dest = null;
428
+ for (const candidate of candidates) {
429
+ dest = intoHere ? here : path.resolve(here, candidate);
430
+ if (!intoHere && fs.existsSync(dest) && fs.readdirSync(dest).length) continue;
431
+ const out = await createProject({ acct, api, name: candidate, tier, dest, install: !args.has('no-install') });
432
+ if (out.exists) { console.log(` (${candidate} is taken, trying another name)`); continue; }
433
+ created = out; name = candidate;
434
+ break;
356
435
  }
357
- const rest = args.positional.filter(a => a !== name);
358
- await createCommand(parse(['create', name, ...rest, ...passFlags]));
359
-
360
- console.log('\n Building with Claude Code? Open the folder and describe your app.');
361
- console.log(' Its CLAUDE.md explains NukeBase, and Claude can deploy with: npx nukebase push --yes');
436
+ if (!created) fail(given ? `You already have a project named ${given}. Pick another name: npx nukebase start <name>` : 'Could not find a free project name. Run: npx nukebase start <name>');
437
+
438
+ // Written for whoever runs this next, person or AI agent.
439
+ console.log(`\n✅ Your app "${name}" is live at ${created.url}`);
440
+ console.log(` Project folder: ${dest}`);
441
+ console.log('\nNext steps (for Claude or you):');
442
+ console.log(` 1. Work in ${dest}. Read CLAUDE.md there first: it explains how NukeBase apps work.`);
443
+ console.log(' 2. Build what the user describes.');
444
+ console.log(' 3. Put it live with: npx nukebase push --yes (run inside the project folder)');
362
445
  }
363
446
 
364
447
  // ---- link -----------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nukebase",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
4
4
  "description": "NukeBase CLI: create, deploy and manage NukeBase projects",
5
5
  "bin": {
6
6
  "nukebase": "bin/nukebase.js"