mirror of
https://github.com/chenasraf/simple-scaffold.git
synced 2026-05-17 17:28:09 +00:00
feat: config validation
This commit is contained in:
@@ -2,3 +2,4 @@ docs/docs/api/
|
||||
examples/
|
||||
.github/
|
||||
CHANGELOG.md
|
||||
pnpm-lock.yaml
|
||||
|
||||
@@ -61,7 +61,8 @@
|
||||
"glob": "^13.0.6",
|
||||
"handlebars": "^4.7.8",
|
||||
"massarg": "2.1.1",
|
||||
"minimatch": "^10.2.4"
|
||||
"minimatch": "^10.2.4",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
|
||||
2195
pnpm-lock.yaml
generated
2195
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
export * from "./scaffold"
|
||||
export * from "./types"
|
||||
export { validateConfig, assertConfigValid, scaffoldConfigSchema } from "./validate"
|
||||
import Scaffold from "./scaffold"
|
||||
|
||||
export default Scaffold
|
||||
|
||||
@@ -17,6 +17,7 @@ import { log, logInitStep } from "./logger"
|
||||
import { parseConfigFile } from "./config"
|
||||
import { resolveInputs } from "./prompts"
|
||||
import { loadIgnorePatterns, filterIgnoredFiles } from "./ignore"
|
||||
import { assertConfigValid } from "./validate"
|
||||
|
||||
/**
|
||||
* Create a scaffold using given `options`.
|
||||
@@ -53,6 +54,7 @@ import { loadIgnorePatterns, filterIgnoredFiles } from "./ignore"
|
||||
export async function Scaffold(config: ScaffoldConfig): Promise<void> {
|
||||
config.output ??= process.cwd()
|
||||
|
||||
await assertConfigValid(config)
|
||||
config = await resolveInputs(config)
|
||||
registerHelpers(config)
|
||||
const writtenFiles: string[] = []
|
||||
|
||||
171
src/validate.ts
Normal file
171
src/validate.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { z } from "zod/v4"
|
||||
import { pathExists } from "./fs-utils"
|
||||
|
||||
// --- Reusable schemas ---
|
||||
|
||||
/** Schema for a JavaScript function value. */
|
||||
const functionSchema = z
|
||||
.any()
|
||||
.refine((v) => typeof v === "function", { message: "Expected a function" })
|
||||
|
||||
/** Schema for a value that can be either a string or a function. */
|
||||
const stringOrFunctionSchema = z.union([z.string(), functionSchema])
|
||||
|
||||
/** Schema for a value that can be either a boolean or a function. */
|
||||
const booleanOrFunctionSchema = z.union([z.boolean(), functionSchema])
|
||||
|
||||
/** Schema for a select input option — either a plain string or a `{ name, value }` object. */
|
||||
const selectOptionSchema = z.union([z.string(), z.object({ name: z.string(), value: z.string() })])
|
||||
|
||||
/** Schema for the input type enum. */
|
||||
const inputTypeSchema = z.enum(["text", "select", "confirm", "number"])
|
||||
|
||||
/** Schema for the log level enum. */
|
||||
const logLevelSchema = z.enum(["none", "debug", "info", "warning", "error"])
|
||||
|
||||
// --- Input schema ---
|
||||
|
||||
/** Zod schema for a single scaffold input definition. */
|
||||
const scaffoldInputSchema = z.object({
|
||||
type: inputTypeSchema.optional(),
|
||||
message: z.string().optional(),
|
||||
required: z.boolean().optional(),
|
||||
default: z.union([z.string(), z.boolean(), z.number()]).optional(),
|
||||
options: z.array(selectOptionSchema).optional(),
|
||||
})
|
||||
|
||||
type InputDef = z.infer<typeof scaffoldInputSchema>
|
||||
|
||||
function validateInputSemantics(
|
||||
key: string,
|
||||
input: InputDef,
|
||||
): { path: (string | number)[]; message: string }[] {
|
||||
const issues: { path: (string | number)[]; message: string }[] = []
|
||||
if (input.type === "select" && (!input.options || input.options.length === 0)) {
|
||||
issues.push({
|
||||
path: ["inputs", key, "options"],
|
||||
message: "select input must have a non-empty options array",
|
||||
})
|
||||
}
|
||||
if (
|
||||
input.type === "confirm" &&
|
||||
input.default !== undefined &&
|
||||
typeof input.default !== "boolean"
|
||||
) {
|
||||
issues.push({
|
||||
path: ["inputs", key, "default"],
|
||||
message: "confirm input default must be a boolean",
|
||||
})
|
||||
}
|
||||
if (input.type === "number" && input.default !== undefined && typeof input.default !== "number") {
|
||||
issues.push({
|
||||
path: ["inputs", key, "default"],
|
||||
message: "number input default must be a number",
|
||||
})
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
// --- Config schema ---
|
||||
|
||||
/** Zod schema for ScaffoldConfig. */
|
||||
const scaffoldConfigSchema = z
|
||||
.object({
|
||||
name: z.string().min(1, "name is required"),
|
||||
templates: z.array(z.string()).min(1, "templates must contain at least one entry"),
|
||||
output: stringOrFunctionSchema,
|
||||
subdir: z.boolean().optional(),
|
||||
data: z.record(z.string(), z.unknown()).optional(),
|
||||
overwrite: booleanOrFunctionSchema.optional(),
|
||||
logLevel: logLevelSchema.optional(),
|
||||
dryRun: z.boolean().optional(),
|
||||
helpers: z.record(z.string(), functionSchema).optional(),
|
||||
subdirHelper: z.string().optional(),
|
||||
inputs: z.record(z.string(), scaffoldInputSchema).optional(),
|
||||
beforeWrite: functionSchema.optional(),
|
||||
afterScaffold: stringOrFunctionSchema.optional(),
|
||||
tmpDir: z.string().optional(),
|
||||
})
|
||||
.check((ctx) => {
|
||||
const config = ctx.value
|
||||
|
||||
if (config.subdirHelper && !config.subdir) {
|
||||
ctx.issues.push({
|
||||
code: "custom",
|
||||
message: "subdirHelper is set but subdir is not enabled",
|
||||
path: ["subdirHelper"],
|
||||
input: config,
|
||||
})
|
||||
}
|
||||
|
||||
if (config.inputs) {
|
||||
for (const [key, val] of Object.entries(config.inputs)) {
|
||||
for (const issue of validateInputSemantics(key, val)) {
|
||||
ctx.issues.push({ code: "custom", ...issue, input: config })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export {
|
||||
scaffoldConfigSchema,
|
||||
scaffoldInputSchema,
|
||||
functionSchema,
|
||||
stringOrFunctionSchema,
|
||||
booleanOrFunctionSchema,
|
||||
selectOptionSchema,
|
||||
inputTypeSchema,
|
||||
logLevelSchema,
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a scaffold config and returns a list of human-readable errors.
|
||||
* Returns an empty array if the config is valid.
|
||||
*/
|
||||
export function validateConfig(config: unknown): string[] {
|
||||
const result = scaffoldConfigSchema.safeParse(config)
|
||||
if (result.success) {
|
||||
return []
|
||||
}
|
||||
return result.error.issues.map((issue) => {
|
||||
const path = issue.path.length > 0 ? issue.path.join(".") : "(root)"
|
||||
return `${path}: ${issue.message}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates template paths exist on disk.
|
||||
* Only checks non-glob, non-negation paths.
|
||||
*/
|
||||
export async function validateTemplatePaths(templates: string[]): Promise<string[]> {
|
||||
const errors: string[] = []
|
||||
for (const tpl of templates) {
|
||||
if (tpl.startsWith("!") || tpl.includes("*")) continue
|
||||
if (!(await pathExists(tpl))) {
|
||||
errors.push(`templates: path does not exist: ${tpl}`)
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the config and throws a formatted error if any issues are found.
|
||||
* Checks both schema validity and template path existence.
|
||||
*/
|
||||
export async function assertConfigValid(config: unknown): Promise<void> {
|
||||
const schemaErrors = validateConfig(config)
|
||||
|
||||
const pathErrors =
|
||||
config &&
|
||||
typeof config === "object" &&
|
||||
"templates" in config &&
|
||||
Array.isArray((config as { templates: unknown }).templates)
|
||||
? await validateTemplatePaths((config as { templates: string[] }).templates)
|
||||
: []
|
||||
|
||||
const allErrors = [...schemaErrors, ...pathErrors]
|
||||
if (allErrors.length > 0) {
|
||||
const lines = allErrors.map((e) => ` - ${e}`)
|
||||
throw new Error(`Invalid scaffold config:\n${lines.join("\n")}`)
|
||||
}
|
||||
}
|
||||
231
tests/validate.test.ts
Normal file
231
tests/validate.test.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest"
|
||||
import mockFs from "mock-fs"
|
||||
import { Console } from "console"
|
||||
import { validateConfig, validateTemplatePaths, assertConfigValid } from "../src/validate"
|
||||
|
||||
const validConfig = {
|
||||
name: "test",
|
||||
templates: ["templates"],
|
||||
output: "output",
|
||||
}
|
||||
|
||||
describe("validate", () => {
|
||||
describe("validateConfig", () => {
|
||||
test("returns no errors for valid config", () => {
|
||||
expect(validateConfig(validConfig)).toEqual([])
|
||||
})
|
||||
|
||||
test("returns no errors with all optional fields", () => {
|
||||
const errors = validateConfig({
|
||||
...validConfig,
|
||||
subdir: true,
|
||||
subdirHelper: "camelCase",
|
||||
data: { key: "value" },
|
||||
logLevel: "debug",
|
||||
dryRun: true,
|
||||
overwrite: true,
|
||||
inputs: {
|
||||
author: { type: "text", message: "Author", required: true },
|
||||
},
|
||||
})
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test("errors on missing name", () => {
|
||||
const errors = validateConfig({ ...validConfig, name: "" })
|
||||
expect(errors.length).toBeGreaterThan(0)
|
||||
expect(errors[0]).toContain("name")
|
||||
})
|
||||
|
||||
test("errors on missing templates", () => {
|
||||
const errors = validateConfig({ ...validConfig, templates: [] })
|
||||
expect(errors.length).toBeGreaterThan(0)
|
||||
expect(errors[0]).toContain("templates")
|
||||
})
|
||||
|
||||
test("errors on invalid logLevel", () => {
|
||||
const errors = validateConfig({ ...validConfig, logLevel: "verbose" })
|
||||
expect(errors.length).toBeGreaterThan(0)
|
||||
expect(errors[0]).toContain("logLevel")
|
||||
})
|
||||
|
||||
test("errors on subdirHelper without subdir", () => {
|
||||
const errors = validateConfig({
|
||||
...validConfig,
|
||||
subdirHelper: "camelCase",
|
||||
})
|
||||
expect(errors.length).toBeGreaterThan(0)
|
||||
expect(errors[0]).toContain("subdirHelper")
|
||||
})
|
||||
|
||||
test("no error on subdirHelper with subdir", () => {
|
||||
const errors = validateConfig({
|
||||
...validConfig,
|
||||
subdir: true,
|
||||
subdirHelper: "camelCase",
|
||||
})
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test("errors on select input without options", () => {
|
||||
const errors = validateConfig({
|
||||
...validConfig,
|
||||
inputs: {
|
||||
license: { type: "select" },
|
||||
},
|
||||
})
|
||||
expect(errors.length).toBeGreaterThan(0)
|
||||
expect(errors.some((e) => e.includes("select") && e.includes("options"))).toBe(true)
|
||||
})
|
||||
|
||||
test("no error on select input with options", () => {
|
||||
const errors = validateConfig({
|
||||
...validConfig,
|
||||
inputs: {
|
||||
license: { type: "select", options: ["MIT", "Apache"] },
|
||||
},
|
||||
})
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test("errors on confirm input with non-boolean default", () => {
|
||||
const errors = validateConfig({
|
||||
...validConfig,
|
||||
inputs: {
|
||||
flag: { type: "confirm", default: "yes" },
|
||||
},
|
||||
})
|
||||
expect(errors.length).toBeGreaterThan(0)
|
||||
expect(errors.some((e) => e.includes("confirm") && e.includes("boolean"))).toBe(true)
|
||||
})
|
||||
|
||||
test("errors on number input with non-number default", () => {
|
||||
const errors = validateConfig({
|
||||
...validConfig,
|
||||
inputs: {
|
||||
port: { type: "number", default: "3000" },
|
||||
},
|
||||
})
|
||||
expect(errors.length).toBeGreaterThan(0)
|
||||
expect(errors.some((e) => e.includes("number"))).toBe(true)
|
||||
})
|
||||
|
||||
test("valid input types pass", () => {
|
||||
const errors = validateConfig({
|
||||
...validConfig,
|
||||
inputs: {
|
||||
a: { type: "text", required: true },
|
||||
b: { type: "select", options: ["x", "y"] },
|
||||
c: { type: "confirm", default: false },
|
||||
d: { type: "number", default: 42 },
|
||||
},
|
||||
})
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test("accepts function output", () => {
|
||||
const errors = validateConfig({
|
||||
...validConfig,
|
||||
output: () => "dynamic-output",
|
||||
})
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test("accepts function overwrite", () => {
|
||||
const errors = validateConfig({
|
||||
...validConfig,
|
||||
overwrite: () => true,
|
||||
})
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test("accepts afterScaffold string", () => {
|
||||
const errors = validateConfig({
|
||||
...validConfig,
|
||||
afterScaffold: "npm install",
|
||||
})
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test("accepts afterScaffold function", () => {
|
||||
const errors = validateConfig({
|
||||
...validConfig,
|
||||
afterScaffold: () => {},
|
||||
})
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateTemplatePaths", () => {
|
||||
beforeEach(() => {
|
||||
console = new Console(process.stdout, process.stderr)
|
||||
mockFs({
|
||||
templates: { "file.txt": "content" },
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore()
|
||||
})
|
||||
|
||||
test("returns no errors for existing paths", async () => {
|
||||
const errors = await validateTemplatePaths(["templates"])
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test("returns error for missing paths", async () => {
|
||||
const errors = await validateTemplatePaths(["nonexistent"])
|
||||
expect(errors.length).toBe(1)
|
||||
expect(errors[0]).toContain("nonexistent")
|
||||
})
|
||||
|
||||
test("skips glob patterns", async () => {
|
||||
const errors = await validateTemplatePaths(["templates/**/*"])
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test("skips negation patterns", async () => {
|
||||
const errors = await validateTemplatePaths(["!excluded"])
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("assertConfigValid", () => {
|
||||
beforeEach(() => {
|
||||
console = new Console(process.stdout, process.stderr)
|
||||
mockFs({
|
||||
templates: { "file.txt": "content" },
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore()
|
||||
})
|
||||
|
||||
test("does not throw for valid config", async () => {
|
||||
await expect(assertConfigValid(validConfig)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("throws formatted error for invalid config", async () => {
|
||||
await expect(assertConfigValid({ name: "", templates: [], output: "" })).rejects.toThrow(
|
||||
"Invalid scaffold config",
|
||||
)
|
||||
})
|
||||
|
||||
test("includes all errors in message", async () => {
|
||||
try {
|
||||
await assertConfigValid({ name: "", templates: [], output: "out" })
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message
|
||||
expect(msg).toContain("name")
|
||||
expect(msg).toContain("templates")
|
||||
}
|
||||
})
|
||||
|
||||
test("checks template path existence", async () => {
|
||||
await expect(
|
||||
assertConfigValid({ name: "test", templates: ["missing"], output: "out" }),
|
||||
).rejects.toThrow("does not exist")
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user