From 519ef273ac3db4b7a1e71c8e1c456aa1334d6fbd Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Mon, 23 Mar 2026 12:11:23 +0200 Subject: [PATCH] feat: interactive inputs --- package.json | 3 + pnpm-lock.yaml | 158 ++++++++++++++++++++++++++++ src/cmd.ts | 30 ++++-- src/prompts.ts | 99 ++++++++++++++++++ tests/prompts.test.ts | 238 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 518 insertions(+), 10 deletions(-) create mode 100644 src/prompts.ts create mode 100644 tests/prompts.test.ts diff --git a/package.json b/package.json index d6aabd7..d932577 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ "ci": "pnpm install --frozen-lockfile" }, "dependencies": { + "@inquirer/input": "^5.0.10", + "@inquirer/select": "^5.1.2", "date-fns": "^4.1.0", "glob": "^13.0.6", "handlebars": "^4.7.8", @@ -54,6 +56,7 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.57.1", "vite": "^8.0.1", + "vite-node": "^6.0.0", "vitest": "^4.1.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c68556c..f475aa6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + '@inquirer/input': + specifier: ^5.0.10 + version: 5.0.10(@types/node@25.5.0) + '@inquirer/select': + specifier: ^5.1.2 + version: 5.1.2(@types/node@25.5.0) date-fns: specifier: ^4.1.0 version: 4.1.0 @@ -45,6 +51,9 @@ importers: vite: specifier: ^8.0.1 version: 8.0.1(@types/node@25.5.0) + vite-node: + specifier: ^6.0.0 + version: 6.0.0(@types/node@25.5.0) vitest: specifier: ^4.1.0 version: 4.1.0(@types/node@25.5.0)(vite@8.0.1(@types/node@25.5.0)) @@ -136,6 +145,50 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@inquirer/ansi@2.0.4': + resolution: {integrity: sha512-DpcZrQObd7S0R/U3bFdkcT5ebRwbTTC4D3tCc1vsJizmgPLxNJBo+AAFmrZwe8zk30P2QzgzGWZ3Q9uJwWuhIg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + + '@inquirer/core@11.1.7': + resolution: {integrity: sha512-1BiBNDk9btIwYIzNZpkikIHXWeNzNncJePPqwDyVMhXhD1ebqbpn1mKGctpoqAbzywZfdG0O4tvmsGIcOevAPQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@2.0.4': + resolution: {integrity: sha512-eLBsjlS7rPS3WEhmOmh1znQ5IsQrxWzxWDxO51e4urv+iVrSnIHbq4zqJIOiyNdYLa+BVjwOtdetcQx1lWPpiQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + + '@inquirer/input@5.0.10': + resolution: {integrity: sha512-nvZ6qEVeX/zVtZ1dY2hTGDQpVGD3R7MYPLODPgKO8Y+RAqxkrP3i/3NwF3fZpLdaMiNuK0z2NaYIx9tPwiSegQ==} + 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: {integrity: sha512-kTK8YIkHV+f02y7bWCh7E0u2/11lul5WepVTclr3UMBtBr05PgcZNWfMa7FY57ihpQFQH/spLMHTcr0rXy50tA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@4.0.4': + resolution: {integrity: sha512-PamArxO3cFJZoOzspzo6cxVlLeIftyBsZw/S9bKY5DzxqJVZgjoj1oP8d0rskKtp7sZxBycsoer1g6UeJV1BBA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -393,10 +446,18 @@ packages: resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} engines: {node: 18 || 20 || >=22} + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -488,6 +549,15 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-wrap-ansi@0.2.0: + resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -700,6 +770,10 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -789,6 +863,10 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -858,6 +936,11 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + vite-node@6.0.0: + resolution: {integrity: sha512-oj4PVrT+pDh6GYf5wfUXkcZyekYS8kKPfLPXVl8qe324Ec6l4K2DUKNadRbZ3LQl0qGcDz+PyOo7ZAh00Y+JjQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + vite@8.0.1: resolution: {integrity: sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1038,6 +1121,42 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@inquirer/ansi@2.0.4': {} + + '@inquirer/core@11.1.7(@types/node@25.5.0)': + dependencies: + '@inquirer/ansi': 2.0.4 + '@inquirer/figures': 2.0.4 + '@inquirer/type': 4.0.4(@types/node@25.5.0) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.0 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 25.5.0 + + '@inquirer/figures@2.0.4': {} + + '@inquirer/input@5.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 + '@inquirer/core': 11.1.7(@types/node@25.5.0) + '@inquirer/figures': 2.0.4 + '@inquirer/type': 4.0.4(@types/node@25.5.0) + optionalDependencies: + '@types/node': 25.5.0 + + '@inquirer/type@4.0.4(@types/node@25.5.0)': + optionalDependencies: + '@types/node': 25.5.0 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -1302,8 +1421,12 @@ snapshots: dependencies: balanced-match: 4.0.4 + cac@7.0.0: {} + chai@6.2.2: {} + cli-width@4.1.0: {} + convert-source-map@2.0.0: {} cross-spawn@7.0.6: @@ -1402,6 +1525,16 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-wrap-ansi@0.2.0: + dependencies: + fast-string-width: 3.0.2 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -1577,6 +1710,8 @@ snapshots: ms@2.1.3: {} + mute-stream@3.0.0: {} + nanoid@3.3.11: {} natural-compare@1.4.0: {} @@ -1665,6 +1800,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@4.1.0: {} + source-map-js@1.2.1: {} source-map@0.6.1: {} @@ -1721,6 +1858,27 @@ snapshots: dependencies: punycode: 2.3.1 + vite-node@6.0.0(@types/node@25.5.0): + dependencies: + cac: 7.0.0 + es-module-lexer: 2.0.0 + obug: 2.1.1 + pathe: 2.0.3 + vite: 8.0.1(@types/node@25.5.0) + transitivePeerDependencies: + - '@types/node' + - '@vitejs/devtools' + - esbuild + - jiti + - less + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + vite@8.0.1(@types/node@25.5.0): dependencies: lightningcss: 1.32.0 diff --git a/src/cmd.ts b/src/cmd.ts index 1280fe0..edb3180 100644 --- a/src/cmd.ts +++ b/src/cmd.ts @@ -3,13 +3,14 @@ import path from "node:path" import fs from "node:fs/promises" import { massarg } from "massarg" -import { ListCommandCliOptions, LogLevel, ScaffoldCmdConfig } from "./types" +import { ListCommandCliOptions, LogLevel, ScaffoldCmdConfig, ScaffoldConfigMap } from "./types" import { Scaffold } from "./scaffold" import { getConfigFile, parseAppendData, parseConfigFile } from "./config" import { log } from "./logger" import { MassargCommand } from "massarg/command" import { getUniqueTmpPath as generateUniqueTmpPath } from "./file" import { colorize } from "./colors" +import { isInteractive, promptForMissingConfig } from "./prompts" export async function parseCliArgs(args = process.argv.slice(2)) { const isProjectRoot = Boolean(await fs.stat(path.join(__dirname, "package.json")).catch(() => false)) @@ -32,6 +33,16 @@ export async function parseCliArgs(args = process.argv.slice(2)) { log(config, LogLevel.info, `Simple Scaffold v${pkg.version}`) config.tmpDir = generateUniqueTmpPath() try { + // If a config file is provided, load it early so we can prompt for template key + const hasConfigSource = Boolean(config.config || config.git) + let configMap: ScaffoldConfigMap | undefined + if (hasConfigSource) { + configMap = await getConfigFile(config) + } + + // Prompt for missing values interactively + config = await promptForMissingConfig(config, configMap) + log(config, LogLevel.debug, "Parsing config file...", config) const parsed = await parseConfigFile(config) await Scaffold(parsed) @@ -40,7 +51,7 @@ export async function parseCliArgs(args = process.argv.slice(2)) { log(config, LogLevel.error, message) } finally { log(config, LogLevel.debug, "Cleaning up temporary files...", config.tmpDir) - await fs.rm(config.tmpDir, { recursive: true, force: true }) + if (config.tmpDir) await fs.rm(config.tmpDir, { recursive: true, force: true }) } }) .option({ @@ -49,9 +60,8 @@ export async function parseCliArgs(args = process.argv.slice(2)) { description: "Name to be passed to the generated files. `{{name}}` and other data parameters inside " + "contents and file names will be replaced accordingly. You may omit the `--name` or `-n` " + - "for this specific option.", + "for this specific option. If omitted in an interactive terminal, you will be prompted.", isDefault: true, - required: !isConfigProvided, }) .option({ name: "config", @@ -68,15 +78,15 @@ export async function parseCliArgs(args = process.argv.slice(2)) { aliases: ["k"], description: "Key to load inside the config file. This overwrites the config key provided after the colon in `--config` " + - "(e.g. `--config scaffold.cmd.js:component)`", + "(e.g. `--config scaffold.cmd.js:component)`. If omitted and multiple templates are available, " + + "you will be prompted to select one.", }) .option({ name: "output", aliases: ["o"], description: "Path to output to. If `--subdir` is enabled, the subdir will be created inside " + - "this path. Default is current working directory.", - required: !isConfigProvided, + "this path. If omitted in an interactive terminal, you will be prompted.", }) .option({ name: "templates", @@ -85,8 +95,8 @@ export async function parseCliArgs(args = process.argv.slice(2)) { description: "Template files to use as input. You may provide multiple files, each of which can be a relative or " + "absolute path, " + - "or a glob pattern for multiple file matching easily.", - required: !isConfigProvided, + "or a glob pattern for multiple file matching easily. If omitted in an interactive terminal, " + + "you will be prompted for a comma-separated list.", }) .flag({ name: "overwrite", @@ -192,7 +202,7 @@ export async function parseCliArgs(args = process.argv.slice(2)) { log(config, LogLevel.error, message) } finally { log(config, LogLevel.debug, "Cleaning up temporary files...", config.tmpDir) - await fs.rm(config.tmpDir, { recursive: true, force: true }) + if (config.tmpDir) await fs.rm(config.tmpDir, { recursive: true, force: true }) } }, }) diff --git a/src/prompts.ts b/src/prompts.ts new file mode 100644 index 0000000..6808e1e --- /dev/null +++ b/src/prompts.ts @@ -0,0 +1,99 @@ +import input from "@inquirer/input" +import select from "@inquirer/select" +import { colorize } from "./colors" +import { ScaffoldCmdConfig, ScaffoldConfigMap } from "./types" + +/** Prompts the user for a scaffold name. */ +export async function promptForName(): Promise { + return input({ + message: colorize.cyan("Scaffold name:"), + required: true, + validate: (value) => { + if (!value.trim()) return "Name cannot be empty" + return true + }, + }) +} + +/** Prompts the user to select a template key from the available config keys. */ +export async function promptForTemplateKey(configMap: ScaffoldConfigMap): Promise { + const keys = Object.keys(configMap) + if (keys.length === 0) { + throw new Error("No templates found in config file") + } + if (keys.length === 1) { + return keys[0] + } + return select({ + message: colorize.cyan("Select a template:"), + choices: keys.map((key) => ({ + name: key, + value: key, + })), + }) +} + +/** Prompts the user for an output directory path. */ +export async function promptForOutput(): Promise { + return input({ + message: colorize.cyan("Output directory:"), + required: true, + default: ".", + validate: (value) => { + if (!value.trim()) return "Output directory cannot be empty" + return true + }, + }) +} + +/** Prompts the user for template paths (comma-separated). */ +export async function promptForTemplates(): Promise { + const value = await input({ + message: colorize.cyan("Template paths (comma-separated):"), + required: true, + validate: (value) => { + if (!value.trim()) return "At least one template path is required" + return true + }, + }) + return value.split(",").map((t) => t.trim()).filter(Boolean) +} + +/** Returns true if the process is running in an interactive terminal. */ +export function isInteractive(): boolean { + return Boolean(process.stdin.isTTY) +} + +/** + * Fills in missing config values by prompting the user interactively. + * Only prompts when running in a TTY — in non-interactive mode, returns config as-is. + */ +export async function promptForMissingConfig( + config: ScaffoldCmdConfig, + configMap?: ScaffoldConfigMap, +): Promise { + if (!isInteractive()) { + return config + } + + if (!config.name) { + config.name = await promptForName() + } + + if (configMap && !config.key) { + const keys = Object.keys(configMap) + if (keys.length > 1) { + config.key = await promptForTemplateKey(configMap) + } + } + + if (!config.output) { + config.output = await promptForOutput() + } + + if (!config.templates || config.templates.length === 0) { + config.templates = await promptForTemplates() + } + + return config +} diff --git a/tests/prompts.test.ts b/tests/prompts.test.ts new file mode 100644 index 0000000..6255da8 --- /dev/null +++ b/tests/prompts.test.ts @@ -0,0 +1,238 @@ +import { describe, test, expect, vi, beforeEach } from "vitest" +import { LogLevel, ScaffoldCmdConfig } from "../src/types" + +vi.mock("@inquirer/input", () => ({ + default: vi.fn(), +})) + +vi.mock("@inquirer/select", () => ({ + default: vi.fn(), +})) + +import inputMock from "@inquirer/input" +import selectMock from "@inquirer/select" +import { + promptForName, + promptForTemplateKey, + promptForOutput, + promptForTemplates, + promptForMissingConfig, + isInteractive, +} from "../src/prompts" + +function mockTTY(value: boolean) { + Object.defineProperty(process.stdin, "isTTY", { value, configurable: true }) +} + +const blankConfig: ScaffoldCmdConfig = { + logLevel: LogLevel.none, + name: "", + output: "", + templates: [], + data: {}, + overwrite: false, + subdir: false, + dryRun: false, + quiet: false, + version: false, +} + +describe("prompts", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("promptForName", () => { + test("calls input prompt and returns result", async () => { + vi.mocked(inputMock).mockResolvedValue("my-component") + const result = await promptForName() + expect(result).toEqual("my-component") + expect(inputMock).toHaveBeenCalledOnce() + }) + }) + + describe("promptForTemplateKey", () => { + test("calls select prompt when multiple keys", async () => { + vi.mocked(selectMock).mockResolvedValue("component") + const result = await promptForTemplateKey({ + default: { name: "d", templates: [], output: "" }, + component: { name: "c", templates: [], output: "" }, + }) + expect(result).toEqual("component") + expect(selectMock).toHaveBeenCalledOnce() + }) + + test("returns single key without prompting", async () => { + const result = await promptForTemplateKey({ + default: { name: "d", templates: [], output: "" }, + }) + expect(result).toEqual("default") + expect(selectMock).not.toHaveBeenCalled() + }) + + test("throws when config map is empty", async () => { + await expect(promptForTemplateKey({})).rejects.toThrow("No templates found") + }) + + test("presents all keys as choices", async () => { + vi.mocked(selectMock).mockResolvedValue("b") + await promptForTemplateKey({ + a: { name: "a", templates: [], output: "" }, + 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 }[] } + expect(call.choices).toEqual([ + { name: "a", value: "a" }, + { name: "b", value: "b" }, + { name: "c", value: "c" }, + ]) + }) + }) + + describe("promptForOutput", () => { + test("calls input prompt and returns result", async () => { + vi.mocked(inputMock).mockResolvedValue("./dist") + const result = await promptForOutput() + expect(result).toEqual("./dist") + expect(inputMock).toHaveBeenCalledOnce() + }) + }) + + describe("promptForTemplates", () => { + test("parses comma-separated input into array", async () => { + vi.mocked(inputMock).mockResolvedValue("src/templates, lib/other") + const result = await promptForTemplates() + expect(result).toEqual(["src/templates", "lib/other"]) + }) + + test("handles single template", async () => { + vi.mocked(inputMock).mockResolvedValue("src/templates") + const result = await promptForTemplates() + expect(result).toEqual(["src/templates"]) + }) + + test("trims whitespace and filters empty entries", async () => { + vi.mocked(inputMock).mockResolvedValue(" a , , b , ") + const result = await promptForTemplates() + expect(result).toEqual(["a", "b"]) + }) + }) + + describe("isInteractive", () => { + test("returns a boolean", () => { + expect(typeof isInteractive()).toBe("boolean") + }) + }) + + describe("promptForMissingConfig", () => { + 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 + + const config = { ...blankConfig } + const result = await promptForMissingConfig(config) + expect(result.name).toEqual("my-app") + expect(result.output).toEqual("./output") + expect(result.templates).toEqual(["src/tpl"]) + expect(inputMock).toHaveBeenCalledTimes(3) + }) + + test("does not prompt for values already provided", async () => { + mockTTY(true) + + const config = { + ...blankConfig, + name: "already-set", + output: "./out", + templates: ["tpl"], + } + const result = await promptForMissingConfig(config) + expect(result.name).toEqual("already-set") + expect(result.output).toEqual("./out") + expect(result.templates).toEqual(["tpl"]) + expect(inputMock).not.toHaveBeenCalled() + }) + + 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 + vi.mocked(selectMock).mockResolvedValue("component") + + const configMap = { + default: { name: "d", templates: [], output: "" }, + component: { name: "c", templates: [], output: "" }, + } + const config = { ...blankConfig } + const result = await promptForMissingConfig(config, configMap) + expect(result.key).toEqual("component") + }) + + test("does not prompt for template key when already set", async () => { + mockTTY(true) + + const configMap = { + default: { name: "d", templates: [], output: "" }, + component: { name: "c", templates: [], output: "" }, + } + 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() + }) + + test("does not prompt for template key when only one template", async () => { + mockTTY(true) + + const configMap = { + default: { name: "d", templates: [], output: "" }, + } + const config = { ...blankConfig, name: "test", output: "./out", templates: ["tpl"] } + const result = await promptForMissingConfig(config, configMap) + expect(result.key).toBeUndefined() + expect(selectMock).not.toHaveBeenCalled() + }) + + test("does not prompt in non-interactive mode", async () => { + mockTTY(false) + + const config = { ...blankConfig } + const result = await promptForMissingConfig(config) + expect(result.name).toEqual("") + expect(result.output).toEqual("") + expect(result.templates).toEqual([]) + expect(inputMock).not.toHaveBeenCalled() + }) + + test("does not prompt for config key when no config map provided", async () => { + mockTTY(true) + vi.mocked(inputMock) + .mockResolvedValueOnce("name") + .mockResolvedValueOnce("./out") + .mockResolvedValueOnce("tpl") + + const config = { ...blankConfig } + const result = await promptForMissingConfig(config) + expect(result.key).toBeUndefined() + expect(selectMock).not.toHaveBeenCalled() + }) + + test("only prompts for missing values, not provided ones", async () => { + mockTTY(true) + vi.mocked(inputMock).mockResolvedValueOnce("src/tpl") // only templates missing + + const config = { ...blankConfig, name: "app", output: "./out" } + const result = await promptForMissingConfig(config) + expect(result.name).toEqual("app") + expect(result.output).toEqual("./out") + expect(result.templates).toEqual(["src/tpl"]) + expect(inputMock).toHaveBeenCalledOnce() + }) + }) +})