@runloop/rl-cli 0.1.2 → 0.3.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 (105) hide show
  1. package/README.md +54 -10
  2. package/dist/cli.js +79 -72
  3. package/dist/commands/auth.js +2 -2
  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 +278 -230
  7. package/dist/commands/blueprint/logs.js +133 -37
  8. package/dist/commands/config.js +118 -0
  9. package/dist/commands/devbox/create.js +120 -40
  10. package/dist/commands/devbox/delete.js +17 -33
  11. package/dist/commands/devbox/download.js +29 -43
  12. package/dist/commands/devbox/exec.js +22 -39
  13. package/dist/commands/devbox/execAsync.js +20 -37
  14. package/dist/commands/devbox/get.js +13 -35
  15. package/dist/commands/devbox/getAsync.js +12 -34
  16. package/dist/commands/devbox/list.js +241 -402
  17. package/dist/commands/devbox/logs.js +20 -38
  18. package/dist/commands/devbox/read.js +29 -43
  19. package/dist/commands/devbox/resume.js +13 -35
  20. package/dist/commands/devbox/rsync.js +26 -78
  21. package/dist/commands/devbox/scp.js +25 -79
  22. package/dist/commands/devbox/sendStdin.js +41 -0
  23. package/dist/commands/devbox/shutdown.js +13 -35
  24. package/dist/commands/devbox/ssh.js +46 -78
  25. package/dist/commands/devbox/suspend.js +13 -35
  26. package/dist/commands/devbox/tunnel.js +37 -88
  27. package/dist/commands/devbox/upload.js +28 -36
  28. package/dist/commands/devbox/write.js +29 -44
  29. package/dist/commands/mcp-http.js +6 -5
  30. package/dist/commands/mcp-install.js +12 -10
  31. package/dist/commands/mcp.js +5 -4
  32. package/dist/commands/menu.js +26 -67
  33. package/dist/commands/object/delete.js +12 -34
  34. package/dist/commands/object/download.js +26 -74
  35. package/dist/commands/object/get.js +12 -34
  36. package/dist/commands/object/list.js +15 -93
  37. package/dist/commands/object/upload.js +35 -96
  38. package/dist/commands/snapshot/create.js +23 -39
  39. package/dist/commands/snapshot/delete.js +17 -33
  40. package/dist/commands/snapshot/get.js +16 -0
  41. package/dist/commands/snapshot/list.js +309 -80
  42. package/dist/commands/snapshot/status.js +12 -34
  43. package/dist/components/ActionsPopup.js +64 -39
  44. package/dist/components/Banner.js +7 -1
  45. package/dist/components/Breadcrumb.js +11 -48
  46. package/dist/components/DevboxActionsMenu.js +117 -207
  47. package/dist/components/DevboxCreatePage.js +12 -7
  48. package/dist/components/DevboxDetailPage.js +76 -28
  49. package/dist/components/ErrorBoundary.js +29 -0
  50. package/dist/components/ErrorMessage.js +10 -2
  51. package/dist/components/Header.js +12 -4
  52. package/dist/components/InteractiveSpawn.js +104 -0
  53. package/dist/components/LogsViewer.js +169 -0
  54. package/dist/components/MainMenu.js +37 -33
  55. package/dist/components/MetadataDisplay.js +4 -4
  56. package/dist/components/OperationsMenu.js +1 -1
  57. package/dist/components/ResourceActionsMenu.js +4 -4
  58. package/dist/components/ResourceListView.js +46 -34
  59. package/dist/components/Spinner.js +7 -2
  60. package/dist/components/StatusBadge.js +1 -1
  61. package/dist/components/SuccessMessage.js +12 -2
  62. package/dist/components/Table.js +16 -6
  63. package/dist/components/UpdateNotification.js +56 -0
  64. package/dist/hooks/useCursorPagination.js +125 -85
  65. package/dist/hooks/useExitOnCtrlC.js +15 -0
  66. package/dist/hooks/useViewportHeight.js +47 -0
  67. package/dist/mcp/server-http.js +2 -1
  68. package/dist/mcp/server.js +71 -7
  69. package/dist/router/Router.js +70 -0
  70. package/dist/router/types.js +1 -0
  71. package/dist/screens/BlueprintListScreen.js +7 -0
  72. package/dist/screens/BlueprintLogsScreen.js +74 -0
  73. package/dist/screens/DevboxActionsScreen.js +25 -0
  74. package/dist/screens/DevboxCreateScreen.js +11 -0
  75. package/dist/screens/DevboxDetailScreen.js +60 -0
  76. package/dist/screens/DevboxListScreen.js +23 -0
  77. package/dist/screens/LogsSessionScreen.js +49 -0
  78. package/dist/screens/MenuScreen.js +23 -0
  79. package/dist/screens/SSHSessionScreen.js +55 -0
  80. package/dist/screens/SnapshotListScreen.js +7 -0
  81. package/dist/services/blueprintService.js +101 -0
  82. package/dist/services/devboxService.js +215 -0
  83. package/dist/services/snapshotService.js +81 -0
  84. package/dist/store/blueprintStore.js +89 -0
  85. package/dist/store/devboxStore.js +105 -0
  86. package/dist/store/index.js +7 -0
  87. package/dist/store/navigationStore.js +101 -0
  88. package/dist/store/snapshotStore.js +87 -0
  89. package/dist/utils/client.js +4 -2
  90. package/dist/utils/config.js +22 -111
  91. package/dist/utils/interactiveCommand.js +3 -2
  92. package/dist/utils/logFormatter.js +208 -0
  93. package/dist/utils/memoryMonitor.js +85 -0
  94. package/dist/utils/output.js +153 -61
  95. package/dist/utils/process.js +106 -0
  96. package/dist/utils/processUtils.js +135 -0
  97. package/dist/utils/screen.js +61 -0
  98. package/dist/utils/ssh.js +6 -3
  99. package/dist/utils/sshSession.js +5 -29
  100. package/dist/utils/terminalDetection.js +185 -0
  101. package/dist/utils/terminalSync.js +39 -0
  102. package/dist/utils/theme.js +162 -13
  103. package/dist/utils/versionCheck.js +53 -0
  104. package/dist/version.js +12 -0
  105. package/package.json +19 -17
@@ -0,0 +1,60 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * DevboxDetailScreen - Pure UI component for devbox details
4
+ * Refactored from components/DevboxDetailPage.tsx
5
+ */
6
+ import React from "react";
7
+ import { useNavigation } from "../store/navigationStore.js";
8
+ import { useDevboxStore } from "../store/devboxStore.js";
9
+ import { DevboxDetailPage } from "../components/DevboxDetailPage.js";
10
+ import { getDevbox } from "../services/devboxService.js";
11
+ import { SpinnerComponent } from "../components/Spinner.js";
12
+ import { ErrorMessage } from "../components/ErrorMessage.js";
13
+ import { Breadcrumb } from "../components/Breadcrumb.js";
14
+ export function DevboxDetailScreen({ devboxId }) {
15
+ const { goBack } = useNavigation();
16
+ const devboxes = useDevboxStore((state) => state.devboxes);
17
+ const setDevboxesInStore = useDevboxStore((state) => state.setDevboxes);
18
+ const [loading, setLoading] = React.useState(false);
19
+ const [error, setError] = React.useState(null);
20
+ const [fetchedDevbox, setFetchedDevbox] = React.useState(null);
21
+ // Find devbox in store first
22
+ const devboxFromStore = devboxes.find((d) => d.id === devboxId);
23
+ // Fetch devbox from API if not in store
24
+ React.useEffect(() => {
25
+ if (!devboxFromStore && devboxId && !loading && !fetchedDevbox) {
26
+ setLoading(true);
27
+ setError(null);
28
+ getDevbox(devboxId)
29
+ .then((devbox) => {
30
+ setFetchedDevbox(devbox);
31
+ // Cache it in store for future access
32
+ setDevboxesInStore([devbox]);
33
+ setLoading(false);
34
+ })
35
+ .catch((err) => {
36
+ setError(err);
37
+ setLoading(false);
38
+ });
39
+ }
40
+ }, [devboxFromStore, devboxId, loading, fetchedDevbox, setDevboxesInStore]);
41
+ // Use devbox from store or fetched devbox
42
+ const devbox = devboxFromStore || fetchedDevbox;
43
+ // Show loading state while fetching
44
+ if (loading) {
45
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Devboxes" }, { label: "Loading...", active: true }] }), _jsx(SpinnerComponent, { message: "Loading devbox details..." })] }));
46
+ }
47
+ // Show error state if fetch failed
48
+ if (error) {
49
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Devboxes" }, { label: "Error", active: true }] }), _jsx(ErrorMessage, { message: "Failed to load devbox details", error: error })] }));
50
+ }
51
+ // Show error if no devbox found and not loading
52
+ if (!devbox && !loading) {
53
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Devboxes" }, { label: "Not Found", active: true }] }), _jsx(ErrorMessage, { message: `Devbox ${devboxId || "unknown"} not found`, error: new Error("Devbox not found in cache and could not be fetched") })] }));
54
+ }
55
+ // At this point devbox is guaranteed to exist (loading check above handles the null case)
56
+ if (!devbox) {
57
+ return null; // TypeScript guard - should never reach here
58
+ }
59
+ return _jsx(DevboxDetailPage, { devbox: devbox, onBack: goBack });
60
+ }
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * DevboxListScreen - Pure UI component using devboxStore
4
+ * Simplified version for now - wraps existing component
5
+ */
6
+ import React from "react";
7
+ import { useNavigation } from "../store/navigationStore.js";
8
+ import { ListDevboxesUI } from "../commands/devbox/list.js";
9
+ export function DevboxListScreen({ status, focusDevboxId, }) {
10
+ const { goBack, navigate } = useNavigation();
11
+ // If focusDevboxId is provided, navigate directly to detail screen
12
+ // instead of letting ListDevboxesUI handle it internally
13
+ React.useEffect(() => {
14
+ if (focusDevboxId) {
15
+ navigate("devbox-detail", { devboxId: focusDevboxId });
16
+ }
17
+ }, [focusDevboxId, navigate]);
18
+ // Navigation callback to handle detail view via Router
19
+ const handleNavigateToDetail = React.useCallback((devboxId) => {
20
+ navigate("devbox-detail", { devboxId });
21
+ }, [navigate]);
22
+ return (_jsx(ListDevboxesUI, { status: status, onBack: goBack, onExit: goBack, onNavigateToDetail: handleNavigateToDetail }));
23
+ }
@@ -0,0 +1,49 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * LogsSessionScreen - Logs viewer using custom InteractiveSpawn
4
+ * Runs the CLI logs command as a subprocess within the Ink UI without exiting
5
+ */
6
+ import React from "react";
7
+ import { Box, Text } from "ink";
8
+ import { InteractiveSpawn } from "../components/InteractiveSpawn.js";
9
+ import { useNavigation, } from "../store/navigationStore.js";
10
+ import { Breadcrumb } from "../components/Breadcrumb.js";
11
+ import { colors } from "../utils/theme.js";
12
+ import figures from "figures";
13
+ import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
14
+ import { fileURLToPath } from "url";
15
+ import { dirname, join } from "path";
16
+ export function LogsSessionScreen() {
17
+ const { params, navigate } = useNavigation();
18
+ // Handle Ctrl+C to exit (before logs command runs or on error)
19
+ useExitOnCtrlC();
20
+ // Extract params
21
+ const devboxId = params.devboxId;
22
+ const devboxName = params.devboxName || params.devboxId || "devbox";
23
+ const returnScreen = params.returnScreen || "devbox-list";
24
+ const returnParams = params.returnParams || {};
25
+ // Get the path to the CLI executable
26
+ const cliPath = React.useMemo(() => {
27
+ const __filename = fileURLToPath(import.meta.url);
28
+ const __dirname = dirname(__filename);
29
+ // When compiled, this file is in dist/screens/, so go up one level to dist/cli.js
30
+ return join(__dirname, "../cli.js");
31
+ }, []);
32
+ // Validate required params
33
+ if (!devboxId) {
34
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Logs", active: true }] }), _jsx(Box, { flexDirection: "column", paddingX: 1, children: _jsxs(Text, { color: colors.error, children: [figures.cross, " Missing devbox ID. Returning..."] }) })] }));
35
+ }
36
+ // Build CLI command args
37
+ const cliArgs = React.useMemo(() => ["devbox", "logs", devboxId], [devboxId]);
38
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Logs", active: true }] }), _jsxs(Box, { flexDirection: "column", paddingX: 1, marginBottom: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.info, " Viewing logs for ", devboxName, "..."] }), _jsx(Text, { color: colors.textDim, dimColor: true, children: "Press Ctrl+C to exit" })] }), _jsx(InteractiveSpawn, { command: "node", args: [cliPath, ...cliArgs], onExit: (_code) => {
39
+ // Navigate back to previous screen when logs command exits
40
+ setTimeout(() => {
41
+ navigate(returnScreen, returnParams || {});
42
+ }, 100);
43
+ }, onError: (_error) => {
44
+ // On error, navigate back as well
45
+ setTimeout(() => {
46
+ navigate(returnScreen, returnParams || {});
47
+ }, 100);
48
+ } })] }));
49
+ }
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useNavigation } from "../store/navigationStore.js";
3
+ import { MainMenu } from "../components/MainMenu.js";
4
+ export function MenuScreen() {
5
+ const { navigate } = useNavigation();
6
+ const handleSelect = (key) => {
7
+ switch (key) {
8
+ case "devboxes":
9
+ navigate("devbox-list");
10
+ break;
11
+ case "blueprints":
12
+ navigate("blueprint-list");
13
+ break;
14
+ case "snapshots":
15
+ navigate("snapshot-list");
16
+ break;
17
+ default:
18
+ // Fallback for any other screen names
19
+ navigate(key);
20
+ }
21
+ };
22
+ return _jsx(MainMenu, { onSelect: handleSelect });
23
+ }
@@ -0,0 +1,55 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * SSHSessionScreen - SSH session using custom InteractiveSpawn
4
+ * Runs SSH as a subprocess within the Ink UI without exiting
5
+ */
6
+ import React from "react";
7
+ import { Box, Text } from "ink";
8
+ import { InteractiveSpawn } from "../components/InteractiveSpawn.js";
9
+ import { useNavigation, } from "../store/navigationStore.js";
10
+ import { Breadcrumb } from "../components/Breadcrumb.js";
11
+ import { colors } from "../utils/theme.js";
12
+ import figures from "figures";
13
+ export function SSHSessionScreen() {
14
+ const { params, replace } = useNavigation();
15
+ // NOTE: Do NOT use useExitOnCtrlC here - SSH handles Ctrl+C itself
16
+ // Using useInput would conflict with the subprocess's terminal control
17
+ // Extract SSH config from params
18
+ const keyPath = params.keyPath;
19
+ const proxyCommand = params.proxyCommand;
20
+ const sshUser = params.sshUser;
21
+ const url = params.url;
22
+ const devboxName = params.devboxName || params.devboxId || "devbox";
23
+ const returnScreen = params.returnScreen || "devbox-list";
24
+ const returnParams = params.returnParams || {};
25
+ // Validate required params
26
+ if (!keyPath || !proxyCommand || !sshUser || !url) {
27
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "SSH Session", active: true }] }), _jsx(Box, { flexDirection: "column", paddingX: 1, children: _jsxs(Text, { color: colors.error, children: [figures.cross, " Missing SSH configuration. Returning..."] }) })] }));
28
+ }
29
+ // Build SSH command args
30
+ // The proxy command needs to be passed as a single value in the -o option
31
+ const sshArgs = React.useMemo(() => [
32
+ "-t", // Force pseudo-terminal allocation for proper input handling
33
+ "-i",
34
+ keyPath,
35
+ "-o",
36
+ `ProxyCommand=${proxyCommand}`,
37
+ "-o",
38
+ "StrictHostKeyChecking=no",
39
+ "-o",
40
+ "UserKnownHostsFile=/dev/null",
41
+ `${sshUser}@${url}`,
42
+ ], [keyPath, proxyCommand, sshUser, url]);
43
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "SSH Session", active: true }] }), _jsxs(Box, { flexDirection: "column", paddingX: 1, marginBottom: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.play, " Connecting to ", devboxName, "..."] }), _jsx(Text, { color: colors.textDim, dimColor: true, children: "Press Ctrl+C or type exit to disconnect" })] }), _jsx(InteractiveSpawn, { command: "ssh", args: sshArgs, onExit: (_code) => {
44
+ // Replace current screen (don't add SSH to history stack)
45
+ // Using replace() instead of navigate() prevents "escape goes back to SSH" bug
46
+ setTimeout(() => {
47
+ replace(returnScreen, returnParams || {});
48
+ }, 100);
49
+ }, onError: (_error) => {
50
+ // On error, replace current screen as well
51
+ setTimeout(() => {
52
+ replace(returnScreen, returnParams || {});
53
+ }, 100);
54
+ } })] }));
55
+ }
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useNavigation } from "../store/navigationStore.js";
3
+ import { ListSnapshotsUI } from "../commands/snapshot/list.js";
4
+ export function SnapshotListScreen() {
5
+ const { goBack } = useNavigation();
6
+ return _jsx(ListSnapshotsUI, { onBack: goBack, onExit: goBack });
7
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Blueprint Service - Handles all blueprint API calls
3
+ */
4
+ import { getClient } from "../utils/client.js";
5
+ /**
6
+ * List blueprints with pagination
7
+ */
8
+ export async function listBlueprints(options) {
9
+ const client = getClient();
10
+ const queryParams = {
11
+ limit: options.limit,
12
+ };
13
+ if (options.startingAfter) {
14
+ queryParams.starting_after = options.startingAfter;
15
+ }
16
+ if (options.search) {
17
+ queryParams.name = options.search;
18
+ }
19
+ const pagePromise = client.blueprints.list(queryParams);
20
+ const page = (await pagePromise);
21
+ const blueprints = [];
22
+ if (page.blueprints && Array.isArray(page.blueprints)) {
23
+ page.blueprints.forEach((b) => {
24
+ // CRITICAL: Truncate all strings to prevent Yoga crashes
25
+ const MAX_ID_LENGTH = 100;
26
+ const MAX_NAME_LENGTH = 200;
27
+ const MAX_STATUS_LENGTH = 50;
28
+ const MAX_ARCH_LENGTH = 50;
29
+ const MAX_RESOURCES_LENGTH = 100;
30
+ // Extract architecture and resources from launch_parameters
31
+ const architecture = b.parameters?.launch_parameters?.architecture;
32
+ const resources = b.parameters?.launch_parameters?.resource_size_request;
33
+ blueprints.push({
34
+ id: String(b.id || "").substring(0, MAX_ID_LENGTH),
35
+ name: String(b.name || "").substring(0, MAX_NAME_LENGTH),
36
+ status: String(b.status || "").substring(0, MAX_STATUS_LENGTH),
37
+ create_time_ms: b.create_time_ms,
38
+ build_status: b.status
39
+ ? String(b.status).substring(0, MAX_STATUS_LENGTH)
40
+ : undefined,
41
+ architecture: architecture
42
+ ? String(architecture).substring(0, MAX_ARCH_LENGTH)
43
+ : undefined,
44
+ resources: resources
45
+ ? String(resources).substring(0, MAX_RESOURCES_LENGTH)
46
+ : undefined,
47
+ });
48
+ });
49
+ }
50
+ const result = {
51
+ blueprints,
52
+ totalCount: page.total_count || blueprints.length,
53
+ hasMore: page.has_more || false,
54
+ };
55
+ return result;
56
+ }
57
+ /**
58
+ * Get a single blueprint by ID
59
+ */
60
+ export async function getBlueprint(id) {
61
+ const client = getClient();
62
+ const blueprint = await client.blueprints.retrieve(id);
63
+ return {
64
+ id: blueprint.id,
65
+ name: blueprint.name,
66
+ status: blueprint.status,
67
+ create_time_ms: blueprint.create_time_ms,
68
+ build_status: blueprint.build_status,
69
+ architecture: blueprint.architecture,
70
+ resources: blueprint.resources,
71
+ };
72
+ }
73
+ /**
74
+ * Get blueprint logs
75
+ * Returns the raw logs array from the API response
76
+ * Similar to getDevboxLogs - formatting is handled by logFormatter
77
+ */
78
+ export async function getBlueprintLogs(id) {
79
+ const client = getClient();
80
+ const response = await client.blueprints.logs(id);
81
+ // Return the logs array directly - formatting is handled by logFormatter
82
+ // Ensure timestamp_ms is present (API may return timestamp or timestamp_ms)
83
+ if (response.logs && Array.isArray(response.logs)) {
84
+ return response.logs.map((log) => {
85
+ // Normalize timestamp field to timestamp_ms if needed
86
+ // Create a new object to avoid mutating the original
87
+ const normalizedLog = { ...log };
88
+ if (normalizedLog.timestamp && !normalizedLog.timestamp_ms) {
89
+ // If timestamp is a number, use it directly; if it's a string, parse it
90
+ if (typeof normalizedLog.timestamp === "number") {
91
+ normalizedLog.timestamp_ms = normalizedLog.timestamp;
92
+ }
93
+ else if (typeof normalizedLog.timestamp === "string") {
94
+ normalizedLog.timestamp_ms = new Date(normalizedLog.timestamp).getTime();
95
+ }
96
+ }
97
+ return normalizedLog;
98
+ });
99
+ }
100
+ return [];
101
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Devbox Service - Handles all devbox API calls
3
+ * Returns plain data objects with no SDK reference retention
4
+ */
5
+ import { getClient } from "../utils/client.js";
6
+ /**
7
+ * Recursively truncate all strings in an object to prevent Yoga crashes
8
+ * CRITICAL: Must be applied to ALL data from API before storing/rendering
9
+ */
10
+ function truncateStrings(obj, maxLength = 200) {
11
+ if (obj === null || obj === undefined)
12
+ return obj;
13
+ if (typeof obj === "string") {
14
+ return (obj.length > maxLength ? obj.substring(0, maxLength) : obj);
15
+ }
16
+ if (Array.isArray(obj)) {
17
+ return obj.map((item) => truncateStrings(item, maxLength));
18
+ }
19
+ if (typeof obj === "object") {
20
+ const result = {};
21
+ for (const key in obj) {
22
+ if (Object.hasOwn(obj, key)) {
23
+ result[key] = truncateStrings(obj[key], maxLength);
24
+ }
25
+ }
26
+ return result;
27
+ }
28
+ return obj;
29
+ }
30
+ /**
31
+ * List devboxes with pagination
32
+ * CRITICAL: Creates defensive copies to break SDK reference chains
33
+ */
34
+ export async function listDevboxes(options) {
35
+ // Check if aborted before making request
36
+ if (options.signal?.aborted) {
37
+ throw new DOMException("Aborted", "AbortError");
38
+ }
39
+ const client = getClient();
40
+ const queryParams = {
41
+ limit: options.limit,
42
+ };
43
+ if (options.startingAfter) {
44
+ queryParams.starting_after = options.startingAfter;
45
+ }
46
+ if (options.status) {
47
+ queryParams.status = options.status;
48
+ }
49
+ if (options.search) {
50
+ queryParams.name = options.search;
51
+ }
52
+ // Fetch ONE page only - never iterate
53
+ const pagePromise = client.devboxes.list(queryParams);
54
+ // Wrap in Promise.race to support abort
55
+ let page;
56
+ if (options.signal) {
57
+ const abortPromise = new Promise((_, reject) => {
58
+ options.signal.addEventListener("abort", () => {
59
+ reject(new DOMException("Aborted", "AbortError"));
60
+ });
61
+ });
62
+ try {
63
+ page = (await Promise.race([
64
+ pagePromise,
65
+ abortPromise,
66
+ ]));
67
+ }
68
+ catch (err) {
69
+ // Re-throw abort errors, convert others
70
+ if (err?.name === "AbortError") {
71
+ throw err;
72
+ }
73
+ throw err;
74
+ }
75
+ }
76
+ else {
77
+ page = (await pagePromise);
78
+ }
79
+ // Check again after await (in case abort happened during request)
80
+ if (options.signal?.aborted) {
81
+ throw new DOMException("Aborted", "AbortError");
82
+ }
83
+ // Extract data and create defensive copies immediately
84
+ const devboxes = [];
85
+ if (page.devboxes && Array.isArray(page.devboxes)) {
86
+ page.devboxes.forEach((d) => {
87
+ // CRITICAL: Recursively truncate ALL strings in the object to prevent Yoga crashes
88
+ // This catches nested fields like launch_parameters.user_parameters.username
89
+ const truncated = truncateStrings(d, 200);
90
+ devboxes.push(truncated);
91
+ });
92
+ }
93
+ const result = {
94
+ devboxes,
95
+ totalCount: page.total_count || devboxes.length,
96
+ hasMore: page.has_more || false,
97
+ };
98
+ return result;
99
+ }
100
+ /**
101
+ * Get a single devbox by ID
102
+ */
103
+ export async function getDevbox(id) {
104
+ const client = getClient();
105
+ const devbox = await client.devboxes.retrieve(id);
106
+ // CRITICAL: Recursively truncate ALL strings in the object to prevent Yoga crashes
107
+ return truncateStrings(devbox, 200);
108
+ }
109
+ /**
110
+ * Delete a devbox (actually shuts it down)
111
+ */
112
+ export async function deleteDevbox(id) {
113
+ const client = getClient();
114
+ await client.devboxes.shutdown(id);
115
+ }
116
+ /**
117
+ * Shutdown a devbox
118
+ */
119
+ export async function shutdownDevbox(id) {
120
+ const client = getClient();
121
+ await client.devboxes.shutdown(id);
122
+ }
123
+ /**
124
+ * Suspend a devbox
125
+ */
126
+ export async function suspendDevbox(id) {
127
+ const client = getClient();
128
+ await client.devboxes.suspend(id);
129
+ }
130
+ /**
131
+ * Resume a devbox
132
+ */
133
+ export async function resumeDevbox(id) {
134
+ const client = getClient();
135
+ await client.devboxes.resume(id);
136
+ }
137
+ /**
138
+ * Upload file to devbox
139
+ */
140
+ export async function uploadFile(id, filepath, remotePath) {
141
+ const client = getClient();
142
+ const fs = await import("fs");
143
+ const fileStream = fs.createReadStream(filepath);
144
+ await client.devboxes.uploadFile(id, {
145
+ file: fileStream,
146
+ path: remotePath,
147
+ });
148
+ }
149
+ /**
150
+ * Create snapshot of devbox
151
+ */
152
+ export async function createSnapshot(id, name) {
153
+ const client = getClient();
154
+ const snapshot = await client.devboxes.snapshotDisk(id, { name });
155
+ return {
156
+ id: String(snapshot.id || "").substring(0, 100),
157
+ name: snapshot.name ? String(snapshot.name).substring(0, 200) : undefined,
158
+ };
159
+ }
160
+ /**
161
+ * Create SSH key for devbox
162
+ */
163
+ export async function createSSHKey(id) {
164
+ const client = getClient();
165
+ const result = await client.devboxes.createSSHKey(id);
166
+ // Truncate keys if they're unexpectedly long (shouldn't happen, but safety)
167
+ return {
168
+ ssh_private_key: String(result.ssh_private_key || "").substring(0, 10000),
169
+ url: String(result.url || "").substring(0, 500),
170
+ };
171
+ }
172
+ /**
173
+ * Create tunnel to devbox
174
+ */
175
+ export async function createTunnel(id, port) {
176
+ const client = getClient();
177
+ const tunnel = await client.devboxes.createTunnel(id, { port });
178
+ return {
179
+ url: String(tunnel.url || "").substring(0, 500),
180
+ };
181
+ }
182
+ /**
183
+ * Execute command in devbox
184
+ */
185
+ export async function execCommand(id, command) {
186
+ const client = getClient();
187
+ const result = await client.devboxes.executeSync(id, { command });
188
+ // CRITICAL: Truncate output to prevent Yoga crashes
189
+ const MAX_OUTPUT_LENGTH = 10000; // Allow more for command output
190
+ let stdout = String(result.stdout || "");
191
+ let stderr = String(result.stderr || "");
192
+ if (stdout.length > MAX_OUTPUT_LENGTH) {
193
+ stdout =
194
+ stdout.substring(0, MAX_OUTPUT_LENGTH) + "\n... (output truncated)";
195
+ }
196
+ if (stderr.length > MAX_OUTPUT_LENGTH) {
197
+ stderr =
198
+ stderr.substring(0, MAX_OUTPUT_LENGTH) + "\n... (output truncated)";
199
+ }
200
+ return {
201
+ stdout,
202
+ stderr,
203
+ exit_code: result.exit_code || 0,
204
+ };
205
+ }
206
+ /**
207
+ * Get devbox logs
208
+ * Returns the raw logs array from the API response
209
+ */
210
+ export async function getDevboxLogs(id) {
211
+ const client = getClient();
212
+ const response = await client.devboxes.logs.list(id);
213
+ // Return the logs array directly - formatting is handled by logFormatter
214
+ return response.logs || [];
215
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Snapshot Service - Handles all snapshot API calls
3
+ */
4
+ import { getClient } from "../utils/client.js";
5
+ /**
6
+ * List snapshots with pagination
7
+ */
8
+ export async function listSnapshots(options) {
9
+ const client = getClient();
10
+ const queryParams = {
11
+ limit: options.limit,
12
+ };
13
+ if (options.startingAfter) {
14
+ queryParams.starting_after = options.startingAfter;
15
+ }
16
+ if (options.devboxId) {
17
+ queryParams.devbox_id = options.devboxId;
18
+ }
19
+ const pagePromise = client.devboxes.listDiskSnapshots(queryParams);
20
+ const page = (await pagePromise);
21
+ const snapshots = [];
22
+ if (page.snapshots && Array.isArray(page.snapshots)) {
23
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
+ page.snapshots.forEach((s) => {
25
+ // CRITICAL: Truncate all strings to prevent Yoga crashes
26
+ const MAX_ID_LENGTH = 100;
27
+ const MAX_NAME_LENGTH = 200;
28
+ const MAX_STATUS_LENGTH = 50;
29
+ // Status is constructed/available in API response but not in type definition
30
+ const snapshotView = s;
31
+ snapshots.push({
32
+ id: String(snapshotView.id || "").substring(0, MAX_ID_LENGTH),
33
+ name: snapshotView.name
34
+ ? String(snapshotView.name).substring(0, MAX_NAME_LENGTH)
35
+ : undefined,
36
+ devbox_id: String(snapshotView.source_devbox_id || "").substring(0, MAX_ID_LENGTH),
37
+ status: snapshotView.status
38
+ ? String(snapshotView.status).substring(0, MAX_STATUS_LENGTH)
39
+ : "",
40
+ create_time_ms: snapshotView.create_time_ms,
41
+ });
42
+ });
43
+ }
44
+ const result = {
45
+ snapshots,
46
+ totalCount: page.total_count || snapshots.length,
47
+ hasMore: page.has_more || false,
48
+ };
49
+ return result;
50
+ }
51
+ /**
52
+ * Get snapshot status by ID
53
+ */
54
+ export async function getSnapshotStatus(id) {
55
+ const client = getClient();
56
+ const status = await client.devboxes.diskSnapshots.queryStatus(id);
57
+ return status;
58
+ }
59
+ /**
60
+ * Create a snapshot
61
+ */
62
+ export async function createSnapshot(devboxId, name) {
63
+ const client = getClient();
64
+ const snapshot = await client.devboxes.snapshotDisk(devboxId, {
65
+ name,
66
+ });
67
+ return {
68
+ id: snapshot.id,
69
+ name: snapshot.name || undefined,
70
+ devbox_id: snapshot.devbox_id || devboxId,
71
+ status: snapshot.status || "pending",
72
+ create_time_ms: snapshot.create_time_ms,
73
+ };
74
+ }
75
+ /**
76
+ * Delete a snapshot
77
+ */
78
+ export async function deleteSnapshot(id) {
79
+ const client = getClient();
80
+ await client.devboxes.diskSnapshots.delete(id);
81
+ }