@runloop/rl-cli 0.0.1 → 0.0.3

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.
@@ -0,0 +1,71 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { Box, Text, useInput, useApp } from 'ink';
4
+ import figures from 'figures';
5
+ import { Banner } from './Banner.js';
6
+ import { Breadcrumb } from './Breadcrumb.js';
7
+ import { VERSION } from '../cli.js';
8
+ export const MainMenu = React.memo(({ onSelect }) => {
9
+ const { exit } = useApp();
10
+ const [selectedIndex, setSelectedIndex] = React.useState(0);
11
+ // Calculate terminal height once at mount and memoize
12
+ const terminalHeight = React.useMemo(() => process.stdout.rows || 24, []);
13
+ const menuItems = React.useMemo(() => [
14
+ {
15
+ key: 'devboxes',
16
+ label: 'Devboxes',
17
+ description: 'Manage cloud development environments',
18
+ icon: '◉',
19
+ color: 'cyan',
20
+ },
21
+ {
22
+ key: 'blueprints',
23
+ label: 'Blueprints',
24
+ description: 'Create and manage devbox templates',
25
+ icon: '▣',
26
+ color: 'magenta',
27
+ },
28
+ {
29
+ key: 'snapshots',
30
+ label: 'Snapshots',
31
+ description: 'Save and restore devbox states',
32
+ icon: '◈',
33
+ color: 'green',
34
+ },
35
+ ], []);
36
+ useInput((input, key) => {
37
+ if (key.upArrow && selectedIndex > 0) {
38
+ setSelectedIndex(selectedIndex - 1);
39
+ }
40
+ else if (key.downArrow && selectedIndex < menuItems.length - 1) {
41
+ setSelectedIndex(selectedIndex + 1);
42
+ }
43
+ else if (key.return) {
44
+ onSelect(menuItems[selectedIndex].key);
45
+ }
46
+ else if (key.escape) {
47
+ exit();
48
+ }
49
+ else if (input === 'd' || input === '1') {
50
+ onSelect('devboxes');
51
+ }
52
+ else if (input === 'b' || input === '2') {
53
+ onSelect('blueprints');
54
+ }
55
+ else if (input === 's' || input === '3') {
56
+ onSelect('snapshots');
57
+ }
58
+ });
59
+ // Use compact layout if terminal height is less than 20 lines (memoized)
60
+ const useCompactLayout = React.useMemo(() => terminalHeight < 20, [terminalHeight]);
61
+ if (useCompactLayout) {
62
+ return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsxs(Box, { paddingX: 2, marginBottom: 1, children: [_jsx(Text, { color: "cyan", bold: true, children: "RUNLOOP.ai" }), _jsxs(Text, { color: "gray", dimColor: true, children: [' ', "\u2022 Cloud development environments \u2022 v", VERSION] })] }), _jsx(Box, { flexDirection: "column", paddingX: 2, children: menuItems.map((item, index) => {
63
+ const isSelected = index === selectedIndex;
64
+ return (_jsxs(Box, { marginBottom: 0, children: [_jsx(Text, { color: isSelected ? item.color : 'gray', children: isSelected ? figures.pointer : ' ' }), _jsx(Text, { children: " " }), _jsx(Text, { color: item.color, bold: true, children: item.icon }), _jsx(Text, { children: " " }), _jsx(Text, { color: isSelected ? item.color : 'white', bold: isSelected, children: item.label }), _jsxs(Text, { color: "gray", dimColor: true, children: [' ', "- ", item.description] }), _jsxs(Text, { color: "gray", dimColor: true, children: [' ', "[", index + 1, "]"] })] }, item.key));
65
+ }) }), _jsx(Box, { paddingX: 2, marginTop: 1, children: _jsxs(Text, { color: "gray", dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022 [1-3] Quick select \u2022 [Enter] Select \u2022 [Esc] Quit"] }) })] }));
66
+ }
67
+ return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsx(Breadcrumb, { items: [{ label: 'Home', active: true }] }), _jsx(Box, { marginBottom: 1, children: _jsx(Banner, {}) }), _jsx(Box, { flexDirection: "column", paddingX: 2, marginBottom: 1, children: _jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: "gray", dimColor: true, children: ["Cloud development environments for your team \u2022 v", VERSION] }) }) }), _jsxs(Box, { flexDirection: "column", paddingX: 2, marginBottom: 1, marginTop: 1, children: [_jsx(Box, { marginBottom: 1, paddingX: 1, children: _jsx(Text, { color: "white", bold: true, children: "Select a resource:" }) }), menuItems.map((item, index) => {
68
+ const isSelected = index === selectedIndex;
69
+ return (_jsxs(Box, { paddingX: 2, paddingY: 0, borderStyle: isSelected ? 'round' : 'single', borderColor: isSelected ? item.color : 'gray', marginBottom: 0, children: [_jsx(Text, { color: item.color, bold: true, children: item.icon }), _jsx(Text, { children: " " }), _jsx(Text, { color: isSelected ? item.color : 'white', bold: isSelected, children: item.label }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "gray", dimColor: true, children: item.description }), _jsx(Text, { children: " " }), _jsxs(Text, { color: "gray", dimColor: true, children: ["[", index + 1, "]"] })] }, item.key));
70
+ })] }), _jsx(Box, { paddingX: 2, children: _jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: "gray", dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022 [1-3] Quick select \u2022 [Enter] Select \u2022 [Esc] Quit"] }) }) })] }));
71
+ });
@@ -7,32 +7,35 @@ export const getStatusDisplay = (status) => {
7
7
  }
8
8
  switch (status) {
9
9
  case 'running':
10
- return { icon: figures.circleFilled, color: 'green', text: 'RUNNING' };
10
+ return { icon: figures.circleFilled, color: 'green', text: 'RUNNING ' };
11
11
  case 'provisioning':
12
- return { icon: figures.hamburger, color: 'yellow', text: 'PROVISIONING' };
12
+ return { icon: figures.ellipsis, color: 'yellow', text: 'PROVISION ' };
13
13
  case 'initializing':
14
- return { icon: figures.ellipsis, color: 'cyan', text: 'INITIALIZING' };
14
+ return { icon: figures.ellipsis, color: 'cyan', text: 'INITIALIZE' };
15
15
  case 'suspended':
16
- return { icon: figures.circleDotted, color: 'yellow', text: 'SUSPENDED' };
16
+ return { icon: figures.circleDotted, color: 'yellow', text: 'SUSPENDED ' };
17
17
  case 'failure':
18
- return { icon: figures.cross, color: 'red', text: 'FAILED' };
18
+ return { icon: figures.cross, color: 'red', text: 'FAILED ' };
19
19
  case 'shutdown':
20
- return { icon: figures.circle, color: 'gray', text: 'SHUTDOWN' };
20
+ return { icon: figures.circle, color: 'gray', text: 'SHUTDOWN ' };
21
21
  case 'resuming':
22
- return { icon: figures.ellipsis, color: 'cyan', text: 'RESUMING' };
22
+ return { icon: figures.ellipsis, color: 'cyan', text: 'RESUMING ' };
23
23
  case 'suspending':
24
24
  return { icon: figures.ellipsis, color: 'yellow', text: 'SUSPENDING' };
25
25
  case 'ready':
26
- return { icon: figures.tick, color: 'green', text: 'READY' };
26
+ return { icon: figures.tick, color: 'green', text: 'READY ' };
27
27
  case 'build_complete':
28
28
  case 'building_complete':
29
- return { icon: figures.tick, color: 'green', text: 'COMPLETE' };
29
+ return { icon: figures.tick, color: 'green', text: 'COMPLETE ' };
30
30
  case 'building':
31
- return { icon: figures.ellipsis, color: 'yellow', text: 'BUILDING' };
31
+ return { icon: figures.ellipsis, color: 'yellow', text: 'BUILDING ' };
32
32
  case 'build_failed':
33
- return { icon: figures.cross, color: 'red', text: 'FAILED' };
33
+ return { icon: figures.cross, color: 'red', text: 'FAILED ' };
34
34
  default:
35
- return { icon: figures.questionMarkPrefix, color: 'gray', text: status.toUpperCase() };
35
+ // Truncate and pad any unknown status to 10 chars to match column width
36
+ const truncated = status.toUpperCase().slice(0, 10);
37
+ const padded = truncated.padEnd(10, ' ');
38
+ return { icon: figures.questionMarkPrefix, color: 'gray', text: padded };
36
39
  }
37
40
  };
38
41
  export const StatusBadge = ({ status, showText = true }) => {
@@ -28,9 +28,13 @@ export class CommandExecutor {
28
28
  return;
29
29
  }
30
30
  // Interactive mode
31
+ // Enter alternate screen buffer
32
+ process.stdout.write('\x1b[?1049h');
31
33
  console.clear();
32
34
  const { waitUntilExit } = render(renderUI());
33
35
  await waitUntilExit();
36
+ // Exit alternate screen buffer
37
+ process.stdout.write('\x1b[?1049l');
34
38
  }
35
39
  /**
36
40
  * Execute a create/action command with automatic format handling
@@ -47,9 +51,13 @@ export class CommandExecutor {
47
51
  return;
48
52
  }
49
53
  // Interactive mode
54
+ // Enter alternate screen buffer
55
+ process.stdout.write('\x1b[?1049h');
50
56
  console.clear();
51
57
  const { waitUntilExit } = render(renderUI());
52
58
  await waitUntilExit();
59
+ // Exit alternate screen buffer
60
+ process.stdout.write('\x1b[?1049l');
53
61
  }
54
62
  /**
55
63
  * Execute a delete command with automatic format handling
@@ -66,8 +74,12 @@ export class CommandExecutor {
66
74
  return;
67
75
  }
68
76
  // Interactive mode
77
+ // Enter alternate screen buffer
78
+ process.stdout.write('\x1b[?1049h');
69
79
  const { waitUntilExit } = render(renderUI());
70
80
  await waitUntilExit();
81
+ // Exit alternate screen buffer
82
+ process.stdout.write('\x1b[?1049l');
71
83
  }
72
84
  /**
73
85
  * Fetch items from an async iterator with optional filtering and limits
@@ -1,12 +1,29 @@
1
1
  import Runloop from '@runloop/api-client';
2
2
  import { getConfig } from './config.js';
3
+ /**
4
+ * Get the base URL based on RUNLOOP_ENV environment variable
5
+ * - dev: https://api.runloop.pro
6
+ * - prod or unset: https://api.runloop.ai (default)
7
+ */
8
+ function getBaseUrl() {
9
+ const env = process.env.RUNLOOP_ENV?.toLowerCase();
10
+ switch (env) {
11
+ case 'dev':
12
+ return 'https://api.runloop.pro';
13
+ case 'prod':
14
+ default:
15
+ return 'https://api.runloop.ai';
16
+ }
17
+ }
3
18
  export function getClient() {
4
19
  const config = getConfig();
5
20
  if (!config.apiKey) {
6
21
  throw new Error('API key not configured. Run: rln auth');
7
22
  }
23
+ const baseURL = getBaseUrl();
8
24
  return new Runloop({
9
25
  bearerToken: config.apiKey,
26
+ baseURL,
10
27
  timeout: 10000, // 10 seconds instead of default 30 seconds
11
28
  maxRetries: 2, // 2 retries instead of default 5 (only for retryable errors)
12
29
  });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Wrapper for interactive commands that need alternate screen buffer management
3
+ */
4
+ export async function runInteractiveCommand(command) {
5
+ // Enter alternate screen buffer
6
+ process.stdout.write('\x1b[?1049h');
7
+ try {
8
+ await command();
9
+ }
10
+ finally {
11
+ // Exit alternate screen buffer
12
+ process.stdout.write('\x1b[?1049l');
13
+ }
14
+ }
@@ -0,0 +1,25 @@
1
+ import { spawnSync } from 'child_process';
2
+ export async function runSSHSession(config) {
3
+ // Reset terminal to fix input visibility issues
4
+ // This ensures the terminal is in a proper state after exiting Ink
5
+ spawnSync('reset', [], { stdio: 'inherit' });
6
+ console.clear();
7
+ console.log(`\nConnecting to devbox ${config.devboxName}...\n`);
8
+ // Spawn SSH in foreground with proper terminal settings
9
+ const result = spawnSync('ssh', [
10
+ '-t', // Force pseudo-terminal allocation for proper input handling
11
+ '-i', config.keyPath,
12
+ '-o', `ProxyCommand=${config.proxyCommand}`,
13
+ '-o', 'StrictHostKeyChecking=no',
14
+ '-o', 'UserKnownHostsFile=/dev/null',
15
+ `${config.sshUser}@${config.url}`
16
+ ], {
17
+ stdio: 'inherit',
18
+ shell: false
19
+ });
20
+ return {
21
+ exitCode: result.status || 0,
22
+ shouldRestart: true,
23
+ returnToDevboxId: config.devboxId
24
+ };
25
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Utility functions for generating URLs
3
+ */
4
+ /**
5
+ * Get the base URL for the Runloop platform based on environment
6
+ * - dev: https://platform.runloop.pro
7
+ * - prod or unset: https://platform.runloop.ai (default)
8
+ */
9
+ export function getBaseUrl() {
10
+ const env = process.env.RUNLOOP_ENV?.toLowerCase();
11
+ switch (env) {
12
+ case 'dev':
13
+ return 'https://platform.runloop.pro';
14
+ case 'prod':
15
+ default:
16
+ return 'https://platform.runloop.ai';
17
+ }
18
+ }
19
+ /**
20
+ * Generate a devbox URL for the given devbox ID
21
+ */
22
+ export function getDevboxUrl(devboxId) {
23
+ const baseUrl = getBaseUrl();
24
+ return `${baseUrl}/devboxes/${devboxId}`;
25
+ }
26
+ /**
27
+ * Generate a blueprint URL for the given blueprint ID
28
+ */
29
+ export function getBlueprintUrl(blueprintId) {
30
+ const baseUrl = getBaseUrl();
31
+ return `${baseUrl}/blueprints/${blueprintId}`;
32
+ }
33
+ /**
34
+ * Generate a settings URL
35
+ */
36
+ export function getSettingsUrl() {
37
+ const baseUrl = getBaseUrl();
38
+ return `${baseUrl}/settings`;
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runloop/rl-cli",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "Beautiful CLI for Runloop devbox management",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47,6 +47,7 @@
47
47
  "dependencies": {
48
48
  "@inkjs/ui": "^2.0.0",
49
49
  "@runloop/api-client": "^0.55.0",
50
+ "@runloop/rl-cli": "^0.0.1",
50
51
  "chalk": "^5.3.0",
51
52
  "commander": "^12.1.0",
52
53
  "conf": "^13.0.1",