nukebase 2.1.1 → 2.2.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.
package/README.md CHANGED
@@ -18,9 +18,9 @@ signed in at once; each project folder knows which account owns it, so you can
18
18
  deploy to different accounts from different terminals at the same time.
19
19
 
20
20
  ```
21
- nukebase login approve in your browser
21
+ nukebase login approve in your browser, or enter the code it shows on any device
22
22
  nukebase login --email you@x.com add another account
23
- nukebase login --device from SSH or a machine without a browser
23
+ nukebase login --device only the code (SSH, a VM, no browser)
24
24
  nukebase accounts list signed-in accounts
25
25
  nukebase use you@x.com default for commands outside a project
26
26
  nukebase logout you@x.com sign out one account
package/bin/nukebase.js CHANGED
@@ -37,9 +37,11 @@ New here? Run: npx nukebase start
37
37
  signs you in, creates a project and downloads it, ready to build
38
38
 
39
39
  Accounts
40
- login sign in by approving in your browser
40
+ login sign in: approve in your browser, or enter the
41
+ code it shows on any device (VM / SSH)
41
42
  login --email you@x.com sign in as a specific account (adds it; others stay)
42
- login --device sign in from SSH or a machine without a browser
43
+ login --device only the code (no browser is opened)
44
+ login --browser only the browser (no code)
43
45
  login --email-link sign in with an emailed link instead
44
46
  login --password username + password (the master account)
45
47
  logout <email> sign out one account
@@ -77,6 +79,9 @@ In a project folder (runs as the account that owns the project)
77
79
  domain connect a custom domain, step by step
78
80
  domain <name> [--email e] connect in one go (DNS already set)
79
81
  domain remove disconnect the custom domain
82
+ domain email send the app's emails from the custom domain
83
+ (--root | --sub, --reply-to you@gmail.com, --wait)
84
+ domain email remove go back to sending from nukebase.com
80
85
  logs [--follow] recent log output; --follow streams
81
86
  download [--out f.zip] the project ZIP, exactly as the server has it
82
87
  regiontransfer [region] move this project to another region, step by step
@@ -62,9 +62,14 @@ async function loginCommand(args) {
62
62
  if (!email) email = (await askLine('Email: ')).trim().toLowerCase();
63
63
  if (!email.includes('@')) fail('An email address is required.');
64
64
  out = await login.emailLink(api, email);
65
- } else {
65
+ } else if (args.has('browser')) {
66
66
  method = 'browser';
67
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 });
68
73
  }
69
74
 
70
75
  // Signing in again replaces this machine's token for that account; sign the
@@ -456,6 +461,7 @@ async function startCommand(args) {
456
461
  const { parse } = require('./args');
457
462
  const passFlags = [];
458
463
  for (const f of ['api', 'region']) if (args.get(f)) passFlags.push(`--${f}`, args.get(f));
464
+ for (const f of ['device', 'browser']) if (args.has(f)) passFlags.push(`--${f}`);
459
465
 
460
466
  const emailArg = args.positional.find(a => a.includes('@')) || args.get('as');
461
467
  console.log('\n Welcome to NukeBase.');
@@ -471,7 +477,9 @@ async function startCommand(args) {
471
477
  : (creds.getDefault() || list[0]);
472
478
  }
473
479
  if (!acct) {
474
- console.log('\n Confirm your Claude session: click Confirm in the browser window that opens.');
480
+ console.log(args.has('device')
481
+ ? '\n Sign in: open the link below on any device and enter the code.'
482
+ : '\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.');
475
483
  await loginCommand(parse(['login', ...(emailArg ? ['--email', emailArg] : []), ...passFlags]));
476
484
  acct = emailArg ? creds.find(emailArg) : (creds.getDefault() || creds.list()[0]);
477
485
  if (!acct) fail('Sign-in did not finish. Run: npx nukebase start');
package/lib/args.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // `--name=value` works too.
7
7
 
8
8
  const VALUE_FLAGS = new Set([
9
- 'region', 'api', 'as', 'tier', 'streams', 'out', 'email', 'to', 'include', 'name'
9
+ 'region', 'api', 'as', 'tier', 'streams', 'out', 'email', 'to', 'include', 'name', 'reply-to'
10
10
  ]);
11
11
 
12
12
  const SHORT = { y: 'yes', f: 'follow', h: 'help', v: 'version' };
package/lib/login.js CHANGED
@@ -4,6 +4,9 @@
4
4
  // browser open /cli-auth.html, approve, a one-time code comes back to a
5
5
  // local server, swapped (with a PKCE secret) for the token
6
6
  // device a short code to type on the website, for SSH / no browser
7
+ // browserOrDevice both at once, whichever is approved first: the default,
8
+ // so a VM or SSH session (where a browser elsewhere cannot reach
9
+ // this machine's 127.0.0.1) can still sign in
7
10
  // emailLink the email sign-in link, delivered back to a local server
8
11
  // password username + password (the master account)
9
12
  //
@@ -76,8 +79,11 @@ function startLoopback(state) {
76
79
  finish.res({ query: q, reply });
77
80
  });
78
81
  const timer = setTimeout(() => { server.close(); finish.rej(new Error('No response within 5 minutes. Run nukebase login again.')); }, LOGIN_TIMEOUT_MS);
82
+ // Stop waiting (the device code won instead). Settling an already
83
+ // settled promise is a no-op, so this is safe after a callback too.
84
+ const close = () => { clearTimeout(timer); server.close(); finish.rej(new Error('Cancelled')); };
79
85
  server.on('error', rejectStart);
80
- server.listen(0, '127.0.0.1', () => resolveStart({ port: server.address().port, result }));
86
+ server.listen(0, '127.0.0.1', () => resolveStart({ port: server.address().port, result, close }));
81
87
  });
82
88
  }
83
89
 
@@ -102,11 +108,13 @@ function needsNewerServer(r, api) {
102
108
  }
103
109
  }
104
110
 
105
- async function browser(api, { email } = {}) {
111
+ // Start a browser sign-in: the loopback server and the approval URL. finish()
112
+ // waits for the approval and swaps its code for the token; close() gives up.
113
+ async function browserStart(api, { email } = {}) {
106
114
  const state = b64url(crypto.randomBytes(24));
107
115
  const verifier = b64url(crypto.randomBytes(32));
108
116
  const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
109
- const { port, result } = await startLoopback(state);
117
+ const { port, result, close } = await startLoopback(state);
110
118
 
111
119
  const url = new URL(`${api}/cli-auth.html`);
112
120
  url.searchParams.set('port', String(port));
@@ -115,56 +123,110 @@ async function browser(api, { email } = {}) {
115
123
  url.searchParams.set('device', deviceName());
116
124
  if (email) url.searchParams.set('email', email);
117
125
 
126
+ async function finish() {
127
+ // The usual reason for a long wait: no account yet, so no sign-in email
128
+ // ever arrives (the server doesn't say which addresses have accounts).
129
+ const hint = setTimeout(() => {
130
+ console.log(`\n Still waiting. No account yet? Sign up at ${api}/start.html first, then run this again.`);
131
+ }, 90 * 1000);
132
+ hint.unref();
133
+ const { query: q, reply } = await result.finally(() => clearTimeout(hint));
134
+ if (q.error) throw new Error('Sign-in was cancelled in the browser.');
135
+ try {
136
+ if (!q.code) throw new Error('The browser did not return a sign-in code.');
137
+ const r = await postJson(`${api}/cli/token`, { code: q.code, verifier });
138
+ needsNewerServer(r, api);
139
+ if (!r.ok) throw new Error(r.data.error || `Sign-in failed (${r.status})`);
140
+ // Signed in: finish the browser on the dashboard, which says so.
141
+ reply({ redirect: `${api}/?cli=signed-in` });
142
+ return r.data;
143
+ } catch (e) {
144
+ reply({ error: e.message });
145
+ throw e;
146
+ }
147
+ }
148
+ return { url: url.toString(), finish, close };
149
+ }
150
+
151
+ async function browser(api, { email } = {}) {
152
+ const b = await browserStart(api, { email });
118
153
  console.log('\n Opening your browser to approve this CLI. If it doesn\'t open, visit:');
119
- console.log(` ${url}\n`);
154
+ console.log(` ${b.url}\n`);
120
155
  console.log(' Waiting for approval… (Ctrl+C to cancel)');
121
- openBrowser(url.toString());
122
-
123
- // The usual reason for a long wait: no account yet, so no sign-in email
124
- // ever arrives (the server doesn't say which addresses have accounts).
125
- const hint = setTimeout(() => {
126
- console.log(`\n Still waiting. No account yet? Sign up at ${api}/start.html first, then run this again.`);
127
- }, 90 * 1000);
128
- hint.unref();
129
- const { query: q, reply } = await result.finally(() => clearTimeout(hint));
130
- if (q.error) throw new Error('Sign-in was cancelled in the browser.');
131
- try {
132
- if (!q.code) throw new Error('The browser did not return a sign-in code.');
133
- const r = await postJson(`${api}/cli/token`, { code: q.code, verifier });
134
- needsNewerServer(r, api);
135
- if (!r.ok) throw new Error(r.data.error || `Sign-in failed (${r.status})`);
136
- // Signed in: finish the browser on the dashboard, which says so.
137
- reply({ redirect: `${api}/?cli=signed-in` });
138
- return r.data;
139
- } catch (e) {
140
- reply({ error: e.message });
141
- throw e;
142
- }
156
+ openBrowser(b.url);
157
+ return b.finish();
143
158
  }
144
159
 
145
- async function device(api) {
160
+ async function deviceStart(api) {
146
161
  const start = await postJson(`${api}/cli/device-start`, { device: deviceName() });
147
162
  needsNewerServer(start, api);
148
163
  if (!start.ok) throw new Error(start.data.error || `Could not start sign-in (${start.status})`);
149
- const { deviceCode, userCode, verifyUrl, interval = 5, expiresIn = 600 } = start.data;
150
-
151
- console.log('\n On any device, open:');
152
- console.log(` ${verifyUrl}`);
153
- console.log(' and enter this code:\n');
154
- console.log(` ${userCode}\n`);
155
- console.log(` Waiting for approval… (expires in ${Math.round(expiresIn / 60)} minutes)`);
164
+ return start.data;
165
+ }
156
166
 
167
+ // Poll until the code is approved. `stop.cancelled` ends it quietly (the
168
+ // browser won instead), resolving null.
169
+ async function devicePoll(api, { deviceCode, interval = 5, expiresIn = 600 }, stop = {}) {
157
170
  let wait = Math.max(interval, 2) * 1000;
158
171
  const deadline = Date.now() + expiresIn * 1000;
159
172
  while (Date.now() < deadline) {
160
- await new Promise(r => setTimeout(r, wait));
173
+ await new Promise(r => setTimeout(r, wait).unref());
174
+ if (stop.cancelled) return null;
161
175
  const r = await postJson(`${api}/cli/device-poll`, { deviceCode });
162
- if (r.status === 410) throw new Error('The code expired. Run nukebase login --device again.');
176
+ if (stop.cancelled) return null;
177
+ if (r.status === 410) throw new Error('The code expired. Run nukebase login again.');
163
178
  if (!r.ok) throw new Error(r.data.error || `Sign-in failed (${r.status})`);
164
179
  if (r.data.status === 'slow_down') { wait += 1000; continue; }
165
180
  if (r.data.status === 'approved') return { uid: r.data.uid, token: r.data.token, label: r.data.label };
166
181
  }
167
- throw new Error('The code expired. Run nukebase login --device again.');
182
+ throw new Error('The code expired. Run nukebase login again.');
183
+ }
184
+
185
+ async function device(api) {
186
+ const d = await deviceStart(api);
187
+ console.log('\n On any device, open:');
188
+ console.log(` ${d.verifyUrl}`);
189
+ console.log(' and enter this code:\n');
190
+ console.log(` ${d.userCode}\n`);
191
+ console.log(` Waiting for approval… (expires in ${Math.round((d.expiresIn || 600) / 60)} minutes)`);
192
+ return devicePoll(api, d);
193
+ }
194
+
195
+ // The default sign-in: open the browser AND show a device code, and take
196
+ // whichever is approved first. On a laptop the browser wins as before; on a
197
+ // VM or over SSH there is no browser here, and a browser elsewhere cannot
198
+ // reach this machine's 127.0.0.1, so the code is the way in. A server too old
199
+ // for device codes gets the browser alone, exactly as before.
200
+ async function browserOrDevice(api, { email } = {}) {
201
+ let d = null;
202
+ try { d = await deviceStart(api); } catch {}
203
+ if (!d || !d.deviceCode) return browser(api, { email });
204
+
205
+ const b = await browserStart(api, { email });
206
+ console.log('\n Opening your browser to approve this CLI. If it doesn\'t open, visit:');
207
+ console.log(` ${b.url}\n`);
208
+ console.log(' No browser on this machine (a VM, SSH, a container)? On any phone or computer, open');
209
+ console.log(` ${d.verifyUrl}`);
210
+ console.log(` and enter the code ${d.userCode}\n`);
211
+ console.log(' Waiting for approval… (Ctrl+C to cancel)');
212
+ openBrowser(b.url);
213
+
214
+ const stop = { cancelled: false };
215
+ const viaBrowser = b.finish();
216
+ const viaDevice = devicePoll(api, d, stop).then((out) => out || new Promise(() => {}));
217
+ // A failed leg (the browser's 5-minute wait running out, say) must not end
218
+ // the sign-in while the other can still be approved: Promise.any settles on
219
+ // the first success and fails only when both have failed.
220
+ try {
221
+ return await Promise.any([viaBrowser, viaDevice]);
222
+ } catch (e) {
223
+ const first = (e && e.errors && e.errors.find(x => x && x.message !== 'Cancelled')) || e;
224
+ throw first;
225
+ } finally {
226
+ stop.cancelled = true;
227
+ viaBrowser.catch(() => {});
228
+ b.close();
229
+ }
168
230
  }
169
231
 
170
232
  // Swap an ordinary session for a CLI token, then sign the session out.
@@ -208,4 +270,4 @@ async function password(api, username, pass) {
208
270
  return { ...out, label: out.label || username };
209
271
  }
210
272
 
211
- module.exports = { browser, device, emailLink, password, exchange, deviceName };
273
+ module.exports = { browser, browserOrDevice, device, emailLink, password, exchange, deviceName };
@@ -854,6 +854,7 @@ async function deleteProject() {
854
854
  // nukebase domain guided: DNS records, wait, connect
855
855
  // nukebase domain <name> [--email you@x] one shot, for scripts (no waiting)
856
856
  // nukebase domain remove disconnect it
857
+ // nukebase domain email [remove] send the app's emails from it
857
858
  //
858
859
  // The guided flow is the dashboard's modal in the terminal: it prints the two
859
860
  // A records and the ownership TXT (nukebase.<domain> = nukebase-uid=<uid>,
@@ -878,6 +879,11 @@ async function domainCommand() {
878
879
  process.exit(0);
879
880
  }
880
881
 
882
+ if (sub === 'email') {
883
+ await emailDomainCommand(args.positional[1] || null);
884
+ process.exit(0);
885
+ }
886
+
881
887
  const email = args.get('email') || (String(accountLabel).includes('@') ? accountLabel : null);
882
888
 
883
889
  // One shot: name given, no hand-holding. Scripts and people who already
@@ -892,6 +898,7 @@ async function domainCommand() {
892
898
  await waitForDns(sub, ip, needTxt);
893
899
  }
894
900
  await connectDomain(sub, email);
901
+ if (args.has('email-domain')) await emailDomainCommand(null);
895
902
  process.exit(0);
896
903
  }
897
904
 
@@ -923,6 +930,13 @@ async function domainCommand() {
923
930
 
924
931
  // Step 3 — the server does the real verification and issues the cert.
925
932
  await connectDomain(domain, email);
933
+
934
+ // Optional: the app's emails from this domain too.
935
+ if (args.has('email-domain') || await confirm(`\n✉️ Send this app's emails from ${domain} too (instead of nukebase.com)? (y/n) `)) {
936
+ await emailDomainCommand(null);
937
+ } else {
938
+ console.log(' Later: nukebase domain email');
939
+ }
926
940
  process.exit(0);
927
941
 
928
942
  async function currentDomain() {
@@ -931,6 +945,106 @@ async function domainCommand() {
931
945
  }
932
946
  }
933
947
 
948
+ // nukebase domain email [--root | --sub] [--reply-to you@gmail.com] [--wait]
949
+ // nukebase domain email remove
950
+ //
951
+ // Send the app's emails (sign-in links today) from its custom domain:
952
+ // login@theirapp.com, replies to --reply-to. The sending domain is either the
953
+ // custom domain itself (--root) or mail.<domain> (--sub; safest when the
954
+ // domain already has email). Prints the two DNS records to add, then waits
955
+ // for the mail provider to verify them (Enter to check now, Ctrl+C to leave
956
+ // it: the server keeps checking every 10 minutes for a week). Run again to
957
+ // see the status, change the Reply-To, or switch between --root and --sub.
958
+ async function emailDomainCommand(action) {
959
+ if (action === 'remove') {
960
+ if (!(await confirm(`Stop sending ${project}'s emails from its domain? They go back to nukebase.com. (y/n) `))) {
961
+ console.log('Aborted.'); process.exit(1);
962
+ }
963
+ await projectPost('/remove-email-domain', {}, { label: 'email remove' });
964
+ console.log('✓ Email sending domain removed. Emails are sent from nukebase.com again.');
965
+ return;
966
+ }
967
+
968
+ const check = await projectPost('/email-domain-check', {}, { label: 'email' });
969
+ const domain = check.domain;
970
+ const current = check.current;
971
+ const wantRoot = args.has('root') ? true : args.has('sub') ? false : null;
972
+ const replyTo = args.get('reply-to');
973
+ const changing = wantRoot !== null && (!current || current.root !== wantRoot);
974
+
975
+ let rec = current;
976
+ if (!current || changing || replyTo !== undefined) {
977
+ let root = wantRoot;
978
+ if (root === null) root = current ? current.root : null;
979
+ if (root === null) {
980
+ const suggestRoot = check.suggested === 'root';
981
+ console.log(`\n✉️ Send ${project}'s emails from ${domain}\n`);
982
+ console.log(` 1) login@${domain}`);
983
+ console.log(` ${check.hasEmail ? 'This domain already has email set up' + (check.spf ? ' (you will update its SPF record)' : '') + '.' : 'Recommended: this domain has no email set up yet.'}`);
984
+ console.log(` 2) login@${domain}, sent via mail.${domain}`);
985
+ console.log(' Safest if you already use email on this domain: its records are not touched.\n');
986
+ if (process.stdin.isTTY && !AUTO_YES) {
987
+ const a = (await askLine(` Choose 1 or 2 [${suggestRoot ? 1 : 2}]: `)).trim();
988
+ root = a === '1' ? true : a === '2' ? false : suggestRoot;
989
+ } else {
990
+ root = suggestRoot;
991
+ }
992
+ }
993
+ let reply = replyTo;
994
+ if (reply === undefined && !current && process.stdin.isTTY && !AUTO_YES) {
995
+ reply = (await askLine(' Where should replies go? (e.g. you@gmail.com, Enter to skip): ')).trim();
996
+ }
997
+ const body = { root };
998
+ if (typeof reply === 'string') body.replyTo = reply;
999
+ rec = (await projectPost('/email-domain', body, { label: 'email' })).emailDomain;
1000
+ }
1001
+
1002
+ printEmailRecords(rec);
1003
+ if (rec.status === 'verified') return;
1004
+ if (!args.has('wait') && (!process.stdin.isTTY || AUTO_YES)) {
1005
+ console.log(' The server checks every 10 minutes. See the status with: nukebase domain email');
1006
+ return;
1007
+ }
1008
+ await waitForEmailDomain();
1009
+ }
1010
+
1011
+ function printEmailRecords(rec) {
1012
+ const ok = rec.status === 'verified';
1013
+ console.log(`\n Sending domain: ${rec.name} ${ok ? `✓ verified — emails go out as login@${rec.fromDomain || rec.name}` : '… waiting for DNS'}`);
1014
+ if (rec.replyTo) console.log(` Replies go to: ${rec.replyTo}`);
1015
+ if (ok) { console.log(''); return; }
1016
+ console.log('\n At your registrar, add these DNS records:\n');
1017
+ for (const r of rec.records || []) {
1018
+ console.log(` ${r.valid ? '✓' : '…'} TXT ${r.host}`);
1019
+ if (r.replace) console.log(' REPLACE your existing SPF record (only one is allowed) with:');
1020
+ console.log(` ${r.value}\n`);
1021
+ }
1022
+ }
1023
+
1024
+ async function waitForEmailDomain() {
1025
+ console.log(' Waiting for verification. Enter to check now, Ctrl+C to stop (the server keeps checking).\n');
1026
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1027
+ let poke = null;
1028
+ rl.on('line', () => { if (poke) poke(); });
1029
+ const wait = (ms) => new Promise(res => { poke = res; setTimeout(res, ms); });
1030
+ try {
1031
+ for (;;) {
1032
+ const r = await projectPost('/email-domain-status', {}, { label: 'email status' });
1033
+ const rec = r.emailDomain;
1034
+ if (!rec) { console.log('\n The sending domain is gone. Run: nukebase domain email'); return; }
1035
+ const marks = (rec.records || []).map(x => `${x.valid ? '✓' : '…'} ${x.kind.toUpperCase()}`).join(' ');
1036
+ process.stdout.write(`\r ${marks} `);
1037
+ if (rec.status === 'verified') {
1038
+ console.log(`\n\n✓ Verified. ${project}'s emails now go out as login@${rec.fromDomain || rec.name}.`);
1039
+ return;
1040
+ }
1041
+ await wait(20000);
1042
+ }
1043
+ } finally {
1044
+ rl.close();
1045
+ }
1046
+ }
1047
+
934
1048
  async function connectDomain(domain, email) {
935
1049
  console.log(`🔐 Step 3 — verifying DNS from the server and issuing SSL for ${domain} (up to a minute)...`);
936
1050
  const r = await projectPost('/custom-domain', email ? { domain, email } : { domain }, {
@@ -1190,7 +1304,7 @@ async function regionTransfer() {
1190
1304
  if (!acct) {
1191
1305
  console.log(`\n🔑 Signing in to ${targetHost} as ${toEmail}...`);
1192
1306
  try {
1193
- const out = await loginFlows.browser(targetApi, { email: toEmail });
1307
+ const out = await loginFlows.browserOrDevice(targetApi, { email: toEmail });
1194
1308
  creds.put(out.uid, { label: out.label, api: targetApi, token: out.token, method: 'browser', created: Date.now(), lastUsed: Date.now() });
1195
1309
  acct = creds.get(out.uid);
1196
1310
  } catch (e) {
@@ -1289,6 +1403,14 @@ async function regionTransfer() {
1289
1403
  if (r.status !== 0) {
1290
1404
  console.error(`\n⚠️ The domain was not connected on ${target}. It still works on ${source}.`);
1291
1405
  console.error(` Finish later with: nukebase domain ${customDomain} --region ${target} --wait${asArgs.length ? ' ' + asArgs.join(' ') : ''}`);
1406
+ } else if (node.emailDomain && node.emailDomain.name) {
1407
+ // Same sending domain on the target: the mail provider already has it
1408
+ // verified (its DNS records do not change), so the target adopts it.
1409
+ const em = node.emailDomain;
1410
+ const emArgs = ['domain', 'email', em.root ? '--root' : '--sub', '--region', target, '--yes', ...asArgs];
1411
+ if (em.replyTo) emArgs.push('--reply-to', em.replyTo);
1412
+ const e = spawnSync(process.execPath, [BIN, ...emArgs], { stdio: 'inherit', cwd: root });
1413
+ if (e.status !== 0) console.error(` Email sending: finish later with: nukebase domain email --region ${target}${asArgs.length ? ' ' + asArgs.join(' ') : ''}`);
1292
1414
  }
1293
1415
  } else {
1294
1416
  console.log(` Skipped. ${customDomain} stays on ${source} for now. When ready:`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nukebase",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "description": "NukeBase CLI: create, deploy and manage NukeBase projects",
5
5
  "bin": {
6
6
  "nukebase": "bin/nukebase.js"