nukebase 2.0.3 → 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.
@@ -202,8 +202,6 @@ async function chooseAccount(args, hint, autoYes) {
202
202
 
203
203
  // ---- plan -----------------------------------------------------------------
204
204
 
205
- const FREE_TIER = 'spark';
206
-
207
205
  // The account's plan, starting the free plan first when it has none. An
208
206
  // account whose paid plan was cancelled is left alone: that's a choice for
209
207
  // the dashboard. Returns the plan name, or null if it couldn't be told.
@@ -226,7 +224,7 @@ async function ensurePlan(api, acct) {
226
224
 
227
225
  const started = await call('/start-free').catch(() => null);
228
226
  if (!started || !started.r.ok) return null; // the create below says why
229
- console.log(`\n✓ You're on the free plan: $${started.data.credit || 10} of credit each month, 1 app (128 MB), 1 GB storage.`);
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.`);
230
228
  console.log(` Upgrade any time at ${api}/`);
231
229
  return 'free';
232
230
  }
@@ -259,72 +257,27 @@ function tierLabel(catalog, key) {
259
257
  return `${t.name}, ${ram} RAM, $${(t.monthlyCents / 100).toFixed(0)}/mo`;
260
258
  }
261
259
 
262
- async function createCommand(args) {
263
- const autoYes = args.has('yes');
264
- const confirm = makeConfirm(autoYes);
265
- const askLine = makeAskLine(autoYes);
266
-
267
- const name = args.positional[0];
268
- if (!name) fail('Usage: nukebase create <name> [size] [email] e.g. nukebase create demoapp123 256mb you@example.com');
269
- if (!PROJECT_NAME_RE.test(name)) fail('Project names are 3-31 characters: lowercase letters, numbers, - and _, starting with a letter.');
270
-
271
- // After the name, in any order: something with an @ is the account; the
272
- // rest is the size.
273
- const rest = args.positional.slice(1);
274
- const emailArg = rest.find(a => a.includes('@')) || null;
275
- const sizeArg = args.get('tier') || rest.find(a => !a.includes('@')) || null;
276
-
277
- const dest = path.resolve(process.cwd(), name);
278
- if (fs.existsSync(dest) && fs.readdirSync(dest).length) fail(`The folder ${name} already exists and isn't empty.`);
279
-
280
- const acct = await chooseAccount(args, emailArg, autoYes);
281
- const api = apiFor(args, acct.api);
282
- const host = hostOf(api);
283
-
284
- // Every account starts free: one with no plan is put on the free plan here,
285
- // so a brand-new `npx nukebase start` goes straight to a running app.
286
- const plan = await ensurePlan(api, acct);
287
- const isFree = plan === 'free';
288
-
289
- let catalog;
260
+ async function loadCatalog(api) {
290
261
  try {
291
262
  const r = await fetch(`${api}/tiers`);
292
263
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
293
- 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;
294
268
  } catch (e) {
295
- fail(`Could not reach ${host} (${e.cause?.message || e.message}).`);
296
- }
297
- const order = catalog.order || Object.keys(catalog.tiers);
298
- const def = catalog.default || order[0];
299
-
300
- let tier = sizeArg ? resolveTier(sizeArg, catalog) : null;
301
- if (sizeArg && !tier) {
302
- fail(`Unknown size "${sizeArg}". One of: ${order.map(k => `${catalog.tiers[k].memoryHigh.replace(/M$/, 'mb').replace(/G$/, 'gb').toLowerCase()} (${k})`).join(', ')}`);
303
- }
304
- if (isFree) {
305
- // The free plan has one size; no question to ask.
306
- if (tier && tier !== FREE_TIER) fail(`The free plan runs 128 MB apps only. Upgrade for bigger sizes: ${api}/`);
307
- tier = FREE_TIER;
308
- }
309
- if (!tier) {
310
- if (autoYes || !process.stdin.isTTY) {
311
- tier = def;
312
- } else {
313
- console.log('\n Sizes:');
314
- order.forEach((k, i) => console.log(` ${i + 1}) ${tierLabel(catalog, k)}${k === def ? ' (default)' : ''}`));
315
- const answer = (await askLine(`\n Size [${def}]: `)).trim();
316
- tier = !answer ? def : /^\d+$/.test(answer) ? order[Number(answer) - 1] : resolveTier(answer, catalog);
317
- if (!tier) fail(`Unknown size "${answer}".`);
318
- }
269
+ fail(`Could not reach ${hostOf(api)} (${e.cause?.message || e.message}).`);
319
270
  }
271
+ }
320
272
 
321
- console.log(`\n Project ${name}`);
322
- console.log(` Size ${tierLabel(catalog, tier)}${isFree ? ' (free plan: paid from your $10 monthly credit)' : ''}`);
323
- console.log(` Account ${describe(acct)}`);
324
- console.log(` Region ${host}`);
325
- const question = isFree ? '\nCreate it? (y/n) ' : '\nCreate it? It is billed to this account. (y/n) ';
326
- if (!(await confirm(question))) { 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
+ }
327
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 }) {
328
281
  console.log(`\n🚀 Creating ${name}...`);
329
282
  let res;
330
283
  try {
@@ -339,10 +292,11 @@ async function createCommand(args) {
339
292
  if (!res.ok) {
340
293
  let msg = '';
341
294
  try { msg = (await res.json()).error; } catch {}
295
+ if (res.status === 400 && /already exists/i.test(msg || '')) return { exists: true };
342
296
  if (res.status === 401) fail(`The sign-in for ${acct.label} is no longer valid. Run: nukebase login --email ${acct.label}`);
343
- // No plan, or no credit: both are fixed on the dashboard.
344
- if (res.status === 403 && /plan|funds|credit|wallet|balance/i.test(msg)) {
345
- 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}/`);
346
300
  }
347
301
  fail(`Create failed (${res.status}): ${msg || res.statusText}`);
348
302
  }
@@ -355,55 +309,139 @@ async function createCommand(args) {
355
309
  let base = {};
356
310
  try { base = readConfig(dest); } catch {}
357
311
  writeConfig(dest, newConfig({ api, uid: acct.uid, project: name, account: acct.label }, base));
358
- console.log(`✓ Downloaded ${count} files into ./${name}`);
312
+ console.log(`✓ Downloaded ${count} files into ${path.relative(process.cwd(), dest) || 'this folder'}`);
359
313
 
360
- if (!args.has('no-install') && fs.existsSync(path.join(dest, 'package.json'))) {
314
+ if (install && fs.existsSync(path.join(dest, 'package.json'))) {
361
315
  console.log('\n📦 npm install\n');
362
316
  const r = spawnSync('npm', ['install'], { cwd: dest, stdio: 'inherit', shell: process.platform === 'win32' });
363
- 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}.`);
364
318
  }
319
+ return { ok: true, url: `https://${acct.uid}-${name}.${hostOf(api)}` };
320
+ }
365
321
 
366
- 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}`);
367
372
  console.log(`\n cd ${name}`);
368
373
  console.log(' nukebase push deploy your changes');
369
374
  }
370
375
 
371
376
  // ---- start ----------------------------------------------------------------
372
- // The one command a new user runs after signing up: sign in if needed, then
373
- // 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.
374
384
  // (Inside a project folder `start` means restart; bin/nukebase.js routes it.)
375
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
+
376
397
  async function startCommand(args) {
377
- const autoYes = args.has('yes');
378
398
  const { parse } = require('./args');
379
399
  const passFlags = [];
380
400
  for (const f of ['api', 'region']) if (args.get(f)) passFlags.push(`--${f}`, args.get(f));
381
- if (autoYes) passFlags.push('--yes');
382
- if (args.has('no-install')) passFlags.push('--no-install');
383
401
 
402
+ const emailArg = args.positional.find(a => a.includes('@')) || args.get('as');
384
403
  console.log('\n Welcome to NukeBase.');
385
- const emailArg = args.positional.find(a => a.includes('@'));
386
- if (!creds.list().length || (emailArg && !creds.find(emailArg))) {
387
- 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.');
388
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');
389
411
  }
390
-
391
- console.log(`\n ${creds.list().length ? 'Step 2 of 2: ' : ''}create your project`);
392
- let name = args.positional.find(a => !a.includes('@') && PROJECT_NAME_RE.test(a));
393
- if (!name) {
394
- if (autoYes || !process.stdin.isTTY) fail('Give the project a name, e.g.: nukebase start myapp');
395
- for (;;) {
396
- name = (await makeAskLine(false)('\n Project name (lowercase, e.g. myapp): ')).trim().toLowerCase();
397
- if (!name) fail('No name given.');
398
- if (PROJECT_NAME_RE.test(name)) break;
399
- console.log(' 3-31 characters: lowercase letters, numbers, - and _, starting with a letter.');
400
- }
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;
401
435
  }
402
- const rest = args.positional.filter(a => a !== name);
403
- await createCommand(parse(['create', name, ...rest, ...passFlags]));
404
-
405
- console.log('\n Building with Claude Code? Open the folder and describe your app.');
406
- 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)');
407
445
  }
408
446
 
409
447
  // ---- link -----------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nukebase",
3
- "version": "2.0.3",
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"