@runloop/rl-cli 0.2.0 → 0.4.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 (42) hide show
  1. package/README.md +5 -75
  2. package/dist/cli.js +24 -56
  3. package/dist/commands/auth.js +2 -1
  4. package/dist/commands/blueprint/list.js +68 -22
  5. package/dist/commands/blueprint/preview.js +38 -42
  6. package/dist/commands/config.js +3 -2
  7. package/dist/commands/devbox/ssh.js +2 -1
  8. package/dist/commands/devbox/tunnel.js +2 -1
  9. package/dist/commands/mcp-http.js +6 -5
  10. package/dist/commands/mcp-install.js +9 -8
  11. package/dist/commands/mcp.js +5 -4
  12. package/dist/commands/menu.js +2 -1
  13. package/dist/components/ActionsPopup.js +18 -17
  14. package/dist/components/Banner.js +7 -1
  15. package/dist/components/Breadcrumb.js +10 -9
  16. package/dist/components/DevboxActionsMenu.js +18 -180
  17. package/dist/components/InteractiveSpawn.js +24 -14
  18. package/dist/components/LogsViewer.js +169 -0
  19. package/dist/components/MainMenu.js +2 -2
  20. package/dist/components/ResourceListView.js +3 -3
  21. package/dist/components/UpdateNotification.js +56 -0
  22. package/dist/hooks/useCursorPagination.js +3 -3
  23. package/dist/hooks/useExitOnCtrlC.js +2 -1
  24. package/dist/mcp/server-http.js +2 -1
  25. package/dist/mcp/server.js +7 -2
  26. package/dist/router/Router.js +3 -1
  27. package/dist/screens/BlueprintLogsScreen.js +74 -0
  28. package/dist/services/blueprintService.js +18 -22
  29. package/dist/utils/CommandExecutor.js +24 -53
  30. package/dist/utils/client.js +5 -1
  31. package/dist/utils/config.js +2 -1
  32. package/dist/utils/logFormatter.js +47 -1
  33. package/dist/utils/output.js +4 -3
  34. package/dist/utils/process.js +106 -0
  35. package/dist/utils/processUtils.js +135 -0
  36. package/dist/utils/screen.js +40 -2
  37. package/dist/utils/ssh.js +3 -2
  38. package/dist/utils/terminalDetection.js +120 -32
  39. package/dist/utils/theme.js +34 -19
  40. package/dist/utils/versionCheck.js +53 -0
  41. package/dist/version.js +12 -0
  42. package/package.json +4 -6
@@ -0,0 +1,169 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * LogsViewer - Shared component for viewing logs (devbox or blueprint)
4
+ * Extracted from DevboxActionsMenu for reuse
5
+ */
6
+ import React from "react";
7
+ import { Box, Text, useInput } from "ink";
8
+ import figures from "figures";
9
+ import { Breadcrumb } from "./Breadcrumb.js";
10
+ import { colors } from "../utils/theme.js";
11
+ import { useViewportHeight } from "../hooks/useViewportHeight.js";
12
+ import { parseAnyLogEntry } from "../utils/logFormatter.js";
13
+ export const LogsViewer = ({ logs, breadcrumbItems = [{ label: "Logs", active: true }], onBack, title = "Logs", }) => {
14
+ const [logsWrapMode, setLogsWrapMode] = React.useState(false);
15
+ const [logsScroll, setLogsScroll] = React.useState(0);
16
+ const [copyStatus, setCopyStatus] = React.useState(null);
17
+ // Calculate viewport for logs output:
18
+ // - Breadcrumb (3 lines + marginBottom): 4 lines
19
+ // - Log box borders: 2 lines
20
+ // - Stats bar (marginTop + content): 2 lines
21
+ // - Help bar (marginTop + content): 2 lines
22
+ // - Safety buffer: 1 line
23
+ // Total: 11 lines
24
+ const logsViewport = useViewportHeight({ overhead: 11, minHeight: 10 });
25
+ // Handle input for logs navigation
26
+ useInput((input, key) => {
27
+ if (key.upArrow || input === "k") {
28
+ setLogsScroll(Math.max(0, logsScroll - 1));
29
+ }
30
+ else if (key.downArrow || input === "j") {
31
+ setLogsScroll(logsScroll + 1);
32
+ }
33
+ else if (key.pageUp) {
34
+ setLogsScroll(Math.max(0, logsScroll - 10));
35
+ }
36
+ else if (key.pageDown) {
37
+ setLogsScroll(logsScroll + 10);
38
+ }
39
+ else if (input === "g") {
40
+ setLogsScroll(0);
41
+ }
42
+ else if (input === "G") {
43
+ const maxScroll = Math.max(0, logs.length - logsViewport.viewportHeight);
44
+ setLogsScroll(maxScroll);
45
+ }
46
+ else if (input === "w") {
47
+ setLogsWrapMode(!logsWrapMode);
48
+ }
49
+ else if (input === "c") {
50
+ // Copy logs to clipboard using shared formatter
51
+ const logsText = logs
52
+ .map((log) => {
53
+ const parts = parseAnyLogEntry(log);
54
+ const cmd = parts.cmd ? `$ ${parts.cmd} ` : "";
55
+ const exitCode = parts.exitCode !== null ? `exit=${parts.exitCode} ` : "";
56
+ const shell = parts.shellName ? `(${parts.shellName}) ` : "";
57
+ return `${parts.timestamp} ${parts.level} [${parts.source}] ${shell}${cmd}${parts.message} ${exitCode}`.trim();
58
+ })
59
+ .join("\n");
60
+ const copyToClipboard = async (text) => {
61
+ const { spawn } = await import("child_process");
62
+ const platform = process.platform;
63
+ let command;
64
+ let args;
65
+ if (platform === "darwin") {
66
+ command = "pbcopy";
67
+ args = [];
68
+ }
69
+ else if (platform === "win32") {
70
+ command = "clip";
71
+ args = [];
72
+ }
73
+ else {
74
+ command = "xclip";
75
+ args = ["-selection", "clipboard"];
76
+ }
77
+ const proc = spawn(command, args);
78
+ proc.stdin.write(text);
79
+ proc.stdin.end();
80
+ proc.on("exit", (code) => {
81
+ if (code === 0) {
82
+ setCopyStatus("Copied to clipboard!");
83
+ setTimeout(() => setCopyStatus(null), 2000);
84
+ }
85
+ else {
86
+ setCopyStatus("Failed to copy");
87
+ setTimeout(() => setCopyStatus(null), 2000);
88
+ }
89
+ });
90
+ proc.on("error", () => {
91
+ setCopyStatus("Copy not supported");
92
+ setTimeout(() => setCopyStatus(null), 2000);
93
+ });
94
+ };
95
+ copyToClipboard(logsText);
96
+ }
97
+ else if (input === "q" || key.escape || key.return) {
98
+ onBack();
99
+ }
100
+ });
101
+ const viewportHeight = Math.max(1, logsViewport.viewportHeight);
102
+ const terminalWidth = logsViewport.terminalWidth;
103
+ const maxScroll = Math.max(0, logs.length - viewportHeight);
104
+ const actualScroll = Math.min(logsScroll, maxScroll);
105
+ const visibleLogs = logs.slice(actualScroll, actualScroll + viewportHeight);
106
+ const hasMore = actualScroll + viewportHeight < logs.length;
107
+ const hasLess = actualScroll > 0;
108
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems }), _jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.border, paddingX: 1, children: logs.length === 0 ? (_jsx(Text, { color: colors.textDim, dimColor: true, children: "No logs available" })) : (visibleLogs.map((log, index) => {
109
+ const parts = parseAnyLogEntry(log);
110
+ // Sanitize message: escape special chars to prevent layout breaks
111
+ const escapedMessage = parts.message
112
+ .replace(/\r\n/g, "\\n")
113
+ .replace(/\n/g, "\\n")
114
+ .replace(/\r/g, "\\r")
115
+ .replace(/\t/g, "\\t");
116
+ // Limit message length to prevent Yoga layout engine errors
117
+ const MAX_MESSAGE_LENGTH = 1000;
118
+ const fullMessage = escapedMessage.length > MAX_MESSAGE_LENGTH
119
+ ? escapedMessage.substring(0, MAX_MESSAGE_LENGTH) + "..."
120
+ : escapedMessage;
121
+ const cmd = parts.cmd
122
+ ? `$ ${parts.cmd.substring(0, 40)}${parts.cmd.length > 40 ? "..." : ""} `
123
+ : "";
124
+ const exitCode = parts.exitCode !== null ? `exit=${parts.exitCode} ` : "";
125
+ // Map color names to theme colors
126
+ const levelColorMap = {
127
+ red: colors.error,
128
+ yellow: colors.warning,
129
+ blue: colors.primary,
130
+ gray: colors.textDim,
131
+ };
132
+ const sourceColorMap = {
133
+ magenta: "#d33682",
134
+ cyan: colors.info,
135
+ green: colors.success,
136
+ yellow: colors.warning,
137
+ gray: colors.textDim,
138
+ white: colors.text,
139
+ };
140
+ const levelColor = levelColorMap[parts.levelColor] || colors.textDim;
141
+ const sourceColor = sourceColorMap[parts.sourceColor] || colors.textDim;
142
+ if (logsWrapMode) {
143
+ return (_jsxs(Box, { children: [_jsx(Text, { color: colors.textDim, dimColor: true, children: parts.timestamp }), _jsx(Text, { children: " " }), _jsx(Text, { color: levelColor, bold: parts.levelColor === "red", children: parts.level }), _jsx(Text, { children: " " }), _jsxs(Text, { color: sourceColor, children: ["[", parts.source, "]"] }), _jsx(Text, { children: " " }), parts.shellName && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["(", parts.shellName, ")", " "] })), cmd && _jsx(Text, { color: colors.info, children: cmd }), _jsx(Text, { children: fullMessage }), exitCode && (_jsxs(Text, { color: parts.exitCode === 0 ? colors.success : colors.error, children: [" ", exitCode] }))] }, index));
144
+ }
145
+ else {
146
+ // Calculate available width for message truncation
147
+ const timestampLen = parts.timestamp.length;
148
+ const levelLen = parts.level.length;
149
+ const sourceLen = parts.source.length + 2; // brackets
150
+ const shellLen = parts.shellName ? parts.shellName.length + 3 : 0;
151
+ const cmdLen = cmd.length;
152
+ const exitLen = exitCode.length;
153
+ const spacesLen = 5; // spaces between elements
154
+ const metadataWidth = timestampLen +
155
+ levelLen +
156
+ sourceLen +
157
+ shellLen +
158
+ cmdLen +
159
+ exitLen +
160
+ spacesLen;
161
+ const safeTerminalWidth = Math.max(80, terminalWidth);
162
+ const availableMessageWidth = Math.max(20, safeTerminalWidth - metadataWidth);
163
+ const truncatedMessage = fullMessage.length > availableMessageWidth
164
+ ? fullMessage.substring(0, Math.max(1, availableMessageWidth - 3)) + "..."
165
+ : fullMessage;
166
+ return (_jsxs(Box, { children: [_jsx(Text, { color: colors.textDim, dimColor: true, children: parts.timestamp }), _jsx(Text, { children: " " }), _jsx(Text, { color: levelColor, bold: parts.levelColor === "red", children: parts.level }), _jsx(Text, { children: " " }), _jsxs(Text, { color: sourceColor, children: ["[", parts.source, "]"] }), _jsx(Text, { children: " " }), parts.shellName && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["(", parts.shellName, ")", " "] })), cmd && _jsx(Text, { color: colors.info, children: cmd }), _jsx(Text, { children: truncatedMessage }), exitCode && (_jsxs(Text, { color: parts.exitCode === 0 ? colors.success : colors.error, children: [" ", exitCode] }))] }, index));
167
+ }
168
+ })) }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", logs.length] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "total logs"] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Viewing ", actualScroll + 1, "-", Math.min(actualScroll + viewportHeight, logs.length), " of", " ", logs.length] }), hasLess && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowUp] }), hasMore && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowDown] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsx(Text, { color: logsWrapMode ? colors.success : colors.textDim, bold: logsWrapMode, children: logsWrapMode ? "Wrap: ON" : "Wrap: OFF" }), copyStatus && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsx(Text, { color: colors.success, bold: true, children: copyStatus })] }))] }), _jsx(Box, { marginTop: 1, paddingX: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022 [g] Top \u2022 [G] Bottom \u2022 [w] Toggle Wrap \u2022 [c] Copy \u2022 [Enter], [q], or [esc] Back"] }) })] }));
169
+ };
@@ -4,7 +4,7 @@ import { Box, Text, useInput, useApp } from "ink";
4
4
  import figures from "figures";
5
5
  import { Banner } from "./Banner.js";
6
6
  import { Breadcrumb } from "./Breadcrumb.js";
7
- import { VERSION } from "../cli.js";
7
+ import { VERSION } from "../version.js";
8
8
  import { colors } from "../utils/theme.js";
9
9
  import { useViewportHeight } from "../hooks/useViewportHeight.js";
10
10
  import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
@@ -69,7 +69,7 @@ export const MainMenu = ({ onSelect }) => {
69
69
  return (_jsxs(Box, { marginBottom: 0, children: [_jsx(Text, { color: isSelected ? item.color : colors.textDim, children: isSelected ? figures.pointer : " " }), _jsx(Text, { children: " " }), _jsx(Text, { color: item.color, bold: true, children: item.icon }), _jsx(Text, { children: " " }), _jsx(Text, { color: isSelected ? item.color : colors.text, bold: isSelected, children: item.label }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "- ", item.description] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[", index + 1, "]"] })] }, item.key));
70
70
  }) }), _jsx(Box, { paddingX: 2, marginTop: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022 [1-3] Quick select \u2022 [Enter] Select \u2022 [Esc] Quit"] }) })] }));
71
71
  }
72
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Breadcrumb, { items: [{ label: "Home", active: true }] }), _jsx(Banner, {}), _jsx(Box, { flexDirection: "column", paddingX: 2, flexShrink: 0, children: _jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Cloud development environments for your team \u2022 v", VERSION] }) }) }), _jsxs(Box, { flexDirection: "column", paddingX: 2, marginTop: 1, flexGrow: 1, children: [_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(Text, { color: colors.text, bold: true, children: "Select a resource:" }) }), menuItems.map((item, index) => {
72
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Breadcrumb, { items: [{ label: "Home", active: true }], showVersionCheck: true }), _jsx(Banner, {}), _jsx(Box, { flexDirection: "column", paddingX: 2, flexShrink: 0, children: _jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Cloud development environments for your team \u2022 v", VERSION] }) }) }), _jsxs(Box, { flexDirection: "column", paddingX: 2, marginTop: 1, flexGrow: 1, children: [_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(Text, { color: colors.text, bold: true, children: "Select a resource:" }) }), menuItems.map((item, index) => {
73
73
  const isSelected = index === selectedIndex;
74
74
  return (_jsxs(Box, { paddingX: 2, paddingY: 0, borderStyle: "single", borderColor: isSelected ? item.color : colors.border, marginTop: index === 0 ? 1 : 0, flexShrink: 0, children: [isSelected && (_jsxs(_Fragment, { children: [_jsx(Text, { color: item.color, bold: true, children: figures.pointer }), _jsx(Text, { children: " " })] })), _jsx(Text, { color: item.color, bold: true, children: item.icon }), _jsx(Text, { children: " " }), _jsx(Text, { color: isSelected ? item.color : colors.text, bold: isSelected, children: item.label }), _jsx(Text, { color: colors.textDim, children: " " }), _jsx(Text, { color: colors.textDim, dimColor: true, children: item.description }), _jsx(Text, { children: " " }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["[", index + 1, "]"] })] }, item.key));
75
75
  })] }), _jsx(Box, { paddingX: 2, flexShrink: 0, children: _jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022 [1-3] Quick select \u2022 [Enter] Select \u2022 [Esc] Quit"] }) }) })] }));
@@ -87,15 +87,15 @@ export function ResourceListView({ config }) {
87
87
  React.useEffect(() => {
88
88
  fetchData(true);
89
89
  }, [fetchData]);
90
- // Auto-refresh
90
+ // Auto-refresh - STOP refreshing when there's an error to avoid flickering
91
91
  React.useEffect(() => {
92
- if (config.autoRefresh?.enabled) {
92
+ if (config.autoRefresh?.enabled && !error) {
93
93
  const interval = setInterval(() => {
94
94
  fetchData(false);
95
95
  }, config.autoRefresh.interval || 3000);
96
96
  return () => clearInterval(interval);
97
97
  }
98
- }, [config.autoRefresh, fetchData]);
98
+ }, [config.autoRefresh, fetchData, error]);
99
99
  // Removed refresh icon animation to prevent constant re-renders and flashing
100
100
  // Filter resources based on search query
101
101
  const filteredResources = React.useMemo(() => {
@@ -0,0 +1,56 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from "react";
3
+ import { Box, Text } from "ink";
4
+ import { colors } from "../utils/theme.js";
5
+ import { VERSION } from "../version.js";
6
+ /**
7
+ * Version check component that checks npm for updates and displays a notification
8
+ * Restored from git history and enhanced with better visual styling
9
+ */
10
+ export const UpdateNotification = () => {
11
+ const [updateAvailable, setUpdateAvailable] = React.useState(null);
12
+ const [isChecking, setIsChecking] = React.useState(true);
13
+ React.useEffect(() => {
14
+ const checkForUpdates = async () => {
15
+ try {
16
+ const currentVersion = VERSION;
17
+ const response = await fetch("https://registry.npmjs.org/@runloop/rl-cli/latest");
18
+ if (response.ok) {
19
+ const data = (await response.json());
20
+ const latestVersion = data.version;
21
+ if (latestVersion && latestVersion !== currentVersion) {
22
+ // Check if current version is older than latest
23
+ const compareVersions = (version1, version2) => {
24
+ const v1parts = version1.split(".").map(Number);
25
+ const v2parts = version2.split(".").map(Number);
26
+ for (let i = 0; i < Math.max(v1parts.length, v2parts.length); i++) {
27
+ const v1part = v1parts[i] || 0;
28
+ const v2part = v2parts[i] || 0;
29
+ if (v1part > v2part)
30
+ return 1;
31
+ if (v1part < v2part)
32
+ return -1;
33
+ }
34
+ return 0;
35
+ };
36
+ const isUpdateAvailable = compareVersions(latestVersion, currentVersion) > 0;
37
+ if (isUpdateAvailable) {
38
+ setUpdateAvailable(latestVersion);
39
+ }
40
+ }
41
+ }
42
+ }
43
+ catch {
44
+ // Silently fail
45
+ }
46
+ finally {
47
+ setIsChecking(false);
48
+ }
49
+ };
50
+ checkForUpdates();
51
+ }, []);
52
+ if (isChecking || !updateAvailable) {
53
+ return null;
54
+ }
55
+ return (_jsxs(Box, { borderStyle: "round", borderColor: colors.warning, paddingX: 1, paddingY: 0, marginTop: 0, children: [_jsx(Text, { color: colors.warning, bold: true, children: "\u2728" }), _jsxs(Text, { color: colors.text, bold: true, children: [" ", "Update available:", " "] }), _jsx(Text, { color: colors.warning, bold: 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.text, bold: true, children: [" ", "\u2022 Run:", " "] }), _jsx(Text, { color: colors.primary, bold: true, children: "npm install -g @runloop/rl-cli@latest" })] }));
56
+ };
@@ -119,9 +119,9 @@ export function useCursorPagination(config) {
119
119
  // Fetch page 0
120
120
  fetchPageData(0, true);
121
121
  }, [depsKey, fetchPageData]);
122
- // Polling effect
122
+ // Polling effect - STOP polling when there's an error to avoid flickering
123
123
  React.useEffect(() => {
124
- if (!pollInterval || pollInterval <= 0 || !pollingEnabled) {
124
+ if (!pollInterval || pollInterval <= 0 || !pollingEnabled || error) {
125
125
  return;
126
126
  }
127
127
  const timer = setInterval(() => {
@@ -130,7 +130,7 @@ export function useCursorPagination(config) {
130
130
  }
131
131
  }, pollInterval);
132
132
  return () => clearInterval(timer);
133
- }, [pollInterval, pollingEnabled, currentPage, fetchPageData]);
133
+ }, [pollInterval, pollingEnabled, currentPage, fetchPageData, error]);
134
134
  // Navigation functions
135
135
  const nextPage = React.useCallback(() => {
136
136
  if (!loading && !navigating && hasMore) {
@@ -4,11 +4,12 @@
4
4
  */
5
5
  import { useInput } from "ink";
6
6
  import { exitAlternateScreenBuffer } from "../utils/screen.js";
7
+ import { processUtils } from "../utils/processUtils.js";
7
8
  export function useExitOnCtrlC() {
8
9
  useInput((input, key) => {
9
10
  if (key.ctrl && input === "c") {
10
11
  exitAlternateScreenBuffer();
11
- process.exit(130); // Standard exit code for SIGINT
12
+ processUtils.exit(130); // Standard exit code for SIGINT
12
13
  }
13
14
  });
14
15
  }
@@ -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
  });
@@ -3,7 +3,9 @@ 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
5
  import Runloop from "@runloop/api-client";
6
+ import { VERSION } from "@runloop/api-client/version.js";
6
7
  import Conf from "conf";
8
+ import { processUtils } from "../utils/processUtils.js";
7
9
  let configInstance = null;
8
10
  function getConfigInstance() {
9
11
  if (!configInstance) {
@@ -43,7 +45,7 @@ function getBaseUrl() {
43
45
  function getClient() {
44
46
  const config = getConfig();
45
47
  if (!config.apiKey) {
46
- throw new Error("API key not configured. Please set RUNLOOP_API_KEY environment variable or run: rli auth");
48
+ throw new Error("API key not configured. Please set RUNLOOP_API_KEY environment variable.");
47
49
  }
48
50
  const baseURL = getBaseUrl();
49
51
  return new Runloop({
@@ -51,6 +53,9 @@ function getClient() {
51
53
  baseURL,
52
54
  timeout: 10000, // 10 seconds instead of default 30 seconds
53
55
  maxRetries: 2, // 2 retries instead of default 5 (only for retryable errors)
56
+ defaultHeaders: {
57
+ "User-Agent": `Runloop/JS ${VERSION} - CLI MCP`,
58
+ },
54
59
  });
55
60
  }
56
61
  // Define available tools for the MCP server
@@ -452,5 +457,5 @@ async function main() {
452
457
  main().catch((error) => {
453
458
  console.error("[MCP] Fatal error in main():", error);
454
459
  console.error("[MCP] Stack trace:", error instanceof Error ? error.stack : "N/A");
455
- process.exit(1);
460
+ processUtils.exit(1);
456
461
  });
@@ -16,6 +16,7 @@ import { DevboxDetailScreen } from "../screens/DevboxDetailScreen.js";
16
16
  import { DevboxActionsScreen } from "../screens/DevboxActionsScreen.js";
17
17
  import { DevboxCreateScreen } from "../screens/DevboxCreateScreen.js";
18
18
  import { BlueprintListScreen } from "../screens/BlueprintListScreen.js";
19
+ import { BlueprintLogsScreen } from "../screens/BlueprintLogsScreen.js";
19
20
  import { SnapshotListScreen } from "../screens/SnapshotListScreen.js";
20
21
  import { SSHSessionScreen } from "../screens/SSHSessionScreen.js";
21
22
  /**
@@ -46,6 +47,7 @@ export function Router() {
46
47
  break;
47
48
  case "blueprint-list":
48
49
  case "blueprint-detail":
50
+ case "blueprint-logs":
49
51
  if (!currentScreen.startsWith("blueprint")) {
50
52
  useBlueprintStore.getState().clearAll();
51
53
  }
@@ -64,5 +66,5 @@ export function Router() {
64
66
  // and mount new component, preventing race conditions during screen transitions.
65
67
  // The key ensures React treats this as a completely new component tree.
66
68
  // Wrap in ErrorBoundary to catch any Yoga WASM errors gracefully.
67
- 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 === "snapshot-list" && (_jsx(SnapshotListScreen, { ...params }, currentScreen)), currentScreen === "snapshot-detail" && (_jsx(SnapshotListScreen, { ...params }, currentScreen)), currentScreen === "ssh-session" && (_jsx(SSHSessionScreen, { ...params }, currentScreen))] }, `boundary-${currentScreen}`));
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}`));
68
70
  }
@@ -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
+ }
@@ -72,34 +72,30 @@ export async function getBlueprint(id) {
72
72
  }
73
73
  /**
74
74
  * Get blueprint logs
75
+ * Returns the raw logs array from the API response
76
+ * Similar to getDevboxLogs - formatting is handled by logFormatter
75
77
  */
76
78
  export async function getBlueprintLogs(id) {
77
79
  const client = getClient();
78
80
  const response = await client.blueprints.logs(id);
79
- // CRITICAL: Truncate all strings to prevent Yoga crashes
80
- const MAX_MESSAGE_LENGTH = 1000;
81
- const MAX_LEVEL_LENGTH = 20;
82
- const logs = [];
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
83
  if (response.logs && Array.isArray(response.logs)) {
84
- response.logs.forEach((log) => {
85
- // Truncate message and escape newlines
86
- let message = String(log.message || "");
87
- if (message.length > MAX_MESSAGE_LENGTH) {
88
- message = message.substring(0, MAX_MESSAGE_LENGTH) + "...";
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
+ }
89
96
  }
90
- message = message
91
- .replace(/\r\n/g, "\\n")
92
- .replace(/\n/g, "\\n")
93
- .replace(/\r/g, "\\r")
94
- .replace(/\t/g, "\\t");
95
- logs.push({
96
- timestamp: log.timestamp,
97
- message,
98
- level: log.level
99
- ? String(log.level).substring(0, MAX_LEVEL_LENGTH)
100
- : undefined,
101
- });
97
+ return normalizedLog;
102
98
  });
103
99
  }
104
- return logs;
100
+ return [];
105
101
  }
@@ -5,8 +5,6 @@
5
5
  import { render } from "ink";
6
6
  import { getClient } from "./client.js";
7
7
  import { shouldUseNonInteractiveOutput, outputList, outputResult, } from "./output.js";
8
- import { enableSynchronousUpdates, disableSynchronousUpdates, } from "./terminalSync.js";
9
- import { exitAlternateScreenBuffer, enterAlternateScreenBuffer, } from "./screen.js";
10
8
  import YAML from "yaml";
11
9
  export class CommandExecutor {
12
10
  options;
@@ -34,16 +32,13 @@ export class CommandExecutor {
34
32
  return;
35
33
  }
36
34
  // Interactive mode
37
- // Enter alternate screen buffer (this automatically clears the screen)
38
- enableSynchronousUpdates();
39
- const { waitUntilExit } = render(renderUI(), {
40
- patchConsole: false,
41
- exitOnCtrlC: false,
42
- });
35
+ // Enter alternate screen buffer
36
+ process.stdout.write("\x1b[?1049h");
37
+ console.clear();
38
+ const { waitUntilExit } = render(renderUI());
43
39
  await waitUntilExit();
44
40
  // Exit alternate screen buffer
45
- disableSynchronousUpdates();
46
- exitAlternateScreenBuffer();
41
+ process.stdout.write("\x1b[?1049l");
47
42
  }
48
43
  /**
49
44
  * Execute a create/action command with automatic format handling
@@ -60,17 +55,13 @@ export class CommandExecutor {
60
55
  return;
61
56
  }
62
57
  // Interactive mode
63
- // Enter alternate screen buffer (this automatically clears the screen)
64
- enterAlternateScreenBuffer();
65
- enableSynchronousUpdates();
66
- const { waitUntilExit } = render(renderUI(), {
67
- patchConsole: false,
68
- exitOnCtrlC: false,
69
- });
58
+ // Enter alternate screen buffer
59
+ process.stdout.write("\x1b[?1049h");
60
+ console.clear();
61
+ const { waitUntilExit } = render(renderUI());
70
62
  await waitUntilExit();
71
63
  // Exit alternate screen buffer
72
- disableSynchronousUpdates();
73
- exitAlternateScreenBuffer();
64
+ process.stdout.write("\x1b[?1049l");
74
65
  }
75
66
  /**
76
67
  * Execute a delete command with automatic format handling
@@ -88,50 +79,30 @@ export class CommandExecutor {
88
79
  }
89
80
  // Interactive mode
90
81
  // Enter alternate screen buffer
91
- enterAlternateScreenBuffer();
92
- enableSynchronousUpdates();
93
- const { waitUntilExit } = render(renderUI(), {
94
- patchConsole: false,
95
- exitOnCtrlC: false,
96
- });
82
+ process.stdout.write("\x1b[?1049h");
83
+ const { waitUntilExit } = render(renderUI());
97
84
  await waitUntilExit();
98
85
  // Exit alternate screen buffer
99
- disableSynchronousUpdates();
100
- exitAlternateScreenBuffer();
86
+ process.stdout.write("\x1b[?1049l");
101
87
  }
102
88
  /**
103
89
  * Fetch items from an async iterator with optional filtering and limits
104
- * IMPORTANT: This method tries to access the page data directly first to avoid
105
- * auto-pagination issues that can cause memory errors with large datasets.
106
90
  */
107
91
  async fetchFromIterator(iterator, options = {}) {
108
92
  const { filter, limit = 100 } = options;
109
- let items = [];
110
- // Try to access page data directly to avoid auto-pagination
111
- const pageData = iterator.data || iterator.items;
112
- if (pageData && Array.isArray(pageData)) {
113
- items = pageData;
114
- }
115
- else {
116
- // Fall back to iteration with limit
117
- let count = 0;
118
- for await (const item of iterator) {
119
- if (filter && !filter(item)) {
120
- continue;
121
- }
122
- items.push(item);
123
- count++;
124
- if (count >= limit) {
125
- break;
126
- }
93
+ const items = [];
94
+ let count = 0;
95
+ for await (const item of iterator) {
96
+ if (filter && !filter(item)) {
97
+ continue;
98
+ }
99
+ items.push(item);
100
+ count++;
101
+ if (count >= limit) {
102
+ break;
127
103
  }
128
104
  }
129
- // Apply filter if provided
130
- if (filter) {
131
- items = items.filter(filter);
132
- }
133
- // Apply limit
134
- return items.slice(0, limit);
105
+ return items;
135
106
  }
136
107
  /**
137
108
  * Handle errors consistently across all commands