/** * Env vars command for CLI. * * @module */ import { isErr } from "@mks2508/no-throw"; import chalk from "chalk"; import { getCoolifyService } from "../../coolify/index.js"; import { getCliSdk } from "../actions.js"; import { resolveUuid } from "../coolify-state.js"; import { resolveAppNameOrUuid } from "../name-resolver.js"; import { parseEnvContent } from "../../utils/env-parser.js"; import { createEnvTable, createChangeSummary, createSpinner, highlightEnvLine, createDiff, } from "../ui/index.js"; /** Options accepted by the `env` command. */ interface IEnvCommandOptions { /** One or more `KEY=VALUE` pairs to set (commander accumulator → array). */ set?: string[]; /** Single key to delete. */ delete?: string; /** Single key to read (prints value only, no decoration). */ get?: string; /** Mark variables as build-time only. */ buildtime?: boolean; /** Mark variables as runtime only. */ "runtime-only"?: boolean; /** Sync from a file (string path) or stdin (`true` + non-TTY) or default `.env`. */ sync?: boolean | string; /** Preview sync changes without applying. */ "dry-run"?: boolean; /** Delete vars not present in the sync source. */ prune?: boolean; /** Force table view instead of highlighted list. */ table?: boolean; /** Emit machine-parseable JSON instead of human-friendly output. */ json?: boolean; } /** Result shape for `--json` output of single-key ops (`--get`, `--set`, `--delete`). */ interface IJsonEnvAction { /** Action performed. */ action: "get" | "set" | "delete"; /** Variable key. */ key: string; /** Variable value (undefined for delete). */ value?: string; /** Whether the value was build-time only. */ is_buildtime?: boolean; /** Whether the value was runtime-only. */ is_runtime?: boolean; } /** * Env vars command handler. * * If no UUID is provided, reads from .coolify.json in the current directory. * * @param uuid - Application UUID (optional if .coolify.json exists) * @param options - Command options */ export async function envCommand( uuid: string | undefined, options: IEnvCommandOptions = {}, ) { let resolvedUuid = resolveUuid(uuid); if (!resolvedUuid && uuid) { resolvedUuid = await resolveAppNameOrUuid(uuid); } if (!resolvedUuid) { console.error( chalk.red("Error: No UUID/name provided and no .coolify.json found"), ); return; } uuid = resolvedUuid; const coolify = getCoolifyService(); const initResult = await coolify.init(); if (isErr(initResult)) { console.error(chalk.red(`Error: ${initResult.error.message}`)); return; } // Handle --set KEY=VALUE (one or many) if (options.set && options.set.length > 0) { const isBuildTime = options["runtime-only"] ? false : (options.buildtime ?? false); // Parse all pairs first so a malformed one fails the whole batch fast. const parsed: Array<{ key: string; value: string }> = []; for (const raw of options.set) { const eq = raw.indexOf("="); // Allow empty value via leading '='? No — require at least key. if (eq === -1) { console.error( chalk.red(`Error: Invalid --set format: "${raw}". Use KEY=VALUE`), ); return; } const key = raw.slice(0, eq); const value = raw.slice(eq + 1); if (!key) { console.error( chalk.red(`Error: Invalid --set format: "${raw}". Empty key.`), ); return; } parsed.push({ key, value }); } // Multi-set path: go straight to bulk so we make one round trip and // also bypass the singular-PATCH bug for any pre-existing vars. if (parsed.length > 1) { const bulkResult = await coolify.bulkUpdateEnvironmentVariables(uuid, [ ...parsed.map(({ key, value }) => ({ key, value, is_buildtime: isBuildTime, is_runtime: !isBuildTime, })), // If --delete was also provided, fold it into the bulk by setting // the key to empty string — Coolify treats empty value as cleared. ...(options.delete ? [ { key: options.delete, value: "", is_buildtime: false, is_runtime: true, }, ] : []), ]); if (isErr(bulkResult)) { console.error(chalk.red(`Error: ${bulkResult.error.message}`)); return; } if (options.json) { const action: IJsonEnvAction[] = parsed.map(({ key, value }) => ({ action: "set", key, value, is_buildtime: isBuildTime, is_runtime: !isBuildTime, })); if (options.delete) { action.push({ action: "delete", key: options.delete }); } console.log(JSON.stringify(action)); } else { for (const { key } of parsed) { console.log(chalk.green(`✓ Set ${chalk.bold(key)} for ${uuid}`)); } if (options.delete) { console.log( chalk.green( `✓ Cleared ${chalk.bold(options.delete)} for ${uuid}`, ), ); } } return; } // Single --set path: delegate to setEnvironmentVariable (which itself // delegates to bulk internally — fix for Bug #1). const [{ key, value }] = parsed; const result = await coolify.setEnvironmentVariable( uuid, key, value, isBuildTime, ); if (isErr(result)) { console.error(chalk.red(`Error: ${result.error.message}`)); return; } if (options.json) { const out: IJsonEnvAction = { action: "set", key, value, is_buildtime: isBuildTime, is_runtime: !isBuildTime, }; console.log(JSON.stringify(out)); } else { console.log(chalk.green(`✓ Set ${chalk.bold(key)} for ${uuid}`)); } return; } // Handle --get KEY — prints the value only (or full JSON with --json). if (options.get) { const result = await coolify.getEnvironmentVariables(uuid); if (isErr(result)) { console.error(chalk.red(`Error: ${result.error.message}`)); return; } const found = result.value.find((ev) => ev.key === options.get); if (!found) { console.error( chalk.red(`Error: Variable ${options.get} not found`), ); return; } if (options.json) { console.log(JSON.stringify(found)); } else { // Plain value to stdout so it composes well in shell pipelines. process.stdout.write(found.value + "\n"); } return; } // Handle --delete KEY if (options.delete) { const result = await coolify.deleteEnvironmentVariable( uuid, options.delete, ); if (isErr(result)) { console.error(chalk.red(`Error: ${result.error.message}`)); return; } if (options.json) { const out: IJsonEnvAction = { action: "delete", key: options.delete, }; console.log(JSON.stringify(out)); } else { console.log( chalk.green( `✓ Deleted ${chalk.bold(options.delete)} from ${uuid}`, ), ); } return; } // Handle --sync [file|-] if (options.sync !== undefined) { let envFile: string; if (options.sync === true) { // Bare `--sync` (no arg): read from stdin if piped, else from `.env`. if (!process.stdin.isTTY) { envFile = "-"; } else { envFile = ".env"; } } else { // options.sync is now narrowed to string | false. The `false` case // shouldn't happen in practice (commander passes true/string/undefined) // but the type allows it. Treat any non-string as "use .env". envFile = typeof options.sync === "string" ? options.sync : ".env"; } console.log(""); console.log(chalk.cyan.bold(" 🔄 Env Sync")); try { const sdk = getCliSdk(); // Build a custom sync source when reading stdin. let stdinContent: string | undefined; if (envFile === "-") { const { readFileSync } = await import("node:fs"); stdinContent = readFileSync(0, "utf-8"); } const result = await (envFile === "-" ? syncFromContent(sdk, uuid, stdinContent ?? "", { dryRun: options["dry-run"] ?? false, prune: options.prune ?? false, onProgress: makeSyncProgressHandler(uuid), }) : sdk.applications.syncEnv(uuid, { filePath: envFile, dryRun: options["dry-run"] ?? false, prune: options.prune ?? false, onProgress: makeSyncProgressHandler(uuid), })); if (options.json) { console.log(JSON.stringify(result)); return; } const totalChanges = result.added.length + result.updated.length + result.removed.length; if (totalChanges === 0) { console.log(""); console.log(chalk.green(" ✓ All variables are already in sync")); console.log(""); } else { console.log(createChangeSummary(result)); if (options["dry-run"]) { console.log(chalk.yellow(" ⚠ Dry run mode - no changes applied")); } else { console.log(chalk.green(` ✓ Synced ${totalChanges} variable(s)`)); } console.log(""); } } catch (error) { console.error(chalk.red(` ✗ Error: ${error}`)); } return; } // Default: list env vars const result = await coolify.getEnvironmentVariables(uuid); if (isErr(result)) { console.error(chalk.red(`Error: ${result.error.message}`)); return; } const envVars = result.value; if (envVars.length === 0) { if (options.json) { console.log("[]"); } else { console.log(chalk.yellow("No environment variables found")); } return; } // --json short-circuits all human formatting. Use this for piping into jq etc. if (options.json) { console.log(JSON.stringify(envVars)); return; } // Use table view if --table flag, otherwise use highlighted list view if (options.table) { console.log( createEnvTable(envVars, { compact: true, showType: true, }), ); } else { console.log(chalk.cyan(`Environment variables (${envVars.length}):\n`)); // Separar runtime de buildtime const runtimeVars = envVars.filter((ev) => ev.is_runtime); const buildtimeVars = envVars.filter((ev) => ev.is_buildtime); if (runtimeVars.length > 0) { console.log(chalk.yellow.bold("Runtime:")); for (const ev of runtimeVars) { const required = ev.is_required ? chalk.red(" *") : ""; const line = `${ev.key}=${ev.value}`; console.log(` ${highlightEnvLine(line)}${required}`); } console.log(); } if (buildtimeVars.length > 0) { console.log(chalk.blue.bold("Buildtime:")); for (const ev of buildtimeVars) { const line = `${ev.key}=${ev.value}`; console.log(` ${highlightEnvLine(line)}`); } } } } /** * Sync env vars from raw .env-formatted text instead of a file path. * * Mirrors ApplicationsResource.syncEnv but reads from a string, so we can * support `--sync -` reading from stdin. * * @param sdk - Coolify SDK instance * @param uuid - Application UUID * @param content - Raw .env file contents * @param options - Sync options (dryRun, prune, onProgress) * @returns Sync result with added/updated/removed changes */ async function syncFromContent( sdk: ReturnType, uuid: string, content: string, options: { dryRun: boolean; prune: boolean; onProgress?: (update: { type: "add" | "update" | "remove"; key: string; value?: string; }) => void; }, ) { const localVars = parseEnvContent(content); if (localVars.size === 0) { return { added: [], updated: [], removed: [], skipped: 0 }; } const currentVarsList = await sdk.applications.envVars(uuid); const currentVars = new Map(currentVarsList.map((v) => [v.key, v.value])); const toAdd: Array<{ key: string; value: string }> = []; const toUpdate: Array<{ key: string; value: string; oldValue: string }> = []; const toRemove: string[] = []; for (const [key, value] of localVars.entries()) { const current = currentVars.get(key); if (!current) toAdd.push({ key, value }); else if (current !== value) toUpdate.push({ key, value, oldValue: current }); } if (options.prune) { for (const key of currentVars.keys()) { if (!localVars.has(key)) toRemove.push(key); } } if (!options.dryRun) { // Use bulk directly to avoid N+1 PATCH calls (and the per-var bulk // delegation already fixes Bug #1 + #2 for the post-delete case). if (toAdd.length > 0 || toUpdate.length > 0) { // bulkSetEnv throws on API error via the SDK's Result.unwrap(), so // no extra error handling needed here — any failure propagates to // the outer try/catch. await sdk.applications.bulkSetEnv( uuid, [...toAdd, ...toUpdate].map(({ key, value }) => ({ key, value, is_runtime: true, is_buildtime: false, })), ); for (const { key, value } of toAdd) { options.onProgress?.({ type: "add", key, value }); } for (const { key, value } of toUpdate) { options.onProgress?.({ type: "update", key, value }); } } for (const key of toRemove) { await sdk.applications.deleteEnv(uuid, key); options.onProgress?.({ type: "remove", key }); } } else { for (const { key, value } of toAdd) { options.onProgress?.({ type: "add", key, value }); } for (const { key, value } of toUpdate) { options.onProgress?.({ type: "update", key, value }); } for (const key of toRemove) { options.onProgress?.({ type: "remove", key }); } } return { added: toAdd, updated: toUpdate, removed: toRemove, skipped: currentVars.size - toUpdate.length - toRemove.length, }; } /** * Builds a progress handler compatible with syncEnv/syncFromContent. The * spinner lifecycle is owned by the caller; we only print colored per-key * lines (no JSON mode here — JSON mode is handled at the print layer). * * @param uuid - Application UUID (used for spinner text) * @returns Progress callback */ function makeSyncProgressHandler(uuid: string) { let spinner: ReturnType | undefined; return (update: { type: "add" | "update" | "remove"; key: string; value?: string; }) => { if (!spinner) { spinner = createSpinner({ text: `Syncing ${uuid}...`, color: "cyan" }); spinner.start(); } if (update.type === "add") { spinner.succeed( ` ${chalk.green("+")} ${highlightEnvLine(`${update.key}=${update.value ?? ""}`)}`, ); spinner.text = "Syncing..."; spinner.start(); } else if (update.type === "update") { spinner.succeed( ` ${chalk.yellow("~")} ${chalk.bold(update.key)} updated`, ); spinner.text = "Syncing..."; spinner.start(); } else { spinner.succeed(` ${chalk.red("-")} ${chalk.bold(update.key)} removed`); spinner.text = "Syncing..."; spinner.start(); } // Reference `createDiff` so unused-import lint stays quiet without // removing the import (kept for parity with original spinner diff UX). void createDiff; }; }