@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,606 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screen renderer — ANSI-based TUI-like UX with 2-column layout.
|
|
3
|
+
* Left: project tree. Right: resource detail panel (when selected).
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import boxen from "boxen";
|
|
9
|
+
import chalk from "chalk";
|
|
10
|
+
import type {
|
|
11
|
+
ICoolifyInfrastructureTree,
|
|
12
|
+
ICoolifyApplication,
|
|
13
|
+
ICoolifyResource,
|
|
14
|
+
} from "../../coolify/types.js";
|
|
15
|
+
import { loadConfig } from "../../coolify/config.js";
|
|
16
|
+
import { getMiniLogoLines } from "./banner.js";
|
|
17
|
+
|
|
18
|
+
// ─── ANSI ────────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export function clearScreen(): void {
|
|
21
|
+
process.stdout.write("\x1b[2J\x1b[H");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function termCols(): number {
|
|
25
|
+
return process.stdout.columns || 80;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function stripAnsi(str: string): string {
|
|
29
|
+
// Strip CSI sequences (colors), OSC 8 hyperlinks, and other escapes
|
|
30
|
+
return str
|
|
31
|
+
.replace(/\x1b\[[0-9;]*m/g, "") // CSI color/style
|
|
32
|
+
.replace(/\x1b\]8;;[^\x07]*\x07/g, ""); // OSC 8 hyperlink open/close
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── Icons ───────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
function statusDot(status: string): string {
|
|
38
|
+
if (status.includes("healthy") && !status.includes("unhealthy"))
|
|
39
|
+
return chalk.green("●");
|
|
40
|
+
if (status.includes("unhealthy")) return chalk.red("●");
|
|
41
|
+
if (status.startsWith("running")) return chalk.yellow("○");
|
|
42
|
+
if (status.includes("exited")) return chalk.red("✗");
|
|
43
|
+
return chalk.gray("○");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function kindTag(kind: string): string {
|
|
47
|
+
if (kind === "database") return chalk.blue("[db]");
|
|
48
|
+
if (kind === "service") return chalk.magenta("[svc]");
|
|
49
|
+
return "";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function stripProtocol(url: string): string {
|
|
53
|
+
return url.replace(/^https?:\/\//, "").split(",")[0].trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Make a domain/URL clickable using OSC 8 hyperlink escape sequences.
|
|
58
|
+
* Supported by most modern terminals (iTerm2, Ghostty, Wezterm, etc).
|
|
59
|
+
*/
|
|
60
|
+
/**
|
|
61
|
+
* OSC 8 hyperlink. IMPORTANT: always emit close sequence on same line.
|
|
62
|
+
* Do NOT use inside box/panel content that gets padded or truncated.
|
|
63
|
+
* Safe for: tree resource lines, resource headers, standalone log lines.
|
|
64
|
+
*/
|
|
65
|
+
function hyperlink(url: string, text: string): string {
|
|
66
|
+
const fullUrl = url.startsWith("http") ? url : `https://${url}`;
|
|
67
|
+
// Open link, render text, close link — all inline, no line breaks
|
|
68
|
+
return `\x1b]8;;${fullUrl}\x07${text}\x1b]8;;\x07`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Render a domain as a clickable link with color.
|
|
73
|
+
* Only use on standalone lines — NOT inside boxes or padded columns.
|
|
74
|
+
*/
|
|
75
|
+
function clickableDomain(fqdn: string, color: string = "#88bbff"): string {
|
|
76
|
+
const firstFqdn = fqdn.split(",")[0].trim();
|
|
77
|
+
const domain = stripProtocol(firstFqdn);
|
|
78
|
+
const url = firstFqdn.startsWith("http") ? firstFqdn : `https://${domain}`;
|
|
79
|
+
return hyperlink(url, chalk.hex(color)(domain));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Render a domain as plain colored text (safe for boxes/padded columns).
|
|
84
|
+
*/
|
|
85
|
+
function plainDomain(fqdn: string, color: string = "#88bbff"): string {
|
|
86
|
+
return chalk.hex(color)(stripProtocol(fqdn));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function projectStatusSummary(statuses: string[]): string {
|
|
90
|
+
const h = statuses.filter((s) => s.includes("healthy") && !s.includes("unhealthy")).length;
|
|
91
|
+
const x = statuses.filter((s) => s.includes("exited")).length;
|
|
92
|
+
const u = statuses.filter((s) => s.includes("unhealthy")).length;
|
|
93
|
+
const p: string[] = [];
|
|
94
|
+
if (h > 0) p.push(chalk.green(`●${h}`));
|
|
95
|
+
if (x > 0) p.push(chalk.red(`✗${x}`));
|
|
96
|
+
if (u > 0) p.push(chalk.red(`!${u}`));
|
|
97
|
+
return p.join(" ");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── Context ─────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
export interface IScreenContext {
|
|
103
|
+
tree: ICoolifyInfrastructureTree;
|
|
104
|
+
cwdProjectUuid?: string;
|
|
105
|
+
focusedProjectUuid?: string;
|
|
106
|
+
activeResourceUuid?: string;
|
|
107
|
+
breadcrumb?: string[];
|
|
108
|
+
/** Full app data for the detail panel (fetched with getApplication). */
|
|
109
|
+
activeAppDetail?: ICoolifyApplication;
|
|
110
|
+
/** Log preview lines to show in a bottom panel. */
|
|
111
|
+
logPreview?: string[];
|
|
112
|
+
/**
|
|
113
|
+
* When true, the expanded project shows only its header (▾ name),
|
|
114
|
+
* NOT the resource list — @clack/prompts renders them inline below.
|
|
115
|
+
*/
|
|
116
|
+
promptInline?: boolean;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ─── Header ──────────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
let cachedMiniLogo: string[] | null = null;
|
|
122
|
+
function miniLogo(): string[] {
|
|
123
|
+
if (!cachedMiniLogo) cachedMiniLogo = getMiniLogoLines();
|
|
124
|
+
return cachedMiniLogo;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function renderHeader(tree: ICoolifyInfrastructureTree): void {
|
|
128
|
+
const c = tree.counts;
|
|
129
|
+
const config = loadConfig();
|
|
130
|
+
const coolifyUrl = config.url || process.env.COOLIFY_URL || "";
|
|
131
|
+
|
|
132
|
+
let serverDisplay: string;
|
|
133
|
+
try {
|
|
134
|
+
const url = new URL(coolifyUrl);
|
|
135
|
+
const host = url.hostname;
|
|
136
|
+
serverDisplay = /^\d+\.\d+\.\d+\.\d+$/.test(host)
|
|
137
|
+
? `${tree.server.name} (${host})`
|
|
138
|
+
: host;
|
|
139
|
+
} catch {
|
|
140
|
+
serverDisplay = tree.server.ip
|
|
141
|
+
? `${tree.server.name} (${tree.server.ip})`
|
|
142
|
+
: tree.server.name;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const logo = miniLogo();
|
|
146
|
+
const logoW = logo.reduce((m, l) => Math.max(m, stripAnsi(l).length), 0);
|
|
147
|
+
|
|
148
|
+
const textLines = [
|
|
149
|
+
"",
|
|
150
|
+
`${chalk.bold.hex("#a875ff")("Coolify CLI")} ${chalk.hex("#777777")("v0.8.0")}`,
|
|
151
|
+
"",
|
|
152
|
+
`${chalk.hex("#cccccc")(serverDisplay)}`,
|
|
153
|
+
"",
|
|
154
|
+
`${chalk.green("●")} ${chalk.hex("#cccccc")(String(c.healthy))} healthy ${chalk.yellow("○")} ${chalk.hex("#cccccc")(String(c.running))} running ${chalk.red("✗")} ${chalk.hex("#cccccc")(String(c.stopped))} stopped`,
|
|
155
|
+
`${chalk.hex("#777777")(`${c.apps} apps ${c.databases} databases ${c.services} services`)}`,
|
|
156
|
+
"",
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
const maxLines = Math.max(logo.length, textLines.length);
|
|
160
|
+
const textStart = Math.max(0, Math.floor((logo.length - textLines.length) / 2));
|
|
161
|
+
|
|
162
|
+
for (let i = 0; i < maxLines; i++) {
|
|
163
|
+
const lp = i < logo.length ? logo[i] : "";
|
|
164
|
+
const pad = " ".repeat(Math.max(0, logoW - stripAnsi(lp).length));
|
|
165
|
+
const ti = i - textStart;
|
|
166
|
+
const tp = ti >= 0 && ti < textLines.length ? " " + textLines[ti] : "";
|
|
167
|
+
logLine(` ${lp}${pad}${tp}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ─── Main render ─────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
/** Line counter — tracks how many lines we've output. */
|
|
174
|
+
let _lineCount = 0;
|
|
175
|
+
function logLine(text: string): void {
|
|
176
|
+
console.log(text);
|
|
177
|
+
_lineCount++;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Render the screen. Returns layout info for positioning the inline selector.
|
|
182
|
+
*/
|
|
183
|
+
export function renderScreen(ctx: IScreenContext): {
|
|
184
|
+
promptRow: number;
|
|
185
|
+
promptCol: number;
|
|
186
|
+
} {
|
|
187
|
+
clearScreen();
|
|
188
|
+
_lineCount = 0;
|
|
189
|
+
const w = Math.min(termCols() - 2, 78);
|
|
190
|
+
|
|
191
|
+
renderHeader(ctx.tree); // updates _lineCount via logLine
|
|
192
|
+
renderBreadcrumb(ctx.breadcrumb);
|
|
193
|
+
logLine(chalk.hex("#444444")("─".repeat(w)));
|
|
194
|
+
|
|
195
|
+
// Track tree start row for inline selector positioning
|
|
196
|
+
const treeStartRow = _lineCount + 1; // 1-based row where tree begins
|
|
197
|
+
|
|
198
|
+
if (ctx.activeResourceUuid && ctx.activeAppDetail && !ctx.promptInline) {
|
|
199
|
+
// Full 2-col layout only when NOT showing inline selector (e.g. non-interactive)
|
|
200
|
+
renderTwoColumnLayout(ctx, w);
|
|
201
|
+
} else if (ctx.activeResourceUuid && ctx.activeAppDetail && ctx.promptInline) {
|
|
202
|
+
// Tree only — menu will render in right col via inlineSelect
|
|
203
|
+
renderTreeCounted(ctx, w);
|
|
204
|
+
} else if (ctx.promptInline && !ctx.focusedProjectUuid && !ctx.cwdProjectUuid) {
|
|
205
|
+
// No tree to show — the inline selector IS the navigation
|
|
206
|
+
// Just leave space for the selector to render
|
|
207
|
+
} else {
|
|
208
|
+
renderTreeCounted(ctx, w);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Log preview — full-width bottom section
|
|
212
|
+
if (ctx.logPreview && ctx.logPreview.length > 0) {
|
|
213
|
+
logLine(chalk.hex("#666666")(` ╶─ ${chalk.hex("#888888")("recent logs")} ${"─".repeat(Math.max(0, w - 20))}`));
|
|
214
|
+
for (const line of ctx.logPreview) {
|
|
215
|
+
logLine(` ${chalk.hex("#444444")("│")} ${line}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (!ctx.promptInline) {
|
|
220
|
+
logLine(chalk.hex("#444444")("─".repeat(w)));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Inline selector starts at the first tree row, right column
|
|
224
|
+
const promptRow = treeStartRow;
|
|
225
|
+
const promptCol = 38;
|
|
226
|
+
|
|
227
|
+
return { promptRow, promptCol };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Render the tree and return the 1-based row of the expanded project header.
|
|
232
|
+
*/
|
|
233
|
+
function renderTreeCounted(ctx: IScreenContext, w: number): number {
|
|
234
|
+
const { tree, cwdProjectUuid, focusedProjectUuid, activeResourceUuid, promptInline } = ctx;
|
|
235
|
+
const expandUuid = focusedProjectUuid || cwdProjectUuid;
|
|
236
|
+
let expandedRow = 0;
|
|
237
|
+
|
|
238
|
+
for (const project of tree.projects) {
|
|
239
|
+
const shouldExpand = project.uuid === expandUuid;
|
|
240
|
+
const isCwd = project.uuid === cwdProjectUuid;
|
|
241
|
+
const totalRes = project.environments.reduce((s, e) => s + e.resources.length, 0);
|
|
242
|
+
const statuses = project.environments.flatMap((e) => e.resources.map((r) => r.status));
|
|
243
|
+
const marker = isCwd ? chalk.cyan(" ←") : "";
|
|
244
|
+
|
|
245
|
+
if (shouldExpand) {
|
|
246
|
+
const ns = isCwd ? chalk.bold.cyan(project.name) : chalk.bold(project.name);
|
|
247
|
+
expandedRow = _lineCount + 1; // 1-based
|
|
248
|
+
logLine(` ${chalk.white("▾")} ${ns}${marker}`);
|
|
249
|
+
|
|
250
|
+
if (!promptInline) {
|
|
251
|
+
for (const env of project.environments) {
|
|
252
|
+
if (project.environments.length > 1) logLine(` ${chalk.gray(env.name)}`);
|
|
253
|
+
const indent = project.environments.length > 1 ? " " : " ";
|
|
254
|
+
for (let i = 0; i < env.resources.length; i++) {
|
|
255
|
+
const res = env.resources[i];
|
|
256
|
+
const isLast = i === env.resources.length - 1;
|
|
257
|
+
const isActive = res.uuid === activeResourceUuid;
|
|
258
|
+
const conn = isLast ? "└─" : "├─";
|
|
259
|
+
const dot = statusDot(res.status);
|
|
260
|
+
const tag = kindTag(res.kind);
|
|
261
|
+
const tagStr = tag ? `${tag} ` : "";
|
|
262
|
+
let name = res.name.length > 20 ? res.name.slice(0, 19) + "…" : res.name;
|
|
263
|
+
if (isActive) name = chalk.bold.underline(name);
|
|
264
|
+
const avail = w - indent.length - 10 - (tag ? 6 : 0) - 20;
|
|
265
|
+
const domain = res.fqdn && avail > 10 ? ` ${plainDomain(res.fqdn, "#668899")}` : "";
|
|
266
|
+
const am = isActive ? chalk.cyan(" ◂") : "";
|
|
267
|
+
logLine(`${indent}${chalk.gray(conn)} ${dot} ${tagStr}${name}${domain}${am}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
const summary = projectStatusSummary(statuses);
|
|
273
|
+
logLine(` ${chalk.gray("▸")} ${chalk.hex("#aaaaaa")(project.name)} ${chalk.gray(`(${totalRes})`)} ${summary}${marker}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return expandedRow;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function renderBreadcrumb(bc?: string[]): void {
|
|
280
|
+
if (!bc || bc.length === 0) return;
|
|
281
|
+
logLine(
|
|
282
|
+
chalk.gray(" ") + bc.map((b) => chalk.white(b)).join(chalk.gray(" › ")),
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ─── Two-column layout ───────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Renders tree on left (~35 chars) and detail panel on right.
|
|
290
|
+
*/
|
|
291
|
+
function renderTwoColumnLayout(ctx: IScreenContext, totalWidth: number): void {
|
|
292
|
+
const treeLines = buildTreeLines(ctx);
|
|
293
|
+
const detailLines = buildDetailPanel(ctx.activeAppDetail!, totalWidth);
|
|
294
|
+
|
|
295
|
+
// Build right column: detail + log preview (if available)
|
|
296
|
+
const rightW = totalWidth - 40;
|
|
297
|
+
const rightLines = [...detailLines];
|
|
298
|
+
if (ctx.logPreview && ctx.logPreview.length > 0) {
|
|
299
|
+
rightLines.push("");
|
|
300
|
+
rightLines.push(chalk.hex("#666666")("╶─ recent logs ─────────────"));
|
|
301
|
+
for (const raw of ctx.logPreview) {
|
|
302
|
+
// Preserve colors: don't slice raw string (breaks ANSI).
|
|
303
|
+
// Instead, just let long lines overflow — the terminal clips them.
|
|
304
|
+
rightLines.push(raw);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const leftW = 38;
|
|
309
|
+
const maxLines = Math.max(treeLines.length, rightLines.length);
|
|
310
|
+
|
|
311
|
+
for (let i = 0; i < maxLines; i++) {
|
|
312
|
+
const left = i < treeLines.length ? treeLines[i] : "";
|
|
313
|
+
const right = i < rightLines.length ? rightLines[i] : "";
|
|
314
|
+
const leftVisible = stripAnsi(left).length;
|
|
315
|
+
const pad = " ".repeat(Math.max(1, leftW - leftVisible));
|
|
316
|
+
logLine(`${left}${pad}${chalk.hex("#444444")("│")} ${right}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Build tree as string lines (for 2-col layout).
|
|
322
|
+
*/
|
|
323
|
+
function buildTreeLines(ctx: IScreenContext): string[] {
|
|
324
|
+
const lines: string[] = [];
|
|
325
|
+
const { tree, cwdProjectUuid, focusedProjectUuid, activeResourceUuid } = ctx;
|
|
326
|
+
const expandUuid = focusedProjectUuid || cwdProjectUuid;
|
|
327
|
+
|
|
328
|
+
for (const project of tree.projects) {
|
|
329
|
+
const shouldExpand = project.uuid === expandUuid;
|
|
330
|
+
const isCwd = project.uuid === cwdProjectUuid;
|
|
331
|
+
const totalRes = project.environments.reduce((s, e) => s + e.resources.length, 0);
|
|
332
|
+
const statuses = project.environments.flatMap((e) => e.resources.map((r) => r.status));
|
|
333
|
+
const marker = isCwd ? chalk.cyan(" ←") : "";
|
|
334
|
+
|
|
335
|
+
if (shouldExpand) {
|
|
336
|
+
const ns = isCwd ? chalk.bold.cyan(project.name) : chalk.bold(project.name);
|
|
337
|
+
lines.push(` ${chalk.white("▾")} ${ns}${marker}`);
|
|
338
|
+
for (const env of project.environments) {
|
|
339
|
+
const indent = " ";
|
|
340
|
+
for (let i = 0; i < env.resources.length; i++) {
|
|
341
|
+
const res = env.resources[i];
|
|
342
|
+
const isLast = i === env.resources.length - 1;
|
|
343
|
+
const isActive = res.uuid === activeResourceUuid;
|
|
344
|
+
const conn = isLast ? "└─" : "├─";
|
|
345
|
+
const dot = statusDot(res.status);
|
|
346
|
+
const tag = kindTag(res.kind);
|
|
347
|
+
const tagStr = tag ? `${tag} ` : "";
|
|
348
|
+
let name = res.name.length > 16 ? res.name.slice(0, 15) + "…" : res.name;
|
|
349
|
+
if (isActive) name = chalk.bold.underline(name);
|
|
350
|
+
const am = isActive ? chalk.cyan(" ◂") : "";
|
|
351
|
+
lines.push(`${indent}${chalk.gray(conn)} ${dot} ${tagStr}${name}${am}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
} else {
|
|
355
|
+
const summary = projectStatusSummary(statuses);
|
|
356
|
+
lines.push(` ${chalk.gray("▸")} ${chalk.gray(project.name)} ${chalk.gray(`(${totalRes})`)} ${summary}${marker}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return lines;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Build detail panel lines for the right column.
|
|
364
|
+
*/
|
|
365
|
+
function buildDetailPanel(
|
|
366
|
+
app: ICoolifyApplication,
|
|
367
|
+
totalWidth: number,
|
|
368
|
+
): string[] {
|
|
369
|
+
const lines: string[] = [];
|
|
370
|
+
const rightW = totalWidth - 40;
|
|
371
|
+
|
|
372
|
+
const dot = statusDot(app.status);
|
|
373
|
+
lines.push(`${dot} ${chalk.bold.hex("#cccccc")(app.name)}`);
|
|
374
|
+
lines.push(chalk.hex("#888888")(app.status));
|
|
375
|
+
lines.push("");
|
|
376
|
+
|
|
377
|
+
if (app.fqdn) {
|
|
378
|
+
lines.push(`${chalk.hex("#888888")("Domain:")} ${plainDomain(app.fqdn)}`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const fields: Array<[string, string | undefined | null]> = [
|
|
382
|
+
["Repo", app.git_repository],
|
|
383
|
+
["Branch", app.git_branch],
|
|
384
|
+
["Build", app.build_pack],
|
|
385
|
+
["Ports", app.ports_exposes],
|
|
386
|
+
["Dockerfile", app.dockerfile_location],
|
|
387
|
+
];
|
|
388
|
+
|
|
389
|
+
for (const [label, value] of fields) {
|
|
390
|
+
if (value) {
|
|
391
|
+
const truncated = value.length > rightW - 12 ? value.slice(0, rightW - 15) + "…" : value;
|
|
392
|
+
lines.push(`${chalk.hex("#888888")(label + ":")} ${chalk.hex("#cccccc")(truncated)}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (app.destination?.server) {
|
|
397
|
+
lines.push("");
|
|
398
|
+
lines.push(
|
|
399
|
+
`${chalk.hex("#888888")("Server:")} ${chalk.hex("#cccccc")(app.destination.server.name)} ${chalk.hex("#668899")("(" + app.destination.server.ip + ")")}`,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (app.install_command || app.build_command || app.start_command) {
|
|
404
|
+
lines.push("");
|
|
405
|
+
if (app.install_command) lines.push(`${chalk.hex("#888888")("Install:")} ${chalk.hex("#aaaaaa")(app.install_command.slice(0, rightW - 10))}`);
|
|
406
|
+
if (app.build_command) lines.push(`${chalk.hex("#888888")("Build:")} ${chalk.hex("#aaaaaa")(app.build_command.slice(0, rightW - 10))}`);
|
|
407
|
+
if (app.start_command) lines.push(`${chalk.hex("#888888")("Start:")} ${chalk.hex("#aaaaaa")(app.start_command.slice(0, rightW - 10))}`);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return lines;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ─── Single-column tree (when no detail panel) ──────────────────────────────
|
|
414
|
+
|
|
415
|
+
// ─── Live preview panel (rendered at a position, updated on highlight) ───────
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Render a compact detail panel at a specific row/col.
|
|
419
|
+
* Called by the onHighlight callback when navigating the tree selector.
|
|
420
|
+
* Clears previous content and writes new detail lines.
|
|
421
|
+
*/
|
|
422
|
+
export function renderPreviewAt(
|
|
423
|
+
row: number,
|
|
424
|
+
col: number,
|
|
425
|
+
lines: string[],
|
|
426
|
+
maxLines: number = 18,
|
|
427
|
+
): void {
|
|
428
|
+
const w = Math.max(0, termCols() - col - 2);
|
|
429
|
+
// Clear at least as many lines as the content OR maxLines (whichever is bigger)
|
|
430
|
+
const clearCount = Math.max(maxLines, lines.length + 1);
|
|
431
|
+
for (let i = 0; i < clearCount; i++) {
|
|
432
|
+
process.stdout.write(`\x1b[${row + i};${col}H\x1b[K`); // move + clear to EOL
|
|
433
|
+
if (i < lines.length) {
|
|
434
|
+
const line = lines[i];
|
|
435
|
+
const visible = stripAnsi(line);
|
|
436
|
+
if (visible.length > w) {
|
|
437
|
+
process.stdout.write(line.slice(0, w + (line.length - visible.length)));
|
|
438
|
+
} else {
|
|
439
|
+
process.stdout.write(line);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Build preview lines for a project — stats, health, environments.
|
|
447
|
+
*/
|
|
448
|
+
/** Fixed box dimensions for the preview panel. */
|
|
449
|
+
const PREVIEW_BOX_W = 36;
|
|
450
|
+
const PREVIEW_BOX_H = 13;
|
|
451
|
+
|
|
452
|
+
export function buildProjectPreview(
|
|
453
|
+
project: { name: string; uuid: string; description?: string | null; environments: Array<{ name: string; resources: ICoolifyResource[] }> },
|
|
454
|
+
maxWidth: number = 40,
|
|
455
|
+
): string[] {
|
|
456
|
+
const allRes = project.environments.flatMap((e) => e.resources);
|
|
457
|
+
const apps = allRes.filter((r) => r.kind === "app");
|
|
458
|
+
const dbs = allRes.filter((r) => r.kind === "database");
|
|
459
|
+
const svcs = allRes.filter((r) => r.kind === "service");
|
|
460
|
+
const healthyN = allRes.filter((r) => r.status.includes("healthy") && !r.status.includes("unhealthy")).length;
|
|
461
|
+
const stoppedN = allRes.filter((r) => r.status.includes("exited")).length;
|
|
462
|
+
const unhealthyN = allRes.filter((r) => r.status.includes("unhealthy")).length;
|
|
463
|
+
const cw = Math.min(PREVIEW_BOX_W, maxWidth - 4);
|
|
464
|
+
|
|
465
|
+
const inner: string[] = [];
|
|
466
|
+
|
|
467
|
+
// Centered title
|
|
468
|
+
const titleText = project.name;
|
|
469
|
+
const titlePad = Math.max(0, Math.floor((cw - titleText.length) / 2));
|
|
470
|
+
inner.push(" ".repeat(titlePad) + chalk.bold.hex("#a875ff")(titleText));
|
|
471
|
+
inner.push(chalk.hex("#444444")("─".repeat(cw)));
|
|
472
|
+
|
|
473
|
+
// Description if exists
|
|
474
|
+
if (project.description && project.description.trim()) {
|
|
475
|
+
const desc = project.description.trim();
|
|
476
|
+
inner.push(chalk.hex("#999999")(desc.length > cw ? desc.slice(0, cw - 1) + "…" : desc));
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Stats line — compact
|
|
480
|
+
const statParts = [
|
|
481
|
+
apps.length > 0 ? `${chalk.hex("#cccccc").bold(String(apps.length))} ${chalk.hex("#777777")("apps")}` : "",
|
|
482
|
+
dbs.length > 0 ? `${chalk.hex("#cccccc").bold(String(dbs.length))} ${chalk.hex("#777777")("dbs")}` : "",
|
|
483
|
+
svcs.length > 0 ? `${chalk.hex("#cccccc").bold(String(svcs.length))} ${chalk.hex("#777777")("svcs")}` : "",
|
|
484
|
+
].filter(Boolean).join(chalk.hex("#555555")(" · "));
|
|
485
|
+
inner.push(statParts);
|
|
486
|
+
|
|
487
|
+
// Health — single line
|
|
488
|
+
const hp: string[] = [];
|
|
489
|
+
if (healthyN > 0) hp.push(`${chalk.green("●")} ${healthyN}`);
|
|
490
|
+
if (stoppedN > 0) hp.push(`${chalk.red("✗")} ${stoppedN}`);
|
|
491
|
+
if (unhealthyN > 0) hp.push(`${chalk.red("!")} ${unhealthyN}`);
|
|
492
|
+
inner.push(hp.join(" "));
|
|
493
|
+
|
|
494
|
+
inner.push("");
|
|
495
|
+
|
|
496
|
+
// Environment
|
|
497
|
+
inner.push(`${chalk.hex("#777777")("env")} ${chalk.hex("#cccccc")(project.environments.map((e) => e.name).join(", "))}`);
|
|
498
|
+
|
|
499
|
+
// Domains — plain text inside box (OSC 8 breaks box padding)
|
|
500
|
+
const fqdns = allRes
|
|
501
|
+
.map((r) => r.fqdn)
|
|
502
|
+
.filter((f): f is string => !!f && f.trim().length > 0);
|
|
503
|
+
if (fqdns.length > 0) {
|
|
504
|
+
inner.push("");
|
|
505
|
+
for (const fqdn of fqdns.slice(0, 3)) {
|
|
506
|
+
const d = stripProtocol(fqdn);
|
|
507
|
+
const short = d.length > cw - 4 ? d.slice(0, cw - 5) + "…" : d;
|
|
508
|
+
inner.push(`${chalk.hex("#555555")("→")} ${chalk.hex("#88bbff")(short)}`);
|
|
509
|
+
}
|
|
510
|
+
if (fqdns.length > 3) inner.push(chalk.hex("#555555")(` +${fqdns.length - 3} more`));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return wrapInBox(inner, cw, PREVIEW_BOX_H);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Wrap content in a fixed-size box. Content is padded/truncated to fit.
|
|
518
|
+
*/
|
|
519
|
+
function wrapInBox(content: string[], width: number, height: number): string[] {
|
|
520
|
+
const border = chalk.hex("#444444");
|
|
521
|
+
const lines: string[] = [];
|
|
522
|
+
|
|
523
|
+
lines.push(border(`╭${"─".repeat(width + 2)}╮`));
|
|
524
|
+
|
|
525
|
+
for (let i = 0; i < height; i++) {
|
|
526
|
+
const raw = i < content.length ? content[i] : "";
|
|
527
|
+
const visLen = stripAnsi(raw).length;
|
|
528
|
+
|
|
529
|
+
let display = raw;
|
|
530
|
+
if (visLen > width) {
|
|
531
|
+
// Truncate — keep ANSI but cut visible chars
|
|
532
|
+
display = raw.slice(0, width + (raw.length - visLen) - 1) + "…";
|
|
533
|
+
const newVisLen = Math.min(visLen, width);
|
|
534
|
+
const pad = " ".repeat(Math.max(0, width - newVisLen));
|
|
535
|
+
lines.push(`${border("│")} ${display}${pad} ${border("│")}`);
|
|
536
|
+
} else {
|
|
537
|
+
const pad = " ".repeat(width - visLen);
|
|
538
|
+
lines.push(`${border("│")} ${display}${pad} ${border("│")}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
lines.push(border(`╰${"─".repeat(width + 2)}╯`));
|
|
543
|
+
|
|
544
|
+
return lines;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Build preview lines for a resource (app detail).
|
|
549
|
+
*/
|
|
550
|
+
export function buildResourcePreview(app: ICoolifyApplication): string[] {
|
|
551
|
+
const lines: string[] = [
|
|
552
|
+
`${statusDot(app.status)} ${chalk.bold.hex("#cccccc")(app.name)}`,
|
|
553
|
+
chalk.hex("#888888")(app.status),
|
|
554
|
+
"",
|
|
555
|
+
];
|
|
556
|
+
const fields: [string, string | undefined | null][] = [
|
|
557
|
+
["Domain", app.fqdn ? stripProtocol(app.fqdn) : null],
|
|
558
|
+
["Repo", app.git_repository],
|
|
559
|
+
["Branch", app.git_branch],
|
|
560
|
+
["Build", app.build_pack],
|
|
561
|
+
["Ports", app.ports_exposes],
|
|
562
|
+
["Dockerfile", app.dockerfile_location],
|
|
563
|
+
];
|
|
564
|
+
for (const [label, value] of fields) {
|
|
565
|
+
if (value) lines.push(`${chalk.hex("#888888")(label + ":")} ${chalk.hex("#cccccc")(value)}`);
|
|
566
|
+
}
|
|
567
|
+
if (app.destination?.server) {
|
|
568
|
+
lines.push("");
|
|
569
|
+
lines.push(`${chalk.hex("#888888")("Server:")} ${chalk.hex("#cccccc")(app.destination.server.name)}`);
|
|
570
|
+
}
|
|
571
|
+
return lines;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ─── Resource header (no detail panel fallback) ──────────────────────────────
|
|
575
|
+
|
|
576
|
+
export function renderResourceHeader(
|
|
577
|
+
name: string,
|
|
578
|
+
kind: string,
|
|
579
|
+
status: string,
|
|
580
|
+
fqdn?: string | null,
|
|
581
|
+
): void {
|
|
582
|
+
const dot = statusDot(status);
|
|
583
|
+
const tag = kindTag(kind);
|
|
584
|
+
const tagStr = tag ? `${tag} ` : "";
|
|
585
|
+
const domain = fqdn ? ` ${clickableDomain(fqdn)}` : "";
|
|
586
|
+
console.log(` ${dot} ${tagStr}${chalk.bold.hex("#cccccc")(name)}${domain}`);
|
|
587
|
+
console.log(chalk.hex("#888888")(` ${status}\n`));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// ─── Wait for key ────────────────────────────────────────────────────────────
|
|
591
|
+
|
|
592
|
+
export function waitForKey(message?: string): Promise<void> {
|
|
593
|
+
return new Promise((resolve) => {
|
|
594
|
+
const msg = message || chalk.gray("\n Press Enter to continue...");
|
|
595
|
+
process.stdout.write(msg);
|
|
596
|
+
if (!process.stdin.isTTY) { resolve(); return; }
|
|
597
|
+
const wasRaw = process.stdin.isRaw;
|
|
598
|
+
process.stdin.setRawMode?.(true);
|
|
599
|
+
process.stdin.resume();
|
|
600
|
+
process.stdin.once("data", () => {
|
|
601
|
+
process.stdin.setRawMode?.(wasRaw ?? false);
|
|
602
|
+
process.stdin.pause();
|
|
603
|
+
resolve();
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
}
|