/** * .coolify.json state loader for CLI. * * When running CLI commands from a project directory that has a .coolify.json * (generated by create-bunspace or first deploy), reads the state to auto-fill * UUIDs so users don't need to copy-paste them. * * @module */ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { validateCoolifyState } from "../utils/format.js"; /** * State stored in .coolify.json (generated by create-bunspace deployer or init command). * * This file links a local directory to a Coolify deployment, storing all necessary * UUIDs and metadata for CLI commands to work without manual UUID entry. */ export interface ICoolifyDeployState { /** Application UUID in Coolify */ appUuid: string; /** Application name (for display purposes) */ appName?: string; /** Server UUID */ serverUuid: string; /** Server name (for display purposes) */ serverName?: string; /** Project UUID */ projectUuid: string; /** Project name (for display purposes) */ projectName?: string; /** Environment UUID */ environmentUuid: string; /** Environment name (for display purposes) */ environmentName?: string; /** Domain if configured */ domain?: string; /** Docker compose or Dockerfile path */ dockerComposePath?: string; /** Base directory for build context */ baseDirectory?: string; /** Git branch */ branch?: string; /** Git repository URL */ gitRepository?: string; /** Application source type (github-app, deploy-key, docker-image, etc.) */ sourceType?: string; /** Application type (legacy, may be undefined) */ type?: string; /** Build pack (dockerfile, nixpacks, static, dockercompose) */ buildPack?: string; /** Ports exposed (comma-separated) */ portsExposes?: string; /** Auto-deploy enabled (Coolify default: true) */ autoDeployEnabled?: boolean; /** Watch paths for selective auto-deploy (newline-separated globs, null = all changes) */ watchPaths?: string | null; /** When this state was last updated */ updatedAt?: string; /** Coolify instance URL */ coolifyUrl?: string; } /** * Multi-app state for monorepos deploying multiple services from one repo. * * Stored alongside or instead of the single-app state in .coolify.json. */ export interface ICoolifyMultiAppState { /** All apps deployed from this repo */ apps: Array<{ /** Application UUID in Coolify */ uuid: string; /** Application name */ name: string; /** Service role (e.g. "backend", "admin", "docs", "proxy") */ service: string; /** Domain if configured (with protocol). Null for internal services. */ domain?: string | null; /** Dockerfile path relative to repo root */ dockerfile?: string | null; /** Exposed port */ port?: number; /** Auto-deploy enabled (Coolify default: true) */ autoDeployEnabled?: boolean; /** Watch paths for selective auto-deploy (newline-separated globs, null = all changes) */ watchPaths?: string | null; }>; /** Server UUID */ serverUuid: string; /** Server name (for display purposes) */ serverName?: string; /** Project UUID */ projectUuid: string; /** Project name (for display purposes) */ projectName?: string; /** Environment UUID */ environmentUuid: string; /** Environment name (for display purposes) */ environmentName?: string; /** Git branch */ branch?: string; /** Git repository URL */ gitRepository?: string; /** Coolify instance URL */ coolifyUrl?: string; /** When this state was last updated */ updatedAt?: string; } const STATE_FILE = ".coolify.json"; /** * Loads .coolify.json from the current working directory (single-app format). * * Handles both single-app and multi-app formats. For multi-app state with * exactly one app, returns a synthesized single-app state for backward compat. * * @returns The deploy state if found, null otherwise */ export function loadCoolifyState(): ICoolifyDeployState | null { const statePath = join(process.cwd(), STATE_FILE); if (!existsSync(statePath)) { return null; } try { const content = readFileSync(statePath, "utf-8"); const state = JSON.parse(content); // Multi-app format — synthesize single-app state if exactly 1 app if (Array.isArray(state.apps)) { if (state.apps.length === 1) { const app = state.apps[0]; return { appUuid: app.uuid, appName: app.name, serverUuid: state.serverUuid, serverName: state.serverName, projectUuid: state.projectUuid, projectName: state.projectName, environmentUuid: state.environmentUuid, environmentName: state.environmentName, domain: app.domain, branch: state.branch, gitRepository: state.gitRepository, coolifyUrl: state.coolifyUrl, updatedAt: state.updatedAt, }; } // Multi-app with multiple apps — cannot auto-select return null; } // Single-app format if (!state.appUuid) { return null; } // Validate state against schema rules const validation = validateCoolifyState(state); if (!validation.valid) { console.error( `Warning: .coolify.json has errors: ${validation.errors.join("; ")}`, ); } if (validation.warnings.length > 0) { for (const w of validation.warnings) { console.error(`Warning: .coolify.json — ${w}`); } } return state as ICoolifyDeployState; } catch { return null; } } /** * Loads .coolify.json in multi-app format. * * If the file is in single-app format, returns null (use loadCoolifyState instead). * * @returns The multi-app state if found, null otherwise */ export function loadMultiAppState(): ICoolifyMultiAppState | null { const statePath = join(process.cwd(), STATE_FILE); if (!existsSync(statePath)) { return null; } try { const content = readFileSync(statePath, "utf-8"); const state = JSON.parse(content); if (!Array.isArray(state.apps)) { return null; } return state as ICoolifyMultiAppState; } catch { return null; } } /** * Writes .coolify.json in multi-app format to the current working directory. * * @param state - The multi-app state to write */ export function writeMultiAppState(state: ICoolifyMultiAppState): void { const validation = validateCoolifyState( state as unknown as Record, ); if (!validation.valid) { console.error( `Warning: writing .coolify.json with errors: ${validation.errors.join("; ")}`, ); } const statePath = join(process.cwd(), STATE_FILE); writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n", "utf-8"); } /** * Writes .coolify.json to the current working directory. * * @param state - The state to write */ export function writeCoolifyState(state: ICoolifyDeployState): void { const validation = validateCoolifyState( state as unknown as Record, ); if (!validation.valid) { console.error( `Warning: writing .coolify.json with errors: ${validation.errors.join("; ")}`, ); } const statePath = join(process.cwd(), STATE_FILE); writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n", "utf-8"); } /** * Resolves a UUID argument — if not provided, tries to read from .coolify.json. * * Supports both single-app and multi-app state formats. For multi-app state * with exactly one app, auto-selects that app. For multiple apps, returns null * (user must specify explicitly). * * @param uuid - UUID from CLI argument (may be undefined) * @param field - Which field to read from .coolify.json (default: appUuid) * @returns The resolved UUID or null if not found */ export function resolveUuid( uuid: string | undefined, field: keyof ICoolifyDeployState = "appUuid", ): string | null { // Only return directly if it looks like a UUID (lowercase alphanumeric, 16-40 chars) // Names like "mks-backend" should fall through to .coolify.json or name resolver if (uuid && /^[a-z0-9]{16,40}$/.test(uuid)) return uuid; // Try single-app state first (also handles multi-app with 1 app) const state = loadCoolifyState(); if (state) { const value = state[field]; return typeof value === "string" ? value : null; } // Try multi-app state — only auto-resolve if exactly 1 app const multiState = loadMultiAppState(); if (multiState && multiState.apps.length === 1 && field === "appUuid") { return multiState.apps[0].uuid; } return null; }