@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.
- package/README.md +54 -10
- package/dist/cli.js +79 -72
- package/dist/commands/auth.js +2 -2
- package/dist/commands/blueprint/create.js +31 -83
- package/dist/commands/blueprint/get.js +29 -34
- package/dist/commands/blueprint/list.js +278 -230
- package/dist/commands/blueprint/logs.js +133 -37
- package/dist/commands/config.js +118 -0
- package/dist/commands/devbox/create.js +120 -40
- package/dist/commands/devbox/delete.js +17 -33
- package/dist/commands/devbox/download.js +29 -43
- package/dist/commands/devbox/exec.js +22 -39
- package/dist/commands/devbox/execAsync.js +20 -37
- package/dist/commands/devbox/get.js +13 -35
- package/dist/commands/devbox/getAsync.js +12 -34
- package/dist/commands/devbox/list.js +241 -402
- package/dist/commands/devbox/logs.js +20 -38
- package/dist/commands/devbox/read.js +29 -43
- package/dist/commands/devbox/resume.js +13 -35
- package/dist/commands/devbox/rsync.js +26 -78
- package/dist/commands/devbox/scp.js +25 -79
- package/dist/commands/devbox/sendStdin.js +41 -0
- package/dist/commands/devbox/shutdown.js +13 -35
- package/dist/commands/devbox/ssh.js +46 -78
- package/dist/commands/devbox/suspend.js +13 -35
- package/dist/commands/devbox/tunnel.js +37 -88
- package/dist/commands/devbox/upload.js +28 -36
- package/dist/commands/devbox/write.js +29 -44
- package/dist/commands/mcp-http.js +6 -5
- package/dist/commands/mcp-install.js +12 -10
- package/dist/commands/mcp.js +5 -4
- package/dist/commands/menu.js +26 -67
- package/dist/commands/object/delete.js +12 -34
- package/dist/commands/object/download.js +26 -74
- package/dist/commands/object/get.js +12 -34
- package/dist/commands/object/list.js +15 -93
- package/dist/commands/object/upload.js +35 -96
- package/dist/commands/snapshot/create.js +23 -39
- package/dist/commands/snapshot/delete.js +17 -33
- package/dist/commands/snapshot/get.js +16 -0
- package/dist/commands/snapshot/list.js +309 -80
- package/dist/commands/snapshot/status.js +12 -34
- package/dist/components/ActionsPopup.js +64 -39
- package/dist/components/Banner.js +7 -1
- package/dist/components/Breadcrumb.js +11 -48
- package/dist/components/DevboxActionsMenu.js +117 -207
- package/dist/components/DevboxCreatePage.js +12 -7
- package/dist/components/DevboxDetailPage.js +76 -28
- package/dist/components/ErrorBoundary.js +29 -0
- package/dist/components/ErrorMessage.js +10 -2
- package/dist/components/Header.js +12 -4
- package/dist/components/InteractiveSpawn.js +104 -0
- package/dist/components/LogsViewer.js +169 -0
- package/dist/components/MainMenu.js +37 -33
- package/dist/components/MetadataDisplay.js +4 -4
- package/dist/components/OperationsMenu.js +1 -1
- package/dist/components/ResourceActionsMenu.js +4 -4
- package/dist/components/ResourceListView.js +46 -34
- package/dist/components/Spinner.js +7 -2
- package/dist/components/StatusBadge.js +1 -1
- package/dist/components/SuccessMessage.js +12 -2
- package/dist/components/Table.js +16 -6
- package/dist/components/UpdateNotification.js +56 -0
- package/dist/hooks/useCursorPagination.js +125 -85
- package/dist/hooks/useExitOnCtrlC.js +15 -0
- package/dist/hooks/useViewportHeight.js +47 -0
- package/dist/mcp/server-http.js +2 -1
- package/dist/mcp/server.js +71 -7
- package/dist/router/Router.js +70 -0
- package/dist/router/types.js +1 -0
- package/dist/screens/BlueprintListScreen.js +7 -0
- package/dist/screens/BlueprintLogsScreen.js +74 -0
- package/dist/screens/DevboxActionsScreen.js +25 -0
- package/dist/screens/DevboxCreateScreen.js +11 -0
- package/dist/screens/DevboxDetailScreen.js +60 -0
- package/dist/screens/DevboxListScreen.js +23 -0
- package/dist/screens/LogsSessionScreen.js +49 -0
- package/dist/screens/MenuScreen.js +23 -0
- package/dist/screens/SSHSessionScreen.js +55 -0
- package/dist/screens/SnapshotListScreen.js +7 -0
- package/dist/services/blueprintService.js +101 -0
- package/dist/services/devboxService.js +215 -0
- package/dist/services/snapshotService.js +81 -0
- package/dist/store/blueprintStore.js +89 -0
- package/dist/store/devboxStore.js +105 -0
- package/dist/store/index.js +7 -0
- package/dist/store/navigationStore.js +101 -0
- package/dist/store/snapshotStore.js +87 -0
- package/dist/utils/client.js +4 -2
- package/dist/utils/config.js +22 -111
- package/dist/utils/interactiveCommand.js +3 -2
- package/dist/utils/logFormatter.js +208 -0
- package/dist/utils/memoryMonitor.js +85 -0
- package/dist/utils/output.js +153 -61
- package/dist/utils/process.js +106 -0
- package/dist/utils/processUtils.js +135 -0
- package/dist/utils/screen.js +61 -0
- package/dist/utils/ssh.js +6 -3
- package/dist/utils/sshSession.js +5 -29
- package/dist/utils/terminalDetection.js +185 -0
- package/dist/utils/terminalSync.js +39 -0
- package/dist/utils/theme.js +162 -13
- package/dist/utils/versionCheck.js +53 -0
- package/dist/version.js +12 -0
- 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
|
+
}
|
package/dist/utils/output.js
CHANGED
|
@@ -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
|
-
*
|
|
11
|
+
* Resolve the output format from options
|
|
7
12
|
*/
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
*
|
|
22
|
+
* Format a value for text output (key-value pairs)
|
|
13
23
|
*/
|
|
14
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
process.exit(1);
|
|
123
|
+
processUtils.exit(1);
|
|
43
124
|
}
|
|
44
125
|
/**
|
|
45
|
-
*
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
185
|
+
* @deprecated Use output() instead
|
|
75
186
|
*/
|
|
76
187
|
export function outputList(items, options) {
|
|
77
188
|
if (shouldUseNonInteractiveOutput(options)) {
|
|
78
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|