diff --git a/docs/docs/usage/02-configuration_files.md b/docs/docs/usage/02-configuration_files.md index 4bd763b..8cd1ac0 100644 --- a/docs/docs/usage/02-configuration_files.md +++ b/docs/docs/usage/02-configuration_files.md @@ -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 diff --git a/docs/docs/usage/03-cli.md b/docs/docs/usage/03-cli.md index 59c2ac4..1b695bc 100644 --- a/docs/docs/usage/03-cli.md +++ b/docs/docs/usage/03-cli.md @@ -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 diff --git a/docs/docs/usage/04-node.md b/docs/docs/usage/04-node.md index eb60776..2d6f4f5 100644 --- a/docs/docs/usage/04-node.md +++ b/docs/docs/usage/04-node.md @@ -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 }} diff --git a/package.json b/package.json index 36e2c9c..ee05bfb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4f55fc..c7c6b16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/prompts.ts b/src/prompts.ts index f796ece..ae7f8cf 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -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 { @@ -62,6 +70,59 @@ export async function promptForTemplates(): Promise { .filter(Boolean) } +/** Prompts for a single input based on its type. */ +async function promptSingleInput( + key: string, + def: ScaffoldInput, +): Promise { + 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 } diff --git a/src/types.ts b/src/types.ts index 9f7ef7e..a1c2617 100644 --- a/src/types.ts +++ b/src/types.ts @@ -236,18 +236,44 @@ export interface AfterScaffoldContext { */ export type AfterScaffoldHook = ((context: AfterScaffoldContext) => void | Promise) | 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 })[] } /** diff --git a/tests/prompts.test.ts b/tests/prompts.test.ts index fe8ef67..a656743 100644 --- a/tests/prompts.test.ts +++ b/tests/prompts.test.ts @@ -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", () => {