@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,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DNA-014: Adaptive Output Format (JSON/Pretty/Plain)
|
|
3
|
+
*
|
|
4
|
+
* 환경에 따라 출력 형식을 자동 결정
|
|
5
|
+
* - TTY: Pretty (색상 + 포맷팅)
|
|
6
|
+
* - CI/pipe/agent: JSON (자동 처리/에이전트 친화)
|
|
7
|
+
* - --json 플래그: JSON
|
|
8
|
+
* - MANDU_OUTPUT 환경변수: 강제 지정
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { theme, isRich, stripAnsi } from "./theme.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 출력 모드
|
|
15
|
+
*/
|
|
16
|
+
export type OutputMode = "json" | "pretty" | "plain";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 출력 옵션
|
|
20
|
+
*/
|
|
21
|
+
export interface OutputOptions {
|
|
22
|
+
/** JSON 출력 강제 */
|
|
23
|
+
json?: boolean;
|
|
24
|
+
/** Plain 텍스트 강제 */
|
|
25
|
+
plain?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 출력 모드 결정
|
|
30
|
+
*
|
|
31
|
+
* 우선순위:
|
|
32
|
+
* 1. --json 플래그 → "json"
|
|
33
|
+
* 2. --plain 플래그 → "plain"
|
|
34
|
+
* 3. MANDU_OUTPUT 환경변수 → 지정된 값
|
|
35
|
+
* 4. 에이전트 환경 → "json"
|
|
36
|
+
* 5. !TTY (파이프), CI → "json"
|
|
37
|
+
* 6. 기본값 → "pretty"
|
|
38
|
+
*/
|
|
39
|
+
export function getOutputMode(opts: OutputOptions = {}): OutputMode {
|
|
40
|
+
// 플래그 우선
|
|
41
|
+
if (opts.json) return "json";
|
|
42
|
+
if (opts.plain) return "plain";
|
|
43
|
+
|
|
44
|
+
// 환경변수 체크
|
|
45
|
+
const envOutput = process.env.MANDU_OUTPUT?.toLowerCase();
|
|
46
|
+
if (envOutput === "json") return "json";
|
|
47
|
+
if (envOutput === "plain") return "plain";
|
|
48
|
+
if (envOutput === "pretty") return "pretty";
|
|
49
|
+
if (envOutput === "agent") return "json";
|
|
50
|
+
|
|
51
|
+
// 에이전트 환경이면 JSON
|
|
52
|
+
const agentSignals = [
|
|
53
|
+
"MANDU_AGENT",
|
|
54
|
+
"CODEX_AGENT",
|
|
55
|
+
"CODEX",
|
|
56
|
+
"CLAUDE_CODE",
|
|
57
|
+
"ANTHROPIC_CLAUDE_CODE",
|
|
58
|
+
];
|
|
59
|
+
for (const key of agentSignals) {
|
|
60
|
+
const value = process.env[key];
|
|
61
|
+
if (value === "1" || value === "true") {
|
|
62
|
+
return "json";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// CI 환경이면 JSON
|
|
67
|
+
if (process.env.CI) return "json";
|
|
68
|
+
|
|
69
|
+
// TTY가 아니면 JSON
|
|
70
|
+
if (!process.stdout.isTTY) return "json";
|
|
71
|
+
|
|
72
|
+
return "pretty";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 출력 모드에 따른 포맷팅 컨텍스트
|
|
77
|
+
*/
|
|
78
|
+
export interface FormatContext {
|
|
79
|
+
mode: OutputMode;
|
|
80
|
+
rich: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 포맷팅 컨텍스트 생성
|
|
85
|
+
*/
|
|
86
|
+
export function createFormatContext(opts: OutputOptions = {}): FormatContext {
|
|
87
|
+
const mode = getOutputMode(opts);
|
|
88
|
+
return {
|
|
89
|
+
mode,
|
|
90
|
+
rich: mode === "pretty" && isRich(),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 데이터를 모드에 맞게 포맷팅
|
|
96
|
+
*/
|
|
97
|
+
export function formatOutput<T>(
|
|
98
|
+
data: T,
|
|
99
|
+
ctx: FormatContext,
|
|
100
|
+
formatters: {
|
|
101
|
+
json?: (data: T) => unknown;
|
|
102
|
+
pretty?: (data: T, rich: boolean) => string;
|
|
103
|
+
plain?: (data: T) => string;
|
|
104
|
+
}
|
|
105
|
+
): string {
|
|
106
|
+
const { mode, rich } = ctx;
|
|
107
|
+
|
|
108
|
+
if (mode === "json") {
|
|
109
|
+
const jsonData = formatters.json ? formatters.json(data) : data;
|
|
110
|
+
return JSON.stringify(jsonData, null, 2);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (mode === "plain") {
|
|
114
|
+
if (formatters.plain) {
|
|
115
|
+
return formatters.plain(data);
|
|
116
|
+
}
|
|
117
|
+
// Pretty 포맷터에서 ANSI 코드 제거
|
|
118
|
+
if (formatters.pretty) {
|
|
119
|
+
return stripAnsi(formatters.pretty(data, false));
|
|
120
|
+
}
|
|
121
|
+
return String(data);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Pretty 모드
|
|
125
|
+
if (formatters.pretty) {
|
|
126
|
+
return formatters.pretty(data, rich);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return String(data);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 에러 출력 포맷팅
|
|
134
|
+
*/
|
|
135
|
+
export interface ErrorOutput {
|
|
136
|
+
type: "error";
|
|
137
|
+
message: string;
|
|
138
|
+
error?: string;
|
|
139
|
+
hint?: string;
|
|
140
|
+
code?: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 에러를 모드에 맞게 포맷팅
|
|
145
|
+
*/
|
|
146
|
+
export function formatError(
|
|
147
|
+
error: Error | string,
|
|
148
|
+
ctx: FormatContext,
|
|
149
|
+
options: {
|
|
150
|
+
hint?: string;
|
|
151
|
+
code?: string;
|
|
152
|
+
} = {}
|
|
153
|
+
): string {
|
|
154
|
+
const { mode, rich } = ctx;
|
|
155
|
+
const message = error instanceof Error ? error.message : error;
|
|
156
|
+
const errorStack = error instanceof Error ? error.stack : undefined;
|
|
157
|
+
|
|
158
|
+
if (mode === "json") {
|
|
159
|
+
const output: ErrorOutput = {
|
|
160
|
+
type: "error",
|
|
161
|
+
message,
|
|
162
|
+
code: options.code,
|
|
163
|
+
hint: options.hint,
|
|
164
|
+
};
|
|
165
|
+
if (error instanceof Error && error.stack) {
|
|
166
|
+
output.error = error.stack;
|
|
167
|
+
}
|
|
168
|
+
return JSON.stringify(output, null, 2);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const lines: string[] = [];
|
|
172
|
+
|
|
173
|
+
// 에러 메시지
|
|
174
|
+
if (options.code) {
|
|
175
|
+
lines.push(
|
|
176
|
+
rich
|
|
177
|
+
? `${theme.error("❌ Error")} [${theme.muted(options.code)}]`
|
|
178
|
+
: `Error [${options.code}]`
|
|
179
|
+
);
|
|
180
|
+
} else {
|
|
181
|
+
lines.push(rich ? theme.error("❌ Error") : "Error");
|
|
182
|
+
}
|
|
183
|
+
lines.push(rich ? ` ${message}` : ` ${message}`);
|
|
184
|
+
|
|
185
|
+
// 힌트
|
|
186
|
+
if (options.hint) {
|
|
187
|
+
lines.push("");
|
|
188
|
+
lines.push(rich ? theme.muted(`💡 ${options.hint}`) : `Hint: ${options.hint}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return lines.join("\n");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 성공 메시지 포맷팅
|
|
196
|
+
*/
|
|
197
|
+
export function formatSuccess(
|
|
198
|
+
message: string,
|
|
199
|
+
ctx: FormatContext,
|
|
200
|
+
details?: Record<string, unknown>
|
|
201
|
+
): string {
|
|
202
|
+
const { mode, rich } = ctx;
|
|
203
|
+
|
|
204
|
+
if (mode === "json") {
|
|
205
|
+
return JSON.stringify({ type: "success", message, ...details }, null, 2);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (rich) {
|
|
209
|
+
return `${theme.success("✓")} ${message}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return `[OK] ${message}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 경고 메시지 포맷팅
|
|
217
|
+
*/
|
|
218
|
+
export function formatWarning(
|
|
219
|
+
message: string,
|
|
220
|
+
ctx: FormatContext,
|
|
221
|
+
details?: Record<string, unknown>
|
|
222
|
+
): string {
|
|
223
|
+
const { mode, rich } = ctx;
|
|
224
|
+
|
|
225
|
+
if (mode === "json") {
|
|
226
|
+
return JSON.stringify({ type: "warning", message, ...details }, null, 2);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (rich) {
|
|
230
|
+
return `${theme.warn("⚠")} ${message}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return `[WARN] ${message}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 정보 메시지 포맷팅
|
|
238
|
+
*/
|
|
239
|
+
export function formatInfo(
|
|
240
|
+
message: string,
|
|
241
|
+
ctx: FormatContext,
|
|
242
|
+
details?: Record<string, unknown>
|
|
243
|
+
): string {
|
|
244
|
+
const { mode, rich } = ctx;
|
|
245
|
+
|
|
246
|
+
if (mode === "json") {
|
|
247
|
+
return JSON.stringify({ type: "info", message, ...details }, null, 2);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (rich) {
|
|
251
|
+
return `${theme.info("ℹ")} ${message}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return `[INFO] ${message}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* 리스트 출력 포맷팅
|
|
259
|
+
*/
|
|
260
|
+
export function formatList<T>(
|
|
261
|
+
items: T[],
|
|
262
|
+
ctx: FormatContext,
|
|
263
|
+
options: {
|
|
264
|
+
title?: string;
|
|
265
|
+
itemFormatter?: (item: T, rich: boolean) => string;
|
|
266
|
+
emptyMessage?: string;
|
|
267
|
+
} = {}
|
|
268
|
+
): string {
|
|
269
|
+
const { mode, rich } = ctx;
|
|
270
|
+
const { title, itemFormatter, emptyMessage = "No items" } = options;
|
|
271
|
+
|
|
272
|
+
if (mode === "json") {
|
|
273
|
+
return JSON.stringify({ title, items, count: items.length }, null, 2);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const lines: string[] = [];
|
|
277
|
+
|
|
278
|
+
if (title) {
|
|
279
|
+
lines.push(rich ? theme.heading(title) : title);
|
|
280
|
+
lines.push("");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (items.length === 0) {
|
|
284
|
+
lines.push(rich ? theme.muted(emptyMessage) : emptyMessage);
|
|
285
|
+
} else {
|
|
286
|
+
for (const item of items) {
|
|
287
|
+
const formatted = itemFormatter
|
|
288
|
+
? itemFormatter(item, rich)
|
|
289
|
+
: String(item);
|
|
290
|
+
lines.push(` ${formatted}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return lines.join("\n");
|
|
295
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DNA-009: Mandu Color Palette
|
|
3
|
+
*
|
|
4
|
+
* Inspired by OpenClaw's "Lobster Seam" palette
|
|
5
|
+
* @see https://github.com/dominikwilkowski/cfonts
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Mandu 브랜드 색상 팔레트
|
|
10
|
+
* 분홍색 기반의 따뜻한 톤
|
|
11
|
+
*/
|
|
12
|
+
export const MANDU_PALETTE = {
|
|
13
|
+
// 브랜드 컬러 (만두 분홍)
|
|
14
|
+
accent: "#E8B4B8",
|
|
15
|
+
accentBright: "#F5D0D3",
|
|
16
|
+
accentDim: "#C9A0A4",
|
|
17
|
+
|
|
18
|
+
// 시맨틱 컬러
|
|
19
|
+
info: "#87CEEB", // 스카이 블루
|
|
20
|
+
success: "#90EE90", // 라이트 그린
|
|
21
|
+
warn: "#FFD700", // 골드
|
|
22
|
+
error: "#FF6B6B", // 코랄 레드
|
|
23
|
+
|
|
24
|
+
// 뉴트럴
|
|
25
|
+
muted: "#9CA3AF", // 그레이
|
|
26
|
+
dim: "#6B7280", // 다크 그레이
|
|
27
|
+
text: "#F9FAFB", // 화이트
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
export type ManduColor = keyof typeof MANDU_PALETTE;
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DNA-012: Multi-fallback Progress
|
|
3
|
+
*
|
|
4
|
+
* 다단계 폴백 프로그레스 시스템
|
|
5
|
+
* - TTY: 스피너 (ora) → 라인 → 로그
|
|
6
|
+
* - Non-TTY: 로그만
|
|
7
|
+
* - withProgress() 패턴으로 자동 정리
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { theme } from "./theme.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 프로그레스 옵션
|
|
14
|
+
*/
|
|
15
|
+
export interface ProgressOptions {
|
|
16
|
+
/** 레이블 */
|
|
17
|
+
label: string;
|
|
18
|
+
/** 전체 단계 수 (기본: 100) */
|
|
19
|
+
total?: number;
|
|
20
|
+
/** 출력 스트림 (기본: stderr) */
|
|
21
|
+
stream?: NodeJS.WriteStream;
|
|
22
|
+
/** 폴백 모드 */
|
|
23
|
+
fallback?: "spinner" | "line" | "log" | "none";
|
|
24
|
+
/** 성공 메시지 */
|
|
25
|
+
successMessage?: string;
|
|
26
|
+
/** 실패 메시지 */
|
|
27
|
+
failMessage?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 프로그레스 리포터
|
|
32
|
+
*/
|
|
33
|
+
export interface ProgressReporter {
|
|
34
|
+
/** 레이블 변경 */
|
|
35
|
+
setLabel: (label: string) => void;
|
|
36
|
+
/** 퍼센트 설정 (0-100) */
|
|
37
|
+
setPercent: (percent: number) => void;
|
|
38
|
+
/** 진행 (delta 만큼 증가) */
|
|
39
|
+
tick: (delta?: number) => void;
|
|
40
|
+
/** 성공 완료 */
|
|
41
|
+
done: (message?: string) => void;
|
|
42
|
+
/** 실패 완료 */
|
|
43
|
+
fail: (message?: string) => void;
|
|
44
|
+
/** 현재 퍼센트 */
|
|
45
|
+
getPercent: () => number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 스피너 문자
|
|
50
|
+
*/
|
|
51
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 간단한 스피너 구현 (ora 대체)
|
|
55
|
+
*/
|
|
56
|
+
function createSpinner(stream: NodeJS.WriteStream) {
|
|
57
|
+
let frameIndex = 0;
|
|
58
|
+
let intervalId: ReturnType<typeof setInterval> | null = null;
|
|
59
|
+
let text = "";
|
|
60
|
+
let isRunning = false;
|
|
61
|
+
|
|
62
|
+
const render = () => {
|
|
63
|
+
if (!isRunning) return;
|
|
64
|
+
const frame = SPINNER_FRAMES[frameIndex % SPINNER_FRAMES.length];
|
|
65
|
+
stream.write(`\r${theme.accent(frame)} ${text}`);
|
|
66
|
+
frameIndex++;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
start: (initialText: string) => {
|
|
71
|
+
text = initialText;
|
|
72
|
+
isRunning = true;
|
|
73
|
+
render();
|
|
74
|
+
intervalId = setInterval(render, 80);
|
|
75
|
+
},
|
|
76
|
+
setText: (newText: string) => {
|
|
77
|
+
text = newText;
|
|
78
|
+
},
|
|
79
|
+
succeed: (successText: string) => {
|
|
80
|
+
isRunning = false;
|
|
81
|
+
if (intervalId) clearInterval(intervalId);
|
|
82
|
+
stream.write(`\r${theme.success("✓")} ${successText}\n`);
|
|
83
|
+
},
|
|
84
|
+
fail: (failText: string) => {
|
|
85
|
+
isRunning = false;
|
|
86
|
+
if (intervalId) clearInterval(intervalId);
|
|
87
|
+
stream.write(`\r${theme.error("✗")} ${failText}\n`);
|
|
88
|
+
},
|
|
89
|
+
stop: () => {
|
|
90
|
+
isRunning = false;
|
|
91
|
+
if (intervalId) clearInterval(intervalId);
|
|
92
|
+
stream.write("\r" + " ".repeat(text.length + 5) + "\r");
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 프로그레스 바 렌더링
|
|
99
|
+
*/
|
|
100
|
+
function renderProgressBar(percent: number, width: number = 20): string {
|
|
101
|
+
const filled = Math.round((percent / 100) * width);
|
|
102
|
+
const empty = width - filled;
|
|
103
|
+
const bar = "█".repeat(filled) + "░".repeat(empty);
|
|
104
|
+
return `[${bar}]`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* CLI 프로그레스 생성
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* ```ts
|
|
112
|
+
* const progress = createCliProgress({ label: "Building...", total: 4 });
|
|
113
|
+
*
|
|
114
|
+
* progress.setLabel("Scanning routes...");
|
|
115
|
+
* await scanRoutes();
|
|
116
|
+
* progress.tick();
|
|
117
|
+
*
|
|
118
|
+
* progress.setLabel("Bundling...");
|
|
119
|
+
* await bundle();
|
|
120
|
+
* progress.tick();
|
|
121
|
+
*
|
|
122
|
+
* progress.done("Build complete!");
|
|
123
|
+
* ```
|
|
124
|
+
*/
|
|
125
|
+
export function createCliProgress(options: ProgressOptions): ProgressReporter {
|
|
126
|
+
const {
|
|
127
|
+
label: initialLabel,
|
|
128
|
+
total = 100,
|
|
129
|
+
stream = process.stderr,
|
|
130
|
+
fallback = "spinner",
|
|
131
|
+
successMessage,
|
|
132
|
+
failMessage,
|
|
133
|
+
} = options;
|
|
134
|
+
|
|
135
|
+
const isTty = stream.isTTY;
|
|
136
|
+
|
|
137
|
+
let label = initialLabel;
|
|
138
|
+
let completed = 0;
|
|
139
|
+
|
|
140
|
+
// TTY: 스피너 사용 (stdout이 pipe여도 stderr TTY면 동작)
|
|
141
|
+
const spinner = isTty && fallback === "spinner" ? createSpinner(stream) : null;
|
|
142
|
+
|
|
143
|
+
if (spinner) {
|
|
144
|
+
spinner.start(label);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const getPercent = () => Math.round((completed / total) * 100);
|
|
148
|
+
|
|
149
|
+
const render = () => {
|
|
150
|
+
const percent = getPercent();
|
|
151
|
+
const text =
|
|
152
|
+
total > 1 ? `${label} ${renderProgressBar(percent)} ${percent}%` : label;
|
|
153
|
+
|
|
154
|
+
if (spinner) {
|
|
155
|
+
spinner.setText(text);
|
|
156
|
+
} else if (isTty && fallback === "line") {
|
|
157
|
+
stream.write(`\r${text}`);
|
|
158
|
+
}
|
|
159
|
+
// "log" 모드는 상태 변경 시마다 로그하지 않음
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
setLabel: (next: string) => {
|
|
164
|
+
label = next;
|
|
165
|
+
render();
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
setPercent: (percent: number) => {
|
|
169
|
+
completed = (Math.max(0, Math.min(100, percent)) / 100) * total;
|
|
170
|
+
render();
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
tick: (delta = 1) => {
|
|
174
|
+
completed = Math.min(total, completed + delta);
|
|
175
|
+
render();
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
getPercent,
|
|
179
|
+
|
|
180
|
+
done: (message?: string) => {
|
|
181
|
+
const finalMessage =
|
|
182
|
+
message ?? successMessage ?? `${initialLabel} completed`;
|
|
183
|
+
|
|
184
|
+
if (spinner) {
|
|
185
|
+
spinner.succeed(finalMessage);
|
|
186
|
+
} else if (isTty) {
|
|
187
|
+
stream.write(`\r${theme.success("✓")} ${finalMessage}\n`);
|
|
188
|
+
} else if (fallback !== "none") {
|
|
189
|
+
stream.write(`[OK] ${finalMessage}\n`);
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
fail: (message?: string) => {
|
|
194
|
+
const finalMessage =
|
|
195
|
+
message ?? failMessage ?? `${initialLabel} failed`;
|
|
196
|
+
|
|
197
|
+
if (spinner) {
|
|
198
|
+
spinner.fail(finalMessage);
|
|
199
|
+
} else if (isTty) {
|
|
200
|
+
stream.write(`\r${theme.error("✗")} ${finalMessage}\n`);
|
|
201
|
+
} else if (fallback !== "none") {
|
|
202
|
+
stream.write(`[FAIL] ${finalMessage}\n`);
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* 프로그레스 컨텍스트 패턴
|
|
210
|
+
*
|
|
211
|
+
* 작업 완료 후 자동으로 프로그레스 정리
|
|
212
|
+
*
|
|
213
|
+
* @example
|
|
214
|
+
* ```ts
|
|
215
|
+
* const result = await withProgress(
|
|
216
|
+
* { label: "Building...", total: 4 },
|
|
217
|
+
* async (progress) => {
|
|
218
|
+
* progress.setLabel("Step 1");
|
|
219
|
+
* await step1();
|
|
220
|
+
* progress.tick();
|
|
221
|
+
*
|
|
222
|
+
* progress.setLabel("Step 2");
|
|
223
|
+
* await step2();
|
|
224
|
+
* progress.tick();
|
|
225
|
+
*
|
|
226
|
+
* return { success: true };
|
|
227
|
+
* }
|
|
228
|
+
* );
|
|
229
|
+
* ```
|
|
230
|
+
*/
|
|
231
|
+
export async function withProgress<T>(
|
|
232
|
+
options: ProgressOptions,
|
|
233
|
+
work: (progress: ProgressReporter) => Promise<T>
|
|
234
|
+
): Promise<T> {
|
|
235
|
+
const progress = createCliProgress(options);
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
const result = await work(progress);
|
|
239
|
+
progress.done();
|
|
240
|
+
return result;
|
|
241
|
+
} catch (error) {
|
|
242
|
+
progress.fail(
|
|
243
|
+
error instanceof Error ? error.message : String(error)
|
|
244
|
+
);
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* 단순 스피너 (진행률 없음)
|
|
251
|
+
*
|
|
252
|
+
* @example
|
|
253
|
+
* ```ts
|
|
254
|
+
* const stop = startSpinner("Loading...");
|
|
255
|
+
* await loadData();
|
|
256
|
+
* stop("Loaded!");
|
|
257
|
+
* ```
|
|
258
|
+
*/
|
|
259
|
+
export function startSpinner(
|
|
260
|
+
label: string,
|
|
261
|
+
stream: NodeJS.WriteStream = process.stderr
|
|
262
|
+
): (successMessage?: string) => void {
|
|
263
|
+
const isTty = stream.isTTY;
|
|
264
|
+
|
|
265
|
+
if (!isTty) {
|
|
266
|
+
stream.write(`${label}\n`);
|
|
267
|
+
return (msg) => {
|
|
268
|
+
if (msg) stream.write(`${msg}\n`);
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const spinner = createSpinner(stream);
|
|
273
|
+
spinner.start(label);
|
|
274
|
+
|
|
275
|
+
return (successMessage?: string) => {
|
|
276
|
+
if (successMessage) {
|
|
277
|
+
spinner.succeed(successMessage);
|
|
278
|
+
} else {
|
|
279
|
+
spinner.stop();
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* 다중 단계 프로그레스
|
|
286
|
+
*
|
|
287
|
+
* @example
|
|
288
|
+
* ```ts
|
|
289
|
+
* await runSteps([
|
|
290
|
+
* { label: "Installing dependencies", fn: installDeps },
|
|
291
|
+
* { label: "Building", fn: build },
|
|
292
|
+
* { label: "Testing", fn: test },
|
|
293
|
+
* ]);
|
|
294
|
+
* ```
|
|
295
|
+
*/
|
|
296
|
+
export async function runSteps<T>(
|
|
297
|
+
steps: Array<{
|
|
298
|
+
label: string;
|
|
299
|
+
fn: () => T | Promise<T>;
|
|
300
|
+
}>,
|
|
301
|
+
options: Omit<ProgressOptions, "label" | "total"> = {}
|
|
302
|
+
): Promise<T[]> {
|
|
303
|
+
const results: T[] = [];
|
|
304
|
+
const total = steps.length;
|
|
305
|
+
let current = 0;
|
|
306
|
+
|
|
307
|
+
const progress = createCliProgress({
|
|
308
|
+
...options,
|
|
309
|
+
label: steps[0]?.label ?? "Processing...",
|
|
310
|
+
total,
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
for (const step of steps) {
|
|
315
|
+
progress.setLabel(step.label);
|
|
316
|
+
const result = await step.fn();
|
|
317
|
+
results.push(result);
|
|
318
|
+
current++;
|
|
319
|
+
progress.tick();
|
|
320
|
+
}
|
|
321
|
+
progress.done();
|
|
322
|
+
return results;
|
|
323
|
+
} catch (error) {
|
|
324
|
+
progress.fail();
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
}
|