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.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/bin/cli.js +306 -0
  4. package/branding.json +8 -0
  5. package/package.json +72 -0
  6. package/src/__tests__/cli-smoke.test.js +46 -0
  7. package/src/blueprint/__tests__/blueprint.test.js +116 -0
  8. package/src/blueprint/blueprint.js +181 -0
  9. package/src/blueprint/default.blueprint.json +78 -0
  10. package/src/blueprint/index.js +10 -0
  11. package/src/blueprint/loader.js +101 -0
  12. package/src/blueprint/schema-kit.js +161 -0
  13. package/src/blueprint/schema.js +78 -0
  14. package/src/branding/__tests__/branding.test.js +49 -0
  15. package/src/branding/index.js +48 -0
  16. package/src/commands/__tests__/commands.test.js +83 -0
  17. package/src/commands/check.js +71 -0
  18. package/src/commands/cleanup.js +347 -0
  19. package/src/commands/customize.js +263 -0
  20. package/src/commands/doctor.js +84 -0
  21. package/src/commands/env.js +75 -0
  22. package/src/commands/finalize.js +68 -0
  23. package/src/commands/generate/ci-cd.js +378 -0
  24. package/src/commands/generate/deploy-advanced.js +253 -0
  25. package/src/commands/generate/deploy.js +99 -0
  26. package/src/commands/generate/env.template.js +221 -0
  27. package/src/commands/generate/index.js +7 -0
  28. package/src/commands/generate/module.js +836 -0
  29. package/src/commands/generate/page.js +1415 -0
  30. package/src/commands/generate/test-scaffold.js +279 -0
  31. package/src/commands/generate/theme.js +67 -0
  32. package/src/commands/generate-resource.js +133 -0
  33. package/src/commands/index.js +9 -0
  34. package/src/commands/init.js +350 -0
  35. package/src/commands/make/resource.js +298 -0
  36. package/src/commands/preset.js +57 -0
  37. package/src/commands/remove.js +170 -0
  38. package/src/commands/rename.js +54 -0
  39. package/src/commands/rollback.js +90 -0
  40. package/src/commands/wizard.js +303 -0
  41. package/src/core/__tests__/generator.test.js +67 -0
  42. package/src/core/__tests__/marker-strategy.test.js +57 -0
  43. package/src/core/__tests__/resource-definition.test.js +32 -0
  44. package/src/core/generator.js +542 -0
  45. package/src/core/marker-strategy.js +138 -0
  46. package/src/core/resource-definition.js +346 -0
  47. package/src/core/state-tracker.js +67 -0
  48. package/src/core/template-loader.js +163 -0
  49. package/src/engine/__tests__/engine.test.js +306 -0
  50. package/src/engine/index.js +21 -0
  51. package/src/engine/injector.js +198 -0
  52. package/src/engine/pipeline.js +138 -0
  53. package/src/engine/transaction.js +105 -0
  54. package/src/engine/validator.js +190 -0
  55. package/src/index.js +4 -0
  56. package/src/recipes/__tests__/recipe.test.js +128 -0
  57. package/src/recipes/builtin/module.json +22 -0
  58. package/src/recipes/builtin/page.json +21 -0
  59. package/src/recipes/builtin/resource.json +35 -0
  60. package/src/recipes/condition.js +147 -0
  61. package/src/recipes/index.js +11 -0
  62. package/src/recipes/loader.js +95 -0
  63. package/src/recipes/recipe.js +89 -0
  64. package/src/recipes/schema.js +47 -0
  65. package/src/schemas/__tests__/schemas.test.js +67 -0
  66. package/src/schemas/index.js +18 -0
  67. package/src/schemas/options.js +38 -0
  68. package/src/schemas/resource.js +112 -0
  69. package/src/services/__tests__/reporter.test.js +98 -0
  70. package/src/services/clock.js +31 -0
  71. package/src/services/index.js +43 -0
  72. package/src/services/reporter.js +136 -0
  73. package/src/templates/resource/api.js.ejs +39 -0
  74. package/src/templates/resource/components/form.jsx.ejs +81 -0
  75. package/src/templates/resource/components/table.jsx.ejs +68 -0
  76. package/src/templates/resource/controller.js.ejs +154 -0
  77. package/src/templates/resource/hooks.js.ejs +46 -0
  78. package/src/templates/resource/model.js.ejs +64 -0
  79. package/src/templates/resource/page-detail.jsx.ejs +55 -0
  80. package/src/templates/resource/page-form.jsx.ejs +30 -0
  81. package/src/templates/resource/page-inline.jsx.ejs +74 -0
  82. package/src/templates/resource/page-modal.jsx.ejs +98 -0
  83. package/src/templates/resource/page-page.jsx.ejs +99 -0
  84. package/src/templates/resource/page-sidepanel.jsx.ejs +100 -0
  85. package/src/templates/resource/routes.js.ejs +35 -0
  86. package/src/templates/resource/service.js.ejs +132 -0
  87. package/src/templates/resource/test.ejs +71 -0
  88. package/src/templates/resource/types.ts.ejs +17 -0
  89. package/src/templates/resource/validator.js.ejs +26 -0
  90. package/src/templates/snippets/lazy-import.ejs +1 -0
  91. package/src/templates/snippets/nav-entry.ejs +1 -0
  92. package/src/templates/snippets/route-entry.ejs +5 -0
  93. package/src/templates/snippets/route-mount.ejs +1 -0
  94. package/src/utils/fieldValidators.js +371 -0
  95. package/src/utils/logging/logger.js +47 -0
  96. package/src/utils/namingUtils.js +38 -0
  97. package/src/utils/sanitize.js +200 -0
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Pipeline — composable generation as an ordered list of small steps.
3
+ *
4
+ * Generation is not a god-class with a dozen methods; it is a sequence of
5
+ * independently testable steps that pass one accumulating context object
6
+ * along. New capabilities are new steps, not new branches. The standard
7
+ * transactional pipeline is:
8
+ *
9
+ * plan → render → validate → commit
10
+ *
11
+ * render-to-temp, validate-everything, then atomic-commit — so a syntactically
12
+ * broken file is caught before anything touches the project.
13
+ */
14
+ import { FileTransaction, realFs } from "./transaction.js";
15
+ import { Validator } from "./validator.js";
16
+ import { Injector } from "./injector.js";
17
+
18
+ export class Pipeline {
19
+ constructor(steps = []) {
20
+ this.steps = [...steps];
21
+ }
22
+
23
+ /** Append a step. */
24
+ use(step) {
25
+ this.steps.push(step);
26
+ return this;
27
+ }
28
+
29
+ /** Run every step in order, threading `context` through. Returns the context. */
30
+ async run(context = {}) {
31
+ for (const step of this.steps) {
32
+ if (!step || typeof step.run !== "function") {
33
+ throw new Error(`Pipeline step "${step?.name ?? "?"}" has no run() method`);
34
+ }
35
+ await step.run(context);
36
+ }
37
+ return context;
38
+ }
39
+ }
40
+
41
+ /** Build a named step. */
42
+ export function defineStep(name, run) {
43
+ return { name, run };
44
+ }
45
+
46
+ /** Resolve the recipe manifest into a concrete plan for this invocation. */
47
+ export const planStep = defineStep("plan", (context) => {
48
+ const { recipe, blueprint, projectRoot, recipeContext = {}, vars = {} } = context;
49
+ if (!recipe) throw new Error("Generation pipeline: context.recipe is required");
50
+ context.plan = recipe.plan({ context: recipeContext, blueprint, projectRoot, vars });
51
+ });
52
+
53
+ /** Render each planned file and stage it in a transaction — nothing written yet. */
54
+ export function createRenderStep({ renderer, fs = realFs }) {
55
+ if (typeof renderer !== "function") {
56
+ throw new Error("createRenderStep requires a renderer(templatePath, context) function");
57
+ }
58
+ return defineStep("render", async (context) => {
59
+ const { projectRoot, plan, templateContext = {} } = context;
60
+ const transaction = new FileTransaction({ projectRoot, fs });
61
+ for (const file of plan.files) {
62
+ const content = await renderer(file.template, templateContext);
63
+ transaction.stage(file.out, content);
64
+ }
65
+ context.transaction = transaction;
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Render each recipe `inject` snippet and splice it into the project's anchor
71
+ * files, staging the modified files into the same transaction. Idempotent.
72
+ */
73
+ export function createInjectStep({ renderer, injector }) {
74
+ if (typeof renderer !== "function") {
75
+ throw new Error("createInjectStep requires a renderer(templatePath, context) function");
76
+ }
77
+ const inj = injector ?? new Injector();
78
+ return defineStep("inject", async (context) => {
79
+ const { plan, blueprint, projectRoot, transaction, templateContext = {} } = context;
80
+ context.injections = [];
81
+ for (const entry of plan.inject || []) {
82
+ const snippet = await renderer(entry.template, templateContext);
83
+ context.injections.push(
84
+ inj.inject({ anchorName: entry.anchor, snippet, blueprint, projectRoot, transaction }),
85
+ );
86
+ }
87
+ });
88
+ }
89
+
90
+ /** Validate every staged file; abort the whole generation on any failure. */
91
+ export function createValidateStep({ validator = new Validator() } = {}) {
92
+ return defineStep("validate", (context) => {
93
+ const result = validator.validateAll(context.transaction.staged());
94
+ context.validation = result;
95
+ if (!result.ok) {
96
+ const detail = result.failures.map((f) => ` • ${f.relPath}: ${f.message}`).join("\n");
97
+ throw new Error(
98
+ `Generation aborted — ${result.failures.length} file(s) failed validation:\n${detail}`,
99
+ );
100
+ }
101
+ });
102
+ }
103
+
104
+ /** Commit the transaction — or, in dry-run, just record the plan. */
105
+ export const commitStep = defineStep("commit", (context) => {
106
+ const { transaction, dryRun } = context;
107
+ context.result = dryRun
108
+ ? { dryRun: true, files: transaction.plan() }
109
+ : { dryRun: false, files: transaction.commit() };
110
+ });
111
+
112
+ /**
113
+ * Compose the standard transactional generation pipeline:
114
+ * plan → render → inject → validate → commit
115
+ * Rendering is injected so the engine never depends on a specific template
116
+ * library. Pass `withInject: false` to skip anchor injection (e.g. recipes that
117
+ * only emit standalone files, or tests exercising render/commit in isolation).
118
+ * @param {object} args
119
+ * @param {(templatePath:string, context:object) => Promise<string>|string} args.renderer
120
+ * @param {Validator} [args.validator]
121
+ * @param {Injector} [args.injector]
122
+ * @param {typeof realFs} [args.fs]
123
+ * @param {boolean} [args.withInject=true]
124
+ */
125
+ export function createGenerationPipeline({
126
+ renderer,
127
+ validator,
128
+ injector,
129
+ fs = realFs,
130
+ withInject = true,
131
+ } = {}) {
132
+ const steps = [planStep, createRenderStep({ renderer, fs })];
133
+ if (withInject) {
134
+ steps.push(createInjectStep({ renderer, injector: injector ?? new Injector({ fs }) }));
135
+ }
136
+ steps.push(createValidateStep({ validator }), commitStep);
137
+ return new Pipeline(steps);
138
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * FileTransaction — atomic, all-or-nothing file writes.
3
+ *
4
+ * Generation stages every file here first; nothing touches the project until
5
+ * `commit()`. If any write fails mid-commit, every file already written in this
6
+ * commit is rolled back (prior contents restored, freshly-created files
7
+ * removed). A half-generated project is never left behind.
8
+ *
9
+ * The `fs` adapter is injected, so tests can drive failure paths deterministically.
10
+ */
11
+ import {
12
+ existsSync,
13
+ readFileSync,
14
+ writeFileSync,
15
+ mkdirSync,
16
+ rmSync,
17
+ } from "node:fs";
18
+ import path from "node:path";
19
+
20
+ /** Default adapter — the real filesystem. */
21
+ export const realFs = { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync };
22
+
23
+ export class FileTransaction {
24
+ /**
25
+ * @param {object} args
26
+ * @param {string} args.projectRoot - absolute root all staged paths are relative to
27
+ * @param {typeof realFs} [args.fs] - filesystem adapter (injectable for tests)
28
+ */
29
+ constructor({ projectRoot, fs = realFs }) {
30
+ if (!projectRoot) throw new Error("FileTransaction requires a projectRoot");
31
+ this.projectRoot = projectRoot;
32
+ this.fs = fs;
33
+ this._staged = [];
34
+ this._committed = false;
35
+ }
36
+
37
+ /**
38
+ * Stage a file's final content (relative to projectRoot). Upsert semantics —
39
+ * re-staging the same path replaces it, so successive injections into one
40
+ * file compose correctly.
41
+ */
42
+ stage(relPath, content) {
43
+ if (this._committed) throw new Error("Cannot stage on an already-committed transaction");
44
+ const existing = this._staged.find((file) => file.relPath === relPath);
45
+ if (existing) existing.content = content;
46
+ else this._staged.push({ relPath, content });
47
+ return this;
48
+ }
49
+
50
+ /** Latest staged content for a path, or undefined if it has not been staged. */
51
+ get(relPath) {
52
+ const entry = this._staged.find((file) => file.relPath === relPath);
53
+ return entry ? entry.content : undefined;
54
+ }
55
+
56
+ /** Staged files annotated with the action they would perform. */
57
+ staged() {
58
+ return this._staged.map((file) => ({
59
+ relPath: file.relPath,
60
+ content: file.content,
61
+ action: this.fs.existsSync(path.join(this.projectRoot, file.relPath))
62
+ ? "update"
63
+ : "create",
64
+ }));
65
+ }
66
+
67
+ /** Dry-run preview — paths + actions, no content, nothing written. */
68
+ plan() {
69
+ return this.staged().map(({ relPath, action }) => ({ relPath, action }));
70
+ }
71
+
72
+ /**
73
+ * Write every staged file. On any failure, roll back all writes from this
74
+ * commit and rethrow. Returns the journal of applied changes on success.
75
+ */
76
+ commit() {
77
+ if (this._committed) throw new Error("Transaction already committed");
78
+ const journal = [];
79
+ try {
80
+ for (const file of this._staged) {
81
+ const abs = path.join(this.projectRoot, file.relPath);
82
+ const existed = this.fs.existsSync(abs);
83
+ const backup = existed ? this.fs.readFileSync(abs) : null;
84
+ this.fs.mkdirSync(path.dirname(abs), { recursive: true });
85
+ this.fs.writeFileSync(abs, file.content);
86
+ journal.push({ abs, existed, backup });
87
+ }
88
+ } catch (err) {
89
+ for (const entry of journal.reverse()) {
90
+ try {
91
+ if (entry.existed) this.fs.writeFileSync(entry.abs, entry.backup);
92
+ else this.fs.rmSync(entry.abs, { force: true });
93
+ } catch {
94
+ // best-effort rollback — surface the original failure regardless
95
+ }
96
+ }
97
+ throw err;
98
+ }
99
+ this._committed = true;
100
+ return journal.map((entry) => ({
101
+ relPath: path.relative(this.projectRoot, entry.abs),
102
+ action: entry.existed ? "update" : "create",
103
+ }));
104
+ }
105
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Validator — the pre-commit gate that makes "error-free code" a guarantee.
3
+ *
4
+ * Every staged file is checked before the transaction is allowed to commit; a
5
+ * single failure aborts the whole generation, so a syntactically broken file
6
+ * never reaches the project.
7
+ *
8
+ * Strategies are pluggable per file extension. The shipped default for code is
9
+ * `scanDelimiters` — a dependency-free balanced-delimiter scanner that catches
10
+ * the real bug class (stray code, unbalanced braces/parens, unterminated
11
+ * strings). It is a heuristic, not a full parser: a `@babel/parser` / `tsc`
12
+ * strategy can be registered when those deps are available, without touching
13
+ * any caller.
14
+ */
15
+ import path from "node:path";
16
+
17
+ /**
18
+ * Punctuation after which a `/` begins a regex literal rather than division.
19
+ * `<` and `>` are deliberately excluded: in JSX `</Tag>` and `/>` are far more
20
+ * common than a regex following a comparison operator.
21
+ */
22
+ const REGEX_PRECEDERS = new Set("([{,;:=!&|?+-*%~^".split(""));
23
+
24
+ /**
25
+ * Scan source for balanced (), [], {} — aware of strings, template literals,
26
+ * comments and regex literals. Returns `{ balanced, error? }`.
27
+ *
28
+ * Known heuristic limits: template-literal `${}` interiors are treated as
29
+ * opaque, and JSX angle brackets are not tracked.
30
+ */
31
+ export function scanDelimiters(src) {
32
+ const closeToOpen = { ")": "(", "]": "[", "}": "{" };
33
+ const openers = new Set(["(", "[", "{"]);
34
+ const stack = [];
35
+ let prev = "";
36
+ let i = 0;
37
+ const n = src.length;
38
+
39
+ while (i < n) {
40
+ const c = src[i];
41
+ const next = src[i + 1];
42
+
43
+ if (c === "/" && next === "/") {
44
+ i += 2;
45
+ while (i < n && src[i] !== "\n") i++;
46
+ continue;
47
+ }
48
+ if (c === "/" && next === "*") {
49
+ i += 2;
50
+ while (i < n && !(src[i] === "*" && src[i + 1] === "/")) i++;
51
+ if (i >= n) return { balanced: false, error: "Unterminated block comment" };
52
+ i += 2;
53
+ continue;
54
+ }
55
+ if (c === "'" || c === '"') {
56
+ i++;
57
+ while (i < n && src[i] !== c) {
58
+ if (src[i] === "\\") i++;
59
+ i++;
60
+ }
61
+ if (i >= n) return { balanced: false, error: "Unterminated string literal" };
62
+ i++;
63
+ prev = c;
64
+ continue;
65
+ }
66
+ if (c === "`") {
67
+ i++;
68
+ while (i < n && src[i] !== "`") {
69
+ if (src[i] === "\\") i++;
70
+ i++;
71
+ }
72
+ if (i >= n) return { balanced: false, error: "Unterminated template literal" };
73
+ i++;
74
+ prev = "`";
75
+ continue;
76
+ }
77
+ if (c === "/" && (prev === "" || REGEX_PRECEDERS.has(prev))) {
78
+ i++;
79
+ let inClass = false;
80
+ while (i < n) {
81
+ const r = src[i];
82
+ if (r === "\\") {
83
+ i += 2;
84
+ continue;
85
+ }
86
+ if (r === "[") inClass = true;
87
+ else if (r === "]") inClass = false;
88
+ else if (r === "/" && !inClass) break;
89
+ else if (r === "\n") return { balanced: false, error: "Unterminated regex literal" };
90
+ i++;
91
+ }
92
+ if (i >= n) return { balanced: false, error: "Unterminated regex literal" };
93
+ i++;
94
+ prev = "/";
95
+ continue;
96
+ }
97
+ if (c === " " || c === "\t" || c === "\r" || c === "\n") {
98
+ i++;
99
+ continue;
100
+ }
101
+ if (openers.has(c)) {
102
+ stack.push({ c, i });
103
+ prev = c;
104
+ i++;
105
+ continue;
106
+ }
107
+ if (closeToOpen[c]) {
108
+ const top = stack.pop();
109
+ if (!top || top.c !== closeToOpen[c]) {
110
+ return { balanced: false, error: `Unexpected "${c}" at index ${i}` };
111
+ }
112
+ prev = c;
113
+ i++;
114
+ continue;
115
+ }
116
+ prev = c;
117
+ i++;
118
+ }
119
+
120
+ if (stack.length) {
121
+ const top = stack[stack.length - 1];
122
+ return { balanced: false, error: `Unclosed "${top.c}" at index ${top.i}` };
123
+ }
124
+ return { balanced: true };
125
+ }
126
+
127
+ /** Code strategy: non-empty + balanced delimiters. */
128
+ function codeStrategy(file) {
129
+ if (!file.content || !file.content.trim()) {
130
+ return { ok: false, relPath: file.relPath, message: "Generated file is empty" };
131
+ }
132
+ const scan = scanDelimiters(file.content);
133
+ if (!scan.balanced) {
134
+ return { ok: false, relPath: file.relPath, message: scan.error };
135
+ }
136
+ return { ok: true, relPath: file.relPath };
137
+ }
138
+
139
+ /** JSON strategy: must parse. */
140
+ function jsonStrategy(file) {
141
+ try {
142
+ JSON.parse(file.content);
143
+ return { ok: true, relPath: file.relPath };
144
+ } catch (err) {
145
+ return { ok: false, relPath: file.relPath, message: `Invalid JSON: ${err.message}` };
146
+ }
147
+ }
148
+
149
+ /** Unknown extensions pass — only registered types are gated. */
150
+ function passStrategy(file) {
151
+ return { ok: true, relPath: file.relPath };
152
+ }
153
+
154
+ const DEFAULT_STRATEGIES = {
155
+ js: codeStrategy,
156
+ cjs: codeStrategy,
157
+ mjs: codeStrategy,
158
+ jsx: codeStrategy,
159
+ ts: codeStrategy,
160
+ tsx: codeStrategy,
161
+ json: jsonStrategy,
162
+ };
163
+
164
+ export class Validator {
165
+ /**
166
+ * @param {object} [options]
167
+ * @param {Record<string, (file:{relPath:string,content:string}) => {ok:boolean}>} [options.strategies]
168
+ * Extra/override strategies keyed by extension (no dot).
169
+ */
170
+ constructor({ strategies = {} } = {}) {
171
+ this.strategies = { ...DEFAULT_STRATEGIES, ...strategies };
172
+ }
173
+
174
+ /** Validate one staged file. */
175
+ validateFile(file) {
176
+ const ext = path.extname(file.relPath).slice(1).toLowerCase();
177
+ const strategy = this.strategies[ext] || passStrategy;
178
+ return strategy(file);
179
+ }
180
+
181
+ /** Validate every staged file. Returns `{ ok, failures }`. */
182
+ validateAll(files) {
183
+ const failures = [];
184
+ for (const file of files) {
185
+ const result = this.validateFile(file);
186
+ if (!result.ok) failures.push(result);
187
+ }
188
+ return { ok: failures.length === 0, failures };
189
+ }
190
+ }
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { default as init } from "./commands/init.js";
2
+ export { default as generate } from "./commands/generate/index.js";
3
+ export { default as preset } from "./commands/preset.js";
4
+ export { default as finalize } from "./commands/finalize.js";
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import os from "node:os";
3
+ import { evaluateCondition, recipeLoader, RecipeLoadError } from "../index.js";
4
+ import { blueprintLoader } from "../../blueprint/index.js";
5
+
6
+ describe("evaluateCondition", () => {
7
+ it("resolves identifiers (missing = falsy)", () => {
8
+ expect(evaluateCondition("withFrontend", { withFrontend: true })).toBe(true);
9
+ expect(evaluateCondition("withFrontend", {})).toBe(false);
10
+ });
11
+
12
+ it("treats empty/missing expressions as always-true", () => {
13
+ expect(evaluateCondition("", {})).toBe(true);
14
+ expect(evaluateCondition(undefined, {})).toBe(true);
15
+ });
16
+
17
+ it("supports !, &&, ||, ==, != and parentheses", () => {
18
+ expect(evaluateCondition("!withFrontend", { withFrontend: false })).toBe(true);
19
+ expect(evaluateCondition("a && b", { a: true, b: false })).toBe(false);
20
+ expect(evaluateCondition("a || b", { a: false, b: true })).toBe(true);
21
+ expect(evaluateCondition('architecture == "advanced"', { architecture: "advanced" })).toBe(true);
22
+ expect(evaluateCondition('architecture != "advanced"', { architecture: "moderate" })).toBe(true);
23
+ expect(evaluateCondition("(a || b) && c", { a: true, b: false, c: false })).toBe(false);
24
+ });
25
+
26
+ it("allows : in identifiers (hasField:slug)", () => {
27
+ expect(evaluateCondition("hasField:slug", { "hasField:slug": true })).toBe(true);
28
+ });
29
+
30
+ it("rejects malformed expressions", () => {
31
+ expect(() => evaluateCondition("a &&", {})).toThrow();
32
+ expect(() => evaluateCondition("(a || b", {})).toThrow();
33
+ expect(() => evaluateCondition("a == == b", {})).toThrow();
34
+ });
35
+ });
36
+
37
+ describe("RecipeLoader", () => {
38
+ it("loads the built-in resource recipe", async () => {
39
+ const recipe = await recipeLoader.load("resource");
40
+ expect(recipe.name).toBe("resource");
41
+ expect(recipe.data.files.length).toBe(14);
42
+ expect(recipe.data.inject.length).toBe(4);
43
+ expect(recipe.data.requires.length).toBe(1);
44
+ });
45
+
46
+ it("loads the module and page recipes", async () => {
47
+ expect((await recipeLoader.load("module")).name).toBe("module");
48
+ expect((await recipeLoader.load("page")).name).toBe("page");
49
+ });
50
+
51
+ it("rejects an unknown recipe", async () => {
52
+ await expect(recipeLoader.load("nope-not-real")).rejects.toThrow(RecipeLoadError);
53
+ });
54
+ });
55
+
56
+ describe("Recipe.plan", () => {
57
+ it("applies declared param defaults", async () => {
58
+ const recipe = await recipeLoader.load("resource");
59
+ const plan = recipe.plan({ context: {} });
60
+ expect(plan.context.withFrontend).toBe(true);
61
+ expect(plan.context.withTests).toBe(false);
62
+ expect(plan.context.architecture).toBe("moderate");
63
+ expect(plan.context.formMode).toBe("page");
64
+ });
65
+
66
+ it("filters files/inject/requires by `when` and renders out paths", async () => {
67
+ const recipe = await recipeLoader.load("resource");
68
+ const blueprint = await blueprintLoader.load(os.tmpdir());
69
+ const vars = { kebab: "order", Name: "Order" };
70
+
71
+ const backendOnly = recipe.plan({
72
+ context: { withFrontend: false },
73
+ blueprint,
74
+ projectRoot: os.tmpdir(),
75
+ vars,
76
+ });
77
+ expect(backendOnly.files.length).toBe(5);
78
+ expect(backendOnly.inject.length).toBe(1);
79
+ expect(backendOnly.requires.length).toBe(0);
80
+ expect(backendOnly.files[0].out).toBe("backend/src/modules/order/models/Order.js");
81
+
82
+ const full = recipe.plan({
83
+ context: { withFrontend: true, withTests: true, "hasField:slug": true },
84
+ blueprint,
85
+ projectRoot: os.tmpdir(),
86
+ vars,
87
+ });
88
+ expect(full.files.length).toBe(13);
89
+ expect(full.inject.length).toBe(4);
90
+ expect(full.requires).toEqual([
91
+ { scope: "backend", package: "slugify", version: "^1.6.6" },
92
+ ]);
93
+ });
94
+
95
+ it("emits the types file only for TypeScript projects", async () => {
96
+ const recipe = await recipeLoader.load("resource");
97
+ const blueprint = await blueprintLoader.load(os.tmpdir());
98
+ const plan = recipe.plan({
99
+ context: { withFrontend: true, usesTypeScript: true },
100
+ blueprint,
101
+ projectRoot: os.tmpdir(),
102
+ vars: { kebab: "order", Name: "Order" },
103
+ });
104
+ expect(plan.files.some((f) => f.out.endsWith("types/order.types.ts"))).toBe(true);
105
+ });
106
+
107
+ it("lets formMode select the page-shell template", async () => {
108
+ const recipe = await recipeLoader.load("resource");
109
+ const blueprint = await blueprintLoader.load(os.tmpdir());
110
+ const planFor = (formMode) =>
111
+ recipe.plan({
112
+ context: { withFrontend: true, formMode },
113
+ blueprint,
114
+ projectRoot: os.tmpdir(),
115
+ vars: { kebab: "order", Name: "Order" },
116
+ });
117
+
118
+ for (const mode of ["page", "modal", "sidepanel", "inline"]) {
119
+ const plan = planFor(mode);
120
+ const shell = plan.files.find((f) => f.out.endsWith("ListPage.jsx"));
121
+ expect(shell.template).toBe(`resource/page-${mode}.jsx.ejs`);
122
+ }
123
+
124
+ // The dedicated routed form page exists only for "page" mode.
125
+ expect(planFor("page").files.some((f) => f.out.endsWith("FormPage.jsx"))).toBe(true);
126
+ expect(planFor("modal").files.some((f) => f.out.endsWith("FormPage.jsx"))).toBe(false);
127
+ });
128
+ });
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "module",
3
+ "description": "Backend-only module: Mongoose model, service, controller, routes, validator. No frontend.",
4
+ "params": {
5
+ "withTests": { "type": "boolean", "default": false, "description": "Generate backend test files." },
6
+ "architecture": { "type": "string", "default": "moderate", "description": "lightweight | moderate | advanced." }
7
+ },
8
+ "files": [
9
+ { "template": "resource/model.js.ejs", "out": "{@backend.modules}/{kebab}/models/{Name}.js" },
10
+ { "template": "resource/service.js.ejs", "out": "{@backend.modules}/{kebab}/services/{Name}.service.js", "when": "architecture != \"lightweight\"" },
11
+ { "template": "resource/controller.js.ejs", "out": "{@backend.modules}/{kebab}/controllers/{Name}.controller.js" },
12
+ { "template": "resource/routes.js.ejs", "out": "{@backend.modules}/{kebab}/routes/{Name}.routes.js" },
13
+ { "template": "resource/validator.js.ejs", "out": "{@backend.validators}/{Name}.validator.js" },
14
+ { "template": "resource/test.ejs", "out": "{@backend.modules}/{kebab}/tests/{Name}.test.js", "when": "architecture != \"lightweight\" && (withTests || architecture == \"advanced\")" }
15
+ ],
16
+ "inject": [
17
+ { "anchor": "backend.routes", "template": "snippets/route-mount.ejs" }
18
+ ],
19
+ "requires": [
20
+ { "scope": "backend", "package": "slugify", "version": "^1.6.6", "when": "hasField:slug || hasField:code || hasField:sku" }
21
+ ]
22
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "page",
3
+ "description": "Frontend admin page wired to an existing backend resource: list page plus optional table/form components and API client.",
4
+ "params": {
5
+ "withForm": { "type": "boolean", "default": true, "description": "Generate a form component for create/edit." },
6
+ "withApi": { "type": "boolean", "default": true, "description": "Generate the API client module." },
7
+ "formMode": { "type": "string", "default": "page", "description": "page | modal | sidepanel | inline — how the form is mounted." }
8
+ },
9
+ "files": [
10
+ { "template": "resource/page-{formMode}.jsx.ejs", "out": "{@frontend.adminPages}/{kebab}/ListPage.jsx" },
11
+ { "template": "resource/components/table.jsx.ejs", "out": "{@frontend.components}/tables/{Name}Table.jsx" },
12
+ { "template": "resource/components/form.jsx.ejs", "out": "{@frontend.components}/forms/{Name}Form.jsx", "when": "withForm" },
13
+ { "template": "resource/api.js.ejs", "out": "{@frontend.api}/{kebab}.api.js", "when": "withApi" }
14
+ ],
15
+ "inject": [
16
+ { "anchor": "frontend.lazyImports", "template": "snippets/lazy-import.ejs" },
17
+ { "anchor": "frontend.routes", "template": "snippets/route-entry.ejs" },
18
+ { "anchor": "frontend.nav", "template": "snippets/nav-entry.ejs" }
19
+ ],
20
+ "requires": []
21
+ }
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "resource",
3
+ "description": "Full-stack CRUD resource: Mongoose model, service, controller, routes, validator, plus optional admin pages, table/form components, API client and hooks.",
4
+ "params": {
5
+ "withFrontend": { "type": "boolean", "default": true, "description": "Generate frontend pages, components, API client and hooks." },
6
+ "withTests": { "type": "boolean", "default": false, "description": "Generate backend test files." },
7
+ "architecture": { "type": "string", "default": "moderate", "description": "lightweight | moderate | advanced." },
8
+ "formMode": { "type": "string", "default": "page", "description": "page | modal | sidepanel | inline — how the create/edit form is mounted." }
9
+ },
10
+ "files": [
11
+ { "template": "resource/model.js.ejs", "out": "{@backend.modules}/{kebab}/models/{Name}.js" },
12
+ { "template": "resource/service.js.ejs", "out": "{@backend.modules}/{kebab}/services/{Name}.service.js", "when": "architecture != \"lightweight\"" },
13
+ { "template": "resource/controller.js.ejs", "out": "{@backend.modules}/{kebab}/controllers/{Name}.controller.js" },
14
+ { "template": "resource/routes.js.ejs", "out": "{@backend.modules}/{kebab}/routes/{Name}.routes.js" },
15
+ { "template": "resource/validator.js.ejs", "out": "{@backend.validators}/{Name}.validator.js" },
16
+ { "template": "resource/test.ejs", "out": "{@backend.modules}/{kebab}/tests/{Name}.test.js", "when": "architecture != \"lightweight\" && (withTests || architecture == \"advanced\")" },
17
+ { "template": "resource/page-{formMode}.jsx.ejs", "out": "{@frontend.adminPages}/{kebab}/ListPage.jsx", "when": "withFrontend" },
18
+ { "template": "resource/page-detail.jsx.ejs", "out": "{@frontend.adminPages}/{kebab}/DetailPage.jsx", "when": "withFrontend" },
19
+ { "template": "resource/page-form.jsx.ejs", "out": "{@frontend.adminPages}/{kebab}/FormPage.jsx", "when": "withFrontend && formMode == \"page\"" },
20
+ { "template": "resource/components/table.jsx.ejs", "out": "{@frontend.components}/tables/{Name}Table.jsx", "when": "withFrontend" },
21
+ { "template": "resource/components/form.jsx.ejs", "out": "{@frontend.components}/forms/{Name}Form.jsx", "when": "withFrontend" },
22
+ { "template": "resource/api.js.ejs", "out": "{@frontend.api}/{kebab}.api.js", "when": "withFrontend" },
23
+ { "template": "resource/hooks.js.ejs", "out": "{@frontend.hooks}/use{Name}.js", "when": "withFrontend" },
24
+ { "template": "resource/types.ts.ejs", "out": "{frontend}/src/types/{kebab}.types.ts", "when": "withFrontend && usesTypeScript" }
25
+ ],
26
+ "inject": [
27
+ { "anchor": "backend.routes", "template": "snippets/route-mount.ejs" },
28
+ { "anchor": "frontend.lazyImports", "template": "snippets/lazy-import.ejs", "when": "withFrontend" },
29
+ { "anchor": "frontend.routes", "template": "snippets/route-entry.ejs", "when": "withFrontend" },
30
+ { "anchor": "frontend.nav", "template": "snippets/nav-entry.ejs", "when": "withFrontend" }
31
+ ],
32
+ "requires": [
33
+ { "scope": "backend", "package": "slugify", "version": "^1.6.6", "when": "hasField:slug || hasField:code || hasField:sku" }
34
+ ]
35
+ }