create-nevela 0.1.1 → 0.1.2

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/index.mjs CHANGED
@@ -1,418 +1,442 @@
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)));
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 { isNewer, 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
+ /** The newest create-nevela on npm, when it is newer than this one. */
170
+ async function newerVersion() {
171
+ try {
172
+ const response = await fetch('https://registry.npmjs.org/create-nevela/latest', { signal: AbortSignal.timeout(4000) });
173
+ const latest = (await response.json()).version;
174
+ return isNewer(latest, VERSION) ? latest : null;
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+
180
+ function detectPackageManager(requested) {
181
+ const known = ['pnpm', 'npm', 'yarn', 'bun'];
182
+ if (requested) {
183
+ if (!known.includes(requested)) fail(`--pm must be one of: ${known.join(', ')}.`);
184
+ if (!available(requested)) fail(`${requested} isn't installed, or isn't on your PATH.`);
185
+ return requested;
186
+ }
187
+ // "pnpm/10.1.0 npm/? node/v22…" when run through `pnpm create`.
188
+ const agent = (process.env.npm_config_user_agent ?? '').split('/')[0];
189
+ if (known.includes(agent)) return agent;
190
+ return available('pnpm') ? 'pnpm' : 'npm';
191
+ }
192
+
193
+ function checkRequirements() {
194
+ const node = process.versions.node.split('.').map(Number);
195
+ if (node[0] < 20) fail(`Nevela needs Node.js 20 or newer. You have ${process.versions.node}.`);
196
+
197
+ const php = available('php', ['-v']);
198
+ 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');
199
+ const version = /PHP (\d+)\.(\d+)/.exec(php);
200
+ if (!version || Number(version[1]) < 8 || (Number(version[1]) === 8 && Number(version[2]) < 3)) {
201
+ fail(`Nevela needs PHP 8.3 or newer. You have ${version ? `${version[1]}.${version[2]}` : 'a version I can\'t read'}.`);
202
+ }
203
+
204
+ if (!available('composer')) fail('Composer isn\'t installed, or isn\'t on your PATH: https://getcomposer.org/download/');
205
+ }
206
+
207
+ /** Sanctum's trait on the User model. `install:api` asks for this by hand; do it for them. */
208
+ function addApiTokens(userModel) {
209
+ let source = fs.readFileSync(userModel, 'utf8');
210
+ if (source.includes('HasApiTokens')) return true;
211
+ const withImport = source.replace(/^(use Illuminate\\Notifications\\Notifiable;)$/m, '$1\nuse Laravel\\Sanctum\\HasApiTokens;');
212
+ const withTrait = withImport.replace(/^(\s+use )(HasFactory, Notifiable;)$/m, '$1HasApiTokens, $2');
213
+ if (withImport === source || withTrait === withImport) return false;
214
+ fs.writeFileSync(userModel, withTrait);
215
+ return true;
216
+ }
217
+
218
+ function write(file, contents) {
219
+ fs.mkdirSync(path.dirname(file), { recursive: true });
220
+ fs.writeFileSync(file, contents);
221
+ }
222
+
223
+ function writeRootFiles(root, { name, title, pm, admin }) {
224
+ write(path.join(root, 'package.json'), `${JSON.stringify({
225
+ name,
226
+ version: '0.1.0',
227
+ private: true,
228
+ type: 'module',
229
+ scripts: {
230
+ dev: 'node scripts/dev.mjs',
231
+ nevela: 'php nevela',
232
+ 'dev:api': 'cd apps/api && php artisan serve',
233
+ 'dev:web': `cd apps/web && ${pm} run dev`,
234
+ },
235
+ }, null, 2)}\n`);
236
+
237
+ write(path.join(root, '.gitignore'), 'node_modules/\nvendor/\n.env\n.env.*\n!.env.example\n.next/\n*.tsbuildinfo\nnext-env.d.ts\n');
238
+
239
+ write(path.join(root, 'scripts', 'dev.mjs'), `// Run the Laravel API and the Next.js dashboard together: \`${pm} run dev\`.
240
+ import { spawn } from 'node:child_process';
241
+
242
+ const shell = process.platform === 'win32';
243
+ const apps = [
244
+ { name: 'api', colour: 35, command: 'php', args: ['artisan', 'serve'], cwd: 'apps/api' },
245
+ { name: 'web', colour: 36, command: '${pm}', args: ['run', 'dev'], cwd: 'apps/web' },
246
+ ];
247
+
248
+ const running = apps.map(({ name, colour, command, args, cwd }) => {
249
+ // One string when going through a shell: Node warns about (and doesn't escape) separate args there.
250
+ const child = shell
251
+ ? spawn([command, ...args].join(' '), { cwd, shell, stdio: ['ignore', 'pipe', 'pipe'] })
252
+ : spawn(command, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
253
+ const prefix = \`\\x1b[\${colour}m\${name}\\x1b[0m │ \`;
254
+ for (const stream of [child.stdout, child.stderr]) {
255
+ let rest = '';
256
+ stream.on('data', (chunk) => {
257
+ const lines = (rest + chunk).split('\\n');
258
+ rest = lines.pop();
259
+ for (const line of lines) console.log(prefix + line);
260
+ });
261
+ }
262
+ child.on('exit', (code) => {
263
+ console.log(\`\${prefix}stopped\${code ? \` (exit \${code})\` : ''}\`);
264
+ stop();
265
+ });
266
+ return child;
267
+ });
268
+
269
+ let stopping = false;
270
+ function stop() {
271
+ if (stopping) return;
272
+ stopping = true;
273
+ for (const child of running) {
274
+ // On Windows the child is a shell; killing it alone would leave php and node running.
275
+ if (shell && child.pid) spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
276
+ else child.kill();
277
+ }
278
+ }
279
+ process.on('SIGINT', stop);
280
+ process.on('SIGTERM', stop);
281
+ `);
282
+
283
+ write(path.join(root, 'README.md'), `# ${title}
284
+
285
+ A [Nevela](${DOCS}) app: a Laravel API in \`apps/api\` and a Next.js dashboard in \`apps/web\`.
286
+
287
+ ## Run it
288
+
289
+ \`\`\`sh
290
+ ${pm} run dev
291
+ \`\`\`
292
+
293
+ 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.
294
+ ${admin ? `
295
+ The app starts with one account, in your local database only:
296
+
297
+ | | |
298
+ |---|---|
299
+ | Email | \`${admin.email}\` |
300
+ | Password | \`${admin.password}\` |
301
+ ` : ''}
302
+ Everything else runs from this folder too, with \`php nevela\`:
303
+
304
+ \`\`\`sh
305
+ php nevela user # add someone who can sign in
306
+ php nevela update # update Nevela and the dashboard
307
+ php nevela # everything it can do
308
+ \`\`\`
309
+
310
+ ## Add a resource
311
+
312
+ \`\`\`sh
313
+ php nevela resource Product --fields="name:string, sku:string!, price:money, notes:text?"
314
+ \`\`\`
315
+
316
+ That creates the table as well. Reload the dashboard: Products is in the sidebar. Fill it with \`php nevela seed Product\`.
317
+
318
+ ## Before real use
319
+
320
+ Every policy in \`apps/api/app/Policies\` starts by allowing any signed-in user. Write your rules there.
321
+
322
+ Docs: ${DOCS}
323
+ `);
324
+ }
325
+
326
+ async function main() {
327
+ const options = parseArgs(process.argv.slice(2));
328
+
329
+ console.log(`\n ${indigo(bold('Nevela'))} ${dim(`v${VERSION}`)}\n`);
330
+
331
+ // A package manager may serve an older installer than the newest one: pnpm holds back
332
+ // versions published in the last day, and both pnpm and npm cache. Say so, with the fix.
333
+ const newer = await newerVersion();
334
+ const outdated = newer
335
+ ? ` ${red('!')} create-nevela ${bold(newer)} is out, and this is ${VERSION}. To use it: ${bold(`pnpm create nevela@${newer} ${options.name ?? 'my-app'}`)}\n`
336
+ : null;
337
+ if (outdated) console.log(outdated);
338
+
339
+ let name = options.name;
340
+ if (!name) {
341
+ // The only question this ever asks, and only when the name was left off.
342
+ if (options.yes || !process.stdin.isTTY) fail('Give the app a name: pnpm create nevela my-app');
343
+ const prompt = readline.createInterface({ input: process.stdin, output: process.stdout });
344
+ name = (await prompt.question(' What is the app called? ')).trim();
345
+ prompt.close();
346
+ }
347
+ if (!/^[a-z0-9][a-z0-9-_]*$/.test(name)) {
348
+ fail(`"${name}" can't be used as a name. Use lowercase letters, numbers and dashes, for example my-app.`);
349
+ }
350
+
351
+ const root = path.resolve(process.cwd(), name);
352
+ if (fs.existsSync(root) && fs.readdirSync(root).length > 0) fail(`${root} already exists and isn't empty.`);
353
+
354
+ checkRequirements();
355
+ const pm = detectPackageManager(options.pm);
356
+ const title = titleCase(name);
357
+ const api = path.join(root, 'apps', 'api');
358
+ const web = path.join(root, 'apps', 'web');
359
+ const began = Date.now();
360
+ fs.mkdirSync(path.join(root, 'apps'), { recursive: true });
361
+
362
+ // The dashboard first, so its packages can download while Composer works on Laravel.
363
+ // The two don't touch each other, and Composer is the long part.
364
+ copyWebTemplate(web, { name, title });
365
+ 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');
366
+ if (pm === 'pnpm') {
367
+ // Its own workspace file: lets sharp build, and keeps pnpm from adopting a workspace further up.
368
+ write(path.join(web, 'pnpm-workspace.yaml'), 'allowBuilds:\n sharp: true\n');
369
+ }
370
+ // Which dashboard this app started from: `php artisan nevela:update` compares against it
371
+ // to tell the files you changed from the ones you didn't.
372
+ write(path.join(web, '.nevela.json'), `${JSON.stringify({ template: VERSION }, null, 2)}\n`);
373
+ writeRootFiles(root, { name, title, pm, admin: options.user ? ADMIN : null });
374
+ const packages = options.install ? start(pm, ['install'], { cwd: web }) : null;
375
+
376
+ await step('Creating the Laravel app', () => {
377
+ run('composer', ['create-project', 'laravel/laravel', 'apps/api', '--no-interaction', '--no-progress', '--prefer-dist'], { cwd: root });
378
+ });
379
+
380
+ const tokens = await step('Installing Nevela and token sign-in', async () => {
381
+ const wanted = VERSION.split('.').slice(0, 2).map(Number);
382
+ let nevela = `nevela/laravel:^${wanted.join('.')}`;
383
+ if (options.bundledPackage || !(await onPackagist(wanted))) {
384
+ // No matching release on Packagist (or --bundled-package): the copy that travels with
385
+ // this installer is placed in the app and installed by path.
386
+ copyLaravelPackage(path.join(root, 'packages', 'nevela-laravel'));
387
+ run('composer', ['config', 'repositories.nevela', 'path', '../../packages/nevela-laravel'], { cwd: api });
388
+ nevela = 'nevela/laravel:@dev';
389
+ }
390
+ // One Composer run for both. `php artisan install:api` would add Sanctum in a run of
391
+ // its own, plus a routes/api.php this app doesn't use: Nevela registers its routes itself.
392
+ run('composer', ['require', 'laravel/sanctum', nevela, '--no-interaction', '--no-progress'], { cwd: api });
393
+ run('php', ['artisan', 'vendor:publish', '--tag=sanctum-migrations', '--no-interaction'], { cwd: api });
394
+ return addApiTokens(path.join(api, 'app', 'Models', 'User.php'));
395
+ });
396
+
397
+ let admin = false;
398
+ await step('Setting up the database', () => {
399
+ // Writes routes/nevela.php and the dashboard's (empty) resource registry.
400
+ run('php', ['artisan', 'nevela:generate'], { cwd: api });
401
+ run('php', ['artisan', 'migrate', '--force', '--no-interaction'], { cwd: api });
402
+ if (options.user && tokens) {
403
+ admin = run('php', ['artisan', 'nevela:user', `--name=${ADMIN.name}`, `--email=${ADMIN.email}`, `--password=${ADMIN.password}`], { cwd: api, allowFailure: true }).ok;
404
+ }
405
+ });
406
+
407
+ let installed = false;
408
+ if (packages) {
409
+ // Usually finished long before Composer; the time shown is how long it took, not how long we waited here.
410
+ installed = (await step(`Installing dashboard packages (${pm})`, () => packages.done)).ok;
411
+ }
412
+
413
+ if (options.git && available('git') && !run('git', ['rev-parse', '--is-inside-work-tree'], { cwd: root, allowFailure: true }).ok) {
414
+ run('git', ['init', '--quiet'], { cwd: root, allowFailure: true });
415
+ }
416
+
417
+ console.log(`\n ${green('✔')} ${bold(`Created ${name}`)} ${dim(`in ${duration((Date.now() - began) / 1000)}`)}\n`);
418
+ if (!tokens) {
419
+ console.log(` ${red('!')} I couldn't add Sanctum's trait to apps/api/app/Models/User.php. Add it by hand:`);
420
+ console.log(` ${dim('use Laravel\\Sanctum\\HasApiTokens; and use HasApiTokens; inside the class')}\n`);
421
+ }
422
+ if (packages && !installed) {
423
+ console.log(` ${red('!')} The dashboard's packages didn't install. Run it yourself to see why: cd ${name}/apps/web && ${pm} install\n`);
424
+ }
425
+ console.log(' Next:\n');
426
+ console.log(` cd ${name}`);
427
+ if (!options.install) console.log(` cd apps/web && ${pm} install && cd ../..`);
428
+ if (!admin) console.log(` php nevela user ${dim('# someone to sign in as')}`);
429
+ console.log(` ${pm} run dev\n`);
430
+ console.log(` Then open ${indigo('http://localhost:3000/sign-in')}${admin ? ' and sign in with:' : ''}`);
431
+ if (admin) {
432
+ console.log(`\n Email ${bold(ADMIN.email)}`);
433
+ console.log(` Password ${bold(ADMIN.password)}\n`);
434
+ console.log(` ${dim('That account is in this app\'s local database only. Add your own: php nevela user')}`);
435
+ }
436
+ console.log(`\n Add your first resource: ${dim('php nevela resource Product --fields="name:string, price:money"')}`);
437
+ console.log(` Everything runs from this folder: ${dim('php nevela')}`);
438
+ console.log(` Docs: ${DOCS}/start/quickstart/\n`);
439
+ if (outdated) console.log(outdated);
440
+ }
441
+
442
+ main().catch((error) => fail(error?.message ?? String(error)));