boilercraft 1.0.0 → 1.1.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 (41) hide show
  1. package/README.md +70 -5
  2. package/bin/boilercraft.js +34 -4
  3. package/cli/app.js +144 -27
  4. package/cli/ui.js +67 -1
  5. package/package.json +17 -5
  6. package/public/index.html +25 -1
  7. package/server.js +3 -1
  8. package/services/engine/extras.js +354 -0
  9. package/services/engine/postCreate.js +30 -0
  10. package/services/engine/scaffoldEngine.js +34 -8
  11. package/services/engine/versionCatalog.js +16 -0
  12. package/services/generate.js +2 -2
  13. package/services/presets.js +87 -0
  14. package/services/updateNotifier.js +50 -0
  15. package/stacks/django/overlays/common/templates/home.html +20 -0
  16. package/stacks/django/snippets/settings.py +27 -0
  17. package/stacks/django/stack.json +42 -0
  18. package/stacks/dotnet-core/stack.json +6 -0
  19. package/stacks/fastapi/overlays/common/app/__init__.py +0 -0
  20. package/stacks/fastapi/overlays/common/app/main.py +22 -0
  21. package/stacks/fastapi/overlays/common/app/static/index.html +20 -0
  22. package/stacks/fastapi/overlays/db/app/db.py +30 -0
  23. package/stacks/fastapi/stack.json +40 -0
  24. package/stacks/go-gin/overlays/common/main.go +32 -0
  25. package/stacks/go-gin/overlays/common/public/index.html +20 -0
  26. package/stacks/go-gin/stack.json +28 -0
  27. package/stacks/laravel/stack.json +12 -0
  28. package/stacks/nestjs/stack.json +46 -0
  29. package/stacks/nextjs/stack.json +12 -0
  30. package/stacks/node-express/stack.json +11 -0
  31. package/stacks/raw-php/stack.json +25 -0
  32. package/stacks/vite/stack.json +61 -0
  33. package/templates/raw-php-mysql/config/Database.php +1 -0
  34. package/docs/STACK_ENGINE.md +0 -93
  35. package/docs/assets/cli-create.svg +0 -52
  36. package/docs/assets/cli-menu.svg +0 -47
  37. package/docs/stacks/dotnet-core.md +0 -69
  38. package/docs/stacks/laravel-vue.md +0 -46
  39. package/docs/stacks/nextjs.md +0 -58
  40. package/docs/stacks/node-express.md +0 -58
  41. package/docs/stacks/raw-php.md +0 -130
package/package.json CHANGED
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "boilercraft",
3
- "version": "1.0.0",
4
- "description": "Create Laravel, Next.js, Express and .NET projects with the official tooling — any version, auth, themes. Run: npx boilercraft",
3
+ "version": "1.1.0",
4
+ "description": "Create Laravel, Next.js, NestJS, Vite, Django, FastAPI, Go, Express and .NET projects with the official tooling — any version, auth, themes, Docker, CI. Run: npx boilercraft",
5
5
  "main": "main.js",
6
6
  "scripts": {
7
7
  "start": "node server.js",
8
8
  "desktop": "electron .",
9
9
  "dev": "node server.js",
10
10
  "build:exe": "electron-builder --win portable",
11
- "cli": "node bin/boilercraft.js"
11
+ "cli": "node bin/boilercraft.js",
12
+ "test": "node --test test/engine.test.js test/extras.test.js test/cli-services.test.js"
12
13
  },
13
14
  "build": {
14
15
  "appId": "com.argrabbi.boilercraft",
@@ -41,7 +42,19 @@
41
42
  "cli",
42
43
  "scaffold",
43
44
  "create-app",
44
- "npx"
45
+ "npx",
46
+ "nestjs",
47
+ "vite",
48
+ "react",
49
+ "vue",
50
+ "svelte",
51
+ "django",
52
+ "fastapi",
53
+ "python",
54
+ "go",
55
+ "gin",
56
+ "docker",
57
+ "prisma"
45
58
  ],
46
59
  "author": "ARG RABBI",
47
60
  "license": "MIT",
@@ -73,7 +86,6 @@
73
86
  "stacks/",
74
87
  "templates/",
75
88
  "public/",
76
- "docs/",
77
89
  "server.js",
78
90
  "README.md",
79
91
  "LICENSE"
package/public/index.html CHANGED
@@ -530,6 +530,7 @@
530
530
  const { stacks } = await res.json();
531
531
  for (const stack of stacks) {
532
532
  stackCatalog[stack.id] = stack;
533
+ addBackendCard(stack);
533
534
  const select = document.getElementById(VERSION_SELECTS[stack.id]);
534
535
  if (!select) continue;
535
536
  select.innerHTML = '';
@@ -552,6 +553,26 @@
552
553
  updateSummary();
553
554
  }
554
555
 
556
+ // Stacks without a hand-made card above (NestJS, Vite, Django, FastAPI, Go …)
557
+ // get one from their manifest, so a new stack shows up here with no HTML change.
558
+ function addBackendCard(stack) {
559
+ const grid = document.getElementById('backendOptions');
560
+ if (grid.querySelector(`[data-stack="${stack.id}"]`)) return;
561
+ if ([...grid.querySelectorAll('.choice-card')].some(c => (c.getAttribute('onclick') || '').includes(`'${stack.id}'`))) return;
562
+ const card = document.createElement('div');
563
+ card.className = 'choice-card';
564
+ card.dataset.stack = stack.id;
565
+ card.onclick = () => selectBackend(stack.id, card);
566
+ const title = document.createElement('div');
567
+ title.className = 'choice-title';
568
+ title.textContent = stack.name;
569
+ const desc = document.createElement('div');
570
+ desc.className = 'choice-desc';
571
+ desc.textContent = stack.description || '';
572
+ card.append(title, desc);
573
+ grid.appendChild(card);
574
+ }
575
+
555
576
  function renderEngineNote() {
556
577
  const note = document.getElementById('engineNote');
557
578
  const stack = stackCatalog[state.backend];
@@ -645,7 +666,10 @@
645
666
  port: parseInt(document.getElementById('serverPort').value),
646
667
  backend: state.backend,
647
668
  stack: state.backend,
648
- database: state.database,
669
+ // Stacks like Vite or Go have their own database list (e.g. only 'none').
670
+ database: stackCatalog[state.backend] && !stackCatalog[state.backend].databases.includes(state.database)
671
+ ? stackCatalog[state.backend].databases[0]
672
+ : state.database,
649
673
  styling: state.styling,
650
674
  // Keyed by stack id; values are major versions ("12", "16", …).
651
675
  versions: {
package/server.js CHANGED
@@ -1,7 +1,9 @@
1
1
  const express = require('express');
2
2
  const cors = require('cors');
3
3
  const path = require('path');
4
- const { generateProject } = require('./services/projectGenerator');
4
+ // Every stack now goes through services/generate.js (manifest engine, with the
5
+ // template generator only as an offline fallback), so this direct import is unused.
6
+ // const { generateProject } = require('./services/projectGenerator');
5
7
  const { getLatestNpmVersion, getLatestPackagistVersion } = require('./services/versionResolver');
6
8
  const { describeStacks, RequirementError } = require('./services/engine/scaffoldEngine');
7
9
  // generate() (engine + legacy fallback) lives in services/generate.js, shared with the CLI.
@@ -0,0 +1,354 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ /**
5
+ * Optional add-ons that work across stacks: Docker, GitHub Actions CI,
6
+ * ESLint + Prettier and Prisma. A manifest opts in with
7
+ *
8
+ * "runtime": "node" | "php" | "dotnet" | "python" | "go",
9
+ * "extras": ["docker", "ci", "lint", "prisma"],
10
+ * "docker": { "build": "npm run build", "start": "npm start" },
11
+ * "ci": { "test": "php artisan test" }
12
+ *
13
+ * Files are generated from the runtime, so a new stack gets all of this by
14
+ * declaring three fields instead of copying templates around.
15
+ */
16
+
17
+ const EXTRAS = {
18
+ docker: { label: 'Docker', hint: 'Dockerfile + docker-compose with your database' },
19
+ ci: { label: 'GitHub Actions CI', hint: 'build + test on every push' },
20
+ lint: { label: 'ESLint + Prettier', hint: 'lint and format scripts' },
21
+ prisma: { label: 'Prisma ORM', hint: 'schema, client and migrations' }
22
+ };
23
+
24
+ function supportedExtras(stack) {
25
+ return (stack.extras || []).filter(x => EXTRAS[x]);
26
+ }
27
+
28
+ const write = (dir, rel, content) => {
29
+ const file = path.join(dir, rel);
30
+ fs.mkdirSync(path.dirname(file), { recursive: true });
31
+ fs.writeFileSync(file, content.replace(/^\n/, ''));
32
+ };
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Docker
36
+ // ---------------------------------------------------------------------------
37
+
38
+ function dockerfile(stack, ctx) {
39
+ const port = ctx.vars.PORT;
40
+ const d = stack.docker || {};
41
+ const cmd = JSON.stringify((d.start || '').split(' ').filter(Boolean));
42
+ switch (stack.runtime) {
43
+ case 'node':
44
+ return `
45
+ FROM node:22-alpine
46
+ WORKDIR /app
47
+ COPY package*.json ./
48
+ RUN npm ci
49
+ COPY . .
50
+ ${d.build ? `RUN ${d.build}\n` : ''}ENV PORT=${port}
51
+ EXPOSE ${port}
52
+ CMD ${cmd}
53
+ `;
54
+ case 'php':
55
+ return `
56
+ FROM php:8.3-cli
57
+ RUN apt-get update && apt-get install -y git unzip libzip-dev \\
58
+ && docker-php-ext-install pdo pdo_mysql zip && rm -rf /var/lib/apt/lists/*
59
+ COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
60
+ WORKDIR /app
61
+ COPY . .
62
+ ${fs.existsSync(path.join(ctx.projectDir, 'composer.json')) ? 'RUN composer install --no-dev --optimize-autoloader --no-interaction\n' : ''}EXPOSE ${port}
63
+ CMD ${cmd}
64
+ `;
65
+ case 'dotnet':
66
+ return `
67
+ FROM mcr.microsoft.com/dotnet/sdk:${ctx.major}.0 AS build
68
+ WORKDIR /src
69
+ COPY . .
70
+ RUN dotnet publish -c Release -o /out
71
+
72
+ FROM mcr.microsoft.com/dotnet/aspnet:${ctx.major}.0
73
+ WORKDIR /app
74
+ COPY --from=build /out .
75
+ ENV ASPNETCORE_URLS=http://+:${port}
76
+ EXPOSE ${port}
77
+ ENTRYPOINT ["dotnet", "${ctx.name}.dll"]
78
+ `;
79
+ case 'python':
80
+ return `
81
+ FROM python:3.12-slim
82
+ WORKDIR /app
83
+ COPY requirements.txt .
84
+ RUN pip install --no-cache-dir -r requirements.txt
85
+ COPY . .
86
+ EXPOSE ${port}
87
+ CMD ${cmd}
88
+ `;
89
+ case 'go':
90
+ return `
91
+ FROM golang:1-alpine AS build
92
+ WORKDIR /src
93
+ COPY go.* ./
94
+ RUN go mod download
95
+ COPY . .
96
+ RUN CGO_ENABLED=0 go build -o /app .
97
+
98
+ FROM alpine:3
99
+ COPY --from=build /app /app
100
+ COPY --from=build /src/public /public
101
+ WORKDIR /
102
+ ENV PORT=${port}
103
+ EXPOSE ${port}
104
+ ENTRYPOINT ["/app"]
105
+ `;
106
+ default:
107
+ return null;
108
+ }
109
+ }
110
+
111
+ const DB_SERVICES = {
112
+ mysql: name => `
113
+ db:
114
+ image: mysql:8
115
+ environment:
116
+ MYSQL_DATABASE: ${name}
117
+ MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
118
+ ports: ["3306:3306"]
119
+ volumes: [db-data:/var/lib/mysql]
120
+ `,
121
+ postgresql: name => `
122
+ db:
123
+ image: postgres:16
124
+ environment:
125
+ POSTGRES_DB: ${name}
126
+ POSTGRES_PASSWORD: postgres
127
+ ports: ["5432:5432"]
128
+ volumes: [db-data:/var/lib/postgresql/data]
129
+ `,
130
+ mongodb: () => `
131
+ db:
132
+ image: mongo:7
133
+ ports: ["27017:27017"]
134
+ volumes: [db-data:/data/db]
135
+ `,
136
+ mssql: () => `
137
+ db:
138
+ image: mcr.microsoft.com/mssql/server:2022-latest
139
+ environment:
140
+ ACCEPT_EULA: "Y"
141
+ MSSQL_SA_PASSWORD: \${DB_PASSWORD:-Change_me_123}
142
+ ports: ["1433:1433"]
143
+ volumes: [db-data:/var/opt/mssql]
144
+ `
145
+ };
146
+
147
+ function applyDocker(stack, ctx) {
148
+ const file = dockerfile(stack, ctx);
149
+ if (!file) return ctx.warn(`Docker: no Dockerfile recipe for runtime '${stack.runtime}'`);
150
+ const { projectDir, vars, database } = ctx;
151
+ write(projectDir, 'Dockerfile', file);
152
+ write(projectDir, '.dockerignore', `
153
+ node_modules
154
+ vendor
155
+ bin
156
+ obj
157
+ .venv
158
+ __pycache__
159
+ .git
160
+ .env
161
+ `);
162
+ const db = DB_SERVICES[database];
163
+ write(projectDir, 'docker-compose.yml', `
164
+ services:
165
+ app:
166
+ build: .
167
+ ports: ["${vars.PORT}:${vars.PORT}"]
168
+ env_file:
169
+ - path: .env
170
+ required: false
171
+ ${db ? ' environment:\n DB_HOST: db\n depends_on: [db]\n' : ''}${db ? db(vars.DB_NAME) : ''}${db ? '\nvolumes:\n db-data:\n' : ''}`);
172
+ ctx.log('Added Dockerfile and docker-compose.yml');
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // GitHub Actions
177
+ // ---------------------------------------------------------------------------
178
+
179
+ function ciSteps(stack, ctx) {
180
+ const test = stack.ci?.test;
181
+ switch (stack.runtime) {
182
+ case 'node':
183
+ return `
184
+ - uses: actions/setup-node@v4
185
+ with:
186
+ node-version: 22
187
+ cache: npm
188
+ - run: npm ci
189
+ - run: npm run lint --if-present
190
+ - run: npm run build --if-present
191
+ - run: ${test || 'npm test --if-present'}`;
192
+ case 'php':
193
+ return `
194
+ - uses: shivammathur/setup-php@v2
195
+ with:
196
+ php-version: '8.3'
197
+ ${fs.existsSync(path.join(ctx.projectDir, 'composer.json')) ? ' - run: composer install --no-interaction --prefer-dist\n' : ''} - run: ${test || 'find . -name "*.php" -not -path "./vendor/*" -print0 | xargs -0 -n1 php -l'}`;
198
+ case 'dotnet':
199
+ return `
200
+ - uses: actions/setup-dotnet@v4
201
+ with:
202
+ dotnet-version: '${ctx.major}.0.x'
203
+ - run: dotnet build
204
+ - run: ${test || 'dotnet test --no-build'}`;
205
+ case 'python':
206
+ return `
207
+ - uses: actions/setup-python@v5
208
+ with:
209
+ python-version: '3.12'
210
+ - run: pip install -r requirements.txt
211
+ - run: ${test || 'python -m compileall -q .'}`;
212
+ case 'go':
213
+ return `
214
+ - uses: actions/setup-go@v5
215
+ with:
216
+ go-version: stable
217
+ - run: go vet ./...
218
+ - run: go build ./...
219
+ - run: ${test || 'go test ./...'}`;
220
+ default:
221
+ return null;
222
+ }
223
+ }
224
+
225
+ function applyCi(stack, ctx) {
226
+ const steps = ciSteps(stack, ctx);
227
+ if (!steps) return ctx.warn(`CI: no workflow recipe for runtime '${stack.runtime}'`);
228
+ write(ctx.projectDir, '.github/workflows/ci.yml', `
229
+ name: CI
230
+
231
+ on:
232
+ push:
233
+ pull_request:
234
+
235
+ jobs:
236
+ build:
237
+ runs-on: ubuntu-latest
238
+ steps:
239
+ - uses: actions/checkout@v4${steps}
240
+ `);
241
+ ctx.log('Added GitHub Actions workflow');
242
+ }
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // ESLint + Prettier (Node stacks)
246
+ // ---------------------------------------------------------------------------
247
+
248
+ function readPkg(dir) {
249
+ const file = path.join(dir, 'package.json');
250
+ return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : null;
251
+ }
252
+
253
+ function mergeScripts(dir, scripts) {
254
+ const pkg = readPkg(dir);
255
+ if (!pkg) return;
256
+ pkg.scripts = { ...scripts, ...pkg.scripts }; // never clobber the framework's own scripts
257
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n');
258
+ }
259
+
260
+ async function applyLint(stack, ctx, { runCommand }) {
261
+ const { projectDir } = ctx;
262
+ const pkg = readPkg(projectDir);
263
+ if (!pkg) return ctx.warn('ESLint + Prettier: no package.json, skipped');
264
+ const hasEslint = !!(pkg.devDependencies?.eslint || pkg.dependencies?.eslint);
265
+
266
+ ctx.log('Adding ESLint + Prettier');
267
+ const deps = ['prettier', 'eslint-config-prettier'];
268
+ if (!hasEslint) deps.push('eslint', '@eslint/js', 'globals');
269
+ await runCommand('npm', ['install', '-D', ...deps], projectDir, ctx.log);
270
+
271
+ if (!hasEslint && pkg.type === 'module') {
272
+ write(projectDir, 'eslint.config.js', `
273
+ import js from '@eslint/js';
274
+ import globals from 'globals';
275
+ import prettier from 'eslint-config-prettier';
276
+
277
+ export default [
278
+ { ignores: ['node_modules/', 'dist/'] },
279
+ js.configs.recommended,
280
+ { languageOptions: { ecmaVersion: 'latest', sourceType: 'module', globals: { ...globals.browser, ...globals.node } } },
281
+ prettier
282
+ ];
283
+ `);
284
+ } else if (!hasEslint) {
285
+ write(projectDir, 'eslint.config.js', `
286
+ const js = require('@eslint/js');
287
+ const globals = require('globals');
288
+ const prettier = require('eslint-config-prettier');
289
+
290
+ module.exports = [
291
+ { ignores: ['node_modules/', 'dist/', 'public/'] },
292
+ js.configs.recommended,
293
+ { languageOptions: { ecmaVersion: 'latest', sourceType: 'commonjs', globals: globals.node } },
294
+ prettier
295
+ ];
296
+ `);
297
+ }
298
+ write(projectDir, '.prettierrc', '{\n "singleQuote": true,\n "semi": true,\n "printWidth": 100\n}\n');
299
+ write(projectDir, '.prettierignore', 'node_modules\ndist\n.next\nbuild\n');
300
+ mergeScripts(projectDir, { lint: 'eslint .', format: 'prettier --write .' });
301
+ }
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // Prisma (Node stacks)
305
+ // ---------------------------------------------------------------------------
306
+
307
+ const PRISMA_PROVIDERS = { mysql: 'mysql', postgresql: 'postgresql', mssql: 'sqlserver', mongodb: 'mongodb', sqlite: 'sqlite' };
308
+
309
+ function prismaUrl(database, db) {
310
+ return {
311
+ mysql: `mysql://root:@127.0.0.1:3306/${db}`,
312
+ postgresql: `postgresql://postgres:postgres@127.0.0.1:5432/${db}`,
313
+ mssql: `sqlserver://127.0.0.1:1433;database=${db};user=sa;password=change-me;trustServerCertificate=true`,
314
+ mongodb: `mongodb://127.0.0.1:27017/${db}`,
315
+ sqlite: 'file:./dev.db'
316
+ }[database];
317
+ }
318
+
319
+ async function applyPrisma(stack, ctx, { runCommand }) {
320
+ const provider = PRISMA_PROVIDERS[ctx.database];
321
+ if (!provider) return ctx.warn(`Prisma: database '${ctx.database}' is not supported, skipped`);
322
+ const { projectDir } = ctx;
323
+ ctx.log('Adding Prisma ORM');
324
+ await runCommand('npm', ['install', '-D', 'prisma@6'], projectDir, ctx.log);
325
+ await runCommand('npm', ['install', '@prisma/client@6'], projectDir, ctx.log);
326
+ await runCommand('npx', ['prisma', 'init', '--datasource-provider', provider], projectDir, ctx.log);
327
+
328
+ const url = `DATABASE_URL="${prismaUrl(ctx.database, ctx.vars.DB_NAME)}"`;
329
+ for (const f of ['.env', '.env.local']) {
330
+ const file = path.join(projectDir, f);
331
+ if (!fs.existsSync(file)) continue;
332
+ const content = fs.readFileSync(file, 'utf8');
333
+ fs.writeFileSync(file, /^DATABASE_URL=.*$/m.test(content) ? content.replace(/^DATABASE_URL=.*$/m, url) : content.replace(/\s*$/, `\n${url}\n`));
334
+ }
335
+ mergeScripts(projectDir, { 'db:migrate': 'prisma migrate dev', 'db:studio': 'prisma studio' });
336
+ }
337
+
338
+ // ---------------------------------------------------------------------------
339
+
340
+ async function applyExtras(ctx, helpers) {
341
+ const { stack } = ctx;
342
+ for (const extra of ctx.extras || []) {
343
+ try {
344
+ if (extra === 'docker') applyDocker(stack, ctx);
345
+ else if (extra === 'ci') applyCi(stack, ctx);
346
+ else if (extra === 'lint') await applyLint(stack, ctx, helpers);
347
+ else if (extra === 'prisma') await applyPrisma(stack, ctx, helpers);
348
+ } catch (err) {
349
+ ctx.warn(`${EXTRAS[extra].label} failed: ${err.message.split('\n')[0]}`);
350
+ }
351
+ }
352
+ }
353
+
354
+ module.exports = { EXTRAS, supportedExtras, applyExtras, dockerfile };
@@ -0,0 +1,30 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ /**
5
+ * Leaves the project as a git repository with one clean commit. Some official
6
+ * creators (create-next-app, nest new) already run `git init` and commit their
7
+ * own files; then only BoilerCraft's additions are committed on top.
8
+ * Never fails the scaffold — a missing git or identity just becomes a warning.
9
+ */
10
+ async function initGit(ctx, capture) {
11
+ const { projectDir, log, warn } = ctx;
12
+ const git = (...args) => capture('git', args, projectDir);
13
+
14
+ if (!(await git('--version')).ok) return warn('git not found — skipped repository setup');
15
+
16
+ const fresh = !fs.existsSync(path.join(projectDir, '.git'));
17
+ if (fresh) {
18
+ const init = await git('init', '-b', 'main');
19
+ if (!init.ok && !(await git('init')).ok) return warn('git init failed — skipped repository setup');
20
+ }
21
+ await git('add', '-A');
22
+ if (!(await git('status', '--porcelain')).out.trim()) return log('Git repository ready');
23
+
24
+ const message = fresh ? 'Initial commit from BoilerCraft' : 'Add BoilerCraft auth, database and theme';
25
+ const commit = await git('commit', '-q', '-m', message);
26
+ if (commit.ok) return log(fresh ? 'Initialized git repository' : 'Committed BoilerCraft files');
27
+ warn('git commit failed (set user.name / user.email), files are staged — commit them yourself');
28
+ }
29
+
30
+ module.exports = { initGit };
@@ -4,6 +4,8 @@ const crypto = require('crypto');
4
4
  const { spawn } = require('child_process');
5
5
  const { listVersions, majorOf } = require('./versionCatalog');
6
6
  const { toolEnv } = require('./toolInstaller');
7
+ const { applyExtras, supportedExtras } = require('./extras');
8
+ const { initGit } = require('./postCreate');
7
9
  const { generateThemeCSS, getThemeSwitcherJS } = require('../themeGenerator');
8
10
  const { generateDynamicStackReadme } = require('../readmeGenerator');
9
11
  const {
@@ -31,7 +33,8 @@ const {
31
33
  * needs one more overlay folder + a `when: { version: ">=N" }` step.
32
34
  */
33
35
 
34
- const STACKS_DIR = path.join(__dirname, '..', '..', 'stacks');
36
+ // BOILERCRAFT_STACKS_DIR lets tests point the engine at fixture stacks.
37
+ const STACKS_DIR = process.env.BOILERCRAFT_STACKS_DIR || path.join(__dirname, '..', '..', 'stacks');
35
38
 
36
39
  class RequirementError extends Error {
37
40
  constructor(missing) {
@@ -52,7 +55,8 @@ function loadStacks() {
52
55
  .map(dir => {
53
56
  const manifest = JSON.parse(fs.readFileSync(path.join(STACKS_DIR, dir, 'stack.json'), 'utf8'));
54
57
  return { ...manifest, id: manifest.id || dir, dir: path.join(STACKS_DIR, dir) };
55
- });
58
+ })
59
+ .sort((a, b) => (a.order ?? 99) - (b.order ?? 99));
56
60
  }
57
61
 
58
62
  function getStack(id) {
@@ -96,6 +100,7 @@ function matches(when, ctx) {
96
100
  if (when.auth !== undefined && when.auth !== ctx.auth) return false;
97
101
  if (when.roles !== undefined && when.roles !== ctx.roles) return false;
98
102
  if (when.platform !== undefined && !oneOf(when.platform, process.platform)) return false;
103
+ if (when.extra !== undefined && !(ctx.extras || []).includes(when.extra)) return false;
99
104
  // Any other key refers to a stack-specific option (e.g. Next.js "router").
100
105
  for (const [key, expected] of Object.entries(when)) {
101
106
  if (BUILTIN_WHEN_KEYS.has(key)) continue;
@@ -104,7 +109,7 @@ function matches(when, ctx) {
104
109
  return true;
105
110
  }
106
111
 
107
- const BUILTIN_WHEN_KEYS = new Set(['version', 'database', 'styling', 'auth', 'roles', 'platform']);
112
+ const BUILTIN_WHEN_KEYS = new Set(['version', 'database', 'styling', 'auth', 'roles', 'platform', 'extra']);
108
113
 
109
114
  // Stack-specific options (manifest "options"), validated, with defaults filled in.
110
115
  function resolveOptions(stack, given = {}) {
@@ -190,7 +195,9 @@ function buildVars(config, stack, ctx) {
190
195
  const dbEnv = password => ({
191
196
  mongodb: `MONGODB_URI=mongodb://127.0.0.1:27017/${vars.DB_NAME}`,
192
197
  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}`
198
+ mssql: `DB_HOST=127.0.0.1\nDB_PORT=1433\nDB_DATABASE=${vars.DB_NAME}\nDB_USERNAME=sa\nDB_PASSWORD=${password}`,
199
+ postgresql: `DB_HOST=127.0.0.1\nDB_PORT=5432\nDB_DATABASE=${vars.DB_NAME}\nDB_USERNAME=postgres\nDB_PASSWORD=${password}`,
200
+ sqlite: 'DB_PATH=database.sqlite'
194
201
  }[ctx.database] || '');
195
202
  // Stack options are available as {{OPTION_<ID>}}, e.g. {{OPTION_FRONTEND}}.
196
203
  for (const [id, value] of Object.entries(ctx.options || {})) vars[`OPTION_${id.toUpperCase()}`] = String(value);
@@ -253,7 +260,8 @@ function runCommand(cmd, args, cwd, log, extraEnv = {}) {
253
260
  const child = spawn(line, {
254
261
  cwd,
255
262
  shell: true,
256
- env: { ...toolEnv(), CI: '1', COMPOSER_NO_INTERACTION: '1', DOTNET_CLI_TELEMETRY_OPTOUT: '1', DOTNET_NOLOGO: '1', ...extraEnv }
263
+ stdio: ['ignore', 'pipe', 'pipe'], // no stdin: a tool that prompts gets EOF instead of hanging
264
+ env: { ...toolEnv(), CI: '1', COMPOSER_NO_INTERACTION: '1', DOTNET_CLI_TELEMETRY_OPTOUT: '1', DOTNET_NOLOGO: '1', CHECKPOINT_DISABLE: '1', ...extraEnv }
257
265
  });
258
266
  let tail = '';
259
267
  const onData = chunk => {
@@ -272,8 +280,12 @@ function runCommand(cmd, args, cwd, log, extraEnv = {}) {
272
280
  }
273
281
 
274
282
  function captureCommand(cmd, args) {
283
+ return captureIn(cmd, args);
284
+ }
285
+
286
+ function captureIn(cmd, args, cwd) {
275
287
  return new Promise(resolve => {
276
- const child = spawn([cmd, ...args].map(quoteArg).join(' '), { shell: true, env: toolEnv() });
288
+ const child = spawn([cmd, ...args].map(quoteArg).join(' '), { shell: true, cwd, env: toolEnv() });
277
289
  let out = '';
278
290
  child.stdout.on('data', c => (out += c));
279
291
  child.stderr.on('data', c => (out += c));
@@ -325,6 +337,7 @@ async function executeStep(step, ctx) {
325
337
  copyDir(path.join(stack.dir, step.copy), step.to ? resolvePath(step.to) : projectDir, vars, log);
326
338
  } else if (step.write) {
327
339
  const file = resolvePath(step.write);
340
+ if (step.ifMissing && fs.existsSync(file)) return;
328
341
  ensureDir(path.dirname(file));
329
342
  fs.writeFileSync(file, interpolate(readSource(step), vars));
330
343
  } else if (step.prepend) {
@@ -334,6 +347,7 @@ async function executeStep(step, ctx) {
334
347
  } else if (step.append) {
335
348
  const file = resolvePath(step.append);
336
349
  if (!fs.existsSync(file)) return warn(`append: ${step.append} not found, skipped`);
350
+ if (step.skipIfContains && fs.readFileSync(file, 'utf8').includes(step.skipIfContains)) return;
337
351
  fs.appendFileSync(file, interpolate(readSource(step), vars));
338
352
  } else if (step.replace) {
339
353
  const file = resolvePath(step.replace);
@@ -376,6 +390,7 @@ async function executeStep(step, ctx) {
376
390
  async function checkRequirements(stack, major) {
377
391
  const missing = [];
378
392
  for (const req of stack.requires || []) {
393
+ if (req.platform && !oneOf(req.platform, process.platform)) continue;
379
394
  const { ok, out } = await captureCommand(req.cmd, req.args || ['--version']);
380
395
  if (!ok) {
381
396
  missing.push({ tool: req.tool || req.cmd, name: req.name || req.cmd, install: req.install, major });
@@ -413,6 +428,10 @@ async function describeStacks() {
413
428
  databases: stack.databases,
414
429
  options: stack.options || [],
415
430
  stylings: stack.stylings,
431
+ extras: supportedExtras(stack),
432
+ auth: stack.auth !== false,
433
+ description: stack.description || '',
434
+ nextSteps: stack.nextSteps || [],
416
435
  versions,
417
436
  requirements: { ok: missing.length === 0, missing }
418
437
  };
@@ -467,9 +486,10 @@ async function scaffold(config, outputBasePath = null, onLog = () => {}) {
467
486
  major,
468
487
  database,
469
488
  styling,
470
- auth: config.auth ? config.auth.enabled !== false : true,
489
+ auth: stack.auth === false ? false : config.auth ? config.auth.enabled !== false : true,
471
490
  roles: !!config.auth?.roles,
472
491
  options: resolveOptions(stack, config.options),
492
+ extras: (config.extras || []).filter(x => supportedExtras(stack).includes(x)),
473
493
  parentDir,
474
494
  projectDir,
475
495
  log,
@@ -491,6 +511,9 @@ async function scaffold(config, outputBasePath = null, onLog = () => {}) {
491
511
  );
492
512
  }
493
513
 
514
+ await applyExtras(ctx, { runCommand, interpolate });
515
+ if (config.git !== false) await initGit(ctx, captureIn);
516
+
494
517
  log('Done.');
495
518
  return {
496
519
  success: true,
@@ -498,7 +521,7 @@ async function scaffold(config, outputBasePath = null, onLog = () => {}) {
498
521
  projectName: name,
499
522
  path: projectDir,
500
523
  version: major,
501
- combination: [`${stack.id}@${major}`, ...Object.values(ctx.options), database, styling].join(' + '),
524
+ combination: [`${stack.id}@${major}`, ...Object.values(ctx.options), database, styling, ...ctx.extras].join(' + '),
502
525
  nextSteps: (stack.nextSteps || []).map(s => interpolate(s, ctx.vars)),
503
526
  warnings,
504
527
  logs
@@ -513,5 +536,8 @@ module.exports = {
513
536
  loadStacks,
514
537
  parseMajor,
515
538
  versionMatches,
539
+ matches,
540
+ interpolate,
541
+ resolveOptions,
516
542
  RequirementError
517
543
  };
@@ -55,6 +55,22 @@ const sources = {
55
55
  return newestPerMajor(releases.map(r => r.version), minMajor);
56
56
  },
57
57
 
58
+ async pypi({ package: pkg, minMajor = 0 }) {
59
+ const res = await axios.get(`https://pypi.org/pypi/${encodeURIComponent(pkg)}/json`, { timeout: 5000 });
60
+ return newestPerMajor(Object.keys(res.data.releases || {}), minMajor);
61
+ },
62
+
63
+ // Go modules: proxy.golang.org lists every tagged version, one per line.
64
+ async goproxy({ package: mod, minMajor = 0 }) {
65
+ const res = await axios.get(`https://proxy.golang.org/${mod.toLowerCase()}/@v/list`, { timeout: 5000, responseType: 'text' });
66
+ return newestPerMajor(String(res.data).split('\n').filter(Boolean), minMajor);
67
+ },
68
+
69
+ // Stacks with no upstream framework release (e.g. raw PHP) pin their list.
70
+ async static({ offline = [] }) {
71
+ return offline.map(major => ({ major, latest: `${major}.x` }));
72
+ },
73
+
58
74
  async dotnet({ minMajor = 0 }) {
59
75
  const res = await axios.get(
60
76
  'https://dotnetcli.blob.core.windows.net/dotnet/release-metadata/releases-index.json',
@@ -21,8 +21,8 @@ async function runLegacy(config, warning) {
21
21
  }
22
22
 
23
23
  /**
24
- * Stacks with a manifest in stacks/ are scaffolded by the official-CLI engine.
25
- * Everything else (e.g. raw-php) still uses the template generator. If the
24
+ * Every stack has a manifest in stacks/ and is scaffolded by the engine;
25
+ * the template generator is only an offline fallback. If the
26
26
  * required toolchain is missing, fall back to templates unless the caller
27
27
  * opted out with allowLegacyFallback: false.
28
28
  */