@runloop/rl-cli 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -0
- package/dist/cli.js +73 -60
- package/dist/commands/auth.js +0 -1
- package/dist/commands/blueprint/create.js +31 -83
- package/dist/commands/blueprint/get.js +29 -34
- package/dist/commands/blueprint/list.js +215 -213
- package/dist/commands/blueprint/logs.js +133 -37
- package/dist/commands/blueprint/preview.js +42 -38
- package/dist/commands/config.js +117 -0
- package/dist/commands/devbox/create.js +120 -40
- package/dist/commands/devbox/delete.js +17 -33
- package/dist/commands/devbox/download.js +29 -43
- package/dist/commands/devbox/exec.js +22 -39
- package/dist/commands/devbox/execAsync.js +20 -37
- package/dist/commands/devbox/get.js +13 -35
- package/dist/commands/devbox/getAsync.js +12 -34
- package/dist/commands/devbox/list.js +241 -402
- package/dist/commands/devbox/logs.js +20 -38
- package/dist/commands/devbox/read.js +29 -43
- package/dist/commands/devbox/resume.js +13 -35
- package/dist/commands/devbox/rsync.js +26 -78
- package/dist/commands/devbox/scp.js +25 -79
- package/dist/commands/devbox/sendStdin.js +41 -0
- package/dist/commands/devbox/shutdown.js +13 -35
- package/dist/commands/devbox/ssh.js +45 -78
- package/dist/commands/devbox/suspend.js +13 -35
- package/dist/commands/devbox/tunnel.js +36 -88
- package/dist/commands/devbox/upload.js +28 -36
- package/dist/commands/devbox/write.js +29 -44
- package/dist/commands/mcp-install.js +4 -3
- package/dist/commands/menu.js +24 -66
- package/dist/commands/object/delete.js +12 -34
- package/dist/commands/object/download.js +26 -74
- package/dist/commands/object/get.js +12 -34
- package/dist/commands/object/list.js +15 -93
- package/dist/commands/object/upload.js +35 -96
- package/dist/commands/snapshot/create.js +23 -39
- package/dist/commands/snapshot/delete.js +17 -33
- package/dist/commands/snapshot/get.js +16 -0
- package/dist/commands/snapshot/list.js +309 -80
- package/dist/commands/snapshot/status.js +12 -34
- package/dist/components/ActionsPopup.js +63 -39
- package/dist/components/Breadcrumb.js +10 -52
- package/dist/components/DevboxActionsMenu.js +182 -110
- package/dist/components/DevboxCreatePage.js +12 -7
- package/dist/components/DevboxDetailPage.js +76 -28
- package/dist/components/ErrorBoundary.js +29 -0
- package/dist/components/ErrorMessage.js +10 -2
- package/dist/components/Header.js +12 -4
- package/dist/components/InteractiveSpawn.js +94 -0
- package/dist/components/MainMenu.js +36 -32
- package/dist/components/MetadataDisplay.js +4 -4
- package/dist/components/OperationsMenu.js +1 -1
- package/dist/components/ResourceActionsMenu.js +4 -4
- package/dist/components/ResourceListView.js +46 -34
- package/dist/components/Spinner.js +7 -2
- package/dist/components/StatusBadge.js +1 -1
- package/dist/components/SuccessMessage.js +12 -2
- package/dist/components/Table.js +16 -6
- package/dist/hooks/useCursorPagination.js +125 -85
- package/dist/hooks/useExitOnCtrlC.js +14 -0
- package/dist/hooks/useViewportHeight.js +47 -0
- package/dist/mcp/server.js +65 -6
- package/dist/router/Router.js +68 -0
- package/dist/router/types.js +1 -0
- package/dist/screens/BlueprintListScreen.js +7 -0
- package/dist/screens/DevboxActionsScreen.js +25 -0
- package/dist/screens/DevboxCreateScreen.js +11 -0
- package/dist/screens/DevboxDetailScreen.js +60 -0
- package/dist/screens/DevboxListScreen.js +23 -0
- package/dist/screens/LogsSessionScreen.js +49 -0
- package/dist/screens/MenuScreen.js +23 -0
- package/dist/screens/SSHSessionScreen.js +55 -0
- package/dist/screens/SnapshotListScreen.js +7 -0
- package/dist/services/blueprintService.js +105 -0
- package/dist/services/devboxService.js +215 -0
- package/dist/services/snapshotService.js +81 -0
- package/dist/store/blueprintStore.js +89 -0
- package/dist/store/devboxStore.js +105 -0
- package/dist/store/index.js +7 -0
- package/dist/store/navigationStore.js +101 -0
- package/dist/store/snapshotStore.js +87 -0
- package/dist/utils/CommandExecutor.js +53 -24
- package/dist/utils/client.js +0 -2
- package/dist/utils/config.js +20 -90
- package/dist/utils/interactiveCommand.js +3 -2
- package/dist/utils/logFormatter.js +162 -0
- package/dist/utils/memoryMonitor.js +85 -0
- package/dist/utils/output.js +150 -59
- package/dist/utils/screen.js +23 -0
- package/dist/utils/ssh.js +3 -1
- package/dist/utils/sshSession.js +5 -29
- package/dist/utils/terminalDetection.js +97 -0
- package/dist/utils/terminalSync.js +39 -0
- package/dist/utils/theme.js +147 -13
- package/package.json +16 -13
|
@@ -1,21 +1,24 @@
|
|
|
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
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
4
|
import TextInput from "ink-text-input";
|
|
5
5
|
import figures from "figures";
|
|
6
|
-
import { getClient } from "../utils/client.js";
|
|
7
6
|
import { Header } from "./Header.js";
|
|
8
7
|
import { SpinnerComponent } from "./Spinner.js";
|
|
9
8
|
import { ErrorMessage } from "./ErrorMessage.js";
|
|
10
9
|
import { SuccessMessage } from "./SuccessMessage.js";
|
|
11
10
|
import { Breadcrumb } from "./Breadcrumb.js";
|
|
12
11
|
import { colors } from "../utils/theme.js";
|
|
12
|
+
import { useViewportHeight } from "../hooks/useViewportHeight.js";
|
|
13
|
+
import { useNavigation } from "../store/navigationStore.js";
|
|
14
|
+
import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
|
|
15
|
+
import { getDevboxLogs, execCommand, suspendDevbox, resumeDevbox, shutdownDevbox, uploadFile, createSnapshot as createDevboxSnapshot, createTunnel, createSSHKey, } from "../services/devboxService.js";
|
|
16
|
+
import { parseLogEntry } from "../utils/logFormatter.js";
|
|
13
17
|
export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
|
|
14
18
|
{ label: "Devboxes" },
|
|
15
19
|
{ label: devbox.name || devbox.id, active: true },
|
|
16
|
-
], initialOperation, initialOperationIndex = 0, skipOperationsMenu = false,
|
|
17
|
-
const {
|
|
18
|
-
const { stdout } = useStdout();
|
|
20
|
+
], initialOperation, initialOperationIndex = 0, skipOperationsMenu = false, }) => {
|
|
21
|
+
const { navigate, currentScreen, params } = useNavigation();
|
|
19
22
|
const [loading, setLoading] = React.useState(false);
|
|
20
23
|
const [selectedOperation, setSelectedOperation] = React.useState(initialOperationIndex);
|
|
21
24
|
const [executingOperation, setExecutingOperation] = React.useState(initialOperation || null);
|
|
@@ -26,6 +29,44 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
|
|
|
26
29
|
const [logsScroll, setLogsScroll] = React.useState(0);
|
|
27
30
|
const [execScroll, setExecScroll] = React.useState(0);
|
|
28
31
|
const [copyStatus, setCopyStatus] = React.useState(null);
|
|
32
|
+
// Calculate viewport for exec output:
|
|
33
|
+
// - Breadcrumb (3 lines + marginBottom): 4 lines
|
|
34
|
+
// - Command header (border + 2 content + border + marginBottom): 5 lines
|
|
35
|
+
// - Output box borders: 2 lines
|
|
36
|
+
// - Stats bar (marginTop + content): 2 lines
|
|
37
|
+
// - Help bar (marginTop + content): 2 lines
|
|
38
|
+
// - Safety buffer: 1 line
|
|
39
|
+
// Total: 16 lines
|
|
40
|
+
const execViewport = useViewportHeight({ overhead: 16, minHeight: 10 });
|
|
41
|
+
// Calculate viewport for logs output:
|
|
42
|
+
// - Breadcrumb (3 lines + marginBottom): 4 lines
|
|
43
|
+
// - Log box borders: 2 lines
|
|
44
|
+
// - Stats bar (marginTop + content): 2 lines
|
|
45
|
+
// - Help bar (marginTop + content): 2 lines
|
|
46
|
+
// - Safety buffer: 1 line
|
|
47
|
+
// Total: 11 lines
|
|
48
|
+
const logsViewport = useViewportHeight({ overhead: 11, minHeight: 10 });
|
|
49
|
+
// CRITICAL: Aggressive memory cleanup to prevent heap exhaustion
|
|
50
|
+
React.useEffect(() => {
|
|
51
|
+
// Clear large data immediately when results are shown to free memory faster
|
|
52
|
+
if (operationResult || operationError) {
|
|
53
|
+
const timer = setTimeout(() => {
|
|
54
|
+
// After 100ms, if user hasn't acted, start aggressive cleanup
|
|
55
|
+
// This helps with memory without disrupting UX
|
|
56
|
+
}, 100);
|
|
57
|
+
return () => clearTimeout(timer);
|
|
58
|
+
}
|
|
59
|
+
}, [operationResult, operationError]);
|
|
60
|
+
// Cleanup on unmount
|
|
61
|
+
React.useEffect(() => {
|
|
62
|
+
return () => {
|
|
63
|
+
// Aggressively null out all large data structures
|
|
64
|
+
setOperationResult(null);
|
|
65
|
+
setOperationError(null);
|
|
66
|
+
setOperationInput("");
|
|
67
|
+
setLoading(false);
|
|
68
|
+
};
|
|
69
|
+
}, []);
|
|
29
70
|
const allOperations = [
|
|
30
71
|
{
|
|
31
72
|
key: "logs",
|
|
@@ -123,6 +164,8 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
|
|
|
123
164
|
executeOperation();
|
|
124
165
|
}
|
|
125
166
|
}, [executingOperation]);
|
|
167
|
+
// Handle Ctrl+C to exit
|
|
168
|
+
useExitOnCtrlC();
|
|
126
169
|
useInput((input, key) => {
|
|
127
170
|
// Handle operation input mode
|
|
128
171
|
if (executingOperation && !operationResult && !operationError) {
|
|
@@ -130,7 +173,6 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
|
|
|
130
173
|
executeOperation();
|
|
131
174
|
}
|
|
132
175
|
else if (input === "q" || key.escape) {
|
|
133
|
-
console.clear();
|
|
134
176
|
setExecutingOperation(null);
|
|
135
177
|
setOperationInput("");
|
|
136
178
|
}
|
|
@@ -139,20 +181,21 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
|
|
|
139
181
|
// Handle operation result display
|
|
140
182
|
if (operationResult || operationError) {
|
|
141
183
|
if (input === "q" || key.escape || key.return) {
|
|
142
|
-
|
|
184
|
+
// Clear large data structures immediately to prevent memory leaks
|
|
185
|
+
setOperationResult(null);
|
|
186
|
+
setOperationError(null);
|
|
187
|
+
setOperationInput("");
|
|
188
|
+
setLogsWrapMode(true);
|
|
189
|
+
setLogsScroll(0);
|
|
190
|
+
setExecScroll(0);
|
|
191
|
+
setCopyStatus(null);
|
|
143
192
|
// If skipOperationsMenu is true, go back to parent instead of operations menu
|
|
144
193
|
if (skipOperationsMenu) {
|
|
194
|
+
setExecutingOperation(null);
|
|
145
195
|
onBack();
|
|
146
196
|
}
|
|
147
197
|
else {
|
|
148
|
-
setOperationResult(null);
|
|
149
|
-
setOperationError(null);
|
|
150
198
|
setExecutingOperation(null);
|
|
151
|
-
setOperationInput("");
|
|
152
|
-
setLogsWrapMode(true);
|
|
153
|
-
setLogsScroll(0);
|
|
154
|
-
setExecScroll(0);
|
|
155
|
-
setCopyStatus(null);
|
|
156
199
|
}
|
|
157
200
|
}
|
|
158
201
|
else if ((key.upArrow || input === "k") &&
|
|
@@ -193,9 +236,7 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
|
|
|
193
236
|
...(operationResult.stdout || "").split("\n"),
|
|
194
237
|
...(operationResult.stderr || "").split("\n"),
|
|
195
238
|
];
|
|
196
|
-
const
|
|
197
|
-
const viewportHeight = Math.max(10, terminalHeight - 15);
|
|
198
|
-
const maxScroll = Math.max(0, lines.length - viewportHeight);
|
|
239
|
+
const maxScroll = Math.max(0, lines.length - execViewport.viewportHeight);
|
|
199
240
|
setExecScroll(maxScroll);
|
|
200
241
|
}
|
|
201
242
|
else if (input === "c" &&
|
|
@@ -277,9 +318,7 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
|
|
|
277
318
|
typeof operationResult === "object" &&
|
|
278
319
|
operationResult.__customRender === "logs") {
|
|
279
320
|
const logs = operationResult.__logs || [];
|
|
280
|
-
const
|
|
281
|
-
const viewportHeight = Math.max(10, terminalHeight - 10);
|
|
282
|
-
const maxScroll = Math.max(0, logs.length - viewportHeight);
|
|
321
|
+
const maxScroll = Math.max(0, logs.length - logsViewport.viewportHeight);
|
|
283
322
|
setLogsScroll(maxScroll);
|
|
284
323
|
}
|
|
285
324
|
else if (input === "w" &&
|
|
@@ -292,19 +331,15 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
|
|
|
292
331
|
operationResult &&
|
|
293
332
|
typeof operationResult === "object" &&
|
|
294
333
|
operationResult.__customRender === "logs") {
|
|
295
|
-
// Copy logs to clipboard
|
|
334
|
+
// Copy logs to clipboard using shared formatter
|
|
296
335
|
const logs = operationResult.__logs || [];
|
|
297
336
|
const logsText = logs
|
|
298
337
|
.map((log) => {
|
|
299
|
-
const
|
|
300
|
-
const
|
|
301
|
-
const
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
const exitCode = log.exit_code !== null && log.exit_code !== undefined
|
|
305
|
-
? `(${log.exit_code}) `
|
|
306
|
-
: "";
|
|
307
|
-
return `${time} ${level}/${source} ${exitCode}${cmd}${message}`;
|
|
338
|
+
const parts = parseLogEntry(log);
|
|
339
|
+
const cmd = parts.cmd ? `$ ${parts.cmd} ` : "";
|
|
340
|
+
const exitCode = parts.exitCode !== null ? `exit=${parts.exitCode} ` : "";
|
|
341
|
+
const shell = parts.shellName ? `(${parts.shellName}) ` : "";
|
|
342
|
+
return `${parts.timestamp} ${parts.level} [${parts.source}] ${shell}${cmd}${parts.message} ${exitCode}`.trim();
|
|
308
343
|
})
|
|
309
344
|
.join("\n");
|
|
310
345
|
const copyToClipboard = async (text) => {
|
|
@@ -348,9 +383,14 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
|
|
|
348
383
|
}
|
|
349
384
|
// Operations selection mode
|
|
350
385
|
if (input === "q" || key.escape) {
|
|
351
|
-
|
|
352
|
-
|
|
386
|
+
// Clear all state before going back to free memory
|
|
387
|
+
setOperationResult(null);
|
|
388
|
+
setOperationError(null);
|
|
389
|
+
setOperationInput("");
|
|
390
|
+
setExecutingOperation(null);
|
|
353
391
|
setSelectedOperation(0);
|
|
392
|
+
setLoading(false);
|
|
393
|
+
onBack();
|
|
354
394
|
}
|
|
355
395
|
else if (key.upArrow && selectedOperation > 0) {
|
|
356
396
|
setSelectedOperation(selectedOperation - 1);
|
|
@@ -359,7 +399,6 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
|
|
|
359
399
|
setSelectedOperation(selectedOperation + 1);
|
|
360
400
|
}
|
|
361
401
|
else if (key.return) {
|
|
362
|
-
console.clear();
|
|
363
402
|
const op = operations[selectedOperation].key;
|
|
364
403
|
setExecutingOperation(op);
|
|
365
404
|
}
|
|
@@ -367,20 +406,17 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
|
|
|
367
406
|
// Check if input matches any operation shortcut
|
|
368
407
|
const matchedOp = operations.find((op) => op.shortcut === input);
|
|
369
408
|
if (matchedOp) {
|
|
370
|
-
console.clear();
|
|
371
409
|
setExecutingOperation(matchedOp.key);
|
|
372
410
|
}
|
|
373
411
|
}
|
|
374
412
|
});
|
|
375
413
|
const executeOperation = async () => {
|
|
376
|
-
const client = getClient();
|
|
377
414
|
try {
|
|
378
415
|
setLoading(true);
|
|
379
416
|
switch (executingOperation) {
|
|
380
417
|
case "exec":
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
});
|
|
418
|
+
// Use service layer (already truncates output to prevent Yoga crashes)
|
|
419
|
+
const execResult = await execCommand(devbox.id, operationInput);
|
|
384
420
|
// Format exec result for custom rendering
|
|
385
421
|
const formattedExecResult = {
|
|
386
422
|
__customRender: "exec",
|
|
@@ -392,23 +428,19 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
|
|
|
392
428
|
setOperationResult(formattedExecResult);
|
|
393
429
|
break;
|
|
394
430
|
case "upload":
|
|
395
|
-
|
|
396
|
-
const fileStream = fs.createReadStream(operationInput);
|
|
431
|
+
// Use service layer
|
|
397
432
|
const filename = operationInput.split("/").pop() || "file";
|
|
398
|
-
await
|
|
399
|
-
path: filename,
|
|
400
|
-
file: fileStream,
|
|
401
|
-
});
|
|
433
|
+
await uploadFile(devbox.id, operationInput, filename);
|
|
402
434
|
setOperationResult(`File ${filename} uploaded successfully`);
|
|
403
435
|
break;
|
|
404
436
|
case "snapshot":
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
});
|
|
437
|
+
// Use service layer
|
|
438
|
+
const snapshot = await createDevboxSnapshot(devbox.id, operationInput || `snapshot-${Date.now()}`);
|
|
408
439
|
setOperationResult(`Snapshot created: ${snapshot.id}`);
|
|
409
440
|
break;
|
|
410
441
|
case "ssh":
|
|
411
|
-
|
|
442
|
+
// Use service layer
|
|
443
|
+
const sshKey = await createSSHKey(devbox.id);
|
|
412
444
|
const fsModule = await import("fs");
|
|
413
445
|
const pathModule = await import("path");
|
|
414
446
|
const osModule = await import("os");
|
|
@@ -421,45 +453,45 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
|
|
|
421
453
|
const sshUser = devbox.launch_parameters?.user_parameters?.username || "user";
|
|
422
454
|
const env = process.env.RUNLOOP_ENV?.toLowerCase();
|
|
423
455
|
const sshHost = env === "dev" ? "ssh.runloop.pro" : "ssh.runloop.ai";
|
|
424
|
-
|
|
425
|
-
|
|
456
|
+
// macOS openssl doesn't support -verify_quiet, use compatible flags
|
|
457
|
+
// servername should be %h (target hostname) - SSH will replace %h with the actual hostname from the SSH command
|
|
458
|
+
// This matches the reference implementation where servername is the target hostname
|
|
459
|
+
const proxyCommand = `openssl s_client -quiet -servername %h -connect ${sshHost}:443 2>/dev/null`;
|
|
460
|
+
// Navigate to SSH session screen
|
|
461
|
+
navigate("ssh-session", {
|
|
426
462
|
keyPath,
|
|
427
463
|
proxyCommand,
|
|
428
464
|
sshUser,
|
|
429
465
|
url: sshKey.url,
|
|
430
466
|
devboxId: devbox.id,
|
|
431
467
|
devboxName: devbox.name || devbox.id,
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
onSSHRequest(sshConfig);
|
|
436
|
-
exit();
|
|
437
|
-
}
|
|
438
|
-
else {
|
|
439
|
-
setOperationError(new Error("SSH session handler not configured"));
|
|
440
|
-
}
|
|
468
|
+
returnScreen: currentScreen,
|
|
469
|
+
returnParams: params,
|
|
470
|
+
});
|
|
441
471
|
break;
|
|
442
472
|
case "logs":
|
|
443
|
-
|
|
444
|
-
|
|
473
|
+
// Use service layer (already truncates and escapes log messages)
|
|
474
|
+
const logs = await getDevboxLogs(devbox.id);
|
|
475
|
+
if (logs.length === 0) {
|
|
445
476
|
setOperationResult("No logs available for this devbox.");
|
|
446
477
|
}
|
|
447
478
|
else {
|
|
448
|
-
logsResult
|
|
449
|
-
|
|
450
|
-
|
|
479
|
+
const logsResult = {
|
|
480
|
+
__customRender: "logs",
|
|
481
|
+
__logs: logs,
|
|
482
|
+
__totalCount: logs.length,
|
|
483
|
+
};
|
|
451
484
|
setOperationResult(logsResult);
|
|
452
485
|
}
|
|
453
486
|
break;
|
|
454
487
|
case "tunnel":
|
|
488
|
+
// Use service layer
|
|
455
489
|
const port = parseInt(operationInput);
|
|
456
490
|
if (isNaN(port) || port < 1 || port > 65535) {
|
|
457
491
|
setOperationError(new Error("Invalid port number. Please enter a port between 1 and 65535."));
|
|
458
492
|
}
|
|
459
493
|
else {
|
|
460
|
-
const tunnel = await
|
|
461
|
-
port,
|
|
462
|
-
});
|
|
494
|
+
const tunnel = await createTunnel(devbox.id, port);
|
|
463
495
|
setOperationResult(`Tunnel created!\n\n` +
|
|
464
496
|
`Local Port: ${port}\n` +
|
|
465
497
|
`Public URL: ${tunnel.url}\n\n` +
|
|
@@ -467,15 +499,18 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
|
|
|
467
499
|
}
|
|
468
500
|
break;
|
|
469
501
|
case "suspend":
|
|
470
|
-
|
|
502
|
+
// Use service layer
|
|
503
|
+
await suspendDevbox(devbox.id);
|
|
471
504
|
setOperationResult(`Devbox ${devbox.id} suspended successfully`);
|
|
472
505
|
break;
|
|
473
506
|
case "resume":
|
|
474
|
-
|
|
507
|
+
// Use service layer
|
|
508
|
+
await resumeDevbox(devbox.id);
|
|
475
509
|
setOperationResult(`Devbox ${devbox.id} resumed successfully`);
|
|
476
510
|
break;
|
|
477
511
|
case "delete":
|
|
478
|
-
|
|
512
|
+
// Use service layer
|
|
513
|
+
await shutdownDevbox(devbox.id);
|
|
479
514
|
setOperationResult(`Devbox ${devbox.id} shut down successfully`);
|
|
480
515
|
break;
|
|
481
516
|
}
|
|
@@ -501,8 +536,7 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
|
|
|
501
536
|
const stdoutLines = stdout ? stdout.split("\n") : [];
|
|
502
537
|
const stderrLines = stderr ? stderr.split("\n") : [];
|
|
503
538
|
const allLines = [...stdoutLines, ...stderrLines].filter((line) => line !== "");
|
|
504
|
-
const
|
|
505
|
-
const viewportHeight = Math.max(10, terminalHeight - 15);
|
|
539
|
+
const viewportHeight = execViewport.viewportHeight;
|
|
506
540
|
const maxScroll = Math.max(0, allLines.length - viewportHeight);
|
|
507
541
|
const actualScroll = Math.min(execScroll, maxScroll);
|
|
508
542
|
const visibleLines = allLines.slice(actualScroll, actualScroll + viewportHeight);
|
|
@@ -512,12 +546,14 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
|
|
|
512
546
|
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
513
547
|
...breadcrumbItems,
|
|
514
548
|
{ label: "Execute Command", active: true },
|
|
515
|
-
] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.primary, paddingX: 1, marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.play, " Command:"] }), _jsx(Text, { children: " " }), _jsx(Text, { color: colors.text, children: command
|
|
549
|
+
] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.primary, paddingX: 1, marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.play, " Command:"] }), _jsx(Text, { children: " " }), _jsx(Text, { color: colors.text, children: command.length > 500
|
|
550
|
+
? command.substring(0, 500) + "..."
|
|
551
|
+
: command })] }), _jsxs(Box, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Exit Code:", " "] }), _jsx(Text, { color: exitCodeColor, bold: true, children: exitCode })] })] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.border, paddingX: 1, children: [allLines.length === 0 && (_jsx(Text, { color: colors.textDim, dimColor: true, children: "No output" })), visibleLines.map((line, index) => {
|
|
516
552
|
const actualIndex = actualScroll + index;
|
|
517
553
|
const isStderr = actualIndex >= stdoutLines.length;
|
|
518
554
|
const lineColor = isStderr ? colors.error : colors.text;
|
|
519
555
|
return (_jsx(Box, { children: _jsx(Text, { color: lineColor, children: line }) }, index));
|
|
520
|
-
})
|
|
556
|
+
})] }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", allLines.length] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "lines"] }), allLines.length > 0 && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Viewing ", actualScroll + 1, "-", Math.min(actualScroll + viewportHeight, allLines.length), " of", " ", allLines.length] }), hasLess && (_jsxs(Text, { color: colors.primary, children: [" ", figures.arrowUp] })), hasMore && (_jsxs(Text, { color: colors.primary, children: [" ", figures.arrowDown] }))] })), stdout && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.success, dimColor: true, children: ["stdout: ", stdoutLines.length, " lines"] })] })), stderr && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.error, dimColor: true, children: ["stderr: ", stderrLines.length, " lines"] })] })), copyStatus && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsx(Text, { color: colors.success, bold: true, children: copyStatus })] }))] }), _jsx(Box, { marginTop: 1, paddingX: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022 [g] Top \u2022 [G] Bottom \u2022 [c] Copy \u2022 [Enter], [q], or [esc] Back"] }) })] }));
|
|
521
557
|
}
|
|
522
558
|
// Check for custom logs rendering
|
|
523
559
|
if (operationResult &&
|
|
@@ -525,45 +561,76 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
|
|
|
525
561
|
operationResult.__customRender === "logs") {
|
|
526
562
|
const logs = operationResult.__logs || [];
|
|
527
563
|
const totalCount = operationResult.__totalCount || 0;
|
|
528
|
-
const
|
|
529
|
-
const terminalWidth =
|
|
530
|
-
const viewportHeight = Math.max(10, terminalHeight - 10);
|
|
564
|
+
const viewportHeight = logsViewport.viewportHeight;
|
|
565
|
+
const terminalWidth = logsViewport.terminalWidth;
|
|
531
566
|
const maxScroll = Math.max(0, logs.length - viewportHeight);
|
|
532
567
|
const actualScroll = Math.min(logsScroll, maxScroll);
|
|
533
568
|
const visibleLogs = logs.slice(actualScroll, actualScroll + viewportHeight);
|
|
534
569
|
const hasMore = actualScroll + viewportHeight < logs.length;
|
|
535
570
|
const hasLess = actualScroll > 0;
|
|
536
|
-
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [...breadcrumbItems, { label: "Logs", active: true }] }),
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
571
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [...breadcrumbItems, { label: "Logs", active: true }] }), _jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.border, paddingX: 1, children: visibleLogs.map((log, index) => {
|
|
572
|
+
const parts = parseLogEntry(log);
|
|
573
|
+
// Sanitize message: escape special chars to prevent layout breaks
|
|
574
|
+
const escapedMessage = parts.message
|
|
575
|
+
.replace(/\r\n/g, "\\n")
|
|
576
|
+
.replace(/\n/g, "\\n")
|
|
577
|
+
.replace(/\r/g, "\\r")
|
|
578
|
+
.replace(/\t/g, "\\t");
|
|
579
|
+
// Limit message length to prevent Yoga layout engine errors
|
|
580
|
+
const MAX_MESSAGE_LENGTH = 1000;
|
|
581
|
+
const fullMessage = escapedMessage.length > MAX_MESSAGE_LENGTH
|
|
582
|
+
? escapedMessage.substring(0, MAX_MESSAGE_LENGTH) + "..."
|
|
583
|
+
: escapedMessage;
|
|
584
|
+
const cmd = parts.cmd
|
|
585
|
+
? `$ ${parts.cmd.substring(0, 40)}${parts.cmd.length > 40 ? "..." : ""} `
|
|
586
|
+
: "";
|
|
587
|
+
const exitCode = parts.exitCode !== null ? `exit=${parts.exitCode} ` : "";
|
|
588
|
+
// Map color names to theme colors
|
|
589
|
+
const levelColorMap = {
|
|
590
|
+
red: colors.error,
|
|
591
|
+
yellow: colors.warning,
|
|
592
|
+
blue: colors.primary,
|
|
593
|
+
gray: colors.textDim,
|
|
594
|
+
};
|
|
595
|
+
const sourceColorMap = {
|
|
596
|
+
magenta: "#d33682",
|
|
597
|
+
cyan: colors.info,
|
|
598
|
+
green: colors.success,
|
|
599
|
+
yellow: colors.warning,
|
|
600
|
+
gray: colors.textDim,
|
|
601
|
+
white: colors.text,
|
|
602
|
+
};
|
|
603
|
+
const levelColor = levelColorMap[parts.levelColor] || colors.textDim;
|
|
604
|
+
const sourceColor = sourceColorMap[parts.sourceColor] || colors.textDim;
|
|
605
|
+
if (logsWrapMode) {
|
|
606
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: colors.textDim, dimColor: true, children: parts.timestamp }), _jsx(Text, { children: " " }), _jsx(Text, { color: levelColor, bold: parts.levelColor === "red", children: parts.level }), _jsx(Text, { children: " " }), _jsxs(Text, { color: sourceColor, children: ["[", parts.source, "]"] }), _jsx(Text, { children: " " }), parts.shellName && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["(", parts.shellName, ")", " "] })), cmd && _jsx(Text, { color: colors.info, children: cmd }), _jsx(Text, { children: fullMessage }), exitCode && (_jsxs(Text, { color: parts.exitCode === 0 ? colors.success : colors.error, children: [" ", exitCode] }))] }, index));
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
// Calculate available width for message truncation
|
|
610
|
+
const timestampLen = parts.timestamp.length;
|
|
611
|
+
const levelLen = parts.level.length;
|
|
612
|
+
const sourceLen = parts.source.length + 2; // brackets
|
|
613
|
+
const shellLen = parts.shellName
|
|
614
|
+
? parts.shellName.length + 3
|
|
615
|
+
: 0;
|
|
616
|
+
const cmdLen = cmd.length;
|
|
617
|
+
const exitLen = exitCode.length;
|
|
618
|
+
const spacesLen = 5; // spaces between elements
|
|
619
|
+
const metadataWidth = timestampLen +
|
|
620
|
+
levelLen +
|
|
621
|
+
sourceLen +
|
|
622
|
+
shellLen +
|
|
623
|
+
cmdLen +
|
|
624
|
+
exitLen +
|
|
625
|
+
spacesLen;
|
|
626
|
+
const safeTerminalWidth = Math.max(80, terminalWidth);
|
|
627
|
+
const availableMessageWidth = Math.max(20, safeTerminalWidth - metadataWidth);
|
|
628
|
+
const truncatedMessage = fullMessage.length > availableMessageWidth
|
|
629
|
+
? fullMessage.substring(0, Math.max(1, availableMessageWidth - 3)) + "..."
|
|
630
|
+
: fullMessage;
|
|
631
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: colors.textDim, dimColor: true, children: parts.timestamp }), _jsx(Text, { children: " " }), _jsx(Text, { color: levelColor, bold: parts.levelColor === "red", children: parts.level }), _jsx(Text, { children: " " }), _jsxs(Text, { color: sourceColor, children: ["[", parts.source, "]"] }), _jsx(Text, { children: " " }), parts.shellName && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["(", parts.shellName, ")", " "] })), cmd && _jsx(Text, { color: colors.info, children: cmd }), _jsx(Text, { children: truncatedMessage }), exitCode && (_jsxs(Text, { color: parts.exitCode === 0 ? colors.success : colors.error, children: [" ", exitCode] }))] }, index));
|
|
632
|
+
}
|
|
633
|
+
}) }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", totalCount] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "total logs"] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Viewing ", actualScroll + 1, "-", Math.min(actualScroll + viewportHeight, logs.length), " of", " ", logs.length] }), hasLess && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowUp] }), hasMore && (_jsxs(Text, { color: colors.primary, children: [" ", figures.arrowDown] })), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsx(Text, { color: logsWrapMode ? colors.success : colors.textDim, bold: logsWrapMode, children: logsWrapMode ? "Wrap: ON" : "Wrap: OFF" }), copyStatus && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsx(Text, { color: colors.success, bold: true, children: copyStatus })] }))] }), _jsx(Box, { marginTop: 1, paddingX: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022 [g] Top \u2022 [G] Bottom \u2022 [w] Toggle Wrap \u2022 [c] Copy \u2022 [Enter], [q], or [esc] Back"] }) })] }));
|
|
567
634
|
}
|
|
568
635
|
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [...breadcrumbItems, { label: operationLabel, active: true }] }), _jsx(Header, { title: "Operation Result" }), operationResult && _jsx(SuccessMessage, { message: operationResult }), operationError && (_jsx(ErrorMessage, { message: "Operation failed", error: operationError })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.textDim, dimColor: true, children: "Press [Enter], [q], or [esc] to continue" }) })] }));
|
|
569
636
|
}
|
|
@@ -598,7 +665,12 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
|
|
|
598
665
|
snapshot: "Snapshot name (optional):",
|
|
599
666
|
tunnel: "Port number to expose:",
|
|
600
667
|
};
|
|
601
|
-
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [...breadcrumbItems, { label: operationLabel, active: true }] }), _jsx(Header, { title: operationLabel }), _jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: colors.primary, bold: true, children:
|
|
668
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [...breadcrumbItems, { label: operationLabel, active: true }] }), _jsx(Header, { title: operationLabel }), _jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: colors.primary, bold: true, children: (() => {
|
|
669
|
+
const name = devbox.name || devbox.id;
|
|
670
|
+
return name.length > 100
|
|
671
|
+
? name.substring(0, 100) + "..."
|
|
672
|
+
: name;
|
|
673
|
+
})() }) }), _jsx(Box, { children: _jsxs(Text, { color: colors.textDim, children: [prompts[executingOperation], " "] }) }), _jsx(Box, { marginTop: 1, children: _jsx(TextInput, { value: operationInput, onChange: setOperationInput, placeholder: executingOperation === "exec"
|
|
602
674
|
? "ls -la"
|
|
603
675
|
: executingOperation === "upload"
|
|
604
676
|
? "/path/to/file"
|
|
@@ -608,7 +680,7 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
|
|
|
608
680
|
}
|
|
609
681
|
// Operations selection mode - only show if not skipping
|
|
610
682
|
if (!skipOperationsMenu || !executingOperation) {
|
|
611
|
-
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems
|
|
683
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.play, " Operations"] }), _jsx(Box, { flexDirection: "column", children: operations.map((op, index) => {
|
|
612
684
|
const isSelected = index === selectedOperation;
|
|
613
685
|
return (_jsxs(Box, { children: [_jsxs(Text, { color: isSelected ? colors.primary : colors.textDim, children: [isSelected ? figures.pointer : " ", " "] }), _jsxs(Text, { color: isSelected ? op.color : colors.textDim, bold: isSelected, children: [op.icon, " ", op.label] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[", op.shortcut, "]"] })] }, op.key));
|
|
614
686
|
}) })] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022 [Enter] Select \u2022 [q] Back"] }) })] }));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx,
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import React from "react";
|
|
3
3
|
import { Box, Text, useInput } from "ink";
|
|
4
4
|
import TextInput from "ink-text-input";
|
|
@@ -10,6 +10,7 @@ import { SuccessMessage } from "./SuccessMessage.js";
|
|
|
10
10
|
import { Breadcrumb } from "./Breadcrumb.js";
|
|
11
11
|
import { MetadataDisplay } from "./MetadataDisplay.js";
|
|
12
12
|
import { colors } from "../utils/theme.js";
|
|
13
|
+
import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
|
|
13
14
|
export const DevboxCreatePage = ({ onBack, onCreate, initialBlueprintId, }) => {
|
|
14
15
|
const [currentField, setCurrentField] = React.useState("create");
|
|
15
16
|
const [formData, setFormData] = React.useState({
|
|
@@ -68,6 +69,8 @@ export const DevboxCreatePage = ({ onBack, onCreate, initialBlueprintId, }) => {
|
|
|
68
69
|
"CUSTOM_SIZE",
|
|
69
70
|
];
|
|
70
71
|
const currentFieldIndex = fields.findIndex((f) => f.key === currentField);
|
|
72
|
+
// Handle Ctrl+C to exit
|
|
73
|
+
useExitOnCtrlC();
|
|
71
74
|
useInput((input, key) => {
|
|
72
75
|
// Handle result screen
|
|
73
76
|
if (result) {
|
|
@@ -97,7 +100,6 @@ export const DevboxCreatePage = ({ onBack, onCreate, initialBlueprintId, }) => {
|
|
|
97
100
|
}
|
|
98
101
|
// Back to list
|
|
99
102
|
if (input === "q" || key.escape) {
|
|
100
|
-
console.clear();
|
|
101
103
|
onBack();
|
|
102
104
|
return;
|
|
103
105
|
}
|
|
@@ -241,10 +243,13 @@ export const DevboxCreatePage = ({ onBack, onCreate, initialBlueprintId, }) => {
|
|
|
241
243
|
});
|
|
242
244
|
}
|
|
243
245
|
else if (currentField === "resource_size") {
|
|
244
|
-
|
|
246
|
+
// Find current index, defaulting to 0 if not found (e.g., empty string)
|
|
247
|
+
const currentSize = formData.resource_size || "SMALL";
|
|
248
|
+
const currentIndex = resourceSizes.indexOf(currentSize);
|
|
249
|
+
const safeIndex = currentIndex === -1 ? 0 : currentIndex;
|
|
245
250
|
const newIndex = key.leftArrow
|
|
246
|
-
? Math.max(0,
|
|
247
|
-
: Math.min(resourceSizes.length - 1,
|
|
251
|
+
? Math.max(0, safeIndex - 1)
|
|
252
|
+
: Math.min(resourceSizes.length - 1, safeIndex + 1);
|
|
248
253
|
setFormData({
|
|
249
254
|
...formData,
|
|
250
255
|
resource_size: resourceSizes[newIndex],
|
|
@@ -339,7 +344,7 @@ export const DevboxCreatePage = ({ onBack, onCreate, initialBlueprintId, }) => {
|
|
|
339
344
|
};
|
|
340
345
|
// Result screen
|
|
341
346
|
if (result) {
|
|
342
|
-
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Devboxes" }, { label: "Create", active: true }] }), _jsx(SuccessMessage, { message: "Devbox created successfully!",
|
|
347
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Devboxes" }, { label: "Create", active: true }] }), _jsx(SuccessMessage, { message: "Devbox created successfully!" }), _jsxs(Box, { marginLeft: 2, flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["ID:", " "] }), _jsx(Text, { color: colors.idColor, children: result.id })] }), _jsx(Box, { children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Name: ", result.name || "(none)"] }) }), _jsx(Box, { children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Status: ", result.status] }) })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.textDim, dimColor: true, children: "Press [Enter], [q], or [esc] to return to list" }) })] }));
|
|
343
348
|
}
|
|
344
349
|
// Error screen
|
|
345
350
|
if (error) {
|
|
@@ -350,7 +355,7 @@ export const DevboxCreatePage = ({ onBack, onCreate, initialBlueprintId, }) => {
|
|
|
350
355
|
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Devboxes" }, { label: "Create", active: true }] }), _jsx(SpinnerComponent, { message: "Creating devbox..." })] }));
|
|
351
356
|
}
|
|
352
357
|
// Form screen
|
|
353
|
-
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Devboxes" }, { label: "Create", active: true }] }), _jsx(Box, { flexDirection: "column", marginBottom: 1, children: fields.map((field
|
|
358
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Devboxes" }, { label: "Create", active: true }] }), _jsx(Box, { flexDirection: "column", marginBottom: 1, children: fields.map((field) => {
|
|
354
359
|
const isActive = currentField === field.key;
|
|
355
360
|
const fieldData = formData[field.key];
|
|
356
361
|
if (field.type === "action") {
|