@relesio/cli 0.2.6 → 0.3.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
@@ -34,13 +34,22 @@ bun link
34
34
 
35
35
  ## Quick Start
36
36
 
37
- 1. **Authenticate** with your API token:
37
+ 1. **Authenticate** via browser (recommended):
38
38
 
39
39
  ```bash
40
- relesio auth login --token rls_your_token_here
40
+ relesio auth login
41
41
  ```
42
42
 
43
- Or use the `RELESIO_API_TOKEN` environment variable.
43
+ The CLI prints a short code, opens your browser to `relesio.com/device`, and waits
44
+ for you to approve. Once approved, a named API key is stored automatically.
45
+
46
+ For headless / CI environments, pass an API key directly:
47
+
48
+ ```bash
49
+ relesio auth login --token rls_your_token_here
50
+ # or set the environment variable
51
+ export RELESIO_API_TOKEN=rls_your_token_here
52
+ ```
44
53
 
45
54
  2. **Upload** a built frontend:
46
55
 
@@ -58,12 +67,12 @@ bun link
58
67
 
59
68
  ### Authentication
60
69
 
61
- | Command | Description |
62
- | --------------------- | ------------------------ |
63
- | `relesio auth login` | Authenticate with token |
64
- | `relesio auth status` | Show current auth state |
65
- | `relesio auth logout` | Clear stored credentials |
66
- | `relesio whoami` | Show current user info |
70
+ | Command | Description |
71
+ | --------------------- | ------------------------------------------------ |
72
+ | `relesio auth login` | Authenticate via browser (device flow) or token |
73
+ | `relesio auth status` | Show current auth state |
74
+ | `relesio auth logout` | Clear stored credentials |
75
+ | `relesio whoami` | Show current user info |
67
76
 
68
77
  ### Organizations & Projects
69
78
 
@@ -90,17 +99,30 @@ bun link
90
99
  ### Authentication
91
100
 
92
101
  ```bash
93
- # Interactive login (prompts for token)
102
+ # Browser-based login (recommended opens relesio.com/device)
94
103
  relesio auth login
95
104
 
96
- # With token flag
105
+ # Headless / CI: pass an API key directly
97
106
  relesio auth login --token rls_...
98
107
 
99
- # Via environment variable
108
+ # Or export the token as an environment variable
100
109
  export RELESIO_API_TOKEN=rls_...
101
110
  relesio auth status
102
111
  ```
103
112
 
113
+ **Device flow steps:**
114
+
115
+ 1. Run `relesio auth login` — a short code is printed in the terminal.
116
+ 2. Your browser opens automatically to `https://relesio.com/device`.
117
+ 3. Enter the code (or confirm the pre-filled one), then click **Approve**.
118
+ 4. The CLI detects the approval, creates a named `rls_*` API key
119
+ (`CLI - <hostname> - <date>`), and stores it locally.
120
+ 5. All subsequent commands use the stored key automatically.
121
+
122
+ If the browser cannot open (headless server), the URL and code are printed —
123
+ navigate to the URL manually and enter the code. The CLI keeps polling until
124
+ you approve, deny, or the 30-minute code expires.
125
+
104
126
  ### Upload
105
127
 
106
128
  ```bash
@@ -140,12 +162,12 @@ relesio rollback my-app --env production --yes
140
162
 
141
163
  ### Environment Variables
142
164
 
143
- | Variable | Description | Default |
144
- | ------------------ | --------------------------------------- | -------------------------- |
145
- | `RELESIO_API_TOKEN`| API token (starts with `rls_`) | — |
146
- | `RELESIO_API_URL` | API base URL | `https://api.relesio.com` |
147
- | `RELESIO_ORG_ID` | Override active organization | — |
148
- | `DEBUG` | Enable debug output | — |
165
+ | Variable | Description | Default |
166
+ | ------------------- | --------------------------------------------------------------- | ------------------------- |
167
+ | `RELESIO_API_TOKEN` | API key (`rls_*`). Bypasses device flow — required for CI/CD. | — |
168
+ | `RELESIO_API_URL` | Override the API base URL | `https://api.relesio.com` |
169
+ | `RELESIO_ORG_ID` | Override active organization ID | — |
170
+ | `DEBUG` | Enable verbose debug output | — |
149
171
 
150
172
  ### Organization Context
151
173
 
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Device Authorization Flow — Unit Tests
3
+ *
4
+ * Tests the CLI login command's device flow logic including:
5
+ * - Device code request
6
+ * - Polling loop with RFC 8628 error handling
7
+ * - API key exchange
8
+ * - Org selection after authentication
9
+ * - Backward-compatible --token login
10
+ * - Integration tests calling the actual exported functions
11
+ */
12
+ export {};
@@ -0,0 +1,450 @@
1
+ /**
2
+ * Device Authorization Flow — Unit Tests
3
+ *
4
+ * Tests the CLI login command's device flow logic including:
5
+ * - Device code request
6
+ * - Polling loop with RFC 8628 error handling
7
+ * - API key exchange
8
+ * - Org selection after authentication
9
+ * - Backward-compatible --token login
10
+ * - Integration tests calling the actual exported functions
11
+ */
12
+ import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test";
13
+ import os from "node:os";
14
+ // Prevent the `open` package from opening real browser tabs during tests.
15
+ mock.module("open", () => ({ default: mock(() => Promise.resolve()) }));
16
+ import { DEVICE_ERROR, saveAuthAndSelectOrg, loginWithToken, loginWithDeviceFlow, deriveFrontendUrl } from "../login.js";
17
+ import { ConfigManager } from "../../../lib/config/manager.js";
18
+ import { RelesioAPIClient } from "../../../lib/api/client.js";
19
+ // --------------- Helpers ---------------
20
+ function makeDeviceCodeResponse(overrides) {
21
+ return {
22
+ device_code: "dev_abc123",
23
+ user_code: "ABCD-1234",
24
+ verification_uri: "https://relesio.com/device",
25
+ verification_uri_complete: "https://relesio.com/device?user_code=ABCD-1234",
26
+ expires_in: 1800,
27
+ interval: 5,
28
+ ...overrides
29
+ };
30
+ }
31
+ function makeErrorResponse(error, description) {
32
+ return { error, error_description: description };
33
+ }
34
+ function makeFetchResponse(status, body) {
35
+ return new Response(JSON.stringify(body), {
36
+ status,
37
+ headers: { "Content-Type": "application/json" }
38
+ });
39
+ }
40
+ function makeMeResponse(overrides) {
41
+ return {
42
+ message: "ok",
43
+ data: {
44
+ userId: "user_123",
45
+ email: "user@example.com",
46
+ name: "Test User",
47
+ activeOrganizationId: "org_123",
48
+ activeOrganizationName: "Test Org",
49
+ activeOrganizationSlug: "test-org",
50
+ organizations: [
51
+ {
52
+ id: "org_123",
53
+ name: "Test Org",
54
+ slug: "test-org",
55
+ role: "OWNER"
56
+ }
57
+ ],
58
+ apiKey: {
59
+ id: "key_1",
60
+ name: "CLI - host - 2026-03-01",
61
+ metadata: null
62
+ },
63
+ ...(overrides?.organizations !== undefined
64
+ ? { organizations: overrides.organizations }
65
+ : {}),
66
+ ...(overrides?.activeOrganizationId !== undefined
67
+ ? { activeOrganizationId: overrides.activeOrganizationId }
68
+ : {})
69
+ }
70
+ };
71
+ }
72
+ // --------------- Unit Tests ---------------
73
+ describe("Device Authorization Flow", () => {
74
+ describe("RFC 8628 Error Codes", () => {
75
+ it("should define all required error codes", () => {
76
+ expect(DEVICE_ERROR.PENDING).toBe("authorization_pending");
77
+ expect(DEVICE_ERROR.SLOW_DOWN).toBe("slow_down");
78
+ expect(DEVICE_ERROR.DENIED).toBe("access_denied");
79
+ expect(DEVICE_ERROR.EXPIRED).toBe("expired_token");
80
+ });
81
+ });
82
+ describe("Device Code Request", () => {
83
+ it("should parse device code response correctly", () => {
84
+ const response = makeDeviceCodeResponse();
85
+ expect(response.device_code).toBe("dev_abc123");
86
+ expect(response.user_code).toBe("ABCD-1234");
87
+ expect(response.verification_uri).toBe("https://relesio.com/device");
88
+ expect(response.verification_uri_complete).toContain("user_code=ABCD-1234");
89
+ expect(response.expires_in).toBe(1800);
90
+ expect(response.interval).toBe(5);
91
+ });
92
+ it("should use default interval of 5 when not provided", () => {
93
+ const response = makeDeviceCodeResponse({
94
+ interval: undefined
95
+ });
96
+ const pollingIntervalMs = (response.interval ?? 5) * 1000;
97
+ expect(pollingIntervalMs).toBe(5000);
98
+ });
99
+ it("should use default expires_in of 1800 when not provided", () => {
100
+ const response = makeDeviceCodeResponse({
101
+ expires_in: undefined
102
+ });
103
+ const expiresIn = response.expires_in ?? 1800;
104
+ expect(expiresIn).toBe(1800);
105
+ });
106
+ });
107
+ describe("Polling Logic", () => {
108
+ it("should continue polling on authorization_pending", () => {
109
+ const errorBody = makeErrorResponse(DEVICE_ERROR.PENDING);
110
+ const shouldContinue = errorBody.error === DEVICE_ERROR.PENDING;
111
+ expect(shouldContinue).toBe(true);
112
+ });
113
+ it("should add 5 seconds to interval on slow_down (RFC 8628 §3.5)", () => {
114
+ let currentInterval = 5000;
115
+ const errorBody = makeErrorResponse(DEVICE_ERROR.SLOW_DOWN);
116
+ if (errorBody.error === DEVICE_ERROR.SLOW_DOWN) {
117
+ currentInterval += 5000;
118
+ }
119
+ expect(currentInterval).toBe(10000);
120
+ });
121
+ it("should add 5s cumulatively on repeated slow_down", () => {
122
+ let currentInterval = 5000;
123
+ for (let i = 0; i < 3; i++) {
124
+ currentInterval += 5000;
125
+ }
126
+ expect(currentInterval).toBe(20000);
127
+ });
128
+ it("should exit with code 1 on access_denied", () => {
129
+ const errorBody = makeErrorResponse(DEVICE_ERROR.DENIED);
130
+ const shouldExit = errorBody.error === DEVICE_ERROR.DENIED;
131
+ expect(shouldExit).toBe(true);
132
+ });
133
+ it("should exit with code 1 on expired_token", () => {
134
+ const errorBody = makeErrorResponse(DEVICE_ERROR.EXPIRED);
135
+ const shouldExit = errorBody.error === DEVICE_ERROR.EXPIRED;
136
+ expect(shouldExit).toBe(true);
137
+ });
138
+ it("should enforce client-side deadline with 30s buffer", () => {
139
+ const expiresIn = 1800;
140
+ const now = Date.now();
141
+ const deadline = now + (expiresIn + 30) * 1000;
142
+ expect(deadline - now).toBe(1830 * 1000);
143
+ });
144
+ it("should apply minimum 60s floor to expires_in to guard against malformed server responses", () => {
145
+ // Mirrors the production code: Math.max(deviceCodeData.expires_in ?? 1800, 60)
146
+ const applyExpiresFloor = (v) => Math.max(v ?? 1800, 60);
147
+ expect(applyExpiresFloor(0)).toBe(60);
148
+ expect(applyExpiresFloor(30)).toBe(60);
149
+ expect(applyExpiresFloor(undefined)).toBe(1800);
150
+ expect(applyExpiresFloor(1800)).toBe(1800);
151
+ });
152
+ it("should not sleep before the first poll (immediate first check)", () => {
153
+ let isFirstPoll = true;
154
+ const sleepCalls = [];
155
+ const mockSleep = (ms) => {
156
+ sleepCalls.push(ms);
157
+ };
158
+ if (!isFirstPoll) {
159
+ mockSleep(5000);
160
+ }
161
+ isFirstPoll = false;
162
+ if (!isFirstPoll) {
163
+ mockSleep(5000);
164
+ }
165
+ expect(sleepCalls).toHaveLength(1);
166
+ expect(sleepCalls[0]).toBe(5000);
167
+ });
168
+ });
169
+ describe("API Key Exchange", () => {
170
+ it("should generate correct key name with hostname and date", () => {
171
+ const hostname = os.hostname();
172
+ const date = new Date().toISOString().slice(0, 10);
173
+ const keyName = `CLI - ${hostname} - ${date}`;
174
+ expect(keyName).toContain("CLI - ");
175
+ expect(keyName).toContain(hostname);
176
+ expect(keyName).toMatch(/\d{4}-\d{2}-\d{2}$/);
177
+ });
178
+ it("should send Bearer token in Authorization header for key exchange", () => {
179
+ const accessToken = "test_access_token_xyz";
180
+ const headers = {
181
+ "Content-Type": "application/json",
182
+ Authorization: `Bearer ${accessToken}`
183
+ };
184
+ expect(headers.Authorization).toBe(`Bearer ${accessToken}`);
185
+ });
186
+ });
187
+ describe("Browser Open", () => {
188
+ it("should open the derived dashboard URL with user_code appended", () => {
189
+ const apiBaseUrl = "https://api.relesio.com";
190
+ const userCode = "ABCD-1234";
191
+ const frontendBase = deriveFrontendUrl(apiBaseUrl);
192
+ const browserUri = `${frontendBase}/device?user_code=${userCode}`;
193
+ expect(browserUri).toBe("https://relesio.com/device?user_code=ABCD-1234");
194
+ });
195
+ it("should derive localhost:3000 browser URL when API is on localhost", () => {
196
+ const apiBaseUrl = "http://localhost:8787";
197
+ const userCode = "WXYZ-5678";
198
+ const frontendBase = deriveFrontendUrl(apiBaseUrl);
199
+ const browserUri = `${frontendBase}/device?user_code=${userCode}`;
200
+ expect(browserUri).toBe("http://localhost:3000/device?user_code=WXYZ-5678");
201
+ });
202
+ });
203
+ describe("deriveFrontendUrl", () => {
204
+ it("maps api.relesio.com to relesio.com", () => {
205
+ expect(deriveFrontendUrl("https://api.relesio.com")).toBe("https://relesio.com");
206
+ });
207
+ it("maps api-stage.relesio.com to stage.relesio.com", () => {
208
+ expect(deriveFrontendUrl("https://api-stage.relesio.com")).toBe("https://stage.relesio.com");
209
+ });
210
+ it("maps localhost:8787 to localhost:3000 preserving protocol", () => {
211
+ expect(deriveFrontendUrl("http://localhost:8787")).toBe("http://localhost:3000");
212
+ });
213
+ it("maps 127.0.0.1 to localhost:3000 preserving protocol", () => {
214
+ expect(deriveFrontendUrl("http://127.0.0.1:8787")).toBe("http://localhost:3000");
215
+ });
216
+ it("passes through unknown API hosts unchanged", () => {
217
+ expect(deriveFrontendUrl("https://custom.api.example.com")).toBe("https://custom.api.example.com");
218
+ });
219
+ it("returns the input unchanged when URL is invalid", () => {
220
+ expect(deriveFrontendUrl("not-a-valid-url")).toBe("not-a-valid-url");
221
+ });
222
+ });
223
+ });
224
+ // --------------- Integration Tests: saveAuthAndSelectOrg ---------------
225
+ describe("saveAuthAndSelectOrg (integration)", () => {
226
+ let saveSpy;
227
+ let originalFetch;
228
+ beforeEach(() => {
229
+ originalFetch = globalThis.fetch;
230
+ saveSpy = spyOn(ConfigManager, "save").mockImplementation(() => { });
231
+ });
232
+ afterEach(() => {
233
+ globalThis.fetch = originalFetch;
234
+ saveSpy.mockRestore();
235
+ });
236
+ it("stores activeOrganizationId directly when already set in response", async () => {
237
+ const meData = makeMeResponse().data;
238
+ await saveAuthAndSelectOrg("rls_test_key", meData);
239
+ expect(saveSpy).toHaveBeenCalledTimes(1);
240
+ const savedArgs = saveSpy.mock.calls[0][0];
241
+ expect(savedArgs.apiToken).toBe("rls_test_key");
242
+ expect(savedArgs.activeOrganizationId).toBe("org_123");
243
+ expect(savedArgs.userEmail).toBe("user@example.com");
244
+ });
245
+ it("auto-selects the only org when activeOrganizationId is null", async () => {
246
+ const meData = {
247
+ ...makeMeResponse().data,
248
+ activeOrganizationId: null,
249
+ activeOrganizationName: null,
250
+ activeOrganizationSlug: null,
251
+ organizations: [
252
+ {
253
+ id: "org_solo",
254
+ name: "Solo Org",
255
+ slug: "solo-org",
256
+ role: "OWNER"
257
+ }
258
+ ]
259
+ };
260
+ await saveAuthAndSelectOrg("rls_solo_key", meData);
261
+ const saved = saveSpy.mock.calls[0][0];
262
+ expect(saved.activeOrganizationId).toBe("org_solo");
263
+ expect(saved.activeOrganizationName).toBe("Solo Org");
264
+ expect(saved.activeOrganizationSlug).toBe("solo-org");
265
+ // Legacy fields kept in sync
266
+ expect(saved.currentOrgId).toBe("org_solo");
267
+ });
268
+ it("saves with null org when organizations array is empty", async () => {
269
+ const meData = {
270
+ ...makeMeResponse().data,
271
+ activeOrganizationId: null,
272
+ activeOrganizationName: null,
273
+ activeOrganizationSlug: null,
274
+ organizations: []
275
+ };
276
+ await saveAuthAndSelectOrg("rls_no_org", meData);
277
+ const saved = saveSpy.mock.calls[0][0];
278
+ expect(saved.activeOrganizationId).toBeNull();
279
+ expect(saved.apiToken).toBe("rls_no_org");
280
+ });
281
+ it("stored config has matching legacy fields (currentOrgId = activeOrganizationId)", async () => {
282
+ const meData = makeMeResponse().data;
283
+ await saveAuthAndSelectOrg("rls_compat_key", meData);
284
+ const saved = saveSpy.mock.calls[0][0];
285
+ expect(saved.currentOrgId).toBe(saved.activeOrganizationId);
286
+ expect(saved.currentOrgName).toBe(saved.activeOrganizationName);
287
+ });
288
+ });
289
+ // --------------- Integration Tests: loginWithToken ---------------
290
+ describe("loginWithToken (integration)", () => {
291
+ let saveSpy;
292
+ let clientGetSpy;
293
+ beforeEach(() => {
294
+ saveSpy = spyOn(ConfigManager, "save").mockImplementation(() => { });
295
+ });
296
+ afterEach(() => {
297
+ saveSpy.mockRestore();
298
+ clientGetSpy?.mockRestore();
299
+ });
300
+ it("validates token against /v1/api-token/me and saves config on success", async () => {
301
+ // RelesioAPIClient uses undici internally, so spy on the prototype method
302
+ clientGetSpy = spyOn(RelesioAPIClient.prototype, "get").mockResolvedValue(makeMeResponse());
303
+ await loginWithToken("rls_valid_token", "https://api.relesio.com");
304
+ expect(clientGetSpy).toHaveBeenCalledWith("/v1/api-token/me", expect.objectContaining({
305
+ headers: { "x-api-key": "rls_valid_token" }
306
+ }));
307
+ expect(saveSpy).toHaveBeenCalledTimes(1);
308
+ const saved = saveSpy.mock.calls[0][0];
309
+ expect(saved.apiToken).toBe("rls_valid_token");
310
+ expect(saved.userEmail).toBe("user@example.com");
311
+ expect(saved.activeOrganizationId).toBe("org_123");
312
+ });
313
+ it("throws on non-ok response from /v1/api-token/me", async () => {
314
+ clientGetSpy = spyOn(RelesioAPIClient.prototype, "get").mockRejectedValue(new Error("Unauthorized"));
315
+ await expect(loginWithToken("rls_bad_token", "https://api.relesio.com")).rejects.toThrow("Unauthorized");
316
+ expect(saveSpy).not.toHaveBeenCalled();
317
+ });
318
+ });
319
+ // --------------- Integration Tests: loginWithDeviceFlow ---------------
320
+ //
321
+ // loginWithDeviceFlow uses globalThis.fetch for the device code endpoints (raw RFC 8628 calls)
322
+ // and RelesioAPIClient (undici) for /v1/api-token/me. We mock both separately.
323
+ describe("loginWithDeviceFlow (integration)", () => {
324
+ let originalFetch;
325
+ let saveSpy;
326
+ let exitSpy;
327
+ let clientGetSpy;
328
+ beforeEach(() => {
329
+ originalFetch = globalThis.fetch;
330
+ saveSpy = spyOn(ConfigManager, "save").mockImplementation(() => { });
331
+ // Intercept process.exit to prevent it from terminating the test process
332
+ exitSpy = spyOn(process, "exit").mockImplementation((() => {
333
+ throw new Error("process.exit called");
334
+ }));
335
+ });
336
+ afterEach(() => {
337
+ globalThis.fetch = originalFetch;
338
+ saveSpy.mockRestore();
339
+ exitSpy.mockRestore();
340
+ clientGetSpy?.mockRestore();
341
+ });
342
+ it("completes full flow: pending×2 → approval → key exchange → /me → saves config", async () => {
343
+ let pollCount = 0;
344
+ // /api/auth/device/* calls go through globalThis.fetch (raw fetch in login.ts)
345
+ globalThis.fetch = mock((url) => {
346
+ const urlStr = typeof url === "string" ? url : url.toString();
347
+ if (urlStr.includes("/api/auth/device/code")) {
348
+ return Promise.resolve(makeFetchResponse(200, makeDeviceCodeResponse({ interval: 0 })));
349
+ }
350
+ if (urlStr.includes("/api/auth/device/token")) {
351
+ pollCount++;
352
+ if (pollCount <= 2) {
353
+ return Promise.resolve(makeFetchResponse(400, makeErrorResponse(DEVICE_ERROR.PENDING)));
354
+ }
355
+ return Promise.resolve(makeFetchResponse(200, {
356
+ access_token: "at_xyz789",
357
+ token_type: "Bearer"
358
+ }));
359
+ }
360
+ if (urlStr.includes("/api/auth/api-key/create")) {
361
+ return Promise.resolve(makeFetchResponse(200, {
362
+ key: "rls_device_key",
363
+ id: "key_1",
364
+ name: "CLI - host"
365
+ }));
366
+ }
367
+ return Promise.resolve(makeFetchResponse(404, { error: "unexpected url in test" }));
368
+ });
369
+ // /v1/api-token/me goes through RelesioAPIClient (undici) — spy on the prototype
370
+ clientGetSpy = spyOn(RelesioAPIClient.prototype, "get").mockResolvedValue(makeMeResponse());
371
+ await loginWithDeviceFlow("https://api.relesio.com");
372
+ // Polling ran at least 3 times (2 pending + 1 success)
373
+ expect(pollCount).toBeGreaterThanOrEqual(3);
374
+ // ConfigManager.save was called with the rls_* key from key exchange
375
+ expect(saveSpy).toHaveBeenCalledTimes(1);
376
+ const saved = saveSpy.mock.calls[0][0];
377
+ expect(saved.apiToken).toBe("rls_device_key");
378
+ expect(saved.userEmail).toBe("user@example.com");
379
+ expect(saved.activeOrganizationId).toBe("org_123");
380
+ });
381
+ it("exits with code 1 when device request is denied", async () => {
382
+ globalThis.fetch = mock((url) => {
383
+ const urlStr = typeof url === "string" ? url : url.toString();
384
+ if (urlStr.includes("/api/auth/device/code")) {
385
+ return Promise.resolve(makeFetchResponse(200, makeDeviceCodeResponse({ interval: 0 })));
386
+ }
387
+ return Promise.resolve(makeFetchResponse(400, makeErrorResponse(DEVICE_ERROR.DENIED)));
388
+ });
389
+ await expect(loginWithDeviceFlow("https://api.relesio.com")).rejects.toThrow("process.exit called");
390
+ expect(exitSpy).toHaveBeenCalledWith(1);
391
+ expect(saveSpy).not.toHaveBeenCalled();
392
+ });
393
+ it("exits with code 1 when device code expires", async () => {
394
+ globalThis.fetch = mock((url) => {
395
+ const urlStr = typeof url === "string" ? url : url.toString();
396
+ if (urlStr.includes("/api/auth/device/code")) {
397
+ return Promise.resolve(makeFetchResponse(200, makeDeviceCodeResponse({ interval: 0 })));
398
+ }
399
+ return Promise.resolve(makeFetchResponse(400, makeErrorResponse(DEVICE_ERROR.EXPIRED)));
400
+ });
401
+ await expect(loginWithDeviceFlow("https://api.relesio.com")).rejects.toThrow("process.exit called");
402
+ expect(exitSpy).toHaveBeenCalledWith(1);
403
+ expect(saveSpy).not.toHaveBeenCalled();
404
+ });
405
+ it("exits with code 1 when key exchange fails (401) after device approval", async () => {
406
+ globalThis.fetch = mock((url) => {
407
+ const urlStr = typeof url === "string" ? url : url.toString();
408
+ if (urlStr.includes("/api/auth/device/code")) {
409
+ return Promise.resolve(makeFetchResponse(200, makeDeviceCodeResponse({ interval: 0 })));
410
+ }
411
+ if (urlStr.includes("/api/auth/device/token")) {
412
+ return Promise.resolve(makeFetchResponse(200, {
413
+ access_token: "at_short_lived",
414
+ token_type: "Bearer"
415
+ }));
416
+ }
417
+ // /api/auth/api-key/create → 401
418
+ return Promise.resolve(makeFetchResponse(401, { error: "Unauthorized" }));
419
+ });
420
+ await expect(loginWithDeviceFlow("https://api.relesio.com")).rejects.toThrow("process.exit called");
421
+ expect(exitSpy).toHaveBeenCalledWith(1);
422
+ expect(saveSpy).not.toHaveBeenCalled();
423
+ });
424
+ it("throws on /device/code endpoint server error", async () => {
425
+ globalThis.fetch = mock(() => Promise.resolve(makeFetchResponse(500, { error: "Server error" })));
426
+ await expect(loginWithDeviceFlow("https://api.relesio.com")).rejects.toThrow();
427
+ expect(saveSpy).not.toHaveBeenCalled();
428
+ });
429
+ });
430
+ // --------------- Backward Compatibility ---------------
431
+ describe("Backward Compatibility", () => {
432
+ it("should prefer --token flag when provided", () => {
433
+ const options = { token: "rls_explicit_token" };
434
+ const envToken = undefined;
435
+ const token = options.token || envToken;
436
+ expect(token).toBe("rls_explicit_token");
437
+ });
438
+ it("should fall back to RELESIO_API_TOKEN env var", () => {
439
+ const options = { token: undefined };
440
+ const envToken = "rls_env_token";
441
+ const token = options.token || envToken;
442
+ expect(token).toBe("rls_env_token");
443
+ });
444
+ it("should trigger device flow when no token provided", () => {
445
+ const options = { token: undefined };
446
+ const envToken = undefined;
447
+ const token = options.token || envToken;
448
+ expect(token).toBeFalsy();
449
+ });
450
+ });
@@ -1,2 +1,79 @@
1
1
  import { Command } from "commander";
2
+ export interface ApiKeyMeResponse {
3
+ message: string;
4
+ data: {
5
+ userId: string;
6
+ email: string;
7
+ name: string | null;
8
+ activeOrganizationId: string | null;
9
+ activeOrganizationName: string | null;
10
+ activeOrganizationSlug: string | null;
11
+ organizations: Array<{
12
+ id: string;
13
+ name: string;
14
+ slug: string;
15
+ role: string;
16
+ }>;
17
+ apiKey: {
18
+ id: string;
19
+ name: string | null;
20
+ metadata: Record<string, unknown> | null;
21
+ };
22
+ };
23
+ }
24
+ export interface DeviceCodeResponse {
25
+ device_code: string;
26
+ user_code: string;
27
+ verification_uri: string;
28
+ verification_uri_complete?: string;
29
+ expires_in: number;
30
+ interval: number;
31
+ }
32
+ export interface DeviceTokenSuccessResponse {
33
+ access_token: string;
34
+ token_type: string;
35
+ }
36
+ export interface DeviceTokenErrorResponse {
37
+ error: string;
38
+ error_description?: string;
39
+ }
40
+ export interface ApiKeyCreateResponse {
41
+ key: string;
42
+ id: string;
43
+ name: string | null;
44
+ }
45
+ /** RFC 8628 §3.5 error codes */
46
+ export declare const DEVICE_ERROR: {
47
+ readonly PENDING: "authorization_pending";
48
+ readonly SLOW_DOWN: "slow_down";
49
+ readonly DENIED: "access_denied";
50
+ readonly EXPIRED: "expired_token";
51
+ };
2
52
  export declare const loginCommand: Command;
53
+ /** Exported for unit testing — not part of the public CLI API. */
54
+ export declare function loginWithToken(token: string, apiBaseUrl: string): Promise<void>;
55
+ /** Exported for unit testing — not part of the public CLI API. */
56
+ export declare function loginWithDeviceFlow(apiBaseUrl: string): Promise<void>;
57
+ export interface ResolvedOrg {
58
+ activeOrganizationId: string | null;
59
+ activeOrganizationName: string | null;
60
+ activeOrganizationSlug: string | null;
61
+ }
62
+ /**
63
+ * Org-selection logic applied after both device flow and --token login.
64
+ * Implements the logic from Technical Notes §9.
65
+ *
66
+ * Returns the resolved org values so the caller can display them without a
67
+ * second disk read. Exported for unit testing — not part of the public CLI API.
68
+ */
69
+ export declare function saveAuthAndSelectOrg(apiToken: string, data: ApiKeyMeResponse["data"]): Promise<ResolvedOrg>;
70
+ /**
71
+ * Maps an API base URL to its corresponding dashboard (frontend) URL.
72
+ *
73
+ * Used by the device flow to derive the correct dashboard domain from
74
+ * RELESIO_API_URL, independent of what FRONTEND_URL the API server is
75
+ * configured with.
76
+ *
77
+ * Exported for unit testing — not part of the public CLI API.
78
+ */
79
+ export declare function deriveFrontendUrl(apiBaseUrl: string): string;