@runloop/rl-cli 0.2.0 → 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 (39) hide show
  1. package/README.md +0 -10
  2. package/dist/cli.js +7 -13
  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 +8 -7
  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/UpdateNotification.js +56 -0
  21. package/dist/hooks/useExitOnCtrlC.js +2 -1
  22. package/dist/mcp/server-http.js +2 -1
  23. package/dist/mcp/server.js +6 -1
  24. package/dist/router/Router.js +3 -1
  25. package/dist/screens/BlueprintLogsScreen.js +74 -0
  26. package/dist/services/blueprintService.js +18 -22
  27. package/dist/utils/CommandExecutor.js +24 -53
  28. package/dist/utils/client.js +4 -0
  29. package/dist/utils/logFormatter.js +47 -1
  30. package/dist/utils/output.js +4 -3
  31. package/dist/utils/process.js +106 -0
  32. package/dist/utils/processUtils.js +135 -0
  33. package/dist/utils/screen.js +40 -2
  34. package/dist/utils/ssh.js +3 -2
  35. package/dist/utils/terminalDetection.js +120 -32
  36. package/dist/utils/theme.js +34 -19
  37. package/dist/utils/versionCheck.js +53 -0
  38. package/dist/version.js +12 -0
  39. package/package.json +4 -5
@@ -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) {
@@ -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
@@ -1,4 +1,5 @@
1
1
  import Runloop from "@runloop/api-client";
2
+ import { VERSION } from "@runloop/api-client/version.js";
2
3
  import { getConfig } from "./config.js";
3
4
  /**
4
5
  * Get the base URL based on RUNLOOP_ENV environment variable
@@ -24,5 +25,8 @@ export function getClient() {
24
25
  return new Runloop({
25
26
  bearerToken: config.apiKey,
26
27
  baseURL,
28
+ defaultHeaders: {
29
+ "User-Agent": `Runloop/JS ${VERSION} - CLI`,
30
+ },
27
31
  });
28
32
  }
@@ -83,7 +83,7 @@ export function getSourceInfo(source) {
83
83
  return { abbrev, color: "white" };
84
84
  }
85
85
  /**
86
- * Parse a log entry into formatted parts (for use in Ink UI)
86
+ * Parse a devbox log entry into formatted parts (for use in Ink UI)
87
87
  */
88
88
  export function parseLogEntry(log) {
89
89
  const levelInfo = getLogLevelInfo(log.level);
@@ -101,6 +101,52 @@ export function parseLogEntry(log) {
101
101
  exitCodeColor: log.exit_code === 0 ? "green" : "red",
102
102
  };
103
103
  }
104
+ /**
105
+ * Parse a blueprint log entry into formatted parts (for use in Ink UI)
106
+ * Blueprint logs have a simpler structure (no source, shell_name, cmd, exit_code)
107
+ */
108
+ export function parseBlueprintLogEntry(log) {
109
+ const levelInfo = getLogLevelInfo(log.level);
110
+ // Blueprint logs don't have a source, use "build" as default
111
+ const sourceInfo = getSourceInfo("build");
112
+ // Handle timestamp - may be timestamp_ms or timestamp
113
+ let timestampMs;
114
+ if (log.timestamp_ms !== undefined) {
115
+ timestampMs = log.timestamp_ms;
116
+ }
117
+ else if (log.timestamp !== undefined) {
118
+ const ts = log.timestamp;
119
+ timestampMs = typeof ts === "number" ? ts : new Date(ts).getTime();
120
+ }
121
+ else {
122
+ // Fallback to current time if no timestamp
123
+ timestampMs = Date.now();
124
+ }
125
+ return {
126
+ timestamp: formatTimestamp(timestampMs),
127
+ level: levelInfo.name,
128
+ levelColor: levelInfo.color,
129
+ source: sourceInfo.abbrev,
130
+ sourceColor: sourceInfo.color,
131
+ shellName: null,
132
+ cmd: null,
133
+ message: log.message || "",
134
+ exitCode: null,
135
+ exitCodeColor: "gray",
136
+ };
137
+ }
138
+ /**
139
+ * Parse any log entry (devbox or blueprint) into formatted parts
140
+ */
141
+ export function parseAnyLogEntry(log) {
142
+ // Check if it's a devbox log by looking for source field
143
+ if ("source" in log || "shell_name" in log || "cmd" in log) {
144
+ return parseLogEntry(log);
145
+ }
146
+ else {
147
+ return parseBlueprintLogEntry(log);
148
+ }
149
+ }
104
150
  /**
105
151
  * Format a log entry as a string with chalk colors (for CLI output)
106
152
  */
@@ -6,6 +6,7 @@
6
6
  * - outputError(message, error) - outputs error and exits
7
7
  */
8
8
  import YAML from "yaml";
9
+ import { processUtils } from "./processUtils.js";
9
10
  /**
10
11
  * Resolve the output format from options
11
12
  */
@@ -15,7 +16,7 @@ function resolveFormat(options) {
15
16
  return format;
16
17
  }
17
18
  console.error(`Unknown output format: ${format}. Valid options: text, json, yaml`);
18
- process.exit(1);
19
+ processUtils.exit(1);
19
20
  }
20
21
  /**
21
22
  * Format a value for text output (key-value pairs)
@@ -119,7 +120,7 @@ export function outputError(message, error) {
119
120
  if (error && errorMessage !== message) {
120
121
  console.error(` ${errorMessage}`);
121
122
  }
122
- process.exit(1);
123
+ processUtils.exit(1);
123
124
  }
124
125
  /**
125
126
  * Output a success message for action commands
@@ -202,5 +203,5 @@ export function validateOutputFormat(format) {
202
203
  return "yaml";
203
204
  }
204
205
  console.error(`Unknown output format: ${format}. Valid options: text, json, yaml`);
205
- process.exit(1);
206
+ processUtils.exit(1);
206
207
  }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Process utilities wrapper for testability.
3
+ *
4
+ * This module provides wrappers around Node.js process functions
5
+ * that can be easily mocked in tests.
6
+ */
7
+ /**
8
+ * Default implementation using real process
9
+ */
10
+ const defaultProcess = {
11
+ exit: (code) => {
12
+ process.exit(code);
13
+ },
14
+ stdout: {
15
+ write: (data) => process.stdout.write(data),
16
+ get isTTY() {
17
+ return process.stdout.isTTY;
18
+ },
19
+ },
20
+ stderr: {
21
+ write: (data) => process.stderr.write(data),
22
+ },
23
+ get env() {
24
+ return process.env;
25
+ },
26
+ cwd: () => process.cwd(),
27
+ };
28
+ /**
29
+ * Current process implementation - can be swapped for testing
30
+ */
31
+ let currentProcess = defaultProcess;
32
+ /**
33
+ * Get the current process wrapper
34
+ */
35
+ export function getProcess() {
36
+ return currentProcess;
37
+ }
38
+ /**
39
+ * Set a custom process wrapper (for testing)
40
+ */
41
+ export function setProcess(proc) {
42
+ currentProcess = proc;
43
+ }
44
+ /**
45
+ * Reset to the default process wrapper
46
+ */
47
+ export function resetProcess() {
48
+ currentProcess = defaultProcess;
49
+ }
50
+ /**
51
+ * Exit the process with an optional exit code
52
+ */
53
+ export function exitProcess(code) {
54
+ return currentProcess.exit(code);
55
+ }
56
+ /**
57
+ * Write to stdout
58
+ */
59
+ export function writeStdout(data) {
60
+ return currentProcess.stdout.write(data);
61
+ }
62
+ /**
63
+ * Write to stderr
64
+ */
65
+ export function writeStderr(data) {
66
+ return currentProcess.stderr.write(data);
67
+ }
68
+ /**
69
+ * Check if stdout is a TTY
70
+ */
71
+ export function isStdoutTTY() {
72
+ return !!currentProcess.stdout.isTTY;
73
+ }
74
+ /**
75
+ * Get environment variable
76
+ */
77
+ export function getEnv(key) {
78
+ return currentProcess.env[key];
79
+ }
80
+ /**
81
+ * Get current working directory
82
+ */
83
+ export function getCwd() {
84
+ return currentProcess.cwd();
85
+ }
86
+ /**
87
+ * Create a mock process for testing
88
+ */
89
+ export function createMockProcess(overrides = {}) {
90
+ const exitFn = jest.fn();
91
+ const stdoutWriteFn = jest.fn().mockReturnValue(true);
92
+ const stderrWriteFn = jest.fn().mockReturnValue(true);
93
+ const cwdFn = jest.fn().mockReturnValue("/mock/cwd");
94
+ return {
95
+ exit: overrides.exit ?? exitFn,
96
+ stdout: overrides.stdout ?? {
97
+ write: stdoutWriteFn,
98
+ isTTY: true,
99
+ },
100
+ stderr: overrides.stderr ?? {
101
+ write: stderrWriteFn,
102
+ },
103
+ env: overrides.env ?? {},
104
+ cwd: overrides.cwd ?? cwdFn,
105
+ };
106
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Process utilities wrapper for testability.
3
+ *
4
+ * This module provides a mockable interface for process-related operations
5
+ * like exit, stdout/stderr writes, and terminal detection. In tests, you can
6
+ * replace these functions with mocks to avoid actual process termination
7
+ * and capture output.
8
+ *
9
+ * Usage in code:
10
+ * import { processUtils } from '../utils/processUtils.js';
11
+ * processUtils.exit(1);
12
+ * processUtils.stdout.write('Hello');
13
+ *
14
+ * Usage in tests:
15
+ * import { processUtils, resetProcessUtils } from '../utils/processUtils.js';
16
+ * processUtils.exit = jest.fn();
17
+ * // ... run tests ...
18
+ * resetProcessUtils(); // restore original behavior
19
+ */
20
+ // Store original references for reset
21
+ const originalExit = process.exit.bind(process);
22
+ const originalStdoutWrite = process.stdout.write.bind(process.stdout);
23
+ const originalStderrWrite = process.stderr.write.bind(process.stderr);
24
+ const originalCwd = process.cwd.bind(process);
25
+ const originalOn = process.on.bind(process);
26
+ const originalOff = process.off.bind(process);
27
+ /**
28
+ * The main process utilities object.
29
+ * All properties are mutable for testing purposes.
30
+ */
31
+ export const processUtils = {
32
+ exit: originalExit,
33
+ stdout: {
34
+ write: (data) => originalStdoutWrite(data),
35
+ get isTTY() {
36
+ return process.stdout.isTTY ?? false;
37
+ },
38
+ },
39
+ stderr: {
40
+ write: (data) => originalStderrWrite(data),
41
+ get isTTY() {
42
+ return process.stderr.isTTY ?? false;
43
+ },
44
+ },
45
+ stdin: {
46
+ get isTTY() {
47
+ return process.stdin.isTTY ?? false;
48
+ },
49
+ setRawMode: process.stdin.setRawMode?.bind(process.stdin),
50
+ on: process.stdin.on.bind(process.stdin),
51
+ removeListener: process.stdin.removeListener.bind(process.stdin),
52
+ },
53
+ cwd: originalCwd,
54
+ on: originalOn,
55
+ off: originalOff,
56
+ get env() {
57
+ return process.env;
58
+ },
59
+ };
60
+ /**
61
+ * Reset all process utilities to their original implementations.
62
+ * Call this in test teardown to restore normal behavior.
63
+ */
64
+ export function resetProcessUtils() {
65
+ processUtils.exit = originalExit;
66
+ processUtils.stdout.write = (data) => originalStdoutWrite(data);
67
+ processUtils.stderr.write = (data) => originalStderrWrite(data);
68
+ processUtils.cwd = originalCwd;
69
+ processUtils.on = originalOn;
70
+ processUtils.off = originalOff;
71
+ }
72
+ /**
73
+ * Create a mock process utils for testing.
74
+ * Returns an object with jest mock functions.
75
+ */
76
+ export function createMockProcessUtils() {
77
+ const exitMock = (() => {
78
+ throw new Error("process.exit called");
79
+ });
80
+ return {
81
+ exit: exitMock,
82
+ stdout: {
83
+ write: () => true,
84
+ isTTY: false,
85
+ },
86
+ stderr: {
87
+ write: () => true,
88
+ isTTY: false,
89
+ },
90
+ stdin: {
91
+ isTTY: false,
92
+ setRawMode: () => { },
93
+ on: () => { },
94
+ removeListener: () => { },
95
+ },
96
+ cwd: () => "/mock/cwd",
97
+ on: () => { },
98
+ off: () => { },
99
+ env: {},
100
+ };
101
+ }
102
+ /**
103
+ * Install mock process utils for testing.
104
+ * Returns a cleanup function to restore originals.
105
+ */
106
+ export function installMockProcessUtils(mock) {
107
+ const backup = {
108
+ exit: processUtils.exit,
109
+ stdoutWrite: processUtils.stdout.write,
110
+ stderrWrite: processUtils.stderr.write,
111
+ cwd: processUtils.cwd,
112
+ on: processUtils.on,
113
+ off: processUtils.off,
114
+ };
115
+ if (mock.exit)
116
+ processUtils.exit = mock.exit;
117
+ if (mock.stdout?.write)
118
+ processUtils.stdout.write = mock.stdout.write;
119
+ if (mock.stderr?.write)
120
+ processUtils.stderr.write = mock.stderr.write;
121
+ if (mock.cwd)
122
+ processUtils.cwd = mock.cwd;
123
+ if (mock.on)
124
+ processUtils.on = mock.on;
125
+ if (mock.off)
126
+ processUtils.off = mock.off;
127
+ return () => {
128
+ processUtils.exit = backup.exit;
129
+ processUtils.stdout.write = backup.stdoutWrite;
130
+ processUtils.stderr.write = backup.stderrWrite;
131
+ processUtils.cwd = backup.cwd;
132
+ processUtils.on = backup.on;
133
+ processUtils.off = backup.off;
134
+ };
135
+ }