feat: release 2.7.0-rc2
This commit is contained in:
+238
@@ -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}`);
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user