@runloop/rl-cli 1.7.1 → 1.9.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 (73) hide show
  1. package/README.md +19 -5
  2. package/dist/cli.js +0 -0
  3. package/dist/commands/blueprint/delete.js +21 -0
  4. package/dist/commands/blueprint/list.js +226 -174
  5. package/dist/commands/blueprint/prune.js +13 -28
  6. package/dist/commands/devbox/create.js +41 -0
  7. package/dist/commands/devbox/list.js +125 -109
  8. package/dist/commands/devbox/tunnel.js +4 -19
  9. package/dist/commands/gateway-config/create.js +44 -0
  10. package/dist/commands/gateway-config/delete.js +21 -0
  11. package/dist/commands/gateway-config/get.js +15 -0
  12. package/dist/commands/gateway-config/list.js +493 -0
  13. package/dist/commands/gateway-config/update.js +60 -0
  14. package/dist/commands/menu.js +2 -1
  15. package/dist/commands/secret/list.js +379 -4
  16. package/dist/commands/snapshot/list.js +11 -2
  17. package/dist/commands/snapshot/prune.js +265 -0
  18. package/dist/components/BenchmarkMenu.js +108 -0
  19. package/dist/components/DetailedInfoView.js +20 -0
  20. package/dist/components/DevboxActionsMenu.js +9 -61
  21. package/dist/components/DevboxCreatePage.js +531 -14
  22. package/dist/components/DevboxDetailPage.js +27 -22
  23. package/dist/components/GatewayConfigCreatePage.js +265 -0
  24. package/dist/components/LogsViewer.js +6 -40
  25. package/dist/components/MainMenu.js +63 -22
  26. package/dist/components/ResourceDetailPage.js +143 -160
  27. package/dist/components/ResourceListView.js +3 -33
  28. package/dist/components/ResourcePicker.js +220 -0
  29. package/dist/components/SecretCreatePage.js +183 -0
  30. package/dist/components/SettingsMenu.js +95 -0
  31. package/dist/components/StateHistory.js +1 -20
  32. package/dist/components/StatusBadge.js +80 -0
  33. package/dist/components/StreamingLogsViewer.js +8 -42
  34. package/dist/components/form/FormTextInput.js +4 -2
  35. package/dist/components/resourceDetailTypes.js +18 -0
  36. package/dist/hooks/useInputHandler.js +103 -0
  37. package/dist/router/Router.js +99 -2
  38. package/dist/screens/BenchmarkDetailScreen.js +163 -0
  39. package/dist/screens/BenchmarkJobCreateScreen.js +524 -0
  40. package/dist/screens/BenchmarkJobDetailScreen.js +614 -0
  41. package/dist/screens/BenchmarkJobListScreen.js +479 -0
  42. package/dist/screens/BenchmarkListScreen.js +266 -0
  43. package/dist/screens/BenchmarkMenuScreen.js +29 -0
  44. package/dist/screens/BenchmarkRunDetailScreen.js +425 -0
  45. package/dist/screens/BenchmarkRunListScreen.js +275 -0
  46. package/dist/screens/BlueprintDetailScreen.js +5 -1
  47. package/dist/screens/DevboxCreateScreen.js +2 -2
  48. package/dist/screens/GatewayConfigDetailScreen.js +236 -0
  49. package/dist/screens/GatewayConfigListScreen.js +7 -0
  50. package/dist/screens/MenuScreen.js +5 -2
  51. package/dist/screens/ScenarioRunDetailScreen.js +226 -0
  52. package/dist/screens/ScenarioRunListScreen.js +245 -0
  53. package/dist/screens/SecretCreateScreen.js +7 -0
  54. package/dist/screens/SecretDetailScreen.js +198 -0
  55. package/dist/screens/SecretListScreen.js +7 -0
  56. package/dist/screens/SettingsMenuScreen.js +26 -0
  57. package/dist/screens/SnapshotDetailScreen.js +6 -0
  58. package/dist/services/agentService.js +42 -0
  59. package/dist/services/benchmarkJobService.js +122 -0
  60. package/dist/services/benchmarkService.js +120 -0
  61. package/dist/services/gatewayConfigService.js +114 -0
  62. package/dist/services/scenarioService.js +34 -0
  63. package/dist/store/benchmarkJobStore.js +66 -0
  64. package/dist/store/benchmarkStore.js +183 -0
  65. package/dist/store/betaFeatureStore.js +47 -0
  66. package/dist/store/gatewayConfigStore.js +83 -0
  67. package/dist/store/index.js +1 -0
  68. package/dist/utils/browser.js +22 -0
  69. package/dist/utils/clipboard.js +41 -0
  70. package/dist/utils/commands.js +80 -0
  71. package/dist/utils/config.js +8 -0
  72. package/dist/utils/time.js +121 -0
  73. package/package.json +42 -43
@@ -1,9 +1,384 @@
1
- /**
2
- * List secrets command
3
- */
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";
4
5
  import { getClient } from "../../utils/client.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 { NavigationTips } from "../../components/NavigationTips.js";
12
+ import { Table, createTextColumn } from "../../components/Table.js";
13
+ import { ActionsPopup } from "../../components/ActionsPopup.js";
14
+ import { formatTimeAgo } from "../../components/ResourceListView.js";
15
+ import { SearchBar } from "../../components/SearchBar.js";
5
16
  import { output, outputError } from "../../utils/output.js";
6
- const DEFAULT_PAGE_SIZE = 20;
17
+ import { colors } from "../../utils/theme.js";
18
+ import { useViewportHeight } from "../../hooks/useViewportHeight.js";
19
+ import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js";
20
+ import { useCursorPagination } from "../../hooks/useCursorPagination.js";
21
+ import { useListSearch } from "../../hooks/useListSearch.js";
22
+ import { useNavigation } from "../../store/navigationStore.js";
23
+ import { ConfirmationPrompt } from "../../components/ConfirmationPrompt.js";
24
+ const DEFAULT_PAGE_SIZE = 10;
25
+ const ListSecretsUI = ({ onBack, onExit, }) => {
26
+ const { exit: inkExit } = useApp();
27
+ const { navigate } = useNavigation();
28
+ const [selectedIndex, setSelectedIndex] = React.useState(0);
29
+ const [showPopup, setShowPopup] = React.useState(false);
30
+ const [selectedOperation, setSelectedOperation] = React.useState(0);
31
+ const [selectedSecret, setSelectedSecret] = React.useState(null);
32
+ const [executingOperation, setExecutingOperation] = React.useState(null);
33
+ const [operationResult, setOperationResult] = React.useState(null);
34
+ const [operationError, setOperationError] = React.useState(null);
35
+ const [operationLoading, setOperationLoading] = React.useState(false);
36
+ const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false);
37
+ // Search state
38
+ const search = useListSearch({
39
+ onSearchSubmit: () => setSelectedIndex(0),
40
+ onSearchClear: () => setSelectedIndex(0),
41
+ });
42
+ // Calculate overhead for viewport height
43
+ const overhead = 13 + search.getSearchOverhead();
44
+ const { viewportHeight, terminalWidth } = useViewportHeight({
45
+ overhead,
46
+ minHeight: 5,
47
+ });
48
+ const PAGE_SIZE = viewportHeight;
49
+ // All width constants
50
+ const fixedWidth = 6; // border + padding
51
+ const idWidth = 30;
52
+ const timeWidth = 20;
53
+ // Name width uses remaining space after fixed columns
54
+ const baseWidth = fixedWidth + idWidth + timeWidth;
55
+ const remainingWidth = terminalWidth - baseWidth;
56
+ const nameWidth = Math.min(80, Math.max(15, remainingWidth));
57
+ // Fetch function for pagination hook
58
+ const fetchPage = React.useCallback(async (params) => {
59
+ const client = getClient();
60
+ const pageSecrets = [];
61
+ // Secrets API doesn't support cursor pagination, fetch all and paginate client-side
62
+ const result = await client.secrets.list({ limit: 5000 });
63
+ // Extract data and filter by search if needed
64
+ if (result.secrets && Array.isArray(result.secrets)) {
65
+ let filtered = result.secrets;
66
+ // Client-side search filtering
67
+ if (search.submittedSearchQuery) {
68
+ const query = search.submittedSearchQuery.toLowerCase();
69
+ filtered = filtered.filter((s) => s.name?.toLowerCase().includes(query) ||
70
+ s.id?.toLowerCase().includes(query));
71
+ }
72
+ // Client-side pagination
73
+ const startIdx = params.startingAt
74
+ ? filtered.findIndex((s) => s.id === params.startingAt) + 1
75
+ : 0;
76
+ const pageItems = filtered.slice(startIdx, startIdx + params.limit);
77
+ pageItems.forEach((s) => {
78
+ pageSecrets.push({
79
+ id: s.id,
80
+ name: s.name,
81
+ create_time_ms: s.create_time_ms,
82
+ update_time_ms: s.update_time_ms,
83
+ });
84
+ });
85
+ return {
86
+ items: pageSecrets,
87
+ hasMore: startIdx + params.limit < filtered.length,
88
+ totalCount: filtered.length,
89
+ };
90
+ }
91
+ return {
92
+ items: pageSecrets,
93
+ hasMore: false,
94
+ totalCount: 0,
95
+ };
96
+ }, [search.submittedSearchQuery]);
97
+ // Use the shared pagination hook
98
+ const { items: secrets, loading, navigating, error, currentPage, hasMore, hasPrev, totalCount, nextPage, prevPage, refresh, } = useCursorPagination({
99
+ fetchPage,
100
+ pageSize: PAGE_SIZE,
101
+ getItemId: (secret) => secret.id,
102
+ pollInterval: 10000,
103
+ pollingEnabled: !showPopup &&
104
+ !executingOperation &&
105
+ !showDeleteConfirm &&
106
+ !search.searchMode,
107
+ deps: [PAGE_SIZE, search.submittedSearchQuery],
108
+ });
109
+ // Operations for a specific secret (shown in popup)
110
+ const operations = React.useMemo(() => [
111
+ {
112
+ key: "view_details",
113
+ label: "View Details",
114
+ color: colors.primary,
115
+ icon: figures.pointer,
116
+ },
117
+ {
118
+ key: "delete",
119
+ label: "Delete Secret",
120
+ color: colors.error,
121
+ icon: figures.cross,
122
+ },
123
+ ], []);
124
+ // Build columns
125
+ const columns = React.useMemo(() => [
126
+ createTextColumn("id", "ID", (secret) => secret.id, {
127
+ width: idWidth + 1,
128
+ color: colors.idColor,
129
+ dimColor: false,
130
+ bold: false,
131
+ }),
132
+ createTextColumn("name", "Name", (secret) => secret.name || "", {
133
+ width: nameWidth,
134
+ }),
135
+ createTextColumn("created", "Created", (secret) => secret.create_time_ms ? formatTimeAgo(secret.create_time_ms) : "-", {
136
+ width: timeWidth,
137
+ color: colors.textDim,
138
+ dimColor: false,
139
+ bold: false,
140
+ }),
141
+ ], [idWidth, nameWidth, timeWidth]);
142
+ // Handle Ctrl+C to exit
143
+ useExitOnCtrlC();
144
+ // Ensure selected index is within bounds
145
+ React.useEffect(() => {
146
+ if (secrets.length > 0 && selectedIndex >= secrets.length) {
147
+ setSelectedIndex(Math.max(0, secrets.length - 1));
148
+ }
149
+ }, [secrets.length, selectedIndex]);
150
+ const selectedSecretItem = secrets[selectedIndex];
151
+ // Calculate pagination info for display
152
+ const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
153
+ const startIndex = currentPage * PAGE_SIZE;
154
+ const endIndex = startIndex + secrets.length;
155
+ const executeOperation = async (secret, operationKey) => {
156
+ const client = getClient();
157
+ if (!secret)
158
+ return;
159
+ try {
160
+ setOperationLoading(true);
161
+ switch (operationKey) {
162
+ case "delete":
163
+ await client.secrets.delete(secret.name);
164
+ setOperationResult(`Secret "${secret.name}" deleted successfully`);
165
+ break;
166
+ }
167
+ }
168
+ catch (err) {
169
+ setOperationError(err);
170
+ }
171
+ finally {
172
+ setOperationLoading(false);
173
+ }
174
+ };
175
+ useInput((input, key) => {
176
+ // Handle search mode input
177
+ if (search.searchMode) {
178
+ if (key.escape) {
179
+ search.cancelSearch();
180
+ }
181
+ return;
182
+ }
183
+ // Handle operation result display
184
+ if (operationResult || operationError) {
185
+ if (input === "q" || key.escape || key.return) {
186
+ const wasDelete = executingOperation === "delete";
187
+ const hadError = operationError !== null;
188
+ setOperationResult(null);
189
+ setOperationError(null);
190
+ setExecutingOperation(null);
191
+ setSelectedSecret(null);
192
+ // Refresh the list after delete to show updated data
193
+ if (wasDelete && !hadError) {
194
+ setTimeout(() => refresh(), 0);
195
+ }
196
+ }
197
+ return;
198
+ }
199
+ // Handle popup navigation
200
+ if (showPopup) {
201
+ if (key.upArrow && selectedOperation > 0) {
202
+ setSelectedOperation(selectedOperation - 1);
203
+ }
204
+ else if (key.downArrow && selectedOperation < operations.length - 1) {
205
+ setSelectedOperation(selectedOperation + 1);
206
+ }
207
+ else if (key.return) {
208
+ setShowPopup(false);
209
+ const operationKey = operations[selectedOperation].key;
210
+ if (operationKey === "view_details") {
211
+ navigate("secret-detail", {
212
+ secretId: selectedSecretItem.id,
213
+ });
214
+ }
215
+ else if (operationKey === "delete") {
216
+ // Show delete confirmation
217
+ setSelectedSecret(selectedSecretItem);
218
+ setShowDeleteConfirm(true);
219
+ }
220
+ else {
221
+ setSelectedSecret(selectedSecretItem);
222
+ setExecutingOperation(operationKey);
223
+ // Execute immediately with values passed directly
224
+ executeOperation(selectedSecretItem, operationKey);
225
+ }
226
+ }
227
+ else if (input === "c") {
228
+ // Create hotkey
229
+ setShowPopup(false);
230
+ navigate("secret-create");
231
+ }
232
+ else if (input === "v" && selectedSecretItem) {
233
+ // View details hotkey
234
+ setShowPopup(false);
235
+ navigate("secret-detail", {
236
+ secretId: selectedSecretItem.id,
237
+ });
238
+ }
239
+ else if (key.escape || input === "q") {
240
+ setShowPopup(false);
241
+ setSelectedOperation(0);
242
+ }
243
+ else if (input === "d") {
244
+ // Delete hotkey - show confirmation
245
+ setShowPopup(false);
246
+ setSelectedSecret(selectedSecretItem);
247
+ setShowDeleteConfirm(true);
248
+ }
249
+ return;
250
+ }
251
+ const pageSecrets = secrets.length;
252
+ // Handle list view navigation
253
+ if (key.upArrow && selectedIndex > 0) {
254
+ setSelectedIndex(selectedIndex - 1);
255
+ }
256
+ else if (key.downArrow && selectedIndex < pageSecrets - 1) {
257
+ setSelectedIndex(selectedIndex + 1);
258
+ }
259
+ else if ((input === "n" || key.rightArrow) &&
260
+ !loading &&
261
+ !navigating &&
262
+ hasMore) {
263
+ nextPage();
264
+ setSelectedIndex(0);
265
+ }
266
+ else if ((input === "p" || key.leftArrow) &&
267
+ !loading &&
268
+ !navigating &&
269
+ hasPrev) {
270
+ prevPage();
271
+ setSelectedIndex(0);
272
+ }
273
+ else if (key.return && selectedSecretItem) {
274
+ // Enter key navigates to detail view
275
+ navigate("secret-detail", {
276
+ secretId: selectedSecretItem.id,
277
+ });
278
+ }
279
+ else if (input === "a") {
280
+ setShowPopup(true);
281
+ setSelectedOperation(0);
282
+ }
283
+ else if (input === "c") {
284
+ // Create shortcut
285
+ navigate("secret-create");
286
+ }
287
+ else if (input === "/") {
288
+ search.enterSearchMode();
289
+ }
290
+ else if (key.escape) {
291
+ if (search.handleEscape()) {
292
+ return;
293
+ }
294
+ if (onBack) {
295
+ onBack();
296
+ }
297
+ else if (onExit) {
298
+ onExit();
299
+ }
300
+ else {
301
+ inkExit();
302
+ }
303
+ }
304
+ });
305
+ // Delete confirmation
306
+ if (showDeleteConfirm && selectedSecret) {
307
+ return (_jsx(ConfirmationPrompt, { title: "Delete Secret", message: `Are you sure you want to delete "${selectedSecret.name}"?`, details: "This action cannot be undone. Any devboxes using this secret will no longer have access to it.", breadcrumbItems: [
308
+ { label: "Settings" },
309
+ { label: "Secrets" },
310
+ { label: selectedSecret.name || selectedSecret.id },
311
+ { label: "Delete", active: true },
312
+ ], onConfirm: () => {
313
+ setShowDeleteConfirm(false);
314
+ setExecutingOperation("delete");
315
+ executeOperation(selectedSecret, "delete");
316
+ }, onCancel: () => {
317
+ setShowDeleteConfirm(false);
318
+ setSelectedSecret(null);
319
+ } }));
320
+ }
321
+ // Operation result display
322
+ if (operationResult || operationError) {
323
+ const operationLabel = operations.find((o) => o.key === executingOperation)?.label ||
324
+ "Operation";
325
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
326
+ { label: "Settings" },
327
+ { label: "Secrets" },
328
+ {
329
+ label: selectedSecret?.name || selectedSecret?.id || "Secret",
330
+ },
331
+ { label: operationLabel, active: true },
332
+ ] }), _jsx(Header, { title: "Operation Result" }), operationResult && _jsx(SuccessMessage, { message: operationResult }), operationError && (_jsx(ErrorMessage, { message: "Operation failed", error: operationError })), _jsx(NavigationTips, { tips: [{ key: "Enter/q/esc", label: "Continue" }] })] }));
333
+ }
334
+ // Operation loading state
335
+ if (operationLoading && selectedSecret) {
336
+ const operationLabel = operations.find((o) => o.key === executingOperation)?.label ||
337
+ "Operation";
338
+ const messages = {
339
+ delete: "Deleting secret...",
340
+ };
341
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
342
+ { label: "Settings" },
343
+ { label: "Secrets" },
344
+ { label: selectedSecret.name || selectedSecret.id },
345
+ { label: operationLabel, active: true },
346
+ ] }), _jsx(Header, { title: "Executing Operation" }), _jsx(SpinnerComponent, { message: messages[executingOperation] || "Please wait..." })] }));
347
+ }
348
+ // Loading state
349
+ if (loading && secrets.length === 0) {
350
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Settings" }, { label: "Secrets", active: true }] }), _jsx(SpinnerComponent, { message: "Loading secrets..." })] }));
351
+ }
352
+ // Error state
353
+ if (error) {
354
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Settings" }, { label: "Secrets", active: true }] }), _jsx(ErrorMessage, { message: "Failed to list secrets", error: error })] }));
355
+ }
356
+ // Main list view
357
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Settings" }, { label: "Secrets", active: true }] }), _jsx(SearchBar, { searchMode: search.searchMode, searchQuery: search.searchQuery, submittedSearchQuery: search.submittedSearchQuery, resultCount: totalCount, onSearchChange: search.setSearchQuery, onSearchSubmit: search.submitSearch, placeholder: "Search secrets..." }), !showPopup && (_jsx(Table, { data: secrets, keyExtractor: (secret) => secret.id, selectedIndex: selectedIndex, title: `secrets[${totalCount}]`, columns: columns, emptyState: _jsxs(Text, { color: colors.textDim, children: [figures.info, " No secrets found. Press [c] to create one."] }) })), !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] }), search.submittedSearchQuery && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.warning, children: ["Filtered: \"", search.submittedSearchQuery, "\""] })] }))] })), showPopup && selectedSecretItem && (_jsx(Box, { marginTop: 2, justifyContent: "center", children: _jsx(ActionsPopup, { devbox: selectedSecretItem, operations: operations.map((op) => ({
358
+ key: op.key,
359
+ label: op.label,
360
+ color: op.color,
361
+ icon: op.icon,
362
+ shortcut: op.key === "view_details"
363
+ ? "v"
364
+ : op.key === "delete"
365
+ ? "d"
366
+ : "",
367
+ })), selectedOperation: selectedOperation, onClose: () => setShowPopup(false) }) })), _jsx(NavigationTips, { showArrows: true, tips: [
368
+ {
369
+ icon: `${figures.arrowLeft}${figures.arrowRight}`,
370
+ label: "Page",
371
+ condition: hasMore || hasPrev,
372
+ },
373
+ { key: "Enter", label: "Details" },
374
+ { key: "c", label: "Create" },
375
+ { key: "a", label: "Actions" },
376
+ { key: "/", label: "Search" },
377
+ { key: "Esc", label: "Back" },
378
+ ] })] }));
379
+ };
380
+ // Export the UI component for use in the main menu
381
+ export { ListSecretsUI };
7
382
  export async function listSecrets(options = {}) {
8
383
  try {
9
384
  const client = getClient();
@@ -436,8 +436,17 @@ export async function listSnapshots(options) {
436
436
  }
437
437
  // Fetch snapshots
438
438
  const page = (await client.devboxes.listDiskSnapshots(queryParams));
439
- // Extract snapshots array
440
- const snapshots = page.snapshots || [];
439
+ // Extract snapshots array and strip to plain objects to avoid
440
+ // camelCase aliases added by the API client library
441
+ const snapshots = (page.snapshots || []).map((s) => ({
442
+ id: s.id,
443
+ name: s.name ?? undefined,
444
+ create_time_ms: s.create_time_ms,
445
+ metadata: s.metadata,
446
+ source_devbox_id: s.source_devbox_id,
447
+ source_blueprint_id: s.source_blueprint_id ?? undefined,
448
+ commit_message: s.commit_message ?? undefined,
449
+ }));
441
450
  output(snapshots, { format: options.output, defaultFormat: "json" });
442
451
  }
443
452
  catch (error) {
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Snapshot prune command - Delete old snapshots for a given source devbox
3
+ */
4
+ import * as readline from "readline";
5
+ import { getClient } from "../../utils/client.js";
6
+ import { output, outputError } from "../../utils/output.js";
7
+ import { formatRelativeTime } from "../../utils/time.js";
8
+ /**
9
+ * Query the async status for a snapshot and return a normalized status string.
10
+ * Maps API statuses: "complete" → "ready", others passed through.
11
+ */
12
+ async function querySnapshotStatus(snapshotId) {
13
+ const client = getClient();
14
+ try {
15
+ const statusResponse = await client.devboxes.diskSnapshots.queryStatus(snapshotId);
16
+ const operationStatus = statusResponse.status;
17
+ return operationStatus === "complete" ? "ready" : operationStatus;
18
+ }
19
+ catch {
20
+ return "unknown";
21
+ }
22
+ }
23
+ /**
24
+ * Fetch all snapshots for a given source devbox (handles pagination)
25
+ * and enrich each snapshot with its async operation status.
26
+ */
27
+ async function fetchAllSnapshotsForDevbox(devboxId) {
28
+ const client = getClient();
29
+ const allSnapshots = [];
30
+ let hasMore = true;
31
+ let startingAfter = undefined;
32
+ while (hasMore) {
33
+ const params = {
34
+ devbox_id: devboxId,
35
+ limit: 100,
36
+ };
37
+ if (startingAfter) {
38
+ params.starting_after = startingAfter;
39
+ }
40
+ try {
41
+ const page = await client.devboxes.listDiskSnapshots(params);
42
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
43
+ const snapshots = (page.snapshots || []);
44
+ allSnapshots.push(...snapshots);
45
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
46
+ hasMore = page.has_more || false;
47
+ if (hasMore && snapshots.length > 0) {
48
+ startingAfter = snapshots[snapshots.length - 1].id;
49
+ }
50
+ else {
51
+ hasMore = false;
52
+ }
53
+ }
54
+ catch (error) {
55
+ console.error("Warning: Error fetching snapshots:", error);
56
+ // Continue with partial results
57
+ hasMore = false;
58
+ }
59
+ }
60
+ // The listDiskSnapshots endpoint does not include status — query it for each snapshot
61
+ const enriched = await Promise.all(allSnapshots.map(async (snapshot) => ({
62
+ ...snapshot,
63
+ status: await querySnapshotStatus(snapshot.id),
64
+ })));
65
+ return enriched;
66
+ }
67
+ /**
68
+ * Categorize snapshots into successful and failed, and determine what to keep/delete
69
+ */
70
+ function categorizeSnapshots(snapshots, keepCount) {
71
+ // Filter successful snapshots (status "ready" means completed successfully)
72
+ const successful = snapshots.filter((s) => s.status === "ready");
73
+ // Filter failed/incomplete snapshots
74
+ const failed = snapshots.filter((s) => s.status !== "ready");
75
+ // Sort successful by create_time_ms descending (newest first)
76
+ successful.sort((a, b) => (b.create_time_ms || 0) - (a.create_time_ms || 0));
77
+ // Determine what to keep and delete
78
+ const toKeep = successful.slice(0, keepCount);
79
+ const toDelete = [...successful.slice(keepCount), ...failed];
80
+ return {
81
+ toKeep,
82
+ toDelete,
83
+ successful,
84
+ failed,
85
+ };
86
+ }
87
+ /**
88
+ * Display a summary of what will be kept and deleted
89
+ */
90
+ function displaySummary(devboxId, result, isDryRun) {
91
+ const total = result.successful.length + result.failed.length;
92
+ console.log(`\nAnalyzing snapshots for devbox "${devboxId}"...`);
93
+ console.log(`\nFound ${total} snapshot${total !== 1 ? "s" : ""}:`);
94
+ console.log(` ✓ ${result.successful.length} ready snapshot${result.successful.length !== 1 ? "s" : ""}`);
95
+ console.log(` ✗ ${result.failed.length} failed/incomplete snapshot${result.failed.length !== 1 ? "s" : ""}`);
96
+ // Show what will be kept
97
+ console.log(`\nKeeping (${result.toKeep.length} most recent ready):`);
98
+ if (result.toKeep.length === 0) {
99
+ console.log(" (none - no ready snapshots found)");
100
+ }
101
+ else {
102
+ for (const snapshot of result.toKeep) {
103
+ const label = snapshot.name ? ` "${snapshot.name}"` : "";
104
+ console.log(` ✓ ${snapshot.id}${label} - Created ${formatRelativeTime(snapshot.create_time_ms)}`);
105
+ }
106
+ }
107
+ // Show what will be deleted
108
+ console.log(`\n${isDryRun ? "Would delete" : "To be deleted"} (${result.toDelete.length} snapshot${result.toDelete.length !== 1 ? "s" : ""}):`);
109
+ if (result.toDelete.length === 0) {
110
+ console.log(" (none)");
111
+ }
112
+ else {
113
+ for (const snapshot of result.toDelete) {
114
+ const icon = snapshot.status === "ready" ? "✓" : "⚠";
115
+ const statusLabel = snapshot.status === "ready" ? "ready" : snapshot.status || "unknown";
116
+ const label = snapshot.name ? ` "${snapshot.name}"` : "";
117
+ console.log(` ${icon} ${snapshot.id}${label} - Created ${formatRelativeTime(snapshot.create_time_ms)} (${statusLabel})`);
118
+ }
119
+ }
120
+ }
121
+ /**
122
+ * Display all deleted snapshots
123
+ */
124
+ function displayDeletedSnapshots(deleted) {
125
+ if (deleted.length === 0) {
126
+ return;
127
+ }
128
+ console.log("\nDeleted snapshots:");
129
+ for (const snapshot of deleted) {
130
+ const icon = snapshot.status === "ready" ? "✓" : "⚠";
131
+ const statusLabel = snapshot.status === "ready" ? "ready" : snapshot.status || "unknown";
132
+ const label = snapshot.name ? ` "${snapshot.name}"` : "";
133
+ console.log(` ${icon} ${snapshot.id}${label} - Created ${formatRelativeTime(snapshot.create_time_ms)} (${statusLabel})`);
134
+ }
135
+ }
136
+ /**
137
+ * Prompt user for confirmation
138
+ */
139
+ async function confirmDeletion(count) {
140
+ const rl = readline.createInterface({
141
+ input: process.stdin,
142
+ output: process.stdout,
143
+ });
144
+ return new Promise((resolve) => {
145
+ rl.question(`\nDelete ${count} snapshot${count !== 1 ? "s" : ""}? (y/N): `, (answer) => {
146
+ rl.close();
147
+ resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
148
+ });
149
+ });
150
+ }
151
+ /**
152
+ * Delete snapshots with error tracking
153
+ */
154
+ async function deleteSnapshotsWithTracking(snapshots) {
155
+ const client = getClient();
156
+ const results = {
157
+ deleted: [],
158
+ failed: [],
159
+ };
160
+ for (const snapshot of snapshots) {
161
+ try {
162
+ await client.devboxes.diskSnapshots.delete(snapshot.id);
163
+ results.deleted.push(snapshot);
164
+ }
165
+ catch (error) {
166
+ results.failed.push({
167
+ id: snapshot.id,
168
+ error: error instanceof Error ? error.message : String(error),
169
+ });
170
+ }
171
+ }
172
+ return results;
173
+ }
174
+ /**
175
+ * Main prune function
176
+ */
177
+ export async function pruneSnapshots(devboxId, options = {}) {
178
+ try {
179
+ // Parse and validate options
180
+ const isDryRun = !!options.dryRun;
181
+ const autoConfirm = !!options.yes;
182
+ const keepCount = parseInt(options.keep || "1", 10);
183
+ if (isNaN(keepCount) || keepCount < 0) {
184
+ outputError("--keep must be a non-negative integer");
185
+ }
186
+ // Fetch all snapshots for the given devbox
187
+ console.log(`Fetching snapshots for devbox "${devboxId}"...`);
188
+ const snapshots = await fetchAllSnapshotsForDevbox(devboxId);
189
+ // Handle no snapshots found
190
+ if (snapshots.length === 0) {
191
+ console.log(`No snapshots found for devbox: ${devboxId}`);
192
+ return;
193
+ }
194
+ // Categorize snapshots
195
+ const categorized = categorizeSnapshots(snapshots, keepCount);
196
+ // Display summary
197
+ displaySummary(devboxId, categorized, isDryRun);
198
+ // Handle dry-run mode
199
+ if (isDryRun) {
200
+ console.log("\n(Dry run - no changes made)");
201
+ const result = {
202
+ sourceDevboxId: devboxId,
203
+ totalFound: snapshots.length,
204
+ successfulSnapshots: categorized.successful.length,
205
+ failedSnapshots: categorized.failed.length,
206
+ kept: categorized.toKeep,
207
+ deleted: [],
208
+ failed: [],
209
+ dryRun: true,
210
+ };
211
+ if (options.output && options.output !== "text") {
212
+ output(result, { format: options.output, defaultFormat: "json" });
213
+ }
214
+ return;
215
+ }
216
+ // Handle nothing to delete
217
+ if (categorized.toDelete.length === 0) {
218
+ console.log("\nNothing to delete.");
219
+ return;
220
+ }
221
+ // Warn if no successful snapshots
222
+ if (categorized.successful.length === 0) {
223
+ console.log("\nWarning: No ready snapshots found. Only deleting failed/incomplete snapshots.");
224
+ }
225
+ // Get confirmation unless --yes flag is set
226
+ if (!autoConfirm) {
227
+ const confirmed = await confirmDeletion(categorized.toDelete.length);
228
+ if (!confirmed) {
229
+ console.log("\nOperation cancelled.");
230
+ return;
231
+ }
232
+ }
233
+ // Perform deletions
234
+ console.log(`\nDeleting ${categorized.toDelete.length} snapshot${categorized.toDelete.length !== 1 ? "s" : ""}...`);
235
+ const deletionResults = await deleteSnapshotsWithTracking(categorized.toDelete);
236
+ // Display results
237
+ console.log("\nResults:");
238
+ console.log(` ✓ Successfully deleted: ${deletionResults.deleted.length} snapshot${deletionResults.deleted.length !== 1 ? "s" : ""}`);
239
+ // Show all deleted snapshots
240
+ displayDeletedSnapshots(deletionResults.deleted);
241
+ if (deletionResults.failed.length > 0) {
242
+ console.log(`\n ✗ Failed to delete: ${deletionResults.failed.length} snapshot${deletionResults.failed.length !== 1 ? "s" : ""}`);
243
+ for (const failure of deletionResults.failed) {
244
+ console.log(` - ${failure.id}: ${failure.error}`);
245
+ }
246
+ }
247
+ // Output structured data if requested
248
+ if (options.output && options.output !== "text") {
249
+ const result = {
250
+ sourceDevboxId: devboxId,
251
+ totalFound: snapshots.length,
252
+ successfulSnapshots: categorized.successful.length,
253
+ failedSnapshots: categorized.failed.length,
254
+ kept: categorized.toKeep,
255
+ deleted: deletionResults.deleted,
256
+ failed: deletionResults.failed,
257
+ dryRun: false,
258
+ };
259
+ output(result, { format: options.output, defaultFormat: "json" });
260
+ }
261
+ }
262
+ catch (error) {
263
+ outputError("Failed to prune snapshots", error);
264
+ }
265
+ }