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/README.md CHANGED
@@ -27,8 +27,8 @@ npx boilercraft
27
27
 
28
28
  Most boilerplates are a frozen copy of someone's project. They go stale the day a new framework version ships. BoilerCraft works differently:
29
29
 
30
- - **Always the real thing.** The base project is created by the framework's own creator (`composer create-project`, `create-next-app`, `dotnet new`, `npm`), so it matches the version you pick exactly.
31
- - **Any version, including future ones.** Versions are fetched live from npm, Packagist and the .NET release index. A new release shows up in the menu without an update to BoilerCraft.
30
+ - **Always the real thing.** The base project is created by the framework's own creator (`composer create-project`, `create-next-app`, `nest new`, `create-vite`, `django-admin`, `dotnet new`, `go mod init`), so it matches the version you pick exactly.
31
+ - **Any version, including future ones.** Versions are fetched live from npm, Packagist, PyPI, the Go module proxy and the .NET release index. A new release shows up in the menu without an update to BoilerCraft.
32
32
  - **Ready to build on.** Auth, database wiring, a themed landing page and UI components are added on top, so you start with a working app instead of a blank one.
33
33
  - **One command, zero setup.** Everything happens inside `npx boilercraft`. Missing PHP, Composer or the .NET SDK? It offers to install them for you.
34
34
 
@@ -53,8 +53,13 @@ When it's done, pick **Start the dev server** or **Open in VS Code** from the sa
53
53
  | **Laravel** | 10 – 13 | Blade · Vue 3 (Inertia) · React (Inertia) | MySQL · SQL Server | Sanctum API tokens, or full Breeze UI (login, register, profile) for Vue/React |
54
54
  | **Next.js** | 14 – 16 | App Router · Pages Router | MongoDB · MySQL · SQL Server | JWT register/login API routes |
55
55
  | **.NET** | 8 – 10 | MVC (Model · View · Controller) · Web API | SQL Server · MySQL | MVC: cookie login/register pages · API: JWT + Swagger |
56
+ | **NestJS** | 10 – 12 | — | PostgreSQL · MySQL · MongoDB · SQLite | — (add Prisma for the data layer) |
56
57
  | **Node.js + Express** | 4 – 5 | — | MySQL · MongoDB · SQL Server | JWT middleware with roles |
57
- | **Raw PHP (MVC)** | — | — | MySQL | Session auth |
58
+ | **Vite** | 6 – 9 | React · Vue · Svelte, TypeScript or JavaScript | — (frontend) | — |
59
+ | **Django** | 4 – 6 | — | SQLite · PostgreSQL | Built-in admin + auth |
60
+ | **FastAPI** | 0.x | — | SQLite · PostgreSQL (SQLAlchemy) · none | — |
61
+ | **Go + Gin** | 1.x | — | — | — |
62
+ | **Raw PHP (MVC)** | 8 | — | MySQL · PostgreSQL · SQLite · SQL Server | Session auth |
58
63
 
59
64
  Every project also gets:
60
65
  - A **theme**: Slate Midnight, Cyber Emerald, Royal Indigo or Crimson Amber, in dark, light or both with a toggle.
@@ -63,6 +68,19 @@ Every project also gets:
63
68
 
64
69
  The version list above is just what's verified today. Newer releases appear automatically and are marked *preview support* until verified.
65
70
 
71
+ ### Extras
72
+
73
+ Pick any of these with space in the **Extras** step, or pass `-e docker,ci`:
74
+
75
+ | Extra | What you get | Available for |
76
+ |---|---|---|
77
+ | **Docker** | `Dockerfile`, `.dockerignore` and a `docker-compose.yml` that also starts your database (MySQL, PostgreSQL, MongoDB or SQL Server) | every framework |
78
+ | **GitHub Actions CI** | `.github/workflows/ci.yml` that installs, lints, builds and tests on every push and pull request | every framework |
79
+ | **ESLint + Prettier** | configs plus `npm run lint` and `npm run format` | Next.js · NestJS · Express · Vite |
80
+ | **Prisma ORM** | `prisma/schema.prisma`, the client, a `DATABASE_URL` for your database, `npm run db:migrate` | Next.js · NestJS · Express |
81
+
82
+ After the files are in place, BoilerCraft runs `git init` and makes an **initial commit**, so the project starts with a clean history (turn it off with `--no-git`).
83
+
66
84
  ## Missing a toolchain?
67
85
 
68
86
  BoilerCraft checks what's installed and offers to set up the rest. You can also do it any time from **Check & install tools** in the menu.
@@ -72,10 +90,52 @@ BoilerCraft checks what's installed and offers to set up the rest. You can also
72
90
  | .NET SDK | .NET | Microsoft's official `dotnet-install` script, exact version, user folder, **no admin rights** |
73
91
  | Composer | Laravel | Official installer, signature-verified, user folder |
74
92
  | PHP | Laravel, Raw PHP | `winget` (Windows) · Homebrew (macOS) · apt / dnf / pacman (Linux) |
75
- | Node.js | Next.js, Express | Already there, since `npx` runs on it |
93
+ | Node.js | Next.js, NestJS, Express, Vite | Already there, since `npx` runs on it |
94
+ | Python 3 | Django, FastAPI | Install from [python.org](https://www.python.org/downloads) (BoilerCraft creates a `.venv` per project) |
95
+ | Go | Go + Gin | Install from [go.dev/dl](https://go.dev/dl) |
76
96
 
77
97
  Tools installed into the user folder live in `~/.boilercraft/tools` and are added to your user PATH.
78
98
 
99
+ ### `npx boilercraft doctor`
100
+
101
+ Doctor is a quick health check. Run it before your first project, when something fails, or paste its output into a bug report.
102
+
103
+ ```bash
104
+ npx boilercraft doctor
105
+ ```
106
+
107
+ It prints three sections:
108
+
109
+ 1. **System**: BoilerCraft version, Node.js version, OS and CPU, and where BoilerCraft keeps the tools it installs.
110
+ 2. **Tools**: each tool BoilerCraft can use (Node.js, npm, PHP, Composer, .NET SDK, Python, Go, git, Docker), with the version it found or ✖ *not found*, and which frameworks need it.
111
+ 3. **Frameworks**: every framework with ✔ ready or ✖ plus exactly what is missing and where to get it, e.g. `✖ Django · needs Python 3 (https://www.python.org/downloads)`.
112
+
113
+ Doctor only reads; it never installs or changes anything. It finishes with a line like `7/10 frameworks ready`. The exit code is `0` when every framework is ready and `1` otherwise, so you can use it in CI:
114
+
115
+ ```bash
116
+ npx boilercraft doctor || echo "some toolchains are missing"
117
+ ```
118
+
119
+ To fix what doctor reports, run `npx boilercraft tools` (installs PHP, Composer or the .NET SDK for you) or follow the links it prints. Doctor is also in the main menu as **Run doctor**.
120
+
121
+ ## Presets
122
+
123
+ Creating the same kind of project often? Save your answers once and skip the questions next time.
124
+
125
+ - After a project is created, choose **Save these choices as a preset**, or add `--save-preset team-api` to a `new` command.
126
+ - Next time, the menu asks **Start from** a preset, or run `npx boilercraft new my-app --preset team-api`.
127
+ - A preset can also be a JSON file you commit and share with your team: `--preset ./boilercraft.json`.
128
+
129
+ ```json
130
+ { "stack": "nextjs", "version": "16", "router": "app", "db": "mysql", "extras": ["docker", "ci", "lint"], "theme": "emerald" }
131
+ ```
132
+
133
+ Keys match the CLI flags. Saved presets live in `~/.boilercraft/presets`, and `npx boilercraft presets` lists them.
134
+
135
+ ## Staying up to date
136
+
137
+ BoilerCraft checks npm for a newer release at most once a day (the result is cached) and shows a notice with the command to update: `npx boilercraft@latest`. Set `BOILERCRAFT_NO_UPDATE_CHECK=1` to turn this off. It is always off in CI.
138
+
79
139
  ## Web studio
80
140
 
81
141
  Prefer a browser? Choose **Open the web studio** in the menu. You get the same generator as a visual form at `http://localhost:4800`.
@@ -91,6 +151,10 @@ Every question has a flag. Anything you leave out is asked interactively, or tak
91
151
  npx boilercraft new shop -s laravel -v 13 --frontend vue -t emerald -d mysql -y
92
152
  npx boilercraft new web -s nextjs --router pages --no-auth -d mongodb -y
93
153
  npx boilercraft new admin -s dotnet-core -v 10 --template mvc -y --install-tools
154
+ npx boilercraft new ui -s vite --framework vue --language ts -e lint,docker -y
155
+ npx boilercraft new api -s nestjs -d postgresql -e prisma,docker,ci -y --open
156
+ npx boilercraft new blog -s django -d postgresql -e docker -y
157
+ npx boilercraft new team --preset ./boilercraft.json -y
94
158
  npx boilercraft --help
95
159
  ```
96
160
 
@@ -127,7 +191,8 @@ BoilerCraft/
127
191
  │ ├── generate.js # Shared entry for the CLI and web studio
128
192
  │ ├── themeGenerator.js # Theme palettes and dark/light CSS
129
193
  │ └── componentGenerator.js # Skeleton, spinner, modal and drawer components
130
- ├── templates/ # Offline fallback templates (and Raw PHP)
194
+ ├── templates/ # Offline fallback templates (Raw PHP skeleton too)
195
+ ├── test/ # node --test suite (npm test)
131
196
  ├── public/index.html # Web studio
132
197
  └── server.js # Web studio API
133
198
  ```
@@ -35,21 +35,36 @@ const HELP = `
35
35
  ${c.bold('Automation')} ${c.gray('(scripts / CI, no prompts)')}
36
36
  npx boilercraft new <name> [flags]
37
37
 
38
- -s, --stack <id> laravel | nextjs | node-express | dotnet-core | raw-php
38
+ -s, --stack <id> laravel | nextjs | nestjs | vite | node-express | dotnet-core
39
+ django | fastapi | go-gin | raw-php
39
40
  -v, --version <major> Framework major version (default: recommended)
40
41
  --auth / --no-auth Authentication module (default: on)
41
42
  --router <type> app | pages (Next.js, default: app)
42
43
  --frontend <name> blade | vue | react (Laravel, default: blade)
43
44
  --template <type> mvc | api (.NET, default: mvc)
45
+ --framework <name> react | vue | svelte (Vite, default: react)
46
+ --language <lang> ts | js (Vite, default: ts)
44
47
  -t, --theme <name> midnight | emerald | royal | crimson
45
48
  -m, --mode <mode> all | dark | light
46
- -d, --db <name> mysql | mssql | mongodb
49
+ -d, --db <name> mysql | mssql | mongodb | postgresql | sqlite | none
47
50
  --styling <name> tailwind | bootstrap | vanilla
51
+ -e, --extras <list> docker,ci,lint,prisma (comma separated)
52
+ --git / --no-git Initialize a git repository (default: on)
53
+ -p, --preset <name> Use a saved preset or a JSON file for the answers
54
+ --save-preset <n> Save this run's answers as a preset
55
+ --open Open the project in your editor when done
56
+ --editor <cmd> Editor for --open (default: code, e.g. cursor)
48
57
  -o, --out <dir> Parent folder (default: current directory)
49
58
  -y, --yes Defaults for anything not given
50
59
  --install-tools Install missing PHP / Composer / .NET SDK automatically
51
60
  --no-fallback Fail instead of using offline templates
52
61
  -h, --help This help
62
+
63
+ ${c.bold('Other commands')}
64
+ npx boilercraft doctor Report installed tools and which frameworks are ready
65
+ npx boilercraft tools Install PHP, Composer or the .NET SDK
66
+ npx boilercraft list Frameworks and live versions
67
+ npx boilercraft presets Saved presets
53
68
  `;
54
69
 
55
70
  async function main() {
@@ -65,6 +80,14 @@ async function main() {
65
80
  router: { type: 'string' },
66
81
  frontend: { type: 'string' },
67
82
  template: { type: 'string' },
83
+ framework: { type: 'string' },
84
+ language: { type: 'string' },
85
+ extras: { type: 'string', short: 'e' },
86
+ git: { type: 'boolean' },
87
+ preset: { type: 'string', short: 'p' },
88
+ 'save-preset': { type: 'string' },
89
+ open: { type: 'boolean' },
90
+ editor: { type: 'string' },
68
91
  db: { type: 'string', short: 'd' },
69
92
  styling: { type: 'string' },
70
93
  port: { type: 'string' },
@@ -86,12 +109,19 @@ async function main() {
86
109
  // Non-interactive / scripted use.
87
110
  if (command === 'new' || command === 'create') {
88
111
  app.printBanner();
89
- const given = { ...values, name, installTools: values['install-tools'], noFallback: values.fallback === false };
112
+ const given = { ...values, name, installTools: values['install-tools'], noFallback: values.fallback === false, savePreset: values['save-preset'] };
90
113
  await app.createFlow(given);
114
+ await app.notifyUpdate();
91
115
  return app.goodbye();
92
116
  }
93
117
  if (command === 'list') return app.browseFlow();
94
- if (command === 'doctor' || command === 'tools') return app.toolsFlow();
118
+ // `doctor` used to open the interactive tools screen; it is now a plain report.
119
+ if (command === 'doctor') {
120
+ if (!(await app.doctor())) process.exitCode = 1;
121
+ return app.notifyUpdate();
122
+ }
123
+ if (command === 'tools') return app.toolsFlow();
124
+ if (command === 'presets') return app.listPresetsFlow();
95
125
 
96
126
  // `npx boilercraft my-app` → menu-free create with the name filled in.
97
127
  app.printBanner();
package/cli/app.js CHANGED
@@ -7,6 +7,9 @@ const { describeStacks, checkRequirements, getStack } = require('../services/eng
7
7
  const { installMissing, canInstall, toolEnv, RECIPES, TOOLS_HOME } = require('../services/engine/toolInstaller');
8
8
  const { generate } = require('../services/generate');
9
9
  const { THEMES } = require('../services/themeGenerator');
10
+ const { EXTRAS } = require('../services/engine/extras');
11
+ const presets = require('../services/presets');
12
+ const updateNotifier = require('../services/updateNotifier');
10
13
  const pkg = require('../package.json');
11
14
 
12
15
  const { c, log } = ui;
@@ -15,7 +18,9 @@ const AUTHOR = 'ARG RABBI';
15
18
  const REPO_URL = 'https://github.com/itrabbi24/BoilerCraft';
16
19
  const TAGLINE = 'Production-ready projects on the official tooling · any version · auth · themes';
17
20
 
18
- const LEGACY_STACKS = [{
21
+ // raw-php now has a manifest in stacks/raw-php and runs through the engine.
22
+ // Kept for reference in case a stack without a manifest is needed again.
23
+ /* const LEGACY_STACKS = [{
19
24
  id: 'raw-php',
20
25
  name: 'Raw PHP (MVC)',
21
26
  databases: ['mysql'],
@@ -23,7 +28,7 @@ const LEGACY_STACKS = [{
23
28
  versions: [],
24
29
  requirements: { ok: true, missing: [] },
25
30
  nextSteps: ['php -S localhost:8000 -t public']
26
- }];
31
+ }]; */
27
32
 
28
33
  const STACK_HINTS = {
29
34
  'laravel': 'Blade · Vue · React',
@@ -32,7 +37,8 @@ const STACK_HINTS = {
32
37
  'dotnet-core': 'MVC · Web API · EF Core',
33
38
  'raw-php': 'No framework · PDO'
34
39
  };
35
- const DB_LABELS = { mysql: 'MySQL', mssql: 'SQL Server', mongodb: 'MongoDB' };
40
+ const stackHint = s => STACK_HINTS[s.id] || s.description || '';
41
+ const DB_LABELS = { mysql: 'MySQL', mssql: 'SQL Server', mongodb: 'MongoDB', postgresql: 'PostgreSQL', sqlite: 'SQLite', none: 'None' };
36
42
  const STYLE_LABELS = { tailwind: 'Tailwind CSS', bootstrap: 'Bootstrap 5', vanilla: 'Plain CSS' };
37
43
  const COLOR_MODES = [
38
44
  { value: 'all', label: 'Dark + Light', hint: 'with a toggle' },
@@ -47,7 +53,7 @@ const COLOR_MODES = [
47
53
  let stacksPromise = null;
48
54
  function loadStacks({ refresh = false } = {}) {
49
55
  if (!stacksPromise || refresh) {
50
- stacksPromise = describeStacks().then(list => [...list, ...LEGACY_STACKS]);
56
+ stacksPromise = describeStacks(); // was: .then(list => [...list, ...LEGACY_STACKS])
51
57
  }
52
58
  return stacksPromise;
53
59
  }
@@ -99,6 +105,12 @@ function isNewer(latest, current) {
99
105
  }
100
106
 
101
107
  async function checkForUpdate() {
108
+ return updateNotifier.checkForUpdate(pkg);
109
+ }
110
+
111
+ // Previous uncached check, replaced by services/updateNotifier.js (once a day, cached).
112
+ // eslint-disable-next-line no-unused-vars
113
+ async function checkForUpdateUncached() {
102
114
  try {
103
115
  const res = await axios.get('https://registry.npmjs.org/boilercraft/latest', { timeout: 1500 });
104
116
  return res.data?.version && isNewer(res.data.version, pkg.version) ? res.data.version : null;
@@ -125,13 +137,32 @@ function versionOption(v) {
125
137
  return { value: v.major, label: `${v.major}.x`, short: `${v.major}.x`, hint: `latest ${v.latest}${tags.length ? ' · ' + tags.join(', ') : ''}` };
126
138
  }
127
139
 
128
- async function askProject(given = {}) {
129
- const cwd = path.resolve(given.out || process.cwd());
130
- const ask = !given.yes;
140
+ // Asks only when there is a real choice; a single option is picked silently.
141
+ async function choose(ask, question, options, { initial = 0 } = {}) {
142
+ if (options.length === 1 || !ask) return options[options.length === 1 ? 0 : initial].value;
143
+ return ui.select(question, options, { initial });
144
+ }
131
145
 
146
+ async function askPreset(given) {
147
+ if (given.preset) return presets.loadPreset(given.preset);
148
+ if (given.yes || given.stack) return {};
149
+ const saved = presets.listPresets();
150
+ if (!saved.length) return {};
151
+ const pick = await ui.select('Start from', [
152
+ { value: null, label: 'A fresh project', short: 'Fresh' },
153
+ ...saved.map(p => ({ value: p.name, label: `Preset: ${p.name}`, short: p.name, hint: [p.stack, p.db, ...(p.extras || [])].filter(Boolean).join(' · ') }))
154
+ ]);
155
+ return pick ? presets.loadPreset(pick) : {};
156
+ }
157
+
158
+ async function askProject(given = {}) {
132
159
  console.log(ui.divider('New project'));
133
160
  console.log('');
134
161
 
162
+ given = { ...(await askPreset(given)), ...given };
163
+ const cwd = path.resolve(given.out || process.cwd());
164
+ const ask = !given.yes;
165
+
135
166
  const projectName = given.name
136
167
  ? (() => {
137
168
  const err = validateName(given.name, cwd);
@@ -150,7 +181,7 @@ async function askProject(given = {}) {
150
181
  value: s,
151
182
  label: s.name,
152
183
  short: s.name,
153
- hint: s.requirements.ok ? STACK_HINTS[s.id] : c.yellow(`needs ${s.requirements.missing.map(m => m.name).join(', ')} · can install`)
184
+ hint: s.requirements.ok ? stackHint(s) : c.yellow(`needs ${s.requirements.missing.map(m => m.name).join(', ')} · can install`)
154
185
  })))
155
186
  : stacks[0];
156
187
  }
@@ -164,7 +195,7 @@ async function askProject(given = {}) {
164
195
  throw new Error(`${stack.name} ${given.version} is not available. Options: ${stack.versions.map(v => v.major).join(', ')}`);
165
196
  }
166
197
  } else {
167
- version = ask ? await ui.select(`${stack.name} version`, stack.versions.map(versionOption), { initial: rec }) : stack.versions[rec].major;
198
+ version = await choose(ask, `${stack.name} version`, stack.versions.map(versionOption), { initial: rec });
168
199
  }
169
200
  }
170
201
 
@@ -176,12 +207,10 @@ async function askProject(given = {}) {
176
207
  throw new Error(`Invalid ${opt.label.toLowerCase()} "${preset}". Options: ${opt.choices.map(ch => ch.value).join(', ')}`);
177
208
  }
178
209
  const initial = Math.max(0, opt.choices.findIndex(ch => ch.value === opt.default));
179
- options[opt.id] = preset ?? (ask
180
- ? await ui.select(opt.label, opt.choices.map(ch => ({ ...ch, short: ch.label })), { initial })
181
- : opt.choices[initial].value);
210
+ options[opt.id] = preset ?? await choose(ask, opt.label, opt.choices.map(ch => ({ ...ch, short: ch.label })), { initial });
182
211
  }
183
212
 
184
- const auth = given.auth !== undefined ? given.auth : ask
213
+ const auth = stack.auth === false ? false : given.auth !== undefined ? given.auth : ask
185
214
  ? await ui.select('Authentication', [
186
215
  { value: true, label: 'Include authentication', short: 'Included', hint: 'register · login · token' },
187
216
  { value: false, label: 'No authentication', short: 'None' }
@@ -202,12 +231,20 @@ async function askProject(given = {}) {
202
231
  if (given.db && !stack.databases.includes(given.db)) {
203
232
  throw new Error(`${stack.name} supports: ${stack.databases.join(', ')} (got "${given.db}")`);
204
233
  }
205
- const database = given.db || (ask
206
- ? await ui.select('Database', stack.databases.map(d => ({ value: d, label: DB_LABELS[d] || d })))
207
- : stack.databases[0]);
208
- const styling = given.styling || (ask
209
- ? await ui.select('Styling', stack.stylings.map(s => ({ value: s, label: STYLE_LABELS[s] || s })))
210
- : stack.stylings[0]);
234
+ const database = given.db || await choose(ask, 'Database', stack.databases.map(d => ({ value: d, label: DB_LABELS[d] || d })));
235
+ if (given.styling && !stack.stylings.includes(given.styling)) {
236
+ throw new Error(`${stack.name} supports styling: ${stack.stylings.join(', ')} (got "${given.styling}")`);
237
+ }
238
+ const styling = given.styling || await choose(ask, 'Styling', stack.stylings.map(s => ({ value: s, label: STYLE_LABELS[s] || s })));
239
+
240
+ const supported = stack.extras || [];
241
+ const givenExtras = typeof given.extras === 'string' ? given.extras.split(',').map(x => x.trim()).filter(Boolean) : given.extras;
242
+ const unknown = (givenExtras || []).filter(x => !supported.includes(x));
243
+ if (unknown.length) throw new Error(`${stack.name} does not support: ${unknown.join(', ')}. Options: ${supported.join(', ') || 'none'}`);
244
+ const extras = givenExtras || (ask && supported.length
245
+ ? await ui.multiSelect('Extras', supported.map(x => ({ value: x, label: EXTRAS[x].label, short: EXTRAS[x].label, hint: EXTRAS[x].hint })))
246
+ : []);
247
+ const git = given.git !== undefined ? given.git : ask ? await ui.confirm('Initialize a git repository?', true) : true;
211
248
 
212
249
  return {
213
250
  stack,
@@ -221,6 +258,8 @@ async function askProject(given = {}) {
221
258
  colorMode,
222
259
  database,
223
260
  styling,
261
+ extras,
262
+ git,
224
263
  port: given.port ? parseInt(given.port, 10) : undefined,
225
264
  appTitle: given.title,
226
265
  author: given.author,
@@ -241,10 +280,12 @@ function summaryBox(stack, config) {
241
280
  ['Project', c.bold(config.projectName)],
242
281
  ['Framework', `${stack.name}${version ? ` ${version}.x` : ''}`],
243
282
  ...(stack.options || []).map(opt => [opt.label, opt.choices.find(ch => ch.value === config.options?.[opt.id])?.label || '']),
244
- ['Auth', config.auth.enabled ? 'Included' : 'None'],
283
+ ...(stack.auth === false ? [] : [['Auth', config.auth.enabled ? 'Included' : 'None']]),
245
284
  ['Theme', `${ui.swatch(THEMES[config.theme].primary)} ${THEMES[config.theme].name} · ${COLOR_MODES.find(m => m.value === config.colorMode)?.label}`],
246
285
  ['Database', DB_LABELS[config.database] || config.database],
247
286
  ['Styling', STYLE_LABELS[config.styling] || config.styling],
287
+ ['Extras', config.extras.length ? config.extras.map(x => EXTRAS[x].label).join(', ') : 'None'],
288
+ ['Git', config.git ? 'Initialize repository' : 'No'],
248
289
  ['Location', c.gray(displayPath(path.join(config.outputDir, config.projectName.toLowerCase())))]
249
290
  ];
250
291
  return ui.box(rows.map(([k, v]) => `${c.gray(k.padEnd(10))} ${v}`), { title: 'Summary' });
@@ -312,7 +353,7 @@ async function runGeneration(config) {
312
353
  return { ...result, seconds };
313
354
  }
314
355
 
315
- async function afterCreate(stack, result) {
356
+ async function afterCreate(stack, result, config) {
316
357
  const rel = path.relative(process.cwd(), result.path) || '.';
317
358
  const steps = result.nextSteps || (stack.nextSteps || []);
318
359
  console.log('');
@@ -331,6 +372,7 @@ async function afterCreate(stack, result) {
331
372
  { value: 'run', label: 'Start the dev server', hint: steps.join(' → ') },
332
373
  hasCode && { value: 'code', label: 'Open in VS Code' },
333
374
  { value: 'folder', label: 'Open the project folder' },
375
+ config && { value: 'preset', label: 'Save these choices as a preset', hint: 'reuse with --preset or from the menu' },
334
376
  { value: 'menu', label: 'Back to main menu' },
335
377
  { value: 'exit', label: 'Exit' }
336
378
  ].filter(Boolean);
@@ -356,6 +398,10 @@ async function afterCreate(stack, result) {
356
398
  } else if (action === 'code') {
357
399
  openInBackground(`code "${result.path}"`);
358
400
  log.ok('Opened in VS Code');
401
+ } else if (action === 'preset') {
402
+ const name = await ui.input('Preset name', { initial: `${stack.id}-default`, validate: v => (presets.slug(v) ? null : 'Use letters or numbers') });
403
+ const file = presets.savePreset(name, presets.presetFromConfig(config, stack));
404
+ log.ok(`Saved ${c.gray(file)} · next time: ${c.cyan(`npx boilercraft new my-app --preset ${presets.slug(name)}`)}`);
359
405
  } else if (action === 'folder') {
360
406
  openInBackground(openerFor(result.path));
361
407
  log.ok('Opened folder');
@@ -390,14 +436,25 @@ async function createFlow(given = {}) {
390
436
  }
391
437
 
392
438
  const result = await runGeneration(config);
439
+ if (given.savePreset) {
440
+ log.ok(`Preset saved: ${presets.savePreset(given.savePreset, presets.presetFromConfig(config, stack))}`);
441
+ }
442
+ if (given.open) openEditor(result.path, given.editor);
393
443
  if (given.yes || !ui.isTTY) {
394
444
  printPlainNextSteps(stack, result);
395
445
  return 'exit';
396
446
  }
397
- return afterCreate(stack, result);
447
+ return afterCreate(stack, result, config);
398
448
  }
399
449
  }
400
450
 
451
+ // --open, with --editor <cmd> (VS Code by default, e.g. --editor cursor).
452
+ function openEditor(dir, editor) {
453
+ const cmd = typeof editor === 'string' && editor ? editor : 'code';
454
+ openInBackground(`${cmd} "${dir}"`);
455
+ log.ok(`Opening in ${cmd}`);
456
+ }
457
+
401
458
  function printPlainNextSteps(stack, result) {
402
459
  const rel = path.relative(process.cwd(), result.path) || '.';
403
460
  console.log('');
@@ -413,6 +470,13 @@ function printPlainNextSteps(stack, result) {
413
470
  // ---------------------------------------------------------------------------
414
471
 
415
472
  async function toolStatus() {
473
+ const first = out => (out ? (out.match(/(\d+\.\d+(\.\d+)?)/) || [])[1] || out.split('\n')[0] : null);
474
+ const [git, python, go, docker] = await Promise.all([
475
+ probe('git', ['--version']),
476
+ probe(process.platform === 'win32' ? 'python' : 'python3', ['--version']),
477
+ probe('go', ['version']),
478
+ probe('docker', ['--version'])
479
+ ]);
416
480
  const [npm, php, composer, sdks] = await Promise.all([
417
481
  probe('npm', ['--version']),
418
482
  probe('php', ['-r', '"echo PHP_VERSION;"']),
@@ -425,7 +489,11 @@ async function toolStatus() {
425
489
  { id: 'npm', name: 'npm', version: npm, usedBy: 'Next.js · Express' },
426
490
  { id: 'php', name: 'PHP', version: php, usedBy: 'Laravel · Raw PHP' },
427
491
  { id: 'composer', name: 'Composer', version: composer && (composer.match(/(\d+\.\d+\.\d+)/) || [])[1], usedBy: 'Laravel' },
428
- { id: 'dotnet', name: '.NET SDK', version: sdkList.length ? sdkList.join(', ') : null, usedBy: '.NET' }
492
+ { id: 'dotnet', name: '.NET SDK', version: sdkList.length ? sdkList.join(', ') : null, usedBy: '.NET' },
493
+ { id: 'python', name: 'Python', version: first(python), usedBy: 'Django · FastAPI', install: 'https://www.python.org/downloads' },
494
+ { id: 'go', name: 'Go', version: first(go), usedBy: 'Go + Gin', install: 'https://go.dev/dl' },
495
+ { id: 'git', name: 'git', version: first(git), usedBy: 'repository setup', install: 'https://git-scm.com' },
496
+ { id: 'docker', name: 'Docker', version: first(docker), usedBy: 'Docker extra (optional)', install: 'https://docs.docker.com/get-docker' }
429
497
  ];
430
498
  }
431
499
 
@@ -470,6 +538,34 @@ async function toolsFlow() {
470
538
  }
471
539
  }
472
540
 
541
+ /**
542
+ * `boilercraft doctor`: a plain, non-interactive health report. Lists every
543
+ * tool, which frameworks are ready right now, and exits 1 if a framework
544
+ * cannot be created (handy in CI and when reporting an issue).
545
+ */
546
+ async function doctor() {
547
+ console.log(ui.divider('Doctor'));
548
+ console.log('');
549
+ const [tools, stacks] = await ui.spinner('Checking tools and frameworks…', () => Promise.all([toolStatus(), loadStacks()]));
550
+ console.log(ui.box([
551
+ `${c.gray('BoilerCraft'.padEnd(12))} v${pkg.version}`,
552
+ `${c.gray('Node.js'.padEnd(12))} ${process.version}`,
553
+ `${c.gray('Platform'.padEnd(12))} ${process.platform} ${process.arch}`,
554
+ `${c.gray('Tools home'.padEnd(12))} ${TOOLS_HOME}`
555
+ ], { title: 'System' }));
556
+ console.log(ui.box(tools.map(t => {
557
+ const mark = t.version ? c.green('✔') : c.yellow('✖');
558
+ return `${mark} ${t.name.padEnd(10)} ${(t.version || c.yellow('not found')).padEnd(18)} ${c.gray(t.usedBy)}`;
559
+ }), { title: 'Tools' }));
560
+ console.log(ui.box(stacks.map(s => s.requirements.ok
561
+ ? `${c.green('✔')} ${s.name}`
562
+ : `${c.yellow('✖')} ${s.name} ${c.gray('· needs ' + s.requirements.missing.map(m => `${m.name}${m.install ? ` (${m.install})` : ''}`).join(', '))}`
563
+ ), { title: 'Frameworks' }));
564
+ const ready = stacks.filter(s => s.requirements.ok).length;
565
+ console.log(`\n ${ready}/${stacks.length} frameworks ready.${ready < stacks.length ? ` Run ${c.cyan('npx boilercraft tools')} to install PHP, Composer or .NET for you.` : ''}\n`);
566
+ return ready === stacks.length;
567
+ }
568
+
473
569
  // ---------------------------------------------------------------------------
474
570
  // Browse / About / Web studio
475
571
  // ---------------------------------------------------------------------------
@@ -480,7 +576,7 @@ async function browseFlow() {
480
576
  const stacks = await ui.spinner('Loading frameworks and latest versions…', () => loadStacks());
481
577
  for (const s of stacks) {
482
578
  const status = s.requirements.ok ? c.green('● ready') : c.yellow(`○ needs ${s.requirements.missing.map(m => m.name).join(', ')}`);
483
- const lines = [`${status} ${c.gray(STACK_HINTS[s.id] || '')}`];
579
+ const lines = [`${status} ${c.gray(stackHint(s))}`];
484
580
  if (s.versions.length) {
485
581
  lines.push('');
486
582
  for (const v of s.versions) {
@@ -491,7 +587,7 @@ async function browseFlow() {
491
587
  lines.push('', `${c.gray('Databases')} ${s.databases.map(d => DB_LABELS[d] || d).join(', ')}`);
492
588
  console.log(ui.box(lines, { title: s.name }));
493
589
  }
494
- console.log(`\n ${c.gray('Versions are fetched live from npm, Packagist and the .NET release index,')}`);
590
+ console.log(`\n ${c.gray('Versions are fetched live from npm, Packagist, PyPI, the Go proxy and .NET,')}`);
495
591
  console.log(` ${c.gray('so new framework releases appear here automatically.')}\n`);
496
592
  await ui.select('Done?', [{ value: 'back', label: 'Back to main menu' }]);
497
593
  }
@@ -564,8 +660,9 @@ async function home() {
564
660
  let action;
565
661
  try {
566
662
  action = await ui.select('What would you like to do?', [
567
- { value: 'create', label: 'Create a new project', hint: 'Laravel · Next.js · Express · .NET · PHP' },
663
+ { value: 'create', label: 'Create a new project', hint: 'Laravel · Next.js · NestJS · Vite · Django · FastAPI · Go · .NET' },
568
664
  { value: 'tools', label: 'Check & install tools', hint: 'PHP · Composer · .NET SDK' },
665
+ { value: 'doctor', label: 'Run doctor', hint: 'what is installed, what is ready' },
569
666
  { value: 'browse', label: 'Browse frameworks & versions' },
570
667
  { value: 'web', label: 'Open the web studio', hint: 'same features in your browser' },
571
668
  { value: 'about', label: 'About BoilerCraft' },
@@ -583,6 +680,7 @@ async function home() {
583
680
  if ((await createFlow()) === 'exit') return goodbye();
584
681
  } else if (action === 'tools') await toolsFlow();
585
682
  else if (action === 'browse') await browseFlow();
683
+ else if (action === 'doctor') await doctor();
586
684
  else if (action === 'web') await webFlow();
587
685
  else if (action === 'about') await aboutFlow();
588
686
  } catch (err) {
@@ -593,4 +691,23 @@ async function home() {
593
691
  }
594
692
  }
595
693
 
596
- module.exports = { home, createFlow, toolsFlow, browseFlow, printBanner, goodbye, AUTHOR };
694
+ function listPresetsFlow() {
695
+ const saved = presets.listPresets();
696
+ console.log('');
697
+ if (!saved.length) {
698
+ log.info(`No presets yet. Create a project and choose "Save these choices as a preset", or pass ${c.cyan('--save-preset <name>')}.`);
699
+ } else {
700
+ console.log(ui.box(saved.map(p => `${c.bold(p.name.padEnd(18))} ${c.gray([p.stack, p.version && `v${p.version}`, p.db, ...(p.extras || [])].filter(Boolean).join(' · '))}`), { title: 'Presets' }));
701
+ console.log(`\n ${c.gray(`Stored in ${presets.PRESETS_DIR}`)}`);
702
+ console.log(` ${c.gray('Use:')} ${c.cyan(`npx boilercraft new my-app --preset ${saved[0].name}`)}`);
703
+ }
704
+ console.log('');
705
+ }
706
+
707
+ // Shown after scripted commands; the menu shows it at the top instead.
708
+ async function notifyUpdate() {
709
+ const latest = await Promise.race([checkForUpdate(), new Promise(r => setTimeout(() => r(null), 1500))]);
710
+ if (latest) console.log(ui.box([`Update available ${c.gray(pkg.version)} → ${c.green(latest)}`, `Run ${c.cyan('npx boilercraft@latest')}`], { color: c.yellow }));
711
+ }
712
+
713
+ module.exports = { home, createFlow, toolsFlow, browseFlow, doctor, notifyUpdate, listPresetsFlow, printBanner, goodbye, AUTHOR };
package/cli/ui.js CHANGED
@@ -260,6 +260,72 @@ async function selectPlain(question, options, initial) {
260
260
  throw new CancelError();
261
261
  }
262
262
 
263
+ /**
264
+ * Checkbox list. options: [{ value, label, hint, checked }]
265
+ * Space toggles, "a" toggles all, Enter confirms. Returns the chosen values.
266
+ */
267
+ async function multiSelect(question, options) {
268
+ const picked = new Set(options.filter(o => o.checked).map(o => o.value));
269
+ if (!isTTY) {
270
+ console.log(` ${c.cyan('?')} ${c.bold(question)}`);
271
+ options.forEach((o, i) => console.log(` ${String(i + 1).padStart(2)}) ${o.label}${o.hint ? c.gray(` ${o.hint}`) : ''}`));
272
+ process.stdout.write(` ${c.gray('numbers, comma separated (blank = none):')} `);
273
+ const answer = await nextLine();
274
+ if (answer === null) throw new CancelError();
275
+ process.stdout.write(`${answer}\n`);
276
+ return answer.split(',').map(n => options[parseInt(n, 10) - 1]).filter(Boolean).map(o => o.value);
277
+ }
278
+
279
+ let index = 0;
280
+ return new Promise((resolve, reject) => {
281
+ const out = process.stdout;
282
+ readline.emitKeypressEvents(process.stdin);
283
+ process.stdin.setRawMode(true);
284
+ process.stdin.resume();
285
+ out.write('\x1b[?25l');
286
+ out.write(truncate(` ${c.cyan('?')} ${c.bold(question)} ${c.gray('· space to toggle, enter to confirm')}`, columns() - 1) + '\n');
287
+ const render = first => {
288
+ if (!first) out.write(`\x1b[${options.length}A`);
289
+ options.forEach((o, i) => {
290
+ const mark = picked.has(o.value) ? c.green('◉') : c.gray('◯');
291
+ const label = i === index ? c.cyan(c.bold(o.label)) : o.label;
292
+ const full = ` ${i === index ? c.cyan('❯') : ' '} ${mark} ${label}${o.hint ? ` ${c.gray(o.hint)}` : ''}`;
293
+ out.write(`\x1b[2K${width(full) <= columns() - 1 ? full : truncate(full, columns() - 1)}\n`);
294
+ });
295
+ };
296
+ render(true);
297
+ const finish = () => {
298
+ process.stdin.removeListener('keypress', onKey);
299
+ process.stdin.setRawMode(false);
300
+ process.stdin.pause();
301
+ out.write(`\x1b[${options.length + 1}A\x1b[0J\x1b[?25h`);
302
+ };
303
+ const onKey = (str, key = {}) => {
304
+ if ((key.ctrl && key.name === 'c') || key.name === 'escape') {
305
+ finish();
306
+ return reject(new CancelError());
307
+ }
308
+ if (key.name === 'up' || key.name === 'k') index = (index - 1 + options.length) % options.length;
309
+ else if (key.name === 'down' || key.name === 'j' || key.name === 'tab') index = (index + 1) % options.length;
310
+ else if (key.name === 'space') {
311
+ const v = options[index].value;
312
+ if (picked.has(v)) picked.delete(v);
313
+ else picked.add(v);
314
+ } else if (str === 'a') {
315
+ if (picked.size === options.length) picked.clear();
316
+ else options.forEach(o => picked.add(o.value));
317
+ } else if (key.name === 'return' || key.name === 'enter') {
318
+ finish();
319
+ const chosen = options.filter(o => picked.has(o.value));
320
+ out.write(` ${c.green('✔')} ${c.bold(question)} ${c.gray('·')} ${c.cyan(chosen.length ? chosen.map(o => o.short || stripAnsi(o.label)).join(', ') : 'None')}\n`);
321
+ return resolve(chosen.map(o => o.value));
322
+ }
323
+ render(false);
324
+ };
325
+ process.stdin.on('keypress', onKey);
326
+ });
327
+ }
328
+
263
329
  async function confirm(question, initial = true) {
264
330
  return select(question, [
265
331
  { value: true, label: 'Yes', short: 'Yes' },
@@ -353,4 +419,4 @@ const log = {
353
419
  info: msg => console.log(` ${c.blue('ℹ')} ${msg}`)
354
420
  };
355
421
 
356
- module.exports = { truncate, c, isTTY, swatch, gradient, banner, box, divider, input, select, confirm, spinner, tasks, log, width, stripAnsi, CancelError };
422
+ module.exports = { truncate, c, isTTY, swatch, gradient, banner, box, divider, input, select, multiSelect, confirm, spinner, tasks, log, width, stripAnsi, CancelError };