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,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
|
+
}
|