@runloop/rl-cli 1.6.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,337 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * StreamingLogsViewer - Live streaming logs viewer with auto-refresh
4
+ * Polls for new logs periodically and auto-scrolls when at bottom
5
+ */
6
+ import React from "react";
7
+ import { Box, Text, useInput } from "ink";
8
+ import figures from "figures";
9
+ import { Breadcrumb } from "./Breadcrumb.js";
10
+ import { NavigationTips } from "./NavigationTips.js";
11
+ import { colors } from "../utils/theme.js";
12
+ import { useViewportHeight } from "../hooks/useViewportHeight.js";
13
+ import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
14
+ import { parseAnyLogEntry } from "../utils/logFormatter.js";
15
+ import { getDevboxLogs } from "../services/devboxService.js";
16
+ // Color maps - defined outside component to avoid recreation on every render
17
+ const levelColorMap = {
18
+ red: colors.error,
19
+ yellow: colors.warning,
20
+ blue: colors.primary,
21
+ gray: colors.textDim,
22
+ };
23
+ const sourceColorMap = {
24
+ magenta: "#d33682",
25
+ cyan: colors.info,
26
+ green: colors.success,
27
+ yellow: colors.warning,
28
+ gray: colors.textDim,
29
+ white: colors.text,
30
+ };
31
+ export const StreamingLogsViewer = ({ devboxId, breadcrumbItems = [{ label: "Logs", active: true }], onBack, }) => {
32
+ const [logs, setLogs] = React.useState([]);
33
+ const [loading, setLoading] = React.useState(true);
34
+ const [error, setError] = React.useState(null);
35
+ const [logsWrapMode, setLogsWrapMode] = React.useState(false);
36
+ const [logsScroll, setLogsScroll] = React.useState(0);
37
+ const [copyStatus, setCopyStatus] = React.useState(null);
38
+ const [autoScroll, setAutoScroll] = React.useState(true);
39
+ const [isPolling, setIsPolling] = React.useState(true);
40
+ // Refs for cleanup
41
+ const pollIntervalRef = React.useRef(null);
42
+ // Calculate viewport - overhead increased to reduce overdraw/flashing
43
+ const logsViewport = useViewportHeight({ overhead: 11, minHeight: 10 });
44
+ // Handle Ctrl+C
45
+ useExitOnCtrlC();
46
+ // Fetch logs function - only update state if logs actually changed
47
+ const fetchLogs = React.useCallback(async () => {
48
+ try {
49
+ const newLogs = await getDevboxLogs(devboxId);
50
+ // Only update logs state if the logs have actually changed
51
+ // This prevents unnecessary re-renders that cause flashing in non-tmux terminals
52
+ setLogs((prevLogs) => {
53
+ // Quick length check first
54
+ if (prevLogs.length !== newLogs.length) {
55
+ return newLogs;
56
+ }
57
+ // If same length, check if last log entry is different (most common case for streaming)
58
+ if (newLogs.length > 0) {
59
+ const prevLast = prevLogs[prevLogs.length - 1];
60
+ const newLast = newLogs[newLogs.length - 1];
61
+ // Compare by timestamp and message for efficiency
62
+ if (prevLast &&
63
+ newLast &&
64
+ "timestamp" in prevLast &&
65
+ "timestamp" in newLast &&
66
+ "message" in prevLast &&
67
+ "message" in newLast &&
68
+ prevLast.timestamp === newLast.timestamp &&
69
+ prevLast.message === newLast.message) {
70
+ // Logs haven't changed, return previous state to avoid re-render
71
+ return prevLogs;
72
+ }
73
+ }
74
+ return newLogs;
75
+ });
76
+ setError(null);
77
+ if (loading)
78
+ setLoading(false);
79
+ }
80
+ catch (err) {
81
+ setError(err.message);
82
+ if (loading)
83
+ setLoading(false);
84
+ }
85
+ }, [devboxId, loading]);
86
+ // Start polling on mount
87
+ React.useEffect(() => {
88
+ // Initial fetch
89
+ fetchLogs();
90
+ // Poll every 2 seconds
91
+ pollIntervalRef.current = setInterval(() => {
92
+ if (isPolling) {
93
+ fetchLogs();
94
+ }
95
+ }, 2000);
96
+ return () => {
97
+ if (pollIntervalRef.current) {
98
+ clearInterval(pollIntervalRef.current);
99
+ }
100
+ };
101
+ }, [fetchLogs, isPolling]);
102
+ // Calculate max scroll position
103
+ const getMaxScroll = () => {
104
+ if (logsWrapMode) {
105
+ return Math.max(0, logs.length - 1);
106
+ }
107
+ else {
108
+ return Math.max(0, logs.length - logsViewport.viewportHeight);
109
+ }
110
+ };
111
+ // Auto-scroll effect
112
+ React.useEffect(() => {
113
+ if (autoScroll && logs.length > 0) {
114
+ const maxScroll = getMaxScroll();
115
+ setLogsScroll(maxScroll);
116
+ }
117
+ }, [logs.length, autoScroll, logsViewport.viewportHeight]);
118
+ // Handle input
119
+ useInput((input, key) => {
120
+ const maxScroll = getMaxScroll();
121
+ if (key.upArrow || input === "k") {
122
+ setLogsScroll(Math.max(0, logsScroll - 1));
123
+ setAutoScroll(false);
124
+ }
125
+ else if (key.downArrow || input === "j") {
126
+ const newScroll = Math.min(maxScroll, logsScroll + 1);
127
+ setLogsScroll(newScroll);
128
+ // Re-enable auto-scroll if we scroll to bottom
129
+ if (newScroll >= maxScroll) {
130
+ setAutoScroll(true);
131
+ }
132
+ }
133
+ else if (key.pageUp) {
134
+ setLogsScroll(Math.max(0, logsScroll - 10));
135
+ setAutoScroll(false);
136
+ }
137
+ else if (key.pageDown) {
138
+ const newScroll = Math.min(maxScroll, logsScroll + 10);
139
+ setLogsScroll(newScroll);
140
+ if (newScroll >= maxScroll) {
141
+ setAutoScroll(true);
142
+ }
143
+ }
144
+ else if (input === "g") {
145
+ setLogsScroll(0);
146
+ setAutoScroll(false);
147
+ }
148
+ else if (input === "G") {
149
+ setLogsScroll(maxScroll);
150
+ setAutoScroll(true);
151
+ }
152
+ else if (input === "w") {
153
+ setLogsWrapMode(!logsWrapMode);
154
+ }
155
+ else if (input === "p") {
156
+ // Toggle polling
157
+ setIsPolling(!isPolling);
158
+ }
159
+ else if (input === "c" && !key.ctrl) {
160
+ // Copy logs to clipboard (ignore if Ctrl+C for quit)
161
+ const logsText = logs
162
+ .map((log) => {
163
+ const parts = parseAnyLogEntry(log);
164
+ const cmd = parts.cmd ? `$ ${parts.cmd} ` : "";
165
+ const exitCode = parts.exitCode !== null ? `exit=${parts.exitCode} ` : "";
166
+ const shell = parts.shellName ? `(${parts.shellName}) ` : "";
167
+ return `${parts.timestamp} ${parts.level} [${parts.source}] ${shell}${cmd}${parts.message} ${exitCode}`.trim();
168
+ })
169
+ .join("\n");
170
+ const copyToClipboard = async (text) => {
171
+ const { spawn } = await import("child_process");
172
+ const platform = process.platform;
173
+ let command;
174
+ let args;
175
+ if (platform === "darwin") {
176
+ command = "pbcopy";
177
+ args = [];
178
+ }
179
+ else if (platform === "win32") {
180
+ command = "clip";
181
+ args = [];
182
+ }
183
+ else {
184
+ command = "xclip";
185
+ args = ["-selection", "clipboard"];
186
+ }
187
+ const proc = spawn(command, args);
188
+ proc.stdin.write(text);
189
+ proc.stdin.end();
190
+ proc.on("exit", (code) => {
191
+ if (code === 0) {
192
+ setCopyStatus("Copied!");
193
+ setTimeout(() => setCopyStatus(null), 2000);
194
+ }
195
+ else {
196
+ setCopyStatus("Failed");
197
+ setTimeout(() => setCopyStatus(null), 2000);
198
+ }
199
+ });
200
+ proc.on("error", () => {
201
+ setCopyStatus("Not supported");
202
+ setTimeout(() => setCopyStatus(null), 2000);
203
+ });
204
+ };
205
+ copyToClipboard(logsText);
206
+ }
207
+ else if (input === "q" || key.escape || key.return) {
208
+ onBack();
209
+ }
210
+ });
211
+ const viewportHeight = Math.max(1, logsViewport.viewportHeight);
212
+ const terminalWidth = logsViewport.terminalWidth;
213
+ const boxChrome = 8;
214
+ const contentWidth = Math.max(40, terminalWidth - boxChrome);
215
+ // Helper to sanitize log message
216
+ const sanitizeMessage = (message) => {
217
+ const strippedAnsi = message.replace(
218
+ // eslint-disable-next-line no-control-regex
219
+ /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
220
+ return (strippedAnsi
221
+ .replace(/\r\n/g, " ")
222
+ .replace(/\n/g, " ")
223
+ .replace(/\r/g, " ")
224
+ .replace(/\t/g, " ")
225
+ // eslint-disable-next-line no-control-regex
226
+ .replace(/[\x00-\x1F]/g, ""));
227
+ };
228
+ // Calculate visible logs
229
+ let visibleLogs;
230
+ let actualScroll;
231
+ if (logsWrapMode) {
232
+ actualScroll = Math.min(logsScroll, Math.max(0, logs.length - 1));
233
+ visibleLogs = [];
234
+ let lineCount = 0;
235
+ for (let i = actualScroll; i < logs.length; i++) {
236
+ const parts = parseAnyLogEntry(logs[i]);
237
+ const sanitized = sanitizeMessage(parts.message);
238
+ const totalLength = parts.timestamp.length +
239
+ parts.level.length +
240
+ parts.source.length +
241
+ sanitized.length +
242
+ 10;
243
+ const entryLines = Math.ceil(totalLength / contentWidth);
244
+ if (lineCount + entryLines > viewportHeight && visibleLogs.length > 0) {
245
+ break;
246
+ }
247
+ visibleLogs.push(logs[i]);
248
+ lineCount += entryLines;
249
+ }
250
+ }
251
+ else {
252
+ const maxScroll = Math.max(0, logs.length - viewportHeight);
253
+ actualScroll = Math.min(logsScroll, maxScroll);
254
+ visibleLogs = logs.slice(actualScroll, actualScroll + viewportHeight);
255
+ }
256
+ const hasMore = actualScroll + visibleLogs.length < logs.length;
257
+ const hasLess = actualScroll > 0;
258
+ // Build lines array - always render exactly viewportHeight lines for stable structure
259
+ // This prevents Ink from doing structural redraws that cause flashing
260
+ const renderLines = React.useMemo(() => {
261
+ const lines = [];
262
+ if (loading) {
263
+ // First line shows loading message
264
+ lines.push(_jsx(Box, { width: contentWidth, children: _jsx(Text, { color: colors.textDim, children: "\u25CF Loading logs..." }) }, "loading"));
265
+ }
266
+ else if (error) {
267
+ // First line shows error
268
+ lines.push(_jsx(Box, { width: contentWidth, children: _jsxs(Text, { color: colors.error, children: [figures.cross, " Error: ", error] }) }, "error"));
269
+ }
270
+ else if (logs.length === 0) {
271
+ // First line shows waiting message
272
+ lines.push(_jsx(Box, { width: contentWidth, children: _jsxs(Text, { color: colors.textDim, children: [isPolling ? "● " : "", "Waiting for logs..."] }) }, "waiting"));
273
+ }
274
+ else {
275
+ // Render visible log entries
276
+ for (let i = 0; i < visibleLogs.length; i++) {
277
+ const log = visibleLogs[i];
278
+ const parts = parseAnyLogEntry(log);
279
+ const sanitizedMessage = sanitizeMessage(parts.message);
280
+ const MAX_MESSAGE_LENGTH = 1000;
281
+ const fullMessage = sanitizedMessage.length > MAX_MESSAGE_LENGTH
282
+ ? sanitizedMessage.substring(0, MAX_MESSAGE_LENGTH) + "..."
283
+ : sanitizedMessage;
284
+ const cmd = parts.cmd
285
+ ? `$ ${parts.cmd.substring(0, 40)}${parts.cmd.length > 40 ? "..." : ""} `
286
+ : "";
287
+ const exitCode = parts.exitCode !== null ? `exit=${parts.exitCode} ` : "";
288
+ const levelColor = levelColorMap[parts.levelColor] || colors.textDim;
289
+ const sourceColor = sourceColorMap[parts.sourceColor] || colors.textDim;
290
+ if (logsWrapMode) {
291
+ lines.push(_jsx(Box, { width: contentWidth, flexDirection: "column", children: _jsxs(Text, { wrap: "wrap", 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] }))] }) }, i));
292
+ }
293
+ else {
294
+ const shellPart = parts.shellName ? `(${parts.shellName}) ` : "";
295
+ const exitPart = exitCode ? ` ${exitCode}` : "";
296
+ const prefix = `${parts.timestamp} ${parts.level} [${parts.source}] ${shellPart}${cmd}`;
297
+ const suffix = exitPart;
298
+ const availableForMessage = contentWidth - prefix.length - suffix.length;
299
+ let displayMessage;
300
+ if (availableForMessage <= 3) {
301
+ displayMessage = "";
302
+ }
303
+ else if (fullMessage.length <= availableForMessage) {
304
+ displayMessage = fullMessage;
305
+ }
306
+ else {
307
+ displayMessage =
308
+ fullMessage.substring(0, availableForMessage - 3) + "...";
309
+ }
310
+ lines.push(_jsx(Box, { width: contentWidth, children: _jsxs(Text, { wrap: "truncate-end", 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: displayMessage }), exitCode && (_jsxs(Text, { color: parts.exitCode === 0 ? colors.success : colors.error, children: [" ", exitCode] }))] }) }, i));
311
+ }
312
+ }
313
+ }
314
+ // Pad with empty lines to maintain consistent structure
315
+ // This prevents Ink from doing structural redraws
316
+ while (lines.length < viewportHeight) {
317
+ lines.push(_jsx(Box, { width: contentWidth, children: _jsx(Text, { children: " " }) }, `pad-${lines.length}`));
318
+ }
319
+ return lines;
320
+ }, [
321
+ loading,
322
+ error,
323
+ logs.length,
324
+ visibleLogs,
325
+ isPolling,
326
+ logsWrapMode,
327
+ contentWidth,
328
+ viewportHeight,
329
+ ]);
330
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems }), _jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.border, paddingX: 1, height: viewportHeight + 2, children: renderLines }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", logs.length] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "logs"] }), logs.length > 0 && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [actualScroll + 1, "-", Math.min(actualScroll + visibleLogs.length, 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", " "] }), isPolling ? (_jsx(Text, { color: colors.success, children: "\u25CF Live" })) : (_jsx(Text, { color: colors.textDim, children: "\u25CB Paused" })), _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(NavigationTips, { showArrows: true, tips: [
331
+ { key: "g/G", label: "Top/Bottom" },
332
+ { key: "p", label: isPolling ? "Pause" : "Resume" },
333
+ { key: "w", label: "Wrap" },
334
+ { key: "c", label: "Copy" },
335
+ { key: "q/esc", label: "Back" },
336
+ ] })] }));
337
+ };
@@ -16,6 +16,7 @@ import { MenuScreen } from "../screens/MenuScreen.js";
16
16
  import { DevboxListScreen } from "../screens/DevboxListScreen.js";
17
17
  import { DevboxDetailScreen } from "../screens/DevboxDetailScreen.js";
18
18
  import { DevboxActionsScreen } from "../screens/DevboxActionsScreen.js";
19
+ import { DevboxExecScreen } from "../screens/DevboxExecScreen.js";
19
20
  import { DevboxCreateScreen } from "../screens/DevboxCreateScreen.js";
20
21
  import { BlueprintListScreen } from "../screens/BlueprintListScreen.js";
21
22
  import { BlueprintDetailScreen } from "../screens/BlueprintDetailScreen.js";
@@ -47,6 +48,7 @@ export function Router() {
47
48
  case "devbox-list":
48
49
  case "devbox-detail":
49
50
  case "devbox-actions":
51
+ case "devbox-exec":
50
52
  case "devbox-create":
51
53
  // Clear devbox data when leaving devbox screens
52
54
  // Keep cache if we're still in devbox context
@@ -88,5 +90,5 @@ export function Router() {
88
90
  // and mount new component, preventing race conditions during screen transitions.
89
91
  // The key ensures React treats this as a completely new component tree.
90
92
  // Wrap in ErrorBoundary to catch any Yoga WASM errors gracefully.
91
- return (_jsxs(ErrorBoundary, { children: [currentScreen === "menu" && (_jsx(MenuScreen, { ...params }, currentScreen)), currentScreen === "devbox-list" && (_jsx(DevboxListScreen, { ...params }, currentScreen)), currentScreen === "devbox-detail" && (_jsx(DevboxDetailScreen, { ...params }, currentScreen)), currentScreen === "devbox-actions" && (_jsx(DevboxActionsScreen, { ...params }, currentScreen)), currentScreen === "devbox-create" && (_jsx(DevboxCreateScreen, { ...params }, currentScreen)), currentScreen === "blueprint-list" && (_jsx(BlueprintListScreen, { ...params }, currentScreen)), currentScreen === "blueprint-detail" && (_jsx(BlueprintDetailScreen, { ...params }, currentScreen)), currentScreen === "blueprint-logs" && (_jsx(BlueprintLogsScreen, { ...params }, currentScreen)), currentScreen === "snapshot-list" && (_jsx(SnapshotListScreen, { ...params }, currentScreen)), currentScreen === "snapshot-detail" && (_jsx(SnapshotDetailScreen, { ...params }, currentScreen)), currentScreen === "network-policy-list" && (_jsx(NetworkPolicyListScreen, { ...params }, currentScreen)), currentScreen === "network-policy-detail" && (_jsx(NetworkPolicyDetailScreen, { ...params }, currentScreen)), currentScreen === "network-policy-create" && (_jsx(NetworkPolicyCreateScreen, { ...params }, currentScreen)), currentScreen === "object-list" && (_jsx(ObjectListScreen, { ...params }, currentScreen)), currentScreen === "object-detail" && (_jsx(ObjectDetailScreen, { ...params }, currentScreen)), currentScreen === "ssh-session" && (_jsx(SSHSessionScreen, { ...params }, currentScreen))] }, `boundary-${currentScreen}`));
93
+ return (_jsxs(ErrorBoundary, { children: [currentScreen === "menu" && (_jsx(MenuScreen, { ...params }, currentScreen)), currentScreen === "devbox-list" && (_jsx(DevboxListScreen, { ...params }, currentScreen)), currentScreen === "devbox-detail" && (_jsx(DevboxDetailScreen, { ...params }, currentScreen)), currentScreen === "devbox-actions" && (_jsx(DevboxActionsScreen, { ...params }, currentScreen)), currentScreen === "devbox-exec" && (_jsx(DevboxExecScreen, { ...params }, currentScreen)), currentScreen === "devbox-create" && (_jsx(DevboxCreateScreen, { ...params }, currentScreen)), currentScreen === "blueprint-list" && (_jsx(BlueprintListScreen, { ...params }, currentScreen)), currentScreen === "blueprint-detail" && (_jsx(BlueprintDetailScreen, { ...params }, currentScreen)), currentScreen === "blueprint-logs" && (_jsx(BlueprintLogsScreen, { ...params }, currentScreen)), currentScreen === "snapshot-list" && (_jsx(SnapshotListScreen, { ...params }, currentScreen)), currentScreen === "snapshot-detail" && (_jsx(SnapshotDetailScreen, { ...params }, currentScreen)), currentScreen === "network-policy-list" && (_jsx(NetworkPolicyListScreen, { ...params }, currentScreen)), currentScreen === "network-policy-detail" && (_jsx(NetworkPolicyDetailScreen, { ...params }, currentScreen)), currentScreen === "network-policy-create" && (_jsx(NetworkPolicyCreateScreen, { ...params }, currentScreen)), currentScreen === "object-list" && (_jsx(ObjectListScreen, { ...params }, currentScreen)), currentScreen === "object-detail" && (_jsx(ObjectDetailScreen, { ...params }, currentScreen)), currentScreen === "ssh-session" && (_jsx(SSHSessionScreen, { ...params }, currentScreen))] }, `boundary-${currentScreen}`));
92
94
  }
@@ -0,0 +1,51 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * DevboxExecScreen - Dedicated screen for command execution
4
+ * Route-based state ensures stability across terminal resizes
5
+ */
6
+ import React from "react";
7
+ import { Box, Text } from "ink";
8
+ import figures from "figures";
9
+ import { useNavigation } from "../store/navigationStore.js";
10
+ import { useDevboxStore } from "../store/devboxStore.js";
11
+ import { ExecViewer } from "../components/ExecViewer.js";
12
+ import { Breadcrumb } from "../components/Breadcrumb.js";
13
+ import { colors } from "../utils/theme.js";
14
+ export function DevboxExecScreen({ devboxId, execCommand, executionId, devboxName, returnScreen, returnParams, }) {
15
+ const { goBack, replace, params } = useNavigation();
16
+ const devboxes = useDevboxStore((state) => state.devboxes);
17
+ // Find devbox in store for display name
18
+ const devbox = devboxes.find((d) => d.id === devboxId);
19
+ const displayName = devboxName || devbox?.name || devboxId || "devbox";
20
+ // Validate required params
21
+ if (!devboxId || !execCommand) {
22
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Exec", active: true }] }), _jsx(Box, { flexDirection: "column", paddingX: 1, children: _jsxs(Text, { color: colors.error, children: [figures.cross, " Missing execution parameters. Returning..."] }) })] }));
23
+ }
24
+ // Build breadcrumbs
25
+ const breadcrumbItems = [
26
+ { label: "Devboxes" },
27
+ { label: displayName },
28
+ { label: "Execute", active: true },
29
+ ];
30
+ // Handle execution ID update - store in route params for persistence
31
+ const handleExecutionStart = React.useCallback((newExecutionId) => {
32
+ // Update route params to persist execution ID across re-renders
33
+ replace("devbox-exec", {
34
+ ...params,
35
+ executionId: newExecutionId,
36
+ });
37
+ }, [replace, params]);
38
+ // Handle back navigation
39
+ const handleBack = React.useCallback(() => {
40
+ goBack();
41
+ }, [goBack]);
42
+ // Handle run another command - navigate to actions menu with exec pre-selected
43
+ const handleRunAnother = React.useCallback(() => {
44
+ // Replace current screen with actions menu, exec operation pre-selected
45
+ replace("devbox-actions", {
46
+ devboxId: devboxId,
47
+ operation: "exec",
48
+ });
49
+ }, [replace, devboxId]);
50
+ return (_jsx(ExecViewer, { devboxId: devboxId, command: execCommand, breadcrumbItems: breadcrumbItems, onBack: handleBack, onRunAnother: handleRunAnother, existingExecutionId: executionId, onExecutionStart: handleExecutionStart }));
51
+ }
@@ -96,6 +96,20 @@ export function SnapshotDetailScreen({ snapshotId, }) {
96
96
  fields: basicFields,
97
97
  });
98
98
  }
99
+ // Commit message section (if present)
100
+ if (snapshot.commit_message) {
101
+ detailSections.push({
102
+ title: "Commit Message",
103
+ icon: figures.info,
104
+ color: colors.info,
105
+ fields: [
106
+ {
107
+ label: "",
108
+ value: snapshot.commit_message,
109
+ },
110
+ ],
111
+ });
112
+ }
99
113
  // Metadata section
100
114
  if (snapshot.metadata && Object.keys(snapshot.metadata).length > 0) {
101
115
  const metadataFields = Object.entries(snapshot.metadata).map(([key, value]) => ({
@@ -172,6 +186,12 @@ export function SnapshotDetailScreen({ snapshotId, }) {
172
186
  lines.push(_jsxs(Text, { dimColor: true, children: [" ", "Disk Size: ", sizeGB, " GB"] }, "core-size"));
173
187
  }
174
188
  lines.push(_jsx(Text, { children: " " }, "core-space"));
189
+ // Commit Message
190
+ if (snap.commit_message) {
191
+ lines.push(_jsx(Text, { color: colors.warning, bold: true, children: "Commit Message" }, "commit-title"));
192
+ lines.push(_jsxs(Text, { color: colors.info, children: [" ", snap.commit_message] }, "commit-msg"));
193
+ lines.push(_jsx(Text, { children: " " }, "commit-space"));
194
+ }
175
195
  // Metadata
176
196
  if (snap.metadata && Object.keys(snap.metadata).length > 0) {
177
197
  lines.push(_jsx(Text, { color: colors.warning, bold: true, children: "Metadata" }, "meta-title"));
@@ -146,12 +146,19 @@ export async function uploadFile(id, filepath, remotePath) {
146
146
  path: remotePath,
147
147
  });
148
148
  }
149
- /**
150
- * Create snapshot of devbox
151
- */
152
- export async function createSnapshot(id, name) {
149
+ export async function createSnapshot(id, options) {
153
150
  const client = getClient();
154
- const snapshot = await client.devboxes.snapshotDisk(id, { name });
151
+ const params = {};
152
+ if (options?.name) {
153
+ params.name = options.name;
154
+ }
155
+ if (options?.metadata && Object.keys(options.metadata).length > 0) {
156
+ params.metadata = options.metadata;
157
+ }
158
+ if (options?.commit_message) {
159
+ params.commit_message = options.commit_message;
160
+ }
161
+ const snapshot = await client.devboxes.snapshotDisk(id, params);
155
162
  return {
156
163
  id: String(snapshot.id || "").substring(0, 100),
157
164
  name: snapshot.name ? String(snapshot.name).substring(0, 200) : undefined,
@@ -213,3 +220,33 @@ export async function getDevboxLogs(id) {
213
220
  // Return the logs array directly - formatting is handled by logFormatter
214
221
  return response.logs || [];
215
222
  }
223
+ /**
224
+ * Execute command asynchronously in devbox
225
+ * Used for both sync and async modes to enable kill/leave-early functionality
226
+ */
227
+ export async function execCommandAsync(id, command) {
228
+ const client = getClient();
229
+ const result = await client.devboxes.executions.executeAsync(id, { command });
230
+ // Extract execution ID from result
231
+ const executionId = result.execution_id || result.id || String(result);
232
+ return {
233
+ executionId: String(executionId).substring(0, 100),
234
+ status: result.status || "running",
235
+ };
236
+ }
237
+ /**
238
+ * Get execution status and output
239
+ * Used for polling in sync mode and checking status
240
+ */
241
+ export async function getExecution(devboxId, executionId) {
242
+ const client = getClient();
243
+ return client.devboxes.executions.retrieve(devboxId, executionId);
244
+ }
245
+ /**
246
+ * Kill a running execution
247
+ * Available in both sync and async modes
248
+ */
249
+ export async function killExecution(devboxId, executionId) {
250
+ const client = getClient();
251
+ await client.devboxes.executions.kill(devboxId, executionId);
252
+ }
@@ -26,6 +26,7 @@ export async function listSnapshots(options) {
26
26
  const MAX_ID_LENGTH = 100;
27
27
  const MAX_NAME_LENGTH = 200;
28
28
  const MAX_STATUS_LENGTH = 50;
29
+ const MAX_COMMIT_MSG_LENGTH = 1000;
29
30
  // Status is constructed/available in API response but not in type definition
30
31
  const snapshotView = s;
31
32
  snapshots.push({
@@ -33,6 +34,9 @@ export async function listSnapshots(options) {
33
34
  name: snapshotView.name
34
35
  ? String(snapshotView.name).substring(0, MAX_NAME_LENGTH)
35
36
  : undefined,
37
+ commit_message: snapshotView.commit_message
38
+ ? String(snapshotView.commit_message).substring(0, MAX_COMMIT_MSG_LENGTH)
39
+ : undefined,
36
40
  create_time_ms: snapshotView.create_time_ms,
37
41
  metadata: snapshotView.metadata || {},
38
42
  source_devbox_id: String(snapshotView.source_devbox_id || "").substring(0, MAX_ID_LENGTH),
@@ -81,6 +85,7 @@ export async function getSnapshot(id) {
81
85
  return {
82
86
  id: snapshot.id,
83
87
  name: snapshot.name || undefined,
88
+ commit_message: snapshot.commit_message || undefined,
84
89
  create_time_ms: snapshot.create_time_ms,
85
90
  metadata: snapshot.metadata || {},
86
91
  source_devbox_id: snapshot.source_devbox_id || "",
@@ -92,11 +97,19 @@ export async function getSnapshot(id) {
92
97
  /**
93
98
  * Create a snapshot
94
99
  */
95
- export async function createSnapshot(devboxId, name) {
100
+ export async function createSnapshot(devboxId, options) {
96
101
  const client = getClient();
97
- const snapshot = await client.devboxes.snapshotDisk(devboxId, {
98
- name,
99
- });
102
+ const params = {};
103
+ if (options?.name) {
104
+ params.name = options.name;
105
+ }
106
+ if (options?.commit_message) {
107
+ params.commit_message = options.commit_message;
108
+ }
109
+ if (options?.metadata && Object.keys(options.metadata).length > 0) {
110
+ params.metadata = options.metadata;
111
+ }
112
+ const snapshot = await client.devboxes.snapshotDisk(devboxId, params);
100
113
  return {
101
114
  id: snapshot.id,
102
115
  name: snapshot.name || undefined,
@@ -32,6 +32,7 @@ export function createProgram() {
32
32
  .option("--entrypoint <command>", "Entrypoint command to run")
33
33
  .option("--launch-commands <commands...>", "Initialization commands to run on startup")
34
34
  .option("--env-vars <vars...>", "Environment variables (format: KEY=value)")
35
+ .option("--secrets <secrets...>", "Secrets to inject as environment variables (format: ENV_VAR=SECRET_NAME)")
35
36
  .option("--code-mounts <mounts...>", "Code mount configurations (JSON format)")
36
37
  .option("--idle-time <seconds>", "Idle time in seconds before idle action")
37
38
  .option("--idle-action <action>", "Action on idle (shutdown, suspend)")
@@ -136,6 +137,7 @@ export function createProgram() {
136
137
  devbox
137
138
  .command("tunnel <id> <ports>")
138
139
  .description("Create a port-forwarding tunnel to a devbox")
140
+ .option("--open", "Open the tunnel URL in browser automatically")
139
141
  .option("-o, --output [format]", "Output format: text|json|yaml (default: text)")
140
142
  .action(async (id, ports, options) => {
141
143
  const { createTunnel } = await import("../commands/devbox/tunnel.js");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runloop/rl-cli",
3
- "version": "1.6.0",
3
+ "version": "1.7.1",
4
4
  "description": "Beautiful CLI for the Runloop platform",
5
5
  "type": "module",
6
6
  "bin": {