nukebase 2.0.1 → 2.0.3
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 +49 -4
- package/lib/login.js +54 -16
- 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,37 @@ async function chooseAccount(args, hint, autoYes) {
|
|
|
197
200
|
return picked;
|
|
198
201
|
}
|
|
199
202
|
|
|
203
|
+
// ---- plan -----------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
const FREE_TIER = 'spark';
|
|
206
|
+
|
|
207
|
+
// The account's plan, starting the free plan first when it has none. An
|
|
208
|
+
// account whose paid plan was cancelled is left alone: that's a choice for
|
|
209
|
+
// the dashboard. Returns the plan name, or null if it couldn't be told.
|
|
210
|
+
async function ensurePlan(api, acct) {
|
|
211
|
+
const call = async (path) => {
|
|
212
|
+
const r = await fetch(`${api}${path}`, {
|
|
213
|
+
method: 'POST',
|
|
214
|
+
headers: { 'Content-Type': 'application/json', Cookie: cookieFor(acct) },
|
|
215
|
+
body: '{}'
|
|
216
|
+
});
|
|
217
|
+
let data = {};
|
|
218
|
+
try { data = await r.json(); } catch {}
|
|
219
|
+
return { r, data };
|
|
220
|
+
};
|
|
221
|
+
let who;
|
|
222
|
+
try { who = await call('/cli/whoami'); } catch { return null; }
|
|
223
|
+
if (who.r.status === 401) fail(`The sign-in for ${acct.label} is no longer valid. Run: nukebase login --email ${acct.label}`);
|
|
224
|
+
if (!who.r.ok) return null;
|
|
225
|
+
if (who.data.plan || who.data.canceled) return who.data.plan || null;
|
|
226
|
+
|
|
227
|
+
const started = await call('/start-free').catch(() => null);
|
|
228
|
+
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.`);
|
|
230
|
+
console.log(` Upgrade any time at ${api}/`);
|
|
231
|
+
return 'free';
|
|
232
|
+
}
|
|
233
|
+
|
|
200
234
|
// ---- create ---------------------------------------------------------------
|
|
201
235
|
|
|
202
236
|
// "256mb", "256", "1gb", "1g", "512 MB" -> megabytes; null when not a size.
|
|
@@ -247,6 +281,11 @@ async function createCommand(args) {
|
|
|
247
281
|
const api = apiFor(args, acct.api);
|
|
248
282
|
const host = hostOf(api);
|
|
249
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
|
+
|
|
250
289
|
let catalog;
|
|
251
290
|
try {
|
|
252
291
|
const r = await fetch(`${api}/tiers`);
|
|
@@ -262,6 +301,11 @@ async function createCommand(args) {
|
|
|
262
301
|
if (sizeArg && !tier) {
|
|
263
302
|
fail(`Unknown size "${sizeArg}". One of: ${order.map(k => `${catalog.tiers[k].memoryHigh.replace(/M$/, 'mb').replace(/G$/, 'gb').toLowerCase()} (${k})`).join(', ')}`);
|
|
264
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
|
+
}
|
|
265
309
|
if (!tier) {
|
|
266
310
|
if (autoYes || !process.stdin.isTTY) {
|
|
267
311
|
tier = def;
|
|
@@ -275,10 +319,11 @@ async function createCommand(args) {
|
|
|
275
319
|
}
|
|
276
320
|
|
|
277
321
|
console.log(`\n Project ${name}`);
|
|
278
|
-
console.log(` Size ${tierLabel(catalog, tier)}`);
|
|
322
|
+
console.log(` Size ${tierLabel(catalog, tier)}${isFree ? ' (free plan: paid from your $10 monthly credit)' : ''}`);
|
|
279
323
|
console.log(` Account ${describe(acct)}`);
|
|
280
324
|
console.log(` Region ${host}`);
|
|
281
|
-
|
|
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; }
|
|
282
327
|
|
|
283
328
|
console.log(`\n🚀 Creating ${name}...`);
|
|
284
329
|
let res;
|
package/lib/login.js
CHANGED
|
@@ -31,7 +31,13 @@ function page(mark, title, message, accent) {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
// A one-shot server on 127.0.0.1 for the browser to come back to. Resolves
|
|
34
|
-
// with
|
|
34
|
+
// with { query, reply } once a callback arrives carrying the right state.
|
|
35
|
+
// The browser's request is held open until the caller calls reply(), so the
|
|
36
|
+
// browser only lands on "signed in" once the CLI really has its token:
|
|
37
|
+
// reply({ redirect }) send the browser on (the dashboard)
|
|
38
|
+
// reply({ error }) show what went wrong
|
|
39
|
+
// reply() a plain "signed in, close this tab" page
|
|
40
|
+
// If reply() never comes, the page is sent after 30 seconds anyway.
|
|
35
41
|
function startLoopback(state) {
|
|
36
42
|
return new Promise((resolveStart, rejectStart) => {
|
|
37
43
|
let finish;
|
|
@@ -44,14 +50,30 @@ function startLoopback(state) {
|
|
|
44
50
|
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
45
51
|
return res.end(page('✕', 'Sign-in error', 'This sign-in could not be verified. Return to your terminal and try again.', '#dc2626'));
|
|
46
52
|
}
|
|
47
|
-
const denied = q.error === 'access_denied';
|
|
48
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
49
|
-
res.end(denied
|
|
50
|
-
? page('✕', 'Cancelled', 'The CLI was not signed in. You can close this tab.', '#64748b')
|
|
51
|
-
: page('✓', 'CLI signed in', 'You can close this tab and return to your terminal.', '#16a34a'));
|
|
52
53
|
clearTimeout(timer);
|
|
53
54
|
server.close();
|
|
54
|
-
|
|
55
|
+
if (q.error === 'access_denied') {
|
|
56
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
57
|
+
res.end(page('✕', 'Cancelled', 'The CLI was not signed in. You can close this tab.', '#64748b'));
|
|
58
|
+
return finish.res({ query: q, reply: () => {} });
|
|
59
|
+
}
|
|
60
|
+
let sent = false;
|
|
61
|
+
const reply = ({ redirect, error } = {}) => {
|
|
62
|
+
if (sent) return;
|
|
63
|
+
sent = true;
|
|
64
|
+
clearTimeout(fallback);
|
|
65
|
+
if (redirect) {
|
|
66
|
+
res.writeHead(302, { Location: redirect });
|
|
67
|
+
return res.end();
|
|
68
|
+
}
|
|
69
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
70
|
+
res.end(error
|
|
71
|
+
? page('✕', 'Sign-in failed', `${String(error).replace(/[<>&"]/g, '')} Return to your terminal.`, '#dc2626')
|
|
72
|
+
: page('✓', 'CLI signed in', 'You can close this tab and return to your terminal.', '#16a34a'));
|
|
73
|
+
};
|
|
74
|
+
const fallback = setTimeout(() => reply(), 30 * 1000);
|
|
75
|
+
fallback.unref();
|
|
76
|
+
finish.res({ query: q, reply });
|
|
55
77
|
});
|
|
56
78
|
const timer = setTimeout(() => { server.close(); finish.rej(new Error('No response within 5 minutes. Run nukebase login again.')); }, LOGIN_TIMEOUT_MS);
|
|
57
79
|
server.on('error', rejectStart);
|
|
@@ -104,13 +126,20 @@ async function browser(api, { email } = {}) {
|
|
|
104
126
|
console.log(`\n Still waiting. No account yet? Sign up at ${api}/start.html first, then run this again.`);
|
|
105
127
|
}, 90 * 1000);
|
|
106
128
|
hint.unref();
|
|
107
|
-
const q = await result.finally(() => clearTimeout(hint));
|
|
129
|
+
const { query: q, reply } = await result.finally(() => clearTimeout(hint));
|
|
108
130
|
if (q.error) throw new Error('Sign-in was cancelled in the browser.');
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
+
}
|
|
114
143
|
}
|
|
115
144
|
|
|
116
145
|
async function device(api) {
|
|
@@ -156,9 +185,18 @@ async function emailLink(api, email) {
|
|
|
156
185
|
console.log(`\n ✉ Sign-in link sent to ${email} (if it has a NukeBase account).`);
|
|
157
186
|
console.log(' Open it in a browser on this computer.');
|
|
158
187
|
console.log('\n Waiting for the link to be clicked… (expires in 5 minutes)');
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
188
|
+
// This browser has no dashboard session (the link signed in the CLI, not
|
|
189
|
+
// the browser), so it gets the plain "signed in" page rather than a redirect.
|
|
190
|
+
const { query: q, reply } = await result;
|
|
191
|
+
try {
|
|
192
|
+
if (!q.uid || !q.token) throw new Error('The sign-in link did not return a session.');
|
|
193
|
+
const out = await exchange(api, q.uid, q.token);
|
|
194
|
+
reply();
|
|
195
|
+
return out;
|
|
196
|
+
} catch (e) {
|
|
197
|
+
reply({ error: e.message });
|
|
198
|
+
throw e;
|
|
199
|
+
}
|
|
162
200
|
}
|
|
163
201
|
|
|
164
202
|
async function password(api, username, pass) {
|