@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
|
@@ -1,82 +1,405 @@
|
|
|
1
|
+
import os from "node:os";
|
|
1
2
|
import { Command } from "commander";
|
|
2
3
|
import inquirer from "inquirer";
|
|
4
|
+
import open from "open";
|
|
3
5
|
import ora from "ora";
|
|
4
6
|
import chalk from "chalk";
|
|
5
7
|
import { ConfigManager } from "../../lib/config/manager.js";
|
|
6
8
|
import { RelesioAPIClient } from "../../lib/api/client.js";
|
|
9
|
+
import { USER_AGENT } from "../../lib/version.js";
|
|
10
|
+
/** RFC 8628 §3.5 error codes */
|
|
11
|
+
export const DEVICE_ERROR = {
|
|
12
|
+
PENDING: "authorization_pending",
|
|
13
|
+
SLOW_DOWN: "slow_down",
|
|
14
|
+
DENIED: "access_denied",
|
|
15
|
+
EXPIRED: "expired_token"
|
|
16
|
+
};
|
|
7
17
|
export const loginCommand = new Command("login")
|
|
8
|
-
.description("Authenticate with
|
|
18
|
+
.description("Authenticate with Relesio")
|
|
9
19
|
.option("-t, --token <token>", "API token (or use RELESIO_API_TOKEN env var)")
|
|
10
20
|
.action(async (options) => {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
if (
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
message: "Enter your API token:",
|
|
19
|
-
mask: "*",
|
|
20
|
-
validate: (input) => {
|
|
21
|
-
if (!input || input.length === 0) {
|
|
22
|
-
return "Token is required";
|
|
23
|
-
}
|
|
24
|
-
return true;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
]);
|
|
28
|
-
token = answers.token;
|
|
21
|
+
const token = options.token || process.env.RELESIO_API_TOKEN;
|
|
22
|
+
const config = ConfigManager.load();
|
|
23
|
+
if (token) {
|
|
24
|
+
await loginWithToken(token, config.apiBaseUrl);
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
await loginWithDeviceFlow(config.apiBaseUrl);
|
|
29
28
|
}
|
|
30
|
-
|
|
29
|
+
});
|
|
30
|
+
// ---------- Token login (unchanged from original) ----------
|
|
31
|
+
/** Exported for unit testing — not part of the public CLI API. */
|
|
32
|
+
export async function loginWithToken(token, apiBaseUrl) {
|
|
31
33
|
const spinner = ora("Validating token...").start();
|
|
32
34
|
try {
|
|
33
|
-
const
|
|
34
|
-
if (process.env.DEBUG) {
|
|
35
|
-
console.error(`\n[DEBUG] Login attempt:`);
|
|
36
|
-
console.error(` API Base URL: ${config.apiBaseUrl}`);
|
|
37
|
-
console.error(` Token (first 20 chars): ${token.substring(0, 20)}...`);
|
|
38
|
-
}
|
|
39
|
-
// Create a temporary client without auth to call /v1/api-token/me
|
|
40
|
-
const tempClient = new RelesioAPIClient(config.apiBaseUrl);
|
|
41
|
-
if (process.env.DEBUG) {
|
|
42
|
-
console.error(`\n[DEBUG] Calling /v1/api-token/me API...`);
|
|
43
|
-
}
|
|
44
|
-
// Validate API key and get full user context
|
|
35
|
+
const tempClient = new RelesioAPIClient(apiBaseUrl);
|
|
45
36
|
const response = await tempClient.get("/v1/api-token/me", {
|
|
46
37
|
headers: { "x-api-key": token },
|
|
47
38
|
raw: true
|
|
48
39
|
});
|
|
49
|
-
|
|
50
|
-
console.error(`\n[DEBUG] /v1/api-token/me response:`, JSON.stringify(response, null, 2));
|
|
51
|
-
}
|
|
52
|
-
// Save token and user info
|
|
53
|
-
ConfigManager.save({
|
|
54
|
-
apiToken: token,
|
|
55
|
-
userId: response.data.userId,
|
|
56
|
-
userEmail: response.data.email,
|
|
57
|
-
userName: response.data.name,
|
|
58
|
-
activeOrganizationId: response.data.activeOrganizationId,
|
|
59
|
-
activeOrganizationName: response.data.activeOrganizationName,
|
|
60
|
-
activeOrganizationSlug: response.data.activeOrganizationSlug,
|
|
61
|
-
// Legacy fields for backward compatibility
|
|
62
|
-
currentOrgId: response.data.activeOrganizationId,
|
|
63
|
-
currentOrgName: response.data.activeOrganizationName
|
|
64
|
-
});
|
|
40
|
+
const resolvedOrg = await saveAuthAndSelectOrg(token, response.data);
|
|
65
41
|
spinner.succeed("Authentication successful!");
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
42
|
+
printSuccessMessage(response.data, resolvedOrg);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
spinner.fail("Authentication failed");
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// ---------- Device flow (RFC 8628) ----------
|
|
50
|
+
/** Exported for unit testing — not part of the public CLI API. */
|
|
51
|
+
export async function loginWithDeviceFlow(apiBaseUrl) {
|
|
52
|
+
// Step 1 — request device code
|
|
53
|
+
const spinner = ora("Requesting device code...").start();
|
|
54
|
+
let deviceCodeData;
|
|
55
|
+
try {
|
|
56
|
+
const res = await fetchWithTimeout(`${apiBaseUrl}/api/auth/device/code`, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers: {
|
|
59
|
+
"Content-Type": "application/json",
|
|
60
|
+
"User-Agent": USER_AGENT
|
|
61
|
+
},
|
|
62
|
+
body: JSON.stringify({ client_id: "relesio-cli" })
|
|
63
|
+
});
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
const body = await res.text();
|
|
66
|
+
throw new Error(`Failed to request device code (${res.status}): ${body}`);
|
|
69
67
|
}
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
deviceCodeData = (await res.json());
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
spinner.fail("Failed to initiate device flow");
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
spinner.stop();
|
|
75
|
+
const { device_code, user_code, interval } = deviceCodeData;
|
|
76
|
+
const pollingIntervalMs = Math.max(interval ?? 5, 1) * 1000;
|
|
77
|
+
// Prefer server-provided verification URLs (RFC 8628), with a safe fallback
|
|
78
|
+
// for local/non-standard environments.
|
|
79
|
+
const frontendBase = deriveFrontendUrl(apiBaseUrl);
|
|
80
|
+
const displayUri = deviceCodeData.verification_uri || `${frontendBase}/device`;
|
|
81
|
+
const browserUri = deviceCodeData.verification_uri_complete
|
|
82
|
+
? deviceCodeData.verification_uri_complete
|
|
83
|
+
: buildVerificationUri(displayUri, user_code);
|
|
84
|
+
// Step 2 — display instructions
|
|
85
|
+
console.log();
|
|
86
|
+
console.log(chalk.bold(" Browser authentication"));
|
|
87
|
+
console.log();
|
|
88
|
+
console.log(` ${chalk.dim("First copy your one-time code:")} ${chalk.bold.white(user_code)}`);
|
|
89
|
+
console.log();
|
|
90
|
+
console.log(` ${chalk.dim("Then open:")} ${chalk.cyan(displayUri)}`);
|
|
91
|
+
console.log();
|
|
92
|
+
// Step 3 — prompt to open browser (gh-style: user controls when browser opens)
|
|
93
|
+
await promptToOpenBrowser(browserUri, displayUri);
|
|
94
|
+
// Step 4 — poll for token (RFC 8628 §3.5)
|
|
95
|
+
const pollSpinner = ora("Waiting for browser approval...").start();
|
|
96
|
+
let accessToken;
|
|
97
|
+
let currentInterval = pollingIntervalMs;
|
|
98
|
+
// Client-side guard: stop polling slightly after the device code's expiresIn window.
|
|
99
|
+
// Floor at 60 s to protect against a malformed server response of 0.
|
|
100
|
+
const expiresIn = Math.max(deviceCodeData.expires_in ?? 1800, 60);
|
|
101
|
+
const deadline = Date.now() + (expiresIn + 30) * 1000;
|
|
102
|
+
// Skip the initial sleep so the first poll fires immediately — the user may
|
|
103
|
+
// have already approved by the time the browser opens.
|
|
104
|
+
let isFirstPoll = true;
|
|
105
|
+
try {
|
|
106
|
+
while (true) {
|
|
107
|
+
if (Date.now() > deadline) {
|
|
108
|
+
throw new Error("Device code expired (local timeout). Please run 'relesio auth login' again.");
|
|
109
|
+
}
|
|
110
|
+
if (!isFirstPoll) {
|
|
111
|
+
await sleep(currentInterval);
|
|
112
|
+
}
|
|
113
|
+
isFirstPoll = false;
|
|
114
|
+
let tokenRes;
|
|
115
|
+
try {
|
|
116
|
+
tokenRes = await fetchWithTimeout(`${apiBaseUrl}/api/auth/device/token`, {
|
|
117
|
+
method: "POST",
|
|
118
|
+
headers: {
|
|
119
|
+
"Content-Type": "application/json",
|
|
120
|
+
"User-Agent": USER_AGENT
|
|
121
|
+
},
|
|
122
|
+
body: JSON.stringify({
|
|
123
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
124
|
+
device_code,
|
|
125
|
+
client_id: "relesio-cli"
|
|
126
|
+
})
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
catch (fetchError) {
|
|
130
|
+
if (Date.now() > deadline) {
|
|
131
|
+
throw fetchError;
|
|
132
|
+
}
|
|
133
|
+
pollSpinner.text =
|
|
134
|
+
"Waiting for browser approval... (network issue, retrying)";
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (tokenRes.ok) {
|
|
138
|
+
const data = (await tokenRes.json());
|
|
139
|
+
if (!data.access_token) {
|
|
140
|
+
throw new Error("Device authorization response missing access token");
|
|
141
|
+
}
|
|
142
|
+
accessToken = data.access_token;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
// Non-2xx — parse RFC 8628 error codes
|
|
146
|
+
const rawBody = await tokenRes.text();
|
|
147
|
+
let errorBody;
|
|
148
|
+
try {
|
|
149
|
+
errorBody = JSON.parse(rawBody);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
throw new Error(`Unexpected token error (${tokenRes.status}): ${rawBody}`);
|
|
153
|
+
}
|
|
154
|
+
const errorCode = errorBody.error;
|
|
155
|
+
if (errorCode === DEVICE_ERROR.PENDING) {
|
|
156
|
+
// Normal — user hasn't approved yet
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (errorCode === DEVICE_ERROR.SLOW_DOWN) {
|
|
160
|
+
// Must add 5 seconds to the polling interval (RFC 8628 §3.5)
|
|
161
|
+
currentInterval += 5000;
|
|
162
|
+
pollSpinner.text = `Waiting for browser approval... (slowing down, next check in ${currentInterval / 1000}s)`;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (errorCode === DEVICE_ERROR.DENIED) {
|
|
166
|
+
pollSpinner.fail("Device request denied.");
|
|
167
|
+
console.log(chalk.red("✖"), "The device request was denied in the browser.");
|
|
168
|
+
process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
if (errorCode === DEVICE_ERROR.EXPIRED) {
|
|
171
|
+
pollSpinner.fail("Device code expired.");
|
|
172
|
+
console.log(chalk.red("✖"), "The device code expired. Please run 'relesio auth login' again.");
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
// Unexpected error
|
|
176
|
+
throw new Error(errorBody.error_description ??
|
|
177
|
+
errorBody.error ??
|
|
178
|
+
`Unexpected token error (${tokenRes.status})`);
|
|
72
179
|
}
|
|
73
|
-
|
|
74
|
-
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
pollSpinner.fail("Authentication failed during device flow");
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
pollSpinner.succeed("Browser approved!");
|
|
186
|
+
// Step 5 — exchange access_token for a named rls_* API key
|
|
187
|
+
const keyName = `CLI - ${os.hostname()} - ${new Date().toISOString().slice(0, 10)}`;
|
|
188
|
+
const keySpinner = ora("Creating API key...").start();
|
|
189
|
+
let apiKey;
|
|
190
|
+
try {
|
|
191
|
+
const keyRes = await fetchWithTimeout(`${apiBaseUrl}/api/auth/api-key/create`, {
|
|
192
|
+
method: "POST",
|
|
193
|
+
headers: {
|
|
194
|
+
"Content-Type": "application/json",
|
|
195
|
+
"User-Agent": USER_AGENT,
|
|
196
|
+
Authorization: `Bearer ${accessToken}`
|
|
197
|
+
},
|
|
198
|
+
body: JSON.stringify({ name: keyName })
|
|
199
|
+
});
|
|
200
|
+
if (!keyRes.ok) {
|
|
201
|
+
const body = await keyRes.text();
|
|
202
|
+
keySpinner.fail("API key creation failed.");
|
|
203
|
+
console.log(chalk.red("✖"), "Device authorization succeeded but API key creation failed.");
|
|
204
|
+
console.log(chalk.dim(` Server response (${keyRes.status}): ${body}`));
|
|
205
|
+
console.log(chalk.dim(" Please run 'relesio auth login' again."));
|
|
206
|
+
process.exit(1);
|
|
75
207
|
}
|
|
76
|
-
|
|
208
|
+
const keyData = (await keyRes.json());
|
|
209
|
+
apiKey = keyData.key;
|
|
77
210
|
}
|
|
78
211
|
catch (error) {
|
|
79
|
-
|
|
212
|
+
keySpinner.fail("API key creation failed.");
|
|
213
|
+
console.log(chalk.red("✖"), "Device authorization succeeded but API key creation failed.");
|
|
214
|
+
console.log(chalk.dim(` Error: ${String(error)}`));
|
|
215
|
+
console.log(chalk.dim(" Please run 'relesio auth login' again."));
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
// access_token is no longer needed — discard it
|
|
219
|
+
keySpinner.succeed("API key created");
|
|
220
|
+
// Step 6 — validate key and load user/org context (identical to --token path from here)
|
|
221
|
+
const validateSpinner = ora("Loading account info...").start();
|
|
222
|
+
try {
|
|
223
|
+
const tempClient = new RelesioAPIClient(apiBaseUrl);
|
|
224
|
+
const response = await tempClient.get("/v1/api-token/me", {
|
|
225
|
+
headers: { "x-api-key": apiKey },
|
|
226
|
+
raw: true
|
|
227
|
+
});
|
|
228
|
+
const resolvedOrg = await saveAuthAndSelectOrg(apiKey, response.data);
|
|
229
|
+
validateSpinner.succeed("Authentication successful!");
|
|
230
|
+
printSuccessMessage(response.data, resolvedOrg);
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
validateSpinner.fail("Failed to load account info");
|
|
80
234
|
throw error;
|
|
81
235
|
}
|
|
82
|
-
}
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Org-selection logic applied after both device flow and --token login.
|
|
239
|
+
* Implements the logic from Technical Notes §9.
|
|
240
|
+
*
|
|
241
|
+
* Returns the resolved org values so the caller can display them without a
|
|
242
|
+
* second disk read. Exported for unit testing — not part of the public CLI API.
|
|
243
|
+
*/
|
|
244
|
+
export async function saveAuthAndSelectOrg(apiToken, data) {
|
|
245
|
+
let activeOrganizationId = data.activeOrganizationId;
|
|
246
|
+
let activeOrganizationName = data.activeOrganizationName;
|
|
247
|
+
let activeOrganizationSlug = data.activeOrganizationSlug;
|
|
248
|
+
if (!activeOrganizationId) {
|
|
249
|
+
if (data.organizations.length === 1) {
|
|
250
|
+
// Auto-select the only org
|
|
251
|
+
const org = data.organizations[0];
|
|
252
|
+
activeOrganizationId = org.id;
|
|
253
|
+
activeOrganizationName = org.name;
|
|
254
|
+
activeOrganizationSlug = org.slug;
|
|
255
|
+
}
|
|
256
|
+
else if (data.organizations.length > 1) {
|
|
257
|
+
// Prompt user to pick one
|
|
258
|
+
const { selectedOrgId } = await inquirer.prompt([
|
|
259
|
+
{
|
|
260
|
+
type: "list",
|
|
261
|
+
name: "selectedOrgId",
|
|
262
|
+
message: "Select your active organization:",
|
|
263
|
+
choices: data.organizations.map((org) => ({
|
|
264
|
+
name: `${org.name} (${org.slug}) — ${org.role}`,
|
|
265
|
+
value: org.id
|
|
266
|
+
}))
|
|
267
|
+
}
|
|
268
|
+
]);
|
|
269
|
+
const selected = data.organizations.find((o) => o.id === selectedOrgId);
|
|
270
|
+
if (selected) {
|
|
271
|
+
activeOrganizationId = selected.id;
|
|
272
|
+
activeOrganizationName = selected.name;
|
|
273
|
+
activeOrganizationSlug = selected.slug;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// If zero orgs: fall through — warning printed in printSuccessMessage
|
|
277
|
+
}
|
|
278
|
+
ConfigManager.save({
|
|
279
|
+
apiToken,
|
|
280
|
+
userId: data.userId,
|
|
281
|
+
userEmail: data.email,
|
|
282
|
+
userName: data.name,
|
|
283
|
+
activeOrganizationId,
|
|
284
|
+
activeOrganizationName,
|
|
285
|
+
activeOrganizationSlug,
|
|
286
|
+
// Legacy fields for backward compatibility
|
|
287
|
+
currentOrgId: activeOrganizationId,
|
|
288
|
+
currentOrgName: activeOrganizationName
|
|
289
|
+
});
|
|
290
|
+
return {
|
|
291
|
+
activeOrganizationId,
|
|
292
|
+
activeOrganizationName,
|
|
293
|
+
activeOrganizationSlug
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
function printSuccessMessage(data, resolvedOrg) {
|
|
297
|
+
console.log(chalk.green("✓"), `Authenticated as ${data.email}`);
|
|
298
|
+
if (resolvedOrg.activeOrganizationId) {
|
|
299
|
+
console.log(chalk.green("✓"), `Active organization: ${resolvedOrg.activeOrganizationName} (${resolvedOrg.activeOrganizationSlug})`);
|
|
300
|
+
}
|
|
301
|
+
else if (data.organizations.length === 0) {
|
|
302
|
+
console.log(chalk.yellow("⚠"), "No organization found. Use 'relesio organizations set <id>' once you join one.");
|
|
303
|
+
}
|
|
304
|
+
console.log(chalk.dim("\nTip: Use 'relesio auth status' to view your authentication details"));
|
|
305
|
+
}
|
|
306
|
+
function sleep(ms) {
|
|
307
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
308
|
+
}
|
|
309
|
+
const BROWSER_PROMPT_TIMEOUT_MS = 60_000;
|
|
310
|
+
/**
|
|
311
|
+
* Prompts the user to press Enter before opening the browser (gh auth login style).
|
|
312
|
+
* In non-interactive environments (CI, pipes, tests) the prompt is skipped and
|
|
313
|
+
* the URL is printed for manual use — no browser is opened automatically.
|
|
314
|
+
* Times out after 60 s and falls back to printing the URL.
|
|
315
|
+
*/
|
|
316
|
+
async function promptToOpenBrowser(browserUri, displayUri) {
|
|
317
|
+
if (!process.stdin.isTTY) {
|
|
318
|
+
// Non-interactive: just print the URL, don't open or block
|
|
319
|
+
console.log(chalk.dim(` Open manually: ${displayUri}`));
|
|
320
|
+
console.log();
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
await new Promise((resolve) => {
|
|
324
|
+
process.stdout.write(` ${chalk.dim("Press")} ${chalk.bold("Enter")} ${chalk.dim("to open the browser, or")} ${chalk.bold("q")} ${chalk.dim("+ Enter to skip...")}`);
|
|
325
|
+
const cleanup = (openBrowser) => {
|
|
326
|
+
clearTimeout(timer);
|
|
327
|
+
process.stdin.removeListener("data", onData);
|
|
328
|
+
process.stdin.setRawMode?.(false);
|
|
329
|
+
process.stdin.pause();
|
|
330
|
+
console.log();
|
|
331
|
+
if (openBrowser) {
|
|
332
|
+
open(browserUri).catch(() => {
|
|
333
|
+
console.log(chalk.yellow("⚠"), "Could not open browser automatically.");
|
|
334
|
+
console.log(chalk.dim(` Open manually: ${displayUri}`));
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
console.log(chalk.dim(` Open manually: ${displayUri}`));
|
|
339
|
+
}
|
|
340
|
+
resolve();
|
|
341
|
+
};
|
|
342
|
+
const timer = setTimeout(() => {
|
|
343
|
+
console.log();
|
|
344
|
+
console.log(chalk.yellow("⚠"), "Timed out waiting for input.");
|
|
345
|
+
cleanup(false);
|
|
346
|
+
}, BROWSER_PROMPT_TIMEOUT_MS);
|
|
347
|
+
const onData = (chunk) => {
|
|
348
|
+
const input = chunk.toString().trim().toLowerCase();
|
|
349
|
+
cleanup(input !== "q");
|
|
350
|
+
};
|
|
351
|
+
process.stdin.resume();
|
|
352
|
+
process.stdin.setRawMode?.(true);
|
|
353
|
+
process.stdin.once("data", onData);
|
|
354
|
+
});
|
|
355
|
+
console.log();
|
|
356
|
+
}
|
|
357
|
+
function buildVerificationUri(displayUri, userCode) {
|
|
358
|
+
try {
|
|
359
|
+
const url = new URL(displayUri);
|
|
360
|
+
url.searchParams.set("user_code", userCode);
|
|
361
|
+
return url.toString();
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
return `${displayUri}?user_code=${encodeURIComponent(userCode)}`;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
function fetchWithTimeout(url, init, timeoutMs = 15_000) {
|
|
368
|
+
const controller = new AbortController();
|
|
369
|
+
const id = setTimeout(() => controller.abort(), timeoutMs);
|
|
370
|
+
return fetch(url, { ...init, signal: controller.signal }).finally(() => clearTimeout(id));
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Maps an API base URL to its corresponding dashboard (frontend) URL.
|
|
374
|
+
*
|
|
375
|
+
* Used by the device flow to derive the correct dashboard domain from
|
|
376
|
+
* RELESIO_API_URL, independent of what FRONTEND_URL the API server is
|
|
377
|
+
* configured with.
|
|
378
|
+
*
|
|
379
|
+
* Exported for unit testing — not part of the public CLI API.
|
|
380
|
+
*/
|
|
381
|
+
export function deriveFrontendUrl(apiBaseUrl) {
|
|
382
|
+
const envOverride = process.env.FRONTEND_URL;
|
|
383
|
+
if (envOverride) {
|
|
384
|
+
try {
|
|
385
|
+
new URL(envOverride);
|
|
386
|
+
return envOverride.replace(/\/$/, "");
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
/* fall through */
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
const { hostname, protocol } = new URL(apiBaseUrl);
|
|
394
|
+
if (hostname === "api.relesio.com")
|
|
395
|
+
return "https://relesio.com";
|
|
396
|
+
if (hostname === "api-stage.relesio.com")
|
|
397
|
+
return "https://stage.relesio.com";
|
|
398
|
+
if (hostname === "localhost" || hostname === "127.0.0.1")
|
|
399
|
+
return `${protocol}//localhost:3000`;
|
|
400
|
+
return apiBaseUrl;
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
return apiBaseUrl;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import { RelesioAPIClient } from "../../lib/api/client.js";
|
|
4
|
+
import { ConfigManager } from "../../lib/config/manager.js";
|
|
5
|
+
import { createEnvironment } from "../../lib/api/environments.js";
|
|
6
|
+
import { handleError } from "../../lib/errors/handler.js";
|
|
7
|
+
export const createEnvironmentCommand = new Command("create")
|
|
8
|
+
.description("Create a new environment")
|
|
9
|
+
.requiredOption("--name <name>", "Environment name")
|
|
10
|
+
.requiredOption("--slug <slug>", "Environment slug (lowercase alphanumeric and hyphens)")
|
|
11
|
+
.option("--production", "Mark as a production environment", false)
|
|
12
|
+
.option("--json", "Output as JSON")
|
|
13
|
+
.action(async (options, command) => {
|
|
14
|
+
const spinner = ora("Creating environment...").start();
|
|
15
|
+
try {
|
|
16
|
+
const config = ConfigManager.load();
|
|
17
|
+
if (!config.apiToken) {
|
|
18
|
+
spinner.fail("Not authenticated. Run 'relesio auth login' first.");
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
const orgId = command.parent?.opts().org || config.activeOrganizationId;
|
|
22
|
+
if (!orgId) {
|
|
23
|
+
spinner.fail("No organization context. Use --org flag, set RELESIO_ORG_ID, or run 'relesio organizations set <org-id>'");
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
const slugPattern = /^[a-z0-9-]+$/;
|
|
27
|
+
if (!slugPattern.test(options.slug)) {
|
|
28
|
+
spinner.fail("Invalid slug. Must be lowercase alphanumeric characters and hyphens only.");
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
const client = new RelesioAPIClient(config.apiBaseUrl, config.apiToken);
|
|
32
|
+
const environment = await createEnvironment(client, {
|
|
33
|
+
name: options.name,
|
|
34
|
+
slug: options.slug,
|
|
35
|
+
isProduction: options.production
|
|
36
|
+
});
|
|
37
|
+
spinner.stop();
|
|
38
|
+
if (options.json) {
|
|
39
|
+
console.log(JSON.stringify({ environment }, null, 2));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
console.log(`\n✅ Environment created successfully!\n`);
|
|
43
|
+
console.log(` ID: ${environment.id}`);
|
|
44
|
+
console.log(` Name: ${environment.name}`);
|
|
45
|
+
console.log(` Slug: ${environment.slug}`);
|
|
46
|
+
console.log(` Type: ${environment.isProduction ? "Production" : "Staging"}`);
|
|
47
|
+
console.log(` Created: ${new Date(environment.createdAt).toLocaleString()}`);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
spinner.fail("Failed to create environment");
|
|
51
|
+
handleError(error);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import { RelesioAPIClient } from "../../lib/api/client.js";
|
|
4
|
+
import { ConfigManager } from "../../lib/config/manager.js";
|
|
5
|
+
import { getEnvironment } from "../../lib/api/environments.js";
|
|
6
|
+
import { formatTable } from "../../lib/output/table.js";
|
|
7
|
+
import { handleError } from "../../lib/errors/handler.js";
|
|
8
|
+
export const getEnvironmentCommand = new Command("get")
|
|
9
|
+
.description("Get environment details with deployment history")
|
|
10
|
+
.argument("<environmentId>", "Environment ID (UUID)")
|
|
11
|
+
.option("--history-limit <number>", "Number of recent deployments to show", "10")
|
|
12
|
+
.option("--json", "Output as JSON")
|
|
13
|
+
.action(async (environmentId, options, command) => {
|
|
14
|
+
const spinner = ora("Loading environment...").start();
|
|
15
|
+
try {
|
|
16
|
+
const config = ConfigManager.load();
|
|
17
|
+
if (!config.apiToken) {
|
|
18
|
+
spinner.fail("Not authenticated. Run 'relesio auth login' first.");
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
const orgId = command.parent?.opts().org || config.activeOrganizationId;
|
|
22
|
+
if (!orgId) {
|
|
23
|
+
spinner.fail("No organization context. Use --org flag, set RELESIO_ORG_ID, or run 'relesio organizations set <org-id>'");
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
const client = new RelesioAPIClient(config.apiBaseUrl, config.apiToken);
|
|
27
|
+
const details = await getEnvironment(client, environmentId, {
|
|
28
|
+
limit: Number(options.historyLimit)
|
|
29
|
+
});
|
|
30
|
+
spinner.stop();
|
|
31
|
+
if (options.json) {
|
|
32
|
+
console.log(JSON.stringify(details, null, 2));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const { environment, recentDeployments } = details;
|
|
36
|
+
console.log(`\n🌍 Environment: ${environment.name}\n`);
|
|
37
|
+
console.log(` ID: ${environment.id}`);
|
|
38
|
+
console.log(` Slug: ${environment.slug}`);
|
|
39
|
+
console.log(` Type: ${environment.isProduction ? "Production" : "Staging"}`);
|
|
40
|
+
console.log(` Created: ${new Date(environment.createdAt).toLocaleString()}`);
|
|
41
|
+
console.log(` Updated: ${new Date(environment.updatedAt).toLocaleString()}`);
|
|
42
|
+
if (recentDeployments.length === 0) {
|
|
43
|
+
console.log("\n No deployments yet.");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
console.log(`\n📦 Recent Deployments (${recentDeployments.length}):\n`);
|
|
47
|
+
console.log(formatTable([
|
|
48
|
+
"Version",
|
|
49
|
+
"Deployed At",
|
|
50
|
+
"Deployed By",
|
|
51
|
+
"Branch",
|
|
52
|
+
"Git SHA"
|
|
53
|
+
], recentDeployments.map((d) => [
|
|
54
|
+
d.version,
|
|
55
|
+
new Date(d.deployedAt).toLocaleString(),
|
|
56
|
+
d.deployedBy,
|
|
57
|
+
d.gitBranch ?? "—",
|
|
58
|
+
d.gitSha ? d.gitSha.substring(0, 7) : "—"
|
|
59
|
+
])));
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
spinner.fail("Failed to get environment");
|
|
63
|
+
handleError(error);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { listEnvironmentsCommand } from "./list.js";
|
|
3
|
+
import { createEnvironmentCommand } from "./create.js";
|
|
4
|
+
import { getEnvironmentCommand } from "./get.js";
|
|
5
|
+
import { updateEnvironmentCommand } from "./update.js";
|
|
6
|
+
export const environmentsCommand = new Command("environments")
|
|
7
|
+
.alias("env")
|
|
8
|
+
.description("Manage environments")
|
|
9
|
+
.addCommand(listEnvironmentsCommand)
|
|
10
|
+
.addCommand(createEnvironmentCommand)
|
|
11
|
+
.addCommand(getEnvironmentCommand)
|
|
12
|
+
.addCommand(updateEnvironmentCommand);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import { RelesioAPIClient } from "../../lib/api/client.js";
|
|
4
|
+
import { ConfigManager } from "../../lib/config/manager.js";
|
|
5
|
+
import { listEnvironments } from "../../lib/api/environments.js";
|
|
6
|
+
import { formatTable } from "../../lib/output/table.js";
|
|
7
|
+
import { handleError } from "../../lib/errors/handler.js";
|
|
8
|
+
export const listEnvironmentsCommand = new Command("list")
|
|
9
|
+
.description("List all environments in your organization")
|
|
10
|
+
.option("--limit <number>", "Number of environments to return", "20")
|
|
11
|
+
.option("--offset <number>", "Offset for pagination", "0")
|
|
12
|
+
.option("--json", "Output as JSON")
|
|
13
|
+
.action(async (options, command) => {
|
|
14
|
+
const spinner = ora("Loading environments...").start();
|
|
15
|
+
try {
|
|
16
|
+
const config = ConfigManager.load();
|
|
17
|
+
if (!config.apiToken) {
|
|
18
|
+
spinner.fail("Not authenticated. Run 'relesio auth login' first.");
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
const orgId = command.parent?.opts().org || config.activeOrganizationId;
|
|
22
|
+
if (!orgId) {
|
|
23
|
+
spinner.fail("No organization context. Use --org flag, set RELESIO_ORG_ID, or run 'relesio organizations set <org-id>'");
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
const client = new RelesioAPIClient(config.apiBaseUrl, config.apiToken);
|
|
27
|
+
const { environments, count } = await listEnvironments(client, {
|
|
28
|
+
limit: Number(options.limit),
|
|
29
|
+
offset: Number(options.offset)
|
|
30
|
+
});
|
|
31
|
+
spinner.stop();
|
|
32
|
+
if (options.json) {
|
|
33
|
+
console.log(JSON.stringify({ environments, count }, null, 2));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (environments.length === 0) {
|
|
37
|
+
console.log("\n✨ No environments yet. Create one with:");
|
|
38
|
+
console.log(" relesio environments create --name Production --slug production --production");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
console.log(`\n🌍 Environments (${count}):\n`);
|
|
42
|
+
console.log(formatTable(["Name", "Slug", "Type", "Current Version", "Created"], environments.map((e) => [
|
|
43
|
+
e.name,
|
|
44
|
+
e.slug,
|
|
45
|
+
e.isProduction ? "Production" : "Staging",
|
|
46
|
+
e.currentDeployment?.version ?? "—",
|
|
47
|
+
new Date(e.createdAt).toLocaleDateString()
|
|
48
|
+
])));
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
spinner.fail("Failed to list environments");
|
|
52
|
+
handleError(error);
|
|
53
|
+
}
|
|
54
|
+
});
|