/** * Create command for CLI. * * @module */ import * as p from "@clack/prompts"; import { isOk, isErr } from "@mks2508/no-throw"; import ora from "ora"; import chalk from "chalk"; import { getCoolifyService } from "../../coolify/index.js"; import { validatePorts } from "../../utils/format.js"; import type { ICoolifyGithubApp } from "../../coolify/types.js"; /** * Create command options. */ interface ICreateOptions { name: string; description?: string; server: string; project: string; environment?: string; repo?: string; branch?: string; type?: | "public" | "private-github-app" | "private-deploy-key" | "dockerfile" | "docker-image" | "docker-compose"; buildPack?: "dockerfile" | "nixpacks" | "static" | "dockercompose"; ports?: string; dockerImage?: string; dockerCompose?: string; dockerComposeLocation?: string; dockerfileLocation?: string; baseDirectory?: string; githubAppUuid?: string; privateKeyUuid?: string; domain?: string; } /** * Identifier format accepted by Coolify. * * Coolify uses two ID formats: * - Standard UUID v4 (8-4-4-4-12 hex with dashes) * - Laravel-style 24-char alphanumeric IDs (e.g. `awgcco0k48g4kgw8cckkc808`) * * Both are accepted; we reject only clearly invalid input to fail fast * before hitting the API. */ const ID_REGEX = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[a-z0-9]{20,30})$/i; /** * Valid --type values. */ const VALID_APP_TYPES = [ "public", "private-github-app", "private-deploy-key", "dockerfile", "docker-image", "docker-compose", ] as const; /** * Valid --build-pack values. */ const VALID_BUILD_PACKS = ["nixpacks", "static", "dockerfile", "dockercompose"] as const; /** * TTY detection — used to guard interactive prompts. */ const isTTY = process.stdout.isTTY === true; /** * Validates create command options and exits with code 1 on failure. * Called before any API calls. * * @param options - Create options to validate */ function validateCreateOptions(options: ICreateOptions): void { // --type validation if (options.type && !VALID_APP_TYPES.includes(options.type)) { console.error(chalk.red(`Invalid --type: '${options.type}'`)); console.error(chalk.gray(` Valid values: ${VALID_APP_TYPES.join(", ")}`)); process.exit(1); } // --build_pack validation if (options.buildPack && !VALID_BUILD_PACKS.includes(options.buildPack)) { console.error(chalk.red(`Invalid --build-pack: '${options.buildPack}'`)); console.error(chalk.gray(` Valid values: ${VALID_BUILD_PACKS.join(", ")}`)); process.exit(1); } // Identifier format validations (UUID v4 OR Coolify 24-char ID) const idFields: Array<[string, string]> = [ ["--server", options.server], ["--project", options.project], ["--environment", options.environment ?? ""], ["--github-app-uuid", options.githubAppUuid ?? ""], ]; for (const [flag, value] of idFields) { if (value && !ID_REGEX.test(value)) { console.error(chalk.red(`Invalid ID format for ${flag}: '${value}'`)); console.error( chalk.gray( ` Expected: UUID v4 (8-4-4-4-12 hex) or Coolify 24-char ID (e.g. a1b2c3d4-e5f6-7890-abcd-ef1234567890 or awgcco0k48g4kgw8cckkc808)`, ), ); process.exit(1); } } // Required fields if (!options.name) { console.error(chalk.red("--name is required")); process.exit(1); } if (!options.server) { console.error(chalk.red("--server is required")); process.exit(1); } if (!options.project) { console.error(chalk.red("--project is required")); process.exit(1); } } /** * Prompts the user to select a GitHub App from a list of private apps. * Skips prompt entirely if --github-app-uuid is passed explicitly. * * @param apps - List of GitHub apps (should already be filtered to private) * @param explicitUuid - The --github-app-uuid passed by the user (optional) * @returns The selected app's uuid, or null if cancelled */ async function promptGithubAppSelection( apps: ICoolifyGithubApp[], explicitUuid?: string, ): Promise { // If --github-app-uuid was passed, validate it exists in the list if (explicitUuid) { const found = apps.find((a) => a.uuid === explicitUuid); if (!found) { console.error( chalk.red(`GitHub App '${explicitUuid}' not found among configured private apps`), ); if (apps.length > 0) { console.error( chalk.gray( ` Available: ${apps.map((a) => `${a.name} (${a.uuid})`).join(", ")}`, ), ); } process.exit(1); } return explicitUuid; } // 0 private apps → loud error if (apps.length === 0) { console.error( chalk.red("No private GitHub Apps found. Cannot create private-github-app deployment."), ); console.error( chalk.gray(" Configure a private GitHub App in Coolify: Settings → Sources → GitHub App"), ); process.exit(1); } // 1 private app → silently use it if (apps.length === 1) { return apps[0].uuid; } // 2+ private apps → interactive picker if (!isTTY) { console.error( chalk.red( "Multiple private GitHub Apps found but stdin is not a TTY. " + "Pass --github-app-uuid to select one explicitly.", ), ); process.exit(1); } const choices = apps.map((app) => ({ label: app.name, value: app.uuid, hint: app.organization ?? undefined, })); const selected = await p.select({ message: "Select a GitHub App for this deployment:", options: choices, }); if (p.isCancel(selected)) { console.error(chalk.yellow("Cancelled.")); process.exit(1); } return selected as string; } /** * Create command handler. * * @param options - Create options */ export async function createCommand(options: ICreateOptions) { const spinner = ora("Initializing Coolify connection...").start(); try { // Fail-fast validation before any API calls validateCreateOptions(options); const coolify = getCoolifyService(); const initResult = await coolify.init(); if (isErr(initResult)) { spinner.fail(chalk.red(`Failed to initialize: ${initResult.error.message}`)); process.exit(1); } // ── Environment resolution ───────────────────────────────────────────── let environmentUuid: string | undefined = options.environment; let environmentName: string | undefined; if (!environmentUuid) { spinner.text = "Fetching project environments..."; const envResult = await coolify.getProjectEnvironments(options.project); if (isErr(envResult)) { spinner.fail( chalk.red(`Failed to fetch environments: ${envResult.error.message}`), ); process.exit(1); } if (envResult.value.length === 0) { spinner.fail( chalk.red("No environments found for project. Specify --environment "), ); process.exit(1); } if (envResult.value.length === 1) { environmentUuid = envResult.value[0].uuid; environmentName = envResult.value[0].name; spinner.info( chalk.cyan(`Using only environment: ${environmentName} (${environmentUuid})`), ); } else { // Interactive environment picker if (!isTTY) { spinner.fail( chalk.red( "No --environment specified and stdin is not a TTY. " + "Pass --environment explicitly.", ), ); process.exit(1); } const choices = envResult.value.map((env) => ({ label: env.name, value: env.uuid, hint: env.description || undefined, })); const selected = await p.select({ message: "Select an environment:", options: choices, }); if (p.isCancel(selected)) { spinner.stop("Cancelled."); process.exit(1); } environmentUuid = selected as string; environmentName = envResult.value.find((e) => e.uuid === environmentUuid)?.name; spinner.info(chalk.cyan(`Selected environment: ${environmentName} (${environmentUuid})`)); } } else { // Environment was provided — validate it exists and get its name spinner.text = "Validating environment..."; const envResult = await coolify.getProjectEnvironments(options.project); if (isOk(envResult)) { const env = envResult.value.find((e) => e.uuid === environmentUuid); if (env) { environmentName = env.name; } else { spinner.fail( chalk.red(`Environment '${environmentUuid}' not found in project.`) + chalk.gray( `\n Available: ${envResult.value.map((e) => `${e.name} (${e.uuid})`).join(", ")}`, ), ); process.exit(1); } } } if (!environmentUuid) { spinner.fail(chalk.red("Environment UUID is required")); process.exit(1); } // ── GitHub App resolution ─────────────────────────────────────────────── let appType = options.type || "public"; let githubAppUuid = options.githubAppUuid; if (!githubAppUuid && (appType === "private-github-app" || !options.type)) { spinner.text = "Detecting GitHub Apps..."; const ghAppsResult = await coolify.listGithubAppsAll(); if (isOk(ghAppsResult)) { // Filter to private (non-public) GitHub Apps only. const privateApps = ghAppsResult.value.filter((app) => !app.is_public); githubAppUuid = await promptGithubAppSelection(privateApps, options.githubAppUuid); if (githubAppUuid === null) { // Should not reach here — promptGithubAppSelection exits on cancel process.exit(1); } appType = "private-github-app"; const usedApp = privateApps.find((a) => a.uuid === githubAppUuid); if (usedApp) { spinner.info( chalk.cyan( `Using GitHub App: ${usedApp.name}` + (usedApp.organization ? ` (${usedApp.organization})` : ""), ), ); } } else if (appType === "private-github-app") { spinner.fail( chalk.red(`Could not list GitHub Apps: ${ghAppsResult.error.message}`), ); process.exit(1); } } // ── Port validation ───────────────────────────────────────────────────── const portsStr = options.ports || "3000"; const portValidation = validatePorts(portsStr); if (!portValidation.valid) { spinner.fail(chalk.red(`Invalid ports: ${portValidation.error}`)); process.exit(1); } spinner.text = "Creating application..."; const result = await coolify.createApplication( { name: options.name, description: options.description, projectUuid: options.project, environmentUuid, environmentName, serverUuid: options.server, type: appType, githubAppUuid, githubRepoUrl: options.repo, branch: options.branch || "main", buildPack: options.buildPack || "dockerfile", portsExposes: portsStr, dockerImage: options.dockerImage, dockerCompose: options.dockerCompose, dockerComposeLocation: options.dockerComposeLocation, dockerfileLocation: options.dockerfileLocation, baseDirectory: options.baseDirectory, privateKeyUuid: options.privateKeyUuid, }, (percent, message) => { spinner.text = `${chalk.bold(`[${percent}%]`)} ${message}`; }, ); if (isOk(result)) { const createdUuid = result.value.uuid; spinner.succeed( chalk.green( `Application created! UUID: ${chalk.cyan(createdUuid)}`, ), ); // Set domain via PATCH if --domain was provided if (options.domain && createdUuid) { const domainSpinner = ora("Setting domain...").start(); const domainValue = options.domain.startsWith("http") ? options.domain : `https://${options.domain}`; const updateResult = await coolify.updateApplication(createdUuid, { domains: domainValue, }); if (isOk(updateResult)) { domainSpinner.succeed(chalk.green(`Domain set: ${chalk.cyan(domainValue)}`)); } else { // Non-fatal — domain setting failed but app was created domainSpinner.fail( chalk.red(`Failed to set domain: ${updateResult.error.message}`), ); } } console.log(` Name: ${chalk.cyan(options.name)}`); console.log(` Type: ${chalk.cyan(appType)}`); if (options.domain) { const domainValue = options.domain.startsWith("http") ? options.domain : `https://${options.domain}`; console.log(` Domain: ${chalk.cyan(domainValue)}`); } console.log(` Next steps:`); console.log( ` 1. Set environment variables: ${chalk.yellow("coolify-mcp env " + createdUuid)}`, ); console.log( ` 2. Deploy application: ${chalk.yellow("coolify-mcp deploy " + createdUuid)}`, ); } else { spinner.fail(chalk.red(`Creation failed: ${result.error.message}`)); process.exit(1); } } catch (error) { spinner.fail( chalk.red( `Error: ${error instanceof Error ? error.message : String(error)}`, ), ); process.exit(1); } }