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,163 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import ejs from 'ejs';
5
+ import { fileURLToPath } from 'url';
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+
10
+ const HOME = os.homedir();
11
+
12
+ /**
13
+ * TemplateLoader — resolves and renders templates from 3 locations:
14
+ * 1. ./<project>/.loom/templates/<template-path> (project-specific — highest priority)
15
+ * 2. ~/.loom/templates/<template-path> (user-global overrides)
16
+ * 3. built-in/packages/cli/src/templates/ (shipped defaults)
17
+ */
18
+ export class TemplateLoader {
19
+ constructor() {
20
+ this.cache = new Map(); // cacheKey → { compiled, mtime }
21
+ this.projectRoot = process.cwd(); // default, can be overridden per call
22
+ }
23
+
24
+ /**
25
+ * Resolve template file path from template identifier
26
+ * @param {string} templatePath - e.g., 'resource/model.js.ejs'
27
+ * @param {string} projectRoot - root of the Stackloom project
28
+ * @returns {string} absolute path to template file
29
+ */
30
+ resolve(templatePath, projectRoot = this.projectRoot) {
31
+ const locations = [
32
+ path.join(projectRoot, '.loom', 'templates', templatePath),
33
+ path.join(HOME, '.loom', 'templates', templatePath),
34
+ path.join(__dirname, '..', '..', 'src', 'templates', templatePath),
35
+ ];
36
+
37
+ for (const loc of locations) {
38
+ if (fs.existsSync(loc)) {
39
+ return loc;
40
+ }
41
+ }
42
+
43
+ // Not found anywhere — helpful error
44
+ const relativePath = templatePath;
45
+ throw new Error(
46
+ `Template not found: ${relativePath}\n` +
47
+ `Searched in:\n` +
48
+ locations.map(l => ` ${l}`).join('\n') + '\n' +
49
+ `Run 'loom template list' to see available templates.`
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Render a template with given context
55
+ * @param {string} templatePath - template identifier
56
+ * @param {object} context - variables available in template
57
+ * @param {string} projectRoot - Stackloom project root
58
+ * @returns {string} rendered content
59
+ */
60
+ async render(templatePath, context = {}, projectRoot = this.projectRoot) {
61
+ const fullPath = this.resolve(templatePath, projectRoot);
62
+
63
+ // Build cache key: file path + resource name (so cache invalidation is per-resource)
64
+ const cacheKey = `${fullPath}:${context.resource?.name || 'global'}`;
65
+
66
+ // Check cache
67
+ const cached = this.cache.get(cacheKey);
68
+ if (cached) {
69
+ const currentMtime = (await fs.stat(fullPath)).mtimeMs;
70
+ if (currentMtime === cached.mtime) {
71
+ return cached.compiled(context);
72
+ }
73
+ }
74
+
75
+ const source = await fs.readFile(fullPath, 'utf-8');
76
+
77
+ // Compile with EJS.
78
+ // rmWhitespace MUST stay false — it strips every line's leading whitespace,
79
+ // flattening generated code to column 0. Templates instead control output
80
+ // whitespace explicitly with the `-%>` slurp tag.
81
+ const compiled = ejs.compile(source, {
82
+ filename: fullPath,
83
+ cache: false, // we manage our own cache
84
+ rmWhitespace: false,
85
+ });
86
+
87
+ // Verify compilation works
88
+ try {
89
+ const result = compiled(context);
90
+
91
+ // Cache it
92
+ const mtime = (await fs.stat(fullPath)).mtimeMs;
93
+ this.cache.set(cacheKey, { compiled, mtime, path: fullPath });
94
+
95
+ return result;
96
+ } catch (err) {
97
+ const lineInfo = this.getErrorLine(err, source);
98
+ throw new Error(
99
+ `Template render error in ${templatePath}${lineInfo ? ` (line ${lineInfo})` : ''}:\n` +
100
+ `${err.message}\n` +
101
+ `Available context keys: ${Object.keys(context).join(', ')}`
102
+ );
103
+ }
104
+ }
105
+
106
+ getErrorLine(err, source) {
107
+ const match = err.stack?.match(/\((\d+):(\d+)\)$/);
108
+ if (match) return match[1];
109
+ return null;
110
+ }
111
+
112
+ /**
113
+ * List all built-in templates
114
+ * @returns {Array<{relativePath: string, fullPath: string, size: number}>}
115
+ */
116
+ async listBuiltIn() {
117
+ const builtInDir = path.join(__dirname, '..', '..', 'src', 'templates');
118
+ return this.listTemplatesRecursive(builtInDir, '');
119
+ }
120
+
121
+ listTemplatesRecursive(dir, prefix) {
122
+ const entries = [];
123
+ const items = fs.readdirSync(dir, { withFileTypes: true });
124
+
125
+ for (const item of items) {
126
+ const relPath = prefix ? `${prefix}/${item.name}` : item.name;
127
+ const fullPath = path.join(dir, item.name);
128
+
129
+ if (item.isDirectory()) {
130
+ entries.push(...this.listTemplatesRecursive(fullPath, relPath));
131
+ } else if (item.isFile() && (item.name.endsWith('.ejs') || item.name.endsWith('.js') || item.name.endsWith('.jsx'))) {
132
+ const stat = fs.statSync(fullPath);
133
+ entries.push({
134
+ relativePath: relPath.replace(/\.ejs$/, ''), // strip .ejs for display
135
+ fullPath,
136
+ size: stat.size,
137
+ });
138
+ }
139
+ }
140
+
141
+ return entries.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
142
+ }
143
+
144
+ /**
145
+ * Clear cache (useful for tests or after template edits)
146
+ */
147
+ clearCache() {
148
+ this.cache.clear();
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Helper functions available in all templates
154
+ */
155
+ export const templateHelpers = {
156
+ pascal: (s) => s.charAt(0).toUpperCase() + s.slice(1),
157
+ camel: (s) => s.charAt(0).toLowerCase() + s.slice(1),
158
+ snake: (s) => s.replace(/[A-Z]/g, m => '_' + m.toLowerCase()),
159
+ kebab: (s) => s.replace(/[A-Z]/g, m => '-' + m.toLowerCase()),
160
+ quote: (str) => JSON.stringify(str),
161
+ indent: (str, n = 2) => str.split('\n').map(l => ' '.repeat(n) + l).join('\n'),
162
+ pluralize: (word) => word + 's', // simplistic
163
+ };
@@ -0,0 +1,306 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import {
6
+ FileTransaction,
7
+ Validator,
8
+ scanDelimiters,
9
+ Injector,
10
+ InjectionError,
11
+ Pipeline,
12
+ defineStep,
13
+ createGenerationPipeline,
14
+ } from "../index.js";
15
+ import { blueprintLoader } from "../../blueprint/index.js";
16
+ import { recipeLoader } from "../../recipes/index.js";
17
+
18
+ const tmp = (label) =>
19
+ path.join(os.tmpdir(), `${label}-${Math.random().toString(36).slice(2)}`);
20
+
21
+ /** A minimal project carrying the four MERN anchor files. */
22
+ function scaffold(root) {
23
+ const write = (rel, content) => {
24
+ const abs = path.join(root, rel);
25
+ mkdirSync(path.dirname(abs), { recursive: true });
26
+ writeFileSync(abs, content);
27
+ };
28
+ write("backend/package.json", "{}");
29
+ write(
30
+ "backend/src/routes/index.js",
31
+ 'const router = require("express").Router();\nrouter.use("/auth", authRoutes);\nmodule.exports = router;\n',
32
+ );
33
+ write("frontend/src/main.jsx", "");
34
+ write(
35
+ "frontend/src/routes/AppRouter.jsx",
36
+ 'import { lazy } from "react";\n' +
37
+ 'const LoginPage = lazy(() => import("@/pages/auth/LoginPage"));\n' +
38
+ "export function AppRouter() {\n return (\n <Routes>\n" +
39
+ ' <Route path="*" element={<NotFound />} />\n' +
40
+ " </Routes>\n );\n}\n",
41
+ );
42
+ write(
43
+ "frontend/src/config/app-preset.js",
44
+ 'export const preset = {\n navigation: [\n { label: "Dashboard", href: "/dashboard" },\n ],\n};\n',
45
+ );
46
+ return root;
47
+ }
48
+
49
+ /** Fake renderer: sensible, balanced output keyed by template path. */
50
+ const fakeRenderer = (templatePath) => {
51
+ if (templatePath === "snippets/route-mount.ejs") return 'router.use("/order", r);\n';
52
+ if (templatePath === "snippets/lazy-import.ejs") return "const OrderList = lazy(() => 0);\n";
53
+ if (templatePath === "snippets/route-entry.ejs") return '<Route path="/admin/order" />\n';
54
+ if (templatePath === "snippets/nav-entry.ejs") return '{ label: "Order" },\n';
55
+ return `// ${templatePath}\nmodule.exports = {};\n`;
56
+ };
57
+
58
+ describe("scanDelimiters", () => {
59
+ it("accepts balanced code, strings, regex, template literals and JSX", () => {
60
+ expect(scanDelimiters("function f() { return [1, 2]; }").balanced).toBe(true);
61
+ expect(scanDelimiters("const s = '})]'; const o = {};").balanced).toBe(true);
62
+ expect(scanDelimiters("const re = /[(]/; f();").balanced).toBe(true);
63
+ expect(scanDelimiters("const t = `a ${b} c`; g();").balanced).toBe(true);
64
+ expect(scanDelimiters("return (<div><Routes></Routes></div>);").balanced).toBe(true);
65
+ });
66
+
67
+ it("rejects unbalanced delimiters and unterminated literals", () => {
68
+ expect(scanDelimiters("function broken( {").balanced).toBe(false);
69
+ expect(scanDelimiters("return 1; }").balanced).toBe(false);
70
+ expect(scanDelimiters("const s = 'unterminated").balanced).toBe(false);
71
+ });
72
+ });
73
+
74
+ describe("Validator", () => {
75
+ it("gates code and JSON, lets unknown file types pass", () => {
76
+ const v = new Validator();
77
+ expect(v.validateFile({ relPath: "a.js", content: "module.exports = {};" }).ok).toBe(true);
78
+ expect(v.validateFile({ relPath: "a.js", content: "module.exports = {" }).ok).toBe(false);
79
+ expect(v.validateFile({ relPath: "a.js", content: " " }).ok).toBe(false);
80
+ expect(v.validateFile({ relPath: "a.json", content: '{"x":1}' }).ok).toBe(true);
81
+ expect(v.validateFile({ relPath: "a.json", content: "{bad}" }).ok).toBe(false);
82
+ expect(v.validateFile({ relPath: "readme.md", content: "# unbalanced ]" }).ok).toBe(true);
83
+ });
84
+
85
+ it("validateAll reports every failing file", () => {
86
+ const result = new Validator().validateAll([
87
+ { relPath: "ok.js", content: "f();" },
88
+ { relPath: "bad.jsx", content: "<div>{" },
89
+ ]);
90
+ expect(result.ok).toBe(false);
91
+ expect(result.failures.map((f) => f.relPath)).toEqual(["bad.jsx"]);
92
+ });
93
+ });
94
+
95
+ describe("FileTransaction", () => {
96
+ it("stages without writing, then commits atomically", () => {
97
+ const root = tmp("tx");
98
+ mkdirSync(root, { recursive: true });
99
+ const tx = new FileTransaction({ projectRoot: root });
100
+ tx.stage("src/a.js", "// a\n").stage("src/nested/b.js", "// b\n");
101
+ expect(existsSync(path.join(root, "src/a.js"))).toBe(false);
102
+ expect(tx.commit()).toHaveLength(2);
103
+ expect(readFileSync(path.join(root, "src/a.js"), "utf-8")).toBe("// a\n");
104
+ rmSync(root, { recursive: true, force: true });
105
+ });
106
+
107
+ it("upserts: re-staging a path replaces it", () => {
108
+ const tx = new FileTransaction({ projectRoot: tmp("tx-upsert") });
109
+ tx.stage("a.js", "first").stage("a.js", "second");
110
+ expect(tx.get("a.js")).toBe("second");
111
+ expect(tx.staged()).toHaveLength(1);
112
+ });
113
+
114
+ it("rolls back fully when a write fails mid-commit", () => {
115
+ const root = tmp("tx-rollback");
116
+ mkdirSync(root, { recursive: true });
117
+ writeFileSync(path.join(root, "existing.js"), "ORIGINAL");
118
+
119
+ let writes = 0;
120
+ const fs = {
121
+ existsSync,
122
+ readFileSync,
123
+ mkdirSync,
124
+ rmSync,
125
+ writeFileSync(file, data) {
126
+ if (++writes === 2) throw new Error("disk full (simulated)");
127
+ writeFileSync(file, data);
128
+ },
129
+ };
130
+ const tx = new FileTransaction({ projectRoot: root, fs });
131
+ tx.stage("existing.js", "MODIFIED").stage("fresh.js", "NEW");
132
+ expect(() => tx.commit()).toThrow(/disk full/);
133
+ expect(readFileSync(path.join(root, "existing.js"), "utf-8")).toBe("ORIGINAL");
134
+ expect(existsSync(path.join(root, "fresh.js"))).toBe(false);
135
+ rmSync(root, { recursive: true, force: true });
136
+ });
137
+ });
138
+
139
+ describe("Injector", () => {
140
+ it("splices each strategy at its anchor", async () => {
141
+ const root = scaffold(tmp("inj"));
142
+ const blueprint = await blueprintLoader.load(root);
143
+ const injector = new Injector();
144
+ const tx = new FileTransaction({ projectRoot: root });
145
+ const run = (anchorName, snippet) =>
146
+ injector.inject({ anchorName, snippet, blueprint, projectRoot: root, transaction: tx });
147
+
148
+ run("backend.routes", 'router.use("/order", orderRoutes);');
149
+ run("frontend.lazyImports", 'const OrderList = lazy(() => import("@/pages/admin/order/ListPage"));');
150
+ run("frontend.routes", '<Route path="/admin/order" element={<OrderList />} />');
151
+ run("frontend.nav", '{ label: "Order", href: "/admin/order" },');
152
+
153
+ const routes = tx.get("backend/src/routes/index.js");
154
+ expect(routes.indexOf('router.use("/order"')).toBeLessThan(routes.indexOf("module.exports = router;"));
155
+
156
+ const router = tx.get("frontend/src/routes/AppRouter.jsx");
157
+ expect(router).toContain("const OrderList = lazy(");
158
+ expect(router.indexOf("/admin/order")).toBeLessThan(router.indexOf('path="*"'));
159
+
160
+ expect(tx.get("frontend/src/config/app-preset.js")).toContain(
161
+ '{ label: "Order", href: "/admin/order" },',
162
+ );
163
+ rmSync(root, { recursive: true, force: true });
164
+ });
165
+
166
+ it("is idempotent — re-injecting the same snippet is a no-op", async () => {
167
+ const root = scaffold(tmp("inj-idem"));
168
+ const blueprint = await blueprintLoader.load(root);
169
+ const injector = new Injector();
170
+ const tx = new FileTransaction({ projectRoot: root });
171
+ const args = {
172
+ anchorName: "backend.routes",
173
+ snippet: 'router.use("/order", orderRoutes);',
174
+ blueprint,
175
+ projectRoot: root,
176
+ transaction: tx,
177
+ };
178
+ expect(injector.inject(args).action).toBe("inject");
179
+ const second = injector.inject(args);
180
+ expect(second.action).toBe("skip");
181
+ expect(second.reason).toBe("already-present");
182
+ const count = tx.get("backend/src/routes/index.js").split('router.use("/order"').length - 1;
183
+ expect(count).toBe(1);
184
+ rmSync(root, { recursive: true, force: true });
185
+ });
186
+
187
+ it("throws InjectionError when the anchor file is missing", async () => {
188
+ const root = tmp("inj-missing");
189
+ mkdirSync(root, { recursive: true });
190
+ const blueprint = await blueprintLoader.load(root);
191
+ expect(() =>
192
+ new Injector().inject({
193
+ anchorName: "backend.routes",
194
+ snippet: "x",
195
+ blueprint,
196
+ projectRoot: root,
197
+ transaction: new FileTransaction({ projectRoot: root }),
198
+ }),
199
+ ).toThrow(InjectionError);
200
+ rmSync(root, { recursive: true, force: true });
201
+ });
202
+ });
203
+
204
+ describe("Pipeline", () => {
205
+ it("runs steps in order, threading the context", async () => {
206
+ const trail = [];
207
+ const out = await new Pipeline([
208
+ defineStep("one", (ctx) => {
209
+ trail.push("one");
210
+ ctx.a = 1;
211
+ }),
212
+ defineStep("two", (ctx) => {
213
+ trail.push("two");
214
+ ctx.b = ctx.a + 1;
215
+ }),
216
+ ]).run({});
217
+ expect(trail).toEqual(["one", "two"]);
218
+ expect(out.b).toBe(2);
219
+ });
220
+ });
221
+
222
+ describe("createGenerationPipeline", () => {
223
+ const setup = async (root) => {
224
+ scaffold(root);
225
+ return {
226
+ blueprint: await blueprintLoader.load(root),
227
+ recipe: await recipeLoader.load("resource"),
228
+ };
229
+ };
230
+ const invoke = (root, blueprint, recipe, extra = {}) => ({
231
+ projectRoot: root,
232
+ recipe,
233
+ blueprint,
234
+ recipeContext: { withFrontend: true },
235
+ vars: { kebab: "order", Name: "Order" },
236
+ templateContext: {},
237
+ ...extra,
238
+ });
239
+
240
+ it("plan → render → inject → validate → commit writes files and anchors", async () => {
241
+ const root = tmp("gen-ok");
242
+ const { blueprint, recipe } = await setup(root);
243
+ const ctx = await createGenerationPipeline({ renderer: fakeRenderer }).run(
244
+ invoke(root, blueprint, recipe),
245
+ );
246
+ // 12 generated files (full-stack, no tests, no TS) + 3 modified anchor files.
247
+ expect(ctx.result.files.length).toBe(15);
248
+ expect(ctx.injections.filter((i) => i.action === "inject")).toHaveLength(4);
249
+ expect(existsSync(path.join(root, "backend/src/modules/order/models/Order.js"))).toBe(true);
250
+ expect(readFileSync(path.join(root, "backend/src/routes/index.js"), "utf-8")).toContain(
251
+ 'router.use("/order"',
252
+ );
253
+ rmSync(root, { recursive: true, force: true });
254
+ });
255
+
256
+ it("commits nothing when any file fails validation", async () => {
257
+ const root = tmp("gen-bad");
258
+ const { blueprint, recipe } = await setup(root);
259
+ const pipeline = createGenerationPipeline({ renderer: () => "function broken( {\n" });
260
+ await expect(pipeline.run(invoke(root, blueprint, recipe))).rejects.toThrow(/failed validation/);
261
+ expect(existsSync(path.join(root, "backend/src/modules/order/models/Order.js"))).toBe(false);
262
+ rmSync(root, { recursive: true, force: true });
263
+ });
264
+
265
+ it("a bad inject snippet aborts the whole commit, leaving anchor files untouched", async () => {
266
+ const root = tmp("gen-inj-bad");
267
+ const { blueprint, recipe } = await setup(root);
268
+ const renderer = (t) =>
269
+ t === "snippets/nav-entry.ejs" ? "{ label: broken (\n" : "module.exports = {};\n";
270
+ await expect(
271
+ createGenerationPipeline({ renderer }).run(invoke(root, blueprint, recipe)),
272
+ ).rejects.toThrow(/failed validation/);
273
+ expect(readFileSync(path.join(root, "frontend/src/config/app-preset.js"), "utf-8")).not.toContain(
274
+ "broken",
275
+ );
276
+ rmSync(root, { recursive: true, force: true });
277
+ });
278
+
279
+ it("dry-run reports the plan without writing", async () => {
280
+ const root = tmp("gen-dry");
281
+ const { blueprint, recipe } = await setup(root);
282
+ const ctx = await createGenerationPipeline({ renderer: fakeRenderer }).run(
283
+ invoke(root, blueprint, recipe, { dryRun: true }),
284
+ );
285
+ expect(ctx.result.dryRun).toBe(true);
286
+ expect(existsSync(path.join(root, "backend/src/modules/order/models/Order.js"))).toBe(false);
287
+ expect(readFileSync(path.join(root, "backend/src/routes/index.js"), "utf-8")).not.toContain(
288
+ "/order",
289
+ );
290
+ rmSync(root, { recursive: true, force: true });
291
+ });
292
+
293
+ it("withInject: false runs render → validate → commit only", async () => {
294
+ const root = tmp("gen-noinject");
295
+ const { blueprint, recipe } = await setup(root);
296
+ const ctx = await createGenerationPipeline({ renderer: fakeRenderer, withInject: false }).run(
297
+ invoke(root, blueprint, recipe, { recipeContext: { withFrontend: false } }),
298
+ );
299
+ expect(ctx.result.files).toHaveLength(5);
300
+ expect(ctx.injections).toBeUndefined();
301
+ expect(readFileSync(path.join(root, "backend/src/routes/index.js"), "utf-8")).not.toContain(
302
+ "/order",
303
+ );
304
+ rmSync(root, { recursive: true, force: true });
305
+ });
306
+ });
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Engine — the transactional generation core.
3
+ *
4
+ * Composable, injectable, all-or-nothing: a recipe is planned, every file is
5
+ * rendered and validated in a staging transaction, and only a fully-valid set
6
+ * is committed atomically. Nothing here knows about a specific architecture or
7
+ * template library — those arrive as a Blueprint and an injected renderer.
8
+ */
9
+ export { FileTransaction, realFs } from "./transaction.js";
10
+ export { Validator, scanDelimiters } from "./validator.js";
11
+ export { Injector, InjectionError } from "./injector.js";
12
+ export {
13
+ Pipeline,
14
+ defineStep,
15
+ planStep,
16
+ commitStep,
17
+ createRenderStep,
18
+ createInjectStep,
19
+ createValidateStep,
20
+ createGenerationPipeline,
21
+ } from "./pipeline.js";
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Injector — splices generated snippets into existing project files at the
3
+ * injection points a blueprint declares.
4
+ *
5
+ * Replaces the scattered, fragile regex string-replacement in the old
6
+ * generator. Properties:
7
+ * - blueprint-driven — anchors (file + strategy + pattern) come from the
8
+ * architecture contract, not hardcoded here
9
+ * - idempotent — re-running an injection that is already present is a
10
+ * no-op, so generators are safe to re-run
11
+ * - transaction-aware — modified files are staged into the same
12
+ * FileTransaction as generated files, so the whole change set is atomic and
13
+ * successive injections into one file compose correctly
14
+ * - loud on failure — a missing anchor file or unfound pattern throws an
15
+ * InjectionError rather than silently skipping
16
+ */
17
+ import path from "node:path";
18
+ import { realFs } from "./transaction.js";
19
+
20
+ export class InjectionError extends Error {
21
+ constructor(message, { anchor, file } = {}) {
22
+ super(message);
23
+ this.name = "InjectionError";
24
+ this.anchor = anchor;
25
+ this.file = file;
26
+ }
27
+ }
28
+
29
+ /** Leading whitespace of the line containing `idx`. */
30
+ function lineIndentAt(content, idx) {
31
+ const lineStart = content.lastIndexOf("\n", idx) + 1;
32
+ return (content.slice(lineStart).match(/^[ \t]*/) || [""])[0];
33
+ }
34
+
35
+ /** Indent every non-blank line of `block` by `indent`. */
36
+ function indentBlock(block, indent) {
37
+ return block
38
+ .split("\n")
39
+ .map((line) => (line.trim() ? indent + line : line))
40
+ .join("\n");
41
+ }
42
+
43
+ /**
44
+ * Index of the delimiter that closes the one at `openIndex`, aware of strings,
45
+ * template literals and comments. Returns -1 if unbalanced.
46
+ */
47
+ function findMatchingDelimiter(src, openIndex, openChar, closeChar) {
48
+ let depth = 0;
49
+ let i = openIndex;
50
+ const n = src.length;
51
+ while (i < n) {
52
+ const c = src[i];
53
+ const next = src[i + 1];
54
+ if (c === "/" && next === "/") {
55
+ i += 2;
56
+ while (i < n && src[i] !== "\n") i++;
57
+ continue;
58
+ }
59
+ if (c === "/" && next === "*") {
60
+ i += 2;
61
+ while (i < n && !(src[i] === "*" && src[i + 1] === "/")) i++;
62
+ i += 2;
63
+ continue;
64
+ }
65
+ if (c === "'" || c === '"' || c === "`") {
66
+ i++;
67
+ while (i < n && src[i] !== c) {
68
+ if (src[i] === "\\") i++;
69
+ i++;
70
+ }
71
+ i++;
72
+ continue;
73
+ }
74
+ if (c === openChar) depth++;
75
+ else if (c === closeChar) {
76
+ depth--;
77
+ if (depth === 0) return i;
78
+ }
79
+ i++;
80
+ }
81
+ return -1;
82
+ }
83
+
84
+ /** Apply one anchor's strategy, returning the updated file content. */
85
+ function applyStrategy(anchor, anchorName, relFile, content, snippet) {
86
+ const fail = (msg) => {
87
+ throw new InjectionError(`${msg} (anchor "${anchorName}" → ${relFile})`, {
88
+ anchor: anchorName,
89
+ file: relFile,
90
+ });
91
+ };
92
+ const block = snippet.endsWith("\n") ? snippet : `${snippet}\n`;
93
+
94
+ switch (anchor.strategy) {
95
+ case "marker-comment": {
96
+ const idx = anchor.comment ? content.indexOf(anchor.comment) : -1;
97
+ if (idx === -1) fail(`anchor comment "${anchor.comment}" not found`);
98
+ const nl = content.indexOf("\n", idx);
99
+ const at = nl === -1 ? content.length : nl + 1;
100
+ return content.slice(0, at) + indentBlock(block, lineIndentAt(content, idx)) + content.slice(at);
101
+ }
102
+
103
+ case "before-line": {
104
+ const idx = content.indexOf(anchor.pattern);
105
+ if (idx === -1) fail(`pattern "${anchor.pattern}" not found`);
106
+ const lineStart = content.lastIndexOf("\n", idx) + 1;
107
+ return content.slice(0, lineStart) + indentBlock(block, lineIndentAt(content, idx)) + content.slice(lineStart);
108
+ }
109
+
110
+ case "before-match": {
111
+ const match = new RegExp(anchor.pattern, "m").exec(content);
112
+ if (!match) fail(`pattern /${anchor.pattern}/ not found`);
113
+ const lineStart = content.lastIndexOf("\n", match.index) + 1;
114
+ return (
115
+ content.slice(0, lineStart) +
116
+ indentBlock(block, lineIndentAt(content, match.index)) +
117
+ content.slice(lineStart)
118
+ );
119
+ }
120
+
121
+ case "after-last-match": {
122
+ const re = new RegExp(anchor.pattern, "gm");
123
+ let last = null;
124
+ let m;
125
+ while ((m = re.exec(content)) !== null) {
126
+ last = m;
127
+ if (m[0] === "") re.lastIndex++;
128
+ }
129
+ if (!last) fail(`pattern /${anchor.pattern}/ not found`);
130
+ const nl = content.indexOf("\n", last.index + last[0].length);
131
+ const at = nl === -1 ? content.length : nl + 1;
132
+ return content.slice(0, at) + indentBlock(block, lineIndentAt(content, last.index)) + content.slice(at);
133
+ }
134
+
135
+ case "array-append": {
136
+ const match = new RegExp(anchor.pattern, "m").exec(content);
137
+ if (!match) fail(`array pattern /${anchor.pattern}/ not found`);
138
+ const open = content.indexOf("[", match.index);
139
+ if (open === -1) fail(`no "[" found after array pattern`);
140
+ const close = findMatchingDelimiter(content, open, "[", "]");
141
+ if (close === -1) fail(`unbalanced "[" for array anchor`);
142
+
143
+ const inner = content.slice(open + 1, close).replace(/\s+$/, "");
144
+ const closeIndent = lineIndentAt(content, close);
145
+ const entryIndent = `${closeIndent} `;
146
+ const needsComma = inner.length > 0 && !inner.endsWith(",");
147
+ const newInner = `${inner}${needsComma ? "," : ""}\n${entryIndent}${snippet.trim()}\n${closeIndent}`;
148
+ return content.slice(0, open + 1) + newInner + content.slice(close);
149
+ }
150
+
151
+ default:
152
+ return fail(`unknown injection strategy "${anchor.strategy}"`);
153
+ }
154
+ }
155
+
156
+ export class Injector {
157
+ /**
158
+ * @param {object} [options]
159
+ * @param {typeof realFs} [options.fs] - filesystem adapter (injectable for tests)
160
+ */
161
+ constructor({ fs = realFs } = {}) {
162
+ this.fs = fs;
163
+ }
164
+
165
+ /**
166
+ * Splice `snippet` into the file named by blueprint anchor `anchorName`,
167
+ * staging the modified file into `transaction`.
168
+ * @returns {{ anchor: string, file: string, action: "inject"|"skip", reason?: string }}
169
+ */
170
+ inject({ anchorName, snippet, blueprint, projectRoot, transaction }) {
171
+ const anchor = blueprint.getAnchor(anchorName); // throws if anchor undeclared
172
+ const relFile = blueprint.expand(anchor.file, projectRoot);
173
+ const absFile = path.join(projectRoot, relFile);
174
+
175
+ // Prefer the transaction's pending content so successive injections compose.
176
+ let content = transaction.get(relFile);
177
+ if (content === undefined) {
178
+ if (!this.fs.existsSync(absFile)) {
179
+ throw new InjectionError(
180
+ `Anchor "${anchorName}" targets ${relFile}, which does not exist in this project.`,
181
+ { anchor: anchorName, file: relFile },
182
+ );
183
+ }
184
+ content = this.fs.readFileSync(absFile, "utf-8");
185
+ }
186
+
187
+ const trimmed = snippet.trim();
188
+ if (!trimmed) {
189
+ return { anchor: anchorName, file: relFile, action: "skip", reason: "empty-snippet" };
190
+ }
191
+ if (content.includes(trimmed)) {
192
+ return { anchor: anchorName, file: relFile, action: "skip", reason: "already-present" };
193
+ }
194
+
195
+ transaction.stage(relFile, applyStrategy(anchor, anchorName, relFile, content, snippet));
196
+ return { anchor: anchorName, file: relFile, action: "inject" };
197
+ }
198
+ }