stackloom-cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/bin/cli.js +306 -0
- package/branding.json +8 -0
- package/package.json +72 -0
- package/src/__tests__/cli-smoke.test.js +46 -0
- package/src/blueprint/__tests__/blueprint.test.js +116 -0
- package/src/blueprint/blueprint.js +181 -0
- package/src/blueprint/default.blueprint.json +78 -0
- package/src/blueprint/index.js +10 -0
- package/src/blueprint/loader.js +101 -0
- package/src/blueprint/schema-kit.js +161 -0
- package/src/blueprint/schema.js +78 -0
- package/src/branding/__tests__/branding.test.js +49 -0
- package/src/branding/index.js +48 -0
- package/src/commands/__tests__/commands.test.js +83 -0
- package/src/commands/check.js +71 -0
- package/src/commands/cleanup.js +347 -0
- package/src/commands/customize.js +263 -0
- package/src/commands/doctor.js +84 -0
- package/src/commands/env.js +75 -0
- package/src/commands/finalize.js +68 -0
- package/src/commands/generate/ci-cd.js +378 -0
- package/src/commands/generate/deploy-advanced.js +253 -0
- package/src/commands/generate/deploy.js +99 -0
- package/src/commands/generate/env.template.js +221 -0
- package/src/commands/generate/index.js +7 -0
- package/src/commands/generate/module.js +836 -0
- package/src/commands/generate/page.js +1415 -0
- package/src/commands/generate/test-scaffold.js +279 -0
- package/src/commands/generate/theme.js +67 -0
- package/src/commands/generate-resource.js +133 -0
- package/src/commands/index.js +9 -0
- package/src/commands/init.js +350 -0
- package/src/commands/make/resource.js +298 -0
- package/src/commands/preset.js +57 -0
- package/src/commands/remove.js +170 -0
- package/src/commands/rename.js +54 -0
- package/src/commands/rollback.js +90 -0
- package/src/commands/wizard.js +303 -0
- package/src/core/__tests__/generator.test.js +67 -0
- package/src/core/__tests__/marker-strategy.test.js +57 -0
- package/src/core/__tests__/resource-definition.test.js +32 -0
- package/src/core/generator.js +542 -0
- package/src/core/marker-strategy.js +138 -0
- package/src/core/resource-definition.js +346 -0
- package/src/core/state-tracker.js +67 -0
- package/src/core/template-loader.js +163 -0
- package/src/engine/__tests__/engine.test.js +306 -0
- package/src/engine/index.js +21 -0
- package/src/engine/injector.js +198 -0
- package/src/engine/pipeline.js +138 -0
- package/src/engine/transaction.js +105 -0
- package/src/engine/validator.js +190 -0
- package/src/index.js +4 -0
- package/src/recipes/__tests__/recipe.test.js +128 -0
- package/src/recipes/builtin/module.json +22 -0
- package/src/recipes/builtin/page.json +21 -0
- package/src/recipes/builtin/resource.json +35 -0
- package/src/recipes/condition.js +147 -0
- package/src/recipes/index.js +11 -0
- package/src/recipes/loader.js +95 -0
- package/src/recipes/recipe.js +89 -0
- package/src/recipes/schema.js +47 -0
- package/src/schemas/__tests__/schemas.test.js +67 -0
- package/src/schemas/index.js +18 -0
- package/src/schemas/options.js +38 -0
- package/src/schemas/resource.js +112 -0
- package/src/services/__tests__/reporter.test.js +98 -0
- package/src/services/clock.js +31 -0
- package/src/services/index.js +43 -0
- package/src/services/reporter.js +136 -0
- package/src/templates/resource/api.js.ejs +39 -0
- package/src/templates/resource/components/form.jsx.ejs +81 -0
- package/src/templates/resource/components/table.jsx.ejs +68 -0
- package/src/templates/resource/controller.js.ejs +154 -0
- package/src/templates/resource/hooks.js.ejs +46 -0
- package/src/templates/resource/model.js.ejs +64 -0
- package/src/templates/resource/page-detail.jsx.ejs +55 -0
- package/src/templates/resource/page-form.jsx.ejs +30 -0
- package/src/templates/resource/page-inline.jsx.ejs +74 -0
- package/src/templates/resource/page-modal.jsx.ejs +98 -0
- package/src/templates/resource/page-page.jsx.ejs +99 -0
- package/src/templates/resource/page-sidepanel.jsx.ejs +100 -0
- package/src/templates/resource/routes.js.ejs +35 -0
- package/src/templates/resource/service.js.ejs +132 -0
- package/src/templates/resource/test.ejs +71 -0
- package/src/templates/resource/types.ts.ejs +17 -0
- package/src/templates/resource/validator.js.ejs +26 -0
- package/src/templates/snippets/lazy-import.ejs +1 -0
- package/src/templates/snippets/nav-entry.ejs +1 -0
- package/src/templates/snippets/route-entry.ejs +5 -0
- package/src/templates/snippets/route-mount.ejs +1 -0
- package/src/utils/fieldValidators.js +371 -0
- package/src/utils/logging/logger.js +47 -0
- package/src/utils/namingUtils.js +38 -0
- package/src/utils/sanitize.js +200 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* condition — a tiny, safe boolean-expression evaluator for recipe `when` rules.
|
|
3
|
+
*
|
|
4
|
+
* Supports identifiers (resolved from a flat context; missing = falsy),
|
|
5
|
+
* quoted string literals, `&&`, `||`, `!`, `==`, `!=`, and parentheses.
|
|
6
|
+
* Hand-written recursive-descent parser — no `eval`, no dependencies — so a
|
|
7
|
+
* recipe manifest can carry conditions without becoming a scripting surface.
|
|
8
|
+
*
|
|
9
|
+
* evaluateCondition('withFrontend && usesTypeScript', ctx)
|
|
10
|
+
* evaluateCondition('withTests || architecture == "advanced"', ctx)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Sticky tokenizer: leading whitespace, then one operator / paren / quoted
|
|
14
|
+
// string / identifier. Identifiers allow `:`, `.`, `-` so `hasField:slug` works.
|
|
15
|
+
const TOKEN = /\s*(\|\||&&|==|!=|!|\(|\)|"[^"]*"|'[^']*'|[\w:.\-]+)/y;
|
|
16
|
+
|
|
17
|
+
function tokenize(input) {
|
|
18
|
+
const tokens = [];
|
|
19
|
+
let pos = 0;
|
|
20
|
+
while (pos < input.length) {
|
|
21
|
+
TOKEN.lastIndex = pos;
|
|
22
|
+
const match = TOKEN.exec(input);
|
|
23
|
+
if (!match || match.index !== pos) {
|
|
24
|
+
if (input.slice(pos).trim() === "") break;
|
|
25
|
+
throw new Error(`Cannot parse condition near "${input.slice(pos)}"`);
|
|
26
|
+
}
|
|
27
|
+
tokens.push(match[1]);
|
|
28
|
+
pos = TOKEN.lastIndex;
|
|
29
|
+
}
|
|
30
|
+
return tokens;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const OPERATORS = new Set(["||", "&&", "==", "!=", "!", "(", ")"]);
|
|
34
|
+
|
|
35
|
+
function parse(tokens) {
|
|
36
|
+
let i = 0;
|
|
37
|
+
const peek = () => tokens[i];
|
|
38
|
+
const next = () => tokens[i++];
|
|
39
|
+
|
|
40
|
+
function parseOr() {
|
|
41
|
+
let node = parseAnd();
|
|
42
|
+
while (peek() === "||") {
|
|
43
|
+
next();
|
|
44
|
+
node = { op: "||", left: node, right: parseAnd() };
|
|
45
|
+
}
|
|
46
|
+
return node;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseAnd() {
|
|
50
|
+
let node = parseUnary();
|
|
51
|
+
while (peek() === "&&") {
|
|
52
|
+
next();
|
|
53
|
+
node = { op: "&&", left: node, right: parseUnary() };
|
|
54
|
+
}
|
|
55
|
+
return node;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseUnary() {
|
|
59
|
+
if (peek() === "!") {
|
|
60
|
+
next();
|
|
61
|
+
return { op: "!", operand: parseUnary() };
|
|
62
|
+
}
|
|
63
|
+
return parsePrimary();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parsePrimary() {
|
|
67
|
+
if (peek() === "(") {
|
|
68
|
+
next();
|
|
69
|
+
const node = parseOr();
|
|
70
|
+
if (next() !== ")") throw new Error("Unbalanced parentheses in condition");
|
|
71
|
+
return node;
|
|
72
|
+
}
|
|
73
|
+
const left = parseAtom();
|
|
74
|
+
if (peek() === "==" || peek() === "!=") {
|
|
75
|
+
const op = next();
|
|
76
|
+
return { op, left, right: parseAtom() };
|
|
77
|
+
}
|
|
78
|
+
return left;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseAtom() {
|
|
82
|
+
const token = next();
|
|
83
|
+
if (token === undefined) throw new Error("Unexpected end of condition");
|
|
84
|
+
if (OPERATORS.has(token)) throw new Error(`Unexpected token "${token}" in condition`);
|
|
85
|
+
if (
|
|
86
|
+
(token.startsWith('"') && token.endsWith('"')) ||
|
|
87
|
+
(token.startsWith("'") && token.endsWith("'"))
|
|
88
|
+
) {
|
|
89
|
+
return { type: "literal", value: token.slice(1, -1) };
|
|
90
|
+
}
|
|
91
|
+
return { type: "ident", name: token };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const ast = parseOr();
|
|
95
|
+
if (i < tokens.length) {
|
|
96
|
+
throw new Error(`Unexpected trailing token "${tokens[i]}" in condition`);
|
|
97
|
+
}
|
|
98
|
+
return ast;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function valueOf(node, ctx) {
|
|
102
|
+
if (node.type === "literal") return node.value;
|
|
103
|
+
if (node.type === "ident") return ctx[node.name];
|
|
104
|
+
return truthy(node, ctx);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function truthy(node, ctx) {
|
|
108
|
+
if (node.type === "literal") return Boolean(node.value);
|
|
109
|
+
if (node.type === "ident") return Boolean(ctx[node.name]);
|
|
110
|
+
switch (node.op) {
|
|
111
|
+
case "||":
|
|
112
|
+
return truthy(node.left, ctx) || truthy(node.right, ctx);
|
|
113
|
+
case "&&":
|
|
114
|
+
return truthy(node.left, ctx) && truthy(node.right, ctx);
|
|
115
|
+
case "!":
|
|
116
|
+
return !truthy(node.operand, ctx);
|
|
117
|
+
case "==":
|
|
118
|
+
return valueOf(node.left, ctx) === valueOf(node.right, ctx);
|
|
119
|
+
case "!=":
|
|
120
|
+
return valueOf(node.left, ctx) !== valueOf(node.right, ctx);
|
|
121
|
+
default:
|
|
122
|
+
throw new Error("Malformed condition node");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const astCache = new Map();
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Evaluate a recipe `when` expression against a flat context object.
|
|
130
|
+
* A missing/empty expression means "always" (returns true).
|
|
131
|
+
* @param {string|boolean|undefined} expr
|
|
132
|
+
* @param {Record<string, unknown>} ctx
|
|
133
|
+
* @returns {boolean}
|
|
134
|
+
*/
|
|
135
|
+
export function evaluateCondition(expr, ctx = {}) {
|
|
136
|
+
if (expr === undefined || expr === null || expr === "") return true;
|
|
137
|
+
if (typeof expr === "boolean") return expr;
|
|
138
|
+
|
|
139
|
+
let ast = astCache.get(expr);
|
|
140
|
+
if (!ast) {
|
|
141
|
+
const tokens = tokenize(String(expr).trim());
|
|
142
|
+
if (tokens.length === 0) return true;
|
|
143
|
+
ast = parse(tokens);
|
|
144
|
+
astCache.set(expr, ast);
|
|
145
|
+
}
|
|
146
|
+
return truthy(ast, ctx);
|
|
147
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipe subsystem — declarative generation manifests.
|
|
3
|
+
*
|
|
4
|
+
* A recipe answers "what gets generated"; the blueprint answers "where it
|
|
5
|
+
* goes"; the engine just executes. New generation capabilities are new recipe
|
|
6
|
+
* JSON, not new engine code.
|
|
7
|
+
*/
|
|
8
|
+
export { Recipe } from "./recipe.js";
|
|
9
|
+
export { RecipeLoader, RecipeLoadError, recipeLoader } from "./loader.js";
|
|
10
|
+
export { recipeSchema } from "./schema.js";
|
|
11
|
+
export { evaluateCondition } from "./condition.js";
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RecipeLoader — resolves, reads, and validates a recipe by name.
|
|
3
|
+
*
|
|
4
|
+
* Resolution order:
|
|
5
|
+
* 1. A blueprint override — `blueprint.recipes[name]`, resolved relative to
|
|
6
|
+
* the blueprint file's directory (lets a project ship custom recipes).
|
|
7
|
+
* 2. The CLI's built-in recipe — `recipes/builtin/<name>.json`.
|
|
8
|
+
*
|
|
9
|
+
* Every recipe is schema-validated before use, so a malformed manifest fails
|
|
10
|
+
* fast with a path-pointed error rather than producing broken output.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { readFile } from "node:fs/promises";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { recipeSchema } from "./schema.js";
|
|
17
|
+
import { Recipe } from "./recipe.js";
|
|
18
|
+
|
|
19
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const BUILTIN_RECIPES = path.join(__dirname, "builtin");
|
|
21
|
+
|
|
22
|
+
/** Raised when a recipe cannot be found, read, or validated. */
|
|
23
|
+
export class RecipeLoadError extends Error {
|
|
24
|
+
constructor(message, { source, issues } = {}) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "RecipeLoadError";
|
|
27
|
+
this.source = source;
|
|
28
|
+
this.issues = issues;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class RecipeLoader {
|
|
33
|
+
/** Absolute path to the CLI's built-in recipe directory. */
|
|
34
|
+
get builtinDir() {
|
|
35
|
+
return BUILTIN_RECIPES;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Load a recipe by name, honoring any blueprint override.
|
|
40
|
+
* @param {string} name
|
|
41
|
+
* @param {import('../blueprint/blueprint.js').Blueprint} [blueprint]
|
|
42
|
+
*/
|
|
43
|
+
async load(name, blueprint) {
|
|
44
|
+
const ref = blueprint ? blueprint.getRecipe(name) : null;
|
|
45
|
+
if (ref) {
|
|
46
|
+
const resolved = path.isAbsolute(ref)
|
|
47
|
+
? ref
|
|
48
|
+
: path.resolve(path.dirname(blueprint.source), ref);
|
|
49
|
+
if (!existsSync(resolved)) {
|
|
50
|
+
throw new RecipeLoadError(
|
|
51
|
+
`Blueprint "${blueprint.id}" points recipe "${name}" at ${resolved}, which does not exist.`,
|
|
52
|
+
{ source: resolved },
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return this.loadFile(resolved);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const builtin = path.join(BUILTIN_RECIPES, `${name}.json`);
|
|
59
|
+
if (existsSync(builtin)) return this.loadFile(builtin);
|
|
60
|
+
|
|
61
|
+
throw new RecipeLoadError(
|
|
62
|
+
`No recipe named "${name}". Expected a built-in at ${builtin} or a blueprint override.`,
|
|
63
|
+
{ source: builtin },
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Load + validate a recipe from an explicit file path. */
|
|
68
|
+
async loadFile(source) {
|
|
69
|
+
let raw;
|
|
70
|
+
try {
|
|
71
|
+
raw = JSON.parse(await readFile(source, "utf-8"));
|
|
72
|
+
} catch (err) {
|
|
73
|
+
throw new RecipeLoadError(
|
|
74
|
+
`Could not read recipe JSON at ${source}: ${err.message}`,
|
|
75
|
+
{ source },
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const parsed = recipeSchema.safeParse(raw);
|
|
80
|
+
if (!parsed.success) {
|
|
81
|
+
const issues = parsed.error.issues.map(
|
|
82
|
+
(i) => ` • ${i.path.join(".") || "(root)"}: ${i.message}`,
|
|
83
|
+
);
|
|
84
|
+
throw new RecipeLoadError(
|
|
85
|
+
`Invalid recipe at ${source}:\n${issues.join("\n")}`,
|
|
86
|
+
{ source, issues: parsed.error.issues },
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return new Recipe(parsed.data, source);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Shared loader instance for convenience. */
|
|
95
|
+
export const recipeLoader = new RecipeLoader();
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipe — a validated generation manifest with behavior.
|
|
3
|
+
*
|
|
4
|
+
* `plan()` turns the declarative manifest into a concrete, resolved plan for a
|
|
5
|
+
* specific invocation: param defaults applied, `when` conditions evaluated,
|
|
6
|
+
* output paths rendered against the blueprint. The engine then just executes
|
|
7
|
+
* the plan — it never decides *what* to generate, only *how*.
|
|
8
|
+
*/
|
|
9
|
+
import { evaluateCondition } from "./condition.js";
|
|
10
|
+
|
|
11
|
+
/** Substitute `{token}` placeholders from `values`; unknown tokens are left intact. */
|
|
12
|
+
function interpolate(str, values) {
|
|
13
|
+
return str.replace(/\{([\w.-]+)\}/g, (match, key) =>
|
|
14
|
+
Object.prototype.hasOwnProperty.call(values, key) ? String(values[key]) : match,
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class Recipe {
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} data - validated recipe data
|
|
21
|
+
* @param {string} source - absolute path the recipe was loaded from
|
|
22
|
+
*/
|
|
23
|
+
constructor(data, source = "unknown") {
|
|
24
|
+
this.data = data;
|
|
25
|
+
this.source = source;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get name() {
|
|
29
|
+
return this.data.name;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get description() {
|
|
33
|
+
return this.data.description || "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get params() {
|
|
37
|
+
return this.data.params;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Merge declared param defaults under a caller-supplied context. Caller values
|
|
42
|
+
* win; any param the caller omitted falls back to its declared default.
|
|
43
|
+
*/
|
|
44
|
+
applyParamDefaults(context = {}) {
|
|
45
|
+
const out = { ...context };
|
|
46
|
+
for (const [key, spec] of Object.entries(this.data.params)) {
|
|
47
|
+
if (out[key] === undefined) out[key] = spec.default;
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolve the manifest into a concrete plan for one invocation.
|
|
54
|
+
* @param {object} args
|
|
55
|
+
* @param {Record<string, unknown>} args.context - eval context (params + derived flags)
|
|
56
|
+
* @param {import('../blueprint/blueprint.js').Blueprint} [args.blueprint] - resolves `out` path tokens
|
|
57
|
+
* @param {string} [args.projectRoot] - project root for path resolution
|
|
58
|
+
* @param {Record<string, string>} [args.vars] - extra `{token}` substitutions (kebab, Name, ...)
|
|
59
|
+
* @returns {{ recipe: string, context: object, files: Array, inject: Array, requires: Array }}
|
|
60
|
+
*/
|
|
61
|
+
plan({ context = {}, blueprint, projectRoot, vars = {} } = {}) {
|
|
62
|
+
const ctx = this.applyParamDefaults(context);
|
|
63
|
+
const keep = (entry) => evaluateCondition(entry.when, ctx);
|
|
64
|
+
const renderOut = (template) =>
|
|
65
|
+
blueprint ? blueprint.renderTemplate(template, projectRoot, vars) : template;
|
|
66
|
+
// Template *paths* may also carry `{token}`s — notably `{formMode}`, so one
|
|
67
|
+
// recipe entry resolves to the right page-shell variant per invocation.
|
|
68
|
+
const tokens = { ...ctx, ...vars };
|
|
69
|
+
const renderTemplatePath = (template) => interpolate(template, tokens);
|
|
70
|
+
|
|
71
|
+
const files = this.data.files.filter(keep).map((file) => ({
|
|
72
|
+
template: renderTemplatePath(file.template),
|
|
73
|
+
out: renderOut(file.out),
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
const inject = this.data.inject.filter(keep).map((entry) => ({
|
|
77
|
+
anchor: entry.anchor,
|
|
78
|
+
template: renderTemplatePath(entry.template),
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
const requires = this.data.requires.filter(keep).map((req) => ({
|
|
82
|
+
scope: req.scope,
|
|
83
|
+
package: req.package,
|
|
84
|
+
version: req.version,
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
return { recipe: this.name, context: ctx, files, inject, requires };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipe schema — a recipe is a *declarative* generation manifest.
|
|
3
|
+
*
|
|
4
|
+
* It lists exactly which files to render, which anchors to inject into, and
|
|
5
|
+
* which dependencies to add — each entry gated by an optional `when` condition.
|
|
6
|
+
* The engine emits only what a recipe asks for, so "zero bloat" is structural
|
|
7
|
+
* rather than aspirational.
|
|
8
|
+
*/
|
|
9
|
+
import { object, record, arrayOf, string, any } from "../blueprint/schema-kit.js";
|
|
10
|
+
|
|
11
|
+
/** A declared input parameter (drives `when` conditions and template context). */
|
|
12
|
+
const paramSchema = object({
|
|
13
|
+
type: string().default("string"),
|
|
14
|
+
default: any().optional(),
|
|
15
|
+
description: string().optional(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/** One file to render: a template, an output path template, an optional gate. */
|
|
19
|
+
const fileSchema = object({
|
|
20
|
+
template: string(),
|
|
21
|
+
out: string(),
|
|
22
|
+
when: string().optional(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** One injection: a blueprint anchor name + a snippet template, optional gate. */
|
|
26
|
+
const injectSchema = object({
|
|
27
|
+
anchor: string(),
|
|
28
|
+
template: string(),
|
|
29
|
+
when: string().optional(),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** One dependency requirement, scoped to a root (e.g. "backend"), optional gate. */
|
|
33
|
+
const requireSchema = object({
|
|
34
|
+
scope: string(),
|
|
35
|
+
package: string(),
|
|
36
|
+
version: string(),
|
|
37
|
+
when: string().optional(),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const recipeSchema = object({
|
|
41
|
+
name: string(),
|
|
42
|
+
description: string().optional(),
|
|
43
|
+
params: record(paramSchema).default({}),
|
|
44
|
+
files: arrayOf(fileSchema).default([]),
|
|
45
|
+
inject: arrayOf(injectSchema).default([]),
|
|
46
|
+
requires: arrayOf(requireSchema).default([]),
|
|
47
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { validateResourceDefinition, validateGenerateOptions } from "../index.js";
|
|
3
|
+
|
|
4
|
+
describe("validateResourceDefinition", () => {
|
|
5
|
+
it("accepts a valid definition and fills defaults", () => {
|
|
6
|
+
const r = validateResourceDefinition({
|
|
7
|
+
name: "Product",
|
|
8
|
+
fields: [{ name: "title", type: "string", validation: { required: true } }],
|
|
9
|
+
});
|
|
10
|
+
expect(r.success).toBe(true);
|
|
11
|
+
expect(r.data.fields[0].type).toBe("string");
|
|
12
|
+
expect(r.data.relations).toEqual({});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("rejects a missing name", () => {
|
|
16
|
+
const r = validateResourceDefinition({ fields: [] });
|
|
17
|
+
expect(r.success).toBe(false);
|
|
18
|
+
expect(r.issues.some((i) => i.includes("name"))).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("rejects a non-PascalCase name", () => {
|
|
22
|
+
const r = validateResourceDefinition({ name: "product", fields: [] });
|
|
23
|
+
expect(r.success).toBe(false);
|
|
24
|
+
expect(r.issues.some((i) => i.includes("PascalCase"))).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("rejects an unknown field type", () => {
|
|
28
|
+
const r = validateResourceDefinition({ name: "Product", fields: [{ name: "x", type: "wormhole" }] });
|
|
29
|
+
expect(r.success).toBe(false);
|
|
30
|
+
expect(r.issues.some((i) => i.includes("type"))).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("rejects duplicate field names", () => {
|
|
34
|
+
const r = validateResourceDefinition({
|
|
35
|
+
name: "Product",
|
|
36
|
+
fields: [{ name: "title" }, { name: "title" }],
|
|
37
|
+
});
|
|
38
|
+
expect(r.success).toBe(false);
|
|
39
|
+
expect(r.issues.some((i) => i.includes("duplicate"))).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("rejects an invalid field identifier", () => {
|
|
43
|
+
const r = validateResourceDefinition({ name: "Product", fields: [{ name: "2bad" }] });
|
|
44
|
+
expect(r.success).toBe(false);
|
|
45
|
+
expect(r.issues.some((i) => i.includes("identifier"))).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("validateGenerateOptions", () => {
|
|
50
|
+
it("accepts valid options", () => {
|
|
51
|
+
expect(
|
|
52
|
+
validateGenerateOptions({ arch: "moderate", formMode: "modal", recipe: "resource" }).success,
|
|
53
|
+
).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("rejects unknown enum values", () => {
|
|
57
|
+
expect(validateGenerateOptions({ arch: "extreme" }).success).toBe(false);
|
|
58
|
+
expect(validateGenerateOptions({ formMode: "popover" }).success).toBe(false);
|
|
59
|
+
expect(validateGenerateOptions({ recipe: "widget" }).success).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("rejects mutually exclusive --fields and --file", () => {
|
|
63
|
+
const r = validateGenerateOptions({ fields: "a:str", file: "x.js" });
|
|
64
|
+
expect(r.success).toBe(false);
|
|
65
|
+
expect(r.issues.some((i) => i.includes("mutually exclusive"))).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schemas — strict validation for everything that enters the CLI from outside:
|
|
3
|
+
* resource-definition files, parsed field specs, and command options.
|
|
4
|
+
*
|
|
5
|
+
* Every validator returns a discriminated `{ success, ... }` result rather than
|
|
6
|
+
* throwing, so callers decide how to surface the typed error.
|
|
7
|
+
*/
|
|
8
|
+
export {
|
|
9
|
+
resourceDefinitionSchema,
|
|
10
|
+
validateResourceDefinition,
|
|
11
|
+
FIELD_TYPES,
|
|
12
|
+
} from "./resource.js";
|
|
13
|
+
export {
|
|
14
|
+
validateGenerateOptions,
|
|
15
|
+
ARCHITECTURES,
|
|
16
|
+
FORM_MODES,
|
|
17
|
+
RECIPES,
|
|
18
|
+
} from "./options.js";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command-option validation — rejects bad CLI flags before any generation runs.
|
|
3
|
+
*
|
|
4
|
+
* Commander does presence/type; this does *domain* validity: an `--arch` or
|
|
5
|
+
* `--form-mode` that the engine doesn't support is caught here with a clear
|
|
6
|
+
* message, not discovered as a broken render later.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const ARCHITECTURES = ["lightweight", "moderate", "advanced"];
|
|
10
|
+
export const FORM_MODES = ["page", "modal", "sidepanel", "inline"];
|
|
11
|
+
export const RECIPES = ["resource", "module", "page"];
|
|
12
|
+
|
|
13
|
+
/** A reusable "must be one of" check. */
|
|
14
|
+
function oneOf(value, allowed, flag) {
|
|
15
|
+
if (value === undefined || value === null) return null;
|
|
16
|
+
if (!allowed.includes(value)) {
|
|
17
|
+
return `${flag} must be one of: ${allowed.join(", ")} (got "${value}")`;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Validate the options accepted by `loom generate <type>`.
|
|
24
|
+
* @returns {{ success: boolean, issues: string[] }}
|
|
25
|
+
*/
|
|
26
|
+
export function validateGenerateOptions(options = {}) {
|
|
27
|
+
const issues = [
|
|
28
|
+
oneOf(options.arch, ARCHITECTURES, "--arch"),
|
|
29
|
+
oneOf(options.formMode, FORM_MODES, "--form-mode"),
|
|
30
|
+
oneOf(options.recipe, RECIPES, "--recipe"),
|
|
31
|
+
].filter(Boolean);
|
|
32
|
+
|
|
33
|
+
if (options.fields && options.file) {
|
|
34
|
+
issues.push("--fields and --file are mutually exclusive");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { success: issues.length === 0, issues };
|
|
38
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resource-definition schema — strict validation for `--file` definitions and
|
|
3
|
+
* the structures the field-spec parser produces.
|
|
4
|
+
*
|
|
5
|
+
* Replaces "parse, maybe warn, generate anyway" with "validate, fail fast with
|
|
6
|
+
* a path-pointed error". A bad definition never reaches the engine.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
object,
|
|
10
|
+
record,
|
|
11
|
+
arrayOf,
|
|
12
|
+
string,
|
|
13
|
+
boolean,
|
|
14
|
+
number,
|
|
15
|
+
enumOf,
|
|
16
|
+
any,
|
|
17
|
+
} from "../blueprint/schema-kit.js";
|
|
18
|
+
|
|
19
|
+
/** Field types the generator understands (kept in sync with resource-definition.js). */
|
|
20
|
+
export const FIELD_TYPES = [
|
|
21
|
+
"string",
|
|
22
|
+
"text",
|
|
23
|
+
"richtext",
|
|
24
|
+
"number",
|
|
25
|
+
"range",
|
|
26
|
+
"boolean",
|
|
27
|
+
"date",
|
|
28
|
+
"datetime",
|
|
29
|
+
"time",
|
|
30
|
+
"email",
|
|
31
|
+
"password",
|
|
32
|
+
"phone",
|
|
33
|
+
"url",
|
|
34
|
+
"color",
|
|
35
|
+
"ref",
|
|
36
|
+
"reference",
|
|
37
|
+
"array",
|
|
38
|
+
"object",
|
|
39
|
+
"image",
|
|
40
|
+
"file",
|
|
41
|
+
"select",
|
|
42
|
+
"multiselect",
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const fieldSchema = object({
|
|
46
|
+
name: string(),
|
|
47
|
+
type: enumOf(...FIELD_TYPES).default("string"),
|
|
48
|
+
validation: object({
|
|
49
|
+
required: boolean().optional(),
|
|
50
|
+
unique: boolean().optional(),
|
|
51
|
+
min: number().optional(),
|
|
52
|
+
max: number().optional(),
|
|
53
|
+
minLength: number().optional(),
|
|
54
|
+
maxLength: number().optional(),
|
|
55
|
+
pattern: string().optional(),
|
|
56
|
+
default: any().optional(),
|
|
57
|
+
}).default({}),
|
|
58
|
+
special: record(any()).default({}),
|
|
59
|
+
ui: record(any()).default({}),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
export const resourceDefinitionSchema = object({
|
|
63
|
+
name: string(),
|
|
64
|
+
collection: string().optional(),
|
|
65
|
+
fields: arrayOf(fieldSchema).default([]),
|
|
66
|
+
relations: record(any()).default({}),
|
|
67
|
+
features: record(any()).default({}),
|
|
68
|
+
ui: record(any()).default({}),
|
|
69
|
+
hooks: record(any()).default({}),
|
|
70
|
+
permissions: record(any()).default({}),
|
|
71
|
+
options: record(any()).default({}),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const IDENTIFIER = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
|
|
75
|
+
const PASCAL_CASE = /^[A-Z][a-zA-Z0-9]*$/;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Validate a raw resource definition. Returns `{ success, data }` or
|
|
79
|
+
* `{ success: false, issues: string[] }`. Beyond the shape check it enforces
|
|
80
|
+
* the semantic rules the engine depends on: PascalCase name, identifier-safe
|
|
81
|
+
* unique field names.
|
|
82
|
+
*/
|
|
83
|
+
export function validateResourceDefinition(raw) {
|
|
84
|
+
const parsed = resourceDefinitionSchema.safeParse(raw);
|
|
85
|
+
if (!parsed.success) {
|
|
86
|
+
return {
|
|
87
|
+
success: false,
|
|
88
|
+
issues: parsed.error.issues.map(
|
|
89
|
+
(i) => `${i.path.join(".") || "(root)"}: ${i.message}`,
|
|
90
|
+
),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const issues = [];
|
|
95
|
+
const { name, fields } = parsed.data;
|
|
96
|
+
|
|
97
|
+
if (!PASCAL_CASE.test(name)) {
|
|
98
|
+
issues.push(`name: "${name}" must be PascalCase (start uppercase, alphanumeric)`);
|
|
99
|
+
}
|
|
100
|
+
for (const field of fields) {
|
|
101
|
+
if (!IDENTIFIER.test(field.name)) {
|
|
102
|
+
issues.push(`fields: "${field.name}" is not a valid identifier`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const names = fields.map((f) => f.name);
|
|
106
|
+
const duplicates = [...new Set(names.filter((n, i) => names.indexOf(n) !== i))];
|
|
107
|
+
if (duplicates.length) {
|
|
108
|
+
issues.push(`fields: duplicate field name(s): ${duplicates.join(", ")}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return issues.length ? { success: false, issues } : { success: true, data: parsed.data };
|
|
112
|
+
}
|