@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 +59 -13
- package/dist/commands/auth.js +2 -1
- package/dist/commands/blueprint/list.js +18 -7
- package/dist/commands/devbox/list.js +88 -41
- package/dist/commands/menu.js +70 -0
- package/dist/commands/snapshot/list.js +18 -9
- package/dist/components/Banner.js +3 -6
- package/dist/components/Breadcrumb.js +3 -4
- package/dist/components/DevboxActionsMenu.js +40 -19
- package/dist/components/DevboxDetailPage.js +6 -5
- package/dist/components/MainMenu.js +71 -0
- package/dist/utils/CommandExecutor.js +12 -0
- package/dist/utils/client.js +17 -0
- package/dist/utils/interactiveCommand.js +14 -0
- package/dist/utils/sshSession.js +25 -0
- package/dist/utils/url.js +39 -0
- package/package.json +1 -1
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
|
|
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
|
+
})();
|
package/dist/commands/auth.js
CHANGED
|
@@ -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:
|
|
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 =
|
|
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 =
|
|
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 (
|
|
254
|
-
|
|
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: [' ', "[
|
|
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 =
|
|
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
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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,
|
|
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
|
-
//
|
|
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
|
-
] }),
|
|
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]" })] })),
|
|
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 [
|
|
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
|
-
|
|
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
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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 (
|
|
93
|
-
|
|
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(
|
|
109
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
103
110
|
{ label: 'Snapshots', active: !devboxId },
|
|
104
111
|
...(devboxId ? [{ label: `Devbox: ${devboxId}`, active: true }] : []),
|
|
105
|
-
] }),
|
|
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: [' ', "[
|
|
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
|
|
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",
|
|
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
|
|
7
|
-
const isDevEnvironment =
|
|
8
|
-
return (
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
299
|
-
|
|
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
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
package/dist/utils/client.js
CHANGED
|
@@ -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
|
+
}
|