/** * Table formatting utilities for CLI output. * * @module */ import boxen from "boxen"; import chalk from "chalk"; import Table from "cli-table3"; export interface ITableColumn { /** Header text */ header: string; /** Width in characters (optional, auto-calculated if not provided) */ width?: number; /** Alignment: 'left' | 'center' | 'right' */ align?: "left" | "center" | "right"; /** Color function to apply to all values in this column */ color?: (text: string | string[], ...args: unknown[]) => string; } export interface ITableOptions { /** Table title */ title?: string; /** Column definitions */ columns: ITableColumn[]; /** Row data */ rows: Array>; /** Show borders */ borders?: boolean; /** Compact mode (less padding) */ compact?: boolean; } /** * Create and render a formatted table. */ export function createTable(options: ITableOptions): string { const { title, columns, rows, borders = true, compact = false } = options; const tableConfig = { chars: borders ? { top: "─", "top-mid": "┬", "top-left": "╭", "top-right": "╮", bottom: "─", "bottom-mid": "┴", "bottom-left": "╰", "bottom-right": "╯", left: "│", "left-mid": "├", mid: "─", "mid-mid": "┼", right: "│", "right-mid": "┤", middle: "│", } : { top: "", "top-mid": "", "top-left": "", "top-right": "", bottom: "", "bottom-mid": "", "bottom-left": "", "bottom-right": "", left: "", "left-mid": "", mid: "", "mid-mid": "", right: "", "right-mid": "", middle: " ", }, style: { head: [], border: compact ? [] : ["gray"], compact, }, }; const table = new Table({ ...tableConfig, head: columns.map((col) => chalk.bold(col.header)), colWidths: columns.map((col) => col.width).filter((w): w is number => w !== undefined), colAligns: columns.map((col) => col.align ?? "left"), }); // Add rows for (const row of rows) { const coloredRow: string[] = []; for (const col of columns) { let value = String(row[col.header] ?? ""); if (col.color) { value = col.color([value])[0]; } coloredRow.push(value); } table.push(coloredRow); } let output = table.toString(); if (title) { const titleLine = chalk.bold.cyan(`\n${title}`); const separator = "─".repeat( Math.max(title.length - 1, output.split("\n")[0]?.length ?? 0), ); output = `${titleLine}\n${chalk.gray(separator)}\n${output}`; } return output; } /** * Create a table for environment variables. */ export function createEnvTable( envVars: Array<{ key: string; value: string; is_runtime?: boolean; is_buildtime?: boolean; is_required?: boolean; }>, options?: { compact?: boolean; showType?: boolean }, ): string { const columns: ITableColumn[] = [ { header: "Key", align: "left" }, { header: "Value", align: "left" }, ]; if (options?.showType) { columns.push({ header: "Type", align: "center" }); } const rows = envVars.map((env) => { const row: Record = { Key: env.key, Value: truncateValue(env.value, 50), }; if (options?.showType) { const types = []; if (env.is_runtime) types.push("Runtime"); if (env.is_buildtime) types.push("Build"); if (env.is_required) types.push(chalk.red("*")); row.Type = types.join(" ") || chalk.gray("—"); } return row; }); return createTable({ title: chalk.bold("Environment Variables"), columns, rows, compact: options?.compact ?? true, }); } /** * Truncate a value to a maximum length with ellipsis. */ function truncateValue(value: string, maxLength: number): string { if (value.length <= maxLength) { return chalk.gray(value); } return chalk.gray(`${value.slice(0, maxLength - 3)}...`); } /** * Create a summary card with key-value pairs using boxen. */ export function createSummaryCard( title: string, data: Record< string, { value: string; label?: string; color?: (text: string) => string } >, ): string { const lines: string[] = []; for (const [key, item] of Object.entries(data)) { const label = item.label ?? key; const color = item.color ?? chalk.white; lines.push(`${chalk.gray(label)}: ${color(item.value)}`); } return boxen(lines.join("\n"), { title: chalk.bold.cyan(title), titleAlignment: "left", padding: { left: 1, right: 1, top: 0, bottom: 0 }, borderStyle: "round", borderColor: "gray", width: Math.min(60, process.stdout.columns || 80), }); } /** * Create a change summary for env sync operations using boxen. */ export function createChangeSummary(changes: { added: Array<{ key: string; value: string }>; updated: Array<{ key: string; value: string; oldValue: string }>; removed: string[]; }): string { const lines: string[] = []; if (changes.added.length > 0) { lines.push(`${chalk.green("+")} Add ${changes.added.length} new`); for (const { key, value } of changes.added.slice(0, 5)) { lines.push(` ${chalk.green(key)} = ${chalk.gray(truncateValue(value, 40))}`); } if (changes.added.length > 5) { lines.push(chalk.gray(` ... and ${changes.added.length - 5} more`)); } lines.push(""); } if (changes.updated.length > 0) { lines.push(`${chalk.yellow("~")} Update ${changes.updated.length}`); for (const { key, oldValue } of changes.updated.slice(0, 5)) { lines.push(` ${chalk.yellow(key)}: ${chalk.gray(stripe(oldValue))} -> ${chalk.green("new")}`); } if (changes.updated.length > 5) { lines.push(chalk.gray(` ... and ${changes.updated.length - 5} more`)); } lines.push(""); } if (changes.removed.length > 0) { lines.push(`${chalk.red("-")} Remove ${changes.removed.length}`); for (const key of changes.removed.slice(0, 5)) { lines.push(` ${chalk.red(key)}`); } if (changes.removed.length > 5) { lines.push(chalk.gray(` ... and ${changes.removed.length - 5} more`)); } } return boxen(lines.join("\n"), { title: chalk.bold.cyan("Changes to apply"), titleAlignment: "left", padding: { left: 1, right: 1, top: 0, bottom: 0 }, borderStyle: "round", borderColor: "yellow", width: Math.min(60, process.stdout.columns || 80), }); } function stripe(text: string): string { return text.length > 20 ? `${text.slice(0, 17)}...` : text; } /** * Truncate a name, handling repo-style names (user/repo:branch-uuid). */ function truncateName(name: string, maxLen: number): string { // Strip repo URL prefix patterns like "m-k-s2508/repo:branch-uuid" if (name.includes("/") && name.includes(":")) { const parts = name.split("/"); const last = parts[parts.length - 1]; const repoName = last.split(":")[0]; name = repoName; } if (name.length > maxLen) { return name.slice(0, maxLen - 1) + "\u2026"; } return name; } /** * Format status with colors. */ export function formatStatus(status: string): string { if (status.includes("healthy")) return chalk.green(status); if (status === "running") return chalk.yellow(status); if (status === "exited") return chalk.red(status); if (status === "deploying") return chalk.blue(status); return status; } /** * Show status dashboard from infrastructure tree. */ export function showStatusDashboard( data: { server: { name: string; ip?: string }; projects: Array<{ name: string; uuid: string; environments: Array<{ name: string; resources: Array<{ name: string; kind: string; status: string; fqdn?: string | null }>; }>; }>; counts: { apps: number; databases: number; services: number; healthy: number; running: number; stopped: number; unhealthy: number; }; }, highlightProjectUuid?: string, ): void { const termWidth = Math.min(76, (process.stdout.columns || 80) - 4); const serverLabel = data.server.ip ? `${data.server.name} (${data.server.ip})` : data.server.name; const c = data.counts; // Summary line const lines: string[] = [ `${chalk.cyan("Apps")} ${c.apps} ${chalk.cyan("DBs")} ${c.databases} ${chalk.cyan("Svcs")} ${c.services} ${chalk.green("●")} ${c.healthy} healthy ${chalk.yellow("○")} ${c.running} running ${chalk.red("✗")} ${c.stopped} stopped`, "", ]; // Project tree for (let pi = 0; pi < data.projects.length; pi++) { const project = data.projects[pi]; const isLastProject = pi === data.projects.length - 1; const projectPrefix = isLastProject ? "└─" : "├─"; const isCurrent = project.uuid === highlightProjectUuid; const projectLabel = isCurrent ? `${chalk.bold.cyan(project.name)} ${chalk.cyan("←")}` : chalk.bold(project.name); lines.push(`${chalk.gray(projectPrefix)} ${projectLabel}`); for (let ei = 0; ei < project.environments.length; ei++) { const env = project.environments[ei]; const isLastEnv = ei === project.environments.length - 1; const envBranch = isLastProject ? " " : "│ "; const envPrefix = isLastEnv ? "└─" : "├─"; lines.push(`${chalk.gray(envBranch + envPrefix)} ${chalk.gray(env.name)}`); for (let ri = 0; ri < env.resources.length; ri++) { const res = env.resources[ri]; const isLastRes = ri === env.resources.length - 1; const resBranch = envBranch + (isLastEnv ? " " : "│ "); const resPrefix = isLastRes ? "└─" : "├─"; const kindIcon = res.kind === "database" ? chalk.blue("[db]") : res.kind === "service" ? chalk.magenta("[svc]") : ""; const statusIcon = formatStatusIcon(res.status); const name = truncateName(res.name, 20); // Calculate available space for domain after prefix + icon + name const prefixLen = (resBranch + resPrefix).length + 3 + (kindIcon ? 6 : 0) + Math.min(name.length, 20); const maxDomainLen = Math.max(0, termWidth - prefixLen - 6); const domain = res.fqdn && maxDomainLen > 10 ? chalk.gray(` ${truncateName(stripProtocol(pickFirstDomain(res.fqdn)), maxDomainLen)}`) : ""; lines.push( `${chalk.gray(resBranch + resPrefix)} ${statusIcon} ${kindIcon}${kindIcon ? " " : ""}${name}${domain}`, ); } } } console.log( boxen(lines.join("\n"), { title: `${chalk.bold("Coolify")} ${chalk.gray("—")} ${chalk.cyan(serverLabel)}`, titleAlignment: "left", padding: { left: 1, right: 1, top: 1, bottom: 1 }, borderStyle: "round", borderColor: "cyan", width: termWidth, }), ); } /** * Format a status into a colored icon. */ function formatStatusIcon(status: string): string { if (status.includes("healthy") && !status.includes("unhealthy")) return chalk.green("●"); if (status.includes("unhealthy")) return chalk.red("●"); if (status.startsWith("running")) return chalk.yellow("○"); if (status.includes("exited")) return chalk.red("✗"); return chalk.gray("○"); } /** * Strip protocol from domain for compact display. */ function stripProtocol(url: string): string { return url.replace(/^https?:\/\//, ""); } /** * Pick the first domain from a comma-separated FQDN string. */ function pickFirstDomain(fqdn: string): string { const first = fqdn.split(",")[0].trim(); return first; }