import { readFileSync } from "node:fs"; import { parse } from "yaml"; import { z } from "zod"; const AppSpecSchema = z .object({ source: z.object({ repo: z.string(), branch: z.string() }).strict(), build: z .object({ pack: z.enum(["nixpacks", "static", "dockerfile", "dockercompose"]), base_directory: z.string(), publish_directory: z.string().optional(), compose_file: z.string().optional(), }) .strict(), port: z.number().int().optional(), healthcheck: z.string().optional(), domains: z.array(z.string()).optional(), service_domains: z.record(z.array(z.string())).optional(), env_template: z.string().optional(), }) .strict() .superRefine((app, ctx) => { if (app.build.pack === "dockercompose") { if (!app.build.compose_file) ctx.addIssue({ code: "custom", message: "dockercompose apps require build.compose_file", }); if (!app.service_domains) ctx.addIssue({ code: "custom", message: "dockercompose apps require service_domains", }); for (const k of ["port", "healthcheck", "domains"] as const) if (app[k] !== undefined) ctx.addIssue({ code: "custom", message: `${k} not allowed on a dockercompose app (lives in the compose file)`, }); if (app.build.publish_directory) ctx.addIssue({ code: "custom", message: "publish_directory not allowed on a dockercompose app", }); } else { if (!app.domains) ctx.addIssue({ code: "custom", message: "domains required (non-compose app)", }); if (app.service_domains || app.build.compose_file) ctx.addIssue({ code: "custom", message: "service_domains/compose_file only allowed with pack dockercompose", }); } }); const DatabaseSpecSchema = z .object({ type: z.enum(["postgresql", "redis"]), version: z.string().optional(), backup: z .object({ frequency: z.string(), retention: z.number().int() }) .strict() .optional(), }) .strict(); const ServiceSpecSchema = z .object({ type: z.string(), domains: z.array(z.string()).optional(), env_template: z.string().optional(), }) .strict(); const EnvironmentSpecSchema = z .object({ applications: z.record(AppSpecSchema), databases: z.record(DatabaseSpecSchema).optional(), services: z.record(ServiceSpecSchema).optional(), }) .strict(); const ManifestSchema = z .object({ project: z.string(), environments: z.record(EnvironmentSpecSchema), }) .strict(); export type AppSpec = z.infer; export type DatabaseSpec = z.infer; export type ServiceSpec = z.infer; export type EnvironmentSpec = z.infer; export type Manifest = z.infer; export function loadManifest( path: string, opts: { overrideText?: string } = {}, ): Manifest { const text = opts.overrideText ?? readFileSync(path, "utf8"); const result = ManifestSchema.safeParse(parse(text)); if (!result.success) { throw new Error( `invalid manifest ${path}: ${result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`, ); } return result.data; }