rushdeploy 1.0.9 → 1.2.1

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.
Files changed (3) hide show
  1. package/README.md +4 -3
  2. package/bin/index.js +215 -63
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -17,10 +17,11 @@ npm install -g rushdeploy
17
17
  ## Quickstart
18
18
 
19
19
  ### 1. Authenticate with your server
20
- Generate a CLI token in Settings inside the Web Dashboard and log in:
20
+ Run the login command to authenticate directly in your browser:
21
21
  ```bash
22
- rushdeploy login <token>
22
+ rushdeploy login
23
23
  ```
24
+ *(Or provide a token directly in headless/CI environments: `rushdeploy login <token>`)*
24
25
 
25
26
  ### 2. Check Workspace Status
26
27
  ```bash
@@ -38,7 +39,7 @@ rushdeploy deploy git https://github.com/my-username/my-react-app.git --branch m
38
39
 
39
40
  ## Commands Reference
40
41
 
41
- * **`rushdeploy login <token>`**: Authenticate CLI session.
42
+ * **`rushdeploy login [token]`**: Authenticate via browser (or pass access token).
42
43
  * **`rushdeploy logout`**: Clear local auth details.
43
44
  * **`rushdeploy whoami`**: View details of the active user profile.
44
45
  * **`rushdeploy status`**: Render resource metrics cards.
package/bin/index.js CHANGED
@@ -7,13 +7,26 @@
7
7
  const fs = require('fs');
8
8
  const path = require('path');
9
9
  const os = require('os');
10
+ let crypto;
11
+ try {
12
+ crypto = require('node:crypto');
13
+ } catch {
14
+ try {
15
+ crypto = require('crypto');
16
+ } catch {}
17
+ }
10
18
  const http = require('http');
11
19
  const https = require('https');
12
- const { exec } = require('child_process');
20
+ const { exec, spawn } = require('child_process');
13
21
 
14
22
  const CONFIG_DIR = path.join(os.homedir(), '.rushdeploy');
15
23
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
16
24
 
25
+ let CLI_VERSION = '1.2.1';
26
+ try {
27
+ CLI_VERSION = require('../package.json').version;
28
+ } catch {}
29
+
17
30
  // Domain Hardcoding: Always default to rushdeploy.com
18
31
  const DEFAULT_SERVER = 'https://rushdeploy.com';
19
32
 
@@ -92,8 +105,8 @@ const ui = {
92
105
  },
93
106
 
94
107
  box(fields, options = {}) {
95
- const fieldPairs = Array.isArray(fields)
96
- ? fields
108
+ const fieldPairs = Array.isArray(fields)
109
+ ? fields
97
110
  : Object.entries(fields).map(([label, value]) => ({ label, value }));
98
111
 
99
112
  const labelWidth = 14;
@@ -110,7 +123,7 @@ const ui = {
110
123
  const innerWidth = Math.max(options.minWidth || 66, leftIndent + labelWidth + gap + maxValLen + rightMargin);
111
124
 
112
125
  const topBorder = `┌${'─'.repeat(innerWidth)}┐`;
113
- const emptyRow = `│${' '.repeat(innerWidth)}│`;
126
+ const emptyRow = `│${' '.repeat(innerWidth)}│`;
114
127
  const bottomBorder = `└${'─'.repeat(innerWidth)}┘`;
115
128
 
116
129
  console.log(`\n${colors.gray}${topBorder}${colors.reset}`);
@@ -192,10 +205,18 @@ function loadConfig() {
192
205
  function saveConfig(server, token) {
193
206
  try {
194
207
  if (!fs.existsSync(CONFIG_DIR)) {
195
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
208
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
196
209
  }
197
210
  const cleanServer = (server || DEFAULT_SERVER).replace(/\/$/, '');
198
- fs.writeFileSync(CONFIG_FILE, JSON.stringify({ server: cleanServer, token }, null, 2), 'utf8');
211
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify({ server: cleanServer, token }, null, 2), { encoding: 'utf8', mode: 0o600 });
212
+ try {
213
+ fs.chmodSync(CONFIG_FILE, 0o600);
214
+ } catch (chmodErr) {
215
+ try {
216
+ fs.unlinkSync(CONFIG_FILE);
217
+ } catch {}
218
+ throw chmodErr;
219
+ }
199
220
  return true;
200
221
  } catch (err) {
201
222
  ui.stepError(`Error saving configuration: ${err.message}`);
@@ -210,8 +231,9 @@ function makeRequest(apiPath, method = 'GET', data = null) {
210
231
  const isPlansPath = apiPath.endsWith('/subscriptions/plans') && method === 'GET';
211
232
 
212
233
  if (!config && !isLoginPath && !isPlansPath) {
213
- ui.stepError(`You are not logged in. Run 'rushdeploy login <token>' first.`);
214
- process.exit(1);
234
+ const err = new Error(`You are not logged in. Run 'rushdeploy login <token>' first.`);
235
+ err.statusCode = 401;
236
+ return reject(err);
215
237
  }
216
238
 
217
239
  const serverUrl = getServerUrl();
@@ -255,25 +277,27 @@ function makeRequest(apiPath, method = 'GET', data = null) {
255
277
  } else if (jsonResponse && jsonResponse.detail) {
256
278
  errorMsg = typeof jsonResponse.detail === 'object' ? jsonResponse.detail.message : jsonResponse.detail;
257
279
  }
258
-
280
+
281
+ let formattedError;
259
282
  if (res.statusCode === 401) {
260
- ui.stepError(`Unauthorized (401). Your token may have expired or been revoked.`);
261
- console.log(` Please log in again using: ${colors.green}rushdeploy login <token>${colors.reset}`);
283
+ formattedError = `Unauthorized (401). Your token may have expired or been revoked.\n Please log in again using: ${colors.green}rushdeploy login <token>${colors.reset}`;
262
284
  } else if (res.statusCode === 429) {
263
- ui.stepError(`Rate Limit Exceeded (429): You are sending commands too quickly.`);
264
- console.log(` Please wait a minute before running more CLI commands.`);
285
+ formattedError = `Rate Limit Exceeded (429): You are sending commands too quickly.\n Please wait a minute before running more CLI commands.`;
265
286
  } else {
266
- ui.stepError(`Error (${res.statusCode}): ${errorMsg}`);
287
+ formattedError = `Request failed (${res.statusCode}): ${errorMsg}`;
267
288
  }
268
- process.exit(1);
289
+ const err = new Error(formattedError);
290
+ err.statusCode = res.statusCode;
291
+ err.response = jsonResponse;
292
+ reject(err);
269
293
  }
270
294
  });
271
295
  });
272
296
 
273
297
  req.on('error', (err) => {
274
- ui.stepError(`Connection Error: Unable to connect to server at ${serverUrl}`);
275
- console.log(` Details: ${colors.gray}${err.message}${colors.reset}`);
276
- process.exit(1);
298
+ const connErr = new Error(`Connection Error: Unable to connect to server at ${serverUrl} (${err.message})`);
299
+ connErr.cause = err;
300
+ reject(connErr);
277
301
  });
278
302
 
279
303
  if (data) {
@@ -312,24 +336,31 @@ function formatDate(isoStr) {
312
336
 
313
337
  function openBrowser(url) {
314
338
  let command;
339
+ let args = [];
315
340
  switch (process.platform) {
316
341
  case 'darwin':
317
- command = `open "${url}"`;
342
+ command = 'open';
343
+ args = [url];
318
344
  break;
319
345
  case 'win32':
320
- command = `start "" "${url}"`;
346
+ command = 'explorer.exe';
347
+ args = [url];
321
348
  break;
322
349
  default:
323
- command = `xdg-open "${url}"`;
350
+ command = 'xdg-open';
351
+ args = [url];
324
352
  break;
325
353
  }
326
- exec(command, (err) => {
327
- if (err) {
354
+ try {
355
+ const child = spawn(command, args, { stdio: 'ignore', detached: true });
356
+ child.on('error', (err) => {
328
357
  ui.stepError(`Failed to open browser: ${err.message}`);
329
- } else {
330
- ui.step(`Opening browser: ${colors.brightCyan}${url}${colors.reset}`);
331
- }
332
- });
358
+ });
359
+ child.unref();
360
+ ui.step(`Opening browser: ${colors.brightCyan}${url}${colors.reset}`);
361
+ } catch (err) {
362
+ ui.stepError(`Failed to open browser: ${err.message}`);
363
+ }
333
364
  }
334
365
 
335
366
  function extractRepoName(url) {
@@ -342,19 +373,135 @@ function extractRepoName(url) {
342
373
  }
343
374
  }
344
375
 
345
- async function cmdLogin(token, serverArg) {
376
+ function generateRandomState() {
377
+ if (crypto && typeof crypto.randomBytes === 'function') {
378
+ try {
379
+ return crypto.randomBytes(16).toString('hex');
380
+ } catch {}
381
+ }
382
+ if (typeof globalThis !== 'undefined' && globalThis.crypto?.getRandomValues) {
383
+ try {
384
+ const array = new Uint8Array(16);
385
+ globalThis.crypto.getRandomValues(array);
386
+ return Array.from(array, (b) => b.toString(16).padStart(2, '0')).join('');
387
+ } catch {}
388
+ }
389
+ if (typeof globalThis !== 'undefined' && typeof globalThis.crypto?.randomUUID === 'function') {
390
+ try {
391
+ return globalThis.crypto.randomUUID().replace(/-/g, '');
392
+ } catch {}
393
+ }
394
+ return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
395
+ }
396
+
397
+ function loginViaBrowser(server) {
398
+ return new Promise((resolve, reject) => {
399
+ const state = generateRandomState();
400
+
401
+ const localServer = http.createServer((req, res) => {
402
+ try {
403
+ const parsed = new URL(req.url, 'http://127.0.0.1');
404
+ if (parsed.pathname === '/callback') {
405
+ const returnedToken = parsed.searchParams.get('token');
406
+ const returnedState = parsed.searchParams.get('state');
407
+
408
+ if (!returnedToken || returnedState !== state) {
409
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
410
+ res.end('<h1>Authentication Failed</h1><p>Invalid state or token parameter received.</p>');
411
+ return;
412
+ }
413
+
414
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
415
+ res.end(`
416
+ <!DOCTYPE html>
417
+ <html>
418
+ <head>
419
+ <meta charset="utf-8">
420
+ <title>RushDeploy CLI - Authenticated</title>
421
+ <style>
422
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #09090b; color: #fff; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
423
+ .card { background: #18181b; border: 1px solid #27272a; padding: 36px; border-radius: 16px; text-align: center; max-width: 400px; box-shadow: 0 20px 40px rgba(0,0,0,0.6); }
424
+ .icon { width: 48px; height: 48px; background: rgba(16,185,129,0.15); color: #10b981; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: 24px; margin-bottom: 16px; }
425
+ h1 { color: #fafafa; font-size: 20px; font-weight: 600; margin: 0 0 8px 0; }
426
+ p { color: #a1a1aa; font-size: 13px; line-height: 1.5; margin: 0; }
427
+ </style>
428
+ </head>
429
+ <body>
430
+ <div class="card">
431
+ <div class="icon">&#10003;</div>
432
+ <h1>Authentication Successful!</h1>
433
+ <p>Your CLI terminal has been successfully connected to RushDeploy. You can close this tab and return to your terminal.</p>
434
+ </div>
435
+ </body>
436
+ </html>
437
+ `);
438
+
439
+ if (timer) clearTimeout(timer);
440
+ localServer.close();
441
+ resolve(returnedToken);
442
+ } else {
443
+ res.writeHead(404);
444
+ res.end();
445
+ }
446
+ } catch (err) {
447
+ res.writeHead(500);
448
+ res.end('Internal server error');
449
+ }
450
+ });
451
+
452
+ let timer;
453
+
454
+ localServer.listen(0, '127.0.0.1', () => {
455
+ const address = localServer.address();
456
+ const port = address.port;
457
+ const callbackUrl = `http://127.0.0.1:${port}/callback`;
458
+ const authUrl = `${server}/auth/cli-auth?callback=${encodeURIComponent(callbackUrl)}&state=${state}&client=cli`;
459
+
460
+ ui.stepInfo(`Opening your browser to authenticate with RushDeploy...`);
461
+ console.log(` Waiting for authorization at:\n ${colors.brightCyan}${authUrl}${colors.reset}\n`);
462
+
463
+ openBrowser(authUrl);
464
+ });
465
+
466
+ // 2-minute timeout
467
+ timer = setTimeout(() => {
468
+ try {
469
+ localServer.close();
470
+ } catch { }
471
+ reject(new Error('Authentication timed out after 2 minutes'));
472
+ }, 120000);
473
+ if (timer.unref) {
474
+ timer.unref();
475
+ }
476
+ });
477
+ }
478
+
479
+ async function cmdLogin(tokenArg, serverArg) {
346
480
  const server = serverArg || getServerUrl();
347
- ui.stepInfo(`Connecting to ${colors.brightWhite}${server}${colors.reset}...`);
348
-
481
+ let token = tokenArg;
482
+
483
+ if (!token) {
484
+ ui.stepInfo(`Connecting to ${colors.brightWhite}${server}${colors.reset}...`);
485
+ try {
486
+ token = await loginViaBrowser(server);
487
+ } catch (err) {
488
+ ui.stepError(`Browser login failed: ${err.message}`);
489
+ console.log(` You can also log in manually using: ${colors.green}rushdeploy login <token>${colors.reset}`);
490
+ process.exit(1);
491
+ }
492
+ } else {
493
+ ui.stepInfo(`Connecting to ${colors.brightWhite}${server}${colors.reset}...`);
494
+ }
495
+
349
496
  saveConfig(server, token);
350
-
497
+
351
498
  try {
352
499
  ui.stepInfo(`Validating authentication token...`);
353
500
  const res = await makeRequest('/auth/me');
354
501
  if (res.success) {
355
502
  const user = res.user;
356
503
  ui.step(`Authentication successful!`);
357
-
504
+
358
505
  ui.box([
359
506
  { label: 'Account', value: `${colors.bold}${user.name}${colors.reset} (${colors.gray}${user.email}${colors.reset})` },
360
507
  { label: 'Role', value: user.role.toUpperCase() },
@@ -364,7 +511,7 @@ async function cmdLogin(token, serverArg) {
364
511
  }
365
512
  } catch (err) {
366
513
  if (fs.existsSync(CONFIG_FILE)) fs.unlinkSync(CONFIG_FILE);
367
- ui.stepError(`Authentication failed. Check your token or server URL.`);
514
+ ui.stepError(`Authentication failed: ${err.message || 'Check your token or server URL.'}`);
368
515
  process.exit(1);
369
516
  }
370
517
  }
@@ -421,7 +568,7 @@ async function cmdPlans() {
421
568
  function drawCards(cards, cardsPerRow = 2) {
422
569
  const cardWidth = 34;
423
570
  const rows = [];
424
-
571
+
425
572
  for (let i = 0; i < cards.length; i += cardsPerRow) {
426
573
  rows.push(cards.slice(i, i + cardsPerRow));
427
574
  }
@@ -440,19 +587,19 @@ function drawCards(cards, cardsPerRow = 2) {
440
587
 
441
588
  const label = card.label || '';
442
589
  let value = card.value !== undefined ? String(card.value) : '';
443
-
590
+
444
591
  let valColor = colors.bold;
445
592
  if (card.status === 'online') valColor = colors.green + colors.bold;
446
593
  else if (card.status === 'offline') valColor = colors.red + colors.bold;
447
594
  else if (card.status === 'warn') valColor = colors.yellow + colors.bold;
448
-
595
+
449
596
  const valStr = `${valColor}${value}${colors.reset}`;
450
597
  const rawVal = stripAnsi(value);
451
-
598
+
452
599
  const textLen = label.length + rawVal.length;
453
600
  const padLen = cardWidth - 6 - textLen;
454
601
  const pad = ' '.repeat(Math.max(1, padLen));
455
-
602
+
456
603
  labelValLine += space + `${colors.gray}│${colors.reset} ${colors.brightCyan}${label}${colors.reset}${pad}${valStr} ${colors.gray}│${colors.reset}`;
457
604
  sepLine += space + colors.gray + '├' + '─'.repeat(cardWidth - 2) + '┤' + colors.reset;
458
605
 
@@ -460,7 +607,7 @@ function drawCards(cards, cardsPerRow = 2) {
460
607
  const rawHint = stripAnsi(hint);
461
608
  const hintPadLen = cardWidth - 6 - rawHint.length;
462
609
  const hintPad = ' '.repeat(Math.max(0, hintPadLen));
463
-
610
+
464
611
  hintLine += space + `${colors.gray}│${colors.reset} ${colors.gray}${hint}${colors.reset}${hintPad} ${colors.gray}│${colors.reset}`;
465
612
  bottomBorder += space + colors.gray + '└' + '─'.repeat(cardWidth - 2) + '┘' + colors.reset;
466
613
  });
@@ -499,7 +646,7 @@ async function cmdStatus() {
499
646
  const byStatus = stats.projects_by_status || {};
500
647
  const celery = stats.celery || {};
501
648
  const workerOnline = (celery.workers ?? 0) > 0;
502
-
649
+
503
650
  const activeBuildingProjects = (byStatus.building || byStatus.BUILDING || 0) + (byStatus.pending || byStatus.PENDING || 0);
504
651
  const activeTasksCount = (celery.active && celery.active > 0) ? celery.active : activeBuildingProjects;
505
652
  const pendingInQueue = stats.redis_queue_len ?? celery.queued ?? 0;
@@ -517,23 +664,23 @@ async function cmdStatus() {
517
664
  const adminCards = [
518
665
  { label: 'Users', value: stats.users ?? 0, hint: 'Total registered users' },
519
666
  { label: 'Databases', value: stats.databases ?? 0, hint: 'Total workspace databases' },
520
- {
521
- label: 'Projects',
522
- value: stats.projects ?? 0,
523
- hint: `${colors.green}${runCount} run${colors.reset} · ${colors.yellow}${buildCount} build${colors.reset} · ${colors.red}${failCount} fail${colors.reset}`
667
+ {
668
+ label: 'Projects',
669
+ value: stats.projects ?? 0,
670
+ hint: `${colors.green}${runCount} run${colors.reset} · ${colors.yellow}${buildCount} build${colors.reset} · ${colors.red}${failCount} fail${colors.reset}`
524
671
  },
525
- {
526
- label: 'App Containers',
527
- value: stats.running_containers ?? 0,
672
+ {
673
+ label: 'App Containers',
674
+ value: stats.running_containers ?? 0,
528
675
  status: stats.docker_available ? 'online' : 'offline',
529
676
  hint: stats.docker_available ? 'Active running containers' : 'Engine offline'
530
677
  },
531
678
  { label: 'CPU Usage', value: cpuVal, hint: 'Host CPU load' },
532
679
  { label: 'RAM Usage', value: ramVal, hint: 'Host memory allocation' },
533
680
  { label: 'Disk Usage', value: diskVal, hint: 'Host storage capacity' },
534
- {
535
- label: 'Task Queue',
536
- value: workerOnline ? 'Online' : 'Offline',
681
+ {
682
+ label: 'Task Queue',
683
+ value: workerOnline ? 'Online' : 'Offline',
537
684
  status: workerOnline ? 'online' : 'offline',
538
685
  hint: `${activeTasksCount} active · ${pendingInQueue} pending`
539
686
  }
@@ -835,14 +982,14 @@ async function resolveProjectId(idOrSlug) {
835
982
  if (/^\d+$/.test(idOrSlug)) {
836
983
  return parseInt(idOrSlug, 10);
837
984
  }
838
-
985
+
839
986
  const res = await makeRequest('/projects/');
840
987
  const projects = res.projects || [];
841
988
  const matched = projects.find(p => p.slug === idOrSlug || p.name.toLowerCase() === idOrSlug.toLowerCase());
842
989
  if (matched) {
843
990
  return matched.id;
844
991
  }
845
-
992
+
846
993
  ui.stepError(`No project found matching name or slug "${idOrSlug}".`);
847
994
  process.exit(1);
848
995
  }
@@ -912,7 +1059,7 @@ async function cmdProject(idOrSlug, subcommand, subArgs) {
912
1059
  const stats = statsRes.stats;
913
1060
 
914
1061
  ui.stepInfo(`Resource metrics for Project ${id}`);
915
-
1062
+
916
1063
  const fields = [
917
1064
  { label: 'Current Status', value: ui.badge(info.status) }
918
1065
  ];
@@ -1193,13 +1340,13 @@ async function cmdWorkspace() {
1193
1340
 
1194
1341
  function printHelp() {
1195
1342
  console.log(`
1196
- ${colors.bold}${colors.brightWhite}RushDeploy CLI${colors.reset} ${colors.gray}v1.0.9${colors.reset} - Modern PaaS Command Line Interface
1343
+ ${colors.bold}${colors.brightWhite}RushDeploy CLI${colors.reset} ${colors.gray}v${CLI_VERSION}${colors.reset} - Modern PaaS Command Line Interface
1197
1344
 
1198
1345
  ${colors.bold}USAGE:${colors.reset}
1199
1346
  ${colors.brightCyan}rushdeploy${colors.reset} <command> [arguments] [options]
1200
1347
 
1201
1348
  ${colors.bold}GLOBAL COMMANDS:${colors.reset}
1202
- ${colors.green}login <token>${colors.reset} Authenticate with your access token
1349
+ ${colors.green}login [token]${colors.reset} Authenticate with browser (or provide access token)
1203
1350
  ${colors.green}logout${colors.reset} Clear your stored CLI session token
1204
1351
  ${colors.green}whoami${colors.reset} Display active profile details
1205
1352
  ${colors.green}plans${colors.reset} List subscription plans and limits
@@ -1249,21 +1396,26 @@ async function main() {
1249
1396
  process.exit(0);
1250
1397
  }
1251
1398
 
1399
+ if (command === '--version' || command === '-v' || command === 'version') {
1400
+ console.log(CLI_VERSION);
1401
+ process.exit(0);
1402
+ }
1403
+
1252
1404
  switch (command) {
1253
1405
  case 'login': {
1254
- const token = args[1];
1255
- if (!token) {
1256
- ui.stepError(`Please provide your access token.`);
1257
- console.log(` Usage: ${colors.green}rushdeploy login <token> [--server <url>]${colors.reset}`);
1258
- process.exit(1);
1259
- }
1260
-
1406
+ let token = null;
1261
1407
  let server = null;
1408
+
1262
1409
  const serverIndex = args.indexOf('--server');
1263
1410
  if (serverIndex !== -1 && args[serverIndex + 1]) {
1264
1411
  server = args[serverIndex + 1];
1265
1412
  }
1266
-
1413
+
1414
+ // Check if user passed a token (first argument after 'login' that isn't --server or a flag)
1415
+ if (args[1] && !args[1].startsWith('--')) {
1416
+ token = args[1];
1417
+ }
1418
+
1267
1419
  await cmdLogin(token, server);
1268
1420
  break;
1269
1421
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rushdeploy",
3
- "version": "1.0.9",
3
+ "version": "1.2.1",
4
4
  "description": "CLI tool to manage your self-hosted RushDeploy PaaS account.",
5
5
  "main": "bin/index.js",
6
6
  "bin": {
@@ -21,4 +21,4 @@
21
21
  "bin",
22
22
  "README.md"
23
23
  ]
24
- }
24
+ }