/** * Formatting utilities for CLI output. * * Provides table formatting and color helpers. * * @module */ import Table from "cli-table3"; import chalk from "chalk"; /** * Gets the terminal width. * * @returns Terminal column count */ function getTerminalWidth(): number { return process.stdout.columns || 80; } /** * Creates a formatted table with headers. * * @param headers - Column headers * @param colWidths - Optional column widths * @returns Configured Table instance */ export function createTable(headers: string[], colWidths?: number[]) { const columns = getTerminalWidth(); return new Table({ head: headers.map((h) => chalk.cyan(h)), colWidths: colWidths || calculateColumnWidths(columns, headers.length), }); } /** * Calculates column widths based on terminal width. * * @param totalWidth - Total terminal width * @param numColumns - Number of columns * @returns Array of column widths */ function calculateColumnWidths( totalWidth: number, numColumns: number, ): number[] { const padding = 4; const availableWidth = totalWidth - padding * numColumns; const colWidth = Math.floor(availableWidth / numColumns); return Array(numColumns).fill(colWidth); } /** * Formats application status with colored indicator. * * @param status - Application status string * @returns Formatted status string */ export function formatStatus(status: string): string { const statusMap: Record = { running: chalk.green("● Running"), "running:unknown": chalk.green("● Running"), stopped: chalk.red("○ Stopped"), restarting: chalk.yellow("◐ Restarting"), error: chalk.red("✗ Error"), deploying: chalk.yellow("◐ Deploying"), }; return statusMap[status] || status; } /** * Formats bytes to human-readable format. * * @param bytes - Number of bytes * @returns Formatted string with unit */ export function formatBytes(bytes: number): string { const units = ["B", "KB", "MB", "GB", "TB"]; let i = 0; while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; } return `${bytes.toFixed(1)} ${units[i]}`; } // ─── Validation ───────────────────────────────────────────────────────────── /** * Validates a port number string (single or comma-separated). * Valid: "3000", "3000,3001", "80,443,8080" * Invalid: "abc", "99999", "0", "-1", "3000,", ",3000" * * @param ports - Port string to validate * @returns Object with valid flag, parsed ports, and error message */ export function validatePorts(ports: string): { valid: boolean; ports: number[]; error?: string; } { if (!ports || !ports.trim()) { return { valid: false, ports: [], error: "Port string is empty" }; } const parts = ports.split(",").map((p) => p.trim()); const parsed: number[] = []; for (const part of parts) { if (!/^\d+$/.test(part)) { return { valid: false, ports: [], error: `Invalid port "${part}" — must be a number`, }; } const num = parseInt(part, 10); if (num < 1 || num > 65535) { return { valid: false, ports: [], error: `Port ${num} out of range (1-65535)`, }; } parsed.push(num); } const dupes = parsed.filter((p, i) => parsed.indexOf(p) !== i); if (dupes.length > 0) { return { valid: false, ports: parsed, error: `Duplicate port: ${dupes[0]}`, }; } return { valid: true, ports: parsed }; } /** * Parses EXPOSE directives from a Dockerfile. * * @param dockerfilePath - Path to the Dockerfile * @returns Array of exposed port numbers, empty if none found or file unreadable */ export function parseDockerfileExpose(dockerfilePath: string): number[] { try { const content = require("node:fs").readFileSync(dockerfilePath, "utf-8"); const ports: number[] = []; for (const line of content.split("\n")) { const match = line.match(/^\s*EXPOSE\s+(.+)/i); if (match) { for (const token of match[1].split(/\s+/)) { // Handle "3000/tcp", "3000/udp", or just "3000" const portStr = token.split("/")[0].trim(); const num = parseInt(portStr, 10); if (!isNaN(num) && num >= 1 && num <= 65535) { ports.push(num); } } } } return [...new Set(ports)]; } catch { return []; } } /** * Validates a .coolify.json state object (single-app format). * Checks required fields, types, and port validity. * * @param state - The parsed JSON object * @returns Object with valid flag, warnings, and errors */ export function validateCoolifyState(state: Record): { valid: boolean; errors: string[]; warnings: string[]; } { const errors: string[] = []; const warnings: string[] = []; // Detect format const isMultiApp = Array.isArray(state.apps); const isSingleApp = typeof state.appUuid === "string"; if (!isMultiApp && !isSingleApp) { errors.push( 'Invalid format: must have "appUuid" (single-app) or "apps" array (multi-app)', ); return { valid: false, errors, warnings }; } // Common required fields const commonRequired = ["serverUuid", "projectUuid", "environmentUuid"]; for (const field of commonRequired) { if (!state[field] || typeof state[field] !== "string") { errors.push(`Missing or invalid required field: ${field}`); } } if (isSingleApp) { // Validate single-app specific fields if ( state.portsExposes && typeof state.portsExposes === "string" ) { const portResult = validatePorts(state.portsExposes as string); if (!portResult.valid) { warnings.push(`portsExposes: ${portResult.error}`); } } if (state.buildPack && typeof state.buildPack === "string") { const validBuildPacks = [ "dockerfile", "nixpacks", "static", "dockercompose", ]; if (!validBuildPacks.includes(state.buildPack as string)) { warnings.push( `Unknown buildPack "${state.buildPack}" — expected: ${validBuildPacks.join(", ")}`, ); } } } if (isMultiApp) { const apps = state.apps as Array>; if (apps.length === 0) { errors.push("Multi-app format requires at least one app"); } for (let i = 0; i < apps.length; i++) { const app = apps[i]; if (!app.uuid || typeof app.uuid !== "string") { errors.push(`apps[${i}]: missing or invalid "uuid"`); } if (!app.name || typeof app.name !== "string") { errors.push(`apps[${i}]: missing or invalid "name"`); } if (!app.service || typeof app.service !== "string") { errors.push(`apps[${i}]: missing or invalid "service"`); } if (app.port !== undefined) { const port = app.port as number; if (typeof port !== "number" || port < 1 || port > 65535) { warnings.push(`apps[${i}] (${app.name}): invalid port ${port}`); } } } // Check for duplicate UUIDs const uuids = apps.map((a) => a.uuid).filter(Boolean); const dupeUuids = uuids.filter((u, i) => uuids.indexOf(u) !== i); if (dupeUuids.length > 0) { errors.push(`Duplicate app UUIDs: ${dupeUuids.join(", ")}`); } } return { valid: errors.length === 0, errors, warnings }; } /** * Formats timestamp to relative time. * * @param timestamp - ISO timestamp string * @returns Relative time string */ export function formatRelativeTime(timestamp: string): string { const date = new Date(timestamp); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMs / 3600000); const diffDays = Math.floor(diffMs / 86400000); if (diffMins < 1) return "just now"; if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; return `${diffDays}d ago`; }