mirror of
https://github.com/chenasraf/simple-scaffold.git
synced 2026-05-17 17:28:09 +00:00
feat: interactive inputs
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
158
pnpm-lock.yaml
generated
158
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
30
src/cmd.ts
30
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 })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
99
src/prompts.ts
Normal file
99
src/prompts.ts
Normal file
@@ -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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string[]> {
|
||||
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<ScaffoldCmdConfig> {
|
||||
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
|
||||
}
|
||||
238
tests/prompts.test.ts
Normal file
238
tests/prompts.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user