nukebase 0.0.0-stage → 2.0.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
@@ -1,3 +1,48 @@
1
- # Temporary Holding Version
1
+ # nukebase
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Create, deploy and manage [NukeBase](https://nukebase.com) projects.
4
+
5
+ ```
6
+ npx nukebase login
7
+ npx nukebase create myapp 256mb
8
+ cd myapp
9
+ npx nukebase push
10
+ ```
11
+
12
+ For daily use, install it once: `npm i -g nukebase`, then run `nukebase …`.
13
+
14
+ ## Accounts
15
+
16
+ Sign in by approving the CLI on the NukeBase website. Several accounts can be
17
+ signed in at once; each project folder knows which account owns it, so you can
18
+ deploy to different accounts from different terminals at the same time.
19
+
20
+ ```
21
+ nukebase login approve in your browser
22
+ nukebase login --email you@x.com add another account
23
+ nukebase login --device from SSH or a machine without a browser
24
+ nukebase accounts list signed-in accounts
25
+ nukebase use you@x.com default for commands outside a project
26
+ nukebase logout you@x.com sign out one account
27
+ nukebase logout sign out every account
28
+ ```
29
+
30
+ Sign-ins are kept in `~/.nukebase/credentials.json`, never inside a project.
31
+ CLI sign-ins can deploy and manage projects but can't change billing, and stay
32
+ signed in until you sign them out here or under **Signed-in devices** on the
33
+ dashboard.
34
+
35
+ ## Projects
36
+
37
+ ```
38
+ nukebase create <name> [size] [email] sizes: 128mb, 256mb, 512mb, 1gb
39
+ nukebase link <name> [email] connect a folder to an existing project
40
+ nukebase push | pull | logs | rollback | backup | env push | domain | ...
41
+ ```
42
+
43
+ Run `nukebase help` for every command.
44
+
45
+ ## CI
46
+
47
+ Create a build token on the dashboard (Signed-in devices → Create build token)
48
+ and set `NUKEBASE_TOKEN` and `NUKEBASE_UID`.
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ // nukebase: create, deploy and manage NukeBase projects.
3
+ //
4
+ // Account commands (login, logout, accounts, use, whoami, create, link) work
5
+ // anywhere. Project commands run in a project folder and act as the account
6
+ // that owns it, so several terminals can deploy to different accounts at once.
7
+
8
+ const args = require('../lib/args').parse(process.argv.slice(2));
9
+ const { version } = require('../package.json');
10
+
11
+ const ACCOUNT_COMMANDS = ['login', 'logout', 'accounts', 'use', 'whoami', 'create', 'link'];
12
+ const PROJECT_COMMANDS = [
13
+ 'push', 'pull', 'update', 'rollback', 'restorepoints', 'restore-points', 'backup', 'env',
14
+ 'pause', 'restart', 'start', 'resume', 'delete', 'domain', 'logs', 'download',
15
+ 'regiontransfer', 'region-transfer'
16
+ ];
17
+
18
+ function usage() {
19
+ console.log(`nukebase ${version}
20
+
21
+ Usage: nukebase <command> (or: npx nukebase <command>)
22
+
23
+ Accounts
24
+ login sign in by approving in your browser
25
+ login --email you@x.com sign in as a specific account (adds it; others stay)
26
+ login --device sign in from SSH or a machine without a browser
27
+ login --email-link sign in with an emailed link instead
28
+ login --password username + password (the master account)
29
+ logout <email> sign out one account
30
+ logout sign out every account on this machine
31
+ accounts list signed-in accounts
32
+ use <email> default account for commands outside a project
33
+ whoami who owns this project, and are you signed in
34
+
35
+ New projects
36
+ create <name> [size] [email]
37
+ make a project and download it, e.g.
38
+ nukebase create demoapp123 256mb you@example.com
39
+ sizes: 128mb, 256mb, 512mb, 1gb (or the tier name)
40
+ link <name> [email] connect this folder to an existing project
41
+
42
+ In a project folder (runs as the account that owns the project)
43
+ push upload code and restart (--tier <size> if it has
44
+ to create the project in this region)
45
+ push --include backup ALSO upload the backup set, replacing the
46
+ server's live data with your copy
47
+ pull download code
48
+ update refresh the NukeBase engine files
49
+ backup [--out <dir>] download the backup set from the server
50
+ env push make the hosted environment (process.env) equal
51
+ to ./.env, then restart the project
52
+ env list [--show] variable names set on the server
53
+ rollback [id] undo a push. Live data is kept.
54
+ rollback backup [id] undo a push AND restore the backup set as it was
55
+ then; discards writes since
56
+ rollback list show restore points (also: restorepoints)
57
+ pause stop the project (base fee + disk still accrue)
58
+ restart | start | resume restart it; also resumes a paused project
59
+ delete delete it on the server; asks for the name
60
+ domain connect a custom domain, step by step
61
+ domain <name> [--email e] connect in one go (DNS already set)
62
+ domain remove disconnect the custom domain
63
+ logs [--follow] recent log output; --follow streams
64
+ download [--out f.zip] the project ZIP, exactly as the server has it
65
+ regiontransfer [region] move this project to another region, step by step
66
+ regiontransfer --to <email> ... into a different account on the target
67
+
68
+ Options
69
+ --region <name> / --api <url>
70
+ run against the project in another region
71
+ --as <email> with --region: as another signed-in account
72
+ --yes, -y non-interactive (no y/n prompts)
73
+ --no-check push even if server-side .js files don't parse
74
+ --streams <1-8> parallel connections for big push/pull/backup
75
+
76
+ CI: set NUKEBASE_TOKEN and NUKEBASE_UID (create a build token on the
77
+ dashboard under Signed-in devices).
78
+
79
+ Sign-ins are kept in ~/.nukebase/credentials.json, never in a project.`);
80
+ }
81
+
82
+ const command = args.command;
83
+
84
+ if (args.has('version') && !command) {
85
+ console.log(version);
86
+ } else if (!command || command === 'help' || (args.has('help') && !command)) {
87
+ usage();
88
+ } else if (ACCOUNT_COMMANDS.includes(command)) {
89
+ Promise.resolve()
90
+ .then(() => require('../lib/account-commands')[command](args))
91
+ .then(() => process.exit(0), err => { console.error('❌', err.message); process.exit(1); });
92
+ } else if (PROJECT_COMMANDS.includes(command)) {
93
+ require('../lib/project-commands');
94
+ } else {
95
+ console.error(`Unknown command "${command}". Run nukebase help for the list.`);
96
+ process.exit(1);
97
+ }
@@ -0,0 +1,369 @@
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, 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
+ function fail(message) {
18
+ console.error(`❌ ${message}`);
19
+ process.exit(1);
20
+ }
21
+
22
+ function describe(acct) {
23
+ return `${acct.label || acct.uid} (${acct.uid})`;
24
+ }
25
+
26
+ // The project in the current folder, if any, without its secrets mattering.
27
+ function currentProject() {
28
+ const root = findProject(process.cwd());
29
+ if (!root) return null;
30
+ try { return { root, config: readConfig(root) }; } catch { return null; }
31
+ }
32
+
33
+ // ---- login ----------------------------------------------------------------
34
+
35
+ async function loginCommand(args) {
36
+ const askLine = makeAskLine(false);
37
+ const here = currentProject();
38
+ const api = apiFor(args, here?.config.API || creds.getDefault()?.api);
39
+
40
+ // `nukebase login` inside a project whose account isn't signed in asks for
41
+ // that account, so you don't have to remember which email it was.
42
+ let email = args.get('email') || args.positional.find(a => a.includes('@')) || null;
43
+ if (!email && here && here.config.uid && !creds.get(here.config.uid) && String(here.config.account || '').includes('@')) {
44
+ email = here.config.account;
45
+ }
46
+
47
+ let out, method;
48
+ if (args.has('password')) {
49
+ method = 'password';
50
+ const username = args.positional.find(a => !a.includes('@')) || (await askLine('Username: ')).trim();
51
+ const pass = await askHidden('Password: ');
52
+ if (!username || !pass) fail('Username and password are required.');
53
+ out = await login.password(api, username, pass);
54
+ } else if (args.has('device')) {
55
+ method = 'device';
56
+ out = await login.device(api);
57
+ } else if (args.has('email-link')) {
58
+ method = 'email-link';
59
+ if (!email) email = (await askLine('Email: ')).trim().toLowerCase();
60
+ if (!email.includes('@')) fail('An email address is required.');
61
+ out = await login.emailLink(api, email);
62
+ } else {
63
+ method = 'browser';
64
+ out = await login.browser(api, { email });
65
+ }
66
+
67
+ // Signing in again replaces this machine's token for that account; sign the
68
+ // old one out on the server rather than leave it valid and forgotten.
69
+ const previous = creds.get(out.uid);
70
+ creds.put(out.uid, { label: out.label, api, token: out.token, method, created: Date.now(), lastUsed: Date.now() });
71
+ if (previous && previous.token && previous.token !== out.token) await revoke(previous);
72
+
73
+ console.log(`\n✓ Signed in as ${describe(out)} on ${hostOf(api)}`);
74
+ if (email && String(out.label).toLowerCase() !== email.toLowerCase()) {
75
+ console.log(` Note: you asked for ${email} but approved as ${out.label}.`);
76
+ }
77
+ const n = creds.list().length;
78
+ if (n > 1) console.log(` ${n} accounts signed in on this machine. List them with: nukebase accounts`);
79
+ }
80
+
81
+ // ---- logout ---------------------------------------------------------------
82
+
83
+ async function revoke(acct) {
84
+ try {
85
+ const r = await fetch(`${acct.api}/cli/revoke`, {
86
+ method: 'POST',
87
+ headers: { 'Content-Type': 'application/json', Cookie: cookieFor(acct) },
88
+ body: '{}'
89
+ });
90
+ return r.ok || r.status === 401; // 401: already signed out
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+
96
+ // `nukebase logout <email|uid>` signs out that account; plain `nukebase
97
+ // logout` signs out every account on this machine.
98
+ async function logoutCommand(args) {
99
+ const target = args.positional[0];
100
+ let accounts;
101
+ if (target) {
102
+ const acct = creds.find(target);
103
+ if (!acct) fail(`${target} isn't signed in on this machine. See: nukebase accounts`);
104
+ accounts = [acct];
105
+ } else {
106
+ accounts = creds.list();
107
+ if (!accounts.length) { console.log('No accounts are signed in.'); return; }
108
+ }
109
+ for (const acct of accounts) {
110
+ const ok = await revoke(acct);
111
+ creds.remove(acct.uid);
112
+ console.log(`✓ Signed out ${describe(acct)}${ok ? '' : ` (couldn't reach ${hostOf(acct.api)}; sign it out from the dashboard's Signed-in devices too)`}`);
113
+ }
114
+ }
115
+
116
+ // ---- accounts / use / whoami ----------------------------------------------
117
+
118
+ function relative(ms) {
119
+ if (!ms) return 'never';
120
+ const days = Math.floor((Date.now() - ms) / 86400000);
121
+ return days <= 0 ? 'today' : days === 1 ? 'yesterday' : `${days} days ago`;
122
+ }
123
+
124
+ function accountsCommand() {
125
+ const list = creds.list();
126
+ if (!list.length) { console.log('No accounts are signed in. Run: nukebase login'); return; }
127
+ const w = Math.max(...list.map(a => String(a.label || '').length), 7);
128
+ console.log('');
129
+ for (const a of list) {
130
+ console.log(` ${a.isDefault ? '*' : ' '} ${String(a.label || '').padEnd(w)} ${a.uid} ${hostOf(a.api)} last used ${relative(a.lastUsed)}`);
131
+ }
132
+ console.log(`\n * default, for commands run outside a project (nukebase use <email> to change)`);
133
+ console.log(` Inside a project, the account that owns it is used automatically.\n`);
134
+ }
135
+
136
+ function useCommand(args) {
137
+ const q = args.positional[0];
138
+ if (!q) fail('Usage: nukebase use <email|uid>');
139
+ const acct = creds.find(q);
140
+ if (!acct) fail(`${q} isn't signed in on this machine. Run: nukebase login --email ${q}`);
141
+ creds.setDefault(acct.uid);
142
+ console.log(`✓ Default account: ${describe(acct)}`);
143
+ }
144
+
145
+ async function whoamiCommand() {
146
+ const here = currentProject();
147
+ if (here && here.config.uid) {
148
+ const acct = creds.get(here.config.uid);
149
+ const name = here.config.project || path.basename(here.root);
150
+ if (!acct) {
151
+ console.log(`${name} belongs to ${here.config.account || 'uid'} (${here.config.uid}), which isn't signed in on this machine.`);
152
+ console.log(`Run: nukebase login${String(here.config.account || '').includes('@') ? ` --email ${here.config.account}` : ''}`);
153
+ return;
154
+ }
155
+ console.log(`${name} belongs to ${describe(acct)} on ${hostOf(here.config.API)}`);
156
+ console.log(await check(acct, here.config.API));
157
+ return;
158
+ }
159
+ const acct = creds.getDefault();
160
+ if (!acct) { console.log(creds.list().length ? 'No default account. Set one with: nukebase use <email>' : 'Not signed in. Run: nukebase login'); return; }
161
+ console.log(`Default account: ${describe(acct)} on ${hostOf(acct.api)}`);
162
+ console.log(await check(acct, acct.api));
163
+ }
164
+
165
+ async function check(acct, api) {
166
+ try {
167
+ const r = await fetch(`${api}/cli/whoami`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookieFor(acct) }, body: '{}' });
168
+ if (r.ok) return '✓ Signed in';
169
+ if (r.status === 401) return `✗ This sign-in was revoked. Run: nukebase login${String(acct.label).includes('@') ? ` --email ${acct.label}` : ''}`;
170
+ return `? ${hostOf(api)} answered ${r.status}`;
171
+ } catch {
172
+ return `? Couldn't reach ${hostOf(api)}`;
173
+ }
174
+ }
175
+
176
+ // ---- choosing an account for create / link ----------------------------------
177
+
178
+ async function chooseAccount(args, hint, autoYes) {
179
+ const q = args.get('as') || hint;
180
+ if (q) {
181
+ const acct = creds.find(q);
182
+ if (!acct) fail(`${q} isn't signed in on this machine. Run: nukebase login --email ${q}`);
183
+ return acct;
184
+ }
185
+ const list = creds.list();
186
+ if (!list.length) fail('Not signed in. Run: nukebase login');
187
+ if (list.length === 1) return list[0];
188
+ // Creating a project is billed to the account, so never guess between several.
189
+ if (autoYes || !process.stdin.isTTY) {
190
+ fail(`Several accounts are signed in. Say which one, e.g.: nukebase ${args.command} ${args.positional[0] || '<name>'} ${list[0].label}`);
191
+ }
192
+ console.log('\n Which account?');
193
+ list.forEach((a, i) => console.log(` ${i + 1}) ${a.label} (${a.uid})${a.isDefault ? ' default' : ''}`));
194
+ const answer = (await makeAskLine(false)('\n Account (number or email): ')).trim();
195
+ const picked = /^\d+$/.test(answer) ? list[Number(answer) - 1] : creds.find(answer);
196
+ if (!picked) fail('No account chosen.');
197
+ return picked;
198
+ }
199
+
200
+ // ---- create ---------------------------------------------------------------
201
+
202
+ // "256mb", "256", "1gb", "1g", "512 MB" -> megabytes; null when not a size.
203
+ function sizeToMb(text) {
204
+ const m = /^(\d+(?:\.\d+)?)\s*(m|mb|g|gb)?$/i.exec(String(text).trim());
205
+ if (!m) return null;
206
+ const n = Number(m[1]);
207
+ return /^g/i.test(m[2] || '') ? n * 1024 : n;
208
+ }
209
+
210
+ // A size the user typed -> tier key. Matches the RAM each tier buys
211
+ // (memoryHigh) or the tier's name.
212
+ function resolveTier(input, catalog) {
213
+ const tiers = catalog.tiers || {};
214
+ const key = String(input).toLowerCase();
215
+ if (tiers[key]) return key;
216
+ const want = sizeToMb(input);
217
+ if (want === null) return null;
218
+ return (catalog.order || Object.keys(tiers)).find(k => sizeToMb(tiers[k].memoryHigh) === want) || null;
219
+ }
220
+
221
+ function tierLabel(catalog, key) {
222
+ const t = catalog.tiers[key];
223
+ const mb = sizeToMb(t.memoryHigh);
224
+ const ram = mb >= 1024 ? `${mb / 1024} GB` : `${mb} MB`;
225
+ return `${t.name}, ${ram} RAM, $${(t.monthlyCents / 100).toFixed(0)}/mo`;
226
+ }
227
+
228
+ async function createCommand(args) {
229
+ const autoYes = args.has('yes');
230
+ const confirm = makeConfirm(autoYes);
231
+ const askLine = makeAskLine(autoYes);
232
+
233
+ const name = args.positional[0];
234
+ if (!name) fail('Usage: nukebase create <name> [size] [email] e.g. nukebase create demoapp123 256mb you@example.com');
235
+ if (!PROJECT_NAME_RE.test(name)) fail('Project names are 3-31 characters: lowercase letters, numbers, - and _, starting with a letter.');
236
+
237
+ // After the name, in any order: something with an @ is the account; the
238
+ // rest is the size.
239
+ const rest = args.positional.slice(1);
240
+ const emailArg = rest.find(a => a.includes('@')) || null;
241
+ const sizeArg = args.get('tier') || rest.find(a => !a.includes('@')) || null;
242
+
243
+ const dest = path.resolve(process.cwd(), name);
244
+ if (fs.existsSync(dest) && fs.readdirSync(dest).length) fail(`The folder ${name} already exists and isn't empty.`);
245
+
246
+ const acct = await chooseAccount(args, emailArg, autoYes);
247
+ const api = apiFor(args, acct.api);
248
+ const host = hostOf(api);
249
+
250
+ let catalog;
251
+ try {
252
+ const r = await fetch(`${api}/tiers`);
253
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
254
+ catalog = await r.json();
255
+ } catch (e) {
256
+ fail(`Could not reach ${host} (${e.cause?.message || e.message}).`);
257
+ }
258
+ const order = catalog.order || Object.keys(catalog.tiers);
259
+ const def = catalog.default || order[0];
260
+
261
+ let tier = sizeArg ? resolveTier(sizeArg, catalog) : null;
262
+ if (sizeArg && !tier) {
263
+ fail(`Unknown size "${sizeArg}". One of: ${order.map(k => `${catalog.tiers[k].memoryHigh.replace(/M$/, 'mb').replace(/G$/, 'gb').toLowerCase()} (${k})`).join(', ')}`);
264
+ }
265
+ if (!tier) {
266
+ if (autoYes || !process.stdin.isTTY) {
267
+ tier = def;
268
+ } else {
269
+ console.log('\n Sizes:');
270
+ order.forEach((k, i) => console.log(` ${i + 1}) ${tierLabel(catalog, k)}${k === def ? ' (default)' : ''}`));
271
+ const answer = (await askLine(`\n Size [${def}]: `)).trim();
272
+ tier = !answer ? def : /^\d+$/.test(answer) ? order[Number(answer) - 1] : resolveTier(answer, catalog);
273
+ if (!tier) fail(`Unknown size "${answer}".`);
274
+ }
275
+ }
276
+
277
+ console.log(`\n Project ${name}`);
278
+ console.log(` Size ${tierLabel(catalog, tier)}`);
279
+ console.log(` Account ${describe(acct)}`);
280
+ console.log(` Region ${host}`);
281
+ if (!(await confirm('\nCreate it? It is billed to this account. (y/n) '))) { console.log('Aborted. Nothing was created.'); return; }
282
+
283
+ console.log(`\n🚀 Creating ${name}...`);
284
+ let res;
285
+ try {
286
+ res = await fetch(`${api}/project`, {
287
+ method: 'POST',
288
+ headers: { 'Content-Type': 'application/json', 'X-Project': name, Cookie: cookieFor(acct) },
289
+ body: JSON.stringify({ tier })
290
+ });
291
+ } catch (e) {
292
+ fail(`Create failed: ${e.cause?.message || e.message}. Check the dashboard before retrying; it may have been created.`);
293
+ }
294
+ if (!res.ok) {
295
+ let msg = '';
296
+ try { msg = (await res.json()).error; } catch {}
297
+ if (res.status === 401) fail(`The sign-in for ${acct.label} is no longer valid. Run: nukebase login --email ${acct.label}`);
298
+ fail(`Create failed (${res.status}): ${msg || res.statusText}`);
299
+ }
300
+
301
+ // The server finishes setup as the ZIP finishes streaming, so read it all.
302
+ const zip = Buffer.from(await res.arrayBuffer());
303
+ fs.mkdirSync(dest, { recursive: true });
304
+ const count = unzip.extract(zip, dest);
305
+
306
+ let base = {};
307
+ try { base = readConfig(dest); } catch {}
308
+ writeConfig(dest, newConfig({ api, uid: acct.uid, project: name, account: acct.label }, base));
309
+ console.log(`✓ Downloaded ${count} files into ./${name}`);
310
+
311
+ if (!args.has('no-install') && fs.existsSync(path.join(dest, 'package.json'))) {
312
+ console.log('\n📦 npm install\n');
313
+ const r = spawnSync('npm', ['install'], { cwd: dest, stdio: 'inherit', shell: process.platform === 'win32' });
314
+ if (r.status !== 0) console.log(`\n⚠️ npm install failed. Run it yourself in ./${name}.`);
315
+ }
316
+
317
+ console.log(`\n✅ ${name} is live at https://${acct.uid}-${name}.${host}`);
318
+ console.log(`\n cd ${name}`);
319
+ console.log(' nukebase push deploy your changes');
320
+ }
321
+
322
+ // ---- link -----------------------------------------------------------------
323
+
324
+ async function linkCommand(args) {
325
+ const autoYes = args.has('yes');
326
+ const name = args.positional[0];
327
+ if (!name) fail('Usage: nukebase link <project> [email] connects this folder to an existing project');
328
+ const acct = await chooseAccount(args, args.positional.slice(1).find(a => a.includes('@')), autoYes);
329
+ const api = apiFor(args, acct.api);
330
+
331
+ let r;
332
+ try {
333
+ r = await fetch(`${api}/sync`, {
334
+ method: 'POST',
335
+ headers: { 'Content-Type': 'application/json', 'X-Project': name, Cookie: cookieFor(acct) },
336
+ body: JSON.stringify({ exclude: ['sys', '.env', 'node_modules', 'server'], files: {}, direction: 'pull', symlinks: false })
337
+ });
338
+ } catch (e) {
339
+ fail(`Could not reach ${hostOf(api)} (${e.cause?.message || e.message}).`);
340
+ }
341
+ if (r.status === 404) fail(`${acct.label} has no project named ${name} on ${hostOf(api)}.`);
342
+ if (r.status === 401) fail(`The sign-in for ${acct.label} is no longer valid. Run: nukebase login --email ${acct.label}`);
343
+ if (!r.ok) fail(`Could not check the project (${r.status}).`);
344
+
345
+ const root = process.cwd();
346
+ let base = {};
347
+ const existing = fs.existsSync(path.join(root, 'sys', 'config.json'));
348
+ if (existing) {
349
+ try { base = readConfig(root); } catch {}
350
+ 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) `))) {
351
+ console.log('Aborted.');
352
+ return;
353
+ }
354
+ }
355
+ writeConfig(root, newConfig({ api, uid: acct.uid, project: name, account: acct.label }, base));
356
+ console.log(`✓ Linked this folder to ${name} (${describe(acct)}) on ${hostOf(api)}`);
357
+ console.log(' nukebase pull download its files');
358
+ }
359
+
360
+ module.exports = {
361
+ login: loginCommand,
362
+ logout: logoutCommand,
363
+ accounts: accountsCommand,
364
+ use: useCommand,
365
+ whoami: whoamiCommand,
366
+ create: createCommand,
367
+ link: linkCommand,
368
+ resolveTier, sizeToMb
369
+ };
package/lib/args.js ADDED
@@ -0,0 +1,47 @@
1
+ // Command-line parsing shared by every command.
2
+ //
3
+ // Options that take a value consume the next word, so it is never mistaken
4
+ // for a positional argument: `nukebase rollback --region usw3` has no
5
+ // restore-point id, and `nukebase update --as a@b.com` has no file filter.
6
+ // `--name=value` works too.
7
+
8
+ const VALUE_FLAGS = new Set([
9
+ 'region', 'api', 'as', 'tier', 'streams', 'out', 'email', 'to', 'include', 'name'
10
+ ]);
11
+
12
+ const SHORT = { y: 'yes', f: 'follow', h: 'help', v: 'version' };
13
+
14
+ function parse(argv) {
15
+ const flags = {};
16
+ const words = [];
17
+ for (let i = 0; i < argv.length; i++) {
18
+ const a = argv[i];
19
+ if (a === '--') { words.push(...argv.slice(i + 1)); break; }
20
+ if (a.startsWith('--')) {
21
+ const eq = a.indexOf('=');
22
+ if (eq > 2) { flags[a.slice(2, eq)] = a.slice(eq + 1); continue; }
23
+ const name = a.slice(2);
24
+ if (VALUE_FLAGS.has(name)) {
25
+ const next = argv[i + 1];
26
+ flags[name] = next !== undefined && !next.startsWith('-') ? (i++, next) : '';
27
+ } else {
28
+ flags[name] = true;
29
+ }
30
+ continue;
31
+ }
32
+ if (a.length > 1 && a.startsWith('-') && !/^-\d/.test(a)) {
33
+ for (const ch of a.slice(1)) flags[SHORT[ch] || ch] = true;
34
+ continue;
35
+ }
36
+ words.push(a);
37
+ }
38
+ return {
39
+ command: words[0],
40
+ positional: words.slice(1),
41
+ flags,
42
+ has: (name) => flags[name] !== undefined && flags[name] !== false,
43
+ get: (name) => (typeof flags[name] === 'string' && flags[name] !== '' ? flags[name] : null)
44
+ };
45
+ }
46
+
47
+ module.exports = { parse };
@@ -0,0 +1,118 @@
1
+ // ~/.nukebase/credentials.json — every account signed in on this machine,
2
+ // keyed by uid. A project's sys/config.json says which uid owns it; the token
3
+ // for that uid lives here and never inside a project folder.
4
+ //
5
+ // {
6
+ // "version": 1,
7
+ // "default": "<uid>", used only outside a project
8
+ // "accounts": {
9
+ // "<uid>": { "label": "you@example.com", "api": "https://usw4.nukebase.com",
10
+ // "token": "...", "method": "browser", "created": 0, "lastUsed": 0 }
11
+ // }
12
+ // }
13
+ //
14
+ // Several terminals may run at once (pushes to different accounts in
15
+ // parallel), so every write re-reads the file, changes one entry, writes a
16
+ // temporary file and renames it over the original. Reads never lock.
17
+
18
+ const fs = require('fs');
19
+ const os = require('os');
20
+ const path = require('path');
21
+
22
+ const DIR = process.env.NUKEBASE_HOME || path.join(os.homedir(), '.nukebase');
23
+ const FILE = path.join(DIR, 'credentials.json');
24
+ const LAST_USED_EVERY_MS = 60 * 60 * 1000;
25
+
26
+ function empty() {
27
+ return { version: 1, default: null, accounts: {} };
28
+ }
29
+
30
+ function load() {
31
+ try {
32
+ const data = JSON.parse(fs.readFileSync(FILE, 'utf8'));
33
+ if (!data || typeof data !== 'object' || typeof data.accounts !== 'object') return empty();
34
+ return data;
35
+ } catch (e) {
36
+ return empty();
37
+ }
38
+ }
39
+
40
+ function sleepSync(ms) {
41
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
42
+ }
43
+
44
+ // Apply `change` to a fresh read and write the result atomically.
45
+ function edit(change) {
46
+ fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
47
+ const data = load();
48
+ change(data);
49
+ const tmp = `${FILE}.${process.pid}.${Date.now()}.tmp`;
50
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
51
+ // Windows refuses a rename onto a file another process has open for a
52
+ // moment; a few short retries ride that out.
53
+ for (let attempt = 0; ; attempt++) {
54
+ try { fs.renameSync(tmp, FILE); break; } catch (e) {
55
+ if (attempt >= 20 || !['EPERM', 'EACCES', 'EBUSY'].includes(e.code)) {
56
+ try { fs.unlinkSync(tmp); } catch {}
57
+ throw e;
58
+ }
59
+ sleepSync(25);
60
+ }
61
+ }
62
+ return data;
63
+ }
64
+
65
+ function list() {
66
+ const data = load();
67
+ return Object.entries(data.accounts).map(([uid, a]) => ({ uid, ...a, isDefault: data.default === uid }));
68
+ }
69
+
70
+ function get(uid) {
71
+ const a = uid ? load().accounts[uid] : null;
72
+ return a ? { uid, ...a } : null;
73
+ }
74
+
75
+ // By uid, or by label (email or username), case-insensitive.
76
+ function find(query) {
77
+ if (!query) return null;
78
+ const data = load();
79
+ if (data.accounts[query]) return { uid: query, ...data.accounts[query] };
80
+ const q = String(query).toLowerCase();
81
+ const hit = Object.entries(data.accounts).find(([, a]) => String(a.label || '').toLowerCase() === q);
82
+ return hit ? { uid: hit[0], ...hit[1] } : null;
83
+ }
84
+
85
+ function getDefault() {
86
+ const data = load();
87
+ if (data.default && data.accounts[data.default]) return { uid: data.default, ...data.accounts[data.default] };
88
+ const uids = Object.keys(data.accounts);
89
+ return uids.length === 1 ? { uid: uids[0], ...data.accounts[uids[0]] } : null;
90
+ }
91
+
92
+ function put(uid, entry) {
93
+ return edit((data) => {
94
+ data.accounts[uid] = { ...(data.accounts[uid] || {}), ...entry };
95
+ if (!data.default || !data.accounts[data.default]) data.default = uid;
96
+ });
97
+ }
98
+
99
+ function remove(uid) {
100
+ return edit((data) => {
101
+ delete data.accounts[uid];
102
+ if (data.default === uid) data.default = Object.keys(data.accounts)[0] || null;
103
+ });
104
+ }
105
+
106
+ function setDefault(uid) {
107
+ return edit((data) => { if (data.accounts[uid]) data.default = uid; });
108
+ }
109
+
110
+ // Pushes only read this file; "last used" is written at most once an hour so
111
+ // ten parallel pushes don't queue up on it.
112
+ function touch(uid) {
113
+ const a = get(uid);
114
+ if (!a || Date.now() - (a.lastUsed || 0) < LAST_USED_EVERY_MS) return;
115
+ try { edit((data) => { if (data.accounts[uid]) data.accounts[uid].lastUsed = Date.now(); }); } catch {}
116
+ }
117
+
118
+ module.exports = { FILE, list, get, find, getDefault, put, remove, setDefault, touch };