@runloop/rl-cli 1.6.0 → 1.7.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.
@@ -0,0 +1,276 @@
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 Spinner from "ink-spinner";
9
+ import figures from "figures";
10
+ import { Breadcrumb } from "./Breadcrumb.js";
11
+ import { NavigationTips } from "./NavigationTips.js";
12
+ import { colors } from "../utils/theme.js";
13
+ import { useViewportHeight } from "../hooks/useViewportHeight.js";
14
+ import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
15
+ import { parseAnyLogEntry } from "../utils/logFormatter.js";
16
+ import { getDevboxLogs } from "../services/devboxService.js";
17
+ export const StreamingLogsViewer = ({ devboxId, breadcrumbItems = [{ label: "Logs", active: true }], onBack, }) => {
18
+ const [logs, setLogs] = React.useState([]);
19
+ const [loading, setLoading] = React.useState(true);
20
+ const [error, setError] = React.useState(null);
21
+ const [logsWrapMode, setLogsWrapMode] = React.useState(false);
22
+ const [logsScroll, setLogsScroll] = React.useState(0);
23
+ const [copyStatus, setCopyStatus] = React.useState(null);
24
+ const [autoScroll, setAutoScroll] = React.useState(true);
25
+ const [isPolling, setIsPolling] = React.useState(true);
26
+ // Refs for cleanup
27
+ const pollIntervalRef = React.useRef(null);
28
+ // Calculate viewport
29
+ const logsViewport = useViewportHeight({ overhead: 10, minHeight: 10 });
30
+ // Handle Ctrl+C
31
+ useExitOnCtrlC();
32
+ // Fetch logs function
33
+ const fetchLogs = React.useCallback(async () => {
34
+ try {
35
+ const newLogs = await getDevboxLogs(devboxId);
36
+ setLogs(newLogs);
37
+ setError(null);
38
+ if (loading)
39
+ setLoading(false);
40
+ }
41
+ catch (err) {
42
+ setError(err.message);
43
+ if (loading)
44
+ setLoading(false);
45
+ }
46
+ }, [devboxId, loading]);
47
+ // Start polling on mount
48
+ React.useEffect(() => {
49
+ // Initial fetch
50
+ fetchLogs();
51
+ // Poll every 2 seconds
52
+ pollIntervalRef.current = setInterval(() => {
53
+ if (isPolling) {
54
+ fetchLogs();
55
+ }
56
+ }, 2000);
57
+ return () => {
58
+ if (pollIntervalRef.current) {
59
+ clearInterval(pollIntervalRef.current);
60
+ }
61
+ };
62
+ }, [fetchLogs, isPolling]);
63
+ // Calculate max scroll position
64
+ const getMaxScroll = () => {
65
+ if (logsWrapMode) {
66
+ return Math.max(0, logs.length - 1);
67
+ }
68
+ else {
69
+ return Math.max(0, logs.length - logsViewport.viewportHeight);
70
+ }
71
+ };
72
+ // Auto-scroll effect
73
+ React.useEffect(() => {
74
+ if (autoScroll && logs.length > 0) {
75
+ const maxScroll = getMaxScroll();
76
+ setLogsScroll(maxScroll);
77
+ }
78
+ }, [logs.length, autoScroll, logsViewport.viewportHeight]);
79
+ // Handle input
80
+ useInput((input, key) => {
81
+ const maxScroll = getMaxScroll();
82
+ if (key.upArrow || input === "k") {
83
+ setLogsScroll(Math.max(0, logsScroll - 1));
84
+ setAutoScroll(false);
85
+ }
86
+ else if (key.downArrow || input === "j") {
87
+ const newScroll = Math.min(maxScroll, logsScroll + 1);
88
+ setLogsScroll(newScroll);
89
+ // Re-enable auto-scroll if we scroll to bottom
90
+ if (newScroll >= maxScroll) {
91
+ setAutoScroll(true);
92
+ }
93
+ }
94
+ else if (key.pageUp) {
95
+ setLogsScroll(Math.max(0, logsScroll - 10));
96
+ setAutoScroll(false);
97
+ }
98
+ else if (key.pageDown) {
99
+ const newScroll = Math.min(maxScroll, logsScroll + 10);
100
+ setLogsScroll(newScroll);
101
+ if (newScroll >= maxScroll) {
102
+ setAutoScroll(true);
103
+ }
104
+ }
105
+ else if (input === "g") {
106
+ setLogsScroll(0);
107
+ setAutoScroll(false);
108
+ }
109
+ else if (input === "G") {
110
+ setLogsScroll(maxScroll);
111
+ setAutoScroll(true);
112
+ }
113
+ else if (input === "w") {
114
+ setLogsWrapMode(!logsWrapMode);
115
+ }
116
+ else if (input === "p") {
117
+ // Toggle polling
118
+ setIsPolling(!isPolling);
119
+ }
120
+ else if (input === "c" && !key.ctrl) {
121
+ // Copy logs to clipboard (ignore if Ctrl+C for quit)
122
+ const logsText = logs
123
+ .map((log) => {
124
+ const parts = parseAnyLogEntry(log);
125
+ const cmd = parts.cmd ? `$ ${parts.cmd} ` : "";
126
+ const exitCode = parts.exitCode !== null ? `exit=${parts.exitCode} ` : "";
127
+ const shell = parts.shellName ? `(${parts.shellName}) ` : "";
128
+ return `${parts.timestamp} ${parts.level} [${parts.source}] ${shell}${cmd}${parts.message} ${exitCode}`.trim();
129
+ })
130
+ .join("\n");
131
+ const copyToClipboard = async (text) => {
132
+ const { spawn } = await import("child_process");
133
+ const platform = process.platform;
134
+ let command;
135
+ let args;
136
+ if (platform === "darwin") {
137
+ command = "pbcopy";
138
+ args = [];
139
+ }
140
+ else if (platform === "win32") {
141
+ command = "clip";
142
+ args = [];
143
+ }
144
+ else {
145
+ command = "xclip";
146
+ args = ["-selection", "clipboard"];
147
+ }
148
+ const proc = spawn(command, args);
149
+ proc.stdin.write(text);
150
+ proc.stdin.end();
151
+ proc.on("exit", (code) => {
152
+ if (code === 0) {
153
+ setCopyStatus("Copied!");
154
+ setTimeout(() => setCopyStatus(null), 2000);
155
+ }
156
+ else {
157
+ setCopyStatus("Failed");
158
+ setTimeout(() => setCopyStatus(null), 2000);
159
+ }
160
+ });
161
+ proc.on("error", () => {
162
+ setCopyStatus("Not supported");
163
+ setTimeout(() => setCopyStatus(null), 2000);
164
+ });
165
+ };
166
+ copyToClipboard(logsText);
167
+ }
168
+ else if (input === "q" || key.escape || key.return) {
169
+ onBack();
170
+ }
171
+ });
172
+ const viewportHeight = Math.max(1, logsViewport.viewportHeight);
173
+ const terminalWidth = logsViewport.terminalWidth;
174
+ const boxChrome = 8;
175
+ const contentWidth = Math.max(40, terminalWidth - boxChrome);
176
+ // Helper to sanitize log message
177
+ const sanitizeMessage = (message) => {
178
+ const strippedAnsi = message.replace(
179
+ // eslint-disable-next-line no-control-regex
180
+ /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
181
+ return (strippedAnsi
182
+ .replace(/\r\n/g, " ")
183
+ .replace(/\n/g, " ")
184
+ .replace(/\r/g, " ")
185
+ .replace(/\t/g, " ")
186
+ // eslint-disable-next-line no-control-regex
187
+ .replace(/[\x00-\x1F]/g, ""));
188
+ };
189
+ // Calculate visible logs
190
+ let visibleLogs;
191
+ let actualScroll;
192
+ if (logsWrapMode) {
193
+ actualScroll = Math.min(logsScroll, Math.max(0, logs.length - 1));
194
+ visibleLogs = [];
195
+ let lineCount = 0;
196
+ for (let i = actualScroll; i < logs.length; i++) {
197
+ const parts = parseAnyLogEntry(logs[i]);
198
+ const sanitized = sanitizeMessage(parts.message);
199
+ const totalLength = parts.timestamp.length +
200
+ parts.level.length +
201
+ parts.source.length +
202
+ sanitized.length +
203
+ 10;
204
+ const entryLines = Math.ceil(totalLength / contentWidth);
205
+ if (lineCount + entryLines > viewportHeight && visibleLogs.length > 0) {
206
+ break;
207
+ }
208
+ visibleLogs.push(logs[i]);
209
+ lineCount += entryLines;
210
+ }
211
+ }
212
+ else {
213
+ const maxScroll = Math.max(0, logs.length - viewportHeight);
214
+ actualScroll = Math.min(logsScroll, maxScroll);
215
+ visibleLogs = logs.slice(actualScroll, actualScroll + viewportHeight);
216
+ }
217
+ const hasMore = actualScroll + visibleLogs.length < logs.length;
218
+ const hasLess = actualScroll > 0;
219
+ // Color maps
220
+ const levelColorMap = {
221
+ red: colors.error,
222
+ yellow: colors.warning,
223
+ blue: colors.primary,
224
+ gray: colors.textDim,
225
+ };
226
+ const sourceColorMap = {
227
+ magenta: "#d33682",
228
+ cyan: colors.info,
229
+ green: colors.success,
230
+ yellow: colors.warning,
231
+ gray: colors.textDim,
232
+ white: colors.text,
233
+ };
234
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems }), _jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.border, paddingX: 1, height: viewportHeight + 2, children: loading ? (_jsxs(Box, { children: [_jsx(Text, { color: colors.info, children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { color: colors.textDim, children: " Loading logs..." })] })) : error ? (_jsxs(Text, { color: colors.error, children: [figures.cross, " Error: ", error] })) : logs.length === 0 ? (_jsxs(Box, { children: [isPolling && (_jsxs(Text, { color: colors.info, children: [_jsx(Spinner, { type: "dots" }), " "] })), _jsx(Text, { color: colors.textDim, children: "Waiting for logs..." })] })) : (visibleLogs.map((log, index) => {
235
+ const parts = parseAnyLogEntry(log);
236
+ const sanitizedMessage = sanitizeMessage(parts.message);
237
+ const MAX_MESSAGE_LENGTH = 1000;
238
+ const fullMessage = sanitizedMessage.length > MAX_MESSAGE_LENGTH
239
+ ? sanitizedMessage.substring(0, MAX_MESSAGE_LENGTH) + "..."
240
+ : sanitizedMessage;
241
+ const cmd = parts.cmd
242
+ ? `$ ${parts.cmd.substring(0, 40)}${parts.cmd.length > 40 ? "..." : ""} `
243
+ : "";
244
+ const exitCode = parts.exitCode !== null ? `exit=${parts.exitCode} ` : "";
245
+ const levelColor = levelColorMap[parts.levelColor] || colors.textDim;
246
+ const sourceColor = sourceColorMap[parts.sourceColor] || colors.textDim;
247
+ if (logsWrapMode) {
248
+ return (_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] }))] }) }, index));
249
+ }
250
+ else {
251
+ const shellPart = parts.shellName ? `(${parts.shellName}) ` : "";
252
+ const exitPart = exitCode ? ` ${exitCode}` : "";
253
+ const prefix = `${parts.timestamp} ${parts.level} [${parts.source}] ${shellPart}${cmd}`;
254
+ const suffix = exitPart;
255
+ const availableForMessage = contentWidth - prefix.length - suffix.length;
256
+ let displayMessage;
257
+ if (availableForMessage <= 3) {
258
+ displayMessage = "";
259
+ }
260
+ else if (fullMessage.length <= availableForMessage) {
261
+ displayMessage = fullMessage;
262
+ }
263
+ else {
264
+ displayMessage =
265
+ fullMessage.substring(0, availableForMessage - 3) + "...";
266
+ }
267
+ return (_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] }))] }) }, index));
268
+ }
269
+ })) }), _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 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: colors.success, children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { color: colors.success, children: " Live" })] })) : (_jsx(Text, { color: colors.textDim, children: "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: [
270
+ { key: "g/G", label: "Top/Bottom" },
271
+ { key: "p", label: isPolling ? "Pause" : "Resume" },
272
+ { key: "w", label: "Wrap" },
273
+ { key: "c", label: "Copy" },
274
+ { key: "q/esc", label: "Back" },
275
+ ] })] }));
276
+ };
@@ -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.0",
4
4
  "description": "Beautiful CLI for the Runloop platform",
5
5
  "type": "module",
6
6
  "bin": {