@mandujs/cli 0.12.2 → 0.13.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 (51) hide show
  1. package/README.ko.md +234 -234
  2. package/README.md +354 -354
  3. package/package.json +2 -2
  4. package/src/commands/contract.ts +173 -173
  5. package/src/commands/dev.ts +8 -68
  6. package/src/commands/doctor.ts +27 -27
  7. package/src/commands/guard-arch.ts +303 -303
  8. package/src/commands/guard-check.ts +3 -3
  9. package/src/commands/monitor.ts +300 -300
  10. package/src/commands/openapi.ts +107 -107
  11. package/src/commands/registry.ts +367 -357
  12. package/src/commands/routes.ts +228 -228
  13. package/src/commands/start.ts +184 -0
  14. package/src/errors/codes.ts +35 -35
  15. package/src/errors/index.ts +2 -2
  16. package/src/errors/messages.ts +143 -143
  17. package/src/hooks/index.ts +17 -17
  18. package/src/hooks/preaction.ts +256 -256
  19. package/src/main.ts +37 -34
  20. package/src/terminal/banner.ts +166 -166
  21. package/src/terminal/help.ts +306 -306
  22. package/src/terminal/index.ts +71 -71
  23. package/src/terminal/output.ts +295 -295
  24. package/src/terminal/palette.ts +30 -30
  25. package/src/terminal/progress.ts +327 -327
  26. package/src/terminal/stream-writer.ts +214 -214
  27. package/src/terminal/table.ts +354 -354
  28. package/src/terminal/theme.ts +142 -142
  29. package/src/util/bun.ts +6 -6
  30. package/src/util/fs.ts +23 -23
  31. package/src/util/handlers.ts +96 -0
  32. package/src/util/manifest.ts +52 -52
  33. package/src/util/output.ts +22 -22
  34. package/src/util/port.ts +71 -71
  35. package/templates/default/AGENTS.md +96 -96
  36. package/templates/default/app/api/health/route.ts +13 -13
  37. package/templates/default/app/globals.css +49 -49
  38. package/templates/default/app/layout.tsx +27 -27
  39. package/templates/default/app/page.tsx +38 -38
  40. package/templates/default/package.json +1 -0
  41. package/templates/default/src/client/shared/lib/utils.ts +16 -16
  42. package/templates/default/src/client/shared/ui/button.tsx +57 -57
  43. package/templates/default/src/client/shared/ui/card.tsx +78 -78
  44. package/templates/default/src/client/shared/ui/index.ts +21 -21
  45. package/templates/default/src/client/shared/ui/input.tsx +24 -24
  46. package/templates/default/tests/example.test.ts +58 -58
  47. package/templates/default/tests/helpers.ts +52 -52
  48. package/templates/default/tests/setup.ts +9 -9
  49. package/templates/default/tsconfig.json +12 -14
  50. package/templates/default/apps/server/main.ts +0 -67
  51. package/templates/default/apps/web/entry.tsx +0 -35
@@ -1,354 +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
- }
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
+ }