nukebase 0.0.1 → 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 +42 -3
- package/bin/nukebase.js +96 -1
- package/lib/account-commands.js +369 -0
- package/lib/args.js +47 -0
- package/lib/credentials.js +118 -0
- package/lib/login.js +167 -0
- package/lib/project-commands.js +2323 -0
- package/lib/project.js +125 -0
- package/lib/sdk.js +94 -0
- package/lib/ui.js +91 -0
- package/lib/unzip.js +97 -0
- package/package.json +9 -4
package/README.md
CHANGED
|
@@ -1,9 +1,48 @@
|
|
|
1
1
|
# nukebase
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Create, deploy and manage [NukeBase](https://nukebase.com) projects.
|
|
4
4
|
|
|
5
5
|
```
|
|
6
|
-
npx nukebase
|
|
6
|
+
npx nukebase login
|
|
7
|
+
npx nukebase create myapp 256mb
|
|
8
|
+
cd myapp
|
|
9
|
+
npx nukebase push
|
|
7
10
|
```
|
|
8
11
|
|
|
9
|
-
|
|
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`.
|
package/bin/nukebase.js
CHANGED
|
@@ -1,2 +1,97 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
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 };
|