@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.
@@ -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 API token")
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
- let token = options.token || process.env.RELESIO_API_TOKEN;
12
- // If no token provided, prompt it
13
- if (!token) {
14
- const answers = await inquirer.prompt([
15
- {
16
- type: "password",
17
- name: "token",
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
- // Test token by making API call
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 config = ConfigManager.load();
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
- if (process.env.DEBUG) {
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
- console.log(chalk.green("✓"), `Authenticated as ${response.data.email}`);
67
- if (response.data.activeOrganizationId) {
68
- console.log(chalk.green("✓"), `Active organization: ${response.data.activeOrganizationName} (${response.data.activeOrganizationSlug})`);
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
- else {
71
- console.log(chalk.yellow("⚠"), "No active organization set. Use 'relesio organizations set <org-id>' to set one.");
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
- if (response.data.organizations.length > 1) {
74
- console.log(chalk.blue("ℹ"), `You belong to ${response.data.organizations.length} organizations. Use --org flag to override context.`);
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
- console.log(chalk.dim("\nTip: Use 'relesio auth status' to view your authentication details"));
208
+ const keyData = (await keyRes.json());
209
+ apiKey = keyData.key;
77
210
  }
78
211
  catch (error) {
79
- spinner.fail("Authentication failed");
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,2 @@
1
+ import { Command } from "commander";
2
+ export declare const createEnvironmentCommand: Command;
@@ -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,2 @@
1
+ import { Command } from "commander";
2
+ export declare const getEnvironmentCommand: Command;
@@ -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,2 @@
1
+ import { Command } from "commander";
2
+ export declare const environmentsCommand: Command;
@@ -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,2 @@
1
+ import { Command } from "commander";
2
+ export declare const listEnvironmentsCommand: Command;
@@ -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
+ });
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare const updateEnvironmentCommand: Command;