@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.
Files changed (96) hide show
  1. package/README.md +54 -0
  2. package/dist/cli.js +73 -60
  3. package/dist/commands/auth.js +0 -1
  4. package/dist/commands/blueprint/create.js +31 -83
  5. package/dist/commands/blueprint/get.js +29 -34
  6. package/dist/commands/blueprint/list.js +215 -213
  7. package/dist/commands/blueprint/logs.js +133 -37
  8. package/dist/commands/blueprint/preview.js +42 -38
  9. package/dist/commands/config.js +117 -0
  10. package/dist/commands/devbox/create.js +120 -40
  11. package/dist/commands/devbox/delete.js +17 -33
  12. package/dist/commands/devbox/download.js +29 -43
  13. package/dist/commands/devbox/exec.js +22 -39
  14. package/dist/commands/devbox/execAsync.js +20 -37
  15. package/dist/commands/devbox/get.js +13 -35
  16. package/dist/commands/devbox/getAsync.js +12 -34
  17. package/dist/commands/devbox/list.js +241 -402
  18. package/dist/commands/devbox/logs.js +20 -38
  19. package/dist/commands/devbox/read.js +29 -43
  20. package/dist/commands/devbox/resume.js +13 -35
  21. package/dist/commands/devbox/rsync.js +26 -78
  22. package/dist/commands/devbox/scp.js +25 -79
  23. package/dist/commands/devbox/sendStdin.js +41 -0
  24. package/dist/commands/devbox/shutdown.js +13 -35
  25. package/dist/commands/devbox/ssh.js +45 -78
  26. package/dist/commands/devbox/suspend.js +13 -35
  27. package/dist/commands/devbox/tunnel.js +36 -88
  28. package/dist/commands/devbox/upload.js +28 -36
  29. package/dist/commands/devbox/write.js +29 -44
  30. package/dist/commands/mcp-install.js +4 -3
  31. package/dist/commands/menu.js +24 -66
  32. package/dist/commands/object/delete.js +12 -34
  33. package/dist/commands/object/download.js +26 -74
  34. package/dist/commands/object/get.js +12 -34
  35. package/dist/commands/object/list.js +15 -93
  36. package/dist/commands/object/upload.js +35 -96
  37. package/dist/commands/snapshot/create.js +23 -39
  38. package/dist/commands/snapshot/delete.js +17 -33
  39. package/dist/commands/snapshot/get.js +16 -0
  40. package/dist/commands/snapshot/list.js +309 -80
  41. package/dist/commands/snapshot/status.js +12 -34
  42. package/dist/components/ActionsPopup.js +63 -39
  43. package/dist/components/Breadcrumb.js +10 -52
  44. package/dist/components/DevboxActionsMenu.js +182 -110
  45. package/dist/components/DevboxCreatePage.js +12 -7
  46. package/dist/components/DevboxDetailPage.js +76 -28
  47. package/dist/components/ErrorBoundary.js +29 -0
  48. package/dist/components/ErrorMessage.js +10 -2
  49. package/dist/components/Header.js +12 -4
  50. package/dist/components/InteractiveSpawn.js +94 -0
  51. package/dist/components/MainMenu.js +36 -32
  52. package/dist/components/MetadataDisplay.js +4 -4
  53. package/dist/components/OperationsMenu.js +1 -1
  54. package/dist/components/ResourceActionsMenu.js +4 -4
  55. package/dist/components/ResourceListView.js +46 -34
  56. package/dist/components/Spinner.js +7 -2
  57. package/dist/components/StatusBadge.js +1 -1
  58. package/dist/components/SuccessMessage.js +12 -2
  59. package/dist/components/Table.js +16 -6
  60. package/dist/hooks/useCursorPagination.js +125 -85
  61. package/dist/hooks/useExitOnCtrlC.js +14 -0
  62. package/dist/hooks/useViewportHeight.js +47 -0
  63. package/dist/mcp/server.js +65 -6
  64. package/dist/router/Router.js +68 -0
  65. package/dist/router/types.js +1 -0
  66. package/dist/screens/BlueprintListScreen.js +7 -0
  67. package/dist/screens/DevboxActionsScreen.js +25 -0
  68. package/dist/screens/DevboxCreateScreen.js +11 -0
  69. package/dist/screens/DevboxDetailScreen.js +60 -0
  70. package/dist/screens/DevboxListScreen.js +23 -0
  71. package/dist/screens/LogsSessionScreen.js +49 -0
  72. package/dist/screens/MenuScreen.js +23 -0
  73. package/dist/screens/SSHSessionScreen.js +55 -0
  74. package/dist/screens/SnapshotListScreen.js +7 -0
  75. package/dist/services/blueprintService.js +105 -0
  76. package/dist/services/devboxService.js +215 -0
  77. package/dist/services/snapshotService.js +81 -0
  78. package/dist/store/blueprintStore.js +89 -0
  79. package/dist/store/devboxStore.js +105 -0
  80. package/dist/store/index.js +7 -0
  81. package/dist/store/navigationStore.js +101 -0
  82. package/dist/store/snapshotStore.js +87 -0
  83. package/dist/utils/CommandExecutor.js +53 -24
  84. package/dist/utils/client.js +0 -2
  85. package/dist/utils/config.js +20 -90
  86. package/dist/utils/interactiveCommand.js +3 -2
  87. package/dist/utils/logFormatter.js +162 -0
  88. package/dist/utils/memoryMonitor.js +85 -0
  89. package/dist/utils/output.js +150 -59
  90. package/dist/utils/screen.js +23 -0
  91. package/dist/utils/ssh.js +3 -1
  92. package/dist/utils/sshSession.js +5 -29
  93. package/dist/utils/terminalDetection.js +97 -0
  94. package/dist/utils/terminalSync.js +39 -0
  95. package/dist/utils/theme.js +147 -13
  96. package/package.json +16 -13
@@ -1,90 +1,319 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useStdout } from "ink";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from "react";
3
+ import { Box, Text, useInput, useApp } from "ink";
4
+ import figures from "figures";
3
5
  import { getClient } from "../../utils/client.js";
4
- import { createTextColumn, } from "../../components/Table.js";
5
- import { ResourceListView, formatTimeAgo, } from "../../components/ResourceListView.js";
6
- import { createExecutor } from "../../utils/CommandExecutor.js";
6
+ import { Header } from "../../components/Header.js";
7
+ import { SpinnerComponent } from "../../components/Spinner.js";
8
+ import { ErrorMessage } from "../../components/ErrorMessage.js";
9
+ import { SuccessMessage } from "../../components/SuccessMessage.js";
10
+ import { Breadcrumb } from "../../components/Breadcrumb.js";
11
+ import { Table, createTextColumn } from "../../components/Table.js";
12
+ import { ActionsPopup } from "../../components/ActionsPopup.js";
13
+ import { formatTimeAgo } from "../../components/ResourceListView.js";
14
+ import { output, outputError } from "../../utils/output.js";
7
15
  import { colors } from "../../utils/theme.js";
8
- const PAGE_SIZE = 10;
9
- const MAX_FETCH = 100;
10
- const ListSnapshotsUI = ({ devboxId, onBack, onExit }) => {
11
- const { stdout } = useStdout();
12
- // Calculate responsive column widths
13
- const terminalWidth = stdout?.columns || 120;
14
- const showDevboxId = terminalWidth >= 100 && !devboxId; // Hide devbox column if filtering by devbox
15
- const showFullId = terminalWidth >= 80;
16
- const statusIconWidth = 2;
17
- const statusTextWidth = 10;
16
+ import { useViewportHeight } from "../../hooks/useViewportHeight.js";
17
+ import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js";
18
+ import { useCursorPagination } from "../../hooks/useCursorPagination.js";
19
+ const DEFAULT_PAGE_SIZE = 10;
20
+ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
21
+ const { exit: inkExit } = useApp();
22
+ const [selectedIndex, setSelectedIndex] = React.useState(0);
23
+ const [showPopup, setShowPopup] = React.useState(false);
24
+ const [selectedOperation, setSelectedOperation] = React.useState(0);
25
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
26
+ const [selectedSnapshot, setSelectedSnapshot] = React.useState(null);
27
+ const [executingOperation, setExecutingOperation] = React.useState(null);
28
+ const [operationResult, setOperationResult] = React.useState(null);
29
+ const [operationError, setOperationError] = React.useState(null);
30
+ const [operationLoading, setOperationLoading] = React.useState(false);
31
+ // Calculate overhead for viewport height
32
+ const overhead = 13;
33
+ const { viewportHeight, terminalWidth } = useViewportHeight({
34
+ overhead,
35
+ minHeight: 5,
36
+ });
37
+ const PAGE_SIZE = viewportHeight;
38
+ // All width constants
18
39
  const idWidth = 25;
19
- const nameWidth = terminalWidth >= 120 ? 30 : 25;
40
+ const nameWidth = Math.max(15, terminalWidth >= 120 ? 30 : 25);
20
41
  const devboxWidth = 15;
21
42
  const timeWidth = 20;
22
- return (_jsx(ResourceListView, { config: {
23
- resourceName: "Snapshot",
24
- resourceNamePlural: "Snapshots",
25
- fetchResources: async () => {
26
- const client = getClient();
27
- const allSnapshots = [];
28
- let count = 0;
29
- const params = devboxId ? { devbox_id: devboxId } : {};
30
- for await (const snapshot of client.devboxes.listDiskSnapshots(params)) {
31
- allSnapshots.push(snapshot);
32
- count++;
33
- if (count >= MAX_FETCH)
34
- break;
35
- }
36
- return allSnapshots;
37
- },
38
- columns: [
39
- createTextColumn("id", "ID", (snapshot) => snapshot.id, {
40
- width: idWidth,
41
- color: colors.textDim,
42
- dimColor: false,
43
- bold: false,
44
- }),
45
- createTextColumn("name", "Name", (snapshot) => snapshot.name || "(unnamed)", {
46
- width: nameWidth,
47
- }),
48
- createTextColumn("devbox", "Devbox", (snapshot) => snapshot.source_devbox_id || "", {
49
- width: devboxWidth,
50
- color: colors.primary,
51
- dimColor: false,
52
- bold: false,
53
- visible: showDevboxId,
54
- }),
55
- createTextColumn("created", "Created", (snapshot) => snapshot.create_time_ms
56
- ? formatTimeAgo(snapshot.create_time_ms)
57
- : "", {
58
- width: timeWidth,
59
- color: colors.textDim,
60
- dimColor: false,
61
- bold: false,
62
- }),
63
- ],
64
- keyExtractor: (snapshot) => snapshot.id,
65
- emptyState: {
66
- message: "No snapshots found. Try:",
67
- command: "rli snapshot create <devbox-id>",
68
- },
69
- pageSize: PAGE_SIZE,
70
- maxFetch: MAX_FETCH,
71
- onBack: onBack,
72
- onExit: onExit,
73
- breadcrumbItems: [
74
- { label: "Snapshots", active: !devboxId },
75
- ...(devboxId ? [{ label: `Devbox: ${devboxId}`, active: true }] : []),
76
- ],
77
- } }));
43
+ const showDevboxIdColumn = terminalWidth >= 100 && !devboxId;
44
+ // Fetch function for pagination hook
45
+ const fetchPage = React.useCallback(async (params) => {
46
+ const client = getClient();
47
+ const pageSnapshots = [];
48
+ // Build query params
49
+ const queryParams = {
50
+ limit: params.limit,
51
+ };
52
+ if (params.startingAt) {
53
+ queryParams.starting_after = params.startingAt;
54
+ }
55
+ if (devboxId) {
56
+ queryParams.devbox_id = devboxId;
57
+ }
58
+ // Fetch ONE page only
59
+ const page = (await client.devboxes.listDiskSnapshots(queryParams));
60
+ // Extract data and create defensive copies
61
+ if (page.snapshots && Array.isArray(page.snapshots)) {
62
+ page.snapshots.forEach((s) => {
63
+ pageSnapshots.push({
64
+ id: s.id,
65
+ name: s.name,
66
+ status: s.status,
67
+ create_time_ms: s.create_time_ms,
68
+ source_devbox_id: s.source_devbox_id,
69
+ });
70
+ });
71
+ }
72
+ const result = {
73
+ items: pageSnapshots,
74
+ hasMore: page.has_more || false,
75
+ totalCount: page.total_count || pageSnapshots.length,
76
+ };
77
+ return result;
78
+ }, [devboxId]);
79
+ // Use the shared pagination hook
80
+ const { items: snapshots, loading, navigating, error, currentPage, hasMore, hasPrev, totalCount, nextPage, prevPage, } = useCursorPagination({
81
+ fetchPage,
82
+ pageSize: PAGE_SIZE,
83
+ getItemId: (snapshot) => snapshot.id,
84
+ pollInterval: 2000,
85
+ pollingEnabled: !showPopup && !executingOperation,
86
+ deps: [devboxId, PAGE_SIZE],
87
+ });
88
+ // Operations for snapshots
89
+ const operations = React.useMemo(() => [
90
+ {
91
+ key: "delete",
92
+ label: "Delete Snapshot",
93
+ color: colors.error,
94
+ icon: figures.cross,
95
+ },
96
+ ], []);
97
+ // Build columns
98
+ const columns = React.useMemo(() => [
99
+ createTextColumn("id", "ID", (snapshot) => snapshot.id, {
100
+ width: idWidth + 1,
101
+ color: colors.idColor,
102
+ dimColor: false,
103
+ bold: false,
104
+ }),
105
+ createTextColumn("name", "Name", (snapshot) => snapshot.name || "", {
106
+ width: nameWidth,
107
+ }),
108
+ createTextColumn("devbox", "Devbox", (snapshot) => snapshot.source_devbox_id || "", {
109
+ width: devboxWidth,
110
+ color: colors.idColor,
111
+ dimColor: false,
112
+ bold: false,
113
+ visible: showDevboxIdColumn,
114
+ }),
115
+ createTextColumn("created", "Created", (snapshot) => snapshot.create_time_ms ? formatTimeAgo(snapshot.create_time_ms) : "", {
116
+ width: timeWidth,
117
+ color: colors.textDim,
118
+ dimColor: false,
119
+ bold: false,
120
+ }),
121
+ ], [idWidth, nameWidth, devboxWidth, timeWidth, showDevboxIdColumn]);
122
+ // Handle Ctrl+C to exit
123
+ useExitOnCtrlC();
124
+ // Ensure selected index is within bounds
125
+ React.useEffect(() => {
126
+ if (snapshots.length > 0 && selectedIndex >= snapshots.length) {
127
+ setSelectedIndex(Math.max(0, snapshots.length - 1));
128
+ }
129
+ }, [snapshots.length, selectedIndex]);
130
+ const selectedSnapshotItem = snapshots[selectedIndex];
131
+ // Calculate pagination info for display
132
+ const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
133
+ const startIndex = currentPage * PAGE_SIZE;
134
+ const endIndex = startIndex + snapshots.length;
135
+ const executeOperation = async () => {
136
+ const client = getClient();
137
+ const snapshot = selectedSnapshot;
138
+ if (!snapshot)
139
+ return;
140
+ try {
141
+ setOperationLoading(true);
142
+ switch (executingOperation) {
143
+ case "delete":
144
+ await client.devboxes.deleteDiskSnapshot(snapshot.id);
145
+ setOperationResult(`Snapshot ${snapshot.id} deleted successfully`);
146
+ break;
147
+ }
148
+ }
149
+ catch (err) {
150
+ setOperationError(err);
151
+ }
152
+ finally {
153
+ setOperationLoading(false);
154
+ }
155
+ };
156
+ useInput((input, key) => {
157
+ // Handle operation result display
158
+ if (operationResult || operationError) {
159
+ if (input === "q" || key.escape || key.return) {
160
+ setOperationResult(null);
161
+ setOperationError(null);
162
+ setExecutingOperation(null);
163
+ setSelectedSnapshot(null);
164
+ }
165
+ return;
166
+ }
167
+ // Handle popup navigation
168
+ if (showPopup) {
169
+ if (key.upArrow && selectedOperation > 0) {
170
+ setSelectedOperation(selectedOperation - 1);
171
+ }
172
+ else if (key.downArrow && selectedOperation < operations.length - 1) {
173
+ setSelectedOperation(selectedOperation + 1);
174
+ }
175
+ else if (key.return) {
176
+ setShowPopup(false);
177
+ const operationKey = operations[selectedOperation].key;
178
+ setSelectedSnapshot(selectedSnapshotItem);
179
+ setExecutingOperation(operationKey);
180
+ // Execute immediately after state update
181
+ setTimeout(() => executeOperation(), 0);
182
+ }
183
+ else if (key.escape || input === "q") {
184
+ setShowPopup(false);
185
+ setSelectedOperation(0);
186
+ }
187
+ else if (input === "d") {
188
+ // Delete hotkey
189
+ setShowPopup(false);
190
+ setSelectedSnapshot(selectedSnapshotItem);
191
+ setExecutingOperation("delete");
192
+ setTimeout(() => executeOperation(), 0);
193
+ }
194
+ return;
195
+ }
196
+ const pageSnapshots = snapshots.length;
197
+ // Handle list view navigation
198
+ if (key.upArrow && selectedIndex > 0) {
199
+ setSelectedIndex(selectedIndex - 1);
200
+ }
201
+ else if (key.downArrow && selectedIndex < pageSnapshots - 1) {
202
+ setSelectedIndex(selectedIndex + 1);
203
+ }
204
+ else if ((input === "n" || key.rightArrow) &&
205
+ !loading &&
206
+ !navigating &&
207
+ hasMore) {
208
+ nextPage();
209
+ setSelectedIndex(0);
210
+ }
211
+ else if ((input === "p" || key.leftArrow) &&
212
+ !loading &&
213
+ !navigating &&
214
+ hasPrev) {
215
+ prevPage();
216
+ setSelectedIndex(0);
217
+ }
218
+ else if (input === "a" && selectedSnapshotItem) {
219
+ setShowPopup(true);
220
+ setSelectedOperation(0);
221
+ }
222
+ else if (key.escape) {
223
+ if (onBack) {
224
+ onBack();
225
+ }
226
+ else if (onExit) {
227
+ onExit();
228
+ }
229
+ else {
230
+ inkExit();
231
+ }
232
+ }
233
+ });
234
+ // Operation result display
235
+ if (operationResult || operationError) {
236
+ const operationLabel = operations.find((o) => o.key === executingOperation)?.label ||
237
+ "Operation";
238
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
239
+ { label: "Snapshots" },
240
+ {
241
+ label: selectedSnapshot?.name || selectedSnapshot?.id || "Snapshot",
242
+ },
243
+ { label: operationLabel, active: true },
244
+ ] }), _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" }) })] }));
245
+ }
246
+ // Operation loading state
247
+ if (operationLoading && selectedSnapshot) {
248
+ const operationLabel = operations.find((o) => o.key === executingOperation)?.label ||
249
+ "Operation";
250
+ const messages = {
251
+ delete: "Deleting snapshot...",
252
+ };
253
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
254
+ { label: "Snapshots" },
255
+ { label: selectedSnapshot.name || selectedSnapshot.id },
256
+ { label: operationLabel, active: true },
257
+ ] }), _jsx(Header, { title: "Executing Operation" }), _jsx(SpinnerComponent, { message: messages[executingOperation] || "Please wait..." })] }));
258
+ }
259
+ // Loading state
260
+ if (loading && snapshots.length === 0) {
261
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
262
+ { label: "Snapshots", active: !devboxId },
263
+ ...(devboxId
264
+ ? [{ label: `Devbox: ${devboxId}`, active: true }]
265
+ : []),
266
+ ] }), _jsx(SpinnerComponent, { message: "Loading snapshots..." })] }));
267
+ }
268
+ // Error state
269
+ if (error) {
270
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
271
+ { label: "Snapshots", active: !devboxId },
272
+ ...(devboxId
273
+ ? [{ label: `Devbox: ${devboxId}`, active: true }]
274
+ : []),
275
+ ] }), _jsx(ErrorMessage, { message: "Failed to list snapshots", error: error })] }));
276
+ }
277
+ // Empty state
278
+ if (snapshots.length === 0) {
279
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
280
+ { label: "Snapshots", active: !devboxId },
281
+ ...(devboxId
282
+ ? [{ label: `Devbox: ${devboxId}`, active: true }]
283
+ : []),
284
+ ] }), _jsxs(Box, { children: [_jsx(Text, { color: colors.warning, children: figures.info }), _jsx(Text, { children: " No snapshots found. Try: " }), _jsxs(Text, { color: colors.primary, bold: true, children: ["rli snapshot create ", "<devbox-id>"] })] })] }));
285
+ }
286
+ // Main list view
287
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
288
+ { label: "Snapshots", active: !devboxId },
289
+ ...(devboxId ? [{ label: `Devbox: ${devboxId}`, active: true }] : []),
290
+ ] }), !showPopup && (_jsx(Table, { data: snapshots, keyExtractor: (snapshot) => snapshot.id, selectedIndex: selectedIndex, title: `snapshots[${totalCount}]`, columns: columns })), !showPopup && (_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"] }), totalPages > 1 && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), navigating ? (_jsxs(Text, { color: colors.warning, children: [figures.pointer, " Loading page ", currentPage + 1, "..."] })) : (_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Page ", currentPage + 1, " of ", totalPages] }))] })), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Showing ", startIndex + 1, "-", endIndex, " of ", totalCount] })] })), showPopup && selectedSnapshotItem && (_jsx(Box, { marginTop: 2, justifyContent: "center", children: _jsx(ActionsPopup, { devbox: selectedSnapshotItem, operations: operations.map((op) => ({
291
+ key: op.key,
292
+ label: op.label,
293
+ color: op.color,
294
+ icon: op.icon,
295
+ shortcut: op.key === "delete" ? "d" : "",
296
+ })), selectedOperation: selectedOperation, onClose: () => setShowPopup(false) }) })), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate"] }), (hasMore || hasPrev) && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 ", figures.arrowLeft, figures.arrowRight, " Page"] })), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [a] Actions"] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [Esc] Back"] })] })] }));
78
297
  };
79
298
  // Export the UI component for use in the main menu
80
299
  export { ListSnapshotsUI };
81
300
  export async function listSnapshots(options) {
82
- const executor = createExecutor(options);
83
- await executor.executeList(async () => {
84
- const client = executor.getClient();
85
- const params = options.devbox ? { devbox_id: options.devbox } : {};
86
- return executor.fetchFromIterator(client.devboxes.listDiskSnapshots(params), {
87
- limit: PAGE_SIZE,
88
- });
89
- }, () => _jsx(ListSnapshotsUI, { devboxId: options.devbox }), PAGE_SIZE);
301
+ try {
302
+ const client = getClient();
303
+ // Build query params
304
+ const queryParams = {
305
+ limit: DEFAULT_PAGE_SIZE,
306
+ };
307
+ if (options.devbox) {
308
+ queryParams.devbox_id = options.devbox;
309
+ }
310
+ // Fetch snapshots
311
+ const page = (await client.devboxes.listDiskSnapshots(queryParams));
312
+ // Extract snapshots array
313
+ const snapshots = page.snapshots || [];
314
+ output(snapshots, { format: options.output, defaultFormat: "json" });
315
+ }
316
+ catch (error) {
317
+ outputError("Failed to list snapshots", error);
318
+ }
90
319
  }
@@ -1,37 +1,15 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React from "react";
1
+ /**
2
+ * Get snapshot status command
3
+ */
3
4
  import { getClient } from "../../utils/client.js";
4
- import { Banner } from "../../components/Banner.js";
5
- import { SpinnerComponent } from "../../components/Spinner.js";
6
- import { SuccessMessage } from "../../components/SuccessMessage.js";
7
- import { ErrorMessage } from "../../components/ErrorMessage.js";
8
- import { createExecutor } from "../../utils/CommandExecutor.js";
9
- const SnapshotStatusUI = ({ snapshotId }) => {
10
- const [loading, setLoading] = React.useState(true);
11
- const [result, setResult] = React.useState(null);
12
- const [error, setError] = React.useState(null);
13
- React.useEffect(() => {
14
- const getSnapshotStatus = async () => {
15
- try {
16
- const client = getClient();
17
- const status = await client.devboxes.diskSnapshots.queryStatus(snapshotId);
18
- setResult(status);
19
- }
20
- catch (err) {
21
- setError(err);
22
- }
23
- finally {
24
- setLoading(false);
25
- }
26
- };
27
- getSnapshotStatus();
28
- }, [snapshotId]);
29
- return (_jsxs(_Fragment, { children: [_jsx(Banner, {}), loading && _jsx(SpinnerComponent, { message: "Getting snapshot status..." }), result && (_jsx(SuccessMessage, { message: "Snapshot status retrieved", details: `Snapshot ID: ${result.id}\nStatus: ${result.status}\nCreated: ${new Date(result.createdAt).toLocaleString()}` })), error && (_jsx(ErrorMessage, { message: "Failed to get snapshot status", error: error }))] }));
30
- };
5
+ import { output, outputError } from "../../utils/output.js";
31
6
  export async function getSnapshotStatus(options) {
32
- const executor = createExecutor({ output: options.outputFormat });
33
- await executor.executeAction(async () => {
34
- const client = executor.getClient();
35
- return client.devboxes.diskSnapshots.queryStatus(options.snapshotId);
36
- }, () => _jsx(SnapshotStatusUI, { snapshotId: options.snapshotId }));
7
+ try {
8
+ const client = getClient();
9
+ const status = await client.devboxes.diskSnapshots.queryStatus(options.snapshotId);
10
+ output(status, { format: options.output, defaultFormat: "json" });
11
+ }
12
+ catch (error) {
13
+ outputError("Failed to get snapshot status", error);
14
+ }
37
15
  }
@@ -2,44 +2,68 @@ 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
- export const ActionsPopup = ({ devbox, operations, selectedOperation, onClose, }) => {
6
- // Calculate the maximum width needed
7
- const maxLabelLength = Math.max(...operations.map((op) => op.label.length));
8
- const contentWidth = maxLabelLength + 12; // Content + icon + pointer + shortcuts
9
- // Strip ANSI codes to get real length, then pad
10
- const stripAnsi = (str) => str.replace(/\u001b\[[0-9;]*m/g, "");
11
- const bgLine = (content) => {
12
- const cleanLength = stripAnsi(content).length;
13
- const padding = Math.max(0, contentWidth - cleanLength);
14
- return chalk.bgBlack(content + " ".repeat(padding));
5
+ import { isLightMode } from "../utils/theme.js";
6
+ export const ActionsPopup = ({ devbox: _devbox, operations, selectedOperation, onClose: _onClose, }) => {
7
+ // Calculate max width needed for content (visible characters only)
8
+ // CRITICAL: Ensure all values are valid numbers to prevent Yoga crashes
9
+ const maxContentWidth = Math.max(...operations.map((op) => {
10
+ const lineText = `${figures.pointer} ${op.icon} ${op.label} [${op.shortcut}]`;
11
+ const len = lineText.length;
12
+ return Number.isFinite(len) && len > 0 ? len : 0;
13
+ }), `${figures.play} Quick Actions`.length, `${figures.arrowUp}${figures.arrowDown} Nav • [Enter] • [Esc] Close`.length, 40);
14
+ // Add horizontal padding to width (2 spaces on each side = 4 total)
15
+ // Plus 2 for border characters = 6 total extra
16
+ // CRITICAL: Validate all computed widths are positive integers
17
+ const contentWidth = Math.max(10, maxContentWidth + 4);
18
+ // Get background color chalk function - inverted for contrast
19
+ // In light mode (light terminal), use black background for popup
20
+ // In dark mode (dark terminal), use white background for popup
21
+ const bgColor = isLightMode() ? chalk.bgBlack : chalk.bgWhite;
22
+ const textColor = isLightMode() ? chalk.white : chalk.black;
23
+ // Helper to create background lines with proper padding including left/right margins
24
+ const createBgLine = (styledContent, plainContent) => {
25
+ const visibleLength = plainContent.length;
26
+ // CRITICAL: Validate repeat count is non-negative integer
27
+ const repeatCount = Math.max(0, Math.floor(maxContentWidth - visibleLength));
28
+ const rightPadding = " ".repeat(repeatCount);
29
+ // Apply background to left padding + content + right padding
30
+ return bgColor(" " + styledContent + rightPadding + " ");
15
31
  };
16
- // Render all lines with background
17
- const lines = [
18
- bgLine(chalk.cyan.bold(` ${figures.play} Quick Actions`)),
19
- chalk.bgBlack(" ".repeat(contentWidth)),
20
- ...operations.map((op, index) => {
21
- const isSelected = index === selectedOperation;
22
- const pointer = isSelected ? figures.pointer : " ";
23
- const content = ` ${pointer} ${op.icon} ${op.label} [${op.shortcut}]`;
24
- let styled;
25
- if (isSelected) {
26
- const colorFn = chalk[op.color];
27
- styled =
28
- typeof colorFn === "function"
29
- ? colorFn.bold(content)
30
- : chalk.white.bold(content);
31
- }
32
- else {
33
- styled = chalk.gray(content);
34
- }
35
- return bgLine(styled);
36
- }),
37
- chalk.bgBlack(" ".repeat(contentWidth)),
38
- bgLine(chalk.gray.dim(` ${figures.arrowUp}${figures.arrowDown} Nav • [Enter]`)),
39
- bgLine(chalk.gray.dim(` [Esc] Close`)),
40
- ];
41
- // Draw custom border with background to fill gaps
42
- const borderTop = chalk.cyan("╭" + "─".repeat(contentWidth) + "╮");
43
- const borderBottom = chalk.cyan("╰" + "─".repeat(contentWidth) + "╯");
44
- return (_jsx(Box, { flexDirection: "column", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: borderTop }), lines.map((line, i) => (_jsxs(Text, { children: [chalk.cyan("│"), line, chalk.cyan("│")] }, i))), _jsx(Text, { children: borderBottom })] }) }));
32
+ // Create empty line with full background
33
+ // CRITICAL: Validate repeat count is positive integer
34
+ const emptyLine = bgColor(" ".repeat(Math.max(1, Math.floor(contentWidth))));
35
+ // Create border lines with background and integrated title
36
+ const title = `${figures.play} Quick Actions`;
37
+ // The content between and ╮ should be exactly contentWidth
38
+ // Format: " title ─────"
39
+ const titleWithSpaces = ` ${title} `;
40
+ const titleTotalLength = titleWithSpaces.length + 1; // +1 for leading dash
41
+ // CRITICAL: Validate repeat counts are non-negative integers
42
+ const remainingDashes = Math.max(0, Math.floor(contentWidth - titleTotalLength));
43
+ // Use theme primary color for borders to match theme
44
+ const borderColorFn = isLightMode() ? chalk.cyan : chalk.blue;
45
+ const borderTop = bgColor(borderColorFn("╭─" + titleWithSpaces + "─".repeat(remainingDashes) + "╮"));
46
+ // CRITICAL: Validate contentWidth is a positive integer
47
+ const borderBottom = bgColor(borderColorFn("╰" + "─".repeat(Math.max(1, Math.floor(contentWidth))) + "╯"));
48
+ const borderSide = (content) => {
49
+ return bgColor(borderColorFn("│") + content + borderColorFn("│"));
50
+ };
51
+ 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
+ const isSelected = index === selectedOperation;
53
+ const pointer = isSelected ? figures.pointer : " ";
54
+ const lineText = `${pointer} ${op.icon} ${op.label} [${op.shortcut}]`;
55
+ let styledLine;
56
+ if (isSelected) {
57
+ // Selected: use operation-specific color for icon and label
58
+ const opColor = op.color;
59
+ const colorFn = chalk[opColor] || textColor;
60
+ styledLine = `${textColor(pointer)} ${colorFn(op.icon)} ${colorFn.bold(op.label)} ${textColor(`[${op.shortcut}]`)}`;
61
+ }
62
+ else {
63
+ // Unselected: gray/dim text for everything
64
+ const dimFn = isLightMode() ? chalk.gray : chalk.gray;
65
+ styledLine = `${dimFn(pointer)} ${dimFn(op.icon)} ${dimFn(op.label)} ${dimFn(`[${op.shortcut}]`)}`;
66
+ }
67
+ return (_jsx(Text, { children: borderSide(createBgLine(styledLine, lineText)) }, op.key));
68
+ }), _jsx(Text, { children: borderSide(emptyLine) }), _jsx(Text, { children: borderSide(createBgLine(textColor(`${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 })] }) }));
45
69
  };
@@ -2,57 +2,15 @@ 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
- import { VERSION } from "../cli.js";
6
- // Version check component
7
- const VersionCheck = () => {
8
- const [updateAvailable, setUpdateAvailable] = React.useState(null);
9
- const [isChecking, setIsChecking] = React.useState(true);
10
- React.useEffect(() => {
11
- const checkForUpdates = async () => {
12
- try {
13
- const currentVersion = process.env.npm_package_version || "0.0.1";
14
- const response = await fetch("https://registry.npmjs.org/@runloop/rl-cli/latest");
15
- if (response.ok) {
16
- const data = await response.json();
17
- const latestVersion = data.version;
18
- if (latestVersion && latestVersion !== currentVersion) {
19
- // Check if current version is older than latest
20
- const compareVersions = (version1, version2) => {
21
- const v1parts = version1.split('.').map(Number);
22
- const v2parts = version2.split('.').map(Number);
23
- for (let i = 0; i < Math.max(v1parts.length, v2parts.length); i++) {
24
- const v1part = v1parts[i] || 0;
25
- const v2part = v2parts[i] || 0;
26
- if (v1part > v2part)
27
- return 1;
28
- if (v1part < v2part)
29
- return -1;
30
- }
31
- return 0;
32
- };
33
- const isUpdateAvailable = compareVersions(latestVersion, currentVersion) > 0;
34
- if (isUpdateAvailable) {
35
- setUpdateAvailable(latestVersion);
36
- }
37
- }
38
- }
39
- }
40
- catch (error) {
41
- // Silently fail
42
- }
43
- finally {
44
- setIsChecking(false);
45
- }
46
- };
47
- checkForUpdates();
48
- }, []);
49
- if (isChecking || !updateAvailable) {
50
- return null;
51
- }
52
- return (_jsxs(Box, { children: [_jsx(Text, { color: colors.primary, bold: true, children: "\u2728" }), _jsxs(Text, { color: colors.text, bold: true, children: [" ", "Update available:", " "] }), _jsx(Text, { color: colors.textDim, dimColor: true, children: VERSION }), _jsxs(Text, { color: colors.primary, bold: true, children: [" ", "\u2192", " "] }), _jsx(Text, { color: colors.success, bold: true, children: updateAvailable }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 Run:", " "] }), _jsx(Text, { color: colors.primary, bold: true, children: "npm install -g @runloop/rl-cli@latest" })] }));
53
- };
54
- export const Breadcrumb = React.memo(({ items, showVersionCheck = false }) => {
5
+ export const Breadcrumb = ({ items }) => {
55
6
  const env = process.env.RUNLOOP_ENV?.toLowerCase();
56
7
  const isDevEnvironment = env === "dev";
57
- return (_jsxs(Box, { marginBottom: 1, paddingX: 1, paddingY: 0, flexDirection: "column", 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: "redBright", bold: true, children: [" ", "(dev)"] })), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u203A", " "] }), items.map((item, index) => (_jsxs(React.Fragment, { children: [_jsx(Text, { color: item.active ? colors.text : colors.textDim, bold: item.active, dimColor: !item.active, children: item.label }), index < items.length - 1 && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u203A", " "] }))] }, index)))] }), showVersionCheck && (_jsx(Box, { paddingX: 2, marginTop: 0, children: _jsx(VersionCheck, {}) }))] }));
58
- });
8
+ return (_jsx(Box, { marginBottom: 1, paddingX: 1, 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) => {
9
+ // Limit label length to prevent Yoga layout engine errors
10
+ const MAX_LABEL_LENGTH = 80;
11
+ const truncatedLabel = item.label.length > MAX_LABEL_LENGTH
12
+ ? item.label.substring(0, MAX_LABEL_LENGTH) + "..."
13
+ : item.label;
14
+ 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));
15
+ })] }) }));
16
+ };