@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 +40 -18
- package/dist/commands/auth/__tests__/login.test.d.ts +12 -0
- package/dist/commands/auth/__tests__/login.test.js +450 -0
- package/dist/commands/auth/login.d.ts +77 -0
- package/dist/commands/auth/login.js +381 -58
- package/dist/commands/environments/create.d.ts +2 -0
- package/dist/commands/environments/create.js +53 -0
- package/dist/commands/environments/get.d.ts +2 -0
- package/dist/commands/environments/get.js +65 -0
- package/dist/commands/environments/index.d.ts +2 -0
- package/dist/commands/environments/index.js +12 -0
- package/dist/commands/environments/list.d.ts +2 -0
- package/dist/commands/environments/list.js +54 -0
- package/dist/commands/environments/update.d.ts +2 -0
- package/dist/commands/environments/update.js +51 -0
- package/dist/commands/organizations/set.js +21 -11
- package/dist/index.js +10 -4
- package/dist/lib/api/client.js +2 -1
- package/dist/lib/api/environments.d.ts +62 -0
- package/dist/lib/api/environments.js +42 -0
- package/dist/lib/version.d.ts +2 -0
- package/dist/lib/version.js +5 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -34,13 +34,22 @@ bun link
|
|
|
34
34
|
|
|
35
35
|
## Quick Start
|
|
36
36
|
|
|
37
|
-
1. **Authenticate**
|
|
37
|
+
1. **Authenticate** via browser (recommended):
|
|
38
38
|
|
|
39
39
|
```bash
|
|
40
|
-
relesio auth login
|
|
40
|
+
relesio auth login
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
|
|
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
|
|
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
|
-
#
|
|
102
|
+
# Browser-based login (recommended — opens relesio.com/device)
|
|
94
103
|
relesio auth login
|
|
95
104
|
|
|
96
|
-
#
|
|
105
|
+
# Headless / CI: pass an API key directly
|
|
97
106
|
relesio auth login --token rls_...
|
|
98
107
|
|
|
99
|
-
#
|
|
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
|
|
144
|
-
|
|
|
145
|
-
| `RELESIO_API_TOKEN
|
|
146
|
-
| `RELESIO_API_URL`
|
|
147
|
-
| `RELESIO_ORG_ID`
|
|
148
|
-
| `DEBUG`
|
|
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;
|