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.
- package/lib/account-commands.js +131 -93
- package/package.json +1 -1
package/lib/account-commands.js
CHANGED
|
@@ -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 (
|
|
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
|
|
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 ${
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|
344
|
-
if (res.status === 403 && /plan|funds|credit|wallet|balance/i.test(msg)) {
|
|
345
|
-
fail(`${msg}\n
|
|
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
|
|
312
|
+
console.log(`✓ Downloaded ${count} files into ${path.relative(process.cwd(), dest) || 'this folder'}`);
|
|
359
313
|
|
|
360
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
|
373
|
-
//
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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 ${
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
console.log(
|
|
406
|
-
console.log(
|
|
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 -----------------------------------------------------------------
|