feat: release 2.7.0-rc2

This commit is contained in:
2026-10-06 15:31:51 +00:00 Unverified
parent cd9fb512cd
commit c8de9ddcba
42 changed files with 6127 additions and 593 deletions
+238
View File
@@ -0,0 +1,238 @@
import { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process";
import { basename } from "node:path";
import { defineCommand } from "citty";
import { ctx } from "../lib/context";
import {
addAppToCompose,
checkedAppPath,
detectAppPaths,
type AppOptions,
type Source,
} from "../lib/scaffold";
import {
listDockerfileTemplates,
templateIds,
} from "../lib/scaffold-templates";
export type ScaffoldPrompt = (
question: string,
defaultValue?: string,
) => Promise<string>;
export const terminalPrompt: ScaffoldPrompt = async (
question,
defaultValue,
) => {
if (!stdin.isTTY)
throw new Error(`${question}: supply flags or use an interactive terminal`);
const readline = createInterface({ input: stdin, output: stdout });
try {
const result = (
await readline.question(
`${question}${defaultValue ? ` [${defaultValue}]` : ""}: `,
)
).trim();
return result || defaultValue || "";
} finally {
readline.close();
}
};
export type AppInput = Partial<Omit<AppOptions, "source">> & {
source?: "image" | "auto" | "template";
image?: string;
template?: string;
nonInteractive?: boolean;
};
async function answer(
value: string | undefined,
question: string,
fallback: string | undefined,
prompt: ScaffoldPrompt,
nonInteractive: boolean,
): Promise<string> {
if (value !== undefined) return value;
if (nonInteractive) {
if (fallback !== undefined) return fallback;
throw new Error(`${question} is required in non-interactive mode`);
}
return prompt(question, fallback);
}
export async function resolveAppInput(
root: string,
input: AppInput,
prompt: ScaffoldPrompt = terminalPrompt,
): Promise<AppOptions> {
const detected = await detectAppPaths(root);
let path: string;
if (input.path !== undefined) path = input.path;
else if (detected.length === 0) path = ".";
else if (detected.length === 1) path = detected[0]!;
else {
if (input.nonInteractive)
throw new Error(
`Multiple app roots found (${detected.join(", ")}); specify --path`,
);
path = await prompt(`App path (${detected.join(", ")})`);
if (!detected.includes(path))
throw new Error(
`Select one of the detected app roots: ${detected.join(", ")}`,
);
}
path = await checkedAppPath(root, path);
const suggestedName =
basename(path === "." ? root : path)
.toLowerCase()
.replace(/[^a-z0-9-]/g, "-")
.replace(/^-+|-+$/g, "") || "app";
const name = await answer(
input.name,
"Service name",
/^[a-z]/.test(suggestedName) ? suggestedName : `app-${suggestedName}`,
prompt,
!!input.nonInteractive,
);
const defaultSource = detected.length === 0 ? "image" : "auto";
const selectedSource = await answer(
input.source,
"Source (image/auto/template)",
defaultSource,
prompt,
!!input.nonInteractive,
);
let source: Source;
if (selectedSource === "image")
source = {
kind: "image",
image: await answer(
input.image,
"Image",
"nginx:stable",
prompt,
!!input.nonInteractive,
),
};
else if (selectedSource === "auto") source = { kind: "auto" };
else if (selectedSource === "template") {
const choices = listDockerfileTemplates()
.map(({ id, label }) => `${id} (${label})`)
.join(", ");
const template = await answer(
input.template,
`Dockerfile template (${choices})`,
undefined,
prompt,
!!input.nonInteractive,
);
if (!templateIds.includes(template as (typeof templateIds)[number]))
throw new Error(`Unknown Dockerfile template: ${template}`);
source = {
kind: "template",
template: template as (typeof templateIds)[number],
};
} else throw new Error("Source must be image, auto, or template");
const postgres = input.nonInteractive
? input.postgres
: await prompt(
"Postgres claim (name or user/database; blank for none)",
input.postgres,
);
const s3 = input.nonInteractive
? input.s3
: await prompt("S3 claim (name or key/bucket; blank for none)", input.s3);
const cpu = input.nonInteractive
? input.cpu
: await prompt("CPU limit in cores (blank for default)", input.cpu);
const memory = input.nonInteractive
? input.memory
: await prompt("Memory limit (blank for default)", input.memory);
const replicasAnswer = input.nonInteractive
? input.replicas?.toString()
: await prompt("Replicas (blank for default)", input.replicas?.toString());
return {
name,
path,
source,
...(postgres ? { postgres } : {}),
...(s3 ? { s3 } : {}),
...(cpu ? { cpu } : {}),
...(memory ? { memory } : {}),
...(replicasAnswer ? { replicas: Number(replicasAnswer) } : {}),
};
}
export const appArgs = {
name: { type: "string" as const, description: "Service name" },
path: {
type: "string" as const,
description: "App directory relative to the project",
},
source: {
type: "string" as const,
description: "image, auto (Buildpacks), or template",
},
image: {
type: "string" as const,
description: "Image reference when source=image",
},
template: {
type: "string" as const,
description: "Dockerfile template ID when source=template",
},
postgres: {
type: "string" as const,
description: "Postgres claim name or user/database",
},
s3: { type: "string" as const, description: "S3 claim name or key/bucket" },
cpu: { type: "string" as const, description: "CPU limit in cores" },
memory: { type: "string" as const, description: "Memory limit, e.g. 512m" },
replicas: { type: "string" as const, description: "Replica count" },
"non-interactive": {
type: "boolean" as const,
description: "Use defaults and require explicit choices when ambiguous",
},
};
export function appInputFromArgs(args: Record<string, unknown>): AppInput {
return {
...Object.fromEntries(
[
"name",
"path",
"source",
"image",
"template",
"postgres",
"s3",
"cpu",
"memory",
]
.filter((key) => args[key] !== undefined)
.map((key) => [key, String(args[key])]),
),
...(args.replicas !== undefined ? { replicas: Number(args.replicas) } : {}),
nonInteractive: Boolean(args["non-interactive"]),
};
}
export const add = defineCommand({
meta: { name: "add", description: "Add a resource to this project" },
subCommands: {
app: defineCommand({
meta: {
name: "app",
description: "Add a service without changing existing services",
},
args: appArgs,
async run({ args }) {
const { cwd, path } = ctx();
const app = await resolveAppInput(cwd, appInputFromArgs(args));
const composePath = await addAppToCompose(cwd, app, await path());
console.log(`Added ${app.name} to ${composePath}`);
},
}),
},
});