@hystudio/dsh-quota-pill 0.2.7 → 0.2.12

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.
@@ -1,42 +1,69 @@
1
1
  /**
2
2
  * Open Design (open-design.ai) — Cloud coding-plan allowance.
3
3
  *
4
- * Optional provider. Unlike the others it does **not** read an API key: the
5
- * allowance lives in the cloud console's own API and is authenticated with the
6
- * console's session cookie. Set it once:
4
+ * Two ways in, in order:
5
+ *
6
+ * 1. **Control key** (preferred): `vela_ctrl_…` from an Open Design account
7
+ * sign-in. It is accepted by the Bearer-only control-plane endpoints
8
+ * `/billing/preflight` and `/wallet/balance`, alongside
9
+ * `x-vela-workspace-id`. This is the same credential the Open Design CLI
10
+ * (`vela login`) and the magpie/OpenCode provider use.
11
+ * 2. **Console session cookie** (fallback): `__Secure-better-auth.session_token`
12
+ * from the Cloud console; it can read the browser-session-only
13
+ * `/billing/coding-plan-usage` route.
14
+ *
15
+ * Configure one of:
7
16
  *
8
17
  * ~/.dsh/.credentials.yaml
9
18
  * refs:
19
+ * OPENDESIGN_CONTROL_KEY: vela_ctrl_…
20
+ * OPENDESIGN_WORKSPACE_ID: br542…
21
+ * # or
10
22
  * OPENDESIGN_SESSION_COOKIE: <__Secure-better-auth.session_token value>
11
23
  *
12
- * To get that value: open https://open-design.ai/cloud/dashboard while signed in,
13
- * then in the browser's devtools read the `__Secure-better-auth.session_token`
14
- * cookie (it is HttpOnly, so `document.cookie` will not show it).
24
+ * The workspace id is optional when a control key is set: the adapter resolves
25
+ * it through `/workspaces/current`.
15
26
  *
16
27
  * Two traps, both verified:
17
28
  * · The inference gateway at amr-link.open-design.ai/v1 has NO balance
18
29
  * endpoint — it is a chat proxy only. Probing it for quota always 404s.
19
30
  * · amr-api sits behind Cloudflare, which rejects Node's TLS fingerprint with
20
- * `error code: 1010`. The request therefore goes through system curl.
21
- * That is the same request over a different TLS stack, not an auth bypass.
31
+ * `error code: 1010`. Requests therefore go through system curl. That is
32
+ * the same request over a different TLS stack, not an auth bypass.
33
+ *
34
+ * The control-plane endpoint accepts only the control key; an inference
35
+ * `sk-amr-…` API key is a different credential and returns 401 here.
22
36
  */
23
- import { readRef } from './credentials.js';
37
+ import { readRef, readFirstRef } from './credentials.js';
24
38
  import { getJsonViaCurl } from './http.js';
25
39
 
26
40
  const API = process.env.OPENDESIGN_API_BASE || 'https://amr-api.open-design.ai/api/v1';
41
+ const ORIGIN = 'https://open-design.ai';
42
+ const CONTROL_REFS = ['OPENDESIGN_CONTROL_KEY', 'VELA_CONTROL_KEY'];
43
+ const WORKSPACE_REFS = ['OPENDESIGN_WORKSPACE_ID', 'VELA_WORKSPACE_ID', 'OPENDESIGN_WORKSPACE'];
44
+ const COOKIE_REF = 'OPENDESIGN_SESSION_COOKIE';
27
45
 
28
46
  const round2 = (n) => Math.round(n * 100) / 100;
29
47
 
30
- function headers(cookie) {
48
+ /** Headers both control-plane reads need. */
49
+ function controlHeaders(workspaceId) {
50
+ return {
51
+ 'x-vela-workspace-id': workspaceId,
52
+ Origin: ORIGIN,
53
+ Referer: ORIGIN + '/',
54
+ };
55
+ }
56
+
57
+ function cookieHeaders(cookie) {
31
58
  return {
32
59
  Cookie: '__Secure-better-auth.session_token=' + cookie,
33
- Origin: 'https://open-design.ai',
34
- Referer: 'https://open-design.ai/',
60
+ Origin: ORIGIN,
61
+ Referer: ORIGIN + '/',
35
62
  };
36
63
  }
37
64
 
38
65
  /** Window label from its length: 18000s is the 5h cap, 604800s the weekly one. */
39
- function windowKey(seconds) {
66
+ export function windowKey(seconds) {
40
67
  const s = Number(seconds) || 0;
41
68
  if (s > 0 && s <= 6 * 3600) return '5h';
42
69
  if (s > 0 && s <= 8 * 86400) return 'weekly';
@@ -45,50 +72,151 @@ function windowKey(seconds) {
45
72
  }
46
73
 
47
74
  /**
48
- * The workspace id is discoverable, so the user only supplies a cookie.
49
- * `/workspaces/current` already resolves to the default workspace.
75
+ * The workspace whose allowance is shown. A control key can resolve its own
76
+ * active workspace; an explicit ref wins when the account has more than one.
50
77
  */
51
- async function currentWorkspace(cookie) {
52
- const res = await getJsonViaCurl(`${API}/workspaces/current`, { headers: headers(cookie) });
78
+ async function workspaceForControl(controlKey) {
79
+ const configured = readFirstRef(WORKSPACE_REFS);
80
+ if (configured) return { id: configured };
81
+ const res = await getJsonViaCurl(API + '/workspaces/current', {
82
+ token: controlKey,
83
+ headers: { Origin: ORIGIN, Referer: ORIGIN + '/' },
84
+ });
53
85
  if (!res.ok) return { error: res.error };
54
- const id = res.data && res.data.workspaceId;
55
- return id ? { id } : { error: 'no workspaceId in response' };
86
+ const body = res.data || {};
87
+ const id = body.activeWorkspaceId || body.workspaceId;
88
+ return id ? { id } : { error: 'no activeWorkspaceId in /workspaces/current' };
56
89
  }
57
90
 
58
- export async function opendesign() {
59
- const cookie = readRef('OPENDESIGN_SESSION_COOKIE');
60
- if (!cookie) {
91
+ /**
92
+ * Turn the control-plane preflight's `codingPlan.windows` into the pill's
93
+ * window rows. The preflight reports **used** credits; the pill renders
94
+ * **remaining** percent, so the conversion happens once here.
95
+ */
96
+ export function preflightWindows(body) {
97
+ const plan = body && body.codingPlan;
98
+ const out = [];
99
+ for (const w of (plan && plan.windows) || []) {
100
+ const limit = Number(w.limitCredits);
101
+ const used = Number(w.usedCredits);
102
+ if (!Number.isFinite(limit) || limit <= 0 || !Number.isFinite(used)) continue;
103
+ const remain = Math.max(0, limit - used);
104
+ out.push({
105
+ key: windowKey(w.durationSeconds),
106
+ remainPct: round2((remain / limit) * 100),
107
+ resetAt: w.resetsAt || null,
108
+ credits: { remain: round2(remain), total: round2(limit), unit: 'credits' },
109
+ });
110
+ }
111
+ return out;
112
+ }
113
+
114
+ /** Shared result shape for both credential paths (and the tests). */
115
+ export function shapeReading({ windows, balanceUsd, tier = null, source }) {
116
+ if (windows.length) {
117
+ const tight = windows.reduce((a, b) => (a.remainPct <= b.remainPct ? a : b));
118
+ const ORDER = { '5h': 0, weekly: 1, monthly: 2, 'credits-all': 3 };
119
+ windows.sort((a, b) => (ORDER[a.key] ?? 9) - (ORDER[b.key] ?? 9));
120
+ const products = {};
121
+ for (const w of windows) products[w.key] = round2(100 - w.remainPct);
61
122
  return {
62
- kind: 'none',
123
+ kind: 'subscription-quota',
63
124
  provider: 'opendesign',
64
- error: 'no OPENDESIGN_SESSION_COOKIE in ~/.dsh/.credentials.yaml',
65
- setup: {
66
- kind: 'session-cookie',
67
- ref: 'OPENDESIGN_SESSION_COOKIE',
68
- hint: 'Copy the __Secure-better-auth.session_token cookie from the Open Design cloud dashboard.',
69
- },
125
+ account: tier || null,
126
+ remainingPct: tight.remainPct,
127
+ usedPct: round2(100 - tight.remainPct),
128
+ window: tight.key,
129
+ resetAt: tight.resetAt,
130
+ products,
131
+ windows,
132
+ raw: { source, tier },
70
133
  };
71
134
  }
72
135
 
73
- const ws = await currentWorkspace(cookie);
136
+ const balance = Number(balanceUsd);
137
+ if (Number.isFinite(balance)) {
138
+ return {
139
+ kind: 'payg',
140
+ provider: 'opendesign',
141
+ account: tier || null,
142
+ note: 'wallet balance $' + balance.toFixed(2),
143
+ remainingPct: null,
144
+ usedPct: null,
145
+ credits: { remain: round2(balance), total: null, unit: 'USD' },
146
+ raw: { source, tier },
147
+ };
148
+ }
149
+
150
+ return {
151
+ kind: 'none',
152
+ provider: 'opendesign',
153
+ error: source + ' returned no allowance windows and no wallet balance',
154
+ };
155
+ }
156
+
157
+ /** Control key: Bearer-only `/billing/preflight` plus the wallet balance. */
158
+ async function readWithControl(controlKey) {
159
+ const ws = await workspaceForControl(controlKey);
74
160
  if (ws.error) {
75
161
  return {
76
162
  kind: 'probe-failed',
77
163
  provider: 'opendesign',
78
- error: `workspace lookup failed: ${ws.error}`,
164
+ error: 'workspace lookup failed: ' + ws.error,
165
+ hint: 'A Control Key (vela_ctrl_…) is required; an inference sk-amr-… key cannot read the control plane.',
166
+ };
167
+ }
168
+
169
+ const res = await getJsonViaCurl(
170
+ API + '/workspaces/' + encodeURIComponent(ws.id) + '/billing/preflight',
171
+ { token: controlKey, headers: controlHeaders(ws.id), timeoutMs: 25000 },
172
+ );
173
+ if (!res.ok) {
174
+ const denied = res.status === 401 || res.status === 403;
175
+ return {
176
+ kind: 'probe-failed',
177
+ provider: 'opendesign',
178
+ error: 'billing/preflight failed: ' + res.error,
179
+ hint: denied
180
+ ? 'The Control Key is invalid, expired, or not allowed to read this workspace.'
181
+ : undefined,
182
+ };
183
+ }
184
+
185
+ const body = res.data || {};
186
+ const plan = body.codingPlan || null;
187
+ return shapeReading({
188
+ windows: preflightWindows(body),
189
+ balanceUsd: body.balanceUsd ?? (plan && plan.balanceUsd),
190
+ tier: (plan && (plan.tier || plan.membershipTier)) || body.tier || null,
191
+ source: 'billing/preflight (control key)',
192
+ });
193
+ }
194
+
195
+ /** Console session cookie: browser-session-only `/billing/coding-plan-usage`. */
196
+ async function readWithCookie(cookie) {
197
+ const wsRes = await getJsonViaCurl(API + '/workspaces/current', { headers: cookieHeaders(cookie) });
198
+ if (!wsRes.ok) {
199
+ return {
200
+ kind: 'probe-failed',
201
+ provider: 'opendesign',
202
+ error: 'workspace lookup failed: ' + wsRes.error,
79
203
  hint: 'The console session cookie has probably expired — copy a fresh one.',
80
204
  };
81
205
  }
206
+ const id = (wsRes.data && (wsRes.data.activeWorkspaceId || wsRes.data.workspaceId));
207
+ if (!id) {
208
+ return { kind: 'probe-failed', provider: 'opendesign', error: 'no workspaceId in /workspaces/current' };
209
+ }
82
210
 
83
211
  const res = await getJsonViaCurl(
84
- `${API}/workspaces/${ws.id}/billing/coding-plan-usage`,
85
- { headers: headers(cookie), timeoutMs: 25000 },
212
+ API + '/workspaces/' + encodeURIComponent(id) + '/billing/coding-plan-usage',
213
+ { headers: cookieHeaders(cookie), timeoutMs: 25000 },
86
214
  );
87
215
  if (!res.ok) {
88
216
  return {
89
217
  kind: 'probe-failed',
90
218
  provider: 'opendesign',
91
- error: `coding-plan-usage failed: ${res.error}`,
219
+ error: 'coding-plan-usage failed: ' + res.error,
92
220
  hint: res.error && res.error.includes('40')
93
221
  ? 'The console session cookie has probably expired — copy a fresh one.'
94
222
  : undefined,
@@ -108,34 +236,48 @@ export async function opendesign() {
108
236
  credits: { remain: round2(remain), total: round2(limit), unit: 'credits' },
109
237
  });
110
238
  }
239
+ return shapeReading({
240
+ windows,
241
+ balanceUsd: body.balanceUsd,
242
+ tier: body.tier || null,
243
+ source: 'coding-plan-usage (console session)',
244
+ });
245
+ }
111
246
 
112
- if (!windows.length) {
113
- return {
114
- kind: 'none',
115
- provider: 'opendesign',
116
- error: `coding-plan-usage returned no windows (tier ${body.tier || 'unknown'})`,
117
- };
247
+ export async function opendesign() {
248
+ const controlKey = readFirstRef(CONTROL_REFS);
249
+ const cookie = readRef(COOKIE_REF);
250
+
251
+ let controlResult = null;
252
+ if (controlKey) {
253
+ controlResult = await readWithControl(controlKey);
254
+ if (controlResult.kind === 'subscription-quota' || controlResult.kind === 'payg') {
255
+ return controlResult;
256
+ }
118
257
  }
119
258
 
120
- // The tightest window is what blocks the next request, but the list itself
121
- // stays in canonical order (5h before weekly) so rows match every other
122
- // provider instead of reshuffling as the numbers move.
123
- const tight = windows.reduce((a, b) => (a.remainPct <= b.remainPct ? a : b));
124
- const ORDER = { '5h': 0, weekly: 1, monthly: 2, 'credits-all': 3 };
125
- windows.sort((a, b) => (ORDER[a.key] ?? 9) - (ORDER[b.key] ?? 9));
126
- const products = {};
127
- for (const w of windows) products[w.key] = round2(100 - w.remainPct);
259
+ if (cookie) {
260
+ const cookieResult = await readWithCookie(cookie);
261
+ if (cookieResult.kind === 'subscription-quota' || cookieResult.kind === 'payg') {
262
+ return cookieResult;
263
+ }
264
+ return controlResult || cookieResult;
265
+ }
266
+
267
+ if (controlResult) return controlResult;
128
268
 
129
269
  return {
130
- kind: 'subscription-quota',
270
+ kind: 'none',
131
271
  provider: 'opendesign',
132
- account: body.tier || null,
133
- remainingPct: tight.remainPct,
134
- usedPct: round2(100 - tight.remainPct),
135
- window: tight.key,
136
- resetAt: tight.resetAt,
137
- products,
138
- windows,
139
- raw: { source: 'coding-plan-usage (console session)', tier: body.tier },
272
+ error: 'no OPENDESIGN_CONTROL_KEY (or VELA_CONTROL_KEY) and no OPENDESIGN_SESSION_COOKIE in ~/.dsh/.credentials.yaml',
273
+ setup: {
274
+ kind: 'control-key',
275
+ ref: 'OPENDESIGN_CONTROL_KEY',
276
+ refs: CONTROL_REFS,
277
+ workspaceRefs: WORKSPACE_REFS,
278
+ cookieRef: COOKIE_REF,
279
+ hint: 'Add the Control Key from an Open Design account sign-in (vela_ctrl_…); '
280
+ + 'a console session cookie also works as a fallback.',
281
+ },
140
282
  };
141
283
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Start the sidecar as soon as the DSH host plugin loads.
3
+ *
4
+ * The pill is useless without the sidecar, and asking a fresh user to install a
5
+ * launchd/systemd unit before they see any quota is the opposite of out-of-box.
6
+ * The host half therefore spawns the bundled server when nothing is listening
7
+ * on the port.
8
+ *
9
+ * - If a sidecar is already running (launchd, systemd, a previous DSH launch),
10
+ * the port check makes this a no-op instead of a duplicate.
11
+ * - The child is detached so it survives a DSH window reload and can serve the
12
+ * next launch too. QUOTA_PILL_NO_AUTOSTART=1 opts out (service managers).
13
+ * - Electron's executable is not Node; ELECTRON_RUN_AS_NODE=1 turns it into one.
14
+ */
15
+ import { spawn } from 'node:child_process';
16
+ import fs from 'node:fs';
17
+ import net from 'node:net';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ const DEFAULT_PORT = 8319;
21
+ const SERVER = fileURLToPath(new URL('../bin/quota-pill-server.js', import.meta.url));
22
+
23
+ export function sidecarPort() {
24
+ const port = Number(process.env.QUOTA_PILL_PORT || DEFAULT_PORT);
25
+ return Number.isInteger(port) && port > 0 && port < 65536 ? port : DEFAULT_PORT;
26
+ }
27
+
28
+ /** True when something (healthy or not) is already listening. */
29
+ export function isPortOpen(port, timeoutMs = 500) {
30
+ return new Promise((resolve) => {
31
+ const socket = net.connect({ host: '127.0.0.1', port });
32
+ let settled = false;
33
+ const done = (value) => {
34
+ if (settled) return;
35
+ settled = true;
36
+ socket.destroy();
37
+ resolve(value);
38
+ };
39
+ socket.once('connect', () => done(true));
40
+ socket.once('error', () => done(false));
41
+ // A port that accepts TCP but never speaks still means "do not start a
42
+ // second copy"; the health endpoint is the client's job.
43
+ socket.setTimeout(timeoutMs, () => done(true));
44
+ });
45
+ }
46
+
47
+ export async function ensureSidecar({ port = sidecarPort(), server = SERVER, env = process.env } = {}) {
48
+ if (env.QUOTA_PILL_NO_AUTOSTART === '1' || env.QUOTA_PILL_NO_AUTOSTART === 'true') return null;
49
+ if (!fs.existsSync(server)) return null;
50
+ if (await isPortOpen(port)) return null;
51
+
52
+ const childEnv = { ...env, QUOTA_PILL_PORT: String(port) };
53
+ if (process.versions.electron && !childEnv.ELECTRON_RUN_AS_NODE) {
54
+ childEnv.ELECTRON_RUN_AS_NODE = '1';
55
+ }
56
+ const child = spawn(process.execPath, [server], {
57
+ detached: true,
58
+ stdio: 'ignore',
59
+ windowsHide: true,
60
+ env: childEnv,
61
+ });
62
+ child.unref();
63
+ return child;
64
+ }
@@ -82,14 +82,33 @@ function unquote(v) {
82
82
  return v;
83
83
  }
84
84
 
85
+ /** Pick the first present, non-empty ref from an ordered list. */
86
+ export function pickFirstRef(refs, names) {
87
+ for (const name of names || []) {
88
+ const v = refs && refs[name];
89
+ if (typeof v === 'string' && v) return v;
90
+ }
91
+ return null;
92
+ }
93
+
85
94
  /** Read one DSH credential ref. Returns null when absent. */
86
95
  export function readRef(name) {
96
+ return readFirstRef([name]);
97
+ }
98
+
99
+ /**
100
+ * Read the first of several acceptable credential refs.
101
+ *
102
+ * DSH names a provider's key after its configured `apiKeyEnv` (for example
103
+ * `COMMANDCODE_GOAT_API_KEY`), while older docs and other setups used the
104
+ * short provider name. Accepting both keeps a key the user already has
105
+ * visible instead of reporting the provider as unconfigured.
106
+ */
107
+ export function readFirstRef(names) {
87
108
  try {
88
109
  const text = fs.readFileSync(CREDENTIALS_FILE, 'utf8');
89
110
  const doc = parseCredentialsYaml(text);
90
- const refs = doc.refs || {};
91
- const v = refs[name];
92
- return typeof v === 'string' && v ? v : null;
111
+ return pickFirstRef(doc.refs || {}, names);
93
112
  } catch {
94
113
  return null;
95
114
  }
@@ -149,17 +168,49 @@ export function hasCliProxy() {
149
168
  }
150
169
  }
151
170
 
171
+ /** Credential sources contributed by user adapters, keyed by provider id. */
172
+ const userCredentialSources = new Map();
173
+
174
+ export function registerCredentialSource(provider, source) {
175
+ if (provider && source) userCredentialSources.set(String(provider), source);
176
+ }
177
+
178
+ export function clearUserCredentialSources() {
179
+ userCredentialSources.clear();
180
+ }
181
+
152
182
  /** Where a given provider's credentials come from, for the setup docs / AGENTS.md. */
153
183
  export function credentialSource(provider) {
184
+ const fromAdapter = userCredentialSources.get(provider);
185
+ if (fromAdapter) return fromAdapter;
154
186
  if (provider === 'xai' || provider === 'codex' || provider === 'agy') {
155
- return { kind: 'cli-proxy', dir: CLI_PROXY_DIR, ref: null };
187
+ return { kind: 'cli-proxy', dir: CLI_PROXY_DIR, ref: null, refs: [] };
188
+ }
189
+ if (provider === 'deepseek') {
190
+ // The official account is read through DSH's own Platform sign-in; there is
191
+ // no credential the user can add to this file.
192
+ return { kind: 'dsh-account', dir: null, ref: null, refs: [] };
193
+ }
194
+ if (provider === 'opendesign') {
195
+ // The quota control plane authenticates with a Control Key (vela_ctrl_…);
196
+ // a console session cookie is a fallback. The inference sk-amr-… key is a
197
+ // different credential and cannot read it.
198
+ return {
199
+ kind: 'control-key',
200
+ dir: null,
201
+ ref: 'OPENDESIGN_CONTROL_KEY',
202
+ refs: ['OPENDESIGN_CONTROL_KEY', 'VELA_CONTROL_KEY'],
203
+ workspaceRefs: ['OPENDESIGN_WORKSPACE_ID', 'VELA_WORKSPACE_ID'],
204
+ cookieRef: 'OPENDESIGN_SESSION_COOKIE',
205
+ };
156
206
  }
157
- const refByProvider = {
158
- kimi: 'KIMI_CODING_API_KEY',
159
- commandcode: 'COMMANDCODE_API_KEY',
160
- opencode: 'OPENCODE_GO_API_KEY',
207
+ const refsByProvider = {
208
+ kimi: ['KIMI_CODING_API_KEY'],
209
+ commandcode: ['COMMANDCODE_API_KEY', 'COMMANDCODE_GOAT_API_KEY'],
210
+ opencode: ['OPENCODE_GO_API_KEY'],
161
211
  };
162
- return { kind: 'api-key', dir: null, ref: refByProvider[provider] || null };
212
+ const refs = refsByProvider[provider] || [];
213
+ return { kind: 'api-key', dir: null, ref: refs[0] || null, refs };
163
214
  }
164
215
 
165
216
  /**
package/src/http.js CHANGED
@@ -67,6 +67,51 @@ export function getJsonViaCurl(url, { token, headers = {}, timeoutMs = DEFAULT_T
67
67
  });
68
68
  }
69
69
 
70
+ /** POST JSON through the system curl (device authorization, control-plane calls). */
71
+ export function postJsonViaCurl(url, body, { token, headers = {}, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
72
+ const args = [
73
+ '-sS',
74
+ '--max-time', String(Math.ceil(timeoutMs / 1000)),
75
+ '-H', 'Accept: application/json',
76
+ '-H', 'Content-Type: application/json',
77
+ '-w', '\n%{http_code}',
78
+ ];
79
+ if (!proxyUrl()) args.push('--noproxy', '*');
80
+ if (token) args.push('-H', 'Authorization: Bearer ' + token);
81
+ if (proxyUrl()) args.push('--proxy', proxyUrl());
82
+ for (const [k, v] of Object.entries(headers)) {
83
+ if (v != null && v !== '') args.push('-H', `${k}: ${v}`);
84
+ }
85
+ args.push('--data', JSON.stringify(body ?? {}));
86
+ args.push(url);
87
+
88
+ return new Promise((resolve) => {
89
+ execFile('curl', args, { maxBuffer: 8 * 1024 * 1024 }, (err, stdout) => {
90
+ if (err && !stdout) {
91
+ resolve({ ok: false, error: String(err.message || err) });
92
+ return;
93
+ }
94
+ const text = String(stdout || '');
95
+ const cut = text.lastIndexOf('\n');
96
+ if (cut < 0) {
97
+ resolve({ ok: false, error: 'empty response' });
98
+ return;
99
+ }
100
+ const responseBody = text.slice(0, cut);
101
+ const status = Number(text.slice(cut + 1).trim());
102
+ if (status < 200 || status >= 300) {
103
+ resolve({ ok: false, status, error: `http ${status}: ${responseBody.slice(0, 160)}` });
104
+ return;
105
+ }
106
+ try {
107
+ resolve({ ok: true, status, data: JSON.parse(responseBody) });
108
+ } catch {
109
+ resolve({ ok: false, status, error: 'unparsable json: ' + responseBody.slice(0, 120) });
110
+ }
111
+ });
112
+ });
113
+ }
114
+
70
115
  /** GET JSON with fetch. Cheaper; fine for upstreams without TLS fingerprinting. */
71
116
  export async function getJson(url, { token, headers = {}, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
72
117
  const controller = new AbortController();
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Open Design account sign-in — the device authorization `vela login` runs.
3
+ *
4
+ * The control plane (https://amr-api.open-design.ai) issues two keys:
5
+ * · control key (`vela_ctrl_…`) — plan, wallet, Design Plan allowance
6
+ * · runtime key (`sk-amr-…`) — model requests at amr-link
7
+ *
8
+ * The quota pill only needs the control key. The CLI flow is:
9
+ * POST /api/v1/cli/device-authorizations → deviceId, deviceSecret, code, activation URL
10
+ * user approves the URL in a signed-in browser
11
+ * POST /api/v1/cli/device-authorizations/:id/token { deviceSecret } → the keys
12
+ */
13
+ import os from 'node:os';
14
+ import { getJsonViaCurl, postJsonViaCurl } from './http.js';
15
+
16
+ export const CONTROL_ORIGIN = 'https://amr-api.open-design.ai';
17
+ export const CONTROL_API = CONTROL_ORIGIN + '/api/v1';
18
+ const CLI_VERSION = '0.1.4';
19
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
20
+
21
+ function headers(extra = {}) {
22
+ return { Origin: 'https://open-design.ai', Referer: 'https://open-design.ai/', ...extra };
23
+ }
24
+
25
+ export async function startDevice() {
26
+ const res = await postJsonViaCurl(CONTROL_API + '/cli/device-authorizations', {
27
+ cliVersion: CLI_VERSION,
28
+ host: os.hostname() || 'dsh',
29
+ profile: 'prod',
30
+ }, { headers: headers() });
31
+ if (!res.ok) throw new Error('device authorization failed: ' + res.error);
32
+ const d = res.data || {};
33
+ if (!d.deviceId || !d.deviceSecret) {
34
+ throw new Error('device authorization response is missing deviceId/deviceSecret');
35
+ }
36
+ return {
37
+ deviceId: String(d.deviceId),
38
+ deviceSecret: String(d.deviceSecret),
39
+ userCode: d.userCode ? String(d.userCode) : '',
40
+ activationUrl: d.activationUrl ? String(d.activationUrl) : '',
41
+ intervalSeconds: Number(d.pollIntervalSeconds) > 0 ? Number(d.pollIntervalSeconds) : 2,
42
+ expiresAt: Date.parse(d.expiresAt || '') || Date.now() + 10 * 60_000,
43
+ };
44
+ }
45
+
46
+ export async function waitForDevice(device, { onPending } = {}) {
47
+ for (;;) {
48
+ if (Date.now() > device.expiresAt) {
49
+ throw new Error('the device code expired before it was approved');
50
+ }
51
+ await sleep((device.intervalSeconds || 2) * 1000);
52
+ const res = await postJsonViaCurl(
53
+ CONTROL_API + '/cli/device-authorizations/' + encodeURIComponent(device.deviceId) + '/token',
54
+ { deviceSecret: device.deviceSecret },
55
+ { headers: headers() },
56
+ );
57
+ if (res.ok && res.data && res.data.controlKey && res.data.runtimeKey) {
58
+ return {
59
+ controlKey: String(res.data.controlKey),
60
+ runtimeKey: String(res.data.runtimeKey),
61
+ email: res.data.user && res.data.user.email ? String(res.data.user.email) : null,
62
+ };
63
+ }
64
+ const status = String((res.data && res.data.status) || '').toLowerCase();
65
+ if (status === 'expired' || status === 'denied' || status === 'cancelled' || status === 'canceled' || status === 'rejected') {
66
+ throw new Error('the device authorization was ' + status);
67
+ }
68
+ if (res.status === 401 && /invalid_device_secret/.test(String((res.data && res.data.error) || ''))) {
69
+ throw new Error('the device code is no longer valid');
70
+ }
71
+ if (onPending) onPending(status || 'pending');
72
+ }
73
+ }
74
+
75
+ /** The workspace whose allowance is shown. Explicit ref wins; otherwise ask. */
76
+ export async function workspaceOf(controlKey) {
77
+ const res = await getJsonViaCurl(CONTROL_API + '/workspaces/current', {
78
+ token: controlKey,
79
+ headers: headers(),
80
+ });
81
+ if (!res.ok) throw new Error('workspace lookup failed: ' + res.error);
82
+ const body = res.data || {};
83
+ const id = body.activeWorkspaceId || body.workspaceId;
84
+ if (!id) throw new Error('the account has no active workspace');
85
+ return String(id);
86
+ }
87
+
88
+ /** Run the full device login. `onDevice` receives the URL/code to show the user. */
89
+ export async function loginOpenDesign({ onDevice, onPending } = {}) {
90
+ const device = await startDevice();
91
+ if (onDevice) onDevice(device);
92
+ const keys = await waitForDevice(device, { onPending });
93
+ const workspaceId = await workspaceOf(keys.controlKey);
94
+ return { ...keys, workspaceId };
95
+ }