/** * Deploy command for CLI — with real-time progress polling and multi-app support. * Detects TTY and falls back to static output when not interactive. * * @module */ import * as p from "@clack/prompts"; import { isErr } from "@mks2508/no-throw"; import boxen from "boxen"; import chalk from "chalk"; import { getCoolifyService } from "../../coolify/index.js"; import { resolveUuid, loadMultiAppState, type ICoolifyMultiAppState, } from "../coolify-state.js"; import { resolveAppNameOrUuid } from "../name-resolver.js"; const POLL_INTERVAL = 3000; const isTTY = process.stdout.isTTY === true; /** * Deploy command handler. * Supports --all for multi-app, --service for specific service, * and polls deployment status for real-time progress feedback. * * @param uuid - Application UUID or name (optional) * @param options - Deploy options */ export async function deployCommand( uuid: string | undefined, options: { force?: boolean; tag?: string; all?: boolean; service?: string }, ) { const coolify = getCoolifyService(); const initResult = await coolify.init(); if (isErr(initResult)) { console.error(chalk.red(`Error: ${initResult.error.message}`)); return; } // Multi-app deploy: --all or --service const multiState = loadMultiAppState(); if (options.all && multiState?.apps?.length) { await deployMultiApp(coolify, multiState, options); return; } if (options.service && multiState?.apps?.length) { const app = multiState.apps.find( (a) => a.service === options.service || a.name === options.service, ); if (!app) { console.error( chalk.red(`Service "${options.service}" not found in .coolify.json`), ); console.log( chalk.gray( `Available: ${multiState.apps.map((a) => a.service || a.name).join(", ")}`, ), ); return; } await deploySingleApp(coolify, app.uuid, app.name, options); return; } // Interactive selection if no UUID let displayName = uuid || ""; if (!uuid) { uuid = await resolveOrPromptApp(multiState); if (!uuid) return; const app = multiState?.apps?.find((a) => a.uuid === uuid); displayName = app?.name || uuid.slice(0, 8); } else { displayName = uuid; let resolvedUuid = resolveUuid(uuid); if (!resolvedUuid) { resolvedUuid = await resolveAppNameOrUuid(uuid); } if (!resolvedUuid) { console.error(chalk.red("Error: Could not resolve app UUID/name")); return; } uuid = resolvedUuid; } if (uuid === "_all" && multiState) { await deployMultiApp(coolify, multiState, options); return; } await deploySingleApp(coolify, uuid, displayName, options); } /** * Deploy a single app with real-time progress polling. */ async function deploySingleApp( coolify: ReturnType, uuid: string, displayName: string, options: { force?: boolean; tag?: string }, ): Promise<{ success: boolean; deploymentUuid?: string }> { log(`Triggering deployment for ${displayName}...`); const result = await coolify.deploy({ uuid, force: options.force, tag: options.tag, }); if (isErr(result)) { log(chalk.red(`Deploy failed: ${result.error.message}`)); return { success: false }; } const deploymentUuid = result.value.deploymentUuid; log(chalk.green(`✓ Deployment #${deploymentUuid.slice(0, 8)} triggered for ${displayName}`)); await pollDeploymentProgress(coolify, deploymentUuid, displayName); return { success: true, deploymentUuid }; } /** * Poll deployment status until finished or failed. * Uses spinner in TTY mode, static lines in non-TTY mode. */ async function pollDeploymentProgress( coolify: ReturnType, deploymentUuid: string, displayName: string, ): Promise { let spinner: ReturnType | null = null; if (isTTY) { spinner = p.spinner(); spinner.start(`${chalk.cyan(displayName)} — Building...`); } let lastLogLength = 0; let lastStatus = ""; while (true) { const result = await coolify.getDeploymentLogs(deploymentUuid); if (isErr(result)) { if (spinner) spinner.stop(chalk.yellow("Could not fetch deployment status")); else log(chalk.yellow("Could not fetch deployment status")); break; } const { status, logs } = result.value; const logLines = logs ? logs.split("\n") : []; const newLines = logLines.slice(lastLogLength); lastLogLength = logLines.length; const progressHint = extractProgressHint(newLines); if (status !== lastStatus || progressHint) { const statusIcon = getStatusIcon(status); const progressText = progressHint ? ` — ${progressHint}` : ""; const msg = `${statusIcon} ${displayName} ${status}${progressText}`; if (spinner) { spinner.message(msg); } else if (status !== lastStatus) { // Non-TTY: only log on status change to avoid spam log(msg); } lastStatus = status; } // Terminal states if (status === "finished") { const msg = `${chalk.green("✓")} ${chalk.cyan(displayName)} — deployed successfully`; if (spinner) spinner.stop(msg); else log(msg); break; } if (status === "failed" || status === "cancelled") { const msg = `${chalk.red("✗")} ${chalk.cyan(displayName)} — ${status}`; if (spinner) spinner.stop(msg); else log(msg); const errorLines = logLines.slice(-5).filter((l) => l.trim().length > 0); if (errorLines.length > 0) { if (isTTY) { console.log( boxen(errorLines.join("\n"), { title: chalk.red("Error"), borderStyle: "round", borderColor: "red", padding: { left: 1, right: 1, top: 0, bottom: 0 }, }), ); } else { log(chalk.red("--- Error context ---")); errorLines.forEach((l) => log(` ${l}`)); } } break; } await sleep(POLL_INTERVAL); } } /** * Deploy multiple apps in parallel with individual progress tracking. */ async function deployMultiApp( coolify: ReturnType, state: ICoolifyMultiAppState, options: { force?: boolean; tag?: string }, ): Promise { const apps = state.apps; log(`Deploying ${apps.length} apps in parallel...`); const deployPromises = apps.map(async (app) => { log(` ${chalk.cyan(app.name)} — triggering...`); const result = await coolify.deploy({ uuid: app.uuid, force: options.force, tag: options.tag, }); if (isErr(result)) { log(` ${chalk.red("✗")} ${app.name} — ${result.error.message}`); return { app, success: false, deploymentUuid: undefined }; } const deploymentUuid = result.value.deploymentUuid; log(` ${chalk.cyan(app.name)} — building (#${deploymentUuid.slice(0, 8)})`); let finalStatus = "unknown"; while (true) { const statusResult = await coolify.getDeploymentLogs(deploymentUuid); if (isErr(statusResult)) { log(` ${chalk.yellow("⚠")} ${app.name} — could not fetch status`); break; } const { status, logs } = statusResult.value; if (status !== finalStatus) { const hint = extractProgressHint(logs ? logs.split("\n").slice(-10) : []); log(` ${getStatusIcon(status)} ${app.name} — ${status}${hint ? ` (${hint})` : ""}`); finalStatus = status; } if (status === "finished") { log(` ${chalk.green("✓")} ${app.name} — deployed`); break; } if (status === "failed" || status === "cancelled") { log(` ${chalk.red("✗")} ${app.name} — ${status}`); break; } await sleep(POLL_INTERVAL); } return { app, success: finalStatus === "finished", deploymentUuid }; }); const results = await Promise.allSettled(deployPromises); const succeeded = results.filter( (r) => r.status === "fulfilled" && r.value?.success, ).length; const failed = results.length - succeeded; log(""); if (failed === 0) { log(chalk.green(`All ${succeeded} apps deployed successfully`)); } else { log(`${chalk.green(`${succeeded} succeeded`)}, ${chalk.red(`${failed} failed`)}`); } } /** * Extract a human-readable progress hint from recent build log lines. */ function extractProgressHint(lines: string[]): string | null { for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i].trim(); if (!line) continue; const stepMatch = line.match(/Step (\d+)\/(\d+)/i); if (stepMatch) return `Step ${stepMatch[1]}/${stepMatch[2]}`; const buildKitMatch = line.match(/#(\d+) \[.*?\] (.*)/); if (buildKitMatch) return buildKitMatch[2].slice(0, 40); if (/cloning|clone/i.test(line)) return "Git clone"; if (/installing|npm install|bun install|yarn install/i.test(line)) return "Installing dependencies"; if (/health.?check|healthy/i.test(line)) return "Health check"; if (/starting|container.*start/i.test(line)) return "Starting container"; } return null; } /** * Get a status icon for deployment state. */ function getStatusIcon(status: string): string { switch (status) { case "finished": return chalk.green("✓"); case "failed": case "cancelled": return chalk.red("✗"); case "in_progress": case "queued": return chalk.cyan("●"); default: return chalk.yellow("○"); } } /** * Resolve app UUID or prompt user to pick from multi-app state. */ async function resolveOrPromptApp( multiState: ICoolifyMultiAppState | null, ): Promise { const resolved = resolveUuid(undefined); if (resolved) return resolved; if (isTTY && multiState && multiState.apps.length > 0) { const response = await p.select({ message: "Deploy which app?", options: [ { label: `All (${multiState.apps.length} apps in parallel)`, value: "_all", hint: "parallel deploy", }, ...multiState.apps.map((app) => ({ label: app.name, value: app.uuid, hint: app.domain || app.service, })), ], }); if (p.isCancel(response)) return null; return response as string; } console.error( chalk.red("Error: No UUID/name provided and no .coolify.json found"), ); return null; } /** * Log a message to stdout (static, no spinner frames). */ function log(msg: string): void { console.log(msg); } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); }