@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
package/README.md CHANGED
@@ -253,16 +253,6 @@ npm run dev
253
253
  npm start -- <command>
254
254
  ```
255
255
 
256
- ## Tech Stack
257
-
258
- - [Ink](https://github.com/vadimdemedes/ink) - React for CLIs
259
- - [Ink Gradient](https://github.com/sindresorhus/ink-gradient) - Gradient text
260
- - [Ink Big Text](https://github.com/sindresorhus/ink-big-text) - ASCII art
261
- - [Commander.js](https://github.com/tj/commander.js) - CLI framework
262
- - [@runloop/api-client](https://github.com/runloopai/api-client-ts) - Runloop API client
263
- - TypeScript - Type safety
264
- - [Figures](https://github.com/sindresorhus/figures) - Unicode symbols
265
-
266
256
  ## Publishing
267
257
 
268
258
  To publish a new version to npm:
package/dist/cli.js CHANGED
@@ -6,21 +6,15 @@ import { deleteDevbox } from "./commands/devbox/delete.js";
6
6
  import { execCommand } from "./commands/devbox/exec.js";
7
7
  import { uploadFile } from "./commands/devbox/upload.js";
8
8
  import { getConfig } from "./utils/config.js";
9
- import { readFileSync } from "fs";
10
- import { fileURLToPath } from "url";
11
- import { dirname, join } from "path";
12
- // Get version from package.json
13
- const __filename = fileURLToPath(import.meta.url);
14
- const __dirname = dirname(__filename);
15
- const packageJson = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf8"));
16
- export const VERSION = packageJson.version;
9
+ import { VERSION } from "./version.js";
17
10
  import { exitAlternateScreenBuffer } from "./utils/screen.js";
11
+ import { processUtils } from "./utils/processUtils.js";
18
12
  // Global Ctrl+C handler to ensure it always exits
19
- process.on("SIGINT", () => {
13
+ processUtils.on("SIGINT", () => {
20
14
  // Force exit immediately, clearing alternate screen buffer
21
15
  exitAlternateScreenBuffer();
22
- process.stdout.write("\n");
23
- process.exit(130); // Standard exit code for SIGINT
16
+ processUtils.stdout.write("\n");
17
+ processUtils.exit(130); // Standard exit code for SIGINT
24
18
  });
25
19
  const program = new Command();
26
20
  program
@@ -55,7 +49,7 @@ config
55
49
  }
56
50
  else {
57
51
  console.error(`\n❌ Invalid theme mode: ${mode}\nValid options: auto, light, dark\n`);
58
- process.exit(1);
52
+ processUtils.exit(1);
59
53
  }
60
54
  });
61
55
  // Devbox commands
@@ -464,7 +458,7 @@ program
464
458
  const config = getConfig();
465
459
  if (!config.apiKey) {
466
460
  console.error("\n❌ API key not configured. Run: rli auth\n");
467
- process.exit(1);
461
+ processUtils.exit(1);
468
462
  }
469
463
  }
470
464
  // If no command provided, show main menu
@@ -8,6 +8,7 @@ import { Banner } from "../components/Banner.js";
8
8
  import { SuccessMessage } from "../components/SuccessMessage.js";
9
9
  import { getSettingsUrl } from "../utils/url.js";
10
10
  import { colors } from "../utils/theme.js";
11
+ import { processUtils } from "../utils/processUtils.js";
11
12
  const AuthUI = () => {
12
13
  const [apiKey, setApiKeyInput] = React.useState("");
13
14
  const [saved, setSaved] = React.useState(false);
@@ -15,7 +16,7 @@ const AuthUI = () => {
15
16
  if (key.return && apiKey.trim()) {
16
17
  setApiKey(apiKey.trim());
17
18
  setSaved(true);
18
- setTimeout(() => process.exit(0), 1000);
19
+ setTimeout(() => processUtils.exit(0), 1000);
19
20
  }
20
21
  });
21
22
  if (saved) {
@@ -20,6 +20,7 @@ import { DevboxCreatePage } from "../../components/DevboxCreatePage.js";
20
20
  import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js";
21
21
  import { useViewportHeight } from "../../hooks/useViewportHeight.js";
22
22
  import { useCursorPagination } from "../../hooks/useCursorPagination.js";
23
+ import { useNavigation } from "../../store/navigationStore.js";
23
24
  const DEFAULT_PAGE_SIZE = 10;
24
25
  const ListBlueprintsUI = ({ onBack, onExit, }) => {
25
26
  const { exit: inkExit } = useApp();
@@ -34,6 +35,7 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
34
35
  const [showCreateDevbox, setShowCreateDevbox] = React.useState(false);
35
36
  const [selectedIndex, setSelectedIndex] = React.useState(0);
36
37
  const [showPopup, setShowPopup] = React.useState(false);
38
+ const { navigate } = useNavigation();
37
39
  // Calculate overhead for viewport height
38
40
  const overhead = 13;
39
41
  const { viewportHeight, terminalWidth } = useViewportHeight({
@@ -154,6 +156,13 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
154
156
  // Helper function to generate operations based on selected blueprint
155
157
  const getOperationsForBlueprint = (blueprint) => {
156
158
  const operations = [];
159
+ // View Logs is always available
160
+ operations.push({
161
+ key: "view_logs",
162
+ label: "View Logs",
163
+ color: colors.info,
164
+ icon: figures.info,
165
+ });
157
166
  if (blueprint &&
158
167
  (blueprint.status === "build_complete" ||
159
168
  blueprint.status === "building_complete")) {
@@ -186,14 +195,33 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
186
195
  const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
187
196
  const startIndex = currentPage * PAGE_SIZE;
188
197
  const endIndex = startIndex + blueprints.length;
189
- const executeOperation = async () => {
198
+ const executeOperation = async (blueprintOverride, operationOverride) => {
190
199
  const client = getClient();
191
- const blueprint = selectedBlueprint;
192
- if (!blueprint)
200
+ // Use override if provided, otherwise use selectedBlueprint from state
201
+ // If neither is available, use selectedBlueprintItem as fallback
202
+ const blueprint = blueprintOverride || selectedBlueprint || selectedBlueprintItem;
203
+ // Use operation override if provided (to avoid state timing issues)
204
+ const operation = operationOverride || executingOperation;
205
+ if (!blueprint) {
206
+ console.error("No blueprint selected for operation");
193
207
  return;
208
+ }
209
+ // Ensure selectedBlueprint is set in state if it wasn't already
210
+ if (!selectedBlueprint && blueprint) {
211
+ setSelectedBlueprint(blueprint);
212
+ }
194
213
  try {
195
214
  setOperationLoading(true);
196
- switch (executingOperation) {
215
+ switch (operation) {
216
+ case "view_logs":
217
+ // Navigate to the logs screen
218
+ setOperationLoading(false);
219
+ setExecutingOperation(null);
220
+ navigate("blueprint-logs", {
221
+ blueprintId: blueprint.id,
222
+ blueprintName: blueprint.name || blueprint.id,
223
+ });
224
+ return;
197
225
  case "create_devbox":
198
226
  setShowCreateDevbox(true);
199
227
  setExecutingOperation(null);
@@ -226,15 +254,18 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
226
254
  useInput((input, key) => {
227
255
  // Handle operation input mode
228
256
  if (executingOperation && !operationResult && !operationError) {
257
+ // Allow escape/q to cancel any operation, even during loading
258
+ if (input === "q" || key.escape) {
259
+ setExecutingOperation(null);
260
+ setOperationInput("");
261
+ setOperationLoading(false);
262
+ return;
263
+ }
229
264
  const currentOp = allOperations.find((op) => op.key === executingOperation);
230
265
  if (currentOp?.needsInput) {
231
266
  if (key.return) {
232
267
  executeOperation();
233
268
  }
234
- else if (input === "q" || key.escape) {
235
- setExecutingOperation(null);
236
- setOperationInput("");
237
- }
238
269
  }
239
270
  return;
240
271
  }
@@ -271,7 +302,7 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
271
302
  else {
272
303
  setSelectedBlueprint(selectedBlueprintItem);
273
304
  setExecutingOperation(operationKey);
274
- executeOperation();
305
+ executeOperation(selectedBlueprintItem, operationKey);
275
306
  }
276
307
  }
277
308
  else if (key.escape || input === "q") {
@@ -293,7 +324,16 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
293
324
  setShowPopup(false);
294
325
  setSelectedBlueprint(selectedBlueprintItem);
295
326
  setExecutingOperation("delete");
296
- executeOperation();
327
+ executeOperation(selectedBlueprintItem, "delete");
328
+ }
329
+ }
330
+ else if (input === "l") {
331
+ const logsIndex = allOperations.findIndex((op) => op.key === "view_logs");
332
+ if (logsIndex >= 0) {
333
+ setShowPopup(false);
334
+ setSelectedBlueprint(selectedBlueprintItem);
335
+ setExecutingOperation("view_logs");
336
+ executeOperation(selectedBlueprintItem, "view_logs");
297
337
  }
298
338
  }
299
339
  return;
@@ -324,6 +364,11 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
324
364
  setShowPopup(true);
325
365
  setSelectedOperation(0);
326
366
  }
367
+ else if (input === "l" && selectedBlueprintItem) {
368
+ setSelectedBlueprint(selectedBlueprintItem);
369
+ setExecutingOperation("view_logs");
370
+ executeOperation(selectedBlueprintItem, "view_logs");
371
+ }
327
372
  else if (input === "o" && blueprints[selectedIndex]) {
328
373
  const url = getBlueprintUrl(blueprints[selectedIndex].id);
329
374
  const openBrowser = async () => {
@@ -373,27 +418,26 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
373
418
  const needsInput = currentOp?.needsInput;
374
419
  const operationLabel = currentOp?.label || "Operation";
375
420
  if (operationLoading) {
421
+ const messages = {
422
+ delete: "Deleting blueprint...",
423
+ view_logs: "Fetching logs...",
424
+ };
376
425
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
377
426
  { label: "Blueprints" },
378
427
  { label: selectedBlueprint.name || selectedBlueprint.id },
379
428
  { label: operationLabel, active: true },
380
- ] }), _jsx(Header, { title: "Executing Operation" }), _jsx(SpinnerComponent, { message: "Please wait..." })] }));
429
+ ] }), _jsx(Header, { title: "Executing Operation" }), _jsx(SpinnerComponent, { message: messages[executingOperation] || "Please wait..." }), _jsx(Box, { marginTop: 1, paddingX: 1, children: _jsx(Text, { color: colors.textDim, dimColor: true, children: "Press [q] or [esc] to cancel" }) })] }));
381
430
  }
382
- if (!needsInput) {
383
- const messages = {
384
- delete: "Deleting blueprint...",
385
- };
431
+ // Only show input screen if operation needs input
432
+ // Operations like view_logs navigate away and don't need this screen
433
+ if (needsInput) {
386
434
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
387
435
  { label: "Blueprints" },
388
436
  { label: selectedBlueprint.name || selectedBlueprint.id },
389
437
  { label: operationLabel, active: true },
390
- ] }), _jsx(Header, { title: "Executing Operation" }), _jsx(SpinnerComponent, { message: messages[executingOperation] || "Please wait..." })] }));
438
+ ] }), _jsx(Header, { title: operationLabel }), _jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: colors.primary, bold: true, children: selectedBlueprint.name || selectedBlueprint.id }) }), _jsx(Box, { children: _jsxs(Text, { color: colors.textDim, children: [currentOp?.inputPrompt || "", " "] }) }), _jsx(Box, { marginTop: 1, children: _jsx(TextInput, { value: operationInput, onChange: setOperationInput, placeholder: currentOp?.inputPlaceholder || "" }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.textDim, dimColor: true, children: "Press [Enter] to execute \u2022 [q or esc] Cancel" }) })] })] }));
391
439
  }
392
- return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
393
- { label: "Blueprints" },
394
- { label: selectedBlueprint.name || selectedBlueprint.id },
395
- { label: operationLabel, active: true },
396
- ] }), _jsx(Header, { title: operationLabel }), _jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: colors.primary, bold: true, children: selectedBlueprint.name || selectedBlueprint.id }) }), _jsx(Box, { children: _jsxs(Text, { color: colors.textDim, children: [currentOp.inputPrompt, " "] }) }), _jsx(Box, { marginTop: 1, children: _jsx(TextInput, { value: operationInput, onChange: setOperationInput, placeholder: currentOp.inputPlaceholder || "" }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.textDim, dimColor: true, children: "Press [Enter] to execute \u2022 [q or esc] Cancel" }) })] })] }));
440
+ // For operations that don't need input (like view_logs), fall through to list view
397
441
  }
398
442
  // Create devbox screen
399
443
  if (showCreateDevbox && selectedBlueprint) {
@@ -427,7 +471,9 @@ const ListBlueprintsUI = ({ onBack, onExit, }) => {
427
471
  ? "c"
428
472
  : op.key === "delete"
429
473
  ? "d"
430
- : "",
474
+ : op.key === "view_logs"
475
+ ? "l"
476
+ : "",
431
477
  })), selectedOperation: selectedOperation, onClose: () => setShowPopup(false) }) })), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate"] }), (hasMore || hasPrev) && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 ", figures.arrowLeft, figures.arrowRight, " Page"] })), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [a] Actions"] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [o] Browser"] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [Esc] Back"] })] })] }));
432
478
  };
433
479
  // Export the UI component for use in the main menu
@@ -1,49 +1,45 @@
1
- /**
2
- * Preview blueprint command
3
- */
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from "react";
4
3
  import { getClient } from "../../utils/client.js";
5
- import { output, outputError } from "../../utils/output.js";
6
- export async function previewBlueprint(options) {
7
- try {
8
- const client = getClient();
9
- // Parse user parameters
10
- let userParameters = undefined;
11
- if (options.user && options.root) {
12
- outputError("Only one of --user or --root can be specified");
13
- }
14
- else if (options.user) {
15
- const [username, uid] = options.user.split(":");
16
- if (!username || !uid) {
17
- outputError("User must be in format 'username:uid'");
4
+ import { Banner } from "../../components/Banner.js";
5
+ import { SpinnerComponent } from "../../components/Spinner.js";
6
+ import { SuccessMessage } from "../../components/SuccessMessage.js";
7
+ import { ErrorMessage } from "../../components/ErrorMessage.js";
8
+ import { createExecutor } from "../../utils/CommandExecutor.js";
9
+ const PreviewBlueprintUI = ({ name, dockerfile, systemSetupCommands }) => {
10
+ const [loading, setLoading] = React.useState(true);
11
+ const [result, setResult] = React.useState(null);
12
+ const [error, setError] = React.useState(null);
13
+ React.useEffect(() => {
14
+ const previewBlueprint = async () => {
15
+ try {
16
+ const client = getClient();
17
+ const blueprint = await client.blueprints.preview({
18
+ name,
19
+ dockerfile,
20
+ system_setup_commands: systemSetupCommands,
21
+ });
22
+ setResult(blueprint);
23
+ }
24
+ catch (err) {
25
+ setError(err);
18
26
  }
19
- userParameters = { username, uid: parseInt(uid) };
20
- }
21
- else if (options.root) {
22
- userParameters = { username: "root", uid: 0 };
23
- }
24
- // Build launch parameters
25
- const launchParameters = {};
26
- if (options.resources) {
27
- launchParameters.resource_size_request = options.resources;
28
- }
29
- if (options.architecture) {
30
- launchParameters.architecture = options.architecture;
31
- }
32
- if (options.availablePorts) {
33
- launchParameters.available_ports = options.availablePorts.map((port) => parseInt(port, 10));
34
- }
35
- if (userParameters) {
36
- launchParameters.user_parameters = userParameters;
37
- }
38
- const preview = await client.blueprints.preview({
27
+ finally {
28
+ setLoading(false);
29
+ }
30
+ };
31
+ previewBlueprint();
32
+ }, [name, dockerfile, systemSetupCommands]);
33
+ return (_jsxs(_Fragment, { children: [_jsx(Banner, {}), loading && _jsx(SpinnerComponent, { message: "Previewing blueprint..." }), result && (_jsx(SuccessMessage, { message: "Blueprint preview generated", details: `Name: ${result.name}\nDockerfile: ${result.dockerfile ? "Present" : "Not provided"}\nSetup Commands: ${result.systemSetupCommands?.length || 0}` })), error && (_jsx(ErrorMessage, { message: "Failed to preview blueprint", error: error }))] }));
34
+ };
35
+ export async function previewBlueprint(options) {
36
+ const executor = createExecutor({ output: options.output });
37
+ await executor.executeAction(async () => {
38
+ const client = executor.getClient();
39
+ return client.blueprints.preview({
39
40
  name: options.name,
40
41
  dockerfile: options.dockerfile,
41
42
  system_setup_commands: options.systemSetupCommands,
42
- launch_parameters: launchParameters,
43
43
  });
44
- output(preview, { format: options.output, defaultFormat: "json" });
45
- }
46
- catch (error) {
47
- outputError("Failed to preview blueprint", error);
48
- }
44
+ }, () => (_jsx(PreviewBlueprintUI, { name: options.name, dockerfile: options.dockerfile, systemSetupCommands: options.systemSetupCommands })));
49
45
  }
@@ -6,6 +6,7 @@ import { setThemePreference, getThemePreference, clearDetectedTheme, } from "../
6
6
  import { Header } from "../components/Header.js";
7
7
  import { SuccessMessage } from "../components/SuccessMessage.js";
8
8
  import { colors, getCurrentTheme, setThemeMode } from "../utils/theme.js";
9
+ import { processUtils } from "../utils/processUtils.js";
9
10
  const themeOptions = [
10
11
  {
11
12
  value: "auto",
@@ -95,10 +96,10 @@ const StaticConfigUI = ({ action, value }) => {
95
96
  clearDetectedTheme();
96
97
  }
97
98
  setSaved(true);
98
- setTimeout(() => process.exit(0), 1500);
99
+ setTimeout(() => processUtils.exit(0), 1500);
99
100
  }
100
101
  else if (action === "get" || !action) {
101
- setTimeout(() => process.exit(0), 2000);
102
+ setTimeout(() => processUtils.exit(0), 2000);
102
103
  }
103
104
  }, [action, value]);
104
105
  const currentPreference = getThemePreference();
@@ -4,6 +4,7 @@
4
4
  import { spawn } from "child_process";
5
5
  import { getClient } from "../../utils/client.js";
6
6
  import { output, outputError } from "../../utils/output.js";
7
+ import { processUtils } from "../../utils/processUtils.js";
7
8
  import { getSSHKey, waitForReady, generateSSHConfig, checkSSHTools, getProxyCommand, } from "../../utils/ssh.js";
8
9
  export async function sshDevbox(devboxId, options = {}) {
9
10
  try {
@@ -59,7 +60,7 @@ export async function sshDevbox(devboxId, options = {}) {
59
60
  stdio: "inherit",
60
61
  });
61
62
  sshProcess.on("close", (code) => {
62
- process.exit(code || 0);
63
+ processUtils.exit(code || 0);
63
64
  });
64
65
  sshProcess.on("error", (err) => {
65
66
  outputError("SSH connection failed", err);
@@ -4,6 +4,7 @@
4
4
  import { spawn } from "child_process";
5
5
  import { getClient } from "../../utils/client.js";
6
6
  import { output, outputError } from "../../utils/output.js";
7
+ import { processUtils } from "../../utils/processUtils.js";
7
8
  import { getSSHKey, getProxyCommand, checkSSHTools } from "../../utils/ssh.js";
8
9
  export async function createTunnel(devboxId, options) {
9
10
  try {
@@ -56,7 +57,7 @@ export async function createTunnel(devboxId, options) {
56
57
  });
57
58
  tunnelProcess.on("close", (code) => {
58
59
  console.log("\nTunnel closed.");
59
- process.exit(code || 0);
60
+ processUtils.exit(code || 0);
60
61
  });
61
62
  tunnelProcess.on("error", (err) => {
62
63
  outputError("Tunnel creation failed", err);
@@ -2,12 +2,13 @@
2
2
  import { spawn } from "child_process";
3
3
  import { fileURLToPath } from "url";
4
4
  import { dirname, join } from "path";
5
+ import { processUtils } from "../utils/processUtils.js";
5
6
  const __filename = fileURLToPath(import.meta.url);
6
7
  const __dirname = dirname(__filename);
7
8
  export async function startMcpHttpServer(port) {
8
9
  // Get the path to the compiled MCP HTTP server
9
10
  const serverPath = join(__dirname, "../mcp/server-http.js");
10
- const env = { ...process.env };
11
+ const env = { ...processUtils.env };
11
12
  if (port) {
12
13
  env.PORT = port.toString();
13
14
  }
@@ -20,18 +21,18 @@ export async function startMcpHttpServer(port) {
20
21
  });
21
22
  serverProcess.on("error", (error) => {
22
23
  console.error("Failed to start MCP HTTP server:", error);
23
- process.exit(1);
24
+ processUtils.exit(1);
24
25
  });
25
26
  serverProcess.on("exit", (code) => {
26
27
  if (code !== 0) {
27
28
  console.error(`MCP HTTP server exited with code ${code}`);
28
- process.exit(code || 1);
29
+ processUtils.exit(code || 1);
29
30
  }
30
31
  });
31
32
  // Handle Ctrl+C
32
- process.on("SIGINT", () => {
33
+ processUtils.on("SIGINT", () => {
33
34
  console.log("\nShutting down MCP HTTP server...");
34
35
  serverProcess.kill("SIGINT");
35
- process.exit(0);
36
+ processUtils.exit(0);
36
37
  });
37
38
  }
@@ -3,13 +3,14 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
3
3
  import { homedir, platform } from "os";
4
4
  import { join } from "path";
5
5
  import { execSync } from "child_process";
6
+ import { processUtils } from "../utils/processUtils.js";
6
7
  function getClaudeConfigPath() {
7
8
  const plat = platform();
8
9
  if (plat === "darwin") {
9
10
  return join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
10
11
  }
11
12
  else if (plat === "win32") {
12
- const appData = process.env.APPDATA;
13
+ const appData = processUtils.env.APPDATA;
13
14
  if (!appData) {
14
15
  throw new Error("APPDATA environment variable not found");
15
16
  }
@@ -51,7 +52,7 @@ export async function installMcpConfig() {
51
52
  catch {
52
53
  console.error("❌ Error: Claude config file exists but is not valid JSON");
53
54
  console.error("Please fix the file manually or delete it to create a new one");
54
- process.exit(1);
55
+ processUtils.exit(1);
55
56
  }
56
57
  }
57
58
  else {
@@ -71,20 +72,20 @@ export async function installMcpConfig() {
71
72
  // Ask if they want to overwrite
72
73
  console.log("\n❓ Do you want to overwrite it? (y/N): ");
73
74
  // For non-interactive mode, just exit
74
- if (process.stdin.isTTY) {
75
+ if (processUtils.stdin.isTTY) {
75
76
  const response = await new Promise((resolve) => {
76
- process.stdin.once("data", (data) => {
77
+ processUtils.stdin.on("data", (data) => {
77
78
  resolve(data.toString().trim().toLowerCase());
78
79
  });
79
80
  });
80
81
  if (response !== "y" && response !== "yes") {
81
82
  console.log("\n✓ Keeping existing configuration");
82
- process.exit(0);
83
+ processUtils.exit(0);
83
84
  }
84
85
  }
85
86
  else {
86
87
  console.log("\n✓ Keeping existing configuration (non-interactive mode)");
87
- process.exit(0);
88
+ processUtils.exit(0);
88
89
  }
89
90
  }
90
91
  // Add runloop MCP server config
@@ -116,6 +117,6 @@ export async function installMcpConfig() {
116
117
  },
117
118
  },
118
119
  }, null, 2));
119
- process.exit(1);
120
+ processUtils.exit(1);
120
121
  }
121
122
  }
@@ -2,6 +2,7 @@
2
2
  import { spawn } from "child_process";
3
3
  import { fileURLToPath } from "url";
4
4
  import { dirname, join } from "path";
5
+ import { processUtils } from "../utils/processUtils.js";
5
6
  const __filename = fileURLToPath(import.meta.url);
6
7
  const __dirname = dirname(__filename);
7
8
  export async function startMcpServer() {
@@ -14,17 +15,17 @@ export async function startMcpServer() {
14
15
  });
15
16
  serverProcess.on("error", (error) => {
16
17
  console.error("Failed to start MCP server:", error);
17
- process.exit(1);
18
+ processUtils.exit(1);
18
19
  });
19
20
  serverProcess.on("exit", (code) => {
20
21
  if (code !== 0) {
21
22
  console.error(`MCP server exited with code ${code}`);
22
- process.exit(code || 1);
23
+ processUtils.exit(code || 1);
23
24
  }
24
25
  });
25
26
  // Handle Ctrl+C
26
- process.on("SIGINT", () => {
27
+ processUtils.on("SIGINT", () => {
27
28
  serverProcess.kill("SIGINT");
28
- process.exit(0);
29
+ processUtils.exit(0);
29
30
  });
30
31
  }
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { render } from "ink";
3
3
  import { enterAlternateScreenBuffer, exitAlternateScreenBuffer, } from "../utils/screen.js";
4
+ import { processUtils } from "../utils/processUtils.js";
4
5
  import { Router } from "../router/Router.js";
5
6
  import { NavigationProvider } from "../store/navigationStore.js";
6
7
  function AppInner() {
@@ -24,5 +25,5 @@ export async function runMainMenu(initialScreen = "menu", focusDevboxId) {
24
25
  console.error("Error in menu:", error);
25
26
  }
26
27
  exitAlternateScreenBuffer();
27
- process.exit(0);
28
+ processUtils.exit(0);
28
29
  }
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import figures from "figures";
4
4
  import chalk from "chalk";
5
- import { isLightMode } from "../utils/theme.js";
5
+ import { getChalkTextColor, getChalkColor } from "../utils/theme.js";
6
6
  export const ActionsPopup = ({ devbox: _devbox, operations, selectedOperation, onClose: _onClose, }) => {
7
7
  // Calculate max width needed for content (visible characters only)
8
8
  // CRITICAL: Ensure all values are valid numbers to prevent Yoga crashes
@@ -15,11 +15,12 @@ export const ActionsPopup = ({ devbox: _devbox, operations, selectedOperation, o
15
15
  // Plus 2 for border characters = 6 total extra
16
16
  // CRITICAL: Validate all computed widths are positive integers
17
17
  const contentWidth = Math.max(10, maxContentWidth + 4);
18
- // Get background color chalk function - inverted for contrast
19
- // In light mode (light terminal), use black background for popup
20
- // In dark mode (dark terminal), use white background for popup
21
- const bgColor = isLightMode() ? chalk.bgBlack : chalk.bgWhite;
22
- const textColor = isLightMode() ? chalk.white : chalk.black;
18
+ // Get background color chalk function - use theme colors to match the theme mode
19
+ // In light mode, use light background; in dark mode, use dark background
20
+ const popupBgHex = getChalkColor("background");
21
+ const popupTextHex = getChalkColor("text");
22
+ const bgColorFn = chalk.bgHex(popupBgHex);
23
+ const textColorFn = chalk.hex(popupTextHex);
23
24
  // Helper to create background lines with proper padding including left/right margins
24
25
  const createBgLine = (styledContent, plainContent) => {
25
26
  const visibleLength = plainContent.length;
@@ -27,11 +28,11 @@ export const ActionsPopup = ({ devbox: _devbox, operations, selectedOperation, o
27
28
  const repeatCount = Math.max(0, Math.floor(maxContentWidth - visibleLength));
28
29
  const rightPadding = " ".repeat(repeatCount);
29
30
  // Apply background to left padding + content + right padding
30
- return bgColor(" " + styledContent + rightPadding + " ");
31
+ return bgColorFn(" " + styledContent + rightPadding + " ");
31
32
  };
32
33
  // Create empty line with full background
33
34
  // CRITICAL: Validate repeat count is positive integer
34
- const emptyLine = bgColor(" ".repeat(Math.max(1, Math.floor(contentWidth))));
35
+ const emptyLine = bgColorFn(" ".repeat(Math.max(1, Math.floor(contentWidth))));
35
36
  // Create border lines with background and integrated title
36
37
  const title = `${figures.play} Quick Actions`;
37
38
  // The content between ╭ and ╮ should be exactly contentWidth
@@ -41,12 +42,12 @@ export const ActionsPopup = ({ devbox: _devbox, operations, selectedOperation, o
41
42
  // CRITICAL: Validate repeat counts are non-negative integers
42
43
  const remainingDashes = Math.max(0, Math.floor(contentWidth - titleTotalLength));
43
44
  // Use theme primary color for borders to match theme
44
- const borderColorFn = isLightMode() ? chalk.cyan : chalk.blue;
45
- const borderTop = bgColor(borderColorFn("╭─" + titleWithSpaces + "─".repeat(remainingDashes) + "╮"));
45
+ const borderColorFn = getChalkTextColor("primary");
46
+ const borderTop = bgColorFn(borderColorFn("╭─" + titleWithSpaces + "─".repeat(remainingDashes) + "╮"));
46
47
  // CRITICAL: Validate contentWidth is a positive integer
47
- const borderBottom = bgColor(borderColorFn("╰" + "─".repeat(Math.max(1, Math.floor(contentWidth))) + "╯"));
48
+ const borderBottom = bgColorFn(borderColorFn("╰" + "─".repeat(Math.max(1, Math.floor(contentWidth))) + "╯"));
48
49
  const borderSide = (content) => {
49
- return bgColor(borderColorFn("│") + content + borderColorFn("│"));
50
+ return bgColorFn(borderColorFn("│") + content + borderColorFn("│"));
50
51
  };
51
52
  return (_jsx(Box, { flexDirection: "column", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: borderTop }), _jsx(Text, { children: borderSide(emptyLine) }), operations.map((op, index) => {
52
53
  const isSelected = index === selectedOperation;
@@ -56,14 +57,14 @@ export const ActionsPopup = ({ devbox: _devbox, operations, selectedOperation, o
56
57
  if (isSelected) {
57
58
  // Selected: use operation-specific color for icon and label
58
59
  const opColor = op.color;
59
- const colorFn = chalk[opColor] || textColor;
60
- styledLine = `${textColor(pointer)} ${colorFn(op.icon)} ${colorFn.bold(op.label)} ${textColor(`[${op.shortcut}]`)}`;
60
+ const colorFn = chalk[opColor] || textColorFn;
61
+ styledLine = `${textColorFn(pointer)} ${colorFn(op.icon)} ${colorFn.bold(op.label)} ${textColorFn(`[${op.shortcut}]`)}`;
61
62
  }
62
63
  else {
63
- // Unselected: gray/dim text for everything
64
- const dimFn = isLightMode() ? chalk.gray : chalk.gray;
64
+ // Unselected: use theme's textDim color for dimmed text
65
+ const dimFn = getChalkTextColor("textDim");
65
66
  styledLine = `${dimFn(pointer)} ${dimFn(op.icon)} ${dimFn(op.label)} ${dimFn(`[${op.shortcut}]`)}`;
66
67
  }
67
68
  return (_jsx(Text, { children: borderSide(createBgLine(styledLine, lineText)) }, op.key));
68
- }), _jsx(Text, { children: borderSide(emptyLine) }), _jsx(Text, { children: borderSide(createBgLine(textColor(`${figures.arrowUp}${figures.arrowDown} Nav • [Enter] • [Esc] Close`), `${figures.arrowUp}${figures.arrowDown} Nav • [Enter] • [Esc] Close`)) }), _jsx(Text, { children: borderSide(emptyLine) }), _jsx(Text, { children: borderBottom })] }) }));
69
+ }), _jsx(Text, { children: borderSide(emptyLine) }), _jsx(Text, { children: borderSide(createBgLine(textColorFn(`${figures.arrowUp}${figures.arrowDown} Nav • [Enter] • [Esc] Close`), `${figures.arrowUp}${figures.arrowDown} Nav • [Enter] • [Esc] Close`)) }), _jsx(Text, { children: borderSide(emptyLine) }), _jsx(Text, { children: borderBottom })] }) }));
69
70
  };
@@ -3,6 +3,12 @@ import React from "react";
3
3
  import { Box } from "ink";
4
4
  import BigText from "ink-big-text";
5
5
  import Gradient from "ink-gradient";
6
+ import { isLightMode } from "../utils/theme.js";
6
7
  export const Banner = React.memo(() => {
7
- return (_jsx(Box, { flexDirection: "column", alignItems: "flex-start", children: _jsx(Gradient, { name: "vice", children: _jsx(BigText, { text: "RUNLOOP.ai", font: "simple3d" }) }) }));
8
+ // Use theme-aware gradient colors
9
+ // In light mode, use darker/deeper colors for better contrast on light backgrounds
10
+ // "teen" has darker colors (blue/purple) that work well on light backgrounds
11
+ // In dark mode, use the vibrant "vice" gradient (pink/cyan) that works well on dark backgrounds
12
+ const gradientName = isLightMode() ? "teen" : "vice";
13
+ return (_jsx(Box, { flexDirection: "column", alignItems: "flex-start", paddingX: 1, children: _jsx(Gradient, { name: gradientName, children: _jsx(BigText, { text: "RUNLOOP.ai", font: "simple3d" }) }) }));
8
14
  });