@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
@@ -2,15 +2,16 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React from "react";
3
3
  import { Box, Text } from "ink";
4
4
  import { colors } from "../utils/theme.js";
5
- export const Breadcrumb = ({ items }) => {
5
+ import { UpdateNotification } from "./UpdateNotification.js";
6
+ export const Breadcrumb = ({ items, showVersionCheck = false, }) => {
6
7
  const env = process.env.RUNLOOP_ENV?.toLowerCase();
7
8
  const isDevEnvironment = env === "dev";
8
- return (_jsx(Box, { marginBottom: 1, paddingX: 1, paddingY: 0, children: _jsxs(Box, { borderStyle: "round", borderColor: colors.primary, paddingX: 2, paddingY: 0, children: [_jsx(Text, { color: colors.primary, bold: true, children: "rl" }), isDevEnvironment && (_jsxs(Text, { color: colors.error, bold: true, children: [" ", "(dev)"] })), _jsx(Text, { color: colors.textDim, children: " \u203A " }), items.map((item, index) => {
9
- // Limit label length to prevent Yoga layout engine errors
10
- const MAX_LABEL_LENGTH = 80;
11
- const truncatedLabel = item.label.length > MAX_LABEL_LENGTH
12
- ? item.label.substring(0, MAX_LABEL_LENGTH) + "..."
13
- : item.label;
14
- return (_jsxs(React.Fragment, { children: [_jsx(Text, { color: item.active ? colors.primary : colors.textDim, children: truncatedLabel }), index < items.length - 1 && (_jsx(Text, { color: colors.textDim, children: " \u203A " }))] }, index));
15
- })] }) }));
9
+ return (_jsxs(Box, { marginBottom: 1, paddingX: 0, paddingY: 0, children: [_jsxs(Box, { borderStyle: "round", borderColor: colors.primary, paddingX: 2, paddingY: 0, children: [_jsx(Text, { color: colors.primary, bold: true, children: "rl" }), isDevEnvironment && (_jsxs(Text, { color: colors.error, bold: true, children: [" ", "(dev)"] })), _jsx(Text, { color: colors.textDim, children: " \u203A " }), items.map((item, index) => {
10
+ // Limit label length to prevent Yoga layout engine errors
11
+ const MAX_LABEL_LENGTH = 80;
12
+ const truncatedLabel = item.label.length > MAX_LABEL_LENGTH
13
+ ? item.label.substring(0, MAX_LABEL_LENGTH) + "..."
14
+ : item.label;
15
+ return (_jsxs(React.Fragment, { children: [_jsx(Text, { color: item.active ? colors.primary : colors.textDim, children: truncatedLabel }), index < items.length - 1 && (_jsx(Text, { color: colors.textDim, children: " \u203A " }))] }, index));
16
+ })] }), showVersionCheck && (_jsx(Box, { paddingX: 2, marginTop: 0, children: _jsx(UpdateNotification, {}) }))] }));
16
17
  };
@@ -13,7 +13,7 @@ import { useViewportHeight } from "../hooks/useViewportHeight.js";
13
13
  import { useNavigation } from "../store/navigationStore.js";
14
14
  import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
15
15
  import { getDevboxLogs, execCommand, suspendDevbox, resumeDevbox, shutdownDevbox, uploadFile, createSnapshot as createDevboxSnapshot, createTunnel, createSSHKey, } from "../services/devboxService.js";
16
- import { parseLogEntry } from "../utils/logFormatter.js";
16
+ import { LogsViewer } from "./LogsViewer.js";
17
17
  export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
18
18
  { label: "Devboxes" },
19
19
  { label: devbox.name || devbox.id, active: true },
@@ -25,8 +25,6 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
25
25
  const [operationInput, setOperationInput] = React.useState("");
26
26
  const [operationResult, setOperationResult] = React.useState(null);
27
27
  const [operationError, setOperationError] = React.useState(null);
28
- const [logsWrapMode, setLogsWrapMode] = React.useState(false);
29
- const [logsScroll, setLogsScroll] = React.useState(0);
30
28
  const [execScroll, setExecScroll] = React.useState(0);
31
29
  const [copyStatus, setCopyStatus] = React.useState(null);
32
30
  // Calculate viewport for exec output:
@@ -38,14 +36,6 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
38
36
  // - Safety buffer: 1 line
39
37
  // Total: 16 lines
40
38
  const execViewport = useViewportHeight({ overhead: 16, minHeight: 10 });
41
- // Calculate viewport for logs output:
42
- // - Breadcrumb (3 lines + marginBottom): 4 lines
43
- // - Log box borders: 2 lines
44
- // - Stats bar (marginTop + content): 2 lines
45
- // - Help bar (marginTop + content): 2 lines
46
- // - Safety buffer: 1 line
47
- // Total: 11 lines
48
- const logsViewport = useViewportHeight({ overhead: 11, minHeight: 10 });
49
39
  // CRITICAL: Aggressive memory cleanup to prevent heap exhaustion
50
40
  React.useEffect(() => {
51
41
  // Clear large data immediately when results are shown to free memory faster
@@ -185,8 +175,6 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
185
175
  setOperationResult(null);
186
176
  setOperationError(null);
187
177
  setOperationInput("");
188
- setLogsWrapMode(true);
189
- setLogsScroll(0);
190
178
  setExecScroll(0);
191
179
  setCopyStatus(null);
192
180
  // If skipOperationsMenu is true, go back to parent instead of operations menu
@@ -283,102 +271,6 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
283
271
  };
284
272
  copyToClipboard(output);
285
273
  }
286
- else if ((key.upArrow || input === "k") &&
287
- operationResult &&
288
- typeof operationResult === "object" &&
289
- operationResult.__customRender === "logs") {
290
- setLogsScroll(Math.max(0, logsScroll - 1));
291
- }
292
- else if ((key.downArrow || input === "j") &&
293
- operationResult &&
294
- typeof operationResult === "object" &&
295
- operationResult.__customRender === "logs") {
296
- setLogsScroll(logsScroll + 1);
297
- }
298
- else if (key.pageUp &&
299
- operationResult &&
300
- typeof operationResult === "object" &&
301
- operationResult.__customRender === "logs") {
302
- setLogsScroll(Math.max(0, logsScroll - 10));
303
- }
304
- else if (key.pageDown &&
305
- operationResult &&
306
- typeof operationResult === "object" &&
307
- operationResult.__customRender === "logs") {
308
- setLogsScroll(logsScroll + 10);
309
- }
310
- else if (input === "g" &&
311
- operationResult &&
312
- typeof operationResult === "object" &&
313
- operationResult.__customRender === "logs") {
314
- setLogsScroll(0);
315
- }
316
- else if (input === "G" &&
317
- operationResult &&
318
- typeof operationResult === "object" &&
319
- operationResult.__customRender === "logs") {
320
- const logs = operationResult.__logs || [];
321
- const maxScroll = Math.max(0, logs.length - logsViewport.viewportHeight);
322
- setLogsScroll(maxScroll);
323
- }
324
- else if (input === "w" &&
325
- operationResult &&
326
- typeof operationResult === "object" &&
327
- operationResult.__customRender === "logs") {
328
- setLogsWrapMode(!logsWrapMode);
329
- }
330
- else if (input === "c" &&
331
- operationResult &&
332
- typeof operationResult === "object" &&
333
- operationResult.__customRender === "logs") {
334
- // Copy logs to clipboard using shared formatter
335
- const logs = operationResult.__logs || [];
336
- const logsText = logs
337
- .map((log) => {
338
- const parts = parseLogEntry(log);
339
- const cmd = parts.cmd ? `$ ${parts.cmd} ` : "";
340
- const exitCode = parts.exitCode !== null ? `exit=${parts.exitCode} ` : "";
341
- const shell = parts.shellName ? `(${parts.shellName}) ` : "";
342
- return `${parts.timestamp} ${parts.level} [${parts.source}] ${shell}${cmd}${parts.message} ${exitCode}`.trim();
343
- })
344
- .join("\n");
345
- const copyToClipboard = async (text) => {
346
- const { spawn } = await import("child_process");
347
- const platform = process.platform;
348
- let command;
349
- let args;
350
- if (platform === "darwin") {
351
- command = "pbcopy";
352
- args = [];
353
- }
354
- else if (platform === "win32") {
355
- command = "clip";
356
- args = [];
357
- }
358
- else {
359
- command = "xclip";
360
- args = ["-selection", "clipboard"];
361
- }
362
- const proc = spawn(command, args);
363
- proc.stdin.write(text);
364
- proc.stdin.end();
365
- proc.on("exit", (code) => {
366
- if (code === 0) {
367
- setCopyStatus("Copied to clipboard!");
368
- setTimeout(() => setCopyStatus(null), 2000);
369
- }
370
- else {
371
- setCopyStatus("Failed to copy");
372
- setTimeout(() => setCopyStatus(null), 2000);
373
- }
374
- });
375
- proc.on("error", () => {
376
- setCopyStatus("Copy not supported");
377
- setTimeout(() => setCopyStatus(null), 2000);
378
- });
379
- };
380
- copyToClipboard(logsText);
381
- }
382
274
  return;
383
275
  }
384
276
  // Operations selection mode
@@ -560,77 +452,23 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
560
452
  typeof operationResult === "object" &&
561
453
  operationResult.__customRender === "logs") {
562
454
  const logs = operationResult.__logs || [];
563
- const totalCount = operationResult.__totalCount || 0;
564
- const viewportHeight = logsViewport.viewportHeight;
565
- const terminalWidth = logsViewport.terminalWidth;
566
- const maxScroll = Math.max(0, logs.length - viewportHeight);
567
- const actualScroll = Math.min(logsScroll, maxScroll);
568
- const visibleLogs = logs.slice(actualScroll, actualScroll + viewportHeight);
569
- const hasMore = actualScroll + viewportHeight < logs.length;
570
- const hasLess = actualScroll > 0;
571
- return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [...breadcrumbItems, { label: "Logs", active: true }] }), _jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.border, paddingX: 1, children: visibleLogs.map((log, index) => {
572
- const parts = parseLogEntry(log);
573
- // Sanitize message: escape special chars to prevent layout breaks
574
- const escapedMessage = parts.message
575
- .replace(/\r\n/g, "\\n")
576
- .replace(/\n/g, "\\n")
577
- .replace(/\r/g, "\\r")
578
- .replace(/\t/g, "\\t");
579
- // Limit message length to prevent Yoga layout engine errors
580
- const MAX_MESSAGE_LENGTH = 1000;
581
- const fullMessage = escapedMessage.length > MAX_MESSAGE_LENGTH
582
- ? escapedMessage.substring(0, MAX_MESSAGE_LENGTH) + "..."
583
- : escapedMessage;
584
- const cmd = parts.cmd
585
- ? `$ ${parts.cmd.substring(0, 40)}${parts.cmd.length > 40 ? "..." : ""} `
586
- : "";
587
- const exitCode = parts.exitCode !== null ? `exit=${parts.exitCode} ` : "";
588
- // Map color names to theme colors
589
- const levelColorMap = {
590
- red: colors.error,
591
- yellow: colors.warning,
592
- blue: colors.primary,
593
- gray: colors.textDim,
594
- };
595
- const sourceColorMap = {
596
- magenta: "#d33682",
597
- cyan: colors.info,
598
- green: colors.success,
599
- yellow: colors.warning,
600
- gray: colors.textDim,
601
- white: colors.text,
602
- };
603
- const levelColor = levelColorMap[parts.levelColor] || colors.textDim;
604
- const sourceColor = sourceColorMap[parts.sourceColor] || colors.textDim;
605
- if (logsWrapMode) {
606
- 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));
607
- }
608
- else {
609
- // Calculate available width for message truncation
610
- const timestampLen = parts.timestamp.length;
611
- const levelLen = parts.level.length;
612
- const sourceLen = parts.source.length + 2; // brackets
613
- const shellLen = parts.shellName
614
- ? parts.shellName.length + 3
615
- : 0;
616
- const cmdLen = cmd.length;
617
- const exitLen = exitCode.length;
618
- const spacesLen = 5; // spaces between elements
619
- const metadataWidth = timestampLen +
620
- levelLen +
621
- sourceLen +
622
- shellLen +
623
- cmdLen +
624
- exitLen +
625
- spacesLen;
626
- const safeTerminalWidth = Math.max(80, terminalWidth);
627
- const availableMessageWidth = Math.max(20, safeTerminalWidth - metadataWidth);
628
- const truncatedMessage = fullMessage.length > availableMessageWidth
629
- ? fullMessage.substring(0, Math.max(1, availableMessageWidth - 3)) + "..."
630
- : fullMessage;
631
- 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));
632
- }
633
- }) }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", totalCount] }), _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"] }) })] }));
455
+ return (_jsx(LogsViewer, { logs: logs, breadcrumbItems: [
456
+ ...breadcrumbItems,
457
+ { label: "Logs", active: true },
458
+ ], onBack: () => {
459
+ // Clear large data structures immediately to prevent memory leaks
460
+ setOperationResult(null);
461
+ setOperationError(null);
462
+ setOperationInput("");
463
+ // If skipOperationsMenu is true, go back to parent instead of operations menu
464
+ if (skipOperationsMenu) {
465
+ setExecutingOperation(null);
466
+ onBack();
467
+ }
468
+ else {
469
+ setExecutingOperation(null);
470
+ }
471
+ }, title: "Logs" }));
634
472
  }
635
473
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [...breadcrumbItems, { label: operationLabel, active: true }] }), _jsx(Header, { title: "Operation Result" }), operationResult && _jsx(SuccessMessage, { message: operationResult }), operationError && (_jsx(ErrorMessage, { message: "Operation failed", error: operationError })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.textDim, dimColor: true, children: "Press [Enter], [q], or [esc] to continue" }) })] }));
636
474
  }
@@ -5,7 +5,8 @@
5
5
  */
6
6
  import React from "react";
7
7
  import { spawn } from "child_process";
8
- import { exitAlternateScreenBuffer, enterAlternateScreenBuffer, } from "../utils/screen.js";
8
+ import { showCursor, clearScreen, enterAlternateScreenBuffer, } from "../utils/screen.js";
9
+ import { processUtils } from "../utils/processUtils.js";
9
10
  /**
10
11
  * Releases terminal control from Ink so a subprocess can take over.
11
12
  * This directly manipulates stdin to bypass Ink's input handling.
@@ -15,17 +16,31 @@ function releaseTerminal() {
15
16
  process.stdin.pause();
16
17
  // Disable raw mode so the subprocess can control terminal echo and line buffering
17
18
  // SSH needs to set its own terminal modes
18
- if (process.stdin.isTTY && process.stdin.setRawMode) {
19
- process.stdin.setRawMode(false);
19
+ if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) {
20
+ processUtils.stdin.setRawMode(false);
21
+ }
22
+ // Reset terminal attributes (SGR reset) - clears any colors/styles Ink may have set
23
+ if (processUtils.stdout.isTTY) {
24
+ processUtils.stdout.write("\x1b[0m");
25
+ }
26
+ // Show cursor - Ink may have hidden it, and subprocesses expect it to be visible
27
+ showCursor();
28
+ // Flush stdout to ensure all pending writes are complete before handoff
29
+ if (processUtils.stdout.isTTY) {
30
+ processUtils.stdout.write("");
20
31
  }
21
32
  }
22
33
  /**
23
34
  * Restores terminal control to Ink after subprocess exits.
24
35
  */
25
36
  function restoreTerminal() {
37
+ // Clear the screen to remove subprocess output before Ink renders
38
+ clearScreen();
39
+ // Re-enter alternate screen buffer for Ink's fullscreen UI
40
+ enterAlternateScreenBuffer();
26
41
  // Re-enable raw mode for Ink's input handling
27
- if (process.stdin.isTTY && process.stdin.setRawMode) {
28
- process.stdin.setRawMode(true);
42
+ if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) {
43
+ processUtils.stdin.setRawMode(true);
29
44
  }
30
45
  // Resume stdin so Ink can read input again
31
46
  process.stdin.resume();
@@ -41,12 +56,11 @@ export const InteractiveSpawn = ({ command, args, onExit, onError, }) => {
41
56
  return;
42
57
  }
43
58
  hasSpawnedRef.current = true;
44
- // Exit alternate screen so subprocess gets a clean terminal
45
- exitAlternateScreenBuffer();
46
59
  // Release terminal from Ink's control
47
60
  releaseTerminal();
48
- // Small delay to ensure terminal state is fully released
49
- setTimeout(() => {
61
+ // Use setImmediate to ensure terminal state is released without noticeable delay
62
+ // This is faster than setTimeout and ensures the event loop has processed the release
63
+ setImmediate(() => {
50
64
  // Spawn the process with inherited stdio for proper TTY allocation
51
65
  const child = spawn(command, args, {
52
66
  stdio: "inherit", // This allows the process to use the terminal directly
@@ -59,8 +73,6 @@ export const InteractiveSpawn = ({ command, args, onExit, onError, }) => {
59
73
  hasSpawnedRef.current = false;
60
74
  // Restore terminal control to Ink
61
75
  restoreTerminal();
62
- // Re-enter alternate screen after process exits
63
- enterAlternateScreenBuffer();
64
76
  if (onExit) {
65
77
  onExit(code);
66
78
  }
@@ -71,13 +83,11 @@ export const InteractiveSpawn = ({ command, args, onExit, onError, }) => {
71
83
  hasSpawnedRef.current = false;
72
84
  // Restore terminal control to Ink
73
85
  restoreTerminal();
74
- // Re-enter alternate screen on error
75
- enterAlternateScreenBuffer();
76
86
  if (onError) {
77
87
  onError(error);
78
88
  }
79
89
  });
80
- }, 50);
90
+ });
81
91
  // Cleanup function - kill the process if component unmounts
82
92
  return () => {
83
93
  if (processRef.current && !processRef.current.killed) {
@@ -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"] }) }) })] }));
@@ -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
+ };
@@ -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
  });