336 lines
11 KiB
TypeScript
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) } : {}) };
|
|
}
|