import { basename, isAbsolute, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import type { KuberConfig, KuberHookContext } from "../types"; import { IMAGE_REGISTRY } from "../const"; const DEFAULT_CONFIG_FILE = ".kuberrc.ts"; export const DEFAULT_REGISTRY = IMAGE_REGISTRY; export const DEFAULT_ROLLOUT_TIMEOUT_MS = 300_000; type Hooks = Pick< KuberConfig, "compose" | "preBuild" | "postBuild" | "postRender" | "postApply" >; export type ResolvedKuberConfig = Hooks & { project: string; projectConfigured: boolean; composeFile?: string; registry: string; rolloutTimeoutMs: number; configFile?: string; }; function requireNonEmptyString(value: unknown, field: string): string { if (typeof value !== "string" || value.trim().length === 0) { throw new Error(`${field} must be a non-empty string`); } return value.trim(); } function validateConfig(config: unknown, file: string): KuberConfig { if (!config || typeof config !== "object" || Array.isArray(config)) { throw new Error(`${file} must default export a configuration object`); } const value = config as Record; for (const field of [ "compose", "preBuild", "postBuild", "postRender", "postApply", ]) { if (value[field] !== undefined && typeof value[field] !== "function") { throw new Error(`${field} in ${file} must be a function`); } } if (value.project !== undefined) requireNonEmptyString(value.project, "project"); if (value.composeFile !== undefined) requireNonEmptyString(value.composeFile, "composeFile"); if (value.registry !== undefined) requireNonEmptyString(value.registry, "registry"); if ( value.rolloutTimeoutMs !== undefined && (typeof value.rolloutTimeoutMs !== "number" || !Number.isFinite(value.rolloutTimeoutMs) || value.rolloutTimeoutMs <= 0) ) { throw new Error(`rolloutTimeoutMs in ${file} must be a positive number`); } return config as KuberConfig; } export function extractConfigArgument(args: string[]): { configPath?: string; rawArgs: string[]; } { let configPath: string | undefined; const rawArgs: string[] = []; for (let index = 0; index < args.length; index += 1) { const argument = args[index]!; if (argument === "--") { rawArgs.push(...args.slice(index)); break; } if (argument.startsWith("--config=")) { const value = argument.slice("--config=".length); if (!value) throw new Error("--config requires a path"); if (configPath) throw new Error("--config may only be specified once"); configPath = value; continue; } if (argument === "--config") { const value = args[index + 1]; if (!value || value.startsWith("-")) { throw new Error(`${argument} requires a path`); } if (configPath) throw new Error("--config may only be specified once"); configPath = value; index += 1; continue; } rawArgs.push(argument); } return { configPath, rawArgs }; } export function resolveConfigArgument(args: string[]): string | undefined { return extractConfigArgument(args).configPath; } export async function loadConfig( cwd: string, requestedPath?: string, ): Promise { const configFile = resolve(cwd, requestedPath ?? DEFAULT_CONFIG_FILE); const exists = await Bun.file(configFile).exists(); if (!exists && requestedPath) { throw new Error(`Config file cannot be found: ${configFile}`); } const raw = exists ? validateConfig( (await import(pathToFileURL(configFile).href)).default, configFile, ) : {}; const composeFile = raw.composeFile?.trim(); const registry = (raw.registry?.trim() ?? DEFAULT_REGISTRY).replace( /\/+$/, "", ); if (!registry) throw new Error(`registry in ${configFile} cannot be empty`); return { project: raw.project?.trim() ?? basename(cwd), projectConfigured: raw.project !== undefined, composeFile: composeFile ? isAbsolute(composeFile) ? composeFile : resolve(cwd, composeFile) : undefined, registry, rolloutTimeoutMs: raw.rolloutTimeoutMs ?? DEFAULT_ROLLOUT_TIMEOUT_MS, configFile: exists ? configFile : undefined, compose: raw.compose, preBuild: raw.preBuild, postBuild: raw.postBuild, postRender: raw.postRender, postApply: raw.postApply, }; } export function hookContext( config: ResolvedKuberConfig, cwd: string, composeFile: string, ): KuberHookContext { return { cwd, project: config.project, composeFile, configFile: config.configFile, }; }