@mks2508/coolify-mks-cli-mcp 0.6.3 → 0.8.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/dist/cli/coolify-state.d.ts +92 -4
- package/dist/cli/coolify-state.d.ts.map +1 -1
- package/dist/cli/index.js +22149 -11456
- package/dist/cli/ui/highlighter.d.ts +28 -0
- package/dist/cli/ui/highlighter.d.ts.map +1 -0
- package/dist/cli/ui/index.d.ts +9 -0
- package/dist/cli/ui/index.d.ts.map +1 -0
- package/dist/cli/ui/spinners.d.ts +100 -0
- package/dist/cli/ui/spinners.d.ts.map +1 -0
- package/dist/cli/ui/tables.d.ts +103 -0
- package/dist/cli/ui/tables.d.ts.map +1 -0
- package/dist/coolify/index.d.ts +22 -3
- package/dist/coolify/index.d.ts.map +1 -1
- package/dist/coolify/types.d.ts +99 -1
- package/dist/coolify/types.d.ts.map +1 -1
- package/dist/examples/demo-ui.d.ts +8 -0
- package/dist/examples/demo-ui.d.ts.map +1 -0
- package/dist/index.cjs +322 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +322 -12
- package/dist/index.js.map +1 -1
- package/dist/sdk.d.ts +41 -0
- package/dist/sdk.d.ts.map +1 -1
- package/dist/server/stdio.js +258 -9
- package/package.json +16 -4
- package/src/cli/actions.ts +9 -2
- package/src/cli/commands/create.ts +71 -5
- package/src/cli/commands/db.ts +37 -0
- package/src/cli/commands/delete.ts +6 -2
- package/src/cli/commands/deploy.ts +347 -49
- package/src/cli/commands/deployments.ts +6 -2
- package/src/cli/commands/diagnose.ts +3 -3
- package/src/cli/commands/env.ts +121 -22
- package/src/cli/commands/exec.ts +6 -2
- package/src/cli/commands/init.ts +937 -0
- package/src/cli/commands/logs.ts +224 -24
- package/src/cli/commands/main-menu.ts +21 -0
- package/src/cli/commands/projects.ts +312 -29
- package/src/cli/commands/restart.ts +6 -2
- package/src/cli/commands/service-logs.ts +14 -0
- package/src/cli/commands/show.ts +6 -2
- package/src/cli/commands/start.ts +6 -2
- package/src/cli/commands/status.ts +538 -0
- package/src/cli/commands/stop.ts +6 -2
- package/src/cli/commands/update.ts +27 -2
- package/src/cli/coolify-state.ts +164 -11
- package/src/cli/index.ts +91 -10
- package/src/cli/name-resolver.ts +228 -0
- package/src/cli/ui/banner.ts +276 -0
- package/src/cli/ui/highlighter.ts +176 -0
- package/src/cli/ui/index.ts +9 -0
- package/src/cli/ui/prompts.ts +155 -0
- package/src/cli/ui/screen.ts +606 -0
- package/src/cli/ui/select.ts +280 -0
- package/src/cli/ui/spinners.ts +256 -0
- package/src/cli/ui/tables.ts +407 -0
- package/src/coolify/index.ts +257 -12
- package/src/coolify/types.ts +103 -1
- package/src/examples/demo-ui.ts +78 -0
- package/src/sdk.ts +162 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom ANSI selectors with live preview support.
|
|
3
|
+
* The tree IS the selector — arrow keys navigate, right panel updates live.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import chalk from "chalk";
|
|
9
|
+
|
|
10
|
+
export interface ISelectOption {
|
|
11
|
+
label: string;
|
|
12
|
+
value: string;
|
|
13
|
+
hint?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ─── Theme ───────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
const T = {
|
|
19
|
+
accent: "#a875ff", // brighter purple for bullets
|
|
20
|
+
selectedBg: "#2d1f5e", // visible bg even on dark terminals
|
|
21
|
+
selectedFg: "#ffffff", // white text on selected
|
|
22
|
+
normal: "#cccccc", // normal unselected labels (high contrast)
|
|
23
|
+
dim: "#888888", // dimmed unselected labels
|
|
24
|
+
hint: "#999999", // hints next to labels
|
|
25
|
+
sep: "#444444", // separator lines
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// ─── Dual-panel select (tree left + preview right) ───────────────────────────
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Interactive selector rendered at a position, with a callback on each
|
|
32
|
+
* highlight change so the caller can update a right-side preview panel.
|
|
33
|
+
*
|
|
34
|
+
* @param options - Selectable items
|
|
35
|
+
* @param startRow - Terminal row (1-based) where the selector starts
|
|
36
|
+
* @param startCol - Terminal column (1-based) where the selector starts
|
|
37
|
+
* @param onHighlight - Called on each arrow key move with the highlighted value.
|
|
38
|
+
* Use this to re-render a detail panel to the right.
|
|
39
|
+
* @returns Selected value on Enter, null on Esc/Ctrl+C
|
|
40
|
+
*/
|
|
41
|
+
export function inlineSelect(
|
|
42
|
+
options: ISelectOption[],
|
|
43
|
+
startRow: number,
|
|
44
|
+
startCol: number,
|
|
45
|
+
onHighlight?: (value: string, index: number) => void,
|
|
46
|
+
): Promise<string | null> {
|
|
47
|
+
let selected = 0;
|
|
48
|
+
// Skip separators
|
|
49
|
+
while (selected < options.length && (!options[selected].value || options[selected].value === "_sep")) selected++;
|
|
50
|
+
|
|
51
|
+
function render(clearExtra = false): void {
|
|
52
|
+
for (let i = 0; i < options.length; i++) {
|
|
53
|
+
process.stdout.write(`\x1b[${startRow + i};${startCol}H\x1b[K`);
|
|
54
|
+
process.stdout.write(formatItem(options[i], i === selected));
|
|
55
|
+
}
|
|
56
|
+
// Clear leftover lines from previous menus (up to 15 extra lines)
|
|
57
|
+
if (clearExtra) {
|
|
58
|
+
for (let i = 0; i < 15; i++) {
|
|
59
|
+
process.stdout.write(`\x1b[${startRow + options.length + i};${startCol}H\x1b[K`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return new Promise((resolve) => {
|
|
65
|
+
if (!process.stdin.isTTY) { resolve(null); return; }
|
|
66
|
+
|
|
67
|
+
process.stdout.write("\x1b[?25l"); // hide cursor
|
|
68
|
+
const wasRaw = process.stdin.isRaw;
|
|
69
|
+
process.stdin.setRawMode(true);
|
|
70
|
+
process.stdin.resume();
|
|
71
|
+
process.stdin.setEncoding("utf8");
|
|
72
|
+
|
|
73
|
+
render(true); // First render clears leftover lines from previous menus
|
|
74
|
+
onHighlight?.(options[selected].value, selected);
|
|
75
|
+
|
|
76
|
+
const onData = (key: string): void => {
|
|
77
|
+
if (key === "\x1b[A" || key === "k") { // Up
|
|
78
|
+
do { selected = (selected - 1 + options.length) % options.length; }
|
|
79
|
+
while (!options[selected].value || options[selected].value === "_sep");
|
|
80
|
+
render();
|
|
81
|
+
onHighlight?.(options[selected].value, selected);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (key === "\x1b[B" || key === "j") { // Down
|
|
85
|
+
do { selected = (selected + 1) % options.length; }
|
|
86
|
+
while (!options[selected].value || options[selected].value === "_sep");
|
|
87
|
+
render();
|
|
88
|
+
onHighlight?.(options[selected].value, selected);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (key.charCodeAt(0) === 13) { cleanup(); resolve(options[selected].value); return; } // Enter
|
|
92
|
+
if ((key.charCodeAt(0) === 27 && key.length === 1) || key === "q") { cleanup(); resolve(null); return; } // Esc
|
|
93
|
+
if (key.charCodeAt(0) === 3) { cleanup(); resolve(null); return; } // Ctrl+C
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
function cleanup(): void {
|
|
97
|
+
process.stdin.removeListener("data", onData);
|
|
98
|
+
process.stdin.setRawMode(wasRaw ?? false);
|
|
99
|
+
process.stdin.pause();
|
|
100
|
+
process.stdout.write("\x1b[?25h");
|
|
101
|
+
process.stdout.write(`\x1b[${startRow + options.length + 1};1H`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
process.stdin.on("data", onData);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─── Full-width select (below current output) ───────────────────────────────
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Full-width selector below current output. For sub-menus (actions, logs, env).
|
|
112
|
+
*/
|
|
113
|
+
export async function fullSelect(
|
|
114
|
+
title: string,
|
|
115
|
+
options: ISelectOption[],
|
|
116
|
+
onHighlight?: (value: string, index: number, startRow: number) => void,
|
|
117
|
+
): Promise<string | null> {
|
|
118
|
+
let selected = 0;
|
|
119
|
+
while (selected < options.length && (!options[selected].value || options[selected].value === "_sep")) selected++;
|
|
120
|
+
|
|
121
|
+
// Print title + reserve space for options
|
|
122
|
+
if (title) console.log(`\n ${chalk.bold(title)}`);
|
|
123
|
+
|
|
124
|
+
// Use CSI 6n to get actual cursor row BEFORE printing option lines
|
|
125
|
+
const cursorRowBefore = await getCursorRowAsync();
|
|
126
|
+
const optionsStartRow = cursorRowBefore;
|
|
127
|
+
|
|
128
|
+
// Reserve vertical space
|
|
129
|
+
for (let i = 0; i < options.length; i++) process.stdout.write("\n");
|
|
130
|
+
|
|
131
|
+
function render(): void {
|
|
132
|
+
// Use absolute row positioning to avoid drift
|
|
133
|
+
for (let i = 0; i < options.length; i++) {
|
|
134
|
+
process.stdout.write(`\x1b[${optionsStartRow + i};4H\x1b[K`);
|
|
135
|
+
process.stdout.write(formatItem(options[i], i === selected));
|
|
136
|
+
}
|
|
137
|
+
// Park cursor after the list
|
|
138
|
+
process.stdout.write(`\x1b[${optionsStartRow + options.length};1H`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return new Promise((resolve) => {
|
|
142
|
+
if (!process.stdin.isTTY) { resolve(null); return; }
|
|
143
|
+
|
|
144
|
+
process.stdout.write("\x1b[?25l");
|
|
145
|
+
const wasRaw = process.stdin.isRaw;
|
|
146
|
+
process.stdin.setRawMode(true);
|
|
147
|
+
process.stdin.resume();
|
|
148
|
+
process.stdin.setEncoding("utf8");
|
|
149
|
+
|
|
150
|
+
render();
|
|
151
|
+
onHighlight?.(options[selected].value, selected, optionsStartRow);
|
|
152
|
+
|
|
153
|
+
const onData = (key: string): void => {
|
|
154
|
+
if (key === "\x1b[A" || key === "k") {
|
|
155
|
+
do { selected = (selected - 1 + options.length) % options.length; }
|
|
156
|
+
while (!options[selected].value || options[selected].value === "_sep");
|
|
157
|
+
render(); onHighlight?.(options[selected].value, selected, optionsStartRow); return;
|
|
158
|
+
}
|
|
159
|
+
if (key === "\x1b[B" || key === "j") {
|
|
160
|
+
do { selected = (selected + 1) % options.length; }
|
|
161
|
+
while (!options[selected].value || options[selected].value === "_sep");
|
|
162
|
+
render(); onHighlight?.(options[selected].value, selected, optionsStartRow); return;
|
|
163
|
+
}
|
|
164
|
+
if (key.charCodeAt(0) === 13) { cleanup(); resolve(options[selected].value); return; }
|
|
165
|
+
if ((key.charCodeAt(0) === 27 && key.length === 1) || key === "q") { cleanup(); resolve(null); return; }
|
|
166
|
+
if (key.charCodeAt(0) === 3) { cleanup(); resolve(null); return; }
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
function cleanup(): void {
|
|
170
|
+
process.stdin.removeListener("data", onData);
|
|
171
|
+
process.stdin.setRawMode(wasRaw ?? false);
|
|
172
|
+
process.stdin.pause();
|
|
173
|
+
process.stdout.write("\x1b[?25h");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
process.stdin.on("data", onData);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ─── Text input ──────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
export function textInput(message: string): Promise<string | null> {
|
|
183
|
+
return new Promise((resolve) => {
|
|
184
|
+
if (!process.stdin.isTTY) { resolve(null); return; }
|
|
185
|
+
process.stdout.write(`\n ${chalk.hex(T.accent)("?")} ${chalk.bold(message)} `);
|
|
186
|
+
process.stdin.setRawMode(false);
|
|
187
|
+
process.stdin.resume();
|
|
188
|
+
process.stdin.setEncoding("utf8");
|
|
189
|
+
const onData = (data: string): void => {
|
|
190
|
+
process.stdin.removeListener("data", onData);
|
|
191
|
+
process.stdin.pause();
|
|
192
|
+
resolve(data.trim() || null);
|
|
193
|
+
};
|
|
194
|
+
process.stdin.on("data", onData);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function confirm(message: string): Promise<boolean | null> {
|
|
199
|
+
return new Promise((resolve) => {
|
|
200
|
+
if (!process.stdin.isTTY) { resolve(null); return; }
|
|
201
|
+
process.stdout.write(`\n ${chalk.hex(T.accent)("?")} ${chalk.bold(message)} ${chalk.gray("(y/n)")} `);
|
|
202
|
+
const wasRaw = process.stdin.isRaw;
|
|
203
|
+
process.stdin.setRawMode(true);
|
|
204
|
+
process.stdin.resume();
|
|
205
|
+
process.stdin.setEncoding("utf8");
|
|
206
|
+
const onData = (key: string): void => {
|
|
207
|
+
process.stdin.removeListener("data", onData);
|
|
208
|
+
process.stdin.setRawMode(wasRaw ?? false);
|
|
209
|
+
process.stdin.pause();
|
|
210
|
+
process.stdout.write("\n");
|
|
211
|
+
if (key === "y" || key === "Y") resolve(true);
|
|
212
|
+
else if (key.charCodeAt(0) === 3 || key.charCodeAt(0) === 27) resolve(null);
|
|
213
|
+
else resolve(false);
|
|
214
|
+
};
|
|
215
|
+
process.stdin.on("data", onData);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ─── Formatting ──────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
function formatItem(opt: ISelectOption, isSelected: boolean): string {
|
|
222
|
+
if (!opt.value || opt.value === "_sep") {
|
|
223
|
+
return chalk.hex(T.sep)(" ───────────────────");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (isSelected) {
|
|
227
|
+
const bullet = chalk.hex(T.accent)("▸ ");
|
|
228
|
+
const label = chalk.bgHex(T.selectedBg).hex(T.selectedFg).bold(` ${opt.label} `);
|
|
229
|
+
// Don't wrap hint in a color — it may contain pre-colored text (●, ✗)
|
|
230
|
+
const hint = opt.hint ? ` ${opt.hint}` : "";
|
|
231
|
+
return `${bullet}${label}${hint}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const bullet = chalk.hex(T.dim)(" ");
|
|
235
|
+
const label = chalk.hex(T.normal)(opt.label);
|
|
236
|
+
// Hints on unselected items: pass through (may have colors)
|
|
237
|
+
const hint = opt.hint ? ` ${opt.hint}` : "";
|
|
238
|
+
return `${bullet}${label}${hint}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ─── Cursor position query ───────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Get the current cursor row using CSI 6n (Device Status Report).
|
|
245
|
+
* Falls back to a reasonable guess if the terminal doesn't respond.
|
|
246
|
+
*/
|
|
247
|
+
function getCursorRowAsync(): Promise<number> {
|
|
248
|
+
return new Promise((resolve) => {
|
|
249
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
250
|
+
resolve(10);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const timeout = setTimeout(() => {
|
|
255
|
+
process.stdin.removeListener("data", onData);
|
|
256
|
+
process.stdin.setRawMode(wasRaw ?? false);
|
|
257
|
+
process.stdin.pause();
|
|
258
|
+
resolve(10); // fallback
|
|
259
|
+
}, 150);
|
|
260
|
+
|
|
261
|
+
const wasRaw = process.stdin.isRaw;
|
|
262
|
+
process.stdin.setRawMode(true);
|
|
263
|
+
process.stdin.resume();
|
|
264
|
+
process.stdin.setEncoding("utf8");
|
|
265
|
+
|
|
266
|
+
const onData = (data: string): void => {
|
|
267
|
+
const match = data.match(/\x1b\[(\d+);(\d+)R/);
|
|
268
|
+
if (match) {
|
|
269
|
+
clearTimeout(timeout);
|
|
270
|
+
process.stdin.removeListener("data", onData);
|
|
271
|
+
process.stdin.setRawMode(wasRaw ?? false);
|
|
272
|
+
process.stdin.pause();
|
|
273
|
+
resolve(parseInt(match[1], 10));
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
process.stdin.on("data", onData);
|
|
278
|
+
process.stdout.write("\x1b[6n"); // Query cursor position
|
|
279
|
+
});
|
|
280
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enhanced spinner utilities for CLI operations.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
import ora, { Ora } from "ora";
|
|
9
|
+
|
|
10
|
+
export type SpinnerStatus = "loading" | "success" | "error" | "warning" | "info";
|
|
11
|
+
|
|
12
|
+
export interface ISpinnerOptions {
|
|
13
|
+
/** Initial text to display */
|
|
14
|
+
text: string;
|
|
15
|
+
/** Spinner color */
|
|
16
|
+
color?: "cyan" | "yellow" | "green" | "red" | "blue" | "magenta";
|
|
17
|
+
/** Hide the cursor */
|
|
18
|
+
hideCursor?: boolean;
|
|
19
|
+
/** Use a specific spinner style */
|
|
20
|
+
spinner?: "dots" | "line" | "arrow" | "bouncingBar" | "simpleDots";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const spinnerStyles: Record<
|
|
24
|
+
Required<ISpinnerOptions>["spinner"],
|
|
25
|
+
any
|
|
26
|
+
> = {
|
|
27
|
+
dots: "dots",
|
|
28
|
+
line: "line",
|
|
29
|
+
arrow: "arrow2",
|
|
30
|
+
bouncingBar: "bouncingBar",
|
|
31
|
+
simpleDots: "simpleDots",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Create an enhanced spinner with better defaults.
|
|
36
|
+
*/
|
|
37
|
+
export function createSpinner(options: ISpinnerOptions): Ora {
|
|
38
|
+
const { text, color = "cyan", hideCursor = true, spinner = "dots" } = options;
|
|
39
|
+
|
|
40
|
+
return ora({
|
|
41
|
+
text,
|
|
42
|
+
color,
|
|
43
|
+
hideCursor,
|
|
44
|
+
spinner: spinnerStyles[spinner],
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Multi-spinner manager for parallel operations.
|
|
50
|
+
*/
|
|
51
|
+
export class MultiSpinner {
|
|
52
|
+
private spinners: Map<string, Ora> = new Map();
|
|
53
|
+
private interval: ReturnType<typeof setInterval> | null = null;
|
|
54
|
+
|
|
55
|
+
constructor(private options: { interval?: number } = {}) {}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Add a new spinner for a named operation.
|
|
59
|
+
*/
|
|
60
|
+
add(name: string, text: string, status: SpinnerStatus = "loading"): void {
|
|
61
|
+
const spinner = ora({
|
|
62
|
+
text,
|
|
63
|
+
prefixText: this.getPrefixText(name),
|
|
64
|
+
}).start();
|
|
65
|
+
|
|
66
|
+
this.spinners.set(name, spinner);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Update a spinner's text.
|
|
71
|
+
*/
|
|
72
|
+
update(name: string, text: string): void {
|
|
73
|
+
const spinner = this.spinners.get(name);
|
|
74
|
+
if (spinner) {
|
|
75
|
+
spinner.text = text;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Mark a spinner as succeeded.
|
|
81
|
+
*/
|
|
82
|
+
succeed(name: string, text?: string): void {
|
|
83
|
+
const spinner = this.spinners.get(name);
|
|
84
|
+
if (spinner) {
|
|
85
|
+
spinner.succeed(text);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Mark a spinner as failed.
|
|
91
|
+
*/
|
|
92
|
+
fail(name: string, text?: string): void {
|
|
93
|
+
const spinner = this.spinners.get(name);
|
|
94
|
+
if (spinner) {
|
|
95
|
+
spinner.fail(text);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Mark a spinner with a warning.
|
|
101
|
+
*/
|
|
102
|
+
warn(name: string, text?: string): void {
|
|
103
|
+
const spinner = this.spinners.get(name);
|
|
104
|
+
if (spinner) {
|
|
105
|
+
spinner.warn(text);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Stop all spinners.
|
|
111
|
+
*/
|
|
112
|
+
stopAll(persist = false): void {
|
|
113
|
+
for (const spinner of this.spinners.values()) {
|
|
114
|
+
if (persist) {
|
|
115
|
+
spinner.stop();
|
|
116
|
+
} else {
|
|
117
|
+
spinner.stopAndPersist({ symbol: " ", text: "" });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
this.spinners.clear();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Get the prefix text for a spinner.
|
|
125
|
+
*/
|
|
126
|
+
private getPrefixText(name: string): string {
|
|
127
|
+
return chalk.gray(`[${name}]`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Create a progress bar for long-running operations.
|
|
133
|
+
*/
|
|
134
|
+
export class ProgressBar {
|
|
135
|
+
private current = 0;
|
|
136
|
+
private total: number;
|
|
137
|
+
private width: number;
|
|
138
|
+
private label: string;
|
|
139
|
+
private startTime: number;
|
|
140
|
+
|
|
141
|
+
constructor(total: number, options: { width?: number; label?: string } = {}) {
|
|
142
|
+
this.total = total;
|
|
143
|
+
this.width = options.width ?? 40;
|
|
144
|
+
this.label = options.label ?? "Progress";
|
|
145
|
+
this.startTime = Date.now();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Update the progress.
|
|
150
|
+
*/
|
|
151
|
+
update(current: number): void {
|
|
152
|
+
this.current = Math.min(current, this.total);
|
|
153
|
+
this.render();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Increment the progress by 1.
|
|
158
|
+
*/
|
|
159
|
+
increment(): void {
|
|
160
|
+
this.current = Math.min(this.current + 1, this.total);
|
|
161
|
+
this.render();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Render the progress bar.
|
|
166
|
+
*/
|
|
167
|
+
private render(): void {
|
|
168
|
+
const percentage = this.current / this.total;
|
|
169
|
+
const filled = Math.round(this.width * percentage);
|
|
170
|
+
const empty = this.width - filled;
|
|
171
|
+
|
|
172
|
+
const filledBar = chalk.green("█".repeat(filled));
|
|
173
|
+
const emptyBar = chalk.gray("░".repeat(empty));
|
|
174
|
+
|
|
175
|
+
const elapsed = Date.now() - this.startTime;
|
|
176
|
+
const elapsedSec = (elapsed / 1000).toFixed(1);
|
|
177
|
+
|
|
178
|
+
const eta =
|
|
179
|
+
this.current > 0
|
|
180
|
+
? ((elapsed / this.current) * (this.total - this.current) / 1000).toFixed(
|
|
181
|
+
1,
|
|
182
|
+
)
|
|
183
|
+
: "—";
|
|
184
|
+
|
|
185
|
+
process.stdout.write(
|
|
186
|
+
`\r${this.label}: [${filledBar}${emptyBar}] ${Math.round(percentage * 100)}% (${this.current}/${this.total}) ETA: ${eta}s `,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
if (this.current >= this.total) {
|
|
190
|
+
process.stdout.write("\n");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Create a stylized status indicator.
|
|
197
|
+
*/
|
|
198
|
+
export function createStatusIndicator(
|
|
199
|
+
status: SpinnerStatus,
|
|
200
|
+
text: string,
|
|
201
|
+
): string {
|
|
202
|
+
const icons = {
|
|
203
|
+
loading: chalk.cyan("●"),
|
|
204
|
+
success: chalk.green("✓"),
|
|
205
|
+
error: chalk.red("✗"),
|
|
206
|
+
warning: chalk.yellow("⚠"),
|
|
207
|
+
info: chalk.blue("ℹ"),
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const colors = {
|
|
211
|
+
loading: chalk.cyan,
|
|
212
|
+
success: chalk.green,
|
|
213
|
+
error: chalk.red,
|
|
214
|
+
warning: chalk.yellow,
|
|
215
|
+
info: chalk.blue,
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
return `${icons[status]} ${colors[status](text)}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Create a step-by-step progress display.
|
|
223
|
+
*/
|
|
224
|
+
export class StepProgress {
|
|
225
|
+
private steps: Array<{ name: string; status: SpinnerStatus }> = [];
|
|
226
|
+
|
|
227
|
+
addStep(name: string): void {
|
|
228
|
+
this.steps.push({ name, status: "loading" });
|
|
229
|
+
this.render();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
completeStep(name: string, success = true): void {
|
|
233
|
+
const step = this.steps.find((s) => s.name === name);
|
|
234
|
+
if (step) {
|
|
235
|
+
step.status = success ? "success" : "error";
|
|
236
|
+
this.render();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private render(): void {
|
|
241
|
+
const lines = ["", chalk.bold("Progress:"), ""];
|
|
242
|
+
|
|
243
|
+
for (const step of this.steps) {
|
|
244
|
+
const status = createStatusIndicator(step.status, step.name);
|
|
245
|
+
lines.push(` ${status}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Clear previous output and render
|
|
249
|
+
process.stdout.write("\x1b[2K"); // Clear line
|
|
250
|
+
for (let i = 0; i < this.steps.length + 3; i++) {
|
|
251
|
+
process.stdout.write("\x1b[1A"); // Move up
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
255
|
+
}
|
|
256
|
+
}
|