/** * Status + interactive navigation — custom ANSI selectors throughout. * No @clack/prompts for selection — all custom for integrated TUI feel. * * @module */ import * as p from "@clack/prompts"; import { isErr } from "@mks2508/no-throw"; import chalk from "chalk"; import { getCoolifyService } from "../../coolify/index.js"; import type { ICoolifyInfrastructureTree, ICoolifyApplication, ICoolifyResource, } from "../../coolify/types.js"; import { renderScreen, clearScreen, renderResourceHeader, renderPreviewAt, buildProjectPreview, buildResourcePreview, waitForKey, type IScreenContext, } from "../ui/screen.js"; import { showStatusDashboard, formatStatus } from "../ui/tables.js"; import { loadCoolifyState, loadMultiAppState } from "../coolify-state.js"; import { loadConfig, getCachedAppSettings } from "../../coolify/config.js"; import { inlineSelect, fullSelect, textInput, confirm as customConfirm, type ISelectOption, } from "../ui/select.js"; import { fetchLogPreview } from "./logs.js"; const isTTY = process.stdout.isTTY === true; // ─── CWD detection ─────────────────────────────────────────────────────────── function detectCwdContext() { const single = loadCoolifyState(); if (single) return { projectName: single.projectName, projectUuid: single.projectUuid, appUuids: [single.appUuid] }; const multi = loadMultiAppState(); if (multi) return { projectName: multi.projectName, projectUuid: multi.projectUuid, appUuids: multi.apps.map((a) => a.uuid) }; return { appUuids: [] as string[], projectName: undefined, projectUuid: undefined }; } // ─── Entry ─────────────────────────────────────────────────────────────────── export async function statusCommand(options?: { watch?: boolean }): Promise { const coolify = getCoolifyService(); const initResult = await coolify.init(); if (isErr(initResult)) { console.error(chalk.red(`Error: ${initResult.error.message}`)); return; } const fetchTree = async () => { const r = await coolify.getInfrastructureTree(); return isErr(r) ? null : r.value; }; const cwdCtx = detectCwdContext(); if (options?.watch) { let count = 0; const render = async () => { const d = await fetchTree(); if (d) { clearScreen(); console.log(chalk.gray(` refresh #${++count}`)); showStatusDashboard(d, cwdCtx.projectUuid); } }; await render(); const interval = setInterval(render, 5000); process.on("SIGINT", () => { clearInterval(interval); process.exit(0); }); return; } if (!isTTY) { const d = await fetchTree(); if (d) showStatusDashboard(d, cwdCtx.projectUuid); return; } clearScreen(); console.log(chalk.gray(" Loading...")); const tree = await fetchTree(); if (!tree) { console.error(chalk.red(" Error fetching infrastructure")); return; } await navigate(tree, cwdCtx.projectUuid); } // ─── Navigation ────────────────────────────────────────────────────────────── async function navigate(tree: ICoolifyInfrastructureTree, cwdProjectUuid?: string): Promise { const hasCwd = cwdProjectUuid && tree.projects.some((pr) => pr.uuid === cwdProjectUuid); if (hasCwd) { const ctx: IScreenContext = { tree, cwdProjectUuid, focusedProjectUuid: cwdProjectUuid, promptInline: true }; const layout = renderScreen(ctx); const resources = collectResources(tree, cwdProjectUuid); const opts: ISelectOption[] = resources.map((res) => { const tag = res.kind === "database" ? "[db] " : res.kind === "service" ? "[svc] " : ""; return { label: `${tag}${res.name}`, value: `res:${res.uuid}:${res.kind}:${res.name}`, hint: formatStatus(res.status) }; }); opts.push({ label: "", value: "_sep" }); opts.push({ label: "All projects", value: "overview" }); opts.push({ label: "Refresh", value: "refresh" }); opts.push({ label: "Exit", value: "exit" }); // Live preview: when hovering a resource, show its detail on the right const previewCol = layout.promptCol + 28; const choice = await inlineSelect(opts, layout.promptRow, layout.promptCol, async (value) => { if (value.startsWith("res:")) { const [, resUuid] = value.split(":"); const res = resources.find((r) => r.uuid === resUuid); if (res) { // Try to build a quick preview from what we have const lines = [ `${chalk.bold(res.name)}`, chalk.gray(res.status), "", res.fqdn ? `${chalk.gray("Domain:")} ${res.fqdn.replace(/^https?:\/\//, "").split(",")[0]}` : "", res.kind !== "app" ? `${chalk.gray("Type:")} ${res.kind}` : "", ].filter(Boolean); renderPreviewAt(layout.promptRow, previewCol, lines, opts.length); } } else { // Clear preview for non-resource items renderPreviewAt(layout.promptRow, previewCol, [], opts.length); } }); if (!choice || choice === "exit" || choice === "_sep") return; if (choice === "refresh") return await refreshAndNavigate(cwdProjectUuid); if (choice === "overview") return await projectPicker(tree, cwdProjectUuid); if (choice.startsWith("res:")) { const [, uuid, kind, ...np] = choice.split(":"); const res = resources.find((r) => r.uuid === uuid); await navigateResource(tree, uuid, np.join(":"), kind, cwdProjectUuid, res, cwdProjectUuid); await navigate(tree, cwdProjectUuid); } return; } // No CWD project: header + full-width project picker (no tree) const ctx: IScreenContext = { tree, cwdProjectUuid, promptInline: true }; renderScreen(ctx); await projectPicker(tree, cwdProjectUuid); } async function projectPicker( tree: ICoolifyInfrastructureTree, cwdProjectUuid?: string, layout?: { promptRow: number; promptCol: number }, ): Promise { const opts: ISelectOption[] = tree.projects.map((proj) => { const total = proj.environments.reduce((s, e) => s + e.resources.length, 0); const statuses = proj.environments.flatMap((e) => e.resources.map((r) => r.status)); const h = statuses.filter((s) => s.includes("healthy") && !s.includes("unhealthy")).length; const x = statuses.filter((s) => s.includes("exited")).length; const parts = [ h > 0 ? `${chalk.green("●")}${chalk.hex("#cccccc")(String(h))}` : "", x > 0 ? `${chalk.red("✗")}${chalk.hex("#cccccc")(String(x))}` : "", chalk.hex("#888888")(`${total}res`), ].filter(Boolean).join(" "); return { label: proj.uuid === cwdProjectUuid ? chalk.cyan(proj.name) : proj.name, value: proj.uuid, hint: parts }; }); opts.push({ label: "", value: "_sep" }); opts.push({ label: "Refresh", value: "_refresh" }); opts.push({ label: "Exit", value: "_exit" }); // Live preview: show project details on the right when hovering const pCol = 38; const maxPreviewW = Math.max(20, (process.stdout.columns || 80) - pCol - 4); const choice = layout ? await inlineSelect(opts, layout.promptRow, layout.promptCol, (value) => { const proj = tree.projects.find((pr) => pr.uuid === value); if (proj) renderPreviewAt(layout.promptRow, pCol, buildProjectPreview(proj, maxPreviewW), opts.length + 2); }) : await fullSelect("Select a project:", opts, (value, _idx, startRow) => { const proj = tree.projects.find((pr) => pr.uuid === value); if (proj) renderPreviewAt(Math.max(1, startRow - 1), pCol, buildProjectPreview(proj, maxPreviewW), opts.length + 4); }); if (!choice || choice === "_exit" || choice === "_sep") return; if (choice === "_refresh") return await refreshAndNavigate(cwdProjectUuid); await navigateProject(tree, choice, cwdProjectUuid); await navigate(tree, cwdProjectUuid); } async function refreshAndNavigate(cwdProjectUuid?: string): Promise { clearScreen(); console.log(chalk.gray(" Refreshing...\n")); const coolify = getCoolifyService(); const r = await coolify.getInfrastructureTree(); if (!isErr(r)) await navigate(r.value, cwdProjectUuid); } /** Render screen with no project expanded — all collapsed overview. */ function renderOverviewScreen(tree: ICoolifyInfrastructureTree, cwdProjectUuid?: string): void { const ctx: IScreenContext = { tree, cwdProjectUuid }; renderScreen(ctx); } // ─── Project navigation ────────────────────────────────────────────────────── async function navigateProject(tree: ICoolifyInfrastructureTree, projectUuid: string, cwdProjectUuid?: string): Promise { const project = tree.projects.find((pr) => pr.uuid === projectUuid); if (!project) return; const ctx: IScreenContext = { tree, cwdProjectUuid, focusedProjectUuid: projectUuid, promptInline: true }; const layout = renderScreen(ctx); const resources = collectResources(tree, projectUuid); const opts: ISelectOption[] = resources.map((res) => { const tag = res.kind === "database" ? "[db] " : res.kind === "service" ? "[svc] " : ""; return { label: `${tag}${res.name}`, value: `${res.uuid}:${res.kind}:${res.name}`, hint: formatStatus(res.status) }; }); opts.push({ label: "", value: "_sep" }); opts.push({ label: "← Back", value: "back" }); const choice = await inlineSelect(opts, layout.promptRow, layout.promptCol); if (!choice || choice === "back" || choice === "_sep") return; const [uuid, kind, ...np] = choice.split(":"); const res = resources.find((r) => r.uuid === uuid); await navigateResource(tree, uuid, np.join(":"), kind, cwdProjectUuid, res, projectUuid); await navigateProject(tree, projectUuid, cwdProjectUuid); } // ─── Resource navigation ───────────────────────────────────────────────────── async function navigateResource( tree: ICoolifyInfrastructureTree, uuid: string, name: string, kind: string, cwdProjectUuid?: string, resource?: ICoolifyResource, parentProjectUuid?: string, ): Promise { const projUuid = parentProjectUuid || findProjectForResource(tree, uuid) || cwdProjectUuid; const projName = tree.projects.find((pr) => pr.uuid === projUuid)?.name; let appDetail: ICoolifyApplication | undefined; let logPreview: string[] | undefined; if (kind === "app") { const coolify = getCoolifyService(); const [dr, lp, cached] = await Promise.all([ coolify.getApplication(uuid), fetchLogPreview(uuid, 6), getCachedAppSettings(uuid), ]); if (!isErr(dr)) { appDetail = dr.value; // Augment with cached settings (API GET doesn't return settings) if (cached && !appDetail.settings) { appDetail.settings = { is_auto_deploy_enabled: cached.isAutoDeployEnabled, }; } } if (lp.length > 0 && !lp[0].includes("unavailable")) logPreview = lp; } const ctx: IScreenContext = { tree, cwdProjectUuid, focusedProjectUuid: projUuid, activeResourceUuid: uuid, breadcrumb: projName ? [projName, name] : [name], activeAppDetail: appDetail, logPreview, promptInline: true, // Action menu renders inline to the right }; const layout = renderScreen(ctx); if (!appDetail) renderResourceHeader(name, kind, resource?.status || "unknown", resource?.fqdn); // Sub-menus render inline at the right column position const pos = { row: layout.promptRow, col: layout.promptCol }; if (kind === "app") await appActions(tree, uuid, name, cwdProjectUuid, resource, parentProjectUuid, appDetail, pos); else if (kind === "database") await dbActions(tree, uuid, name, cwdProjectUuid, parentProjectUuid, pos); else if (kind === "service") await svcActions(tree, uuid, name, cwdProjectUuid, parentProjectUuid, pos); } // ─── App actions ───────────────────────────────────────────────────────────── async function appActions( tree: ICoolifyInfrastructureTree, uuid: string, name: string, cwdProjectUuid?: string, resource?: ICoolifyResource, parentProjectUuid?: string, appDetail?: ICoolifyApplication, pos?: { row: number; col: number }, ): Promise { const menuOpts: ISelectOption[] = [ { label: "Logs", value: "logs", hint: "Follow, filter, tmux" }, { label: "Redeploy", value: "deploy" }, { label: "Deployments", value: "deployments" }, { label: "Env variables", value: "env", hint: "Set, delete, sync" }, { label: "Exec command", value: "exec", hint: "Via SSH" }, { label: "Restart", value: "restart" }, { label: "Start", value: "start" }, { label: "Stop", value: "stop" }, { label: "Build logs", value: "build-logs", hint: "Last deploy" }, { label: "Diagnose", value: "diagnose", hint: "AI analysis" }, { label: chalk.red("Delete"), value: "delete" }, { label: "← Back", value: "back" }, ]; const action = pos ? await inlineSelect(menuOpts, pos.row, pos.col) : await fullSelect(`${name}:`, menuOpts); if (!action || action === "back") return; // Sub-menus that have their own inline selector — stay in layout if (action === "logs") { await logsSubMenu(uuid, name, pos); } else if (action === "env") { await envSubMenu(uuid, name, pos); } else if (action === "exec") { await execSubMenu(uuid, name, pos); } else { // Actions that produce output: clear screen first, then run, wait, re-render clearScreen(); console.log(chalk.hex("#a875ff")(` ${name} — ${action}\n`)); switch (action) { case "deploy": await (await import("./deploy.js")).deployCommand(uuid, {}); break; case "deployments": await (await import("./deployments.js")).deploymentsCommand(uuid, {}); break; case "start": await (await import("./start.js")).startCommand(uuid); break; case "stop": await (await import("./stop.js")).stopCommand(uuid); break; case "restart": await (await import("./restart.js")).restartCommand(uuid); break; case "build-logs": { const coolify = getCoolifyService(); const deploys = await coolify.getApplicationDeploymentHistory(uuid); if (isErr(deploys) || deploys.value.length === 0) console.log(chalk.yellow(" No deployments found")); else await (await import("./build-logs.js")).buildLogsCommand(deploys.value[deploys.value.length - 1].uuid, {}); break; } case "diagnose": await (await import("./diagnose.js")).diagnoseAppCommand(uuid); break; case "delete": { const yes = await customConfirm(`Delete ${name}? This cannot be undone.`); if (yes) { await (await import("./delete.js")).deleteCommand(uuid, { yes: true }); await waitForKey(); return; } break; } } await waitForKey(); } await navigateResource(tree, uuid, name, "app", cwdProjectUuid, resource, parentProjectUuid); } // ─── Logs sub-menu ─────────────────────────────────────────────────────────── async function logsSubMenu(uuid: string, appName: string, pos?: { row: number; col: number }): Promise { const opts: ISelectOption[] = [ { label: "Follow (live)", value: "follow", hint: "Ctrl+C stop" }, { label: "Last 50 lines", value: "50" }, { label: "Last 200 lines", value: "200" }, { label: "Errors only", value: "errors" }, { label: "Last 1 hour", value: "since-1h" }, { label: "Open in tmux", value: "tmux", hint: "Background" }, { label: "← Back", value: "back" }, ]; const action = pos ? await inlineSelect(opts, pos.row, pos.col) : await fullSelect(`${appName} — Logs:`, opts); if (!action || action === "back") return; // All log actions produce output — clear screen first clearScreen(); console.log(chalk.hex("#a875ff")(` ${appName} — Logs\n`)); const { logsCommand } = await import("./logs.js"); switch (action) { case "follow": await logsCommand(uuid, { follow: true }); break; case "50": await logsCommand(uuid, { lines: 50 }); await waitForKey(); break; case "200": await logsCommand(uuid, { lines: 200 }); await waitForKey(); break; case "errors": await logsCommand(uuid, { lines: 100, errors: true }); await waitForKey(); break; case "since-1h": await logsCommand(uuid, { lines: 200, since: "1h" }); await waitForKey(); break; case "tmux": { const sessionName = `coolify-logs-${appName.replace(/[^a-zA-Z0-9-]/g, "-")}`; try { const { spawnSync } = await import("child_process"); const bunPath = process.argv[0] || "bun"; const scriptPath = process.argv[1] || "coolify-cli"; const cmd = `'${bunPath}' '${scriptPath}' logs ${uuid} -f`; const childEnv = { ...process.env, COOLIFY_URL: process.env.COOLIFY_URL ?? "", COOLIFY_TOKEN: process.env.COOLIFY_TOKEN ?? "", }; spawnSync("tmux", ["kill-session", "-t", sessionName], { stdio: "ignore" }); spawnSync("tmux", ["new-session", "-d", "-s", sessionName, cmd], { env: childEnv }); const check = spawnSync("tmux", ["has-session", "-t", sessionName]); if (check.status === 0) { console.log(chalk.green(`\n ✓ Session "${sessionName}" created`)); console.log(chalk.gray(` tmux attach -t ${sessionName}`)); } else { console.log(chalk.yellow(" Session may have exited. Try manually:")); console.log(chalk.gray(` tmux new-session -s ${sessionName} "${cmd}"`)); } } catch (e) { console.error(chalk.red(` Failed: ${e}`)); } await waitForKey(); break; } } // Return to parent — navigateResource will re-render screen and show appActions again } // ─── Env sub-menu ──────────────────────────────────────────────────────────── async function envSubMenu(uuid: string, appName: string, pos?: { row: number; col: number }): Promise { const opts: ISelectOption[] = [ { label: "List all", value: "list" }, { label: "Set variable", value: "set", hint: "KEY=VALUE" }, { label: "Delete variable", value: "delete" }, { label: "Sync from .env", value: "sync" }, { label: "Sync (dry-run)", value: "sync-dry", hint: "Preview" }, { label: "← Back", value: "back" }, ]; const action = pos ? await inlineSelect(opts, pos.row, pos.col) : await fullSelect(`${appName} — Env:`, opts); if (!action || action === "back") return; // Env actions produce output — clear screen clearScreen(); console.log(chalk.hex("#a875ff")(` ${appName} — Env Variables\n`)); const { envCommand } = await import("./env.js"); switch (action) { case "list": await envCommand(uuid, {}); await waitForKey(); break; case "set": { const input = await textInput("KEY=VALUE:"); if (input) { await envCommand(uuid, { set: [input] }); await waitForKey(); } break; } case "delete": { const key = await textInput("Variable name:"); if (key) { await envCommand(uuid, { delete: key }); await waitForKey(); } break; } case "sync": await envCommand(uuid, { sync: true }); await waitForKey(); break; case "sync-dry": await envCommand(uuid, { sync: true, "dry-run": true }); await waitForKey(); break; } } // ─── Exec sub-menu ─────────────────────────────────────────────────────────── async function execSubMenu(uuid: string, appName: string, pos?: { row: number; col: number }): Promise { const containerName = `${appName}-${uuid}`; const opts: ISelectOption[] = [ { label: "Copy docker exec cmd", value: "copy", hint: "Manual SSH" }, { label: "SSH + exec in tmux", value: "tmux", hint: "Background" }, { label: "← Back", value: "back" }, ]; const action = pos ? await inlineSelect(opts, pos.row, pos.col) : await fullSelect(`${appName} — Exec:`, opts); if (!action || action === "back") return; // Clear screen before output clearScreen(); console.log(chalk.hex("#a875ff")(` ${appName} — Execute\n`)); if (action === "copy") { console.log(chalk.gray(" Run on your Coolify server:\n")); console.log(` ${chalk.cyan(`docker exec -it ${containerName} bash`)}`); console.log(chalk.gray(`\n Or: docker exec -it ${containerName} sh`)); await waitForKey(); } if (action === "tmux") { const coolify = getCoolifyService(); const tr = await coolify.getInfrastructureTree(); const ip = !isErr(tr) ? tr.value.server.ip : undefined; if (!ip) { console.error(chalk.red(" Could not find server IP")); await waitForKey(); return; } const session = `coolify-exec-${appName.replace(/[^a-zA-Z0-9-]/g, "-")}`; const sshCmd = `ssh root@${ip} -t 'docker exec -it ${containerName} bash || docker exec -it ${containerName} sh'`; try { const { spawnSync } = await import("child_process"); spawnSync("tmux", ["kill-session", "-t", session], { stdio: "ignore" }); spawnSync("tmux", ["new-session", "-d", "-s", session, sshCmd]); console.log(chalk.green(`\n ✓ Session "${session}" created`)); console.log(chalk.gray(` tmux attach -t ${session}`)); } catch { console.log(chalk.gray(`\n Manual: ${sshCmd}`)); } await waitForKey(); } } // ─── DB actions ────────────────────────────────────────────────────────────── async function dbActions( tree: ICoolifyInfrastructureTree, uuid: string, name: string, cwdProjectUuid?: string, parentProjectUuid?: string, pos?: { row: number; col: number }, ): Promise { const opts: ISelectOption[] = [ { label: "Start", value: "start" }, { label: "Stop", value: "stop" }, { label: "Restart", value: "restart" }, { label: "Backups", value: "backups" }, { label: chalk.red("Delete"), value: "delete" }, { label: "← Back", value: "back" }, ]; const action = pos ? await inlineSelect(opts, pos.row, pos.col) : await fullSelect(`[db] ${name}:`, opts); if (!action || action === "back") return; clearScreen(); console.log(chalk.hex("#a875ff")(` [db] ${name} — ${action}\n`)); const { dbStartCommand, dbStopCommand, dbRestartCommand, dbBackupsCommand, dbDeleteCommand } = await import("./db.js"); switch (action) { case "start": await dbStartCommand(uuid); break; case "stop": await dbStopCommand(uuid); break; case "restart": await dbRestartCommand(uuid); break; case "backups": await dbBackupsCommand(uuid); break; case "delete": { const yes = await customConfirm(`Delete database ${name}?`); if (yes) { await dbDeleteCommand(uuid); await waitForKey(); return; } break; } } await waitForKey(); await navigateResource(tree, uuid, name, "database", cwdProjectUuid, undefined, parentProjectUuid); } // ─── Service actions ───────────────────────────────────────────────────────── async function svcActions( tree: ICoolifyInfrastructureTree, uuid: string, name: string, cwdProjectUuid?: string, parentProjectUuid?: string, pos?: { row: number; col: number }, ): Promise { const opts: ISelectOption[] = [ { label: "Start", value: "start" }, { label: "Stop", value: "stop" }, { label: "Restart", value: "restart" }, { label: "Env variables", value: "env" }, { label: chalk.red("Delete"), value: "delete" }, { label: "← Back", value: "back" }, ]; const action = pos ? await inlineSelect(opts, pos.row, pos.col) : await fullSelect(`[svc] ${name}:`, opts); if (!action || action === "back") return; clearScreen(); console.log(chalk.hex("#a875ff")(` [svc] ${name} — ${action}\n`)); const { svcStartCommand, svcStopCommand, svcRestartCommand, svcEnvCommand, svcDeleteCommand } = await import("./svc.js"); switch (action) { case "start": await svcStartCommand(uuid); break; case "stop": await svcStopCommand(uuid); break; case "restart": await svcRestartCommand(uuid); break; case "env": await svcEnvCommand(uuid); break; case "delete": { const yes = await customConfirm(`Delete service ${name}?`); if (yes) { await svcDeleteCommand(uuid); await waitForKey(); return; } break; } } await waitForKey(); await navigateResource(tree, uuid, name, "service", cwdProjectUuid, undefined, parentProjectUuid); } // ─── Helpers ───────────────────────────────────────────────────────────────── function collectResources(tree: ICoolifyInfrastructureTree, projectUuid?: string): ICoolifyResource[] { const resources: ICoolifyResource[] = []; const projects = projectUuid ? tree.projects.filter((pr) => pr.uuid === projectUuid) : tree.projects; for (const proj of projects) for (const env of proj.environments) for (const res of env.resources) resources.push(res); return resources; } function findProjectForResource(tree: ICoolifyInfrastructureTree, resourceUuid: string): string | undefined { for (const proj of tree.projects) for (const env of proj.environments) if (env.resources.some((r) => r.uuid === resourceUuid)) return proj.uuid; return undefined; }