nukebase 0.0.1 → 2.0.1

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.
@@ -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 };
package/lib/login.js ADDED
@@ -0,0 +1,173 @@
1
+ // The ways `nukebase login` gets a CLI token. Each returns
2
+ // { uid, token, label } for credentials.put().
3
+ //
4
+ // browser open /cli-auth.html, approve, a one-time code comes back to a
5
+ // local server, swapped (with a PKCE secret) for the token
6
+ // device a short code to type on the website, for SSH / no browser
7
+ // emailLink the email sign-in link, delivered back to a local server
8
+ // password username + password (the master account)
9
+ //
10
+ // The last two get an ordinary session first and swap it at /cli/exchange,
11
+ // which also signs that session out.
12
+
13
+ const http = require('http');
14
+ const os = require('os');
15
+ const crypto = require('crypto');
16
+ const { openBrowser, hostOf } = require('./ui');
17
+
18
+ const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
19
+
20
+ const deviceName = () => os.hostname() || 'Unknown device';
21
+ const b64url = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
22
+
23
+ function page(mark, title, message, accent) {
24
+ return `<!doctype html><meta charset="utf-8"><title>NukeBase</title><body style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:#f1f5f9;margin:0;">
25
+ <div style="max-width:420px;margin:80px auto;background:#fff;border:1px solid #e2e8f0;border-radius:14px;padding:36px;text-align:center;">
26
+ <div style="font-size:18px;font-weight:800;color:#0f172a;letter-spacing:-0.3px;">&#9670; NukeBase</div>
27
+ <div style="font-size:40px;margin:16px 0 8px;color:${accent};">${mark}</div>
28
+ <h1 style="font-size:20px;color:#0f172a;margin:0 0 8px;">${title}</h1>
29
+ <p style="color:#475569;margin:0;font-size:15px;">${message}</p>
30
+ </div></body>`;
31
+ }
32
+
33
+ // A one-shot server on 127.0.0.1 for the browser to come back to. Resolves
34
+ // with the callback's query once one arrives carrying the right state.
35
+ function startLoopback(state) {
36
+ return new Promise((resolveStart, rejectStart) => {
37
+ let finish;
38
+ const result = new Promise((res, rej) => { finish = { res, rej }; });
39
+ const server = http.createServer((req, res) => {
40
+ const url = new URL(req.url, 'http://127.0.0.1');
41
+ if (url.pathname !== '/callback') { res.writeHead(404); return res.end(); }
42
+ const q = Object.fromEntries(url.searchParams);
43
+ if (q.state !== state) {
44
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
45
+ return res.end(page('✕', 'Sign-in error', 'This sign-in could not be verified. Return to your terminal and try again.', '#dc2626'));
46
+ }
47
+ const denied = q.error === 'access_denied';
48
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
49
+ res.end(denied
50
+ ? page('✕', 'Cancelled', 'The CLI was not signed in. You can close this tab.', '#64748b')
51
+ : page('✓', 'CLI signed in', 'You can close this tab and return to your terminal.', '#16a34a'));
52
+ clearTimeout(timer);
53
+ server.close();
54
+ finish.res(q);
55
+ });
56
+ const timer = setTimeout(() => { server.close(); finish.rej(new Error('No response within 5 minutes. Run nukebase login again.')); }, LOGIN_TIMEOUT_MS);
57
+ server.on('error', rejectStart);
58
+ server.listen(0, '127.0.0.1', () => resolveStart({ port: server.address().port, result }));
59
+ });
60
+ }
61
+
62
+ async function postJson(url, body, cookie) {
63
+ const headers = { 'Content-Type': 'application/json' };
64
+ if (cookie) headers.Cookie = cookie;
65
+ let res;
66
+ try {
67
+ res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body || {}) });
68
+ } catch (e) {
69
+ throw new Error(`Could not reach ${hostOf(url)} (${e.cause?.message || e.message})`);
70
+ }
71
+ let data = {};
72
+ try { data = await res.json(); } catch {}
73
+ return { status: res.status, ok: res.ok, data };
74
+ }
75
+
76
+ // A server from before CLI sign-in answers 404 (or the static 404 page).
77
+ function needsNewerServer(r, api) {
78
+ if (r.status === 404 && !r.data.error) {
79
+ throw new Error(`${hostOf(api)} doesn't support CLI sign-in yet. Try again after it's updated.`);
80
+ }
81
+ }
82
+
83
+ async function browser(api, { email } = {}) {
84
+ const state = b64url(crypto.randomBytes(24));
85
+ const verifier = b64url(crypto.randomBytes(32));
86
+ const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
87
+ const { port, result } = await startLoopback(state);
88
+
89
+ const url = new URL(`${api}/cli-auth.html`);
90
+ url.searchParams.set('port', String(port));
91
+ url.searchParams.set('state', state);
92
+ url.searchParams.set('challenge', challenge);
93
+ url.searchParams.set('device', deviceName());
94
+ if (email) url.searchParams.set('email', email);
95
+
96
+ console.log('\n Opening your browser to approve this CLI. If it doesn\'t open, visit:');
97
+ console.log(` ${url}\n`);
98
+ console.log(' Waiting for approval… (Ctrl+C to cancel)');
99
+ openBrowser(url.toString());
100
+
101
+ // The usual reason for a long wait: no account yet, so no sign-in email
102
+ // ever arrives (the server doesn't say which addresses have accounts).
103
+ const hint = setTimeout(() => {
104
+ console.log(`\n Still waiting. No account yet? Sign up at ${api}/start.html first, then run this again.`);
105
+ }, 90 * 1000);
106
+ hint.unref();
107
+ const q = await result.finally(() => clearTimeout(hint));
108
+ if (q.error) throw new Error('Sign-in was cancelled in the browser.');
109
+ if (!q.code) throw new Error('The browser did not return a sign-in code.');
110
+ const r = await postJson(`${api}/cli/token`, { code: q.code, verifier });
111
+ needsNewerServer(r, api);
112
+ if (!r.ok) throw new Error(r.data.error || `Sign-in failed (${r.status})`);
113
+ return r.data;
114
+ }
115
+
116
+ async function device(api) {
117
+ const start = await postJson(`${api}/cli/device-start`, { device: deviceName() });
118
+ needsNewerServer(start, api);
119
+ if (!start.ok) throw new Error(start.data.error || `Could not start sign-in (${start.status})`);
120
+ const { deviceCode, userCode, verifyUrl, interval = 5, expiresIn = 600 } = start.data;
121
+
122
+ console.log('\n On any device, open:');
123
+ console.log(` ${verifyUrl}`);
124
+ console.log(' and enter this code:\n');
125
+ console.log(` ${userCode}\n`);
126
+ console.log(` Waiting for approval… (expires in ${Math.round(expiresIn / 60)} minutes)`);
127
+
128
+ let wait = Math.max(interval, 2) * 1000;
129
+ const deadline = Date.now() + expiresIn * 1000;
130
+ while (Date.now() < deadline) {
131
+ await new Promise(r => setTimeout(r, wait));
132
+ const r = await postJson(`${api}/cli/device-poll`, { deviceCode });
133
+ if (r.status === 410) throw new Error('The code expired. Run nukebase login --device again.');
134
+ if (!r.ok) throw new Error(r.data.error || `Sign-in failed (${r.status})`);
135
+ if (r.data.status === 'slow_down') { wait += 1000; continue; }
136
+ if (r.data.status === 'approved') return { uid: r.data.uid, token: r.data.token, label: r.data.label };
137
+ }
138
+ throw new Error('The code expired. Run nukebase login --device again.');
139
+ }
140
+
141
+ // Swap an ordinary session for a CLI token, then sign the session out.
142
+ async function exchange(api, uid, token) {
143
+ const r = await postJson(`${api}/cli/exchange`, { device: deviceName(), revokeCurrent: true }, `uid=${uid}; token=${token}`);
144
+ needsNewerServer(r, api);
145
+ if (r.status === 401) throw new Error('That sign-in is no longer valid.');
146
+ if (!r.ok) throw new Error(r.data.error || `Sign-in failed (${r.status})`);
147
+ return r.data;
148
+ }
149
+
150
+ async function emailLink(api, email) {
151
+ const state = crypto.randomBytes(16).toString('hex');
152
+ const { port, result } = await startLoopback(state);
153
+ const r = await postJson(`${api}/magic-link`, { email, redirectUri: `http://127.0.0.1:${port}/callback`, state });
154
+ if (r.data.status !== 'Success') throw new Error(r.data.message || 'Could not send the sign-in link.');
155
+
156
+ console.log(`\n ✉ Sign-in link sent to ${email} (if it has a NukeBase account).`);
157
+ console.log(' Open it in a browser on this computer.');
158
+ console.log('\n Waiting for the link to be clicked… (expires in 5 minutes)');
159
+ const q = await result;
160
+ if (!q.uid || !q.token) throw new Error('The sign-in link did not return a session.');
161
+ return exchange(api, q.uid, q.token);
162
+ }
163
+
164
+ async function password(api, username, pass) {
165
+ const r = await postJson(`${api}/login`, { username, password: pass });
166
+ if (r.data.status !== 'Success' || !r.data.uid || !r.data.token) {
167
+ throw new Error(r.data.message || 'Sign-in failed');
168
+ }
169
+ const out = await exchange(api, r.data.uid, r.data.token);
170
+ return { ...out, label: out.label || username };
171
+ }
172
+
173
+ module.exports = { browser, device, emailLink, password, exchange, deviceName };