import path from "node:path"; import { Result, TaggedError } from "better-result"; import { frameworkByKey, isConfigBackedBuildType } from "./frameworks.ts"; import { COMPUTE_FRAMEWORKS, type ComputeFramework } from "./types.ts"; export interface ComputeDeployTargetBuild { /** Build command, null to skip the build step, undefined when not configured. */ command: string | null | undefined; /** Normalized output path relative to the app root, undefined when not configured. */ outputDirectory: string | undefined; } export interface ComputeDeployTarget { /** `apps` map key, or null for a single-app `app` config. */ key: string | null; name: string | null; /** Normalized app directory relative to the config file, or null for the config directory. */ root: string | null; framework: ComputeFramework | null; entry: string | null; httpPort: number | null; /** Env inputs in deploy order: dotenv file paths first, then NAME=VALUE assignments. */ envInputs: string[]; /** Build settings; non-null means the config owns build configuration. */ build: ComputeDeployTargetBuild | null; } export interface LoadedComputeConfig { configPath: string; /** Directory containing the config file. Config-relative paths resolve from here. */ configDir: string; relativeConfigPath: string; kind: "single" | "multi"; targets: ComputeDeployTarget[]; } export class ComputeConfigInvalidError extends TaggedError( "ComputeConfigInvalidError", )<{ message: string; configPath: string; issues: string[]; }>() { constructor(configPath: string, issues: string[]) { super({ message: `${path.basename(configPath)} is invalid: ${issues.join(" ")}`, configPath, issues, }); } } export class ComputeConfigTargetRequiredError extends TaggedError( "ComputeConfigTargetRequiredError", )<{ message: string; configPath: string; availableTargets: string[]; }>() { constructor(configPath: string, availableTargets: string[]) { super({ message: `${path.basename(configPath)} defines multiple apps. Pass a target: ${availableTargets.join(", ")}.`, configPath, availableTargets, }); } } export class ComputeConfigTargetUnknownError extends TaggedError( "ComputeConfigTargetUnknownError", )<{ message: string; configPath: string; requestedTarget: string; availableTargets: string[]; }>() { constructor( configPath: string, requestedTarget: string, availableTargets: string[], ) { super({ message: `${path.basename(configPath)} does not define an app named "${requestedTarget}". Available: ${availableTargets.join(", ")}.`, configPath, requestedTarget, availableTargets, }); } } export type ComputeConfigTargetError = | ComputeConfigTargetRequiredError | ComputeConfigTargetUnknownError; const KNOWN_APP_KEYS = [ "name", "root", "framework", "entry", "httpPort", "env", "build", ] as const; const KNOWN_ENV_KEYS = ["file", "vars"] as const; const KNOWN_BUILD_KEYS = ["command", "outputDirectory"] as const; /** * Validates and normalizes a config module's default export. Reports every * issue at once so authors fix the file in one pass. */ export function normalizeComputeConfig( exported: unknown, configPath: string, ): Result { const issues: string[] = []; const targets: ComputeDeployTarget[] = []; let kind: LoadedComputeConfig["kind"] = "single"; if (!isPlainObject(exported)) { issues.push( "The config must `export default defineComputeConfig({ ... })` with an object value.", ); } else { const hasApp = exported.app !== undefined; const hasApps = exported.apps !== undefined; for (const key of Object.keys(exported)) { if (key !== "app" && key !== "apps") { issues.push( `Unknown top-level key "${key}". Expected "app" or "apps".`, ); } } if (hasApp && hasApps) { issues.push( "Use either `app` (single app) or `apps` (multi-app), not both.", ); } else if (hasApp) { const target = normalizeAppEntry(exported.app, "app", null, issues); if (target) { targets.push(target); } } else if (hasApps) { kind = "multi"; if (!isPlainObject(exported.apps)) { issues.push("`apps` must be an object keyed by deploy target name."); } else { const entries = Object.entries(exported.apps); if (entries.length === 0) { issues.push("`apps` must define at least one app."); } for (const [key, value] of entries) { if (key.trim().length === 0) { issues.push("`apps` keys must be non-empty target names."); continue; } const target = normalizeAppEntry(value, `apps.${key}`, key, issues); if (target) { targets.push(target); } } } } else { issues.push( "Define `app` for a single-app repository or `apps` for a multi-app repository.", ); } } if (issues.length > 0) { return Result.err(new ComputeConfigInvalidError(configPath, issues)); } return Result.ok({ configPath, configDir: path.dirname(configPath), relativeConfigPath: path.basename(configPath), kind, targets, }); } /** Absolute app directory of a config target. */ export function computeTargetAppDir( config: LoadedComputeConfig, target: ComputeDeployTarget, ): string { return path.resolve(config.configDir, target.root ?? "."); } /** * Selects a deploy target. Single-app configs return their app (a requested * target must then match the configured `name`); multi-app configs require a * matching key unless they hold exactly one target. */ export function selectComputeDeployTarget( config: LoadedComputeConfig, requestedTarget: string | undefined, ): Result { if (config.kind === "single") { const target = config.targets[0]; if (!target) { return Result.err( new ComputeConfigTargetRequiredError(config.configPath, []), ); } if (requestedTarget && requestedTarget !== target.name) { return Result.err( new ComputeConfigTargetUnknownError( config.configPath, requestedTarget, target.name ? [target.name] : [], ), ); } return Result.ok(target); } const availableTargets = config.targets.map((target) => target.key ?? ""); if (!requestedTarget) { const only = config.targets[0]; if (config.targets.length === 1 && only) { return Result.ok(only); } return Result.err( new ComputeConfigTargetRequiredError(config.configPath, availableTargets), ); } const matched = config.targets.find( (target) => target.key === requestedTarget, ); if (!matched) { return Result.err( new ComputeConfigTargetUnknownError( config.configPath, requestedTarget, availableTargets, ), ); } return Result.ok(matched); } /** * Infers the deploy target whose app directory contains `cwd`, so commands * run from inside a target's root select it without a target argument. The * deepest matching root wins; an ambiguous tie infers nothing. */ export function inferComputeTargetFromCwd( config: LoadedComputeConfig, cwd: string, ): string | undefined { if (config.kind !== "multi") { return undefined; } const resolvedCwd = path.resolve(cwd); let bestKey: string | undefined; let bestDepth = -1; let bestIsTied = false; for (const target of config.targets) { const appDir = computeTargetAppDir(config, target); const relative = path.relative(appDir, resolvedCwd); if (relative.startsWith("..") || path.isAbsolute(relative)) { continue; } const depth = appDir.split(path.sep).length; if (depth > bestDepth) { bestKey = target.key ?? undefined; bestDepth = depth; bestIsTied = false; } else if (depth === bestDepth) { bestIsTied = true; } } return bestIsTied ? undefined : bestKey; } function normalizeAppEntry( value: unknown, label: string, key: string | null, issues: string[], ): ComputeDeployTarget | null { if (!isPlainObject(value)) { issues.push(`\`${label}\` must be an object.`); return null; } for (const entryKey of Object.keys(value)) { if (!(KNOWN_APP_KEYS as readonly string[]).includes(entryKey)) { issues.push( `Unknown key "${entryKey}" in \`${label}\`. Expected one of: ${KNOWN_APP_KEYS.join(", ")}.`, ); } } const name = readOptionalNonEmptyString(value.name, `${label}.name`, issues); const entry = readOptionalNonEmptyString( value.entry, `${label}.entry`, issues, ); let root: string | null = null; if (value.root !== undefined) { const rawRoot = readOptionalNonEmptyString( value.root, `${label}.root`, issues, ); if (rawRoot) { const normalized = normalizeRelativePath(rawRoot)?.replace(/\/+$/, ""); if (!normalized) { issues.push( `\`${label}.root\` must be a relative path inside the repository.`, ); } else if (normalized !== ".") { root = normalized; } } } let framework: ComputeFramework | null = null; if (value.framework !== undefined) { if ( typeof value.framework === "string" && (COMPUTE_FRAMEWORKS as readonly string[]).includes(value.framework) ) { framework = value.framework as ComputeFramework; } else { issues.push( `\`${label}.framework\` must be one of: ${COMPUTE_FRAMEWORKS.join(", ")}.`, ); } } if (entry && framework && !frameworkByKey(framework).usesEntrypoint) { issues.push( `\`${label}.entry\` is not supported with the ${framework} framework; it derives its entrypoint from build output.`, ); } let httpPort: number | null = null; if (value.httpPort !== undefined) { if ( typeof value.httpPort === "number" && Number.isInteger(value.httpPort) && value.httpPort > 0 && value.httpPort <= 65535 ) { httpPort = value.httpPort; } else { issues.push( `\`${label}.httpPort\` must be an integer between 1 and 65535.`, ); } } const envInputs = normalizeEnvConfig(value.env, `${label}.env`, issues); const build = normalizeBuildConfig(value.build, `${label}.build`, issues); if ( build && framework && !isConfigBackedBuildType(frameworkByKey(framework).buildType) ) { issues.push( `\`${label}.build\` is not supported with the ${framework} framework; its build runs automatically during deploy.`, ); } return { key, name, root, framework, entry, httpPort, envInputs, build, }; } function normalizeBuildConfig( value: unknown, label: string, issues: string[], ): ComputeDeployTargetBuild | null { if (value === undefined) { return null; } if (!isPlainObject(value)) { issues.push( `\`${label}\` must be an object with \`command\` and/or \`outputDirectory\`.`, ); return null; } for (const buildKey of Object.keys(value)) { if (!(KNOWN_BUILD_KEYS as readonly string[]).includes(buildKey)) { issues.push( `Unknown key "${buildKey}" in \`${label}\`. Expected one of: ${KNOWN_BUILD_KEYS.join(", ")}.`, ); } } let command: string | null | undefined; if (value.command !== undefined) { if (value.command === null) { command = null; } else if ( typeof value.command === "string" && value.command.trim().length > 0 ) { command = value.command.trim(); } else { issues.push( `\`${label}.command\` must be a non-empty string, or null to skip the build step.`, ); } } let outputDirectory: string | undefined; if (value.outputDirectory !== undefined) { const normalized = typeof value.outputDirectory === "string" ? normalizeRelativePath(value.outputDirectory)?.replace(/\/+$/, "") : undefined; if (!normalized) { issues.push( `\`${label}.outputDirectory\` must be a relative path inside the app root.`, ); } else { outputDirectory = normalized; } } if (command === undefined && outputDirectory === undefined) { issues.push( `\`${label}\` must set \`command\` and/or \`outputDirectory\`.`, ); return null; } return { command, outputDirectory }; } function normalizeEnvConfig( value: unknown, label: string, issues: string[], ): string[] { if (value === undefined) { return []; } if (typeof value === "string") { const file = value.trim(); if (file.length === 0) { issues.push( `\`${label}\` must be a non-empty dotenv file path when given as a string.`, ); return []; } return [file]; } if (!isPlainObject(value)) { issues.push( `\`${label}\` must be a dotenv file path or an object with \`file\` and/or \`vars\`.`, ); return []; } for (const envKey of Object.keys(value)) { if (!(KNOWN_ENV_KEYS as readonly string[]).includes(envKey)) { issues.push( `Unknown key "${envKey}" in \`${label}\`. Expected one of: ${KNOWN_ENV_KEYS.join(", ")}.`, ); } } const envInputs: string[] = []; if (value.file !== undefined) { const files = Array.isArray(value.file) ? value.file : [value.file]; for (const file of files) { if (typeof file !== "string" || file.trim().length === 0) { issues.push( `\`${label}.file\` must be a non-empty dotenv file path or an array of them.`, ); continue; } envInputs.push(file.trim()); } } if (value.vars !== undefined) { if (!isPlainObject(value.vars)) { issues.push(`\`${label}.vars\` must be an object of NAME: value pairs.`); } else { for (const [varName, varValue] of Object.entries(value.vars)) { if (typeof varValue !== "string" || varValue.length === 0) { issues.push( `\`${label}.vars.${varName}\` must be a non-empty string.`, ); continue; } envInputs.push(`${varName}=${varValue}`); } } } return envInputs; } function readOptionalNonEmptyString( value: unknown, label: string, issues: string[], ): string | null { if (value === undefined) { return null; } if (typeof value !== "string" || value.trim().length === 0) { issues.push(`\`${label}\` must be a non-empty string.`); return null; } return value.trim(); } function isPlainObject(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function normalizeRelativePath(value: string): string | undefined { const raw = value.trim().replace(/\\/g, "/"); if (raw.length === 0 || raw.split("/").includes("..")) { return undefined; } // Windows drive-relative paths ("C:dir") escape the config directory but // are not absolute under either path.win32 or path.posix. if (/^[A-Za-z]:/.test(raw)) { return undefined; } const normalized = path.posix.normalize(raw); const segments = normalized.split("/"); if ( path.win32.isAbsolute(value) || path.posix.isAbsolute(normalized) || segments.includes("..") ) { return undefined; } return normalized === "." ? "." : normalized; }