@runloop/rl-cli 0.2.0 → 0.4.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 +5 -75
- package/dist/cli.js +24 -56
- package/dist/commands/auth.js +2 -1
- package/dist/commands/blueprint/list.js +68 -22
- package/dist/commands/blueprint/preview.js +38 -42
- package/dist/commands/config.js +3 -2
- package/dist/commands/devbox/ssh.js +2 -1
- package/dist/commands/devbox/tunnel.js +2 -1
- package/dist/commands/mcp-http.js +6 -5
- package/dist/commands/mcp-install.js +9 -8
- package/dist/commands/mcp.js +5 -4
- package/dist/commands/menu.js +2 -1
- package/dist/components/ActionsPopup.js +18 -17
- package/dist/components/Banner.js +7 -1
- package/dist/components/Breadcrumb.js +10 -9
- package/dist/components/DevboxActionsMenu.js +18 -180
- package/dist/components/InteractiveSpawn.js +24 -14
- package/dist/components/LogsViewer.js +169 -0
- package/dist/components/MainMenu.js +2 -2
- package/dist/components/ResourceListView.js +3 -3
- package/dist/components/UpdateNotification.js +56 -0
- package/dist/hooks/useCursorPagination.js +3 -3
- package/dist/hooks/useExitOnCtrlC.js +2 -1
- package/dist/mcp/server-http.js +2 -1
- package/dist/mcp/server.js +7 -2
- package/dist/router/Router.js +3 -1
- package/dist/screens/BlueprintLogsScreen.js +74 -0
- package/dist/services/blueprintService.js +18 -22
- package/dist/utils/CommandExecutor.js +24 -53
- package/dist/utils/client.js +5 -1
- package/dist/utils/config.js +2 -1
- package/dist/utils/logFormatter.js +47 -1
- package/dist/utils/output.js +4 -3
- package/dist/utils/process.js +106 -0
- package/dist/utils/processUtils.js +135 -0
- package/dist/utils/screen.js +40 -2
- package/dist/utils/ssh.js +3 -2
- package/dist/utils/terminalDetection.js +120 -32
- package/dist/utils/theme.js +34 -19
- package/dist/utils/versionCheck.js +53 -0
- package/dist/version.js +12 -0
- package/package.json +4 -6
|
@@ -3,13 +3,14 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
|
3
3
|
import { homedir, platform } from "os";
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
import { execSync } from "child_process";
|
|
6
|
+
import { processUtils } from "../utils/processUtils.js";
|
|
6
7
|
function getClaudeConfigPath() {
|
|
7
8
|
const plat = platform();
|
|
8
9
|
if (plat === "darwin") {
|
|
9
10
|
return join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
10
11
|
}
|
|
11
12
|
else if (plat === "win32") {
|
|
12
|
-
const appData =
|
|
13
|
+
const appData = processUtils.env.APPDATA;
|
|
13
14
|
if (!appData) {
|
|
14
15
|
throw new Error("APPDATA environment variable not found");
|
|
15
16
|
}
|
|
@@ -51,7 +52,7 @@ export async function installMcpConfig() {
|
|
|
51
52
|
catch {
|
|
52
53
|
console.error("❌ Error: Claude config file exists but is not valid JSON");
|
|
53
54
|
console.error("Please fix the file manually or delete it to create a new one");
|
|
54
|
-
|
|
55
|
+
processUtils.exit(1);
|
|
55
56
|
}
|
|
56
57
|
}
|
|
57
58
|
else {
|
|
@@ -71,20 +72,20 @@ export async function installMcpConfig() {
|
|
|
71
72
|
// Ask if they want to overwrite
|
|
72
73
|
console.log("\n❓ Do you want to overwrite it? (y/N): ");
|
|
73
74
|
// For non-interactive mode, just exit
|
|
74
|
-
if (
|
|
75
|
+
if (processUtils.stdin.isTTY) {
|
|
75
76
|
const response = await new Promise((resolve) => {
|
|
76
|
-
|
|
77
|
+
processUtils.stdin.on("data", (data) => {
|
|
77
78
|
resolve(data.toString().trim().toLowerCase());
|
|
78
79
|
});
|
|
79
80
|
});
|
|
80
81
|
if (response !== "y" && response !== "yes") {
|
|
81
82
|
console.log("\n✓ Keeping existing configuration");
|
|
82
|
-
|
|
83
|
+
processUtils.exit(0);
|
|
83
84
|
}
|
|
84
85
|
}
|
|
85
86
|
else {
|
|
86
87
|
console.log("\n✓ Keeping existing configuration (non-interactive mode)");
|
|
87
|
-
|
|
88
|
+
processUtils.exit(0);
|
|
88
89
|
}
|
|
89
90
|
}
|
|
90
91
|
// Add runloop MCP server config
|
|
@@ -100,7 +101,7 @@ export async function installMcpConfig() {
|
|
|
100
101
|
console.log("\n📝 Next steps:");
|
|
101
102
|
console.log("1. Restart Claude Desktop completely (quit and reopen)");
|
|
102
103
|
console.log('2. Ask Claude: "List my devboxes" or "What Runloop tools do you have?"');
|
|
103
|
-
console.log(
|
|
104
|
+
console.log("\n💡 Tip: Make sure RUNLOOP_API_KEY environment variable is set!");
|
|
104
105
|
}
|
|
105
106
|
catch (error) {
|
|
106
107
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
@@ -116,6 +117,6 @@ export async function installMcpConfig() {
|
|
|
116
117
|
},
|
|
117
118
|
},
|
|
118
119
|
}, null, 2));
|
|
119
|
-
|
|
120
|
+
processUtils.exit(1);
|
|
120
121
|
}
|
|
121
122
|
}
|
package/dist/commands/mcp.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { spawn } from "child_process";
|
|
3
3
|
import { fileURLToPath } from "url";
|
|
4
4
|
import { dirname, join } from "path";
|
|
5
|
+
import { processUtils } from "../utils/processUtils.js";
|
|
5
6
|
const __filename = fileURLToPath(import.meta.url);
|
|
6
7
|
const __dirname = dirname(__filename);
|
|
7
8
|
export async function startMcpServer() {
|
|
@@ -14,17 +15,17 @@ export async function startMcpServer() {
|
|
|
14
15
|
});
|
|
15
16
|
serverProcess.on("error", (error) => {
|
|
16
17
|
console.error("Failed to start MCP server:", error);
|
|
17
|
-
|
|
18
|
+
processUtils.exit(1);
|
|
18
19
|
});
|
|
19
20
|
serverProcess.on("exit", (code) => {
|
|
20
21
|
if (code !== 0) {
|
|
21
22
|
console.error(`MCP server exited with code ${code}`);
|
|
22
|
-
|
|
23
|
+
processUtils.exit(code || 1);
|
|
23
24
|
}
|
|
24
25
|
});
|
|
25
26
|
// Handle Ctrl+C
|
|
26
|
-
|
|
27
|
+
processUtils.on("SIGINT", () => {
|
|
27
28
|
serverProcess.kill("SIGINT");
|
|
28
|
-
|
|
29
|
+
processUtils.exit(0);
|
|
29
30
|
});
|
|
30
31
|
}
|
package/dist/commands/menu.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { render } from "ink";
|
|
3
3
|
import { enterAlternateScreenBuffer, exitAlternateScreenBuffer, } from "../utils/screen.js";
|
|
4
|
+
import { processUtils } from "../utils/processUtils.js";
|
|
4
5
|
import { Router } from "../router/Router.js";
|
|
5
6
|
import { NavigationProvider } from "../store/navigationStore.js";
|
|
6
7
|
function AppInner() {
|
|
@@ -24,5 +25,5 @@ export async function runMainMenu(initialScreen = "menu", focusDevboxId) {
|
|
|
24
25
|
console.error("Error in menu:", error);
|
|
25
26
|
}
|
|
26
27
|
exitAlternateScreenBuffer();
|
|
27
|
-
|
|
28
|
+
processUtils.exit(0);
|
|
28
29
|
}
|
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
3
|
import figures from "figures";
|
|
4
4
|
import chalk from "chalk";
|
|
5
|
-
import {
|
|
5
|
+
import { getChalkTextColor, getChalkColor } from "../utils/theme.js";
|
|
6
6
|
export const ActionsPopup = ({ devbox: _devbox, operations, selectedOperation, onClose: _onClose, }) => {
|
|
7
7
|
// Calculate max width needed for content (visible characters only)
|
|
8
8
|
// CRITICAL: Ensure all values are valid numbers to prevent Yoga crashes
|
|
@@ -15,11 +15,12 @@ export const ActionsPopup = ({ devbox: _devbox, operations, selectedOperation, o
|
|
|
15
15
|
// Plus 2 for border characters = 6 total extra
|
|
16
16
|
// CRITICAL: Validate all computed widths are positive integers
|
|
17
17
|
const contentWidth = Math.max(10, maxContentWidth + 4);
|
|
18
|
-
// Get background color chalk function -
|
|
19
|
-
// In light mode
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
const
|
|
18
|
+
// Get background color chalk function - use theme colors to match the theme mode
|
|
19
|
+
// In light mode, use light background; in dark mode, use dark background
|
|
20
|
+
const popupBgHex = getChalkColor("background");
|
|
21
|
+
const popupTextHex = getChalkColor("text");
|
|
22
|
+
const bgColorFn = chalk.bgHex(popupBgHex);
|
|
23
|
+
const textColorFn = chalk.hex(popupTextHex);
|
|
23
24
|
// Helper to create background lines with proper padding including left/right margins
|
|
24
25
|
const createBgLine = (styledContent, plainContent) => {
|
|
25
26
|
const visibleLength = plainContent.length;
|
|
@@ -27,11 +28,11 @@ export const ActionsPopup = ({ devbox: _devbox, operations, selectedOperation, o
|
|
|
27
28
|
const repeatCount = Math.max(0, Math.floor(maxContentWidth - visibleLength));
|
|
28
29
|
const rightPadding = " ".repeat(repeatCount);
|
|
29
30
|
// Apply background to left padding + content + right padding
|
|
30
|
-
return
|
|
31
|
+
return bgColorFn(" " + styledContent + rightPadding + " ");
|
|
31
32
|
};
|
|
32
33
|
// Create empty line with full background
|
|
33
34
|
// CRITICAL: Validate repeat count is positive integer
|
|
34
|
-
const emptyLine =
|
|
35
|
+
const emptyLine = bgColorFn(" ".repeat(Math.max(1, Math.floor(contentWidth))));
|
|
35
36
|
// Create border lines with background and integrated title
|
|
36
37
|
const title = `${figures.play} Quick Actions`;
|
|
37
38
|
// The content between ╭ and ╮ should be exactly contentWidth
|
|
@@ -41,12 +42,12 @@ export const ActionsPopup = ({ devbox: _devbox, operations, selectedOperation, o
|
|
|
41
42
|
// CRITICAL: Validate repeat counts are non-negative integers
|
|
42
43
|
const remainingDashes = Math.max(0, Math.floor(contentWidth - titleTotalLength));
|
|
43
44
|
// Use theme primary color for borders to match theme
|
|
44
|
-
const borderColorFn =
|
|
45
|
-
const borderTop =
|
|
45
|
+
const borderColorFn = getChalkTextColor("primary");
|
|
46
|
+
const borderTop = bgColorFn(borderColorFn("╭─" + titleWithSpaces + "─".repeat(remainingDashes) + "╮"));
|
|
46
47
|
// CRITICAL: Validate contentWidth is a positive integer
|
|
47
|
-
const borderBottom =
|
|
48
|
+
const borderBottom = bgColorFn(borderColorFn("╰" + "─".repeat(Math.max(1, Math.floor(contentWidth))) + "╯"));
|
|
48
49
|
const borderSide = (content) => {
|
|
49
|
-
return
|
|
50
|
+
return bgColorFn(borderColorFn("│") + content + borderColorFn("│"));
|
|
50
51
|
};
|
|
51
52
|
return (_jsx(Box, { flexDirection: "column", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: borderTop }), _jsx(Text, { children: borderSide(emptyLine) }), operations.map((op, index) => {
|
|
52
53
|
const isSelected = index === selectedOperation;
|
|
@@ -56,14 +57,14 @@ export const ActionsPopup = ({ devbox: _devbox, operations, selectedOperation, o
|
|
|
56
57
|
if (isSelected) {
|
|
57
58
|
// Selected: use operation-specific color for icon and label
|
|
58
59
|
const opColor = op.color;
|
|
59
|
-
const colorFn = chalk[opColor] ||
|
|
60
|
-
styledLine = `${
|
|
60
|
+
const colorFn = chalk[opColor] || textColorFn;
|
|
61
|
+
styledLine = `${textColorFn(pointer)} ${colorFn(op.icon)} ${colorFn.bold(op.label)} ${textColorFn(`[${op.shortcut}]`)}`;
|
|
61
62
|
}
|
|
62
63
|
else {
|
|
63
|
-
// Unselected:
|
|
64
|
-
const dimFn =
|
|
64
|
+
// Unselected: use theme's textDim color for dimmed text
|
|
65
|
+
const dimFn = getChalkTextColor("textDim");
|
|
65
66
|
styledLine = `${dimFn(pointer)} ${dimFn(op.icon)} ${dimFn(op.label)} ${dimFn(`[${op.shortcut}]`)}`;
|
|
66
67
|
}
|
|
67
68
|
return (_jsx(Text, { children: borderSide(createBgLine(styledLine, lineText)) }, op.key));
|
|
68
|
-
}), _jsx(Text, { children: borderSide(emptyLine) }), _jsx(Text, { children: borderSide(createBgLine(
|
|
69
|
+
}), _jsx(Text, { children: borderSide(emptyLine) }), _jsx(Text, { children: borderSide(createBgLine(textColorFn(`${figures.arrowUp}${figures.arrowDown} Nav • [Enter] • [Esc] Close`), `${figures.arrowUp}${figures.arrowDown} Nav • [Enter] • [Esc] Close`)) }), _jsx(Text, { children: borderSide(emptyLine) }), _jsx(Text, { children: borderBottom })] }) }));
|
|
69
70
|
};
|
|
@@ -3,6 +3,12 @@ import React from "react";
|
|
|
3
3
|
import { Box } from "ink";
|
|
4
4
|
import BigText from "ink-big-text";
|
|
5
5
|
import Gradient from "ink-gradient";
|
|
6
|
+
import { isLightMode } from "../utils/theme.js";
|
|
6
7
|
export const Banner = React.memo(() => {
|
|
7
|
-
|
|
8
|
+
// Use theme-aware gradient colors
|
|
9
|
+
// In light mode, use darker/deeper colors for better contrast on light backgrounds
|
|
10
|
+
// "teen" has darker colors (blue/purple) that work well on light backgrounds
|
|
11
|
+
// In dark mode, use the vibrant "vice" gradient (pink/cyan) that works well on dark backgrounds
|
|
12
|
+
const gradientName = isLightMode() ? "teen" : "vice";
|
|
13
|
+
return (_jsx(Box, { flexDirection: "column", alignItems: "flex-start", paddingX: 1, children: _jsx(Gradient, { name: gradientName, children: _jsx(BigText, { text: "RUNLOOP.ai", font: "simple3d" }) }) }));
|
|
8
14
|
});
|
|
@@ -2,15 +2,16 @@ 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
4
|
import { colors } from "../utils/theme.js";
|
|
5
|
-
|
|
5
|
+
import { UpdateNotification } from "./UpdateNotification.js";
|
|
6
|
+
export const Breadcrumb = ({ items, showVersionCheck = false, }) => {
|
|
6
7
|
const env = process.env.RUNLOOP_ENV?.toLowerCase();
|
|
7
8
|
const isDevEnvironment = env === "dev";
|
|
8
|
-
return (
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
9
|
+
return (_jsxs(Box, { marginBottom: 1, paddingX: 0, paddingY: 0, children: [_jsxs(Box, { borderStyle: "round", borderColor: colors.primary, paddingX: 2, paddingY: 0, children: [_jsx(Text, { color: colors.primary, bold: true, children: "rl" }), isDevEnvironment && (_jsxs(Text, { color: colors.error, bold: true, children: [" ", "(dev)"] })), _jsx(Text, { color: colors.textDim, children: " \u203A " }), items.map((item, index) => {
|
|
10
|
+
// Limit label length to prevent Yoga layout engine errors
|
|
11
|
+
const MAX_LABEL_LENGTH = 80;
|
|
12
|
+
const truncatedLabel = item.label.length > MAX_LABEL_LENGTH
|
|
13
|
+
? item.label.substring(0, MAX_LABEL_LENGTH) + "..."
|
|
14
|
+
: item.label;
|
|
15
|
+
return (_jsxs(React.Fragment, { children: [_jsx(Text, { color: item.active ? colors.primary : colors.textDim, children: truncatedLabel }), index < items.length - 1 && (_jsx(Text, { color: colors.textDim, children: " \u203A " }))] }, index));
|
|
16
|
+
})] }), showVersionCheck && (_jsx(Box, { paddingX: 2, marginTop: 0, children: _jsx(UpdateNotification, {}) }))] }));
|
|
16
17
|
};
|
|
@@ -13,7 +13,7 @@ import { useViewportHeight } from "../hooks/useViewportHeight.js";
|
|
|
13
13
|
import { useNavigation } from "../store/navigationStore.js";
|
|
14
14
|
import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
|
|
15
15
|
import { getDevboxLogs, execCommand, suspendDevbox, resumeDevbox, shutdownDevbox, uploadFile, createSnapshot as createDevboxSnapshot, createTunnel, createSSHKey, } from "../services/devboxService.js";
|
|
16
|
-
import {
|
|
16
|
+
import { LogsViewer } from "./LogsViewer.js";
|
|
17
17
|
export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
|
|
18
18
|
{ label: "Devboxes" },
|
|
19
19
|
{ label: devbox.name || devbox.id, active: true },
|
|
@@ -25,8 +25,6 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
|
|
|
25
25
|
const [operationInput, setOperationInput] = React.useState("");
|
|
26
26
|
const [operationResult, setOperationResult] = React.useState(null);
|
|
27
27
|
const [operationError, setOperationError] = React.useState(null);
|
|
28
|
-
const [logsWrapMode, setLogsWrapMode] = React.useState(false);
|
|
29
|
-
const [logsScroll, setLogsScroll] = React.useState(0);
|
|
30
28
|
const [execScroll, setExecScroll] = React.useState(0);
|
|
31
29
|
const [copyStatus, setCopyStatus] = React.useState(null);
|
|
32
30
|
// Calculate viewport for exec output:
|
|
@@ -38,14 +36,6 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
|
|
|
38
36
|
// - Safety buffer: 1 line
|
|
39
37
|
// Total: 16 lines
|
|
40
38
|
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
39
|
// CRITICAL: Aggressive memory cleanup to prevent heap exhaustion
|
|
50
40
|
React.useEffect(() => {
|
|
51
41
|
// Clear large data immediately when results are shown to free memory faster
|
|
@@ -185,8 +175,6 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
|
|
|
185
175
|
setOperationResult(null);
|
|
186
176
|
setOperationError(null);
|
|
187
177
|
setOperationInput("");
|
|
188
|
-
setLogsWrapMode(true);
|
|
189
|
-
setLogsScroll(0);
|
|
190
178
|
setExecScroll(0);
|
|
191
179
|
setCopyStatus(null);
|
|
192
180
|
// If skipOperationsMenu is true, go back to parent instead of operations menu
|
|
@@ -283,102 +271,6 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
|
|
|
283
271
|
};
|
|
284
272
|
copyToClipboard(output);
|
|
285
273
|
}
|
|
286
|
-
else if ((key.upArrow || input === "k") &&
|
|
287
|
-
operationResult &&
|
|
288
|
-
typeof operationResult === "object" &&
|
|
289
|
-
operationResult.__customRender === "logs") {
|
|
290
|
-
setLogsScroll(Math.max(0, logsScroll - 1));
|
|
291
|
-
}
|
|
292
|
-
else if ((key.downArrow || input === "j") &&
|
|
293
|
-
operationResult &&
|
|
294
|
-
typeof operationResult === "object" &&
|
|
295
|
-
operationResult.__customRender === "logs") {
|
|
296
|
-
setLogsScroll(logsScroll + 1);
|
|
297
|
-
}
|
|
298
|
-
else if (key.pageUp &&
|
|
299
|
-
operationResult &&
|
|
300
|
-
typeof operationResult === "object" &&
|
|
301
|
-
operationResult.__customRender === "logs") {
|
|
302
|
-
setLogsScroll(Math.max(0, logsScroll - 10));
|
|
303
|
-
}
|
|
304
|
-
else if (key.pageDown &&
|
|
305
|
-
operationResult &&
|
|
306
|
-
typeof operationResult === "object" &&
|
|
307
|
-
operationResult.__customRender === "logs") {
|
|
308
|
-
setLogsScroll(logsScroll + 10);
|
|
309
|
-
}
|
|
310
|
-
else if (input === "g" &&
|
|
311
|
-
operationResult &&
|
|
312
|
-
typeof operationResult === "object" &&
|
|
313
|
-
operationResult.__customRender === "logs") {
|
|
314
|
-
setLogsScroll(0);
|
|
315
|
-
}
|
|
316
|
-
else if (input === "G" &&
|
|
317
|
-
operationResult &&
|
|
318
|
-
typeof operationResult === "object" &&
|
|
319
|
-
operationResult.__customRender === "logs") {
|
|
320
|
-
const logs = operationResult.__logs || [];
|
|
321
|
-
const maxScroll = Math.max(0, logs.length - logsViewport.viewportHeight);
|
|
322
|
-
setLogsScroll(maxScroll);
|
|
323
|
-
}
|
|
324
|
-
else if (input === "w" &&
|
|
325
|
-
operationResult &&
|
|
326
|
-
typeof operationResult === "object" &&
|
|
327
|
-
operationResult.__customRender === "logs") {
|
|
328
|
-
setLogsWrapMode(!logsWrapMode);
|
|
329
|
-
}
|
|
330
|
-
else if (input === "c" &&
|
|
331
|
-
operationResult &&
|
|
332
|
-
typeof operationResult === "object" &&
|
|
333
|
-
operationResult.__customRender === "logs") {
|
|
334
|
-
// Copy logs to clipboard using shared formatter
|
|
335
|
-
const logs = operationResult.__logs || [];
|
|
336
|
-
const logsText = logs
|
|
337
|
-
.map((log) => {
|
|
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();
|
|
343
|
-
})
|
|
344
|
-
.join("\n");
|
|
345
|
-
const copyToClipboard = async (text) => {
|
|
346
|
-
const { spawn } = await import("child_process");
|
|
347
|
-
const platform = process.platform;
|
|
348
|
-
let command;
|
|
349
|
-
let args;
|
|
350
|
-
if (platform === "darwin") {
|
|
351
|
-
command = "pbcopy";
|
|
352
|
-
args = [];
|
|
353
|
-
}
|
|
354
|
-
else if (platform === "win32") {
|
|
355
|
-
command = "clip";
|
|
356
|
-
args = [];
|
|
357
|
-
}
|
|
358
|
-
else {
|
|
359
|
-
command = "xclip";
|
|
360
|
-
args = ["-selection", "clipboard"];
|
|
361
|
-
}
|
|
362
|
-
const proc = spawn(command, args);
|
|
363
|
-
proc.stdin.write(text);
|
|
364
|
-
proc.stdin.end();
|
|
365
|
-
proc.on("exit", (code) => {
|
|
366
|
-
if (code === 0) {
|
|
367
|
-
setCopyStatus("Copied to clipboard!");
|
|
368
|
-
setTimeout(() => setCopyStatus(null), 2000);
|
|
369
|
-
}
|
|
370
|
-
else {
|
|
371
|
-
setCopyStatus("Failed to copy");
|
|
372
|
-
setTimeout(() => setCopyStatus(null), 2000);
|
|
373
|
-
}
|
|
374
|
-
});
|
|
375
|
-
proc.on("error", () => {
|
|
376
|
-
setCopyStatus("Copy not supported");
|
|
377
|
-
setTimeout(() => setCopyStatus(null), 2000);
|
|
378
|
-
});
|
|
379
|
-
};
|
|
380
|
-
copyToClipboard(logsText);
|
|
381
|
-
}
|
|
382
274
|
return;
|
|
383
275
|
}
|
|
384
276
|
// Operations selection mode
|
|
@@ -560,77 +452,23 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
|
|
|
560
452
|
typeof operationResult === "object" &&
|
|
561
453
|
operationResult.__customRender === "logs") {
|
|
562
454
|
const logs = operationResult.__logs || [];
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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"] }) })] }));
|
|
455
|
+
return (_jsx(LogsViewer, { logs: logs, breadcrumbItems: [
|
|
456
|
+
...breadcrumbItems,
|
|
457
|
+
{ label: "Logs", active: true },
|
|
458
|
+
], onBack: () => {
|
|
459
|
+
// Clear large data structures immediately to prevent memory leaks
|
|
460
|
+
setOperationResult(null);
|
|
461
|
+
setOperationError(null);
|
|
462
|
+
setOperationInput("");
|
|
463
|
+
// If skipOperationsMenu is true, go back to parent instead of operations menu
|
|
464
|
+
if (skipOperationsMenu) {
|
|
465
|
+
setExecutingOperation(null);
|
|
466
|
+
onBack();
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
setExecutingOperation(null);
|
|
470
|
+
}
|
|
471
|
+
}, title: "Logs" }));
|
|
634
472
|
}
|
|
635
473
|
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" }) })] }));
|
|
636
474
|
}
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import React from "react";
|
|
7
7
|
import { spawn } from "child_process";
|
|
8
|
-
import {
|
|
8
|
+
import { showCursor, clearScreen, enterAlternateScreenBuffer, } from "../utils/screen.js";
|
|
9
|
+
import { processUtils } from "../utils/processUtils.js";
|
|
9
10
|
/**
|
|
10
11
|
* Releases terminal control from Ink so a subprocess can take over.
|
|
11
12
|
* This directly manipulates stdin to bypass Ink's input handling.
|
|
@@ -15,17 +16,31 @@ function releaseTerminal() {
|
|
|
15
16
|
process.stdin.pause();
|
|
16
17
|
// Disable raw mode so the subprocess can control terminal echo and line buffering
|
|
17
18
|
// SSH needs to set its own terminal modes
|
|
18
|
-
if (
|
|
19
|
-
|
|
19
|
+
if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) {
|
|
20
|
+
processUtils.stdin.setRawMode(false);
|
|
21
|
+
}
|
|
22
|
+
// Reset terminal attributes (SGR reset) - clears any colors/styles Ink may have set
|
|
23
|
+
if (processUtils.stdout.isTTY) {
|
|
24
|
+
processUtils.stdout.write("\x1b[0m");
|
|
25
|
+
}
|
|
26
|
+
// Show cursor - Ink may have hidden it, and subprocesses expect it to be visible
|
|
27
|
+
showCursor();
|
|
28
|
+
// Flush stdout to ensure all pending writes are complete before handoff
|
|
29
|
+
if (processUtils.stdout.isTTY) {
|
|
30
|
+
processUtils.stdout.write("");
|
|
20
31
|
}
|
|
21
32
|
}
|
|
22
33
|
/**
|
|
23
34
|
* Restores terminal control to Ink after subprocess exits.
|
|
24
35
|
*/
|
|
25
36
|
function restoreTerminal() {
|
|
37
|
+
// Clear the screen to remove subprocess output before Ink renders
|
|
38
|
+
clearScreen();
|
|
39
|
+
// Re-enter alternate screen buffer for Ink's fullscreen UI
|
|
40
|
+
enterAlternateScreenBuffer();
|
|
26
41
|
// Re-enable raw mode for Ink's input handling
|
|
27
|
-
if (
|
|
28
|
-
|
|
42
|
+
if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) {
|
|
43
|
+
processUtils.stdin.setRawMode(true);
|
|
29
44
|
}
|
|
30
45
|
// Resume stdin so Ink can read input again
|
|
31
46
|
process.stdin.resume();
|
|
@@ -41,12 +56,11 @@ export const InteractiveSpawn = ({ command, args, onExit, onError, }) => {
|
|
|
41
56
|
return;
|
|
42
57
|
}
|
|
43
58
|
hasSpawnedRef.current = true;
|
|
44
|
-
// Exit alternate screen so subprocess gets a clean terminal
|
|
45
|
-
exitAlternateScreenBuffer();
|
|
46
59
|
// Release terminal from Ink's control
|
|
47
60
|
releaseTerminal();
|
|
48
|
-
//
|
|
49
|
-
setTimeout
|
|
61
|
+
// Use setImmediate to ensure terminal state is released without noticeable delay
|
|
62
|
+
// This is faster than setTimeout and ensures the event loop has processed the release
|
|
63
|
+
setImmediate(() => {
|
|
50
64
|
// Spawn the process with inherited stdio for proper TTY allocation
|
|
51
65
|
const child = spawn(command, args, {
|
|
52
66
|
stdio: "inherit", // This allows the process to use the terminal directly
|
|
@@ -59,8 +73,6 @@ export const InteractiveSpawn = ({ command, args, onExit, onError, }) => {
|
|
|
59
73
|
hasSpawnedRef.current = false;
|
|
60
74
|
// Restore terminal control to Ink
|
|
61
75
|
restoreTerminal();
|
|
62
|
-
// Re-enter alternate screen after process exits
|
|
63
|
-
enterAlternateScreenBuffer();
|
|
64
76
|
if (onExit) {
|
|
65
77
|
onExit(code);
|
|
66
78
|
}
|
|
@@ -71,13 +83,11 @@ export const InteractiveSpawn = ({ command, args, onExit, onError, }) => {
|
|
|
71
83
|
hasSpawnedRef.current = false;
|
|
72
84
|
// Restore terminal control to Ink
|
|
73
85
|
restoreTerminal();
|
|
74
|
-
// Re-enter alternate screen on error
|
|
75
|
-
enterAlternateScreenBuffer();
|
|
76
86
|
if (onError) {
|
|
77
87
|
onError(error);
|
|
78
88
|
}
|
|
79
89
|
});
|
|
80
|
-
}
|
|
90
|
+
});
|
|
81
91
|
// Cleanup function - kill the process if component unmounts
|
|
82
92
|
return () => {
|
|
83
93
|
if (processRef.current && !processRef.current.killed) {
|