import Enquirer from "enquirer"; import { validateBuildpackUri } from "../shared/build-protocol"; import { stdin } from "node:process"; import { basename } from "node:path"; import { checkedAppPath, detectAppPaths, projectName, serviceForApp, type AppOptions, type Source, } from "./scaffold"; import { listDockerfileTemplates, templateIds } from "./scaffold-templates"; export type AppInput = Partial> & { source?: "image" | "auto" | "template"; image?: string; template?: string; cnb?: string; nonInteractive?: boolean; }; type Choice = { name: string; message: string }; export type PromptField = { name: string; initial?: string; validate?: (value: unknown) => boolean | string; }; export type ScaffoldQuestion = { type: "select" | "input" | "autocomplete" | "snippet"; name: string; message: string; initial?: string | number; choices?: Choice[]; template?: string; fields?: PromptField[]; required?: string[]; validate?: (value: unknown) => boolean | string | Promise; suggest?: (input: string, choices: Choice[]) => Promise; }; export type ScaffoldPrompt = (question: ScaffoldQuestion) => Promise; export const terminalPrompt: ScaffoldPrompt = async (question) => { if (!stdin.isTTY) throw new Error( `${question.message}: supply flags or use an interactive terminal`, ); // Enquirer's bundled types omit Snippet fields and required field-name arrays. const answers = await new Enquirer().prompt( question as unknown as Parameters[0], ); return (answers as Record)[question.name]; }; function text(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } function validation(check: () => unknown): boolean | string { try { check(); return true; } catch (error) { return error instanceof Error ? error.message : "Invalid value"; } } // Literal preview values must never become Snippet tab stops. function literal(value: string): string { return JSON.stringify(value).replace(/([${#])/g, "\\$1"); } export type ScaffoldContext = { project?: string; defaultProject?: string; existingServices?: string[]; }; /** The preview is presentation only; only validated typed field values leave here. */ export async function resolveScaffoldInput( root: string, input: AppInput, prompt: ScaffoldPrompt = terminalPrompt, context: ScaffoldContext = {}, ): Promise<{ app: AppOptions; project?: string }> { const detected = await detectAppPaths(root); const interactive = !input.nonInteractive; const ask = async (question: ScaffoldQuestion, fallback = "") => interactive ? text(await prompt(question)) : fallback; const defaultSource = detected.length ? "auto" : "image"; const selected = input.source ?? (await ask( { type: "select", name: "source", message: "App source", initial: defaultSource === "auto" ? 1 : 0, choices: [ { name: "image", message: "Container image" }, { name: "auto", message: "Buildpacks (auto)" }, { name: "template", message: "Dockerfile template" }, ], }, defaultSource, )); let source: Source; if (selected === "image") { const image = input.image ?? (await ask( { type: "input", name: "image", message: "Container image", initial: "nginx:stable", validate: (value) => !!text(value) || "Image must not be empty", }, "nginx:stable", )); source = { kind: "image", image }; } else if (selected === "auto") { const uri = input.cnb ?? (await ask({ type: "input", name: "cnb", message: "CNB link (optional; blank for automatic detection)", validate: (value) => validation(() => { if (text(value)) validateBuildpackUri(text(value)); }), })); source = { kind: "auto", ...(uri.trim() ? { uri: uri.trim() } : {}) }; } else if (selected === "template") { const template = input.template ?? (await ask({ type: "select", name: "template", message: "Dockerfile template", initial: 0, choices: listDockerfileTemplates().map(({ id, label }) => ({ name: id, message: label, })), })); if (!templateIds.includes(template as (typeof templateIds)[number])) throw new Error( !interactive && input.template === undefined ? "Dockerfile template is required in non-interactive mode" : `Unknown Dockerfile template: ${template}`, ); source = { kind: "template", template: template as (typeof templateIds)[number], }; } else throw new Error("Source must be image, auto, or template"); let path = input.path; if (path === undefined) { if (!interactive && detected.length > 1) throw new Error( `Multiple app roots found (${detected.join(", ")}); specify --path`, ); const suggested = detected.length === 1 ? detected[0]! : "."; if (interactive) { const paths = [...new Set([suggested, ".", ...detected])]; path = (await ask({ type: "autocomplete", name: "path", message: "App path (optional; . is project root)", choices: paths.map((name) => ({ name, message: name })), initial: 0, validate: async (value) => { try { await checkedAppPath(root, text(value) || suggested); return true; } catch (error) { return error instanceof Error ? error.message : "Invalid app path"; } }, suggest: async (value, choices) => { const matches = choices.filter((choice) => choice.message.toLowerCase().includes(value.toLowerCase()), ); if (value && !choices.some((choice) => choice.name === value)) { try { const valid = await checkedAppPath(root, value); matches.push({ name: valid, message: valid }); } catch { /* Only valid custom directories become choices. */ } } return matches; }, })) || suggested; } else path = suggested; } path = await checkedAppPath(root, path); const suggestedName = basename(path === "." ? root : path) .toLowerCase() .replace(/[^a-z0-9-]/g, "-") .replace(/^-+|-+$/g, "") || "app"; const existing = context.existingServices ?? []; const baseName = ( /^[a-z]/.test(suggestedName) ? suggestedName : `app-${suggestedName}` ) .slice(0, 63) .replace(/-+$/, ""); let defaultName = baseName; for (let suffix = 2; existing.includes(defaultName); suffix++) defaultName = `${baseName.slice(0, 62 - String(suffix).length)}-${suffix}`; const defaults: Record = { name: input.name ?? defaultName, postgres: input.postgres ?? "", s3: input.s3 ?? "", cpu: input.cpu ?? "", memory: input.memory ?? "", replicas: input.replicas?.toString() ?? (interactive ? "1" : ""), ...(context.defaultProject ? { project: context.project ?? context.defaultProject } : {}), }; const locked: Record = { name: input.name, postgres: input.postgres, s3: input.s3, cpu: input.cpu, memory: input.memory, replicas: input.replicas?.toString(), project: context.project, }; const toApp = (values: Record): AppOptions => ({ name: values.name!, path, source, ...(values.postgres ? { postgres: values.postgres } : {}), ...(values.s3 ? { s3: values.s3 } : {}), ...(values.cpu ? { cpu: values.cpu } : {}), ...(values.memory ? { memory: values.memory } : {}), ...(values.replicas ? { replicas: Number(values.replicas) } : {}), }); const checkField = (name: string, value: unknown) => validation(() => { const valueText = text(value); if (name === "project") { if (!valueText || projectName(valueText) !== valueText) throw new Error( "Project must be a lowercase Kubernetes name (max 63)", ); } else { if (name === "name" && existing.includes(valueText)) throw new Error(`Service ${valueText} already exists`); serviceForApp(toApp({ ...defaults, [name]: valueText })); } }); const placeholder = (name: string) => locked[name] !== undefined ? literal(locked[name]!) : `\${${name}}`; const service = serviceForApp({ name: "app", path, source }); const sourceLine = source.kind === "image" ? `image: ${literal(String(service.image))}` : `build: ${literal(String(service.build))}`; const template = [ ...(context.defaultProject ? [`name: ${placeholder("project")}`] : []), "services:", ...existing.map( (name) => ` ${/^[a-z][a-z0-9-]*$/.test(name) ? name : literal(name)}: ...`, ), ` ${placeholder("name")}:`, ` ${sourceLine}`, ...(service["x-kuber-build-context"] ? [` x-kuber-build-context: ${literal(path)}`] : []), " volumes:", ` - postgresql:${placeholder("postgres")}`, ` - s3:${placeholder("s3")}`, " deploy:", ` replicas: ${placeholder("replicas")}`, " resources:", " limits:", ` cpus: ${placeholder("cpu")}`, ` memory: ${placeholder("memory")}`, ].join("\n"); let values = defaults; if (interactive) { const fields = Object.keys(defaults) .filter((name) => locked[name] === undefined) .map((name) => ({ name, initial: defaults[name], validate: (value: unknown) => checkField(name, value), })); if (fields.length) { const response = await prompt({ type: "snippet", name: "compose", message: "Compose service (blank optional fields are omitted)", template, fields, required: fields .filter(({ name }) => name === "name" || name === "project") .map(({ name }) => name), }); if ( !response || typeof response !== "object" || !("values" in response) || !response.values || typeof response.values !== "object" ) throw new Error("Snippet did not return field values"); const supplied = response.values as Record; values = { ...defaults }; for (const field of fields) values[field.name] = text(supplied[field.name]); } } const app = toApp(values); serviceForApp(app); if (existing.includes(app.name)) throw new Error(`Service ${app.name} already exists`); const project = values.project; if (project && interactive && projectName(project) !== project) throw new Error("Project must be a lowercase Kubernetes name (max 63)"); if (context.defaultProject && !project) throw new Error("Project name must not be empty"); return { app, ...(project ? { project: projectName(project) } : {}) }; }