@runloop/rl-cli 0.0.2 → 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.
package/dist/cli.js CHANGED
@@ -6,11 +6,26 @@ import { deleteDevbox } from './commands/devbox/delete.js';
6
6
  import { execCommand } from './commands/devbox/exec.js';
7
7
  import { uploadFile } from './commands/devbox/upload.js';
8
8
  import { getConfig } from './utils/config.js';
9
+ import { readFileSync } from 'fs';
10
+ import { fileURLToPath } from 'url';
11
+ import { dirname, join } from 'path';
12
+ // Get version from package.json
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = dirname(__filename);
15
+ const packageJson = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'));
16
+ export const VERSION = packageJson.version;
17
+ // Global Ctrl+C handler to ensure it always exits
18
+ process.on('SIGINT', () => {
19
+ // Force exit immediately, clearing alternate screen buffer
20
+ process.stdout.write('\x1b[?1049l');
21
+ process.stdout.write('\n');
22
+ process.exit(130); // Standard exit code for SIGINT
23
+ });
9
24
  const program = new Command();
10
25
  program
11
26
  .name('rln')
12
27
  .description('Beautiful CLI for Runloop devbox management')
13
- .version('1.0.0');
28
+ .version(VERSION);
14
29
  program
15
30
  .command('auth')
16
31
  .description('Configure API authentication')
@@ -35,7 +50,16 @@ devbox
35
50
  .description('List all devboxes')
36
51
  .option('-s, --status <status>', 'Filter by status')
37
52
  .option('-o, --output [format]', 'Output format: text|json|yaml (default: interactive)')
38
- .action(listDevboxes);
53
+ .action(async (options) => {
54
+ // Only use alternate screen for interactive mode
55
+ if (!options.output) {
56
+ const { runInteractiveCommand } = await import('./utils/interactiveCommand.js');
57
+ await runInteractiveCommand(() => listDevboxes(options));
58
+ }
59
+ else {
60
+ await listDevboxes(options);
61
+ }
62
+ });
39
63
  devbox
40
64
  .command('delete <id>')
41
65
  .description('Shutdown a devbox')
@@ -65,7 +89,13 @@ snapshot
65
89
  .option('-o, --output [format]', 'Output format: text|json|yaml (default: interactive)')
66
90
  .action(async (options) => {
67
91
  const { listSnapshots } = await import('./commands/snapshot/list.js');
68
- listSnapshots(options);
92
+ if (!options.output) {
93
+ const { runInteractiveCommand } = await import('./utils/interactiveCommand.js');
94
+ await runInteractiveCommand(() => listSnapshots(options));
95
+ }
96
+ else {
97
+ await listSnapshots(options);
98
+ }
69
99
  });
70
100
  snapshot
71
101
  .command('create <devbox-id>')
@@ -96,15 +126,31 @@ blueprint
96
126
  .option('-o, --output [format]', 'Output format: text|json|yaml (default: interactive)')
97
127
  .action(async (options) => {
98
128
  const { listBlueprints } = await import('./commands/blueprint/list.js');
99
- listBlueprints(options);
129
+ if (!options.output) {
130
+ const { runInteractiveCommand } = await import('./utils/interactiveCommand.js');
131
+ await runInteractiveCommand(() => listBlueprints(options));
132
+ }
133
+ else {
134
+ await listBlueprints(options);
135
+ }
100
136
  });
101
- // Check if API key is configured (except for auth command)
102
- const args = process.argv.slice(2);
103
- if (args[0] !== 'auth' && args[0] !== '--help' && args[0] !== '-h' && args.length > 0) {
104
- const config = getConfig();
105
- if (!config.apiKey) {
106
- console.error('\n❌ API key not configured. Run: rln auth\n');
107
- process.exit(1);
137
+ // Main CLI entry point
138
+ (async () => {
139
+ // Check if API key is configured (except for auth command)
140
+ const args = process.argv.slice(2);
141
+ if (args[0] !== 'auth' && args[0] !== '--help' && args[0] !== '-h' && args.length > 0) {
142
+ const config = getConfig();
143
+ if (!config.apiKey) {
144
+ console.error('\n❌ API key not configured. Run: rln auth\n');
145
+ process.exit(1);
146
+ }
147
+ }
148
+ // If no command provided, show main menu
149
+ if (args.length === 0) {
150
+ const { runMainMenu } = await import('./commands/menu.js');
151
+ runMainMenu();
152
+ }
153
+ else {
154
+ program.parse();
108
155
  }
109
- }
110
- program.parse();
156
+ })();
@@ -6,6 +6,7 @@ import { setApiKey } from '../utils/config.js';
6
6
  import { Header } from '../components/Header.js';
7
7
  import { Banner } from '../components/Banner.js';
8
8
  import { SuccessMessage } from '../components/SuccessMessage.js';
9
+ import { getSettingsUrl } from '../utils/url.js';
9
10
  const AuthUI = () => {
10
11
  const [apiKey, setApiKeyInput] = React.useState('');
11
12
  const [saved, setSaved] = React.useState(false);
@@ -19,7 +20,7 @@ const AuthUI = () => {
19
20
  if (saved) {
20
21
  return (_jsxs(_Fragment, { children: [_jsx(Banner, {}), _jsx(Header, { title: "Authentication" }), _jsx(SuccessMessage, { message: "API key saved!", details: "Try: rln devbox list" })] }));
21
22
  }
22
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Banner, {}), _jsx(Header, { title: "Authentication" }), _jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: "gray", children: "Get your key: " }), _jsx(Text, { color: "cyan", children: "https://runloop.ai/settings" })] }), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: "API Key: " }), _jsx(TextInput, { value: apiKey, onChange: setApiKeyInput, placeholder: "rl_...", mask: "*" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", dimColor: true, children: "Press Enter to save" }) })] }));
23
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Banner, {}), _jsx(Header, { title: "Authentication" }), _jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: "gray", children: "Get your key: " }), _jsx(Text, { color: "cyan", children: getSettingsUrl() })] }), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: "API Key: " }), _jsx(TextInput, { value: apiKey, onChange: setApiKeyInput, placeholder: "ak_...", mask: "*" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", dimColor: true, children: "Press Enter to save" }) })] }));
23
24
  };
24
25
  export default function auth() {
25
26
  console.clear();
@@ -14,6 +14,7 @@ import { MetadataDisplay } from '../../components/MetadataDisplay.js';
14
14
  import { Table, createTextColumn, createComponentColumn } from '../../components/Table.js';
15
15
  import { OperationsMenu } from '../../components/OperationsMenu.js';
16
16
  import { createExecutor } from '../../utils/CommandExecutor.js';
17
+ import { getBlueprintUrl } from '../../utils/url.js';
17
18
  const PAGE_SIZE = 10;
18
19
  const MAX_FETCH = 100;
19
20
  // Format time ago
@@ -36,9 +37,9 @@ const formatTimeAgo = (timestamp) => {
36
37
  const years = Math.floor(months / 12);
37
38
  return `${years}y ago`;
38
39
  };
39
- const ListBlueprintsUI = () => {
40
+ const ListBlueprintsUI = ({ onBack, onExit }) => {
40
41
  const { stdout } = useStdout();
41
- const { exit } = useApp();
42
+ const { exit: inkExit } = useApp();
42
43
  const [loading, setLoading] = React.useState(true);
43
44
  const [blueprints, setBlueprints] = React.useState([]);
44
45
  const [error, setError] = React.useState(null);
@@ -180,7 +181,7 @@ const ListBlueprintsUI = () => {
180
181
  }
181
182
  else if (input === 'o' && selectedBlueprint) {
182
183
  // Open in browser
183
- const url = `https://platform.runloop.ai/blueprints/${selectedBlueprint.id}`;
184
+ const url = getBlueprintUrl(selectedBlueprint.id);
184
185
  const openBrowser = async () => {
185
186
  const { exec } = await import('child_process');
186
187
  const platform = process.platform;
@@ -232,7 +233,7 @@ const ListBlueprintsUI = () => {
232
233
  }
233
234
  else if (input === 'o' && selectedBlueprint) {
234
235
  // Open in browser
235
- const url = `https://platform.runloop.ai/blueprints/${selectedBlueprint.id}`;
236
+ const url = getBlueprintUrl(selectedBlueprint.id);
236
237
  const openBrowser = async () => {
237
238
  const { exec } = await import('child_process');
238
239
  const platform = process.platform;
@@ -250,8 +251,16 @@ const ListBlueprintsUI = () => {
250
251
  };
251
252
  openBrowser();
252
253
  }
253
- else if (input === 'q') {
254
- process.exit(0);
254
+ else if (key.escape) {
255
+ if (onBack) {
256
+ onBack();
257
+ }
258
+ else if (onExit) {
259
+ onExit();
260
+ }
261
+ else {
262
+ inkExit();
263
+ }
255
264
  }
256
265
  });
257
266
  const totalPages = Math.ceil(blueprints.length / PAGE_SIZE);
@@ -360,8 +369,10 @@ const ListBlueprintsUI = () => {
360
369
  visible: showDescription,
361
370
  }),
362
371
  createTextColumn('created', 'Created', (blueprint) => blueprint.create_time_ms ? formatTimeAgo(blueprint.create_time_ms) : '', { width: timeWidth, color: 'gray', dimColor: true, bold: false }),
363
- ] }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "gray", dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022 [Enter] Operations \u2022 [o] Open in Browser \u2022"] }), totalPages > 1 && (_jsxs(Text, { color: "gray", dimColor: true, children: [' ', figures.arrowLeft, figures.arrowRight, " Page \u2022"] })), _jsxs(Text, { color: "gray", dimColor: true, children: [' ', "[q] Quit"] })] })] })), error && _jsx(ErrorMessage, { message: "Failed to list blueprints", error: error })] }));
372
+ ] }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "gray", dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022 [Enter] Operations \u2022 [o] Open in Browser \u2022"] }), totalPages > 1 && (_jsxs(Text, { color: "gray", dimColor: true, children: [' ', figures.arrowLeft, figures.arrowRight, " Page \u2022"] })), _jsxs(Text, { color: "gray", dimColor: true, children: [' ', "[Esc] Back"] })] })] })), error && _jsx(ErrorMessage, { message: "Failed to list blueprints", error: error })] }));
364
373
  };
374
+ // Export the UI component for use in the main menu
375
+ export { ListBlueprintsUI };
365
376
  export async function listBlueprints(options = {}) {
366
377
  const executor = createExecutor(options);
367
378
  await executor.executeList(async () => {
@@ -14,6 +14,8 @@ import { DevboxDetailPage } from '../../components/DevboxDetailPage.js';
14
14
  import { DevboxCreatePage } from '../../components/DevboxCreatePage.js';
15
15
  import { DevboxActionsMenu } from '../../components/DevboxActionsMenu.js';
16
16
  import { ActionsPopup } from '../../components/ActionsPopup.js';
17
+ import { getDevboxUrl } from '../../utils/url.js';
18
+ import { runSSHSession } from '../../utils/sshSession.js';
17
19
  // Format time ago in a succinct way
18
20
  const formatTimeAgo = (timestamp) => {
19
21
  const seconds = Math.floor((Date.now() - timestamp) / 1000);
@@ -35,8 +37,8 @@ const formatTimeAgo = (timestamp) => {
35
37
  return `${years}y ago`;
36
38
  };
37
39
  const DEFAULT_PAGE_SIZE = 10;
38
- const ListDevboxesUI = ({ status }) => {
39
- const { exit } = useApp();
40
+ const ListDevboxesUI = ({ status, onSSHRequest, focusDevboxId, onBack, onExit }) => {
41
+ const { exit: inkExit } = useApp();
40
42
  const { stdout } = useStdout();
41
43
  const [loading, setLoading] = React.useState(true);
42
44
  const [devboxes, setDevboxes] = React.useState([]);
@@ -99,6 +101,17 @@ const ListDevboxesUI = ({ status }) => {
99
101
  { key: 'resume', label: 'Resume Devbox', color: 'green', icon: figures.play, shortcut: 'r' },
100
102
  { key: 'delete', label: 'Shutdown Devbox', color: 'red', icon: figures.cross, shortcut: 'd' },
101
103
  ];
104
+ // Check if we need to focus on a specific devbox after returning from SSH
105
+ React.useEffect(() => {
106
+ if (focusDevboxId && devboxes.length > 0 && !loading) {
107
+ // Find the devbox in the current page
108
+ const devboxIndex = devboxes.findIndex(d => d.id === focusDevboxId);
109
+ if (devboxIndex !== -1) {
110
+ setSelectedIndex(devboxIndex);
111
+ setShowDetails(true);
112
+ }
113
+ }
114
+ }, [devboxes, loading, focusDevboxId]);
102
115
  React.useEffect(() => {
103
116
  const list = async (isInitialLoad = false) => {
104
117
  try {
@@ -189,6 +202,11 @@ const ListDevboxesUI = ({ status }) => {
189
202
  return () => clearInterval(interval);
190
203
  }, [showDetails, showCreate, showActions]);
191
204
  useInput((input, key) => {
205
+ // Handle Ctrl+C to force exit
206
+ if (key.ctrl && input === 'c') {
207
+ process.stdout.write('\x1b[?1049l'); // Exit alternate screen
208
+ process.exit(130);
209
+ }
192
210
  const pageDevboxes = currentDevboxes.length;
193
211
  // Skip input handling when in search mode - let TextInput handle it
194
212
  if (searchMode) {
@@ -271,7 +289,7 @@ const ListDevboxesUI = ({ status }) => {
271
289
  }
272
290
  else if (input === 'o' && selectedDevbox) {
273
291
  // Open in browser
274
- const url = `https://platform.runloop.ai/devboxes/${selectedDevbox.id}`;
292
+ const url = getDevboxUrl(selectedDevbox.id);
275
293
  const openBrowser = async () => {
276
294
  const { exec } = await import('child_process');
277
295
  const platform = process.platform;
@@ -292,14 +310,25 @@ const ListDevboxesUI = ({ status }) => {
292
310
  else if (input === '/') {
293
311
  setSearchMode(true);
294
312
  }
295
- else if (key.escape && searchQuery) {
296
- // Clear search when Esc is pressed and there's an active search
297
- setSearchQuery('');
298
- setCurrentPage(0);
299
- setSelectedIndex(0);
300
- }
301
- else if (input === 'q') {
302
- process.exit(0);
313
+ else if (key.escape) {
314
+ if (searchQuery) {
315
+ // Clear search when Esc is pressed and there's an active search
316
+ setSearchQuery('');
317
+ setCurrentPage(0);
318
+ setSelectedIndex(0);
319
+ }
320
+ else {
321
+ // Go back to home
322
+ if (onBack) {
323
+ onBack();
324
+ }
325
+ else if (onExit) {
326
+ onExit();
327
+ }
328
+ else {
329
+ inkExit();
330
+ }
331
+ }
303
332
  }
304
333
  });
305
334
  // Filter devboxes based on search query (client-side only for current page)
@@ -313,10 +342,14 @@ const ListDevboxesUI = ({ status }) => {
313
342
  devbox.status?.toLowerCase().includes(query));
314
343
  });
315
344
  }, [devboxes, searchQuery]);
316
- const running = filteredDevboxes.filter((d) => d.status === 'running').length;
317
- const stopped = filteredDevboxes.filter((d) => ['stopped', 'suspended'].includes(d.status)).length;
318
345
  // Current page is already fetched, no need to slice
319
346
  const currentDevboxes = filteredDevboxes;
347
+ // Ensure selected index is within bounds after filtering
348
+ React.useEffect(() => {
349
+ if (currentDevboxes.length > 0 && selectedIndex >= currentDevboxes.length) {
350
+ setSelectedIndex(Math.max(0, currentDevboxes.length - 1));
351
+ }
352
+ }, [currentDevboxes.length, selectedIndex]);
320
353
  const selectedDevbox = currentDevboxes[selectedIndex];
321
354
  // Calculate pagination info
322
355
  const totalPages = Math.ceil(totalCount / PAGE_SIZE);
@@ -359,11 +392,11 @@ const ListDevboxesUI = ({ status }) => {
359
392
  }, breadcrumbItems: [
360
393
  { label: 'Devboxes' },
361
394
  { label: selectedDevbox.name || selectedDevbox.id, active: true }
362
- ], initialOperation: selectedOp?.key, initialOperationIndex: selectedOperation }));
395
+ ], initialOperation: selectedOp?.key, skipOperationsMenu: true, onSSHRequest: onSSHRequest }));
363
396
  }
364
397
  // Details view
365
398
  if (showDetails && selectedDevbox) {
366
- return _jsx(DevboxDetailPage, { devbox: selectedDevbox, onBack: () => setShowDetails(false) });
399
+ return (_jsx(DevboxDetailPage, { devbox: selectedDevbox, onBack: () => setShowDetails(false), onSSHRequest: onSSHRequest }));
367
400
  }
368
401
  // Show popup with table in background
369
402
  if (showPopup && selectedDevbox) {
@@ -408,14 +441,30 @@ const ListDevboxesUI = ({ status }) => {
408
441
  createTextColumn('created', 'Created', (devbox) => devbox.create_time_ms ? formatTimeAgo(devbox.create_time_ms) : '', { width: timeWidth, color: 'gray', dimColor: true, bold: false }),
409
442
  ] }) })), _jsx(Box, { marginTop: -Math.min(operations.length + 10, PAGE_SIZE + 5), justifyContent: "center", children: _jsx(ActionsPopup, { devbox: selectedDevbox, operations: operations, selectedOperation: selectedOperation, onClose: () => setShowPopup(false) }) })] }));
410
443
  }
411
- // List view
444
+ // If loading or error, show that first
445
+ if (loading) {
446
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
447
+ { label: 'Devboxes', active: true }
448
+ ] }), _jsx(SpinnerComponent, { message: "Loading..." })] }));
449
+ }
450
+ if (error) {
451
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
452
+ { label: 'Devboxes', active: true }
453
+ ] }), _jsx(ErrorMessage, { message: "Failed to list devboxes", error: error })] }));
454
+ }
455
+ if (!loading && !error && devboxes.length === 0) {
456
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
457
+ { label: 'Devboxes', active: true }
458
+ ] }), _jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: figures.info }), _jsx(Text, { children: " No devboxes found. Try: " }), _jsx(Text, { color: "cyan", bold: true, children: "rln devbox create" })] })] }));
459
+ }
460
+ // List view with data
412
461
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
413
462
  { label: 'Devboxes', active: true }
414
- ] }), loading && _jsx(SpinnerComponent, { message: "Loading..." }), !loading && !error && devboxes.length === 0 && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: figures.info }), _jsx(Text, { children: " No devboxes found. Try: " }), _jsx(Text, { color: "cyan", bold: true, children: "rln devbox create" })] })), !loading && !error && devboxes.length > 0 && (_jsxs(_Fragment, { children: [searchMode && (_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: "cyan", children: [figures.pointerSmall, " Search: "] }), _jsx(TextInput, { value: searchQuery, onChange: setSearchQuery, placeholder: "Type to search (name, id, status)...", onSubmit: () => {
463
+ ] }), currentDevboxes && currentDevboxes.length >= 0 && (_jsxs(_Fragment, { children: [searchMode && (_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: "cyan", children: [figures.pointerSmall, " Search: "] }), _jsx(TextInput, { value: searchQuery, onChange: setSearchQuery, placeholder: "Type to search (name, id, status)...", onSubmit: () => {
415
464
  setSearchMode(false);
416
465
  setCurrentPage(0);
417
466
  setSelectedIndex(0);
418
- } }), _jsx(Text, { color: "gray", dimColor: true, children: " [Esc to cancel]" })] })), searchQuery && !searchMode && (_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: "cyan", children: [figures.info, " Searching for: "] }), _jsx(Text, { color: "yellow", bold: true, children: searchQuery }), _jsxs(Text, { color: "gray", dimColor: true, children: [" (", filteredDevboxes.length, " results) [/ to edit, Esc to clear]"] })] })), _jsx(Table, { data: currentDevboxes, keyExtractor: (devbox) => devbox.id, selectedIndex: selectedIndex, title: `devboxes[${totalCount}]`, columns: [
467
+ } }), _jsx(Text, { color: "gray", dimColor: true, children: " [Esc to cancel]" })] })), !searchMode && searchQuery && (_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: "cyan", children: [figures.info, " Searching for: "] }), _jsx(Text, { color: "yellow", bold: true, children: searchQuery }), _jsxs(Text, { color: "gray", dimColor: true, children: [" (", currentDevboxes.length, " results) [/ to edit, Esc to clear]"] })] })), _jsx(Table, { data: currentDevboxes, keyExtractor: (devbox) => devbox.id, selectedIndex: selectedIndex, title: `devboxes[${searchQuery ? currentDevboxes.length : totalCount}]`, columns: [
419
468
  {
420
469
  key: 'statusIcon',
421
470
  label: '',
@@ -452,36 +501,34 @@ const ListDevboxesUI = ({ status }) => {
452
501
  }, { width: capabilitiesWidth, color: 'blue', dimColor: true, bold: false, visible: showCapabilities }),
453
502
  createTextColumn('tags', 'Tags', (devbox) => devbox.blueprint_id ? '[bp]' : devbox.snapshot_id ? '[snap]' : '', { width: tagWidth, color: 'yellow', dimColor: true, bold: false, visible: showTags }),
454
503
  createTextColumn('created', 'Created', (devbox) => devbox.create_time_ms ? formatTimeAgo(devbox.create_time_ms) : '', { width: timeWidth, color: 'gray', dimColor: true, bold: false }),
455
- ] }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: "cyan", bold: true, children: [figures.hamburger, " ", totalCount] }), _jsx(Text, { color: "gray", dimColor: true, children: " total" }), totalPages > 1 && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", dimColor: true, children: " \u2022 " }), _jsxs(Text, { color: "gray", dimColor: true, children: ["Page ", currentPage + 1, " of ", totalPages] })] })), _jsx(Text, { color: "gray", dimColor: true, children: " \u2022 " }), _jsxs(Text, { color: "gray", dimColor: true, children: ["Showing ", startIndex + 1, "-", endIndex, " of ", totalCount] }), hasMore && (_jsx(Text, { color: "gray", dimColor: true, children: " (more available)" })), _jsx(Text, { children: " " }), refreshing ? (_jsx(Text, { color: "cyan", children: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'][refreshIcon % 10] })) : (_jsx(Text, { color: "green", children: figures.circleFilled }))] }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: "gray", dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate"] }), totalPages > 1 && (_jsxs(Text, { color: "gray", dimColor: true, children: [' ', "\u2022 ", figures.arrowLeft, figures.arrowRight, " Page"] })), _jsxs(Text, { color: "gray", dimColor: true, children: [' ', "\u2022 [Enter] Details \u2022 [a] Actions \u2022 [c] Create \u2022 [/] Search \u2022 [o] Browser \u2022 [q] Quit"] })] })] })), error && _jsx(ErrorMessage, { message: "Failed to list devboxes", error: error })] }));
504
+ ] }, `table-${searchQuery}-${currentPage}`), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: "cyan", bold: true, children: [figures.hamburger, " ", totalCount] }), _jsx(Text, { color: "gray", dimColor: true, children: " total" }), totalPages > 1 && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", dimColor: true, children: " \u2022 " }), _jsxs(Text, { color: "gray", dimColor: true, children: ["Page ", currentPage + 1, " of ", totalPages] })] })), _jsx(Text, { color: "gray", dimColor: true, children: " \u2022 " }), _jsxs(Text, { color: "gray", dimColor: true, children: ["Showing ", startIndex + 1, "-", endIndex, " of ", totalCount] }), hasMore && (_jsx(Text, { color: "gray", dimColor: true, children: " (more available)" })), _jsx(Text, { children: " " }), refreshing ? (_jsx(Text, { color: "cyan", children: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'][refreshIcon % 10] })) : (_jsx(Text, { color: "green", children: figures.circleFilled }))] }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: "gray", dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate"] }), totalPages > 1 && (_jsxs(Text, { color: "gray", dimColor: true, children: [' ', "\u2022 ", figures.arrowLeft, figures.arrowRight, " Page"] })), _jsxs(Text, { color: "gray", dimColor: true, children: [' ', "\u2022 [Enter] Details \u2022 [a] Actions \u2022 [c] Create \u2022 [/] Search \u2022 [o] Browser \u2022 [Esc] Back"] })] })] }))] }));
456
505
  };
457
- export async function listDevboxes(options) {
506
+ // Export the UI component for use in the main menu
507
+ export { ListDevboxesUI };
508
+ export async function listDevboxes(options, focusDevboxId) {
458
509
  const executor = createExecutor(options);
510
+ let sshSessionConfig = null;
459
511
  await executor.executeList(async () => {
460
512
  const client = executor.getClient();
461
513
  return executor.fetchFromIterator(client.devboxes.list(), {
462
514
  filter: options.status ? (devbox) => devbox.status === options.status : undefined,
463
515
  limit: DEFAULT_PAGE_SIZE,
464
516
  });
465
- }, () => _jsx(ListDevboxesUI, { status: options.status }), DEFAULT_PAGE_SIZE);
466
- // Check if we need to spawn SSH after Ink exit
467
- const sshCommand = global.__sshCommand;
468
- if (sshCommand) {
469
- delete global.__sshCommand;
470
- // Import spawn
471
- const { spawnSync } = await import('child_process');
472
- // Clear and show connection message
473
- console.clear();
474
- console.log(`\nConnecting to devbox ${sshCommand.devboxName}...\n`);
475
- // Spawn SSH in foreground
476
- const result = spawnSync('ssh', [
477
- '-i', sshCommand.keyPath,
478
- '-o', `ProxyCommand=${sshCommand.proxyCommand}`,
479
- '-o', 'StrictHostKeyChecking=no',
480
- '-o', 'UserKnownHostsFile=/dev/null',
481
- `${sshCommand.sshUser}@${sshCommand.url}`
482
- ], {
483
- stdio: 'inherit'
484
- });
485
- process.exit(result.status || 0);
517
+ }, () => (_jsx(ListDevboxesUI, { status: options.status, focusDevboxId: focusDevboxId, onSSHRequest: (config) => {
518
+ sshSessionConfig = config;
519
+ } })), DEFAULT_PAGE_SIZE);
520
+ // If SSH was requested, handle it now after Ink has exited
521
+ if (sshSessionConfig) {
522
+ const result = await runSSHSession(sshSessionConfig);
523
+ if (result.shouldRestart) {
524
+ console.clear();
525
+ console.log(`\nSSH session ended. Returning to CLI...\n`);
526
+ await new Promise(resolve => setTimeout(resolve, 500));
527
+ // Restart the list view with the devbox ID to focus on
528
+ await listDevboxes(options, result.returnToDevboxId);
529
+ }
530
+ else {
531
+ process.exit(result.exitCode);
532
+ }
486
533
  }
487
534
  }
@@ -0,0 +1,70 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { render, useApp } from 'ink';
4
+ import { MainMenu } from '../components/MainMenu.js';
5
+ import { runSSHSession } from '../utils/sshSession.js';
6
+ // Import the UI components directly
7
+ import { ListDevboxesUI } from './devbox/list.js';
8
+ import { ListBlueprintsUI } from './blueprint/list.js';
9
+ import { ListSnapshotsUI } from './snapshot/list.js';
10
+ import { Box } from 'ink';
11
+ const App = ({ onSSHRequest, initialScreen = 'menu', focusDevboxId }) => {
12
+ const { exit } = useApp();
13
+ const [currentScreen, setCurrentScreen] = React.useState(initialScreen);
14
+ const [, forceUpdate] = React.useReducer(x => x + 1, 0);
15
+ const handleMenuSelect = (key) => {
16
+ setCurrentScreen(key);
17
+ };
18
+ const handleBack = () => {
19
+ setCurrentScreen('menu');
20
+ };
21
+ const handleExit = () => {
22
+ exit();
23
+ };
24
+ // Wrap everything in a full-height container
25
+ return (_jsxs(Box, { flexDirection: "column", minHeight: process.stdout.rows || 24, children: [currentScreen === 'menu' && _jsx(MainMenu, { onSelect: handleMenuSelect }), currentScreen === 'devboxes' && (_jsx(ListDevboxesUI, { onBack: handleBack, onExit: handleExit, onSSHRequest: onSSHRequest, focusDevboxId: focusDevboxId })), currentScreen === 'blueprints' && _jsx(ListBlueprintsUI, { onBack: handleBack, onExit: handleExit }), currentScreen === 'snapshots' && _jsx(ListSnapshotsUI, { onBack: handleBack, onExit: handleExit })] }));
26
+ };
27
+ export async function runMainMenu(initialScreen = 'menu', focusDevboxId) {
28
+ // Enter alternate screen buffer once at the start
29
+ process.stdout.write('\x1b[?1049h');
30
+ let sshSessionConfig = null;
31
+ let shouldContinue = true;
32
+ let currentInitialScreen = initialScreen;
33
+ let currentFocusDevboxId = focusDevboxId;
34
+ while (shouldContinue) {
35
+ sshSessionConfig = null;
36
+ try {
37
+ const { waitUntilExit } = render(_jsx(App, { onSSHRequest: (config) => {
38
+ sshSessionConfig = config;
39
+ }, initialScreen: currentInitialScreen, focusDevboxId: currentFocusDevboxId }));
40
+ await waitUntilExit();
41
+ shouldContinue = false;
42
+ }
43
+ catch (error) {
44
+ console.error('Error in menu:', error);
45
+ shouldContinue = false;
46
+ }
47
+ // If SSH was requested, handle it now after Ink has exited
48
+ if (sshSessionConfig) {
49
+ // Exit alternate screen buffer for SSH
50
+ process.stdout.write('\x1b[?1049l');
51
+ const result = await runSSHSession(sshSessionConfig);
52
+ if (result.shouldRestart) {
53
+ console.clear();
54
+ console.log(`\nSSH session ended. Returning to menu...\n`);
55
+ await new Promise(resolve => setTimeout(resolve, 500));
56
+ // Re-enter alternate screen buffer and return to devboxes list
57
+ process.stdout.write('\x1b[?1049h');
58
+ currentInitialScreen = 'devboxes';
59
+ currentFocusDevboxId = result.returnToDevboxId;
60
+ shouldContinue = true;
61
+ }
62
+ else {
63
+ shouldContinue = false;
64
+ }
65
+ }
66
+ }
67
+ // Exit alternate screen buffer once at the end
68
+ process.stdout.write('\x1b[?1049l');
69
+ process.exit(0);
70
+ }
@@ -1,10 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import React from 'react';
3
- import { Box, Text, useInput, useStdout } from 'ink';
3
+ import { Box, Text, useInput, useStdout, useApp } from 'ink';
4
4
  import figures from 'figures';
5
5
  import { getClient } from '../../utils/client.js';
6
- import { Header } from '../../components/Header.js';
7
- import { Banner } from '../../components/Banner.js';
8
6
  import { SpinnerComponent } from '../../components/Spinner.js';
9
7
  import { ErrorMessage } from '../../components/ErrorMessage.js';
10
8
  import { StatusBadge } from '../../components/StatusBadge.js';
@@ -33,8 +31,9 @@ const formatTimeAgo = (timestamp) => {
33
31
  const years = Math.floor(months / 12);
34
32
  return `${years}y ago`;
35
33
  };
36
- const ListSnapshotsUI = ({ devboxId }) => {
34
+ const ListSnapshotsUI = ({ devboxId, onBack, onExit }) => {
37
35
  const { stdout } = useStdout();
36
+ const { exit: inkExit } = useApp();
38
37
  const [loading, setLoading] = React.useState(true);
39
38
  const [snapshots, setSnapshots] = React.useState([]);
40
39
  const [error, setError] = React.useState(null);
@@ -89,8 +88,16 @@ const ListSnapshotsUI = ({ devboxId }) => {
89
88
  setCurrentPage(currentPage - 1);
90
89
  setSelectedIndex(0);
91
90
  }
92
- else if (input === 'q') {
93
- process.exit(0);
91
+ else if (key.escape) {
92
+ if (onBack) {
93
+ onBack();
94
+ }
95
+ else if (onExit) {
96
+ onExit();
97
+ }
98
+ else {
99
+ inkExit();
100
+ }
94
101
  }
95
102
  });
96
103
  const totalPages = Math.ceil(snapshots.length / PAGE_SIZE);
@@ -99,17 +106,19 @@ const ListSnapshotsUI = ({ devboxId }) => {
99
106
  const currentSnapshots = snapshots.slice(startIndex, endIndex);
100
107
  const ready = snapshots.filter((s) => s.status === 'ready').length;
101
108
  const pending = snapshots.filter((s) => s.status !== 'ready').length;
102
- return (_jsxs(_Fragment, { children: [_jsx(Banner, {}), _jsx(Breadcrumb, { items: [
109
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
103
110
  { label: 'Snapshots', active: !devboxId },
104
111
  ...(devboxId ? [{ label: `Devbox: ${devboxId}`, active: true }] : []),
105
- ] }), _jsx(Header, { title: "Snapshots", subtitle: devboxId ? `Filtering by devbox: ${devboxId}` : undefined }), loading && _jsx(SpinnerComponent, { message: "Loading snapshots..." }), !loading && !error && snapshots.length === 0 && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: figures.info }), _jsx(Text, { children: " No snapshots found. Try: " }), _jsx(Text, { color: "cyan", bold: true, children: "rln snapshot create <devbox-id>" })] })), !loading && !error && snapshots.length > 0 && (_jsxs(_Fragment, { children: [_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: "green", children: [figures.tick, " ", ready] }), _jsx(Text, { children: " " }), _jsxs(Text, { color: "yellow", children: [figures.ellipsis, " ", pending] }), _jsx(Text, { children: " " }), _jsxs(Text, { color: "cyan", children: [figures.hamburger, " ", snapshots.length, snapshots.length >= MAX_FETCH && '+'] }), totalPages > 1 && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: " \u2022 " }), _jsxs(Text, { color: "gray", dimColor: true, children: ["Page ", currentPage + 1, "/", totalPages] })] }))] }), _jsx(Table, { data: currentSnapshots, keyExtractor: (snapshot) => snapshot.id, selectedIndex: selectedIndex, columns: [
112
+ ] }), loading && _jsx(SpinnerComponent, { message: "Loading snapshots..." }), !loading && !error && snapshots.length === 0 && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: figures.info }), _jsx(Text, { children: " No snapshots found. Try: " }), _jsx(Text, { color: "cyan", bold: true, children: "rln snapshot create <devbox-id>" })] })), !loading && !error && snapshots.length > 0 && (_jsxs(_Fragment, { children: [_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: "green", children: [figures.tick, " ", ready] }), _jsx(Text, { children: " " }), _jsxs(Text, { color: "yellow", children: [figures.ellipsis, " ", pending] }), _jsx(Text, { children: " " }), _jsxs(Text, { color: "cyan", children: [figures.hamburger, " ", snapshots.length, snapshots.length >= MAX_FETCH && '+'] }), totalPages > 1 && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: " \u2022 " }), _jsxs(Text, { color: "gray", dimColor: true, children: ["Page ", currentPage + 1, "/", totalPages] })] }))] }), _jsx(Table, { data: currentSnapshots, keyExtractor: (snapshot) => snapshot.id, selectedIndex: selectedIndex, columns: [
106
113
  createComponentColumn('status', 'Status', (snapshot) => _jsx(StatusBadge, { status: snapshot.status, showText: false }), { width: 2 }),
107
114
  createTextColumn('id', 'ID', (snapshot) => showFullId ? snapshot.id : snapshot.id.slice(0, 13), { width: showFullId ? idWidth : 15, color: 'gray', dimColor: true, bold: false }),
108
115
  createTextColumn('name', 'Name', (snapshot) => snapshot.name || '(unnamed)', { width: nameWidth }),
109
116
  createTextColumn('devbox', 'Devbox', (snapshot) => snapshot.devbox_id || '', { width: devboxWidth, color: 'cyan', dimColor: true, bold: false, visible: showDevboxId }),
110
117
  createTextColumn('created', 'Created', (snapshot) => snapshot.created_at ? formatTimeAgo(new Date(snapshot.created_at).getTime()) : '', { width: timeWidth, color: 'gray', dimColor: true, bold: false }),
111
- ] }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "gray", dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022"] }), totalPages > 1 && (_jsxs(Text, { color: "gray", dimColor: true, children: [' ', figures.arrowLeft, figures.arrowRight, " Page \u2022"] })), _jsxs(Text, { color: "gray", dimColor: true, children: [' ', "[q] Quit"] })] })] })), error && _jsx(ErrorMessage, { message: "Failed to list snapshots", error: error })] }));
118
+ ] }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "gray", dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022"] }), totalPages > 1 && (_jsxs(Text, { color: "gray", dimColor: true, children: [' ', figures.arrowLeft, figures.arrowRight, " Page \u2022"] })), _jsxs(Text, { color: "gray", dimColor: true, children: [' ', "[Esc] Back"] })] })] })), error && _jsx(ErrorMessage, { message: "Failed to list snapshots", error: error })] }));
112
119
  };
120
+ // Export the UI component for use in the main menu
121
+ export { ListSnapshotsUI };
113
122
  export async function listSnapshots(options) {
114
123
  const executor = createExecutor(options);
115
124
  await executor.executeList(async () => {
@@ -1,11 +1,8 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import React from 'react';
3
- import { Box, Text } from 'ink';
3
+ import { Box } from 'ink';
4
+ import BigText from 'ink-big-text';
4
5
  import Gradient from 'ink-gradient';
5
6
  export const Banner = React.memo(() => {
6
- return (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: _jsx(Gradient, { colors: ['#0a4d3a', '#e5f1ed'], children: _jsx(Text, { bold: true, children: `
7
- ╦═╗╦ ╦╔╗╔╦ ╔═╗╔═╗╔═╗
8
- ╠╦╝║ ║║║║║ ║ ║║ ║╠═╝
9
- ╩╚═╚═╝╝╚╝╩═╝╚═╝╚═╝╩ .ai
10
- ` }) }) }));
7
+ return (_jsx(Box, { flexDirection: "column", alignItems: "flex-start", children: _jsx(Gradient, { name: "vice", children: _jsx(BigText, { text: "RUNLOOP.ai", font: "simple3d" }) }) }));
11
8
  });
@@ -1,9 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React from 'react';
3
3
  import { Box, Text } from 'ink';
4
- import figures from 'figures';
5
4
  export const Breadcrumb = React.memo(({ items }) => {
6
- const baseUrl = process.env.RUNLOOP_BASE_URL;
7
- const isDevEnvironment = baseUrl && baseUrl !== 'https://api.runloop.ai';
8
- return (_jsxs(Box, { marginBottom: 1, paddingX: 1, paddingY: 0, children: [_jsx(Text, { color: "green", dimColor: true, bold: true, children: "RL" }), isDevEnvironment && _jsx(Text, { color: "redBright", bold: true, children: " (dev)" }), _jsx(Text, { children: " " }), _jsxs(Text, { color: "gray", dimColor: true, children: [figures.arrowRight, " "] }), items.map((item, index) => (_jsxs(React.Fragment, { children: [_jsx(Text, { color: item.active ? 'cyan' : 'gray', bold: item.active, dimColor: !item.active, children: item.label }), index < items.length - 1 && (_jsx(Text, { color: "gray", dimColor: true, children: " / " }))] }, index)))] }));
5
+ const env = process.env.RUNLOOP_ENV?.toLowerCase();
6
+ const isDevEnvironment = env === 'dev';
7
+ return (_jsx(Box, { marginBottom: 1, paddingX: 2, paddingY: 0, children: _jsxs(Box, { borderStyle: "round", borderColor: "cyan", paddingX: 2, paddingY: 0, children: [_jsx(Text, { color: "cyan", bold: true, children: "rl" }), isDevEnvironment && _jsx(Text, { color: "redBright", bold: true, children: " (dev)" }), _jsx(Text, { color: "gray", dimColor: true, children: " \u203A " }), items.map((item, index) => (_jsxs(React.Fragment, { children: [_jsx(Text, { color: item.active ? 'white' : 'gray', bold: item.active, dimColor: !item.active, children: item.label }), index < items.length - 1 && (_jsx(Text, { color: "gray", dimColor: true, children: " \u203A " }))] }, index)))] }) }));
9
8
  });
@@ -12,7 +12,7 @@ import { Breadcrumb } from './Breadcrumb.js';
12
12
  export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
13
13
  { label: 'Devboxes' },
14
14
  { label: devbox.name || devbox.id, active: true }
15
- ], initialOperation, initialOperationIndex = 0, }) => {
15
+ ], initialOperation, initialOperationIndex = 0, skipOperationsMenu = false, onSSHRequest, }) => {
16
16
  const { exit } = useApp();
17
17
  const { stdout } = useStdout();
18
18
  const [loading, setLoading] = React.useState(false);
@@ -21,7 +21,7 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
21
21
  const [operationInput, setOperationInput] = React.useState('');
22
22
  const [operationResult, setOperationResult] = React.useState(null);
23
23
  const [operationError, setOperationError] = React.useState(null);
24
- const [logsWrapMode, setLogsWrapMode] = React.useState(true);
24
+ const [logsWrapMode, setLogsWrapMode] = React.useState(false);
25
25
  const [logsScroll, setLogsScroll] = React.useState(0);
26
26
  const [execScroll, setExecScroll] = React.useState(0);
27
27
  const [copyStatus, setCopyStatus] = React.useState(null);
@@ -56,7 +56,8 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
56
56
  }) : allOperations;
57
57
  // Auto-execute operations that don't need input
58
58
  React.useEffect(() => {
59
- if ((executingOperation === 'delete' || executingOperation === 'ssh' || executingOperation === 'logs' || executingOperation === 'suspend' || executingOperation === 'resume') && !loading && devbox) {
59
+ const autoExecuteOps = ['delete', 'ssh', 'logs', 'suspend', 'resume'];
60
+ if (executingOperation && autoExecuteOps.includes(executingOperation) && !loading && devbox) {
60
61
  executeOperation();
61
62
  }
62
63
  }, [executingOperation]);
@@ -77,14 +78,20 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
77
78
  if (operationResult || operationError) {
78
79
  if (input === 'q' || key.escape || key.return) {
79
80
  console.clear();
80
- setOperationResult(null);
81
- setOperationError(null);
82
- setExecutingOperation(null);
83
- setOperationInput('');
84
- setLogsWrapMode(true);
85
- setLogsScroll(0);
86
- setExecScroll(0);
87
- setCopyStatus(null);
81
+ // If skipOperationsMenu is true, go back to parent instead of operations menu
82
+ if (skipOperationsMenu) {
83
+ onBack();
84
+ }
85
+ else {
86
+ setOperationResult(null);
87
+ setOperationError(null);
88
+ setExecutingOperation(null);
89
+ setOperationInput('');
90
+ setLogsWrapMode(true);
91
+ setLogsScroll(0);
92
+ setExecScroll(0);
93
+ setCopyStatus(null);
94
+ }
88
95
  }
89
96
  else if ((key.upArrow || input === 'k') && operationResult && typeof operationResult === 'object' && operationResult.__customRender === 'exec') {
90
97
  setExecScroll(Math.max(0, execScroll - 1));
@@ -295,15 +302,25 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
295
302
  const keyPath = pathModule.join(sshDir, `${devbox.id}.pem`);
296
303
  fsModule.writeFileSync(keyPath, sshKey.ssh_private_key, { mode: 0o600 });
297
304
  const sshUser = devbox.launch_parameters?.user_parameters?.username || 'user';
298
- const proxyCommand = 'openssl s_client -quiet -verify_quiet -servername %h -connect ssh.runloop.ai:443 2>/dev/null';
299
- global.__sshCommand = {
305
+ const env = process.env.RUNLOOP_ENV?.toLowerCase();
306
+ const sshHost = env === 'dev' ? 'ssh.runloop.pro' : 'ssh.runloop.ai';
307
+ const proxyCommand = `openssl s_client -quiet -verify_quiet -servername %h -connect ${sshHost}:443 2>/dev/null`;
308
+ const sshConfig = {
300
309
  keyPath,
301
310
  proxyCommand,
302
311
  sshUser,
303
312
  url: sshKey.url,
313
+ devboxId: devbox.id,
304
314
  devboxName: devbox.name || devbox.id
305
315
  };
306
- exit();
316
+ // Notify parent that SSH is requested
317
+ if (onSSHRequest) {
318
+ onSSHRequest(sshConfig);
319
+ exit();
320
+ }
321
+ else {
322
+ setOperationError(new Error('SSH session handler not configured'));
323
+ }
307
324
  break;
308
325
  case 'logs':
309
326
  const logsResult = await client.devboxes.logs.list(devbox.id);
@@ -452,9 +469,13 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
452
469
  ? '8080'
453
470
  : 'my-snapshot' }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", dimColor: true, children: "Press [Enter] to execute \u2022 [q or esc] Cancel" }) })] })] }));
454
471
  }
455
- // Operations selection mode
456
- return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "cyan", bold: true, children: [figures.play, " Operations"] }), _jsx(Box, { flexDirection: "column", children: operations.map((op, index) => {
457
- const isSelected = index === selectedOperation;
458
- return (_jsxs(Box, { children: [_jsxs(Text, { color: isSelected ? 'cyan' : 'gray', children: [isSelected ? figures.pointer : ' ', " "] }), _jsxs(Text, { color: isSelected ? op.color : 'gray', bold: isSelected, children: [op.icon, " ", op.label] }), _jsxs(Text, { color: "gray", dimColor: true, children: [" [", op.shortcut, "]"] })] }, op.key));
459
- }) })] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "gray", dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022 [Enter] Select \u2022 [q] Back"] }) })] }));
472
+ // Operations selection mode - only show if not skipping
473
+ if (!skipOperationsMenu || !executingOperation) {
474
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "cyan", bold: true, children: [figures.play, " Operations"] }), _jsx(Box, { flexDirection: "column", children: operations.map((op, index) => {
475
+ const isSelected = index === selectedOperation;
476
+ return (_jsxs(Box, { children: [_jsxs(Text, { color: isSelected ? 'cyan' : 'gray', children: [isSelected ? figures.pointer : ' ', " "] }), _jsxs(Text, { color: isSelected ? op.color : 'gray', bold: isSelected, children: [op.icon, " ", op.label] }), _jsxs(Text, { color: "gray", dimColor: true, children: [" [", op.shortcut, "]"] })] }, op.key));
477
+ }) })] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "gray", dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022 [Enter] Select \u2022 [q] Back"] }) })] }));
478
+ }
479
+ // If skipOperationsMenu is true and executingOperation is set, show loading while it executes
480
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems }), _jsx(SpinnerComponent, { message: "Loading..." })] }));
460
481
  };
@@ -7,6 +7,7 @@ import { StatusBadge } from './StatusBadge.js';
7
7
  import { MetadataDisplay } from './MetadataDisplay.js';
8
8
  import { Breadcrumb } from './Breadcrumb.js';
9
9
  import { DevboxActionsMenu } from './DevboxActionsMenu.js';
10
+ import { getDevboxUrl } from '../utils/url.js';
10
11
  // Format time ago in a succinct way
11
12
  const formatTimeAgo = (timestamp) => {
12
13
  const seconds = Math.floor((Date.now() - timestamp) / 1000);
@@ -27,7 +28,7 @@ const formatTimeAgo = (timestamp) => {
27
28
  const years = Math.floor(months / 12);
28
29
  return `${years}y ago`;
29
30
  };
30
- export const DevboxDetailPage = ({ devbox: initialDevbox, onBack }) => {
31
+ export const DevboxDetailPage = ({ devbox: initialDevbox, onBack, onSSHRequest }) => {
31
32
  const { stdout } = useStdout();
32
33
  const [showDetailedInfo, setShowDetailedInfo] = React.useState(false);
33
34
  const [detailScroll, setDetailScroll] = React.useState(0);
@@ -125,7 +126,7 @@ export const DevboxDetailPage = ({ devbox: initialDevbox, onBack }) => {
125
126
  }
126
127
  if (input === 'o') {
127
128
  // Open in browser
128
- const url = `https://platform.runloop.ai/devboxes/${selectedDevbox.id}`;
129
+ const url = getDevboxUrl(selectedDevbox.id);
129
130
  const openBrowser = async () => {
130
131
  const { exec } = await import('child_process');
131
132
  const platform = process.platform;
@@ -275,14 +276,14 @@ export const DevboxDetailPage = ({ devbox: initialDevbox, onBack }) => {
275
276
  };
276
277
  // Actions view - show the DevboxActionsMenu when an action is triggered
277
278
  if (showActions) {
279
+ const selectedOp = operations[selectedOperation];
278
280
  return (_jsx(DevboxActionsMenu, { devbox: selectedDevbox, onBack: () => {
279
281
  setShowActions(false);
280
282
  setSelectedOperation(0);
281
283
  }, breadcrumbItems: [
282
284
  { label: 'Devboxes' },
283
- { label: selectedDevbox.name || selectedDevbox.id },
284
- { label: 'Actions', active: true }
285
- ] }));
285
+ { label: selectedDevbox.name || selectedDevbox.id }
286
+ ], initialOperation: selectedOp?.key, skipOperationsMenu: true, onSSHRequest: onSSHRequest }));
286
287
  }
287
288
  // Detailed info mode - full screen
288
289
  if (showDetailedInfo) {
@@ -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
+ });
@@ -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.2",
3
+ "version": "0.0.3",
4
4
  "description": "Beautiful CLI for Runloop devbox management",
5
5
  "type": "module",
6
6
  "bin": {