boilercraft 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 (146) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +171 -0
  3. package/bin/boilercraft.js +110 -0
  4. package/cli/app.js +596 -0
  5. package/cli/ui.js +356 -0
  6. package/docs/STACK_ENGINE.md +93 -0
  7. package/docs/assets/cli-create.svg +52 -0
  8. package/docs/assets/cli-menu.svg +47 -0
  9. package/docs/stacks/dotnet-core.md +69 -0
  10. package/docs/stacks/laravel-vue.md +46 -0
  11. package/docs/stacks/nextjs.md +58 -0
  12. package/docs/stacks/node-express.md +58 -0
  13. package/docs/stacks/raw-php.md +130 -0
  14. package/main.js +82 -0
  15. package/package.json +86 -0
  16. package/public/index.html +721 -0
  17. package/public/logo.png +17 -0
  18. package/public/logo.svg +17 -0
  19. package/server.js +84 -0
  20. package/services/componentGenerator.js +195 -0
  21. package/services/engine/scaffoldEngine.js +517 -0
  22. package/services/engine/toolInstaller.js +232 -0
  23. package/services/engine/versionCatalog.js +115 -0
  24. package/services/generate.js +46 -0
  25. package/services/projectGenerator.js +686 -0
  26. package/services/readmeGenerator.js +221 -0
  27. package/services/themeGenerator.js +150 -0
  28. package/services/versionResolver.js +176 -0
  29. package/stacks/dotnet-core/overlays/api/Controllers/ProductsController.cs +33 -0
  30. package/stacks/dotnet-core/overlays/api/Program.cs +33 -0
  31. package/stacks/dotnet-core/overlays/api/wwwroot/index.html +38 -0
  32. package/stacks/dotnet-core/overlays/api-auth/Controllers/AuthController.cs +87 -0
  33. package/stacks/dotnet-core/overlays/common/Data/ApplicationDbContext.cs +25 -0
  34. package/stacks/dotnet-core/overlays/common/Models/Product.cs +19 -0
  35. package/stacks/dotnet-core/overlays/mvc/Controllers/ProductsController.cs +84 -0
  36. package/stacks/dotnet-core/overlays/mvc/Views/Home/Index.cshtml +23 -0
  37. package/stacks/dotnet-core/overlays/mvc/Views/Products/Create.cshtml +16 -0
  38. package/stacks/dotnet-core/overlays/mvc/Views/Products/Delete.cshtml +16 -0
  39. package/stacks/dotnet-core/overlays/mvc/Views/Products/Edit.cshtml +17 -0
  40. package/stacks/dotnet-core/overlays/mvc/Views/Products/Index.cshtml +46 -0
  41. package/stacks/dotnet-core/overlays/mvc/Views/Products/_Form.cshtml +19 -0
  42. package/stacks/dotnet-core/overlays/mvc-auth/Controllers/AccountController.cs +98 -0
  43. package/stacks/dotnet-core/overlays/mvc-auth/Models/AppUser.cs +48 -0
  44. package/stacks/dotnet-core/overlays/mvc-auth/Views/Account/Login.cshtml +35 -0
  45. package/stacks/dotnet-core/overlays/mvc-auth/Views/Account/Register.cshtml +41 -0
  46. package/stacks/dotnet-core/overlays/mvc-auth/Views/Shared/_AuthNav.cshtml +22 -0
  47. package/stacks/dotnet-core/snippets/packages-auth.xml +4 -0
  48. package/stacks/dotnet-core/snippets/packages-common.xml +5 -0
  49. package/stacks/dotnet-core/snippets/packages-ef.xml +4 -0
  50. package/stacks/dotnet-core/snippets/packages-mssql.xml +4 -0
  51. package/stacks/dotnet-core/snippets/packages-mysql.xml +4 -0
  52. package/stacks/dotnet-core/snippets/packages-swagger.xml +4 -0
  53. package/stacks/dotnet-core/stack.json +426 -0
  54. package/stacks/laravel/overlays/auth/app/Http/Controllers/Api/AuthController.php +67 -0
  55. package/stacks/laravel/overlays/common/app/Http/Controllers/BoilerCraftController.php +14 -0
  56. package/stacks/laravel/overlays/common/resources/views/boilercraft.blade.php +36 -0
  57. package/stacks/laravel/overlays/inertia-common/resources/views/app.blade.php +15 -0
  58. package/stacks/laravel/overlays/inertia-react/resources/js/Pages/Welcome.jsx +18 -0
  59. package/stacks/laravel/overlays/inertia-react/resources/js/app.jsx +13 -0
  60. package/stacks/laravel/overlays/inertia-vue/resources/js/Pages/Welcome.vue +23 -0
  61. package/stacks/laravel/overlays/inertia-vue/resources/js/app.js +13 -0
  62. package/stacks/laravel/snippets/api-routes.php +9 -0
  63. package/stacks/laravel/snippets/web-routes-inertia.php +7 -0
  64. package/stacks/laravel/snippets/web-routes.php +4 -0
  65. package/stacks/laravel/stack.json +477 -0
  66. package/stacks/nextjs/overlays/auth/src/lib/auth.js +36 -0
  67. package/stacks/nextjs/overlays/auth-app/src/app/api/auth/login/route.js +11 -0
  68. package/stacks/nextjs/overlays/auth-app/src/app/api/auth/register/route.js +14 -0
  69. package/stacks/nextjs/overlays/auth-pages/src/pages/api/auth/login.js +14 -0
  70. package/stacks/nextjs/overlays/auth-pages/src/pages/api/auth/register.js +17 -0
  71. package/stacks/nextjs/overlays/common/.env.example +3 -0
  72. package/stacks/nextjs/overlays/common/.env.local +3 -0
  73. package/stacks/nextjs/overlays/db/mongodb/src/lib/db.js +12 -0
  74. package/stacks/nextjs/overlays/db/mssql/src/lib/db.js +17 -0
  75. package/stacks/nextjs/overlays/db/mysql/src/lib/db.js +13 -0
  76. package/stacks/nextjs/overlays/router-app/src/app/api/health/route.js +10 -0
  77. package/stacks/nextjs/overlays/router-app/src/app/layout.js +16 -0
  78. package/stacks/nextjs/overlays/router-app/src/app/page.js +19 -0
  79. package/stacks/nextjs/overlays/router-pages/src/pages/_app.js +7 -0
  80. package/stacks/nextjs/overlays/router-pages/src/pages/_document.js +13 -0
  81. package/stacks/nextjs/overlays/router-pages/src/pages/api/health.js +8 -0
  82. package/stacks/nextjs/overlays/router-pages/src/pages/index.js +27 -0
  83. package/stacks/nextjs/stack.json +257 -0
  84. package/stacks/node-express/overlays/auth/src/controllers/authController.js +53 -0
  85. package/stacks/node-express/overlays/auth/src/middlewares/auth.js +36 -0
  86. package/stacks/node-express/overlays/auth/src/routes/authRoutes.js +16 -0
  87. package/stacks/node-express/overlays/common/.env.example +5 -0
  88. package/stacks/node-express/overlays/common/public/index.html +31 -0
  89. package/stacks/node-express/overlays/common/src/server.js +32 -0
  90. package/stacks/node-express/overlays/db/mongodb/src/db.js +13 -0
  91. package/stacks/node-express/overlays/db/mssql/src/db.js +22 -0
  92. package/stacks/node-express/overlays/db/mysql/src/db.js +22 -0
  93. package/stacks/node-express/stack.json +165 -0
  94. package/templates/dotnet-bootstrap-mssql/Controllers/AuthController.cs +74 -0
  95. package/templates/dotnet-bootstrap-mssql/Controllers/ProductsController.cs +33 -0
  96. package/templates/dotnet-bootstrap-mssql/Data/ApplicationDbContext.cs +24 -0
  97. package/templates/dotnet-bootstrap-mssql/Models/Product.cs +10 -0
  98. package/templates/dotnet-core/Controllers/AuthController.cs +74 -0
  99. package/templates/laravel-full/app/Http/Controllers/AuthController.php +79 -0
  100. package/templates/laravel-full/app/Http/Controllers/HomeController.php +22 -0
  101. package/templates/laravel-full/app/Models/User.php +49 -0
  102. package/templates/laravel-full/artisan +22 -0
  103. package/templates/laravel-full/bootstrap/app.php +21 -0
  104. package/templates/laravel-full/config/database.php +54 -0
  105. package/templates/laravel-full/database/migrations/2024_01_01_000001_create_users_table.php +27 -0
  106. package/templates/laravel-full/public/index.php +17 -0
  107. package/templates/laravel-full/resources/css/app.css +3 -0
  108. package/templates/laravel-full/resources/js/Pages/Home.vue +76 -0
  109. package/templates/laravel-full/resources/js/app.js +21 -0
  110. package/templates/laravel-full/resources/js/bootstrap.js +3 -0
  111. package/templates/laravel-full/resources/views/app.blade.php +15 -0
  112. package/templates/laravel-full/routes/api.php +8 -0
  113. package/templates/laravel-full/routes/console.php +7 -0
  114. package/templates/laravel-full/routes/web.php +24 -0
  115. package/templates/laravel-full/tailwind.config.js +12 -0
  116. package/templates/laravel-full/vite.config.js +20 -0
  117. package/templates/laravel-vue/app/Http/Controllers/Auth/AuthenticatedSessionController.php +43 -0
  118. package/templates/laravel-vue-mysql/app/Http/Controllers/HomeController.php +25 -0
  119. package/templates/laravel-vue-mysql/resources/js/Pages/Home.vue +70 -0
  120. package/templates/nextjs/src/app/api/auth/login/route.js +28 -0
  121. package/templates/nextjs-mongodb/src/app/api/health/route.js +17 -0
  122. package/templates/nextjs-mongodb/src/lib/mongodb.js +18 -0
  123. package/templates/nextjs-mssql/src/app/api/health/route.js +24 -0
  124. package/templates/nextjs-mssql/src/lib/mssql.js +38 -0
  125. package/templates/node-express/src/controllers/authController.js +53 -0
  126. package/templates/node-express/src/middlewares/auth.js +36 -0
  127. package/templates/node-express/src/routes/authRoutes.js +16 -0
  128. package/templates/node-mongodb/src/controllers/userController.js +30 -0
  129. package/templates/node-mongodb/src/middlewares/errorHandler.js +8 -0
  130. package/templates/node-mongodb/src/models/User.js +23 -0
  131. package/templates/node-mongodb/src/routes/api.js +9 -0
  132. package/templates/raw-php-mysql/app/Controllers/AuthController.php +73 -0
  133. package/templates/raw-php-mysql/app/Controllers/HomeController.php +65 -0
  134. package/templates/raw-php-mysql/app/Models/User.php +60 -0
  135. package/templates/raw-php-mysql/app/Views/404.php +15 -0
  136. package/templates/raw-php-mysql/app/Views/auth/login.php +66 -0
  137. package/templates/raw-php-mysql/app/Views/auth/register.php +71 -0
  138. package/templates/raw-php-mysql/app/Views/dashboard.php +50 -0
  139. package/templates/raw-php-mysql/app/Views/home.php +83 -0
  140. package/templates/raw-php-mysql/config/Auth.php +62 -0
  141. package/templates/raw-php-mysql/config/Database.php +100 -0
  142. package/templates/raw-php-mysql/config/Env.php +30 -0
  143. package/templates/raw-php-mysql/config/Middleware.php +53 -0
  144. package/templates/raw-php-mysql/config/QueryBuilder.php +109 -0
  145. package/templates/raw-php-mysql/config/Router.php +33 -0
  146. package/templates/raw-php-mysql/public/index.php +57 -0
@@ -0,0 +1,517 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const crypto = require('crypto');
4
+ const { spawn } = require('child_process');
5
+ const { listVersions, majorOf } = require('./versionCatalog');
6
+ const { toolEnv } = require('./toolInstaller');
7
+ const { generateThemeCSS, getThemeSwitcherJS } = require('../themeGenerator');
8
+ const { generateDynamicStackReadme } = require('../readmeGenerator');
9
+ const {
10
+ getSkeletonComponent,
11
+ getSpinnerComponent,
12
+ getModalComponent,
13
+ getOffcanvasComponent,
14
+ getSwalFireComponent,
15
+ getToastrSonnerComponent
16
+ } = require('../componentGenerator');
17
+
18
+ /**
19
+ * Manifest-driven scaffolding engine.
20
+ *
21
+ * Each stack lives in stacks/<id>/stack.json and describes:
22
+ * - where its versions come from (npm / packagist / dotnet release index)
23
+ * - which tools it needs (php, composer, node, dotnet …)
24
+ * - an ordered list of steps: run the framework's OFFICIAL creator
25
+ * (composer create-project, create-next-app, dotnet new …), then lay our
26
+ * own files on top.
27
+ *
28
+ * Every step can carry a `when` filter (version range, database, styling,
29
+ * auth), so version-specific differences are data, not code. Supporting a
30
+ * new framework release usually needs nothing at all; a breaking release
31
+ * needs one more overlay folder + a `when: { version: ">=N" }` step.
32
+ */
33
+
34
+ const STACKS_DIR = path.join(__dirname, '..', '..', 'stacks');
35
+
36
+ class RequirementError extends Error {
37
+ constructor(missing) {
38
+ super(`Missing required tools: ${missing.map(m => m.name).join(', ')}`);
39
+ this.name = 'RequirementError';
40
+ this.missing = missing;
41
+ }
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Stack registry
46
+ // ---------------------------------------------------------------------------
47
+
48
+ function loadStacks() {
49
+ if (!fs.existsSync(STACKS_DIR)) return [];
50
+ return fs.readdirSync(STACKS_DIR)
51
+ .filter(dir => fs.existsSync(path.join(STACKS_DIR, dir, 'stack.json')))
52
+ .map(dir => {
53
+ const manifest = JSON.parse(fs.readFileSync(path.join(STACKS_DIR, dir, 'stack.json'), 'utf8'));
54
+ return { ...manifest, id: manifest.id || dir, dir: path.join(STACKS_DIR, dir) };
55
+ });
56
+ }
57
+
58
+ function getStack(id) {
59
+ const stacks = loadStacks();
60
+ return stacks.find(s => s.id === id || (s.aliases || []).includes(id)) || null;
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Conditions
65
+ // ---------------------------------------------------------------------------
66
+
67
+ // Major-version range matcher: "*", "11", ">=11", "<11", "10-12", ">=9 <11".
68
+ function versionMatches(range, major) {
69
+ if (range === undefined || range === '*' || range === '') return true;
70
+ return String(range).trim().split(/\s+/).every(part => {
71
+ let m;
72
+ if ((m = /^(\d+)-(\d+)$/.exec(part))) return major >= +m[1] && major <= +m[2];
73
+ if ((m = /^(>=|<=|>|<|=)?(\d+)$/.exec(part))) {
74
+ const n = +m[2];
75
+ switch (m[1]) {
76
+ case '>=': return major >= n;
77
+ case '<=': return major <= n;
78
+ case '>': return major > n;
79
+ case '<': return major < n;
80
+ default: return major === n;
81
+ }
82
+ }
83
+ throw new Error(`Invalid version range '${range}'`);
84
+ });
85
+ }
86
+
87
+ function oneOf(expected, actual) {
88
+ return Array.isArray(expected) ? expected.includes(actual) : expected === actual;
89
+ }
90
+
91
+ function matches(when, ctx) {
92
+ if (!when) return true;
93
+ if (when.version !== undefined && !versionMatches(when.version, ctx.major)) return false;
94
+ if (when.database !== undefined && !oneOf(when.database, ctx.database)) return false;
95
+ if (when.styling !== undefined && !oneOf(when.styling, ctx.styling)) return false;
96
+ if (when.auth !== undefined && when.auth !== ctx.auth) return false;
97
+ if (when.roles !== undefined && when.roles !== ctx.roles) return false;
98
+ if (when.platform !== undefined && !oneOf(when.platform, process.platform)) return false;
99
+ // Any other key refers to a stack-specific option (e.g. Next.js "router").
100
+ for (const [key, expected] of Object.entries(when)) {
101
+ if (BUILTIN_WHEN_KEYS.has(key)) continue;
102
+ if (!oneOf(expected, ctx.options?.[key])) return false;
103
+ }
104
+ return true;
105
+ }
106
+
107
+ const BUILTIN_WHEN_KEYS = new Set(['version', 'database', 'styling', 'auth', 'roles', 'platform']);
108
+
109
+ // Stack-specific options (manifest "options"), validated, with defaults filled in.
110
+ function resolveOptions(stack, given = {}) {
111
+ const resolved = {};
112
+ for (const opt of stack.options || []) {
113
+ const value = given[opt.id] ?? opt.default ?? opt.choices[0].value;
114
+ if (!opt.choices.some(ch => ch.value === value)) {
115
+ throw new Error(`${stack.name}: invalid ${opt.label || opt.id} '${value}'. Options: ${opt.choices.map(ch => ch.value).join(', ')}`);
116
+ }
117
+ resolved[opt.id] = value;
118
+ }
119
+ return resolved;
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Placeholders
124
+ // ---------------------------------------------------------------------------
125
+
126
+ function escapeHtml(value) {
127
+ return String(value ?? '')
128
+ .replace(/&/g, '&amp;')
129
+ .replace(/</g, '&lt;')
130
+ .replace(/>/g, '&gt;')
131
+ .replace(/"/g, '&quot;')
132
+ .replace(/'/g, '&#39;');
133
+ }
134
+
135
+ function buildVars(config, stack, ctx) {
136
+ const projectName = ctx.name;
137
+ const styling = ctx.styling;
138
+ const colorMode = config.colorMode || 'all';
139
+ const title = config.appTitle || projectName;
140
+ const description = config.description || 'Enterprise grade multi-combination application';
141
+ const author = config.author || 'ARG RABBI';
142
+
143
+ let uiComponents = '';
144
+ if (config.components?.skeleton) uiComponents += getSkeletonComponent(styling) + '\n';
145
+ if (config.components?.spinner) uiComponents += getSpinnerComponent(styling) + '\n';
146
+ if (config.components?.modal) uiComponents += getModalComponent(styling) + '\n';
147
+ if (config.components?.offcanvas) uiComponents += getOffcanvasComponent(styling) + '\n';
148
+ if (config.components?.swalfire) uiComponents += getSwalFireComponent() + '\n';
149
+ if (config.components?.toastr) uiComponents += getToastrSonnerComponent() + '\n';
150
+
151
+ const logoHtml = config.logoUrl
152
+ ? `<img src="${escapeHtml(config.logoUrl)}" alt="${escapeHtml(title)}" class="bc-logo-img" />`
153
+ : `<div class="bc-logo">${escapeHtml(projectName[0].toUpperCase())}</div>`;
154
+
155
+ const stylesheetLink = styling === 'bootstrap'
156
+ ? '<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">'
157
+ : styling === 'tailwind'
158
+ ? '<script src="https://cdn.tailwindcss.com"></script>'
159
+ : '';
160
+
161
+ const vars = {
162
+ PROJECT_NAME: projectName,
163
+ NAMESPACE: projectName.replace(/[^A-Za-z0-9_]/g, '_').replace(/^(\d)/, '_$1'),
164
+ APP_TITLE: escapeHtml(title),
165
+ APP_DESC: escapeHtml(description),
166
+ AUTHOR: escapeHtml(author),
167
+ APP_TITLE_JS: JSON.stringify(title),
168
+ APP_DESC_JS: JSON.stringify(description),
169
+ AUTHOR_JS: JSON.stringify(author),
170
+ DB_NAME: `${projectName.replace(/-/g, '_')}_db`,
171
+ DB_TYPE: ctx.database,
172
+ DATABASE: ctx.database.toUpperCase(),
173
+ STYLING: styling.toUpperCase(),
174
+ THEME: config.theme || 'midnight',
175
+ COLOR_MODE: colorMode === 'all' || colorMode === 'device' ? 'dark' : colorMode,
176
+ PORT: String(config.port || stack.defaultPort || 3000),
177
+ VERSION: String(ctx.major),
178
+ STACK_NAME: stack.name,
179
+ AUTH_ENABLED: ctx.auth ? 'true' : 'false',
180
+ LOGO_HTML: logoHtml,
181
+ STYLESHEET_LINK: stylesheetLink,
182
+ UI_COMPONENTS: uiComponents,
183
+ UI_COMPONENTS_RAZOR: uiComponents.replace(/@/g, '@@'), // "@" is Razor syntax
184
+ THEME_CSS: generateThemeCSS(config.theme || 'midnight', colorMode),
185
+ THEME_JS: getThemeSwitcherJS(),
186
+ RANDOM_SECRET: crypto.randomBytes(32).toString('hex'),
187
+ RANDOM_PASSWORD: crypto.randomBytes(12).toString('base64url') + 'A1!'
188
+ };
189
+
190
+ const dbEnv = password => ({
191
+ mongodb: `MONGODB_URI=mongodb://127.0.0.1:27017/${vars.DB_NAME}`,
192
+ mysql: `DB_HOST=127.0.0.1\nDB_PORT=3306\nDB_DATABASE=${vars.DB_NAME}\nDB_USERNAME=root\nDB_PASSWORD=`,
193
+ mssql: `DB_HOST=127.0.0.1\nDB_PORT=1433\nDB_DATABASE=${vars.DB_NAME}\nDB_USERNAME=sa\nDB_PASSWORD=${password}`
194
+ }[ctx.database] || '');
195
+ // Stack options are available as {{OPTION_<ID>}}, e.g. {{OPTION_FRONTEND}}.
196
+ for (const [id, value] of Object.entries(ctx.options || {})) vars[`OPTION_${id.toUpperCase()}`] = String(value);
197
+
198
+ vars.DB_ENV = dbEnv(vars.RANDOM_PASSWORD);
199
+ vars.DB_ENV_EXAMPLE = dbEnv('change-me');
200
+
201
+ // Stack-defined conditional vars; later entries override earlier ones.
202
+ for (const v of stack.vars || []) {
203
+ if (!(v.name in vars)) vars[v.name] = '';
204
+ if (matches(v.when, ctx)) vars[v.name] = interpolate(v.value, vars);
205
+ }
206
+ return vars;
207
+ }
208
+
209
+ function interpolate(text, vars) {
210
+ return String(text).replace(/\{\{([A-Z0-9_]+)\}\}/g, (whole, key) =>
211
+ Object.prototype.hasOwnProperty.call(vars, key) ? vars[key] : whole
212
+ );
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // Step executors
217
+ // ---------------------------------------------------------------------------
218
+
219
+ function ensureDir(p) {
220
+ fs.mkdirSync(p, { recursive: true });
221
+ }
222
+
223
+ function isBinary(buf) {
224
+ return buf.subarray(0, 8000).includes(0);
225
+ }
226
+
227
+ function copyDir(src, dest, vars, log) {
228
+ ensureDir(dest);
229
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
230
+ const s = path.join(src, entry.name);
231
+ const d = path.join(dest, interpolate(entry.name, vars));
232
+ if (entry.isDirectory()) {
233
+ copyDir(s, d, vars, log);
234
+ } else {
235
+ const buf = fs.readFileSync(s);
236
+ fs.writeFileSync(d, isBinary(buf) ? buf : interpolate(buf.toString('utf8'), vars));
237
+ }
238
+ }
239
+ }
240
+
241
+ function quoteArg(arg) {
242
+ if (/^[A-Za-z0-9_\-.,:/=@^+]+$/.test(arg)) return arg;
243
+ return process.platform === 'win32'
244
+ ? `"${arg.replace(/"/g, '\\"')}"`
245
+ : `'${arg.replace(/'/g, `'\\''`)}'`;
246
+ }
247
+
248
+ function runCommand(cmd, args, cwd, log, extraEnv = {}) {
249
+ return new Promise((resolve, reject) => {
250
+ const line = [cmd, ...args].map(quoteArg).join(' ');
251
+ log(`$ ${line}`);
252
+ // shell:true so Windows can resolve composer.bat / npx.cmd shims.
253
+ const child = spawn(line, {
254
+ cwd,
255
+ shell: true,
256
+ env: { ...toolEnv(), CI: '1', COMPOSER_NO_INTERACTION: '1', DOTNET_CLI_TELEMETRY_OPTOUT: '1', DOTNET_NOLOGO: '1', ...extraEnv }
257
+ });
258
+ let tail = '';
259
+ const onData = chunk => {
260
+ const text = chunk.toString();
261
+ tail = (tail + text).slice(-4000);
262
+ if (!process.env.BOILERCRAFT_CLI) process.stdout.write(text); // the CLI shows its own progress
263
+ };
264
+ child.stdout.on('data', onData);
265
+ child.stderr.on('data', onData);
266
+ child.on('error', reject);
267
+ child.on('close', code => {
268
+ if (code === 0) resolve(tail);
269
+ else reject(new Error(`Command failed (exit ${code}): ${line}\n${tail.trim().split('\n').slice(-15).join('\n')}`));
270
+ });
271
+ });
272
+ }
273
+
274
+ function captureCommand(cmd, args) {
275
+ return new Promise(resolve => {
276
+ const child = spawn([cmd, ...args].map(quoteArg).join(' '), { shell: true, env: toolEnv() });
277
+ let out = '';
278
+ child.stdout.on('data', c => (out += c));
279
+ child.stderr.on('data', c => (out += c));
280
+ child.on('error', () => resolve({ ok: false, out }));
281
+ child.on('close', code => resolve({ ok: code === 0, out }));
282
+ });
283
+ }
284
+
285
+ // Sets KEY=value in an env file, replacing an existing (or commented-out) line.
286
+ function setEnvValues(file, values) {
287
+ if (!fs.existsSync(file)) return false;
288
+ let content = fs.readFileSync(file, 'utf8');
289
+ for (const [key, value] of Object.entries(values)) {
290
+ const re = new RegExp(`^#?\\s*${key}=.*$`, 'm');
291
+ const line = `${key}=${value}`;
292
+ content = re.test(content) ? content.replace(re, line) : content.replace(/\s*$/, `\n${line}\n`);
293
+ }
294
+ fs.writeFileSync(file, content);
295
+ return true;
296
+ }
297
+
298
+ function deepMerge(target, source) {
299
+ for (const [k, v] of Object.entries(source)) {
300
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
301
+ target[k] = deepMerge(target[k] && typeof target[k] === 'object' ? target[k] : {}, v);
302
+ } else {
303
+ target[k] = v;
304
+ }
305
+ }
306
+ return target;
307
+ }
308
+
309
+ async function executeStep(step, ctx) {
310
+ const { vars, projectDir, parentDir, stack, log, warn } = ctx;
311
+ const resolvePath = rel => path.join(projectDir, interpolate(rel, vars));
312
+ const readSource = s => (s.from ? fs.readFileSync(path.join(stack.dir, s.from), 'utf8') : s.content);
313
+
314
+ if (step.run) {
315
+ const [cmd, ...args] = step.run.map(a => interpolate(a, vars));
316
+ const cwd = step.cwd === 'parent' ? parentDir : projectDir;
317
+ ensureDir(cwd);
318
+ try {
319
+ await runCommand(cmd, args, cwd, log, step.env);
320
+ } catch (err) {
321
+ if (step.optional) warn(`${step.label || cmd} failed (optional step): ${err.message.split('\n')[0]}`);
322
+ else throw err;
323
+ }
324
+ } else if (step.copy) {
325
+ copyDir(path.join(stack.dir, step.copy), step.to ? resolvePath(step.to) : projectDir, vars, log);
326
+ } else if (step.write) {
327
+ const file = resolvePath(step.write);
328
+ ensureDir(path.dirname(file));
329
+ fs.writeFileSync(file, interpolate(readSource(step), vars));
330
+ } else if (step.prepend) {
331
+ const file = resolvePath(step.prepend);
332
+ if (!fs.existsSync(file)) return warn(`prepend: ${step.prepend} not found, skipped`);
333
+ fs.writeFileSync(file, interpolate(readSource(step), vars) + fs.readFileSync(file, 'utf8'));
334
+ } else if (step.append) {
335
+ const file = resolvePath(step.append);
336
+ if (!fs.existsSync(file)) return warn(`append: ${step.append} not found, skipped`);
337
+ fs.appendFileSync(file, interpolate(readSource(step), vars));
338
+ } else if (step.replace) {
339
+ const file = resolvePath(step.replace);
340
+ if (!fs.existsSync(file)) return warn(`replace: ${step.replace} not found, skipped`);
341
+ const content = fs.readFileSync(file, 'utf8');
342
+ if (step.skipIfContains && content.includes(step.skipIfContains)) return;
343
+ // `find` is a plain string (no escaping needed); `findRegex` for patterns.
344
+ // In `with`, "$&" inserts the matched text.
345
+ const pattern = step.findRegex ? new RegExp(step.findRegex, 'm') : step.find;
346
+ const found = step.findRegex ? pattern.test(content) : content.includes(pattern);
347
+ if (!found) return warn(`replace: pattern not found in ${step.replace} — check this manually`);
348
+ fs.writeFileSync(file, content.replace(pattern, interpolate(step.with, vars)));
349
+ } else if (step.insertBefore) {
350
+ const file = resolvePath(step.insertBefore);
351
+ if (!fs.existsSync(file)) return warn(`insertBefore: ${step.insertBefore} not found, skipped`);
352
+ const content = fs.readFileSync(file, 'utf8');
353
+ const idx = content.lastIndexOf(step.marker);
354
+ if (idx === -1) return warn(`insertBefore: marker '${step.marker}' not found in ${step.insertBefore}`);
355
+ fs.writeFileSync(file, content.slice(0, idx) + interpolate(readSource(step), vars) + content.slice(idx));
356
+ } else if (step.env) {
357
+ const values = Object.fromEntries(Object.entries(step.env).map(([k, v]) => [k, interpolate(v, vars)]));
358
+ for (const f of step.files || ['.env', '.env.example']) setEnvValues(resolvePath(f), values);
359
+ } else if (step.json) {
360
+ const file = resolvePath(step.json);
361
+ const data = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {};
362
+ const merge = JSON.parse(interpolate(JSON.stringify(step.merge), vars));
363
+ fs.writeFileSync(file, JSON.stringify(deepMerge(data, merge), null, 2) + '\n');
364
+ } else if (step.remove) {
365
+ const file = resolvePath(step.remove);
366
+ if (fs.existsSync(file)) fs.rmSync(file, { recursive: true, force: true });
367
+ } else {
368
+ throw new Error(`Unknown step in ${stack.id}/stack.json: ${JSON.stringify(step)}`);
369
+ }
370
+ }
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // Requirements
374
+ // ---------------------------------------------------------------------------
375
+
376
+ async function checkRequirements(stack, major) {
377
+ const missing = [];
378
+ for (const req of stack.requires || []) {
379
+ const { ok, out } = await captureCommand(req.cmd, req.args || ['--version']);
380
+ if (!ok) {
381
+ missing.push({ tool: req.tool || req.cmd, name: req.name || req.cmd, install: req.install, major });
382
+ continue;
383
+ }
384
+ // e.g. .NET: an SDK of at least the requested major must be installed.
385
+ if (req.minMajor && major !== undefined) {
386
+ const majors = [...out.matchAll(new RegExp(req.minMajor.pattern, 'gm'))].map(m => parseInt(m[1], 10));
387
+ if (!majors.some(m => m >= major)) {
388
+ missing.push({ tool: req.tool || req.cmd, name: `${req.name || req.cmd} ${major}+`, install: req.install, major });
389
+ }
390
+ }
391
+ }
392
+ return missing;
393
+ }
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // Public API
397
+ // ---------------------------------------------------------------------------
398
+
399
+ function parseMajor(value) {
400
+ if (value === undefined || value === null || value === '') return null;
401
+ if (typeof value === 'number') return value;
402
+ const m = /(\d+)/.exec(String(value)); // "11", "^11.0", "net8.0", "8.x"
403
+ return m ? parseInt(m[1], 10) : null;
404
+ }
405
+
406
+ async function describeStacks() {
407
+ const stacks = loadStacks();
408
+ return Promise.all(stacks.map(async stack => {
409
+ const [versions, missing] = await Promise.all([listVersions(stack), checkRequirements(stack)]);
410
+ return {
411
+ id: stack.id,
412
+ name: stack.name,
413
+ databases: stack.databases,
414
+ options: stack.options || [],
415
+ stylings: stack.stylings,
416
+ versions,
417
+ requirements: { ok: missing.length === 0, missing }
418
+ };
419
+ }));
420
+ }
421
+
422
+ async function scaffold(config, outputBasePath = null, onLog = () => {}) {
423
+ const backend = config.backend || config.stack;
424
+ const stack = getStack(backend);
425
+ if (!stack) throw new Error(`No stack manifest for '${backend}'`);
426
+
427
+ const name = (config.projectName || 'my-app').trim().toLowerCase().replace(/[^a-z0-9_-]/g, '-');
428
+ const parentDir = outputBasePath || path.join(__dirname, '..', '..', 'generated');
429
+ const projectDir = path.join(parentDir, name);
430
+
431
+ const logs = [];
432
+ const warnings = [];
433
+ const echo = !process.env.BOILERCRAFT_CLI; // the CLI prints via onLog itself
434
+ const log = msg => { logs.push(msg); onLog(msg); if (echo) console.log(`[Engine] ${msg}`); };
435
+ const warn = msg => { warnings.push(msg); log(`WARNING: ${msg}`); };
436
+
437
+ // Resolve the requested major (or the recommended one).
438
+ let major = parseMajor(config.versions?.[stack.id] ?? config.version);
439
+ const catalog = await listVersions(stack);
440
+ if (major === null) major = (catalog.find(v => v.recommended) || catalog[0]).major;
441
+ const entry = catalog.find(v => v.major === major);
442
+ if (major < stack.versions.minMajor) {
443
+ throw new Error(`${stack.name} ${major} is below the minimum supported version (${stack.versions.minMajor}).`);
444
+ }
445
+ if (!entry || !entry.tested) {
446
+ warn(`${stack.name} ${major} is newer than the last verified release (${stack.versions.testedUpTo}). ` +
447
+ `The official base will be correct; review the BoilerCraft overlays after generation.`);
448
+ }
449
+
450
+ const database = config.database || stack.databases[0];
451
+ const styling = config.styling || stack.stylings[0];
452
+ if (!stack.databases.includes(database)) {
453
+ throw new Error(`${stack.name} supports these databases: ${stack.databases.join(', ')} (got '${database}').`);
454
+ }
455
+
456
+ const missing = await checkRequirements(stack, major);
457
+ if (missing.length) throw new RequirementError(missing);
458
+
459
+ if (fs.existsSync(projectDir) && fs.readdirSync(projectDir).length) {
460
+ throw new Error(`Target folder already exists and is not empty: ${projectDir}`);
461
+ }
462
+ ensureDir(parentDir);
463
+
464
+ const ctx = {
465
+ stack,
466
+ name,
467
+ major,
468
+ database,
469
+ styling,
470
+ auth: config.auth ? config.auth.enabled !== false : true,
471
+ roles: !!config.auth?.roles,
472
+ options: resolveOptions(stack, config.options),
473
+ parentDir,
474
+ projectDir,
475
+ log,
476
+ warn
477
+ };
478
+ ctx.vars = buildVars(config, stack, ctx);
479
+
480
+ log(`Scaffolding ${stack.name} ${major} (${database}, ${styling}) → ${projectDir}`);
481
+ for (const step of stack.steps) {
482
+ if (!matches(step.when, ctx)) continue;
483
+ if (step.label) log(interpolate(step.label, ctx.vars));
484
+ await executeStep(step, ctx);
485
+ }
486
+
487
+ if (stack.readme !== false) {
488
+ fs.writeFileSync(
489
+ path.join(projectDir, 'BOILERCRAFT.md'),
490
+ generateDynamicStackReadme({ ...config, projectName: name }, stack.id, database, styling, ctx.vars.THEME, config.colorMode || 'all')
491
+ );
492
+ }
493
+
494
+ log('Done.');
495
+ return {
496
+ success: true,
497
+ engine: 'official',
498
+ projectName: name,
499
+ path: projectDir,
500
+ version: major,
501
+ combination: [`${stack.id}@${major}`, ...Object.values(ctx.options), database, styling].join(' + '),
502
+ nextSteps: (stack.nextSteps || []).map(s => interpolate(s, ctx.vars)),
503
+ warnings,
504
+ logs
505
+ };
506
+ }
507
+
508
+ module.exports = {
509
+ scaffold,
510
+ describeStacks,
511
+ checkRequirements,
512
+ getStack,
513
+ loadStacks,
514
+ parseMajor,
515
+ versionMatches,
516
+ RequirementError
517
+ };