Files
kuber/lib/scaffold-prompts.ts
T
2026-10-07 05:10:59 +00:00

336 lines
11 KiB
TypeScript

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<Omit<AppOptions, "source">> & {
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<boolean | string>;
suggest?: (input: string, choices: Choice[]) => Promise<Choice[]>;
};
export type ScaffoldPrompt = (question: ScaffoldQuestion) => Promise<unknown>;
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<Enquirer["prompt"]>[0],
);
return (answers as Record<string, unknown>)[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<string, string> = {
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<string, string | undefined> = {
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<string, string>): 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<string, unknown>;
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) } : {}) };
}