@cobinar/dalus 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,27 +18,53 @@ relying on this day to day.
18
18
 
19
19
  ## Install
20
20
 
21
+ ```
22
+ npm install -g @cobinar/dalus
23
+ ```
24
+
25
+ Or from source, for working on dalus itself:
26
+
21
27
  ```
22
28
  cd dalus
23
29
  npm install
24
30
  npm link # makes the `dalus` command available globally
25
31
  ```
26
32
 
27
- (Not published to npm — this is source you own and can change.)
28
-
29
33
  ## Log in
30
34
 
31
35
  ```
32
- dalus login --api-base https://worker.lobby.cobinar.com --token <your bearer token>
36
+ dalus login
33
37
  ```
34
38
 
35
- dalus doesn't implement Cobinar's own signup/login flow that lives in
36
- `cobinar-developers-worker`, a separate system. This command stores a
37
- token you've already obtained from wherever that flow gives you one, the
38
- same way `gh auth login --with-token` accepts a token instead of
39
- reimplementing GitHub's own login. Omit `--api-base`/`--token` to be
40
- prompted instead. Credentials are stored in `~/.dalus/credentials.json`
41
- (mode 0600), never inside a project directory.
39
+ Opens your browser to sign in with Cobinar (the same sign-in every
40
+ cobinar.com visitor uses dalus doesn't have, and doesn't need, its own
41
+ separate login), then stores what it gets back in
42
+ `~/.dalus/credentials.json` (mode 0600), never inside a project
43
+ directory. You'll also be asked for your dashboard-worker URL the first
44
+ time `dalus login --api-base <url>` to skip that prompt.
45
+
46
+ Mechanically: dalus starts a one-shot local server on an ephemeral port,
47
+ opens `https://cobinar.com/cli-login?port=<port>&ls=<random>`, and waits.
48
+ That page runs the exact same auth.cobinar.com round trip as a normal
49
+ sign-in; once it succeeds, cobinar.com's own `/auth/callback` page POSTs a
50
+ short-lived, single-use code to dalus's local server (proving it's
51
+ genuinely that page, not some other local process, via the `ls` value).
52
+ dalus then redeems that code itself, directly against
53
+ `cobinar-developers-worker`'s `/auth/redeem-code` (override with
54
+ `--developers-base` for staging), which is what actually mints the
55
+ signed token dashboard-worker checks — cobinar.com's callback never
56
+ holds or sees that token, only the one-time code. No separate OAuth
57
+ client, no manually copying anything out of a browser session.
58
+
59
+ The stored token lasts 2 hours (`cobinar-developers-worker` sets that
60
+ expiry) — `dalus login` again once a command starts failing with a
61
+ session-expired error.
62
+
63
+ `--token <token> [--api-base <url>]` is kept as a manual fallback for a
64
+ CI runner or a headless box with no browser to open — get a token some
65
+ other way and hand it to dalus directly, the same way `gh auth login
66
+ --with-token` works. A real headless flow (a one-time code, email
67
+ verification) is planned but not built yet.
42
68
 
43
69
  `dalus login --whoami` shows what's currently stored (token
44
70
  redacted). `dalus login --logout` clears it.
package/bin/dalus.js CHANGED
@@ -8,13 +8,18 @@ const { initCommand } = require('../src/commands/init');
8
8
  const HELP = `dalus — deploy Cobinar projects from the command line
9
9
 
10
10
  Usage:
11
- dalus login [--api-base <url>] [--token <token>]
11
+ dalus login [--web-base <url>] [--api-base <url>]
12
+ dalus login --token <token> [--api-base <url>]
12
13
  dalus login --whoami
13
14
  dalus login --logout
14
15
  dalus init [--force]
15
16
  dalus forge [--pages] [--workers] [--storage] [--database] [--vault]
16
17
  [--env <name>] [--name <resourceName>] [--yes]
17
18
 
19
+ Bare "dalus login" opens your browser to sign in with Cobinar and stores
20
+ what it gets back. --token accepts one you already have some other way
21
+ (CI, a headless box with no browser) instead.
22
+
18
23
  Bare "dalus forge" deploys every resource in dalus.jsonc / dalus.toml /
19
24
  dalus.json found in the current directory. Passing one or more of
20
25
  --pages/--workers/--storage/--database/--vault narrows it to just
@@ -55,6 +60,10 @@ async function main() {
55
60
  }
56
61
 
57
62
  main().catch((err) => {
58
- console.error(err.message || err);
63
+ if (err && err.status === 401) {
64
+ console.error('Your Cobinar session expired or was rejected. Run "dalus login" again.');
65
+ } else {
66
+ console.error(err.message || err);
67
+ }
59
68
  process.exitCode = 1;
60
69
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cobinar/dalus",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "Deploy Cobinar projects (Edge Compute, Static Hosting, Object Storage, Document DB, Vault) from the command line.",
5
5
  "bin": {
6
6
  "dalus": "./bin/dalus.js"
@@ -1,7 +1,20 @@
1
1
  'use strict';
2
+ const http = require('http');
3
+ const crypto = require('crypto');
2
4
  const readline = require('readline');
5
+ const { execFile } = require('child_process');
3
6
  const { saveCredentials, clearCredentials, loadCredentials, CREDENTIALS_PATH } = require('../config');
4
7
 
8
+ const DEFAULT_WEB_BASE = 'https://cobinar.com';
9
+ // cobinar-developers-worker — mints the actual bearer token dashboard-worker
10
+ // checks (a signed "workerToken"), from the one-time code cobinar.com's
11
+ // callback hands us. Not the same thing as cobinar.com itself or as
12
+ // auth.cobinar.com (Cobinar's general sign-in provider, which cobinar.com
13
+ // is a client of) — those are three separate services. See the comment on
14
+ // exchangeSsoCode below for the exact handoff.
15
+ const DEFAULT_DEVELOPERS_BASE = 'https://worker.dashboard.cobinar.com';
16
+ const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
17
+
5
18
  function ask(question) {
6
19
  return new Promise((resolve) => {
7
20
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -9,13 +22,145 @@ function ask(question) {
9
22
  });
10
23
  }
11
24
 
12
- // dalus does not implement Cobinar's own signup/login flow itself — that
13
- // lives in cobinar-developers-worker (a separate system) and issues the
14
- // bearer token the dashboard's browser session already uses. This
15
- // command just stores a token you've already obtained (from that flow,
16
- // wherever it prompts you for one) so dalus can send it on every
17
- // request, the same way `gh auth login --with-token` accepts a token
18
- // you got some other way rather than reimplementing GitHub's own login.
25
+ function openBrowser(url) {
26
+ // execFile, not exec no shell involved, so the URL can't be
27
+ // misinterpreted as shell syntax no matter what's in it.
28
+ const done = () => {}; // best-effort; the URL is always printed too
29
+ if (process.platform === 'darwin') execFile('open', [url], done);
30
+ else if (process.platform === 'win32') execFile('cmd', ['/c', 'start', '""', url], done);
31
+ else execFile('xdg-open', [url], done);
32
+ }
33
+
34
+ function postJson(urlString, body) {
35
+ return new Promise((resolve, reject) => {
36
+ const url = new URL(urlString);
37
+ const data = JSON.stringify(body);
38
+ const req = require(url.protocol === 'http:' ? 'http' : 'https').request(
39
+ {
40
+ hostname: url.hostname,
41
+ port: url.port || (url.protocol === 'http:' ? 80 : 443),
42
+ path: url.pathname + url.search,
43
+ method: 'POST',
44
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
45
+ },
46
+ (res) => {
47
+ let out = '';
48
+ res.on('data', (c) => (out += c));
49
+ res.on('end', () => {
50
+ let parsed = null;
51
+ try { parsed = JSON.parse(out); } catch { /* leaves parsed null below */ }
52
+ resolve({ status: res.statusCode, json: parsed });
53
+ });
54
+ },
55
+ );
56
+ req.on('error', reject);
57
+ req.write(data);
58
+ req.end();
59
+ });
60
+ }
61
+
62
+ // The other half of cobinar-developers-worker's POST /auth/redeem-code:
63
+ // cobinar.com's callback page writes { uid, email, name, picture } to a KV
64
+ // namespace it shares with cobinar-developers-worker, keyed by a one-time
65
+ // code, and hands US that code (over the loopback server below, once the
66
+ // browser gets there). Redeeming it here — not on cobinar.com's server, and
67
+ // not in the browser — is what actually mints the workerToken dashboard-
68
+ // worker will accept; nothing before this point has needed dashboard-worker
69
+ // to trust anything, since a workerToken is the first credential in this
70
+ // whole flow it actually checks.
71
+ async function exchangeSsoCode(developersBase, code) {
72
+ const { status, json } = await postJson(`${developersBase}/auth/redeem-code`, { code });
73
+ if (status !== 200 || !json || typeof json.workerToken !== 'string') {
74
+ const detail = json && json.error ? json.error : `HTTP ${status}`;
75
+ throw new Error(`Could not finish signing in (${detail}). Run "dalus login" again.`);
76
+ }
77
+ return json;
78
+ }
79
+
80
+ // Starts a one-shot local server, opens the browser to Cobinar's own web
81
+ // login (cobinar-main's public/cli-login/ page), and waits for that page to
82
+ // POST a one-time SSO code back here once the normal auth.cobinar.com round
83
+ // trip finishes. dalus never talks to auth.cobinar.com directly and never
84
+ // gets its own client_id/secret — it rides along on cobinar.com's existing
85
+ // one, same as any other visitor signing in there. See cobinar-main's
86
+ // public/cli-login/index.html and public/auth/callback.html for the other
87
+ // half of this handshake, and exchangeSsoCode above for what happens to the
88
+ // code once it arrives.
89
+ function browserLogin(webBase, developersBase) {
90
+ // "ls" (local state): proves the browser tab that finishes is the one
91
+ // THIS process opened, not some other local process guessing the port
92
+ // and racing it — the port alone isn't a secret (any local process can
93
+ // see what's listening), this is.
94
+ const ls = crypto.randomBytes(24).toString('hex');
95
+ const allowedOrigin = new URL(webBase).origin;
96
+
97
+ return new Promise((resolve, reject) => {
98
+ let settled = false;
99
+
100
+ const server = http.createServer((req, res) => {
101
+ const url = new URL(req.url, 'http://127.0.0.1');
102
+ res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
103
+ res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
104
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
105
+
106
+ if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
107
+ if (url.pathname !== '/complete' || req.method !== 'POST') { res.writeHead(404); res.end(); return; }
108
+
109
+ let body = '';
110
+ req.on('data', (chunk) => {
111
+ body += chunk;
112
+ if (body.length > 1e6) req.destroy(); // this payload is a short code + a small profile, never anywhere near this size
113
+ });
114
+ req.on('end', async () => {
115
+ let parsed = null;
116
+ try { parsed = JSON.parse(body); } catch { /* falls through to the ok:false below */ }
117
+
118
+ if (!parsed || parsed.ls !== ls || typeof parsed.code !== 'string' || !parsed.code) {
119
+ res.writeHead(400, { 'Content-Type': 'application/json' });
120
+ res.end(JSON.stringify({ ok: false }));
121
+ return; // deliberately not finish()/reject() here — a stray or malicious request to this port shouldn't cancel a login still in progress
122
+ }
123
+
124
+ try {
125
+ const redeemed = await exchangeSsoCode(developersBase, parsed.code);
126
+ res.writeHead(200, { 'Content-Type': 'application/json' });
127
+ res.end(JSON.stringify({ ok: true }));
128
+ finish(null, {
129
+ token: redeemed.workerToken,
130
+ user: parsed.user || { email: redeemed.cobinarEmail, name: redeemed.displayName, picture: redeemed.photoURL },
131
+ });
132
+ } catch (err) {
133
+ res.writeHead(502, { 'Content-Type': 'application/json' });
134
+ res.end(JSON.stringify({ ok: false, error: err.message }));
135
+ finish(err);
136
+ }
137
+ });
138
+ });
139
+
140
+ function finish(err, result) {
141
+ if (settled) return;
142
+ settled = true;
143
+ clearTimeout(timer);
144
+ server.close();
145
+ if (err) reject(err); else resolve(result);
146
+ }
147
+
148
+ const timer = setTimeout(() => {
149
+ finish(new Error(`Timed out waiting for sign-in (${LOGIN_TIMEOUT_MS / 1000}s). Run "dalus login" again.`));
150
+ }, LOGIN_TIMEOUT_MS);
151
+
152
+ server.on('error', (err) => finish(err));
153
+
154
+ server.listen(0, '127.0.0.1', () => {
155
+ const port = server.address().port;
156
+ const loginUrl = `${webBase}/cli-login?port=${port}&ls=${ls}`;
157
+ console.log('Opening your browser to sign in with Cobinar...');
158
+ console.log(`If it doesn't open automatically, visit:\n ${loginUrl}\n`);
159
+ openBrowser(loginUrl);
160
+ });
161
+ });
162
+ }
163
+
19
164
  async function loginCommand(args) {
20
165
  if (args.includes('--logout')) {
21
166
  clearCredentials();
@@ -26,29 +171,59 @@ async function loginCommand(args) {
26
171
  const creds = loadCredentials();
27
172
  if (!creds) { console.log('Not logged in.'); return; }
28
173
  console.log(`API base: ${creds.apiBase}`);
174
+ if (creds.user && creds.user.email) console.log(`Signed in as: ${creds.user.email}`);
29
175
  console.log(`Token: ${creds.token.slice(0, 8)}...${creds.token.slice(-4)} (stored in ${CREDENTIALS_PATH})`);
176
+ console.log('Note: this token expires 2 hours after signing in — "dalus login" again once it does.');
30
177
  return;
31
178
  }
32
179
 
33
- const apiBaseArgIdx = args.indexOf('--api-base');
34
180
  const tokenArgIdx = args.indexOf('--token');
35
- const apiBase = apiBaseArgIdx !== -1 ? args[apiBaseArgIdx + 1] : await ask('Cobinar dashboard-worker URL (e.g. https://worker.lobby.cobinar.com): ');
36
- // Visible input, deliberately — Node's readline has no built-in masked
37
- // prompt, and a hand-rolled no-echo hack is the kind of thing that's
38
- // easy to get subtly wrong (garbled input, doesn't work across
39
- // terminals) without a real TTY to test it against. --token on the
40
- // command line, or piping the value in, avoids the prompt entirely if
41
- // that matters more than a visible paste in a scrollback buffer.
42
- const token = tokenArgIdx !== -1 ? args[tokenArgIdx + 1] : await ask('Bearer token (visible as you type/paste — see the note in README.md): ');
43
-
44
- if (!apiBase || !token) {
45
- console.error('Both a worker URL and a token are required.');
181
+ const apiBaseArgIdx = args.indexOf('--api-base');
182
+
183
+ // --token stays as a manual escape hatch for CI/headless boxes that
184
+ // can't open a browser at all — not the default anymore, but not worth
185
+ // removing until there's a real headless flow (one-time code / email,
186
+ // planned later) to replace it with.
187
+ if (tokenArgIdx !== -1) {
188
+ const token = args[tokenArgIdx + 1];
189
+ const apiBase = apiBaseArgIdx !== -1 ? args[apiBaseArgIdx + 1] : await ask('Cobinar dashboard-worker URL: ');
190
+ if (!token || !apiBase) {
191
+ console.error('Both a worker URL and a token are required.');
192
+ process.exitCode = 1;
193
+ return;
194
+ }
195
+ saveCredentials({ apiBase: apiBase.replace(/\/$/, ''), token });
196
+ console.log(`Saved to ${CREDENTIALS_PATH}. Run "dalus forge" from a project directory to deploy.`);
197
+ return;
198
+ }
199
+
200
+ const webBaseArgIdx = args.indexOf('--web-base');
201
+ const webBase = (webBaseArgIdx !== -1 ? args[webBaseArgIdx + 1] : DEFAULT_WEB_BASE).replace(/\/$/, '');
202
+ const developersBaseArgIdx = args.indexOf('--developers-base');
203
+ const developersBase = (developersBaseArgIdx !== -1 ? args[developersBaseArgIdx + 1] : DEFAULT_DEVELOPERS_BASE).replace(/\/$/, '');
204
+
205
+ let result;
206
+ try {
207
+ result = await browserLogin(webBase, developersBase);
208
+ } catch (err) {
209
+ console.error(err.message);
210
+ process.exitCode = 1;
211
+ return;
212
+ }
213
+
214
+ const apiBase = apiBaseArgIdx !== -1
215
+ ? args[apiBaseArgIdx + 1]
216
+ : await ask('Cobinar dashboard-worker URL (e.g. https://worker.lobby.cobinar.com): ');
217
+ if (!apiBase) {
218
+ console.error('A dashboard-worker URL is required.');
46
219
  process.exitCode = 1;
47
220
  return;
48
221
  }
49
222
 
50
- saveCredentials({ apiBase: apiBase.replace(/\/$/, ''), token });
51
- console.log(`Saved to ${CREDENTIALS_PATH}. Run "dalus forge" from a project directory to deploy.`);
223
+ saveCredentials({ apiBase: apiBase.replace(/\/$/, ''), token: result.token, user: result.user || null });
224
+ console.log(`Signed in${result.user && result.user.email ? ' as ' + result.user.email : ''}. Saved to ${CREDENTIALS_PATH}.`);
225
+ console.log('This session lasts 2 hours — "dalus login" again once it expires.');
226
+ console.log('Run "dalus forge" from a project directory to deploy.');
52
227
  }
53
228
 
54
- module.exports = { loginCommand };
229
+ module.exports = { loginCommand, browserLogin, exchangeSsoCode };