feat: select, confirm and number input types

This commit is contained in:
2026-03-23 16:32:03 +02:00
parent 0a4ead17c0
commit f6408f221d
8 changed files with 336 additions and 48 deletions

View File

@@ -58,16 +58,25 @@ module.exports = {
output: "src/components",
inputs: {
author: { message: "Author name", required: true },
license: { message: "License type", default: "MIT" },
description: { message: "Component description" },
license: {
type: "select",
message: "License",
options: ["MIT", "Apache-2.0", "GPL-3.0"],
},
private: { type: "confirm", message: "Private?", default: false },
port: { type: "number", message: "Port", default: 3000 },
},
},
}
```
In your templates, use these as `{{ author }}`, `{{ license }}`, `{{ description }}`.
In your templates, use these as `{{ author }}`, `{{ license }}`, `{{ private }}`, `{{ port }}`.
Supported input types: `text` (default), `select`, `confirm`, `number`. See
[Template Inputs](cli#template-inputs) for the full reference.
- **Required** inputs are prompted interactively if not provided via `--data` or `-D`
- **Select and confirm** inputs are always prompted unless pre-provided
- **Optional** inputs with a `default` use that value silently if not provided
- In non-interactive environments, only defaults are applied

View File

@@ -59,7 +59,13 @@ module.exports = {
output: "src/components",
inputs: {
author: { message: "Author name", required: true },
license: { message: "License type", default: "MIT" },
license: {
type: "select",
message: "License",
options: ["MIT", "Apache-2.0", "GPL-3.0"],
},
private: { type: "confirm", message: "Private package?", default: false },
port: { type: "number", message: "Dev server port", default: 3000 },
description: { message: "Description" },
},
},
@@ -69,8 +75,18 @@ module.exports = {
Each input becomes available as a Handlebars variable in your templates (e.g., `{{ author }}`,
`{{ license }}`).
**Input types:**
| Type | Description | Value type |
| --------- | ------------------------------ | ---------- |
| `text` | Free-form text input (default) | `string` |
| `select` | Choose from a list of options | `string` |
| `confirm` | Yes/no prompt | `boolean` |
| `number` | Numeric input | `number` |
- **Required inputs** without a value will be prompted interactively
- **Optional inputs** with a `default` will use that value if not provided
- **Select and confirm** inputs are always prompted (unless pre-provided)
- **Optional text/number inputs** with a `default` will use that value silently
- All inputs can be pre-provided via `--data` or `-D` to skip the prompt:
```shell

View File

@@ -33,9 +33,11 @@ interface ScaffoldConfig {
}
interface ScaffoldInput {
type?: "text" | "select" | "confirm" | "number"
message?: string
required?: boolean
default?: string
default?: string | boolean | number
options?: (string | { name: string; value: string })[] // for type: "select"
}
interface AfterScaffoldContext {
@@ -104,7 +106,13 @@ await Scaffold({
output: "src/components",
inputs: {
author: { message: "Author name", required: true },
license: { message: "License", default: "MIT" },
license: {
type: "select",
message: "License",
options: ["MIT", "Apache-2.0", "GPL-3.0"],
},
private: { type: "confirm", message: "Private package?", default: false },
port: { type: "number", message: "Dev server port", default: 3000 },
},
})
// In templates: {{ author }}, {{ license }}

View File

@@ -53,7 +53,9 @@
]
},
"dependencies": {
"@inquirer/confirm": "^6.0.10",
"@inquirer/input": "^5.0.10",
"@inquirer/number": "^4.0.10",
"@inquirer/select": "^5.1.2",
"date-fns": "^4.1.0",
"glob": "^13.0.6",

44
pnpm-lock.yaml generated
View File

@@ -7,9 +7,15 @@ settings:
importers:
.:
dependencies:
"@inquirer/confirm":
specifier: ^6.0.10
version: 6.0.10(@types/node@25.5.0)
"@inquirer/input":
specifier: ^5.0.10
version: 5.0.10(@types/node@25.5.0)
"@inquirer/number":
specifier: ^4.0.10
version: 4.0.10(@types/node@25.5.0)
"@inquirer/select":
specifier: ^5.1.2
version: 5.1.2(@types/node@25.5.0)
@@ -219,6 +225,18 @@ packages:
}
engines: { node: ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" }
"@inquirer/confirm@6.0.10":
resolution:
{
integrity: sha512-tiNyA73pgpQ0FQ7axqtoLUe4GDYjNCDcVsbgcA5anvwg2z6i+suEngLKKJrWKJolT//GFPZHwN30binDIHgSgQ==,
}
engines: { node: ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" }
peerDependencies:
"@types/node": ">=18"
peerDependenciesMeta:
"@types/node":
optional: true
"@inquirer/core@11.1.7":
resolution:
{
@@ -250,6 +268,18 @@ packages:
"@types/node":
optional: true
"@inquirer/number@4.0.10":
resolution:
{
integrity: sha512-Ht8OQstxiS3APMGjHV0aYAjRAysidWdwurWEo2i8yI5xbhOBWqizT0+MU1S2GCcuhIBg+3SgWVjEoXgfhY+XaA==,
}
engines: { node: ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" }
peerDependencies:
"@types/node": ">=18"
peerDependenciesMeta:
"@types/node":
optional: true
"@inquirer/select@5.1.2":
resolution:
{
@@ -1956,6 +1986,13 @@ snapshots:
"@inquirer/ansi@2.0.4": {}
"@inquirer/confirm@6.0.10(@types/node@25.5.0)":
dependencies:
"@inquirer/core": 11.1.7(@types/node@25.5.0)
"@inquirer/type": 4.0.4(@types/node@25.5.0)
optionalDependencies:
"@types/node": 25.5.0
"@inquirer/core@11.1.7(@types/node@25.5.0)":
dependencies:
"@inquirer/ansi": 2.0.4
@@ -1977,6 +2014,13 @@ snapshots:
optionalDependencies:
"@types/node": 25.5.0
"@inquirer/number@4.0.10(@types/node@25.5.0)":
dependencies:
"@inquirer/core": 11.1.7(@types/node@25.5.0)
"@inquirer/type": 4.0.4(@types/node@25.5.0)
optionalDependencies:
"@types/node": 25.5.0
"@inquirer/select@5.1.2(@types/node@25.5.0)":
dependencies:
"@inquirer/ansi": 2.0.4

View File

@@ -1,7 +1,15 @@
import input from "@inquirer/input"
import select from "@inquirer/select"
import confirm from "@inquirer/confirm"
import number from "@inquirer/number"
import { colorize } from "./colors"
import { ScaffoldCmdConfig, ScaffoldConfig, ScaffoldConfigMap, ScaffoldInput } from "./types"
import {
ScaffoldCmdConfig,
ScaffoldConfig,
ScaffoldConfigMap,
ScaffoldInput,
ScaffoldInputType,
} from "./types"
/** Prompts the user for a scaffold name. */
export async function promptForName(): Promise<string> {
@@ -62,6 +70,59 @@ export async function promptForTemplates(): Promise<string[]> {
.filter(Boolean)
}
/** Prompts for a single input based on its type. */
async function promptSingleInput(
key: string,
def: ScaffoldInput,
): Promise<string | boolean | number | undefined> {
const type: ScaffoldInputType = def.type ?? "text"
const message = colorize.cyan(def.message ?? `${key}:`)
switch (type) {
case "text":
return input({
message,
required: def.required,
default: def.default as string | undefined,
validate: def.required
? (value) => {
if (!value.trim()) return `${key} is required`
return true
}
: undefined,
})
case "select": {
const choices = (def.options ?? []).map((opt) =>
typeof opt === "string" ? { name: opt, value: opt } : opt,
)
if (choices.length === 0) {
throw new Error(`Input "${key}" has type "select" but no options defined`)
}
return select({
message,
choices,
default: def.default as string | undefined,
})
}
case "confirm":
return confirm({
message,
default: (def.default as boolean | undefined) ?? false,
})
case "number":
return (
(await number({
message,
required: def.required,
default: def.default as number | undefined,
})) ?? def.default
)
}
}
/**
* Prompts the user for any required scaffold inputs that are not already provided in data.
* Also applies default values for optional inputs that have one.
@@ -79,16 +140,8 @@ export async function promptForInputs(
continue
}
if (def.required) {
data[key] = await input({
message: colorize.cyan(def.message ?? `${key}:`),
required: true,
default: def.default,
validate: (value) => {
if (!value.trim()) return `${key} is required`
return true
},
})
if (def.required || def.type === "select" || def.type === "confirm") {
data[key] = await promptSingleInput(key, def)
} else if (def.default !== undefined && !(key in data)) {
data[key] = def.default
}

View File

@@ -236,18 +236,44 @@ export interface AfterScaffoldContext {
*/
export type AfterScaffoldHook = ((context: AfterScaffoldContext) => void | Promise<void>) | string
/**
* The type of an interactive input prompt.
*
* - `"text"` — free-form text input (default)
* - `"select"` — choose from a list of options
* - `"confirm"` — yes/no boolean prompt
* - `"number"` — numeric input
*
* @category Config
*/
export type ScaffoldInputType = "text" | "select" | "confirm" | "number"
/**
* Defines a single interactive input for a scaffold template.
*
* @example
* ```typescript
* inputs: {
* author: { message: "Author name", required: true },
* license: { type: "select", message: "License", options: ["MIT", "Apache-2.0", "GPL-3.0"] },
* private: { type: "confirm", message: "Private package?", default: false },
* port: { type: "number", message: "Dev server port", default: 3000 },
* }
* ```
*
* @category Config
*/
export interface ScaffoldInput {
/** The type of prompt. Defaults to `"text"`. */
type?: ScaffoldInputType
/** The prompt message shown to the user. Defaults to the input key name if omitted. */
message?: string
/** Whether this input must be provided. If true and missing, the user will be prompted interactively. */
required?: boolean
/** Default value used when the user doesn't provide one. */
default?: string
/** Default value. Type depends on the input type: string for text/select, boolean for confirm, number for number. */
default?: string | boolean | number
/** List of options for `type: "select"`. Each can be a string or `{ name, value }`. */
options?: (string | { name: string; value: string })[]
}
/**

View File

@@ -9,8 +9,18 @@ vi.mock("@inquirer/select", () => ({
default: vi.fn(),
}))
vi.mock("@inquirer/confirm", () => ({
default: vi.fn(),
}))
vi.mock("@inquirer/number", () => ({
default: vi.fn(),
}))
import inputMock from "@inquirer/input"
import selectMock from "@inquirer/select"
import confirmMock from "@inquirer/confirm"
import numberMock from "@inquirer/number"
import {
promptForName,
promptForTemplateKey,
@@ -83,7 +93,9 @@ describe("prompts", () => {
b: { name: "b", templates: [], output: "" },
c: { name: "c", templates: [], output: "" },
})
const call = vi.mocked(selectMock).mock.calls[0][0] as { choices: { name: string; value: string }[] }
const call = vi.mocked(selectMock).mock.calls[0][0] as {
choices: { name: string; value: string }[]
}
expect(call.choices).toEqual([
{ name: "a", value: "a" },
{ name: "b", value: "b" },
@@ -131,9 +143,9 @@ describe("prompts", () => {
test("prompts for all missing values when interactive", async () => {
mockTTY(true)
vi.mocked(inputMock)
.mockResolvedValueOnce("my-app") // name
.mockResolvedValueOnce("./output") // output
.mockResolvedValueOnce("src/tpl") // templates
.mockResolvedValueOnce("my-app") // name
.mockResolvedValueOnce("./output") // output
.mockResolvedValueOnce("src/tpl") // templates
const config = { ...blankConfig }
const result = await promptForMissingConfig(config)
@@ -162,9 +174,9 @@ describe("prompts", () => {
test("prompts for template key when multiple templates and no key", async () => {
mockTTY(true)
vi.mocked(inputMock)
.mockResolvedValueOnce("name") // name
.mockResolvedValueOnce("./output") // output
.mockResolvedValueOnce("src/tpl") // templates
.mockResolvedValueOnce("name") // name
.mockResolvedValueOnce("./output") // output
.mockResolvedValueOnce("src/tpl") // templates
vi.mocked(selectMock).mockResolvedValue("component")
const configMap = {
@@ -183,7 +195,13 @@ describe("prompts", () => {
default: { name: "d", templates: [], output: "" },
component: { name: "c", templates: [], output: "" },
}
const config = { ...blankConfig, name: "test", output: "./out", templates: ["tpl"], key: "default" }
const config = {
...blankConfig,
name: "test",
output: "./out",
templates: ["tpl"],
key: "default",
}
const result = await promptForMissingConfig(config, configMap)
expect(result.key).toEqual("default")
expect(selectMock).not.toHaveBeenCalled()
@@ -227,7 +245,7 @@ describe("prompts", () => {
test("only prompts for missing values, not provided ones", async () => {
mockTTY(true)
vi.mocked(inputMock).mockResolvedValueOnce("src/tpl") // only templates missing
vi.mocked(inputMock).mockResolvedValueOnce("src/tpl") // only templates missing
const config = { ...blankConfig, name: "app", output: "./out" }
const result = await promptForMissingConfig(config)
@@ -259,10 +277,7 @@ describe("prompts", () => {
})
test("applies default value for optional inputs not in data", async () => {
const result = await promptForInputs(
{ license: { default: "MIT" } },
{},
)
const result = await promptForInputs({ license: { default: "MIT" } }, {})
expect(result.license).toEqual("MIT")
expect(inputMock).not.toHaveBeenCalled()
})
@@ -277,18 +292,13 @@ describe("prompts", () => {
test("uses input key as message fallback", async () => {
vi.mocked(inputMock).mockResolvedValueOnce("val")
await promptForInputs(
{ myField: { required: true } },
{},
)
await promptForInputs({ myField: { required: true } }, {})
const call = vi.mocked(inputMock).mock.calls[0][0] as { message: string }
expect(call.message).toContain("myField")
})
test("prompts multiple required inputs in order", async () => {
vi.mocked(inputMock)
.mockResolvedValueOnce("John")
.mockResolvedValueOnce("2.0")
vi.mocked(inputMock).mockResolvedValueOnce("John").mockResolvedValueOnce("2.0")
const result = await promptForInputs(
{
author: { message: "Author", required: true },
@@ -318,23 +328,143 @@ describe("prompts", () => {
})
test("preserves existing data keys not in inputs", async () => {
const result = await promptForInputs(
{ license: { default: "MIT" } },
{ extra: "value" },
)
const result = await promptForInputs({ license: { default: "MIT" } }, { extra: "value" })
expect(result.extra).toEqual("value")
expect(result.license).toEqual("MIT")
})
test("required input with default pre-fills prompt", async () => {
vi.mocked(inputMock).mockResolvedValueOnce("custom")
await promptForInputs(
{ author: { required: true, default: "Anonymous" } },
{},
)
await promptForInputs({ author: { required: true, default: "Anonymous" } }, {})
const call = vi.mocked(inputMock).mock.calls[0][0] as { default?: string }
expect(call.default).toEqual("Anonymous")
})
test("select input prompts with options", async () => {
vi.mocked(selectMock).mockResolvedValueOnce("MIT")
const result = await promptForInputs(
{
license: {
type: "select",
message: "License",
options: ["MIT", "Apache-2.0", "GPL-3.0"],
},
},
{},
)
expect(result.license).toEqual("MIT")
expect(selectMock).toHaveBeenCalledOnce()
const call = vi.mocked(selectMock).mock.calls[0][0] as {
choices: { name: string; value: string }[]
}
expect(call.choices).toEqual([
{ name: "MIT", value: "MIT" },
{ name: "Apache-2.0", value: "Apache-2.0" },
{ name: "GPL-3.0", value: "GPL-3.0" },
])
})
test("select input with object options", async () => {
vi.mocked(selectMock).mockResolvedValueOnce("mit")
const result = await promptForInputs(
{
license: {
type: "select",
options: [
{ name: "MIT License", value: "mit" },
{ name: "Apache 2.0", value: "apache" },
],
},
},
{},
)
expect(result.license).toEqual("mit")
})
test("select input throws when no options", async () => {
await expect(promptForInputs({ license: { type: "select" } }, {})).rejects.toThrow(
"no options defined",
)
})
test("select input skipped when value already provided", async () => {
const result = await promptForInputs(
{ license: { type: "select", options: ["MIT", "Apache"] } },
{ license: "MIT" },
)
expect(result.license).toEqual("MIT")
expect(selectMock).not.toHaveBeenCalled()
})
test("confirm input prompts and returns boolean", async () => {
vi.mocked(confirmMock).mockResolvedValueOnce(true)
const result = await promptForInputs(
{ private: { type: "confirm", message: "Private?" } },
{},
)
expect(result.private).toBe(true)
expect(confirmMock).toHaveBeenCalledOnce()
})
test("confirm input with default false", async () => {
vi.mocked(confirmMock).mockResolvedValueOnce(false)
await promptForInputs({ private: { type: "confirm", default: false } }, {})
const call = vi.mocked(confirmMock).mock.calls[0][0] as { default?: boolean }
expect(call.default).toBe(false)
})
test("confirm input skipped when value already provided", async () => {
const result = await promptForInputs({ private: { type: "confirm" } }, { private: true })
expect(result.private).toBe(true)
expect(confirmMock).not.toHaveBeenCalled()
})
test("number input prompts and returns number", async () => {
vi.mocked(numberMock).mockResolvedValueOnce(8080)
const result = await promptForInputs(
{ port: { type: "number", message: "Port", required: true } },
{},
)
expect(result.port).toBe(8080)
expect(numberMock).toHaveBeenCalledOnce()
})
test("number input with default", async () => {
vi.mocked(numberMock).mockResolvedValueOnce(3000)
await promptForInputs({ port: { type: "number", default: 3000, required: true } }, {})
const call = vi.mocked(numberMock).mock.calls[0][0] as { default?: number }
expect(call.default).toBe(3000)
})
test("number input skipped when value already provided", async () => {
const result = await promptForInputs(
{ port: { type: "number", required: true } },
{ port: 9090 },
)
expect(result.port).toBe(9090)
expect(numberMock).not.toHaveBeenCalled()
})
test("mixed input types in one config", async () => {
vi.mocked(inputMock).mockResolvedValueOnce("John")
vi.mocked(selectMock).mockResolvedValueOnce("MIT")
vi.mocked(confirmMock).mockResolvedValueOnce(true)
vi.mocked(numberMock).mockResolvedValueOnce(3000)
const result = await promptForInputs(
{
author: { type: "text", message: "Author", required: true },
license: { type: "select", message: "License", options: ["MIT", "Apache"] },
private: { type: "confirm", message: "Private?" },
port: { type: "number", message: "Port", required: true },
},
{},
)
expect(result.author).toEqual("John")
expect(result.license).toEqual("MIT")
expect(result.private).toBe(true)
expect(result.port).toBe(3000)
})
})
describe("resolveInputs", () => {