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 +3 -1
- package/lib/account-commands.js +164 -81
- package/package.json +1 -1
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
|
-
|
|
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 {
|
package/lib/account-commands.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 ${
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
299
|
-
if (res.status === 403 && /plan|funds|credit|wallet|balance/i.test(msg)) {
|
|
300
|
-
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}/`);
|
|
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
|
|
312
|
+
console.log(`✓ Downloaded ${count} files into ${path.relative(process.cwd(), dest) || 'this folder'}`);
|
|
314
313
|
|
|
315
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
|
328
|
-
//
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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 ${
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
console.log(
|
|
361
|
-
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)');
|
|
362
445
|
}
|
|
363
446
|
|
|
364
447
|
// ---- link -----------------------------------------------------------------
|