@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
@@ -1,125 +1,165 @@
1
1
  import React from "react";
2
+ /**
3
+ * Hook for cursor-based pagination with polling.
4
+ *
5
+ * Design:
6
+ * - No caching: always fetches fresh data on navigation or poll
7
+ * - Cursor history: tracks the last item ID of each visited page
8
+ * - cursorHistory[N] = last item ID of page N (used as startingAt for page N+1)
9
+ * - Polling: refreshes current page every pollInterval ms
10
+ *
11
+ * Navigation:
12
+ * - Page 0: startingAt = undefined
13
+ * - Page N: startingAt = cursorHistory[N-1]
14
+ * - Going back uses known cursor from history
15
+ */
2
16
  export function useCursorPagination(config) {
17
+ const { fetchPage, pageSize, getItemId, pollInterval = 2000, deps = [], pollingEnabled = true, } = config;
18
+ // State
3
19
  const [items, setItems] = React.useState([]);
4
20
  const [loading, setLoading] = React.useState(true);
21
+ const [navigating, setNavigating] = React.useState(false);
5
22
  const [error, setError] = React.useState(null);
6
23
  const [currentPage, setCurrentPage] = React.useState(0);
7
- const [totalCount, setTotalCount] = React.useState(0);
8
24
  const [hasMore, setHasMore] = React.useState(false);
9
- const [refreshing, setRefreshing] = React.useState(false);
10
- // Cache for page data and cursors
11
- const pageCache = React.useRef(new Map());
12
- const lastIdCache = React.useRef(new Map());
13
- const fetchData = React.useCallback(async (isInitialLoad = false) => {
25
+ const [totalCount, setTotalCount] = React.useState(0);
26
+ // Cursor history: cursorHistory[N] = last item ID of page N
27
+ // Used to determine startingAt for page N+1
28
+ const cursorHistoryRef = React.useRef([]);
29
+ // Track if component is mounted
30
+ const isMountedRef = React.useRef(true);
31
+ // Track if we're currently fetching (to prevent concurrent fetches)
32
+ const isFetchingRef = React.useRef(false);
33
+ // Store stable references to config
34
+ const fetchPageRef = React.useRef(fetchPage);
35
+ const getItemIdRef = React.useRef(getItemId);
36
+ const pageSizeRef = React.useRef(pageSize);
37
+ // Keep refs in sync
38
+ React.useEffect(() => {
39
+ fetchPageRef.current = fetchPage;
40
+ }, [fetchPage]);
41
+ React.useEffect(() => {
42
+ getItemIdRef.current = getItemId;
43
+ }, [getItemId]);
44
+ React.useEffect(() => {
45
+ pageSizeRef.current = pageSize;
46
+ }, [pageSize]);
47
+ // Cleanup on unmount
48
+ React.useEffect(() => {
49
+ isMountedRef.current = true;
50
+ return () => {
51
+ isMountedRef.current = false;
52
+ };
53
+ }, []);
54
+ /**
55
+ * Fetch a specific page
56
+ * @param page - Page number to fetch (0-indexed)
57
+ * @param isInitialLoad - Whether this is the initial load (shows loading state)
58
+ * @param isNavigation - Whether this is a page navigation (shows navigating state)
59
+ */
60
+ const fetchPageData = React.useCallback(async (page, isInitialLoad = false, isNavigation = false) => {
61
+ if (!isMountedRef.current)
62
+ return;
63
+ if (isFetchingRef.current)
64
+ return;
65
+ isFetchingRef.current = true;
14
66
  try {
15
67
  if (isInitialLoad) {
16
- setRefreshing(true);
17
- }
18
- setLoading(true);
19
- // Check cache first (skip on refresh)
20
- if (!isInitialLoad && pageCache.current.has(currentPage)) {
21
- setItems(pageCache.current.get(currentPage) || []);
22
- setLoading(false);
23
- return;
68
+ setLoading(true);
24
69
  }
25
- const pageItems = [];
26
- // Get starting_at cursor from previous page's last ID
27
- const startingAt = currentPage > 0
28
- ? lastIdCache.current.get(currentPage - 1)
29
- : undefined;
30
- // Build query params
31
- const queryParams = {
32
- limit: config.pageSize,
33
- ...config.queryParams,
34
- };
35
- if (startingAt) {
36
- queryParams.starting_at = startingAt;
70
+ if (isNavigation) {
71
+ setNavigating(true);
37
72
  }
38
- // Fetch the page
39
- const result = await config.fetchPage(queryParams);
40
- // Extract items (handle both array response and paginated response)
41
- const fetchedItems = Array.isArray(result) ? result : result.items;
42
- pageItems.push(...fetchedItems.slice(0, config.pageSize));
43
- // Update pagination metadata
44
- if (!Array.isArray(result)) {
45
- setTotalCount(result.total_count || pageItems.length);
46
- setHasMore(result.has_more || false);
47
- }
48
- else {
49
- setTotalCount(pageItems.length);
50
- setHasMore(false);
73
+ setError(null);
74
+ // Determine startingAt cursor:
75
+ // - Page 0: undefined
76
+ // - Page N: cursorHistory[N-1] (last item ID from previous page)
77
+ const startingAt = page > 0 ? cursorHistoryRef.current[page - 1] : undefined;
78
+ const result = await fetchPageRef.current({
79
+ limit: pageSizeRef.current,
80
+ startingAt,
81
+ });
82
+ if (!isMountedRef.current)
83
+ return;
84
+ // Update items
85
+ setItems(result.items);
86
+ // Update cursor history for this page
87
+ if (result.items.length > 0) {
88
+ const lastItemId = getItemIdRef.current(result.items[result.items.length - 1]);
89
+ cursorHistoryRef.current[page] = lastItemId;
51
90
  }
52
- // Cache the page data and last ID
53
- if (pageItems.length > 0) {
54
- pageCache.current.set(currentPage, pageItems);
55
- lastIdCache.current.set(currentPage, config.getItemId(pageItems[pageItems.length - 1]));
91
+ // Update pagination state
92
+ setHasMore(result.hasMore);
93
+ if (result.totalCount !== undefined) {
94
+ setTotalCount(result.totalCount);
56
95
  }
57
- // Update items for current page
58
- setItems(pageItems);
59
96
  }
60
97
  catch (err) {
98
+ if (!isMountedRef.current)
99
+ return;
61
100
  setError(err);
62
101
  }
63
102
  finally {
64
- setLoading(false);
65
- if (isInitialLoad) {
66
- setTimeout(() => setRefreshing(false), 300);
103
+ if (isMountedRef.current) {
104
+ setLoading(false);
105
+ setNavigating(false);
67
106
  }
107
+ isFetchingRef.current = false;
68
108
  }
69
- }, [currentPage, config]);
70
- // Initial load and page changes
109
+ }, []);
110
+ // Reset when deps change (e.g., filters, search)
111
+ const depsKey = JSON.stringify(deps);
71
112
  React.useEffect(() => {
72
- fetchData(true);
73
- }, [fetchData, currentPage]);
74
- // Auto-refresh
113
+ // Clear cursor history when deps change
114
+ cursorHistoryRef.current = [];
115
+ setCurrentPage(0);
116
+ setItems([]);
117
+ setHasMore(false);
118
+ setTotalCount(0);
119
+ // Fetch page 0
120
+ fetchPageData(0, true);
121
+ }, [depsKey, fetchPageData]);
122
+ // Polling effect
75
123
  React.useEffect(() => {
76
- if (!config.refreshInterval || config.refreshInterval <= 0) {
124
+ if (!pollInterval || pollInterval <= 0 || !pollingEnabled) {
77
125
  return;
78
126
  }
79
- const interval = setInterval(() => {
80
- // Clear cache on refresh
81
- pageCache.current.clear();
82
- lastIdCache.current.clear();
83
- fetchData(false);
84
- }, config.refreshInterval);
85
- return () => clearInterval(interval);
86
- }, [config.refreshInterval, fetchData]);
127
+ const timer = setInterval(() => {
128
+ if (isMountedRef.current && !isFetchingRef.current) {
129
+ fetchPageData(currentPage, false);
130
+ }
131
+ }, pollInterval);
132
+ return () => clearInterval(timer);
133
+ }, [pollInterval, pollingEnabled, currentPage, fetchPageData]);
134
+ // Navigation functions
87
135
  const nextPage = React.useCallback(() => {
88
- if (!loading && hasMore) {
89
- setCurrentPage((prev) => prev + 1);
136
+ if (!loading && !navigating && hasMore) {
137
+ const newPage = currentPage + 1;
138
+ setCurrentPage(newPage);
139
+ fetchPageData(newPage, false, true);
90
140
  }
91
- }, [loading, hasMore]);
141
+ }, [loading, navigating, hasMore, currentPage, fetchPageData]);
92
142
  const prevPage = React.useCallback(() => {
93
- if (!loading && currentPage > 0) {
94
- setCurrentPage((prev) => prev - 1);
95
- }
96
- }, [loading, currentPage]);
97
- const goToPage = React.useCallback((page) => {
98
- if (!loading && page >= 0) {
99
- setCurrentPage(page);
143
+ if (!loading && !navigating && currentPage > 0) {
144
+ const newPage = currentPage - 1;
145
+ setCurrentPage(newPage);
146
+ fetchPageData(newPage, false, true);
100
147
  }
101
- }, [loading]);
148
+ }, [loading, navigating, currentPage, fetchPageData]);
102
149
  const refresh = React.useCallback(() => {
103
- pageCache.current.clear();
104
- lastIdCache.current.clear();
105
- fetchData(true);
106
- }, [fetchData]);
107
- const clearCache = React.useCallback(() => {
108
- pageCache.current.clear();
109
- lastIdCache.current.clear();
110
- }, []);
150
+ fetchPageData(currentPage, false);
151
+ }, [currentPage, fetchPageData]);
111
152
  return {
112
153
  items,
113
154
  loading,
155
+ navigating,
114
156
  error,
115
157
  currentPage,
116
- totalCount,
117
158
  hasMore,
118
- refreshing,
159
+ hasPrev: currentPage > 0,
160
+ totalCount,
119
161
  nextPage,
120
162
  prevPage,
121
- goToPage,
122
163
  refresh,
123
- clearCache,
124
164
  };
125
165
  }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Hook to handle Ctrl+C (SIGINT) consistently across all screens
3
+ * Exits the program with proper cleanup of alternate screen buffer
4
+ */
5
+ import { useInput } from "ink";
6
+ import { exitAlternateScreenBuffer } from "../utils/screen.js";
7
+ import { processUtils } from "../utils/processUtils.js";
8
+ export function useExitOnCtrlC() {
9
+ useInput((input, key) => {
10
+ if (key.ctrl && input === "c") {
11
+ exitAlternateScreenBuffer();
12
+ processUtils.exit(130); // Standard exit code for SIGINT
13
+ }
14
+ });
15
+ }
@@ -0,0 +1,47 @@
1
+ import React from "react";
2
+ import { useStdout } from "ink";
3
+ /**
4
+ * Custom hook to calculate available viewport height for content rendering.
5
+ * Ensures consistent layout calculations across all CLI screens and prevents overflow.
6
+ *
7
+ * @param options Configuration for viewport calculation
8
+ * @returns Viewport dimensions including available height for content
9
+ *
10
+ * @example
11
+ * ```tsx
12
+ * const { viewportHeight } = useViewportHeight({ overhead: 10 });
13
+ * const pageSize = viewportHeight; // Use for dynamic page sizing
14
+ * ```
15
+ */
16
+ export function useViewportHeight(options = {}) {
17
+ const { overhead = 0, minHeight = 5, maxHeight = 100 } = options;
18
+ const { stdout } = useStdout();
19
+ // Sample terminal dimensions ONCE and use fixed values - no reactive dependencies
20
+ // This prevents re-renders and Yoga WASM crashes from dynamic resizing
21
+ // CRITICAL: Initialize with safe fallback values to prevent null/undefined
22
+ const dimensions = React.useRef({
23
+ width: 120,
24
+ height: 30,
25
+ });
26
+ // Only sample on first call when still at default values
27
+ if (dimensions.current.width === 120 && dimensions.current.height === 30) {
28
+ // Only sample if stdout has valid dimensions
29
+ const sampledWidth = stdout?.columns && stdout.columns > 0 ? stdout.columns : 120;
30
+ const sampledHeight = stdout?.rows && stdout.rows > 0 ? stdout.rows : 30;
31
+ // Always enforce safe bounds to prevent Yoga crashes
32
+ dimensions.current = {
33
+ width: Math.max(80, Math.min(200, sampledWidth)),
34
+ height: Math.max(20, Math.min(100, sampledHeight)),
35
+ };
36
+ }
37
+ const terminalHeight = dimensions.current.height;
38
+ const terminalWidth = dimensions.current.width;
39
+ // Calculate viewport height with bounds
40
+ const viewportHeight = Math.max(minHeight, Math.min(maxHeight, terminalHeight - overhead));
41
+ // Removed console.logs to prevent rendering interference
42
+ return {
43
+ viewportHeight,
44
+ terminalHeight,
45
+ terminalWidth,
46
+ };
47
+ }
@@ -4,6 +4,7 @@ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
5
  import { getClient } from "../utils/client.js";
6
6
  import express from "express";
7
+ import { processUtils } from "../utils/processUtils.js";
7
8
  // Define available tools for the MCP server
8
9
  const TOOLS = [
9
10
  {
@@ -412,5 +413,5 @@ async function main() {
412
413
  }
413
414
  main().catch((error) => {
414
415
  console.error("Fatal error in main():", error);
415
- process.exit(1);
416
+ processUtils.exit(1);
416
417
  });
@@ -2,7 +2,62 @@
2
2
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
- import { getClient } from "../utils/client.js";
5
+ import Runloop from "@runloop/api-client";
6
+ import { VERSION } from "@runloop/api-client/version.js";
7
+ import Conf from "conf";
8
+ import { processUtils } from "../utils/processUtils.js";
9
+ let configInstance = null;
10
+ function getConfigInstance() {
11
+ if (!configInstance) {
12
+ configInstance = new Conf({
13
+ projectName: "runloop-cli",
14
+ });
15
+ }
16
+ return configInstance;
17
+ }
18
+ function getConfig() {
19
+ // Check environment variable first
20
+ const envApiKey = process.env.RUNLOOP_API_KEY;
21
+ if (envApiKey) {
22
+ return { apiKey: envApiKey };
23
+ }
24
+ // Fall back to stored config
25
+ try {
26
+ const config = getConfigInstance();
27
+ const apiKey = config.get("apiKey");
28
+ return { apiKey };
29
+ }
30
+ catch (error) {
31
+ console.error("Warning: Failed to load config:", error);
32
+ return {};
33
+ }
34
+ }
35
+ function getBaseUrl() {
36
+ const env = process.env.RUNLOOP_ENV?.toLowerCase();
37
+ switch (env) {
38
+ case "dev":
39
+ return "https://api.runloop.pro";
40
+ case "prod":
41
+ default:
42
+ return "https://api.runloop.ai";
43
+ }
44
+ }
45
+ function getClient() {
46
+ const config = getConfig();
47
+ if (!config.apiKey) {
48
+ throw new Error("API key not configured. Please set RUNLOOP_API_KEY environment variable or run: rli auth");
49
+ }
50
+ const baseURL = getBaseUrl();
51
+ return new Runloop({
52
+ bearerToken: config.apiKey,
53
+ baseURL,
54
+ timeout: 10000, // 10 seconds instead of default 30 seconds
55
+ maxRetries: 2, // 2 retries instead of default 5 (only for retryable errors)
56
+ defaultHeaders: {
57
+ "User-Agent": `Runloop/JS ${VERSION} - CLI MCP`,
58
+ },
59
+ });
60
+ }
6
61
  // Define available tools for the MCP server
7
62
  const TOOLS = [
8
63
  {
@@ -386,12 +441,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
386
441
  });
387
442
  // Start the server
388
443
  async function main() {
389
- const transport = new StdioServerTransport();
390
- await server.connect(transport);
391
- // Log to stderr so it doesn't interfere with MCP protocol on stdout
392
- console.error("Runloop MCP server running on stdio");
444
+ try {
445
+ console.error("[MCP] Starting Runloop MCP server...");
446
+ const transport = new StdioServerTransport();
447
+ console.error("[MCP] Created stdio transport");
448
+ await server.connect(transport);
449
+ // Log to stderr so it doesn't interfere with MCP protocol on stdout
450
+ console.error("[MCP] Server initialization complete, waiting for requests...");
451
+ }
452
+ catch (error) {
453
+ console.error("[MCP] Error in main():", error);
454
+ throw error;
455
+ }
393
456
  }
394
457
  main().catch((error) => {
395
- console.error("Fatal error in main():", error);
396
- process.exit(1);
458
+ console.error("[MCP] Fatal error in main():", error);
459
+ console.error("[MCP] Stack trace:", error instanceof Error ? error.stack : "N/A");
460
+ processUtils.exit(1);
397
461
  });
@@ -0,0 +1,70 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Router - Manages screen navigation with clean mount/unmount lifecycle
4
+ * Replaces conditional rendering pattern from menu.tsx
5
+ */
6
+ import React from "react";
7
+ import { useNavigation } from "../store/navigationStore.js";
8
+ import { useDevboxStore } from "../store/devboxStore.js";
9
+ import { useBlueprintStore } from "../store/blueprintStore.js";
10
+ import { useSnapshotStore } from "../store/snapshotStore.js";
11
+ import { ErrorBoundary } from "../components/ErrorBoundary.js";
12
+ // Import screen components
13
+ import { MenuScreen } from "../screens/MenuScreen.js";
14
+ import { DevboxListScreen } from "../screens/DevboxListScreen.js";
15
+ import { DevboxDetailScreen } from "../screens/DevboxDetailScreen.js";
16
+ import { DevboxActionsScreen } from "../screens/DevboxActionsScreen.js";
17
+ import { DevboxCreateScreen } from "../screens/DevboxCreateScreen.js";
18
+ import { BlueprintListScreen } from "../screens/BlueprintListScreen.js";
19
+ import { BlueprintLogsScreen } from "../screens/BlueprintLogsScreen.js";
20
+ import { SnapshotListScreen } from "../screens/SnapshotListScreen.js";
21
+ import { SSHSessionScreen } from "../screens/SSHSessionScreen.js";
22
+ /**
23
+ * Router component that renders the current screen
24
+ * Implements memory cleanup on route changes
25
+ *
26
+ * Uses React key prop to force complete unmount/remount on screen changes,
27
+ * which prevents Yoga WASM errors during transitions.
28
+ */
29
+ export function Router() {
30
+ const { currentScreen, params } = useNavigation();
31
+ const prevScreenRef = React.useRef(null);
32
+ // Memory cleanup on route changes
33
+ React.useEffect(() => {
34
+ const prevScreen = prevScreenRef.current;
35
+ if (prevScreen && prevScreen !== currentScreen) {
36
+ // Immediate cleanup without delay - React's key-based remount handles timing
37
+ switch (prevScreen) {
38
+ case "devbox-list":
39
+ case "devbox-detail":
40
+ case "devbox-actions":
41
+ case "devbox-create":
42
+ // Clear devbox data when leaving devbox screens
43
+ // Keep cache if we're still in devbox context
44
+ if (!currentScreen.startsWith("devbox")) {
45
+ useDevboxStore.getState().clearAll();
46
+ }
47
+ break;
48
+ case "blueprint-list":
49
+ case "blueprint-detail":
50
+ case "blueprint-logs":
51
+ if (!currentScreen.startsWith("blueprint")) {
52
+ useBlueprintStore.getState().clearAll();
53
+ }
54
+ break;
55
+ case "snapshot-list":
56
+ case "snapshot-detail":
57
+ if (!currentScreen.startsWith("snapshot")) {
58
+ useSnapshotStore.getState().clearAll();
59
+ }
60
+ break;
61
+ }
62
+ }
63
+ prevScreenRef.current = currentScreen;
64
+ }, [currentScreen]);
65
+ // CRITICAL: Use key prop to force React to completely unmount old component
66
+ // and mount new component, preventing race conditions during screen transitions.
67
+ // The key ensures React treats this as a completely new component tree.
68
+ // Wrap in ErrorBoundary to catch any Yoga WASM errors gracefully.
69
+ return (_jsxs(ErrorBoundary, { children: [currentScreen === "menu" && (_jsx(MenuScreen, { ...params }, currentScreen)), currentScreen === "devbox-list" && (_jsx(DevboxListScreen, { ...params }, currentScreen)), currentScreen === "devbox-detail" && (_jsx(DevboxDetailScreen, { ...params }, currentScreen)), currentScreen === "devbox-actions" && (_jsx(DevboxActionsScreen, { ...params }, currentScreen)), currentScreen === "devbox-create" && (_jsx(DevboxCreateScreen, { ...params }, currentScreen)), currentScreen === "blueprint-list" && (_jsx(BlueprintListScreen, { ...params }, currentScreen)), currentScreen === "blueprint-detail" && (_jsx(BlueprintListScreen, { ...params }, currentScreen)), currentScreen === "blueprint-logs" && (_jsx(BlueprintLogsScreen, { ...params }, currentScreen)), currentScreen === "snapshot-list" && (_jsx(SnapshotListScreen, { ...params }, currentScreen)), currentScreen === "snapshot-detail" && (_jsx(SnapshotListScreen, { ...params }, currentScreen)), currentScreen === "ssh-session" && (_jsx(SSHSessionScreen, { ...params }, currentScreen))] }, `boundary-${currentScreen}`));
70
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useNavigation } from "../store/navigationStore.js";
3
+ import { ListBlueprintsUI } from "../commands/blueprint/list.js";
4
+ export function BlueprintListScreen() {
5
+ const { goBack } = useNavigation();
6
+ return _jsx(ListBlueprintsUI, { onBack: goBack, onExit: goBack });
7
+ }
@@ -0,0 +1,74 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * BlueprintLogsScreen - Screen for viewing blueprint build logs
4
+ */
5
+ import React from "react";
6
+ import { Box, Text } from "ink";
7
+ import { useNavigation } from "../store/navigationStore.js";
8
+ import { LogsViewer } from "../components/LogsViewer.js";
9
+ import { Header } from "../components/Header.js";
10
+ import { SpinnerComponent } from "../components/Spinner.js";
11
+ import { Breadcrumb } from "../components/Breadcrumb.js";
12
+ import { ErrorMessage } from "../components/ErrorMessage.js";
13
+ import { getBlueprintLogs } from "../services/blueprintService.js";
14
+ import { colors } from "../utils/theme.js";
15
+ export function BlueprintLogsScreen({ blueprintId }) {
16
+ const { goBack, params } = useNavigation();
17
+ const [logs, setLogs] = React.useState([]);
18
+ const [loading, setLoading] = React.useState(true);
19
+ const [error, setError] = React.useState(null);
20
+ // Use blueprintId from props or params
21
+ const id = blueprintId || params.blueprintId;
22
+ React.useEffect(() => {
23
+ if (!id) {
24
+ goBack();
25
+ return;
26
+ }
27
+ let cancelled = false;
28
+ const fetchLogs = async () => {
29
+ try {
30
+ setLoading(true);
31
+ setError(null);
32
+ const blueprintLogs = await getBlueprintLogs(id);
33
+ if (!cancelled) {
34
+ setLogs(Array.isArray(blueprintLogs) ? blueprintLogs : []);
35
+ setLoading(false);
36
+ }
37
+ }
38
+ catch (err) {
39
+ if (!cancelled) {
40
+ setError(err);
41
+ setLoading(false);
42
+ }
43
+ }
44
+ };
45
+ fetchLogs();
46
+ return () => {
47
+ cancelled = true;
48
+ };
49
+ }, [id, goBack]);
50
+ if (!id) {
51
+ return null;
52
+ }
53
+ // Get blueprint name from params if available (for breadcrumb)
54
+ const blueprintName = params.blueprintName || id;
55
+ if (loading) {
56
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
57
+ { label: "Blueprints" },
58
+ { label: blueprintName },
59
+ { label: "Logs", active: true },
60
+ ] }), _jsx(Header, { title: "Loading Logs" }), _jsx(SpinnerComponent, { message: "Fetching blueprint logs..." })] }));
61
+ }
62
+ if (error) {
63
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
64
+ { label: "Blueprints" },
65
+ { label: blueprintName },
66
+ { label: "Logs", active: true },
67
+ ] }), _jsx(Header, { title: "Error" }), _jsx(ErrorMessage, { message: "Failed to load blueprint logs", error: error }), _jsx(Box, { marginTop: 1, paddingX: 1, children: _jsx(Text, { color: colors.textDim, dimColor: true, children: "Press [q] or [esc] to go back" }) })] }));
68
+ }
69
+ return (_jsx(LogsViewer, { logs: logs, breadcrumbItems: [
70
+ { label: "Blueprints" },
71
+ { label: blueprintName },
72
+ { label: "Logs", active: true },
73
+ ], onBack: goBack, title: "Blueprint Build Logs" }));
74
+ }
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * DevboxActionsScreen - Pure UI component for devbox actions
4
+ * Refactored from components/DevboxActionsMenu.tsx
5
+ */
6
+ import React from "react";
7
+ import { useNavigation } from "../store/navigationStore.js";
8
+ import { useDevboxStore } from "../store/devboxStore.js";
9
+ import { DevboxActionsMenu } from "../components/DevboxActionsMenu.js";
10
+ export function DevboxActionsScreen({ devboxId, operation, }) {
11
+ const { goBack } = useNavigation();
12
+ const devboxes = useDevboxStore((state) => state.devboxes);
13
+ // Find devbox in store
14
+ const devbox = devboxes.find((d) => d.id === devboxId);
15
+ // Navigate back if devbox not found - must be in useEffect, not during render
16
+ React.useEffect(() => {
17
+ if (!devbox) {
18
+ goBack();
19
+ }
20
+ }, [devbox, goBack]);
21
+ if (!devbox) {
22
+ return null;
23
+ }
24
+ return (_jsx(DevboxActionsMenu, { devbox: devbox, onBack: goBack, initialOperation: operation }));
25
+ }
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useNavigation } from "../store/navigationStore.js";
3
+ import { DevboxCreatePage } from "../components/DevboxCreatePage.js";
4
+ export function DevboxCreateScreen() {
5
+ const { goBack } = useNavigation();
6
+ const handleCreate = () => {
7
+ // After creation, go back to list (which will refresh)
8
+ goBack();
9
+ };
10
+ return _jsx(DevboxCreatePage, { onBack: goBack, onCreate: handleCreate });
11
+ }