Repository: JoshuaKGoldberg/bingo (ts)

Issue:
🚀 Feature: Option CLIs should support unions of numbers and strings
### Bug Report Checklist

- [x] I have pulled the latest `main` branch of the repository.
- [x] I have [searched for related issues](https://github.com/JoshuaKGoldberg/bingo/issues?q=is%3Aissue) and found none that matched my issue.

### Overview

Right now, if a template is an option that's a Zod union, it will prompt the user for a plaintext value with `prompts.text`:

https://github.com/JoshuaKGoldberg/create/blob/293e610b7353a63811a1c513c621e6ab31c0f756/packages/bingo/src/cli/prompts/promptForSchema.ts#L39-L46

That's not idea. Stratum templates use unions for their `preset` option. Bingo CLIs will want to use `prompts.select` for them.

### Additional Info

💝 

Pull request description:
feat: support union schemas in CLI prompts
## PR Checklist

- [x] Addresses an existing open issue: fixes #226
- [x] That issue was marked as [`status: accepting prs`](https://github.com/JoshuaKGoldberg/bingo/issues?q=is%3Aopen+is%3Aissue+label%3A%22status%3A+accepting+prs%22)
- [x] Steps in [CONTRIBUTING.md](https://github.com/JoshuaKGoldberg/bingo/blob/main/.github/CONTRIBUTING.md) were taken

## Overview

Standardizes the names of the `prompt*` files and functions a bit. Also extracts out a `slugifyPresetName` function.

💝 

Patch:
diff --git a/packages/bingo-stratum/src/creators/createBase.ts b/packages/bingo-stratum/src/creators/createBase.ts
index c3489e7..938c990 100644
--- a/packages/bingo-stratum/src/creators/createBase.ts
+++ b/packages/bingo-stratum/src/creators/createBase.ts
@@ -14,7 +14,9 @@ import { Preset, PresetDefinition } from "../types/presets.js";
 import {
 	StratumTemplate,
 	StratumTemplateDefinition,
+	ZodPresetNameLiterals,
 } from "../types/templates.js";
+import { slugifyPresetName } from "../utils.ts/slugifyPresetName.js";
 import { assertNoDuplicateBlocks } from "./assertNoDuplicateBlocks.js";
 import { assertNoPresetOption } from "./assertNoPresetOption.js";
 import { applyZodDefaults, isDefinitionWithAddons } from "./utils.js";
@@ -78,12 +80,24 @@ export function createBase<OptionsShape extends AnyShape>(
 	function createStratumTemplate(
 		templateDefinition: StratumTemplateDefinition<OptionsShape>,
 	): StratumTemplate<OptionsShape> {
+		const presetOption = z
+			.union(
+				templateDefinition.presets.map((preset) =>
+					z.literal(slugifyPresetName(preset.about.name)),
+				) as ZodPresetNameLiterals,
+			)
+			.describe("starting set of tooling to use");
 		const template: StratumTemplate<OptionsShape> = {
 			...templateDefinition,
 			base,
 			options: {
 				...base.options,
-				preset: z.string().describe("Which starting set of tooling to use."),
+				preset: presetOption.default(
+					slugifyPresetName(
+						(templateDefinition.suggested ?? templateDefinition.presets[0])
+							.about.name,
+					),
+				) as unknown as z.ZodUnion<ZodPresetNameLiterals>, // TODO: why don't the types allow a ZodDefault here?
 			},
 			prepare: base.prepare,
 			produce(context) {
diff --git a/packages/bingo-stratum/src/types/templates.ts b/packages/bingo-stratum/src/types/templates.ts
index b9976f0..2f8b871 100644
--- a/packages/bingo-stratum/src/types/templates.ts
+++ b/packages/bingo-stratum/src/types/templates.ts
@@ -30,9 +30,15 @@ export interface StratumTemplateDefinition<
 }
 
 export interface StratumTemplateOptionsShape {
-	preset: z.ZodString;
+	preset: z.ZodUnion<ZodPresetNameLiterals>;
 }
 
 export type StratumTemplateProduce<Options extends object> = (
 	context: TemplateContext<Options & { preset: string }>,
 ) => Partial<BlockCreation<Options>>;
+
+export type ZodPresetNameLiterals = [
+	z.ZodLiteral<string>,
+	z.ZodLiteral<string>,
+	...z.ZodLiteral<string>[],
+];
diff --git a/packages/bingo-stratum/src/utils.ts/getPresetByName.ts b/packages/bingo-stratum/src/utils.ts/getPresetByName.ts
index 5f4ad05..7813833 100644
--- a/packages/bingo-stratum/src/utils.ts/getPresetByName.ts
+++ b/packages/bingo-stratum/src/utils.ts/getPresetByName.ts
@@ -1,7 +1,7 @@
 import { AnyShape } from "bingo";
-import slugify from "slugify";
 
 import { Preset } from "../types/presets.js";
+import { slugifyPresetName } from "./slugifyPresetName.js";
 
 export function getPresetByName<OptionsShape extends AnyShape>(
 	presets: Preset<OptionsShape>[],
@@ -9,17 +9,12 @@ export function getPresetByName<OptionsShape extends AnyShape>(
 ) {
 	const presetsByName = new Map(
 		Array.from(
-			presets.map((preset) => [
-				// @ts-expect-error -- https://github.com/simov/slugify/issues/196
-				slugify(preset.about.name, { lower: true }),
-				preset,
-			]),
+			presets.map((preset) => [slugifyPresetName(preset.about.name), preset]),
 		),
 	);
 
 	return (
-		// @ts-expect-error -- https://github.com/simov/slugify/issues/196
-		presetsByName.get(slugify(requested, { lower: true })) ??
+		presetsByName.get(slugifyPresetName(requested)) ??
 		new Error(
 			`${requested} is not one of: ${Array.from(presetsByName.keys()).join(", ")}`,
 		)
diff --git a/packages/bingo-stratum/src/utils.ts/slugifyPresetName.ts b/packages/bingo-stratum/src/utils.ts/slugifyPresetName.ts
new file mode 100644
index 0000000..4666b1a
--- /dev/null
+++ b/packages/bingo-stratum/src/utils.ts/slugifyPresetName.ts
@@ -0,0 +1,6 @@
+import slugify from "slugify";
+
+export function slugifyPresetName(original: string) {
+	// @ts-expect-error -- https://github.com/simov/slugify/issues/196
+	return slugify(original, { lower: true }) as string;
+}
diff --git a/packages/bingo/src/cli/prompts/promptForOptionSchema.ts b/packages/bingo/src/cli/prompts/promptForOptionSchema.ts
new file mode 100644
index 0000000..f4718d7
--- /dev/null
+++ b/packages/bingo/src/cli/prompts/promptForOptionSchema.ts
@@ -0,0 +1,93 @@
+import * as prompts from "@clack/prompts";
+import * as z from "zod";
+
+import { validateNumber, validatorFromSchema } from "./validators.js";
+
+type ZodDef =
+	| z.ZodBooleanDef
+	| (z.ZodDefault<z.ZodTypeAny> & {
+			innerType: z.ZodTypeAny;
+			typeName: z.ZodFirstPartyTypeKind.ZodDefault;
+	  })
+	| z.ZodUnionDef
+	| ZodStringLikeDef;
+
+type ZodStringLikeDef = z.ZodNumberDef | z.ZodStringDef;
+
+export async function promptForOptionSchema(
+	key: string,
+	schema: z.ZodTypeAny,
+	description: string | undefined,
+	defaultValue: unknown,
+) {
+	const def = schema._def as ZodDef;
+	if (def.typeName === z.ZodFirstPartyTypeKind.ZodDefault) {
+		return await promptForOptionSchema(
+			key,
+			def.innerType,
+			def.innerType.description,
+			defaultValue,
+		);
+	}
+	const message = description
+		? `What will the ${description} be? (--${key})`
+		: `What will the --${key} be?`;
+	let value: unknown;
+
+	while (value === undefined || value === "") {
+		switch (def.typeName) {
+			case z.ZodFirstPartyTypeKind.ZodBoolean: {
+				value = await prompts.confirm({
+					initialValue: defaultValue as boolean,
+					message,
+				});
+				break;
+			}
+
+			case z.ZodFirstPartyTypeKind.ZodNumber:
+				value = Number(
+					await prompts.text({
+						message,
+						placeholder: defaultValue as string,
+						validate: validateNumber,
+					}),
+				);
+				break;
+
+			case z.ZodFirstPartyTypeKind.ZodUnion: {
+				const options = def.options.map((option) => ({
+					// TODO: Handle non-string-like schema data types
+					// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
+					value: `${(option._def as { value: number | string }).value}`,
+				}));
+				const text = await prompts.select({
+					initialValue: defaultValue as string,
+					message,
+					options,
+				});
+
+				if (prompts.isCancel(text)) {
+					return text;
+				}
+
+				return schema.parse(text) as unknown;
+			}
+
+			default: {
+				const text = await prompts.text({
+					message,
+					placeholder: defaultValue as string,
+					validate: validatorFromSchema(schema),
+				});
+
+				if (prompts.isCancel(text)) {
+					return text;
+				}
+
+				return schema.parse(text) as unknown;
+			}
+		}
+	}
+
+	return value;
+}
diff --git a/packages/bingo/src/cli/prompts/promptForOptions.ts b/packages/bingo/src/cli/prompts/promptForOptionSchemas.ts
similarity index 89%
rename from packages/bingo/src/cli/prompts/promptForOptions.ts
rename to packages/bingo/src/cli/prompts/promptForOptionSchemas.ts
index 9a939c5..9601b41 100644
--- a/packages/bingo/src/cli/prompts/promptForOptions.ts
+++ b/packages/bingo/src/cli/prompts/promptForOptionSchemas.ts
@@ -5,7 +5,7 @@ import { prepareOptions } from "../../preparation/prepareOptions.js";
 import { SystemContext } from "../../types/system.js";
 import { Template } from "../../types/templates.js";
 import { getSchemaDefaultValue } from "../../utils/getSchemaDefaultValue.js";
-import { promptForSchema } from "./promptForSchema.js";
+import { promptForOptionSchema } from "./promptForOptionSchema.js";
 
 export type PromptedOptions<Options extends object> =
 	| PromptedOptionsCancelled<Options>
@@ -28,7 +28,7 @@ export interface PromptForOptionsSettings<OptionsShape extends AnyShape> {
 	system: SystemContext;
 }
 
-export async function promptForOptions<
+export async function promptForOptionSchemas<
 	OptionsShape extends AnyShape = AnyShape,
 >(
 	template: Template<OptionsShape>,
@@ -57,7 +57,12 @@ export async function promptForOptions<
 			continue;
 		}
 
-		const produced = await promptForSchema(key, schema, defaultValue);
+		const produced = await promptForOptionSchema(
+			key,
+			schema,
+			schema.description,
+			defaultValue,
+		);
 		if (prompts.isCancel(produced)) {
 			return { cancelled: true, prompted };
 		}
diff --git a/packages/bingo/src/cli/prompts/promptForSchema.ts b/packages/bingo/src/cli/prompts/promptForSchema.ts
deleted file mode 100644
index 8cc0009..0000000
--- a/packages/bingo/src/cli/prompts/promptForSchema.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import * as prompts from "@clack/prompts";
-import * as z from "zod";
-
-import { validateNumber, validatorFromSchema } from "./validators.js";
-
-type ZodDef = z.ZodBooleanDef | z.ZodNumberDef | z.ZodStringDef;
-
-export async function promptForSchema(
-	key: string,
-	schema: z.ZodTypeAny,
-	defaultValue: unknown,
-) {
-	const def = schema._def as ZodDef;
-	const message = schema.description
-		? `What will the ${schema.description} be? (--${key})`
-		: `What will the --${key} be?`;
-	let value: unknown;
-
-	while (value === undefined || value === "") {
-		switch (def.typeName) {
-			case z.ZodFirstPartyTypeKind.ZodBoolean: {
-				value = await prompts.confirm({
-					initialValue: defaultValue as boolean,
-					message,
-				});
-				break;
-			}
-
-			case z.ZodFirstPartyTypeKind.ZodNumber:
-				value = Number(
-					await prompts.text({
-						message,
-						placeholder: defaultValue as string,
-						validate: validateNumber,
-					}),
-				);
-				break;
-
-			// TODO: Handle numeric literals, unions, ...
-
-			default: {
-				const text = await prompts.text({
-					message,
-					placeholder: defaultValue as string,
-					validate: validatorFromSchema(schema),
-				});
-
-				if (prompts.isCancel(text)) {
-					return text;
-				}
-
-				return schema.parse(text) as unknown;
-			}
-		}
-	}
-
-	return value;
-}
diff --git a/packages/bingo/src/cli/setup/runModeSetup.ts b/packages/bingo/src/cli/setup/runModeSetup.ts
index e05d492..200f42c 100644
--- a/packages/bingo/src/cli/setup/runModeSetup.ts
+++ b/packages/bingo/src/cli/setup/runModeSetup.ts
@@ -14,7 +14,7 @@ import { logRerunSuggestion } from "../loggers/logRerunSuggestion.js";
 import { logStartText } from "../loggers/logStartText.js";
 import { parseZodArgs } from "../parsers/parseZodArgs.js";
 import { promptForDirectory } from "../prompts/promptForDirectory.js";
-import { promptForOptions } from "../prompts/promptForOptions.js";
+import { promptForOptionSchemas } from "../prompts/promptForOptionSchemas.js";
 import { CLIStatus } from "../status.js";
 import { ModeResults } from "../types.js";
 import { makeRelative } from "../utils.js";
@@ -64,7 +64,7 @@ export async function runModeSetup<OptionsShape extends AnyShape>({
 		offline,
 	});
 
-	const baseOptions = await promptForOptions(template, {
+	const baseOptions = await promptForOptionSchemas(template, {
 		existing: {
 			directory,
 			repository: repository ?? directory,
diff --git a/packages/bingo/src/cli/transition/runModeTransition.ts b/packages/bingo/src/cli/transition/runModeTransition.ts
index 0f8f1d1..0e2a8fc 100644
--- a/packages/bingo/src/cli/transition/runModeTransition.ts
+++ b/packages/bingo/src/cli/transition/runModeTransition.ts
@@ -10,7 +10,7 @@ import { logHelpText } from "../loggers/logHelpText.js";
 import { logRerunSuggestion } from "../loggers/logRerunSuggestion.js";
 import { logStartText } from "../loggers/logStartText.js";
 import { parseZodArgs } from "../parsers/parseZodArgs.js";
-import { promptForOptions } from "../prompts/promptForOptions.js";
+import { promptForOptionSchemas } from "../prompts/promptForOptionSchemas.js";
 import { CLIStatus } from "../status.js";
 import { ModeResults } from "../types.js";
 import { clearTemplateFiles } from "./clearTemplateFiles.js";
@@ -66,7 +66,7 @@ export async function runModeTransition<OptionsShape extends AnyShape>({
 		);
 	}
 
-	const baseOptions = await promptForOptions(template, {
+	const baseOptions = await promptForOptionSchemas(template, {
 		existing: {
 			...parseZodArgs(args, template.options),
 		},


Test patch:
diff --git a/packages/bingo/src/cli/transition/runModeTransition.test.ts b/packages/bingo/src/cli/transition/runModeTransition.test.ts
index e1407ba..91a8aa5 100644
--- a/packages/bingo/src/cli/transition/runModeTransition.test.ts
+++ b/packages/bingo/src/cli/transition/runModeTransition.test.ts
@@ -38,11 +38,11 @@ vi.mock("../../contexts/createSystemContextWithAuth.js", () => ({
 	},
 }));
 
-const mockPromptForOptions = vi.fn();
+const mockPromptForOptionSchemas = vi.fn();
 
-vi.mock("../prompts/promptForOptions.js", () => ({
-	get promptForOptions() {
-		return mockPromptForOptions;
+vi.mock("../prompts/promptForOptionSchemas.js", () => ({
+	get promptForOptionSchemas() {
+		return mockPromptForOptionSchemas;
 	},
 }));
 
@@ -157,7 +157,7 @@ describe("runModeTransition", () => {
 	});
 
 	it("returns the cancellation when promptForOptions is cancelled", async () => {
-		mockPromptForOptions.mockResolvedValueOnce({
+		mockPromptForOptionSchemas.mockResolvedValueOnce({
 			cancelled: true,
 			prompted: promptedOptions,
 		});
@@ -179,7 +179,7 @@ describe("runModeTransition", () => {
 	it("returns the error when runTemplate resolves with an error", async () => {
 		const error = new Error("Oh no!");
 
-		mockPromptForOptions.mockResolvedValueOnce({
+		mockPromptForOptionSchemas.mockResolvedValueOnce({
 			prompted: promptedOptions,
 		});
 		mockRunTemplate.mockRejectedValueOnce(error);
@@ -201,7 +201,7 @@ describe("runModeTransition", () => {
 	});
 
 	it("doesn't clear the existing repository when the template does not have a repository locator", async () => {
-		mockPromptForOptions.mockResolvedValueOnce({
+		mockPromptForOptionSchemas.mockResolvedValueOnce({
 			prompted: promptedOptions,
 		});
 
@@ -223,7 +223,7 @@ describe("runModeTransition", () => {
 	});
 
 	it("clears the existing repository online when a forked repository locator is available and offline is falsy", async () => {
-		mockPromptForOptions.mockResolvedValueOnce({
+		mockPromptForOptionSchemas.mockResolvedValueOnce({
 			prompted: promptedOptions,
 		});
 		mockGetForkedRepositoryLocator.mockResolvedValueOnce({
@@ -259,7 +259,7 @@ describe("runModeTransition", () => {
 	});
 
 	it("clears the existing repository offline when a forked repository locator is available and offline is true", async () => {
-		mockPromptForOptions.mockResolvedValueOnce({
+		mockPromptForOptionSchemas.mockResolvedValueOnce({
 			prompted: promptedOptions,
 		});
 		mockGetForkedRepositoryLocator.mockResolvedValueOnce({