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/
|
examples/
|
||||||
.github/
|
.github/
|
||||||
CHANGELOG.md
|
CHANGELOG.md
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|||||||
@@ -61,7 +61,8 @@
|
|||||||
"glob": "^13.0.6",
|
"glob": "^13.0.6",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"massarg": "2.1.1",
|
"massarg": "2.1.1",
|
||||||
"minimatch": "^10.2.4"
|
"minimatch": "^10.2.4",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@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 "./scaffold"
|
||||||
export * from "./types"
|
export * from "./types"
|
||||||
|
export { validateConfig, assertConfigValid, scaffoldConfigSchema } from "./validate"
|
||||||
import Scaffold from "./scaffold"
|
import Scaffold from "./scaffold"
|
||||||
|
|
||||||
export default Scaffold
|
export default Scaffold
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { log, logInitStep } from "./logger"
|
|||||||
import { parseConfigFile } from "./config"
|
import { parseConfigFile } from "./config"
|
||||||
import { resolveInputs } from "./prompts"
|
import { resolveInputs } from "./prompts"
|
||||||
import { loadIgnorePatterns, filterIgnoredFiles } from "./ignore"
|
import { loadIgnorePatterns, filterIgnoredFiles } from "./ignore"
|
||||||
|
import { assertConfigValid } from "./validate"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a scaffold using given `options`.
|
* Create a scaffold using given `options`.
|
||||||
@@ -53,6 +54,7 @@ import { loadIgnorePatterns, filterIgnoredFiles } from "./ignore"
|
|||||||
export async function Scaffold(config: ScaffoldConfig): Promise<void> {
|
export async function Scaffold(config: ScaffoldConfig): Promise<void> {
|
||||||
config.output ??= process.cwd()
|
config.output ??= process.cwd()
|
||||||
|
|
||||||
|
await assertConfigValid(config)
|
||||||
config = await resolveInputs(config)
|
config = await resolveInputs(config)
|
||||||
registerHelpers(config)
|
registerHelpers(config)
|
||||||
const writtenFiles: string[] = []
|
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