@mandujs/cli 0.9.46 → 0.11.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/package.json +3 -2
- package/src/commands/check.ts +290 -238
- package/src/commands/dev.ts +486 -440
- package/src/commands/init.ts +128 -21
- package/src/commands/lock.ts +434 -0
- package/src/commands/registry.ts +357 -0
- package/src/hooks/index.ts +17 -0
- package/src/hooks/preaction.ts +256 -0
- package/src/main.ts +228 -419
- package/src/terminal/banner.ts +166 -0
- package/src/terminal/help.ts +306 -0
- package/src/terminal/index.ts +71 -0
- package/src/terminal/output.ts +295 -0
- package/src/terminal/palette.ts +30 -0
- package/src/terminal/progress.ts +327 -0
- package/src/terminal/stream-writer.ts +214 -0
- package/src/terminal/table.ts +354 -0
- package/src/terminal/theme.ts +142 -0
- package/src/util/output.ts +7 -26
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DNA-013: Safe Stream Writer
|
|
3
|
+
*
|
|
4
|
+
* 파이프 환경에서 EPIPE 에러를 안전하게 처리
|
|
5
|
+
* - `mandu routes list | head -5` 같은 파이프 사용 시 안전
|
|
6
|
+
* - Broken pipe 감지 후 추가 쓰기 방지
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Safe Stream Writer 옵션
|
|
11
|
+
*/
|
|
12
|
+
export interface SafeStreamWriterOptions {
|
|
13
|
+
/**
|
|
14
|
+
* Broken pipe 발생 시 콜백
|
|
15
|
+
*/
|
|
16
|
+
onBrokenPipe?: (error: NodeJS.ErrnoException, stream: NodeJS.WriteStream) => void;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 조용히 실패 (에러 출력 안 함)
|
|
20
|
+
*/
|
|
21
|
+
silent?: boolean;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Broken pipe 이외의 에러 핸들러 (선택)
|
|
25
|
+
*/
|
|
26
|
+
onError?: (error: Error, stream: NodeJS.WriteStream) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Safe Stream Writer 인터페이스
|
|
31
|
+
*/
|
|
32
|
+
export interface SafeStreamWriter {
|
|
33
|
+
/**
|
|
34
|
+
* 스트림에 텍스트 쓰기
|
|
35
|
+
* @returns 성공 여부
|
|
36
|
+
*/
|
|
37
|
+
write: (stream: NodeJS.WriteStream, text: string) => boolean;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 스트림에 줄 쓰기 (자동 개행)
|
|
41
|
+
* @returns 성공 여부
|
|
42
|
+
*/
|
|
43
|
+
writeLine: (stream: NodeJS.WriteStream, text: string) => boolean;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* stdout에 쓰기 (편의 메서드)
|
|
47
|
+
*/
|
|
48
|
+
print: (text: string) => boolean;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* stdout에 줄 쓰기 (편의 메서드)
|
|
52
|
+
*/
|
|
53
|
+
println: (text: string) => boolean;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* stderr에 쓰기 (편의 메서드)
|
|
57
|
+
*/
|
|
58
|
+
printError: (text: string) => boolean;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 상태 리셋
|
|
62
|
+
*/
|
|
63
|
+
reset: () => void;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 스트림이 닫혔는지 확인
|
|
67
|
+
*/
|
|
68
|
+
isClosed: () => boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Broken Pipe 에러인지 확인
|
|
73
|
+
*/
|
|
74
|
+
function isBrokenPipeError(err: unknown): err is NodeJS.ErrnoException {
|
|
75
|
+
const errno = err as NodeJS.ErrnoException;
|
|
76
|
+
return errno?.code === "EPIPE" || errno?.code === "EIO";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Safe Stream Writer 생성
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```ts
|
|
84
|
+
* const writer = createSafeStreamWriter();
|
|
85
|
+
*
|
|
86
|
+
* // 기본 사용
|
|
87
|
+
* writer.println("Hello, World!");
|
|
88
|
+
*
|
|
89
|
+
* // 파이프에서 안전하게 사용
|
|
90
|
+
* for (const line of lines) {
|
|
91
|
+
* if (!writer.println(line)) {
|
|
92
|
+
* // 파이프가 닫힘, 루프 종료
|
|
93
|
+
* break;
|
|
94
|
+
* }
|
|
95
|
+
* }
|
|
96
|
+
* ```
|
|
97
|
+
*/
|
|
98
|
+
export function createSafeStreamWriter(
|
|
99
|
+
options: SafeStreamWriterOptions = {}
|
|
100
|
+
): SafeStreamWriter {
|
|
101
|
+
const closedStreams = new Set<NodeJS.WriteStream>();
|
|
102
|
+
const errorHandlers = new Map<NodeJS.WriteStream, (err: Error) => void>();
|
|
103
|
+
|
|
104
|
+
const ensureErrorHandler = (stream: NodeJS.WriteStream): void => {
|
|
105
|
+
if (errorHandlers.has(stream)) return;
|
|
106
|
+
|
|
107
|
+
const handler = (err: Error) => {
|
|
108
|
+
if (isBrokenPipeError(err)) {
|
|
109
|
+
closedStreams.add(stream);
|
|
110
|
+
options.onBrokenPipe?.(err, stream);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (options.onError) {
|
|
115
|
+
options.onError(err, stream);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!options.silent) {
|
|
120
|
+
console.error("[SafeStreamWriter] Stream error:", err);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 비정상 에러는 기존 동작을 유지하도록 비동기 재-throw
|
|
124
|
+
setTimeout(() => {
|
|
125
|
+
throw err;
|
|
126
|
+
}, 0);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
stream.on("error", handler);
|
|
130
|
+
errorHandlers.set(stream, handler);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const isStreamClosed = (stream: NodeJS.WriteStream): boolean => {
|
|
134
|
+
const anyStream = stream as NodeJS.WriteStream & {
|
|
135
|
+
destroyed?: boolean;
|
|
136
|
+
writableEnded?: boolean;
|
|
137
|
+
};
|
|
138
|
+
if (anyStream.destroyed || anyStream.writableEnded) return true;
|
|
139
|
+
return closedStreams.has(stream);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const write = (stream: NodeJS.WriteStream, text: string): boolean => {
|
|
143
|
+
if (isStreamClosed(stream)) return false;
|
|
144
|
+
|
|
145
|
+
ensureErrorHandler(stream);
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
stream.write(text);
|
|
149
|
+
return true;
|
|
150
|
+
} catch (err) {
|
|
151
|
+
if (!isBrokenPipeError(err)) {
|
|
152
|
+
throw err;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
closedStreams.add(stream);
|
|
156
|
+
options.onBrokenPipe?.(err, stream);
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
write,
|
|
163
|
+
writeLine: (stream, text) => write(stream, `${text}\n`),
|
|
164
|
+
print: (text) => write(process.stdout, text),
|
|
165
|
+
println: (text) => write(process.stdout, `${text}\n`),
|
|
166
|
+
printError: (text) => write(process.stderr, `${text}\n`),
|
|
167
|
+
reset: () => {
|
|
168
|
+
closedStreams.clear();
|
|
169
|
+
for (const [stream, handler] of errorHandlers) {
|
|
170
|
+
stream.removeListener("error", handler);
|
|
171
|
+
}
|
|
172
|
+
errorHandlers.clear();
|
|
173
|
+
},
|
|
174
|
+
isClosed: () => isStreamClosed(process.stdout),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 기본 Safe Writer 인스턴스 (싱글톤)
|
|
180
|
+
*/
|
|
181
|
+
let defaultWriter: SafeStreamWriter | null = null;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 기본 Safe Writer 가져오기
|
|
185
|
+
*/
|
|
186
|
+
export function getSafeWriter(): SafeStreamWriter {
|
|
187
|
+
if (!defaultWriter) {
|
|
188
|
+
defaultWriter = createSafeStreamWriter({ silent: true });
|
|
189
|
+
}
|
|
190
|
+
return defaultWriter;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 안전한 console.log 대체
|
|
195
|
+
*
|
|
196
|
+
* @example
|
|
197
|
+
* ```ts
|
|
198
|
+
* import { safePrint, safePrintln } from "./stream-writer";
|
|
199
|
+
*
|
|
200
|
+
* safePrintln("Hello, World!");
|
|
201
|
+
* // 파이프가 닫혀도 에러 없음
|
|
202
|
+
* ```
|
|
203
|
+
*/
|
|
204
|
+
export function safePrint(text: string): boolean {
|
|
205
|
+
return getSafeWriter().print(text);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function safePrintln(text: string): boolean {
|
|
209
|
+
return getSafeWriter().println(text);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function safePrintError(text: string): boolean {
|
|
213
|
+
return getSafeWriter().printError(text);
|
|
214
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DNA-011: ANSI-aware Table Rendering
|
|
3
|
+
*
|
|
4
|
+
* 터미널 테이블 렌더링
|
|
5
|
+
* - ANSI 색상 코드 인식 (너비 계산에서 제외)
|
|
6
|
+
* - 유니코드 박스 문자 지원
|
|
7
|
+
* - 반응형 너비 조정 (flex 컬럼)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { stripAnsi } from "./theme.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 테이블 컬럼 정의
|
|
14
|
+
*/
|
|
15
|
+
export interface TableColumn {
|
|
16
|
+
/** 데이터 키 */
|
|
17
|
+
key: string;
|
|
18
|
+
/** 헤더 텍스트 */
|
|
19
|
+
header: string;
|
|
20
|
+
/** 정렬 (기본: left) */
|
|
21
|
+
align?: "left" | "right" | "center";
|
|
22
|
+
/** 최소 너비 */
|
|
23
|
+
minWidth?: number;
|
|
24
|
+
/** 최대 너비 */
|
|
25
|
+
maxWidth?: number;
|
|
26
|
+
/** 반응형 너비 조정 대상 */
|
|
27
|
+
flex?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 테두리 스타일
|
|
32
|
+
*/
|
|
33
|
+
export type BorderStyle = "unicode" | "ascii" | "none";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 테이블 렌더링 옵션
|
|
37
|
+
*/
|
|
38
|
+
export interface RenderTableOptions {
|
|
39
|
+
/** 컬럼 정의 */
|
|
40
|
+
columns: TableColumn[];
|
|
41
|
+
/** 데이터 행 */
|
|
42
|
+
rows: Record<string, unknown>[];
|
|
43
|
+
/** 테두리 스타일 (기본: unicode) */
|
|
44
|
+
border?: BorderStyle;
|
|
45
|
+
/** 최대 테이블 너비 */
|
|
46
|
+
maxWidth?: number;
|
|
47
|
+
/** 헤더 표시 여부 (기본: true) */
|
|
48
|
+
showHeader?: boolean;
|
|
49
|
+
/** 컴팩트 모드 (패딩 최소화) */
|
|
50
|
+
compact?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 박스 그리기 문자
|
|
55
|
+
*/
|
|
56
|
+
interface BoxChars {
|
|
57
|
+
tl: string; // top-left
|
|
58
|
+
tr: string; // top-right
|
|
59
|
+
bl: string; // bottom-left
|
|
60
|
+
br: string; // bottom-right
|
|
61
|
+
h: string; // horizontal
|
|
62
|
+
v: string; // vertical
|
|
63
|
+
t: string; // top junction
|
|
64
|
+
b: string; // bottom junction
|
|
65
|
+
ml: string; // middle-left
|
|
66
|
+
mr: string; // middle-right
|
|
67
|
+
m: string; // middle junction
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const UNICODE_BOX: BoxChars = {
|
|
71
|
+
tl: "┌",
|
|
72
|
+
tr: "┐",
|
|
73
|
+
bl: "└",
|
|
74
|
+
br: "┘",
|
|
75
|
+
h: "─",
|
|
76
|
+
v: "│",
|
|
77
|
+
t: "┬",
|
|
78
|
+
b: "┴",
|
|
79
|
+
ml: "├",
|
|
80
|
+
mr: "┤",
|
|
81
|
+
m: "┼",
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const ASCII_BOX: BoxChars = {
|
|
85
|
+
tl: "+",
|
|
86
|
+
tr: "+",
|
|
87
|
+
bl: "+",
|
|
88
|
+
br: "+",
|
|
89
|
+
h: "-",
|
|
90
|
+
v: "|",
|
|
91
|
+
t: "+",
|
|
92
|
+
b: "+",
|
|
93
|
+
ml: "+",
|
|
94
|
+
mr: "+",
|
|
95
|
+
m: "+",
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* ANSI 코드를 제외한 실제 표시 너비 계산
|
|
100
|
+
*/
|
|
101
|
+
function displayWidth(str: string): number {
|
|
102
|
+
return stripAnsi(str).length;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 문자열 패딩 (ANSI 인식)
|
|
107
|
+
*/
|
|
108
|
+
function padString(
|
|
109
|
+
str: string,
|
|
110
|
+
width: number,
|
|
111
|
+
align: "left" | "right" | "center" = "left"
|
|
112
|
+
): string {
|
|
113
|
+
const visibleLen = displayWidth(str);
|
|
114
|
+
const padding = width - visibleLen;
|
|
115
|
+
|
|
116
|
+
if (padding <= 0) return str;
|
|
117
|
+
|
|
118
|
+
switch (align) {
|
|
119
|
+
case "right":
|
|
120
|
+
return " ".repeat(padding) + str;
|
|
121
|
+
case "center": {
|
|
122
|
+
const left = Math.floor(padding / 2);
|
|
123
|
+
const right = padding - left;
|
|
124
|
+
return " ".repeat(left) + str + " ".repeat(right);
|
|
125
|
+
}
|
|
126
|
+
case "left":
|
|
127
|
+
default:
|
|
128
|
+
return str + " ".repeat(padding);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 문자열 트렁케이션 (ANSI 인식)
|
|
134
|
+
*/
|
|
135
|
+
function truncateWithAnsi(str: string, maxWidth: number): string {
|
|
136
|
+
const plain = stripAnsi(str);
|
|
137
|
+
if (plain.length <= maxWidth) return str;
|
|
138
|
+
|
|
139
|
+
// ANSI 코드 위치 추적
|
|
140
|
+
const ansiRegex = /\x1b\[[0-9;]*m/g;
|
|
141
|
+
const ansiCodes: { index: number; code: string }[] = [];
|
|
142
|
+
let match;
|
|
143
|
+
while ((match = ansiRegex.exec(str)) !== null) {
|
|
144
|
+
ansiCodes.push({ index: match.index, code: match[0] });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// plain text 기준으로 트렁케이션
|
|
148
|
+
const truncatedPlain = plain.slice(0, maxWidth - 1) + "…";
|
|
149
|
+
|
|
150
|
+
// ANSI 코드가 없으면 그대로 반환
|
|
151
|
+
if (ansiCodes.length === 0) return truncatedPlain;
|
|
152
|
+
|
|
153
|
+
// ANSI 코드 재삽입 (복잡하므로 단순화)
|
|
154
|
+
// 첫 번째와 마지막 ANSI 코드만 보존
|
|
155
|
+
const firstCode = ansiCodes[0]?.code || "";
|
|
156
|
+
const resetCode = "\x1b[0m";
|
|
157
|
+
|
|
158
|
+
if (firstCode && ansiCodes.length > 0) {
|
|
159
|
+
return firstCode + truncatedPlain + resetCode;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return truncatedPlain;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 컬럼 너비 계산
|
|
167
|
+
*/
|
|
168
|
+
function calculateWidths(
|
|
169
|
+
columns: TableColumn[],
|
|
170
|
+
rows: Record<string, unknown>[],
|
|
171
|
+
maxWidth?: number,
|
|
172
|
+
compact?: boolean,
|
|
173
|
+
border: BorderStyle = "unicode"
|
|
174
|
+
): number[] {
|
|
175
|
+
const padding = compact ? 1 : 2;
|
|
176
|
+
|
|
177
|
+
// 각 컬럼의 초기 너비 계산
|
|
178
|
+
const widths = columns.map((col) => {
|
|
179
|
+
const headerWidth = displayWidth(col.header);
|
|
180
|
+
const maxCellWidth = Math.max(
|
|
181
|
+
0,
|
|
182
|
+
...rows.map((row) => displayWidth(String(row[col.key] ?? "")))
|
|
183
|
+
);
|
|
184
|
+
const contentWidth = Math.max(headerWidth, maxCellWidth);
|
|
185
|
+
|
|
186
|
+
let width = contentWidth + padding;
|
|
187
|
+
|
|
188
|
+
if (col.minWidth) width = Math.max(width, col.minWidth);
|
|
189
|
+
if (col.maxWidth) width = Math.min(width, col.maxWidth);
|
|
190
|
+
|
|
191
|
+
return width;
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// 최대 너비 제약 처리
|
|
195
|
+
if (maxWidth) {
|
|
196
|
+
const borderWidth = border === "none" ? 0 : columns.length + 1; // 세로선 개수
|
|
197
|
+
const totalWidth = widths.reduce((a, b) => a + b, 0) + borderWidth;
|
|
198
|
+
|
|
199
|
+
if (totalWidth > maxWidth) {
|
|
200
|
+
const overflow = totalWidth - maxWidth;
|
|
201
|
+
const flexIndices = columns
|
|
202
|
+
.map((c, i) => (c.flex ? i : -1))
|
|
203
|
+
.filter((i) => i >= 0);
|
|
204
|
+
|
|
205
|
+
if (flexIndices.length > 0) {
|
|
206
|
+
// flex 컬럼에서 균등 축소
|
|
207
|
+
const shrinkPerColumn = Math.ceil(overflow / flexIndices.length);
|
|
208
|
+
for (const idx of flexIndices) {
|
|
209
|
+
const minW = columns[idx].minWidth ?? 5;
|
|
210
|
+
widths[idx] = Math.max(minW, widths[idx] - shrinkPerColumn);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return widths;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 테이블 렌더링
|
|
221
|
+
*
|
|
222
|
+
* @example
|
|
223
|
+
* ```ts
|
|
224
|
+
* const output = renderTable({
|
|
225
|
+
* columns: [
|
|
226
|
+
* { key: "name", header: "Name", minWidth: 10 },
|
|
227
|
+
* { key: "status", header: "Status", align: "center" },
|
|
228
|
+
* { key: "size", header: "Size", align: "right" },
|
|
229
|
+
* ],
|
|
230
|
+
* rows: [
|
|
231
|
+
* { name: "file1.ts", status: "✓", size: "1.2KB" },
|
|
232
|
+
* { name: "file2.ts", status: "✗", size: "3.4KB" },
|
|
233
|
+
* ],
|
|
234
|
+
* border: "unicode",
|
|
235
|
+
* });
|
|
236
|
+
*
|
|
237
|
+
* // ┌────────────┬────────┬───────┐
|
|
238
|
+
* // │ Name │ Status │ Size │
|
|
239
|
+
* // ├────────────┼────────┼───────┤
|
|
240
|
+
* // │ file1.ts │ ✓ │ 1.2KB │
|
|
241
|
+
* // │ file2.ts │ ✗ │ 3.4KB │
|
|
242
|
+
* // └────────────┴────────┴───────┘
|
|
243
|
+
* ```
|
|
244
|
+
*/
|
|
245
|
+
export function renderTable(options: RenderTableOptions): string {
|
|
246
|
+
const {
|
|
247
|
+
columns,
|
|
248
|
+
rows,
|
|
249
|
+
border = "unicode",
|
|
250
|
+
maxWidth,
|
|
251
|
+
showHeader = true,
|
|
252
|
+
compact = false,
|
|
253
|
+
} = options;
|
|
254
|
+
|
|
255
|
+
if (columns.length === 0) return "";
|
|
256
|
+
|
|
257
|
+
const widths = calculateWidths(columns, rows, maxWidth, compact, border);
|
|
258
|
+
const box = border === "unicode" ? UNICODE_BOX : ASCII_BOX;
|
|
259
|
+
const lines: string[] = [];
|
|
260
|
+
|
|
261
|
+
// 수평선 생성
|
|
262
|
+
const createLine = (
|
|
263
|
+
left: string,
|
|
264
|
+
mid: string,
|
|
265
|
+
right: string,
|
|
266
|
+
fill: string
|
|
267
|
+
): string => {
|
|
268
|
+
const segments = widths.map((w) => fill.repeat(w));
|
|
269
|
+
return left + segments.join(mid) + right;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// 데이터 행 생성
|
|
273
|
+
const createRow = (data: Record<string, unknown>): string => {
|
|
274
|
+
const cells = columns.map((col, i) => {
|
|
275
|
+
let value = String(data[col.key] ?? "");
|
|
276
|
+
const width = widths[i];
|
|
277
|
+
|
|
278
|
+
// 트렁케이션
|
|
279
|
+
if (displayWidth(value) > width - (compact ? 1 : 2)) {
|
|
280
|
+
value = truncateWithAnsi(value, width - (compact ? 1 : 2));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 패딩
|
|
284
|
+
const padded = padString(value, width - (compact ? 0 : 1), col.align);
|
|
285
|
+
return compact ? padded : " " + padded;
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
if (border === "none") {
|
|
289
|
+
return cells.join(" ");
|
|
290
|
+
}
|
|
291
|
+
return box.v + cells.join(box.v) + box.v;
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
// 상단 테두리
|
|
295
|
+
if (border !== "none") {
|
|
296
|
+
lines.push(createLine(box.tl, box.t, box.tr, box.h));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// 헤더
|
|
300
|
+
if (showHeader) {
|
|
301
|
+
const headerRow: Record<string, unknown> = {};
|
|
302
|
+
for (const col of columns) {
|
|
303
|
+
headerRow[col.key] = col.header;
|
|
304
|
+
}
|
|
305
|
+
lines.push(createRow(headerRow));
|
|
306
|
+
|
|
307
|
+
// 헤더 구분선
|
|
308
|
+
if (border !== "none") {
|
|
309
|
+
lines.push(createLine(box.ml, box.m, box.mr, box.h));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// 데이터 행
|
|
314
|
+
for (const row of rows) {
|
|
315
|
+
lines.push(createRow(row));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// 하단 테두리
|
|
319
|
+
if (border !== "none") {
|
|
320
|
+
lines.push(createLine(box.bl, box.b, box.br, box.h));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return lines.join("\n");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* 간단한 리스트 테이블 (키-값 쌍)
|
|
328
|
+
*
|
|
329
|
+
* @example
|
|
330
|
+
* ```ts
|
|
331
|
+
* renderKeyValueTable([
|
|
332
|
+
* { key: "Name", value: "Mandu" },
|
|
333
|
+
* { key: "Version", value: "0.11.0" },
|
|
334
|
+
* ]);
|
|
335
|
+
* // ┌─────────┬─────────┐
|
|
336
|
+
* // │ Name │ Mandu │
|
|
337
|
+
* // │ Version │ 0.11.0 │
|
|
338
|
+
* // └─────────┴─────────┘
|
|
339
|
+
* ```
|
|
340
|
+
*/
|
|
341
|
+
export function renderKeyValueTable(
|
|
342
|
+
items: { key: string; value: string }[],
|
|
343
|
+
options: { border?: BorderStyle; maxWidth?: number } = {}
|
|
344
|
+
): string {
|
|
345
|
+
return renderTable({
|
|
346
|
+
columns: [
|
|
347
|
+
{ key: "key", header: "Key" },
|
|
348
|
+
{ key: "value", header: "Value", flex: true },
|
|
349
|
+
],
|
|
350
|
+
rows: items,
|
|
351
|
+
showHeader: false,
|
|
352
|
+
...options,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DNA-009: Mandu CLI Theme System
|
|
3
|
+
*
|
|
4
|
+
* Chalk-based dynamic color theme with NO_COLOR/FORCE_COLOR support
|
|
5
|
+
* Inspired by OpenClaw's terminal/theme.ts
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { MANDU_PALETTE } from "./palette.js";
|
|
9
|
+
|
|
10
|
+
// Bun's native console supports colors, but we need a simple wrapper
|
|
11
|
+
// for consistent theming across the CLI
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Check if rich output (colors) is supported
|
|
15
|
+
*/
|
|
16
|
+
function checkRichSupport(): boolean {
|
|
17
|
+
// NO_COLOR takes precedence (accessibility standard)
|
|
18
|
+
if (process.env.NO_COLOR) {
|
|
19
|
+
const forceColor = process.env.FORCE_COLOR?.trim();
|
|
20
|
+
if (forceColor !== "1" && forceColor !== "true") {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Check TTY
|
|
26
|
+
if (!process.stdout.isTTY) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Check TERM
|
|
31
|
+
const term = process.env.TERM;
|
|
32
|
+
if (term === "dumb") {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const richSupported = checkRichSupport();
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* ANSI escape code wrapper
|
|
43
|
+
*/
|
|
44
|
+
function ansi(code: string) {
|
|
45
|
+
return richSupported ? `\x1b[${code}m` : "";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Convert hex to ANSI 256 color (approximation)
|
|
50
|
+
*/
|
|
51
|
+
function hexToAnsi256(hex: string): number {
|
|
52
|
+
const r = parseInt(hex.slice(1, 3), 16);
|
|
53
|
+
const g = parseInt(hex.slice(3, 5), 16);
|
|
54
|
+
const b = parseInt(hex.slice(5, 7), 16);
|
|
55
|
+
|
|
56
|
+
// Convert to 6x6x6 color cube
|
|
57
|
+
const ri = Math.round((r / 255) * 5);
|
|
58
|
+
const gi = Math.round((g / 255) * 5);
|
|
59
|
+
const bi = Math.round((b / 255) * 5);
|
|
60
|
+
|
|
61
|
+
return 16 + 36 * ri + 6 * gi + bi;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Create a color function from hex
|
|
66
|
+
*/
|
|
67
|
+
function hex(hexColor: string): (text: string) => string {
|
|
68
|
+
if (!richSupported) return (text) => text;
|
|
69
|
+
|
|
70
|
+
const colorCode = hexToAnsi256(hexColor);
|
|
71
|
+
return (text) => `\x1b[38;5;${colorCode}m${text}\x1b[0m`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Create a bold color function
|
|
76
|
+
*/
|
|
77
|
+
function boldHex(hexColor: string): (text: string) => string {
|
|
78
|
+
if (!richSupported) return (text) => text;
|
|
79
|
+
|
|
80
|
+
const colorCode = hexToAnsi256(hexColor);
|
|
81
|
+
return (text) => `\x1b[1;38;5;${colorCode}m${text}\x1b[0m`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Mandu CLI Theme
|
|
86
|
+
*/
|
|
87
|
+
export const theme = {
|
|
88
|
+
// Brand colors
|
|
89
|
+
accent: hex(MANDU_PALETTE.accent),
|
|
90
|
+
accentBright: hex(MANDU_PALETTE.accentBright),
|
|
91
|
+
accentDim: hex(MANDU_PALETTE.accentDim),
|
|
92
|
+
|
|
93
|
+
// Semantic colors
|
|
94
|
+
info: hex(MANDU_PALETTE.info),
|
|
95
|
+
success: hex(MANDU_PALETTE.success),
|
|
96
|
+
warn: hex(MANDU_PALETTE.warn),
|
|
97
|
+
error: hex(MANDU_PALETTE.error),
|
|
98
|
+
|
|
99
|
+
// Neutral
|
|
100
|
+
muted: hex(MANDU_PALETTE.muted),
|
|
101
|
+
dim: hex(MANDU_PALETTE.dim),
|
|
102
|
+
|
|
103
|
+
// Composite styles
|
|
104
|
+
heading: boldHex(MANDU_PALETTE.accent),
|
|
105
|
+
command: hex(MANDU_PALETTE.accentBright),
|
|
106
|
+
option: hex(MANDU_PALETTE.warn),
|
|
107
|
+
path: hex(MANDU_PALETTE.info),
|
|
108
|
+
|
|
109
|
+
// Basic styles
|
|
110
|
+
bold: richSupported ? (text: string) => `\x1b[1m${text}\x1b[0m` : (text: string) => text,
|
|
111
|
+
italic: richSupported ? (text: string) => `\x1b[3m${text}\x1b[0m` : (text: string) => text,
|
|
112
|
+
underline: richSupported ? (text: string) => `\x1b[4m${text}\x1b[0m` : (text: string) => text,
|
|
113
|
+
|
|
114
|
+
// Reset
|
|
115
|
+
reset: richSupported ? "\x1b[0m" : "",
|
|
116
|
+
} as const;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Check if rich output is available
|
|
120
|
+
*/
|
|
121
|
+
export function isRich(): boolean {
|
|
122
|
+
return richSupported;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Conditionally apply color based on rich mode
|
|
127
|
+
*/
|
|
128
|
+
export function colorize(
|
|
129
|
+
rich: boolean,
|
|
130
|
+
colorFn: (text: string) => string,
|
|
131
|
+
text: string
|
|
132
|
+
): string {
|
|
133
|
+
return rich ? colorFn(text) : text;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Strip ANSI codes from string (for width calculation)
|
|
138
|
+
*/
|
|
139
|
+
export function stripAnsi(text: string): string {
|
|
140
|
+
// eslint-disable-next-line no-control-regex
|
|
141
|
+
return text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
142
|
+
}
|