nukebase 2.2.2 → 2.3.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/bin/nukebase.js +2 -0
- package/lib/account-commands.js +627 -627
- package/lib/guide.js +4 -2
- package/lib/project-commands.js +2601 -2487
- package/lib/storage.js +227 -0
- package/package.json +1 -1
package/lib/account-commands.js
CHANGED
|
@@ -1,627 +1,627 @@
|
|
|
1
|
-
// Commands that work with or without a project folder: signing accounts in
|
|
2
|
-
// and out, choosing the default, and making or linking projects.
|
|
3
|
-
|
|
4
|
-
const fs = require('fs');
|
|
5
|
-
const path = require('path');
|
|
6
|
-
const { spawnSync } = require('child_process');
|
|
7
|
-
const creds = require('./credentials');
|
|
8
|
-
const login = require('./login');
|
|
9
|
-
const unzip = require('./unzip');
|
|
10
|
-
const { findProject, hasConfig, readConfig, writeConfig, newConfig } = require('./project');
|
|
11
|
-
const { hostOf, apiFor, makeConfirm, makeAskLine, askHidden } = require('./ui');
|
|
12
|
-
|
|
13
|
-
const PROJECT_NAME_RE = /^[a-z][a-z0-9_-]{2,30}$/;
|
|
14
|
-
|
|
15
|
-
const cookieFor = (acct) => `uid=${acct.uid}; token=${acct.token}`;
|
|
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.
|
|
21
|
-
function fail(message) {
|
|
22
|
-
throw new Error(message);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function describe(acct) {
|
|
26
|
-
return `${acct.label || acct.uid} (${acct.uid})`;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// The project in the current folder, if any, without its secrets mattering.
|
|
30
|
-
function currentProject() {
|
|
31
|
-
const root = findProject(process.cwd());
|
|
32
|
-
if (!root) return null;
|
|
33
|
-
try { return { root, config: readConfig(root) }; } catch { return null; }
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// ---- login ----------------------------------------------------------------
|
|
37
|
-
|
|
38
|
-
async function loginCommand(args) {
|
|
39
|
-
const askLine = makeAskLine(false);
|
|
40
|
-
const here = currentProject();
|
|
41
|
-
const api = apiFor(args, here?.config.API || creds.getDefault()?.api);
|
|
42
|
-
|
|
43
|
-
// `nukebase login` inside a project whose account isn't signed in asks for
|
|
44
|
-
// that account, so you don't have to remember which email it was.
|
|
45
|
-
let email = args.get('email') || args.positional.find(a => a.includes('@')) || null;
|
|
46
|
-
if (!email && here && here.config.uid && !creds.get(here.config.uid) && String(here.config.account || '').includes('@')) {
|
|
47
|
-
email = here.config.account;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
let out, method;
|
|
51
|
-
if (args.has('password')) {
|
|
52
|
-
method = 'password';
|
|
53
|
-
const username = args.positional.find(a => !a.includes('@')) || (await askLine('Username: ')).trim();
|
|
54
|
-
const pass = await askHidden('Password: ');
|
|
55
|
-
if (!username || !pass) fail('Username and password are required.');
|
|
56
|
-
out = await login.password(api, username, pass);
|
|
57
|
-
} else if (args.has('device')) {
|
|
58
|
-
method = 'device';
|
|
59
|
-
out = await login.device(api);
|
|
60
|
-
} else if (args.has('email-link')) {
|
|
61
|
-
method = 'email-link';
|
|
62
|
-
if (!email) email = (await askLine('Email: ')).trim().toLowerCase();
|
|
63
|
-
if (!email.includes('@')) fail('An email address is required.');
|
|
64
|
-
out = await login.emailLink(api, email);
|
|
65
|
-
} else if (args.has('browser')) {
|
|
66
|
-
method = 'browser';
|
|
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 });
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Signing in again replaces this machine's token for that account; sign the
|
|
76
|
-
// old one out on the server rather than leave it valid and forgotten.
|
|
77
|
-
const previous = creds.get(out.uid);
|
|
78
|
-
creds.put(out.uid, { label: out.label, api, token: out.token, method, created: Date.now(), lastUsed: Date.now() });
|
|
79
|
-
if (previous && previous.token && previous.token !== out.token) await revoke(previous);
|
|
80
|
-
|
|
81
|
-
console.log(`\n✓ Signed in as ${describe(out)} on ${hostOf(api)}`);
|
|
82
|
-
if (email && String(out.label).toLowerCase() !== email.toLowerCase()) {
|
|
83
|
-
console.log(` Note: you asked for ${email} but approved as ${out.label}.`);
|
|
84
|
-
}
|
|
85
|
-
const n = creds.list().length;
|
|
86
|
-
if (n > 1) console.log(` ${n} accounts signed in on this machine. List them with: nukebase accounts`);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// ---- logout ---------------------------------------------------------------
|
|
90
|
-
|
|
91
|
-
async function revoke(acct) {
|
|
92
|
-
try {
|
|
93
|
-
const r = await fetch(`${acct.api}/cli/revoke`, {
|
|
94
|
-
method: 'POST',
|
|
95
|
-
headers: { 'Content-Type': 'application/json', Cookie: cookieFor(acct) },
|
|
96
|
-
body: '{}'
|
|
97
|
-
});
|
|
98
|
-
return r.ok || r.status === 401; // 401: already signed out
|
|
99
|
-
} catch {
|
|
100
|
-
return false;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// `nukebase logout <email|uid>` signs out that account; plain `nukebase
|
|
105
|
-
// logout` signs out every account on this machine.
|
|
106
|
-
async function logoutCommand(args) {
|
|
107
|
-
const target = args.positional[0];
|
|
108
|
-
let accounts;
|
|
109
|
-
if (target) {
|
|
110
|
-
const acct = creds.find(target);
|
|
111
|
-
if (!acct) fail(`${target} isn't signed in on this machine. See: nukebase accounts`);
|
|
112
|
-
accounts = [acct];
|
|
113
|
-
} else {
|
|
114
|
-
accounts = creds.list();
|
|
115
|
-
if (!accounts.length) { console.log('No accounts are signed in.'); return; }
|
|
116
|
-
}
|
|
117
|
-
for (const acct of accounts) {
|
|
118
|
-
const ok = await revoke(acct);
|
|
119
|
-
creds.remove(acct.uid);
|
|
120
|
-
console.log(`✓ Signed out ${describe(acct)}${ok ? '' : ` (couldn't reach ${hostOf(acct.api)}; sign it out from the dashboard's Signed-in devices too)`}`);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// ---- accounts / use / whoami ----------------------------------------------
|
|
125
|
-
|
|
126
|
-
function relative(ms) {
|
|
127
|
-
if (!ms) return 'never';
|
|
128
|
-
const days = Math.floor((Date.now() - ms) / 86400000);
|
|
129
|
-
return days <= 0 ? 'today' : days === 1 ? 'yesterday' : `${days} days ago`;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function accountsCommand() {
|
|
133
|
-
const list = creds.list();
|
|
134
|
-
if (!list.length) { console.log('No accounts are signed in. Run: nukebase login'); return; }
|
|
135
|
-
const w = Math.max(...list.map(a => String(a.label || '').length), 7);
|
|
136
|
-
console.log('');
|
|
137
|
-
for (const a of list) {
|
|
138
|
-
console.log(` ${a.isDefault ? '*' : ' '} ${String(a.label || '').padEnd(w)} ${a.uid} ${hostOf(a.api)} last used ${relative(a.lastUsed)}`);
|
|
139
|
-
}
|
|
140
|
-
console.log(`\n * default, for commands run outside a project (nukebase use <email> to change)`);
|
|
141
|
-
console.log(` Inside a project, the account that owns it is used automatically.\n`);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function useCommand(args) {
|
|
145
|
-
const q = args.positional[0];
|
|
146
|
-
if (!q) fail('Usage: nukebase use <email|uid>');
|
|
147
|
-
const acct = creds.find(q);
|
|
148
|
-
if (!acct) fail(`${q} isn't signed in on this machine. Run: nukebase login --email ${q}`);
|
|
149
|
-
creds.setDefault(acct.uid);
|
|
150
|
-
console.log(`✓ Default account: ${describe(acct)}`);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
async function whoamiCommand() {
|
|
154
|
-
const here = currentProject();
|
|
155
|
-
if (here && here.config.uid) {
|
|
156
|
-
const acct = creds.get(here.config.uid);
|
|
157
|
-
const name = here.config.project || path.basename(here.root);
|
|
158
|
-
if (!acct) {
|
|
159
|
-
console.log(`${name} belongs to ${here.config.account || 'uid'} (${here.config.uid}), which isn't signed in on this machine.`);
|
|
160
|
-
console.log(`Run: nukebase login${String(here.config.account || '').includes('@') ? ` --email ${here.config.account}` : ''}`);
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
console.log(`${name} belongs to ${describe(acct)} on ${hostOf(here.config.API)}`);
|
|
164
|
-
console.log(await check(acct, here.config.API));
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
const acct = creds.getDefault();
|
|
168
|
-
if (!acct) { console.log(creds.list().length ? 'No default account. Set one with: nukebase use <email>' : 'Not signed in. Run: nukebase login'); return; }
|
|
169
|
-
console.log(`Default account: ${describe(acct)} on ${hostOf(acct.api)}`);
|
|
170
|
-
console.log(await check(acct, acct.api));
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
async function check(acct, api) {
|
|
174
|
-
try {
|
|
175
|
-
const r = await fetch(`${api}/cli/whoami`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookieFor(acct) }, body: '{}' });
|
|
176
|
-
if (r.ok) return '✓ Signed in';
|
|
177
|
-
if (r.status === 401) return `✗ This sign-in was revoked. Run: nukebase login${String(acct.label).includes('@') ? ` --email ${acct.label}` : ''}`;
|
|
178
|
-
return `? ${hostOf(api)} answered ${r.status}`;
|
|
179
|
-
} catch {
|
|
180
|
-
return `? Couldn't reach ${hostOf(api)}`;
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// ---- choosing an account for create / link ----------------------------------
|
|
185
|
-
|
|
186
|
-
async function chooseAccount(args, hint, autoYes) {
|
|
187
|
-
const q = args.get('as') || hint;
|
|
188
|
-
if (q) {
|
|
189
|
-
const acct = creds.find(q);
|
|
190
|
-
if (!acct) fail(`${q} isn't signed in on this machine. Run: nukebase login --email ${q}`);
|
|
191
|
-
return acct;
|
|
192
|
-
}
|
|
193
|
-
const list = creds.list();
|
|
194
|
-
if (!list.length) fail('Not signed in. Run: nukebase login');
|
|
195
|
-
if (list.length === 1) return list[0];
|
|
196
|
-
// Creating a project is billed to the account, so never guess between several.
|
|
197
|
-
if (autoYes || !process.stdin.isTTY) {
|
|
198
|
-
fail(`Several accounts are signed in. Say which one, e.g.: nukebase ${args.command} ${args.positional[0] || '<name>'} ${list[0].label}`);
|
|
199
|
-
}
|
|
200
|
-
console.log('\n Which account?');
|
|
201
|
-
list.forEach((a, i) => console.log(` ${i + 1}) ${a.label} (${a.uid})${a.isDefault ? ' default' : ''}`));
|
|
202
|
-
const answer = (await makeAskLine(false)('\n Account (number or email): ')).trim();
|
|
203
|
-
const picked = /^\d+$/.test(answer) ? list[Number(answer) - 1] : creds.find(answer);
|
|
204
|
-
if (!picked) fail('No account chosen.');
|
|
205
|
-
return picked;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// ---- plan -----------------------------------------------------------------
|
|
209
|
-
|
|
210
|
-
// The account's plan, starting the free plan first when it has none. An
|
|
211
|
-
// account whose paid plan was cancelled is left alone: that's a choice for
|
|
212
|
-
// the dashboard. Returns the plan name, or null if it couldn't be told.
|
|
213
|
-
async function ensurePlan(api, acct) {
|
|
214
|
-
const call = async (path) => {
|
|
215
|
-
const r = await fetch(`${api}${path}`, {
|
|
216
|
-
method: 'POST',
|
|
217
|
-
headers: { 'Content-Type': 'application/json', Cookie: cookieFor(acct) },
|
|
218
|
-
body: '{}'
|
|
219
|
-
});
|
|
220
|
-
let data = {};
|
|
221
|
-
try { data = await r.json(); } catch {}
|
|
222
|
-
return { r, data };
|
|
223
|
-
};
|
|
224
|
-
let who;
|
|
225
|
-
try { who = await call('/cli/whoami'); } catch { return null; }
|
|
226
|
-
if (who.r.status === 401) fail(`The sign-in for ${acct.label} is no longer valid. Run: nukebase login --email ${acct.label}`);
|
|
227
|
-
if (!who.r.ok) return null;
|
|
228
|
-
if (who.data.plan || who.data.canceled) return who.data.plan || null;
|
|
229
|
-
|
|
230
|
-
const started = await call('/start-free').catch(() => null);
|
|
231
|
-
if (!started || !started.r.ok) return null; // the create below says why
|
|
232
|
-
console.log(`\n✓ You're on the free plan: $${started.data.credit || 10} of credit each month, any number of apps at any size, 1 GB storage.`);
|
|
233
|
-
console.log(` Upgrade any time at ${api}/`);
|
|
234
|
-
return 'free';
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// ---- create ---------------------------------------------------------------
|
|
238
|
-
|
|
239
|
-
// "256mb", "256", "1gb", "1g", "512 MB" -> megabytes; null when not a size.
|
|
240
|
-
function sizeToMb(text) {
|
|
241
|
-
const m = /^(\d+(?:\.\d+)?)\s*(m|mb|g|gb)?$/i.exec(String(text).trim());
|
|
242
|
-
if (!m) return null;
|
|
243
|
-
const n = Number(m[1]);
|
|
244
|
-
return /^g/i.test(m[2] || '') ? n * 1024 : n;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// A size the user typed -> tier key. Matches the RAM each tier buys
|
|
248
|
-
// (memoryHigh) or the tier's name.
|
|
249
|
-
function resolveTier(input, catalog) {
|
|
250
|
-
const tiers = catalog.tiers || {};
|
|
251
|
-
const key = String(input).toLowerCase();
|
|
252
|
-
if (tiers[key]) return key;
|
|
253
|
-
const want = sizeToMb(input);
|
|
254
|
-
if (want === null) return null;
|
|
255
|
-
return (catalog.order || Object.keys(tiers)).find(k => sizeToMb(tiers[k].memoryHigh) === want) || null;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
function tierLabel(catalog, key) {
|
|
259
|
-
const t = catalog.tiers[key];
|
|
260
|
-
const mb = sizeToMb(t.memoryHigh);
|
|
261
|
-
const ram = mb >= 1024 ? `${mb / 1024} GB` : `${mb} MB`;
|
|
262
|
-
return `${t.name}, ${ram} RAM, $${(t.monthlyCents / 100).toFixed(0)}/mo`;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
async function loadCatalog(api) {
|
|
266
|
-
try {
|
|
267
|
-
const r = await fetch(`${api}/tiers`);
|
|
268
|
-
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
269
|
-
const catalog = await r.json();
|
|
270
|
-
catalog.order = catalog.order || Object.keys(catalog.tiers);
|
|
271
|
-
catalog.default = catalog.default || catalog.order[0];
|
|
272
|
-
return catalog;
|
|
273
|
-
} catch (e) {
|
|
274
|
-
fail(`Could not reach ${hostOf(api)} (${e.cause?.message || e.message}).`);
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
function sizeList(catalog) {
|
|
279
|
-
return catalog.order.map(k => `${catalog.tiers[k].memoryHigh.replace(/M$/, 'mb').replace(/G$/, 'gb').toLowerCase()} (${k})`).join(', ');
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// A project's package.json must not be named "nukebase": npm resolves
|
|
283
|
-
// `npx nukebase` to the current project when the names match, and a project
|
|
284
|
-
// has no executable, so every command inside it fails with "could not
|
|
285
|
-
// determine executable to run". Older server templates shipped that name;
|
|
286
|
-
// the project's own name (a valid npm name) replaces it. Returns true when
|
|
287
|
-
// it changed something.
|
|
288
|
-
function namePackage(dir, name) {
|
|
289
|
-
const pkgPath = path.join(dir, 'package.json');
|
|
290
|
-
try {
|
|
291
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
292
|
-
if (pkg.name !== 'nukebase' && pkg.name !== 'nukebase-app') return false;
|
|
293
|
-
pkg.name = name;
|
|
294
|
-
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
295
|
-
return true;
|
|
296
|
-
} catch { return false; }
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// Read a download to the end, showing how much has arrived: one line that
|
|
300
|
-
// updates in a terminal, or a line every 10 MB when the output goes to a file
|
|
301
|
-
// or an AI agent (where \r updates would pile up). The archives are streamed
|
|
302
|
-
// without a length, so it shows MB so far, not a percentage.
|
|
303
|
-
async function readWithProgress(res, label) {
|
|
304
|
-
const tty = process.stdout.isTTY;
|
|
305
|
-
const mb = (n) => (n / 1048576).toFixed(1);
|
|
306
|
-
const chunks = [];
|
|
307
|
-
let got = 0, lastShown = 0, lastLine = 0;
|
|
308
|
-
const show = (final) => {
|
|
309
|
-
if (tty) process.stdout.write(`\r ${label}… ${mb(got)} MB${final ? '\n' : ''}`);
|
|
310
|
-
else if (final || got - lastLine >= 10 * 1048576) { console.log(` ${label}… ${mb(got)} MB`); lastLine = got; }
|
|
311
|
-
};
|
|
312
|
-
if (!res.body || typeof res.body.getReader !== 'function') {
|
|
313
|
-
const buf = Buffer.from(await res.arrayBuffer());
|
|
314
|
-
got = buf.length; show(true);
|
|
315
|
-
return buf;
|
|
316
|
-
}
|
|
317
|
-
const reader = res.body.getReader();
|
|
318
|
-
try {
|
|
319
|
-
for (;;) {
|
|
320
|
-
const { done, value } = await reader.read();
|
|
321
|
-
if (done) break;
|
|
322
|
-
chunks.push(Buffer.from(value));
|
|
323
|
-
got += value.length;
|
|
324
|
-
if (Date.now() - lastShown > 250) { lastShown = Date.now(); show(false); }
|
|
325
|
-
}
|
|
326
|
-
} catch (e) {
|
|
327
|
-
// End the half-drawn progress line, so whatever is printed next starts clean.
|
|
328
|
-
if (tty && got) process.stdout.write('\n');
|
|
329
|
-
throw e;
|
|
330
|
-
}
|
|
331
|
-
show(true);
|
|
332
|
-
return Buffer.concat(chunks);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// The project's files as the server has them, without node_modules (an
|
|
336
|
-
// `npm install` follows; servers too old for ?skip send them anyway). One
|
|
337
|
-
// retry, since this runs right after a dropped connection.
|
|
338
|
-
async function downloadExisting({ acct, api, name }) {
|
|
339
|
-
const headers = { 'X-Project': name, Cookie: cookieFor(acct) };
|
|
340
|
-
const url = `${api}/download-project?project=${encodeURIComponent(name)}&skip=node_modules`;
|
|
341
|
-
let lastErr = null;
|
|
342
|
-
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
343
|
-
try {
|
|
344
|
-
const dl = await fetch(url, { headers });
|
|
345
|
-
if (!dl.ok) { lastErr = new Error(`the server answered ${dl.status}`); continue; }
|
|
346
|
-
return await readWithProgress(dl, `Downloading ${name}`);
|
|
347
|
-
} catch (e) {
|
|
348
|
-
lastErr = e;
|
|
349
|
-
if (attempt === 1) console.log(' (download interrupted, trying again)');
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
fail(`${name} exists but could not be downloaded (${lastErr.cause?.message || lastErr.message}).\n` +
|
|
353
|
-
` In an empty folder, run: nukebase link ${name} then: nukebase pull --yes then: npm install`);
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
// Create `name` on the server and unpack it into `dest`. When the account
|
|
357
|
-
// already has a project by that name: with `reuse`, that project is
|
|
358
|
-
// downloaded into `dest` instead (`start`: running it again finishes the
|
|
359
|
-
// job rather than making name-2); without, returns { exists: true }. Any
|
|
360
|
-
// other failure stops the command.
|
|
361
|
-
async function createProject({ acct, api, name, tier, dest, install, reuse = false }) {
|
|
362
|
-
console.log(`\n🚀 Creating ${name}...`);
|
|
363
|
-
const headers = (extra = {}) => ({ ...extra, 'X-Project': name, Cookie: cookieFor(acct) });
|
|
364
|
-
|
|
365
|
-
// The connection dropped while the server was setting up. That is not the
|
|
366
|
-
// same as "not created": setup ends with an nginx reload, and a reload
|
|
367
|
-
// sends GOAWAY on an open HTTP/2 connection, which Node's fetch reports as
|
|
368
|
-
// a failed request after the project already exists. (Older servers reload
|
|
369
|
-
// before streaming the ZIP; current ones reload after.) So check, and if it
|
|
370
|
-
// is there, fetch its files the other way rather than leaving a project
|
|
371
|
-
// behind and trying the next name.
|
|
372
|
-
async function recover(err) {
|
|
373
|
-
const why = err.cause?.message || err.message;
|
|
374
|
-
let probe = null;
|
|
375
|
-
try {
|
|
376
|
-
probe = await fetch(`${api}/sync`, {
|
|
377
|
-
method: 'POST',
|
|
378
|
-
headers: headers({ 'Content-Type': 'application/json' }),
|
|
379
|
-
body: JSON.stringify({ exclude: [], files: {}, direction: 'pull', symlinks: false })
|
|
380
|
-
});
|
|
381
|
-
} catch {}
|
|
382
|
-
if (!probe || probe.status === 404) {
|
|
383
|
-
fail(`Create failed: ${why}. Check the dashboard before retrying; it may have been created.`);
|
|
384
|
-
}
|
|
385
|
-
if (!probe.ok) fail(`Create failed: ${why} (and the server answered ${probe.status} when asked whether ${name} exists).`);
|
|
386
|
-
console.log(` (the connection dropped while the server finished setting up ${name} — it exists, downloading it)`);
|
|
387
|
-
return downloadExisting({ acct, api, name });
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
let res = null, zip = null;
|
|
391
|
-
try {
|
|
392
|
-
res = await fetch(`${api}/project`, {
|
|
393
|
-
method: 'POST',
|
|
394
|
-
headers: headers({ 'Content-Type': 'application/json' }),
|
|
395
|
-
body: JSON.stringify({ tier })
|
|
396
|
-
});
|
|
397
|
-
} catch (e) {
|
|
398
|
-
zip = await recover(e);
|
|
399
|
-
}
|
|
400
|
-
if (res && !res.ok) {
|
|
401
|
-
let msg = '';
|
|
402
|
-
try { msg = (await res.json()).error; } catch {}
|
|
403
|
-
const taken = res.status === 400 && /already exists/i.test(msg || '');
|
|
404
|
-
if (taken && !reuse) return { exists: true };
|
|
405
|
-
if (taken) {
|
|
406
|
-
console.log(` ${name} already exists in your account, so downloading it here instead of making a new one.`);
|
|
407
|
-
zip = await downloadExisting({ acct, api, name });
|
|
408
|
-
} else {
|
|
409
|
-
if (res.status === 401) fail(`The sign-in for ${acct.label} is no longer valid. Run: nukebase login --email ${acct.label}`);
|
|
410
|
-
// No plan or no credit: fixed on the dashboard.
|
|
411
|
-
if (res.status === 403 && /plan|funds|credit|wallet|balance|upgrade/i.test(msg)) {
|
|
412
|
-
fail(`${msg}\n Manage your plan at ${api}/`);
|
|
413
|
-
}
|
|
414
|
-
fail(`Create failed (${res.status}): ${msg || res.statusText}`);
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
// The server finishes setup as the ZIP finishes streaming, so read it all.
|
|
419
|
-
// A drop here is the same case as above.
|
|
420
|
-
if (!zip) {
|
|
421
|
-
try { zip = await readWithProgress(res, `Downloading ${name}`); }
|
|
422
|
-
catch (e) { zip = await recover(e); }
|
|
423
|
-
}
|
|
424
|
-
fs.mkdirSync(dest, { recursive: true });
|
|
425
|
-
const count = unzip.extract(zip, dest);
|
|
426
|
-
|
|
427
|
-
let base = {};
|
|
428
|
-
try { base = readConfig(dest); } catch {}
|
|
429
|
-
writeConfig(dest, newConfig({ api, uid: acct.uid, project: name, account: acct.label }, base));
|
|
430
|
-
namePackage(dest, name);
|
|
431
|
-
console.log(`✓ Downloaded ${count} files into ${path.relative(process.cwd(), dest) || 'this folder'}`);
|
|
432
|
-
|
|
433
|
-
if (install && fs.existsSync(path.join(dest, 'package.json'))) {
|
|
434
|
-
console.log('\n📦 Installing packages (npm install)…\n');
|
|
435
|
-
// One command string under a shell: npm is npm.cmd on Windows, which
|
|
436
|
-
// needs a shell, and Node 26 deprecates passing separate args with one.
|
|
437
|
-
const r = spawnSync('npm install', [], { cwd: dest, stdio: 'inherit', shell: true });
|
|
438
|
-
if (r.status !== 0) console.log(`\n⚠️ npm install failed. Run it yourself in ${dest}.`);
|
|
439
|
-
}
|
|
440
|
-
return { ok: true, url: `https://${acct.uid}-${name}.${hostOf(api)}` };
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
async function createCommand(args) {
|
|
444
|
-
const autoYes = args.has('yes');
|
|
445
|
-
const confirm = makeConfirm(autoYes);
|
|
446
|
-
const askLine = makeAskLine(autoYes);
|
|
447
|
-
|
|
448
|
-
const name = args.positional[0];
|
|
449
|
-
if (!name) fail('Usage: nukebase create <name> [size] [email] e.g. nukebase create demoapp123 256mb you@example.com');
|
|
450
|
-
if (!PROJECT_NAME_RE.test(name)) fail('Project names are 3-31 characters: lowercase letters, numbers, - and _, starting with a letter.');
|
|
451
|
-
|
|
452
|
-
// After the name, in any order: something with an @ is the account; the
|
|
453
|
-
// rest is the size.
|
|
454
|
-
const rest = args.positional.slice(1);
|
|
455
|
-
const emailArg = rest.find(a => a.includes('@')) || null;
|
|
456
|
-
const sizeArg = args.get('tier') || rest.find(a => !a.includes('@')) || null;
|
|
457
|
-
|
|
458
|
-
const dest = path.resolve(process.cwd(), name);
|
|
459
|
-
if (fs.existsSync(dest) && fs.readdirSync(dest).length) fail(`The folder ${name} already exists and isn't empty.`);
|
|
460
|
-
|
|
461
|
-
const acct = await chooseAccount(args, emailArg, autoYes);
|
|
462
|
-
const api = apiFor(args, acct.api);
|
|
463
|
-
|
|
464
|
-
// Every account starts free: one with no plan is put on the free plan here.
|
|
465
|
-
const isFree = (await ensurePlan(api, acct)) === 'free';
|
|
466
|
-
const catalog = await loadCatalog(api);
|
|
467
|
-
|
|
468
|
-
let tier = sizeArg ? resolveTier(sizeArg, catalog) : null;
|
|
469
|
-
if (sizeArg && !tier) fail(`Unknown size "${sizeArg}". One of: ${sizeList(catalog)}`);
|
|
470
|
-
if (!tier) {
|
|
471
|
-
if (autoYes || !process.stdin.isTTY || isFree) {
|
|
472
|
-
tier = catalog.default;
|
|
473
|
-
} else {
|
|
474
|
-
console.log('\n Sizes:');
|
|
475
|
-
catalog.order.forEach((k, i) => console.log(` ${i + 1}) ${tierLabel(catalog, k)}${k === catalog.default ? ' (default)' : ''}`));
|
|
476
|
-
const answer = (await askLine(`\n Size [${catalog.default}]: `)).trim();
|
|
477
|
-
tier = !answer ? catalog.default : /^\d+$/.test(answer) ? catalog.order[Number(answer) - 1] : resolveTier(answer, catalog);
|
|
478
|
-
if (!tier) fail(`Unknown size "${answer}".`);
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
console.log(`\n Project ${name}`);
|
|
483
|
-
console.log(` Size ${tierLabel(catalog, tier)}${isFree ? ' (paid from your $10 monthly credit)' : ''}`);
|
|
484
|
-
console.log(` Account ${describe(acct)}`);
|
|
485
|
-
console.log(` Region ${hostOf(api)}`);
|
|
486
|
-
const question = isFree ? '\nCreate it? (y/n) ' : '\nCreate it? It is billed to this account. (y/n) ';
|
|
487
|
-
if (!(await confirm(question))) { console.log('Aborted. Nothing was created.'); return; }
|
|
488
|
-
|
|
489
|
-
const out = await createProject({ acct, api, name, tier, dest, install: !args.has('no-install') });
|
|
490
|
-
if (out.exists) fail(`You already have a project named ${name}. Pick another name, or link to it: nukebase link ${name}`);
|
|
491
|
-
|
|
492
|
-
console.log(`\n✅ ${name} is live at ${out.url}`);
|
|
493
|
-
console.log(`\n cd ${name}`);
|
|
494
|
-
console.log(' nukebase push deploy your changes');
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
// ---- start ----------------------------------------------------------------
|
|
498
|
-
// The one command a new user runs, usually by pasting it into Claude Code:
|
|
499
|
-
// `npx nukebase start [name] [size] [email]`. It asks nothing. It signs in
|
|
500
|
-
// if needed (one click in the browser), puts a new account on the free
|
|
501
|
-
// plan, picks a name (from the folder, else "myapp", adding -2, -3... if
|
|
502
|
-
// taken), creates the project at the smallest size unless one is given,
|
|
503
|
-
// downloads it into this folder when it's empty (else a subfolder), and
|
|
504
|
-
// installs it. An AI agent can run it without any back and forth.
|
|
505
|
-
// (Inside a project folder `start` means restart; bin/nukebase.js routes it.)
|
|
506
|
-
|
|
507
|
-
// Empty apart from dotfiles (.claude, .git, .vscode): a folder made for this.
|
|
508
|
-
function isEmptyFolder(dir) {
|
|
509
|
-
try { return fs.readdirSync(dir).every(n => n.startsWith('.')); } catch { return false; }
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
// A folder name as a project name, or null: "My Recipes" -> "my-recipes".
|
|
513
|
-
function nameFromFolder(dir) {
|
|
514
|
-
const slug = path.basename(dir).toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^[^a-z]+/, '').replace(/-+$/, '').slice(0, 31);
|
|
515
|
-
return PROJECT_NAME_RE.test(slug) ? slug : null;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
async function startCommand(args) {
|
|
519
|
-
const { parse } = require('./args');
|
|
520
|
-
const passFlags = [];
|
|
521
|
-
for (const f of ['api', 'region']) if (args.get(f)) passFlags.push(`--${f}`, args.get(f));
|
|
522
|
-
for (const f of ['device', 'browser']) if (args.has(f)) passFlags.push(`--${f}`);
|
|
523
|
-
|
|
524
|
-
const emailArg = args.positional.find(a => a.includes('@')) || args.get('as');
|
|
525
|
-
console.log('\n Welcome to NukeBase.');
|
|
526
|
-
|
|
527
|
-
// With several accounts signed in, ask — creating a project bills the
|
|
528
|
-
// account, so never guess between them. Only interactively: an agent (no
|
|
529
|
-
// terminal) or --yes gets the default, as before.
|
|
530
|
-
let acct = emailArg ? creds.find(emailArg) : null;
|
|
531
|
-
if (!acct && !emailArg) {
|
|
532
|
-
const list = creds.list();
|
|
533
|
-
acct = (list.length > 1 && process.stdin.isTTY && !args.has('yes'))
|
|
534
|
-
? await chooseAccount(args, null, false)
|
|
535
|
-
: (creds.getDefault() || list[0]);
|
|
536
|
-
}
|
|
537
|
-
if (!acct) {
|
|
538
|
-
console.log(args.has('device')
|
|
539
|
-
? '\n Sign in: open the link below on any device and enter the code.'
|
|
540
|
-
: '\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.');
|
|
541
|
-
await loginCommand(parse(['login', ...(emailArg ? ['--email', emailArg] : []), ...passFlags]));
|
|
542
|
-
acct = emailArg ? creds.find(emailArg) : (creds.getDefault() || creds.list()[0]);
|
|
543
|
-
if (!acct) fail('Sign-in did not finish. Run: npx nukebase start');
|
|
544
|
-
}
|
|
545
|
-
const api = apiFor(args, acct.api);
|
|
546
|
-
console.log(`\n Signed in as ${describe(acct)}`);
|
|
547
|
-
|
|
548
|
-
const isFree = (await ensurePlan(api, acct)) === 'free';
|
|
549
|
-
const catalog = await loadCatalog(api);
|
|
550
|
-
const sizeArg = args.get('tier') || args.positional.find(a => !a.includes('@') && resolveTier(a, catalog));
|
|
551
|
-
const tier = sizeArg ? resolveTier(sizeArg, catalog) : catalog.default;
|
|
552
|
-
|
|
553
|
-
const here = process.cwd();
|
|
554
|
-
const intoHere = isEmptyFolder(here);
|
|
555
|
-
const given = args.positional.find(a => !a.includes('@') && PROJECT_NAME_RE.test(a) && !resolveTier(a, catalog));
|
|
556
|
-
const base = given || (intoHere && nameFromFolder(here)) || 'myapp';
|
|
557
|
-
// One name. If the account already has a project by it, that project is
|
|
558
|
-
// downloaded here rather than a name-2 being created: running start again
|
|
559
|
-
// after a hiccup finishes the job.
|
|
560
|
-
const name = base;
|
|
561
|
-
const dest = intoHere ? here : path.resolve(here, name);
|
|
562
|
-
if (!intoHere && fs.existsSync(dest) && fs.readdirSync(dest).length) {
|
|
563
|
-
fail(`There is already a folder named ${name} here with files in it. Run this in an empty folder, or choose another name: npx nukebase start <name>`);
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
console.log(` Size: ${tierLabel(catalog, tier)}${isFree ? ' (paid from your $10 monthly credit)' : ''}`);
|
|
567
|
-
const created = await createProject({ acct, api, name, tier, dest, install: !args.has('no-install'), reuse: true });
|
|
568
|
-
|
|
569
|
-
// Written for whoever runs this next, person or AI agent.
|
|
570
|
-
console.log(`\n✅ Your app "${name}" is live at ${created.url}`);
|
|
571
|
-
console.log(` Project folder: ${dest}`);
|
|
572
|
-
console.log('\nNext steps (for Claude or you):');
|
|
573
|
-
console.log(` 1. Work in ${dest}. Read CLAUDE.md there first: it explains how NukeBase apps work.`);
|
|
574
|
-
console.log(' 2. Build what the user describes.');
|
|
575
|
-
console.log(' 3. Put it live with: npx nukebase push --yes (run inside the project folder)');
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
// ---- link -----------------------------------------------------------------
|
|
579
|
-
|
|
580
|
-
async function linkCommand(args) {
|
|
581
|
-
const autoYes = args.has('yes');
|
|
582
|
-
const name = args.positional[0];
|
|
583
|
-
if (!name) fail('Usage: nukebase link <project> [email] connects this folder to an existing project');
|
|
584
|
-
const acct = await chooseAccount(args, args.positional.slice(1).find(a => a.includes('@')), autoYes);
|
|
585
|
-
const api = apiFor(args, acct.api);
|
|
586
|
-
|
|
587
|
-
let r;
|
|
588
|
-
try {
|
|
589
|
-
r = await fetch(`${api}/sync`, {
|
|
590
|
-
method: 'POST',
|
|
591
|
-
headers: { 'Content-Type': 'application/json', 'X-Project': name, Cookie: cookieFor(acct) },
|
|
592
|
-
body: JSON.stringify({ exclude: ['sys', '.env', 'node_modules', 'server'], files: {}, direction: 'pull', symlinks: false })
|
|
593
|
-
});
|
|
594
|
-
} catch (e) {
|
|
595
|
-
fail(`Could not reach ${hostOf(api)} (${e.cause?.message || e.message}).`);
|
|
596
|
-
}
|
|
597
|
-
if (r.status === 404) fail(`${acct.label} has no project named ${name} on ${hostOf(api)}.`);
|
|
598
|
-
if (r.status === 401) fail(`The sign-in for ${acct.label} is no longer valid. Run: nukebase login --email ${acct.label}`);
|
|
599
|
-
if (!r.ok) fail(`Could not check the project (${r.status}).`);
|
|
600
|
-
|
|
601
|
-
const root = process.cwd();
|
|
602
|
-
let base = {};
|
|
603
|
-
const existing = hasConfig(root);
|
|
604
|
-
if (existing) {
|
|
605
|
-
try { base = readConfig(root); } catch {}
|
|
606
|
-
if (base.uid && base.uid !== acct.uid && !(await makeConfirm(autoYes)(`This folder belongs to ${base.account || base.uid}. Link it to ${acct.label} instead? (y/n) `))) {
|
|
607
|
-
console.log('Aborted.');
|
|
608
|
-
return;
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
writeConfig(root, newConfig({ api, uid: acct.uid, project: name, account: acct.label }, base));
|
|
612
|
-
console.log(`✓ Linked this folder to ${name} (${describe(acct)}) on ${hostOf(api)}`);
|
|
613
|
-
console.log(' nukebase pull download its files');
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
module.exports = {
|
|
617
|
-
namePackage,
|
|
618
|
-
login: loginCommand,
|
|
619
|
-
logout: logoutCommand,
|
|
620
|
-
accounts: accountsCommand,
|
|
621
|
-
use: useCommand,
|
|
622
|
-
whoami: whoamiCommand,
|
|
623
|
-
create: createCommand,
|
|
624
|
-
start: startCommand,
|
|
625
|
-
link: linkCommand,
|
|
626
|
-
resolveTier, sizeToMb
|
|
627
|
-
};
|
|
1
|
+
// Commands that work with or without a project folder: signing accounts in
|
|
2
|
+
// and out, choosing the default, and making or linking projects.
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { spawnSync } = require('child_process');
|
|
7
|
+
const creds = require('./credentials');
|
|
8
|
+
const login = require('./login');
|
|
9
|
+
const unzip = require('./unzip');
|
|
10
|
+
const { findProject, hasConfig, readConfig, writeConfig, newConfig } = require('./project');
|
|
11
|
+
const { hostOf, apiFor, makeConfirm, makeAskLine, askHidden } = require('./ui');
|
|
12
|
+
|
|
13
|
+
const PROJECT_NAME_RE = /^[a-z][a-z0-9_-]{2,30}$/;
|
|
14
|
+
|
|
15
|
+
const cookieFor = (acct) => `uid=${acct.uid}; token=${acct.token}`;
|
|
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.
|
|
21
|
+
function fail(message) {
|
|
22
|
+
throw new Error(message);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function describe(acct) {
|
|
26
|
+
return `${acct.label || acct.uid} (${acct.uid})`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The project in the current folder, if any, without its secrets mattering.
|
|
30
|
+
function currentProject() {
|
|
31
|
+
const root = findProject(process.cwd());
|
|
32
|
+
if (!root) return null;
|
|
33
|
+
try { return { root, config: readConfig(root) }; } catch { return null; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---- login ----------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
async function loginCommand(args) {
|
|
39
|
+
const askLine = makeAskLine(false);
|
|
40
|
+
const here = currentProject();
|
|
41
|
+
const api = apiFor(args, here?.config.API || creds.getDefault()?.api);
|
|
42
|
+
|
|
43
|
+
// `nukebase login` inside a project whose account isn't signed in asks for
|
|
44
|
+
// that account, so you don't have to remember which email it was.
|
|
45
|
+
let email = args.get('email') || args.positional.find(a => a.includes('@')) || null;
|
|
46
|
+
if (!email && here && here.config.uid && !creds.get(here.config.uid) && String(here.config.account || '').includes('@')) {
|
|
47
|
+
email = here.config.account;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let out, method;
|
|
51
|
+
if (args.has('password')) {
|
|
52
|
+
method = 'password';
|
|
53
|
+
const username = args.positional.find(a => !a.includes('@')) || (await askLine('Username: ')).trim();
|
|
54
|
+
const pass = await askHidden('Password: ');
|
|
55
|
+
if (!username || !pass) fail('Username and password are required.');
|
|
56
|
+
out = await login.password(api, username, pass);
|
|
57
|
+
} else if (args.has('device')) {
|
|
58
|
+
method = 'device';
|
|
59
|
+
out = await login.device(api);
|
|
60
|
+
} else if (args.has('email-link')) {
|
|
61
|
+
method = 'email-link';
|
|
62
|
+
if (!email) email = (await askLine('Email: ')).trim().toLowerCase();
|
|
63
|
+
if (!email.includes('@')) fail('An email address is required.');
|
|
64
|
+
out = await login.emailLink(api, email);
|
|
65
|
+
} else if (args.has('browser')) {
|
|
66
|
+
method = 'browser';
|
|
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 });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Signing in again replaces this machine's token for that account; sign the
|
|
76
|
+
// old one out on the server rather than leave it valid and forgotten.
|
|
77
|
+
const previous = creds.get(out.uid);
|
|
78
|
+
creds.put(out.uid, { label: out.label, api, token: out.token, method, created: Date.now(), lastUsed: Date.now() });
|
|
79
|
+
if (previous && previous.token && previous.token !== out.token) await revoke(previous);
|
|
80
|
+
|
|
81
|
+
console.log(`\n✓ Signed in as ${describe(out)} on ${hostOf(api)}`);
|
|
82
|
+
if (email && String(out.label).toLowerCase() !== email.toLowerCase()) {
|
|
83
|
+
console.log(` Note: you asked for ${email} but approved as ${out.label}.`);
|
|
84
|
+
}
|
|
85
|
+
const n = creds.list().length;
|
|
86
|
+
if (n > 1) console.log(` ${n} accounts signed in on this machine. List them with: nukebase accounts`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---- logout ---------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
async function revoke(acct) {
|
|
92
|
+
try {
|
|
93
|
+
const r = await fetch(`${acct.api}/cli/revoke`, {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: { 'Content-Type': 'application/json', Cookie: cookieFor(acct) },
|
|
96
|
+
body: '{}'
|
|
97
|
+
});
|
|
98
|
+
return r.ok || r.status === 401; // 401: already signed out
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// `nukebase logout <email|uid>` signs out that account; plain `nukebase
|
|
105
|
+
// logout` signs out every account on this machine.
|
|
106
|
+
async function logoutCommand(args) {
|
|
107
|
+
const target = args.positional[0];
|
|
108
|
+
let accounts;
|
|
109
|
+
if (target) {
|
|
110
|
+
const acct = creds.find(target);
|
|
111
|
+
if (!acct) fail(`${target} isn't signed in on this machine. See: nukebase accounts`);
|
|
112
|
+
accounts = [acct];
|
|
113
|
+
} else {
|
|
114
|
+
accounts = creds.list();
|
|
115
|
+
if (!accounts.length) { console.log('No accounts are signed in.'); return; }
|
|
116
|
+
}
|
|
117
|
+
for (const acct of accounts) {
|
|
118
|
+
const ok = await revoke(acct);
|
|
119
|
+
creds.remove(acct.uid);
|
|
120
|
+
console.log(`✓ Signed out ${describe(acct)}${ok ? '' : ` (couldn't reach ${hostOf(acct.api)}; sign it out from the dashboard's Signed-in devices too)`}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---- accounts / use / whoami ----------------------------------------------
|
|
125
|
+
|
|
126
|
+
function relative(ms) {
|
|
127
|
+
if (!ms) return 'never';
|
|
128
|
+
const days = Math.floor((Date.now() - ms) / 86400000);
|
|
129
|
+
return days <= 0 ? 'today' : days === 1 ? 'yesterday' : `${days} days ago`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function accountsCommand() {
|
|
133
|
+
const list = creds.list();
|
|
134
|
+
if (!list.length) { console.log('No accounts are signed in. Run: nukebase login'); return; }
|
|
135
|
+
const w = Math.max(...list.map(a => String(a.label || '').length), 7);
|
|
136
|
+
console.log('');
|
|
137
|
+
for (const a of list) {
|
|
138
|
+
console.log(` ${a.isDefault ? '*' : ' '} ${String(a.label || '').padEnd(w)} ${a.uid} ${hostOf(a.api)} last used ${relative(a.lastUsed)}`);
|
|
139
|
+
}
|
|
140
|
+
console.log(`\n * default, for commands run outside a project (nukebase use <email> to change)`);
|
|
141
|
+
console.log(` Inside a project, the account that owns it is used automatically.\n`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function useCommand(args) {
|
|
145
|
+
const q = args.positional[0];
|
|
146
|
+
if (!q) fail('Usage: nukebase use <email|uid>');
|
|
147
|
+
const acct = creds.find(q);
|
|
148
|
+
if (!acct) fail(`${q} isn't signed in on this machine. Run: nukebase login --email ${q}`);
|
|
149
|
+
creds.setDefault(acct.uid);
|
|
150
|
+
console.log(`✓ Default account: ${describe(acct)}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function whoamiCommand() {
|
|
154
|
+
const here = currentProject();
|
|
155
|
+
if (here && here.config.uid) {
|
|
156
|
+
const acct = creds.get(here.config.uid);
|
|
157
|
+
const name = here.config.project || path.basename(here.root);
|
|
158
|
+
if (!acct) {
|
|
159
|
+
console.log(`${name} belongs to ${here.config.account || 'uid'} (${here.config.uid}), which isn't signed in on this machine.`);
|
|
160
|
+
console.log(`Run: nukebase login${String(here.config.account || '').includes('@') ? ` --email ${here.config.account}` : ''}`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
console.log(`${name} belongs to ${describe(acct)} on ${hostOf(here.config.API)}`);
|
|
164
|
+
console.log(await check(acct, here.config.API));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const acct = creds.getDefault();
|
|
168
|
+
if (!acct) { console.log(creds.list().length ? 'No default account. Set one with: nukebase use <email>' : 'Not signed in. Run: nukebase login'); return; }
|
|
169
|
+
console.log(`Default account: ${describe(acct)} on ${hostOf(acct.api)}`);
|
|
170
|
+
console.log(await check(acct, acct.api));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function check(acct, api) {
|
|
174
|
+
try {
|
|
175
|
+
const r = await fetch(`${api}/cli/whoami`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookieFor(acct) }, body: '{}' });
|
|
176
|
+
if (r.ok) return '✓ Signed in';
|
|
177
|
+
if (r.status === 401) return `✗ This sign-in was revoked. Run: nukebase login${String(acct.label).includes('@') ? ` --email ${acct.label}` : ''}`;
|
|
178
|
+
return `? ${hostOf(api)} answered ${r.status}`;
|
|
179
|
+
} catch {
|
|
180
|
+
return `? Couldn't reach ${hostOf(api)}`;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---- choosing an account for create / link ----------------------------------
|
|
185
|
+
|
|
186
|
+
async function chooseAccount(args, hint, autoYes) {
|
|
187
|
+
const q = args.get('as') || hint;
|
|
188
|
+
if (q) {
|
|
189
|
+
const acct = creds.find(q);
|
|
190
|
+
if (!acct) fail(`${q} isn't signed in on this machine. Run: nukebase login --email ${q}`);
|
|
191
|
+
return acct;
|
|
192
|
+
}
|
|
193
|
+
const list = creds.list();
|
|
194
|
+
if (!list.length) fail('Not signed in. Run: nukebase login');
|
|
195
|
+
if (list.length === 1) return list[0];
|
|
196
|
+
// Creating a project is billed to the account, so never guess between several.
|
|
197
|
+
if (autoYes || !process.stdin.isTTY) {
|
|
198
|
+
fail(`Several accounts are signed in. Say which one, e.g.: nukebase ${args.command} ${args.positional[0] || '<name>'} ${list[0].label}`);
|
|
199
|
+
}
|
|
200
|
+
console.log('\n Which account?');
|
|
201
|
+
list.forEach((a, i) => console.log(` ${i + 1}) ${a.label} (${a.uid})${a.isDefault ? ' default' : ''}`));
|
|
202
|
+
const answer = (await makeAskLine(false)('\n Account (number or email): ')).trim();
|
|
203
|
+
const picked = /^\d+$/.test(answer) ? list[Number(answer) - 1] : creds.find(answer);
|
|
204
|
+
if (!picked) fail('No account chosen.');
|
|
205
|
+
return picked;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---- plan -----------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
// The account's plan, starting the free plan first when it has none. An
|
|
211
|
+
// account whose paid plan was cancelled is left alone: that's a choice for
|
|
212
|
+
// the dashboard. Returns the plan name, or null if it couldn't be told.
|
|
213
|
+
async function ensurePlan(api, acct) {
|
|
214
|
+
const call = async (path) => {
|
|
215
|
+
const r = await fetch(`${api}${path}`, {
|
|
216
|
+
method: 'POST',
|
|
217
|
+
headers: { 'Content-Type': 'application/json', Cookie: cookieFor(acct) },
|
|
218
|
+
body: '{}'
|
|
219
|
+
});
|
|
220
|
+
let data = {};
|
|
221
|
+
try { data = await r.json(); } catch {}
|
|
222
|
+
return { r, data };
|
|
223
|
+
};
|
|
224
|
+
let who;
|
|
225
|
+
try { who = await call('/cli/whoami'); } catch { return null; }
|
|
226
|
+
if (who.r.status === 401) fail(`The sign-in for ${acct.label} is no longer valid. Run: nukebase login --email ${acct.label}`);
|
|
227
|
+
if (!who.r.ok) return null;
|
|
228
|
+
if (who.data.plan || who.data.canceled) return who.data.plan || null;
|
|
229
|
+
|
|
230
|
+
const started = await call('/start-free').catch(() => null);
|
|
231
|
+
if (!started || !started.r.ok) return null; // the create below says why
|
|
232
|
+
console.log(`\n✓ You're on the free plan: $${started.data.credit || 10} of credit each month, any number of apps at any size, 1 GB storage.`);
|
|
233
|
+
console.log(` Upgrade any time at ${api}/`);
|
|
234
|
+
return 'free';
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---- create ---------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
// "256mb", "256", "1gb", "1g", "512 MB" -> megabytes; null when not a size.
|
|
240
|
+
function sizeToMb(text) {
|
|
241
|
+
const m = /^(\d+(?:\.\d+)?)\s*(m|mb|g|gb)?$/i.exec(String(text).trim());
|
|
242
|
+
if (!m) return null;
|
|
243
|
+
const n = Number(m[1]);
|
|
244
|
+
return /^g/i.test(m[2] || '') ? n * 1024 : n;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// A size the user typed -> tier key. Matches the RAM each tier buys
|
|
248
|
+
// (memoryHigh) or the tier's name.
|
|
249
|
+
function resolveTier(input, catalog) {
|
|
250
|
+
const tiers = catalog.tiers || {};
|
|
251
|
+
const key = String(input).toLowerCase();
|
|
252
|
+
if (tiers[key]) return key;
|
|
253
|
+
const want = sizeToMb(input);
|
|
254
|
+
if (want === null) return null;
|
|
255
|
+
return (catalog.order || Object.keys(tiers)).find(k => sizeToMb(tiers[k].memoryHigh) === want) || null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function tierLabel(catalog, key) {
|
|
259
|
+
const t = catalog.tiers[key];
|
|
260
|
+
const mb = sizeToMb(t.memoryHigh);
|
|
261
|
+
const ram = mb >= 1024 ? `${mb / 1024} GB` : `${mb} MB`;
|
|
262
|
+
return `${t.name}, ${ram} RAM${t.diskGb ? `, ${t.diskGb} GB disk` : ''}, $${(t.monthlyCents / 100).toFixed(0)}/mo`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function loadCatalog(api) {
|
|
266
|
+
try {
|
|
267
|
+
const r = await fetch(`${api}/tiers`);
|
|
268
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
269
|
+
const catalog = await r.json();
|
|
270
|
+
catalog.order = catalog.order || Object.keys(catalog.tiers);
|
|
271
|
+
catalog.default = catalog.default || catalog.order[0];
|
|
272
|
+
return catalog;
|
|
273
|
+
} catch (e) {
|
|
274
|
+
fail(`Could not reach ${hostOf(api)} (${e.cause?.message || e.message}).`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function sizeList(catalog) {
|
|
279
|
+
return catalog.order.map(k => `${catalog.tiers[k].memoryHigh.replace(/M$/, 'mb').replace(/G$/, 'gb').toLowerCase()} (${k})`).join(', ');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// A project's package.json must not be named "nukebase": npm resolves
|
|
283
|
+
// `npx nukebase` to the current project when the names match, and a project
|
|
284
|
+
// has no executable, so every command inside it fails with "could not
|
|
285
|
+
// determine executable to run". Older server templates shipped that name;
|
|
286
|
+
// the project's own name (a valid npm name) replaces it. Returns true when
|
|
287
|
+
// it changed something.
|
|
288
|
+
function namePackage(dir, name) {
|
|
289
|
+
const pkgPath = path.join(dir, 'package.json');
|
|
290
|
+
try {
|
|
291
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
292
|
+
if (pkg.name !== 'nukebase' && pkg.name !== 'nukebase-app') return false;
|
|
293
|
+
pkg.name = name;
|
|
294
|
+
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
295
|
+
return true;
|
|
296
|
+
} catch { return false; }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Read a download to the end, showing how much has arrived: one line that
|
|
300
|
+
// updates in a terminal, or a line every 10 MB when the output goes to a file
|
|
301
|
+
// or an AI agent (where \r updates would pile up). The archives are streamed
|
|
302
|
+
// without a length, so it shows MB so far, not a percentage.
|
|
303
|
+
async function readWithProgress(res, label) {
|
|
304
|
+
const tty = process.stdout.isTTY;
|
|
305
|
+
const mb = (n) => (n / 1048576).toFixed(1);
|
|
306
|
+
const chunks = [];
|
|
307
|
+
let got = 0, lastShown = 0, lastLine = 0;
|
|
308
|
+
const show = (final) => {
|
|
309
|
+
if (tty) process.stdout.write(`\r ${label}… ${mb(got)} MB${final ? '\n' : ''}`);
|
|
310
|
+
else if (final || got - lastLine >= 10 * 1048576) { console.log(` ${label}… ${mb(got)} MB`); lastLine = got; }
|
|
311
|
+
};
|
|
312
|
+
if (!res.body || typeof res.body.getReader !== 'function') {
|
|
313
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
314
|
+
got = buf.length; show(true);
|
|
315
|
+
return buf;
|
|
316
|
+
}
|
|
317
|
+
const reader = res.body.getReader();
|
|
318
|
+
try {
|
|
319
|
+
for (;;) {
|
|
320
|
+
const { done, value } = await reader.read();
|
|
321
|
+
if (done) break;
|
|
322
|
+
chunks.push(Buffer.from(value));
|
|
323
|
+
got += value.length;
|
|
324
|
+
if (Date.now() - lastShown > 250) { lastShown = Date.now(); show(false); }
|
|
325
|
+
}
|
|
326
|
+
} catch (e) {
|
|
327
|
+
// End the half-drawn progress line, so whatever is printed next starts clean.
|
|
328
|
+
if (tty && got) process.stdout.write('\n');
|
|
329
|
+
throw e;
|
|
330
|
+
}
|
|
331
|
+
show(true);
|
|
332
|
+
return Buffer.concat(chunks);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// The project's files as the server has them, without node_modules (an
|
|
336
|
+
// `npm install` follows; servers too old for ?skip send them anyway). One
|
|
337
|
+
// retry, since this runs right after a dropped connection.
|
|
338
|
+
async function downloadExisting({ acct, api, name }) {
|
|
339
|
+
const headers = { 'X-Project': name, Cookie: cookieFor(acct) };
|
|
340
|
+
const url = `${api}/download-project?project=${encodeURIComponent(name)}&skip=node_modules`;
|
|
341
|
+
let lastErr = null;
|
|
342
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
343
|
+
try {
|
|
344
|
+
const dl = await fetch(url, { headers });
|
|
345
|
+
if (!dl.ok) { lastErr = new Error(`the server answered ${dl.status}`); continue; }
|
|
346
|
+
return await readWithProgress(dl, `Downloading ${name}`);
|
|
347
|
+
} catch (e) {
|
|
348
|
+
lastErr = e;
|
|
349
|
+
if (attempt === 1) console.log(' (download interrupted, trying again)');
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
fail(`${name} exists but could not be downloaded (${lastErr.cause?.message || lastErr.message}).\n` +
|
|
353
|
+
` In an empty folder, run: nukebase link ${name} then: nukebase pull --yes then: npm install`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Create `name` on the server and unpack it into `dest`. When the account
|
|
357
|
+
// already has a project by that name: with `reuse`, that project is
|
|
358
|
+
// downloaded into `dest` instead (`start`: running it again finishes the
|
|
359
|
+
// job rather than making name-2); without, returns { exists: true }. Any
|
|
360
|
+
// other failure stops the command.
|
|
361
|
+
async function createProject({ acct, api, name, tier, dest, install, reuse = false }) {
|
|
362
|
+
console.log(`\n🚀 Creating ${name}...`);
|
|
363
|
+
const headers = (extra = {}) => ({ ...extra, 'X-Project': name, Cookie: cookieFor(acct) });
|
|
364
|
+
|
|
365
|
+
// The connection dropped while the server was setting up. That is not the
|
|
366
|
+
// same as "not created": setup ends with an nginx reload, and a reload
|
|
367
|
+
// sends GOAWAY on an open HTTP/2 connection, which Node's fetch reports as
|
|
368
|
+
// a failed request after the project already exists. (Older servers reload
|
|
369
|
+
// before streaming the ZIP; current ones reload after.) So check, and if it
|
|
370
|
+
// is there, fetch its files the other way rather than leaving a project
|
|
371
|
+
// behind and trying the next name.
|
|
372
|
+
async function recover(err) {
|
|
373
|
+
const why = err.cause?.message || err.message;
|
|
374
|
+
let probe = null;
|
|
375
|
+
try {
|
|
376
|
+
probe = await fetch(`${api}/sync`, {
|
|
377
|
+
method: 'POST',
|
|
378
|
+
headers: headers({ 'Content-Type': 'application/json' }),
|
|
379
|
+
body: JSON.stringify({ exclude: [], files: {}, direction: 'pull', symlinks: false })
|
|
380
|
+
});
|
|
381
|
+
} catch {}
|
|
382
|
+
if (!probe || probe.status === 404) {
|
|
383
|
+
fail(`Create failed: ${why}. Check the dashboard before retrying; it may have been created.`);
|
|
384
|
+
}
|
|
385
|
+
if (!probe.ok) fail(`Create failed: ${why} (and the server answered ${probe.status} when asked whether ${name} exists).`);
|
|
386
|
+
console.log(` (the connection dropped while the server finished setting up ${name} — it exists, downloading it)`);
|
|
387
|
+
return downloadExisting({ acct, api, name });
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
let res = null, zip = null;
|
|
391
|
+
try {
|
|
392
|
+
res = await fetch(`${api}/project`, {
|
|
393
|
+
method: 'POST',
|
|
394
|
+
headers: headers({ 'Content-Type': 'application/json' }),
|
|
395
|
+
body: JSON.stringify({ tier })
|
|
396
|
+
});
|
|
397
|
+
} catch (e) {
|
|
398
|
+
zip = await recover(e);
|
|
399
|
+
}
|
|
400
|
+
if (res && !res.ok) {
|
|
401
|
+
let msg = '';
|
|
402
|
+
try { msg = (await res.json()).error; } catch {}
|
|
403
|
+
const taken = res.status === 400 && /already exists/i.test(msg || '');
|
|
404
|
+
if (taken && !reuse) return { exists: true };
|
|
405
|
+
if (taken) {
|
|
406
|
+
console.log(` ${name} already exists in your account, so downloading it here instead of making a new one.`);
|
|
407
|
+
zip = await downloadExisting({ acct, api, name });
|
|
408
|
+
} else {
|
|
409
|
+
if (res.status === 401) fail(`The sign-in for ${acct.label} is no longer valid. Run: nukebase login --email ${acct.label}`);
|
|
410
|
+
// No plan or no credit: fixed on the dashboard.
|
|
411
|
+
if (res.status === 403 && /plan|funds|credit|wallet|balance|upgrade/i.test(msg)) {
|
|
412
|
+
fail(`${msg}\n Manage your plan at ${api}/`);
|
|
413
|
+
}
|
|
414
|
+
fail(`Create failed (${res.status}): ${msg || res.statusText}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// The server finishes setup as the ZIP finishes streaming, so read it all.
|
|
419
|
+
// A drop here is the same case as above.
|
|
420
|
+
if (!zip) {
|
|
421
|
+
try { zip = await readWithProgress(res, `Downloading ${name}`); }
|
|
422
|
+
catch (e) { zip = await recover(e); }
|
|
423
|
+
}
|
|
424
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
425
|
+
const count = unzip.extract(zip, dest);
|
|
426
|
+
|
|
427
|
+
let base = {};
|
|
428
|
+
try { base = readConfig(dest); } catch {}
|
|
429
|
+
writeConfig(dest, newConfig({ api, uid: acct.uid, project: name, account: acct.label }, base));
|
|
430
|
+
namePackage(dest, name);
|
|
431
|
+
console.log(`✓ Downloaded ${count} files into ${path.relative(process.cwd(), dest) || 'this folder'}`);
|
|
432
|
+
|
|
433
|
+
if (install && fs.existsSync(path.join(dest, 'package.json'))) {
|
|
434
|
+
console.log('\n📦 Installing packages (npm install)…\n');
|
|
435
|
+
// One command string under a shell: npm is npm.cmd on Windows, which
|
|
436
|
+
// needs a shell, and Node 26 deprecates passing separate args with one.
|
|
437
|
+
const r = spawnSync('npm install', [], { cwd: dest, stdio: 'inherit', shell: true });
|
|
438
|
+
if (r.status !== 0) console.log(`\n⚠️ npm install failed. Run it yourself in ${dest}.`);
|
|
439
|
+
}
|
|
440
|
+
return { ok: true, url: `https://${acct.uid}-${name}.${hostOf(api)}` };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function createCommand(args) {
|
|
444
|
+
const autoYes = args.has('yes');
|
|
445
|
+
const confirm = makeConfirm(autoYes);
|
|
446
|
+
const askLine = makeAskLine(autoYes);
|
|
447
|
+
|
|
448
|
+
const name = args.positional[0];
|
|
449
|
+
if (!name) fail('Usage: nukebase create <name> [size] [email] e.g. nukebase create demoapp123 256mb you@example.com');
|
|
450
|
+
if (!PROJECT_NAME_RE.test(name)) fail('Project names are 3-31 characters: lowercase letters, numbers, - and _, starting with a letter.');
|
|
451
|
+
|
|
452
|
+
// After the name, in any order: something with an @ is the account; the
|
|
453
|
+
// rest is the size.
|
|
454
|
+
const rest = args.positional.slice(1);
|
|
455
|
+
const emailArg = rest.find(a => a.includes('@')) || null;
|
|
456
|
+
const sizeArg = args.get('tier') || rest.find(a => !a.includes('@')) || null;
|
|
457
|
+
|
|
458
|
+
const dest = path.resolve(process.cwd(), name);
|
|
459
|
+
if (fs.existsSync(dest) && fs.readdirSync(dest).length) fail(`The folder ${name} already exists and isn't empty.`);
|
|
460
|
+
|
|
461
|
+
const acct = await chooseAccount(args, emailArg, autoYes);
|
|
462
|
+
const api = apiFor(args, acct.api);
|
|
463
|
+
|
|
464
|
+
// Every account starts free: one with no plan is put on the free plan here.
|
|
465
|
+
const isFree = (await ensurePlan(api, acct)) === 'free';
|
|
466
|
+
const catalog = await loadCatalog(api);
|
|
467
|
+
|
|
468
|
+
let tier = sizeArg ? resolveTier(sizeArg, catalog) : null;
|
|
469
|
+
if (sizeArg && !tier) fail(`Unknown size "${sizeArg}". One of: ${sizeList(catalog)}`);
|
|
470
|
+
if (!tier) {
|
|
471
|
+
if (autoYes || !process.stdin.isTTY || isFree) {
|
|
472
|
+
tier = catalog.default;
|
|
473
|
+
} else {
|
|
474
|
+
console.log('\n Sizes:');
|
|
475
|
+
catalog.order.forEach((k, i) => console.log(` ${i + 1}) ${tierLabel(catalog, k)}${k === catalog.default ? ' (default)' : ''}`));
|
|
476
|
+
const answer = (await askLine(`\n Size [${catalog.default}]: `)).trim();
|
|
477
|
+
tier = !answer ? catalog.default : /^\d+$/.test(answer) ? catalog.order[Number(answer) - 1] : resolveTier(answer, catalog);
|
|
478
|
+
if (!tier) fail(`Unknown size "${answer}".`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
console.log(`\n Project ${name}`);
|
|
483
|
+
console.log(` Size ${tierLabel(catalog, tier)}${isFree ? ' (paid from your $10 monthly credit)' : ''}`);
|
|
484
|
+
console.log(` Account ${describe(acct)}`);
|
|
485
|
+
console.log(` Region ${hostOf(api)}`);
|
|
486
|
+
const question = isFree ? '\nCreate it? (y/n) ' : '\nCreate it? It is billed to this account. (y/n) ';
|
|
487
|
+
if (!(await confirm(question))) { console.log('Aborted. Nothing was created.'); return; }
|
|
488
|
+
|
|
489
|
+
const out = await createProject({ acct, api, name, tier, dest, install: !args.has('no-install') });
|
|
490
|
+
if (out.exists) fail(`You already have a project named ${name}. Pick another name, or link to it: nukebase link ${name}`);
|
|
491
|
+
|
|
492
|
+
console.log(`\n✅ ${name} is live at ${out.url}`);
|
|
493
|
+
console.log(`\n cd ${name}`);
|
|
494
|
+
console.log(' nukebase push deploy your changes');
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ---- start ----------------------------------------------------------------
|
|
498
|
+
// The one command a new user runs, usually by pasting it into Claude Code:
|
|
499
|
+
// `npx nukebase start [name] [size] [email]`. It asks nothing. It signs in
|
|
500
|
+
// if needed (one click in the browser), puts a new account on the free
|
|
501
|
+
// plan, picks a name (from the folder, else "myapp", adding -2, -3... if
|
|
502
|
+
// taken), creates the project at the smallest size unless one is given,
|
|
503
|
+
// downloads it into this folder when it's empty (else a subfolder), and
|
|
504
|
+
// installs it. An AI agent can run it without any back and forth.
|
|
505
|
+
// (Inside a project folder `start` means restart; bin/nukebase.js routes it.)
|
|
506
|
+
|
|
507
|
+
// Empty apart from dotfiles (.claude, .git, .vscode): a folder made for this.
|
|
508
|
+
function isEmptyFolder(dir) {
|
|
509
|
+
try { return fs.readdirSync(dir).every(n => n.startsWith('.')); } catch { return false; }
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// A folder name as a project name, or null: "My Recipes" -> "my-recipes".
|
|
513
|
+
function nameFromFolder(dir) {
|
|
514
|
+
const slug = path.basename(dir).toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^[^a-z]+/, '').replace(/-+$/, '').slice(0, 31);
|
|
515
|
+
return PROJECT_NAME_RE.test(slug) ? slug : null;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function startCommand(args) {
|
|
519
|
+
const { parse } = require('./args');
|
|
520
|
+
const passFlags = [];
|
|
521
|
+
for (const f of ['api', 'region']) if (args.get(f)) passFlags.push(`--${f}`, args.get(f));
|
|
522
|
+
for (const f of ['device', 'browser']) if (args.has(f)) passFlags.push(`--${f}`);
|
|
523
|
+
|
|
524
|
+
const emailArg = args.positional.find(a => a.includes('@')) || args.get('as');
|
|
525
|
+
console.log('\n Welcome to NukeBase.');
|
|
526
|
+
|
|
527
|
+
// With several accounts signed in, ask — creating a project bills the
|
|
528
|
+
// account, so never guess between them. Only interactively: an agent (no
|
|
529
|
+
// terminal) or --yes gets the default, as before.
|
|
530
|
+
let acct = emailArg ? creds.find(emailArg) : null;
|
|
531
|
+
if (!acct && !emailArg) {
|
|
532
|
+
const list = creds.list();
|
|
533
|
+
acct = (list.length > 1 && process.stdin.isTTY && !args.has('yes'))
|
|
534
|
+
? await chooseAccount(args, null, false)
|
|
535
|
+
: (creds.getDefault() || list[0]);
|
|
536
|
+
}
|
|
537
|
+
if (!acct) {
|
|
538
|
+
console.log(args.has('device')
|
|
539
|
+
? '\n Sign in: open the link below on any device and enter the code.'
|
|
540
|
+
: '\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.');
|
|
541
|
+
await loginCommand(parse(['login', ...(emailArg ? ['--email', emailArg] : []), ...passFlags]));
|
|
542
|
+
acct = emailArg ? creds.find(emailArg) : (creds.getDefault() || creds.list()[0]);
|
|
543
|
+
if (!acct) fail('Sign-in did not finish. Run: npx nukebase start');
|
|
544
|
+
}
|
|
545
|
+
const api = apiFor(args, acct.api);
|
|
546
|
+
console.log(`\n Signed in as ${describe(acct)}`);
|
|
547
|
+
|
|
548
|
+
const isFree = (await ensurePlan(api, acct)) === 'free';
|
|
549
|
+
const catalog = await loadCatalog(api);
|
|
550
|
+
const sizeArg = args.get('tier') || args.positional.find(a => !a.includes('@') && resolveTier(a, catalog));
|
|
551
|
+
const tier = sizeArg ? resolveTier(sizeArg, catalog) : catalog.default;
|
|
552
|
+
|
|
553
|
+
const here = process.cwd();
|
|
554
|
+
const intoHere = isEmptyFolder(here);
|
|
555
|
+
const given = args.positional.find(a => !a.includes('@') && PROJECT_NAME_RE.test(a) && !resolveTier(a, catalog));
|
|
556
|
+
const base = given || (intoHere && nameFromFolder(here)) || 'myapp';
|
|
557
|
+
// One name. If the account already has a project by it, that project is
|
|
558
|
+
// downloaded here rather than a name-2 being created: running start again
|
|
559
|
+
// after a hiccup finishes the job.
|
|
560
|
+
const name = base;
|
|
561
|
+
const dest = intoHere ? here : path.resolve(here, name);
|
|
562
|
+
if (!intoHere && fs.existsSync(dest) && fs.readdirSync(dest).length) {
|
|
563
|
+
fail(`There is already a folder named ${name} here with files in it. Run this in an empty folder, or choose another name: npx nukebase start <name>`);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
console.log(` Size: ${tierLabel(catalog, tier)}${isFree ? ' (paid from your $10 monthly credit)' : ''}`);
|
|
567
|
+
const created = await createProject({ acct, api, name, tier, dest, install: !args.has('no-install'), reuse: true });
|
|
568
|
+
|
|
569
|
+
// Written for whoever runs this next, person or AI agent.
|
|
570
|
+
console.log(`\n✅ Your app "${name}" is live at ${created.url}`);
|
|
571
|
+
console.log(` Project folder: ${dest}`);
|
|
572
|
+
console.log('\nNext steps (for Claude or you):');
|
|
573
|
+
console.log(` 1. Work in ${dest}. Read CLAUDE.md there first: it explains how NukeBase apps work.`);
|
|
574
|
+
console.log(' 2. Build what the user describes.');
|
|
575
|
+
console.log(' 3. Put it live with: npx nukebase push --yes (run inside the project folder)');
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ---- link -----------------------------------------------------------------
|
|
579
|
+
|
|
580
|
+
async function linkCommand(args) {
|
|
581
|
+
const autoYes = args.has('yes');
|
|
582
|
+
const name = args.positional[0];
|
|
583
|
+
if (!name) fail('Usage: nukebase link <project> [email] connects this folder to an existing project');
|
|
584
|
+
const acct = await chooseAccount(args, args.positional.slice(1).find(a => a.includes('@')), autoYes);
|
|
585
|
+
const api = apiFor(args, acct.api);
|
|
586
|
+
|
|
587
|
+
let r;
|
|
588
|
+
try {
|
|
589
|
+
r = await fetch(`${api}/sync`, {
|
|
590
|
+
method: 'POST',
|
|
591
|
+
headers: { 'Content-Type': 'application/json', 'X-Project': name, Cookie: cookieFor(acct) },
|
|
592
|
+
body: JSON.stringify({ exclude: ['sys', '.env', 'node_modules', 'server'], files: {}, direction: 'pull', symlinks: false })
|
|
593
|
+
});
|
|
594
|
+
} catch (e) {
|
|
595
|
+
fail(`Could not reach ${hostOf(api)} (${e.cause?.message || e.message}).`);
|
|
596
|
+
}
|
|
597
|
+
if (r.status === 404) fail(`${acct.label} has no project named ${name} on ${hostOf(api)}.`);
|
|
598
|
+
if (r.status === 401) fail(`The sign-in for ${acct.label} is no longer valid. Run: nukebase login --email ${acct.label}`);
|
|
599
|
+
if (!r.ok) fail(`Could not check the project (${r.status}).`);
|
|
600
|
+
|
|
601
|
+
const root = process.cwd();
|
|
602
|
+
let base = {};
|
|
603
|
+
const existing = hasConfig(root);
|
|
604
|
+
if (existing) {
|
|
605
|
+
try { base = readConfig(root); } catch {}
|
|
606
|
+
if (base.uid && base.uid !== acct.uid && !(await makeConfirm(autoYes)(`This folder belongs to ${base.account || base.uid}. Link it to ${acct.label} instead? (y/n) `))) {
|
|
607
|
+
console.log('Aborted.');
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
writeConfig(root, newConfig({ api, uid: acct.uid, project: name, account: acct.label }, base));
|
|
612
|
+
console.log(`✓ Linked this folder to ${name} (${describe(acct)}) on ${hostOf(api)}`);
|
|
613
|
+
console.log(' nukebase pull download its files');
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
module.exports = {
|
|
617
|
+
namePackage,
|
|
618
|
+
login: loginCommand,
|
|
619
|
+
logout: logoutCommand,
|
|
620
|
+
accounts: accountsCommand,
|
|
621
|
+
use: useCommand,
|
|
622
|
+
whoami: whoamiCommand,
|
|
623
|
+
create: createCommand,
|
|
624
|
+
start: startCommand,
|
|
625
|
+
link: linkCommand,
|
|
626
|
+
resolveTier, sizeToMb
|
|
627
|
+
};
|