create-nevela 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,53 +1,55 @@
1
- # create-nevela
2
-
3
- Create a new [Nevela](https://nevela-docs.vercel.app) app: a Laravel API and a Next.js dashboard, generated from one resource description.
4
-
5
- ```sh
6
- pnpm create nevela my-app
7
- # or
8
- npm create nevela@latest my-app
9
- ```
10
-
11
- You need PHP 8.3 or newer, Composer, and Node.js 20 or newer.
12
-
13
- ## What you get
14
-
15
- ```
16
- my-app/
17
- ├─ apps/api/ a Laravel app with Sanctum and Nevela installed
18
- ├─ apps/web/ the Next.js dashboard, pointed at the API
19
- └─ scripts/ `dev` runs both together
20
- ```
21
-
22
- Then:
23
-
24
- ```sh
25
- cd my-app
26
- pnpm run dev
27
- ```
28
-
29
- Open http://localhost:3000/sign-in. Add your first resource from `apps/api`:
30
-
31
- ```sh
32
- php artisan nevela:resource Product --fields="name:string, sku:string!, price:money"
33
- php artisan migrate
34
- ```
35
-
36
- ## Options
37
-
38
- | Option | Meaning |
39
- |---|---|
40
- | `--pm <pnpm\|npm\|yarn\|bun>` | Package manager for the dashboard. Default: the one you ran this with. |
41
- | `--no-install` | Don't install the dashboard's dependencies. |
42
- | `--no-user` | Don't ask to create the first user. |
43
- | `--no-git` | Don't run `git init`. |
44
- | `--bundled-package` | Use the copy of `nevela/laravel` inside this installer instead of the release on Packagist. |
45
- | `-y`, `--yes` | Ask nothing; take the defaults. |
46
-
47
- With npm, put options after `--`: `npm create nevela@latest my-app -- --no-install`.
48
-
49
- ## How it installs the Laravel package
50
-
51
- `nevela/laravel` is installed from [Packagist](https://packagist.org/packages/nevela/laravel), at the release that matches this installer's version.
52
-
53
- If Packagist has no matching release, or you pass `--bundled-package`, the copy that travels inside this installer is placed in `my-app/packages/nevela-laravel` and Composer installs it from there. That is how Nevela's own CI tests a change to the package before it is released.
1
+ # create-nevela
2
+
3
+ Create a new [Nevela](https://nevela-docs.vercel.app) app: a Laravel API and a Next.js dashboard, generated from one resource description.
4
+
5
+ ```sh
6
+ pnpm create nevela my-app
7
+ # or
8
+ npm create nevela@latest my-app
9
+ ```
10
+
11
+ You need PHP 8.3 or newer, Composer, and Node.js 20 or newer.
12
+
13
+ ## What you get
14
+
15
+ ```
16
+ my-app/
17
+ ├─ apps/api/ a Laravel app with Sanctum and Nevela installed
18
+ ├─ apps/web/ the Next.js dashboard, pointed at the API
19
+ └─ scripts/ `dev` runs both together
20
+ ```
21
+
22
+ Then:
23
+
24
+ ```sh
25
+ cd my-app
26
+ pnpm run dev
27
+ ```
28
+
29
+ Open http://localhost:3000/sign-in and sign in as `admin@example.com` with the password `password`. That starter account is in your local database only; add your own with `php artisan nevela:user`.
30
+
31
+ Add your first resource from `apps/api`:
32
+
33
+ ```sh
34
+ php artisan nevela:resource Product --fields="name:string, sku:string!, price:money"
35
+ php artisan migrate
36
+ ```
37
+
38
+ ## Options
39
+
40
+ | Option | Meaning |
41
+ |---|---|
42
+ | `--pm <pnpm\|npm\|yarn\|bun>` | Package manager for the dashboard. Default: the one you ran this with. |
43
+ | `--no-install` | Don't install the dashboard's dependencies. |
44
+ | `--no-user` | Don't create the starter account. |
45
+ | `--no-git` | Don't run `git init`. |
46
+ | `--bundled-package` | Use the copy of `nevela/laravel` inside this installer instead of the release on Packagist. |
47
+ | `-y`, `--yes` | Ask nothing. Only the app name is ever asked for. |
48
+
49
+ With npm, put options after `--`: `npm create nevela@latest my-app -- --no-install`.
50
+
51
+ ## How it installs the Laravel package
52
+
53
+ `nevela/laravel` is installed from [Packagist](https://packagist.org/packages/nevela/laravel), at the release that matches this installer's version.
54
+
55
+ If Packagist has no matching release, or you pass `--bundled-package`, the copy that travels inside this installer is placed in `my-app/packages/nevela-laravel` and Composer installs it from there. That is how Nevela's own CI tests a change to the package before it is released.
package/index.mjs CHANGED
@@ -1,363 +1,418 @@
1
- #!/usr/bin/env node
2
- // Create a new Nevela app: a Laravel API and a Next.js dashboard, side by side.
3
- //
4
- // pnpm create nevela my-app
5
- // npm create nevela@latest my-app
6
- //
7
- // No dependencies on purpose: this runs before anything is installed.
8
-
9
- import { spawnSync } from 'node:child_process';
10
- import fs from 'node:fs';
11
- import path from 'node:path';
12
- import readline from 'node:readline/promises';
13
- import { fileURLToPath } from 'node:url';
14
- import { onPackagist } from './packagist.mjs';
15
- import { copyLaravelPackage, copyWebTemplate } from './template.mjs';
16
-
17
- const here = path.dirname(fileURLToPath(import.meta.url));
18
- const VERSION = JSON.parse(fs.readFileSync(path.join(here, 'package.json'), 'utf8')).version;
19
- const DOCS = 'https://nevela-docs.vercel.app';
20
- const windows = process.platform === 'win32';
21
-
22
- const colour = (code) => (text) => (process.stdout.isTTY ? `\x1b[${code}m${text}\x1b[0m` : text);
23
- const bold = colour(1);
24
- const dim = colour(2);
25
- const green = colour(32);
26
- const red = colour(31);
27
- const indigo = colour('38;5;99');
28
-
29
- const HELP = `
30
- ${bold('create-nevela')} ${dim(`v${VERSION}`)}
31
-
32
- Create a new Nevela app: a Laravel API and a Next.js dashboard.
33
-
34
- ${bold('Usage')}
35
- pnpm create nevela <name> [options]
36
- npm create nevela@latest <name> -- [options]
37
-
38
- ${bold('Options')}
39
- --pm <pnpm|npm|yarn|bun> Package manager for the dashboard. Default: the one you ran this with.
40
- --no-install Don't install the dashboard's dependencies.
41
- --no-user Don't ask to create the first user.
42
- --no-git Don't run git init.
43
- --bundled-package Use the copy of nevela/laravel that ships with this installer
44
- instead of the release on Packagist.
45
- -y, --yes Ask nothing; take the defaults.
46
- -h, --help Show this.
47
- -v, --version Show the version.
48
-
49
- ${bold('Needs')} PHP 8.3+, Composer, Node.js 20+
50
- ${bold('Docs')} ${DOCS}
51
- `;
52
-
53
- function fail(message, detail) {
54
- console.error(`\n ${red('✖')} ${message}`);
55
- if (detail) console.error(`\n${detail.trim().split('\n').map((line) => ` ${line}`).join('\n')}`);
56
- console.error('');
57
- process.exit(1);
58
- }
59
-
60
- /** Run a command. Quiet unless it fails, when its output is the error message. */
61
- function run(command, args, { cwd, interactive = false, allowFailure = false } = {}) {
62
- const result = spawnCommand(command, args, { cwd, stdio: interactive ? 'inherit' : 'pipe', encoding: 'utf8' });
63
- const output = `${result.stdout ?? ''}${result.stderr ?? ''}`;
64
- if (result.error || result.status !== 0) {
65
- if (allowFailure) return { ok: false, output };
66
- fail(`\`${command} ${args.join(' ')}\` failed.`, output || String(result.error ?? ''));
67
- }
68
- return { ok: true, output };
69
- }
70
-
71
- /**
72
- * .bat and .cmd shims (composer, pnpm, Herd's php) only resolve through a shell on
73
- * Windows. A shell takes one command string, so arguments are quoted here; none of them
74
- * come from anywhere but this file and the validated app name.
75
- */
76
- function spawnCommand(command, args, options) {
77
- if (!windows) return spawnSync(command, args, options);
78
- // `^` is not in the safe list on purpose: unquoted, cmd.exe treats it as an escape
79
- // character and drops it, which turned the constraint "^0.1" into an exact "0.1".
80
- const quote = (arg) => (/^[\w./:=@,-]+$/.test(arg) ? arg : `"${arg.replace(/"/g, '\\"')}"`);
81
- return spawnSync([command, ...args.map(quote)].join(' '), { ...options, shell: true });
82
- }
83
-
84
- function available(command, args = ['--version']) {
85
- const result = spawnCommand(command, args, { stdio: 'pipe', encoding: 'utf8' });
86
- return result.status === 0 ? `${result.stdout}${result.stderr}` : null;
87
- }
88
-
89
- async function step(label, work) {
90
- const started = Date.now();
91
- process.stdout.write(` ${dim('◇')} ${label}…`);
92
- const result = await work();
93
- const seconds = (Date.now() - started) / 1000;
94
- const took = seconds >= 1 ? dim(` (${seconds >= 60 ? `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s` : `${seconds.toFixed(1)}s`})`) : '';
95
- process.stdout.write(`\r ${green('✔')} ${label}${took} \n`);
96
- return result;
97
- }
98
-
99
- function parseArgs(argv) {
100
- const options = { name: undefined, pm: undefined, install: true, user: true, git: true, yes: false, bundledPackage: false };
101
- for (let i = 0; i < argv.length; i++) {
102
- const arg = argv[i];
103
- if (arg === '-h' || arg === '--help') {
104
- console.log(HELP);
105
- process.exit(0);
106
- } else if (arg === '-v' || arg === '--version') {
107
- console.log(VERSION);
108
- process.exit(0);
109
- } else if (arg === '-y' || arg === '--yes') options.yes = true;
110
- else if (arg === '--no-install') options.install = false;
111
- else if (arg === '--no-user') options.user = false;
112
- else if (arg === '--no-git') options.git = false;
113
- else if (arg === '--bundled-package') options.bundledPackage = true;
114
- else if (arg === '--pm') options.pm = argv[++i];
115
- else if (arg.startsWith('--pm=')) options.pm = arg.slice(5);
116
- else if (arg.startsWith('-')) fail(`Unknown option ${arg}. Run with --help to see the options.`);
117
- else if (options.name === undefined) options.name = arg;
118
- else fail(`Unexpected argument "${arg}". The app name is "${options.name}".`);
119
- }
120
- return options;
121
- }
122
-
123
- /** "my-shop" → "My Shop", for the dashboard's title. */
124
- const titleCase = (slug) => slug.split(/[-_]+/).filter(Boolean).map((word) => word[0].toUpperCase() + word.slice(1)).join(' ');
125
-
126
- function detectPackageManager(requested) {
127
- const known = ['pnpm', 'npm', 'yarn', 'bun'];
128
- if (requested) {
129
- if (!known.includes(requested)) fail(`--pm must be one of: ${known.join(', ')}.`);
130
- if (!available(requested)) fail(`${requested} isn't installed, or isn't on your PATH.`);
131
- return requested;
132
- }
133
- // "pnpm/10.1.0 npm/? node/v22…" when run through `pnpm create`.
134
- const agent = (process.env.npm_config_user_agent ?? '').split('/')[0];
135
- if (known.includes(agent)) return agent;
136
- return available('pnpm') ? 'pnpm' : 'npm';
137
- }
138
-
139
- function checkRequirements() {
140
- const node = process.versions.node.split('.').map(Number);
141
- if (node[0] < 20) fail(`Nevela needs Node.js 20 or newer. You have ${process.versions.node}.`);
142
-
143
- const php = available('php', ['-v']);
144
- if (!php) fail('PHP isn\'t installed, or isn\'t on your PATH. Nevela needs PHP 8.3 or newer: https://www.php.net/downloads');
145
- const version = /PHP (\d+)\.(\d+)/.exec(php);
146
- if (!version || Number(version[1]) < 8 || (Number(version[1]) === 8 && Number(version[2]) < 3)) {
147
- fail(`Nevela needs PHP 8.3 or newer. You have ${version ? `${version[1]}.${version[2]}` : 'a version I can\'t read'}.`);
148
- }
149
-
150
- if (!available('composer')) fail('Composer isn\'t installed, or isn\'t on your PATH: https://getcomposer.org/download/');
151
- }
152
-
153
- /** Sanctum's trait on the User model. `install:api` asks for this by hand; do it for them. */
154
- function addApiTokens(userModel) {
155
- let source = fs.readFileSync(userModel, 'utf8');
156
- if (source.includes('HasApiTokens')) return true;
157
- const withImport = source.replace(/^(use Illuminate\\Notifications\\Notifiable;)$/m, '$1\nuse Laravel\\Sanctum\\HasApiTokens;');
158
- const withTrait = withImport.replace(/^(\s+use )(HasFactory, Notifiable;)$/m, '$1HasApiTokens, $2');
159
- if (withImport === source || withTrait === withImport) return false;
160
- fs.writeFileSync(userModel, withTrait);
161
- return true;
162
- }
163
-
164
- function write(file, contents) {
165
- fs.mkdirSync(path.dirname(file), { recursive: true });
166
- fs.writeFileSync(file, contents);
167
- }
168
-
169
- function writeRootFiles(root, { name, title, pm }) {
170
- write(path.join(root, 'package.json'), `${JSON.stringify({
171
- name,
172
- version: '0.1.0',
173
- private: true,
174
- type: 'module',
175
- scripts: {
176
- dev: 'node scripts/dev.mjs',
177
- 'dev:api': 'cd apps/api && php artisan serve',
178
- 'dev:web': `cd apps/web && ${pm} run dev`,
179
- },
180
- }, null, 2)}\n`);
181
-
182
- write(path.join(root, '.gitignore'), 'node_modules/\nvendor/\n.env\n.env.*\n!.env.example\n.next/\n*.tsbuildinfo\nnext-env.d.ts\n');
183
-
184
- write(path.join(root, 'scripts', 'dev.mjs'), `// Run the Laravel API and the Next.js dashboard together: \`${pm} run dev\`.
185
- import { spawn } from 'node:child_process';
186
-
187
- const shell = process.platform === 'win32';
188
- const apps = [
189
- { name: 'api', colour: 35, command: 'php', args: ['artisan', 'serve'], cwd: 'apps/api' },
190
- { name: 'web', colour: 36, command: '${pm}', args: ['run', 'dev'], cwd: 'apps/web' },
191
- ];
192
-
193
- const running = apps.map(({ name, colour, command, args, cwd }) => {
194
- // One string when going through a shell: Node warns about (and doesn't escape) separate args there.
195
- const child = shell
196
- ? spawn([command, ...args].join(' '), { cwd, shell, stdio: ['ignore', 'pipe', 'pipe'] })
197
- : spawn(command, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
198
- const prefix = \`\\x1b[\${colour}m\${name}\\x1b[0m │ \`;
199
- for (const stream of [child.stdout, child.stderr]) {
200
- let rest = '';
201
- stream.on('data', (chunk) => {
202
- const lines = (rest + chunk).split('\\n');
203
- rest = lines.pop();
204
- for (const line of lines) console.log(prefix + line);
205
- });
206
- }
207
- child.on('exit', (code) => {
208
- console.log(\`\${prefix}stopped\${code ? \` (exit \${code})\` : ''}\`);
209
- stop();
210
- });
211
- return child;
212
- });
213
-
214
- let stopping = false;
215
- function stop() {
216
- if (stopping) return;
217
- stopping = true;
218
- for (const child of running) {
219
- // On Windows the child is a shell; killing it alone would leave php and node running.
220
- if (shell && child.pid) spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
221
- else child.kill();
222
- }
223
- }
224
- process.on('SIGINT', stop);
225
- process.on('SIGTERM', stop);
226
- `);
227
-
228
- write(path.join(root, 'README.md'), `# ${title}
229
-
230
- A [Nevela](${DOCS}) app: a Laravel API in \`apps/api\` and a Next.js dashboard in \`apps/web\`.
231
-
232
- ## Run it
233
-
234
- \`\`\`sh
235
- ${pm} run dev
236
- \`\`\`
237
-
238
- That starts Laravel on http://127.0.0.1:8000 and the dashboard on http://localhost:3000. Sign in at http://localhost:3000/sign-in.
239
-
240
- To create someone who can sign in:
241
-
242
- \`\`\`sh
243
- cd apps/api
244
- php artisan nevela:user
245
- \`\`\`
246
-
247
- ## Add a resource
248
-
249
- \`\`\`sh
250
- cd apps/api
251
- php artisan nevela:resource Product --fields="name:string, sku:string!, price:money, notes:text?"
252
- php artisan migrate
253
- \`\`\`
254
-
255
- Reload the dashboard: Products is in the sidebar. Fill it with \`php artisan nevela:seed Product\`.
256
-
257
- ## Before real use
258
-
259
- Every policy in \`apps/api/app/Policies\` starts by allowing any signed-in user. Write your rules there.
260
-
261
- Docs: ${DOCS}
262
- `);
263
- }
264
-
265
- async function main() {
266
- const options = parseArgs(process.argv.slice(2));
267
-
268
- console.log(`\n ${indigo(bold('Nevela'))} ${dim(`v${VERSION}`)}\n`);
269
-
270
- let name = options.name;
271
- const interactive = !options.yes && process.stdin.isTTY;
272
- const prompt = interactive ? readline.createInterface({ input: process.stdin, output: process.stdout }) : null;
273
- if (!name) {
274
- if (!prompt) fail('Give the app a name: pnpm create nevela my-app');
275
- name = (await prompt.question(' What is the app called? ')).trim();
276
- }
277
- if (!/^[a-z0-9][a-z0-9-_]*$/.test(name)) {
278
- fail(`"${name}" can't be used as a name. Use lowercase letters, numbers and dashes, for example my-app.`);
279
- }
280
-
281
- const root = path.resolve(process.cwd(), name);
282
- if (fs.existsSync(root) && fs.readdirSync(root).length > 0) fail(`${root} already exists and isn't empty.`);
283
-
284
- checkRequirements();
285
- const pm = detectPackageManager(options.pm);
286
- const title = titleCase(name);
287
- const api = path.join(root, 'apps', 'api');
288
- const web = path.join(root, 'apps', 'web');
289
- fs.mkdirSync(path.join(root, 'apps'), { recursive: true });
290
-
291
- await step('Creating the Laravel app', () => {
292
- run('composer', ['create-project', 'laravel/laravel', 'apps/api', '--no-interaction', '--no-progress', '--prefer-dist'], { cwd: root });
293
- });
294
-
295
- const tokens = await step('Adding token sign-in (Sanctum)', () => {
296
- run('php', ['artisan', 'install:api', '--no-interaction'], { cwd: api });
297
- return addApiTokens(path.join(api, 'app', 'Models', 'User.php'));
298
- });
299
-
300
- await step('Installing Nevela', async () => {
301
- const wanted = VERSION.split('.').slice(0, 2).map(Number);
302
- if (!options.bundledPackage && (await onPackagist(wanted))) {
303
- run('composer', ['require', `nevela/laravel:^${wanted.join('.')}`, '--no-interaction', '--no-progress'], { cwd: api });
304
- } else {
305
- // No matching release there (or --bundled-package): the copy that travels with this
306
- // installer is placed in the app and installed by path.
307
- copyLaravelPackage(path.join(root, 'packages', 'nevela-laravel'));
308
- run('composer', ['config', 'repositories.nevela', 'path', '../../packages/nevela-laravel'], { cwd: api });
309
- run('composer', ['require', 'nevela/laravel:@dev', '--no-interaction', '--no-progress'], { cwd: api });
310
- }
311
- });
312
-
313
- await step('Adding the dashboard', () => {
314
- copyWebTemplate(web, { name, title });
315
- write(path.join(web, '.env.local'), '# Where the Laravel API is, including its prefix.\nNEVELA_API_URL=http://127.0.0.1:8000/api\n');
316
- if (pm === 'pnpm') {
317
- // Its own workspace file: lets sharp build, and keeps pnpm from adopting a workspace further up.
318
- write(path.join(web, 'pnpm-workspace.yaml'), 'allowBuilds:\n sharp: true\n');
319
- }
320
- writeRootFiles(root, { name, title, pm });
321
- // Writes routes/nevela.php and the dashboard's (empty) resource registry.
322
- run('php', ['artisan', 'nevela:generate'], { cwd: api });
323
- run('php', ['artisan', 'migrate', '--force', '--no-interaction'], { cwd: api });
324
- });
325
-
326
- if (options.install) {
327
- await step(`Installing the dashboard's dependencies with ${pm}`, () => {
328
- run(pm, ['install'], { cwd: web });
329
- });
330
- }
331
-
332
- if (options.git && available('git') && !run('git', ['rev-parse', '--is-inside-work-tree'], { cwd: root, allowFailure: true }).ok) {
333
- run('git', ['init', '--quiet'], { cwd: root, allowFailure: true });
334
- }
335
-
336
- let userCreated = false;
337
- if (options.user && prompt) {
338
- const answer = (await prompt.question(`\n Create a user to sign in with now? ${dim('(Y/n)')} `)).trim().toLowerCase();
339
- prompt.close();
340
- if (answer === '' || answer === 'y' || answer === 'yes') {
341
- console.log('');
342
- userCreated = run('php', ['artisan', 'nevela:user'], { cwd: api, interactive: true, allowFailure: true }).ok;
343
- }
344
- } else {
345
- prompt?.close();
346
- }
347
-
348
- console.log(`\n ${green('✔')} ${bold(`Created ${name}`)}\n`);
349
- if (!tokens) {
350
- console.log(` ${red('!')} I couldn't add Sanctum's trait to apps/api/app/Models/User.php. Add it by hand:`);
351
- console.log(` ${dim('use Laravel\\Sanctum\\HasApiTokens; and use HasApiTokens; inside the class')}\n`);
352
- }
353
- console.log(' Next:\n');
354
- console.log(` cd ${name}`);
355
- if (!options.install) console.log(` cd apps/web && ${pm} install && cd ../..`);
356
- if (!userCreated) console.log(` cd apps/api && php artisan nevela:user && cd ../.. ${dim('# someone to sign in as')}`);
357
- console.log(` ${pm} run dev\n`);
358
- console.log(` Then open ${indigo('http://localhost:3000/sign-in')}`);
359
- console.log(` Add your first resource: ${dim('cd apps/api && php artisan nevela:resource Product --fields="name:string, price:money"')}`);
360
- console.log(` Docs: ${DOCS}/start/quickstart/\n`);
361
- }
362
-
363
- main().catch((error) => fail(error?.message ?? String(error)));
1
+ #!/usr/bin/env node
2
+ // Create a new Nevela app: a Laravel API and a Next.js dashboard, side by side.
3
+ //
4
+ // pnpm create nevela my-app
5
+ // npm create nevela@latest my-app
6
+ //
7
+ // No dependencies on purpose: this runs before anything is installed.
8
+
9
+ import { spawn, spawnSync } from 'node:child_process';
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import readline from 'node:readline/promises';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { onPackagist } from './packagist.mjs';
16
+ import { copyLaravelPackage, copyWebTemplate } from './template.mjs';
17
+
18
+ const here = path.dirname(fileURLToPath(import.meta.url));
19
+ const VERSION = JSON.parse(fs.readFileSync(path.join(here, 'package.json'), 'utf8')).version;
20
+ const DOCS = 'https://nevela-docs.vercel.app';
21
+ const windows = process.platform === 'win32';
22
+
23
+ /**
24
+ * The account every new app starts with, so there is something to sign in as straight
25
+ * away. It is created in the app's local database, never in code or a migration, so it
26
+ * does not follow the app to a server.
27
+ */
28
+ const ADMIN = { name: 'Admin', email: 'admin@example.com', password: 'password' };
29
+
30
+ const colour = (code) => (text) => (process.stdout.isTTY ? `\x1b[${code}m${text}\x1b[0m` : text);
31
+ const bold = colour(1);
32
+ const dim = colour(2);
33
+ const green = colour(32);
34
+ const red = colour(31);
35
+ const indigo = colour('38;5;99');
36
+
37
+ const HELP = `
38
+ ${bold('create-nevela')} ${dim(`v${VERSION}`)}
39
+
40
+ Create a new Nevela app: a Laravel API and a Next.js dashboard.
41
+
42
+ ${bold('Usage')}
43
+ pnpm create nevela <name> [options]
44
+ npm create nevela@latest <name> -- [options]
45
+
46
+ ${bold('Options')}
47
+ --pm <pnpm|npm|yarn|bun> Package manager for the dashboard. Default: the one you ran this with.
48
+ --no-install Don't install the dashboard's dependencies.
49
+ --no-user Don't create the starter admin account.
50
+ --no-git Don't run git init.
51
+ --bundled-package Use the copy of nevela/laravel that ships with this installer
52
+ instead of the release on Packagist.
53
+ -y, --yes Ask nothing. (Only the app name is ever asked for.)
54
+ -h, --help Show this.
55
+ -v, --version Show the version.
56
+
57
+ ${bold('Needs')} PHP 8.3+, Composer, Node.js 20+
58
+ ${bold('Docs')} ${DOCS}
59
+ `;
60
+
61
+ function fail(message, detail) {
62
+ console.error(`\n ${red('✖')} ${message}`);
63
+ if (detail) console.error(`\n${detail.trim().split('\n').map((line) => ` ${line}`).join('\n')}`);
64
+ console.error('');
65
+ process.exit(1);
66
+ }
67
+
68
+ /** Run a command. Quiet unless it fails, when its output is the error message. */
69
+ function run(command, args, { cwd, interactive = false, allowFailure = false } = {}) {
70
+ const result = spawnCommand(command, args, { cwd, stdio: interactive ? 'inherit' : 'pipe', encoding: 'utf8' });
71
+ const output = `${result.stdout ?? ''}${result.stderr ?? ''}`;
72
+ if (result.error || result.status !== 0) {
73
+ if (allowFailure) return { ok: false, output };
74
+ fail(`\`${command} ${args.join(' ')}\` failed.`, output || String(result.error ?? ''));
75
+ }
76
+ return { ok: true, output };
77
+ }
78
+
79
+ /**
80
+ * .bat and .cmd shims (composer, pnpm, Herd's php) only resolve through a shell on
81
+ * Windows. A shell takes one command string, so arguments are quoted here; none of them
82
+ * come from anywhere but this file and the validated app name.
83
+ */
84
+ function spawnCommand(command, args, options) {
85
+ if (!windows) return spawnSync(command, args, options);
86
+ return spawnSync(shellLine(command, args), { ...options, shell: true });
87
+ }
88
+
89
+ // `^` is not in the safe list on purpose: unquoted, cmd.exe treats it as an escape
90
+ // character and drops it, which turned the constraint "^0.1" into an exact "0.1".
91
+ const quote = (arg) => (/^[\w./:=@,-]+$/.test(arg) ? arg : `"${arg.replace(/"/g, '\\"')}"`);
92
+ const shellLine = (command, args) => [command, ...args.map(quote)].join(' ');
93
+
94
+ /**
95
+ * Start a command and carry on. Its output goes to a file rather than a pipe: the steps
96
+ * that follow block this process for minutes, and a pipe nobody is reading fills up and
97
+ * stalls the command.
98
+ */
99
+ function start(command, args, { cwd }) {
100
+ const log = path.join(os.tmpdir(), `create-nevela-${process.pid}-${command}.log`);
101
+ const out = fs.openSync(log, 'w');
102
+ const started = Date.now();
103
+ const child = windows
104
+ ? spawn(shellLine(command, args), { cwd, shell: true, stdio: ['ignore', out, out] })
105
+ : spawn(command, args, { cwd, stdio: ['ignore', out, out] });
106
+ const done = new Promise((resolve) => {
107
+ const finish = (ok) => {
108
+ fs.closeSync(out);
109
+ const output = fs.readFileSync(log, 'utf8');
110
+ // This process hears about the exit only once the blocking steps let it. The log's
111
+ // last write is when the command really finished.
112
+ const ended = Math.max(started, Math.min(Date.now(), fs.statSync(log).mtimeMs));
113
+ fs.rmSync(log, { force: true });
114
+ resolve({ ok, output, seconds: (ended - started) / 1000 });
115
+ };
116
+ child.on('error', () => finish(false));
117
+ child.on('close', (code) => finish(code === 0));
118
+ });
119
+ return { done };
120
+ }
121
+
122
+ function available(command, args = ['--version']) {
123
+ const result = spawnCommand(command, args, { stdio: 'pipe', encoding: 'utf8' });
124
+ return result.status === 0 ? `${result.stdout}${result.stderr}` : null;
125
+ }
126
+
127
+ const duration = (seconds) => (seconds >= 60 ? `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s` : `${seconds.toFixed(1)}s`);
128
+
129
+ async function step(label, work) {
130
+ const started = Date.now();
131
+ // The "in progress" line is rewritten in place when the step ends. That only works if
132
+ // it fits on one line; in a narrow terminal it would wrap and be left behind.
133
+ const inPlace = process.stdout.isTTY && label.length + 16 < (process.stdout.columns ?? 80);
134
+ if (inPlace) process.stdout.write(` ${dim('◇')} ${label}…`);
135
+ const result = await work();
136
+ const seconds = result?.seconds ?? (Date.now() - started) / 1000;
137
+ const took = seconds >= 1 ? dim(` (${duration(seconds)})`) : '';
138
+ process.stdout.write(`${inPlace ? '\r' : ''} ${green('✔')} ${label}${took} \n`);
139
+ return result;
140
+ }
141
+
142
+ function parseArgs(argv) {
143
+ const options = { name: undefined, pm: undefined, install: true, user: true, git: true, yes: false, bundledPackage: false };
144
+ for (let i = 0; i < argv.length; i++) {
145
+ const arg = argv[i];
146
+ if (arg === '-h' || arg === '--help') {
147
+ console.log(HELP);
148
+ process.exit(0);
149
+ } else if (arg === '-v' || arg === '--version') {
150
+ console.log(VERSION);
151
+ process.exit(0);
152
+ } else if (arg === '-y' || arg === '--yes') options.yes = true;
153
+ else if (arg === '--no-install') options.install = false;
154
+ else if (arg === '--no-user') options.user = false;
155
+ else if (arg === '--no-git') options.git = false;
156
+ else if (arg === '--bundled-package') options.bundledPackage = true;
157
+ else if (arg === '--pm') options.pm = argv[++i];
158
+ else if (arg.startsWith('--pm=')) options.pm = arg.slice(5);
159
+ else if (arg.startsWith('-')) fail(`Unknown option ${arg}. Run with --help to see the options.`);
160
+ else if (options.name === undefined) options.name = arg;
161
+ else fail(`Unexpected argument "${arg}". The app name is "${options.name}".`);
162
+ }
163
+ return options;
164
+ }
165
+
166
+ /** "my-shop" → "My Shop", for the dashboard's title. */
167
+ const titleCase = (slug) => slug.split(/[-_]+/).filter(Boolean).map((word) => word[0].toUpperCase() + word.slice(1)).join(' ');
168
+
169
+ function detectPackageManager(requested) {
170
+ const known = ['pnpm', 'npm', 'yarn', 'bun'];
171
+ if (requested) {
172
+ if (!known.includes(requested)) fail(`--pm must be one of: ${known.join(', ')}.`);
173
+ if (!available(requested)) fail(`${requested} isn't installed, or isn't on your PATH.`);
174
+ return requested;
175
+ }
176
+ // "pnpm/10.1.0 npm/? node/v22…" when run through `pnpm create`.
177
+ const agent = (process.env.npm_config_user_agent ?? '').split('/')[0];
178
+ if (known.includes(agent)) return agent;
179
+ return available('pnpm') ? 'pnpm' : 'npm';
180
+ }
181
+
182
+ function checkRequirements() {
183
+ const node = process.versions.node.split('.').map(Number);
184
+ if (node[0] < 20) fail(`Nevela needs Node.js 20 or newer. You have ${process.versions.node}.`);
185
+
186
+ const php = available('php', ['-v']);
187
+ if (!php) fail('PHP isn\'t installed, or isn\'t on your PATH. Nevela needs PHP 8.3 or newer: https://www.php.net/downloads');
188
+ const version = /PHP (\d+)\.(\d+)/.exec(php);
189
+ if (!version || Number(version[1]) < 8 || (Number(version[1]) === 8 && Number(version[2]) < 3)) {
190
+ fail(`Nevela needs PHP 8.3 or newer. You have ${version ? `${version[1]}.${version[2]}` : 'a version I can\'t read'}.`);
191
+ }
192
+
193
+ if (!available('composer')) fail('Composer isn\'t installed, or isn\'t on your PATH: https://getcomposer.org/download/');
194
+ }
195
+
196
+ /** Sanctum's trait on the User model. `install:api` asks for this by hand; do it for them. */
197
+ function addApiTokens(userModel) {
198
+ let source = fs.readFileSync(userModel, 'utf8');
199
+ if (source.includes('HasApiTokens')) return true;
200
+ const withImport = source.replace(/^(use Illuminate\\Notifications\\Notifiable;)$/m, '$1\nuse Laravel\\Sanctum\\HasApiTokens;');
201
+ const withTrait = withImport.replace(/^(\s+use )(HasFactory, Notifiable;)$/m, '$1HasApiTokens, $2');
202
+ if (withImport === source || withTrait === withImport) return false;
203
+ fs.writeFileSync(userModel, withTrait);
204
+ return true;
205
+ }
206
+
207
+ function write(file, contents) {
208
+ fs.mkdirSync(path.dirname(file), { recursive: true });
209
+ fs.writeFileSync(file, contents);
210
+ }
211
+
212
+ function writeRootFiles(root, { name, title, pm, admin }) {
213
+ write(path.join(root, 'package.json'), `${JSON.stringify({
214
+ name,
215
+ version: '0.1.0',
216
+ private: true,
217
+ type: 'module',
218
+ scripts: {
219
+ dev: 'node scripts/dev.mjs',
220
+ 'dev:api': 'cd apps/api && php artisan serve',
221
+ 'dev:web': `cd apps/web && ${pm} run dev`,
222
+ },
223
+ }, null, 2)}\n`);
224
+
225
+ write(path.join(root, '.gitignore'), 'node_modules/\nvendor/\n.env\n.env.*\n!.env.example\n.next/\n*.tsbuildinfo\nnext-env.d.ts\n');
226
+
227
+ write(path.join(root, 'scripts', 'dev.mjs'), `// Run the Laravel API and the Next.js dashboard together: \`${pm} run dev\`.
228
+ import { spawn } from 'node:child_process';
229
+
230
+ const shell = process.platform === 'win32';
231
+ const apps = [
232
+ { name: 'api', colour: 35, command: 'php', args: ['artisan', 'serve'], cwd: 'apps/api' },
233
+ { name: 'web', colour: 36, command: '${pm}', args: ['run', 'dev'], cwd: 'apps/web' },
234
+ ];
235
+
236
+ const running = apps.map(({ name, colour, command, args, cwd }) => {
237
+ // One string when going through a shell: Node warns about (and doesn't escape) separate args there.
238
+ const child = shell
239
+ ? spawn([command, ...args].join(' '), { cwd, shell, stdio: ['ignore', 'pipe', 'pipe'] })
240
+ : spawn(command, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
241
+ const prefix = \`\\x1b[\${colour}m\${name}\\x1b[0m │ \`;
242
+ for (const stream of [child.stdout, child.stderr]) {
243
+ let rest = '';
244
+ stream.on('data', (chunk) => {
245
+ const lines = (rest + chunk).split('\\n');
246
+ rest = lines.pop();
247
+ for (const line of lines) console.log(prefix + line);
248
+ });
249
+ }
250
+ child.on('exit', (code) => {
251
+ console.log(\`\${prefix}stopped\${code ? \` (exit \${code})\` : ''}\`);
252
+ stop();
253
+ });
254
+ return child;
255
+ });
256
+
257
+ let stopping = false;
258
+ function stop() {
259
+ if (stopping) return;
260
+ stopping = true;
261
+ for (const child of running) {
262
+ // On Windows the child is a shell; killing it alone would leave php and node running.
263
+ if (shell && child.pid) spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
264
+ else child.kill();
265
+ }
266
+ }
267
+ process.on('SIGINT', stop);
268
+ process.on('SIGTERM', stop);
269
+ `);
270
+
271
+ write(path.join(root, 'README.md'), `# ${title}
272
+
273
+ A [Nevela](${DOCS}) app: a Laravel API in \`apps/api\` and a Next.js dashboard in \`apps/web\`.
274
+
275
+ ## Run it
276
+
277
+ \`\`\`sh
278
+ ${pm} run dev
279
+ \`\`\`
280
+
281
+ That starts Laravel on http://127.0.0.1:8000 and the dashboard on http://localhost:3000. Sign in at http://localhost:3000/sign-in.
282
+ ${admin ? `
283
+ The app starts with one account, in your local database only:
284
+
285
+ | | |
286
+ |---|---|
287
+ | Email | \`${admin.email}\` |
288
+ | Password | \`${admin.password}\` |
289
+ ` : ''}
290
+ To add someone who can sign in:
291
+
292
+ \`\`\`sh
293
+ cd apps/api
294
+ php artisan nevela:user
295
+ \`\`\`
296
+
297
+ ## Add a resource
298
+
299
+ \`\`\`sh
300
+ cd apps/api
301
+ php artisan nevela:resource Product --fields="name:string, sku:string!, price:money, notes:text?"
302
+ php artisan migrate
303
+ \`\`\`
304
+
305
+ Reload the dashboard: Products is in the sidebar. Fill it with \`php artisan nevela:seed Product\`.
306
+
307
+ ## Before real use
308
+
309
+ Every policy in \`apps/api/app/Policies\` starts by allowing any signed-in user. Write your rules there.
310
+
311
+ Docs: ${DOCS}
312
+ `);
313
+ }
314
+
315
+ async function main() {
316
+ const options = parseArgs(process.argv.slice(2));
317
+
318
+ console.log(`\n ${indigo(bold('Nevela'))} ${dim(`v${VERSION}`)}\n`);
319
+
320
+ let name = options.name;
321
+ if (!name) {
322
+ // The only question this ever asks, and only when the name was left off.
323
+ if (options.yes || !process.stdin.isTTY) fail('Give the app a name: pnpm create nevela my-app');
324
+ const prompt = readline.createInterface({ input: process.stdin, output: process.stdout });
325
+ name = (await prompt.question(' What is the app called? ')).trim();
326
+ prompt.close();
327
+ }
328
+ if (!/^[a-z0-9][a-z0-9-_]*$/.test(name)) {
329
+ fail(`"${name}" can't be used as a name. Use lowercase letters, numbers and dashes, for example my-app.`);
330
+ }
331
+
332
+ const root = path.resolve(process.cwd(), name);
333
+ if (fs.existsSync(root) && fs.readdirSync(root).length > 0) fail(`${root} already exists and isn't empty.`);
334
+
335
+ checkRequirements();
336
+ const pm = detectPackageManager(options.pm);
337
+ const title = titleCase(name);
338
+ const api = path.join(root, 'apps', 'api');
339
+ const web = path.join(root, 'apps', 'web');
340
+ const began = Date.now();
341
+ fs.mkdirSync(path.join(root, 'apps'), { recursive: true });
342
+
343
+ // The dashboard first, so its packages can download while Composer works on Laravel.
344
+ // The two don't touch each other, and Composer is the long part.
345
+ copyWebTemplate(web, { name, title });
346
+ write(path.join(web, '.env.local'), '# Where the Laravel API is, including its prefix.\nNEVELA_API_URL=http://127.0.0.1:8000/api\n');
347
+ if (pm === 'pnpm') {
348
+ // Its own workspace file: lets sharp build, and keeps pnpm from adopting a workspace further up.
349
+ write(path.join(web, 'pnpm-workspace.yaml'), 'allowBuilds:\n sharp: true\n');
350
+ }
351
+ writeRootFiles(root, { name, title, pm, admin: options.user ? ADMIN : null });
352
+ const packages = options.install ? start(pm, ['install'], { cwd: web }) : null;
353
+
354
+ await step('Creating the Laravel app', () => {
355
+ run('composer', ['create-project', 'laravel/laravel', 'apps/api', '--no-interaction', '--no-progress', '--prefer-dist'], { cwd: root });
356
+ });
357
+
358
+ const tokens = await step('Installing Nevela and token sign-in', async () => {
359
+ const wanted = VERSION.split('.').slice(0, 2).map(Number);
360
+ let nevela = `nevela/laravel:^${wanted.join('.')}`;
361
+ if (options.bundledPackage || !(await onPackagist(wanted))) {
362
+ // No matching release on Packagist (or --bundled-package): the copy that travels with
363
+ // this installer is placed in the app and installed by path.
364
+ copyLaravelPackage(path.join(root, 'packages', 'nevela-laravel'));
365
+ run('composer', ['config', 'repositories.nevela', 'path', '../../packages/nevela-laravel'], { cwd: api });
366
+ nevela = 'nevela/laravel:@dev';
367
+ }
368
+ // One Composer run for both. `php artisan install:api` would add Sanctum in a run of
369
+ // its own, plus a routes/api.php this app doesn't use: Nevela registers its routes itself.
370
+ run('composer', ['require', 'laravel/sanctum', nevela, '--no-interaction', '--no-progress'], { cwd: api });
371
+ run('php', ['artisan', 'vendor:publish', '--tag=sanctum-migrations', '--no-interaction'], { cwd: api });
372
+ return addApiTokens(path.join(api, 'app', 'Models', 'User.php'));
373
+ });
374
+
375
+ let admin = false;
376
+ await step('Setting up the database', () => {
377
+ // Writes routes/nevela.php and the dashboard's (empty) resource registry.
378
+ run('php', ['artisan', 'nevela:generate'], { cwd: api });
379
+ run('php', ['artisan', 'migrate', '--force', '--no-interaction'], { cwd: api });
380
+ if (options.user && tokens) {
381
+ admin = run('php', ['artisan', 'nevela:user', `--name=${ADMIN.name}`, `--email=${ADMIN.email}`, `--password=${ADMIN.password}`], { cwd: api, allowFailure: true }).ok;
382
+ }
383
+ });
384
+
385
+ let installed = false;
386
+ if (packages) {
387
+ // Usually finished long before Composer; the time shown is how long it took, not how long we waited here.
388
+ installed = (await step(`Installing dashboard packages (${pm})`, () => packages.done)).ok;
389
+ }
390
+
391
+ if (options.git && available('git') && !run('git', ['rev-parse', '--is-inside-work-tree'], { cwd: root, allowFailure: true }).ok) {
392
+ run('git', ['init', '--quiet'], { cwd: root, allowFailure: true });
393
+ }
394
+
395
+ console.log(`\n ${green('✔')} ${bold(`Created ${name}`)} ${dim(`in ${duration((Date.now() - began) / 1000)}`)}\n`);
396
+ if (!tokens) {
397
+ console.log(` ${red('!')} I couldn't add Sanctum's trait to apps/api/app/Models/User.php. Add it by hand:`);
398
+ console.log(` ${dim('use Laravel\\Sanctum\\HasApiTokens; and use HasApiTokens; inside the class')}\n`);
399
+ }
400
+ if (packages && !installed) {
401
+ console.log(` ${red('!')} The dashboard's packages didn't install. Run it yourself to see why: cd ${name}/apps/web && ${pm} install\n`);
402
+ }
403
+ console.log(' Next:\n');
404
+ console.log(` cd ${name}`);
405
+ if (!options.install) console.log(` cd apps/web && ${pm} install && cd ../..`);
406
+ if (!admin) console.log(` cd apps/api && php artisan nevela:user && cd ../.. ${dim('# someone to sign in as')}`);
407
+ console.log(` ${pm} run dev\n`);
408
+ console.log(` Then open ${indigo('http://localhost:3000/sign-in')}${admin ? ' and sign in with:' : ''}`);
409
+ if (admin) {
410
+ console.log(`\n Email ${bold(ADMIN.email)}`);
411
+ console.log(` Password ${bold(ADMIN.password)}\n`);
412
+ console.log(` ${dim('That account is in this app\'s local database only. Add your own: cd apps/api && php artisan nevela:user')}`);
413
+ }
414
+ console.log(`\n Add your first resource: ${dim('cd apps/api && php artisan nevela:resource Product --fields="name:string, price:money"')}`);
415
+ console.log(` Docs: ${DOCS}/start/quickstart/\n`);
416
+ }
417
+
418
+ main().catch((error) => fail(error?.message ?? String(error)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nevela",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Create a new Nevela app: a Laravel API and a Next.js dashboard from one resource description.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,7 @@ use Nevela\Laravel\Support\Naming;
17
17
  final class Nevela
18
18
  {
19
19
  /** Written by `pnpm release`; the same number as the repository's git tag. */
20
- public const VERSION = '0.1.0';
20
+ public const VERSION = '0.1.1';
21
21
 
22
22
  /** @var array<string, Descriptor> */
23
23
  private static array $descriptors = [];
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nevela-web",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {