feat: release 2.7.0-rc3
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
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) } : {}) };
|
||||
}
|
||||
+13
-5
@@ -11,6 +11,7 @@ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { YAML } from "bun";
|
||||
import type { Service } from "../schema/docker.d";
|
||||
import { validateBuildpackUri } from "../shared/build-protocol";
|
||||
import { readCompose, resolveComposeFile } from "./yaml";
|
||||
import {
|
||||
renderDockerfileTemplate,
|
||||
@@ -44,7 +45,7 @@ const serviceNamePattern = /^[a-z][a-z0-9-]*$/;
|
||||
|
||||
export type Source =
|
||||
| { kind: "image"; image: string }
|
||||
| { kind: "auto" }
|
||||
| { kind: "auto"; uri?: string }
|
||||
| { kind: "template"; template: DockerfileTemplateId };
|
||||
export type AppOptions = {
|
||||
name: string;
|
||||
@@ -131,12 +132,19 @@ export function serviceForApp(app: AppOptions): Service {
|
||||
if (!app.source.image.trim()) throw new Error("Image must not be empty");
|
||||
service.image = app.source.image.trim();
|
||||
} else {
|
||||
if (app.source.kind === "auto" && app.source.uri?.trim())
|
||||
validateBuildpackUri(app.source.uri.trim());
|
||||
if (
|
||||
app.source.kind === "template" &&
|
||||
!templateIds.includes(app.source.template)
|
||||
)
|
||||
throw new Error(`Unknown Dockerfile template: ${app.source.template}`);
|
||||
service.build = app.source.kind === "auto" ? "auto" : path;
|
||||
service.build =
|
||||
app.source.kind === "auto"
|
||||
? app.source.uri?.trim()
|
||||
? `auto:${app.source.uri.trim()}`
|
||||
: "auto"
|
||||
: path;
|
||||
if (app.source.kind === "auto" && path !== ".")
|
||||
service["x-kuber-build-context"] = path;
|
||||
}
|
||||
@@ -221,13 +229,13 @@ export async function addAppToCompose(
|
||||
throw new Error("Compose file cannot be found. Run kuber init first.");
|
||||
await checkedAppPath(root, app.path);
|
||||
const service = serviceForApp(app);
|
||||
const compose = await readCompose(path);
|
||||
if (Object.hasOwn(compose.services ?? {}, app.name))
|
||||
throw new Error(`Service ${app.name} already exists`);
|
||||
const generated: string[] = [];
|
||||
const lock = await open(`${path}.kuber.lock`, "wx");
|
||||
try {
|
||||
const original = await readFile(path, "utf8");
|
||||
const compose = await readCompose(path);
|
||||
if (Object.hasOwn(compose.services ?? {}, app.name))
|
||||
throw new Error(`Service ${app.name} already exists`);
|
||||
const source = app.source;
|
||||
if (source.kind === "template") {
|
||||
const templates = renderDockerfileTemplate(source.template);
|
||||
|
||||
Reference in New Issue
Block a user