@runloop/rl-cli 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +54 -10
  2. package/dist/cli.js +79 -72
  3. package/dist/commands/auth.js +2 -2
  4. package/dist/commands/blueprint/create.js +31 -83
  5. package/dist/commands/blueprint/get.js +29 -34
  6. package/dist/commands/blueprint/list.js +278 -230
  7. package/dist/commands/blueprint/logs.js +133 -37
  8. package/dist/commands/config.js +118 -0
  9. package/dist/commands/devbox/create.js +120 -40
  10. package/dist/commands/devbox/delete.js +17 -33
  11. package/dist/commands/devbox/download.js +29 -43
  12. package/dist/commands/devbox/exec.js +22 -39
  13. package/dist/commands/devbox/execAsync.js +20 -37
  14. package/dist/commands/devbox/get.js +13 -35
  15. package/dist/commands/devbox/getAsync.js +12 -34
  16. package/dist/commands/devbox/list.js +241 -402
  17. package/dist/commands/devbox/logs.js +20 -38
  18. package/dist/commands/devbox/read.js +29 -43
  19. package/dist/commands/devbox/resume.js +13 -35
  20. package/dist/commands/devbox/rsync.js +26 -78
  21. package/dist/commands/devbox/scp.js +25 -79
  22. package/dist/commands/devbox/sendStdin.js +41 -0
  23. package/dist/commands/devbox/shutdown.js +13 -35
  24. package/dist/commands/devbox/ssh.js +46 -78
  25. package/dist/commands/devbox/suspend.js +13 -35
  26. package/dist/commands/devbox/tunnel.js +37 -88
  27. package/dist/commands/devbox/upload.js +28 -36
  28. package/dist/commands/devbox/write.js +29 -44
  29. package/dist/commands/mcp-http.js +6 -5
  30. package/dist/commands/mcp-install.js +12 -10
  31. package/dist/commands/mcp.js +5 -4
  32. package/dist/commands/menu.js +26 -67
  33. package/dist/commands/object/delete.js +12 -34
  34. package/dist/commands/object/download.js +26 -74
  35. package/dist/commands/object/get.js +12 -34
  36. package/dist/commands/object/list.js +15 -93
  37. package/dist/commands/object/upload.js +35 -96
  38. package/dist/commands/snapshot/create.js +23 -39
  39. package/dist/commands/snapshot/delete.js +17 -33
  40. package/dist/commands/snapshot/get.js +16 -0
  41. package/dist/commands/snapshot/list.js +309 -80
  42. package/dist/commands/snapshot/status.js +12 -34
  43. package/dist/components/ActionsPopup.js +64 -39
  44. package/dist/components/Banner.js +7 -1
  45. package/dist/components/Breadcrumb.js +11 -48
  46. package/dist/components/DevboxActionsMenu.js +117 -207
  47. package/dist/components/DevboxCreatePage.js +12 -7
  48. package/dist/components/DevboxDetailPage.js +76 -28
  49. package/dist/components/ErrorBoundary.js +29 -0
  50. package/dist/components/ErrorMessage.js +10 -2
  51. package/dist/components/Header.js +12 -4
  52. package/dist/components/InteractiveSpawn.js +104 -0
  53. package/dist/components/LogsViewer.js +169 -0
  54. package/dist/components/MainMenu.js +37 -33
  55. package/dist/components/MetadataDisplay.js +4 -4
  56. package/dist/components/OperationsMenu.js +1 -1
  57. package/dist/components/ResourceActionsMenu.js +4 -4
  58. package/dist/components/ResourceListView.js +46 -34
  59. package/dist/components/Spinner.js +7 -2
  60. package/dist/components/StatusBadge.js +1 -1
  61. package/dist/components/SuccessMessage.js +12 -2
  62. package/dist/components/Table.js +16 -6
  63. package/dist/components/UpdateNotification.js +56 -0
  64. package/dist/hooks/useCursorPagination.js +125 -85
  65. package/dist/hooks/useExitOnCtrlC.js +15 -0
  66. package/dist/hooks/useViewportHeight.js +47 -0
  67. package/dist/mcp/server-http.js +2 -1
  68. package/dist/mcp/server.js +71 -7
  69. package/dist/router/Router.js +70 -0
  70. package/dist/router/types.js +1 -0
  71. package/dist/screens/BlueprintListScreen.js +7 -0
  72. package/dist/screens/BlueprintLogsScreen.js +74 -0
  73. package/dist/screens/DevboxActionsScreen.js +25 -0
  74. package/dist/screens/DevboxCreateScreen.js +11 -0
  75. package/dist/screens/DevboxDetailScreen.js +60 -0
  76. package/dist/screens/DevboxListScreen.js +23 -0
  77. package/dist/screens/LogsSessionScreen.js +49 -0
  78. package/dist/screens/MenuScreen.js +23 -0
  79. package/dist/screens/SSHSessionScreen.js +55 -0
  80. package/dist/screens/SnapshotListScreen.js +7 -0
  81. package/dist/services/blueprintService.js +101 -0
  82. package/dist/services/devboxService.js +215 -0
  83. package/dist/services/snapshotService.js +81 -0
  84. package/dist/store/blueprintStore.js +89 -0
  85. package/dist/store/devboxStore.js +105 -0
  86. package/dist/store/index.js +7 -0
  87. package/dist/store/navigationStore.js +101 -0
  88. package/dist/store/snapshotStore.js +87 -0
  89. package/dist/utils/client.js +4 -2
  90. package/dist/utils/config.js +22 -111
  91. package/dist/utils/interactiveCommand.js +3 -2
  92. package/dist/utils/logFormatter.js +208 -0
  93. package/dist/utils/memoryMonitor.js +85 -0
  94. package/dist/utils/output.js +153 -61
  95. package/dist/utils/process.js +106 -0
  96. package/dist/utils/processUtils.js +135 -0
  97. package/dist/utils/screen.js +61 -0
  98. package/dist/utils/ssh.js +6 -3
  99. package/dist/utils/sshSession.js +5 -29
  100. package/dist/utils/terminalDetection.js +185 -0
  101. package/dist/utils/terminalSync.js +39 -0
  102. package/dist/utils/theme.js +162 -13
  103. package/dist/utils/versionCheck.js +53 -0
  104. package/dist/version.js +12 -0
  105. package/package.json +19 -17
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Shared log formatting utilities for both CLI and interactive mode
3
+ */
4
+ import chalk from "chalk";
5
+ // Source abbreviations for consistent display
6
+ const SOURCE_CONFIG = {
7
+ setup_commands: { abbrev: "setup", color: "magenta" },
8
+ entrypoint: { abbrev: "entry", color: "cyan" },
9
+ exec: { abbrev: "exec", color: "green" },
10
+ files: { abbrev: "files", color: "yellow" },
11
+ stats: { abbrev: "stats", color: "gray" },
12
+ };
13
+ const SOURCE_WIDTH = 5;
14
+ /**
15
+ * Format timestamp based on how recent the log is
16
+ */
17
+ export function formatTimestamp(timestampMs) {
18
+ const date = new Date(timestampMs);
19
+ const now = new Date();
20
+ const isToday = date.toDateString() === now.toDateString();
21
+ const isThisYear = date.getFullYear() === now.getFullYear();
22
+ const time = date.toLocaleTimeString("en-US", {
23
+ hour12: false,
24
+ hour: "2-digit",
25
+ minute: "2-digit",
26
+ second: "2-digit",
27
+ });
28
+ const ms = date.getMilliseconds().toString().padStart(3, "0");
29
+ if (isToday) {
30
+ return `${time}.${ms}`;
31
+ }
32
+ else if (isThisYear) {
33
+ const monthDay = date.toLocaleDateString("en-US", {
34
+ month: "short",
35
+ day: "numeric",
36
+ });
37
+ return `${monthDay} ${time}`;
38
+ }
39
+ else {
40
+ const fullDate = date.toLocaleDateString("en-US", {
41
+ year: "numeric",
42
+ month: "short",
43
+ day: "numeric",
44
+ });
45
+ return `${fullDate} ${time}`;
46
+ }
47
+ }
48
+ /**
49
+ * Get log level info (normalized name and color)
50
+ */
51
+ export function getLogLevelInfo(level) {
52
+ const normalized = level.toUpperCase();
53
+ switch (normalized) {
54
+ case "ERROR":
55
+ case "ERR":
56
+ return { name: "ERROR", color: "red" };
57
+ case "WARN":
58
+ case "WARNING":
59
+ return { name: "WARN ", color: "yellow" };
60
+ case "INFO":
61
+ return { name: "INFO ", color: "blue" };
62
+ case "DEBUG":
63
+ return { name: "DEBUG", color: "gray" };
64
+ default:
65
+ return { name: normalized.padEnd(5), color: "gray" };
66
+ }
67
+ }
68
+ /**
69
+ * Get source info (abbreviated name and color)
70
+ */
71
+ export function getSourceInfo(source) {
72
+ if (!source) {
73
+ return { abbrev: "sys".padEnd(SOURCE_WIDTH), color: "gray" };
74
+ }
75
+ const config = SOURCE_CONFIG[source];
76
+ if (config) {
77
+ return { abbrev: config.abbrev.padEnd(SOURCE_WIDTH), color: config.color };
78
+ }
79
+ // Unknown source: truncate/pad to width
80
+ const abbrev = source.length > SOURCE_WIDTH
81
+ ? source.slice(0, SOURCE_WIDTH)
82
+ : source.padEnd(SOURCE_WIDTH);
83
+ return { abbrev, color: "white" };
84
+ }
85
+ /**
86
+ * Parse a devbox log entry into formatted parts (for use in Ink UI)
87
+ */
88
+ export function parseLogEntry(log) {
89
+ const levelInfo = getLogLevelInfo(log.level);
90
+ const sourceInfo = getSourceInfo(log.source);
91
+ return {
92
+ timestamp: formatTimestamp(log.timestamp_ms),
93
+ level: levelInfo.name,
94
+ levelColor: levelInfo.color,
95
+ source: sourceInfo.abbrev,
96
+ sourceColor: sourceInfo.color,
97
+ shellName: log.shell_name || null,
98
+ cmd: log.cmd || null,
99
+ message: log.message || "",
100
+ exitCode: log.exit_code ?? null,
101
+ exitCodeColor: log.exit_code === 0 ? "green" : "red",
102
+ };
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
+ }
150
+ /**
151
+ * Format a log entry as a string with chalk colors (for CLI output)
152
+ */
153
+ export function formatLogEntryString(log) {
154
+ const parts = parseLogEntry(log);
155
+ const result = [];
156
+ // Timestamp (dim)
157
+ result.push(chalk.dim(parts.timestamp));
158
+ // Level (colored, bold for errors)
159
+ const levelChalk = parts.levelColor === "red"
160
+ ? chalk.red.bold
161
+ : parts.levelColor === "yellow"
162
+ ? chalk.yellow.bold
163
+ : parts.levelColor === "blue"
164
+ ? chalk.blue
165
+ : chalk.gray;
166
+ result.push(levelChalk(parts.level));
167
+ // Source (colored, in brackets)
168
+ const sourceChalk = {
169
+ magenta: chalk.magenta,
170
+ cyan: chalk.cyan,
171
+ green: chalk.green,
172
+ yellow: chalk.yellow,
173
+ gray: chalk.gray,
174
+ white: chalk.white,
175
+ }[parts.sourceColor] || chalk.white;
176
+ result.push(sourceChalk(`[${parts.source}]`));
177
+ // Shell name if present
178
+ if (parts.shellName) {
179
+ result.push(chalk.dim(`(${parts.shellName})`));
180
+ }
181
+ // Command if present
182
+ if (parts.cmd) {
183
+ result.push(chalk.cyan("$") + " " + chalk.white(parts.cmd));
184
+ }
185
+ // Message
186
+ if (parts.message) {
187
+ result.push(parts.message);
188
+ }
189
+ // Exit code if present
190
+ if (parts.exitCode !== null) {
191
+ const exitChalk = parts.exitCode === 0 ? chalk.green : chalk.red;
192
+ result.push(exitChalk(`exit=${parts.exitCode}`));
193
+ }
194
+ return result.join(" ");
195
+ }
196
+ /**
197
+ * Format logs for CLI output
198
+ */
199
+ export function formatLogsForCLI(response) {
200
+ const logs = response.logs;
201
+ if (!logs || logs.length === 0) {
202
+ console.log(chalk.dim("No logs available"));
203
+ return;
204
+ }
205
+ for (const log of logs) {
206
+ console.log(formatLogEntryString(log));
207
+ }
208
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Memory Monitor - Track memory usage and manage GC
3
+ * Helps prevent heap exhaustion during navigation
4
+ */
5
+ let lastMemoryUsage = null;
6
+ let gcAttempts = 0;
7
+ const MAX_GC_ATTEMPTS_PER_MINUTE = 5;
8
+ let lastGCReset = Date.now();
9
+ // Memory thresholds (in bytes)
10
+ const HEAP_WARNING_THRESHOLD = 3.5e9; // 3.5 GB
11
+ const HEAP_CRITICAL_THRESHOLD = 4e9; // 4 GB
12
+ export function logMemoryUsage(label) {
13
+ if (process.env.NODE_ENV === "development" || process.env.DEBUG_MEMORY) {
14
+ const current = process.memoryUsage();
15
+ const heapUsedMB = (current.heapUsed / 1024 / 1024).toFixed(2);
16
+ const heapTotalMB = (current.heapTotal / 1024 / 1024).toFixed(2);
17
+ const rssMB = (current.rss / 1024 / 1024).toFixed(2);
18
+ let delta = "";
19
+ if (lastMemoryUsage) {
20
+ const heapDelta = current.heapUsed - lastMemoryUsage.heapUsed;
21
+ const heapDeltaMB = (heapDelta / 1024 / 1024).toFixed(2);
22
+ delta = ` (Δ ${heapDeltaMB}MB)`;
23
+ }
24
+ console.error(`[MEMORY] ${label}: Heap ${heapUsedMB}/${heapTotalMB}MB, RSS ${rssMB}MB${delta}`);
25
+ // Warn if approaching limits
26
+ if (current.heapUsed > HEAP_WARNING_THRESHOLD) {
27
+ console.warn(`[MEMORY WARNING] Heap usage is high: ${heapUsedMB}MB (threshold: 3500MB)`);
28
+ }
29
+ lastMemoryUsage = current;
30
+ }
31
+ }
32
+ export function getMemoryPressure() {
33
+ const usage = process.memoryUsage();
34
+ const heapUsedPercent = (usage.heapUsed / usage.heapTotal) * 100;
35
+ if (usage.heapUsed > HEAP_CRITICAL_THRESHOLD || heapUsedPercent > 95)
36
+ return "critical";
37
+ if (usage.heapUsed > HEAP_WARNING_THRESHOLD || heapUsedPercent > 85)
38
+ return "high";
39
+ if (heapUsedPercent > 70)
40
+ return "medium";
41
+ return "low";
42
+ }
43
+ export function shouldTriggerGC() {
44
+ const pressure = getMemoryPressure();
45
+ return pressure === "high" || pressure === "critical";
46
+ }
47
+ /**
48
+ * Force garbage collection if available and needed
49
+ * Respects rate limiting to avoid GC thrashing
50
+ */
51
+ export function tryForceGC(reason) {
52
+ // Reset GC attempt counter every minute
53
+ const now = Date.now();
54
+ if (now - lastGCReset > 60000) {
55
+ gcAttempts = 0;
56
+ lastGCReset = now;
57
+ }
58
+ // Rate limit GC attempts
59
+ if (gcAttempts >= MAX_GC_ATTEMPTS_PER_MINUTE) {
60
+ return false;
61
+ }
62
+ // Check if global.gc is available (requires --expose-gc flag)
63
+ if (typeof global.gc === "function") {
64
+ const beforeHeap = process.memoryUsage().heapUsed;
65
+ global.gc();
66
+ gcAttempts++;
67
+ const afterHeap = process.memoryUsage().heapUsed;
68
+ const freedMB = ((beforeHeap - afterHeap) / 1024 / 1024).toFixed(2);
69
+ if (process.env.DEBUG_MEMORY) {
70
+ console.error(`[MEMORY] Forced GC${reason ? ` (${reason})` : ""}: Freed ${freedMB}MB`);
71
+ }
72
+ return true;
73
+ }
74
+ return false;
75
+ }
76
+ /**
77
+ * Monitor memory and trigger GC if needed
78
+ * Call this after major operations like screen transitions
79
+ */
80
+ export function checkMemoryPressure() {
81
+ const pressure = getMemoryPressure();
82
+ if (pressure === "critical" || pressure === "high") {
83
+ tryForceGC(`Memory pressure: ${pressure}`);
84
+ }
85
+ }
@@ -1,17 +1,99 @@
1
1
  /**
2
2
  * Utility for handling different output formats across CLI commands
3
+ *
4
+ * Simple API:
5
+ * - output(data, options) - outputs data in specified format
6
+ * - outputError(message, error) - outputs error and exits
3
7
  */
4
8
  import YAML from "yaml";
9
+ import { processUtils } from "./processUtils.js";
5
10
  /**
6
- * Check if the command should use non-interactive output
11
+ * Resolve the output format from options
7
12
  */
8
- export function shouldUseNonInteractiveOutput(options) {
9
- return !!options.output && options.output !== "interactive";
13
+ function resolveFormat(options) {
14
+ const format = options.format || options.defaultFormat || "json";
15
+ if (format === "json" || format === "yaml" || format === "text") {
16
+ return format;
17
+ }
18
+ console.error(`Unknown output format: ${format}. Valid options: text, json, yaml`);
19
+ processUtils.exit(1);
10
20
  }
11
21
  /**
12
- * Output data in the specified format
22
+ * Format a value for text output (key-value pairs)
13
23
  */
14
- export function outputData(data, format = "json") {
24
+ function formatKeyValue(data, indent = 0) {
25
+ const prefix = " ".repeat(indent);
26
+ if (data === null || data === undefined) {
27
+ return `${prefix}(none)`;
28
+ }
29
+ if (typeof data === "string" ||
30
+ typeof data === "number" ||
31
+ typeof data === "boolean") {
32
+ return String(data);
33
+ }
34
+ if (Array.isArray(data)) {
35
+ if (data.length === 0) {
36
+ return `${prefix}(empty)`;
37
+ }
38
+ // For arrays of primitives, join them
39
+ if (data.every((item) => typeof item !== "object" || item === null)) {
40
+ return data.join(", ");
41
+ }
42
+ // For arrays of objects, format each with separator
43
+ return data
44
+ .map((item) => {
45
+ if (typeof item === "object" && item !== null) {
46
+ const lines = [];
47
+ for (const [key, value] of Object.entries(item)) {
48
+ if (value !== null && value !== undefined) {
49
+ const formattedValue = typeof value === "object"
50
+ ? formatKeyValue(value, indent + 1)
51
+ : String(value);
52
+ lines.push(`${prefix}${key}: ${formattedValue}`);
53
+ }
54
+ }
55
+ return lines.join("\n");
56
+ }
57
+ return `${prefix}${item}`;
58
+ })
59
+ .join(`\n${prefix}---\n`);
60
+ }
61
+ if (typeof data === "object") {
62
+ const lines = [];
63
+ for (const [key, value] of Object.entries(data)) {
64
+ if (value !== null && value !== undefined) {
65
+ if (typeof value === "object" && !Array.isArray(value)) {
66
+ lines.push(`${prefix}${key}:`);
67
+ lines.push(formatKeyValue(value, indent + 1));
68
+ }
69
+ else if (Array.isArray(value)) {
70
+ lines.push(`${prefix}${key}: ${formatKeyValue(value, 0)}`);
71
+ }
72
+ else {
73
+ lines.push(`${prefix}${key}: ${value}`);
74
+ }
75
+ }
76
+ }
77
+ return lines.join("\n");
78
+ }
79
+ return String(data);
80
+ }
81
+ /**
82
+ * Main output function - outputs data in the specified format
83
+ *
84
+ * @param data - The data to output
85
+ * @param options - Output options (format, defaultFormat)
86
+ *
87
+ * @example
88
+ * // Output a devbox as text (default for single items)
89
+ * output(devbox, { format: options.output, defaultFormat: 'text' });
90
+ *
91
+ * @example
92
+ * // Output a list as JSON (default for lists)
93
+ * output(devboxes, { format: options.output, defaultFormat: 'json' });
94
+ */
95
+ export function output(data, options = {}) {
96
+ const format = resolveFormat(options);
15
97
  if (format === "json") {
16
98
  console.log(JSON.stringify(data, null, 2));
17
99
  return;
@@ -20,49 +102,78 @@ export function outputData(data, format = "json") {
20
102
  console.log(YAML.stringify(data));
21
103
  return;
22
104
  }
23
- if (format === "text") {
24
- // Simple text output
25
- if (Array.isArray(data)) {
26
- // For lists of complex objects, just output IDs
27
- data.forEach((item) => {
28
- if (typeof item === "object" && item !== null && "id" in item) {
29
- console.log(item.id);
30
- }
31
- else {
32
- console.log(formatTextOutput(item));
33
- }
34
- });
35
- }
36
- else {
37
- console.log(formatTextOutput(data));
38
- }
39
- return;
105
+ // Text format - key-value pairs
106
+ console.log(formatKeyValue(data));
107
+ }
108
+ /**
109
+ * Output an error message and exit
110
+ *
111
+ * @param message - Human-readable error message
112
+ * @param error - Optional Error object with details
113
+ *
114
+ * @example
115
+ * outputError('Failed to get devbox', error);
116
+ */
117
+ export function outputError(message, error) {
118
+ const errorMessage = error instanceof Error ? error.message : String(error || message);
119
+ console.error(`Error: ${message}`);
120
+ if (error && errorMessage !== message) {
121
+ console.error(` ${errorMessage}`);
40
122
  }
41
- console.error(`Unknown output format: ${format}`);
42
- process.exit(1);
123
+ processUtils.exit(1);
43
124
  }
44
125
  /**
45
- * Format a single item as text output
126
+ * Output a success message for action commands
127
+ *
128
+ * @param message - Success message
129
+ * @param data - Optional data to include
130
+ * @param options - Output options
46
131
  */
47
- function formatTextOutput(item) {
48
- if (typeof item === "string") {
49
- return item;
50
- }
51
- // For objects, create a simple key: value format
52
- const lines = [];
53
- for (const [key, value] of Object.entries(item)) {
54
- if (value !== null && value !== undefined) {
55
- lines.push(`${key}: ${value}`);
56
- }
132
+ export function outputSuccess(message, data, options = {}) {
133
+ const format = resolveFormat(options);
134
+ if (format === "json") {
135
+ console.log(JSON.stringify({
136
+ success: true,
137
+ message,
138
+ ...(data && typeof data === "object" ? data : { data }),
139
+ }, null, 2));
140
+ return;
57
141
  }
58
- return lines.join("\n");
142
+ if (format === "yaml") {
143
+ console.log(YAML.stringify({
144
+ success: true,
145
+ message,
146
+ ...(data && typeof data === "object" ? data : { data }),
147
+ }));
148
+ return;
149
+ }
150
+ // Text format
151
+ console.log(`✓ ${message}`);
152
+ if (data) {
153
+ console.log(formatKeyValue(data));
154
+ }
155
+ }
156
+ // ============================================================================
157
+ // Legacy API (for backward compatibility during migration)
158
+ // ============================================================================
159
+ /**
160
+ * @deprecated Use output() instead
161
+ */
162
+ export function shouldUseNonInteractiveOutput(options) {
163
+ return !!options.output && options.output !== "interactive";
59
164
  }
60
165
  /**
61
- * Output a single result (for create, delete, etc)
166
+ * @deprecated Use output() instead
167
+ */
168
+ export function outputData(data, format = "json") {
169
+ output(data, { format, defaultFormat: format });
170
+ }
171
+ /**
172
+ * @deprecated Use output() instead
62
173
  */
63
174
  export function outputResult(result, options, successMessage) {
64
175
  if (shouldUseNonInteractiveOutput(options)) {
65
- outputData(result, options.output);
176
+ output(result, { format: options.output, defaultFormat: "text" });
66
177
  return;
67
178
  }
68
179
  // Interactive mode - print success message
@@ -71,34 +182,15 @@ export function outputResult(result, options, successMessage) {
71
182
  }
72
183
  }
73
184
  /**
74
- * Output a list of items (for list commands)
185
+ * @deprecated Use output() instead
75
186
  */
76
187
  export function outputList(items, options) {
77
188
  if (shouldUseNonInteractiveOutput(options)) {
78
- outputData(items, options.output);
79
- }
80
- }
81
- /**
82
- * Handle errors in both interactive and non-interactive modes
83
- */
84
- export function outputError(error, options) {
85
- if (shouldUseNonInteractiveOutput(options)) {
86
- if (options.output === "json") {
87
- console.error(JSON.stringify({ error: error.message }, null, 2));
88
- }
89
- else if (options.output === "yaml") {
90
- console.error(YAML.stringify({ error: error.message }));
91
- }
92
- else {
93
- console.error(`Error: ${error.message}`);
94
- }
95
- process.exit(1);
189
+ output(items, { format: options.output, defaultFormat: "json" });
96
190
  }
97
- // Let interactive UI handle the error
98
- throw error;
99
191
  }
100
192
  /**
101
- * Validate output format option
193
+ * @deprecated Use validateOutputFormat with the new output() function
102
194
  */
103
195
  export function validateOutputFormat(format) {
104
196
  if (!format || format === "text") {
@@ -111,5 +203,5 @@ export function validateOutputFormat(format) {
111
203
  return "yaml";
112
204
  }
113
205
  console.error(`Unknown output format: ${format}. Valid options: text, json, yaml`);
114
- process.exit(1);
206
+ processUtils.exit(1);
115
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
+ }