import { existsSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; export interface UniDeskConfig { project: { name: string; timezone: string }; runtime: { typescript: "bun"; bunVersion: string }; network: { host: string; publicHost: string; core: { port: number; containerPort: number }; frontend: { port: number; containerPort: number }; devFrontend: { port: number; containerPort: number }; database: { port: number; containerPort: number }; providerIngress: { port: number; containerPort: number }; providerData: { port: number; containerPort: number }; restrictedHostAccess?: { bindHost: string; allowedSourceCidrs: string[] }; }; auth: { username: string; password: string; sessionSecret: string; sessionTtlSeconds: number }; database: { user: string; password: string; name: string; volume: string; volumeSize: string }; providerGateway: { id: string; name: string; token: string; labels: Record; heartbeatIntervalMs: number; reconnectBaseMs: number; reconnectMaxMs: number; metrics: { diskPath: string }; upgrade: { hostProjectRoot: string; workspacePath: string; composeFile: string; composeEnvFile: string; composeProject: string; service: string; runnerImage: string; }; }; docker: { composeFile: string; projectName: string }; microservices: UniDeskMicroserviceConfig[]; paths: { stateDir: string; logsDir: string; docsReferenceDir: string }; sshForwarding: { mode: string; keyDir: string; host: string; port: number; user: string }; } export interface UniDeskMicroserviceConfig { id: string; name: string; providerId: string; description: string; repository: { url: string; commitId: string; dockerfile: string; artifactSource?: { kind: "upstream-image"; imageRef: string; digestPinRequired: boolean; mirrorRepository: string; ciDockerfileBuild: boolean; pullOnlyCd: boolean; }; composeFile: string; composeService: string; containerName: string; }; backend: { nodeBaseUrl: string; nodeBindHost: string; nodePort: number; proxyMode: string; frontendOnly: boolean; public: boolean; allowedMethods: string[]; allowedPathPrefixes: string[]; healthPath: string; timeoutMs: number; }; deployment: { mode: "unidesk-direct" | "k3sctl-managed" | "internal-sidecar"; adapterServiceId?: string; k3sServiceId?: string; namespace?: string; expectedNodeIds?: string[]; activeNodeId?: string; }; development: { providerId: string; sshPassthrough: boolean; worktreePath: string; }; frontend: { route: string; integrated: boolean; }; } const moduleDir = dirname(fileURLToPath(import.meta.url)); export const repoRoot = join(moduleDir, "..", ".."); export function rootPath(...parts: string[]): string { return join(repoRoot, ...parts); } function asRecord(value: unknown, name: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { throw new Error(`${name} must be an object`); } return value as Record; } function stringField(obj: Record, key: string, path: string): string { const value = obj[key]; if (typeof value !== "string" || value.length === 0) throw new Error(`${path}.${key} must be a non-empty string`); return value; } function numberField(obj: Record, key: string, path: string): number { const value = obj[key]; if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw new Error(`${path}.${key} must be a positive number`); return value; } function portPair(obj: Record, key: string): { port: number; containerPort: number } { const value = asRecord(obj[key], `network.${key}`); return { port: numberField(value, "port", `network.${key}`), containerPort: numberField(value, "containerPort", `network.${key}`) }; } function booleanField(obj: Record, key: string, path: string): boolean { const value = obj[key]; if (typeof value !== "boolean") throw new Error(`${path}.${key} must be a boolean`); return value; } function optionalStringField(obj: Record, key: string, path: string): string | undefined { const value = obj[key]; if (value === undefined) return undefined; if (typeof value !== "string" || value.length === 0) throw new Error(`${path}.${key} must be a non-empty string`); return value; } function optionalArray(value: unknown, name: string): Record[] { if (value === undefined) return []; if (!Array.isArray(value)) throw new Error(`${name} must be an array`); return value.map((item, index) => asRecord(item, `${name}[${index}]`)); } function optionalStringArrayField(obj: Record, key: string, path: string): string[] | undefined { const value = obj[key]; if (value === undefined) return undefined; return stringArrayField(obj, key, path); } function stringArrayField(obj: Record, key: string, path: string): string[] { const value = obj[key]; if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0)) { throw new Error(`${path}.${key} must be an array of non-empty strings`); } return value as string[]; } function optionalArtifactSource(repository: Record, path: string): UniDeskMicroserviceConfig["repository"]["artifactSource"] { const value = repository.artifactSource; if (value === undefined) return undefined; const source = asRecord(value, `${path}.artifactSource`); const kind = stringField(source, "kind", `${path}.artifactSource`); if (kind !== "upstream-image") throw new Error(`${path}.artifactSource.kind must be upstream-image`); return { kind, imageRef: stringField(source, "imageRef", `${path}.artifactSource`), digestPinRequired: booleanField(source, "digestPinRequired", `${path}.artifactSource`), mirrorRepository: stringField(source, "mirrorRepository", `${path}.artifactSource`), ciDockerfileBuild: booleanField(source, "ciDockerfileBuild", `${path}.artifactSource`), pullOnlyCd: booleanField(source, "pullOnlyCd", `${path}.artifactSource`), }; } function optionalRestrictedHostAccess(network: Record): UniDeskConfig["network"]["restrictedHostAccess"] { const value = network.restrictedHostAccess; if (value === undefined) return undefined; const record = asRecord(value, "network.restrictedHostAccess"); const allowedSourceCidrs = stringArrayField(record, "allowedSourceCidrs", "network.restrictedHostAccess"); if (allowedSourceCidrs.length === 0) throw new Error("network.restrictedHostAccess.allowedSourceCidrs must not be empty"); return { bindHost: stringField(record, "bindHost", "network.restrictedHostAccess"), allowedSourceCidrs, }; } function microserviceConfig(item: Record, index: number): UniDeskMicroserviceConfig { const path = `microservices[${index}]`; const repository = asRecord(item.repository, `${path}.repository`); const backend = asRecord(item.backend, `${path}.backend`); const development = asRecord(item.development, `${path}.development`); const frontend = asRecord(item.frontend, `${path}.frontend`); const deployment = item.deployment === undefined ? undefined : asRecord(item.deployment, `${path}.deployment`); const deploymentMode = deployment === undefined ? "unidesk-direct" : stringField(deployment, "mode", `${path}.deployment`); if (deploymentMode !== "unidesk-direct" && deploymentMode !== "k3sctl-managed" && deploymentMode !== "internal-sidecar") { throw new Error(`${path}.deployment.mode must be unidesk-direct, k3sctl-managed, or internal-sidecar`); } return { id: stringField(item, "id", path), name: stringField(item, "name", path), providerId: stringField(item, "providerId", path), description: stringField(item, "description", path), repository: { url: stringField(repository, "url", `${path}.repository`), commitId: stringField(repository, "commitId", `${path}.repository`), dockerfile: stringField(repository, "dockerfile", `${path}.repository`), artifactSource: optionalArtifactSource(repository, `${path}.repository`), composeFile: stringField(repository, "composeFile", `${path}.repository`), composeService: stringField(repository, "composeService", `${path}.repository`), containerName: stringField(repository, "containerName", `${path}.repository`), }, backend: { nodeBaseUrl: stringField(backend, "nodeBaseUrl", `${path}.backend`), nodeBindHost: stringField(backend, "nodeBindHost", `${path}.backend`), nodePort: numberField(backend, "nodePort", `${path}.backend`), proxyMode: stringField(backend, "proxyMode", `${path}.backend`), frontendOnly: booleanField(backend, "frontendOnly", `${path}.backend`), public: booleanField(backend, "public", `${path}.backend`), allowedMethods: stringArrayField(backend, "allowedMethods", `${path}.backend`), allowedPathPrefixes: stringArrayField(backend, "allowedPathPrefixes", `${path}.backend`), healthPath: stringField(backend, "healthPath", `${path}.backend`), timeoutMs: numberField(backend, "timeoutMs", `${path}.backend`), }, deployment: deployment === undefined ? { mode: "unidesk-direct" } : { mode: deploymentMode, adapterServiceId: optionalStringField(deployment, "adapterServiceId", `${path}.deployment`), k3sServiceId: optionalStringField(deployment, "k3sServiceId", `${path}.deployment`), namespace: optionalStringField(deployment, "namespace", `${path}.deployment`), expectedNodeIds: optionalStringArrayField(deployment, "expectedNodeIds", `${path}.deployment`), activeNodeId: optionalStringField(deployment, "activeNodeId", `${path}.deployment`), }, development: { providerId: stringField(development, "providerId", `${path}.development`), sshPassthrough: booleanField(development, "sshPassthrough", `${path}.development`), worktreePath: stringField(development, "worktreePath", `${path}.development`), }, frontend: { route: stringField(frontend, "route", `${path}.frontend`), integrated: booleanField(frontend, "integrated", `${path}.frontend`), }, }; } export function readConfig(): UniDeskConfig { const configPath = rootPath("config.json"); if (!existsSync(configPath)) throw new Error(`config.json not found at ${configPath}`); const raw = readFileSync(configPath, "utf8"); const parsed = asRecord(JSON.parse(raw) as unknown, "config.json"); const project = asRecord(parsed.project, "project"); const runtime = asRecord(parsed.runtime, "runtime"); const network = asRecord(parsed.network, "network"); const database = asRecord(parsed.database, "database"); const auth = asRecord(parsed.auth, "auth"); const providerGateway = asRecord(parsed.providerGateway, "providerGateway"); const docker = asRecord(parsed.docker, "docker"); const microservices = optionalArray(parsed.microservices, "microservices").map(microserviceConfig); const paths = asRecord(parsed.paths, "paths"); const sshForwarding = asRecord(parsed.sshForwarding, "sshForwarding"); const labels = asRecord(providerGateway.labels, "providerGateway.labels"); const providerMetrics = asRecord(providerGateway.metrics, "providerGateway.metrics"); const providerUpgrade = asRecord(providerGateway.upgrade, "providerGateway.upgrade"); const typescript = stringField(runtime, "typescript", "runtime"); if (typescript !== "bun") throw new Error("runtime.typescript must be bun"); return { project: { name: stringField(project, "name", "project"), timezone: stringField(project, "timezone", "project") }, runtime: { typescript, bunVersion: stringField(runtime, "bunVersion", "runtime") }, network: { host: stringField(network, "host", "network"), publicHost: stringField(network, "publicHost", "network"), core: portPair(network, "core"), frontend: portPair(network, "frontend"), devFrontend: portPair(network, "devFrontend"), database: portPair(network, "database"), providerIngress: portPair(network, "providerIngress"), providerData: portPair(network, "providerData"), restrictedHostAccess: optionalRestrictedHostAccess(network), }, database: { user: stringField(database, "user", "database"), password: stringField(database, "password", "database"), name: stringField(database, "name", "database"), volume: stringField(database, "volume", "database"), volumeSize: stringField(database, "volumeSize", "database"), }, providerGateway: { id: stringField(providerGateway, "id", "providerGateway"), name: stringField(providerGateway, "name", "providerGateway"), token: stringField(providerGateway, "token", "providerGateway"), labels, heartbeatIntervalMs: numberField(providerGateway, "heartbeatIntervalMs", "providerGateway"), reconnectBaseMs: numberField(providerGateway, "reconnectBaseMs", "providerGateway"), reconnectMaxMs: numberField(providerGateway, "reconnectMaxMs", "providerGateway"), metrics: { diskPath: stringField(providerMetrics, "diskPath", "providerGateway.metrics"), }, upgrade: { hostProjectRoot: stringField(providerUpgrade, "hostProjectRoot", "providerGateway.upgrade"), workspacePath: stringField(providerUpgrade, "workspacePath", "providerGateway.upgrade"), composeFile: stringField(providerUpgrade, "composeFile", "providerGateway.upgrade"), composeEnvFile: stringField(providerUpgrade, "composeEnvFile", "providerGateway.upgrade"), composeProject: stringField(providerUpgrade, "composeProject", "providerGateway.upgrade"), service: stringField(providerUpgrade, "service", "providerGateway.upgrade"), runnerImage: stringField(providerUpgrade, "runnerImage", "providerGateway.upgrade"), }, }, docker: { composeFile: stringField(docker, "composeFile", "docker"), projectName: stringField(docker, "projectName", "docker") }, microservices, paths: { stateDir: stringField(paths, "stateDir", "paths"), logsDir: stringField(paths, "logsDir", "paths"), docsReferenceDir: stringField(paths, "docsReferenceDir", "paths"), }, sshForwarding: { mode: stringField(sshForwarding, "mode", "sshForwarding"), keyDir: stringField(sshForwarding, "keyDir", "sshForwarding"), host: stringField(sshForwarding, "host", "sshForwarding"), port: numberField(sshForwarding, "port", "sshForwarding"), user: stringField(sshForwarding, "user", "sshForwarding"), }, auth: { username: stringField(auth, "username", "auth"), password: stringField(auth, "password", "auth"), sessionSecret: stringField(auth, "sessionSecret", "auth"), sessionTtlSeconds: numberField(auth, "sessionTtlSeconds", "auth"), }, }; }